☰
Linux系统篇45——线程(十) 线程同步与条件变量,pthread_cond_wait 的两段阻塞
2026/10/9 5:34:28 网站建设 项目流程


📚 本文收录于「流浪」的系列专栏

🐧Linux系统⚙️C++
📊数据结构与算法🐍Python
🔗LangChain & LangGraph🗄️MySQL 数据库
🌿Git 工具🌐计算机网络
🤖LLM💯大厂面试、八股
📚学习筑基专栏

🏠 博客主页:流浪 | 📝 原创首发于 CSDN


前言:线程(九)把互斥锁的原理和申请锁为什么必须原子讲透,锁保证同一时刻只有一个执行流进临界区。可它只回答了能不能进,没回答轮到谁进,反复抢锁不干活的线程能把别人挡在门外。本篇从这个缺口讲起,先说线程饥饿与同步的两条规则,再拆条件变量凭什么让线程睡下去、由谁叫醒。


一、只加一把锁,还是会出现线程饥饿

1.1 反复抢锁的线程

1. 拿到锁才发现没事干

假如有一个线程频繁申请锁却没有做有效的任务,从而导致其他线程无法申请得到锁,那该如何呢?

  1. 典型循环是加锁、判断条件、条件不成立、解锁、立刻再抢
  2. 抢到锁不等于有活干,锁只保证进得去,不保证进去有东西
  3. 这类线程一直在运行态,抢锁这一步不需要经历从阻塞到唤醒的调度延迟

1.2 线程饥饿

1. 定义
  1. 一个线程极度频繁地申请锁,却没有做有效交互动作
  2. 导致其他线程始终申请不到锁,这就是线程饥饿
2. 饥饿的代价
  1. 数据没出错,锁的互斥职责尽到了
  2. 但 CPU 没闲着:别的线程反复抢锁、反复失败、反复被唤醒
  3. 缺的不是安全,是秩序

锁解决了能不能进,解决不了轮到谁进。


二、线程同步的两条规则

2.1 规则一,解锁的线程不能立即申请锁

1. 为什么要这条
  1. 刚解锁就重新申请,等于刚出队又插回队首
  2. 阻塞中的线程要先被唤醒、再参与竞争,天然慢一步

所以让出一次机会窗口,别的线程才有机会拿到锁

2. 靠什么落实
  1. 不能靠写代码的人自觉,线程自己并不知道还有谁在等
  2. 得有一个等待队列:没资格申请的先挂起,被叫到再来

2.2 规则二,出来的线程必须到队尾二次申请

1. 排队
  1. 阻塞的线程在队列里等着,不参与空转
  2. 队列给了先来后到的顺序
2. 二次申请
  1. 从队列里出来不等于拿到锁,还得再申请一次
  2. 刚放锁又要申请的线程排到队尾,不许插队

2.3 同步与互斥的分工

1. 互斥管安全
  1. 同一时刻只允许一个执行流进临界区
  2. 线程(九)讲的互斥锁,管的正是这一层
2. 同步管顺序
  1. 在保证线程安全的前提下,让所有执行流按一定的顺序访问临界资源
  2. 顺序不是越快越好,而是别让任何一个线程永远轮不上

互斥是能不能进,同步是轮到谁进;两条规则合起来,才把安全升级成有序。


三、条件变量,给锁配一个铃铛

3.1 故事引入

假想老师发苹果

1. 场上有什么
  1. 一个老师(一个线程)往箱子里放苹果
  2. 一群排队的同学(其余线程)从箱子里取苹果
  3. 一个不透明的箱子,箱子上着锁(模拟互斥锁)
2. 双方都蒙着眼睛
  1. 老师放完苹果就蒙上眼,同学排队时也蒙着眼
  2. 在开箱之前谁都不知道箱子里有没有苹果

3.2 蒙眼带来的困境

1. 只能盲目开箱
  1. 同学不知道有没有苹果,只能先抢锁、开箱、空的、放锁、再来
  2. 这正是 1.1 那种极度频繁申请锁的来源
  3. 抢锁的开销一分没省,活一件没干
