性能工程师如何用 eBPF 揪出应用瓶颈?这几个方向要盯紧了!
1. CPU 使用率过高?看看是不是陷入了死循环
1.1 排查用户态 CPU 占用
1.2 排查内核态 CPU 占用
2. I/O 延迟过长?看看是不是在疯狂读写磁盘
2.1 追踪磁盘 I/O
2.2 分析 I/O 类型
3. 内存泄漏?看看是不是有内存分配了没释放
3.1 追踪内存分配和释放
3.2 使用 Valgrind
4. 总结
作为一名性能工程师,优化应用性能是我的日常。应用跑得慢、CPU 占用高、I/O 延迟大,这些问题就像家常便饭,时不时就得处理一下。以前排查这些问题,我可能会用 top
、iostat
这些工具,但说实话,它们给的信息太粗略了,很难定位到具体是哪个函数、哪行代码出了问题。
自从我开始用 eBPF,感觉就像拿到了一把瑞士军刀,各种性能问题都能迎刃而解。eBPF 可以在内核中安全地运行自定义代码,实时监控应用的各种行为,而且性能开销非常小,几乎可以忽略不计。更重要的是,eBPF 可以提供非常细粒度的信息,比如函数调用次数、执行时间、I/O 请求的详细信息等等,这些信息对于定位性能瓶颈非常有帮助。
如果你也是一名性能工程师,或者对应用性能优化感兴趣,那么 eBPF 绝对值得你深入学习。下面我就结合我自己的经验,分享一下我是如何使用 eBPF 来分析应用性能瓶颈的,希望能给你带来一些启发。
1. CPU 使用率过高?看看是不是陷入了死循环
CPU 使用率过高是应用性能问题中最常见的一种。遇到这种情况,首先要确定是用户态代码占用了 CPU,还是内核态代码占用了 CPU。如果是用户态代码占用了 CPU,那么很可能是应用代码中存在死循环、频繁的内存分配、或者复杂的计算逻辑。如果是内核态代码占用了 CPU,那么很可能是应用频繁地进行系统调用,或者内核中存在性能瓶颈。
1.1 排查用户态 CPU 占用
对于用户态 CPU 占用过高的问题,我通常会使用 perf
工具来分析。perf
可以采样应用的函数调用栈,然后生成火焰图,火焰图可以直观地展示 CPU 时间都消耗在哪些函数上。但是,perf
的缺点是需要停止应用才能进行采样,而且采样频率过高可能会影响应用的性能。
这时候,eBPF 就可以派上用场了。我们可以使用 eBPF 来动态地追踪应用的函数调用,而无需停止应用。下面是一个简单的 eBPF 脚本,它可以统计每个函数的 CPU 占用时间:
from bcc import BPF # 定义 eBPF 程序 program = """ #include <uapi/linux/ptrace.h> BPF_HASH(start, u64, u64); int func_entry(struct pt_regs *ctx) { u64 pid_tgid = bpf_get_current_pid_tgid(); u64 ts = bpf_ktime_get_ns(); start.update(&pid_tgid, &ts); return 0; } int func_return(struct pt_regs *ctx) { u64 pid_tgid = bpf_get_current_pid_tgid(); u64 *tsp = start.lookup(&pid_tgid); if (tsp == NULL) { return 0; } u64 delta = bpf_ktime_get_ns() - *tsp; bpf_trace_printk("pid_tgid = %d, delta = %llu ns\n", pid_tgid, delta); start.delete(&pid_tgid); return 0; } """ # 创建 BPF 实例 bpf = BPF(text=program) # 附加 eBPF 程序到函数入口和出口 func_name = "your_function_name" # 替换成你要追踪的函数名 bpf.attach_uprobe(name="your_application", sym=func_name, f=bpf.load_func("func_entry", BPF.KPROBE)) bpf.attach_uretprobe(name="your_application", sym=func_name, f=bpf.load_func("func_return", BPF.KRETPROBE)) # 打印 eBPF 输出 bpf.trace_print()
这个脚本使用了 uprobe
和 uretprobe
来分别追踪函数的入口和出口。在函数入口处,脚本记录下当前的时间戳;在函数出口处,脚本计算函数的执行时间,并打印出来。你需要将 your_function_name
替换成你要追踪的函数名,将 your_application
替换成你的应用名。
运行这个脚本,你就可以看到每个函数的执行时间。如果某个函数的执行时间特别长,那么很可能这个函数就是导致 CPU 占用过高的原因。
1.2 排查内核态 CPU 占用
如果发现是内核态代码占用了 CPU,那么很可能是应用频繁地进行系统调用。系统调用会陷入内核态,执行一些内核代码,如果系统调用次数过多,就会导致内核态 CPU 占用过高。
我们可以使用 tracepoints
来追踪系统调用。tracepoints
是内核中预先定义的一些hook点,我们可以在这些hook点上附加 eBPF 程序,来监控系统调用的执行情况。下面是一个简单的 eBPF 脚本,它可以统计每个进程的系统调用次数:
from bcc import BPF # 定义 eBPF 程序 program = """ #include <linux/sched.h> BPF_HISTOGRAM(syscall_count, u64); TRACEPOINT_PROBE(raw_syscalls, sys_enter) { u64 pid_tgid = bpf_get_current_pid_tgid(); syscall_count.increment(pid_tgid); return 0; } """ # 创建 BPF 实例 bpf = BPF(text=program) # 打印系统调用次数 while True: try: sleep(1) bpf["syscall_count"].print_linear_hist() except KeyboardInterrupt: exit()
这个脚本使用了 TRACEPOINT_PROBE
来附加 eBPF 程序到 sys_enter
这个 tracepoint 上。sys_enter
是系统调用入口的 tracepoint,每次有进程进行系统调用,这个 tracepoint 就会被触发。脚本会统计每个进程的系统调用次数,并打印出来。
运行这个脚本,你就可以看到每个进程的系统调用次数。如果某个进程的系统调用次数特别多,那么很可能这个进程就是导致内核态 CPU 占用过高的原因。
2. I/O 延迟过长?看看是不是在疯狂读写磁盘
I/O 延迟过长是另一个常见的应用性能问题。I/O 延迟过长会导致应用响应变慢,用户体验下降。I/O 延迟可能发生在磁盘 I/O、网络 I/O、或者内存 I/O 等等。这里我们主要讨论磁盘 I/O 延迟的问题。
2.1 追踪磁盘 I/O
我们可以使用 block:block_rq_issue
和 block:block_rq_complete
这两个 tracepoint 来追踪磁盘 I/O。block:block_rq_issue
是块设备请求发出的 tracepoint,block:block_rq_complete
是块设备请求完成的 tracepoint。我们可以使用这两个 tracepoint 来计算每个 I/O 请求的延迟。
下面是一个简单的 eBPF 脚本,它可以统计每个 I/O 请求的延迟:
from bcc import BPF # 定义 eBPF 程序 program = """ #include <linux/blkdev.h> BPF_HASH(start, struct request *, u64); TRACEPOINT_PROBE(block, block_rq_issue) { struct request *req = (struct request *)args->req; u64 ts = bpf_ktime_get_ns(); start.update(&req, &ts); return 0; } TRACEPOINT_PROBE(block, block_rq_complete) { struct request *req = (struct request *)args->req; u64 *tsp = start.lookup(&req); if (tsp == NULL) { return 0; } u64 delta = bpf_ktime_get_ns() - *tsp; bpf_trace_printk("latency = %llu ns\n", delta); start.delete(&req); return 0; } """ # 创建 BPF 实例 bpf = BPF(text=program) # 打印 I/O 延迟 bpf.trace_print()
这个脚本使用了 TRACEPOINT_PROBE
来附加 eBPF 程序到 block:block_rq_issue
和 block:block_rq_complete
这两个 tracepoint 上。在 block:block_rq_issue
处,脚本记录下当前的时间戳;在 block:block_rq_complete
处,脚本计算 I/O 请求的延迟,并打印出来。
运行这个脚本,你就可以看到每个 I/O 请求的延迟。如果某个 I/O 请求的延迟特别长,那么很可能这个 I/O 请求就是导致 I/O 延迟过长的原因。
2.2 分析 I/O 类型
除了追踪 I/O 延迟之外,我们还可以分析 I/O 的类型。I/O 可以分为顺序 I/O 和随机 I/O。顺序 I/O 是指连续地读取或写入磁盘,随机 I/O 是指随机地读取或写入磁盘。顺序 I/O 的性能通常比随机 I/O 的性能要好。如果应用频繁地进行随机 I/O,那么就会导致 I/O 延迟过长。
我们可以使用 biolatency
工具来分析 I/O 的类型。biolatency
是 BCC (BPF Compiler Collection) 中的一个工具,它可以统计 I/O 的延迟分布,以及 I/O 的类型。
运行 biolatency
工具,你可以看到 I/O 的延迟分布,以及 I/O 的类型。如果发现 I/O 的延迟比较高,并且 I/O 的类型主要是随机 I/O,那么就需要考虑优化 I/O 的类型,尽量减少随机 I/O 的次数。
3. 内存泄漏?看看是不是有内存分配了没释放
内存泄漏是指应用分配的内存没有及时释放,导致内存占用不断增加。内存泄漏会导致应用性能下降,甚至崩溃。排查内存泄漏是一个比较困难的任务,因为内存泄漏的原因可能有很多,比如代码中的 bug、不合理的内存管理策略等等。
3.1 追踪内存分配和释放
我们可以使用 malloc
和 free
这两个函数来追踪内存分配和释放。malloc
函数用于分配内存,free
函数用于释放内存。我们可以使用 uprobe
和 uretprobe
来分别追踪 malloc
和 free
函数的调用。
下面是一个简单的 eBPF 脚本,它可以统计每个进程的内存分配和释放情况:
from bcc import BPF # 定义 eBPF 程序 program = """ #include <uapi/linux/ptrace.h> BPF_HASH(alloc_size, u64, size_t); int malloc_enter(struct pt_regs *ctx) { u64 pid_tgid = bpf_get_current_pid_tgid(); size_t size = (size_t)PT_REGS_PARM1(ctx); alloc_size.update(&pid_tgid, &size); return 0; } int malloc_return(struct pt_regs *ctx) { u64 pid_tgid = bpf_get_current_pid_tgid(); size_t *size_p = alloc_size.lookup(&pid_tgid); if (size_p == NULL) { return 0; } void *addr = (void *)PT_REGS_RC(ctx); bpf_trace_printk("pid_tgid = %d, malloc(size = %lu) = %p\n", pid_tgid, *size_p, addr); alloc_size.delete(&pid_tgid); return 0; } int free_enter(struct pt_regs *ctx) { u64 pid_tgid = bpf_get_current_pid_tgid(); void *addr = (void *)PT_REGS_PARM1(ctx); bpf_trace_printk("pid_tgid = %d, free(%p)\n", pid_tgid, addr); return 0; } """ # 创建 BPF 实例 bpf = BPF(text=program) # 附加 eBPF 程序到 malloc 和 free 函数 bpf.attach_uprobe(name="your_application", sym="malloc", f=bpf.load_func("malloc_enter", BPF.KPROBE)) bpf.attach_uretprobe(name="your_application", sym="malloc", f=bpf.load_func("malloc_return", BPF.KRETPROBE)) bpf.attach_uprobe(name="your_application", sym="free", f=bpf.load_func("free_enter", BPF.KPROBE)) # 打印内存分配和释放情况 bpf.trace_print()
这个脚本使用了 uprobe
和 uretprobe
来分别追踪 malloc
和 free
函数的入口和出口。在 malloc
函数入口处,脚本记录下分配的内存大小;在 malloc
函数出口处,脚本记录下分配的内存地址;在 free
函数入口处,脚本记录下要释放的内存地址。通过对比 malloc
和 free
的调用情况,我们可以判断是否存在内存泄漏。
3.2 使用 Valgrind
除了使用 eBPF 之外,我们还可以使用 Valgrind 来检测内存泄漏。Valgrind 是一个强大的内存调试工具,它可以检测内存泄漏、内存越界、使用未初始化的内存等问题。Valgrind 的缺点是性能开销比较大,会显著降低应用的性能,因此不适合在生产环境中使用。
4. 总结
eBPF 是一个非常强大的性能分析工具,它可以帮助我们定位应用性能瓶颈,提高应用性能。本文介绍了如何使用 eBPF 来分析 CPU 使用率过高、I/O 延迟过长、内存泄漏等问题。希望这些内容能对你有所帮助。
当然,eBPF 的应用场景远不止这些。例如,你还可以使用 eBPF 来:
- 分析网络性能:例如,追踪 TCP 连接的建立和关闭,统计网络延迟,分析网络拥塞等。
- 监控安全事件:例如,检测恶意进程,监控文件访问,分析系统调用等。
- 自定义监控指标:例如,监控应用的业务指标,例如请求数量、响应时间、错误率等。
总之,eBPF 的潜力是无限的,只要你发挥想象力,就可以用 eBPF 解决各种各样的问题。