Go 1.27 に上げたら、HTTP/1 で200リクエストを投げたときの新規TCP接続が201本から3本に減りました。変えたのはビルドに使うGoのバージョンだけです。resp.Body.Close() の裏側が変わっている。
Closeするだけで接続が再利用されるようになった
新規接続の本数を数える
httptest のサーバーに ConnState フックを挿すと、新しいTCP接続が張られた回数を数えられます。本文を読まずに Close() だけ呼ぶクライアントで200回叩きます。
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", strconv.Itoa(bodySize))
w.Write(payload)
}))
srv.Config.ConnState = func(c net.Conn, s http.ConnState) {
if s == http.StateNew {
atomic.AddInt64(&newConns, 1)
}
}
srv.Start()
defer srv.Close()
client := srv.Client()
start := time.Now()
for i := 0; i < reqs; i++ {
resp, err := client.Get(srv.URL)
if err != nil {
return
}
resp.Body.Close() // 本文は読まない
}
fmt.Printf("newConns=%d elapsed=%v\n", atomic.LoadInt64(&newConns), time.Since(start))
実ポートを開く NewUnstartedServer と Start() を使っています。同じ Go 1.27 で入った httptest.NewTestServer は in-memory network で動くので、数えるべきTCP接続が存在しません。
201本が3本になる
本文4KB、200リクエスト。同じソースを Go 1.26.4 と Go 1.27.0 で走らせた結果です。
$ go version
go version go1.26.4 darwin/arm64
$ go run main.go
HTTP/1 4KB Closeのみ reqs=200 newConns=201 elapsed=22.92ms 0.115ms/req
$ GOTOOLCHAIN=go1.27.0 go run main.go
HTTP/1 4KB Closeのみ reqs=200 newConns= 3 elapsed=11.64ms 0.058ms/req
1リクエストあたりの時間は 0.115ms から 0.058ms に落ちました。ループバック接続なので3ウェイハンドシェイクはほぼタダで、それでも2倍の差。TLSが乗るリモート相手なら、差はこれより開きます。
200リクエストに対して接続は3本。1本に収まらないのは、自動ドレインが非同期に走るからです。
Go 1.26のTransportはなぜ接続を捨てていたか
「読み切って、閉じる」の2条件
Go 1.26 の net/http にある Response.Body のフィールドコメントは、こう書いてありました。
The default HTTP client’s Transport may not reuse HTTP/1.x “keep-alive” TCP connections if the Body is not read to completion and closed.
読み切る(read to completion)。そして閉じる。この2つが揃わないと Transport は接続を再利用しません。本文が残ったままのTCP接続に次のリクエストを書けば、レスポンスの境界がずれる。Transport はその接続を捨てるしかない、という理屈です。
だからio.Copyとio.Discardを書いていた
ステータスコードしか見ないリクエストでも、Go 1.26 までは読み捨てが要りました。
resp, err := client.Do(req)
if err != nil {
return err
}
defer func() {
io.Copy(io.Discard, resp.Body) // これが無いと接続が捨てられる
resp.Body.Close()
}()
Googleのサジェストに「go defer resp body close」が並ぶくらいには定番の型。io.ReadAll ではなく io.Copy(io.Discard, ...) にするのは、捨てるだけの中身をヒープに積まないためです。
自動ドレインの上限は256KiBと50ms
Go 1.27 リリースノートは “Minor changes to the library” の net/http の項でこの変更に触れています。
HTTP/1 Response.Body now automatically drains any unread content upon being closed, up to a conservative limit, to allow better connection reuse.
“a conservative limit” が何バイトなのかは書かれていません。実装を見にいきます。
transport.goの2つの定数
// maxPostCloseReadBytes is the max number of bytes that a client is willing to
// read when draining the response body of any unread bytes after it has been
// closed. This number is chosen for consistency with maxPostHandlerReadBytes.
const maxPostCloseReadBytes = 256 << 10
// maxPostCloseReadTime defines the maximum amount of time that a client is
// willing to spend on draining a response body of any unread bytes after it
// has been closed.
const maxPostCloseReadTime = 50 * time.Millisecond
src/net/http/transport.go に追加された定数です。262144バイト(256KiB)と50ミリ秒。どちらかを超えた時点でGoランタイムはドレインを諦め、接続を閉じます。
262144バイトは通り、262145バイトは通らない
境界を1バイトずつ跨いで確かめます。
$ GOTOOLCHAIN=go1.27.0 go run main.go
Content-Length=262144 (256KiB ちょうど) / Closeのみ reqs=10 newConns= 1
Content-Length=262145 (256KiB+1) / Closeのみ reqs=10 newConns=10
1バイト増えただけで再利用が消えました。判定しているのは readLoop のこの1行です。
tryDrain := !bodyEOF && resp.ContentLength <= maxPostCloseReadBytes
Content-Length ヘッダを先に見て、超えていればドレインを試みることすらしません。読みかけて途中で諦めるのではなく、最初から手を出さない。大きなファイルを配信するエンドポイントを叩くコードは、Go 1.27 でも従来どおり接続を1本ずつ捨て続けます。
chunkedはContent-Lengthが-1なので必ず読みにいく
チャンク転送のレスポンスでは resp.ContentLength が -1 になります。-1 <= 262144 は真。つまり実サイズが何MBあろうと tryDrain は立ち、262145バイト目まで読んでから打ち切ります。
D: chunked(CL=-1) 1KB, Closeのみ reqs=10 newConns= 1
E: chunked 300KB, Closeのみ reqs=10 newConns=10
結果は同じ「再利用されない」でも、300KBのほうは262145バイトを実際にソケットから読んでから捨てています。Content-Length を返さないストリーミングAPIでは、接続を閉じる前にこの空読みが乗ります。
io.Copyを消してよい条件
実測したケースを並べます。いずれも Close() のみ、リクエスト間隔20ms、10リクエストです。
| レスポンス | Go 1.26.4 | Go 1.27.0 | 明示ドレイン |
|---|---|---|---|
| Content-Length 1KB | 10本 | 1本 | 不要 |
| Content-Length 262144 | 10本 | 1本 | 不要 |
| Content-Length 262145 | 10本 | 10本 | 要る |
| chunked 1KB | 10本 | 1本 | 不要 |
| chunked 300KB | 10本 | 10本 | 要る |
| 本文の途中で80ms停止 | 10本 | 10本 | 要る |
256KiBを超える本文を読まずに捨てる箇所と、応答の遅い相手を叩く箇所。この2つには io.Copy(io.Discard, resp.Body) を残します。ヘルスチェックやステータスコードだけ見るAPI呼び出しからは消して構いません。
ドレインは非同期で走る。Closeは待たない
Go 1.27 で更新された Response.Body のコメントに、一番大事な語が入っています。
however, manually reading the body to completion should not be needed in most cases, as closing the body will also cause the body to be read to completion asynchronously, up to a conservative limit.
asynchronously。Close() は即座に返り、実際の読み捨ては Transport の readLoop が別のgoroutineで進めます。接続がアイドルプールに戻るのは残りの本文がサーバーから届き切ったあとです。
間隔を空けないと再利用は起きない
本文の後半を10ms遅らせて返すハンドラを用意し、次のリクエストまでの間隔だけを変えて計測しました。
$ GOTOOLCHAIN=go1.27.0 go run main.go
本文が10ms遅れて届く / 次のリクエストまで0ms reqs=10 newConns=10
本文が10ms遅れて届く / 次のリクエストまで30ms reqs=10 newConns= 1
遅延なし / 次のリクエストまで0ms reqs=10 newConns= 2
間隔0では再利用がまったく起きません。30ms空けると1本で足ります。ドレインが終わる前に次の Get が走り、プールが空なので新しい接続を張る。
同じ遅延サーバーに対して明示ドレインを挟むと、結果が変わります。
本文10ms遅延 / Closeのみ / 間隔0 reqs=10 newConns=10
本文10ms遅延 / io.Copy+Close / 間隔0 reqs=10 newConns= 1
io.Copy(io.Discard, resp.Body) は同期。呼び出し側が読み切るまで次の行に進まないので、Get の時点では確実に接続がプールにあります。レイテンシの高い相手をループで詰めて叩く処理なら、Go 1.27 でも明示ドレインのほうが速い。
50msを超えたgoroutineはどうなるか
func maybeDrainBody(body io.Reader) bool {
drainedCh := make(chan bool, 1)
go func() {
if _, err := io.CopyN(io.Discard, body, maxPostCloseReadBytes+1); err == io.EOF {
drainedCh <- true
} else {
drainedCh <- false
}
}()
select {
case drained := <-drainedCh:
return drained
case <-time.After(maxPostCloseReadTime):
return false
}
}
タイムアウトしても、読み込み中のgoroutineはその場に残ります。io.CopyN が返るのは接続が閉じられたときなので、そこまでブロックし続ける。ただし drainedCh はバッファ1のチャネルなので、送信で詰まってリークすることはありません。
冒頭のベンチで3本になったのもこれが理由です。ループバックの httptest 相手でも、readLoop がドレインを終える前に次の Get が走る瞬間がある。
go.modのgoディレクティブは関係ない
検証に使ったモジュールの go.mod は go 1.26 のままです。GOTOOLCHAIN=go1.27.0 を付けただけで挙動が変わりました。Go 1.27 にはモジュールのgoディレクティブで切り替わる変更もある。トレースバックへのgoroutineラベル付与がそれで、Go 1.27 以降を宣言したモジュールだけが対象です。自動ドレインのほうは、ビルドに使うツールチェーンのバージョンだけで決まります。
GODEBUGによるオプトアウトも用意されていません。リリースノートが挙げる回避策は Transport.DisableKeepAlives を true にすることだけで、これは接続再利用そのものを切る設定です。
In these cases, setting Transport.DisableKeepAlives to true will disable connection reuse. However, such performance degradation usually indicates improper configuration or usage of Transport or Client in the first place, and a deeper look would likely be beneficial.
まとめ
- Go 1.27.0(2026-08-19リリース)から、HTTP/1の
Response.BodyはClose()時に未読分を自動で読み捨てる。実測で200リクエストあたりの新規接続が201本から3本になった - 上限は
maxPostCloseReadBytes= 262144バイトとmaxPostCloseReadTime= 50ms。Content-Lengthが262145以上なら試行すらしない - ドレインは非同期。応答の遅い相手を間隔ゼロで叩くと再利用されないので、その経路には
io.Copy(io.Discard, resp.Body)を残す - HTTP/2は元から1接続を多重化するため、この変更の影響を受けない
「Closeだけで十分になった」と読むと、256KiB超えと低速サーバーで踏みます。io.Copy を消す判断の材料は、その経路のレスポンスサイズと応答速度の2つです。