首页
/ protobuf upb 设计剖析:upb 与 C++ Protobuf 在内存模型、代码生成与反射机制上的关键差异

protobuf upb 设计剖析:upb 与 C++ Protobuf 在内存模型、代码生成与反射机制上的关键差异

2026-09-04 17:53:40作者:董灵辛Dennis

upb(μpb)是 Protocol Buffers 仓库中的一个小型 C 语言 protobuf 内核,也是 Ruby、PHP、Python 等语言绑定的底层运行时。本文基于仓库中的设计对比文档 vs-cpp-protos.md,系统梳理 upb 与 C++ Protobuf 库在 设计目标、Arena 内存模型、代码生成 vs 数据表、可选反射 四个维度上的差异,并结合 upb/mem/arena.hupb/mini_table/message.hupb/util/required_fields.h 等源码实现进行佐证。读完后你将理解:为什么 upb 选择“纯 Arena + 数据表 + 惰性链接反射”的路线,以及这种设计如何同时服务于嵌入式/受限环境与多语言 FFI 封装两大场景。

一、设计目标:用户级库 vs. 可封装内核

两种库的差异首先来自定位的不同,这是理解后续所有技术分叉的前提:

  • C++ protobuf 是用户级(user-level)库:直接面向 C++ 应用,提供完整的、符合 C++ 习惯的 API 面;愿意为服务端性能增加特性(即使这会增加体积或复杂度);由于直接面向用户,API 稳定性最重要,破坏性变更很少且被严格管控;与 C 的 ABI 兼容性不是优先级。
  • upb 是为“被其他语言封装”而设计的 C protobuf 内核:API 面尽量小且正交(orthogonal);虽然支持全部 conformance 所需的 protobuf 特性,但优先考虑简单性和小代码体积,避免引入 lazy fields 这类“提升部分场景性能但复杂度代价巨大”的特性;因为它不直接面向终端用户,所以在必要时拥有更多做破坏性 API 变更的自由度,从而让内核保持精简;同时为了兼容所有 FFI 接口,C ABI 兼容性是硬性要求

值得注意的是,尽管定位不同,两者提供的核心特性集合大致相同(生成式 API、反射、二进制/JSON wire format、text format 序列化、oneof/map/unknown fields/extensions 等完整特性)。这一特性清单可以在 upb/README.md 中确认,其中还补充了 upb 相对 C++ 的三项独有特性:可选反射(生成代码不感知反射是否链接)、无全局状态(无 pre-main 注册)、基于反射的快速解析(运行时加载的 message 与编译期内置的解析速度相同)。同时 README 也如实列出了 upb 的不支持项:text format 解析、以及与 protoc 同等深度的 descriptor 校验。

二、Arena 内存模型:混合分配 vs. 纯 Arena

C++:历史包袱造就的混合分配模型

C++ protobuf 2008 年开源时没有 arena,当时只有“唯一所有权(unique ownership)”模型:每条 message 唯一拥有其所有子 message,父对象析构时释放子对象。Arena 分配是 2014 年才加入的特性,目的是大幅降低分配(尤其是释放)成本。但由于历史用户量太大,库无法移除唯一所有权模型,于是 C++ 至今维持一个混合分配模型:message 可以直接分配在栈/堆上,也可以从 arena 分配。为了防止悬垂指针,库在部分场景(例如 a->set_allocated_b(b)ab 处于不同 arena)会自动执行拷贝

此外,C++ 的 google::protobuf::Arena线程安全(thread-safe)的:多线程可以不加锁地并发向同一 arena 分配。用户可以向 arena 提供初始内存块,并可通过参数控制 arena block 大小;也可以提供自定义 alloc/dealloc 函数,但 alloc 函数被要求“总能返回内存”——C++ 库整体上不处理内存耗尽(OOM)状况

upb:Arena 是唯一分配方式

upb 只支持 arena 分配:所有 message 必须从 arena 分配,只能通过释放 arena 来释放。是否存在悬垂指针完全由用户保证——当你设置某个 message 字段时,upb 只是简单地覆盖指针,永远不会做隐式拷贝

