每次我们在 shell 中执行命令或命令管道(pipeline)时,都会创建一个新的进程组(process group)。在 shell 内,进程组通常称为作业。每个进程组又属于一个会话(Session)。Linux 内核为所有正在运行的进程提供两级层次结构(参见下面的图 )。因此,进程组是进程的集合,会话是相关进程组的集合。另一个重要的限制是进程组及其成员可以是单个会话的成员。
下图显示了会话、进程组和进程之间的关系。
两级层次结构
❶ – 会话id( )与会话领导进程( )SID相同。bashPID
❷ – 会话领导者进程 ( bash) 有自己的进程组,它是领导者,因此PGID与它的进程组相同PID.
❸、❹ – 会话还有 2 个进程组,分别为PGIDs 200 和 300。
❺、❻ – 只有一组可以作为终端的前台。所有其他进程组都是后台。我们稍后将讨论这些术语。
❼、❽、❾ – 会话的所有成员共享一个伪终端/dev/pts/0。
$ sleep 100 # a process group with 1 process$ cat /var/log/nginx.log | grep string | head # a process group with 3 processes
进程组
进程组有其进程组标识符PGID和创建该组的领导者。进程组领导者的PID等于相应的PGID。PID和 PGID 的类型是相同的: (pid_t)。进程组成员创建的所有新进程都继承PGID并成为进程组成员。为了创建一个进程组,我们使用setpgid()和setpgrp()系统调用。
pid_t: https://ftp.gnu.org/old-gnu/Manuals/glibc-2.2.3/html_node/libc_554.html
进程组只要有一个成员(进程),它就会存在。这样即使进程组的领导者终止了,进程组仍然有效并继续履行其职责。进程可以通过以下方式离开其进程组:
加入另一个进程组;
创建自己新的进程组;
终止。
Linux 内核可以在新进程中重用PID,前提是该进程组PGID没有成员。它确保了有效的层次结构。
进程组的领导者不能加入另一个进程组,否则将违反进程的 PID 和PGID组成员之间的约束。
进程组两个有趣的功能是:
父进程可以使用进程组 ID 来调用 wait() 方法来等待子进程;
killpg()或kill()通过使用带有负号的 PGID 参数,可以将信号发送到进程组的所有成员。
以下命令将发送SIGTERM(15)信号到进程组 123 的所有成员:
$ kill -15 -123
完整例子如下。进程组中有 2 个通过管道连接并长时间运行的脚本(它是由 shell 自动为我们创建的)。
print.pyimport signalimport osimport sysimport timedef signal_handler(signum, frame): print(f"[print] signal number: {signum}", file=sys.stderr) os._exit(signum)signal.signal(signal.SIGTERM, signal_handler)print(f"PGID: {os.getpgrp()}", file=sys.stderr)for i in range(9999): print(f"{i}") sys.stdout.flush() time.sleep(1)
以及:
stdin.pyimport fileinputimport signalimport osimport sysdef signal_handler(signum, frame): print(f"[stdin] signal number: {signum}", file=sys.stderr) os._exit(signum)signal.signal(signal.SIGTERM, signal_handler)for i, line in enumerate(fileinput.input()): print(f"{i+1}: {line.rstrip()}")
启动管道,并在执行过程中,在新的终端窗口中运行 kill 命令。
$ python3 ./print.py | python3 ./stdin.pyPGID: 97431: 02: 13: 24: 3[stdin] signal number: 15[print] signal number: 15
通过指定的 PGID 来杀死进程组:
$ kill -15 -9743会话
会话就其本身而言,它是进程组的集合。会话的所有成员都通过相同的 SID 来标识自己。它也是一种pid_t类型,并且与进程组类似,也继承自创建会话的会话领导者。会话中的所有进程共享一个控制终端(我们稍后会讨论这一点)。
新进程继承其父进程的会话 ID。为了启动新会话,进程需要调用setsid()。运行此系统调用的进程开始一个新会话,并成为会话的领导者,启动一个新进程组,也会成为其领导者。SID和PGID都被设置为进程的PID。这就是进程组领导者无法启动新会话的原因:进程组可以有成员,并且所有这些成员必须位于同一个会话中。
setsid: https://man7.org/linux/man-pages/man2/setsid.2.html
通常新会话在两种情况下创建:
当我们需要使用交互式 shell 登录时。shell 进程成为具有控制终端的会话领导者(稍后讨论);
守护进程启动并希望在自己的会话中运行以保护自身安全(稍后我们将更详细地介绍守护进程)。
为了获取正在运行的进程的所有上述信息,我们可以读取该/proc/$PID/stat文件。例如,对于我正在运行的 bash shell$$进程:
$ cat /proc/$$/stat | cut -d " " -f 1,4,5,6,7,8 | tr ' ' '' | paste <(echo -ne "pidppidpgidsidttytgid") -pid 8415 # PIDppid 8414 # parent PIDpgid 8415 # process group IDsid 8415 # sessions IDtty 34816 # tty numbertgid 9348 # foreground process group ID
相关含义(https://man7.org/linux/man-pages/man5/proc.5.html):
pid– 进程 ID。
ppid–PID该进程的父进程的。
pgrp– 进程的进程组 ID。
sid– 进程的会话 ID。
tty– 进程的控制终端。(次设备号包含在位 31 到 20 和 7 到 0 的组合中;主设备号包含在位 15 到 8 中。)
tgid– 进程控制终端的前台进程组的id。
控制终端、控制进程、前台和后台进程组
控制终端是控制会话的终端(tty、pty、控制台等)。会话可能没有控制终端。这对于守护进程来说很常见。
为了创建控制终端,首先,会话领导者(通常是 shell 进程)使用 setsid() 启动一个新会话。此操作会删除先前可用的终端(如果存在)。那么该进程需要打开一个终端设备。在第一次调用 open()时,目标终端成为会话的控制终端。从此时起,会话中的所有已经存在的进程也都可以使用终端。控制终端由fork()调用继承,并由 execve() 调用保存。某一终端只能作为一次会话的控制终端。
控制终端有 2 个重要的概念:前台进程组和后台进程组。在任何时刻,会话只能有一个前台进程组,并且可以有任意数量的后台进程组。只有前台进程组中的进程才能从控制终端读取数据。另一方面,默认情况下允许任何进程进行写入。终端有一些技巧,我们稍后讨论。
终端用户可以在控制终端上键入有特殊信号含义的终端字符。最著名的是CTRL+C和CTRL+Z。顾名思义,相应的信号被发送到前台进程组。默认情况下,CTRL+C触发一个SIGINT信号,CTRL+Z触发一个 SIGTSTP信号。
另外,打开控制终端将使会话领导者成为该终端的控制进程。从这一刻开始,如果终端断开连接,内核将向会话领导者(通常是shell进程)发送 SIGHUP 信号。
tcsetpgrp 是一个 libc 函数,用于将进程组提升到控制终端的前台组。tcgetpgrp 函数用于获取当前的前台组。这些函数主要由 shell 使用,用于控制作业。在 Linux 上,我们还可以使用 ioctl 函数( TIOCGPGRP 和 TIOCSPGRP )来获取和设置前台组。
tcsetpgrp: https://man7.org/linux/man-pages/man3/tcsetpgrp.3.html
让我们编写一个脚本来模拟为管道创建进程组的 shell 逻辑。
pg.pyimport osprint(f"parent: {os.getpid()}")pgpid = os.fork() # ⓵if not pgpid: # child os.setpgid(os.getpid(), os.getpid()) # ⓶ os.execve("./sleep.py", ["./sleep.py", ], os.environ)print(f"pgid: {pgpid}")pid = os.fork()if not pid: # child os.setpgid(os.getpid(), pgpid) # ⓷ os.execve("./sleep.py", ["./sleep.py", ], os.environ)pid = os.fork()if not pid: # child os.setpgid(os.getpid(), pgpid) # ⓷ os.execve("./sleep.py", ["./sleep.py", ], os.environ)for i in range(3) : pid, status = os.waitpid(-1, 0)
⓵ – 在 shell 管道中创建第一个进程。
⓶ – 启动一个新的进程组,并将其 PID 设置为所有未来进程的PGID。
⓷ – 启动新进程,并将它们移动到 PGID 的进程组中.
运行之后我们可以看到以下输出:
python3 ./pg.pyparent: 8429pgid: 84308431 sleep8432 sleep8430 sleep
进程的完整状态:
$ cat /proc/8429/stat | cut -d " " -f 1,4,5,6,7,8 | tr ' ' '' | paste <(echo -ne "pidppidpgidsidttytgid") -pid 8429ppid 8415pgid 8429sid 8415tty 34816tgid 8429
$ cat /proc/8430/stat | cut -d " " -f 1,4,5,6,7,8 | tr ' ' '' | paste <(echo -ne "pidppidpgidsidttytgid") -pid 8430ppid 8429pgid 8430sid 8415tty 34816tgid 8429
$ cat /proc/8431/stat | cut -d " " -f 1,4,5,6,7,8 | tr ' ' '' | paste <(echo -ne "pidppidpgidsidttytgid") -pid 8431ppid 8429pgid 843sid 8415tty 34816tgid 8429
$ cat /proc/8432/stat | cut -d " " -f 1,4,5,6,7,8 | tr ' ' '' | paste <(echo -ne "pidppidpgidsidttytgid") -pid 8432ppid 8429pgid 8430sid 8415tty 34816tgid 8429
上面代码存在一个问题:我们没有将前台组转移到新创建的进程组。控制台输出的 tgid 说明了这一点。pg.py 脚本父进程的 PGID 是 8429(PID),而不是新创建的进程组 8430。
如果我们按CTRL+C终止进程,我们将只停止所有父进程 PID 为8429 的进程。从控制终端的角度来看它位于前台组中,所以被干掉了。而 8430 组中的所有进程将继续在后台运行。如果 8430 进程组尝试从终端(stdin) 读取数据,控制终端将通过向它们发送 SIGTTIN 信号来阻止它们。这是因为进程组尝试从控制终端读取而不不是通过获取前台进程组读取的结果。如果我们注销或关闭控制终端,该后台进程组将不会收到信号SIGHUP,因为该bash进程(控制进程)不知道后台启动的进程组。
为了解决这个问题,我们需要通知控制终端我们想要在前台运行另一个进程组。让我们修改代码并添加tcsetpgrp()调用。
import osimport timeimport signalprint(f"parent: {os.getpid()}")pgpid = os.fork()if not pgpid: # child os.setpgid(os.getpid(), os.getpid()) os.execve("./sleep.py", ["./sleep.py", ], os.environ)print(f"pgid: {pgpid}")pid = os.fork()if not pid: # child os.setpgid(os.getpid(), pgpid) os.execve("./sleep.py", ["./sleep.py", ], os.environ)pid = os.fork()if not pid: # child os.setpgid(os.getpid(), pgpid) os.execve("./sleep.py", ["./sleep.py", ], os.environ)tty_fd = os.open("/dev/tty", os.O_RDONLY) # ⓵os.tcsetpgrp(tty_fd, pgpid) # ⓶for i in range(3): # ⓷ os.waitpid(-1, 0) h = signal.signal(signal.SIGTTOU, signal.SIG_IGN) # ⓸os.tcsetpgrp(tty_fd, os.getpgrp()) # ⓹signal.signal(signal.SIGTTOU, h) # ⓺print("got foreground back")time.sleep(99999)
⓵ – 为了运行tcsetpgrp(),我们需要知道当前的控制终端路径。最安全的方法是打开一个特殊的虚拟文件/dev/tty。如果进程有控制终端,它将返回该终端的文件描述符。理论上,我们也可以使用一种标准的文件描述符。但这是不可持续的,因为调用者可以使用重定向。
⓶ – 将新进程组放入控制终端的前台组。
⓷ – 等待进程退出。如果调用CTRL+C,将会回到这里。
⓸ – 在命令控制终端返回前台会话之前,我们需要使 SIGTTOU 信号静音。手册说明:如果tcsetpgrp()由其会话中后台进程组的成员调用,并且调用进程没有阻塞或忽略SIGTTOU,SIGTTOU则会向该后台进程组的所有成员发送信号。我们不需要这个信号,所以屏蔽掉就可以了。
⓹ – 返回前台。
⓺ – 恢复SIGTTOU信号处理。
如果我们现在运行脚本并按CTRL+C,一切都应该按预期工作。
$ python3 ./pg.pyparent: 8621pgid: 86228622 sleep8624 sleep8623 sleep^C <------------------- CTRL+C was pressedTraceback (most recent call last): File "/home/vagrant/data/blog/post2/./sleep.py", line 7, in <module>Traceback (most recent call last): File "/home/vagrant/data/blog/post2/./sleep.py", line 7, in <module>Traceback (most recent call last): File "/home/vagrant/data/blog/post2/./sleep.py", line 7, in <module> time.sleep(99999)KeyboardInterrupt time.sleep(99999)KeyboardInterrupt time.sleep(99999)KeyboardInterruptgot foreground back <----------------- back to foreground
控制台作业控制
现在是时候了解 shell 如何允许我们同时运行多个命令以及如何控制它们。
例如,当我们运行以下管道时:
$ sleep 999 | grep 123
shell 在这里:
使用组中第一个进程的 PID 作为 PGID创建一个新的进程组;
通过调用 tcsetpgrp() 将这个进程组设置为终端的前台组;
存储PID并设置waitpid()系统调用。
进程组也称为 shell 作业。
PID 为:
$ ps a | grep sleep 9367 pts/1 S+ 0:00 sleep 999
$ ps a | grep grep 9368 pts/1 S+ 0:00 grep 123
如果我们查看 Sleep 命令的详细信息:
$ cat /proc/9367/stat | cut -d " " -f 1,4,5,6,7,8 | tr ' ' '' | paste <(echo -ne "pidppidpgidsidttytgid") -pid 9367ppid 6821pgid 9367sid 6821tty 34817tgid 936
grep 命令的信息:
$ cat /proc/9368/stat | cut -d " " -f 1,4,5,6,7,8 | tr ' ' '' | paste <(echo -ne "pidppidpgidsidttytgid") -pid 9368ppid 6821pgid 9367sid 6821tty 34817tgid 9367
在等待前台作业完成过程中,我们可以通过按 Ctrl+Z,将该作业移动到后台。向前台进程组发送 SIGTSTP 信号是一个终端的控制动作。进程的默认信号处理程序是停止操作。反过来,bash从 waitpid()调用获取通知(返回值),表示被监控进程(子进程)的状态已更改。当bash发现前台组已停止时,它通过运行tcsetpgrp()命令将前台返回給 shell :
^Z[1]+ Stopped sleep 999 | grep 123$
我们可以使用内置jobs命令获取所有已知作业的当前状态:
$ jobs -l[1]+ 7962 Stopped sleep 999 7963 | grep 123
我们可以通过使用作业 ID 调用内置的 bg shell 来恢复后台作业,这里我们使用了 killpg 和 SIGCONT 信号。
$ bg %1[1]+ sleep 999 | grep 123 &
检查状态:
$ jobs -l[1]+ 7962 Running sleep 999 7963 | grep 123 &
如果需要,我们可以通过调用内置 fg 命令将作业移回前台:
$ fg %1sleep 999 | grep 123
我们还可以通过在管道末尾添加一个 & 符号来在后台启动作业:
$ sleep 999 | grep 123 &[1] 9408$
kill命令
kill命令:
Shell 通常允许通过作业 ID 来终止作业。因此,我们需要能够将内部作业 ID 解析为进程组 ID(%job_id语法)。
如果系统达到最大运行进程限制,允许用户向进程发送信号。通常,在紧急情况和系统异常期间。
例如,bash -int kill_builtin()和zsh- int bin_kill()。
另一个是“-1”进程组。它是一个特殊的组,向它发送信号将会把信号发送给系统上除第PID 为1 的进程之外的所有进程(现代 GNU/Linux 发行版上的 1 号进程通常是 Systemd):
[remote ~] $ sudo kill -15 -1Connection to 192.168.0.1 closed by remote host.Connection to 192.168.0.1 closed.[local ~] $
终止 Shell
当控制进程失去终端连接时,内核会发送一个SIGHUP信号来通知它。如果控制进程或会话的其他成员忽略此信号,或处理它时不执行任何操作,则后续读取和写入关闭终端(通常/dev/pts/*)调用将返EOF。
Shell 进程(通常是控制终端)有一个处理程序来捕获SIGHUP信号。接收信号会启动一个扇出(fan-out)过程,将SIGHUP 信号发送到它已创建且所知的所有作业(使用fg、bg和waitpid())。SIGHUP 的默认操作是终止。
nohup和disown
假设我们想要保护长时间运行的程序不会被连接中断或笔记本电脑电池电量不足而突然终止。在这种情况下,我们可以使用 nohup 启动程序或使用disown内置命令。
执行nohup:
将 stdin 改为/dev/null;
将stdout和stderr重定向到磁盘上文件上。
为 SIGHUP 信号设置SIG_IGN忽略标志。这里有趣的是在执行 execve() 系统调用后SIG_IGN将被保留。
执行execve()。
上述所有操作使程序不受SIGHUP信号影响,并且不会因写入或读取关闭的终端而失败。
$ nohup ./long_running_script.py &[1] 9946$ nohup: ignoring input and appending output to 'nohup.out'
$ jobs -l[1]+ 9946 Running nohup ./long_running_script.py &
实现长时间运行的程序的另一种方法是使用内置disownshell bash。它不会忽略 SIGHUP 信号,而只是从已知作业列表中删除对应 PID 的作业。因此我们不会向该进程组发送 SIGHUP 信号。
$ ./long_running_script.py &[1] 9949
$ jobs -l[1]+ 9949 Running ./long_running_script.py &
$ disown 9949
$ jobs -l
$ ps a | grep 9949 9949 pts/0 S 0:00 /usr/bin/python3 ./long_running_script.py 9954 pts/0 S+ 0:00 grep 9949
上述方案的缺点是我们不会覆盖并关闭终端标准 fd。因此当写入或读取关闭的终端时,可能会失败。
我们可以得出的另一个结论是:shell 不会发送SIGHUP到不是它创建的进程或进程组,即使该进程位于 shell 是会话领导者的同一个会话中。
守护进程
守护进程(daemon)是一种长期存在的进程。它通常在系统启动,在操作系统停止时停止。守护进程在后台运行,无需控制终端。守护进程永远不会从内核获取与终端相关的信号:SIGINT、SIGTSTP 和 SIGHUP。
创建守护进程的经典“unix”方式是:double-fork,也就是执行两次fork()后,父进程立即退出。
第一个fork()(必须):
成为 systemd (PID=1)的子进程;
如果守护进程手动从终端启动,它会将自己置于后台,并且 shell 不知道它,因此我们无法方便的终止守护进程;
保证子进程不会成为进程组领导者,因此以下setsid()调用将启动一个新会话并断开与现有控制终端的连接。
第二个fork():让守护进程不再担任会话领导者。此步骤可防止守护进程打开新的控制终端,因为只有会话领导者才能执行此操作。
gnu提供了一个 convininetlibc函数来让我们的程序成为守护进程:daemon()。
systemd不在遵守双分叉技巧。systemd 提供了新的功能:
systemd可以为守护进程启动一个新的进程会话;
它可以将 stdin、stdout 和 stderr 与常规文件或套接字交换,而不是手动关闭或将它们重定向到 syslog。例如以下 nginx 代码:
... fd = open("/dev/null", O_RDWR); ... if (dup2(fd, STDIN_FILENO) == -1) { ngx_log_error(NGX_LOG_EMERG, log, ngx_errno, "dup2(STDIN) failed"); return NGX_ERROR; } ...
因此,守护进程可以继续安全地写入到stderr和stdout,而不必担心 EOF。
参考以下设置:
https://www.freedesktop.org/software/systemd/man/systemd.exec.html
StandardOutput=StandardError=
例如,etcd 服务不进行双分叉并完全依赖于 systemd。 这就是为什么它的 PID 是 PGID 和 SID,所以它是一个会话领导者。
$ cat /proc/10350/stat | cut -d " " -f 1,4,5,6,7,8 | tr ' ' '' | paste <(echo -ne "pidppidpgidsidttytgid") -pid 10350ppid 1pgid 10350sid 10350tty 0tgid -1
systemd 为现代服务开发人员提供了许多其他功能,例如实时升级助手、套接字激活、共享套接字、cgroup 限制等……,因此我们自己实现的守护进程应该使用 systemd,而不是 double-fork 方式。