30.10.26
Go 1.27 : réutiliser les connexions HTTP/114 min de lecture2 704 mots

Go 1.27 : réutiliser les connexions HTTP/1

Découvrez pourquoi Go 1.27 réutilise mieux les connexions HTTP/1, accélère les requêtes séquentielles et simplifie la fermeture des réponses. Voici les limites.

réutilisation des connexions HTTPGo 1.27HTTP/1 en Goclient HTTP Goperformances réseau
Go 1.27 : réutiliser les connexions HTTP/1

Go 1.27 accélère les requêtes HTTP/1 : ce qui change vraiment

Un appel à http.Get, la lecture d'un code de statut, puis la fermeture de la réponse : sur le papier, c'est simple. Sauf que, répété des dizaines de fois, ce petit scénario peut vite coûter plus cher que prévu. Le serveur n'est pas forcément en cause, ni même le débit réseau. En HTTP/1, si le client laisse une partie du corps de réponse non lue, il peut empêcher la réutilisation de la connexion TCP.

Avec Go 1.27, le transport HTTP standard limite une partie de ce problème : quand un corps HTTP/1 n'a pas été entièrement consommé, net/http essaie de le vider en arrière-plan, dans des bornes définies. Sur des requêtes séquentielles, le gain peut être réel, sans exiger de « nettoyage » dans le code applicatif.

Voici le mécanisme, ses limites, et comment l'appliquer sans se raconter d'histoires.

Pourquoi l'ouverture d'une connexion coûte cher

Quand un client HTTP démarre une nouvelle connexion, il ne « paie » pas seulement le temps de traitement côté serveur. Il doit aussi établir le transport réseau, puis, en HTTPS, négocier le chiffrement avant même que la requête applicative commence.

Sur TCP, l'établissement passe par un échange de paquets. En HTTPS, TLS ajoute sa propre négociation. Le nombre d'allers-retours dépend du contexte (version de TLS, conditions réseau), mais le principe reste le même : quand le client et le serveur sont éloignés, chaque aller-retour se voit.

Avec TLS 1.2, une connexion HTTPS peut demander plusieurs allers-retours. TLS 1.3 réduit ça dans les cas courants, mais ça ne rend pas la création d'une connexion « gratuite ». Une latence réseau élevée, une négociation cryptographique coûteuse ou un serveur distant peuvent faire de cette phase un morceau non négligeable du temps total, surtout pour de petites requêtes.

Pourquoi le problème apparaît surtout en séquentiel

Regardons une fonction qui ne fait qu'un aller-retour : récupérer le code de statut.

func getStatus(url string) (int, error) {

resp, err := http.Get(url)

if err != nil {

return 0, err

}

defer resp.Body.Close()

return resp.StatusCode, nil

}

Le code ferme bien resp.Body, mais il ne lit pas le corps. Sur les versions avant Go 1.27, cette différence pouvait empêcher le transport de réutiliser la connexion HTTP/1. Si cette fonction est appelée 100 fois de suite, le client peut finir par ouvrir des connexions à répétition plutôt que d'en réutiliser une déjà établie.

Dans l'exemple publié par l'équipe Go, 100 requêtes HTTP/1 vers index.golang.org prenaient environ 710 ms avec Go 1.26, contre environ 150 ms avec Go 1.27 sur la machine de mesure. L'ordre de grandeur est là, mais il ne faut pas en faire une règle générale : ça dépend du serveur, du réseau, du protocole réellement négocié, de la taille des réponses et de la manière dont le client les consomme.

Le gain se remarque surtout quand les appels sont séquentiels : chaque requête attend la précédente, et réutiliser une connexion évite de refaire une partie du « démarrage ». Dans un système distribué, ça peut réduire une latence cumulée si plusieurs services s'interrogent. Et, au passage, ça peut aussi limiter le nombre de connexions TCP ouvertes côté client et serveur.

Réutiliser une connexion ne rend pas les requêtes « gratuites »

