首页
/ upb 设计解析:Protobuf 的 C 内核、Arena 内存模型与 MiniTable / Reflection 双层 Schema

upb 设计解析:Protobuf 的 C 内核、Arena 内存模型与 MiniTable / Reflection 双层 Schema

2026-09-04 09:13:12作者:庞队千Virginia

本文基于 docs/upb/design.md 梳理 upb 的完整设计思路:upb 是 Protocol Buffers 仓库中一个用 C 实现的 protobuf 内核,它不面向应用直接开发,而是作为“语言运行时之下的底座”,被 Python、Rust、PHP、Ruby、Lua 等多种语言绑定所封装。读完本文,你可以理解 upb 的设计目标与取舍、以 upb_Arena 为核心的内存管理模型(含 Fuse 与固定内存块的使用方式),以及 MiniTable 与 Reflection 两套 Schema 的加载、链接与适用场景,从而掌握“把一个 C 内核安全地包装进托管语言运行时”的关键工程方法。

什么是 upb:一个刻意“不稳定”的 C 内核

upb(upb is a protobuf kernel written in C)是一个快速且完全符合 protobuf 规范的实现,暴露的是一套低级别 C API。它的定位非常明确:upb 不设计给应用直接调用——这个 C API 非常底层、不安全(unsafe)、并且变化频繁。文档明确指出,upb 必须保留“随时进行破坏性 API 变更”的自由,以避免背上牺牲其两大目标(小代码体积、高性能)的技术债。

设计目标与非目标

原设计文档将目标分为三类,完整列出如下:

Goals(目标):

  • 完整的 protobuf 一致性(Full protobuf conformance)
  • 小的代码体积(Small code size)
  • 快的性能(Fast performance,且不以代码体积为代价)
  • 易于被语言运行时封装(Easy to wrap in language runtimes)
  • 易于适配不同的内存管理方案(引用计数、GC 等)

Non-Goals(非目标):

  • 稳定的 API(Stable API)
  • 安全的 API(Safe API)
  • 面向应用的顺手 API(Ergonomic API)

Parameters(实现参数):

  • 语言标准为 C99
  • 支持 32 位或 64 位 CPU(假定 4 或 8 字节指针)
  • 使用指针打标(pointer tagging),但避免其他实现定义行为(implementation-defined behavior)
  • 目标是绝不触发未定义行为(通过 ASAN、UBSAN 等测试保证)
  • 无全局状态、完全可重入(fully re-entrant)

这套取舍可以从仓库结构中得到印证:upb/ 目录按职责拆分为 base/mem/wire/mini_table/mini_descriptor/reflection/json/text/lex/ 等子模块,而语言封装层则分布在 lua/upb.clua/upb.luarust/php/ruby/ 等处;一致性则由 conformance/ 目录下的测试套件(含 failure_list_python_upb.txtfailure_list_rust_upb.txt 等针对 upb 后端的失败列表)持续验证。

Arena:upb 全部内存管理的唯一模型

upb 中所有内存管理都通过 arena 完成,统一使用 upb_Arena 类型。Arena 是 malloc()/free() 的替代方案,能显著降低内存分配开销:

  • Arena 从某个底层分配器(通常是 malloc()free())获取内存块;
  • 对块内的分配请求,用一个简单的 bump allocator 按线性顺序推进来满足;
  • 单个分配不能被单独释放,只能整体 upb_Arena_Free() 释放整个 arena,连带释放其所有底层内存块。

设计文档中的标准用法示例:

upb_Arena* arena = upb_Arena_New();

// Perform some allocations.
int* x = upb_Arena_Malloc(arena, sizeof(*x));
int* y = upb_Arena_Malloc(arena, sizeof(*y));

// We cannot free `x` and `y` separately, we can only free the arena
// as a whole.
upb_Arena_Free(arena);

这个“arena 参数”模式贯穿于所有 upb 数据结构 API:任何会分配内存的 upb 函数都接收一个 upb_Arena* 参数,并用该 arena 而非 malloc()/free() 进行分配:

