淺析 Linux 進程與線程

簡介

進程與線程是全部的程序員都熟知的概念,簡單來講進程是一個執行中的程序,而線程是進程中的一條執行路徑。進程是操做系統中基本的抽象概念,本文介紹 Linux 中進程和線程的用法以及原理,包括建立、消亡等。程序員

進程

建立與執行

Linux 中進程的建立與執行分爲兩個函數,分別是 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 從當前進程建立一個子進程,此函數返回兩次,對於父進程而言,返回的是子進程的進程號,對於子進程而言返回 0。子進程是父進程的副本,擁有與父進程同樣的數據空間、堆和棧的副本,而且共享代碼段。數據結構

因爲子進程一般是爲了調用 exec 裝載其它程序執行,因此 Linux 採用了寫時拷貝技術,即數據段、堆和棧的副本並不會在 fork 以後就真的拷貝,只是將這些內存區域的訪問權限變爲只讀,若是父子進程中有任一個要修改這些區域,纔會修改對應的內存頁生成新的副本,這樣子是爲了提升性能。函數

fork 以後父進程先執行仍是子進程先執行是不肯定的,因此若是要求父子進程進行同步,每每須要使用進程間通訊。fork 以後子進程會繼承父進程的不少東西,如:性能

  • 打開的文件
  • 實際用戶 ID、組用戶 ID 等
  • 進程組
  • 當前工做目錄
  • 信號屏蔽和安排
  • ...

父子進程的區別在於:spa

  • 進程 ID 不一樣
  • 子進程不繼承父進程的文件鎖
  • 子進程的未處理信號集爲空
  • ...

fork 以後,子進程能夠執行不一樣的代碼段,也可使用 exec 函數執行其它的程序。操作系統

進程描述符

進程在運行的時候,除了加載程序,還會打開文件、佔用一些資源,而且會進入睡眠等其它狀態。操做系統爲了支持進程的運行,必然有一個數據結構保存着這些東西。在 Linux 中,一個名爲 task_struct 的結構保存了進程運行時的全部信息,稱爲進程描述符:線程

struct task_struct {
    unsigned long state;
    int prio;
    pid_t pid;
    ...
}

進程描述符完整描述了一個進程:打開的文件、進程的地址空間、掛起的信號以及進程的信號等。系統將全部的進程描述符放在一個雙端循環列表中:設計

queue_task_struct.png

進程描述符具體存放在內存的哪裏呢?在內核棧的末尾。衆所周知,進程中佔用的內存一部分是棧,主要用於函數調用,不過這裏說的棧通常指的是用戶空間的棧,其實進程還有內核棧。當進程調用系統調用的時候,進程陷入內核,此時內核表明進程執行某個操做,此時使用的是內核空間的棧。3d

進程狀態

進程描述符中的 state 描述了進程當前的狀態,有以下 5 種:

  1. TASK_RUNNING:進程是可執行的,此時進程要麼是正在執行,要麼是在運行隊列中等待被調度
  2. TASK_INTERRUPTIBLE:進程正在睡眠(阻塞),等待條件達成。若是條件達成或者收到信號,進程會被喚醒而且進入可運行狀態
  3. TASK_UNINTERRUPTIBLE:進程處於不可中斷狀態,就算信號也沒法喚醒,這種狀態用的比較少
  4. _TASK_TRACED:進程正在被其它進程追蹤,一般是爲了調試
  5. _TASK_STOPPED:進程中止運行,一般是接收到 SIGINT、SIGTSTP 信號的時候。

fork 與 vfork

在使用了寫時拷貝後,fork 的實際開銷就是複製父進程的頁表以及給子進程建立惟一的進程描述符。fork 爲了建立一個進程到底作了什麼呢?fork 其實調用了 clone,這是一個系統調用,經過給 clone 傳遞參數,代表父子進程須要共享的資源,clone 內部會調用 do_fork,而 do_fork 的主要邏輯在 copy_process 中,大體有如下幾步:

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

除了 fork 以外,Linux 還有一個相似的函數 vfork。它的功能與 vfork 相同,子進程在父進程的地址空間運行。不過,父進程會阻塞,直到子進程退出或者執行 exec。須要注意的是,子進程不能向地址空間寫入數據。若是子進程修改數據、進行函數調用或者沒有調用 exec 那麼會帶來未知的結果。vforkfork 沒有寫時拷貝的技術時是有着性能優點,如今已經沒有太大的意義。

