2.17

Voir en anglais

2.17 Concurrence et parallélisme

Vue d’ensemble et motivation

La concurrence est l’art de structurer un programme comme des tâches indépendantes qui peuvent progresser sans s’attendre les unes les autres. Le parallélisme consiste à exécuter réellement ces tâches au même instant sur plusieurs processeurs. La distinction n’est pas pédante. La concurrence est une façon d’organiser le code pour qu’un appel réseau lent ne gèle pas tout le programme ; le parallélisme est une façon de terminer un gros calcul plus vite en le répartissant sur des cœurs. Confondre les deux amène les équipes à ajouter des threads en espérant de la vitesse et à ne recevoir que des bugs.

Pour les grandes équipes, ce sujet compte parce que la concurrence est là où la correction meurt discrètement. Un auteur unique écrivant du code monothread peut le raisonner ligne par ligne, mais dès que de nombreux auteurs partagent de la mémoire à travers des threads, le nombre d’entrelacements possibles explose, et un programme qui passe tous les tests peut quand même échouer une fois sur un million sous charge de production, pas avec un plantage bruyant mais avec des données corrompues, des requêtes bloquées, et des incidents que personne ne peut reproduire. Ce chapitre s’appuie sur la vision au niveau du code du chapitre 2.16 (ingénierie de performance) et sur les fondements informatiques du chapitre 2.13, et il alimente les problèmes de coordination du chapitre 3.3 (systèmes distribués), qui est de la concurrence à travers des machines avec la cruauté supplémentaire d’un réseau non fiable.

Pour les entreprises, les bugs de concurrence sont des bugs de débit. Les services à fort trafic vivent ou meurent selon leur capacité à gérer des milliers de requêtes simultanées sans course sur l’état partagé, et un seul compteur non synchronisé peut corrompre un grand livre sous charge. Pour l’administration publique, les enjeux sont la correction et l’auditabilité dans des systèmes qui fonctionnent pendant des décennies et touchent la sécurité, les prestations ou les registres publics. Une course dans un système fiscal ou de santé n’est pas un désagrément ; c’est une mauvaise réponse que quelqu’un devra plus tard expliquer à un organe de contrôle. Dans les deux contextes, l’objectif est le même : faire du chemin sûr la valeur par défaut, pour que les nombreuses personnes qui touchent le code n’aient pas chacune à être experte en concurrence.

Principes clés

  • La concurrence est la structure ; le parallélisme est l’exécution. Décidez de celui dont vous avez réellement besoin avant de recourir aux threads.
  • L’état mutable partagé est l’ennemi. Presque tous les bugs de concurrence remontent à deux tâches touchant les mêmes données changeables.
  • Préférez l’immuabilité et le passage de messages. Les données qui ne peuvent pas changer ne peuvent pas faire l’objet d’une course, et les messages battent la mémoire partagée en sécurité.
  • Le non-déterminisme est la difficulté centrale. Le bug qui apparaît une fois sur mille exécutions est le problème entier, pas un cas limite.
  • Bornez tout. Les files, les nombres de threads et le travail en vol non bornés transforment un pic en panne.
  • Les modèles de haut niveau battent les verrous bruts. Les acteurs, les canaux et la concurrence structurée donnent à de nombreux auteurs une valeur par défaut sûre, et chaque verrou a un coût.
  • Testez les entrelacements, pas seulement le chemin heureux. Les tests déterministes ne peuvent pas attraper un bug qu’un ordre rare révèle seul.

Recommandations

Décider si vous avez besoin de concurrence ou de parallélisme

Commencez par nommer le problème. Si votre service passe la plupart de son temps à attendre (des bases de données, des appels réseau ou du disque), vous avez une charge de travail liée aux entrées-sorties, et la concurrence est la réponse : structurez le code pour que pendant qu’une requête attend, d’autres progressent. Un seul thread avec async/await, ou un petit bassin, peut servir des milliers de requêtes en attente. Si au contraire votre programme est lié au processeur, broyant du calcul avec peu d’attente, alors le parallélisme à travers les cœurs est ce qui achète de la vitesse, et ici le plafond est fixé par la loi d’Amdahl (voir chapitre 2.16) : la fraction série plafonne votre accélération peu importe combien de cœurs vous ajoutez. Mesurez dans quel régime vous êtes avant de concevoir.

Traiter l’état mutable partagé comme l’ennemi

