春江暮客

春江暮客的个人学习分享网站

Python 子进程超时后仍在写文件?在 Linux 用进程组停止工作进程

2026-11-05 技术
Python 子进程超时后仍在写文件?在 Linux 用进程组停止工作进程
文章目录

外部命令已经超时退出,后台工作进程却还在写文件。只给直接子进程设置 timeout,并不等于管理它启动的所有工作进程。

本文面向初级到中级读者,在 Linux 上为一次可信命令建立独立进程组:超时先发送 SIGTERM,留出收尾时间,再发送 SIGKILL,最后回收直接子进程。我们用一个忽略 SIGTERM 的工作进程验证清理效果,不改调度器或系统配置。

1. 方法:超时管理的是进程组,不是任意进程树

已有子进程超时与日志教程处理直接子进程;本篇补上它明确未覆盖的“子进程又启动工作进程”场景。

  1. Popen(..., start_new_session=True) 为命令建立新会话,避免给当前终端所在的组发送信号。
  2. 超时后,用 os.killpg(process.pid, ...) 给这次启动的组发信号,不使用进程名匹配,也不调用 pkill。
  3. 发送 SIGTERM 后等待固定宽限期,即使组长已经退出,也继续给该组发送 SIGKILL。否则忽略 SIGTERM 的工作进程可能继续运行。
  4. 最后调用 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. 运行:成功与真实失败

  1. 查看帮助,运行一个正常命令:
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
  1. 验证非零退出没有被包装成成功。下面是故意失败的演示,不要放在启用 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 的工作进程也停止

  1. 把下面完整代码保存为 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()
  1. 在干净目录内运行。这个命令也会故意返回非零状态:
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,应先排查工作进程启动失败或机器太忙,不能把本次实验视为已经验证清理。

  1. 验证该 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 停止,未生成该文件。测试进程接管并回收了演示产生的孤儿工作进程。

  1. 最后确认旧日志不会被覆盖:
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,能覆盖仍在组内的工作进程。验证时不要只看组长退出,要同时检查工作进程状态和延迟产物。明确跨组、正常退出遗留进程与强杀的边界后,再把包装器接入自己的批处理。

标签

1024 12306 2fa ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn chatgpt checkpoint cli cloudflare codex cofoldarena concurrency configparser configuration copy cprofile cpu监控 cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging decimal deployment desktop-app devtools difflib disk-space disown dns docker dovecot download electron encoding esm esm2 esm3 esmc esmfold2 etag evaluation faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io file-management filecmp filenames files flashppi flask folium frontend game generator git github-actions google google-research gpt-6.1-sol grep gzip harness heapq hls html http hugo imports indexnow ipaddress jailbreak javascript jev json jsonl just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp memory mirror mmcif model-evaluation monitoring mp3 mp4 mpnn multiomics multiprocessing mutation mysql nanobert nanobodies networking networkx nginx normalize numpy ollama omegatherm pandas password pathlib pdb pep-723 performance permissions phaser pillow pip postfix preprocessing print prompt-engineering protein-alignment protein-design protein-embeddings protein-interactions protein-language-models protein-stability protein-structure proxy pydantic pyecharts pyqt python python3 r raincloud reproducibility requests reservoir-sampling rfoptimization rg ripgrep rosettafold3 roundcube rrsi rsi rsync s-tui sampling scale scikit-learn scrapy screen seaborn security selenium seo sequence-identity sha256 shell signals sitemap sklearn socket solana somaticsignatures sorting spl sqlite ssh standardize static-site subprocess symlink sysadmin system-one tarfile tcp tempfile tensorflow tkinter tls toml totp tracemalloc tron tronpy troubleshooting turtle typesafe-ai ubuntu unicode upsert urllib usdt utf-8 uv venv vhhbert vite web-tools webp wordcloud wordpress workflow xml yaml zipfile zoneinfo 后台 寓言 概率 经济 贸易 迅雷解析 钱包

友情链接

其它