この記事について
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 が使われる。
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
が同じリクエストであれば、同じコネクションプールを使う。
type connectMethodKey struct {
proxy, scheme, addr string
onlyH1 bool
}
コネクションプールとコネクション
Transport は、この connectMethodKey
をキーとして各種のキュー、リストを管理している。これがコネクションプールの実体になる。
以下に Transport でコネクションプールを構成する変数を抜粋:
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 が動く。
// 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コネクションが張られる。
2. MaxConnsPerHost = 1 の場合 (同一宛先あたり、同時に1コネクションしか張れない)
http.Client はこのように作成。
cli := &http.Client{
Transport: &http.Transport{
MaxConnsPerHost: 1,
},
}
結果:
「コネクションは1個しか張れない」状態なので、最初の goroutine だけが新規コネクションを張り、2~3個目の goroutine はそのコネクションが空くのを順番に待ってリクエストを送る。