Presque tous les défauts de concurrence se réduisent à la même forme : deux tâches lisent et écrivent les mêmes données changeables sans s’accorder sur un ordre. C’est une situation de compétition, et elle produit des mises à jour perdues, des objets à moitié écrits, et des valeurs qui violent des invariants que le code supposait sûrs. La défense la plus fiable est d’avoir moins d’état mutable partagé. Donnez à chaque tâche ses propres données, passez des copies plutôt que des références, et confinez l’état mutable à un seul propriétaire que les autres atteignent par des messages. Quand vous devez réellement partager, rendez le partage explicite et petit, pour qu’un relecteur puisse voir chaque endroit où l’état est touché.

Préférer l’immuabilité et le passage de messages par défaut

Les données partagées les plus sûres sont des données qui ne peuvent pas changer. Un objet immuable, une fois construit, peut être lu par n’importe quel nombre de threads avec zéro synchronisation, parce qu’il n’y a rien sur quoi faire la course. Faites de l’immuabilité votre valeur par défaut et de la mutabilité l’exception délibérée. Quand des tâches doivent se coordonner, préférez le passage de messages à la mémoire partagée : plutôt que de partager une variable commune, faites en sorte qu’une tâche envoie la valeur à l’autre, ce qui est la philosophie derrière le proverbe Go « ne communiquez pas en partageant la mémoire ; partagez la mémoire en communiquant ». Le passage de messages transforme des bugs invisibles et dépendants de l’ordre en un flux de données explicite et inspectable, et cette clarté vaut presque toujours son coût par message dans du code que beaucoup de gens maintiennent.

Recourir aux modèles de haut niveau avant les verrous bruts

Le verrouillage écrit à la main est correct en principe et désastreux en pratique, parce que les humains sont mauvais pour raisonner sur chaque entrelacement. Préférez des modèles qui font de la concurrence sûre la valeur par défaut. Le modèle acteur donne à chaque acteur un état privé et une boîte aux lettres : les acteurs ne partagent jamais de mémoire et n’envoient que des messages, si bien que des classes entières de course disparaissent. Les processus séquentiels communicants (CSP), le modèle derrière les canaux dans des langages comme Go, font passer des valeurs entre processus indépendants via des canaux typés. La concurrence structurée lie la durée de vie des tâches concurrentes à une portée lexicale, si bien que les tâches ne peuvent pas survivre au bloc qui les a créées et que les erreurs se propagent au lieu de disparaître. Async/await vous permet d’écrire du code concurrent lié aux entrées-sorties dans un style séquentiel. Chacun de ces modèles élève le plancher pour l’auteur moyen, ce dont une grande équipe a besoin.

Comprendre votre modèle mémoire, l’atomicité et la visibilité

Quand vous partagez réellement de la mémoire, deux propriétés mordent. L’atomicité signifie qu’une opération se produit d’un coup ou pas du tout ; un simple incrément (x = x + 1) n’est pas atomique, parce qu’il lit, additionne et écrit en trois étapes qu’un autre thread peut interrompre, ce qui explique comment les compteurs perdent des mises à jour. La visibilité signifie qu’une écriture par un thread devient observable par un autre ; sans synchronisation appropriée, une valeur écrite sur un cœur peut rester dans un cache invisible à un autre, si bien qu’un thread peut boucler indéfiniment sur un drapeau déjà positionné. Le modèle mémoire de votre langage définit quand les écritures deviennent visibles et quels réordonnancements le compilateur et le processeur peuvent effectuer, donc vous ne pouvez pas supposer que le code s’exécute dans l’ordre où vous l’avez écrit. Utilisez les types atomiques et les primitives de synchronisation du langage plutôt que d’inventer votre propre schéma sans verrou.

Utiliser les primitives de synchronisation délibérément, et concevoir contre l’interblocage

