Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 15599] ファイルの概要

このコミットは、Go言語の net/http パッケージにおけるHTTPクライアントの Response.Body.Close メソッドの挙動を変更するものです。具体的には、HTTPレスポンスボディの読み取りが完了していない状態で Close が呼び出された際に、TCPコネクションを即座に閉じるように修正されています。これにより、以前の「EOFまで読み続ける」という挙動に起因する潜在的な問題(EOFが来ない、または時間がかかりすぎる)が解消されます。

コミット

commit ce8341554caa8be64aeafd9bf2077a98db462fda
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date:   Tue Mar 5 18:47:27 2013 -0800

    net/http: close TCP connection on Response.Body.Close
    
    Previously the HTTP client's (*Response).Body.Close would try
    to keep reading until EOF, hoping to reuse the keep-alive HTTP
    connection, but the EOF might never come, or it might take a
    long time. Now we immediately close the TCP connection if we
    haven't seen EOF.
    
    This shifts the burden onto clients to read their whole response
    bodies if they want the advantage of reusing TCP connections.
    
    In the future maybe we could decide on heuristics to read some
    number of bytes for some max amount of time before forcefully
    closing, but I'd rather not for now.
    
    Statistically, touching this code makes things regress, so I
    wouldn't be surprised if this introduces new bugs, but all the
    tests pass, and I think the code is simpler now too. Maybe.
    
    Please test your HTTP client code before Go 1.1.
    
    Fixes #3672
    
    R=golang-dev, adg
    CC=golang-dev
    https://golang.org/cl/7419050

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/ce8341554caa8be64aeafd9bf2077a98db462fda

元コミット内容

net/http: close TCP connection on Response.Body.Close

以前のHTTPクライアントの (*Response).Body.Close は、キープアライブHTTPコネクションを再利用するために、EOF(End Of File)が来るまで読み続けようとしていました。しかし、EOFが全く来ないか、来るまでに非常に長い時間がかかる可能性がありました。この変更により、EOFがまだ来ていない場合は、TCPコネクションを即座に閉じます。

この変更は、TCPコネクションの再利用の恩恵を受けたいクライアントに対して、レスポンスボディ全体を読み切る責任を負わせるものです。

将来的には、強制的に閉じる前に、一定量のバイトを最大時間読み取るためのヒューリスティックを導入することも考えられますが、現時点では行いません。

統計的に、このコードに手を加えると問題が発生しやすい傾向があるため、新しいバグが導入されても驚きませんが、全てのテストはパスしており、コードも以前よりシンプルになったと考えています。おそらく。

Go 1.1リリース前に、HTTPクライアントコードをテストしてください。

Fixes #3672

変更の背景

このコミットの背景には、Goの net/http クライアントがHTTPレスポンスボディを処理する際の、キープアライブコネクションの管理における課題がありました。

従来の Response.Body.Close() の実装では、HTTP/1.1のキープアライブ機能を利用してTCPコネクションを再利用するために、レスポンスボディの残りをEOFまで読み切ろうとする挙動がありました。これは、コネクションプールにコネクションを戻す前に、そのコネクションが完全にクリーンな状態であることを保証し、次のリクエストで予期せぬデータが読み込まれることを防ぐための一般的なプラクティスです。

しかし、このアプローチにはいくつかの問題がありました。

  1. EOFが来ない可能性: サーバーがレスポンスボディの送信を途中で停止したり、ネットワークの問題が発生したりした場合、クライアントは永遠にEOFを待ち続ける可能性がありました。これは、リソースリークやアプリケーションのハングアップにつながる可能性があります。
  2. EOFまでの時間が長すぎる可能性: 非常に大きなレスポンスボディの場合、クライアントがボディ全体を読み切るのに時間がかかりすぎることがありました。クライアントがボディの途中で処理を打ち切り、Close() を呼び出した場合でも、裏側でEOFまで読み続ける処理がブロックされ、コネクションの解放が遅れる原因となっていました。
  3. リソースの無駄: クライアントがレスポンスボディの全てを必要としない場合でも、キープアライブのために不要なデータを読み続ける必要がありました。これは帯域幅の無駄遣いにもなり得ます。

