淺析 Linux 進程與線程

簡介

進程與線程是所有的程序員都熟知的概念,簡單來說進程是一個執(zhí)行中的程序,而線程是進程中的一條執(zhí)行路徑。進程是操作系統(tǒng)中基本的抽象概念,本文介紹 Linux 中進程和線程的用法以及原理,包括創(chuàng)建、消亡等。

進程

創(chuàng)建與執(zhí)行

Linux 中進程的創(chuàng)建與執(zhí)行分為兩個函數(shù),分別是 forkexec,如下代碼所示:

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 種:

  1. TASK_RUNNING:進程是可執(zhí)行的,此時進程要么是正在執(zhí)行,要么是在運行隊列中等待被調(diào)度
  2. TASK_INTERRUPTIBLE:進程正在睡眠(阻塞),等待條件達成。如果條件達成或者收到信號,進程會被喚醒并且進入可運行狀態(tài)
  3. TASK_UNINTERRUPTIBLE:進程處于不可中斷狀態(tài),就算信號也無法喚醒,這種狀態(tài)用的比較少
  4. _TASK_TRACED:進程正在被其它進程追蹤,通常是為了調(diào)試
  5. _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 中,大致有以下幾步:

  1. 為新進程創(chuàng)建一個內(nèi)核棧以及 task_struct,此時它們的值與父進程相同
  2. 將 task_struct 中某些變量,如統(tǒng)計信息,設(shè)置為 0
  3. 將子進程狀態(tài)設(shè)置為 TASK_UNINTERRUPTIBLE,保證它不會被投入運行
  4. 分配 pid
  5. 根據(jù)傳遞給 clone 的參數(shù),拷貝或者共享打開的文件、文件系統(tǒng)信息、信號處理函數(shù)以及進程的地址空間等。
  6. 返回指向子進程的指針

除了 fork 之外,Linux 還有一個類似的函數(shù) vfork。它的功能與 vfork 相同,子進程在父進程的地址空間運行。不過,父進程會阻塞,直到子進程退出或者執(zhí)行 exec。需要注意的是,子進程不能向地址空間寫入數(shù)據(jù)。如果子進程修改數(shù)據(jù)、進行函數(shù)調(diào)用或者沒有調(diào)用 exec 那么會帶來未知的結(jié)果。vforkfork 沒有寫時拷貝的技術(shù)時是有著性能優(yōu)勢,現(xiàn)在已經(jīng)沒有太大的意義。

退出

進程的運行終有退出的時候,有 8 種方式使進程終止,其中 5 中為正常終止:

  1. 從 main 返回
  2. 調(diào)用 exit
  3. 調(diào)用 _exit 或 _Exit
  4. 最后一個線程從其啟動例程返回
  5. 從最后一個線程調(diào)用 pthread_exit

異常終止方式有 3 種:

  1. 調(diào)用 abort
  2. 接收到一個信號
  3. 最后一個線程對取消請求作出響應(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ù),主要有以下步驟:

  1. 刪除進程定時器
  2. 釋放進程占用的頁表
  3. 遞減文件描述符的引用計數(shù),如果某個引用計數(shù)為 0,則關(guān)閉文件
  4. 向父進程發(fā)信號,給子進程重新找養(yǎng)父,并且把進程狀態(tài)設(shè)置為 EXIT_ZOMBIE
  5. 調(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)》
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容