侧边栏壁纸
  • 累计撰写 142 篇文章
  • 累计创建 20 个标签
  • 累计收到 3 条评论

目 录CONTENT

文章目录

Linux 内核如何解决并发问题?从原子操作、自旋锁到内存屏障

YaFuX
2026-06-17 / 0 评论 / 0 点赞 / 5 阅读 / 0 字
温馨提示:
部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

前言

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_trwlock_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 官方文档将 mutexsemaphorerw_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 中相近的抽象需要注意
原子读—改—写AtomicIntegerAtomicReferenceJava 原子类不是 Linux atomic_t 的直接包装
SpinlockJVM 内部自旋、手工 CAS 自旋Java 标准库没有通用的内核式 SpinLock
MutexsynchronizedReentrantLock语义层次和实现方式不同
rwlock / rwsemReentrantReadWriteLock内核中还要区分自旋和睡眠
Semaphorejava.util.concurrent.Semaphore都表示许可,但调度和实现不同
CompletionCountDownLatch(1)Future只是在“等待完成”语义上相近
Seqlock乐观读、版本号校验JDK 没有完全对应的通用类
Memory BarriervolatileVarHandleJava 提供的是内存模型语义
禁止抢占无直接对应属于内核调度和 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 内核同步机制在设计思想上存在联系,但不能简单理解为对某个内核锁的直接包装。

最后可以用一句话概括内核同步机制的选择原则:

先判断谁会并发访问、当前上下文能否睡眠以及需要建立什么约束,再选择原子操作、自旋锁、睡眠锁、事件通知或内存顺序机制,而不是先选择一把看起来性能最高的锁。

0

评论区