Comprendre les Goroutines en Go
Qu'est-ce qu'un thread ?
Un thread est l'unité de base utilisée par les systèmes d'exploitation modernes pour l'exécution des CPU. Un thread comprend principalement un ID de thread, un compteur de programme, des registres et une pile d'exécution. Les threads partagent la zone de code, la zone de données et les ressources du système d'exploitation de leur processus.
Pourquoi les threads sont-ils "lourds" ?
Les threads ont beaucoup de contexte, et le système d'exploitation doit les planifier pour éviter qu'un thread monopolise le CPU. La planification des threads implique des commutations de contexte : arrêter le thread en cours d'exécution, sauvegarder son état (contexte), sélectionner un autre thread, charger son contexte et l'exécuter.
La commutation de thread est coûteuse en temps car elle implique plusieurs éléments :
- Le compteur de programme
- Les registres
- Le cache CPU
- La planification CPU
- La gestion de l'état des threads
- ...
Qu'est-ce qu'une coroutine ?
Les coroutines ne sont pas un nouveau concept. De nombreux langages les utilisent déjà, comme les VirtualThread de Java 19, asyncio de Python, async/await de JavaScript ES6, C#, Kotlin, etc.
Une coroutine est un thread léger. La relation entre les coroutines légères et les threads lourds du système d'exploitation est de type M:N (généralement M < N), ce qui réduit les coûts de planification et de commutation.
Les avantages des coroutines incluent :
- Faible empreinte mémoire : Selon Go, créer une coroutine ne nécessite que 2 Ko de mémoire.
- Faible coût de commutation : La commutation de coroutine n'implique que la planification au niveau utilisateur, sans les éléments coûteux de la commmutation de thread.
- Création et destruction rapides : Gérées par le programme utilisateur, donc plus rapides.
Relations de mappage entre coroutines et threads
Les threads peuvent être considérés comme les CPU des coroutines. Une coroutine doit être planifiée sur un thread pour s'exécuter. Plusieurs modèles de mappage existent :
Un-à-un
À un moment donné, chaque coroutine est gérée et exécutée par un thread spécifique.
Un-à-plusieurs
Pendant une période, une coroutine peut être déplacée vers différents threads, mais à un instant précis, elle n'exécute que sur un seul thread.
Plusieurs-à-un
Plusieurs coroutines peuvent être planifiées sur le même thread pendant une période donnée.
Plusieurs-à-plusieurs (M:N)
Le modèle d'exécution des coroutines est M:N, où M coroutines sont mappées sur N threads.
Les Goroutines en Go
Go fournit un modèle et des API pour les coroutines, mais pas de modèle et d'API directs pour les threads.
Caractéristiques des goroutines Go
- Légères
- Exécution concurrente
- Exécution asynchrone
- Réutilisation : réutilisation des threads du système d'exploitation
- Communication et synchronisation entre goroutines via les Channels
- Planification non préemptive : une goroutine en cours d'exécution ne peut être interrompue que par elle-même (par exemple, lors d'une opération d'E/S)
- Commutation de contexte efficace
- Fermeture élégante
- Ne bloque pas le thread principal : si le thread principle se termine, les goroutines se terminent aussi
Environnement
Nous utiliserons go testing et testify pour écrire des tests démontrant les caractéristiques des goroutines.
go get github.com/stretchr/testify
import (
"fmt"
"runtime"
"testing"
"time"
"context"
"github.com/stretchr/testify/assert"
)
Création de goroutines
En Go, créer une goroutine est simple : il suffit d'utiliser le mot-clé go suivi de la fonction à exécuter.
Création avec une fonction standard
func fonctionStandard(termine chan bool) {
println("Bonjour, Goroutine de fonction standard")
termine <- true
}
func TestGoroutine_CreerAvecFonctionStandard(t *testing.T) {
termine := make(chan bool)
go fonctionStandard(termine)
resultat := <-termine
assert.True(t, resultat)
}
Création avec une fonction anonymee/fermeture
func TestGoroutine_CreerAvecFonctionAnonyme(t *testing.T) {
termine := make(chan bool)
go func() {
println("Bonjour, Goroutine de fonction anonyme")
termine <- true
}()
resultat := <-termine
assert.True(t, resultat)
}
Création avec une méthode de structure
type Structure struct {
termine chan bool
}
func (s *Structure) executer() {
println("Bonjour, Goroutine de méthode de structure")
s.termine <- true
}
func (s *Structure) attendre() {
<-s.termine
}
func TestGoroutine_CreerAvecMethodeStructure(t *testing.T) {
s := &Structure{
termine: make(chan bool),
}
go s.executer()
s.attendre()
}
Création avec interface{} et réflexion
func planifierFonction(wg *sync.WaitGroup, f interface{}, args ...interface{}) {
valeurFonction := reflect.ValueOf(f)
entrees := make([]reflect.Value, len(args))
for k, param := range args {
entrees[k] = reflect.ValueOf(param)
}
wg.Add(1)
go func() {
defer wg.Done()
valeurFonction.Call(entrees)
}()
}
func tache1(message string) {
fmt.Printf("Message : %s\n", message)
}
func tache2(premier, second string) {
fmt.Printf("Message combiné : %s-%s\n", premier, second)
}
func TestGoroutine_CreerAvecReflexion(t *testing.T) {
var wg sync.WaitGroup
planifierFonction(&wg, tache1, "Bonjour, goroutine !")
planifierFonction(&wg, tache2, "Salut", "goroutine !")
wg.Wait()
}
Configuration du nombre de threads et de goroutines
Nombre de threads par défaut
func TestNombreThreads_ParDefaut(t *testing.T) {
// Si GOMAXPROCS() reçoit 0, il retourne le nombre de threads
nombre := runtime.GOMAXPROCS(0)
fmt.Printf("nombre de threads par défaut : %d\n", nombre)
}
Définition du nombre de threads
func TestNombreThreads_Defini(t *testing.T) {
nombreSpecifique := 4
runtime.GOMAXPROCS(nombreSpecifique)
fmt.Printf("nombre de threads défini : %d\n", nombreSpecifique)
assert.Equal(t, nombreSpecifique, runtime.GOMAXPROCS(0))
}
Définition via variable d'environnement
export GOMAXPROCS=4
Fermeture de goroutines
Fermeture automatique
Une goroutine se termine lorsque son exécution est terminée, comme dans les exemples précédents.
Fermeture manuelle
La fermeture manuelle nécessite des mécanismes de contrôle. Deux exemples :
- Transmission d'un signal de cancellation via context
- Envoi d'un signal de cancellation via channel
Tâche de fond
func tacheDeFond(ctx context.Context) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
println("Bonjour, tâche de fond")
}
}
}
Transmission d'un signal de cancellation via context
func TestGoroutine_ArretAvecContext(t *testing.T) {
ctx, annuler := context.WithCancel(context.Background())
go tacheDeFond(ctx)
time.Sleep(time.Second * 5)
annuler()
time.Sleep(time.Second * 2)
}
Envoi d'un signal de cancellation via channel
func tacheSignal(signal chan bool) {
for {
select {
case <-signal:
return
default:
println("Bonjour, tâche de signal")
time.Sleep(time.Second * 1)
}
}
}
func TestGoroutine_ArretAvecSignal(t *testing.T) {
signal := make(chan bool)
go tacheSignal(signal)
time.Sleep(time.Second * 5)
signal <- true
time.Sleep(time.Second * 2)
}