11 - Race conditions en single-thread
Ce que tu vas apprendre
- Pourquoi JavaScript a des race conditions malgre son thread unique
- Le problème des stale closures avec l'asynchrone
- Le bug classique du champ de recherche
- Les solutions : abort, sequence IDs, mutex
Prerequisites
Avoir lu l'article 10 - AbortController.
"JavaScript est single-thread, donc pas de race conditions." J'ai entendu cette phrase des dizaines de fois. Elle est fausse. Le single-thread garantit qu'il n'y a pas d'acces concurrent a la mémoire au meme instant. Mais des que tu as des opérations asynchrones, tu peux avoir des résultats qui arrivent dans un ordre différent de celui dans lequel tu les as demandes. Et ca, c'est exactement une race condition.
Le bug classique : la recherche
L'utilisateur tape "par" dans un champ de recherche. Ton code envoie trois requêtes :
- "p" -> requête A
- "pa" -> requête B
- "par" -> requête C
Si le serveur répond dans l'ordre C, A, B, ton interface affiche les résultats de "pa" (la dernière réponse reçue) alors que l'utilisateur a tape "par".
typescript// VERSION BUGGEE
const searchInput = document.getElementById("search") as HTMLInputElement;
searchInput.addEventListener("input", async () => {
const query = searchInput.value;
const response = await fetch(`/api/search?q=${query}`);
const results = await response.json();
displayResults(results); // Quelle requete arrive en dernier ?
});
Chaque frappe lance un fetch. Rien ne garantit que les réponses arrivent dans l'ordre des requêtes. La dernière réponse reçue ecrase les précédentes, meme si elle correspond a une requête plus ancienne.
Stale closures
Un autre type de race condition, plus insidieux :
typescriptfunction Counter() {
const [count, setCount] = useState(0);
async function incrementLater() {
await delay(1000);
setCount(count + 1); // "count" est capture au moment du clic, pas au moment de l'execution
}
return <button onClick={incrementLater}>+1 (delayed)</button>;
}
Si l'utilisateur clique trois fois rapidement, count vaut 0 dans les trois closures. Apres une seconde, les trois callbacks executent setCount(0 + 1). Le compteur affiche 1 au lieu de 3.
La correction :
typescriptasync function incrementLater() {
await delay(1000);
setCount((prev) => prev + 1); // utilise la valeur actuelle, pas la closure
}
Le pattern setCount(prev => prev + 1) est la version React de "lire la valeur au moment de l'écriture, pas au moment de la capture". C'est l'équivalent d'un compare-and-swap en programmation concurrente.
Mutations concurrentes d'état
Meme sans React, le problème existe. Deux fonctions async qui modifient le meme objet :
typescriptlet balance = 100;
async function withdraw(amount: number) {
const current = balance; // lecture
await validateWithBank(amount); // suspension -- l'event loop tourne
balance = current - amount; // ecriture avec une valeur potentiellement obsolete
}
// L'utilisateur lance deux retraits en meme temps
withdraw(30); // lit balance = 100
withdraw(50); // lit balance = 100 (pas encore modifie)
// Apres les deux awaits : balance = 50 au lieu de 20
Entre la lecture et l'écriture, il y a un await. Pendant cette suspension, une autre opération peut lire la meme valeur et ecraser le résultat.
Solution 1 : AbortController
Pour le problème de la recherche, la solution la plus propre est d'annuler la requête précédente :
typescriptlet currentController: AbortController | null = null;
searchInput.addEventListener("input", async () => {
currentController?.abort(); // annule la requete precedente
currentController = new AbortController();
const query = searchInput.value;
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: currentController.signal,
});
const results = await response.json();
displayResults(results);
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return; // requete annulee, on ignore
}
throw error;
}
});
Chaque nouvelle requête annule la précédente. Seule la dernière réponse est traitee. C'est le pattern que j'utilise sur paltemps.fr pour toutes les recherches.
Solution 2 : sequence IDs
Si tu ne peux pas annuler l'opération (par exemple un appel a une lib tierce qui ne supporte pas AbortSignal), utilise un compteur de sequence :
typescriptlet requestId = 0;
searchInput.addEventListener("input", async () => {
const thisRequest = ++requestId;
const query = searchInput.value;
const response = await fetch(`/api/search?q=${query}`);
const results = await response.json();
// Ignore si une requete plus recente a ete lancee
if (thisRequest !== requestId) {
return;
}
displayResults(results);
});
Le principe : chaque requête recoit un numero. Si au moment de traiter la réponse, le numero ne correspond plus au dernier, on jette le résultat. Simple, sans dépendance externe.
Solution 3 : le pattern mutex
Pour les mutations concurrentes, un mutex (mutual exclusion) garantit qu'une seule opération async s'exécuté a la fois :
typescriptfunction createMutex() {
let current = Promise.resolve();
return function <T>(fn: () => Promise<T>): Promise<T> {
const next = current.then(() => fn());
current = next.then(() => {}, () => {}); // ignore les erreurs pour la chaine
return next;
};
}
const mutex = createMutex();
async function withdraw(amount: number) {
return mutex(async () => {
const current = balance;
await validateWithBank(amount);
balance = current - amount;
});
}
// Maintenant les deux retraits s'executent en sequence
await Promise.all([withdraw(30), withdraw(50)]);
// balance = 20 (correct)
Le mutex chaîne les opérations : la deuxieme attend que la première soit terminee avant de commencer. C'est l'équivalent d'un synchronized en Java, mais avec des Promises.
Solution 4 : React useEffect cleanup
En React, le pattern recommande combine AbortController et le cleanup de useEffect :
typescriptfunction SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Result[]>([]);
useEffect(() => {
const controller = new AbortController();
let cancelled = false;
async function search() {
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
const data = await response.json();
if (!cancelled) {
setResults(data);
}
} catch (error) {
if (error instanceof Error && error.name !== "AbortError") {
console.error(error);
}
}
}
search();
return () => {
cancelled = true;
controller.abort();
};
}, [query]);
return <ResultList results={results} />;
}
Le double garde (cancelled + abort) est un peu paranoiaque, mais il couvre tous les cas. Le flag cancelled protégé contre les réponses en cache qui arrivent de manière synchrone (avant que le cleanup puisse appeler abort()).
Détecter les race conditions
Les race conditions sont difficiles a trouver parce qu'elles sont intermittentes. Quelques indices :
- L'interface affiche brievement des donnees incorrectes puis se corrige (ou pas)
- Un bug qui ne se reproduit que "quand on clique vite"
- Des warnings React "setState on unmounted component"
- Des résultats de test instables (flaky tests)
Mon approche : chaque fois que j'ecris un await dans un handler d'événement ou un useEffect, je me demande "que se passe-t-il si cette fonction est appelee une deuxieme fois avant que le premier await se resolve ?" Si la réponse est "ca casse", j'ajoute un mecanisme de protection.
Résumé
- JavaScript est single-thread mais a des race conditions via l'asynchrone
- Les réponses réseau arrivent dans un ordre imprevisible
- Les stale closures capturent des valeurs obsolètes
- AbortController annule les requêtes précédentes (solution préférée)
- Les sequence IDs ignorent les réponses obsolètes sans annuler
- Le mutex sérialisé les opérations concurrentes
- En React, combine abort + cleanup dans useEffect
Article précédent : 10 - AbortController Article suivant : 12 - Contrôle de concurrence