像跟蹤分散式服務呼叫那樣跟蹤 Go 函式呼叫鏈
這篇文章的初衷是想解答知乎上的一位知友提出的問題。沒想到完成一種實現後,這個問題居然被刪除了。那麼既然實現了,就分享出來吧。問題的原文找不到了,問題大致是這樣的:
一個程式中存在多個函式呼叫鏈都呼叫了函式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 以實現滿足你們自己需求的實現。
7. 參考資料
- https://mattermost.com/blog/instrumenting-go-code-via-ast/
- https://developers.mattermost.com/blog/open-tracing/
- https://blog.gopheracademy.com/code-generation-from-the-ast/
- http://www.go2live.cn/nocate/golang-ast.html語法樹使用教程及示例
- https://www.ctolib.com/topics-80234.html
- https://github.com/yuroyoro/goast-viewer
- https://liudanking.com/performance/golang-%e8%8e%b7%e5%8f%96-goroutine-id-%e5%ae%8c%e5%85%a8%e6%8c%87%e5%8d%97/
本文中涉及到的示例原始碼可以到這裡下載 https://github.com/bigwhite/experiments/tree/master/trace-function-call-chain。
“Gopher 部落” 知識星球開球了!高品質首發 Go 技術文章,“三天” 首發閱讀權,每年兩期 Go 語言發展現狀分析,每天提前 1 小時閱讀到新鮮的 Gopher 日報,網課、技術專欄、圖書內容前瞻,六小時內必答保證等滿足你關於 Go 語言生態的所有需求!星球首開,福利自然是少不了的!2020 年年底之前,8.8 折 (很吉利吧^_^) 加入星球,下方圖片掃起來吧!
我的 Go 技術專欄:“改善 Go 語⾔程式設計質量的 50 個有效實踐” 主要滿足廣大 gopher 關於 Go 語言進階的需求,圍繞如何寫出地道且高質量 Go 程式碼給出 50 條有效實踐建議,上線後收到一致好評! 歡迎大家訂閱!
我的網課 “Kubernetes 實戰:高可用叢集搭建、配置、運維與應用” 在慕課網熱賣中,歡迎小夥伴們訂閱學習!
Gopher Daily(Gopher 每日新聞) 歸檔倉庫 - https://github.com/bigwhite/gopherdaily
我的聯絡方式:
- 微博:https://weibo.com/bigwhite20xx
- 微信公眾號:iamtonybai
- 部落格:tonybai.com
- github: https://github.com/bigwhite
- “Gopher 部落” 知識星球:https://public.zsxq.com/groups/51284458844544
- 加微信實戰群請加微信(註明:實戰群):gocnio
相關文章
- 分散式服務呼叫鏈追蹤分散式
- 使用dtrace跟蹤oracle函式呼叫Oracle函式
- 分散式呼叫鏈跟蹤工具Jaeger?兩分鐘極速體驗分散式
- 呼叫鏈與日誌的關聯式跟蹤查詢
- 基於OpenTelemetry實現Java微服務呼叫鏈跟蹤Java微服務
- 棧呼叫關係跟蹤
- Dubbo 整合 Pinpoint 做分散式服務請求跟蹤分散式
- 微服務分散式跟蹤工具Brave簡介微服務分散式
- DiagnosticSource DiagnosticListener 無侵入式分散式跟蹤分散式
- 螞蟻金服分散式鏈路跟蹤元件取樣策略和原始碼 | 剖析分散式元件原始碼
- 分散式系統中的分散式鏈路追蹤與分散式呼叫鏈路分散式
- 使用docker-compose 一鍵部署你的分散式呼叫鏈跟蹤框架skywalkingDocker分散式框架
- 分散式跟蹤系統zipkin簡介分散式
- 中介軟體---分散式跟蹤---Pinpoint分散式
- 【分散式跟蹤系統Zipkin 介紹】分散式
- Zipkin開源分散式跟蹤系統分散式
- 分散式系統的跟蹤系統分散式
- 螞蟻金服分散式鏈路跟蹤元件 SOFATracer 總覽 | 剖析分散式元件
- 使用zipKin構建NetCore分散式鏈路跟蹤NetCore分散式
- Zipkin — 微服務鏈路跟蹤.微服務
- Welcome to YARP - 8.分散式跟蹤分散式
- Dapper分散式跟蹤系統論文APP分散式
- 分散式跟蹤系統——產品對比分散式
- Java呼叫鏈跟蹤關鍵技術(四)SQL監控JavaSQL
- 原理 | 分散式鏈路跟蹤元件 SOFATracer 和 Zipkin 模型轉換分散式元件模型
- Ftrace使用指南及跟蹤系統呼叫
- Itrace跟蹤程式呼叫庫函式引數選項-怎麼學習linux運維函式Linux運維
- 耐克公司的WingTips分散式跟蹤系統分散式
- 微服務呼叫鏈追蹤中心搭建微服務
- 使用Spring Cloud Sleuth和OpenTelemetry實現分散式跟蹤SpringCloud分散式
- Dapper:谷歌的大規模分散式跟蹤系統APP谷歌分散式
- java可以像python中的函式式工具裡的reduce那樣呼叫嗎?JavaPython函式
- ⑦SpringCloud 實戰:引入Sleuth元件,完善服務鏈路跟蹤SpringGCCloud元件
- 一個非侵入式跟蹤分析程式
- [zt] oracle跟蹤檔案與跟蹤事件Oracle事件
- oracle跟蹤檔案與跟蹤事件(zt)Oracle事件
- oracle跟蹤檔案和跟蹤事件(zt)Oracle事件
- springcloud(十二):使用Spring Cloud Sleuth和Zipkin進行分散式鏈路跟蹤SpringGCCloud分散式