Quand le partage est inévitable, recourez à la bonne primitive et respectez son prix. Un verrou ou mutex (exclusion mutuelle) laisse un thread à la fois entrer dans une section critique, mais sérialise l’accès, si bien qu’un verrou chaud devient un goulot d’étranglement qui efface le bénéfice de nombreux cœurs. Un sémaphore limite combien de tâches peuvent progresser à la fois, ce qui est comment vous bornez un bassin. Les opérations atomiques offrent des mises à jour sans verrou pour des valeurs simples comme des compteurs, moins chères qu’un verrou mais faciles à mal utiliser pour quoi que ce soit de composé. Les verrous apportent trois modes de défaillance classiques. Un interblocage survient quand des tâches s’attendent mutuellement en cycle et qu’aucune ne peut progresser, le cas d’école étant deux threads détenant chacun un verrou et voulant l’autre. Un livelock survient quand des tâches continuent de réagir l’une à l’autre mais ne progressent pas. La famine survient quand une tâche n’obtient jamais une ressource parce que d’autres continuent de passer devant. Les disciplines qui préviennent cela sont concrètes : imposer un ordre global des verrous, détenir les verrous brièvement, ajouter des délais d’expiration pour qu’une tâche bloquée échoue bruyamment, ne jamais appeler du code inconnu en détenant un verrou, et utiliser un ordonnancement équitable là où la famine est un risque. Consignez ces règles par écrit, parce qu’un nouvel auteur ne peut pas les redécouvrir à partir du code seul.

Borner vos files, bassins et travail en vol avec la contre-pression

Une file non bornée est une bombe à retardement. Sous un pic de trafic, le travail arrive plus vite qu’il ne se vide, la file grandit sans limite, la mémoire se remplit, et le service meurt d’une façon qui ressemble à un mystérieux plantage par manque de mémoire plutôt qu’à la surcharge qu’elle est réellement. Bornez chaque file, plafonnez chaque bassin de threads, et appliquez la contre-pression : quand le système est plein, signalez en amont de ralentir ou rejetez le travail rapidement plutôt que d’accepter un travail infini que vous ne pouvez pas terminer. Dimensionnez les bassins selon la charge de travail (environ le nombre de cœurs pour un travail lié au processeur, plus élevé pour un travail lié aux entrées-sorties où les threads attendent surtout), et traitez la borne comme une décision de capacité délibérée. Cela se rattache aux motifs de résilience du chapitre 3.3.

Utiliser le parallélisme de données là où le travail est embarrassamment parallèle

Certains problèmes se divisent proprement : appliquer la même opération à chaque élément d’un grand jeu de données, sans qu’aucun élément ne dépende d’un autre. Ce parallélisme de données est le plus amical, parce qu’il y a peu d’état partagé sur lequel faire la course et l’accélération peut approcher le nombre de cœurs, comme le montrent les pipelines map-reduce, les opérations vectorielles sur des tableaux, et le code numérique vectorisé. Même ici, respectez la loi d’Amdahl : l’étape de fusion ou de réduction est souvent série et plafonne votre gain, et le coût de division peut dominer pour de petites entrées. Recourez-y quand le travail par élément est substantiel et que les éléments sont vraiment indépendants ; sinon la version séquentielle la plus simple est souvent à la fois assez rapide et bien plus facile à garder correcte, un point que renforcent les pratiques de construction du chapitre 2.9.

Tester et déboguer le code non déterministe délibérément

Les bugs de concurrence sont non déterministes, donc les tests ordinaires, qui exécutent un seul entrelacement, les manquent surtout. Attaquez le problème délibérément avec des tests de stress et de fuzzing qui exécutent de nombreuses tâches sous un minutage aléatoire pour faire sortir les ordres rares. Recourez aux détecteurs de course et aux sanitiseurs de threads, un outillage qui instrumente l’accès mémoire pour attraper les courses de données même quand l’entrelacement fautif ne s’est pas produit cette fois. Là où votre plateforme l’offre, utilisez la simulation déterministe ou des ordonnanceurs contrôlés qui rejouent un entrelacement spécifique, transformant un heisenbug en bug reproductible, et concevez pour qu’un blocage en production vous permette de capturer les états des threads et la propriété des verrous, ce qui se rattache à la discipline de débogage du chapitre 2.15. Par-dessus tout, préférez des conceptions (immuabilité, passage de messages, propriété unique) qui rendent des catégories entières de ces bugs impossibles, parce qu’un bug que vous ne pouvez pas créer est un bug que vous n’avez jamais à déboguer.

Compromis : avantages et inconvénients

