并发协调的代价

A Mutex is Slow

Mutex 到底慢不慢?

Mutex 本身并不慢,问题的根源在于 CPU 缓存一致性协议

一个简单的基准测试演示这个问题:

rust 复制代码
// 使用原子引用计数的计数器,多个线程读取
let counter = Arc::clone(&shared_counter);

for _ in 0..n {
    // 获取读锁并读取计数器
    let _guard = black_box(&counter).lock().unwrap();
    let _value = counter.load(Ordering::SeqCst);
}

black_box 用于防止编译器优化掉这个循环,因为如果没有实际使用读取的值,编译器可能会将整个循环视为无操作。

结果

线程数 吞吐量(单线程基准)
1 线程 ~250 million ops/sec(约每 10 条指令完成一次加锁+读取)
2 线程 ~25 million ops/sec(下降约 10 倍)
更多线程 几乎持平,略微下降

违反直觉增加线程后性能反而下降。按理说互斥锁只允许一个线程执行,其他线程应该等待------性能应该保持不变,而不是变差。这说明存在其他影响因素。


CPU 缓存的三层

要理解这个问题,需要先了解 CPU 缓存的工作方式

层级 延迟 特点
L1 Cache ~1 ns 离 CPU 最近,直接焊在 CPU 上,非常小
L2 Cache ~3-5 ns 比 L1 大,比 L3 小
L3 Cache ~10-20 ns 多个核心共享,距离主存更近
主存 (RAM) ~100 ns 距离 CPU 最远,延迟极高

主存访问对于 CPU 来说「极其漫长」------在等待主存响应的同时,CPU 可以执行数百条指令。

缓存行 (Cache Line)

CPU 将主存划分为 64 字节 的块来管理,每个块称为一个 缓存行 (cache line)。所有缓存一致性的协调都基于缓存行而非单个字节。


MESI 协议:缓存一致性

当多个 CPU 核心需要访问同一块内存时,需要一个协议来协调------这就是 MESI 协议

四种状态

状态 含义
Modified (M) 当前核心持有唯一副本,且数据与主存不一致(脏数据)。需要写回主存才能让其他核心读取
Exclusive (E) 当前核心持有唯一副本,数据与主存一致。其他核心没有这个缓存行时处于此状态
Shared (S) 多个核心都持有该缓存行,且数据值相同
Invalid (I) 当前核心没有该缓存行的有效副本

当一个核心要将 Shared 状态的缓存行改为可写时:

复制代码
核心必须与所有其他核心通信
→ 确认它们都已经放弃对该行的修改权限
→ 将状态转为 Exclusive
→ 然后才能写入

这就是跨核心通信的来源 ------每次协调都需要在核心之间「ping-pong」传递缓存行。

延迟数据

操作 延迟
L1 Cache 访问 ~1 ns
跨核心通信 (cache line transfer) ~30 ns
RAM 访问 ~100 ns

跨核心通信的延迟是 L1 缓存访问的 30 倍,几乎达到主存延迟的三分之一


读写锁的隐藏成本

Reader-Writer Lock 的问题

读写锁的实现内部有一个读者计数器 。获取读锁时需要对这个计数器执行 fetch_add(原子加一)操作。

问题在于如果 100 个线程都要获取读锁,它们都在对**同一个共享字段**执行写操作

复制代码
线程 0: 获取 Exclusive → 修改计数器 → 转为 Shared
线程 1: 观察到 Modified → 核心 0 写回数据给核心 1 → 核心 1 变为 Modified
线程 0: 释放锁 → 核心 1 写回 → 核心 0 变为 Modified
线程 2: ...
...(每个读者都需要一次 cache line ping-pong)
  • 单线程:~250 million ops/sec(与 Mutex 相同)
  • 单线程 Mutex:性能随线程增加而下降,最终稳定在低水平
  • Reader-Writer Lock :开始时与 Mutex 相同,但随着读者增加,性能比 Mutex 更差

原因

对于互斥锁,持有锁的线程可以一直保持 Exclusive 状态直到释放

对于读写锁,每个读者都需要修改共享计数器,导致频繁的缓存行争夺

Reader-Writer Lock 在读多写少场景下「理应」更快,但实际上随着读者数量增加,它们彼此之间的竞争反而更严重


什么时候锁的开销真正成为问题

当锁保护的代码(临界区)很短时,锁的开销才会成为瓶颈。

短代码(如简单计数),锁开销可能超过执行时间,成为主要成本

这解释了为什么大多数代码不会遇到这个问题------只有在高度专业化的 hot path 代码中,这个问题才会显现。


方案:Left-Right 数据结构

设计思想

问题:所有读者为什么要访问同一个共享缓存行?如果读者之间不需要协调,它们的缓存行应该保持独立。

Left-Right 数据结构保留两份数据副本

复制代码
┌─────────────────────────────────────────┐
│           Atomic Pointer                │
│    (指向 left 或 right 副本)             │
└─────────────────────────────────────────┘
        │                      │
        ▼                      ▼
   ┌─────────┐           ┌─────────┐
   │  Left   │           │  Right  │
   │  Copy   │           │  Copy   │
   └─────────┘           └─────────┘
  • 读者:通过原子指针读取当前激活的副本
  • 写者:修改非激活的副本,然后切换指针

读者计数器机制

读者需要通知写者「我已经看到新指针了」才能安全地开始修改旧副本。实现方式:

