Python 脚本卡住怎么办:用 faulthandler 查看线程堆栈
批处理脚本不再输出日志,进程却还在运行。直接重启会丢掉现场:它是在等锁、等网络,还是主线程一直等工作线程结束?本文用 Python 标准库 faulthandler 记录线程当前执行位置,再按堆栈找等待点。
示例只依赖 Linux 和普通启用 GIL 的 CPython 3.10+,不需要 pip 包。代码已在 Linux、Python 3.11.4 上验证;人为制造的锁等待会在指定时间后解除,不会永久卡住。
1. 选择诊断方法
- 方法一:定时快照。 提前在程序里配置,每隔几秒输出一次堆栈,适合无人值守任务。
- 方法二:按需快照。 提前注册
SIGUSR1,遇到停滞时从另一终端触发,适合 Linux 现场排查。
Python 官方文档说明了定时输出和取消接口。本文的 exit=False 保留现场,快照本身不会结束任务。python3 -X faulthandler 主要启用故障信号处理;它不会自动设置定时输出或注册 SIGUSR1。
如果任务根本没有启动,先按cron 环境排查教程检查解释器和工作目录。
2. 准备并保存完整示例
- 新建一个练习目录,确认解释器:
mkdir python-hang-demo
cd python-hang-demo
python3 --version
- 下载 hang_demo.py 到该目录,或把下面代码保存为同名文件:
"""Inspect a bounded lock wait using CPython 3.10+ on Linux."""
import argparse
import faulthandler
import os
import signal
import threading
from pathlib import Path
def worker(lock):
with lock:
print("WORKER: acquired lock", flush=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--log", type=Path, default=Path("stacks.log"))
parser.add_argument("--seconds", type=int, default=8)
parser.add_argument("--interval", type=int, default=2)
args = parser.parse_args()
if not (1 <= args.seconds <= 3600 and 1 <= args.interval <= 3600):
parser.error("seconds and interval must be between 1 and 3600")
lock = threading.Lock()
# Exclusive creation preserves stack logs from previous runs.
with args.log.open("x", encoding="utf-8") as stack_log:
faulthandler.register(signal.SIGUSR1, file=stack_log, all_threads=True)
try:
faulthandler.dump_traceback_later(
args.interval, repeat=True, file=stack_log, exit=False
)
with lock:
thread = threading.Thread(target=worker, args=(lock,))
thread.start()
# Registration is complete before these readiness messages.
print(f"READY pid={os.getpid()}", flush=True)
print(f"SNAPSHOT: kill -USR1 {os.getpid()}", flush=True)
thread.join(timeout=args.seconds)
# Releasing the lock lets the waiting worker finish.
thread.join()
finally:
faulthandler.cancel_dump_traceback_later()
faulthandler.unregister(signal.SIGUSR1)
print(f"DONE: {args.log}", flush=True)
if __name__ == "__main__":
main()
- 查看命令参数:
python3 hang_demo.py --help
--seconds 是演示中主线程持锁等待的时间;--interval 是快照间隔。两者单位均为秒,范围为 1–3600。代码只用于独立练习进程;接入已有服务前,先确认服务没有把 SIGUSR1 用于其他用途。
3. 方法一:定时记录等待点
python3 hang_demo.py --log timer.log --seconds 8 --interval 2
cat timer.log
终端会先显示 READY 和 SNAPSHOT,约八秒后显示 WORKER: acquired lock 与 DONE: timer.log。日志中会有 Timeout (0:00:02)! 和线程堆栈;临界时刻的快照次数可能略有差异。
观察两处位置:工作线程停在 worker 的 with lock:,主线程位于 thread.join(timeout=args.seconds),上面通常还有 threading.py 的等待帧。按官方 Thread.join 说明,超时只结束这次等待,不会终止线程;本例随后退出 with lock,释放锁,工作线程才继续运行。
这说明主线程一边持锁,一边等需要同一把锁的工作线程。真实代码应把 join() 移到持锁区外,而不是仅把等待时间调大。下面是可单独运行的修正示例:
import threading
lock = threading.Lock()
def worker():
with lock:
print("WORKER: acquired lock")
thread = threading.Thread(target=worker)
thread.start()
thread.join(timeout=2)
assert not thread.is_alive(), "worker did not finish"
print("OK: joined without holding the lock")
4. 方法二:用 SIGUSR1 立即输出堆栈
- 在终端 A 的练习目录运行:
python3 hang_demo.py --log signal.log --seconds 30 --interval 60
- 等到显示
READY,复制程序打印的整条kill -USR1命令,在终端 B 执行。然后在同一个练习目录读取:
cat signal.log
此处间隔设为 60 秒,演示会在约 30 秒后结束,因此文件里的快照来自手动信号。发信号后任务继续运行;终端 A 最后仍会显示 DONE: signal.log。
只向打印该命令、仍在运行的练习进程发送信号。SIGUSR1 是用户自定义信号,具体行为由接收进程决定。没有注册处理器时,不要把它当作通用“查看 Python 堆栈”命令;本文也不能给一个已经卡住且未接入诊断的进程临时注入处理器。
5. 接入自己的任务并验证
保留示例中打开文件、注册信号、设置定时器及 finally 清理的部分,把锁等待演示替换为实际工作函数。日志句柄必须在诊断期间保持打开;顺序是 取消定时器 → 注销信号 → 关闭文件。
每次运行选择新的堆栈文件。普通业务日志可以按日志轮转教程管理,但不要直接轮转这里的已注册句柄;文件描述符说明要求更换文件后重新配置诊断输出。
验证时同时检查:日志非空、有工作函数名、有主线程等待帧,且任务最终正常退出。若真正卡住的是另一个程序,应给那个进程接入诊断;Python 父进程的堆栈只会显示自己在等待,可结合子进程超时与日志教程管理外部命令。
6. 常见问题
| 现象 | 原因与处理 |
|---|---|
FileExistsError |
已有同名日志。保留旧文件,改用 --log timer-2.log。 |
FileNotFoundError |
日志父目录不存在。先执行 mkdir -p traces,再用 --log traces/run-1.log。 |
| 日志为空 | 程序可能在首次快照前结束。重跑示例时用新的日志名,设置 --seconds 8 --interval 2。 |
kill 报 No such process |
演示已结束。重新启动,等待新 READY,使用新打印的命令。 |
| 多次快照停在同一行 | 检查那一行调用的锁、I/O 或等待对象;单凭位置相同还不能认定死锁。 |
本教程查看 Python 线程堆栈,不展开原生库内部调用,也不是性能分析器。每次运行只配置一套定时输出,避免不同模块互相覆盖;长时间重复快照会增加日志体积,应按运行时长选择间隔并管理历史文件。
7. 总结
给长任务提前配置定时快照或 SIGUSR1,停滞时就能留下等待位置。先用可结束的锁等待练习验证日志,再把诊断接入实际脚本。定位到阻塞调用后修正锁范围或设置合适的 I/O 超时,堆栈输出本身不会解除等待。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-hang-faulthandler.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。