返回
创建于
状态公开
我们现在来构造一个真实可演示的编译器重排问题示例,并且展示使用 atomic 如何解决。
这个例子灵感来源于双重检查锁(Double-Checked Locking) 和线程间通信的错误方式,这是很多并发代码中常犯的坑。
一、问题背景:共享对象未初始化完就被使用
我们想创建一个对象,并且让另一个 goroutine 能在它被“就绪”后使用。
二、错误示例(未使用原子操作,可能被重排)
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{},但因为没有使用原子操作,编译器可能重排为:
1ready = true // 标志先设置
2config = cfg // 对象尚未设置于是 reader 就可能读取到 config == nil 的状态 —— 这就是典型的重排问题。
三、运行结果(可能会出现):
1重排触发!读取到了 nil你可能多运行几次才会看到这个结果,因为它是并发+编译器重排引发的“非确定性错误”。
四、解决方案:使用 atomic 保证顺序性
我们用 atomic 来替代布尔标志,并加上内存屏障(memory barrier):
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 后的好处:
Store是 release 屏障,保证前面的 config 写入不能被“拖”到后面。Load是 acquire 屏障,保证后面的读取不能被“提前”。
运行这段代码,无论运行多少次,永远不会出现 config == nil。
五、总结
| 项目 | 未加 atomic | 使用 atomic |
|---|---|---|
| 是否可能触发重排 | 是 | 否 |
| 是否线程安全 | 否 | 是 |
| 适合低延迟并发场景 | 否 | 是 |
| 是否保证初始化顺序 | 否 | 是(release/acquire) |
六、一句话记忆规则:
在多线程共享数据初始化场景中,若使用布尔变量做标志,一定要用 atomic.Load/Store 保证顺序。