《UNIX環境高級編程》第8章 進程控制

8.1 前言

本章介紹UNIX系統的進程控制,包括創建新進程、執行程序和進程終止。
還將說明進程屬性的各種ID是如何受到進程控制原語的影響的。
還包括瞭解釋器文件和system函數。
還講述了UNIX系統所提供的進程會計機制,這種機制使得我們能夠從另一個角度瞭解進程控制功能。

8.2 進程標識

每個進程都有一個非負整型標識唯一進程ID。雖然進程ID是唯一的但它是可複用的。當一個進程終止後其ID就成爲複用的候選者,大多數UNIX系統實現延時複用算法,使得賦予新進程的ID不同於最近終止進程所用的ID。這防止了將新進程誤認爲是使用同一ID的某個已經終止的先前進程。
系統中有一些專用進程:
- ID=0的進程是調度進程,常常被稱爲交換進程(swapper)。該進程是內核的一部分,它並不執行任何磁盤上的程序,因此也被稱爲系統進程
- ID=1的進程是init進程,在自舉過程結束時由內核調用。該進程的程序文件在UNIX早起版本中是/etc/init,新版本中是/sbin/init。此進程負責在自舉內核後啓動一個UNIX系統。init通常讀取與系統有關的初始化文件(/etc/rc*或/etc/inittab或/etc/init.d文件夾中的文件),並將系統引導到一個狀態。init進程絕不會終止。它是一個普通用戶進程(與交換進程不同,它不是內核中的系統進程),但它是以超級用戶特權運行的。

除了進程ID,每個進程還有一些其他標識,以下函數返回這些標識:

#include <unistd.h>
pid_t getpid(void); //返回調用進程的ID
pid_t getpid(void); //返回調用進程的父進程ID

pid_t getuid(void); //返回調用進程實際用戶ID
pid_t geteuid(void);    //返回調用進程的有效用戶ID

pid_t getgid(void); //返回調用進程的實際組ID
pid_t getegid(void);    //返回調用進程的有效組ID

8.3 函數fork

一個現有的進程可以調用fork函數創建一個新進程。

#include <unistd.h>
pit_t fork(void);

由fork創建的新進程被稱爲子進程(child process)。fork函數被調用一次,但返回兩次。兩次返回的區別是子進程返回值是0,而父進程返回值是新建子進程的進程ID。
子進程和父進程繼續執行fork調用之後的指令。子進程是父進程的副本,它從父進程獲得了數據空間、堆和棧的副本,但這份副本是獨立的,父子進程之間並不共享這些存儲空間部分。父子進程共享正文段。(現在這些存儲空間使用了寫時複製技術,也就是父子進程共享一個副本,但這個副本是隻讀的,如果任何一方要寫,那就複製一份那個要寫內存塊的副本,這通常是虛擬存儲的一頁。)

8.4 函數vfork

vfork函數用於創建一個新進程,而該新進程的目的是exec一個新程序。
vfork與fork一樣都創建一個子進程,但是它並不將父進程的地址空間完全複製到子進程中,因爲子進程會立即調用exec(或者exit),於是也就不會引用該地址空間。不過在子進程調用exec或exit之前它在父進程空間中運行。但是如果子進程修改了數據、進行函數調用或者沒有使用exec或exit就返回都有可能會帶來未知的結果。
vfork和fork的另一個區別是:vfork保證子進程先運行,在它調用exec或exit之後父進程纔可能被調度運行,當子進程調用這兩個函數中的任意一個時,父進程會恢復運行。(如果在調用這兩個函數之前子進程依賴父進程的進一步動作,則會導致死鎖。)

8.5 函數exit

