前言
Java 程序员学习并发时,通常会接触下面这些工具:
synchronized
volatile
AtomicInteger
ReentrantLock
ReadWriteLock
Semaphore
CountDownLatch
AQS
我们知道:
synchronized (lock) {
count++;
}
可以避免多个线程同时修改 count。
也知道:
AtomicInteger count = new AtomicInteger();
count.incrementAndGet();
不需要显式加锁,也可以完成线程安全的自增。
但继续向下思考,就会遇到一系列更底层的问题:
count++为什么不是原子操作?
AtomicInteger没有使用synchronized,为什么仍然能够保证线程安全?
CAS 是一条 CPU 指令,还是一种并发算法?
自旋锁获取失败后一直占用 CPU,为什么 Linux 内核还要使用它?
信号量、互斥锁都能让线程等待,它们解决的是同一种问题吗?
读写锁允许多个读者同时进入,为什么它不一定比普通互斥锁更快?
顺序锁允许读操作和写操作同时执行,读者怎样避免使用写到一半的数据?
禁止内核抢占以后,是否就不需要加锁了?
volatile所谓的可见性,是否等于“立即把数据写回主内存”?
内存屏障可以控制指令顺序,它能不能代替锁?
这些问题看起来都在讨论“锁”,实际上跨越了多个层次:
Java 语言内存模型
↓
JVM 并发机制
↓
操作系统线程调度
↓
Linux 内核同步原语
↓
CPU 原子指令
↓
多核缓存一致性与内存顺序
如果把这些层次混在一起,就很容易得出一些似是而非的结论:
AtomicInteger 是通过 Linux 自旋锁实现的
volatile 就是强制把变量写回主内存
mutex 只是取值为 0 和 1 的信号量
读写信号量允许多个线程同时写
顺序锁允许读者使用写到一半的数据
关闭抢占后就不需要加锁
内存屏障可以替代 synchronized
这些说法要么不准确,要么缺少非常重要的适用条件。
Linux 内核提供的同步机制并不是简单地分成“轻量级锁”和“重量级锁”。内核首先把锁分成睡眠锁、CPU 本地锁和自旋锁等类别,而选择同步机制的关键,是判断当前上下文能否睡眠、竞争来自哪里,以及我们究竟需要互斥、资源计数、事件通知还是内存顺序。
本文将从内核中的并发来源出发,依次理解:
临界区与竞争条件
原子性、可见性和有序性
原子操作与 CAS
自旋锁
读写自旋锁
信号量
读写信号量
互斥锁
完成量
顺序锁
禁止抢占
内存屏障
最后,我们再把这些机制与 Java 中的并发工具重新对应起来。
本文主要讨论普通的非实时 Linux 内核。开启
PREEMPT_RT后,部分spinlock_t、rwlock_t和本地锁的实现语义会发生变化,不能把普通内核的所有结论直接套用到实时内核。
一、Linux 内核中的并发来自哪里?
在普通 Java 应用中,我们主要关注多个线程之间的并发。
但在 Linux 内核中,共享数据可能被更多执行上下文访问。
1. 多个 CPU 同时执行
现代计算机通常包含多个 CPU 核心。
CPU 0 可能正在执行:
修改网络连接表
CPU 1 可能同时执行:
查询网络连接表
CPU 2 还可能正在执行:
删除已经超时的连接
如果这些操作访问同一个共享数据结构,就可能发生真正的物理并行:
CPU 0 ──→ 共享数据结构
CPU 1 ──→ 共享数据结构
CPU 2 ──→ 共享数据结构
这类并发通常称为:
SMP 并发
Symmetric Multiprocessing
关闭当前 CPU 的任务抢占,并不能阻止其他 CPU 访问同一份数据。
2. 当前任务可能被抢占
假设任务 A 正在内核中修改一份数据:
读取旧值
↓
计算新值
↓
写回新值
在写回之前,任务 A 可能被调度器抢占。
任务 B 随后在同一个 CPU 上运行,并访问同一份数据:
任务 A 读取旧值
↓
任务 A 被抢占
↓
任务 B 修改数据
↓
任务 A 恢复
↓
任务 A 写回根据旧值计算的结果
这同样会导致数据被覆盖。
3. 中断可能打断当前执行流程
假设普通内核代码正在修改设备状态:
进程上下文
↓
修改设备队列
此时硬件中断到来,中断处理程序也访问同一个队列:
普通代码执行到一半
↓
硬件中断
↓
中断处理程序访问同一数据
↓
返回原来的代码
即使机器只有一个 CPU,也可能发生这种重入。
4. 软中断和其他延迟处理机制
除了硬件中断,Linux 还存在:
SoftIRQ
Tasklet
Timer
Workqueue
内核线程
等不同执行机制。
它们是否允许睡眠、是否能够被抢占、是否运行在中断上下文中,都可能不同。
因此,在内核中保护一份数据时,不能只问:
会不会有另一个线程访问它?
还要问:
会不会有另一个 CPU 访问?
会不会被中断处理程序访问?
会不会被软中断访问?
会不会被当前 CPU 上的另一个任务访问?
当前代码能不能睡眠?
同步机制的选择,首先取决于并发来源。
二、临界区、竞争条件和同步到底是什么?
1. 什么是临界区?
访问共享状态,并且需要受到同步保护的代码区域,称为:
临界区
Critical Section
例如:
synchronized (lock) {
balance -= amount;
version++;
}
大括号中的代码是一个临界区。
不过,“临界区同一时间只能有一个线程进入”并不是绝对定义。
例如读写锁保护的读取临界区,可以允许多个读者同时进入:
读者 A ─┐
读者 B ─┼─→ 同时进入读取临界区
读者 C ─┘
真正的要求是:
临界区必须遵守该数据结构所需的并发访问规则。
互斥锁要求同一时间只有一个所有者;读写锁则允许多个读者,但不允许读者和写者同时进入。
2. 什么是竞争条件?
竞争条件并不是简单地指:
两个线程同时获得了执行机会
更准确地说:
当程序结果依赖多个并发执行者不可预测的执行顺序时,就存在竞争条件。
假设两个线程同时执行:
count++;
它在底层可以抽象为:
读取 count
计算 count + 1
写回 count
初始值为:
count = 10
一种可能的执行顺序是:
线程 A 读取 10
线程 B 读取 10
线程 A 计算出 11
线程 B 计算出 11
线程 A 写回 11
线程 B 写回 11
执行了两次加一,最终结果却是:
11
而不是:
12
这就是典型的更新丢失。
3. 什么是数据竞争?
如果两个执行者访问同一个内存位置:
至少有一个执行者进行写入
并且它们之间没有正确同步
就可能形成数据竞争。
竞争条件的范围更广。
例如,两个操作分别修改不同变量,但违反了业务状态之间的不变量,也可能产生竞争条件。
假设订单状态包含:
status
paidTime
transactionId
即使三个字段分别使用原子变量,也不能自动保证其他线程不会观察到:
status = PAID
paidTime = null
transactionId = null
因此:
单个字段线程安全
≠
整个业务状态线程安全
4. 什么是同步?
同步是为并发执行者建立必要的约束关系。
它可能用于实现:
同一时间只能有一个执行者
↓
互斥
最多允许 N 个执行者
↓
资源计数
线程 B 必须等待线程 A 完成
↓
事件通知
读者必须看到写者发布的数据
↓
可见性和内存顺序
锁只是同步的一种实现方式。
原子操作、完成量、等待队列和内存屏障,也都属于同步机制的一部分。
三、原子性、可见性和有序性有什么区别?
1. 原子性
在并发语境中,原子操作表示:
其他执行者不能观察到该操作只完成了一部分。
例如,原子的比较交换包含两个动作:
比较当前值
+
必要时写入新值
这两个动作必须作为一个不可分割的整体完成。
但要注意:
并发原子性
≠
数据库事务原子性
下面三个操作即使各自都是原子的:
balance.addAndGet(-100);
version.incrementAndGet();
status.set(PAID);
也不能保证三个操作会一起成功或者一起回滚。
2. 可见性
线程 A 修改:
ready = true;
线程 B 执行:
while (!ready) {
// 等待
}
如果没有正确同步,线程 B 不一定会按照程序员期望的方式重新读取 ready。
编译器可能复用之前读取的值,处理器和内存系统也可能让不同执行者观察到不同的内存状态。
可见性要解决的是:
一个执行者完成的写入,在什么条件下必须被另一个执行者观察到?
3. 有序性
线程 A 执行:
data = 100;
ready = true;
程序员通常希望线程 B 在看到:
ready == true
时,也能够看到:
data == 100
但编译器和处理器允许进行不会破坏单线程结果的优化和重排。
如果程序没有建立正确同步关系,另一个线程可能观察到违反直觉的结果。
Java 内存模型规定,对某个 volatile 字段的写入,先行发生于后续对该字段的读取;同一监视器的解锁,也先行发生于后续对该监视器的加锁。
4. 三者不能互相代替
例如:
volatile int count;
虽然 volatile 可以建立可见性和顺序关系,但:
count++;
仍然包含读取、计算和写回三个步骤。
因此它不能自动保证读—改—写的原子性。
反过来,一个原子操作也不一定自动带有最强的内存顺序。
Linux 原子 API 明确区分:
relaxed
acquire
release
完整顺序语义
Java 的 VarHandle 也提供普通、Opaque、Acquire、Release、Volatile 和 CAS 等不同访问模式。
可以先记住:
原子性
关注操作是否会被并发穿插
可见性
关注写入何时能被其他执行者观察到
有序性
关注内存操作之间必须遵守的先后关系
四、内核同步机制应该怎样分类?
在讨论每一种锁之前,先建立一个整体框架。
| 同步机制 | 等待方式 | 能否睡眠 | 主要用途 |
|---|---|---|---|
| 原子操作 | 不等待或重试 | 否 | 单个状态的原子更新 |
| 自旋锁 | 忙等 | 否 | 极短临界区、原子上下文 |
| 读写自旋锁 | 忙等 | 否 | 短小的读多写少临界区 |
| Mutex | 阻塞等待 | 是 | 一般互斥临界区 |
| Semaphore | 阻塞等待 | 是 | 计数资源或并发许可 |
| RW Semaphore | 阻塞等待 | 是 | 可睡眠的多读单写场景 |
| Completion | 阻塞等待 | 是 | 等待某个事件完成 |
| Seqlock | 读者重试、写者加锁 | 读者不睡眠 | 读多写少的一致性快照 |
| 禁止抢占 | 不允许本 CPU 调度切换 | 否 | 保护 CPU 本地状态 |
| 内存屏障 | 不属于等待机制 | 否 | 建立内存访问顺序 |
其中最重要的一条边界是:
睡眠锁只能在允许调度和睡眠的任务上下文中使用,不能在普通中断上下文或其他不可睡眠区域随意获取。
Linux 官方文档将 mutex、semaphore 和 rw_semaphore 归类为睡眠锁,将 raw_spinlock_t 归类为严格自旋锁,并将本地锁和禁止抢占归入 CPU 本地同步范畴。
五、原子操作如何避免更新丢失?
1. 原子读—改—写
Linux 内核提供:
atomic_t
atomic64_t
atomic_long_t
等原子类型。
它们主要封装处理器架构提供的跨 CPU 原子读—改—写能力,例如:
原子加减
原子交换
原子比较交换
原子位运算
Linux 原子 API 包括:
atomic_inc();
atomic_dec();
atomic_add();
atomic_xchg();
atomic_cmpxchg();
atomic_fetch_add();
等操作。
一个简单的内核计数器可以写成:
#include <linux/atomic.h>
static atomic_t request_count = ATOMIC_INIT(0);
void record_request(void)
{
atomic_inc(&request_count);
}
int get_request_count(void)
{
return atomic_read(&request_count);
}
多个 CPU 同时执行 atomic_inc() 时,更新不会像普通 count++ 那样互相覆盖。
2. 什么是 CAS?
CAS 表示:
Compare-And-Set
或者
Compare-And-Swap
其核心逻辑为:
读取当前值
↓
当前值是否等于期望值?
┌───────┴───────┐
│ │
是 否
│ │
写入新值 不修改
│ │
返回成功 返回失败
可以抽象为:
boolean compareAndSet(int expectedValue, int newValue);
Java AtomicInteger.compareAndSet() 会在当前值等于期望值时,以原子方式更新为新值。
3. CAS 如何实现自增?
public static int increment(AtomicInteger value) {
while (true) {
int oldValue = value.get();
int newValue = oldValue + 1;
if (value.compareAndSet(oldValue, newValue)) {
return newValue;
}
}
}
假设线程 A 和线程 B 都读取到:
oldValue = 10
线程 A 先成功执行:
10 → 11
线程 B 再尝试将:
10 → 11
但实际值已经变成 11,因此 CAS 失败。
线程 B 重新读取后,再执行:
11 → 12
最终不会发生更新丢失。
4. 所有原子操作都是用 CAS 实现的吗?
不一定。
CAS 是一种重要的原子读—改—写操作,但不同 CPU 架构可能直接提供:
原子加
原子交换
Load-Linked/Store-Conditional
Compare-And-Swap
等不同能力。
Linux 的 atomic_t 是面向内核代码的统一接口,具体如何映射到底层指令,由对应 CPU 架构实现。
因此不能简单地写成:
所有原子操作底层都是 CAS
更准确的说法是:
CAS 是原子操作的一种,也是构建很多无锁算法和锁实现的重要基础。
5. 原子操作能保护复杂临界区吗?
原子变量适合:
计数器
状态标志
引用替换
简单状态机
引用计数
但它不适合直接保护复杂数据不变量。
例如:
account.setBalance(account.getBalance() - amount);
account.setVersion(account.getVersion() + 1);
account.setUpdatedAt(now);
即使每个字段本身都使用原子变量,另一个线程仍可能看到字段之间不一致的中间状态。
这时通常需要:
互斥锁
不可变对象整体替换
事务
版本化状态
其他并发协议
六、自旋锁为什么要一直占用 CPU?
1. 自旋锁的基本模型
自旋锁通常包含一个锁状态:
0:空闲
1:已占用
执行者尝试通过原子操作完成:
0 → 1
成功则进入临界区。
失败时不立即进入睡眠,而是在 CPU 上反复尝试:
while (!try_lock()) {
cpu_relax();
}
这就是:
Spin
自旋
2. 为什么不直接让线程睡眠?
线程进入睡眠可能涉及:
进入调度器
修改任务状态
移出运行队列
执行上下文切换
以后重新唤醒
重新参与调度
恢复执行
假设持锁者只会执行几十条指令。
那么等待者进入睡眠再被唤醒,可能比直接等待锁释放更加昂贵。
因此,自旋锁适合:
临界区非常短
等待时间可预测
当前上下文不能睡眠
共享数据会被多个 CPU 访问
3. 自旋锁为什么不能保护长操作?
假设 CPU 0 持有自旋锁后执行了一个长时间操作:
CPU 0:持锁执行 100 毫秒
CPU 1:持续自旋 100 毫秒
CPU 2:持续自旋 100 毫秒
CPU 3:持续自旋 100 毫秒
其他 CPU 会白白消耗大量计算资源。
因此,持有自旋锁时不能执行可能睡眠或长时间阻塞的操作,例如:
等待磁盘 I/O
等待网络数据
主动调用调度器
可能睡眠的内存分配
获取睡眠锁
长时间复杂计算
Linux 内核文档明确提醒,持有自旋锁或关闭抢占时不能调用可能睡眠的函数。
4. 一个典型的自旋锁
#include <linux/spinlock.h>
static DEFINE_SPINLOCK(counter_lock);
static int counter;
void increment_counter(void)
{
unsigned long flags;
spin_lock_irqsave(&counter_lock, flags);
counter++;
spin_unlock_irqrestore(&counter_lock, flags);
}
这里使用 irqsave 版本,表示:
保存当前本地中断状态
↓
关闭本地中断
↓
获取自旋锁
↓
执行临界区
↓
释放自旋锁
↓
恢复之前的中断状态
只有当同一份数据可能被中断上下文访问时,才需要考虑相应的中断保护。
如果数据从不在中断上下文中访问,就不应该机械地给所有自旋锁都加上 irqsave。
5. 为什么有多种自旋锁接口?
常见接口包括:
spin_lock()
spin_lock_bh()
spin_lock_irq()
spin_lock_irqsave()
它们解决的并发来源不同:
spin_lock()
保护跨 CPU 并发
spin_lock_bh()
额外防止本 CPU 的软中断并发
spin_lock_irq()
额外关闭本 CPU 硬中断
spin_lock_irqsave()
保存并关闭本 CPU 中断,释放时恢复原状态
普通非实时内核中的自旋锁会隐式关闭抢占,而 _bh、_irq 和 _irqsave 后缀会进一步控制软中断或硬中断。
6. 自旋锁是不是“缓存锁”?
不是。
自旋锁描述的是:
获取失败后是否忙等
多核 CPU 对同一个锁变量执行原子修改时,通常需要借助缓存一致性和缓存行所有权转移。
但:
自旋锁
与:
缓存一致性协议
不是同一个概念。
更准确的关系是:
自旋锁
依赖原子读—改—写建立所有权
原子读—改—写
需要处理器和缓存一致性系统协同实现
7. PREEMPT_RT 下的特殊情况
在普通非实时内核中,spinlock_t 是自旋锁。
在 PREEMPT_RT 内核中,spinlock_t 会映射为基于实时互斥机制的实现,可能发生阻塞,并且不再通过普通方式关闭抢占。
只有 raw_spinlock_t 在实时内核中仍然保持严格自旋语义。
所以阅读内核代码时,必须考虑内核配置。
七、读写自旋锁为什么不一定更快?
1. 读写锁的基本规则
读写锁遵循:
读—读:可以并发
读—写:互斥
写—写:互斥
读取方获取共享锁:
Read Lock
Shared Lock
写入方获取独占锁:
Write Lock
Exclusive Lock
Linux 中的读写自旋锁类型为:
rwlock_t
使用方式类似:
static DEFINE_RWLOCK(config_lock);
void read_config(void)
{
read_lock(&config_lock);
/* 只读取受保护的数据 */
read_unlock(&config_lock);
}
void update_config(void)
{
write_lock(&config_lock);
/* 修改受保护的数据 */
write_unlock(&config_lock);
}
2. 为什么读锁也有成本?
为了允许多个读者同时进入,锁必须维护额外状态,例如:
当前有多少读者
是否有写者
是否有写者正在等待
多个 CPU 更新这些共享状态时,会产生额外的原子操作和缓存竞争。
Linux 锁文档指出,读写自旋锁需要比普通自旋锁更多的原子内存操作;如果读取临界区很短,普通自旋锁往往反而更合适。
所以:
读多写少
≠
读写锁一定更快
还要考虑:
读取临界区有多长
CPU 数量
锁竞争程度
缓存行争用
写者是否容易饥饿
3. 读锁能不能升级为写锁?
通常不能直接这样做:
先获取读锁
↓
发现需要修改
↓
直接升级成写锁
因为多个线程可能都持有读锁,并同时等待升级:
线程 A 持有读锁,等待写锁
线程 B 持有读锁,等待写锁
双方都不释放读锁,就可能永久等待。
如果执行路径可能修改数据,通常应该从一开始就获取写锁,或者释放读锁后重新获取写锁,并重新检查数据状态。Linux 自旋锁文档也明确指出,不能直接把读锁升级为写锁。
八、信号量不是简单的“重量级锁”
1. 信号量表示资源数量
信号量内部维护一个计数。
假设:
可用许可 = 3
表示最多允许三个执行者同时使用某类资源:
线程 A 获取许可
线程 B 获取许可
线程 C 获取许可
线程 D 必须等待
适合表示:
连接池中的连接
设备通道
并行任务名额
固定数量的硬件资源
并发访问配额
Linux 内核中的 semaphore 是计数信号量。
2. P 操作和 V 操作
经典信号量操作可以理解为:
P / down / wait / acquire
申请资源
减少可用许可
V / up / signal / release
释放资源
增加可用许可
因此,下面的说法是错误的:
P/down 是释放
V/up 是占有
正确过程是:
初始许可 = 3
线程 A 执行 down
许可变为 2
线程 B 执行 down
许可变为 1
线程 C 执行 down
许可变为 0
线程 D 执行 down
没有许可,进入等待
线程 A 执行 up
许可被释放,线程 D 可以继续
3. Linux 信号量示例
#include <linux/semaphore.h>
static struct semaphore slots;
void init_slots(void)
{
sema_init(&slots, 10);
}
void use_resource(void)
{
down(&slots);
/* 使用数量受限的资源 */
up(&slots);
}
如果没有可用许可,down() 可能让当前任务进入等待。
因此,不能在不允许睡眠的上下文中随意使用信号量。
4. 信号量是所有锁的核心吗?
不能这样概括。
信号量确实是一种经典同步原语,也可以通过计数为 1 模拟互斥。
但现代 Linux 内核为不同语义提供了更明确的同步机制:
互斥
使用 mutex
等待事件完成
使用 completion
多个读者、一个写者
使用 rw_semaphore
资源数量控制
使用 semaphore
Linux 官方文档建议,新的代码不要继续把信号量同时用于互斥和事件等待,而应该根据语义分别使用 mutex 和 completion。
九、读写信号量允许多个写者吗?
不允许。
Linux 中的:
rw_semaphore
是一个:
多读者
+
单写者
的睡眠锁。
官方文档对它的定义就是:
multiple readers and single writer。
其规则仍然是:
读—读:可以并发
读—写:互斥
写—写:互斥
示例:
#include <linux/rwsem.h>
static DECLARE_RWSEM(config_sem);
void read_config(void)
{
down_read(&config_sem);
/* 读取配置 */
up_read(&config_sem);
}
void update_config(void)
{
down_write(&config_sem);
/* 修改配置 */
up_write(&config_sem);
}
它与 rwlock_t 的主要区别是等待方式:
rwlock_t
获取失败时自旋
不能睡眠
适合短临界区
rw_semaphore
获取失败时可以阻塞
只能用于允许睡眠的任务上下文
适合可能持续较长时间的读写临界区
分段锁为什么允许多个写者?
分段锁不是一把锁允许多个写者进入。
它是把数据划分为多个相互独立的区域:
分段 0 → 锁 0
分段 1 → 锁 1
分段 2 → 锁 2
于是可以出现:
线程 A 持有锁 0,修改分段 0
线程 B 持有锁 1,修改分段 1
两个线程同时写,是因为它们持有的是不同的锁,并且修改不同的数据区域。
因此:
分段锁允许并行写不同分段
≠
读写信号量允许多个写者持有同一把写锁
十、Mutex 和二值信号量有什么区别?
1. Mutex 强调所有者
Mutex 表示:
Mutual Exclusion
互斥
同一时间只有一个执行者拥有锁。
Linux mutex 具有严格的所有者语义:
谁获取
谁释放
信号量则没有同样严格的所有者概念。Linux 锁文档指出,除信号量外,其他主要锁类型通常具有严格的所有者语义。
因此不能简单写成:
Mutex 就是计数为 1 的 Semaphore
它们虽然都可能实现互斥效果,但在以下方面存在差异:
所有者语义
错误检测
优先级处理
调试能力
API 使用约束
设计目的
2. Linux Mutex 的三个路径
Linux mutex 并不是每次加锁都立即让线程睡眠。
根据锁的竞争状态,可能经过:
快速路径
↓
直接通过原子操作获取锁
中间路径
↓
进行有限的乐观自旋
慢速路径
↓
进入等待队列并调度出去
Linux mutex 会记录当前所有者,并在无竞争时尝试通过原子比较交换完成快速获取;发生竞争时,才进入自旋或等待路径。
因此,把 mutex 简单叫作:
重量级锁
容易掩盖它的快速路径和自适应实现。
3. Mutex 示例
#include <linux/mutex.h>
static DEFINE_MUTEX(config_lock);
void update_config(void)
{
mutex_lock(&config_lock);
/* 可以执行比自旋锁临界区更长的操作 */
/* 但仍应尽量避免不必要的长时间持锁 */
mutex_unlock(&config_lock);
}
Mutex 允许等待者睡眠,所以只能在允许睡眠的任务上下文中获取。
4. synchronized 就是 Linux Mutex 吗?
不是。
Java 的 synchronized 表达的是 JVM 监视器语义。
它包含:
互斥
可重入
自动释放
wait
notify
notifyAll
Java 内存模型中的可见性与顺序
JLS 规定,每个 Java 对象都关联一个监视器,同一时间只能有一个线程持有该监视器;synchronized 代码正常或异常退出时,都会自动执行解锁。
Linux mutex 则是 Linux 内核内部的同步原语。
两者都具有互斥效果,但处于不同抽象层:
synchronized
Java 语言和 JVM 层语义
Linux mutex
Linux 内核同步原语
不能认为 synchronized 只是对 Linux mutex 的直接包装。
十一、完成量解决的不是互斥问题
1. 什么是 Completion?
完成量用于表达:
一个执行者必须等待另一个执行者完成某个事件。
例如:
线程 A 启动设备初始化
↓
线程 A 继续执行其他无关工作
↓
真正需要设备时等待初始化完成
线程 B 或中断处理程序完成初始化
↓
发送完成信号
↓
线程 A 继续
它的重点不是:
谁能够进入临界区
而是:
某件事情是否已经完成
Linux completion 建立在调度器的等待队列和唤醒机制之上,提供初始化、等待和完成通知三个核心步骤。
2. Completion 示例
#include <linux/completion.h>
static DECLARE_COMPLETION(initialization_done);
void wait_for_device(void)
{
wait_for_completion(&initialization_done);
/* 初始化完成后继续 */
}
void finish_device_initialization(void)
{
/* 完成初始化 */
complete(&initialization_done);
}
需要唤醒所有等待者时,可以使用:
complete_all(&initialization_done);
3. 通知发生在等待之前会丢失吗?
不会必然丢失。
Completion 内部维护完成状态。
如果:
complete()
先执行,之后才调用:
wait_for_completion()
等待方可以直接观察到事件已经完成,而不必进入睡眠。
官方文档明确说明,等待和完成通知可以按任意顺序发生。
这也是它与简单的“先检查条件,再进入睡眠”代码相比更加安全的原因。
4. Completion 是回调函数吗?
不是。
回调函数通常表示:
事件发生
↓
调用预先注册的函数
Completion 表示:
等待方阻塞或检查完成状态
↓
完成方发出完成信号
↓
等待方恢复执行
两者都可以用于协调异步操作,但控制流模型不同。
5. Linux Completion 和 Windows IOCP 是一回事吗?
不是。
两者都包含单词 Completion,但:
Linux completion
是内核内部的事件同步原语
Windows I/O Completion Port
是面向异步 I/O 完成事件分发的操作系统机制
不能因为名称相似,就认为它们属于同一种实现。
十二、顺序锁为什么允许读写并发?
1. 顺序锁解决什么问题?
普通读写锁在写者工作时,会阻止读者:
写者持有写锁
↓
所有读者等待
但有些数据具有以下特点:
读取非常频繁
写入非常少
数据可以快速复制
读者可以在冲突后重试
例如:
系统时间相关状态
小型统计快照
若干需要保持一致的整数
这时可以采用另一种思路:
读者先读取数据,读取后再判断读取过程中是否发生过写操作。
Linux sequence counter 和 seqlock 就是这种读者重试型一致性机制。
2. 奇数和偶数表示什么?
假设序列号初始为:
sequence = 0
写者开始前,将序列号加一:
0 → 1
奇数表示:
写操作正在进行
写者完成后再次加一:
1 → 2
偶数表示:
当前没有未完成的写操作
整个过程如下:
sequence = 0
↓
写者开始
↓
sequence = 1
↓
修改共享数据
↓
写者结束
↓
sequence = 2
3. 读者为什么要检查两次?
读者的正确流程是:
读取开始序列号
↓
如果是奇数,重新尝试
↓
复制共享数据
↓
再次读取序列号
↓
前后是否相同?
┌───────┴───────┐
│ │
相同 不同
│ │
读取有效 丢弃结果并重试
示例:
unsigned int seq;
int local_x;
int local_y;
do {
seq = read_seqbegin(&state_lock);
local_x = shared_x;
local_y = shared_y;
} while (read_seqretry(&state_lock, seq));
假设执行过程是:
读者读取 sequence = 2
读者读取 shared_x
写者开始,sequence = 3
写者修改 shared_x
写者修改 shared_y
写者结束,sequence = 4
读者读取 shared_y
读者再次读取 sequence = 4
读者发现:
开始值 2
结束值 4
说明读取期间发生过写操作。
这次结果必须全部丢弃。
4. 读者能不能使用写到一半的数据?
不能。
读者确实可能暂时读取到中间状态,但:
暂时读到
≠
允许使用
它必须在完成校验之前,把数据保存在本地临时变量中。
只有当:
开始序列号为偶数
+
结束序列号与开始相同
时,才能使用读取结果。
Linux 文档明确要求,如果读取前后的序列号发生变化,读者必须重试。
5. 写者之间如何互斥?
裸 seqcount_t 只提供序列计数,并不会自动阻止多个写者同时进入。
因此,多个写者必须通过外部锁串行化。
seqlock_t 则组合了:
序列计数器
+
用于串行化写者的自旋锁
Linux 官方文档明确说明,seqlock_t 内部包含序列计数机制和用于保护写者的自旋锁。
写入示例:
static DEFINE_SEQLOCK(state_lock);
void update_state(void)
{
write_seqlock(&state_lock);
shared_x++;
shared_y = shared_x * 2;
write_sequnlock(&state_lock);
}
6. 顺序锁有哪些限制?
写临界区必须很短
如果写者在序列号为奇数时长时间停顿,所有普通读者都会持续失败并重试。
如果写者被抢占,而高优先级实时读者不断循环检查,还可能造成严重活锁问题。
不适合直接保护可能失效的指针
假设读者读取一个指针:
ptr → 对象
写者可能在读者跟随指针时释放对象。
即使读者之后发现序列号变化,也已经可能访问了无效内存。
因此,Linux 文档明确指出,普通 sequence counter 不适合直接保护写者可能使其失效的指针数据。
写入必须少
写入越频繁,读者重试次数越多。
当写竞争很高时,顺序锁可能比普通锁更差。
十三、禁止抢占为什么不能替代锁?
1. 什么是禁止抢占?
Linux 内核可以通过:
preempt_disable();
/* 不允许当前任务被普通调度抢占 */
preempt_enable();
暂时关闭当前 CPU 上的内核抢占。
它主要防止:
当前任务
↓
在同一个 CPU 上被另一个普通任务调度替换
2. 禁止抢占能阻止其他 CPU 吗?
不能。
假设:
CPU 0:preempt_disable() 后修改 global_count
CPU 1:同时修改 global_count
数据竞争仍然存在。
禁止抢占只影响当前 CPU 的调度行为,不会让其他 CPU 停止执行。
3. 禁止抢占能阻止中断吗?
不能。
即使关闭了任务抢占,硬件中断仍可能打断当前代码。
如果中断处理程序也访问相同数据,还需要对应的中断同步措施。
因此:
禁止抢占
不能替代关闭中断
关闭本地中断
不能阻止其他 CPU
自旋锁
不能自动解决所有中断嵌套问题
每一种机制保护的并发来源不同。
4. 禁止抢占适合什么场景?
典型场景是访问:
per-CPU 数据
例如,每个 CPU 都有自己的计数器:
CPU 0 → counter[0]
CPU 1 → counter[1]
CPU 2 → counter[2]
如果确保其他 CPU 不会访问当前 CPU 的那一份数据,就不需要跨 CPU 互斥。
但当前任务不能在操作过程中迁移到另一个 CPU。
因此可以暂时禁止抢占或使用相应的 per-CPU 接口。
5. 为什么更推荐 local_lock?
现代内核还提供:
local_lock
它为“通过关闭抢占或本地中断保护的临界区”提供一个明确名称。
与裸 preempt_disable() 相比,它的优势是:
保护范围更加清楚
可以与 lockdep 配合检查
在 PREEMPT_RT 下能够映射为合适的锁实现
Linux 锁文档指出,local_lock 在普通内核中可以映射为抢占或中断控制,同时为静态分析和 lockdep 提供明确的保护范围。
所以:
preempt_disable()
不是“当前 CPU 独占整个系统”,也不是一个通用锁。
十四、内存屏障为什么不是锁?
1. 为什么会发生重排?
假设生产者执行:
data = 100
ready = true
消费者执行:
如果 ready 为 true
使用 data
从源代码看,data 明显先于 ready 写入。
但实际执行会经过:
编译器优化
JIT 编译
CPU 乱序执行
Store Buffer
多级缓存
缓存一致性传播
只要不改变单线程观察到的结果,编译器和处理器可能调整某些操作。
Linux 内存屏障文档指出,编译器和 CPU 都可能对内存访问进行重排、合并或优化,因此共享内存和设备访问需要明确的顺序约束。
2. Acquire 和 Release
一种常见的发布流程是:
生产者:
写入普通数据
↓
Release 写入 ready
消费者:
Acquire 读取 ready
↓
读取普通数据
可以抽象为:
/* 生产者 */
WRITE_ONCE(data, 100);
smp_store_release(&ready, true);
/* 消费者 */
if (smp_load_acquire(&ready)) {
int value = READ_ONCE(data);
}
Release 保证它之前需要发布的操作,不会越过发布动作跑到后面。
Acquire 保证它之后依赖发布结果的操作,不会被移到获取动作之前。
3. 内存屏障能防止两个线程同时修改数据吗?
不能。
假设两个 CPU 同时执行:
count++
即使前后放置内存屏障:
屏障
count++
屏障
读—改—写过程仍然可能互相穿插。
所以:
内存屏障
解决内存顺序问题
原子操作
解决单次状态转换不可分割问题
锁
解决临界区互斥问题
它们不能互相随意替换。
4. 锁是否包含内存屏障语义?
通常包含一定的 Acquire 和 Release 语义。
加锁后,临界区内的访问不能被随意移动到获取锁之前。
解锁前,临界区内需要发布的访问不能被随意移动到解锁之后。
Linux 文档将自旋锁、读写锁、mutex、semaphore 和读写信号量等锁操作列为具有隐式内存屏障语义的内核操作。
但是:
锁包含一定内存顺序
≠
任意屏障都能实现锁
5. Java 能不能直接使用内存屏障?
不能说 Java 完全无法使用内存屏障。
普通业务代码一般不直接书写 CPU 屏障指令,但 Java 提供了:
volatile
synchronized
AtomicInteger
Lock
VarHandle
并发集合
线程启动和结束规则
等更高层语义。
JVM 根据 Java 内存模型和目标 CPU 架构,生成满足这些语义的机器代码。
Java 标准 API 中的 VarHandle 还直接提供:
acquireFence()
releaseFence()
fullFence()
loadLoadFence()
storeStoreFence()
等屏障接口。
不过,业务代码通常不应该手工拼装屏障协议,而应优先使用:
volatile
锁
原子类
并发容器
不可变对象
6. volatile 是否等于“写回主内存”?
不准确。
“主内存”和“工作内存”可以作为初学模型,但不应把 volatile 理解成每次都机械地:
强制写入物理内存条
+
让其他 CPU 直接读取内存条
现代 CPU 的数据仍然大量经过缓存系统。
volatile 的核心是 Java 内存模型提供的可见性和顺序保证:
volatile 写
happens-before
后续对同一变量的 volatile 读
具体如何通过缓存一致性、编译器约束和处理器指令实现,由 JVM 和硬件平台共同决定。
十五、BKL 为什么不再是现代内核同步方案?
早期 Linux 内核曾经存在:
BKL
Big Kernel Lock
大内核锁
它通过一把覆盖范围极大的全局锁,将大量内核代码串行化。
这种设计的优点是简单:
一次只允许一个执行者进入大量内核区域
缺点也非常明显:
锁竞争严重
多核扩展性差
无关子系统互相阻塞
难以发挥多 CPU 性能
随着 Linux 内核逐步使用更细粒度的锁和其他并发机制替代 BKL,这把历史上的大锁最终被移除。Linux 2.6.39 的内核发展资料将“移除 Big Kernel Lock”列为该版本的重要变化。
因此,今天学习 BKL 的意义主要是理解:
一把覆盖范围过大的全局锁虽然容易保证正确性,却会严重限制并发能力和系统扩展性。
它不是现代内核代码中应该继续选择的一种常规锁类型。
十六、Linux 内核同步机制如何对应 Java?
下面只能做概念类比,不能理解成直接实现关系。
| Linux 内核概念 | Java 中相近的抽象 | 需要注意 |
|---|---|---|
| 原子读—改—写 | AtomicInteger、AtomicReference | Java 原子类不是 Linux atomic_t 的直接包装 |
| Spinlock | JVM 内部自旋、手工 CAS 自旋 | Java 标准库没有通用的内核式 SpinLock |
| Mutex | synchronized、ReentrantLock | 语义层次和实现方式不同 |
| rwlock / rwsem | ReentrantReadWriteLock | 内核中还要区分自旋和睡眠 |
| Semaphore | java.util.concurrent.Semaphore | 都表示许可,但调度和实现不同 |
| Completion | CountDownLatch(1)、Future | 只是在“等待完成”语义上相近 |
| Seqlock | 乐观读、版本号校验 | JDK 没有完全对应的通用类 |
| Memory Barrier | volatile、VarHandle | Java 提供的是内存模型语义 |
| 禁止抢占 | 无直接对应 | 属于内核调度和 CPU 本地控制 |
1. AtomicInteger 不是 Linux 原子变量的包装
Java AtomicInteger 提供单变量的原子更新,并通过 VarHandle 语义定义不同操作的内存效果。
它的实现可能使用:
JVM intrinsic
硬件原子指令
运行时系统支持
但不能简单画成:
AtomicInteger
↓
Linux atomic_t
Java 用户态程序通常不需要通过 Linux 内核的 atomic_t 完成普通原子变量更新。
2. synchronized 不是内核 Mutex 的别名
synchronized 是 Java 语言级监视器语义。
JVM 可以根据竞争状态采用:
快速路径
原子状态更新
短暂自旋
线程阻塞
操作系统等待与唤醒机制
等不同策略。
Linux mutex 只是操作系统内核中的一种具体同步原语。
3. CountDownLatch 和 Completion
当 CountDownLatch 的计数为 1 时:
CountDownLatch latch = new CountDownLatch(1);
等待方:
latch.await();
完成方:
latch.countDown();
它与 Linux completion 在“等待某件事情完成”这一语义上比较接近。
但它们仍然属于不同系统中的不同实现。
十七、如何选择正确的同步机制?
可以按照下面的流程判断。
是否只需要修改一个简单值?
│
┌────┴────┐
│ │
是 否
│ │
考虑原子操作 是否在等待某个事件完成?
│
┌────┴────┐
│ │
是 否
│ │
考虑 Completion 是否需要控制资源数量?
│
┌────┴────┐
│ │
是 否
│ │
考虑 Semaphore 当前上下文能否睡眠?
│
┌────┴────┐
│ │
能 不能
│ │
Mutex / rw_semaphore 临界区是否极短?
│
┌────┴────┐
│ │
是 否
│ │
Spinlock / rwlock 重新设计
如果数据具有:
读极多
写极少
读者可以失败后重试
数据能够安全复制
不包含会被释放的裸指针
还可以考虑:
Seqlock
Sequence Counter
如果数据是 CPU 本地数据,并且不存在跨 CPU 访问,可以考虑:
per-CPU 数据
local_lock
禁止抢占
如果只需要建立发布和读取顺序,而不需要互斥,可以考虑:
Acquire / Release
内存屏障
正确的原子访问接口
十八、常见错误总结
1. 临界区同一时间一定只能有一个线程进入
不一定。
读写锁的读临界区可以允许多个读者同时进入。
2. 原子性表示一段代码要么全部执行,要么全部回滚
不准确。
这是更接近事务原子性的描述。
并发原子操作强调其他执行者不能观察到操作被拆开执行。
3. 所有原子操作都是 CAS
不一定。
不同架构可以使用不同原子指令或协议。
4. 自旋锁一定比 Mutex 快
不一定。
持锁时间长或竞争严重时,自旋会浪费大量 CPU。
5. Semaphore 是重量级锁,Spinlock 是轻量级锁
这种分类过于粗糙。
更重要的区别是:
Spinlock 忙等且不能睡眠
Semaphore 表示许可并允许等待者睡眠
6. P 操作释放资源,V 操作占有资源
错误。
P/down:申请资源,减少许可
V/up:释放资源,增加许可
7. 读写信号量允许多个写者
错误。
它允许多个读者,但同一时间只有一个写者。
8. 分段锁就是读写锁允许多写
错误。
分段锁是多把独立的锁保护不同数据区域。
9. Mutex 就是值为 0 或 1 的 Semaphore
只在非常粗略的理论效果上相似。
Mutex 具有更明确的所有者和互斥语义。
10. Completion 就是回调
错误。
Completion 是等待和通知某个事件完成的同步原语。
11. 顺序锁读者可以使用中间状态
错误。
读者必须前后校验序列号,校验失败后丢弃全部结果并重试。
12. 顺序锁的写者不需要锁
错误。
多个写者仍然必须串行化;seqlock_t 内部就包含写者使用的自旋锁。
13. 禁止抢占后当前 CPU 独占所有资源
错误。
它只阻止当前 CPU 上的普通任务抢占,不能阻止其他 CPU 和中断。
14. 内存屏障可以代替锁
错误。
屏障建立内存顺序,但不能自动提供互斥和原子读—改—写。
15. volatile 就是强制访问物理内存
错误。
volatile 提供 Java 内存模型中的可见性和顺序语义,具体实现由 JVM 和硬件共同完成。
十九、总结
Linux 内核中的共享数据可能同时被:
多个 CPU
不同任务
硬件中断
软中断
定时器
内核线程
访问。
因此,选择同步机制前必须先确定:
并发来自哪里?
当前上下文能不能睡眠?
临界区会持续多久?
需要保护一个值还是一组数据不变量?
需要互斥、资源计数、事件通知还是内存顺序?
原子操作适合单个状态的不可分割更新:
原子加减
交换
比较交换
状态转换
自旋锁获取失败后继续占用 CPU:
适合非常短的临界区
适合不能睡眠的上下文
不适合长时间阻塞操作
读写自旋锁允许:
读读并发
读写互斥
写写互斥
但它需要更多原子操作,并不一定比普通自旋锁更快。
信号量表示一定数量的资源许可:
P/down:申请许可
V/up:释放许可
读写信号量是可睡眠的多读者、单写者锁,而不是允许多个写者进入。
Mutex 提供明确的互斥和所有者语义,并且可以在竞争时让任务进入等待。
Completion 用于等待某个事件完成,而不是保护普通临界区。
Seqlock 通过:
写者修改序列号
读者复制数据
读者前后校验
失败后重新读取
为读多写少的数据提供低成本一致性快照。
禁止抢占只能控制当前 CPU 上的调度行为,不能保护跨 CPU 共享数据。
内存屏障负责建立内存访问顺序:
它不是锁
不能提供互斥
不能代替原子更新
Java 中的:
AtomicInteger
synchronized
ReentrantLock
Semaphore
CountDownLatch
volatile
VarHandle
与 Linux 内核同步机制在设计思想上存在联系,但不能简单理解为对某个内核锁的直接包装。
最后可以用一句话概括内核同步机制的选择原则:
先判断谁会并发访问、当前上下文能否睡眠以及需要建立什么约束,再选择原子操作、自旋锁、睡眠锁、事件通知或内存顺序机制,而不是先选择一把看起来性能最高的锁。
评论区