upb 的 arena 与 C++ 的具体差异,可以在源码 upb/mem/arena.h 中得到印证:

  • 线程兼容而非线程安全:头文件注释明确写道 “A upb_Arena is not thread-safe, although some functions related to its managing its lifetime are, and are documented as such”(L14-L15),即只有少数生命周期管理函数(如 upb_Arena_Fuseupb_Arena_IsFused、引用计数函数)声明为可并发调用。
  • 可返回 NULL 的分配器:通过 upb_Arena_Init 传入的 upb_alloc* 在内存不足时允许返回 NULLalloc 传 NULL 则是固定大小、不可增长的 arena)。这使得 upb arena 可以具有最大/固定尺寸,理论上支持编写能容忍 OOM 的代码——这与 C++ “alloc 必须总能成功”的假设形成鲜明对比。
  • 初始块与尺寸提示upb_Arena_Init(void* mem, size_t n, upb_alloc* alloc) 中,若提供了初始块 mem,则 n 是块的长度,且该 arena 的生命周期不允许被 upb_Arena_IncRefForupb_Arena_Fuse 延长;若没有初始块,n 只是首个分配块的大小提示(保证 upb_Arena_Malloc(hint) 不会再触发一次 alloc 调用)。
  • 块大小控制:upb 不像 C++ 那样提供显式的 block size 参数,但存在一个全局实验性接口 upb_Arena_SetMaxBlockSize(注释标明 “meant for experimentation only. It will likely be removed in the future”),可见 upb 有意保持 arena 参数面的最小化。

upb 独有操作:fuse(以及 ref)

upb 的 arena 支持一个称为 fuse 的新颖操作:把两个 arena 合并到同一个生命周期中——虽然两个 arena 仍须分别释放,但在两个 arena 都被释放之前,其内存实际上都不会被真正释放。这在“reparenting(换父)一个可能位于另一个 arena 上的 message”时非常有用,可以避免悬垂指针。

源码中该操作的声明与语义在 upb_Arena_Fuse

// Fuses the lifetime of two arenas, such that no arenas that have been
// transitively fused together will be freed until all of them have reached a
// zero refcount. This operation is safe to use concurrently from multiple
// threads.
UPB_API bool upb_Arena_Fuse(const upb_Arena* a, const upb_Arena* b);

可以看到 fuse 是传递性的(transitively fused),且该操作本身可并发调用。设计文档也指出了 fuse 的代价:把一个 arena 传入某个函数,就可能让该函数以“潜在不可预测的方式”延长 arena 的寿命;必要时可以让 fuse 失败(例如一方带有初始块时)来阻止这种行为,但这要求调用方处理 fuse 失败的分支,增加了一定复杂度。

仓库当前版本中还能看到一个更精细的补充机制 upb_Arena_RefArena:在 fromto 两个 arena 之间建立单向引用,保证 tofrom 释放前不会被释放,且头文件用大段注释明确规定了禁止条件——不得在 arena 间制造引用环不得对已 fuse(或未来会 fuse)的两个 arena 建立引用。这说明“跨 arena 生命周期管理”是 upb 内核中被认真对待的角落场景,而非一行之笔带过的特性。

对比小结

维度 C++ upb 权衡
分配模型 混合(堆/栈 + arena),隐式拷贝防悬垂 纯 arena,指针覆盖无拷贝,用户自行保证生命周期 混合模型带来大量复杂度与不可预测性;但唯一所有权天然支持“reparenting 后子对象精确跟随新父对象生命周期”,arena 方案则必须深拷贝或延长生命周期
线程语义 arena 线程安全 arena 线程兼容(需外部同步) 线程安全更安全易用;但 2014 年后 Thread Sanitizer 等工具已能较好发现数据竞争,且线程兼容实现更简单、性能更好。C++ 线程安全 arena 依赖 thread-local 变量,在部分平台引入额外复杂性,正确性与性能推理也更微妙
内存耗尽 不处理(alloc 必须成功) alloc 可返回 NULL,可容忍 OOM 受限环境(嵌入式、受限内存语言绑定)下 upb 的模型更实用
fuse upb_Arena_Fuse 传递性合并生命周期 支撑动态语言中 foo.bar = bar 这类跨 arena 赋值而不做深拷贝;代价是调用方需处理 fuse 失败

三、代码生成 vs. 数据表:foo.pb.cc 生成函数,foo.upb.c 只生成数据

