Linux進程的建立函數fork()及其fork內核實現解析

進程的建立之fork()
Linux系統下,進程能夠調用fork函數來建立新的進程。調用進程爲父進程,被建立的進程爲子進程。
fork函數的接口定義以下:
   
   
   
   
#include <unistd.h>pid_t fork(void);
與普通函數不一樣,fork函數會返回兩次。通常說來,建立兩個徹底相同的進程並無太多的價值。大部分狀況下,父子進程會執行不一樣的代碼分支。fork函數的返回值就成了區分父子進程的關鍵。fork函數向子進程返回0,並將子進程的進程ID返給父進程。固然了,若是fork失敗,該函數則返回-1,並設置errno。

從2.6.24起,Linux採用徹底公平調度(Completely Fair Scheduler,CFS)。用戶建立的普通進程,都採用CFS調度策略。對於CFS調度策略,procfs提供了以下控制選項:
   
   
   
   
/proc/sys/kernel/sched_child_runs_first
該值默認是0,表示父進程優先得到調度。若是將該值改爲1,那麼子進程會優先得到調度。

f ork以後父子進程的內存關係
fork以後的子進程徹底拷貝了父進程的地址空間,包括棧、堆、代碼段等。經過下面的示例代碼,咱們一塊兒來查看父子進程的內存關係:
   
   
   
   
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <string.h>#include <errno.h>#include <sys/types.h>#include <wait.h>int g_int = 1;//數據段的全局變量int main(){ int local_int = 1;//棧上的局部變量 int *malloc_int = malloc(sizeof(int));//經過malloc動態分配在堆上的變量 *malloc_int = 1; pid_t pid = fork(); if(pid == 0) /*子進程*/ { local_int = 0; g_int = 0; *malloc_int = 0; fprintf(stderr,"[CHILD ] child change local global malloc value to 0\n"); free(malloc_int); sleep(10); fprintf(stderr,"[CHILD ] child exit\n"); exit(0); } else if(pid < 0) { printf("fork failed (%s)",strerror(errno)); return 1; } fprintf(stderr,"[PARENT] wait child exit\n"); waitpid(pid,NULL,0); fprintf(stderr,"[PARENT] child have exit\n"); printf("[PARENT] g_int = %d\n",g_int); printf("[PARENT] local_int = %d\n",local_int); printf("[PARENT] malloc_int = %d\n",local_int); free(malloc_int); return 0;}
這裏刻意定義了三個變量,一個是位於數據段的全局變量,一個是位於棧上的局部變量,還有一個是經過malloc動態分配位於堆上的變量,三者的初始值都是1。而後調用fork建立子進程,子進程將三個變量的值都改爲了0。
按照fork的語義,子進程徹底拷貝了父進程的數據段、棧和堆上的內存,若是父子進程對相應的數據進行修改,那麼兩個進程是並行不悖、互不影響的。所以,在上面示例代碼中,儘管子進程將三個變量的值都改爲了0,對父進程而言這三個值都沒有變化,仍然是1,代碼的輸出也證明了這一點。
   
   
   
   
[PARENT] wait child exit[CHILD ] child change local global malloc value to 0[CHILD ] child exit[PARENT] child have exit[PARENT] g_int = 1[PARENT] local_int = 1[PARENT] malloc_int = 1

