アジョブジ星通信

進捗が出た頃に更新されるブログ。

System.Buffers.ManagedBufferPool を理解する

corefxlab Advent Calendar 6日目です(大嘘)。

昨日の記事の補足というか、こっちもちゃんと理解していないと無駄が多くなりそうということで書いておきます。

この記事の最終更新日は 2015/12/06 です。変化の多いリポジトリで、仕様も固まっていないことから、ここで説明する内容はすぐに古くなる可能性があります。

ManagedBufferPool の基本的な使い方

ManagedBufferPool は配列を使いまわして GC 回数を減らすことが目的のもので、 System.Buffers アセンブリに含まれています。インストール方法は昨日の記事を見ればわかると思います。

ManagedBufferPool<T> ということでジェネリックパラメータを持っていますが、ソースコードを読んだ感じだと byte 以外は想定されていなさそうです。

インスタンスを作成する

コンストラクタはこのようになっています。

ManagedBufferPool(int maxBufferSizeInBytes = 2048000)

maxBufferSizeInBytes という名前がついていますが、今のところ要素数なのでバイト単位ではありません。

maxBufferSizeInBytes で指定されたサイズを超えるバッファを要求した場合には、その byte 配列は再利用されることはありません。

また、コンストラクタを呼ぶほかに、デフォルトのインスタンスが存在しています。

ManagedBufferPool<byte>.SharedByteBufferPool

byte 配列専用なのになんでジェネリッククラス内に定義したのかさっぱりわかりませんが、とにかくこのように取得しておけばいいと思います。なお、このクラスは使い回せば使い回すほど効果が高いので、基本的に SharedByteBufferPool を使うことをおすすめします。

バッファの取得、拡張、破棄

バッファの取得は T[] RentBuffer(int size, bool clearBuffer = false) を使います。 size に指定したサイズぴったりの配列が返されるわけではないことに注意してください。また clearBuffer に true を指定することで、確実に空の配列を得ることができます。逆に false を指定した場合は何が入っているかわかりません。

バッファを拡張するには void EnlargeBuffer(ref T[] buffer, int newSize, bool clearFreeSpace = false) を使います。第一引数は ref なことに注意してください。

バッファを破棄するには void ReturnBuffer(ref T[] buffer, bool clearBuffer = false) を使います。これも第一引数は ref です。ここで指定した buffer には null が代入されることがあります。つまりこれを呼び出した後は、その変数を使うなということです。

var pool = ManagedBufferPool<byte>.SharedByteBufferPool;
var buffer = pool.RentBuffer(16);
pool.EnlargeBuffer(ref buffer, 32);
pool.ReturnBuffer(ref buffer);

さぁもっと深入りするぞ

ManagedBufferPool は内部で大量の配列を保持しているわけですが、その法則を見ていきましょう。

作成される配列

まず、配列の最小サイズは 16 です。 RentBuffer(1) を実行したところで Length は 16 です。 16 より大きいサイズを指定すると 32, 64 と 2 倍されて行きます。

そして、同じサイズの配列は内部で 50 個作成されます。これは遅延実行ではなく、そのサイズのバッファが初めて要求されたタイミングで一気に作成されます。

Rent と Return の挙動

これはソースコードを見るのが早いです。
https://github.com/dotnet/corefxlab/blob/master/src/System.Buffers/src/System/Buffers/ManagedBufferBucket.cs

Rent では _data(50個の配列)から 1 つ取り出し、 _index をインクリメントします。 Return では _index をデクリメントし、 _data に配列を戻します。つまりこのような状態の変化が起きています。

var pool = new ManagedBufferPool<byte>();

// サイズ 16 の配列 50 個の状態
// _index = 0
// _data = [buf, buf, buf, ...]

var buf1 = pool.RentBuffer(16);

// _index = 1
// _data = [null, buf, buf, ...]

var buf2 = pool.RentBuffer(16);

// _index = 2
// _data = [null, null, buf, ...]

pool.ReturnBuffer(ref buf1);

// _index = 1
// _data = [null, buf, buf, ...]

ここで、 _index が 50 以上になると、この _data から配列を得ることができなくなるため、新たに配列が作成されることになります。このことから ReturnBuffer をし忘れると、どんどん _data が削られていき、足りなくなって再利用効率が悪くなります。

効率の良い使い方

  • SharedByteBufferPool を使用する
  • 2のn乗を意識してバッファを取得する
  • 確実に ReturnBuffer する

ManagedBufferPool インスタンスを大量に作成することは無駄な配列を大量に作成することになるので、 ManagedBufferPool の意味がなくなってしまいます。

確実に ReturnBuffer すると書きましたが、別に RentBuffer で取得したバッファを返す必要はありません。同じサイズの配列を新たに作成して入れてあげてもいいでしょう。そのほうがコピーするより速いですから。

ReturnBuffer の不正利用の危険

さっき ReturnBuffer に RentBuffer で取得したバッファを返す必要はないとか言いましたが、適当な配列を投げつけていいとは言ってません。やってみると結構恐ろしいことになります。

var pool = new ManagedBufferPool<byte>();
pool.RentBuffer(16);
var x = new byte[15];
pool.ReturnBuffer(ref x);
var buf = pool.RentBuffer(16);

この最後の buf は byte[15] です。気をつけてください。では。