Accueil / Articles

Attente versus calcul : concurrence et parallélisme dans Node.js et Go

Exemples exécutables en Node.js et Go qui distinguent la concurrence du parallélisme, montrant quand les promesses suffisent, quand des threads de travail sont nécessaires, et en quoi les goroutines diffèrent.

Éditeur : REACTAPP.TOP8 min de lecture1761 mots

Sources: Concurrency vs parallelism, explained with Node.js and Go

Considérer la concurrence et le parallélisme comme des synonymes entraîne de véritables bugs, tels qu’une API qui ralentit sous charge. Comparer Node.js et Go rend cette différence concrète : Node.js offre la concurrence sans aucun effort, vous obligeant à développer le parallélisme vous-même, tandis que Go les fournit tous deux grâce à un seul mot-clé. Grâce à de petits programmes exécutables dans chacun de ces langages, vous saurez lequel convient à votre charge de travail.

Deux définitions et une cuisine

La concurrence signifie qu’un seul processus gère de nombreuses tâches en passant à une autre dès que la tâche actuelle est en attente. Le parallélisme, quant à lui, signifie que plusieurs processus exécutent plusieurs tâches littéralement au même moment.

Imaginez une cuisine. Un seul cuisinier avec trois poêles, c’est de la concurrence : le cuisinier remue dans la première poêle, et pendant que la deuxième mijote, il coupe des légumes pour la troisième. À aucun moment deux choses ne se produisent en même temps, pourtant le cuisinier n’est jamais inactif. Mettez trois cuisiniers dans la cuisine, un par poêle, et vous avez du parallélisme.

Node.js : la concurrence est intégrée

Un seul thread, de nombreuses requêtes en cours

Node.js exécute votre JavaScript sur une seule thread. Il gère néanmoins bien un fort volume de trafic, car la plupart des tâches serveur consistent à attendre une base de données, un fichier ou un autre service. Node transfère cette attente au système d’exploitation (et, pour certaines opérations, à la pool de threads interne de libuv) et continue avec le suivant morceau de JavaScript. Ce mécanisme d’ordonnancement est la boucle d’événements. Pour en savoir plus sur ce fonctionnement, consultez comment libuv, la boucle d’événements et la pool de threads s’articulent.

Le programme suivant simule trois requêtes, chacune prenant une seconde, en utilisant des temporiseurs encapsulés dans des promesses :

function fakeRequest(name, ms) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(`${name} done`), ms);
  });
}

async function main() {
  console.time("total");

  const results = await Promise.all([
    fakeRequest("users", 1000),
    fakeRequest("orders", 1000),
    fakeRequest("products", 1000),
  ]);

  console.log(results);
  console.timeEnd("total");
}

main();

Comme Promise.all lance les trois tâches en même temps et que les compteurs temporels s’épuisent simultanément, console.timeEnd indique environ une seconde au lieu de trois. Un seul thread gérait trois tâches en cours sans jamais rester inactif en attendant l’une d’elles. C’est cela, la concurrence.

Que se passe-t-il avec des tâches réelles sur le CPU

Le cycle d’événements ne peut superposer que des opérations d’attente. Lorsqu’une tâche consiste en une simple computation, il n’y a rien à transmettre au système d’exploitation. Ce programme additionne une grande plage de valeurs trois fois :

function countTo(n) {
  let sum = 0;
  for (let i = 0; i < n; i++) sum += i;
  return sum;
}

console.time("total");
countTo(2_000_000_000);
countTo(2_000_000_000);
countTo(2_000_000_000);
console.timeEnd("total");

Le temps total est d’environ trois fois la durée d’une seule appel. Envelopper ces appels dans Promise.all ne servirait à rien, car chaque countTo s’exécute de manière synchrone du début à la fin avant que quoi que ce soit d’autre ne puisse être exécuté. En termes culinaires, le cuisinier seul doit finir chaque plat à tour de rôle. Pire encore, sur un serveur, le processus ne peut pas répondre à aucune requête HTTP tant que la boucle est en cours.

Obtenir du parallélisme avec des threads de travail

L’utilisation de plus d’un cœur CPU nécessite plus d’un thread, et l’outil de Node à cet effet est le module worker_threads. Tout d’abord, le thread de travail : un fichier séparé qui lit ses données depuis workerData, effectue les calculs et renvoie le résultat au processus parent.

// worker.js
const { parentPort, workerData } = require("worker_threads");

