Bibliothèque · Résumé et avis

Go avancé

De Thierry Fournier (D-BookeR, 2026). Une visite du moteur de Go (slices, maps, panics, mémoire) : une bonne carte, avec quelques erreurs à vérifier avant de la suivre.

FR EN
Couverture de Go avancé, Thierry Fournier

Go avancé

Go avancé : Au cœur du runtime, architecture, concurrence et optimisations

6.8 /10

« Précieux sur le moteur de Go, trompeur par endroits, à lire le terminal ouvert. »

  • AuteurThierry Fournier
  • ÉditeurD-BookeR, 2026 · 612 pages
  • LangueEn français
  • Fiche~47 min de lecture
Notation du livre sur 6 dimensionsIdées7/10Applicable6,5/10Lisibilité7/10Actualité7/10Exemples5/10Profondeur8,5/10

Une visite du moteur de Go (slices, maps, panics, mémoire) : une bonne carte, avec quelques erreurs à vérifier avant de la suivre.

Pourquoi ce livre

Un programme Go tourne bien, puis son conteneur se fait tuer faute de mémoire. Un conteneur est la boîte isolée où le programme s'exécute, sur une machine partagée avec d'autres (avec Docker, par exemple). On y fixe des plafonds de CPU et de mémoire. Ou bien modifier une liste en change une autre, sans raison apparente. Pour comprendre, il faut regarder sous le capot, dans le runtime de Go : le code embarqué dans chaque programme, qui gère la mémoire et fait tourner les tâches en parallèle.

Ce regard sous le capot, c'est le propos du livre de Thierry Fournier (éditions D-BookeR). Il compte plus de 600 pages sur la mémoire, les slices, les maps, defer et les goroutines. Il traite aussi le partage du travail entre les cœurs, la synchronisation, le compilateur et le profilage. L'exemplaire, un PDF, m'a été fourni par les Éditions D-BookeR, qui me l'ont proposé après avoir découvert ce blog. Cette fiche ne contient aucun lien d'affiliation, et l'avis reste franc.

Ce livre explique très bien ce que Go fait sous le capot. Mais il se trompe parfois sur le détail. Un morceau de code recopiable ne compile pas. Un « 493 fois plus vite » repose sur une boucle vide : le compilateur avait supprimé l'allocation qu'on croyait mesurer. J'en montre quelques-unes plus bas, avec de quoi les vérifier.

Les idées qui restent

Les vingt idées suivent les quatre parties du livre, dans son ordre :

1Conteneur limité à un cœur : Go peut y lancer huit fils, Go 1.25 corrige cela sous condition

