读 brpc 源码时,最容易得到一个正确但没有解释力的答案:它用了 epoll、用户态线程、无锁队列和零拷贝,所以快。

真正的问题是:这些机制怎样连成一条完整的 RPC 链路?响应、超时、取消、重试和备用请求可能同时到达时,谁决定最终结果?连接被多个 Channel 和请求共享时,怎样避免写竞争?业务代码阻塞时,为什么不会把一个 pthread 一起钉死?服务列表持续变化时,负载均衡如何保证读路径足够便宜?

这篇文章沿着一次 RPC 的生命周期回答这些问题。分析基于 Apache brpc 提交 8b2410d,提交时间为 2026-09-15。源码链接都固定到这个提交,避免后续代码移动导致结论与行号错位。

需要先给出本文最重要的判断:

brpc 的核心不是一个“高性能网络库”,而是一套把调用状态、协议、连接、调度、内存、服务发现、流量治理和诊断统一在同一运行时里的 RPC 系统。它的性能来自端到端协作,不来自某个单独的系统调用或数据结构。

01 全景:brpc 把哪些层放进了同一个进程

brpc 整体架构

图 1:brpc 的主要分层。Controller 是单次调用的状态中枢,bthread、IOBuf、Socket 与 bvar 则组成所有层共享的运行时基础。

先用一张表建立源码地图。

层关键对象解决的问题主要目录
API 与调用状态Channel、Controller、Server初始化调用、超时、取消、重试、回调src/brpc/
服务发现与选址NamingServiceThread、LoadBalancer、SocketMap动态实例列表、连接复用、节点选择src/brpc/details/、src/brpc/policy/
协议与消息Protocol、InputMessenger、InputMessageBase序列化、协议识别、请求/响应分发src/brpc/protocol.h、src/brpc/policy/
连接与 I/OSocket、Transport、EventDispatcher连接生命周期、读写合并、事件通知src/brpc/socket*、event_dispatcher*
并发运行时TaskControl、TaskGroup、butexM:N 调度、工作窃取、协程感知同步src/bthread/
内存与数据IOBuf、ResourcePool、对象池分段缓冲、引用共享、减少分配src/butil/
治理与诊断bvar、Span、builtin services指标、追踪、rpcz、在线诊断src/bvar/、src/brpc/builtin/

Channel 并不持有“一个请求的一切”。它更像可复用的调用入口,保存协议、命名服务、负载均衡和连接策略。真正的一次调用状态都在 Controller:请求和响应附件、超时、错误、对端、重试次数、trace、call id 以及最终回调。

这也是源码依赖图里 Controller 成为最大枢纽的原因。它位于 API、负载均衡、Socket、协议和可观测性之间,是一次 RPC 的事务对象。

02 一次客户端 RPC 的完整生命线

一次 brpc 调用的完整时序

图 2:一次 RPC 的关键时序。响应、超时、取消、重试和 backup request 最终都回到同一个版本化 call id 状态机。

客户端从 Channel::CallMethod() 开始,大致经过下面十步:

  1. 合并 ChannelOptions 和本次 Controller 上的选项。
  2. 为 call id 分配可用版本范围。
  3. 创建 client span,记录协议、方法名和开始时间。
  4. 把 protobuf request 序列化到 IOBuf。
  5. 注册 timeout 或 backup request 定时器。
  6. Controller::IssueRPC() 通过负载均衡选择目标 Socket。
  7. 协议的 pack_request 组装线上的帧,并注册响应回调。
  8. Socket::Write() 进入共享连接的发送链路。
  9. 对端响应回来后,协议处理器用 correlation id 找回这次调用。
  10. Controller::OnVersionedRPCReturned() 决定结束、重试、发备用请求或忽略迟到结果。

同步和异步调用只在最后一步的等待方式上不同。传入 done 时,调用返回后由完成路径执行 closure;done == nullptr 时,Join(correlation_id) 阻塞当前执行单元。若当前是 bthread,Join 会让出 worker;若当前是 pthread,则等待 pthread。这使同一套 API 同时支持同步写法和异步执行。

2.1 最精巧的对象:版本化 bthread_id

Controller::IssueRPC() 中有一段非常关键的注释:

call_id         : timeout / cancel
call_id + 1     : first attempt
call_id + 2     : retry 1
...
call_id + N + 1 : retry N