每个读者维护自己的计数器,每个计数器独占一个缓存行 (通过 #[repr(align(64))] 对齐)。

rust 复制代码
// 读者每次读取前递增计数器
reader_counter.fetch_add(1, Ordering::SeqCst);
let data = atomic_pointer.load(Ordering::SeqCst).data;
reader_counter.fetch_add(1, Ordering::SeqCst);

// 写者切换指针后,遍历所有读者计数器
// 如果某个计数器的值变化了至少 1,说明该读者已经看到新指针
for reader in all_readers {
    if current_value - previous_value >= 1 {
        // 该读者已切换,可以安全修改旧副本
    }
}

性能结果

线程数 吞吐量
1 线程 ~250 million ops/sec(基准)
N 线程 线性增长,接近 N × 基准

读者之间完全独立,不需要任何协调。读操作是 无锁的 (lock-free)无等待的 (wait-free)------不需要获取任何锁,不需要等待写者完成。


调试:False Sharing(伪共享)

在测试 Left-Right 数据结构时,发现当线程数达到 4 时,性能突然下降 10 倍------而不是预期的线性增长。

原因

缓存行大小是 64 字节。如果多个线程的计数器碰巧落在**同一个缓存行**上

复制代码
[线程 0 计数器][线程 1 计数器][线程 2 计数器][线程 3 计数器]
<---------------------- 同一缓存行 ----------------------->

即使每个线程只修改自己的计数器,由于它们在同一个缓存行上,MESI 协议仍然会将缓存行在核心之间来回传递。

修复

rust 复制代码
// 一行修复:将计数器类型对齐到 64 字节
#[repr(align(64))]
struct ReaderCounter {
    value: AtomicUsize,
}

现在每个计数器必须位于独立的缓存行上,问题消失。

总结

无锁 (lock-free) ≠ 无竞争 (contention-free)

即使没有使用锁,内存布局和缓存一致性机制仍然会对性能产生重大影响。


选择并发原语的决策框架

需要问自己的问题

  1. 读写比例是多少?

    • 读多写少 → Left-Right 可能适合
    • 读写均衡 → 互斥锁可能是更好的选择
  2. 临界区有多长?

    • 短临界区 → 锁开销占比高,需要仔细选择
    • 长临界区 → 大多数并发原语都足够好
  3. 需要多少线程?

    • 少量线程(< 3)→ 大多数情况下不需要特别优化
    • 高并发 → 协调成本急剧增加
  4. 能否容忍最终一致性?

    • Left-Right 保证最终一致性不保证线性一致性
    • 金融交易等场景需要强一致性保证,不适合
  5. 需要线性化保证吗?

    • 某些场景需要严格的 interleaving 约束
    • 需要了解具体的一致性语义需求

各方案的平衡

方案 优点 缺点 适用场景
Mutex 简单,正确性好强保证 高竞争下性能差 大多数场景
Reader-Writer Lock 读多写少时理论上高效 读者多时彼此竞争 读密集但写不频繁
Left-Right 读者完全并行,无锁读取 写操作成本高;需要两份数据;不保证立即可见性 极稀疏的写操作
Lock-Free 数据结构 不阻塞线程 实现复杂;需要精心设计 高性能关键路径

Left-Right 数据结构的额外限制

  1. 单写者限制

    • 只允许一个写者。如果需要多个写者,必须在 Left-Right 外层再加互斥锁
  2. 操作必须是确定性的

    • 需要能够将操作记录为日志并重放
    • 因为写者需要将同一批操作应用到两个副本
  3. 写者需要等待所有读者离开

    • 写者不能立即开始修改,必须等待所有读者切换到新副本
  4. 不是线性可化的

    • 只保证最终一致性,不适合所有场景

硬件层面的演进

MESI 协议的演进

  • 最初:MSI 协议
  • 后来:添加了 E (Exclusive) 状态,可以避免一半的缓存行竞争
  • 现代 CPU:已经有 7-8 个状态的变体(但具体实现是专有的)

3D V-Cache 技术

AMD 的 3D V-Cache 技术将 L3 缓存堆叠在 CPU 上方,缩短了物理距离,有助于减少某些场景下的延迟。

Fetch-Add 优化

Fetch-Add 操作是可交换的 (commutative),理论上可以让多个 CPU 批量执行后再统一通信。但这种优化目前似乎没有在实际平台中使用。


  • 协调是昂贵的 ,不是因为锁本身慢,而是因为缓存一致性协议需要在核心之间传递缓存行

  • 没有银弹------每种并发方案都有权衡。选择的关键是理解你的应用特征(读写比例、临界区长度、一致性需求),然后选择与这些特征匹配的数据结构

    性能 = 选择与你的数据访问模式相匹配的算法
    + 利用你的工作负载的所有特征
    + 消除不必要的跨核心通信

永远要**先测量再优化**,理解你正在优化的具体瓶颈是什么。

相关推荐
不爱学英文的码字机器4 小时前
[鸿蒙PC命令行移植适配]移植rust三方库sd到鸿蒙PC的完整实践
华为·rust·harmonyos
星栈5 小时前
Makepad 不只是画界面:事件、状态和组件通信,到底怎么写
前端·rust
绍磊leo5 小时前
Tauri 2.x 教程系列 (二):React 组件化与 Tauri 命令系统
rust·tauri
日火5 小时前
Are Mutexes Slow——互斥锁真的慢吗?
性能优化·rust
禁默5 小时前
[鸿蒙PC命令行移植适配]移植rust三方库eza到鸿蒙PC的完整实践
华为·rust·harmonyos
绍磊leo5 小时前
Tauri 2.x 教程系列 (一):Hello Tauri — 从零搭建第一个桌面应用
rust·tauri
smallswan5 小时前
第十七 位运算
rust
a诠释淡然1 天前
C++ vs Rust:哪个更适合你的下一个项目?
开发语言·c++·rust
Vallelonga1 天前
Rust 生命周期标注积累
开发语言·rust