Fork - 寫時複製 COW 原理

本篇文章的內核的代碼是 Linux 0.11

上一篇文章我們看了計算機系統中的異常和中斷是怎麼做的,這篇文章我們來看看 fork 是如何利用異常實現進程的創建,以及 COW 實現原理

使用

fork 函數是一個系統調用函數,它是用來創建一個新的進程,如下:

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
   
int main(int argc, char *argv[]) { 
    printf("hello world (pid:%d)\n"(int) getpid()); 
    int rc = fork(); 
    if (rc < 0) { 
        // fork failed 
        fprintf(stderr, "fork failed\n"); 
        exit(1); 
    } else if (rc == 0) { 
        // child (new process) 
        printf("hello, I am child (pid:%d)\n"(int) getpid()); 
    } else { 
        // parent goes down this path (main) 
        printf("hello, I am parent of %d (pid:%d)\n",   rc, (int) getpid()); 
        wait(NULL);
    }
    return 0;
}

我們執行一下:

hello world (pid:5093)
hello, I am parent of 5094 (pid:5093)
hello, I am child (pid:5094)

上面這個例子中分別輸出了三次進程號,在調用 fork 函數之後,如果是父進程會返回子進程號,如果是子進程,那麼會返回 0 。所以上面可以通過 fork 返回的進程號來判斷父子進程。並且被 fork 出來的子進程不會從 main 函數開始執行,而是接着 fork 函數往下執行,彷佛像自己調用了 fork 函數一樣。

父進程之所以要調用 wait 函數是爲了等一下子進程,同步一下子進程的狀態,否則可能會產生孤兒進程或殭屍進程,這個我們後面再說。

還有一點需要注意的是,父子進程的調度是隨機的,並沒有規定父進程調度一定會先於子進程被調度。

fork 函數實現過程

進程都是由其他進程創建出來的,每個進程都有自己的 PID(進程標識號),在 Linux 系統的進程之間存在一個繼承關係,所有的進程都是 init 進程(1 號進程)的後代。可以通過 pstree 命令查看到進程的族譜。在 Linux 中所有的進程都通過 task_struct 描述,裏面通過 parent 和 children 字段維護一個父子進程樹的鏈表結構。

而 fork 函數是創建子進程的一種方法,它是一個系統調用函數,所以在看 fork 系統調用之前我們先來看看 system_call。

系統調用處理函數 system_call 與 int 0x80 中斷描述符表掛接。 system_call 是整個操作系統中系統調用軟中斷的總入口。所有用戶程序使用系統調用,產生 int 0x80 軟中斷後,操作系統都是通過這個總入口找到具體的系統調用函數。

系統調用函數是操作系統對用戶程序的基本支持。在操作系統中,像類似讀盤、創建子進程之類的事物需要通過系統調用實現。系統調用被調用後會觸發 int 0x80 軟中斷,然後由用戶態切換到內核態(從用戶進程的 3 特權級翻轉到內核的 0 特權級),通過 IDT 找到系統調用端口,調用具體的系統調用函數來處理事物,處理完畢之後再由 iret 指令回到用戶態繼續執行原來的邏輯。

fork 函數由於也是系統調用的函數之一,所以也是通過 int 0x80 軟中斷來進行觸發的。在觸發 int 0x80 軟中斷後會切換到內核態,找到 sys_call_table 中根據 fork 的 index(也就是 2) 找到對應的函數:

fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close... };

然後拿到對應的 C 函數 sys_fork。因爲會變中對應 C 的函數名在前面多加一個下劃線,所以會跳轉到 _sys_fork 處執行。

在 _sys_fork 中首先會調用 find_empty_process 申請一個空閒位置並獲取一個新的進程號 pid。空閒位置由 task[64] 這個數組決定,也就是說最多隻能同時 64 個進程同時在跑,並用全局變量 last_pid 來存放系統自開機以來累計的進程數,如果有空閒位置,那麼 ++last_pid 作爲新進程的進程號,在 task[64] 中找到的空閒位置的 index 作爲任務號

_sys_fork 接下來調用 copy_process 進行進程複製:

  1. 1. 將 task_struct 複製給子進程,task_struct 是用來定義進程結構體,裏面有關於進程所有信息;

  2. 2. 隨後對複製來的進程結構內容進行一些修改和初始化賦 0。比方說狀態、進程號、父進程號、運行時間等,還有一些統計信息的初始化,其餘大部分保持不變;

  3. 3. 然後會調用 copy_mem 複製進程的頁表,但是由於 Linux 系統採用了寫時複製 (copy on write) 技術,因此這裏僅爲新進程設置自己的頁目錄表項和頁表項,而沒有實際爲新進程分配物理內存頁面,此時新進程與其父進程共享所有物理內存頁面。

  4. 4. 最後 GDT 表中設置子進程的 TSS(Task State Segment) 段和 LDT(Local Descriptor Table) 段描述符項,將子進程號返回。其中 TSS 段是用來存儲描述進程相關的信息,比方一些寄存器、當前的特權級別等;

