Linux下的多進程編程初步

來源:互聯網
上載者:User
文章摘要:
   多線程程式設計的概念早在六十年代就被提出,但直到八十年代中期,Unix系統中才引入多線程機制,如今,由於自身的許多優點,多線程編程已經得到了廣泛的應用。本文我們將介紹在Linux下編寫多進程和多線程程式的一些初步知識。

--------------------------------------------------------------------------------

本文:
Linux下的多進程編程初步

1 引言
   對於沒有接觸過Unix/Linux作業系統的人來說,fork是最難理解的概念之一:它執行一次卻返回兩個值。fork函數是Unix系統最傑出的成就之一,它是七十年代UNIX早期的開發人員經過長期在理論和實踐上的艱苦探索後取得的成果,一方面,它使作業系統在進程管理上付出了最小的代價,另一方面,又為程式員提供了一個簡潔明了的多進程方法。與DOS和早期的Windows不同,Unix/Linux系統是真正實現多任務操作的系統,可以說,不使用多進程編程,就不能算是真正的Linux環境下編程。
   多線程程式設計的概念早在六十年代就被提出,但直到八十年代中期,Unix系統中才引入多線程機制,如今,由於自身的許多優點,多線程編程已經得到了廣泛的應用。
   下面,我們將介紹在Linux下編寫多進程和多線程程式的一些初步知識。

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

  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的基本架構:

void main(){
int i;
if ( fork() == 0 ) {
/* 子進程程式 */
for ( i = 1; i <1000; i ++ ) printf("This is child process\n");
}
else {
/* 父進程程式*/
for ( i = 1; i <1000; i ++ ) printf("This is process process\n");
}
}
   程式運行後,你就能看到螢幕上交替出現子進程與父進程各列印出的一千條資訊了。如果程式還在運行中,你用ps命令就能看到系統中有兩個它在運行了。
   那麼調用這個fork函數時發生了什麼呢?fork函數啟動一個新的進程,前面我們說過,這個進程幾乎是當前進程的一個拷貝:子進程和父進程使用相同的程式碼片段;子進程複製父進程的堆棧段和資料區段。這樣,父進程的所有資料都可以留給子進程,但是,子進程一旦開始運行,雖然它繼承了父進程的一切資料,但實際上資料卻已經分開,相互之間不再有影響了,也就是說,它們之間不再共用任何資料了。它們再要互動資訊時,只有通過處理序間通訊來實現,這將是我們下面的內容。既然它們如此相象,系統如何來區分它們呢?這是由函數的傳回值來決定的。對於父進程,fork函數返回了子程式的進程號,而對於子程式,fork函數則返回零。在作業系統中,我們用ps函數就可以看到不同的進程號,對父進程而言,它的進程號是由比它更低層的系統調用賦予的,而對於子進程而言,它的進程號即是fork函數對父進程的傳回值。在程式設計中,父進程和子進程都要調用函數fork()下面的代碼,而我們就是利用fork()函數對父子進程的不同傳回值用if...else...語句來實現讓父子進程完成不同的功能,正如我們上面舉的例子一樣。我們看到,上面例子執行時兩條資訊是互動無規則的列印出來的,這是父子進程獨立執行的結果,雖然我們的代碼似乎和串列的代碼沒有什麼區別。
   讀者也許會問,如果一個大程式在運行中,它的資料區段和堆棧都很大,一次fork就要複製一次,那麼fork的系統開銷不是很大嗎?其實UNIX自有其解決的辦法,大家知道,一般CPU都是以"頁"為單位來分配記憶體空間的,每一個頁都是實際實體記憶體的一個映像,象INTEL的CPU,其一頁在通常情況下是4086位元組大小,而無論是資料區段還是堆棧段都是由許多"頁"構成的,fork函數複製這兩個段,只是"邏輯"上的,並非"物理"上的,也就是說,實際執行fork時,物理空間上兩個進程的資料區段和堆棧段都還是共用著的,當有一個進程寫了某個資料時,這時兩個進程之間的資料才有了區別,系統就將有區別的"頁"從物理上也分開。系統在空間上的開銷就可以達到最小。
   下面示範一個足以"搞死"Linux的小程式,其原始碼非常簡單:
   void main()
   {
     for( ; ; ) fork();
   }
   這個程式什麼也不做,就是死迴圈地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的使用。下面一段代碼顯示如何啟動運行其它程式:

char command[256];
void main()
{
int rtn; /*子進程的返回數值*/
while(1) {
/* 從終端讀取要執行的命令 */
printf( ">" );
fgets( command, 256, stdin );
command[strlen(command)-1] = 0;
if ( fork() == 0 ) {
/* 子進程執行此命令 */
execlp( command, command );
/* 如果exec函數返回,表明沒有正常執行命令,列印錯誤資訊*/
perror( command );
exit( errorno );
}
else {
/* 父進程, 等待子進程結束,並列印子進程的傳回值 */
wait ( &rtn );
printf( " child process return %d\n",. rtn );
}
}
}

   此程式從終端讀入命令並執行之,執行完成後,父進程繼續等待從終端讀入命令。熟悉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]的輸入。下面的例子示範了如何在父進程和子進程間實現通訊。

