Programmation · concepts · performance
Concurrence et parallélisme
Concurrence et parallélisme sont deux des mots les plus confondus en programmation, et la confusion se comprend : les deux évoquent le fait de « faire plusieurs choses à la fois », et dans le langage courant on les emploie indifféremment. Mais ils répondent à des questions différentes. La concurrence porte sur la façon dont vous structurez un programme pour traiter de nombreuses tâches qui se chevauchent dans le temps ; le parallélisme porte sur l'exécution effective de plusieurs tâches au même instant, sur plusieurs processeurs. L'une est une manière d'organiser le travail, l'autre une manière de l'exécuter. Ce guide trace la frontière clairement, montre comment les deux s'articulent, et déroule des exemples réels en Python et en Haskell.
Les définitions courtes
La concurrence est la capacité d'un programme à traiter beaucoup de choses à la fois en les entrelaçant : il démarre une tâche, la met en pause pendant qu'elle attend, passe à une autre, y revient plus tard. Les tâches progressent sur des périodes qui se chevauchent, mais pas nécessairement au même instant. La concurrence relève fondamentalement de la structure et de la composition — découper un programme en morceaux qui progressent indépendamment.
Le parallélisme consiste à faire plusieurs choses littéralement en même temps, ce qui exige un matériel capable d'exécuter plusieurs flux d'instructions simultanément : plusieurs cœurs de processeur, plusieurs processeurs, ou un GPU. Le parallélisme relève de l'exécution — répartir plusieurs morceaux de travail sur plusieurs exécutants pour qu'ils finissent plus tôt.
La formule de Rob Pike
La formulation la plus claire et la plus citée vient de Rob Pike, l'un des concepteurs de Go, dans sa conférence « Concurrency Is Not Parallelism ». Son point central : la concurrence consiste à gérer beaucoup de choses à la fois, tandis que le parallélisme consiste à faire beaucoup de choses à la fois — la concurrence est une façon de structurer un logiciel, le parallélisme une propriété de l'exécution qu'une bonne structure concurrente peut ensuite exploiter. Autrement dit : la concurrence est un outil de conception, le parallélisme un résultat d'exécution. Un programme concurrent bien structuré s'exécutera en parallèle si le matériel le permet, ou sur un seul cœur par entrelacement — et il reste correct dans les deux cas.
Une analogie de cuisine
Imaginez un cuisinier préparant trois plats. Un cuisinier concurrent ne reste pas planté devant le four pendant qu'un gâteau cuit : il enfourne le gâteau, commence à couper les légumes du plat suivant, remue une sauce, puis vérifie le gâteau. Une seule paire de mains bouge à un instant donné, mais trois plats sont « en cours ». C'est la concurrence sur un seul exécutant — entrelacer les tâches pour ne jamais gaspiller le temps d'attente.
Le parallélisme, c'est embaucher trois cuisiniers qui préparent chacun un plat en même temps. Là, trois choses se produisent réellement au même instant, parce qu'il y a trois exécutants. Notez qu'on peut avoir de la concurrence avec un seul cuisinier (structure, sans simultanéité) et du parallélisme avec plusieurs (simultanéité). Les deux idées sont indépendantes, et c'est exactement pourquoi il leur faut deux mots distincts.
Comment ils s'articulent
Comme ils sont indépendants, les quatre combinaisons existent.
Concurrence sans parallélisme. Un cœur unique qui bascule rapidement entre les tâches (découpage temporel) fait tourner beaucoup de choses « à la fois » du point de vue de l'utilisateur, mais un seul flux d'instructions s'exécute à un instant donné. C'est ainsi qu'une machine monocœur faisait tourner des dizaines de programmes, et c'est ainsi qu'une boucle d'événements async jongle avec des milliers de connexions réseau sur un seul fil d'exécution.
Le parallélisme exige plusieurs cœurs. Pour exécuter réellement deux calculs au même instant, il faut au moins deux unités d'exécution matérielles. Sur un portable, cela signifie plusieurs cœurs ; à grande échelle, des serveurs à nombreux cœurs. Comme l'accélération parallèle croît avec le nombre de cœurs disponibles, louer une machine plus grosse est un levier concret — des serveurs cloud multi-cœurs comme les droplets à forte densité CPU fournissent les cœurs dont une charge parallèle a réellement besoin.
Parallélisme sans concurrence explicite. Une bibliothèque numérique qui répartit une grosse multiplication de matrices sur plusieurs cœurs vous donne une exécution parallèle sans que vous ayez écrit le moindre flot de contrôle concurrent. Les deux ensemble constituent le cas moderne courant : une conception concurrente (de nombreuses tâches) projetée sur un matériel parallèle (de nombreux cœurs).
Threads ou async
Ce sont deux manières courantes d'exprimer la concurrence, et la différence compte.
Les threads sont des lignes d'exécution indépendantes gérées par le système d'exploitation, partageant la même mémoire. Sur une machine multicœur, le système peut placer différents threads sur différents cœurs : les threads peuvent donc apporter un vrai parallélisme. Le prix à payer est que la mémoire mutable partagée rend la correction difficile (voir les conditions de concurrence plus bas), et que chaque thread a un coût.
L'async (une boucle d'événements avec async/await) fait tourner de nombreuses tâches sur un seul fil par basculement coopératif : une tâche cède volontairement la main à un point await chaque fois qu'elle devrait attendre — une réponse réseau, une lecture disque, un minuteur. Pendant qu'une tâche attend, la boucle en exécute une autre. L'async donne de la concurrence à très faible coût, mais à lui seul il n'apporte pas de parallélisme, puisque tout tourne sur un seul fil. Il brille quand les tâches passent l'essentiel de leur temps à attendre plutôt qu'à calculer.
Exemples en Python
La bibliothèque standard de Python propose les deux styles. L'async avec asyncio est idéal pour de nombreuses tâches limitées par les entrées/sorties sur un seul fil :
import asyncio
async def fetch(name):
await asyncio.sleep(1) # tient lieu d appel reseau lent
return name
async def main():
# trois taches se chevauchent ; l attente totale est d environ 1s, pas 3s
results = await asyncio.gather(fetch("a"), fetch("b"), fetch("c"))
print(results)
asyncio.run(main()) Les trois « récupérations » se chevauchent dans le temps bien qu'un seul fil les exécute, parce que chacune cède la main pendant qu'elle attend. C'est de la concurrence sans parallélisme.
Les threads y ressemblent, mais comportent une nuance importante. Dans l'interpréteur standard, CPython possède un verrou global (GIL) : un mutex qui n'autorise qu'un seul thread à exécuter du bytecode Python à la fois. Les threads Python apportent donc une vraie concurrence et un vrai parallélisme pour les entrées/sorties (le GIL est relâché pendant qu'un thread attend le système), mais ils n'apportent pas d'accélération parallèle pour du code Python purement calculatoire, puisque le GIL sérialise le bytecode. Pour du parallélisme calculatoire, la réponse classique est multiprocessing, qui lance des processus distincts, chacun avec son propre interpréteur et son propre GIL, répartis sur les cœurs :
from concurrent.futures import ProcessPoolExecutor
def heavy(n):
return sum(i * i for i in range(n)) # travail limite par le CPU
with ProcessPoolExecutor() as pool:
print(list(pool.map(heavy, [10_000_000] * 4))) # reparti sur les coeurs Notez que le GIL est un détail d'implémentation de CPython, pas une clause de la spécification du langage ; des versions récentes de CPython proposent une compilation optionnelle et expérimentale capable de s'en passer, mais la version courante par défaut le conserve — traitez donc le parallélisme calculatoire par threads en Python comme limité, sauf si vous avez vérifié votre build.
Exemples en Haskell
Haskell est réputé pour bien prendre en charge les deux, et il garde les deux idées nettement séparées. Pour la concurrence, forkIO lance un fil léger (green thread) ; l'environnement d'exécution multiplexe un très grand nombre d'entre eux sur un petit ensemble de fils système et peut les répartir sur les cœurs :
import Control.Concurrent (forkIO)
main :: IO ()
main = do
_ <- forkIO (putStrLn "depuis un autre fil")
putStrLn "depuis le fil principal" Pour le parallélisme déterministe, Haskell offre un mécanisme distinct. Comme les fonctions pures n'ont pas d'effets de bord, évaluer deux d'entre elles en parallèle ne peut pas changer le résultat — vous pouvez donc suggérer à l'environnement d'exécution de le faire, sans aucun verrou. Le combinateur par crée une étincelle (spark) : l'indication qu'une valeur peut être évaluée en parallèle, que l'exécution peut reprendre sur un cœur libre.
import Control.Parallel (par, pseq)
-- suggere que 'a' peut etre evalue en parallele de 'b'
parAdd :: Int -> Int -> Int
parAdd a b = a `par` (b `pseq` (a + b)) La séparation est tout l'intérêt : forkIO est un outil de concurrence (structurer des fils qui interagissent, y compris pour les entrées/sorties), tandis que par et les sparks sont un outil de parallélisme destiné à accélérer un calcul pur. On compile avec l'environnement d'exécution threadé et l'on passe une option comme +RTS -N pour autoriser le programme à utiliser plusieurs cœurs.
Quand chacun compte
Une règle empirique utile tient à la nature du goulot : attendre ou calculer.
Le travail limité par les entrées/sorties appelle la concurrence. Si les tâches passent l'essentiel de leur temps à attendre le réseau, le disque ou une base de données, vous n'avez pas besoin de beaucoup de cœurs — vous avez besoin d'une structure qui empêche une tâche en attente de bloquer les autres. L'async ou un pool de threads modeste permet à un seul cœur de maintenir des milliers de connexions en vol. Ajouter des cœurs n'aide presque pas, puisqu'ils attendraient eux aussi.
Le travail limité par le CPU appelle le parallélisme. Si les tâches calculent sans relâche — traitement d'images, simulation numérique, compilation, cryptographie — la façon de finir plus tôt est de répartir le travail sur les cœurs et de l'exécuter en même temps. Ici, la concurrence seule n'apporte rien ; il faut une exécution réellement parallèle, et votre gain est plafonné par le nombre de cœurs et par la part parallélisable du travail.
Les pièges classiques
Les deux approches partagent de la mémoire ou de l'état, et c'est là que naissent les bugs.
Les conditions de concurrence. Quand deux fils lisent et écrivent la même donnée sans coordination, le résultat dépend d'un enchaînement imprévisible. L'exemple classique : deux fils exécutant chacun count = count + 1 — la lecture, l'addition et l'écriture peuvent s'entrelacer de sorte qu'une mise à jour soit perdue. Les remèdes : verrous et mutex, opérations atomiques, ou l'évitement pur et simple de l'état mutable partagé (passage de messages, données immuables).
Les interblocages. Quand des fils s'attendent en cycle — le fil A détient le verrou 1 et veut le 2, le fil B détient le 2 et veut le 1 — plus personne n'avance et le programme se fige. Acquérir les verrous dans un ordre global cohérent est la parade standard.
Autres dangers : la famine (une tâche n'est jamais ordonnancée), et la fausse idée que davantage de fils signifie toujours plus de vitesse — au-delà du nombre de cœurs, les fils supplémentaires ajoutent du coût d'ordonnancement sans parallélisme supplémentaire. Les données immuables et les fonctions pures, comme dans le style fonctionnel, contournent des catégories entières de ces bugs, ce qui explique en grande partie pourquoi des langages comme Haskell rendent la concurrence et le parallélisme plus sûrs.
Concurrence et parallélisme sont le versant « exécution » d'idées qui façonnent aussi la conception des programmes : exprimer le travail en étapes indépendantes et composables est proche de la manière dont la programmation fonctionnelle évite l'état mutable partagé, et les deux reposent sur les mêmes fondations que tout algorithme et son usage de la récursion pour découper le travail. Parcourez d'autres explications claires dans notre index des guides.
Questions fréquentes
Quelle est la différence entre concurrence et parallélisme ?
La concurrence structure un programme pour traiter des tâches qui se chevauchent dans le temps en les entrelaçant ; le parallélisme exécute plusieurs tâches au même instant sur plusieurs cœurs. La concurrence relève de la structure et de la composition, le parallélisme de l'exécution simultanée. On peut avoir de la concurrence sans parallélisme (un cœur qui découpe son temps), et le parallélisme exige plus d'une unité d'exécution matérielle.
Peut-on avoir de la concurrence sans parallélisme ?
Oui. Un seul cœur peut exécuter de nombreuses tâches de façon concurrente en basculant rapidement entre elles, ou une boucle d'événements async peut jongler avec des milliers de connexions sur un fil. Les tâches se chevauchent et progressent toutes, mais un seul flux d'instructions s'exécute à un instant donné : il n'y a pas d'exécution parallèle. Le parallélisme exige spécifiquement plusieurs cœurs ou processeurs.
Le GIL de Python empêche-t-il le parallélisme ?
Le GIL de CPython n'autorise qu'un thread à exécuter du bytecode Python à la fois : les threads n'apportent donc pas d'accélération parallèle pour du code Python calculatoire. Ils aident toujours pour les entrées/sorties, car le GIL est relâché pendant l'attente système. Pour du parallélisme calculatoire, utilisez multiprocessing (processus séparés, chacun avec son interpréteur) ou une extension C qui relâche le GIL. Le GIL est un détail de CPython, pas une clause du langage.
Faut-il utiliser des threads ou de l'async ?
Utilisez l'async pour de nombreuses tâches limitées par les entrées/sorties qui passent l'essentiel de leur temps à attendre, car une boucle d'événements en gère des milliers à faible coût sur un fil. Utilisez les threads (ou les processus) quand vous avez besoin d'un ordonnancement par le système ou d'une répartition sur les cœurs. Pour du travail parallèle calculatoire en Python, préférez multiprocessing aux threads à cause du GIL. Choisissez l'outil selon que le goulot est l'attente ou le calcul.
Entrées/sorties égale concurrence, et CPU égale parallélisme ?
En règle générale, oui. Le travail limité par les entrées/sorties profite de la concurrence : on veut une structure qui laisse les autres tâches progresser pendant qu'une attend, et des cœurs supplémentaires n'aident guère. Le travail limité par le CPU profite du parallélisme : pour finir plus tôt, il faut répartir le calcul sur plusieurs cœurs et l'exécuter simultanément, le gain étant borné par le nombre de cœurs et par la part parallélisable du travail.