C# での Singleton についてまとめ

type initializer を使った Singleton パターンの実装のスレッドセーフ性について訊かれたことがあったので、それについて調べたことをメモってみた・・・らいつの間にかまとめっぽくなってしまったのであった。

履歴

  • 09/10/18: 誤字修正(beforefirstinit -> beforefieldinit)
  • 11/01/20: コード例の誤り修正、細かい表現の修正

基本

C# で singleton を実現する場合、基本的には以下のようにするのが、よく知られたイディオムである。

sealed class Singleton{
	public static readonly Singleton Instance = new Singleton();
	private Singleton(){ /* ... */ }
}

ただし、このコードには、以下の注意点がある。

  1. Singleton の static フィールドへの初回アクセス以前の「任意の時点」で Instance が初期化される
  2. side-effect などについてスレッドセーフ性が保証されない
  3. シングルトンの初期化が循環依存する場合に注意する必要
  4. Instance 取得時の処理のカスタマイズ性の問題
  5. シリアライズ時にインスタンスが増えてしまう問題

それぞれの注意点について詳細を以下に述べ、最後にガイドライン的なものを示す。

フィールドへの初回アクセス以前の「任意の時点」で Instance が初期化される

これについてはネット上の既存の多くの記事でも言われているが、先述のコードでは Instance の初期化(Instance = new Singleton())は Singleton の static フィールドへの初回アクセス「かそれ以前の任意のタイミング」で行われる。

なぜなら・・・

この挙動が望ましくないのなら、以下のように static コンストラクタを定義する手がある。

sealed class Singleton{
	public static readonly Singleton Instance = new Singleton();
	static Singleton(){} // suppress beforefieldinit
	private Singleton(){ /* ... */ }
}

このようにすることで、type initializer に beforefieldinit 属性が付加されなくなる。それによって、Instance の初期化は、Singleton.Instance に初めてアクセスした瞬間に行われることが保証される。

なお、ネット上の記事などでは inner class を用いることで初期化タイミングを保証するといった記述もあるが、それらは CLI 仕様ではなく実装依存な挙動であり、実行環境に依存するので注意が必要である。
ただし、ここで述べている static コンストラクタを書くという方法も、C# コンパイラの(beforefieldinit 属性についての)実装依存であるので、コンパイラに依存しないコードを書く場合には注意が必要である。

side-effect などについてスレッドセーフ性が保証されない

beforefieldinit 属性が付加されることによるもう一つの注意点として、スレッドセーフ性についての保証が破られうるという問題がある。

beforefieldinit 属性がついていないのなら、

  1. 確実に一回だけ、type initializer が呼ばれる
  2. type initializer が完了するまで、他のスレッドが Singleton.Instace にアクセスしたとしても待たされる

ということが保証されるが、beforefieldinit 属性がついていると、CLI 仕様ではこれらが保証されない。これは、type initializer のパフォーマンスを稼ぐための beforefieldinit の(CLI)仕様によるものである(単純に side-effect なしで static フィールドを初期化するためだけの type initializer のパフォーマンスを稼ぐため)。詳しくは Type initializer についてまとめ (同一 blog) なども参照のこと。

そのため、マルチスレッドに Singleton.Instance にアクセスされうる場合には、beforefieldinit を付けさせないようにする(→ static コンストラクタを書いておく)必要があるだろう。

シングルトンの初期化が循環依存する場合に注意する必要

以下のように二種類(かそれ以上)の Singleton なクラスがあり、それらの初期化処理が相互依存している場合には、ある Singleton 初期化処理中にもう片方の Singleton の参照が得られない(null になる)ことがあり得るので注意が必要である。

sealed class SingletonA{
	public static readonly SingletonA Instance = new SingletonA();
	private SingletonA(){
		SingletonB.Instance ...
	}
}
sealed class SingletonB{
	public static readonly SingletonB Instance = new SingletonB();
	private SingletonB(){
		SingletonA.Instance ...	
	}
}

この場合、Singleton{A, B} の type initializer から間接的に呼ばれている Singleton{B, A} の type initializer が実行完了しているかどうかが保証されないため、上のコードで Singleton{A, B}.Instance にアクセスした結果が null になりうる。

Instance 取得時の処理のカスタマイズ性の問題

先述のコードの場合、Instance フィールドが露出しているため、後からプロパティに差し替えた場合、依存しているコードの再コンパイルが必要になりうる。
そのため、この Singleton のコードに依存するアセンブリがあり、かつそれらが Singleton のコードとは別にコンパイルやリリースされるのであれば、以下のようにプロパティでラップしておくべきだろう。

sealed class Singleton{
	private static readonly Singleton _instance = new Singleton();
	public static Singleton Instance{ get{ return _instance; } }
	private Singleton(){ /* ... */ }
}

シリアライズ時にインスタンスが増えてしまう問題

