二つの static は同じものではない
完全なシングルトンクラスは通常次のように書かれます:
class Singleton {
public:
static Singleton& Instance() {
static Singleton instance;
return instance;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
Singleton() = default;
~Singleton() = default;
};
外側の static はクラスメンバー関数の宣言に現れ、Instance が静的メンバー関数であることを示しています。これは this ポインターを持たず、Singleton::Instance() で直接呼び出すことができます。
内側の static が修飾しているのは、関数本体の中にある局所変数 instance です。この変数は依然としてその関数本体内でのみ見えますが、記憶域期間(ストレージ期間)は「関数が呼ばれるたびに新しく作られる」という自動的記憶域期間ではなく、静的記憶域期間となります。つまり、プログラムの実行中ずっと同じオブジェクトが保持され続けます。初期化のタイミングは、関数が呼ばれるたびに新しく構築し直されるのではなく、制御の流れが初めてこの宣言を通過したときです。
したがって、ここでの「シングルトン」は、いくつかの条件が重なり合うことから生じています。
instanceという名前はInstance関数内に限定されているため、外部から直接同じ名前の別のオブジェクトを取得することはできません。staticにより、複数回の呼び出しにわたって同じオブジェクトが保持されます。- コンストラクタは private であり、外部で勝手に
Singleton sと書くことはできません。 - コピーコンストラクタとコピー代入演算子を削除し、コピーによって2つ目のオブジェクトが作られるのを防ぎます。
Singleton&を返すことで、同じオブジェクトの参照を呼び出し側に渡し、コピーは返しません。
最後の 2 つは C++11 の書き方です。C++98 には = delete も = default も存在しないため、コピーコンストラクタ、代入演算子、コンストラクタを private 領域で宣言するだけで定義しないという方法を取るしかありません。
T& とは実際には何をするのか
T& は右から左へ読むと「型が T であるものへの参照」となります。したがって:
static T& Instance()
Instance が返すのは T オブジェクトの参照であり、T オブジェクトそのものではないことを示します。
return instance;
ここで返されるのは、静的オブジェクトを指すローカル名 instance の値です。その生存期間はプログラム全体の実行期間をカバーしているため、関数の返後も参照は有効です。もしそれが通常のローカル変数であったなら、このように参照を返すとダングリング参照になってしまいます。
参照返しにはもう2つの直接的な効果があります。呼び出し側は元のオブジェクトを取得し、それを変更できます。同時に、余分なオブジェクトのコピーは発生しません。読み取りのみを許可したい場合は、インターフェースを const T& として記述することができますが、これによって T 内部の他の共有状態が自動的に並行処理に対して安全になるわけではありません。
初めての呼び出し時、コンパイラは実際に何を解決するのか
呼び出しプロセスを3つのシナリオに分けるのが最も分かりやすい。
最初の呼び出し時に、制御フローは次へ進みます。
static T instance;
オブジェクトがまだ初期化されていない場合は、T のデフォルトコンストラクタを呼び出します。構築が完了したら return instance を実行します。
再度呼び出された時点では、instance はすでに初期化済みのため、コンストラクタは再実行されず、コードは同じオブジェクトを直接返します。
2 つのスレッドが初めて同時に呼び出したときに問題が発生します。どちらもオブジェクトが「まだ初期化されていない」と同時に判定する可能性があります。追加の同期がなければ、二重に構築されたり、あるスレッドが半分だけ構築されたオブジェクトを読み取ったり、初期化フラグがオブジェクトの内容より先に別のスレッドから見えるといったことが起こり得ます。
実装では通常、ローカルの static のためにガード状態を1つ隠しておきます。下記はあくまで理解を助けるための擬似コードであり、標準で要求されている実際の ABI ではありません。
if (!guard_is_complete()) {
lock_guard_for_this_static();
if (!guard_is_complete()) {
construct(instance);
mark_guard_complete();
}
unlock_guard_for_this_static();
}
return instance;
要点は guard という名前の変数があるかどうかではなく、「チェック、構築、完了フラグの設定」という一連の処理全体が、実装側で標準準拠の同期機構によって保護されなければならないという点である。初期化が完了するまで、競合するスレッドは待機しなければならず、初期化が完了して初めて、それらのスレッドはそのオブジェクトを取得できる。
C++98 がスレッドセーフにできない理由
ここで誤解されやすい点があります。C++98 ではこのコードを書けなかったわけではありません。ローカル static の構文と「一度だけ初期化される」という順序意味論は、すでに C++98 の時点で存在していました。
違いは、C++98 がシングルスレッドの抽象機械における実行のみを記述しており、C++11 のように標準化されたマルチスレッド実行、データ競合、メモリの可視性に関する規則を持たない点である。2 つのスレッドが同時に宣言を初めて通過する際に、一方のスレッドが初期化を完了し、他のスレッドが待機しなければならないことを保証していない。
したがって、C++98 におけるこのコードについては次のように言うのが正確です。シングルスレッドでは遅延初期化シングルトンを実現できます。マルチスレッドでの安全性は、コンパイラ、ランタイムライブラリ、プラットフォームの実装に依存し、C++98 標準自体から保証を導くことはできません。
次のような C++98 版は、特に「ポインターの再割り当てを判定する」という理由だけで安全にするべきではありません。
static T* instance = 0;
if (instance == 0) {
instance = new T;
}
return *instance;
2 つのスレッドが同時に instance == 0 を通過し、それぞれが T を 2 つ生成する可能性があります。「先にチェック、ロックし、ロック内で再チェック」というダブルチェック構造を追加したとしても、C++98 には、ポインタを公開する際に、別スレッドが常に正しい順序で完全なオブジェクトを確実に参照できることを保証する十分なメモリモデルがありません。volatile はスレッド同期ツールではなく、この問題を修正することもできません。
C++98 プロジェクトでこの保証が本当に必要になる場合、たとえば POSIX の one-time initialization、Windows の同期プリミティブ、当時利用可能だったスレッドライブラリなど、標準外のプラットフォームやライブラリに同期処理を委ねるしかなかった。または、すべてのアクセスを 1 つのミューテックス経由にするしかなかった。これは「外部の同期で保証を補う」ことであって、C++98 のこの static 行自体が保証を提供しているわけではない。
C++11 が変えるのはセマンティクスであり、この行の構文ではない
C++11 の重要な変化は、以下のものを発明したことではありません:
static T instance;
この声明はずっと前から書けた。C++11 は並行初期化ルールを正式に言語標準に組み込んだ。関数ローカルで静的記憶期間を持つ変数は、制御フローが初めて宣言を通過する際に動的初期化が行われる。複数のスレッドが並行して進入し、変数が初期化中の場合、他の実行はその初期化の完了を待つべきである。
これがコメントにある「thread-safe initialization」の正確な意味です。保証されるのは初期化段階で、T のコンストラクタが一度だけ成功して実行され、競合するスレッドが未完成の構築中のオブジェクトをまたいで使用することはないということです。
下記の業務コードが本質的に安全であることを保証するものではありません。
Singleton::Instance().append(data); // 複数スレッドが同じオブジェクトを変更する場合、データ競合が発生する可能性がある
append が共有コンテナを変更する場合でも、T 内部でミューテックスやアトミック変数などの適切な並行設計を引き続き使用する必要があります。シングルトンは「オブジェクトがいつ、どのように一度だけ構築されるか」という問題を解決するだけであり、「オブジェクト内部の各操作が並行してどのように実行されるか」という問題を解決するものではありません。
C++11 にはまだいくつかの境界がある
まず、static T instance; は T がデフォルト構築可能であり、コンストラクタとデストラクタがここでアクセス可能である必要があります。コンストラクタが例外を投げた場合、その初期化は完了したとは見なされません。次回の制御フローが宣言に到達したとき、半完成状態のままではなく、初期化が再試行されます。
第二に、初期化関数は同じローカル static の初期化プロセスに再帰的に再入することはできない。例えば T のコンストラクタが Instance() を再度呼び出した場合、規格で明確に禁止されている再帰的な初期化シナリオに入ることになり、それを通常のリエントラントとして扱ってはならない。
第三に、もし instance が実際に構築されれば、プログラムの終了段階でデストラクタが実行される。この特性は、通常 new を手書きで書いて意図的にリークするより健全だが、他のグローバルオブジェクトのデストラクト順序に依存する可能性もある。シングルトンのコンストラクタでは、別のファイルにまたがるグローバル状態にこっそり依存しないほうがよい。
最後に、シングルトンの「グローバルに一意」というのは、主に言語レベルでの一つのオブジェクトを指します。動的ライブラリ、プラグイン、または異なるランタイム境界をまたぐ場合、本当にプロセス全体で唯一かどうかは、リンクや読み込みの境界に依存します。クラス名だけで、すべてのモジュールが同じインスタンスを共有していると決めつけることはできません。
最後に:この区別を覚えておきましょう
C++98 と C++11 のどちらでもローカル static シングルトンを記述することができますが、「一度だけ初期化され、他のスレッドはそれを待機する」という並行初期化の保証が標準化されるのは C++11 からです。
したがって、このコードを目にしたときは、3つの層でチェックするのが最善である。static によってオブジェクトの寿命が長くなり、関数スコープによって入り口が狭くなり、C++11の言語規則によって、移植性を保証された上での初めての並行初期化が可能になる。オブジェクトの構築が完了した後、その内部のデータを複数のスレッドが同時に変更できるかどうかについては、それは別の問題である。
参考資料
- C++11 作業草案 N3337:6.7 宣言文
- 現行 C++ 作業草案:[stmt.dcl]
- WG21 N1875:C++ Threads
- Microsoft:/Zc:threadSafeInit(スレッドセーフなローカル static 初期化)
写作附记
オリジナルプロンプト
$blog-writer C++ シングルトンについて、詳しく解説してほしい。この原理、および構文レベルでどのようにサポートされているのか、
static T &Instance() { // C++11 guarantees thread-safe initialization of function-local static objects. static T instance; return instance; }について、なぜ C98 ではこのようにすることができないのか、なぜ C11 ではこのようにすることができるのかを説明してほしい。
この位置までスクロールするとコメントを読み込みます。