ThreadLocal: 正しいイディオム、誤った時代
Java EE Servlet コンテナ(1999年頃):1 リクエストにつき 1 スレッド。スレッドはリクエストを最初から最後まで処理し、その後終了する。ThreadLocal は現在のスレッドをキーとして値を保存する。1 スレッド = 1 リクエストのモデルでは、ThreadLocal に保存された値はちょうど 1 つのリクエストに属する。このイディオムは正しかった。
スレッドプールが契約を変えた。スレッドはリクエスト A を処理し、ThreadLocal に principal A を保存し、リクエスト A を終了してプールに戻る。スレッドプールはスレッドの状態をリセットしない。ThreadLocal.remove() でクリーンアップできるが、それには明示的な規律が必要である。規律が崩れると、同じスレッドでリクエスト B が実行され、ThreadLocal から principal A が読み取られる。
5 段階の漏洩:
1. リクエスト A が到着。サーバーは Thread-7 を割り当てる。
2. Thread-7 がリクエスト開始時に ThreadLocal.set(principal_A) を実行する。
3. リクエスト A が完了する。Thread-7 はプールに戻る。ThreadLocal.remove() は呼び出されない。
4. リクエスト B が到着する。サーバーは Thread-7 を割り当てる(プール再利用)。
5. Thread-7 が ThreadLocal.get() を読み取る:principal_A が返される。リクエスト B は誤った ID で実行される。
テストで見逃される理由
ユニットテストは分離された環境で実行される:スレッドプールも再利用もない。統合テストでは新しいスレッドを使用するか、テスト間で状態をリセットする。負荷テストでは正しいユーザーと低並行性でウォームアップする。この欠陥は、スレッドプールの再利用とリクエストの重複という条件でのみ現れる。これは通常のトラフィック下の本番環境で発生するが、テスト構成では検出されない。
セキュリティ上の影響
ユーザー A のプリンシパルがユーザー B のリクエストに漏洩する。クラッシュも例外も発生しない。静かなセキュリティ境界違反:ユーザー B がユーザー A としてアクションを実行し、ユーザー A のデータを読み取り、またはユーザー B の権限をバイパスする。システムはエラーを出力しない。ログにはリクエスト B が承認されたと記録される。すべてが正しく見える。
The Five Steps
ThreadLocalリークの5つのステップは、問題が誤ったコードが実行された瞬間に発生するわけではないことを示しています。問題は、クリーンアップステップが存在しないことによって、もっと早い段階で発生します。
Scope-Attached Values
ThreadLocal は値をスレッドに結び付けます。スレッドはリクエストよりも長く存続します。ミスマッチです。
スコープに結び付けられた値は、値を作業単位に結び付けます。作業単位が終了すると、値もそれとともに終了します。明示的なクリーンアップは不要です。忘れるための remove() も必要ありません。
Java 21: ScopedValue
// ThreadLocal (欠陥のあるキャリア)
static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
PRINCIPAL.set(principal); // リクエスト開始時に設定
// ... リクエスト処理 ...
PRINCIPAL.remove(); // 必ず呼び出す(よく忘れられる)
// ScopedValue(正しいキャリア)
static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
ScopedValue.where(PRINCIPAL, principal).run(() -> {
// ... リクエスト処理 ...
// run() が終了すると値は自動的に破棄される
});
Go: context.Context
// context.Context は値を明示的に運ぶ。スコープ = 関数呼び出しチェーン
ctx := context.WithValue(r.Context(), principalKey, principal)
handleRequest(ctx) // ctx は明示的に渡される。関数が戻ると消滅する
Python asyncio: contextvars.ContextVar
# 各非同期タスクにスコープされる ContextVar
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal) # このタスクのみに設定
# ... タスクの処理 ...
PRINCIPAL.reset(token) # または: タスク終了時にスコープが終了
これらに共通する特性: ライフタイムが作業単位と一致する。リクエストが終了すると(run() が返る、関数が返る、タスクが完了する)、値も終了する。忘れてはならないクリーンアップもなければ、破損する可能性のあるプールもない。
Identify & Replace
Java EEアプリケーションは、リクエスト開始時にテナントIDをThreadLocalに保存します。高負荷時には、テナントBのリクエストにテナントAのIDが混入し、テナントBのクエリがテナントAのデータを返します。例外は発生しません。この欠陥は本番負荷テストでのみ顕在化します。