ApprocheAvantagesInconvénients
Mémoire partagée avec verrousRapide par opération ; familierBugs de course, d’interblocage et de visibilité ; difficile à garder correct pour de nombreux auteurs
ImmuabilitéAucune synchronisation nécessaire ; lectures triviellement sûres pour les threadsCoût de copie ; maladroit pour de grandes structures mutables
Passage de messages (acteurs, canaux)Flux de données explicite ; des classes entières de bugs disparaissentSurcharge par message ; peut cacher la contre-pression si les files sont non bornées
Async/awaitConcurrence bon marché pour le travail lié aux entrées-sorties ; code d’apparence séquentiellePas de parallélisme pour le travail processeur ; bloquer une tâche en bloque d’autres
Concurrence structuréeDurées de vie de tâches claires ; les erreurs se propagent ; pas de tâches fuyantesPlus récent, moins disponible dans certains écosystèmes
Parallélisme de donnéesAccélération quasi linéaire sur du travail indépendantPlafond d’Amdahl ; la surcharge domine les petites entrées
Atomiques / sans verrouPas de contention de verrou pour les valeurs simplesExtrêmement facile à se tromper subtilement ; difficile à relire

La tension centrale est la sécurité contre la vitesse brute, et la résolution est d’acheter la correction d’abord et de dépenser de la performance seulement là où la mesure prouve que c’est nécessaire. Le verrouillage brut en mémoire partagée est le plus rapide par opération et le plus dangereux par ligne de code ; les modèles de haut niveau coûtent un peu de débit et rendent une grande quantité de sécurité et de clarté, et pour du code maintenu par de nombreuses mains, cet échange vaut décisivement la peine. Réservez la concurrence sans verrou réglée à la main aux petits points chauds où un profileur (chapitre 2.16) prouve que la surcharge de coordination compte, et gardez même ceux-là derrière une frontière bien testée.

