自旋锁与信号量深度对比:从原理到选型,避开并发性能陷阱 1. 从一次线上卡顿说起为什么“锁”的选择会拖垮整个服务很多开发者第一次接触并发编程时都会有一个朴素的认知加锁就是加锁能保护临界区就行用哪个不是用我早期也是这么想的直到有一次参与排查一个线上服务的间歇性卡顿问题。那个服务在压测环境下表现正常QPS 拉到几千也没什么明显异常但一上生产环境在流量高峰期就会出现周期性的响应时间飙升从平均 20ms 直接跳到 800ms 以上而且这种飙升没有规律监控上看 CPU 使用率也没有打满线程池也没有排队堆积日志里更没有任何报错。当时团队里几个人轮流看代码最后把怀疑点落在了一处共享计数器的保护逻辑上。那段代码用的是信号量来做互斥临界区本身非常短就是对一个内存计数做自增然后判断是否超过阈值。按理说这种操作耗时在纳秒级别不应该成为瓶颈。但问题恰恰出在这里信号量在竞争激烈时会把拿不到锁的线程挂起进入睡眠状态等锁释放后再唤醒。这个挂起和唤醒的过程涉及操作系统内核的调度开销远大于临界区本身。当并发线程数远大于 CPU 核心数时大量线程频繁地在“运行—睡眠—唤醒”之间切换上下文切换的开销就把整个服务的响应时间拖垮了。后来我们把那个计数器换成了自旋锁问题立刻缓解。原因也很直接临界区极短自旋等待几个时钟周期就能拿到锁完全没必要让线程睡下去再被叫醒。这件事给我留下的印象很深——锁的选择不是“能用就行”而是要和临界区的长度、竞争强度、线程数量匹配。选错了性能差距可能是几十倍甚至上百倍。这篇文章就围绕自旋锁和信号量这两个最常被拿来对比的同步原语把它们的底层机制、适用边界、实测差异和踩坑经验一次讲透。不管你是刚学并发编程的新手还是已经在线上调过性能问题的老手相信都能从中找到对自己有用的部分。我会尽量用生活化的类比把原理讲清楚同时给出可以直接参考的代码示例和参数建议让你在下次做技术选型时不再凭感觉拍脑袋。2. 自旋锁与信号量的本质差异一个在门口等一个回家等电话要理解两者的差异最直观的方式是用生活场景来类比。假设有一间只能容纳一个人的会议室外面有十个人想进去开会。自旋锁的做法是每个人都站在会议室门口不停地推门试探门一开就立刻冲进去。谁都不离开门口谁都不休息就是一直试。这种方式的好处是一旦里面的人出来外面的人几乎在瞬间就能进去响应极快。坏处也很明显十个人都堵在门口占着走廊如果里面的人开长会外面这十个人就白白耗着什么也干不了。信号量的做法则是门口放一个登记本谁想进去先登记然后回工位等通知。里面的人出来时管理员根据登记顺序叫下一个人。等待的人可以去干别的事不占走廊。好处是资源利用率高等待期间不浪费人力。坏处是从“被叫到”到“走到会议室”需要时间这个调度延迟比自旋锁的直接推门要长。把这个类比映射到计算机世界就得到了两者的核心定义自旋锁Spinlock是一种忙等待锁。线程在尝试获取锁失败时不会放弃 CPU而是在一个循环中不断重试直到成功。它不涉及线程状态的切换全程处于运行态或就绪态。信号量Semaphore是一种计数型同步原语。它维护一个许可计数线程获取许可时如果计数不足就会被阻塞并放入等待队列交出 CPU当其他线程释放许可时再从等待队列中唤醒一个线程。它涉及线程的睡眠与唤醒也就是上下文切换。从这两个定义出发可以推导出它们最本质的差异我整理成了一张对照表对比维度自旋锁信号量等待方式忙等待持续占用 CPU阻塞等待让出 CPU线程状态切换无有涉及内核调度获取锁延迟极低通常几十到几百纳秒较高通常几微秒到几十微秒CPU 开销等待期间持续消耗等待期间几乎不消耗适用临界区长度极短纳秒到微秒级可长可短长临界区更合适适用竞争强度低到中等高竞争场景更稳是否可重入通常不可重入需自行处理取决于具体实现典型实现原子操作 循环重试计数 等待队列 调度这张表里最需要划重点的是“等待方式”和“适用临界区长度”这两行。很多人选错锁根本原因就是没有把临界区的实际耗时和锁的等待机制对应起来。临界区只有几十条指令却用信号量等于让一个只需要推门就能进的人去走一套登记叫号流程纯属浪费。反过来临界区里要读文件、发网络请求、做复杂计算却用自旋锁那等待的线程就会一直空转烧 CPU把核心数占满其他任务反而被拖慢。还有一个容易被忽略的点自旋锁在单核 CPU 上的表现。如果系统只有一个核心自旋锁的持有者正在这个核心上运行等待者也在同一个核心上自旋那等待者永远等不到锁释放因为持有者根本没机会被调度。所以在单核环境下自旋锁必须配合关抢占或者干脆不用。这一点在多核时代虽然不那么突出但在嵌入式或容器限制 CPU 的场景下依然要注意。理解了这些本质差异接下来我们深入到实现层面看看它们各自是怎么工作的以及为什么会有这些差异。2.1 自旋锁的原子操作基础CAS 到底做了什么自旋锁的实现离不开一个核心指令CASCompare-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 的PAUSEARM 的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 配额比如--cpus1即使宿主机是多核容器内的进程也可能只在一个核心上运行同样会触发这个问题。修复方式是检测可用 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.7416209802.18.5811807206.815.21662051022.428.63231038058.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%再考虑自适应锁或无锁方案。我个人在实际项目中的体会是锁的选择从来不是孤立的它和数据结构设计、锁粒度、业务访问模式紧密相关。与其纠结“自旋锁和信号量哪个好”不如先问自己“这个临界区到底有多长”“有多少线程会同时进来”“能不能把锁拆细”。把这些问题想清楚答案往往就自己浮现出来了。最后再分享一个小技巧在代码里给每把锁起一个有意义的名字并在获取锁时记录等待时间这样线上出问题时能快速定位是哪把锁在拖后腿比盲目猜测高效得多。