深入理解Linux的fork函數

一、問題引入
    工作期間,某系統設計師拋出如下一個問題,下面的代碼,輸出幾個“-”?:

/****************************************************************************** 
Copyright by Thomas Hu, All rights reserved! 
Filename    : fork01.c 
Author      : Thomas Hu 
Date        : 2012-8-5 
Version     : 1.0 
Description : fork函數問題原型 
******************************************************************************/  
#include <unistd.h>  
#include <stdio.h>  
  
int main()  
{  
    int i = 0;  
    for(i = 0; i < 2; i++)  
    {  
        fork();  
        printf("-");  
    }  
  
    return 0;  
}  

過了N久之後,仍然沒有人回答這個問題(也許大家都忙,沒空理他^_^)。

如果您回答是2, 那建議您還是先看看Linux中的fork函數使用說明;
如果您回答是6, 說明您對fork函數有一定的理解了,但還需要繼續看本篇文檔;
如果您回答是8,並且理解背後原理(不是執行程序得出的結論),那您就不需要看本文啦,請繞道行走^_^。

我大略分析了一下,然後輸入代碼編譯執行,執行結果竟然爲8個,覺得不可思議!(理論上是6個啊,對8個百思不得其解,後來查閱了資料,才發現自己還沒搞懂 fork 背後的本質,因此撰此文,大家共同探討。)

要搞清楚fork的執行過程,就必須先弄清楚操作系統中的“進程(process)”概念。一個進程,主要包含三個元素:
1. 一個可以執行的程序;
2. 和該進程相關聯的全部數據(包括變量,內存空間,緩衝區等等);
3. 程序的執行上下文(execution context)。
不妨簡單理解爲,一個進程表示的,就是一個可執行程序的一次執行過程中的一個狀態。操作系統對進程的管理,典型的情況,是通過進程表完成的。進程表中的每一個表項,記錄的是當前操作系統中一個進程的情況。對於單 CPU的情況而言,每一特定時刻只有一個進程佔用 CPU,但是系統中可能同時存在多個活動的(等待執行或繼續執行的)進程。
一個稱爲“程序計數器(program counter, pc)”的寄存器,指出當前佔用 CPU的進程要執行的下一條指令的位置。
當分給某個進程的 CPU時間已經用完,操作系統將該進程相關的寄存器的值,保存到該進程在進程表中對應的表項裏面;把將要接替這個進程佔用 CPU的那個進程的上下文,從進程表中讀出,並更新相應的寄存器(這個過程稱爲“上下文交換(process context switch)”,實際的上下文交換需要涉及到更多的數據,那和fork無關,不再多說,主要要記住程序寄存器pc記錄了程序當前已經執行到哪裏,是進程上下文的重要內容,換出 CPU的進程要保存這個寄存器的值,換入CPU的進程,也要根據進程表中保存的本進程執行上下文信息,更新這個寄存器)。

