在Python程序运行过程中,有时会遇到进程突然卡住、无响应的情况,这类问题往往出现在多线程或者多进程协作的场景中,尤其是死锁问题,很难通过常规日志快速定位。此时不需要修改代码重启服务,我们可以利用Py-Spy工具动态附加到正在运行的进程,生成火焰图来分析问题根因。

Py-Spy工具简介
Py-Spy是一款开源的Python进程采样分析工具,它的最大特点是支持动态附加到已经运行的Python进程,不需要对目标代码做任何修改,也不会影响进程的正常运行状态。它可以直接读取目标进程的内存信息,采样函数调用栈,最终生成直观的火焰图,帮助开发者快速定位性能瓶颈和阻塞问题。
安装Py-Spy
Py-Spy支持通过pip快速安装,执行以下命令即可完成安装:
pip install py-spy
安装完成后,可以在终端执行py-spy --help验证是否安装成功,正常会输出工具的帮助信息。
动态附加到运行中的Python进程
首先我们需要获取目标Python进程的PID,在Linux或者macOS系统中,可以通过ps命令查找:
ps aux | grep python
假设我们查到的目标进程PID是12345,接下来就可以使用Py-Spy附加到这个进程,执行以下命令开始采样:
py-spy record -o profile.svg --pid 12345
上述命令中,-o参数指定生成的火焰图文件路径,--pid指定目标进程的PID。执行命令后,Py-Spy会开始持续采样目标进程的函数调用栈,采样时间默认是60秒,也可以自己通过--duration参数指定采样时长,比如采样30秒:
py-spy record -o profile.svg --pid 12345 --duration 30
火焰图分析死锁位置
采样完成后,会生成对应的SVG格式火焰图文件,用浏览器打开这个文件即可查看。火焰图的横轴表示采样到的函数调用栈,纵轴表示调用栈的深度,每一个色块代表一个函数,色块的宽度代表该函数的采样命中次数,也就是函数执行耗时的相对占比。
分析死锁问题时,我们可以重点关注以下内容:
- 寻找宽度特别大且处于调用栈底层的函数,这类函数往往是阻塞的核心逻辑
- 查看是否有多个线程的调用栈都卡在获取锁的相关逻辑上,比如
threading.Lock.acquire相关的调用 - 检查是否存在循环等待锁的情况,比如线程A持有锁1等待锁2,线程B持有锁2等待锁1
以下是一个模拟死锁的Python代码示例,我们可以用它来验证Py-Spy的分析效果:
import threading
import time
# 定义两个锁
lock1 = threading.Lock()
lock2 = threading.Lock()
def thread_func1():
# 线程1先获取锁1,再尝试获取锁2
with lock1:
print("线程1获取锁1")
time.sleep(1)
print("线程1尝试获取锁2")
with lock2:
print("线程1获取锁2")
def thread_func2():
# 线程2先获取锁2,再尝试获取锁1
with lock2:
print("线程2获取锁2")
time.sleep(1)
print("线程2尝试获取锁1")
with lock1:
print("线程2获取锁1")
if __name__ == "__main__":
t1 = threading.Thread(target=thread_func1)
t2 = threading.Thread(target=thread_func2)
t1.start()
t2.start()
# 主线程等待子线程执行
t1.join()
t2.join()
运行上述代码后,进程会卡住进入死锁状态,此时我们用Py-Spy附加到这个进程生成火焰图,就可以看到两个子线程的调用栈都卡在acquire相关的逻辑上,对应到代码就是两个线程互相等待对方持有的锁,从而快速定位死锁位置。
其他常用Py-Spy命令
除了生成火焰图,Py-Spy还支持实时查看进程的函数调用栈,执行以下命令可以每秒输出一次当前进程的所有线程调用栈:
py-spy top --pid 12345
如果需要查看更详细的调用栈信息,可以使用dump命令直接输出当前所有线程的调用栈:
py-spy dump --pid 12345
注意事项
使用Py-Spy时需要注意以下几点:
- 附加到进程需要对应的系统权限,在Linux系统中如果提示权限不足,可以加上
sudo执行命令 - Py-Spy目前主要支持CPython解释器,对其他Python实现的支持可能不完善
- 采样过程对目标进程的性能影响极小,可以放心用于线上环境的排查