Clean Code
Chapitre 13 / 14 · 15 min de lecture

La concurrence

Pourquoi le code concurrent est difficile, les principes de défense, et les modèles d'exécution classiques.

Un programme mono-thread est rassurant : à tout instant, l'état complet de l'application se lit dans la pile d'appels. Posez un point d'arrêt, examinez la pile, et vous savez exactement où vous en êtes. Ajoutez un second fil d'exécution, et cette belle certitude s'évapore. Le même bout de code peut désormais s'exécuter selon des milliers de chemins différents, dont une poignée produit silencieusement de mauvais résultats — une fois sur mille, ou une fois sur un million.

La concurrence est donc une stratégie de découplage : elle sépare le quoi (ce qui doit être fait) du quand (le moment où ça se fait). Bien employée, elle améliore le débit et la réactivité d'un système. Mal employée — et il est facile de mal l'employer — elle engendre des bugs parmi les plus pénibles du métier : sporadiques, non reproductibles, et trop souvent classés à tort comme « anomalies cosmiques ». Ce chapitre explique pourquoi la concurrence est difficile, et surtout comment écrire du code concurrent propre, défendable et testable. Le livre raisonne en Java ; nous transposerons en TypeScript, dont le modèle d'exécution mérite ici un éclairage particulier.

Pourquoi adopter la concurrence ?

Découpler le quoi du quand change radicalement la structure d'une application. Au lieu d'une grande boucle principale, on obtient une nuée de petits collaborateurs autonomes, plus faciles à raisonner séparément. Le modèle des serveurs web l'illustre : chaque requête entrante est traitée comme un petit monde isolé, et le développeur n'a pas à orchestrer manuellement les milliers de requêtes simultanées.

Mais la structure n'est pas le seul mobile. Certaines contraintes de débit et de temps de réponse imposent la concurrence :

  • Un agrégateur qui interroge des centaines de sites en série finit par dépasser ses 24 heures de fenêtre. L'essentiel de ce temps est de l'attente d'entrées/sorties — du temps mort que plusieurs requêtes parallèles pourraient se partager.
  • Un service qui traite un utilisateur à la fois, à raison d'une seconde chacun, devient inacceptable dès la 150e personne dans la file. Traiter plusieurs utilisateurs de front rétablit la réactivité.
  • Un calcul qui doit consommer un énorme jeu de données peut le découper et le répartir sur plusieurs machines en parallèle.

Note

La concurrence brille surtout quand il existe du temps d'attente à mutualiser : appels réseau, accès disque, requêtes en base. C'est exactement le terrain de jeu de JavaScript, dont la boucle d'événements est née pour ne jamais bloquer sur une entrée/sortie.

Mythes et idées fausses

Avant les principes, déminons trois croyances tenaces.

Idée reçueRéalité
« La concurrence améliore toujours les performances. »Seulement quand il y a du temps d'attente à partager — et ce n'est jamais trivial. Sinon, le surcoût domine.
« Le design ne change pas en concurrent. »Le design d'un algorithme concurrent diffère souvent profondément de sa version mono-thread.
« Avec un conteneur (web, ORM, pool), inutile de comprendre la concurrence. »Vous feriez mieux de savoir ce que fait votre conteneur, et comment vous prémunir des mises à jour concurrentes et des interblocages.

Quelques vérités plus mesurées tiennent lieu de garde-fous :

  • La concurrence a un coût, en performances comme en code supplémentaire.
  • Une concurrence correcte est complexe, même pour des problèmes simples.
  • Ses bugs ne sont généralement pas reproductibles — d'où la tentation de les ignorer comme des accidents isolés, alors que ce sont de vrais défauts.
  • Elle exige souvent un changement de fond de la stratégie de conception.

Le cœur du problème : un seul chemin fautif suffit

Considérez cette classe d'apparence triviale, traduite en TypeScript :

class GenerateurId {
  private dernierId = 42;

  prochainId(): number {
    return ++this.dernierId; // une seule ligne... vraiment ?
  }
}

Partagez cette instance entre deux fils d'exécution qui appellent prochainId() en même temps. Trois issues sont possibles :

  • Le fil 1 obtient 43, le fil 2 obtient 44, dernierId vaut 44 (correct).
  • Le fil 1 obtient 44, le fil 2 obtient 43, dernierId vaut 44 (correct).
  • Le fil 1 et le fil 2 obtiennent 43, dernierId vaut 43 (faux !).

