kill -9导致subprocess.run启动的子进程无法退出

背景

我们有一个任务托管平台,该平台可以托管python语言编写任务,并且可以对任务状态进行管理。由于业务的需要,我们需要在python的任务中调起一个shell脚本来完成一些额外的事情。当我们把编写好的任务部署到任务托管平台之后,我们发现一个奇怪的现象:当在任务的超时时间内手动结束任务的时候,只有python的父进程退出了,而python启动的shell子进程却没有退出。

subprocess模块

我们使用subprocess.run()来创建新的shell进程,具体如下:

1
2
3
4
5
6
7
8
9
subprocess.run(
cmd,
cwd=cwd,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
encoding="utf-8",
timeout=600
)

为了方便测试,分别写了一段python代码shell代码,可以点击链接查看具体代码。其中,shell脚本为一个死循环,具体如下:

1
2
3
4
for((i=0;;i++))
do
echo "$i" >log 2>&1
done

然后,我们在本地使用kill -2(ctrl+c)结束父进程的时候,子进程也确实结束了。具体如下图所示:

我们继续查出问题的原因,我们咨询了任务托管平台的负责人:任务托管平台页面上的结束任务是怎么实现的?

平台的负责人回应说:kill -9命令结束的。

在这时候,我知道,我可能大概知道问题的原因了。

kill和signal

关于kill命令,此处不做详细介绍,具体可以参考kill(1)手册kill的作用是向某个特殊的进程或进程组发送一个特殊的信号,从而达到结束进程的目的。关于信号(signal),此处也不做详细介绍,具体可以参考signal(7)手册

kill -9命令实际上是向进程发送了SIGKILL信号,而在signal(7)手册中可以看到:The signals SIGKILL and SIGSTOP cannot be caught, blocked, or ignored. 因此,kill -9是一种不可捕获的、不可忽略的信号,用来在特殊情况下紧急结束进程(如果该信号可以捕获和忽略的话,就达不到这个目的了)。

而对于一个单进程的程序而言,直接kill -9结束并没有什么问题,但是对于一个多进程的程序,例如本文中的例子,在python进程中又创建了shell子进程,那么直接用kill -9粗暴的结束父进程是非常不安全的,具体如下图所示:

可见,在kill -9结束父进程之后,shell编写的子进程成为了孤儿进程,并继续执行。

这也就是,我们在任务托管平台上结束任务后,子进程并没有退出的根本原因。父进程结束的信号根本就没有机会通知到子进程,子进程也就不可能结束了。

那么,我们换另外一个可以被捕获和忽略的信号,例如SIGTERM是否能结束子进程呢?

从图中可以看出,SIGTERM信号也没有结束子进程。

subprocess.run()所捕获的异常

我们从subprocess模块的源码中可以发现,subprocess.run()实际上只捕获自定义的TimeoutExpired异常和KeyboardInterrupt异常,而在python中,KeyboardInterrupt异常对应的就是用户中断执行,一般就是输入ctl+c或发送SIGINT信号。具体如下:

1
2
3
4
5
6
7
8
9
10
with Popen(*popenargs, **kwargs) as process:
try:
stdout, stderr = process.communicate(input, timeout=timeout)
except TimeoutExpired as exc:
# ...
raise
except: # Including KeyboardInterrupt, communicate handled that.
process.kill()
# We don't call process.wait() as .__exit__ does that for us.
raise

可见,对于SIGINT信号而言,subprocess.run()函数会调用Popen.kill()来结束子进程。

因此,对于多进程而言,当父进结束之前,需要通过某种机制来通知其子进程,进而让子进程知晓父进程的退出信息,并作出合理的后续行为。否则,就会出现本文中出现的孤儿进程的现象。

因此,对于其他的信号,subprocess模块本身就无法处理了。

捕获SIGTERM信号

如果要捕获SIGTERM信号,使得kill -15结束python任务的时候,同时也能结束子进程,那么就要耍点小聪明了,例如:在python中捕获其他信号,并将其转成SIGINT信号,具体可以参见timeout_1.py。具体执行效果如下所示:

此处,我用了一个偷懒的方法,也就是把SIGTERM信号捕获之后转成SIGINT信号,具体的代码如下:

1
2
3
4
5
6
7
8
def sigintHandler(signum, frame):
raise KeyboardInterrupt

exit()

def run_cmd(cmd, cwd):
signal.signal(signal.SIGTERM, sigintHandler)
# ...

结语

查完这个问题,也算是对进程相关的内容有了更深入的了解。孤儿进程,僵尸进程,不可屏蔽进程……,好像经过很多时间之后,忽然都不记得自己曾经也钻研过这些概念一样。感谢我的同事一卓(@GerenLiu),在工作之余抽时间来一起讨论这个问题。

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2020-2024 Wang Wei
  • 本站访问人数: | 本站浏览次数:

请我喝杯咖啡吧~

微信