7

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

 3 years ago
source link: https://wangwei1237.gitee.io/2020/08/07/cannot-terminate-the-subprocess-run-child-process-caused-by-kill-9/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

我们有一个任务托管平台,该平台可以托管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)结束父进程的时候,子进程也确实结束了。具体如下图所示:

sigint.png

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

平台的负责人回应说: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粗暴的结束父进程是非常不安全的,具体如下图所示:

sig9.jpg

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

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

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

sigterm.jpg

从图中可以看出,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。具体执行效果如下所示:

popen.jpg

此处,我用了一个偷懒的方法,也就是把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),在工作之余抽时间来一起讨论这个问题。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK