
1. 为什么这两个词总被放在一起比较但凡写过几年并发程序或者维护过稍微复杂一点的多线程服务几乎都绕不开“自旋锁”和“信号量”这两个词。面试里问代码评审里吵线上出问题排查时也经常发现根子就在这俩东西的选型上。我见过太多项目明明该用信号量的地方硬塞了个自旋锁结果CPU空转烧到90%以上也见过该用自旋锁的极短临界区非要走一遍信号量的睡眠唤醒流程白白多出几微秒的上下文切换开销。这篇文章想干的事很明确把自旋锁和信号量从概念、实现原理、适用场景到实际选型掰开揉碎讲清楚。不是那种“自旋锁是忙等待信号量是睡眠等待”一句话带过的科普而是从底层机制出发解释为什么它们会有不同的行为特征以及在真实项目里怎么判断该用哪个。适合已经接触过并发编程、但在这两个同步原语的选型上还拿不准的开发者也适合想从原理层面彻底搞懂它们区别的读者。核心关键词就两个自旋锁和信号量。但围绕它们展开的是临界区长度、上下文切换成本、CPU占用、优先级反转、中断上下文限制等一系列实际问题。我会尽量用生活化的类比配合代码层面的细节让不同基础的读者都能找到自己需要的那一层信息。先说一个我自己的判断标准后面会反复用到临界区里干的活如果耗时比一次线程上下文切换还短那自旋大概率划算如果明显更长或者临界区内可能发生阻塞那信号量才是正解。这个判断不是拍脑袋来的后面会用具体数据说明为什么。2. 从底层机制理解两者的本质差异2.1 自旋锁到底在“自旋”什么自旋锁的核心逻辑非常朴素一个线程尝试获取锁如果锁已经被别人拿走了它不睡觉而是原地反复检查锁的状态直到锁被释放。这个“反复检查”的动作就是“自旋”。用生活场景类比你去洗手间发现门锁着自旋锁的做法是你站在门口一直拧门把手每隔几秒拧一次直到里面的人出来。你不离开不休息就耗在门口。从实现角度看自旋锁通常依赖一个原子变量比如一个整型标志位获取锁的操作是一次原子比较并交换Compare-And-SwapCAS。伪代码大概长这样while (!atomic_compare_exchange_weak(lock, 0, 1)) { // 自旋什么都不做或者执行CPU的pause/yield指令 } // 拿到锁进入临界区 // ... // 释放锁 atomic_store(lock, 0);这里有个关键细节现代CPU在自旋等待时通常会执行一条特殊的指令比如x86架构下的PAUSE指令。这条指令的作用不是“暂停”而是告诉CPU“我在自旋等待”让CPU优化自己的流水线和功耗管理。没有这条指令的自旋锁在超线程场景下可能会严重拖慢同物理核心上另一个线程的执行速度。这是很多手写自旋锁容易忽略的点。自旋锁的另一个重要特征是它不涉及操作系统调度器的介入。整个等待过程完全在用户态完成不会发生线程状态切换不会触发系统调用。这是它快的原因也是它危险的原因——如果锁一直拿不到这个线程就会一直占着CPU不放。2.2 信号量的“信号”是怎么传递的信号量的机制完全不同。它内部维护一个计数器表示当前可用的资源数量。线程尝试获取信号量时如果计数器大于零就减一并继续执行如果计数器等于零线程就被放入等待队列然后主动让出CPU进入睡眠状态。当另一个线程释放信号量时计数器加一并从等待队列中唤醒一个线程。还是用洗手间的类比信号量的做法是你走到门口发现门锁着你去旁边的等候区坐下告诉管理员“有人出来了叫我”。里面的人出来时管理员会来叫你。你不耗在门口但你需要管理员这个中间人而且从被叫醒到走到门口需要时间。信号量的核心操作通常由操作系统内核提供比如POSIX信号量sem_wait和sem_post。sem_wait在计数器为零时会触发系统调用把当前线程挂起sem_post在释放时会检查等待队列必要时唤醒一个线程。这个唤醒过程涉及内核调度器成本远高于一次CAS操作。这里要区分两种信号量计数信号量和二元信号量。计数信号量的计数器可以大于1用于管理多个同类资源二元信号量的计数器只有0和1行为上类似互斥锁但机制不同。很多人把二元信号量当互斥锁用这本身没问题但要注意它和自旋锁在等待行为上的本质区别。2.3 一张表看清核心差异对比维度自旋锁信号量等待方式忙等待占用CPU睡眠等待让出CPU上下文切换无有涉及内核调度适用临界区长度极短通常小于两次上下文切换时间可长可短长临界区更合适中断上下文可用性可用需关中断变体不可用可能睡眠优先级反转风险有且无法通过调度缓解有但可通过优先级继承缓解实现层级通常用户态原子指令通常内核系统调用获取失败时的CPU行为持续消耗CPU释放CPU给其他线程典型获取延迟纳秒级微秒级含系统调用这张表是后面所有讨论的基础。理解每一行的“为什么”比记住结论更重要。3. 临界区长度选型的第一决定因素3.1 上下文切换到底有多贵要判断“临界区够不够短”首先得知道参照物——一次线程上下文切换的成本是多少。这个数字因硬件、操作系统和负载而异但量级上可以这样估算在主流x86服务器上一次线程上下文切换包括保存寄存器、切换栈、更新调度器数据结构、可能的内核态到用户态往返大约在1到5微秒之间。如果涉及跨NUMA节点或者缓存冷启动可能更高。而一次自旋锁的CAS操作在无竞争情况下大约10到20纳秒有竞争但很快获得的情况下也在百纳秒量级。也就是说一次上下文切换的时间足够执行几十到几百次CAS操作。这就是“临界区比上下文切换短就用自旋”这个经验法则的量化依据。但这里有个容易踩的坑很多人只比较了“临界区执行时间”和“上下文切换时间”却忽略了信号量在无竞争情况下的开销。实际上如果信号量没有竞争sem_wait可能只需要一次原子减操作不一定触发系统调用取决于实现比如glibc的POSIX信号量在快速路径上就是用户态原子操作。所以真正该比较的是有竞争时自旋等待的预期浪费 vs 睡眠唤醒的确定开销。3.2 什么算“极短”临界区结合上面的数据我通常把临界区分为三档极短临界区几十到几百纳秒比如修改一个计数器、更新一个标志位、往无锁队列里推一个节点。这类操作用自旋锁等待时间通常远小于一次上下文切换。中等临界区几微秒到几十微秒比如遍历一个小型链表、做一次简单的内存拷贝。这时候要看竞争概率低竞争下自旋可能仍可接受高竞争下信号量更稳。长临界区几十微秒以上比如涉及I/O操作、复杂计算、可能阻塞的调用。必须用信号量自旋会导致CPU严重浪费。我见过一个反面案例某服务在自旋锁保护的临界区里做了一次日志落盘操作。日志落盘涉及文件I/O耗时动辄几毫秒。结果在高并发下大量线程在自旋锁上空转CPU使用率飙升到接近100%但有效吞吐反而下降。把自旋锁换成信号量后CPU降到30%左右吞吐提升了两倍多。这个案例说明临界区里只要有I/O或任何可能阻塞的操作自旋锁就是错误选择。3.3 竞争概率的隐藏影响临界区长度不是唯一因素竞争概率同样关键。即使临界区很短如果大量线程同时争抢同一把锁自旋的浪费也会累积。假设临界区执行时间为T锁被持有的概率为p自旋一次检查耗时为c。那么一个线程获取锁的预期自旋次数大约是p/(1-p)量级。当p接近1时自旋次数急剧上升。这时候即使T很小总的自旋浪费也可能超过信号量的睡眠唤醒成本。所以更准确的判断方式是当“临界区时间 × 平均等待线程数”明显小于“上下文切换时间”时自旋锁占优否则信号量更合适。这个乘积可以理解为“自旋浪费的期望上限”。4. 使用场景与限制条件深度拆解4.1 中断上下文自旋锁的专属领地中断处理程序包括硬中断和软中断运行在特殊的上下文中不允许睡眠。这意味着在中断上下文里信号量是绝对不能用的——你无法让中断处理程序去睡眠等待因为中断上下文没有对应的任务结构可供调度器挂起。自旋锁在中断上下文里可以用但必须使用关中断变体。原因很简单如果中断处理程序尝试获取一个已经被普通线程持有的自旋锁而那个线程又恰好被中断打断就会形成死锁——线程等着中断处理完才能释放锁中断等着线程释放锁才能继续。关中断变体如spin_lock_irqsave在获取锁之前先关闭本地CPU中断避免这种死锁。这个限制直接决定了任何可能被中断上下文访问的共享数据只能用自旋锁保护不能用信号量。这是硬性约束没有商量余地。4.2 睡眠限制信号量的边界反过来信号量的使用场景有一个明确边界只能在允许睡眠的上下文里使用。进程上下文普通线程可以睡眠所以可以用信号量中断上下文、软中断上下文、以及某些内核中的原子上下文不能用。在用户态编程中这个限制通常不是问题因为用户态线程都可以睡眠。但在内核模块开发或者驱动开发中这个边界就非常关键。我见过有开发者在定时器回调里用信号量结果触发内核警告甚至崩溃。定时器回调运行在软中断上下文不允许睡眠。4.3 优先级反转两者都有但缓解手段不同优先级反转是指高优先级线程等待一个被低优先级线程持有的锁而低优先级线程又被中优先级线程抢占导致高优先级线程实际上被中优先级线程“间接阻塞”。自旋锁和信号量都可能发生优先级反转。区别在于缓解手段信号量可以配合优先级继承机制。当高优先级线程等待低优先级线程持有的信号量时低优先级线程临时继承高优先级避免被中优先级线程抢占。POSIX的pthread_mutex支持优先级继承属性。自旋锁通常不提供优先级继承因为自旋锁的持有时间极短设计假设是“等一会儿就拿到了”。如果自旋锁持有时间真的长到足以引发严重优先级反转那本身就是选型错误。这里有个经验在实时系统里如果必须用锁且存在优先级反转风险优先考虑支持优先级继承的互斥量二元信号量的一种实现而不是自旋锁。但前提是临界区允许睡眠。4.4 多核与单核的差异自旋锁在多核系统上才有意义。单核系统上如果一个线程自旋等待另一个线程释放锁而那个线程根本没有机会运行因为自旋线程占着CPU就会死锁。所以单核系统上的自旋锁实现通常会在自旋时主动让出CPU或者干脆禁用自旋。在多核系统上自旋锁的等待线程和持有线程可以真正并行运行自旋才有意义。这也是为什么自旋锁的适用性和CPU核心数密切相关。核心数越多自旋锁的收益越明显核心数少或者竞争激烈时信号量反而更稳。5. 实操选型与代码层面的关键细节5.1 一个可复用的选型决策流程结合前面的分析我整理了一个实际项目中可以直接套用的决策流程临界区是否可能阻塞I/O、内存分配、可能睡眠的调用是则必须用信号量自旋锁直接排除。是否在中断上下文或不允许睡眠的上下文是则必须用自旋锁关中断变体信号量直接排除。临界区执行时间是否明显小于一次上下文切换约1-5微秒是则倾向自旋锁。竞争是否激烈等待线程数经常大于CPU核心数是则倾向信号量即使临界区短。是否需要优先级继承来避免优先级反转是则倾向支持优先级继承的互斥量。以上都不明确时默认用信号量/互斥量。因为它的行为更可预测不会因为临界区意外变长而导致CPU灾难。这个流程的核心思想是先用硬性约束排除不可能选项再用性能特征做倾向性选择最后用保守默认兜底。5.2 自旋锁的实操要点如果确定用自旋锁有几个细节必须注意第一临界区里绝对不能有阻塞操作。包括但不限于文件读写、网络调用、内存分配某些分配器可能加锁、打印日志可能加锁或I/O、调用可能睡眠的函数。这些操作一旦放进自旋锁临界区轻则性能暴跌重则死锁。第二自旋锁的持有时间要尽可能短。我通常建议把临界区压缩到只包含必要的共享数据访问其他准备工作放在锁外完成。比如需要更新一个结构体先在锁外把新值算好进锁后只做一次赋值。第三注意自旋锁的公平性。基础的自旋锁不保证公平可能出现某个线程一直抢不到锁的情况饥饿。如果需要公平性可以考虑票据自旋锁ticket spinlock它按先来后到分配锁避免饥饿。但票据锁的获取路径稍长需要权衡。第四关中断变体的选择。在Linux内核里spin_lock、spin_lock_irq、spin_lock_irqsave的区别在于是否关中断以及是否保存中断状态。如果确定中断在锁持有期间不会访问同一把锁可以用最轻量的spin_lock如果不确定用spin_lock_irqsave最安全但开销稍大。5.3 信号量的实操要点用信号量时同样有几个容易踩的坑第一注意信号量的初始值。计数信号量的初始值决定了同时允许多少个线程进入临界区。如果本意是互斥初始值必须是1。我见过把初始值设成0导致所有线程都阻塞的案例也见过设成大于1导致互斥失效的案例。第二sem_wait和sem_post必须配对。每次成功sem_wait后必须在所有路径上包括错误路径执行sem_post否则信号量计数会泄漏最终导致其他线程永久阻塞。用RAII风格的封装比如C的std::lock_guard配合自定义信号量包装可以大幅降低这种风险。第三注意信号量的销毁时机。如果有线程正在等待一个信号量此时销毁它行为是未定义的。必须确保所有等待线程都已退出或已被唤醒才能销毁信号量。第四命名信号量和匿名信号量的选择。匿名信号量用于同一进程内的线程同步命名信号量可以跨进程使用。跨进程场景下命名信号量的创建、打开、关闭、删除都有额外的注意事项比如权限设置和残留清理。5.4 混合场景读写锁与自旋锁的结合实际项目里经常遇到“读多写少”的场景。这时候单纯的互斥锁或信号量可能不是最优解读写锁rwlock更合适。读写锁允许同时多个读者进入但写者独占。读写锁的实现通常结合了自旋和睡眠读锁的获取可能用自旋因为读操作通常短写锁的获取可能用睡眠因为写操作可能较长。Linux内核里的rwlock_t和用户态的pthread_rwlock_t在实现策略上有所不同选择时要看具体平台的文档和实测数据。我的一般建议是如果读操作极短且读远多于写考虑自旋读写锁如果读写操作都可能较长用睡眠读写锁。但读写锁本身也有优先级反转和写者饥饿的问题使用前要确认实现是否提供了公平性保证。6. 常见问题与排查技巧实录6.1 自旋锁导致的CPU飙高怎么排查现象服务响应变慢CPU使用率异常升高但吞吐没有相应提升。排查思路用性能分析工具如perf top查看CPU时间花在哪里。如果大量时间花在自旋指令或CAS操作上基本可以确定是自旋锁竞争。检查自旋锁保护的临界区是否包含意外操作。常见的是日志打印、内存分配、甚至另一个锁的获取。检查竞争是否过于激烈。如果等待线程数远超核心数考虑换信号量或减小锁粒度。我遇到过一次perf top显示__pthread_mutex_lock占用大量CPU但代码里用的是自旋锁。后来发现是自旋锁在竞争激烈时内部退化成了系统调用某些实现会在自旋一定次数后让出CPU本质上变成了低效的信号量。换成原生信号量后问题消失。6.2 信号量导致的响应延迟怎么定位现象服务吞吐正常但个别请求延迟很高呈现长尾分布。排查思路检查是否有线程在信号量上等待过久。可以用strace跟踪sem_wait的调用耗时或者用性能分析工具查看调度延迟。检查信号量的持有者是否在临界区内做了耗时操作。信号量本身不限制临界区长度但过长的临界区会导致其他线程等待过久。检查是否存在优先级反转。如果高优先级线程等待低优先级线程释放信号量而低优先级线程被中优先级线程抢占就会出现长尾延迟。用支持优先级继承的互斥量可以缓解。6.3 常见问题速查表问题现象可能原因排查手段解决方向CPU高但吞吐低自旋锁竞争激烈或临界区过长perf top查看自旋占比换信号量或缩短临界区请求延迟长尾信号量等待过久或优先级反转跟踪sem_wait耗时、检查调度缩短临界区、启用优先级继承系统卡死无响应自旋锁死锁中断上下文检查是否用了非关中断变体改用关中断变体信号量计数泄漏sem_wait/sem_post未配对代码审查、计数监控用RAII封装、确保所有路径释放单核上自旋锁失效单核自旋导致死锁确认CPU核心数单核禁用自旋或改用信号量6.4 几个反直觉的实操经验经验一自旋锁不一定比信号量快。在无竞争或极低竞争下自旋锁确实快。但在高竞争下自旋锁的缓存一致性流量多个核心反复争抢同一缓存行可能成为瓶颈此时信号量的睡眠唤醒反而更高效。我实测过一个场景8核机器上16个线程争抢一把自旋锁吞吐只有信号量版本的60%。经验二信号量的系统调用开销可以被摊销。如果临界区操作本身耗时较长比如几十微秒那么信号量的系统调用开销几微秒占比就很小了。这时候纠结“信号量比自旋锁慢几微秒”没有意义应该关注临界区本身的优化。经验三混合使用有时是最优解。比如用一个自旋锁保护一个短小的状态标志用一个信号量保护实际的数据操作。线程先自旋获取状态标志判断是否需要进入慢路径需要时再走信号量。这种分层设计在很多高性能框架里都能看到。经验四测试环境的核心数和生产环境不一致会导致选型误判。在4核开发机上自旋锁表现良好上了64核生产机可能因为缓存一致性流量暴增而性能崩塌。选型时一定要在接近生产的硬件配置上做压力测试。7. 从代码到线上一个完整的选型复盘7.1 场景描述假设有一个共享的统计计数器模块多个线程需要频繁更新计数偶尔需要读取汇总值。更新操作就是简单的加法读取操作需要遍历所有计数器求和。7.2 初始方案与问题最初方案用一把自旋锁保护整个计数器数组。更新时获取自旋锁做加法释放。读取时获取自旋锁遍历求和释放。问题很快出现读取操作遍历整个数组耗时随计数器数量线性增长。当计数器有几百个时读取临界区达到几十微秒。在高频更新场景下读取线程持有自旋锁期间大量更新线程在自旋等待CPU飙升。7.3 优化方案优化分两步第一步把更新和读取分开保护。更新用自旋锁保护单个计数器每个计数器一把锁或一组锁读取用信号量保护一个快照机制。读取时先获取信号量复制一份计数器快照释放信号量后在锁外做求和。这样读取的临界区缩短为一次内存拷贝。第二步更新路径进一步优化为原子操作。如果计数器是64位对齐的直接用原子加法完全不需要锁。只有在需要读取一致快照时才用信号量协调。7.4 优化效果与经验优化后更新路径几乎无锁CPU占用大幅下降读取路径的临界区从几十微秒缩短到几微秒信号量等待显著减少。整体吞吐提升约3倍。这个案例的经验是不要一开始就纠结自旋锁还是信号量先看能不能消除锁。很多同步问题通过原子操作、无锁数据结构、快照机制可以绕过锁的选择难题。锁的选型是最后一步不是第一步。7.5 选型检查清单在实际项目中做锁选型时我会过一遍这个清单临界区是否可能阻塞是则排除自旋锁。是否在中断或原子上下文是则排除信号量。临界区执行时间是否小于1微秒是则倾向自旋锁。竞争线程数是否经常超过CPU核心数是则倾向信号量。是否需要优先级继承是则倾向互斥量。是否可以用原子操作或无锁结构替代是则优先考虑。是否有现成的、经过验证的同步原语可用是则优先使用避免手写。这个清单不能保证100%正确但能避免绝大多数明显错误。剩下的就是实测调优了。8. 写在最后的一些个人体会自旋锁和信号量的选择本质上是在“CPU时间”和“调度延迟”之间做权衡。自旋锁用CPU时间换低延迟信号量用调度延迟换CPU效率。没有绝对的好坏只有适不适合当前场景。我自己的习惯是默认用信号量或互斥量只有在性能分析明确指向锁开销、且确认临界区极短无阻塞时才换成自旋锁。这个保守策略让我避免了很多因为自旋锁滥用导致的线上事故。自旋锁是一把锋利的刀用得好能提升性能用不好会伤到自己。另外不要迷信任何“最佳实践”。不同的硬件、不同的内核版本、不同的负载特征都可能改变选型结论。唯一可靠的方法是理解原理明确约束然后在真实环境里实测。实测数据比任何经验法则都可靠。最后分享一个排查锁问题的小技巧如果怀疑是锁竞争导致的性能问题先用perf或类似工具确认CPU时间确实花在锁操作上再去看临界区代码。不要一上来就改锁的类型先确认问题真的出在锁上。很多时候性能问题根因是临界区里的意外操作换锁只是治标不治本。