Deux requêtes arrivent sur le même endpoint “retirer 100 €” à un demi-milliseconde d’intervalle. Le compte contient 150 €. Les deux requêtes lisent ce solde, les deux voient des fonds suffisants, les deux approuvent, les deux soustraient 100 €. Le compte termine à -50 €, et aucune branche du code n’aurait dû permettre cela. C’est une race condition : le résultat dépend de l’opération qui termine la première, et “la première” n’a jamais été quelque chose que le code a décidé.
Qu’est-ce qui cause une race condition ?
Il faut deux ingrédients à la fois : un état mutable partagé, et plus d’un chemin d’exécution qui le touche sans coordination. Les chemins peuvent être des threads, des tâches async, des processus, ou deux serveurs qui tapent sur la même ligne de base de données. Retirez l’un des deux ingrédients et le bug disparaît. Un seul thread ne peut pas entrer en compétition avec lui-même, et un état immuable ne peut pas être corrompu par une double lecture.
La forme classique est le check-then-act : vous lisez une valeur, vous décidez quelque chose sur cette base, puis vous agissez sur cette décision. Si un autre chemin exécute sa propre séquence lire-décider-agir dans l’intervalle entre votre lecture et votre écriture, les deux prennent une décision valable pour un instant qui n’existe plus au moment où l’un des deux écrit.
Un compteur partagé montre le mécanisme à l’état pur. counter += 1 ressemble à une seule opération. Ce sont en réalité trois opérations : lire la valeur actuelle, ajouter un, écrire le résultat. Si deux threads exécutent cette séquence sur un compteur qui part de 0, et que les deux lisent avant que l’un des deux n’écrive, les deux calculent 1 et les deux écrivent 1. Un incrément a disparu. Pas d’exception, pas de ligne de log.
| |
Lancez-le plusieurs fois. L’écart n’est jamais le même, et c’est la deuxième signature d’une race condition : elle ne se reproduit pas de façon fiable, parce qu’elle dépend de l’ordonnancement, qui change d’une exécution à l’autre et d’une machine à l’autre.
Comment corriger une race condition avec un lock ?
Rendez la séquence lire-modifier-écrire atomique : une fois qu’un thread l’a commencée, aucun autre ne doit pouvoir s’y interposer. Un lock s’en charge en n’autorisant qu’un seul thread à le détenir à la fois. Tout autre thread qui appelle acquire() se bloque jusqu’à ce que le détenteur appelle release().
| |
with lock: acquiert à l’entrée et libère à la sortie, y compris quand une exception est levée dans le bloc. Vous ne vous retrouvez jamais avec un lock que personne ne libère.
L’erreur qui maintient le bug en vie même après l’ajout d’un lock : verrouiller l’écriture mais pas la vérification. Cette fonction de dosage a l’air sûre et ne l’est pas :
| |
Deux threads peuvent tous les deux passer la vérification avant que l’un des deux ne prenne le lock. Les deux ajoutent leur quantité, et le total dépasse la limite. Le lock doit englober la vérification et l’action comme une seule section critique, pas seulement la ligne qui a l’air dangereuse :
| |
Avec le GIL de Python, la version étroite fonctionne souvent par hasard. C’est pour ça qu’elle survit à la code review : elle passe tous les tests sur votre laptop, puis échoue sous la charge de production ou le jour où vous passez à une build free-threaded.
Le JavaScript async peut-il avoir des race conditions ?
Oui. JavaScript exécute votre code sur un seul thread dans le navigateur et dans l’exécution principale de Node, mais le même mode de défaillance ressurgit dès que deux await peuvent se terminer dans un ordre que vous n’aviez pas prévu. Un champ de recherche est le cas classique. L’utilisateur tape “chat”, une requête part, il continue à taper “chaton”, une deuxième requête part. Si la première requête est plus lente, sa réponse arrive après la seconde et remplace les résultats récents par des résultats obsolètes.
| |
Chaque appel se voit attribuer un ID avant le moindre await. Quand la réponse revient, elle n’est affichée que si elle correspond toujours à la requête la plus récente. AbortController est l’autre outil courant : il annule carrément la requête obsolète au lieu de la laisser se terminer pour jeter son résultat, ce qui économise aussi l’aller-retour réseau.
La version la plus subtile ne comporte aucun appel réseau, juste une variable partagée qui traverse un await :
| |
Les deux appels lisent totalLength à 0 avant que l’un des deux await ne se résolve. Les deux calculent leur nouveau total à partir du même point de départ obsolète, et la seconde écriture écrase la première. La lecture et l’écriture se trouvent de part et d’autre d’un point de suspension, et n’importe quoi peut s’exécuter pendant la suspension. Si cet ordonnancement ne vous semble pas encore évident, /fr/posts/javascript/async-await-et-event-loop-javascript/ détaille ce que fait l’event loop à chaque await. La correction : résolvez d’abord la valeur et intégrez-la à l’état partagé en une seule étape synchrone (const len = await getPageLength(n); totalLength += len;), ou exécutez les additions en séquence plutôt qu’avec Promise.all.
Comment prévenir les race conditions dans une base de données ?
Déplacez le compteur dans une ligne et l’écart entre check et act s’élargit, car il couvre désormais un aller-retour vers la base de données. Deux serveurs d’application, chacun exécutant “lire le solde, valider, écrire le nouveau solde” sur la même ligne, peuvent tous les deux lire 150 € et tous les deux approuver un retrait de 100 €. C’est l’exemple de départ, au niveau SQL.
Deux solutions, avec des coûts différents.
Le lock pessimiste prend le lock de la ligne en amont. Dans PostgreSQL, c’est SELECT ... FOR UPDATE :
| |
Une deuxième transaction qui tente un SELECT ... FOR UPDATE sur la même ligne se bloque jusqu’à ce que la première fasse un commit ou un rollback. Correct, mais chaque requête concurrente sur cette ligne attend désormais en file derrière le lock. Cela coûte de la latence sous contention, et peut créer un deadlock si deux transactions verrouillent des lignes dans un ordre opposé. Gardez la transaction courte : ici, la ligne est trouvée par clé primaire, donc par une recherche indexée. Filtrez sur une colonne sans index et la recherche devient un scan, qui allonge chaque transaction pendant que les autres attendent (les mécanismes sont détaillés dans /fr/posts/databases/comment-fonctionnent-les-index-de-base-de-donnees/).
Le lock optimiste se passe du lock et détecte un conflit au moment de l’écriture, généralement avec une colonne de version :
| |
Si une autre transaction a déjà mis à jour la ligne, version n’est plus égale à 7, aucune ligne ne correspond, et votre application le détecte et retente avec la valeur fraîche. Cela monte mieux en charge quand les conflits sont rares. Cela ne fonctionne que si la vérification de version et l’écriture ont lieu dans la même instruction. Lisez la version, puis exécutez un UPDATE séparé qui s’appuie sur elle, et vous avez rouvert l’écart que vous essayiez de fermer.
Quelle est la différence entre une race condition et un deadlock ?
Les deux se classent comme “bugs de concurrence”, et ils échouent de façons opposées :
| Race condition | Deadlock | |
|---|---|---|
| Ce qui se passe | L’exécution se termine, avec un résultat faux ou incohérent | L’exécution s’arrête ; rien ne se termine |
| Cause | Accès non synchronisé à un état partagé | Deux locks ou plus attendus dans un cycle |
| Comment le repérer | Des chiffres qui ne tombent pas juste, des données “parfois” fausses | Le programme ou la requête reste bloqué |
| Correction habituelle | Locks, opérations atomiques, ou suppression de l’état mutable partagé | Ordre cohérent des locks, timeouts |
Corriger l’un peut introduire l’autre. Envelopper un bug check-then-act dans un lock élimine la race. Si ce code prend aussi un second lock, dans un ordre différent d’un autre chemin de code, vous avez échangé une corruption silencieuse des données contre un blocage total. Acquérez toujours les locks dans le même ordre, partout, et gardez la section verrouillée aussi courte que la correction le permet.
Comment détecter les race conditions avant la production ?
Les races apparaissent rarement dans un test mono-thread qui tourne seul sur votre laptop. Elles sont une cause fréquente de tests flaky, le type abordé dans /fr/posts/testing/tests-unitaires-vs-integration-vs-end-to-end/. Un test qui échoue une fois sur cinquante sous la charge de la CI et passe chaque fois que vous le relancez en local mérite d’être suspecté avant d’être classé comme “flaky”.
Go embarque un détecteur pour ça. Compilez ou testez avec -race et le runtime instrumente les accès mémoire, signalant chaque cas où deux goroutines touchent la même variable en concurrence et où au moins l’une des deux écrit.
| |
Il ne détecte que les races qui s’exécutent réellement pendant la run, donc plus vos tests se rapprochent d’une charge concurrente réelle, plus il en trouve. Il augmente aussi fortement l’usage CPU et mémoire, donc sa place est en CI, pas dans le binaire de production. Python et Java n’ont rien d’intégré qui l’égale, une raison de plus de s’appuyer sur des locks et des opérations atomiques en base de données plutôt que de tester pour gagner en confiance.
Les caches cachent le même bug. Deux requêtes qui lisent la même clé, trouvent une valeur obsolète puis la mettent à jour, c’est à nouveau du check-then-act, et c’est un écart dont le pattern cache-aside de /fr/posts/backend/cache-redis-fonctionnement/ doit tenir compte.
Avant de recourir à un lock, demandez-vous cependant si l’état mutable partagé a vraiment besoin d’exister. Un solde mis à jour uniquement via une seule instruction atomique comme UPDATE accounts SET balance = balance - 100 WHERE id = 42 AND balance >= 100 n’a aucun écart entre check et act, parce que la base de données fait les deux en une seule étape. La correction la moins coûteuse pour une race condition consiste à supprimer le second chemin qui entrait en compétition avec le premier, donc commencez votre prochaine review en listant chaque endroit qui écrit dans cet état.