这里的 id 不是简单的请求序号。它同时承担四个职责:

  • 关联响应:协议中的 correlation id 能找到对应 Controller。
  • 完成同步:同步调用可以 Join,完成路径负责唤醒。
  • 异步事件入口:超时和取消通过 bthread_id_error() 注入同一完成路径。
  • 版本屏障:每次 attempt 使用不同版本,旧 attempt 的迟到响应无法覆盖新状态。

假设第一次请求已经失败并触发 retry 1,此时第一次请求的响应才到。它携带的是 call_id + 1,当前 attempt 已经是 call_id + 2。OnVersionedRPCReturned() 会识别旧版本,清理该分支并直接忽略,而不是让迟到响应修改用户的 response。

这比“给每种事件各放一个布尔变量”更可靠。响应、超时、取消、重试都在竞争同一个带版本的完成权,状态和等待原语合并在一个对象里。

2.2 backup request 与 retry 是两种机制

二者都可能再次发送请求,但目的不同:

机制触发条件目标原请求状态
Retry当前 attempt 出现策略允许重试的错误恢复瞬时故障当前 attempt 已结束
Backup request首个 attempt 超过延迟阈值仍未完成对冲长尾延迟原 attempt 仍在运行

在 OnVersionedRPCReturned() 中,EBACKUPREQUEST 会保存原 _current_call,把另一份 Call 放进 _unfinished_call,排除已经访问的节点,然后并行发起下一次请求。两个 attempt 谁先成功,谁取得最终完成权。普通 retry 则先结束当前 attempt,清理旧响应,再根据退避策略重发。

ExcludedServers 也很重要。它把刚刚失败或已被 backup 访问的节点交给负载均衡器,避免“重试了一次,但又选回同一台机器”。这不是强保证,因为集群规模和策略可能限制选择,但它把恢复动作和节点选择联系了起来。

03 服务端接收链路:一次就绪事件如何变成业务方法

Linux 下的主路径可以压缩成:

epoll edge-triggered event
  -> EventDispatcher
  -> Socket::ProcessEvent
  -> TcpTransport::ProcessEvent
  -> InputMessenger::OnNewMessages
  -> Protocol::parse
  -> ProcessInputMessage
  -> Protocol::process_request
  -> protobuf Service::CallMethod

3.1 epoll 只负责通知,真正的并发模型在其上层

EventDispatcher 等待 fd 事件。读事件到来后,Socket::ProcessEvent() 把处理交给具体 Transport。TCP transport 会启动一个 urgent bthread 执行读取。

所以 epoll 只是 readiness 通知器。性能还取决于:谁来 drain fd、一次读多少、如何切消息、消息是否并发执行、业务阻塞后怎样让出 worker。只说“用了 epoll”解释不了这些行为。

3.2 ET 模式要求一次尽量读空

InputMessenger::OnNewMessages() 围绕 IOPortal 循环读取和解析,直到 EAGAIN、EOF、错误或达到控制条件。IOPortal 使用 readv 把数据直接读进多个可写 block,解析器再从分段缓冲上识别完整消息。

一个读循环可能得到多条消息。brpc 不会机械地为每条消息都做一次同样的调度:前面的完整消息可以启动独立 bthread 并发处理,批次最后一条消息可在当前 bthread 内直接执行。这减少了一次任务投递和上下文切换,同时保留流水线并行。

3.3 多协议识别是函数表,不是继承树

Protocol 是一张函数指针表,主要槽位包括:

  • parse
  • serialize_request
  • pack_request
  • process_request
  • process_response
  • verify
  • parse_server_address
  • get_method_name

服务器收到新连接时,可以让注册的 parser 根据输入前缀判断协议。parser 返回“完整消息”“还需更多数据”“不是该协议”或错误,匹配成功后连接记住该协议,后续消息不必重复全量探测。

这种设计的效果有三点:

  1. 协议的 wire format 与 RPC 核心解耦,HTTP、H2、Redis、Memcache 和 baidu_std 可以共用 Socket、调度和可观测性。
  2. 热路径是直接函数调用,不要求每个消息穿过深层虚函数体系。
  3. client/server 能力可分别表达,例如只有 process_response 的客户端协议。

代价也很明确:函数表依赖约定,类型系统无法检查不同回调之间的语义一致性;协议注册发生在全局初始化阶段,动态卸载并不是设计目标。

