天天看點

[轉載] Linux下的多程序程式設計初步

引言

對于沒有接觸過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 );

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;

int id;

struct sembuf lock_it;

union semun options;

int i;

unique_key = ftok(".", '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);

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了。

繼續閱讀