作業系統應該提供怎樣的進程來創建及控制接口?

程序員書屋 發佈 2020-05-22T10:07:04+00:00

本章將討論UNIX系統中的進程創建。UNIX系統採用了一種非常有趣的創建新進程的方式,即通過一對系統調用:fork()和exec()。進程還可以通過第三個系統調用wait(),來等待其創建的子進程執行完成。本章將詳細介紹這些接口,通過一些簡單的例子來激發興趣。

本章將討論UNIX系統中的進程創建。UNIX系統採用了一種非常有趣的創建新進程的方式,即通過一對系統調用:fork()和exec()。進程還可以通過第三個系統調用wait(),來等待其創建的子進程執行完成。本章將詳細介紹這些接口,通過一些簡單的例子來激發興趣。

關鍵問題:如何創建並控制進程 

作業系統應該提供怎樣的進程來創建及控制接口?如何設計這些接口才能既方便又實用?

5.1 fork()系統調用

系統調用fork()用於創建新進程[C63]。但要小心,這可能是你使用過的最奇怪的接口[1]。具體來說,你可以運行一個程序,代碼如圖5.1所示。仔細看這段代碼,建議親自鍵入並運行!

1    #include <stdio.h>
2    #include <stdlib.h>
3    #include <unistd.h>
4
5    int
6    main(int argc, char *argv[])
7    {
8        printf("hello world (pid:%d)\n", (int) getpid());
9        int rc = fork();
10       if (rc < 0) {        // fork failed; exit
11           fprintf(stderr, "fork failed\n");
12           exit(1);
13       } else if (rc == 0) { // child (new process)
14           printf("hello, I am child (pid:%d)\n", (int) getpid());
15       } else {             // parent goes down this path (main)
16           printf("hello, I am parent of %d (pid:%d)\n",
17                   rc, (int) getpid());
18       }
19       return 0;
20   }

圖5.1 調用fork()(p1.c)

運行這段程序(p1.c),將看到如下輸出:

prompt> ./p1
hello world (pid:29146)
hello, I am parent of 29147 (pid:29146) 
hello, I am child (pid:29147)
prompt>

讓我們更詳細地理解一下p1.c到底發生了什麼。當它剛開始運行時,進程輸出一條hello world信息,以及自己的進程描述符(process identifier,PID)。該進程的PID是29146。在UNIX系統中,如果要操作某個進程(如終止進程),就要通過PID來指明。到目前為止,一切正常。

緊接著有趣的事情發生了。進程調用了fork()系統調用,這是作業系統提供的創建新進程的方法。新創建的進程幾乎與調用進程完全一樣,對作業系統來說,這時看起來有兩個完全一樣的p1程序在運行,並都從fork()系統調用中返回。新創建的進程稱為子進程(child),原來的進程稱為父進程(parent)。子進程不會從main()函數開始執行(因此hello world信息只輸出了一次),而是直接從fork()系統調用返回,就好像是它自己調用了fork()。

你可能已經注意到,子進程並不是完全拷貝了父進程。具體來說,雖然它擁有自己的地址空間(即擁有自己的私有內存)、寄存器、程序計數器等,但是它從fork()返回的值是不同的。父進程獲得的返回值是新創建子進程的PID,而子進程獲得的返回值是0。這個差別非常重要,因為這樣就很容易編寫代碼處理兩種不同的情況(像上面那樣)。  

你可能還會注意到,它的輸出不是確定的(deterministic)。子進程被創建後,我們就需要關心系統中的兩個活動進程了:子進程和父進程。假設我們在單個CPU的系統上運行(簡單起見),那么子進程或父進程在此時都有可能運行。在上面的例子中,父進程先運行並輸出信息。在其他情況下,子進程可能先運行,會有下面的輸出結果:

prompt> ./p1
hello world (pid:29146) 
hello, I am child (pid:29147)
hello, I am parent of 29147 (pid:29146) 
prompt>

CPU調度程序(scheduler)決定了某個時刻哪個進程被執行,我們稍後將詳細介紹這部分內容。由於CPU調度程序非常複雜,所以我們不能假設哪個進程會先運行。事實表明,這種不確定性(non-determinism)會導致一些很有趣的問題,特別是在多線程程序(multi-threaded program)中。在本書第2部分中學習並發(concurrency)時,我們會看到許多不確定性。

