优秀 C++ 代码赏析:bRPC 中的多线程一致性保障机制
bRPC 中有许多值得学习的 C++ 实现。它的价值不只在于架构设计,也在于如何把并发约束、对象生命周期和性能快慢路径落到具体代码中。
一次 RPC 可能同时面临 response、timeout、cancel 和连接失败,还可能因 retry 或 backup request 存在多个网络 attempt。bRPC 需要解决三个问题:
- 多种事件并发到达时,如何只有一个事件完成 RPC;
- slot 复用后,如何防止迟到事件访问新调用;
- 多个 attempt 如何归属于同一个逻辑 RPC。
bRPC 把 ResourcePool、CallId、version range 和 per-Id lock 组合成一套调用生命周期机制。这套方法不只适用于 RPC,也值得需要管理大量并发对象的网络程序借鉴。
1. 针对等长对象使用 ResourcePool<T> 创建和释放,获得比通用 malloc 更稳定、快速的性能
多线程小对象分配需要在两个目标之间权衡:
- 尽量走 thread-local 路径,避免每次分配都竞争全局锁;
- 允许资源跨线程回收,避免“线程 A 一直申请,线程 B 一直释放”导致内存滞留。
bRPC 的 Id 是等长对象,这使得 ResourcePool 无需处理任意尺寸分配、合并和外部碎片,可以用 block 和整数索引管理存储。
获取资源的主要路径是:
- 优先从 thread-local free chunk 取一个已释放的 ID。
- 本地为空时,从全局 free-chunk 队列批量取回一组 ID。
- 没有可复用对象时,从当前 thread-local block 构造新对象。
- 当前 block 已满时,再向全局 pool 申请新 block。
释放时则先将 ID 放入当前线程的 free chunk,chunk 满后再批量归还全局 pool。
绝大多数分配和释放
│
▼
thread-local FreeChunk
│ 批量交换
▼
global FreeChunk
│ 无可复用对象
▼
Block
这个设计将高频操作留在线程本地,但又通过批量交换保留了跨线程复用能力。它不保证每次操作都无锁,追求的是更少的全局竞争和更稳定的延迟。
ResourcePool 使用一个 32 位 ResourceId 定位对象:
ResourceId = block_index * BLOCK_NITEM + item_index
由此可以反向计算 block 和 item 下标,以 O(1) 的索引操作找到对象,不会因为空闲槽位碎片化而发生线性扫描。
ResourcePool 解决的是 Id 的分配、定位与复用,并不直接解决 RPC 状态一致性。
这也提供了一个可迁移的经验:当系统长期、高频地创建同一种等长对象时,针对该类型设计的专用对象池,可能比 jemalloc 等通用分配器具有更少的元数据开销、更稳定的延迟和更好的局部性。但这是特定负载下的工程优化,应当由压测证明,而不应将每个类型都变成一套彼此隔离的内存池。
2. ResourcePool slot 拼接 version,解决 slot 复用后的 ABA 问题
CallId 在类型上就是 bthread_id_t,但在 bRPC 中专门用来表示一次逻辑 RPC。它的 64 位布局是:
┌───────────────────────┬───────────────────────┐
│ ResourcePool slot │ version │
│ 高 32 位 │ 低 32 位 │
└───────────────────────┴───────────────────────┘
高 32 位使用 ResourcePool 定位 Id,低 32 位校验该 ID 是否仍属于当前生命周期。Id::data 保存对应的 Controller*。
当一个 Id 被销毁并归还 ResourcePool 后,其 slot 可能很快被新 RPC 复用。新旧 ID 虽然 slot 相同,version 却不同。迟到的 response、timeout 或 cancel 因此无法访问新 Controller。
旧 RPC: [slot 42 | version 100] ──销毁──┐
├── slot 42 被复用
新 RPC: [slot 42 | version 110] ──有效──┘
迟到的 version 100 不能操作 version 110 的对象。
3. version range 将一个逻辑 RPC 和多个网络 attempt 关联起来
Controller 持有一个基础 CallId,Channel 根据 max_retry 为它预留一段连续 version。基础 ID 表示整个逻辑 RPC,后续 version 表示各次 attempt:
[slot 42 | version 100] 基础 CallId:cancel、timeout、Join
[slot 42 | version 101] 首次 attempt
[slot 42 | version 102] retry 1
[slot 42 | version 103] retry 2
它们通过相同 slot 定位同一个 Id 和 Controller,又通过不同 version 区分具体 attempt。
假设首次请求发给 Server A,retry 发给 Server B。Server A 的迟到响应携带 version 101,而 Controller 当前等待的是 version 102,因此可以安全忽略旧响应。
这一设计把两个层次分开了:
Logical RPC / Controller
├── attempt 0 / Call / Server A
├── attempt 1 / Call / Server B
└── backup attempt / Call / Server C
单个 attempt 失败不一定会完成 RPC,它可能只是触发 retry。最终完成的是 Controller 代表的逻辑 RPC。
“slot + generation”也是一种通用的并发对象句柄。例如游戏中的 entity 或资源管理器可以用 slot 定位对象,用 generation 判断句柄是否已过期。即使对象销毁后原位置被复用,旧句柄也不会错误操作新对象。bRPC 进一步利用 version range,把多个子操作关联到同一个逻辑对象。
4. per-Id lock 让 response、timeout、cancel 竞争唯一终态
bRPC 没有让多个线程通过原子变量各自修改 Controller,而是让每个 Id 自带锁定状态、版本范围和 pending error 队列。
对于同一个 RPC:
- response 使用 versioned attempt ID 锁定 Id;
- timeout 和 cancel 向基础 CallId 投递 error;
- Socket 失败也通过 CallId 投递 error。
如果 Id 当前没有持有者,事件可以获得它并处理 Controller;如果 Id 已被锁定,错误事件会进入 pending_q,而不是和当前持有者并发修改 Controller。
Response ─────────► versioned attempt ID ──┐
Timeout ─────────► base CallId ────────┤
Cancel ──────────► base CallId ────────┤
Socket failure ────► CallId ────────────┘
│
▼
同一个 Id
│
▼
串行修改 Controller
因此 bRPC 并不是在这里追求 lock-free,而是把竞争缩小到单个 RPC:两个无关 RPC 使用不同 Id,不会互相竞争。
对同一 Id 的 lock 获取顺序定义了这些并发事件的处理顺序,从而为 Controller 状态转移提供清晰的线性化点。
这也说明了“无锁”不应成为脱离负载的目标。只有同一 RPC 的多个终止事件同时到达时,per-Id lock 才会发生真正竞争。如果这不是系统的性能热点,使用细粒度锁往往能以很小的代价换取更少的状态分支、更清楚的生命周期与更容易验证的正确性。是否改为无锁结构,应当由 contention 指标和压测结果决定。
5. 分离 attempt 生命周期与逻辑 RPC 生命周期
OnVersionedRPCReturned() 的设计价值不在于它包含多少个分支,而在于 bRPC 没有把“一次网络请求完成”等同于“用户 RPC 完成”。
网络 attempt 只需报告自己的结果与 version;Controller 则在更高层决定该结果应当被忽略、触发 retry,还是结束整个 RPC。这使得 retry、backup request 和迟到响应都能被纳入同一个逻辑调用,而不会向用户暴露多次完成。
这是一个可复用的分层思路:
传输层:某个 attempt 成功或失败
│
▼
策略层:忽略 / retry / 发起 backup / 结束调用
│
▼
用户层:逻辑 RPC 只完成一次
当逻辑 RPC 最终结束时,整段 version range 被统一作废。于是“对用户只完成一次”与“所有迟到 attempt 都不再有效”由同一个生命周期边界保证,而不是靠每个分支各自记得清理。
6. Socket 只保存 CallId,却能将连接失败扇出给所有 RPC
一个 Socket 可能承载多个并发 RPC。Socket 不直接拥有这些 Controller,而是在 _id_wait_list 中记录需要接收连接失败通知的 CallId。
连接失败时,Socket 遍历 wait list 投递错误。每个 CallId 再通过 Id::data 找到 Controller,进入和 timeout、cancel 相同的串行化路径。
Socket failed
│
▼
_id_wait_list
├── CallId A ──► Controller A
├── CallId B ──► Controller B
└── CallId C ──► Controller C
这种设计同时保留了两种索引:
- Socket 知道连接失败时应该通知哪些 CallId;
- CallId 知道应该安全地访问哪个 Controller。
这体现了 bRPC “大 Socket”的设计理念:Socket 不只是 fd 的 RAII 封装,而是一个网络连接的运行时对象。它集中管理连接生命周期、写入序列化、失败传播、健康状态、引用计数和协议相关状态。
代码上的关系也因此非常直接: 将一条连接的运行时事实聚合在同一个对象中,是成熟网络框架中常见的方法。它使失败传播、关闭与并发写等跨模块行为有一个明确的归属点,也便于围绕连接快速路径做整体优化。代价是 Socket 的职责会很多,必须依靠清楚的不变式和子结构维持可审查性。
7. 这套机制值得学习的地方
bRPC 的 CallId 不只是一个用于响应匹配的 correlation number。它串联了:
- ResourcePool 的 O(1) 索引定位;
- slot 复用后的 version 校验;
- 一个逻辑 RPC 内的多个 attempt;
- response、timeout、cancel 和 socket failure 的细粒度串行化;
- callback 的唯一完成;
Join()与对象销毁。
这套设计最值得学习的地方,不是某个函数或宏,而是它如何选择稳定句柄作为并发协议的中心:
用 slot 快速定位对象,用 version 拒绝迟到事件,用 version range 表达多个 attempt,用 per-Id lock 让真正相关的事件有序地修改同一个 Controller。
从 C++ 代码组织上还可以得到几个经验:
- 跨线程传递稳定句柄,而不是没有生命周期保障的裸指针;
- 将 ResourcePool 的本地分配、Id 的无竞争锁定设计成明确快速路径,将批量回收和 pending error 留在慢路径;
- 把 version 校验、锁定和销毁等不变式集中在
bthread_id_t基础设施中,不让每个 RPC 分支重复实现; - 让底层 Id 机制只负责并发排序和生命周期,让 Controller 负责 retry 和 backup 等 RPC 策略。
优秀的并发 C++ 代码不一定意味着最少的锁或最多的原子操作。更重要的是,它能否用明确的快慢路径和集中的不变式,让性能和正确性同时可以被分析、测量和验证。