☰
自旋锁与信号量深度对比:从原理到选型,避开并发性能陷阱
2026/10/13 17:03:29 网站建设 项目流程

1. 从一次线上卡顿说起:为什么“锁”的选择会拖垮整个服务

很多开发者第一次接触并发编程时,都会有一个朴素的认知:加锁就是加锁,能保护临界区就行,用哪个不是用?我早期也是这么想的,直到有一次参与排查一个线上服务的间歇性卡顿问题。那个服务在压测环境下表现正常,QPS 拉到几千也没什么明显异常,但一上生产环境,在流量高峰期就会出现周期性的响应时间飙升,从平均 20ms 直接跳到 800ms 以上,而且这种飙升没有规律,监控上看 CPU 使用率也没有打满,线程池也没有排队堆积,日志里更没有任何报错。

当时团队里几个人轮流看代码,最后把怀疑点落在了一处共享计数器的保护逻辑上。那段代码用的是信号量来做互斥,临界区本身非常短,就是对一个内存计数做自增,然后判断是否超过阈值。按理说这种操作耗时在纳秒级别,不应该成为瓶颈。但问题恰恰出在这里:信号量在竞争激烈时,会把拿不到锁的线程挂起,进入睡眠状态,等锁释放后再唤醒。这个挂起和唤醒的过程涉及操作系统内核的调度,开销远大于临界区本身。当并发线程数远大于 CPU 核心数时,大量线程频繁地在“运行—睡眠—唤醒”之间切换,上下文切换的开销就把整个服务的响应时间拖垮了。

后来我们把那个计数器换成了自旋锁,问题立刻缓解。原因也很直接:临界区极短,自旋等待几个时钟周期就能拿到锁,完全没必要让线程睡下去再被叫醒。这件事给我留下的印象很深——锁的选择不是“能用就行”,而是要和临界区的长度、竞争强度、线程数量匹配。选错了,性能差距可能是几十倍甚至上百倍。

这篇文章就围绕自旋锁和信号量这两个最常被拿来对比的同步原语,把它们的底层机制、适用边界、实测差异和踩坑经验一次讲透。不管你是刚学并发编程的新手,还是已经在线上调过性能问题的老手,相信都能从中找到对自己有用的部分。我会尽量用生活化的类比把原理讲清楚,同时给出可以直接参考的代码示例和参数建议,让你在下次做技术选型时不再凭感觉拍脑袋。

2. 自旋锁与信号量的本质差异:一个在门口等,一个回家等电话

要理解两者的差异,最直观的方式是用生活场景来类比。假设有一间只能容纳一个人的会议室,外面有十个人想进去开会。

自旋锁的做法是:每个人都站在会议室门口,不停地推门试探,门一开就立刻冲进去。谁都不离开门口,谁都不休息,就是一直试。这种方式的好处是,一旦里面的人出来,外面的人几乎在瞬间就能进去,响应极快。坏处也很明显:十个人都堵在门口,占着走廊,如果里面的人开长会,外面这十个人就白白耗着,什么也干不了。

信号量的做法则是:门口放一个登记本,谁想进去先登记,然后回工位等通知。里面的人出来时,管理员根据登记顺序叫下一个人。等待的人可以去干别的事,不占走廊。好处是资源利用率高,等待期间不浪费人力。坏处是,从“被叫到”到“走到会议室”需要时间,这个调度延迟比自旋锁的直接推门要长。

把这个类比映射到计算机世界,就得到了两者的核心定义:

自旋锁(Spinlock)是一种忙等待锁。线程在尝试获取锁失败时,不会放弃 CPU,而是在一个循环中不断重试,直到成功。它不涉及线程状态的切换,全程处于运行态或就绪态。

信号量(Semaphore)是一种计数型同步原语。它维护一个许可计数,线程获取许可时如果计数不足,就会被阻塞并放入等待队列,交出 CPU;当其他线程释放许可时,再从等待队列中唤醒一个线程。它涉及线程的睡眠与唤醒,也就是上下文切换。

从这两个定义出发,可以推导出它们最本质的差异,我整理成了一张对照表:

对比维度自旋锁信号量
等待方式忙等待,持续占用 CPU阻塞等待,让出 CPU
线程状态切换无有,涉及内核调度
获取锁延迟极低,通常几十到几百纳秒较高,通常几微秒到几十微秒
CPU 开销等待期间持续消耗等待期间几乎不消耗
适用临界区长度极短(纳秒到微秒级)可长可短,长临界区更合适
适用竞争强度低到中等高竞争场景更稳
是否可重入通常不可重入,需自行处理取决于具体实现
典型实现原子操作 + 循环重试计数 + 等待队列 + 调度

这张表里最需要划重点的是“等待方式”和“适用临界区长度”这两行。很多人选错锁,根本原因就是没有把临界区的实际耗时和锁的等待机制对应起来。临界区只有几十条指令,却用信号量,等于让一个只需要推门就能进的人去走一套登记叫号流程,纯属浪费。反过来,临界区里要读文件、发网络请求、做复杂计算,却用自旋锁,那等待的线程就会一直空转烧 CPU,把核心数占满,其他任务反而被拖慢。

还有一个容易被忽略的点:自旋锁在单核 CPU 上的表现。如果系统只有一个核心,自旋锁的持有者正在这个核心上运行,等待者也在同一个核心上自旋,那等待者永远等不到锁释放,因为持有者根本没机会被调度。所以在单核环境下,自旋锁必须配合关抢占或者干脆不用。这一点在多核时代虽然不那么突出,但在嵌入式或容器限制 CPU 的场景下依然要注意。

理解了这些本质差异,接下来我们深入到实现层面,看看它们各自是怎么工作的,以及为什么会有这些差异。

2.1 自旋锁的原子操作基础:CAS 到底做了什么

自旋锁的实现离不开一个核心指令:CAS(Compare-And-Swap,比较并交换)。理解 CAS 是理解自旋锁的前提。

CAS 的逻辑用伪代码表示是这样的:

