系统调用是用户态程序与内核交互的唯一入口,它的延迟直接决定了应用程序的响应速度。当数据库查询变慢、Web 服务超时、或者某个后台任务莫名其妙地卡顿,问题往往出在系统调用层面——可能是某个 read() 等待磁盘 I/O 太久,也可能是 futex() 在锁竞争中被反复唤醒。传统的排查手段要么依赖 strace(开销巨大,生产环境基本不可用),要么靠猜。eBPF 改变了这个局面:它让你以极低开销在生产环境中精确测量每一次系统调用的耗时,并且拿到完整的调用上下文。
这篇文章将从原理到代码,完整走一遍用 eBPF 追踪系统调用延迟的实践路径。
为什么传统工具不够用
strace 的工作方式是 ptrace 系统调用,每次目标进程进入或退出系统调用时都要被中断两次,上下文切换开销极大。在高频系统调用的场景下,strace 可能让目标进程慢几十倍甚至上百倍,这在生产环境是不可接受的。
perf trace 基于 perf 子系统,开销比 strace 小,但它本质上仍然是采样和事件记录的思路,在高并发场景下容易丢事件,而且很难做精细的延迟聚合分析。
eBPF 的优势在于:程序直接在内核中执行,不需要上下文切换;通过 kprobe/tracepoint 挂载到内核函数上,可以精确捕获每次系统调用的进入和退出;数据通过高效的 ring buffer 或 perf buffer 传到用户态。开销通常在个位数百分比以内,完全可以跑在生产环境。
核心原理:挂载点与数据结构
追踪系统调用延迟的核心逻辑非常直观:在系统调用入口记录一个时间戳,在出口再记录一个时间戳,两者相减就是延迟。
Linux 内核提供了两个理想的挂载点:
raw_syscalls:sys_enter:所有系统调用的统一入口 tracepoint,参数中包含系统调用号和寄存器值。raw_syscalls:sys_exit:所有系统调用的统一出口 tracepoint,包含系统调用号和返回值。
选择 tracepoint 而不是 kprobe 挂载 sys_enter/sys_exit,是因为 tracepoint 是内核稳定 ABI,不会因为内核版本变化导致偏移量失效。对于生产环境工具,稳定性至关重要。
数据结构方面,我们需要一个以线程 ID(TID)为键的哈希表,在 sys_enter 时存入时间戳和系统调用号,在 sys_exit 时取出并计算差值。eBPF 提供了 BPF_MAP_TYPE_HASH 来满足这个需求。
用 BCC 快速实现
BCC(BPF Compiler Collection)提供了 Python 前端,适合快速原型开发。下面是一个追踪系统调用延迟并输出慢调用的完整脚本:
from bcc import BPF
import time
bpf_text = r"""
#include <uapi/linux/ptrace.h>
struct val_t {
u64 ts;
u64 syscall_nr;
};
struct data_t {
u32 pid;
u64 delta;
u64 syscall_nr;
char comm[16];
};
BPF_HASH(start, u32, struct val_t);
BPF_PERF_OUTPUT(events);
TRACEPOINT_PROBE(raw_syscalls, sys_enter) {
u32 tid = bpf_get_current_pid_tgid();
struct val_t val = {};
val.ts = bpf_ktime_get_ns();
val.syscall_nr = args->id;
start.update(&tid, &val);
return 0;
}
TRACEPOINT_PROBE(raw_syscalls, sys_exit) {
u32 tid = bpf_get_current_pid_tgid();
struct val_t *valp = start.lookup(&tid);
if (valp == NULL)
return 0;
u64 delta = bpf_ktime_get_ns() - valp->ts;
// 只输出超过 1ms 的调用,减少噪音
if (delta < 1000000) {
start.delete(&tid);
return 0;
}
struct data_t data = {};
data.pid = bpf_get_current_pid_tgid() >> 32;
data.delta = delta;
data.syscall_nr = valp->syscall_nr;
bpf_get_current_comm(&data.comm, sizeof(data.comm));
events.perf_submit(args, &data, sizeof(data));
start.delete(&tid);
return 0;
}
"""
b = BPF(text=bpf_text)
syscall_names = {}
with open("/usr/include/x86_64-linux-gnu/asm/unistd_64.h") as f:
for line in f:
parts = line.split()
if len(parts) == 3 and parts[0] == "#define":
syscall_names[int(parts[2])] = parts[1].replace("__NR_", "")
def print_event(cpu, data, size):
event = b["events"].event(data)
name = syscall_names.get(event.syscall_nr, str(event.syscall_nr))
print(f"{event.comm.decode():16s} pid={event.pid:<8d} "
f"syscall={name:20s} latency={event.delta / 1e6:.3f} ms")
b["events"].open_perf_buffer(print_event)
print("Tracing syscall latency > 1ms... Ctrl-C to exit.")
while True:
try:
b.perf_buffer_poll()
except KeyboardInterrupt:
break
这段代码的关键点:
BPF_HASH(start, ...)以 TID 为键存储入口时间戳。用 TID 而非 PID 是因为同一进程的不同线程可能同时发起系统调用,必须区分。args->id在sys_enter中拿到系统调用号,在sys_exit中通过之前存储的值获取。- 阈值过滤 放在 eBPF 程序内部(
delta < 1000000),避免把所有系统调用都传到用户态,大幅降低开销。 bpf_get_current_comm获取进程名,方便定位问题进程。
运行这个脚本,你会看到类似这样的输出:
postgres pid=1842 syscall=read latency=12.450 ms
nginx pid=3105 syscall=epoll_wait latency=3.210 ms
python3 pid=7821 syscall=futex latency=1.870 ms
从“能看到”到“能分析”
上面的脚本适合实时观察,但生产环境更需要的是聚合统计和长期监控。几个改进方向:
按系统调用类型聚合延迟分布。 用 BPF_MAP_TYPE_HISTOGRAM 记录每种系统调用的延迟分布,而不是逐条输出。BCC 提供了 BPF_HISTOGRAM 宏,可以按系统调用号分桶统计。
关联调用栈。 在 sys_enter 时用 bpf_get_stackid 抓取用户态调用栈,这样不仅知道哪个系统调用慢,还能知道是代码的哪一行触发的。配合 -fno-omit-frame-pointer 编译选项效果最好。
过滤特定进程或容器。 通过 cgroup ID 过滤,可以只追踪某个容器内的系统调用,这在 Kubernetes 环境中非常实用。eBPF 程序可以读取 bpf_get_current_cgroup_id(),用户态传入目标 cgroup ID 进行匹配。
导出到监控系统。 将聚合数据定期导出为 Prometheus 指标,结合 Grafana 做长期趋势分析。当 P99 延迟突然升高时触发告警,比事后排查高效得多。
生产环境注意事项
内核版本要求。 完整的 tracepoint 和 map 支持需要 Linux 4.9+,建议使用 5.4 或更高版本。RHEL 8 系列(4.18 内核)经过大量 backport,也能满足大部分需求。
权限。 加载 eBPF 程序需要 CAP_BPF 和 CAP_PERFMON(或 root)。在容器中运行时,需要 --privileged 或精细的 capability 配置。
哈希表清理。 如果某个线程在 sys_enter 之后异常退出(比如收到信号),sys_exit 不会触发,哈希表中会残留条目。长时间运行需要在用户态定期清理过期条目,或者使用 BPF_MAP_TYPE_LRU_HASH 自动淘汰。
性能开销。 即使做了阈值过滤,在高频系统调用场景下(比如每秒百万次),哈希表的读写本身也会带来开销。建议先用采样模式(比如每 100 次采样 1 次)评估影响,再决定是否全量追踪。
总结
eBPF 让系统调用延迟追踪从“事后猜测”变成了“实时精确测量”。核心思路只有三步:在 sys_enter 记录时间戳,在 sys_exit 计算差值,通过 map 和 perf buffer 把数据传出来。BCC 让原型开发变得简单,而生产部署需要关注聚合、过滤和开销控制。
当你下一次面对“服务变慢了但不知道慢在哪里”的问题时,不妨从系统调用延迟入手——它往往能直接指向根因,无论是磁盘 I/O 瓶颈、锁竞争、还是网络等待。
未经允许不得转载:任鹏个人博客 » 使用 eBPF 追踪 Linux 系统调用延迟的完整实践


朋友圈点赞图在线生成源码