返回
创建于
状态
公开

我们现在来构造一个真实可演示的编译器重排问题示例,并且展示使用 atomic 如何解决

这个例子灵感来源于双重检查锁(Double-Checked Locking)线程间通信的错误方式,这是很多并发代码中常犯的坑。


一、问题背景:共享对象未初始化完就被使用

我们想创建一个对象,并且让另一个 goroutine 能在它被“就绪”后使用。


二、错误示例(未使用原子操作,可能被重排)

go
1package main
2
3import (
4	"fmt"
5	"time"
6)
7
8type Config struct {
9	value int
10}
11
12var config *Config
13var ready bool
14
15func writer() {
16	cfg := &Config{value: 42}
17	config = cfg       // 第一步:写入对象
18	ready = true       // 第二步:标记就绪
19}
20
21func reader() {
22	for {
23		if ready {
24			if config == nil {
25				fmt.Println("重排触发!读取到了 nil")
26			} else {
27				fmt.Println("读取成功:", config.value)
28			}
29			return
30		}
31	}
32}
33
34func main() {
35	go writer()
36	go reader()
37
38	time.Sleep(time.Second)
39}

问题解释:

理论上 ready = true 之前应该执行完 config = &Config{},但因为没有使用原子操作,编译器可能重排为

go
1ready = true     // 标志先设置
2config = cfg     // 对象尚未设置

于是 reader 就可能读取到 config == nil 的状态 —— 这就是典型的重排问题


三、运行结果(可能会出现):

bash
1重排触发!读取到了 nil

你可能多运行几次才会看到这个结果,因为它是并发+编译器重排引发的“非确定性错误”。


四、解决方案:使用 atomic 保证顺序性

我们用 atomic 来替代布尔标志,并加上内存屏障(memory barrier)

go
1package main
2
3import (
4	"fmt"
5	"sync/atomic"
6	"time"
7)
8
9type Config struct {
10	value int
11}
12
13var config *Config
14var ready int32
15
16func writer() {
17	cfg := &Config{value: 42}
18	config = cfg                       // 写数据
19	atomic.StoreInt32(&ready, 1)      // release 屏障:保证 config 写入在 ready 之前
20}
21
22func reader() {
23	for {
24		if atomic.LoadInt32(&ready) == 1 { // acquire 屏障:保证 config 写入在读取之后可见
25			if config == nil {
26				fmt.Println("不应该出现 nil!")
27			} else {
28				fmt.Println("读取成功:", config.value)
29			}
30			return
31		}
32	}
33}
34
35func main() {
36	go writer()
37	go reader()
38
39	time.Sleep(time.Second)
40}

使用 atomic 后的好处:

  • Storerelease 屏障,保证前面的 config 写入不能被“拖”到后面。
  • Loadacquire 屏障,保证后面的读取不能被“提前”。

运行这段代码,无论运行多少次,永远不会出现 config == nil


五、总结

项目未加 atomic使用 atomic
是否可能触发重排
是否线程安全
适合低延迟并发场景
是否保证初始化顺序是(release/acquire)

六、一句话记忆规则:

在多线程共享数据初始化场景中,若使用布尔变量做标志,一定要用 atomic.Load/Store 保证顺序。