Skip to content

锁优化、锁竞争模型、读写锁与无锁取舍(性能向)

本篇关注「锁本身成为性能瓶颈」时的系统思维,HPC/游戏服务器高并发常为此出题。

一、锁的代价分解(答「为什么锁慢」要有结构)

加锁不该是无差别「耗时大」,而应拆开讲:

  1. 争用(contention)成本:多个线程抢同一锁 → 只能进一个 → 串行化;阻塞唤醒还引起上下文切换(μs 级)+ cache 抖动。
  2. cache 一致性流量:持锁写共享变量 → 其他 cache line 失效;频繁进出把共享行在核间来回搬。
  3. 原子/屏障开销lock 前缀或 acquire/release 栅栏,阻止乱序,本身有代价。
  4. 持锁时间:临界区内做 IO/分配/大循环 = 放大前三点。

核心公式直觉:争用 ∝ 临界区长 × 进入频率(负载的平方级增长)。锁越挤,T 越糟。

二、优化手段(按收益排序作答)

  1. 缩小临界区 / 持锁时间:把能移出锁外的读、不变量计算移出去,先在本地副本算完再一次同步。
  2. 减少锁粒度 / 分区(sharding/lock-free per-thread)
    • 计数用 per-thread 局部累加,定期合并;或 std::atomic 直接无锁。
    • 大表拆成 N 个区各持一把锁 / striped lock。
  3. 只读并发共享 → 读写锁(shared_mutex):读多写少,读与读可并行(写独占)。
    • 代价:shared_mutex 通常比普通 mutex 略贵(要多维护读者计数);写者饥饿需注意(可用带写者偏向的)。
  4. 乐观 / 版本号(seqlock):写者加锁但读者无锁:读者读版本号两次一致性校验,不一致重读。适用「大量读、少量写、数据可拷贝」。
  5. RCU (内核) / hazard pointer:读无需任何原子写,靠「延迟回收」——超高性能读路径。
  6. 无锁化:能拆成原子计数/单写者就先无锁(见 lockfree.md)。
  7. 更快的锁实现:自旋锁(极短临界且确定短)、mcs/排队锁(减少 cache 行争用与惊群)、std::mutex 的 futex 简化。

三、自旋锁 vs 互斥锁(细致对比)

自旋锁(spinning)互斥/排队锁
等待忙等(循环 CAS/test)睡眠(futex/内核排队)
什么时候用临界区极短且已知、不切换更划算、多核忙占亦可临界区可能长、需要让出 CPU 给别线程
CPU烧(空转)让位,上下文切换有成本
风险单核/抢占式调度下持锁线程被调度走 → 死等(需可抢占+超时)无此问题但切换成本高
用途内核 / 高频元数据结构短更新大部分业务

工程答案模板:先按读多写少选读写锁,临界区超短且能确定则自旋/无锁,否则互斥锁;实在有竞争再用分区减争用;再犹豫用 profiling。

四、假共享(False Sharing)——HPC 超高频

现象:两个线程写「不同变量」,但两变量恰在同一条 cache line(64B) 上 → 每个写都使对方那行失效 → 核间大量一致性消息 → 吞吐骤降,有时比真共享还难查(因为明明"各写各的")。

cpp
struct PerThread {
    long counter;
    // char pad[56];          // 让结构体占满一行,隔离
};
// 或用 alignas(64) 对齐数组元素,令每个元素占独立 cache line

对策

  • 热点每线程数据按 cache line 对齐隔离alignas(64)/std::hardware_destructive_interference_size(C++17)。
  • 把「只读共享」与「每线程写」分开布局。
  • 避免两个频繁写变量紧挨同 cache line。

面试题:给个多线程计数器暴慢场景 → 先怀疑 false sharing,用 perf c2c / 按行对齐验证,再优化。

五、Amdahl / Gustafson 常被串起来问性能上限

  • Amdahl:串行比例 P 决定加速上限 1/P。别把「串行化(同一把锁全局计数器)」写进每周期的热路径。
  • Gustafson:数据规模变大,串行部分相对变小 → 分析时按「实际负载并行度」看收益,别光看 Amdahl 悲观结论。

六、读写锁 shared_mutex(C++17)要点

cpp
std::shared_mutex m;
void read()  { std::shared_lock<std::shared_mutex> lk(m); /* 只读 */ }
void write() { std::unique_lock<std::shared_mutex> lk(m); /* 独占 */ }
  • 读锁可多个并存,写锁独占;读锁与写锁互斥。
  • 适用:读远多于写、写可容忍一点的写者延迟/饥饿。
  • 不适用:写频繁(读写竞争重,shared_mutex 反而不如普通 mutex)。

七、总结一句

锁的种类只是工具;性能瓶颈在「共享与争用」。先想:能不能不共享(per-thread)、少共享(分区)、读共享(读写锁/RCU)、极短则无锁/自旋。

C++ 面试八股 · VitePress 版