C++ protobuf 自始至终围绕代码生成构建:foo.pb.cc 中包含大量函数。文档列出的不完整清单包括:

  • FooMsg::FooMsg()(构造函数):将所有字段初始化为默认值;
  • FooMsg::~FooMsg()(析构函数):释放存在的子 message;
  • FooMsg::Clear():把所有字段清回默认/空值;
  • FooMsg::_InternalParse() / FooMsg::_InternalSerialize():解析与序列化逻辑;
  • FooMsg::ByteSizeLong():序列化前的首遍尺寸计算;
  • FooMsg::MergeFrom():从另一条 message 拷贝/追加已存在字段;
  • FooMsg::IsInitialized():检查 required 字段是否已设置。

这些代码位于 .text 段,且包含对子 message 生成类函数的调用。

upb 则不向 foo.upb.c 生成任何函数,只生成数据结构——一种紧凑的数据表,称为 mini table,用它表示 schema 和全部字段。mini table 的公开 API 见 upb/mini_table/message.h,例如按字段号查找字段的 upb_MiniTable_FindFieldByNumber、获取子 message 表的 upb_MiniTable_SubMessage、遍历 oneof 字段的 upb_MiniTable_GetOneof / upb_MiniTable_NextOneofField 等,全部是“查表”操作而非生成的行为代码。

对应上一节 C++ 的函数清单,upb 的做法是:

C++ 生成函数 upb 的替代方式
构造函数(初始化默认值) 所有 message 统一用 memset(msg, 0, size) 初始化;非零默认值在访问器(accessor)中注入
析构函数 通过释放 arena 释放 message,无需逐个析构
Clear() memset(msg, 0, size) 即可
_InternalParse() 解析器以 mini table 为数据驱动,不生成代码
_InternalSerialize() 序列化器同样由 mini table 驱动
ByteSizeLong() upb 反向执行序列化,因此无需先做一遍尺寸计算
MergeFrom() 通过对另一条 message 执行 serialize + parse 实现
IsInitialized() 编解码器内置 special flags 检查 required 字段;边角情况由工具库 upb/util/required_fields.h 处理

关于最后一行,源码 upb/util/required_fields.h 中的核心函数 upb_util_HasUnsetRequired 会递归检查 msg 及其所有子 message 中是否有未设置的 required 字段,并在发现问题时通过输出参数返回“缺失字段的路径数组”(upb_FieldPathEntry**,支持 fieldarray_indexmap_key 三种路径条目,可用 upb_FieldPath_ToText 渲染为 foo.barrepeated_baz[2].barstring_msg_map["abc"] 这样的文本)。这正是设计文档所说“util 库处理 corner cases”的落点。

编译后体积对比

文档给出了一个极端简单的二进制(仅对 descriptor.proto 做一次 parse + serialize)的段大小对比,它同时包含了核心库本体开销与 descriptor.proto 的生成代码(或数据表)开销:

Library .text .data .bss
upb 26Ki 0.6Ki 0.01Ki
C++ (lite) 187Ki 2.8Ki 1.25Ki
C++ (code size) 904Ki 6.1Ki 1.88Ki
C++ (full) 983Ki 6.1Ki 1.88Ki

其中 “C++ (code size)” 指以 optimize_for = CODE_SIZE 编译的 proto:生成代码中仅保留 reflection,以换取更小的生成代码体积,但代价是它需要完整运行时而非 lite 运行时。可以看到,即使 C++ 特意切换到代码体积优化模式(且此时已无法使用 lite runtime),其 .text 段(904Ki)仍约为 upb(26Ki)的 35 倍——这正是“生成数据而非生成代码”这一设计选择的直接收益,也与 upb/README.md 中“与 protobuf C++ 速度相当,但代码体积分级更小”的描述一致。

四、分裂式 vs. 可选式反射:Message/MessageLite 分叉 vs. 单一 upb_Message

两者都提供反射且都不强制,但“开关反射”的模型完全不同。

C++:默认全量反射,lite 需要显式声明

C++ message 默认带完整反射:message 一般继承自 Message,基类提供成员函数 Reflection* Message::GetReflection()。这带来两个后果:

  1. 任何继承自 Message 的 message,其反射都会被链入二进制,无论你是否使用过反射对象;
  2. 由于 GetReflection() 是基类函数,静态上无法确定某个 message 的反射是否被使用
