拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

《从零入门Linux系统篇(五十五):线程篇·八——线程同步详解:从互斥锁到条件变量与线程协作》

《从零入门Linux系统篇(五十五):线程篇·八——线程同步详解:从互斥锁到条件变量与线程协作》

前面两篇,我们把互斥量从概念、接口,一路讲到原理和封装,算是把它彻底摸透了。从这篇开始,我们进入同步和条件变量的地盘。

目录

一、从互斥走向同步——为什么只有一把锁还不够

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内部又做了什么

一个线程在条件变量上等到了通知,醒来之后,它内部要走这么几步:

  1. 脱离条件变量队列:从等待队列里把自己摘出来,不再排队。
  2. 重新竞争互斥锁(关键步骤):这一步很要紧。醒来不等于立刻就能干活,它得重新去抢那把互斥锁,抢到了才有资格继续。
  3. 成功获取锁:锁到手,函数准备返回。
  4. 函数返回,继续向下执行:回到代码里,从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呢?

所有等待线程会在一瞬间全部被叫醒,场面看着挺热闹。但别慌,因为“重新竞争锁”这道关卡还在,它们依然得排着队,一个一个抢到锁,才能进入后续逻辑。绝对不会出现一窝蜂冲进临界区、同时改数据的混乱场面。锁在门口站着呢,谁也别想蒙混过关。


如果这篇文章对你有帮助,别忘了点个赞、点个收藏、点个关注。你的每一次反馈,都是我继续硬核输出的最大动力。

返回列表