Go 1.27でHTTP/1の接続再利用が自動に — Body.Close()だけで約5倍速くなる仕組み

はじめに

2026年10月14日、Go 公式ブログに「Better HTTP/1 connection reuse」という記事が公開されました。Go チームの Nicholas Husin 氏と Damien Neil 氏によるもので、Hacker News でも100ポイントを超えています。

内容は、Go 1.27 の net/http が「読まれていないレスポンス本文」を自動で読み捨てるようになり、HTTP/1 の接続が以前より再利用されやすくなった、というものです。ステータスコードだけを見る http.Get を100回呼ぶ例では、約710ミリ秒が約150ミリ秒になったと紹介されています。

本記事では、なぜ以前は接続が捨てられていたのか、Go 1.27 で何が変わったのか、手元のコードで何を見直せばよいのかを整理します。

何が変わったのか — Body.Close() だけで接続が残る

ブログの例は次のような、どこにでもあるコードです。

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
}

本文は読まず、Close() だけを呼んでいます。HTTP/2 を無効にした状態(GODEBUG=http2client=0)で index.golang.org に100回投げると、Go 1.26 では約710ミリ秒、Go 1.27 では約150ミリ秒でした。約4.7倍の差です(筆者の手元での計測値です)。

Go 1.27 の net/http.Transport は、本文が残ったまま Close() されたとき、残りをバックグラウンドで最大50ミリ秒・最大256KBまで読み捨てます。読み切れれば接続はアイドルプールに戻り、次のリクエストで再利用されます。レスポンスに Connection: close が付いていれば、どうせ再利用できないので読み捨ては省きます。なお、go1.27.2 のソース(transport.go)を見ると、Content-Length が256KBを超えると分かっている本文は最初から読み捨ての対象外で、これまでどおり接続を切ります。対象になるのは256KB以下の本文と、長さが事前に分からないチャンク形式の本文です。

リリースノートにも「HTTP/1 の Response.Body は、閉じたときに未読の内容を控えめな上限まで自動で読み捨てる。ほとんどのプログラムでは変化なしか性能向上になる」と書かれています。あわせて公式の推奨も変わりました。今後は「不要になったら Body.Close() するだけでよい」というのが Go チームの立場です。

なぜ再利用に失敗していたのか

原因は HTTP/1 の仕組みそのものにあります。

HTTP/1.1 では接続は既定で持続しますが、1本の接続の上でレスポンスは順番に、1本のバイト列として流れてきます。http.Get はステータス行とヘッダーを読んだ時点で Response を返し、本文は接続の上に残ったままです。次のレスポンスがどこから始まるかは、今の本文を Content-Length や Transfer-Encoding で示された最後まで読まないと分かりません。

そのため Go 1.26 までは、本文が残った状態で Close() されると「この接続は再利用できない」と判断して接続を捨てていました。次のリクエストは新しい接続になり、TCP ハンドシェイク(1 RTT)と TLS 1.2 のハンドシェイク(2 RTT)で、リクエストを送るまでに合計3 RTT かかります。ブログは、地域をまたぐ通信なら RTT が100ミリ秒に達することも多く、接続を張るだけで300ミリ秒になると説明しています。冒頭の計測先 index.golang.org は TLS 1.3 に対応していて TLS のハンドシェイクは1 RTT で済みますが、それでも差ははっきり出ました。

この問題は、本文を読んでいるコードでも起きます。encoding/json.Decoder は JSON オブジェクトの閉じ括弧 } を読んだところで止まるので、サーバーが末尾に改行を付けて返すと、その1バイトが残って接続が捨てられます。HN のコメントでも「Decoder の後に More() を呼んで空白を読み切っている」という人がいました。

HTTP/2 ではこの問題は起きません。1本の接続の上でフレーム単位で多重化するので、ある応答の本文が残っていても他のリクエストの邪魔をしないからです。

自前の drain 関数はどうするか

