在第 1 節 “信號的基本概念”中我說過“Shell可以同時運行一個前台進程和任意多個後台進程”其實是不全面的,現在我們來研究更複雜的情況。事實上,Shell分前後台來控制的不是進程而是作業(Job)或者進程組(Process Group)。一個前台作業可以由多個進程組成,一個後台作業也可以由多個進程組成,Shell可以同時運行一個前台作業和任意多個後台作業,這稱為作業控制(Job Control)。例如用以下命令啟動5個進程(這個例子出自[APUE2e]):
$ proc1 | proc2 & $ proc3 | proc4 | proc5
其中proc1
和proc2
屬於同一個後台進程組,proc3
、proc4
、proc5
屬於同一個前台進程組,Shell進程本身屬於一個單獨的進程組。這些進程組的控制終端相同,它們屬於同一個Session。當用戶在控制終端輸入特殊的修飾鍵(例如Ctrl-C)時,內核會發送相應的信號(例如SIGINT
)給前台進程組的所有進程。各進程、進程組、Session的關係如下圖所示。
現在我們從Session和進程組的角度重新來看登錄和執行命令的過程。
getty
或telnetd
進程在打開終端設備之前調用setsid
函數創建一個新的Session,該進程稱為Session Leader,該進程的id也可以看作Session的id,然後該進程打開終端設備作為這個Session中所有進程的控制終端。在創建新Session的同時也創建了一個新的進程組,該進程是這個進程組的Process Group Leader,該進程的id也是進程組的id。
在登錄過程中,getty
或telnetd
進程變成login
,然後變成Shell,但仍然是同一個進程,仍然是Session Leader。
由Shell進程fork
出的子進程本來具有和Shell相同的Session、進程組和控制終端,但是Shell調用setpgid
函數將作業中的某個子進程指定為一個新進程組的Leader,然後調用setpgid
將該作業中的其它子進程也轉移到這個進程組中。如果這個進程組需要在前台運行,就調用tcsetpgrp
函數將它設置為前台進程組,由於一個Session只能有一個前台進程組,所以Shell所在的進程組就自動變成後台進程組。
在上面的例子中,proc3
、proc4
、proc5
被Shell放到同一個前台進程組,其中有一個進程是該進程組的Leader,Shell調用wait
等待它們運行結束。一旦它們全部運行結束,Shell就調用tcsetpgrp
函數將自己提到前台繼續接受命令。但是注意,如果proc3
、proc4
、proc5
中的某個進程又fork
出子進程,子進程也屬於同一進程組,但是Shell並不知道子進程的存在,也不會調用wait
等待它結束。換句話說,proc3 | proc4 | proc5
是Shell的作業,而這個子進程不是,這是作業和進程組在概念上的區別。一旦作業運行結束,Shell就把自己提到前台,如果原來的前台進程組還存在(如果這個子進程還沒終止),則它自動變成後台進程組(回顧一下例 30.3 “fork”)。
下面看兩個例子。
$ ps -o pid,ppid,pgrp,session,tpgid,comm | cat PID PPID PGRP SESS TPGID COMMAND 6994 6989 6994 6994 8762 bash 8762 6994 8762 6994 8762 ps 8763 6994 8762 6994 8762 cat
這個作業由ps
和cat
兩個進程組成,在前台運行。從PPID
列可以看出這兩個進程的父進程是bash
。從PGRP
列可以看出,bash
在id為6994的進程組中,這個id等於bash
的進程id,所以它是進程組的Leader,而兩個子進程在id為8762的進程組中,ps
是這個進程組的Leader。從SESS
可以看出三個進程都在同一Session中,bash
是Session Leader。從TPGID
可以看出,前台進程組的id是8762,也就是兩個子進程所在的進程組。
$ ps -o pid,ppid,pgrp,session,tpgid,comm | cat & [1] 8835 $ PID PPID PGRP SESS TPGID COMMAND 6994 6989 6994 6994 6994 bash 8834 6994 8834 6994 6994 ps 8835 6994 8834 6994 6994 cat
這個作業由ps
和cat
兩個進程組成,在後台運行,bash
不等作業結束就打印提示信息[1] 8835
然後給出提示符接受新的命令,[1]
是作業的編號,如果同時運行多個作業可以用這個編號區分,8835是該作業中某個進程的id。請讀者自己分析ps
命令的輸出結果。
我們通過實驗來理解與作業控制有關的信號。
$ cat & [1] 9386 $ (再次回車) [1]+ Stopped cat
將cat
放到後台運行,由於cat
需要讀標準輸入(也就是終端輸入),而後台進程是不能讀終端輸入的,因此內核發SIGTTIN
信號給進程,該信號的預設處理動作是使進程停止。
$ jobs [1]+ Stopped cat $ fg %1 cat hello(回車) hello ^Z [1]+ Stopped cat
jobs
命令可以查看當前有哪些作業。fg
命令可以將某個作業提至前台運行,如果該作業的進程組正在後台運行則提至前台運行,如果該作業處于停止狀態,則給進程組的每個進程發SIGCONT
信號使它繼續運行。參數%1
表示將第1個作業提至前台運行。cat
提到前台運行後,掛起等待終端輸入,當輸入hello
並回車後,cat
打印出同樣的一行,然後繼續掛起等待輸入。如果輸入Ctrl-Z則向所有前台進程發SIGTSTP
信號,該信號的預設動作是使進程停止。
$ bg %1 [1]+ cat & [1]+ Stopped cat
bg
命令可以讓某個停止的作業在後台繼續運行,也需要給該作業的進程組的每個進程發SIGCONT
信號。cat
進程繼續運行,又要讀終端輸入,然而它在後台不能讀終端輸入,所以又收到SIGTTIN
信號而停止。
$ ps PID TTY TIME CMD 6994 pts/0 00:00:05 bash 11022 pts/0 00:00:00 cat 11023 pts/0 00:00:00 ps $ kill 11022 $ ps PID TTY TIME CMD 6994 pts/0 00:00:05 bash 11022 pts/0 00:00:00 cat 11024 pts/0 00:00:00 ps $ fg %1 cat Terminated
用kill
命令給一個停止的進程發SIGTERM
信號,這個信號並不會立刻處理,而要等進程準備繼續運行之前處理,預設動作是終止進程。但如果給一個停止的進程發SIGKILL
信號就不同了。
$ cat & [1] 11121 $ ps PID TTY TIME CMD 6994 pts/0 00:00:05 bash 11121 pts/0 00:00:00 cat 11122 pts/0 00:00:00 ps [1]+ Stopped cat $ kill -KILL 11121 [1]+ Killed cat
SIGKILL
信號既不能被阻塞也不能被忽略,也不能用自定義函數捕捉,只能按系統的預設動作立刻處理。與此類似的還有SIGSTOP
信號,給一個進程發SIGSTOP
信號會使進程停止,這個預設的處理動作不能改變。這樣保證了不管什麼樣的進程都能用SIGKILL
終止或者用SIGSTOP
停止,當系統出現異常時管理員總是有辦法殺掉有問題的進程或者暫時停掉懷疑有問題的進程。
上面講了如果後台進程試圖從控制終端讀,會收到SIGTTIN
信號而停止,如果試圖向控制終端寫呢?通常是允許寫的。如果覺得後台進程向控制終端輸出信息干擾了用戶使用終端,可以設置一個終端選項禁止後台進程寫。
$ cat testfile & [1] 11426 $ hello [1]+ Done cat testfile $ stty tostop $ cat testfile & [1] 11428 [1]+ Stopped cat testfile $ fg %1 cat testfile hello
首先用stty
命令設置終端選項,禁止後台進程寫,然後啟動一個後台進程準備往終端寫,這時進程收到一個SIGTTOU
信號,預設處理動作也是停止進程。