Betere hergebruik van HTTP/1-verbindingen
Neem een kijkje naar de volgende functie:
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
}
Deze functie verzendt een GET-verzoek naar de url en retourneert de HTTP-statuscode.
Bij gebruik van HTTP/1 (GODEBUG=http2client=0) neemt het 100 keer aanroepen van deze functie tegen index.golang.org ongeveer 710 milliseconden in beslag op mijn machine met Go 1.26. In Go 1.27 neemt hetzelfde programma ongeveer 150 milliseconden in beslag.
Dat is bijna 5 keer sneller! Hoe is een zo'n drastisch prestatieverschil mogelijk?
De kosten van het opzetten van HTTP-verbindingen
Om het verschil te begrijpen, moeten we kijken naar wat er op het netwerk gebeurt wanneer we een HTTP-verzoek doen.
Het net/http-pakket van Go ondersteunt HTTP/1.0, HTTP/1.1 en HTTP/2. Alle drie werken ze over TCP, en meestal over TLS wanneer er met HTTPS-endpoints wordt gecommuniceerd. Een verzoek over een nieuwe verbinding ziet er ongeveer als volgt uit:
- TCP handshake (1 RTT): de client verzendt een SYN-pakket, de server antwoordt met SYN-ACK, en de client antwoordt met ACK. Omdat de client het eerste TLS-bericht samen met die laatste ACK kan verzenden, kost het opzetten van de TCP-verbinding 1 round-trip time (RTT).
- TLS handshake (2 RTT): vervolgens wisselt een TLS-handshake encryptiesleutels uit en verifieert de identiteit van de server; met TLS 1.2 kost dit 2 RTT.
- HTTP-verzoek: zodra de sleutels zijn uitgewisseld, kunnen HTTP-verzoeken eindelijk worden verzonden na een totaal van 3 RTT.
Deze round-trips (plus een deel CPU-tijd besteed aan TLS-handshakes) tellen snel op. Bij communicatie tussen verschillende regio's bereiken of overschrijden éénrichtingslatenties vaak de 50 ms (100 ms RTT). Vergeleken met de 300 ms die alleen al wordt besteed aan het opzetten van de verbinding, kan het HTTP-verzoek zelf goedkoop zijn en slechts iets meer dan 1 RTT kosten wanneer de bandbreedte ruim is of de HTTP-respons klein.
Zelfs onder gunstigere omstandigheden kan het openen van een nieuwe verbinding nog steeds kostbaar zijn. Onze openingsbenchmark raadpleegt index.golang.org, dat wereldwijd is verspreid en TLS 1.3 ondersteunt. Dit betekent dat de latentie relatief laag is en de TLS-handshake zelfs in slechts 1 RTT kan worden voltooid. Toch is het prestatieverschil nog steeds zeer merkbaar.
Hergebruik van verbindingen
Om deze opzetkosten bij elk verzoek te vermijden, proberen HTTP-clients en -servers bestaande verbindingen zoveel mogelijk te hergebruiken voor meerdere verzoeken. De mechanica hiervan verschillen per HTTP-versie:
- HTTP/1.0: hoewel het geen onderdeel is van de officiële specificatie, kiezen clients en servers vaak voor hergebruik van verbindingen door een
Connection: keep-aliveheader te verzenden. Hergebruik gaat door totdat een van de eindpunten de header weglaat of eenConnection: closeheader verzendt. - HTTP/1.1: verbindingen zijn standaard persistent en kunnen worden hergebruikt voor volgende verzoeken en responsen, tenzij een van beide partijen anders aangeeft door een
Connection: closeheader te verzenden. - HTTP/2: meerdere verzoeken en responsen kunnen worden gemultiplext over een enkele TCP-verbinding.
Wanneer hergebruik van verbindingen faalt
In HTTP/1 (zowel HTTP/1.0 als HTTP/1.1) worden meerdere verzoeken en responsen op dezelfde verbinding sequentieel over de lijn verzonden. Als er twee GET-verzoeken naar index.golang.org over dezelfde TCP-verbinding worden verzonden, komen de responsen aan als één enkele stroom bytes.
net/http.Get leest en analyseert de statusregel en headers uit de bytestroom om net/http.Response te vullen, en keert vervolgens onmiddellijk terug. De response body blijft ongelezen op de verbinding en wordt pas geconsumeerd wanneer de aanroeper leest uit net/http.Response.Body.
Omdat responsen vlak achter elkaar op dezelfde stroom worden verzonden, kan net/http het begin van de volgende respons pas vinden nadat de huidige response body tot het allerlaatste byte is gelezen (zoals bepaald door de Content-Length of Transfer-Encoding header).
Kijk nu terug naar ons voorbeeldprogramma, waarin we resp.Body nooit lezen:
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
}
Tot en met Go 1.26 gebeurt het volgende: wanneer resp.Body.Close wordt aangeroepen, ziet net/http dat er nog een ongelezen response body is. Omdat het systeem beseft dat het de verbinding niet kan hergebruiken, wordt de bestaande verbinding opgegeven en worden er voor volgende verzoeken nieuwe verbindingen aangemaakt. Hierdoor worden herhaaldelijk de kosten voor het opzetten van nieuwe verbindingen gemaakt.
(HTTP/2 heeft dit probleem niet. Omdat het streams van frames over een enkele verbinding multiplext in plaats van responsen strikt achter elkaar te verzenden, blokkeert een ongelezen response body op één stream andere verzoeken niet om de verbinding te delen.)
Waar mislukt hergebruik van verbindingen impact heeft
Deze subtiliteit van HTTP/1-verbindinghergebruik kan zorgen voor stille prestatievermindering in ogenschijnlijk onschadelijke code.
Zoals in ons openingsvoorbeeld kan een client alleen geïnteresseerd zijn in de statuscode van een API-respons. De API-endpoint waar hij mee communiceert kan echter bodies verzenden die gedetailleerdere maar uiteindelijk ongebruikte informatie bevatten, zoals {"success": true, "message": "update processed"}. Dit zal helaas ervoor zorgen dat het hergebruik van de verbinding mislukt.
Zelfs programma's die de response body wel lezen, kunnen tegen dit probleem aanlopen als ze net vóór het einde van de body stoppen. Bijvoorbeeld: encoding/json.Decoder stopt met lezen zodra het de sluitende } van een JSON-object ziet. Als een server een JSON-respons verzendt die eindigt met een nieuwe regel, zal het decoderen van het JSON-object die nieuwe regel ongelezen laten. De verbinding kan niet worden hergebruikt voor een nieuw verzoek totdat die nieuwe regel is geconsumeerd. Zo kan een gezonde verbinding worden opgegeven, terwijl deze nog maar één byte verwijderd was van hergebruik.
De handmatige oplossing
Het probleem van de ongelezen body is al lange tijd een lastige valkuil. In de loop der jaren hebben veel Go-ontwikkelaars hun eigen oplossingen bedacht. Meestal gebeurt dit door de response body leeg te maken (draining) voordat deze wordt gesloten:
func getStatus(url string) (int, error) {
resp, err := http.Get(url)
if err != nil {
return 0, err
}
drainAndCloseBody(resp.Body)
return resp.StatusCode, nil
}
func drainAndCloseBody(body io.ReadCloser) {
io.Copy(io.Discard, body) // Maak de response body leeg voordat deze wordt gesloten.
body.Close()
}
Hoewel dit werkt voor kleine responsen, kan bij een grote ongelezen body het opzetten van een nieuwe TCP-verbinding sneller zijn dan het downloaden en weggooien van de rest van de body. Een betere aanpak is om het aantal bytes dat we bereid zijn leeg te maken te beperken:
func drainAndCloseBody(body io.ReadCloser) {
io.CopyN(io.Discard, body, 256<<10) // Maak slechts tot maximaal 256 KB leeg.
body.Close()
}
Echter, een server kan een response body verzenden die langzaam binnenstroomt, dus we moeten ook de tijd die we besteden aan het leegmaken beperken:
func drainAndCloseBody(body io.ReadCloser) {
drainDone := make(chan struct{}, 1)
go func() {
io.CopyN(io.Discard, body, 256<<10) // Maak slechts tot maximaal 256 KB leeg.
drainDone <- struct{}{}
}()
// Besteed maximaal 50 ms aan het proberen leeg te maken van de response body.
select {
case <-drainDone:
case <-time.After(50 * time.Millisecond):
// Als we de timeout van 50 ms bereiken, zal body.Close worden uitgevoerd
// en io.CopyN onderbreken, wat goroutine-leaks voorkomt.
}
body.Close()
}
Natuurlijk willen we niet tot 50 milliseconden blokkeren bij het sluiten van een response body. In plaats daarvan kunnen we de body asynchroon op de achtergrond leegmaken en deze laten racen tegen volgende verzoeken:
func drainAndCloseBody(body io.ReadCloser) {
drainDone := make(chan struct{}, 1)
go func() {
io.CopyN(io.Discard, body, 256<<10) // Maak slechts tot maximaal 256 KB leeg.
drainDone <- struct{}{}
}()
go func() {
// Besteed maximaal 50 ms aan het proberen leeg te maken van de response body.
select {
case <-drainDone:
// Als het leegmaken kan worden voltooid voordat een nieuw verzoek
// wordt verzonden, wordt de verbinding hergebruikt; anders wordt
// een nieuwe verbinding gemaakt en wordt de huidige verbinding
// in een pool van idle verbindingen geplaatst, waar deze
// potentieel voor een toekomstig verzoek kan worden hergebruikt.
case <-time.After(50 * time.Millisecond):
// Als we de timeout van 50 ms bereiken, zal body.Close worden uitgevoerd
// en io.CopyN onderbreken, wat goroutine-leaks voorkomt.
}
body.Close()
}()
}
Zoals we kunnen zien, is het veilig leegmaken van response bodies verrassend lastig en omslachtig. Erger nog is dat, naarmate de praktijk van het leegmaken van response bodies zich verspreidde, sommige nuances vaak verloren gingen. Dit heeft geleid tot misverstanden, zoals het proberen leeg te maken van response bodies voor HTTP/2-verbindingen, terwijl dit onnodig is.
De huidige stand van zaken en conclusie
Vanaf Go 1.27 doet net/http.Transport automatisch alles wat we in de bovenstaande code hebben geïmplementeerd: het maakt asynchroon een ongelezen HTTP/1 response body leeg voor maximaal 50 ms of 256 KB.
Er zijn ook extra optimalisaties doorgevoerd; wanneer een respons bijvoorbeeld de Connection: close header bevat, is het leegmaken van de body gegarandeerd verspilde moeite, en wordt dit daarom overgeslagen. Let op dat dit gedrag in toekomstige versies van Go kan evolueren naarmate andere mogelijke verbeteringen duidelijk worden.
Als algemene best practice raden we gebruikers nu aan om simpelweg hun HTTP-bodies te sluiten zodra ze deze niet meer nodig hebben, zoals in ons oorspronkelijke voorbeeld:
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
}
Go draait om eenvoud. Houd je code schoon en simpel, en laat net/http de complexiteit voor je afhandelen.
Groetjes,