资讯动态

【金丹·73】线程安全:锁、原子操作与内存屏障

发布时间:2026/10/1 6:38:58 来源:尧图企业网站定制
【金丹·73】线程安全锁、原子操作与内存屏障码农修仙传 · 金丹期 · 第73篇我是玄芯散人带你从炼气修到大乘。境界标识╔══════════════════════════════════╗ ║ 金丹期 · 第73篇 ║ ║ 线程安全锁、原子操作、 ║ ║ 内存屏障 ║ ║ mutex/spinlock/CAS/mb/volatile║ ║ 预计阅读25分钟 ║ ╚══════════════════════════════════╝修仙引入上一篇把文件系统收掉了。可修真界里还有一件大事没解决多个弟子同时动一件东西时怎么不出乱子。两位金丹修士对同一卷功法同时抄录一卷变两卷十位弟子同时往同一个灵石账户里存灵石最后少了几颗某位弟子刚要去读令牌上的内容另一位已经把这张令牌换成了新内容。这些事听起来像江湖恩怨放到操作系统里就是线程安全问题。修真界里这件事归山门戒律堂管。戒律堂有一套规矩互斥锁让密室同时只进一人自旋锁让门外弟子原地等CAS 是用探测术取代排队内存屏障是看破幻术的定心咒volatile是看似厉害实则只挡内门幻术的护身符。这一篇把戒律堂拆开看每件兵器怎么用适合什么情形。硬核主体为什么多线程需要同步先把问题摆出来。线程是进程的分身共享进程的地址空间。共享带来便利也带来麻烦。// 两位弟子同时往共享账户里存灵石intbalance0;// 共享变量voiddeposit(intamount){balancebalanceamount;// 这一行不是原子的}// 启动 100 个线程每个线程存 100 次 1// 期望 balance 10000// 实际可能得到 9873 或其它值修真比喻两位弟子在同一个账本上记账。第一位弟子先看一眼账本读到余额 100记下要加 50第二位弟子也看一眼也读到 100也记下要加 30。第一位弟子写回账本变成 150第二位弟子写回账本变成 130。最终账本上只有 130丢了 50。修真比喻对应到 CPU 视角balance balance amount这行 C 代码被编译器翻译成三条指令。load balance到寄存器add amount到寄存器store 寄存器回 balance两个线程同时跑这三条指令可能交错执行interleaving。交错顺序不同最终结果不同。这叫竞态条件race condition。共享内存多线程编程的根上所有 bug都来自这种看起来是一件事实际是三件事。修真比喻修真界里修炼密室是共享资源。多个弟子同时在密室里修炼灵识灵识会互相冲撞修为会乱套。戒律堂的职责就是让同一时刻只允许一位弟子在密室里。互斥锁 mutex阻塞式同步互斥锁mutexmutual exclusion是修真界最常用的兵器。规矩是进密室前先拿令牌令牌被拿走了就在门外睡阻塞令牌还回来时守门人叫醒一个在等的弟子。#includepthread.hintbalance0;pthread_mutex_tlockPTHREAD_MUTEX_INITIALIZER;voiddeposit(intamount){pthread_mutex_lock(lock);// 拿令牌没拿到就睡balancebalanceamount;// 临界区pthread_mutex_unlock(lock);// 还令牌叫醒一个等的人}修真比喻修炼密室门口站着守门人。守门人手里有一把钥匙。弟子想进密室先问守门人拿钥匙。拿到了进去修炼。守门人手里没钥匙被别人拿走了弟子就席地而睡阻塞。进密室的弟子修炼完出来还钥匙守门人摇醒睡着的某个弟子。锁的实现在 Linux 上走 futexfast userspace mutex机制。2002 年由 Linux 内核引入主要作者是 Franke、Kirkwood、Molnár 与 Russell。futex 的精髓是无竞争时完全在用户态跑碰上竞争才进内核。// pthread_mutex_lock 的简化伪代码voidmutex_lock(int*lock){// 第一阶段用户态原子尝试if(atomic_compare_exchange(lock,0,1))// CAS当前是 0 吗是就改成 1return;// 拿到了结束// 第二阶段进内核等待while(atomic_load(lock)!0)// 自旋检查一遍futex_wait(lock,1);// 没人抢就睡锁状态变了内核叫醒我// 被叫醒后再 CAS 一次while(!atomic_compare_exchange(lock,0,1));}修真比喻守门人手里其实有一面旗。无竞争时弟子在门口看一眼旗用户态读旗上写无人0就把旗翻成有人1然后直接进密室CAS 一次成功完全不用打扰守门人。竞争激烈时旗上写有人1弟子就去偏房睡觉进内核阻塞守门人记下有弟子在等。等里面的人出来把旗翻成 0解锁守门人就从偏房摇醒一个弟子。mutex 的阻塞对 CPU 友好不占 CPU 周期。所以 mutex 适合临界区比较长的情形毫秒级以上的操作比如文件读写和数据库事务。mutex 的代价线程阻塞要进内核要切栈和改调度队列。一次进内核出内核的开销是几微秒具体看内核版本和硬件。临界区短到几百纳秒时这笔开销反而成了主要成本。修真比喻守门人不能 24 小时盯着你。每次你进出都要登记和查玉牌进内核。修炼一炷香就出来临界区短登记换衣的时间比修炼还长。所以短活儿不要叫守门人自旋锁更合算。自旋锁 spinlock忙等式同步自旋锁spinlock规矩是进密室前先拿令牌令牌被拿走了就在门口转圈等busy-wait不睡觉。// Linux 内核自旋锁示例#includelinux/spinlock.hspinlock_tmy_lock;intshared_counter0;voidincrement(void){spin_lock(my_lock);// 拿锁转圈等shared_counter;// 临界区spin_unlock(my_lock);// 放锁}修真比喻修炼密室门口挂着一条红绳。红绳一端有令牌。弟子想进密室先拽红绳。令牌在没人用拽过来就进去。令牌被别的弟子拿着弟子就站在门口转圈走自旋眼睛一直盯着红绳那端。对方一放令牌弟子立刻拽过来冲进去。自旋锁的实现走 CPU 硬件原子的 test-and-set 指令// 自旋锁的简化伪代码x86 上对应 XCHG 或 LOCK 指令typedefstruct{intlocked;// 0 表示空闲1 表示被持有}spinlock_t;voidspin_lock(spinlock_t*lock){while(1){// test-and-set原子地读旧值 写 1intoldatomic_xchg(lock-locked,1);if(old0)// 旧值是 0空闲现在被我设成 1占用return;// 拿到了// 否则继续循环}}修真比喻对应每个红绳一端都贴着一张黄纸上面写无人或有人。弟子伸手把黄纸翻成有人的同时看一眼旧字test-and-set 是原子的。旧字是无人意味着没人在用弟子拿着黄纸进密室。旧字是有人意味着别人在用弟子站在原地继续翻黄纸自旋。自旋锁的几个细节第一x86 上 test-and-set 用XCHG指令或LOCK前缀的指令实现。Wikipedia 确认 x86 自 80486 起有CMPXCHG和XCHG等原子指令。ARM 上用LDREX/STREXLoad-Link / Store-Conditional实现。第二ticket lock 解决了自旋锁不公平的问题。原版自旋锁所有等的人一拥而上抢令牌可能导致某些弟子永远轮不上。ticket lock 改成发号排队的模式每个线程拿号ticket锁上记录当前服务号serving。serving my_ticket才进。Wikipedia 确认 Linux 内核 2008 年起在部分场景用 ticket lock 替代纯自旋锁强制 FIFO 行为。// ticket lock 伪代码typedefstruct{atomic_tnext_ticket;// 下一个要发的号atomic_tnow_serving;// 当前服务的号}ticket_lock_t;voidticket_lock(ticket_lock_t*lock){intmy_ticketatomic_fetch_add(lock-next_ticket,1);// 拿号while(atomic_load(lock-now_serving)!my_ticket)// 轮到我了吗;// 自旋等}voidticket_unlock(ticket_lock_t*lock){atomic_fetch_add(lock-now_serving,1);// 叫下一个号}修真比喻守门人手里有一本排队簿簿上写当前号。弟子来领号守门人在簿上记下这个号弟子记在自己的玉牌上。弟子盯着簿等当前号变成自己玉牌上的号自己再进。绝对公平先来先进。第三自旋锁适合临界区极短纳秒级的情形。锁等待时间短自旋的 CPU 浪费小。Linux 内核里中断处理就用自旋锁因为中断处理里不能睡眠。临界区长就改用 mutex。自旋锁的致命禁忌不能在用户态进程间随便用自旋锁。一个线程拿了自旋锁还没放被操作系统切走时间片到了另一个线程在自旋就白白烧 CPU如果只有这一颗核整个系统死锁持锁线程永远拿不回 CPU 跑完释放。Linux 内核里的自旋锁专门用于中断上下文或内核态不能睡眠的场合。CAS 原子操作无锁编程的基础CASCompare-And-Swap比较并交换是另一种兵器不用锁也能保证原子性。规矩是用探测术取代排队。// CAS 的逻辑伪代码boolCAS(int*addr,intexpected,intnew_value){if(*addrexpected){// 当前值符合预期*addrnew_value;// 是换成新值returntrue;// 成功}returnfalse;// 否失败}CAS 在 x86 上对应CMPXCHG指令。Wikipedia 确认 x86 自 80486 起有CMPXCHG多处理器上必须加LOCK前缀。这条指令由 CPU 硬件保证原子性不会被中断。修真比喻修炼密室不用门锁改用符箓。弟子进密室前先掐诀念符“若密室无人expected 符合则推门进入并设置占用标记new_value返回成功”“若有人不符合返回失败”。整个过程不阻塞任何人弟子只是不断念符-判定-再念符。无锁累加器的典型实现#includestdatomic.hatomic_int counter0;// C11 原子类型voidincrement(void){intoldatomic_load(counter);// atomic_compare_exchange_weak当前是 old 吗// 是改成 old1返回 true// 否把 old 更新为当前最新值返回 falsewhile(!atomic_compare_exchange_weak(counter,old,old1)){// old 已被自动更新继续循环}}修真比喻多位弟子要往同一个灵石账户存灵石。第一位弟子读账户读到 100念符若账户是 100则改成 101成功了。第二位弟子读账户也读到 100念符若账户是 100则改成 101失败了符箓把 old 自动更新成 101。第二位弟子再念符若账户是 101则改成 102这次成功。CAS 看着比锁优雅但有两个大坑。第一是 ABA 问题。修真比喻弟子 A 读账户读到 100弟子 B 把账户从 100 改成 101又改回 100。弟子 A 念符若账户是 100则改成 101成功账户变成 101。可是中间账户从 100 变 101 变 100 这一段B 可能存了又取了灵石A 没看到。这在指针操作里尤其致命指针指向的内存被释放又重新分配A 还以为是原来的对象。解法是加版本号用 64 位 CAS把版本号塞高 32 位。第二是 livelock活锁。所有线程都 CAS 失败不断重试CPU 跑满但没进展。修真比喻两位弟子同时去推门门只能容一人两人互相让路又同时进结果谁都进不去。解法是失败时随机 sleep 一小段时间再重试指数退避。CAS 适用的情形是临界区短、竞争不激烈的场合。竞争激烈时 CAS 失败率高所有线程都重试性能反而比锁差。无锁队列、无锁哈希表是 CAS 的经典用法。修真比喻修炼密室门口放一个签筒弟子抽签决定先后。抽到的签写着先就能进。所有人各抽各的不用排队。问题是大家同时抽到先就撞上了得多抽几次重试。内存屏障解决指令重排锁和 CAS 解决原子性但并发还有第二类问题可见性。修真比喻弟子 A 把灵石放进宝箱弟子 B 过了一会儿开箱看发现灵石不在。这是因为 A 的放动作还没同步到 B 看的内存视图上。更隐蔽的是第三类问题指令重排。CPU 和编译器都会重排指令以提高性能重排后单线程结果不变但多线程可能错乱。// 看似合理的双线程同步intready0;intdata0;// 线程 Adata42;// (1) 写数据ready1;// (2) 标记就绪// 线程 B自旋等待while(ready0);// (3) 等就绪print(data);// (4) 打印数据期望 42修真比喻弟子 A 把灵石放进宝箱步骤 1再贴一张已就绪的封条步骤 2。弟子 B 看见封条步骤 3就开箱取灵石步骤 4。问题出在 A 心里贴封条是小事先做也行后做也行。A 可能先贴封条再放灵石。B 看见封条开箱箱里是空的。修真比喻对应到硬件x86 是强内存模型TSO大多数写操作天然按顺序对其它核可见。但 ARM 和 POWER 是弱内存模型写操作可能乱序到达其它核。即使是 x86编译器也可能把data 42重排到ready 1之后单线程看没区别多线程就错了。内存屏障memory barrier就是破重排幻术的定心咒。Wikipedia 确认 memory barrier 又叫 memory fence。x86 上对应三条指令CPU 内存屏障SFENCEStore Fence屏障前的写对其它核可见LFENCELoad Fence屏障前的读按顺序执行MFENCEFull Fence读写都按顺序修真比喻弟子 A 想确保先放灵石再贴封条就在两步之间念一道封字诀memory barrier。这道诀的效果是写灵石的动作必须真真切切落到主存或对其它核可见才能开始写封条。CPU 内部的乱序执行单元看到这道诀前面的写就不能越过它。屏障分两类。第一类CPU 内存屏障hardware memory barrier / fence。x86 上的SFENCE、LFENCE、MFENCE都是。SFENCE保证屏障前的所有 store 在屏障后的 store 之前对其它核可见。LFENCE保证屏障前的所有 load 按程序顺序执行。MFENCE两边都管。ARM 上的DMBData Memory Barrier和DSBData Synchronization Barrier是对应物。第二类编译器屏障compiler barrier。编译器也会重排代码C 编译器处理时单线程语义不变就重排。GCC 上对应asm volatile( ::: memory)// 编译器屏障禁止编译器把屏障前后的内存操作重排#defineCOMPILER_BARRIER()__asm____volatile__(:::memory)data42;// (1)COMPILER_BARRIER();// 编译器不许把 (2) 排到 (1) 前面ready1;// (2)Wikipedia 提到 compiler barrier 跟 CPU memory barrier 不是一回事。Compiler barrier 只挡编译器的处理不让它重排不挡 CPU 的乱序执行。挡 CPU 乱序得用 CPU 屏障。修真比喻弟子 A 心里先放灵石再贴封条编译器心里的算盘觉得顺序无所谓就先贴封条再放灵石。COMPILER_BARRIER()是定心诀让算盘不许算这步。同样的CPU手也可能先贴封条再放灵石SFENCE/MFENCE是定手诀让手不许先贴。高级语言把这一切都封装好了。Java 5 之后volatile自带 release/acquire 语义。C11/C11 引入stdatomic.h/atomic提供六种内存序。最强的是memory_order_seq_cst模拟顺序一致性最弱的是memory_order_relaxed只保证原子性不保证顺序。memory_order_release和memory_order_acquire是最常用的两个前者用于写、后者用于读按需挑用。volatile 关键字的真相C/C 里的volatile是程序员最常误用的词。它的本意是告诉编译器这个变量随时可能被外部修改别精简。修真比喻弟子 A 在告示牌写灵石已入箱用的是一张会褪色的符纸volatile 变量。弟子 B 路过看符纸因为符纸颜色淡B 一开始没看见就读了个空值。B 的眼睛编译器觉得这符纸反正会变干脆只看一次于是永远看到空。volatile在 C/C 里只防止编译器精简不防止CPU 指令重排编译器屏障不等于 CPU 屏障多核缓存不一致A 核写了B 核的 L1 缓存可能还是旧值读操作被合并到寄存器不加 volatile 时编译器可能把读精简成读寄存器缓存Wikipedia 直接点出volatile在 C/C 里不提供内存序保证多线程同步不能用volatile当锁用。C 标准明确说明volatile的合法用途是访问硬件寄存器或与信号处理函数共享变量不用于线程同步。修真比喻volatile是挡心魔诀挡的是弟子自己心里的偷懒编译器精简。它挡不了外面的天魔CPU 重排和缓存不一致。要把所有天魔都挡住得用更高级的符箓Java 的volatileJVM 5 之后有 release/acquire 语义、C11/C11 的_Atomic/std::atomic。正确写法C11/C11#includestdatomic.hatomic_int ready0;// 原子变量自带内存屏障intdata0;// 线程 Adata42;atomic_store_explicit(ready,1,memory_order_release);// 写屏障// 线程 Bwhile(atomic_load_explicit(ready,memory_order_acquire)0);print(data);// 一定看到 42memory_order_release保证ready 1之前的写data 42不会被重排到ready 1之后。memory_order_acquire保证ready 1之后的读print(data)不会被重排到ready 1之前。两者配合形成 release-acquire 同步。修真比喻弟子 A 在写完灵石已入箱之前要先念一道封字诀release这道诀确保前面的写全完成。弟子 B 在看见灵石已入箱之后念一道开字诀acquire这道诀确保后面的读按顺序执行。两道诀合起来A 的写和 B 的读形成先来后到的因果链。选兵器什么时候用什么修真比喻收尾。线程安全兵器有四件挑哪件要看情形。情形兵器修真比喻临界区长毫秒级mutex阻塞守门人偏房弟子睡下不烧 CPU临界区短纳秒级spinlock忙等红绳转圈看弟子站着等简单计数器、标志位CAS 原子操作签筒抽签弟子各凭本事复杂数据结构mutex 包住整个操作整间密室只进一人单变量标志位_Atomic release/acquire符箓 封字诀 开字诀MMIOvolatile挡心魔诀访问外设寄存器修真比喻弟子练功要看情形。短兵器飞剑灵巧但杀不了重甲兵CAS 适合短临界区。长兵器长枪威力大但笨重mutex 适合长临界区。符箓是辅助内存屏障不能当主战兵器。volatile是练功时的护身符挡心魔不是杀招。修真比喻落到底层选 mutex 还是 spinlock看临界区长度和能否睡眠。Linux 内核中断上下文不能睡眠必须用 spinlock。用户态进程间一般用 pthread mutex。竞争激烈时 mutex 内部还是会先自旋adaptive mutex比如 glibc 的PTHREAD_MUTEX_ADAPTIVE_NP自旋几次还拿不到才进内核阻塞。修仙术语对照表修仙术语技术现实本篇位置修炼密室临界区互斥锁守门人mutex 实现互斥锁偏房futex 等待队列互斥锁红绳转圈等自旋锁忙等自旋锁黄纸翻面test-and-set 指令自旋锁排队簿ticket lock自旋锁签筒抽签CAS 原子操作CAS符箓念诀CAS 探测CASABA 偷梁换柱ABA 问题CAS活锁堵门livelockCAS封字诀release 屏障内存屏障开字诀acquire 屏障内存屏障定心诀compiler barrier内存屏障定手诀CPU memory fence内存屏障挡心魔符volatile词volatile真符箓_Atomic/std::atomicvolatile进阶条件看完这一篇到能向别人讲清多线程共享资源怎么不出乱子差这几条能讲清为什么balance balance 1不是原子的编译器翻译成 load-add-store 三条指令会被中断能讲清互斥锁和自旋锁的区别mutex 阻塞让出 CPUspinlock 忙等占 CPU能讲清 futex 机制的精髓无竞争时完全在用户态竞争时才进内核能讲清 ticket lock 解决的是什么问题自旋锁的不公平能讲清 CAS 的两个坑ABA 问题、活锁能讲清 CPU 内存屏障和编译器屏障的区别前者挡硬件重排后者挡编译重排能讲清 x86 三条内存屏障指令的区别SFENCE/LFENCE/MFENCE 各自管什么能讲清为什么 C/C 的volatile不能用于线程同步不保证内存序、不保证多核可见能讲清 C11/C11 的 release/acquire 语义怎么用最后一条是金丹期对线程安全的分水岭。面试里被问volatile能不能当锁用能直接说出不能volatile 只挡编译器精简不挡 CPU 重排C11 之后用_Atomic release/acquire这一关就过了。下期预告 互动上一篇把文件系统收掉了这一篇把线程安全拆开讲。可修真界里还有一件大事弟子们收完外设通知中断之后谁来处理善后多大动静由谁来干。下一篇围绕中断下半部展开把下半部那三件兵器软中断、Tasklet、Workqueue先拆开看再讲什么时候用哪件。看完了再有人问我的网卡中断为什么把 CPU 跑满你能直接答出来。现在问你 写一段两个线程竞争counter的代码编译跑一下循环十万次看结果是不是二十万。结果不是的话试试用pthread_mutex包住看结果变成二十万。再把pthread_mutex换成atomic_fetch_add(counter, 1)看结果也对。这三个版本哪个最快为什么评论区聊聊你的实测结果。⚙️ 调一下你的 Linux 内核编译参数里加上-O3再编一份默认编译的。对比两份二进制对volatile变量的处理用objdump -d看反汇编。volatile那份会真的每次都去内存取值-O3那份可能把读精简到寄存器。这是volatile唯一确定的副作用挡编译器精简挡不住其它东西。评论区聊聊你跟线程安全打过的交道。我是玄芯散人带你从炼气修到大乘。本文是「码农修仙传」系列第73篇。系列导航见 xren.ren

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