📚 本文收录于「流浪」的系列专栏
| 🐧Linux系统 | ⚙️C++ |
| 📊数据结构与算法 | 🐍Python |
| 🔗LangChain & LangGraph | 🗄️MySQL 数据库 |
| 🌿Git 工具 | 🌐计算机网络 |
| 🤖LLM | 💯大厂面试、八股 |
| 📚学习筑基专栏 |
🏠 博客主页:流浪 | 📝 原创首发于 CSDN
前言:线程(九)把互斥锁的原理和申请锁为什么必须原子讲透,锁保证同一时刻只有一个执行流进临界区。可它只回答了能不能进,没回答轮到谁进,反复抢锁不干活的线程能把别人挡在门外。本篇从这个缺口讲起,先说线程饥饿与同步的两条规则,再拆条件变量凭什么让线程睡下去、由谁叫醒。
一、只加一把锁,还是会出现线程饥饿
1.1 反复抢锁的线程
1. 拿到锁才发现没事干
假如有一个线程频繁申请锁却没有做有效的任务,从而导致其他线程无法申请得到锁,那该如何呢?
- 典型循环是加锁、判断条件、条件不成立、解锁、立刻再抢
- 抢到锁不等于有活干,锁只保证进得去,不保证进去有东西
- 这类线程一直在运行态,抢锁这一步不需要经历从阻塞到唤醒的调度延迟
1.2 线程饥饿
1. 定义
- 一个线程极度频繁地申请锁,却没有做有效交互动作
- 导致其他线程始终申请不到锁,这就是线程饥饿
2. 饥饿的代价
- 数据没出错,锁的互斥职责尽到了
- 但 CPU 没闲着:别的线程反复抢锁、反复失败、反复被唤醒
- 缺的不是安全,是秩序
锁解决了能不能进,解决不了轮到谁进。
二、线程同步的两条规则
2.1 规则一,解锁的线程不能立即申请锁
1. 为什么要这条
- 刚解锁就重新申请,等于刚出队又插回队首
- 阻塞中的线程要先被唤醒、再参与竞争,天然慢一步
所以让出一次机会窗口,别的线程才有机会拿到锁
2. 靠什么落实
- 不能靠写代码的人自觉,线程自己并不知道还有谁在等
- 得有一个等待队列:没资格申请的先挂起,被叫到再来
2.2 规则二,出来的线程必须到队尾二次申请
1. 排队
- 阻塞的线程在队列里等着,不参与空转
- 队列给了先来后到的顺序
2. 二次申请
- 从队列里出来不等于拿到锁,还得再申请一次
- 刚放锁又要申请的线程排到队尾,不许插队
2.3 同步与互斥的分工
1. 互斥管安全
- 同一时刻只允许一个执行流进临界区
- 线程(九)讲的互斥锁,管的正是这一层
2. 同步管顺序
- 在保证线程安全的前提下,让所有执行流按一定的顺序访问临界资源
- 顺序不是越快越好,而是别让任何一个线程永远轮不上
互斥是能不能进,同步是轮到谁进;两条规则合起来,才把安全升级成有序。
三、条件变量,给锁配一个铃铛
3.1 故事引入
假想老师发苹果
1. 场上有什么
- 一个老师(一个线程)往箱子里放苹果
- 一群排队的同学(其余线程)从箱子里取苹果
- 一个不透明的箱子,箱子上着锁(模拟互斥锁)
2. 双方都蒙着眼睛
- 老师放完苹果就蒙上眼,同学排队时也蒙着眼
- 在开箱之前谁都不知道箱子里有没有苹果
3.2 蒙眼带来的困境
1. 只能盲目开箱
- 同学不知道有没有苹果,只能先抢锁、开箱、空的、放锁、再来
- 这正是 1.1 那种极度频繁申请锁的来源
- 抢锁的开销一分没省,活一件没干
2. 换一句正式的说法
- 一个线程互斥地访问某个变量时
- 它可能发现在其他线程改变状态之前,它什么也做不了
- 什么也做不了却还要反复抢锁,就是纯浪费
3.3 铃铛和等待队列
1. 约定敲铃
- 老师每次放完苹果,敲一下铃
- 同学听到铃响才去开箱取苹果,取完到队尾重新排队
2. 回归本质——两样东西合起来才是条件变量
- 铃铛负责通知:条件可能成立了,你们可以动了
- 等待队列负责排队:没被叫到的先挂着,别去抢锁
- 只有铃铛没有队列,所有人一起涌上来抢;只有队列没有铃铛,没人知道什么时候该动
四、条件变量的接口
4.1 定义与初始化
1. 全局用宏静态初始化
pthread_cond_tcond=PTHREAD_COND_INITIALIZER;// 定义即初始化,不做错误检查- 和互斥锁的
PTHREAD_MUTEX_INITIALIZER是一个套路 - 随程序的生命周期走,不用手动销毁
2. 局部用 init,用完 destroy
pthread_cond_tcond;// 局部变量,只有空间pthread_cond_init(&cond,NULL);// 动态初始化,attr 传 NULL 用默认属性/* ... 使用 ... */pthread_cond_destroy(&cond);// 用完了销毁- 落在栈上或堆上的条件变量必须显式 init
- 销毁之前确保没有线程还挂在它上面等待
4.2 pthread_cond_wait 的两个参数
1. 第一个参数,条件变量
- 在指定的条件变量下等待,也就是挂到它的等待队列上
- 谁在这个条件变量上敲铃,谁就能叫醒它
2. 第二个参数,互斥锁
- 等待时自动把锁释放掉,别人才能进临界区改条件
- 手册口径:这个函数原子地释放 mutex,并让调用线程阻塞在条件变量 cond 上
- 原子在这里指的是:相对于另一个线程先拿到 mutex、再访问条件变量这两步而言不可被打断
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;pthread_cond_t cond=PTHREAD_COND_INITIALIZER;intticket=100;#defineNUM5void*routine(void*mes){std::string name=(char*)mes;while(true){if(ticket>0){pthread_mutex_lock(&mutex);pthread_cond_wait(&cond,&mutex);std::cout<<name<<" "<<ticket<<std::endl;ticket++;pthread_mutex_unlock(&mutex);}else{pthread_mutex_unlock(&mutex);break;}}returnnullptr;}4.3 等待时不释放锁会怎样
1. 死锁推演
- 线程拿着锁睡进了条件变量的等待队列
- 别的线程连临界区都进不去,既改不了条件,也敲不了铃
- 等待方永远等不到铃,其他人永远拿不到锁,死锁
2. 拆成两步也不行
- 若先 unlock 再 wait,中间就空出一个窗口
- 窗口里别的线程改好了条件、敲了铃,而这一铃打在还没睡下去的线程身上,直接丢失
- 等它真正睡下去时铃早就响完了,于是永远等下去
- 所以释放锁和进入等待必须是原子的,这也是它必须收一把锁的根本原因
4.4 唤醒,signal 与 broadcast
1. 两个接口
pthread_cond_signal:唤醒等待队列里的一个线程pthread_cond_broadcast:唤醒等待队列里的所有线程- 都写在临界区里,改完条件再敲铃
1.pthread_cond_signal——唤醒等待队列里的一个线程
while(true){std::cout<<"唤醒一个线程"<<std::endl;pthread_cond_signal(&cond);std::cout<<"执行下一块"<<std::endl;sleep(1);}2.pthread_cond_broadcast——唤醒等待队列里的所有线程
while(true){std::cout<<"唤醒所有进程"<<std::endl;pthread_cond_broadcast(&cond);std::cout<<"执行下一块"<<std::endl;sleep(1);}2. 唤醒不等于放行
- 唤醒只说明你可以去抢锁了
- 通知方改完条件、敲完铃就完事
- 被叫醒的线程能不能跑,取决于锁
- 抢不到锁照样等着,这就是下一段要讲的第二段阻塞
4.完整demo
#include<iostream>#include<pthread.h>#include<unistd.h>pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;pthread_cond_t cond=PTHREAD_COND_INITIALIZER;intticket=100;#defineNUM5void*routine(void*mes){std::string name=(char*)mes;while(true){if(ticket>0){pthread_mutex_lock(&mutex);pthread_cond_wait(&cond,&mutex);std::cout<<name<<" "<<ticket<<std::endl;ticket++;pthread_mutex_unlock(&mutex);}else{pthread_mutex_unlock(&mutex);break;}}returnnullptr;}intmain(){pthread_t arr[NUM];for(inti=0;i<NUM;i++){charbuf[64];snprintf(buf,sizeof(buf),"thread-%d",i);intn=pthread_create(&arr[i],nullptr,routine,(void*)buf);if(n!=0)continue;}std::cout<<"线程已经创建完毕,请稍等"<<std::endl;while(true){std::cout<<"唤醒一个线程"<<std::endl;pthread_cond_signal(&cond);// std::cout<<"唤醒所有进程"<<std::endl;// pthread_cond_broadcast(&cond);std::cout<<"执行下一块"<<std::endl;sleep(1);}for(inti=0;i<NUM;i++){pthread_join(arr[i],nullptr);}return0;}五、pthread_cond_wait 的两段阻塞
5.1 第一段,等条件
1. 发生在哪
- 线程刚进入
pthread_cond_wait,主动把 mutex 释放 - 挂到条件变量的等待队列上
2. 等的是什么
- 这不是普通的锁阻塞
- 它靠 signal / broadcast 的信号唤醒,不靠别人 unlock mutex
- 别人把锁放了也没用,铃不响它不动
5.2 第二段,等锁
1. 发生在哪
- 收到 signal 后从条件变量队列出来,尝试重新抢锁
- 锁被别人占着拿不到,就挂到 mutex 的锁等待队列上
2. 等的是什么
- 这里的阻塞等同于
pthread_mutex_lock的普通阻塞 - 只等其他线程
pthread_mutex_unlock释放锁,不再理会 cond 上的 signal - 铃响只能把你从第一段送出来,送不出第二段
5.3 两段对照
| 第一段(条件等待) | 第二段(锁等待) | |
|---|---|---|
| 挂在哪个队列 | 条件变量等待队列 | 互斥锁等待队列 |
| 等什么 | signal / broadcast | pthread_mutex_unlock |
| 谁叫醒它 | 敲铃的线程 | 放锁的线程 |
| 醒来之后 | 还要去抢锁 | 拿到锁,接着往下跑 |
- 一次
pthread_cond_wait里两段阻塞是串着发生的 - 先等条件(要信号)、再等锁(要解锁),顺序不能倒
5.4 被唤醒的位置在临界区内
1. 原地醒来
- 线程停在
pthread_cond_wait这一行,这行本身就在加锁和解锁之间 - 所以它是被在临界区内唤醒的,醒来就要接着操作共享资源
2. 想继续执行必须先重新持有锁
- 手册口径:成功返回时,mutex 已被锁定且归调用线程所有
- 拿不到锁函数就返回不了,卡在返回之前,这段卡住正是第二段阻塞
- 所以从
pthread_cond_wait后面接着跑的代码,始终持有锁
5.5 拿到锁之后还要再判一次条件
1. 唤醒不等于条件成立
- 伪唤醒:没有任何线程敲铃,等待的线程也可能被唤醒,这是标准允许的
- broadcast 一次叫醒所有人,先抢到锁的那个可能已经把条件消费掉了
- 轮到你时,条件未必还是成立的那一个
2. 写法
pthread_mutex_lock(&mutex);while(条件不成立)// 用 while,不用 ifpthread_cond_wait(&cond,&mutex);/* 条件成立,干活 */pthread_mutex_unlock(&mutex);- 条件不成立就再 wait 一次,等于回到第一段阻塞
- 手册的建议也是把条件等待包在等价的 while 循环里
5.6 一次完整等待的六步
- 加锁,进临界区
- 判断条件,不成立才wait
- 调
pthread_cond_wait,释放锁、挂到条件变量队列(第一段阻塞) - 被 signal 叫醒,出队、重新申请锁(第二段阻塞)
- 拿到锁、返回,再判一次条件
- 条件成立就干活,干完解锁
六、signal 换成 broadcast 会发生什么
6.1 唤醒范围
1. signal
- 唤醒等待队列里的一个线程
- 一个苹果只需要一个人来取时用它
2. broadcast
- 唤醒等待队列里的所有线程
- 状态整体变化、所有人都该重新看一眼时用它
前面已经演示过了
6.2 惊群
1. 现象
- 全部被叫醒,一起去抢那把锁
- 只有一个抢到、条件成立、把活干了
- 其余的抢到锁后一判条件,发现又空了,只能回到等待队列
2. 代价
- 多出来的唤醒和抢锁都是白跑的上下文切换
- 唤醒的人越多浪费越大,这就是惊群效应
6.3 怎么用才对
- 一个资源只需要一个人处理,用 signal
- 所有等待者的条件同时成立、或不知道该叫谁,用 broadcast
- 不管用哪个,等待方都得用 while 再判一次条件
七、全篇总结
1. 饥饿从哪来
- 锁只保证进得去,不保证轮得上
- 反复抢锁却不干活的线程,会把其他人一直挡在门外
2. 同步的两条规则
- 解锁的线程不能立即申请锁
- 从队列里出来的线程必须到队尾二次申请
3. 条件变量是什么
- 一个通知机制(铃铛)加一个等待队列
- 让线程在条件不成立时睡下去,而不是空转抢锁
4. wait 的两个参数
- 条件变量决定挂在哪个队列、等谁的铃
- 互斥锁在等待时自动释放,否则别人改不了条件也敲不了铃
5. 两段阻塞
- 第一段挂在条件变量队列,等 signal 或 broadcast
- 第二段挂在锁等待队列,等 unlock,与普通加锁阻塞没有区别
6. 唤醒的两件事
- 醒来就在临界区内,返回前必须重新持有锁
- 拿到锁还要用 while 再判一次条件,不成立就回去接着等
八、文末面试题
8.1 推导题
1. 已经有互斥锁了,为什么还会出现线程饥饿?
答(推导):因为锁只提供互斥,不提供顺序。线程拿到锁之后才发现条件不成立,只能释放锁重来,这个线程一直处在运行态、不需要经历唤醒和调度的延迟,而手册只规定解锁后由调度策略决定下一个归属、并不要求按申请的先后授予,于是它可以连着抢到锁,阻塞在锁上的线程反复被唤醒又反复落空。数据没出错,但有的线程永远轮不上,这就是饥饿。
2. 线程同步的两条规则各解决什么问题?
答(推导):规则一解决插队,刚解锁就重新申请等于刚出队又回到队首,让出一次窗口别人才能拿到锁;规则二解决顺序,从等待队列里出来不等于拿到锁,必须重新申请,且刚放锁又要申请的线程要排到队尾。两条合起来的效果是在互斥安全的前提下,让所有执行流按一定顺序访问临界资源,把能不能进升级成轮到谁进。
3. pthread_cond_wait 为什么必须传一把互斥锁?
答(推导):两个原因。一是条件本身写在共享变量里,读它改它都得在锁的保护下做;二是线程睡下去之前必须先把锁放掉,否则它拿着锁睡,别人既进不了临界区改条件、也发不出唤醒信号,等待方和其他人都永远卡死。这把锁就是让"释放锁"和"进入等待"这两件事绑在一起的那个纽带。
4. 释放锁和进入等待为什么必须原子,拆成两步会出什么事?
答(推导):拆成先 unlock 再 wait,中间就空出一个窗口:窗口里别的线程完全可以拿到锁、改好条件、敲完铃,而这时的等待线程还没睡下去,这一铃就打空了;等它真正睡下去,铃早就响完,于是它永远等一个已经发生过的信号。手册把这两步定义成原子的,正是要关掉这个窗口,也是它强制要求调用时已经持有锁的原因。
5. pthread_cond_wait 的两段阻塞分别挂在哪个队列、等什么?
答(推导):第一段挂在条件变量的等待队列上,等的是 signal 或 broadcast 的信号,别人放锁叫不醒它;收到信号出队后进入第二段,挂在互斥锁的等待队列上,等的是别的线程 unlock,这时的阻塞和普通的 pthread_mutex_lock 阻塞没有区别,不再理会条件变量上的信号。顺序是先等条件、再等锁,铃只能把你从第一段送出来。
6. 线程被唤醒时是在临界区内醒来,这句话该怎么理解?
答(推导):线程停在 pthread_cond_wait 这一行,这一行本身就写在加锁和解锁之间,所以代码位置天然在临界区里;但醒来不等于能往下跑,手册要求成功返回时 mutex 已被锁定且归调用线程所有,拿不到锁就卡在返回之前,也就是第二段阻塞。因此 wait 之后的代码始终持锁,这也是它返回后能直接操作共享资源、以及必须再判一次条件的原因。
8.2 真题
1. 什么是条件变量,为什么必须配合互斥锁使用?
【真题·转述自 CSDN《面试问题总结 — Linux篇》
答(推导 · 已对照面经,转述):条件变量用于多线程里等待某个条件达成,必须配合互斥锁使用:条件不满足时,pthread_cond_wait 会释放锁并阻塞休眠;其他线程修改条件后用 signal 唤醒它,被唤醒的线程要重新获取锁、再次检查条件,从而避免循环轮询白白占用 CPU。配合互斥锁的原因在于条件本身是共享数据,判断和修改都得在锁的保护下完成,而且等待时若不释放锁,别人根本进不来改条件,也就永远发不出唤醒。
2. pthread_cond_wait 返回之后还要做什么,signal 和 broadcast 怎么选?
【真题·转述自 CSDN《Linux线程同步与互斥(二):条件变量与生产者消费者模型》
答(推导 · 已对照公开考点,转述):返回之后要做两件事,
- 一是重新持有锁,这一点由函数保证,返回时锁已经在你手上,所以 wait 后面的代码可以直接安全访问共享资源;
- 二是必须在 while 循环里重新检查条件,被唤醒并不等于条件成立,虚假唤醒和别人抢先消费都会让条件再次变假,用 if 单次判断会直接执行到空队列读取上去。signal 与 broadcast 的选择看事件性质:一个资源只需要一个人处理用 signal,需要所有等待者都响应或不确定该叫谁用 broadcast,代价是惊群,被叫醒的线程一窝蜂抢同一把锁,多数抢到后发现条件又假、白跑一趟再睡回去。
💬结语:条件变量的全部机关就一句话,铃响只负责把你从条件队列里放出来,能不能接着往下跑还得看锁在谁手里。把这两段阻塞分清,再看 wait 就不是一次等待,而是两次。评论区聊聊你第一次用 if 判条件踩的坑。如果这篇对你有帮助,点个赞再走,关注流浪,Linux 系统篇持续更新。