Python 子进程超时后仍在写文件?在 Linux 用进程组停止工作进程
文章目录
外部命令已经超时退出,后台工作进程却还在写文件。只给直接子进程设置 timeout,并不等于管理它启动的所有工作进程。
本文面向初级到中级读者,在 Linux 上为一次可信命令建立独立进程组:超时先发送 SIGTERM,留出收尾时间,再发送 SIGKILL,最后回收直接子进程。我们用一个忽略 SIGTERM 的工作进程验证清理效果,不改调度器或系统配置。
1. 方法:超时管理的是进程组,不是任意进程树
已有子进程超时与日志教程处理直接子进程;本篇补上它明确未覆盖的“子进程又启动工作进程”场景。
Popen(..., start_new_session=True)为命令建立新会话,避免给当前终端所在的组发送信号。- 超时后,用
os.killpg(process.pid, ...)给这次启动的组发信号,不使用进程名匹配,也不调用pkill。 - 发送 SIGTERM 后等待固定宽限期,即使组长已经退出,也继续给该组发送 SIGKILL。否则忽略 SIGTERM 的工作进程可能继续运行。
- 最后调用
wait()回收直接子进程;日志使用新文件,标准输出和标准错误直接写磁盘,不用 PIPE。
Python 的 Popen 参数说明解释了 start_new_session;Linux 进程标识手册说明新建子进程通常继承进程组。这里的边界是仍在同一组内的进程,不是任意后代。
2. 准备:独立目录与现有 Python
需要 Linux、bash 或 zsh、Python 3.9 或更新版本,无第三方依赖。示例在 Linux、Python 3.11.4 下验证。所有后续命令在同一个终端、同一个新目录执行;重复实验时换目录名,避免旧 PID 文件影响检查。
python3 --version
mkdir process-group-demo
cd process-group-demo
将下一节代码保存为 run_group.py。只运行自己信任的命令与参数;shell=False 不是沙箱,参数列表的用法可参考字面参数与 shell 注入排查。
3. 实现:新会话、两阶段停止与独立日志
"""Run a trusted Linux command; stop its process group on timeout. Python 3.9+."""
import argparse
import math
import os
from pathlib import Path
import signal
import subprocess
import sys
import time
def signal_group(pgid, signum):
try:
os.killpg(pgid, signum)
except ProcessLookupError:
pass # The group has already disappeared.
def run_group(argv, log_path, *, timeout=60.0, grace=1.0):
if isinstance(argv, (str, bytes)) or not argv:
raise ValueError("argv must be a nonempty argument list")
if not math.isfinite(timeout) or timeout <= 0:
raise ValueError("timeout must be finite and positive")
if not math.isfinite(grace) or grace < 0:
raise ValueError("grace must be finite and nonnegative")
with Path(log_path).open("xb") as log:
process = subprocess.Popen(
argv, stdin=subprocess.DEVNULL,
stdout=log, stderr=subprocess.STDOUT,
shell=False, start_new_session=True,
)
try:
code = process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
# Do not poll/wait/reap the leader before the final group signal.
try:
signal_group(process.pid, signal.SIGTERM)
time.sleep(grace)
finally:
signal_group(process.pid, signal.SIGKILL)
process.wait() # Reap our direct child after group cleanup.
raise TimeoutError("process group timeout")
return code if code >= 0 else 128 - code
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("--timeout", type=float, default=60.0)
parser.add_argument("--grace", type=float, default=1.0)
parser.add_argument("log", type=Path)
parser.add_argument("command", nargs=argparse.REMAINDER)
args = parser.parse_args()
command = args.command
if command[:1] == ["--"]:
command = command[1:]
if not command:
parser.error("provide a command after the log path and --")
try:
code = run_group(command, args.log, timeout=args.timeout, grace=args.grace)
except TimeoutError:
print(f"TIMEOUT: log={args.log}", file=sys.stderr)
return 124
except (OSError, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
if code:
print(f"FAILED: exit={code}; log={args.log}", file=sys.stderr)
else:
print(f"OK: log={args.log}")
return code
if __name__ == "__main__":
raise SystemExit(main())
Popen.wait(timeout=...) 超时抛出异常,不会替你执行本例的组清理。代码只忽略“组已消失”的 ProcessLookupError,其他系统错误会报告到标准错误。
超时分支在最后一次组信号之前不调用 poll() 或 wait()。在本例独立、单线程、没有自定义 SIGCHLD 处理器的进程中,让组长保留为未回收子进程,避免过早释放 PID 后再用旧编号发信号。不要把这段逻辑直接搬进有其他子进程回收器的宿主程序。
os.killpg() 面向进程组;Linux 信号手册说明 SIGKILL 不能被捕获或忽略。因此需要优雅停止的工作进程应先处理 SIGTERM,做法见记录边界上的收尾教程。强杀不能保证业务结果完整。
4. 运行:成功与真实失败
- 查看帮助,运行一个正常命令:
python3 run_group.py --help
python3 run_group.py --timeout 5 ok.log -- python3 -c 'print("processed 3 records")'
cat ok.log
最后两条命令的输出:
OK: log=ok.log
processed 3 records
- 验证非零退出没有被包装成成功。下面是故意失败的演示,不要放在启用
set -e的脚本里直接连跑:
python3 run_group.py --timeout 5 fail.log -- python3 -c 'import sys; print("bad input", file=sys.stderr); sys.exit(7)'
printf 'exit=%s\n' "$?"
cat fail.log
对应输出:
FAILED: exit=7; log=fail.log
exit=7
bad input
退出码约定:成功为 0,参数、日志或启动错误为 2,包装器检测到超时为 124,普通命令的非负退出码原样传回;被信号 N 终止时映射为 128 + N。命令自身也可能返回 2 或 124,因此还要看 ERROR、FAILED、TIMEOUT 诊断前缀,而非只凭退出码判断来源。
5. 验证:忽略 SIGTERM 的工作进程也停止
- 把下面完整代码保存为
tree_job.py。它启动一个工作进程,安装忽略 SIGTERM 的处理方式后写出 PID;若未被停止,6 秒后写入late.txt。
"""A direct child plus a worker that ignores SIGTERM; Linux demo only."""
from pathlib import Path
import subprocess
import sys
import time
worker_code = """
from pathlib import Path
import os
import signal
import time
signal.signal(signal.SIGTERM, signal.SIG_IGN)
Path('worker.pid').write_text(str(os.getpid()), encoding='ascii')
time.sleep(6)
Path('late.txt').write_text('worker survived', encoding='utf-8')
"""
worker = subprocess.Popen([sys.executable, "-c", worker_code])
# Print only after the worker installed its handler and recorded its PID.
deadline = time.monotonic() + 3
while not Path("worker.pid").exists():
if time.monotonic() > deadline:
raise RuntimeError("worker did not become ready")
time.sleep(0.01)
print(f"READY: worker={worker.pid}", flush=True)
worker.wait()
- 在干净目录内运行。这个命令也会故意返回非零状态:
python3 run_group.py --timeout 1 --grace 0.5 tree.log -- python3 tree_job.py
printf 'exit=%s\n' "$?"
cat tree.log
应看到 TIMEOUT: log=tree.log、exit=124,日志包含 READY: worker=...,其中 PID 每次不同。若没有 READY,应先排查工作进程启动失败或机器太忙,不能把本次实验视为已经验证清理。
- 验证该 PID 不再运行,并等待超过工作进程的原始写入时间:
python3 - <<'PY_CHECK'
from pathlib import Path
import time
pid = int(Path("worker.pid").read_text(encoding="ascii"))
def still_running(pid):
try:
status = Path(f"/proc/{pid}/status").read_text()
except FileNotFoundError:
return False
state = next(line for line in status.splitlines() if line.startswith("State:"))
return state.split()[1] not in {"Z", "X"}
deadline = time.monotonic() + 2
while still_running(pid) and time.monotonic() < deadline:
time.sleep(0.05)
assert not still_running(pid), "worker is still running"
time.sleep(6)
assert not Path("late.txt").exists(), "worker wrote after timeout"
print("PASS: worker stopped; no late output")
PY_CHECK
预期输出 PASS: worker stopped; no late output。Linux /proc 中的 Z 表示僵尸进程,不再执行代码;它不等于仍在写文件,回收由其父进程或接管它的进程负责。这里检查的是停止执行与没有延迟产物,不是承诺整个组的每个 PID 都已从系统表消失。
本例测试还对照了相同的 tree_job.py:仅使用 subprocess.run(timeout=1) 时,工作进程继续生成了 late.txt;使用本包装器时,工作进程因 SIGKILL 停止,未生成该文件。测试进程接管并回收了演示产生的孤儿工作进程。
- 最后确认旧日志不会被覆盖:
python3 - <<'PY_CHECK'
from pathlib import Path
import subprocess
import sys
before = Path("ok.log").read_bytes()
result = subprocess.run(
[sys.executable, "run_group.py", "ok.log", "--",
sys.executable, "-c", "print('replacement')"],
capture_output=True, text=True,
)
assert result.returncode == 2, result.stderr
assert Path("ok.log").read_bytes() == before
print("PASS: existing log preserved")
PY_CHECK
6. 排障与使用边界
| 现象 | 具体处理 |
|---|---|
File exists |
换新的日志路径;重复整个实验时使用新目录,不删除真实任务日志。 |
No such file or directory |
核对可执行文件和日志父目录;日志文件可能已创建但命令未启动。 |
ERROR: timeout/grace... |
timeout 必须为有限正数,grace 为有限非负数;不接受 NaN 或 Infinity。 |
| 超时后仍有其他工作进程 | 检查工具是否调用 setsid()、改变组或启动守护进程;它们可能已离开受控组。 |
| 停止后结果文件不完整 | 不读取未通过业务校验的产物;SIGKILL 不执行 finally、刷新缓冲或回滚文件。 |
本例只覆盖命令仍未退出而触发超时的情况。若直接子进程正常退出却留下后台工作进程,包装器不会再清理它们。需要监管完整任务生命周期、处理包装器被 SIGTERM/SIGKILL 终止、跨组进程或权限变化时,应交给具备资源边界的任务管理器,而不是不断增加进程名匹配规则。
超时不是硬实时截止:创建进程、固定宽限期以及不可中断内核等待都可能增加总耗时。日志没有大小上限,也不应记录凭据;本例不提供自动重试、磁盘配额或文件回滚。只用于自己管理的、进程组行为明确的小型 Linux 自动化任务。
7. 总结
为一次外部命令建立独立进程组,超时后先 TERM、再 KILL,能覆盖仍在组内的工作进程。验证时不要只看组长退出,要同时检查工作进程状态和延迟产物。明确跨组、正常退出遗留进程与强杀的边界后,再把包装器接入自己的批处理。
- 原文作者:yanzhi
- 原文链接:https://www.bobobk.com/python-subprocess-timeout-process-group.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可。非商业转载请注明作者与原文链接,商业转载请联系作者获得授权。