これらの問題は、特にストリーミングデータや、クライアントがレスポンスボディの一部だけを必要とするようなシナリオで顕著でした。Issue #3672("Client can't close HTTP stream")は、まさにこの問題点を指摘しており、クライアントがレスポンスボディの読み取りを途中で中断し、コネクションを閉じたい場合に、それが適切に行えないという状況を報告していました。

このコミットは、これらの問題を解決し、クライアントが Response.Body.Close() を呼び出した際に、より予測可能で即時的なリソース解放を可能にすることを目的としています。

前提知識の解説

このコミットを理解するためには、以下の概念について基本的な知識が必要です。

  1. HTTP/1.1 Keep-Alive (持続的接続):

    • HTTP/1.0では、各リクエスト/レスポンスのペアごとに新しいTCPコネクションが確立され、閉じられていました。これはオーバーヘッドが大きく、特に多数の小さなリクエストを送信する場合に非効率でした。
    • HTTP/1.1では、デフォルトで「Keep-Alive」が有効になっています。これにより、一つのTCPコネクション上で複数のHTTPリクエスト/レスポンスのやり取りが可能になります。コネクションの再利用は、TCPハンドシェイクやTLSハンドシェイクのオーバーヘッドを削減し、ネットワークの混雑を緩和し、全体的なパフォーマンスを向上させます。
    • コネクションを再利用するためには、前のレスポンスボディが完全に読み込まれ、コネクションがクリーンな状態になっている必要があります。
  2. io.Readerio.Closer インターフェース:

    • Go言語の io.Reader インターフェースは、データを読み取るための Read(p []byte) (n int, err error) メソッドを定義します。
    • io.Closer インターフェースは、リソースを閉じるための Close() error メソッドを定義します。
    • Response.Bodyio.ReadCloser インターフェース(io.Readerio.Closer の両方を満たす)を実装しており、レスポンスボディの読み取りと、その基盤となるリソース(通常はTCPコネクション)のクローズを可能にします。
  3. EOF (End Of File):

    • データストリームの終端を示す概念です。ファイル読み取りやネットワークストリームにおいて、これ以上データがないことを示すために使用されます。Goの io.ReaderRead メソッドは、EOFに達すると (0, io.EOF) を返します。
  4. TCPコネクション:

    • インターネットプロトコルスイートの一部であり、信頼性の高い、コネクション指向のデータ転送サービスを提供します。HTTP通信の基盤となります。
  5. Goの net/http パッケージ:

    • Go言語の標準ライブラリに含まれるパッケージで、HTTPクライアントとサーバーの実装を提供します。
    • http.Client: HTTPリクエストを送信し、レスポンスを受信するクライアント。
    • http.Response: HTTPレスポンスを表す構造体。その Body フィールドは io.ReadCloser です。
    • http.Transport: http.Client の基盤となる実装で、実際のネットワークI/O、コネクションプーリング、プロキシ設定などを扱います。
  6. bodyEOFSignal 構造体:

    • net/http パッケージ内部で使用されるラッパー構造体で、Response.Body の読み取りがEOFに達したか、または Close が呼び出されたときに特定のコールバック関数 (fn) を実行するために使用されます。これにより、レスポンスボディのライフサイクルイベントをフックできます。

これらの概念を理解することで、このコミットがなぜ必要とされ、どのように機能するのかを深く把握することができます。特に、キープアライブの利点と、そのためにレスポンスボディを完全に読み切る必要性、そしてその読み切りがうまくいかない場合の課題が、この変更の核心にあります。

技術的詳細

