:线程篇·八——线程同步详解:从互斥锁到条件变量与线程协作》)
前面两篇我们把互斥量从概念、接口一路讲到原理和封装算是把它彻底摸透了。从这篇开始我们进入同步和条件变量的地盘。目录一、从互斥走向同步——为什么只有一把锁还不够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_castchar *(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::vectorpthread_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呢所有等待线程会在一瞬间全部被叫醒场面看着挺热闹。但别慌因为“重新竞争锁”这道关卡还在它们依然得排着队一个一个抢到锁才能进入后续逻辑。绝对不会出现一窝蜂冲进临界区、同时改数据的混乱场面。锁在门口站着呢谁也别想蒙混过关。如果这篇文章对你有帮助别忘了点个赞、点个收藏、点个关注。你的每一次反馈都是我继续硬核输出的最大动力。