C++ Arena 分配指南

Arena 分配是一项仅限 C++ 的功能,可帮助您在使用 Protocol Buffers 时优化内存使用并提高性能。

本页面介绍了在启用 Arena 分配时,除了《C++ 生成代码指南》中描述的代码之外,Protocol Buffer 编译器还会生成哪些 C++ 代码。本页面假定您已熟悉语言指南《C++ 生成代码指南》中的内容。

为何使用 Arena 分配?

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

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

要获得这些好处,您需要了解对象的生命周期,并找到一个合适的粒度来使用 Arena(对于服务器而言,通常是每个请求一个)。您可以在用法模式与最佳实践中了解如何最大程度地利用 Arena 分配。

下表总结了使用 Arena 的典型性能优缺点。

操作堆分配的 proto 消息Arena 分配的 proto 消息
消息分配平均较慢平均较快
消息销毁平均较慢平均较快
消息移动始终为移动(成本等同于浅拷贝有时是深拷贝

入门指南

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

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

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

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

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

Arena 类 API

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

构造函数

  • 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 必须有一个平凡的构造函数:当数组在 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 消息,其析构函数不会被调用,或者因为它是在由 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 分配的对象的线程进行同步。

生成的消息类

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

消息类方法

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

内嵌消息字段

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

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

对于字段定义:

Bar foo = 1;

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

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

字符串字段

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

重复字段

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

重复的数值字段

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

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

重复内嵌消息字段

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

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

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

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

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

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

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

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

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

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

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

重复的字符串字段

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

用法模式与最佳实践

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

意外的复制

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

设置已分配/添加已分配/释放

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

然而,当父消息在 Arena 上时,这些方法现在有时需要复制传入或返回的对象,以保持与现有所有权契约的兼容性。更具体地说,接收所有权的方法(set_allocated_field()AddAllocated())如果父消息在 Arena 上而新的子对象不在,或者反之,或者它们在不同的 Arena 上,就可能会复制数据。释放所有权的方法(release_field()ReleaseLast())如果父消息在 Arena 上,就可能会复制数据,因为根据契约,返回的对象必须在堆上。

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

  • 在同一个 Arena 的不同部分之间移动消息树。请注意,要保证安全,消息必须在同一个 Arena 上。
  • 临时将一个拥有的消息借给一个树以避免复制。将一个不安全的 *add*/*set* 方法与一个不安全的 *release* 方法配对,无论每个消息如何被拥有,都能以最低成本的方式实现借用(这种模式在它们在同一个 Arena、不同 Arena 或根本没有 Arena 的情况下都有效)。请注意,在不安全的 *add*/*set* 与其对应的 *release* 之间,借用者不得被交换、移动、清除或销毁;借出的消息不得被交换或移动;借出的消息不得被借用者清除或释放;借出的消息不得被销毁。

以下是一个如何使用这些方法避免不必要复制的示例。假设您在 Arena 上创建了以下消息。

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

改用“不安全 Arena”版本可以避免复制:

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

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

交换

当两个消息的内容使用 Swap() 进行交换时,如果两个消息位于不同的 Arena 上,或者一个在 Arena 上而另一个在堆上,底层子对象可能会被复制。如果您想避免这种复制,并且 (i) 知道两个消息在同一个 Arena 上或在不同的 Arena 上但这些 Arena 具有等效的生命周期,或者 (ii) 知道两个消息都在堆上,您可以使用一个新方法 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_1 相同的 Arena 上分配 message_2

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

粒度

我们发现在大多数应用服务器用例中,“每个请求一个 Arena”的模型效果很好。您可能会倾向于进一步细分 Arena 的使用,要么是为了减少堆开销(通过更频繁地销毁较小的 Arena),要么是为了减少 perceived 的线程争用问题。然而,使用更细粒度的 Arena 可能会导致意外的消息复制,如上所述。我们还努力优化了 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;
};

消息构造和释放

#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 库的内部实现细节,不应假定其可靠性。 ↩︎