深入剖析 Python subprocess 清理失效的根因——terminate 的实际行为、信号到达时的进程状态、以及信号发出后的进程去向,并给出可靠终止协议。
凌晨两点,部署脚本报告成功。日志显示 worker 已完成、健康检查通过、脚本以退出码 0 退出。但日志没有显示的是,它在十分钟前启动的子进程——仍然活着、仍然占着端口、仍然在往一个已经被轮转走的日志文件里追加内容。下一次部署失败了,因为端口被占用了。事后分析报告写了最明显的那句话:上一次运行没有清理干净。没有人输入错误的命令。脚本只是杀错了目标。
每一种启动子进程的语言都附带了一个 kill 按钮,而在每一种语言里,这个按钮有一半的时间是在说谎。Python 的谎言有三层,它们按固定顺序失效:terminate() 实际发送了什么、信号到达时谁在监听、以及信号落地后进程发生了什么。大多数清理 bug 都藏在调用和僵尸之间的那个缝隙里,而且大多数都逃过了代码审查——因为正常路径(子进程自己退出)从来不会暴露它们。本文从第一个信号一路走到被回收的进程,最后给出一个确保什么都不跑的终止协议,以及不需要任何 sleep 就能证明它有效的测试。
terminate() Actually Sends当你对一个 subprocess.Popen 对象调用 process.terminate() 时,Python 向子进程发送 SIGTERM。SIGTERM 不是 kill。它是一个退出请求,交给一个信号处理器——而这个处理器可能被子进程替换了、可能被忽略了、也可能还没准备好处理。一个行为良好的进程会退出。有处理器(负责刷新缓冲区、保存状态、完成网络调用)的进程可能需要几秒甚至几分钟。没有任何处理器的进程会立即退出。而一个处理器(在你不控制的代码里)决定不退出的进程——它会继续运行。
process.kill() 发送 SIGKILL,内核不加询问地投递过去。没有什么能忽略它,没有什么能推迟它,进程也没有任何机会做清理。这两个调用看起来像是在温和和粗暴之间切换。实际上它们是一场谈判的两个端点,而父进程必须主导这场谈判——因为父进程无法提前知道它在和哪种类型的子进程打交道。
import signal
import subprocess
proc = subprocess.Popen(["worker", "--queue", "high"])
proc.terminate() # SIGTERM: a polite request
try:
proc.wait(timeout=5) # give the handler time to clean up
except subprocess.TimeoutExpired:
proc.kill() # SIGKILL: the kernel takes over
proc.wait()
第二个谎言是投递。默认情况下,信号只发往一个进程:直接的子进程。如果那个子进程是一个 shell——只要你传了 shell=True 它就是,而当你命名的可执行文件是一个 wrapper 脚本、Makefile 或者会自己 spawn worker 的语言运行时,这种情况经常发生——子进程不会转发任何东西。你的 SIGTERM 到达了坐在三棵进程树顶端的 shell,而 shell 对于发给它孩子们的信号有自己的想法。
即使没有 shell,真正的子进程也会 spawn 孙子进程。构建工具启动编译器,编译器启动链接器,媒体编码器分叉出 worker 进程。父进程只持有那棵树的根节点的句柄,杀死根节点会让其余的继续运行。而其余的才是占着端口、锁文件或写到一半的数据库行的那部分。
# shell=True wraps the command in /bin/sh -c
proc = subprocess.Popen(
"ffmpeg -i in.mp4 out.mp4", # sh spawns ffmpeg as its own child
shell=True,
)
proc.terminate() # kills the shell; ffmpeg keeps transcoding
解决办法是停止寻址进程本身,转而寻址整个组。POSIX 系统精确地组织进程分组,以便一个信号可以瞄准一棵树。用 start_new_session=True 在自己的会话中启动子进程,这让它成为进程组 leader,然后用 os.killpg 向组内的每个成员发信号。你启动的子进程、它 spawn 的孙子进程、以及中间的 shell——全部在一个调用中收到信号。
proc = subprocess.Popen(
["worker", "--queue", "high"],
start_new_session=True, # the child leads its own process group
)
os.killpg(proc.pid, signal.SIGTERM) # the whole tree, politely
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL) # the whole tree, for real
proc.wait()
分组改变了 kill 的含义:从"告诉顶层停止"变成"告诉这个单元里的所有人停止"。这就像是打电话给经理和疏散整层楼的区别,对于进程树来说,这是确保楼层真正清空的唯一方法。
第三个谎言是关于时间的。Popen.communicate(timeout=N) 是约束子进程最常见的方式,而它的失败模式众所周知地臭名昭著:这个方法抛出 TimeoutExpired,调用方捕获了它,然后子进程继续运行。超时限制的是父进程的耐心,而不是子进程的寿命。这个陷阱格外讨厌的地方在于它不是静默的——代码清楚地知道调用超时了——然而自然反应(记录日志然后继续)恰恰是把孤儿留在身后的那种反应。
try:
out, err = proc.communicate(timeout=10)
except subprocess.TimeoutExpired:
logger.error("worker exceeded 10 seconds") # logged, and...
# ...the worker is still running, still holding the queue
同一个方法里还有第二个陷阱。超时之后,communicate() 已经把管道留在了不一致的状态,标准库文档明确说明了恢复方式:先 kill 进程,然后再次调用 communicate() 来排空缓冲区里剩余的内容。第二次调用不是可选的簿记工作。一个在写大容量载荷时死掉的子进程,如果剩余字节没有被读取,可能让父进程阻塞在满的管道缓冲区上。
except subprocess.TimeoutExpired:
proc.kill()
out, err = proc.communicate() # drain the pipes, then reap
把各个部分拼在一起,可靠的终止是一个固定的四步序列:礼貌地向组发信号、带期限地等待、强制地向组发信号、再等待直到尸体被回收。两个信号之间的期限是给行为良好的进程做清理的机会,而第二次等待是保证进程表中这个槽位在父进程继续之前真正空闲。
def terminate_tree(proc: subprocess.Popen, grace: float = 5.0) -> int:
os.killpg(proc.pid, signal.SIGTERM)
try:
return proc.wait(timeout=grace)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
return proc.wait()
宽限期是一个策略决定,不是调优旋钮:它是你愿意在接受半写入文件的风险之前付出的清理时间。协议之所以有效,是因为每一步都是无条件的——没有哪一步会问子进程看起来是否配合,也没有哪一步会在决定之前重新检查进程状态。整个流程中唯一的条件是超时,而超时是一个期限,不是对子进程情绪的猜测。
退出的进程并没有结束。它变成了僵尸:进程表中的一个条目,带着退出状态,等待它的父进程用 wait() 读取那个状态。僵尸几乎不消耗任何东西——没有 CPU、没有值得命名的内存——但它占用一个进程表槽位,而有足够多僵尸的系统最终会拒绝 spawn 新进程。内核保留尸体是因为父进程可能想知道子进程是怎么死的。父进程的工作是收尸。
Popen.wait() 和 communicate() 替你做了回收。微妙之处在于当父进程从不调用它们时会发生什么。一个长期运行的 worker spawn 一次性的子进程却忘记为每一个调用 wait,会积累僵尸直到机器发现。一个父进程先死的子进程会被最近的幸存祖先收养,这通常意味着它在你的代码停止运行的同时不再是你的问题——但前提是你的代码确实停止了。在一个捕获错误并继续运行的进程中,未回收的子进程保持未回收。
asyncio 世界有相同的形状但不同的管道。create_subprocess_exec 返回一个具有相同 wait() 和 communicate() 方法的 Process,同样的升级逻辑也适用。额外的危险是:一个 bare 异常处理器包裹着一个未 await 的子进程,会让子进程一直运行到事件循环本身结束,而循环会愉快地结束——即使子进程还在工作。终止不是事件循环替你管理的细节。
生命周期 bug 对人们通常写的测试是不可见的,因为通常的测试启动一个进程、断言它的输出、然后让它自然退出。本文中每一种失败模式都涉及一个拒绝退出的子进程,所以测试必须主动创建一个。技巧是用 Python 自己作为不听话的子进程:一个短脚本,捕获 SIGTERM、打印它收到了信号、然后继续运行直到更强的东西到来。
STUB = """
import signal
import sys
import time
def hold(signum, frame):
print("got SIGTERM, ignoring", flush=True)
signal.signal(signal.SIGTERM, hold)
print("ready", flush=True)
while True:
time.sleep(0.05)
"""
def test_terminate_tree_reaches_everyone():
proc = subprocess.Popen(
[sys.executable, "-c", STUB],
start_new_session=True,
stdout=subprocess.PIPE,
)
assert proc.stdout.readline().strip() == "ready"
os.killpg(proc.pid, signal.SIGTERM)
assert proc.poll() is None # the polite step was ignored
os.killpg(proc.pid, signal.SIGKILL)
assert proc.wait(timeout=5) == -signal.SIGKILL
断言中间状态——SIGTERM 后活着、SIGKILL 后死了——把测试精确地钉在 bug 所在的位置。同一个 stub 适用于 shell 场景:用 shell=True 启动、终止顶层、验证孙子进程在 plain terminate() 下存活但在 killpg 下不存活。每一个断言都是关于进程状态的,不是墙上时钟的时间,所以测试是确定性的,永远不需要 sleep。
终止一个子进程不是一个调用。它是一个有四项义务的协议:知道你的信号在请求什么、知道谁在监听、寻址整棵树、收集尸体。Python 提供了每一块——SIGTERM、start_new_session、os.killpg、wait——但如果这些碎片以错误的顺序运行,哪一块都不起作用,而且没有任何测试会检查那些让子进程自己退出的路径。下一次部署因为端口被占用而失败时,问题不是上一次运行是否杀死了它的子进程。问题是子进程是否真的被彻底杀死了。
Originally published on Dispatch.