Jour 37 Day 37 · vendredi 25 septembre 2026 Friday 25 September 2026 CS Avancé
Concurrence : threads, race conditions & locks Concurrency: threads, race conditions & locks
Pourquoi `compteur++` perd des incréments, comment un mutex répare ça, quand un deadlock survient, et le critère qui tranche entre async et threads — le sujet système le plus discriminant en entretien. Why `counter++` loses increments, how a mutex fixes it, when deadlock strikes, and the one criterion that settles async vs threads — the most discriminating systems topic in interviews.
L’essentiel
Un processus est un programme en cours d’exécution avec son propre espace mémoire, isolé des autres par l’OS. Un thread est un fil d’exécution à l’intérieur d’un processus : tous les threads d’un même processus partagent le tas (heap) — variables globales, objets, buffers — mais chacun garde sa propre pile (stack) et ses propres registres. Communiquer entre threads est gratuit (même mémoire) ; entre processus, c’est explicite (pipes, sockets, mémoire partagée).
Ce partage est à double tranchant : il rend les threads légers et rapides à faire coopérer, et il est la source de tous les bugs de concurrence.
Deux notions à ne jamais confondre :
- Concurrence — gérer plusieurs tâches sur la même période ; elles peuvent s’entrelacer sur un seul cœur. Une question de structure du programme.
- Parallélisme — exécuter plusieurs tâches au même instant, sur plusieurs cœurs. Une question de matériel.
Node.js est massivement concurrent avec un seul thread (zéro parallélisme côté JS) ; un calcul découpé sur 8 cœurs est parallèle sans être spécialement concurrent.
🎤 En entretien — « c’est quoi une race condition ? » Réponse modèle en trois temps : « Un bug où le résultat dépend de l’ordre d’exécution de threads non synchronisés. Exemple canonique : deux threads font
compteur++en même temps ; comme l’incrément est en réalité trois instructions (load, add, store), un entrelacement peut perdre une écriture. On corrige avec un mutex autour de la section critique, ou avec un atomic. » Vous avez défini, illustré et résolu en trente secondes.
Comment ça marche
compteur++ déroulé instruction par instruction
compteur++ a l’air d’une opération unique. Pour le processeur, c’en est trois : lire la valeur en mémoire vers un registre, incrémenter le registre, réécrire le résultat en mémoire. L’ordonnanceur peut suspendre un thread entre n’importe lesquelles de ces instructions :
Thread A Thread B compteur
──────── ──────── ────────
LOAD rA ← compteur(0) 0
LOAD rB ← compteur(0) 0
ADD rA ← rA+1 0
ADD rB ← rB+1 0
STORE compteur ← rA 1
STORE compteur ← rB 1 ✗
Deux threads ont incrémenté, le compteur ne vaut que 1 : B travaillait sur une valeur périmée et a écrasé l’écriture de A. Sur 2 × 100 000 incréments, le résultat final varie d’une exécution à l’autre — ce non-déterminisme est ce qui rend ces bugs si durs à reproduire.
Section critique et mutex
La portion de code qui lit-modifie-écrit l’état partagé est une section critique. Un mutex (mutual exclusion, un lock) garantit qu’un seul thread à la fois l’exécute : les autres bloquent à l’entrée jusqu’à la libération du verrou.
import threading
counter = 0
lock = threading.Lock()
def worker_unsafe():
global counter
for _ in range(100_000):
counter += 1 # RACE : load + add + store
def worker_safe():
global counter
for _ in range(100_000):
with lock: # un seul thread entre ici à la fois
counter += 1 # section critique protégée
# le `with` libère le lock même en cas d'exception
💡 Règle d’or — un lock protège des données, pas du code : chaque accès à l’état partagé (lectures comprises) doit passer par le même verrou. Un seul accès oublié, et la race est de retour.
Deadlock : l’étreinte mortelle
Thread A tient le lock 1 et attend le lock 2 ; thread B tient le 2 et attend le 1 : plus personne n’avance, pour toujours. Un deadlock exige quatre conditions simultanées (conditions de Coffman) : exclusion mutuelle, rétention et attente (hold & wait), pas de préemption des verrous, attente circulaire. En casser une seule suffit — et la plus simple à casser en pratique est la dernière : toujours acquérir les locks dans le même ordre global (par identifiant croissant, par exemple). Autres armes : timeout à l’acquisition (try_lock), ou ne jamais tenir deux locks à la fois.
Concepts clés à maîtriser
- Atomics — instructions matérielles indivisibles (
fetch_add, compare-and-swap) : pour un simple compteur,std::atomic<int>ouAtomicIntegerest plus léger qu’un mutex — pas de blocage, pas de deadlock possible. - Message passing — au lieu de partager la mémoire, les tâches s’envoient des messages (channels Go, acteurs Erlang/Akka) : « don’t communicate by sharing memory; share memory by communicating ». Pas d’état partagé → pas de race sur cet état.
- Event loop JavaScript — un seul thread exécute le JS : deux callbacks ne tournent jamais en même temps, donc aucune race sur les variables. La concurrence vient de l’entrelacement entre callbacks — les races logiques restent possibles (deux réponses HTTP qui arrivent dans le désordre).
- GIL Python — un verrou global fait qu’un seul thread exécute du bytecode Python à la fois : les threads Python conviennent à l’I/O (le GIL est relâché pendant les attentes) mais n’apportent rien au calcul pur, d’où
multiprocessing. - Async vs threads — LE critère : où le temps passe-t-il ?
| Charge | Exemples | Outil adapté |
|---|---|---|
| I/O-bound (on attend) | appels API, DB, fichiers, réseau | async/await + event loop, ou threads (GIL inclus) |
| CPU-bound (on calcule) | encodage, crypto, ML, gros parsing | multiprocessing / threads natifs, ≈ 1 par cœur |
| Mixte | serveur web + calculs lourds | event loop + pool de workers |
L’async n’accélère aucun calcul : il permet d’attendre des milliers d’I/O sans bloquer un thread par attente. Le parallélisme CPU, lui, exige plusieurs cœurs réellement utilisés.
En entretien
« Différence entre un processus et un thread ? » — Le processus a son espace mémoire propre, isolé par l’OS ; le thread vit dans un processus et partage son heap avec les autres threads (piles séparées). Threads : création et communication peu coûteuses, mais synchronisation obligatoire. Processus : isolation forte, communication explicite (IPC), crash contenu.
« Pourquoi compteur++ n’est-il pas thread-safe ? » — Parce que c’est trois instructions (load, add, store) et que l’ordonnanceur peut intercaler un autre thread entre elles : deux threads lisent la même valeur, l’un écrase l’écriture de l’autre. Fix : mutex autour de l’opération, ou incrément atomique.
« C’est quoi un deadlock, et comment l’éviter ? » — Une attente circulaire de verrous : A tient L1 et attend L2, B tient L2 et attend L1. Quatre conditions de Coffman ; il suffit d’en casser une. Le remède le plus courant : imposer un ordre global d’acquisition des locks. Sinon : timeouts, ou un seul lock à la fois.
« Async/await ou threads : comment tu choisis ? » — Selon la nature de la charge. I/O-bound → async (des milliers de connexions en attente sur un seul thread) ; CPU-bound → processus ou threads natifs pour occuper les cœurs. En Python, le GIL rend le critère encore plus tranché : threads pour l’I/O, multiprocessing pour le CPU.
« Concurrence et parallélisme, c’est pareil ? » — Non : la concurrence structure un programme en tâches qui progressent sur la même période (possible sur un seul cœur) ; le parallélisme les exécute physiquement en même temps (plusieurs cœurs). On peut avoir l’un sans l’autre.
Pièges & idées reçues
⚠️ Le bug qui disparaît en debug — une race dépend du timing : ajouter un
go test -race, jstack/async-profiler côté JVM.
- « Plus de threads = plus rapide » — faux au-delà du nombre de cœurs pour du CPU-bound : le context switching peut même ralentir. Et pour l’I/O massif, un event loop bat 10 000 threads.
- « Une lecture n’a pas besoin de lock » — une lecture concurrente d’une écriture non protégée est déjà une data race : valeur périmée ou déchirée, comportement indéfini en C/C++.
sleep()comme synchronisation — attendre 100 ms « pour laisser le temps » ne supprime pas la race, elle la rend juste plus rare. Utiliserjoin, events, barrières.- Check-then-act —
if not exists: create()sans verrou est une race classique (TOCTOU), même si chaque opération est atomique individuellement.
Pour aller plus loin
- Rob Pike — Concurrency is not Parallelism : la distinction, en 30 minutes
- OSTEP — partie Concurrency : threads, locks, sémaphores — gratuit et limpide
- The Little Book of Semaphores : des dizaines de puzzles de synchronisation corrigés
- MDN — The event loop : le modèle d’exécution de JavaScript
- Reproduire la race soi-même : deux threads Python sur
counter += 1× 100 000, et constater le résultat
The essentials
A process is a running program with its own memory space, isolated from others by the OS. A thread is an execution flow inside a process: all threads of the same process share the heap — globals, objects, buffers — but each keeps its own stack and registers. Communicating between threads is free (same memory); between processes it’s explicit (pipes, sockets, shared memory).
That sharing cuts both ways: it makes threads cheap to create and coordinate, and it is the source of every concurrency bug.
Two notions never to confuse:
- Concurrency — dealing with several tasks over the same period; they may interleave on a single core. A matter of program structure.
- Parallelism — executing several tasks at the same instant, on several cores. A matter of hardware.
Node.js is massively concurrent with a single thread (zero JS-side parallelism); a computation split across 8 cores is parallel without being particularly concurrent.
🎤 In an interview — “what’s a race condition?” Model answer in three beats: “A bug where the result depends on the execution order of unsynchronized threads. Canonical example: two threads run
counter++at the same time; since the increment is actually three instructions (load, add, store), an interleaving can lose a write. You fix it with a mutex around the critical section, or an atomic.” You’ve defined, illustrated and solved it in thirty seconds.
How it works
counter++ unrolled instruction by instruction
counter++ looks like a single operation. To the CPU it’s three: read the value from memory into a register, increment the register, write the result back. The scheduler can suspend a thread between any of these instructions:
Thread A Thread B counter
──────── ──────── ───────
LOAD rA ← counter(0) 0
LOAD rB ← counter(0) 0
ADD rA ← rA+1 0
ADD rB ← rB+1 0
STORE counter ← rA 1
STORE counter ← rB 1 ✗
Two threads incremented, the counter is only 1: B was working on a stale value and clobbered A’s write. Over 2 × 100,000 increments the final result changes from run to run — that non-determinism is what makes these bugs so hard to reproduce.
Critical section and mutex
The code portion that reads-modifies-writes shared state is a critical section. A mutex (mutual exclusion, a lock) guarantees only one thread at a time executes it: the others block at the entrance until the lock is released.
import threading
counter = 0
lock = threading.Lock()
def worker_unsafe():
global counter
for _ in range(100_000):
counter += 1 # RACE: load + add + store
def worker_safe():
global counter
for _ in range(100_000):
with lock: # only one thread enters at a time
counter += 1 # protected critical section
# `with` releases the lock even if an exception is raised
💡 Golden rule — a lock protects data, not code: every access to the shared state (reads included) must go through the same lock. Miss a single access, and the race is back.
Deadlock: the deadly embrace
Thread A holds lock 1 and waits for lock 2; thread B holds 2 and waits for 1: nobody moves, forever. A deadlock requires four simultaneous conditions (the Coffman conditions): mutual exclusion, hold & wait, no preemption of locks, circular wait. Breaking a single one is enough — and the easiest to break in practice is the last: always acquire locks in the same global order (by increasing id, for instance). Other weapons: acquisition timeouts (try_lock), or never holding two locks at once.
Key concepts to master
- Atomics — indivisible hardware instructions (
fetch_add, compare-and-swap): for a simple counter,std::atomic<int>orAtomicIntegeris lighter than a mutex — no blocking, no deadlock possible. - Message passing — instead of sharing memory, tasks send each other messages (Go channels, Erlang/Akka actors): “don’t communicate by sharing memory; share memory by communicating”. No shared state → no race on that state.
- JavaScript event loop — a single thread runs the JS: two callbacks never run at the same time, so no race on variables. Concurrency comes from the interleaving between callbacks — logical races remain possible (two HTTP responses arriving out of order).
- Python’s GIL — a global lock means only one thread executes Python bytecode at a time: Python threads are fine for I/O (the GIL is released while waiting) but useless for pure computation, hence
multiprocessing. - Async vs threads — THE criterion: where does the time go?
| Workload | Examples | Right tool |
|---|---|---|
| I/O-bound (waiting) | API calls, DB, files, network | async/await + event loop, or threads (GIL included) |
| CPU-bound (computing) | encoding, crypto, ML, heavy parsing | multiprocessing / native threads, ≈ 1 per core |
| Mixed | web server + heavy computation | event loop + worker pool |
Async speeds up no computation: it lets you wait on thousands of I/O operations without dedicating a thread per wait. CPU parallelism, on the other hand, requires actually using multiple cores.
In an interview
“What’s the difference between a process and a thread?” — A process has its own memory space, isolated by the OS; a thread lives inside a process and shares its heap with the other threads (separate stacks). Threads: cheap creation and communication, but mandatory synchronization. Processes: strong isolation, explicit communication (IPC), contained crashes.
“Why isn’t counter++ thread-safe?” — Because it’s three instructions (load, add, store) and the scheduler can interleave another thread between them: two threads read the same value, one clobbers the other’s write. Fix: a mutex around the operation, or an atomic increment.
“What’s a deadlock, and how do you avoid it?” — A circular wait on locks: A holds L1 and waits for L2, B holds L2 and waits for L1. Four Coffman conditions; breaking one is enough. Most common remedy: impose a global lock-acquisition order. Otherwise: timeouts, or one lock at a time.
“Async/await or threads: how do you choose?” — By the nature of the workload. I/O-bound → async (thousands of waiting connections on a single thread); CPU-bound → processes or native threads to keep the cores busy. In Python the GIL makes the criterion even sharper: threads for I/O, multiprocessing for CPU.
“Are concurrency and parallelism the same thing?” — No: concurrency structures a program into tasks that make progress over the same period (possible on one core); parallelism physically executes them at the same time (several cores). You can have either without the other.
Pitfalls & misconceptions
⚠️ The bug that vanishes in debug — a race depends on timing: adding a
go test -race, jstack/async-profiler on the JVM.
- “More threads = faster” — false beyond the core count for CPU-bound work: context switching can even slow you down. And for massive I/O, one event loop beats 10,000 threads.
- “A read doesn’t need a lock” — a read concurrent with an unprotected write is already a data race: stale or torn value, undefined behavior in C/C++.
sleep()as synchronization — waiting 100 ms “to leave enough time” doesn’t remove the race, it just makes it rarer. Usejoin, events, barriers.- Check-then-act —
if not exists: create()without a lock is a classic race (TOCTOU), even when each operation is individually atomic.
Going further
- Rob Pike — Concurrency is not Parallelism: the distinction, in 30 minutes
- OSTEP — the Concurrency chapters: threads, locks, semaphores — free and crystal clear
- The Little Book of Semaphores: dozens of solved synchronization puzzles
- MDN — The event loop: JavaScript’s execution model
- Reproduce the race yourself: two Python threads doing
counter += 1× 100,000, and look at the result