退出

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

  1. 從 main 返回
  2. 調用 exit
  3. 調用 _exit 或 _Exit
  4. 最後一個線程從其啓動例程返回
  5. 從最後一個線程調用 pthread_exit

異常終止方式有 3 種:

  1. 調用 abort
  2. 接收到一個信號
  3. 最後一個線程對取消請求做出響應

exit 函數會執行標準 I/O 庫的清理關閉操做:對全部打開的流調用 fclose 函數,全部緩衝中的數據會被沖洗,而 _exit 會直接陷入內核。看下面的代碼:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    printf("line 1\n");
    printf("line 2"); // 沒有換行符

    // exit(0)
    _exit(0);
}

其中第二行輸出沒有 \n,若是末尾調用的是 _exit,則只會輸出 line 1,若是替換爲 exit,則第二行 line 2 也會輸出。

進程退出最終會執行到系統的 do_exit 函數,主要有如下步驟:

  1. 刪除進程定時器
  2. 釋放進程佔用的頁表
  3. 遞減文件描述符的引用計數,若是某個引用計數爲 0,則關閉文件
  4. 向父進程發信號,給子進程從新找養父,而且把進程狀態設置爲 EXIT_ZOMBIE
  5. 調度其它進程

此時,進程的大部分資源都被釋放了,而且不會進入運行狀態。不過還有些資源保持着,主要是 task_struct 結構。之因此要留着是給父進程提供信息,讓父進程知道子進程的一些信息,如退出碼等。

須要注意的是,若是父進程不進行任何操做,那麼這些信息會一直保留在內存中,成爲殭屍進程,佔用系統資源,以下面的代碼:

int main() {
    pid_t pid = fork();
    if (pid == 0) {
        exit(0);
    } else {
        sleep(10);
    }
}

父進程 fork 出子進程後,子進程馬上退出,而父進程則進入睡眠。運行程序,觀察進程狀態:

zombie.png

能夠看到,第一行進程爲父進程,狀態爲 S,表示其正在睡眠,而第二爲子進程,狀態爲 Z,表示殭屍狀態(zombie),由於此時子進程已經退出,然而 task_struct 還保存着,等待父進程來處理。

父進程如何處理?調用 wait 函數,正如本文第一段代碼中所示。當父進程調用 wait 後,子進程的 task_struct 才被釋放。

若是父進程先結束了呢?在父進程結束的時候,會爲其子進程找新的父進程,一直往上找,最終成爲 init 進程的子進程。init 子進程會負責調用 wait 釋放子進程的遺留信息。

線程

上面介紹了 Linux 中的進程,那麼線程又是怎麼的?網上一些說法是,Linux 中並無真正的內核線程,線程是以進程的方式實現的,只不過它們之間會共享內存。這種說法有必定道理,但並不徹底準確。

Linux 中剛開始是不支持線程的,後來出現了線程庫 LinuxThreads,不過它有不少問題,主要是與 POXIS 標準不兼容。自 Linux 2.6 以來,Linux 中使用的就是新的線程庫,NPTL(Native POSIX Thread Library)。

NPTL 中線程的建立也是經過 clone 實現的,而且經過如下的參數代表了線程的特徵:

CLONE_VM | CLONE_FILES | CLONE_FS | CLONE_SIGHAND | CLONE_THREAD | CLONE_SETTLS | 
CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | CLONE_SYSVSEM

部分參數的含義以下:

  • CLONE_VM:全部線程都共享同一個進程地址空間
  • CLONE_FILES:全部線程都共享進程的文件描述符列表
  • CLONE_THREAD:全部線程都共享同一個進程 ID 以及 父進程 ID

NPTL 所實現的線程庫是 1:1 的從用戶線程映射到內核線程,而且內核爲了實現 POSIX 的線程標準也作了一些改動,好比對於信號的處理等。因此說 Linux 內核徹底不區分進程和線程,甚至不知道線程的存在這種說法如今是不許確的。

線程間共享代碼段、堆以及打開的文件等,線程私有的部分有如下內容:

  • 線程 ID
  • 寄存器
  • 錯誤碼(errno)
  • 信號屏蔽
  • ...

總結

Linux 中進程與線程的使用是程序員必備的技能,而若是能瞭解一些實現的原理,則可使用的更加駕輕就熟。本文介紹了 Linux 中進程的建立、執行以及消亡等,對於線程的實現及其與進程的關係也進行了簡單的說明。進程和線程還有更多的內容能夠研究,如進程調度、進程以及線程間的通訊等。

參考

  • 《UNIX 環境高級編程》
  • 《Linux 內核設計與實現》
相關文章
相關標籤/搜索