
2026/10/15 1:29
Go 1.27 で HTTP/1 接続の再利用が向上
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
Go 1.27 は、HTTP/1 プロトコルにおいて未読み取りレスポンスボディを自動的に処理することで、接続を安全に閉じるために以前必要だった手動の労力を実質的に排除し、重要な進歩を示しています。このアップデートは、接続再利用に伴うレイテンシペナルティを劇的に削減し、データ損失や複雑なワークアラウンドへの心配なく、標準パターン
defer resp.Body.Close() を安心して使用することを可能にします。以前、アプリケーションは後続のバイトが未処理の場合、高価な TCP/TLS ハンドシェイクをあえて破棄することがあり、その結果、次のリクエストに対して高コストな新しい接続が必要とされていました。新しい自動非同期ドレイン機能は、50ms または 256KB 以内に残留データを効率的に処理し、接続の放棄を防ぎつつ、明示的に閉じることを指定されたレスポンスについては作業を賢くスキップします。この変更により、開発者が未使用の大規模ファイルに帯域幅を浪費したり、意図せずにゴルーチンリークを引き起こすような歴史的問題が解決されました。これらの複雑性を内部化することで、Go の標準ライブラリはより安全なコードと効率的なクライアント管理を可能にします。究極的には、新しい接続確立の主要コストが低下することにより、業界全体のパフォーマンスが向上し、小規模かつ頻繁なリクエストが全体的に大幅に高速化され、リソース利用も少なくて済みます。本文
Go 1.27 で実現された HTTP パフォーマンス劇的改善とその背景
概要
Go 1.26 と Go 1.27 を比較したベンチマークでは、HTTP ステータスコード取得のパフォーマンスに劇的な変化が見られました。
- Go 1.26:
への 100 回呼び出しで約 710 ミリ秒index.golang.org - Go 1.27: 同条件下で約 150 ミリ秒(ほぼ 5 倍の高速化)
この性能差の原因は、HTTP/1 で未読のレスポンス本体を処理する際の「接続再利用失敗」メカニズムと、Go 1.27 の自動最適化にあります。
HTTP 接続確立のコスト
新しい TCP 接続を作成するには、以下のラウンドトリップ(RTT)が発生します。
- TCP ハンドルシャーク (1 RTT)
- クライアント → サーバー:SYN パケット
- サーバー → クライアント:SYN-ACK
- クライアント → サーバー:ACK + TLS メッセージ初回
- TLS ハンドルシャーク (2 RTT)
- 暗号化鍵の交換とサーバー認証(TLS 1.2 の場合は 2 RTT)
- HTTP リクエスト
- キー交換完了後のリクエスト送信
合計:約 3 RTT
クロスリージョン通信では片方向遅延が 50ms(RTT 100ms)に達することがあり、接続確立コスト(約 300ms)は HTTP リクエスト自体の帯域幅利用効率を大幅に下回ります。
注:
は TLS 1.3 をサポートしており、実際の RTT は少ないものの、コスト自体がゼロにはなりません。index.golang.org
接続の再利用とバージョン별 차이
HTTP クライアントは既存接続の再利用を試みますが、実装方式は HTTP バージョンによって異なります。
| HTTP バージョン | 特徴 |
|---|---|
| HTTP/1.0 | ヘッダーが必要(公式仕様の一部ではないが一般的)。省略または を送れば終了。 |
| HTTP/1.1 | デフォルトで接続永続化。明示的に でない限り再利用可能。 |
| HTTP/2 | 単一 TCP 接続内でマルチプレキシング(並列処理)が可能。 |
接続の再利用が失敗するメカニズム
HTTP/1 では、同じ TCP 接続上で複数のリクエストを行う際、レスポンスは バイトストリーム として連続して届きます。
net/http.Get
の動作フロー
net/http.Get- ステータス行とヘッダーを読み取り解析し、
オブジェクトを返す。Response - レスポンス本体(ボディ)はまだストリーム上で未読のまま。
- 次のレスポンスを開始するには、現在のレスポンス本体の最後まで読み取らなければならない。
- 理由:
またはContent-Length
を待つ必要があるため。Transfer-Encoding
- 理由:
問題の発生
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()- 「接続再利用失敗」:既存接続を破棄し、新規 TCP 接続を作成して次へ進む。
- 結果: 毎回新しい接続確立コスト(RTT)が発生し続ける。
Go 1.27 の対応: これらの未読処理を内部で自動的に最適化し、接続再利用を維持します。
再利用失敗が影響しやすいパターン
「ステータスコードのみ取得」するコードでも、以下の理由で接続再利用に失敗しやすかったのです。
1. API レスポンスの詳細なボディ
- エンドポイントが
のような詳細情報を送信する場合。{ "success": true, ... } - ステータスコードだけを抜き取り、本体を閉じるだけで接続切断されるリスクがある。
2. デコーダーの不完全な読み取り
がencoding/json.Decoder
で停止する場合でも、その後の改行などが未読で残ることがある。}- その未読バイトを消費しないと、次のレスポンスの開始位置が見つからないため接続が切断される。
結論: 「一見無害なコード」でも、HTTP/1 では沈黙したパフォーマンス劣化を引き起こす要因でした。
手動による解決策(推奨されない)
未読ボディの問題に対処するため、開発者が独自に実装した回避策の例です。
バリエーション 1: 全排出(非推奨)
func drainAndCloseBody(body io.ReadCloser) { io.Copy(io.Discard, body) // 全ダウンロードして破棄 body.Close() }
- 欠点: レスポンス本体が巨大な場合、新規接続コストよりもダウンロード時間が長くなる可能性。
バリエーション 2: サイズ制限付き排出
func drainAndCloseBody(body io.ReadCloser) { io.CopyN(io.Discard, body, 256<<10) // 最大 256 KB body.Close() }
バリエーション 3: タイムアウト付き排出
func drainAndCloseBody(body io.ReadCloser) { drainDone := make(chan struct{}, 1) go func() { io.CopyN(io.Discard, body, 256<<10) drainDone <- struct{}{} }() select { case <-drainDone: // 完了 case <-time.After(50 * time.Millisecond): // タイムアウト処理 } body.Close() }
バリエーション 4: 非同期排出(最良の試み)
複数のゴルーチンを使って、排出処理を背景で実行し、接続再利用と競争させる手法です。
欠点:
- 実装が複雑(cumbersome)。
- 「レスポンスボディを排出する」という慣行自体が誤解を生み(例:HTTP/2 に対して不要な排出を試みる)、マイクロバグの原因となることが多かった。
結論:Go 1.27 以降のベストプラクティス
Go 1.27 以降、
net/http.Transport は以下の挙動を自動的に実行します。
- 自動的な非同期排出: 未読の HTTP/1 レスポンス本体に対して、最大 50 ミリ秒 または 256 KB の範囲まで自動排出。
- ヘッダー判定による最適化:
ヘッダーがある場合、無駄な排出処理をスキップ。Connection: close - 追加の内部最適化: 将来的なバージョンでさらなる強化が施される可能性があります。
おすすめコードパターン
複雑な回避策を入れる必要はありません。シンプルさこそが 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 }
要するに: コードを清潔でシンプルに保ち、複雑な最適化は
net/http に任せましょう。