https://bpf.plus/go
使用 BPF 跟踪 Go 应用程序最大的困难在于:不能使用 uretprobe,具体细节可以参考以下讨论:
https://github.com/iovisor/bcc/issues/1320
这不仅使我们无法获取函数的返回值,也无法通过在函数的入口和返回添加钩子获取函数执行耗时。
关于第一个问题,如果一个函数的返回值被作为另一个函数的入参传递,那么我们可以间接地通过 uprobe 跟踪第二个函数的入参来获得第一个函数的返回值。
func doSomething() {
val := step1()
step2(val)
}
(另一个思路来自上面 BCC issue 中的讨论,可以通过 uprobe 跟踪函数的返回语句,这涉及到反汇编计算指令的偏移量等细节,我未曾验证,此处不展开讨论。)
关于第二个问题,思路也是类似的。如下:
func doSomething() {
step1()
step2()
}
要知道函数 step1() 的执行耗时,我们只需要使用 uprobe 分别记录函数 step1() 和 step2() 入口处的时间戳,两者之差即可作为 step1() 的执行耗时。
思路很简单,实现起来却颇有难度。因为我们需要一个上下文信息,用来记录前后两个函数是顺序依次执行的。在跟踪内核函数或者其他编程语言编译的程序时,这个上下文信息我们一般可以选用线程 ID(TID),而在 Go 应用程序中,我们不能假设前后两个函数一定在同一个线程上运行。(感兴趣的读者可了解 Go 的 GMP 调度模型及 Work Stealing 策略,这超出了本文的范围)
一个可靠的上下文信息是 Goroutine ID,这个信息保存在当前线程的本地存储中(TLS, Thread Local Storage)。具体地说,它的基地址可以通过 struct thread_struct 的 fsbase 获得:
struct task_struct {
...
/* CPU-specific state of this task: */
struct thread_struct thread;
...
};
struct thread_struct {
...
unsigned long fsbase;
...
};
参考 BCC 的这个讨论 https://github.com/iovisor/bcc/issues/3575,原谅一年半前无知的我,感谢无私的网友 :D
根据网友的指引,我们知道 Go 的调试器 delve 已经有一个实现了:
// Get the Goroutine ID which is stored in thread local storage.
__u64 goid;
size_t g_addr;
bpf_probe_read_user(&g_addr, sizeof(void *), (void*)(task->thread.fsbase+parsed_args->g_addr_offset));
bpf_probe_read_user(&goid, sizeof(void *), (void*)(g_addr+parsed_args->goid_offset));
这里我们需要两个信息:Go 的 g struct 位于 TLS 中的偏移,以及 goid 在 g struct 中的偏移量。
第一个信息可以通过以下代码(来自 delve)获取:
package main
import (
"debug/elf"
"log"
"os"
)
func getSymbol(exe *elf.File, name string) *elf.Symbol {
symbols, err := exe.Symbols()
if err != nil {
log.Fatal(err)
}
for _, symbol := range symbols {
if symbol.Name == name {
s := symbol
return &s
}
}
return nil
}
func main() {
exe, err := elf.Open(os.Args[1])
if err != nil {
log.Fatal(err)
}
defer exe.Close()
var tls *elf.Prog
for _, prog := range exe.Progs {
if prog.Type == elf.PT_TLS {
tls = prog
break
}
}
tlsg := getSymbol(exe, "runtime.tlsg")
if tlsg == nil || tls == nil {
offset := ^uint64(8) + 1
log.Printf("%x\n", offset)
return
}
memsz := tls.Memsz + (-tls.Vaddr-tls.Memsz)&(tls.Align-1)
offset := ^(memsz) + 1 + tlsg.Value
log.Printf("%x\n", offset)
}
(在下面的例子中,这个偏移量是 0xfffffffffffffff8)
第二个信息就比较简单了,可以通过 readelf --debug-dump 获取:
<2><ab025>: Abbrev Number: 24 (DW_TAG_member)
<ab026> DW_AT_name : goid
<ab02b> DW_AT_data_member_location: 152
<ab02d> DW_AT_type : <0xaa1c3>
<ab031> Unknown AT value: 2903: 0
(或者手动计算一下 goid 的偏移,在下面的例子中是 152)
有了上述信息,我们就可以在 BPF 程序中拿到 Goroutine ID 了:
uprobe:/path/to/your_go_app:main.doSomething {
$task = (struct task_struct *)curtask;
$addr = *($task->thread.fsbase + (uint64)0xfffffffffffffff8);
$goid = *($addr + 152);
printf("comm: %s, pid: %d, tid: %d, goid: %d\n", comm, pid, tid, $goid);
}
可以通过触发 panic 确认 BPF 程序确实拿到了正确的 Goroutine ID:
func doSomething() {
panic("goid")
}
如下:
panic: goid
goroutine 18 [running]:
main.doSomething()
10 +0x27 :
created by main.main
29 +0x35 :
而我们的 BPF 程序确实抓到了对应的 goid:
Attaching 1 probe...
comm: goid, pid: 3004528, tid: 3004532, goid: 18
至此,我们有了获取 goid 的超能力,可以部分地解决开篇提到的两个问题了!