このコミットの技術的な核心は、net/http パッケージの Transport 構造体内の persistConn (持続的コネクション)の管理ロジックと、bodyEOFSignal の挙動の変更にあります。

変更の目的と新しい挙動

  • 目的: Response.Body.Close() が呼び出された際に、レスポンスボディが完全に読み込まれていない場合でも、基盤となるTCPコネクションを即座に閉じることで、リソースの早期解放とデッドロックの回避を実現します。
  • 新しい挙動:
    • クライアントが resp.Body.Close() を呼び出し、かつレスポンスボディがEOFまで読み込まれていない場合、TCPコネクションは即座に閉じられます。
    • これにより、そのTCPコネクションは再利用されず、コネクションプールに戻されることもありません。
    • コネクションの再利用(キープアライブ)の恩恵を受けたい場合は、クライアントは resp.Body を完全に読み切る必要があります。

transport.go の変更点

  1. persistConn.readLoop() の変更:

    • persistConn は、単一のTCPコネクション上で複数のHTTPリクエスト/レスポンスを処理するロジックをカプセル化しています。readLoop() は、このコネクションからのレスポンスを継続的に読み取るゴルーチンです。
    • lastbody 変数の削除: 以前は、前のレスポンスボディが完全に読み込まれていない場合に lastbody.Close() を呼び出すロジックがありましたが、これが削除されました。これは、新しいアプローチでは Response.Body.Close() が直接コネクションを閉じるため、persistConn 側でボディの読み込み完了を待つ必要がなくなったためです。
    • bodyEOFSignalearlyCloseFn の導入:
      • resp.Body.(*bodyEOFSignal).earlyCloseFn という新しいコールバック関数が設定されるようになりました。
      • この関数は、bodyEOFSignalClose() メソッドが呼び出され、かつ io.EOF がまだ見られていない(つまり、ボディが完全に読み込まれていない)場合に実行されます。
      • earlyCloseFnwaitForBodyRead <- false をチャネルに送信します。これは readLoop ゴルーチンに、現在のコネクションを alive = false に設定し、ループを終了してコネクションを閉じるように指示します。
  2. bodyEOFSignal 構造体の変更:

    • earlyCloseFn func() error という新しいフィールドが追加されました。これは、EOFに達する前に Close() が呼び出された場合に実行されるオプションの関数です。
    • Close() メソッドのロジックが変更されました。
      • es.closedtrue でないことを確認した後、es.earlyCloseFn != nil && es.rerr != io.EOF の条件がチェックされます。
      • この条件が真(つまり、earlyCloseFn が設定されており、かつEOFに達していない)の場合、es.earlyCloseFn() が呼び出され、その戻り値が Close() の戻り値となります。
      • それ以外の場合(EOFに達しているか、earlyCloseFn が設定されていない場合)、従来の es.body.Close() が呼び出され、es.condfn(err) が実行されます。

response_test.go および sniff_test.go の変更点

  • readFirstCloseBoth 構造体と discardOnCloseReadCloser 構造体が削除され、より汎用的な readerAndCloser 構造体が導入されました。これは、io.Readerio.Closer を組み合わせるためのシンプルなラッパーです。以前の構造体は、ボディの読み込み完了を待つという古いロジックに関連していたため、不要になりました。

transport_test.go の変更点

  • TestTransportCloseResponseBody の追加:
    • この新しいテストケースは、コミットの意図を直接検証するために追加されました。
    • サーバーは無限にデータを書き込み続けるストリーミングサーバーとして機能します。
    • クライアントはレスポンスボディの一部(len(msg)*repeats バイト)だけを読み取ります。
    • その後、res.Body.Close() を呼び出します。
    • テストは、res.Body.Close() が呼び出された後に、サーバー側で書き込みエラー(パイプが閉じられたことによるエラー)が発生することを検証します。これは、クライアントが Close() を呼び出したことでTCPコネクションが即座に閉じられ、サーバーへの書き込みが失敗したことを示します。
    • これにより、Response.Body.Close() が実際にTCPコネクションを閉じるという新しい挙動が確認されます。