シリアライズ可能なオブジェクトから Singleton が参照されていたり、Singleton それ自体をシリアライズ可能にする場合、[Serializable] 属性を付けることになる。
しかし、単に [Serializable] 属性を付けただけの場合、Singleton がデシリアライズされる際に Singleton の新しいインスタンスが作られてしまうという問題がある。

これを防ぐためには、ISerializable インタフェースを実装した上で、SerializationInfo.SetType や IObjectReferece を用いることで対処することが出来る。
具体的なコードは、ISerializable Interface に詳しいのでそれを参照のこと。

ガイドライン

上記を踏まえると、C# で Singleton を実現する場合、エントリ先頭で示した基本となるコードに加えて、以下のようにした方が良いのだろう。

  • 以下の場合には、static コンストラクタを必ず書く (beforefieldinit 属性を付けさせない)
    • スレッドセーフな Singleton にする必要がある場合
    • Singleton の初期化タイミングを、確実に Instance アクセス時にしたい場合
    • ただし、コンパイラ非依存なコードにしたい場合には、static readonly ではなく lock などを用いた実装にする
  • Singleton の初期化処理が相互に依存しないように気をつける
    • それが難しいのなら、初期化処理にて他の Singleton の参照が null になることを考慮したコードを書く
    • beforefieldinit 属性を付けさせないことによってタイミングを保証させるのも一つの手である
  • 別のコンパイル/リリース単位から参照される場合、プロパティでラップしておく
  • Singleton を [Serializable] にする場合、単純にシリアライズさせないようにする

Type initializer についてまとめ

参考資料

  • ECMA-335 (4th edition)

Type initializer とは

Type initializer は CLI 仕様で規定されている、型それ自体を初期化するための特別なメソッドである*1

要するに(C# で言うところの) static メンバの初期化子や static コンストラクタを実行してくれるアレである。

CLI 仕様書(ECMA-335)の 10.5.3 (Type initializer) 以下では、下に述べる事柄が述べられている。

Type initializer の満たすべき条件

Type initializer には以下の制約が与えられている。これらは、普通の C# コンパイラを使ってコード生成する限りは気にする必要はないだろう。

  • static である
  • パラメタを持たない
  • 戻り値を持たない
  • rtspecialname, specialname を持ち、".cctor" という名前である

Type initializer で出来ること

Type initializer では、普通のメソッドとして出来ること以外に以下の事ができる。

  • initonly 属性を持つ static フィールドに書き込める

Type initializer の実行について、CLI によって保証されること

Type initializer の実行については、以下の点が守られることが、CLI 仕様によって保証されている(し、CLR*2 や mono はそれを守っている)。

共通して言えること

  • type initializer の実行タイミングについて
    • "all-zero" な値型や、null な参照型のメンバアクセスについてのみは、無視されうる
      • つまり、null なインスタンスのメンバにアクセスした場合などは、事前に type initializer が実行されるとは限らない
  • static field はいかなるアクセスよりも前に、known state に初期化されている
    • "all-zero" 値や null 値などであるということ

beforefieldinit 属性がないなら

  • 以下のうちいずれかの時に type initializer が実行される
    • その型の static field への初回アクセス時 または
    • その型の static method の初回の呼び出し時 または
    • その型のコンストラクタの初回の呼び出し時
  • 確実に一回だけ*3、与えられた型について実行される
    • ただし、ユーザーによって明示的に呼び出さた場合はこれは保証されない。
  • Type initializer の実行が完了していない型のフィールドには、type initializer によって呼ばれたメソッド以外はアクセスできない

beforefieldinit 属性でマークされているなら

  • 以下の時に type initializer が実行される
    • その型の static field への初回アクセス時 か それ以前

なお、beforefieldinit 属性がある場合、beforefieldinit ない場合で述べた事柄は保証されなくて良い。特に、最後の保証(type initializer の完了を待機する)が保証されないことがありうる。(ECMA-335 の 10.5.3.2 の Rationale 曰く、最後の保証は、複数 AppDomain 環境ではコストが高くつくうえ、滅多に必要がない*4ため、とのこと。)

また、この beforefieldinit の挙動は、初期化コードが特に有意な side-effect を持たない時のために用意されているそうだ(ECMA-335 8.9.5 の Note より)。

C# コンパイラの生成するコード

なお、C# コンパイラは、以下の処理を順番にひっつけて、Type initializer を生成する。

  1. static メンバの初期化処理
  2. static コンストラクタの中身

また、static コンストラクタが無い場合には、type initializer に beforefieldinit 属性を付加する。

*1:CLI 仕様(ECMA-335) 10.5.3 より、"a special method called a type initializer, which is used to initialize the type itself."

*2:.net framework で使われている CLI 実装

*3:executed exactly once

*4:多くのコードでは、type initializer は単にフィールドを初期化するためだけのものであることが理由だそうだ。