Commençons par le processeur. Un programme Go compte souvent bien plus de goroutines, des tâches légères lancées avec go, que de cœurs. Le scheduler de Go (l'ordonnanceur) les fait passer à tour de rôle sur des fils d'exécution (threads), que le système confie aux cœurs. GOMAXPROCS fixe combien de fils exécutent du code Go en même temps. Une goroutine qui tourne occupe un fil, posé sur un cœur : c'est donc aussi le nombre de goroutines qui tournent réellement en parallèle. Par défaut, c'est le nombre de cœurs logiques que Go détecte au démarrage (l'hyperthreading en compte deux par cœur physique, p. 13).

Image : une cuisine. Les goroutines sont des commandes à préparer, les fils sont les cuisiniers, et GOMAXPROCS est le nombre de postes de travail. Un cuisinier sans poste ne cuisine pas. Le livre donne les vrais noms : G pour la goroutine, M pour le fil, P pour le poste, et chaque P tient la liste des G à exécuter (p. 12). Sa règle : « peu importe le nombre de goroutines créées ou de threads disponibles, seul le nombre de P détermine combien de goroutines peuvent réellement s’exécuter simultanément » (p. 13).

Imaginez une machine de huit cœurs, donc capable de faire huit choses à la fois. Un conteneur est une boîte qui isole un programme sur la machine et lui impose des limites. Ici, la boîte ne vous prête qu'un cœur sur huit. Comment Linux fait-il respecter cette limite ? Avec un forfait de temps de calcul, renouvelé toutes les 100 millisecondes. Chaque période de 100 ms s'appelle une tranche.

Le forfait vaut le nombre de cœurs prêtés fois 100 ms. Un cœur prêté donne 100 ms par tranche, deux cœurs en donnent 200 ms, et ainsi de suite. Pensez à un réservoir qu'on remplit au début de chaque tranche : chaque fil qui travaille y puise 1 ms par milliseconde qui passe. Quand le réservoir est vide, Linux met tous les fils en pause jusqu'à la tranche suivante. Ce forfait s'appelle un quota, et le mécanisme de Linux qui le compte, un cgroup (p. 19).

Le piège, avec un Go d'avant 1.25 : Go ne voit pas ce forfait. Il regarde la machine, compte ses huit cœurs et lance huit fils en parallèle. Son GOMAXPROCS vaut 8 (p. 19).

Ma lecture

Le livre date ici d'avant Go 1.25. Le livre écrit « Le runtime Go n’est pas nativement conscient des quotas cgroup » (p. 19). C'est vrai jusqu'à Go 1.24.

Reprenons un cœur prêté, donc 100 ms de forfait par tranche. Huit fils qui calculent sans pause, comme dans un programme saturé, puisent 8 ms par milliseconde. Le réservoir est donc vide au bout de 12,5 ms (100 ÷ 8). Pendant les 87,5 ms qui restent de la tranche, tout est gelé : c'est le throttling (l'étranglement) (p. 20).

Une requête reçue pendant ce gel attend sa fin. D'où cette phrase : « Une requête qui devrait prendre 10ms peut alors prendre jusqu’à 100ms à cause de cette attente forcée » (p. 20).

La règle générale : le réservoir se vide au bout de forfait ÷ nombre de fils, et le gel dure le reste de la tranche. Voici une tranche de 100 ms, pour un conteneur et un programme que vous réglez vous-même, avec des fils qui travaillent sans pause. Sous la courbe, le programme tourne. Au-dessus, il est gelé. Changez les cœurs prêtés et GOMAXPROCS :

Sur une tranche de 100 ms, le temps où le programme tourne (sous la courbe) et le temps où il est gelé (au-dessus), selon GOMAXPROCS et les cœurs prêtés. Une tranche de 100 ms (en ms) 1 cœur prêté, 8 fils 0 25 50 75 100 1 2 4 6 8 10 12 14 16 GOMAXPROCS (nombre de fils qui travaillent sans pause) le programme tourne le programme est gelé tourne 12,5 ms

Avec 8 fils et 1 cœur prêté, le forfait de 100 ms est vide au bout de 12,5 ms, puis le gel dure 87,5 ms.

Sous la courbe, le programme tourne. Au-dessus, jusqu'à 100 ms, il est gelé. Changez les deux valeurs : la courbe suit.

Pourquoi ne pas descendre à 1, qui ne gèle jamais ? Parce que « Réduire GOMAXPROCS à 1 peut impacter négativement le garbage collector » (p. 18). Le ramasse-miettes (GC) est le travail de fond qui libère la mémoire des objets devenus inutiles. Il tourne d'ordinaire en parallèle du programme, sur un autre fil. Avec un seul fil, ils doivent se relayer : le programme s'arrête pendant que le GC passe (p. 20). Le livre fixe donc un plancher de 2.

Ma lecture

Hors livre, notes de version de Go 1.25. Depuis Go 1.25, Go lit lui-même le quota du conteneur, mais seulement si la ligne go du go.mod dit 1.25 ou plus. Mon essai, avec Docker, --cpus=2 (un quota de 2 cœurs) et un hôte, la machine physique, à 16 cœurs :

  • ligne go 1.24 : le programme voit 16 cœurs
  • ligne go 1.25 ou plus : il en voit 2
  • ligne go 1.24 avec GODEBUG=containermaxprocs=1 : 2

GODEBUG est une variable d'environnement qui active ou coupe un comportement du runtime, et containermaxprocs est celui-ci. Avec un quota plus petit (0,5, 1 ou 1,5 cœur), Go en voit encore 2 : c'est le plancher. Le livre ne mentionne pas ce changement.

Les remèdes du livre restent valables pour un Go plus ancien. Le premier : lire le quota dans les fichiers du cgroup, puis régler GOMAXPROCS soi-même, par la variable d'environnement du même nom ou par runtime.GOMAXPROCS(n) (pp. 14, 20). Il donne une formule, GOMAXPROCS = max(2, ceil(quota)) : un quota de 200000 µs (200 ms) pour une tranche de 100000 µs (100 ms) vaut 2,0 cœurs (p. 20). Le second est la bibliothèque automaxprocs, qui lit les fichiers du cgroup au démarrage (p. 21).

Ce que vous emportez : depuis Go 1.25, Go peut lire lui-même le forfait du conteneur et régler GOMAXPROCS en conséquence, mais pas toujours. Deux choses doivent être vraies à la fois. D'abord, la version de Go qui compile votre programme doit être la 1.25 ou une plus récente. Ensuite, la ligne go de votre fichier go.mod (celle qui annonce la version de Go visée par le projet) doit dire 1.25 ou plus. Si l'une des deux est trop ancienne, Go compte les cœurs de la machine comme avant, et le piège revient.

Il y a deux façons de le réparer. La plus propre : poser GODEBUG=containermaxprocs=1, qui réactive seulement ce comportement. L'autre : monter la ligne go à 1.25, mais cela active aussi d'autres changements de Go 1.25, que vous n'avez pas forcément choisis.

2Au démarrage, Go prépare vos variables selon leurs besoins, pas selon l'ordre où vous les écrivez

Avant d'ouvrir un restaurant, on prépare la cuisine. On ne fait pas la sauce avant d'avoir sorti les tomates. Un programme Go fait pareil avant de lancer main, sa fonction de départ. Il prépare d'abord ses variables globales, celles écrites hors de toute fonction. Chacune peut avoir besoin d'une autre. Cette préparation s'appelle l'initialisation.

Le livre donne un exemple où l'ordre d'écriture a l'air faux (p. 33) :

var (
	client      = createClient(apiEndpoint, timeout)
	apiEndpoint = baseURL + "/v1"
	baseURL     = "https://api.example.com"
	timeout     = 30 * time.Second
)

client a besoin de apiEndpoint et de timeout. apiEndpoint a besoin de baseURL, écrit plus bas. On s'attend à une erreur à la compilation, l'étape où Go transforme le code en programme. Pourtant ça compile et ça marche (p. 33).

Go ne lit pas la liste de haut en bas. Il prend la première variable de la liste dont tous les besoins sont déjà prêts, la prépare, puis recommence. Ici : baseURL, apiEndpoint, timeout, puis client (p. 34).

Une fois les variables prêtes, Go lance les fonctions init(). Vous les écrivez, mais vous ne les appelez jamais : Go les lance seul, avant main. À quoi servent-elles ? Une bibliothèque, ou package (un dossier de code Go, comme fmt), s'en sert pour s'installer toute seule au démarrage. Le package image/png, par exemple, s'annonce ainsi comme lecteur de fichiers PNG.

Plusieurs init() peuvent se suivre : dans quel ordre ? Dans un fichier, de haut en bas. Entre fichiers, Go suit l'ordre des noms : a.go, puis b.go, puis main.go (p. 34). Renommez a.go en z.go et son init() passe en dernier.

Évitez donc qu'un init() compte sur un autre. Imaginez-en un qui ouvre la configuration, et un autre pour la lire. Après un renommage de fichier, le second passe d'abord et lit du vide. Le livre prévient : « Un simple renommage de fichier peut briser la logique d’initialisation » (p. 35). Mon conseil, hors livre : écrire rarement des init(), et mettre dans main ce qui doit se faire dans un ordre précis, où on le lit.

Tout ce travail se paie au démarrage. Pour voir combien, compilez avec go build -o app, puis lancez GODEBUG=inittrace=1 ./app (la variable d'environnement GODEBUG, vue à l'idée 1). Le programme écrit une ligne par package qui a du travail d'initialisation. Voici la ligne de l'exemple ci-dessus. Son initialisation, c'est préparer ses quatre variables :

init main @0.42 ms, 0.005 ms clock, 64 bytes, 2 allocs

Le chiffre devant clock est la durée de l'initialisation du package, ici 5 microsecondes. @0.42 ms dit quand elle a commencé, après le lancement. Le reste est la mémoire demandée : 64 octets, en 2 demandes. Les temps changent un peu à chaque lancement. Si la durée devant clock d'un package grimpe, regardez ce qu'il fait au démarrage (réseau, fichier, gros calcul).

Ma lecture

Mes essais, hors livre. J'ai rejoué l'exemple du livre. L'ordre est bien celui du schéma, et la ligne inittrace ci-dessus est sa vraie sortie. Avec trois fichiers, renommer a.go en z.go fait passer son init() en dernier. Le cas de image/png vient du code de Go.

3Un booléen partagé n'est pas un signal : seule une synchronisation donne une garantie

Un cuisinier prépare le plat, puis lève un drapeau. Le serveur attend le drapeau, puis emporte le plat. Rien de plus naturel. Mais rien n'est garanti sans synchronisation, un point où deux goroutines se mettent d'accord (un channel ou un verrou, par exemple). En Go, cela s'écrit avec deux goroutines qui s'exécutent en parallèle. C'est le « piège classique » du livre :

// ✗ AVANT : le code du livre
package main

import "fmt"

var ready bool
var data int

func producer() {
	data = 42
	ready = true
}

func main() {
	go producer()
	for !ready {
	}
	fmt.Println(data)
}

Le livre pose le raisonnement qu'on est tenté de faire : « Le raisonnement semble logique : Si ready = true, alors forcément data = 42 a été exécuté avant. » (p. 43). Puis il le casse. Pour aller plus vite, le compilateur ou le processeur peuvent exécuter deux écritures indépendantes dans l'autre ordre (p. 43). C'est permis quand rien ne dépend de leur ordre, vu depuis une seule goroutine. La goroutine qui lit peut alors voir le cas « ready = true, data = 0 (ready écrit mais pas encore data : le bug) » (p. 44).

Dans une seule goroutine, Go garantit le résultat de l'ordre écrit. Entre deux goroutines, rien de tel. Imaginez que chaque cuisinier tienne son carnet personnel. Le cache est la petite mémoire rapide de chaque cœur du processeur, son carnet, où il recopie ce qu'il utilise. Quand le cuisinier note « plat prêt » dans le sien, le serveur, qui lit un autre carnet, ne le voit pas forcément tout de suite : chaque cœur voit les écritures à son rythme (pp. 43-44). Rien n'impose non plus que « plat posé » atteigne le carnet du serveur avant « plat prêt ». Ces carnets sont une image, pas le fonctionnement exact du matériel. La garantie vient d'une règle que le livre appelle happens-before (« arrive avant »). Elle relie deux événements, A et B, et dit : « A sera observable par toutes les goroutines avant que B ne devienne observable » (p. 45), si A arrive avant B. Traduction : tout ce qu'A a écrit est visible quand B s'exécute. Chez nous, data = 42 arrive déjà avant ready = true, dans la même goroutine. Ce qui manque, c'est un lien entre l'écriture de ready et sa lecture par l'autre goroutine.

Reste à créer ce lien, et un drapeau ordinaire n'en crée pas. Le livre en donne le tableau (pp. 52-53). Quatre outils le créent :

OutilCe que c'estCe qui arrive avant
Channelun tuyau entre goroutinesl'envoi arrive avant la réception
Mutexun verrou qui ne laisse passer qu'une goroutine à la foisUnlock arrive avant le Lock suivant
WaitGroupun compteur qui attend la fin d'un groupe de goroutinesDone arrive avant le retour de Wait
Atomiqueune lecture ou une écriture d'un seul bloc, sans que personne ne s'intercaleun Store arrive avant le Load qui lit sa valeur

Il suffit donc de faire de ready un atomique : on l'écrit avec Store, on l'attend avec Load.

// ✓ APRÈS : ready devient un atomique
package main

import (
	"fmt"
	"sync/atomic"
)

var ready atomic.Bool
var data int

func producer() {
	data = 42
	ready.Store(true)
}

func main() {
	go producer()
	for !ready.Load() {
	}
	fmt.Println(data)
}

data reste une variable ordinaire : le Store de ready arrive avant le Load qui le lit, donc l'écriture de data qui précède est visible aussi.

Le programme du livre (avant) a une data race (course aux données) : deux goroutines touchent la même variable, l'une au moins l'écrit, sans synchronisation entre elles. J'ai lancé le programme du livre avec Go 1.26.6 sur amd64. Il affiche 42. go vet, l'analyseur de code de Go, ne dit rien. Le détecteur de courses (-race), lui, parle (extrait, deux alertes en tout, une sur ready et une sur data) :

$ go run -race main.go
WARNING: DATA RACE
Write at 0x000000622288 by goroutine 8:
  main.producer()
...
Previous read at 0x000000622288 by main goroutine:
  main.main()
...
42
Found 2 data race(s)

Avec l'atomic.Bool, le même programme affiche 42 et -race se tait.

Le réflexe : lancez vos tests avec -race. C'est le seul outil qui voit ce bug.

Un piège voisin : la fin d'une goroutine ne lie rien non plus. Le livre l'écrit en majuscules dans un encadré : « LA TERMINAISON D’UNE GOROUTINE N’ÉTABLIT AUCUNE RELATION HAPPENS-BEFORE » (p. 50). Savoir qu'elle a fini ne suffit pas à voir ce qu'elle a écrit : il faut un WaitGroup ou un channel. Retenez ceci : pour dire « c'est prêt » à une autre goroutine, n'utilisez jamais une variable ordinaire.

Ma lecture

Mon essai, hors livre. Je n'ai jamais vu apparaître le cas ready = true, data = 0 sur amd64. Je l'ai cherché dans 3000 lancements du programme du livre. Je l'ai cherché aussi dans environ 22 millions de tours où ready passait à true, avec 1 à 96 paires de goroutines. Je ne l'affirme donc pas. Le programme « marche » à chaque lancement, et c'est le piège : un test vert ne prouve pas qu'il n'y a pas de course.

4Un objet coûte sa classe de taille : un pointeur de plus peut coûter 64 octets

Demandez à Go de la place pour 64 pointeurs. Un pointeur est une adresse mémoire de 8 octets, donc cela fait 512 octets. Go vous en réserve exactement 512. Ajoutez un seul pointeur, 65 en tout : 520 octets demandés, et Go en réserve 576. Huit octets de plus à la demande, soixante-quatre de plus à la réservation. D'où vient ce saut ?

Go fait deux choses, dans cet ordre. D'abord, il colle une petite étiquette de 8 octets, l'en-tête, sur les objets de plus de 512 octets qui contiennent des pointeurs. Ensuite, il range le tout dans la plus petite case toute faite qui le contient : les cases n'existent qu'en tailles fixes, comme 512 ou 576. Le tableau de 64 pointeurs fait pile 512 octets : pas d'étiquette, et il tient dans la case de 512.

Ce rangement est le travail de l'allocateur, le morceau du runtime (le moteur de Go, embarqué dans chaque programme) qui distribue la mémoire. Chaque demande qu'on lui fait est une allocation, et un objet est tout ce qu'on alloue : struct, tableau, string. Il ne coupe jamais au plus juste. Le livre l'écrit ainsi : « Au lieu d’allouer exactement la taille demandée, l’allocateur arrondit à la classe de taille supérieure la plus proche. » (p. 57).

Ces tailles fixes, les cases, sont les classes de taille : « Le runtime définit exactement 67 classes de taille, de 8 octets à 32 ko » (p. 58). Les premières montent de 8 en 8 (8, 16, 24, 32), puis l'écart grandit. Pensez à une consigne de gare : un sac trop gros pour le casier de 24 prend un casier de 32 tout entier. La place en trop reste perdue tant que le sac y est. Le livre donne un cas réel : « Quand un objet de 25 octets est alloué, il occupe une classe de 32 octets, gaspillant 7 octets » (p. 66).

Vérifions avec de vrais objets : j'en garde 2000 en vie, puis je divise par 2000 le nombre d'octets que Go a alloués. Dans le code, TotalAlloc compte les octets alloués depuis le début du programme et KeepAlive empêche de libérer les objets trop tôt :

package main

import (
	"fmt"
	"runtime"
	"unsafe"
)

func cout[T any](nom string) {
	const n = 2000
	gardes := make([]*T, 0, n)
	var avant, apres runtime.MemStats
	runtime.GC()
	runtime.ReadMemStats(&avant)
	for i := 0; i < n; i++ {
		gardes = append(gardes, new(T))
	}
	runtime.ReadMemStats(&apres)
	var zero T
	fmt.Printf("%-10s demandé %4d  payé %6.1f octets\n", nom, unsafe.Sizeof(zero),
		float64(apres.TotalAlloc-avant.TotalAlloc)/n)
	runtime.KeepAlive(gardes)
}

func main() {
	cout[[24]byte]("[24]byte")
	cout[[25]byte]("[25]byte")
	cout[[33]byte]("[33]byte")
	cout[[64]*int]("[64]*int")
	cout[[65]*int]("[65]*int")
}
[24]byte   demandé   24  payé   24.0 octets
[25]byte   demandé   25  payé   32.0 octets
[33]byte   demandé   33  payé   48.0 octets
[64]*int   demandé  512  payé  512.0 octets
[65]*int   demandé  520  payé  576.0 octets

La deuxième ligne suit le livre : 25 passe à 32. Les autres sont de mon essai (Go 1.26.6) : 24 tient pile, 33 passe à 48. Les deux dernières lignes sont notre casse-tête du début : 64 pointeurs gardent 512 octets, 65 en prennent 576. Le saut vient de l'en-tête, où Go note le type de l'objet (p. 64).

Mon tableau de 65 pointeurs fait 520 octets, plus 8 d'en-tête : 528. Les cases voisines sont 512, 576 et 640 (table de Go 1.26.6). Il va donc dans celle de 576, et 48 octets restent perdus.

Combien de place perd-on, en moyenne ? Le livre répond : environ 5,2 % (p. 66). Je l'ai vérifié (Go 1.26.6) : j'ai demandé à Go des tableaux d'octets de toutes les tailles, de 16 octets à 32 Ko, et mesuré ce qu'il réserve vraiment. Si toutes les tailles sont aussi fréquentes, 5,1 % de la mémoire réservée est perdue : le livre est dans le vrai. Les petits objets perdent plus : entre 16 et 128 octets, c'est 8,7 %. Au-delà de 128 octets, une case perd au plus 15,8 % de sa taille.

Un objet isolé perd donc peu. Mais un programme qui crée des millions d'objets identiques paie la perte des millions de fois. Pour ceux-là, mesurez la taille réellement réservée, comme ci-dessus, avant de choisir combien d'éléments mettre dans l'objet.

5L'allocateur a trois étages : presque toutes les demandes se servent au plan de travail de chaque poste

Un programme Go demande de la mémoire sans arrêt, depuis des centaines de goroutines à la fois. Si toutes passaient par un seul guichet, avec un seul verrou (un passage où une seule goroutine entre à la fois), elles feraient la queue.

Go évite la queue avec trois étages, que le livre décrit (pp. 59-62). Reprenons la cuisine de l'idée 1 : chaque poste de travail a son propre plan de travail, et deux étages partagés se trouvent derrière lui.

Chaque poste a donc un plan de travail, avec un bac d'ingrédients par taille de portion. Ce sont les 67 tailles de l'idée 4, les mêmes pour tous les postes. Au départ, aucun poste n'a de bac : la première fois qu'une taille est demandée, il va en chercher un au garde-manger (①). Ensuite, quand une goroutine demande un objet, il prend la prochaine portion libre du bon bac. Personne d'autre ne touche à son plan de travail : aucun verrou, et c'est très rapide.

Quand le bac est épuisé, faute de portion libre (p. 62), le poste le rend au garde-manger (②). Il en reprend un qui a encore des portions. Il y a un garde-manger par taille, partagé entre tous les postes, qui peuvent donc s'y gêner. Un bac rendu n'est pas perdu : quand le ramasse-miettes libère des objets, des portions y redeviennent disponibles et il retourne sur l'étagère verte.

Si le garde-manger n'a plus de bac avec des portions, il s'adresse à la réserve, une seule pour tout le programme, partagée elle aussi : elle en fabrique un neuf (③). Quand la réserve n'a plus de place, elle en commande au système d'exploitation, le fournisseur (④). Les très gros objets, à partir de 32 Ko, sautent les deux premiers étages et vont droit à la réserve (p. 61).

Le livre donne leurs vrais noms : le plan de travail est le mcache, le garde-manger le mcentral, la réserve le mheap. Chaque bac est un span, un bloc de mémoire réservé à une seule classe de taille (idée 4).

Chaque étage descendu coûte plus cher, parce qu'il est partagé et que le dernier passe par le système. On y descend pourtant rarement. Le livre dit du chemin lent qu'il « reste relativement rare » (p. 62). Un bac de petits objets en contient des centaines. Une seule descente en sert donc des centaines. Dans le code de Go 1.26.6, un bac fait 8 Ko pour la plupart des petites tailles et jusqu'à 80 Ko pour les plus grosses : plus la portion est grosse, moins il y en a par bac.

Ma lecture

Code de Go 1.26.6, hors livre. Ce qui est partagé coûte, ce qui est privé ne coûte rien. Les idées 17 et 18 le montrent : deux compteurs voisins se gênent, un verrou fait faire la queue. L'allocateur y répond en donnant à chaque poste son plan de travail. Un détail du livre est périmé : il écrit que chaque mcentral a son propre mutex, un verrou (p. 61). Dans Go 1.26.6, le garde-manger range ses bacs dans des ensembles qu'on modifie le plus souvent sans verrou (idée 20). Les postes se coordonnent encore pour partager, et ce partage coûte plus que le plan de travail privé.

6Le GC marque les objets en trois couleurs, pendant que votre programme tourne

Le ramasse-miettes (le GC) libère la mémoire des objets devenus inutiles. Comment sait-il qu'un objet ne sert plus ? Il ne le devine pas. Il part de ce qui est sûrement utile, puis il suit les pointeurs. Un objet qu'aucun chemin n'atteint est mort.

Les points de départ s'appellent les racines : les variables globales et les variables locales des goroutines. Les objets, eux, vivent dans le tas, la mémoire commune du programme. Le livre colore chaque objet du tas (p. 82) :

  • blanc : pas encore vu, candidat à la suppression.
  • gris : découvert, mais ses pointeurs n'ont pas encore été suivis.
  • noir : entièrement traité.

Au départ, tout est blanc, sauf les objets que les racines pointent : ceux-là sont gris (p. 83). Puis le GC répète une boucle : il prend un objet gris, grise les objets blancs qu'il pointe, et le noircit (p. 83). Quand il ne reste plus de gris, tout ce qui est encore blanc est hors d'atteinte : le GC le libère (p. 83). Deux objets morts qui se pointent l'un l'autre restent blancs aussi, puisqu'aucun chemin ne mène des racines jusqu'à eux. Avancez pas à pas dans le schéma.

Le marquage à trois couleurs, pas à pas : une racine, trois objets atteignables et deux objets morts qui se pointent l'un l'autre. Le ramasse-miettes grise puis noircit les objets atteignables, et libère les blancs restants. blanc : pas encore vu gris : vu, à suivre noir : traité racine A B C D E

Chaque cercle est un objet. Les flèches sont des pointeurs. Cliquez sur « Étape suivante » pour avancer.

Ce parcours suppose que rien ne bouge. Or le programme ne s'arrête pas pendant que le GC marque : il tourne en même temps (p. 84), et il modifie des pointeurs en route. C'est là que ça se complique.

Le GC doit donc respecter une règle : aucun objet noir ne pointe sur un objet blanc (p. 86). Pourquoi ? Le GC ne relit jamais un objet noir. Si un noir pointait sur un blanc que personne d'autre ne pointe, le GC ne verrait jamais ce blanc. Il le libérerait alors qu'il sert encore.

Voici comment le programme peut l'enfreindre. B est blanc et seul G, un objet gris, le pointe. A est déjà noir. Le programme écrit a.field = b : A pointe maintenant sur B. Puis il retire le pointeur de G vers B (g.field = nil). Seul A pointe encore sur B, et le GC ne relira pas A. B reste blanc, donc il est libéré, alors qu'A s'en sert (pp. 86-87).

Pour l'empêcher, le compilateur ajoute à chaque écriture de pointeur un petit contrôle, la write barrier (barrière d'écriture). Pendant le marquage, elle grise l'objet vers lequel on pointe : ici, B devient gris dès a.field = b, et le GC le traitera. Le reste du temps, elle ne fait rien (p. 87). Le schéma montre les deux cas.