04 发送链路:共享连接上怎样让很多调用一起写

brpc 的 single connection 可以被许多 Channel 和 RPC 复用。若所有调用都拿一把连接写锁,每次只写自己的小包,锁竞争、系统调用和 cache line 抖动都会变重。

Socket::WriteRequest 使用 cache-line 对齐的请求节点。发送者准备好节点后,在 Socket::StartWrite() 对 _write_head 做原子 exchange:

  • 如果旧 head 为空,当前发送者成为 writer,直接负责排空队列。
  • 如果已有 writer,当前请求接到 MPSC 链上,然后返回。
  • writer 调用 DoWrite(),把多个请求组织成批次写出。
  • 遇到部分写或 EAGAIN 时,KeepWrite 等待 EPOLLOUT 后继续。

这是一个“多生产者、单写者”的所有权转移模型。它没有让多个线程同时操作 fd,而是让竞争者只做原子入队,由获得所有权的 writer 合并发送。

它带来三个直接效果:

  • 减少共享 mutex 的竞争。
  • 将多个小请求合并到更少的 writev/发送操作中。
  • 保证同一个 fd 的写状态、部分写位置和错误收口由一个执行者维护。

这里要准确地区分局部与整体:写请求入队的关键路径主要依靠原子操作,不等于整个 Socket 无锁。连接建立、失败恢复、SSL、健康检查和生命周期管理仍然需要锁、butex 或其他同步。

05 bthread:让阻塞式代码跑在 M:N 调度器上

I/O 事件与 bthread 调度

图 3:I/O 线程只产生可运行任务;TaskGroup 在本地队列、远端队列和其他 worker 之间取任务。butex 等待会挂起 bthread,并把 pthread 留给其他任务。

brpc 希望业务代码可以自然地写同步逻辑,同时承受大量并发。bthread 的答案是 stackful M:N 调度:很多 bthread 映射到较少的 worker pthread 上。

5.1 TaskControl 管全局,TaskGroup 管一个 worker

一个 worker pthread 对应一个 TaskGroup。每个 group 主要面对两类任务来源:

  • _rq:当前 worker 的本地 work-stealing queue。
  • _remote_rq:其他线程向这个 group 投递任务的远端队列。

本地 push/pop 由 owner 高频操作,空闲 worker 可以从其他 group 的另一端 steal。跨线程提交走 remote queue,避免多个生产者直接破坏 owner 优化过的本地队列。

WorkStealingQueue 的关键并发关系是:owner 执行 push/pop,其他 worker 执行 steal;只有队列剩最后一个元素时,owner 和 thief 才真正竞争。这正是 work stealing 适合调度器的原因:大多数操作局部化,负载不均时才付出跨核代价。

5.2 空闲 worker 不自旋烧 CPU

TaskGroup::task_runner() 先取本地和远端任务,再尝试 steal。仍然无任务时进入 ParkingLot。新任务到来后,提交方依据并发状态唤醒 parked worker。

这构成一个完整的弹性过程:局部队列保持 cache locality,steal 修正不均衡,ParkingLot 避免空闲轮询。

5.3 butex:同步语义知道“当前是谁在等”

butex 可以理解为 bthread-aware futex。它的地址指向一个原子值,等待者只有在值仍等于 expected 时才睡眠,从而避免检查条件与入睡之间丢失唤醒。

butex_wait() 会区分调用上下文:

  • bthread 中等待:把当前 task 放入 waiter 队列,切走并运行其他 bthread。
  • 原生 pthread 中等待:走 pthread/futex 兼容路径,阻塞这个线程。

因此,bthread_mutex、condition variable、Join、fd wait 等 API 能保持阻塞式写法,又不会在 bthread 上把 worker pthread 一起阻塞。前提是使用 bthread-aware 的同步和 I/O API;若业务在 bthread 中调用一个无法让调度器感知的阻塞系统调用,worker 仍会被钉住。

5.4 stackful 上下文为何能运行普通 C++ 调用栈

bthread 为任务准备独立栈,stack allocator 使用 mmap,并可用 PROT_NONE guard page 捕获越界。架构相关汇编保存和恢复寄存器、栈指针和指令位置。于是 bthread 在任意普通函数深处等待时,可以保留完整调用栈,恢复后从原位置继续。

