Linux | c&cpp | Email | github | QQ群:425043908 关注本站

itarticle.cc

您现在的位置是:网站首页 -> Linux 文章内容

那些永不消逝的进程(nohup的原理)-itarticl.cc-IT技术类文章记录&分享

发布时间: 5年前Linux 125人已围观返回

从 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.c

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 显示进程组和会话信息

#首先远程 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 环境下的进程布局:

from clipboard


诚然,以事后诸葛亮的眼光来看,这样的设计是存在不少弊端的,比如进程组对控制终端缺乏有效的管理手段;再比如所有进程无差别共享控制终端的设计会带来灾难性的混乱。


于是在 SVR4 之后,作业控制(job control)的概念被提了出来,会话的设计也随即被引入了进来:


  • 会话是一个若干进程组的集合,同样的,系统中每一个进程组也都必须从属于某一个会话;
  • 一个会话只拥有最多一个控制终端(也可以没有),该终端为会话中所有进程组中的进程所共用。当然和早期设计中所有进程都可以无差别读写控制终端不同,这一次,进程被以进程组为单位划分为两类:前台进程组(foreground process group)和后台进程组(background process group)。一个会话中前台进程组只会有一个,只有其中的进程才可以和控制终端进行交互;除了前台进程组外的进程组,都是后台进程组;
  • 和进程组先导类似,会话中也有会话先导(session leader)的概念,用来表示建立起到控制终端连接的进程。在拥有控制终端的会话中,session leader 也被称为控制进程(controlling process),一般来说控制进程也就是登入系统的 shell 进程(login shell);
  • 为了支持作业控制,很多 shell 工具也做了相应的修改:在执行一个新的命令时,新生成的进程都会被置于一个和 Shell 进程不一样的全新的进程组之下;


一言以蔽之,新的设计将控制终端(tty 或 pty)的访问和控制完全置于了会话的管理之下,最大限度的避免了旧设计所带来的弊端。下图反映了在引入了会话的设计之后,有终端用户访问系统时进程的大致布局。

from clipboard

现在我们再来回顾一下清单 3 中的那些"巧合":


  • 在 login shell 进程(PID=21682)被创建出来的同时,一个新的会话(SID=21682)和新的进程组(PGID=21682) 也同时被创建了出来,会话的控制进程(或会话先导)即 login shell 进程本身,控制终端是 pts/20;
  • 当 sleep 进程(PID=23661)被创建出来的同时,新的进程组(PGID=23661)也同时被创建了起来,进程组被置于后台运行(命令行末尾有&)。由于隶属于会话 21682,所以该进程组的控制终端也是 pts/20;


大致搞清了进程组和会话之后,现在我们再回到最初的那个问题:信号 SIGHUP 在这一设计体系下到底扮演了什么角色?


SIGHUP,如其字面所述,这是一个用来描述 "挂断" 状态的信号,也就是说,当终端连接被关闭或无法维系之时,就需要这个信号出场了。具体来讲,每当:


终端连接中断时,SIGHUP 会被发送到控制进程,后者会在将这个信号转发给会话中所有的进程组之后自行了断;

控制进程被关闭时,SIGHUP 会被直接发送给会话中所有的进程组;

顺便说一句,一般进程对于 SIGHUP 信号的默认处理也同样是终结自己。


这样一来,笔者当年的困惑就被解答了:用于编译的 make 程序没有对 SIGHUP 信号做任何特殊处理,所以当终端连接中断时(远程终端应用程序被关闭),慢悠悠的编译进程也就这么被终止了。


永不消逝的进程 v1:

下面让我们通过一个实际例子来看一看一个被 nohup 处理过的进程在其所属的会话的控制进程收到 SIGHUP 信号时会发生些什么:


清单 4 实例:一个 nohup 处理过的进程

#首先远程 SSH 登陆一台 Linux 服务器

$ ssh zhang@9.115.241.18

#然后打开一个后台进程 (27871) 直接进入休眠

$ nohup sleep 1000 &

[1] 10590

#利用 ps j 命令来查看一下 27871 进程的作业(job)相关信息

$ ps j 10590

PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND

10417 10590 10590 10417 pts/20 10655 S 1000 0:00 sleep 1000

#一切正常,现在断开连接,关闭会话

$ exit