Questions à discuter avec votre équipe

  1. Pour votre service le plus fréquenté, la charge de travail est-elle liée aux entrées-sorties ou au processeur, et votre conception de concurrence correspond-elle ? Les équipes ajoutent couramment des bassins de threads à des services qui passent 95 % de leur temps à attendre une base de données, gagnant de la contention mais aucun débit, ou elles essaient de paralléliser un calcul dont la fraction série plafonne toute accélération. La bonne conception suit du régime : async ou un petit bassin pour un travail à forte attente, du vrai parallélisme à travers les cœurs pour un travail à forte computation. Apportez un profil qui montre où le temps va réellement, pas une supposition, et si la majeure partie du temps est passée à calculer, mesurez la fraction série et laissez la loi d’Amdahl vous dire le plafond. La réponse façonne si vous recourez à async, à un bassin borné, ou au parallélisme de données.

  2. Quelle est la valeur par défaut de votre équipe pour partager l’état à travers les tâches, et est-elle sûre par construction ? Dans une grande équipe, la valeur par défaut compte plus que les exceptions, parce que la plupart du code est écrit par des gens qui ne sont pas des spécialistes de la concurrence et qui copient quel que soit le motif déjà présent. Si la valeur par défaut est des objets mutables partagés gardés par des verrous ad hoc, vous êtes à un verrou oublié d’une course qui surgit des mois plus tard en production. Si la valeur par défaut est l’immuabilité et le passage de messages, des catégories entières de bugs ne se produisent jamais, et le rare endroit qui a vraiment besoin de mémoire partagée ressort pour une relecture attentive. Discutez de ce qu’un nouvel ingénieur choisirait aujourd’hui, si vos relectures attraperaient une écriture non synchronisée, et comment faire du chemin sûr le chemin facile.

  3. Comment trouveriez-vous, reproduiriez-vous et corrigeriez-vous un bug de concurrence qui apparaît une fois sur un million de requêtes en production ? La réponse honnête pour de nombreuses équipes est qu’elles ne le pourraient pas, parce que le bug disparaît quand elles regardent et que leurs tests n’exécutent jamais qu’un seul entrelacement bénin. Cela devrait vous inquiéter, parce que ces bugs corrompent les données silencieusement et érodent la confiance. Parlez de si vous exécutez des détecteurs de course et des sanitiseurs de threads en intégration continue, si vous faites des tests de stress avec un minutage aléatoire, et si votre observabilité de production capture l’état des threads et des verrous au moment d’un blocage. Les meilleures équipes répondent en rendant la plupart de ces bugs impossibles par leur choix de modèle, si bien que les quelques résidus sont rares et contenus.

  4. Où dans votre système une file non bornée ou un bassin de threads non plafonné existe-t-il encore, et que lui arrive-t-il sous un pic soudain d’un facteur dix ? Cela compte parce que le travail en vol non borné est l’échec qui se fait passer pour un mystérieux plantage par manque de mémoire : le travail arrive plus vite qu’il ne se vide, la mémoire se remplit, et le service meurt en ressemblant à une panne matérielle plutôt qu’à la surcharge qu’elle est réellement. Les considérations concurrentes sont réelles, parce qu’une borne trop basse rejette du trafic légitime et une borne trop haute diffère le plantage au lieu de le prévenir, donc le chiffre est une décision de capacité, pas une supposition. Apportez un inventaire de chaque file et bassin, sa borne actuelle (ou l’aveu qu’il n’en a pas), le comportement de contre-pression quand il se remplit, et une preuve de test de charge de comment le système se dégrade à la limite. Pour une flotte d’entreprise, une seule file non bornée peut se propager en panne à l’échelle de la flotte, et pour une plateforme gouvernementale qui doit rester disponible pour les citoyens, un rejet gracieux avec une erreur claire est une obligation de service, donc la borne et son chemin de rejet appartiennent au plan de capacité et au manuel d’exploitation, pas à la mémoire d’un seul ingénieur.

  5. Quelle est la politique de votre équipe pour utiliser des modèles de concurrence de haut niveau contre des verrous écrits à la main, et où avez-vous permis des exceptions ? Le modèle par défaut décide de la sécurité du changement moyen, parce que la plupart des auteurs ne sont pas des spécialistes de la concurrence et copieront quel que soit le motif déjà présent : les acteurs, les canaux et la concurrence structurée élèvent le plancher pour tout le monde, tandis que le verrouillage brut est correct en théorie et une source d’interblocages en pratique. La tension est que les modèles de haut niveau coûtent un peu de surcharge par message ou par tâche, et un profileur prouvera occasionnellement qu’un chemin chaud a besoin de code sans verrou réglé à la main, donc une interdiction générale est aussi fausse qu’une permission générale. Apportez la liste des endroits où vous êtes descendus sous la valeur par défaut sûre, la preuve de profilage qui a justifié chacun, et comment chaque exception est clôturée derrière une frontière testée et un ordre de verrouillage documenté. Dans une grande entreprise, cette politique est ce qui empêche des milliers de contributeurs de chacun réinventer un schéma dangereux, et dans un système gouvernemental de longue durée, c’est ce qui permet à un relecteur des années plus tard de comprendre pourquoi un motif dangereux a été autorisé et de confirmer qu’il est toujours justifié.

  6. Quand vous décidez de paralléliser un calcul, comment mesurez-vous la fraction série, et qui est responsable de confirmer que l’accélération est réelle ? Les équipes répartissent couramment un calcul à travers des cœurs et célèbrent un chiffre qu’un profileur ne confirmerait jamais, parce que la loi d’Amdahl plafonne le gain à l’inverse de la fraction série peu importe combien de cœurs vous ajoutez, et la surcharge de division-fusion peut effacer entièrement le bénéfice pour de petites entrées. La tension concurrente est que le parallélisme ajoute une vraie complexité et une nouvelle surface de course, donc la question est de savoir si l’accélération mesurée justifie le risque de correction que vous prenez. Apportez un profil qui isole la portion série, les tailles d’entrée où le parallélisme gagne réellement, et un benchmark avant-après sur du matériel représentatif plutôt qu’une estimation optimiste. Pour une entreprise payant pour une grande flotte de calcul, une analyse honnête de la fraction série se traduit en dépense matérielle économisée ou gaspillée, et pour un organisme gouvernemental redevable du coût d’un système public, la personne qui a validé la conception parallèle devrait pouvoir montrer la mesure qui l’a justifiée sous audit.

Regard sectoriel

Jeune pousse. Avec une petite équipe et aucune marge à épargner, achetez la correction par la structure, pas par un spécialiste de la concurrence que vous ne pouvez pas embaucher. Recourez à la seule valeur par défaut sûre que votre langage vous donne, async/await pour le travail lié aux entrées-sorties, une tâche propriétaire ou un acteur pour tout état partagé, et sautez entièrement le verrouillage réglé à la main. Une course de mise à jour perdue dans un chemin de paiement peut vous couler plus vite qu’une fonctionnalité manquée, donc dépensez la petite quantité de code supplémentaire pour rendre cette classe de bug impossible et avancez.

Petite entreprise. Vous n’avez personne dont le travail est la concurrence, donc favorisez les plateformes et services gérés qui la gèrent pour vous : une transaction de base de données, une file hébergée, ou le modèle de requête d’un framework bat les threads que vous maintenez à la main. Quand vous évaluez un outil, traitez « cela rend-il la concurrence sûre par défaut » comme une question d’acheter contre construire, et préférez l’option où un mauvais entrelacement ne peut pas corrompre silencieusement le dossier d’un client. Gardez l’état mutable partagé hors de votre propre code partout où un service borné et géré peut le détenir à la place.

