連続的に高速なトランザクションを処理すると、オブジェクトの生成速度とGCの実行サイクルの間の不均衡がメモリとCPU使用率を増加させる状況を引き起こす可能性があります。 今回の投稿では、このような状況でオブジェクトプール(Object Pool)を使用するかどうかのアイデアを検証した結果を共有します。
テスト結果と評価
CPUとメモリ使用量の測定結果
左がオブジェクトプールを適用した結果であり、右が必要なときにだけオブジェクトを作成して使用した結果です。
- メモリ使用量は目立つ違いは見られませんでした。
- オブジェクトフルを使用しない場合、GCが実行されるたびにオブジェクトが返され、メモリ使用量の変化がやや大きいと測定されました。
- CPU使用量はオブジェクトプールを使用した方が高く、使用量の変化も大きかったです。
テスト実施時間測定結果
テストの実行時間に、メモリプールを使用した側がやや速いと測定されました。
評価
テストに使用されたオブジェクトプールの方がCPUをもうちょっと使用していると判明され、実行時間に大きな違いはありません。
テストの条件が単純で、今回の結果だけでどれが良いと結論を出すのは難しいですが、一般的に大きなメリットはないようです。 ただし、メモリプールのバッファサイズとスレッド数によってパフォーマンスの優位性が変わり、オブジェクトの初期化または解除プロセスの複雑さによって、オブジェクトプールのメリットがさらに強調される可能性があります。
オブジェクトプールの対象となるオブジェクトのクラスコードは、次のように単純です。
public class Step : IDisposable
{
private bool _disposed = false;
~Step()
{
Dispose(false);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
_disposed = true;
if (disposing)
{
// Dispose managed state (managed objects).
}
}
public Step Parent { get; }
public long TraceId { get; set; }
public DateTimeOffset StartTime { get; }
public TimeSpan Duration { get; set; }
public string ResourceName { get; set; }
}
以下は、StepクラスをIDisposableから継承せずに、さらに単純にしたときの実行時間の結果です。
オブジェクトプールの実行時間が12倍ほど長く、損害がさらに大きくなったことがわかります。
上記のテストで使用されたStepクラスのコードは次のとおりです。
public class Step
{
public Step Parent { get; }
public long TraceId { get; set; }
public DateTimeOffset StartTime { get; }
public TimeSpan Duration { get; set; }
public string ResourceName { get; set; }
}
メモリプールのコードを見る
LazyRelease class
マルチスレッド環境でメモリプールを実装するときにロックを使用すると、パフォーマンスに悪影響を及ぼす可能性があります。したがって、ロックの代わりにInterlocked操作を使用してメモリプールを実装しました。 このための基本的なアルゴリズムは、フル状態のない上書き形式のリングバッファです。これにより、現在の位置を指すインデックスを増やすだけで、Interlocked操作を使用してロックなしでスレッドセーフなデータ構造を実装できます。
LazyRelease は Release() メソッドのみを持ち、このメソッドに渡されたオブジェクトはすぐに消滅せずにリングバッファのサイズだけ保持されます。だから遅く削除されるという意味でLazyReleaseと名付けることにしました。
public class LazyRelease
{
private const uint Capacity = 1 << 16; // 2^16
private readonly T[] buffer = new T[Capacity];
private int tail = 0;
public T Release(T value)
{
int index = Interlocked.Increment(ref tail) & 0xFFFF;
T oldItem = buffer[index];
buffer[index] = value;
return oldItem;
}
}
indexが増えていくと、indexがoverflowしないように最初の位置に循環させる必要があります。 以下のコードのように変更する前の値が他のスレッドによって変化していないことを確認しながら処理する必要があります。
if (index > Capacity)
{
Interlocked.CompareExchange(ref tail, index % Capacity, index);
}
問題はロックほどではなくてもInterlockedもかなりCPUをいじめる命令語だという点です。そのため、LazyReleaseクラスではoverflowが起きても問題がないように、Capacityをindexの最大値を割って落とせる値を使用しました。
そして、CPUをもっと苦しめる%オペレーターの代わりに&オペレーターに置き換えることができるようになり、さらにパフォーマンスを節約できるようになりました。
Interlocked Object Pool
Interlocked Object Poolは、実際のオブジェクトプールを実装したクラスです。 GetOrCreate() メソッドが新しいオブジェクトを返す機能を提供します。メモリプールから取得できる場合はそのオブジェクトをすぐに返しますが、失敗した場合は新しいオブジェクトを作成して返します。
メモリプールにオブジェクトが十分に格納されていても失敗することがあります。これを正しく処理するには、最終的にロックをかけなければなりません。 ロックをかけると、メモリプールを使用することのメリットよりもパフォーマンスの問題に対する費用が増える可能性があるため、このような失敗を許容する方法を使用することになります
public class InterlockedObjectPool
{
private LazyRelease _buffer = new LazyRelease();
public Step GetOrCreate()
{
Step newObject = null;
var old = _buffer.Release(null);
if (old == null) newObject = new Step();
else newObject = old;
return newObject;
}
public void Release(Step step)
{
_buffer.Release(step);
}
}