Introduction

有名な話だが、 System.Net.Http.HttpClient クラスは接続を終了するたびに Dispose してはいけないという注意事項がある。
IDisposable を実装しているにもかかわらず、このような注意事項がある点についてネットでは怨嗟の声があがっている。

インスタンス化 にあるように、

HttpClient は、1 回インスタンス化され、アプリケーションの有効期間中に再利用されることを目的としています。 .NET Core と .NET 5 以降では、HttpClient はハンドラー インスタンス内の接続をプールし、複数の要求にわたって接続を再利用します。 すべての要求に対して HttpClient クラスをインスタンス化すると、大量の負荷で使用可能なソケットの数が使い果たされます。 この枯渇により、エラーが発生 SocketException します。

とある。

これだけだとよくわからないのが、Dispose を呼ぶことの問題点。
Dispose を呼ぶことによって、通常は使用されていたリソースが解放されるが、ソケットに関してはそうは問屋が卸さない。

Windows には接続が閉じられた時、接続が TIME_WAIT に留まる仕様がある (というか、Windows じゃなくて、TCP/IP の常識) が、この時、 TIME_WAIT に留まる時間が決まっていて、これがデフォルトで 120 秒 (Windows Server 2003 以前は 240 秒)。
MaxUserPort と TcpTimedWaitDelay の設定を調整する を参照。

つまり、Dispose を呼び出しても、120 秒間はそのソケットは再利用できない。
どうしてこんな仕様があるのかはおいておいて、この時間にはそれなりに意味がある。

で、もう一度 System.Net.Http.HttpClient クラスに話を戻す。
IDispose を実装しているものだから、世の中の .NET 開発者はそれはそれは

1
using var client = new HttpClient();

なんて実装したものだ。
開発者を苦しめる.NETのHttpClientのバグと紛らわしいドキュメント では、これでもか、という位恨みつらみが記されている。

何は兎も角、解決策は、Dispose をしない、ではなく、システム全体で 1 つの HttpClient インスタンスを使いまわすのが正解。
シングルトンで持ち回れってこと。
ただし、場合によっては複数のインスタンスを持つことは許される。
たとえば、接続先ごとに設定やポリシーが違う場合など。
このあたりのサンプルは、ASP.NET ではまさにそうで、ASP.NET Core で IHttpClientFactory を使用して HTTP 要求を行う を参照。

What happend?

ところで、これらの「Dispose を使うな!」という記事はそこかしこにあるが、実際にどのような例外を投げるのか、って記事が全然ない。
ないわけではないが、サンプルコードもないし、そもそも、本当に実験したん?って思う。

ということで実験してみた。

条件

  • Windows 11 on Hyper-V
  • .NET 8.0 のコンソールアプリで、指定した回数 HttpClienthttps://www.google.com に GET 要求を投げる
  • HttpClinet を破棄する、使いまわすモードを用意
  • OS が利用できるポート数は事前に減らしておく

まず、利用可能ポート数を 255 に減らす (下限が 255 であるため)。

1
2
3
4
5
6
7
8
9
10
11
12
13
>netsh int ipv4 show dynamicport tcp

プロトコル tcp の動的ポートの範囲
---------------------------------
開始ポート : 49152
ポート数 : 16384

>netsh int ipv4 show dynamicport tcp

プロトコル tcp の動的ポートの範囲
---------------------------------
開始ポート : 49152
ポート数 : 255

続いてコードの確認。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
using NLog;

