文章目錄
- 前言
- 一、重談進程創建
- 二、進程終止
- 2.1 正常終止的退出碼機制
- 2.2 異常終止的信號機制
- 2.3 進程常見的退出方法
- 三、進程等待:避免僵尸進程的關鍵
- 3.1 進程等待的必要性
- 3.2 進程等待的兩個系統調用接口
- 3.2.1 wait()
- 3.2.2 waitpid()
- 區別
- 四、進程程序替換
- 4.1 進程替換原理(單進程)
- 4.2 多進程版程序替換效果——驗證程序替換接口
- 4.3 進程替換的接口
- 總結
前言
本文將用最直白的語言帶你掌握Linux進程管理的四大核心操作!包含大量代碼示例與圖解,建議邊看邊動手實踐!
一、重談進程創建
當我們使用fork()
系統調用創建子進程時,父子進程的執行順序并非固定,而是由操作系統的進程調度器決定。這種不確定性源于現代操作系統的并發特性
可能輸出結果:
父進程PID: 1234
子進程PID: 1235或
子進程PID: 1235
父進程PID: 1234
1.2 調度機制解析
調度策略 | 說明 | 典型系統 |
---|---|---|
完全公平調度(CFS) | 基于虛擬運行時間分配CPU時間片 | Linux默認 |
實時調度 | 優先級驅動 | 嵌入式系統 |
輪轉調度 | 均分時間片 | 早期Unix系統 |
二、進程終止
2.1 正常終止的退出碼機制
為什么main函數總是return 0 返回,這個0去哪里了?為什么
進程退出場景:
- 代碼運行完畢,結果正確
- 代碼運行完畢,結果不正確
- 代碼異常終止
在程序運行期間,我們通常并不關心意料之中的【程序正常運行且結果正確】的情況,反而那些結果有誤、異常退出的情況能夠讓程序員及時發現問題原因并解決它。
程序中的return 0
表示的是進程的退出碼,表示進程的運行結果是否正確
此外,在系統層面上,進程運行時,一般來說是父進程最關心自己的子進程的運行狀態,所以退出碼是直接向父進程呈現的。
換言之,在子進程正常運行且退出時(返回0),父進程并不關心,更多的是關心子進程運行失敗的原因并轉交給用戶,此時,程序就可以用return返回不同的值,來表示不同的出錯原因。
我們可以使用以下指令來查看最近一次運行的進程返回的退出碼是多少:
$ echo $? # $?表示最近一個程序運行的退出碼
此外,在C語言中有將退出碼轉化為錯誤信息的strerror()
函數
//打印所有退出碼表示的信息
#include <string.h>int main()
{int i = 0;for(; i<200; i++){printf("%d: %s\n",i,strerror(i));}return 0;
}
此外,在我們平常使用的cd指令、pwd指令等,底層實現也是一段程序,同樣會返回錯誤碼信息:
舉例: ls 不存在的目錄時,返回2錯誤碼
(No such file or directory)
2.2 異常終止的信號機制
在進程運行時,正常的順序是先檢查是否異常,再判斷結果信息,只要是程序異常終止了,退出碼就沒有任何意義了。例如野指針問題或者除0錯誤等都會觸發硬件級的異常問題,操作系統會直接給進程返回某種信號
結束進程(kill)。
【這部分的內容我們會在后面的信號章節詳細為大家講解】
信號 | 值 | 說明 | 觸發場景 |
---|---|---|---|
SIGSEGV | 11 | 段錯誤 | 非法內存訪問 |
SIGFPE | 8 | 算術異常 | 除零操作 |
SIGKILL | 9 | 強制終止 | kill -9 |
2.3 進程常見的退出方法
- return 0(在 main 函數中)
在 main 函數中使用 return 0 表示程序正常終止,返回值 0 作為進程的退出狀態碼。
觸發 main 函數的隱式 exit 調用,
執行標準庫的清理工作(如刷新 I/O 緩沖區、關閉文件流)。
最終通過系統調用(如 _exit)終止進程。
int main() {printf("Hello"); // 無換行符,但 return 會刷新緩沖區return 0; // 輸出 Hello
}
- exit(int status)
標準庫函數(stdlib.h),立即終止進程,返回狀態碼 status
刷新所有標準 I/O 緩沖區(如 printf 未輸出的內容)。
關閉所有打開的流(FILE 類型)。
最終調用 _exit 終止進程。
#include <stdlib.h>
void func() {printf("World"); // 無換行符exit(0); // 輸出 World
}
- _exit(int status)
系統調用(unistd.h),直接終止進程,返回狀態碼 status
不執行任何清理:
不刷新 I/O 緩沖區(可能導致數據丟失)。
不調用 atexit 注冊的函數。
立即終止進程。
#include <unistd.h>
int main() {printf("Hello"); // 無換行符_exit(0); // 無輸出(緩沖區未刷新)
}
方式 | 所屬庫/系統調用 | 清理操作(I/O 緩沖區、atexit 函數) | 適用場景 |
---|---|---|---|
return 0 | C 標準庫 | 是(隱式調用 exit) | main 函數正常退出 |
exit() | C 標準庫 | 是 | 任意位置終止并清理資源 |
_exit() | 系統調用 | 否 | 快速終止,避免干擾(如子進程) |
三、進程等待:避免僵尸進程的關鍵
通過系統調用wait / waitpid
,來對子進程進行狀態檢測與回收的功能。
3.1 進程等待的必要性
-
避免僵尸進程(Zombie Process)
子進程退出后,若父進程未調用等待函數,子進程的退出狀態會殘留在內核中,成為僵尸進程(占用 PID,但無法被調度,并且)。
后果:大量僵尸進程會導致 PID 耗盡,系統無法創建新進程。 -
同步父子進程
父進程可能需要等待子進程完成特定任務后再繼續執行(如數據處理、文件讀寫)。 -
獲取子進程退出狀態
父進程需知道子進程是正常退出(如返回碼)、被信號終止,還是其他異常情況。【這里可以通過系統調用來獲取不同的值來表示不同的情況,在wait和waitpid接口中存在參數status(輸出型參數,由操作系統填充,如果傳遞NULL,表示不關心子進程的退出狀態信息。),status在系統中以整形的方式存在,但不同區間的比特位代表不同的信息(如下圖)】
pid_t wait(int *status);
pid_ t waitpid(pid_t pid, int *status, int options);
進程之間具有獨立性,因此父進程獲取子進程運行狀態不能簡單地通過訪問某個全局變量獲取子進程狀態因為父進程無法看到該變量(每個進程有自己的獨立的進程地址空間),只能通過操作系統調用接口來實現。
關于status參數,它是一個整型指針,用來存儲子進程的退出狀態。status不能簡單的當作整形來看待,可以當作位圖來看待,具體細節如下圖(只研究status低16比特位),表示含義為:
如果進程正常退出,那么高八位為進程的退出狀態碼;如果進程被信號所殺(異常中止),那么低七位表示信號碼,第八位表示core dump標志(核心轉儲,是操作系統在程序異常終止時生成的一個文件,記錄了程序崩潰時的內存狀態、寄存器值、堆棧信息等關鍵數據。)
exitCode = (status >> 8) & 0xFF; //退出碼
exitSignal = status & 0x7F; //退出信號//or 使用系統定義的宏正常退出:
WIFEXITED(status):若為真,表示子進程正常退出。
WEXITSTATUS(status):獲取子進程的退出碼(即exit(code)中的code)。信號終止:
WIFSIGNALED(status):若為真,表示子進程被信號終止。
WTERMSIG(status):獲取導致終止的信號編號(如SIGKILL對應9)。
總的來說,系統為什么要進行進程等待:
- 僵尸進程無法被kill -9信號殺死(無法殺掉一個已經死掉的進程)殺死,需要通過進程等待來殺掉進程。
- 需要通過進程等待獲得子進程的退出情況(退出碼)
3.2 進程等待的兩個系統調用接口
3.2.1 wait()
wait函數的作用是讓父進程等待子進程結束。調用wait的時候,父進程會被阻塞,直到有一個子進程結束。然后wait會返回結束的子進程的PID,并且通過參數status來傳遞子進程的退出狀態。
#include <sys/types.h>
#include <sys/wait.h>pid_t wait(int *status);//返回值:成功返回被等待進程pid,失敗返回-1。
參數:輸出型參數,獲取子進程退出狀態,不關心則可以設置成為NULL
如果有多個子進程,父進程每次調用wait只能處理一個,所以可能需要循環調用直到所有子進程都被回收,否則可能會有僵尸進程殘留。
3.2.2 waitpid()
waitpid函數更靈活。它的參數中可以指定要等待的子進程的PID,或者用-1表示等待任意子進程,類似wait。另外,waitpid還可以設置選項,比如WNOHANG
,這樣父進程不會被阻塞,可以立即返回檢查是否有子進程結束。這在需要父進程同時處理其他任務的時候很有用,避免阻塞。
pid_ t waitpid(pid_t pid, int *status, int options);
//返回值:當正常返回的時候waitpid返回收集到的子進程的進程ID;如果設置了選項WNOHANG,而調用中waitpid發現沒有已退出的子進程可收集,則返回0;如果調用中出錯,則返回-1,這時errno會被設置成相應的值以指示錯誤所在;
//參數:pid:Pid=-1,等待任一個子進程。與wait等效。Pid>0.等待其進程ID與pid相等的子進程。status:WIFEXITED(status): 若為正常終止子進程返回的狀態,則為真。(查看進程是否是正常退出)WEXITSTATUS(status): 若WIFEXITED非零,提取子進程退出碼。(查看進程的退出碼)options:WNOHANG: 若pid指定的子進程沒有結束,則waitpid()函數返回0,不予以等待。若正常結束,則返回該子進程的ID。
區別
那wait和waitpid的區別主要在哪兒呢?
參數 | wait | waitpid |
---|---|---|
指定進程 | ? 只能等任意 | ? 可指定PID |
阻塞控制 | ? 強制等待 | ? 支持非阻塞 |
狀態獲取 | ? 基礎信息 | ? 詳細信息 |
如果子進程永遠在執行,父進程wait會一直在等待子進程退出,在等待期間,父進程不能做任何事情,這種等待叫做阻塞等待 (回憶阻塞狀態,scanf會等待硬件資源,這個例子是等待軟件資源)
除了可以指定PID和選項之外,waitpid可以更精確地控制要等待哪個子進程,以及是否阻塞。比如,當父進程有多個子進程時,如果使用wait,只能按任意順序處理結束的子進程,而waitpid可以選擇等待特定的子進程,或者以非阻塞方式輪詢WNOHANG子進程狀態。
舉個例子,假設父進程創建了三個子進程,然后想要等待第二個子進程結束,這時候就可以用waitpid,傳入第二個子進程的PID作為參數。或者,如果父進程不想阻塞,可以設置WNOHANG選項,這樣如果沒有子進程結束,waitpid會立即返回0,而不會讓父進程掛起。
那如何正確使用這些函數呢?
比如,父進程fork之后,子進程可能執行不同的任務,父進程需要收集它們的退出狀態。用wait的話,父進程會阻塞直到有子進程結束,然后處理。而用waitpid的話,可以更靈活,比如循環調用waitpid(-1, &status, WNOHANG)來非阻塞地收集所有已結束的子進程,避免僵尸進程的產生,同時父進程可以做其他事情。
關于非阻塞輪詢方式需要注意:
非阻塞輪詢方式WNOHANG,可以做自己的事情
- 父進程順帶做自己事情的時候,這個任務不能太重,因為等待子進程退出信息才是主要任務
- 若子進程先于父進程任務退出,那么等待一會兒再回收也是可以的,并不是立即需要回收子進程。
- 通過進程等待,可以保證父進程是最后一個退出的進程,這意味著父進程創建的所有子進程已被回收。
//wait
int status;
pid_t pid = fork();
if (pid == 0) {// 子進程執行任務后退出//while(1){} 若子進程不退出,那么父進程wait阻塞等待exit(42);
} else {wait(&status); // 父進程阻塞等待子進程結束if (WIFEXITED(status)) {printf("子進程退出碼: %d\n", WEXITSTATUS(status)); // 輸出42}
}//waitpid
int status;
pid_t child_pid = fork();
if (child_pid == 0) {// 子進程任務//while(1){} 可以通過WNOHANG觸發非阻塞輪詢方式exit(3);
} else {// 父進程非阻塞等待特定子進程while (waitpid(child_pid, &status, WNOHANG) == 0) {printf("子進程未結束,父進程繼續工作...\n");sleep(1);}if (WIFEXITED(status)) {printf("子進程退出碼: %d\n", WEXITSTATUS(status)); // 輸出3}
}
四、進程程序替換
進程程序替換是Linux系統中一個重要的概念,它允許一個進程用另一個程序完全替換當前的執行內容,同時保留進程的基本屬性(如PID、文件描述符等)。這一過程通過exec系列函數實現,這些函數能夠加載新的程序映像到當前進程中,從而改變進程的行為。調用exec并不創建新進程,所以調用exec前后該進程的pid并未改變。
4.1 進程替換原理(單進程)
之前了解到一個進程執行的過程是,首先系統創建該進程的PCB并分配進程地址空間(虛擬內存),創建映射物理內存的頁表。進程先將存儲在磁盤上的代碼塊和數據塊加載至物理內存后開始執行,運行到execl函數處,由于ls指令也是存放在磁盤上的,進程替換就非常簡單地用ls指令的代碼塊和數據塊替換掉進程的代碼塊和數據塊,再將進程從ls指令的main函數重新執行,就達到了進程切換的目的。
int main()
{printf("before\n");execl("/usr/bin/ls","ls","-a",NULL);printf("after\n");//無法執行after,因為已被替換return 0;
}
4.2 多進程版程序替換效果——驗證程序替換接口
int main()
{pid_t id = fork();if(id == 0){// 子進程執行進程替換//sleep(5)printf("before: i am a process,pid: %d, ppid: %d\n",getpid(),getppid());// 要執行的程序 、 怎樣執行該程序 、 ...為可變參數列表execl("/usr/bin/ls","ls","-a","-l",NULL);//這類方法的標準寫法//execl("/usr/bin/top","top",NULL);//execv("usr/bin/ls",myargv);//execl("./otherExec","otherExec",NULL);printf("after: i am a process,pid: %d, ppid: %d\n",getpid(),getppid());exit(0);//防止子進程繼續運行后續代碼}//父進程pid_t ret = waitpid(id,NULL,0);if(ret > 0){printf("等待子進程返回成功,father pid: %d, ret id: %d\n",getpid(),ret);}sleep(5);return 0;
}
現象:程序打印before,等待五秒后切換ls進程執行,ls執行完畢瞬間waitpid接收到子進程退出信號,遂執行父進程下的打印操作,等待五秒后程序退出。
我們知道父子進程是獨立的兩塊進程地址空間,且存在寫時拷貝技術,所以子進程在替換進程的時候,是不會影響到父進程的。
寫時拷貝在修改數據時生效,替換了ls的數據塊可以理解,但代碼塊存放在常量區按理來說不應該會被修改,所以不應該發生寫時拷貝,但代碼塊不發生寫時拷貝,豈不是就會影響到父進程的執行?
答:系統層面上寫時拷貝不僅發生在數據區,也發生在代碼區,用戶無法修改代碼區的數據,但操作系統需要寫入ls代碼至父子進程,由于這塊代碼區是父子共享的且只讀,所以仍然發生了寫時拷貝。
程序替換有沒有創建新的進程?
很明顯是沒有,這一點在 ps ajx
指令可以看到,并沒有額外的進程被創建,只是修改了原PCB等結構體的一些字段。但系統是如何做到替換呢?
exec系統調用的步驟是:加載新程序到內存,替換原有代碼段和數據段,調整堆棧,重置PC等寄存器,最后跳轉到新程序的入口點(main)。
從進程地址空間角度來看,每個進程由用戶地址空間與內核空間構成,在替換時,會保留內核結構,之是將新程序的代碼和數據加載到當前進程的用戶地址空間(直接覆蓋),這一操作只需通過改變文件描述符表就可以做到替換,不創建新的進程是因為內核在執行替換時是復用了原本的內核數據結構(PCB信息),并且只是更改映射關系,重新創建進程開銷巨大,這也就是exec與fork函數的區別之一。
CPU如何知道程序的入口地址?
Linux系統下,在編譯時會產生一個程序頭表,該表中存放著可執行文件的代碼段和數據段的加載位置,在執行進程替換后,程序頭表也會被替換,但同時,程序計數器PC的值也會被重置頭表的入口地址,根據偏移量,PC就能夠找到新程序的入口地址了。
多進程替換的特點:
- 替換后,進程的用戶空間代碼和數據被完全替換,新程序從其main函數開始執行。
- 進程的PID保持不變,但代碼段、數據段、堆和棧會被替換。
- 替換成功后,原進程的后續代碼不會執行,只有在替換失敗時才會繼續執行
4.3 進程替換的接口
//庫函數 man 3int execl(const char *path, const char *arg, ...);//路徑+參數列表,參數逐個傳遞,以NULL結尾int execlp(const char *file, const char *arg, ...);//文件名+參數列表,自動在PATH中查找int execle(const char *path, const char *arg, ...,char *const envp[]);//可自定義環境變量(通過envp數組傳遞)int execv(const char *path, char *const argv[]);//路徑+參數數組,參數通過數組傳遞int execvp(const char *file, char *const argv[]);//文件名+參數數組,自動在PATH環境變量中查找可執行文件
exec系列函數:本質是一個加載器
環境變量是什么時候給進程的?
環境變量也是數據,創建子進程的時候環境變量就已經被子進程繼承下去了(進程地址空間有一部分為進程環境變量),于是即使不寫main函數中的環境變量參數表,系統也會找到進程的環境變量,替換進程后,環境變量不變。
子進程可以新增自己的環境變量信息(putenv
),再往后的子進程可以繼承它的環境變量。傳遞自定義環境變量列表時,原環境變量會被替換。
//系統調用接口
//以上六個庫函數接口實現都會調用這一接口int execve(const char *path, char *const argv[], char *const envp[])
總結
本文篇幅較大,設計知識點眾多,所以有幾個點并沒有深入討論,如核心轉儲、exel接口函數中的環境變量表如何使用等,這些內容會在后續學習中逐漸補全。
👍 ?感謝各位大佬觀看。如果本文有幫助,請點贊收藏支持~