千言万语,不如实验来的直接...
基于sleep的小实验
首先通过实验直观感受一下后台服务的运行状况(请注意,前方高能,相关概念在更后面才有解释)。
在命令行上以不同方式执行 sleep
确定登录 shell 和伪终端。
1 2 3 4 5 6 7 8 9 10 11 12 |
|
分别以后台方式(&)、setsid、nohup 和前台方式执行 sleep
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
|
查看此时的进程关系
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
叉掉 ssh 连接窗口,查看此时的 sleep 进程状态
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
实验结论:
以不同方式启动进程,在 ssh 连接窗口被叉掉的时候会造成不同的影响。标号为 1 和 4 的两个进程都消失了,标号为 3 的进程有属性发生了变化,只有标号为 2 的进程没有任何改变。
在 shell 脚本中上以不同方式执行 sleep
测试一(前台进程组)
1 2 3 4 5 6 7 8 |
|
在另一个窗口中查看
1 2 3 4 5 6 7 8 9 10 |
|
此时叉掉启动 test_1.sh 脚本的窗口,可以看到对应的进程全部消失。
1 2 3 4 5 6 7 |
|
测试二(孤儿后台进程组)
1 2 3 4 5 6 7 8 |
|
在另一个窗口中查看
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
此时叉掉启动 test_2.sh 脚本的窗口,可以看到 sleep 600 对应进程的 TTY 和 TPGID 发生了变化,但进程并未消失。
1 2 3 4 5 6 7 8 9 |
|
测试三(前台进程组)
1 2 3 4 5 6 7 8 9 |
|
在另一个窗口中查看
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
此时叉掉启动 test_3.sh 脚本的窗口,可以看到对应的进程全部消失。
1 2 3 4 5 6 7 8 9 |
|
测试四(后台进程组)
1 2 3 4 5 6 7 8 9 10 |
|
在另外一个窗口中查看
1 2 3 4 5 6 7 8 9 10 11 12 |
|
此时叉掉后台启动 test_1.sh 脚本的窗口,可以看到对应的进程全部消失。
1 2 3 4 5 6 7 8 9 |
|
实验结论:
- 如果 shell 脚本中存在前台执行的命令,则在其未执行结束前,会“卡住”当前 shell 脚本对应的进程,进而“卡住”bash 进程。即整个进程树在 ps ajxf 中都会作为前台进程组显示。
- & 的使用在 shell 脚本内外会产生不同的效果,在 shell 脚本内可以产生孤儿进程组(前提是没有其他命令的执行导致 shell 脚本无法退出),在脚本外则产生普通的后台进程组;
- 对于孤儿进程组和普通后台进程组 SIGHUP 信号在处理细节上是不同的;
相关概念
要想理解上面的实验结果,首先必须理解如下一些概念:
【进程组】
- 一个或多个进程的集合。通常与同一作业(job)相关联。
- 每个进程组都可以有一个组长进程,组长进程的特征是“进程组 ID 等于其进程 ID”,即PGID = PID。
- 组长进程自身可以在创建一个进程组后,再创建该进程组中的其他进程,然后终止自己。
- 只要在某个进程组中有一个进程存在,则该进程组就存在,这与其组长进程是否终止无关。
- 进程可以通过调用 setpgid 来加入一个现有的进程组或者创建一个新进程组(也可以通过 setsid 创建一个新的进程组)。
- 一个进程只能为它自己或它的子进程设置进程组 ID。
【会话】
- POSIX.1 引入会话(session)的概念。
- 登录 shell 是一个会话的开始,而终端或伪终端则是会话的控制终端。
- 会话是一个或多个进程组的集合。
- 进程通过调用 setsid 函数建立一个新会话。
- 如果调用 setsid 函数的进程不是一个进程组的组长,则调用 setsid 函数就会创建一个新会话,同时发生下面 3 件事,
(a) 该进程变成会话首进程(session leader)(会话首进程是创建该会话的进程);
(b) 该进程成为一个新进程组的组长进程。新进程组 ID 是该调用进程的进程 ID;
(c) 该进程没有控制终端,如果在调用 setsid 之前,该进程有一个控制终端,那么这种联系也会被中断。 - 如果调用此函数的进程已经是一个进程组的组长,则此函数返回出错。为了保证不会发生这种情况,通常先调用 fork,然后使其父进程终止,而子进程则继续。因为子进程继承了父进程的进程组 ID,而其进程 ID 则是新分配的,两者不可能相等,所以就保证了子进程不会是一个进程组的组长。
- 会话首进程总是一个进程组的组长进程,所以两者是等价的。所以可以认为
会话首进程ID = 会话ID = 进程组ID = 进程组组长ID - 一个会话中包含的多个进程组可以被分成一个前台进程组(foreground process group)以及一个或几个后台进程组(background process group)。
【登陆shell】
当通过终端或网络登录时,可以得到一个登录 shell,其标准输入、输出和标准出错将连接到一个终端设备或者伪终端设备上。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
可以看到,上面所有 -bash 均为通过 ssh 网络连接建立的登陆 shell,且都对应到 pts 伪终端上,即登陆 shell 拥有控制终端。
另外,可以看到登陆 shell 的 PID = PGID = SID,所以登陆 shell 就是会话首进程,以及进程组组长进程。
登陆 shell 是一个 POSIX.1 会话的开始,而此终端或伪终端则是会话的控制终端。
【伪终端】
为使同一个软件既能处理终端 login,又能处理网络 login,系统使用了一种称为伪终端的软件驱动程序,它仿真串行终端的运行行为,并将终端操作映射为网络操作,反之亦然。
当通过终端(基于硬链接和终端设备驱动程序)或网络(基于网络连接和伪终端设备驱动程序)登录时,我们得到一个登录 shell,其标准输入、输出和标准出错连接到一个终端设备或者伪终端设备上。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
【控制终端】
- 一个会话可以有一个控制终端(controlling terminal),这通常是登陆到其上的终端设备(终端登陆)或伪终端设备(网络登录)。
- 只有建立与控制终端连接的会话首进程被称为控制进程(controlling process)。
- 如果一个会话有一个控制终端,则它有一个前台进程组,会话中的其他进程组则为后台进程组。
- 无论何时键入终端的中断键(Ctrl+C),就会将中断信号 SIGINT 发送给前台进程组中的所有进程;
- 无论何时键入终端的退出键(Ctrl+\),就会将退出信号 SIGQUIT 发送给前台进程组中的所有进程;
- 如果终端接口检测到调制解调器或网路已经断开连接,则将挂断信号 SIGHUP 发送给控制进程(会话首进程)。
- 如果在调用setsid之前某进程有一个控制终端,那么在调用后该控制终端会被中断。
【作业控制】
作业控制是 BSD 在 1980 年前后增加的一个特性。它允许在一个终端上启动多个作业(进程组),它控制哪一个作业可以访问该终端,以及哪些作业在后台运行。
【信号】
- SIGTTOU - 后台作业试图输出到控制终端,若用户设置了禁止后台作业写到控制终端(stty tostop),终端驱动程序会将该写操作标识为来自于后台进程,会向其发送该信号。
- SIGTTIN - 后台作业试图读取控制终端,终端驱动程序发现后,会向后台作业发送该信号。
- SIGTSTP - 键入Ctrl+Z挂起键与终端驱动程序进行交互,令其将该信号送至前台进程组中的所有进程,后台进程组作业不受影响。
- SIGHUP - 当检测到来自控制终端的 Hangup 信号时,或者当控制进程死亡时 (signal(7))
【守护进程】
- 守护进程也称为 daemon,是生存期较长的一种进程。常常在系统自举时启动,仅在系统关闭时才终止。没有控制终端,在后台运行。
- 大多数守护进程都以超级用户(UID 为 0)特权运行。
- 守护进程没有控制终端,ps 输出时,其控制终端显示为问号(?),前台进程组 ID 为 -1。
- 内核守护进程一般会以无控制终端方式启动;而用户实现的守护进程若没有控制终端,则可能是因为创建守护进程时调用了 setsid。
- 大多数守护进程的父进程是 init 进程。
【孤儿进程组】
- 一个其父进程已经终止的进程称为孤儿进程,这种进程会被 init 进程“收养”;
- POSIX.1 将孤儿进程组定义为:该进程组中每个成员的父进程要么是该组的一个成员,要么不是该组所属会话的成员;
- 对孤儿进程组的另一种描述为:一个进程组不是孤儿进程组的条件是,该组中有一个进程,其父进程在属于同一会话的另一个组中。
- 若父进程退出导致进程组成为孤儿进程组,且该进程组中有进程处于停止状态(收到 SIGSTOP 或 SIGTSTP 信号后被挂起),信号 SIGHUP 会被发送到该进程组中的每一个被挂起的进程。
进程消失的原因--SIGHUP
之前整理了一篇关于 SIGHUP 信号的博文,下面 给出一些结论:
- 系统对 SIGHUP 信号的默认处理是终止收到该信号的进程。所以若程序中没有捕捉该信号,当收到该信号时,进程就会退出;
- 终端(或伪终端)被关闭时,信号 SIGHUP 会被内核发送到具有控制终端的会话的会话首进程;
- 会话首进程退出前,信号 SIGHUP 会被内核发送到当前会话中的前台进程组中的每一个进程;
- bash 收到 SIGHUP 时,会给其下运行的各个作业(包括前后台)发送 SIGHUP,然后自己退出;
- 前后台的各个作业收到来自 bash 的 SIGHUP 后将退出(如果存在针对 SIGHUP 的处理,就不会退出)
基于strace研究各种运行方式的差别
既然知道了进程消失是因为 SIGHUP 信号导致,那么就可以通过 strace 观察各种运行方式下,都做了哪些相关处理。
跟踪前台运行,可以看到其中没有针对 SIGHUP 信号做任何处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
|
跟踪后台运行,可以看到其中同样没有针对SIGHUP信号做任何处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
|
跟踪 setsid 的使用,可以看到其中同样没有针对 SIGHUP 信号做任何处理(通过 setsid 执行后不会退出的原因后续再说明)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
|
跟踪 nohup 的使用,可以看到内部设置了对 SIGHUP 信号的忽略处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
|
setsid 和 nohup 的源码实现
通过上面的 strace 输出没有看出,为何通过 setsid 方式启动的程序不会因为 SIGHUP 信号退出的原因(但从理论上讲,我们知道是因为创建的进程没有控制终端的缘故)。下面看一下这两命令的源码实现。
下面是 setsid 的核心源码(取自 util-linux-2.26)
下面给出 nohup 的核心源码(取自 coreutils-8.24)
可以看到,源码实现中的逻辑与 strace 看到的内容完全对应上了。
部署工具脚本中的问题
基于以上的内容,就可以很容易发现或解释我们实际使用中的脚本存在哪些问题(公司内容,略)。