Un dernier risque : le programme peut allouer plus vite que le GC ne marque, et le GC ne suivrait plus. Le runtime fait alors payer celui qui alloue. Avant de recevoir sa mémoire, une goroutine doit marquer elle-même des objets, et plus elle alloue, plus elle en marque : c'est le mark assist (pp. 85-86). Le programme alloue donc plus lentement, le marquage avance plus vite, et l'équilibre revient. Pour vous, cela se voit comme des goroutines qui ralentissent pendant un cycle du GC.

7GOGC règle quand le GC passe, GOMEMLIMIT règle combien on peut tenir

Le ramasse-miettes de l'idée précédente ne tourne pas en continu : il passe de temps en temps libérer les objets dont plus personne ne se sert. Quand doit-il passer ? Trop souvent, il consomme du CPU. Trop rarement, la mémoire gonfle, et cela finit mal dans un conteneur, dont la mémoire est plafonnée. Deux réglages répondent à ces deux questions.

Prenons un exemple. Un programme garde 10 Mo d'objets utiles et en alloue sans arrêt de nouveaux, dont la plupart ne serviront plus. Le tas grossit donc. Après chaque passage, le GC mesure le tas vivant, la part de ces objets qui servent encore (heaplive dans le livre, ici 10 Mo). GOGC fixe quand il repasse : c'est le pourcentage dont le tas doit grossir depuis cette mesure (p. 95). Par défaut, GOGC vaut 100 : le GC repasse quand le tas a doublé, ici à 20 Mo (p. 95). Chaque passage parcourt les objets et coûte du CPU. Monter GOGC espace les passages et coûte de la mémoire, le descendre fait l'inverse. Mais GOGC ne fixe aucun plafond, il règle seulement le rythme.

Le plafond vient de GOMEMLIMIT, une limite souple de mémoire : plus le tas s'en approche, plus le GC devient agressif et repasse souvent, au prix de CPU. Baissez-la dans le schéma ci-dessous : les dents se resserrent.

Le tas d'un programme qui alloue sans arrêt et garde 10 Mo d'objets utiles : il monte jusqu'au seuil de repassage du ramasse-miettes, puis retombe. Le seuil dépend de GOGC et de GOMEMLIMIT. Mémoire du tas (Mo) 0 10 20 30 40 50 60 tas vivant : 10 Mo Le temps passe GOMEMLIMIT : 40 Mo

Avec GOGC 100, le GC repasse quand le tas atteint 20 Mo : 6 passages sur la fenêtre.

Le programme alloue à vitesse constante et garde 10 Mo d'objets utiles. Chaque chute est un passage du ramasse-miettes.

Souple veut dire que la limite peut être dépassée. Le livre le dit : « GOMEMLIMIT est un souhait, pas une garantie » (p. 97). Il liste trois cas de dépassement : le tas vivant dépasse déjà la limite, une grosse allocation arrive d'un coup, ou le coût du GC deviendrait prohibitif (p. 97). Prenons le premier cas : tout est vivant, donc le GC ne trouve rien à libérer et repasse en boucle. Le runtime limite alors le GC à environ la moitié du temps de calcul : l'autre moitié reste au programme, pour qu'il avance (code de Go 1.26.6, hors livre). Le programme ralentit et, s'il continue d'allouer, la mémoire monte jusqu'à ce que le conteneur le tue.

