TL; DR;
- Golangのnet/http実装からHTTP1.1クライアント挙動の疑問を解消しました。
- http.Transportにはmaxディレクティブのような利用回数上限はなく、idleConnTimeoutよりも前にコネクションを再利用し続けることでコネクションを維持できます。
- HTTPリクエストをcontextでキャンセルすると、そのコネクションは再利用されず切断されます。
- Response.BodyをEOFまで読まなかったりCloseを忘れると下の表のような挙動になります。
Keep-Alive無効 | Keep-Alive有効 | |
---|---|---|
EOFまで読まずCloseもしない | 切断処理の途中でスタックしコネクションが残り続ける | コネクションが残り続けてkeep-aliveされない |
EOFまで読むがCloseはしない | コネクションは切断される | keep-aliveされる |
EOFまで読まずCloseはする | コネクションは切断される | コネクションが切断されてkeep-aliveされない |
EOFまで読みCloseする | コネクションは切断される | keep-aliveされる |
はじめに
先日、Golangを利用したアプリケーションによるHTTPリクエストで発生したNW起因エラーの調査をしていたのですが、細かいところまで深く考え始めると「こういうときってTCPコネクションって再利用されるんだっけ…」「なんでこうなるんだっけ…」と気になり始めました。
この記事は、これを機にGolangのnet/httpのコードリーディングを行い実装に基づいて様々な挙動を調べてみた結果を整理する目的で執筆しています。 具体的には、
- Transport.idleConnTimeoutよりも前にコネクションを再利用し続けるとどうなるのか?
- HTTPのkeep-aliveヘッダにはmaxディレクティブがあり利用回数の上限を設定できますが、golangのHTTPクライアントでは上限があるのか?
- contextによってHTTPリクエストをキャンセルするとTCPコネクションは再利用されるのか?
- Response.BodyはEOFまで読んだ上でCloseしなくてはいけないとよく言われるけどやらないとどうなるのか?
という疑問です。
この記事では、はじめにnet/httpでのHTTPクライアントがどのように実装されているのかを解説します。 その後で、それを元に上記の疑問を実装ベース・実際の挙動ベースの両方から検証していきます。
実装の解説は細かめになるので、頭からじっくり読んでもいいですし、各疑問に飛んで読んでもいいです。
net/httpでのHTTPクライアントの実装
今回参考にした実装はGolang 1.24.1のものです。 詳細な実装の説明に入る前に、net/httpでのHTTPクライアントの主要な型の関係を図にまとめておきます。
RequestやResponseは読んで字のごとくリクエストやレスポンスそのものを表す型ですが、Client/RoundTripper/Transport/net.Dialerなどは初見ではよくわからないと思います。
Clientは、HTTPクライアントそのものです。 単純にリクエストを送る場合にhttp.Get()を使うことがあるかと思いますが、内部ではClient型のデフォルト実装として用意されているDefaultClientが使われています。
とはいってもClientが直接HTTPリクエストを送るわけではなく、RoundTripperインタフェースを実装したTransportフィールドが直接的なHTTPリクエストを送信しています。 少しややこしいですが、Transportフィールドを特に指定しなかった場合、RoundTripperインタフェースを実装したTransport型のデフォルト実装であるDefaultTransportが使われています。 Transportというフィールド名にTransport型を入れていますが、あくまでもRoundTripperインタフェースを実装している型を指定しているだけです。
type RoundTripper interface {
// ...
RoundTrip(*Request) (*Response, error)
}
RoundTripperインタフェースは上記のように単独のHTTPリクエストを行うRoundTripメソッドを持つだけのインタフェースです。 そのため、内部でコネクションプールを実装することも可能ですし、もしかすると鳩通信プロトコルを使ってリクエストを送る実装もあるかもしれません。
このように、HTTPクライアントで使われるTCPコネクションの挙動はRoundTripperとして具体的に何が使われるかに依存するため、全てのHTTPクライアントの挙動を説明することはできませんが、それだとこの記事の意味がなくなってしまうのでこれ以降では多くの場合に使われるTransport型を前提として説明します。 Transport型ではRoundTripメソッドはroundTripメソッドのラッパーとなっているため、Transport型のroundTripメソッドを見ればHTTPクライアントの挙動を理解できると言えます。
それではいよいよTransport.roundTripメソッドを起点にHTTPリクエスト時の挙動をみていきましょう。 以下の図がTransport.roundTripメソッドの処理の流れです。
曼荼羅のようですが、大雑把に分けると以下のようにコネクションプール管理・TCPコネクション生成・TCPコネクションによるHTTP読み書きに分けられます。 つまり、プールされたもしくは新たに生成されたTCPコネクションを取得し、そのコネクションに対してHTTPリクエスト発行を要求・その結果を受信することでリクエストが行われます。
実際のコードは下のようになっています。 Transport.getConn()でTCPコネクション(persistConn型)を取得し、persistConn.roundTrip()でHTTPリクエストを行ってResponseを受け取ります。
// roundTrip implements a RoundTripper over HTTP.
func (t *Transport) roundTrip(req *Request) (_ *Response, err error) {
...
// forとなっているのはリトライのため
for{
...
pconn, err := t.getConn(treq, cm)
...
var resp *Response
if pconn.alt != nil {
...
} else {
resp, err = pconn.roundTrip(treq)
}
if err == nil {
...
return resp, nil
}
...
}
}
cf. https://cs.opensource.google/go/go/+/refs/tags/go1.24.1:src/net/http/transport.go
Transport.getConn()では、TCPコネクションの獲得要求(wantConn型)をTransport.queueForIdleConn()でコネクションプールに対して行い、コネクションがプールされていればそのコネクションを利用し、プールされていなければTransport.queueForDial()で新たにTCPコネクションを生成する要求(wantConn型)を発行します。
プールされていなかった場合、新たに作成するTCPコネクションを待ってもいいですし他のリクエストで使われていたコネクションがidleになったらそれを使ってもよいようになっているため、チャネル(wantConn.result)を使ってTCPコネクションの取得結果を待ちます。
func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (_ *persistConn, err error) {
...
w := &wantConn{
cm: cm,
key: cm.key(),
ctx: dialCtx,
cancelCtx: dialCancel,
result: make(chan connOrError, 1),
beforeDial: testHookPrePendingDial,
afterDial: testHookPostPendingDial,
}
// コネクションプールに対してコネクション取得要求を発行
if delivered := t.queueForIdleConn(w); !delivered {
// プールされていない場合新たにTCPコネクションを生成
t.queueForDial(w)
}
// コネクション取得結果を待つ
select {
case r := <-w.result:
...
return r.pc, r.err
...
}
}
cf. https://cs.opensource.google/go/go/+/refs/tags/go1.24.1:src/net/http/transport.go
Transport.queueForIdleConn()では、コネクションプールからコネクションを取得するか、プールされていない場合はコネクション待ち行列に追加します。 また、HTTP Keep-Aliveが無効な場合にはプールという概念がないため、常に「プールから取得できなかった」とみなします。
この際、idleコネクションの最も新しいものからチェック・取得されます。
func (t *Transport) queueForIdleConn(w *wantConn) (delivered bool) {
// Keep-Alive無効時にはプールという概念がない
if t.DisableKeepAlives {
return false
}
...
if list, ok := t.idleConn[w.key]; ok {
...
// Keep-Alive有効の時にはidleコネクションの最も新しいものからチェック
for len(list) > 0 && !stop {
pconn := list[len(list)-1]
...
delivered = w.tryDeliver(pconn, nil, pconn.idleAt)
if delivered {
if pconn.alt != nil {
...
} else {
...
t.idleLRU.remove(pconn)
list = list[:len(list)-1]
}
}
stop = true
}
...
// プールからコネクションを取得できたので早期リターン
if stop {
return delivered
}
}
...
// 利用可能なidleコネクションがない場合にはidleコネクション待ち行列に追加
q := t.idleConnWait[w.key]
...
q.pushBack(w)
...
return false
}
cf. https://cs.opensource.google/go/go/+/refs/tags/go1.24.1:src/net/http/transport.go
wantConn.tryDeliver()では、コネクションをチャネルに流し、Transport.getConn()に処理を戻します。 Transport.queueForIdleConn()ではプールから取得できた場合に即座に呼ばれる他、後ほど説明するTCPコネクション生成時にも呼ばれます。
func (w *wantConn) tryDeliver(pc *persistConn, err error, idleAt time.Time) bool {
...
w.result <- connOrError{pc: pc, err: err, idleAt: idleAt}
close(w.result)
return true
}
cf. https://cs.opensource.google/go/go/+/refs/tags/go1.24.1:src/net/http/transport.go
さて、Keep-Aliveが無効であったりプールからコネクションを取得できなかった場合には、新たにTCPコネクションを生成する必要があります。 これを行うのがTransport.getConn()から呼ばれるTransport.queueForDial()です。
この関数では、Transport.MaxConnsPerHostに設定されたホストあたりの最大接続数に達していない場合には新たにTCPコネクションを生成し、達している場合には待ち行列に追加します。
func (t *Transport) queueForDial(w *wantConn) {
// コネクションの最大接続数が設定されていない場合には接続を開始する
if t.MaxConnsPerHost <= 0 {
t.startDialConnForLocked(w)
return
}
// コネクションの最大接続数に達していない場合には接続を開始する
if n := t.connsPerHost[w.key]; n < t.MaxConnsPerHost {
...
t.connsPerHost[w.key] = n + 1
t.startDialConnForLocked(w)
return
}
// コネクションの最大接続数に達している場合には待ち行列に入れる
...
q := t.connsPerHostWait[w.key]
...
q.pushBack(w)
...
}
cf. https://cs.opensource.google/go/go/+/refs/tags/go1.24.1:src/net/http/transport.go
実際のTCPコネクション生成は複数の関数が直列に呼ばれることでなされますが、重要なのは2点で、
- Transport.DialContext()でTCPコネクションを生成
- 生成したTCPコネクションをpersistConn型にセットしreadLoop/writeLoopをgoroutineで起動
です。
特に、persistConn.readLoop()とpersistConn.writeLoop()はTCPコネクションの読み書きを実際に行うための関数となっており、persistConn.roundTrip()とチャネル経由でやり取りを行います。
func (t *Transport) startDialConnForLocked(w *wantConn) {
...
go func() {
t.dialConnFor(w)
...
}()
}
func (t *Transport) dialConnFor(w *wantConn) {
...
pc, err := t.dialConn(ctx, w.cm)
delivered := w.tryDeliver(pc, err, time.Time{})
...
}
func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *persistConn, err error) {
pconn = &persistConn{
...
}
...
if cm.scheme() == "https" && t.hasCustomTLSDialer() {
...
} else {
conn, err := t.dial(ctx, "tcp", cm.addr())
...
pconn.conn = conn
...
}
...
go pconn.readLoop()
go pconn.writeLoop()
return pconn, nil
}
func (t *Transport) dial(ctx context.Context, network, addr string) (net.Conn, error) {
if t.DialContext != nil {
c, err := t.DialContext(ctx, network, addr)
...
return c, err
}
...
return zeroDialer.DialContext(ctx, network, addr)
}
cf. https://cs.opensource.google/go/go/+/refs/tags/go1.24.1:src/net/http/transport.go
実際のコードは下のようになっており、persistConn.writech/reqch経由でreadLoop/writeLoopに要求を送信し、チャネルからレスポンスを受け取ります。
func (pc *persistConn) roundTrip(req *transportRequest) (resp *Response, err error) {
...
// persistConn.writeLoop()にリクエストを送信
pc.writech <- writeRequest{req, writeErrCh, continueCh}
// persistConn.readLoop()にレスポンス要求を送信
resc := make(chan responseAndError)
pc.reqch <- requestAndChan{
...
ch: resc,
...
}
handleResponse := func(re responseAndError) (*Response, error) {
...
return re.res, nil
}
...
for {
...
select {
...
// persistConn.readLoop()からのレスポンスを受信
case re := <-resc:
return handleResponse(re)
...
}
}
}
cf. https://cs.opensource.google/go/go/+/refs/tags/go1.24.1:src/net/http/transport.go
persistConn.writeLoop()では、persistConn.writechに送られたリクエスト送信要求をひたすら送信し続けます。
func (pc *persistConn) writeLoop() {
...
for {
select {
case wr := <-pc.writech:
...
err := wr.req.Request.write(pc.bw, pc.isProxy, wr.req.extra, pc.waitForContinue(wr.continueCh))
...
if err != nil {
pc.close(err)
return
}
case <-pc.closech:
return
}
}
}
cf. https://cs.opensource.google/go/go/+/refs/tags/go1.24.1:src/net/http/transport.go
persistConn.readLoop()では、persistConn.reqchに送られたレスポンス要求を受信し、実際にコネクションからHTTPレスポンスを取得しレスポンス要求に応えます。 また、適切なタイミング(後述)でTransport.tryPutIdleConn()を呼び出すことで、このコネクションをidleコネクションプールに戻します。
func (pc *persistConn) readLoop() {
...
// idleコネクションプールへ戻すための一時関数
tryPutIdleConn := func(treq *transportRequest) bool {
...
if err := pc.t.tryPutIdleConn(pc); err != nil {
...
return false
}
...
return true
}
...
for alive {
...
// persistConn.roundTrip()からのレスポンス要求を受信
rc := <-pc.reqch
...
var resp *Response
if err == nil {
// 実際にコネクションからHTTPレスポンスを取得
resp, err = pc.readResponse(rc, trace)
} else {
...
}
...
select {
// persistConn.roundTrip()にHTTPレスポンス要求への返答を送信
case rc.ch <- responseAndError{res: resp}:
...
}
// 適宜tryPutIdleConn()が呼ばれる
...
}
}
cf. https://cs.opensource.google/go/go/+/refs/tags/go1.24.1:src/net/http/transport.go
transport.tryPutIdleConn()では、コネクションをidleコネクションプールに戻します。
func (t *Transport) tryPutIdleConn(pconn *persistConn) error {
...
// idleコネクション待ち行列にコネクションを渡す
key := pconn.cacheKey
if q, ok := t.idleConnWait[key]; ok {
done := false
if pconn.alt == nil {
...
for q.len() > 0 {
w := q.popFront()
if w.tryDeliver(pconn, nil, time.Time{}) {
done = true
break
}
}
} else {
...
}
...
}
...
// idleコネクション数上限を超えていた場合には最古のコネクションを切断
t.idleConn[key] = append(idles, pconn)
t.idleLRU.add(pconn)
if t.MaxIdleConns != 0 && t.idleLRU.len() > t.MaxIdleConns {
oldest := t.idleLRU.removeOldest()
oldest.close(errTooManyIdle)
t.removeIdleConnLocked(oldest)
}
// idleTimeoutをリセットする
if t.IdleConnTimeout > 0 && pconn.alt == nil {
if pconn.idleTimer != nil {
pconn.idleTimer.Reset(t.IdleConnTimeout)
} else {
pconn.idleTimer = time.AfterFunc(t.IdleConnTimeout, pconn.closeConnIfStillIdle)
}
}
pconn.idleAt = time.Now()
return nil
}
cf. https://cs.opensource.google/go/go/+/refs/tags/go1.24.1:src/net/http/transport.go
一通りnet/httpのHTTPクライアントの実装を見てきましたが、この実装を元に疑問を解消していきます。
疑問1: Transport.idleConnTimeoutよりも前に再利用し続けるとどうなるのか?
HTTP Keep-Alive関連の設定として、ClientのidleConnTimeoutフィールドのドキュメントには、以下のように継続してidleである時間がこの設定を超えるとコネクションが切断されると書かれています。
// IdleConnTimeout is the maximum amount of time an idle
// (keep-alive) connection will remain idle before closing
// itself.
// Zero means no limit.
IdleConnTimeout time.Duration
しかし、Keep-Aliveヘッダのmaxディレクティブのようなコネクションの利用回数の上限や、idleであったかにかかわらずコネクションの生存期間で制限はないのでしょうか? ドキュメントを見る限りそのような制限の設定はないように見えますが、実際のところを確かめてみます。
実験用にHTTPサーバとHTTPクライアントを用意し、HTTPクライアントからHTTPサーバに定期的にリクエストを送信するようにします。 クライアントではIdleConnTimeoutを90sに設定し、クライアントの自発的な挙動を確かめるためにサーバではHTTP Keep-Aliveタイムアウトを無限に設定します。
// client/main.go
package main
...
func main() {
client := &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 1,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
fmt.Printf("Error creating request: %v\n", err)
return
}
go func() {
for {
doReq(client, req)
time.Sleep(5 * time.Second)
}
}()
...
}
func doReq(client *http.Client, req *http.Request) {
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Error making GET request: %v\n", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading response body: %v\n", err)
return
}
...
}
// server/main.go
package main
...
func main() {
...
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("Hello, World!"))
})
server := &http.Server{
Addr: ":8080",
Handler: http.DefaultServeMux,
IdleTimeout: 0 * time.Second,
}
listener, err := net.Listen("tcp", server.Addr)
if err != nil {
fmt.Printf("Error creating listener: %v\n", err)
return
}
if err := server.Serve(listener); err != nil {
fmt.Printf("Server error: %v\n", err)
}
...
}
実際に試してみると、しばらく(1h以上経過させてみた)経ってもHTTPリクエスト時に新たなTCPコネクションが生成されることはなく、同じコネクションが再利用され続けることが確認できました。
実機での挙動を確認したので、次は実装上でも同じ結論となるかを見ていきます。
Transport.tryPutIdleConn()では利用していたコネクションをidleコネクションプールに戻す際に、IdleConnTimeoutだけ経った後にpersistConn.closeConnIfStillIdle()を呼び出すタイマーをセット/リセットしています。 タイマーが発火する前にコネクションが使用され再びidleコネクションプールに戻されるとタイマーはリセットされます。 また、コネクション使用中にタイマーが発火した場合でも、この関数がコネクションを切断するのはidleの場合のみなので切断はされません。
func (t *Transport) tryPutIdleConn(pconn *persistConn) error {
...
if t.IdleConnTimeout > 0 && pconn.alt == nil {
if pconn.idleTimer != nil {
pconn.idleTimer.Reset(t.IdleConnTimeout)
} else {
pconn.idleTimer = time.AfterFunc(t.IdleConnTimeout, pconn.closeConnIfStillIdle)
}
}
pconn.idleAt = time.Now()
return nil
}
func (pc *persistConn) closeConnIfStillIdle() {
...
if _, ok := t.idleLRU.m[pc]; !ok {
// Not idle.
return
}
...
}
つまり、IdleConnTimeoutよりも前に再利用し続ければ、コネクションは切断されることはないということです。
疑問2: HTTPリクエストをcontextでキャンセルするとTCPコネクションは再利用されるのか?
本格的なアプリケーションを作る際には、タイムアウトなどによってHTTPリクエストをキャンセルすることがあります。 その際にはcontextのキャンセルを使うことが多いかと思いますが、この場合にTCPコネクションは再利用されるのでしょうか?
まずは実際に試してみます。 疑問1とほぼ同じコードにリクエストのキャンセルを行うためにcontextを追加し、1sでリクエストをキャンセルする処理を追加しました。
func doReq(client *http.Client, req *http.Request) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Error making GET request: %v\n", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading response body: %v\n", err)
return
}
...
}
予想ではキャンセルされたらコネクションはidleコネクションプールに戻るだろうと思っていましたが、実際に試してみるとコネクションは切断されました。
さらに、リクエストをキャンセルしたタイミングではサーバー側からのFINは送られず、レスポンスが送られた後でFINが送られていました。
後者の挙動についてはサーバー側の実装に起因するので取り上げませんが、前者の挙動については実装を見ていきます。
persistConn.roundTrip()においては、レスポンスを待つselect-caseにおいて、context.Done()を受け取ると、persistConn.cancelRequest()が呼ばれます。
func (pc *persistConn) roundTrip(req *transportRequest) (resp *Response, err error) {
...
ctxDoneChan := req.ctx.Done()
pcClosed := pc.closech
for {
...
select {
...
case re := <-resc:
return handleResponse(re)
case <-ctxDoneChan:
...
pc.cancelRequest(context.Cause(req.ctx))
}
}
}
persistConn.cancelRequest()では、persistConn.closeLocked()によってTCPコネクションが切断されています。
func (pc *persistConn) cancelRequest(err error) {
...
pc.closeLocked(errRequestCanceled)
}
func (pc *persistConn) closeLocked(err error) {
...
if pc.closed == nil {
...
// Close HTTP/1 (pc.alt == nil) connection.
// HTTP/2 closes its connection itself.
if pc.alt == nil {
if err != errCallerOwnsConn {
pc.conn.Close()
}
close(pc.closech)
}
}
...
}
つまり、コネクションがidleコネクションプールに戻ることはなく、キャンセルされたリクエストに対してはコネクションが切断されることがわかりました。
疑問3: Response.BodyはEOFまで読んだ上でCloseする必要があるとされるがやらないとどうなるのか?
公式ドキュメントの記述では、Response.Bodyは必ずEOFまで読んだ上でCloseする必要があるとされており、その理由としてコネクションの再利用が妨げられるからとしています。
// ...
// The http Client and Transport guarantee that Body is always
// non-nil, even on responses without a body or responses with
// a zero-length body. It is the caller's responsibility to
// close Body. The default HTTP client's Transport may not
// reuse HTTP/1.x "keep-alive" TCP connections if the Body is
// not read to completion and closed.
// ...
Body io.ReadCloser
cf. http package - net/http - Go Packages
他の記事でもこの点については注意するべき点として挙げられている他、実装面から見ている記事もありました。 そこでこの記事ではより詳細まで踏み込んで、ネットワークレイヤにまで踏み込んでこの挙動を確認してみます。 またKeep-Aliveのオンオフ・EOFまで読むか・BodyをCloseするかという条件を網羅的に検証してみます。
そのために、EOFまで読むか、BodyをCloseするかという条件を変えた4つの関数を用意し、さらにClient側でKeep-Aliveを有効にするかどうかも変えて検証します。 つまり条件としては合計8通りとなりますが、このうちEOFまで読んだ上でCloseをするものについてはいわば正常系であり特筆すべき点はないため省略し、最終的に6通りについて検証します
// EOFまで読んでCloseする
func doReq(client *http.Client, req *http.Request) {
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Error making GET request: %v\n", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading response body: %v\n", err)
return
}
...
}
// EOFまで読まずCloseもしない
func doReqNotReadEOFWithoutClose(client *http.Client, req *http.Request) {
_, err := client.Do(req)
if err != nil {
fmt.Printf("Error making GET request: %v\n", err)
return
}
}
// EOFまで読んでCloseしない
func doReqWithoutClose(client *http.Client, req *http.Request) {
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Error making GET request: %v\n", err)
return
}
_, err = io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading response body: %v\n", err)
return
}
}
// EOFまで読まずCloseはする
func doReqNotReadEOF(client *http.Client, req *http.Request) {
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Error making GET request: %v\n", err)
return
}
defer resp.Body.Close()
}
3-1: Keep-Alive有効・EOFまで読まずCloseもしない
公式ドキュメントでも警告されている通り、この条件ではコネクションの再利用が妨げられています。
さらに、パケットキャプチャやソケットの状態をみると、使いまわされなかったコネクションは接続されたまま残り続けていることがわかります。
これはサーバー側・クライアント側ともにファイルディスクリプタややリソースを消費し続けることに繋がってしまいます。
❯ netstat -an -p tcp -f inet | grep 8080
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52239 ESTABLISHED
tcp4 0 0 10.18.53.59.52239 10.18.53.59.8080 ESTABLISHED
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52238 ESTABLISHED
tcp4 0 0 10.18.53.59.52238 10.18.53.59.8080 ESTABLISHED
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52237 ESTABLISHED
tcp4 0 0 10.18.53.59.52237 10.18.53.59.8080 ESTABLISHED
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52236 ESTABLISHED
tcp4 0 0 10.18.53.59.52236 10.18.53.59.8080 ESTABLISHED
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52235 ESTABLISHED
tcp4 0 0 10.18.53.59.52235 10.18.53.59.8080 ESTABLISHED
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52234 ESTABLISHED
tcp4 0 0 10.18.53.59.52234 10.18.53.59.8080 ESTABLISHED
tcp46 0 0 *.8080 *.* LISTEN
3-2: Keep-Alive有効・EOFまで読んでCloseはしない
それではCloseはしないもののEOFまではしっかりと読む場合はどうでしょうか?
実際に試してみると、どうもコネクションはしっかりと再利用されているようです。
❯ netstat -an -p tcp -f inet | grep 8080
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52300 ESTABLISHED
tcp4 0 0 10.18.53.59.52300 10.18.53.59.8080 ESTABLISHED
tcp46 0 0 *.8080 *.* LISTEN
3-3: Keep-Alive有効・EOFまで読まずCloseはする
逆にEOFまでは読まないもののCloseはする場合をみてみましょう。
EOFまで読まずCloseしない場合と似たように、コネクションの再利用は妨げられているようですが、コネクションが残り続けるということはないようです。
❯ netstat -an -p tcp -f inet | grep 8080
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52268 ESTABLISHED
tcp4 0 0 10.18.53.59.52268 10.18.53.59.8080 ESTABLISHED
tcp46 0 0 *.8080 *.* LISTEN
tcp4 0 0 10.18.53.59.52265 10.18.53.59.8080 TIME_WAIT
tcp4 0 0 10.18.53.59.52266 10.18.53.59.8080 TIME_WAIT
tcp4 0 0 10.18.53.59.52267 10.18.53.59.8080 TIME_WAIT
ここまでの結果をみる限りでは、Keep-Aliveが有効である場合にはEOFまで読むことでコネクションが適切に再利用されるようです。
3-4: Keep-Alive無効・EOFまで読まずCloseもしない
ここからは、Keep-Aliveを無効にした場合について検証していきます。 EOFまで読まずCloseもしない場合については、コネクションが残り続けることがわかります。 ただし残り続けるといってもESTABLISHED状態ではなく、サーバー側ではFIN_WAIT_2状態・クライアント側ではCLOSE_WAIT状態となっていることがわかります。
これはつまり、クライアント側がコネクションの切断をしないままになっていることを示しています。
❯ netstat -an -p tcp -f inet | grep 8080
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52356 FIN_WAIT_2
tcp4 0 0 10.18.53.59.52356 10.18.53.59.8080 CLOSE_WAIT
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52355 FIN_WAIT_2
tcp4 0 0 10.18.53.59.52355 10.18.53.59.8080 CLOSE_WAIT
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52354 FIN_WAIT_2
tcp4 0 0 10.18.53.59.52354 10.18.53.59.8080 CLOSE_WAIT
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52353 FIN_WAIT_2
tcp4 0 0 10.18.53.59.52353 10.18.53.59.8080 CLOSE_WAIT
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52352 FIN_WAIT_2
tcp4 0 0 10.18.53.59.52352 10.18.53.59.8080 CLOSE_WAIT
tcp46 0 0 *.8080 *.* LISTEN
3-5: Keep-Alive無効・EOFまで読んでCloseはしない
CloseをしないもののEOFまで読む場合については、サーバー側・クライアント側ともに切断に至っており、コネクションがそのままになることはないようです。
❯ netstat -an -p tcp -f inet | grep 8080
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52399 ESTABLISHED
tcp4 0 0 10.18.53.59.52399 10.18.53.59.8080 ESTABLISHED
tcp46 0 0 *.8080 *.* LISTEN
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52395 TIME_WAIT
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52396 TIME_WAIT
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52398 TIME_WAIT
3-6: Keep-Alive無効・EOFまで読まずCloseはする
逆の場合を試してみます。
コネクションの再利用が妨げられていたKeep-Alive有効の場合とは異なり、正しい挙動をしているようです。
❯ netstat -an -p tcp -f inet | grep 8080
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52419 ESTABLISHED
tcp4 0 0 10.18.53.59.52419 10.18.53.59.8080 ESTABLISHED
tcp46 0 0 *.8080 *.* LISTEN
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52416 TIME_WAIT
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52417 TIME_WAIT
tcp4 0 0 10.18.53.59.8080 10.18.53.59.52418 TIME_WAIT
Keep-Aliveが無告である場合には、EOFまで読むかCloseをするかのどちらかをすることでコネクションは適切に切断されるようです。
3-まとめ
まとめると以下のような挙動となりました。 とりあえずはEOFまで読んでおけば所望の挙動が達成されるようで、CloseするのはEOFまで読まなかった場合のコネクションリークを防ぐ効果があるようです。
Keep-Alive無効 | Keep-Alive有効 | |
---|---|---|
EOFまで読まずCloseもしない | 切断処理の途中でスタックしコネクションが残り続ける | コネクションが残り続けてkeep-aliveされない |
EOFまで読むがCloseはしない | コネクションは切断される | keep-aliveされる |
EOFまで読まずCloseはする | コネクションは切断される | コネクションが切断されてkeep-aliveされない |
EOFまで読みCloseする | コネクションは切断される | keep-aliveされる |
3-実装
実機の挙動を確認したので、次は実装上でも同じ結論となるかを見ていきます。 Response.BodyのCloseやEOFまでの読み込みについては、persistConn.readLoop()内をみるのが良いです。
どうもResponse.Bodyとしてセットされているのは、bodyEOFSignalという構造体のようです。
func (pc *persistConn) readLoop() {
...
alive := true
for alive {
...
waitForBodyRead := make(chan bool, 2)
body := &bodyEOFSignal{
...
}
resp.Body = body
...
}
}
この構造体はio.ReadCloserを実装しており、Read()でEOF含むエラーが発生した場合・Close()された場合にfnフィールドの関数が最大1回呼ばれます。 また、EOFエラーが発生する前にClose()された場合には代わりにearlyCloseFnフィールドの関数が呼ばれます。
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 // err will be nil on Read io.EOF
earlyCloseFn func() error // optional alt Close func used if io.EOF not seen
}
...
func (es *bodyEOFSignal) Read(p []byte) (n int, err error) {
...
n, err = es.body.Read(p)
if err != nil {
...
err = es.condfn(err)
}
return
}
func (es *bodyEOFSignal) Close() error {
...
if es.earlyCloseFn != nil && es.rerr != io.EOF {
return es.earlyCloseFn()
}
err := es.body.Close()
return es.condfn(err)
}
func (es *bodyEOFSignal) condfn(err error) error {
...
err = es.fn(err)
es.fn = nil
return err
}
この挙動を踏まえてもう一度persistConn.readLoop()をみてみると、
- EOFまで読まずCloseもしない場合:earlyCloseFnもfnも呼ばれず、waitForBodyReadチャネルはブロックし続ける
- Keep-Alive有効であればtryPutIdleConn()が呼ばれずコネクションは再利用されず、Keep-Alive無効であればdeferなclose()が呼ばれず自発的にFINを送らない
- EOFまで読むがCloseはしない場合:fnが呼ばれwaitForBodyReadチャネルにはtrueが返るためブロックされずtryPutIdleConn()が呼ばれる
- EOFまで読まずCloseはする場合:earlyCloseFnが呼ばれwaitForBodyReadチャネルにはfalseが返るためtryPutIdleConn()が呼ばれない
- ブロックされないためKeep-Alive無効な場合はコネクションが切断されるが、Keep-Alive有効な場合にはコネクションの再利用が妨げられる
- EOFまで読みCloseする場合:fnが呼ばれwaitForBodyReadチャネルにはtrueが返るためブロックされずtryPutIdleConn()が呼ばれる
という挙動をとることがわかり、実機で確かめた挙動と一致しています。
func (pc *persistConn) readLoop() {
closeErr := errReadLoopExiting // default value, if not changed below
defer func() {
pc.close(closeErr)
pc.t.removeIdleConn(pc)
}()
...
eofc := make(chan struct{})
defer close(eofc) // unblock reader on errors
...
for alive {
...
waitForBodyRead := make(chan bool, 2)
body := &bodyEOFSignal{
body: resp.Body,
earlyCloseFn: func() error {
waitForBodyRead <- false
<-eofc // will be closed by deferred call at the end of the function
return nil
},
fn: func(err error) error {
isEOF := err == io.EOF
waitForBodyRead <- isEOF
if isEOF {
<-eofc // see comment above eofc declaration
} else if err != nil {
if cerr := pc.canceled(); cerr != nil {
return cerr
}
}
return err
},
}
resp.Body = body
...
select {
case bodyEOF := <-waitForBodyRead:
// bodyEOFがfalseだと短絡評価されtryPutIdleConn()が呼ばれない
alive = alive &&
bodyEOF &&
!pc.sawEOF &&
pc.wroteRequest() &&
tryPutIdleConn(rc.treq)
if bodyEOF {
eofc <- struct{}{}
}
...
}
...
}
}
まとめ
非常に長くなってしまいましたが、この記事ではnet/httpのHTTPクライアントの実装を追うことで、「こういう時ってどうなるんだっけ?」という挙動を確認することができました。 やはり実装を追うのは大事ですね。