Cet article résume « The Data Race That Wasn’t a Bug (and the One That Was) », publié par VictoriaMetrics le 6 octobre 2026.

L’article source décrit une data race signalée par le race detector de Go à l’intérieur de net/http, d’abord dans vmagent, puis dans vmauth. VictoriaMetrics a gardé la première et corrigé la seconde.

Nous reprendrons le mécanisme de la race et les critères de leur décision montrant le tradeoff parfois à trancher dans la correction d’un bug.

Le programme qui déclenche la race

Le programme envoie un lot de logs d’environ 300 Ko à un serveur et réessaie tant que la réponse est une erreur. Pour éviter une allocation à chaque tentative, il réutilise un bytes.Buffer global :

var buf = bytes.NewBuffer(nil)

func insertLogs(client *http.Client, logs []LogEntry) error {
	buf.Reset()
	if err := json.MarshalWrite(buf, logs); err != nil {
		panic(err)
	}

	resp, err := client.Post(url, "application/json", bytes.NewReader(buf.Bytes()))
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	_, _ = io.Copy(io.Discard, resp.Body)
	if resp.StatusCode/100 != 2 {
		return fmt.Errorf("unexpected status code %d", resp.StatusCode)
	}
	return nil
}

Le serveur de test répond toujours 502 Bad Gateway sans lire le corps.
Après quelques tentatives, le race detector signale :

  • une écriture par json.MarshalWrite dans main;
  • une lecture par un bytes.Reader appelé depuispersistConn.writeLoop,
  • une goroutine interne à net/http.

Le cas a été remonté à l’équipe du langage Go dans l’issue golang/go#81445.

Pourquoi y a-t-il trois goroutines derrière client.Post ❓

Pour chaque connexion HTTP/1.1, le http.Transport démarre deux goroutines :

  • writeLoop envoie la requête : ligne de requête, en-têtes, puis corps ;
  • readLoop lit la réponse et la transmet.

La troisième goroutine est celle de l’appelant.

Dans persistConn.roundTrip, elle confie la requête aux deux autres, puis attend dans un select le premier des deux événements : writeLoop a fini d’écrire ou readLoop a reçu une réponse.

Une connexion TCP transporte les données dans les deux sens de façon indépendante.

Un serveur peut donc répondre dès la lecture des en-têtes, sans lire le corps :

  • requête non autorisée,
  • Content-Length trop grand,
  • backend injoignable derrière un proxy.

C’est dans ces cas que readLoop peut livrer la réponse, le select retourne et client.Post rend la main alors que writeLoop envoie encore le corps.

Derrière client.Post : la goroutine appelante confie la requête à writeLoop, qui l’envoie, et à readLoop, qui attend la réponse. Elle rend la main dès que readLoop livre une réponse. Source : VictoriaMetrics.

Ce comportement est documenté dans http.RoundTripper :

le corps peut être fermé dans une autre goroutine après le retour de RoundTrip.

Le déroulement de la race

writeLoop copie le corps par blocs de 32 Kio depuis le tableau sous-jacent de buf vers son propre tampon, puis vers le socket.
buf.Reset() ne libèrant pas ce tableau, chaque tentative réécrit dans la même mémoire.

Le chemin du corps : le tableau de buf est copié par blocs de 32 Kio dans le tampon de writeLoop, puis écrit dans le socket. Seule la première copie touche la mémoire de l’appelant. Source : VictoriaMetrics.

La séquence est la suivante :

  1. l’appelant encode le JSON dans le tableau et appelle client.Post ;
  2. writeLoop commence à copier le corps ;
  3. le serveur répond 502 avec Connection: close sans avoir lu le corps ;
  4. client.Post retourne, l’appelant réessaie et réencode dans le même tableau ;
  5. writeLoop lit toujours ce tableau pour finir l’envoi précédent.

Chronologie de la race : l’appelant écrit le tampon et appelle Do, writeLoop le copie par blocs de 32 Kio, readLoop renvoie le 502 sans attendre writeLoop, et la nouvelle tentative réécrit le tableau pendant que writeLoop le lit encore. Source : VictoriaMetrics.

Les étapes 4 et 5 se chevauchent : c’est la data race.

Elle reste rare en local, car le corps part presque instantanément, et Go attend jusqu’à 50 ms la fin de writeLoop quand la connexion doit être réutilisée.

Ce que la race peut produire

Le tampon de l’appelant n’est jamais corrompu car writeLoop ne fait que lire.

Le risque porte sur les octets envoyés. Si le tampon sert à des requêtes différentes, writeLoop peut envoyer un mélange de l’ancien et du nouveau contenu :

  • un JSON valide avec un timestamp qu’aucune requête ne portait,
  • un JSON invalide si le nouveau contenu est plus court.

