《从零入门Linux系统篇(五十四):线程篇·七——互斥锁底层原理:从原子交换到线程竞争与锁实现》
上一篇我们把互斥量的概念和接口用法捋了一遍也补了些细节。但有个关键问题一直悬着——互斥量到底是怎么给临界区加上锁的它凭什么能做到“锁住别人放行自己”这篇就来掀开这层盖子看看互斥量底层到底是怎么运转的。讲完原理我们再顺手封装一个属于自己的互斥量把理论变成能上手用的东西。目录一、理解互斥锁的基础——硬件上下文与线程私有数据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::vectorpthread_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把这种坑直接从根上填了。如果这篇文章对你有帮助别忘了点个赞、点个收藏、点个关注。你的每一次反馈都是我继续硬核输出的最大动力。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →