C++ Arena 分配指南

Arena 分配是 C++ 特有的功能,有助于您在使用 Protocol Buffers 时优化内存使用和提高性能。

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

为何使用 Arena 分配?

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

基于 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,此方法将为 n 个类型为 T 的元素分配原始存储并返回。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 被销毁时,它会遍历此列表并依次调用每个析构函数。它不会尝试释放对象的底层内存。当对象嵌入在 Arena 分配的存储中,但其析构函数不会被调用时,此方法很有用,例如,因为其包含类是一个 protobuf 消息,其析构函数不会被调用,或者因为它是在通过 AllocateArray() 分配的块中手动构建的。

其他方法

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

线程安全

google::protobuf::Arena 的分配方法是线程安全的,底层实现花了一些功夫来使多线程分配快速。Reset() 方法不是线程安全的:执行 Arena 重置的线程必须首先与所有执行分配或使用从该 Arena 分配的对象的线程同步。

生成的 Message 类

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

Message 类方法

  • 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() 会对底层数据执行深拷贝。这种新行为是必要的,因为否则交换的子对象可能具有不同的生命周期,可能导致使用后释放 (use-after-free) 错误。
  • 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 上的对象一起被释放。

对于以下任一字段定义

optional Bar foo = 1;
required 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 或堆上。有关安全使用方法的详细信息,请参阅allocated/release 模式
  • Bar* unsafe_arena_release_foo(): 类似于 release_foo(),但跳过所有权检查。有关安全使用方法的详细信息,请参阅allocated/release 模式

字符串字段

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

重复字段

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

重复的数字字段

包含基本类型的 RepeatedField 对象在启用 Arena 分配时具有以下新/更改的方法

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

重复的嵌入式消息字段

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

  • 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)。

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

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

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

  • 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() 不同,此方法从不复制提取的元素。有关安全使用方法的详细信息,请参阅allocated/release 模式

重复的字符串字段

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

使用模式和最佳实践

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

意外的复制

一些在不使用 Arena 分配时从不创建对象副本的方法,在启用 Arena 支持时最终可能会创建副本。如果您确保对象已适当分配和/或使用提供的 Arena 特定方法版本,则可以避免这些不必要的副本,如下所述更详细。

Set Allocated/Add Allocated/Release

默认情况下,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

当两个消息的内容使用 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_2 分配到与 message_1 相同的 Arena 上

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

粒度

我们发现在大多数应用服务器用例中,“每个请求一个 Arena”的模型效果很好。您可能想进一步细分 Arena 的使用,以减少堆开销(通过更频繁地销毁较小的 Arena)或减少线程争用问题。但是,使用更细粒度的 Arena 可能导致意外的消息复制,如上所述。我们还投入了精力来优化多线程用例的 Arena 实现,因此即使有多个线程处理该请求,单个 Arena 也应该适合在整个请求生命周期内使用。

示例

示例

// my_feature.proto

syntax = "proto2";
import "nested_message.proto";

package feature_package;

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

syntax = "proto2";

package feature_package;

// NEXT Tag to use: 2
message NestedMessage {
  optional 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("Proto2 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 库内部的,不应假定其可靠。 ↩︎