C++ Arena 分配指南

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

本页详细介绍了在启用 arena 分配时,protocol buffer 编译器除了 C++ 代码生成指南 中描述的代码之外,还会生成哪些 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 中 repeated 字段中的子消息)也都在 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 被销毁时,它遍历此列表并使用运算符 delete(即系统内存分配器)释放每个对象。当对象的生命周期应与 arena 绑定,但由于某种原因,对象本身不能或尚未在 arena 上分配时,此方法很有用。
  • template<typename T> void OwnDestructor(T* object):将 object 的析构函数添加到 arena 的析构函数调用列表中。当 arena 被销毁时,它遍历此列表并依次调用每个析构函数。它不会尝试释放对象的底层内存。当对象嵌入在 arena 分配的存储中,但其析构函数不会以其他方式调用时,此方法很有用,例如,因为其包含类是一个 protocol buffer 消息,其析构函数不会被调用,或者因为它是在由 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() 执行底层数据的深拷贝。这种新行为是必要的,因为否则交换的子对象的生命周期可能不同,从而可能导致释放后使用错误。
  • 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 上时才应使用此方法;否则,此方法可能会产生不可预测的结果。

嵌入式 Message 字段

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

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

对于以下任一字段定义

optional Bar foo = 1;
required Bar foo = 1;

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

  • Bar* mutable_foo():返回子消息实例的可变指针。如果父对象在 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 模式

String 字段

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

Repeated 字段

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

Repeated 数值字段

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

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

Repeated 嵌入式消息字段

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

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

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

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

    • 源和目标都是 arena 分配的,并且在同一 arena 上:对象指针直接添加到底层数组。
    • 源和目标都是 arena 分配的,并且在不同的 arena 上:制作副本,如果原始副本是堆分配的,则释放原始副本,并将副本放置在数组中。
    • 源是堆分配的,目标是 arena 分配的:不制作副本。
    • 源是 arena 分配的,目标是堆分配的:制作副本并将其放置在数组中。
    • 源和目标都是堆分配的:对象指针直接添加到底层数组。

    这保持了不变性,即 repeated 字段指向的所有对象都与 repeated 字段的 arena 指针指示的相同所有权域(堆或特定 arena)中。

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

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

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

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

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

Repeated 字符串字段

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

使用模式和最佳实践

当使用 arena 分配的消息时,几种使用模式可能会导致意外的副本或其他负面性能影响。您应该注意以下常见模式,这些模式在为 arena 调整代码时可能需要更改。(请注意,我们在 API 设计中已小心确保仍然发生正确的行为 - 但更高性能的解决方案可能需要进行一些改造。)

意外的副本

在不使用 arena 分配时从不创建对象副本的几种方法,在启用 arena 支持时最终可能会这样做。如果您确保您的对象分配得当和/或使用提供的 arena 特定方法版本,则可以避免这些不需要的副本,如下面更详细的描述。

Set Allocated/Add Allocated/Release

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

但是,当父消息在 arena 上时,这些方法现在有时需要复制传入或返回的对象,以维护与现有所有权约定的兼容性。更具体地说,获取所有权的方法(set_allocated_field()AddAllocated())如果父消息在 arena 上且新的子对象不在 arena 上,或者反之亦然,或者它们在不同的 arena 上,则可能会复制数据。释放所有权的方法(release_field()ReleaseLast())如果父消息在 arena 上,则可能会复制数据,因为根据约定,返回的对象必须在堆上。

为了避免此类副本,我们添加了这些方法的相应“unsafe arena”版本,其中永远不会执行副本:unsafe_arena_set_allocated_field()unsafe_arena_release_field()UnsafeArenaAddAllocated()UnsafeArenaRelease() 分别用于单数和 repeated 字段。只有当您知道它们可以安全地使用时,才应使用这些方法。这些方法有两种常见模式

  • 在同一 arena 的不同部分之间移动消息树。请注意,消息必须在同一 arena 上,这种情况才是安全的。
  • 临时借用拥有的消息到树以避免复制。将 unsafe add/set 方法与 unsafe release 方法配对,无论消息的所有权方式如何,都以最便宜的方式执行借用(当它们在同一 arena、不同 arena 或根本不在 arena 中时,此模式都有效)。请注意,在 unsafe 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

使用“unsafe 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_1 相同的 arena 上分配 message_2

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

粒度

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

示例

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

// 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 库内部的,不应假定为可靠的。 ↩︎