La question qui décide de la gravité est donc : qui lit ces octets mélangés ?

vmagent : une race conservée

Le client remote write de vmagent (#11507) suit le même schéma. Un worker réutilise une slice d’octets pour chaque bloc lu dans sa file, et l’envoie via un nouveau bytes.Buffer à chaque requête.

Chaque requête possède donc son propre lecteur, avec sa propre longueur et sa propre position. Seul le tableau d’octets est partagé. Le modèle mémoire de Go garantit que la lecture d’un octet en cours d’écriture renvoie l’ancienne ou la nouvelle valeur, sans état intermédiaire. Le programme ne plante pas et aucune autre zone mémoire n’est touchée.

Les octets mélangés partent dans une requête déjà traitée par le serveur, sur une connexion généralement en cours de fermeture. Personne ne les consomme.

Les corrections possibles avaient un coût :

  • allouer un tampon par requête annule le gain de la réutilisation ;
  • attendre que le transport ait fini avec le corps a été tenté, fusionné, puis annulé trois jours plus tard : les workers pouvaient se bloquer, un deadlock restait possible dans de rares cas, et tous les cas n’étaient pas couverts.

VictoriaMetrics a gardé la race et l’a documentée comme un signalement connu et accepté. L’équipe prévoit d’y revenir si Go fournit un moyen officiel d’attendre la fin de l’utilisation du corps.

Damien Neil a proposé une méthode Request.Close dans l’issue amont.

vmauth : une race corrigée

vmauth (#11508) est un proxy qui réessaie la même requête sur un autre backend en cas d’échec ou de réponse 503. Pour renvoyer le même corps, il le garde en mémoire dans un type bufferedBody.

Avant la correction, bufferedBody servait aussi de lecteur : il contenait les octets et la position de lecture. À chaque nouvelle tentative, vmauth remettait la position à zéro et passait le même objet à la requête suivante. Le writeLoop de la tentative échouée continuait à déplacer cette position, et la remettait même à zéro en se terminant, pendant l’envoi de la tentative suivante.

Le backend suivant pouvait alors recevoir un corps avec des morceaux manquants ou répétés. Quand la longueur ne correspondait pas, la requête échouait. Quand elle correspondait, un backend sain acceptait une requête corrompue sans que personne ne le remarque.

La correction (#11647) supprime le partage au lieu d’ajouter de la synchronisation. bufferedBody garde les octets, et chaque tentative reçoit son propre bytes.Buffer au-dessus de ces octets. Les octets partagés ne sont plus que lus, il n’y a donc plus de race. Le coût se limite à quelques petites allocations par tentative, sans copie des données, sur un chemin dominé par l’aller-retour réseau.

Les critères de décision

Les deux signalements viennent du même comportement de net/http. Le race detector avait raison dans les deux cas, mais il n’indique pas si la race a des conséquences. L’article source s’appuie sur deux questions :

  1. Qu’est-ce qui est partagé ? Des octets copiés ailleurs, ou un état qui pilote le comportement, comme une position, une longueur ou un pointeur ? Une race sur des octets produit des octets périmés. Une race sur un curseur produit un faux comportement.
  2. Qui consomme le résultat ? Dans vmagent, une requête déjà traitée sur une connexion en cours de fermeture. Dans vmauth, un backend sain qui ingère les données.

Pour le client HTTP de Go, l’article en tire trois règles :

  • le transport peut encore lire le corps de la requête après le retour de Do ;
  • un même io.Reader avec état ne doit pas servir à deux requêtes ;
  • la mémoire derrière un corps déjà envoyé ne doit pas être réutilisée, remise dans un pool ou modifiée avant que le transport ait appelé Close() dessus.

Conclusion

Le même signalement du race detector a conduit à deux décisions opposées :

  • dans vmagent, corriger la race coûtait en performance et en complexité pour protéger des octets que personne ne lit,
  • dans vmauth, la laisser exposait un backend à des données corrompues.

Le code en cause était presque identique, l’impact ne l’était pas.

Notre métier repose sur ce type de compromis. Avant de corriger, il faut mesurer ce que le problème provoque réellement, puis décider en connaissance de cause et documenter ce choix.

L’article de VictoriaMetrics détaille le code de net/http, le chemin des octets jusqu’au socket et la chronologie de la race avec des schémas. Il montre aussi comment documenter une décision technique : le problème, les options étudiées, le choix retenu et les conditions pour y revenir.

C’est un exemple à suivre pour nos propres arbitrages.

Resources