1. 读者写者问题到底在争什么:一场缓存事故给我的教训
有一次线上服务出了个怪故障:后台管理员刚更新了一条配置,但一部分用户看到的还是旧值,另一部分已经看到新值,而且这个“分裂状态”持续了将近一分钟。一开始大家怀疑是缓存过期策略有问题,查了一圈才发现根子在一个特别基础的点——多个线程并发读写同一个共享对象时,我们没有做对互斥。这个问题的学名就是读者写者问题,也是并发编程里最经典的同步问题之一:多个读者可以同时读一份数据,写者则必须独占访问;读读可以并行,读写不行,写写也不行。
听起来像一句废话,但真正落地的时候,坑远比你想象得多。我见过不少团队用一把大锁锁所有读写,结果读多写少的场景性能直接砍半;也见过照着教科书写读者优先,结果写者被活活饿死;还有人在条件变量里用 if 判断而不是 while,导致线上出现诡异的脏读。今天这篇不是复述课本,而是结合我自己的事故排查和工程实践,把这个问题从头到尾拆一遍,顺便聊聊它和数据库、缓存、内核里的各种变体,希望能帮你少踩几个坑。
1.1 读者的“读”和写者的“写”不是对称的
要理解读者写者问题,先要抓住一个关键点:读操作不会改变数据,所以多个读者同时读同一份数据是安全的;写操作会改变数据,一旦有两个线程同时写,或者一个线程在写另一个线程在读,都可能看到中间状态。
打个比方:图书馆里有一本书,大家都在看。看书这事是可以并行的,你翻你的页,我翻我的页,互不影响。可如果有个管理员要把这本书的内容擦掉重写,那在他写的这段时间里,谁都不能看这本书——不然读者可能看到写到一半的残章,管理员也可能因为读者翻页而写错位置。更极端的是,如果有两个管理员同时改这本书,那这本书大概率会被改得面目全非。
所以读者写者问题的约束就两条:
- 读者之间不互斥,可以同时进入临界区读数据。
- 写者和所有其他线程(无论是读者还是写者)互斥,必须独占临界区。
这种不对称性是设计一切解决方案的基础。很多新手会下意识地觉得“只要保证同一时间只有一个线程访问共享数据就对了”,这当然不会出错,但会丢掉一个很大的优化空间:读并发是天然被允许的,为什么要用锁把它也串行化呢?
1.2 为什么不能用一把大锁解决一切
最朴素的做法,就是给共享数据加一把互斥锁,所有读操作和写操作都走 lock/unlock。这样正确性没问题,但并发度很差:两个读者明明可以同时读,结果被锁强制排队。
在“读多写少”的场景下,大锁的代价尤其明显。举个例子,一个商品详情页的配置,每秒有几万次读操作,但写操作可能平均几秒钟才一次。如果用一把大锁,这几万次读操作全部串行执行,锁竞争会非常激烈,CPU 时间全耗在等待锁上。而如果用读者写者锁,读操作之间完全不互斥,只有偶尔的写操作才需要独占,吞吐量能翻好几倍。
我见过不少在线服务的性能瓶颈,查到最后都不是数据库慢,而是代码里用了一个全局 synchronized 或者 std::mutex 来保护一个本来只读的热点数据。所以读者写者问题的工程价值就在这儿:它教你在允许多读并发的前提下,仍然保证写操作的原子性和可见性。当然,不同场景下“公平性”和“饥饿”问题随之而来,这也是下文要展开的重点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三套经典实现:读者优先、写者优先,以及读写公平
教科书里提到读者写者问题,通常会给三种信号量实现:读者优先、写者优先、公平读写。这三种方案看起来只是信号量排列组合不同,实际上代表了三种完全不同的设计倾向。
2.1 读者优先:一个计数器加两把信号量
先看最经典的读者优先实现。核心思路是用一个 readers 计数器记录当前正在读的线程数,rw_mutex 控制读写互斥和写写互斥,count_mutex 保护 readers 计数器的访问。
c复制// 读者优先模型:读者可抢占写者
sem_t rw_mutex; // 控制读写互斥、写写互斥
sem_t count_mutex; //
