Linux下Fork與Exec使用

一、引言

  對於沒有接觸過Unix/Linux操作系統的人來說,fork是最難理解的概念之一:它執行一次卻返回兩個值。fork函數是Unix系統最傑出的成就之一,它是七十年代UNIX早期的開發者經過長期在理論和實踐上的艱苦探索後取得的成果,一方面,它使操作系統在進程管理上付出了最小的代價,另一方面,又爲程序員提供了一個簡潔明瞭的多進程方法。與DOS和早期的Windows不同,Unix/Linux系統是真正實現多任務操作的系統,可以說,不使用多進程編程,就不能算是真正的Linux環境下編程。

  多線程程序設計的概念早在六十年代就被提出,但直到八十年代中期,Unix系統中才引入多線程機制,如今,由於自身的許多優點,多線程編程已經得到了廣泛的應用。

下面,我們將介紹在Linux下編寫多進程和多線程程序的一些初步知識。

二、多進程編程

什麼是一個進程?進程這個概念是針對系統而不是針對用戶的,對用戶來說,他面對的概念是程序。當用戶敲入命令執行一個程序的時候,對系統而言,它將啓動一個進程。但和程序不同的是,在這個進程中,系統可能需要再啓動一個或多個進程來完成獨立的多個任務。多進程編程的主要內容包括進程控制和進程間通信,在瞭解這些之前,我們先要簡單知道進程的結構。

  2.1 Linux下進程的結構

  Linux下一個進程在內存裏有三部分的數據,就是"代碼段"、"堆棧段"和"數據段"。其實學過彙編語言的人一定知道,一般的CPU都有上述三種段寄存器,以方便操作系統的運行。這三個部分也是構成一個完整的執行序列的必要的部分。

  "代碼段",顧名思義,就是存放了程序代碼的數據,假如機器中有數個進程運行相同的一個程序,那麼它們就可以使用相同的代碼段。"堆棧段"存放的就是子程序的返回地址、子程序的參數以及程序的局部變量。而數據段則存放程序的全局變量,常數以及動態數據分配的數據空間(比如用malloc之類的函數取得的空間)。這其中有許多細節問題,這裏限於篇幅就不多介紹了。系統如果同時運行數個相同的程序,它們之間就不能使用同一個堆棧段和數據段。  

  2.2 Linux下的進程控制

