Read OSS

μpb:为 Python、Ruby 和 PHP 提供支持的轻量级 C 运行时

中级

前置知识

  • 第 4 篇:序列化、Arena 与 TcTable(用于与 C++ 运行时对比)
  • 基础 C 语言编程知识

μpb:为 Python、Ruby 和 PHP 提供支持的轻量级 C 运行时

当你在 Python 中执行 pip install protobuf、在 Ruby 中执行 gem install google-protobuf,或在 PHP 中执行 pecl install protobuf 时,你拿到的并不是 C++ protobuf 运行时,而是 μpb——一个专为动态语言运行时后端而生的快速、精简的 C 语言 protobuf 实现。

μpb(micro protobuf,常写作"upb")在 protobuf 生态中占据着独特的位置:它能提供与 C++ 运行时相当的解析速度,代码量却少了一个数量级。本文将带你了解 upb 的设计初衷、MiniTable Schema 如何实现紧凑存储、Arena 融合机制如何解决 upb 设计中的特有问题,以及各动态语言如何通过 C 扩展模块封装 upb。

μpb 的设计初衷

upb README 对 upb 的定位说得直截了当。它列出了三个相对于 C++ 运行时的优势,这些优势在嵌入式场景中尤为关键:

  1. 无全局状态:不需要 main() 前的注册逻辑,没有全局 descriptor pool。这对动态语言至关重要——protobuf 库可能随时被加载和卸载。
  2. 按需启用反射:生成的消息无论是否链接了反射模块,行为完全一致。而在 C++ protobuf 中,反射与 descriptor 是深度耦合的。
  3. 高性能反射解析:运行时动态加载(通过反射)的消息,解析速度与编译期固化的消息相同。C++ protobuf 中的 DynamicMessage 存在明显的性能损耗。
graph LR
    subgraph "C++ Protobuf"
        CPPSIZE["Large code size"]
        CPPGLOBAL["Global state required"]
        CPPREF["Reflection always present"]
    end
    
    subgraph "μpb"
        UPBSIZE["Small code size"]
        UPBNOGLOB["No global state"]
        UPBOPTREF["Reflection optional"]
    end
    
    subgraph "Consumers"
        PY["Python"] --> UPBSIZE
        RB["Ruby"] --> UPBNOGLOB
        PHP["PHP"] --> UPBOPTREF
        HPB["HPB (C++)"] --> UPBSIZE
    end

这带来的实际效益是显著的:Python 的 protobuf 库(基于 upb)原生代码只有几百 KB。如果换用 C++ 运行时,体积会达到数 MB,还会引入与 Python 模块系统不兼容的全局状态。

MiniTable:紧凑的 Schema 表示

C++ 运行时使用带有丰富方法和字符串名称的 Descriptor 对象,而 upb 使用的是 MiniTable——一种专为字段访问和解析性能优化的极简扁平结构。

upb_MiniTable 是一个包含以下内容的结构体:

  • upb_MiniTableField 条目数组(每个字段对应一项)
  • 指向子消息 MiniTable 的指针(用于消息类型字段)
  • 字段数据在 upb_Message 结构体中的偏移量
  • Hasbit 和 oneof case 信息
classDiagram
    class upb_MiniTable {
        +upb_MiniTable_FindFieldByNumber()
        +upb_MiniTable_GetFieldByIndex()
        +upb_MiniTable_FieldCount()
        +upb_MiniTable_SubMessage()
        +upb_MiniTable_MapKey()
        +upb_MiniTable_MapValue()
    }
    
    class upb_MiniTableField {
        +field number
        +field type
        +offset in message
        +presence (hasbit/oneof)
    }
    
    class upb_MiniTableEnum {
        +value validation
    }
    
    upb_MiniTable --> upb_MiniTableField : fields array
    upb_MiniTable --> upb_MiniTable : sub-message links
    upb_MiniTableField --> upb_MiniTableEnum : enum validation

API 的设计以整数索引访问为核心。upb 提供了 upb_MiniTable_FindFieldByNumber() 按字段编号查找,以及 upb_MiniTable_GetFieldByIndex() 按位置访问,完全避免了通过名称查找时需要哈希表和字符串比较的开销。字段编号的查找会根据字段编号的分布情况,自动选择密集或稀疏两种存储方式。

更关键的是,MiniTable 可以在运行时从序列化数据构建——Python 的 descriptor_pool.c 正是这样工作的。当你在 Python 中调用 pool.Add(file_descriptor_proto) 时,系统会根据序列化的 descriptor 构建出一个 MiniTable,从而让该消息类型以全速解析。这正是 README 中提到的"高性能反射解析"特性的实现基础。

提示: 如果你在编写需要处理 protobuf 的 C 库,upb 的 MiniTable 方案远比链接完整的 C++ 运行时更合适。生成的 C API 提供了类型化的访问器,而 MiniTable 也可以在运行时从 descriptor 动态构建,满足动态 Schema 的需求。

upb Arena 与融合机制

与 C++ 运行时类似,upb 也采用 Arena 内存分配。但 upb 的 arena 有一个独特的能力:Arena 融合(arena fusing)

融合要解决的问题是这样的:当 Arena A 上的消息引用了 Arena B 上的数据,会发生什么?在 C++ protobuf 中,这由生成代码的拷贝语义负责管理。但在 upb 以及封装它的动态语言中,消息的操作往往更加随意——你可能会把一个来自 Arena B 的子消息,赋值给 Arena A 上消息的某个字段。

upb_Arena_Fuse() 将两个 Arena 的生命周期绑定在一起,确保只有两者都被释放后,内存才会真正回收:

