Ces quatre mots se retrouvent partout dans les docs, les articles, les entretiens. Et ils sont mélangés en permanence — y compris par des devs expérimentés. "Asynchrone" et "concurrent" ne veulent pas dire la même chose. "Parallèle" et "concurrent" non plus. Et un programme peut être les quatre à la fois, ou seulement deux, et ça change ce qu'il fait concrètement.
Cet article complète le précédent sur concurrence et parallélisme en ajoutant la dimension synchrone/asynchrone, et en montrant comment les quatre notions s'articulent en Go.
Synchrone vs asynchrone : une question d'attente
Ces deux mots ne parlent pas de "combien de tâches tournent en même temps". Ils parlent de ce que fait le code pendant qu'il attend une réponse.
Synchrone : tu envoies une requête, tu t'arrêtes et tu attends la réponse avant de faire quoi que ce soit d'autre. Comme appeler quelqu'un au téléphone et rester muet jusqu'à ce qu'il réponde.
Asynchrone : tu envoies une requête, tu continues à faire autre chose, et tu traites la réponse quand elle arrive. Comme envoyer un SMS — tu poses ton téléphone et tu reprends ta vie.
package main
import (
"fmt"
"time"
)
func appelAPI() string {
time.Sleep(200 * time.Millisecond) // simule une requête réseau
return "résultat"
}
func synchrone() {
// On attend. On ne fait rien d'autre pendant 200ms.
résultat := appelAPI()
fmt.Println("synchrone :", résultat)
}
func asynchrone() {
ch := make(chan string, 1)
// On lance l'appel, on n'attend pas
go func() {
ch <- appelAPI()
}()
// On peut faire autre chose ici pendant que l'appel est en cours
fmt.Println("asynchrone : je fais autre chose pendant l'appel...")
// On récupère le résultat quand on en a besoin
résultat := <-ch
fmt.Println("asynchrone : résultat reçu :", résultat)
}
La version synchrone bloque pendant 200ms. La version asynchrone continue à travailler pendant ce temps. Dans une application qui fait 1000 appels réseau par seconde, la différence est massive.
Les quatre combinaisons possibles
Ce qui rend les choses confuses, c'est que synchrone/asynchrone et concurrent/parallèle sont deux axes indépendants. Un programme peut être n'importe quelle combinaison des deux :
| Synchrone | Asynchrone | |
|---|---|---|
| Séquentiel | Script bash classique | Node.js (1 thread, callbacks) |
| Concurrent | Goroutines qui se bloquent sur des channels | Goroutines avec I/O non-bloquante |
| Parallèle | Threads qui attendent leurs résultats | Go en prod : goroutines sur plusieurs cœurs |
Node.js est un exemple parfait de code asynchrone mais séquentiel : un seul thread, mais il ne bloque jamais — il délègue les I/O et reprend quand c'est prêt via la boucle d'événements. Go fait différemment : il est concurrent et peut être parallèle, et laisse le développeur choisir si une opération est synchrone ou asynchrone.
Go : synchrone en surface, asynchrone sous le capot
C'est une des forces de Go qui est souvent mal comprise. Quand vous écrivez une requête HTTP en Go, ça ressemble à du code synchrone :
// Ça ressemble à "on attend" — mais Go ne bloque pas le thread OS
resp, err := http.Get("https://api.exemple.com/data")
if err != nil {
return err
}
defer resp.Body.Close()
En réalité, quand cette goroutine attend la réponse réseau, le runtime Go la met
en pause et utilise le thread OS pour faire avancer d'autres goroutines.
Vous écrivez du code qui lit comme du synchrone, mais qui se comporte
comme de l'asynchrone. C'est le meilleur des deux mondes : pas de callback hell,
pas de async/await partout, mais pas de thread bloqué non plus.
Comparez avec le même pattern en JavaScript :
// JavaScript : obligé d'écrire l'asynchronisme explicitement
const resp = await fetch('https://api.exemple.com/data')
const data = await resp.json()
En JS, le await est nécessaire pour ne pas bloquer le thread unique.
En Go, vous n'avez pas à y penser — la goroutine se "gèle" toute seule pendant l'I/O
et le thread OS reste disponible.
Concurrence dans le parallélisme : les deux ensemble
Un programme Go en production est souvent concurrent ET parallèle en même temps. Voilà comment les deux coexistent :
- Vous avez 8 cœurs sur la machine → Go utilise 8 threads OS (
GOMAXPROCS=8) - Vous avez 10 000 goroutines → Go les répartit sur les 8 threads
- Chaque thread fait de la concurrence : il alterne entre plusieurs goroutines
- Les 8 threads ensemble font du parallélisme : ils tournent vraiment en même temps
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
fmt.Printf("Cœurs disponibles : %d\n", runtime.NumCPU())
fmt.Printf("Threads utilisés (GOMAXPROCS) : %d\n", runtime.GOMAXPROCS(0))
var wg sync.WaitGroup
resultats := make(chan int, 1000)
// 1000 goroutines lancées — concurrence + parallélisme simultanément
// Go les répartit sur les threads disponibles
for i := range 1000 {
wg.Add(1)
go func(n int) {
defer wg.Done()
resultats <- n * n // calcul en parallèle sur plusieurs cœurs
}(i)
}
go func() {
wg.Wait()
close(resultats)
}()
total := 0
for r := range resultats {
total += r
}
fmt.Println("Somme des carrés :", total)
}
Sur une machine 8 cœurs : les 1000 goroutines sont réparties sur 8 threads. Chaque thread alterne entre ~125 goroutines (concurrence). Les 8 threads tournent en même temps (parallélisme). Résultat : 1000 calculs traités beaucoup plus vite qu'en séquentiel.
Quand utiliser quoi en Go ?
En pratique, voici comment choisir :
Code séquentiel synchrone — le défaut. Script, traitement simple, logique métier sans I/O. Lisible, prévisible, rien à gérer.
// Séquentiel synchrone — simple et suffisant
func traiterCommande(cmd Commande) error {
if err := valider(cmd); err != nil {
return err
}
if err := sauvegarder(cmd); err != nil {
return err
}
return notifier(cmd)
}
Concurrent asynchrone — quand vous faites plusieurs appels I/O indépendants (plusieurs APIs, plusieurs requêtes BDD). Lancez-les en parallèle, récupérez les résultats.
// Concurrent asynchrone — 3 appels en parallèle au lieu de séquentiel
func récupérerDonnées(ctx context.Context, id string) (Résultat, error) {
chUser := make(chan User, 1)
chOrders := make(chan []Order, 1)
chStats := make(chan Stats, 1)
go func() { chUser <- getUser(ctx, id) }()
go func() { chOrders <- getOrders(ctx, id) }()
go func() { chStats <- getStats(ctx, id) }()
// Les 3 appels tournent en même temps
// Temps total = max(temps user, temps orders, temps stats)
// au lieu de sum(temps user + temps orders + temps stats)
return Résultat{
User: <-chUser,
Orders: <-chOrders,
Stats: <-chStats,
}, nil
}
Worker pool — quand vous avez beaucoup de tâches similaires et que vous voulez contrôler la charge. Pas 10 000 goroutines simultanées, mais N workers qui pigent dans une file.
// Worker pool : N goroutines traitent M tâches
func traiterEnParallèle(tâches []Tâche, nbWorkers int) {
file := make(chan Tâche, len(tâches))
for _, t := range tâches {
file <- t
}
close(file)
var wg sync.WaitGroup
for range nbWorkers {
wg.Add(1)
go func() {
defer wg.Done()
for t := range file {
traiter(t) // chaque worker prend une tâche à la fois
}
}()
}
wg.Wait()
}
Le résumé en une image mentale
Pour ne plus jamais mélanger les quatre concepts :
- Synchrone/Asynchrone = est-ce que j'attends le résultat avant de continuer ? Oui → synchrone. Non, je continue et je récupère plus tard → asynchrone.
- Séquentiel/Concurrent = est-ce que je gère plusieurs tâches en même temps ? Non, une après l'autre → séquentiel. Oui, j'alterne → concurrent.
- Séquentiel/Parallèle = est-ce que plusieurs tâches s'exécutent physiquement en même temps sur plusieurs cœurs ? Non → séquentiel. Oui → parallèle.
Go rend tout ça relativement transparent : vous lancez une goroutine, Go décide si elle tourne en concurrence sur un thread ou en parallèle sur plusieurs. Vous écrivez du code qui ressemble à du synchrone, Go gère l'asynchronisme sous le capot pendant les I/O. C'est pour ça que Go est agréable à écrire pour ce genre de problèmes — vous pensez à la logique, pas à la plomberie des threads.