跳转到正文
zeno's blog
返回

Go 并发(三):内存模型、happens-before 与同步语义

专题: Go 并发

Table of contents

Open Table of contents

TL;DR

Go 内存模型定义了 happens-before 偏序关系,它是 sequenced-before(单 goroutine 内语句顺序)和 synchronized-before(跨 goroutine 同步操作)的传递闭包。只有通过 happens-before 关联的写操作才保证对读操作可见。2022 年(Go 1.19)修订正式赋予 sync/atomic 顺序一致性语义,与 C++ seq_cst、Java volatile 对齐。任何存在 data race 的 Go 程序行为未定义——不存在”良性竞争”。

实践速查表(happens-before 表格、race detector 用法)见 go-sync-包与并发原语.md 第 9 节。本文聚焦理论基础和形式化定义。


1. 为什么需要内存模型

1.1 问题的本质

现代计算机系统存在三个层面的重排序,它们共同导致”写操作的可见性”变得不确定:

源代码语句顺序


┌──────────────┐
│  编译器重排序  │  ← 编译器为优化性能可以重排无依赖的指令
└──────┬───────┘

┌──────────────┐
│  CPU 乱序执行  │  ← 处理器流水线并行执行无数据依赖的指令
└──────┬───────┘

┌──────────────┐
│  Store Buffer │  ← 写操作先进入本地缓冲区,延迟写入共享内存
└──────┬───────┘

  其他 CPU 看到的顺序  ← 可能与源代码顺序完全不同

单线程程序不受影响:编译器和 CPU 保证重排序在单线程视角下不可观察(as-if-serial 语义)。但在多线程/多 goroutine 环境下,一个 goroutine 的写操作何时、以何种顺序被另一个 goroutine 看到,取决于硬件和编译器的具体行为——如果没有规则约束,就没有正确的并发程序可写。

1.2 内存模型提供什么

内存模型是一份契约

1.3 顺序一致性(Sequential Consistency)

Leslie Lamport 1979 年定义的最简模型:

“The result of any execution is the same as if the operations of all the processors were executed in some sequential order, and the operations of each individual processor appear in this sequence in the order specified by its program.”

即:所有处理器的操作像是在一个全局时间线上交替执行,每个处理器自身的操作保持程序顺序。

为什么真实硬件不提供它:因为太昂贵。每次写操作都必须等待全局可见后才能继续执行下一条指令,相当于禁用了 store buffer、指令流水线并行——性能损失可达数量级。

1.4 没有内存模型的后果

语言/平台问题
C (C11 之前)线程行为完全未定义。POSIX Threads 靠文档约定,语言层面没有保证
Java (JDK 1.4 及之前)初代 JMM 存在严重缺陷:volatile 不提供 happens-before 语义,导致 Double-Checked Locking 在规范层面就是错误的(尽管在 x86 上碰巧能工作)
Go (1.19 之前)sync/atomic 没有正式的 happens-before 保证,使用靠惯例而非规范

2. Go 内存模型的形式化定义

来源:go.dev/ref/mem,Version of June 6, 2022

2.1 三个核心偏序关系

Sequenced before(单 goroutine 内)

同一个 goroutine 内,语句按照 Go 语言规范定义的求值顺序执行。这是最直觉的部分:

// 在同一个 goroutine 中:
a = 1         // (1)
b = 2         // (2)
// (1) sequenced-before (2)

Synchronized before(跨 goroutine)

由同步操作创建的跨 goroutine 偏序关系。当一个同步写操作 w 被一个同步读操作 r 观察到时,w synchronized-before r。

Happens before(传递闭包)

Happens-before = sequenced-before ∪ synchronized-before 的传递闭包。

Goroutine 1              Goroutine 2
    │                        │
    ▼                        │
  a = 1       ───┐           │
    │             │ sequenced │
    ▼             │  before   │
  ch <- 0    ─────┼──────── ─┤    synchronized
    │             │           │       before
    │             │           ▼
    │             │       <-ch        ← ch send → ch recv
    │             │           │
    │             │           │ sequenced
    │             │           │  before
    │             │           ▼
    │             └──────── print(a)  ← 保证看到 a = 1

