10
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

Go言語: http.Client のコネクション管理 (HTTP/1.x)

この記事について

net/http パッケージの Client は内部で TCP コネクションプールを持ち、コネクションのキャッシュ・再利用を行う。リクエスト送信時は、プール内に空きコネクションがあればそれを利用する。

そのため、Client は何度も作るのではなく使いまわした方がよい。この記事ではそのコネクションプールの仕組みについてまとめる。

なお、HTTP/1.x と HTTP/2 では TCP コネクションの使い方が全く異なるため、HTTP/1.x ついてのみ記載する。

  • 対象の Go バージョン: 1.14.4

まとめ

  • http.Client 内でコネクションプールを管理しているのは http.Transport
  • Transport は、以下の connectMethodKey の単位でコネクションを管理する
    • つまり、connectMethodKey が同じになるリクエストは同じコネクションプールを使う
type connectMethodKey struct {
    proxy, scheme, addr string
    onlyH1              bool
}
  • http.Transport でのコネクションプール関連パラメータは以下の通り。
パラメータ名 説明
MaxIdleConns Transport 全体で保持できる空きコネクション総数。DefaultTransport では 100 にセットされる。
MaxIdleConnsPerHost connectMethodKey ごとに保持できる空きコネクション総数。デフォルト値は 2。
MaxConnsPerHost connectMethodKey ごとのコネクション総数 (使用中・空き・接続中のものを含む)。デフォルト値は 0 (制限無しの意味)。
IdleConnTimeout 空きコネクションとして保持できる最長時間。DefaultTransport では 90秒にセットされる。

詳細

初めに Transport について

Client は Do()メソッドの呼び出しによりリクエストを送信するが、実際にTCPコネクションを張って(つまり Dial して)、リクエストの送受信を行うのは RoundTripper インターフェースを実装したオブジェクトである。

type Client struct {
    // Transport specifies the mechanism by which individual
    // HTTP requests are made.
    // If nil, DefaultTransport is used.
    Transport RoundTripper
    // ...
}

net/http では RoundTripper として Transport という構造体が実装されており、この Transport が TCP コネクションを管理するコネクションプールを持っている。

なお、Client へ明示的に RoundTripper が指定されなければデフォルトとして、以下の DefaultTransport が使われる。

go/src/net/http/transport.go
var DefaultTransport RoundTripper = &Transport{
    Proxy: ProxyFromEnvironment,
    DialContext: (&net.Dialer{
        Timeout:   30 * time.Second,
        KeepAlive: 30 * time.Second,
        DualStack: true,
    }).DialContext,
    ForceAttemptHTTP2:     true,
    MaxIdleConns:          100,
    IdleConnTimeout:       90 * time.Second,
    TLSHandshakeTimeout:   10 * time.Second,
    ExpectContinueTimeout: 1 * time.Second,
}

コネクション管理

コネクションの識別

Transport はリクエストの宛先ごとにコネクションプールを持つ。異なるリクエストでも以下の4つの値が同じであれば、それぞれ同じ宛先とみなされて同一のコネクションプールを使用する。

  • Proxy の URL
  • HTTP のスキーム (http or https)
  • アドレス (192.168.1.1:8080など)
  • HTTP/1 が必須かのフラグ ※WebSocketへのUpgradeなどは HTTP/1 が必須

これらは具体的には以下の connectMethodKey で表現される。つまり connectMethodKey が同じリクエストであれば、同じコネクションプールを使う。

go/src/net/http/transport.go
type connectMethodKey struct {
    proxy, scheme, addr string
    onlyH1              bool
}

コネクションプールとコネクション

Transport は、この connectMethodKey をキーとして各種のキュー、リストを管理している。これがコネクションプールの実体になる。

以下に Transport でコネクションプールを構成する変数を抜粋:

go/src/net/http/transport.go
type Transport struct {
    idleConn     map[connectMethodKey][]*persistConn    // 宛先ごとの空きのTCPコネクションの一覧
    idleConnWait map[connectMethodKey]wantConnQueue     // 宛先ごとのコネクション取得待ちのキュー
    idleLRU      connLRU                                // Transport 全体での空きコネクション一覧
    connsPerHost     map[connectMethodKey]int           // 宛先ごとのコネクション数
    connsPerHostWait map[connectMethodKey]wantConnQueue // 宛先ごとの Dial 順番待ちのキュー
    // ...
}

persistConn は net/http 内部で1つのコネクションを表す構造体で、素の net.Conn をラップしている。なお、1つの persistConn ごとに送信用(writeLoop)と受信用(readLoop)の2つの goroutine が動く。

go/src/net/http/transport.go
// persistConn wraps a connection, usually a persistent one
// (but may be used for non-keep-alive requests as well)
type persistConn struct {
    t         *Transport          // このコネクションの管理元の Transport
    cacheKey  connectMethodKey    // 上記記載の connectMethodKey
    conn      net.Conn            // 素の net.Conn
    br        *bufio.Reader       // conn をラップした受信用バッファ
    bw        *bufio.Writer       // conn をラップした送信用バッファ   
    writech   chan writeRequest   // リクエスト送信チャネル。writeLoop() が監視していて、リクエストを送信する。
    reqch     chan requestAndChan // レスポンス受信方法を伝えるチャネル。readLoop() が見る。
}

http.Transport でのコネクションプール関連パラメータ

コネクションプールに関連するパラメータを以下に記載。

