从汇编到运行时,抽丝剥茧分析Go应用崩溃
导读:这篇文章分析了Go编译时插桩工具导致go build -race竞态检测产生崩溃的原因。在接下来的部分,我们从汇编代码出发,层层抽丝剥茧,直到Go编译器实现,深入分析出应用崩溃根因并给出解决方案。
不久前,阿里云 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
竞态检查的代码,因此出现崩溃的现象是非预期的,所以我们花了一些时间排查这个崩溃问题,崩溃的堆栈信息如下:
可以看到崩溃源于 __tsan_func_enter
,而引发该问题的关键点是 runtime.contextPropagate
。我们的工具在 runtime.newproc1
函数的开头插入了以下代码:
TakeSnapShot
被 Go 编译器在函数入口和出口分别注入了 racefuncenter()
和 racefuncexit()
,最终调用 __tsan_func_enter
导致崩溃。由此确定崩溃问题确实是我们的注入代码导致的,继续深入排查。
排查过程
崩溃根源
使用 objdump
查看 __tsan_func_enter
的源码,看到它接收两个函数参数,出错的地方是第一行 mov 0x10(%rdi),%rdx
,它约等于 rdx = *(rdi + 0x10)
。打印寄存器后发现 rdi = 0
,根据调用约定,rdi
存放的是第一个函数参数,因此这里的问题就是函数第一个参数 thr
为 0。
那么第一个参数 thr
是谁传进来的呢?接着往上分析调用链。
调用链分析
出错的整个调用链是 racefuncenter(Go) -> racecall(Go) -> __tsan_func_enter(C)
。需要注意的是,前两个函数都是 Go 代码,Go 调用 Go 遵循 Go 的调用约定。在 amd64 平台,前九个函数参数使用以下寄存器:
RAX | RBX | RCX | RDI | RSI | R8 | R9 | R10 | R11 |
---|
另外以下寄存器用于特殊用途:
RSP | 栈顶寄存器 |
---|---|
RBP | 栈基址寄存器 |
RDX | 闭包上下文寄存器 |
R12 | 自由使用 |
R13 | 自由使用 |
R14 | 当前goroutine |
R15 | GOT符号表 |
X15 | 零值 |
后两个函数一个Go代码一个C代码,Go 调用 C 的情况下,遵循 System V AMD64 调用约定,在 Linux 平台上使用以下寄存器作为前六个参数:
RDI | RSI | RDX | RCX | R8 | R9 |
---|
理解了Go和C的调用约定之后,再来看整个调用链的代码:
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
二次确认:
那么为什么当前R14.racectx
为0?下一步看看R14具体的状态。
协程调度
经过排查,在代码 #1 处,R14.racectx
是正常的,但到了代码 #2 处,R14.racectx
就为空了,原因是 systemstack
被调用,它有一个切换协程的动作,具体如下:
原来systemstack
有一个切换协程的动作,会先把当前协程切换成g0,然后执行fn,最后恢复原始协程执行。
在 Go 语言的 GMP(Goroutine-Machine-Processor)调度模型中,每个系统级线程 M 都拥有一个特殊的g0 协程,以及若干用于执行用户任务的普通协程 g。g0 协程主要负责当前 M 上用户 g 的调度工作。由于协程调度是不可抢占的,调度过程中会临时切换到系统栈(system stack)上执行代码。在系统栈上运行的代码是隐式不可抢占的,并且垃圾回收器不会扫描系统栈。
到这里我们已经知道执行 newproc1
时的协程总是 g0
,而 g0.racectx
是在 main
执行开始时被主动设置为 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
检查的代码:
疑惑2
理论上插入 //go:norace
之后问题应该得到解决,但实际上程序还是发生了崩溃。经过排查发现,TakeSnapShot
中有 map 初始化和 map 循环操作,这些操作会被编译器展开成 mapinititer()
等函数调用。这些函数直接手动启用了竞态检测器,而且无法加上 //go:norace
:
对此问题的解决办法是在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