之前所述,進程有5種正常終止及3種異常終止方式。不管進程如何終止,最後都會執行內核中的同一段代碼。這段代碼爲相應進程關閉所有打開描述符,釋放它所使用的存儲器等。
對任一終止情形,我們都希望終止進程能夠通知其父進程是如何終止的。
- 對於3個終止函數(exit 、_exit和_Exit),實現這一點的方法是,將其退出狀態(exit status)作爲參數傳遞給函數。
- 對於異常終止,內核產生一個指示異常終止原因的終止狀態(termination status)
在任意狀態下,該終止進程的父進程都能用wait或waitpid函數取得其終止狀態。
這裏使用了“退出狀態”來區別於“終止狀態”,在最後調用_exit時,內核將退出狀態裝換成終止狀態。


  • 如果子進程正常終止,則父進程可以獲得子進程的退出狀態。如果父進程在子進程前終止,則子進程的父進程都變爲init進程。我們稱這些進程由init進程收養。其操作過程大概是:在一個進程終止時,內核逐個檢查所有活動進程,以判斷它是否是正要終止的進程的子進程,如果是,則該進程的父進程ID就更改爲1(init的進程ID)。這種處理方法保證了每個進程都有一個父進程。
  • 另一個問題是:如果子進程先於父進程終止,那麼父進程是如何在檢查時得到子進程的終止狀態的呢?如果子進程消失了,父進程在最終準備好檢查子進程是否終止時是無法獲取它的終止狀態的。內核爲每個終止子進程保存了一定量的信息,所以當終止進程的父進程調用wait或waitpid時,可以得到這些消息。這些消息至少包括進程ID、該進程終止狀態已經該進程使用的CPU時間總量。內核可以釋放終止進程所使用的所以存儲區,關閉所有打開文件。在UNIX術語中,一個已經終止、但其父進程尚未對其進行善後處理(取得終止子進程的有關信息、釋放佔用資源)的進程成爲僵死進程(zombie)。
  • 最後一個問題:一個由init進程收養的進程終止時會發生什麼?它會不會變成僵死進程?答案是當然不會,因爲init進程被編寫爲無論什麼時候只要有一個子進程終止,init進程就會調用一個wait函數取得其終止狀態。這樣也就防止了在系統中塞滿僵死進程。一個init的子進程可以是init直接產生的進程也可以是其父進程已經終止,由init收養的進程。

8.6 函數wait和watipid

當一個進程正常或異常終止時,內核就像其父進程發送SIGCHLD信號。因爲子進程終止是個異步事件,所以這種信號也是內核向父進程發的異步通知。父進程可以選擇忽略該信號,或者提供一個該信號發生時即被調用執行的函數(信號處理程序)。對於這種信號的系統默認動作是忽略它。
調用wait或waitpid時進程可能會發生什麼:
- 如果其所有子進程都還在運行,則阻塞;
- 如果一個子進程已經終止,正等待父進程獲取其終止狀態,則取得該子進程終止狀態立即返回;
- 如果它沒有任何子進程,則立即出錯返回。

#include <sys/wait.h>
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid,int *statloc,int options);

兩個函數區別如下:
- 在一個子進程終止前,wait使其調用者阻塞,而waitpid有一個選項,可以使調用者不阻塞。
- waitpid並不等待在其調用之後的第一個終止子進程,它有若干個選項,可以控制它所等待的進程。

如果子進程已經終止,並且是一個僵死進程,則wait立即返回並取得該子進程的狀態;否則wait使其調用者阻塞,直到一個子進程終止。如果調用者阻塞,而且它有多個子進程,則在其某一個子進程終止時,wait就立即返回。因爲wait返回終止子進程的ID,所以它總能瞭解是哪一個子進程終止了。

參數statloc是一個整形指針。如果statloc不是一個空指針,則進程的終止狀態就存放在它所指向的單元內。如果不關心終止狀態,則可以將該參數指定爲空指針。
依據傳統,這兩個函數返回的整形狀態字是由實現定義的。其中某些位表示退出狀態(正常返回),其他位則指示信號編號(異常返回),有一位指示是否產生了core文件等。
在sys/wait.h中有4個互斥的宏可以用來取得進程終止的原因,他們的名字都是以WIF開始:

