10月14日、go.devが「Better HTTP/1 connection reuse」と題した記事を公開した。Go 1.27におけるnet/httpパッケージのHTTP/1接続再利用の改善と、その背景にある低レイヤーの仕組みについて詳しく解説している。
Go 1.27は2026年8月にリリースされた最新のメジャーバージョンだ。このバージョンから、長年Goコミュニティを悩ませてきたHTTP/1の接続再利用問題が標準ライブラリのレベルで解決された。ユーザーコードを一切変更しなくても、条件次第でHTTPリクエストのスループットが約5倍向上する。
同じコードが、Go 1.27で約5倍速くなる
以下のコードを見てほしい。
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(HTTP/2を無効化した環境)でindex.golang.orgに対して100回呼び出すと、Go 1.26では約710ミリ秒かかる。ところがGo 1.27では約150ミリ秒で完了する。コードに変更は一切ない。
なぜこれほどの差が出るのか。その理由を理解するには、HTTP接続の確立コストと、HTTP/1が抱える構造的な制約から話を始める必要がある。
HTTP接続確立の「見えないコスト」
net/httpパッケージはHTTP/1.0、HTTP/1.1、HTTP/2をサポートしており、いずれもTCP(Transmission Control Protocol:信頼性のある順序保証付きの通信プロトコル)上で動作する。HTTPSエンドポイントへのリクエストでは、さらにTLS(Transport Layer Security:通信を暗号化するプロトコル)が重なる。新規接続でリクエストを送る場合のフローは以下のとおりだ。
- TCPハンドシェイク(1 RTT): SYN → SYN-ACK → ACK
※ RTT(Round-Trip Time)=パケットが往復するのにかかる時間 - TLSハンドシェイク(最大2 RTT): 暗号鍵の交換とサーバー認証(TLS 1.2の場合)
- HTTPリクエスト送信: 合計3 RTT経過後にようやく本題
クロスリージョン通信では片道レイテンシが50ms(往復100ms)を超えることもある。そうなると接続確立だけで300msが消える計算だ。接続を再利用できれば、このコストを完全に省ける。
HTTP/1で接続再利用が「サイレントに」失敗する理由
接続確立コストを毎回払わないよう、HTTPには接続再利用の仕組みがある。HTTP/1.1ではデフォルトで接続が持続され、Connection: closeヘッダーが来ない限り再利用できる。
しかしHTTP/1には根本的な制約がある。複数のリクエストとレスポンスが同一接続上で逐次的にやり取りされる構造上、net/httpは現在のレスポンスボディを末尾まで読み切らないと、次のレスポンスの開始位置を特定できない。
冒頭のgetStatus関数はresp.Bodyを読まずにCloseしている。Go 1.26までのnet/httpは、ボディが未読の状態でCloseが呼ばれると接続の再利用を諦め、毎回新しい接続を張り直す。これが710msの正体だ。
この問題が厄介なのは、コードが見た目上まったく正しいという点だ。ステータスコードしか使わないなら、ボディを読まないのは合理的な判断に見える。しかし裏では毎回TCPとTLSのハンドシェイクを繰り返している。エラーは出ない。ログにも残らない。パフォーマンス問題として表面化して初めて気づく、という性質の罠だ。
さらに見落とされがちなケースもある。encoding/json.DecoderはJSONオブジェクトの閉じ}を読んだ時点で停止するため、サーバーがレスポンスの末尾に改行を付けていると、1バイトだけ未読の状態になる。それだけで接続が再利用されず捨てられる。
なお、HTTP/2はこの問題を持たない。単一接続上でストリームを多重化する設計のため、あるストリームの未読ボディが他のリクエストをブロックしない。ただし、プロキシ環境やレガシーサーバーとの通信ではHTTP/1.1が強制されるケースも多く、この問題は現実的な影響を持ち続けてきた。
これまでの「手動回避策」とその複雑さ
この問題は長年知られており、Goコミュニティでは独自の回避策が広まっていた。最も一般的なパターンはボディをドレイン(読み捨て)してからCloseする方法だ。
func drainAndCloseBody(body io.ReadCloser) {
io.Copy(io.Discard, body)
body.Close()
}
しかしこれには問題がある。ボディが大きい場合、ダウンロードして捨てるより新規接続を張った方が速い。そこで読み捨てるバイト数を制限し、さらに時間もキャップする必要が出てくる。
func drainAndCloseBody(body io.ReadCloser) {
drainDone := make(chan struct{}, 1)
go func() {
io.CopyN(io.Discard, body, 256<<10) // 最大256KBまで
drainDone <- struct{}{}
}()
go func() {
select {
case <-drainDone:
case <-time.After(50 * time.Millisecond): // 最大50ms
}
body.Close()
}()
}
非同期でドレインを走らせ、次のリクエストと競争させる形だ。正しく実装すると意外なほど複雑になる。そして実際、「HTTP/2接続でも念のためドレインする」といった誤ったコードが広まるという二次被害も起きていた。
Go 1.27での解決
Go 1.27から、net/http.Transportがこのドレイン処理を自動で行う。具体的には、未読のHTTP/1レスポンスボディを最大50ms・最大256KBの範囲で非同期にドレインする。Connection: closeヘッダーが含まれる場合はドレインが無駄になることが確定しているため、その処理は省略される。
結果として、冒頭のシンプルな書き方で構わない。
defer resp.Body.Close()
これだけでGo 1.27以降は適切に接続再利用が機能する。独自のdrainAndCloseBody関数を用意していた場合は削除を検討していいが、Go 1.26以前もサポートするプロジェクトでは引き続き手動処理が必要な点には注意が必要だ。なお公式ブログでは、この最適化の挙動は今後のGoバージョンで変わる可能性があると明記されている。
「正しく書いたつもりのコードが、実は毎回接続を張り直していた」という状況が、標準ライブラリのアップデートひとつで解消される。Go 1.27へのアップグレードを検討している場合、このパフォーマンス改善は移行の十分な動機になるはずだ。
詳細はBetter HTTP/1 connection reuseを参照していただきたい。