Le conteneur, lui, a une limite dure : son cgroup plafonne la mémoire. Si le processus la dépasse, le noyau Linux le tue : c'est l'OOM kill (out of memory, plus de mémoire). Le conseil est net : « définissez GOMEMLIMIT à environ 80-90% de la limite du conteneur » (p. 98). Pourquoi 80 à 90 % et pas 100 % ? Parce que GOMEMLIMIT est un souhait : la marge laisse de la place aux allocations temporaires qui le dépassent (p. 98). Le livre en tire la morale : « seule une limite système dure (cgroup) peut réellement arrêter les allocations » (p. 98). Arrêter les allocations, ici, c'est tuer le processus : la limite du cgroup tue, GOMEMLIMIT prévient.

Un dernier détail surprend en conteneur. Après un passage du GC, la mémoire vue par le système ne redescend pas tout de suite. Le GC libère pour Go, pas pour le système : la mémoire libérée reste comptée dans la RSS, la mémoire physique que le système attribue au processus (p. 93). Un scavenger la rend ensuite au système, en tâche de fond (p. 94).

8L'ordre des champs d'une struct change sa taille : 24 octets en vrac, 16 une fois triés

Un objet coûte souvent plus que ce qu'on lui demande (idée 4). Ici, on réduit la demande elle-même, sans toucher à la logique du programme : en rangeant les champs dans un autre ordre.

Prenons deux structs, c'est-à-dire deux enregistrements qui rangent plusieurs champs l'un derrière l'autre en mémoire. Elles ont les mêmes quatre champs, écrits dans deux ordres :

type Desordre struct {
	actif bool
	prix  int32
	promo bool
	stock int64
}

type Trie struct {
	stock int64
	prix  int32
	actif bool
	promo bool
}

La première pèse 24 octets, la seconde 16 (mesuré avec unsafe.Sizeof, Go 1.26.6). Mêmes champs, un tiers de mémoire en moins, rien qu'en changeant l'ordre. Où passent les 8 octets d'écart ?

Dans du vide, et ce vide n'est pas un oubli de Go : c'est le prix d'une règle du processeur. Pensez à un parking aux places numérotées. Un camion de huit places ne se gare qu'au numéro 0, 8 ou 16. Une voiture de quatre places démarre à 0, 4 ou 8, et un vélo se range où il veut.

Chez Go, l'int64 (8 octets) est le camion, l'int32 (4 octets) la voiture et le bool (1 octet) le vélo. Chaque valeur doit commencer à une adresse multiple de sa taille : c'est l'alignement (p. 163). Le processeur la lit alors sans effort.

Go range les champs dans l'ordre où vous les écrivez, « sans jamais modifier l’ordre spécifié dans le code source » (p. 166). Quand le champ suivant ne tombe pas sur une adresse permise, Go laisse des octets vides pour l'y amener : c'est le padding (le bourrage). Le schéma montre nos deux structs, une ligne par groupe de 8 octets.

Dans Desordre, actif prend l'octet 0. Le prix ne peut pas commencer à l'octet 1 : il attend l'octet 4, et 3 octets restent vides. promo prend l'octet 8. Le stock doit commencer à un multiple de 8, donc à 16, et 7 octets de plus restent vides. Total : 24 octets, dont 10 de vide.

Dans Trie, le camion passe en premier et occupe les octets 0 à 7. Le prix s'y colle, de 8 à 11, puis actif et promo prennent les octets 12 et 13. Plus aucun vide à rattraper. Il en reste 2 à la fin : une struct est arrondie à un multiple de l'alignement de son plus gros champ, ici l'int64, donc 8 (p. 166). Sans cet arrondi, la deuxième struct d'un tableau commencerait à l'octet 14, une adresse interdite pour son int64.

Une struct seule ne gagne presque rien. Un million de Desordre occupent 24 Mo, un million de Trie 16 Mo. Le tri compte donc pour les structs créées par millions : points, événements, lignes d'un catalogue gardé en mémoire.

La règle à retenir : du plus gros au plus petit. Le livre l'écrit ainsi : « classez simplement les champs du plus grand vers le plus petit » (p. 169). Une taille plus grande est un multiple des plus petites : après un int64, l'adresse convient déjà à un int32, donc aucun vide entre les champs.

Inutile de compter à la main : l'outil fieldalignment, installé avec go install, repère les structs gaspilleuses. Sur Desordre, mon essai affiche Desordre has size 24 but the optimal size is 16 leading to a waste of 8 bytes (33%).

Le livre nomme une réserve : des champs lus ensemble gagnent à rester voisins (p. 169), car le processeur charge la mémoire par blocs de 64 octets : deux champs voisins arrivent ensemble, deux champs éloignés peuvent demander deux chargements. Triez donc là où cela pèse, et mesurez.

Ma lecture

Mon essai, Go 1.26.6. J'ai relevé les positions avec unsafe.Offsetof, 0, 4, 8 et 16 pour Desordre, 0, 8, 12 et 13 pour Trie, comme sur le schéma. Le message de fieldalignment est celui d'aujourd'hui, plus bavard que celui du livre (p. 170).

9Une slice ne pèse que 24 octets : la copier ne copie pas ses données

Une slice est la liste redimensionnable de Go, écrite []int pour une liste d'entiers. Écrire b := a ne copie pas la liste, et voici pourquoi. Une variable de type slice est un petit enregistrement (une struct). Le livre donne son poids : il « occupe exactement 24 octets sur une architecture 64 bits » (p. 184). Il compte trois champs de 8 octets :

  • array : l'adresse du premier élément de la slice, dans un tableau sous-jacent (backing array), la zone de mémoire où sont rangées les valeurs. Ce n'est pas forcément le début de ce tableau (p. 185).
  • len : le nombre d'éléments de la slice.
  • cap : la capacité, c'est-à-dire le nombre d'éléments atteignables depuis array jusqu'à la fin du tableau sous-jacent.

Copier une slice, c'est donc copier ces trois champs, que le livre appelle l'en-tête (header). Il chiffre le contraste : « Un []int de 1 million d’éléments se copie en 24 octets (header de slice), pas en 8 Mo » (p. 448). La copie est rapide, mais les deux slices regardent ensuite le même tableau (p. 190) :

a := []int{10, 20, 30}
b := a             // copie l'en-tête, partage le tableau
a[1] = 99
fmt.Println(b[1])  // 99
a = append(a, 40, 50) // capacité 3 dépassée : nouveau tableau pour a
b[0] = 77             // b garde l'ancien tableau
fmt.Println(a[0])     // 10

Un append ajoute des éléments à la fin, et il ne regarde qu'une chose : reste-t-il de la place dans le tableau ? Ici, a a une longueur de 3 et une capacité de 3, donc le tableau est plein. Une slice littérale a une capacité égale à sa longueur, donc « Tout append ultérieur déclenchera une réallocation » (p. 188). Pour ajouter 40 et 50, Go alloue un nouveau tableau plus grand et y recopie 10 20 30. Il ajoute 40 et 50, et a pointe désormais vers ce tableau. b n'a pas bougé : son en-tête vise toujours l'ancien tableau, et b[0] = 77 n'écrit que là. Le nouveau tableau double la capacité jusqu'à 256 éléments, puis grandit plus doucement. Sa taille est ensuite arrondie à une classe de taille de l'idée 4 (code de Go 1.26.6, hors livre).

Quand il reste de la place, c'est l'inverse : append écrit dans le tableau existant, donc aussi chez les autres slices qui le regardent. Prenons un tableau de huit valeurs, data := []int{0, 1, 2, 3, 4, 5, 6, 7}. Une fenêtre dessus, w := data[2:5], regarde les cases 2, 3 et 4 : [2 3 4]. Sa longueur est 3. Le 5 borne ce qu'elle voit, pas ce qu'elle atteint : derrière, le tableau continue. Sa capacité est 6 : c'est le nombre de cases entre son début, la case 2, et la fin du tableau, la case 7 (p. 188). Une slice ne recule pas, elle ne grandit que vers la droite. Ces 6 cases sont donc la place dont elle dispose sans changer de tableau.

Que fait w = append(w, 99) ? Il reste de la place, donc Go écrit 99 dans la case suivante, la case 5. Cette case appartient aussi à data : data[5] valait 5 et vaut maintenant 99 (mon essai, Go 1.26.10). On a modifié data sans le vouloir, par la fenêtre.

Go permet de fermer la fenêtre avec un troisième indice. v := data[2:5:6] regarde les mêmes cases 2 à 4, mais sa capacité s'arrête avant la case 6 : elle vaut 4 (p. 188). Un premier append peut encore écrire dans la case 5. Le suivant n'a plus de place et crée un nouveau tableau : les cases 6 et 7 de data restent à l'abri (mon essai, Go 1.26.10).

Pour isoler vraiment, le livre conseille des copies défensives : copier les données dans un nouveau tableau avant de les prêter (pp. 189, 193). Retenez ceci : une slice reçue n'est pas à vous, c'est le tableau de quelqu'un d'autre qu'on vous prête. Trois habitudes suffisent. Gardez toujours le résultat d'append : a = append(a, x). Ne modifiez pas une slice reçue sans le dire. Pour un tableau à vous, copiez avec slices.Clone(a). Pour prêter sans risque de débordement, fermez la fenêtre avec slices.Clip(a) (mon essai, Go 1.26.6).

10Une string, ce sont des octets, pas du texte

Un fichier venu d'un vieux système contient « café », écrit dans un ancien encodage, l'ISO-8859-1, où le é tient sur un seul octet. Votre programme Go le lit, le transforme en liste de caractères, puis le reconstruit. Le texte a changé, et personne n'a rien vu.

s := "caf\xe9 !" // « café » en ISO-8859-1 : le é tient sur un seul octet
fmt.Println(len(s), utf8.ValidString(s)) // 6 false
copie := string([]rune(s))
fmt.Println(copie == s, len(copie)) // false 8

Une string Go est un petit enregistrement de deux champs : un pointeur vers des octets et une longueur en octets (p. 206). C'est l'en-tête d'une slice de l'idée 9, sans la capacité. Rien n'y dit que ces octets forment du texte : « Les strings Go, en tant que type de données, stockent simplement une séquence d’octets sans validation d’encodage intrinsèque » (p. 203). Pourtant certaines opérations supposent de l'UTF-8, l'encodage standard d'Unicode où un caractère prend de 1 à 4 octets : « Go considère par défaut qu’une string contient de l’UTF-8 et tente automatiquement de la décoder » (p. 205). C'est le cas de range et de la conversion en []rune, une liste de runes, les caractères tels que Go les voit.