說明
WIFEXITED(status) 若子進程正常終止,則爲真。這時可執行WEXITSTATUS(status)取得子進程傳送給exit或_exit參數的低8位。
WIFSIGNALED(status) 若子進程異常終止,則爲真。這時可執行WTERMSIG(status)取得子進程終止信號編號,有些實現定義了宏WCOREDUMP(status),若已產生終止進程的core文件,則它返回真。
WIFSTOPPED(status) 若爲當前暫停子進程的返回的狀態,則爲真。這時可執行WSTOPSIG(status),獲取使子進程暫停的信號編號。
WIFCONTINUED(status) 在作業控制暫停後(第9章討論作業控制)已經繼續的子進程返回了狀態,則爲真()僅用於waitpid。

如果要等待一個指定的進程終止,可以使用wiatpid函數;waitpid函數中的pid參數作用解釋如下:
- pid==-1:等待任一子進程,這種情況下waitpid和wait函數等效;
- pid>0 :等待進程號爲pid的子進程;
- pid==0:等待組ID等於調用進程組ID的任一子進程;
- pid<-1:等待組ID等於pid絕對值的任一子進程。
對於wait函數,唯一出錯是調用進程沒有子進程;而waitpid如果指定的進程或進程組不存在,或者參數pid指定的進程不是調用進程的子進程,都有可能出錯。

options參數使我們能進一步控制wiatpid的操作。此參數值可以使0或者是以下常量按位或的結果。

常量 說明
WCONTINUED 若實現支持作用控制,那麼由pid指定的任一子進程在停止後已經繼續,但其狀態尚未報告,則返回其狀態。
WNOHANG 若pid指定的子進程並不是立即可用的,則waitpid不阻塞,此時其返回值爲0.
WNOTRACED 若實現支持作業控制,而pid所指定的子進程已處於停止狀態,並且其狀態自停止以來還未報告過,則返回其狀態。WIFSTOPPED宏確定返回值是否對應於一個停止的子進程。

waitpid函數提供了wait函數沒有提供的3個功能:
- waitpid可以等待一個特定的進程。而wait則返回任一終止子進程的狀態。
- waitpid提供了wait的非阻塞版本。有時希望獲取一個子進程的狀態,但不想阻塞。
- waitpid通過WUNTRACED和WCONTINUED選項支持作業控制。


如果希望產生一個子進程,其父進程不需要等待它終止,同時此子進程也不希望處於僵死狀態直到父進程終止(如果此子進程處於僵死狀態,父進程沒有回收子進程,那就要一直等到父進程僵死,被init回收後再由init回收該子進程),可以使用兩次fork達到此效果。這就等於直接創建了一個父進程爲init的子進程。這裏如果在圖形界面下運行,輸出此子進程的ppid會發現不是init進程1,而是upstart進程號,只有在終端下運行纔是init進程號1.

8.7 函數waitid

另一個取得進程終止狀態的函數-waitid,此函數類似於waitpid,但提供了更多的靈活性。

#include <sys/wait.h>
int waitid(idtype_t idtype,id_t id,siginfo_t *infop,int options);

與waitpid類似,waitid允許一個進程指定要等待的子進程。但它使用兩個單獨的參數表示要等待的子進程所屬的類型。id的作用與idtype有關。idtype類型如下表:

常量 說明
P_PID 等待一特定進程,id包含要等待子進程的ID.
P_PGID 等待一特定進程組中的任一子進程,id包含要等待進程組的進程組ID.
P_ALL 等待任一子進程,忽略id

options參數是以下表中各標誌按位或運算:

常量 說明
WCONTINUED 等待任一進程,它曾被停止過,但又已繼續,但尚未上報。
WWXITED 等待已退出的進程。
WNOHANG 如無可用的子進程退出狀態,立即返回而非阻塞。
WSTOPPED 等待一進程,它已經停止,但其狀態尚未報告。

infop參數是指向siginfo結構的指針。該結構包含了造成子進程狀態改變有關信號的詳細信息。

8.8 wait3 和wait4

