34 进程组、会话和作业控制
进程组
进程组和会话的作用
进程组和会话的主要作用是 shell 的作业控制。一个以 ;
(或者什么都没有)结尾的命令会启用一个前台进程组,一个以 &
结尾的命令会启用一个后台进程组。在终端(窗口环境中的控制终端实际上是一个伪终端)中键入特殊字符发送信号时,信号会发给前台进程组的中的所有进程。
通常 shell 和 login(1) 会通过系统调用设置进程组和会话号。
进程组获取 / 设置的 API
可以通过 pid_t getpgrp(void)
获得当前进程的进程组 ID,通过 setpgid(pid, pgid)
来设置给定进程的进程组 ID。这两个函数的后缀不同,是因为历史原因。
对于 setpgid
,如果两个参数指定了同一个进程,那么就会创建一个新的线程组;否则,指定的进程会被移动到给定的线程组中。pid
参数只能用来指定调用进程和其中一个子进程,而 pgid
参数必须是会话中的进程。另外,一个进程在子进程执行 exec()
之后就不能对其设置进程组 ID 了。
在作业控制 shell 中设置进程组
一个任务(即一个命令或一组以管道符连接的命令)中的所有进程必须被放置在一个进程组中。实际上 shell 进程需要将其他任务的进程组号设置为第一个任务的进程号。
同时,由于我们不清楚 fork()
之后父子进程的调度顺序,所以我们在 fork()
后的父进程和子进程后都要对子进程设置进程组号,并在父进程中忽略 EACCES
错误(因为子进程可能已经执行了 exec()
)。在父进程中也要设置子进程的进程组号,可能是因为父进程后面的逻辑还会依赖进程组的使用。
会话
获取会话 ID
会话是进程组的集合。通过系统调用 getsid(pid)
可以获得进程号为 pid 的进程的会话号。少数系统上只有当前进程和给定的进程处于同一个会话当中时,才能获取给定进程的会话 ID,在 Linux 上没有这个限制。
创建新的会话
使用无参的 setsid()
函数可以创建一个新的会话。这个函数在命名上有点奇怪,尽管它的名字中带有 set
,但并不代表会用参数去设置会话 ID,反而是创建了新的会话。
用 setsid()
创建新会话后,调用进程会成为会话和进程组中的首个进程。正因此,如果调用进程已经是进程组的首个进程,那么 setsid()
系统调用就会报错(设想进程组的组长都跑了,怎么对进程组正常管理呢?)。一般通过 fork()
之后让子进程创建新会话、退出父进程的方法来确保不会发生这样的调用错误。
创建的新会话还会和以前的控制终端断开连接,从而没有控制终端(需要以后再分配)。
控制终端
一个会话最多可以关联一个控制终端,一个终端也最多成为一个会话的控制终端。控制终端需要由会话的首进程完成关联,这时该进程也成为终端的控制进程。控制进程通常是一个 shell。
- 在 System V(包括 Linux)中,会话首进程如果没有控制终端,在打开一个没有和其他会话关联的终端时,会将其作为会话的控制终端。在调用
open()
时指定O_NOCTTY
标记可以防止终端成为会话的控制终端。 - 在 BSD 中,会话首进程可以通过
ioctl(fd, TIOCSCTTY)
来完成到描述符为 fd 的终端设备的关联;Linux 也支持用ioctl()
来关联控制终端,但是这个操作在其他非 BSD 系统中不常见。
当进程所在的会话关联有控制终端时,进程可以通过打开文件 /dev/tty 来打开其控制终端(例如,getpass()
函数就会打开 /dev/tty 文件)。
如果进程从会话继承了控制终端,想要和其断开连接,可以使用 ioctl(fd, TIOCNOTTY)
。这样便不能再打开 /dev/tty 文件。
如果终端退出,那么内核会向终端的控制进程发送 SIGHUP。如果控制进程退出,那么会话(和会话中的所有进程)就会和终端断开连接,内核还会向前台进程组的所有成员发送 SIGHUP 和 SIGCONT 信号来通知控制终端的丢失。
前台和后台进程组
前台进程组可以通过标准输入 / 输出流和终端交互(这既是性质又是定义)。Shell 进程会监控前台进程组的状态,如果前台进程组完成执行,就会将自己移动到前台。
为了支持控制终端的前台进程组的切换,有以下的系统调用:
#include <unistd.h>
pid_t tcgetpgrp(int fd);
int tcsetpgrp(int fd, pid_t pgrp);
SIGHUP 信号
SIGHUP 信号在终端中的产生
首先,终端断开时,控制进程会收到 SIGHUP 信号和 SIGCONT 信号。而 shell(常作为终端的控制进程)会注册信号处理器,从而在退出前将 SIGHUP 发给它创建的各个任务。信号只会发送给 shell 所在的进程组,如果有任务将自己转移到了新的进程组(比如使用 setpgid(0, 0)
),即便是在同一会话中也不会收到 shell 发来的 SIGHUP 信号。
举例:终端驱动器检测到连接断开(调制解调器或终端行上的信号丢失);关闭终端窗口,与该窗口关联的伪终端的文件描述符随之关闭。
其次,如果控制进程终止了,内核就会将 SIGHUP(和一个 SIGCONT,Linux 会这样做,但是 SUSv3 没规定)发给前台进程组的所有进程。这里不管控制进程终止的原因,它可以是正常退出,也可能是因为处理了 SIGHUP 后退出的。书上的原句:
If the controlling process terminates for any reason, then the foreground process group is signaled with SIGHUP.
一些 shell 在退出时也会向后台任务(而不只是前台任务)发送 SIGHUP,比如 bash 和 Korn shell。
终端断开 ---> 内核给控制进程发 SIGHUP
|
| 可能导致
▼
控制进程终止 ---> 内核给前台进程组发 SIGHUP
|
|
▼
如果是 shell 正常退出(exit/^D)或者优雅退出(处理 SIGHUP 信号),
它还会给它管理的进程(同一个进程组)发 SIGHUP。
实验:验证 shell 创建的后台进程是否会因为 shell 来不及杀而逃过一劫
问题:如果 shell 有后台进程,我们向 shell 发送 SIGKILL 终止它,它没有机会发送 SIGHUP,而内核只会向控制终端的前台进程组发送 SIGHUP,这是否会让 shell 之前创建的后台进程得到保留?
1. 第一次错误
错误的实验方法:为了防止 shell 直接被杀没了,我使用 bash
命令再次进入 bash,在其中运行 sleep 1000
,杀掉 bash 本身之后,sleep 进程被 init 收养。不过奇怪的是,这个 init 的命令是 /init,进程号为 211。而进程号为 1 的进程同样是 init,命令是 /sbin/init。用 ps -p 212 -o pid,ppid,cmd
这样的方法重复几次发现有一条 211 -> 210 -> 2 -> 1 的进程链,后者为前者的父进程,而沿途的进程名都是 init。
这个实验的错误之处在于:我杀掉的 bash 不属于控制终端。不过还是能从中得出这样的结论,即控制进程还在,父进程被杀了,子进程会被 init 收养,而且负责收养的 init 进程并不一定是 1 号进程。这个可能和 wsl 内核的实现有关系,见 issue 5176。
2. 改进实验
我现在直接拿 wsl 的登陆 shell 做测试,开了两个终端。其中一个终端运行 sleep 1000
,另外一个终端杀死前一个终端的 shell,结果 sleep 进程作为前台进程组的一员也被杀掉了,这符合预期。然后用同样的方法让终端 1 运行 sleep 1000 &
,终端 2 杀死终端 1 的 shell(也是终端的控制进程),结果 sleep 作为后台进程组的一员也被杀了。这是为什么?
我后来发现我查找进程的方式有错误。我之前使用的是 ps a
来列举 wsl 下的进程(因为 wsl 没有运行桌面环境,所以进程数很少,一页就能列完),但是 ps a
只能列举出和终端关联的进程。网页 https://unix.stackexchange.com/a/106848/ 中有这样的信息:
a = show processes for all users u = display the process’s user/owner x = also show processes not attached to a terminal
实际上 ps
似乎是只能列出当前会话的进程,而 ps a
可以列出所有会话的进程。我开了 2 个 wsl 终端,登陆的是同一个用户,所以说 a
选项的用户应该不是“Linux 用户”的意思。
3. 终于搞对了
使用 ps aux
就能发现之前的 sleep 进程了。同样追溯一下它的父进程,有 886600 (sleep) -> 886529 (/init) -> 1 (/sbin/init)
的父进程链(可能和 wsl 有关)。
忽略 SIGHUP 信号
nohup(1) 创建的进程会忽略 SIGHUP。
Bash 内置的 disown
命令则可以将一个任务从 shell 的任务列表中删除,这样 shell 在退出时就不会给这个任务发送信号了。为了避免内核向这个任务发送信号,这个任务必须是后台任务。
作业控制
SIGCONT 信号
一般来说,非特权进程只有在真实或有效用户 ID 和另外一个进程的真实或保存的设置用户 ID(saved set-user-ID)相等时,才能向它发送信号。但是 SIGCONT 是个特例,如果目标进程和当前进程处在同一个会话中,也可以发送。因为用户可能会运行一个 set-user-ID 程序并改变进程的真实用户 ID,但我们仍需要用终端控制它。
SIGTTIN 和 SIGTTOU 信号
后台任务读取终端就会收到 SIGTTIN 信号而停止(stop,可以被恢复),但它们仍然是可以写终端的。如果还想禁止后台任务写终端,可以给终端设置 TOSTOP 标志,这样它们写终端时就会收到 SIGTTOU 信号而停止。
对于后台任务,如果阻塞或者忽略了 STGTTIN 或 SIGTTOU,对应的信号就不会发送(这样应该也不会产生 pending 的信号?):
- 如果阻塞或者忽略了 SIGTTIN,
read()
该失败的时候还是会失败,但是程序不会停止。 - 如果阻塞或者忽略了 SIGTTOU,
write()
和其他尝试修改控制终端属性的操作(比如tcsetpgrp()
/tcsetattr()
/tcflush()
等)会成功。
SIGTSTP 信号
Screen-handling 程序(如 vi
和 less
)要正确处理 SIGTSTP 信号,因为它们会改变终端的一些设置(比如光标和显示方式),所以在被停止前要将这些设置恢复。
信号处理器的操作顺序有一些讲究(英文书 P724):
- 保存
errno
。 - 完成信号处理。
- 将 SIGTSTP 的信号处理方式恢复成默认。
- 用
raise()
给自己发送 SIGTSTP 信号。 - 修改信号掩码,允许发送 SIGTSTP 信号。因为上一步已经发送了信号,这一步在返回之前就会收到信号而暂停程序执行。
- 程序恢复后,立即再次阻塞 SIGTSTP 信号。不用解开阻塞,因为信号处理器的调用者在处理这个信号的时候就会保存信号掩码,并在信号处理器函数返回时恢复信号掩码。
- 再次注册当前的信号处理函数。
- 恢复
errno
。
注册 SIGTSTP 信号处理器时:
- 先获取旧的信号处理器,看看旧的信号处理器是不是 SIG_IGN。如果是,则可能表示父进程希望我们不注册信号处理器(this prevents an application from attempting to handle these signals if it is started from a non-job-control shell)。这是一条通则,即我们应该尊重父进程对信号的忽略。比如在没有作业控制的 shell 中,用来表示后台任务时可能也会把 SIGINT 和 SIGTERM 的忽略方式设置为 SIG_IGN;用户可能会使用 nohup(1) 让进程忽略 SIGHUP 信号。
sa.sa_flags
还有个SA_RESTART
标志,这是因为我们希望接收到 SIGTSTP 信号而中断执行的系统调用能够自动重启。
Note
如果第 6 步阻塞 SIGTSTP(即便时间间隔已经非常短了)还是晚于另外一个 SIGTSTP 信号的接收,那么程序又会再次进入暂停状态。
孤儿进程组(Orphaned Process Group)
一个进程组不是孤儿进程组当且仅当它至少有一个进程的父进程在不同的进程组,但是在同一个会话中。( 对于一般的 shell 任务进程组,每个进程的父进程是 shell,这就满足了在不同的进程组、但是在同一个会话中的条件,所以不是孤儿。)
根据定义,一个 session leader 处在孤儿进程组中,因为 setsid()
会创建一个新会话和新进程组。
为什么要设计孤儿进程组的概念
假设 shell 创建了一个任务,这个任务有个父进程创建了子进程,随后父进程退出,子进程继续运行。这个时候 shell 不知道子进程的存在(因为子进程不是由 shell 创建的),所以 shell 不可能去管理这个子进程。这个子进程就成了孤儿、被 init 收养,这个子进程所在的进程组也成了孤儿进程组。
即便孤儿进程组中一个被停止的进程有一个仍然存活但是处于不同会话中的父进程,这个父进程也会因为信号发送限制不能跨会话向其发送 SIGCONT 信号,导致无法将子进程从停止状态恢复。
因此,孤儿进程组的进程实际上是没有进程管得了(或者说知道要去管理)的状态。设计了孤儿进程组的概念之后,内核可以在进程组成为孤儿进程组时,检查是否有进程是停止状态,如果有则向孤儿进程组中的成员发送 SIGHUP 和 SIGCONT。(如果没有进程是停止状态,则不会发送这些信号。)
孤儿进程组和停止信号
因为孤儿进程组没有进程去管,所以内核不希望其中的进程停止,在父进程退出时检查到进程组变成孤儿、且有进程在停止状态时会发送 SIGHUP 和 SIGCONT 也是这个原因。
那如果有信号会把孤儿进程组中的进程停止呢?这个时候内核会避免发送 SIGTTIN 和 SIGTTOU 信号,比如 read()
和 write()
失败时不发送这些信号,而是返回 EIO 错误。同样,如果孤儿进程组中的进程还是收到了停止信号,信号会悄无声息被丢弃。