C++ Arena 分配指南

Arena 分配是 C++ 独有的特性,可帮助您在使用 Protocol Buffers 时优化内存使用并提升性能。

本页描述了当启用 arena 分配时,除了 C++ 代码生成指南 中描述的代码之外,Protocol Buffer 编译器还会生成哪些 C++ 代码。本页假设您已熟悉 语言指南C++ 代码生成指南 中的内容。

为何使用 Arena 分配?

内存的分配与释放占据了 Protocol Buffers 代码中相当一部分的 CPU 时间。默认情况下,Protocol Buffers 会为每个 message 对象、每个子对象以及几种字段类型(如字符串)执行堆分配。这些分配在解析 message 和在内存中构建新 message 时会批量发生,而相关的释放在 message 及其子对象树被释放时进行。

基于 Arena 的分配旨在降低这一性能成本。通过 arena 分配,新对象从一块称为 arena 的大型预分配内存中进行分配。所有对象可以通过丢弃整个 arena 一次性释放,理想情况下无需运行任何包含对象的析构函数(尽管 arena 在需要时仍可以维护一个“析构函数列表”)。这使得对象分配更快,因为它简化为了一个简单的指针递增操作,并使释放操作几乎没有成本。Arena 分配还提供了更高的缓存效率:解析 message 时,它们更有可能被分配在连续的内存中,这使得遍历 message 时更有可能命中热缓存行。

为了获得这些好处,您需要了解对象的生命周期,并找到一个合适的粒度来使用 arena(对于服务器来说,通常是每个请求一个 arena)。您可以在使用模式与最佳实践中了解更多关于如何充分利用 arena 分配的信息。

下表总结了使用 arena 的典型性能优缺点:

