[インデックス 19095] ファイルの概要
このコミットは、Go言語の標準ライブラリ net/http
パッケージにおける Transport
の接続再利用ロジックと、それに伴うゴルーチンリークの可能性を修正するものです。特に、クライアントがリクエストボディを完全に書き込む前にサーバーが応答を返した場合の接続の健全性を確保し、また接続のクローズ処理におけるデッドロックの可能性を解消しています。
コミット
commit 6278a9549288784563bfc9dc2f94cb0031e4ab2f
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date: Wed Apr 9 21:50:24 2014 -0700
net/http: don't reuse Transport connection unless Request.Write finished
In a typical HTTP request, the client writes the request, and
then the server replies. Go's HTTP client code (Transport) has
two goroutines per connection: one writing, and one reading. A
third goroutine (the one initiating the HTTP request)
coordinates with those two.
Because most HTTP requests are done when the server replies,
the Go code has always handled connection reuse purely in the
readLoop goroutine.
But if a client is writing a large request and the server
replies before it's consumed the entire request (e.g. it
replied with a 403 Forbidden and had no use for the body), it
was possible for Go to re-select that connection for a
subsequent request before we were done writing the first. That
wasn't actually a data race; the second HTTP request would
just get enqueued to write its request on the writeLoop. But
because the previous writeLoop didn't finish writing (and
might not ever), that connection is in a weird state. We
really just don't want to get into a state where we're
re-using a connection when the server spoke out of turn.
This CL changes the readLoop goroutine to verify that the
writeLoop finished before returning the connection.
In the process, it also fixes a potential goroutine leak where
a connection could close but the recycling logic could be
blocked forever waiting for the client to read to EOF or
error. Now it also selects on the persistConn's close channel,
and the closer of that is no longer the readLoop (which was
dead locking in some cases before). It's now closed at the
same place the underlying net.Conn is closed. This likely fixes
or helps Issue 7620.
Also addressed some small cosmetic things in the process.
Update #7620
Fixes #7569
LGTM=adg
R=golang-codereviews, adg
CC=dsymonds, golang-codereviews, rsc
https://golang.org/cl/86290043
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/6278a9549288784563bfc9dc2f94cb0031e4ab2f
元コミット内容
net/http
: Request.Write
が完了するまで Transport
接続を再利用しない
一般的なHTTPリクエストでは、クライアントがリクエストを書き込み、その後サーバーが応答します。GoのHTTPクライアントコード (Transport
) は、接続ごとに2つのゴルーチンを持っています。1つは書き込み用、もう1つは読み込み用です。3つ目のゴルーチン(HTTPリクエストを開始するゴルーチン)がこれら2つと連携します。
ほとんどのHTTPリクエストはサーバーが応答した時点で完了するため、Goのコードは常に readLoop
ゴルーチン内で接続の再利用を処理していました。
しかし、クライアントが大きなリクエストを書き込んでいる最中に、サーバーがリクエスト全体を消費する前に応答を返した場合(例えば、403 Forbiddenで応答し、ボディを必要としなかった場合など)、Goは最初の書き込みが完了する前に、その接続を後続のリクエストのために再選択してしまう可能性がありました。これは実際にはデータ競合ではありませんでした。2番目のHTTPリクエストは単に writeLoop
に書き込みのためにキューに入れられるだけです。しかし、以前の writeLoop
が書き込みを完了しなかった(そして、おそらく今後も完了しない)ため、その接続は奇妙な状態になっていました。サーバーが予期せず応答を返したときに、接続を再利用する状態になることを避けたかったのです。
この変更は、readLoop
ゴルーチンが接続を返す前に writeLoop
が完了したことを検証するように変更します。
この過程で、接続がクローズされたにもかかわらず、クライアントがEOFまたはエラーを読み込むのを永遠に待機してリサイクルロジックがブロックされる可能性のあるゴルーチンリークも修正されました。これにより、persistConn
の close
チャネルも選択されるようになり、そのクローザーはもはや readLoop
ではありません(以前は一部のケースでデッドロックを引き起こしていました)。現在は、基盤となる net.Conn
がクローズされるのと同じ場所でクローズされます。これは Issue 7620 を修正または改善する可能性があります。
また、いくつかの小さな見た目の修正も行われました。
Update #7620 Fixes #7569
変更の背景
このコミットは、主に2つの問題に対処するために行われました。
-
HTTP接続の不適切な再利用 (Issue 7569): Goの
net/http
パッケージのTransport
は、HTTP/1.1のKeep-Alive機能を利用して、複数のリクエストで同じTCP接続を再利用することでパフォーマンスを向上させます。しかし、これまでの実装では、クライアントが大きなリクエストボディを送信している途中で、サーバーがそのリクエストボディを完全に読み込む前に応答を返すという特殊なケース(例: サーバーがリクエストボディを必要としないエラー応答、例えば403 Forbiddenを返す場合)において、問題が発生する可能性がありました。具体的には、
Transport
は接続の再利用をreadLoop
ゴルーチン(サーバーからの応答を読み取る役割)に依存していました。サーバーが早期に応答を返すと、readLoop
は接続が再利用可能であると判断し、アイドル接続プールに戻してしまう可能性がありました。しかし、この時点ではwriteLoop
ゴルーチン(クライアントのリクエストボディを書き込む役割)はまだ書き込みを完了しておらず、場合によっては永遠に完了しない可能性がありました。これにより、後続のリクエストがこの「奇妙な状態」の接続を再利用しようとすると、予期せぬ動作やハングアップが発生する可能性がありました。これはデータ競合ではありませんでしたが、接続の健全性を損なう状態でした。 -
ゴルーチンリークとデッドロックの可能性 (Issue 7620): 既存の接続クローズおよびリサイクルロジックには、ゴルーチンリークの潜在的な問題がありました。接続がクローズされたにもかかわらず、リサイクルロジックがクライアントがEOF(End Of File)またはエラーを読み込むのを永遠に待ち続けることでブロックされる可能性がありました。これは、
readLoop
がpersistConn
のclosech
をクローズする役割を担っていたため、特定のシナリオでデッドロックを引き起こす可能性がありました。
これらの問題は、GoのHTTPクライアントの堅牢性と信頼性に影響を与えるため、修正が必要とされました。
前提知識の解説
このコミットを理解するためには、以下のGo言語およびHTTPプロトコルに関する基本的な知識が必要です。
-
Goのゴルーチンとチャネル:
- ゴルーチン (Goroutine): Goランタイムによって管理される軽量なスレッドのようなものです。非常に低コストで生成でき、並行処理を実現します。このコミットでは、HTTP接続ごとに
readLoop
(読み込み) とwriteLoop
(書き込み) の2つの主要なゴルーチンが動作していることが言及されています。 - チャネル (Channel): ゴルーチン間で安全にデータを送受信するための通信メカニズムです。チャネルは、ゴルーチン間の同期にも使用されます。このコミットでは、
reqch
(リクエストチャネル)、writech
(書き込みリクエストチャネル)、closech
(クローズチャネル)、writeErrCh
(書き込みエラーチャネル) などが使用されています。
- ゴルーチン (Goroutine): Goランタイムによって管理される軽量なスレッドのようなものです。非常に低コストで生成でき、並行処理を実現します。このコミットでは、HTTP接続ごとに
-
Goの
net/http
パッケージ:http.Client
: HTTPリクエストを送信するためのクライアントです。http.Transport
:http.Client
の内部で使用され、HTTPリクエストの実際の送信、接続の管理、Keep-Alive、プロキシなどの低レベルな詳細を処理します。このコミットの主要な変更対象です。- Keep-Alive: HTTP/1.1の機能で、複数のHTTPリクエスト/レスポンスで同じTCP接続を再利用することを可能にします。これにより、新しいTCP接続を確立するオーバーヘッドが削減され、パフォーマンスが向上します。
Transport
はこのKeep-Alive接続を管理し、アイドル状態の接続をプールします。 persistConn
:net/http
パッケージ内部で、Keep-Alive接続を表す構造体です。この構造体が、接続の読み書きゴルーチンやチャネルを管理します。
-
HTTPプロトコルの基本:
- リクエスト/レスポンスサイクル: クライアントがリクエストを送信し、サーバーがレスポンスを返すという基本的なHTTPのやり取り。
- リクエストボディ: POSTやPUTなどのリクエストで、クライアントがサーバーに送信するデータ。
- レスポンスボディ: サーバーがクライアントに返すデータ。
- Content-Lengthヘッダ: HTTPメッセージボディの長さをバイト単位で示すヘッダ。
-
デッドロックとゴルーチンリーク:
- デッドロック: 複数のゴルーチンが互いに相手の処理完了を待ち続け、結果としてどのゴルーチンも処理を進められなくなる状態。
- ゴルーチンリーク: 終了すべきゴルーチンが何らかの理由で終了せず、リソース(メモリなど)を消費し続ける状態。
技術的詳細
このコミットの技術的な変更は、主に src/pkg/net/http/transport.go
内の persistConn
構造体とその関連メソッド、特に readLoop
と writeLoop
に集中しています。
1. persistConn
構造体の変更
-
writeErrCh chan error
の追加:persistConn
構造体にwriteErrCh
という新しいチャネルが追加されました。これは、writeLoop
ゴルーチンからreadLoop
ゴルーチンへ、リクエストの書き込みが成功したかどうか(エラーが発生したかどうか)を伝えるために使用されます。このチャネルはバッファリングされており、make(chan error, 1)
で作成されます。これにより、writeLoop
が書き込みエラーを送信し、readLoop
がそれを受け取ることで、書き込みの完了状態を同期的に確認できるようになります。// transport.go type persistConn struct { // ... 既存のフィールド ... writech chan writeRequest // written by roundTrip; read by writeLoop closech chan struct{} // closed when conn closed isProxy bool // writeErrCh passes the request write error (usually nil) // from the writeLoop goroutine to the readLoop which passes // it off to the res.Body reader, which then uses it to decide // whether or not a connection can be reused. Issue 7569. writeErrCh chan error // ... 既存のフィールド ... }
-
closech
の役割変更: 以前はreadLoop
がclosech
をクローズしていましたが、この変更によりclosech
はpersistConn.closeLocked()
メソッド内で、基盤となるnet.Conn
がクローズされるのと同じタイミングでクローズされるようになりました。これにより、readLoop
がclosech
をクローズするのを待つことによるデッドロックの可能性が排除されます。// transport.go // 変更前: defer close(pc.closech) が readLoop にあった // 変更後: close(pc.closech) が pc.closeLocked() に移動 func (pc *persistConn) closeLocked() { if !pc.closed { pc.conn.Close() pc.closed = true close(pc.closech) // ここで closech がクローズされる } pc.mutateHeaderFunc = nil }
2. writeLoop
ゴルーチンの変更
-
writeErrCh
へのエラー送信:writeLoop
はリクエストの書き込みが完了した後、その結果(エラーの有無)をpc.writeErrCh
に送信するようになりました。これにより、readLoop
が書き込みの完了状態を正確に把握できるようになります。// transport.go func (pc *persistConn) writeLoop() { // ... select { case wr := <-pc.writech: err := wr.req.Request.Write(pc.bw) if err != nil { pc.markBroken() } pc.writeErrCh <- err // to the body reader, which might recycle us wr.ch <- err // to the roundTrip function case <-pc.closech: return } // ... }
3. readLoop
ゴルーチンの変更
-
wroteRequest()
メソッドの導入と利用:readLoop
は、接続をアイドルプールに戻す前に、新しく導入されたpc.wroteRequest()
メソッドを呼び出して、リクエストの書き込みが正常に完了したことを確認するようになりました。wroteRequest()
メソッドは、pc.writeErrCh
からエラーを受け取ることで、書き込みが完了したかどうかを判断します。もしwriteErrCh
にデータがない場合(書き込みがまだ完了していないか、非常に早く読み込みが完了してしまった場合)、50ミリ秒のタイムアウトを設定して再度チャネルからの読み込みを試みます。このタイムアウトは、サーバーが早期に応答を返した場合に、readLoop
がwriteLoop
の完了を不必要に長く待たないようにするためのものです。// transport.go func (pc *persistConn) readLoop() { // ... if alive && !hasBody { // 変更前: if !pc.t.putIdleConn(pc) { alive = false } // 変更後: alive = !pc.sawEOF && pc.wroteRequest() && // ここで書き込み完了をチェック pc.t.putIdleConn(pc) } // ... if waitForBodyRead != nil { select { case alive = <-waitForBodyRead: case <-pc.closech: // closech がクローズされた場合も考慮 alive = false } } // ... } // wroteRequest is a check before recycling a connection that the previous write // (from writeLoop above) happened and was successful. func (pc *persistConn) wroteRequest() bool { select { case err := <-pc.writeErrCh: // Common case: the write happened well before the response, so // avoid creating a timer. return err == nil default: // Rare case: the request was written in writeLoop above but // before it could send to pc.writeErrCh, the reader read it // all, processed it, and called us here. In this case, give the // write goroutine a bit of time to finish its send. // // Less rare case: We also get here in the legitimate case of // Issue 7569, where the writer is still writing (or stalled), // but the server has already replied. In this case, we don't // want to wait too long, and we want to return false so this // connection isn't re-used. select { case err := <-pc.writeErrCh: return err == nil case <-time.After(50 * time.Millisecond): return false } } }
-
bodyEOFSignal
のfn
コールバックのロジック変更: レスポンスボディが読み込まれた後に呼び出されるコールバック関数fn
のロジックも簡素化され、pc.wroteRequest()
の結果が接続の再利用可否に直接影響するように変更されました。// transport.go resp.Body.(*bodyEOFSignal).fn = func(err error) { waitForBodyRead <- alive && err == nil && !pc.sawEOF && pc.wroteRequest() && // ここでも書き込み完了をチェック pc.t.putIdleConn(pc) }
4. テストコードの変更 (src/pkg/net/http/transport_test.go
)
-
TestTransportNoReuseAfterEarlyResponse
の追加: Issue 7569 のシナリオを再現し、サーバーがリクエストボディの完了前に応答を返した場合に、接続が再利用されないことを検証する新しいテストが追加されました。このテストでは、大きなPOSTリクエストを送信し、サーバーがすぐに200 OKを返すように設定します。その後、同じクライアントでGETリクエストを送信し、新しい接続が確立されることを期待します。 -
testConnSet
の同期メカニズムの改善: テストユーティリティtestConnSet
のロックメカニズムがsync.Mutex
からtcs.mu sync.Mutex
に変更され、より明確になりました。また、接続が適切にクローズされたことを確認するためのcheck
メソッドのロジックも改善されています。
コアとなるコードの変更箇所
このコミットにおける主要なコード変更は以下のファイルに集中しています。
src/pkg/net/http/transport.go
:http.Transport
の主要なロジックが実装されているファイル。persistConn
構造体、readLoop
、writeLoop
、closeLocked
、wroteRequest
メソッドが変更されています。src/pkg/net/http/transport_test.go
:http.Transport
のテストコード。新しいテストケースTestTransportNoReuseAfterEarlyResponse
が追加され、既存のテストユーティリティが修正されています。
具体的な変更行数:
src/pkg/net/http/transport.go
: 88行追加、25行削除src/pkg/net/http/transport_test.go
: 133行追加、15行削除
コアとなるコードの解説
transport.go
の変更点
-
persistConn
構造体:writeErrCh chan error
の追加は、writeLoop
とreadLoop
の間の新しい通信経路を確立します。これにより、readLoop
はwriteLoop
の状態を非同期かつ安全に知ることができます。これは、サーバーが早期に応答を返した場合に、readLoop
が接続を再利用可能と判断する前に、writeLoop
がリクエストの書き込みを完了したことを確認するために不可欠です。
-
writeLoop
:pc.writeErrCh <- err
の追加により、writeLoop
がリクエストの書き込み結果をwriteErrCh
に送信するようになりました。これにより、readLoop
はこのチャネルを監視することで、書き込みが成功したか、またはエラーが発生したかを知ることができます。
-
readLoop
:defer close(pc.closech)
の削除: 以前はreadLoop
が終了する際にclosech
をクローズしていましたが、これがデッドロックの原因となる可能性がありました。この変更により、closech
のクローズはpc.closeLocked()
に移され、基盤となる接続がクローズされるタイミングと同期されるようになりました。pc.wroteRequest()
の導入: これは最も重要な変更点の一つです。readLoop
が接続をアイドルプールに戻す前に、wroteRequest()
を呼び出して、前回のリクエストの書き込みが完了したことを確認します。select
ステートメントを使用してpc.writeErrCh
からの読み込みを試みます。default
ケースでは、time.After(50 * time.Millisecond)
を使用して短いタイムアウトを設定します。これは、writeLoop
がまだwriteErrCh
に結果を送信していない場合に、readLoop
が無限に待機するのを防ぐためです。このタイムアウトは、サーバーが早期に応答を返した(Issue 7569)が、writeLoop
がまだ書き込み中であるか、または結果を送信していない場合に、接続の再利用を防ぐための重要なメカニズムです。
waitForBodyRead
のselect
に<-pc.closech
を追加: レスポンスボディの読み込み完了を待つ際に、接続がクローズされた場合(closech
がクローズされた場合)も考慮に入れることで、ゴルーチンがブロックされるのを防ぎます。
-
closeLocked()
:close(pc.closech)
の追加:net.Conn
がクローズされるのと同時にclosech
をクローズすることで、readLoop
がclosech
をクローズするのを待つことによるデッドロックの可能性を排除し、Issue 7620 の修正に貢献します。
transport_test.go
の変更点
TestTransportNoReuseAfterEarlyResponse
: このテストは、コミットが解決しようとしている主要な問題(Issue 7569)を具体的に検証します。- 大きなPOSTリクエストを送信し、サーバーがリクエストボディの完了前に応答を返すシナリオをシミュレートします。
byteFromChanReader
というカスタムio.Reader
を使用して、POSTリクエストの最後のバイトの送信を制御し、サーバーが早期に応答を返すことを可能にします。- 最初のPOSTリクエストの後にGETリクエストを送信し、
Transport
が新しい接続を使用することを確認します。もし古い接続が再利用された場合、テストは失敗します。これにより、Transport
が「奇妙な状態」の接続を再利用しないことが保証されます。
これらの変更は、GoのHTTPクライアントの堅牢性を大幅に向上させ、特定のコーナーケースにおける接続の不健全な状態やゴルーチンリークを防ぐものです。
関連リンク
-
Go Issue 7569: net/http: Transport reuses connection when Request.Write hasn't finished: https://code.google.com/p/go/issues/detail?id=7569 (Google Code Archiveへのリンクですが、当時のIssueの詳細が確認できます)
-
Go Issue 7620: net/http: Transport can leak goroutines: https://code.google.com/p/go/issues/detail?id=7620 (Google Code Archiveへのリンクですが、当時のIssueの詳細が確認できます)
-
Gerrit Change 86290043: https://golang.org/cl/86290043 (GoのコードレビューシステムGerritでのこのコミットの変更履歴)
参考にした情報源リンク
- Go言語の公式ドキュメント:
net/http
パッケージ https://pkg.go.dev/net/http - Go言語のゴルーチンとチャネルに関する公式ドキュメントやチュートリアル https://go.dev/tour/concurrency/1
- HTTP/1.1 Keep-Aliveに関する一般的な情報 https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Connection
- GoのIssueトラッカー (現在はGitHub Issuesに移行) https://github.com/golang/go/issues (ただし、このコミットが修正したIssueはGoogle Code Archiveにあります)
- Goのソースコード (GitHub)
https://github.com/golang/go
(特に
src/net/http/transport.go
とsrc/net/http/transport_test.go
)
# [インデックス 19095] ファイルの概要
このコミットは、Go言語の標準ライブラリ `net/http` パッケージにおける `Transport` の接続再利用ロジックと、それに伴うゴルーチンリークの可能性を修正するものです。特に、クライアントがリクエストボディを完全に書き込む前にサーバーが応答を返した場合の接続の健全性を確保し、また接続のクローズ処理におけるデッドロックの可能性を解消しています。
## コミット
commit 6278a9549288784563bfc9dc2f94cb0031e4ab2f Author: Brad Fitzpatrick bradfitz@golang.org Date: Wed Apr 9 21:50:24 2014 -0700
net/http: don't reuse Transport connection unless Request.Write finished
In a typical HTTP request, the client writes the request, and
then the server replies. Go's HTTP client code (Transport) has
two goroutines per connection: one writing, and one reading. A
third goroutine (the one initiating the HTTP request)
coordinates with those two.
Because most HTTP requests are done when the server replies,
the Go code has always handled connection reuse purely in the
readLoop goroutine.
But if a client is writing a large request and the server
replies before it's consumed the entire request (e.g. it
replied with a 403 Forbidden and had no use for the body), it
was possible for Go to re-select that connection for a
subsequent request before we were done writing the first. That
wasn't actually a data race; the second HTTP request would
just get enqueued to write its request on the writeLoop. But
because the previous writeLoop didn't finish writing (and
might not ever), that connection is in a weird state. We
really just don't want to get into a state where we're
re-using a connection when the server spoke out of turn.
This CL changes the readLoop goroutine to verify that the
writeLoop finished before returning the connection.
In the process, it also fixes a potential goroutine leak where
a connection could close but the recycling logic could be
blocked forever waiting for the client to read to EOF or
error. Now it also selects on the persistConn's close channel,
and the closer of that is no longer the readLoop (which was
dead locking in some cases before). It's now closed at the
same place the underlying net.Conn is closed. This likely fixes
or helps Issue 7620.
Also addressed some small cosmetic things in the process.
Update #7620
Fixes #7569
LGTM=adg
R=golang-codereviews, adg
CC=dsymonds, golang-codereviews, rsc
https://golang.org/cl/86290043
## GitHub上でのコミットページへのリンク
[https://github.com/golang/go/commit/6278a9549288784563bfc9dc2f94cb0031e4ab2f](https://github.com/golang/go/commit/6278a9549288784563bfc9dc2f94cb0031e4ab2f)
## 元コミット内容
`net/http`: `Request.Write` が完了するまで `Transport` 接続を再利用しない
一般的なHTTPリクエストでは、クライアントがリクエストを書き込み、その後サーバーが応答します。GoのHTTPクライアントコード (`Transport`) は、接続ごとに2つのゴルーチンを持っています。1つは書き込み用、もう1つは読み込み用です。3つ目のゴルーチン(HTTPリクエストを開始するゴルーチン)がこれら2つと連携します。
ほとんどのHTTPリクエストはサーバーが応答した時点で完了するため、Goのコードは常に `readLoop` ゴルーチン内で接続の再利用を処理していました。
しかし、クライアントが大きなリクエストを書き込んでいる最中に、サーバーがリクエスト全体を消費する前に応答を返した場合(例えば、403 Forbiddenで応答し、ボディを必要としなかった場合など)、Goは最初の書き込みが完了する前に、その接続を後続のリクエストのために再選択してしまう可能性がありました。これは実際にはデータ競合ではありませんでした。2番目のHTTPリクエストは単に `writeLoop` に書き込みのためにキューに入れられるだけです。しかし、以前の `writeLoop` が書き込みを完了しなかった(そして、おそらく今後も完了しない)ため、その接続は奇妙な状態になっていました。サーバーが予期せず応答を返したときに、接続を再利用する状態になることを避けたかったのです。
この変更は、`readLoop` ゴルーチンが接続を返す前に `writeLoop` が完了したことを検証するように変更します。
この過程で、接続がクローズされたにもかかわらず、クライアントがEOFまたはエラーを読み込むのを永遠に待機してリサイクルロジックがブロックされる可能性のあるゴルーチンリークも修正されました。これにより、`persistConn` の `close` チャネルも選択されるようになり、そのクローザーはもはや `readLoop` ではありません(以前は一部のケースでデッドロックを引き起こしていました)。現在は、基盤となる `net.Conn` がクローズされるのと同じ場所でクローズされます。これは Issue 7620 を修正または改善する可能性があります。
また、いくつかの小さな見た目の修正も行われました。
Update #7620
Fixes #7569
## 変更の背景
このコミットは、主に2つの問題に対処するために行われました。
1. **HTTP接続の不適切な再利用 (Issue 7569)**:
Goの `net/http` パッケージの `Transport` は、HTTP/1.1のKeep-Alive機能を利用して、複数のリクエストで同じTCP接続を再利用することでパフォーマンスを向上させます。しかし、これまでの実装では、クライアントが大きなリクエストボディを送信している途中で、サーバーがそのリクエストボディを完全に読み込む前に応答を返すという特殊なケース(例: サーバーがリクエストボディを必要としないエラー応答、例えば403 Forbiddenを返す場合)において、問題が発生する可能性がありました。
具体的には、`Transport` は接続の再利用を `readLoop` ゴルーチン(サーバーからの応答を読み取る役割)に依存していました。サーバーが早期に応答を返すと、`readLoop` は接続が再利用可能であると判断し、アイドル接続プールに戻してしまう可能性がありました。しかし、この時点では `writeLoop` ゴルーチン(クライアントのリクエストボディを書き込む役割)はまだ書き込みを完了しておらず、場合によっては永遠に完了しない可能性がありました。これにより、後続のリクエストがこの「奇妙な状態」の接続を再利用しようとすると、予期せぬ動作やハングアップが発生する可能性がありました。これはデータ競合ではありませんでしたが、接続の健全性を損なう状態でした。
2. **ゴルーチンリークとデッドロックの可能性 (Issue 7620)**:
既存の接続クローズおよびリサイクルロジックには、ゴルーチンリークの潜在的な問題がありました。接続がクローズされたにもかかわらず、リサイクルロジックがクライアントがEOF(End Of File)またはエラーを読み込むのを永遠に待ち続けることでブロックされる可能性がありました。これは、`readLoop` が `persistConn` の `closech` をクローズする役割を担っていたため、特定のシナリオでデッドロックを引き起こす可能性がありました。
これらの問題は、GoのHTTPクライアントの堅牢性と信頼性に影響を与えるため、修正が必要とされました。
## 前提知識の解説
このコミットを理解するためには、以下のGo言語およびHTTPプロトコルに関する基本的な知識が必要です。
1. **Goのゴルーチンとチャネル**:
* **ゴルーチン (Goroutine)**: Goランタイムによって管理される軽量なスレッドのようなものです。非常に低コストで生成でき、並行処理を実現します。このコミットでは、HTTP接続ごとに `readLoop` (読み込み) と `writeLoop` (書き込み) の2つの主要なゴルーチンが動作していることが言及されています。
* **チャネル (Channel)**: ゴルーチン間で安全にデータを送受信するための通信メカニズムです。チャネルは、ゴルーチン間の同期にも使用されます。このコミットでは、`reqch` (リクエストチャネル)、`writech` (書き込みリクエストチャネル)、`closech` (クローズチャネル)、`writeErrCh` (書き込みエラーチャネル) などが使用されています。
2. **Goの `net/http` パッケージ**:
* **`http.Client`**: HTTPリクエストを送信するためのクライアントです。
* **`http.Transport`**: `http.Client` の内部で使用され、HTTPリクエストの実際の送信、接続の管理、Keep-Alive、プロキシなどの低レベルな詳細を処理します。このコミットの主要な変更対象です。
* **Keep-Alive**: HTTP/1.1の機能で、複数のHTTPリクエスト/レスポンスで同じTCP接続を再利用することを可能にします。これにより、新しいTCP接続を確立するオーバーヘッドが削減され、パフォーマンスが向上します。`Transport` はこのKeep-Alive接続を管理し、アイドル状態の接続をプールします。
* **`persistConn`**: `net/http` パッケージ内部で、Keep-Alive接続を表す構造体です。この構造体が、接続の読み書きゴルーチンやチャネルを管理します。
3. **HTTPプロトコルの基本**:
* **リクエスト/レスポンスサイクル**: クライアントがリクエストを送信し、サーバーがレスポンスを返すという基本的なHTTPのやり取り。
* **リクエストボディ**: POSTやPUTなどのリクエストで、クライアントがサーバーに送信するデータ。
* **レスポンスボディ**: サーバーがクライアントに返すデータ。
* **Content-Lengthヘッダ**: HTTPメッセージボディの長さをバイト単位で示すヘッダ。
4. **デッドロックとゴルーチンリーク**:
* **デッドロック**: 複数のゴルーチンが互いに相手の処理完了を待ち続け、結果としてどのゴルーチンも処理を進められなくなる状態。
* **ゴルーチンリーク**: 終了すべきゴルーチンが何らかの理由で終了せず、リソース(メモリなど)を消費し続ける状態。
## 技術的詳細
このコミットの技術的な変更は、主に `src/pkg/net/http/transport.go` 内の `persistConn` 構造体とその関連メソッド、特に `readLoop` と `writeLoop` に集中しています。
### 1. `persistConn` 構造体の変更
* **`writeErrCh chan error` の追加**:
`persistConn` 構造体に `writeErrCh` という新しいチャネルが追加されました。これは、`writeLoop` ゴルーチンから `readLoop` ゴルーチンへ、リクエストの書き込みが成功したかどうか(エラーが発生したかどうか)を伝えるために使用されます。このチャネルはバッファリングされており、`make(chan error, 1)` で作成されます。これにより、`writeLoop` が書き込みエラーを送信し、`readLoop` がそれを受け取ることで、書き込みの完了状態を同期的に確認できるようになります。
```go
// transport.go
type persistConn struct {
// ... 既存のフィールド ...
writech chan writeRequest // written by roundTrip; read by writeLoop
closech chan struct{} // closed when conn closed
isProxy bool
// writeErrCh passes the request write error (usually nil)
// from the writeLoop goroutine to the readLoop which passes
// it off to the res.Body reader, which then uses it to decide
// whether or not a connection can be reused. Issue 7569.
writeErrCh chan error
// ... 既存のフィールド ...
}
```
* **`closech` の役割変更**:
以前は `readLoop` が `closech` をクローズしていましたが、この変更により `closech` は `persistConn.closeLocked()` メソッド内で、基盤となる `net.Conn` がクローズされるのと同じタイミングでクローズされるようになりました。これにより、`readLoop` が `closech` をクローズするのを待つことによるデッドロックの可能性が排除されます。
```go
// transport.go
// 変更前: defer close(pc.closech) が readLoop にあった
// 変更後: close(pc.closech) が pc.closeLocked() に移動
func (pc *persistConn) closeLocked() {
if !pc.closed {
pc.conn.Close()
pc.closed = true
close(pc.closech) // ここで closech がクローズされる
}
pc.mutateHeaderFunc = nil
}
```
### 2. `writeLoop` ゴルーチンの変更
* **`writeErrCh` へのエラー送信**:
`writeLoop` はリクエストの書き込みが完了した後、その結果(エラーの有無)を `pc.writeErrCh` に送信するようになりました。これにより、`readLoop` が書き込みの完了状態を正確に把握できるようになります。
```go
// transport.go
func (pc *persistConn) writeLoop() {
// ...
select {
case wr := <-pc.writech:
err := wr.req.Request.Write(pc.bw)
if err != nil {
pc.markBroken()
}
pc.writeErrCh <- err // to the body reader, which might recycle us
wr.ch <- err // to the roundTrip function
case <-pc.closech:
return
}
// ...
}
```
### 3. `readLoop` ゴルーチンの変更
* **`wroteRequest()` メソッドの導入と利用**:
`readLoop` は、接続をアイドルプールに戻す前に、新しく導入された `pc.wroteRequest()` メソッドを呼び出して、リクエストの書き込みが正常に完了したことを確認するようになりました。
`wroteRequest()` メソッドは、`pc.writeErrCh` からエラーを受け取ることで、書き込みが完了したかどうかを判断します。もし `writeErrCh` にデータがない場合(書き込みがまだ完了していないか、非常に早く読み込みが完了してしまった場合)、50ミリ秒のタイムアウトを設定して再度チャネルからの読み込みを試みます。このタイムアウトは、サーバーが早期に応答を返した場合に、`readLoop` が `writeLoop` の完了を不必要に長く待たないようにするためのものです。
```go
// transport.go
func (pc *persistConn) readLoop() {
// ...
if alive && !hasBody {
// 変更前: if !pc.t.putIdleConn(pc) { alive = false }
// 変更後:
alive = !pc.sawEOF &&
pc.wroteRequest() && // ここで書き込み完了をチェック
pc.t.putIdleConn(pc)
}
// ...
if waitForBodyRead != nil {
select {
case alive = <-waitForBodyRead:
case <-pc.closech: // closech がクローズされた場合も考慮
alive = false
}
}
// ...
}
// wroteRequest is a check before recycling a connection that the previous write
// (from writeLoop above) happened and was successful.
func (pc *persistConn) wroteRequest() bool {
select {
case err := <-pc.writeErrCh:
// Common case: the write happened well before the response, so
// avoid creating a timer.
return err == nil
default:
// Rare case: the request was written in writeLoop above but
// before it could send to pc.writeErrCh, the reader read it
// all, processed it, and called us here. In this case, give the
// write goroutine a bit of time to finish its send.
//
// Less rare case: We also get here in the legitimate case of
// Issue 7569, where the writer is still writing (or stalled),
// but the server has already replied. In this case, we don't
// want to wait too long, and we want to return false so this
// connection isn't re-used.
select {
case err := <-pc.writeErrCh:
return err == nil
case <-time.After(50 * time.Millisecond):
return false
}
}
}
```
* **`bodyEOFSignal` の `fn` コールバックのロジック変更**:
レスポンスボディが読み込まれた後に呼び出されるコールバック関数 `fn` のロジックも簡素化され、`pc.wroteRequest()` の結果が接続の再利用可否に直接影響するように変更されました。
```go
// transport.go
resp.Body.(*bodyEOFSignal).fn = func(err error) {
waitForBodyRead <- alive &&
err == nil &&
!pc.sawEOF &&
pc.wroteRequest() && // ここでも書き込み完了をチェック
pc.t.putIdleConn(pc)
}
```
### 4. テストコードの変更 (`src/pkg/net/http/transport_test.go`)
* **`TestTransportNoReuseAfterEarlyResponse` の追加**:
Issue 7569 のシナリオを再現し、サーバーがリクエストボディの完了前に応答を返した場合に、接続が再利用されないことを検証する新しいテストが追加されました。このテストでは、大きなPOSTリクエストを送信し、サーバーがすぐに200 OKを返すように設定します。その後、同じクライアントでGETリクエストを送信し、新しい接続が確立されることを期待します。
* **`testConnSet` の同期メカニズムの改善**:
テストユーティリティ `testConnSet` のロックメカニズムが `sync.Mutex` から `tcs.mu sync.Mutex` に変更され、より明確になりました。また、接続が適切にクローズされたことを確認するための `check` メソッドのロジックも改善されています。
## コアとなるコードの変更箇所
このコミットにおける主要なコード変更は以下のファイルに集中しています。
* `src/pkg/net/http/transport.go`: `http.Transport` の主要なロジックが実装されているファイル。`persistConn` 構造体、`readLoop`、`writeLoop`、`closeLocked`、`wroteRequest` メソッドが変更されています。
* `src/pkg/net/http/transport_test.go`: `http.Transport` のテストコード。新しいテストケース `TestTransportNoReuseAfterEarlyResponse` が追加され、既存のテストユーティリティが修正されています。
具体的な変更行数:
* `src/pkg/net/http/transport.go`: 88行追加、25行削除
* `src/pkg/net/http/transport_test.go`: 133行追加、15行削除
## コアとなるコードの解説
### `transport.go` の変更点
1. **`persistConn` 構造体**:
* `writeErrCh chan error` の追加は、`writeLoop` と `readLoop` の間の新しい通信経路を確立します。これにより、`readLoop` は `writeLoop` の状態を非同期かつ安全に知ることができます。これは、サーバーが早期に応答を返した場合に、`readLoop` が接続を再利用可能と判断する前に、`writeLoop` がリクエストの書き込みを完了したことを確認するために不可欠です。
2. **`writeLoop`**:
* `pc.writeErrCh <- err` の追加により、`writeLoop` がリクエストの書き込み結果を `writeErrCh` に送信するようになりました。これにより、`readLoop` はこのチャネルを監視することで、書き込みが成功したか、またはエラーが発生したかを知ることができます。
3. **`readLoop`**:
* `defer close(pc.closech)` の削除: 以前は `readLoop` が終了する際に `closech` をクローズしていましたが、これがデッドロックの原因となる可能性がありました。この変更により、`closech` のクローズは `pc.closeLocked()` に移され、基盤となる接続がクローズされるタイミングと同期されるようになりました。
* `pc.wroteRequest()` の導入: これは最も重要な変更点の一つです。`readLoop` が接続をアイドルプールに戻す前に、`wroteRequest()` を呼び出して、前回のリクエストの書き込みが完了したことを確認します。
* `select` ステートメントを使用して `pc.writeErrCh` からの読み込みを試みます。
* `default` ケースでは、`time.After(50 * time.Millisecond)` を使用して短いタイムアウトを設定します。これは、`writeLoop` がまだ `writeErrCh` に結果を送信していない場合に、`readLoop` が無限に待機するのを防ぐためです。このタイムアウトは、サーバーが早期に応答を返した(Issue 7569)が、`writeLoop` がまだ書き込み中であるか、または結果を送信していない場合に、接続の再利用を防ぐための重要なメカニズムです。
* `waitForBodyRead` の `select` に `<-pc.closech` を追加: レスポンスボディの読み込み完了を待つ際に、接続がクローズされた場合(`closech` がクローズされた場合)も考慮に入れることで、ゴルーチンがブロックされるのを防ぎます。
4. **`closeLocked()`**:
* `close(pc.closech)` の追加: `net.Conn` がクローズされるのと同時に `closech` をクローズすることで、`readLoop` が `closech` をクローズするのを待つことによるデッドロックの可能性を排除し、Issue 7620 の修正に貢献します。
### `transport_test.go` の変更点
* **`TestTransportNoReuseAfterEarlyResponse`**:
このテストは、コミットが解決しようとしている主要な問題(Issue 7569)を具体的に検証します。
* 大きなPOSTリクエストを送信し、サーバーがリクエストボディの完了前に応答を返すシナリオをシミュレートします。
* `byteFromChanReader` というカスタム `io.Reader` を使用して、POSTリクエストの最後のバイトの送信を制御し、サーバーが早期に応答を返すことを可能にします。
* 最初のPOSTリクエストの後にGETリクエストを送信し、`Transport` が新しい接続を使用することを確認します。もし古い接続が再利用された場合、テストは失敗します。これにより、`Transport` が「奇妙な状態」の接続を再利用しないことが保証されます。
これらの変更は、GoのHTTPクライアントの堅牢性を大幅に向上させ、特定のコーナーケースにおける接続の不健全な状態やゴルーチンリークを防ぐものです。
## 関連リンク
* **Go Issue 7569: net/http: Transport reuses connection when Request.Write hasn't finished**:
[https://code.google.com/p/go/issues/detail?id=7569](https://code.google.com/p/go/issues/detail?id=7569)
(Google Code Archiveへのリンクですが、当時のIssueの詳細が確認できます)
* **Go Issue 7620: net/http: Transport can leak goroutines**:
[https://code.google.com/p/go/issues/detail?id=7620](https://code.google.com/p/go/issues/detail?id=7620)
(Google Code Archiveへのリンクですが、当時のIssueの詳細が確認できます)
* **Gerrit Change 86290043**:
[https://golang.org/cl/86290043](https://golang.org/cl/86290043)
(GoのコードレビューシステムGerritでのこのコミットの変更履歴)
## 参考にした情報源リンク
* Go言語の公式ドキュメント: `net/http` パッケージ
[https://pkg.go.dev/net/http](https://pkg.go.dev/net/http)
* Go言語のゴルーチンとチャネルに関する公式ドキュメントやチュートリアル
[https://go.dev/tour/concurrency/1](https://go.dev/tour/concurrency/1)
* HTTP/1.1 Keep-Aliveに関する一般的な情報
[https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Connection](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Connection)
* GoのIssueトラッカー (現在はGitHub Issuesに移行)
[https://github.com/golang/go/issues](https://github.com/golang/go/issues)
(ただし、このコミットが修正したIssueはGoogle Code Archiveにあります)
* Goのソースコード (GitHub)
[https://github.com/golang/go](https://github.com/golang/go)
(特に `src/net/http/transport.go` と `src/net/http/transport_test.go`)