锁与同步⚓︎
摘要
本文从一般并发程序中的共享状态出发,说明互斥锁、自旋锁、读写锁、条件变量、信号量与 futex 分别解决什么问题,以及锁竞争、惊群和优先级反转为什么会直接表现为吞吐下降和尾延迟放大。
问题背景⚓︎
只要程序采用多线程、线程池或多个协作执行流共享同一组资源,就必然会遇到同步问题。任务队列、计数器、缓存、内存池乃至日志缓冲区,都会把“并发执行”转化成“共享状态访问”。
因此,锁与同步并不是为了让代码写法更规范,而是为了在多个执行流同时推进时,仍然保证状态演化满足预期顺序。真正需要讨论的核心问题是:
互斥首先解决临界区的正确性⚓︎
互斥锁的作用最直接:任意时刻只允许一个线程进入某段临界区。它把共享状态的更新串行化,以换取逻辑正确性。抽象上可以写成:
这类约束非常基础,因此互斥锁也是最常见的同步原语。但互斥并不只是“加了就安全”,它同时意味着访问共享状态的那部分代码会被串行化。临界区越长、热点越集中,吞吐越容易受影响。
自旋锁与阻塞锁处理的是不同等待成本⚓︎
当多个线程争夺同一把锁时,系统必须决定等待线程是进入睡眠,还是继续在 CPU 上轮询。自旋锁选择后者,适合临界区极短、持锁时间远小于一次睡眠唤醒成本的场景。它避免了调度器介入,但会把等待时间直接转化为 CPU 空转。
互斥锁通常在竞争严重或等待较长时进入阻塞路径,由内核参与睡眠与唤醒。这种方式节省 CPU,但会带来上下文切换和调度抖动。因此不存在放之四海而皆准的“更快的锁”,只有是否匹配当前竞争形态的问题。
读多写少时才有读写锁的空间⚓︎
读写锁试图把“共享访问”和“独占修改”区分开来。多个读者可以并发进入,但写者必须独占。这种结构只有在读远多于写,并且读路径足够长、足够值得并发化时才真正有意义。
它的收益来自把只读访问从完全串行化中解放出来,但代价也同样存在:实现更复杂,元数据更多,写者可能饥饿,锁升级与降级的语义也更难控制。因此读写锁不是“互斥锁的更高级版本”,而是一种针对访问分布的特定优化。
条件变量与信号量解决的是等待条件⚓︎
有些并发问题并不是“谁先进临界区”,而是“什么时候条件成立”。例如任务队列为空时,工作线程应当等待;生产者放入任务后,消费者应当被唤醒。这种等待条件变化的场景通常由条件变量处理。
条件变量本身不负责互斥,它依赖互斥锁保护共享状态,再通过等待与通知把线程挂到“条件尚未成立”的位置。它解决的是:
信号量则更接近一个带计数的许可系统。它适用于资源池容量控制、并发度限制、批量许可发放等场景。若同时可用资源数量为 k,则同时进入者满足:
因此,条件变量更适合等待某个状态变化,信号量更适合管理某种可计数资源。
futex 把快路径留在用户态⚓︎
Linux 线程库中的很多锁之所以在低竞争下开销不高,关键在于 futex 的思路:无竞争时尽量在用户态完成原子检查与状态修改,只有真正需要睡眠或唤醒时才进入内核。
这可以写成:
因此,用户看到的 pthread_mutex 并不是“每次加锁都调用内核”,而是在无竞争时走极轻的用户态路径,在竞争出现时才借助内核等待队列完成阻塞与唤醒。理解 futex 后,才能真正明白为什么“加锁”与“系统调用”在很多时候并不等价。
锁竞争如何变成性能问题⚓︎
锁竞争的危害不只是线程排队本身,更在于它会同时制造多种次生成本:等待线程无法推进,持锁线程延长关键路径,共享 cache line 在多个核心之间抖动,调度器频繁介入,尾延迟随之被放大。
因此在判断锁问题时,比“有没有锁”更重要的量是:
只要热点资源过于集中,哪怕锁实现本身再精细,也无法抵消共享设计上的瓶颈。
惊群与优先级反转是两类典型失控⚓︎
惊群问题指的是多个等待者被同时唤醒,但真正只有少量线程能够继续推进,其余线程很快再次睡眠。在通用系统里,这常见于多个线程同时等待同一事件源、同一批任务源或过于粗放的广播式唤醒策略。它消耗的不是逻辑正确性,而是调度预算。
优先级反转则表现为高优先级线程被低优先级线程持有的锁阻塞,而中优先级线程又不断占用 CPU,导致真正重要的工作迟迟无法恢复。它在实时系统和延迟敏感服务里尤其危险,因为问题并不出在 CPU 不够,而出在同步依赖链条已经被扭曲。
工程上的基本判断⚓︎
程序里的同步优化,通常不是一开始就追求“无锁”,而是先判断共享是否真的必要、临界区是否可以缩小、状态是否可以分片、唤醒是否可以更精确。很多有效优化并不来自换一种锁,而是来自减少同一把锁保护的对象规模。
因此更稳妥的优化方向通常是:
而不是简单地把所有互斥锁替换成自旋锁、读写锁或 lock-free 结构。
与系统设计的关系⚓︎
并发程序一旦采用线程池、事件分发加工作线程分离、共享缓存或统一状态管理,就无法绕开同步问题。I/O 模型决定线程何时被唤醒,执行模型决定有多少线程会同时推进,而锁与同步决定这些线程在共享状态面前是否还能保持吞吐。
如果继续看执行实体如何与等待模型配合,可结合 进程与线程 与 I/O 模型 一起理解;如果继续看 futex、COW 与 mmap 依赖的内核内存基础,可阅读 虚拟内存。