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执行。等信号量可用时,再恢复之前那个线程的现场,让它继续执行。
上下文切换的代价是实打实的。一次完整的上下文切换,包括保存和恢复寄存器、刷新TLB(Translation 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可能触发睡眠。更安全的方式是把要删除的节点先移到本地链表,释放锁之后再统一kfree:
LIST_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_post,System 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微秒,优先信号量。
- 代码可能在中断上下文中执行吗?如果是,只能用自旋锁。
- 临界区中可能睡眠吗?如果是,只能用信号量。
- 锁的争抢程度如何?如果几乎不争抢,用什么都行;如果争抢激烈,需要更仔细地评估。
- 对延迟的敏感度如何?如果要求确定性延迟,自旋锁更合适;如果可以容忍一定的延迟抖动,信号量更合适。
- 系统是单核还是多核?单核上自旋锁的意义有限,多核上自旋锁才能发挥优势。
把这几个问题回答清楚,选型基本就不会出大错了。
我个人在实际操作中的体会是:不要迷信任何一种锁,也不要过早优化。先用最简单的方案把功能跑通,然后在压力测试中观察性能瓶颈,再针对性地优化。很多锁的问题不是选错了类型,而是临界区设计得不合理。把临界区缩小到只包含必要的操作,往往比换锁类型更有效。
最后再分享一个小技巧:如果你不确定该用自旋锁还是信号量,可以先都用上,然后在代码里加统计信息,记录每种锁的等待时间和争抢次数。跑一段时间后,数据会告诉你答案。这比拍脑袋决策靠谱得多。