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

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


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


问题描述

Cloud Native



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


排查过程

Cloud Native


崩溃根源

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


调用链分析


出错的整个调用链是 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 
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具体的状态。


协程调度


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


解决方案

Cloud Native


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

  • newproc1 中插入的 contextPropagate 调用TakeSnapshot,而TakeSnapshotgo build -race 强行在函数开始插入了 racefuncenter() 函数调用,该函数将使用 racectx
  • newproc1 是在 g0 协程执行下运行,该协程的 racectx 字段是 0,最终导致崩溃。

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


疑惑1

Cloud Native


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

Cloud Native


理论上插入 //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数据结构。


总结

Cloud Native


以上就是 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

相关文章
|
16天前
|
消息中间件 缓存 NoSQL
Redis各类数据结构详细介绍及其在Go语言Gin框架下实践应用
这只是利用Go语言和Gin框架与Redis交互最基础部分展示;根据具体业务需求可能需要更复杂查询、事务处理或订阅发布功能实现更多高级特性应用场景。
144 86
|
4月前
|
人工智能 监控 安全
Go通道机制与应用详解
本文全面解析了Go语言中的通道(Channel),从基础概念到高级应用,涵盖创建、操作、垃圾回收及实际场景使用。通道作为Go并发模型的核心,支持协程间安全高效的数据通信与同步。文章介绍了无缓冲和有缓冲通道的特性,以及发送、接收、关闭等操作,并探讨了`select`语句、超时处理、遍历通道等高级用法。此外,还深入分析了通道的垃圾回收机制,包括引用计数、生命周期管理和循环引用问题。最后通过数据流处理、任务调度和状态监控等实例,展示了通道在实际开发中的广泛应用。理解通道不仅有助于构建高并发系统,还能优化资源管理,提升程序性能。
156 31
|
7月前
|
运维 监控 算法
监控局域网其他电脑:Go 语言迪杰斯特拉算法的高效应用
在信息化时代,监控局域网成为网络管理与安全防护的关键需求。本文探讨了迪杰斯特拉(Dijkstra)算法在监控局域网中的应用,通过计算最短路径优化数据传输和故障检测。文中提供了使用Go语言实现的代码例程,展示了如何高效地进行网络监控,确保局域网的稳定运行和数据安全。迪杰斯特拉算法能减少传输延迟和带宽消耗,及时发现并处理网络故障,适用于复杂网络环境下的管理和维护。
|
2月前
|
Java Shell Maven
【Azure Container App】构建Java应用镜像时候遇无法编译错误:ERROR [build 10/10] RUN ./mvnw.cmd dependency:go-offline -B -Dproduction package
在部署Java应用到Azure Container App时,构建镜像过程中出现错误:“./mvnw.cmd: No such file or directory”。尽管项目根目录包含mvnw和mvnw.cmd文件,但依然报错。问题出现在Dockerfile构建阶段执行`./mvnw dependency:go-offline`命令时,系统提示找不到可执行文件。经过排查,确认是mvnw文件内容异常所致。最终通过重新生成mvnw文件解决该问题,镜像成功构建。
|
5月前
|
监控 Java Go
无感改造,完美监控:Docker 多阶段构建 Go 应用无侵入观测
本文将介绍一种基于 Docker 多阶段构建的无侵入 Golang 应用观测方法,通过此方法用户无需对 Golang 应用源代码或者编译指令做任何改造,即可零成本为 Golang 应用注入可观测能力。
311 85
|
2月前
|
人工智能 Go
GO语言之泛型应用
本文介绍了Go语言中泛型的使用,包括为何引入泛型、泛型语法详解以及如何自定义约束。通过实例展示了泛型在简化代码、提高复用性方面的优势,并演示了泛型在slice、指针、map等数据类型中的应用。
|
3月前
|
测试技术 程序员 Go
Go语言测试简明指南:深度解读go test命令
总的来说,go test是 Go 语言中一个强而有力的工具,每个 Go 程序员都应该掌握并把它融入到日常的开发和调试过程中。就像是一个眼镜过滤出的太阳,让我们在宽阔的代码海洋中游泳,而不是淹没。用好它,让我们的代码更健壮,让我们的生产力更强效。
222 23
|
2月前
|
存储 监控 算法
公司员工泄密防护体系中跳表数据结构及其 Go 语言算法的应用研究
在数字化办公中,企业面临员工泄密风险。本文探讨使用跳表(Skip List)数据结构优化泄密防护系统,提升敏感数据监测效率。跳表以其高效的动态数据处理能力,为企业信息安全管理提供了可靠技术支持。
51 0
|
7月前
|
存储 缓存 监控
企业监控软件中 Go 语言哈希表算法的应用研究与分析
在数字化时代,企业监控软件对企业的稳定运营至关重要。哈希表(散列表)作为高效的数据结构,广泛应用于企业监控中,如设备状态管理、数据分类和缓存机制。Go 语言中的 map 实现了哈希表,能快速处理海量监控数据,确保实时准确反映设备状态,提升系统性能,助力企业实现智能化管理。
115 3
|
NoSQL Java 测试技术
Go应用单元测试实践
Go应用单元测试搭建
Go应用单元测试实践