C#のArrayPoolについて


記事の内容

  • ArrayPoolとは何か
  • ArrayPoolの使い方
  • 共有プールと自分で生成するプールの違い

ArrayPoolとは何か

ArrayPoolは任意の型の配列インスタンスを再利用できるようにするリソースプールを提供するクラスです。

特にシリアライズ・デシリアライズのようなたくさん配列を作っては捨てを繰り返すシチュエーションでは重宝してます。

ArrayPoolの使い方

  • 基本的に「借りる => 返す」を繰り返す
  • 借りる際は、最低限必要な長さを指定する

注意点

  • 取得する配列は初期化されてない場合がある
  • 配列が足りない場合は、新しい配列を作成する
    • i.e. 配列を返却しないと新しい配列を作ろうとするのでパフォーマンスが低下する

実際に使ってみる

ArrayPoolインスタンスはArrayPool<T>.Create()で作成する方法と共有プール(ArrayPool<T>.Shared)を使う方法があります。

これらはArrayPool<T>という抽象クラスで抽象化されているので、使い方に違いはありません。


var arrayPool1 = ArrayPool<int>.Shared; // 共有プールを使う方法
// var arrayPool2 = ArrayPool<int>.Create(); // 自分で生成する方法

var buffer = arrayPool1.Rent(16);

// 二つ目の引数にtrueを指定すると中身をクリア(Array.Clear())します。
// デフォルトではクリアしません。
// var buffer = arrayPool1.Rent(16, true);

arrayPool1.Return(buffer);

ArrayPool<T>.Create()で作成したプール

ArrayPool<T>.Create()では、ConfigurableArrayPoolインスタンスを作成します。

ConfigurableArrayPoolは生成時に16(2^4), 2^5, 2^6…と2^n単位でバケツという配列のプールを作成します。

PlantUML Diagram

Rent()の挙動

ConfigurableArrayPoolでRentをすると、minimumLengthが最低限入る2^n単位の長さの配列が返されます。

ConfiguredArrayPoolでは以下のように処理します。

  1. 最適な長さの配列を持つバケツが空だった場合、更に長い配列を持つバケツを見に行く(最大リトライ数は2回)
  2. 配列を借りるのに失敗した場合は、新たな長さminimumLengtの配列を作成し返す

Return()の挙動

Returnすると、最適なバケツに配列が戻されます。この時長さが2^n単位でない場合は例外が出ます。

既にバケツが一杯の場合は、特に何もしません。バケツから零れたものはGC行きになります。

https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Buffers/ConfigurableArrayPool.cs

共有プール

共有プールは各型ごとにバケツを持ちます。バケツは計27個で、それぞれ2^4 ~ 2^30の長さの配列を保持します。

またバケツの他にPartitionという配列のスタックが存在します。

PlantUML Diagram

SharedArrayPoolの特徴は、配列を複数持つバケツがThreadStaticである(ThreadLocalStorage内に確保される)ということです。1

バケツに対し、パーティションは複数のコアからアクセス可能な配列のスタックで、「スレッド間で共有可能なバケツ」のような挙動をします。

パーティションは複数インスタンス存在していてRent, Return時のプロセッサーIDによって参照するパーティションが異なるところがバケツとは異なります。

Rent時の挙動

配列の取得は以下の順で行われます

  1. バケツを確認
  2. 利用可能な配列が無ければ共有パーティションから取得
  3. それでも無ければ新規作成

Return時の挙動

配列の返却は以下の手順で行われます

  1. バケツに格納を試みる
  2. 満杯なら共有パーティションに格納
  3. それでも満杯ならGCで破棄

Trim機能

SharedArrayPoolにはTrimという機能があります。

SharedArrayPoolでは一度確保した配列を保持し続けメモリを圧迫するので、メモリを定期的に開放するという機能です。

そして取り過ぎた配列分のメモリを解放する代わりに、SharedArrayPoolのインスタンスはガベージコレクションによって回収されることはありません。 2

Trimではメモリ状況がひっ迫しているときは、バケツが保持する配列をクリアにしパーティションが保持する配列を一定量まで減らします。

そこまでひっ迫していないときは、バケツはそのままでパーティションの持つ配列を減らします。

Footnotes

  1. ThreadStaticであるのは、ロックの回数を減らすためだと思われます。

  2. これはGen2GcCallback.Registerメソッドで実現しています。https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Buffers/SharedArrayPool.cs#L288