在傳統的Unix環境下,有兩個基本的操作用於創建和修改進程:函數fork( )用來創建一個新的進程,該進程幾乎是當前進程的一個完全拷貝;函數族exec( )用來啓動另外的進程以取代當前運行的進程。Linux的進程控制和傳統的Unix進程控制基本一致,只在一些細節的地方有些區別,例如在Linux系統中調用vfork和fork完全相同,而在有些版本的Unix系統中,vfork調用有不同的功能。由於這些差別幾乎不影響我們大多數的編程,在這裏我們不予考慮。

  2.2.1 fork()

  fork在英文中是"分叉"的意思。爲什麼取這個名字呢?因爲一個進程在運行中,如果使用了fork,就產生了另一個進程,於是進程就"分叉"了,所以這個名字取得很形象。下面就看看如何具體使用fork,這段程序演示了使用fork的基本框架:

 1void main()
 2{
 3    int i;
 4    if ( fork() == 0 ) 
 5    {
 6       /* 子進程程序 */
 7       for ( i = 1; i <1000; i ++ ) 
 8          printf("This is child process\n");
 9    }
10    else 
11    {
12       /* 父進程程序*/
13       for ( i = 1; i <1000; i ++ ) 
14       printf("This is process process\n");
15    }
16}

  程序運行後,你就能看到屏幕上交替出現子進程與父進程各打印出的一千條信息了。如果程序還在運行中,你用ps命令就能看到系統中有兩個它在運行了。

  那麼調用這個fork函數時發生了什麼呢?fork函數啓動一個新的進程,前面我們說過,這個進程幾乎是當前進程的一個拷貝:子進程和父進程使用相同的代碼段;子進程複製父進程的堆棧段和數據段。這樣,父進程的所有數據都可以留給子進程,但是,子進程一旦開始運行,雖然它繼承了父進程的一切數據,但實際上數據卻已經分開,相互之間不再有影響了,也就是說,它們之間不再共享任何數據了。它們再要交互信息時,只有通過進程間通信來實現,這將是我們下面的內容。既然它們如此相象,系統如何來區分它們呢?這是由函數的返回值來決定的。對於父進程, fork函數返回了子程序的進程號,而對於子程序,fork函數則返回零。在操作系統中,我們用ps函數就可以看到不同的進程號,對父進程而言,它的進程號是由比它更低層的系統調用賦予的,而對於子進程而言,它的進程號即是fork函數對父進程的返回值。在程序設計中,父進程和子進程都要調用函數fork()下面的代碼,而我們就是利用fork()函數對父子進程的不同返回值用if...else...語句來實現讓父子進程完成不同的功能,正如我們上面舉的例子一樣。我們看到,上面例子執行時兩條信息是交互無規則的打印出來的,這是父子進程獨立執行的結果,雖然我們的代碼似乎和串行的代碼沒有什麼區別。

  讀者也許會問,如果一個大程序在運行中,它的數據段和堆棧都很大,一次fork就要複製一次,那麼fork的系統開銷不是很大嗎?其實UNIX自有其解決的辦法,大家知道,一般CPU都是以"頁"爲單位來分配內存空間的,每一個頁都是實際物理內存的一個映像,象INTEL的CPU,其一頁在通常情況下是 4086字節大小,而無論是數據段還是堆棧段都是由許多"頁"構成的,fork函數複製這兩個段,只是"邏輯"上的,並非"物理"上的,也就是說,實際執行fork時,物理空間上兩個進程的數據段和堆棧段都還是共享着的,當有一個進程寫了某個數據時,這時兩個進程之間的數據纔有了區別,系統就將有區別的" 頁"從物理上也分開。系統在空間上的開銷就可以達到最小。

  下面演示一個足以"搞死"Linux的小程序,其源代碼非常簡單:

1void main()
2{
3   for( ; ; )
4   {
5     fork();
6   }
7}

  這個程序什麼也不做,就是死循環地fork,其結果是程序不斷產生進程,而這些進程又不斷產生新的進程,很快,系統的進程就滿了,系統就被這麼多不斷產生 的進程"撐死了"。當然只要系統管理員預先給每個用戶設置可運行的最大進程數,這個惡意的程序就完成不了企圖了。

 

2.2.2 exec( )函數族

下面我們來看看一個進程如何來啓動另一個程序的執行。在Linux中要使用exec函數族。系統調用execve()對當前進程進行替換,替換者爲一個指定的程序,其參數包括文件名(filename)、參數列表(argv)以及環境變量(envp)。exec函數族當然不止一個,但它們大致相同,在 Linux中,它們分別是:execl,execlp,execle,execv,execve和execvp,下面我只以execlp爲例,其它函數究竟與execlp有何區別,請通過manexec命令來了解它們的具體情況。

  一個進程一旦調用exec類函數,它本身就"死亡"了,系統把代碼段替換成新的程序的代碼,廢棄原有的數據段和堆棧段,併爲新程序分配新的數據段與堆棧段,唯一留下的,就是進程號,也就是說,對系統而言,還是同一個進程,不過已經是另一個程序了。(不過exec類函數中有的還允許繼承環境變量之類的信息。)