大多數UNIX實現提供了wait3和wait4這兩個函數,他們提供了比函數wait、waitpid和waitid的功能要多一個,這與附加參數有關。該參數允許內核返回由終止進程及其所有子進程使用的資源概況。

#include <sys/types.h>
#include <sys/wait.h>
#include <sys/time.h>
#include <sys/resource.h>

pid_t wait3(int *statloc,int options,struct rusage *rusage);
pid_t wait4(pid_t pid,int *statloc,int options,struct rusage *rusage);

資源統計信息包括用戶CPU時間總量、系統CPU時間總量、缺頁次數、收到信號的次數等(getrusage-get resource usage)。

8.9 競爭條件

當多個進程企圖對共享數據進行某種處理,而最後的結果又取決於進程運行的順序時,我們就認爲發生了競爭條件(race condition)。
如果父進程需要等待子進程終止,則它必須使用wait函數中的一個。如果一個子進程需要等待父進程終止,則可以使用以下形式:

while(getppid()!=1) 
sleep(1);

這種形式的循環稱爲輪詢(polling),它的問題是浪費了CPU時間,因爲調用者每隔1s都被喚醒,然後進行調節測試。
爲了避免競爭條件和輪詢,在多個進程之間需要有某種形式的信號發送和接收方法。在UNIX中可以使用信號機制,各種形式的進程間通信(IPC)也可以使用。

8.10 函數exec

當進程調用一種exec函數時,該進程執行的程序完全替換爲新程序,而新程序則從其main函數開始執行。因爲調用exec並不創建新進程,所以前後的進程ID並未改變。exec只是用磁盤上的一個新程序替換了當前進程的正文段、數據段、堆和棧。
有7種不同的exec函數可供使用,它們被統稱爲exec函數:

#include <unistd.h>
int execl(const char *pathname,const char *arg0,.../*(char*)0*/);   //以空指針結尾參數列表
int execv(const char *pathname,char *const argv[]); //以指針數組作爲參數
int execle(const char *pathname,const char *arg0,.../*(char*)0*/,char *const envp[]);   //以空指針結尾參數列表
int execve(const char *pathname,char *const argv[],char *const envp[]);

int execlp(const char *filename,const char *arg0,.../*(char*)0*/);  //以空指針結尾參數列表
int execvp(const char *filename,char *const argv[]);
int fexecve(int fd,char *const argv[],char *const envp[]);
  1. 第一個區別是前4個函數取路徑名作爲參數,後兩個函數則取文件名作爲參數,最後一個取文件描述符作爲參數。當指定filename作爲參數時:
    • 如果filename中包含/,則就將其視爲路徑名;
    • 否則就按PATH環境變量,在它指定的各目錄中搜尋可執行變量。

如果execlp或execvp使用路徑前綴(PATH變量中的一個成員)中的一個找到了一個可執行文件,但是該文件不是由連接器產生的機器可執行文件,則就認爲該文件是一個腳本,於是嘗試用/bin/sh並以filename作爲shell的輸入。

fexecv函數避免了尋找正確的可執行文件,而是依賴調用進程來完成這項工作。調用進程可以使用文件描述符驗證所需要的文件並且無競爭地執行該文件。否則,擁有特權的用戶就可以在找到文件並驗證之後,在調用該文件之前替換可執行文件。


  1. 第二個區別與參數表的傳遞有關:
    l表示列表list,v表示矢量vector。
    • 函數execl、execlp、execle要求新程序的的每個命令行參數都說明爲一個單獨的參數。這種參數表以空指針結尾。
    • 函數execv、execvp、execve、fexecve,則應先構造一個指向各參數的指針數組,然後講該數組地址作爲這4各函數的參數。

  1. 第三個區別是向新程序傳遞環境變量表相關。
    • 以e結尾的函數(execle、execve、fexecve)可以傳遞一個指向環境變量字符串指針數組的指針 。其他4各函數則使用調用進程中的environ變量作爲新程序複製現有的環境。

    • 這7個exec函數很難記,函數名中的字符會給一些幫助:
    • p表示該函數取filename作爲參數,並且用PATH環境變量尋找可執行文件。
    • l表示該函數取一個參數表,它與字母v互斥。
    • v表示該函數取一個argv[]矢量。