Ce troisième résultat survient quand les deux fils se marchent dessus. Pourquoi ? Parce que ++this.dernierId n'est pas atomique : c'est en réalité « lire, incrémenter, écrire ». Au niveau du bytecode, ces deux fils empruntent 12 870 chemins d'exécution distincts à travers cette seule ligne. Changez le type de l'entier pour un type plus large et le nombre grimpe à 2 704 156. La quasi-totalité produit le bon résultat. Le drame, c'est que quelques-uns ne le produisent pas — et il suffit d'un.

Attention

Une condition de course (race condition) ne se déclenche que sur une fraction infime des chemins possibles. La probabilité d'emprunter le chemin fautif peut être bouleversante de petitesse. C'est précisément ce qui rend ces bugs invisibles en test et dévastateurs en production.

Les principes de défense

Face à ce danger, la stratégie n'est pas l'astuce mais la discipline. Voici les principes que recommande le livre.

Appliquer le principe de responsabilité unique (SRP)

Le code lié à la concurrence a son propre cycle de vie de développement, de modification et de réglage. Il porte ses propres défis, distincts et plus ardus que ceux du code ordinaire. Le mélanger au code métier multiplie les façons d'échouer.

Gardez votre code lié à la concurrence séparé du reste du code.

Concrètement, isolez la mécanique des fils d'exécution dans des classes dédiées, et laissez votre logique métier dans des objets simples, ignorants de tout aspect concurrent.

// ❌ Avant : la logique métier connaît les détails de concurrence
class ProcesseurCommandes {
  private mutex = new Mutex();
  private file: Commande[] = [];

  async traiter(commande: Commande): Promise<void> {
    await this.mutex.lock();
    try {
      this.file.push(commande);
      this.calculerRemise(commande); // métier noyé dans la plomberie
    } finally {
      this.mutex.unlock();
    }
  }
}
// ✅ Après : le métier est un objet simple, testable hors concurrence
class CalculateurRemise {
  appliquer(commande: Commande): Commande {
    // logique pure, aucun verrou, aucun fil
    return { ...commande, remise: commande.total * 0.1 };
  }
}

// La concurrence vit dans une classe à part qui orchestre l'objet simple
class OrdonnanceurCommandes {
  constructor(private readonly calc: CalculateurRemise) {}
  // ... gère files, verrous, signalisation ici, et seulement ici
}

Corollaire : limiter la portée des données

Deux fils qui modifient le même champ d'un objet partagé interfèrent. La parade consiste à protéger les sections critiques par des verrous — mais il faut en restreindre le nombre. Plus les endroits où une donnée partagée peut être modifiée sont nombreux, plus vous risquez :

  • d'oublier d'en protéger un, ce qui casse tout le code qui touche cette donnée ;
  • de dupliquer l'effort de protection (violation de DRY) ;
  • de ne plus savoir d'où vient une panne déjà difficile à localiser.

Prenez l'encapsulation des données au sérieux : restreignez sévèrement l'accès à toute donnée susceptible d'être partagée.

Corollaire : utiliser des copies de données

Le meilleur moyen d'éviter une donnée partagée, c'est de ne pas la partager du tout. Souvent, on peut copier les objets et les traiter en lecture seule. Dans d'autres cas, chaque fil travaille sur sa propre copie, collecte ses résultats, puis un unique fil fusionne le tout à la fin.

// Données immuables : impossible de se marcher dessus
type Panier = Readonly<{
  articles: readonly Article[];
  total: number;
}>;

function ajouterArticle(panier: Panier, article: Article): Panier {
  // on renvoie une NOUVELLE valeur, on ne mute jamais l'ancienne
  return {
    articles: [...panier.articles, article],
    total: panier.total + article.prix,
  };
}

On s'inquiète parfois du coût des créations d'objets supplémentaires. Mesurez avant de conclure : si la copie permet de supprimer la synchronisation, l'économie des verrous compense largement le surcoût d'allocation.

Corollaire : rendre les fils aussi indépendants que possible

