
自律是普通人最大的武器
| 系列专栏 | 直达链接 |
|---|---|
| 🐧 Linux | 进入专栏 → |
| 🔤 C语言 | 进入专栏 → |
| 🔧 C++ | 进入专栏 → |
| 🐬 MySQL | 进入专栏 → |
| 🧩 算法刷题 | 进入专栏 → |
🏠 博客主页:虚无的纽扣
目录
- 引言
- 1、为什么只有互斥还不够
- 2、条件变量
- [3、wait 为什么要传一把锁](#3、wait 为什么要传一把锁)
- 结语
引言
上一篇的互斥锁解决了"对不对"的问题:临界区同一时刻只放一个线程进来,数据不一致的风险被挡住了。但只有互斥的系统并不完美------它可能很不高效,也很不公平。这一篇我们就来看互斥的另一面:为什么纯互斥会导致线程饥饿,以及操作系统用什么样的规矩让线程按秩序排队,这就是线程同步,而它的核心工具叫条件变量。
1、为什么只有互斥还不够
设想这样一个线程:它高频地申请锁、释放锁,但在持有锁的短暂时间里并没有做什么有效操作,就一直重复申请和释放。结果呢?其他线程完全申请不到锁,这就是线程饥饿问题。这不是夸张。因为 CPU 的执行速度极快,在纯互斥的场景下,很可能很长一段时间内都是同一个线程恰好抢到锁,其他线程完全得不到执行机会。所以纯互斥虽然没错,但是它既不高效,也不公平。于是操作系统的设计师给线程定了两条规矩:一个线程刚释放锁,不能立即申请第二次;申请锁失败的线程,必须按队列排队等待下一次申请,新来的线程必须排到队列的尾部。
在保证资源安全的前提下,让所有执行流按照一定的顺序访问临界资源,这就叫做线程同步。一句话区分这两个概念:互斥解决的是"有没有错"的问题,同步解决的是"不高效、不公平"的问题。
2、条件变量
光让线程排队还不够,还有一类场景需要新工具。当一个线程互斥地访问某个变量时,它可能发现:在其他线程改变状态之前,自己什么也做不了。比如一个线程访问一个容器时发现容器是空的,而它又不是负责往容器里添加数据的线程,那它就只能等------直到其他线程往容器里放了一个节点。这种"等某个条件成立"的场景,就要用到条件变量。
条件变量可以理解成一个铃铛:条件满足时,铃铛被摇响,唤醒正在阻塞的线程依次执行;条件不满足时,线程就进入等待队列挂起,直到条件满足为止。它的创建方式和互斥锁几乎一模一样------可以定义成全局的(用 PTHREAD_COND_INITIALIZER),也可以局部定义用 init 初始化、用 destroy 销毁,特性几乎一样。
c
#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
条件不满足时线程会被挂进这个条件变量的等待队列,所以条件变量还得提供等待的接口:
c
#include <pthread.h>
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex,
const struct timespec *restrict abstime);
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
wait 接口的第一个参数,指在指定的条件变量下等待;第二个参数是一把互斥锁------为什么等待还要传一把锁?这是条件变量最精妙的设计,下面单独用一节讲。timedwait 的参数与 wait 几乎一样,只是多了一个 abstime,等到这个时刻线程会自动醒来,不至于永远睡下去。注意 abstime 是绝对时间(需要用 clock_gettime 取当前时间再叠加等待时长),不是"再睡 N 秒"的相对时间,这是初学者常踩的坑。
除了等待的线程,还有负责改变条件的一方,它们要有能力唤醒正在等待的线程,所以还有两个接口:signal 唤醒至少一个在指定条件变量下等待的线程------在按队列排队的模型里,可以理解为队头的那个;broadcast 则唤醒在指定条件变量下等待的所有线程。摇铃的人和等铃的人,共同构成了一套完整的"条件-等待"机制。
c
#include <pthread.h>
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
3、wait 为什么要传一把锁
接口看完了,回头解决前面埋的问题:wait 的第二个参数为什么是一把互斥锁?什么条件下线程才需要等待?条件不满足的时候。而要知道条件满不满足,就必须对条件进行判断------判断本身就是在访问临界资源,所以判断一定发生在临界区内部,判定结果也一定基于临界资源的状态。推论就是:线程因条件不满足而休眠时,一定是在临界区内休眠的。
麻烦来了。一个线程在临界区内休眠时,它还没走出临界区,锁还攥在手里。如果一个线程带着唯一一把锁去休眠了,其他线程怎么办?它们连临界区都进不去,更不可能去改变条件、摇响铃铛------死锁一般的局面。所以 wait 接口必须传入互斥锁:线程在休眠前,wait 内部会自动把这把锁释放掉,让给其他线程去竞争。
那线程被唤醒之后呢?它醒来时是不是还在临界区里,可以直接访问资源?显然不行------锁早就被别人拿走了。所以它还得重新去竞争锁,竞争失败就在锁上阻塞等待(这和第一次的休眠不同,是又一次排队)。wait 这个接口,其实是在内部帮我们把"解锁、休眠、唤醒、加锁"四个过程封装好了,所以才需要传入一把互斥锁:休眠前用它来放锁,醒来后靠它来收回锁。
结语
互斥保证安全,同步保证秩序,条件变量则把"条件不满足就挂起、条件满足就唤醒"这件事标准化成了 wait 和 signal 两个动作。光有这两件工具还不够:多个线程一读一写,到底该怎么组织?下一篇我们用生产者消费者模型把这一切整合到一起,并给出可以直接跑的完整代码。