深入理解 Linux 中進程控制
一、進程創建
fork 函數初識
在 Linux 中,fork 函數是非常重要的函數,它從已存在進程中創建一個新進程。新進程爲子進程,而原進程爲父進程。
返回值:
在子進程中返回 0,父進程中返回子進程的 PID,子進程創建失敗返回 - 1。
進程調用 fork,當控制轉移到內核中的 fork 代碼後,內核做:
-
分配新的內存塊和內核數據結構給子進程。
-
將父進程部分數據結構內容拷貝至子進程。
-
添加子進程到系統進程列表當中。
-
fork 返回,開始調度器調度。
fork 之後,父子進程代碼共享。例如:
運行結果如下:
這裏可以看到,Before 只輸出了一次,而 After 輸出了兩次。其中,Before 是由父進程打印的,而調用 fork 函數之後打印的兩個 After,則分別由父進程和子進程兩個進程執行。也就是說,fork 之前父進程獨立執行,而 fork 之後父子兩個執行流分別執行。
注意: fork 之後,父進程和子進程誰先執行完全由調度器決定。
fork 函數返回值
fork 函數爲什麼要給子進程返回 0,給父進程返回子進程的 PID?
一個父進程可以創建多個子進程,而一個子進程只能有一個父進程。因此,對於子進程來說,父進程是不需要被標識的;而對於父進程來說,子進程是需要被標識的,因爲父進程創建子進程的目的是讓其執行任務的,父進程只有知道了子進程的 PID 才能很好的對該子進程指派任務。
爲什麼 fork 函數有兩個返回值?
父進程調用 fork 函數後,爲了創建子進程,fork 函數內部將會進行一系列操作,包括創建子進程的進程控制塊、創建子進程的進程地址空間、創建子進程對應的頁表等等。子進程創建完畢後,操作系統還需要將子進程的進程控制塊添加到系統進程列表當中,此時子進程便創建完畢了。
也就是說,在 fork 函數內部執行 return 語句之前,子進程就已經創建完畢了,那麼之後的 return 語句不僅父進程需要執行,子進程也同樣需要執行,這就是 fork 函數有兩個返回值的原因。
寫時拷貝
當子進程剛剛被創建時,子進程和父進程的數據和代碼是共享的,即父子進程的代碼和數據通過頁表映射到物理內存的同一塊空間。只有當父進程或子進程需要修改數據時,纔將父進程的數據在內存當中拷貝一份,然後再進行修改。
這種在需要進行數據修改時再進行拷貝的技術,稱爲寫時拷貝技術。
1、爲什麼數據要進行寫時拷貝?
進程具有獨立性。多進程運行,需要獨享各種資源,多進程運行期間互不干擾,不能讓子進程的修改影響到父進程。
2、爲什麼不在創建子進程的時候就進行數據的拷貝?
子進程不一定會使用父進程的所有數據,並且在子進程不對數據進行寫入的情況下,沒有必要對數據進行拷貝,我們應該按需分配,在需要修改數據的時候再分配(延時分配),這樣可以高效的使用內存空間。
3、代碼會不會進行寫時拷貝?
90% 的情況下是不會的,但這並不代表代碼不能進行寫時拷貝,例如在進行進程替換的時候,則需要進行代碼的寫時拷貝。
fork 常規用法
-
一個進程希望複製自己,使子進程同時執行不同的代碼段。例如父進程等待客戶端請求,生成子進程來處理請求。
-
一個進程要執行一個不同的程序。例如子進程從 fork 返回後,調用 exec 函數。
fork 調用失敗的原因
fork 函數創建子進程也可能會失敗,有以下兩種情況:
-
系統中有太多的進程,內存空間不足,子進程創建失敗。
-
實際用戶的進程數超過了限制,子進程創建失敗。
二、進程終止
進程退出場景
進程退出只有三種情況:
-
代碼運行完畢,結果正確。
-
代碼運行完畢,結果不正確。
-
代碼異常終止(進程崩潰)。
進程退出碼
我們都知道 main 函數是代碼的入口,但實際上 main 函數只是用戶級別代碼的入口,main 函數也是被其他函數調用的,例如在 VS2013 當中 main 函數就是被一個名爲__tmainCRTStartup 的函數所調用,而__tmainCRTStartup 函數又是通過加載器被操作系統所調用的,也就是說 main 函數是間接性被操作系統所調用的。
既然 main 函數是間接性被操作系統所調用的,那麼當 main 函數調用結束後就應該給操作系統返回相應的退出信息,而這個所謂的退出信息就是以退出碼的形式作爲 main 函數的返回值返回,我們一般以 0 表示代碼成功執行完畢,以非 0 表示代碼執行過程中出現錯誤,這就是爲什麼我們都在 main 函數的最後返回 0 的原因。
當我們的代碼運行起來就變成了進程,當進程結束後 main 函數的返回值實際上就是該進程的進程退出碼,我們可以使用 echo $? 命令查看最近一次進程退出的退出碼信息。
例如,對於下面這個簡單的代碼:
代碼運行結束後,我們可以查看該進程的進程退出碼。
[cl@VM-0-15-centos procTermination]$ echo $?
這時便可以確定 main 函數是順利執行完畢了。
爲什麼以 0 表示代碼執行成功,以非 0 表示代碼執行錯誤?
因爲代碼執行成功只有一種情況,成功了就是成功了,而代碼執行錯誤卻有多種原因,例如內存空間不足、非法訪問以及棧溢出等等,我們就可以用這些非 0 的數字分別表示代碼執行錯誤的原因。
C 語言當中的 strerror 函數可以通過錯誤碼,獲取該錯誤碼在 C 語言當中對應的錯誤信息:
運行代碼後我們就可以看到各個錯誤碼所對應的錯誤信息:
實際上 Linux 中的 ls、pwd 等命令都是可執行程序,使用這些命令後我們也可以查看其對應的退出碼。
可以看到,這些命令成功執行後,其退出碼也是 0。
但是命令執行錯誤後,其退出碼就是非 0 的數字,該數字具體代表某一錯誤信息。
注意: 退出碼都有對應的字符串含義,幫助用戶確認執行失敗的原因,而這些退出碼具體代表什麼含義是人爲規定的,不同環境下相同的退出碼的字符串含義可能不同。
進程正常退出
return 退出
在 main 函數中使用 return 退出進程是我們常用的方法。
例如,在 main 函數最後使用 return 退出進程。
運行結果:
exit 函數
使用 exit 函數退出進程也是我們常用的方法,exit 函數可以在代碼中的任何地方退出進程,並且 exit 函數在退出進程前會做一系列工作:
-
執行用戶通過 atexit 或 on_exit 定義的清理函數。
-
關閉所有打開的流,所有的緩存數據均被寫入。
-
調用_exit 函數終止進程。
例如,以下代碼中 exit 終止進程前會將緩衝區當中的數據輸出。
運行結果:
_exit 函數
使用_exit 函數退出進程的方法我們並不經常使用,_exit 函數也可以在代碼中的任何地方退出進程,但是_exit 函數會直接終止進程,並不會在退出進程前會做任何收尾工作。
例如,以下代碼中使用_exit 終止進程,則緩衝區當中的數據將不會被輸出。
運行結果:
return、exit 和_exit 之間的區別與聯繫
return、exit 和_exit 之間的區別
只有在 main 函數當中的 return 才能起到退出進程的作用,子函數當中 return 不能退出進程,而 exit 函數和_exit 函數在代碼中的任何地方使用都可以起到退出進程的作用。
使用 exit 函數退出進程前,exit 函數會執行用戶定義的清理函數、沖刷緩衝,關閉流等操作,然後再終止進程,而_exit 函數會直接終止進程,不會做任何收尾工作。
return、exit 和_exit 之間的聯繫
執行 return num 等同於執行 exit(num),因爲調用 main 函數運行結束後,會將 main 函數的返回值當做 exit 的參數來調用 exit 函數。
使用 exit 函數退出進程前,exit 函數會先執行用戶定義的清理函數、沖刷緩衝,關閉流等操作,然後再調用_exit 函數終止進程。
進程異常退出
情況一:向進程發生信號導致進程異常退出。
例如,在進程運行過程中向進程發生 kill -9 信號使得進程異常退出,或是使用 Ctrl+C 使得進程異常退出等。
情況二:代碼錯誤導致進程運行時異常退出。
例如,代碼當中存在野指針問題使得進程運行時異常退出,或是出現除 0 的情況使得進程運行時異常退出等。
三、進程等待
進程等待的必要性
-
子進程退出,父進程如果不讀取子進程的退出信息,子進程就會變成殭屍進程,進而造成內存泄漏。
-
進程一旦變成殭屍進程,那麼就算是 kill -9 命令也無法將其殺死,因爲誰也無法殺死一個已經死去的進程。
-
對於一個進程來說,最關心自己的就是其父進程,因爲父進程需要知道自己派給子進程的任務完成的如何。
-
父進程需要通過進程等待的方式,回收子進程資源,獲取子進程的退出信息。
獲取子進程 status
下面進程等待所使用的兩個函數 wait 和 waitpid,都有一個 status 參數,該參數是一個輸出型參數,由操作系統進行填充。
如果對 status 參數傳入 NULL,表示不關心子進程的退出狀態信息。否則,操作系統會通過該參數,將子進程的退出信息反饋給父進程。
status 是一個整型變量,但 status 不能簡單的當作整型來看待,status 的不同比特位所代表的信息不同,具體細節如下(只研究 status 低 16 比特位):
在 status 的低 16 比特位當中,高 8 位表示進程的退出狀態,即退出碼。進程若是被信號所殺,則低 7 位表示終止信號,而第 8 位比特位是 core dump 標誌。
我們通過一系列位操作,就可以根據 status 得到進程的退出碼和退出信號。
exitCode = (status >> 8) & 0xFF; //退出碼
exitSignal = status & 0x7F; //退出信號
對於此,系統當中提供了兩個宏來獲取退出碼和退出信號。
-
WIFEXITED(status):用於查看進程是否是正常退出,本質是檢查是否收到信號。
-
WEXITSTATUS(status):用於獲取進程的退出碼。
exitNormal = WIFEXITED(status); //是否正常退出
exitCode = WEXITSTATUS(status); //獲取退出碼
需要注意的是,當一個進程非正常退出時,說明該進程是被信號所殺,那麼該進程的退出碼也就沒有意義了。
進程等待的方法
wait 方法
函數原型:pid_t wait(int* status);
作用:等待任意子進程。
返回值:等待成功返回被等待進程的 pid,等待失敗返回 - 1。
參數:輸出型參數,獲取子進程的退出狀態,不關心可設置爲 NULL。
例如,創建子進程後,父進程可使用 wait 函數一直等待子進程,直到子進程退出後讀取子進程的退出信息。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
int main()
{
pid_t id = fork();
if(id == 0){
//child
int count = 10;
while(count--){
printf("I am child...PID:%d, PPID:%d\n", getpid(), getppid());
sleep(1);
}
exit(0);
}
//father
int status = 0;
pid_t ret = wait(&status);
if(ret > 0){
//wait success
printf("wait child success...\n");
if(WIFEXITED(status)){
//exit normal
printf("exit code:%d\n", WEXITSTATUS(status));
}
}
sleep(3);
return 0;
}
我們可以使用以下監控腳本對進程進行實時監控:
[cl@VM-0-15-centos procWait]$ while :; do ps axj | head -1 && ps axj | grep proc | grep -v grep;echo "######################";sleep 1;done
這時我們可以看到,當子進程退出後,父進程讀取了子進程的退出信息,子進程也就不會變成殭屍進程了。
waitpid 方法
函數原型:pid_t waitpid(pid_t pid, int* status, int options);
作用:等待指定子進程或任意子進程。
返回值:
1、等待成功返回被等待進程的 pid。
2、如果設置了選項 WNOHANG,而調用中 waitpid 發現沒有已退出的子進程可收集,則返回 0。
3、如果調用中出錯,則返回 - 1,這時 errno 會被設置成相應的值以指示錯誤所在。
參數:
1、pid:待等待子進程的 pid,若設置爲 - 1,則等待任意子進程。
2、status:輸出型參數,獲取子進程的退出狀態,不關心可設置爲 NULL。
3、options:當設置爲 WNOHANG 時,若等待的子進程沒有結束,則 waitpid 函數直接返回 0,不予以等待。若正常結束,則返回該子進程的 pid。
例如,創建子進程後,父進程可使用 waitpid 函數一直等待子進程(此時將 waitpid 的第三個參數設置爲 0),直到子進程退出後讀取子進程的退出信息。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
int main()
{
pid_t id = fork();
if (id == 0){
//child
int count = 10;
while (count--){
printf("I am child...PID:%d, PPID:%d\n", getpid(), getppid());
sleep(1);
}
exit(0);
}
//father
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret >= 0){
//wait success
printf("wait child success...\n");
if (WIFEXITED(status)){
//exit normal
printf("exit code:%d\n", WEXITSTATUS(status));
}
else{
//signal killed
printf("killed by siganl %d\n", status & 0x7F);
}
}
sleep(3);
return 0;
}
在父進程運行過程中,我們可以嘗試使用 kill -9 命令將子進程殺死,這時父進程也能等待子進程成功。
注意: 被信號殺死而退出的進程,其退出碼將沒有意義。
多進程創建以及等待的代碼模型
上面演示的都是父進程創建以及等待一個子進程的例子,實際上我們還可以同時創建多個子進程,然後讓父進程依次等待子進程退出,這叫做多進程創建以及等待的代碼模型。
例如,以下代碼中同時創建了 10 個子進程,同時將子進程的 pid 放入到 ids 數組當中,並將這 10 個子進程退出時的退出碼設置爲該子進程 pid 在數組 ids 中的下標,之後父進程再使用 waitpid 函數指定等待這 10 個子進程。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t ids[10];
for (int i = 0; i < 10; i++){
pid_t id = fork();
if (id == 0){
//child
printf("child process created successfully...PID:%d\n", getpid());
sleep(3);
exit(i); //將子進程的退出碼設置爲該子進程PID在數組ids中的下標
}
//father
ids[i] = id;
}
for (int i = 0; i < 10; i++){
int status = 0;
pid_t ret = waitpid(ids[i], &status, 0);
if (ret >= 0){
//wait child success
printf("wiat child success..PID:%d\n", ids[i]);
if (WIFEXITED(status)){
//exit normal
printf("exit code:%d\n", WEXITSTATUS(status));
}
else{
//signal killed
printf("killed by signal %d\n", status & 0x7F);
}
}
}
return 0;
}
運行代碼,這時我們便可以看到父進程同時創建多個子進程,當子進程退出後,父進程再依次讀取這些子進程的退出信息。
基於非阻塞接口的輪詢檢測方案
上述所給例子中,當子進程未退出時,父進程都在一直等待子進程退出,在等待期間,父進程不能做任何事情,這種等待叫做阻塞等待。
實際上我們可以讓父進程不要一直等待子進程退出,而是當子進程未退出時父進程可以做一些自己的事情,當子進程退出時再讀取子進程的退出信息,即非阻塞等待。
做法很簡單,向 waitpid 函數的第三個參數 potions 傳入 WNOHANG,這樣一來,等待的子進程若是沒有結束,那麼 waitpid 函數將直接返回 0,不予以等待。而等待的子進程若是正常結束,則返回該子進程的 pid。
例如,父進程可以隔一段時間調用一次 waitpid 函數,若是等待的子進程尚未退出,則父進程可以先去做一些其他事,過一段時間再調用 waitpid 函數讀取子進程的退出信息。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0){
//child
int count = 3;
while (count--){
printf("child do something...PID:%d, PPID:%d\n", getpid(), getppid());
sleep(3);
}
exit(0);
}
//father
while (1){
int status = 0;
pid_t ret = waitpid(id, &status, WNOHANG);
if (ret > 0){
printf("wait child success...\n");
printf("exit code:%d\n", WEXITSTATUS(status));
break;
}
else if (ret == 0){
printf("father do other things...\n");
sleep(1);
}
else{
printf("waitpid error...\n");
break;
}
}
return 0;
}
運行結果就是,父進程每隔一段時間就去查看子進程是否退出,若未退出,則父進程先去忙自己的事情,過一段時間再來查看,直到子進程退出後讀取子進程的退出信息。
四、進程程序替換
替換原理
用 fork 創建子進程後,子進程執行的是和父進程相同的程序(但有可能執行不同的代碼分支),若想讓子進程執行另一個程序,往往需要調用一種 exec 函數。
當進程調用一種 exec 函數時,該進程的用戶空間代碼和數據完全被新程序替換,並從新程序的啓動例程開始執行。
當進行進程程序替換時,有沒有創建新的進程?
進程程序替換之後,該進程對應的 PCB、進程地址空間以及頁表等數據結構都沒有發生改變,只是進程在物理內存當中的數據和代碼發生了改變,所以並沒有創建新的進程,而且進程程序替換前後該進程的 pid 並沒有改變。
子進程進行進程程序替換後,會影響父進程的代碼和數據嗎?
子進程剛被創建時,與父進程共享代碼和數據,但當子進程需要進行進程程序替換時,也就意味着子進程需要對其數據和代碼進行寫入操作,這時便需要將父子進程共享的代碼和數據進行寫時拷貝,此後父子進程的代碼和數據也就分離了,因此子進程進行程序替換後不會影響父進程的代碼和數據。
替換函數
替換函數有六種以 exec 開頭的函數,它們統稱爲 exec 函數:
一、int execl(const char *path, const char *arg, ...);
第一個參數是要執行程序的路徑,第二個參數是可變參數列表,表示你要如何執行這個程序,並以 NULL 結尾。
例如,要執行的是 ls 程序。
execl("/usr/bin/ls", "ls", "-a", "-i", "-l", NULL);
二、int execlp(const char *file, const char *arg, ...);
第一個參數是要執行程序的名字,第二個參數是可變參數列表,表示你要如何執行這個程序,並以 NULL 結尾。
例如,要執行的是 ls 程序。
execlp("ls", "ls", "-a", "-i", "-l", NULL);
三、int execle(const char *path, const char *arg, ..., char *const envp[]);
第一個參數是要執行程序的路徑,第二個參數是可變參數列表,表示你要如何執行這個程序,並以 NULL 結尾,第三個參數是你自己設置的環境變量。
例如,你設置了 MYVAL 環境變量,在 mycmd 程序內部就可以使用該環境變量。
char* myenvp[] = { "MYVAL=2021", NULL };
execle("./mycmd", "mycmd", NULL, myenvp);
四、int execv(const char *path, char *const argv[]);
第一個參數是要執行程序的路徑,第二個參數是一個指針數組,數組當中的內容表示你要如何執行這個程序,數組以 NULL 結尾。
例如,要執行的是 ls 程序。
char* myargv[] = { "ls", "-a", "-i", "-l", NULL };
execv("/usr/bin/ls", myargv);
五、int execvp(const char *file, char *const argv[]);
第一個參數是要執行程序的名字,第二個參數是一個指針數組,數組當中的內容表示你要如何執行這個程序,數組以 NULL 結尾。
例如,要執行的是 ls 程序。
char* myargv[] = { "ls", "-a", "-i", "-l", NULL };
execvp("ls", myargv);
六、int execve(const char *path, char *const argv[], char *const envp[]);
第一個參數是要執行程序的路徑,第二個參數是一個指針數組,數組當中的內容表示你要如何執行這個程序,數組以 NULL 結尾,第三個參數是你自己設置的環境變量。
例如,你設置了 MYVAL 環境變量,在 mycmd 程序內部就可以使用該環境變量。
char* myargv[] = { "mycmd", NULL };
char* myenvp[] = { "MYVAL=2021", NULL };
execve("./mycmd", myargv, myenvp);
函數解釋
-
這些函數如果調用成功,則加載指定的程序並從啓動代碼開始執行,不再返回。
-
如果調用出錯,則返回 - 1。
也就是說,exec 系列函數只要返回了,就意味着調用失敗。
命名理解
這六個 exec 系列函數的函數名都以 exec 開頭,其後綴的含義如下:
-
l(list):表示參數採用列表的形式,一一列出。
-
v(vector):表示參數採用數組的形式。
-
p(path):表示能自動搜索環境變量 PATH,進行程序查找。
-
e(env):表示可以傳入自己設置的環境變量。
事實上,只有 execve 纔是真正的系統調用,其它五個函數最終都是調用的 execve,所以 execve 在 man 手冊的第 2 節,而其它五個函數在 man 手冊的第 3 節,也就是說其他五個函數實際上是對系統調用 execve 進行了封裝,以滿足不同用戶的不同調用場景的。
下圖爲 exec 系列函數族之間的關係:
做一個簡易的 shell
shell 也就是命令行解釋器,其運行原理就是:當有命令需要執行時,shell 創建子進程,讓子進程執行命令,而 shell 只需等待子進程退出即可。
其實 shell 需要執行的邏輯非常簡單,其只需循環執行以下步驟:
-
獲取命令行。
-
解析命令行。
-
創建子進程。
-
替換子進程。
-
等待子進程退出。
其中,創建子進程使用 fork 函數,替換子進程使用 exec 系列函數,等待子進程使用 wait 或者 waitpid 函數。
於是我們可以很容易實現一個簡易的 shell,代碼如下:
#include <stdio.h>
#include <pwd.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#define LEN 1024 //命令最大長度
#define NUM 32 //命令拆分後的最大個數
int main()
{
char cmd[LEN]; //存儲命令
char* myargv[NUM]; //存儲命令拆分後的結果
char hostname[32]; //主機名
char pwd[128]; //當前目錄
while (1){
//獲取命令提示信息
struct passwd* pass = getpwuid(getuid());
gethostname(hostname, sizeof(hostname)-1);
getcwd(pwd, sizeof(pwd)-1);
int len = strlen(pwd);
char* p = pwd + len - 1;
while (*p != '/'){
p--;
}
p++;
//打印命令提示信息
printf("[%s@%s %s]$ ", pass->pw_name, hostname, p);
//讀取命令
fgets(cmd, LEN, stdin);
cmd[strlen(cmd) - 1] = '\0';
//拆分命令
myargv[0] = strtok(cmd, " ");
int i = 1;
while (myargv[i] = strtok(NULL, " ")){
i++;
}
pid_t id = fork(); //創建子進程執行命令
if (id == 0){
//child
execvp(myargv[0], myargv); //child進行程序替換
exit(1); //替換失敗的退出碼設置爲1
}
//shell
int status = 0;
pid_t ret = waitpid(id, &status, 0); //shell等待child退出
if (ret > 0){
printf("exit code:%d\n", WEXITSTATUS(status)); //打印child的退出碼
}
}
return 0;
}
效果展示:
說明:
當執行./myshell 命令後,便是我們自己實現的 shell 在進行命令行解釋,我們自己實現的 shell 在子進程退出後都打印了子進程的退出碼,我們可以根據這一點來區分我們當前使用的是 Linux 操作系統的 shell 還是我們自己實現的 shell。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/uf_mJZpirL8kjBzPEy9aRw