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

为什么 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?还是读写权重可调?后者需要额外参数和运行时决策,一旦加了,就再难做到无锁路径。多数业务其实只需要“写不被饿死”,这就够了。











