質問の歴史
Java 5以前は、スレッドの調整はThread.suspend(固有のデッドロックリスクのため非推奨)やObject.wait/notifyといった原始的なメソッドに依存していました。これらは厳格なモニタ所有権を必要とし、通知が待機の前に行われた場合にウェイクアップが失われるという問題がありました。Java 5でjava.util.concurrentが導入され、LockSupportは高性能の同期機構(例:AbstractQueuedSynchronizer)を構築するための低レベルの非ブロック化プリミティブとして設計されました。
問題
並行プログラミングでは、信号を送るスレッドがターゲットのスレッドが実際にパークする前にアンパークメカニズムを呼び出すという古典的な競合状態が発生します。従来の条件変数では、この信号は失われ、ターゲットスレッドは無期限にスリープ状態に陥ります。素朴な解決策はカウントセマフォを使用して許可を蓄積することですが、これにより複雑さが増し、生産者が消費者を上回る場合にリソースリークの可能性が生じます。
解決策
LockSupportは各スレッドに関連付けられた非蓄積型の単一ビットの許可を使用しています。この許可は使い捨てのスレッドローカルなゲートパスのようなものです:
許可は累積的でないため(1で飽和します)、過剰なアンパークからのメモリリークを防ぎ、パークの前に発行された一度のアンパークが記憶されることを保証します。これにより、失われたウェイクアップの問題は「発生前関係」によって排除されます。
import java.util.concurrent.locks.LockSupport; public class PermitExample { public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { System.out.println("Worker: Initial work..."); try { Thread.sleep(100); } catch (InterruptedException e) {} System.out.println("Worker: Attempting to park..."); LockSupport.park(); System.out.println("Worker: Unparked successfully!"); }); worker.start(); // Signal before the worker actually parks Thread.sleep(50); System.out.println("Main: Calling unpark before worker parks"); LockSupport.unpark(worker); worker.join(); } }
問題の説明
高頻度取引システムのオーダーマッチングエンジンを設計する際、消費者スレッドが入出力キューが満杯になったときに処理を一時停止できるバックプレッシャーメカニズムが必要でした。このメカニズムは、モニタを保持することなく、プロデューサーがキューの状態を確認できるようにするものでした。標準のReentrantLockとConditionは、信号送信中にキューのロックに競合を生じさせ、Object.wait/notifyは高流動性レース中の失われたウェイクアップのリスクに悩まされました。
検討した異なる解決策
1. Object.wait/notifyAll
このアプローチは、キューの固有のロックを使用しました。利点:標準モニタを使用するシンプルな実装。欠点:モニタを取得してnotifyを呼び出す必要があり、シリアライゼーションボトルネックが発生しました。さらに、プロデューサーが消費者がキューサイズを確認してwaitを呼び出す間の短いウィンドウでnotifyを呼び出すと、信号が失われ、消費者が永遠にデッドロックに陥ります。
2. ReentrantLockと複数の条件
「満杯」と「空」の状態のために別々の条件を使用しようとしました。利点:固有のロックよりも柔軟性があり、選択的なウェイクアップが可能です。欠点:依然として信号のためにロックを取得する必要があり(signalAll)、条件キュー間のスレッドの正しい移動の複雑さは、根本的なロックのオーバーヘッドを解決することなくメンテナンスオーバーヘッドを引き起こしました。
3. LockSupportと明示的な原子状態
選ばれた解決策は、進行の「許可を表す」ためにAtomicBooleanを使用し、ブロッキングのためにLockSupportを使用しました。キューが満杯になると、消費者は原子的に「needsParking」フラグを設定し、その後パークしました。プロデューサーは項目を取り除いた後にフラグを確認し、設定されていればunparkを呼び出しました。利点:信号送信にはロックが不要で、ウェイクアップ中の競合を排除しました。単ビット許可モデルは、たとえプロデューサーが消費者がパークを呼び出すほんの数ナノ秒前にunparkを呼び出しても、そのウェイクアップが失われないことを保証しました。
選ばれた解決策と結果
我々はLockSupportアプローチを選択しました。信号送信メカニズムをキューの構造ロックから切り離すことで、重負荷時のプロデューサーのレイテンシを40%削減し、ストレステスト中に観察された失われたウェイクアップシナリオを排除しました。明示的な状態管理(unpark後に条件を二重確認すること)は、park()の虚偽のウェイクアップ契約にもかかわらず正確性を保証しました。
LockSupport.parkはスレッドが保持しているモニタの所有権を解放するのか?
いいえ。これはObject.wait()との決定的な違いです。スレッドがLockSupport.parkを呼び出すと、待機状態に入りますが、現在保持しているすべてのモニタの所有権を保持したままとなります。他のスレッドがそのモニタのいずれかに入ろうとすると(たとえば、同じオブジェクトに対するsynchronizedブロック)、ブロックされます。これは、待機中のスレッドが唯一の解放者である場合にデッドロックを引き起こす可能性があります。候補者はしばしばparkがwaitのようにロックを解放すると誤解します。これは純粋にスレッドローカルなスケジューラのプリミティブです。
LockSupport.parkは、割り込み状態が設定されたスレッドで呼び出された場合、どのような動作をするのか?
このメソッドはすぐに戻り、ブロックしません、さらに割り込み状態をクリアすることもありません。これは、割り込み状態をクリアしInterruptedExceptionをスローする**Object.wait()**とは根本的に異なります。LockSupportでは、スレッドは割り込みの慣行を尊重したい場合、明示的に割り込み状態をチェックしクリアする必要があります(**Thread.interrupted()**を介して)。この設計により、parkは割り込み不能な文脈で使用できるか、あるいは割り込みが駐車許可とは別の関心事として扱われる場合に使用できます。
LockSupportは虚偽のウェイクアップをどのように処理し、これはコーディングパターンにどのように影響するのか?
LockSupport.parkは「理由なく戻る」と文書化されていますが(虚偽のウェイクアップ)、実際にはこれは現代のJVMでは稀です。許可ベースのウェイクアップ(unpark)とは異なり、虚偽のウェイクアップは許可を消費しません。したがって、呼び出し側は常にループで待機の原因となった条件を再チェックする必要があります:
while (!canProceed()) { LockSupport.park(); }
候補者は、park後に条件を一度だけ確認することは不十分であることを見落としがちです。スレッドは虚偽のウェイクアップ(または逸れた割り込み)で目が覚める可能性があるため、unpark呼び出しなしで状態条件を再評価する必要があります。許可は有効なunparkが失われないことを保証しますが、虚偽の戻りを防ぐものではありません。