二、fork函數詳解

        #include<unistd.h>
  #include<sys/types.h>
  函數定義:
  pid_t fork( void);
  (pid_t 是一個宏定義,其實質是int 被定義在#include<sys/types.h>中)
  返回值:
                  若成功調用一次則返回兩個值,子進程返回0,父進程返回子進程ID;否則,出錯返回-1 。
                 fork出錯可能有兩種原因:(1)當前的進程數已經達到了系統規定的上限,這時errno的值被設置爲EAGAIN。(2)系統內存不足,這時errno的值被設置爲ENOMEM。
  函數說明:
  一個現有進程可以調用fork函數創建一個新進程。由fork創建的新進程被稱爲子進程(child process)。fork函數被調用一次但返回兩次。兩次返回的唯一區別是子進程中返回0值而父進程中返回子進程ID。 將子進程id返回給父進程的理由是:因爲一個進程的子進程可以多於一個,沒有一個函數使一個進程可以獲得其所有子進程的進程id。對子進程來說,之所以fork返回0給它,是因爲它隨時可以調用getpid()來獲取自己的pid;也可以調用getppid()來獲取父進程的id。(進程id 0總是由交換進程使用,所以一個子進程的進程id不可能爲0 )。
  子進程是父進程的副本,它將獲得父進程數據空間、堆、棧等資源的副本。注意,子進程持有的是上述存儲空間的“副本”,這意味着父子進程間不共享這些存儲空間。
  linux將複製父進程的地址空間內容給子進程,因此,子進程有了獨立的地址空間。可以這樣想象,2個進程一直同時運行,而且步調一致,在fork之後,他們分別做不同的工作,也就是分岔了。這也是fork爲什麼叫fork的原因。
         Linux幫助手冊,對 fork 函數有非常詳細的說明,如下:

DESCRIPTION
       fork()  creates  a  new  process  by  duplicating the calling process.  The new process, referred to as the child, is an exact duplicate of the calling process, referred to as the parent, except for the following
       points:

       *  The child has its own unique process ID, and this PID does not match the ID of any existing process group (setpgid(2)).

       *  The child's parent process ID is the same as the parent's process ID.

       *  The child does not inherit its parent's memory locks (mlock(2), mlockall(2)).

       *  Process resource utilizations (getrusage(2)) and CPU time counters (times(2)) are reset to zero in the child.

       *  The child's set of pending signals is initially empty (sigpending(2)).

       *  The child does not inherit semaphore adjustments from its parent (semop(2)).

       *  The child does not inherit record locks from its parent (fcntl(2)).

       *  The child does not inherit timers from its parent (setitimer(2), alarm(2), timer_create(2)).

       *  The child does not inherit outstanding asynchronous I/O operations from its parent (aio_read(3), aio_write(3)), nor does it inherit any asynchronous I/O contexts from its parent (seeio_setup(2)).

       The process attributes in the preceding list are all specified in POSIX.1-2001.  The parent and child also differ with respect to the following Linux-specific process attributes:

       *  The child does not inherit directory change notifications (dnotify) from its parent (see the description of F_NOTIFY in fcntl(2)).

       *  The prctl(2) PR_SET_PDEATHSIG setting is reset so that the child does not receive a signal when its parent terminates.

       *  Memory mappings that have been marked with the madvise(2) MADV_DONTFORK flag are not inherited across a fork().

       *  The termination signal of the child is always SIGCHLD (see clone(2)).
Note the following further points:

       *  The child process is created with a single thread ?.the one that called fork().  The entire virtual address space of the parent is replicated in the child, including the states of mutexes, condition variables,
          and other pthreads objects; the use of pthread_atfork(3) may be helpful for dealing with problems that this can cause.

       *  The  child  inherits  copies  of the parent's set of open file descriptors.  Each file descriptor in the child refers to the same open file description (see open(2)) as the corresponding file descriptor in the
          parent.  This means that the two descriptors share open file status flags, current file offset, and signal-driven I/O attributes (see the description of F_SETOWN and F_SETSIG in fcntl(2)).

       *  The child inherits copies of the parent's set of open message queue descriptors (see mq_overview(7)).  Each descriptor in the child refers to the same  open  message  queue  description  as  the  corresponding
          descriptor in the parent.  This means that the two descriptors share the same flags (mq_flags).

       *  The  child  inherits  copies  of  the parent's set of open directory streams (see opendir(3)).  POSIX.1-2001 says that the corresponding directory streams in the parent and child may share the directory stream
          positioning; on Linux/glibc they do not.

RETURN VALUE
       On success, the PID of the child process is returned in the parent, and 0 is returned in the child.  On failure, -1 is returned in the parent, no child process is created, and errno is set appropriately.

ERRORS
       EAGAIN fork() cannot allocate sufficient memory to copy the parent's page tables and allocate a task structure for the child.

       EAGAIN It was not possible to create a new process because the caller's RLIMIT_NPROC resource limit was encountered.  To exceed this limit, the process must have either the CAP_SYS_ADMIN or  the  CAP_SYS_RESOURCE
              capability.

       ENOMEM fork() failed to allocate the necessary kernel structures because memory is tight.

CONFORMING TO
       SVr4, 4.3BSD, POSIX.1-2001.

NOTES
       Under  Linux,  fork()  is implemented using copy-on-write pages, so the only penalty that it incurs is the time and memory required to duplicate the parent's page tables, and to create a unique task structure for
       the child.

       Since version 2.3.3, rather than invoking the kernel's fork() system call, the glibc fork() wrapper that is provided as part of the NPTL threading implementation invokes clone(2) with flags that provide the  same
       effect as the traditional system call.  The glibc wrapper invokes any fork handlers that have been established using pthread_atfork(3).

以上英文內容,相信大家都看得懂吧^_^,如果不懂,那還真不太適合搞程序啊。下次有時間的話,我再給大家翻譯吧(如果有需求的話^_^)。

三、問題分析
  前面說了那麼多廢話,其實都是爲了解決那個“詭異”的輸出 8 個“-”的問題的。fork函數,使子進程複製了父進程的整個虛擬地址空間(包括互斥狀態、條件變量、其他pthread對象等),子進程繼承父進程的打開文件描述符集合、打開消息隊列描述符集合以及打開的目錄流集合等,但內存鎖、CPU時間片、旗標、記錄鎖、定時器等不會從父進程繼承下來。
         下面從for循環開始逐步分析源碼。
          1、當 i = 0 時, 在循環體內執行 fork 函數,此時父進程(暫且命名爲 P)創建了一個子進程(姑且命名爲 A)。此時, 進程 A擁有與父進程相同的條件變量, 在進程A中,i 也爲0;接着兩個進程 P 和 A 執行 printf 語句。注意,此時系統中存在兩個進程,分別分析如下。
         2、在 P 進程中, i 加 1, 此時 i = 1,滿足循環條件,進入循環體執行。執行 fork 函數,再次創建一個子進程 B, 此時在進程 P 和 B 中, i = 1;接着兩個進程 P 和 A 執行 printf語句。
         3、在 A 進程中, i 加1, 此時 i = 1, 滿足循環條件,進入循環體執行。執行 fork 函數, 創建一個進程 A 的子進程(姑且命名爲AA)。此時,在進程 A 和 AA中, i =1; 接着兩個進程 A 和 AA分別執行 printf 語句。
         4、在進程 P、 A、AA、B進程中,i 再次加1, 此時 i = 2;均不滿足循環體判斷條件,4個進程跳出循環體,執行循環體後面的 return 語句,進程結束。
以上分析過程,如下圖所示(相同顏色的是同一個進程):


     仔細的讀者可能會驚呼,4個進程,不是總共只執行了 6 次 printf 語句嗎?怎麼會打印 8 個“-”呢?是的,只執行了 6 次 printf語句,這毋庸置疑!
      這是因爲printf(“-”);語句在作怪!我們知道,在Linux/Unix下的設備有“塊設備”和“字符設備”的概念,所謂塊設備,就是以一塊一塊的數據存取的設備,字符設備是一次存取一個字符的設備。磁盤、內存、顯示器都是塊設備,字符設備如鍵盤和串口。塊設備一般都有緩存,而字符設備一般都沒有緩存。
所以,對於上述程序,printf(“-”);把“-”放到了緩存中,並沒有真正的輸出,在fork的時候,緩存被複制到了子進程空間,所以,就多了兩個,就成了8個,而不是6個。
我們如果修改一下上面的printf語句爲:
printf("-\n");
或是
printf("-");
flush();

就沒有問題了,程序會只輸出6個 “-”,因爲程序遇到“\n”或是EOF,或是緩中區滿,或是文件描述符關閉,或是主動flush,就會把數據刷出緩衝區。

完整的代碼如下:

/****************************************************************************** 
Copyright by Thomas Hu, All rights reserved! 
Filename    : fork02.c 
Author      : Thomas Hu 
Date        : 2012-8-5 
Version     : 1.0 
Description : fork函數問題,打印進程號,通過 pstree -p 查看進程樹關係 
******************************************************************************/  
#include <unistd.h>  
#include <stdio.h>  
  
int main()  
{  
    int i = 0;  
    for(i = 0; i < 2; i++)  
    {  
        fork();  
          
        /*注意:下面的printf有“\n”*/  
        printf("ppid=%d, pid=%d, i=%d \n", getppid(), getpid(), i);  
    }  
  
    sleep(10); /*讓進程停留十秒,這樣我們可以用pstree -p 查看一下進程樹*/  
  
    return 0;  
}  

執行結果如下:


通過進程樹查看,如下所示:


如下圖所示,就是陰影並雙邊框了那兩個子進程複製了父進程標準輸出緩中區裏的的內容,而導致了多次輸出。


注:以上進程樹分析的兩張圖片,摘自:http://coolshell.cn/articles/7965.html ,版權歸原作者所有,在此表示感謝!

 

四、總結
    在計算機編程領域,從來就沒有所謂“詭異”的事件,有果必有因,有因必有果!若出現“詭異”事件,說明在某個隱蔽的角落,我們沒有想到,或沒有深入理解其本質,纔會導致某些現象“不可思議”!
我們只有透過現象,看透本質,某些“詭異”的問題,就能迎刃而解,最終發現“詭異”現象本身就是一種自然現象,是我們的無知造成了“靈異”事件^_^。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章