代价是每个活跃 stackful task 都有栈地址空间和上下文切换成本。brpc 通过栈池和不同栈规格摊薄分配,但它仍不等于无栈 coroutine。仓库里的 C++20 coroutine 是另一套能力,不能与经典 bthread 栈切换混为一谈。

5.5 bthread local 必须随任务迁移

work stealing 意味着一个 bthread 恢复时可能换了 worker。普通 thread_local 属于 pthread,不能表达“逻辑请求上下文”。bthread key table 跟随 TaskMeta,因此 bthread-local 数据会随着逻辑任务迁移。这也是 tracing、请求上下文或兼容 pthread TLS 语义时必须注意的边界。

06 IOBuf:减少数据搬运,而不是一句“全链路零拷贝”

IOBuf 不是连续 std::string,而是分段缓冲:

IOBuf
  ├─ BlockRef { Block*, offset, length }
  ├─ BlockRef { Block*, offset, length }
  └─ ...

Block
  ├─ reference count
  ├─ capacity / size
  └─ bytes[]

Block 拥有字节,BlockRef 只描述一段视图,IOBuf 管理 ref 集合。复制或把一个 IOBuf append 到另一个时,append_to(IOBuf*) 通常复制 BlockRef 并增加引用计数,不复制 payload。

6.1 它具体省掉了哪些复制

  • 协议 header 和 body 可以用 ref 拼接成一条逻辑消息。
  • parser 可以通过 cut/pop 移动视图,不必移动剩余字节。
  • request/response attachment 可以跨层传递 block 所有权。
  • IOBufAsZeroCopyInputStream/OutputStream 让 protobuf 直接读写 block。
  • IOPortal 用 readv 一次填充多个空闲 block。
  • Socket 写出时可以从多个分片生成 iovec。

小对象还采用 in-place 表示:少量 BlockRef 直接放在 IOBuf 自身,超过阈值才转成动态 ref 数组。block 有线程本地缓存,减少频繁 malloc/free 和全局 allocator 竞争。

6.2 为什么不能叫“端到端零拷贝”

用户数据仍可能经历:应用构造 protobuf、protobuf 序列化、TLS 加密缓冲、内核 socket buffer、网卡 DMA、对端解析。压缩、协议转换以及落到连续 std::string 也会复制。

所以准确表述是:**IOBuf 通过分段引用、scatter/gather I/O 和 protobuf stream adapter 消除框架内部许多不必要的数据搬运。**它没有承诺每种协议、每条路径都从用户对象到网卡完全零拷贝。

07 动态服务发现:变化在后台,读路径保持便宜

Naming service 持续产生 ServerNode 集合。brpc 将新旧列表排序后做集合差分,把 added/removed 节点通知 watcher。NamingServiceThread::Actions 同时维护 endpoint 到 Socket 的关系。

SocketMap 的 key 不只有 endpoint,还包含 channel signature。相同地址且连接参数兼容的 Channel 可以共享 Socket;TLS、连接选项等签名不同时则不会错误共享。这样,服务发现更新的是逻辑节点集合,连接对象仍有独立引用计数和健康状态。

7.1 DoublyBufferedData 把锁成本推给写者

负载均衡器的 server list 是典型“频繁读、偶尔改”。DoublyBufferedData 保存前台和后台两份数据:

  1. reader 通过线程本地 wrapper 读取当前 foreground,并用本地 mutex 标记读区间。
  2. writer 修改 background。
  3. writer 原子切换 foreground。
  4. writer 逐个等待已有 reader 离开旧 foreground。
  5. writer 再把修改应用到原 foreground,使两份数据重新一致。

读者不争抢一把全局 RWLock;不同线程通常只触碰自己的 wrapper 和一个前台索引。写者承担两次修改和等待旧读者的成本。这不是通用替代方案:写频繁、数据复制昂贵或读临界区很长时,写侧代价会显著上升。但它非常适合 server list、证书映射这类 read-mostly 状态。

08 负载均衡:选“谁”不能只看轮询

brpc 通过 Extension<LoadBalancer> 注册多种策略,GlobalExtensions 内置 round-robin、weighted random、locality-aware、P2C、consistent hashing 等实现。

8.1 Locality-aware load balancing

LocalityAwareLoadBalancer 不只问“谁的平均延迟最低”。它根据成功反馈中的延迟、吞吐和 inflight 信息动态调整节点权重。选择更多落到近期产出高、排队少的节点;性能退化后,反馈会降低其流量。