#最后再在远程运行 ps -j 命令来检查 10590 进程当前的状态

$ ssh zhang@9.115.241.18 ‘ps -j’

PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND

1 10590 10590 10417 ? -1 S 1000 0:00 sleep 1000

比较一下 exit 前后的 ps 输出,可以发现:


由于原有父进程 10417 已死,10590 变成了孤儿进程(orphan);

由于 pts/20 随着会话 10417 被关闭了,TTY 和 TPGID 被置为了?和-1;

而原有的 PGID 和 SID 保持不变,只不过 process group leader 和 session leader 分别变成了 10590;

因此,我们得出了结论:一个忽略了 SIGHUP 信号的进程,在它所属的会话的控制进程被终结之后依旧可以继续运行;但此时由于原有控制终端已经不再存在了,它便不再有终端输入或输出的能力;此外,原有的会话依旧存在,只不过会话先导(session leader,由于此时的会话中已没有任何终端,因此不能称之为控制进程了)变为该进程。


总而言之,看起来 SIGHUP 基本可以满足笔者的需求了,下文的清单 5 是笔者对清单 1 中例程略作修改之后的结果,这一次,即使 shell 进程被关闭,child process 仍然可以继续在后台欢快的运行,称得上拥有"不死之身"了。有兴趣的读者可以在自己的环境里尝试一下。


清单 5 永不消逝的进程 v1

import signal

import time

from multiprocessing import Process

from os import getpid



def child_process():

# child process

# 忽略 child process 的 SIGHUP 信号

signal.signal(signal.SIGHUP, signal.SIG_IGN)

print("child process's pid: %d" % getpid())

while (1):

print("child's still alive.")

time.sleep(1)



def main():

p = Process(target=child_process)

"""

这里就不能再设置 daemon 属性为 True 了,

因为如果 daemon 属性为 True,则 Process 进程结束时会自动 terminate 所有的子进程

这样就没 SIGHUP 什么事了

"""

p.start()

# parent process

print("Parent process ends here.")

print("Will child process live forever?")



if __name__ == '__main__':

main()

看着 ps 输出里的 client process 在那里闷声发大财,是不是有点小激动?好,现在到了泼冷水的时候了,下面咱们来探讨一下用屏蔽 SIGHUP 实现出的"不死"进程都有些什么痛脚。


nohup 的局限性

咱们先来说结论,用屏蔽 SIGHUP 的方式来实现守护进程,平时个人用用偷着乐还行;真要做成通用的解决方案,那还是有一定差距的。


最容易想到的问题来自于易用性:显而易见,控制进程被杀之后带来的最直接的软肋就是控制终端就此失效,这给想要获取程序的运行状态的用户带来了一个难题。以前文的'nohup make &'为例,nohup 会很体贴的默认将应用程序的 stdout 或 stderr 转到文件 nohup.out 之中,但如若这个文件被删,那就欲哭无泪了。因此为守护进程提供一个稳定的执行结果输出方案是非常必要的;


获取了输出,那下一步要考虑的就是如何对守护进程输入了:一般来说,配置文件是这类问题的入门机配置。但是,如何告诉一个正在运行的进程去重新载入用户刚刚修改过的配置呢?如果你不打算额外实现点什么,那么最简便的方法就是利用操作系统已经实现好了的机制:信号。


那么问题又来了,使用什么信号量告诉程序重载配置文件比较好呢?很遗憾,解决这个问题的常规方式还是 SIGHUP,理由也比较充分:POSIX 中信号量的确是定义了不少,但却各司其职;且守护进程本就没有控制终端了,那不用 SIGHUP 用谁?


于是我们又开始要面对一个小小的悖论了:屏蔽 SIGHUP 真的好吗?


当然,上面说的两点之外,我们还需要面对一箩筐的问题:比如守护进程的工作目录无法被 umount;再比如绝对不可以允许守护进程再偷偷的拥有一个控制终端;再比如如何清理那些遗留在系统边边角角的守护进程……


发布时间: 5年前Linux125人已围观返回回到顶端

很赞哦! (1)

文章评论

  • 请先说点什么
    热门评论
    124人参与,0条评论

站点信息

  • 建站时间:2016-04-01
  • 文章统计:728条
  • 文章评论:82条
  • QQ群二维码:扫描二维码,互相交流