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 のコンソールアプリで、指定した回数
HttpClient
でhttps://www.google.com
に GET 要求を投げる HttpClinet
を破棄する、使いまわすモードを用意- OS が利用できるポート数は事前に減らしておく
まず、利用可能ポート数を 255 に減らす (下限が 255 であるため)。
1 | >netsh int ipv4 show dynamicport tcp |
続いてコードの確認。
1 | using NLog; |
既に接続しているソケット。
1 | netstat -an -p tcp |
エファメラルポートが 16 個 (0.0.0.0 を除外して) だが、正しいカウントか?
ともあれ、まずは毎回破棄するモード。
1 | >Demo.exe 1024 false |
239 個目でソケットが枯渇した模様。
前述の 16 を考えると、 238 + 16 = 254 で腑に落ちないが、近似しているのはわかる。
次に、インスタンスを使いまわすモード。
1 | >Demo.exe 1024 true |
途中で 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。
- Groupmax Address/Mail Version 7 システム管理者ガイド 基本操作編 4.1.5 ポート数不足を回避する設定
- Interstage Application Server V12.0.0 チューニングガイド 3.3 TCP/IPパラメタのチューニング
- WebSphere Application Server Network Deployment Windows システムのチューニング
それでいいのか?