23.6 happened-before 关系

一句话

happened-before 是分布式系统里最基础的那个不依赖物理时钟的“先后”定义:a → b 当且仅当存在一条从 a 到 b 的消息链(含同进程内的程序顺序);它是偏序而不是全序,两个事件不可比就是并发——并发不是“同时发生”,而是“无法从因果上排序”。

定义:三条规则,一个传递闭包

Lamport 在 1978 年的《Time, Clocks, and the Ordering of Events in a Distributed System》里给出的定义只有三条:

# 规则 记法
1 同一进程内,先执行的事件先于后执行的事件(程序顺序) a ≺_p b ⇒ a → b
2 消息的发送先于该消息的接收 send(m) → recv(m)
3 传递性:a → b 且 b → c,则 a → c → 是传递关系

关键在于:没有第四条。→ 是满足上面三条规则的最小关系(传递闭包),凡是不能靠这三条推出来的先后,一律不算。

进程 P1:   a1 ──▶ a2 ── send(m) ──▶ a3
                       │
                       ▼
进程 P2:   b1 ──▶ b2 ─ recv(m) ──▶ b3 ── send(n) ──▶ b4
                                                      │
                                                      ▼
进程 P3:   c1 ─────────────────────────────── recv(n) ──▶ c2

由规则可得:a2 → b2 → b3 → c2,再由传递性:a2 → c2
但:a3 与 b2 不可比 —— a3 虽然在 a2 之后,却没有一条边通向 b2

🔑 “不可比”不是“同时发生”,也不是“谁先谁后都行”:它是没有任何因果依据。两个不可比的事件在物理时间上可能差几秒,也可能真的同时。

六条性质,决定了它能干什么、不能干什么

性质 含义 工程后果
严格偏序 非自反(a → a 不成立)、反对称(不能互达)、传递 不能拿来给事件“打分排序”,只能比“有没有路”
不是全序 并发事件之间没有先后 需要一个全序时,必须人为加决胜规则(如进程 ID)
并发 = 不可比 a ∥ b 定义为 ¬(a→b) ∧ ¬(b→a) 冲突检测的唯一严谨判据:只有并发写才需要合并
不依赖时钟 定义里没有任何物理时间 时钟漂移、NTP 误差都不影响结论
蕴含物理先后 消息传播需要时间,所以 a → b 意味着 a 在物理时间上不晚于 b 它给出的顺序不会与“真实发生顺序”矛盾
不等于实时顺序 两个进程各干各的、没有消息往来,实时上分先后也仍然不可比 线性一致比“尊重 HB”更强(见下节)

最后一条最容易反直觉:HB 是实时顺序的子集,不是超集。a → b 一定意味着 a 在实时上不晚于 b;但“a 在实时上早于 b”推不出 a → b——没有消息链,就没有因果关系。

与四个一致性模型的关系

这是它最实用的地方:不同的一致性模型,对 → 的要求不同。

模型 是否必须尊重 HB 还额外要求什么 主场
线性一致 是 还要与实时顺序一致(全序是 HB 的线性扩展) 16.2 线性一致性
顺序一致 是 所有进程共享一个全序,且保程序顺序 16.3 顺序一致性
因果一致 只要求尊重 HB 不要求全序,并发事件可各看各的 16.4 因果一致性
最终一致 不要求 只要求最终收敛,允许出现“因果气泡” 16.5 最终一致性

💡 一句话记住:线性一致 = HB + 实时;因果一致 = 只有 HB;最终一致 = 连 HB 都不管。

完整的一致性谱系、四个模型之间的包含关系与判定方法,见 16.7 一致性模型谱系图。

怎么判定:两种时钟,两种结论

关系定义好了,剩下的问题是“我怎么知道 a → b”。这需要元数据:

手段 能判定什么 每个事件的空间 主场
Lamport 时钟 a → b ⇒ C(a) < C(b)(必要不充分) 8 字节 23.3 Lamport 逻辑时钟
向量时钟 a → b ⇔ V(a) < V(b)(充分必要) N 个计数(N 个节点) 23.4 向量时钟
矩阵时钟 还能知道“别人知道我知道什么” N² 个计数 用于垃圾回收、因果投递
混合逻辑时钟(HLC) 保因果(同 Lamport),但判不了并发;数值贴近物理时间 8–16 字节 23.5 混合逻辑时钟 (HLC)

