English· Español· Deutsch· Nederlands· Français· 日本語· ქართული· 繁體中文· 简体中文· Português· Русский· العربية· हिन्दी· Italiano· 한국어· Polski· Svenska· Türkçe· Українська· Tiếng Việt· Bahasa Indonesia

un

ゲスト
1 / ?

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つのステップは、問題が誤ったコードが実行された瞬間に発生するわけではないことを示しています。問題は、クリーンアップステップが存在しないことによって、もっと早い段階で発生します。

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のデータを返します。例外は発生しません。この欠陥は本番負荷テストでのみ顕在化します。

この欠陥はどのMOADに該当しますか?欠陥を可能にしたキャリアは何ですか?それを置き換えるものは何で、その置き換えのどの特性が漏洩を防ぎますか?