質問への回答。
質問の歴史。
Java 5で導入されたReentrantReadWriteLockは、単一のミューテックスよりも大幅な同時実行性の向上を提供し、複数の同時読み取り者を許可しました。しかし、その設計は明示的にロックアップグレードを禁止しています。つまり、読み取りロックを保持しながら書き込みロックを取得することはできず、実装はスレッドごとの読み取り保持カウントを追跡します。読み取りロックを保持するスレッドが書き込みロックを取得しようとすると、自らデッドロックに陥ります:書き込みロックは排他制御を要求し、読み取りロック(スレッド自身のロックを含む)が保持されていると、付与されることはありません。Java 8で非再入可能な代替として導入されたStampedLockは、読み取り段階でロックを保持せずに行う楽観的な読み取りスタンプを通じてこの制限に対処しました。
問題。
根本的な危険は、ロック取得の意味論の非対称性から生じます。ReentrantReadWriteLockでは、アップグレードには読み取りロックを解放してから書き込みロックを取得する必要があるため、解放と再取得の間の脆弱なウィンドウが生まれ、他のスレッドが書き込みロックを取得したり、状態を変更したりする可能性があります。これにより、開発者は複雑なダブルチェックロックパターンやリトライループを実装しなければならず、コードの複雑さとレイテンシが増加します。さらに、開発者が誤って直接のアップグレードを試みる(読み取りロックを保持しながらwriteLock().lock())と、スレッドは自身が読み取り許可を解放するのを待ちながら回復不可能なデッドロック状態に入ります。
解決策。
StampedLockは、**tryOptimisticRead()を通じてこの危険を排除し、ロックを取得せずに長いスタンプを取得します。スレッドは読み取り操作を実行し、その後validate(stamp)**を呼び出します;もしスタンプが有効なままであれば(介入する書き込みが発生しなかった場合)、ブロックなしで一貫した読み取りが行えます。スレッドが書き込む必要があると検出した場合、**tryConvertToWriteLock(stamp)**を試み、その間に状態が変更されていなければスタンプを原子的に検証し、書き込みロックを取得します。このアプローチは、スレッドが移行中に競合する読み取りロックを保持することがなく、解放と再取得戦略の競争ウィンドウを避けることで、デッドロックを防ぎます。
コード例。
import java.util.concurrent.locks.StampedLock; public class AtomicUpgradeCache { private final StampedLock lock = new StampedLock(); private int value = 0; public void conditionalUpdate(int threshold, int newValue) { long stamp = lock.tryOptimisticRead(); int current = value; // 行動の前に検証 if (!lock.validate(stamp)) { stamp = lock.readLock(); try { current = value; } finally { lock.unlockRead(stamp); } } if (current < threshold) { // 原子的アップグレードの試行 stamp = lock.tryConvertToWriteLock(stamp); if (stamp == 0L) { // 変換に失敗、最新の書き込みロックを取得 stamp = lock.writeLock(); } try { // 排他ロックの下で条件を再チェック if (value < threshold) { value = newValue; } } finally { lock.unlock(stamp); } } } }
実生活の状況
問題の説明。
高頻度取引プラットフォームは、ライブ市場深さを表すメモリ内のオーダーブックキャッシュを維持しており、数百のスレッドから毎秒約50,000の読み取りが必要ですが、価格ティックが到着したときの更新はごく稀です。最初の実装では、synchronizedブロックを使用していたため、スレッドがモニターを争う際に市場の変動中に壊滅的なレイテンシスパイクが発生し、読み取りレイテンシが500ミリ秒を超えることもありました。エンジニアチームは、価格更新が観察から変異へのアップグレード中にデッドロックを引き起こさずに市場条件を原子的に確認し、ブックを変更できるようにしながら、読み取り側の争奪を完全に排除する必要がありました。
検討された異なる解決策。
解決策1:解放と再取得によるReentrantReadWriteLock。
このアプローチは、読み取りロックを取得して市場条件を調査し、それを解放した後、必要な場合に即座に書き込みロックの取得を試みるものでした。この方法はデッドロックを防ぎましたが、重大な競争条件を導入しました:読み取りロックを解放して書き込みロックを取得する間に、競合するスレッドが同じ古い条件を観察し、冗長なデータベースクエリや交換APIコールを開始する可能性があり、トンザリングハード行動と計算リソースの浪費を引き起こしました。また、読み取りモードと書き込みモードの間での定常的なコンテキストスイッチは、高ボリュームの取引期間中に測定可能なオーバーヘッドを追加しました。
解決策2:不変なスナップショットと揮発性参照。
このソリューションは、ロックを完全に放棄し、オーダーブックを揮発性フィールドによって参照される不変データ構造として維持することにしました。読者は単に揮発性を逆参照して一貫したスナップショットを取得し、書き込み者は完全に新しいオーダーブックのコピーを作成し、参照に対して原子的な比較とセット操作を実行しました。これにより、読み取り競合が完全に排除され、優れた読み取り性能が提供されました。しかし、それは巨大な割り当て圧力を生成しました。価格の小さな更新ごとにオーダーブック構造全体をコピーする必要があり、頻繁に若い世代のガーベジコレクションの一時停止が発生し、変動する市場環境下でのアプリケーションの10ミリ秒のレイテンシSLAを違反しました。
解決策3:楽観的な読み取りと条件付け変換を使用したStampedLock。
選ばれた解決策は、StampedLockを使用してホットパスに楽観的な読み取りアクセスを提供しました:スレッドは**tryOptimisticRead()**を使用してオーダーブックの状態を楽観的に読み取り、スタンプを検証し、同時書き込みが行われていない場合にのみ進行しました。まれな書き込み操作の場合、システムは楽観的スタンプを直接書き込みロックに変換しようとし、観察された状態が現在のままであることを原子的に検証しました。変換が失敗した場合、システムは伝統的な再試行ロジックを用いて明示的な書き込みロックの取得にフォールバックしました。このアプローチは、読み取りのオーバーヘッドをほぼゼロにし(生の揮発性アクセスに似て)、ReentrantReadWriteLockアップグレードに内在するデッドロックリスクを防ぎました。
選択された解決策(理由)。
チームは、解決策3を選択しました。これにより、極端な読み込みスループット要件(楽観的読み取りはスレッド数に対して線形的にスケール)と条件付き更新の原子的な安全要件がユニークにバランスされました。解決策1とは異なり、スタンプの検証メカニズムを通じて、読み取り解放と書き込み取得間の競争ウィンドウを排除しました。解決策2とは異なり、各小さな価格調整に対して完全な構造コピーを必要とするのではなく、変換された書き込みロックの保護下でインプレース修正を可能にして、メモリ割り当て圧力を回避しました。原子的に検証し、変換する能力により、価格更新は市場状態が決定基準に完全に一致する場合のみ発生することが保証され、以前のプロトタイプで発生していた一貫性違反を防ぎました。
結果。
実装後、アプリケーションは毎秒50,000の同時読み取りを維持し、p99.9のレイテンシは15マイクロ秒未満であり、以前の同期アプローチに対して30倍の改善を示しました。1,000の同時価格更新が行われる変動市場シミュレーション中には、システムはデッドロック事件をゼロに保ち、ガーベジコレクションの一時停止も2ミリ秒未満に抑えていました。StampedLockの実装は、6ヶ月の本番取引を単一の同時実行に関連する事件やデータ競合なしで成功裏に処理し、高頻度の読み取りシナリオに対する楽観的ロックの使用に関するアーキテクチャの決定を検証しました。
候補者がよく見落とす点。
なぜStampedLockは再入可能性をサポートしておらず、スレッドが同じロックを再帰的に取得しようとするとどのような壊滅的な失敗モードが発生するか?
StampedLockは内部の状態追跡を最小限にし、スループットを最大化するために明示的に非再入可能なロックとして設計されています。ReentrantReadWriteLockとは異なり、所有スレッドや保持カウントのマップを維持するのではなく、StampedLockは単にどのスレッドがアクセスを保持しているかを追跡します。したがって、読み取りロックを保持しているスレッドが同じStampedLockインスタンスで別の読み取りロック(または書き込みロック)を取得しようとすると、すぐにデッドロックに陥ります:取得の呼び出しは、すべての既存ロックが解放されるのを待機してブロックしますが、ブロックされたスレッド自体がそのロックの1つを保持しているため、解決不可能な循環依存関係が生まれます。開発者は、ネストされたロック取得を試みるのではなく、現在のスタンプをメソッドパラメーターとして渡すようにコードをリファクタリングしなければならず、これは通常、以前はスレッドローカルのロック状態に依存していた内部APIに対して重大なアーキテクチャの変更を必要とします。
**validate()単独では、一貫性を確保するには不十分である理由は何ですか?
楽観的読み取りは**tryOptimisticRead()**を介して単独ではハプンズビフォー保証を提供しません。それは単にメモリフェンスを発行したり、命令の再配置を防いだりすることなく、バージョンスタンプを捉えます。楽観的なフェーズ中に観察されたデータは、CPUキャッシュラインが古いものであるか、他のスレッドによってまだ初期化されているフィールドを持つオブジェクトを参照している可能性があります。なぜなら、JVMメモリモデルは楽観的な読み取りを同期意味論なしの通常の変数アクセスと見なすからです。**validate(stamp)**が真を返すと、楽観的な読み取りが始まって以来の書き込みロックが取得されなかったことが確立され、最近の書き込みロックの解放との間に必要なハプンズビフォーエッジが作成されます。しかし、候補者はしばしば見落とします、**validate()**はロック状態のみを保証するものであり、データ構造の内部の一貫性を保証するものではありません:保護されたデータが変更可能なオブジェクトへの非揮発参照を含む場合、楽観的読み取りは他のスレッドによって初期化中のフィールドを持つオブジェクトへの参照を観察する可能性があります(安全でない公開)。したがって、楽観的読み取りは、保護された状態が揮発性参照または不変オブジェクトで構成されていることを必要とし、ロックのメモリ意味論に関係なく安全な公開を確保します。
StampedLockとVirtual Threads(プロジェクトLoom)との間には根本的な不整合性があり、これは現代の高同時実行アプリケーションにおいてStampedLockを避ける必要があります。なぜですか?
StampedLockの実装は、仮想スレッドがロックを保持している間にブロックされると、基になるPlatform Thread(キャリアスレッド)をピン留めするLockSupport.park操作に依存しています。仮想スレッドが競合するStampedLockを取得しようとすると(読み取りまたは書き込みのいずれか)、JVMはロック内部がまだ仮想スレッドのイールド用に適応されていないネイティブ同期プリミティブを使用しているため、仮想スレッドをそのキャリアから取り外すことができません。このピン留めは、仮想スレッドが数千の仮想スレッドをわずか数つのプラットフォームスレッドにマルチプレックスするという仮想スレッドのスケーラビリティの約束を打ち消します。同時に複数の仮想スレッドがStampedLockの競合にブロックされると、アプリケーションの実行が凍結し、数百万の仮想スレッドが理論的には使用可能であるにもかかわらず、キャリアスレッドプール全体を独占します。対照的に、ReentrantLockやSemaphoreは、非ブロッキングアルゴリズムや仮想スレッドから呼び出された際の専門的なイールドメカニズムを使用するように改良されています。したがって、VirtualThreadエグゼキュータを使用する現代のアプリケーションでは、キャリアスレッドの飢餓を防ぐためにStampedLockの代わりにReentrantLockまたは並列データ構造を使用しなければなりません。