Dans mon exemple, l'octet 0xE9 seul n'est pas de l'UTF-8 valide. Le décodage ne plante pas : il met à sa place le caractère de remplacement U+FFFD, le « � » (p. 205). Ce caractère prend trois octets en UTF-8, d'où les 6 octets devenus 8 : 3 pour caf, 3 pour le remplacement, 2 pour l'espace et le point d'exclamation. string([]rune(s)) n'est donc pas une copie fidèle (mon essai, Go 1.26.6).

Les deux champs de la string expliquent un second piège. Couper une string ne copie rien : gros[:10] est un nouvel enregistrement, de longueur 10, qui pointe sur le début des mêmes octets. Tant que cette sous-chaîne vit, tous les octets de départ restent en mémoire, même ceux dont plus personne ne se sert. Le livre : « Une petite substring de quelques caractères peut empêcher la libération d’une string de plusieurs mégaoctets » (p. 210).

gros := strings.Repeat("x", 10_000_000)
debut := gros[:10]            // pointe dans les 10 Mo
debut = strings.Clone(debut)  // copie 10 octets neufs, les 10 Mo peuvent partir

J'ai gardé cinq débuts de dix octets, chacun tiré d'une chaîne de 10 Mo. Après un passage du ramasse-miettes, le programme occupe 50,5 Mo, contre 0,5 Mo au départ. Avec strings.Clone, il retombe à 0,5 Mo (mon essai, Go 1.26.6).

Retenez ceci : pour du texte venu de l'extérieur, vérifiez-le avec utf8.ValidString avant de le décoder. Pour garder un petit morceau d'une grosse chaîne, copiez-le avec strings.Clone.

11Une map ne rétrécit jamais, même vidée

Après les listes, les tables. Une map (map[string]int) associe des clés à des valeurs. Imaginez des casiers : huit casiers se remplissent jusqu'à 52 paires, soit 6,5 par casier en moyenne. Voici les noms. Le runtime transforme chaque clé en un nombre, le hash, qui désigne un bucket : une case de rangement qui contient jusqu'à huit paires clé-valeur (p. 217). Le facteur de charge est le nombre moyen d'éléments par bucket. Quand il dépasse 6,5, la map grandit : Go double le nombre de buckets et y recopie les éléments (pp. 220-221). Le livre y voit « le meilleur compromis observé entre utilisation mémoire et performance d’accès » (p. 220).

Deux points suivent : la taille, qui reste vraie, puis la date. D'abord la taille. Une map ne rend jamais sa place : « Les maps Go ne rétrécissent jamais automatiquement » (p. 223). L'exemple du livre : « Une map ayant contenu un million d’éléments conservera ses buckets (environ 262 000) même après suppression de tous les éléments » (p. 224).

Mais pourquoi le ramasse-miettes n'y peut-il rien ? Mon explication, valable pour l'ancien modèle (avant Go 1.24, voir plus bas) : les buckets forment un tableau d'un seul bloc, que la map référence tant qu'elle existe. Supprimer un élément vide une case de ce bloc, sans le rendre. Pour Go 1.26, où il n'y a plus de buckets, je n'ai que la mesure ci-dessous.

Mon essai confirme. Pour un million d'entiers, le tas pèse 36 Mo avec Go 1.26 (38 Mo avec Go 1.23). Il pèse autant après avoir supprimé tous les éléments, et autant après clear (la fonction qui vide une map). Il retombe à 0 Mo seulement quand la variable qui référence la map est remise à nil : plus rien ne référence la map, le GC la libère. Le remède du livre est donc fondé : « considérez la recréation périodique de la map » (p. 224). On construit une nouvelle map avec les éléments restants, et le GC libère l'ancienne. Pensez aux caches d'application et aux tables de sessions qui ont connu un pic.

Le second point est une date. Depuis Go 1.24, les maps ne sont plus faites de buckets mais de Swiss Tables (encadré plus bas). Le modèle du livre est exact jusqu'à Go 1.23 inclus, testé avec Go 1.23.12. Or le livre se base « principalement sur Go 1.26 et versions ultérieures » (p. xxviii), et le mot Swiss n'y apparaît nulle part (0 occurrence dans les 604 pages).

Un chiffre du livre se trompe même dans l'ancien modèle. Pour make(map[string]int, 1000), il annonce environ 1024 buckets et « jusqu’à environ 6600 éléments sans redimensionnement » (p. 226). Il a compté un élément par bucket. J'obtiens 256 buckets avec Go 1.23.12 : 1000 ÷ 6,5 fait environ 154, arrondi à la puissance de 2 suivante. Cela donne 1664 éléments au plus (6,5 × 256).

Ma lecture

Hors livre, notes de version de Go. Depuis Go 1.24, les maps sont des Swiss Tables, une autre conception de table de hachage. Une table compte au plus 1024 emplacements, les cases qui reçoivent chacune une paire clé-valeur, rangées en groupes de 8. Elle se remplit au plus aux 7/8 : la place maximale occupée, comme le facteur de charge mais en proportion des cases. Il n'y a plus de buckets. Avec Go 1.26, make(map[string]int, 1000) crée deux tables de 1024 emplacements (mon essai). Comptons : 1024 × 7/8 = 896 éléments par table, moins que 1000, donc il en faut deux. L'annonce du livre, 1024 buckets, ne correspond donc ni à Go 1.23, ni à Go 1.26. Restent vrais : la map ne rétrécit pas, l'ordre de parcours est aléatoire (p. 224), un accès concurrent non protégé est détecté (idée 15), et make(map, n) pré-dimensionne. Sources : notes de version de Go 1.24 et billet go.dev/blog/swisstable.

12Une interface, c'est deux mots : un nil qui n'en est pas un, et un entier qui alloue

Une fonction ne trouve aucune erreur, et son appelant en voit une quand même. Voici la fonction. Son type de retour, error, est une interface : une variable qui peut contenir n'importe quelle valeur dotée d'une méthode Error().

package main

import "fmt"

type MonErreur struct{}

func (*MonErreur) Error() string { return "échec" }

func ouvre(echec bool) error {
	var err *MonErreur
	if echec {
		err = &MonErreur{}
	}
	return err
}

func main() {
	err := ouvre(false)
	fmt.Println(err != nil) // true
	fmt.Printf("%T\n", err) // *main.MonErreur
}

Une variable d'interface tient en deux mots de 8 octets, soit 16 octets (p. 231). Le premier désigne le type de la valeur rangée dedans, le second la valeur elle-même. Le livre règle la question du nil en une phrase : « Une interface est nil quand son premier champ est nil. » (p. 236). Or err est un *MonErreur qui vaut nil, et au return Go le range dans un error : « Cette conversion crée une nouvelle interface qui encapsule à la fois le type *os.PathError et la valeur nil. » (p. 236). Le livre écrit ici *os.PathError : dans notre exemple, ce serait *MonErreur. Le premier mot contient *MonErreur, donc il n'est pas nil. Le second est nil. Le test err != nil répond vrai.

Le livre propose deux issues (p. 237) : écrire return nil quand tout va bien, ou déclarer la variable avec le type error plutôt qu'avec le type concret.

Le second mot explique aussi un coût. Imaginez l'interface comme une enveloppe à deux cases : le type, et l'adresse de la valeur. Pour avoir une adresse, un entier doit d'abord être posé sur une étagère, c'est-à-dire alloué sur le tas, la mémoire commune du programme (idée 6). Le livre affirme l'inverse. Les valeurs de la taille d'un pointeur « sont stockées directement dans le champ data de l’eface sans allocation supplémentaire » (p. 238). L'eface est le nom interne de l'interface vide, any. Les strings seraient un cas particulier : « elles contiennent un pointeur vers les données et une longueur, donc tiennent dans l’espace disponible » (p. 238). Mais un pointeur et une longueur font deux mots, et le champ en a un seul. La section s'ouvre pourtant sur le bon constat : une valeur rangée dans une interface vide peut allouer (p. 238).

J'ai compté les allocations quand la valeur est rangée dans une variable globale puits, donc qu'elle survit à la fonction :

var puits any

func main() {
	n := 999 + len(os.Args)   // 1000, connu à l'exécution seulement
	petit := len(os.Args) + 6 // 7
	s := os.Args[0]
	p := new(int)
	fmt.Println("entier 1000  :", testing.AllocsPerRun(1000, func() { puits = n }))      // 1
	fmt.Println("entier 7     :", testing.AllocsPerRun(1000, func() { puits = petit })) // 0
	fmt.Println("string       :", testing.AllocsPerRun(1000, func() { puits = s }))      // 1
	fmt.Println("pointeur     :", testing.AllocsPerRun(1000, func() { puits = p }))      // 0
	fmt.Println("constante    :", testing.AllocsPerRun(1000, func() { puits = 1000 }))   // 0
}

Le code du runtime explique le reste (Go 1.26). Pour un entier, convT64 renvoie l'adresse d'une table statique s'il est inférieur à 256, sinon il alloue 8 octets. Pour une string non vide, convTstring alloue 16 octets.

Retenez deux choses. Une fonction qui renvoie error renvoie nil ou une vraie erreur, jamais une variable typée. Et dans une boucle appelée souvent, ranger un entier ou une string dans un any coûte une allocation quand la valeur sort de la fonction. Mesurez avec testing.AllocsPerRun avant de croire une règle, même celle d'un livre.

13Capturer une variable ne coûte rien : c'est la closure qui s'échappe qui se paie

Un compteur en Go tient en quelques lignes. La fonction retourneAvecModif crée n := 0 et renvoie func() int { n++; return n }, une closure : une fonction qui garde l'accès aux variables de la fonction qui l'a créée. Ici, elle incrémente n. Deux zones de mémoire entrent en jeu. La pile est le plan de travail d'une fonction : elle est vidée quand la fonction rend la main. Le tas, croisé à l'idée 6, est un entrepôt : seul le ramasse-miettes le vide, quand plus personne n'utilise l'objet. Une question se pose : où vit n quand retourneAvecModif est terminée ? Sur la pile, il disparaîtrait avec sa fonction. Celle-ci doit survivre.

Le livre répond : « Les closures en Go capturent les variables de leur contexte englobant par référence » (p. 513). Par référence veut dire que la closure et la fonction partagent la même variable, sans copie. Mon essai le confirme : après x := 1 et f := func() int { return x }, l'affectation x = 99 fait renvoyer 99 à f().

Le compilateur peut pourtant tricher quand personne ne verrait la différence. Il copie alors la valeur dans la closure : c'est la capture par valeur. Le livre la réserve aux variables qui remplissent trois conditions à la fois (p. 284) :

  • une taille inférieure ou égale à 128 octets ;
  • aucune réassignation après la création de la closure ;
  • « une adresse jamais prise avec l’opérateur & dans le code » (p. 284).

