ebpf 通过跟踪 epoll_wait 等系统调用定位 asyncio 事件循环卡点,需结合 python 启动时记录的 epoll_fd 进行精准过滤,并关注 timerfd、signalfd 等非网络 fd,推荐使用 libbpf + co-re 实现跨内核版本稳定追踪。

asyncio 事件循环卡住时怎么用 eBPF 看清它在等谁
eBPF 本身不理解 Python 的 asyncio 概念,它只能跟踪系统调用和内核事件。想看到事件循环“卡在哪”,得聚焦到它实际依赖的底层:主要是 epoll_wait(Linux)、kqueue(macOS)或 select(fallback)。一旦事件循环阻塞在这些系统调用里,eBPF 就能捕获到。
常见错误现象是服务 CPU 占用极低但请求不响应——这往往说明 asyncio.run() 或 loop.run_forever() 正停在 epoll_wait 上,而没人往 fd 写数据、没定时器到期、也没新任务提交。
- 用
bpftool prog list确认你的 tracepoint 是否已加载(比如syscalls/sys_enter_epoll_wait) - 优先跟踪
syscalls/sys_enter_epoll_wait和syscalls/sys_exit_epoll_wait,对比耗时,确认是否真卡住而非正常等待 - 别试图 hook
asyncio的 Python 层函数(如loop._run_once),eBPF 无法安全读取 CPython 解释器栈帧,容易 crash 或返回垃圾值 - 注意 Python 进程可能 fork 出多个子进程(比如用
multiprocessing启动 worker),eBPF 默认只跟踪指定 PID,需用pid == 0或动态过滤
如何让 eBPF 程序识别出这是 asyncio 的 epoll 实例
Linux 下 asyncio 默认用 epoll_create1(0) 创建实例,但 eBPF 没法直接判断某个 epoll_fd 是不是被 Python 用了。可行办法是结合用户态上下文:在 Python 启动时记录它的 epoll_fd 值,再让 eBPF 只对这个 fd 生效。
使用场景通常是调试线上服务,你没法改业务代码,但可以加一行启动日志:
立即学习“Python免费学习笔记(深入)”;
import asyncio
import os
loop = asyncio.get_event_loop()
# 打印 epoll fd(仅 Linux)
if hasattr(loop, '_selector') and hasattr(loop._selector, '_epoll'):
print(f"EPOLL_FD={loop._selector._epoll._epollfd}", file=open("/tmp/asyncio-epoll-fd", "w"))- eBPF 程序里用
bpf_map_lookup_elem(&target_epoll_fds, &fd)查表,只对已知 fd 做深度追踪 - 不要硬编码 fd 值——进程重启后 fd 会变,必须每次启动重读
/tmp/asyncio-epoll-fd - 如果用
uvloop,它的 epoll fd 存在loop._selector._epoll._fd,路径略有不同 - Windows 不适用这套逻辑,
asyncio在 Windows 用overlapped I/O,eBPF 无法跟踪
跟踪 await 点对应的文件描述符时容易漏掉什么
await 本身不产生系统调用,真正触发的是它背后对象的 __await__ 方法——比如 asyncio.StreamReader.read() 最终调用 recvfrom,而 asyncio.sleep() 则靠 timerfd_settime 驱动。eBPF 跟不到 await 关键字,但能抓到这些底层 fd 操作。
容易踩的坑是只盯着网络 fd,忽略 timerfd 和 signalfd:
-
asyncio.sleep()、loop.call_later()依赖timerfd,需跟踪syscalls/sys_enter_timerfd_settime - 如果你用
loop.add_signal_handler(),信号处理走signalfd,也要单独 hook - HTTP 客户端(如
aiohttp)可能用socketpair做内部唤醒,这类 fd 很短命,eBPF 要开足够大的 perf buffer,否则丢事件 - 避免用
bpf_trace_printk()打印大量内容,它会严重拖慢epoll_wait返回,反而掩盖真实问题
为什么用 libbpf + CO-RE 比 BCC 更适合 asyncio 场景
BCC 编译期绑定内核头,而 asyncio 服务常跑在容器里,宿主机和容器内核版本可能不一致;libbpf + CO-RE(Compile Once – Run Everywhere)能在运行时适配结构体偏移,更稳。
性能上差异明显:BCC 的 Python 层要频繁调用 bpf_perf_event_read() 拉数据,而 libbpf 可以用 ringbuf 零拷贝传给用户态,对高吞吐 asyncio 服务更友好。
- CO-RE 要求目标机器有
vmlinux.h(可通过bpftool btf dump file /sys/kernel/btf/vmlinux format c生成) - libbpf 加载程序时默认不验证 map 大小,务必手动检查
struct { __u64 fd; __u64 ts; } __attribute__((packed))这类结构体是否被编译器 padding - 别用 BCC 的
trace.py一类脚本临时跟踪——它们启动慢、hook 粒度粗,容易错过毫秒级的 epoll 唤醒抖动 - 如果用
pyperf或perf record -e 'syscalls:sys_enter_*'辅助验证,注意 perf event 和 eBPF tracepoint 的采样精度差异
最麻烦的其实是 Python 解释器自身的内存布局干扰:CPython 的 GIL、gc、以及 asyncio 对 PyThreadState 的修改,会让 eBPF 读取用户栈变得不可靠。所以别指望 eBPF 能告诉你 “卡在 await db.fetch() 这一行”,它能告诉你的上限是 “卡在 epoll_wait,当前注册了 3 个 socket fd 和 1 个 timerfd”。剩下的,得靠日志和 fd 对应关系反推。