很多UNIX實現中,這7個函數中只有execve是內核的系統調用。另外6個只是庫函數,他們最終都要調用該系統調用。這7個函數之間的關係如下:這裏寫圖片描述
在這種規劃中,庫函數fexecve使用/proc/self/fd 把文件描述符參數轉換成路徑名,execve使用該路徑名去執行程序。

8.11 更改用戶ID和更改組ID

在UNIX系統中,特權(如改變當前日期時間等)以及訪問控制(如讀寫一個特定的文件),是基於用戶ID和組ID的。
當程序需要增加特權,或需要訪問當前並不允許訪問的資源時,我們需要更換自己的用戶ID或組ID,使得新ID具有合適的特權或訪問權限。與此類似,當程序需要降低其特權或阻止對某些資源的訪問時,也需要更換用戶ID或組ID,新ID不具有相應特權或訪問這些資源的能力。
一般而言,再設計應用時,我們總是試圖使用最小權限(least privilege)模型。
我們可以使用setuid函數設置實際用戶ID和有效用戶ID。與此類似,可以用setgid函數設置實際組ID和有效組ID。

#include <unistd.h>
int setuid(uid_t uid);
int setgid(gid_t gid);

特權以及訪問控制這塊內容比較繞,還是具體看書吧。

8.12解釋器文件

所有的UNIX系統都支持解釋器文件(interpret file)。這種文件是文本文件,其起始行的形式是:

#! pathname [optional-argument]
//最常見的解釋器文件是以下列行開始的
#! /bin/sh

pathname通常是絕對路徑名,對它不進行什麼特殊處理(不適用PATH進程路徑搜索)。
對這種文件的識別是由內核作爲exec系統調用處理的一部分來完成的。內核使調用exec函數的進程實際執行的並不是該解釋器文件,而是該解釋器文件中第一行pathname中所指定的文件。
當內核exec解釋器時,argv[0]是該解釋器的pathname,argv[1]是該解釋器的可選參數,然後纔是exec的第2和第3個參數。(exec的第一個參數去哪呢?沒搞清楚、以後再研究吧)

8.13 函數system

ISO C定義了system函數,system函數對操作系統的依賴性很強。

#include <stdlib.h>
int system(const char *cmdstring);

如果cmdstring是一個空指針,如系統支持命令處理程序,system返回非0,這一特徵可以確定在一個操作系統上是否支持system函數。
一種簡單的system函數實現如下:

