Skip to content

同步与锁原理(CAS / 自旋锁 / futex / 读写锁 / RCU)

操作系统 ⭐⭐⭐ 中等 🔥🔥🔥 高频

💡 核心要点

所有锁的底层只有两块基石:① 硬件提供的原子指令lock cmpxchg),② CPU 的内存屏障(保证可见性与有序性)。往上才是自旋锁、互斥锁、读写锁、信号量。Linux 的 pthread_mutexfutex 实现"无竞争走用户态、有竞争才陷内核",这是面试最容易问倒人的点。本页把 OS 层的锁讲透,Java 层的 synchronized/AQSJava 并发编程

概念

多个执行流(进程 / 线程)并发访问共享资源时,如果不加协调,就会出现 竞态条件(Race Condition)——结果取决于线程的执行时序。

c
// 经典竞态: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)原子执行:

c
// CAS 语义(硬件一条指令完成)
bool CAS(int* addr, int expected, int new_val) {
    if (*addr == expected) {   // 比较
        *addr = new_val;        // 交换
        return true;
    }
    return false;
}

自旋实现无锁自增

c
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 CAScmpxchg16b)把指针+计数器一起比较。

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 即管程
c
// 条件变量必须 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 UPDATECAS / 版本号 / 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 调度