在python中,使用多进程可以绕过GIL的限制从而充分利用多核CPU来加速任务。但如果对进程处理不当,就容易造成僵尸进程或孤儿进程,从而造成系统资源的浪费。
本文分析了在python中可能会生成这两种进程的写法以及如何正确地处理代码以避免它们的产生。
本文所有的代码均在CentOS下测试通过,其他的版本与操作系统不做保证。
1. 僵尸进程的产生与处理
1.1 产生一个僵尸进程
当父进程分配给一个子进程去某项任务后不再管理该子进程(无论该子进程是否完成、是否报错),那么当子进程完成任务后,就会变成僵尸进程。
利用python制造一个僵尸进程非常简单,假设有两个脚本,分别名为main.py 和worker.py ,其内容如下:
import subprocess
import os
import time
if __name__ == '__main__':
print(f"I am the parent process, with pid: {os.getpid()}")
subprocess.Popen(['python', 'worker.py'])
time.sleep(100)
print(f"I finish my work.")
import time
import os
if __name__ == '__main__':
print(f"I am the child process, with pid: {os.getpid()} and I'm going to sleep.")
time.sleep(5)
print("Now I wake up")
现在,在控制台执行python main.py ,首先会打印以下内容:
I am the parent process, with pid: 29947
I am the child process, with pid: 29968 and I'm going to sleep.
然后马上利用ps 查看进程,那么将会打印出如下内容: 由以上可知,主进程(29947)产生了一个子进程(29968)来执行任务;5秒钟之后,控制台会继续打印:
Now I wake up
这说明子进程的任务已经完成,由于主进程在生成子进程后就没有再进行管理,此时会发现子进程已经变成了僵尸进程: 如果再等待95秒钟,主进程完成并退出,那么再通过ps查看会发现僵尸进程已经消失。这说明主进程退出后,僵尸进程转由init进程管理,它会周期性地回收僵尸进程。
所以,main.py 的最后一行是为了保证主进程持续足够的时间,这样子进程就不会交由init进程管理,僵尸进程就可以通过ps 观察到。
1.2 僵尸进程的处理
尽管生成一个子进程便不再管理是一个「不负责任」的做法,但如果子进程可以完成所有的工作(例如,能将计算结果存储到指定的数据库中而不必汇总到主进程),那这种写法确实是便利的,我们只需要考虑将僵尸进行回收即可。
python通过signal 模块提供了这样的方法。修改main.py 的代码如下:
import subprocess
import os
import time
import signal
signal.signal(signal.SIGCHLD, signal.SIG_IGN)
if __name__ == '__main__':
print(f"I am the parent process, with pid: {os.getpid()}")
subprocess.Popen(['python', 'worker.py'])
time.sleep(100)
print(f"I finish my work.")
重复1.1中的过程,会发现子进程在5秒钟后执行完成会直接退出,而没有变成僵尸进程。显然,起作用的就是这一行代码:
signal.signal(signal.SIGCHLD, signal.SIG_IGN)
signal.signal() 接受两个参数,第一个是收到的信号值,第二是所采取的行为。在这里例子里,signal.signal(signal.SIGCHLD, signal.SIG_IGN) 表示主进程在收到子进程被终止的信号(signal.SIGCHLD )时,对其进行忽略(signal.SIG_IGN ),从而子进程的资源得到了释放。
2. 孤儿进程的产生与处理
当父进程在子进程完成任务之前终止时,由于子进程还会继续完成任务,这时就变成了一个孤儿进程。
2.1 产生一个孤儿进程
稍微修改上述代码,就可以产生一个孤儿进程:
import subprocess
import time
import os
if __name__ == '__main__':
print(f"I am the parent process, with pid: {os.getpid()}")
subprocess.Popen(['python', 'worker.py'])
time.sleep(5)
print(f"I finish my work.")
import time
import os
if __name__ == '__main__':
print(f"I am the child process, with pid: {os.getpid()} and I'm going to sleep.")
time.sleep(10)
print("Now I wake up")
仍然在控制台执行python main.py ,则首先会打印如下内容:
I am the parent process, with pid: 12994
I am the child process, with pid: 13015 and I'm going to sleep.
这是利用ps 查看进程,内容如下: 5秒钟之后,父进程退出了。这时,ps 的结果如下: 注意,1631号进程已经退出,而1641号进程的父进程已经变成了init进程。这说明它已经变成了孤儿进程了,交由init来管理。
10秒钟之后,子进程也完成了任务并退出了,用ps 可以验证这一点。
由于孤儿进程有init接管,因此它不会变成僵尸进程。
2.2 孤儿进程的处理
尽管孤儿进程不同于僵尸进程,但在某些情况下(例如任务运行起来后发现在程序的最后有一个错误,为了避免执行前置代码浪费时间,想要终止这些进程)我们希望主进程退出后,自动终止所有的子进程。
顺着1.2中的想法,我们希望当主进程收到终止信号时,它先终止所有的子进程,然后再退出。
要实现这一点,我们需要维护一个列表来存储所有子进程:
import subprocess
import time
import os
import signal
child_processes = []
def handler(signum, action):
for i, p in enumerate(child_processes):
print(f'Killing {i+1}/5...')
p.kill()
raise
if __name__ == '__main__':
print(f"I am the parent process, with pid: {os.getpid()}")
for i in range(5):
p = subprocess.Popen(['python', 'worker.py'])
child_processes.append(p)
signal.signal(signal.SIGTERM, handler)
time.sleep(10)
print(f"I finish my work.")
signal.SIGTERM 表示终止信号,也就是在使用kill <pid> 时向目标进程发送的信号。通过定义handler 函数,我们已经修改了主进程在收到终止信号时的行为:找到所有的子进程,然后杀死它们。
注意,你不能发送SIGKILL (即kill -9 <pid> 发送的信号)或SIGSTOP ,这两个信号进程是无法捕获的,会被立即终止而不进行任何操作。
如果你执行了python main.py ,那么控制台会马上打印以下内容:
I am the parent process, with pid: 12162
I am the child process, with pid: 12194 and I'm going to sleep.
I am the child process, with pid: 12207 and I'm going to sleep.
I am the child process, with pid: 12219 and I'm going to sleep.
I am the child process, with pid: 12234 and I'm going to sleep.
I am the child process, with pid: 12245 and I'm going to sleep.
然后在另外的shell中执行kill 12162 ,这样就向主进程发送了一个signal.SIGTERM 信号。主进程在收到这个信号时,转入handler 函数进行相应的动作,于是,你会在控制台上看到如下内容:
Killing 1/5...
Killing 2/5...
Killing 3/5...
Killing 4/5...
Killing 5/5...
Traceback (most recent call last):
File "main.py", line 21, in <module>
time.sleep(10)
File "main.py", line 12, in handler
raise
RuntimeError: No active exception to reraise
通过ps 查看,发现所有主进程和子进程都被终止了。
看到这里,你应该已经可以想到,在handler 函数中的raise 其实是用来终止主进程的。因为我们修改了主进程收到SIGTERM 信号时的行为,为了能让kill 仍能杀死主进程,所以可以通过杀死所有子进程后抛出一个异常的方式来终止主进程。
2.3 一种更加pythonic的方式
虽然通过2.2的方式可以实现我们的想法,但通过抛出异常的方式来终止进程总显得不够pythonic;另外,在主进程中记录所有的子进程也显得不够完美(姑且不提如果你使用了进程池时该怎么办)。那么,有没有一种更好的方法呢?
答案是肯定的。
linux中有一个进程组的概念,即主进程以及它创建的子进程都属于同一个进程组,它有唯一的编号。于是,通过os.killpg() 方法,可以批量杀死进程组中的所有进程。
于是,修改上述代码如下:
import subprocess
import time
import os
import signal
def handler(signum, action):
os.killpg(os.getpgid(os.getpid()), signal.SIGKILL)
if __name__ == '__main__':
print(f"I am the parent process, with pid: {os.getpid()}")
for i in range(5):
p = subprocess.Popen(['python', 'worker.py'])
signal.signal(signal.SIGTERM, handler)
time.sleep(10)
print(f"I finish my work.")
执行python main.py 并kill 掉该主进程后,控制台打印的内容应该如下:
I am the parent process, with pid: 20416
I am the child process, with pid: 20438 and I'm going to sleep.
I am the child process, with pid: 20445 and I'm going to sleep.
I am the child process, with pid: 20453 and I'm going to sleep.
I am the child process, with pid: 20472 and I'm going to sleep.
I am the child process, with pid: 20463 and I'm going to sleep.
已杀死
3 总结
本文简要分析了linux操作系统中的信号处理机制以及它们在python中的实现。重点讨论了如何利用python进行多进程编程时通过signal 来避免产生僵尸进程和孤儿进程。
想要了解更多的内容,可以查看本文的参考内容。
4 参考内容
- signal — 设置异步事件处理程序
- Linux信号基础
- Python模块之信号(signal)
- 主进程被杀死时,如何保证子进程同时退出,而不变为孤儿进程(一)
|