Écrivez votre code de sorte que chaque fil vive dans son propre monde, sans aucune donnée partagée. Chaque fil traite une requête, toutes ses données provenant d'une source non partagée et stockées en variables locales. Il se comporte alors comme s'il était seul au monde — plus aucun besoin de synchronisation.

Cherchez à partitionner les données en sous-ensembles indépendants, traités par des fils indépendants, éventuellement sur des processeurs différents.

Astuce

En pratique, la règle d'or se résume ainsi : pas de mutation partagée. Soit la donnée est locale au fil, soit elle est immuable, soit elle est confiée à une structure conçue pour la concurrence. Tout le reste est une condition de course en sursis.

Connaître sa bibliothèque

Ne réinventez pas les primitives de concurrence : elles sont notoirement difficiles à écrire correctement. Apprenez ce que votre plateforme offre.

  • Utilisez les collections thread-safe fournies.
  • Utilisez un framework d'exécuteurs (pool de tâches) pour les travaux indépendants.
  • Préférez les solutions non bloquantes quand c'est possible.
  • Sachez que plusieurs classes de la bibliothèque ne sont pas thread-safe.

Le livre cite le paquet java.util.concurrent (avec ConcurrentHashMap, plus rapide que HashMap dans presque tous les cas, et des verrous évolués comme ReentrantLock, Semaphore, CountDownLatch). En TypeScript côté navigateur ou Node, les équivalents sont les Web Workers / worker_threads, les SharedArrayBuffer couplés aux opérations Atomics, et des files asynchrones, pour lesquelles on s'appuie souvent sur des bibliothèques éprouvées plutôt que de les écrire soi-même.

Passez en revue les classes à votre disposition. Familiarisez-vous avec les outils de concurrence de votre environnement avant d'en bricoler.

Connaître les modèles d'exécution

Quelques définitions d'abord, car elles reviennent partout.

TermeDéfinition
Ressource bornéeRessource en nombre fixe (connexions en base, buffers de taille fixe).
Exclusion mutuelleUn seul fil accède à la donnée ou ressource partagée à la fois.
Famine (starvation)Un fil est empêché de progresser trop longtemps, voire indéfiniment.
Interblocage (deadlock)Deux fils s'attendent mutuellement, chacun détenant ce que l'autre réclame.
Verrou vivant (livelock)Des fils s'agitent en cadence sans jamais réussir à progresser.

L'écrasante majorité des problèmes concurrents que vous rencontrerez sont des variations de trois modèles classiques.

Producteur-consommateur

Un ou plusieurs fils producteurs créent du travail et le déposent dans une file ; un ou plusieurs consommateurs le retirent et le traitent. La file est une ressource bornée : les producteurs attendent qu'il y ait de la place, les consommateurs attendent qu'il y ait de quoi consommer. Les deux se signalent mutuellement : le producteur signale « la file n'est plus vide », le consommateur signale « la file n'est plus pleine ».

// Une file asynchrone : le consommateur attend sans bloquer le thread
class FileAsync<T> {
  private elements: T[] = [];
  private enAttente: ((valeur: T) => void)[] = [];

  produire(element: T): void {
    const consommateur = this.enAttente.shift();
    if (consommateur) consommateur(element); // réveille un consommateur
    else this.elements.push(element);
  }

  consommer(): Promise<T> {
    const dispo = this.elements.shift();
    if (dispo !== undefined) return Promise.resolve(dispo);
    // sinon, on s'enregistre et on attend un signal du producteur
    return new Promise((resoudre) => this.enAttente.push(resoudre));
  }
}

Lecteurs-écrivains

Une ressource sert surtout de source d'information pour des lecteurs, mais des écrivains la mettent occasionnellement à jour. Tout l'art consiste à équilibrer : trop privilégier le débit des lecteurs affame les écrivains et laisse traîner de l'information périmée ; trop privilégier les écrivains effondre le débit, car un écrivain bloque longtemps de nombreux lecteurs. Il faut coordonner pour qu'aucun lecteur ne lise une donnée qu'un écrivain est en train de modifier — sans pour autant provoquer de famine.

Le dîner des philosophes

Des philosophes sont assis autour d'une table, une fourchette à gauche de chacun, un grand plat de spaghettis au centre. Pour manger, un philosophe doit saisir les deux fourchettes qui l'entourent ; sinon il attend. Remplacez les philosophes par des fils et les fourchettes par des ressources, et vous tenez le portrait de nombreuses applications où des processus se disputent des ressources. Mal conçus, ces systèmes connaissent interblocage, verrou vivant et effondrement du débit.