L'option -gcflags=-m=2 (par exemple go build -gcflags=-m=2) affiche la décision du compilateur, variable par variable, et width y désigne la taille en octets :

func retourneSansModif(n int) func() int {
    return func() int { return n * 2 } // n jamais réassigné
}

func retourneAvecModif() func() int {
    n := 0
    return func() int { n++; return n } // n réassigné
}
retourneSansModif capturing by value: n (addr=false assign=false width=8)
retourneAvecModif capturing by ref: n (addr=false assign=true width=8)
moved to heap: n

La ligne moved to heap dit que n a quitté la pile pour le tas : sans cela, il disparaîtrait avec sa fonction. Dans mon essai, la closure renvoyée coûte 1 allocation avec la capture par valeur, et 2 avec la capture par référence. Un tableau de 200 octets, même jamais réassigné, est capturé par référence et part aussi sur le tas : la limite de 128 octets du livre est exacte.

Le coût dépend ensuite de la sortie de la closure. Le compilateur fait une analyse d'échappement : il regarde si la closure quitte la fonction qui l'a créée, parce qu'elle est renvoyée ou lancée avec go. Si elle ne sort pas, la variable reste sur la pile, même capturée par référence. Cas minimal : appelée sur place, la closure fait 0 allocation, renvoyée elle en fait 1 ou 2. Le livre l'écrit : « Sinon, elle peut rester sur la stack selon l’analyse d’échappement. » (p. 284). Il écrit pourtant : « Les variables capturées par référence nécessitent une allocation sur le heap. » (p. 515). Les deux phrases ne peuvent pas être vraies ensemble. L'essai donne raison à la première. Même l'exemple testClosure du livre (p. 515), dont la variable err est pourtant réassignée, fait 0 allocation. Aucune ligne moved to heap n'apparaît.

Ma lecture

Mon essai, avec prudence. Le livre mesure un milliard de counter++ et trouve, sur x86, que le code avec closure est « environ 7,4 fois plus lent » que le code direct (p. 514). Il note lui-même que sur ARM l'écart attendu ne se reproduit pas : 224 ms contre 268 ms, soit 1,2 fois. J'ai rejoué le programme du livre avec Go 1.26.8 sur un portable récent à processeur AMD : 222 ms avec la closure, 210 ms en direct, soit environ 1,05 fois. Le facteur dépend donc de la machine.

Retenez ceci : une closure appelée sur place est quasi gratuite. Elle se paie quand elle sort de sa fonction, et seul -m=2 dit ce que le compilateur a décidé pour chaque variable.

14Un panic non récupéré dans une goroutine tue tout le programme

Après les types, la fin d'un programme. C'est là que le livre commet son erreur la plus dangereuse, parce qu'il l'écrit quatre fois. Quatre mots d'abord :

  • une goroutine est une fonction lancée avec go f() : elle s'exécute en parallèle de l'appelant.
  • un defer programme un appel pour la fin de la fonction, par exemple fermer un fichier.
  • un panic est un arrêt d'urgence : la fonction s'interrompt et ses defer s'exécutent. Puis le panic remonte à la fonction appelante, et ainsi de suite.
  • un recover, placé dans un defer, arrête la remontée.

Une goroutine n'a pas d'appelant. Si le panic atteint la fin de sa première fonction sans rencontrer de recover, il n'a plus nulle part où remonter. Que devient-il alors ? Le livre répond d'abord qu'il reste enfermé : « un panic dans une goroutine n’affecte que cette goroutine et ne se propage pas aux autres » (p. 296). Il le répète avec « un panic termine la goroutine fautive mais pas le programme entier (sauf pour la goroutine principale) » (p. 309), puis dans le résumé du chapitre (p. 314) et dans le chapitre sur les goroutines (p. 383).

Trois passages disent le contraire, et ils ont raison. Le plus net : « Quelle que soit la goroutine (principale ou non), un panic non récupéré déclenche la fonction runtime.fatalpanic qui termine le programme entier » (p. 313). Les pages 383 et 384 le redisent : la page 383 dit même les deux, à quelques lignes d'écart. Le livre se contredit donc lui-même. Son propre exemple tranche. La première goroutine panique, la seconde attend 100 ms puis affiche un message, et la goroutine principale attend 200 ms :

go func() { panic("goroutine 1") }()
go func() { time.Sleep(100 * time.Millisecond); fmt.Println("goroutine 2 continue") }()
time.Sleep(200 * time.Millisecond)
fmt.Println("main continue")

Le texte qui suit cet exemple dit : « le panic de la première goroutine termine tout le programme » (p. 384). J'ai rejoué l'exemple avec Go 1.26.10. Le programme affiche panic: goroutine 1, puis sort avec un code de sortie non nul (p. 314). Ce code est le nombre que tout programme rend au système en s'arrêtant, 0 si tout va bien. Ici il vaut 2. Ni « goroutine 2 continue » ni « main continue » n'apparaissent. Si la goroutine fautive contient defer func() { recover() }(), le programme continue.

Retenez la règle, pas la phrase du livre : chaque goroutine que vous lancez doit se protéger elle-même avec un recover, sinon un seul panic arrête tout le service.

15Un defer ne s'exécute pas quand le programme s'arrête brutalement

Le recover de l'idée précédente vit dans un defer. Or un defer n'est pas aussi sûr que le livre le laisse croire. Le livre le présente comme une garantie : l'appel part « que cette terminaison soit normale ou due à un panic » (p. 288). Il ajoute que « Cette garantie d’exécution en fait un outil particulièrement pratique pour la gestion des ressources » (p. 288).

Cette garantie a au moins quatre exceptions. Dans mon essai sous Docker, un defer qui appelle recover ne s'exécute dans aucune d'elles :

  • os.Exit(3) : le programme s'arrête sur-le-champ avec le code de sortie 3, sans exécuter aucun defer.
  • deux goroutines qui écrivent dans la même map sans verrou : le runtime arrête tout. Un verrou (mutex) ne laisse passer qu'une goroutine à la fois. Le livre montre ce message, fatal error: concurrent map read and map write (p. 225).
  • un interblocage total (deadlock) : toutes les goroutines attendent, et aucune ne peut plus avancer.
  • une récursion sans fin, qui fait déborder la pile (stack), la zone de mémoire où une goroutine empile les appels de fonctions en cours.

Les trois derniers cas sont des fatal errors, des arrêts décidés par le runtime lui-même. Un panic non récupéré, lui, exécute d'abord les defer de sa goroutine (idée 14). Une fatal error s'arrête sans en exécuter aucun. os.Exit fait de même sans être une fatal error : c'est le programme qui le demande. Le livre ne le dit pas : entre les pages 288 et 314, consacrées à defer et aux panics, il ne contient ni os.Exit ni fatal error (0 occurrence). Un cas voisin n'apparaît qu'à la page 383 : les goroutines encore en cours quand le programme s'arrête sont tuées sans exécuter leurs defer.

Ma lecture

Lu dans le code de la bibliothèque standard. log.Fatal appelle os.Exit(1). Un log.Fatal dans main saute donc les defer de main. Cas concret : déplacez le travail dans une fonction run() error qui contient les defer et rend la main normalement. Seul main appelle ensuite os.Exit(1) si run renvoie une erreur, et les defer de run ont déjà tourné.

La règle : un defer protège la sortie d'une fonction, même par un panic. Il ne protège pas l'arrêt du programme entier.

16Un itérateur qui ignore le break de la boucle plante au premier break

Depuis Go 1.23, une boucle for v := range accepte une fonction à la place d'une slice ou d'une map. Cette fonction est un itérateur : elle produit les valeurs une à une. Elle reçoit en paramètre une autre fonction, nommée yield par convention, qu'elle appelle une fois par valeur. La boucle ressemble à toutes les autres. Le piège se cache dessous.

Le compilateur découpe en effet la boucle en deux. Le corps, entre accolades, devient une petite fonction, une closure (idée 13). C'est cette closure que l'itérateur reçoit sous le nom yield. Le livre le décrit ainsi : « le corps de la boucle est extrait dans une closure séparée » (p. 317). Chaque tour de boucle est donc un appel de yield.

Un break ou un return dans le corps fait répondre false à la closure. L'itérateur doit alors s'arrêter : « Si yield retourne false, l’itérateur doit immédiatement cesser de produire des valeurs et s’arrêter. » (p. 316). Voici un itérateur distrait :

func Distrait(yield func(int) bool) {
    for i := 0; i < 5; i++ {
        yield(i) // le retour de yield est ignoré
    }
}

func main() {
    for i := range Distrait {
        if i == 2 {
            break // panic ici
        }
    }
}

Avec Go 1.26.8, Distrait passe tant que la boucle va au bout. Au break, le runtime détecte que l'itérateur a continué et s'arrête sur panic: runtime error: range function continued iteration after function for loop body returned false. Le bug n'apparaît donc qu'au premier appelant qui casse sa boucle, parfois longtemps après l'écriture de l'itérateur. La parade tient en une ligne : if !yield(i) { return }.

Le découpage a un dernier effet, sur defer. Puisque le corps est une closure, on pourrait croire que le defer s'exécute à la fin de chaque tour. Il n'en est rien : « un defer s’attache toujours à la fonction dans laquelle il apparaît syntaxiquement, pas à la closure générée par le compilateur » (p. 321). Voici l'essai, où Bon est le même itérateur avec if !yield(i) { return } :

func essai() {
    for i := range Bon {
        defer fmt.Println("defer du corps, i =", i)
        if i == 1 {
            break
        }
    }
    fmt.Println("fin de la boucle")
}

Avec Go 1.26.8, essai() affiche d'abord « fin de la boucle », puis defer du corps, i = 1, puis defer du corps, i = 0. Les defer attendent le retour de la fonction qui contient la boucle, comme dans une boucle ordinaire.

Ma lecture

Mes mesures et le code source de Go. Le livre parle d'un « coût d’exécution non négligeable » (p. 322) sans le chiffrer. Sur 65 536 entiers et un corps de boucle trivial, la boucle sur fonction coûte environ 0,45 à 0,5 ns par élément. Un range sur une slice en coûte 0,22 (Go 1.26.8). C'est un ordre de grandeur, qui dépend de la machine. Le livre présente aussi comme une erreur un itérateur qui se termine sans avoir reçu false (p. 320). C'est pourtant le cas normal, qui ne panique pas.

17Deux compteurs voisins se ralentissent : 56 octets de vide règlent le problème

