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

程式運作後,你就能看到螢幕上交替出現子程序與父程序各列印出的一千條資訊了。如果程式還在運作中,你用ps指令就能看到系統中有兩個它在運作了。
那麼調用這個fork函數時發生了什麼呢?fork函數啟動一個新的程序,前面我們說過,這個程序幾乎是目前程序的一個拷貝:子程序和父程序使用相同的代碼段;子程序複制父程序的堆棧段和資料段。這樣,父程序的所有資料都可以留給子程序,但是,子程序一旦開始運作,雖然它繼承了父程序的一切資料,但實際上資料卻已經分開,互相之間不再有影響了,也就是說,它們之間不再共享任何資料了。它們再要互動資訊時,隻有通過程序間通信來實作,這将是我們下面的内容。既然它們如此相象,系統如何來區分它們呢?這是由函數的傳回值來決定的。對于父程序,fork函數傳回了子程式的程序号,而對于子程式,fork函數則傳回零。在作業系統中,我們用ps函數就可以看到不同的程序号,對父程序而言,它的程序号是由比它更低層的系統調用賦予的,而對于子程序而言,它的程序号即是fork函數對父程序的傳回值。在程式設計中,父程序和子程序都要調用函數fork()下面的代碼,而我們就是利用fork()函數對父子程序的不同傳回值用if...else...語句來實作讓父子程序完成不同的功能,正如我們上面舉的例子一樣。我們看到,上面例子執行時兩條資訊是互動無規則的列印出來的,這是父子程序獨立執行的結果,雖然我們的代碼似乎和串行的代碼沒有什麼差別。
讀者也許會問,如果一個大程式在運作中,它的資料段和堆棧都很大,一次fork就要複制一次,那麼fork的系統開銷不是很大嗎?其實UNIX自有其解決的辦法,大家知道,一般CPU都是以"頁"為機關來配置設定記憶體空間的,每一個頁都是實際實體記憶體的一個映像,象INTEL的CPU,其一頁在通常情況下是4086位元組大小,而無論是資料段還是堆棧段都是由許多"頁"構成的,fork函數複制這兩個段,隻是"邏輯"上的,并非"實體"上的,也就是說,實際執行fork時,實體空間上兩個程序的資料段和堆棧段都還是共享着的,當有一個程序寫了某個資料時,這時兩個程序之間的資料才有了差別,系統就将有差別的"頁"從實體上也分開。系統在空間上的開銷就可以達到最小。
下面示範一個足以"搞死"Linux的小程式,其源代碼非常簡單:
這個程式什麼也不做,就是死循環地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的使用。下面一段代碼顯示如何啟動運作其它程式:
此程式從終端讀入指令并執行之,執行完成後,父程序繼續等待從終端讀入指令。熟悉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]的輸入。下面的例子示範了如何在父程序和子程序間實作通信。
在Linux系統下,有名管道可由兩種方式建立:指令行方式mknod系統調用和函數mkfifo。下面的兩種途徑都在目前目錄下生成了一個名為myfifo的有名管道:
方式一:mkfifo("myfifo