5.2 wait()系統調用

到目前為止,我們沒有做太多事情:只是創建了一個子進程,列印了一些信息並退出。事實表明,有時候父進程需要等待子進程執行完畢,這很有用。這項任務由wait()系統調用(或者更完整的兄弟接口waitpid())。圖5.2展示了更多細節。

1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <unistd.h>
4 #include <sys/wait.h>
5
6    int
7    main(int argc, char *argv[])
8    {
9        printf("hello world (pid:%d)\n", (int) getpid());
10       int rc = fork();
11       if (rc < 0) {        // fork failed; exit
12           fprintf(stderr, "fork failed\n");
13           exit(1);
14       } else if (rc == 0) { // child (new process)
15           printf("hello, I am child (pid:%d)\n", (int) getpid());
16       } else {    // parent goes down this path (main)
17           int wc = wait(NULL);
18           printf("hello, I am parent of %d (wc:%d) (pid:%d)\n",
19                   rc, wc, (int) getpid());
20       }
21       return 0;
22   }

圖5.2 調用fork()和wait()(p2.c)

在p2.c的例子中,父進程調用wait(),延遲自己的執行,直到子進程執行完畢。當子進程結束時,wait()才返回父進程。

上面的代碼增加了wait()調用,因此輸出結果也變得確定了。這是為什麼呢?想想看。

(等你想想看……好了)

下面是輸出結果:

prompt> ./p2
hello world (pid:29266) 
hello, I am child (pid:29267)
hello, I am parent of 29267 (wc:29267) (pid:29266) 
prompt>

通過這段代碼,現在我們知道子進程總是先輸出結果。為什麼知道?好吧,它可能只是碰巧先運行,像以前一樣,因此先於父進程輸出結果。但是,如果父進程碰巧先運行,它會馬上調用wait()。該系統調用會在子進程運行結束後才返回[2]。因此,即使父進程先運行,它也會禮貌地等待子進程運行完畢,然後wait()返回,接著父進程才輸出自己的信息。

5.3 最後是exec()系統調用

最後是exec()系統調用,它也是創建進程API的一個重要部分[3]。這個系統調用可以讓子進程執行與父進程不同的程序。例如,在p2.c中調用fork(),這只是在你想運行相同程序的拷貝時有用。但時,我們常常想運行不同的程序,exec()正好做這樣的事(見圖5.3)。

prompt> ./p3
hello world (pid:29383) 
hello, I am child (pid:29384)
      29     107   1030 p3.c
hello, I am parent of 29384 (wc:29384) (pid:29383) 
prompt>
1    #include <stdio.h>
2    #include <stdlib.h>
3    #include <unistd.h>
4    #include <string.h>
5    #include <sys/wait.h>
6
7    int
8    main(int argc, char *argv[])
9    {
10       printf("hello world (pid:%d)\n", (int) getpid());
11       int rc = fork();
12       if (rc < 0) {        // fork failed; exit
13           fprintf(stderr, "fork failed\n");
14           exit(1);
15       } else if (rc == 0) { // child (new process)
16           printf("hello, I am child (pid:%d)\n", (int) getpid());
17           char *myargs[3];
18           myargs[0] = strdup("wc");   // program: "wc" (word count)
19           myargs[1] = strdup("p3.c"); // argument: file to count
20           myargs[2] = NULL;          // marks end of array
21           execvp(myargs[0], myargs); // runs word count
22           printf("this shouldn't print out");
23       } else {    // parent goes down this path (main)
24           int wc = wait(NULL);
25           printf("hello, I am parent of %d (wc:%d) (pid:%d)\n",
26                   rc, wc, (int) getpid());
27       }
28       return 0;
29   }

圖5.3 調用fork()、wait()和exec()(p3.c)

在這個例子中,子進程調用execvp()來運行字符計數程序wc。實際上,它針對原始碼文件p3.c運行wc,從而告訴我們該文件有多少行、多少單詞,以及多少字節。