Réutiliser ne veut pas dire que les requêtes suivantes deviennent instantanées. Le client doit toujours envoyer la requête, recevoir la réponse et traiter les données. Le serveur doit toujours faire son travail. La réutilisation évite surtout de recréer le canal de communication à chaque fois.

C'est particulièrement utile quand le coût d'établissement pèse plus lourd que le travail réel. Pour une requête longue ou une réponse volumineuse, le temps de traitement et de transfert domine souvent. À l'inverse, pour une petite réponse et un réseau lent, éviter une nouvelle connexion peut faire une vraie différence.

En pratique, il faut distinguer trois choses : le temps d'établissement de la connexion, le temps de traitement/attente de la réponse, et le temps de lecture du corps. Un benchmark qui ne regarde que le temps total peut masquer la cause. Si vous voulez comprendre ce qui se passe, regardez aussi les traces HTTP de Go et vérifiez si les connexions sont effectivement réutilisées.

Pourquoi un corps non lu bloque HTTP/1

La réutilisation dépend du protocole. En HTTP/1.0 et HTTP/1.1, les réponses successives passent par le même flux d'octets. Résultat : pour que le client puisse commencer la réponse suivante sur la même connexion, il doit savoir exactement où se termine la réponse en cours.

En HTTP/1.1, les connexions persistantes sont actives par défaut, sauf indication contraire, par exemple Connection: close. Mais une connexion « persistante » ne suffit pas : il faut aussi que la réponse précédente soit consommée jusqu'à sa fin. Cette fin dépend notamment de Content-Length ou du mécanisme de transfert utilisé.

Fermer le corps ne garantit pas qu'il a été lu jusqu'au bout

Quand net/http reçoit une réponse, il lit le statut et les en-têtes pour construire http.Response. Le corps reste ensuite accessible via resp.Body, et l'application le lit au moment où elle en a besoin.

Si elle ne le lit pas, une partie des octets peut rester dans le flux.

Et là, ça coince : en HTTP/1, le client ne peut pas simplement « ignorer » ces octets et se repositionner pour retrouver le début de la prochaine réponse. Sans lire la fin, il n'a pas de façon fiable de savoir où la réponse suivante commence.

Jusqu'à Go 1.26, appeler resp.Body.Close() alors qu'il restait un corps non consommé conduisait souvent le transport à abandonner la connexion, justement pour éviter de mélanger des données.

La requête suivante pouvait alors marcher, mais en utilisant une nouvelle connexion. Ce n'était pas forcément visible via une erreur : c'était plutôt une dégradation silencieuse, avec davantage de latence et plus de négociations réseau.

Même « presque tout » peut poser problème

Ce n'est pas réservé aux applications qui ne lisent jamais le corps. Ça arrive aussi quand un lecteur s'arrête juste avant la fin.

Par exemple, json.Decoder peut décoder un objet JSON et rendre la main alors qu'il reste encore des octets dans le flux (un saut de ligne, des données supplémentaires, etc.). Sur le plan métier, tout va bien : l'application a ce dont elle a besoin. Sur le plan transport, la réponse n'est pas terminée, donc la connexion peut ne pas être réutilisable.

On retrouve le même type de situation quand une application ne lit que les premiers octets d'une erreur, ou quand elle s'arrête après avoir trouvé une information précise dans un flux. Le code peut rester correct, mais la question pratique demeure : faut-il lire le reste, et ça « coûte » quoi ?

Le coût dépend de la taille des réponses et du comportement du serveur. Télécharger quelques centaines d'octets peut être moins cher que d'ouvrir une nouvelle connexion. En revanche, vider plusieurs mégaoctets inutiles (ou attendre un serveur lent qui envoie au compte-gouttes) peut faire basculer la décision.

HTTP/2 n'a pas la même contrainte

En HTTP/2, plusieurs requêtes peuvent partager une même connexion TCP via multiplexage. Les données d'une réponse sont découpées en trames rattachées à un flux donné, ce qui permet aux autres flux de continuer sans attendre que le corps du premier flux soit entièrement consommé.

