作者:金庆辉 MO云原生研发工程师
目录
1. 背景
2. 历史
Uprobe
BPF
Bpftrace
3. 当BPF遇上Golang
问题
实现
编译
生成对特定函数的测量脚本
BUG
本文字数:2500字+
阅读时间:15分钟+
Part 1
背景
Golang作为云原生领域中使用最广泛的编程技术,是我们MatrixOrigin数据库主力开发语言。Golang本身提供了pprof性能剖析工具,可以让我们快速,粗略的分析性能瓶颈,在日常开发中广泛使用。然而,随着性能调优要求不断升高,我们需要更精准的性能指标,比如某个特定golang函数的执行时间,此时pprof就无能为力了,我们必须另想办法。
第一反应,我们可以考虑人工加入计时函数,然后重新编译代码执行。该方案当然可行,但是缺点也很明显,对每个需要分析的函数都要修改代码,重新编译执行。该方案工作量比较大,也不易维护。如果是线上系统,往往无法修改代码。
那么,是否有更好的方法来测量特定函数的时延?最好是不用修改代码并重新编译。答案是:Yes。
Part 2
历史
Uprobe
如果想在不修改代码的前提下,实现修改代码测量的功能,那么自然而然的选择,则在程序运行时动态的修改程序的代码段,加入我们自定义逻辑。
Linux开发者们,提供了在程序运行时,动态修改程序代码的功能——uprobe。它可以修改指定汇编代码为int3指令,并把原指令保存起来。当应用程序运行到int3指令时,便会触发异常,切换到Linux内核态,这里可以执行提前好的测量逻辑。随后返回用户空间,执行最开始被int3替换并保存的指令。在应用程序看来,一切都毫无感觉。而我们的测量逻辑,记录了当前时间。如果我们在函数开始和结束,各记录一次,则可以通过计算差值,得到函数的执行时间。
BPF
Uprobe机制,提供了动态追踪应用程序的基础框架。然而,它依旧很难使用,测量逻辑需要写Linux内核模块,这对大多数应用程序开发者来说,使用门槛依旧很高。
随着BPF技术横空出世,Linux内核开发者们,将其整合进入了uprobe框架。从而可以使用类似C的代码,编写动态追踪逻辑。这比Linux内核模块实现,要简单多了。
Bpftrace
为了进一步降低uprobe + BPF 的使用门槛,开发者们设计并实现了bpftrace工具,它作为BPF的前端,利用类似于脚本语法的简单方式,编译生成BPF程序并自动加载。这在我们分析应用程序时,可以非常快速的实现动态测量程序。也可以提前准备好脚本,直接拿来使用。
Bpftrace原本设计主要是针对C语言开发的应用场景,那么它可以用在golang环境中吗?答案是可以,但是需要调整。接下来的篇幅,将介绍如何让bpftrace在golang环境中正确的使用。
Part 3
当BPF遇上Golang
问题
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对这类情况支持并不太好,或许之后会解决。目前我们可以用地址来替换函数名,绕过这个问题。
实现
>>> 开源项目
https://github.com/stevenjohnstone/go-bpf-gen
很好的解决了BPF在golang中的适配问题。我们聚焦在latency.bt 脚本上,它可以测量任意golang函数的调用时延的直方图。
>>> 编译
go build
>>> 生成对特定函数的测量脚本
./go-bpf-gen templates/latency.bt ../../matrixone/mo-service symbol="github.com/matrixorigin/matrixone/pkg/logservice.(*managedClient).Append"
BEGIN {
printf("Hit CTRL+C to end profiling\\n");
}
uprobe:/home/jinqinghui/matrixone/mo-service:runtime.execute {
// map thread id to goroutine id
@gids[tid] = reg("ax")
}
tracepoint:sched:sched_process_exit {
delete(@gids[tid]);
}
uprobe:/home/jinqinghui/matrixone/mo-service:"github.com/matrixorigin/matrixone/pkg/logservice.(*managedClient).Append" {
$gid = @gids[tid];
@start0[$gid, pid] = nsecs;
}
uprobe:/home/jinqinghui/matrixone/mo-service:"github.com/matrixorigin/matrixone/pkg/logservice.(*managedClient).Append" + 273,
uprobe:/home/jinqinghui/matrixone/mo-service:"github.com/matrixorigin/matrixone/pkg/logservice.(*managedClient).Append" + 291 {
$gid = @gids[tid];
@durations["github.com/matrixorigin/matrixone/pkg/logservice.(*managedClient).Append"] = hist((nsecs - @start0[$gid, pid])/1000000);
delete(@start0[$gid, pid]);
}
该脚本的关键点:
函数调用上线文的获取:trace runtime.execute()。该函数是golang运行时,用于调度协程的分派函数。通过跟踪参数,可以知道当前线程上正在调度的golang协程标识。
函数参数的获取:runtime.execute()在golang 1.19中是通过寄存器传递。可以通过查看汇编确定。
Golang函数的返回点:遍历二进制,找到该函数的ret指令偏移量,然后和函数起始位置相加。有多少ret指令,就需要跟踪多少次。
通过记录函数进入时间和返回时间,计算出时延,最后保存在直方图树结构里。
>>> BUG
遗憾的是,该脚本在最新bpftrace中,并不能正确执行。原因在于被追踪函数含有"(" 目前bpftrace支持不了。替代方案,人工把函数名,用二进制中函数的具体地址替换即可。需要人工计算。
MO将基于go-bpf-gen项目进行改进——生成脚本时自动用函数地址替换函数名。避免人工查看汇编而带来的不便。 持 续 优 化