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 " );
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
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
#include
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
#include
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
#include
#include
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
#include
#include
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
#include
#include
#include
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的進程最大的不同。
1. Linux與MS-DOS的區別
在同一系統上運行Linux和MS-DOS已很普遍,但它們之間還是有較多區別的。
就發揮處理器功能來說,MS-DOS沒有完全發揮x86處理器的功能,而Linux完全在處理器保護模式下運行,並且發揮了處理器的所有特性。Linux可以直接存取電腦內的所有可用記憶體,提供完整的Unix介面,而MS-DOS只支援部分Unix的介面。
就使用費用而言,Linux和MS-DOS是兩種完全不同的實體。與其他商業作業系統相比,MS-DOS價格比較便宜,而且在PC機使用者中有很大的佔有率,任何其他PC機作業系統都很難達到MS-DOS的普及程度,因為其他動作系統的費用對大多數PC機使用者來說都是一個不小的負擔,而Linux是免費的,使用者可以從Internet上或者其他途徑獲得它的版本,而且可以任意使用,不用考慮費用問題。
就作業系統的功能來說,MS-DOS是單任務的作業系統,一旦使用者運行了一個MS-DOS的應用程式,它就獨佔了系統的資源,使用者不可能再同時運行其他應用程式,而Linux是多任務的作業系統,使用者可以同時運行多個應用程式。
2. Linux與OS/2、Windows的區別 從發展的背景看,Linux與其他動作系統區別在於:Linux是從一個比較成熟的作業系統發展而來的,而其他動作系統(如Windows NT、Windows 2000等)都是自成體系,無對應的相依託的作業系統。這一區別使得Linux的使用者能大大地從Unix團體貢獻中獲利。因為Unix是當今世界上使用最普遍、發展最成熟的作業系統之一,它是20世紀70年代中期發展起來的微機和巨型機的多任務系統,雖然有時介面比較混亂,並缺少相對集中的標準,但還是逐步發展壯大成為最廣泛使用的作業系統之一。
無論是Unix的作者還是Unix的使用者,都認為只有Unix才是一個真正的作業系統,許多電腦系統(從個人電腦到超級電腦)都存在Unix版本,Unix的使用者可以從很多方面得到支援和協助。因此,Linux作為Unix的一個複製,它的使用者同樣會得到相應的支援和協助,Linux將直接擁有Unix在使用者中建立的牢固地位。
從使用費用上看,Linux與其他動作系統的區別在於:Linux是一種開放、免費的作業系統,而其他動作系統都是封閉的系統,需要有償使用。這一區別使得我們不用花錢就能得到很多Linux的版本以及為其開發的應用軟體。當我們訪問Internet時,會發現幾乎所有可用的自由軟體都能夠運行在Linux系統上,不同軟體商對這些軟體有不同的Unix實現方法。Unix的開發、發展商以開放系統的方式推動其標準化,但卻沒有一個公司來控制這種設計。
因此,任何一個軟體商(或開拓者)都能在某種Unix中實現這些標準。而OS/2和Windows等作業系統是具有著作權的產品,其介面和設計均由某一公司控制,而且只有這些公司才有權實現其設計,它們都是在封閉的環境下發展的。