文章目錄
- 1.行程創建
- 1.1 fork函式的回傳值
- 1.2 寫時拷貝
- 1.3 fork函式的用法及其呼叫失敗的原因
- 2.行程終止
- 2.1 行程終止的原因
- 2.2 常見的行程退出方法
- 3.行程等待
- 3.1 為什么要有行程等待?
- 3.2 行程等待的方法
- 3.2.1 wait函式
- 3.2.2 waitpid函式
- 3.3.3 子行程的status
- 4.行程替換
- 4.1行程替換的原理
- 4.2 環境變數
- 4.2.1常見的環境變數
- 4.2.2與環境變數相關的函式
- 4.2.3 環境變數的組織形式
- 4.3 exec函式族
- 4.3.1 exec函式族的使用
- 4.3.2 行程替換的應用
1.行程創建
在上一節講解行程概念時,我們提到fork函式是從已經存在的行程中創建一個新行程,那么,系統是如何創建一個新行程的呢?這就需要我們更深入的剖析fork函式,
1.1 fork函式的回傳值
呼叫fork創建行程時,原行程為父行程,新行程為子行程,運行man fork后,我們可以看到如下資訊:
#include <unistd.h>
pid_t fork(void);
fork函式有兩個回傳值,子行程中回傳0,父行程回傳子行程pid,如果創建失敗則回傳-1,
實際上,當我們呼叫fork后,系統內核將會做:
- 分配新的記憶體塊和內核資料結構(如task_struct)給子行程
- 將父行程的部分資料結構內容拷貝至子行程
- 添加子行程到系統行程串列中
- fork回傳,開始調度

1.2 寫時拷貝
在創建行程的程序中,默認情況下,父子行程共享代碼,但是資料是各自私有一份的,如果父子只需要對資料進行讀取,那么大多數的資料是不需要私有的,這里有三點需要注意:
第一,為什么子行程也會從fork之后開始執行?
因為父子行程是共享代碼的,在給子行程創建PCB時,子行程PCB中的大多數資料是父行程的拷貝,這里面就包括了程式計數器(PC),由于PC中的資料是即將執行的下一條指令的地址,所以當fork回傳之后,子行程會和父行程一樣,都執行fork之后的代碼,
第二,創建行程時,子行程需要拷貝父行程所有的資料嗎?
父行程的資料有很多,但并不是所有的資料都要立馬使用,因此并不是所有的資料都進行拷貝,一般情況下,只有當父行程或者子行程對某些資料進行寫操作時,作業系統才會從記憶體中申請記憶體塊,將新的資料拷寫入申請的記憶體塊中,并且更改頁表對應的頁表項,這就是寫時拷貝,原理如下圖所示:

第三,為什么資料要各自私有?
這是因為行程具有獨立性,每個行程的運行不能干擾彼此,
1.3 fork函式的用法及其呼叫失敗的原因
fork函式的用法:
- 一個父行程希望復制自己,通過條件判斷,使父子行程分流同時執行不同的代碼段,例如,父行程等待客戶端請求,生成子 行程來處理請求,
- 如子行程從fork回傳后,呼叫行程替換的函式,如exec等(將會在本節4.程式替換中講解),
fork函式呼叫失敗的原因:
- 系統中行程太多
- 實際用戶的行程數超過了限制
2.行程終止
2.1 行程終止的原因
行程終止的原因有三種
- 代碼運行完畢,結果正確
- 代碼運行完畢,結果不正確
- 代碼例外終止
2.2 常見的行程退出方法
行程正常終止
1.從main函式return,這是最常見的行程退出方法,在函式設計中,0代表正確,非0代表錯誤,其中不同的非0的退出碼對應了退出原因,
2.呼叫exit或者_exit
_exit函式是系統呼叫,執行man _exit可以看到
#include <unistd.h>
void _exit(int status);
status 定義了行程的終止狀態,父行程可以通過wait來獲得子行程的status(會在3.行程等待中講解),
需要注意的是,exit函式是庫函式,雖然status是int,但是僅有低8位可以被父行程所用,所以_exit(-1)時,在終端執行echo $?發現回傳值是255,
#include <stdlib.h>
void exit(int status);
從作用上來看,_exit和exit是相似的,exit是對_exit的封裝,exit的執行實際上是通過呼叫_exit來實作的,
但是二者也有一些細微的差別,請看如下代碼段:
代碼1
int main()
{
printf("Hello world");
exit(0);
}