Dernier réglage, côté processeur. Image : deux collègues annotent chacun une ligne différente de la même page photocopiée. Dès que l'un écrit sur la page, l'autre doit recopier la page entière avant de continuer, même s'il n'a pas touché à sa ligne. Voici les mots techniques. Le processeur ne lit pas la mémoire octet par octet mais par blocs, les lignes de cache, de « généralement 64 octets » (p. 401). Chaque cœur garde dans son cache, la mémoire rapide vue à l'idée 3, une copie des lignes qu'il utilise. Quand un cœur écrit dans une ligne, les copies des autres cœurs sont périmées : on dit qu'elles sont invalidées (p. 401), et chaque cœur doit recharger la ligne avant de s'en servir. Les compteurs ci-dessous passent par des opérations atomiques, comme Add sur un atomic.Uint64 (idée 3).

Transposez à la mémoire : deux compteurs indépendants, côte à côte. Ils se retrouvent dans la même ligne. Deux cœurs les incrémentent chacun de leur côté. Chaque écriture invalide la copie de la ligne chez l'autre cœur, qui doit la recharger. Les compteurs ne partagent rien, mais ils se ralentissent. C'est le false sharing (faux partage). Le livre propose du padding (du remplissage) : un champ muet, noté _, qui force chaque compteur sur sa propre ligne. Il vaut [56]byte, soit « 64 - 8 » (p. 402) : une ligne de 64 octets moins les 8 octets d'un compteur Uint64. Le second _ isole aussi le dernier compteur de ce qui le suit en mémoire.

type GoodMetrics struct {
    atomic_counter_1 atomic.Uint64
    _                [56]byte // 64 - 8 = 56
    atomic_counter_2 atomic.Uint64
    _                [56]byte
}
Ma lecture

Mon banc d'essai. Le livre ne chiffre pas ce gain. Il écrit seulement que le sharding avec un padding approprié améliore « drastiquement les performances sous forte charge » (p. 401). J'ai mesuré deux goroutines qui font chacune 100 millions d'Add, avec Go 1.26.10. Sans padding : 1,58 à 1,72 s. Avec padding : 0,17 à 0,18 s. C'est 9 à 10 fois plus vite sur trois essais, au prix d'une structure qui passe de 16 à 128 octets. Le chiffre dépend de la machine.

Ce que vous emportez : réservez le padding aux compteurs très sollicités par des goroutines différentes. Partout ailleurs, il gaspille de la mémoire. Le livre mesure aussi un autre ralentissement. Il fait faire 500 millions d'incrémentations à un nombre croissant de goroutines. La tâche prend 0,15 seconde dans une boucle sans atomique, la référence. Avec un seul compteur atomique partagé par 7 CPU, elle prend 133 fois plus de temps (pp. 400-401). C'est la contention : des goroutines qui modifient la même variable se gênent.

Le padding seul n'y peut rien, puisqu'il n'y a qu'une variable. Le livre conseille de la fragmenter en plusieurs compteurs, répartis par goroutine, numéro de CPU ou hash. On les additionne à la lecture, et on les sépare « avec un padding approprié » (p. 401). Un atomique est rapide seul, pas sous contention.

18Un channel est un verrou avec une file, et un RWMutex n'est pas un Mutex plus rapide

Deux réflexes de bon sens. Un channel va trop lentement : on lui ajoute un buffer. Un Mutex gêne les lecteurs : on le remplace par un RWMutex. Dans les deux cas, on s'attend à gagner. Dans les deux cas, on peut perdre, pour la même raison : chaque opération franchit un point partagé, un verrou ou un compteur, que toutes les goroutines passent à tour de rôle.

Un channel est un tuyau entre goroutines : l'une envoie avec ch <- v, l'autre reçoit avec <-ch. Un buffer ajoute des cases où les valeurs attendent. Sous le capot, le channel est une structure du runtime qui contient un verrou, un anneau de cases et deux files de goroutines endormies : celles qui veulent envoyer, celles qui veulent recevoir. Pensez à un guichet : on y entre un à la fois par la porte, qui est le verrou. Sur l'étagère, les cases gardent les valeurs, et deux files attendent dehors. Chaque envoi et chaque réception prend ce verrou, buffer ou non. Le livre le dit : « Le buffer réduit la fréquence des blocages de goroutines, mais pas celle des accès au mutex. » (p. 443). Le buffer évite des attentes, pas des passages par le verrou. Pour passer moins souvent, il faut envoyer moins de fois, donc envoyer des lots de valeurs :

ch := make(chan int, 100)
for i := 0; i < n; i++ {
    ch <- i // un passage par le verrou pour chaque valeur
}

lots := make(chan []int, 10)
lot := make([]int, 0, 100)
for i := 0; i < n; i++ {
    lot = append(lot, i)
    if len(lot) == 100 {
        lots <- lot // un passage pour 100 valeurs
        lot = make([]int, 0, 100)
    }
}
if len(lot) > 0 {
    lots <- lot
}

Le livre voit ce gain : l'envoi par lots divise le nombre d'opérations par la taille du lot (p. 444). Mon essai : 5 millions d'entiers, un producteur, un consommateur, Go 1.26.8. Sans buffer, 104,5 ns (nanosecondes) par valeur. Avec un buffer de 100, 23,0 ns (4,6 fois plus vite). Avec des lots de 100, 3,7 ns (28 fois plus vite). Le livre annonce qu'« un buffer de taille appropriée peut multiplier le débit par un facteur 10 à 100 » (p. 443). Chez moi, le buffer seul donne de 1,3 à 4,9 fois selon sa taille (de 1 à 1000). Les facteurs de 13 à 35 viennent des lots. Ces chiffres dépendent de la machine.

Un mutex est plus simple : quand personne n'attend, Lock ne fait qu'un test atomique. Sinon la goroutine s'endort dans une file, comme dans le channel (lu dans le code de Go, hors livre).

Le RWMutex est un verrou à deux portes : plusieurs lecteurs entrent ensemble, un écrivain entre seul. On en déduit qu'il bat le Mutex quand on lit beaucoup. Pas toujours. Chaque lecture prend le verrou, puis le rend. Le livre compte le prix : « dans le cas courant une lecture ne coûte que deux opérations atomiques » (p. 422). Ces deux opérations modifient le même compteur de lecteurs, partagé par toutes les goroutines. Les lecteurs ne s'attendent pas, mais ils passent tous par ce compteur.

Mon essai : 13 lecteurs en boucle, un écrivain qui passe toutes les 500 µs, 2 secondes par mesure. La section protégée est le code exécuté verrou pris. Quand elle est vide (une simple lecture), le Mutex fait 32,6 millions de lectures par seconde et le RWMutex 15,2 millions : deux fois moins. Avec une section de 1000 additions, le Mutex tombe à 3,8 millions et le RWMutex monte à 13,1 millions : 3,5 fois plus. La mesure est bruitée, retenez l'ordre de grandeur. La longueur de la section décide. Le livre le dit aussi : le RWMutex devient intéressant quand les lectures dominent largement et que les sections sont « suffisamment longues » (p. 424).

Ma lecture

Le code source de sync et mon banc. Le livre écrit que « les écrivains peuvent être totalement affamés sous forte charge de lecture » (p. 424). Être affamé, ici, c'est ne jamais obtenir le verrou. La page 419 dit l'inverse : « Écrivains prioritaires : un écrivain en attente bloque les nouveaux lecteurs pour éviter la famine. » (p. 419). Le code source donne raison à la page 419 : dans sync/rwmutex.go, un Lock en attente fait patienter les nouveaux RLock, afin que l'écrivain finisse par passer. Mon banc le confirme : avec 20 lecteurs, l'écrivain obtient le verrou de 665 à 1428 fois en 2 secondes, jamais zéro. Ses écritures ralentissent, elles ne s'arrêtent pas.

Retenez ceci : un channel ou un RWMutex se mesure. Envoyez par lots quand les valeurs sont petites et nombreuses. Gardez le RWMutex pour des lectures très majoritaires et des sections qui durent. Dans les autres cas, mesurez : un simple Mutex peut aller plus vite.

19Un timeout de cinq minutes se teste en zéro seconde : testing/synctest simule l'horloge

Un test qui attend pour de vrai est lent ou capricieux. Pour vérifier qu'un contexte (le context de Go, qui porte une échéance) expire au bout de cinq minutes, le test dort cinq minutes. Pour aller plus vite, on raccourcit le délai, et le test échoue de temps en temps parce que la machine était occupée. Le livre nomme le second défaut : « Cette imprécision rend les tests non déterministes et provoque des échecs intermittents. » (p. 467).

Le package testing/synctest change la règle : « Le package testing/synctest résout ces problèmes en fournissant une horloge simulée. » (p. 467). Le test s'exécute dans une bulle, un petit monde isolé. Les goroutines lancées dedans en font partie. Le package time y lit une horloge simulée, qui part de minuit UTC le 1er janvier 2000 (p. 468). Cette horloge ne tourne pas toute seule : « Le temps n’avance que lorsque toutes les goroutines de la bulle sont durablement bloquées. » (p. 468).

Une goroutine est durablement bloquée quand elle attend quelque chose qui ne peut venir que de la bulle : un time.Sleep, un channel créé dans la bulle. Quand toutes les goroutines dorment ainsi, le runtime avance l'horloge jusqu'à la prochaine échéance et réveille ce qui dormait. Le temps saute, il ne s'écoule plus.

func TestTimeout(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        start := time.Now()
        ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute)
        defer cancel()

        time.Sleep(5*time.Minute - time.Nanosecond)
        synctest.Wait()
        if ctx.Err() != nil {
            t.Fatal("expiré trop tôt")
        }

        time.Sleep(time.Nanosecond)
        synctest.Wait()
        if ctx.Err() != context.DeadlineExceeded {
            t.Fatal("devrait être expiré")
        }
        t.Log("temps simulé écoulé :", time.Since(start), "départ :", start.UTC())
    })
}

Le test dort cinq minutes moins une nanoseconde, vérifie que le contexte est encore vivant, puis dort la dernière nanoseconde et vérifie qu'il a expiré. synctest.Wait() attend que toute la bulle soit bloquée, ce qui laisse les réveils se produire avant la vérification. C'est aussi un point de synchronisation (p. 468). Avec Go 1.25 dans le go.mod, mon essai passe en 0.00s et affiche temps simulé écoulé : 5m0s départ : 2000-01-01 00:00:00 +0000 UTC. Un test équivalent sans bulle, avec un timeout de 5 secondes, dure 5.00s.

Ma lecture

Mon essai, et une limite du livre. L'horloge n'avance pas quand une goroutine attend un mutex ou le réseau. Le livre l'explique : « Les mutex sont exclus de la considération de blocage durable car ils protègent souvent des ressources globales partagées entre la bulle et l’extérieur. » (p. 469). Un verrou ou une connexion peut être débloqué par l'extérieur, donc le runtime ne peut pas savoir si l'attente se terminera sans lui. Pour du réseau, le livre propose net.Pipe, une connexion en mémoire (p. 469). Autre effet utile : un interblocage dans la bulle fait échouer le test tout de suite. Mon essai, avec un channel sans émetteur, affiche panic: deadlock: all goroutines in bubble are blocked. Sans bulle, il faudrait attendre le délai par défaut de go test, dix minutes.

