Go编译时插桩工具与-race竞态检测冲突导致应用崩溃,问题根源在于g0协程的racectx为0,解决方案为在相关函数添加//go:norace指令并避免使用map。
原文标题:1 行命令引发的Go应用崩溃
原文作者:阿里云开发者
冷月清谈:
问题出现在使用otel go build -race命令编译后,程序在运行时崩溃。经过调试发现,崩溃源于__tsan_func_enter,关键点是runtime.contextPropagate。
otel go build工具会在runtime.newproc1函数开头插入一段代码,这段代码调用了contextPropagate函数,进而调用TakeSnapShot函数。由于开启了竞态检测,Go编译器会在TakeSnapShot函数的入口和出口分别插入racefuncenter()和racefuncexit()函数调用,最终调用__tsan_func_enter导致崩溃。
崩溃的根本原因是g_racectx(R14)的值为0。具体来说,newproc1函数是在g0协程下执行的,而g0协程的racectx字段在程序启动时被主动设置为0,因此导致__tsan_func_enter函数出错。
最终的解决方案是在TakeSnapShot函数声明后加上//go:norace指令,使编译器忽略该函数的竞态检测,避免插入racefuncenter()调用。但是,由于TakeSnapShot函数中使用了map数据结构,而map相关的操作会被编译器展开成mapinititer()等函数调用,这些函数会主动启用竞态检测器,且无法添加//go:norace指令。因此,还需要避免在TakeSnapShot函数中使用map数据结构。
怜星夜思:
2、除了文中提到的 //go:norace 指令,还有其他方法可以避免这种编译时插桩和竞态检测的冲突吗?
3、文章中提到的Go自动插桩技术,除了监控,还有什么其他应用场景?
原文内容
阿里妹导读
这篇文章分析了Go编译时插桩工具导致go build -race竞态检测产生崩溃的原因。
问题描述
近期,我们收到用户反馈,使用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 () ....
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: 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 ...
调用链分析
出错的整个调用链是 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数据备份与高效环境搭建
基于快照提供数据保护和环境搭建,实现无代理且有效可靠的数据备份,同时可以快速克隆部署开发测试环境。
点击阅读原文查看详情。