向量时钟的账单:N 个节点、每个计数 8 字节,单事件元数据 8N 字节。100 个节点 = 800 字节/事件;1 亿事件 = 80GB 的因果元数据。这就是为什么大规模系统要么限制副本数(N 小),要么用 HLC 之类的近似方案,要么干脆只保留“最近 K 个版本”。

从定义直接算:HB 就是图上的可达性

抛开时钟,→ 的定义本身就是一张有向图上的可达性:程序顺序连边、消息传递连边,然后求传递闭包。下面这段程序完全按定义实现,不依赖任何时钟:

package main

import "fmt"

type event struct {
	id      string
	edges   []string // 直接前驱(程序顺序 + 消息传递)
	lamport int
}

func main() {
	evs := []*event{
		{id: "e1"}, {id: "e2"}, {id: "e3"}, // 进程 P1
		{id: "f1"}, {id: "f2"}, {id: "f3"}, // 进程 P2
		{id: "g1"}, {id: "g2"},             // 进程 P3
	}
	byID := map[string]*event{}
	for _, e := range evs {
		byID[e.id] = e
	}
	link := func(a, b string) { byID[b].edges = append(byID[b].edges, a) }

	// 规则 1:程序顺序
	link("e1", "e2"); link("e2", "e3")
	link("f1", "f2"); link("f2", "f3")
	link("g1", "g2")
	// 规则 2:消息传递(e2→f2、f3→g2)
	link("e2", "f2"); link("f3", "g2")

	// 规则 3:传递闭包 —— 对每个事件做一次 BFS,看它能到达谁
	reach := map[string]map[string]bool{}
	for _, e := range evs {
		seen := map[string]bool{}
		stack := []string{e.id}
		for len(stack) > 0 {
			cur := stack[len(stack)-1]
			stack = stack[:len(stack)-1]
			for _, nxt := range evs {
				if seen[nxt.id] {
					continue
				}
				for _, pre := range nxt.edges {
					if pre == cur {
						seen[nxt.id] = true
						stack = append(stack, nxt.id)
						break
					}
				}
			}
		}
		reach[e.id] = seen
	}

	// 顺便算一遍 Lamport 时钟(按拓扑序:P1、P2、P3 的事件交错但前驱在前)
	for _, id := range []string{"e1", "f1", "g1", "e2", "f2", "e3", "f3", "g2"} {
		m := 0
		for _, pre := range byID[id].edges {
			if byID[pre].lamport > m {
				m = byID[pre].lamport
			}
		}
		byID[id].lamport = m + 1
	}

	// 关系表:→ 因果先于,← 因果后于,∥ 并发,= 自身
	fmt.Println("=== 由定义(可达性)算出的 happened-before 关系 ===")
	fmt.Printf("%4s", "")
	for _, e := range evs {
		fmt.Printf("%4s", e.id)
	}
	fmt.Println()
	for _, a := range evs {
		fmt.Printf("%4s", a.id)
		for _, b := range evs {
			switch {
			case a.id == b.id:
				fmt.Printf("%4s", "=")
			case reach[a.id][b.id]:
				fmt.Printf("%4s", "→")
			case reach[b.id][a.id]:
				fmt.Printf("%4s", "←")
			default:
				fmt.Printf("%4s", "∥")
			}
		}
		fmt.Println()
	}

	// 并发对:无序对里既非 a→b 也非 b→a
	conc := 0
	for i := 0; i < len(evs); i++ {
		for j := i + 1; j < len(evs); j++ {
			a, b := evs[i].id, evs[j].id
			if !reach[a][b] && !reach[b][a] {
				conc++
			}
		}
	}
	total := len(evs) * (len(evs) - 1) / 2
	fmt.Printf("\n并发对:%d / %d(%.0f%%)\n", conc, total, 100*float64(conc)/float64(total))

	// Lamport 时钟只给必要条件:比较时钟大小,看能不能判定因果
	fmt.Println("\n=== Lamport 时钟的局限(C(a) < C(b) 不代表 a → b)===")
	fmt.Printf("%-4s %-4s %-6s %-6s %-8s\n", "a", "b", "C(a)", "C(b)", "真实关系")
	for _, pair := range [][2]string{{"f1", "e3"}, {"e3", "f2"}, {"e1", "f3"}} {
		a, b := byID[pair[0]], byID[pair[1]]
		rel := "并发 ∥"
		if reach[a.id][b.id] {
			rel = "a → b"
		} else if reach[b.id][a.id] {
			rel = "b → a"
		}
		fmt.Printf("%-4s %-4s %-6d %-6d %-8s\n", a.id, b.id, a.lamport, b.lamport, rel)
	}
}

本机实测(macOS,Apple M1,Go 1.27):

=== 由定义(可达性)算出的 happened-before 关系 ===
      e1  e2  e3  f1  f2  f3  g1  g2
  e1   =   →   →   ∥   →   →   ∥   →
  e2   ←   =   →   ∥   →   →   ∥   →
  e3   ←   ←   =   ∥   ∥   ∥   ∥   ∥
  f1   ∥   ∥   ∥   =   →   →   ∥   →
  f2   ←   ←   ∥   ←   =   →   ∥   →
  f3   ←   ←   ∥   ←   ←   =   ∥   →
  g1   ∥   ∥   ∥   ∥   ∥   ∥   =   →
  g2   ←   ←   ∥   ←   ←   ←   ←   =

并发对:12 / 28(43%)

=== Lamport 时钟的局限(C(a) < C(b) 不代表 a → b)===
a    b    C(a)   C(b)   真实关系
f1   e3   1      3      并发 ∥
e3   f2   3      3      并发 ∥
e1   f3   1      4      a → b

三个读法,比结论本身重要:

现象 解释
e3 与 f2 都是 e2 的“后代”,却互不可比 HB 只沿程序顺序和消息边传播,不会因为“共享祖先”就横向连边
8 个事件里 12 对并发(43%) 并发是常态:只有一条消息链上的事件才可比
f1 的时钟(1)小于 e3(3),但两者并发 Lamport 时钟只保证“因果 ⇒ 时钟小”,不保证反过来

⚠️ 用可达性算闭包的复杂度是 O(V·E);真实系统每秒百万事件,算不起。所以工程上不存图,而是增量维护时钟(23.3/23.4 的主场),把“判定”变成两次整数比较。

Go 的内存模型:同一个关系,另一个域

Go 1.19 起的内存模型(go.dev/ref/mem)用的就是这套结构,只是把“事件”换成了内存操作:

happens-before 定义为 sequenced before(同一 goroutine 内的程序顺序)与 synchronized before(同步操作建立的顺序)的并集的传递闭包。

结构完全同源:程序顺序 = 规则 1,同步操作 = 规则 2,传递闭包 = 规则 3。但域不同:Go 的 HB 管的是“对同一变量的读写有没有竞争”,分布式的 HB 管的是“跨进程事件的因果”。混用会出错,见后面的误解表。

Go 内存模型里的十条规则

动作 规则(Go 内存模型原文的浓缩)
包初始化 所有 init 完成 synchronized before main.main 开始
启动 goroutine go f() 这条语句 synchronized before f 的执行开始
goroutine 退出 不保证 synchronized before 任何事件(这是最大的坑)
channel 发送 → 接收 发送 synchronized before 对应接收完成
关闭 channel close(c) synchronized before 因关闭而返回零值的接收
无缓冲接收 → 发送 接收 synchronized before 对应发送完成(会合语义)
容量 C 的缓冲 channel 第 k 次接收 synchronized before 第 k+C 次发送完成
sync.Mutex / RWMutex 第 n 次 Unlock synchronized before 第 m 次 Lock 返回(n < m)
sync.WaitGroup Wait 返回时,所有被等待的 Done 之前的写都可见(细节以 sync 包文档为准)
sync.Once / sync/atomic f 完成 synchronized before 任何 Do 返回;原子操作 A 的效果被 B 观察到,则 A synchronized before B(原子操作整体是顺序一致的)

实测:有同步与没同步,差一条边

package main

import "fmt"

func main() {
	// 反面:读与写之间没有任何 HB 边
	x := 0
	go func() { x = 42 }()
	fmt.Println("读到:", x)

	// 正面:close(done) happens before <-done,之后读 y 才有保证
	y := 0
	done := make(chan struct{})
	go func() {
		y = 42
		close(done)
	}()
	<-done
	fmt.Println("读到:", y)
}

用 go run -race race.go 跑(Go 官方文档给出量级:耗时 2–20 倍、内存 5–10 倍):

读到: 0
==================
WARNING: DATA RACE
Write at 0x00c000012178 by goroutine 6:
  main.main.func1()
      /tmp/hb/race.go:8 +0x2c

Previous read at 0x00c000012178 by main goroutine:
  main.main()
      /tmp/hb/race.go:9 +0xcc

Goroutine 6 (running) created at:
  main.main()
      /tmp/hb/race.go:8 +0x9c
==================
读到: 42
Found 1 data race(s)
exit status 66

报告插在哪一行、两行 读到 谁先谁后、地址是多少,每次都不同;固定的是 Found 1 data race(s) 与退出码 66。

第一段(x)被 race detector 判定为数据竞争:go func(){ x = 42 }() 与 fmt.Println(x) 之间没有 HB 边——goroutine 的退出不同步于任何事件,所以主 goroutine 完全可能先读到 0。第二段(y)靠 close(done) 与 <-done 建立了边,读到 42 是有保证的,不是运气。

💡 race detector 内部用的正是向量时钟一类的因果元数据(ThreadSanitizer):靠记录每个内存访问的“因果时间”,判断两次访问是否不可比。Go 侧的调试工具和分布式侧的判定工具,是同一个数学对象的两套实现。

Go 里三个最常见的 HB 坑

坑 为什么错 正确做法
go func(){ x = 1 }() 之后 time.Sleep 再读 x Sleep 不是同步操作,不建立任何 HB 边;优化器甚至可以删掉那个 goroutine 用 channel、sync.WaitGroup 或锁建立边
wg.Add(1) 写在 goroutine 内部 Wait 可能在 Add 之前返回(Add 与 Wait 之间没有顺序) Add 必须在 go 之前,或在 Wait 之前
以为 cancel() 返回就代表处理函数已结束 cancel 只是关闭 Done 通道;子 goroutine 的退出没有 HB 保证 用 WaitGroup 等子任务真正退出,再释放资源

工程用途:HB 不是概念,是四类代码的骨架

用途 用 → 做什么 反例(不做会怎样)
冲突检测 只有 a ∥ b 才是真冲突,才需要合并规则 把顺序写也当冲突,合并逻辑复杂且会“合并掉”正常更新
因果投递 投递时保证“前驱已投递”,否则先缓冲 用户看到评论、看不到帖子(因果气泡,16.4)
幂等 / 去重 用 (client_id, seq) 或向量判断“这个前驱我处理过没有” 重试导致重复扣款(25.4 幂等设计与去重)
排障 trace 的 parent/child span 本质就是 HB 边 只按机器时间拼日志,跨机顺序全错(时钟漂移见 23.1 物理时钟与漂移)

🔑 判断“要不要协调”的第一问不是“强一致还是弱一致”,而是“这两个操作之间有没有 →”:有,就不需要特殊处理;没有,才需要选择合并规则或加协调(见 17.6 CAP 之后的补充理论)。

常见误解

误解 现实
“并发就是同时发生” 并发是不可比;两个差几秒的事件也可能并发,两个真正同时的事件也可能有因果边(同一进程内)
“时间戳小就是先发生” Lamport 时钟只保证 a→b ⇒ C(a)<C(b);反例见上面的 f1(1) 与 e3(3)
“时钟相同就是并发” 时钟相同只能说明“无法用这个时钟判定”,是否并发要用向量或图的定义
“HB 能给出全局顺序” HB 是偏序;要全序必须额外加决胜规则(如比较进程 ID),而这个全序对并发事件是任意的
“Go 的 happens-before 就是分布式的 happened-before” 关系结构相同,域不同:一个管内存读写竞争,一个管跨进程因果;判定“数据竞争”和判定“业务冲突”是两件事
“用物理时间排序最省事” 时钟会漂移、会回拨;按物理时间给并发写排序就是 LWW,必然静默丢写(见 17.3 分区期间的取舍)

反模式

反模式 后果
用机器时间戳判断跨服务事件顺序 时钟漂移/回拨导致“结果早于原因”,排查被带偏
把 Lamport 时钟当因果判据 把并发写成“有先后”,冲突被静默吞掉
只给事件存“最新版本号”还声称能判并发 单个计数器无法区分“并发”与“因果”,必须向量(或图)
用 LWW 合并资金/库存 并发冲突被时间戳“解决”,丢写且不可追溯
在 Go 里用 Sleep 代替同步 数据竞争,race detector 一跑就报,线上表现为随机读到旧值
用 goroutine 退出隐式表示“写完了” 无 HB 保证,主流程可能先读后写
把因果一致当成“不用管顺序” 因果一致恰恰要求尊重 HB(16.4)

自检

Q1 已知 C(a) = 3、C(b) = 5(Lamport 时钟)。能推出 a → b 吗?能推出什么?

▸ 答案:不能推出 a → b。Lamport 时钟只保证 a → b ⇒ C(a) < C(b);反过来不成立(实测里 f1(1) 与 e3(3) 就是并发)。能推出的是它的逆否命题:C(a) ≥ C(b) ⇒ ¬(a → b),即 b 不可能在因果上先于 a。要判定并发与否,必须用向量时钟或直接算可达性。

Q2 实测里 8 个事件有 12 对并发。为什么 e3 与 f2 不可比——它们明明都发生在 e2 之后(e2 → e3、e2 → f2)?

▸ 答案:HB 是传递闭包,只能沿“程序顺序”和“消息传递”往前走,不会因为两个事件有共同祖先就产生横向的边。e2 → e3 与 e2 → f2 只说明二者都在 e2 之后,不说明它们之间的先后。要成为 e3 → f2,必须存在一条从 e3 到 f2 的路径(比如 e3 发消息给 P2),而图上没有。这也说明“共享祖先”不等于“有序”。

Q3 Go 里 go func(){ x = 1 }() 之后 time.Sleep(time.Second) 再读 x,为什么仍然可能读到 0?正确的写法是什么?

▸ 答案:Sleep 不是同步操作,不建立任何 happens-before 边;而 Go 内存模型明确说 goroutine 的退出不保证同步于任何事件,编译器甚至可能把这条 go 语句整个删掉。正确写法是用同步原语建立边:done := make(chan struct{}) + close(done) + <-done,或者 sync.WaitGroup 的 Add/Done/Wait,或者互斥锁。写了之后 go run -race 不再报竞争,才算真的对。

Q4 为什么说“只有并发写才需要合并”,而“有因果关系的写不需要特殊处理”?如果不管这个区别,会发生什么?

▸ 答案:如果 a → b,说明 b 的写入者已经看到了 a(消息链把它带过去了),所以“b 覆盖 a”是语义正确的最终状态,不需要合并。只有 a ∥ b 时,两边都不知道对方,才可能出现两个都“正确”的版本,需要 CRDT、业务规则或人工合并。若不管这个区别、一律按物理时间戳 LWW 合并,那么:① 本应被覆盖的旧写可能因时钟回拨反覆盖新写;② 真正的并发写被静默丢掉一条,且没有记录丢了什么(见 17.3 的 LWW 丢写量化)。

Q5 为什么 “happened-before” 是偏序而不是全序?

▸ 答案:因为并发事件之间没有因果关系:既不是 a 先于 b,也不是 b 先于 a。偏序只定义 “能确定的先后”,剩下的并发对无法比较——这正是 Lamport 时钟只能保因果、向量时钟才能识别并发的原因(见 23.4 向量时钟)。

关联