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

un

invité
1 / ?
retour aux leçons

ThreadLocal : idiome correct, ère incorrecte

Conteneurs Servlet Java EE, vers 1999 : un thread par requête. Un thread traite exactement une requête du début à la fin, puis se termine. ThreadLocal stocke une valeur associée au thread courant. Avec un modèle « un thread par requête », une valeur stockée dans ThreadLocal appartient à exactement une requête. L’idiome : correct.

Les pools de threads ont changé le contrat. Un thread traite la requête A, stocke le principal A dans ThreadLocal, termine la requête A, puis retourne au pool. Les pools de threads ne réinitialisent pas l’état du thread. ThreadLocal.remove() nettoie, mais son appel exige une discipline explicite. Quand cette discipline fait défaut, la requête B s’exécute sur le même thread et lit le principal A dans ThreadLocal.

La fuite en 5 étapes :

1. La requête A arrive. Le serveur affecte Thread-7.

2. Thread-7 appelle ThreadLocal.set(principal_A) au début de la requête.

3. La requête A se termine. Thread-7 retourne au pool. ThreadLocal.remove() n’a pas été appelé.

4. La requête B arrive. Le serveur réaffecte Thread-7 (réutilisation du pool).

5. Thread-7 lit ThreadLocal.get() : il obtient principal_A. La requête B s’exécute donc sous la mauvaise identité.

Pourquoi les tests ne le détectent pas

Les tests unitaires s’exécutent de manière isolée : pas de pool de threads, pas de réutilisation. Les tests d’intégration utilisent des threads frais ou réinitialisent l’état entre les tests. Les tests de charge se calent avec des utilisateurs corrects et une faible concurrence. Le défaut ne se manifeste que lors de la réutilisation d’un thread du pool avec des requêtes qui se chevauchent, une condition qui apparaît en production sous un trafic normal, et non dans les configurations de test qui le vérifient.

La conséquence en matière de sécurité

Le principal de l’utilisateur A fuit dans la requête de l’utilisateur B. Ce n’est pas un plantage. Ce n’est pas une exception. C’est une violation silencieuse de la frontière de sécurité : l’utilisateur B effectue des actions en tant qu’utilisateur A, lit les données de l’utilisateur A ou contourne les permissions de l’utilisateur B. Le système ne produit aucune erreur. Les journaux indiquent que la requête B a été autorisée. Tout semble correct.

Les cinq étapes

Les cinq étapes d’une fuite ThreadLocal comptent précisément : le défaut ne se produit pas au moment où le code incorrect s’exécute. Il se produit plus tôt, en l’absence d’une étape de nettoyage.

Parcourez les 5 étapes. À quelle étape le défaut se produit-il, et pourquoi une suite de tests pourrait-elle le manquer ?

Valeurs attachées à la portée

ThreadLocal attache une valeur à un thread. Un thread survit à une requête. Inadéquation.

Les valeurs attachées à un scope lient une valeur à une unité de travail. Lorsque l’unité de travail se termine, la valeur disparaît avec elle. Aucun nettoyage explicite. Pas de remove() à appeler.

Java 21 : ScopedValue

// ThreadLocal (porteur de DÉFAUT)
static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
PRINCIPAL.set(principal);           // défini au début de la requête
// ... traitement de la requête ...
PRINCIPAL.remove();                 // DOIT être appelé ; souvent oublié

// ScopedValue (transporteur CORRECT)
static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
ScopedValue.where(PRINCIPAL, principal).run(() -> {
// ... traitement de la requête ...
// la valeur disparaît automatiquement quand run() retourne
});

Go : context.Context

// context.Context transporte des valeurs explicitement ; scope = chaîne d'appels de fonctions
ctx := context.WithValue(r.Context(), principalKey, principal)
handleRequest(ctx)  // ctx passé explicitement ; disparaît quand la fonction retourne

Python asyncio : contextvars.ContextVar

# ContextVar limité à chaque tâche asynchrone
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal)    # défini uniquement pour cette tâche
# ... traitement de la tâche ...
PRINCIPAL.reset(token)              # ou : la portée se termine avec la tâche

La propriété qu’ils partagent : la durée de vie correspond à l’unité de travail. Quand la requête se termine (le run() retourne, la fonction retourne, la tâche se termine), la valeur disparaît. Aucun nettoyage à oublier. Aucun pool à corrompre.

Identifier & Remplacer

Une application Java EE stocke l’ID du locataire dans un ThreadLocal au début de la requête. Sous forte charge, l’ID du locataire A apparaît dans les requêtes du locataire B. Les requêtes du locataire B renvoient les données du locataire A. Aucune exception n’est levée. Le défaut n’apparaît que lors des tests de charge en production.

Quel MOAD cela décrit-il ? Quel porteur a rendu le défaut possible ? Que le remplace, et quelle propriété du remplacement empêche la fuite ?