竞态:原子性、可见性、有序性
「有竞态」是并发 bug 的万能诊断书 —— 就像医生说「你不舒服」。它其实是三种完全不同的病,成因不同,症状不同,药也不同。counter++ 丢更新是原子性问题,加 volatile 治不了;一个 while(!flag) 死循环是可见性问题,加锁能治但太重;而双重检查锁定曾经错了整整十年,错在有序性上。这一章把三者拆开,并且真的把 counter++ 拆成三条指令随机交错,让你亲眼看它丢。
病一:原子性 —— counter++ 不是一步
这是最有名的一个。counter++ 看起来是一条语句,编译成机器码是三条指令:
counter++ 编译成:
LOAD r1 ← [counter] ; 从内存(或缓存)读到寄存器
ADD r1 ← r1 + 1 ; 在寄存器里加一
STORE [counter] ← r1 ; 写回内存
; 三条指令之间,可以被抢占、可以被另一个核插队。
如果两个线程的 LOAD 挨在一起,它们会读到同一个旧值,然后各自加一、各自写回 —— 两次自增,只生效了一次。
下面这个 demo 是真的在做这件事:它把两个线程的 LOAD/ADD/STORE 指令流随机交错执行,然后统计结果。先点几次「换一次随机交错」,看看单次的 trace;然后按「跑 1000 次」看分布。
你应该已经在上面的分布图里看到了:大部分时候它是对的。
这意味着:
- 单元测试测不出来。跑 100 次全绿。
- 本地复现不了。你的开发机负载低、核少、时序稳定。
- 它会在生产环境的某个周二下午出现一次,然后你花两周去查一个「不可能发生」的数据不一致。
而上面的 demo 里,那个「窗口」是三条指令宽。真实代码里的窗口通常只有几纳秒 —— 但一个 QPS 5000 的服务,一天有 4 亿次机会。
不要用「跑了很多次都没问题」来证明并发代码是对的。这在逻辑上等于没证明。要用竞态检测器(go test -race、TSan、JCStress),或者用证明(happens-before 分析)。
药:让那三条指令变成不可分割的一步
| 方案 | 怎么写 | 代价 |
|---|---|---|
| 加锁 | synchronized { counter++ }mu.Lock(); counter++; mu.Unlock()async with lock: counter += 1 |
最通用、最省心。竞争激烈时会有线程挂起/唤醒的开销。默认选它。 |
| 原子类 / CAS | AtomicInteger.incrementAndGet()atomic.AddInt64(&counter, 1) |
无锁,底层是一条 CPU 指令(LOCK XADD)。竞争不激烈时比锁快;竞争激烈时会疯狂自旋重试(第 10 章)。 |
| 分片 | LongAdder(Java)每个线程一个计数器,读的时候求和 |
高竞争下的最优解。代价:读(求和)变慢了,而且读到的值不是某个精确瞬间的快照。 |
| 根本不共享 | 每个 worker 自己算,最后汇总一次 或者:用 channel 把数据发给一个 owner goroutine |
最好的方案,如果你的问题允许。「不要用共享内存来通信」(第 17 章)。 |
volatile 治不了原子性@Volatile var counter = 0 // ❌ 这样写,counter++ 照样会丢更新!
counter++ // 还是三步:LOAD / ADD / STORE
// volatile 只保证「每次 LOAD 都读到最新值、每次 STORE 都立刻可见」
// 它不保证这三步之间不被打断。
volatile 保证的是「可见性」和「有序性」,从来不保证「原子性」。这是 Java 并发里最常见的一个误解,而且它的症状极其隐蔽 —— 因为 volatile 确实让 bug 变少了(窗口变窄了),少到你以为它修好了。
需要原子性?用 AtomicInteger 或者锁。
病二:可见性 —— 那个永远退不出的循环
class Worker {
private var running = true // ❌ 没有 volatile
fun run() {
while (running) { // 工作线程
doWork()
}
println("停止了") // ← 可能永远执行不到
}
fun stop() {
running = false // 主线程,想让它停下来
}
}
主线程调了 stop(),但工作线程可能永远看不到,永远循环下去。
为什么?回忆第 6 章的两个谎言:
- 缓存:工作线程把
running缓存在它那个核的 L1 里,一直读缓存。主线程的写在另一个核的写缓冲区/缓存里。 - 编译器优化:更狠。编译器看到循环体里没人改
running,直接把它提到循环外面(这叫「循环不变量外提」):tmp = running ; 只读一次! while (tmp) { ; 然后永远读寄存器 doWork() } ; 这在单线程语义下是完全合法的优化。
编译器和 CPU 做这些优化完全合法。因为语言规范只承诺一件事:在单线程语义下,结果和你写的一样。
你想要跨线程的可见性?你必须明确地要。方法是建立 happens-before 关系:
- Java/Kotlin:
@Volatile var running = true—— 强制每次都从内存读、写了立刻刷出去。 - Go:
atomic.Bool,或者更 Go 的方式:用一个 channel 的 close 来发停止信号(<-done)。或者直接用context(第 18 章)。 - Python:基本不用担心 —— GIL 会在切换时做完整同步。(但 free-threaded Python 里,这个免费的保护没了。)用
threading.Event是正确姿势。
注意:加锁也能解决可见性(进入/退出锁都有屏障),但对一个布尔标志来说,锁太重了 —— volatile / atomic 是为这种场景准备的。
病三:有序性 —— 那个错了十年的双检锁
这是最精妙、也最能说明问题的一个例子。单例模式的「双重检查锁定」(DCL):
class Singleton {
companion object {
private var instance: Singleton? = null // ❌ 没有 volatile
fun get(): Singleton {
if (instance == null) { // 第一次检查(无锁,快)
synchronized(this) {
if (instance == null) { // 第二次检查(有锁,安全)
instance = Singleton() // ← 就是这一行,埋了十年
}
}
}
return instance!!
}
}
}
这段代码看起来无懈可击:加了锁、检查了两次、性能也好(大部分调用不用进锁)。它在 Java 世界里被抄了十年。
它是错的。
凶手:instance = Singleton() 不是一步
instance = Singleton() 实际上是三步: ① 分配内存 ② 调用构造函数,初始化对象 ③ 把 instance 指向那块内存 ; JVM 和 CPU 都可以合法地把 ② 和 ③ 重排成: ① 分配内存 ③ 把 instance 指向那块内存 ← 此时 instance != null,但对象还没构造完! ② 调用构造函数 ; 因为在单线程视角下,②③ 换序毫无区别。
于是这个交错就要命了:
instance != null,直接返回了。但那个对象的构造函数还没跑完 —— 它的字段全是 0 / null。
线程 B 拿到了一个「存在但没初始化」的对象。然后在离案发现场很远的地方,抛出一个莫名其妙的 NPE。
药:一个关键字
private @Volatile var instance: Singleton? = null // ✅ volatile 禁止了 ②③ 重排
但更好的药是:根本别写 DCL。
// ✅ Kotlin 的正解 —— 一行,而且线程安全由语言保证
object Singleton { ... } // object 声明,JVM 类加载器保证线程安全
// ✅ 或者延迟初始化
val instance by lazy { Singleton() } // 默认就是 LazyThreadSafetyMode.SYNCHRONIZED
// ✅ Java 的正解:静态内部类(利用类加载的线程安全保证)
private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton get() { return Holder.INSTANCE; }
1990 年代,DCL 被当作 Java 单例的标准写法广泛传播。
2001 年,一群人(包括 Doug Lea、Bill Pugh)发表了《The "Double-Checked Locking is Broken" Declaration》—— 一份由十几位并发专家联署的声明,明确指出:在当时的 Java 内存模型下,DCL 无法用任何方式修好。(当时的 volatile 语义太弱,连它都救不了。)
直到 Java 5(2004),JSR-133 重写了整个 Java 内存模型,给了 volatile 足够强的语义(禁止重排),DCL 才终于可以被修好。
教训不是「DCL 很难」,而是:连并发领域最顶尖的一群人,都花了十年才把一个 12 行的函数彻底搞清楚。这就是为什么本书的建议永远是:能用现成的原语,就别自己造。
两个容易混的词:data race vs race condition
这两个词常被混用,但它们不是一回事,而且区分它们有实际价值:
| Data Race(数据竞争) | Race Condition(竞态条件) | |
|---|---|---|
| 定义 | 技术性的、可精确判定的: 两个线程并发访问同一内存,至少一个是写,且没有 happens-before 关系。 | 语义性的、跟业务有关: 程序的正确性依赖于事件发生的时序,而那个时序不受控。 |
| 例子 | 两个线程同时 counter++ | 「检查再执行」:if (!file.exists()) file.create()两个进程都通过了检查,然后都去创建。 每一步都是原子的、加了锁的 —— 但整体逻辑还是错的。 |
| 工具能查吗 | 能。go test -race、TSan 就是干这个的。 | 不能。工具不知道你的业务语义。 |
| 怎么修 | 加同步(锁 / atomic / channel) | 扩大原子性的边界:把「检查」和「执行」放进同一个临界区,或者用一个原子的「检查并执行」操作(putIfAbsent、O_EXCL、CAS、数据库的唯一索引)。 |
这一点非常重要,因为它是很多人的盲点:
// 这段代码完全没有 data race —— 每次访问都加了锁。
// 但它有 race condition —— 而且是个经典的账户超额扣款漏洞。
func Withdraw(acc *Account, amount int) bool {
mu.Lock()
balance := acc.Balance // 读:安全
mu.Unlock()
if balance < amount { // ← 检查
return false
}
mu.Lock()
acc.Balance -= amount // ← 执行(和上面的检查之间,别人插进来了!)
mu.Unlock()
return true
}
// 两个线程同时读到 balance = 100,都通过了检查,
// 然后各扣 100 → 余额变成 -100。
-race 检测器会说:干净,没问题。因为每一次内存访问都被锁保护了。
问题在于:临界区画错了。「检查」和「执行」必须在同一个临界区里。
所以:竞态检测器能帮你抓 data race,但抓不了逻辑。逻辑要靠你自己想清楚「哪些操作必须是原子的一整块」。
三病一表
| 病 | 症状 | 成因 | 药 | volatile 有用吗 |
|---|---|---|---|---|
| 原子性 | 计数器少了;余额扣多了 | 一个「逻辑上的一步」,物理上是好几步,中间被插队 | 锁 / 原子类 / CAS | ❌ 没用 |
| 可见性 | 循环退不出;标志位改了没反应 | 缓存 + 编译器优化,写了别人看不见 | volatile / atomic / 锁 | ✅ 就是干这个的 |
| 有序性 | 对象「存在但没初始化」;莫名其妙的 NPE | 编译器 / CPU 合法地重排了指令 | volatile / 锁 / 内存屏障 | ✅ 有用 |
- Go 最容易。它有真并行 + 共享内存 + 弱内存模型,而且没有
volatile这种东西。好消息:go build -race是业界最好用的竞态检测器之一,而且 Go 官方文化就是「上线前跑一遍 race」。 - Kotlin / JVM 中等。JMM 定义得很清楚(JSR-133),
volatile/Atomic*/synchronized语义明确。但 Java 的共享可变对象文化让坑很多。协程也不能免疫 —— 两个协程照样能并发改同一个var。 - Python 最安全,但那是偶然的。GIL 让绝大多数操作事实上原子化了(
list.append、dict[k] = v),也提供了免费的内存屏障。而 free-threaded Python(3.13t/3.14t)会把这层保护拿掉。那时候,二十年来「凑巧是对的」的 Python 并发代码,会开始出现真正的竞态 —— 这可能是接下来几年 Python 生态最大的一场地震。
1. 用你的语言真的写一遍那个 counter++ 实验:两个线程各加 100 万次,看最后是多少。(Java/Go 会明显丢;Python 里因为 GIL 可能丢得很少 —— 那就把 sys.setswitchinterval(0.000001) 调小,或者用 3.14t。)
2. 把上面那个 Withdraw 的 bug 修好。(提示:整个函数应该在一个临界区里。)然后想一想:如果 Account 在数据库里,你该怎么修?(提示:SELECT ... FOR UPDATE,或者乐观锁 + version 字段 —— 那就是分布式版的 CAS。)
3. 在你项目的 CI 里加上 -race。这一步的投入产出比,是这本书里最高的。
下一章:既然要加锁 —— 锁本身是怎么工作的?为什么没竞争的锁几乎免费?以及,两把锁怎么就变成了一场永久的僵局。