网站界面要素,房地产信息管理系统软件,网上做翻译兼职网站,旅游网站开发技术文档一、前言
不久前#xff0c;阿里云 ARMS 团队、编译器团队、MSE 团队携手合作#xff0c;共同发布并开源了 Go 语言的编译时自动插桩技术。该技术以其零侵入的特性#xff0c;为 Go 应用提供了与 Java 监控能力相媲美的解决方案。开发者只需将 go build 替换为新编译命令 o… 一、前言
不久前阿里云 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 (callpc4317632)#5 0x0000000000a247d8 in ../sdk/trace.(*traceContext).TakeSnapShot (tcoptimized out, ~r0...)#6 0x00000000004a2c25 in runtime.contextPropagate#7 0x0000000000480185 in runtime.newproc1.func1 () #8 0x00000000004800e2 in runtime.newproc1 (fn0xc00030a1f0, callergp0xc0000061e0, callerpc12379404, retVal00xc0002c8f00)#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 RETTEXT 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_schedgobuf_sp)(DX), SP// 在g0协程上运行目标函数fn MOVQ DI, DX MOVQ 0(DI), DI CALL DI// 切换回原始协程 ...原来 systemstack 有一个切换协程的动作会先把当前协程切换成 g0然后执行 fn最后恢复原始协程执行。 在 Go 语言的 GMPGoroutine-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.govar 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