Grande entreprise. À travers de nombreuses équipes, l’objectif est une valeur par défaut maison qui garde des milliers de contributeurs en sécurité : l’immuabilité et le passage de messages comme norme, des modèles de haut niveau plutôt que des verrous bruts, des files et bassins bornés avec contre-pression, et un ordre global de verrouillage documenté. Encodez cela dans les normes d’ingénierie, imposez-le avec des détecteurs de course et des tests de stress en CI, et gouvernez les exceptions où un profileur a justifié du code sans verrou pour que chacune reste derrière une frontière testée et relue. Gérez la capacité de concurrence comme une préoccupation à l’échelle de la flotte avec des bornes de file et des tailles de bassin liées à la charge mesurée.

Gouvernement. La correction et l’auditabilité dans des systèmes qui fonctionnent pendant des décennies l’emportent sur le débit brut. Exigez que chaque transition d’état soit enregistrée et rejouable, pour qu’une course suspectée puisse être reproduite et la correction prouvée à un organe de contrôle, et gardez des chemins déterministes sans IA pour les décisions qui touchent les prestations, la sécurité ou les registres publics. L’approvisionnement devrait exiger que les fournisseurs divulguent leur modèle de concurrence et la preuve d’une couverture par détecteur de course et test de stress, parce qu’une mauvaise réponse sous charge dans un système public n’est pas un désagrément, c’est quelque chose qu’un responsable devra plus tard expliquer.

Exemples

Jeune pousse. Une petite équipe livre une fonctionnalité de paiement et remarque que les soldes de compte dérivent occasionnellement de quelques centimes sous charge. La cause est un simple lire-modifier-écrire sur un champ de solde depuis des gestionnaires de requêtes concurrents, une course de mise à jour perdue. Plutôt que de saupoudrer des verrous, ils déplacent le solde de chaque compte derrière une seule tâche propriétaire qui traite les débits et crédits comme des messages, un à la fois. La dérive disparaît, le code devient facile à raisonner, et ils ajoutent un test de stress qui déclenche des milliers de transferts concurrents pour garder la correction. Un changement structurel, une classe entière de bugs éliminée.

Grande entreprise. Un service de commandes à haut débit traitant des dizaines de milliers de requêtes par seconde souffre de pics de latence périodiques et de plantages occasionnels par manque de mémoire pendant les poussées de trafic. L’investigation trouve une file de travail non bornée derrière un bassin de threads qui grandit sans limite une fois que la demande dépasse la capacité. L’équipe borne la file, plafonne le bassin à une taille liée au nombre de cœurs, et ajoute une contre-pression qui rejette rapidement la charge excédentaire avec une erreur claire. Le débit devient prévisible, les plantages s’arrêtent, et un verrou chaud sur un cache partagé est remplacé par une structure sans verrou seulement après qu’un profileur ait prouvé que la contention est réelle. Des valeurs par défaut sûres pour les nombreux auteurs, une concurrence réglée seulement là où mesurée.

Gouvernement. Une plateforme nationale de prestations fonctionne pendant des décennies et doit produire des résultats auditables et corrects même sous des mises à jour de dossiers concurrentes. L’équipe choisit l’immuabilité et le passage de messages comme valeur par défaut maison, confine chaque morceau d’état mutable à un seul propriétaire, et impose un ordre global de verrouillage partout où des verrous subsistent, tout cela écrit dans les normes d’ingénierie. Ils exécutent des sanitiseurs de threads et des tests de stress aléatoires dans le pipeline, et conçoivent pour que chaque transition d’état soit enregistrée et rejouable pour le contrôle, ce qui leur permet de reproduire et de prouver la correction quand un entrelacement rare est suspecté. La correction et l’auditabilité sont traitées comme des exigences de premier ordre, pas comme des réflexions après coup sur la performance.

Argumentaire économique : motivations, retour sur investissement et coût total de possession

Le retour sur une concurrence disciplinée apparaît comme des incidents qui ne se produisent jamais. Une seule course en production peut corrompre des données à travers des milliers d’enregistrements, et le coût inclut à la fois les heures d’ingénierie pour trouver un bug qui se cache quand on l’observe et le coût bien plus grand de réconcilier de mauvaises données, notifier les utilisateurs affectés, et reconstruire la confiance. Ce sont parmi les défauts les plus coûteux à diagnostiquer précisément parce qu’ils sont non déterministes, donc poursuivre un heisenbug peut éclipser l’effort de choisir un modèle sûr en amont.

