同步与锁原理(CAS / 自旋锁 / futex / 读写锁 / RCU)
操作系统 ⭐⭐⭐ 中等 🔥🔥🔥 高频
💡 核心要点
所有锁的底层只有两块基石:① 硬件提供的原子指令(lock cmpxchg),② CPU 的内存屏障(保证可见性与有序性)。往上才是自旋锁、互斥锁、读写锁、信号量。Linux 的 pthread_mutex 用 futex 实现"无竞争走用户态、有竞争才陷内核",这是面试最容易问倒人的点。本页把 OS 层的锁讲透,Java 层的 synchronized/AQS 见 Java 并发编程。
概念
多个执行流(进程 / 线程)并发访问共享资源时,如果不加协调,就会出现 竞态条件(Race Condition)——结果取决于线程的执行时序。
// 经典竞态:i++ 不是原子操作
// 1) load i → 寄存器
// 2) add 寄存器 + 1
// 3) store 寄存器 → i
// 两个线程交错执行 store,会丢失一次自增把"必须互斥访问"的代码段叫 临界区(Critical Section)。同步机制的目标就是保证临界区的 互斥(Mutual Exclusion),同时尽量减少等待开销。
| 同步要解决的 3 个问题 | 含义 |
|---|---|
| 原子性 | 操作不可被打断(要么全做,要么不做) |
| 可见性 | 一个线程的修改,其他线程能立即看到 |
| 有序性 | 禁止编译器 / CPU 把指令重排到临界区外 |
核心原理
1. 原子操作与 CAS(一切锁的基石)
单条 CPU 指令天然原子,但 i++ 这种"读-改-写"需要硬件支持。x86 用 lock 前缀 锁总线 / 缓存行,保证 cmpxchg(Compare-And-Swap)原子执行:
// CAS 语义(硬件一条指令完成)
bool CAS(int* addr, int expected, int new_val) {
if (*addr == expected) { // 比较
*addr = new_val; // 交换
return true;
}
return false;
}自旋实现无锁自增:
int old;
do {
old = counter;
} while (!CAS(&counter, old, old + 1)); // 失败就重试⚠️ CAS 的 ABA 问题
线程 1 读到 A,准备 CAS。期间线程 2 把 A→B→A。线程 1 的 CAS 仍然成功,但中间状态被忽略了。 解决:加版本号(AtomicStampedReference / 指针打 tag)。Linux 内核用 double-width CAS(cmpxchg16b)把指针+计数器一起比较。
2. 内存屏障与可见性
CPU 为提速会乱序执行 + Store Buffer 缓存写,导致一个核的写其他核看不到。内存屏障(Memory Barrier) 强制刷新顺序:
| 屏障类型 | 作用 |
|---|---|
| LoadLoad | 屏障前的读,先于屏障后的读完成 |
| StoreStore | 屏障前的写,先于屏障后的写刷出 |
| LoadStore / StoreLoad | 读写之间的顺序约束(StoreLoad 最贵) |
| acquire 语义 | 加锁:后续读写不能重排到它前面 |
| release 语义 | 解锁:之前读写不能重排到它后面 |
缓存一致性靠 MESI 协议(Modified / Exclusive / Shared / Invalid)在核间同步缓存行;屏障保证的是"屏障两侧指令的相对顺序",二者配合才有正确的可见性。Java 的
volatile、C++ 的std::atomic都编译成对应屏障。
3. 自旋锁 vs 互斥锁(何时该忙等)
自旋锁 spinlock:拿不到锁 → 原地 while 循环重试(不让出 CPU)
互斥锁 mutex :拿不到锁 → 线程睡眠,让出 CPU,被唤醒再跑| 维度 | 自旋锁 | 互斥锁 |
|---|---|---|
| 等待方式 | 忙等(空转 CPU) | 睡眠(让出 CPU) |
| 切换开销 | 无上下文切换 | 有,睡眠+唤醒 ~1-3μs |
| 适用场景 | 临界区极短、多核 | 临界区长 / 可能阻塞 |
| 单核 | ❌ 灾难(持锁者拿不到 CPU) | ✅ |
| 内核典型 | spinlock_t(关抢占) | mutex / semaphore |
经验法则:临界区执行时间 < 一次上下文切换(约 1μs)→ 自旋更划算;否则互斥。现代实现常用 自适应锁:先自旋几十次,失败再睡眠(glibc PTHREAD_MUTEX_ADAPTIVE_NP、Java 偏向/轻量级锁)。
4. futex —— Linux 锁的真正引擎(必背)
问题:早期每次加锁都要 syscall 陷内核,即使没有竞争也付出几百 ns。 futex(Fast Userspace muTEX) 的核心思想是 "无竞争走用户态,有竞争才陷内核":
加锁:
① 用户态 CAS 把 futex 值 0→1 ── 成功?直接返回,全程不进内核(快路径)
② CAS 失败(有人持锁)→ futex_wait() 系统调用,线程睡入内核等待队列
解锁:
① 用户态 CAS 把 futex 值 1→0
② 若有等待者 → futex_wake() 唤醒一个┌─────────── 用户态 ───────────┐
│ futex 整数 (共享内存) │
│ 无竞争: CAS 一把搞定,0 syscall │ ← 99% 的快路径
└──────────────┬───────────────┘
│ 有竞争才下沉
┌──────────────▼───────────────┐
│ 内核态: futex_wait/wake │
│ 维护每个 futex 的等待队列 │
└──────────────────────────────┘一句话答:
pthread_mutex/std::mutex底层都是 futex。无竞争时零系统调用(用户态 CAS),只有发生争用才futex_wait陷内核睡眠——这就是 Linux 锁高效的根因。
5. 读写锁与 RCU(读多写少)
读写锁(rwlock):读读共享、读写互斥、写写互斥。
| 读锁 | 写锁 | |
|---|---|---|
| 读锁 | ✅ 并发 | ❌ 互斥 |
| 写锁 | ❌ 互斥 | ❌ 互斥 |
缺点:写者可能饥饿;读锁本身也有原子计数开销。
RCU(Read-Copy-Update) 是 Linux 内核读多写少场景的杀手锏,读侧几乎零开销:
读者:直接读,不加锁(只关抢占 / 标记宽限期)
写者:① 复制一份副本 ② 修改副本 ③ 原子替换指针 ④ 等所有旧读者退出(宽限期 grace period)再释放旧数据- 优势:读路径无锁、无屏障、可无限并发;适合路由表、
dentry缓存、配置热更新。 - 代价:写者复杂、有延迟回收(宽限期),不适合写频繁。
- Java 世界的
CopyOnWriteArrayList就是 RCU 思想的应用。
6. 信号量 / 条件变量 / 管程
| 原语 | 解决什么 | 关键点 |
|---|---|---|
| 互斥锁 Mutex | 互斥(1 个资源) | 谁加锁谁解锁 |
| 信号量 Semaphore | 计数同步(N 个资源 / 生产消费) | P() 减、V() 加;二值信号量≈锁 |
| 条件变量 Condvar | "等待某条件成立" | 必须配合锁;while 判断防虚假唤醒 |
| 管程 Monitor | 锁 + 条件变量的高级封装 | Java synchronized+wait/notify 即管程 |
// 条件变量必须 while 判断(防虚假唤醒 spurious wakeup)
pthread_mutex_lock(&m);
while (queue_empty()) // ✅ while 不是 if
pthread_cond_wait(&cond, &m); // 原子地"释放锁+睡眠",唤醒后重新抢锁
consume();
pthread_mutex_unlock(&m);7. 乐观锁 vs 悲观锁
| 悲观锁 | 乐观锁 | |
|---|---|---|
| 假设 | 一定会冲突,先加锁 | 大概率不冲突,先做后验证 |
| 实现 | mutex / SELECT ... FOR UPDATE | CAS / 版本号 / WHERE version=? |
| 适用 | 写多、冲突频繁 | 读多写少、冲突罕见 |
| 代价 | 阻塞、可能死锁 | 冲突时重试,高冲突下空转浪费 |
面试常问 & 怎么答
Q1:锁的底层是怎么实现的?
自底向上三层:① 硬件——lock cmpxchg 原子指令 + 内存屏障;② OS——Linux 用 futex,无竞争时用户态 CAS 零系统调用,有竞争才 futex_wait 陷内核睡眠;③ 语言层——Java synchronized 锁升级(偏向→轻量级自旋→重量级 monitor)、AQS 用 CAS+CLH 队列。能讲清"快路径在用户态"是高级信号。
Q2:自旋锁和互斥锁怎么选?
看临界区长度。临界区比一次上下文切换(~1μs)还短,且在多核 → 自旋锁(避免切换开销);临界区长或可能阻塞(如里面有 I/O)→ 互斥锁(睡眠让出 CPU)。单核绝不用纯自旋锁(持锁者拿不到 CPU 就死锁式空转)。现代实现多用自适应:先自旋若干次再睡眠。
Q3:什么是 CAS?有什么问题?
Compare-And-Swap,比较内存值与预期值,相等才写入,硬件保证原子。无锁编程的基础。三大问题:① ABA(加版本号解决);② 高冲突下自旋空转浪费 CPU(改用 LongAdder 分段或退化为锁);③ 只能保证单个变量原子(多变量需要 cmpxchg16b 或锁)。
Q4:futex 为什么快?
传统锁每次加解锁都要系统调用陷内核。futex 把锁状态放在用户态共享内存的一个整数里:无竞争时一次用户态 CAS 就完成,根本不进内核;只有 CAS 失败(真的有人持锁)才调用 futex_wait 睡眠。绝大多数加锁是无竞争的,所以平均开销极低。
Q5:RCU 凭什么读侧零开销?
读者直接访问数据,不加锁不加屏障;写者采用"复制-修改-原子替换指针",旧版本等到所有读者都离开临界区(宽限期)后才回收。代价是写者复杂、回收有延迟,所以只适合读远多于写(路由表、配置热更新)。
看到什么就先想到这类
| 关键词 / 场景 | 联想到 |
|---|---|
| 无锁、原子自增 | CAS + 自旋重试,注意 ABA |
| 一个线程改了别的线程看不到 | 内存屏障 / 可见性 / MESI |
| 临界区极短、多核 | 自旋锁 spinlock |
| pthread_mutex 为什么快 | futex:用户态 CAS 快路径 + 内核慢路径 |
| 读多写少、读不能慢 | 读写锁 → 极端则 RCU / COW |
| 生产者消费者、限流 N 个资源 | 信号量 Semaphore |
| 等待队列非空 / 条件满足 | 条件变量(while 防虚假唤醒) |
| 数据库行级冲突少 | 乐观锁 version 字段 |
| 高优先级被低优先级锁阻塞 | 优先级反转 → 见 CPU 调度 |