两个线程同时执行 count++,结果可能只加了一次——同步机制要管的就是这类事。本文从临界区讲起,理清互斥锁、自旋锁、信号量、读写锁各自等的是什么,futex 如何把等待成本压到最低,最后用两个经典问题把原语串起来。

它解决什么问题

count++ 在源码里是一行,编译后却是”读内存、加一、写回”三条指令。线程 A 读到旧值还没写回,线程 B 也读到同一个旧值,两个线程各算各的,最后一次写回覆盖了另一次——这就是竞态条件(Race Condition):结果取决于执行时序,而时序不受你控制。

访问共享可变状态、且不能被并发交错执行的那段代码,叫临界区(Critical Section)。锁与同步机制的全部使命,就是保证同一时刻只有一个执行流在临界区里,并且它写的成果能被下一个进入者正确看到。评价一种同步机制看四个维度:正确性(真的互斥)、进展性(不会全员卡死)、公平性(没人永远等不到)、性能(无竞争时足够便宜)。

核心原理:四种锁各自等什么

互斥锁(Mutex) 等的是”进入临界区的资格”。拿不到就睡眠,把 CPU 让出来,由内核在锁释放时唤醒。它有明确的所有权语义:谁加锁谁解锁,别人不能代劳。

自旋锁(Spinlock) 等的是同一个资格,但姿势是原地忙等,循环检查锁是否空出来。它赌的是”持锁者马上就走”:临界区只有几条指令时,睡眠加唤醒的两次上下文切换比空转几圈贵得多。赌输的代价是纯烧 CPU,所以它只适合极短临界区,且内核里持自旋锁时会禁用抢占,防止持锁者被换下后等待者空转到天荒地老。

信号量(Semaphore) 等的是”数量”。它本质是一个非负计数器:P 操作(sem_wait)减一,减不动就睡眠;V 操作(sem_post)加一并唤醒等待者。初始化为 1 时可当互斥锁用,但真正的主场是资源计数——连接池还剩几个连接、缓冲区还剩几个空位。注意它没有所有者概念,一个线程 P、另一个线程 V 完全合法。

读写锁(ReadWrite Lock) 把互斥拆成不对称的两半:读者之间互不排斥,写者排斥所有人。适合读远多于写的场景(配置、路由表、缓存元数据),读临界区可以成群并行通过;写频繁时写者排队成本高,还可能出现写者饥饿,反而不如普通互斥锁。

这些锁无论哪种,最底下都要落在硬件原子指令上:test-and-set、compare-and-swap 保证”检查锁是否空闲”和”占住锁”之间不可能插入第三者。锁的获取与释放还附带内存序语义(acquire/release),保证临界区内的写入对下一个持锁者可见。

深入机制:futex 的两段式设计

如果每次加锁都要进内核睡眠,一次系统调用的开销(几百纳秒到微秒级)在无竞争时纯属浪费。Linux 的答案是 futex(fast userspace mutex):把锁拆成快路径和慢路径。

  • 快路径:无竞争时,线程在用户态用一条原子指令把锁字从 0 改成 1,直接进临界区,全程不碰内核。
  • 慢路径:发现锁已被占,才调用 futex(FUTEX_WAIT) 陷入内核。内核先原子地核对锁字仍是调用者看到的那个值(防止刚检查完锁就释放了的竞态),确认后才把线程挂进等待队列。
  • 唤醒:持锁者释放时若发现有人排队,调用 futex(FUTEX_WAKE) 唤醒一个或多个等待者。

glibc 的 pthread mutex、JVM 的锁实现,底层都是这个套路。对应用开发者的启示只有一句:现代锁在没竞争时几乎免费,真正昂贵的是竞争本身,优化方向应该是缩短临界区、降低竞争,而不是回避加锁。

工程实践:两个经典问题

生产者-消费者

有界缓冲区需要三件套:两个信号量管数量,一把互斥锁管结构:

sem_t empty_slots;   // 初始化为缓冲区容量 N:还有几个空位
sem_t filled_slots; // 初始化为 0:还有几件产品
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;

void producer(void) {
item_t item = produce();
sem_wait(&empty_slots); // 等空位
pthread_mutex_lock(&mtx);
put_item(item); // 临界区只放数据
pthread_mutex_unlock(&mtx);
sem_post(&filled_slots); // 库存 +1
}

void consumer(void) {
sem_wait(&filled_slots); // 等产品
pthread_mutex_lock(&mtx);
item_t item = take_item();
pthread_mutex_unlock(&mtx);
sem_post(&empty_slots); // 空位 +1
consume(item);
}

顺序是生命线:sem_wait 必须在拿互斥锁之前。如果先拿锁再等信号量,缓冲满时生产者握着锁睡死,消费者进不来取货,就是教科书式死锁。

读者-写者

配置中心这类”读多写少”的场景,用读写锁最直白:

ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();

String readConfig() {
rwLock.readLock().lock();
try {
return config; // 多个读者可并行进入
} finally {
rwLock.readLock().unlock();
}
}

void writeConfig(String next) {
rwLock.writeLock().lock();
try {
config = next; // 写期间读者全部挡在外面
} finally {
rwLock.writeLock().unlock();
}
}

要警惕写者饥饿:读者源源不断地来,写锁可能永远等不到安静的窗口。ReentrantReadWriteLock 的公平模式让新读者排在等待的写者后面,用一部分读吞吐换写者的进展保证。

对比与权衡

机制 等待对象 等不到时 适用场景 主要风险
互斥锁 临界区资格 睡眠让出 CPU 绝大多数共享状态保护 临界区过长、死锁
自旋锁 临界区资格 忙等烧 CPU 内核/极短临界区 持锁者被抢占、临界区含 I/O
信号量 资源数量 计数为 0 睡眠 限流、有界缓冲、事件计数 无所有权、语义模糊易误用
读写锁 读写角色许可 按角色排队 读远多于写的共享数据 写者饥饿、写多时反而更慢
futex 锁字期望值 仅竞争时陷内核 作为用户态锁的地基 一般不直接使用

面试视角

互斥锁和自旋锁怎么选?

看临界区长度和等待预期。临界区极短、持锁者很快释放时,自旋省掉睡眠唤醒两次上下文切换;临界区长或含阻塞操作时,自旋纯属烧 CPU,必须让线程睡下去。用户态业务代码几乎总是选互斥锁。

信号量和互斥锁的区别?

互斥锁表达所有权:谁拿谁还,保护一段临界区;信号量表达计数:还剩几份资源,可以由不同线程增减。把信号量当初值为 1 的锁用是常见误用,遇到需要所有权语义(如优先级继承)的场景就会出问题。

futex 解决了什么问题?

纯内核锁每次操作都要系统调用,无竞争时也照付不误。futex 让用户态原子操作处理无竞争的快路径,只有真正需要睡眠或唤醒时才进内核,把常见路径的成本压到一条 CAS 指令。

条件变量为什么要配合互斥锁、且用 while 等待?

wait 需要原子地”放锁并入睡”,否则检查条件和入睡之间会丢通知。醒来必须用 while 重查条件,因为存在虚假唤醒,也可能被其他消费者抢先取走了数据。

小结

  • 同步的目标是维护共享状态的不变量,竞态条件源于多步操作被时序交错。
  • 互斥锁管资格、信号量管数量、读写锁管角色、自旋锁用 CPU 换延迟,futex 负责把无竞争路径留在用户态。
  • 经典问题的价值在于模式:生产者-消费者示范”数量与结构分离”,读者-写者示范”按角色拆分互斥”。