这种反馈环比静态权重更能适应异构机器和暂时抖动,但也会引入探索与收敛问题。若请求代价差异巨大、反馈样本稀少或延迟主要由调用方造成,权重可能不能准确代表节点能力。

8.2 P2C + peak EWMA

P2CEwmaLoadBalancer 随机采样少量候选,再比较基于 peak EWMA latency、inflight 和 weight 的负载分数。P2C 不用每次遍历全体节点,却能显著避开繁忙节点;peak EWMA 对近期高延迟反应更快,随后随时间衰减。

8.3 Consistent hashing

一致性哈希把节点和请求 key 放到 hash ring 上。成员变化时,只重新映射环上局部 key,适合 cache、分片和需要亲和性的请求。它优化的是映射稳定性,不自动保证实时负载均衡;热点 key 仍可能造成热点节点。

三种策略解决的是不同问题:LALB 关注反馈控制,P2C 关注低开销的动态避忙,一致性哈希关注数据亲和与最小重映射。没有一种策略在所有业务中都最优。

09 容错与过载保护:五个环不能混成一个概念

brpc 把客户端恢复和服务端保护拆成多条控制回路。

机制观察对象动作主要作用域
Retry单次 attempt 的错误码重新选址并发送客户端调用
Backup request未完成调用的尾延迟并行发第二份请求客户端调用
Circuit breaker某个 Socket 的错误和延迟暂时隔离 endpoint客户端节点
Health check失效连接的恢复状态探活并重建可用性连接/endpoint
Adaptive concurrencymethod 的 QPS、延迟、并发接收或以 ELIMIT 拒绝服务端 method

9.1 熔断器隔离的是 endpoint

CircuitBreaker 使用错误成本和延迟的 EMA 判断连接是否异常。熔断后节点进入隔离期,隔离时长会更新。它阻止流量持续打向已明显失败的节点;健康检查则负责判断节点何时恢复。

熔断不等于 retry。retry 决定“这个调用再试一次吗”,熔断决定“这个 endpoint 现在还应被选择吗”。

9.2 backup request 用额外流量换尾延迟

阈值太小会把正常的稍慢请求大量复制,放大后端压力,压力又进一步推高延迟。阈值太大则无法覆盖真正的长尾。合理做法是基于 latency CDF 选择阈值,并限制 backup 比例。当前源码还提供 rate-limited backup policy,用滑动窗口近似约束额外请求比例。

它只适合幂等操作,或服务端有业务级去重。框架保证的是“客户端只接受一个最终结果”,无法自动撤销后端已经执行的另一个副作用。

9.3 自适应并发限制是服务端 admission control

AutoConcurrencyLimiter 用 Little’s Law 建立并发、QPS 和延迟的关系,估算无负载延迟 min_latency 与峰值吞吐 max_qps,再更新 method 的最大并发。当前源码中的常规计算可以概括为:

max_concurrency = min_latency_us × ema_max_qps / 1,000,000 × (1 + explore_ratio)

explore_ratio 不是常数。当前窗口的平均延迟较低,或 QPS 还没有接近历史峰值时,它会逐步增大,为继续探测吞吐留出并发余量;延迟和 QPS 都显示系统接近高负载时,它会逐步减小。失败请求还可以按策略折算成延迟惩罚。限流器会周期性按 reduce_ratio 降低并发,重新测量 no-load latency,避免高负载下的延迟被误当成新基线。

仓库的中文文档仍给出早期的 max_qps × ((2 + alpha) × min_latency - latency) 公式,与当前实现不一致。这里以固定提交中的代码为准,这也是阅读长期演进项目时必须同时核对文档和实现的一个例子。

限流的目标不是让所有请求都成功,而是主动拒绝一部分请求,避免排队把所有请求一起拖垮。集群要获得完整收益,客户端还应能把 ELIMIT 重试到其他节点。

10 bvar 与 rpcz:观测能力为什么能放进热路径

可观测性如果每次更新都争抢全局锁,会直接破坏被观测系统。bvar 的核心策略是“写入分散、读取聚合”。

AgentGroup 为每种 reducer 分配 agent id,每个线程在 TLS block 中维护自己的 agent。Adder、Maxer、LatencyRecorder 等更新通常落在本线程状态上;查看 /vars 或采样时,再由 combiner 聚合各线程值。