代碼2
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("Hello world");
_exit(0);

相比于_exit函式,exit函式先要執行用戶定義的清理函式,在沖刷緩沖區,關閉所有打開的流,將所有的快取資料寫入檔案后,再呼叫_exit,因此我們可以看到,執行exit輸出了“hello World",而執行_exit并沒有輸出,
那么,return和exit有什么區別呢?
在普通函式中,return是用來終止函式的,只有在main函式中才是終止行程,而exit無論在哪里,一旦呼叫,整個行程就會終止,
3.行程等待
3.1 為什么要有行程等待?
在講行程概念時我們提到,當子行程退出,父行程如果不管不顧,子行程殘留資源(PCB)存放于內核中,就可能會造成僵尸行程,如果該資源不能得到釋放,就會導致記憶體泄漏,僵尸行程是不能使用 kill -9 命令清除掉的,因為 kill 命令只是用來終止行程的, 而僵尸行程已經終止,
同時,父行程派給子行程的任務完成的如何,我們是需要知道的,例如,子行程運行完成,結果對還是不對, 或者是否正常退出,
因此,就需要父行程通過行程等待的方式,回收子行程的資源,
3.2 行程等待的方法
一個行程在終止時會關閉所有檔案,釋放在用戶空間分配的記憶體,但它的 PCB 還保留著,內核在其中保存了一些資訊:如果是正常終止則保存著退出狀態,如果是例外終止則保存著導致該行程終止的信號是哪個,當這個行程的父行程呼叫 wait 或 waitpid 獲取這些資訊后,才會將這個行程徹底清除掉,
一個行程的退出狀態可以在 Shell 中通過運行echo $?查看,因為 Shell 是它的父行程,當它終止時 Shell 呼叫 wait 或 waitpid 得到它的退出 狀態同時徹底清除掉這個行程,
3.2.1 wait函式
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status);
- 回傳值:成功回傳被等待行程pid,失敗回傳-1,
- status:是一個輸出型引數,將wait函式內部計算的結果通過status回傳給呼叫者,父行程從而獲取子行程退出狀態,如果不關心子行程的退出狀態則可以將引數設定成為NULL,
這里提一下輸入型引數和輸出型引數的區別,輸入型引數是呼叫者給函式傳的引數,而輸出型引數是是函式將內部計算結果回傳給呼叫者,因此輸出型引數往往用指標,
父行程呼叫 wait 函式可以回收子行程終止資訊,該函式有三個功能:
- 阻塞等待子行程退出
- 回收子行程殘留資源
- 獲取子行程結束狀態(退出原因),
當父行程呼叫wait得到傳出引數status后,可以借助宏函式來進一步判斷行程終止的具體原因:
WIFEXITED(status): 若為正常終止子行程回傳的狀態,則為真,(查看行程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,說明子行程正常終止,提取子行程退出碼,(查看行程的退出碼(exit 的引數))
3.2.2 waitpid函式
作用同 wait,但waitpid可指定 pid 行程清理,可以通過非阻塞方式等待子行程退出,
pid_ t waitpid(pid_t pid, int *status, int options);
pid:
- pid = -1,等待任一子行程退出,此時與wait等效
- pid > 0, 回收指定 ID 的子行程,pid為指定行程的行程號,如果不存在該子行程,則立即出錯回傳
status:
- 同wait
option:
- 0:阻塞模式,即父行程會阻塞在waitpid處,等到子行程退出后繼續,
- WNOHANG: 非阻塞模式,若pid指定的子行程沒有結束,則waitpid函式回傳0,不予以等待,若正常結束,則回傳該子行程的ID,一般情況下,非阻塞模式需要搭配回圈使用,
注意:一次 wait 或 waitpid 呼叫只能清理一個子行程,清理多個子行程應使用回圈,
回傳值:
- 當正常回傳的時候waitpid回傳收集到的子行程的行程ID;
- 如果設定了選項WNOHANG,而呼叫中waitpid發現沒有已退出的子行程可收集,則回傳0;
- 如果呼叫中出錯,則回傳-1,這時errno會被設定成相應的值以指示錯誤所在
3.3.3 子行程的status
關于status的用法,我已經在wait函式處講解,此處不再贅述,這里將從底層的角度剖析status的含義,
status不能簡單的當作整形來看待,可以當作位圖來看待,具體細節如下圖(只研究status低16位元位),

我們以下一段代碼為例,來展示一下非阻塞等待方式
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
pid_t pid;
pid = fork();
if(pid < 0){
printf("%s fork error\n",__FUNCTION__);
return 1;
}else if( pid == 0 ){ //child
printf("child is run, pid is : %d\n",getpid());
sleep(5);
exit(1);
} else{
int status = 0;
pid_t ret = 0;
do
{
ret = waitpid(-1, &status, WNOHANG);//非阻塞式等待
if( ret == 0 ){
printf("child is running\n");
}
sleep(1);
}while(ret == 0);
if( WIFEXITED(status) && ret == pid ){
printf("wait child 5s success, child return codeis:%d.\n",WEXITSTATUS(status));
}else{
printf("wait child failed, return.\n");
return 1;
}
}
return 0;
}
這段代碼先創建子行程,讓子行程等待5s再退出,父行程每1s檢查一下,5s后子行程退出,ret將變成子行程的行程號,退出回圈等待,最終的運行結果如下:

4.行程替換
4.1行程替換的原理
在講行程替換原理前,我們需要先知道什么是行程替換,在講fork函式時我們提到,fork 創建子行程后執行的是和父行程相同的程式(但有可能執行不同的代碼分支),如果此時我們用一個新的程式替換掉子行程的地址空間、代碼段和資料,子行程將會從新程式的啟動例程開始執行,這就是行程替換,
行程替換并不是創建新的行程,因為替換前后該行程的PID并未改變,
4.2 環境變數
行程替換需要用到一種exec函式,在講exec函式族之前,我們先介紹一下環境變數的概念,
4.2.1常見的環境變數
按照慣例,環境變數字串都是name=value 這樣的形式,大多數 name 由大寫字母加下劃線組成,一般把name 的部分叫做環境變數,value 的部分則是環境變數的值,
環境變數定義了行程的運行環境,具有全域屬性,因此設定環境變數時要加export,一些比較重要的環境變數的含義如下:
PATH
可執行檔案的搜索路徑,ls 命令也是一個程式,執行它不需要提供完整的路徑名/bin/ls, 然而通常我們執行當前目錄下的程式 a.out 卻需要提供完整的路徑名./a.out,這是因為 PATH 環境變數的值里面包含了 ls 命令所在的目錄/bin,卻不包含 a.out 所在的目錄,
PATH 環境變數的值可以包含多個目錄,用:號隔開,在 Shell 中用 echo 命令可以查看這個環境變數的值: echo $PATH

SHELL
當前 Shell,它的值通常是/bin/bash,

TERM
當前終端型別

HOME
當前用戶主目錄的路徑,很多程式需要在主目錄下保存組態檔,使得每個用戶在運行該程式時都有自己的一套配置,

