protobuf 中基于 upb 构建语言绑定的完整指南:FFI 前置条件、Reflection/MiniTables 选型与 Arena 内存管理 protobuf 中基于 upb 构建语言绑定的完整指南FFI 前置条件、Reflection/MiniTables 选型与 Arena 内存管理【免费下载链接】protobufProtocol Buffers - Googles data interchange format项目地址: https://gitcode.com/GitHub_Trending/pr/protobuf本文基于 protobuf 官方设计文档 wrapping-upb.md 展开系统讲解如何把一个用 C 编写的 protobuf 内核 upb 封装成某个新语言文中以假想语言 zlang 为例的 protobuf 实现。读完后你将掌握需要实现哪些组件代码生成器与运行时胶水层、语言运行时必须满足的三项前置能力、Reflection 与 MiniTables 两条数据访问路线的取舍标准以及 upb Arena 与语言 GC 集成的指针模型和跨 arena 生命周期管理方案。总体架构代码生成器 运行时胶水一个完整的 protobuf 实现由两部分组成代码生成器在编译期运行把.proto文件转成目标语言的源文件。以假想语言 zlang扩展名.z为例即protoc-gen-zlang它由protoc调用把foo.proto变成foo.z运行时组件实现 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 之前目标语言运行时必须提供以下能力FFI外部函数接口语言必须能通过 FFI 调用 C API。大多数语言都支持某种形式的 FFI要么通过native extensions写一些 C 代码来实现语言的新方法要么通过直接 FFI借助特殊库从语言直接调用普通 C 函数。仓库中的 Lua 绑定 和 Rust 绑定rust/upb 目录就是这两种路线的真实例子。Finalizers、Destructors 或 Cleaners运行时必须提供某种终结机制——当语言 GC 回收或销毁对象时能够触发对某个 C 函数的调用。不关心它叫 finalizer、destructor 还是 cleaner只要对象销毁时最终会被调用。upb 在 C 空间分配内存终结器是确保内存被释放、不发生泄漏的唯一途径。弱值 HashMap可选这不是硬性要求但一个全局的弱值 hashmap注意value 是 weakkey 不是有时很有用可以充当upb_msg* - wrapper的对象缓存。文档也提到这一模式未来是否继续使用仍有待观察——而 Lua 绑定目前正是这么做的见下文对象缓存一节。第一个关键设计决策Reflection vs. MiniTables代码生成的第一步决策是生成的代码通过 reflection 还是 minitables 来访问消息数据。一般规律是——动态语言倾向 reflection静态语言倾向 minitables。路线一基于 Reflection 的数据访问Reflection 式访问最适合高度动态的语言解释器因为这类语言的方法分派本身就通过字符串和哈希表查找完成。在这类语言里你可以实现__getattr__Python或method_missingRuby这样的特殊方法接收方法名作为字符串再用 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 中所有消息类所需的全部内容。这段代码不追求可读性而是由一个独立的.pyistub 文件提供完整展开、可读的方法列表# 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 路线的接口清单用 upb/reflection/def.h 中的接口加载和访问 descriptor 数据该头文件汇总导出field_def.h、message_def.h、file_def.h等一组 def 类型用 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 路线的接口清单用 upb/mini_descriptor/decode.h 中的接口加载 MiniDescriptors 数据用 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 的对象树与 Arenaupb 用 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 头部注释明确写出了三条不变式every wrapper references the arena that contains it.every fused arena includes all arenas that own upb objects reachable from that arena...实现上lupb_Arenauserdata 包装upb_Arena永不暴露给用户唯一职责就是在 Lua GC 判定该 arena 内不再有任何可达引用时释放它lua/msg.c每个消息包装对象强引用自己所属的 arena。跨 Arena 链接Fuse 与单向引用上述方案对单 arena 内的对象工作得很好但用户如果想在两个不同 arena 的对象之间建立链接怎么办原文档此节标注为 TODO但仓库中已有完整的答案设计细节见 arena_fusion.mdAPI 在 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内嵌序列化 descriptorupb/reflection/def.hMiniTables MiniDescriptors体积约小 60 倍upb/mini_descriptor/decode.h字段访问__getattr__式按名字查 upb 哈希表按字段号查 MiniTable或 unsafe 定偏移直读upb/message/accessors.h字段访问接口upb/reflection/message.hupb/message/accessors.hJSON/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 形态。【免费下载链接】protobufProtocol Buffers - Googles data interchange format项目地址: https://gitcode.com/GitHub_Trending/pr/protobuf创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考