最近发现,用 Python 启动子进程,子进程内部会再 ssh 到远程机器跑编译。超时之后发现子进程没有被清理干净,还有孙子进程在后台游离着。
排查了一圈,找到原因。记录一下。
场景
进程树长这样:
Python 编排器
└── claude(agent,用 subprocess.Popen 启动)
└── ssh
└── make(跑在远程机器上)
claude 内部 fork 出 ssh 去跑远程编译,然后 claude 自己退出了。但 Linux 里父进程退出,子进程不会跟着死,ssh 变成孤儿进程,被 init 收养,继续等远程 make 跑完。
claude 退出了,ssh 还活着。
p.kill() 救不了
超时之后想杀掉进程,直觉是 p.kill()。但 p.kill() 只发信号给 claude(p.pid),claude 可能已经退出了,就算没退出,也只杀 claude 一个进程。
ssh 是 claude 的子进程,p.kill() 不管它。ssh 还活着继续跑,变成游离的孤儿进程。
本质上这是个进程泄漏问题:超时后没能把整棵进程树清干净,孙子进程还在后台占着资源。
修复:killpg
解法是两步:
第一步,启动时加 start_new_session=True:
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, start_new_session=True)
pgid = os.getpgid(p.pid) # 立刻保存,进程退出后 getpgid 会失败
start_new_session=True 在 fork 后调用 setsid(),让 claude 成为新进程组的 leader,pgid = claude 的 pid。claude 之后 fork 出来的所有子进程(ssh、以及 ssh 可能再 fork 的东西)都继承这个 pgid,整棵树在同一个进程组里。
第二步,超时时用 killpg 杀整组:
except subprocess.TimeoutExpired:
os.killpg(pgid, signal.SIGKILL)
for pipe in (p.stdout, p.stderr):
if pipe:
pipe.close()
os.killpg(pgid, SIGKILL) 把 SIGKILL 发给整个进程组,不管树有多深,claude、ssh、以及任何孙子进程全部收到,一次清干净。
最后显式关掉 Python 侧的 pipe fd,防止 communicate() 内部还有线程在读。
注意 pgid 要在 communicate() 之前保存。超时触发时 claude 可能已经退出,os.getpgid(p.pid) 会抛 ProcessLookupError。
验证
写了三个脚本复现这个场景。
grandchild.py — 模拟 ssh,持有继承来的 pipe 写端,sleep 30s:
import time, os
print(f"[grandchild pid={os.getpid()}] 启动,sleep 30s", flush=True)
time.sleep(30)
child.py — 模拟 claude,fork 出孙子后立即退出:
import subprocess, os, sys
grandchild = subprocess.Popen([sys.executable, "grandchild.py"],
cwd=os.path.dirname(os.path.abspath(__file__)))
# 把孙子 pid 写到临时文件,供主脚本读取
with open("/tmp/demo_gc_pid.txt", "w") as f:
f.write(str(grandchild.pid))
sys.exit(0)
demo_deadlock.py — 主脚本,分两种方式对比:
import subprocess, os, sys, time, signal
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
CHILD = [sys.executable, "child.py"]
def log(msg):
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
def is_alive(pid):
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
def gc_pid():
try:
return int(open("/tmp/demo_gc_pid.txt").read().strip())
except Exception:
return None
# ── p.kill() 版 ─────────────────────────────────
log("# p.kill() 版")
p = subprocess.Popen(CHILD, cwd=SCRIPT_DIR, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
log(f"child pid={p.pid}")
time.sleep(0.5)
log(f"child returncode={p.poll()}(0=已退出)")
gcp = gc_pid()
log(f"grandchild pid={gcp},还活着: {is_alive(gcp)}")
log("调用 communicate(timeout=3),等待 pipe EOF...")
log(" child 已退出,但 grandchild 还持有 pipe 写端,EOF 不来")
t0 = time.time()
try:
p.communicate(timeout=3)
except subprocess.TimeoutExpired:
log(f"✅ 超时确认:communicate() 超时 {time.time()-t0:.1f}s,EOF 一直没来")
log(f" grandchild pid={gcp} 此时还活着: {is_alive(gcp)}")
p.kill()
log(f" p.kill() 后 grandchild 还活着: {is_alive(gcp)}")
log(" → p.kill() 无法杀孙子,进程泄漏")
os.kill(gcp, signal.SIGKILL)
p.stdout.close()
print()
# ── killpg 版 ───────────────────────────────────
log("# killpg 版")
p = subprocess.Popen(CHILD, cwd=SCRIPT_DIR, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, start_new_session=True)
pgid = os.getpgid(p.pid) # 先保存,进程退出后 getpgid 会失败
log(f"child pid={p.pid},pgid={pgid}(已保存,超时后用)")
time.sleep(0.5)
log(f"child returncode={p.poll()}")
gcp = gc_pid()
log(f"grandchild pid={gcp},还活着: {is_alive(gcp)}")
log("调用 communicate(timeout=3)...")
t0 = time.time()
try:
p.communicate(timeout=3)
except subprocess.TimeoutExpired:
log(f"超时 {time.time()-t0:.1f}s,触发 killpg...")
log(f"os.killpg(pgid={pgid}, SIGKILL) → 杀整个进程组")
try:
os.killpg(pgid, signal.SIGKILL)
log(" killpg 成功")
except ProcessLookupError:
log(" 进程组已不存在")
for pipe in (p.stdout, p.stderr):
if pipe: pipe.close()
time.sleep(0.3)
log(f"killpg 后 grandchild pid={gcp} 还活着: {is_alive(gcp)}")
log("✅ grandchild 已被杀,进程树清理完毕")
跑出来的日志:
# p.kill() 版
[18:55:27] child pid=63109
[18:55:27] child returncode=0(0=已退出)
[18:55:27] grandchild pid=63111,还活着: True
[18:55:27] 调用 communicate(timeout=3),等待 pipe EOF...
[18:55:27] child 已退出,但 grandchild 还持有 pipe 写端,EOF 不来
[18:55:30] ✅ 超时确认:communicate() 超时 3.0s,EOF 一直没来
[18:55:30] grandchild pid=63111 此时还活着: True
[18:55:30] p.kill() 后 grandchild 还活着: True
[18:55:30] → p.kill() 无法杀孙子,进程泄漏
# killpg 版
[18:55:30] child pid=63360,pgid=63360(已保存,超时后用)
[18:55:31] child returncode=0
[18:55:31] grandchild pid=63361,还活着: True
[18:55:31] 调用 communicate(timeout=3)...
[18:56:01] 超时 3.0s,触发 killpg...
[18:56:01] os.killpg(pgid=63360, SIGKILL) → 杀整个进程组
[18:56:01] killpg 成功
[18:56:01] killpg 后 grandchild pid=63361 还活着: False
[18:56:01] ✅ grandchild 已被杀,进程树清理完毕
小结
subprocess.run 和 Popen + communicate 的一个隐患:子进程超时后,p.kill() 只杀直接子进程,孙子进程变孤儿继续跑。
正确做法是 start_new_session=True + 超时时 killpg,一刀杀整棵进程树,才算真正清干净。