1 行命令引发的Go应用崩溃

简介: 这篇文章分析了Go编译时插桩工具导致go build -race竞态检测产生崩溃的原因。

不久前,阿里云 ARMS 团队、编译器团队、MSE 团队携手合作,共同发布并开源了 Go 语言的编译时自动插桩技术。该技术以其零侵入的特性,为 Go 应用提供了与 Java 监控能力相媲美的解决方案。开发者只需将 go build 替换为新编译命令 otel go build,就能实现对 Go 应用的全面监控和治理。


1.问题描述

近期,我们收到用户反馈,使用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导致崩溃。由此确定崩溃问题确实是我们的注入代码导致的,继续深入排查。


2.排查过程

2.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 是谁传进来的呢?接着往上分析调用链。

2.2调用链分析

出错的整个调用链是 racefuncenter(Go) -> racecall(Go) -> __tsan_func_enter(C)。需要注意的是,前两个函数都是 Go 代码,Go 函数调用 Go 函数遵循 Go 的调用约定在 amd64 平台,前九个函数参数使用以下寄存器:

image.png

另外以下寄存器用于特殊用途:

image.png

后两个函数一个Go代码一个C代码,Go 调用 C 的情况下,遵循 System V AMD64 调用约定,在 Linux 平台上使用以下寄存器作为前六个参数:

image.png

理解了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函数指针
  ...

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具体的状态。


2.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
  ...

3.解决方案

到这里基本上可以做一个总结了,程序崩溃的原因如下:


  • newproc1 中插入的 contextPropagate 调用TakeSnapshot,而TakeSnapshotgo build -race 强行在函数开始插入了 racefuncenter() 函数调用,该函数将使用 racectx


  • newproc1 是在 g0 协程执行下运行,该协程的 racectx 字段是 0,最终导致崩溃。


一个解决办法是给TakeSnapshot加上 Go编译器的特殊指令 //go:norace,该指令需紧跟在函数声明后面,用于指定该函数的内存访问将被竞态检测器忽略,Go编译器将不会强行插入racefuncenter()调用。


4.疑惑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
        })
    }
    ...
})

5.疑惑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数据结构。


6.总结

以上就是 Go 自动插桩工具在使用 go build -race 时出现崩溃的分析全过程。通过对崩溃内容和调用链的排查,我们找到了产生问题的根本原因以及相应的解决方案。这将有助于我们在理解运行时机制的基础上,更加谨慎地编写注入到运行时的代码。


最后诚邀大家试用我们的Go自动插桩商业化产品[2],并加入我们的钉钉群(开源群:102565007776,商业化群:35568145),共同提升Go应用监控与服务治理能力。通过群策群力,我们相信能为Go开发者社区带来更加优质的云原生体验。



参考一

参考二

参考三




来源  |  阿里云开发者公众号

作者  |  青风

相关文章
|
14天前
|
供应链 监控 安全
对话|企业如何构建更完善的容器供应链安全防护体系
阿里云与企业共筑容器供应链安全
171332 12
|
17天前
|
供应链 监控 安全
对话|企业如何构建更完善的容器供应链安全防护体系
随着云计算和DevOps的兴起,容器技术和自动化在软件开发中扮演着愈发重要的角色,但也带来了新的安全挑战。阿里云针对这些挑战,组织了一场关于云上安全的深度访谈,邀请了内部专家穆寰、匡大虎和黄竹刚,深入探讨了容器安全与软件供应链安全的关系,分析了当前的安全隐患及应对策略,并介绍了阿里云提供的安全解决方案,包括容器镜像服务ACR、容器服务ACK、网格服务ASM等,旨在帮助企业构建涵盖整个软件开发生命周期的安全防护体系。通过加强基础设施安全性、技术创新以及倡导协同安全理念,阿里云致力于与客户共同建设更加安全可靠的软件供应链环境。
150295 32
|
25天前
|
弹性计算 人工智能 安全
对话 | ECS如何构筑企业上云的第一道安全防线
随着中小企业加速上云,数据泄露、网络攻击等安全威胁日益严重。阿里云推出深度访谈栏目,汇聚产品技术专家,探讨云上安全问题及应对策略。首期节目聚焦ECS安全性,提出三道防线:数据安全、网络安全和身份认证与权限管理,确保用户在云端的数据主权和业务稳定。此外,阿里云还推出了“ECS 99套餐”,以高性价比提供全面的安全保障,帮助中小企业安全上云。
201962 14
对话 | ECS如何构筑企业上云的第一道安全防线
|
2天前
|
机器学习/深度学习 自然语言处理 PyTorch
深入剖析Transformer架构中的多头注意力机制
多头注意力机制(Multi-Head Attention)是Transformer模型中的核心组件,通过并行运行多个独立的注意力机制,捕捉输入序列中不同子空间的语义关联。每个“头”独立处理Query、Key和Value矩阵,经过缩放点积注意力运算后,所有头的输出被拼接并通过线性层融合,最终生成更全面的表示。多头注意力不仅增强了模型对复杂依赖关系的理解,还在自然语言处理任务如机器翻译和阅读理解中表现出色。通过多头自注意力机制,模型在同一序列内部进行多角度的注意力计算,进一步提升了表达能力和泛化性能。
|
7天前
|
存储 人工智能 安全
对话|无影如何助力企业构建办公安全防护体系
阿里云无影助力企业构建办公安全防护体系
1252 8
|
7天前
|
人工智能 自然语言处理 程序员
通义灵码2.0全新升级,AI程序员全面开放使用
通义灵码2.0来了,成为全球首个同时上线JetBrains和VSCode的AI 程序员产品!立即下载更新最新插件使用。
1304 24
|
9天前
|
机器学习/深度学习 自然语言处理 搜索推荐
自注意力机制全解析:从原理到计算细节,一文尽览!
自注意力机制(Self-Attention)最早可追溯至20世纪70年代的神经网络研究,但直到2017年Google Brain团队提出Transformer架构后才广泛应用于深度学习。它通过计算序列内部元素间的相关性,捕捉复杂依赖关系,并支持并行化训练,显著提升了处理长文本和序列数据的能力。相比传统的RNN、LSTM和GRU,自注意力机制在自然语言处理(NLP)、计算机视觉、语音识别及推荐系统等领域展现出卓越性能。其核心步骤包括生成查询(Q)、键(K)和值(V)向量,计算缩放点积注意力得分,应用Softmax归一化,以及加权求和生成输出。自注意力机制提高了模型的表达能力,带来了更精准的服务。
|
7天前
|
消息中间件 人工智能 运维
1月更文特别场——寻找用云高手,分享云&AI实践
我们寻找你,用云高手,欢迎分享你的真知灼见!
590 23
1月更文特别场——寻找用云高手,分享云&AI实践
|
7天前
|
机器学习/深度学习 人工智能 自然语言处理
|
13天前
|
人工智能 自然语言处理 API
阿里云百炼xWaytoAGI共学课DAY1 - 必须了解的企业级AI应用开发知识点
本课程旨在介绍阿里云百炼大模型平台的核心功能和应用场景,帮助开发者和技术小白快速上手,体验AI的强大能力,并探索企业级AI应用开发的可能性。