Go 内存模型

Go 内存模型定义跨 goroutine 的读写可见性与排序必须依赖哪些同步事件;区分语言规范保证与编译器/CPU 重排的实现现实。

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go 内存模型

这个概念为什么会出现

单线程里“先写后读”的直觉来自程序顺序。多 goroutine 共享变量时:

  • 编译器可能重排无数据依赖的访问
  • CPU 缓存使写入不立即对其他核可见
  • 没有同步时,读可能看到陈旧值、部分更新,或与 data race 一起进入未定义般危险的行为

Go 内存模型回答:在什么条件下,goroutine A 的写入保证被 goroutine B 的读取看到?它是并发正确性的语义底座,不是 GC 教程,也不是“把变量设为 volatile”的清单。

[!abstract] 一句话理解 除非通过内存模型承认的同步操作建立 happens-before,否则一个 goroutine 的写不保证被另一个 goroutine 的读看到;程序顺序只约束同一 goroutine 内。

最小可运行示例

先把示例放进可观察实验,再看代码:

场景:配置热更新「发布新开关」必须让工作协程看见

特性开关服务在后台 goroutine 写好 featureOn,再通知请求路径读取。
若只用普通变量 + 忙等/flag,规范不保证读者一定看到新值,且常伴随 data race。
正确做法:用 channel(或锁/atomic)建立 happens-before,把「写配置」发布给「读配置」。

下面先给错误示范(不要当生产模板),再给 channel 发布的正确最小模型。

package main

import (
	"fmt"
	"time"
)

// 错误示范:无同步发布特性开关(实验用,勿照抄上线)。
var featureOn, ready int

func badPublish() {
	go func() {
		featureOn = 1 // 想发布「开关打开」
		ready = 1     // 想当「发布完成」标志
	}()

	for ready == 0 {
		// 忙等:既可能 data race,也不构成内存模型承认的同步
	}
	// 即使碰巧退出循环,规范仍不保证一定读到 featureOn==1
	fmt.Println("bad featureOn=", featureOn)
	time.Sleep(0) // sleep 不能修复可见性语义
}
package main

import "fmt"

// publishFeatureFlag 正确:写开关后 close(done) 做发布。
//
// 业务意图:热更新完成后,所有等到 done 的请求路径必见新值。
// 教学点:close 完成 HB 因关闭而完成的接收 → 接收之后的读可见之前的写。
func publishFeatureFlag() {
	var featureOn int
	done := make(chan struct{})

	go func() {
		featureOn = 1  // (1) 写共享配置
		close(done)    // (2) 发布:关闭 happens-before 接收完成
	}()

	<-done                 // (3) 等发布完成
	fmt.Println(featureOn) // (4) 保证看到 1
}

func main() {
	publishFeatureFlag()
}

建议运行(只跑正确示例文件时):

go run .
# 错误示范可用 go run -race 观察 data race 报警(若合并进同一程序)

期望输出(正确示例):

1

结合场景再看三个关注点

  1. 程序顺序只约束当前 goroutine
    后台「先写 featureOn 再写 ready」不自动让另一 goroutine 有序看见。

  2. 忙等 / sleep 不是同步原语
    配置热更新用它们只会得到偶发正确,规范与 -race 都不认。

  3. 发布要靠模型承认的同步
    channel、Mutex、atomicOnce 等建立 HB;细节见 go-happens-before-and-synchronization

核心概念与准确模型

规范文档位置

权威文本:The Go Memory Model。它属于语言语义规范向文档,描述程序员可依赖的保证。

核心词汇

术语含义
程序顺序单 goroutine 内语句顺序带来的 happens-before
Happens-before偏序:若事件 e1 HB e2,则 e1 对 e2 “可见”意义上的排序
同步创建跨 goroutine HB 边的操作(锁、channel 等)
Data race冲突访问且无 HB 排序——必须消除

更细的同步列表见 go-happens-before-and-synchronization

同一 goroutine 内

在单个 goroutine 中,内存模型保证行为如同按程序顺序执行(对数据依赖与语言规则而言)。你不必为单线程函数插入屏障。

跨 goroutine

没有同步时:

  • 读可能看到旧值
  • 多个写的可见顺序可能出乎“源码行号”直觉
  • 存在 data race 时,整个程序行为不再有可靠讨论基础(应用 -race 抓)

同步建立可见性

写共享状态  ──HB──►  同步事件 S  ──HB──►  另一 goroutine 读

典型 S:

  • Unlock → 之后的 Lock
  • channel 发送与对应接收
  • Once.Do 中函数完成
  • goroutine 启动的 go 语句相对新 goroutine 开始
  • 某些 atomic 操作(见文档与 sync/atomic 语义)