fork()系統調用很奇怪,它的夥伴exec()也不一般。給定可執行程序的名稱(如wc)及需要的參數(如p3.c)後,exec()會從可執行程序中加載代碼和靜態數據,並用它覆寫自己的代碼段(以及靜態數據),堆、棧及其他內存空間也會被重新初始化。然後作業系統就執行該程序,將參數通過argv傳遞給該進程。因此,它並沒有創建新進程,而是直接將當前運行的程序(以前的p3)替換為不同的運行程序(wc)。子進程執行exec()之後,幾乎就像p3.c從未運行過一樣。對exec()的成功調用永遠不會返回。

5.4 為什麼這樣設計API

當然,你的心中可能有一個大大的問號:為什麼設計如此奇怪的接口,來完成簡單的、創建新進程的任務?好吧,事實證明,這種分離fork()及exec()的做法在構建UNIX shell的時候非常有用,因為這給了shell在fork之後exec之前運行代碼的機會,這些代碼可以在運行新程序前改變環境,從而讓一系列有趣的功能很容易實現。

提示:重要的是做對事(LAMPSON定律) 

Lampson在他的著名論文《Hints for Computer Systems Design》[L83]中曾經說過:「做對事(Get it right)。抽象和簡化都不能替代做對事。」有時你必須做正確的事,當你這樣做時,總是好過其他方案。有許多方式來設計創建進程的API,但fork()和exec()的組合既簡單又極其強大。因此UNIX的設計師們做對了。因為Lampson經常「做對事」,所以我們就以他來命名這條定律。

shell也是一個用戶程序[4],它首先顯示一個提示符(prompt),然後等待用戶輸入。你可以向它輸入一個命令(一個可執行程序的名稱及需要的參數),大多數情況下,shell可以在文件系統中找到這個可執行程序,調用fork()創建新進程,並調用exec()的某個變體來執行這個可執行程序,調用wait()等待該命令完成。子進程執行結束後,shell從wait()返回並再次輸出一個提示符,等待用戶輸入下一條命令。

fork()和exec()的分離,讓shell可以方便地實現很多有用的功能。比如:

prompt> wc p3.c > newfile.txt

在上面的例子中,wc的輸出結果被重定向(redirect)到文件newfile.txt中(通過newfile.txt之前的大於號來指明重定向)。shell實現結果重定向的方式也很簡單,當完成子進程的創建後,shell在調用exec()之前先關閉了標準輸出(standard output),打開了文件newfile.txt。這樣,即將運行的程序wc的輸出結果就被發送到該文件,而不是列印在螢幕上。

圖5.4展示了這樣做的一個程序。重定向的工作原理,是基於對作業系統管理文件描述符方式的假設。具體來說,UNIX系統從0開始尋找可以使用的文件描述符。在這個例子中,STDOUT_FILENO將成為第一個可用的文件描述符,因此在open()被調用時,得到賦值。然後子進程向標準輸出文件描述符的寫入(例如通過printf()這樣的函數),都會被透明地轉向新打開的文件,而不是螢幕。

下面是運行p4.c的結果:

prompt> ./p4
prompt> cat p4.output
      32     109    846 p4.c
prompt>
1    #include <stdio.h>
2    #include <stdlib.h>
3    #include <unistd.h>
4    #include <string.h>
5    #include <fcntl.h>
6    #include <sys/wait.h>
7
8    int
9    main(int argc, char *argv[])
10   {
11       int rc = fork();
12       if (rc < 0) {    // fork failed; exit
13           fprintf(stderr, "fork failed\n");
14           exit(1);
15       } else if (rc == 0) { // child: redirect standard output to a file
16           close(STDOUT_FILENO);
17           open("./p4.output", O_CREAT|O_WRONLY|O_TRUNC, S_IRWXU);
18
19           // now exec "wc"...
20           char *myargs[3];
21           myargs[0] = strdup("wc");       // program: "wc" (word count)
22           myargs[1] = strdup("p4.c");      // argument: file to count
23           myargs[2] = NULL;               // marks end of array
24           execvp(myargs[0], myargs);     // runs word count
25       } else {                           // parent goes down this path (main)
26           int wc = wait(NULL);
27       }
28       return 0;
29   }

