Appearance
锁优化、锁竞争模型、读写锁与无锁取舍(性能向)
本篇关注「锁本身成为性能瓶颈」时的系统思维,HPC/游戏服务器高并发常为此出题。
一、锁的代价分解(答「为什么锁慢」要有结构)
加锁不该是无差别「耗时大」,而应拆开讲:
- 争用(contention)成本:多个线程抢同一锁 → 只能进一个 → 串行化;阻塞唤醒还引起上下文切换(μs 级)+ cache 抖动。
- cache 一致性流量:持锁写共享变量 → 其他 cache line 失效;频繁进出把共享行在核间来回搬。
- 原子/屏障开销:
lock前缀或 acquire/release 栅栏,阻止乱序,本身有代价。 - 持锁时间:临界区内做 IO/分配/大循环 = 放大前三点。
核心公式直觉:争用 ∝ 临界区长 × 进入频率(负载的平方级增长)。锁越挤,T 越糟。
二、优化手段(按收益排序作答)
- 缩小临界区 / 持锁时间:把能移出锁外的读、不变量计算移出去,先在本地副本算完再一次同步。
- 减少锁粒度 / 分区(sharding/lock-free per-thread):
- 计数用 per-thread 局部累加,定期合并;或
std::atomic直接无锁。 - 大表拆成 N 个区各持一把锁 / striped lock。
- 计数用 per-thread 局部累加,定期合并;或
- 只读并发共享 → 读写锁(shared_mutex):读多写少,读与读可并行(写独占)。
- 代价:shared_mutex 通常比普通 mutex 略贵(要多维护读者计数);写者饥饿需注意(可用带写者偏向的)。
- 乐观 / 版本号(seqlock):写者加锁但读者无锁:读者读版本号两次一致性校验,不一致重读。适用「大量读、少量写、数据可拷贝」。
- RCU (内核) / hazard pointer:读无需任何原子写,靠「延迟回收」——超高性能读路径。
- 无锁化:能拆成原子计数/单写者就先无锁(见 lockfree.md)。
- 更快的锁实现:自旋锁(极短临界且确定短)、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)、极短则无锁/自旋。