在多核 CPU 时代,多线程编程已成为充分提升系统吞吐量与响应速度的必备手段。然而,多个线程并发访问同一块内存区域或修改共享状态时,极易触发数据竞态(Data Race),导致数据错乱或未定义行为。
多线程并发同步机制就是用来控制多个线程同时访问共享资源、或者协调它们执行顺序的工具。它的核心目的是保障数据一致性、防止竞态条件,并避免程序发生死锁(Deadlock)与饥饿(Starvation)。
根据不同的应用场景与协调方式,并发同步机制主要划分为四大类:
锁机制(保护共享资源)
锁是最基础、最直接的同步机制,用于建立临界区(Critical Section),确保同一时刻只有一个(或特定数量的)线程能够操作某块代码或数据。
互斥锁(Mutex)
互斥锁(Mutual Exclusion Lock)是最常用的独占锁。
- 原理:互斥锁具有“排他性”。当一个线程成功加锁(Lock)后,其他试图获取该锁的线程会被内核挂起并放入等待队列(进入阻塞/休眠状态),直到持锁线程释放锁(Unlock)并唤醒等待线程。
- CPU 行为:获取失败时会触发上下文切换(Context Switch),线程让出 CPU 执行权。
- 适用场景:临界区代码执行时间较长、涉及磁盘/网络 I/O 或复杂业务计算的通用并发保护。
- 经典示例(POSIX pthread / C++):
#include <pthread.h>
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int shared_counter = 0;
void* increment(void* arg) {
pthread_mutex_lock(&lock);
shared_counter++; // 临界区操作
pthread_mutex_unlock(&lock);
return NULL;
}
读写锁(Read-Write Lock)
读写锁(如 pthread_rwlock_t / Java ReentrantReadWriteLock)采用“读写分离”的策略来提升并发读吞吐。
- 原理:
- 读共享(Read Lock):允许多个线程同时获取读锁,相互不阻塞。
- 写独占(Write Lock):只要有线程持有写锁或申请写锁,其他线程的读/写申请均会被阻塞。
- 锁策略:读写锁通常分为读优先、写优先或公平模式,防止写线程因源源不断的读请求而产生“写饥饿”。
- 适用场景:读多写少的缓存系统、配置中心、路由表等高频读取场景。
自旋锁(Spinlock)
自旋锁是一种“不休眠”的互斥锁。
- 原理:当线程尝试获取自旋锁失败时,不会进入休眠或让出 CPU,而是在 CPU 上运行一个紧凑的循环死等(Busy-waiting),持续检查锁是否被释放。
- CPU 行为:不触发上下文切换,但会持续占用 100% CPU 核心。
- 适用场景:临界区非常小、持锁时间极短(如仅修改一个指针或几条指令)、且上下文切换的开销远大于等待锁时间的高性能底层场景(如 Linux 内核中断处理)。
无锁并发方案(Lock-Free)
无锁并发通过硬件原子指令与特殊连续内存结构,避免了传统锁机制带来的上下文切换与锁竞争瓶颈。
乐观锁与 CAS(Compare-And-Swap)
乐观锁假设多个线程并发冲突的概率较低,在更新时刻利用 CPU 提供的硬件级原子指令进行校验与更新。
- 原理:比较内存中的原值 $V$ 是否等于期望值 $A$,若相等则将其更新为新值 $B$,整个过程为单条原子 CPU 指令(如 x86 的
CMPXCHG)。若校验失败,通常采取自旋重试(Spin-retry)。 - 经典问题与解法:
- ABA 问题:变量值从 $A$ 改为 $B$ 又改回 $A$,CAS 误认为未改变。解法:引入递增版本号/时间戳(如 Java
AtomicStampedReference)。 - 高并发自旋 CPU 浪费:高冲突下会导致大量 CAS 重试。
- ABA 问题:变量值从 $A$ 改为 $B$ 又改回 $A$,CAS 误认为未改变。解法:引入递增版本号/时间戳(如 Java
- 适用场景:无锁计数器、无锁栈/队列(如 Treiber Stack / Michael-Scott Queue)。
无锁环形队列(Ring Buffer)
无锁环形队列是一种基于固定大小连续数组的高性能无锁数据交接方案,常用于单机高吞吐、极低延迟的场景。
- 原理:
- 固定连续数组:初始化时一次性分配好内存,原位覆盖更新,零 GC 压力。
- 快速位运算寻址:数组长度取 $2^n$,使用
index & (capacity - 1)快速定位。 - 读写指针分离:单生产者单消费者(SPSC)场景下,写指针
Head仅由生产者推进写入,读指针Tail仅由消费者推进读取,两端互不干涉,完全无需加锁即可实现高效交接。
- 适用场景:高性能网络通信(如 Linux
ring_buffer/ DPDK)、进程内异步日志队列、音视频数据流缓冲区。
条件与通知(协同步调)
当线程间需要根据特定的业务条件来协调执行顺序时(如生产者-消费者模型),使用条件机制。
条件变量(Condition Variable)
条件变量(Condition Variable)是操作系统与标准库提供的基础同步原语,必须与互斥锁配合使用。
- 核心组件:
wait():释放已持有的互斥锁,并将当前线程挂起放入条件等待队列,直到被唤醒;唤醒后会自动重新竞争并获取互斥锁。signal()/notify():唤醒至少一个在条件变量上等待的线程。broadcast()/notifyAll():唤醒所有在条件变量上等待的线程。
- 核心防错要点(虚假唤醒 Spurious Wakeup):
在等待条件满足时,必须使用
while循环而非if语句检查条件,因为操作系统可能在未收到显式 Signal 的情况下解挂线程,或者被唤醒的线程在抢到锁前条件又被其他线程打破。
// 生产者-消费者模式下的条件变量经典范式
pthread_mutex_lock(&lock);
while (!condition_is_met) { // 必须使用 while 防止虚假唤醒
pthread_cond_wait(&cond, &lock);
}
// 执行业务逻辑...
pthread_mutex_unlock(&lock);
计数器与栅栏(多任务对齐)
用于控制多个线程的整体执行节奏和数量限制。
信号量(Semaphore)
信号量(Semaphore)由 Dijkstra 提出,本质上是一个带同步队列的共享资源计数器。
- 基本操作:
P()/acquire():计数器减 1,若 $< 0$ 则线程挂起等待。V()/release():计数器加 1,并唤醒等待队列中的线程。
- 应用分类:
- 二元信号量:初始值为 1,功能等价于互斥锁。
- 计数信号量:初始值为 $N$,用于资源池限流(如数据库连接池上限、API 并发限速)。
倒计时栅栏(CountDownLatch)
倒计时栅栏用于实现一等待多的同步对齐模式。
- 原理:初始化时指定倒计时总数 $N$。主线程调用
await()进入阻塞,其他 $N$ 个并行工作子线程在完成各自任务后调用countDown()使计数器减 1。当计数器归零时,主线程被唤醒继续往下执行。 - 特点:一次性使用,计数器归零后不可重置。
- 经典场景:微服务并行调用(主线程等待 5 个下游服务接口全部返回后再拼装结果)、多线程初始化检测。
循环屏障(CyclicBarrier)
循环屏障用于实现多线程互相等待、齐头并进的集合点模式。
- 原理:让一组线程(例如 $N$ 个线程)在到达某个代码关卡(Barrier Checkpoint)时调用
await()挂起。只有当所有 $N$ 个线程都到达了该关卡后,屏障才会打开,允许所有线程同时越过关卡继续向下执行。 - 特点:可循环复用。在全员越过关卡后,屏障会自动重置,用于下一轮迭代。
- 经典场景:多线程分步并行计算(如矩阵计算、多阶段并行仿真建模)。
语言特有的高级同步抽象与 JVM 经典实现
现代编程语言与运行时环境为了降低并发死锁风险、兼顾极致性能与开发体验,封装了更高层的同步模型与核心框架。
JVM Monitor 锁(synchronized 机制)
在 Java 中,synchronized 关键字底层依赖 JVM 的 Monitor(管程/监视器锁) 实现。在 HotSpot JVM 中,每个对象头(Mark Word)均可关联一个 C++ ObjectMonitor 对象。
ObjectMonitor 核心结构:
_owner:指向当前持有 Monitor 锁的 Thread。_cxq/EntryList:争抢锁失败的线程会被封装并放入阻塞队列。_WaitSet:线程持有锁期间调用Object.wait()后,会释放锁并进入该等待集合,直到被notify()唤醒。
JVM 锁膨胀升级(Lock Escalation): JVM 为了优化
synchronized的性能,设计了无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁的锁膨胀路径:- 偏向锁(Biased Locking):针对“锁总是由同一线程多次获得”的场景,仅在 Mark Word 中记录 ThreadID,无需任何 CAS 操作。
- 轻量级锁(Lightweight Locking):当有其他线程竞争偏向锁时,升级为轻量级锁,线程在各自栈帧的 Lock Record 中通过 CAS 试图获取锁,避免内核态切换。
- 重量级锁(Heavyweight Locking):自旋 CAS 超过阈值或竞争激烈时,膨胀为重量级锁,创建底层
ObjectMonitor,未抢到锁的线程进入内核态挂起 (park)。
Java AQS 框架(AbstractQueuedSynchronizer)
Java 在 java.util.concurrent (JUC) 包中提供了 AQS(AbstractQueuedSynchronizer),这是整个 Java 并发包的基石(如 ReentrantLock、Semaphore、CountDownLatch 等均基于 AQS)。
AQS 核心三大要素:
volatile int state:表示同步状态。通过getState()、setState()和compareAndSetState()进行原子更新(如在ReentrantLock中表示重入次数,在Semaphore中表示剩余许可证数量)。- 双向 CLH 队列:基于 CAS 构建的虚拟双向链表。抢锁失败的线程被封装为
Node节点挂载到队列尾部,进入自旋/挂起状态。 LockSupport.park/unpark:底层利用 POSIXpthread_mutex/condition或 Linuxfutex来挂起和唤醒具体线程。
独占与共享两种模式:
- 独占模式(Exclusive):如
ReentrantLock,同一时刻仅允许一个线程持有锁。 - 共享模式(Shared):如
CountDownLatch、Semaphore,允许多个线程同时获取共享许可。
- 独占模式(Exclusive):如
Go 语言 Channel (CSP 模型)
Go 语言在语言层实现了 CSP(Communicating Sequential Processes) 理论。
- 理念:“不要通过共享内存来通信,而要通过通信来共享内存”。
- 核心机制:Channel 是连接 Goroutine 的第一公民管道。数据发送与接收天然具备同步语义。
- 无缓冲 Channel:发送与接收必须同步交接,直接作为精确的同步关卡。
- 带缓冲 Channel:基于内置环形队列,实现生产与消费解耦。
- 控制能力:配合
select关键字,轻松实现多路复用与超时控制。
并发同步机制对比与选型指南
下表总结了各类同步机制的核心特征与选型依据:
| 同步机制 | 核心目的 | 阻塞方式 | CPU 开销 | 典型适用场景 |
|---|---|---|---|---|
| 互斥锁 (Mutex) | 共享资源独占保护 | 线程休眠 (Context Switch) | 中 | 通用临界区保护,持锁时间中长 |
| 读写锁 (RWLock) | 读写分离提升并发读 | 线程休眠 | 中 | 读多写少的高频读场景 |
| 自旋锁 (Spinlock) | 短临界区快速保护 | 死等 Busy-waiting (占用 CPU) | 低(时间短)/ 高(时间长) | 极其微小的临界区,内核底层开发 |
| CAS / 乐观锁 | 无锁原子更新 | 自旋重试 | 低(低冲突)/ 高(高冲突) | 无锁计数器、无锁数据结构 |
| 无锁环形队列 | 高吞吐/低延迟无锁交接 | 游标推进 / 原子自旋 | 极低 | 异步日志、网络报文缓冲区、音视频流 |
| 条件变量 | 条件满足时通知唤醒 | 线程休眠 | 中 | 生产者-消费者模式、状态等待 |
| 信号量 (Semaphore) | 控制并发资源上限 | 线程休眠 | 中 | 资源池限流、API 并发控制 |
| CountDownLatch | 一等待多线程完成 | 主线程休眠 | 中 | 并行任务结果汇总、多服务并行调用 |
| CyclicBarrier | 多线程互相等待集合 | 所有线程休眠等待 | 中 | 多阶段分步并行计算 |
| Go Channel | 消息传递与同步 | 用户态 Goroutine 挂起 | 极低 | 协程间通信、任务分发、解耦 |