Go 中的数据竞争与内存模型

Lobsters Hottest 工具

摘要

本文解释了数据竞争和 Go 内存模型,说明了在 goroutine 中对共享变量的非同步访问可能导致的问题,并讨论了正确的同步方法。

<p><a href="https://lobste.rs/s/chhsuf/data_races_memory_model_go">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/08/21 14:56

# Go 中的数据竞争与内存模型 来源:https://func25.dev/posts/go-memory-visibility/ 我们可能在一个 goroutine 中写入一个值,然后在另一个 goroutine 中读取它。程序没有崩溃,而且读取到了这个值。看起来我们的代码运行正常。 但实际上我们只是运气好。Go 并不保证一个 goroutine 会看到另一个 goroutine 所做的写操作,除非程序明确地协调它们的操作。本文将解释在实际机器上会发生什么、为什么会这样,以及我们应该使用什么替代方案。 ## 1. 一个能运行的程序 假设我们想要在不使用通道的情况下,将一个值从一个 goroutine 传递给另一个。一个简单的做法是将值存储在一个变量中,并使用一个布尔变量来报告写操作何时完成。读取线程在访问该值之前,会等待这个布尔变量: go ``` func main() { var done bool var msg string go func() { msg = "hello" done = true }() for !done { } fmt.Println(msg) } ``` 你可以在 Go Playground (https://go.dev/play/p/Q4ti8oRCNCm) 中运行此示例。Playground 会打印出 `hello` 并退出,因此这次执行确实产生了我们预期的结果。 但是,当我们使用 `go run -race` 运行这段代码时,竞态检测器报告了两个竞争:一个涉及 `done`,另一个涉及 `msg`: ``` ================== WARNING: DATA RACE Write at 0x00c0000121cf by goroutine 7: main.main.func1() main.go:11 +0x68 Previous read at 0x00c0000121cf by main goroutine: main.main() main.go:14 +0x110 ================== ================== WARNING: DATA RACE Read at 0x00c000014040 by main goroutine: main.main() main.go:16 +0x128 Previous write at 0x00c000014040 by goroutine 7: main.main.func1() main.go:10 +0x30 ================== hello Found 2 data race(s) exit status 66 ``` 那么,因为我们使用了 `for` 循环来检查 `done` 标志,这段代码就是安全和有效的吗?让我们查阅 Go 内存模型。 ## 2. Go 内存模型 竞态检测器不关心输出结果。它检查的是两个 goroutine 是否在没有同步的情况下并发访问同一内存,并且至少有一次访问是写操作。 Go 内存模型 (https://go.dev/ref/mem) 回答了下一个问题:“每次读操作必须使用哪次写操作的值?”它告诉我们在所有执行中 Go 保证了哪些行为。我知道这并不直观,因此让我们来诊断这两个报告的竞态。 ### 竞态 1:main 可能无法读取到 `true` 让我们把相关代码放在这里,以免失去上下文: go ``` // goroutine A go func() { msg = "hello" done = true }() for !done { } fmt.Println(msg) ``` 第一个警告是关于 `done` 的: ``` WARNING: DATA RACE Write at 0x00c0000121cf by goroutine 7: main.main.func1() main.go:11 +0x68 Previous read at 0x00c0000121cf by main goroutine: main.main() main.go:14 +0x110 ``` `done = true` 是一次非原子写操作,而每次对 `!done` 的求值都包含一次对同一变量的非原子读操作。程序并不保证写操作发生在这些读操作之前。 由于两个 goroutine 访问同一个变量,且其中一次访问是写操作,因此 `done` 存在读写数据竞争。 Go 内存模型 (https://go.dev/ref/mem#badsync) 并不保证一个 goroutine 中的写操作会自行变得对另一个 goroutine 可见。这段代码没有将 *对 `done` 的写操作* 与 *main 中对 `done` 的读操作* 进行同步,因此循环可能继续读到 `false`。这可能听起来很奇怪,因为在 Go 源代码中顺序看起来很清晰。 理论上,程序的行为可能表现得像生成的代码重用了第一次读取的值: | 源代码 | `for !done {}` | 可能的优化形式 | | :--- | :--- | :--- | | 生成的汇编 | `cached := if !cached { for {} }` | `cached := done if !cached { for {} }` | 当然,右边的代码仅为解释目的。编译器不会为这个例子生成那种形式。重点是,Go 源代码和生成的汇编代码不需要一一对应。 没有保证新 goroutine 所做的写操作会变得对 `main` 可见,因此编译器可能会重用寄存器或临时变量中已加载的值,或以其他顺序排列指令,只要这种优化在 Go 内存模型允许的范围内。 另一个问题是,如果写入 goroutine 在读取 goroutine 正在读取时更新了 `done` 会怎样?`main` goroutine 能接收到部分写入的值吗? 对于这个特定情况,答案是否定的。 - 在 arm64 上,一个 `bool` 使用一个字节, - 写入者使用一条 `MOVB`(移动字节)指令存储整个字节, - `main` 使用一条 `MOVBU`(无符号移动字节)指令加载整个字节。 由于每条指令访问完整的单字节 `bool`,因此访问是原子的:`main` 不可能只接收到一半的值。 | 写入者 Goroutine | | | | | :--- | :--- | :--- | :--- | | Go 源代码 | `done = true` | 编译为 (arm64) | `MOVBR0, (R1)` | | 存储 `done` | 1 字节 | `false` 0x00 | `true` 0x01 | | **读取** | **加载** | | | | **主 Goroutine** | | | | | Go 源代码 | `for !done {}` | 编译为 (arm64) | `MOVBU(R1), R2` | | | | **完整字节访问,无部分值** | | 写入者和主 goroutine 在 arm64 上访问的是完整的单字节 `bool`。但同样的推理并不适用于整个结构体、数组或其他由多个部分组成的值。 Go 可能一次读取或写入结构体的一个字段,一次处理数组的一个元素,一次处理复数的一个部分。大于一个机器字的值可能会组合来自不同写入的部分。字符串、切片和接口通常使用多词内部表示,因此竞争可能导致不一致的值,甚至可能破坏内存。 例如,考虑一个由三个 `uint64` 字段组成的 24 字节结构体: go ``` type State struct { A uint64 B uint64 C uint64 } var state State go func() { state = State{A: 2, B: 2, C: 2} }() snapshot := state ``` 在源代码中,赋值是一条语句,但 Go 可能会单独写入这三个字段,且顺序任意。 一个有效的执行可能先写入 `C`。然后,发生竞争的读取器可能会一起读取 `A` 和 `B` 的旧值以及 `C` 的新值: | 赋值 | `state = State{A: 2, B: 2, C: 2}` | | | | :--- | :--- | :--- | :--- | | **字段写入** | **初始状态** | **写入 C** | **竞态读取** | | | `A = 0` | `A = 0` | **快照** | | | `B = 0` | `B = 0` | `A = 0` | | | `C = 0` | `C = 2` | `B = 0` | | | | | `C = 2` | | **不是一个完整的状态** | | | | 发生竞态的结构体读取合并了旧字段值和新字段值。结果 `snapshot` 是 `{A: 0, B: 0, C: 2}`。在此示例使用的 64 位机器上,每个字段包含一个完整的 `uint64` 值,但整个结构体既不匹配初始的 `{A: 0, B: 0, C: 0}`,也不匹配写入者赋的值 `{A: 2, B: 2, C: 2}`。 注意 你可以在 Go Playground (https://go.dev/play/p/RZnQcmBdhF9) 中重现这种混合读取。Playground 版本故意在每对字段之间添加了一个 64 KiB 的字节数组。这使得 `state = one` 和 `s := state` 都复制 131,096 字节而不是 24 字节。 更大的复制需要更长的时间,因此在任何一个完成之前,它们更有可能同时运行。额外的字节只是让混合结果更容易重现。即使没有它们,数据竞争也已经存在。 ### 竞态 2:`done == true` 并不保证 `msg == "hello"` 假设循环读到了 `true` 并退出,就像在 Playground 中一样。竞态 2 问的是另一个问题:“`done == true` 是否也保证 `fmt.Println` 从 `msg` 读取到 `"hello"`?” 让我们把相关代码片段放在这里,这样我们就不必滚动回去了: go ``` var done bool var msg string go func() { msg = "hello" done = true }() for !done { } fmt.Println(msg) ``` 第二个警告指向 `msg`: ``` WARNING: DATA RACE Read at 0x00c000014040 by main goroutine: main.main() main.go:16 +0x128 Previous write at 0x00c000014040 by goroutine 7: main.main.func1() main.go:10 +0x30 ``` 由于两个 goroutine 访问同一个变量,且其中一次访问是写操作,因此 `msg` 存在第二个数据竞争。 Go 源代码给出了每个 goroutine 内部的一个顺序。新 goroutine 在写入 `done` 之前写入了 `msg`。`main` 在退出循环之前读取了 `done`,然后为 `fmt.Println` 读取了 `msg`: | 新 goroutine | 主 goroutine | | | :--- | :--- | :--- | | **写入 msg** | `"hello"` | 同一 goroutine | | **写入 done** | `true` | **读取得到 true** | | | **读取 done** | `true` | | | | 同一 goroutine | | | **读取 msg** | `?` | 为什么读取 done 不能对 msg 的写入和读取进行排序如果我们从上到下阅读代码,似乎很显然,当 `main` 离开循环时,`msg` 必须包含 `"hello"`。新 goroutine 在将 `done` 设置为 `true` 之前写入了 `msg`,而 `main` 只有在从 `done` 读到 `true` 之后才读取 `msg`。 但从 Go 的角度来看,对 `done` 的读取只回答了一个问题:哪次写操作提供了这次读取返回的值?值 `true` 来自 `done = true`,但 `msg` 是一个独立的内存位置,有独立的读取操作。 在 Go 内存模型下,没有任何保证 `main` 离开循环时,`msg` 包含另一个 goroutine 写入的值。 因此,Go 允许出现这种结果: ``` read done true read msg "" ``` Playground 在此示例中打印 `"hello"`,因此这次运行没有向我们展示可能出现的问题。让我们使用另一个代码片段,其中同样的跨 goroutine 顺序缺失导致了在真实硬件上可以重现的结果。 两个 goroutine 同时启动。每个都写入自己的变量,然后读取另一个的变量: go ``` var x, y int var r1, r2 int go func() { // goroutine A x = 1 r1 = y }() go func() { // goroutine B y = 1 r2 = x }() ``` 现在让我们思考可能的结果: - 如果 goroutine A 在 goroutine B 开始之前完成,那么 `r2` 是 1。 - 如果 goroutine B 先完成,那么 `r1` 是 1。 - 如果它们交错执行,至少有一个 goroutine 会看到另一个的写操作,因此要么 `r1` 或 `r2` 是 1,要么两者都是。 但无论我们想象什么顺序,**`r1` 和 `r2` 都为 0 应该是不可能的**,因为那将要求每次读取都发生在另一个 goroutine 的写操作之前。 在 Apple M 系列机器上进行 200,000 次实验,结果如下: ``` both goroutines read 0: 2 out of 200000 rounds (0.0010%) ``` 这个程序在 200,000 轮中两次产生了这个“不可能”的结果,即 0.0010%。而且硬件没有任何问题。你可以在 Playground (https://go.dev/play/p/0JqEfwfdSTO) 中运行相同的实验。 但这个结果需要两个独立的解释。 首先,Go 保证在单个 goroutine 内部,源代码要求的顺序会得到执行。请看这段代码: `a` 必须包含 `1`。编译器可以合并这两条语句,用其他指令替换它们,或者以不同的顺序排列这些指令。但任何优化都必须仍然保留从 `b` 到 `a` 的依赖关系并产生正确的结果。 但在我们的例子中,goroutine A 中的两条语句是独立的: - `r1 = y` 不使用 `x`, - `x = 1` 不使用 `y`。 goroutine B 也是一样。只要生成的程序仍然遵循 Go 内存模型 (https://go.dev/ref/mem),编译器不必在二进制文件中保留它们的文本顺序。 | Go 源代码 | `x = 1`<br>`r1 = y` | 编译器**可能**重排序 | 可能的 arm64 汇编 | | :--- | :--- | :--- | :--- | | | 存储,然后加载 | | `LDR y`<br>`STR x` | | | | | 加载,然后存储 | 编译器可能在存储之前发出独立的加载指令。这种编译器重排序可以解释在竞态 2 中描述的结果。但在上面的实验中,编译器在生成的 arm64 二进制文件中保留了这两条内存指令的源顺序。 那就只剩下第二种解释了:CPU 核心如何让彼此的写操作变得可见。 即使机器指令保持了源顺序,一个核心也不必在它的下一次读取完成之前,使其写操作对另一个核心可用。这个二进制文件中没有内存屏障来强制执行该顺序。 核心 A 可以在核心 B 读取旧值 `x` 的同时读取旧值 `y`,因此 `r1` 和 `r2` 都可以是 `0`。 | 核心 A | | 核心 B | | :--- | :--- | :--- | | `STR x = 1` | | `STR y = 1` | | `LDR y = 0` | | `LDR x = 0` | | **无内存屏障** | | | | **A 的存储对 B 的加载不可见** | | **B 的存储对 A 的加载不可见** | | **两个加载都可能读到 0** | | | 写操作可以在两个读操作都完成后才到达另一个核心。这也是为什么我们运行中的概率是 0.0010% 而不是 50%。确切的比率取决于 goroutine 调度、核心分配、处理器内存行为和其他运行时条件,因此一次测试可能只是偶尔看到结果。这种不稳定且恼人的 bug 通常是最难重现的。 ## 3. 同步如何使早期的写操作变得可见 为了让这段代码正确,它需要一个保证:写入者必须在 `main` 读取 `msg` 之前将 `"hello"` 写入 `msg`。提供这一保证的最小改动是将普通的 `done bool` 标志替换为 `done atomic.Bool`: go ``` var msg string var done atomic.Bool go func() { msg = "hello" done.Store(true) }() for !done.Load() {} fmt.Println(msg) ``` 为什么 `atomic.Bool` 能创建这种顺序? `atomic.Bool` 通过 `sync/atomic` 定义的操作来读取和写入其值。Go 为原子操作定义了以下规则: ``` 如果原子操作 A 的效果被原子操作 B 观察到,则 A “同步于” B。 ``` 在我们的例子中: 1. `done.Store(true)` 是操作 `A`。 2. 返回 `true` 的 `done.Load()` 调用是操作 `B`。 3. 操作 `B` 读取了操作 `A` 写入的值,因此 `A` *同步于* `B`。 换句话说,所有在写入者 goroutine 中排在 `done.Store(true)` 之前的写操作,都保证在 `done.Load()` 读取到该 `true` 之后,对 `main` 可见。这包括 `msg = "hello"`,因此 `fmt.Println` 中后来对 `msg` 的读取必须看到 `"hello"`。 如果你阅读 Go 运行时和编译器源代码,你会看到像 `StoreRelease` 和 `LoadAcquire` 这样的名称。 - *释放 (Release)* 描述了存储操作对其之前完成的写操作所做的保证。 - *获取 (Acquire)* 描述了加载操作对其之后运行的读操作所做的保证。 | 写入者 | | 主线程 | | :--- | :--- | :--- | | **写入 msg** | 源顺序 | | | **done.Store(true)** | 释放端 | **原子顺序** | | | | **done.Load() == true** | | | | 获取端 | | | | 源顺序 | | | | **读取 msg** | 原子存储和加载将 msg 的写入与其后的读取连接起来。### 互斥锁提供互斥和可见性 你可能熟悉 `sync.Mutex` 及其主要任务:允许一次只有一个 goroutine 访问受保护的状态。 go ``` type Counter struct { mu sync.Mutex n int } func (c *Counter) Add() { c.mu.Lock() defer c.mu.Unlock() c.n++ } func (c *Counter) Value() int { c.mu.Lock() defer c.mu.Unlock() return c.n } ``` 但互斥锁还使一个锁持有者的写操作对下一个锁持有者可见。这个 `Counter` 使用了这两种保证。 | 写入者 | | 读取者 | | :--- | :--- | :--- | | **写入 n** | 源顺序 | | | **Unlock** | 互斥锁规则 | **Lock 返回** | | | | 源顺序 | | | | **读取 n** | Unlock 将受保护的写操作连接到其后 Lock 的读操作。Go 保证对 `Unlock` 的调用 *同步于* 后来返回的 `Lock` 调用。换句话说,在 `Add` 写入 `n` 并解锁 `mu` 之后,一个后来锁定 `mu` 的 `Value` 调用保证会看到那次写入。 ### WaitGroup 等待任务完成 原始代码片段只需要 `main` 等待一个任务。`sync.WaitGroup` 无需忙等即可提供这种关系: go ``` func main() { var msg string var tasks sync.WaitGroup tasks.Go(func() { msg = "hello" }) tasks.Wait() fmt.Println(msg) } ``` `tasks.Go` 启动函数并跟踪该任务。`tasks.Wait()` 在函数完成之前不会返回。Go 还保证函数在返回前所做的写操作在 `Wait()` 返回后可见。因此 `fmt.Println` 读取到 `"hello"`。 与之前的 `for !done` 循环不同,`main` 在 `Wait()` 内部阻塞,而不是在任务仍在运行时反复检查一个值并消耗 CPU。 ### 通道关闭可以发出完成信号 同一个程序可以在一个 goroutine 需要宣布事件时使用通道: go ``` func main() { var msg string ready := make(chan struct{}) go func() { msg = "hello" close(ready) }() <-ready fmt.Println(msg) } ``` 当然,调用像 `abc()` 这样的任意函数本身并不会在 goroutine 之间建立保证。该函数需要在内部使用同步操作。`close(ready)` 提供了这样的操作,因为 Go 将其与一个在 `ready` 关闭后完成的接收操作连接起来。 | 写入者 | | 主线程 | | :--- | :--- | :--- | | **msg = "hello"** | 源顺序 | | | **close(ready)** | 通道规则 | **receive ready** | | | | 源顺序 | | | | **读取 msg** | 通道将 msg 的写入与其后的读取连接起来。所以 Go 保证关闭通道会同步于那个完成的接收操作,这使得写入者在关闭前的所有写操作对从该通道接收的 goroutine 可见。

相似文章

不会编译的数据竞争

Hacker News Top

本文解释了作者如何利用ruxe库中的类型级不相交技术,教会Rust的类型系统拒绝可能导致数据竞争的并行reducer管道。

大多数多智能体内存设计中隐藏的竞态条件

Reddit r/AI_Agents

一位生产环境工程师描述了多智能体共享内存中的并发写入引发的竞态条件,并解释了如何通过切换到带投影的仅追加事件日志来解决该问题,同时指出了读己之写延迟方面的权衡。

并发服务器:第 8 部分 - Go

Eli Bendersky

本文是编写并发网络服务器系列文章的第 8 部分,重点介绍如何在 Go 中使用 goroutines 和 Go 运行时调度来实现并发。

C语言中的Go风格并发

Hacker News Top

一篇详细的技术文章,探讨如何在C语言中复制Go的并发模型,使用POSIX线程、互斥锁、条件变量和工作池,作为Solod转译器项目的一部分。

那一次我用Go panic做流程控制

Lobsters Hottest

一位Go工程师讲述了一次事件:内存数据存储因排序缓慢而超载,他们在排序函数内部实现了上下文取消,使用panic和recover进行非局部流程控制,类似于encoding/json处理错误的方式。