[C#] 遅延初期化を実現する3つのパターンと2つのクラス、Singletonパターンに特化した2つの方法

  • 38
    いいね
  • 0
    コメント

前置き

.NET4.0以降、インスタンスの生成を遅延させ、必要になったときにはじめて生成するためのクラスがふたつ追加されている。

  • Lazy<T>
  • LazyInitializer

それぞれの特徴と、それ以外の遅延初期化方法について説明する。

.NET Framework 4.0以前

.NET4.0以前には、Lazy<T>LazyInitializerもなかったため、遅延初期化を自分で実現する必要があり、その方法にはおおよそ三種類のパターンがあった。
Lazy<T>LazyInitializerも、その3つのパターンをもとに実装されているので、まずは、このパターンを説明する。

スレッドセーフでない遅延初期化

スレッドを考慮しない遅延初期化。
だれでも簡単に実装でき、コード量が少ない。

マルチスレッドに対応する必要のない環境ではこれで十分であり、インスタンスフィールドの初期化にあたって広く使用できる。

NotThreadSafe.cs
class NotThreadSafe
{
    private LazyInitialized _lazyinitialized;

    private LazyInitialized Instance
    {
        get
        {
            if (_lazyinitialized == null)
                _lazyinitialized = new LazyInitialized();
            return _lazyinitialized;
        }
    }
}

Race-to-initializeパターン

マルチスレッドに対応するために、フィールドへの代入を「すでに他のスレッドから代入されていなかったときのみ」に限定している。
こうすることで、初期化処理は何度も実行される可能性はあるが、代入自体は一度しかおこなわれないことが保証される。

RaceToInitialize.cs
class RaceToInitialize
{
    private LazyInitialized _lazyinitialized;

    private LazyInitialized Instance
    {
        get
        {
            if (_lazyinitialized == null)
            {
                var instance = new LazyInitialized();
                Interlocked.CompareExchange(ref _lazyinitialized, instance, null);
            }
            return _lazyinitialized;
        }
    }
}

IDisposableの考慮

IDisposableインタフェースを実装するクラスのインスタンスを格納するのであれば、代入できなかったときにDispose()メソッドを呼んだほうがよりよいだろう。

Disposeする必要があるのなら、以下のようになる。

private LazyInitialized Instance
{
    get
    {
        if (_lazyinitialized == null)
        {
            var instance = new LazyInitialized();
            if (Interlocked.CompareExchange(ref _lazyinitialized, instance, null) != null)
            {
                var dispose = instance as IDisposable;
                if (dispose != null) dispose.Dispose();
            }
        }
        return _lazyinitialized;
    }
}

欠点

初期化処理そのものがスレッドセーフである必要がある。
前述のとおり、初期化処理が複数回実行される場合があり、初期化処理の実行を一回に抑制したい場合は採用できない。
また、遅延初期化する必要のある初期化処理は一般に時間のかかる重い処理であり、必要のない実行をするぐらいならそのスレッドには寝ていてもらった方がましな場合が多い。
そのため、このパターンを適用したい場面はそう多くはないはずだ。

Double-checked-lockingイディオムパターン

lockステートメントのブロック内部で、

  • nullチェック
  • 初期化処理の実行
  • 変数への代入

を実行することで、初期化処理の実行とその代入が一度しかおこなわれないことを保証し、その外部で初回のnullチェックをすることで、あきらかに初期化済みなときのパフォーマンスをかせぐ方法。
初期化処理がスレッドセーフである必要がない。

DoubleCheckedLocking.cs
class DoubleCheckedLocking
{
    private volatile LazyInitialized _lazyinitialized;
    private readonly object _lock = new object();

    private LazyInitialized Instance
    {
        get
        {
            if (_lazyinitialized == null)
            {
                lock (_lock)
                {
                    if (_lazyinitialized == null)
                        _lazyinitialized = new LazyInitialized();
                }
            }
            return _lazyinitialized;
        }
    }
}

遅延初期化されるフィールドをvolatile宣言する

Javaの場合、1.4とそれ以前ではこのパターンは使用できなかった。インスタンスフィールドがnullではないが、コンストラクタも実行されていないという状況が発生するためである。
1.5以降でも、遅延初期化するフィールドにvolatileをつけ忘れると同じ現象が発生する。

C#の場合でもvolatileをつけるべきだということにかわりはないが、Microsoftが今まで提供してきたx86/x64用環境に限定すれば、つけなくてもプログラマの想定したとおりに動作するようだ。
(詳細は参考情報のThe need for volatile modifier in double checked locking in .NETを読んでほしい)

lockステートメントに指定するオブジェクト

排他制御のためにlockステートメントに指定するオブジェクトは、基本的に

  • インスタンスフィールドの遅延初期化にはインスタンスフィールド
  • 静的フィールドの遅延初期化には静的フィールド

に定義されているオブジェクトを使用する。readonlyを使用して、途中で違う値を再代入されないことを保証するとよい。

欠点

  • lockInterlocked.CompareExchangeよりもコストが高い
  • 初期化処理呼び出し部分で他のオブジェクトを使用したlockステートメントを含むコードを実行すると、デッドロックのおそれがある
  • volatilelockのためのオブジェクトと、注意する点がRace-to-initializeよりも多い

そのほか

  • 複数回の初期化を許容するインスタンスフィールドを遅延して初期化するために、Double-checked-lockingから2回めのnullチェックを省略した「Single-check idiom」
  • 32bit以下のサイズの組込まれた値型を使ってSingle-check idiomからvolatileを取り除いた「Racy single-check idiom」
  • 他にも、lockステートメントのブロック内で初回にして最後のnullチェックをおこなうパターンもあるだろう。もちろん、非効率である。

.NET Framework 4.0以降

前述のとおり、.NET4.0以降ではふたつのクラスを使用することで、より簡単に遅延初期化を実現できる。

System.Lazy<T>

遅延初期化対象のインスタンスやロック用オブジェクトといった必要な情報を、そのインスタンスに格納してくれるクラス。Valueプロパティを呼び出したとき、はじめて初期化処理を呼び出す動作をする。

デフォルトでは、Double-checked-lockingパターンを使用する。
ただし、LazyThreadSafetyMode列挙体を指定することで、3種類すべての遅延初期化方法を使うことができる。

LazyExample.cs
class LazyExample
{
    private Lazy<LazyInitialized> _lazyinitialized
        = new Lazy<LazyInitialized>(() => new LazyInitialized());
       // new Lazy<LazyInitialized>(() => new LazyInitialized(), LazyThreadSafetyMode.xxx); とすることで遅延初期化の実現方法を変更することができる

    private LazyInitialized Instance
    {
        get { return _lazyinitialized.Value; }
    }
}

LazyThreadSafetyMode

LazyThreadSafetyModeには3つの値があり、デフォルトではExecutionAndPublicationの動作をする。

意味
None スレッドセーフでない初期化をおこなう
ExecutionAndPublication Double-checked-lockingパターンによる初期化。初期化処理呼び出しも代入も一回だけおこなわれる
PublicationOnly Race-to-initializeパターンによる初期化。代入は一回だけだが、初期化処理は複数回実行される場合がある

System.Threading.LazyInitializer

EnsureInitializedというクラスメソッドと、初期化対象となる外部の変数を使用して遅延初期化を実現するクラス。

デフォルトでは、Race-to-initializeパターンを使用する。
ただし、相互排他ロックをおこなうためのオブジェクト、または、それを格納する変数を用意することで、Double-checked-lockingパターンも使用できる(使用する変数は自前で用意する必要があるので煩雑になってしまう)。
System.Threading下に宣言されているだけあって、スレッドセーフでない遅延初期化には対応していない。

使用できる型は参照型に限られ、値型には使用できない。

LazyInitilizerExample.cs
class LazyInitializerExample
{
    private LazyInitialized _lazyinitialized;
    private bool _initialized;
    private object _lock;

    private LazyInitialized Instance
    {
        get
        {
            LazyInitializer.EnsureInitialized
                (ref _lazyinitialized, () => new LazyInitialized());
            //LazyInitializer.EnsureInitialized
            //    (ref _lazyinitialized,
            //     ref _initialized,
            //     ref _lock, () => new LazyInitialized());
            // とすることで、Double-checked-lockingによる初期化になる
            return _lazyinitialized;
        }
    }
}

ふたつのクラスのつかいわけ

  • 値型の遅延初期化
  • スレッドセーフでない遅延初期化

を行うのなら、Lazy<T>を使用するしかない。また、

  • Race-to-initializeパターンを使うのなら、LazyInitialier
  • Double-checked-lockingパターンを使うのなら、Lazy<T>

を使用するとコード量を減らすことができる。そのため、Lazy<T>LazyInitializerのどちらを使用すべきか、というより、Race-to-initializeとDouble-checked-lockingのどちらを使用するのが適切かという判断がそのまま使用クラスになる。

Race-to-initializeの部分で説明したとおり、重い初期化処理が複数回実行される可能性のあるRace-to-initializeの使用機会より、Double-checked-lockingを使用したい機会が多い。

さらに、Lazy<T>LazyInitializerも、Race-to-initializeパターンの場合、フィールドに格納できなかったインスタンスがIDiposableインタフェースを実装したクラスだったとしても、Disposeしてくれない。となれば、この場合も、Double-checked-lockingを使用したくなる。

結果的に、Lazy<T>クラスの使用機会が多くなるはずだ。

Singletonパターン

GoFのデザインパターンに含まれるSingletonパターンを遅延初期化によって実現する方法について述べる。

静的コンストラクタ/静的フィールド初期化子

静的コンストラクタや静的フィールドの初期化子を使った方法。完全な遅延初期化とは言えないが、初期化タイミングが最初の静的フィールド/メンバ参照時になり、プログラムのロード時よりは確実に遅いので、遅延初期化と表現させていただく。

Singleton1.cs
sealed class Singleton1
{
    private Singleton1() { }
    private static Singleton1 _Instance;

    static Singleton1()
    {
        _Instance = new Singleton1();
    }
}

初期化タイミング

外からはインスタンスを作成できないSingletonパターンに話を限定すれば、最初に静的メンバを参照したときに初期化される。静的メンバがSingletonのインスタンスの取得しかないクラスにはこれで十分であり、他の方法を用いる必要はない。

スレッド安全性

静的コンストラクタについて、MSDNによれば、

https://msdn.microsoft.com/en-us/library/aa645612.aspx

The static constructor for a class executes at most once in a given application domain.

とあるので、最高で一回しか実行されないことが保障されている。
初期化処理が同時に複数のスレッドで実行される考慮をしなくてもよい。
(もちろん、初期化処理が、初期化処理以外と同時に実行される考慮は必要である)

Initialization-on-demand holderイディオム

ネストされたクラスの静的フィールドにインスタンスを保持させる方法。
インスタンス保持用のクラスを参照したときにはじめてその静的フィールドの初期化子が実行されるので、これでも遅延初期化を実現できる。
JavaでSingletonパターンを実現するのによく用いられる方法だが、C#でも使用できる。

Singloton2.cs
sealed class Singleton2
{
    private Singleton2() { }
    public static Singleton2 Instance
    {
        get { return SingletonHolder._Instance; }
    }

    private static class SingletonHolder
    {
        static SingletonHolder() { }
        internal static readonly Singleton2 _Instance = new Singleton2();
    }
}

スレッド安全性

前述のとおり、静的コンストラクタや静的フィールドの初期化子は一度しか実行されない。
初期化処理そのものが同時に複数のスレッドで実行される考慮は不要である。

欠点

Lazy<T>LazyInitializerを使用するよりもコードは単純になるが、コードを読むプログラマによっては、インスタンス保持用のクラスの存在理由がわからないだろう。うっかり、リファクタリングのつもりでネストされたクラスを削除して、中のインスタンス保持用のフィールドを移動されるかもしれない。このとき、遅延初期化の動作が完全に失わるわけではないが、それでも動作が変わってしまう。(そして、それは容易には判明しないだろう)

また、静的コンストラクタの方法に比べたら実装のコストが高い。

Lazy<T>/LazyInitializer

すでに説明したふたつのクラスは、当然、Singletonパターンを実現するためにも使用できる。

Singleton3.cs
sealed class Singleton3
{
    private Singleton3() { }
    private static Lazy<Singleton3> _Instance
        = new Lazy<Singleton3>(() => new Singleton3());

    private static Singleton3 Instance
    {
        get { return _Instance.Value; }
    }
}

ここでは、Lazy<T>を使用して、Double-checked-lockingパターンの手法で遅延初期化を実現している。
一般的に、静的フィールドの値の変更にはスレッドセーフな方法を選択すべきである。Singletonパターンの遅延初期化にも同様のことが言えるので、LazyThreadSafetyMode.Noneを指定しないこと。

結論

.NET4.0から、Lazy<T>LazyInitializerというふたつの遅延初期化を助けてくれるクラスが実装され、自分でスレッドセーフを意識した遅延初期化を実装する必要がなくなった。両者では、Lazy<T>の使用機会が多くなるはずだ。
内部で使用される3つのパターンを理解することで、マルチスレッド環境での遅延初期化の安全性を高めることができる。
また、Singletonパターンの実現にあたっては、これらのクラスを使う必要のない場面も数多い。必要な場合のみ使用すること。

課題

なぜ似たようなクラスがふたつも別々に実装されたのか、そして同時にリリースされたのか、結局わからなかった。
Race-to-initializeパターンのnullチェックにVolatile.ReadThread.VolatileReadを使用したほうが、初期化機会を減らすことができるのではないかと思うのだがどうだろうか。このあたりのメモリモデル関係がよくわからない。

参考情報

A lazy initialization primitive for .NET | Joe Duffy's Blog
http://joeduffyblog.com/2007/06/09/a-lazy-initialization-primitive-for-net/

C# in Depth: Implementing the Singleton Pattern
http://csharpindepth.com/Articles/General/Singleton.aspx

The need for volatile modifier in double checked locking in .NET
http://stackoverflow.com/questions/1964731/the-need-for-volatile-modifier-in-double-checked-locking-in-net

Lazy<T> クラス
https://msdn.microsoft.com/ja-jp/library/dd642331%28v=vs.110%29.aspx

LazyInitializer クラス
https://msdn.microsoft.com/ja-jp/library/system.threading.lazyinitializer%28v=vs.110%29.aspx

Lazy<T>LazyInitializerのソースコード

Effective Java 第2版