簡介
進程與線程是所有的程序員都熟知的概念,簡單來說進程是一個執(zhí)行中的程序,而線程是進程中的一條執(zhí)行路徑。進程是操作系統(tǒng)中基本的抽象概念,本文介紹 Linux 中進程和線程的用法以及原理,包括創(chuàng)建、消亡等。
進程
創(chuàng)建與執(zhí)行
Linux 中進程的創(chuàng)建與執(zhí)行分為兩個函數(shù),分別是 fork 和 exec,如下代碼所示:
int main() {
pid_t pid;
if ((pid = fork() < 0) {
printf("fork error\n");
} else if (pid == 0) {
// child
if (execle("/home/work/bin/test1", "test1", NULL) < 0) {
printf("exec error\n");
}
}
// parent
if (waitpid(pid, NULL) < 0) {
printf("wait error\n");
}
}
fork 從當(dāng)前進程創(chuàng)建一個子進程,此函數(shù)返回兩次,對于父進程而言,返回的是子進程的進程號,對于子進程而言返回 0。子進程是父進程的副本,擁有與父進程一樣的數(shù)據(jù)空間、堆和棧的副本,并且共享代碼段。
由于子進程通常是為了調(diào)用 exec 裝載其它程序執(zhí)行,所以 Linux 采用了寫時拷貝技術(shù),即數(shù)據(jù)段、堆和棧的副本并不會在 fork 之后就真的拷貝,只是將這些內(nèi)存區(qū)域的訪問權(quán)限變?yōu)橹蛔x,如果父子進程中有任一個要修改這些區(qū)域,才會修改對應(yīng)的內(nèi)存頁生成新的副本,這樣子是為了提高性能。
fork 之后父進程先執(zhí)行還是子進程先執(zhí)行是不確定的,所以如果要求父子進程進行同步,往往需要使用進程間通信。fork 之后子進程會繼承父進程的很多東西,如:
- 打開的文件
- 實際用戶 ID、組用戶 ID 等
- 進程組
- 當(dāng)前工作目錄
- 信號屏蔽和安排
- ...
父子進程的區(qū)別在于:
- 進程 ID 不同
- 子進程不繼承父進程的文件鎖
- 子進程的未處理信號集為空
- ...
fork 之后,子進程可以執(zhí)行不同的代碼段,也可以使用 exec 函數(shù)執(zhí)行其它的程序。
進程描述符
進程在運行的時候,除了加載程序,還會打開文件、占用一些資源,并且會進入睡眠等其它狀態(tài)。操作系統(tǒng)為了支持進程的運行,必然有一個數(shù)據(jù)結(jié)構(gòu)保存著這些東西。在 Linux 中,一個名為 task_struct 的結(jié)構(gòu)保存了進程運行時的所有信息,稱為進程描述符:
struct task_struct {
unsigned long state;
int prio;
pid_t pid;
...
}
進程描述符完整描述了一個進程:打開的文件、進程的地址空間、掛起的信號以及進程的信號等。系統(tǒng)將所有的進程描述符放在一個雙端循環(huán)列表中:
進程描述符具體存放在內(nèi)存的哪里呢?在內(nèi)核棧的末尾。眾所周知,進程中占用的內(nèi)存一部分是棧,主要用于函數(shù)調(diào)用,不過這里說的棧一般指的是用戶空間的棧,其實進程還有內(nèi)核棧。當(dāng)進程調(diào)用系統(tǒng)調(diào)用的時候,進程陷入內(nèi)核,此時內(nèi)核代表進程執(zhí)行某個操作,此時使用的是內(nèi)核空間的棧。
進程狀態(tài)
進程描述符中的 state 描述了進程當(dāng)前的狀態(tài),有如下 5 種:
- TASK_RUNNING:進程是可執(zhí)行的,此時進程要么是正在執(zhí)行,要么是在運行隊列中等待被調(diào)度
- TASK_INTERRUPTIBLE:進程正在睡眠(阻塞),等待條件達成。如果條件達成或者收到信號,進程會被喚醒并且進入可運行狀態(tài)
- TASK_UNINTERRUPTIBLE:進程處于不可中斷狀態(tài),就算信號也無法喚醒,這種狀態(tài)用的比較少
- _TASK_TRACED:進程正在被其它進程追蹤,通常是為了調(diào)試
- _TASK_STOPPED:進程停止運行,通常是接收到 SIGINT、SIGTSTP 信號的時候。
fork 與 vfork
在使用了寫時拷貝后,fork 的實際開銷就是復(fù)制父進程的頁表以及給子進程創(chuàng)建唯一的進程描述符。fork 為了創(chuàng)建一個進程到底做了什么呢?fork 其實調(diào)用了 clone,這是一個系統(tǒng)調(diào)用,通過給 clone 傳遞參數(shù),表明父子進程需要共享的資源,clone 內(nèi)部會調(diào)用 do_fork,而 do_fork 的主要邏輯在 copy_process 中,大致有以下幾步:
- 為新進程創(chuàng)建一個內(nèi)核棧以及 task_struct,此時它們的值與父進程相同
- 將 task_struct 中某些變量,如統(tǒng)計信息,設(shè)置為 0
- 將子進程狀態(tài)設(shè)置為 TASK_UNINTERRUPTIBLE,保證它不會被投入運行
- 分配 pid
- 根據(jù)傳遞給
clone的參數(shù),拷貝或者共享打開的文件、文件系統(tǒng)信息、信號處理函數(shù)以及進程的地址空間等。 - 返回指向子進程的指針
除了 fork 之外,Linux 還有一個類似的函數(shù) vfork。它的功能與 vfork 相同,子進程在父進程的地址空間運行。不過,父進程會阻塞,直到子進程退出或者執(zhí)行 exec。需要注意的是,子進程不能向地址空間寫入數(shù)據(jù)。如果子進程修改數(shù)據(jù)、進行函數(shù)調(diào)用或者沒有調(diào)用 exec 那么會帶來未知的結(jié)果。vfork 在 fork 沒有寫時拷貝的技術(shù)時是有著性能優(yōu)勢,現(xiàn)在已經(jīng)沒有太大的意義。
退出
進程的運行終有退出的時候,有 8 種方式使進程終止,其中 5 中為正常終止:
- 從 main 返回
- 調(diào)用 exit
- 調(diào)用 _exit 或 _Exit
- 最后一個線程從其啟動例程返回
- 從最后一個線程調(diào)用 pthread_exit
異常終止方式有 3 種:
- 調(diào)用 abort
- 接收到一個信號
- 最后一個線程對取消請求作出響應(yīng)
exit 函數(shù)會執(zhí)行標準 I/O 庫的清理關(guān)閉操作:對所有打開的流調(diào)用 fclose 函數(shù),所有緩沖中的數(shù)據(jù)會被沖洗,而 _exit 會直接陷入內(nèi)核??聪旅娴拇a:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
printf("line 1\n");
printf("line 2"); // 沒有換行符
// exit(0)
_exit(0);
}
其中第二行輸出沒有 \n,如果末尾調(diào)用的是 _exit,則只會輸出 line 1,如果替換為 exit,則第二行 line 2 也會輸出。
進程退出最終會執(zhí)行到系統(tǒng)的 do_exit 函數(shù),主要有以下步驟:
- 刪除進程定時器
- 釋放進程占用的頁表
- 遞減文件描述符的引用計數(shù),如果某個引用計數(shù)為 0,則關(guān)閉文件
- 向父進程發(fā)信號,給子進程重新找養(yǎng)父,并且把進程狀態(tài)設(shè)置為 EXIT_ZOMBIE
- 調(diào)度其它進程
此時,進程的大部分資源都被釋放了,并且不會進入運行狀態(tài)。不過還有些資源保持著,主要是 task_struct 結(jié)構(gòu)。之所以要留著是給父進程提供信息,讓父進程知道子進程的一些信息,如退出碼等。
需要注意的是,如果父進程不進行任何操作,那么這些信息會一直保留在內(nèi)存中,成為僵尸進程,占用系統(tǒng)資源,如下面的代碼:
int main() {
pid_t pid = fork();
if (pid == 0) {
exit(0);
} else {
sleep(10);
}
}
父進程 fork 出子進程后,子進程立刻退出,而父進程則進入睡眠。運行程序,觀察進程狀態(tài):
可以看到,第一行進程為父進程,狀態(tài)為 S,表示其正在睡眠,而第二為子進程,狀態(tài)為 Z,表示僵尸狀態(tài)(zombie),因為此時子進程已經(jīng)退出,然而 task_struct 還保存著,等待父進程來處理。
父進程如何處理?調(diào)用 wait 函數(shù),正如本文第一段代碼中所示。當(dāng)父進程調(diào)用 wait 后,子進程的 task_struct 才被釋放。
如果父進程先結(jié)束了呢?在父進程結(jié)束的時候,會為其子進程找新的父進程,一直往上找,最終成為 init 進程的子進程。init 子進程會負責(zé)調(diào)用 wait 釋放子進程的遺留信息。
線程
上面介紹了 Linux 中的進程,那么線程又是怎么的?網(wǎng)上一些說法是,Linux 中并沒有真正的內(nèi)核線程,線程是以進程的方式實現(xiàn)的,只不過它們之間會共享內(nèi)存。這種說法有一定道理,但并不完全準確。
Linux 中剛開始是不支持線程的,后來出現(xiàn)了線程庫 LinuxThreads,不過它有很多問題,主要是與 POXIS 標準不兼容。自 Linux 2.6 以來,Linux 中使用的就是新的線程庫,NPTL(Native POSIX Thread Library)。
NPTL 中線程的創(chuàng)建也是通過 clone 實現(xiàn)的,并且通過以下的參數(shù)表明了線程的特征:
CLONE_VM | CLONE_FILES | CLONE_FS | CLONE_SIGHAND | CLONE_THREAD | CLONE_SETTLS |
CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | CLONE_SYSVSEM
部分參數(shù)的含義如下:
- CLONE_VM:所有線程都共享同一個進程地址空間
- CLONE_FILES:所有線程都共享進程的文件描述符列表
- CLONE_THREAD:所有線程都共享同一個進程 ID 以及 父進程 ID
NPTL 所實現(xiàn)的線程庫是 1:1 的從用戶線程映射到內(nèi)核線程,并且內(nèi)核為了實現(xiàn) POSIX 的線程標準也做了一些改動,比如對于信號的處理等。所以說 Linux 內(nèi)核完全不區(qū)分進程和線程,甚至不知道線程的存在這種說法現(xiàn)在是不準確的。
線程間共享代碼段、堆以及打開的文件等,線程私有的部分有以下內(nèi)容:
- 線程 ID
- 寄存器
- 錯誤碼(errno)
- 棧
- 信號屏蔽
- ...
總結(jié)
Linux 中進程與線程的使用是程序員必備的技能,而如果能了解一些實現(xiàn)的原理,則可以使用的更加得心應(yīng)手。本文介紹了 Linux 中進程的創(chuàng)建、執(zhí)行以及消亡等,對于線程的實現(xiàn)及其與進程的關(guān)系也進行了簡單的說明。進程和線程還有更多的內(nèi)容可以研究,如進程調(diào)度、進程以及線程間的通信等。
參考
- 《UNIX 環(huán)境高級編程》
- 《Linux 內(nèi)核設(shè)計與實現(xiàn)》