首页
/ protobuf 中基于 upb 构建语言绑定的完整指南:FFI 前置条件、Reflection/MiniTables 选型与 Arena 内存管理

protobuf 中基于 upb 构建语言绑定的完整指南:FFI 前置条件、Reflection/MiniTables 选型与 Arena 内存管理

2026-09-05 15:11:37作者:咎竹峻Karen

本文基于 protobuf 官方设计文档 wrapping-upb.md 展开,系统讲解如何把一个用 C 编写的 protobuf 内核 upb 封装成某个新语言(文中以假想语言 zlang 为例)的 protobuf 实现。读完后,你将掌握:需要实现哪些组件(代码生成器与运行时胶水层)、语言运行时必须满足的三项前置能力、Reflection 与 MiniTables 两条数据访问路线的取舍标准,以及 upb Arena 与语言 GC 集成的指针模型和跨 arena 生命周期管理方案。

总体架构:代码生成器 + 运行时胶水

一个完整的 protobuf 实现由两部分组成:

  1. 代码生成器:在编译期运行,把 .proto 文件转成目标语言的源文件。以假想语言 zlang(扩展名 .z)为例,即 protoc-gen-zlang,它由 protoc 调用,把 foo.proto 变成 foo.z
  2. 运行时组件:实现 wire format,并提供表示 protobuf 数据与元数据的结构。
Compile Time:   foo.proto ──> protoc ──> protoc-gen-zlang ──> foo.z
Runtime:        foo.z ──> zlang/upb glue (FFI) ──> upb (C)

其中需要自己实现的部分是绿色的两块:protoc-gen-zlang 和 zlang/upb 之间的 FFI 胶水层。

