ThreadLocal:正確的慣用法,卻錯了時代
Java EE Servlet 容器(約 1999 年):一個請求對應一個 Thread。Thread 從頭到尾處理單一請求,處理完畢即終止。ThreadLocal 以目前 Thread 為鍵值儲存資料。在「一 Thread 一請求」的模型下,ThreadLocal 裡的值正好屬於單一請求。此慣用法在當時是正確的。
執行緒池改變了契約。同一條 Thread 先處理請求 A,將 principal A 存入 ThreadLocal,請求 A 結束後 Thread 返回池中。執行緒池不會重置 Thread 狀態。雖然 ThreadLocal.remove() 可以清理,但必須主動呼叫。若未確實執行,請求 B 由同一條 Thread 處理時,便會讀取到 ThreadLocal 中殘留的 principal A。
五步驟洩漏:
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 以錯誤的身分執行。
為什麼測試無法發現此問題
單元測試在隔離環境中執行:沒有執行緒池,也沒有重用。整合測試使用新執行緒,或在測試之間重置狀態。負載測試在低併發且正確使用者的情況下進行暖機。此缺陷僅在執行緒池重用且請求重疊時才會顯現,這種情況只會在正式環境的正常流量下發生,而非任何測試組態中。
安全性後果
使用者 A 的主體洩漏到使用者 B 的請求中。這不是當機,也不是例外,而是無聲的安全邊界違規:使用者 B 以使用者 A 的身分執行操作、讀取使用者 A 的資料,或繞過使用者 B 的權限。系統不會產生任何錯誤。日誌顯示請求 B 已通過授權。一切看起來都正確無誤。
五個步驟
ThreadLocal 洩漏的五個步驟至關重要:缺陷並非發生在錯誤程式碼執行的那一刻,而是發生在更早的階段,也就是缺少清理步驟的時候。
與作用域綁定的值
ThreadLocal 會將值附加到執行緒上。執行緒的生命週期比請求長,造成不匹配。
Scope-attached values 會將值附加到一個工作單元上。當工作單元結束時,值也隨之結束。無需明確清理,也不需要呼叫 remove() 來清除。
Java 21: ScopedValue
// ThreadLocal (DEFECT carrier)
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 綁定於每個 async task 的作用域
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal) # 僅為此 task 設定
# ... task 處理 ...
PRINCIPAL.reset(token) # 或:隨著 task 結束而清除作用域
這些範例共同的特性:生命週期與工作單位一致。當請求結束(run() 返回、函式返回、task 完成),值也隨之結束。無需擔心遺漏清理,也不會污染資源池。
識別與替換
某 Java EE 應用程式在請求開始時將租戶 ID 儲存在 ThreadLocal 中。在高負載下,租戶 A 的 ID 卻出現在租戶 B 的請求中。租戶 B 的查詢回傳租戶 A 的資料,且未拋出任何例外。此缺陷僅在生產環境的負載測試中出現。