摘要:今儿我们来聊聊计算机江湖里一个贼拉关键的玩意儿——互斥锁(江湖人称Mutex, Mutual Exclusion 的缩写,听着就挺排外的哈)。
今儿我们来聊聊计算机江湖里一个贼拉关键的玩意儿——互斥锁(江湖人称 Mutex, Mutual Exclusion 的缩写,听着就挺排外的哈)。
现在这世道,程序动不动就搞多线程、多进程,跟赶大集似的,一堆人(线程)乌泱泱都想抢同一个宝贝(共享资源),这不乱套了吗?
数据打架、结果跑偏那都是分分钟的事儿!
所以啊,就得请出咱们这位镇场子的狠角色——互斥锁,专治各种不服,保证同一时间,就一个狠人能摸到那宝贝疙瘩!
一、互斥锁是个啥路数?
简单粗暴!它就相当于给那“藏宝洞”(专业点叫临界区)门口杵了个彪形大汉。这“藏宝洞”里头放的啥?就是你程序里那些大家伙(线程/进程)都眼红的共享资源!
想进?行啊~
规则就一条: 先拿钥匙(Lock)!钥匙就一把,谁抢到谁进!进去办事儿,办完立马把钥匙交出来(UnLock),下一个兄弟接着来!这不就和谐了?完美避免“数据打架”!
1、Lock(上锁):
线程大哥大摇大摆走到门口,伸手一摸——“哎哟,钥匙不在?被隔壁老王拿走了?” 行,那你就老实蹲墙角等着,别瞎嚷嚷,CPU 给你省着点用,去睡个觉先!
2、Unlock(放锁):
老王办完事儿,把钥匙“啪”一声扔门口:“下一个!” 睡觉的兄弟立马被叫醒:“轮到我了!” 抄起钥匙就往里冲!
就这么简单粗暴,保证同一时间,就一个线程在临界区蹦迪,其他兄弟?排队!别急!
1、硬件派:
玩的就是原子操作!
啥叫原子?就是“一气呵成,中间不许断”!
比如“测试并设置”(Test-and-Set)、“比较并交换”(Compare-and-Swap),CPU 硬件直接给你包圆儿,一气呵成,贼拉靠谱!多核处理器也得乖乖听话!
2、软件党:
比如那个传说中的 Dekker算法、Peterson算法,纯靠代码逻辑“掰手腕”。
老铁,玩锁不是闹着玩的,搞不好就把自己“锁”进去了!
三大“作死”行为,你可得记住了:
1、死锁(Deadlock):
想象一下:线程A拿着锁1,死活等着锁2;线程B拿着锁2,死活等着锁1。好家伙,俩大爷互相卡脖子,谁也不松手!结果就是——全卡死!
破解之法: 要么大家按统一顺序拿锁(比如都先1后2),要么用try_lock,拿不到就溜,别硬刚!这叫“能屈能伸,方为线程好汉”!
2、优先级反转(Priority Inversion):
高优先级线程急着办事,结果发现钥匙被低优先级线程捏着,慢悠悠地磨蹭。高优先级大哥只能干瞪眼?这不乱套了!
破解之法: 上优先级继承!让拿钥匙的低优先级线程临时“升职加薪”,变成高优先级,赶紧把事儿办了,把钥匙交出来!这叫“为了大局,临时抱佛脚”!
3、锁的粒度——“锁得太大” or “锁得太碎”?
锁太大:整个函数甚至模块都锁了,那还玩啥并发?并发变串行,性能直接躺平!
锁太碎:到处都是小锁,管理开销巨大,代码乱成一锅粥,维护起来想哭!
这俩兄弟,看着像,脾气差远了!
自旋锁(Spinlock):
这哥们儿轴!拿不到锁?不睡觉!不放弃! 就在门口“busy-waiting”,CPU疯狂空转:“好了没?好了没?”——CPU 烤机专用锁! 只适合临界区贼短的情况,比如就几条指令,转几圈就完了。时间长了?CPU 直接给你干冒烟!
互斥锁(Mutex):
这才是“文明人”!拿不到锁?麻利儿地睡觉去! 把CPU让给其他兄弟干活。等拿锁的哥们儿一放钥匙,立马有人喊:“醒醒!轮到你了!” 瞬间满血复活!省电、高效、适合临界区较长的场景!std::mutex 的 lock 就是这路数——拿不到?睡!unlock 一放锁?喊人!
在C++里,光有 std::mutex 还不够,还得配“锁管理器”:
std::lock_guard:
“一键锁死,死后解锁”!构造时自动 lock,析构时(比如出了作用域)自动 unlock。简单粗暴,性能贼好,适合“进去-干活-出来”一条龙,不搞花里胡哨的。
std::unique_lock
:
“锁界变形金刚”!更灵活!能手动 lock/unlock,能延迟加锁,还能转移锁的“所有权”!
关键一点:想用条件变量(condition_variable)?必须用它!std::lock_guard 太“死板”,搞不定!
你以为 std::mutex是啥高科技?错!它就是个“包工头”!真正的“苦力活”它可不干!咱瞅瞅它的源码:class mutex : private __mutex_base // 基类:__mutex_base{ void lock { int __e = __gthread_mutex_lock(&_M_mutex); // 喊人!干活去! // ... } void unlock { __gthread_mutex_unlock(&_M_mutex); // 喊人!收工! } // ...}看到没?lock 和 unlock 里面干了啥?
直接调用 __gthread_mutex_lock 和 __gthread_mutex_unlock!
这俩货又是谁?
class __mutex_base // mutexd的基类{ protected: typedef __gthread_mutex_t __native_type; __native_type _M_mutex = __GTHREAD_MUTEX_INIT; // 初始化宏 constexpr __mutex_base noexcept = default; // ... }typedef pthread_mutex_t __gthread_mutex_t; // _M_mutex的原始类型就是pthread_mutex_t#define __GTHREAD_MUTEX_INIT PTHREAD_MUTEX_INITIALIZER // 初始化宏是glibc库的初始化宏所以,std::mutex 本质上就是对 glibc 里的 pthread_mutex_t 的一层马甲!真正的“脏活累活”,全交给 pthread 库了!
C++标准库:我负责优雅,你们负责拼命!
typedef union { struct __pthread_mutex_s { int __lock; // !核心中的核心:__lock! 这个int值,就是锁的“状态指示灯”:
0:绿灯! 没人用,空着呢!冲啊兄弟!1:黄灯! 有人用了,但没竞争!别急,等会儿。2:红灯! 有人用了,而且后面排大队了!竞争激烈!注意! 这int读写不是原子的!你不能直接 if (__lock == 0) __lock = 1;,这中间可能被其他线程插一脚!所以,必须用原子操作!比如 test-and-set 或者 compare-and-swap (CAS)!这叫“原子操作,安全第一”!
pthread_mutex 的牛X之处,在于它用了 futex!全称 Fast Userspace muTEX,翻译过来就是“快速用户态互斥锁”。
这玩意儿的精髓就是:能不进内核,就绝不进内核! 节省开销!
这代码啥意思?翻译成人话:
“试探性进攻”:我先瞅一眼 __lock 是不是 0?
“原子抢钥匙”:如果是 0,我立马用 CAS 操作,把它从 0 改成 1!这一套操作在用户态瞬间完成!没有系统调用!没有上下文切换!贼快!
“抢不到就喊爹”:如果 __lock 不是 0(说明是 1 或 2),CAS 失败了!那没办法了,只能调用 __lll_lock_wait_private,正式进入内核,请求 futex 系统调用帮忙!
这叫啥?这叫“乐观锁”!先假设没竞争,抢到了就赢麻了;抢不到,再老老实实排队!
#define __lll_unlock(futex, private) \ ((void) ({ \ int *__futex = (futex); \ int __oldval = atomic_exchange_rel (__futex, 0); // 原子地把__lock设为0,并拿到旧值 if (__oldval > 1) { // 旧值大于1?说明曾经是2!有兄弟在等! __lll_lock_wake_private (__futex); // 喊内核!叫醒一个等锁的兄弟! } \ }))解锁流程:
“归还钥匙”:用 atomic_exchange_rel 原子操作,把 __lock 直接设为 0。这个操作会返回设置前的旧值。
“看情况喊人”:
如果旧值是 1:说明只有我一个线程在用,没竞争。直接走人! 不用叫任何人!如果旧值是 2:说明曾经有竞争!有兄弟在内核里“睡大觉”呢!赶紧调用 __lll_lock_wake_private,进入内核,通过 futex_wake 系统调用,把等锁的兄弟喊醒一个!“报到”:线程B调用 futex_wait(&__lock, expected_val),说:“大佬,我等 __lock 变成 expected_val(通常是0)”。
“再确认”:内核拿到 &__lock 的地址,先计算哈希,找到对应的队列,上自旋锁,然后再次检查 *uaddr 的值! 为啥?防止在你进内核的路上,锁已经被释放了!这就是“双重检查”!
“请入席”:如果检查发现锁还是没释放(*uaddr != expected_val),内核就把线程B的状态设为 TASK_INTERRUPTIBLE(可中断睡眠),把它插入到等待队列里,然后释放自旋锁,并调用调度器让出CPU。线程B开始“睡觉”。
“点名”:线程A调用 futex_wake(&__lock, nr_wake=1),说:“大佬,把等 __lock 的兄弟叫醒一个!”
“找人”:内核同样计算哈希,找到队列,上自旋锁,遍历队列,找到一个正在等这个 futex 的线程(比如B)。
“叫醒”:把线程B从等待队列移除,加入到CPU的就绪队列,等待被调度。内核返回。
灵魂拷问:unlock时,state=0和futex_wake之间,如果C抢锁了,B醒来怎么办?完美设计!不怕!
A解锁:__lock 从 2 -> 0,调用 futex_wake 喊B。
就在A调用futex_wake和B被真正唤醒调度的间隙,线程C来了!它执行 lock:
看 __lock 是 0!CAS操作,把 __lock 从 0 -> 1!成功抢到锁!B被唤醒,调度到CPU上执行:
B从 futex_wait 返回,回到 pthread_mutex_lock 的代码里。关键来了! B不会直接认为“我拿到锁了”!它会重新进入__lll_lock的流程!此时B会再次执行 atomic_compare_and_exchange_bool_acq (&__lock, 1, 0)!但这时 __lock 是 1(被C占了),CAS失败!于是B发现:“哦,还是没抢到”,它会再次进入 __lll_lock_wait_private,重新调用 futex_wait,继续睡觉!整个过程就像一场接力赛:
第一棒(用户态CAS): 能抢就抢,抢到就赢!第二棒(内核futex_wait): 抢不到?进内核“候车室”(等待队列)睡觉!第三棒(内核futex_wake): 有人放锁?内核“调度员”喊醒一个“候车”的!第四棒(醒来重试): 被喊醒的线程,还得重新跑第一棒(CAS)!抢到算你狠,抢不到继续睡!所以,std::mutex 的高效,就在于它最大限度地减少了昂贵的系统调用,把“无竞争”的场景完美地留在了用户态! 这就是现代操作系统和库设计的智慧结晶!
老铁们,这把锁的奥义学废了没!下回想看哪些知识?评论区喊出来!点赞关注走起,咱下期接着唠!
来源:音视频开发老舅