一、高性能基础库 base ├── (1)无锁哈希表 AtomicHashMap ├── (2)原子读写锁 AtomicRWLock ← 本文 ├── (3)读写锁守卫 RWLockGuard ← 本文 ├── (4)可重入读写锁 ReentrantRWLock ├── (5)有界队列 BoundedQueue ├── (6)等待策略 WaitStrategy ├── (7)无界队列 UnboundedQueue ├── (8)线程安全队列 ThreadSafeQueue ├── (9)线程池 ThreadPool ├── (10)对象池 ObjectPool ├── (11)并发对象池 CCObjectPool ├── (12)信号槽 Signal ├── (13)区间遍历 FOR_EACH └── (14)基础宏 macros源文件:cyber/base/atomic_rw_lock.h、cyber/base/rw_lock_guard.h
上一篇的哈希表自己改指针,表里面没有锁。Cyber 里还有调度器的协程表、拓扑图、Clock:普通容器,读的人多、改的人少。一把mutex会让读者也排队。下面这把锁允许好几个线程同时读,写的时候必须一个人独占。
先看最小用法。加锁、解锁你都不用手写,花括号结束就会解开:
usingapollo::cyber::base::AtomicRWLock;usingapollo::cyber::base::ReadLockGuard;usingapollo::cyber::base::WriteLockGuard;AtomicRWLock lock;// 默认写优先{ReadLockGuard<AtomicRWLock>g(lock);// 进来:加读锁// 读共享数据。别的线程也可以同时拿读锁}// 出去:自动解读锁{WriteLockGuard<AtomicRWLock>g(lock);// 进来:加写锁// 改共享数据。这时别人读、写都进不来}// 出去:自动解写锁ReadLock()是私有的,所以你不能写lock.ReadLock()。为什么要私有、守卫里那几行在干什么,放到第(五)节。先搞清楚锁里那两个数字。
(一)两个数字:lock_num_和排队人数
锁的全部状态就是一个原子整数lock_num_,再加一个「有多少人正在抢写锁」的计数。常量只是起名:RW_LOCK_FREE = 0,WRITE_EXCLUSIVE = -1。
lock_num_ | 名字 | 含义 | 别人可以做什么 |
|---|---|---|---|
0 | 空闲 | 没人持锁 | 读者可以变成1;写者可以变成-1 |
1, 2, 3… | 读者人数 | 几个线程正在读 | 读者可以再+1;写者必须等到回到0 |
-1 | 写者独占 | 恰好一个线程在写 | 读者、写者都要等 |
另外还有write_lock_wait_num_:已经执行了 WriteLock、但还没把lock_num_改成-1的人数。它不是「已经持有写锁」,只是报名。默认write_first_ = true:这个数大于 0,新读者就不许再+1。
对应到源码成员:
// 0表示没人占用锁staticconstint32_tRW_LOCK_FREE=0;// -1表示有人占用写锁staticconstint32_tWRITE_EXCLUSIVE=-1;// 最大重试次数staticconstuint32_tMAX_RETRY_TIMES=5;// ...// 等待写锁的线程数std::atomic<uint32_t>write_lock_wait_num_={0};// 锁的占用情况std::atomic<int32_t>lock_num_={0};// 是否优先写锁boolwrite_first_=true;数字看懂之后,再用门口来记迁移(先有表,再有这句话):0是空屋子;正数是屋里几个读者;-1是一个写者把门锁上。写者进门前先在门口挂个号,write_lock_wait_num_就是挂了几个号。
这一节的问题
- 正数为什么表示读者、要用
-1表示写者?因为一个整数要同时表达「几个读者」和「有没有写者」。读者用加一减一;写者单独占一个负数,避免和「一个读者」的1混在一起。 - 这把锁给谁用?调度器、拓扑仓库、
Clock。哈希表自己 CAS,不用它。
(二)场景一:读者进门
默认写优先时,读锁是两层循环:内层等到「现在能读」,外层用 CAS 把人数加一。
inlinevoidAtomicRWLock::ReadLock(){uint32_tretry_times=0;int32_tlock_num=lock_num_.load();// 判断当前是否可读if(write_first_){do{// 如果锁的占用情况小于0或者等待写锁的线程数大于0,则等待while(lock_num<RW_LOCK_FREE||write_lock_wait_num_.load()>0){if(++retry_times==MAX_RETRY_TIMES){// 告诉操作系统让出CPU时间片std::this_thread::yield();retry_times=0;}lock_num=lock_num_.load();}// 代表没人占用写锁,可以占用读锁}while(!lock_num_.compare_exchange_weak(lock_num,lock_num+1,std::memory_order_acq_rel,std::memory_order_relaxed));}else{// 读优先do{while(lock_num<RW_LOCK_FREE){if(++retry_times==MAX_RETRY_TIMES){// saving cpustd::this_thread::yield();retry_times=0;}lock_num=lock_num_.load();}}while(!lock_num_.compare_exchange_weak(lock_num,lock_num+1,std::memory_order_acq_rel,std::memory_order_relaxed));}}yield():当前线程对操作系统说「先跑别人」。不是把锁解开,也不是睡固定时间。
读优先(AtomicRWLock lock(false))内层只判断lock_num < 0,不管有没有人报名写。默认构造不走那条。
带数值走一遍:没有写者
假设现在lock_num_ = 2(两个读者),write_lock_wait_num_ = 0。线程 A 来拿读锁。
lock_num读到2。- 内层条件:
2 < 0为假,wait > 0为假,不进内层 while。 - CAS:期望仍是
2,想改成3。没人抢,成功。 while (!true)为假,函数返回。现在lock_num_ = 3,三个读者。
CAS 可以先记成一句话:原子量还等于我手里的期望值,才改成新值。这里期望是局部变量lock_num(2),新值是3。
带数值走一遍:有人在抢写锁
同一时刻线程 B 已经进了WriteLock,报名成功,write_lock_wait_num_ = 1,但屋里还有读者,B 还没把数改成-1。线程 A 再来读。
lock_num可能仍是2。- 内层:
wait > 0为真,卡在这一行的 while 里,反复load,满 5 次yield。 - A 不会去执行后面的 CAS,所以不会出现「写者在排队,读者人数还在涨」。
- 等 B 抢到
-1再释放,wait回到 0 且lock_num_回到 0 之后,A 才能 CAS0 → 1。
这就是写优先:写者还没拿到锁,只报了名,新读者已经被挡住。否则读者可以一直+1,写者永远看不到0。
外层do-while还防一种情况:内层刚跳出,别人抢先把数改了。CAS 失败则!false为真,再进内层看门。
这一节的问题
- 真正上读锁的是哪一句?最后的 CAS。前面的 while 只是等。
- 线程 A 在写者报名时卡在哪?
while (lock_num < 0 || wait > 0)。
(三)场景二:写者进门(报名是关键)
inlinevoidAtomicRWLock::WriteLock(){int32_trw_lock_free=RW_LOCK_FREE;uint32_tretry_times=0;// 等待写锁的线程数加1write_lock_wait_num_.fetch_add(1);// 尝试获取写锁while(!lock_num_.compare_exchange_weak(rw_lock_free,WRITE_EXCLUSIVE,std::memory_order_acq_rel,std::memory_order_relaxed)){// rw_lock_free will change after CAS fail, so init aginrw_lock_free=RW_LOCK_FREE;if(++retry_times==MAX_RETRY_TIMES){// saving cpustd::this_thread::yield();retry_times=0;}}// 等待写锁的线程数减1write_lock_wait_num_.fetch_sub(1);}compare_exchange_weak(期望, 新值):
- 成功:
lock_num_从0变成-1,返回true。前面有!,while 结束。 - 失败:原子量不变,但会把你传入的
rw_lock_free改成当前真实值,返回false。
最后这一点对初学者最容易漏:失败改的是你手里那个局部变量,不是「把锁改坏」。所以循环里必须再赋成0,下次还是「我期望空闲」。
带数值走一遍
开始:lock_num_ = 2,wait = 0。线程 B 要写。
wait变成1。之后新读者都会卡在上一节的内层 while。- CAS:期望
0,实际是2,失败。rw_lock_free被改成2。 - 若不写回
0,下一轮会拿期望2去比,可能误把「两个读者」换成-1,把读锁拆掉。所以要rw_lock_free = 0。 - 两个读者陆续离开:
2 → 1 → 0。 - B 再 CAS:
0 → -1成功。wait减回0。B 独占。
报名为什么必须在 CAS 之前:若先抢0 → -1、抢不到再报名,中间会有空档,新读者可以继续+1,写者可能一直饿着。先报名,写优先才成立。
weak和哈希表里的strong都是 CAS。weak允许「值其实没变也失败一次」,这把锁外层本来就要重试,用weak即可;换成strong也对。假失败时同样会改期望值,同样要写回0。
这一节的问题
- 为什么还没拿到锁,报名就能挡读者?因为读者看的是
wait > 0,不是「已经是-1」。 while (!cas)何时为真?CAS 失败(屋里不空,或 weak 空失败)就继续转;成功才停。
(四)场景三:解锁
inlinevoidAtomicRWLock::ReadUnlock(){lock_num_.fetch_sub(1);}inlinevoidAtomicRWLock::WriteUnlock(){lock_num_.fetch_add(1);}fetch_sub/fetch_add自己是原子的,不用再 CAS。
| 当前 | 谁走 | 之后 | 为什么 |
|---|---|---|---|
3 | 一个读者 | 2 | 人数减一 |
1 | 最后一个读者 | 0 | 屋子空了,写者可以进 |
-1 | 写者 | 0 | -1 + 1 = 0,不是再减一 |
写者若也减一,会变成-2,读者会把「小于 0」一直当成有人在写,锁就坏了。
这四处才会改lock_num_:读 CAS 成功、写 CAS 成功、读解锁、写解锁。空转里的load只看不改。
同一线程再拿一次写锁:lock_num_已经是-1,自己再 CAS0 → -1永远失败,会空转等自己。这把锁不能重入,那是(4)ReentrantRWLock的事。
(五)守卫:为什么例子里要写ReadLockGuard
上面四句加解锁都是private。类开头写了友元,只有守卫能调:
friendclassReadLockGuard<AtomicRWLock>;friendclassWriteLockGuard<AtomicRWLock>;所以不是「建议用守卫」,是只能用守卫。这也逼你把「加」和「解」绑在同一个栈对象上,避免ReadLock()之后return把ReadUnlock()跳过。
RAII:资源跟对象的寿命走。对象造出来就拿资源,对象销毁就还资源。
{ReadLockGuard<AtomicRWLock>g(lock);// 构造 → ReadLock()// ...}// 析构 → ReadUnlock(),即使中间 return 也会走这里源码
template<typenameRWLock>classReadLockGuard{public:explicitReadLockGuard(RWLock&lock):rw_lock_(lock){rw_lock_.ReadLock();}~ReadLockGuard(){rw_lock_.ReadUnlock();}private:ReadLockGuard(constReadLockGuard&)=delete;ReadLockGuard&operator=(constReadLockGuard&)=delete;RWLock&rw_lock_;};