这里有一个非常关键的设计特性:protoc-gen-zlang 完全不需要生成任何 C 代码(如 foo.c。虽然 upb 本身用 C 编写,但它的解析器/序列化器是纯表驱动(table-driven)的——每个 proto 都不需要生成 C 代码,也没有任何收益。即使 schema 数据是在运行时从内嵌在 foo.z 里的字符串动态加载,upb 也能达到满速解析。这正是 upb 相比 C++ 实现的核心优势:C++ proto 传统上依赖 foo.pb.cc 里生成的解析器才能达到满速,运行时加载 schema 时解析器会付出约 10 倍的速度惩罚,而 upb 没有这个问题。

语言运行时的三项前置条件

封装 upb 之前,目标语言运行时必须提供以下能力:

  1. FFI(外部函数接口):语言必须能通过 FFI 调用 C API。大多数语言都支持某种形式的 FFI,要么通过"native extensions"(写一些 C 代码来实现语言的新方法),要么通过直接 FFI(借助特殊库从语言直接调用普通 C 函数)。仓库中的 Lua 绑定 和 Rust 绑定(rust/upb 目录)就是这两种路线的真实例子。

  2. Finalizers、Destructors 或 Cleaners:运行时必须提供某种终结机制——当语言 GC 回收或销毁对象时,能够触发对某个 C 函数的调用。不关心它叫 finalizer、destructor 还是 cleaner,只要对象销毁时最终会被调用。upb 在 C 空间分配内存,终结器是确保内存被释放、不发生泄漏的唯一途径。

  3. 弱值 HashMap(可选):这不是硬性要求,但一个全局的弱值 hashmap(注意:value 是 weak,key 不是)有时很有用,可以充当 upb_msg* -> wrapper 的对象缓存。文档也提到,这一模式未来是否继续使用仍有待观察——而 Lua 绑定目前正是这么做的(见下文"对象缓存"一节)。

第一个关键设计决策:Reflection vs. MiniTables

代码生成的第一步决策是:生成的代码通过 reflection 还是 minitables 来访问消息数据。一般规律是——动态语言倾向 reflection,静态语言倾向 minitables

路线一:基于 Reflection 的数据访问

Reflection 式访问最适合高度动态的语言解释器,因为这类语言的方法分派本身就通过字符串和哈希表查找完成。

在这类语言里,你可以实现 __getattr__(Python)或 method_missing(Ruby)这样的特殊方法,接收方法名作为字符串,再用 upb 的 reflection 按名字查找字段——从而复用 upb 的哈希表,而不是让语言运行时再维护一份:

class FooMessage:
  # Written in Python for illustration, but in practice we will want to
  # implement this in C for speed.
  def __getattr__(self, name):
    field = FooMessage.descriptor.fields_by_name[name]
    return field.get_value(self)

采用这种设计,每个消息类只需要挂一个 __getattr__ 方法,而不用为每个字段单独定义 getter/setter,避免了 upb 与语言解释器之间哈希表的重复,降低内存占用。

Reflection 路线的代价:

  • 需要运行时加载完整 reflection。生成的代码必须内嵌序列化后的 descriptor(即 descriptor.proto 的序列化消息),有体积开销,且把所有消息/字段名暴露进二进制;
  • 字段访问的关键路径上被迫引入一次哈希表查找。如果语言的方法调用本来就有这个开销(如动态语言),则无额外负担;但对静态分派语言则是额外开销。

把这条路走到逻辑终点,就是全部动态类创建:只以二进制 descriptor 作为输入,"生成的代码"就退化为一段内嵌 descriptor 加一个加载它的库调用。Python 已经走上这条路,生成代码形如:

# main_pb2.py
from google3.net.proto2.python.internal import builder as _builder
from google3.net.proto2.python.public import descriptor_pool as _descriptor_pool

DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile("<...>")
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google3.main_pb2', globals())

这就是运行时创建该 descriptor 中所有消息类所需的全部内容。这段代码不追求可读性,而是由一个独立的 .pyi stub 文件提供完整展开、可读的方法列表:

# main_pb2.pyi
from google3.net.proto2.python.public import descriptor as _descriptor
from google3.net.proto2.python.public import message as _message
from typing import ClassVar as _ClassVar, Optional as _Optional

DESCRIPTOR: _descriptor.FileDescriptor

class MyMessage(_message.Message):
    __slots__ = ["my_field"]
    MY_FIELD_FIELD_NUMBER: _ClassVar[int]
    my_field: str
    def __init__(self, my_field: _Optional[str] = ...) -> None: ...

使用 Reflection 路线的接口清单:

  1. upb/reflection/def.h 中的接口加载和访问 descriptor 数据(该头文件汇总导出 field_def.hmessage_def.hfile_def.h 等一组 def 类型);
  2. upb/reflection/message.h 中的接口访问消息数据,核心 API 包括 upb_Message_GetFieldByDef / upb_Message_SetFieldByDef / upb_Message_Mutable / upb_Message_WhichOneofByDef / upb_Message_Next(遍历已设置字段)等,全部以 upb_FieldDef* 为字段标识。

路线二:基于 MiniTables 的数据访问

MiniTables 是一种"lite" schema 表示,比 reflection 小得多:它丢弃 .proto 文件里的名字、options 以及几乎所有其他信息,只保留解析/序列化二进制格式所必需的字段信息。

MiniTables 通过 MiniDescriptors 加载进 upb。MiniDescriptors 是字节导向的格式,可内嵌到生成代码中再交给 upb 构建 MiniTables。它只使用可打印字符,嵌入生成代码的字符串时不需要任何转义,体积相比常规 descriptor 大约小 60 倍。仓库中 upb/mini_descriptor/decode.h 定义了入口 API:

// 从 MiniDescriptor 数据构建 MiniTable,分配在给定 arena 上
UPB_NODISCARD upb_MiniTable* upb_MiniTable_Build(const char* data, size_t len,
                                                 upb_Arena* arena,
                                                 upb_Status* status);

MiniTables 与 MiniDescriptors 是编译型语言的天然选择——这类语言在编译期解析方法调用。对于"有时编译、有时解释"的语言,选择可能不那么明显。静态绑定场景下希望尽可能削减 accessor 开销,极端做法是用 unsafe API 在已知偏移量直接读原始内存:

// Example of a maximally-optimized generated accessor.
class FooMessage {
    public long getBarField() {
        // Using Unsafe should give us performance that is comparable to a
        // native member access.
        //
        // The constant "24" is obtained from upb at compile time.
        sun.misc.Unsafe.getLong(this.ptr, 24);
    }
}

这种设计非常底层,把生成代码与特定 schema/编译器版本紧密耦合。更慢但更安全的版本是按字段号查找:

// Example of a more loosely-coupled accessor.
class FooMessage {
    public long getBarField() {
        // The constant "2" is the field number.  Internally this will look
        // up the number "2" in the MiniTable and use that to read the value
        // from the message.
        upb.glue.getLong(this.ptr, 2);
    }
}

MiniTables 的一个缺点:无法支持 JSON 或 TextFormat 的解析/序列化,因为它不知道字段名。理论上可以在"旁边"额外生成 reflection 数据(放入独立的生成文件),让 reflection 仅在被使用时才被拉入,但相关 API 目前尚不存在。

使用 MiniTables 路线的接口清单:

  1. upb/mini_descriptor/decode.h 中的接口加载 MiniDescriptors 数据;
  2. upb/message/accessors.h 中的接口访问消息数据。该头文件提供按 upb_MiniTableField* 访问的完整 API 族:upb_Message_GetInt64 / upb_Message_GetBool / upb_Message_GetMap / upb_Message_GetMessage / upb_Message_GetOrCreateMutableArray 等,其中 *BaseField() 后缀的函数只处理非扩展字段,*Extension() 后缀的函数只处理扩展字段。

内存管理:upb Arena 模型

封装 upb 时最核心的设计挑战是内存管理。无论目标语言用 GC、引用计数、手动管理还是混合方案,upb 这一侧都是统一的:它是 C 代码,用 arena 做内存管理

upb 的对象树与 Arena

upb 用 C 数据结构表示消息、数组(repeated 字段)和 map。一个 protobuf 消息就是这些对象构成的层次树,例如一个较简单的消息树可能长这样:

upb Message ──> upb Message
      └──────> upb Array

所有 upb 对象都从某个 arena 分配。arena 允许逐个对象分配,但不允许逐个释放——只能整体释放 arena,届时从该 arena 分配的所有对象一起消失。

简单场景下,整棵对象树都活在同一个 arena 里,好处是对象之间不可能出现悬垂指针(所有对象同时释放)。但 upb 允许在任意两个对象之间建立链接,无论它们是否在同一个 arena——库不关心也不检查对象所在的 arena。当对象分布在不同 arena 时,由使用者负责保证没有悬垂指针:例如若 Arena 2 中的 Message 3 被 Arena 1 中的 Message 1/2 指向,则 Arena 2 必须活得比 Message 1、Message 2 更久。关于 arena 分配器的底层机制(bump allocator、块管理、线程模型),仓库的 upb 设计文档 有更完整的说明,upb/mem/arena.h 则定义了全部 API。

与语言 GC 集成

在自动内存管理的语言里,目标是让 arena 完全在幕后处理——用户既不需要手动管理,甚至不需要知道它的存在。

要做到这一点,关键在于把对象图搭建成特定形状:给所有 C 对象(包括 arena 本身)创建包装对象,并保证 arena 包装对象不会在 arena 内所有 C 对象都不可达之前被 GC 掉。以 Python 为例,指针关系是:

  • raw ptr:不携带所有权的指针;
  • unique ptr:对目标拥有唯一所有权,持有者在其析构器/finalizer/cleaner 中释放目标,一个对象只能有一个 unique 指针;
  • shared (GC) ptr:共享所有权指针。多个对象可以指向同一目标,直到所有引用消失才删除。在 GC 运行时中这是参与 GC 的引用(Python 用引用计数,其他 VM 可能用 mark and sweep 等)。

在这个模型下:Python Message 包装对象只持有对底层消息的 raw 指针,但同时持有一个指向 arena 的 shared 指针——这个 shared 指针确保 raw 指针始终有效。只有当所有消息包装对象都被销毁后,Python Arena 才变得不可达,upb arena 最终被释放。

仓库中的 Lua 绑定是这套策略的完整落地。lua/msg.c 头部注释明确写出了三条不变式:

  1. every wrapper references the arena that contains it.
  2. every fused arena includes all arenas that own upb objects reachable from that arena...

实现上,lupb_Arena userdata 包装 upb_Arena,永不暴露给用户,唯一职责就是在 Lua GC 判定该 arena 内不再有任何可达引用时释放它(lua/msg.c);每个消息包装对象强引用自己所属的 arena。

跨 Arena 链接:Fuse 与单向引用

上述方案对单 arena 内的对象工作得很好,但用户如果想在两个不同 arena 的对象之间建立链接怎么办?原文档此节标注为 TODO,但仓库中已有完整的答案,设计细节见 arena_fusion.md,API 在 upb/mem/arena.h

双向融合——upb_Arena_Fuse(a, b):把两个 arena 的生命周期绑定在一起,所有传递性融合的 arena 在引用计数全部归零之前都不会被释放。典型场景是"子消息被单独构造后再挂到父消息上":把父 arena 与子 arena 融合,子的生命周期就与父绑定,无需拷贝。实现是无锁的(混合了 disjoint set 与双向链表,详见 arena_fusion.md 的数据结构与路径分裂查找),且修改引用计数和 fuse 操作都是线程安全的——多个线程可以各自持有专属 arena 并与一个共享的 const upb_Arena* parent 融合,实现并发分配。

Lua 绑定在处理"用户引用了另一个 arena 的对象"时正是调用这个 API:

// lua/msg.c —— 当 wrapper 跨 arena 建立引用时
static void lupb_Arena_Fuse(lua_State* L, int to, int from) {
  upb_Arena* to_arena = lupb_Arena_check(L, to);
  upb_Arena* from_arena = lupb_Arena_check(L, from);
  upb_Arena_Fuse(to_arena, from_arena);
}

(见 lua/msg.c#L189-L203

单向引用——upb_Arena_RefArena(from, to):有些场景只需要单向依赖。如果 arena A 中的消息指向 arena B 中的消息,但反向没有,则 B 只需至少与 A 同样长寿。RefArena(A, B) 让 A 在释放前递增 B 的引用计数(通过在 A 中分配一个持有 B 指针的特殊块,并在 upb_Arena_Free(A) 时释放该引用)。与 fuse 不同,RefArena 并发作用于 from 时不是线程安全的(对 to 安全)。

两条严格约束(debug 构建会检查,opt 构建下是 UB):

  • 不得在 arena 之间创建引用环(例如 RefArena(A, B); RefArena(B, A));
  • 不得在已融合(或未来会融合)的两个 arena 之间创建单向引用——因为 fuse 是双向依赖,Fuse(A, B) 之后再 RefArena(B, A) 等价于环 A<->B->A

debug 构建下 upb 会在每次 fuse/引用操作后用一次非记忆化递归 DFS 做环检测,一旦发现环立即断言失败(算法细节同样见 arena_fusion.md)。

开放议题与对象缓存

原文档把 UTF-8 vs. UTF-16(字符串在 C 侧与语言侧的编码边界如何跨越)留作 TODO,当前仓库未提供定论,此处不做展开,仅提示做语言绑定设计时这是必须面对的独立问题。

Object Cache(对象缓存) 一节原文同样标注 TODO,但 Lua 绑定给出了一个可参考的成熟实现:lua/msg.c 维护一个全局对象缓存,把 C 指针(upb_Message*upb_Array*upb_Map*)映射到对应的 Lua 包装对象,引用是弱引用——包装对象可被 GC 回收,之后随时可以重新构造。这正对应前置条件里提到的"弱值 hashmap"模式:key 是 C 裸指针(强),value 是语言包装对象(弱),从而保证缓存本身永远不会阻止任何 upb 对象被释放。

小结:封装 upb 的决策清单

决策点 选项 A(动态语言) 选项 B(静态/编译型语言)
Schema 表示 完整 reflection,内嵌序列化 descriptor(upb/reflection/def.h MiniTables + MiniDescriptors,体积约小 60 倍(upb/mini_descriptor/decode.h
字段访问 __getattr__ 式按名字查 upb 哈希表 按字段号查 MiniTable,或 unsafe 定偏移直读(upb/message/accessors.h
字段访问接口 upb/reflection/message.h upb/message/accessors.h
JSON/TextFormat 天然支持 MiniTables 不支持,需额外旁路生成 reflection
生命周期 每个包装对象强引用其 arena 包装对象,由 finalizer 释放 同左,arena 绑定由语言侧 GC 驱动
跨 arena 链接 upb_Arena_Fuse(双向)或 upb_Arena_RefArena(单向),见 upb/mem/arena.h 同左

需要强调的是(与 upb 设计文档 的声明一致):upb 的 C API 是低层、不安全且频繁变动的,它明确不以稳定 API 或应用级易用性为目标——它的全部设计重心就是"易于被语言运行时封装"和"易于适配各种内存管理方案"。因此语言绑定的封装层(FFI glue)是随语言版本和 upb 版本一起演进的,本文所有接口均对应当前仓库的 API 形态。

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