パラメータ名 説明
MaxIdleConns Transport 全体で保持できる空きコネクション総数。DefaultTransport では 100 にセットされる。
MaxIdleConnsPerHost connectMethodKey ごとに保持できる空きコネクション総数。デフォルト値は 2。
MaxConnsPerHost connectMethodKey ごとのコネクション総数 (使用中・空き・接続中のものを含む)。デフォルト値は 0 (制限無しの意味)。
IdleConnTimeout 空きコネクションとして保持できる最長時間。DefaultTransport では 90秒にセットされる。

TCP コネクション~リクエストの送受信までの流れ

Client.Do() を呼んだ後の、TCPコネクションの確立・取得~リクエストの送受信までの流れをざっと記載する。

なお、[XXX]と表記しているのは Transport のソースコード go/src/net/http/transport.go 中の関数名。

(1) Transport での処理に入る

  • Client.Do() の延長で Transport.roundTrip() に入る
  • Proxy サーバーのURLや、HTTP/1.x が必須かどうかの情報を取得 [connectMethodForRequest]
    ※ これらはリクエストの送信や、connectMethodKey の生成に必要

(2) Keep-Alive が有効かチェック

  • もし Transport.DisableKeepAlives が true なら、HTTP Keep-Alive が無効なのでコネクションプールを利用せず、新規に Dial する [getConn, queueForIdleConn]
    ⇒ (4) へ行く

(3) 同一宛先への空きコネクションがあるか Transport.idleConn をチェック
まず、リクエストの宛先(connectMethodKey)用の空きコネクションがあるか Transport.idleConn をチェックする。 [queueForIdleConn]

  • ある場合
    • コネクションを取得し、Transport.IdleConn からは削除
    • リクエストの送信に進む
      ⇒ (5) へ行く
  • 無い場合
    • 空きコネクションを順番に待つためのキュー Transport.idleConnWait にエントリを追加
    • それと同時に、自身で新たなコネクションの確立を試みる
      ⇒ (4) へ行く

(4) 新規コネクションの確立を試みる
自分で Dial してコネクションを確立しようとする。ただ、コネクション数の上限(MaxConnsPerHost)に達している場合は Dial するための順番待ちのキューに入る。[queueForDial]

  • 以下の条件に合う場合は、Dial して新規コネクションを張る。
    • Transport.MaxConnsPerHost が 0 (つまり無制限)
    • 現在の同一宛先の合計コネクション数が Transport.MaxConnsPerHost より少ない場合
  • それ以外の場合は、Dial するための順番待ちキュー Transport.connsPerHostWait にエントリを追加
  • 空きコネクションが出来るか、自分で Dial 出来るようになるか、どちらか早い方で取得したコネクションを獲得

(5) 取得したコネクションを用いて、リクエストを送信する
コネクションを取得できたので、Transport.roundTrip() から各コネクションへリクエストを送信する。 [Transport.roundTrip, persistConn.roundTrip, persistConn.writeLoop]

  • リクエストを各コネクション(persistConn)ごとの送信チャネル persistConn.writech に追加
  • 送信チャネルを監視している goroutine(writeLoop) がリクエストを送信する

(6) レスポンスの取得とコネクションの解放
サーバーからレスポンスを受信したらそれを上位に渡して、コネクションを次のリクエストに使えるよう解放する。[persistConn.writeLoop, Transport.tryPutIdleConn]

  • コネクションからのデータ受信を監視してる goroutine(readLoop) が、レスポンスを受信
  • 受信後、空きコネクション待ちキュー Transport.idleConnWait で待ってるリクエストがあれば、それにコネクションを引き渡す
  • 待ちリクエストが無ければ、空きTCPコネクション一覧 Transport.idleConn にコネクションを追加
    • この時、空きTCPコネクション数が MaxIdleConnsPerHost を超えていれば追加しない。
    • Transport 全体の空きコネクション総数(idleLRU)が MaxIdleConns を超えていたら、一番古いコネクションをクローズする

コネクションの Timeout と Close

コネクションプールに入っている空きコネクションにはタイムアウトがあり、未使用状態の時間が Transport.IdleConnTimeout 以上になるとクローズされる。この値は DefaultTransport では90秒である。

また、明示的にコネクションをクローズしたい時は Transport.CloseIdleConnections() が使える。この関数はすべての空きコネクションをクローズする。

試してみた

MaxConnsPerHost の変更

同一宛先あたりの最大コネクション数を示す MaxConnsPerHost の値を変えながら、1つの http.Client に対して、3つの goroutine から一斉に同一宛先にリクエストを送る。

1. MaxConnsPerHost = 0 の場合 (0 はデフォルト値で制限なしの意味)

http.Client はこのように作成。

cli := &http.Client{}

結果:
「空きのコネクションが無い、かつ、MaxConnsPerHostの制限無し」なので、それぞれの goroutine から Dial されて、3つのTCPコネクションが張られる。

01.PNG

2. MaxConnsPerHost = 1 の場合 (同一宛先あたり、同時に1コネクションしか張れない)

http.Client はこのように作成。

cli := &http.Client{
    Transport: &http.Transport{
        MaxConnsPerHost: 1,
    },
}

結果:
「コネクションは1個しか張れない」状態なので、最初の goroutine だけが新規コネクションを張り、2~3個目の goroutine はそのコネクションが空くのを順番に待ってリクエストを送る。

02.PNG

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
10
Help us understand the problem. What are the problem?