一、進程創建
1.1 fork函數初識
在Linux中,fork函數是非常重要的函數,它從已存在進程中創建一個新進程。新進程為子進程,而原進程為父進程。
返回值:
在子進程中返回0,父進程中返回子進程的PID,子進程創建失敗返回-1。
進程調用fork,當控制轉移到內核中的fork代碼后,內核做:
分配新的內存塊和內核數據結構給子進程。
將父進程部分數據結構內容拷貝至子進程。
添加子進程到系統進程列表當中。
fork返回,開始調度器調度。
fork之后,父子進程代碼共享。例如:
這里可以看到,Before只輸出了一次,而After輸出了兩次。其中,Before是由父進程打印的,而調用fork函數之后打印的兩個After,則分別由父進程和子進程兩個進程執行。也就是說,fork之前父進程獨立執行,而fork之后父子兩個執行流分別執行。
注意: fork之后,父進程和子進程誰先執行完全由調度器決定,而一般父進程是先退出的,因為父進程要等待子進程(此內容會在進程等待章節講)。
1.2 fork函數返回值
子進程返回0,父進程返回的是子進程的pid。
這是為什么呢?
一個父進程可以創建多個子進程(1:n),而一個子進程只能有一個父進程。因此,對于子進程來說,父進程是不需要被標識的,子進程肯定是被創建出來的,有子進程一定就會有父進程;而對于父進程來說,子進程是需要被標識的,因為父進程創建子進程的目的是讓其執行任務的,父進程只有知道了子進程的PID才能很好的對該子進程指派任務并且可以用PID和指令去查看進程的信息。
為什么fork函數有兩個返回值?
父進程調用fork函數后,為了創建子進程,fork函數內部將會進行一系列操作,包括創建子進程的進程控制塊(PCB)、創建子進程的進程地址空間(struct files_struct)、創建子進程對應的頁表等等。子進程創建完畢后,操作系統還需要將子進程的進程控制塊添加到系統進程雙鏈表當中,此時子進程便創建完畢了。所以說子進程也用父進程的代碼,return不也是代碼嗎,所以說父子進程都要return,所以有兩個返回值。
1.3 寫時拷貝(和C++中的深拷貝類似)
當子進程剛剛被創建時,子進程和父進程的數據和代碼是共享的,即父子進程的代碼和數據通過頁表映射到物理內存的同一塊空間。只有當父進程或子進程需要修改數據時,才將父進程的數據在內存當中拷貝一份,然后再進行修改。
為什么數據要進行寫時拷貝?
?進程具有獨立性。多進程運行,需要獨享各種資源,多進程運行期間互不干擾,不能讓子進程的修改影響到父進程。
為什么不在創建子進程的時候就進行將父進程的所有數據進行寫時拷貝?
子進程也可能使用父進程的數據,并且在子進程不對數據進行寫入的情況下,沒有必要對數據進行拷貝,我們應該按需分配,在需要修改數據的時候再分配(延時分配),這樣可以高效的使用內存空間。
代碼會不會進行寫時拷貝?
一般情況下是不會的,在進行進程替換的時候,則需要進行代碼的寫時拷貝。
fork常規用法
- 一個進程希望復制自己,使子進程同時執行不同的代碼段。例如父進程等待客戶端請求,生成子進程來處理請求。
- 一個進程要執行一個不同的程序。例如子進程從fork返回后,調用exec函數。
fork調用失敗的原因
fork函數創建子進程也可能會失敗,有以下兩種情況:
- 系統中有太多的進程,內存空間不足,子進程創建失敗。
- 實際用戶的進程數超過了限制,子進程創建失敗
二、進程終止
2.1 進程退出場景
進程退出只有三種情況:
- 代碼運行完畢,結果正確
- 代碼運行完畢,結果不正確
- 代碼異常終止(進程崩潰)
2.2 進程退出碼
我們都知道main函數是代碼的入口,但實際上main函數只是用戶級別代碼的入口,main函數也是被其他函數調用的,例如在VS2013當中main函數就是被一個名為__tmainCRTStartup的函數所調用,而__tmainCRTStartup函數又是通過加載器被操作系統所調用的,也就是說main函數是間接性被操作系統所調用的。
既然main函數是間接性被操作系統所調用的,那么當main函數調用結束后就應該給操作系統返回相應的退出信息,而這個所謂的退出信息就是以退出碼的形式作為main函數的返回值返回,我們一般以0表示代碼成功執行完畢,以非0表示代碼執行過程中出現錯誤,這就是為什么我們都在main函數的最后返回0的原因。
當我們的代碼運行起來就變成了進程,當進程結束后main函數的返回值實際上就是該進程的進程退出碼,我們可以使用echo $?命令查看最近一次進程退出的退出碼信息。
例如,對于下面這個簡單的代碼:
代碼運行結束后,我們可以用下列指令查看該進程的進程退出碼。
echo $?
?
為什么以0表示代碼執行成功,以非0表示代碼執行錯誤?
?因為代碼執行成功只有一種情況,成功了就是成功了,而代碼執行錯誤卻有多種原因,例如內存空間不足、非法訪問以及棧溢出等等,我們就可以用這些非0的數字分別表示代碼執行錯誤的原因。這樣做利于區分。
C語言當中的strerror函數可以通過錯誤碼,獲取該錯誤碼在C語言當中對應的錯誤信息:
實際上Linux中的ls、pwd等命令都是可執行程序,使用這些命令后我們也可以查看其對應的退出碼。
可以看到,這些命令成功執行后,其退出碼也是0。
但是命令執行錯誤后,其退出碼就是非0的數字,該數字具體代表某一錯誤信息。
注意:?退出碼都有對應的字符串含義,幫助用戶確認執行失敗的原因,而這些退出碼具體代表什么含義是人為規定的,不同環境下相同的退出碼的字符串含義可能不同。?
2.3 進程常見退出方法
1)正常終止(可以通過 1. 從main返回 2. 調用exit 3. _exit 異常退出: echo $? 查看進程退出碼)
2)?異常中止: ctrl + c,信號終止。
2.4 進程正常退出
2.4.1 return退出
在main函數中使用return退出進程是我們常用的方法。
例如,在main函數最后使用return退出進程。
2.4.2 exit函數
使用exit函數退出進程也是我們常用的方法,exit函數可以在代碼中的任何地方退出進程,并且exit函數在退出進程前會做一系列工作:
執行用戶通過atexit或on_exit定義的清理函數。
關閉所有打開的流,所有的緩存數據均被寫入。
調用_exit函數終止進程。
例如,以下代碼中exit終止進程前會將該進程的緩沖區當中的數據輸出。
2.4.3 _exit函數
使用_exit函數退出進程的方法我們并不經常使用,_exit函數也可以在代碼中的任何地方退出進程,但是_exit函數會直接終止進程,并不會在退出進程前會做刷新,這和實現有關系,exit是庫函數,_exit是系統調用,exit的實現是封裝的_exit系統調用但包含了會刷新一下緩沖區,而_exit函數是系統調用,并沒有緩沖區的概念,自然就不會刷新。刷新和fflush(stdout)或\n一個概念。
例如,以下代碼中使用_exit終止進程,則緩沖區當中的數據將不會被輸出。
2.4.4 return、exit和_exit之間的區別與聯系
只有在main函數當中的return才能起到退出進程的作用,子函數當中return不能退出進程,而exit函數和_exit函數在代碼中的任何地方使用都可以起到退出進程的作用。
使用exit函數退出進程前,exit函數會執行用戶定義的清理函數、沖刷緩沖,關閉流等操作,然后再終止進程,而_exit函數會直接終止進程,不會做任何收尾工作。
執行return num等同于執行exit(num),因為調用main函數運行結束后,會將main函數的返回值當做exit的參數來調用exit函數。
使用exit函數退出進程前,exit函數會先執行用戶定義的清理函數、沖刷緩沖,關閉流等操作,然后再調用_exit函數終止進程。
2.4進程異常退出
1) 向進程發生信號導致進程異常退出。例如,在進程運行過程中向進程發生kill -9信號使得進程異常退出,或是使用Ctrl+C使得進程異常退出等。
2)?代碼錯誤導致進程運行時異常退出。例如,代碼當中存在野指針問題使得進程運行時異常退出,或是出現除0的情況使得進程運行時異常退出等。
三、進程等待
3.1 進程等待的必要性
之前講過,子進程退出,父進程如果不管不顧,就可能造成‘僵尸進程’的問題,進而造成內存泄漏。
另外,進程一旦變成僵尸狀態,那就刀槍不入,“殺人不眨眼”的kill -9 也無能為力,因為誰也沒有辦法 殺死一個已經死去的進程。
最后,父進程派給子進程的任務完成的如何,我們需要知道。如,子進程運行完成,結果對還是不對, 或者是否正常退出。 父進程通過進程等待的方式,回收子進程資源,獲取子進程退出信息。
3.2 獲取子進程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; //退出信號
? ? ?記不住也是沒關系的,系統當中提供了兩個宏來獲取退出碼和退出信號。
exitNormal = WIFEXITED(status); //是否正常退出
exitCode = WEXITSTATUS(status); //獲取退出碼
當一個進程非正常退出時,說明該進程是被信號所殺,那么該進程的退出碼也就沒有意義了。
3.3 進程等待的方法
3.3.1 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){//childint count = 10;while(count--){printf("I am child...PID:%d, PPID:%d\n", getpid(), getppid());sleep(1);}exit(0);}//fatherint status = 0;pid_t ret = wait(&status);if(ret > 0){//wait successprintf("wait child success...\n");if(WIFEXITED(status)){//exit normalprintf("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
這時我們可以看到,當子進程退出后,父進程讀取了子進程的退出信息,子進程也就不會變成僵尸進程了。
3.3.2 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命令將子進程殺死,這時父進程也能等待子進程成功。
注意:?被信號殺死而退出的進程,其退出碼將沒有意義。
3.3.3 wait VS waitpid
wait
和 waitpid
都是用于等待子進程結束并獲取子進程狀態的系統調用,但在使用上有一些區別:
-
wait
:wait
系統調用會使當前進程阻塞,直到任意一個子進程結束為止。它會暫停當前進程的執行,直到有子進程結束,然后返回結束子進程的進程號。如果沒有子進程退出,wait
會一直阻塞等待。 -
waitpid
:waitpid
允許指定等待的子進程的進程號,通過傳遞不同的參數可以實現不同的等待方式。可以通過指定pid
、options
參數來控制等待的子進程,如等待特定進程、等待非阻塞狀態等。與wait
不同,waitpid
可以通過傳遞參數來選擇是否阻塞等待子進程結束。
總的來說,wait
和 waitpid
都是用于等待子進程結束的系統調用,而 waitpid
提供了更多的靈活性和控制選項,可以更精確地指定等待的子進程。
四、進程程序替換
4.1 原理
用fork創建子進程后,子進程執行的是和父進程相同的程序(但有可能執行不同的代碼分支),若想讓子進程執行另一個程序,往往需要調用一種exec函數。
當進程調用一種exec函數時,該進程的用戶空間代碼和數據完全被新程序替換,并從新程序的啟動例程開始執行。
當進行進程程序替換時,有沒有創建新的進程?
進程程序替換之后,該進程對應的PCB、進程地址空間以及頁表等數據結構都沒有發生改變,只是進程在物理內存當中的數據和代碼發生了改變,所以并沒有創建新的進程,而且進程程序替換前后該進程的pid并沒有改變。
子進程進行進程程序替換后,會影響父進程的代碼和數據嗎?
子進程剛被創建時,與父進程共享代碼和數據,但當子進程需要進行進程程序替換時,也就意味著子進程需要對其數據和代碼進行寫入操作,這時便需要將父子進程共享的代碼和數據進行寫時拷貝,此后父子進程的代碼和數據也就分離了,因此子進程進行程序替換后不會影響父進程的代碼和數據。
4.2 替換函數
替換函數有六種以exec開頭的函數,它們統稱為exec函數:
4.2.1 execl函數
一 int execl(const char *path, const char *arg, ...);
第一個參數是要執行程序的路徑,第二個參數是可變參數列表,表示你要如何執行這個程序,并以NULL結尾。
舉例:
execl("/usr/bin/ls", "ls", "-a", "-i", "-l", NULL);
?4.2.2 execlp函數
二、int execlp(const char *file, const char *arg, ...);
舉例:
execl("/usr/bin/ls", "ls", "-a", "-i", "-l", NULL);
第一個參數是要執行程序的名字,第二個參數是可變參數列表,表示你要如何執行這個程序,并以NULL結尾。
4.2.3 execle函數
int execle(const char *path, const char *arg, ..., char *const envp[]);
第一個參數是要執行程序的路徑,第二個參數是可變參數列表,表示你要如何執行這個程序,并以NULL結尾,第三個參數是你自己設置的環境變量。
例如,你設置了MYVAL環境變量,在mycmd程序內部就可以使用該環境變量。
char* myenvp[] = { "MYVAL=2021", NULL };
execle("./mycmd", "mycmd", NULL, myenvp);
4.3.4 execv函數
int execv(const char *path, char *const argv[]);
第一個參數是要執行程序的路徑,第二個參數是一個指針數組,數組當中的內容表示你要如何執行這個程序,數組以NULL結尾。
例如,要執行的是ls:
char* myargv[] = { "ls", "-a", "-i", "-l", NULL };
execv("/usr/bin/ls", myargv);
4.3.5 execvp函數
int execvp(const char *file, char *const argv[]);
例如,要執行的是ls程序:
char* myargv[] = { "ls", "-a", "-i", "-l", NULL };
execvp("ls", myargv);
4.3.6 execve函數
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);
4.3 函數解釋
- 這些函數如果調用成功,則加載指定的程序并從啟動代碼開始執行,不再返回。
- 如果調用出錯,則返回-1。
也就是說,exec系列函數只要返回了,就意味著調用失敗。
4.4 命名理解
這六個exec系列函數的函數名都以exec開頭,其后綴的含義如下:
- l(list):表示參數采用列表的形式,一一列出。
- v(vector):表示參數采用數組的形式。
- p(path):表示能自動搜索環境變量PATH,進行程序查找。
- e(env):表示可以傳入自己設置的環境變量。
事實上,只有execve才是真正的系統調用,其它五個函數最終都是調用的execve,所以execve在man手冊的第2節,而其它五個函數在man手冊的第3節,也就是說其他五個函數實際上是對系統調用execve進行了封裝,以滿足不同用戶的不同調用場景的。
下圖為exec系列函數族之間的關系: