上一篇,我们把互斥量的概念和接口用法捋了一遍,也补了些细节。但有个关键问题一直悬着——互斥量到底是怎么给临界区加上锁的?它凭什么能做到“锁住别人,放行自己”?
这篇就来掀开这层盖子,看看互斥量底层到底是怎么运转的。讲完原理,我们再顺手封装一个属于自己的互斥量,把理论变成能上手用的东西。
目录
一、理解互斥锁的基础——硬件上下文与线程私有数据
1.1 CPU寄存器与线程硬件上下文
1.2 Swap/Exchange——原子交换语义到底是什么
二、硬件指令如何实现一把锁
2.1 从Lock开始——一次完整的抢锁过程
2.1.1 第一步:准备线程硬件上下文
2.1.2 第二步:用原子交换完成“抢锁”
2.1.3 第三步:根据交换结果做出判断
2.1.4 没抢到锁怎么办:挂起等待与自旋
2.2 Unlock:释放锁之后发生了什么
三、把锁放进真实并发场景中推演
3.1 两个线程竞争同一把锁时,线程如何切换
四、从原理到代码:封装一个完整的互斥锁
一、理解互斥锁的基础——硬件上下文与线程私有数据
在挖互斥锁(Mutex)的底层实现之前,有件事得先搞清楚:CPU寄存器和线程上下文之间,到底是什么关系。
1.1 CPU寄存器与线程硬件上下文
多线程或者多进程的环境里,CPU内部的寄存器硬件,从头到尾只有一套。可跑在系统里的线程,成百上千,一点都不稀奇。一套寄存器,伺候成百上千个线程,怎么做到的?靠的就是来回切换。操作系统每次切换线程或者进程的时候,会把当前线程在CPU寄存器里的那堆数据,原封不动地保存到该线程自己的上下文结构里。等这个线程下次再被调度上来,再把数据从上下文里恢复回寄存器。一存一取,线程就接着上次的断点继续跑。
所以,记住两句话:
CPU寄存器硬件只有一套,所有线程轮流用,谁都别想独占。
寄存器里那组数值,也就是硬件上下文,在某一时刻,只属于当前正在执行的那条线程,是它私有的。别的线程想碰,得等它被换下去。
1.2 Swap/Exchange——原子交换语义到底是什么
所谓“锁”,拆开来看,其实就是内存里的一个状态变量。假设这个变量是1,表示锁当前可用。当你用swap或者exchange指令,把内存里这个变量的值交换到CPU寄存器里时,本质上发生了什么?这个共享变量的内容,被搬进了当前执行流的硬件上下文里。
这里有个关键点必须拎清楚:交换(Exchange)不是拷贝(Copy)。这两者天差地别。拷贝是把值复制一份,原来那份还在;交换是把值整个搬走,原来那份就没了。
正因为是交换,整个系统里数值1从头到尾只有一份。谁通过硬件级别的原子交换操作,把1换到了自己的私有寄存器里,谁就拿到了锁。其他线程再想换,换到的只会是0,锁已经被拿走了,你只能干等。一换定输赢,一换见分晓。
二、硬件指令如何实现一把锁
要保证互斥,光靠软件层面的约定远远不够,关键时刻,得靠硬件出手。体系结构通常都提供汇编级别的交换指令,比如x86的xchgb或者swap。这类指令最大的特点,就是执行时天然原子:它就一条硬件指令,中间没有任何空隙可以被切走,内存和寄存器之间的数据交换过程,一气呵成,谁也打断不了。
有了这把硬件级的“手术刀”,锁的实现就变得非常直接了:
lock: movb $0, %al ; 先把当前线程的al寄存器清零 xchgb %al, mutex ; 原子交换:al寄存器和内存中的mutex互换 if (al 寄存器的内容 > 0) { return 0; ; 换到了1,说明锁是空闲的,申请成功,进临界区 } else { 挂起等待 / 自旋; ; 换到的是0,锁已被别人拿走,申请失败 goto lock; ; 回头再试 } unlock: movb $1, mutex ; 把内存中的mutex重新置为1,表示锁已释放 唤醒等待 Mutex 的线程; ; 叫醒一个正在排队等锁的线程 return 0;仔细看这段伪代码,整个逻辑其实就围绕一个“换”字展开。
加锁的时候,先把寄存器清零,然后拿xchgb去跟内存里的 mutex 交换。这一换,寄存器拿到了mutex原来的值,而mutex则被写成了0。关键在于:换完之后,看寄存器里是什么。如果是1,说明刚才锁还空着,你成功地把它“换”了过来,申请成功。如果是0,说明锁早被人换走了,你换到的是个空壳,申请失败,只能挂起或者自旋,回头再试。
解锁的时候,就更简单了。直接把mutex重新置为1,然后叫醒一个正在等锁的线程。锁一放出来,下一个幸运儿就能通过交换把它抢走。
这套机制的妙处在于:它把“判断锁是否可用”和“占用锁”这两个动作,压缩成了一条不可分割的指令。没有“先看一眼,再决定拿不拿”的中间窗口,也就没有竞态条件滋生的土壤。硬件的一步到位,就是互斥锁最底层的底气。
2.1 从Lock开始——一次完整的抢锁过程
2.1.1 第一步:准备线程硬件上下文
线程准备加锁,第一件事不是去碰内存里的锁,而是先清理自己的“口袋”。它执行一条movb $0, %al,把当前CPU寄存器%al里的值直接清零。
为什么要先清零?因为接下来要拿这个寄存器去跟内存里的锁做交换。如果不清零,里面可能还残留着上一次操作的旧数据,交换出来的结果就掺了杂质,判断就会失准。先把自家门口扫干净,再去跟锁打交道,这一步是整套流程的起点,也是保证后续判断准确无误的前提。
2.1.2 第二步:用原子交换完成“抢锁”
紧接着,执行xchgb %al, mutex。这一条指令,就是整场抢锁大战的胜负手。它把线程私有寄存器 %al(此时值为0)与公共内存中的mutex(初始值为1)进行原子交换。
假设线程A抢先执行。内存里的mutex是1,交换一完成,局面立刻翻转:线程A的寄存器%al变成了1,而内存里的mutex变成了0。
线程A就此独占锁。数值1已经正式进入了线程A的硬件上下文。注意,这个“进入”是实打实的搬家,不是抄一份。从此以后,哪怕线程A立刻被切下CPU,它也会带着%al = 1 这个上下文一起离开。锁跟着它走,别人想拿也拿不到。
2.1.3 第三步:根据交换结果做出判断
交换做完,接下来就看%al里的值,是骡子是马,一判便知。
加锁成功:如果%al > 0,说明线程成功换到了1,锁到手了。函数直接返回0,线程昂首挺胸进入临界区,开始执行代码。
加锁失败:如果后续的线程B试图加锁,它执行xchgb的时候,内存里的mutex早就被线程A改成了0。所以线程B交换一番,换回来的%al也是0。0不大于0,判定失败,线程B只能灰溜溜地退回去,要么挂起,要么自旋,等下一次机会。
2.1.4 没抢到锁怎么办:挂起等待与自旋
申请锁失败的线程,日子可不好过。它面前摆着两条路:要么挂起等待,也就是阻塞;要么用goto lock循环反复尝试,也就是自旋。反正不管走哪条,都得老老实实等到锁被释放的那一天,才有机会翻身。
自旋和挂起,是两种截然不同的等待姿态。自旋像个急性子,站在原地不停试,占着CPU也不肯走;挂起则像个识趣的人,先让出CPU,等别人叫醒再说。选择哪种,取决于锁被持有的时间长短,以及你愿不愿意为那点等待付出CPU空转的代价。
2.2 Unlock:释放锁之后发生了什么
持锁线程在临界区里办完事,临走之前,得把锁还回去。整个释放流程分三步:
- 恢复共享状态:执行movb $1, mutex,把内存里mutex的值重新置为1。这一步等于告诉全世界:“锁空了,谁来都行。”
- 唤醒等待流:叫醒那些阻塞在这把锁上的其他线程。它们之前申请失败,正在门外排队干等。锁一空出来,就得有人去通知它们:“别睡了,有机会了。”
- 完成解锁:函数返回0。被唤醒的线程们,重新获得竞争锁的资格,谁手快谁抢到,下一轮争抢就此展开。
三、把锁放进真实并发场景中推演
有了硬件上下文和交换指令这两样法宝,多线程并发时的互斥性,就有了硬邦邦的保障。
3.1 两个线程竞争同一把锁时,线程如何切换
设想一个极端场景:线程A刚执行完xchgb,交换顺利完成,此时它的%al = 1,内存里的mutex = 0,锁已经攥在手里了。可就在这个节骨眼上,系统突然剥夺了它的CPU执行权,线程切换发生了。接下来会发生什么?
上下文保存:线程A被切走,CPU把%al = 1这个状态,原封不动地保存进线程A的私有上下文里。锁跟着它一起离场。
线程B尝试抢锁:线程B被调度上来,它的寄存器%al先清零,然后拿这个0去跟内存里的mutex交换。可内存里的mutex早就是0了,线程A留下的。交换来交换去,线程B的%al还是0。竞争失败,它只能挂起或者自旋,老老实实等。
线程A恢复执行:线程A再次被调度,系统把它的硬件上下文恢复回来,%al重新变回1。线程A接着往下走,执行条件判断if (al > 0),条件成立,它昂首挺胸进入临界区。
整个过程中,最精妙的地方在于:代表锁的那个1,始终被独占在某一条线程的寄存器上下文里。内存里的mutex变成了0,谁也换不到1。其他线程无论怎么挣扎,在锁被释放之前,都不可能拿到它。于是,临界区里永远只有一条线程在执行,互斥性,就是这么被死死焊住的。
四、从原理到代码:封装一个完整的互斥锁
先看封装部分,一个互斥量本体,外加一个RAII风格的自动锁:
#pragma once #include <iostream> #include <pthread.h> #include <string> #include <cstdio> #include <cstring> #include <functional> #include <vector> #include <unistd.h> namespace MyMutex { // 互斥量本体:把 pthread_mutex_t 的初始化和销毁包进构造析构 class Mutex { public: Mutex() { pthread_mutex_init(&_lock, nullptr); } void Lock() { pthread_mutex_lock(&_lock); } void Unlock() { pthread_mutex_unlock(&_lock); } ~Mutex() { pthread_mutex_destroy(&_lock); } private: pthread_mutex_t _lock; }; // RAII 守卫:构造即加锁,析构即解锁,离开作用域自动释放 class MutexGuard { public: MutexGuard(Mutex& mutex) : _mutex(mutex) { _mutex.Lock(); } ~MutexGuard() { _mutex.Unlock(); } private: Mutex& _mutex; }; }再看调用它的主程序:
#include "MyMutex.hpp" int ticket = 10000; MyMutex::Mutex mutex; void *BuyTicket(void *args) { std::string *name = (std::string *)args; std::cout << "我是子线程[" << *name << "]" << ",我的线程ID是 " << pthread_self() << std::endl; while (true) { { MyMutex::MutexGuard guard(mutex); if (ticket > 0) { usleep(1); ticket--; std::cout << "线程[" << *name << "]抢到一张票," << "ticket = " << ticket << std::endl; } else { break; } } } return nullptr; } int main() { std::vector<pthread_t> ptd; for (int i = 0; i < 5; i++) { pthread_t tid = 0; std::string *name = new std::string("Thread" + std::to_string(i)); pthread_create(&tid, NULL, BuyTicket, name); ptd.push_back(tid); } for (int i = 0; i < 5; i++) { pthread_join(ptd[i], nullptr); } delete name; // 注意:这里释放不了所有线程的 name,存在泄漏 }这套封装,精妙之处在RAII守卫。
MutexGuard就是那个“自动锁”。构造它的时候,它顺手把互斥量锁上;离开作用域,它析构,顺手把锁解开。你不需要手动调Lock()和Unlock(),只要把MutexGuard对象往作用域里一放,剩下的事它全包了。
好处是什么?异常安全,且不怕忘了解锁。临界区里哪怕中间抛了异常,栈回退的时候 MutexGuard 照样会析构,锁照样会释放。手动调pthread_mutex_unlock的写法,一旦在临界区里提前return或者抛异常,锁就永远锁死了。RAII把这种坑,直接从根上填了。
如果这篇文章对你有帮助,别忘了点个赞、点个收藏、点个关注。你的每一次反馈,都是我继续硬核输出的最大动力。