happens_before以及golang的运用

Happens-before 是一个内存模型中的核心概念,它定义了在并发程序中,一个操作(如写入变量)的结果保证对另一个操作(如读取该变量)可见的规则。

它不是关于“真实物理时间”的先后,而是一种关于内存操作顺序和可见性的逻辑承诺。如果事件 A happens-before 事件 B,那么 A 操作对内存产生的所有影响,都必须在 B 操作开始之前,对 B 完全可见。

它解决了并发编程中最棘手的两个问题:指令重排和内存可见性。

  1. 对抗混乱的“幕后优化”:为了追求极致性能,编译器和 CPU 会对你的代码进行“优化”,比如打乱指令的执行顺序 (Instruction Reordering)。在单线程中这通常没问题,但在并发环境下,这种重排可能导致灾难性的后果。
  2. 确保数据同步:在现代多核 CPU 架构中,每个核心都有自己的高速缓存 (Cache)。一个核心对数据的修改不会立即同步到主内存或其他核心的缓存中。这就导致一个 goroutine 修改了数据,另一个 goroutine 可能根本“看不见”这个修改。

1. 介绍

1.1 happens-before 保证

如果A happens-before B,那么A操作对内存的影响 将对执行B的线程(且执行B之前)可见。

  1. 如果操作A和B在相同的线程中执行,并且A操作的声明在B之前,那么A happens-before B。
  2. happens-before关系都是可传递的:如果A happens-before B,同时B happens-before C,那么A happens-before C。当这些关系发生在不同的线程中,传递性将变得非常有用。

1.2 和时间无关

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var a string
var done bool

func setup() {
a = "hello, world" (1)
done = true (2)
}

func main() {
go setup()
// 这里的 for 循环是致命的,它没有任何 happens-before 保证
for !done { (3)
}
print(a) // (4) 可能会打印空字符串,甚至 panic!
}

避坑done 的写入和 a 的写入对 main goroutine 的可见性没有保证。即使 done 变成了 truea 的赋值也可能还没“同步”过来。必须使用 Channel 或 Mutex 来建立 happens-before 关系。

在(1)和(2)之间存在happens-before 关系,同时在(3)和(4)之间也存在happens-before关系。并不意味着在(2)和(3)之间存在happens-before 关系!

2. golang 的 happens-before

2.1 场景保证

  • 包的初始化 (Initialization)
    • main 函数的执行 happens-after 所有 init 函数执行完毕。
    • init 函数的执行顺序遵循包的导入顺序。
  • Goroutine 的创建与销毁
    • 启动一个新 goroutine 的 go 语句 happens-before 该 goroutine 内代码的执行。
    • 一个 goroutine 的退出(无论是正常返回还是 panic)不保证 happens-before 程序中的任何其他事件。这就是为什么需要 sync.WaitGroup 来等待 goroutine 完成。
  • Channel 通信 (最核心的 Go 特色)
    • 对一个 Channel 的发送操作 happens-before 从该 Channel 完成的接收操作。
    • 关闭一个 Channel 的操作 happens-before 从该 Channel 因关闭而收到零值的接收操作。
    • 对于无缓冲 Channel,发送操作 happens-before 接收操作完成。
    • 对于有缓冲 Channel(容量为 C),第 k 次接收 happens-beforek+C 次发送完成
  • 锁 (sync.Mutexsync.RWMutex)
    • 对一个 Mutex 的第 nUnlock() 调用 happens-beforen+1Lock() 调用返回。
  • sync.Once
    • once.Do(f) 中函数 f 的返回 happens-before 任何后续的 once.Do(f) 调用返回。
  • sync.WaitGroup
    • wg.Add(n) 调用和 nwg.Done() 调用 happens-before wg.Wait() 的返回。

2.2 有无缓冲 channel 区别

无缓冲 Channel (Unbuffered Channel):其核心目的是同步 (Synchronization)。它强制发送方和接收方“握手”。

有缓冲 Channel (Buffered Channel):其核心目的是解耦 (Decoupling)。它在发送方和接收方之间提供一个缓冲区,以减少等待。

这个核心目的的不同,直接导致了它们 happens-before 保证的强弱差异。

类型Happens-before 保证行为描述
无缓冲 Channel强同步保证对 Channel 的发送操作 happens-before 从该 Channel 完成的接收操作。因为发送方会一直阻塞,直到接收方准备好并取走数据。
有缓冲 Channel弱同步保证对 Channel 的发送操作 happens-before 从该 Channel 完成的接收操作。这个规则依然成立,但发送操作完成不等于发送方阻塞。只要缓冲区未满,发送方会立刻完成并继续执行。

关键点在于“发送操作完成”这个词的含义。

  • 对于无缓冲 Channel,“发送操作完成”意味着接收方已经取走了数据。

  • 对于有缓冲 Channel,“发送操作完成”意味着数据已经成功放入了缓冲区,发送方可以继续干别的事了,它根本不知道接收方什么时候会来取。

    生产者只需把货物放进仓库就可以离开,消费者有空了自己去仓库取。这解耦了二者的执行节奏,提升了整体吞吐量。happens-before 保证的是你放入仓库的那个货物本身是完整的,但它不保证你放下货物的那一刻,消费者就已经站在仓库门口了。

无缓冲 Channel → “当面递送挂号信” (Relay Race Baton Pass)

有缓冲 Channel → “投递到小区的信箱” (Mailbox)

一个带缓存的channel,则print(a)打印的结果不能够保证是”hello world”。

1
2
3
4
5
6
7
8
9
10
11
var c = make(chan int, 1)
var a string
func f() {
a = "hello, world" // (1)
<-c // (2)
}
func main() {
go f()
c <- 0 // (3)
print(a) // (4)
}

因为这里不再有任何同步保证,使得(2) happens-before (3)。

3. 提问问题

3.1 注意事项

永远不要依赖 time.Sleep() 或者忙等待(for {})来进行 goroutine 间的同步。这些方法完全不提供任何 happens-before 保证,是典型的“侥幸编程”。