まとめ

この変更は、net/http クライアントの Response.Body.Close() のセマンティクスを根本的に変更するものです。以前は「ボディを読み切ってコネクションを再利用しようとする」という挙動でしたが、新しい挙動は「ボディが読み切られていなければコネクションを即座に閉じる」というものです。これにより、クライアントはより明示的にコネクションのライフサイクルを制御できるようになり、リソースリークやハングアップのリスクが低減されます。ただし、キープアライブの恩恵を受けたい場合は、レスポンスボディを完全に読み切る責任がクライアント側に移転します。

コアとなるコードの変更箇所

このコミットにおけるコアとなるコードの変更箇所は、主に src/pkg/net/http/transport.go ファイルに集中しています。

  1. src/pkg/net/http/transport.go:

    • persistConn.readLoop() 関数内のロジック変更。
      • lastbody io.ReadCloser 変数の削除。
      • resp.Body.(*bodyEOFSignal).earlyCloseFn の設定ロジックの追加。
    • bodyEOFSignal 構造体への earlyCloseFn func() error フィールドの追加。
    • bodyEOFSignal.Close() メソッドのロジック変更。earlyCloseFn が設定されており、かつEOFに達していない場合に earlyCloseFn を呼び出す条件分岐の追加。
    • io/ioutil のインポート削除(ioutil.Discard が不要になったため)。
    • readFirstCloseBoth および discardOnCloseReadCloser 構造体の削除。
    • readerAndCloser 構造体の追加。
  2. src/pkg/net/http/response_test.go:

    • TestReadResponseCloseInMiddle 関数内で、readFirstCloseBoth の代わりに readerAndCloser を使用するように変更。
  3. src/pkg/net/http/sniff_test.go:

    • TestContentTypeWithCopy および TestSniffWriteSize 関数内で、res.Body.Close() の前に io.Copy(ioutil.Discard, res.Body) を呼び出すロジックが追加されました。これは、新しい Close の挙動を考慮し、テストが意図した通りに動作するように、明示的にボディを読み捨てることでEOFに達させるための変更です。
  4. src/pkg/net/http/transport_test.go:

    • TestTransportCloseResponseBody という新しいテストケースの追加。これは、Response.Body.Close() が実際にTCPコネクションを閉じることを検証するためのものです。

これらの変更は、HTTPクライアントのコネクション管理とレスポンスボディのライフサイクルに直接影響を与え、Go 1.1における net/http パッケージの重要な挙動変更を構成しています。

コアとなるコードの解説

このコミットの核心は、net/http/transport.go 内の bodyEOFSignal 構造体とその Close メソッド、そして persistConn.readLoop の変更にあります。

bodyEOFSignal 構造体と Close メソッドの変更

type bodyEOFSignal struct {
	body         io.ReadCloser
	mu           sync.Mutex   // guards following 4 fields
	closed       bool         // whether Close has been called
	rerr         error        // sticky Read error
	fn           func(error)  // error will be nil on Read io.EOF
	earlyCloseFn func() error // optional alt Close func used if io.EOF not seen
}