Apprenez ces algorithmes de base et comprenez leurs solutions. La plupart de vos problèmes concurrents en seront des variantes.

Verrous : moins, plus petits, sans dépendances

Méfiez-vous des dépendances entre méthodes synchronisées

Les dépendances entre méthodes synchronisées créent des bugs subtils. Protéger individuellement chaque méthode d'un objet partagé ne suffit pas : si un client en appelle deux à la suite, un autre fil peut s'intercaler entre les deux et casser l'invariant.

Évitez d'appeler plus d'une méthode sur un objet partagé.

Quand c'est inévitable, trois remèdes existent :

  • Verrouillage côté client : le client verrouille le serveur avant le premier appel et ne relâche qu'après le dernier.
  • Verrouillage côté serveur : on ajoute au serveur une méthode qui verrouille, enchaîne tous les appels, puis déverrouille. Le client appelle cette méthode unique.
  • Serveur adapté : on intercale un intermédiaire qui se charge du verrouillage, quand le serveur d'origine ne peut être modifié.

Gardez les sections synchronisées petites

Un verrou garantit qu'un seul fil traverse à la fois les sections qu'il protège. Mais un verrou coûte cher : il introduit délais et surcoût. On ne veut donc pas en parsemer le code. À l'inverse, les sections critiques doivent être protégées. La conception vise donc le minimum de sections critiques, chacune aussi petite que possible.

// ❌ Avant : la section critique englobe un calcul coûteux inutile
async function debiterCompte(id: string, montant: number) {
  await verrou.lock();
  try {
    const taux = await recupererTauxDeChange(); // I/O lente, hors critique !
    const compte = comptes.get(id)!;
    compte.solde -= montant * taux;
  } finally {
    verrou.unlock();
  }
}
// ✅ Après : on ne verrouille que la mutation partagée
async function debiterCompte(id: string, montant: number) {
  const taux = await recupererTauxDeChange(); // calcul hors verrou
  await verrou.lock();
  try {
    const compte = comptes.get(id)!;
    compte.solde -= montant * taux; // section critique minimale
  } finally {
    verrou.unlock();
  }
}

Piège courant

Les programmeurs naïfs croient réduire le nombre de sections critiques en les rendant énormes. C'est l'erreur inverse : étendre la synchronisation au-delà du strict nécessaire augmente la contention et dégrade les performances. Petites et rares, pas grandes et rares.

L'arrêt propre est plus difficile qu'il n'y paraît

Écrire un système censé tourner indéfiniment diffère d'un système qui s'arrête proprement. Imaginez un fil parent qui lance plusieurs enfants puis attend qu'ils finissent tous : si l'un est en interblocage, le parent attend pour toujours. Ou un couple producteur/consommateur où le producteur reçoit le signal d'arrêt et s'éteint aussitôt, laissant le consommateur bloqué à attendre un message qui ne viendra jamais.

Pensez à l'arrêt tôt et faites-le fonctionner tôt. Cela prendra plus de temps que prévu.

Tester du code concurrent

Prouver qu'un code est correct est impraticable ; tester ne garantit pas la correction, mais minimise le risque. Avec des fils partagés, tout se complique. La recommandation de base tient en une phrase :

Écrivez des tests capables d'exposer les problèmes, exécutez-les souvent, sous des configurations et des charges variées, et traquez toute panne — n'ignorez jamais un échec sous prétexte qu'il ne s'est pas reproduit.

