ThreadLocal: Doğru Deyim, Yanlış Dönem
Java EE Servlet konteynerleri, 1999 civarı: istek başına bir thread. Bir thread, isteği baştan sona işler ve sonra sonlanır. ThreadLocal, mevcut thread’e göre anahtarlanmış bir değer depolar. Tek thread-tek istek modelinde, ThreadLocal’de saklanan değer tam olarak bir isteğe aittir. Deyim: doğruydu.
Thread havuzları sözleşmeyi değiştirdi. Bir thread, A isteğini işler, ThreadLocal’e principal A’yı kaydeder, A isteğini bitirir ve havuza döner. Thread havuzları thread durumunu sıfırlamaz. ThreadLocal.remove() temizlik yapar, ancak çağrılması açık disiplin gerektirir. Disiplin başarısız olduğunda, aynı thread üzerinde B isteği çalışır ve ThreadLocal’deki principal A’yı okur.
5 adımlık sızıntı:
1. A isteği gelir. Sunucu Thread-7 atar.
2. Thread-7, istek başlangıcında ThreadLocal.set(principal_A) çağrısını yapar.
3. İstek A tamamlanır. Thread-7 havuza döner. ThreadLocal.remove() çağrılmamıştır.
4. İstek B gelir. Sunucu Thread-7’yi atar (havuz yeniden kullanımı).
5. Thread-7 ThreadLocal.get() okur: principal_A döner. İstek B yanlış kimlik altında çalışır.
Testlerin Neden Yakalayamadığı
Birim testleri yalıtılmış çalışır: thread havuzu yoktur, yeniden kullanım yoktur. Entegrasyon testleri taze thread’ler kullanır veya testler arasında durumu sıfırlar. Yük testleri doğru kullanıcılarla ve düşük eşzamanlılıkla ısınır. Hata yalnızca thread havuzu yeniden kullanımı ve çakışan istekler altında ortaya çıkar; bu durum üretimde normal trafik altında görülür, ancak bunu kontrol eden hiçbir test yapılandırmasında bulunmaz.
Güvenlik Sonucu
Kullanıcı A’nın principal’ı kullanıcı B’nin isteğine sızar. Çökme olmaz. İstisna olmaz. Sessiz bir güvenlik sınırı ihlali: kullanıcı B, kullanıcı A gibi eylemler gerçekleştirir, kullanıcı A’nın verilerini okur veya kullanıcı B’nin izinlerini aşar. Sistem hiçbir hata üretmez. Günlükler, istek B’nin yetkilendirildiğini gösterir. Her şey doğru görünür.
Beş Adım
ThreadLocal sızıntısının beş adımı tam olarak şu nedenle önemlidir: hata, yanlış kod çalıştırıldığı anda oluşmaz. Temizlik adımının eksikliği nedeniyle daha önce oluşur.
Kapsama Bağlı Değerler
ThreadLocal, bir değeri bir thread'e iliştirir. Bir thread, bir request'ten daha uzun yaşar. Uyumsuzluk.
Scope-attached değerler, bir değeri bir iş birimine iliştirir. İş birimi bittiğinde, değer de onunla birlikte biter. Açık temizleme gerekmez. Unutmak için remove() çağrılmaz.
Java 21: ScopedValue
// ThreadLocal (DEFECT taşıyıcısı)
static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
PRINCIPAL.set(principal); // request başlangıcında ayarla
// ... request işleme ...
PRINCIPAL.remove(); // MUTLAKA çağrılmalı; sıklıkla unutulur
// ScopedValue (DOĞRU taşıyıcı)
static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
ScopedValue.where(PRINCIPAL, principal).run(() -> {
// ... istek işleme ...
// run() döndüğünde değer otomatik olarak silinir
});
Go: context.Context
// context.Context değerleri açıkça taşır; kapsam = fonksiyon çağrı zinciri
ctx := context.WithValue(r.Context(), principalKey, principal)
handleRequest(ctx) // ctx açıkça geçirilir; fonksiyon döndüğünde kaybolur
Python asyncio: contextvars.ContextVar
# Her async göreve özel ContextVar
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal) # yalnızca bu görev için ayarla
# ... görev işleme ...
PRINCIPAL.reset(token) # veya: kapsam görevle birlikte biter
Bu örneklerin ortak özelliği: yaşam süresi, iş birimiyle eşleşir. İstek sona erdiğinde (run() döndüğünde, fonksiyon döndüğünde, görev tamamlandığında), değer de sona erer. Unutulacak temizlik yok. Bozulacak havuz yok.
Tanımla ve Değiştir
Bir Java EE uygulaması, istek başlangıcında ThreadLocal içinde tenant ID saklar. Yüksek yük altında, tenant A'nın ID'si tenant B'nin isteklerinde görünür. Tenant B'nin sorguları tenant A'nın verilerini döndürür. Hiçbir istisna fırlatılmaz. Hata yalnızca üretim yük testlerinde ortaya çıkar.