C++ Arena 分配指南

Arena 分配是 C++ 獨有的功能,可幫助您在使用協議緩衝區時最佳化記憶體使用並提高效能。

本頁面描述了當啟用 Arena 分配時,協議緩衝區編譯器除了在C++ 生成程式碼指南中描述的程式碼之外,還精確生成了哪些 C++ 程式碼。它假設您熟悉語言指南C++ 生成程式碼指南中的內容。

為什麼要使用 Arena 分配?

記憶體分配和釋放構成了協議緩衝區程式碼中 CPU 時間的很大一部分。預設情況下,協議緩衝區對每個訊息物件、其每個子物件以及字串等多種欄位型別執行堆分配。這些分配在解析訊息和在記憶體中構建新訊息時批次發生,相關的釋放發生在訊息及其子物件樹被釋放時。

基於 Arena 的分配旨在降低這種效能成本。透過 Arena 分配,新物件從一塊預分配的大記憶體塊(稱為 Arena)中分配。所有物件可以透過丟棄整個 Arena 一次性釋放,理想情況下無需執行任何包含物件的解構函式(儘管 Arena 在需要時仍可維護一個“解構函式列表”)。這透過將物件分配簡化為簡單的指標遞增,從而加快了物件分配速度,並使釋放幾乎免費。Arena 分配還提供了更高的快取效率:當解析訊息時,它們更有可能被分配在連續的記憶體中,這使得遍歷訊息更有可能命中熱快取行。

為了獲得這些好處,您需要了解物件的生命週期並找到一個合適的粒度來使用 Arena(對於伺服器來說,這通常是每個請求)。您可以在使用模式和最佳實踐中找到有關如何充分利用 Arena 分配的更多資訊。

此表總結了使用 Arena 的典型效能優勢和劣勢

操作堆分配的 proto 訊息Arena 分配的 proto 訊息
訊息分配平均較慢平均較快
訊息銷燬平均較慢平均較快
訊息移動始終是移動(成本相當於淺複製有時是深複製

入門

協議緩衝區編譯器為檔案中的訊息生成 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 的新協議緩衝區物件及其子物件。

      如果 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 分配的儲存中但其解構函式不會被呼叫的情況下非常有用,例如,因為其包含類是一個協議緩衝區訊息,其解構函式不會被呼叫,或者因為它是在 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&& other): 如果源訊息不在 Arena 上,移動建構函式會有效地將所有欄位從一個訊息 移動 到另一個訊息,而無需進行復制或堆分配(此操作的時間複雜度為 O(number-of-declared-fields))。但是,如果源訊息在 Arena 上,它會執行底層資料的 深複製。在兩種情況下,源訊息都處於有效但未指定的狀態。
  • Message& operator=(Message&& other): 如果兩個訊息都不在 Arena 上或在 同一 Arena 上,移動賦值運算子會有效地將所有欄位從一個訊息 移動 到另一個訊息,而無需進行復制或堆分配(此操作的時間複雜度為 O(number-of-declared-fields))。但是,如果只有一個訊息在 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->Own(message)(如果 arena 不為 NULL)。
  • 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 或堆上。有關安全使用此方法的詳細資訊,請參閱allocated/release 模式
  • Bar* unsafe_arena_release_foo(): 類似於 release_foo(),但跳過所有所有權檢查。有關安全使用此方法的詳細資訊,請參閱allocated/release 模式

字串欄位

字串欄位將其資料儲存在堆上,即使其父訊息在 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(): 返回一個堆分配的訊息,該訊息等效於重複欄位中的最後一個訊息,並將其從重複欄位中移除。如果重複欄位本身具有 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): 從重複欄位中移除 num 個元素,從索引 start 開始,並在 elements 不為 NULL 時將其返回到 elements 中。如果重複欄位在 Arena 上,並且正在返回元素,則元素會首先複製到堆中。在兩種情況下(Arena 或無 Arena),呼叫者都擁有堆上返回的物件。

  • void UnsafeArenaExtractSubrange(int start, int num, SubMessageType** elements): 從重複欄位中移除 num 個元素,從索引 start 開始,並在 elements 不為 NULL 時將其返回到 elements 中。與 ExtractSubrange() 不同,此方法從不復制提取的元素。有關安全使用此方法的詳細資訊,請參閱allocated/release 模式

重複字串欄位

字串的重複欄位與訊息的重複欄位具有相同的新方法和修改後的語義,因為它們也透過指標引用維護其底層物件(即字串)。

使用模式和最佳實踐

當使用 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 上,此情況才安全。
  • 臨時將擁有的訊息借給樹以避免複製。將不安全的 新增/設定 方法與不安全的 釋放 方法配對,以最便宜的方式執行借用,無論訊息如何擁有(此模式適用於它們在同一 Arena、不同 Arena 或根本沒有 Arena 的情況)。請注意,在不安全的 新增/設定 及其相應的 釋放 之間,借用者不得被交換、移動、清除或銷燬;借用的訊息不得被交換或移動;借用的訊息不得被借用者清除或釋放;借用的訊息不得被銷燬。

以下是如何使用這些方法避免不必要複製的示例。假設您已在 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)或減少感知到的執行緒爭用問題。然而,使用更細粒度的 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. 成為“完全相容”型別所需的條件是協議緩衝區庫內部的,不應假定其可靠性。 ↩︎