L’avantage apparaît aussi comme du débit et du coût. Bien dimensionner la concurrence permet à un service de gérer bien plus de charge sur le même matériel, une économie récurrente pour une grande flotte, tandis que la contre-pression et les files bornées préviennent les pannes en cascade qui transforment un pic de trafic en incident public. Le coût total de possession est modeste et principalement culturel : vous investissez dans un style maison (immuabilité, passage de messages, concurrence structurée), dans de l’outillage (détecteurs de course, sanitiseurs de threads, harnais de stress en CI), et dans des normes qui codifient l’ordre de verrouillage et le bornage. L’alternative est une base de code où la correction dépend de chaque auteur étant expert pour toujours, ce qu’aucune équipe en croissance ne peut soutenir. Faites valoir cela auprès de la direction dans leurs unités : traduisez une course évitée en incidents de corruption de données évités, la contre-pression en pannes prévenues, et une valeur par défaut sûre en temps d’intégration économisé.

Anti-patterns et pièges

  • Ajouter des threads pour la vitesse sur du travail lié aux entrées-sorties. Plus de threads sur un service à forte attente achètent de la contention, pas du débit.
  • État mutable partagé partout. N’importe quel thread mutant n’importe quel objet fait de la correction une question de chance qu’aucun relecteur ne peut vérifier.
  • Files et bassins non bornés. Un pic fait grandir la file jusqu’à ce que la mémoire meure ; le plantage semble mystérieux mais est une simple surcharge.
  • Verrouillage ad hoc sans ordre global. Des verrous pris dans des ordres différents à travers la base de code s’interbloquent sous charge.
  • Supposer que le code s’exécute dans l’ordre écrit. Ignorer le modèle mémoire, si bien qu’un bug de visibilité laisse un thread tourner sur une valeur périmée.
  • Astuce sans verrou faite à la main. Les schémas sans verrou personnalisés sont presque toujours subtilement faux et presque impossibles à relire.
  • Tester seulement l’entrelacement heureux. Les tests déterministes passent pendant que l’ordre d’une fois sur un million corrompt la production.
  • Appeler du code inconnu en détenant un verrou. Un rappel qui bloque ou rentre transforme une section critique en interblocage.

Modèle de maturité

  • Niveau 1 (Initier) : La concurrence est ad hoc et réactive. Des threads et des verrous sont ajoutés par instinct, l’état mutable partagé est partout, et les files sont non bornées. Les situations de compétition surgissent comme des incidents de production irreproductibles que personne ne peut diagnostiquer, et aucun outillage n’existe pour les attraper.
  • Niveau 2 (Développer) : Certaines équipes ont appris des pratiques de base : elles utilisent les verrous avec plus de soin et bornent leurs files les plus évidentes. Il y a une conscience informelle des courses et des interblocages, et quelques chemins critiques reçoivent un examen supplémentaire. La pratique est incohérente à travers les équipes, les tests restent surtout à entrelacement unique, et les motifs sûrs vivent chez des individus plutôt que par écrit.
  • Niveau 3 (Standardiser) : L’organisation a un style maison documenté et imposé à l’échelle de l’organisation : immuabilité et passage de messages comme valeurs par défaut, modèles de haut niveau plutôt que verrous bruts, files et bassins bornés avec contre-pression, et un ordre global de verrouillage documenté. Des détecteurs de course et des tests de stress s’exécutent en CI, et les choix de concurrence suivent de si le travail est lié aux entrées-sorties ou au processeur.
  • Niveau 4 (Gérer) : L’organisation mesure et contrôle sa posture de concurrence par rapport à des références. Elle suit la couverture des détecteurs de course et sanitiseurs de threads à travers les services, enregistre la profondeur de file, le temps d’attente de verrou, la saturation de bassin et les taux de rejet comme métriques surveillées, et fait des tests de charge sur la courbe de dégradation pour que chaque borne soit une décision de capacité fondée sur des données. Les incidents de concurrence sont comptés et suivis en tendance, les fractions séries des charges de travail parallélisées sont mesurées par rapport à l’accélération réellement obtenue, et les décisions de feu vert sur de nouvelles conceptions reposent sur cette preuve plutôt que sur l’instinct.
  • Niveau 5 (Orchestrer) : La concurrence sûre est le chemin de moindre résistance pour chaque auteur, et la pratique est continuellement améliorée et intégrée à travers l’organisation. Des classes entières de bugs sont impossibles par construction, les points chauds sont réglés seulement là où le profilage le prouve, et la relecture déterministe rend le rare bug résiduel reproductible. La correction et l’auditabilité sont des propriétés continuellement défendues, les bornes de capacité s’adaptent à la charge observée, et les normes évoluent à mesure que la plateforme et la charge de travail changent.