2. 换一句正式的说法
  1. 一个线程互斥地访问某个变量时
  2. 它可能发现在其他线程改变状态之前,它什么也做不了
  3. 什么也做不了却还要反复抢锁,就是纯浪费

3.3 铃铛和等待队列

1. 约定敲铃
  1. 老师每次放完苹果,敲一下铃
  2. 同学听到铃响才去开箱取苹果,取完到队尾重新排队
2. 回归本质——两样东西合起来才是条件变量
  1. 铃铛负责通知:条件可能成立了,你们可以动了
  2. 等待队列负责排队:没被叫到的先挂着,别去抢锁
  3. 只有铃铛没有队列,所有人一起涌上来抢;只有队列没有铃铛,没人知道什么时候该动

四、条件变量的接口

4.1 定义与初始化

1. 全局用宏静态初始化
pthread_cond_tcond=PTHREAD_COND_INITIALIZER;// 定义即初始化,不做错误检查
  1. 和互斥锁的PTHREAD_MUTEX_INITIALIZER是一个套路
  2. 随程序的生命周期走,不用手动销毁
2. 局部用 init,用完 destroy
pthread_cond_tcond;// 局部变量,只有空间pthread_cond_init(&cond,NULL);// 动态初始化,attr 传 NULL 用默认属性/* ... 使用 ... */pthread_cond_destroy(&cond);// 用完了销毁
  1. 落在栈上或堆上的条件变量必须显式 init
  2. 销毁之前确保没有线程还挂在它上面等待

4.2 pthread_cond_wait 的两个参数

1. 第一个参数,条件变量
  1. 在指定的条件变量下等待,也就是挂到它的等待队列上
  2. 谁在这个条件变量上敲铃,谁就能叫醒它
2. 第二个参数,互斥锁
  1. 等待时自动把锁释放掉,别人才能进临界区改条件
  2. 手册口径:这个函数原子地释放 mutex,并让调用线程阻塞在条件变量 cond 上
  3. 原子在这里指的是:相对于另一个线程先拿到 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. 死锁推演
  1. 线程拿着锁睡进了条件变量的等待队列
  2. 别的线程连临界区都进不去,既改不了条件,也敲不了铃
  3. 等待方永远等不到铃,其他人永远拿不到锁,死锁
2. 拆成两步也不行
  1. 若先 unlock 再 wait,中间就空出一个窗口
  2. 窗口里别的线程改好了条件、敲了铃,而这一铃打在还没睡下去的线程身上,直接丢失
  3. 等它真正睡下去时铃早就响完了,于是永远等下去
  4. 所以释放锁和进入等待必须是原子的,这也是它必须收一把锁的根本原因

4.4 唤醒,signal 与 broadcast

1. 两个接口
  1. pthread_cond_signal:唤醒等待队列里的一个线程
  2. pthread_cond_broadcast:唤醒等待队列里的所有线程
  3. 都写在临界区里,改完条件再敲铃
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. 唤醒不等于放行
  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. 发生在哪
  1. 线程刚进入pthread_cond_wait,主动把 mutex 释放
  2. 挂到条件变量的等待队列上
2. 等的是什么
  1. 这不是普通的锁阻塞
  2. 它靠 signal / broadcast 的信号唤醒,不靠别人 unlock mutex
  3. 别人把锁放了也没用,铃不响它不动

5.2 第二段,等锁

1. 发生在哪
  1. 收到 signal 后从条件变量队列出来,尝试重新抢锁
  2. 锁被别人占着拿不到,就挂到 mutex 的锁等待队列上
2. 等的是什么
  1. 这里的阻塞等同于pthread_mutex_lock的普通阻塞
  2. 只等其他线程pthread_mutex_unlock释放锁,不再理会 cond 上的 signal
  3. 铃响只能把你从第一段送出来,送不出第二段

5.3 两段对照

