一、高性能基础库 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_hash_map.h
先记住一件事:Cyber 里到处在查「这个 ID 对应谁」。例如 Channel 号 → 缓冲区、节点号 → 名字。这些 ID 是整数,查得很勤,还经常好几个线程同时查、同时改。
std::unordered_map+ 一把大锁也能用,但锁一抢,大家都排队。AtomicHashMap的做法是:表很小、不扩容、不删除,用原子操作硬改指针。换来的是热路径上少排队。
先把几个最容易被问到的结论写死(后文展开):
- 在 CyberRT 里干什么:给整数 ID(node / channel / service / task)做并发查找,例如
GlobalData、DataDispatcher。 - 支不支持并发读写:支持
Set/Get/Has并发;没有 Delete。 - 无锁怎么保证线程安全:不用 mutex,用
std::atomic+ CAS(compare_exchange_strong),失败整轮重试。 - 扩容和冲突:桶数编译期固定(默认 128,须为 2 的幂),不扩容;冲突用桶内有序拉链(链地址法),不是开放定址。
怎么用:
apollo::cyber::base::AtomicHashMap<int,std::string>map;map.Set(5,std::string("hello"));// 插入或覆盖std::string v;if(map.Has(5)&&map.Get(5,&v)){// v == "hello"}(一)这张表长什么样
固定 128 个桶(2 的幂,用key & 127定位)。桶里是升序链表:哑头head_+ 若干Entry。value 不放在节点里,只挂指针,方便 CAS 换值。
图:Set(5,"hello")落到table_[5]。虚线框是哑头head_,实线框是有序Entry,绿色是堆上的 value。改值只 CAS 换指针。
(二)实现方法:定位、CAS、重试
无锁不是「不用同步」,而是不用互斥锁。同步收到 CAS 这一条不可打断的指令上。失败就整轮重来,系统整体还能推进。
对外入口很短:算桶,再交给桶上的Insert。
voidSet(K key,constV&value){uint64_tindex=key&mode_num_;// 5 & 127 → 5table_[index].Insert(key,value);}Insert先Find,再 CAS。
Find在升序链上走,带回插入/更新要用的前后指针。链为哑头 → 3 → 7 → 10时:
| 找谁 | 返回 | prev | target | 接下来 |
|---|---|---|---|---|
| 7 | true | 3 | 7 | CAS 换value_ptr |
| 5 | false | 3 | 7 | CAS 插到 3 和 7 中间 |
| 12 | false | 10 | 空 | CAS 接到 10 后面 |
命中则换值:new一份V,CAS 把value_ptr从旧指针换成新的,成功就delete旧对象。
未命中则先new_entry->next = target,再 CASprev->next:从target换成new_entry。必须先接好后继,再挂到前驱,否则会断链。
CAS 失败不要死磕这一行:continue回到最外层while (true),重新Find。同一 key 并发写,最后一次成功的留下。
(三)读这段源码要用的 C++ 知识点
结构、步骤看完之后,剩下的卡点几乎都是语言知识,列出用到的以下四个知识点供大家参考。
(1)左值引用、右值引用
先分清两件事:表达式是左值还是右值,以及参数写成什么引用。
| 左值 | 右值 | |
|---|---|---|
| 直观 | 有名字、能取地址,如变量s | 临时量、std::move(s) |
| 引用写法 | T&/const T& | T&& |
| 这份代码 | Entry(K, const V&)拷贝 | Entry(K, V&&)尽量移动 |
容易混的点:
const int&是左值引用,int&&是右值引用。int& a = b合法,当且仅当b是可修改的int左值;int& a = 10不合法。const int&可以绑临时量;非 const 的int&不行。- 函数参数就算写成
V&& value,有了名字之后,函数里的value是左值。所以要std::forward<V>(value)(或std::move)才能继续当右值用。
std::forward:按原来的值类别转发出去。std::move是无条件变成右值。这条V&&重载里两者效果接近;const V&那条不该写成forward<V>,拷贝和移动是两条路,不是繁简关系。
explicit Entry(K key):禁止Entry e = some_key这种隐式转换,只能显式构造。
默认构造new V()不是拷贝,只是造一个空的/零的V。对应Set(key)。
(2)compare_exchange_strong
签名可以记成:
boolcompare_exchange_strong(T&expected,T desired,memory_order success,memory_order failure);语义:若当前值 ==expected,改成desired并返回 true;否则不改,把expected写成最新值,返回 false。比较和赋值中间插不进别人的写。
这份代码里两处 CAS:
- 更新:
value_ptr:old_val_ptr→new_value - 插入:
prev->next:target→new_entry
有人抢先改了怎么办?返回 false,continue回到while (true)顶部,不是回到 CAS 那一行。插入失败即使没写continue,也会掉出else再进下一轮循环。
失败时expected会被覆盖——这是接口约定。这里失败后整轮Find,不依赖那个旧变量。
(3)memory_order这一组
原子性来自std::atomic的load/store/ CAS。memory_order_*只规定可见顺序。
| 关键字 | 用在 | 这份代码里的意思 |
|---|---|---|
release | store | 对象构造完再公布指针 |
acquire | load | 拿到指针后,能看见发布前的写入 |
acq_rel | CAS 成功 | 又读又发:既看见别人,也让别人看见自己 |
relaxed | CAS 失败 | 没改出去,只要原子性 |
acquire不是「加载完再发布」,它是读端。发布是release。
memory_order_acquire也不会让一次普通赋值变成原子操作。写成int* p = q;再怎么写 memory_order 都没用。
成对记忆:store(ptr, release)↔load(acquire)。CAS 成功acq_rel、失败relaxed,是无锁代码的常见写法。
(4)std::atomic
std::atomic<V*>value_ptr={nullptr};std::atomic<Entry*>next={nullptr};= {nullptr}是默认空指针。带 key 的构造函数里再store(new V(...), release)。
为什么 value 不直接做成V,而做成原子指针:换值时只 CAS 指针,不用拆节点。next同理,插入时 CAS 一条边。
atomic管的是指针这个字本身的读写;对象有没有构造完,要靠上面的release/acquire。
(四)核心函数:Has、Find、Insert
桶里就是一条按 key 升序的链表。Has/Find是查找,Insert是「找到了改值,没找到插节点」。无锁只多了两件事:指针用atomic读写,改链用 CAS,失败就整轮重找。
Insert只看右值版V&&。另外两个算法一样,一个拷贝、一个默认构造。
Has
// 判断链表中是否存在该节点boolHas(K key){// acquire:读到指针时,节点已经构造完Entry*m_target=head_->next.load(std::memory_order_acquire);while(Entry*target=m_target){// 空指针则到链尾,退出if(target->key<key){m_target=target->next.load(std::memory_order_acquire);continue;// 有序链,还没走到}else{returntarget->key==key;// 相等=有;更大=后面不可能再有}}returnfalse;}有序链上走:比 key 小就继续;否则看等不等于。更大就可以停,后面不会再变小。
Find
// 找到 key 应插入位置的前驱 prev 和当前 targetboolFind(K key,Entry**prev_ptr,Entry**target_ptr){Entry*prev=head_;// 哑头,保证永远有前驱Entry*m_target=head_->next.load(std::memory_order_acquire);while(Entry*target=m_target){// 走到空则链尾if(target->key==key){*prev_ptr=prev;*target_ptr=target;returntrue;// 命中,后面改 value_ptr}elseif(target->key>key){*prev_ptr=prev;*target_ptr=target;returnfalse;// 应插在 prev 和 target 之间}else{prev=target;m_target=target->next.load(std::memory_order_acquire);}}*prev_ptr=prev;*target_ptr=nullptr;// 接到尾巴returnfalse;}还是有序查找,多带回prev/target,给插入当挂钩。哑头head_保证永远有前驱。
- 相等:找到了
- 更大:应插在
prev和target之间 - 走到空:接到尾巴
Insert(K, V&&)
// 插入右值(少一次拷贝)voidInsert(K key,V&&value){Entry*prev=nullptr;Entry*target=nullptr;Entry*new_entry=nullptr;V*new_value=nullptr;while(true){// CAS 失败则整轮重找if(Find(key,&prev,&target)){// key 已存在:只换 value 指针if(!new_value){new_value=newV(std::forward<V>(value));// 移动,避免拷贝}autoold_val_ptr=target->value_ptr.load(std::memory_order_acquire);// CAS:若仍是 old,则换成 new;成功 acq_rel 发布,失败 relaxedif(target->value_ptr.compare_exchange_strong(old_val_ptr,// expected:期望仍是刚才读到的旧指针new_value,// desired:要换成的新 V*std::memory_order_acq_rel,// 成功:既看见别人,也让别人看见自己std::memory_order_relaxed)){// 失败:没改出去,只要原子性deleteold_val_ptr;// CAS 成功才删旧值if(new_entry){deletenew_entry;new_entry=nullptr;}return;}continue;// 有人抢先改了,回到 while 重新 Find}else{// key 不存在:链表插入if(!new_entry){new_entry=newEntry(key,value);}new_entry->next.store(target,std::memory_order_release);// 先接好后继// CAS:prev->next 仍是 target 才挂上 new_entryif(prev->next.compare_exchange_strong(target,// expected:挂钩还没被别人改过new_entry,// desired:新节点std::memory_order_acq_rel,std::memory_order_relaxed)){// 插入成功:prev → new_entry → targetif(new_value){deletenew_value;new_value=nullptr;}return;}// 有人抢先插了,下一轮重新 Find}}}找到了:CAS 换value_ptr,等于改链表节点上的值。
没找到:先让新节点next指向target,再 CAS 把prev->next改成新节点——普通链表插入,只是用 CAS 抢这一步。
forward是为了移动而不是拷贝。new_value/new_entry只造一次,失败留着下一轮用。CAS 失败就回到while (true)重新Find,因为链可能已经变了。
常见问题
AtomicHashMap 在 CyberRT 里有什么作用?
给热路径上的「整型 ID → 对象」做查找:节点名、Channel、回调表。它是cyber/base里的容器,不是业务模块。
原理是什么?怎么实现无锁?
固定桶数组 + 桶内有序链表。改值 CASvalue_ptr,插节点 CASprev->next。失败就while (true)里重新Find。
相比普通哈希表好在哪?
不是单线程一定更快,而是少一把全局锁。多线程同时Set/Get时不必互相堵住。代价是不扩容、不删除、key 必须是整数。
base 库里它如何设计?源码细节有哪些?AtomicHashMap只算桶下标;Bucket管链;Entry存key、原子value_ptr、原子next。对外Has/Get/Set,对内Find+Insert。详见上文(一)(四)。
高性能基础库里的无锁哈希表怎么用?
见文首示例:Set插入或覆盖,Has查询,Get取值。Get(key, V**)返回内部指针,不要delete,也不要拿太久。
支持并发读写吗?线程安全怎么保证?Set/Get/Has可并发。安全靠原子指针和 CAS,不是读写锁。同一 key 同时写,最后一次 CAS 成功的值留下。
扩容和冲突处理是怎么做的?
不扩容。冲突走链地址法:同一下标进同一条升序链表。链太长只会变慢,不会自动 rehash。
这是博主第一次发中间件类笔记,用来记下自己学中间件的过程。Cyber RT 虽已发布多年,把热路径容器做成固定大小、无锁、按整数 ID 查找,这种取舍今天读起来仍值得学。