操作堆分配的 proto messageArena 分配的 proto message
Message 分配平均较慢平均较快
Message 销毁平均较慢平均较快
Message 移动始终为移动(成本上等同于浅拷贝有时为深拷贝

入门

Protocol Buffer 编译器会为您的文件中的 message 生成 arena 分配的代码,如下例所示。

#include <google/protobuf/arena.h>
{
  google::protobuf::Arena arena;
  MyMessage* message = google::protobuf::Arena::Create<MyMessage>(&arena);
  // ...
}

Create() 创建的 message 对象存在于 arena 的整个生命周期内,您不应该 delete 返回的 message 指针。所有 message 对象的内部存储(有少数例外1)和子 message(例如 MyMessage 中重复字段里的子 message)也都在 arena 上分配。

在大多数情况下,您的其余代码将与不使用 arena 分配时相同。

我们将在接下来的部分更详细地探讨 arena API,您可以在文档末尾看到一个更详尽的示例

Arena 类 API

您可以使用 google::protobuf::Arena 类在 arena 上创建 message 对象。该类实现了以下公共方法。

构造函数

  • Arena(): 使用默认参数创建一个新的 arena,这些参数针对一般使用场景进行了优化。
  • Arena(const ArenaOptions& options): 创建一个使用指定分配选项的新 arena。ArenaOptions 中可用的选项包括:在求助于系统分配器之前,使用用户提供的初始内存块进行分配;控制内存块的初始和最大请求大小;以及允许您传入自定义的块分配和释放函数指针,以便在这些块之上构建自由列表等。

分配方法

  • template<typename T> static T* Create(Arena* arena)template<typename T> static T* Create(Arena* arena, args...)

    • 如果 T 是完全兼容的2,则该方法会在 arena 上创建一个新的 T 类型 Protocol Buffer 对象及其子对象。

      如果 arena 不为 NULL,返回的对象将在 arena 上分配,其内部存储和子类型(如果有)也将分配在同一个 arena 上,其生命周期与 arena 相同。该对象不能手动删除/释放:arena 拥有该对象的生命周期。

      如果 arena 为 NULL,返回的对象将在堆上分配,调用者在返回时拥有该对象。

    • 如果 T 是用户类型,该方法允许您在 arena 上创建对象,但不能创建子对象。例如,假设您有这个 C++ 类:

      class MyCustomClass {
          MyCustomClass(int arg1, int arg2);
          // ...
      };
      

      ...您可以在 arena 上创建它的实例,如下所示:

      void func() {
          // ...
          google::protobuf::Arena arena;
          MyCustomClass* c = google::protobuf::Arena::Create<MyCustomClass>(&arena, constructor_arg1, constructor_arg2);
          // ...
      }
      
  • template<typename T> static T* CreateArray(Arena* arena, size_t n): 如果 arena 不为 NULL,此方法为 nT 类型元素分配原始存储并返回。Arena 拥有返回的内存,并将在其销毁时释放。如果 arena 为 NULL,此方法在堆上分配存储,调用者获得所有权。

    T 必须有一个平凡构造函数(trivial constructor):在 arena 上创建数组时,构造函数不会被调用。

“所有权列表”方法

以下方法让您指定特定的对象或析构函数由 arena “拥有”,确保它们在 arena 本身被删除时被删除或调用:

  • template<typename T> void Own(T* object): 将 object 添加到 arena 的已拥有堆对象列表中。当 arena 被销毁时,它会遍历此列表,并使用 operator delete(即系统内存分配器)释放每个对象。当一个对象的生命周期应与 arena 绑定,但出于某种原因,该对象本身不能或尚未在 arena 上分配时,此方法非常有用。
  • template<typename T> void OwnDestructor(T* object): 将 object 的析构函数添加到 arena 的待调用析构函数列表中。当 arena 被销毁时,它会遍历此列表并依次调用每个析构函数。它不会尝试释放 object 的底层内存。当一个对象嵌入在 arena 分配的存储中,但其析构函数不会被以其他方式调用时,此方法非常有用。例如,因为它所在的类是一个 protobuf message,其析构函数不会被调用,或者因为它是在由 AllocateArray() 分配的块中手动构造的。

其他方法

  • uint64 SpaceUsed() const: 返回 arena 的总大小,即底层块大小的总和。此方法是线程安全的;但是,如果多个线程同时进行分配,此方法的返回值可能不包括那些新块的大小。
  • uint64 Reset(): 销毁 arena 的存储,首先调用所有已注册的析构函数并释放所有已注册的堆对象,然后丢弃所有 arena 块。此销毁过程与 arena 的析构函数运行时发生的过程等效,只是在此方法返回后 arena 可以重用于新的分配。返回 arena 使用的总大小:此信息对于性能调优很有用。
  • template<typename T> Arena* GetArena(): 返回指向此 arena 的指针。直接用途不大,但允许 Arena 在期望存在 GetArena() 方法的模板实例化中使用。

线程安全

google::protobuf::Arena 的分配方法是线程安全的,其底层实现为了使多线程分配快速而做了不少努力。Reset() 方法*不是*线程安全的:执行 arena 重置的线程必须首先与所有正在执行分配或使用从该 arena 分配的对象的线程进行同步。

生成的 Message 类

当您启用 arena 分配时,以下 message 类成员会被更改或添加。

Message 类方法

  • Message(Message&& other): 如果源 message 不在 arena 上,移动构造函数会高效地将所有字段从一个 message *移动*到另一个,而不会进行拷贝或堆分配(此操作的时间复杂度为 O(声明的字段数))。但是,如果源 message 在 arena 上,它会执行底层数据的*深拷贝*。在这两种情况下,源 message 都会处于一个有效但未指定的状态。
  • Message& operator=(Message&& other): 如果两个 message 都不在 arena 上,或者它们在*同一个* arena 上,移动赋值运算符会高效地将所有字段从一个 message *移动*到另一个,而不会进行拷贝或堆分配(此操作的时间复杂度为 O(声明的字段数))。但是,如果只有一个 message 在 arena 上,或者两个 message 在不同的 arena 上,它会执行底层数据的*深拷贝*。在这两种情况下,源 message 都会处于一个有效但未指定的状态。
  • void Swap(Message* other): 如果要交换的两个 message 都不在 arena 上,或者它们在*同一个* arena 上,Swap() 的行为与未启用 arena 分配时相同:它高效地交换 message 对象的内容,几乎完全通过廉价的指针交换完成,避免了拷贝。但是,如果只有一个 message 在 arena 上,或者两个 message 在不同的 arena 上,Swap() 会执行底层数据的*深拷贝*。这种新行为是必要的,因为否则交换后的子对象可能会有不同的生命周期,可能导致悬挂指针(use-after-free)的 bug。
  • Message* New(Arena* arena): 标准 New() 方法的一个替代重载。它允许在给定的 arena 上创建此类型的新 message 对象。如果调用它的具体 message 类型是启用 arena 分配生成的,其语义与 Arena::Create(arena) 相同。如果 message 类型未启用 arena 分配生成,那么如果 arena 不为 NULL,它等同于一次普通分配后跟 arena->Own(message)
  • Arena* GetArena(): 返回此 message 对象分配于其上的 arena(如果有的话)。
  • void UnsafeArenaSwap(Message* other): 与 Swap() 相同,但它假定两个对象都在同一个 arena 上(或都不在 arena 上),并始终使用高效的指针交换实现此操作。使用此方法可以提高性能,因为与 Swap() 不同,它在执行交换前不需要检查哪个 message 存在于哪个 arena 上。正如 Unsafe 前缀所暗示的,您只应在确定要交换的 message 不在不同的 arena 上时才使用此方法;否则此方法可能产生不可预测的结果。

内嵌消息字段

当您在一个 arena 上分配一个 message 对象时,其内嵌的 message 字段对象(子 message)也会自动归该 arena 所有。这些 message 对象的分配方式取决于它们的定义位置:

  • 如果 message 类型也定义在启用了 arena 分配的 .proto 文件中,则该对象直接在 arena 上分配。
  • 如果 message 类型来自另一个未启用 arena 分配的 .proto 文件,则该对象是堆分配的,但由父 message 的 arena “拥有”。这意味着当 arena 被销毁时,该对象将与 arena 上的其他对象一起被释放。

对于字段定义:

Bar foo = 1;

当启用 arena 分配时,会添加以下方法或其行为有所特殊。否则,访问器方法仅使用默认行为

  • Bar* mutable_foo(): 返回一个指向子 message 实例的可变指针。如果父对象在一个 arena 上,那么返回的对象也将在这个 arena 上。
  • void set_allocated_foo(Bar* bar): 接收一个新对象,并将其作为字段的新值。当对象跨越 arena/arena 或 arena/heap 边界时,arena 支持增加了额外的拷贝语义以维持正确的所有权:
    • 如果父对象在堆上且 bar 在堆上,或者如果父对象和 message 在同一个 arena 上,此方法的行为不变。
    • 如果父对象在一个 arena 上且 bar 在堆上,父 message 会通过 arena->Own()bar 添加到其 arena 的所有权列表中。
    • 如果父对象在一个 arena 上且 bar 在另一个 arena 上,此方法会拷贝 message 并将拷贝作为新的字段值。
  • Bar* release_foo(): 如果字段已设置,则返回该字段的现有子 message 实例,如果未设置则返回 NULL 指针,并将该实例的所有权释放给调用者,同时清除父 message 的字段。Arena 支持增加了额外的拷贝语义,以维持返回的对象始终是*堆分配的*这一契约:
    • 如果父 message 在一个 arena 上,此方法将在堆上制作一个子 message 的拷贝,清除字段值,并返回该拷贝。
    • 如果父 message 在堆上,此方法的行为不变。
  • void unsafe_arena_set_allocated_foo(Bar* bar): 与 set_allocated_foo 相同,但假定父 message 和子 message 都在同一个 arena 上。使用此版本的方法可以提高性能,因为它不需要检查 message 是在特定的 arena 上还是在堆上。有关安全使用此方法的详细信息,请参阅allocated/release 模式
  • Bar* unsafe_arena_release_foo(): 类似于 release_foo(),但跳过所有所有权检查。有关安全使用此方法的详细信息,请参阅allocated/release 模式

字符串字段

即使其父 message 在 arena 上,字符串字段的数据也存储在堆上。因此,即使启用了 arena 分配,字符串访问器方法也使用默认行为

重复字段

当包含的 message 是 arena 分配时,重复字段会将其内部数组存储分配在 arena 上,并且当元素是按指针保留的独立对象(message 或字符串)时,也会将这些元素分配在 arena 上。在 message 类的层面上,为重复字段生成的方法没有变化。但是,当启用 arena 支持时,由访问器返回的 RepeatedFieldRepeatedPtrField 对象确实有新的方法和修改过的语义。

重复的数值字段

当启用 arena 分配时,包含基本类型的 RepeatedField 对象具有以下新增/更改的方法:

  • void UnsafeArenaSwap(RepeatedField* other): 执行 RepeatedField 内容的交换,而不验证此重复字段和 other 是否在同一个 arena 上。如果不在,这两个重复字段对象必须在具有等效生命周期的 arena 上。一个在 arena 上而另一个在堆上的情况会被检查并禁止。
  • void Swap(RepeatedField* other): 检查每个重复字段对象的 arena,如果一个在 arena 上而另一个在堆上,或者两者都在 arena 上但在不同的 arena 上,那么在交换发生前会拷贝底层数组。这意味着交换后,每个重复字段对象都持有一个位于其自身 arena 或堆上的数组(视情况而定)。

重复内嵌消息字段

当启用 arena 分配时,包含 message 的 RepeatedPtrField 对象具有以下新增/更改的方法。

  • void UnsafeArenaSwap(RepeatedPtrField* other): 执行 RepeatedPtrField 内容的交换,而不验证此重复字段和 other 是否具有相同的 arena 指针。如果它们不同,这两个重复字段对象必须具有生命周期等效的 arena 指针。一个有非 NULL arena 指针而另一个有 NULL arena 指针的情况会被检查并禁止。

  • void Swap(RepeatedPtrField* other): 检查每个重复字段对象的 arena 指针,如果一个是 non-NULL(内容在 arena 上)而另一个是 NULL(内容在堆上),或者两者都是 non-NULL 但值不同,那么在交换发生前会拷贝底层数组及其指向的对象。这意味着交换后,每个重复字段对象都持有一个位于其自身 arena 或堆上的数组(视情况而定)。

  • void AddAllocated(SubMessageType* value): 检查提供的 message 对象是否与重复字段的 arena 指针在同一个 arena 上。

    • 源和目标都是 arena 分配的且在同一个 arena 上:对象指针直接添加到基础数组中。
    • 源和目标都是 arena 分配的但在不同的 arena 上:创建一个拷贝,如果原始对象是堆分配的则释放它,然后将拷贝放入数组中。
    • 源是堆分配的,目标是 arena 分配的:不进行拷贝。
    • 源是 arena 分配的,目标是堆分配的:创建一个拷贝并放入数组中。
    • 源和目标都是堆分配的:对象指针直接添加到基础数组中。

    这维持了一个不变性,即由重复字段指向的所有对象都与重复字段的 arena 指针所指示的所有权域(堆或特定 arena)相同。

  • SubMessageType* ReleaseLast(): 返回一个与重复字段中最后一个 message 等效的堆分配 message,并将其从重复字段中移除。如果重复字段本身有一个 NULL arena 指针(因此,其所有指向的 message 都是堆分配的),则此方法简单地返回指向原始对象的指针。否则,如果重复字段有一个非 NULL arena 指针,此方法会创建一个堆分配的拷贝并返回该拷贝。在这两种情况下,调用者都会获得一个堆分配对象的所有权,并负责删除该对象。

  • void UnsafeArenaAddAllocated(SubMessageType* value): 类似于 AddAllocated(),但不执行堆/arena 检查或任何 message 拷贝。它将提供的指针直接添加到此重复字段的内部指针数组中。有关安全使用此方法的详细信息,请参阅allocated/release 模式

  • SubMessageType* UnsafeArenaReleaseLast(): 类似于 ReleaseLast(),但不执行任何拷贝,即使重复字段具有非 NULL arena 指针。相反,它直接返回指向对象在重复字段中时的指针。有关安全使用此方法的详细信息,请参阅allocated/release 模式

  • void ExtractSubrange(int start, int num, SubMessageType** elements): 从重复字段中移除 num 个元素,从索引 start 开始,如果 elements 不为 NULL,则将它们返回在 elements 中。如果重复字段在 arena 上,并且正在返回元素,则元素会先被拷贝到堆上。在这两种情况(无论是否在 arena 上),调用者都拥有返回的堆上对象的所有权。

  • void UnsafeArenaExtractSubrange(int start, int num, SubMessageType** elements): 从重复字段中移除 num 个元素,从索引 start 开始,如果 elements 不为 NULL,则将它们返回在 elements 中。与 ExtractSubrange() 不同,此方法从不拷贝提取的元素。有关安全使用此方法的详细信息,请参阅allocated/release 模式

重复的字符串字段

字符串的重复字段具有与 message 的重复字段相同的新方法和修改后的语义,因为它们也通过指针引用来维护其底层对象(即字符串)。

使用模式与最佳实践

在使用 arena 分配的 message 时,一些使用模式可能导致意外的拷贝或其他负面性能影响。您应该注意以下常见的模式,在为 arena 调整代码时可能需要修改。(请注意,我们在 API 设计中已尽力确保正确行为仍然发生——但更高性能的解决方案可能需要一些重构。)

意外的拷贝

一些在不使用 arena 分配时从不创建对象拷贝的方法,在启用 arena 支持时可能会这样做。如果您确保您的对象被适当地分配和/或使用提供的 arena 特定方法版本,就可以避免这些不必要的拷贝,具体如下所述。

Set Allocated/Add Allocated/Release

默认情况下,release_field()set_allocated_field() 方法(用于单个 message 字段),以及 ReleaseLast()AddAllocated() 方法(用于重复 message 字段)允许用户代码直接附加和分离子 message,通过传递指针所有权而不拷贝任何数据。

但是,当父 message 在 arena 上时,这些方法现在有时需要拷贝传入或返回的对象,以维持与现有所有权契约的兼容性。更具体地说,接收所有权的方法(set_allocated_field()AddAllocated())可能会在父 message 在 arena 上而新子对象不在,或反之,或它们在不同的 arena 上时拷贝数据。释放所有权的方法(release_field()ReleaseLast())可能会在父 message 在 arena 上时拷贝数据,因为根据契约,返回的对象必须在堆上。

为了避免此类拷贝,我们为这些方法添加了相应的“unsafe arena”版本,这些版本从不执行拷贝:分别为单个和重复字段的 unsafe_arena_set_allocated_field()unsafe_arena_release_field()UnsafeArenaAddAllocated()UnsafeArenaRelease()。这些方法只应在您知道这样做是安全的情况下使用。这些方法有两种常见的模式:

  • 在同一个 arena 的不同部分之间移动 message 树。请注意,在这种情况下,message 必须在同一个 arena 上才安全。
  • 临时将一个拥有的 message 借给一棵树以避免拷贝。将一个不安全的 *add*/*set* 方法与一个不安全的 *release* 方法配对,无论每个 message 的所有权如何,都能以最低成本的方式实现借用(这种模式在它们在同一个 arena、不同的 arena 或根本不在 arena 上时都有效)。请注意,在不安全的 *add*/*set* 和其对应的 *release* 之间,借用方不能被交换、移动、清除或销毁;借出的 message 不能被交换或移动;借出的 message 不能被借用方清除或释放;并且借出的 message 不能被销毁。

这里有一个如何用这些方法避免不必要拷贝的例子。假设您在一个 arena 上创建了以下 message。

Arena* arena = new google::protobuf::Arena();
MyFeatureMessage* arena_message_1 =
  google::protobuf::Arena::Create<MyFeatureMessage>(arena);
arena_message_1->mutable_nested_message()->set_feature_id(11);

MyFeatureMessage* arena_message_2 =
  google::protobuf::Arena::Create<MyFeatureMessage>(arena);

以下代码对 release_...() API 的使用效率低下:

arena_message_2->set_allocated_nested_message(arena_message_1->release_nested_message());

arena_message_1->release_message(); // returns a copy of the underlying nested_message and deletes underlying pointer

改用“unsafe arena”版本可以避免拷贝:

arena_message_2->unsafe_arena_set_allocated_nested_message(
   arena_message_1->unsafe_arena_release_nested_message());

您可以在上面的内嵌 message 字段部分找到有关这些方法的更多信息。

交换(Swap)

当两个 message 的内容通过 Swap() 交换时,如果这两个 message 位于不同的 arena 上,或者一个在 arena 上而另一个在堆上,底层子对象可能会被拷贝。如果您想避免这种拷贝,并且(i)知道这两个 message 在同一个 arena 上,或者在不同的 arena 上但这些 arena 具有等效的生命周期,或者(ii)知道这两个 message 都在堆上,您可以使用一个新方法 UnsafeArenaSwap()。此方法既避免了执行 arena 检查的开销,也避免了如果会发生拷贝时的拷贝操作。

例如,以下代码在 Swap() 调用中会产生一次拷贝:

MyFeatureMessage* message_1 =
  google::protobuf::Arena::Create<MyFeatureMessage>(arena);
message_1->mutable_nested_message()->set_feature_id(11);

MyFeatureMessage* message_2 = new MyFeatureMessage;
message_2->mutable_nested_message()->set_feature_id(22);

message_1->Swap(message_2); // Inefficient swap!

要在此代码中避免拷贝,您需要将 message_2 分配在与 message_1 相同的 arena 上:

MyFeatureMessage* message_2 =
   google::protobuf::Arena::Create<MyFeatureMessage>(arena);

粒度

我们发现在大多数应用服务器的使用场景中,“每个请求一个 arena”的模型效果很好。您可能会倾向于进一步细分 arena 的使用,要么是为了减少堆开销(通过更频繁地销毁较小的 arena),要么是为了减少感知到的线程争用问题。然而,使用更细粒度的 arena 可能会导致意外的 message 拷贝,如我们上面所述。我们还花了很多精力来优化 Arena 实现以适应多线程使用场景,因此单个 arena 应该适用于整个请求生命周期,即使有多个线程处理该请求。

示例

这里有一个简单的完整示例,演示了 arena 分配 API 的一些功能。

// my_feature.proto
edition = "2023";

import "nested_message.proto";

package feature_package;

// NEXT Tag to use: 4
message MyFeatureMessage {
  string feature_name = 1;
  repeated int32 feature_data = 2;
  NestedMessage nested_message = 3;
};
// nested_message.proto
edition = "2023";

package feature_package;

// NEXT Tag to use: 2
message NestedMessage {
  int32 feature_id = 1;
};

Message 构造与销毁

#include <google/protobuf/arena.h>

Arena arena;

MyFeatureMessage* arena_message =
   google::protobuf::Arena::Create<MyFeatureMessage>(&arena);

arena_message->set_feature_name("Editions Arena");
arena_message->mutable_feature_data()->Add(2);
arena_message->mutable_feature_data()->Add(4);
arena_message->mutable_nested_message()->set_feature_id(247);

  1. 目前,即使包含消息在 arena 上,字符串字段的数据也存储在堆上。未知字段也是堆分配的。 ↩︎

  2. 成为“完全兼容”类型需要满足的条件是 protobuf 库的内部细节,不应假定其可靠性。 ↩︎