一、前言
不久前,阿里云 ARMS 團隊、編譯器團隊、MSE 團隊攜手合作,共同發布并開源了 Go 語言的編譯時自動插樁技術。該技術以其零侵入的特性,為 Go 應用提供了與 Java 監控能力相媲美的解決方案。開發者只需將?go build?替換為新編譯命令?otel go build,就能實現對 Go 應用的全面監控和治理。
二、問題描述
近期,我們收到用戶反饋,使用 otel go build -race 替代正常的 go build -race 命令后,編譯生成的程序會導致崩潰。-race[3]是 Go 編譯器的一個參數,用于檢測數據競爭(data race)問題。通過為每個變量的訪問添加額外檢查,確保多個 goroutine 不會以不安全方式同時訪問這些變量。
理論上,我們的工具不應影響-race 競態檢查的代碼,因此出現崩潰的現象是非預期的,所以我們花了一些時間排查這個崩潰問題,崩潰的堆棧信息如下:
(gdb) bt
#0 0x000000000041e1c0 in __tsan_func_enter ()
#1 0x00000000004ad05a in racecall ()
#2 0x0000000000000001 in ?? ()
#3 0x00000000004acf99 in racefuncenter ()
#4 0x00000000004ae7f1 in runtime.racefuncenter (callpc=4317632)
#5 0x0000000000a247d8 in ../sdk/trace.(*traceContext).TakeSnapShot (tc=<optimized out>, ~r0=...)
#6 0x00000000004a2c25 in runtime.contextPropagate
#7 0x0000000000480185 in runtime.newproc1.func1 ()
#8 0x00000000004800e2 in runtime.newproc1 (fn=0xc00030a1f0, callergp=0xc0000061e0, callerpc=12379404, retVal0=0xc0002c8f00)
#9 0x000000000047fc3f in runtime.newproc.func1 ()
#10 0x00000000004a992a in runtime.systemstack ()
....
可以看到崩潰源于?__tsan_func_enter,而引發該問題的關鍵點是?runtime.contextPropagate。我們的工具在?runtime.newproc1?函數的開頭插入了以下代碼:
func newproc1(fn *funcval, callergp *g, callerpc uintptr) (retVal0 *g) {
// 我們插入的代碼
retVal0.otel_trace_context = contextPropagate(callergp.otel_trace_context)
...
}
// 我們插入的代碼
func contextPropagate(tls interface{}) interface{} {
if tls == nil {
return nil
}
if taker, ok := tls.(ContextSnapshoter); ok {
return taker.TakeSnapShot()
}
return tls
}
// 我們插入的代碼
func (tc *traceContext) TakeSnapShot() interface{} {
...
}
TakeSnapShot?被 Go 編譯器在函數入口和出口分別注入了?racefuncenter()?和?racefuncexit(),最終調用?__tsan_func_enter 導致崩潰。由此確定崩潰問題確實是我們的注入代碼導致的,繼續深入排查。
三、排查過程
3.1?崩潰根源
使用?objdump?查看?__tsan_func_enter?的源碼,看到它接收兩個函數參數,出錯的地方是第一行?mov 0x10(%rdi),%rdx,它約等于?rdx = *(rdi + 0x10)。打印寄存器后發現?rdi = 0,根據調用約定,rdi?存放的是第一個函數參數,因此這里的問題就是函數第一個參數?thr?為 0。
// void __tsan_func_enter(ThreadState *thr, void *pc);
000000000041e1c0 <__tsan_func_enter>:
41e1c0: 48 8b 57 10 mov 0x10(%rdi),%rdx
41e1c4: 48 8d 42 08 lea 0x8(%rdx),%rax
41e1c8: a9 f0 0f 00 00 test $0xff0,%eax
...
那么第一個參數?thr?是誰傳進來的呢?接著往上分析調用鏈。
3.2?調用鏈分析
出錯的整個調用鏈是?racefuncenter(Go) -> racecall(Go) -> __tsan_func_enter(C)。需要注意的是,前兩個函數都是 Go 代碼,Go 函數調用 Go 函數遵循 Go 的調用約定。在 amd64 平臺,前九個函數參數使用以下寄存器:

另外以下寄存器用于特殊用途:

后兩個函數一個 Go 代碼一個 C 代碼,Go 調用 C 的情況下,遵循 System V AMD64 調用約定,在 Linux 平臺上使用以下寄存器作為前六個參數:

理解了 Go 和 C 的調用約定之后,再來看整個調用鏈的代碼:
TEXT racefuncenter<>(SB), NOSPLIT|NOFRAME, $0-0
MOVQ DX, BXx
MOVQ g_racectx(R14), RARG0 // RSI存放thr
MOVQ R11, RARG1 // RDI存放pc
MOVQ $__tsan_func_enter(SB), AX // AX存放__tsan_func_enter函數指針
CALL racecall<>(SB)
MOVQ BX, DX
RET
TEXT racecall<>(SB), NOSPLIT|NOFRAME, $0-0
...
CALL AX // 調用__tsan_func_enter函數指針
...
racefuncenter?將?g_racectx(R14)?和?R11?分別放入 C 調用約定的參數寄存器?RSI(RARG0)?和?RDI(RARG1),并將?__tsan_func_enter?放入 Go 調用約定的參數寄存器?RAX,然后調用?racecall,它進一步調用?__tsan_func_enter(RAX),這一系列操作大致相當于?__tsan_func_enter(g_racectx(R14), R11)。
不難看出,問題的根源在于?g_racectx(R14)?為 0。根據 Go 的調用約定 R14?存放當前 goroutine ,它不可能為 0 ,因此出問題的必然是 R14.racectx?字段為 0。為了避免無效努力,通過調試器 dlv 二次確認:
(dlv) p *(*runtime.g)(R14)
runtime.g {
racectx: 0,
...
}
那么為什么當前 R14.racectx 為 0?下一步看看 R14 具體的狀態。
3.3?協調程度
func newproc(fn *funcval) {
gp := getg()
pc := sys.GetCallerPC() #1
systemstack(func() {
newg := newproc1(fn, gp, pc, false, waitReasonZero) #2
...
})
}
經過排查,在代碼 #1 處,R14.racectx?是正常的,但到了代碼 #2 處,R14.racectx?就為空了,原因是?systemstack?被調用,它有一個切換協程的動作,具體如下:
// func systemstack(fn func())
TEXT runtime·systemstack(SB), NOSPLIT, $0-8
...
// 切換到g0協程
MOVQ DX, g(CX)
MOVQ DX, R14 // 設置 R14 寄存器
MOVQ (g_sched+gobuf_sp)(DX), SP
// 在g0協程上運行目標函數fn
MOVQ DI, DX
MOVQ 0(DI), DI
CALL DI
// 切換回原始協程
...
原來 systemstack 有一個切換協程的動作,會先把當前協程切換成 g0,然后執行 fn,最后恢復原始協程執行。
在 Go 語言的 GMP(Goroutine-Machine-Processor)調度模型中,每個系統級線程 M 都擁有一個特殊的 g0 協程,以及若干用于執行用戶任務的普通協程 g。g0 協程主要負責當前 M 上用戶 g 的調度工作。由于協程調度是不可搶占的,調度過程中會臨時切換到系統棧(system stack)上執行代碼。在系統棧上運行的代碼是隱式不可搶占的,并且垃圾回收器不會掃描系統棧。
到這里我們已經知道執行?newproc1?時的協程總是?g0,而?g0.racectx 是在?main?執行開始時被主動設置為 0,最終導致程序崩潰:
// src/runtime/proc.go#main
// The main goroutine.
func main() {
mp := getg().m
// g0 的 racectx 僅用于作為主 goroutine 的父級。
// 不應將其用作其他目的。
mp.g0.racectx = 0
...
四、解決方案
到這里基本上可以做一個總結了,程序崩潰的原因如下:
-
newproc1?中插入的?contextPropagate?調用 TakeSnapshot,而 TakeSnapshot 被?go build -race?強行在函數開始插入了?racefuncenter()?函數調用,該函數將使用?racectx。
-
newproc1?是在?g0?協程執行下運行,該協程的?racectx?字段是 0,最終導致崩潰。
一個解決辦法是給 TakeSnapshot 加上 Go 編譯器的特殊指令?//go:norace,該指令需緊跟在函數聲明后面,用于指定該函數的內存訪問將被競態檢測器忽略,Go 編譯器將不會強行插入 racefuncenter()調用。
五、疑惑一
runtime.newproc1?中不只調用了我們注入的 contextPropagate,還有其他函數調用,為什么這些函數沒有被編譯器插入?race?檢查的代碼(如?racefuncenter)?
經過排查后發現,Go 編譯器會特殊處理?runtime?包,針對?runtime?包中的代碼設置?NoInstrument?標志,從而跳過生成?race?檢查的代碼:
// /src/cmd/internal/objabi/pkgspecial.go
var pkgSpecialsOnce = sync.OnceValue(func() map[string]PkgSpecial {
...
for _, pkg := range runtimePkgs {
set(pkg, func(ps *PkgSpecial) {
ps.Runtime = true
ps.NoInstrument = true
})
}
...
})
六、疑惑二
理論上插入?//go:norace?之后問題應該得到解決,但實際上程序還是發生了崩潰。經過排查發現,TakeSnapShot?中有 map 初始化和 map 循環操作,這些操作會被編譯器展開成?mapinititer()?等函數調用。這些函數直接手動啟用了競態檢測器,而且無法加上?//go:norace:
func mapiterinit(t *abi.SwissMapType, m *maps.Map, it *maps.Iter) {
if raceenabled && m != nil {
// 主動的race檢查
callerpc := sys.GetCallerPC()
racereadpc(unsafe.Pointer(m), callerpc, abi.FuncPCABIInternal(mapiterinit))
}
...
}
對此問題的解決辦法是在 newproc1 注入的代碼里面,避免使用 map 數據結構。
七、總結
以上就是 Go 自動插樁工具在使用?go build -race?時出現崩潰的分析全過程。通過對崩潰內容和調用鏈的排查,我們找到了產生問題的根本原因以及相應的解決方案。這將有助于我們在理解運行時機制的基礎上,更加謹慎地編寫注入到運行時的代碼。
參考鏈接
[01]?Go 自動插樁開源項目
https://github.com/alibaba/opentelemetry-go-auto-instrumentation
[02]?阿里云 ARMS Go Agent 商業版
https://help.aliyun.com/zh/arms/tracing-analysis/monitor-go-applications/
[03]?Go 競態檢查
https://go.dev/doc/articles/race_detector