需要注意的是子進程也會繼承父進程的文件描述符,也就是子進程會將父進程的文件描述符表項都複製一份,也就是說如果父進程和子進程同時寫一個文件的話可能產生併發寫的問題,導致寫入的數據錯亂。

寫時複製 Copy-On-Write

Copy-on-write (COW), sometimes referred to as implicit sharing[1] or shadowing,[2] is a resource-management technique used in computer programming to efficiently implement a "duplicate" or "copy" operation on modifiable resources.[3] If a resource is duplicated but not modified, it is not necessary to create a new resource; the resource can be shared between the copy and the original. Modifications must still create a copy, hence the technique: the copy operation is deferred until the first write.

按照上面 COW 的定義,它是一種延遲拷貝的資源優化策略,通常用在拷貝複製操作中,如果一個資源只是被拷貝但是沒有被修改,那麼這個資源並不會真正被創建,而是和原數據共享。因此這個技術可以推遲拷貝操作到首次寫入之後進行。

fork 函數調用之後,這個時候因爲 Copy-On-Write(COW) 的存在父子進程實際上是共享物理內存的,並沒有直接去拷貝一份,kernel 把會共享的所有的內存頁的權限都設爲 read-only。當父子進程都只讀內存,然後執行 exec 函數時就可以省去大量的數據複製開銷。

當其中某個進程寫內存時,內存管理單元 MMU 檢測到內存頁是 read-only 的,於是觸發缺頁異常(page-fault),處理器會從中斷描述符表(IDT)中獲取到對應的處理程序。在中斷程序中,kernel 就會把觸發的異常的頁複製一份,於是父子進程各自持有獨立的一份,之後進程再修改對應的數據。

COW 的好處是顯而易見的,同時也有相應的缺點,如果父子進程都需要進行大量的寫操作,會產生大量的缺頁異常(page-fault)。缺頁異常不是沒有代價的,它會處理器會停止執行當前程序或任務轉而去執行專門用於處理中斷或異常的程序。處理器會從中斷描述符表(IDT)中獲取到對應的處理程序,當異常或中斷執行完畢之後,會繼續回到被中斷的程序或任務繼續執行。

也就是說缺頁異常會導致上下文切換,然後查詢 copy 數據到新的物理頁這麼個過程,如果在分配新的物理頁的時候發現內存不夠,那麼還需要進行 swap ,執行相應的淘汰策略,然後進行新頁的替換。所以在 fork 之後要避免大量的寫操作。

孤兒進程 & 殭屍進程

因爲 Linux 提供了一種機制可以保證只要父進程想知道子進程結束時的狀態信息, 就可以得到。所以即使子進程運行完了,還依然會掛在那裏,方面父進程可以獲取一些狀態信息,直到父進程通過 wait / waitpid 來取時才釋放。

如果進程不調用 wait / waitpid 的話,那麼保留的那段信息就不會釋放,其進程號就會一直被佔用,殭屍進程就是這麼誕生的。如果大量的產生僵死進程,將因爲沒有可用的進程號而導致系統不能產生新的進程,所以是有一定危害的。

而孤兒進程就是一個父進程退出,而它的一個或多個子進程還在運行,那麼那些子進程將成爲孤兒進程。孤兒進程將被掛到 init 進程(1 號進程)下面,而 init 進程會循環地 wait 它的已經退出的子進程,所以孤兒進程反倒沒什麼問題。

總結

這篇文章還是比較清晰的,沒有什麼比較大的概念。首先要理解 fork 之前需要理解一下操作系統的異常 & 中斷機制,fork 會觸發軟中斷,陷入到系統調用函數里面,最後根據系統函數調用表找到對應的處理函數創建子進程同事拷貝父進程一些數據,但是這個時候雖然拷貝了虛擬頁表,由於 COW 的存在並不會立馬去複製物理內存,而是會延遲到寫入的時候,通過 page fault 中斷來拷貝物理內存數據,最後還補充了一下孤兒進程 & 殭屍進程的知識點作爲收尾。

Reference

《深入理解計算機系統》

《Linux 內核設計的藝術圖解》

《Advanced.Programming.in.the.UNIX.Environment.3rd.Edition》

https://en.wikipedia.org/wiki/Copy-on-write

關注 luozhiyun 很酷,和他一起學習👆

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/pV0hEJMhFutwapZR90jI8w