1.進程的涵義
什麼是一個進程?在操作系統原理使用這樣的術語來描述的:正在運行的程序及其佔用的資源(CPU、內存、系統資源等)叫做進 程。站在程序員的角度來看,我們使用vim編輯生成的C文件叫做源碼,源碼給程序員來看的但機器不識別,這時我們需要使用 編譯器gcc編譯生成CPU可識別的二進制可執行程序並保存在存儲介質上,這時編譯生成的可執行程序只能叫做程序而不能叫進 程。而一旦我們通過命令(./a.out)開始運行時,那正在運行的這個程序及其佔用的資源就叫做進程了。進程這個概念是針對系統 而不是針對用戶的,對用戶來說,他面對的概念是程序。很顯然,一個程序可以執行多次,這也意味着多個進程可以執行同一個 程序。
2.爲什麼要多進程編程
在我們平常寫的簡單的socket客戶端服務器程序,往往只能處理一個客戶的請求,他的實現簡單但是效率卻不高,通常這種服務器被稱爲迭代服務器。 然而在實際應用中,不可能讓一個服務器長時間 地爲一個客戶服務,而需要其具有同時處理 多個客戶請求的能力,這種同時可以處理多個客戶請求的服務器稱爲併發服務器,其 效率很 高卻實現複雜。在實際應用中,併發服務器應用的廣泛。linux有3種實現併發服務器的方式:多進程併發服務器,多線 程併發服務器,IO複用,這裏我們講的是多進程併發服務器的實現。
3.進程空間的內部佈局
在深入理解Linux下多進程編程之前,我們首先要了解Linux下進程在運行時的內存佈局。Linux 進程內存管理的對象都是虛擬 內存,每個進程先天就有 0-4G 的各自互不干涉的虛擬內存空間,0—3G 是用戶空間執行用戶自己的代碼, 高 1GB 的空間是內核 空間執行 Linu x 系統調用,這裏存放在整個內核的代碼和所有的內核模塊,用戶所看到和接觸的都是該虛擬地址,並不是實際 的物理內存地址。 Linux下一個進程在內存裏有三部分的數據,就是”代碼段”、”堆棧段”和”數據段”。其實學過彙編語言 的人一定知道,一般的CPU都有上述三種段寄存器,以方便操作系統的運行。這三個部分是構成一個完整的執行序列的必要的部 分。”代碼段”,顧名思義,就是存放了程序代碼的數據,假如機器中有數個進程運行相同的一個程序,那麼它們就可以使用相
同的代碼段。”堆棧段”存放的就是子程 序的返回地址、子程序的參數以及程序的局部變量和malloc()動態申請內存的地址。而 數據段則存放程序的全局變量,靜態變量及常量的內存空間。
下圖爲大概佈局:
棧區:棧內存由編譯器在程序編譯階段完成,進程的棧空間位於進程用戶空間的頂部並且是向下增長,每個函數的每次調用 都會在棧空間中開闢自己的棧空間,函數參數、局部變量、函數返回地址等都會按照先入者爲棧頂的順序壓入函數棧中, 函數返回後該函數的棧空間消失,所以函數中返回局部變量的地址都是非法的。
堆區:堆內存是在程序執行過程中分配的,用於存放進程運行中被動態分配的的變量,大小並不固定,堆位於非初始化數據 段和棧之間,並且使用過程中是向棧空間靠近的。當進程調用 malloc 等函數分配內存時,新分配的內存並不是該函數的 棧幀中,而是被動態添加到堆上,此時堆就向高地址擴張;當利用 free 等函數釋放內存時,被釋放的內存從堆中被踢 出,堆就會縮減。因爲動態分配的內存並不在函數棧幀中,所以即使函數返回這段內存也是不會消失。
非初始化數據段:通常將此段稱爲 bss 段,用來存放未初始化的全局變量和 static 靜態變量。並且在程序開始執行之前, 就是在 main()之前,內核會將此段中的數據初始化爲 0 或空指針。
初始化數據段:用來保已初始化的全局變量和 static 靜態變量。
Linux 內存管理的基本思想就是隻有在真正訪問一個地址的時候才建立這個地址的物理映射,Linux C/C++語言的分配方式共有 3 種方式。 (1)從靜態存儲區域分配。就是數據段的內存分配,這段內存在程序編譯階段就已經分配好,在程序的整個運行期間都存在, 例如全局變量,static 變量。 (2)在棧上創建。在執行函數時,函數內局部變量的存儲單元都可以在棧上創建,函數執行結束時這些存儲單元自動被釋放。 棧內存分配運算內置於處理器的指令集中,效率很高,但是系統棧中分配的內存容量有限,比如大額數組就會把棧空間撐爆導致
段錯誤。 (3)從堆上分配,亦稱動態內存分配。程序在運行的時候用 malloc 或 new 申請任意多少的內存,程序員自己負責在何時用 free 或 delete 釋放內存。此 區域內存分配稱之爲動態內存分配。動態內存的生存期由我們決定,使用非常靈活,但問題也 多,比如指向某個內存塊的指針取值發生了變化又沒有其他指針指向 這塊內存,這塊內存就無法訪問,發生內存泄露。
4.fork()系統調用
在多進程編程中fork()系統調用是主要的函數,它的作用是在父程序的基礎上來創建一個子程序,來實現多個進程的運行。當然了,相同的函數還有vfork()函數,但常用的是fork()函數,而比較方便。
Linux內核在啓動的後階段會創建init進程來執行程序/sbin/init,該進程是系統運行的第一個進程,進程號爲 1,稱爲 Linux 系統的初始化進程,該進程會創建其他子進程來啓動不同寫系統服務,而每個服務又可能創建不同的子進程來執行不同的 程序。所以init進程是所有其他進程的“祖先”,並且它是由Linux內核創建並以root的權限運行,並不能被殺死。Linux 中維護 着一個數據結構叫做 進程表,保存當前加載在內存中的所有進程的有關信息,其中包括進程的 PID(Process ID)、進程的狀態、 命令字符串等,操作系統通過進程的 PID 對它們進行管理,這些 PID 是進程表的索引。 Linux下有兩個基本的系統調用可以用於創建子進程:fork()和vfork()。fork在英文中是"分叉"的意思。爲什麼取這個名字呢? 因爲一個進程在運行中,如果使用了fork,就產生了另一個進程,於是進程就”分叉”了,所以這個名字取得很形象。在我們編 程的過程中,一個函數調用只有一次返回(return),但由於fork()系統調用會創建一個新的進程,這時它會有兩次返回。一次返回 是給父進程,其返回值是子進程的PID(Process ID),第二次返回是給子進程,其返回值爲0。所以我們在調用fork()後,需要通 過其返回值來判斷當前的代碼是在父進程還是子進程運行,如果返回值是0說明現在是子進程在運行,如果返回值>0說明是父進 程在運行,而如果返回值<0的話,說明fork()系統調用出錯。fork 函數調用失敗的原因主要有兩個:
- 系統中已經有太多的進 程;
- 該實際用戶 ID 的進程總數超過了系統限制。
每個子進程只有一個父進程,並且每個進程都可以通過getpid()獲取自己的進程PID,也可以通過getppid()獲取父進程的 PID,這樣在fork()時返回0給子進程是可取的。一個進程可以創建多個子進程,這樣對於父進程而言,他並沒有一個API函數可 以獲取其子進程的進程ID,所以父進程在通過fork()創建子進程的時候,必須通過返回值的形式告訴父進程其創建的子進程PID。 這也是fork()系統調用兩次返回值設計的原因。
下面通過一段代碼來了解fork()函數:
#include<stdio.h>
#include<errno.h>
#include<unistd.h>
#include<string.h>
int g_var = 6;
char g_buf[] = "A string write to stdout.\n"
int main(int argc, char **argv)
{
int var = 88;
pid_t pid;
if(write(STDOUT_FILENO, g_buf, sizeof(g_buf)-1)<0)
{
printf("Write string to stdout error:%s\n",strerror(errno));
return -1;
}
printf("Before fork\n");
if((pid=fork())<0)
{
printf("fork() error:%s\n",strerror(errno));
return -2;
}
else if(0==pid)
{
printf("Child process PID[%d] running...\n",getpid());
g_var++;
var++;
}
else
{
printf("Parent process PID[%d] waiting...\n");
sleep(1)
}
printf("PID=%1d, g_var=%d,var=%d\n",(long)getgid(),g_var,var);
return 0;
}
代碼運行結果:
在上面的編譯運行過程我們可以看到,父進程在代碼第21行創建了子進程後,系統會將父進程的文本段、數據段、堆棧都拷貝 一份給子進程,這樣子進程也就繼承了父進程數據段中的的全局變量g_var和局部變量var的值。
- 因爲進程創建之後究竟是父進程還是子進程先運行沒有規定,所以父進程在第35行調用了sleep(1)的目的是希望讓子進程 先運行,但這個機制是不能100%確定能讓子進程先執行,如果系統負載較大時1秒的時間內操作系統可能還沒調度到子進 程運行,所以sleep()這個機制並不可靠,這時候我們需要使用到今後學習的進程間通信機制來實現這種父子進程之間的同 步問題;
- 程序中38行的printf()被執行了兩次,這是因爲fork()之後,子進程會複製父進程的代碼段,這樣38行的代碼也被複制給子 進程了。而子進程在運行到第30行後並沒有調用return()或exit()函數讓進程退出,所以程序會繼續執行到38行至39行調 用return 0退出子進程;同理父進程也是執行38行至39行才讓父進程退出,所以38行的printf()分別被父子進程執行了兩 次。
- 子進程在第29行和30行改變了這兩個變量的值,這個改變隻影響子進程的空間的值,並不會影響父進程的內存空間,所 以子進程裏g_var和var分別變成了7和89,而父進程的g_var和var都沒改變。
子進程繼承父進程的哪些東西
- 進程的資格(真實(real)/有效(effective)/已保存(saved) 用戶號(UIDs)和組號(GIDs))
- 環境(environment)變量
- 堆棧
- 內存
- 打開文件的描述符(注意對應的文件的位置由父子進程共享, 這會引起含糊情況)
- 執行時關閉(close-on-exec) 標誌
- 信號(signal)控制設定
- nice值 (譯者注:nice值由nice函數設定,該值表示進程的優先級, 數值越小,優先級越高)
- 進程調度類別(scheduler class)優先級高的進程優 先執行)
- 進程組號
- 對話期ID(Session ID) (譯者注:譯文取自《高級編程》,指:進程所屬的對話期 (session)ID, 一個對話期包括一個或多 個進程組, 更詳細說明參見《APUE》 9.5節)
- 當前工作目錄
- 根目錄 (根目錄不一定是“/”,它可由chroot函數改變)
- 文件方式創建屏蔽字(file mode creation mask (umask))
- 資源限制
- 控制終端
子進程所獨有的
- 進程號
- 不同的父進程號(譯者注: 即子進程的父進程號與父進程的父進程號不同, 父進程號可由getppid函數得到)
- 自己的文件描述符和目錄流的拷貝(譯者注: 目錄流由opendir函數創建,因其爲順序讀取,顧稱“目錄流”)
- 子進程不繼承父進程的進程,正文(text), 數據和其它鎖定內存(memory locks) (譯者注:鎖定內存指被鎖定的虛擬內存 頁,鎖定後, 不允許內核將其在必要時換出(page out), 詳細說明參見《The GNU C Library Reference Manual》 2.2 版, 1999, 3.4.2節)
- 在tms結構中的系統時間(譯者注:tms結構可由times函數獲得, 它保存四個數據用於記錄進程使用中央處理器 (CPU: Central Processing Unit)的時間,包括:用戶時間,系統時間, 用戶各子進程合計時間,系統各子進程合計時間)
- 資源使用(resource utilizations)設定爲0
- 阻塞信號集初始化爲空集(譯者注:原文此處不明確, 譯文根據fork函數手冊頁稍做修改)
- 不繼承由timer_create函數創建的計時器
- 不繼承異步輸入和輸出
- 父進程設置的鎖(因爲如果是排他鎖,被繼承的話就矛盾了)
5.vfork()系統調用
在上面的例子中我們可以看到,在fork()之後常會緊跟着調用exec來執行另外一個程序,而exec會拋棄父進程的文本段、數據 段和堆棧等並加載另外一個程序,所以現在的很多fork()實現並不執行一個父進程數據段、堆和棧的完全副本拷貝。作爲替代, 使用了寫時複製(CopyOnWrite)技術: 這些數據區域由父子進程共享,內核將他們的訪問權限改成只讀,如果父進程和子進程 中的任何一個試圖修改這些區域的時候,內核再爲修改區域的那塊內存製作一個副本。
vfork()是另外一個可以用來創建進程的函數,他與fork()的用法相同,也用於創建一個新進程。 但vfork()並不將父進程的地址 空間完全複製到子進程中,因爲子進程會立即調用exec或exit(),於是也就不會引用該地址空間了。不過子進程再調用exec()或 exit()之前,他將在父進程的空間中運行,但如果子進程想嘗試修改數據域(數據段、堆、棧)都會帶來未知的結果,因爲他會影響 了父進程空間的數據可能會導致父進程的執行異常。此外,vfork()會保證子進程先運行,在他調用了exec或exit()之後父進程才 可能被調度運行。如果子進程依賴於父進程的進一步動作,則會導致死鎖。
vfork()函數原型
#include<unistd.h>
#include<sys/tpyes.h>
pid_t vfork(void);
6.wait()與waitpid()
當一個進程正常或異常退出時,內核就會向其父進程發送SIGCHLD信號。因爲子進程退出是一個異步事件,所以這種信號也 是內核向父進程發送的一個異步通知。父進程可以選擇忽略該信號,或者提供一個該信號發生時即將被執行的函數,父進程可以 調用wait()或waitpid()可以用來查看子進程退出的狀態。
pid_t wait(int *static);
pid_t waitpid(pid_t pid, int *static, int optiona);
在一個子進程終止前,wait使其調用者阻塞,而waitpid有一選項可使調用者不用阻塞。 waitpid並不等待在其調用的之後的 第一個終止進程,他有若干個選項,可以控制他所等待的進程。 如果一個已經終止、但其父進程尚未對其調用wait進行善後處理 (獲取終止子進程的有關信息如CPU時間片、釋放它鎖佔用的資源如文件描述符等)的進程被稱僵死進程(zombie),ps命令將僵死 進程的狀態打印爲Z。如果子進程已經終止,並且是一個僵死進程,則wait立即返回該子進程的狀態。所以,我們在編寫多進程 程序時,好調用wait()或waitpid()來解決殭屍進程的問題。
此外,如果父進程在子進程退出之前退出了,這時候子進程就變成了孤兒進程。當然每一個進程都應該有一個獨一無二的父進 程,init進程就是這樣的一個“慈父”,Linux內核中所有的子進程在變成孤兒進程之後都會被init進程“領養”,這也意味着孤 兒進程的父進程終會變成init進程。
7.多進程改寫服務器程序
流程圖:
代碼示例:
#include<stdio.h>
#include<errno.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<stdlib.h>
#include<getopt.h>
#define WXJ "hello wangruijie\n"
void print_usage(char *progname)
{
printf("%s usage:\n",progname);
printf("-p(--port):sepcify server listen port.\n");
printf("-h(--help):print this help information.\n");
return;
}
int main(int argc, char **argv)
{
int sockfd = -1;
int rv = -1;
struct sockaddr_in servaddr;
struct sockaddr_in cliaddr;
socklen_t len;
int port = 0;
int clifd;
int ch;
int on=1;
pid_t pid;
struct option opts[]=
{
{"port", required_argument, NULL, 'p'},
{"help", no_argument, NULL, 'h'},
{NULL, 0, NULL, 0}
};
while((ch=getopt_long(argc,argv,"p:h", opts,NULL))!=-1)
{
switch(ch)
{
case 'p':
port=atoi(optarg);
break;
case 'h':
print_usage(argv[0]);
return 0;
}
}
if(!port)
{
print_usage(argv[0]);
return 0;
}
sockfd=socket(AF_INET, SOCK_STREAM,0);
if(sockfd<0)
{
printf("Create socket failture:%s\n",strerror(errno));
return -1;
}
printf("Create socket[%d] successfully!\n", sockfd);
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &on,sizeof(on));
memset(&servaddr,0,sizeof(servaddr));
servaddr.sin_family=AF_INET;
servaddr.sin_port=htohs(port);
servaddr.sin_addr.s_addr=htonl(INADDR_ANY);
rv=bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
if(rv<0)
{
printf(" bind error\n");
return -2;
}
listen(sockfd, 13);
printf("Start to listen on port [%d]\n",port);
while(1)
{
printf("Start accept new client incoming...\n");
clifd=accept(sockfd, (struct sockaddr *)&cliaddr,&len);
if(clifd<0)
{
printf("accept error\n");
continue;
}
pid=fork();
if(pid<0)
{
printf("failture\n");
close(clifd);
continue;
}
else if(pid>0)
{
close(clifd);
continue;
}
else if(0==pid)
{
char buf[1024];
close(sockfd);
printf("Child process start to commuicate with socket client...\n");
memset(buf, 0, sizeof(buf));
rv=read(clifd,buf,sizeof(buf));
if(rv<0)
{
printf("failture\n");
close(clifd);
exit(0);
}
else if(rv==0)
{
printf("get disconnected\n");
close(clifd);
exit(0);
}
else if(rv>0)
{
printf("Read %d bytes data from server:%s\n",rv,buf);
}
rv=write(clifd, WXJ, strlen(WXJ));
if(rv<0)
{
printf("Write to client by sockfd[%d] failtur\n",sockfd);
close(clifd);
exit(0)
}
sleep(1);
close(clifd);
exit(0);
}
}
close(sockfd);
return 0;
}
在該程序中,父進程accept()接收到新的連接後,就調用fork()系統調用來創建子進程來處理與客戶端的通信。因爲子進程會繼 承父進程處於listen狀態的socket 文件描述符(sockfd),也會繼承父進程accept()返回的客戶端socket 文件描述符(clifd), 但子進程只處理與客戶端的通信,這時他會將父進程的listen的文件描述符sockfd關閉;同樣父進程只處理監聽的事件,所以會 將clifd關閉。 此時父子進程同時運行完成不同的任務,子進程只負責跟已經建立的客戶端通信,而父進程只用來監聽到來的socket客戶端連 接。所以當有新的客戶端到來時,父進程就有機會來處理新的客戶連接請求了,同時每來一個客戶端都會創建一個子進程爲其服 務。
運行結果:
在我們使用各種描述符的過程中,用完切記close!