2.3.3 锁:从互斥到优化的同步原语
在多道程序环境中,进程或线程对临界资源的访问需要“排他性”保障。锁(Lock)作为同步机制的具体实现,正是这种保障的核心工具。它通过加锁-解锁的原子操作,确保临界区在任意时刻只有一个执行实体访问。锁的设计需兼顾正确性、效率和易用性,不同类型的锁适用于不同场景。
一、互斥锁:最基础的排他性保障
互斥锁(Mutex,Mutual Exclusion Lock)是锁机制的基石,其核心规则是:同一时刻只允许一个进程/线程持有锁,其他申请者必须等待。这种“一夫当关”的特性,完美匹配临界资源的互斥访问需求。
1. 工作原理与实现
互斥锁的状态只有两种:空闲(未上锁)或占用(已上锁)。进程访问临界区前需调用lock()
尝试加锁:
- 若锁空闲,成功加锁并进入临界区;
- 若锁被占用,进程进入阻塞状态(放弃CPU,转入等待队列),直到锁被释放。
释放锁时调用unlock()
,唤醒等待队列中的一个进程,使其重新竞争锁。这种“阻塞-唤醒”机制避免了软件方法的忙等问题,提高了CPU利用率。
以下是基于POSIX接口的互斥锁示例(C语言):
#include <pthread_mutex.h>
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 初始化互斥锁
void *worker(void *arg) {
pthread_mutex_lock(&lock); // 加锁:阻塞直到获取锁
// 临界区:操作共享资源
printf("Thread %ld is in critical section\n", pthread_self());
pthread_mutex_unlock(&lock); // 解锁
return NULL;
}
int main() {
pthread_t tid1, tid2;
pthread_create(&tid1, NULL, worker, NULL);
pthread_create(&tid2, NULL, worker, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
return 0;
}
执行逻辑:两个线程竞争锁,先获取锁的线程进入临界区,另一个线程在pthread_mutex_lock
处阻塞,直到前者解锁。
2. 可重入性:避免递归死锁
互斥锁默认不支持可重入——同一线程重复加锁会导致死锁。例如:
void recursive_func() {
pthread_mutex_lock(&lock);
if (depth > 0) recursive_func(depth - 1); // 递归加锁,导致死锁
pthread_mutex_unlock(&lock);
}
为解决递归场景,操作系统提供可重入互斥锁(如POSIX的pthread_mutex_recursive
)。其内部维护一个计数器:
- 线程首次加锁,计数器置1;
- 递归加锁时,计数器递增,不阻塞;
- 解锁时计数器递减,直到0才真正释放锁。
可重入锁通过“记录持有者线程ID+计数”,避免了同一线程的递归死锁,代价是增加了状态维护的开销。
二、自旋锁:忙等换效率的轻量级选择
互斥锁的阻塞机制虽避免了CPU空转,但线程上下文切换的开销(约1000个CPU周期)在临界区极短时反而成为瓶颈。自旋锁(Spin Lock)应运而生:获取锁失败时,线程不阻塞,而是循环检测锁状态(忙等),直到锁被释放。
1. 实现与适用场景
自旋锁基于硬件原子指令(如x86的LOCK
前缀)实现,典型代码如下(伪代码):
while (!test_and_set(&lock)) { /* 忙等 */ } // 测试并设置锁,原子操作
// 临界区
lock = false; // 解锁
适用场景:
- 临界区执行时间极短(如内核驱动的寄存器操作);
- 多处理器环境,忙等线程可在其他核心运行,避免切换。
局限性:单处理器下自旋锁会导致“死等”(持有锁的线程无法运行,自旋线程持续空转);临界区长时,CPU浪费严重。因此,自旋锁几乎仅用于操作系统内核的底层同步(如Linux内核的RCU机制)。
2. 自旋锁 vs 互斥锁
特性 | 自旋锁 | 互斥锁 |
等待方式 | 忙等(占用CPU) | 阻塞(释放CPU) |
上下文切换 | 无 | 有(开销大) |
临界区长度 | 必须极短(<100个CPU周期) | 无严格限制 |
典型场景 | 内核驱动、硬件寄存器操作 | 用户态长临界区、I/O相关 |
三、读写锁:读多写少的优化方案
互斥锁对读写操作一视同仁,但现实中许多场景(如缓存读取、配置文件解析)是“读多写少”。读写锁(Read-Write Lock)允许多个读线程并发访问,写线程独占,显著提升读并发性能。
1. 读写规则
- 读锁(共享锁):多个读线程可同时持有,临界区数据不会被修改;
- 写锁(排他锁):写线程持有锁时,所有读、写线程阻塞。
状态转移如图2-5所示:
空闲 → 读锁(允许多个读)
空闲 → 写锁(仅一个写)
读锁 → 写锁(需等待所有读释放)
写锁 → 读/写锁(直接切换)
2. 实现与示例
POSIX提供pthread_rwlock
接口,示例如下:
#include <pthread_rwlock.h>
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
int shared_data = 0;
// 读线程
void *reader(void *arg) {
pthread_rwlock_rdlock(&rwlock); // 加读锁
printf("Reader %ld sees data: %d\n", pthread_self(), shared_data);
pthread_rwlock_unlock(&rwlock);
return NULL;
}
// 写线程
void *writer(void *arg) {
pthread_rwlock_wrlock(&rwlock); // 加写锁
shared_data++;
pthread_rwlock_unlock(&rwlock);
return NULL;
}
执行逻辑:多个读线程可同时获取读锁,并发读取;写线程获取写锁时,所有读、写线程阻塞,直到写操作完成。
3. 饥饿问题与优化
读写锁可能导致写线程饥饿:若读线程持续申请读锁,写线程会被无限推迟。解决方案是写优先策略:当写线程等待时,新的读锁申请被阻塞,优先满足写锁。POSIX提供PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE
属性实现此策略。
四、锁的选择:场景决定类型
锁的设计始终在互斥性、并发性、开销之间权衡。考研复习中,需根据以下场景选择锁类型:
场景特征 | 推荐锁类型 | 理由 |
临界区短,多处理器 | 自旋锁 | 避免上下文切换,忙等代价低 |
临界区长,用户态代码 | 互斥锁 | 阻塞避免CPU浪费 |
读多写少 | 读写锁(读锁) | 允许多读并发,写操作独占 |
递归调用 | 可重入互斥锁 | 避免同一线程递归加锁死锁 |
分布式系统 | 分布式锁(如Redis) | 跨节点互斥,需解决网络延迟和单点故障 |
五、锁的常见误区与考研陷阱
- “锁可以解决所有并发问题”
错误。锁仅保证互斥,无法解决同步顺序问题(如生产者-消费者的“先生产后消费”依赖),需配合条件变量或信号量。 - “自旋锁比互斥锁高效”
片面。自旋锁在临界区极短时高效,否则CPU空转代价更高。考研中常考“临界区长度”对锁选择的影响。 - “读写锁的读锁无竞争”
错误。读锁之间虽不互斥,但获取读锁的操作(如计数器递增)仍需原子性,存在缓存一致性开销(如多核CPU的MESI协议同步)。
本节小结
锁是同步与互斥的具象化工具:互斥锁是基础,自旋锁优化短临界区,读写锁平衡读多写少。理解锁的核心是掌握其等待机制(忙等vs阻塞)、适用场景(临界区长度、读写比例)、以及与硬件/操作系统的协同。这些知识不仅是考研重点,更是理解后续信号量、管程等高级机制的基石。