这篇文章的初衷是想解答知乎上的一位知友[提出的问题]。没想到完成一种实现后,这个问题居然被删除了。那么既然实现了,就分享出来吧。问题的原文找不到了,问题大致是这样的:
一个程序中存在多个函数调用链都调用了函数D:
A1 -> B1 > C1 -> D
A2 -> B2 > C2 -> D
A3 -> B3 -> C3 -> D
... ...
那么,如果某次函数D被调用时出现了问题,那么怎么知道这个D是哪个函数调用链里的D呢?
有些gopher可能会说通过Delve在线调试打印函数调用栈可以知晓D的调用链,还有些gopher可能会说通过各个函数中输出的业务日志可以查明出问题的D归属的函数调用链,这些都是可行的思路。
不过当遇到这个问题时,我大脑中的第一反应却是能否像跟踪[分布式服务调用链]那样跟踪函数调用链呢?于是就有了本文对这种思路的一个非生产级的实现以及其演化过程。
1. 利用defer实现函数出入口的跟踪
跟踪函数调用,我们首先想到的就是跟踪函数的出入口,而完成这一任务,当仁不让的就是利用defer。对于我这样的从C语言转到Go的gopher而言,defer是我十分喜欢的Go“语法糖”,因为它可以简化代码的实现,让代码逻辑更清晰,具有更好地可读性(关于defer让代码更清晰的系统描述,可参考我的Go进阶技术专栏文章:https://www.imooc.com/read/87/article/2421)。
下面我们就来看看第一版函数跟踪实现的代码:
// github.com/bigwhite/experiments/blob/master/trace-function-call-chain/trace1/trace.go
func trace() func() {
pc, _, _, ok := runtime.Caller(1)
if !ok {
panic("not found caller")
}
fn := runtime.FuncForPC(pc)
name := fn.Name()
fmt.Printf("enter: %s\n", name)
return func() { fmt.Printf("exit: %s\n", name) }
}
// github.com/bigwhite/experiments/blob/master/trace-function-call-chain/trace1/main.go
func A1() {
defer trace()()
B1()
}
func B1() {
defer trace()()
C1()
}
func C1() {
defer trace()()
D()
}
func D() {
defer trace()()
}
func main() {
A1()
}
我们看到:以A1实现为例,当执行流来带defer语句时,首先会对defer后面的表达式进行求值。trace函数会执行,输出函数入口信息,并返回一个“打印出口信息”的匿名函数。该函数在此并不会执行,而是被注册到函数A1的defer函数栈中,待A1函数执行结束后才会被弹出执行。也就是在A1结束后,会有一条函数的出口信息被输出。
下面我们来真实运行一下上面的trace1示例([Go 1.14], macOS 10.14.6):
// github.com/bigwhite/experiments/trace-function-call-chain/trace1
$go build
$./functrace-demo
enter: main.A1
enter: main.B1
enter: main.C1
enter: main.D
exit: main.D
exit: main.C1
exit: main.B1
exit: main.A1
我们看到各个函数的出入口信息都被输出了,在单Goroutine的情况下,我们从执行顺序上能识别出D究竟是归属于哪个调用链的。
2. 添加trace开关
对函数调用链进行Trace是有一定性能损耗的,我们可能并不想在所有场合都开启trace,那么我们来给Trace添加一个“开关”,我们利用go build tags来实现这个功能特性:
// github.com/bigwhite/experiments/blob/master/trace-function-call-chain/trace2/trace.go
// +build trace
package main
... ...
// github.com/bigwhite/experiments/blob/master/trace-function-call-chain/trace2/trace_nop.go
// +build !trace
package main
func trace() func() {
return func() {
}
}
我们新增一个名为trace_nop.go
的文件,里面包含了一个trace函数的空实现,即在trace函数与其返回的匿名函数中什么都不做。该源文件增加了一个build指示器(directive):
// +build !trace
即在关闭trace开关时,使用该文件中的trace函数。而原trace.go
文件中也增加了一个build指示器:
// +build trace
即只有在打开trace开关的情况下,才会使用该源文件。
我们来对比一下在trace开关打开和关闭下的执行结果:
// github.com/bigwhite/experiments/trace-function-call-chain/trace2
// trace开关关闭
$go build
$./functrace-demo
vs.
// trace开关打开
$go build -tags trace
$./functrace-demo
enter: main.A1
enter: main.B1
enter: main.C1
enter: main.D
exit: main.D
exit: main.C1
exit: main.B1
exit: main.A1
不过这里的实现还是有一个问题的,那就是即便不开启trace开关,trace_nop.go
中的trace函数也是会被编译到可执行程序中的。利用go tool compile -S查看汇编代码,trace_nop.go
中的trace函数以及其返回的匿名函数都没有被inline掉。这会带来一定的运行时开销,这个问题我们先记下并留到后面解决。
3. 增加对多goroutine函数调用链的跟踪支持
前面的实现面对只有一个goroutine的时候还是可以支撑的,但当程序中并发运行多个goroutine的时候,多个函数调用链的出入口信息输出就会混杂在一起无法分辨。下面我们就来改造一下实现,增加对多goroutine函数调用链的跟踪支持。我们的方案就是在输出函数出入口信息时,带上一个在程序每次执行时能唯一区分goroutine的goroutine id:
// github.com/bigwhite/experiments/blob/master/trace-function-call-chain/trace3/trace.go
func getGID() uint64 {
b := make([]byte, 64)
b = b[:runtime.Stack(b, false)]
b = bytes.TrimPrefix(b, []byte("goroutine "))
b = b[:bytes.IndexByte(b, ' ')]
n, _ := strconv.ParseUint(string(b), 10, 64)
return n
}
func trace() func() {
pc, _, _, ok := runtime.Caller(1)
if !ok {
panic("not found caller")
}
fn := runtime.FuncForPC(pc)
name := fn.Name()
id := getGID()
fmt.Printf("g[%02d]: enter %s\n", id, name)
return func() { fmt.Printf("g[%02d]: exit %s\n", id, name) }
}
main.go也改成了启动多个Goroutine:
// github.com/bigwhite/experiments/blob/master/trace-function-call-chain/trace3/main.go
func A1() {
defer trace()()
B1()
}
func B1() {
defer trace()()
C1()
}
func C1() {
defer trace()()
D()
}
func D() {
defer trace()()
}
func A2() {
defer trace()()
B2()
}
func B2() {
defer trace()()
C2()
}
func C2() {
defer trace()()
D()
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
A2()
wg.Done()
}()
time.Sleep(time.Millisecond * 50)
A1()
wg.Wait()
}
在trace功能开关打开的前提下,运行上面例子:
// github.com/bigwhite/experiments/trace-function-call-chain/trace3
$go build -tags trace
$./functrace-demo
g[18]: enter main.A2
g[18]: enter main.B2
g[18]: enter main.C2
g[18]: enter main.D
g[18]: exit main.D
g[18]: exit main.C2
g[18]: exit main.B2
g[18]: exit main.A2
g[01]: enter main.A1
g[01]: enter main.B1
g[01]: enter main.C1
g[01]: enter main.D
g[01]: exit main.D
g[01]: exit main.C1
g[01]: exit main.B1
g[01]: exit main.A1
4. 让输出更美观一些
了解分布式服务调用跟踪的童鞋都知道,通过带有层次感的输出,我们可以很容易识别出某个服务在哪个环节被调用。而上面我们的Trace输出太扁平,没有层次感,不容易识别,我们这里就来美化一下输出。我们将trace.go
做如下改造:
// github.com/bigwhite/experiments/trace-function-call-chain/trace4/trace.go
var mu sync.Mutex
var m = make(map[uint64]int)
func printTrace(id uint64, name, typ string, indent int) {
indents := ""
for i := 0; i < indent; i++ {
indents += "\t"
}
fmt.Printf("g[%02d]:%s%s%s\n", id, indents, typ, name)
}
func trace() func() {
pc, _, _, ok := runtime.Caller(1)
if !ok {
panic("not found caller")
}
id := getGID()
fn := runtime.FuncForPC(pc)
name := fn.Name()
mu.Lock()
v := m[id]
m[id] = v + 1
mu.Unlock()
printTrace(id, name, "->", v+1)
return func() {
mu.Lock()
v := m[id]
m[id] = v - 1
mu.Unlock()
printTrace(id, name, "<-", v)
}
}
编译运行:
// github.com/bigwhite/experiments/trace-function-call-chain/trace4
$go build -tags trace
$./functrace-demo
g[18]: ->main.A2
g[18]: ->main.B2
g[18]: ->main.C2
g[18]: ->main.D
g[18]: <-main.D
g[18]: <-main.C2
g[18]: <-main.B2
g[18]: <-main.A2
g[01]: ->main.A1
g[01]: ->main.B1
g[01]: ->main.C1
g[01]: ->main.D
g[01]: <-main.D
g[01]: <-main.C1
g[01]: <-main.B1
g[01]: <-main.A1
这回显然好看多了,也更容易定位问题了!(当多个goroutine的函数跟踪输出混在一起时,我们还可以用grep工具将特定id的goroutine的函数跟踪输出过滤出来,比如:functrace-demo|grep "01"
)。
5. 利用代码生成将trace代码注入到各个函数中
在前面我们提到过上面实现的一个问题,那就是一旦将trace写死到各个函数代码中,即便在trace开关未打开的情况下,依然是有性能损耗的。并且,上面的实现存在着对业务代码的较强的“代码侵入性”。那么我们能否减少侵入,像分布式服务跟踪那样将“跟踪”的设施注入(instrumenting)到需要跟踪的函数中呢?下面我们就来尝试一下。
1) 将trace单独打包为一个module
我们首先要做的就是将trace相关的代码单独提取打包为一个module。这里我将上面的trace.go和trace_nop.go放入了一个路径为github.com/bigwhite/functrace
的module中:
$tree -F -L 2 functrace
functrace
├── LICENSE
... ...
├── README.md
├── example_test.go
├── go.mod
├── go.sum
├── trace.go
└── trace_nop.go
有了这个module,你可以以“侵入式”的方式为你的代码添加函数链调用跟踪,就像上面repo中example_test.go
中的那样:
// https://github.com/bigwhite/functrace/blob/main/example_test.go
import (
"github.com/bigwhite/functrace"
)
func a() {
defer functrace.Trace()()
b()
}
func b() {
defer functrace.Trace()()
c()
}
func c() {
defer functrace.Trace()()
d()
}
func d() {
defer functrace.Trace()()
}
func ExampleTrace() {
a()
// Output:
// g[01]: ->github.com/bigwhite/functrace_test.a
// g[01]: ->github.com/bigwhite/functrace_test.b
// g[01]: ->github.com/bigwhite/functrace_test.c
// g[01]: ->github.com/bigwhite/functrace_test.d
// g[01]: <-github.com/bigwhite/functrace_test.d
// g[01]: <-github.com/bigwhite/functrace_test.c
// g[01]: <-github.com/bigwhite/functrace_test.b
// g[01]: <-github.com/bigwhite/functrace_test.a
}
2) 增加代码注入功能
我们在github.com/bigwhite/functrace
仓库中增加了一个名为gen的工具。利用该工具我们可以将functrace中的trace基础设施代码自动注入(instrumenting)到目标源文件的各个函数定义中。这个工具调用的核心算法在github.com/bigwhite/functrace/pkg/generator中:
// github.com/bigwhite/functrace/blob/main/pkg/generator/rewrite.go
func Rewrite(filename string) ([]byte, error) {
fset := token.NewFileSet()
oldAST, err := parser.ParseFile(fset, filename, nil, 0)
if err != nil {
return nil, fmt.Errorf("error parsing %s: %w", filename, err)
}
if !hasFuncDecl(oldAST) {
return nil, nil
}
// add import declaration
astutil.AddImport(fset, oldAST, "github.com/bigwhite/functrace")
// inject code into each function declaration
addDeferTraceIntoFuncDecls(oldAST)
buf := &bytes.Buffer{}
err = format.Node(buf, fset, oldAST)
if err != nil {
return nil, fmt.Errorf("error formatting new code: %w", err)
}
return buf.Bytes(), nil
}
我们看到这个包的Rewrite函数使用了Go项目提供的go/ast
包以及Go扩展项目提供的ast(抽象语法树)操作工具包golang.org/x/tools/go/ast/astutil
对目标源文件进行解析、修改并重建的。go/ast
包的内容较多,其本身就具备单独写几篇文章了,这里不赘述。有兴趣的童鞋可以移步本文后面的参考资料,或查看go官方文档了解。
为了帮助大家了解如何使用gen生成带有trace的代码,我还在functrace这个repo中建立了一个demo:examples/gen-demo:
$tree examples/gen-demo
examples/gen-demo
├── Makefile
├── go.mod
├── go.sum
└── main.go
在该demo中,我们利用go generate生成带有跟踪代码的目标代码:
// https://github.com/bigwhite/functrace/blob/main/examples/gen-demo/main.go
package main
//go:generate ../../gen -w main.go
... ...
构建该demo并运行(为了方便构建,我建立了Makefile):
// Makefile
all:
go generate
go build -tags trace
$make
go generate
[../../gen -w main.go]
add trace for main.go ok
go build -tags trace
$./functrace-demo
g[01]: ->main.main
g[01]: ->main.A2
g[01]: ->main.B2
g[01]: ->main.C2
g[01]: ->main.D
g[01]: <-main.D
g[01]: <-main.C2
g[01]: <-main.B2
g[01]: <-main.A2
g[18]: ->main.A1
g[18]: ->main.B1
g[18]: ->main.C1
g[18]: ->main.D
g[18]: <-main.D
g[18]: <-main.C1
g[18]: <-main.B1
g[18]: <-main.A1
g[01]: <-main.main
我们看到,我们通过ast将跟踪代码注入到目标代码并运行的思路成功实现了!
6. 小结
functrace module中Trace函数的实现比较简单,目前仅是输出日志,但实际上我们可以在Trace函数中以及Trace函数返回的匿名函数中通过各种方式输出我们想要的数据,比如,像分布式服务跟踪那样,将数据发送到一个集中的后端做统一存储、分析和展示。但鉴于篇幅和需求不同,这里仅给出满足演示的实现,大家可以自行fork该repo以实现满足你们自己需求的实现。
本文中涉及到的示例源码可以到[这里]github.com/bigwhite/experiments/tree/master/trace-function-call-chain下载
我的Go技术专栏:“改善Go语⾔编程质量的50个有效实践”上线了,欢迎大家订阅学习!
我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用”在慕课网热卖中,欢迎小伙伴们订阅学习!
Gopher Daily(Gopher每日新闻)归档仓库 - github.com/bigwhite/gopherdaily
我的联系方式:
- 微博:weibo.com/bigwhite20xx
- 博客:tonybai.com
- github: github.com/bigwhite