初期化が初めて必要になったときに初期化を行う。
データ構造を遅れて初期化することは一般的なテクニックです。使用されていないデータ構造を初期化するコストを回避できるだけでなく、多くの場合、プログラムを構築するための便利な方法となります。
複数のスレッドがオブジェクトへのアクセスを共有します。
オブジェクトは最初にアクセスされるまで作成すべきではありません。
後者には次の理由が含まれます。
オブジェクトの作成にはコストがかかるため、早く作成するとプログラムのスタートアップが遅くなります。
作成したオブジェクトがプログラムのすべての実行で使用されるとは限りません。
早期の初期化は、読みやすさや構造上の理由から好ましくないコードを追加することになります。
複数の一貫性問題に対処する必要があるため、並列のソリューションには注意が必要です。
競合: 2 つのスレッドが同時に初めてオブジェクトにアクセスし、オブジェクトの作成が行われる場合、両方のスレッドが T 型の同じオブジェクトへの参照を行うように競合を解決する必要があります。
メモリーリーク: 競合が発生した場合、一時的な T オブジェクトがすべてクリーンアップされることを保証しなければなりません。
メモリーの一貫性: スレッド X が value=new T() を実行する場合、ほかのすべてのスレッドから見て、割り当て value= の前に new T() によるストアが行われていなければなりません。
デッドロック: もし T() のコンストラクターがロックを取得することを要求し、ロックの現在の保持者も初めてそのオブジェクトにアクセスしようとしている場合はどうすれば良いでしょうか。
2 つのソリューションがあります。ダブルチェック・ロッキング (double-check locking) に基づくソリューションと、コンペアー・アンド・スワップ (compare-and-swap) に依存するソリューションです。トレードオフと問題点については次のサンプルセクションで説明します。
インテル® スレッディング・ビルディング・ブロック (インテル® TBB) での「ダブルチェック」パターンの実装は次のようになります。
template<typename T, typename Mutex=tbb::mutex> class lazy { tbb::atomic<T*> value; Mutex mut; public: lazy() : value() {} // 値を NULL に初期化 ~lazy() {delete value;} T& get() { if( !value ) { // 値の読み取りは acquire (取得) セマンティクス Mutex::scoped_lock lock(mut); if( !value ) value = new T();. // 値の書き込みは release (解放) セマンティクス } return *value; } };
「ダブルチェック」とはパターンが競合を扱う方法に由来します。ロックなしとロックの後に行われるチェックがあります。最初のチェックは、ロックなしで初期化がすでに行われている一般的なケースを扱います。2 つ目のチェックは、2 つのスレッドがどちらも初期化されていない値を参照していて、両方ともロックを取得しようとしているケースを扱います。この場合、ロックを取得する 2 番目のスレッドは、初期化が行われたことを認識します。
T() が例外をスローした場合、value は NULL のままでオブジェクト lock が破棄されるときに mutex がロック解除されるため、ソリューションは正しくなります。
このソリューションはメモリーの一貫性問題を正しく扱っています。値 tbb::atomic への書き込みには release (解放) セマンティクスが用いられ、書き込みを解放する前にその前の書き込みがすべて行われます。値 tbb::atomic からの読み取りには acquire (取得) セマンティクスが用いられ、読み取りを取得した後にその後の読み取りがすべて行われます。これらの振る舞いはどちらもソリューションにとって重要です。書き込みの解放は、T() の構築が値の割り当て前に行われることを保証します。読み取りの取得は、呼び出し元が *value から読み取る場合、"if(!value)" を確認した後に読み取りが行われることを保証します。解放/取得は本質的にフェンス付きデータ転送パターンです。「メッセージ」は完全に構築されたインスタンス T() で、「準備」フラグはポインター value です。
このソリューションでは、初期化を行っている間スレッドをブロックする必要があります。このため、ブロックに関連する問題が発生することがあります。例えば、最初にロックを取得するスレッドが OS によって休止状態にある場合、ほかのすべてのスレッドはそのスレッドが再開するまで待つ必要があります。ロックフリー・バリエーションでは、競合するすべてのスレッドで初期化を試み、どのスレッドによる試みが成功するかアトミックに決定することで、この問題を回避しています。
インテル® TBB での非ブロック・バリエーションの実装は次のようになります。ロックなしでダブルチェックも行います。
template<typename T> class lazy { tbb::atomic<T*> value; public: lazy() : value() {} // 値を NULL に初期化 ~lazy() {delete value;} T& get() { if( !value ) { T* tmp = new T(); if( value.compare_and_swap(tmp,NULL)!=NULL ) // 別のスレッドにより値が設定されたため自分の値は破棄 delete tmp; } return *value; } };
2 つめの確認は式 value.compare_and_swap(tmp,NULL)!=NULL によって行われます。この式は、value==NULL の場合 value=tmp を条件付きで割り当て、以前の value が NULL だった場合 true を返します。このため、複数のスレッドが同時に初期化を試みた場合、compare_and_swap を実行する最初のスレッドは value を T オブジェクトのポインターに設定します。compare_and_swap を実行するほかのスレッドは、NULL 以外のポインターが返されることで、一時的な T オブジェクトを削除すべきことが分かります。
ロックによるソリューションのように、メモリーの一貫性問題は tbb::atomic のセマンティクスによって扱われます。最初の確認には acquire セマンティクスが、compare_and_swap には acquire と release の両方のセマンティクスが用いられます。
Lawrence Crowl、"Dynamic Initialization and Destruction with Concurrency"、http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2008/n2660.htm