自旋锁与信号量:从原理到选型,彻底搞懂忙等与睡眠机制

发布时间:2026/10/12 21:28:01
自旋锁与信号量:从原理到选型,彻底搞懂忙等与睡眠机制 1. 为什么这两个词总被放在一起比较但凡写过几年多线程代码或者啃过操作系统内核源码的人几乎都绕不开自旋锁和信号量这两个词。面试被问代码review被问排查线上性能问题时还是绕不开它们。但很多人对它们的理解停留在“一个忙等、一个睡觉”这种一句话层面真到了要选型的时候心里还是没底。这篇文章想做的事情很纯粹把自旋锁和信号量这两个东西从原理到实操彻底掰开揉碎讲清楚它们各自适合什么场景、为什么适合、用错了会出什么问题。不管你是刚接触并发编程的新手还是已经写过一些内核模块的老手都能从中找到对自己有用的东西。我会尽量用生活化的类比来解释底层机制同时给出可以直接参考的代码示例和参数选择依据。核心关键词会贯穿全文自旋锁、信号量、忙等、上下文切换、临界区、原子操作、睡眠、中断上下文。这些词不是拿来堆砌的每一个都对应着实实在在的工程决策。先给一个最粗的轮廓自旋锁是“我就在门口转圈等你出来”信号量是“我去旁边椅子上眯一会儿你出来了叫我”。这个类比虽然粗糙但已经能解释它们80%的行为差异。剩下的20%恰恰是最容易踩坑的地方也是这篇文章重点要讲的部分。2. 从底层原理看两者的本质差异2.1 自旋锁的忙等机制到底在忙什么自旋锁的核心逻辑非常朴素一个线程尝试获取锁如果锁已经被别人拿走了它不会放弃CPU而是不停地循环检查锁的状态直到锁被释放。这个“不停地循环检查”就是所谓的忙等busy-waiting。用伪代码表示大概是这样while (atomic_test_and_set(lock) LOCKED) { // 什么都不做继续循环 } // 拿到锁了进入临界区这里的关键在于atomic_test_and_set是一个原子操作它把“检查锁状态”和“设置锁状态”合并成一个不可分割的动作。如果没有原子性保证两个线程可能同时看到锁是空闲的然后同时认为自己拿到了锁这就彻底乱套了。原子操作在硬件层面通常依赖CPU提供的特殊指令比如x86架构上的LOCK CMPXCHG指令ARM架构上的LDREX/STREX指令对。这些指令保证了在多核环境下同一时刻只有一个核心能成功地修改锁的状态。忙等的代价是什么答案是CPU周期。在自旋期间这个核心什么正事都干不了纯粹在烧电。如果锁被持有的时间很短比如几十个时钟周期那这种烧电是可以接受的因为省下了上下文切换的开销。但如果锁被持有几毫秒甚至更久那自旋就是在浪费生命。注意自旋锁在单核CPU上的行为需要特别小心。如果在单核上自旋等待一个永远不会释放的锁比如持有锁的线程被调度出去了那就是死锁。所以很多内核实现会在单核上把自旋锁退化成“关中断空操作”但这属于实现细节不是所有平台都这么做。2.2 信号量的睡眠唤醒机制是怎么运作的信号量的思路完全不同。它内部维护一个计数器当计数器大于0时线程可以获取信号量并把计数器减1当计数器等于0时线程会被放入一个等待队列然后主动让出CPU进入睡眠状态。等到其他线程释放信号量时计数器加1并从等待队列中唤醒一个线程。用伪代码表示// 获取信号量 if (sem-count 0) { sem-count--; } else { add_to_wait_queue(current_thread); schedule(); // 主动让出CPU进入睡眠 } // 释放信号量 sem-count; if (wait_queue_not_empty()) { wake_up_one_thread(); }这里的核心是上下文切换。当一个线程因为获取不到信号量而睡眠时操作系统会保存它的执行现场寄存器状态、程序计数器等然后调度另一个就绪的线程上CPU执行。等信号量可用时再恢复之前那个线程的现场让它继续执行。上下文切换的代价是实打实的。一次完整的上下文切换包括保存和恢复寄存器、刷新TLBTranslation Lookaside Buffer、可能还要更新缓存在典型的x86服务器上大约需要几微秒。听起来不多但如果你每秒要做几十万次信号量操作这个开销就非常可观了。信号量的计数器可以是任意正整数这意味着它可以允许多个线程同时进入临界区。计数器初始值设为1时它就退化成了互斥锁mutex的语义。计数器设为N时就允许N个线程并发访问某个资源池。2.3 一张表看清核心差异对比维度自旋锁信号量等待方式忙等占用CPU睡眠让出CPU上下文切换无有每次等待/唤醒各一次适用等待时长极短纳秒到微秒级任意时长能否在中断上下文使用可以不可以中断上下文不能睡眠持有期间能否睡眠绝对不可以可以实现复杂度低依赖原子指令高需要调度器配合多核扩展性好但争抢激烈时缓存行 bouncing 严重好等待队列分散了争抢公平性通常不保证可以做到近似公平这张表里的每一行都值得展开说尤其是“持有期间能否睡眠”和“中断上下文”这两条它们是实际工程中最容易出问题的地方。3. 选型时到底该看哪些指标3.1 临界区执行时间是最关键的判断依据选自旋锁还是信号量第一个要问的问题就是临界区里的代码大概要跑多久如果临界区只有几条指令比如修改一个计数器、更新一个标志位那执行时间可能在几十纳秒级别。这种情况下上下文切换的开销几微秒比临界区本身执行时间还长用信号量就是亏本买卖。自旋锁虽然浪费了一些CPU周期在等待上但省下了上下文切换总体更划算。如果临界区要执行文件I/O、内存分配、或者任何可能阻塞的操作那执行时间可能在毫秒甚至秒级。这时候自旋就是在烧CPU必须用信号量让线程睡眠把CPU让给其他有用的工作。有一个经验法则如果临界区的执行时间超过两次上下文切换的开销就应该考虑用信号量。两次上下文切换是因为一次用于睡眠一次用于唤醒。在典型的Linux系统上这个阈值大约是几微秒。但这个法则不是绝对的。还要考虑锁的争抢程度。如果锁几乎从不争抢那用什么都无所谓。如果锁争抢非常激烈即使临界区很短自旋锁也可能导致大量CPU周期浪费在缓存行同步上。3.2 上下文切换的真实成本计算很多人知道上下文切换“有开销”但说不清到底有多大。我来给一个粗略的估算过程。一次上下文切换的主要开销包括保存当前线程的通用寄存器约16-32个寄存器每个寄存器一次写内存操作保存浮点/SIMD寄存器如果使用了可能额外32-64个寄存器更新线程控制块TCB状态选择下一个要运行的线程调度器开销恢复下一个线程的寄存器刷新TLB如果新线程的地址空间不同TLB需要刷新这会导致后续内存访问变慢缓存污染新线程的数据会挤占旧线程在L1/L2缓存中的热数据把这些加起来在典型的x86-64服务器上一次上下文切换的直接开销大约在2-5微秒。如果考虑缓存和TLB的间接影响实际影响可能更大。相比之下一次自旋锁的原子操作比如LOCK CMPXCHG在无争抢时大约需要20-40个时钟周期在3GHz的CPU上约合7-13纳秒。即使自旋几百次总开销也可能只有几微秒。所以结论很清晰临界区越短自旋锁的优势越明显临界区越长信号量的优势越明显。3.3 中断上下文这个硬约束有一条铁律在中断上下文包括软中断、tasklet、定时器回调等中只能使用自旋锁不能使用信号量。原因很简单中断上下文不是普通的线程上下文它没有对应的task_struct不能被调度器调度。如果在中段上下文中调用可能睡眠的函数比如信号量的down操作整个内核会崩溃或者进入未定义状态。这也是为什么内核代码里大量的锁是自旋锁——因为很多代码路径可能在中断上下文中被执行。如果你不确定一段代码是否会在中断上下文中运行最安全的选择是自旋锁配合关中断。但反过来如果确定代码只在进程上下文中运行而且临界区可能较长那信号量就是更好的选择。实操心得我曾经遇到过一个bug在一个看起来是进程上下文的函数里用了信号量结果这个函数被一个定时器回调间接调用了导致内核在中断上下文中睡眠触发了“scheduling while atomic”的警告。排查了很久才发现调用链。所以搞清楚代码的执行上下文比选什么锁更重要。4. 自旋锁的实操细节与避坑指南4.1 自旋锁的基本用法与变体在Linux内核中自旋锁的基本API是spinlock_t my_lock; spin_lock_init(my_lock); spin_lock(my_lock); // 临界区 spin_unlock(my_lock);但实际使用中你还需要考虑中断的影响。如果临界区可能被中断处理程序访问那在获取自旋锁之前必须关中断unsigned long flags; spin_lock_irqsave(my_lock, flags); // 临界区 spin_unlock_irqrestore(my_lock, flags);spin_lock_irqsave会保存当前的中断状态并关中断spin_unlock_irqrestore会恢复之前的中断状态。为什么要保存而不是直接开中断因为你可能在一个已经关中断的上下文中被调用直接开中断会破坏外层的假设。还有spin_lock_bh它关闭的是软中断bottom half适用于临界区可能被软中断访问的情况。选择哪个变体取决于你的临界区可能被哪些上下文访问。API变体关闭中断关闭软中断适用场景spin_lock否否临界区只在进程上下文访问spin_lock_bh否是临界区可能被软中断访问spin_lock_irq是是临界区可能被硬中断访问spin_lock_irqsave是保存状态是同上但需要恢复原状态4.2 自旋锁的公平性与饥饿问题标准的自旋锁不保证公平性。也就是说一个线程可能运气不好每次它尝试获取锁的时候锁都被别人抢走了导致它长时间自旋。在极端情况下这个线程可能饿死。Linux内核从某个版本开始引入了排队自旋锁queued spinlock它在保持自旋锁高性能的同时提供了近似公平的语义。排队自旋锁的原理是每个等待的CPU在一个队列中排队每个CPU在自己的本地变量上自旋而不是所有人都在同一个全局变量上自旋。当前一个CPU释放锁时它会把锁传递给队列中的下一个CPU。这样做的好处有两个一是减少了缓存行争抢每个CPU自旋的是自己的本地变量二是保证了公平性先到先得。注意排队自旋锁虽然好但在某些架构上可能不可用或者需要特定的配置。如果你在写可移植的代码不要假设自旋锁是公平的。4.3 自旋锁持有期间绝对不能做的事自旋锁持有期间有几件事是绝对禁止的第一不能睡眠。包括不能调用kmalloc(GFP_KERNEL)、不能调用copy_from_user、不能调用任何可能阻塞的函数。因为一旦睡眠CPU会被调度去执行其他线程而锁还被你持有其他等待这个锁的CPU就会一直自旋直到天荒地老。第二不能递归获取。自旋锁不是可重入锁同一个CPU重复获取同一个自旋锁会导致死锁。如果你需要递归语义得自己用计数器实现。第三不能长时间持有。即使临界区不睡眠如果执行时间太长比如遍历一个很大的链表其他CPU会一直自旋等待浪费大量CPU周期。第四不能调用可能获取其他锁的函数除非你非常清楚锁的顺序。自旋锁之间如果形成循环等待就会死锁。4.4 一个真实的自旋锁误用案例假设有一个内核模块维护一个全局链表用自旋锁保护。某个函数需要遍历链表并删除符合条件的节点spin_lock(list_lock); list_for_each_entry_safe(pos, n, head, list) { if (need_delete(pos)) { list_del(pos-list); kfree(pos); // 这里可能睡眠 } } spin_unlock(list_lock);kfree在大多数情况下不会睡眠但如果开启了内存调试或者使用了特定的分配器kfree可能触发睡眠。更安全的方式是把要删除的节点先移到本地链表释放锁之后再统一kfreeLIST_HEAD(to_free); spin_lock(list_lock); list_for_each_entry_safe(pos, n, head, list) { if (need_delete(pos)) { list_del(pos-list); list_add(pos-list, to_free); } } spin_unlock(list_lock); // 锁外释放 while (!list_empty(to_free)) { pos list_first_entry(to_free, struct my_node, list); list_del(pos-list); kfree(pos); }这个模式在内核代码中非常常见核心思想就是把可能睡眠的操作移到临界区之外。5. 信号量的实操细节与避坑指南5.1 信号量的类型与选择Linux内核提供了两种信号量计数信号量和互斥信号量mutex。计数信号量的计数器可以是任意正整数互斥信号量的计数器只能是1。// 计数信号量 struct semaphore sem; sema_init(sem, 3); // 允许3个并发 // 互斥信号量 struct mutex mtx; mutex_init(mtx);互斥信号量在Linux内核中有特殊的优化比如支持优先级继承priority inheritance可以避免优先级反转问题。如果你只需要互斥语义优先使用mutex而不是计数为1的信号量。用户态的信号量API则不同。POSIX信号量用sem_init、sem_wait、sem_postSystem V信号量用semget、semop。用户态的互斥锁通常用pthread mutex它和信号量在语义上有重叠但pthread mutex通常有更丰富的特性如递归锁、错误检查锁等。5.2 信号量的down操作与中断处理信号量的down操作获取会导致睡眠所以不能在中断上下文中调用。但down_trylock是一个非阻塞版本它尝试获取信号量如果获取不到就立即返回错误码不会睡眠。这个版本可以在中断上下文中使用。if (down_trylock(sem) 0) { // 获取成功 // 临界区 up(sem); } else { // 获取失败做其他处理 }down_trylock的典型用途是在中断处理程序中尝试获取一个信号量如果获取不到就把工作推迟到下半部或者工作队列中执行。5.3 信号量的公平性与等待队列信号量的等待队列通常是FIFO先进先出的这意味着先等待的线程会先被唤醒。这提供了基本的公平性保证避免了饥饿问题。但FIFO也不是绝对的。在某些实现中如果新来的线程发现信号量可用它可能直接获取而不会排在等待队列后面。这被称为“插队”问题。Linux内核的信号量实现通常会尽量避免插队但用户态的某些实现可能不保证。如果你需要严格的公平性可能需要自己实现一个带票据锁ticket lock或者使用专门的公平锁实现。5.4 信号量使用中的常见陷阱第一个陷阱是忘记释放。信号量的up操作必须和down配对任何一条错误路径上遗漏了up都会导致信号量永久泄漏其他线程永远等待。使用goto错误处理或者RAII风格的封装可以减少这类问题。第二个陷阱是释放顺序错误。如果有多个信号量释放顺序应该和获取顺序相反避免死锁。第三个陷阱是在持有信号量时调用可能再次获取同一信号量的函数。信号量不是递归的重复获取会导致死锁。第四个陷阱是混淆信号量和条件变量。信号量适合管理资源计数条件变量适合等待某个条件成立。虽然它们可以互相模拟但用错了会导致代码难以理解和维护。6. 高频问题排查与经验速查6.1 自旋锁导致CPU占用率飙升怎么排查如果你发现某个CPU核心的占用率接近100%而且系统响应变慢很可能是自旋锁争抢导致的。排查步骤第一步用perf top或者oprofile查看热点函数如果看到大量时间花在_raw_spin_lock或者queued_spin_lock_slowpath上基本可以确认。第二步用ftrace的function_graph跟踪器查看自旋锁的持有时间。如果某个自旋锁的持有时间经常超过几微秒就需要考虑优化临界区或者改用信号量。第三步检查是否有中断处理程序在争抢同一个自旋锁。中断处理程序会抢占进程上下文如果它也在自旋等待同一个锁会导致进程上下文的自旋时间被拉长。6.2 信号量导致系统响应变慢怎么排查信号量导致的性能问题通常表现为上下文切换次数异常高。用vmstat查看cs列context switch如果每秒超过几十万次说明上下文切换过于频繁。用pidstat -w可以查看具体进程的上下文切换次数。如果某个进程的cswch/s自愿上下文切换很高说明它在频繁等待信号量。解决方法包括增大信号量的初始计数如果资源允许更多并发、缩短临界区执行时间、或者改用无锁数据结构。6.3 常见问题速查表现象可能原因排查方法解决思路CPU占用高但吞吐低自旋锁争抢激烈perf top看热点缩短临界区、改用排队自旋锁、考虑信号量系统响应慢、上下文切换高信号量争抢导致频繁睡眠唤醒vmstat看cs列增大信号量计数、缩短临界区、减少锁粒度内核报scheduling while atomic在原子上下文调用了可能睡眠的函数检查调用链改用自旋锁或down_trylock死锁锁顺序不一致或递归获取检查所有锁的获取顺序统一锁顺序、避免递归某个线程饿死自旋锁不公平检查是否使用了公平锁改用排队自旋锁或信号量6.4 几个容易被忽略的实操技巧第一个技巧自旋锁的临界区尽量只包含数据修改不包含函数调用。函数调用可能引入意外的睡眠或者锁获取增加风险。第二个技巧信号量的down和up尽量在同一个函数内配对这样容易检查是否遗漏。如果必须跨函数用注释明确标注。第三个技巧用lockdep做死锁检测。Linux内核的lockdep可以检测锁顺序问题和递归获取问题在开发阶段开启它可以提前发现很多隐患。第四个技巧压力测试时用高并发场景验证。很多锁的问题在低负载下不会暴露只有在高并发争抢时才会出现。用stress-ng或者自己写多线程测试程序模拟大量并发访问。第五个技巧关注锁的粒度。一把大锁保护所有数据虽然简单但会成为性能瓶颈。把大锁拆成多把小锁每把锁保护不同的数据可以显著提升并发性能。但锁粒度也不是越细越好太细会导致锁数量过多增加死锁风险和内存开销。7. 从实际项目中学到的选型经验7.1 一个高并发计数器的优化过程某次在一个网络处理模块中需要维护一个全局的包计数器。最初用自旋锁保护spin_lock(counter_lock); global_counter; spin_unlock(counter_lock);在低负载下没问题但当包速率达到每秒几百万时自旋锁争抢变得非常严重多个CPU核心都在自旋等待。用perf查看发现超过30%的CPU时间花在自旋锁上。优化方案是改用原子变量atomic_inc(global_counter);原子变量在底层仍然使用原子指令但它不需要显式的锁硬件可以直接完成递增操作。对于简单的计数器场景原子变量比自旋锁更高效。但如果计数器需要和其他数据一起更新原子变量就不够了这时候可能需要考虑每CPU变量per-CPU variable每个CPU维护自己的计数器读取时汇总所有CPU的值。7.2 一个信号量导致的延迟抖动问题另一个项目中一个工作队列的处理函数需要获取一个信号量来访问共享资源。在大多数情况下信号量可用获取操作很快返回。但当多个工作项同时到达时部分工作项会睡眠等待导致处理延迟从微秒级跳到毫秒级。这个问题的根源是信号量的睡眠唤醒机制引入了不确定性。对于延迟敏感的场景即使信号量可用down操作也可能因为调度器的原因导致延迟。解决方案是改用down_trylock如果获取失败就把工作项重新放回队列稍后重试。这样避免了睡眠但增加了重试的开销。另一种方案是增大信号量的初始计数减少等待的概率。7.3 选型决策的检查清单在实际项目中做选型时我通常会问自己这几个问题临界区大概执行多长时间如果小于1微秒优先自旋锁如果大于10微秒优先信号量。代码可能在中断上下文中执行吗如果是只能用自旋锁。临界区中可能睡眠吗如果是只能用信号量。锁的争抢程度如何如果几乎不争抢用什么都行如果争抢激烈需要更仔细地评估。对延迟的敏感度如何如果要求确定性延迟自旋锁更合适如果可以容忍一定的延迟抖动信号量更合适。系统是单核还是多核单核上自旋锁的意义有限多核上自旋锁才能发挥优势。把这几个问题回答清楚选型基本就不会出大错了。我个人在实际操作中的体会是不要迷信任何一种锁也不要过早优化。先用最简单的方案把功能跑通然后在压力测试中观察性能瓶颈再针对性地优化。很多锁的问题不是选错了类型而是临界区设计得不合理。把临界区缩小到只包含必要的操作往往比换锁类型更有效。最后再分享一个小技巧如果你不确定该用自旋锁还是信号量可以先都用上然后在代码里加统计信息记录每种锁的等待时间和争抢次数。跑一段时间后数据会告诉你答案。这比拍脑袋决策靠谱得多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询