阿里妹導讀
這篇文章分析了Go編譯時插樁工具導致go build -race競態檢測產生崩潰的原因。
問題描述
近期,我們收到使用者反饋,使用otel go build -race替代正常的go build -race命令後,編譯生成的程式會導致崩潰。-race[3]是Go編譯器的一個引數,用於檢測資料競爭(data race)問題。透過為每個變數的訪問新增額外檢查,確保多個 goroutine 不會以不安全方式同時訪問這些變數。
理論上,我們的工具不應影響-race競態檢查的程式碼,因此出現崩潰的現象是非預期的,所以我們花了一些時間排查這個崩潰問題,崩潰的堆疊資訊如下:
(gdb) bt
#00x000000000041e1c0 in __tsan_func_enter ()
#10x00000000004ad05ain 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()
....
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{} {
...
}
排查過程
崩潰根源
使用 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: 488b5710 mov 0x10(%rdi),%rdx
41e1c4: 488d 4208 lea 0x8(%rdx),%rax
41e1c8: a9 f0 0f0000 test $0xff0,%eax
...
呼叫鏈分析
出錯的整個呼叫鏈是 racefuncenter(Go) -> racecall(Go) -> __tsan_func_enter(C)。需要注意的是,前兩個函式都是 Go 程式碼,Go 函式呼叫 Go 函式遵循 Go 的呼叫約定。在 amd64 平臺,前九個函式引數使用以下暫存器:



理解了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函式指標
...
不難看出,問題的根源在於 g_racectx(R14) 為 0。根據 Go 的呼叫約定R14 存放當前 goroutine ,它不可能為 0 ,因此出問題的必然是R14.racectx 欄位為 0。為了避免無效努力,透過偵錯程式dlv二次確認:
(dlv) p *(*runtime.g)(R14)
runtime.g {
racectx: 0,
...
}
協程排程
func newproc(fn *funcval){
gp := getg()
pc := sys.GetCallerPC() #1
systemstack(func() {
newg := newproc1(fn, gp, pc, false, waitReasonZero) #2
...
})
}
// 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
// 切換回原始協程
...
在 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()呼叫。
疑惑1
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
})
}
...
})
疑惑2
理論上插入 //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))
}
...
}
總結
以上就是 Go 自動插樁工具在使用 go build -race 時出現崩潰的分析全過程。透過對崩潰內容和呼叫鏈的排查,我們找到了產生問題的根本原因以及相應的解決方案。這將有助於我們在理解執行時機制的基礎上,更加謹慎地編寫注入到執行時的程式碼。
最後誠邀大家試用我們的Go自動插樁商業化產品[2],並加入我們的釘釘群(開源群:102565007776,商業化群:35568145),共同提升Go應用監控與服務治理能力。透過群策群力,我們相信能為Go開發者社群帶來更加優質的雲原生體驗。
[3] Go競態檢查 https://go.dev/doc/articles/race_detector
無代理ECS資料備份與高效環境搭建
基於快照提供資料保護和環境搭建,實現無代理且有效可靠的資料備份,同時可以快速克隆部署開發測試環境。
點選閱讀原文檢視詳情。