Pistes de réflexion

  1. Si vous auditiez votre service le plus fréquenté aujourd’hui, quelle part de son état est partagée et mutable, et quelle part de ce partage est vraiment nécessaire ?
  2. Quelle est la réponse par défaut de votre équipe quand quelqu’un a besoin que deux tâches se coordonnent, et préféreriez-vous que ce soit l’immuabilité ou le passage de messages ?
  3. Où des files non bornées ou des bassins non plafonnés se cachent-ils encore dans votre système, et que leur arriverait-il sous un pic de trafic soudain d’un facteur dix ?
  4. Vos exécutions d’intégration continue incluent-elles un détecteur de course ou un sanitiseur de threads, et quand l’un d’eux a-t-il attrapé quelque chose pour la dernière fois avant la production ?
  5. Pour votre charge de travail la plus parallélisée, quelle est la fraction série, et la loi d’Amdahl plafonne-t-elle l’accélération que vous poursuivez réellement ?
  6. Votre équipe pourrait-elle reproduire à la demande un bug d’entrelacement d’une fois sur un million, et que faudrait-il pour y arriver ?

Points clés à retenir

  • La concurrence structure un programme comme des tâches indépendantes ; le parallélisme les exécute en même temps. Décidez duquel vous avez besoin avant d’ajouter des threads.
  • L’état mutable partagé est la racine de presque tous les bugs de concurrence ; préférez l’immuabilité et le passage de messages comme valeurs par défaut sûres pour de nombreux auteurs.
  • Recourez aux modèles de haut niveau (acteurs, canaux, concurrence structurée, async/await) avant les verrous écrits à la main, qui sont corrects en théorie et dangereux en pratique.
  • Comprenez l’atomicité, la visibilité et votre modèle mémoire ; utilisez la bonne primitive, détenez les verrous brièvement, et imposez un ordre global de verrouillage pour éviter l’interblocage, le livelock et la famine.
  • Bornez chaque file et bassin et appliquez la contre-pression, pour qu’un pic se dégrade gracieusement au lieu de planter (chapitre 3.3).
  • Testez les entrelacements délibérément avec des détecteurs de course, des tests de stress et la relecture (chapitre 2.15), et respectez la loi d’Amdahl en parallélisant (chapitre 2.16).
  • Pour les entreprises, c’est du débit et des incidents évités ; pour l’administration publique, c’est de la correction et de l’auditabilité dans des systèmes de longue durée.

Références et lectures complémentaires

  • Brian Goetz et al., Java Concurrency in Practice (atomicité, visibilité, le modèle mémoire, et la publication sûre).
  • Herb Sutter, « The Free Lunch Is Over » (pourquoi le logiciel doit embrasser la concurrence à mesure que les vitesses d’horloge plafonnent).
  • Leslie Lamport, « Time, Clocks, and the Ordering of Events in a Distributed System » (l’ordonnancement et les fondements du raisonnement concurrent).
  • C. A. R. Hoare, « Communicating Sequential Processes » (Communications of the ACM, 1978) : le modèle CSP derrière les canaux.
  • Carl Hewitt, Peter Bishop et Richard Steiger, « A Universal Modular Actor Formalism for Artificial Intelligence » (l’origine du modèle acteur).
  • Edsger W. Dijkstra, « Cooperating Sequential Processes » (sémaphores, exclusion mutuelle et le problème de l’interblocage).
  • Maurice Herlihy et Nir Shavit, The Art of Multiprocessor Programming (verrous, atomiques et structures de données sans verrou).
  • Nathaniel J. Smith, « Notes on Structured Concurrency, or: Go Statement Considered Harmful » (l’argumentaire pour la concurrence structurée).
  • Martin Kleppmann, Designing Data-Intensive Applications (concurrence et cohérence où la mémoire rencontre les systèmes distribués).
  • Gene M. Amdahl, « Validity of the Single Processor Approach to Achieving Large-Scale Computing Capabilities » (1967) : l’origine de la loi d’Amdahl.