2026-08-20 17:06

一、从一次线上事故说起:先厘清两个被混用的概念

那天要跑一批网页抓取,几万条,单线程要两三个小时,我顺手用 multiprocessing.Pool 开了 8 个进程并行。本地抽样 200 条没问题,上线后主进程很快打印"完成",可 top 里仍有进程滞留:部分是 Z(zombie),部分是 D(不可中断睡眠),内存从 1.2G 悄悄爬到 3.5G,文件句柄数逼近 ulimit -n

很多人把"僵尸进程"和"资源泄漏"当成一回事,其实它们是两种独立故障,处置路径完全不同

  • 僵尸进程(zombie):子进程已 exit(),父进程尚未 waitpid() 回收,内核保留其 task_struct(退出码、资源账目),占一个 PID 槽位。它不占 RSS、不占 CPU,危害是耗尽 PID 空间(默认上限通常几万)引发系统级故障。

  • 资源泄漏(leak):子进程根本没退出,持有的内存、文件描述符、数据库连接、代理隧道句柄一直存活,缓慢拖垮机器。D/S 态长期挂着多属此类。

排查第一步,先用 ps/pstree 判断眼前是"死透没收尸"还是"还活着赖着",再对症下药。

二、机制层:为什么回收会失败、为什么退出会被卡住

2.1 Linux 进程回收的底层契约

子进程 exit() 后进入僵尸态,等待父进程 waitpid()。内核只为僵尸保留极小的 task_struct,不占物理内存。父进程调用 waitpid(Python Process.join() 的底层)读取退出状态后,PID 才释放。

孤儿进程(父先死)被 PID 1 收养。关键陷阱在容器:若容器以 python main.py 直接启动,这个 Python 进程就是 PID 1,但它不会 wait() 自己派生的子进程——它不是真正的 init。于是子进程变僵尸后永久滞留,直到容器销毁。这是生产环境"僵尸清不掉"的头号真因。解法是用 tinidumb-init 作为 PID 1,由它正确 wait() 所有孤儿:

# Dockerfile:用 tini 作 PID 1,自动回收僵尸孤儿进程
ENTRYPOINT ["/usr/bin/tini", "--", "python", "main.py"]

2.2 start method:fork 与 spawn 决定了"继承了什么"

multiprocessing 的启动方式直接决定子进程拿到什么资源,这也是多数泄漏的源头:

  • fork(Linux 默认):子进程继承父进程全部 fd——数据库连接、监听 socket、亿牛云代理隧道句柄统统继承。若父持有而子未显式关闭,fd 泄漏被放大;更严重的是 fork 只复制调用线程,其他线程凭空消失——若那些线程持有锁(logging、malloc 内部锁等),子进程再碰该锁即永久死锁。这是多进程 + 多线程混用时最隐蔽的卡死来源。

  • spawn(Windows 默认、macOS 新版默认):重新 import 主模块、重建解释器与资源,不继承 fd,安全但启动慢,且子进程入参须可 pickle。

工程建议:IO/代理密集、或与线程混用时,用 multiprocessing.get_context("spawn"),或全局 set_start_method("spawn");若必须用 fork,用 os.register_at_fork(after_in_child=...) 在子进程里关闭无关 fd:

import os

def _close_inherited_fds():
    # 保留 0/1/2,关闭其余从父进程继承来的 fd,杜绝泄漏与死锁
    import resource
    soft, _ = resource.getrlimit(resource.RLIMIT_NOFILE)
    for fd in range(3, soft):
        try:
            os.close(fd)
        except OSError:
            pass

os.register_at_fork(after_in_child=_close_inherited_fds)

2.3 SIGCHLD 与 join 的本质

Process.join() 底层是 waitpid,阻塞等子进程终止。若父进程把 SIGCHLD 设成默认且被信号打断(如 Ctrl+C 触发 EINTR),wait 可能失败、join 提前返回,子进程变僵尸。稳妥做法是用 ProcessPoolExecutor 的上下文管理(内部已处理好 shutdown/join),或裸 Pool 严格 close()join()

三、子进程"退不出"的五大工程根因

1. 网络调用无超时,代理半开连接(最常见)。 爬虫给 requests 配代理却不设 timeout,代理链路半开(连接建立但不回数据)就永久挂起。结合亿牛云动态短效代理场景:它提供按量计费的 HTTP/HTTPS 出口 IP,适合轮换规避风控,但代理不可用必须当作正常错误分支,而非默认永远可用:

import requests
from requests.adapters import HTTPAdapter

def fetch(url, proxy):
    s = requests.Session()
    s.mount("https://", HTTPAdapter(pool_connections=10, pool_maxsize=10))
    try:
        # (连接超时, 读取超时),单位秒;按亿牛云 IP 存活周期设略宽松的读取超时
        return s.get(url, proxies={"http": proxy, "https": proxy}, timeout=(5, 20)).text
    except requests.RequestException:
        return None  # 快速失败,绝不阻塞

2. fork 后多线程死锁(持锁 fork),见 2.2。

3. 子进程 stdout 写满管道。 multiprocessing 把子进程 stdout 经 pipe 重定向父进程;疯狂 print 而父未读,pipe buffer 满后子进程 write() 阻塞。排查时 /proc/<pid>/wchan 常显示 pipe_write。改用 logging+QueueHandler 或重定向 devnull。

4. 共享 fd 泄漏:数据库/文件/代理隧道未 close(),fork 继承进一步放大;/proc/<pid>/fd 数量只增不减即为征兆。

5. 信号模型错误:主进程收 SIGINT/SIGTERM 退出,子进程陷在 C 扩展(加密/解压库)阻塞,收不到也不退出。

四、排查工具箱:从外壳到内核

ps -eo pid,ppid,stat,etime,cmd | grep python
pstree -p <父PID>                 # 看清进程关系
cat /proc/<pid>/status            # State / FDSize / VmRSS 判断是否泄漏
cat /proc/<pid>/wchan             # 内核态等什么(pipe_write? futex?)
ls /proc/<pid>/fd | wc -l        # fd 数异常增长 = 泄漏
lsof -p <pid>                     # 打开的 socket/文件明细

Python 层:py-spy dump --pid <pid> 看 Python 栈;py-spy dump --pid <pid> --native 看 C 栈,定位代理/C 扩展阻塞。strace -p <pid> -e trace=network,futex 实时看系统调用——死锁表现为反复 futex(..., FUTEX_WAIT),网络挂起表现为 recvfrom 不返回。faulthandler.dump_traceback_later(60, exit=True) 给卡死装"黑匣子"。编程式监控用 psutil

import psutil
parent = psutil.Process()
for child in parent.children(recursive=True):
    # num_fds / memory_info().rss 持续上升即泄漏,可接入监控告警
    print(child.pid, child.num_fds(), child.memory_info().rss)

五、根治:工程化最佳实践清单

  1. 网络层:所有请求带 (connect, read) 双超时;连接池复用;指数退避重试;代理做健康检查与熔断(circuit breaker)。亿牛云代理按 IP 存活周期配超时,失败即快速返回。

  2. 启动方式:IO/代理或线程混用场景用 spawn 上下文,规避 fd 继承与 fork 死锁。

  3. fd 卫生os.register_at_fork(after_in_child=...) 关闭无关 fd;resource.setrlimit(resource.RLIMIT_NOFILE, ...) 设上限,超限即崩而非拖垮整机。

  4. 进程池:优先 ProcessPoolExecutor 上下文;裸 Pool 必须 close()join()join(timeout) 超时则 kill()(SIGKILL)兜底:

p.join(timeout=30)
if p.is_alive():
    p.kill(); p.join()   # SIGKILL 强制回收,杜绝无限等待
  1. 优雅退出:注册 SIGTERM handler,置 multiprocessing.Event,工作循环协作退出;C 扩展阻塞用 process.kill() 强杀。

  2. 资源 with + atexit 兜底清理;少 print,用 logging

  3. 容器:以 tini/dumb-init 作 PID 1 回收孤儿僵尸;stop_grace_period/stopwaitsecs 超时转 SIGKILL。

  4. 可观测性psutil 周期采集子进程数、fd、RSS,超阈值告警,把排查从"事后救火"变成"事前预警"。

六、复盘

子进程不退出,根子在任务失控而非"进程没死"。僵尸是回收契约未履行(父未 wait、容器无真 init),泄漏是子进程压根未退出(超时缺失、fork 死锁、fd 未关、信号错配)。排查靠 pstree+/proc+py-spy+strace,根治靠"超时 + spawn 上下文 + fd 卫生 + 优雅退出 + 容器 init + 可观测"的组合拳。那次事故后,代理请求全部加超时与熔断、进程池统一 with、容器接入 tini,僵尸与泄漏再没回来过。


评论