2.2 可见性规则

对变量 v 的读操作 r 可以观察到写操作 w,当且仅当:

  1. w happens-before r
  2. 不存在另一个写操作 w’,使得 w happens-before w’ 且 w’ happens-before r

即:r 能看到的是 happens-before 链上”最近的”那次写。

对于无 data race 的程序,读操作的结果是确定的——只有一个 w 满足上述条件。

2.3 DRF-SC 保证

Go 采用 DRF-SC (Data-Race-Free Sequential Consistency) 模型(源自 Adve & Hill, 1990):

如果程序不存在 data race,其执行结果等价于某种顺序一致的 goroutine 交织。

这是一个精妙的契约:程序员负责消除 data race,编译器和硬件保证顺序一致性的表象。

2.4 内存操作的分类

Go 内存模型将内存操作分为两类:

类型操作
Read-like普通读、atomic 读、Mutex Lock、channel 接收
Write-like普通写、atomic 写、Mutex Unlock、channel 发送、channel 关闭
Bothatomic CAS(同时是 read-like 和 write-like)

Synchronizing 操作(atomic、Mutex、channel)同时建立 synchronized-before 关系;普通读写不建立。


3. 完整的同步操作与 happens-before 保证

以下是 Go 内存模型中所有建立 happens-before 关系的同步操作。

3.1 汇总表

操作 Ahappens-before操作 B关键细节
被导入包 q 的 init() 完成导入包 p 的 init() 开始仅直接 import 有保证
所有 init() 完成main.main() 开始
go f() 语句执行f() 开始执行goroutine 创建
goroutine 退出任何事件常见误区!
ch <- v(发送)<-ch(接收完成)适用于所有 channel
close(ch)<-ch 收到零值可替代发送作为信号
unbuffered ch:<-ch 完成ch <- v 完成注意方向!接收先于发送完成
buffered ch(cap=C):第 k 次接收完成第 (k+C) 次发送完成限流模式的理论基础
mu.Unlock() 第 n 次mu.Lock() 第 m 次 (n < m)Mutex/RWMutex
once.Do(f) 中 f 返回任何 once.Do 返回包括不执行 f 的调用者
atomic 操作 A 的效果被 B 观察到操作 BGo 1.19+ 正式保证
runtime.SetFinalizer(x, f)f(x) 执行

3.2 细节解析

Package init

// 包 a
func init() { /* ... */ }  // 先完成

// 包 b(import "a")
func init() { /* ... */ }  // 后执行

所有 init 函数在单个 goroutine 中按依赖拓扑序执行,全部完成后才启动 main.main()

Goroutine 创建 vs 退出

// 创建:go 语句 happens-before f() 开始
a = "hello"
go f()  // f() 保证能看到 a = "hello"

// 退出:没有任何 happens-before 保证!
go func() {
    a = "hello"
}()
// 这里读 a 是 data race,即使 goroutine 已经"退出"
// 必须用 channel/WaitGroup/mutex 等同步 goroutine 退出

Channel:buffered vs unbuffered 的微妙区别

Unbuffered channel 有一条额外规则:第 k 次接收完成 happens-before 第 k 次发送完成。

这意味着对于 unbuffered channel,接收方”先完成”,发送方”后完成”。这看起来反直觉,但它是 unbuffered channel 能作为同步点的本质原因:

unbuffered channel:
  发送方                 接收方
     │                    │
  ch <- v  ─────────── <-ch
     │   rendezvous 同步点   │
     │                    │
  (发送完成) ← 发生在 → (接收完成)之后

接收完成 happens-before 发送完成
发送操作 happens-before 接收完成

对于 capacity=C 的 buffered channel,第 k 次接收完成 happens-before 第 (k+C) 次发送完成。当 C=0(unbuffered)时,第 k 次接收完成 happens-before 第 k 次发送完成,与上述规则一致。

