本帖最后由 look_w 于 2017-12-19 20:05 编辑
本文缘起于最近几天笔者实现的一段代码,目的是利用 python 在 Linux 中实现一个常驻内存的后台守护进程负责向其他进程提供服务,起初笔者自信的认为 multiprocessing.Process 类的 daemon 属性应该符合要求,于是乎不假思索的挥毫写下测试代码如下:
清单 1 永不消逝的进程 v01
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| from multiprocessing import Process
from time import sleep
def child_process():
# 子进程函数
# 建立一个进程,每隔一秒钟输出"child's still alive."
while (1):
print("child's still alive.")
sleep(1)
def main():
# 主进程函数
p = Process(target=child_process)
p.daemon = True # 设置 daemon 属性为 True
p.start()
sleep(10) # 休眠 10 秒后结束
print("Main process ends.")
print("Will child process live forever?") # 我们期待子进程继续活着,但事实上……
if __name__ == "__main__":
main()
|
这段程序的目的很简单:主进程(main())利用 Process 对象 fork 出一个子进程(child_process()),设上 daemon 属性,然后结束自己,期待着子进程可以就这么活下去,安静地每隔 1 秒输出一行"child's still alive"。
当然,如果运行结果如前所述的话,那笔者估计也不会作此拙作了。
实际的运行结果是:伴随着"will child process live forever?",子进程的输出也戛然而止,这意味着子进程最终也还是随着父进程的消亡而消逝了。
这个问题着实困扰了笔者一阵,直到在中的 daemon 属性下看到了这句描述:
When a process exits, it attempts to terminate all of its daemonic child processes.
真相大白,原来这里的 daemon 并非真正意义上的守护进程,而是"守护父进程的进程",当父进程结束的时候,"守护着"它的进程也会被自动销毁。
于是,在感叹写程序切不可望文生义的同时,笔者也不得不开始琢磨如何自己动手丰衣足食,另辟蹊径来实现守护进程了。
所幸,在 Linux 下,想要实现出一个不死的进程,办法还是很多的。
从 nohup 说开去开始这一部分正文之前,先说一小段题外话:若干年前,笔者刚刚才加工作之时,曾经参加过一款基于嵌入式 Linux 的模块的研发。如今回想起来,当时最为印象深刻的,就是这个模块的软件系统极其庞杂,限于开发服务器的性能,一次完整的编译,有时候竟需要半个小时甚至更久的时间。倘若有幸在临近下班时分下载一份全新的代码进行编译,那欲哭无泪的画面实在是美的令人不忍直视。
那时笔者尚属菜鸟,于是便数次毫无悬念地在 terminal 前面等待满长的编译结束直到华灯初上。直到有一天一位过路神仙给笔者支了个招:
nohup make &
关机!下班!
然后,等到笔者次日懵懵懂懂的回到办公室打开电脑,编译完毕的二进制文件早已安安静静的躺在服务器的硬盘里了。
——知识就是力量!
好,题外话告一段落,现在咱们来看一看这 nohup 的力量到底来自哪里:
解密 nohup一般而言,man 命令是了解绝大部分 Linux 命令的绝佳入口,但是打开 nohup 的 man page,却只能发现寥寥数语:
nohup - run a command immune to hangups, with output to a non-tty
这样简略的信息只怕是不够我们理解其原理的,幸而 nohup 是 GNU Coreutil 的一部分,本着死代码不说谎的原则,笔者又寻到了源码,却发现其实现出人意料的简单(其实现摘要如清单 2 所示,中文注释为笔者所加):
清单 2 nohup.c1
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
| int
main (int argc, char **argv)
{
/* …… */
if (ignoring_input)
{
/* 重定向标准输入到/dev/null */
}
if (redirecting_stdout || (redirecting_stderr && stdout_is_closed))
{
/* 重定向标准输出到文件 */
}
if (redirecting_stderr)
{
/* 重定向标准错误到文件 */
}
/* 忽略 SIGHUP 信号 */
signal (SIGHUP, SIG_IGN);
/* 执行 cmd */
char **cmd = argv + optind;
execvp (*cmd, cmd);
/* …… */
return exit_status;
}
|
抛去一堆重定向带来的视觉杂讯,我们不难发现,在创建一个新的进程执行以参数形式传入的 cmd 之前(execvp),nohup 忽略了 SIGHUP 信号,这意味着,作为 nohup 子进程被执行的命令,如果其自身不做任何特殊处理(例如重新为 SIGHUP 信号绑定一个 handler),同样会继承其父进程对所有信号的处理方式,即对 SIGHUP 信号不闻不问。
结合从 man page 中得到的信息,我们很容易将"immune to hangups"和"signal (SIGHUP, SIG_IGN)"等同起来,但是,为什么忽略了 SIGHUP 信号的子进程就不会随着父进程的结束而消逝?在什么样的场景下,一个进程会收到 SIGHUP 信号呢?
要回答这个问题,我们首先要了解 Linux 系统中描述进程关系(Process Relationships)的两个非常重要的术语:进程组(Process Group)和会话(Session)。
进程组和会话在开始枯燥的术语介绍之前,先让我们来看一看在一个真实的 Linux 环境下的进程组和会话到底长什么样:
清单 3 利用 ps -j 显示进程组和会话信息1
2
3
4
5
6
7
8
9
10
11
12
13
14
| #首先远程 SSH 登陆一台 Linux 服务器
$ ssh zhang@9.115.241.18
#然后打开一个后台进程直接进入休眠
$ sleep 1000 &
[1] 23661 #这里的 23661 号进程就是我们的研究对象
#接下来我们利用 ps j 命令来查看一下当前 login shell 进程 ($$) 和 23661 进程的作业(job)相关信息
$ ps j 23661 $$
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
4721 21682 21682 21682 pts/20 23856 Ss 1000 0:00 -bash
21682 23661 23661 21682 pts/20 23856 S 1000 0:00 sleep 1000
#上表的返回值中,PID 指进程 id;PPID 指父进程 PID;PGID 指进程组 id
#SID 指会话 id;TTY 指会话的控制终端设备;COMMAND 指进程所执行的命令
#TPGID 指前台进程组的 PGID。
#由于当前掌握着控制终端的是 ps 进程,故上述两个进程的 TPGID 都为 23856。
|
由清单 3 最后的 ps 结果可以发现若干貌似巧合的结果,例如进程 23661 的 PGID 恰好等于 PID;又比如进程 23661(sleep 1000)和 21682(login shell 进程)共享同一个 SID(亦即 login shell 的 PID)。在接下来的内容里笔者将通过对进程组和会话的解读,向读者展示这些巧合的背后到底隐藏了怎么样的设计。
进程组和会话都是 Unix 早期被引入的概念,其中进程组的设计在早期 AT&T Unix 发行版中就已初见端倪;而会话则要略晚一些,其设计雏形直到 SVR4 才被引入。
本着先来后到的原则,笔者先来介绍进程组:
- 顾名思义,进程组就是一系列相互关联的进程集合,系统中的每一个进程也必须从属于某一个进程组;
- 每个进程组中都会有一个唯一的 ID(process group id),简称 PGID;PGID 一般等同于进程组的创建进程的 Process ID,而这个进进程一般也会被称为进程组先导(process group leader),同一进程组中除了进程组先导外的其他进程都是其子孙;
- 进程组的存在,方便了系统对多个相关进程执行某些统一的操作,例如,我们可以一次性发送一个信号量给同一进程组中的所有进程。
在早期 Unix 的设计中,进程组主要是用于终端访问控制(control terminal access)。以 SVR3 为例,一个比较典型的应用场景是:每当有一个终端通过某一 TTY 来访问服务器,一个包含了 login shell 进程的进程组就会被建立起来,因此进程组先导一般是为该终端而建的 shell 进程。当时还没有作业控制的概念,于是所有在该 shell 中被建立的新进程都会自动的隶属于同一进程组之下。同时该 tty 也会被设置为该进程组下所有进程共有的控制终端 (Controlling Terminal) ,所有的进程可以同时对控制终端进行读写。下图大致反映了当有终端用户接入时早期 Unix 环境下的进程布局:
图 1. 早期 Unix(SVR3)下的进程组设计诚然,以事后诸葛亮的眼光来看,这样的设计是存在不少弊端的,比如进程组对控制终端缺乏有效的管理手段;再比如所有进程无差别共享控制终端的设计会带来灾难性的混乱。
于是在 SVR4 之后,作业控制(job control)的概念被提了出来,会话的设计也随即被引入了进来:
- 会话是一个若干进程组的集合,同样的,系统中每一个进程组也都必须从属于某一个会话;
- 一个会话只拥有最多一个控制终端(也可以没有),该终端为会话中所有进程组中的进程所共用。当然和早期设计中所有进程都可以无差别读写控制终端不同,这一次,进程被以进程组为单位划分为两类:前台进程组(foreground process group)和后台进程组(background process group)。一个会话中前台进程组只会有一个,只有其中的进程才可以和控制终端进行交互;除了前台进程组外的进程组,都是后台进程组;
- 和进程组先导类似,会话中也有会话先导(session leader)的概念,用来表示建立起到控制终端连接的进程。在拥有控制终端的会话中,session leader 也被称为控制进程(controlling process),一般来说控制进程也就是登入系统的 shell 进程(login shell);
- 为了支持作业控制,很多 shell 工具也做了相应的修改:在执行一个新的命令时,新生成的进程都会被置于一个和 Shell 进程不一样的全新的进程组之下;
一言以蔽之,新的设计将控制终端(tty 或 pty)的访问和控制完全置于了会话的管理之下,最大限度的避免了旧设计所带来的弊端。下图反映了在引入了会话的设计之后,有终端用户访问系统时进程的大致布局。 |