let sum = 0;
for (let i = 0; i < workerData; i++) sum += i;

parentPort.postMessage(sum);

Le fichier principal enrobe chaque travailleur dans une promesse qui se résout lors de l’événement message du travailleur et se rejette en cas d’erreur error, puis lance trois travailleurs en même temps :

// main.js
const { Worker } = require("worker_threads");
const path = require("path");

function countInWorker(n) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(path.join(__dirname, "worker.js"), {
      workerData: n,
    });
    worker.on("message", resolve);
    worker.on("error", reject);
  });
}

async function main() {
  console.time("total");

  await Promise.all([
    countInWorker(2_000_000_000),
    countInWorker(2_000_000_000),
    countInWorker(2_000_000_000),
  ]);

  console.timeEnd("total");
}

main();

Sur une machine dotée de trois cœurs ou plus, la durée totale diminue à environ celle d’une seule appel. Trois cuisiniers, trois poêles, travaillant simultanément. Le thread principal reste également disponible pour traiter les requêtes.

Pourtant, regardez ce qu’il a fallu : un fichier dédié, un canal de messages et un enrobage par promesse. Dans Node.js, le parallélisme est quelque chose qu’il faut construire soi-même. En production, il convient également d’éviter de créer un nouveau travailleur pour chaque tâche, car le démarrage d’un travailleur a un coût réel ; une pool de travailleurs reutilisables est la solution habituelle, comme expliqué dans notre guide sur les threads et pools de travailleurs.

Go : une seule mot-clé pour les deux

Goroutines au lieu d’un boucle d’événements

Au Go, préfixer un appel de fonction par go démarre une goroutine. Chaque goroutine commence avec un tas de seulement quelques kilooctets qui grandit selon les besoins, ce qui permet d’exécuter des milliers d’entre elles sans problème. Le planificateur du runtime Go affecte les goroutines aux threads du système d’exploitation et, par défaut, autorise autant d’exécutions simultanées que de cœurs CPU, une limite contrôlée par GOMAXPROCS.

Voici à nouveau la simulation avec trois requêtes, où sync.WaitGroup est utilisé pour attendre que toutes les goroutines aient terminé :

package main

import (
 "fmt"
 "sync"
 "time"
)

func fakeRequest(name string, wg *sync.WaitGroup) {
 defer wg.Done()
 time.Sleep(time.Second)
 fmt.Println(name, "done")
}

func main() {
 start := time.Now()
 var wg sync.WaitGroup

 for _, name := range []string{"users", "orders", "products"} {
  wg.Add(1)
  go fakeRequest(name, &wg)
 }

 wg.Wait()
 fmt.Println("total:", time.Since(start))
}

Chaque goroutine appelle wg.Done() via defer, et wg.Wait() bloque jusqu’à ce que toutes soient terminées. Le résultat correspond à la version Promise.all de Node.js : environ une seconde au total.

Les tâches gourmandes en CPU n’ont pas besoin de mécanismes supplémentaires

Voici maintenant l’exemple de comptage. Alors que Node.js nécessitait un fichier d’ouvrier et un mécanisme de transmission de messages, la version Go conserve exactement la même structure qu’auparavant :

package main

import (
 "fmt"
 "sync"
 "time"
)

func countTo(n int, wg *sync.WaitGroup) {
 defer wg.Done()
 sum := 0
 for i := 0; i < n; i++ {
  sum += i
 }
 fmt.Println("sum:", sum)
}

func main() {
 start := time.Now()
 var wg sync.WaitGroup

 for i := 0; i < 3; i++ {
  wg.Add(1)
  go countTo(2_000_000_000, &wg)
 }

 wg.Wait()
 fmt.Println("total:", time.Since(start))
}

Sur une machine multi-cœurs, cela s’achève en environ le temps d’une seule appel. Le planificateur a trouvé trois goroutines exécutables et les a lancées sur trois threads en même temps. Vous avez exprimé le travail de manière concurrente, et l’environnement d’exécution l’a transformé en exécution parallèle.

Cela illustre la distinction établie dans le célèbre discours de Rob Pike intitulé « Concurrency is not parallelism » : la concurrence correspond à la manière dont on structure un programme en éléments exécutés de façon indépendante, tandis que le parallélisme est ce que l’environnement d’exécution peut faire avec cette structure lorsqu’il dispose de plusieurs cœurs.

Le prix de la mémoire partagée

