Go 并发的七种数据竞争陷阱:Uber 从 1100 个 Race 修复中总结出的经验
本文是对 Data Race Patterns in Go 的整理与翻译。内容结构概览这篇文章主要讲 Uber 在大规模 Go 代码库中观察到的数据竞争模式。全文可以分为几部分为什么 Go 项目更容易暴露大量并发问题goroutine 很轻量、创建成本低、服务中并发度很高。什么是 data race多个 goroutine 访问同一份数据至少一个是写并且它们之间没有明确的 happens-before 顺序。Uber 的背景在大约 6 个月内Uber 使用动态数据竞争检测系统发现了约 2000 个 data race其中约 1100 个已经被开发者修复。七类 Go 中常见的数据竞争模式goroutine 中闭包按引用捕获自由变量slice 的元信息复制与 append 并发修改Go 内置 map 非线程安全值传递与指针传递混淆尤其是sync.Mutexchannel 消息传递与共享内存混用sync.WaitGroup的Add/Done放置位置错误并行 table-driven test 导致测试代码或业务代码出现 race。Uber 对已修复 data race 的统计结论。这项经验的局限性以及普通 Go 开发者应该如何吸收这些教训。一、为什么 Go 里的 data race 值得单独拿出来讲Go 的并发模型非常顺手。想让一个函数异步执行只需要在调用前面加一个go关键字godoSomething()这个调用会启动一个 goroutine。相比传统线程goroutine 更轻量所以 Go 开发者很容易在服务里大量使用它。比如发起 RPC、读写外部存储、做后台任务、批量处理数据时大家会自然地把原本串行的逻辑拆成多个 goroutine。这种便利性带来的问题是并发变多以后出错的方式也变多了。尤其当多个 goroutine 通过共享内存交换数据时如果没有锁、channel、atomic 或其他同步手段建立明确顺序就可能出现 data race。data race 的定义可以简单理解为两个或更多 goroutine 访问同一份数据其中至少一个访问是写操作并且这些访问之间没有明确的先后顺序。这类 bug 很麻烦因为它通常不是每次都能复现。一次运行正常下一次运行崩掉本地跑没问题上线后偶发异常加一行日志以后问题消失。这就是并发 bug 最难受的地方它依赖调度时机而调度时机通常不受开发者控制。Uber 的场景更典型。Uber 大量使用 Go 编写微服务Go monorepo 规模达到数千万行代码服务数量也非常多。在这样的工程体量下任何语言层面的“易错点”都会被放大。Uber 通过动态数据竞争检测系统在 6 个月左右的时间里发现了约 2000 个 data race其中超过 1000 个被修复。随后他们分析这些已修复案例总结出 Go 中一些特别容易导致 data race 的模式。这篇文章的价值就在这里它不是泛泛而谈“并发要加锁”而是把真实大型 Go 工程中反复出现的坑分类摆出来。二、模式一goroutine 闭包按引用捕获变量Go 支持闭包。闭包可以访问外层函数里的变量这些变量通常被称为自由变量。关键点在于Go 的闭包会按引用捕获这些变量。这本身不是问题问题出在 goroutine 上。闭包如果只是同步执行变量读写的顺序还比较容易理解但闭包一旦被go func() { ... }()放到另一个 goroutine 里执行外层函数和闭包之间就可能同时访问同一个变量。1.1 循环变量捕获典型代码如下for_,job:rangejobs{gofunc(){process(job)}()}很多人第一次看这段代码时会以为每个 goroutine 都会拿到当前循环对应的job。但在早期 Go 版本中循环变量并不是每次循环都创建一个全新的变量而是同一个变量在每轮循环中被反复赋值。闭包捕获的是变量本身而不是变量在某一轮循环里的值。于是就可能发生这种情况第一轮循环启动了 goroutine但 goroutine 还没来得及执行主 goroutine 已经进入第二轮循环把job改成了下一个元素此时第一轮 goroutine 再去读job读到的就不是它原本应该处理的那个任务。更重要的是主 goroutine 在写job子 goroutine 在读job这就是 data race。传统写法是显式创建一个局部副本for_,job:rangejobs{job:jobgofunc(){process(job)}()}或者把变量作为参数传进去for_,job:rangejobs{gofunc(j Job){process(j)}(job)}需要注意的是Go 1.22 对循环变量语义做了重要修改对于声明为go 1.22或更高版本的模块循环变量会更接近“每轮循环一个新变量”的直觉从而修复很多这类闭包捕获问题。也就是说Uber 原文中的这个经典坑在 Go 1.22 之后已经被语言层面缓解了。但在维护旧项目、旧go.mod版本或者读历史代码时仍然要知道这个背景。1.2err变量捕获Go 代码中最常见的变量名之一就是err。Go 鼓励多返回值很多函数会返回实际结果和错误对象x,err:foo()iferr!nil{returnerr}在一个较长的函数里开发者往往会反复复用同一个errx,err:foo()iferr!nil{returnerr}gofunc(){y,err:bar()_yiferr!nil{// handle error}}()z,err:baz()iferr!nil{returnerr}_z这段示意代码里如果 goroutine 内部不是:创建新的局部err而是对外层err赋值那么 goroutine 内部写err和外层函数后续写err就可能并发发生。即使只是读写错误对象也仍然是同一个变量的并发访问。这类问题隐蔽的原因在于err太常见了开发者很容易下意识复用它。尤其当闭包比较长、逻辑比较复杂时很难一眼看出它捕获了外层的err。更稳妥的做法是goroutine 内部使用自己的局部变量gofunc(){y,localErr:bar()_yiflocalErr!nil{// handle localErr}}()如果 goroutine 的错误需要传回主流程可以用 channel、errgroup.Group或者受锁保护的共享结构而不是让多个 goroutine 同时写同一个外层err。1.3 命名返回值捕获Go 还有一种语法糖命名返回值。funcf()(resultint){result10return}这里的result其实是函数作用域里的一个变量。裸return会返回当前result的值。问题是命名返回值也可能被闭包捕获funccalculate()(resultint){result10gofunc(){use(result)}()return20}很多人会觉得return 20只是返回常量20似乎没有写result。但从编译器语义看return 20等价于把20赋给命名返回变量result然后返回。于是 goroutine 里可能正在读result函数返回路径又在写resultdata race 就出现了。还有一种更隐蔽的情况是defer与命名返回值结合funcredeem(req Request)(resp Response,errerror){deferfunc(){resp,errfinalize(req,err)}()errcheck(req)gofunc(){process(req,err!nil)}()return}defer会在函数返回前执行。这里 goroutine 捕获了命名返回值err而 deferred function 又会在返回阶段写err。如果 goroutine 同时读取err就可能产生 race。这类 bug 特别难排查因为从代码表面看goroutine 启动之后似乎没有明显的err写入真正的写入藏在defer和命名返回值语义里。三、模式二slice 看起来加锁了实际还在 raceGo 的 slice 不是简单的数组。它内部可以粗略理解为三部分typesliceHeaderstruct{data*Tlenintcapint}也就是指向底层数组的指针、当前长度、当前容量。append可能会修改这些元信息。比如容量不够时runtime 会分配新的底层数组把旧元素复制过去然后更新 slice header。因此slice 的并发访问不仅仅是“底层数组元素”的问题还包括 slice header 本身的并发读写。看一个示意代码funcprocessAll(ids[]string){varresults[]stringvarmu sync.Mutex appendSafe:func(vstring){mu.Lock()resultsappend(results,v)mu.Unlock()}for_,id:rangeids{gofunc(idstring,snapshot[]string){res:query(id)appendSafe(res)_snapshot}(id,results)}}开发者可能认为append已经放进mu.Lock()和mu.Unlock()之间所以安全了。但问题出在这一句}(id,results)把results作为参数传给 goroutine 时会复制 slice header。这个复制动作发生在加锁之外。与此同时之前启动的某个 goroutine 可能正在appendSafe里执行append修改同一个results的 header。于是一个 goroutine 在写 slice header另一个 goroutine 在读并复制 slice headerrace 仍然存在。这个坑的本质是slice 是引用类型但它又不是单纯的指针。传递 slice 时底层数组不会被整体复制但 slice header 会被复制。很多人只记住了“slice 是引用类型”却忽略了 header 也是一份普通值。修复思路通常有几种第一不要在没有必要的情况下把正在被并发修改的 slice 作为参数传来传去。第二如果确实需要共享 slice就要把所有读写 slice header 的地方都纳入同一把锁保护范围。第三可以把结果写入 channel让单独的 goroutine 汇总结果避免多个 goroutine 直接操作同一个 slice。第四如果每个 goroutine 写入不同下标可以预先分配固定长度的 slice然后只写各自下标但这也要确保不会并发 append也不会并发修改 slice header。四、模式三Go 内置 map 不是线程安全的Go 的 map 很好用m:make(map[string]error)m[id]err也正因为它太好用开发者很容易把它当成“可以按 key 独立访问”的结构。比如funcprocess(ids[]string)map[string]error{errs:make(map[string]error)for_,id:rangeids{gofunc(idstring){iferr:handle(id);err!nil{errs[id]err}}(id)}returnerrs}看起来每个 goroutine 写的是不同的 key好像互不干扰。但 Go 的 map 是哈希表不是数组。不同 key 不代表底层结构互不影响。map 插入、删除、扩容、桶迁移等操作都可能修改内部结构。只要多个 goroutine 同时访问同一个 map并且至少一个是写就可能 data race甚至直接触发运行时错误fatal error: concurrent map writesUber 的观察是Go 中 map 相关 race 很常见原因有两个。第一map 是语言内置结构使用频率非常高。相比 Java 里get/put这类 API 调用Go 的m[k]写法太自然了开发者会更频繁地使用 map。第二m[k]的语法看起来很像数组访问让人误以为不同 key 就像不同下标一样可以并发写。但 map 是稀疏哈希结构访问一个 key 可能涉及桶、溢出桶、扩容状态等共享元信息。常见修复方式包括varmu sync.Mutex errs:make(map[string]error)gofunc(idstring){iferr:handle(id);err!nil{mu.Lock()errs[id]err mu.Unlock()}}(id)或者使用sync.Map但sync.Map不是“所有 map 的默认替代品”。它更适合读多写少、key 集合相对稳定、或者多个 goroutine 访问不同 key 且生命周期特殊的场景。普通业务代码里一把sync.Mutex保护普通 map 往往更清晰。另一种方式是把 map 所有权集中到一个 goroutinetypeitemErrstruct{idstringerrerror}ch:make(chanitemErr)gofunc(){fore:rangech{errs[e.id]e.err}}()这就是 Go 里常说的思路不要通过共享内存来通信而要通过通信来共享内存。但如果采用这种方式就要坚持所有 map 写入都走这个 channel不要一边 channel 一边直接写 map。五、模式四值传递和指针传递混淆导致锁失效Go 里很多东西是值类型比如 struct。sync.Mutex本身也是一个 struct也就是说它是值类型。问题来了如果你把 mutex 按值传给函数其实是复制了一把锁。varcounterintfunccritical(mu sync.Mutex){mu.Lock()countermu.Unlock()}funcmain(){varmu sync.Mutexgocritical(mu)gocritical(mu)}这段代码看上去有锁但实际上两个 goroutine 各自拿到的是mu的副本。它们锁住的是两把不同的锁自然无法保护同一个counter。正确写法应该传指针funccritical(mu*sync.Mutex){mu.Lock()countermu.Unlock()}funcmain(){varmu sync.Mutexgocritical(mu)gocritical(mu)}这个问题之所以容易出现是因为 Go 在方法调用语法上做了很多透明转换。比如mu.Lock()看起来都是一样的但Lock方法的 receiver 实际上是*Mutex。当你在一个 mutex 值上调用Lock()时编译器会自动取地址让调用成立。这种便利在多数时候很好但也会让开发者忽略“这里到底是在操作原对象还是对象副本”。类似问题不只发生在sync.Mutex还可能发生在包含 mutex 的结构体上typeCachestruct{mu sync.Mutex mmap[string]string}func(c Cache)Set(k,vstring){c.mu.Lock()deferc.mu.Unlock()c.m[k]v}这里Set的 receiver 是值类型Cache调用时会复制整个Cache包括里面的sync.Mutex。虽然map底层仍然指向同一份数据但 mutex 已经被复制了锁就不再是同一把锁。更合理的写法是func(c*Cache)Set(k,vstring){c.mu.Lock()deferc.mu.Unlock()c.m[k]v}经验规则很简单包含sync.Mutex、sync.RWMutex、sync.WaitGroup、atomic类型的结构体通常不要复制。方法 receiver 通常应该用指针。Go 官方文档里也经常提醒这些同步原语在第一次使用后不应被复制。六、模式五channel 和共享内存混用顺序关系变复杂Go 里 channel 可以建立 happens-before 关系。一次发送发生在对应接收之前ch-value v:-ch如果所有数据都通过 channel 传递顺序通常比较清晰。但真实项目里经常会出现 channel 只负责“通知完成”真正的数据却写在共享字段里。比如一个 Future 的简化实现typeFuturestruct{chchanstruct{}response Response errerrorfnfunc()(Response,error)}func(f*Future)Start(){gofunc(){resp,err:f.fn()f.responseresp f.errerr f.ch-struct{}{}}()}func(f*Future)Wait(ctx context.Context)error{select{case-f.ch:returnnilcase-ctx.Done():f.errErrCancelledreturnErrCancelled}}正常完成时Start里的 goroutine 写入f.response和f.err然后发送 channelWait收到 channel 以后再继续这条路径上有同步关系。但如果ctx超时Wait会走另一条分支case-ctx.Done():f.errErrCancelled这时后台 goroutine 可能也正在执行f.errerr两个 goroutine 同时写f.err没有锁没有 channel 同步也没有 atomic于是就产生 data race。更糟的是如果Wait因为超时提前返回后台 goroutine 后续执行f.ch-struct{}{}可能没有接收者导致 goroutine 卡住形成 goroutine leak。这个例子说明channel 本身不是魔法。channel 只能为实际发生的 send/receive 建立顺序。如果某条分支没有经过 channel而是直接读写共享字段那这部分仍然需要额外同步。更清晰的设计是要么把结果完整地通过 channel 传递typeresultstruct{resp Response errerror}ch:make(chanresult,1)要么用 mutex 保护Future内部状态。不要让 channel 负责一半同步又让共享字段承担另一半数据传递。七、模式六sync.WaitGroup的Add/Done放错位置sync.WaitGroup是 Go 中非常常用的群组同步工具。它的三个核心方法是wg.Add(n)wg.Done()wg.Wait()语义也很简单Add增加等待计数Done减少计数Wait等到计数归零。问题在于WaitGroup的参与者数量是动态增加的。这给了开发者灵活性也带来了误用空间。6.1 在 goroutine 内部调用Add错误示意funcprocess(ids[]int)[]Result{varwg sync.WaitGroup results:make([]Result,len(ids))fori:rangeids{gofunc(iint){wg.Add(1)deferwg.Done()results[i]handle(ids[i])}(i)}wg.Wait()returnresults}这段代码的问题是wg.Add(1)放在 goroutine 内部。主 goroutine 启动子 goroutine 后可能马上执行到wg.Wait()。如果此时某些子 goroutine 还没来得及执行wg.Add(1)那么Wait看到的计数可能仍然是 0于是直接返回。随后主 goroutine 开始读results而子 goroutine 还在写resultsdata race 就出现了。正确写法是先Add再启动 goroutinefori:rangeids{wg.Add(1)gofunc(iint){deferwg.Done()results[i]handle(ids[i])}(i)}wg.Wait()这条规则非常重要Add应该发生在启动 goroutine 之前。不要让参与者自己“报名”因为主线程可能已经开始等待甚至已经等完了。6.2defer顺序导致Done过早执行Go 的defer是后进先出也就是最后注册的 defer 最先执行。看一个示意gofunc(){defercleanup()deferwg.Done()doWork()}()执行顺序是doWork()结束先执行wg.Done()再执行cleanup()。如果cleanup()里还会写某个共享变量比如locationErr主 goroutine 在wg.Wait()返回后立刻读取locationErr就可能和cleanup()里的写操作并发发生。也就是说wg.Done()并不一定意味着 goroutine 内所有逻辑都完成了。它只意味着计数减一。如果你把Done放在某些清理逻辑之前主 goroutine 就可能过早继续执行。更安全的原则是让wg.Done()成为 goroutine 真正结束前的最后动作。实践中通常可以把它作为第一个 defer 注册gofunc(){deferwg.Done()defercleanup()doWork()}()由于 defer 后进先出这里实际执行时会先cleanup()最后wg.Done()。八、模式七并行 table-driven test 引发 raceGo 项目里常见 table-driven testfuncTestSomething(t*testing.T){cases:[]struct{namestringinputstring}{{case1,a},{case2,b},}for_,tc:rangecases{t.Run(tc.name,func(t*testing.T){got:doSomething(tc.input)_got})}}为了提升测试速度开发者可能会加入t.Parallel()比如for_,tc:rangecases{tc:tc t.Run(tc.name,func(t*testing.T){t.Parallel()got:doSomething(tc.input)_got})}并行测试的问题通常有两类。第一类是测试代码自身共享了状态。比如多个 subtest 共用同一个 mock、同一个全局变量、同一个临时目录、同一个内存 map、同一个 fake clock。串行跑时没问题一旦t.Parallel()这些共享状态就被并发访问。第二类是业务代码本身不是线程安全的。原本这个 API 的设计假设就是串行调用因此内部没有锁。测试并行化以后相当于用并发方式调用了一个非并发安全 API于是暴露出 race。这类 race 很容易被误判。开发者可能会说“线上不会这么并发调用这是测试写错了。”也可能反过来发现“测试只是把真实并发场景提前暴露出来业务代码确实不安全。”判断标准应该回到 API 契约这个对象、函数、组件到底承诺不承诺并发安全如果承诺并发安全那测试暴露出的 race 就是业务 bug如果不承诺并发安全那测试需要隔离状态不能并发共享同一个对象。九、Uber 的统计结论哪些 race 最常见Uber 对 1000 多个已修复 data race 做了人工分类。不同标签之间不是互斥的一个 bug 可能同时属于多个类别。从 Go 语言特性和惯用法相关的类别看比较突出的包括类别数量goroutine 中意外按引用捕获变量121其中捕获err变量50其中捕获循环变量48其中捕获命名返回值4并发访问 slice391并发访问 map38值传递 / 指针传递混淆38channel 消息传递与共享内存混用25WaitGroup使用错误24并行 table-driven test139从语言无关的并发错误看最常见的是类别数量缺失锁或只在部分路径加锁470在读锁保护区内修改共享数据2违反线程安全 API 的使用约定369修改全局变量24atomic 操作使用不完整40语句顺序错误5多组件复杂交互6metrics / logging 相关 race18通过移除并发修复26通过禁用测试修复3通过大规模重构修复30这里最值得注意的是缺失锁或锁使用不完整仍然是最大类问题。但 Go 相关的几个语言细节也非常突出尤其是 slice、闭包捕获、并行测试。这说明 Go 的一些便利语法确实会在大规模工程里反复变成并发陷阱。十、这篇文章对普通 Go 开发者有什么启发第一goroutine 很便宜但不代表共享状态可以随便用。每启动一个 goroutine都应该问一句它会访问哪些外部变量这些变量是否还会被其他 goroutine 访问第二闭包里出现外层变量时要格外小心尤其是err、循环变量、命名返回值。Go 1.22 缓解了循环变量捕获问题但没有消灭所有闭包捕获问题。第三slice 的并发安全不只是元素安全还包括 slice header。只要存在并发 append就要特别小心。第四map 默认不是线程安全的。不同 key 并不意味着可以并发写同一个 map。第五不要复制同步原语。sync.Mutex、sync.RWMutex、sync.WaitGroup、atomic 类型以及包含这些字段的 struct都应该避免复制。方法 receiver 通常应该使用指针。第六channel 和共享内存不要混得太随意。channel 只能保证经过 send/receive 的路径有同步关系不能自动保护旁边那些共享字段。第七WaitGroup.Add放在 goroutine 外面Done应该表示 goroutine 的全部工作真的结束了。第八并行测试要隔离状态。t.Parallel()不是简单的加速按钮它会改变测试执行模型。第九go test -race很有价值但它不是静态证明工具。动态 race detector 只能发现实际执行路径中暴露出来的 race。测试没覆盖到、调度没触发到它就可能发现不了。十一、局限性这些结论不是 Go 世界的全部Uber 也强调这些结论来自他们自己的 Go monorepo 和内部 race 检测实践。不同公司的代码结构、并发模型、测试覆盖率、工程文化都不一样因此不能简单把这些统计数字当成所有 Go 项目的普遍规律。此外动态数据竞争检测依赖实际执行。它需要测试跑到相关代码路径也需要并发交错刚好暴露问题。因此它不可能发现所有潜在 data race。但这些局限并不削弱文章的价值。因为这些模式并不是“Uber 独有”的奇怪问题而是很多 Go 开发者都会遇到的真实陷阱。Uber 的贡献在于他们用足够大的代码规模和足够多的修复案例把这些坑系统地分类整理出来了。十二、总结Go 的并发能力是它最吸引人的地方之一。go关键字、goroutine、channel、sync包让并发代码写起来非常直接。但并发写起来容易不代表并发写对也容易。Uber 的经验提醒我们很多 data race 并不是因为开发者完全不知道要同步而是因为 Go 的某些语义太“顺手”了让人误以为代码是安全的。闭包看起来只是引用了一个变量但它可能跨 goroutine 并发访问slice 看起来是引用类型但传参时会复制 headermap 看起来像数组按 key 访问但内部不是独立元素mutex 看起来传进去就能锁住实际上可能已经被复制channel 看起来提供了同步但旁边共享字段可能完全没被保护WaitGroup看起来只是等一等但Add和Done的位置错了就会提前放行测试看起来只是加了t.Parallel()但共享 fixture 可能已经被并发打爆。写 Go 并发代码时真正重要的不是“有没有 goroutine”而是“每一份共享数据的所有访问路径是否都有明确顺序”。只要这个问题回答不清楚data race 就可能藏在代码里。参考来源Uber Engineering 原文介绍了这七类 Go data race 模式和 Uber 的检测背景。(Uber) 论文版提供了更完整的代码示例和分类统计。(ar5iv) Go 1.22 的循环变量语义变化参考 Go 官方博客。(go.dev)