この問題は長く知られていて、多くの Go 開発者が自前で「閉じる前に本文を読み捨てる」関数を書いてきました。ブログは、その関数を安全に書くのがいかに面倒かを段階を追って示しています。

  1. io.Copy(io.Discard, body) で全部読む → 本文が巨大だと、新しい接続を張るより遅くなる
  2. io.CopyN で256KBまでに制限する → 少しずつしか届かない応答で詰まる
  3. ゴルーチンと time.After で50ミリ秒の時間制限を付ける → Close() が最大50ミリ秒ブロックする
  4. 時間制限ごとバックグラウンドに回す → ようやく実用になる

Go 1.27 の Transport が内部でやっているのは、この4番目に相当する処理です。HN では「自前の drain ヘルパーをやっと消せる」というコメントが複数あり、中には「プロジェクトからプロジェクトへ、会社から会社へコピーしてきた」という人もいました。ブログは、読み捨ての習慣が広まるうちに「HTTP/2 の接続でも本文を読み捨てる」といった不要な使い方まで出てきた、とも指摘しています。

Go 1.27 以降を前提にできるコードなら、こうした関数は消して defer resp.Body.Close() に戻せます。古い Go もサポートするライブラリでは、1.26 以前のために残しておく判断もありです。

エンジニアへの影響と注意点

恩恵が大きいのは、HTTP/1 の相手に大量のリクエストを送るクライアントです。HN でも「2026年に HTTP/1 を使う理由は?」という質問に対し、「自分では制御できない外部 API を叩くから」という返答が付いていました。また Go の標準クライアントは、既定では TLS なしの http:// 宛てに HTTP/2 を使いません(Go 1.24 以降は Transport.Protocols で UnencryptedHTTP2 を明示すれば使えます)。そのため、TLS を張らない社内のマイクロサービス間通信や、ヘルスチェック・Webhook 送信のような「ステータスだけ見る」処理は対象になりやすいところです。

一方で、注意点もあります。

  • 「Close で本文のダウンロードを打ち切れる」前提が変わる: これまでは Close() で残りの受信を止められましたが、今後は最大50ミリ秒・256KBまでは読み続けます。HN でもこの点を惜しむ声がありました。go1.27.2 のソースでは、リクエストに req.Close = true を付けると読み捨てを常に省きます。コンテキストのキャンセルで打ち切る場合は、Close() より前に cancel() を呼ぶ必要があります。defer cancel() の後に defer resp.Body.Close() を書く普通の順序だと Close() が先に走り、読み捨てが始まってしまいます。
  • アイドル接続を溜め込む設定は遅くなる場合がある: リリースノートは、Transport.MaxIdleConns を0にしていたり、リクエストごとに別の Client を作って上限をすり抜けていたりすると、性能が落ちることがあると書いています。その場合は DisableKeepAlives を true にすれば再利用を止められますが、リリースノート自身が「そもそも Transport や Client の使い方を見直すべき」と添えています。
  • 挙動は今後も変わりうる: ブログは「この挙動は将来のバージョンで変わる可能性がある」と明記しています。50ミリ秒・256KBという値に依存したコードは書かないほうが安全です。

ドキュメントについては、HN で「Close のドキュメントに書かれていない」という指摘がありました。返信によれば、Client.Do などの説明に「Body を閉じたとき、Transport は控えめな上限まで非同期で EOF まで読もうとする」と書かれています。

まとめ

  • Go 1.27 の net/http.Transport は、未読の HTTP/1 レスポンス本文を最大50ミリ秒・256KBまでバックグラウンドで読み捨て、接続を再利用する
  • 原因は、HTTP/1 では本文を最後まで読まないと次のレスポンスの位置が分からないこと。JSON の末尾の改行1バイトでも接続が捨てられていた
  • 公式の推奨は「不要になったら Body.Close() するだけ」に変わり、自前の drain 関数は Go 1.27 以降なら不要になる
  • Close() で受信を即座に打ち切る使い方や、アイドル接続を大量に溜める設定では挙動の変化に注意する

HTTP/2 や HTTP/3 が広まった今でも、相手を選べない外部 API や TLS なしの社内通信では、HTTP/1 で話す場面がなくなっていません。コードを変えずに速くなる改善なので、Go 1.27 に上げたら、社内の drain ヘルパーを探して消すところから始めるとよいでしょう。

ソース