HTTP/1のResponse.Bodyがクローズ時に未読分を自動でドレインし、コネクション再利用性が向上する
net/http
概要
HTTP/1のResponse.Bodyは、Closeが呼ばれた際に未読の内容を最大256KB・50ミリ秒を上限として自動的に読み捨てる(drain)ようになった。これまでTCPコネクションを再利用するにはボディを最後まで読み切ってからCloseする必要があったが、この変更により多くのプログラムでは特別な対応をしなくてもコネクション再利用の恩恵を受けられる。ほとんどのプログラムではno-opか、むしろパフォーマンス向上になる想定である。
導入経緯
net/httpのResponse.BodyをCloseした際の挙動については、#60240・#20528・#75309など長年にわたり混乱や関連issueが積み重なっていた。HTTP/1では、レスポンスボディを最後まで読み切らずにクローズするとTCPコネクションが再利用できない(例えば、ステータスコードだけを見てボディを読まないクライアントや、json.NewDecoder(resp.Body)が末尾の改行を読み残すケースなど)。これを避けるためユーザーが手動でボディをドレインする回避策が広まっていたが、この手法は「大きすぎるボディは読み捨てるより新規接続を張った方が速い」「サーバーが応答を細切れに送り続けるとドレインがハングしうる」といった落とし穴があり、正しく実装するのが難しかった。
さらに、HTTP/2では同一コネクション上で新規ストリームを開けるためボディの読了状態に関係なくコネクションを再利用でき、net/http.Server側もハンドラ復帰後に未読リクエストボディを最大256KBまで読み進める仕組みを既に持っていた。この非対称性が「ボディをドレインすればコネクションを再利用できる」というアドバイスの適用範囲を分かりにくくしていた。
このissueはAPI変更を伴わない挙動変更のproposalではなく、go.dev/cl/737720で実施予定の挙動変更を周知するために作成されたものである。
議論のハイライト
- Issueは新規APIを追加するproposalではなく、
Response.Body.Close()時の既存の混乱を整理し、実施予定の挙動変更を事前に周知するために作成された。 - ドキュメント更新の要否について質問があり、
Transportのドキュメントにある「ボディを最後まで読んでクローズしないとkeep-aliveコネクションが再利用されない」という趣旨の記述を更新する方針がコメントで示された。 - 後続のgo.dev/cl/741222で、クローズ後のドレイン処理がブロッキングしてしまう問題への対処が行われている。
- go.dev/cl/756160でこの挙動変更に関するリリースノートが追加された。
使用例
Before
package main
import (
"io"
"net/http"
)
func fetch(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer func() {
// コネクション再利用のため手動でボディをドレインしていた
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
return nil
}
After
package main
import (
"net/http"
)
func fetch(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
// Closeだけで未読分(最大256KB・50ms以内)が自動的にドレインされる
defer resp.Body.Close()
return nil
}
移行時の注意
意図的に大量のアイドルコネクションを維持しないようにしていた場合(Transport.MaxIdleConnsを0にする、あるいはリクエストごとに異なるClientを使うことで実質的にMaxIdleConnsの制限を回避していた場合など)、この変更によりコネクション再利用が増え、かえってパフォーマンスが低下する可能性がある。そのようなケースではTransport.DisableKeepAlivesをtrueに設定するとコネクション再利用自体を無効化できる。ただし、そもそもそのような設定・使い方自体がTransportやClientの構成として不適切であることが多く、根本的な見直しが望ましい。
実装解説
クライアント側のHTTP/1レスポンスボディは内部でbodyEOFSignal(transport.go;l=3215)にラップされている。そのCloseメソッド(transport.go;l=3250)は、ボディがEOFまで読まれていない状態でクローズされるとearlyCloseFnを呼び出し、これがpersistConn.readLoop(transport.go;l=2604)側に「まだ読み切っていない」ことを通知する。
readLoopはこの通知を受けると、レスポンスのContentLengthがmaxPostCloseReadBytes(256KB、transport.go;l=2420)以下であればmaybeDrainBody(transport.go;l=2427)を呼び出す。maybeDrainBodyは別goroutineでio.CopyNによりボディをEOFまで読み進めようとし、maxPostCloseReadTime(50ミリ秒、transport.go;l=2425)以内にEOFへ到達できた場合のみtrueを返す。ドレインに成功し、かつコネクションが生存中(sawEOFでない・リクエスト送信済みなど)であれば、コネクションはアイドルプールに戻され再利用される。