epoll是Linux下高并发服务的基石,Nginx、Redis、各类网关几乎都依赖epoll事件循环工作。但要回答进程在epoll_wait上阻塞了多久、返回时带了几个就绪事件、是被谁唤醒的这类问题,仅靠应用层日志往往不够。eBPF提供了在内核态安全插桩的能力,配合bpf_get_current_task等辅助函数,可以直接观测到每个任务的epoll等待行为。本文从原理到代码,完整演示这套分析方法。

epoll_wait的内核执行路径与可插桩点
要追踪epoll_wait,先要弄清它在内核里的调用链。用户态调用epoll_wait后,进入内核的epoll_wait系统调用,最终走到fs/eventpoll.c中的ep_poll函数。ep_poll负责把当前进程挂到等待队列上,然后调度出去睡眠;当被监视的fd有事件到达时,回调ep_poll_callback被触发,唤醒睡眠的进程,epoll_wait返回就绪事件。
这条路径上有几个天然的观测点。最直接的是对epoll_wait系统调用本身使用tracepoint(sys_enter_epoll_wait / sys_exit_epoll_wait),可以拿到进入时间、返回值(就绪事件数)以及timeout参数。如果需要更深层的信息,比如进程是否真的睡眠了、睡眠时长是多少,则可以对ep_poll或者schedule函数插桩。kprobe和tracepoint的选择取决于内核版本和所需精度。
关于标题里提到的bpf_get_current_task这类辅助函数:需要澄清一点,eBPF并没有一个叫bpf_get_current_task_epoll_wait的helper。实际做法是先用bpf_get_current_task拿到当前任务的task_struct指针,再在epoll相关内核函数的插桩点上把进程上下文和等待行为关联起来。这个组合才是分析epoll等待的正确姿势,很多教程把名字混在一起容易让人误解。
// 在ep_poll入口处记录当前任务和时间戳
SEC("kprobe/ep_poll")
int BPF_KPROBE(trace_ep_poll_enter)
{
__u64 pid_tgid = bpf_get_current_pid_tgid();
__u64 ts = bpf_ktime_get_ns();
bpf_map_update_elem(&start, &pid_tgid, &ts, BPF_ANY);
return 0;
}
// 在ep_poll返回时计算等待时长
SEC("kretprobe/ep_poll")
int BPF_KRETPROBE(trace_ep_poll_return, int ret)
{
__u64 pid_tgid = bpf_get_current_pid_tgid();
__u64 *tsp = bpf_map_lookup_elem(&start, &pid_tgid);
if (!tsp)
return 0;
__u64 delta = bpf_ktime_get_ns() - *tsp;
bpf_map_delete_elem(&start, &pid_tgid);
struct event e = {};
e.pid = pid_tgid >> 32;
e.delta_ns = delta;
e.ret = ret; // 就绪事件数量
bpf_get_current_comm(e.comm, sizeof(e.comm));
bpf_ringbuf_output(&events, &e, sizeof(e), 0);
return 0;
}上面的骨架代码展示了两段式追踪的典型写法:入口保存时间戳到以pid_tgid为key的hash map,返回处计算差值。ret值就是epoll_wait返回的就绪fd数量,结合时长可以立刻分辨出服务是空转(返回0且间隔极短)还是长时间空等(返回0但耗时接近timeout)。
利用bpf_get_current_task关联任务结构信息
光有pid和耗时常说不上问题的根源。bpf_get_current_task返回当前CPU上正在运行的task_struct的指针,有了它就可以在BPF程序里通过CO-RE(Compile Once, Run Everywhere)机制读取任务结构中的字段,比如进程名、线程组id、cgroup信息等。这对多线程服务尤其重要,因为epoll_wait通常只在特定的event loop线程上调用,按线程名过滤可以大幅减少噪声。
读取task_struct字段前,需要在BPF程序里引入vmlinux.h(由bpftool生成),并声明目标结构。借助BPF CO-RE的BPF_CORE_READ宏,即使内核结构体在不同版本间有布局差异,编译出来的程序也能正确运行。比如可以读取task->tgid来区分主进程与工作线程,读取task->cgroups相关字段把指标按容器维度聚合,这在Kubernetes环境下定位问题 Pod 时非常实用。
struct task_struct_min {
int pid;
int tgid;
char comm[16];
};
SEC("kretprobe/ep_poll")
int BPF_KRETPROBE(trace_ret, int ret)
{
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
__u64 pid_tgid = bpf_get_current_pid_tgid();
__u64 *tsp = bpf_map_lookup_elem(&start, &pid_tgid);
if (!tsp)
return 0;
struct event e = {};
e.pid = BPF_CORE_READ(task, pid);
e.tgid = BPF_CORE_READ(task, tgid);
bpf_core_read_str(e.comm, sizeof(e.comm), &task->comm);
e.delta_ns = bpf_ktime_get_ns() - *tsp;
e.ret = ret;
bpf_ringbuf_output(&events, &e, sizeof(e), 0);
return 0;
}需要注意BPF验证器的约束:不能直接解引用任意内核指针,必须通过bpf_core_read系列宏访问,且循环次数、栈空间都有上限。读取字符串时用bpf_core_read_str或bpf_get_current_comm更安全。另外,task_struct指针只代表采样瞬间的运行任务,在kretprobe上下文中通常与触发probe的任务一致,但如果中间发生过调度切换,依然建议以pid_tgid作为关联主键。
从等待时长数据到性能瓶颈定位
采集到每次epoll_wait的耗时和返回事件数后,真正有价值的是分析这些数据的分布。把delta_ns投递到用户态后,通常用直方图聚合,比如参考bcc的libbpf-tools里funclatency的思路,按2的幂次分桶统计。健康的事件循环一般呈现双峰:一簇是极短等待(事件持续到达,几乎立即返回),另一簇落在timeout附近。如果直方图里出现大量接近timeout且返回0的样本,说明服务当前没有流量;如果大量样本等待时间远超预期才返回少量事件,则要怀疑中断合并、软中断调度或CPU争抢问题。
几个常见的诊断模式值得留意。第一,epoll_wait返回值持续为1而调用频率极高,往往意味着事件处理本身太慢,事件循环在追赶积压,这时要结合ep_poll_callback的触发频率看事件到达速率。第二,多个线程同时epoll_wait同一个epoll fd(如SO_REUSEPORT或共享事件循环场景),惊群和负载不均可通过对比各线程的等待时长直方图发现。第三,如果等待时长的P99远大于P50且伴随调度延迟,可以用runqlat类工具交叉验证是否是CPU排队导致唤醒延迟。
#!/usr/bin/env python3
# 基于BCC的简化版epoll等待延迟统计
from bcc import BPF
from time import sleep
b = BPF(text="""
#include <uapi/linux/ptrace.h>
BPF_HASH(start, u32, u64);
BPF_HISTOGRAM(lat, u64);
int trace_enter(struct pt_regs *ctx) {
u32 pid = bpf_get_current_pid_tgid();
u64 ts = bpf_ktime_get_ns();
start.update(&pid, &ts);
return 0;
}
int trace_return(struct pt_regs *ctx) {
u32 pid = bpf_get_current_pid_tgid();
u64 *tsp = start.lookup(&pid);
if (tsp == 0) return 0;
u64 delta = bpf_ktime_get_ns() - *tsp;
lat.increment(bpf_log2l(delta / 1000)); // 微秒级分桶
start.delete(&pid);
return 0;
}
""")
b.attach_kprobe(event="ep_poll", fn_name="trace_enter")
b.attach_kretprobe(event="ep_poll", fn_name="trace_return")
sleep(10)
b["lat"].print_log2_hist("usecs")这套方法同样可以扩展到更细的维度。例如在ep_poll_callback处计数每个epoll实例被唤醒的次数,可以画出各连接的事件到达速率;对ep_send_events插桩则能观察就绪事件的投递效率。把eBPF采集的数据接入Prometheus或自建的可观测平台后,epoll等待分布就能成为服务事件循环健康的常规监控指标,而不是等到线上出问题才临时抓包排查。
总结一下:追踪epoll_wait不需要什么神秘的单一helper函数,核心思路是选择ep_poll等合适的内核插桩点,用bpf_get_current_task结合CO-RE读取任务上下文,用两段式时间戳计算等待时长,最后通过直方图和返回值分析定位瓶颈。掌握这套模式后,同样的技巧完全可以迁移到accept、connect、read等其他系统调用的延迟分析上,形成一套通用的系统调用观测能力。
eBPFepoll_wait内核追踪修改时间:2026-09-11 06:07:54