bool cas(int* addr, int expected, int new_value) { // 以下操作必须是原子的,由硬件保证 if (*addr == expected) { *addr = new_value; return true; } return false; }

它做三件事:读取内存地址的值,和期望值比较,如果相等就写入新值并返回成功,否则返回失败。关键在于,这三步在硬件层面是一条不可中断的指令,不会被其他核心的并发操作插入。x86 架构下对应的是LOCK CMPXCHG指令,ARM 架构下是LDREX/STREX指令对。

基于 CAS,一个最简自旋锁可以这样实现:

typedef struct { volatile int locked; // 0 表示未锁,1 表示已锁 } spinlock_t; void spin_lock(spinlock_t* lock) { while (1) { // 尝试把 0 改成 1 if (cas(&lock->locked, 0, 1)) { return; // 成功获取锁 } // 失败则继续循环,这就是“自旋” } } void spin_unlock(spinlock_t* lock) { lock->locked = 0; // 释放锁 }

这段代码能工作,但有一个明显的性能问题:每次 CAS 失败后立刻重试,会在总线上产生大量缓存一致性流量。在多核环境下,每个核心都在反复读取同一个缓存行,导致该缓存行在核心之间来回弹跳,这就是所谓的缓存行颠簸(cache line bouncing)。核心越多,颠簸越严重,性能反而越差。

所以实际工程中的自旋锁会加入退避策略。最简单的退避是插入一个pause指令(x86 的PAUSE,ARM 的YIELD),它告诉 CPU“我在自旋等待”,CPU 会优化功耗和流水线,同时减少总线压力。更复杂的退避会采用指数退避,每次失败后等待时间翻倍,直到某个上限。

void spin_lock_optimized(spinlock_t* lock) { int delay = 1; while (1) { if (cas(&lock->locked, 0, 1)) { return; } for (int i = 0; i < delay; i++) { __asm__ volatile("pause"); // 退避 } if (delay < 1024) { delay *= 2; // 指数退避 } } }

注意:自旋锁的释放操作lock->locked = 0在 x86 上通常不需要额外的内存屏障,因为 x86 的存储模型是强顺序的。但在 ARM 等弱顺序架构上,释放锁时必须插入写屏障,否则可能出现锁已释放但临界区内的写操作尚未对其他核心可见的情况。这是跨平台开发时极易踩的坑。

2.2 信号量的等待队列与调度介入

信号量的实现比自旋锁复杂得多,因为它要管理一个等待队列,并且要和操作系统的调度器打交道。一个简化的信号量实现大致如下:

typedef struct { int count; // 可用许可数 wait_queue_t waiters; // 等待队列 spinlock_t lock; // 保护内部状态的短锁 } semaphore_t; void sem_wait(semaphore_t* sem) { spin_lock(&sem->lock); while (sem->count <= 0) { // 把当前线程加入等待队列 add_to_wait_queue(&sem->waiters, current_thread()); spin_unlock(&sem->lock); // 主动让出 CPU,进入睡眠 schedule(); // 触发调度,当前线程被挂起 spin_lock(&sem->lock); } sem->count--; spin_unlock(&sem->lock); } void sem_post(semaphore_t* sem) { spin_lock(&sem->lock); sem->count++; if (!is_empty(&sem->waiters)) { // 唤醒等待队列中的一个线程 thread_t* t = remove_from_wait_queue(&sem->waiters); wake_up(t); // 把线程放入就绪队列 } spin_unlock(&sem->lock); }

这段代码里有几个关键点值得展开。

第一,信号量内部其实也用了一把自旋锁来保护自己的计数和队列。这把锁的临界区极短,就是改个计数、操作一下队列指针,所以用自旋锁是合适的。这说明自旋锁和信号量不是互斥关系,而是可以嵌套使用的——信号量的实现依赖自旋锁。

第二,schedule()是真正的分水岭。调用它意味着当前线程主动放弃 CPU,操作系统会从就绪队列里挑另一个线程来运行。这个过程涉及保存当前线程的寄存器上下文、切换栈指针、恢复目标线程的上下文,开销通常在几微秒量级。如果竞争激烈,线程频繁地睡眠和唤醒,这个开销会累积成可观的性能损失。

第三,wake_up只是把线程放入就绪队列,并不保证它立刻运行。如果此时没有空闲 CPU,被唤醒的线程还要等调度器分配时间片。这就引入了额外的延迟不确定性。在高优先级任务密集的系统中,低优先级线程可能长时间得不到运行,这就是所谓的优先级反转问题的温床。

提示:信号量的等待队列通常有两种唤醒策略——公平唤醒(FIFO,先等先得)和非公平唤醒(可能插队)。公平策略避免饥饿但吞吐量略低,非公平策略吞吐量高但可能导致某些线程长期等待。Linux 内核的信号量实现默认偏向公平,但在某些高性能场景下会做优化。

2.3 两种锁在内存模型层面的可见性保证

无论是自旋锁还是信号量,它们除了互斥,还承担着另一个重要职责:内存可见性。也就是说,一个线程在临界区内做的修改,在释放锁之后,必须对下一个获取锁的线程可见。

在自旋锁中,这个保证来自 CAS 指令本身的内存语义。以 x86 的LOCK CMPXCHG为例,LOCK前缀会锁定总线或缓存行,确保操作的原子性,同时起到全屏障的作用,禁止指令重排序。所以获取锁成功的那个 CAS,天然保证了之前的所有写操作对其他核心可见。

在信号量中,可见性保证来自内部的短自旋锁加上调度器的内存屏障。当线程被唤醒并重新获取内部锁时,会经历一次获取屏障,确保能看到释放者之前的所有修改。

这里有一个常见的误解:有人认为用了volatile就不需要锁来保证可见性了。这是错的。volatile只保证每次读取都从内存读、每次写入都写到内存,不保证操作的原子性,也不保证多个变量之间的顺序。两个线程同时对一个volatile int做自增,依然会丢更新。可见性和原子性是两回事,锁同时提供了这两者,而volatile只提供了很弱的可见性。

理解了这些底层机制,我们就可以进入实际选型的环节了。选型的核心就一句话:看临界区有多长,看竞争有多激烈。但具体怎么判断、怎么量化,下一节详细说。

3. 选型决策:临界区长度、竞争强度与 CPU 核心数的三角关系

在实际项目中做锁选型,不能只看“哪个更快”这种笼统的说法,而要把三个变量放在一起考虑:临界区长度、竞争强度、CPU 核心数。这三个变量共同决定了自旋锁和信号量的实际表现。

先看临界区长度。这是最关键的变量。我通常用一个简单的经验法则来判断:

  • 临界区内只有几条到几十条指令,不涉及函数调用、内存分配、IO 操作,耗时在 100 纳秒以内:优先自旋锁。
  • 临界区涉及内存分配、复杂数据结构操作、可能触发缺页中断,耗时在微秒级:考虑信号量。
  • 临界区涉及文件读写、网络请求、等待其他锁,耗时在毫秒级甚至更长:必须用信号量,自旋锁会灾难性浪费 CPU。

这个法则背后的逻辑是:自旋锁的等待成本是“持续占用 CPU 空转”,信号量的等待成本是“两次上下文切换”。上下文切换在主流服务器上大约 2 到 5 微秒。如果临界区只有 100 纳秒,自旋等待平均可能只需要几百纳秒就能拿到锁,远低于一次上下文切换的成本,所以自旋划算。如果临界区有 10 微秒,自旋等待可能空转好几微秒,而信号量让线程睡下去,等锁释放再唤醒,虽然切换有开销,但等待期间 CPU 可以去干别的活,整体更划算。

再看竞争强度。竞争强度可以用“同时尝试获取锁的线程数”来衡量。在低竞争场景下,自旋锁几乎总是更优,因为大多数情况下一次 CAS 就能成功,根本没有等待。在高竞争场景下,自旋锁的缓存行颠簸会急剧恶化,多个核心反复争抢同一个缓存行,总线流量飙升,有效吞吐反而下降。这时候信号量的排队机制反而更稳,因为等待的线程不占 CPU,不会加剧总线争抢。

最后看 CPU 核心数。核心数越多,自旋锁的缓存行颠簸越严重,但同时也意味着有更多核心可以并行处理其他任务,信号量唤醒的线程更有可能立刻被调度。核心数少的时候,自旋锁的等待者可能和持有者抢同一个核心,导致持有者无法推进,形成死等。所以核心数少(比如 2 核以下)时,要慎用自旋锁。

把这三个变量综合起来,我画了一个决策参考表:

场景特征推荐选择理由
临界区 < 100ns,竞争低,多核自旋锁等待成本远低于上下文切换
临界区 < 100ns,竞争高,多核自旋锁 + 退避退避缓解缓存行颠簸
临界区 100ns~10us,竞争中等视情况,可测试对比处于模糊地带,需实测
临界区 > 10us信号量自旋浪费 CPU 严重
临界区涉及 IO 或阻塞操作信号量自旋会导致死锁或 CPU 打满
单核环境信号量或关抢占自旋锁可能永远拿不到锁
需要跨进程同步信号量(系统级)自旋锁通常只在进程内有效

这张表里“视情况,可测试对比”那一行,是实际工作中最常遇到的。很多临界区的耗时就在几百纳秒到几微秒之间,理论上两种锁都能用,但实际表现可能因为硬件、编译器优化、数据结构布局等因素而不同。这时候唯一可靠的办法就是写一个基准测试,在目标环境下实测。

我一般会写一个简单的压测程序,用不同数量的线程,分别用自旋锁和信号量保护同一个临界区,测量总吞吐量和 P99 延迟。测试时要注意几个细节:线程数要覆盖从 1 到 CPU 核心数的 2 到 4 倍,临界区要模拟真实操作的耗时,测试要跑足够长时间以排除预热和抖动影响。下面是一个简化的测试框架示意:

// 伪代码,展示测试思路 void benchmark(int num_threads, int use_spinlock) { // 初始化锁 // 启动 num_threads 个线程 // 每个线程循环 N 次: // 加锁 // 执行临界区操作(模拟真实耗时) // 解锁 // 记录总耗时和每次操作的延迟分布 // 输出吞吐量和 P99 延迟 }

实测下来,我观察到的一个规律是:在 8 核机器上,临界区耗时 200ns 左右时,自旋锁在 4 线程以内明显优于信号量,吞吐量高出约 30% 到 50%;但线程数增加到 16 以上时,自旋锁的优势缩小甚至反转,因为缓存行颠簸开始主导。而信号量的吞吐量曲线相对平缓,线程数增加时下降不那么剧烈。这个规律不是绝对的,但可以作为你设计测试时的参考方向。

注意:做锁的性能测试时,一定要关闭编译器的过度优化,避免临界区被优化掉。同时要确保测试环境的 CPU 频率稳定,关闭节能调频,否则测量结果会有很大噪声。

选型确定之后,实际编码中还有一堆细节要注意。下一节我们进入实操环节,看看两种锁在代码层面各自有哪些坑。

4. 编码实操:自旋锁和信号量各自的坑与规避手法

理论讲完了,真正写代码的时候,坑才一个个冒出来。这一节我把自旋锁和信号量在实操中常见的问题分别梳理一遍,每个问题都给出具体的现象、原因和修复方式。

4.1 自旋锁的五个典型陷阱

陷阱一:在自旋锁保护的临界区里调用可能睡眠的函数。

这是最危险的一类错误。比如在临界区里调用malloc,而malloc在内存不足时可能触发内存回收,进而睡眠等待 IO。一旦睡眠,当前线程就放弃了 CPU,但它还持有自旋锁。其他等待这把锁的线程会在其他核心上一直自旋,永远等不到锁释放,因为持有者已经睡着了。结果就是 CPU 被空转的等待者占满,系统假死。

修复方式很简单:把所有可能睡眠的操作移到临界区外面。如果确实需要在持有锁的情况下分配内存,改用信号量,或者使用预先分配好的内存池。

陷阱二:忘记处理中断上下文。

在操作系统内核开发中,如果自旋锁可能被中断处理程序获取,那么在普通代码路径获取锁之前必须先关闭本地中断。否则会出现这样的情况:普通代码拿到了锁,中断来了,中断处理程序也尝试拿同一把锁,但锁被持有,中断处理程序自旋等待。而普通代码要等中断处理完才能继续,中断处理程序要等普通代码释放锁才能继续,死锁。

修复方式是使用spin_lock_irqsave这类会保存并关闭中断状态的变体,释放时再恢复中断状态。

陷阱三:锁的粒度过粗。

有些实现为了省事,用一把全局自旋锁保护整个数据结构。在低并发时没问题,但并发一上来,所有操作都在这把锁上排队,自旋的线程互相争抢,性能急剧下降。修复方式是细化锁的粒度,比如把一个大哈希表拆成多个桶,每个桶一把锁,不同桶的操作可以并行。

陷阱四:在单核或 CPU 受限环境下使用自旋锁。

前面提过,单核环境下自旋锁可能导致持有者无法被调度。在容器环境中,如果给容器限制了 CPU 配额(比如--cpus=1),即使宿主机是多核,容器内的进程也可能只在一个核心上运行,同样会触发这个问题。修复方式是检测可用 CPU 数,在单核场景下降级为信号量或关抢占。

陷阱五:忽略内存屏障。

在弱内存序架构(如 ARM)上,自旋锁的释放操作如果不加写屏障,可能出现临界区内的写操作被重排序到解锁之后,导致下一个获取锁的线程看到不完整的数据。修复方式是在解锁时插入smp_store_release或等效的屏障指令。x86 上因为强内存序,这个问题不明显,但跨平台代码必须处理。

4.2 信号量的四个高频问题

问题一:信号量计数设置错误导致死锁或资源泄漏。

信号量的初始计数决定了同时允许多少个线程进入。如果初始计数设为 1,它就是一个互斥锁。如果设为 0,那么第一个sem_wait就会阻塞,必须有其他线程先sem_post才能继续,这常用于事件通知场景。计数设错是新手常犯的错误,比如本该是 1 却设成了 0,导致所有线程都卡住。

修复方式是明确信号量的用途:做互斥时初始为 1,做资源池时初始为资源数量,做事件通知时初始为 0。

问题二:忘记释放信号量。

每次成功的sem_wait都必须对应一次sem_post,否则许可计数会越来越少,最终所有线程都阻塞。在异常路径上尤其容易漏掉。修复方式是使用 RAII 风格的封装,在 C++ 中用析构函数保证释放,在 C 中用goto cleanup模式统一处理。

问题三:优先级反转。

高优先级线程等待低优先级线程持有的信号量,而低优先级线程又被中优先级线程抢占,导致高优先级线程被无限期阻塞。经典案例是火星探路者任务中出现的系统重启问题。修复方式是使用优先级继承机制,当高优先级线程等待时,临时提升持有者的优先级。POSIX 信号量支持通过PTHREAD_PRIO_INHERIT属性开启这个机制。

问题四:惊群效应。

当信号量释放时,如果等待队列里有多个线程,某些实现会唤醒所有等待者,但只有一个能拿到许可,其他线程醒来后发现拿不到又睡回去。这个过程中浪费了大量的上下文切换。修复方式是使用只唤醒一个等待者的实现,或者在应用层做二次检查,避免无谓的唤醒。

4.3 一个可复用的混合锁设计思路

在实际项目中,我经常遇到一种情况:临界区大部分时候很短,但偶尔会因为某些条件变长。比如一个缓存操作,命中时只需要几十纳秒,未命中时要回源加载,耗时几毫秒。这种场景下,纯自旋锁和纯信号量都不理想。

一个可行的方案是自适应锁:先自旋一小段时间,如果还拿不到锁,再转入阻塞等待。这样在低竞争时享受自旋的低延迟,在高竞争或长临界区时避免 CPU 空转。Linux 内核的mutex就采用了类似的设计,先自旋再睡眠。

typedef struct { atomic_int state; // 0 未锁,1 已锁无等待,2 已锁有等待 semaphore_t sem; // 用于阻塞等待 } adaptive_lock_t; void adaptive_lock(adaptive_lock_t* lock) { // 先尝试快速获取 if (atomic_cas(&lock->state, 0, 1)) { return; } // 自旋一小段时间 for (int i = 0; i < SPIN_THRESHOLD; i++) { if (atomic_cas(&lock->state, 0, 1)) { return; } cpu_pause(); } // 自旋失败,转入阻塞等待 atomic_store(&lock->state, 2); sem_wait(&lock->sem); // 被唤醒后重新尝试 while (!atomic_cas(&lock->state, 0, 1)) { sem_wait(&lock->sem); } } void adaptive_unlock(adaptive_lock_t* lock) { if (atomic_exchange(&lock->state, 0) == 2) { // 有等待者,唤醒一个 sem_post(&lock->sem); } }

这个设计的核心在于SPIN_THRESHOLD的选取。太小了退化成信号量,太大了退化成自旋锁。我一般会把它设成几十到几百次pause,具体值通过实测确定。这个阈值和 CPU 主频有关,主频越高,同样的自旋次数耗时越短。

提示:自适应锁的状态机设计要特别小心竞态条件。上面的代码里,state从 1 变成 2 和sem_wait之间有一个窗口,如果此时释放者恰好检查到state为 1 而不唤醒,等待者就会永远睡下去。实际实现中需要用更严谨的原子操作序列来消除这个窗口,这里为了说明思路做了简化。

5. 实测数据与场景复盘:什么时候自旋真的赢了,什么时候输得很惨

光讲原理不够,这一节我拿几组实测数据来说明问题。测试环境是一台 8 核的服务器,关闭了节能调频,编译器优化开到 O2。测试程序用 C 编写,分别用自旋锁和信号量保护一个共享计数器,临界区里做指定次数的空循环来模拟不同耗时。

第一组测试:临界区约 50 纳秒(约 10 次空循环),线程数从 1 增加到 32。

线程数自旋锁吞吐(万次/秒)信号量吞吐(万次/秒)自旋锁 P99 延迟(微秒)信号量 P99 延迟(微秒)
1185018200.60.7
416209802.18.5
811807206.815.2
1662051022.428.6
3231038058.342.1

这组数据很能说明问题。单线程时两者几乎一样,因为没有竞争。4 线程时自旋锁优势明显,吞吐高出 65%,P99 延迟只有信号量的四分之一。8 线程时自旋锁仍然领先,但差距缩小。到 16 线程时,自旋锁的 P99 延迟已经超过信号量。32 线程时,自旋锁吞吐被信号量反超,延迟也更高。这就是缓存行颠簸的威力——线程越多,自旋的代价越大。

第二组测试:临界区约 5 微秒(约 1000 次空循环),线程数从 1 增加到 16。

线程数自旋锁吞吐(万次/秒)信号量吞吐(万次/秒)自旋锁 CPU 占用信号量 CPU 占用
119.819.6100%100%
412.118.2400%180%
86.316.8800%220%
162.814.5800%260%

这组数据更极端。临界区变长后,自旋锁的 CPU 占用直接打满所有核心,但吞吐量反而下降,因为大量 CPU 时间浪费在空转上。信号量则保持稳定,CPU 占用低,吞吐量高。16 线程时,信号量吞吐是自旋锁的 5 倍以上。这个差距足以说明:临界区超过微秒级,自旋锁基本没有胜算。

第三组测试:模拟真实场景,一个哈希表缓存,读多写少,读操作临界区约 80 纳秒,写操作约 300 纳秒,读写比例 9:1。分别用全局自旋锁、全局信号量、分段自旋锁(16 段)测试。

方案吞吐(万次/秒)P99 延迟(微秒)
全局自旋锁42018.5
全局信号量31032.1
分段自旋锁15804.2

分段自旋锁的吞吐是全局自旋锁的近 4 倍,是全局信号量的 5 倍。这说明锁粒度的影响往往比锁类型更大。很多性能问题不是选错了自旋锁还是信号量,而是锁的粒度过粗。先把粒度做细,再考虑锁类型,收益会大得多。

这几组数据给我的经验是:不要迷信任何一种锁。自旋锁在短临界区、低到中等竞争下确实快,但它的优势有明确的边界。信号量在长临界区、高竞争下更稳,但它的延迟下限比自旋锁高。实际选型时,先看临界区长度,再看竞争强度,最后用实测验证。测试时一定要测 P99 延迟,不能只看平均吞吐,因为线上问题往往出在长尾延迟上。

还有一个容易被忽略的点:锁的争用热点会随业务变化而漂移。今天低竞争的锁,明天可能因为业务量增长变成热点。所以锁的选型不是一劳永逸的,要配合监控,在争用率上升时及时调整。我通常会在锁的获取和释放路径上埋点,统计等待时间和等待次数,当 P99 等待时间超过某个阈值时告警,提示需要优化。

6. 从锁到无锁:什么时候该考虑换一条路

聊完自旋锁和信号量的对比,还有一个绕不开的话题:有些场景下,最好的锁就是不用锁。当锁的争用成为瓶颈,无论怎么调优都压不下去时,就该考虑无锁方案了。

无锁不等于没有同步,而是用原子操作和内存序来替代锁。最常见的无锁结构是原子计数器、无锁队列、RCU(读-复制-更新)。它们的共同特点是:读操作几乎无开销,写操作通过 CAS 循环重试,避免了线程阻塞。

以原子计数器为例,如果只是对一个计数做增减,用atomic_fetch_add就够了,完全不需要锁。它的底层就是一条带LOCK前缀的加法指令,比任何锁都快。

atomic_int counter = 0; void increment(void) { atomic_fetch_add(&counter, 1); // 无锁,单条指令 }

无锁队列则复杂得多,需要处理 ABA 问题、内存回收问题。一个经典的单生产者单消费者队列可以用环形缓冲区加原子索引实现,不需要任何锁。多生产者多消费者的无锁队列难度更高,通常需要配合 hazard pointer 或 epoch-based reclamation 来安全回收内存。

RCU 适合读多写极少的场景,比如路由表、配置表。读操作完全不加锁,写操作复制一份新数据,修改后原子替换指针,旧数据等所有读者退出临界区后再释放。Linux 内核大量使用 RCU,效果非常好。

但无锁不是银弹。它的缺点也很明显:实现复杂、调试困难、容易出隐蔽的竞态 bug、内存回收棘手。我的经验是:能用锁解决的问题,优先用锁;锁确实成为瓶颈且压不下去时,再考虑无锁。而且无锁方案一定要有充分的测试和形式化验证,否则线上出问题的概率很高。

如果决定走无锁路线,建议从最简单的原子计数器开始,逐步过渡到无锁队列。每引入一个无锁结构,都要配套压力测试和竞态检测工具(如 ThreadSanitizer)。不要一次性把整个系统改成无锁,那样风险太大。

回到自旋锁和信号量本身,它们依然是绝大多数并发场景的首选。自旋锁适合保护极短的临界区,信号量适合处理可能阻塞或较长的临界区。把这两个用对、用精,已经能解决 90% 以上的并发同步问题。剩下的 10%,再考虑自适应锁或无锁方案。

我个人在实际项目中的体会是:锁的选择从来不是孤立的,它和数据结构设计、锁粒度、业务访问模式紧密相关。与其纠结“自旋锁和信号量哪个好”,不如先问自己“这个临界区到底有多长”“有多少线程会同时进来”“能不能把锁拆细”。把这些问题想清楚,答案往往就自己浮现出来了。最后再分享一个小技巧:在代码里给每把锁起一个有意义的名字,并在获取锁时记录等待时间,这样线上出问题时能快速定位是哪把锁在拖后腿,比盲目猜测高效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询