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 インターフェースを実装したオブジェクトである。

```go
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: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:go/src/net/http/transport.go
type connectMethodKey struct {
	proxy, scheme, addr string
	onlyH1              bool
}
```

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

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

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

```go: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 内部で１つのコネクションを表す構造体で、素の net.Conn をラップしている。なお、１つの `persistConn` ごとに送信用(writeLoop)と受信用(readLoop)の2つの goroutine が動く。

```go: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539081/30cf98db-8827-602c-1719-8a2aea9d6d82.png)




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

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

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

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

![02.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539081/0777ace6-ad87-30d2-9063-2ee09d071f22.png)