namespace Demo
{

internal sealed class Program
{

#region Fields

private static readonly Logger Logger = LogManager.GetCurrentClassLogger();

#endregion

#region Methods

public static async Task Main(string[] args)
{
var count = int.Parse(args[0]);
var useSafe = bool.Parse(args[1]);
if (useSafe)
await Safe(count);
else
await Unsafe(count);
}

#region Helpers

private static async Task Safe(int count)
{
var httpClient = new HttpClient();

Logger.Info("Starting connections");

for (var i = 1; i <= count; i++)
{
var result = await httpClient.GetAsync("https://www.google.com");
Console.WriteLine($"{i}: {result.StatusCode}");
}

Logger.Info("Connections done");
}

private static async Task Unsafe(int count)
{
Logger.Info("Starting connections");

for (var i = 1; i <= count; i++)
{
using var httpClient = new HttpClient();
var result = await httpClient.GetAsync("https://www.google.com");
Console.WriteLine($"{i}: {result.StatusCode}");
}

Logger.Info("Connections done");
}

#endregion

#endregion

}

}

既に接続しているソケット。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
netstat -an -p tcp

アクティブな接続

プロトコル ローカル アドレス 外部アドレス 状態
TCP 0.0.0.0:22 0.0.0.0:0 LISTENING
TCP 0.0.0.0:135 0.0.0.0:0 LISTENING
TCP 0.0.0.0:445 0.0.0.0:0 LISTENING
TCP 0.0.0.0:3389 0.0.0.0:0 LISTENING
TCP 0.0.0.0:5040 0.0.0.0:0 LISTENING
TCP 0.0.0.0:9090 0.0.0.0:0 LISTENING
TCP 0.0.0.0:49664 0.0.0.0:0 LISTENING
TCP 0.0.0.0:49665 0.0.0.0:0 LISTENING
TCP 0.0.0.0:49666 0.0.0.0:0 LISTENING
TCP 0.0.0.0:49667 0.0.0.0:0 LISTENING
TCP 0.0.0.0:49668 0.0.0.0:0 LISTENING
TCP 0.0.0.0:49669 0.0.0.0:0 LISTENING
TCP 0.0.0.0:49675 0.0.0.0:0 LISTENING
TCP 172.17.169.252:139 0.0.0.0:0 LISTENING
TCP 172.17.169.252:49234 23.55.45.66:443 CLOSE_WAIT
TCP 172.17.169.252:49235 23.55.45.66:443 CLOSE_WAIT
TCP 172.17.169.252:49236 23.55.45.106:443 CLOSE_WAIT
TCP 172.17.169.252:49237 23.55.45.106:443 CLOSE_WAIT
TCP 172.17.169.252:49238 23.55.45.106:443 CLOSE_WAIT
TCP 172.17.169.252:49239 23.55.45.106:443 CLOSE_WAIT
TCP 172.17.169.252:49240 23.55.45.106:443 CLOSE_WAIT
TCP 172.17.169.252:49241 23.55.45.106:443 CLOSE_WAIT
TCP 172.17.169.252:49242 23.55.45.80:443 CLOSE_WAIT
TCP 172.17.169.252:49243 23.55.45.80:443 CLOSE_WAIT
TCP 172.17.169.252:49244 23.55.45.80:443 CLOSE_WAIT
TCP 172.17.169.252:49245 23.55.45.80:443 CLOSE_WAIT
TCP 172.17.169.252:49246 23.55.45.80:443 CLOSE_WAIT
TCP 172.17.169.252:49247 23.55.45.80:443 CLOSE_WAIT
TCP 172.17.169.252:49249 152.195.38.76:80 CLOSE_WAIT
TCP 172.17.169.252:49943 20.198.119.84:443 ESTABLISHED

