作者簡介
herongwei,北交碩士畢業,現就職於搜狗公司,後端開發工程師。從事 C++,Golang ,Linux 後端開發。
追求技術,熱愛編程與分享,希望能和大家多多交流學習~
座右銘: 認真的人,自帶光芒!
GitHub: https://github.com/rongweihe
個人博客: https://rongweihe.github.io/
1、前言
在研究 Linux 實現之前,首先要對進程、進程組、會話,線程有個整體的瞭解:一個會話包含多個進程組,一個進程組包含多個進程,一個進程包含多個線程。
2、進程控制
進程是 Linux 操作系統環境的基礎,它控制着系統上幾乎所有的活動。每個進程都有自己唯一的標識:進程 ID,也有自己的生命週期。進程都有父進程,父進程也有父進程,從而形成了一個以 init 進程 (PID = 1)爲根的家族樹。除此以外,進程還有其他層次關係:進程組和會話。
一個典型的進程的生命週期如下圖所示。
3、進程ID
Linux 下每個進程都會有一個非負整數表示的唯一進程 ID,簡稱 pid。
Linux 提供了getpid 函數來獲取進程的 pid,同時還提供了 getppid 函數來獲取父進程的 pid,相關接口定義如下:
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
pid_t getppid(void);
每個進程都有自己的父進程,父進程又會有自己的父進程,最終都會追溯到 1 號進程即 init 進程。這就決定了操作系統上所有的進程必然會組成樹狀結構,就像一個家族的家譜一樣。可以通過 pstree 的命 令來查看進程的家族樹,如下圖所示。
procfs 文件系統會在 /proc 下爲每個進程創建一個目錄,名字是該進程的 pid。目錄下有很多文件, 用於記錄進程的運行情況和統計信息等。因爲進程有創建,也有終止,所以 /proc/ 下記錄進程信息的目錄(以及目錄下的文件)也會發生變化。如下圖所示:
4、進程創建
Linux 下創建新進程的系統調用是 fork。其定義如下:
#include <sys/types.h>
#include <unistd.h>
pid_t fork( void );
該函數的每次調用都會返回兩次:在父進程中返回的是子進程的 PID,在子進程中則返回 0 。該返回值是後續代碼用來判斷當前進程是父進程還是子進程的依據。
fork 函數會複製當前進程,在內核進程表中創建一個新的進程表項。新的進程表項有很多屬性和原進程相同,比如堆指針、棧指針和標誌寄存器的值。但也有許多屬性被賦予了新的值,比如該進程的 PPID 被設置成了原進程的 PID,信號位圖被清除(也就是原進程設置的信號處理的函數不再對新進程起作用)。
子進程的代碼與父進程完全相同,同時它還會複製父進程的教據(堆數據、棧數據和靜態數據)。
數據的複製採用的是所謂的寫時複製(copy on writte),即只有在任一進程(父進程或子進程)對數據執行了寫操作時,複製纔會發生(先是缺頁中斷,然後操作系統給子進程分配內存並複製父進程的數據)。
如果我們在程序中分配了大量內存,那麼使用 fork 時也應當謹慎,避免沒必要的內存分配和數據複製。
此外,創建子進程後,父進程中打開的文件描述符默認在子進程中也是打開的,且文件描述符的引用計數加 1,不僅如此,父進程的用戶根目錄、當前工作目錄等變量的引用計數均會加 1。
5、進程層次
進程組和會話在進程之間形成了兩級的層次關係:
進程組是一組相關進程的集合,會話是一組相關進程組的集合。
一個進程會有如下 ID:
1)PID:進程的唯一標識。如果一個進程含有多個線程,所有線程調用 getpid 函數會返回相同的值。
2)PGID:進程組 ID。每個進程都會有進程組 ID,表示該進程所屬的進程組。默認情況下新創建的進程會繼承父進程的進程組 ID。
3)SID:會話 ID。每個進程也都有會話 ID。默認情況下,新創建的進程會繼承父進程的會話 ID。
4)PPID:是程序的父進程號。
可以調用如下指令來查看所有進程的層次關係(To print a process tree):
1、ps -ejH
2、ps axjf
也可以調用以下函數獲取進程組 ID 跟會話 ID :
1、pid_t getpgrp(void);
2、pid_t getsid(pid_t pid);
前面提到,新進程默認會繼承父進程的進程組 ID 和會話 ID,那麼看到這裏,有同學可能會問了:如果都是默認情況的話,那麼一層層往上計算,所有的進程應該有共同的進程組 ID 和會話 ID ,但是當調用 ps axjf 命令查看,實際情況並非如此,系統中存在很多不同的會話,每個會話下也有不同的進程組。
如下圖所示:
這是什麼原因呢?
《Linux環境編程:從應用到內核》這本書裏,有一個解釋,說的比較生動形象:
1)可以打個比方,以家族企業的創業爲例,每個進程可以比喻成家族企業的每個成員。
2)如果從創業之初,所有家族成員都安分守己,循規蹈矩,默認情況下,就只會有一個公司、一個部門。但是也有些“叛逆”的子弟,願意爲家族公司開疆拓土,願意成立新的部門。
3)這些新的部門就是新創建的進程組。如果有子弟“離經叛道”,甚至不願意呆在家族公司裏,他別開天地,另創了一個公司,那這個新公司就是新創建的會話組。由此可見,系統必須要有改變和設置進程組 ID 和會話 ID 的函數接口,否則,系統中只會存在一個會話、一個進程組。
這樣一來,是不是就比較好理解了。
6、進程的狀態
在 Linux 中,進程具有以下可能的狀態:
new-表示正在創建進程。
ready-表示該進程就緒,等待分配處理器。
running -表示該進程正在執行中。
waiting-表示該進程正在等待某些事件發生(例如I / O完成或信號接收)。此外,內核還區分了兩種類型的等待進程。可中斷的等待過程–可以被信號中斷,而不可中斷的等待過程–直接在硬件條件下等待,並且不能被任何事件/信號中斷。
terminated-表示該進程已完成執行。
如下圖所示:
在 Linux 中,常用的 ps-ef
或 ps aux top
命令,也可以用 top
命令查看,如下圖所示:
另外幾個查看進程的命令, htop
, jobs
, fg
和 bg
命令也可以學習一下。
此外,我們還必須瞭解其它三種進程:
孤兒進程:一個父進程退出,而它的一個或多個子進程還在運行,那麼那些子進程將成爲孤兒進程。孤兒進程將被 init 進程(進程號爲1)所收養,並由 init 進程對它們完成狀態收集工作。
殭屍進程:一個進程使用 fork 創建子進程,如果子進程先退出,而父進程並沒有調用 wait 或 waitpid 獲取子進程的狀態信息,那麼子進程的進程描述符仍然保存在系統中。這種進程稱之爲僵死進程。
守護進程:(英語:daemon)是一種在後臺執行的程序。此類程序會被以進程的形式初始化。守護進程程序的名稱通常以字母“d”結尾:例如,syslogd 就是指管理系統日誌的守護進程。
Linux 系統中,每當出現一個孤兒進程的時候,內核就把孤兒進程的父進程設置爲init,而 init 進程會循環地 調用 wait() ,處理已經退出的子進程。這樣,當一個孤兒進程淒涼地結束了其生命週期的時候,init 進程就會代表收容所一樣處理它的一切善後工作。因此孤兒進程並不會有什麼危害。
而對於殭屍進程,如果進程不調用 wait / waitpid的話, 那麼保留的那段信息就不會釋放,其進程號就會一直被佔用,但是系統所能使用的進程號是有限的,如果大量的產生僵死進程,將因爲沒有可用的進程號而導致系統不能產生新的進程,此即爲殭屍進程的危害,應當避免。
解決:僵死進程並不是問題的根源,罪魁禍首是產生出大量僵死進程的那個父進程。因此,當我們要消滅系統中大量的僵死進程時,要做的就是把產生大量僵死進程的那個父進程殺死(通過 kill 發送 SIGTERM 或者 SIGKILL 信號)。殺死之後,產生的僵死進程就變成了孤兒進程,這些孤兒進程會被 init 進程接管。
7、進程組
進程組和會話是爲了支持 shell 作業控制而引入的概念。
修改進程組 ID 的接口如下
int setpgid(pid_t pid, pid_t pgid);
這個函數的含義是,找到進程 ID 爲 pid 的進程,將其進程組 ID 修改爲 pgid,如果 pid 的值爲 0,則表示要修改調用進程的進程組 ID。該接口一般用來創建一個新的進程組。
下面三個函數接口含義一致,都是創立新的進程組,並且指定的進程會成爲進程組的首進程。
如果參數 pid 和 pgid 的值不匹配,那麼 setpgid 函數會將一個進程從原來所屬的進程組遷移到 pgid 對應的進程組。
setpgid(0,0)
setpgid(getpid(),0)
setpgid(getpid(),getpid())
setpgid 函數有一些限制:
1)pid 參數必須指定爲調用 setpgid 函數的進程或其子進程,不能隨意修改不相關進程的進程組 ID,如果違反這條規則,則返回 -1,並置 errno 爲 ESRCH。
2)pid 參數可以指定調用進程的子進程,但是子進程如果已經執行了exec函數,則不能修改子進程的進程組 ID。如果違反這條規則,則返回-1,並置 errno 爲 EACCESS。
3)在進程組間移動,調用進程,pid 指定的進程及目標進程組必須在同一個會話之內。這個比較好理解,不加入公司(會話),就無法加入公司下屬的部門(進程組),否則就是部門要造反的節奏。如果違反這條規則,則返回-1,並置 errno 爲 EPERM。
4)pid 指定的進程,不能是會話首進程。如果違反這條規則,則返回 -1,並置 errno 爲 EPERM。
有了創建進程組的接口,新創建的進程組就不必繼承父進程的進程組 ID 了。
最常見的創建進程組的場景就是在 shell 中執行管道命令。
代碼如下:
cmd1 | cmd2 | cmd3
下面用一個簡單的命令 ps ax|grep nfsd
來說明,其進程之間的關係如下圖所示。
ps
進程和 grep
進程都是 bash
創建的子進程,兩者通過管道協同完成一項工作,它們隸屬於同一個進程組,其中 ps
進程是進程組的組長。
進程組的概念並不難理解,可以將人與人之間的關係做類比。一起工作的同事,自然比毫不相干的路人更加親近。shell 中協同工作的進程屬於同一個進程組,就如同協同工作的人屬於同一個部門一樣。
引入了進程組的概念,可以更方便地管理這一組進程了。比如這項工作放棄了,不必向每個進程一一發送信號,可以直接將信號發送給進程組,進程組內的所有進程都會收到該信號。
8、會話
當有新的用戶登錄 Linux 時,登錄進程會爲這個用戶創建一個會話。
用戶的登錄 shell 就是會話的首進程。會話的首進程 ID 會作爲整個會話的 ID。會話是一個或多個進程組的集合,包括了登錄用戶的所有活動。
在登錄 shell 時,用戶可能會使用管道,讓多個進程互相配合完成一項工作,這一組進程屬於同一個進程組。
前臺進程和後臺進程:
用戶在 shell 中可以同時執行多個命令。對於耗時很久的命令(如編譯大型工程),用戶不必在傻傻的等待命令運行完畢才執行下一個命令。
用戶在執行命令時,可以在命令的結尾添加“&”符號,表示將命令放入後臺執行。這樣該命令對應的進程組即爲後臺進程組。
在任意時刻,可能同時存在多個後臺進程組,但是不管什麼時候都只能有一個前臺進程組。只有在前臺進程組中進程才能在控制終端讀取輸入。當用戶在終端輸入信號生成終端字符(如 ctrl+c、ctrl+z、ctr+\等)時,對應的信號只會發送給前臺進程組。
shell 中可以存在多個進程組,無論是前臺進程組還是後臺進程組,它們或多或少存在一定的聯繫,爲了更好地控制這些進程組(或者稱爲作業),系統引入了會話的概念。
會話的意義在於將很多的工作集中在一個終端,選取其中一個作爲前臺來直接接收終端的輸入及信號,其他的工作則放在後臺執行。
可以使用下面幾個函數來通知內核哪一個進程組是前臺進程組,以便設備驅動程序知道該把終端輸入和終端產生的信號發往何處。
#include <unistd.h>
#include <termios.h>
pid_t tcgetpgrp(int fd); /* 返回值:若成功,返回前臺進程組 ID;否則,返回 -1 */
int tcsetpgrp(int fd, pid_t pgrpid); /* 返回值:若成功,返回 0;否則,返回 -1 */
pid_t tcgetsid(int fd);/* 返回值:若成功,返回會話首進程的進程組 ID;否則,返回 -1 */
一個或多個進程組的集合組成了會話,以用戶登錄系統爲例,可能存在如下圖所示的情況。
Linux 系統提供 setsid 函數來創建會話,其接口定義如下:
#include <unistd.h>
pid_t setsid(void);
如果這個函數的調用進程不是進程組組長,那麼調用該函數會發生以下事情:
1)創建一個新會話,會話 ID 等於進程 ID,調用進程成爲會話的首進程。
2)創建一個進程組,進程組 ID 等於進程 ID,調用進程成爲進程組的組長。
3)該進程沒有控制終端,如果調用 setsid 前,該進程有控制終端,這種聯繫就會斷掉。
調用 setsid 函數的進程不能是進程組的組長,否則調用會失敗,返回-1,並置 errno 爲 EPERM。
這個限制的合理在於如果允許進程組組長遷移到新的會話,而進程組的其他成員仍然在老的會話中,那麼,就會出現同一個進程組的進程分屬不同的會話之中的情況,這就破壞了進程組和會話的嚴格的層次關係。
9、總結
最後來張圖,幫助大家更好的瞭解 PID、PGID、PPID、Session 。
參考資料:《Linux 環境編程:從應用到內核》。
(END)
相關閱讀:
宋寶華:讓Linux的段錯誤(segmentation fault)不再是一個錯誤
宋寶華:世上最好的共享內存(Linux共享內存最透徹的一篇)
宋寶華: 一圖理解終端、會話、 進程組、進程關係
相關課程:
《Linux系統編程兩駕馬車》之《多進程編程》課程
點一點右下角”在看”,爲大神打Call~