这会把成本分配成:

  • 高频写:局部、少竞争。
  • 低频读:遍历和聚合,成本更高。

百分位数没有保存无限样本。PercentileInterval 在有限 bucket 中采样并合并,因此能给出有界内存的近似分位数。它适合在线诊断,不应该被误解为保存每一次调用的精确统计数据库。

10.1 Span 同时服务 tracing 和 rpcz

client/server span 记录 method、trace/span id、开始时间、请求/响应大小、错误码以及子调用关系。Span::dump_to_db() 把采样结果索引到本地存储,/rpcz 可以按时间或 trace 查看调用。

源码还处理了几个现实问题:限制最小保存延迟、限制采样速率、异步落盘、过载时丢弃待处理 span、只序列化已经结束的 child span。说明 rpcz 的第一原则是不能反过来拖垮业务。

10.2 builtin services 把进程变成可检查系统

Server 内置页面包括 /vars、/rpcz、/connections、/sockets、/flags、/status、/threads、/bthreads、/pprof 和 protobuf 元数据等。Describable 接口让 Channel、load balancer、Socket 等对象能输出统一的运行时描述。

这不只是“自带监控页”。它把内部状态模型变成了运维接口:选了哪台节点、连接为何失败、bthread 是否堆积、某个 method 延迟分布怎样,都可以在运行中的进程里追查。

11 C++ 层面的工程技巧

brpc 是系统 C++ 项目,很多效果来自语言机制与数据布局的组合。

11.1 RAII 收口异步回调

ClosureGuard 在析构时执行 protobuf closure。服务方法可以在多个 early return 分支中保持“done 最终执行一次”的语义;需要把所有权转走时调用 release()。同类思想也用于 fd guard、锁 guard、unique_ptr 和引用对象。

11.2 intrusive ref count 与版本化资源 id

Socket 等热对象需要被异步任务、连接表和回调共同持有。侵入式引用计数把计数直接放在对象内,减少额外 control block 和间接访问。资源池 id 除了 slot 还携带版本,slot 被回收再利用后,旧 id 不能误命中新对象。这是在对象池环境中抵抗 ABA/悬空句柄的关键思路。

11.3 placement new、对象池和 TLS 缓存

ResourcePool 按 block 分配对象,线程先从本地 free chunk 获取或归还,再成批和全局结构交换。placement new 在已分配 slot 上构造对象。它降低小对象高频 new/delete 的 allocator 成本,但也增加生命周期复杂度,调用方必须正确 reset 后复用。

11.4 cache-line alignment 与内存序

调度器队列、Socket::WriteRequest、bvar TLS block 等结构使用 cache-line alignment,避免不同线程频繁写入落在同一 cache line 上。原子变量也不是全部使用最强的 sequential consistency:纯计数使用 relaxed,发布对象或切换可见状态使用 release/acquire。

这类优化的前提是先写清 happens-before 关系。较弱内存序不是语法层面的“更快开关”,用错后会产生只在特定架构和压力下出现的并发错误。

11.5 模板注册表与函数指针插件

Extension<T> 为 naming service、load balancer、limiter 等组件提供 name 到实现的注册表。协议层则用函数指针描述 parse/serialize/process。模板负责同类扩展的类型约束,函数指针负责热路径组合。

全局扩展通过 pthread_once 初始化,部分 registry 有意存活到进程退出,不参与复杂的静态析构。这是系统库常见取舍:用少量进程级常驻内存换取可控初始化顺序和退出阶段安全。

11.6 C++14 基线与局部现代特性

当前 CMake 默认 BRPC_CXX_STANDARD=14,特定配置会提高到 C++17;C++ coroutine 文档要求 C++20。主干代码同时存在传统 POSIX/C 风格接口与现代 C++:move semantics、unique_ptr、shared_ptr/weak_ptr、lambda、variadic template、std::atomic。这反映的是兼容性与演进,而不是追求统一的“最新语法风格”。

12 这些机制怎样共同产生效果

把前面的局部机制重新串起来,可以看到 brpc 的性能路径:

服务发现把变化转成增量节点事件
  -> DoublyBufferedData 让选址读路径保持便宜
  -> SocketMap 复用兼容连接
  -> Controller 用版本化 id 管理一次调用的所有竞争事件
  -> IOBuf 降低序列化、拼帧、解析中的数据搬运
  -> MPSC + single writer 合并共享连接上的发送
  -> epoll/kqueue 只报告 readiness
  -> urgent bthread drain fd 并批量解析消息
  -> work stealing 把业务任务分散到 worker
  -> butex 在阻塞时只挂起逻辑任务
  -> bvar/rpcz 以低写竞争暴露运行状态

这里没有哪个节点可以独立解释整体吞吐。比如只有 bthread 而没有协程感知 I/O,阻塞调用仍会钉住 worker;只有 IOBuf 而发送仍然每个小包加锁写一次,系统调用和锁仍可能成为瓶颈;只有负载均衡而没有失败反馈、熔断和重试,选址无法形成闭环。

brpc 真正成熟的地方,是让这些机制共享一致的对象边界:Controller 表达 call,Socket 表达 endpoint/connection,bthread_id 表达带版本的异步完成,IOBuf 表达跨层字节所有权。

13 代价、限制和不适用场景

13.1 复杂度被框架吸收,但没有消失

版本化 id、对象池、侵入式引用、弱内存序、stackful 切换和全局 registry 都提高了调试门槛。崩溃栈会穿过调度器和协议回调,内存错误也可能在对象复用后才暴露。维护者需要扎实的并发、生命周期和 ABI 知识。

13.2 阻塞友好不代表任意阻塞都便宜

bthread-aware mutex、sleep、fd wait 可以让出 worker。未知库内部的阻塞 syscall、长时间 CPU 计算、不可控 pthread mutex 仍可能占住 worker。CPU 密集任务也不会因为换成 bthread 自动获得更多算力。

13.3 反馈算法依赖可解释的信号

LALB、P2C、熔断和自适应并发都依赖延迟、错误、QPS 或 inflight。流量太少、请求成本高度异质、批处理造成突发,或者下游延迟主导时,信号可能滞后甚至误导。需要结合业务压测和指标校准参数。

13.4 backup request 有业务语义约束

对写操作、扣款、发消息等非幂等请求,框架无法替业务完成 exactly-once。即使客户端丢弃慢响应,两个后端 attempt 都可能执行成功。必须用幂等 key、去重表或业务事务处理。

13.5 生态与接口风格需要评估

brpc 很适合高并发 C++ 服务、内部多协议接入、同步编程模型和强在线诊断。若团队主要使用另一语言生态、依赖标准化 service mesh、只需要简单 HTTP API,或者不能接受框架自带调度运行时,集成成本可能高于收益。

14 推荐的源码阅读顺序

不要从 src/brpc 按文件名顺序读。建议沿一条请求逐步扩大边界:

  1. example/echo_c++:先建立 API 使用模型。
  2. Channel::CallMethod():看一次调用如何初始化。
  3. Controller::IssueRPC():看选址、版本 id、注册回调和发送。
  4. baidu_rpc_protocol.cpp:选一个协议看 serialize、pack、parse、process。
  5. socket.cpp:看连接和发送所有权。
  6. InputMessenger::OnNewMessages():看接收、切帧和批量分发。
  7. task_group.cpp 与 butex.cpp:理解任务为什么能挂起和迁移。
  8. iobuf.h:理解字节在各层之间如何共享。
  9. 再读 naming_service_thread.cpp、不同 load balancer、adaptive_max_concurrency.cpp、bvar/ 和 span.cpp。

每读一层都问四个问题:对象归谁所有?谁能并发访问?失败从哪里收口?观测数据在哪里产生?这四个问题比记类名更容易看见设计。

15 外部资料与源码互证

官方的 brpc 源码解析索引 已覆盖多个核心组件,适合作为第一轮导读:

网络链路方面,SF-Zhou 的 brpc Socket 分析 提供了清晰的旧版调用路径。它适合帮助定位概念,但文章基于较早版本,类和函数细节应回到当前固定提交核验。

仓库内文档也非常重要,尤其是 threading_overview.md、io.md、lalb.md、auto_concurrency_limiter.md、rpcz.md 和 vars.md。

这些资料与当前源码结合后,可以把 brpc 的设计归纳成一句更准确的话:它通过局部化竞争、显式所有权和反馈控制,把高并发 RPC 中原本分散的状态竞争收敛到少数稳定抽象上。性能是这种结构的结果,可靠性和可诊断性也是。