跳轉到內容

C 程式設計/POSIX 參考/unistd.h/fork

來自華夏公益教科書,自由的教科書

計算 中,當一個 程序 分叉 時,它會建立一個自身的副本。更一般地說,在 多執行緒 環境中,分叉意味著一個 執行緒 的執行被複制,從父執行緒建立一個子執行緒。

Unix類 Unix 作業系統 中,父程序和子程序可以透過檢查 fork() 系統呼叫 的返回值來區分彼此。在子程序中,fork() 的返回值為 0,而在父程序中,返回值為新建立的子程序的 PID

分叉操作為子程序建立了一個獨立的 地址空間。子程序擁有父程序所有記憶體段的精確副本,儘管如果實現了 寫時複製 語義,實際的物理記憶體可能不會被分配(即,這兩個程序可以共享相同的物理記憶體段一段時間)。父程序和子程序都擁有相同的程式碼段,但它們獨立執行。

Unix 中分叉的重要性

[編輯 | 編輯原始碼]

分叉是 Unix 的重要組成部分,對於支援其設計理念至關重要,該理念鼓勵 過濾器 的開發。在 Unix 中,過濾器是一個(通常很小)程式,它從 stdin 讀取輸入,並將輸出寫入 stdout。這些命令的 管道 可以透過 shell 連線在一起,以建立新的命令。例如,可以將 find(1) 命令的輸出和 wc(1) 命令的輸入連線在一起,建立一個新的命令,該命令將列印當前目錄和任何子目錄中以“.cpp”結尾的檔案的計數,如下所示

$ find . -name "*.cpp" -print | wc -l

為了實現這一點,shell 會分叉自身,並使用 管道,一種 程序間通訊 的形式,將 find 命令的輸出與 wc 命令的輸入連線起來。建立了兩個子程序,每個命令一個(findwc)。這些子程序使用 exec(3) 系統呼叫族(在上面的示例中,find 將覆蓋第一個子程序,wc 將覆蓋第二個子程序,shell 將使用管道將 find 的輸出與 wc 的輸入連線起來)被 覆蓋 了與它們要執行的程式相關的程式碼。

更一般地說,每次使用者發出命令時,shell 也會執行分叉操作。透過分叉 shell 建立子程序,並使用 exec 再次覆蓋與要執行的程式相關的程式碼。

程序地址空間

[編輯 | 編輯原始碼]

每當執行可執行檔案時,它就會成為一個程序。可執行檔案包含二進位制程式碼,這些程式碼被分組到稱為段的若干塊中。每個段用於儲存特定型別的資料。下面列出了典型 ELF 可執行檔案的一些段名稱。

  • 文字 — 包含可執行程式碼的段
  • .bss — 包含初始化為零的資料的段
  • 資料 — 包含已初始化資料的段
  • symtab — 包含程式符號(例如,函式名、變數名等)的段
  • interp — 包含要使用的直譯器名稱的段

readelf 命令可以提供有關 ELF 檔案的更多詳細資訊。當此類檔案載入到記憶體中以執行時,這些段將載入到記憶體中。可執行檔案不必全部載入到連續的記憶體位置。記憶體被分成大小相等的塊,稱為頁面(通常為 4KB)。因此,當可執行檔案載入到記憶體中時,可執行檔案的不同部分被放置在不同的頁面(這些頁面可能不連續)。考慮一個大小為 10K 的 ELF 可執行檔案。如果作業系統支援的頁面大小為 4K,那麼該檔案將被分成三個大小分別為 4K、4K 和 2K 的部分(也稱為 )。這三個幀將被容納在記憶體中任何三個空閒頁面中。

分叉和頁面共享

[編輯 | 編輯原始碼]

當發出 fork() 系統呼叫時,將建立父程序所有對應頁面的副本,並由作業系統載入到子程序的獨立記憶體位置。但在某些情況下,這並非必要。考慮子程序執行“exec”系統呼叫(用於在 C 程式中執行任何可執行檔案)或在 fork() 之後很快退出。當子程序僅用於為父程序執行命令時,無需複製父程序的頁面,因為 exec 將呼叫它的程序的地址空間替換為要執行的命令。

在這種情況下,會使用一種稱為 寫時複製 (COW) 的技術。使用這種技術,當發生分叉時,不會為子程序複製父程序的頁面。相反,這些頁面在子程序和父程序之間共享。每當程序(父程序或子程序)修改頁面時,就會為執行修改的程序(父程序或子程序)建立該特定頁面的單獨副本。然後,該程序將在所有未來的引用中使用新複製的頁面,而不是共享頁面。另一個程序(未修改 共享頁面 的程序)繼續使用該頁面的原始副本(現在不再共享)。該技術稱為寫時複製,因為當某個程序寫入該頁面時,該頁面就會被複制。

Vfork 和頁面共享

[編輯 | 編輯原始碼]

vfork 是另一個用於建立新程序的 UNIX 系統呼叫。當發出 vfork() 系統呼叫時,父程序將被掛起,直到子程序完成執行或被透過 execve() 系統呼叫族中的一個系統呼叫替換為新的可執行映像。即使在 vfork 中,頁面也是在父程序和子程序之間共享的。但 vfork 並不強制執行寫時複製。因此,如果子程序在任何共享頁面中進行修改,則不會建立新頁面,並且父程序也可以看到修改後的頁面。由於完全沒有涉及頁面複製(消耗額外的記憶體),因此當程序需要使用子程序執行阻塞命令時,這種技術非常高效。

在某些系統上,vfork()fork() 相同。

vfork() 函式與 fork() 函式的區別在於子程序可以與呼叫程序(父程序)共享程式碼和資料。這顯著加快了克隆活動,但在濫用 vfork() 時會危及父程序的完整性。

