1、引言linux
對於沒有接觸過Unix/Linux操做系統的人來講,fork是最難理解的概念之一:它執行一次卻返回兩個值。fork函數是Unix系統最傑出的成就之一,它是七十年代UNIX早期的開發者通過長期在理論和實踐上的艱苦探索後取得的成果,一方面,它使操做系統在進程管理上付出了最小的代價,另外一方面,又爲程序員提供了一個簡潔明瞭的多進程方法。與DOS和早期的Windows不一樣,Unix/Linux系統是真正實現多任務操做的系統,能夠說,不使用多進程編程,就不能算是真正的Linux環境下編程。程序員
多線程程序設計的概念早在六十年代就被提出,但直到八十年代中期,Unix系統中才引入多線程機制,現在,因爲自身的許多優勢,多線程編程已經獲得了普遍的應用。shell
下面,咱們將介紹在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的基本框架:
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, 1, 80, 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, 1, 80, 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下的多線程編程》中講述。