前言
在 Java 里,我们已经见过很多并发工具:
synchronized
volatile
AtomicInteger
ReentrantLock
ReadWriteLock
Semaphore
CountDownLatch
如果只停留在 Java API 层,会产生一个很自然的问题:
JVM 自己也是运行在操作系统上的,那么操作系统内核遇到多个 CPU 同时修改共享数据时,又靠什么保证线程安全?
继续往下追,又会冒出更多名字:
Atomic Operation
Spinlock
RW Spinlock
Semaphore
RW Semaphore
Mutex
Completion
BKL
Seqlock
preempt_disable
Memory Barrier
看起来像十一种“锁”,但实际上它们并不都属于同一类东西。
有的是不可分割的原子操作,有的是忙等锁,有的是可以睡眠的锁,有的是事件通知机制,有的是控制 CPU 调度的手段,还有的只是约束内存访问顺序。
所以这一篇不准备把十一项逐个背定义,而是先回答一个更根本的问题:
Linux 内核为什么需要这么多种同步方法?它们分别解决什么问题?
整条主线可以先看成:
理解了“问题类型”,再看这些机制,就不会觉得它们只是十一组需要死记的 API。
一、同步到底要解决什么问题?
1. 临界区:共享数据真正危险的地方
Critical Section,通常翻译成临界区。
它指的是:
访问共享状态、并且不能允许不受控制并发执行的那段代码。
在 Java 里:
synchronized (lock) {
count++;
}
大括号里的代码可以构成一个临界区。
但“临界区”不是 synchronized 发明的概念。
Linux Kernel、数据库、JVM、驱动程序,只要存在:
多个执行者
+
共享可变数据
就可能出现临界区。
2. Race Condition:结果为什么会依赖执行时机?
Race Condition 更准确的意思是:
程序结果依赖多个执行流不可控的执行先后顺序。
例如有一个共享变量:
counter = 5
CPU 0 和 CPU 1 同时执行:
counter++;
它在机器层面不是一个天然不可分割的“魔法动作”,可以粗略展开成:
Load counter
Add 1
Store counter
于是可能出现:
我们本来执行了两次 counter++,理论结果应该是:
7
最后却可能得到:
6
这就是典型的 Lost Update。
真正的问题不是“有没有锁对象”,而是:
多个执行流
在错误的时间窗口
同时操作同一份共享状态
3. Synchronization:锁只是同步的一种手段
Synchronization 是更大的概念。
它的目标是让多个执行流按照某种正确规则协作。
Lock 只是实现 Synchronization 的一种手段。
Synchronization
├── Atomic Operation
├── Lock
├── Event / Completion
├── Preemption Control
├── Memory Ordering
└── 其他同步机制
所以不能反过来理解成:
Synchronization = Lock
4. 原子性、有序性、可见性分别解决什么?
并发里经常把下面三个词放在一起:
Atomicity
Ordering
Visibility
但它们解决的是三类不同问题。
1) Atomicity:操作不能被拆开观察
Atomicity 关心的是:
一个操作能不能被其他执行流看到“做到一半”的状态。
例如:
Compare
+
Exchange
如果这两个步骤中间允许其他 CPU 插进来,就不能构成真正的 CAS。
所以 CAS 的 Compare + Exchange 必须作为一个不可分割的原子操作完成。
2) Ordering:执行顺序不能被错误重排
即使没有两个线程同时写一个变量,Compiler 和 CPU 为了性能也可能调整 Memory Operation 的实际执行顺序。
Ordering 解决的是:
A 应该先发生
B 应该后发生
这层关系不能被破坏。
3) Visibility:修改什么时候能被别人正确观察到?
CPU 有 Cache、Store Buffer 等结构,多核之间并不是每次读写都“直接去同一个 RAM 地址”。
Visibility 关心的是:
一个 CPU 对共享状态的修改,什么时候能被另一个 CPU 按同步规则正确观察到?
这也是为什么 Lock、Atomic、Memory Barrier 往往不仅要解决互斥,还必须同时建立 Memory Ordering。
二、内核同步机制全景:十一项不等于十一把锁
先做一次分类,会比直接背列表清楚很多。
| 机制 | 本质 | 等待时是否可能睡眠 | 主要解决的问题 |
|---|---|---|---|
| Atomic Operation | 原子读改写 | 不涉及睡眠锁等待 | 单个状态的不可分割修改 |
| Spinlock | 忙等互斥锁 | 否 | 很短的临界区 |
| RW Spinlock | 忙等读写锁 | 否 | 读多写少的短临界区 |
| Semaphore | 计数型同步原语 | 可以 | 控制多个资源名额 |
| RW Semaphore | 可睡眠读写锁 | 可以 | 多读、单写的较长临界区 |
| Mutex | 有 Owner 的互斥锁 | 可以 | 一次只允许一个任务进入 |
| Completion | 事件完成通知 | 可以等待 | A 等 B 完成某件事 |
| BKL | 历史上的大内核锁 | — | 早期粗粒度内核互斥 |
| Seqlock / Seqcount | 序列号 + Reader Retry | Reader 通常重试 | 读极多、写很少的一致性读取 |
| Preemption Control | 调度控制 | — | 防止当前 Task 被抢占 / 迁移 |
| Memory Barrier | 内存顺序约束 | — | Ordering / Visibility |
从这张表已经能看出来:
内核同步不是“找到一把万能锁”,而是根据临界区长度、能不能睡眠、读写比例、执行上下文和数据类型选择不同机制。
三、不睡眠的同步:Atomic、Spinlock 与 RW Spinlock
1. Atomic Operation:最小粒度的同步基础
如果只是对一个计数器做:
increment
compare-and-exchange
bit set
bit clear
直接上 Mutex 往往太重。
Linux 内核提供了 atomic_t 等原子接口,让某些简单状态变化可以由底层原子指令完成。
可以把它理解成:
普通读改写
Load → Modify → Store
Atomic Read-Modify-Write
CPU / Architecture 保证整体原子完成
1) Atomic 和 Spinlock 的层级不同
Atomic Operation 保护的是一个非常小的原子状态变化。
Spinlock 则是:
先获得锁
↓
进入一整段 Critical Section
↓
完成多个操作
↓
释放锁
因此:
Atomic Operation
≠
Spinlock
不能说“Linux 原子操作本身必须先获得一把 Spinlock”。
不同 Architecture 会通过自己的 Atomic Instruction / Atomic Primitive 实现这些操作。
2) Java Atomic 也不是“底层靠一把自旋锁”
Java 的 AtomicInteger 很适合拿来建立概念映射,但不能把实现关系说成:
AtomicInteger
↓
先获得 Spinlock
↓
再修改数据
OpenJDK 中 AtomicInteger.compareAndSet() 会进入底层原子 CAS Primitive。
可以执行:
javap -c java.util.concurrent.atomic.AtomicInteger
在 OpenJDK 21 中,compareAndSet 的关键调用可以看到:
Unsafe.compareAndSetInt
也就是说,Java Atomic 和 Linux Atomic 在“利用硬件原子能力”这个思想上相通,但它们不是“Java 调 Linux Spinlock”的关系。
2. Spinlock:为什么宁愿空转,也不把线程睡眠?
Spinlock 的特点非常直接:
锁拿不到,我不睡,我就在 CPU 上循环等。
伪代码可以想成:
while (!try_lock()) {
// spin
}
critical_section();
unlock();
1) 忙等为什么有时候反而更快?
假设临界区只需要几十纳秒或几百纳秒。
如果拿不到锁后立刻让当前任务睡眠,可能要付出:
进入 Scheduler
保存 Context
切换到其他 Task
以后再被唤醒
恢复 Context
这套 Context Switch 成本可能比“原地等一下”还高。
因此 Spinlock 适合:
临界区很短
+
锁很快会释放
+
当前上下文不能随便睡眠
2) Spinlock 的代价是什么?
拿不到锁时,CPU 仍然在执行循环。
所以如果锁长期不释放:
Spinlock
→ 大量浪费 CPU Time
这就是为什么后面还需要 Semaphore、Mutex 这类可以让等待者睡眠的机制。
3. RW Spinlock:读可以共享,写必须独占
如果一个共享结构是:
90% Read
10% Write
普通 Spinlock 会让所有 Reader 互相阻塞:
Reader 1 等 Reader 2
其实这没有必要。
Read-Write Spinlock 的基本规则是:
Read + Read
→ 可以并发
Read + Write
→ 互斥
Write + Write
→ 互斥
它和 Java ReadWriteLock 的“读共享、写独占”思想相近。
但这里仍然是 Kernel Spin Lock 家族:
发生竞争时,核心特点仍然是不能把它当成普通可睡眠锁使用。
四、可睡眠的同步:Semaphore、RW Semaphore 与 Mutex
1. Semaphore:资源不是只有“有锁”和“没锁”两种状态
Mutex 只能表达:
0 个进入
或
1 个进入
但有些资源允许同时给多个执行者使用。
例如一个资源池最多允许 10 个并发使用者:
permits = 10
每进入一个:
permits--
每退出一个:
permits++
当它变成 0 后,新的申请者必须等待。
这就是 Counting Semaphore 的核心。
Linux Kernel 的 struct semaphore 当前仍然可以看到:
count
wait_list
内部 raw_spinlock
因此“Semaphore 会让竞争者进入等待队列”这条理解是对的。
1) Spinlock 和 Semaphore 怎么选?
可以先记一条最重要的直觉:
Spinlock
→ 等待期间占着 CPU
→ 适合极短等待
Semaphore / Mutex
→ 竞争时可以进入 Sleep / Wait
→ 适合等待时间可能更长的场景
当然,真实 Kernel 是否允许睡眠还要看当前 Context。
Interrupt Context 就不能随便使用会 Sleep 的 Lock。
2) Java Semaphore 并不是“拿不到就一直自旋占 CPU”
Java Semaphore 也使用 Permit 计数模型,所以很适合类比概念。
但不能因此说:
Linux Semaphore 会睡眠
Java Semaphore 只会 Spin
OpenJDK 的 Semaphore 建立在 AQS Shared Acquire 机制之上;发生持续竞争时,等待线程可以进入队列并被 Park,而不是永远占着 CPU Busy Spin。
因此这里应该区分:
Semaphore 的抽象语义
和:
某个具体 Runtime / Kernel 的等待实现
2. RW Semaphore:不是“允许多个 Writer 同时写”
这里特别容易混淆。
Read-Write Semaphore 的标准语义仍然是:
多个 Reader
或
一个 Writer
Linux 接口本身就是:
down_read()
up_read()
down_write()
up_write()
其中 down_write() 获取的是写锁。
所以它不是:
多个 Writer 同时进入同一个受保护区域
1) “分段写”其实是另一个概念
如果我们把一个大资源拆成:
Segment 0
Segment 1
Segment 2
Segment 3
不同 Segment 使用不同 Lock,那么:
Thread A 写 Segment 0
Thread B 写 Segment 2
确实可以并行。
但这是:
Lock Striping / Segmented Locking
带来的并发,不是“一把 RW Semaphore 同时允许多个 Writer”。
JDK 7 时代的 ConcurrentHashMap Segment 设计,也属于这种“把锁粒度拆小”的思想。
所以关系应该是:
RW Semaphore
→ 多读 / 单写
Segmented Locking
→ 多把锁保护不同分区
→ 不同分区可以并行写
这两件事不能合并成一个概念。
3. Mutex:为什么不能简单说成“Semaphore 只剩 0 和 1”?
从经典同步理论上,可以用 Binary Semaphore 建立“同一时刻只能一个执行者进入”的效果。
所以教学上常见:
Counting Semaphore
↓
count 只允许 0 / 1
↓
Binary Semaphore
↓
行为很像 Mutex
这个类比适合理解互斥效果。
但在真实 Linux Kernel 里:
struct mutex
和:
struct semaphore
是不同的同步 Primitive。
一个非常重要的差异是 Ownership。
Linux Kernel 的 Semaphore Header 甚至直接指出:Binary Semaphore 没有 Owner,因此 up() 可以由和 down() 不同的执行上下文调用;Mutex 则具有 Owner 语义。
所以更准确的说法是:
Binary Semaphore 和 Mutex 都可以实现“最多一个执行者进入”的效果,但不能把现代 Kernel Mutex 简化成 Semaphore 把 count 限制成 0/1 的同一个实现。
同样,Java synchronized 也只能拿来做“互斥语义”的类比,不能说 JVM Monitor 的底层结构就等于 Linux struct mutex。
五、事件通知与历史演化:Completion 和 BKL
1. Completion:它更像“等一件事做完”
有时线程 A 并不是想和线程 B 抢同一份资源。
它真正想表达的是:
B 的某件事情还没做完
↓
A 先等着
↓
B 做完以后发一个信号
↓
A 再继续
这就是 Completion 的典型用途。
Linux 当前的 struct completion 核心状态可以概括成:
done
+
wait queue
常用操作是:
wait_for_completion()
complete()
complete_all()
这和 Java CountDownLatch(1) 在使用体验上有一定相似性:
一个等待
一个发完成信号
但仍然只是语义类比,不是实现继承关系。
内核中的 vfork 等路径也会使用 Completion 这类“等待某件事完成”的机制。
2. BKL:为什么“锁住整个内核”最终走不下去?
BKL 是 Big Kernel Lock,也就是“大内核锁”。
它代表早期 Kernel 并发演进中的一种粗粒度思路:
既然很多内核路径并发起来容易出错
↓
先用一个很大的全局锁把大量路径串行化
优点非常明显:
简单
容易保证正确
缺点也同样致命:
并发度很差
CPU 越多越容易形成瓶颈
因此 Linux 后来逐步把 BKL 拆成更细粒度的同步机制,并最终移除这一历史机制。
BKL 最值得记住的不是某个 API,而是一条并发设计规律:
锁粒度越粗,正确性越容易;锁粒度越细,并发度越高,但设计复杂度也越高。
六、无锁读取与执行控制:Seqlock、Preemption 与 Barrier
1. Seqlock:Reader 不加普通锁,真的安全吗?
Seqlock 是这一组机制里最值得单独理解的一个。
它的核心不是:
Reader 和 Writer 永远互斥
而是:
Reader 允许和 Writer 并发,但 Reader 必须验证自己读到的数据是否跨越了一次写操作;如果不一致,就重新读。
1) 奇数和偶数代表什么?
假设 Sequence 从 0 开始:
0 → 没有 Writer 正在写
1 → Writer 正在修改
2 → 一轮修改完成
3 → 下一轮 Writer 正在修改
4 → 下一轮修改完成
所以:
Even
→ 当前没有写操作处于临界更新阶段
Odd
→ Writer 正在修改
2) Reader 的正确逻辑不是“读到中间值也可以直接用”
Reader 更典型的流程是:
读 Sequence A
↓
读取 Data
↓
再读 / 检查 Sequence
↓
如果写入发生过
↓
丢弃本次结果并 Retry
也就是说:
Seqlock 的优势不是允许业务直接消费“不一致的中间值”,而是让 Reader 用 Retry 换取不获取普通 Reader Lock 的高并发。
3) Writer 仍然必须被串行化
如果两个 Writer 可以同时乱写:
Writer A
+
Writer B
Sequence Counter 本身无法神奇地保证共享数据一致。
Linux 当前的 Sequence Counter API 也明确要求 Writer Side 必须被 Serialize;seqlock_t 会把 Sequence Counter 和用于 Writer Serialization 的 Lock 组合起来。
因此 Seqlock 的结构更准确地理解为:
Reader
→ Lockless Read + Retry
Writer
→ Serialized Write
4) 为什么适合读多写少?
因为 Reader 不需要和其他 Reader 抢锁。
大量 Reader
+
很少 Writer
时可以获得很高的 Read Concurrency。
如果 Writer 非常频繁,Reader 会不断 Retry,优势就会下降。
2. preempt_disable:它保护的是“当前 CPU 上下文”
preempt_disable() 的作用可以先理解成:
暂时禁止当前 Task 被 Kernel Preemption 切走。
一个特别典型的目标是保护 Per-CPU Data。
假设某份数据严格属于当前 CPU:
CPU 0 → per_cpu_data[0]
CPU 1 → per_cpu_data[1]
当前 Task 在 CPU 0 上访问:
per_cpu_data[0]
如果中途被抢占,然后恢复时迁移到 CPU 1,就可能破坏“我正在操作当前 CPU 数据”的假设。
禁用 Preemption 可以帮助维持:
这段代码执行期间
当前 Task 不被普通 Kernel Preemption 切走
1) 它不是“单核 CPU 才有用”
即使在 SMP Multi-Core 系统里,Per-CPU Data 仍然广泛存在。
所以不能把:
preempt_disable
只理解成“单核时代优化”。
它在多核 Kernel 中依然有实际用途。
2) 它也不是多核互斥锁
假设:
CPU 0
CPU 1
都能访问同一份全局共享数据。
CPU 0 调用了:
preempt_disable();
并不会让 CPU 1 停止执行。
因此:
Disable Preemption
≠
禁止其他 CPU 并行
更不能把它当成普通 Spinlock / Mutex 的通用替代品。
另外,Disable Preemption 和 Disable Interrupt 也不是一回事。
如果同一份数据还会被 Interrupt Handler 访问,就需要继续考虑 IRQ Context 的同步规则。
3. Memory Barrier:它不是锁,而是在约束“先后关系”
Memory Barrier 解决的是:
Memory Ordering
而不是:
谁获得一把锁
例如逻辑上希望:
先写 data
再写 ready = true
另一个 CPU 看到:
ready == true
时,必须同时满足:
之前的 data 已经按照同步协议可见
如果缺少正确的 Ordering,CPU / Compiler Optimization 可能让实际观察顺序变得不符合程序员预期。
Linux Kernel 会根据 Architecture 和场景提供类似:
smp_mb()
smp_rmb()
smp_wmb()
等 Barrier Primitive。
这和 Java volatile / JMM 中的 Happens-Before、Acquire / Release 思想是可以互相印证的。
但不能写成:
Linux Memory Barrier
=
Java volatile 的某一条固定 CPU 指令
因为 JVM 会根据 Architecture、Compiler Optimization 和具体访问模式选择需要的 Fence / Barrier 语义。
七、怎么选择同步机制,以及怎么映射到 Java?
1. 这些机制到底怎么选?
可以用问题反推,而不是背 API。
真正做 Kernel Development 时,还必须继续考虑:
Process Context 还是 Interrupt Context?
能不能 Sleep?
Lock 持有多久?
是否跨 CPU?
是否访问 Per-CPU Data?
Reader / Writer 比例怎样?
是否需要严格 Owner 语义?
所以没有一把“性能最好、任何地方都能用”的万能锁。
2. 把 Linux 同步机制和 Java 放在一起看
Java 程序员最容易理解这些机制的方法,是做概念映射,但必须避免把“概念相似”写成“底层就是同一个实现”。
| Linux Kernel 概念 | Java 中可帮助理解的概念 | 是否一一对应 |
|---|---|---|
| Atomic Operation | AtomicInteger、CAS | 否,只是原子读改写思想相近 |
| Spinlock | CAS Spin Loop | 否,Java 没有普通业务代码直接使用 Kernel Spinlock 的对应 API |
| RW Spinlock | ReadWriteLock | 否,等待策略不同 |
| Semaphore | Semaphore | 语义相近,实现不同 |
| RW Semaphore | ReadWriteLock | 语义接近,不是同一个实现 |
| Mutex | synchronized / ReentrantLock | 互斥概念相近,不是一一对应 |
| Completion | CountDownLatch(1) 等 | 使用模式相近 |
| Seqlock | Optimistic Read 思想 | 思想接近,不是 Java 标准等价物 |
| Preemption Control | 无直接普通 Java 对应 | 否 |
| Memory Barrier | volatile / JMM Fence 语义 | 原理可互相印证,不是一条固定指令映射 |
这里最重要的一条原则是:
可以用 Java 已知概念帮助理解 Kernel Primitive,但不能把类比直接写成实现继承关系。
3. 几个特别容易混淆的说法
1) “AtomicInteger 底层就是 Spinlock”
不准确。
AtomicInteger.compareAndSet() 使用底层 Atomic CAS Primitive;有些高级原子更新逻辑可能通过 CAS Retry Loop 完成,但这不等于先获取 Linux Spinlock。
2) “Java Semaphore 拿不到 Permit 就一直自旋”
不准确。
Java Semaphore 基于 AQS 的 Shared Acquire 机制,持续竞争时线程可以排队并 Park。
3) “RW Semaphore 可以多个 Writer 同时写”
错误。
Read-Write Semaphore 的基本语义仍然是多读单写。多个线程同时修改不同 Segment,属于 Lock Striping / Segmented Locking。
4) “Mutex 就是 Semaphore 把值限制成 0 和 1”
只能作为经典理论类比。
现代 Linux Kernel 中 mutex 和 semaphore 是不同 Primitive,Mutex 有 Owner 语义,Binary Semaphore 没有相同的 Ownership 规则。
5) “Completion 就是一种 Semaphore”
容易误导。
Completion 更应该从 Event Completion 的角度理解:一方等待,另一方 complete() 发出完成事件。
6) “Seqlock 读到 Writer 中间状态也可以直接拿来用”
错误。
标准 Reader Protocol 会在读取后验证 Sequence;发现跨越写入就 Retry,而不是把不一致数据当成最终有效结果。
7) “preempt_disable 只能用于单核机器”
错误。
SMP Kernel 中同样需要 Preemption Control,例如保护 Per-CPU Assumption;但它不能阻止其他 CPU 并行访问全局数据。
8) “Memory Barrier 就是一把特殊的锁”
错误。
Barrier 解决 Ordering / Visibility,不负责直接建立临界区互斥。
八、总结
Linux Kernel 同步机制看起来很多,本质上是在回答不同的问题。
最基础的问题是:
多个 CPU / Task
同时访问共享可变状态
↓
Race Condition
↓
必须建立 Synchronization
如果只是一个很小的状态变化:
Atomic Operation
如果需要保护极短的、不能 Sleep 的临界区:
Spinlock
RW Spinlock
如果竞争者可以睡眠:
Mutex
Semaphore
RW Semaphore
如果不是抢资源,而是在等一个事件:
Completion
如果读特别多、写特别少,希望 Reader 几乎不加锁:
Seqlock / Seqcount
如果只是在当前 CPU 上保护一段不可被普通 Preemption 打断的逻辑:
preempt_disable
如果真正要解决的是内存访问顺序:
Memory Barrier
BKL 则提醒我们,Kernel Synchronization 本身也经历过从粗粒度到细粒度的演化。
所以这一整套知识最终可以压缩成一句话:
同步机制的选择,不是看哪把锁“最强”,而是先判断当前问题到底需要原子修改、忙等互斥、可睡眠互斥、读写并发、事件通知、调度控制,还是内存顺序保证。只有先识别问题类型,才能选择正确的 Kernel Primitive。
评论区