不建議將 vfork() 用於除作為立即呼叫 exec 系列函式或 _exit() 的前奏之外的任何目的。特別是 Linux 的 vfork 手冊頁強烈反對使用它:[1]

Linux 從過去恢復這個幽靈真是不幸。BSD 手冊頁中指出:“當實現適當的系統共享機制時,此係統呼叫將被消除。使用者不應依賴 vfork() 的記憶體共享語義,因為它在這種情況下將與 fork(2) 同義。”

vfork() 函式可用於建立新程序,而無需完全複製舊程序的地址空間。如果分叉的程序只是要呼叫 exec,那麼由 fork() 從父程序複製到子程序的資料空間將不會使用。這在分頁環境中特別低效,使得 vfork() 尤其有用。根據父程序資料空間的大小,vfork() 可以比 fork() 提供顯著的效能提升。

vfork() 函式通常可以像 fork() 一樣使用。但是,它不能在 vfork() 呼叫者的上下文中執行時在子程序中返回,因為最終從 vfork() 返回將返回到不再存在的堆疊幀。如果不能呼叫 exec,還必須注意呼叫 _exit() 而不是 exit(),因為 exit() 會重新整理並關閉標準 I/O 通道,從而損壞父程序的標準 I/O 資料結構。(即使使用 fork(),呼叫 exit() 仍然不正確,因為緩衝的資料將被重新整理兩次。)

如果在 vfork() 後在子程序中呼叫訊號處理程式,它們必須遵循與子程序中其他程式碼相同的規則。[2]

無 MMU 系統

[edit | edit source]

在幾個嵌入式裝置上,沒有 記憶體管理單元,這是實現 fork() 指定的寫時複製語義的要求。如果系統有一些其他機制用於每個程序的地址空間,例如 段暫存器,將整個程序記憶體複製到新程序會達到預期的效果,但是這是一個代價高昂的操作,並且在大多數情況下可能是不必要的,因為新程序幾乎立即會替換大多數情況下的程序映像。

如果所有程序共享一個地址空間,那麼 fork() 的唯一實現方式將是與任務上下文切換的其餘部分一起交換記憶體頁面。與其這樣做,嵌入式作業系統(例如 uClinux)通常會省略 fork(),並且只實現 vfork();移植到此類平臺的部分工作包括重寫程式碼以使用後者。

在其他作業系統中的分叉

[edit | edit source]

Unix 和 Linux 中的分叉機制 (1969) 在底層硬體上保持著隱含的假設:線性記憶體和 分頁 機制,它使對連續地址範圍進行有效的記憶體複製操作成為可能。在 VMS(現在是 OpenVMS)作業系統 (1977) 的原始設計中,將複製操作與隨後對新程序的幾個特定地址的內容進行變異,就像分叉一樣,被認為是有風險的。當前程序狀態中的錯誤可能會複製到子程序。在這裡,使用了程序產生的隱喻:新程序的記憶體佈局的每個元件都是從頭開始新建的。從軟體工程的角度來看,後一種方法將被認為更乾淨、更安全,但分叉機制由於其效率而仍然占主導地位。 生成 (計算) 隱喻後來在 Microsoft 作業系統 (1993) 中採用。

應用程式使用

[edit | edit source]

fork() 系統呼叫 不接受任何引數,並返回一個 程序 ID,它通常是一個整數值。返回的程序 ID 的型別為 pid_t,它已在 標頭檔案 sys/types.h 中定義。

fork() 系統呼叫的目的是建立一個新程序,該程序成為呼叫者的 子程序,之後父程序和子程序都將執行 fork() 系統呼叫之後的程式碼。因此,區分父程序和子程序非常重要。這可以透過測試 fork() 系統呼叫的返回值來完成。

  • 如果 fork() 返回負值,則表示程序建立不成功。
  • fork() 返回零到新建立的子程序。
  • fork() 返回一個正值(子程序的程序 ID)給父程序。[3]

C 中的示例

[edit | edit source]
#include <stdio.h>   /* printf, stderr, fprintf */
#include <sys/types.h> /* pid_t */
#include <unistd.h>  /* _exit, fork */
#include <stdlib.h>  /* exit */
#include <errno.h>   /* errno */

int main(void)
{
   pid_t  pid;

   /* Output from both the child and the parent process
    * will be written to the standard output,
    * as they both run at the same time.
    */
   pid = fork();
   if (pid == -1)
   {   
      /* Error:
       * When fork() returns -1, an error happened
       * (for example, number of processes reached the limit).
       */
      fprintf(stderr, "can't fork, error %d\n", errno);
      exit(EXIT_FAILURE);
   }

   if (pid == 0)
   {
      /* Child process:
       * When fork() returns 0, we are in
       * the child process.
       * Here we count up to ten, one each second.
       */
      int j;
      for (j = 0; j < 10; j++)
      {
         printf("child: %d\n", j);
         sleep(1);
      }
      _exit(0);  /* Note that we do not use exit() */
   }
   else
   { 
      /* Parent process:
       * When fork() returns a positive number, we are in the parent process
       * (the fork return value is the PID of the newly created child process).
       * Again we count up to ten.
       */
      int i;
      for (i = 0; i < 10; i++)
      {
         printf("parent: %d\n", i);
         sleep(1);
      }
      exit(0);
   }
   return 0;
}

參考

[edit | edit source]
  1. VFORK
  2. UNIX 規範版本 2,1997 http://www.opengroup.org/pubs/online/7908799/xsh/vfork.html
  3. "CSL 網站".
[edit | edit source]
華夏公益教科書