func (es *bodyEOFSignal) Close() error {
	es.mu.Lock()
	defer es.mu.Unlock()
	if es.closed {
		return nil
	}
	es.closed = true
	// ★ここが新しい変更点★
	if es.earlyCloseFn != nil && es.rerr != io.EOF {
		return es.earlyCloseFn()
	}
	err := es.body.Close()
	es.condfn(err)
	return err
}
  • earlyCloseFn func() error フィールドの追加:

    • この新しいフィールドは、bodyEOFSignal がラップしている io.ReadCloserRead メソッドが io.EOF を返す前に Close() が呼び出された場合に実行される、オプションのコールバック関数です。
    • この関数が設定されている場合、通常の es.body.Close() の代わりにこの関数が実行され、その戻り値が bodyEOFSignal.Close() の戻り値となります。
  • Close() メソッドのロジック変更:

    • es.closed = true の設定後、新しい条件 if es.earlyCloseFn != nil && es.rerr != io.EOF が評価されます。
    • es.rerr は、Read メソッドが最後に返したエラーを保持します。io.EOF でないということは、ボディの読み取りが完了していないことを意味します。
    • この条件が真の場合、つまり earlyCloseFn が設定されており、かつボディが完全に読み込まれていない状態で Close() が呼び出された場合、es.earlyCloseFn() が実行されます。
    • これにより、クライアントがボディを途中で閉じても、earlyCloseFn を通じて基盤となるTCPコネクションを即座に閉じることが可能になります。

persistConn.readLoop() の変更

persistConn.readLoop() は、HTTPコネクションからのレスポンスを読み取り、それを処理するゴルーチンです。この関数内で、bodyEOFSignalearlyCloseFn が設定されるようになりました。

func (pc *persistConn) readLoop() {
	defer close(pc.closech)
	alive := true

	for alive {
		// ... (リクエストの受信とレスポンスの読み取り) ...

		var waitForBodyRead chan bool
		if hasBody {
			waitForBodyRead = make(chan bool, 2) // バッファサイズが1から2に変更
			// ★ここが新しい変更点★
			resp.Body.(*bodyEOFSignal).earlyCloseFn = func() error {
				// Sending false here sets alive to
				// false and closes the connection
				// below.
				waitForBodyRead <- false // falseを送信してコネクションを閉じる
				return nil
			}
			resp.Body.(*bodyEOFSignal).fn = func(err error) {
				alive1 := alive
				if err != nil {
					// ... (エラー処理) ...
				}
				waitForBodyRead <- alive1 // 読み取り完了時にaliveの状態を送信
			}
		}

		// ... (レスポンスの送信) ...

		if hasBody {
			alive = <-waitForBodyRead // ボディの読み取り完了またはearlyCloseFnからのシグナルを待つ
		} else {
			// ... (ボディがない場合の処理) ...
		}
	}
	// ... (コネクションのクローズ) ...
}
  • earlyCloseFn の設定:
    • レスポンスにボディがある場合 (hasBodytrue)、resp.Body*bodyEOFSignal に型アサートされ、その earlyCloseFn フィールドに匿名関数が設定されます。
    • この匿名関数は、waitForBodyRead <- false をチャネルに送信します。
    • readLoopalive = <-waitForBodyRead でこのチャネルからの値を受け取ります。false を受け取ると alivefalse になり、ループが終了して基盤となるTCPコネクションが閉じられます。

動作のシーケンス

  1. HTTPクライアントがリクエストを送信し、レスポンスを受け取ります。
  2. http.Transport はレスポンスボディを bodyEOFSignal でラップし、その earlyCloseFn を設定します。この earlyCloseFn は、基盤となる persistConn にコネクションを閉じるようシグナルを送る役割を担います。
  3. クライアントが resp.Body.Close() を呼び出します。
  4. bodyEOFSignal.Close() メソッドが実行されます。
  5. もしレスポンスボディがまだ完全に読み込まれていない場合(es.rerr != io.EOF)、設定された earlyCloseFn が呼び出されます。
  6. earlyCloseFnpersistConn.readLoopfalse を送信し、readLoopalivefalse に設定してループを終了します。
  7. readLoop が終了すると、defer close(pc.closech) が実行され、最終的にTCPコネクションが閉じられます。

このメカニズムにより、クライアントが Response.Body.Close() を呼び出すと、ボディの読み取り状況に関わらず、TCPコネクションが即座に解放されるようになりました。これにより、リソースリークやハングアップのリスクが大幅に低減されます。

関連リンク

参考にした情報源リンク