Cours 7 — Livelock et Starvation
Interblocage livelock
Un autre type de blocage est le livelock (pas de traduction). C'est ce qui arrive lorsque vos goroutines sont bloquées de manière active. Contrairement au deadlock (cours 6) où les goroutines sont immobiles en attente, dans un livelock elles continuent de s'exécuter — mais sans jamais progresser.
Exemple classique : la goroutine A obtient une erreur et demande à la goroutine B de la corriger. La goroutine B voit une erreur et demande à A de la corriger, et ainsi de suite. Les deux goroutines réagissent constamment l'une à l'autre sans qu'aucune ne fasse de progrès réel — un peu comme deux personnes qui essaient de se croiser dans un couloir étroit, s'écartent toutes les deux du même côté en même temps, encore et encore.
Pistes pour éviter un livelock :
- Introduire un délai aléatoire (backoff aléatoire) avant de réessayer, pour désynchroniser les tentatives concurrentes.
- Établir une règle de priorité déterministe (ex. : la goroutine avec l'ID le plus petit a toujours priorité) plutôt que de laisser les deux réagir symétriquement.
- Limiter le nombre de tentatives (retry) avant d'abandonner ou d'escalader l'erreur.
Le dîner des philosophes est un problème classique de concurrence, posé par Edsger Dijkstra en 1965, popularisé peu après sous la forme de philosophes et de fourchettes par Tony Hoare : des philosophes sont assis autour d'une table ronde, une fourchette posée entre chaque paire de voisins ; pour manger, un philosophe doit tenir simultanément sa fourchette de gauche et sa fourchette de droite, chacune partagée avec le voisin de ce côté. Si tous les philosophes prennent d'abord leur fourchette de gauche puis attendent indéfiniment la droite (déjà prise par le voisin qui attend symétriquement), plus personne ne progresse jamais — c'est l'exemple canonique de deadlock (cours 6). L'exemple ci-dessous en reprend la structure à deux philosophes (deux fourchettes = deux sync.Mutex, chacun demandant les fourchettes dans un ordre inversé par rapport à l'autre), mais avec TryLock() plutôt que Lock() : au lieu de rester bloquées indéfiniment à attendre la seconde fourchette, les deux goroutines relâchent poliment celle qu'elles tiennent déjà dès l'échec et retentent aussitôt — ce qui, si les deux réagissent de façon parfaitement symétrique à chaque conflit, transforme le deadlock classique en livelock.
Exemple — un dîner des philosophes à deux, avec une version forcée en livelock déterministe et sa correction par backoff aléatoire.
Quand l'utiliser (backoff aléatoire)
Dès que plusieurs goroutines peuvent retenter une opération sur une ressource momentanément indisponible — par exemple, deux goroutines qui gèrent chacune une connexion cliente et tentent d'obtenir un même verrou pour router un message vers un troisième client.
Piège fréquent en programmation réseau : un backoff à délai constant, identique pour toutes les goroutines. Si les deux goroutines réessaient toujours après exactement le même délai, elles se resynchronisent à chaque tentative et se re-bloquent mutuellement indéfiniment. (Les exemples ci-dessous utilisent mu.TryLock() : contrairement à Lock(), qui bloque jusqu'à l'obtention du verrou, TryLock() retourne immédiatement true s'il a pu prendre le verrou, ou false sinon, sans jamais bloquer.)
// BUG : les deux goroutines retentent toutes les 100 ms, en phase l'une avec
// l'autre — elles se percutent à chaque essai, sans jamais progresser
for {
if mu.TryLock() {
routerMessage()
mu.Unlock()
break
}
time.Sleep(100 * time.Millisecond)
}
La correction consiste à ajouter un délai aléatoire (jitter) pour désynchroniser les tentatives :
for {
if mu.TryLock() {
routerMessage()
mu.Unlock()
break
}
delai := 50*time.Millisecond + time.Duration(rand.Intn(100))*time.Millisecond
time.Sleep(delai)
}
Quand l'éviter
Si une seule goroutine retente l'opération (pas de conflit symétrique possible avec une autre), un backoff aléatoire n'apporte rien — un délai fixe ou une file d'attente suffit.
Priorité déterministe (alternative au backoff aléatoire)
Plutôt que de désynchroniser les tentatives avec un délai aléatoire, on peut casser la symétrie directement : attribuer un identifiant fixe à chaque goroutine et n'autoriser que celle avec la priorité la plus haute (par exemple, l'ID le plus petit) à réessayer immédiatement, l'autre devant céder sa place.
func routerAvecPriorite(id int, mu *sync.Mutex, autrePriorite int) {
for {
if mu.TryLock() {
routerMessage()
mu.Unlock()
return
}
if id > autrePriorite {
// je ne suis pas prioritaire : je cède la place plus longtemps
time.Sleep(50 * time.Millisecond)
}
// la goroutine prioritaire réessaie sans attendre
}
}
Contrairement au backoff aléatoire, cette approche est déterministe et reproductible — utile en test — mais elle suppose de pouvoir attribuer une priorité stable à chaque goroutine, ce qui n'est pas toujours possible (ex. : connexions clientes anonymes, sans ordre naturel entre elles).
Starvation (famine)
Les blocages peuvent provoquer une famine (resource starvation). La famine survient lorsqu'une goroutine ne peut pas s'exécuter faute de ressources — par exemple la goroutine A fait une boucle infinie et utilise 100% du CPU, la goroutine B attend son tour sur le CPU pour s'exécuter mais n'obtient jamais sa chance.
C'est aussi un risque avec les verrous : si une politique d'accès à un sync.Mutex (ou à un RWMutex, cours 6) favorise systématiquement certaines goroutines (ex. : beaucoup de lecteurs qui affament l'écrivain sur un RWMutex), les goroutines défavorisées peuvent attendre indéfiniment même si le programme progresse globalement.
Pistes pour éviter la starvation :
- Éviter les boucles serrées (tight loops : des boucles qui s'enchaînent sans jamais s'arrêter ni céder la main — pas d'appel bloquant, pas de pause) sans point de cession (
time.Sleep,runtime.Gosched()) qui monopolisent un cœur CPU. - Préférer des files d'attente équitables (FIFO) plutôt que des mécanismes qui peuvent réordonner indéfiniment les demandeurs.
- Surveiller la durée d'attente des goroutines critiques en conditions de charge, pas seulement leur correction fonctionnelle.
Bon à savoir : avant Go 1.14, une boucle serrée sans appel de fonction ni opération bloquante pouvait monopoliser un cœur indéfiniment, car l'ordonnanceur ne pouvait reprendre la main qu'à certains points précis du code. Depuis Go 1.14, la préemption asynchrone permet à l'ordonnanceur d'interrompre une goroutine même au milieu d'une boucle serrée — ce qui réduit ce risque de famine, mais ne dispense pas d'écrire des boucles qui cèdent volontairement la main.
Exemple — une goroutine gourmande en boucle serrée qui affame une goroutine polie (GOMAXPROCS(1)), et sa correction avec runtime.Gosched().
Quand l'utiliser (traitement équitable dans un select)
Dès qu'un serveur multiplexe plusieurs connexions ou canaux dans une même boucle select et doit garantir qu'aucune source ne soit privée de service indéfiniment, même sous forte charge sur une autre source.
Piège fréquent en programmation réseau : une boucle select qui priorise structurellement le même canal. Vérifier un canal en premier avec un default non bloquant, et ne retomber sur le second canal que dans ce default, l'affame dès que le premier reçoit du trafic soutenu :
// BUG : chPrioritaire est toujours vérifié en premier ; tant qu'il reçoit du
// trafic soutenu, le bloc "default" (donc chNormal) n'est presque jamais exécuté
for {
select {
case conn := <-chPrioritaire:
traiter(conn)
default:
select {
case conn := <-chNormal:
traiter(conn)
default:
}
}
}
La correction consiste à mettre les deux canaux dans le même select, en laissant Go choisir au hasard parmi les cases prêtes plutôt que d'imposer une priorité structurelle :
for {
select {
case conn := <-chPrioritaire:
traiter(conn)
case conn := <-chNormal:
traiter(conn)
}
}
Le sélecteur de Go choisit uniformément au hasard parmi les cases prêtes quand il y en a plusieurs — c'est ce comportement qui évite la famine structurelle, à condition de ne pas le contourner avec des select imbriqués comme dans le code bugué.
Quand l'éviter
Si un canal doit réellement toujours passer avant l'autre (ex.: un canal d'arrêt d'urgence chStop), la priorité structurelle est voulue — dans ce cas précis, le select imbriqué avec default est le bon patron, pas un piège.
Détecter un livelock ou une starvation
Contrairement au deadlock, que le runtime Go peut détecter automatiquement dans le cas trivial où toutes les goroutines sont bloquées (fatal error: all goroutines are asleep - deadlock!), rien de tel n'existe pour le livelock ou la starvation : du point de vue du runtime, les goroutines concernées sont bien vivantes et s'exécutent. Il faut donc observer le programme en marche plutôt que d'attendre un crash.
Goroutine dump (net/http/pprof)
En important le paquet pour ses effets de bord, un serveur HTTP de diagnostic expose un instantané de toutes les goroutines en cours, avec leur pile d'appels :
import _ "net/http/pprof"
func main() {
go http.ListenAndServe("localhost:6060", nil)
// ... reste du programme ...
}
En visitant http://localhost:6060/debug/pprof/goroutine?debug=2, on obtient la pile de chaque goroutine. Le signe d'un livelock ou d'une starvation : reprendre le dump à quelques secondes d'intervalle et constater que les mêmes goroutines sont toujours coincées dans la même boucle (mêmes lignes de code répétées), sans jamais progresser vers une autre étape.
GODEBUG=schedtrace et go tool trace
Pour voir l'ordonnanceur Go lui-même, plutôt que le code applicatif :
GODEBUG=schedtrace=1000 ./monprogramme
# Affiche, chaque seconde, le nombre de goroutines en exécution/attente par processeur
go tool trace (à partir d'une trace capturée avec le paquet runtime/trace) donne une vue chronologique de l'exécution : on y voit visuellement des goroutines qui s'exécutent en boucle sans jamais atteindre les portions de code qui feraient progresser le programme — la signature d'un livelock.
Quand l'utiliser
Dès qu'un service en production semble « vivant » (CPU actif, pas de crash) mais ne répond plus correctement — le réflexe est de prendre un dump de goroutines avant de redémarrer le service, sans quoi la cause du blocage disparaît avec le redémarrage.
Quand l'éviter
Ne laissez jamais net/http/pprof exposé sur une interface publique en production — il révèle des détails internes du programme (piles d'appels, code source) ; limitez-le à localhost ou à un réseau interne protégé.
Thundering herd : livelock à l'échelle d'un service réseau
Le livelock ne se limite pas à deux goroutines qui se bloquent l'une l'autre à l'intérieur d'un seul programme : le même phénomène existe à l'échelle d'un service réseau, sous le nom de thundering herd (« troupeau tonnant ») ou tempête de réessais (retry storm).
Scénario typique : un service tombe brièvement en surcharge et se met à répondre plus lentement ou avec des erreurs. Des centaines de clients, configurés avec le même délai de réessai fixe, réessaient tous en même temps quelques secondes plus tard. Cette vague synchronisée surcharge à nouveau le service, qui répond encore plus lentement, ce qui resynchronise une nouvelle vague de réessais — le service ne se rétablit jamais, exactement comme les deux goroutines du livelock qui ne progressent jamais.
// BUG : tous les clients réessaient après exactement le même délai fixe
func appelerAvecRetry(client *http.Client, url string) (*http.Response, error) {
for tentative := 0; tentative < 5; tentative++ {
resp, err := client.Get(url)
if err == nil {
return resp, nil
}
time.Sleep(2 * time.Second) // tous les clients se resynchronisent ici
}
return nil, errors.New("échec après 5 tentatives")
}
La correction combine deux mécanismes déjà vus dans ce cours : un backoff exponentiel (le délai grandit à chaque tentative, pour laisser au service le temps de récupérer) et du jitter (le délai aléatoire vu plus haut, pour désynchroniser les clients entre eux) :
func appelerAvecRetry(client *http.Client, url string) (*http.Response, error) {
for tentative := 0; tentative < 5; tentative++ {
resp, err := client.Get(url)
if err == nil {
return resp, nil
}
base := time.Duration(1<<tentative) * 100 * time.Millisecond // 100ms, 200ms, 400ms...
jitter := time.Duration(rand.Intn(100)) * time.Millisecond
time.Sleep(base + jitter)
}
return nil, errors.New("échec après 5 tentatives")
}
Quand l'utiliser
Dans tout client réseau (HTTP, gRPC, connexion à une base de données) qui réessaie automatiquement un appel — dès qu'il existe une chance que plusieurs instances de ce client tournent en parallèle (plusieurs pods, plusieurs utilisateurs), le backoff exponentiel avec jitter doit être le comportement par défaut.
Quand l'éviter
Pour un appel unique déclenché manuellement par une seule personne (ex. : un script d'administration exécuté à la main), la complexité d'un backoff exponentiel n'apporte rien — un simple délai fixe ou une seule tentative suffit.
Deadlock, livelock, starvation : la distinction
| Symptôme | Les goroutines... | Cause typique |
|---|---|---|
| Deadlock | sont bloquées, immobiles | attente indéfinie sur une ressource jamais libérée (cours 6) |
| Livelock | s'exécutent activement, mais sans avancer | réaction symétrique répétée à un conflit |
| Starvation | certaines n'obtiennent jamais leur tour | répartition inéquitable des ressources/CPU |