// upb API to create a message.
UPB_API upb_Message* upb_Message_New(const upb_MiniTable* mini_table,
                                     upb_Arena* arena);

void MakeMessage(const upb_MiniTable* mini_table) {
  upb_Arena* arena = upb_Arena_New();

  // This message is allocated on our arena.
  upb_Message* msg = upb_Message_New(mini_table, arena);

  // We can free the arena whenever we want, but we cannot free the
  // message separately from the arena.
  upb_Arena_Free(arena);

  // msg is now deleted.
}

Arena 是 upb 性能故事的关键部分。解析一个大 protobuf payload 通常意味着快速创建一连串 message、数组(repeated 字段)和 map,这些分配的速度对解析性能至关重要;同样重要的是,整棵 message 树的释放也要尽可能快——arena 可以把这项开销从 O(n) 降到 O(lg n)

在仓库中,Arena 的完整实现在 upb/mem/arena.hupb/mem/arena.c。从源码结构看,公开接口比设计文档中还多了几个面向包装层的设施:upb_Arena_IncRefFor()/upb_Arena_DecRefFor()(见 upb/mem/arena.h#L71-L74)允许按 owner 增删引用,upb_Arena_RefArena()(见 upb/mem/arena.h#L108)则创建 from → to 的单向生命周期引用,保证 tofrom 释放前不会被释放——这些正是“把 C arena 与 GC/引用计数体系对接”时需要的原语。upb_Arena_New() 本身也只是内联包装(见 upb/mem/arena.h#L137-L143):

UPB_API_INLINE upb_Arena* upb_Arena_New(void) {
  return upb_Arena_Init(NULL, 0, &upb_alloc_global);
}

避免悬垂指针:单 Arena 与 Fuse 原语

arena 上分配的对象通常会包含指向其他 arena 分配对象的指针,例如一个 upb_Message 会持有指向其子 message 的指针,而这些子 message 同样分配在 arena 上。与 unique_ptr<> 这类独占所有权方案不同,arena 无法自动防止悬垂指针;upb 的做法是提供工具,帮助在高级内存管理方案(GC、引用计数、RAII、borrow checker)与 arena 之间架桥。

单 arena 场景是最简单的:如果一个 arena 内所有对象同时被释放,那么 arena 内部的悬垂指针就不可能发生。用户仍需小心不要在 arena 释放后继续持有指向其内存的指针,但 arena 对象之间的悬垂指针从原理上被排除了。

多 arena 场景才是难点:如果存在从 arena A 指向 arena B 的指针,如何保证它不会悬垂?为此 upb 提供了名为 fuse 的原语:

// Fuses the lifetimes of `a` and `b`.  None of the blocks from `a` or `b`
// will be freed until both arenas are freed.
UPB_API bool upb_Arena_Fuse(const upb_Arena* a, const upb_Arena* b);

两个 arena 被 fuse 后,它们的生命周期被不可逆地绑定:在两个 arena 都被 upb_Arena_Free() 释放之前,任何一方都不会释放自己的内存块,于是两个 arena 之间的悬垂指针不再可能发生。Fuse 的典型用途是把来自两个不同 arena 的 message 合并(例如把一个作为另一个的子 message 挂接)。Fuse 是一个相对便宜的操作,文档给出量级约为 150ns,且对参与 fuse 的 arena 数量几乎是 O(1)(真实复杂度是逆 Ackermann 函数,增长极其缓慢)。

需要注意的代价:每个 arena 自身会占用一定的内存,所以“反复创建新 arena 并 fuse”并不免费,但两个 arena 的 fuse 本身 CPU 成本不高。

upb/mem/arena.h#L59-L63 中可以看到该接口的当前声明,注释还补充了一个设计文档未强调的细节:Fuse 操作本身是线程安全的,可并发从多个线程调用。仓库中 docs/upb/arena_fusion.md 也专门展开了 arena 融合的行为细节,可作为延伸阅读。

初始内存块与自定义分配器:无堆场景下使用 upb

upb_Arena 默认用 malloc()/free() 获取和归还底层块,但这个默认策略可以定制,以适应特定语言的需求。创建 arena 的最底层函数是:

// Creates an arena from the given initial block (if any -- n may be 0).
// Additional blocks will be allocated from |alloc|.  If |alloc| is NULL,
// this is a fixed-size arena and cannot grow.
UPB_API upb_Arena* upb_Arena_Init(void* mem, size_t n, upb_alloc* alloc);

参数行为:

  • [mem, n] 缓冲区作为初始块(initial block)使用,在所有底层分配函数被调用之前优先满足分配请求。注意:upb_Arena 结构体本身若可能也会从初始块中分配,因此 arena 实际可用于分配的内存会少于 n
  • alloc 指定初始块耗尽之后使用的自定义分配函数;
  • 若传入 NULL 作为分配函数,则初始块是 arena 中唯一的内存来源——由此得到一个固定大小、不可增长的 arena,这使 upb 即使在没有堆的环境中也能运行。

由此推出的重要推论:upb_Arena_Malloc() 是一个可能失败的操作;只要存在使用固定大小 arena 的可能,upb_Message_New() 等一切分配型操作都必须检查失败返回值。当前实现中该约束依然成立,例如 upb/mem/arena.h#L49-L50 处的声明保留了相同的三参数签名;另有一个演进细节:实现层新增了 upb_Arena_SetAllocCleanup()(见 upb/mem/arena.h#L56-L57),允许注册一个在 arena 销毁时执行的清理函数,这为封装语言提供了挂载析构逻辑的钩子。

Schema:upb 中几乎所有操作的前提

upb 中几乎每个操作都要求你先拥有一个 schema。protobuf schema 是包含 .proto 文件中定义的所有 message、field、enum 等定义的数据结构;创建、解析、序列化或访问 message 都必须有 schema。因此,加载 schema 通常是使用 upb 时的第一步。

为什么必须 schema-first? 设计文档在此处有一个关于 protobuf 本质的重要洞见:这与 protobuf 线格式(wire format)本身有关。与 JSON 不同,protobuf 无法以无 schema 的方式被解析或操作——因为二进制线格式不区分字符串和子 message,一个对 schema 一无所知的通用解析器在原理上不可能实现。若未来某版线格式能区分这两者,才有可能存在 schema 无关的数据表示、解析器与序列化器。

MiniTable 与 Reflection 对照

upb 中有两类表示 protobuf schema 的主要数据结构:

  • MiniTables:精简紧凑的 schema 版本,只包含解析/序列化二进制线格式所必需的信息;
  • Reflection:包含 .proto 文件中的几乎所有数据,包括所有 message/field 等的原始名称以及全部 options。

两者的主要区别(继承自原文档的对照表):

MiniTables Reflection
Contains(包含) 字段编号和类型(仅此而已) .proto 文件中的全部数据,包括一切名称
Used to parse(用途) 二进制格式 JSON / TextFormat
Wire representation(线格式载体) MiniDescriptor Descriptor
Type names(类型名) upb_MiniTableupb_MiniTableField、… upb_MessageDefupb_FieldDef、…
Registry(注册表) upb_ExtensionRegistry(用于扩展) upb_DefPool

选型原则:如果只需要二进制线格式,MiniTable 比完整 reflection 轻量得多;如果需要解析 JSON 或 TextFormat、或需要访问 .proto 中指定的 options,则要用 Reflection。注意 Reflection 内部也包含 MiniTables——拥有 reflection 就同时拥有 MiniTable,但反向不可行:只加载了 MiniTable 的应用无法得到对应的 reflection。

因此 upb 可以按需要裁剪成两种形态:

  • 只需 MiniTable 的那部分 upb 可视为 “upb lite”——代码体积和运行时内存开销都更小;
  • 需要 reflection 的那部分视为 “upb full”

判断一个函数属于哪一层,只需看签名里出现哪类类型:出现 upb_MiniTable/upb_MiniTableField 等,即该操作需要 MiniTable;出现 upb_MessageDef/upb_FieldDef 等,则需要 Reflection。

MiniTable:类型与二进制 API

MiniTable 由一族以 upb_MiniTable 命名的数据结构表示(upb_MiniTable 代表 message,upb_MiniTableFieldupb_MiniTableFile 等)。例如二进制解析入口:

// Parses the wire format data in the given buffer `[buf, size]` and writes it
// to the message `msg`, which has the type `mt`.
UPB_API upb_DecodeStatus upb_Decode(const char* buf, size_t size,
                                    upb_Message* msg, const upb_MiniTable* mt,
                                    const upb_ExtensionRegistry* extreg,
                                    int options, upb_Arena* arena);

MiniTable 的三种加载方式

  1. 来自 C 生成代码:upb 代码生成器可以输出 .upb_minitable.c 文件,把 MiniTable 作为全局常量变量嵌入。主程序链接这些文件后,MiniTable 会落在二进制的 .rodata(或 .data.rel.ro)段中,运行时通过生成函数直接取到。在 Bazel 中可用 upb_minitable_proto_library() 规则完成生成与链接(仓库中对应规则见 upb_generator/upb/bazel/ 目录下的构建逻辑)。
  2. 来自 MiniDescriptor:用户可以在运行时把 MiniDescriptor 构建为 MiniTable。MiniDescriptor 是一种紧凑的、upb 专属的线格式,专门为此设计;调用 upb_MiniTable_Build() 即可完成转换。当前仓库中该入口位于 upb/mini_descriptor/decode.h#L49-L52
  3. 来自 reflection:如果已经为某类型构建了 reflection 数据结构,可通过 upb_MessageDef_MiniTable()upb_MessageDef 取得对应的 upb_MiniTable

选择准则(设计文档给出的实操指南):

  • 已经使用 reflection 的语言,(3) 是显而易见的首选;
  • 回避 reflection 的语言,在 (1) 与 (2) 之间:若目标语言在给定平台上参与标准二进制链接模型(特别是通常用 ld 链接),则用 (1)——即静态加载(static loading)

静态加载的优点:

  • 不需要任何运行时初始化,启动更快(唯一例外是库或二进制为位置无关代码时,ELF/Mach-O loader 可能做的指针重定位);
  • 有利于跨语言共享 proto message——共享通常要求双方使用完全相同的 MiniTable。

静态加载的主要缺点:需要为每个 .proto 生成一个 .upb.c 文件,并链接其传递闭包内所有 .upb.c。Bazel 下这相对容易,其他构建系统会麻烦一些。

而 (2) 的动态加载优点是不需要为每条消息链接 C 代码。对许多语言工具链来说,为每个 protobuf 文件或消息类型生成并链接自定义 C 代码是沉重负担;MiniDescriptor 提供了一种无需跨越核心运行时之外的 FFI 边界即可加载 MiniTable 的便捷途径。

动态加载的常见模式是把包含 MiniDescriptor 的字符串直接嵌入生成代码。例如 Dart 生成代码中对纯原始字段 message 的样子:

const desc = r'$(+),*-#$%&! /10';
_accessor = $pb.instance.registry.newMessageAccessor(desc);

newMessageAccessor() 的实现基本就是 upb_MiniTable_Build() 的包装,从 MiniDescriptor 构建 MiniTable。在代码生成器中,MiniDescriptor 可由 upb_MessageDef_MiniDescriptorEncode() API 获得——用户永远不需要手工编码 MiniDescriptor。

MiniTable 的链接(Linking)

动态构建 MiniTable 时,把每条 message 链接到其子 message 和 enum 是用户的责任:

  • 每条 message 的 message 类型字段和 closed enum 字段必须分别用 upb_MiniTable_SetSubMessage()upb_MiniTable_SetSubEnum() 链接;
  • 还有一个高层函数 upb_MiniTable_Link() 一次链接所有字段,它与 upb_MiniTable_GetSubList() 是绝配——后者可以在代码生成器中列出所有需要传给 upb_MiniTable_Link() 的 message 和 enum。

这些接口在仓库中的位置与语义可参见 upb/mini_descriptor/link.hupb_MiniTable_GetSubList() 获取子依赖清单(见 upb/mini_descriptor/link.h#L61),upb_MiniTable_Link() 执行批量链接(见 upb/mini_descriptor/link.h#L71)。

常见模式是把 link() 调用直接嵌入生成代码,例如 Dart 中构建含子 message 与 enum 的 MiniTable:

const desc = r'$3334';
_accessor = $pb.instance.registry.newMessageAccessor(desc);
_accessor!.link(
    [
      M2.$_accessor,
      M3.$_accessor,
      M4.$_accessor,
    ],
    [
      E.$_accessor,
    ],
);

这里 upb_MiniTable_GetSubList() 在代码生成器中发现了 3 个子 message 字段和 1 个子 enum 字段需要链接;运行时这份 MiniTable 列表被传入 link(),其内部调用 upb_MiniTable_Link()

两点补充:

  • 某些应用可能作为树摇(tree shaking)策略的一部分,选择推迟甚至跳过注册某些子 message 类型;
  • 使用静态 MiniTable 时不需要手工链接步骤,因为链接由 ld 自动完成。

MiniTable 与 closed enum

MiniTable 主要承载 message、field 与 extension 的数据,但对于 closed enum,还需要一个 upb_MiniTableEnum 结构保存该 enum 中定义的所有数值集合——原因是 closed enum 有一个麻烦的行为:未知 enum 值会被放入 unknown field set。设计文档判断,随着 editions 的推进 closed enum 将逐步淡出,upb_MiniTableEnum 的相关性与开销会随之缩小直至消失。

Reflection:upb full 的完整 schema

Reflection 使用 upb_MessageDefupb_FieldDef 等类型在运行时表示 .proto 文件的完整内容。它们是 upb 中 google::protobuf::Descriptorgoogle::protobuf::FieldDescriptor 等的直接对应物。

一个值得注意的命名约定:upb 一律用 Def 替代 C++ 的 DescriptorDef 不到 Descriptor 长度的 1/3,目的是节省 C 代码中每行都要重复类型名的横向空间,例如 upb_FieldDef_Name() 对比 upb_FieldDescriptor_Name())。这有意与 C++ 命名分叉,是刻意的设计决策。

需要 reflection 的操作示例:

// Parses JSON format into a message object, using reflection.
UPB_API bool upb_JsonDecode(const char* buf, size_t size, upb_Message* msg,
                            const upb_MessageDef* m, const upb_DefPool* symtab,
                            int options, upb_Arena* arena, upb_Status* status);

upb_DefPool 是构建并拥有一组 def 的顶层容器,是 C++ google::protobuf::DescriptorPool 的紧密对应物;用户必须始终保证 upb_DefPool 的寿命长于它所拥有的任何 def 对象。仓库中 upb_DefPool 的核心实现见 upb/reflection/def_pool.hupb/reflection/def_pool.c

Reflection 的两种加载方式

  1. 来自 C 生成代码:upb 代码生成器可以创建 foo.upbdefs.c 文件,嵌入 descriptor 并导出 C 函数,把它们加入用户提供的 upb_DefPool
  2. 来自 descriptor:用户可对运行时获得的 descriptor 手工调用 upb_DefPool_AddFile()(见 upb/reflection/def_pool.h#L86),随后用 upb_DefPool_FindMessageByName()(见 upb/reflection/def_pool.h#L39-L40)按名取出单个 message 的 def。

与 MiniTable 不同,从生成代码加载 reflection 需要运行时初始化upb_MessageDef 这类 reflection 数据结构无法像 upb_MiniTable 那样直接发射进 .rodata。生成代码是把序列化后的 descriptor proto 嵌入 .rodata,运行时再构建为堆对象。

由此不能简单认为 (1) 只是 (2) 的便利包装:

  • (1) 确实链接了静态 .upb.c 中的 MiniTable 结构,而 (2) 会在堆上从头构建 MiniTable;
  • 因此 (1) 在把 descriptor 加载进 upb_DefPool 时 CPU 和 RAM 略省;
  • 更关键的是:(1) 得到的 descriptor 能够反射基于生成的 .upb.c MiniTable 构建的 message,而 (2) 得到的 descriptor 拥有各不相同的 MiniTable,无法反射使用生成 MiniTable 的 message。

PHP、Ruby、Python 这类动态语言的常见模式是用 (2) 配合嵌入生成代码的 descriptor。Python 生成代码当前的样子:

from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf.internal import builder as _builder

_desc = b'\n\x1aprotoc_explorer/main.proto\x12\x03pkg'

DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(_desc)
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google3.protoc_explorer.main_pb2', _globals)

上面的 AddSerializedFile() 本质上就是 upb_DefPool_AddFile() 的薄包装。仓库中 Python 实现(python/ 目录,含 descriptor_pool.c 等 C 层代码)与 C 内核的这条衔接路径,正是设计文档所描述模式的落地。

upb 在仓库中的落地:从内核到语言绑定

从源码结构看,设计文档中各概念在仓库内都有对应落点,可作为继续深入的入口:

设计概念 仓库位置 说明
Arena 内存模型 upb/mem/arena.hupb/mem/arena.c upb_Arena_Init/Free/Fuse/Malloc,以及 RefArena/IncRefFor 等包装层原语
MiniTable 数据结构 upb/mini_table/message.hfield.henum.hextension_registry.c 等) message/field/enum/扩展注册表
MiniDescriptor 构建 upb/mini_descriptor/decode.hupb/mini_descriptor/link.h upb_MiniTable_Buildupb_MiniTable_Link
Reflection upb/reflection/def_pool.hdef.hfield_def.c 等) upb_DefPool_AddFileupb_MessageDef
代码生成器(bootstrap 编译链) upb_generator/minitable/reflection/stage0/upb_generator/bootstrap_compiler.bzl 生成 .upb.c/.upbdefs.c,用 stage0 引导自举
一致性验证 conformance/upb/conformance/ 各语言后端的 failure list 区分 cc/upb 实现
语言绑定示例 lua/upb.clua/upb.luarust/python/ “把 upb 封装进语言运行时”的真实样例

