Kotaro7750's Gehirn
Home About
  • Home
  • Category
  • About

Golangのnet/httpの実装からHTTP1.1クライアントでのTCPコネクションの挙動を確かめる

2025-03-21

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などは初見ではよくわからないと思います。 net/httpの主要型の関係

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メソッドの処理の流れです。

net/httpのHTTPクライアントの実装

曼荼羅のようですが、大雑把に分けると以下のようにコネクションプール管理・TCPコネクション生成・TCPコネクションによるHTTP読み書きに分けられます。 つまり、プールされたもしくは新たに生成されたTCPコネクションを取得し、そのコネクションに対してHTTPリクエスト発行を要求・その結果を受信することでリクエストが行われます。

net/httpの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クライアントの実装を追うことで、「こういう時ってどうなるんだっけ?」という挙動を確認することができました。 やはり実装を追うのは大事ですね。

参考

  • Go言語: http.Client のコネクション管理 (HTTP/1.x) #Go - Qiita
  • goのnet/httpでのやらかし事例 #Go - Qiita
  • Goのnet/httpのclientでなぜresponseBodyをClose、読み切らなくてはいけないのか

Related Posts

AnsibleでACMEのDNSチャレンジによるワイルドカードTLS証明書発行を自動化する

AnsibleでACMEのDNSチャレンジによるワイルドカードTLS証明書発行を自動化する

2024-06-09

フレッツ光のインターネット速度は1日・1週間の中でどう変わるのか?

フレッツ光のインターネット速度は1日・1週間の中でどう変わるのか?

2024-04-20

TCPにおけるシーケンス番号と確認応答番号の定義と使われ方

TCPにおけるシーケンス番号と確認応答番号の定義と使われ方

2023-08-28

New Posts

スケーラブルなOpenTelemetry CollectorパイプラインをKubernetes上に構築する

スケーラブルなOpenTelemetry CollectorパイプラインをKubernetes上に構築する

2025-04-14

OpenTelemetry Certified Associate ( OTCA ) を取得した

OpenTelemetry Certified Associate ( OTCA ) を取得した

2025-04-13

Golangのnet/httpの実装からHTTP1.1クライアントでのTCPコネクションの挙動を確かめる

Golangのnet/httpの実装からHTTP1.1クライアントでのTCPコネクションの挙動を確かめる

2025-03-21

ToC

  • TL; DR;
  • はじめに
  • net/httpでのHTTPクライアントの実装
  • 疑問1: Transport.idleConnTimeoutよりも前に再利用し続けるとどうなるのか?
  • 疑問2: HTTPリクエストをcontextでキャンセルするとTCPコネクションは再利用されるのか?
  • 疑問3: Response.BodyはEOFまで読んだ上でCloseする必要があるとされるがやらないとどうなるのか?
  • 3-1: Keep-Alive有効・EOFまで読まずCloseもしない
  • 3-2: Keep-Alive有効・EOFまで読んでCloseはしない
  • 3-3: Keep-Alive有効・EOFまで読まずCloseはする
  • 3-4: Keep-Alive無効・EOFまで読まずCloseもしない
  • 3-5: Keep-Alive無効・EOFまで読んでCloseはしない
  • 3-6: Keep-Alive無効・EOFまで読まずCloseはする
  • 3-まとめ
  • 3-実装
  • まとめ
  • 参考

Ads

Ads

Privacy Policy