Un corps non lu peut quand même avoir des effets sur les ressources du flux concerné, mais il ne bloque pas la réutilisation de la connexion de la même façon que sur HTTP/1. Donc, on évite de copier-coller mécaniquement des techniques pensées pour « débloquer » du HTTP/1.

Pendant un test, resp.Proto aide à distinguer par exemple HTTP/1.1 de HTTP/2.0. Attention : ce que vous observez via un navigateur ou un outil externe n'est pas forcément identique à ce que fait votre programme Go, car la négociation dépend du client, du serveur, de TLS et de la configuration du transport.

Ce que change Go 1.27 et comment l'utiliser

À partir de Go 1.27, net/http.Transport tente automatiquement de vider en arrière-plan les corps HTTP/1 non lus lorsque l'application ferme resp.Body. L'effort est borné : au maximum 256 Ko, et pendant au plus 50 ms. Si le corps peut être consommé dans ces limites, la connexion a une meilleure chance d'être réutilisée.

L'idée n'est pas de lire « jusqu'au bout » quoi qu'il arrive. Go vise plutôt un compromis : récupérer une connexion réutilisable, sans passer trop de temps ni tirer sur de la bande passante pour des données qui ne servent pas.

Si le corps est trop volumineux, trop lent à arriver, ou si terminer dans les délais prévus devient impossible, la connexion peut ne pas être conservée pour la requête suivante. Et si la réponse contient Connection: close, le transport peut éviter de faire ce travail supplémentaire, puisqu'il n'y a de toute façon pas de réutilisation possible.

Le code applicatif peut rester simple

Dans beaucoup de cas, le réflexe à garder est simple : fermer le corps dès que l'application n'en a plus besoin.

resp, err := http.Get(url)

if err != nil {

return 0, err

}

defer resp.Body.Close()

return resp.StatusCode, nil

Close() reste nécessaire, même si votre code ne lit pas le corps. Ça indique au transport que vous avez fini avec cette réponse et l'aide à libérer ou gérer les ressources associées. Le changement de Go 1.27 ne vous demande pas de supprimer Close() : il réduit plutôt le besoin d'ajouter manuellement du code de drainage.

Autre point : http.Get utilise le transport par défaut. En production, on préfère généralement réutiliser un http.Client plutôt que d'en créer un par requête, pour laisser le transport gérer un pool de connexions. Pour les détails, voir la documentation de net/http.

Les « vidages » manuels sont plus délicats qu'ils n'en ont l'air

Avant Go 1.27, l'approche la plus courante consistait à lire le reste du corps avant de fermer :

io.Copy(io.Discard, resp.Body)

resp.Body.Close()

Ça marche bien si le corps est petit et arrive vite. Mais sans limite, votre programme peut télécharger un corps très volumineux juste pour l'ignorer, ou attendre longtemps un serveur lent. Et à ce moment-là, ouvrir une nouvelle connexion peut devenir moins coûteux que de tenter de préserver l'ancienne.

Une version prudente ajoute au minimum une limite (par exemple avec io.CopyN) et une stratégie de timeout adaptée. Mais dès qu'on rajoute des goroutines, des délais, des fermetures concurrentes et la gestion des erreurs, la logique devient plus difficile à tester et à auditer. Il faut aussi vérifier que la fermeture interrompt bien la lecture en cours et que vous n'introduisez pas de goroutine bloquée.

Bref : on a vite fait de transformer une optimisation locale en complexité opérationnelle. Si vous êtes encore sur une version antérieure de Go, évitez de copier-coller une routine trouvée dans un billet sans regarder la taille des réponses, le comportement des serveurs et le temps que vous acceptez de perdre. Dans certains cas, lire le corps « normalement » est le bon choix. Dans d'autres, abandonner la connexion est plus raisonnable.

Vérifier le gain dans votre application