// 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.
UPB_API bool upb_Arena_Fuse(const upb_Arena* a, const upb_Arena* b);
flowchart TD
    subgraph "Before Fuse"
        A1["Arena A<br/>(msg1)"]
        A2["Arena B<br/>(msg2)"]
    end
    
    subgraph "After msg1.sub = msg2"
        A3["Arena A<br/>(msg1)"]
        A4["Arena B<br/>(msg2)"]
        A3 ---|"Fused"| A4
    end
    
    subgraph "Lifetime"
        A5["Neither freed until<br/>both released"]
    end
    
    A3 --> A5
    A4 --> A5

融合具有传递性——若 A 与 B 融合,B 又与 C 融合,则三者共享同一生命周期。其实现采用了 union-find 数据结构,操作是线程安全的。头文件中也记录了重要约束:Arena 之间不能形成循环引用,且已融合的 Arena 之间不允许再次执行融合操作。

此外,Arena 还支持 upb_Arena_IncRefFor()upb_Arena_DecRefFor() 进行引用计数管理,以及 upb_Arena_SetAllocCleanup() 注册在所有内存块释放后执行的清理回调。

upb_Message 与 Wire 格式

upb_Message 是 upb 的核心消息表示。与 C++ protobuf 中每种消息都有独立的带类型访问器的生成类不同,upb_Message 是一个通用类型——所有消息在 C 层面都是同一个类型,只通过关联的 upb_MiniTable 来区分。

创建消息非常直接:

upb_Message* msg = upb_Message_New(mini_table, arena);

消息按照 MiniTable 指定的偏移量存储字段数据,扩展字段和未知字段则有独立的存储区域。未知字段的遍历采用基于 segment 的 API:

uintptr_t iter = kUpb_Message_UnknownBegin;
upb_StringView data;
while (upb_Message_NextUnknown(msg, &data, &iter)) {
    // Process unknown field data
}

Wire 格式解码的实现位于 upb/wire/decode.h,提供了多个解码选项标志:

标志 用途
kUpb_DecodeOption_AliasString 直接引用输入缓冲区中的字符串,而非复制
kUpb_DecodeOption_CheckRequired 若必填字段缺失则返回失败
kUpb_DecodeOption_AlwaysValidateUtf8 对 proto2 也强制执行 UTF-8 校验
kUpb_DecodeOption_DisableFastTable 禁用快速表解析器(用于调试)

AliasString 选项对动态语言尤为重要。在从 Python 的 bytes 对象解析消息时,解析结果中的字符串可以直接指向输入缓冲区,而无需复制。这要求输入缓冲区的生命周期长于消息本身——当两者都在同一个 Arena 上时,这一条件自然得到保证。

语言绑定:Python、Ruby 与 PHP

每种动态语言都通过 C 扩展模块封装 upb,其中 Python 的实现最为成熟,也是最好的参考示例。

python/message.c 的头文件引用清晰地展示了桥接模式:该文件同时引入了 Python C API 头文件(python/message.hpython/convert.h)和 upb 头文件(upb/message/message.hupb/wire/decode.hupb/reflection/message.h)。Python 消息对象封装了一个 upb_Message*,并持有对其所属 Arena 的引用。

python/descriptor_pool.c 则展示了 Schema 层的工作方式。PyUpb_DescriptorPool 结构体封装了一个 upb_DefPool*——这是 upb 反射层的类型注册表(即"def"系统):

typedef struct {
    PyObject_HEAD
    upb_DefPool* symtab;
    PyObject* db;  // The DescriptorDatabase underlying this pool
} PyUpb_DescriptorPool;
flowchart TD
    subgraph "Python Layer"
        PyMsg["PyUpb_Message<br/>(Python object)"]
        PyPool["PyUpb_DescriptorPool<br/>(Python object)"]
    end
    
    subgraph "upb Layer"
        UMsg["upb_Message*"]
        UArena["upb_Arena*"]
        UDef["upb_DefPool*"]
        UMini["upb_MiniTable*"]
    end
    
    PyMsg --> UMsg
    PyMsg --> UArena
    PyPool --> UDef
    UDef --> UMini
    UMsg -.->|"field access via"| UMini

当 Python 代码调用 msg.SerializeToString() 时,C 扩展会以消息的 upb_Message*upb_MiniTable* 为参数调用 upb_Encode();调用 msg.ParseFromString(data) 时,则调用 upb_Decode()。Python 封装层负责处理类型转换(Python int ↔ upb int32/int64,Python str ↔ upb string)、错误上报以及 Arena 的生命周期管理。

Ruby 和 PHP 遵循同样的模式。Ruby 扩展位于 ruby/ext/google/protobuf_c/,PHP 扩展位于 php/ext/google/protobuf/,两者都以相同的方式封装 upb,只是按各自语言的 C 扩展 API 规范做了适配。

提示: 在调试 Python、Ruby 或 PHP 中的 protobuf 问题时,最常见的根源往往与 Arena 有关——消息引用了已释放 Arena 上的数据。Arena 融合机制应当能防止这种情况,但如果你直接操作消息内部结构(例如通过 C 扩展 API),务必清楚各 Arena 的生命周期边界。

upb 在生态中的位置

upb 不仅仅是一个轻量级 C 库,它是三大动态语言运行时的共同基础。这一架构决策带来了深远影响:upb 解析器的性能提升,Python、Ruby 和 PHP 同时受益;upb Arena 管理层的 bug 修复,三种语言也一并得到修复。

在第 6 篇文章中,我们将从两个视角审视代码生成模式:Rust 创新性的双内核架构(同时以 C++ protobuf 和 upb 作为后端)及其基于代理的 API 设计,以及 C++ 代码生成器针对字段类型特化的策略模式。我们还将介绍 HPB——一个构建于 upb 之上的全新 C++ API,它在完整 C++ 运行时与原始 upb 之间提供了一条折中路径。