先给答案:setsid() 让调用它的进程脱离当前会话,新建一个属于自己的会话,并成为这个会话的首进程。效果是——从此它不再属于任何终端,终端出什么事都与它无关。
要理解这句话,需要三层背景,从下往上说。
进程组(process group) 是一组相关进程的集合,比如你在 shell 里敲一条管道 a | b,这两个进程通常同属一个进程组。信号可以发给单个进程,也可以发给整个进程组,比如你在终端里按 Ctrl+C,内核其实是把中断信号发给了当前的前台进程组。
会话(session) 是若干进程组的集合。你在终端里登录一次——开一个终端窗口、建一条 ssh 连接——对应的就是一个会话。会话里的第一个进程叫会话首进程,通常就是 shell,你在这个终端里跑的程序都属于这个会话。
控制终端(controlling terminal) 是一个会话可以绑定的终端设备。会话里有一个”前台进程组”,终端上敲的 Ctrl+C、Ctrl+Z,以及终端被挂断时产生的 SIGHUP,都是发给前台进程组的。
setsid() 做的事,按 man 手册的规定:新建一个会话和一个进程组,调用者自己同时成为新会话的首进程和新进程组的组长,并且这个新会话不与任何终端关联。最后一条是关键,所谓”脱离终端”就是指这个。
这跟 graphd 的关系在于:不调 setsid(),graphd 就还挂在启动它的那个 ssh 会话里。你断开 ssh 或关掉终端,内核会给这个会话发 SIGHUP;而 graphd 只注册了 SIGINT 和 SIGTERM 两个信号(GraphDaemon.cpp:175-176),SIGHUP 没注册,走默认动作就是终止进程。调过 setsid() 之后,graphd 不在那个会话里了,SIGHUP 到不了它身上。
顺带回答一个必然的疑问:setsid() 为什么必须先 fork() 一次?因为它有一个前提条件——调用者不能是当前进程组的组长,否则直接失败。而从 shell 里启动的程序,在有作业控制的 shell 下通常自己就是进程组组长。所以先 fork(),让子进程继承父进程的进程组(组长仍是父进程),子进程不是组长,setsid() 才能成功。这就是经典守护进程”先 fork、再 setsid”的顺序由来,不是随意的排版。
另外,setsid() 只解决了终端信号这一件事。此时子进程的标准输入还接在终端上(虽然会话已经没关系了),所以代码紧接着把标准输入接到 /dev/null;标准输出和标准错误则更早就在 setupLogging 里接到日志文件了。
内核关心的只有一件事:那个控制终端设备被挂断了。本地窗口关掉时,是终端模拟器进程被杀、pty 被关闭;ssh 断开时,是服务端的 sshd 关掉了这条连接对应的 pty。两种情况下内核做的事一样——给这个会话发 SIGHUP。所以”终端关掉”和”ssh 断开”举的是两个例子,说明同一种事件,远程连过来和坐在本机面前没有区别。
这里叫做默认的信号处理函数,是不是更准确严谨 比”默认动作”好懂,但严格说也不准确——因为默认的那个处置并不是一个函数。
内核给每个信号在进程里存的是一个”处置方式”(disposition),只有三种取值:默认(SIG_DFL)、忽略(SIG_IGN)、或者指向用户注册的处理函数。SIG_DFL 本身不是函数地址,它是个哨兵值,表示”交给内核按这个信号的默认处置办”;内核那段代码你也拿不到指针。所以对 SIGTERM 来说,“默认”实际就是内核直接把进程终止,跟你注册的那种处理函数不是同类东西。手册里也是分开叫的:你装的叫 handler,内核那个叫 default action / default disposition。
要说准确又清楚,写成”默认处置”或”默认处理方式”,再补半句三种取值:
(信号在进程里可以有三种处置:默认、忽略、或交给一个用户注册的处理函数。不注册就是第一种,由内核按这个信号的默认处置来办,多数就是直接终止进程。)
要不要我把这句换进去?