【嵌入式linux学习】为什么原子操作开销通常比锁更小
先总结一下:
原子操作是硬件 CPU 原生支持的单条指令;
锁(自旋锁 / 互斥锁)本质是用原子操作 + 循环 / 睡眠包装出来的高层逻辑,天然多了很多额外工作。
文章目录
- 【嵌入式linux学习】为什么原子操作开销通常比锁更小
- 1. 原子操作底层是什么
- 2. 锁(自旋锁为例)底层是什么
- 3. Cache-line(缓存行)影响
- 原子操作场景
- 自旋锁场景
- 4. 开销对比总结
- 5. 但是!原子操作有巨大局限性
- 6. 多种同步方法进行测试比较
- 【拓】MESI协议
- 状态流转(通俗场景举例)
- 场景 1:CPU0 第一次读 val
- 场景 2:CPU1 也读同一个 val
- 场景 3:CPU0 要写 val(val++)
- 场景 4:现在 CPU1 想读 val
- 总线嗅探(snoop)是什么?
- 回到之前的话题:原子操作、锁为什么会触发 MESI 开销
- 1. `lock` 前缀(原子操作)做了什么(x86)
- 2. 自旋锁为什么比单4纯原子操作更容易引发 MESI 风暴
- 3. 伪共享(false sharing)
- 4. MESI 的局限性
- 总结
- 7.总结
1. 原子操作底层是什么
原子操作(atomic_t、atomic_inc、cmpxchg等),最终编译成 CPU 硬件指令:比如 x86 的lock add。lock前缀会锁住缓存行,保证这一条内存操作不可被打断,整个读 - 改 - 写一次性完成。 整个过程:
- CPU 拿到对应变量所在缓存行;
- 执行带 lock 的单条指令完成修改;
- 释放缓存行独占权。
只做一件事:完成变量的读修改写。
2. 锁(自旋锁为例)底层是什么
自旋锁spinlock_t,底层本身也要靠原子操作来抢锁。 伪代码看自旋锁spin_lock():
// 抢锁:用原子操作尝试把锁从0改成1 while( atomic_cmpxchg(&lock, 0, 1) != 0 ) { // 抢不到,原地循环忙等(自旋) cpu_relax(); }spin_unlock:原子操作把锁置回 0。
可以看到: 自旋锁底层依赖原子指令,但是多了:
- 循环重试逻辑(抢不到锁就一直自旋)
- 锁变量本身的读写、缓存行竞争
- 内存屏障(保证指令顺序)
互斥锁(mutex)开销更大:抢不到锁的时候,进程会睡眠、上下文切换、放入等待队列、唤醒。上下文切换要保存 / 恢复寄存器、换页表,这是非常重的开销。
3. Cache-line(缓存行)影响
CPU 缓存是以缓存行(64 字节)为单位工作的。 当多核 CPU 修改同一个缓存行里的数据,会触发缓存一致性协议(MESI),核之间来回传递缓存行状态(失效、共享、独占),这是多核最大的性能杀手。
原子操作场景
只修改目标原子变量这一个缓存行。一次 lock 指令,短暂独占缓存行,修改完成立刻释放。
操作对象只有业务变量本身。
自旋锁场景
有两个变量在参与竞争:
- 业务变量(你真正要保护的数据)
- 锁变量本身
锁变量是独立变量,大概率也占用一个缓存行;多核抢锁的时候,锁变量所在缓存行会疯狂的在各个核之间来回失效、同步。 哪怕大家只是抢锁、还没操作业务数据,MESI 协议就已经在不停抖动缓存行。
锁带来了额外的缓存行竞争。这就是原文说:原子操作对 cache-line 影响更小。
举个例子:多个核做计数器累加
- 方案 A:
atomic_inc(&cnt),只争cnt这一个缓存行。 - 方案 B:自旋锁保护
cnt++,多核抢锁的时候,锁变量的缓存行持续乒乓失效,额外引入大量 MESI 流量。
4. 开销对比总结
- 指令层面原子操作:单条硬件原子指令。 锁:原子操作 + 循环 / 等待 + 内存屏障,更多指令。mutex 还会有进程睡眠、唤醒、上下文切换。
- 缓存一致性开销原子操作:只竞争业务变量的缓存行。 锁:锁变量本身也会引发多核缓存行竞争,额外增加 MESI 开销。
- 阻塞特性原子操作:永远不会睡眠,不会发生进程上下文切换。 mutex:抢不到锁进程休眠,上下文切换开销巨大。 spinlock:抢不到锁原地自旋,空耗 CPU。
5. 但是!原子操作有巨大局限性
原子操作只能保护单个整型变量的简单操作(自增、加减、比较交换)。 如果你的临界区是多条语句、多个变量,原子操作无能为力,必须使用锁。
例子:
// 可以原子:单变量自增 atomic_inc(&ref); // 不能用原子一次性保护:读取a、读取b、赋值c,这是多条操作 c = a + b;原子操作保护的范围 = 单条硬件指令能完成的范围。临界区一旦变复杂,只能上锁。
6. 多种同步方法进行测试比较
但是,对于那些有高性能要求的代码,对多种同步方法进行测试比较,不失为一种明智的做法。
原因:
- 多核高度竞争场景:即使原子操作,大量
lock指令也会造成缓存行严重乒乓,性能暴跌; - 有些场景,可以换无锁算法(per-cpu 变量,每个核单独计数,最后汇总),比原子操作性能更好;
- 少量并发场景,有时候轻量锁的实际表现不一定比原子差;理论开销只是理论,高性能代码一定要实测。
【拓】MESI协议
前置背景:多核 CPU,每个核都有自己的 L1/L2 高速缓存;缓存最小单位是缓存行(cache line,一般 64 字节)。 多个 CPU 核心,缓存里可能同时保存同一块内存的数据副本。如果不做管理,A 核改了缓存里的数据,B 核还拿着旧副本,就会出错。 MESI 就是多核之间维护缓存一致性的协议,用来保证:所有 CPU 看到的同一份内存数据是一致的。
MESI 是四个状态缩写,每个缓存行在某个 CPU 的缓存里,只能处于下面四种状态之一:
- M Modified(修改):缓存里的数据已经被修改,和主存不一致。这个缓存行是当前 CPU 独有的。必须在合适时机写回主存。
- E Exclusive(独占):缓存行有效,和主存一致;其他 CPU 缓存没有这份数据。当前核可以直接修改,不需要通知别人。
- S Shared(共享):缓存行有效,和主存一致;多个 CPU 的缓存里都有这份副本。任何一个核想写数据,必须先通知其他核,把他们的副本置为无效。
- I Invalid(无效):这份缓存行数据作废,不能使用。需要读内存重新加载。
一句话记住:M 修改、E 独占、S 共享、I 无效。状态保存在每个 CPU 缓存行的标记位。
状态流转(通俗场景举例)
假设系统有 CPU0、CPU1 两个核,变量val放在内存,初始值 = 0。
场景 1:CPU0 第一次读 val
CPU0 去读内存,把 val 所在缓存行加载到自己 L1 缓存。 此时 CPU1 缓存没有这份数据 → 状态变成E(独占)。
CPU0 可以直接修改 val,不需要通知 CPU1。
场景 2:CPU1 也读同一个 val
CPU1 发起读请求,总线嗅探(snoop)发现:CPU0 缓存有这个缓存行(E 状态)。 CPU0 把缓存行状态改成S(共享),把数据发给 CPU1。 CPU1 拿到数据,自己缓存行状态也设为S。
现在 CPU0、CPU1 缓存都是 S 状态,两份副本相同,和内存一致。两个核都只能读;谁想写,必须广播消息。
场景 3:CPU0 要写 val(val++)
CPU0 要修改 S 状态的缓存行,会向总线发失效请求(Invalidate)。 CPU1 收到嗅探消息,把自己缓存里这个缓存行标记为I(无效)。 CPU0 收到确认,把自己缓存行改成M(修改),然后修改缓存里 val 的值。
✅ 此时:CPU0 缓存 M,CPU1 缓存 I;主存数据还是旧的。后续 CPU0 在合适时机把 M 的数据写回内存,状态变回 E。
场景 4:现在 CPU1 想读 val
CPU1 读,嗅探发现 CPU0 缓存行是 M 状态。 CPU0 把缓存行数据发回主存,同时发给 CPU1;CPU0 状态变为 S;CPU1 收到数据状态 S。 两个核又回到共享状态。
总线嗅探(snoop)是什么?
所有 CPU 都监听系统总线上的缓存操作消息。当某个核发读 / 写、失效消息,其他核都能看到,检查自己缓存行对应的状态,做出响应。
MESI 依靠总线广播消息来同步状态。多核越多,总线压力越大。
回到之前的话题:原子操作、锁为什么会触发 MESI 开销
1.lock前缀(原子操作)做了什么(x86)
lock add这种原子指令,本质是:在这条指令期间,独占这个缓存行。
- CPU 拿到缓存行,如果是 S 状态,广播失效,把其他核副本置 I;
- 自己独占缓存行,完成读改写;
- 释放。 整个过程会触发 MESI 状态跳转、总线消息。开销来源就是缓存行状态来回切换,俗称缓存行乒乓(cache line bouncing)。 多核并发修改同一个变量,各个核不断抢缓存行,反复 S↔M↔I 来回切换,性能急剧下降。
2. 自旋锁为什么比单4纯原子操作更容易引发 MESI 风暴
自旋锁有两个变量:锁变量 + 业务变量。 多个核抢锁的时候,大家反复读写锁变量。锁变量本身在一个独立缓存行。 多核不断 CAS 争抢锁变量,导致锁变量对应的缓存行持续在各个核之间来回失效、传递。 哪怕业务数据还没碰,锁变量本身就产生大量 MESI 流量。
原子操作对 cache-line 影响更小,原因就在这里。
对比:原子计数器
atomic_inc(&cnt),只争 cnt 这一个缓存行。自旋锁保护 cnt++,锁变量 + 业务变量两个缓存行都发生竞争。
3. 伪共享(false sharing)
MESI 最经典坑:两个独立变量,放在同一个 64 字节缓存行里。 CPU0 修改变量 A,CPU1 修改变量 B。A、B 无关,但共享同一个缓存行。 CPU0 写 A → 广播失效消息,CPU1 缓存行变 I;CPU1 写 B,又广播失效。 明明修改的是两个互不相关变量,却疯狂触发 MESI 失效,性能暴跌。这叫伪共享。 解决:__attribute__((aligned(64)))做缓存行对齐,把变量分隔开,避免放到同一个缓存行。
4. MESI 的局限性
- 总线广播:核越多,总线嗅探消息越多,扩展性差。
- 状态切换有开销:S/M/I 之间跳转、等待其他核应答,都是延迟。
- MESI 保证缓存一致性(cache coherence),不等于内存一致性(memory ordering)。
MESI 保证所有 CPU 看到缓存的数据一致;但不保证指令执行顺序,所以 CPU 会乱序执行,需要内存屏障(
mfence/smb等)来约束指令顺序。这个点面试经常问,区分缓存一致性和内存序。
atomic_inc 里面的 lock 前缀,和自旋锁抢锁时的缓存行锁,是一回事吗?
底层都是 MESI 缓存行独占机制,但原子操作只锁这一条指令周期;自旋锁会持续反复竞争锁变量的缓存行,竞争持续时间更长,缓存颠簸更严重。
总结
MESI 是多核 CPU 缓存一致性协议,每个缓存行有 M/E/S/I 四种状态。依靠总线嗅探,多核互相感知缓存行状态;当一个核修改共享缓存行时,会广播失效消息,让其他核的副本失效。
原子操作的 lock 指令会触发 MESI 状态切换;自旋锁除业务变量外,锁变量本身也会造成额外缓存行乒乓竞争。伪共享就是无关变量落在同一个缓存行,引发不必要的 MESI 失效。MESI 解决缓存数据一致性,但不解决 CPU 指令乱序,需要内存屏障。
7.总结
原子操作依托 CPU 硬件提供的原子指令,只完成单个变量的读 - 改 - 写操作,只会引起目标变量缓存行的短暂竞争。 而锁(自旋锁、互斥锁)底层本身就要依赖原子操作,还额外引入锁变量带来的缓存行竞争;自旋锁会忙等,互斥锁还会触发进程睡眠和上下文切换,所以开销更大。
但原子操作能力有限,只能保护单个变量的简单操作;多条语句、多个变量的临界区,仍然需要锁。高性能场景不能只靠理论推断,需要实测对比。