请求 A
尚未返回- 读取n = stock.Load()
- 检查n > 0?
- 扣减Add(−1)
- 返回true / false
- 最近一次检查
- 待检查
- 已尝试扣减
- 0 次
当前 / 下一步Load 读取库存
并发代码里,“我已经写了,他过一会儿再读”不是足够的理由。需要知道写和读之间是否有语言认可的同步关系。Go 内存模型就是用来回答这个问题:不同 goroutine 的读写,哪些顺序有保证,哪些程序存在数据竞争。
这篇文章不从 CPU 缓存示意图猜结论,而是从一段安全发布代码开始,再看一个没有数据竞争却仍然超卖的库存例子。学完应能分清三件事:执行时间上的先后、happens-before 关系、业务上的正确性。[1]
一个 goroutine 写入结果,再关闭完成 channel;调用方等关闭以后读取:
var value int
done := make(chan struct{})
go func() { value = 42; close(done) }()
<-done
return value本次程序把返回值打印出来:
published: 42这里没有给 value 加锁,仍然安全。理由可以分成三条边:写入 42 在该 goroutine 的程序顺序上先于 close;close 同步于那个因 channel 已关闭而返回零值的接收;接收在调用方的程序顺序上先于读取 value。把这些边接起来,就得到写入 happens-before 读取。[2]
正在绘制图示…
flowchart LR
subgraph W[写入 goroutine]
A[value = 42] -->|程序内先后| B[close done]
end
subgraph R[读取 goroutine]
C[接收 done:观察到已关闭] -->|程序内先后| D[读取 value]
end
B ==>|同步关系| C
A -. happens-before:沿上述关系可达 .-> D
happens-before 可以理解为由程序内顺序和同步关系连接出来的先后约束,并取传递关系。它不是墙钟时间:让读取者 time.Sleep 一秒,或者打印日志“我写完了”,都不能代替这条同步边。[1:1]
两个操作访问同一块内存,至少有一个是写,并且没有被 happens-before 排出先后,就需要检查是否构成数据竞争。内存模型把包含普通非同步访问的读写冲突、写写冲突正式定义为数据竞争;规范化的原子操作有自己的同步语义,不能仅按“同时发生”一概判成竞争。[3]
| 情况 | 是否可能有数据竞争 | 还可能有什么问题 |
|---|---|---|
| 两个 goroutine 只读不变对象 | 仅这些读不会形成读写/写写冲突 | 要确认对象已安全发布,且没人继续写 |
| 普通变量一边写一边无同步地读 | 会构成需要修复的读写竞争 | 不能靠“通常先写完”推理 |
多个 goroutine 普通 n++ | 可能读写与写写竞争 | 增量也可能丢失 |
| 所有访问都经过同一把锁 | 可建立访问顺序 | 锁范围太小仍可能有业务竞态或死锁 |
| 所有访问都是正确使用的原子操作 | 原子访问本身不构成普通数据竞争 | 多次操作间仍可插入其他请求 |
对没有数据竞争的程序,Go 给出 DRF-SC 保证:可以按各 goroutine 操作的某种顺序一致交错来理解结果。这让程序员不必自行处理每种 CPU 的乱序细节,但仍有许多合法交错,业务必须对这些交错都成立。[1:2]
存在数据竞争的程序也不等同于“某次读取永远只会落后一点”。Go 对竞态程序有部分实现限制,但多字长结构,如 slice、string、interface,在竞争中可能出现不一致的组成部分,后果可以远比丢一次计数严重。正确做法是消除竞争,不利用某个平台恰好观察到的结果。[4]
| 事件 | 内存模型或包文档保证的关系 | 不要多推一步 |
|---|---|---|
go f() | 启动语句同步于新 goroutine 开始执行 | 新 goroutine 结束本身不会同步于调用者 |
| channel 发送/接收 | 一次发送同步于对应接收完成 | 发送返回不表示接收者后续业务处理完成 |
| 无缓冲 channel | 对应接收同步于发送完成 | 改成带缓冲 channel 后不能沿用这条反向确认 |
| 关闭 channel | close 同步于因已关闭而返回零值的接收 | 仅从缓冲里读出旧值,不等于观察到关闭 |
| 同一把 Mutex | 较早的 Unlock 同步于后续 Lock 返回 | 使用另一把“长得一样”的锁无效 |
| Once | 初始化函数返回同步于相关 Do 返回 | 不表示初始化失败会重试 |
| WaitGroup | 任务完成同步于相应 Wait 返回 | 不替任务之间的同时写入排顺序 |
| atomic | 原子操作表现为一个顺序一致的总顺序;观察到另一原子操作的效果时有同步关系 | 多个原子动作不自动组成一个原子业务步骤 |
这些是不同的保证,要按实际操作选择边,不是见到锁、channel 或 atomic 就笼统宣布“线程安全”。[5][2:1][6][7][8]
容量为 C 的 channel,有一条更一般的规则:第 k 次接收同步于第 k+C 次发送完成。它正好解释了为什么有界 channel 可以实现“最多 C 个名额”的信号量:已经完成的接收为后续发送腾出容量。C 为 0 时,就退化成无缓冲 channel 的接收先于对应发送完成。[2:2]
例如把 make(chan struct{}) 改成 make(chan struct{}, 1),原本希望“发送完成说明对方已经接收”的推理就不成立了。第一个发送可以只把值放进缓冲。需要确认对方处理完,使用明确的响应或完成 channel,比从缓冲状态猜测可靠。
关闭广播也有同样的细节。写入 value 后 close 一个无缓冲 done,所有观察到关闭的等待者都能获得前面的写入。若某个数据 channel 在 close 前仍有缓冲元素,读到缓冲元素主要连接的是对应发送;不能因此推断发送以后、close 以前的其他写入也已经与这次读取同步。[2:3]
下面的库存实现全部使用 atomic.Int64:先检查库存是否大于零,之后再扣一个。两次访问本身都正确使用了原子操作。
available := stock.Load() > 0
// 这里可能切换到另一个请求。
if available {
stock.Add(-1)
}文末 BrokenReservation 用一个屏障让两个 goroutine 都完成检查,再同时放行扣减。初始库存为 1,本次输出:
atomic but oversold: -1这不是靠睡眠碰运气。ready 等待两个检查都结束,关闭 release 后才允许扣减,所以“都看见可用,再各扣一次”的业务交错被明确构造出来。所有库存访问都是原子的,race 检测正常通过,但库存不应该小于零的业务约束被破坏了。
| 顺序 | 请求 A | 请求 B | 库存 |
|---|---|---|---|
| 1 | Load 看到 1,判断可卖 | 1 | |
| 2 | Load 看到 1,判断可卖 | 1 | |
| 3 | Add(-1) | 0 | |
| 4 | Add(-1) | -1 |
这种结果依赖多个操作的交错,一般称为竞态条件或业务竞态。数据竞争是一个更具体的内存访问问题。把 data race 修掉,是正确性的必要步骤之一,不是业务正确性的全部证明。
单变量库存可以用 CAS 循环:
for {
n := stock.Load()
if n <= 0 {
return false
}
if stock.CompareAndSwap(n, n-1) {
return true
}
}CAS 仅在当前值仍等于 n 时更新。别人先扣过一次,CAS 失败,本请求重新读取并检查。对初始库存 1 连续调用两次,本次输出:
CAS: true false 0另一个测试让 100 个 goroutine 争 10 份库存,结果恰好 10 次成功、余量 0。它检查了本次压力条件下的行为,不是对所有复杂无锁算法的证明。若操作涉及“库存、预留量、订单状态”几个字段,一把锁包住完整检查和更新,通常更容易解释,也更容易审查。[8:1]
请求 A、B 各想预留一件商品。你决定谁先执行下一步,观察“检查有库存”和“真正扣减”之间的空隙,再用 CAS 循环比较同一次交错。
第 0 步 · 初始库存为 1。两个请求都还没读取;选一个请求推进。
当前 / 下一步Load 读取库存
当前 / 下一步Load 读取库存
每次只推进所选请求的一步。Load 和 Add / CAS 各自是原子操作;中间的检查只读本请求的局部 n。按 Tab 选按钮,Enter 或空格执行。
尚未执行。预设自动完成的步骤也会完整显示在这里。
进行中:尚未超卖还有 2 个请求没结束
试试“交错:都通过检查”,再分别推进 A、B 扣减。 对比“串行”预设:一次安全结果不能证明所有交错都安全。
这是确定性的教学交错,浏览器没有运行 Go。按钮指定了一个合法的操作顺序;预设也按写明的顺序执行。它不模拟运行时调度概率、CPU 耗时或吞吐量。
拆开模式对应先读取 n := stock.Load(),检查 n > 0,再执行 stock.Add(-1)。CAS 模式对应循环读取、检查,并调用 stock.CompareAndSwap(n, n-1);只有 CAS 成功才返回 true,CAS 失败回到循环开头。
这里的共享库存访问全部对应 atomic.Int64 操作,局部 n 属于各自请求。data race 与业务竞态是不同问题;race 检测不负责报告这种超卖。涉及库存、订单和预留量等多个字段时,通常应考虑用同一把锁保护完整约束。这个实验不证明任意无锁算法都正确或更快。
不操作也能读懂:库存 1,A、B 都先读到 1;拆开扣减会得到 −1。换成 CAS,一个成功后,另一个 CAS 失败,重读到 0,再检查并拒绝,最终库存为 0。
依据:Go 内存模型:原子操作;sync/atomic:CompareAndSwap(核对日期:2026-10-14)。
race detector 观察实际执行的内存访问及其同步关系。正常测试用:
go test -race -count=1 ./...它只会检测本次运行涉及的路径。没执行到的超时分支、错误回调、极少见调度路径,都不会因为主路径通过测试就自动安全。还可以用 go run -race 或 go build -race 对实际程序做带检测的运行;平台与构建工具链需支持 race。[9]
文末有一个由 racedemo 构建标签隔离的反例,两个 goroutine 直接执行 n++。单独运行它,本次失败输出摘录如下,省略了内存地址和重复创建栈:
WARNING: DATA RACE
Read at ... by goroutine 9:
...TestIntentionalRace.func1()
Previous write at ... by goroutine 10:
...TestIntentionalRace.func1()
--- FAIL: TestIntentionalRace (0.00s)
testing.go:1867: race detected during execution of test
FAIL外层命令退出码为 1,符合这个故意失败测试的预期。不同运行的地址、goroutine 编号和行号可能不同;判断重点是冲突读写、两边的调用栈,以及创建它们的代码位置。完整反例在文末,默认正常测试不会编入它。
读报告时先找“哪一块共享状态被两条路径访问”,再决定由谁拥有它。修复方式通常是让一个 goroutine 独占并通过消息访问、所有访问用同一把锁,或对确实适合的单值状态使用原子操作。不要只在报错那一行附近加 sleep,也不要只锁写入、不锁读取。
race 检测不负责报告所有死锁、活锁、goroutine 泄漏或超卖。本文的 BrokenReservation 正是一个正常通过 race、却能确定复现业务错误的例子。审查并发代码时仍要写出状态约束,再沿同步边证明它成立。
环境为 Go 1.27.2、Linux/amd64,CGO 已启用。正常程序、三个正向测试和 vet 均运行通过;构建标签反例在 15 秒外部超时保护下实际运行,被 race 检测判为失败,退出码 1。本文没有把运行结果用于推导实现未承诺的硬件内存序。
以同一目录为根,按下面标出的相对路径保存文件。使用 Go 1.27.2 运行:
go run .
go test -race -count=1 ./...
go vet ./...故意产生数据竞争的文件由构建标签隔离。下面命令预期失败,退出码非零;timeout 用来防止教学反例意外长时间运行(GNU coreutils):
timeout 15s go test -race -tags=racedemo -run TestIntentionalRace -count=1 .module example.com/go-articles/06_memory
go 1.25.0package main
import (
"fmt"
"sync"
"sync/atomic"
)
func Published() int {
var value int
done := make(chan struct{})
go func() { value = 42; close(done) }()
<-done
return value
}
// Both read operations are atomic. The compound business operation is not.
func BrokenReservation() int64 {
var stock atomic.Int64
stock.Store(1)
var ready, finished sync.WaitGroup
ready.Add(2)
finished.Add(2)
release := make(chan struct{})
for range 2 {
go func() {
defer finished.Done()
available := stock.Load() > 0
ready.Done()
<-release
if available {
stock.Add(-1)
}
}()
}
ready.Wait()
close(release)
finished.Wait()
return stock.Load()
}
func Reserve(stock *atomic.Int64) bool {
for {
n := stock.Load()
if n <= 0 {
return false
}
if stock.CompareAndSwap(n, n-1) {
return true
}
}
}
func main() {
fmt.Println("published:", Published())
fmt.Println("atomic but oversold:", BrokenReservation())
var stock atomic.Int64
stock.Store(1)
fmt.Println("CAS:", Reserve(&stock), Reserve(&stock), stock.Load())
}package main
import (
"sync"
"sync/atomic"
"testing"
)
func TestPublished(t *testing.T) {
if Published() != 42 {
t.Fatal("publication failed")
}
}
func TestLogicalRace(t *testing.T) {
if got := BrokenReservation(); got != -1 {
t.Fatal(got)
}
}
func TestReserve(t *testing.T) {
var stock, success atomic.Int64
stock.Store(10)
var wg sync.WaitGroup
for range 100 {
wg.Go(func() {
if Reserve(&stock) {
success.Add(1)
}
})
}
wg.Wait()
if stock.Load() != 0 || success.Load() != 10 {
t.Fatal(stock.Load(), success.Load())
}
}//go:build racedemo
package main
import (
"sync"
"testing"
)
// Intentional negative fixture. Run only with -tags=racedemo -race.
func TestIntentionalRace(t *testing.T) {
n := 0
var wg sync.WaitGroup
wg.Add(2)
for range 2 {
go func() {
defer wg.Done()
for range 1000 {
n++
}
}()
}
wg.Wait()
_ = n
}Go 内存模型:Introduction 与 Advice,DRF-SC 与同步要求。 ↩︎ ↩︎ ↩︎
内存模型:Channel communication,关闭、无缓冲与容量 C 的同步规则。 ↩︎ ↩︎ ↩︎ ↩︎
内存模型:Formal model,sequenced-before、synchronized-before、happens-before 与数据竞争定义。 ↩︎
内存模型:Implementation Restrictions for Programs Containing Data Races。 ↩︎
sync,Go 1.27.2,Once 与 WaitGroup 的同步保证。 ↩︎
sync/atomic,Go 1.27.2,顺序一致性及 CompareAndSwap。 ↩︎ ↩︎
Go 官方:Data Race Detector,命令、报告与检测覆盖边界。 ↩︎