Backend 5 min de lectura 854 palabras

Go 1.27 reutiliza las conexiones HTTP/1 aunque no leas el cuerpo de la respuesta

EN
Go 1.27 reutiliza las conexiones HTTP/1 aunque no leas el cuerpo de la respuesta

El blog de Go ha publicado Better HTTP/1 connection reuse, un artículo corto que explica un cambio de Go 1.27 que parece pequeño, pero que puede hacer que muchos programas vayan bastante más rápidos sin tocar una línea. Empieza con esta función:

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
}

Hace una petición GET y devuelve el código de estado. Nada más. Llamada 100 veces contra index.golang.org usando HTTP/1, tarda unos 710 ms con Go 1.26 y unos 150 ms con Go 1.27. Casi cinco veces más rápido, con el mismo código.

Lo que cuesta abrir una conexión

Para entender la diferencia hay que recordar lo que pasa por la red en cada petición nueva:

PasoViajes de ida y vuelta (RTT)
Handshake TCP1
Handshake TLS 1.22
Petición HTTP1 o poco más

Entre regiones distintas, un viaje de ida y vuelta puede pasar fácilmente de 100 ms. Así que antes de enviar la petición ya se han ido unos 300 ms sólo en preparar la conexión, cuando la respuesta en sí puede costar poco más de un viaje. Por eso los clientes HTTP intentan reutilizar las conexiones entre peticiones: en HTTP/1.0 con Connection: keep-alive, en HTTP/1.1 por defecto, y en HTTP/2 multiplexando varias peticiones sobre la misma conexión.

Por qué fallaba la reutilización

En HTTP/1, las respuestas llegan una detrás de otra por la misma conexión, como un único chorro de bytes. http.Get lee la línea de estado y las cabeceras, rellena el http.Response y vuelve. El cuerpo se queda en la conexión, sin leer, hasta que alguien lo lea desde resp.Body.

Y aquí está el problema: el cliente sólo sabe dónde empieza la siguiente respuesta cuando ha leído la actual hasta el final. En la función de arriba nunca se lee el cuerpo. Hasta Go 1.26, al llamar a resp.Body.Close(), net/http veía que quedaban datos sin leer, daba la conexión por perdida y abría una nueva para la siguiente petición. Con todo el coste de TCP y TLS cada vez.

Es un fallo silencioso, sin errores ni avisos, que aparece en código que parece perfectamente correcto:

  • Sólo te interesa el código de estado, pero la API devuelve algo como {"success": true, "message": "update processed"} que nunca lees.
  • Lees el JSON, pero no hasta el último byte. json.Decoder deja de leer en cuanto encuentra la } de cierre. Si el servidor añade un salto de línea al final, ese byte se queda sin leer y la conexión se tira, aunque estaba a un byte de poder reutilizarse.

Con HTTP/2 no pasa, porque cada respuesta va por su propio flujo dentro de la conexión y un cuerpo sin leer no bloquea a los demás.

El apaño de siempre

Este problema lleva años ahí, y en Go se popularizó un apaño: vaciar el cuerpo antes de cerrarlo.

func drainAndCloseBody(body io.ReadCloser) {
	io.Copy(io.Discard, body) // Vaciar el cuerpo antes de cerrarlo.
	body.Close()
}

Lo interesante del artículo es que muestra que hacerlo bien es más difícil de lo que parece:

  • Si el cuerpo es grande, descargarlo para tirarlo puede costar más que abrir una conexión nueva. Hay que limitar cuánto se lee, por ejemplo 256 KB con io.CopyN.
  • Si el servidor envía el cuerpo despacio, también hay que limitar el tiempo, por ejemplo a 50 ms.
  • Y no se quiere bloquear el Close() esos 50 ms, así que hay que vaciarlo en segundo plano, con un par de goroutines y un select.

La versión final del artículo tiene dos goroutines, un canal y un temporizador para algo que parecía una línea. Y además, a fuerza de copiar el apaño de un proyecto a otro, se perdieron los matices: hay quien vacía cuerpos también en conexiones HTTP/2, donde no hace ninguna falta.

Lo que hace Go 1.27

Desde Go 1.27, net/http.Transport hace todo eso por su cuenta: vacía en segundo plano el cuerpo sin leer de una respuesta HTTP/1, hasta 50 ms o 256 KB. Y si la respuesta trae Connection: close, ni lo intenta, porque la conexión no se va a reutilizar de todas formas.

La recomendación del equipo de Go ahora es sencilla: cierra el cuerpo cuando ya no lo necesites, como en la función del principio, y deja que net/http se encargue del resto. Avisan, eso sí, de que el comportamiento puede ajustarse en versiones futuras.

Lo que me llevo

Me gusta mucho este tipo de cambios: la biblioteca estándar absorbe un problema que todo el mundo resolvía a mano, y a veces mal, y el código correcto pasa a ser también el más sencillo. Si tienes un drainAndCloseBody o algo parecido en tus proyectos, con Go 1.27 probablemente puedas borrarlo. Y si nunca lo tuviste, puede que tus clientes HTTP vayan más rápidos sólo con actualizar.

Encaja con la idea de go fix, del que hablé hace unas semanas: Go intenta que el código idiomático sea también el más eficiente, en lugar de obligar a cada uno a saberse los trucos.

Fuente: Better HTTP/1 connection reuse, Nicholas Husin y Damien Neil, The Go Blog.