Cours 9 — IO et le package context
Ce cours présente d'abord les deux interfaces de Go pour lire et écrire des données en flux, io.Reader et io.Writer, puis le package context, qui permet d'arrêter proprement des goroutines. On termine en combinant les deux : une copie de flux qu'on peut abandonner en cours de route.
io.Reader / io.Writer
Le package io est au coeur de Go et contient les abstractions fondamentales pour les opérations d'entrée/sortie. Deux interfaces y occupent une place centrale : io.Reader et io.Writer.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
Writeprend une slice de bytes à écrire, retourne le nombre de bytes effectivement écrits et une erreur.Readfournit une slice de bytes comme buffer, lit jusqu'à sa taille maximale, retourne le nombre de bytes réellement lus, etio.EOFquand la fin est atteinte.
func countLetters(r io.Reader) (map[string]int, error) {
buf := make([]byte, 2048)
out := map[string]int{}
for {
n, err := r.Read(buf)
for _, b := range buf[:n] {
if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') {
out[string(b)]++
}
}
if err == io.EOF {
return out, nil
}
if err != nil {
return nil, err
}
}
}
Types courants implémentant ces interfaces : io.Reader — *os.File, strings.Reader, bytes.Buffer, une connexion réseau (net.Conn) ; io.Writer — *os.File, bytes.Buffer, os.Stdout, une connexion réseau (net.Conn). Leur simplicité (une seule méthode) les rend composables et faciles à mocker pour les tests.
Quand l'utiliser
Privilégier io.Reader/io.Writer plutôt que de charger tout le flux en mémoire dès que la taille des données n'est pas bornée à l'avance — un gros fichier, une connexion TCP. Écrire une fonction qui accepte un io.Reader plutôt qu'un []byte permet aussi de la tester avec un simple strings.Reader, sans réseau ni fichier réel.
Quand l'éviter
Pour une charge dont la taille est petite et connue (un fichier de configuration de quelques Ko), io.ReadAll(r) est plus simple et le risque mémoire est négligeable — inutile de gérer soi-même une boucle de lecture.
Piège fréquent : supposer qu'un seul appel à Read() retourne toutes les données. Rien ne garantit qu'un io.Reader remplit le buffer complet en un seul appel — un flux réseau peut livrer les données par morceaux, même si elles tiennent largement dans le buffer fourni :
// BUG : suppose que buf est rempli d'un coup dès le premier appel
buf := make([]byte, 1024)
n, _ := r.Read(buf)
donnees := buf[:n] // peut être tronqué si Read() a livré moins que demandé, sans erreur
Correction : boucler jusqu'à io.EOF (exactement ce que fait déjà countLetters ci-dessus), ou utiliser io.ReadAll, qui fait cette boucle pour vous :
donnees, err := io.ReadAll(r) // lit jusqu'à EOF, gère la boucle en interne
if err != nil {
return nil, err
}
Le problème : comment arrêter des goroutines?
Avec les goroutines et les channels (cours 4-5), on sait lancer des tâches en parallèle. Mais comment les arrêter proprement quand on n'en a plus besoin?
Scénario typique dans un serveur HTTP : une requête lance des goroutines (BD, appel API externe, lecture de fichiers) ; le client se déconnecte après 2 secondes — sans mécanisme d'annulation, ces goroutines continuent à travailler pour rien.
On pourrait utiliser un channel done propagé manuellement, mais dès qu'il y a plusieurs niveaux de goroutines imbriquées, ça devient lourd et source d'erreurs. Le package context résout exactement ce problème : il propage automatiquement les signaux d'annulation et les délais à travers toute une chaîne de goroutines.
Qu'est-ce qu'un context.Context?
Un Context porte trois informations :
| Information | Description | Exemple d'usage |
|---|---|---|
| Annulation | Signal "arrête-toi" | Client déconnecté |
| Délai / Timeout | Date limite d'exécution | "Maximum 5 secondes" |
| Valeurs | Données associées à la requête | ID de la requête, utilisateur connecté |
Ici, on s'intéresse aux deux premières : l'annulation et le délai.
La règle d'or en Go : le contexte se passe toujours comme premier argument d'une fonction.
func maFonction(ctx context.Context, autresArgs ...) error {
// ...
}
Les fonctions de base
On ne crée jamais un contexte à partir de rien. On part toujours d'un contexte racine, puis on en fabrique des contextes dérivés qui ajoutent chacun une capacité. Chaque contexte dérivé garde un lien vers son parent, ce qui forme un arbre de contextes.
Les deux contextes racines :
context.Background()— le contexte racine, ne s'annule jamais, pas de délai. Point de départ dansmain(), tests, initialisation.context.TODO()— identique àBackground(), mais signale « je ne sais pas encore quel contexte mettre ici » (marqueur temporaire pendant le développement).
Un contexte racine ne sait rien faire d'utile par lui-même : il ne s'annule jamais et ne porte aucune valeur. Pour lui ajouter une annulation ou un délai, on le dérive avec l'une des fonctions With*, qui prennent toutes le contexte parent en premier argument :
| Fonction | Ce qu'elle ajoute au contexte parent |
|---|---|
context.WithCancel(parent) | Une annulation manuelle : le contexte est annulé quand on appelle la fonction cancel retournée |
context.WithTimeout(parent, durée) | Une annulation automatique après une durée (ex. : 3 secondes) |
context.WithDeadline(parent, heure) | Une annulation automatique à une heure précise |
Elles sont détaillées dans les sections qui suivent. Si le parent est annulé, tous ses contextes dérivés le sont aussi.
Ces trois fonctions retournent le contexte dérivé et une fonction cancel à appeler avec defer pour libérer les ressources :
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
Quand l'utiliser
Les fonctions d'annulation (WithCancel, WithTimeout, WithDeadline) servent dès qu'une opération réseau est lancée pour le compte d'une requête entrante et doit s'arrêter si le client renonce — une requête HTTP qui déclenche un appel base de données puis un appel vers une API externe, où l'annulation du contexte de départ doit se propager jusqu'au bout de la chaîne.
Piège fréquent en programmation réseau : oublier d'appeler cancel(). Chaque WithCancel/WithTimeout/WithDeadline alloue des ressources internes (dont, pour WithTimeout, une minuterie) qui ne sont libérées que lorsque cancel() est appelé ou que le délai expire :
// BUG : le "cancel" retourné n'est jamais appelé
func recupererProfil(id uint) (*Profil, error) {
ctx, _ := context.WithTimeout(context.Background(), 3*time.Second)
return interrogerBD(ctx, id)
}
Tant que le délai n'est pas écoulé, la minuterie et le contexte restent en mémoire — un vrai problème si cette fonction est appelée souvent. go vet détecte précisément ce cas (analyseur lostcancel) : « the cancel function returned by context.WithTimeout should be called, not discarded ». La correction :
func recupererProfil(id uint) (*Profil, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return interrogerBD(ctx, id)
}
Quand l'éviter
Pour une fonction purement locale, rapide et sans appel réseau ou I/O — ajouter un context.Context à une simple fonction de calcul n'apporte rien et alourdit sa signature pour rien.
L'arbre de contextes : qui est annulé?
Tous les contextes descendent d'un contexte racine, le plus souvent Background(). On pourrait croire qu'ils sont donc tous liés, et qu'une annulation quelque part peut tout arrêter. Ce n'est pas le cas :
Background() ← ne s'annule jamais
├── ctxA (WithCancel) ← on appelle cancelA()
│ └── ctxA1 (WithCancel) ← annulé aussi : c'est un descendant de ctxA
└── ctxB (WithCancel) ← pas touché
ctxA, cancelA := context.WithCancel(context.Background())
ctxA1, cancelA1 := context.WithCancel(ctxA)
ctxB, cancelB := context.WithCancel(context.Background())
defer cancelA1()
defer cancelB()
cancelA()
fmt.Println(ctxA.Err()) // context canceled
fmt.Println(ctxA1.Err()) // context canceled
fmt.Println(ctxB.Err()) // <nil> : une autre branche, pas touchée
ctx.Err() retourne nil tant que le contexte n'est pas annulé, puis la raison de l'annulation (ici context canceled).
Trois règles expliquent ce résultat :
- L'annulation descend seulement.
cancelA()annulectxAet tous ses descendants, peu importe la profondeur. - Elle ne remonte jamais et ne touche pas les voisins. Annuler
ctxA1n'aurait eu aucun effet surctxA, etctxBest dans une autre branche : il n'a aucun lien avecctxA. Background()ne s'annule jamais. Il n'a pas de fonctioncancel: rien ne peut l'annuler. L'avoir comme ancêtre commun ne relie donc pasctxAetctxB.
Conséquence pratique : c'est l'endroit où l'on place un WithCancel qui décide de ce qu'un cancel() arrêtera. On crée un contexte par travail indépendant (par exemple un par client servi) pour qu'une annulation n'arrête que ce travail. À l'inverse, on place tout sous un même WithCancel quand on veut pouvoir tout arrêter d'un coup, par exemple à l'arrêt du programme.
Annulation avec context.WithCancel
Crée un contexte qu'on peut annuler manuellement.
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, id int) {
for {
fmt.Printf("Worker %d travaille...\n", id)
select {
case <-ctx.Done():
// Le contexte a été annulé
fmt.Printf("Worker %d arrêté : %v\n", id, ctx.Err())
return
case <-time.After(500 * time.Millisecond):
// Pause terminée, on recommence
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
for i := 1; i <= 3; i++ {
go worker(ctx, i)
}
time.Sleep(2 * time.Second)
fmt.Println("Annulation!")
cancel() // annule tous les workers d'un coup
time.Sleep(200 * time.Millisecond)
}
ctx.Done() retourne un channel fermé quand le contexte est annulé (à utiliser dans un select) ; ctx.Err() retourne la raison (context.Canceled ou context.DeadlineExceeded).
Ce que fait concrètement cancel()
cancel() n'arrête aucune goroutine directement. Le channel retourné par ctx.Done() est un <-chan struct{} (en lecture seule, donc impossible à fermer de l'extérieur) qui ne transporte jamais de valeur. C'est exactement le channel done vu au cours 8, et cancel() se contente essentiellement de le fermer. En détail, un appel à cancel() :
- enregistre la raison de l'annulation :
ctx.Err()retourne désormaiscontext.Canceledau lieu denil; - ferme le channel
done(close(done)) ; - appelle
cancel()sur tous les contextes dérivés (les « enfants ») ; - retire le contexte de la liste des enfants de son parent, ce qui permet de libérer sa mémoire.
Seul le premier appel à cancel() a un effet ; les appels suivants ne font rien. C'est pourquoi un defer cancel() reste sans danger même si cancel() a déjà été appelé plus tôt.
Pourquoi fermer le channel plutôt qu'y envoyer une valeur ? Une lecture sur un channel fermé ne bloque plus : elle retourne immédiatement la valeur zéro, pour tous les lecteurs et à chaque lecture. Fermer done prévient donc d'un seul coup les trois workers de l'exemple, alors qu'un envoi done <- struct{}{} n'en réveillerait qu'un seul.
Conséquence directe : passer ctx à une fonction ne l'annule pas automatiquement. cancel() ferme le channel, mais seule une fonction qui écoute ctx.Done() s'en rend compte. Les fonctions de la bibliothèque standard qui acceptent un contexte pour l'annulation le font déjà ; pour nos propres fonctions, c'est à nous de l'écrire.
Piège fréquent en programmation réseau : ignorer ctx.Done() dans une boucle ou un traitement long. Un contexte annulé n'arrête rien tout seul — c'est au code de vérifier explicitement ctx.Done(). Une goroutine qui boucle sans ce test continue de travailler après l'annulation :
// BUG : cette goroutine ignore complètement ctx, elle continue même après cancel()
func worker(ctx context.Context, id int) {
for {
fmt.Printf("Worker %d travaille...\n", id)
time.Sleep(500 * time.Millisecond)
}
}
Une version à mi-chemin vérifie ctx.Done() dans un select, mais fait sa pause avec time.Sleep dans la branche default :
// À ÉVITER : réagit à l'annulation, mais avec jusqu'à 500 ms de retard
func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("Worker %d arrêté : %v\n", id, ctx.Err())
return
default:
fmt.Printf("Worker %d travaille...\n", id)
time.Sleep(500 * time.Millisecond)
}
}
}
Pendant son time.Sleep, la goroutine ne regarde pas ctx.Done(). Elle ne remarque l'annulation qu'au réveil, au prochain passage dans le select. Si main se termine avant ce réveil, le message « arrêté » ne s'affiche jamais.
La version correcte (celle de l'exemple ci-dessus) attend dans le select lui-même, entre ctx.Done() et time.After. La goroutine réagit donc à l'annulation immédiatement, même en pleine pause :
func worker(ctx context.Context, id int) {
for {
fmt.Printf("Worker %d travaille...\n", id)
select {
case <-ctx.Done():
fmt.Printf("Worker %d arrêté : %v\n", id, ctx.Err())
return
case <-time.After(500 * time.Millisecond):
}
}
}
Le même piège existe pour un appel réseau bloquant qui n'accepte pas de contexte (ex.: conn.Read brut) : il faut alors le lancer dans sa propre goroutine et faire un select externe entre son résultat et ctx.Done().
Délai avec context.WithTimeout et context.WithDeadline
WithTimeout annule automatiquement le contexte après une durée donnée ; WithDeadline fait la même chose mais avec une heure absolue plutôt qu'une durée :
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// équivalent avec une heure précise
ctx2, cancel2 := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
defer cancel2()
C'est le même principe que WithCancel, sauf que le cancel() se déclenche tout seul à l'échéance. Utile pour un appel réseau : on passe le contexte à la fonction qui fait l'appel, et elle abandonne dès que le délai est dépassé, au lieu de faire attendre tout le programme.
Propagation en cascade : la vraie puissance de context est qu'un seul cancel() sur un contexte annule tous ses descendants créés avec les fonctions vues ici, peu importe la profondeur dans l'arbre, sans toucher aux autres branches. Encore faut-il que chaque goroutine reçoive ce contexte (ou un descendant) et surveille ctx.Done().
Combiner IO et contexte : une copie annulable
io.Copy(dst, src) copie tout un flux d'un io.Reader vers un io.Writer, mais il n'accepte pas de contexte : une copie depuis une source lente ne peut pas être abandonnée en cours de route. Quand on veut pouvoir l'arrêter (délai dépassé, utilisateur qui annule), on écrit sa propre boucle de copie, qui vérifie le contexte avant chaque lecture.
Pour tester sans vrai réseau, on simule une source lente avec notre propre type. Comme io.Reader est une interface qui n'exige qu'une méthode Read, il suffit d'écrire cette méthode :
// lecteurLent simule une source réseau lente :
// chaque appel à Read attend un peu, puis livre un seul octet.
type lecteurLent struct {
source io.Reader
pause time.Duration
}
func (l *lecteurLent) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
time.Sleep(l.pause)
return l.source.Read(p[:1])
}
La copie annulable reprend la boucle de lecture de countLetters, avec une vérification du contexte au début de chaque tour :
func copierAvecContexte(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) {
buf := make([]byte, 32)
var total int64
for {
if err := ctx.Err(); err != nil { // contexte annulé : on abandonne
return total, err
}
n, err := src.Read(buf)
if n > 0 {
ecrits, errEcriture := dst.Write(buf[:n])
total += int64(ecrits)
if errEcriture != nil {
return total, errEcriture
}
}
if err == io.EOF {
return total, nil
}
if err != nil {
return total, err
}
}
}
Avec une source qui livre un octet toutes les 100 ms et un délai d'une seconde, la copie s'arrête après une dizaine d'octets (strings.NewReader transforme simplement une chaîne en io.Reader) :
src := &lecteurLent{source: strings.NewReader("Bonjour depuis un serveur vraiment lent\n"), pause: 100 * time.Millisecond}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
n, err := copierAvecContexte(ctx, os.Stdout, src)
fmt.Printf("\n%d octets copiés, erreur : %v\n", n, err)
// Sortie typique :
// Bonjour de
// 10 octets copiés, erreur : context deadline exceeded
ctx.Err() sert ici de test non bloquant : il retourne nil tant que le contexte est actif, et l'erreur dès qu'il est annulé ou expiré. La fonction retourne aussi le nombre d'octets déjà copiés, ce qui permet à l'appelant de savoir où la copie s'est arrêtée.
Limite de cette approche : le contexte n'est vérifié qu'entre deux lectures. Un Read déjà commencé n'est pas interrompu : la copie réagit donc avec au plus une lecture de retard. Pour un appel réseau qui peut bloquer longtemps, on revient à la solution vue plus haut : lancer l'appel dans sa propre goroutine et faire un select entre son résultat et ctx.Done().
Bonnes pratiques
- Toujours
defer cancel(), même si le timeout n'est jamais atteint. - Le contexte est toujours le premier argument d'une fonction, jamais stocké dans une struct.
- Ne jamais passer
nil— utilisercontext.TODO()si le bon contexte n'est pas encore déterminé.
| Fonction | Usage |
|---|---|
context.Background() | Point de départ — main(), tests |
context.WithCancel(parent) | Annulation manuelle |
context.WithTimeout(parent, durée) | Annulation après une durée |
context.WithDeadline(parent, heure) | Annulation à une heure précise |