Voici les recommandations fines du livre.

  • Traitez les pannes sporadiques comme de vrais bugs. Le code concurrent fait échouer des choses « qui ne peuvent pas échouer ». N'invoquez ni rayon cosmique ni accident matériel : partez du principe que les « anomalies isolées » n'existent pas.
  • Faites d'abord marcher le code non concurrent. Isolez votre logique métier dans des objets simples, testables hors de tout fil. Ne chassez jamais un bug métier et un bug de concurrence en même temps.
  • Rendez le code concurrent enfichable (pluggable) : exécutable en un fil, plusieurs fils, ou un nombre variable ; capable de dialoguer avec un vrai composant ou un double de test ; configurable sur un nombre d'itérations.
  • Rendez-le réglable (tunable) : le bon nombre de fils s'obtient par essais et erreurs. Prévoyez de mesurer le débit sous diverses configurations, voire de l'auto-régler à chaud.
  • Lancez plus de fils que de processeurs. Forcer les bascules de tâches augmente les chances de révéler une section critique manquante ou un interblocage.
  • Variez les plateformes. Les systèmes d'exploitation ont des politiques d'ordonnancement différentes ; un code fautif échoue beaucoup plus souvent sur l'un que sur l'autre. Testez sur tous vos environnements cibles, tôt et souvent.

Provoquer les pannes en « secouant » le code

Comme les défauts de concurrence se cachent, on peut forcer des ordonnancements différents en insérant des points de bascule. Le livre montre l'idée d'une classe ThreadJigglePoint.jiggle() dont une implémentation ne fait rien (en production) et l'autre choisit au hasard entre dormir, céder la main, ou ne rien faire (en test). Mille exécutions avec ce « secouage » aléatoire débusquent des défauts que les tests ordinaires ne verraient jamais.

Secouez le code pour que les fils s'exécutent dans des ordres différents à des moments différents. Combiné à de bons tests, cela augmente nettement les chances de trouver les erreurs.

Le cas particulier de JavaScript et TypeScript

Le livre raisonne sur des fils OS préemptifs (Java). JavaScript adopte un modèle différent mais pas plus simple : un seul fil principal et une boucle d'événements. Tant qu'une fonction synchrone s'exécute, rien d'autre ne tourne — pas de préemption au milieu d'une expression. Le « bug du ++ » du début est donc impossible sur le fil principal.

Mais les conditions de course reviennent dès qu'apparaît un await : entre deux await, la boucle d'événements peut exécuter du tout autre code, qui modifie l'état que vous croyiez stable.

let soldeEnCache: number | null = null;

// ❌ Course en async : deux appels entrelacés autour du même await
async function appliquerBonus(montant: number) {
  if (soldeEnCache === null) {
    soldeEnCache = await chargerSolde(); // un autre appel s'intercale ici
  }
  soldeEnCache += montant; // peut écraser ou doubler une mise à jour
}
// ✅ On mémorise la promesse, pas la valeur : un seul chargement réel
let chargement: Promise<number> | null = null;

async function soldeAJour(): Promise<number> {
  chargement ??= chargerSolde(); // déduplique les appels concurrents
  return chargement;
}

Pour du vrai parallélisme (calcul intensif), JavaScript propose les Web Workers (navigateur) et worker_threads (Node), qui s'exécutent dans des contextes isolés communiquant par messages — l'incarnation parfaite du principe « fils indépendants, aucune donnée partagée ». Lorsqu'un partage de mémoire est réellement nécessaire, SharedArrayBuffer et les opérations Atomics rétablissent toutes les difficultés du livre : exclusion mutuelle, interblocages, sections critiques. On retombe alors, à la lettre, sur les principes de défense exposés ici.

À retenir

Le modèle mono-thread de JavaScript vous épargne les courses sur les opérations synchrones, mais pas celles introduites par await, ni celles de la mémoire partagée entre workers. Les principes de ce chapitre restent pleinement valables.

À retenir

  • La concurrence découple le quoi du quand : elle améliore débit et réactivité, mais seulement quand il y a du temps d'attente à mutualiser — jamais gratuitement.
  • Une condition de course n'échoue que sur de rares chemins parmi des milliers : ces bugs sont sporadiques, non reproductibles, et n'en sont pas moins réels.
  • Défendez-vous par la discipline : SRP (isoler le code concurrent), portée minimale des données, copies immuables, et fils aussi indépendants que possible.
  • Gardez les sections synchronisées petites et rares, évitez les dépendances entre méthodes verrouillées, et prévoyez l'arrêt propre dès le début.
  • Connaissez les modèles classiques (producteur-consommateur, lecteurs-écrivains, dîner des philosophes) et les outils de votre bibliothèque.
  • Testez agressivement : traitez chaque panne sporadique comme un vrai bug, rendez le code enfichable et réglable, variez fils et plateformes, et « secouez » l'exécution pour forcer les défauts à se révéler.