Python 脚本卡死不退出:进程挂住没反应速查
更新时间:2026-10-24。FAQ 速查页,面向「脚本跑着跑着不动了」的同学——Python 脚本卡死不退出和报错不一样:它什么都不打印,进程活着、CPU 要么为 0 要么 100%,Ctrl+C 之前你永远不知道它卡在哪一行。挂起类问题的真因集中在六根轴:网络请求没设超时(最常见,requests 默认永不超时)、subprocess 的 PIPE 缓冲区写满双向互等、queue.join() 等一个永远不会来的 task_done、多线程锁顺序不一致造成死锁、线程池里某个任务不返回拖死 shutdown、以及 busy loop 把 CPU 烧满但逻辑不推进。定位神器是
py-spy dump:不用改代码、不用重启,直接对运行中的进程抓一份各线程当前栈,卡在哪一行一目了然。
本文要回答的问题
- 进程活着但没输出,怎么知道它卡在哪一行?
- requests 为什么会「永远等下去」?
- subprocess 子进程输出一多父进程就挂,是什么机制?
- queue.join() 一直不返回是为什么?
- CPU 100% 的挂起和 CPU 0% 的挂起,排查思路差在哪?
一、挂起现场与典型现象
挂起没有 traceback,现场长这样(工程路径以 /proj/demo 占位):
$ python3 /proj/demo/fetch.py
正在下载第 1 批……
正在下载第 2 批……
# 光标停在这里 40 分钟,进程还在,CPU 0%$ ps aux | grep fetch.py
user 30182 0.0 0.4 python3 /proj/demo/fetch.py
# 0.0% CPU = 阻塞在等 IO 或锁;100% 才是死循环空转$ py-spy dump --pid 30182
Thread 0x1E03 (active): "MainThread"
read (ssl.py:1123)
recv_into (socket.py:669)
urlopen (client.py:558)
main (/proj/demo/fetch.py:17)
# 栈顶是 socket 读——阻塞在网络上,requests 没设 timeout第一步永远是先分清两种挂法:ps 里 CPU 接近 0%,是阻塞(等网络、等锁、等队列、等子进程);CPU 接近 100%,是活锁/死循环(代码在跑但逻辑不推进)。前者占八成,py-spy 一抓栈就能定罪;后者抓栈看到的是 Python 自己的字节码帧,要回头审循环退出条件。
二、病因处置速查表
| 挂起现象 / 场景 | 可能原因 | 快速验证 | 处置 |
|---|---|---|---|
| 卡在网络调用,CPU 0% | requests/urllib 没设 timeout,默认永不超时 | py-spy 栈顶是 socket read | 所有请求显式传 timeout=(连, 读) |
| 子进程输出量大时父进程挂 | PIPE 缓冲区(约 64KB)写满,父子互等 | py-spy 栈停在 communicate 或 wait | 用 communicate() 收输出或重定向到文件 |
| 并发队列程序收尾时挂 | join() 等的 task_done() 少调一次 | 审查 put/get 次数与 task_done 配对 | get 到的每件必 task_done,或改 sentinel 退出 |
| 加锁的线程程序偶发挂 | 两把锁获取顺序不一致,互相持锁等待 | py-spy 两个线程都停在 acquire | 全局统一锁顺序,或加超时 acquire |
pool.shutdown() 不返回 | 某个任务永不结束,shutdown 默认等全部完成 | py-spy 看 worker 线程栈 | 任务加超时;shutdown(wait=False) + 兜底 |
| CPU 100% 不退出 | while 条件永真 / 轮询无 sleep | py-spy 栈在业务循环里来回 | 修退出条件;轮询循环加 time.sleep |
三、排查决策图
这张图是挂起类问题的标准动线,颜色按角色区分:红是挂起现象与最终定罪的根因,蓝是排查动作,绿是验证通过分支,橙是修复动作,紫是防复发沉淀。
读这张图抓住一句口诀:挂起先量 CPU,再抓栈。CPU 把问题砍成「阻塞」和「空转」两半,py-spy 的栈顶再把「阻塞」精确到一行——栈停在 socket 是网络没超时,停在 acquire 是锁,停在 join 是队列计数不配平。有了栈顶那一行,处置基本都是一行代码的事。
四、逐条排查展开
第一病因:requests 不设超时,默认等到天荒地老。 requests.get(url) 的 timeout 默认值是 None——不是 30 秒,是永不超时。对端 TCP 连接建立后迟迟不回包(防火墙丢包、对端服务半死),你的进程就永远阻塞在 socket 读上。这是 Python 挂起类事故的第一大来源,特征极典型:CPU 0%,栈顶是 ssl.py 或 socket.py 的读。根治一行:
requests.get(url, timeout=(3, 10)) # 连接 3 秒、读 10 秒元组两个值分别管「连不上」和「连上了但不回」。所有出站 HTTP 调用都必须显式给超时,这条写进代码规范。urllib、httpx、aiohttp 同理,aiohttp 默认总超时 5 分钟略好但仍建议显式。
第二病因:subprocess 的 PIPE 写满,父子互等。 这是机制最反直觉的一个。Popen(..., stdout=PIPE) 后如果父进程先 p.wait() 再读输出:操作系统管道缓冲区只有几十 KB(Linux 默认 64KB),子进程输出写满缓冲区后阻塞在 write,父进程又在等子进程退出——双向死等,两边都活着、CPU 都 0%,永不相见。处置两选一:用 communicate()(它内部用线程同时收两路输出),或者干脆把输出重定向到文件:
p = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
# 或 stdout=open("/proj/demo/run.log", "w") 落盘,事后看文件顺手给 subprocess 也加上 timeout,等不回来时抛 TimeoutExpired 而不是挂死。
第三病因:queue.join() 等一个永远不来的 task_done。 Queue.join() 的语义是「阻塞到未完成任务计数归零」,每 put 一件 +1,每 task_done() 一次 -1。消费者某条路径上漏调 task_done()(异常分支、提前 return),计数永远不归零,主线程就在 join 上睡到世界末日。特征:挂在线程/队列收尾处,栈停在 queue.py 的 join。处置:task_done 放进 try/finally 保证必调;或者改 sentinel 模式——生产者结束后 put 一个 None,消费者见到 None 就退出,不用 join 计数。
第四病因:多把锁获取顺序不一致。 线程 A 拿了锁 1 等锁 2,线程 B 拿了锁 2 等锁 1,经典 ABBA 死锁。py-spy 里两个线程都停在 acquire 且各持一把,就是它。注意别冤枉 GIL:GIL 本身不会「死锁」,它只是让多线程 CPU 计算不并行;真正的死锁几乎都是业务锁顺序问题。处置:全项目统一锁获取顺序(比如按资源 id 排序加锁),或者给 acquire 加超时、超时后释放已持锁重试。
第五病因:线程池里某个任务不返回,shutdown 干等。 ThreadPoolExecutor 和 concurrent.futures 的 shutdown() 默认 wait=True——任何一个任务不结束,主线程就陪绑。常见于任务内部又发了个没超时的网络请求(第一病因的并发症)。处置:任务本身加超时和异常兜底;as_completed(fs, timeout=N) 收口;确实要甩手用 shutdown(wait=False, cancel_futures=True)(3.9+)。
第六病因:CPU 100% 的活锁。 while 循环退出条件写错(条件里用到的变量在循环体内根本不更新)、或轮询队列用 q.get_nowait() 不带 sleep 死转。py-spy 抓到的栈会在业务代码几行之间来回跳。处置:修退出条件;轮询循环里 time.sleep(0.1),或者用阻塞式 q.get(timeout=...) 替代空转轮询。
定位工具速记: 生产环境装 py-spy(pip install py-spy,纯采样不打断进程):py-spy dump --pid <PID> 抓一次栈、py-spy top --pid <PID> 看实时热点、py-spy record 出火焰图。没有 py-spy 时,kill -SIGABRT <PID> 配 faulthandler(faulthandler.dump_traceback_later 或 -X faulthandler 启动)也能让进程自己吐出当前栈。
五、规范做法与预防
- 所有网络调用显式传 timeout:requests/urllib/subprocess 一视同仁,CR 见无超时的网络调用直接打回。
- subprocess 大输出一律 communicate 或落文件:禁止「先 wait 再读 PIPE」组合写进团队规范。
- 队列消费者 task_done 放 try/finally:异常路径也不漏计数。
- 锁顺序全局统一并写进设计文档:新增锁先登记顺序再写代码。
- 运行环境预装 py-spy:挂起发生时不需要现场装工具,一条命令定罪。
- 服务入口注册 faulthandler:
faulthandler.dump_traceback_later(600, repeat=True),挂十分钟自动吐栈到日志。
这一篇在知识体系里的位置
本篇是 Python 排错速查簇的挂起篇,定位「进程活着但不干活」:编码炸了看 UnicodeDecodeError 编码报错速查;内存/递归爆了看 MemoryError 与 RecursionError 速查;脚本起不来是路径问题看 FileNotFoundError 文件不存在速查;起不来是没权限看 PermissionError 权限被拒速查;模块导不进来看 ModuleNotFoundError 模块找不到速查;解释器版本冲突看 Python 版本不兼容报错速查。「量 CPU + py-spy 抓栈」两件套对一切语言的挂起问题通用。
一句话总结
Python 脚本卡死不退出,先 ps 看 CPU:0% 是阻塞、100% 是空转。阻塞类用 py-spy dump --pid 抓全线程栈按栈顶定罪——socket 读是 requests 没设 timeout(默认永不超时,必须显式传 (连接, 读取)),PIPE wait 是子进程输出写满 64KB 缓冲区父子互等(改 communicate 或落文件),acquire 是锁顺序不一致死锁,join 是 task_done 漏配。空转类审循环退出条件、轮询加 sleep。防复发就一条主线:一切网络调用必须带超时,py-spy 预装进运行环境。