int system(const char *cmdstring)
{
    pid_t pid;
    int status;

    if(cmdstring==NULL) return(1);    //命令爲空,返回1,可用於測試系統

    if((pid=fork())<0) status=-1;
    else if(pid==0){                  //子進程
    execl("/bin/sh","sh","-c",cmdstring,(char *)0);  //執行命令
    _exit(127);
    }else{                            //父進程
    while(waitpid(pid,&status,0)<0){  //等待子進程返回
    if(errno!=EINTR){
    status=-1;
    break;
    }
    }
return (status);                         //返回
}
  • shell的-c選項搞死shell程序取下一個命令行參數(這裏是cmdstring)作爲命令輸入(而不是標準輸入或從文件中讀取命令)。
  • shell對以null字節終止的命令字符串進行語法分析,將他們分解成命令行參數。傳遞給shell的實際命令字符串可以包含任一有效的shell命令,例如<和>對輸入和輸出重定向。
  • 如果不使用shell執行此命令,而是試圖由我們自己去執行它,那將相當困難。必須要自己分解命令行參數。
  • 調用_exit而不是exit,是爲了防止任一標準IO緩衝在子進程中被沖洗。

8.14 進程會計

大多數UNIX系統提供了一個選項以進行進程會計(process accouting)處理。啓用該選項後,每當進程結束時內核就寫一個會計記錄。典型的會計記錄包括命令名、所使用的CPU時間總量、用戶ID和組ID、啓動時間等。
這使我們得到了一個再次觀察進程的機會。
具體的看書,不多說了。

8.15 用戶標識

任一進程都可以得到其實際用戶ID和有效用戶ID及組ID。但是,我們有時希望得到運行該程序用戶的登錄名。可以使用getlogin函數獲得此登錄名。

#include <unistd.h>
char *getlogin(void);

如果調用此函數的進程沒有連接到用戶登錄時所用的終端,則函數會失敗。通常稱這些進程爲守護進程(daemon)。

8.16 進程調度

UNIX系統歷史上對進程提供的只是基於調度優先級的粗粒度控制(相對於精細控制)。調度策略和調度優先級是由內核確定的。
進程可以通過調整nice值(調高)選擇以更低優先級運行(通過調整nice值降低它對CPU的佔有,因此該進程對CPU來說是“友好的”)。只有特權進程允許提高調度權限(調低nice值)。
注:這裏的意思是nice”友好地”值越高,進程對CPU越“友好”,優先級越低。
POSIX實時擴展增加了在多個調度類別中選擇的接口以進一步細調行爲。這裏我們只討論用於調整nice值的接口。SUS中nice值的範圍在0-(2*NZERO)-1之間,linux3.2.0可以使用sysconf參數_SC_NZERO來訪問NZERO的值。
進程可以通過nice函數獲取或更改它的nice值:

#include <unistd.h>
int nice(int incr);

incr參數被增加到進程的nice值上,如果incr太大,系統直接把它降到最大合法值,不給出提示。如果incr太小,系統也會把它提高到最小合法值。
由於-1也是nice合法的成功返回值,如果調用nice成功並返回-1,那麼errno任然爲0.如果errno不爲0,說明nice調用失敗。

8.17 進程時間

在1.10節中說了我們可以度量的3個時間:牆上時鐘時間、用戶CPU時間和系統CPU時間。任一進程都可以調用times函數獲得它自己以及已終止子進程的上述值。

#include <sys/times.h>
clock_t times(struct tms *buf);

此函數填寫由buf指向的tms結構,該結構定義如下:

struct tms{
clock_t tms_utime;
clock_t tms_stime;
clock_t tms_cutime;
clock_t tms_cstime;
};

此結構沒有包含牆上時鐘時間。其獲取的方法是兩次調用times,根據返回值之差算出牆上時鐘時間。
clock_t使用的是系統滴答時鐘,可以使用sysconf函數通過_SC_CLK_TCK常量取得系統滴答數,再轉換成秒數。
此結構中有兩個針對子進程的字段包含了wait函數族已經等到的各子進程的值。

int elapse_time,user_time,sys_time;
int tims_start,tim_end;
struct tms tms_start,tms_end;

tim_start=times(&tms_start);
//do somthing
tim_end=times(&tms_end);
elapse_time=(tim_end-tim_start)/(double)sysconf(_SC_CLK_TCK);

user_time=(tms_end.utime-tms_start.utime)/(double)sysconf(_SC_CLK_TCK);

sys_time=(tms_end.stime-tms_start.stime)/(double)sysconf(_SC_CLK_TCK);

8.18 小結

對在UNIX環境中的高級編程而言,完整地瞭解UNIX的進程控制時非常重要的。
- 其中必須掌握的幾個函數如fork、exec系列、wait和waitpid系列,很多程序都使用這些簡單的函數。
- 本章還說明了system函數和進程會計,這使我們能進一步瞭解所有這些進程控制函數。
- 還說明了exec函數的另一種變體:解釋器文件以及他們的工作方式。
- 對各種不同的用戶ID和組ID的理解,對編寫安全的設置用戶ID程序時至關重要的。

發佈了55 篇原創文章 · 獲贊 13 · 訪問量 3萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章