圖5.4 之前所有的工作加上重定向(p4.c)

關於這個輸出,你(至少)會注意到兩個有趣的地方。首先,當運行p4程序後,好像什麼也沒有發生。shell只是列印了命令提示符,等待用戶的下一個命令。但事實並非如此,p4確實調用了fork來創建新的子進程,之後調用execvp()來執行wc。螢幕上沒有看到輸出,是由於結果被重定向到文件p4.output。其次,當用cat命令列印輸出文件時,能看到運行wc的所有預期輸出。很酷吧?

UNIX管道也是用類似的方式實現的,但用的是pipe()系統調用。在這種情況下,一個進程的輸出被連結到了一個內核管道(pipe)上(隊列),另一個進程的輸入也被連接到了同一個管道上。因此,前一個進程的輸出無縫地作為後一個進程的輸入,許多命令可以用這種方式串聯在一起,共同完成某項任務。比如通過將grep、wc命令用管道連接可以完成從一個文件中查找某個詞,並統計其出現次數的功能:grep -o foo file | wc -l。

最後,我們剛才只是從較高的層面上簡單介紹了進程API,關於這些系統調用的細節,還有更多需要學習和理解。例如,在本書第 3 部分介紹文件系統時,我們會學習更多關於文件描述符的知識。現在,知道fork()和exec()組合在創建和操作進程時非常強大就足夠了。

補充:RTFM——閱讀man手冊 

很多時候,本書提到某個系統調用或庫函數時,會建議閱讀man手冊。man手冊是UNIX系統中最原生的文檔,要知道它的出現甚至早於網絡(Web)。

花時間閱讀man手冊是系統程式設計師成長的必經之路。手冊里有許多有用的隱藏彩蛋。尤其是你正在使用的shell(如tcsh或bash),以及程序中需要使用的系統調用(以便了解返回值和異常情況)。

最後,閱讀man手冊可以避免尷尬。當你詢問同事某個fork細節時,他可能會回覆:「RTFM」。這是他在有禮貌地督促你閱讀man手冊(Read the Man)。RTFM中的F只是為這個短語增加了一點色彩……

5.5 其他API

除了上面提到的fork()、exec()和wait()之外,在UNIX中還有其他許多與進程交互的方式。比如可以通過kill()系統調用向進程發送信號(signal),包括要求進程睡眠、終止或其他有用的指令。實際上,整個信號子系統提供了一套豐富的向進程傳遞外部事件的途徑,包括接受和執行這些信號。

此外還有許多非常有用的命令行工具。比如通過ps命令來查看當前在運行的進程,閱讀man手冊來了解ps命令所接受的參數。工具top也很有用,它展示當前系統中進程消耗CPU或其他資源的情況。有趣的是,你常常會發現top命令自己就是最占用資源的,它或許有一點自大狂。此外還有許多CPU檢測工具,讓你方便快速地了解系統負載。比如,我們總是讓MenuMeters(來自Raging Menace公司)運行在Mac計算機的工具欄上,這樣就能隨時了解當前的CPU利用率。一般來說,對現狀了解得越多越好。

5.6 小結

本章介紹了在UNIX系統中創建進程需要的API:fork()、exec()和wait()。更多的細節可以閱讀Stevens和Rago的著作 [SR05],尤其是關於進程控制、進程關係及信號的章節。其中的智慧讓人受益良多。

本文摘自《作業系統導論》

本書圍繞3個主題元素展開講解:虛擬化(virtualization)、並發(concurrency)和持久性(persistence)。對於這些概念的討論,最終延伸到討論作業系統所做的大多數重要事情。希望你在這個過程中體會到一些樂趣。學習新事物很有趣,對吧?

每個主要概念在若干章節中加以闡釋,其中大部分章節都提出了一個特定的問題,然後展示了解決它的方法。這些章節很簡短,嘗試(儘可能地)引用作為這些想法真正來源的源材料。我們寫這本書的目的之一就是釐清作業系統的發展脈絡,因為我們認為這有助於學生更清楚地理解過去是什麼、現在是什麼、將來會是什麼。在這種情況下,了解香腸的製作方法幾乎與了解香腸的優點一樣重要。

關鍵字: