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

目 录CONTENT

文章目录

多个 CPU 同时修改内核数据怎么办?从原子操作到自旋锁、信号量与 Seqlock

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

前言

在 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 内核为什么需要这么多种同步方法?它们分别解决什么问题?

整条主线可以先看成:

多个 CPU / 执行上下文访问共享数据
可能发生 Race Condition
必须建立同步规则
单个变量不可分割修改
短临界区互斥
长时间等待
读多写少
等待某个事件完成
保护 Per-CPU 数据
约束内存访问顺序
Atomic Operation
Spinlock
Mutex / Semaphore
RW Lock / Seqlock
Completion
Preemption Control
Memory Barrier

理解了“问题类型”,再看这些机制,就不会觉得它们只是十一组需要死记的 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

于是可能出现:

CPU 1MemoryCPU 0CPU 1MemoryCPU 0读取 counter = 5读取 counter = 5计算得到 6计算得到 6写回 6写回 6

我们本来执行了两次 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 RetryReader 通常重试读极多、写很少的一致性读取
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 仍然在执行循环。

CPU 尝试获取 Spinlock
锁空闲
进入 Critical Section
释放 Spinlock
锁被占用
继续 Busy Wait

所以如果锁长期不释放:

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
→ 互斥
Read Lock
多个 Reader 可以同时进入
Write Lock
Writer 独占临界区
其他 Reader / Writer 等待

它和 Java ReadWriteLock 的“读共享、写独占”思想相近。

但这里仍然是 Kernel Spin Lock 家族:

发生竞争时,核心特点仍然是不能把它当成普通可睡眠锁使用。

四、可睡眠的同步:Semaphore、RW Semaphore 与 Mutex

1. Semaphore:资源不是只有“有锁”和“没锁”两种状态

Mutex 只能表达:

0 个进入
或
1 个进入

但有些资源允许同时给多个执行者使用。

例如一个资源池最多允许 10 个并发使用者:

permits = 10

每进入一个:

permits--

每退出一个:

permits++

当它变成 0 后,新的申请者必须等待。

这就是 Counting Semaphore 的核心。

Semaphore count = 3
Task A 获取一个 Permit
count = 2
Task B 获取一个 Permit
count = 1
Task C 获取一个 Permit
count = 0
后续 Task 进入等待

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()
Task BCompletionTask ATask BCompletionTask Await_for_completion进入等待完成目标工作complete唤醒继续执行

这和 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
Reader 读取 Sequence
读取共享 Data
再次验证 Sequence
Sequence 稳定且未跨越写入
本次读取有效
Sequence 变化或检测到 Writer

也就是说:

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。

需要同步
只是单个状态的原子更新
Atomic Operation
需要保护一段 Critical Section
等待期间能不能 Sleep
不能 Sleep / 临界区极短
Spinlock / RW Spinlock
可以 Sleep
Mutex / Semaphore / RW Semaphore
不是抢资源,而是等待事件完成
Completion
读特别多、写特别少
Seqlock / Seqcount
只需要保护当前 CPU 上下文
Preemption Control
只需要建立内存访问顺序
Memory Barrier

真正做 Kernel Development 时,还必须继续考虑:

Process Context 还是 Interrupt Context?
能不能 Sleep?
Lock 持有多久?
是否跨 CPU?
是否访问 Per-CPU Data?
Reader / Writer 比例怎样?
是否需要严格 Owner 语义?

所以没有一把“性能最好、任何地方都能用”的万能锁。

2. 把 Linux 同步机制和 Java 放在一起看

Java 程序员最容易理解这些机制的方法,是做概念映射,但必须避免把“概念相似”写成“底层就是同一个实现”。

Linux Kernel 概念Java 中可帮助理解的概念是否一一对应
Atomic OperationAtomicInteger、CAS否,只是原子读改写思想相近
SpinlockCAS Spin Loop否,Java 没有普通业务代码直接使用 Kernel Spinlock 的对应 API
RW SpinlockReadWriteLock否,等待策略不同
SemaphoreSemaphore语义相近,实现不同
RW SemaphoreReadWriteLock语义接近,不是同一个实现
Mutexsynchronized / ReentrantLock互斥概念相近,不是一一对应
CompletionCountDownLatch(1)使用模式相近
SeqlockOptimistic Read 思想思想接近,不是 Java 标准等价物
Preemption Control无直接普通 Java 对应
Memory Barriervolatile / 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 中 mutexsemaphore 是不同 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。

0

评论区