Le benchmark de l'article Go compare un scénario précis : requêtes HTTP/1 séquentielles où l'application ne lit pas les corps. Chez vous, le résultat peut être différent.

Pour trancher, comparez des conditions aussi proches que possible, puis mesurez au bon niveau. Un déroulé pragmatique :

  1. Repérez les appels HTTP séquentiels où vous ne consommez qu'un statut (ou une petite partie du corps).
  2. Vérifiez si vous êtes en HTTP/1 ou HTTP/2, par exemple via resp.Proto.
  3. Mesurez le temps total, mais aussi le nombre de connexions créées et réutilisées.
  4. Comparez Go 1.26 et Go 1.27 avec le même serveur, le même réseau et des réponses comparables.
  5. Testez des réponses petites, volumineuses et lentes : l'intérêt du vidage borné dépend beaucoup de ces cas.

Évitez de désactiver HTTP/2 dans un test si votre production l'utilise, sauf si votre objectif est justement d'isoler le comportement HTTP/1. Dans le billet Go, HTTP/2 est désactivé pour rendre visible le mécanisme ciblé, pas pour proposer une règle générale.

Enfin, une connexion réutilisable ne remplace pas une gestion correcte des délais d'expiration ni des erreurs. Configurez votre client, surveillez la latence, et vérifiez vos hypothèses avec des mesures. Les références utiles : la doc officielle sur http.Client et le billet sur la réutilisation des connexions HTTP.

Conclusion : garder le code simple, mesurer le résultat

En HTTP/1, la réutilisation d'une connexion dépend d'un détail facile à rater : le client doit atteindre la fin de la réponse avant de pouvoir enchaîner sur le même flux. Fermer sans consommer ne suffisait pas toujours, et Go pouvait alors abandonner une connexion qui aurait pu être réutilisée.

Go 1.27 améliore ça : le transport tente, de façon asynchrone, de vider les réponses HTTP/1 non lues avec des limites de volume et de temps. Pour la plupart des applications, le bon réflexe reste donc simple : fermer resp.Body, laisser net/http gérer le transport, puis vérifier le résultat dans votre environnement.

Si vous maintenez un client Go, commencez par identifier les appels séquentiels et vérifiez le protocole réellement négocié. Ensuite, comparez les performances avant/après la mise à niveau avec des réponses représentatives. N'ajoutez pas une routine « maison » juste parce qu'elle paraît intelligente : si les mesures ne montrent pas qu'elle est utile, elle peut finir par être plus difficile à maintenir qu'un comportement plus simple.

Questions frequentes

Non. Il faut fermer chaque corps lorsqu’il n’est plus utile, mais le lire entièrement n’est pas toujours nécessaire. À partir de Go 1.27, le transport tente de vider certains corps HTTP/1 non lus dans des limites de taille et de temps. La réutilisation reste conditionnelle : elle dépend aussi du serveur et du protocole.
Non. Le transport tente de récupérer les connexions en vidant une quantité limitée de données pendant un temps limité. Une réponse trop volumineuse ou trop lente, un en-tête Connection: close ou d’autres conditions réseau peuvent empêcher la réutilisation. Le changement améliore les chances de réemploi, sans en faire une garantie.
Pas pour débloquer la connexion de la même façon qu’en HTTP/1. HTTP/2 multiplexe plusieurs flux sur une connexion, de sorte qu’un corps non lu ne bloque pas les autres requêtes sur cette connexion selon le même mécanisme. Il reste important de fermer le corps et de gérer les ressources, mais un vidage systématique n’est pas nécessaire.
Fermez toujours le corps. Si vous voulez favoriser la réutilisation HTTP/1, lisez le corps jusqu’au bout lorsque sa taille et son comportement le permettent. Pour une réponse potentiellement volumineuse ou lente, un vidage illimité peut coûter plus cher qu’une nouvelle connexion : mesurez et choisissez une stratégie bornée adaptée à votre contexte.

Articles similaires