# Multitâche en Go : Comment le CPU, les caches et le planificateur influencent les performances
Les applications Go modernes exploitent intensivement le parallélisme, mais de nombreux développeurs ne saisissent pas pleinement ce qui se passe sous le capot lors de l'utilisation de plusieurs cœurs CPU. Cet article plonge dans les mécanismes de bas niveau qui impactent les performances des programmes multitâches : des détails sur la commutation de contexte à la cohérence des caches. Nous expliquerons pourquoi les opérations atomiques et les barrières mémoire existent, et comment leurs subtilités se manifestent dans des scénarios réels.
Mécanismes de multitâche : coopératif vs. préemptif
Le défi principal des systèmes monocœur est de gérer plusieurs tâches simultanément. Les solutions se déclinent en deux variantes :
- Multitâche coopératif — les tâches cèdent volontairement le CPU via des appels système comme
sched_yield(). L'inconvénient est évident : une tâche bloquée fige tout le système (comme sous Windows 3.1).
- Multitâche préemptif — les tâches sont interrompues de force par un temporisateur. Étapes clés :
- Génération d'une interruption matérielle
- Sauvegarde du contexte de la tâche courante en RAM
- Sélection d'une nouvelle tâche par le planificateur
- Restauration du contexte
Il est crucial de comprendre que le contexte de tâche inclut :
- Valeurs des registres (RAX, RBX, etc.)
- Pointeur d'instruction (RIP)
- Pointeur de pile (RSP)
- Registre des drapeaux (RFLAGS)
- Données de pile
La commutation de contexte nécessite de sauvegarder ces données en RAM, ce qui génère une surcharge. Sur les CPU modernes, une commutation de contexte prend 200–300 cycles — l'équivalent de l'exécution de dizaines d'instructions.
Architecture des systèmes multi-cœurs et hiérarchie des caches
Le passage aux processeurs multi-cœurs a introduit un nouveau problème : synchroniser l'accès à la mémoire partagée. Pour comprendre les solutions, décomposons la hiérarchie des caches :
Cache L1
- Taille : 32–64 Ko
- Latence : ~4 cycles
- Divisé en instructions (L1i) et données (L1d)
- Lié à un cœur physique
Cache L2
- Taille : 256 Ko – 1 Mo
- Latence : 10–15 cycles
- Généralement lié à un cœur
Cache L3
- Taille : 8–40 Mo
- Latence : 30–50 cycles
- Partagé entre tous les cœurs
Point clé : le processeur gère les caches via un contrôleur matériel. L'OS et les applications ne peuvent pas contrôler directement le placement des données. Cela conduit à des situations où différents cœurs travaillent avec des copies obsolètes des données.
Protocole MESI et cohérence des caches
La cohérence des caches est gérée par le protocole MESI (Modified, Exclusive, Shared, Invalid). Chaque ligne de cache (typiquement 64 octets) possède l'un des quatre états :
- Modified : données modifiées dans ce cache et différentes de la RAM
- Exclusive : données identiques à la RAM, accessibles uniquement à ce cœur
- Shared : données identiques à la RAM et potentiellement présentes dans d'autres caches
- Invalid : données obsolètes et inutilisables
Exemple d'opération d'écriture :
- Cœur A lit la variable → état Shared
- Cœur B lit la même variable → état Shared
- Cœur A écrit une nouvelle valeur → envoie une requête d'invalidation
- Cœur B reçoit la requête et passe la ligne en Invalid
Ce mécanisme introduit des délais cachés : les écritures concurrentes obligent les cœurs à échanger des signaux via le bus, ce qui peut prendre 100–300 cycles.
Implications pratiques pour les développeurs Go
Maîtriser ces mécanismes est essentiel lors du travail avec :
- Opérations atomiques : instructions
LOCK XCHGassurent l'atomicité via verrouillage du bus - Barrières mémoire :
mfencecontrôle l'ordre d'exécution des instructions - Affinité CPU : liaison des goroutines à des cœurs spécifiques via
GOMAXPROCS
Exemple de faux partage :
var data [2]int64
// Goroutine 1
for {
data[0]++
}
// Goroutine 2
for {
data[1]++
}
Même s'ils accèdent à des éléments d'array différents, ils tombent dans la même ligne de cache (64 octets). L'écriture dans data[0] invalide la ligne pour le second cœur, déclenchant une synchronisation constante. Solution : ajouter du padding :
var data [2]struct{
value int64
_ [56]byte // Padding to 64 bytes
}
Points clés
- La commutation de contexte sauvegarde l'état en RAM, causant ~200 cycles de délai
- La cohérence des caches via MESI ajoute une surcharge aux écritures concurrentes
- Le faux partage peut dégrader les performances de 2–3 ordres de grandeur
- Les opérations atomiques utilisent le verrouillage de bus ou les protocoles de cache (dépendant de l'architecture)
- Comprendre la hiérarchie des caches permet d'optimiser les structures de données à la taille des lignes de cache
Les développeurs Go rencontrent souvent ces problèmes lors de la construction de services à forte charge. Par exemple, dans les systèmes distribués, une mauvaise gestion des caches peut causer une latence imprévue lors du traitement de milliers de requêtes par seconde. Leçon principale : un parallélisme efficace exige de prendre en compte non seulement la logique applicative, mais aussi les particularités du matériel.
Pour des analyses plus approfondies, examinez la sortie assembleur du compilateur Go avec l'option -S, et utilisez des profileurs comme perf pour mesurer les misses de cache et les commutaisons de contexte. Cela révèle des goulots d'étranglement invisibles au niveau du code source.
— Editorial Team
Aucun commentaire pour le moment.