正如上一篇咱們提到過,進程是Linux系統中僅次於文件的基本抽象概念。正在運行的進程不只僅是二進制代碼,而是數據、資源、狀態和虛擬的計算機組成。咱們今天主要介紹進程的概念,組成,運行狀態和生命週期等。html
進程就是處於執行器的程序(目標代碼放在某種存儲介質上)。算法
但進程並不只僅侷限於一個可執行程序代碼,一般還要包含其餘資源,好比:centos
打開的文件,掛起的信號,內核內部數據,處理器狀態,一個或多個具備內存映射的內存地址空間,一個或多個可執行線程,存放全局變量的數據段等。數組
內核須要有效而又透明地管理全部細節。緩存
執行線程(thread of execution),簡稱線程(thread),是進程中活動的對象。每一個線程都擁有本身的虛擬存儲器,包括程序計數器,棧,一組進程寄存器。編輯器
一個進程只有一個虛擬內存實例,因此,進程下的全部線程共享相同的內存地址空間。函數
進程的另外一個名字是任務(task),Linux內核一般把進程叫作任務。性能
內核把進程列表存放在任務隊列(task list)的雙向循環鏈表中。鏈表中的每個節點都是類型爲task_struct稱爲進程描述符(process descriptor)的結構。測試
每一個進程描述符都包含了一個具體進程的全部信息。在32位機器上,大約有1.7KB。因此它包含了前面進程的定義中提到的「打開的文件,掛起的信號......」諸多信息。spa
Linux內核經過slab分配器分配task_struct結構,這樣能達到對象複用和緩存着色的目的。在Linux2.6後面的內核中,每一個任務的新的thread_info結構在內核棧的尾端分配,該結構中task指針存放的是指向該任務實際task_struct的指針。
而事實上,Linux內核棧是比較小的,在x86上,32位機的內核棧8KB,64位機是16KB。固然能夠配置,每一個處理器也都有本身的棧。可是也說明了內核棧不大,而一個進程描述符佔了1.7KB,已是相對較大了。
內核經過一個惟一的進程標識值PID來標識每一個進程。PID是一個數,實際上爲int類型。PID的最大默認值爲32768。固然能夠配置。
這個值表明了系統中能夠同時存在的進程的最大數目,值越小,轉一圈就越快。可是值一大,若是要切換進程,又成了一問題。該PID存在task_struct結構中,實際上最終目的是獲得進程描述符。因此經過current宏找到當前正在運行的進程的速度顯得尤其重要,硬件體系結構不同,處理不同。有的經過專門的寄存器存放當前進程描述符指針。而x86寄存器有限,只能在內核棧尾端建立thread_info結構,經過計算偏移間接地查找task_struct結構。
進程的狀態以下圖所示,分爲五種,系統中的每一個進程必定處在其中的一種。進程的當前狀態存儲在進程描述符的state域。
該進程狀態圖很是準確而簡要的描述了進程狀態的切換,說明了進程從建立到運行到銷燬的過程,也說明了進程被搶佔或者被中斷的轉換過程。固然可能須要結合書或者這連續幾章的介紹會了解得更加深入。
下面咱們先分開描述五個狀態的含義。
TASK_RUNNING(運行):進程是可執行的,就緒或者正在運行。就緒表示已經加入到運行隊列中等待執行。同時,該狀態也是進程在用戶空間中惟一可能的狀態,因此只有該狀態在用戶空間和內核空間都能表示。
TASK_INTERRUPTIBLE(可中斷):進程正在睡眠(即被阻塞),等待某些條件的達成便可被喚醒。
TASK_UNINTERRUPTIBLE(不可中斷):該進程即便在等待時也不受干擾,不接收信號,使用較少。
注,ps aux查看進程stat字段爲D狀態,不可中斷又不能殺死的進程。它可能正在執行一個重要的任務或者持有一個信號。進程啓動之初也是處於這個狀態。
__TASK_TRACED:被其餘進程跟蹤的進程,例如經過ptrace對調試進程進行跟蹤。
__TASK_STTOPED(中止):進程中止執行;進程沒有投入運行也不能投入運行。
細心的你可能會發現,這五個狀態和圖上的五個狀態明顯不同。是的,但我認爲都沒毛病。上圖是總體的狀態切換,而這裏應該是做者站在內核的角度進行列舉。
TASK_RUNNING中咱們提到了用戶空間和內核空間,咱們在這裏在從進程狀態來看看進程上下文的定義。
進程上下文在上一篇基本概念中提到過。
可執行程序代碼是進程的重要組成部分,這些代碼從一個可執行文件載入到進程的地址空間執行。
通常程序在用戶空間執行,當一個程序執行了系統調用或者觸發了某個異常,它就陷入內核空間。此時,咱們稱內核「表明進程執行」並處於進程上下文中。
注1:系統調用和異常處理程序是內核與外界的接口的統稱,即內核的全部訪問都必須經過這些接口。
注2:中斷上下文在基礎概念中亦提過,在中斷上下文中,系統不表明進程執行,而是執行了一箇中斷處理程序,而每每和驅動相關。因此不會有進程干擾它,此時也不會存在進程上下文。也能夠理解爲內核要麼處於進程上下文中,要麼處於中斷上下文中,固然也能夠休息用戶空間工做便可。
每一個進程都有惟一的ID進行標識,即進程ID,簡稱PID。在Linux系統中,進程之間存在一個明顯的進程關係,全部的進程都是PID爲1的init(centos 7爲systemd)進程的的後代。
而PID爲0表示空閒進程,即當沒有其餘進程在運行時,內核運行該空閒進程。
因此,每個進程都有一個父進程,每個進程也能夠擁有0個或者多個子進程,這樣組成了一顆進程樹。
Unix的進程建立很特別。許多其餘的操做系統都提供了產生(spawn)進程的機制,首先在新的地址空間裏建立進程,讀入可執行文件,最後開始執行。
Unix與之不一樣,將上述的步驟分解到兩個單獨的函數中去執行:fork()和exec()。exec()表明了execve()等一系列函數。
首先建立一個新的進程,而後,經過exec系統調用把新的二進制程序加載到該進程中。
Linux經過clone()系統調用實現fork()。
fork()、vfork()和__clone()庫函數都根據各自須要的參數標誌去調用clone(),而後clone()去調用do_work()。
傳統的fork()系統調用直接把全部的資源複製給新建立的進程。現代的Linux操做系統使用寫時複製(copy-on-write COW)頁來實現。
寫時複製是一種能夠推遲甚至免除複製數據的技術(惰性算法),內核此時並不複製整個進程地址空間,父子進程共享同一份拷貝。
只有在須要寫入時,數據纔會進行復制,並且虛擬內存是分頁來處理,某一頁被修改了會產生缺頁中斷,該頁才須要複製,因此使各個進程擁有各自的拷貝。因此加快了進程的建立。
再回到fork(),內核有意選擇子進程首先執行。由於通常子進程立刻調用exec()函數,這樣避免寫時複製的額外開銷。由於若是父進程首先執行的話,有可能開始向地址空間寫入。
經過fork()系統調用,能夠建立一個和當前進程同樣的進程。新進程稱爲原進程的「子進程」,原進程稱爲「父進程」。在子進程中,成功的fork()返回0;在父進程中,fork()會返回子進程的pid。
除了一些本質性區別,父子進程在其餘各方面都是相同的:
它是在COW出現以前的一種進程建立方式,如今由於有了COW,因此該方式已經基本不使用了。
除了不能拷貝父進程的頁表項外,它和fork()的功能相同。子進程做爲父進程的一個單獨線程在它的地址空間運行,父進程被阻塞,直到子進程退出或者執行exec()。
Microsoft Windows或Sun Solaris等操做系統在內核中提供了專門支持線程的機制。而Linux操做系統中,線程看起來就像是一個普通的進程。
在Linux內核中,並沒有線程的概念。因此它的建立和普通進程相似,只是須要選擇不一樣的參數。
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);
這些參數表示父子倆共享地址空間、文件系統資源、文件描述符和信號處理函數。
對比一下普通進程fork()的實現是:
clone(SIGCHILD, 0);
因此,傳遞給clone()的參數標誌決定了新建立進程的行爲方式和父子進程之間共享的資源種類。
內核常常須要在後臺執行一些操做。這種任務能夠經過內核線程(kernel thread)完成——獨立運行在內核空間的標準進程。
內核線程和普通進程的區別在於內核線程沒有獨立的地址空間!由於它們只在內核空間運行,不須要去用戶空間。
內核線程和普通進程同樣,能夠被調度,能夠被搶佔。
咱們在Linux系統中運行ps -ef命令,能夠看到不少內核線程,好比flush和ksoftirqd等等。
內核是經過從kthreadd內核進程中衍生出來全部新的內核線程。
前面提到過exec()不是一具體函數,而是表明了6個函數,以下。
execl() execlp() execle()
execv() execvp() execve()
第一行三個函數表示參數是可變的,第二行表示參數是固定的,便可變的變成了數組。l表示list,v表示vector。
第二列的p表示會在用戶的絕對路徑下查找可執行文件,即參數指定的文件名必須在用戶路徑下。p表明path。
第三列的e表示會爲新進程提供新的環境變量。
六個函數只有execve()是惟一的系統調用,其餘是在其基礎上封裝的C庫函數。
一個exec()系統調用會把二進制程序加載到內存中,替換地址空間原來的內容,並開始執行,這個過程稱爲「執行(executing)」一個新的程序。
execl("/bin/vi", "vi", NULL);
第一個參數表示二進制程序的路徑,第二個參數通常表示程序名稱,該程序是「vi」編輯器。NULL表示最後的參數,前面提到過l表明了可變參數。
execl("/bin/vi", "vi", "/home/test/123.txt", NULL);
上面的示例加了一個參數,該參數表示vi編輯的對象。
成功的execl調用不只改變了地址空間和進程映像,還改變了進程的其餘一些屬性:
可是進程的某些屬性仍是沒有改變,如pid,ppid,優先級,所屬的用戶和組。
還有文件描述符也被繼承了下來,因此實際操做中通常會在調用exec前關閉打開的文件,固然也能夠經過fcntl(),讓內核去自動完成關閉操做。
通常來講,進程的析構是自身引發的。它發生在進程調用exit()系統調用時,便可以顯示調用,也能夠隱式地從某個程序的主函數返回(如C語言編譯器會自動在main()函數的返回點加上exit())。固然也可能被動的終結,好比信號通知或異常處理等。無論進程如何終結,該任務大部分經過do_exit()來完成。
在終止進程以前,C庫會按順序執行如下關閉進程的步驟。
這些步驟完成了在用戶空間須要作的全部工做,最後exit()會調用_exit(),內核能夠處理終止進程的剩餘工做。
內核清理進程建立的、再也不使用的資源,包括但不侷限於:分配內存、打開文件和System V的信號量。清理完成後,內核會摧毀進程並告知父進程其子進程已近終止。
注:atexit()是POSIX標準函數,而on_exit()是SunOS 4定義的,新版本的Solaris也再也不支持了。atexit()主要用來指定的註冊函數做爲終止函數。
按上述方式將進程終結後,系統還保留了它的進程描述符,在父進程得到已經終結的子進程的信息後,或者通知內核它並不關注那些信息後,子進程的task_struct結構纔會被釋放。
若是父進程在子進程以前退出,必須有機制來保證子進程能找到一個新的父親,不然這些成爲孤兒的進程就會在退出時永遠處於殭屍(EXIT_ZOMBIE)狀態,白白地耗費內存。
Linux內核提供了一些接口,能夠獲取已終止子進程的信息。
wait(), waitpid(), waitid(), wait3(), wait4()
前面三個是標準的POSIX標準定義的,後兩個不是。常使用的是waitpid()函數,等待某個特定的子進程,固然也能夠是一組進程,主要依據是第一個參數的值。
init進程會週期性地調用wait()來檢查子進程,清除全部與其相關的殭屍進程。
ps:事實上,在實際項目中,碰到過幾回殭屍進程,狀況好的重啓系統恢復正常,狀況差的出現太重啓失敗,只能按電源鍵。所幸的是這種狀況極少,出現殭屍進程通常是性能撐不住的狀況下。但個人疑問是,爲啥systemd進程沒有檢查到該殭屍進程呢??若是能檢查到並清理的話,那麼咱們則只需再啓動應用程序便可。
簡單測試
#include <stdio.h> #include <sys/wait.h> #include <sys/types.h> #include <unistd.h>
int main(){ int ret; pid_t pid; pid_t pidSelf = getpid(); printf("hello, my pid is %d~\n", getpid()); pid = fork(); if (pid == -1) return -1; else if (pid == 0){ printf("hello, child process, my pid is %d\n", getpid()); //exit(-1);
sleep(5); } else if (pid > 0){ printf("hello, father process, my pid is %d!\n", getpid()); } printf("hello world, my pid is %d!\n", getpid()); waitpid(-1, NULL, 0); printf("end, my pid is %d.\n", getpid()); return ret; }
輸出結果:
hello, my pid is 2816~
hello, father process, my pid is 2816!
hello world, my pid is 2816!
hello, child process, my pid is 2817 //然後暫停5s.
hello world, my pid is 2817!
end, my pid is 2817.
end, my pid is 2816.
參考資料:
《Linux內核設計與實現》