Go 并发的七种数据竞争陷阱:Uber 从 1100 个 Race 修复中总结出的经验

📅 2026/7/7 2:12:55 👁️ 阅读次数 📝 编程学习
Go 并发的七种数据竞争陷阱:Uber 从 1100 个 Race 修复中总结出的经验

本文是对 Data Race Patterns in Go 的整理与翻译。

内容结构概览

这篇文章主要讲 Uber 在大规模 Go 代码库中观察到的数据竞争模式。全文可以分为几部分:

  1. 为什么 Go 项目更容易暴露大量并发问题:goroutine 很轻量、创建成本低、服务中并发度很高。

  2. 什么是 data race:多个 goroutine 访问同一份数据,至少一个是写,并且它们之间没有明确的 happens-before 顺序。

  3. Uber 的背景:在大约 6 个月内,Uber 使用动态数据竞争检测系统发现了约 2000 个 data race,其中约 1100 个已经被开发者修复。

  4. 七类 Go 中常见的数据竞争模式:

    • goroutine 中闭包按引用捕获自由变量;
    • slice 的元信息复制与 append 并发修改;
    • Go 内置 map 非线程安全;
    • 值传递与指针传递混淆,尤其是sync.Mutex
    • channel 消息传递与共享内存混用;
    • sync.WaitGroupAdd/Done放置位置错误;
    • 并行 table-driven test 导致测试代码或业务代码出现 race。
  5. Uber 对已修复 data race 的统计结论。

  6. 这项经验的局限性,以及普通 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}

在一个较长的函数里,开发者往往会反复复用同一个err

x,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){result=10return}

这里的result其实是函数作用域里的一个变量。裸return会返回当前result的值。

问题是,命名返回值也可能被闭包捕获:

funccalculate()(resultint){result=10gofunc(){use(result)}()return20}

很多人会觉得return 20只是返回常量20,似乎没有写result。但从编译器语义看,return 20等价于把20赋给命名返回变量result,然后返回。于是 goroutine 里可能正在读result,函数返回路径又在写result,data race 就出现了。

还有一种更隐蔽的情况是defer与命名返回值结合:

funcredeem(req Request)(resp Response,errerror){deferfunc(){resp,err=finalize(req,err)}()err=check(req)gofunc(){process(req,err!=nil)}()return}

defer会在函数返回前执行。这里 goroutine 捕获了命名返回值err,而 deferred function 又会在返回阶段写err。如果 goroutine 同时读取err,就可能产生 race。

这类 bug 特别难排查,因为从代码表面看,goroutine 启动之后似乎没有明显的err写入;真正的写入藏在defer和命名返回值语义里。


三、模式二:slice 看起来加锁了,实际还在 race

Go 的 slice 不是简单的数组。它内部可以粗略理解为三部分:

typesliceHeaderstruct{data*Tlenintcapint}

也就是:指向底层数组的指针、当前长度、当前容量。

append可能会修改这些元信息。比如容量不够时,runtime 会分配新的底层数组,把旧元素复制过去,然后更新 slice header。

因此,slice 的并发访问不仅仅是“底层数组元素”的问题,还包括 slice header 本身的并发读写。

看一个示意代码:

funcprocessAll(ids[]string){varresults[]stringvarmu sync.Mutex appendSafe:=func(vstring){mu.Lock()results=append(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 header,race 仍然存在。

这个坑的本质是: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 writes

Uber 的观察是,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 所有权集中到一个 goroutine:

typeitemErrstruct{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()counter++mu.Unlock()}funcmain(){varmu sync.Mutexgocritical(mu)gocritical(mu)}

这段代码看上去有锁,但实际上两个 goroutine 各自拿到的是mu的副本。它们锁住的是两把不同的锁,自然无法保护同一个counter

正确写法应该传指针:

funccritical(mu*sync.Mutex){mu.Lock()counter++mu.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.Mutexsync.RWMutexsync.WaitGroupatomic类型的结构体,通常不要复制。方法 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.response=resp f.err=err f.ch<-struct{}{}}()}func(f*Future)Wait(ctx context.Context)error{select{case<-f.ch:returnnilcase<-ctx.Done():f.err=ErrCancelledreturnErrCancelled}}

正常完成时,Start里的 goroutine 写入f.responsef.err,然后发送 channel;Wait收到 channel 以后再继续,这条路径上有同步关系。

但如果ctx超时,Wait会走另一条分支:

case<-ctx.Done():f.err=ErrCancelled

这时,后台 goroutine 可能也正在执行:

f.err=err

两个 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.WaitGroupAdd/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 还在写results,data race 就出现了。

正确写法是先Add,再启动 goroutine:

fori:=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()}()

执行顺序是:

  1. doWork()结束;
  2. 先执行wg.Done()
  3. 再执行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 引发 race

Go 项目里常见 table-driven test:

funcTestSomething(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
值传递 / 指针传递混淆38
channel 消息传递与共享内存混用25
WaitGroup使用错误24
并行 table-driven test139

从语言无关的并发错误看,最常见的是:

类别数量
缺失锁或只在部分路径加锁470
在读锁保护区内修改共享数据2
违反线程安全 API 的使用约定369
修改全局变量24
atomic 操作使用不完整40
语句顺序错误5
多组件复杂交互6
metrics / logging 相关 race18
通过移除并发修复26
通过禁用测试修复3
通过大规模重构修复30

这里最值得注意的是:缺失锁或锁使用不完整仍然是最大类问题。但 Go 相关的几个语言细节也非常突出,尤其是 slice、闭包捕获、并行测试。这说明 Go 的一些便利语法确实会在大规模工程里反复变成并发陷阱。


十、这篇文章对普通 Go 开发者有什么启发

第一,goroutine 很便宜,但不代表共享状态可以随便用。每启动一个 goroutine,都应该问一句:它会访问哪些外部变量?这些变量是否还会被其他 goroutine 访问?

第二,闭包里出现外层变量时要格外小心,尤其是err、循环变量、命名返回值。Go 1.22 缓解了循环变量捕获问题,但没有消灭所有闭包捕获问题。

第三,slice 的并发安全不只是元素安全,还包括 slice header。只要存在并发 append,就要特别小心。

第四,map 默认不是线程安全的。不同 key 并不意味着可以并发写同一个 map。

第五,不要复制同步原语。sync.Mutexsync.RWMutexsync.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 看起来是引用类型,但传参时会复制 header;map 看起来像数组按 key 访问,但内部不是独立元素;mutex 看起来传进去就能锁住,实际上可能已经被复制;channel 看起来提供了同步,但旁边共享字段可能完全没被保护;WaitGroup看起来只是等一等,但AddDone的位置错了就会提前放行;测试看起来只是加了t.Parallel(),但共享 fixture 可能已经被并发打爆。

写 Go 并发代码时,真正重要的不是“有没有 goroutine”,而是“每一份共享数据的所有访问路径是否都有明确顺序”。只要这个问题回答不清楚,data race 就可能藏在代码里。

参考来源:Uber Engineering 原文介绍了这七类 Go data race 模式和 Uber 的检测背景。(Uber) 论文版提供了更完整的代码示例和分类统计。(ar5iv) Go 1.22 的循环变量语义变化参考 Go 官方博客。(go.dev)