Reflection* GetReflection(const Message& message) {
    // Can refer to any message in the whole binary.
    return message.GetReflection();
}

C++ 库提供的规避手段是 MessageLite,让 message 变 lite 有两种方式:

  • .proto 文件中写 optimize_for = LITE_RUNTIME,使该文件内所有 message 变为 lite;
  • lite 作为 codegen 参数,即使 .proto 未声明 LITE_RUNTIME,也强制所有 message 为 lite。

lite message 继承自 MessageLite,它没有 GetReflection(),因此不产生反射代码体积。

upb:单一消息类型 + 独立对象文件按需链接

upb 没有 Message vs. MessageLite 的分裂。只有一种消息类型 upb_Message,因此无需在 .proto 里配置“哪些 message 需要反射”。每条 message 都可选地从一个独立的 foo.upbdefs.o 文件链接进反射,而 message 本身无需任何改动。这一流程在仓库中对应 upb_generator/reflection 目录:protoc-gen-upbdefs 插件(见 upb_generator/reflection/BUILD)会针对同一 proto 生成 foo.upbdefs.hfoo.upbdefs.c(分别由 header.ccsource.cc 决定文件名),反射代码作为独立编译单元存在,用不到反射的项目直接不链接该对象文件即可。

同时,upb 不提供 Message::GetReflection() 的等价物:没有“在静态类型未知时取反射”的机制。文档也指出,理论上可以在 upb 内核之上叠加这样的机制,但那可能需要额外的代码生成。

对比小结

  • C++ 中大多数 message 不会主动声明 lite,导致大量从未使用的反射被链入二进制、白白膨胀体积;
  • optimize_for = LITE_RUNTIME 实际很难用:它会阻止任何非 lite proto import 该文件;
  • 通过 codegen 参数全局强制 lite(例如移动端构建)比 optimize_for 更实用,但会编译报错任何试图向上转型到 Message 或调用非 lite 方法的代码;
  • C++ 模型唯一的大优势:可以支持在类型擦除的 proto 上调用 msg.DebugString();而 upb 若想执行“把 proto 打印为 text format”这类操作,必须显式地把 upb_MessageDef* 单独传入。

五、设计哲学的延伸理解

从仓库结构看,upb 的这些设计选择在整个 protobuf 生态中承担着一个明确角色:它不是又一个“供 C 应用直接使用的 protobuf 库”(upb/README.md 明确声明 C API/ABI 不稳定、不发布 release、不直接面向 C 消费者),而是多语言绑定的共享内核。因此它的每条设计决策都偏向同一方向:更小的 API 面、更简单的内存语义、更小的编译产物、更自由的破坏性变更,从而让 Ruby、PHP、Python 等语言绑定能够以极低的 C 层维护成本获得与 C++ 相当的解析/序列化能力与完整的 conformance 兼容。

需要说明的是,原文档最后一节 “Explicit Registration vs. Globals” 在仓库中目前仅标注为 TODO,属于未完成草稿,本文不做展开;不过 upb/README.md 中“no global state: no pre-main registration or other global state”的特性描述,可以从侧面印证 upb 在这一维度上与 C++(依赖全局 DescriptorPool/pre-main 自注册)取向不同。

六、对使用者的实际含义

  • 评估嵌入体积受限的环境(IoT、浏览器内嵌、受限语言绑定)时,upb 路线的 .text 体积优势是数量级的,参考上表 descriptor.proto 解析/序列化二进制的对比数据;
  • 使用 C++ 时,若确认不需要反射,应优先考虑 codegen 全局强制 lite,而不是逐文件设置 optimize_for = LITE_RUNTIME(后者会阻断 import 链),但要检查代码中是否存在向上转型到 Message 的调用;
  • 封装 upb 的 FFI 层需要注意三点:arena 非线程安全(并发访问需自行同步,可用 Thread Sanitizer 验证);跨 arena 的字段赋值应使用 upb_Arena_Fuse 而非深拷贝,并处理 fuse 失败分支;required 字段完整性检查需调用 upb_util_HasUnsetRequired,它同时返回缺失字段的路径以便报错;
  • 需要文本格式(text format)解析或 type-erased 反射(如 DebugString)时,upb 当前不支持或不提供等价机制,这两个场景下 C++ 仍是更自然的选择。
登录后查看全文
热门项目推荐
相关项目推荐