Atomic 操作(Go 1.19+ 正式保证)

// 如果 atomic 操作 A 的效果被 atomic 操作 B 观察到,则 A happens-before B
var x atomic.Int64
var data string

// Goroutine 1
data = "hello"        // (1)
x.Store(1)            // (2)  — (1) sequenced-before (2)

// Goroutine 2
if x.Load() == 1 {    // (3)  — 如果观察到 Store,则 (2) synchronized-before (3)
    print(data)        // (4)  — (3) sequenced-before (4)
}                      //       传递性:(1) happens-before (4),print "hello" 是安全的

4. 硬件内存模型与 Go 的抽象

4.1 x86/x64: Total Store Order (TSO)

x86-TSO 模型:
                    ┌─────────────┐
  CPU 0 ──write──→ │ Store Buffer │ ──drain──→ ┌──────────────┐
                    └─────────────┘            │              │
  CPU 0 ←──read──  直接看到自己的 Store Buffer   │  Shared       │
                    (store-buffer forwarding) │  Memory       │
                                               │              │
  CPU 1 ──write──→ ┌─────────────┐ ──drain──→ │              │
                    │ Store Buffer │            └──────────────┘
                    └─────────────┘
  CPU 1 ←──read──  看不到 CPU 0 的 Store Buffer

x86 只允许一种重排序:Store-Load 重排序。 即一个 CPU 的写操作可能还在 store buffer 中时,该 CPU 就执行了后续的读操作(读到另一个地址的旧值)。

重排序类型x86-TSO说明
Store-Load允许唯一允许的重排序
Store-Store禁止写操作保持程序顺序
Load-Load禁止读操作保持程序顺序
Load-Store禁止

后果:x86 上很多有 data race 的程序”碰巧能跑对”,因为 TSO 本身就提供了很强的顺序保证。

4.2 ARM/ARM64: Weak Ordering

ARM 采用弱内存模型,几乎所有重排序都可能发生:

重排序类型ARM说明
Store-Load允许
Store-Store允许写操作可以被重排
Load-Load允许读操作可以被重排
Load-Store允许
不同 CPU 看到不同的写入顺序允许x86 禁止这个(IRIW 测试)

唯一的不变量:对同一内存地址的写操作顺序(coherence),所有 CPU 必须达成一致。

4.3 内存屏障指令

Go runtime 在不同架构上插入的屏障指令:

架构屏障指令语义
x86/x64MFENCE全屏障(清空 store buffer)
x86/x64LOCK 前缀(如 LOCK XADDL隐式全屏障,常用于 atomic 操作
ARM64DMB ISH (Data Memory Barrier)全屏障(Inner Shareable domain)
ARM64DMB ISHSTStore-Store 屏障
ARM64LDAR / STLRAcquire-Load / Release-Store(ARMv8 原生支持)

Go 如何使用这些指令

来源:runtime/internal/atomic/asm_arm64.s、Go issue #43031

4.4 为什么 Go 程序员不需要关心硬件

Go 的内存模型是一个与硬件无关的抽象层。编译器和 runtime 负责在目标架构上插入正确的屏障指令,程序员只需要:

  1. 使用正确的同步原语(channel、mutex、atomic)
  2. 确保程序没有 data race

但理解硬件能解释一个关键现象:为什么 race bug 在 x86 上不复现,却在 ARM 上崩溃。x86 的 TSO 模型”免费”提供了很多顺序保证,掩盖了同步不足的 bug。当同样的代码运行在 ARM(如 Apple Silicon Mac、ARM 服务器、手机)上时,弱内存模型会暴露这些 bug。


5. 编译器重排序

5.1 Go 编译器可以做什么

在没有同步操作的情况下,Go 编译器可以:

5.2 Go 编译器不允许做什么(Go 1.19+ 明确规定)

来源:go.dev/ref/mem “Compiler Correctness” 一节

规则 1:不得将写操作移出条件分支

// 源代码
*p = 1
if cond {
    *p = 2
}

// 编译器不得重写为:
*p = 2       // ← 非法!如果 cond 为 false,其他 goroutine 会看到不应出现的 *p = 2
if !cond {
    *p = 1
}

规则 2:不得假设循环一定终止

n := 0
for e := list; e != nil; e = e.next {
    n++
}
i := *p     // 编译器不得将此读取移到循环之前
*q = 1      // 编译器不得将此写入移到循环之前

规则 3:不得假设函数一定返回或不含同步操作

f()         // 可能永不返回,或包含同步操作
i := *p     // 不得移到 f() 之前

规则 4:单次读取不得观察到多个值

i := *p
if i < 0 || i >= len(funcs) {
    panic("invalid")
}
// ... 大量代码 ...
funcs[i]()  // 编译器不得在此处重新从 *p 读取(可能已经被另一个 goroutine 改了)
            // 必须使用之前保存的 i 值

规则 5:单次写入不得写出多个值

*p = i + *p/2

// 编译器不得分解为:
*p /= 2     // ← 中间状态对其他 goroutine 可见
*p += i

5.3 同步操作作为”编译器屏障”

每个同步操作(channel 操作、mutex 操作、atomic 操作)同时充当编译器屏障——编译器不会将普通读写跨同步操作重排序。这是 happens-before 语义在编译器层面的实现基础。

5.4 一个”碰巧能工作”的危险例子

var data int
var ready bool  // 普通 bool,不是 atomic.Bool

// Goroutine 1
func setup() {
    data = 42     // (A)
    ready = true  // (B)
}

// Goroutine 2
func use() {
    for !ready {}  // (C) 忙等
    fmt.Println(data) // (D)
}

这段代码有三重问题

  1. 编译器可以重排 (A) 和 (B):因为 data 和 ready 之间没有数据依赖
  2. 编译器可以缓存 ready 到寄存器:(C) 的循环可能永远不终止(编译器把 ready 缓存到寄存器,永远不重新读取内存)
  3. 即使在 x86 上碰巧工作:换到 ARM 或换一个优化级别就可能崩溃

正确做法:使用 atomic.Bool 或 channel 来同步。


6. Data Race:定义与后果

6.1 形式化定义

Data race 发生在:

Data Race:
  Goroutine 1      Goroutine 2
      │                 │
    write x           read x        ← 两个操作访问同一个 x
      │                 │              至少一个是写
      │   (无 HB 关系)   │              两者之间没有 happens-before 关系
      │                 │
      ╰────── DATA RACE ──────╯

6.2 Go 的立场:Data Race = 未定义行为(但有限度)

Go 的 data race 语义介于 C/C++ 和 Java 之间:

语言Data Race 后果安全性
C/C++完全未定义行为(“catch fire”)——编译器可以做任何事最不安全
Go有限度的未定义:单机器字大小的读写保证原子性,但多字结构可能腐败中间
Java已定义(DRF-SC + racy 语义):racy read 至少看到某次实际写入的值最安全
JavaScript类似 Java(ES2017 SharedArrayBuffer 规范)安全

Go 对 racy 程序的保证和限制:

保证(实现必须做到)

不保证(可能出现)

// 危险:interface 是两个字(type + pointer),racy 访问可能看到混合值
var v interface{}

// Goroutine 1
v = "hello"       // type=string, ptr=&"hello"

// Goroutine 2
v = 42            // type=int, ptr=&42

// Goroutine 3
fmt.Println(v)    // 可能看到 type=string + ptr=&42 → 内存腐败!

6.3 “良性竞争”在 Go 中不存在

常见误区:“我只是读一个 bool,最多读到旧值,不会有问题。”

这是错误的。 在 Go(和 C/C++)中,data race 就是 undefined behavior。编译器可以基于”程序没有 data race”这个假设进行优化。具体来说:

  1. 编译器可能将 bool 读取缓存到寄存器,导致循环永不终止
  2. 编译器可能消除”冗余”的读取,即使另一个 goroutine 已经修改了值
  3. 编译器可能重排序周围的操作,破坏你以为存在的顺序

Java 能容忍”良性竞争”是因为 JMM 对 racy 读有明确语义(至少看到某次写入的值,且保持类型安全)。Go 选择不提供这个保证——为了给编译器更大的优化空间。

6.4 Race Detector

Go 的 race detector 基于 ThreadSanitizer v2 (TSan),通过编译时插桩实现。

工作原理

开销

必须在 CI 中启用

go test -race ./...

race detector 只能检测实际执行到的 race。一个测试覆盖率低的项目即使 -race 通过也不代表无 race。race detector 是必要条件,不是充分条件。

来源:ThreadSanitizer 论文(Serebryany & Iskhodzhanov, 2009);Go 自 1.1 版本引入 race detector


7. 2022 年修订深度解析(Go 1.19)

7.1 修订背景

Russ Cox 在 2021 年 6-7 月发表了三篇系列博文,系统分析了硬件和编程语言内存模型的现状,为 Go 内存模型的修订奠定了理论基础:

  1. Hardware Memory Models — 分析 x86-TSO、ARM/POWER 弱内存模型
  2. Programming Language Memory Models — 对比 Java JMM、C++ 内存模型、JavaScript 规范
  3. Updating the Go Memory Model — 提出 Go 内存模型的修改方案

7.2 修订前的问题

2009 年版 Go 内存模型存在以下缺陷:

问题影响
sync/atomic 没有正式的 happens-before 语义大量代码靠惯例使用 atomic 做同步,但规范层面无保证
新 sync 原语(Cond、Map、Pool、WaitGroup)未覆盖这些 API 的同步语义只存在于文档注释中
编译器优化限制未明确共享 C/C++ 后端的编译器可能应用对 Go 非法的优化
Data race 后果描述含糊被误读为”和 C/C++ 一样是 catch fire”

7.3 四项核心变更

变更 1:定位说明(Overview)

明确 Go 介于 C/C++(完全 UB)和 Java(完全定义)之间:

“Programs with data races are invalid in the sense that an implementation may report the race and terminate the program. But otherwise, programs with data races have defined semantics with a limited number of outcomes, making errant programs more reliable and easier to debug.”

变更 2:Atomic 操作正式获得顺序一致性语义

修订前: sync/atomic 没有正式的 memory ordering 保证
修订后: 所有 atomic 操作行为如同按某个全局顺序一致顺序执行
        如果 atomic 操作 A 的效果被 atomic 操作 B 观察到,则 A synchronized-before B

这等价于 C++ 的 memory_order_seq_cst 和 Java 的 volatile

变更 3:编译器限制正式成文

五条编译器必须遵守的规则(见第 5.2 节),防止共享 LLVM/GCC 后端的编译器将 C/C++ 的优化规则错误应用于 Go。

变更 4:多字结构竞争风险警告

明确指出 interface、string、slice、map 等多字结构的 racy 访问可能导致内存腐败。

7.4 为什么 Go 只提供顺序一致性 atomic

C++ 提供三个级别的 atomic 语义:

级别C++Go为什么
Relaxedmemory_order_relaxed不支持太容易用错,几乎不可能正确推理
Acquire/Releasememory_order_acq_rel不支持(runtime 内部使用)破坏 DRF-SC,需要推理部分有序,复杂度远超收益
Seq_Cstmemory_order_seq_cstsync/atomic 默认且唯一最安全、最易推理、性能差距在缩小

Russ Cox 的决策理由:

  1. ARMv8 的 LDAR/STLR 指令原生提供 seq_cst 语义,它们也是 ARM 推荐的 acquire/release 实现方式——所以在 ARMv8 上,seq_cst 和 acq/rel 的性能几乎没有差异
  2. Go 标准库本身就依赖 seq_cstsync.WaitGroup 用一对 atomic uint32 值通信,runtime 的信号量实现用独立的 atomic 字来交互——这些模式在 acquire/release 语义下会静默失败
  3. 暴露 acq/rel 会创造出 race detector 无法检测的 bug 类别:一段在 x86 上工作正确的 acq/rel 代码可能在 ARM 上失败,而 race detector 不会报告(因为 atomic 操作本身不是 race)

7.5 对现有代码的影响

零影响。修订只是将已有的惯例和实现行为提升为规范保证。所有在修订前正确使用 atomic 的代码在修订后仍然正确——区别仅在于现在有了规范保障。


8. 常见模式与内存模型分析

8.1 Double-Checked Locking(正确实现)

错误版本(Go 内存模型明确列为反模式):

var config *Config
var done bool

func getConfig() *Config {
    if !done {           // 普通 bool 读:没有 happens-before 保证
        mu.Lock()
        if !done {
            config = loadConfig()
            done = true
        }
        mu.Unlock()
    }
    return config        // 可能看到 done=true 但 config 尚未初始化
}

正确版本 1(推荐):sync.Once

var (
    config *Config
    once   sync.Once
)

func getConfig() *Config {
    once.Do(func() {
        config = loadConfig()
    })
    return config  // once.Do 的 happens-before 保证 config 已初始化
}

正确版本 2:atomic + mutex

var (
    config atomic.Pointer[Config]
    mu     sync.Mutex
)

func getConfig() *Config {
    if c := config.Load(); c != nil {
        return c  // fast path: atomic Load 保证看到完整初始化的对象
    }
    mu.Lock()
    defer mu.Unlock()
    if c := config.Load(); c != nil {
        return c  // double-check under lock
    }
    c := loadConfig()
    config.Store(c)  // atomic Store happens-before 后续 atomic Load
    return c
}

8.2 Publication Pattern(安全发布)

将完全构造好的对象原子地发布给其他 goroutine:

type Server struct {
    addr    string
    handler http.Handler
    // ... 很多字段
}

var current atomic.Pointer[Server]

// 写方(低频)
func updateServer(addr string, h http.Handler) {
    s := &Server{
        addr:    addr,
        handler: h,
    }
    // Store 之前的所有字段赋值 sequenced-before Store
    // Store happens-before 后续的 Load
    // 因此 Load 保证看到完全初始化的 Server
    current.Store(s)
}

// 读方(高频)
func handleRequest(w http.ResponseWriter, r *http.Request) {
    s := current.Load()  // 保证看到完全初始化的 *Server 或 nil
    if s != nil {
        s.handler.ServeHTTP(w, r)
    }
}

8.3 Flag Signaling

// 方案 1:atomic.Bool(适用于"done"标志)
var done atomic.Bool

// producer
func produce() {
    doWork()
    done.Store(true)  // happens-before 后续 Load
}

// consumer
func consume() {
    for !done.Load() {
        runtime.Gosched()  // 让出 CPU,避免忙等
    }
    // 保证看到 doWork() 的所有副作用
}

// 方案 2:channel(更 Go-idiomatic)
func produce(done chan<- struct{}) {
    doWork()
    close(done)  // happens-before 接收零值
}

func consume(done <-chan struct{}) {
    <-done
    // 保证看到 doWork() 的所有副作用
}

channel 方案更惯用,也更安全(不需要忙等)。atomic.Bool 方案在极端低延迟场景有优势。

8.4 Copy-on-Write with atomic.Value

type RouteTable struct {
    routes map[string]Handler
}

var table atomic.Value  // 存储 *RouteTable

func init() {
    table.Store(&RouteTable{routes: make(map[string]Handler)})
}

// 读方(高频,无锁)
func lookup(path string) Handler {
    t := table.Load().(*RouteTable)
    return t.routes[path]  // 安全:map 本身不会被修改
}

// 写方(低频,需要锁防止并发写)
var writeMu sync.Mutex

func addRoute(path string, h Handler) {
    writeMu.Lock()
    defer writeMu.Unlock()

    old := table.Load().(*RouteTable)
    // 复制整个 map(copy-on-write)
    newRoutes := make(map[string]Handler, len(old.routes)+1)
    for k, v := range old.routes {
        newRoutes[k] = v
    }
    newRoutes[path] = h
    table.Store(&RouteTable{routes: newRoutes})  // 原子替换
}

注意atomic.Value 要求所有 Store 的值必须是同一具体类型,否则 panic。


9. 跨语言内存模型对比

维度GoC++JavaRustJavaScript
基本策略DRF-SC + 有限 UBDRF-SC or Catch FireDRF-SC + 定义 racy 语义DRF-SC or Catch Fire + 所有权系统DRF-SC + 定义 racy 语义
Atomic 语义级别仅 Seq_CstRelaxed / Acq-Rel / Seq_Cst仅 Volatile (≈Seq_Cst);Java 9+ VarHandle 增加更多模式Relaxed / Acq-Rel / Seq_Cst(继承 C++)仅 Seq_Cst(SharedArrayBuffer)
Data race 后果有限 UB(单字原子性保证)完全 UB已定义(保证类型安全)完全 UB(但 Safe Rust 无法构造 race)已定义
编译时 race 防护无(靠 -race 运行时检测)Send/Sync trait——编译期阻止大部分 race
复杂度极高中(类型系统复杂,但内存模型继承 C++)
性能控制力低(不能用弱 atomic)极高极高
典型 race bug忘同步、误以为良性误用 relaxed atomic、UB 导致安全漏洞final 字段遗漏、volatile 误用unsafe 块中的 data race几乎不发生(单线程为主)

关键洞察


10. Pitfalls

Pitfall 1: 假设 goroutine 退出会同步

var result string

go func() {
    result = "done"
}()

time.Sleep(time.Second)  // "等它跑完"
fmt.Println(result)      // DATA RACE!goroutine 退出不创建 happens-before

Why:Go 内存模型明确规定 “The exit of a goroutine is not guaranteed to be synchronized before any event in the program.” time.Sleep 不是同步操作。

How to avoid:用 channel、WaitGroup 或 mutex 同步 goroutine 退出。

Pitfall 2: “我的机器上没问题”(x86 掩盖 ARM 上的 race)

var data int
var flag int32

// Goroutine 1
data = 42
atomic.StoreInt32(&flag, 1)

// Goroutine 2
if atomic.LoadInt32(&flag) == 1 {
    fmt.Println(data)  // x86 上总是 42,ARM 上可能是 0
}

上面的代码实际上在 Go 1.19+ 的内存模型下是正确的(atomic Store/Load 创建 happens-before),但如果将 atomic 操作替换为普通的变量读写:

var data int
var flag bool  // 普通 bool,不是 atomic!

// Goroutine 1
data = 42
flag = true    // 普通写,没有 happens-before 保证

// Goroutine 2
if flag {      // 普通读——这是 data race
    fmt.Println(data)  // x86 上碰巧能工作,ARM 上可能崩溃
}

Why:x86 的 TSO 模型禁止 Store-Store 重排,所以 data = 42 总是在 flag = true 之前对其他 CPU 可见。ARM 的弱内存模型不提供这个保证。

How to avoid:跨 goroutine 共享数据必须使用同步原语。不要依赖”在 x86 上碰巧能跑”。

Pitfall 3: 对多字值使用 atomic 思维

var config struct {
    Host string  // string 是两个字:(pointer, length)
    Port int
}

// Goroutine 1
config.Host = "localhost"
config.Port = 8080

// Goroutine 2
fmt.Printf("%s:%d", config.Host, config.Port)
// 可能看到 Host 的 pointer 来自新值,length 来自旧值 → 读到垃圾数据

Why:atomic 操作只对单机器字有效。string、slice、interface、map 等多字结构的 racy 访问可能看到不一致的内部状态,导致内存腐败。

How to avoid:使用 atomic.Pointer[Config] 原子替换整个结构体指针,或用 mutex 保护。

Pitfall 4: 误解 channel 方向与 happens-before 的关系

ch := make(chan int, 1)  // buffered!

// 错误推理:"发送 happens-before 接收",所以:
go func() {
    data = 42
    ch <- 1      // 发送
}()
<-ch             // 接收
print(data)      // 这里是安全的吗?

这个例子其实是安全的——因为对于所有 channel,ch <- v happens-before 对应的 <-ch 完成。

但反过来不对——对于 buffered channel,接收完成不一定 happens-before 发送完成(只有 unbuffered channel 有这条规则)。

ch := make(chan int, 1)  // buffered

go func() {
    <-ch         // 接收
    print(data)  // 安全吗?
}()
data = 42
ch <- 1          // 发送 → 保证 happens-before 对方的 <-ch 完成,所以安全

How to avoid:记住核心规则是”发送 happens-before 接收完成”——这对所有 channel 都成立。额外的”接收完成 happens-before 发送完成”只对 unbuffered channel 成立。

Pitfall 5: init() 顺序假设超出直接 import

// package a
func init() { /* ... */ }

// package b(import "a")
func init() { /* ... */ }  // 保证在 a.init() 之后

// package c(import "a")
func init() { /* ... */ }  // 保证在 a.init() 之后

// 但 b.init() 和 c.init() 之间没有顺序保证!
// 它们可能以任意顺序执行(取决于编译器的 import 遍历顺序)

Why:happens-before 只在直接 import 关系上建立。同级 package 之间没有保证。

How to avoid:不要依赖 init() 的相对顺序来通信。如果需要跨包初始化顺序,用 sync.Once 或显式初始化函数。

Pitfall 6: “良性竞争”谬误

var debug bool  // 普通 bool

// 配置热更新 goroutine
go func() {
    for range ticker.C {
        debug = loadDebugFlag()  // 普通写
    }
}()

// 业务 goroutine
if debug {  // 普通读——DATA RACE
    log.Println("debug info")
}

“只是个 bool,最多读到旧值,不会有问题” 是错误的推理。

实际风险

  1. 编译器可能将 debug 缓存到寄存器,永远不重新读取
  2. 即使每次都读内存,也不保证看到最新的写入值
  3. data race 是 UB,race detector 会报告,CI 会失败

How to avoid:使用 atomic.Bool

Pitfall 7: 不在 CI 中启用 -race

“太慢了”不是借口。

# CI 配置示例
test:
  script:
    - go test -race -count=1 ./...
    # -count=1 禁用测试缓存,确保每次都执行

How to avoid:CI 中强制 -race。如果测试太慢,优化测试本身而不是关掉 race 检测。

Pitfall 8: atomic.Value 存储不同类型导致 panic

var v atomic.Value
v.Store("hello")  // 类型确定为 string
v.Store(42)        // panic: sync/atomic: store of inconsistently typed value

Whyatomic.Value 内部通过 efaceOf 检查类型一致性,第一次 Store 锁定具体类型。

How to avoid:优先使用 atomic.Pointer[T](Go 1.19+),它在编译期保证类型安全。如果必须用 atomic.Value,确保所有 Store 的值类型一致,通常通过自定义 struct 包装来保证。

Pitfall 9: 忙等(busy-wait)不含同步操作

var ready uint32

// Goroutine 1
doSetup()
ready = 1  // 普通写

// Goroutine 2
for ready == 0 {}  // 普通读——编译器可以将其优化为无限循环
use()

Whyready 的读写不是 atomic 操作,编译器有权假设单 goroutine 内值不会变化,将循环优化为 for { }。即使不优化,也是 data race。

How to avoidatomic.LoadUint32 / atomic.StoreUint32,或更好地用 channel/sync.Cond。


11. 生产 Checklist


References


分享这篇文章:

上一篇
Go 并发(四):无锁编程层次-从 CAS 到消除共享
下一篇
Go 并发(二):sync 包与并发原语