
upb 设计解析Protobuf 的 C 内核、Arena 内存模型与 MiniTable / Reflection 双层 Schema【免费下载链接】protobufProtocol Buffers - Googles data interchange format项目地址: https://gitcode.com/GitHub_Trending/pr/protobuf本文基于 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 内核upbupb 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非目标稳定的 APIStable API安全的 APISafe API面向应用的顺手 APIErgonomic APIParameters实现参数语言标准为 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.c、lua/upb.lua、rust/、php/、ruby/ 等处一致性则由 conformance/ 目录下的测试套件含failure_list_python_upb.txt、failure_list_rust_upb.txt等针对 upb 后端的失败列表持续验证。Arenaupb 全部内存管理的唯一模型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.h 与upb/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的单向生命周期引用保证to在from释放前不会被释放——这些正是“把 C arena 与 GC/引用计数体系对接”时需要的原语。upb_Arena_New()本身也只是内联包装见 upb/mem/arena.h#L137-L143UPB_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 融合的行为细节可作为延伸阅读。初始内存块与自定义分配器无堆场景下使用 upbupb_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 实际可用于分配的内存会少于nalloc指定初始块耗尽之后使用的自定义分配函数若传入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 销毁时执行的清理函数这为封装语言提供了挂载析构逻辑的钩子。Schemaupb 中几乎所有操作的前提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。两者的主要区别继承自原文档的对照表MiniTablesReflectionContains包含字段编号和类型仅此而已.proto文件中的全部数据包括一切名称Used to parse用途二进制格式JSON / TextFormatWire representation线格式载体MiniDescriptorDescriptorType names类型名upb_MiniTable、upb_MiniTableField、…upb_MessageDef、upb_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类型与二进制 APIMiniTable 由一族以upb_MiniTable命名的数据结构表示upb_MiniTable代表 messageupb_MiniTableField、upb_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 的三种加载方式来自 C 生成代码upb 代码生成器可以输出.upb_minitable.c文件把 MiniTable 作为全局常量变量嵌入。主程序链接这些文件后MiniTable 会落在二进制的.rodata或.data.rel.ro段中运行时通过生成函数直接取到。在 Bazel 中可用upb_minitable_proto_library()规则完成生成与链接仓库中对应规则见 upb_generator/ 及 upb/bazel/ 目录下的构建逻辑。来自 MiniDescriptor用户可以在运行时把 MiniDescriptor 构建为 MiniTable。MiniDescriptor 是一种紧凑的、upb 专属的线格式专门为此设计调用upb_MiniTable_Build()即可完成转换。当前仓库中该入口位于 upb/mini_descriptor/decode.h#L49-L52。来自 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#L61upb_MiniTable_Link()执行批量链接见 upb/mini_descriptor/link.h#L71。常见模式是把link()调用直接嵌入生成代码例如 Dart 中构建含子 message 与 enum 的 MiniTableconst 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 enumMiniTable 主要承载 message、field 与 extension 的数据但对于 closed enum还需要一个upb_MiniTableEnum结构保存该 enum 中定义的所有数值集合——原因是 closed enum 有一个麻烦的行为未知 enum 值会被放入 unknown field set。设计文档判断随着 editions 的推进 closed enum 将逐步淡出upb_MiniTableEnum的相关性与开销会随之缩小直至消失。Reflectionupb full 的完整 schemaReflection 使用upb_MessageDef、upb_FieldDef等类型在运行时表示.proto文件的完整内容。它们是 upb 中google::protobuf::Descriptor、google::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 的顶层容器是 Cgoogle::protobuf::DescriptorPool的紧密对应物用户必须始终保证upb_DefPool的寿命长于它所拥有的任何 def 对象。仓库中upb_DefPool的核心实现见 upb/reflection/def_pool.h 与upb/reflection/def_pool.c。Reflection 的两种加载方式来自 C 生成代码upb 代码生成器可以创建foo.upbdefs.c文件嵌入 descriptor 并导出 C 函数把它们加入用户提供的upb_DefPool来自 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.cMiniTable 构建的 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.h、upb/mem/arena.cupb_Arena_Init/Free/Fuse/Malloc以及RefArena/IncRefFor等包装层原语MiniTable 数据结构upb/mini_table/message.h、field.h、enum.h、extension_registry.c等message/field/enum/扩展注册表MiniDescriptor 构建upb/mini_descriptor/decode.h、upb/mini_descriptor/link.hupb_MiniTable_Build、upb_MiniTable_Link等Reflectionupb/reflection/def_pool.h、def.h、field_def.c等upb_DefPool_AddFile、upb_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.c、lua/upb.lua、rust/、python/“把 upb 封装进语言运行时”的真实样例其中 Lua 绑定lua/目录中的upb.c/upb.lua/test_upb.lua是体量最小的完整封装样本适合想理解“如何把 C 内核包进一门语言”的读者作为起点阅读。小结upb 的设计可以浓缩为三个关键决策三者共同服务于“可被任意语言运行时低成本封装”这一总目标不追求 API 稳定与安全换取小体积、高性能与自由演进的空间——upb 的定位是内核而非 SDK一切分配经由upb_Arena用 bump allocator 整体释放换取分配/释放速度用upb_Arena_Fuse和引用原语解决跨 arena 悬垂指针问题用upb_Arena_Init的初始块 自定义分配器支持无堆环境双层 SchemaMiniTableupb lite与 Reflectionupb full前者以字段号与类型的紧凑表示支撑二进制线格式并可静态链接进.rodata后者承载完整.proto语义支撑 JSON/TextFormat 与 options 访问且 Reflection 内嵌 MiniTable、单向可降级、不可升级。对正在评估“为一种新语言实现 protobuf 支持”的工程师而言upb 的设计文档与上述源码路径给出了一条被 Python、Rust、PHP、Ruby、Lua、Dart 等绑定反复验证过的路线在语言运行时与 C 内核之间用 Arena 解决内存归属用 MiniTable 或 MiniDescriptor 解决 schema 获取把 FFI 面收敛到极小。【免费下载链接】protobufProtocol Buffers - Googles data interchange format项目地址: https://gitcode.com/GitHub_Trending/pr/protobuf创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考