🏊

C#:オブジェクトプールとVersionパターン

2023/07/23に公開

今回はオブジェクトプールを安全に使うための小ネタを紹介します。

C#では、ガーベジコレクションによるスパイクの発生を抑えるためにヒープメモリ確保を少なくすることが重要です。ヒープメモリ確保を抑えるための最も基本的な対策はclassのかわりにstructを使うことですが、どうしてもclassを使いたい場合があります。その際に有効なのがオブジェクトプールで、一度生成したインスタンスをプールして使いまわします。

最も基本的なのは次のような実装です。

using System;
using System.Collections.Generic;

public class Hoge
{
    private static readonly Stack<Hoge> _pool = new();

    public static Hoge Get()
    {
        if (!_pool.TryPop(out var pooled))
        {
            pooled = new Hoge();
        }

        pooled.InUse = true;
        return pooled;
    }

    public static void Release(Hoge used)
    {
        if (!used.InUse) throw new InvalidOperationException();
        used.InUse = false;
            
        _pool.Push(used);
    }

    private bool InUse { get; set; } = true;
    
    public void SomeApi()
    {
        
    }
}

ただここまで素朴な実装だと、使用者側が既に返却したオブジェクト参照を持ち続け、アクセスできてしまう問題があります。

var instance = Hoge.Get();
instance.SomeApi();

Hoge.Release(instance);

//まだアクセス可能
instance.SomeApi();

そこで、使用者側が返却済みのオブジェクト参照をもう使えないようにする必要があります。こんな時は次のようなパターンが有効です。

using System;
using System.Collections.Generic;

public readonly struct Hoge
{
    class InternalHoge
    {
        public uint Version { get; set; }
    
        public void SomeApi()
        {
        
        }
    }
    
    private static readonly Stack<InternalHoge> _pool = new();

    public static Hoge Get()
    {
        if (!_pool.TryPop(out var pooled))
        {
            pooled = new InternalHoge();
        }
        
        return new Hoge(pooled);
    }

    public static void Release(Hoge used)
    {
        var @internal = used.Internal;
        @internal.Version++;
	
	// オーバーフローするときはプールに戻さない
        if (@internal.Version < uint.MaxValue)
        {
            _pool.Push(@internal);
        }
    }
    
    private uint Version { get; }
    private InternalHoge Internal { get; }

    Hoge(InternalHoge @internal)
    {
        Version = @internal.Version;
        Internal = @internal;
    }

    public void SomeApi()
    {
        if (this.Version != Internal.Version) throw new InvalidOperationException("This Hoge is already released.");
        
        Internal.SomeApi();
    }
}

こんな風に、実際にインスタンス生成するclassは内部に秘匿し、使用者にはそれをラップするstructを渡します。

classstructはお互いにversionと呼ばれる数値を保持し、structは生成時にclass側のversionをコピーします。

そして、インスタンスの返却時にはclass側のversionをインクリメントします。これによりstructのversionとclass側のversionが一致しなくなり、それ以上のアクセスをブロックすることができます。

var instance = Hoge.Get();
instance.SomeApi();

Hoge.Release(instance);

//Version違いによりエラー
instance.SomeApi();

ということで、オブジェクトプールのパフォーマンス的な利点と、安全性を両取りするためのVersionパターン(と呼ぶのが一般的なのかは知らない)の紹介でした。

Discussion