Golang 中利用 BPF 进行动态追踪
Part 1 背景
Golang 作为云原生领域中使用最广泛的编程技术,是我们 MatrixOrigin 数据库主力开发语言。Golang 本身提供了 pprof 性能剖析工具,可以让我们快速,粗略的分析性能瓶颈,在日常开发中广泛使用。然而,随着性能调优要求不断升高,我们需要更精准的性能指标,比如某个特定 golang 函数的执行时间,此时 pprof 就无能为力了,我们必须另想办法。
第一反应,我们可以考虑人工加入计时函数,然后重新编译代码执行。该方案当然可行,但是缺点也很明显,对每个需要分析的函数都要修改代码,重新编译执行。该方案工作量比较大,也不易维护。如果是线上系统,往往无法修改代码。
那么,是否有更好的方法来测量特定函数的时延?最好是不用修改代码并重新编译。答案是:Yes。
Part 2 历史
01 Uprobe
如果想在不修改代码的前提下,实现修改代码测量的功能,那么自然而然的选择,则在程序运行时动态的修改程序的代码段,加入我们自定义逻辑。
Linux 开发者们,提供了在程序运行时,动态修改程序代码的功能——uprobe。它可以修改指定汇编代码为 int3 指令,并把原指令保存起来。当应用程序运行到 int3 指令时,便会触发异常,切换到 Linux 内核态,这里可以执行提前好的测量逻辑。随后返回用户空间,执行最开始被 int3 替换并保存的指令。在应用程序看来,一切都毫无感觉。而我们的测量逻辑,记录了当前时间。如果我们在函数开始和结束,各记录一次,则可以通过计算差值,得到函数的执行时间。
02 BPF
Uprobe 机制,提供了动态追踪应用程序的基础框架。然而,它依旧很难使用,测量逻辑需要写 Linux 内核模块,这对大多数应用程序开发者来说,使用门槛依旧很高。
随着 BPF 技术横空出世,Linux 内核开发者们,将其整合进入了 uprobe 框架。从而可以使用类似 C 的代码,编写动态追踪逻辑。这比 Linux 内核模块实现,要简单多了。
03 Bpftrace
为了进一步降低 uprobe + BPF 的使用门槛,开发者们设计并实现了 bpftrace 工具,它作为 BPF 的前端,利用类似于脚本语法的简单方式,编译生成 BPF 程序并自动加载。这在我们分析应用程序时,可以非常快速的实现动态测量程序。也可以提前准备好脚本,直接拿来使用。
Bpftrace 原本设计主要是针对 C 语言开发的应用场景,那么它可以用在 golang 环境中吗?答案是可以,但是需要调整。接下来的篇幅,将介绍如何让 bpftrace 在 golang 环境中正确的使用。
Part 3 当 BPF 遇上 Golang
01 问题
Golang 作为一种编译型语言,理论上可以直接使用 uprobe + BPF 进行动态追踪。然而,golang 与 C 语言相比,又有微妙的不同之处,这里用 go 1.19 + x86 环境为例子:
Golang ABI 与 C 语言相比,C 语言函数只使用寄存器传递参数。然而 golang 不确定使用寄存器还是使用栈来传递函数参数(要么用寄存器,要么用栈传递,不会混合),编译器有相关算法来决定,具体算法规则可以参考 golang 源码的 src/cmd/compile/internal-abi.md 文档。golang 函数传递参数的方式的不同,这意味着使用 bpftrace 动态追踪时,获取被追踪函数的参数,需要先确认它是利用寄存器传参,还是通过栈传参,然后调用不同的 bpftace 函数来获取。我个人的方法是查看该 golang 函数的汇编语言,人工判断。目前还没想出更智能的办法。
Golang 原生支持协程,它的函数栈与 C 语言相比,初始非常小,并且随着栈深度增加,就会发生栈扩张,这会把旧的栈复制到更大的新栈内存中。golang 这一特性,意味着在 C 语言中,稳定使用的对函数返回的 uretprobe 动态追踪技术(需要修改函数栈),直接应用在 golang 中,当发生栈扩张时,就会导致程序错误。因此,想要正确追踪 golang 函数的返回,只能遍历函数的汇编代码,跟踪所有 ret 汇编指令。
Golang 的函数参数个数和顺序,不能从字面来判断。编译器会可能会进行改写。比如字符串引用,golang 会把它扩张成地址和长度两个参数。目前,笔者还没有找到很好的方法可以找到改写后的参数列表。
Golang 所有函数都在协程中运行,这意味着函数一次执行过程中,可能会从 A 线程切换到 B 线程。而 C 语言的函数,在一次执行中,只会在一个线程中,直到执行完成。这意味着 golang 函数执行的上下文,不能用线程号来唯一的确定。幸运的是,我们依旧有办法唯一的标识 golang 协程上线文。每个 golang 协程,都有 type gobuf struct {...} 实例表示。追踪 rumtime/proc.go:execute(gp *g, inheritTime bool) 函数,记录下*g,这就可以唯一的标记当前协程上下文。
Golang 函数符号,可能会带有(,*之类符号。bpftrace 对这类情况支持并不太好,或许之后会解决。目前我们可以用地址来替换函数名,绕过这个问题。
02 实现
>>> 开源项目
https://github.com/stevenjohnstone/go-bpf-gen
很好的解决了 BPF 在 golang 中的适配问题。我们聚焦在 latency.bt 脚本上,它可以测量任意 golang 函数的调用时延的直方图。
>>> 编译
>>> 生成对特定函数的测量脚本
该脚本的关键点:
函数调用上线文的获取:trace runtime.execute()。该函数是 golang 运行时,用于调度协程的分派函数。通过跟踪参数,可以知道当前线程上正在调度的 golang 协程标识。
函数参数的获取:runtime.execute()在 golang 1.19 中是通过寄存器传递。可以通过查看汇编确定。
Golang 函数的返回点:遍历二进制,找到该函数的 ret 指令偏移量,然后和函数起始位置相加。有多少 ret 指令,就需要跟踪多少次。
通过记录函数进入时间和返回时间,计算出时延,最后保存在直方图树结构里。
>>> BUG
遗憾的是,该脚本在最新 bpftrace 中,并不能正确执行。原因在于被追踪函数含有"(" 目前 bpftrace 支持不了。替代方案,人工把函数名,用二进制中函数的具体地址替换即可。需要人工计算。
MO 将基于 go-bpf-gen 项目进行改进——生成脚本时自动用函数地址替换函数名。避免人工查看汇编而带来的不便。
版权声明: 本文为 InfoQ 作者【MatrixOrigin】的原创文章。
原文链接:【http://xie.infoq.cn/article/d8e41b60c028609a674762eef】。文章转载请联系作者。
评论