4.2.2與環境變數相關的函式
getenv函式
獲取環境變數值: char *getenv(const char *name);
成功:回傳環境變數的值;失敗:NULL (name 不存在)
setenv 函式
設定環境變數的值 :int setenv(const char *name, const char *value, int overwrite);
成功:回傳0;失敗: 回傳-1
引數 overwrite 取值:
1:覆寫原環境變數
0:不覆寫,(該引數常用于設定新環境變數,如:HELLO = “hello”)
unsetenv 函式
洗掉環境變數 name 的定義: int unsetenv(const char *name);
成功:0;失敗:-1
注意事項:name 不存在仍回傳 0(成功),
4.2.3 環境變數的組織形式

environ 變數是一個char ** 型別,存盤著系統的環境變數,每個程式都會收到一張環境表,環境表是一個字符指標陣列,每個指標指向一個以’\0’結尾的環境字串,
4.3 exec函式族
4.3.1 exec函式族的使用
知道了環境變數的概念后,再簡要介紹一下命令列引數,當我們在某個目錄下輸入ls -a 和ls -l時,會有如下顯示:

我們發現,同樣的ls命令,由于后面所跟的字串不同,顯示了不同的結果,這里的“-a”,“-l”被稱為引數,實際上,一個程式內可以通過加入引數,讓相同的程式執行不同的功能,
接下來我們來介紹行程替換必不可少的函式族——exec函式族,
其實有六種以 exec 開頭的函式,統稱 exec 函式:
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);
注意:這些函式如果呼叫成功則加載新的程式從啟動代碼開始執行,不再回傳, 如果呼叫出錯則回傳-1 所以exec函式只有出錯的回傳值而沒有成功的回傳值!
這些函式如何使用,我們來看下面這段代碼:
#include <unistd.h>
int main()
{
char *const argv[] = {"ps", "-ef", NULL};//argv[0]始終是程式名
char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
//execl("/bin/ps", "ps", "-ef", NULL);
// 帶p的,可以使用環境變數PATH,無需寫全路徑
//execlp("ps", "ps", "-ef", NULL);
// 帶e的,需要自己組裝環境變數
//execle("ps", "ps", "-ef", NULL, envp);
//execv("/bin/ps", argv);
// 帶p的,可以使用環境變數PATH,無需寫全路徑
//execvp("ps", argv);
// 帶e的,需要自己組裝環境變數
execve("/bin/ps", argv, envp);
exit(0);
}
事實上,只有execve是真正的系統呼叫,其它五個函式最終都呼叫 execve,
這些函式原型看起來很容易混,但只要掌握了規律就很好記,
- l(list) : 表示引數采用串列,如果采用串列形式,const char *arg中的第一個引數必須是可執行程式本身,如上例中的 “ps”,
- v(vector) : 引數用陣列 ,v和l只能二選一
- e(env) : 表示自己維護環境變數,有e引數中就需要有char *const envp[]
- p(path) : 有p自動搜索環境變數PATH,第一個引數直接輸入程式名即可,且有p一定沒有e,因為有表示已經自動添加了環境變數,如果沒有p則需要輸入對應程式的路徑
4.3.2 行程替換的應用
我們平時使用的shell讀取命令和分析命令就是一個很典型的例子,如下圖所示:

我們平時輸入的如ls -a等命令實際上是一個個可執行程式,當shell讀取一行命令時,shell會對命令進行決議,并且shell創建一個子行程,再通過呼叫execve,用可執行程式替換掉子行程,當程式執行完畢并且退出后,shell讀取子行程的退出資訊,這樣,即便會出現程式崩潰的情況,也不會影響到shell本身,
以上就是關于行程控制的內容,主要分為四個方面——行程創建,行程終止,行程等待以及行程替換,有了以上的知識,我們已經可以實作一個很簡易的shell,如何實作,請讀者自行思考!
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/294472.html
標籤:其他
上一篇:【資料結構】一篇文章學懂并查集+LRU Cache,拿來吧你!
下一篇:Day6:資料結構之二叉樹
