Go 1.27 における HTTP/1 コネクション再利用の改善
原題: Better HTTP/1 connection reuse in Go 1.27
日本語訳
# タイトル
Go 1.27におけるHTTP/1接続再利用の改善
# 本文
HTTP/1接続再利用の改善
次の関数を見てみましょう。
```go
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
}
```
これは`url`にGETリクエストを送信し、HTTPステータスコードを返します。
HTTP/1を使用し(`GODEBUG=http2client=0`)、この関数を`index.golang.org`に対して100回呼び出した場合、私のマシン上のGo 1.26では約710ミリ秒かかりました。一方、Go 1.27では、同じプログラムが約150ミリ秒で終了しました。
これは5倍近く高速です!これほど劇的なパフォーマンスの差がなぜ生まれるのでしょうか?
### HTTP接続確立のコスト
この違いを理解するために、HTTPリクエストを行う際にネットワーク上で何が起きているかを考えてみましょう。
Goの`net/http`パッケージは、HTTP/1.0、HTTP/1.1、およびHTTP/2をサポートしています。これら3つはすべてTCP上で動作し、HTTPSエンドポイントと通信する場合は通常TLS上で動作します。新しい接続を使用してリクエストを行う際の流れは、以下のようになります。
- **TCPハンドシェイク (1 RTT)**: クライアントが`SYN`パケットを送信し、サーバーが`SYN-ACK`で応答し、クライアントが`ACK`で応答します。クライアントは最初のTLSメッセージをこの最後の`ACK`と同時に送信できるため、TCP接続の確立には1ラウンドトリップタイム(RTT)を要します。
- **TLSハンドシェイク (2 RTT)**: 次に、TLSハンドシェイクによって暗号化キーの交換とサーバーの身元確認が行われます。TLS 1.2を使用する場合、これには2 RTTかかります。
- **HTTPリクエスト**: キーの交換が完了すると、合計3 RTTの後にようやくHTTPリクエストを送信できます。
これらのラウンドトリップ(およびTLSハンドシェイクに費やされるCPU時間)は、すぐに積み重なっていきます。リージョン間通信では、片道のレイテンシが上記の図に示されている50ms(RTTで100ms)に達するか、それを超えることがよくあります。接続のセットアップだけで300ms費やしていることを考えると、帯域幅が十分にある場合やHTTPレスポンスが小さい場合、HTTPリクエスト自体の処理は、わずか1 RTT強で済むため安価なものとなります。
より好条件な環境であっても、新しい接続を開くことは依然としてコストがかかる場合があります。今回のベンチマークでは、世界中に分散されておりTLS 1.3をサポートしている`index.golang.org`に対してクエリを行っています。これは、レイテンシが比較的低く、TLSハンドシェイクが実際にはわずか1 RTTで完了することを意味します。それでもなお、パフォーマンスの差は非常に顕著です。
### 接続の再利用
リクエストのたびにこのセットアップコストを支払うのを避けるため、HTTPクライアントとサーバーは、可能な限り既存の接続を複数のリクエストにわたって再利用しようとします。この仕組みはHTTPのバージョンによって異なります。
- **HTTP/1.0**: 公式な仕様の一部ではありませんが、クライアントとサーバーは`Connection: keep-alive`ヘッダーを送信することで接続の再利用を選択することがよくあります。再利用は、いずれかのエンドポイントがヘッダーを省略するか、`Connection: close`ヘッダーを送信するまで続きます。
- **HTTP/1.1**: 接続はデフォルトで持続的(persistent)であり、どちらかの側が`Connection: close`ヘッダーを送信して明示的に宣言しない限り、後続のリクエストとレスポンスに対して再利用できます。
- **HTTP/2**: 単一のTCP接続上で、複数のリクエストとレスポンスをマルチプレクス(多重化)できます。
### 接続の再利用が失敗する場合
HTTP/1(HTTP/1.0とHTTP/1.1の両方)では、同じ接続上の複数のリクエストとレスポンスは、回線上で逐次的に送信されます。もし2つのGETリクエストが同じTCP接続を介して`index.golang.org`に送信された場合、レスポンスは単一のバイトストリームとして届きます。
図に示すように、`net/http.Get`はバイトストリームからステータスラインとヘッダーを読み取って解析し、`net/http.Response`を構築してすぐに制御を戻します。レスポンスボディは接続上に未読のまま残り、呼び出し側が`net/http.Response.Body`から読み取ったときにのみ消費されます。
レスポンスは同じストリーム上で立て続けに送信されるため、`net/http`は現在のレスポンスボディが最後まで読み取られた(`Content-Length`または`Transfer-Encoding`ヘッダーによって決定される)後でなければ、次のレスポンスの開始点を見つけることができません。
ここで、`resp.Body`を一度も読み取っていない先ほどの例プログラムを振り返ってみましょう。
```go
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 1.26までは、`resp.Body.Close`が呼び出されたとき、`net/http`はまだ未読のレスポンスボディがあることを検知します。接続を再利用できないと判断すると、既存の接続を破棄して次のリクエストのために新しい接続を作成するため、新しい接続を確立するコストが繰り返し発生することになります。
(HTTP/2にはこの問題はありません。HTTP/2はレスポンスを厳密に立て続けに送るのではなく、単一の接続上でフレームのストリームをマルチプレクスするため、あるストリームの未読のレスポンスボディが他のリクエストによる接続の共有を妨げることはありません。)
### 接続の再利用失敗による弊害
HTTP/1の接続再利用におけるこの微妙な挙動は、一見問題なさそうなコードにおいて、サイレントなパフォーマンス低下を引き起こす可能性があります。
最初の例のように、クライアントはAPIレスポンスのステータスコードだけに興味があるかもしれません。しかし、通信先のAPIエンドポイントが、`{"success": true, "message": "update processed"}`のように、詳細ではあるが最終的には使用されない情報を含むボディを送信してくることがあります。残念ながら、これは接続の再利用を失敗させる原因となります。
レスポンスボディを読み取っているプログラムであっても、ボディの末尾の手前で読み取りを止めてしまうと、この問題に直面することがあります。例えば、`encoding/json.Decoder`はJSONオブジェクトの閉じ括弧`}`を見つけるとすぐに読み取りを停止します。もしサーバーが改行で終わるJSONレスポンスを送信した場合、JSONオブジェクトをデコードしただけでは改行が未読のまま残ります。その改行が消費されるまで、接続を新しいリクエストに再利用することはできません。その結果、再利用まであとわずか1バイトという状態であっても、健全な接続が破棄されてしまう可能性があるのです。
### 手動による解決策
未読ボディの問題は、長らく扱いにくい問題(sharp edge)でした。長年にわたり、多くのGo開発者が独自の回避策を考えてきました。最も一般的な方法は、閉じる前にレスポンスボディをドレイン(使い切る)することです。
```go
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) // 閉じる前にレスポンスボディをドレインする。
body.Close()
}
```
これは小さなレスポンスには有効ですが、未読のボディが大きい場合、残りのボディをダウンロードして破棄するよりも、新しいTCP接続を確立する方が速い場合があります。より良いアプローチは、ドレインするバイト数に上限を設けることです。
```go
func drainAndCloseBody(body io.ReadCloser) {
io.CopyN(io.Discard, body, 256<<10) // 最大256KBまでドレインする。
body.Close()
}
```
しかし、サーバーがゆっくりとレスポンスボディを送信してくる可能性もあるため、ドレインに費やす時間にも上限を設ける必要があります。
```go
func drainAndCloseBody(body io.ReadCloser) {
drainDone := make(chan struct{}, 1)
go func() {
io.CopyN(io.Discard, body, 256<<10) // 最大256KBまでドレインする。
drainDone <- struct{}{}
}()
// レスポンスボディのドレインに費やす時間は最大50ミリ秒とする。
select {
case <-drainDone:
case <-time.After(50 * time.Millisecond):
// 50ミリ秒のタイムアウトに達した場合、body.Closeが実行されて
// io.CopyNを中断し、ゴルーチンのリークを防ぐ。
}
body.Close()
}
```
もちろん、レスポンスボディを閉じる際に最大50ミリ秒もブロックしたくはありません。代わりに、バックグラウンドで非同期にボディをドレインし、後続のリクエストと競わせる(race)ことができます。
```go
func drainAndCloseBody(body io.ReadCloser) {
drainDone := make(chan struct{}, 1)
go func() {
io.CopyN(io.Discard, body, 256<<10) // 最大256KBまでドレインする。
drainDone <- struct{}{}
}()
go func() {
// レスポンスボディのドレインに費やす時間は最大50ミリ秒とする。
select {
case <-drainDone:
// 新しいリクエストが送信される前にドレインが完了すれば、
// 接続は再利用される。そうでなければ新しい接続が作成され、
// 現在の接続はアイドル接続プールに入れられ、将来別のリクエストに
// 再利用される可能性がある。
case <-time.After(50 * time.Millisecond):
// 50ミリ秒のタイムアウトに達した場合、body.Closeが実行されて
// io.CopyNを中断し、ゴルーチンのリークを防ぐ。
}
body.Close()
}()
}
```
見ての通り、レスポンス
原文(英語)を表示
Better HTTP/1 connection reuse
Take a look at the following function:
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
}
It sends a GET
request to url
and returns the HTTP status code.
Using HTTP/1 (GODEBUG=http2client=0
), calling this function 100 times against
index.golang.org takes ~710 milliseconds on Go 1.26
on my machine. On Go 1.27, the same program takes ~150 milliseconds.
That is almost 5 times faster! How is such a drastic performance difference possible?
The cost of establishing HTTP connections
To understand the difference, consider what happens on the network when we make an HTTP request.
Go’s net/http
package supports HTTP/1.0, HTTP/1.1, and
HTTP/2. All three run over TCP, and usually over TLS when talking to HTTPS
endpoints. Making a request over a new connection looks something like this:
- TCP handshake (1 RTT): the client sends a
SYN
packet, the server replies withSYN-ACK
, and the client replies withACK
. Because the client can send the first TLS message alongside that finalACK
, establishing the TCP connection takes 1 round-trip time (RTT). - TLS handshake (2 RTT): next, a TLS handshake exchanges encryption keys and verifies the server’s identity; using TLS 1.2, this takes 2 RTT.
- HTTP request: with the keys exchanged, HTTP requests can finally be sent after a total of 3 RTT.
These round trips (plus some CPU time spent on TLS handshakes) add up quickly. In cross-region communications, one-way latencies often reach or exceed the 50 ms (100 ms RTT) shown in the diagram above. Compared to the 300 ms spent just setting up the connection, the HTTP request itself can be cheap to serve, taking only a little over 1 RTT when bandwidth is plentiful or the HTTP response is small.
Even in more favorable conditions, opening a new connection can still be expensive. Our opening benchmark queries index.golang.org, which is distributed worldwide and supports TLS 1.3. This means that latency is relatively low, and the TLS handshake can actually finish in just 1 RTT. Even so, the performance difference is still very noticeable.
Connection reuse
To avoid paying this setup cost on every request, HTTP clients and servers try to reuse existing connections across multiple requests when possible. The mechanics of this differ based on the HTTP version:
- HTTP/1.0: while not part of the official specification, clients and
servers often opt into connection reuse by sending a
Connection: keep-alive
header. Reuse continues until either endpoint omits the header or sends aConnection: close
header. - HTTP/1.1: connections are persistent by default, and can be reused for
subsequent requests and responses unless either side declares otherwise by
sending a
Connection: close
header. - HTTP/2: multiple requests and responses can be multiplexed over a single TCP connection.
When connection reuse fails
In HTTP/1 (both HTTP/1.0 and HTTP/1.1), multiple requests and responses on the
same connection are sent sequentially on the wire. If two GET
requests are
sent to index.golang.org over the same TCP
connection, the responses arrive as a single stream of bytes:
As shown in the diagram, net/http.Get
reads and parses
the status line and headers from the byte stream to populate
net/http.Response
, then returns immediately. The
response body remains unread on the connection and is consumed only as the
caller reads from net/http.Response.Body
.
Because responses are sent back-to-back on the same stream,
net/http
can only find the start of the next response after
the current response body has been read to the very end (as determined by the
Content-Length
or Transfer-Encoding
header).
Now look back at our example program, where we never read resp.Body
:
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
}
Through Go 1.26, when resp.Body.Close
is called, net/http
sees that there is still an unread response body. Realizing that it cannot reuse
the connection, it abandons the existing connection and creates new connections
for its next requests, causing it to repeatedly incur the cost of establishing
new connections.
(HTTP/2 does not have this problem. Because it multiplexes streams of frames over a single connection rather than sending responses strictly back-to-back, an unread response body on one stream does not block other requests from sharing the connection.)
Where failed connection reuse hurts
This subtlety of HTTP/1 connection reuse can cause silent performance degradation in seemingly innocuous code.
As in our opening example, a client might only care about an API response’s
status code. However, the API endpoint it is talking to might send bodies that
provide more detailed but ultimately unused information, such as {"success": true, "message": "update processed"}
. This will unfortunately cause connection
reuse to fail.
Even programs that do read the response body can run into this issue if they
stop just short of the end of the body. For example,
encoding/json.Decoder
stops reading as soon as
it sees the closing }
of a JSON object. If a server sends a JSON response
terminated with a newline, decoding the JSON object will leave the newline still
unread. The connection cannot be reused for a new request until that newline is
consumed. Thus, a healthy connection might end up being abandoned, even though
it was only a single byte away from being reusable.
The manual solution
The unread body problem has been a long-standing sharp edge. Over the years, many Go developers came up with their own workarounds. Most commonly, this is done by draining the response body prior to closing it:
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) // Drain the response body before closing it.
body.Close()
}
While this works for small responses, if the unread body is large, establishing a new TCP connection can be faster than downloading and discarding the rest of the body. A better approach is to cap the number of bytes we are willing to drain:
func drainAndCloseBody(body io.ReadCloser) {
io.CopyN(io.Discard, body, 256<<10) // Only drain up to 256 KB.
body.Close()
}
However, a server might send a response body that trickles in slowly, so we also need to cap the time we spend draining:
func drainAndCloseBody(body io.ReadCloser) {
drainDone := make(chan struct{}, 1)
go func() {
io.CopyN(io.Discard, body, 256<<10) // Only drain up to 256 KB.
drainDone <- struct{}{}
}()
// Only spend at most 50 ms trying to drain the response body.
select {
case <-drainDone:
case <-time.After(50 * time.Millisecond):
// If we reach the 50 ms timeout, body.Close will run and interrupt the
// io.CopyN, preventing goroutine leaks.
}
body.Close()
}
Of course, we also do not want to block for up to 50 milliseconds when closing a response body. Instead, we can drain the body asynchronously in the background and let it race against subsequent requests:
func drainAndCloseBody(body io.ReadCloser) {
drainDone := make(chan struct{}, 1)
go func() {
io.CopyN(io.Discard, body, 256<<10) // Only drain up to 256 KB.
drainDone <- struct{}{}
}()
go func() {
// Only spend at most 50 ms trying to drain the response body.
select {
case <-drainDone:
// If the drain can finish before a new request is sent, the
// connection will be reused; otherwise, a new connection is
// created, and the current connection will be put in a pool of
// idle connections, potentially being reused for another future
// request.
case <-time.After(50 * time.Millisecond):
// If we reach the 50 ms timeout, body.Close will run and interrupt
// the io.CopyN, preventing goroutine leaks.
}
body.Close()
}()
}
As we can see, draining response bodies safely is surprisingly tricky and cumbersome. Worse, as the practice of draining response bodies spread, some nuances were often lost. This has led to misunderstandings such as attempting to drain response bodies for HTTP/2 connections, when doing so is unnecessary.
The current state and conclusion
Starting in Go 1.27, net/http.Transport
automatically does everything we have implemented in the code above: it will
asynchronously drain an unread HTTP/1 response body for up to 50 ms or 256 KB.
It also implements additional optimizations; for example, when a response
contains the Connection: close
header, draining the body is guaranteed to be
wasted work, and it therefore skips doing so. Note that this behavior might
evolve in future versions of Go, as other possible enhancements become apparent.
As a general best practice, we now recommend that users simply close their HTTP bodies once they no longer need them, like in our original example:
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 is all about simplicity. Let your code stay clean and simple, and let
net/http
deal with the complexities on your behalf.