那麼如果我的程序想啓動另一程序的執行但自己仍想繼續運行的話,怎麼辦呢?那就是結合fork與exec的使用。下面一段代碼顯示如何啓動運行其它程序:

 1#include <errno.h>
 2#include <stdio.h>
 3#include <stdlib.h>
 4
 5char command[256];
 6void main()
 7{
 8   int rtn; /*子進程的返回數值*/
 9   while(1) {
10       /* 從終端讀取要執行的命令 */
11       printf( ">" );
12       fgets( command, 256, stdin );
13       command[strlen(command)-1= 0;
14       if ( fork() == 0 ) {/* 子進程執行此命令 */
15          execlp( command, NULL );
16          /* 如果exec函數返回,表明沒有正常執行命令,打印錯誤信息*/
17          perror( command );
18          exit( errno );
19       }
20       else {/* 父進程, 等待子進程結束,並打印子進程的返回值 */
21          wait ( &rtn );
22          printf( " child process return %d\n", rtn );
23       }
24   }
25}

  此程序從終端讀入命令並執行之,執行完成後,父進程繼續等待從終端讀入命令。熟悉DOS和WINDOWS系統調用的朋友一定知道DOS/WINDOWS也有exec類函數,其使用方法是類似的,但DOS/WINDOWS還有spawn類函數,因爲DOS是單任務的系統,它只能將"父進程"駐留在機器內再執行"子進程",這就是spawn類的函數。WIN32已經是多任務的系統了,但還保留了spawn類函數,WIN32中實現spawn函數的方法同前述 UNIX中的方法差不多,開設子進程後父進程等待子進程結束後才繼續運行。UNIX在其一開始就是多任務的系統,所以從核心角度上講不需要spawn類函數。

  在這一節裏,我們還要講講system()和popen()函數。system()函數先調用fork(),然後再調用exec()來執行用戶的登錄 shell,通過它來查找可執行文件的命令並分析參數,最後它麼使用wait()函數族之一來等待子進程的結束。函數popen()和函數 system()相似,不同的是它調用pipe()函數創建一個管道,通過它來完成程序的標準輸入和標準輸出。這兩個函數是爲那些不太勤快的程序員設計的,在效率和安全方面都有相當的缺陷,在可能的情況下,應該儘量避免。

 

 2.3 Linux下的進程間通信

  詳細的講述進程間通信在這裏絕對是不可能的事情,而且筆者很難有信心說自己對這一部分內容的認識達到了什麼樣的地步,所以在這一節的開頭首先向大家推薦著名作者Richard Stevens的著名作品:《Advanced Programming in the UNIX Environment》,它的中文譯本《UNIX環境高級編程》已有機械工業出版社出版,原文精彩,譯文同樣地道,如果你的確對在Linux下編程有濃厚的興趣,那麼趕緊將這本書擺到你的書桌上或計算機旁邊來。說這麼多實在是難抑心中的景仰之情,言歸正傳,在這一節裏,我們將介紹進程間通信最最初步和最最簡單的一些知識和概念。

  首先,進程間通信至少可以通過傳送打開文件來實現,不同的進程通過一個或多個文件來傳遞信息,事實上,在很多應用系統裏,都使用了這種方法。但一般說來,進程間通信(IPC:InterProcess Communication)不包括這種似乎比較低級的通信方法。Unix系統中實現進程間通信的方法很多,而且不幸的是,極少方法能在所有的Unix系統中進行移植(唯一一種是半雙工的管道,這也是最原始的一種通信方式)。而Linux作爲一種新興的操作系統,幾乎支持所有的Unix下常用的進程間通信方法:管道、消息隊列、共享內存、信號量、套接口等等。下面我們將逐一介紹。

  2.3.1 管道

  管道是進程間通信中最古老的方式,它包括無名管道和有名管道兩種,前者用於父進程和子進程間的通信,後者用於運行於同一臺機器上的任意兩個進程間的通信。

  無名管道由pipe()函數創建:

  #include <unistd.h>

  int pipe(int filedis[2]);

  參數filedis返回兩個文件描述符:filedes[0]爲讀而打開,filedes[1]爲寫而打開。filedes[1]的輸出是filedes[0]的輸入。下面的例子示範瞭如何在父進程和子進程間實現通信。

 1 #define INPUT 0
 2 #define OUTPUT 1
 3 
 4 void main() {
 5    int file_descriptors[2];
 6    /*定義子進程號 */
 7    pid_t pid;
 8    char buf[256];
 9    int returned_count;
10    /*創建無名管道*/
11    pipe(file_descriptors);
12    /*創建子進程*/
13    if((pid = fork()) == -1) {
14       printf("Error in fork\n");
15       exit(1);
16    }
17    /*執行子進程*/
18    if(pid == 0) {
19       printf("in the spawned (child) process...\n");
20       /*子進程向父進程寫數據,關閉管道的讀端*/
21       close(file_descriptors[INPUT]);
22       write(file_descriptors[OUTPUT], "test data", strlen("test data"));
23       exit(0);
24    } else {
25       /*執行父進程*/
26       printf("in the spawning (parent) process...\n");
27       /*父進程從管道讀取子進程寫的數據,關閉管道的寫端*/
28       close(file_descriptors[OUTPUT]);
29       returned_count = read(file_descriptors[INPUT], buf, sizeof(buf));
30       printf("%d bytes of data received from spawned process: %s\n",
31       returned_count, buf);
32    }
33 }

 在Linux系統下,有名管道可由兩種方式創建:命令行方式mknod系統調用和函數mkfifo。下面的兩種途徑都在當前目錄下生成了一個名爲myfifo的有名管道:

  方式一:mkfifo("myfifo","rw");

  方式二:mknod myfifo p

  生成了有名管道後,就可以使用一般的文件I/O函數如open、close、read、write等來對它進行操作。下面即是一個簡單的例子,假設我們已經創建了一個名爲myfifo的有名管道。

 1/* 進程一:讀有名管道*/
 2#include <stdio.h>
 3#include <unistd.h>
 4void main() {
 5    FILE * in_file;
 6    int count = 1;
 7    char buf[80];
 8    in_file = fopen("mypipe""r");
 9    if (in_file == NULL) {
10        printf("Error in fdopen.\n");
11        exit(1);
12    }
13    while ((count = fread(buf, 180, in_file)) > 0)
14        printf("received from pipe: %s\n", buf);
15    fclose(in_file);
16}
17/* 進程二:寫有名管道*/
18#include <stdio.h>
19#include <unistd.h>
20void main() {
21    FILE * out_file;
22    int count = 1;
23    char buf[80];
24    out_file = fopen("mypipe""w");
25    if (out_file == NULL) {
26        printf("Error opening pipe.");
27        exit(1);
28    }
29    sprintf(buf,"this is test data for the named pipe example\n");
30    fwrite(buf, 180, out_file);
31    fclose(out_file);
32}
33

 

  2.3.2 消息隊列

    消息隊列用於運行於同一臺機器上的進程間通信,它和管道很相似,事實上,它是一種正逐漸被淘汰的通信方式,我們可以用流管道或者套接口的方式來取代它,所以,我們對此方式也不再解釋,也建議讀者忽略這種方式。

  2.3.3 共享內存

  共享內存是運行在同一臺機器上的進程間通信最快的方式,因爲數據不需要在不同的進程間複製。通常由一個進程創建一塊共享內存區,其餘進程對這塊內存區進行讀寫。得到共享內存有兩種方式:映射/dev/mem設備和內存映像文件。前一種方式不給系統帶來額外的開銷,但在現實中並不常用,因爲它控制存取的將是實際的物理內存,在Linux系統下,這隻有通過限制Linux系統存取的內存纔可以做到,這當然不太實際。常用的方式是通過shmXXX函數族來實現利用共享內存進行存儲的。

  首先要用的函數是shmget,它獲得一個共享存儲標識符。

  #include <sys/types.h>

  #include <sys/ipc.h>

  #include <sys/shm.h>

  int shmget(key_t key, int size, int flag);

  這個函數有點類似大家熟悉的malloc函數,系統按照請求分配size大小的內存用作共享內存。Linux系統內核中每個IPC結構都有的一個非負整數的標識符,這樣對一個消息隊列發送消息時只要引用標識符就可以了。這個標識符是內核由IPC結構的關鍵字得到的,這個關鍵字,就是上面第一個函數的 key。數據類型key_t是在頭文件sys/types.h中定義的,它是一個長整形的數據。在我們後面的章節中,還會碰到這個關鍵字。

  當共享內存創建後,其餘進程可以調用shmat()將其連接到自身的地址空間中。

  void *shmat(int shmid, void *addr, int flag);

  shmid爲shmget函數返回的共享存儲標識符,addr和flag參數決定了以什麼方式來確定連接的地址,函數的返回值即是該進程數據段所連接的實際地址,進程可以對此進程進行讀寫操作。

  使用共享存儲來實現進程間通信的注意點是對數據存取的同步,必須確保當一個進程去讀取數據時,它所想要的數據已經寫好了。通常,信號量被要來實現對共享存儲數據存取的同步,另外,可以通過使用shmctl函數設置共享存儲內存的某些標誌位如SHM_LOCK、SHM_UNLOCK等來實現。

  2.3.4 信號量

  信號量又稱爲信號燈,它是用來協調不同進程間的數據對象的,而最主要的應用是前一節的共享內存方式的進程間通信。本質上,信號量是一個計數器,它用來記錄對某個資源(如共享內存)的存取狀況。一般說來,爲了獲得共享資源,進程需要執行下列操作:

  (1) 測試控制該資源的信號量。

  (2) 若此信號量的值爲正,則允許進行使用該資源。進程將進號量減1。

  (3) 若此信號量爲0,則該資源目前不可用,進程進入睡眠狀態,直至信號量值大於0,進程被喚醒,轉入步驟(1)。

  (4) 當進程不再使用一個信號量控制的資源時,信號量值加1。如果此時有進程正在睡眠等待此信號量,則喚醒此進程。

  維護信號量狀態的是Linux內核操作系統而不是用戶進程。我們可以從頭文件/usr/src/linux/include /linux /sem.h 中看到內核用來維護信號量狀態的各個結構的定義。信號量是一個數據集合,用戶可以單獨使用這一集合的每個元素。要調用的第一個函數是semget,用以獲得一個信號量ID。

  #include <sys/types.h>

  #include <sys/ipc.h>

  #include <sys/sem.h>

  int semget(key_t key, int nsems, int flag);

  key是前面講過的IPC結構的關鍵字,它將來決定是創建新的信號量集合,還是引用一個現有的信號量集合。    nsems是該集合中的信號量數。如果是創建新集合(一般在服務器中),則必須指定nsems;如果是引用一個現有的信號量集合(一般在客戶機中)則將nsems指定爲0。

  semctl函數用來對信號量進行操作。

  int semctl(int semid, int semnum, int cmd, union semun arg);

  不同的操作是通過cmd參數來實現的,在頭文件sem.h中定義了7種不同的操作,實際編程時可以參照使用。

semop函數自動執行信號量集合上的操作數組。

  int semop(int semid, struct sembuf semoparray[], size_t nops);

  semoparray是一個指針,它指向一個信號量操作數組。nops規定該數組中操作的數量。

  下面,我們看一個具體的例子,它創建一個特定的IPC結構的關鍵字和一個信號量,建立此信號量的索引,修改索引指向的信號量的值,最後我們清除信號量。在下面的代碼中,函數ftok生成我們上文所說的唯一的IPC關鍵字。

 1#include <stdio.h>
 2#include <sys/types.h>
 3#include <sys/sem.h>
 4#include <sys/ipc.h>
 5void main() {
 6    key_t unique_key; /* 定義一個IPC關鍵字*/
 7    int id;
 8    struct sembuf lock_it;
 9    union semun options;
10    int i;
11
12    unique_key = ftok("."'a'); /* 生成關鍵字,字符'a'是一個隨機種子*/
13    /* 創建一個新的信號量集合*/
14    id = semget(unique_key, 1, IPC_CREAT | IPC_EXCL | 0666);
15    printf("semaphore id=%d\n", id);
16    options.val = 1/*設置變量值*/
17    semctl(id, 0, SETVAL, options); /*設置索引0的信號量*/
18
19    /*打印出信號量的值*/
20    i = semctl(id, 0, GETVAL, 0);
21    printf("value of semaphore at index 0 is %d\n", i);
22
23    /*下面重新設置信號量*/
24    lock_it.sem_num = 0/*設置哪個信號量*/
25    lock_it.sem_op = -1/*定義操作*/
26    lock_it.sem_flg = IPC_NOWAIT; /*操作方式*/
27    if (semop(id, &lock_it, 1== -1) {
28        printf("can not lock semaphore.\n");
29        exit(1);
30    }
31
32    i = semctl(id, 0, GETVAL, 0);
33    printf("value of semaphore at index 0 is %d\n", i);
34
35    /*清除信號量*/
36    semctl(id, 0, IPC_RMID, 0);
37}

 

  2.3.5 套接口

  套接口(socket)編程是實現Linux系統和其他大多數操作系統中進程間通信的主要方式之一。我們熟知的WWW服務、FTP服務、TELNET服務等都是基於套接口編程來實現的。除了在異地的計算機進程間以外,套接口同樣適用於本地同一臺計算機內部的進程間通信。關於套接口的經典教材同樣是 Richard Stevens編著的《Unix網絡編程:聯網的API和套接字》,清華大學出版社出版了該書的影印版。它同樣是Linux程序員的必備書籍之一。

  關於這一部分的內容,可以參照本文作者的另一篇文章《設計自己的網絡螞蟻》,那裏由常用的幾個套接口函數的介紹和示例程序。這一部分或許是Linux進程間通信編程中最須關注和最吸引人的一部分,畢竟,Internet 正在我們身邊以不可思議的速度發展着,如果一個程序員在設計編寫他下一個程序的時候,根本沒有考慮到網絡,考慮到Internet,那麼,可以說,他的設計很難成功。

 

  3 Linux的進程和Win32的進程/線程比較

  熟悉WIN32編程的人一定知道,WIN32的進程管理方式與Linux上有着很大區別,在UNIX裏,只有進程的概念,但在WIN32裏卻還有一個"線程"的概念,那麼Linux和WIN32在這裏究竟有着什麼區別呢?

  WIN32裏的進程/線程是繼承自OS/2的。在WIN32裏,"進程"是指一個程序,而"線程"是一個"進程"裏的一個執行"線索"。從核心上講,WIN32的多進程與Linux並無多大的區別,在WIN32裏的線程才相當於Linux的進程,是一個實際正在執行的代碼。但是,WIN32裏同一個進程裏各個線程之間是共享數據段的。這纔是與Linux的進程最大的不同。

  下面這段程序顯示了WIN32下一個進程如何啓動一個線程。

 1 int g;
 2 DWORD WINAPI ChildProcess( LPVOID lpParameter ){
 3     int i;
 4     for ( i = 1; i <1000; i ++) {
 5         g ++;
 6         printf( "This is Child Thread: %d\n", g );
 7     }
 8     ExitThread( 0 );
 9 };
10 
11 void main()
12 {
13     int threadID;
14     int i;
15     g = 0;
16     CreateThread( NULL, 0, ChildProcess, NULL, 0&threadID );
17     for ( i = 1; i <1000; i ++) {
18         g ++;
19         printf( "This is Parent Thread: %d\n", g );
20     }
21 }

在WIN32下,使用CreateThread函數創建線程,與Linux下創建進程不同,WIN32線程不是從創建處開始運行的,而是由 CreateThread指定一個函數,線程就從那個函數處開始運行。此程序同前面的UNIX程序一樣,由兩個線程各打印1000條信息。 threadID是子線程的線程號,另外,全局變量g是子線程與父線程共享的,這就是與Linux最大的不同之處。大家可以看出,WIN32的進程/線程要比Linux複雜,在Linux要實現類似WIN32的線程並不難,只要fork以後,讓子進程調用ThreadProc函數,並且爲全局變量開設共享數據區就行了,但在WIN32下就無法實現類似fork的功能了。所以現在WIN32下的C語言編譯器所提供的庫函數雖然已經能兼容大多數 Linux/UNIX的庫函數,但卻仍無法實現fork。

 

對於多任務系統,共享數據區是必要的,但也是一個容易引起混亂的問題,在WIN32下,一個程序員很容易忘記線程之間的數據是共享的這一情況,一個線程修改過一個變量後,另一個線程卻又修改了它,結果引起程序出問題。但在Linux下,由於變量本來並不共享,而由程序員來顯式地指定要共享的數據,使程序變得更清晰與安全。

至於WIN32的"進程"概念,其含義則是"應用程序",也就是相當於UNIX下的exec了。

Linux也有自己的多線程函數pthread,它既不同於Linux的進程,也不同於WIN32下的進程,關於pthread的介紹和如何在Linux環境下編寫多線程程序我們將在另一篇文章《Linux下的多線程編程》中講述。

 


發佈了18 篇原創文章 · 獲贊 2 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章