Retenez ceci : un test qui attend un délai n'a pas besoin de dormir. Le livre précise que l'horloge simulée vaut pour Sleep, After, NewTimer, NewTicker, WithDeadline et WithTimeout (p. 469). Une condition : que l'attente ne passe ni par un mutex ni par le réseau.

20Quelques erreurs du livre, faciles à vérifier

Le livre affirme tout avec la même assurance : rien dans sa façon d'écrire ne distingue le vrai du faux. Or l'auteur écrit : « Une fois ces mécanismes compris, les bonnes pratiques découlent naturellement de la compréhension plutôt que de l’application mécanique de règles » (p. xxviii). Un mécanisme mal expliqué fausse donc les pratiques qu'on en déduit. Je n'en garde ici que sept, les plus grossières, celles qu'une ligne de code ou le livre lui-même suffit à prouver. Les idées précédentes en ont déjà traité d'autres, en situation.

Ma lecture

Sept erreurs à vérifier vous-même. Pour chacune, la page du livre et ce qui la contredit.

  • Code à ne pas recopier : la conversion de slices. Le livre dit que d := []byte(c) marche pour c de type []MyByte (p. 194). Cela ne compile pas. Le livre déclare type MyByte byte : c'est un type défini, distinct de byte, et non un alias (type MyByte = byte).
  • Explication fausse : les goroutines et les threads. Le livre écrit « chaque goroutine en attente correspond à un thread OS bloqué » (p. 130). Or 10 001 goroutines qui attendent sur un channel ne mobilisent que 6 threads dans mon essai : seul un appel système bloquant, une demande au système comme lire un fichier, en occupe un. Rappel de l'idée 1 : Go partage peu de fils entre beaucoup de goroutines. Le livre le dit d'ailleurs ailleurs : Go « maintient un nombre constant de threads OS indépendamment du nombre de connexions » (p. 150).
  • Chiffre qui ne tient pas : 493 fois plus vite. Le livre compare une allocation sur la pile et une sur le tas : 0,28 ns contre 140,2 ns, soit « un facteur ~493× » (p. 76). La première mesure est le coût d'une boucle vide : la variable n'est jamais utilisée, donc le compilateur supprime l'allocation. Avec Go 1.25.14, j'obtiens 0,2265 ns contre 244,4 ns.
  • Explication fausse : les verrous et la contention. Le livre écrit « l’absence de verrous élimine la contention » (p. 411). Le même chapitre mesure pourtant, sans verrou, le ralentissement de 133 fois dû à la contention (p. 401).
  • Démonstration qui ne prouve rien : time.Sleep(0). L'annexe écrit que time.Sleep(0) « sert d’artifice pour simuler une préemption » (p. 480). La préemption est l'interruption d'une goroutine par le runtime, qui donne la main à une autre. Or Sleep rend la main aussitôt pour une durée nulle ou négative (documentation de time.Sleep). Dans le programme de l'annexe, deux goroutines incrémentent chacune 10 millions de fois un compteur partagé. Il ne perd donc aucune mise à jour dans mon essai, préemption ou non. Avec runtime.Gosched() à la place, j'en perds la moitié.
  • Explication fausse : l'instruction d'un atomique sur x86-64. Le code du livre annonce que atomic.StoreInt64 génère « une instruction LOCK MOV sur x86-64 » (p. 47). J'ai compilé et désassemblé avec Go 1.26.6 sur amd64 : le Store donne un XCHGQ, qui verrouille de lui-même, et le Load un simple MOVQ. Lire un atomique utilise donc la même instruction qu'une lecture ordinaire.
  • Détail périmé : le mutex de chaque mcentral. Le livre écrit que chaque mcentral est « protégé par son propre mutex » (p. 61), et qu'il tient deux listes, nonempty et empty. Dans le code de Go 1.26.6, mcentral ne protège pas ses spans par un mutex. Il les range dans deux paires d'ensembles, partial et full, où l'on ajoute et retire sans verrou. Un petit verrou ne sert qu'à agrandir un ensemble. Le principe des trois étages reste juste, mais ce détail est daté.

Mon avis, honnêtement

Ce livre me fait-il comprendre ce que Go fait sous le capot ? Oui. Puis-je croire ce qu'il affirme sans vérifier ? Non, et c'est tout le problème. Mon verdict : une bonne carte du moteur de Go, dessinée avec quelques routes qui n'existent plus ou qui n'ont jamais existé.

Sa force : le fond des mécanismes. J'en tire vingt idées, dans les quatre parties du livre : le CPU en conteneur, le coût d'un objet, l'en-tête d'une slice, l'interface, les itérateurs, le faux partage, les channels. Ces constats sont vrais, concrets et utiles au quotidien. L'auteur promet d'ailleurs une méthode sobre : le livre « privilégie les sources officielles » (p. xxvii) et son ton est « délibérément factuel et technique » (p. xxvii). Le ton est tenu. La méthode l'est moins : plusieurs affirmations contredisent le code source ou la documentation de Go (idées 10, 12, 16 et 18, puis la 19).

Première réserve : rien dans le ton ne distingue une phrase juste d'une phrase fausse. Comme il se trompe parfois, tout chiffre, tout nom interne et tout code recopié doit être rejoué avant d'entrer dans votre projet. Sinon vous copierez un SetGCPercent(-1) qui coupe le GC, ou une conversion de slices qui ne compile pas.

Seconde réserve : la version. Le livre annonce Go 1.26 dans son avant-propos (p. xxviii). Pourtant il décrit les maps d'avant Go 1.24 et ignore le CPU lu dans le cgroup depuis Go 1.25. Les principes durent. Les noms de structures internes, les seuils et les chiffres ne se citent pas tels quels dans une revue de code.

Mon conseil : lisez, dans le livre, les chapitres sur les slices (chapitre 10), les panics (18), les goroutines (23) et le ramasse-miettes (5), puis la synchronisation (24). Survolez les maps (chapitre 12) pour l'effet de non-rétrécissement, sans retenir les buckets. Sautez les pages d'assembleur, le code machine : le livre dit lui-même que s'en passer ne bloque pas la compréhension (p. xxvii). Le livre précise que les deux premières parties, Fondements du runtime et Types et structures de données, « sont largement indépendantes et peuvent être lues séparément » (p. xxvi). La moitié de mes idées en vient : si vous n'avez le temps que pour elles, c'est déjà un bon retour. Les deux autres parties s'appuient dessus. Rejouez en dix lignes tout ce que vous comptez recopier, comme le livre vous y invite lui-même : il veut donner au lecteur « les éléments pour forger sa propre opinion » (p. xxvii).

Les types paramétrés n'ont pas d'idée à eux, faute de place. L'ordonnancement des goroutines non plus, alors que l'avant-propos le place au cœur de ses questions : comment le scheduler (l'ordonnanceur) choisit quelle goroutine exécuter (p. xxvi). Le livre y répond en une quarantaine de pages (pp. 113-157). Il décrit les files d'exécution de chaque processeur et le vol de travail entre eux. Il décrit aussi la préemption qui interrompt une goroutine trop gourmande et le relais quand un appel système bloque. Il ajoute les timers, le poller réseau, le thread de surveillance sysmon et la lecture des traces schedtrace. Sa conclusion pratique tient en quelques mots : « écrivez votre code naturellement » (p. 122).

Même sur les sujets traités, cette fiche s'en tient à l'essentiel de chaque mécanisme, et le livre descend bien plus bas. Le ramasse-miettes et la mémoire occupent une trentaine de pages (pp. 82-112) : le détail du marquage, la lecture des métriques de ReadMemStats, la trace gctrace, les profils pprof. Si l'une des idées ci-dessus vous a donné envie de creuser, c'est le livre qu'il faut ouvrir.

Odilon

Toujours valable en 2026 ?

En grande partie. Les principes sont valables : une slice partage son tableau, une map ne rétrécit pas, un panic non récupéré arrête tout (le livre le dit, puis le contredit, idée 14). Pour l'essentiel, GOGC et GOMEMLIMIT se règlent comme décrit. L'avant-propos promet pourtant de signaler les différences significatives entre versions (p. xxviii), et deux manquent. Il se base « principalement sur Go 1.26 et versions ultérieures » (p. xxviii). Une seule page dit : « Ce livre est basé sur Go 1.24 » (p. 467), avec une exception pour testing/synctest, le package de test qui simule l'horloge, stabilisé en Go 1.25.

Deux autres points datent d'avant cette base. Le premier est faux, le second seulement nuancé :

Ce que le livre dit juste sur les versions récentes : les types atomiques depuis Go 1.19, And et Or atomiques depuis Go 1.23 (un ET ou un OU bit à bit sans verrou), et panic(nil). Depuis Go 1.21, recover rend alors une erreur *runtime.PanicNilError au lieu de nil (code source de runtime).

À qui s'adresse-t-il ?

Lisez-le si

  • vous écrivez du Go en production depuis plusieurs mois et vous voulez comprendre ce que le runtime fait de votre mémoire et de vos goroutines.
  • vous réglez un service en conteneur : GC, mémoire, processeur.
  • vous aimez vérifier : vous lancerez un docker run golang:1.26 pour contrôler une affirmation avant de la croire.
  • vous lisez sans gêne les termes anglais : le livre ne traduit ni scheduler ni runtime, et renvoie à un glossaire en fin d'ouvrage (p. xxvii).
  • vous cherchez une carte du runtime avant de lire ses sources, et vous acceptez de la corriger en route.

Passez si

  • vous débutez en Go : le livre écrit lui-même qu'il n'est pas « un tutoriel d’introduction à Go » (p. xxvii). Prenez d'abord un cours ou un livre d'initiation.
  • vous voulez du code à copier tel quel : il n'est pas non plus « un catalogue de solutions clés en main » (p. xxviii). Il explique les mécanismes, à vous de rejouer le code.
  • vous cherchez le comportement exact de Go 1.26 sans recoupement : plusieurs descriptions datent de Go 1.23.

Pour aller plus loin

Fiche fondée sur : le PDF de D-BookeR, fourni par l'éditeur. Vingt chapitres lus en entier (2, 3, 8 à 21, 23 à 26). Parties lues des chapitres 1, 4, 5, 6, 7, 22 et 27, et de quelques annexes. Les passages « Ma lecture » sont de moi : essais sous plusieurs versions de Go, et sources officielles de Go.

Commentaires (0)

Dans le même rayon

Langages côté serveur : Python, Go, PHP

Voir tout le rayon (6 fiches) →

Voir toute la bibliothèque

D'autres fiches arrivent : un livre à la fois, la substantifique moelle seulement.