Chaque travailleur Node.js dispose de son propre tas d’objets isolé et ne peut pas lire les variables des autres ; ils communiquent en échangeant des messages. (Si vous avez vraiment besoin de mémoire partagée dans Node.js, SharedArrayBuffer avec Atomics existe, mais cela soulève les mêmes problèmes de synchronisation décrits ci-dessous.)

En revanche, les goroutines partagent la même mémoire. Si deux goroutines incrémentent un compteur sans coordination, il s’agit d’une course de données et la valeur finale sera incorrecte. La solution typique en Go consiste à transmettre les données via des canaux et à utiliser un mutex uniquement lorsque le partage d’état est inévitable.

Cet ensemble de travailleurs illustre l’approche basée sur les canaux. Trois goroutines lisent des tâches depuis un canal et écrivent des valeurs doublées dans un autre :

package main

import "fmt"

func worker(jobs <-chan int, results chan<- int) {
 for j := range jobs {
  results <- j * 2
 }
}

func main() {
 jobs := make(chan int, 100)
 results := make(chan int, 100)

 for w := 1; w <= 3; w++ {
  go worker(jobs, results)
 }

 for j := 1; j <= 9; j++ {
  jobs <- j
 }
 close(jobs)

 for r := 1; r <= 9; r++ {
  fmt.Println(<-results)
 }
}

Rien n’est partagé à part les canaux eux-mêmes, donc aucune verrouillage n’est nécessaire, et l’ajout de travailleurs permet à la pool d’utiliser davantage de cœurs. Les résultats arrivent dans l’ordre où les travailleurs terminent, ce qui est attendu pour ce modèle. L’exécution du programme avec go run -race main.go active le détecteur de concurrence, qui signale tout accès non synchronisé accidentel à la mémoire partagée.

Les deux modèles comparés

  • Unité de concurrence : Node.js utilise des promesses et des callbacks planifiés sur un seul boucle d’événements ; Go utilise des goroutines.
  • Parallélisme : dans Node.js il est optionnel, via worker_threads ou le module cluster (qui exécute plusieurs processus) ; dans Go il est activé par défaut, le planificateur utilisant chaque cœur.
  • Tâches gourmandes en CPU : dans Node.js, cela bloque la boucle d’événements à moins de le déplacer vers un worker ; en Go, il s’agit simplement d’une autre goroutine.
  • Partage des données : les workers de Node.js échangent des messages ; les goroutines utilisent des canaux ou une mémoire partagée protégée par un mutex.
  • Mode d’échec typique : dans Node.js, une boucle d’événements bloquée ; en Go, des courses de données.
  • Choisir l’outil adapté : attendre ou calculer ?

    Une seule question permet de trancher dans la plupart des cas : la partie lente de votre charge de travail consiste-t-elle à attendre ou à calculer ?

    Si c’est l’attente (en attendant une base de données, le système de fichiers ou une requête réseau), la concurrence est tout ce dont vous avez besoin. Dans Node.js, cela signifie les promesses et async/await. En Go, cela signifie les goroutines, et le planificateur met en veille celles qui sont bloquées afin qu’elles ne consomment pas de CPU.

    Si l’opération nécessite des calculs (redimensionnement d’images, hachage, analyse d’un fichier très volumineux), le parallélisme est indispensable. Dans Node.js, transférez les tâches vers des threads de travail ou utilisez cluster pour répartir le traitement des requêtes entre plusieurs processus. En Go, lancez des goroutines et assurez-vous qu’elles échangent des données via des canaux ou protègent l’état partagé avec un mutex.

    Le parallélisme est limité par le matériel : dans un conteneur à cœur unique, trois tâches gourmandes en CPU ne s’exécuteront pas plus rapidement que séquentiellement, quel que soit le langage utilisé.

    Points clés

    • La concurrence concerne la structure : organiser les tâches de manière à ce que rien ne reste inactif pendant qu’une autre tâche attend. Le parallélisme concerne le matériel : le nombre d’opérations qui s’exécutent réellement en même temps.
    • Dans Node.js, Promise.all permet de superposer les opérations en attente, mais il ne sert à rien pour les calculs synchrones, qui bloquent toutes les requêtes sur le thread.
  • Le parallélisme de Node.js nécessite des threads d’exécution explicites (de préférence gérés en pool) ou plusieurs processus.
  • Le planificateur de Go transforme automatiquement les goroutines concurrentes en exécution parallèle, mais la mémoire partagée rend les courses de données un risque à éviter ; privilégiez les canaux et utilisez le détecteur de courses.