1 行命令引发的 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 竞态检查的代码,因此出现崩溃的现象是非预期的,所以我们花了一些时间排查这个崩溃问题,崩溃的堆栈信息如下:

(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 ()....

可以看到崩溃源于 __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_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,而 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

相关推荐
蟹至之几秒前
类和对象(3)——继承:extends关键字、super关键字、protected关键字、final关键字
java·开发语言·继承·类和对象
万亿少女的梦1682 分钟前
基于PHP的校园兼职系统的设计与开发
开发语言·网络·数据库·爬虫·网络安全·php
米饭「」1 小时前
数据结构-栈和队列
java·开发语言·数据结构
向宇it1 小时前
【unity进阶篇】unity如何实现跨平台及unity最优最小包体打包方式(.NET、Mono和IL2CPP知识介绍)
开发语言·unity·c#·编辑器·游戏引擎·.net
code_shenbing1 小时前
C# 解析视频流播放全解析
开发语言·c#
我曾经是个程序员2 小时前
C#实现字符串反转的4种方法
开发语言·c#
Ai 编码助手2 小时前
使用 Go 语言生成样式美观的 PDF 文件
开发语言·golang·pdf
Allen200002 小时前
wow-agent---Day3 Zigent 智能代理开发框架
开发语言·人工智能·python
明月醉窗台2 小时前
C++ 之多线程相关总结
开发语言·c++·算法
计算机萍萍学姐2 小时前
基于springboot的考研资讯平台
java·spring boot·后端