其中 Lua 绑定(lua/ 目录中的 upb.c/upb.lua/test_upb.lua)是体量最小的完整封装样本,适合想理解“如何把 C 内核包进一门语言”的读者作为起点阅读。

小结

upb 的设计可以浓缩为三个关键决策,三者共同服务于“可被任意语言运行时低成本封装”这一总目标:

  1. 不追求 API 稳定与安全,换取小体积、高性能与自由演进的空间——upb 的定位是内核而非 SDK;
  2. 一切分配经由 upb_Arena,用 bump allocator + 整体释放换取分配/释放速度,用 upb_Arena_Fuse 和引用原语解决跨 arena 悬垂指针问题,用 upb_Arena_Init 的初始块 + 自定义分配器支持无堆环境;
  3. 双层 Schema:MiniTable(upb lite)与 Reflection(upb full),前者以字段号与类型的紧凑表示支撑二进制线格式并可静态链接进 .rodata,后者承载完整 .proto 语义支撑 JSON/TextFormat 与 options 访问,且 Reflection 内嵌 MiniTable、单向可降级、不可升级。

对正在评估“为一种新语言实现 protobuf 支持”的工程师而言,upb 的设计文档与上述源码路径给出了一条被 Python、Rust、PHP、Ruby、Lua、Dart 等绑定反复验证过的路线:在语言运行时与 C 内核之间,用 Arena 解决内存归属,用 MiniTable 或 MiniDescriptor 解决 schema 获取,把 FFI 面收敛到极小。

登录后查看全文
热门项目推荐
相关项目推荐