本文是对 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.Mutex; - channel 消息传递与共享内存混用;
sync.WaitGroup的Add/Done放置位置错误;- 并行 table-driven test 导致测试代码或业务代码出现 race。
-
Uber 对已修复 data race 的统计结论。
-
这项经验的局限性,以及普通 Go 开发者应该如何吸收这些教训。
一、为什么 Go 里的 data race 值得单独拿出来讲
Go 的并发模型非常顺手。想让一个函数异步执行,只需要在调用前面加一个 go 关键字:
go doSomething()
这个调用会启动一个 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 := range jobs {
go func() {
process(job)
}()
}
很多人第一次看这段代码时,会以为每个 goroutine 都会拿到当前循环对应的 job。但在早期 Go 版本中,循环变量并不是每次循环都创建一个全新的变量,而是同一个变量在每轮循环中被反复赋值。闭包捕获的是变量本身,而不是变量在某一轮循环里的值。
于是就可能发生这种情况:
第一轮循环启动了 goroutine,但 goroutine 还没来得及执行;主 goroutine 已经进入第二轮循环,把 job 改成了下一个元素;此时第一轮 goroutine 再去读 job,读到的就不是它原本应该处理的那个任务。更重要的是,主 goroutine 在写 job,子 goroutine 在读 job,这就是 data race。
传统写法是显式创建一个局部副本:
for _, job := range jobs {
job := job
go func() {
process(job)
}()
}
或者把变量作为参数传进去:
for _, job := range jobs {
go func(j Job) {
process(j)
}(job)
}
需要注意的是,Go 1.22 对循环变量语义做了重要修改:对于声明为 go 1.22 或更高版本的模块,循环变量会更接近“每轮循环一个新变量”的直觉,从而修复很多这类闭包捕获问题。也就是说,Uber 原文中的这个经典坑,在 Go 1.22 之后已经被语言层面缓解了。但在维护旧项目、旧 go.mod 版本,或者读历史代码时,仍然要知道这个背景。
1.2 err 变量捕获
Go 代码中最常见的变量名之一就是 err。Go 鼓励多返回值,很多函数会返回实际结果和错误对象:
x, err := foo()
if err != nil {
return err
}
在一个较长的函数里,开发者往往会反复复用同一个 err:
x, err := foo()
if err != nil {
return err
}
go func() {
y, err := bar()
_ = y
if err != nil {
// handle error
}
}()
z, err := baz()
if err != nil {
return err
}
_ = z
这段示意代码里,如果 goroutine 内部不是 := 创建新的局部 err,而是对外层 err 赋值,那么 goroutine 内部写 err 和外层函数后续写 err 就可能并发发生。即使只是读写错误对象,也仍然是同一个变量的并发访问。
这类问题隐蔽的原因在于:err 太常见了,开发者很容易下意识复用它。尤其当闭包比较长、逻辑比较复杂时,很难一眼看出它捕获了外层的 err。
更稳妥的做法是,goroutine 内部使用自己的局部变量:
go func() {
y, localErr := bar()
_ = y
if localErr != nil {
// handle localErr
}
}()
如果 goroutine 的错误需要传回主流程,可以用 channel、errgroup.Group,或者受锁保护的共享结构,而不是让多个 goroutine 同时写同一个外层 err。
1.3 命名返回值捕获
Go 还有一种语法糖:命名返回值。
func f() (result int) {
result = 10
return
}
这里的 result 其实是函数作用域里的一个变量。裸 return 会返回当前 result 的值。
问题是,命名返回值也可能被闭包捕获:
func calculate() (result int) {
result = 10
go func() {
use(result)
}()
return 20
}
很多人会觉得 return 20 只是返回常量 20,似乎没有写 result。但从编译器语义看,return 20 等价于把 20 赋给命名返回变量 result,然后返回。于是 goroutine 里可能正在读 result,函数返回路径又在写 result,data race 就出现了。
还有一种更隐蔽的情况是 defer 与命名返回值结合:
func redeem(req Request) (resp Response, err error) {
defer func() {
resp, err = finalize(req, err)
}()
err = check(req)
go func() {
process(req, err != nil)
}()
return
}
defer 会在函数返回前执行。这里 goroutine 捕获了命名返回值 err,而 deferred function 又会在返回阶段写 err。如果 goroutine 同时读取 err,就可能产生 race。
这类 bug 特别难排查,因为从代码表面看,goroutine 启动之后似乎没有明显的 err 写入;真正的写入藏在 defer 和命名返回值语义里。
三、模式二:slice 看起来加锁了,实际还在 race
Go 的 slice 不是简单的数组。它内部可以粗略理解为三部分:
type sliceHeader struct {
data *T
len int
cap int
}
也就是:指向底层数组的指针、当前长度、当前容量。
append 可能会修改这些元信息。比如容量不够时,runtime 会分配新的底层数组,把旧元素复制过去,然后更新 slice header。
因此,slice 的并发访问不仅仅是“底层数组元素”的问题,还包括 slice header 本身的并发读写。
看一个示意代码:
func processAll(ids []string) {
var results []string
var mu sync.Mutex
appendSafe := func(v string) {
mu.Lock()
results = append(results, v)
mu.Unlock()
}
for _, id := range ids {
go func(id string, 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 独立访问”的结构。比如:
func process(ids []string) map[string]error {
errs := make(map[string]error)
for _, id := range ids {
go func(id string) {
if err := handle(id); err != nil {
errs[id] = err
}
}(id)
}
return errs
}
看起来每个 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 可能涉及桶、溢出桶、扩容状态等共享元信息。
常见修复方式包括:
var mu sync.Mutex
errs := make(map[string]error)
go func(id string) {
if err := 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:
type itemErr struct {
id string
err error
}
ch := make(chan itemErr)
go func() {
for e := range ch {
errs[e.id] = e.err
}
}()
这就是 Go 里常说的思路:不要通过共享内存来通信,而要通过通信来共享内存。但如果采用这种方式,就要坚持所有 map 写入都走这个 channel,不要一边 channel 一边直接写 map。
五、模式四:值传递和指针传递混淆,导致锁失效
Go 里很多东西是值类型,比如 struct。sync.Mutex 本身也是一个 struct,也就是说它是值类型。
问题来了:如果你把 mutex 按值传给函数,其实是复制了一把锁。
var counter int
func critical(mu sync.Mutex) {
mu.Lock()
counter++
mu.Unlock()
}
func main() {
var mu sync.Mutex
go critical(mu)
go critical(mu)
}
这段代码看上去有锁,但实际上两个 goroutine 各自拿到的是 mu 的副本。它们锁住的是两把不同的锁,自然无法保护同一个 counter。
正确写法应该传指针:
func critical(mu *sync.Mutex) {
mu.Lock()
counter++
mu.Unlock()
}
func main() {
var mu sync.Mutex
go critical(&mu)
go critical(&mu)
}
这个问题之所以容易出现,是因为 Go 在方法调用语法上做了很多透明转换。比如 mu.Lock() 看起来都是一样的,但 Lock 方法的 receiver 实际上是 *Mutex。当你在一个 mutex 值上调用 Lock() 时,编译器会自动取地址,让调用成立。
这种便利在多数时候很好,但也会让开发者忽略“这里到底是在操作原对象,还是对象副本”。
类似问题不只发生在 sync.Mutex,还可能发生在包含 mutex 的结构体上:
type Cache struct {
mu sync.Mutex
m map[string]string
}
func (c Cache) Set(k, v string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = v
}
这里 Set 的 receiver 是值类型 Cache,调用时会复制整个 Cache,包括里面的 sync.Mutex。虽然 map 底层仍然指向同一份数据,但 mutex 已经被复制了,锁就不再是同一把锁。
更合理的写法是:
func (c *Cache) Set(k, v string) {
c.mu.Lock()
defer c.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 的简化实现:
type Future struct {
ch chan struct{}
response Response
err error
fn func() (Response, error)
}
func (f *Future) Start() {
go func() {
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:
return nil
case <-ctx.Done():
f.err = ErrCancelled
return ErrCancelled
}
}
正常完成时,Start 里的 goroutine 写入 f.response 和 f.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 传递:
type result struct {
resp Response
err error
}
ch := make(chan result, 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
错误示意:
func process(ids []int) []Result {
var wg sync.WaitGroup
results := make([]Result, len(ids))
for i := range ids {
go func(i int) {
wg.Add(1)
defer wg.Done()
results[i] = handle(ids[i])
}(i)
}
wg.Wait()
return results
}
这段代码的问题是:wg.Add(1) 放在 goroutine 内部。主 goroutine 启动子 goroutine 后,可能马上执行到 wg.Wait()。如果此时某些子 goroutine 还没来得及执行 wg.Add(1),那么 Wait 看到的计数可能仍然是 0,于是直接返回。随后主 goroutine 开始读 results,而子 goroutine 还在写 results,data race 就出现了。
正确写法是先 Add,再启动 goroutine:
for i := range ids {
wg.Add(1)
go func(i int) {
defer wg.Done()
results[i] = handle(ids[i])
}(i)
}
wg.Wait()
这条规则非常重要:Add 应该发生在启动 goroutine 之前。不要让参与者自己“报名”,因为主线程可能已经开始等待甚至已经等完了。
6.2 defer 顺序导致 Done 过早执行
Go 的 defer 是后进先出,也就是最后注册的 defer 最先执行。
看一个示意:
go func() {
defer cleanup()
defer wg.Done()
doWork()
}()
执行顺序是:
doWork()结束;- 先执行
wg.Done(); - 再执行
cleanup()。
如果 cleanup() 里还会写某个共享变量,比如 locationErr,主 goroutine 在 wg.Wait() 返回后立刻读取 locationErr,就可能和 cleanup() 里的写操作并发发生。
也就是说,wg.Done() 并不一定意味着 goroutine 内所有逻辑都完成了。它只意味着计数减一。如果你把 Done 放在某些清理逻辑之前,主 goroutine 就可能过早继续执行。
更安全的原则是:让 wg.Done() 成为 goroutine 真正结束前的最后动作。实践中通常可以把它作为第一个 defer 注册:
go func() {
defer wg.Done()
defer cleanup()
doWork()
}()
由于 defer 后进先出,这里实际执行时会先 cleanup(),最后 wg.Done()。
八、模式七:并行 table-driven test 引发 race
Go 项目里常见 table-driven test:
func TestSomething(t *testing.T) {
cases := []struct {
name string
input string
}{
{"case1", "a"},
{"case2", "b"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := doSomething(tc.input)
_ = got
})
}
}
为了提升测试速度,开发者可能会加入:
t.Parallel()
比如:
for _, tc := range cases {
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 |
| 并发访问 slice | 391 |
| 并发访问 map | 38 |
| 值传递 / 指针传递混淆 | 38 |
| channel 消息传递与共享内存混用 | 25 |
WaitGroup 使用错误 | 24 |
| 并行 table-driven test | 139 |
从语言无关的并发错误看,最常见的是:
| 类别 | 数量 |
|---|---|
| 缺失锁或只在部分路径加锁 | 470 |
| 在读锁保护区内修改共享数据 | 2 |
| 违反线程安全 API 的使用约定 | 369 |
| 修改全局变量 | 24 |
| atomic 操作使用不完整 | 40 |
| 语句顺序错误 | 5 |
| 多组件复杂交互 | 6 |
| metrics / logging 相关 race | 18 |
| 通过移除并发修复 | 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 看起来是引用类型,但传参时会复制 header;map 看起来像数组按 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)
253

被折叠的 条评论
为什么被折叠?



