你真的理解原子操作和锁吗

2025-02-26 pv

最近重翻了《Effective Modern C++》这本书。

言简意赅,对如何高效使用 C++ 11,提供了不少具体可行的建议。

尤其是并发的部分。

Meyers 说:

优先选用基于任务而非基于线程的程序设计。

这让我想到前一篇关于 Chromium 任务的 文章🔗

另外是关于 std::atomic

书里没有谈锁和条件变量,美中不足。

今天这篇文章,打算把这一块拼图补上,谈一谈原子操作(Atomic Operation)和锁(Lock),各自的概念,差异,适合的场景。

通常我们认为的原子操作更快,锁的开销很大,究竟正确吗?

1. 概念

首先要清楚,对于单线程编程,原子操作和锁都是不需要的。

只有在并发编程的时候才会用到。

熟悉并发编程的朋友应该都清楚,多个线程,同时访问同一块内存,会导致冲突

举个通俗的例子。

两个人,面前有块黑板,黑板上有个数字。两个人的任务,都是将黑板上的数字加一后写下来。

最开始的数字是 1。

两个人同时来到黑板面前,看到数字后,心里计算了一会儿,开始写。

那么黑板上最后的数字可能是什么?

可能是 2,也可能是 3

有可能是 2,是因为如果两人在对方写之前,看到的都是 1 这个数字,最后的结果就是 2。

解决这个问题,有两个办法,一种是将读和写操作包装成一个不可分割的整体,另外就是一个人“独占”黑板直到释放。

即原子操作和锁分别要做的事情。

在开始学习并发编程的时候,我将其误认为是一个东西。其实不是。它们底层的生效机制完全不同。

原子操作是指一项操作不能被分解和中断。像上文提到的例子,如果将读和写合并成一个操作,最后的结果只能是 3。

锁是通过阻塞其他线程,确保只有一个线程可以访问共享资源。这看上去有些霸道,不过可以解决问题:在特定时间,黑板只属于我一个人,其他人都得等着。

2. 差异

从使用上看,C++ 分别提供了 std::atomic<T>std::mutex

各自用法如下:

// 方案 A:原子操作
std::atomic<int> counter{0};
void increment() {
counter.fetch_add(1, std::memory_order_relaxed);
}
// 方案 B:互斥锁
std::mutex mtx;
int counter = 0;
void increment() {
std::lock_guard<std::mutex> lock(mtx);
++counter;
}

通常,原子操作针对一个特定对象,粒度较细;锁则是一块区域,经常是多个变量,粒度较粗。

在底层实现上,差异更大。

原子操作依赖 CPU 的 LOCK 指令前缀。

举例来说,x86 在执行 LOCK ADD 时,背后发生了:

  • 使用缓存锁定(Cache Locking) 锁定缓存行
  • 如果数据没有缓存或跨缓存行,总线锁定(Bus Locking)
  • 操作完成后利用 MESI 协议维护缓存一致性

这里有许多背景知识需要填充。

由于 CPU 处理和内存访问的速度差异极大,因此 CPU 内部有 多级缓存🔗。多核心缓存的一致性,通过 协议🔗 协议证。

根据局部性(Locality)原理,缓存一般以行为单位组织,称为缓存行(Cache Line),主流大小是 64 Bytes。

很多时候,CPU 都是直接从缓存行里读取变量,效率更高。

如果变量在缓存行里,锁定该行,阻塞其他核对该行的操作。

如果变量不在缓存里,就要从内存中读取,此时锁定总线,阻塞其他核和内存的通信。这种方式开销较大。

最后执行缓存一致性协议,常见的有 MESI 协议🔗

这里提一嘴 volatile,因为 Meyers 在书中也提到了。它只是用来防止编译器优化,确保每次都从内存中读取,本身无法保证原子性

相较于 CPU 原生支持的原子指令,锁的实现更多放在操作系统里。这里以 Linux 为例。

  • 利用 Futex🔗 机制先在用户态自旋
  • 若成功,直接进入临界区,不必进入内核态;失败后执行系统调用,进入内核态,在队列中等待唤醒
  • 借助 优先级继承(Priority Inheritance)🔗 优先级继承(Priority Inheritance)少优先级反转的问题(对实时系统(RTOS)影响较大)

由此可见,在 Linux 平台上,如果没有竞争(Race),加减锁的开销理应很小,毕竟都是在用户态完成。

看上去平平无奇的一把锁,操作系统其实在背后做了很多优化:核心是尽可能减少进入内核态。无论是 futex 的使用,还是自旋等待,都是在拖延进入内核态的时间。

有点 惰性求值🔗 惰性求值味道。

3. 最佳实践

最后说点实用的,什么时候用原子操作,什么时候用锁。

一句话概括,简单变量,或者说简单操作,例如标志位,用原子操作;复杂变量,尤其是涉及多个变量,或操作较多,用锁

原子操作是 CPU 天生支持,性能很好;锁的话,因为会导致线程挂起,性能略差。

当然,这只是理论上。对于实际环境中的性能测试,最好用 Profiler 验证。

DeepSeek 说,

真正的高手既不会滥用锁导致性能泥潭,也不会迷信原子操作带来隐蔽 bug。记住:没有最好的同步机制,只有最合适的场景选择。

其实最好的同步机制也是有的,就是没有同步

就像 Chromium 搭建的那套基础设施一样。

4. 总结

看似简单的两个概念背后,涉及到硬件和操作系统的无数优化。

侯捷说:源码面前,了无秘密。

有时候,尤其是现在,光会写代码是不够的,要深入到底层,了解原理,方能以不变应万变。

最近在用 Cursor,我发现,单论写代码,恐怕是写不过它的。而且差距估计会越来愈大。

人的优势,在于对生活的好奇,对现实的抽象,对问题的系统性认识,和对未知领域的探索。

(完)

参考

  1. Effective Modern C++(中文版) (豆瓣)🔗
  2. 线性一致性 - 维基百科,自由的百科全书🔗
  3. 互斥锁 - 维基百科,自由的百科全书🔗
在 GitHub 上编辑本页面

最后更新于: 2025-02-27T06:41:37+08:00