Skip to content

Go 内存模型:怎样证明另一个 goroutine 能看见你的写入 ​

并发代码里,“我已经写了,他过一会儿再读”不是足够的理由。需要知道写和读之间是否有语言认可的同步关系。Go 内存模型就是用来回答这个问题:不同 goroutine 的读写,哪些顺序有保证,哪些程序存在数据竞争。

这篇文章不从 CPU 缓存示意图猜结论,而是从一段安全发布代码开始,再看一个没有数据竞争却仍然超卖的库存例子。学完应能分清三件事:执行时间上的先后、happens-before 关系、业务上的正确性。[1]

先看一条能证明的顺序 ​

一个 goroutine 写入结果,再关闭完成 channel;调用方等关闭以后读取:

go
var value int
done := make(chan struct{})
go func() { value = 42; close(done) }()
<-done
return value

本次程序把返回值打印出来:

text
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
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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 后不能沿用这条反向确认
关闭 channelclose 同步于因已关闭而返回零值的接收仅从缓冲里读出旧值,不等于观察到关闭
同一把 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:先检查库存是否大于零,之后再扣一个。两次访问本身都正确使用了原子操作。

go
available := stock.Load() > 0
// 这里可能切换到另一个请求。
if available {
    stock.Add(-1)
}

文末 BrokenReservation 用一个屏障让两个 goroutine 都完成检查,再同时放行扣减。初始库存为 1,本次输出:

text
atomic but oversold: -1

这不是靠睡眠碰运气。ready 等待两个检查都结束,关闭 release 后才允许扣减,所以“都看见可用,再各扣一次”的业务交错被明确构造出来。所有库存访问都是原子的,race 检测正常通过,但库存不应该小于零的业务约束被破坏了。

顺序请求 A请求 B库存
1Load 看到 1,判断可卖1
2Load 看到 1,判断可卖1
3Add(-1)0
4Add(-1)-1

这种结果依赖多个操作的交错,一般称为竞态条件或业务竞态。数据竞争是一个更具体的内存访问问题。把 data race 修掉,是正确性的必要步骤之一,不是业务正确性的全部证明。

修正:让检查和更新成为一个不可拆开的决定 ​

单变量库存可以用 CAS 循环:

go
for {
    n := stock.Load()
    if n <= 0 {
        return false
    }
    if stock.CompareAndSwap(n, n-1) {
        return true
    }
}

CAS 仅在当前值仍等于 n 时更新。别人先扣过一次,CAS 失败,本请求重新读取并检查。对初始库存 1 连续调用两次,本次输出:

text
CAS: true false 0

另一个测试让 100 个 goroutine 争 10 份库存,结果恰好 10 次成功、余量 0。它检查了本次压力条件下的行为,不是对所有复杂无锁算法的证明。若操作涉及“库存、预留量、订单状态”几个字段,一把锁包住完整检查和更新,通常更容易解释,也更容易审查。[8:1]

交互实验:原子操作与库存约束 ​

GO ATOMIC · 一件商品,两个请求

每次读写都是原子的,为什么还会超卖?

请求 A、B 各想预留一件商品。你决定谁先执行下一步,观察“检查有库存”和“真正扣减”之间的空隙,再用 CAS 循环比较同一次交错。

01

输入 · 选实现,再设初始库存

要守住的约束成功预留数 ≤ 初始库存等价地,共享库存始终 ≥ 0。单次 Add 原子性不等于完整预留操作的原子性。
保留当前实现与有效库存,清空事件和请求状态。
从一个明确的交错开始
02

过程 · 选择下一个请求

第 0 步 · 初始库存为 1。两个请求都还没读取;选一个请求推进。

当前共享库存 stock1只有 Add 或成功的 CAS 会改变它
请求 A
尚未返回
  1. 读取n = stock.Load()
  2. 检查n > 0?
  3. 扣减Add(−1)
  4. 返回true / false
本请求保存的局部 n尚未读取共享库存变了,这个局部值不会自动跟着变。
最近一次检查
待检查
已尝试扣减
0 次

当前 / 下一步Load 读取库存

请求 B
尚未返回
  1. 读取n = stock.Load()
  2. 检查n > 0?
  3. 扣减Add(−1)
  4. 返回true / false
本请求保存的局部 n尚未读取共享库存变了,这个局部值不会自动跟着变。
最近一次检查
待检查
已尝试扣减
0 次

当前 / 下一步Load 读取库存

每次只推进所选请求的一步。Load 和 Add / CAS 各自是原子操作;中间的检查只读本请求的局部 n。按 Tab 选按钮,Enter 或空格执行。

事件日志 · 0 步,按实际点击顺序

尚未执行。预设自动完成的步骤也会完整显示在这里。

03

结果 · 没有 data race,也可能做错生意

进行中:尚未超卖还有 2 个请求没结束

成功预留0 / 2预留操作最终返回 true 的请求数
拒绝预留0 / 2重新检查到无库存才返回 false;CAS 失败本身不算拒绝
超卖件数0成功预留数超出初始库存的部分

试试“交错:都通过检查”,再分别推进 A、B 扣减。 对比“串行”预设:一次安全结果不能证明所有交错都安全。

模型边界与对应的 Go 写法

这是确定性的教学交错,浏览器没有运行 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)。

go test -race:找到了就修,没找到还要继续推理 ​

race detector 观察实际执行的内存访问及其同步关系。正常测试用:

sh
go test -race -count=1 ./...

它只会检测本次运行涉及的路径。没执行到的超时分支、错误回调、极少见调度路径,都不会因为主路径通过测试就自动安全。还可以用 go run -race 或 go build -race 对实际程序做带检测的运行;平台与构建工具链需支持 race。[9]

文末有一个由 racedemo 构建标签隔离的反例,两个 goroutine 直接执行 n++。单独运行它,本次失败输出摘录如下,省略了内存地址和重复创建栈:

text
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 运行:

sh
go run .
go test -race -count=1 ./...
go vet ./...

故意产生数据竞争的文件由构建标签隔离。下面命令预期失败,退出码非零;timeout 用来防止教学反例意外长时间运行(GNU coreutils):

sh
timeout 15s go test -race -tags=racedemo -run TestIntentionalRace -count=1 .

go.mod ​

go.mod
module example.com/go-articles/06_memory

go 1.25.0

main.go ​

代码块 · 58 行 · 点击收起 / 展开
go
package 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())
}

main_test.go ​

go
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())
	}
}

race_demo_test.go ​

go
//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
}

  1. Go 内存模型:Introduction 与 Advice,DRF-SC 与同步要求。 ↩︎ ↩︎ ↩︎

  2. 内存模型:Channel communication,关闭、无缓冲与容量 C 的同步规则。 ↩︎ ↩︎ ↩︎ ↩︎

  3. 内存模型:Formal model,sequenced-before、synchronized-before、happens-before 与数据竞争定义。 ↩︎

  4. 内存模型:Implementation Restrictions for Programs Containing Data Races。 ↩︎

  5. 内存模型:Goroutine creation与 Goroutine destruction。 ↩︎

  6. 内存模型:Locks。 ↩︎

  7. sync,Go 1.27.2,Once 与 WaitGroup 的同步保证。 ↩︎

  8. sync/atomic,Go 1.27.2,顺序一致性及 CompareAndSwap。 ↩︎ ↩︎

  9. Go 官方:Data Race Detector,命令、报告与检测覆盖边界。 ↩︎

保留各书来源与许可 · 示例运行条件以正文为准