前面两篇,我们把互斥量从概念、接口,一路讲到原理和封装,算是把它彻底摸透了。从这篇开始,我们进入同步和条件变量的地盘。
目录
一、从互斥走向同步——为什么只有一把锁还不够
1.1 一个极端场景——只有互斥会发生什么
1.1.1 争夺锁与如何高效释放
1.1.2 一直抢不到锁——饥饿问题是怎么产生的
1.2 从临时缓解到真正解决:如何避免无效竞争
1.2.1 临时方案:给循环加入短暂休眠
1.2.2 从规则与队列出发重新设计同步机制
1.3 引入线程同步:让线程按条件协作
1.3.1 什么是线程同步
1.3.2 互斥与同步:两者分别解决什么问题
二、深入理解线程同步:从“拿苹果、放苹果”认识条件变量
2.1 用“拿苹果、放苹果”理解同步机制
2.1.1 没有铃铛和队列:纯互斥逻辑的问题
2.1.2 加入“铃铛与队列”:同步机制开始工作
2.2 从故事抽象到专业概念
2.2.1 条件变量:让线程等待某个条件
2.2.2 同步与竞态条件:为什么线程需要协作
三、条件变量的核心接口
3.1 条件变量的初始化与销毁
3.1.1 静态初始化条件变量
3.1.2 动态初始化条件变量
3.1.3 条件变量什么时候可以销毁
3.2 pthread_cond_wait:让线程进入等待状态
3.2.1 被唤醒后,pthread_cond_wait内部又做了什么
3.3 如何唤醒等待中的线程
3.3.1 pthread_cond_signal:唤醒一个等待线程
3.3.2 pthread_cond_broadcast:唤醒所有等待线程
四、第一个条件变量Demo:从代码验证同步机制
4.1 测试代码展示
4.2 Demo核心逻辑:为什么条件变量必须和锁一起使用
4.2.1 为什么调用pthread_cond_wait前必须先加锁
4.2.2 pthread_cond_wait如何自动释放锁并进入等待
4.2.3 被唤醒之后,为什么还要重新竞争锁
一、从互斥走向同步——为什么只有一把锁还不够
引入一项新技术,往往是因为旧技术留下了新麻烦。
上一篇我们聊了互斥锁(Mutex)的基本概念和用法。它确实把多线程并发访问临界资源时的“数据不一致”和“竞争条件”按住了,保证同一时刻只有一个执行流能踏进临界区。
可问题来了:光靠“纯互斥”,就真的万事大吉了吗?
1.1 一个极端场景——只有互斥会发生什么
为了看清纯互斥到底藏着什么隐患,我们来看一个生活中的“超级自习室”例子。
假设有这么一间自习室,里面只有一张桌子,门锁只认一把钥匙。谁拿到钥匙,谁才能进去。
自习室:相当于临界资源。
钥匙:相当于互斥锁。
想进去自习的人:相当于各个线程,也就是执行流。
1.1.1 争夺锁与如何高效释放
线程A抢到钥匙,推门进了自习室,其他人只能在外面干等。过了一阵,线程A打算走人。他开门出来,顺手把钥匙挂回门口的挂钩上,也就是释放锁。但这时候,一个特别微妙的“物理现象”出现了:
线程A离挂钩最近,刚把钥匙挂上去,一伸手又能把它摘下来!
门外那些排队的线程呢?它们离挂钩远,或者正卡在“准备唤醒”“休眠恢复”这类系统开销和延迟里,动作慢了一拍。于是,线程A前脚刚出门,后脚又把钥匙抢回来,重新锁门进了自习室。
1.1.2 一直抢不到锁——饥饿问题是怎么产生的
进了自习室,线程A发现自己其实没啥正事可干,没做有效操作。于是他又开门出来,把钥匙挂上。可挂上的那一瞬间,他又一次抢到了钥匙……就这么循环往复:
申请锁
进入临界区(但没干正事,或者高频重复操作)
释放锁
凭着距离优势,瞬间又把锁抢回来
从互斥规则上看:纯互斥有错吗?一点错没有!任何时刻确实只有一个人进了自习室,数据安全稳如泰山。
可从系统整体的运行效率和公平性来看,问题就大了:
不高效:线程A频繁申请、释放锁,却没做有效业务处理。
不公平:其他想自习的线程,长时间在门外等着,永远抢不过离挂钩最近的线程 A。
在多线程编程里,这种其他线程长时间拿不到临界资源、导致无法推进的现象,就叫线程饥饿问题(Starvation)。
在Ubuntu、CentOS这些不同的Linux发行版/内核调度环境下,如果不加干预,纯互斥锁的这个弊端往往表现得非常明显。
Tips:其他线程拿到临界资源的三个契机
活跃线程每次都能近水楼台先得月,那其他线程还有机会吗?我整理了三种情况:
契机一:活跃线程“主动歇手”
刚释放锁的线程不再高频申请了,或者它进入了休眠(比如sleep)、去执行了别的非同步耗时任务。这时候没有活跃线程在抢,锁就闲了下来,刚被唤醒的排队线程正好顺理成章地接盘。
契机二:外面的线程“刚好醒来”
这是个概率问题。活跃线程释放锁、准备下一次抢锁的那一瞬间,队列里某个被唤醒的线程刚好完成上下文切换,进入可运行状态(Runnable)。两边同时出手,外面的线程运气好,在硬件或操作系统层面的原子操作里,抢先拿到了锁的标志位。
契机三:触发了“防饥饿”强制排队机制(锁升级/退化)
现代操作系统的锁,比如Java的synchronized或ReentrantLock,都带优化机制。如果外面的线程连续失败太多次、等待时间超过了阈值,锁就会从非公平模式强制切换/升级为公平模式。这时候,锁直接进入“闭关排队”状态,不接受任何活跃线程插队,必须按队列顺序把锁传给外面等着的人。
1.2 从临时缓解到真正解决:如何避免无效竞争
纯互斥既然可能带来“饥饿”和“低效”,那该怎么改?
1.2.1 临时方案:给循环加入短暂休眠
写多线程代码的时候,如果释放完锁之后,顺手加一行极短的休眠:
usleep(100); // 释放锁后给其他线程留点调度和申请的时间别小看这100微秒。线程A一释放锁就主动睡过去,CPU的使用权和抢锁的机会,就这么让出去了。其他线程终于能喘口气,趁这个空档完成唤醒、切换、申请锁。这样一来,各个线程拿到锁的机会就平均多了,饥饿现象明显缓解。
但得说清楚:这只是在“人为硬编码延时”,一种妥协式的调优,没有从机制层面真正解决“访问顺序”的问题。它能让局面好看一点,但治标不治本。
1.2.2 从规则与队列出发重新设计同步机制
要从根上治住自习室的乱象,光靠“让一让”的临时妥协远远不够。管理员一拍桌子,立下两条硬规矩:
- 第一条:出门之后,不许立刻回头再抢。刚从自习室出来的人,没资格当场伸手把钥匙再拿回去。想再进?先歇着。
- 第二条:所有人排成一条队,按顺序来。想进自习室,先去队尾站着。哪怕你刚才还在里面坐着,出来了也得老老实实跑到队尾,重新排队,等下一次轮到你。
这两条规矩一立,局面就变了。先来后到,谁也别插队。每个人什么时候能进、前面还有几个人,心里清清楚楚,不用再靠抢、靠运气、靠“离钥匙挂钩近”。
从“谁手快谁抢到”的混乱争夺,到“排好队、按顺序进”的明确秩序,这就是从制度层面解决问题的思路。后面要讲的条件变量,本质上就是把这套排队机制搬进了多线程的世界。
1.3 引入线程同步:让线程按条件协作
给纯互斥加上一条“按顺序排队、按顺序访问”的约束,我们才算是正式推开了操作系统里那扇叫做线程同步(Thread Synchronization)的大门。
1.3.1 什么是线程同步
所谓线程同步,一句话就能概括:在保证临界资源安全(互斥)的前提下,让所有执行流按照某种确定的顺序去访问临界资源。
注意,关键词是“顺序”。不是随便谁抢到算谁的,而是有规矩、有先后、可预期的。
1.3.2 互斥与同步:两者分别解决什么问题
这两者,各管一摊,缺一不可:
互斥:解决的是“安全”问题,确保数据不被破坏。核心就一句,不能同时进。
同步:解决的是“高效与公平”问题,避免线程饥饿,提升整体协同吞吐量。核心也是就一句,有序轮流进。
只有把“互斥”和“同步”拧成一股绳,多线程对共享资源的协同访问,才算真正走向成熟与高效。光有互斥,秩序是有了,可公平没了;光有同步,顺序是排了,可安全没底。两个一起上,才既锁得住,又排得顺。
二、深入理解线程同步:从“拿苹果、放苹果”认识条件变量
在钻进操作系统底层接口之前,我们先回头看一眼之前打过交道的那个老熟人,管道(Pipe)。
管道通信里,有个特别典型的现象:管道空了,读端就阻塞,读不出东西;管道写满了,写端也阻塞,塞不进去。读端和写端之间那种“有资源才读、有空间才写”的默契配合,本质上就是一种标准的同步机制。一个等,一个送,节奏对上了,数据才能顺顺当当地流动。
不过,光讲抽象概念容易劝退。我们先来做个小游戏。
2.1 用“拿苹果、放苹果”理解同步机制
假设桌上摆着一个盘子,盘子里最多只能放一个苹果。现在有两类角色:
放苹果的人
拿苹果的人
盘子是共享资源,为了防止多人同时抢夺,盘子上方挂着一把锁。每次只能有一个人拿到锁,才能靠近盘子。
2.1.1 没有铃铛和队列:纯互斥逻辑的问题
这个阶段,桌上只有一把锁。拿苹果的人蒙着眼睛,在没拿到锁、没伸手摸盘子之前,压根不知道盘子里有没有苹果。
于是局面就变成了这样:
拿苹果的人先申请锁。
拿到锁,伸手摸盘子——空的。
什么也拿不到,只能释放锁,离开。
可他离锁最近啊,刚把锁放下,顺手又拿了起来,再摸一次盘子——还是空的,又放下……
循环往复,无穷无尽。
这种模式带来的危害是什么?
拿苹果的人在没苹果的时候,疯狂地重复“申请锁 → 发现没苹果 → 释放锁 → 再申请锁”这一套动作。精力全耗在空转上了,CPU 资源被白白吃掉,却没做出任何有效动作。而放苹果的人呢?他想过来放苹果,可根本抢不到锁——因为那个拿苹果的人,正死死霸着锁,一遍又一遍地摸空盘子。
2.1.2 加入“铃铛与队列”:同步机制开始工作
为了解决上面那团乱麻,游戏规则升级了:盘子旁边挂一个铃铛,再设一条排队队列。
新的互动约定是这样的:
拿苹果的人,申请锁之后去摸盘子。
摸到苹果,直接拿走,释放锁,走人。
发现盘子空了,不再盲目地重新抢锁,而是主动释放锁,跑到队列末尾排队,然后休眠。
放苹果的人,申请锁之后把苹果放进盘子。
放完苹果,顺手按一下铃铛。
铃声一响,唤醒队列头部等待的第一个人。
被唤醒的人从队头出来,去拿苹果;拿完,再回到队尾重新排队。
这套规则一上线,局面立刻变了:拿苹果的人不再做无意义的重复抢锁,盘子空着的时候,他就安安静静在队列里等着。放苹果的人放完资源,一个“按铃”精准通知等待者,不多不少。整个过程井然有序,资源被利用得干干净净,再没人空转烧CPU。
故事里的“铃铛 + 队列”,搬到Linux多线程编程里,对应的正是,条件变量(Condition Variable)。
2.2 从故事抽象到专业概念
看懂了上面的故事,再回头看操作系统里关于同步与条件变量的专业定义,就一目了然了。
2.2.1 条件变量:让线程等待某个条件
当一个线程互斥地访问某个变量时,它可能发现,在别的线程改变状态之前,自己什么也做不了。
比方说,一个线程去访问队列,一看,队列是空的。它能干嘛?只能等。等到别的线程往队列里塞进一个节点,它才有活儿可干。这种场景,就得请出条件变量。
2.2.2 同步与竞态条件:为什么线程需要协作
同步:在保证数据安全(互斥)的前提下,让线程按照某种特定的顺序去访问临界资源,从而有效避免饥饿问题。这套机制,就叫同步。
竞态条件(Race Condition):由于执行时序上的偏差,导致程序运行异常,或者结果跟预期对不上,这种现象就叫竞态条件。
三、条件变量的核心接口
条件变量这套接口,跟前面讲的互斥量几乎是同一个模子刻出来的。用之前,头文件<pthread.h>得先请进来。在POSIX线程库里,它的数据类型叫pthread_cond_t。下面把核心API快速过一遍。
3.1 条件变量的初始化与销毁
跟互斥锁一样,条件变量的初始化也分两条路:静态和动态。
3.1.1 静态初始化条件变量
条件变量如果是全局的,或者带static修饰,那就可以用宏来搞定,一行代码,连销毁都省了:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;3.1.2 动态初始化条件变量
可要是条件变量定义在局部,或者是在堆上malloc出来的,那宏就不管用了,得老老实实调函数:
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);- cond:指向要初始化的那个条件变量。
- attr:属性设置。平时填NULL,用默认的就行。
3.1.3 条件变量什么时候可以销毁
走动态初始化这条路进来的,出去的时候也得走销毁这条路。不用了,就得把资源还回去:
int pthread_cond_destroy(pthread_cond_t *cond);一句话总结:静态初始化的不用管,动态初始化的必须销毁。跟互斥量一个脾气,别记岔了。
3.2 pthread_cond_wait:让线程进入等待状态
线程去访问临界资源,结果发现条件不满足。这时候,它不能赖在临界区里干等,得先把自己挂起,进入休眠,排到该条件变量的等待队列里去。
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);- cond:在哪个条件变量下等待,就传哪个。
- mutex:一把互斥锁的指针。
关键疑问:
这里有个很扎眼的细节,pthread_cond_wait的第二个参数,居然是一把互斥锁。为什么在条件变量下等待,还得把锁一并交出去?
3.2.1 被唤醒后,pthread_cond_wait内部又做了什么
一个线程在条件变量上等到了通知,醒来之后,它内部要走这么几步:
- 脱离条件变量队列:从等待队列里把自己摘出来,不再排队。
- 重新竞争互斥锁(关键步骤):这一步很要紧。醒来不等于立刻就能干活,它得重新去抢那把互斥锁,抢到了才有资格继续。
- 成功获取锁:锁到手,函数准备返回。
- 函数返回,继续向下执行:回到代码里,从pthread_cond_wait的下一行接着跑。
3.3 如何唤醒等待中的线程
别的线程把状态改了,比如新资源到位了、苹果放盘子里了,这时候就得去叫醒那些在条件变量队列里睡着的线程。POSIX给了两套唤醒方案。
3.3.1 pthread_cond_signal:唤醒一个等待线程
int pthread_cond_signal(pthread_cond_t *cond);功能很专一:唤醒在指定条件变量cond下等待的一个线程。这就像走到队列前,轻轻按一下铃铛,排在队头的那位被叫醒,起身去抢锁干活。其他人呢?继续睡,等下一次铃声。
3.3.2 pthread_cond_broadcast:唤醒所有等待线程
int pthread_cond_broadcast(pthread_cond_t *cond);这个就豪放多了:在指定条件变量cond下等待的所有线程,全部叫醒。相当于拿起大喇叭,对着整个队列吼一嗓子:“都别睡了,起来抢!”于是所有等待者同时从睡梦中弹起来,一窝蜂冲向那把互斥锁,谁先抢到谁先上。
一个是点名,一个是全体起立。用哪个,看你的场景,只想派一个活,就用signal;状态变化影响所有人,那就上broadcast。
四、第一个条件变量Demo:从代码验证同步机制
4.1 测试代码展示
#include <iostream> #include <pthread.h> #include <vector> #include <unistd.h> int ticket = 100000; pthread_mutex_t _mutex = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t _cond = PTHREAD_COND_INITIALIZER; void *RunThread(void *args) { char *name = static_cast<char *>(args); while (true) { { pthread_mutex_lock(&_mutex); pthread_cond_wait(&_cond, &_mutex); if (ticket > 0) { ticket--; std::cout << "线程" << name << "抢到票: " << ticket << std::endl; } pthread_mutex_unlock(&_mutex); } } } int main() { int cnt = 5; std::vector<pthread_t> pvr; while (cnt) { pthread_t tid; pvr.push_back(tid); char *name = new char[64]; int n = snprintf(name, 64, "thread-%d", cnt); if (n < 0) perror("snprintf error"); pthread_create(&tid, nullptr, RunThread, name); cnt--; } while (true) { std::cout << "唤醒一个线程" << std::endl; usleep(1000); pthread_cond_signal(&_cond); } for (auto e : pvr) { pthread_join(e, nullptr); } return 0; }4.2 Demo核心逻辑:为什么条件变量必须和锁一起使用
这段代码里,主线程一口气拉起5个工作线程,它们全都跑在RunThread里。要搞懂这套机制,盯着下面三个关键问题就够了。
4.2.1 为什么调用pthread_cond_wait前必须先加锁
看RunThread里那两行:
pthread_mutex_lock(&_mutex); pthread_cond_wait(&_cond, &_mutex);原因有两层:
第一,判断条件本身就是访问临界资源。线程为什么要等?因为“条件不满足”,票数不够,或者资源没就绪。而要检查条件,就得去读共享变量ticket。读共享变量的操作,必须在加锁保护的临界区里干,这是规矩。
第二,休眠必须在临界区内发起。线程判定条件不满足、决定要睡的时候,它已经站在临界区里了,手里正攥着那把互斥锁。这时候挂起,才算顺理成章。
4.2.2 pthread_cond_wait如何自动释放锁并进入等待
这里立马蹦出一个严密的逻辑冲突:线程攥着锁直接睡过去,那别的线程岂不是永远拿不到锁,也就没法改变条件了?死锁,闭环了。
这正是pthread_cond_wait必须接收第二个参数&_mutex的根本原因:当线程执行pthread_cond_wait时,操作系统在把它挂入_cond等待队列的同时,会自动释放传入的互斥锁_mutex!
整个执行链路是这样的:
- 申请锁成功,进入临界区
- 判定条件不满足
- 调用pthread_cond_wait
- 线程被挂入条件变量等待队列
- 自动释放锁
- 其他线程得以进入临界区
正因为这一手“自动解锁”,主线程或其他生产者才能顺利拿到互斥锁,去修改共享变量、发出唤醒信号。锁没有被睡着的线程霸占着,整个系统才能转起来。
4.2.3 被唤醒之后,为什么还要重新竞争锁
主线程这边,用了一个死循环,每隔一小会儿就调一次:
pthread_cond_signal(&_cond);这一下一下的“按铃”,背后发生了什么?
有序被唤醒:主线程每发一次pthread_cond_signal,就从_cond等待队列的队头捞一个线程出来唤醒。看终端输出就明白了,各个线程被唤醒的顺序,跟它们当初进队列的顺序一模一样。先来先醒,谁也别想插队,饥饿问题在这儿就被摁住了。
唤醒后重新抢锁:被唤醒的线程从pthread_cond_wait返回时,可不是拍拍屁股就接着往下跑。它得在函数内部重新去抢_mutex那把锁!这一步绕不过去。
安全退出临界区:只有重新把锁抢到手的线程,才能真正从pthread_cond_wait里出来,接着执行后面的ticket--,最后再pthread_mutex_unlock(&_mutex)把锁放掉。抢不到锁的?继续在函数里堵着,直到抢到为止。
那如果把pthread_cond_signal换成pthread_cond_broadcast呢?
所有等待线程会在一瞬间全部被叫醒,场面看着挺热闹。但别慌,因为“重新竞争锁”这道关卡还在,它们依然得排着队,一个一个抢到锁,才能进入后续逻辑。绝对不会出现一窝蜂冲进临界区、同时改数据的混乱场面。锁在门口站着呢,谁也别想蒙混过关。
如果这篇文章对你有帮助,别忘了点个赞、点个收藏、点个关注。你的每一次反馈,都是我继续硬核输出的最大动力。