Agent 启动的子进程退出后成了孤儿

Agent 在执行工具调用或代码运行时启动了子进程,任务结束或 Agent 崩溃后子进程没有被终止,在系统后台持续消耗资源或持有文件锁。本文分析孤儿进程的产生原因并给出清理和预防方案。

你用 Claude Code 跑了一个「启动本地服务器、测试、关闭」的任务,任务完成后发现 8080 端口仍然被占用——一个 node server.js 进程在后台静默运行,持有端口直到系统重启。或者在 LangGraph 里,一个代码沙箱 Agent 为每次测试 subprocess.Popen() 了一个新进程,Agent 在某步崩溃后,这些进程全部成为孤儿,累积了几十个占用 CPU 的僵尸进程,下次 Agent 启动时磁盘读写速度变慢了 80%。孤儿进程在 CI/CD 环境里尤其棘手——它们可能在跨任务之间持有端口或文件锁,导致下一个任务因为资源不可用而失败。

常见原因

1. 没有用上下文管理器管理子进程生命周期

直接调用 subprocess.Popen() 并保存返回的 proc 对象,但没有在 finally 块或 with 语句里调用 proc.terminate()proc.kill()。如果代码在 proc.wait() 之前抛异常,进程就会成为孤儿。

怎么判断:在代码里搜索 Popen(,检查每处 Popen 调用是否都在 with 语句里,或者 proc.terminate() 是否在 finally 块里。

2. Agent 被 SIGKILL 终止,cleanup 代码没有机会运行

当 Agent 进程被 kill -9(SIGKILL)终止时(如 OOM Killer 触发、容器被强制停止),Python 的 atexit 钩子和 finally 块不会被执行,子进程不会被清理。

怎么判断:检查 Agent 进程的退出信号。如果退出码是 -9 或 137(128+9),就是被 SIGKILL 终止的。

3. 子进程组没有被一起终止

subprocess.Popen 启动的进程本身可能又 fork 了子进程(如 shell 脚本启动的后台任务)。调用 proc.terminate() 只终止直接子进程,不终止孙进程,孙进程成为孤儿。

怎么判断:用 ps -eo pid,ppid,cmd | grep <parent_pid> 检查子进程是否有后代进程。如果有后代进程,只终止直接子进程是不够的。

4. 多线程 Agent 里子进程由非主线程启动,清理逻辑在主线程

主线程的 atexit 钩子只在主线程里执行,如果子进程是由工作线程启动的,而工作线程在主线程结束前就被销毁了,子进程清理逻辑不会被触发。

怎么判断:检查 subprocess.Popen() 的调用位置,是否在非主线程的函数里。

5. 端口绑定没有设置 SO_REUSEADDR/SO_REUSEPORT

孤儿进程持有端口后,即使新任务想在同一端口启动新服务,也会失败(Address already in use)。这不是孤儿进程被创建的原因,但放大了孤儿进程的危害。

怎么判断:新任务启动时报 OSError: [Errno 98] Address already in use,用 lsof -i :8080 检查哪个进程在占用端口。

6. 临时文件/数据库连接被孤儿进程持有

孤儿进程持有 SQLite 文件的写锁或 /tmp 下的临时文件,导致下一个使用同一资源的任务无法获取锁,报 database is locked 或类似错误。

怎么判断:用 lsof <file_path> 检查哪些进程在持有目标文件。如果显示一个已经不应该存在的进程,就是孤儿进程。

最短修复路径

Step 1:用上下文管理器包装所有 subprocess 调用

import subprocess, os, signal
from contextlib import contextmanager

@contextmanager
def managed_process(*args, **kwargs):
    """自动清理的子进程上下文管理器,确保进程在退出时被终止。"""
    proc = subprocess.Popen(*args, **kwargs, start_new_session=True)
    # start_new_session=True:子进程在独立的进程组里,方便批量终止
    try:
        yield proc
    finally:
        if proc.poll() is None:  # 进程还在运行
            try:
                # 先发 SIGTERM,给进程优雅退出的机会
                os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
                proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                # 5 秒后仍未退出,强制 SIGKILL
                os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
                proc.wait()
            except ProcessLookupError:
                pass  # 进程已经退出了

# 使用
with managed_process(["node", "server.js"], env={"PORT": "8080"}) as proc:
    run_tests()
# 退出 with 块时,server.js 自动被终止

Step 2:注册 SIGTERM 处理器,在容器停止时清理子进程

import signal, sys, weakref

_active_processes: list[subprocess.Popen] = []

def _cleanup_all_processes():
    """清理所有活跃的子进程,在程序退出时调用。"""
    for proc in _active_processes:
        if proc.poll() is None:
            try:
                os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
                proc.wait(timeout=3)
            except (subprocess.TimeoutExpired, ProcessLookupError, PermissionError):
                try:
                    os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
                except ProcessLookupError:
                    pass

import atexit
atexit.register(_cleanup_all_processes)

def handle_sigterm(signum, frame):
    _cleanup_all_processes()
    sys.exit(0)

signal.signal(signal.SIGTERM, handle_sigterm)
signal.signal(signal.SIGINT, handle_sigterm)

Step 3:任务完成后的孤儿进程巡检

#!/bin/bash
# scripts/cleanup_orphans.sh:任务完成后运行,清理遗留的孤儿进程

TASK_ID=$1

# 找出属于这个任务的孤儿进程(通过环境变量 AGENT_TASK_ID 标记)
orphans=$(ps -eo pid,environ 2>/dev/null | grep "AGENT_TASK_ID=$TASK_ID" | awk '{print $1}')

if [ -n "$orphans" ]; then
    echo "发现孤儿进程:$orphans,正在清理..."
    kill -TERM $orphans
    sleep 2
    # 仍然存活的进程强制终止
    surviving=$(ps -p $orphans -o pid= 2>/dev/null)
    if [ -n "$surviving" ]; then
        kill -KILL $surviving
    fi
    echo "清理完成"
fi

Step 4:在 Agent 的工具定义里追踪子进程

class SubprocessManager:
    """Agent 工具专用的子进程管理器,统一跟踪所有子进程。"""
    
    def __init__(self, task_id: str):
        self.task_id = task_id
        self._processes: dict[str, subprocess.Popen] = {}
    
    def start(self, name: str, cmd: list[str], **kwargs) -> subprocess.Popen:
        env = os.environ.copy()
        env["AGENT_TASK_ID"] = self.task_id  # 标记进程所属任务
        proc = subprocess.Popen(cmd, env=env, start_new_session=True, **kwargs)
        self._processes[name] = proc
        return proc
    
    def cleanup(self):
        for name, proc in self._processes.items():
            if proc.poll() is None:
                print(f"清理进程:{name} (PID {proc.pid})")
                try:
                    os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
                    proc.wait(timeout=5)
                except (subprocess.TimeoutExpired, ProcessLookupError):
                    os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
    
    def __del__(self):
        self.cleanup()

Step 5:在 CI 里加「进程泄漏」检查

# 在 CI 任务的 after_script 里检查是否有进程泄漏
before_count=$(ps aux | grep -c "node\|python\|java" || true)
run_agent_task
after_count=$(ps aux | grep -c "node\|python\|java" || true)

if [ "$after_count" -gt "$before_count" ]; then
    echo "WARNING: 任务结束后进程数增加了 $((after_count - before_count)),可能有孤儿进程"
    ps aux | grep -E "node|python|java"
fi

预防建议

  • 所有 subprocess.Popen() 调用必须放在 with managed_process(...) 上下文管理器里
  • start_new_session=True 启动子进程,让其在独立进程组里,方便用 os.killpg() 批量终止
  • 注册 atexit 钩子和 SIGTERM 处理器,确保程序正常退出时清理所有子进程
  • 为每个任务的子进程设置 AGENT_TASK_ID 环境变量,方便任务结束后巡检和清理
  • 在 CI 的 cleanup 阶段运行孤儿进程巡检脚本,防止跨任务资源泄漏
  • 对需要长期运行的子进程(如本地服务器),用进程监控工具(如 supervisor)管理,而不是直接 Popen
  • 容器化部署时,让 Agent 进程作为 PID 1 运行(或使用 tini 作为 init),确保容器停止时所有子进程都被终止

常见问答 (FAQ)

Q: proc.terminate()proc.kill() 有什么区别? A: proc.terminate() 发送 SIGTERM 信号,给进程优雅退出的机会(进程可以捕获 SIGTERM 做清理工作)。proc.kill() 发送 SIGKILL 信号,立即终止进程,进程无法捕获。推荐顺序:先 terminate(),等 5 秒,如果进程还在运行则 kill()start_new_session=True 配合 os.killpg() 可以一次性终止整个进程组(包括子进程的子进程)。

Q: 在 Docker 容器里,容器停止时 Agent 启动的子进程会自动被清理吗? A: 取决于 Docker 的停止信号处理。docker stop 先发 SIGTERM 给 PID 1,等 10 秒,再发 SIGKILL。如果 Agent 进程是 PID 1 且正确处理了 SIGTERM(调用 cleanup),子进程会被清理。但如果 Agent 不是 PID 1(如通过 shell 脚本启动),SIGTERM 可能不会传递给 Agent 进程,导致子进程成为孤儿。推荐在容器里用 tini 作为 PID 1 的 init 进程,它会正确转发信号并收割僵尸进程。

Q: 孤儿进程持有文件锁时,如何快速解除锁? A: 先用 lsof <file_path> 找出持锁进程的 PID,然后 kill -9 <PID>。如果是 SQLite 的 WAL 锁,还需要删除 .wal.shm 文件(在确认没有其他进程在使用该数据库之后)。强制 kill 是最后手段,如果可能,先用 kill -15(SIGTERM)给进程优雅退出的机会。

Q: 如何在不重启系统的情况下清理端口被占用的孤儿进程? A: lsof -i :8080 -t 输出占用 8080 端口的进程 PID,然后 kill -9 $(lsof -i :8080 -t) 直接终止。macOS 上也可以用 fuser 8080/tcp 找出占用端口的进程。

相关阅读

标签: #AI 编程 #Agents #排查