#define INPUT 0
#define OUTPUT 1

void main() {
int file_descriptors[2];
/*定義子進程號 */
pid_t pid;
char buf[256];
int returned_count;
/*建立無名管道*/
pipe(file_descriptors);
/*建立子進程*/
if((pid = fork()) == -1) {
printf("Error in fork\n");
exit(1);
}
/*執行子進程*/
if(pid == 0) {
printf("in the spawned (child) process...\n");
/*子進程向父進程寫資料,關閉管道的讀端*/
close(file_descriptors[INPUT]);
write(file_descriptors[OUTPUT], "test data", strlen("test data"));
exit(0);
} else {
/*執行父進程*/
printf("in the spawning (parent) process...\n");
/*父進程從管道讀取子進程寫的資料,關閉管道的寫端*/
close(file_descriptors[OUTPUT]);
returned_count = read(file_descriptors[INPUT], buf, sizeof(buf));
printf("%d bytes of data received from spawned process: %s\n",
returned_count, buf);
}
}
   在Linux系統下,有名管道可由兩種方式建立:命令列方式mknod系統調用和函數mkfifo。下面的兩種途徑都在目前的目錄下產生了一個名為myfifo的有名管道:
     方式一:mkfifo("myfifo","rw");
     方式二:mknod myfifo p
   產生了有名管道後,就可以使用一般的檔案I/O函數如open、close、read、write等來對它進行操作。下面即是一個簡單的例子,假設我們已經建立了一個名為myfifo的有名管道。
  /* 進程一:讀有名管道*/
#include <stdio.h>
#include <unistd.h>
void main() {
FILE * in_file;
int count = 1;
char buf[80];
in_file = fopen("mypipe", "r");
if (in_file == NULL) {
printf("Error in fdopen.\n");
exit(1);
}
while ((count = fread(buf, 1, 80, in_file)) > 0)
printf("received from pipe: %s\n", buf);
fclose(in_file);
}
  /* 進程二:寫有名管道*/
#include <stdio.h>
#include <unistd.h>
void main() {
FILE * out_file;
int count = 1;
char buf[80];
out_file = fopen("mypipe", "w");
if (out_file == NULL) {
printf("Error opening pipe.");
exit(1);
}
sprintf(buf,"this is test data for the named pipe example\n");
fwrite(buf, 1, 80, out_file);
fclose(out_file);
}

   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關鍵字。

#include <stdio.h>
#include <sys/types.h>
#include <sys/sem.h>
#include <sys/ipc.h>
void main() {
key_t unique_key; /* 定義一個IPC關鍵字*/
int id;
struct sembuf lock_it;
union semun options;
int i;

unique_key = ftok(".", 'a'); /* 產生關鍵字,字元'a'是一個隨機種子*/
/* 建立一個新的訊號量集合*/
id = semget(unique_key, 1, IPC_CREAT | IPC_EXCL | 0666);
printf("semaphore id=%d\n", id);
options.val = 1; /*設定變數值*/
semctl(id, 0, SETVAL, options); /*設定索引0的訊號量*/

/*列印出訊號量的值*/
i = semctl(id, 0, GETVAL, 0);
printf("value of semaphore at index 0 is %d\n", i);

/*下面重新設定訊號量*/
lock_it.sem_num = 0; /*設定哪個訊號量*/
lock_it.sem_op = -1; /*定義操作*/
lock_it.sem_flg = IPC_NOWAIT; /*操作方式*/
if (semop(id, &lock_it, 1) == -1) {
printf("can not lock semaphore.\n");
exit(1);
}

i = semctl(id, 0, GETVAL, 0);
printf("value of semaphore at index 0 is %d\n", i);

/*清除訊號量*/
semctl(id, 0, IPC_RMID, 0);
}

   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下一個進程如何啟動一個線程。

int g;
DWORD WINAPI ChildProcess( LPVOID lpParameter ){
int i;
for ( i = 1; i <1000; i ++) {
g ++;
printf( "This is Child Thread: %d\n", g );
}
ExitThread( 0 );
};

void main()
{
int threadID;
int i;
g = 0;
CreateThread( NULL, 0, ChildProcess, NULL, 0, &threadID );
for ( i = 1; i <1000; i ++) {
g ++;
printf( "This is Parent Thread: %d\n", g );
}
}

   在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下的多線程編程》中講述。

4 鳴謝
   本文部分內容參照www.lisoleg.org內的《Linux下的多進程編程》,原作者俞磊。

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.