锁与并发
并发与竞态
多个线程/进程同时访问共享资源时,可能产生竞态条件(Race Condition)。锁是解决竞态的主要机制。
临界区(Critical Section):访问共享资源的代码段,同一时间只允许一个线程进入。
锁需要满足的四个条件:
- 互斥:同一时间只有一个线程进入临界区
- 前进:不在临界区的线程不能阻止其他线程进入
- 有限等待:不会出现"饿死"
- 无忙等待(理想情况):线程在等待锁时可以被调度出去
互斥锁(Mutex)
Mutex(Mutual Exclusion)是最基础的同步原语。
c
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&lock);
// 临界区操作
pthread_mutex_unlock(&lock);实现原理
用户态:使用原子操作(如 CAS、xchg)尝试获取锁
- 成功 → 进入临界区
- 失败 → 调用
futex系统调用进入睡眠
内核态:
- Linux
futex(Fast Userspace Mutex):快速用户态互斥体 - 线程睡眠时挂到等待队列,持有锁的线程解锁时通过
futex_wake唤醒
性能:无竞争时仅需一次原子操作(约几十纳秒),有竞争时需系统调用(约几微秒)。
自旋锁(Spinlock)
c
spinlock_t lock;
spin_lock(&lock);
// 临界区(必须简短!)
spin_unlock(&lock);特点:获取锁失败时不会睡眠,而是忙等待(循环检查锁状态)。
适用场景:
- 临界区极短(几十条指令)
- 中断上下文或不允许睡眠的上下文
- 多核 CPU(单核自旋锁无意义,直接关中断)
缺点:浪费 CPU 时间,不适合长时间持有。
读写自旋锁
读操作不互斥,写操作排他:
c
rwlock_t rwlock;
read_lock(&rwlock); // 多个读可同时持有
// 读操作
read_unlock(&rwlock);
write_lock(&rwlock); // 写操作需等待所有读释放
// 写操作
write_unlock(&rwlock);读写锁(RWLock)
读写锁允许多个线程同时读,但写操作需要独占访问。
| 操作 | 无锁 | 有读锁 | 有写锁 |
|---|---|---|---|
| 申请读锁 | 通过 | 通过 | 阻塞 |
| 申请写锁 | 通过 | 阻塞 | 阻塞 |
写优先 vs 读优先:
- 读优先:读锁申请者只要没有写锁就通过,可能导致写操作饿死
- 写优先:有写操作等待时,新读锁申请被阻塞
Java ReadWriteLock
java
ReadWriteLock rwLock = new ReentrantReadWriteLock();
rwLock.readLock().lock(); // 读锁
try { /* 读操作 */ }
finally { rwLock.readLock().unlock(); }
rwLock.writeLock().lock(); // 写锁
try { /* 写操作 */ }
finally { rwLock.writeLock().unlock(); }信号量(Semaphore)
信号量是一个计数器 + 两个原子操作(P/V 或 wait/signal):
c
sem_t sem;
sem_init(&sem, 0, 5); // 初始值为 5
sem_wait(&sem); // P: 值减 1,如果为 0 则阻塞
// 资源操作
sem_post(&sem); // V: 值加 1,唤醒等待线程Mutex vs Semaphore:
- Mutex 是锁(谁持有谁释放),Semaphore 是通知(不同线程可 P/V)
- Mutex 的值只能是 0 或 1,Semaphore 可为任意非负整数
典型应用:控制对有限资源的访问(连接池、线程池)。
条件变量(Condition Variable)
条件变量让线程在某个条件不满足时等待,在条件可能满足时被唤醒。
c
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int ready = 0;
// 等待线程
pthread_mutex_lock(&mutex);
while (!ready) {
pthread_cond_wait(&cond, &mutex); // 自动释放 mutex,被唤醒后自动获取
}
// 条件满足,执行操作
pthread_mutex_unlock(&mutex);
// 通知线程
pthread_mutex_lock(&mutex);
ready = 1;
pthread_cond_signal(&cond); // 唤醒一个等待线程
// pthread_cond_broadcast(&cond); // 唤醒所有等待线程
pthread_mutex_unlock(&mutex);为什么用 while 而非 if:虚假唤醒(spurious wakeup)可能发生。
死锁(Deadlock)
死锁的四个必要条件
- 互斥:资源一次只允许一个线程使用
- 持有并等待:线程持有至少一个资源,同时等待其他资源
- 不可剥夺:资源只能由持有者主动释放
- 循环等待:存在线程循环等待链(T1 等 T2 的资源,T2 等 T1 的资源)
预防
- 破坏"持有并等待":一次性申请所有资源
- 破坏"不可剥夺":无法获取时释放已持有的
- 破坏"循环等待":规定资源获取顺序,所有线程按相同顺序申请
Java 中的死锁检测
bash
jstack <PID> # 查看线程堆栈,JVM 会标注死锁JVisualVM 也可以图形化检测死锁。
活锁(Livelock)与饿死(Starvation)
- 活锁:线程不断重试但始终无法成功(如两个线程互相谦让)
- 饿死:低优先级线程一直得不到调度(可用优先级提升解决)
锁的性能对比
| 锁类型 | 开销 | 适用场景 |
|---|---|---|
| 自旋锁 | 极低(无上下文切换) | 临界区极短、多核 |
| Mutex(无竞争) | 低(用户态原子操作) | 通用场景 |
| Mutex(有竞争) | 高(上下文切换) | 临界区较长 |
| 读写锁 | 中 | 读多写少 |
| RCU | 极低 | 读远多于写(Linux 内核) |
RCU(Read-Copy-Update)
Linux 内核中的无锁同步机制:
- 读:不使用锁,直接通过指针访问
- 写:创建新副本,更新指针,等待所有读操作完成后回收旧副本
- 应用:Linux 内核的路由表、dentry 缓存、文件系统
常见面试问题
- 自旋锁和互斥锁的区别?什么场景用自旋锁?
- 死锁的四个条件是什么?如何预防?
- Java
synchronized和ReentrantLock的底层实现分别是什么? - 读写锁适用什么场景?写优先和读优先各有什么问题?
- 信号量和互斥锁的区别?
- 什么是虚假唤醒?为什么
pthread_cond_wait要用while循环?