与实现的关系

层面内容
规范哪些操作建立 HB;race 的定义;程序员模型
实现编译器重排如何遵守 As-if;CPU 内存序;具体屏障插入
工具race detector 用插桩近似发现 race(实现工具)

实现必须在可观测行为上遵守内存模型;你不能依赖“某 CPU 上碰巧总是看见”。

与 GC 的区别

  • 内存模型:并发可见性/排序
  • GC:对象何时可回收

二者都谈“内存”,问题域不同。

边界情况与反直觉行为

1. 双检锁与发布

错误地用普通 bool flag 发布大结构,无同步:其他 goroutine 可能看到 flag 为真但结构字段未初始化完整。

2. 切片头与底层数组

复制 slice 描述符是值拷贝;底层数组并发写仍需同步。模型看的是内存位置,不是变量名。

3. range 与共享游标

多 goroutine 共游标无锁 → race。

4. “读多写少”仍要同步

无写冲突不代表不要同步;一写多读无 HB 仍是 data race(在 Go 模型下)。

5. atomic 不是万能锁

单个字的 atomic 有其顺序保证;复合不变量仍可能需要 Mutex。

常见误区

[!warning] 常见误区:代码行序 = 全局时间序 错误:以为 A 函数写在 B 启动前就全局先发生。
正确:跨 goroutine 只认 HB 边。

[!warning] 常见误区:用 sleep 当同步 错误:测试里 sleep 让 race “消失”。
正确:真实原语;测试加 -race

[!warning] 常见误区:没崩溃就是对的 错误:x86 上强模型“看起来对”。
正确:按规范推理 + race detector + 审查。

[!warning] 常见误区:内存模型=手动管理内存 错误:与 C++ allocator 混淆。
正确:这里是并发语义,不是 free。

工程实践

  1. 所有权优先:数据尽量单 goroutine 拥有,channel 交接。
  2. 共享则同步:Mutex、RWMutex、atomic、channel。
  3. 文档并发契约:谁可并发调用、是否线程安全。
  4. CI -race:见 go-race-detector
  5. 不要发明屏障:勿用无文档保证的“技巧 flag”。
  6. 读官方 mem 文档再读实现博客,顺序勿反。
type Config struct {
	mu   sync.RWMutex
	data map[string]string
}

func (c *Config) Get(k string) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.data[k]
	return v, ok
}

可验证实验

实验 1:channel 发布

对比无同步忙等与 close(done) 两种发布,后者在模型下正确。

实验 2:-race

对无锁共享计数跑 go test -race

实验 3:Mutex 可见性

Unlock 前写字段,另一 goroutine Lock 后读,确认稳定。

实验 4:错误 flag

用普通 bool 做“数据已就绪”无锁发布,在 -race 下观察(勿当生产模式)。

本节总结

  • 问题:跨 goroutine 写何时可见。
  • 答案:依赖 happens-before 同步,而非行号或 sleep。
  • 规范 vs 实现:mem 文档给保证;编译器/CPU/检测器是实现与工具。
  • 下一步:把同步事件清单吃透——go-happens-before-and-synchronization

自测题

概念题

  1. 程序顺序保证跨 goroutine 可见性吗?
  2. data race 与“逻辑结果偶发错误”关系?
  3. 内存模型与 GC 各管什么?

代码推理题

var done bool
var msg string
go func() { msg = "hi"; done = true }()
for !done {}
fmt.Println(msg)

为何不安全?

工程思考题

库要在初始化完成后只读共享配置。可选哪些发布手段?

参考答案

展开
  1. 不保证。
  2. data race 是未同步冲突访问;即使“看起来对”仍属严重缺陷。
  3. 模型管并发可见性;GC 管回收。
    代码题:done/msg 存在 data race;即使看见 done 为 true 也不保证看见 msg 的写(无 HB)。
    工程题:sync.Once、init 完成后 channel 关闭、Mutex 保护、不可变快照指针的 atomic 发布等——均需符合文档语义。

延伸阅读与资料来源

资料类型支撑
The Go Memory Model规范向HB、同步、race
Package sync标准库锁与 Once 等
Package atomic标准库原子操作语义入口
Data Race Detector工具文档动态检测
Share Memory By Communicating博客所有权风格

笔记元信息

  • 建议文件名:go-memory-model.md
  • 所属阶段:阶段六 / 并发深水
  • 学习顺序:会用 channel/mutex 之后读规范
  • 建议下一篇:happens-before 与同步
  • 本篇状态:已深化(规范 vs 实现分明;最小正反例)
创建于 2026/6/20 更新于 2026/7/15