エファメラルポートが 16 個 (0.0.0.0 を除外して) だが、正しいカウントか?
ともあれ、まずは毎回破棄するモード。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
>Demo.exe 1024 false
2024-04-04 22:36:36.6744 [INFO ] Starting connections
1: OK
2: OK
3: OK
...
237: OK
238: OK
Unhandled exception. System.Net.Http.HttpRequestException: 通常、各ソケット アドレスに対してプロトコル、ネットワーク ア ドレス、またはポートのどれか 1 つのみを使用できます。 (www.google.com:443)
---> System.Net.Sockets.SocketException (10048): 通常、各ソケット アドレスに対してプロトコル、ネットワーク アドレス、またはポートのどれか 1 つのみ を使用できます。
at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.ThrowException(SocketError error, CancellationToken cancellationToken)
at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.System.Threading.Tasks.Sources.IValueTaskSource.GetResult(Int16 token)
at System.Net.Sockets.Socket.<ConnectAsync>g__WaitForConnectWithCancellation|277_0(AwaitableSocketAsyncEventArgs saea, ValueTask connectTask, CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.ConnectToTcpHostAsync(String host, Int32 port, HttpRequestMessage initialRequest, Boolean async, CancellationToken cancellationToken)
--- End of inner exception stack trace ---
at System.Net.Http.HttpConnectionPool.ConnectToTcpHostAsync(String host, Int32 port, HttpRequestMessage initialRequest, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.ConnectAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.CreateHttp11ConnectionAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.AddHttp11ConnectionAsync(HttpRequestMessage request)
at System.Threading.Tasks.TaskCompletionSourceWithCancellation`1.WaitWithCancellationAsync(CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.GetHttp11ConnectionAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.HttpConnectionPool.SendWithVersionDetectionAndRetryAsync(HttpRequestMessage request, Boolean async, Boolean doRequestAuth, CancellationToken cancellationToken)
at System.Net.Http.RedirectHandler.SendAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
at System.Net.Http.HttpClient.<SendAsync>g__Core|83_0(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationTokenSource cts, Boolean disposeCts, CancellationTokenSource pendingRequestsCts, CancellationToken originalCancellationToken)
at Demo.Program.Unsafe(Int32 count) in E:\Works\OpenSource\Demo2\Networking\HttpClient\01_NoUseHttpClientFactory\sources\Demo\Program.cs:line 51
at Demo.Program.Main(String[] args) in E:\Works\OpenSource\Demo2\Networking\HttpClient\01_NoUseHttpClientFactory\sources\Demo\Program.cs:line 24
at Demo.Program.<Main>(String[] args)

239 個目でソケットが枯渇した模様。
前述の 16 を考えると、 238 + 16 = 254 で腑に落ちないが、近似しているのはわかる。

次に、インスタンスを使いまわすモード。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
>Demo.exe 1024 true
2024-04-04 22:51:14.7928 [INFO ] Starting connections
1: OK
2: OK
3: OK
4: OK
5: OK
6: OK
7: OK
8: OK
9: OK
10: OK
...
1007: TooManyRequests
1008: TooManyRequests
1009: OK
1010: OK
1011: OK
1012: OK
1013: OK
1014: OK
1015: OK
1016: OK
1017: OK
1018: OK
1019: OK
1020: OK
1021: OK
1022: OK
1023: OK
1024: OK
2024-04-04 22:54:35.9587 [INFO ] Connections done

途中で TooManyRequests (429) が返ってきてはいるがソケット自体は枯渇することなく完走。
実行中の netstat を見ていたが、ソケットは常に 32 個 (netstat -an -p tcp | find /c "TCP" でカウント) であり、コネクションプールが再利用されている様子がよく分かった。

ちなみ今回は await で待機しており、マルチスレッドではないが、インスタンスを共有していても、下記のメソッドに関してはスレッドセーフであることが明記されているので安心だ。

  • CancelPendingRequests
  • DeleteAsync
  • GetAsync
  • GetByteArrayAsync
  • GetStreamAsync
  • GetStringAsync
  • PostAsync
  • PutAsync
  • SendAsync

Other resolution?

前述の infoq の記事でもそうだが、解決策として

  • TIME_WAIT の時間を短くすればいい
  • エファメラルポートの数を増やせばいい

とあるが…。infoq の記事では

Simon Timms氏によれば、”Googleで検索してみると、コネクションタイムアウトの時間を短くするという恐ろしいアドバイスが見つかります。

といった愚痴通り、まじでそんな記事があった。
しかも天下の日立、富士通、IBM。

それでいいのか?