ソフトウェア工学において、ダブルチェックロック(「ダブルチェックロック最適化」 [1]とも呼ばれる)は、ロックを取得する前にロック基準(「ロックヒント」)をテストすることで、ロック取得のオーバーヘッドを削減するために使用されるソフトウェア設計パターンです。ロックは、ロック基準チェックでロックが必要であることが示された場合にのみ発生します。
このパターンの元の形式は、Pattern Languages of Program Design 3 [2]に登場し、使用するメモリモデルによってはデータ競合が発生し、正しく実行するのが困難です。これをアンチパターンと考える人もいます[3]。このパターンには、 Javaでのキーワードの使用やC++での明示的なメモリバリアの使用など、有効な形式があります[4] 。volatile
このパターンは、通常、マルチスレッド環境で「遅延初期化」を実装する場合、特にシングルトン パターンの一部として、ロックのオーバーヘッドを削減するために使用されます。遅延初期化では、値が最初にアクセスされるまで初期化されません。
動機とオリジナルパターン
例えば、Javaプログラミング言語の次のコードセグメントを考えてみましょう: [4]
// シングルスレッドバージョン
class Foo { private static Helper helper ; public Helper getHelper () { if ( helper == null ) { helper = new Helper (); } return helper ; }
// その他の関数とメンバー...
}
問題は、複数のスレッドを使用する場合、これが機能しないことです。2つのスレッドが同時に呼び出す場合に備えて、ロックを取得する必要がありますgetHelper()。そうしないと、両方のスレッドが同時にオブジェクトを作成しようとするか、または 1 つのスレッドが不完全に初期化されたオブジェクトへの参照を取得することになります。
次の例に示すように、ロックと同期するとこれを修正できます。
// 正しいがおそらくコストがかかるマルチスレッドバージョン
class Foo { private Helper helper ; public synchronized Helper getHelper () { if ( helper == null ) { helper = new Helper (); } return helper ; }
// その他の関数とメンバー...
}
これは正しく、十分なパフォーマンスが得られる可能性が高いです。ただし、 の最初の呼び出しでgetHelper()オブジェクトが作成され、その間にアクセスしようとする少数のスレッドのみが同期される必要があります。その後のすべての呼び出しは、メンバー変数への参照を取得するだけです。メソッドを同期すると、極端な場合にはパフォーマンスが 100 倍以上低下する可能性があるため、[5]このメソッドが呼び出されるたびにロックを取得して解放するオーバーヘッドは不要と思われます。初期化が完了すると、ロックを取得して解放する必要がないように見えます。ダブルチェック ロック デザイン パターンの作成者を含む多くのプログラマーは、次の方法でこの状況を最適化しようとしました。
- 変数が初期化されているかどうかを確認します (ロックを取得せずに)。初期化されている場合は、すぐに返します。
- ロックを取得します。
- 変数がすでに初期化されているかどうかを再確認します。別のスレッドが最初にロックを取得した場合、すでに初期化が完了している可能性があります。その場合は、初期化された変数を返します。
- それ以外の場合は、変数を初期化して返します。
// 壊れたマルチスレッドバージョン
// オリジナルの「Double-Checked Locking」イディオム
class Foo { private Helper helper ; public Helper getHelper () { if ( helper == null ) { synchronized ( this ) { if ( helper == null ) { helper = new Helper (); } } } return helper ; }
// その他の関数とメンバー...
}
直感的に、このアルゴリズムは問題に対する効率的な解決策です。しかし、パターンが注意深く記述されていないと、データ競合が発生します。たとえば、次の一連のイベントを考えてみましょう。
- スレッドA は値が初期化されていないことに気づき、ロックを取得して値の初期化を開始します。
- 一部のプログラミング言語のセマンティクスにより、コンパイラによって生成されたコードは、A が初期化を完了する前に、共有変数を更新して部分的に構築されたオブジェクトを指すようにすることができます。たとえば、Java では、コンストラクタの呼び出しがインライン化されている場合、ストレージが割り当てられるとすぐに共有変数が更新されますが、インライン化されたコンストラクタがオブジェクトを初期化する前に更新される可能性があります。[6]
- スレッドB は、共有変数が初期化された (またはそう見える) ことに気付き、その値を返します。スレッドB は値がすでに初期化されていると認識しているため、ロックを取得しません。A によって行われたすべての初期化が B に認識される前に B がオブジェクトを使用すると( Aが初期化を完了していないか、オブジェクト内の初期化された値の一部がB が使用するメモリにまだパーコレーションされていない(キャッシュ一貫性) ため)、プログラムはクラッシュする可能性があります。
ほとんどのランタイムには、実行ユニット間でメモリの可視性を管理するためのメモリ バリアまたはその他の方法があります。この領域での言語の動作を詳細に理解しないと、アルゴリズムを正しく実装することが困難です。ダブルチェック ロックを使用する際の危険性の 1 つは、単純な実装でもほとんどの場合は機能するように見えることです。つまり、この手法の正しい実装と微妙な問題がある実装を区別するのは簡単ではありません。コンパイラ、スケジューラによるスレッドのインターリーブ、その他の同時システム アクティビティの性質によっては、ダブルチェック ロックの誤った実装によって発生する障害が断続的にしか発生しない場合があります。障害を再現することは困難な場合があります。
C++11での使用法
シングルトン パターンの場合、二重チェック ロックは必要ありません。
変数の初期化中に制御が宣言に同時に入力された場合、同時実行は初期化の完了を待機する必要があります。
— § 6.7 [stmt.dcl] p4
シングルトン& GetInstance () {静的シングルトンs ; return s ; }
std::once_flagC++11 以降では、およびの形式で組み込みの二重チェック ロック パターンも提供されますstd::call_once。
#include <mutex> #include <optional> // C++17以降
// Singleton.h
クラスSingleton { public : static Singleton * GetInstance (); private : Singleton () = default ;
static std ::オプション<シングルトン> s_instance ; static std :: once_flag s_flag ; };
// Singleton.cpp
std ::オプション< Singleton > Singleton :: s_instance ; std :: once_flag Singleton :: s_flag {};
シングルトン* Singleton::GetInstance () { std :: call_once ( Singleton :: s_flag , []() { s_instance . emplace ( Singleton {}); }); return &* s_instance ; }
上記の簡単に動作する例の代わりに、本当にダブルチェックされたイディオムを使用したい場合(例えば、2015リリース以前のVisual Studioは、上で引用した並行初期化に関するC++11標準の言語を実装していなかったため[7])、取得フェンスと解放フェンスを使用する必要があります。[8]
#include <アトミック> #include <ミューテックス>
クラスシングルトン{ public : static Singleton * GetInstance ();
プライベート:
シングルトン() =デフォルト;
static std :: atomic < Singleton *> s_instance ; static std :: mutex s_mutex ; };
シングルトン* Singleton::GetInstance () {シングルトン* p = s_instance . load ( std :: memory_order_acquire ); if ( p == nullptr ) { // 1 回目のチェックstd :: lock_guard < std :: mutex > lock ( s_mutex ); p = s_instance . load ( std :: memory_order_relaxed ); if ( p == nullptr ) { // 2 回目 (ダブル) のチェックp = new Singleton (); s_instance . store ( p , std :: memory_order_release ); } } return p ; }
POSIXでの使用法
pthread_once()
ライブラリ (またはサブモジュール) コードの API に、シングルスレッド モードで呼び出す必要のある専用の初期化プロシージャがない場合、ライブラリ (またはサブモジュール) コードを初期化するために使用する必要があります。
Goでの使用法
パッケージメイン
「同期」をインポートする
var arrOnce sync . Once var arr [] int
// getArr は arr を取得し、最初の呼び出しで遅延初期化します。二重チェックされた
// ロックは sync.Once ライブラリ関数で実装されます。Do
() の呼び出しに勝った最初の goroutine が配列を初期化し、
他の goroutine は Do() が完了するまでブロックします。Do の実行後、
配列を取得するには 1 回のアトミック比較のみが必要です。func
getArr () [] int { arrOnce . Do ( func () { arr = [] int { 0 , 1 , 2 } }) return arr }
func main () { // 二重チェックロックのおかげで、2 つの goroutine が getArr() を試行しても二重初期化は発生しませんgo getArr () go getArr () }
Javaでの使用法
J2SE 5.0では、メモリバリアを作成するためにvolatileキーワードが定義されています。これにより、複数のスレッドがシングルトンインスタンスを正しく処理することを保証するソリューションが可能になります。この新しいイディオムは [3] と [4] で説明されています。
// Java 1.5 以降では volatile の acquire/release セマンティクスで動作します
// Java 1.4 以前では volatile のセマンティクスが機能しません
class Foo { private volatile Helper helper ; public Helper getHelper () { Helper localRef = helper ; if ( localRef == null ) { synchronized ( this ) { localRef = helper ; if ( localRef == null ) { helper = localRef = new Helper (); } } } return localRef ; }
// その他の関数とメンバー...
}
ローカル変数「localRef」は不要と思われることに注意してください。これにより、ヘルパーがすでに初期化されている場合(つまり、ほとんどの場合)、揮発性フィールドは1回だけアクセスされます(「 return helper ; 」ではなく「 return localRef ; 」のため)。これにより、メソッドの全体的なパフォーマンスが最大40パーセント向上します。[9]
Java 9ではVarHandleクラスが導入され、フィールドへのアクセスに緩和されたアトミックの使用が可能になり、メモリモデルが弱いマシンでの読み取りがいくらか高速化されましたが、その代償として、より複雑なメカニズムと順次一貫性の喪失(フィールドアクセスは同期順序、つまり揮発性フィールドへのアクセスのグローバル順序に参加しなくなりました)が発生しました。[10]
// Java 9 で導入された VarHandles の取得/解放セマンティクスで動作します
class Foo { private volatile Helper helper ;
パブリックHelper getHelper () { Helper localRef = getHelperAcquire (); if ( localRef == null ) { synchronized ( this ) { localRef = getHelperAcquire (); if ( localRef == null ) { localRef = new Helper (); setHelperRelease ( localRef ); } } } return localRef ; }
プライベート静的最終VarHandle HELPER ;プライベートHelper getHelperAcquire ( ) { return ( Helper ) HELPER.getAcquire ( this ) ; }プライベートvoid setHelperRelease ( Helper value ) { HELPER.setRelease ( this , value ) ; }
static { try { MethodHandles.Lookup lookup = MethodHandles.lookup ( ) ; HELPER = lookup.findVarHandle ( Foo.class , " helper " , Helper.class ) ; } catch ( ReflectiveOperationException e ) { throw new ExceptionInInitializerError ( e ) ; } }
// その他の関数とメンバー...
}
ヘルパーオブジェクトが静的(クラスローダーごとに1つ)である場合、代替手段としてオンデマンド初期化ホルダーイディオム[11]が挙げられます(前述のテキストの リスト16.6 [12]を参照)。
// Java の遅延初期化を修正する
class Foo { private static class HelperHolder { public static final Helper helper = new Helper (); }
パブリック静的ヘルパーgetHelper () { return HelperHolder . helper ; } }
これは、ネストされたクラスは参照されるまでロードされないという事実に依存しています。
Java 5のfinalフィールドのセマンティクスは、volatileを使用せずにヘルパーオブジェクトを安全に公開するために使用できます。[13]
パブリッククラスFinalWrapper < T > {パブリックfinal T値;パブリックFinalWrapper ( T値) { this . value = value ; } }
パブリッククラスFoo {プライベートFinalWrapper <Helper> helperWrapper ;
パブリックHelper getHelper () { FinalWrapper < Helper > tempWrapper = helperWrapper ;
if ( tempWrapper == null ) { synchronized ( this ) { if ( helperWrapper == null ) { helperWrapper = new FinalWrapper < Helper > ( new Helper ()); } tempWrapper = helperWrapper ; } } return tempWrapper . value ; } }
正確性を保つためにはローカル変数tempWrapperが必要です。nullチェックとreturn文の両方にhelperWrapperを使用するだけでは、Javaメモリモデルで許可されている読み取り順序の変更により失敗する可能性があります。[14]この実装のパフォーマンスは、必ずしも揮発性実装よりも優れているわけではありません。
C# での使用法
.NET Framework 4.0では、Lazy<T>クラスが導入されました。このクラスは、デフォルトで内部的に二重チェックロックを使用し(ExecutionAndPublicationモード)、構築中にスローされた例外、または渡された関数の結果のいずれかを保存しますLazy<T>。[15]
パブリッククラスMySingleton {プライベート静的読み取り専用Lazy < MySingleton > _mySingleton = new Lazy < MySingleton > (() => new MySingleton ());
プライベートMySingleton () { }
パブリック静的MySingletonインスタンス=> _mySingleton .値; }
参照
- 低レベルのロック メカニズムのためのテストおよびテスト アンド セットの 慣用句。
- Java でのスレッドセーフな置き換えのためのオンデマンド初期化ホルダーイディオム。
参考文献
- ^ Schmidt, D 他「パターン指向ソフトウェアアーキテクチャ Vol 2」2000 年 pp353-363
- ^ プログラム設計のパターン言語。3 (PDF) (Nachdr. ed.)。マサチューセッツ州レディング: Addison-Wesley。1998年。ISBN 978-0201310115。
- ^ グレゴワール、マーク(2021年2月24日)。プロフェッショナルC++。ジョン・ワイリー・アンド・サンズ。ISBN 978-1-119-69545-5。
- ^ ab David Bacon 他「二重チェックロックが壊れている」宣言。
- ^ Boehm, Hans-J (2005 年 6 月). 「スレッドはライブラリとして実装できない」(PDF) . ACM SIGPLAN Notices . 40 (6): 261–268. doi :10.1145/1064978.1065042. 2017 年 5 月 30 日時点のオリジナル(PDF)からアーカイブ。2014年 8 月 12 日閲覧。
- ^ Haggar, Peter (2002 年 5 月 1 日). 「Double-checked locking and the Singleton pattern」. IBM. 2017 年 10 月 27 日時点のオリジナルよりアーカイブ。2022年 5 月 19 日閲覧。
- ^ 「C++11-14-17 機能のサポート (Modern C++)」。
- ^ ダブルチェックロックは C++11 で修正されました
- ^ ブロック、ジョシュア (2018)。Effective Java (第3版)。Addison-Wesley。p. 335。ISBN 978-0-13-468599-1私のマシンでは、
上記の方法は、ローカル変数を使用しない明白なバージョンよりも約 1.4 倍高速です。
- ^ 「第17章 スレッドとロック」。docs.oracle.com 。 2018年7月28日閲覧。
- ^ Brian Goetz 他著「Java Concurrency in Practice」、2006 年、348 ページ
- ^ Goetz, Brian; et al. 「Java Concurrency in Practice – listings on website」 。 2014 年10 月 21 日閲覧。
- ^ [1] Javamemorymodel-discussionメーリングリスト
- ^ [2] Manson, Jeremy (2008-12-14). 「パフォーマンス向上のための日付競合遅延初期化 - Java 並行性 (&c)」2016 年12 月 3 日閲覧。
- ^ Albahari, Joseph (2010)。「C# でのスレッド化: スレッドの使用」。C # 4.0 in a Nutshell。O'Reilly Media。ISBN 978-0-596-80095-6
実際には […] ダブルチェック ロックを実装しています。ダブルチェック ロックは、オブジェクトがすでに初期化されている場合にロックを取得するコストを回避するために、追加の揮発性読み取りを実行します
。Lazy<T>
外部リンク
- Jeu George のブログで取り上げられた二重チェックのロック機構に関する問題
- ポートランドパターンリポジトリからの「ダブルチェックロック」の説明
- 「二重チェックロックが壊れている」ポートランドパターンリポジトリからの説明
- 論文「C++ と二重チェックロックの危険性」(475 KB)、Scott MeyersとAndrei Alexandrescu著
- 記事「二重チェックロック:賢いが、破綻している」by Brian Goetz
- Allen Holubによる記事「警告! マルチプロセッサの世界におけるスレッド化」
- ダブルチェックロックとシングルトンパターン
- シングルトンパターンとスレッドセーフティ
- VC++ 2005 の volatile キーワード
- Java の例とダブルチェックロックソリューションのタイミング
- 「Google の Joshua Bloch による、より効果的な Java」。
