1行命令引發的Go應用崩潰

阿里妹導讀
這篇文章分析了Go編譯時插樁工具導致go build -race競態檢測產生崩潰的原因。
不久前,阿里雲 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#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()....
可以看到崩潰源於 __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導致崩潰。由此確定崩潰問題確實是我們的注入程式碼導致的,繼續深入排查。
排查過程
崩潰根源
使用 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),%rdx41e1c4:  488d 4208            lea    0x8(%rdx),%rax41e1c8:  a9 f0 0f0000         test   $0xff0,%eax  ...
那麼第一個引數 thr 是誰傳進來的呢?接著往上分析呼叫鏈。
呼叫鏈分析
出錯的整個呼叫鏈是 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  RETTEXT  racecall<>(SB), NOSPLIT|NOFRAME, $0-0  ...  CALL  AX  // 呼叫__tsan_func_enter函式指標  ...
racefuncenterg_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具體的狀態。
協程排程
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,而TakeSnapshotgo 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.govar 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))  }    ...}
對此問題的解決辦法是在newproc1注入的程式碼裡面,避免使用map資料結構。
總結
以上就是 Go 自動插樁工具在使用 go build -race 時出現崩潰的分析全過程。透過對崩潰內容和呼叫鏈的排查,我們找到了產生問題的根本原因以及相應的解決方案。這將有助於我們在理解執行時機制的基礎上,更加謹慎地編寫注入到執行時的程式碼。
最後誠邀大家試用我們的Go自動插樁商業化產品[2],並加入我們的釘釘群(開源群:102565007776,商業化群:35568145),共同提升Go應用監控與服務治理能力。透過群策群力,我們相信能為Go開發者社群帶來更加優質的雲原生體驗。
[1] Go自動插樁開源專案:https://github.com/alibaba/opentelemetry-go-auto-instrumentation
[2] 阿里雲ARMS Go Agent商業版:https://help.aliyun.com/zh/arms/tracing-analysis/monitor-go-applications/
[3] Go競態檢查 https://go.dev/doc/articles/race_detector
無代理ECS資料備份與高效環境搭建
基於快照提供資料保護和環境搭建,實現無代理且有效可靠的資料備份,同時可以快速克隆部署開發測試環境。   
點選閱讀原文檢視詳情。

相關文章