前文提到過,子進程和父進程執行如出一轍的代碼的情形比較少見。Linux提供了execve系統調用,構建在該系統調用之上,glibc提供了exec系列函數。這個系列函數會丟棄現存的程序代碼段,並構建新的數據段、棧及堆。調用fork以後,子進程幾乎老是經過調用exec系列函數,來執行新的程序。
在這種背景下,fork時子進程徹底拷貝父進程的數據段、棧和堆的作法是不明智的,由於接下來的exec系列函數會絕不留情地拋棄剛剛辛苦拷貝的內存。爲了解決這個問題,Linux引入了寫時拷貝(copy-on-write)的技術。
寫時拷貝是指子進程的頁表項指向與父進程相同的物理內存頁,這樣只拷貝父進程的頁表項就能夠了,固然要把這些頁面標記成只讀(如圖4-4所示)。若是父子進程都不修改內存的內容,你們便相安無事,共用一份物理內存頁。可是一旦父子進程中有任何一方嘗試修改,就會引起缺頁異常(page fault)。此時,內核會嘗試爲該頁面建立一個新的物理頁面,並將內容真正地複製到新的物理頁面中,讓父子進程真正地各自擁有本身的物理內存頁,而後將頁表中相應的表項標記爲可寫。
從上面的描述能夠看出,對於沒有修改的頁面,內核並無真正地複製物理內存頁,僅僅是複製了父進程的頁表。這種機制的引入提高了fork的性能,從而使內核能夠快速地建立一個新的進程。
查看下copy_one_pte函數中有以下代碼:
   
   
   
   
/*若是是寫時拷貝, 那麼不管是初始頁表, 仍是拷貝的頁表, 都設置了寫保護 *後面不管父子進程, 修改頁表對應位置的內存時, 都會觸發page fault */ if (is_cow_mapping(vm_flags)) { ptep_set_wrprotect(src_mm, addr, src_pte);//設置爲寫保護 pte = pte_wrprotect(pte); }
該代碼將頁表設置成寫保護,父子進程中任意一個進程嘗試修改寫保護的頁面時,都會引起缺頁中斷,內核會走向do_wp_page函數,該函數會負責建立副本,即真正的拷貝。
寫時拷貝技術極大地提高了fork的性能,在必定程度上讓vfork成爲了雞肋。


父子進程共用了一套文件偏移量
文件描述符還有一個文件描述符標誌(file descriptor flag)。目前只定義了一個標誌位:FD_CLOSEXEC,這是close_on_exec標誌位。細心閱讀open函數手冊也會發現,open函數也有一個相似的標誌位,即O_CLOSEXEC,該標誌位也是用於設置文件描述符標誌的。
那麼這個標誌位到底有什麼做用呢?若是文件描述符中將這個標誌位置位,那麼調用exec時會自動關閉對應的文件。
但是爲何須要這個標誌位呢?主要是出於安全的考慮。
對於fork以後子進程執行exec這種場景,若是子進程能夠操做父進程打開的文件,就會帶來嚴重的安全隱患。通常來說,調用exec的子進程時,由於它.會另起爐竈,所以父進程打開的文件描述符也應該一併關閉,但事實上內核並無主動這樣作。試想以下場景,Webserver首先以root權限啓動,打開只有擁有root權限才能打開的端口和日誌等文件,再降到普通用戶,fork出一些worker進程,在進程中進行解析腳本、寫日誌、輸出結果等操做。因爲子進程徹底能夠操做父進程打開的文件,所以子進程中的腳本只要繼續操做這些文件描述符,就能越權操做root用戶才能操做的文件。
爲了解決這個問題,Linux引入了close on exec機制。設置了FD_CLOSEXEC標誌位的文件,在子進程調用exec家族函數時會將相應的文件關閉。而設置該標誌位的方法有兩種:
·open時,帶上O_CLOSEXEC標誌位。
·open時若是未設置,那就在後面調用fcntl函數的F_SETFD操做來設置。
建議使用第一種方法。緣由是第二種方法在某些時序條件下並不那麼絕對的安全。考慮圖4-7的場景:Thread 1還沒來得及將FD_CLOSEXEC置位,因爲Thread 2已經執行過fork,這時候fork出來的子進程就不會關閉相應的文件。儘管Thread1後來調用了fcntl的F_SETFD操做,可是爲時已晚,文件已經泄露了。
注意 圖4-7中,多線程程序執行了fork,僅僅是爲了示意,實際中並不鼓勵這種作法。正相反,這種作法是十分危險的。多線程程序不該該調用fork來建立子進程,第8章會分析具體緣由。
前面提到,執行fork時,子進程會獲取父進程全部文件描述符的副本,可是測試結果代表,父子進程共享了文件的不少屬性。這究竟是怎麼回事?讓咱們深刻內核一探究竟。
 在內核的進程描述符task_struct結構體中,與打開文件相關的變量以下所示:
   
   
   
   
struct task_struct { ...struct files_struct *files;...}
調用fork時,內核會在copy_files函數中處理拷貝父進程打開的文件的相關事宜:
   
   
   
   
static int copy_files(unsigned long clone_flags, struct task_struct *tsk){ struct files_struct *oldf, *newf; int error = 0; oldf = current->files;//獲取父進程的文件結構體 if (!oldf) goto out; /*建立線程和vfork, 都不用複製父進程的文件描述符, 增長引用計數便可*/ if (clone_flags & CLONE_FILES) { atomic_inc(&oldf->count); goto out; } /*對於fork而言, 須要複製父進程的文件描述符*/ newf = dup_fd(oldf, &error); //複製一份文件描述符 if (!newf) goto out; tsk->files = newf; error = 0;out: return error;}
CLONE_FILES標誌位用來控制是否共享父進程的文件描述符。若是該標誌位置位,則表示沒必要費勁複製一份父進程的文件描述符了,增長引用計數,直接共用一份就能夠了。對於vfork函數和建立線程的pthread_create函數來講都是如此。可是fork函數卻不一樣,調用fork函數時,該標誌位爲0,表示須要爲子進程拷貝一份父進程的文件描述符。文件描述符的拷貝是經過內核的dup_fd函數來完成的。
   
   
   
   
struct files_struct *dup_fd(struct files_struct *oldf, int *errorp){ struct files_struct *newf; struct file **old_fds, **new_fds; int open_files, size, i; struct fdtable *old_fdt, *new_fdt; *errorp = -ENOMEM; newf = kmem_cache_alloc(files_cachep, GFP_KERNEL); if (!newf) goto out;
dup_fd函數首先會給子進程分配一個file_struct結構體,而後作一些賦值操做。這個結構體是進程描述符中與打開文件相關的數據結構,每個打開的文件都會記錄在該結構體中。其定義代碼以下:
   
   
   
   
struct files_struct { atomic_t count; struct fdtable __rcu *fdt; struct fdtable fdtab; spinlock_t file_lock ____cacheline_aligned_in_smp; int next_fd; struct embedded_fd_set close_on_exec_init; struct embedded_fd_set open_fds_init; struct file __rcu * fd_array[NR_OPEN_DEFAULT];};struct fdtable //文件描述符表{ unsigned int max_fds; struct file __rcu **fd; /* current fd array */ fd_set *close_on_exec; fd_set *open_fds; struct rcu_head rcu; struct fdtable *next;};struct embedded_fd_set { unsigned long fds_bits[1];};
初看之下struct fdtable的內容與struct files_struct的內容有頗多重複之處,包括close_on_exec文件描述符位圖、打開文件描述符位圖及file指針數組等,但事實上並不是如此。struct files_struct中的成員是相應數據結構的實例,而struct fdtable中的成員是相應的指針。
Linux系統假設大多數的進程打開的文件不會太多。因而Linux選擇了一個long類型的位數(32位系統下爲32位,64位系統下爲64位)做爲經驗值。
以64位系統爲例,file_struct結構體自帶了能夠容納64個struct file類型指針的數組fd_array,也自帶了兩個大小爲64的位圖,其中open_fds_init位圖用於記錄文件的打開狀況,close_on_exec_init位圖用於記錄文件描述符的FD_CLOSEXCE標誌位是否置位。只要進程打開的文件個數小於64,file_struct結構體自帶的指針數組和兩個位圖就足以知足須要。所以在分配了file_struct結構體後,內核會初始化file_struct自帶的fdtable,代碼以下所示:
   
   
   
   
atomic_set(&newf->count, 1);spin_lock_init(&newf->file_lock);newf->next_fd = 0;new_fdt = &newf->fdtab;new_fdt->max_fds = NR_OPEN_DEFAULT;new_fdt->close_on_exec = (fd_set *)&newf->close_on_exec_init;new_fdt->open_fds = (fd_set *)&newf->open_fds_init;new_fdt->fd = &newf->fd_array[0];new_fdt->next = NULL;
初始化以後,子進程的file_struct的狀況如圖4-8所示。注意,此時file_struct結構體中的fdt指針並未指向file_struct自帶的struct fdtable類型的fdtab變量。緣由很簡單,由於此時內核尚未檢查父進程打開文件的個數,所以並不肯定自帶的結構體可否知足須要。
接下來,內核會檢查父進程打開文件的個數。若是父進程打開的文件超過了64個,struct files_struct中自帶的數組和位圖就不能知足須要了。這種狀況下內核會分配一個新的struct fdtable,代媽以下:
 
   
   
   
   
spin_lock(&oldf->file_lock); old_fdt = files_fdtable(oldf); open_files = count_open_files(old_fdt); /*若是父進程打開文件的個數超過NR_OPEN_DEFAULT*/ while (unlikely(open_files > new_fdt->max_fds)) { spin_unlock(&oldf->file_lock); /* 若是不是自帶的fdtable而是曾經分配的fdtable, 則須要先釋放*/ if (new_fdt != &newf->fdtab) __free_fdtable(new_fdt); /*建立新的fdtable*/ new_fdt = alloc_fdtable(open_files - 1); if (!new_fdt) { *errorp = -ENOMEM; goto out_release; } /*若是超出了系統限制, 則返回EMFILE*/ if (unlikely(new_fdt->max_fds < open_files)) { __free_fdtable(new_fdt); *errorp = -EMFILE; goto out_release; } spin_lock(&oldf->file_lock); old_fdt = files_fdtable(oldf); open_files = count_open_files(old_fdt); }
alloc_fdtable所作的事情,不過是分配fdtable結構體自己,以及分配一個指針數組和兩個位圖。分配以前會根據父進程打開文件的數目,計算出一個合理的值nr,以確保分配的數組和位圖可以知足須要。
不管是使用file_struct結構體自帶的fdtable,仍是使用alloc_fdtable分配的fdtable,接下來要作的事情都同樣,即將父進程的兩個位圖信息和打開文件的struct file類型指針拷貝到子進程的對應數據結構中,代碼以下:
   
   
   
   
  1. /*父進程的struct file 指針數組*/
  2. new_fds = new_fdt->fd; /*子進程的struct file 指針數組*/
  3. /* 拷貝打開文件位圖 */
  4. memcpy(new_fdt->open_fds->fds_bits,old_fdt->open_fds->fds_bits, open_files/8);
  5. /* 拷貝 close_on_exec位圖 */
  6. memcpy(new_fdt->close_on_exec->fds_bits,old_fdt->close_on_exec->fds_bits, open_files/8);
  7. for (i = open_files; i != 0; i--) {
  8.  struct file *f = *old_fds++;
  9. if (f) {
  10.  get_file(f); /* f對應的文件的引用計數加1 */
  11.  } else {
  12. FD_CLR(open_files - i, new_fdt->open_fds);
  13. }
  14. /* 子進程的struct file類型指針, *指向和父進程相同的struct file 結構體*/
  15. rcu_assign_pointer(*new_fds++, f);  
  16. }
  17. spin_unlock(&oldf->file_lock);/* compute the remainder to be cleared */
  18. size = (new_fdt->max_fds - open_files) * sizeof(struct file *);
  19. /*將還沒有分配到的struct file結構的指針清零*/
  20.     memset(new_fds, 0, size);/*將還沒有分配到的位圖區域清零*/
  21.     if (new_fdt->max_fds > open_files) {
  22. int left = (new_fdt->max_fds-open_files)/8;
  23. int start = open_files / (8 * sizeof(unsigned long));
old_fds = old_fdt->fd; memset(&new_fdt->open_fds->fds_bits[start], 0, left); memset(&new_fdt->close_on_exec->fds_bits[start], 0, left);}    rcu_assign_pointer(newf->fdt, new_fdt);    return newf;out_release:    kmem_cache_free(files_cachep, newf);out:    return NULL;}
經過對上述流程的梳理,不難看出,父子進程之間拷貝的是struct file的指針,而不是struct file的實例,父子進程的struct file類型指針,都指向同一個struct file實例。fork以後,父子進程的文件描述符關係如圖4-10所示。
進程的建立之vfork()
在早期的實現中,fork沒有實現寫時拷貝機制,而是直接對父進程的數據段、堆和棧進行徹底拷貝,效率十分低下。不少程序在fork一個子進程後,會緊接着執行exec家族函數,這更是一種浪費。因此BSD引入了vfork。既然fork以後會執行exec函數,拷貝父進程的內存數據就變成了一種無心義的行爲,因此引入的vfork壓根就不會拷貝父進程的內存數據,而是直接共享。再後來Linux引入了寫時拷貝的機制,其效率提升了不少,這樣一來,vfork其實就能夠退出歷史舞臺了。除了一些須要將性能優化到極致的場景,大部分狀況下不須要再使用vfork函數了。
vfork會建立一個子進程,該子進程會共享父進程的內存數據,並且系統將保證子進程先於父進程得到調度。子進程也會共享父進程的地址空間,而父進程將被一直掛起,直到子進程退出或執行exec。
注意,vfork以後,子進程若是返回,則不要調用return,而應該使用_exit函數。若是使用return,就會出現詭異的錯誤。請看下面的示例代碼:
   
   
   
   
#include<stdio.h>#include <stdlib.h>#include <unistd.h>int glob = 88 ;int main(void) { int var; var = 88; pid_t pid; if ((pid = vfork()) < 0) { printf("vfork error"); exit(-1); } else if (pid == 0) { /* 子進程 */ var++; glob++; return 0; }printf("pid=%d, glob=%d, var=%d\n",getpid(), glob, var); return 0;}
調用子進程,若是使用return返回,就意味着main函數返回了,由於棧是父子進程共享的,因此程序的函數棧發生了變化。main函數return以後,一般會調用exit系的函數,父進程收到子進程的exit以後,就會開始從vfork返回,可是這時整個main函數的棧都已經不復存在了,因此父進程壓根沒法執行。因而會返回一個詭異的棧地址,對於在某些內核版本中,進程會直接報棧錯誤而後退出,可是在某些內核版本中,有可能就會再次進出main,因而進入一個無限循環,直到vfork返回錯誤。筆者的Ubuntu版本就是後者。返回。通常來講,vfork建立的子進程會執行exec,執行完exec後應該調用_exit,注意是_exit而不是exit。由於exit會致使父進程stdio緩衝區的沖刷和關閉。咱們會在後面講述exit和_exit的區別。


相關文章
相關標籤/搜索