C++如何设计高性能的读写公平锁以防止线程饥饿?(并发公平性)

6次阅读

std::shared_mutex 不够公平是因为它采用读偏好策略,读线程可无限插队,导致写线程被饿死;标准库未提供公平读写锁,需用 std::mutex + std::condition_variable 手动实现 fifo 队列化调度。

C++如何设计高性能的读写公平锁以防止线程饥饿?(并发公平性)

为什么 std::shared_mutex 不够公平?

它只保证写入者之间互斥,读线程可以无限插队——只要还有读线程在等,新来的读线程就能立刻抢到锁,写线程永远被压在队尾。这不是“公平”,是“读偏好”。真实场景里,一个持续写入的后台任务(比如日志刷盘、状态同步)可能被成百上千个短平快的读请求活活饿死。

关键点在于:c++ 标准库没有提供带公平队列语义的读写锁。你得自己搭。

  • std::shared_mutex 的 lock_shared()lock() 不共享等待队列,底层调度完全由 OS 决定,不可控
  • POSIX 的 pthread_rwlock_t 同样不保证公平,linux 实现甚至默认偏向读
  • 真正能控制排队顺序的,只有基于 std::mutex + std::condition_variable 手搓的队列化锁

怎么用 std::mutex + condition_variable 实现公平读写队列?

核心思路是把“谁在等”显式建模:维护一个 FIFO 队列,每个等待者注册自己的类型(read/write)和唤醒条件;每次释放锁后,只唤醒队首兼容的等待者(比如队首是 write,就只唤醒它;队首是 read,就批量唤醒所有连续的 read,直到遇到 write 或队列空)。

实操要点:

立即学习C++免费学习笔记(深入)”;

  • 必须用一个全局 std::mutex 保护等待队列和计数器,避免修改队列时竞态
  • std::condition_variable 而不是自旋,否则高并发下 CPU 白烧
  • 读计数器(m_readers)和写状态(m_writer)必须和队列操作原子协同,不能靠单独的 std::atomic 拆开管理
  • 唤醒逻辑必须严格按队列顺序走,不能“看到有读就全放”,否则破坏 FIFO

示例片段(简化):

struct FairRWLock {     std::mutex m_mtx;     std::condition_variable m_cv;     std::queue<std::pair<bool, std::condition_variable*>> m_waiters; // true=write     int m_readers = 0;     bool m_writer = false;      void lock() {         std::unique_lock lk(m_mtx);         auto cv = std::make_unique<std::condition_variable>();         m_waiters.emplace(true, cv.get());         m_cv.wait(lk, [this]{ return !m_writer && m_readers == 0; });         m_writer = true;     }      void unlock() {         std::unique_lock lk(m_mtx);         m_writer = false;         drain_queue(); // 只放行队首兼容者         m_cv.notify_all();     } };

为什么不能用 std::atomic_flag + 自旋实现高性能公平锁?

自旋锁在低争用时确实快,但公平性一加进来,性能就断崖下跌——因为每个等待线程都得不断检查队列头是否轮到自己,而队列头本身又在频繁变更。cache line 乒乓、false sharing、内存屏障开销全来了。

更实际的问题:

  • std::atomic_flag 不支持等待/通知,你得配合 std::this_thread::yield()std::this_thread::sleep_for(1ns),这已经不是“自旋”而是“忙等退避”,反而比阻塞更耗资源
  • 无法区分读/写等待者优先级,只能串行化所有请求,吞吐直接掉一半以上
  • 在 NUMA 架构下,跨 socket 的 atomic 操作延迟飙升,公平队列的等待时间变得极不稳定

std::shared_mutex + 读写超时能缓解饥饿吗?

不能根治,但可作为兜底手段。给写操作加 try_lock_for(),失败后主动让出调度权或降级为重试策略,至少不让线程卡死。

注意几个坑:

  • try_lock_for() 在某些 libstdc++ 版本中对 std::shared_mutex 实际不生效(回退为普通 try_lock()),需实测 std::chrono::steady_clock::now() 时间戳验证
  • 超时值设太短(如 1ms)会导致写线程反复抢锁失败、CPU 占用激增;设太长(如 100ms)又失去响应性
  • 读操作加超时意义不大——读被饿死通常是因为写一直拿不到锁,而不是读太多;重点该保写

真正复杂的点不在锁结构本身,而在你怎么定义“公平”:是严格 FIFO?还是读写权重可调?后者需要额外参数和运行时决策,一旦加了,就再难做到无锁路径。多数业务其实只需要“写不被饿死”,这就够了。

text=ZqhQzanResources