前言:
承接上一篇线程基础与创建回收,多线程共享进程地址空间的特性带来了极低的通信成本,但也引发了并发访问共享资源的数据竞争问题。本篇讲解线程同步的三大核心工具:互斥锁、条件变量与读写锁,深度拆解死锁的成因与规避方案,掌握这些才能写出正确、稳定、高效的多线程程序,也是笔试面试中并发模块的核心必考内容。
一、线程同步基础概念
1. 什么是数据竞争
当多个线程同时对同一个共享资源进行读写操作时,最终结果依赖于线程的调度执行顺序,出现不可预期的错误,这就是数据竞争(竞态条件)。
典型场景:两个线程同时对一个全局变量执行自增操作。自增在底层分为三步:读取变量值、计算加一、写回内存。两个线程交替执行时,可能出现读取到同一个旧值,最终只加了一次的错误结果。
2. 临界区与同步
- 临界区:访问共享资源的代码片段,同一时间只能有一个线程进入执行
- 线程同步:通过特定机制控制多个线程的执行顺序,保证临界区的并发安全,避免数据竞争
线程同步的核心思想不是让线程完全串行,而是只在访问共享资源的临界区进行互斥保护,兼顾安全性与执行效率。
二、互斥锁(Mutex)
互斥锁是最基础、最常用的线程同步工具,本质是一个二元状态变量(锁定 / 解锁),保证同一时间只有一个线程进入临界区。
1. 核心操作函数
#include <pthread.h>
// 初始化互斥锁
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
// 加锁(阻塞)
int pthread_mutex_lock(pthread_mutex_t *mutex);
// 尝试加锁(非阻塞)
int pthread_mutex_trylock(pthread_mutex_t *mutex);
// 解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
// 销毁互斥锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
pthread_mutex_lock:锁已被占用时,线程阻塞等待,直到锁被释放pthread_mutex_trylock:锁已被占用时立刻返回错误,不阻塞,适用于不能等待的场景- 所有函数成功返回 0,失败返回错误码
2. 基础使用模板
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);
// 进入临界区前加锁
pthread_mutex_lock(&mutex);
// 临界区:访问共享资源
shared_data++;
// 离开临界区解锁
pthread_mutex_unlock(&mutex);
pthread_mutex_destroy(&mutex);
3. 实战:解决多线程计数问题
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#define COUNT 10000
int g_count = 0;
pthread_mutex_t g_mutex;
void *task(void *arg) {
for (int i = 0; i < COUNT; i++) {
pthread_mutex_lock(&g_mutex);
g_count++;
pthread_mutex_unlock(&g_mutex);
}
pthread_exit(NULL);
}
int main(void) {
pthread_t tid1, tid2;
pthread_mutex_init(&g_mutex, NULL);
pthread_create(&tid1, NULL, task, NULL);
pthread_create(&tid2, NULL, task, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
printf("最终计数:%d,预期:%d\n", g_count, COUNT * 2);
pthread_mutex_destroy(&g_mutex);
return 0;
}
不加互斥锁时最终结果大概率小于 20000,加锁后结果始终正确。
4. 互斥锁的本质特性
- 原子性:加锁操作是原子的,不会出现两个线程同时加锁成功的情况
- 互斥性:同一时间最多一个线程持有锁,保证临界区串行执行
- 阻塞性:拿不到锁时线程进入阻塞睡眠,不占用 CPU 资源
三、死锁:成因与规避
死锁是多线程编程中最经典的问题,指多个线程互相持有对方需要的锁,同时等待对方释放,导致所有线程永久阻塞。
1. 死锁产生的四个必要条件
- 互斥条件:资源同一时间只能被一个线程持有
- 持有并等待:线程已经持有至少一个资源,同时等待其他线程持有的资源
- 不可剥夺:已持有的锁不能被其他线程强行夺走,只能主动释放
- 循环等待:线程之间形成首尾相接的循环等待资源关系
四个条件同时满足才会产生死锁,破坏任意一个即可避免死锁。
2. 典型死锁场景
两个线程,两把锁,加锁顺序相反:
// 线程A:先锁1,再锁2
pthread_mutex_lock(&lock1);
pthread_mutex_lock(&lock2);
// 业务逻辑
pthread_mutex_unlock(&lock2);
pthread_mutex_unlock(&lock1);
// 线程B:先锁2,再锁1
pthread_mutex_lock(&lock2);
pthread_mutex_lock(&lock1);
// 业务逻辑
pthread_mutex_unlock(&lock1);
pthread_mutex_unlock(&lock2);
当线程 A 拿到 lock1、线程 B 拿到 lock2 时,双方都会阻塞等待对方的锁,永久死锁。
3. 死锁规避方案
- 顺序加锁(最常用):所有线程严格按照相同的顺序加锁,破坏循环等待条件。比如永远先加编号小的锁,再加编号大的锁。
- 尝试加锁 + 回退 :使用
trylock尝试加第二把锁,失败就释放已持有的锁,重试或执行其他逻辑,破坏持有并等待条件。 - 减少锁的数量:尽量用一把锁保护多个资源,避免多锁嵌套,从根源上消除循环等待的可能。
- 设置超时时间:使用带超时的加锁函数,超时后自动释放资源,避免永久等待。
四、条件变量(Condition Variable)
互斥锁解决了临界区互斥访问的问题,但无法实现线程间的等待与通知。条件变量用于实现线程间的同步等待:当条件不满足时,线程阻塞睡眠;当条件满足时,其他线程唤醒等待的线程。
条件变量必须和互斥锁配合使用,由互斥锁保护条件的并发安全。
1. 核心操作函数
#include <pthread.h>
// 初始化条件变量
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
// 等待条件满足(阻塞)
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
// 唤醒一个等待的线程
int pthread_cond_signal(pthread_cond_t *cond);
// 唤醒所有等待的线程
int pthread_cond_broadcast(pthread_cond_t *cond);
// 销毁条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
2. 为什么必须配合互斥锁
pthread_cond_wait内部执行三步原子操作:
- 释放持有的互斥锁
- 阻塞等待条件变量被唤醒
- 被唤醒后,重新获取互斥锁再返回
互斥锁的作用是保护 "条件判断 + 等待" 这个过程的原子性,防止条件在判断和等待之间发生变化,出现唤醒丢失的问题。
3. 标准使用范式
等待线程
pthread_mutex_lock(&mutex);
while (条件不满足) { // 必须用while循环,不能用if
pthread_cond_wait(&cond, &mutex);
}
// 条件满足,处理业务
pthread_mutex_unlock(&mutex);
为什么必须用 while 而不是 if:因为可能出现虚假唤醒,线程被唤醒但条件其实还不满足,需要再次检查条件。这是 POSIX 标准的规范写法。
唤醒线程
pthread_mutex_lock(&mutex);
// 修改共享条件
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond); // 解锁后再发信号,减少无意义的锁竞争
4. 实战:生产者消费者模型
生产者消费者是条件变量最经典的应用场景:生产者生产数据放入队列,消费者从队列取出数据,队列为空时消费者等待,队列满时生产者等待。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
#define MAX_QUEUE 5
int g_queue[MAX_QUEUE];
int g_count = 0;
pthread_mutex_t g_mutex;
pthread_cond_t g_not_empty; // 队列非空条件
pthread_cond_t g_not_full; // 队列非满条件
// 生产者线程
void *producer(void *arg) {
int id = *(int *)arg;
while (1) {
pthread_mutex_lock(&g_mutex);
// 队列满了,等待非满条件
while (g_count == MAX_QUEUE) {
pthread_cond_wait(&g_not_full, &g_mutex);
}
// 生产一个数据
g_queue[g_count++] = rand() % 100;
printf("生产者%d生产,当前数量:%d\n", id, g_count);
pthread_mutex_unlock(&g_mutex);
pthread_cond_signal(&g_not_empty); // 通知消费者队列有数据了
sleep(1);
}
pthread_exit(NULL);
}
// 消费者线程
void *consumer(void *arg) {
int id = *(int *)arg;
while (1) {
pthread_mutex_lock(&g_mutex);
// 队列为空,等待非空条件
while (g_count == 0) {
pthread_cond_wait(&g_not_empty, &g_mutex);
}
// 消费一个数据
int data = g_queue[--g_count];
printf("消费者%d消费:%d,剩余:%d\n", id, data, g_count);
pthread_mutex_unlock(&g_mutex);
pthread_cond_signal(&g_not_full); // 通知生产者队列有空位了
sleep(2);
}
pthread_exit(NULL);
}
int main(void) {
pthread_t p1, p2, c1, c2;
int id1 = 1, id2 = 2;
pthread_mutex_init(&g_mutex, NULL);
pthread_cond_init(&g_not_empty, NULL);
pthread_cond_init(&g_not_full, NULL);
pthread_create(&p1, NULL, producer, &id1);
pthread_create(&p2, NULL, producer, &id2);
pthread_create(&c1, NULL, consumer, &id1);
pthread_create(&c2, NULL, consumer, &id2);
pthread_join(p1, NULL);
pthread_join(p2, NULL);
pthread_join(c1, NULL);
pthread_join(c2, NULL);
pthread_mutex_destroy(&g_mutex);
pthread_cond_destroy(&g_not_empty);
pthread_cond_destroy(&g_not_full);
return 0;
}
五、读写锁(Read-Write Lock)
互斥锁不管读还是写都会加锁,完全串行。在多读少写的场景下,读取操作本身不会修改数据,并发读取是安全的,互斥锁会浪费大量性能。读写锁就是为读多写少场景优化的同步工具。
1. 核心规则
- 读共享:多个线程可以同时持有读锁,并发读取数据
- 写互斥:写锁是独占的,同一时间只能有一个写线程,且写的时候不能有读线程
- 写优先:当有写线程等待时,新来的读线程会被阻塞,避免写操作长期饥饿
2. 核心操作函数
#include <pthread.h>
// 初始化读写锁
int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr);
// 加读锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
// 加写锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
// 解锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
// 销毁读写锁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
3. 适用场景与对比
| 同步工具 | 适用场景 | 并发度 | 性能 |
|---|---|---|---|
| 互斥锁 | 读写都频繁、操作简单 | 最低,完全串行 | 开销小,通用场景 |
| 读写锁 | 读多写少,读取远多于写入 | 中等,读并发写互斥 | 读场景性能高,写场景开销略大 |
注意:写操作很少时读写锁优势明显;如果读写频率接近,读写锁的额外开销可能反而不如普通互斥锁。
六、三种同步工具对比与选型
| 工具 | 核心作用 | 典型场景 | 注意事项 |
|---|---|---|---|
| 互斥锁 | 临界区互斥访问 | 通用共享资源保护 | 避免嵌套加锁导致死锁 |
| 条件变量 | 线程间等待与通知 | 生产者消费者、任务队列 | 必须配合互斥锁,等待用 while 循环 |
| 读写锁 | 读多写少场景优化 | 配置读取、缓存查询 | 写频繁时性能不如互斥锁 |
七、面试高频考点与易错坑点
1. 经典面试问答
Q1:什么是死锁?产生的四个必要条件是什么?怎么避免?
答:
- 死锁是多个线程互相持有对方需要的资源,同时等待对方释放,导致所有线程永久阻塞的状态。
- 四个必要条件:互斥条件、持有并等待、不可剥夺、循环等待。
- 避免方法:破坏任意一个条件即可,最常用的是统一加锁顺序破坏循环等待,或者减少锁的数量避免多锁嵌套。
Q2:条件变量为什么必须和互斥锁配合使用?
答: 因为条件判断和等待操作必须是原子的。如果没有互斥锁保护,线程判断条件不满足后、进入等待前,其他线程可能修改了条件并发出信号,这个信号就会丢失,导致线程永久等待。 pthread_cond_wait 内部会原子地释放锁并进入等待,被唤醒后重新加锁,保证整个过程的安全性。
Q3:条件变量等待为什么要用 while 循环而不是 if?
答: 两个原因:
- 一是可能出现虚假唤醒,线程被唤醒但条件其实还不满足;
- 二是可能有多个线程被同时唤醒,第一个线程处理完后条件又不满足了。 用 while 循环可以在唤醒后再次检查条件,不满足就继续等待,保证逻辑正确性,这是 POSIX 标准的规范写法。
Q4:读写锁有什么特点?什么场景下使用?
答:
- 读写锁的特点是读共享、写互斥:多个读线程可以同时持有读锁,写锁是独占的,写的时候不能有读。
- 适用于读多写少的场景,比如配置信息、缓存数据的读取,能大幅提升并发度;如果写操作频繁,读写锁开销反而更大,适合用普通互斥锁。
Q5:互斥锁和信号量有什么区别?
答:
- 作用范围:互斥锁用于线程间互斥;信号量可以用于进程间,也可以用于线程间。
- 功能:互斥锁只能实现互斥;信号量既可以实现互斥(二元信号量),也可以实现同步,还能控制并发数量。
- 加解锁主体:互斥锁必须由加锁的线程解锁;信号量可以由其他线程释放。
2. 常见易错坑点
- 加锁后忘记解锁,尤其是异常分支提前 return 时,导致其他线程永久阻塞
- 多把锁加锁顺序不一致,导致死锁
- 条件变量等待用 if 而不是 while,出现虚假唤醒导致逻辑错误
- 条件变量使用时忘记配合互斥锁,出现唤醒丢失和数据竞争
- 写频繁的场景盲目使用读写锁,性能反而不如普通互斥锁
- 解锁已经销毁的锁,或者重复销毁锁,导致未定义行为
- 临界区范围过大,把不需要保护的耗时操作也放进锁里,降低并发性能
以上就是线程同步三大核心工具的全部内容,掌握互斥、同步、死锁这三大要点,就能应对绝大多数多线程开发场景。下一篇我们将进入网络编程模块,讲解 TCP/IP 基础与 Socket 编程入门,开启网络并发编程的学习。
制作不易,如果对你有用,希望能点赞收藏支持一下。