第一段(条件等待)第二段(锁等待)
挂在哪个队列条件变量等待队列互斥锁等待队列
等什么signal / broadcastpthread_mutex_unlock
谁叫醒它敲铃的线程放锁的线程
醒来之后还要去抢锁拿到锁,接着往下跑
  1. 一次pthread_cond_wait里两段阻塞是串着发生的
  2. 先等条件(要信号)、再等锁(要解锁),顺序不能倒

5.4 被唤醒的位置在临界区内

1. 原地醒来
  1. 线程停在pthread_cond_wait这一行,这行本身就在加锁和解锁之间
  2. 所以它是被在临界区内唤醒的,醒来就要接着操作共享资源
2. 想继续执行必须先重新持有锁
  1. 手册口径:成功返回时,mutex 已被锁定且归调用线程所有
  2. 拿不到锁函数就返回不了,卡在返回之前,这段卡住正是第二段阻塞
  3. 所以从pthread_cond_wait后面接着跑的代码,始终持有锁

5.5 拿到锁之后还要再判一次条件

1. 唤醒不等于条件成立
  1. 伪唤醒:没有任何线程敲铃,等待的线程也可能被唤醒,这是标准允许的
  2. broadcast 一次叫醒所有人,先抢到锁的那个可能已经把条件消费掉了
  3. 轮到你时,条件未必还是成立的那一个
2. 写法
pthread_mutex_lock(&mutex);while(条件不成立)// 用 while,不用 ifpthread_cond_wait(&cond,&mutex);/* 条件成立,干活 */pthread_mutex_unlock(&mutex);
  1. 条件不成立就再 wait 一次,等于回到第一段阻塞
  2. 手册的建议也是把条件等待包在等价的 while 循环里

5.6 一次完整等待的六步

  1. 加锁,进临界区
  2. 判断条件,不成立才wait
  3. 调pthread_cond_wait,释放锁、挂到条件变量队列(第一段阻塞)
  4. 被 signal 叫醒,出队、重新申请锁(第二段阻塞)
  5. 拿到锁、返回,再判一次条件
  6. 条件成立就干活,干完解锁

六、signal 换成 broadcast 会发生什么

6.1 唤醒范围

1. signal
  1. 唤醒等待队列里的一个线程
  2. 一个苹果只需要一个人来取时用它
2. broadcast
  1. 唤醒等待队列里的所有线程
  2. 状态整体变化、所有人都该重新看一眼时用它

前面已经演示过了

6.2 惊群

1. 现象
  1. 全部被叫醒,一起去抢那把锁
  2. 只有一个抢到、条件成立、把活干了
  3. 其余的抢到锁后一判条件,发现又空了,只能回到等待队列
2. 代价
  1. 多出来的唤醒和抢锁都是白跑的上下文切换
  2. 唤醒的人越多浪费越大,这就是惊群效应

6.3 怎么用才对

  1. 一个资源只需要一个人处理,用 signal
  2. 所有等待者的条件同时成立、或不知道该叫谁,用 broadcast
  3. 不管用哪个,等待方都得用 while 再判一次条件

七、全篇总结

1. 饥饿从哪来
  1. 锁只保证进得去,不保证轮得上
  2. 反复抢锁却不干活的线程,会把其他人一直挡在门外
2. 同步的两条规则
  1. 解锁的线程不能立即申请锁
  2. 从队列里出来的线程必须到队尾二次申请
3. 条件变量是什么
  1. 一个通知机制(铃铛)加一个等待队列
  2. 让线程在条件不成立时睡下去,而不是空转抢锁
4. wait 的两个参数
  1. 条件变量决定挂在哪个队列、等谁的铃
  2. 互斥锁在等待时自动释放,否则别人改不了条件也敲不了铃
5. 两段阻塞
  1. 第一段挂在条件变量队列,等 signal 或 broadcast
  2. 第二段挂在锁等待队列,等 unlock,与普通加锁阻塞没有区别
6. 唤醒的两件事
  1. 醒来就在临界区内,返回前必须重新持有锁
  2. 拿到锁还要用 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 系统篇持续更新。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询