Edited at

HttpClientをマルチスレッドで運用する場合の注意点

More than 1 year has passed since last update.


始めに

HttpClientをマルチスレッドかつ高負荷で回す時、少々ハマった点があったので、注意するべき点について書く。


シングルスレッドの場合

https://aspnetmonsters.com/2016/08/2016-08-27-httpclientwrong/ にもある通り、できる限り一つのHttpClientインスタンスで使いまわすという方法で問題はない。

実際自分もこういう風に使っていた。


マルチスレッドの場合

しかし、マルチスレッドでこれを行うと少々厄介なことになる。

実際に以下のようなメソッドを適当なWindowsマシン上で実行してみよう。(要dotnet-sdk-2.0以上)

using System;

using System.Net;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
// 予めhttp://localhost:10001で適当なhttpサーバーを動かしておく
static async Task MutliThreadedHttpRequest()
{
var client = new HttpClient();
// Do 1000 concurrent tasks, loop 100 times
var tasks = Enumerable.Range(0, 1000).Select(async idx =>
{
for (int i = 0; i < 100; i++)
{
try
{
using (var res = await client.GetAsync("http://localhost:10001").ConfigureAwait(false))
{
res.EnsureSuccessStatusCode();
}
}
catch (Exception e)
{
Console.WriteLine($"error({idx},{i}): {e}");
}
}
Console.WriteLine($"done{idx}");
}).ToArray();
await Task.WhenAll(tasks).ConfigureAwait(false);
Console.WriteLine($"all done");
}

ここで、実行中にnetstat -nコマンドを実行してみると、環境によってはアドレスがIPv6だったり、ESTABLISHEDがTIME_WAITになっていたりと細部が異なる可能性もあるが、おそらく以下のような行が数百~数千程度表示されることになる。



TCP 127.0.0.1:[ランダムポート] 127.0.0.1:10001 ESTABLISHED



これはまさに"シングルスレッドの場合"の記事URLで指摘された、ソケットを大量に作る現象となる。

正直上記のような、HTTPリクエストを1000もの並列タスクで同時展開するというのはあまり無い状況だとは思うが、問題が出る閾値が場合によっては異なるので、実際注意は必要。


なぜ接続が爆発するのか

さて、気になるのは今回接続が爆発した原因である。ServicePointManager.DefaultConnectionLimit、あるいはHttpClientHandler.MaxConnectionsPerServerで制御できるらしいとの話は見つかる(自分の場合はコメントで教えてもらったけど(@inasync さん感謝))。しかし、上記のコードは実際にソケットを爆発させているのは事実だ。


corefxの場合(netcoreapp)

最大接続数の既定値がどこから来ているか探してみると、corefxの場合は大体 HttpHandlerDefaults.DefaultMaxConnectionsPerServer に行き着く。

値は int.MaxValue である。というわけで、corefxの場合は常に設定を気を付けておいた方がいいということになる。

ちなみに corefx版のHttpWebRequestについては、 常にHttpClientをnewしてアクセスしているようなので、そもそも使うべきではないという結論になる。 一応HttpWebRequestについては、 使うなという明言もされている。


netframeworkの場合(net4x)

さて、では.NET Frameworkの場合はどのようになっているだろうか。リファレンスソースを見てみると、色々処理はされているが、つまるところ ローカルアドレスの場合はint.MaxValue、それ以外の場合は2 ということらしい。

(正直ここは無条件に2にして欲しかった...)


monoの場合(5.4)

monoのServicePointManagerを見てみると、デフォルトは10のようである。

しかし、実際に試してみると、シングルスレッドだったとしてもTIME_WAITが多くなってしまった。この辺りの原因は不明である(そもそも問題がない?)。


対処法

この問題に対する対処法を書く


同時接続数を明示的に設定する

コメントで教えていただいた事だが、HttpClient(というより多分ハンドラだと思うけど)の最大同時接続数を設定するという方法が一つ。


.NET Framework 4.7.1以降またはnetcoreapp2.0の場合

net471、netcoreapp(1.0以降)で使用できるやり方(多分net471はnetcoreappからの輸入品)。

これらのバージョンの場合は、HttpClientHandler.MaxConnectionsPerServerを、HttpClientのコンストラクタで渡せばOK。

この方法だと影響範囲はそのインスタンスのみで、排他等考えずとも設定できるので、可能ならばこのやり方で設定するのが簡単だろう。

ただし、netstandard2.0では使えない方法なので、その場合は後述の静的プロパティに設定する必要がある。


.NET Framework 4.7以前、netstandard2.0以前の場合

ServicePointManager.DefaultConnectionLimitを設定する。

注意点としては、この値はHttpClient生成前に設定しておく必要があり、生成後に設定しても意味はないというところ。

HttpWebRequestなどからも間接的に参照されている静的プロパティなので、アプリケーション全体に影響する項目であることは注意する必要がある。

ライブラリで設定すると、アプリケーション側の動作にも影響を及ぼすかもしれないので、ライブラリ側ではあまり設定しない方がいいだろう。

ただし、現行のほとんどの環境で使用できるやり方となるため、実際はこちらを使用することの方が多いかもしれない。

また、.NET Framework 4.7以前ならば、System.Net.Httpパッケージを追加すれば、HttpClientHandlerにMaxConnectionsPerServerが追加される。しかし、netstandard2.0に関しては追加されないので注意。

また、将来的なことを考えると、System.Net.Httpパッケージ自体は今後メンテされる可能性は非常に低いので、あくまで限定的な回避方法として扱うべきだろう。


HttpWebRequest.GetResponseを使う

とりあえずの対処法としては、HttpWebRequest.GetResponseを使用して、同期的なアクセスをするというものがある。

実際似たような処理をHttpWebRequestに置き換えたところ、大体の環境ではnetstat -nで観測できるソケット数はほぼ1-2程度に収まった。

ちなみに、HttpWebRequestにはGetResponseAsyncという、非同期版のAPIがあるが、 localhostに設定なしで接続すると接続が爆発するので要注意

using System;

using System.Net;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
// 予めhttp://localhost:10001で適当なhttpサーバーを動かしておく
static async Task MultiThreadedHttpWebRequest()
{
// Do 1000 concurrent tasks, loop 10 times
var tasks = Enumerable.Range(0, 1000).Select(async idx =>
{
for (int i = 0; i < 10; i++)
{
try
{
// HttpWebRequestはそのリクエストごとに毎回インスタンスを作成する
var client = HttpWebRequest.CreateHttp("http://localhost:10001");
using(var res = client.GetResponse() as HttpWebResponse)
{
if((int)res.StatusCode < 200 || (int)res.StatusCode >= 300 )
{
throw new Exception($"http response failed:{res.StatusCode}");
}
}
break;
}
catch (Exception e)
{
Console.WriteLine($"error({idx},{i}): {e}");
}
}
Console.WriteLine($"done{idx}");
}).ToArray();
await Task.WhenAll(tasks).ConfigureAwait(false);
Console.WriteLine($"all done");
}

ただし、ASP.NET(coreではない)内部で実行した場合等は、同じようにソケットが大量に作成された。

この辺りは推測の域を出ないが、ASP.NETの方が、リクエストごとに少々特殊な処理をしているためではないかと考えている。

また、netcoreapp2.0では非推奨なので、netcoreapp2.0ではこの手は使えない


シングルスレッドにリクエストをまとめる

シングルスレッドに行いたい処理をまとめ、その上で順次処理を回していくという方法。イメージとしてはProducer-Consumerパターンに近い。Producer(HTTPリクエストを送りたいスレッド):Consumer(リクエストを順次処理するスレッド)=n:1という関係になる。

プラットフォームを選ばないやり方だが、実装が複雑になりやすく、またデッドロック等のマルチスレッド固有の危険性もあるので、入念なテストを行うことをお勧めする。

System.Threading.Channelsがあるとかなり実装も楽になると思う。


monoの場合

なぜかmonoの場合、シングルスレッドを含めてどの方法を試してみても、TIME_WAITが増加してしまった。

(最初netstatのオプションを間違えて指定していたため、網に引っかからなかった)

この辺り、自分のやり方が良くないせいもあるかもしれないので、引き続き調査を行う必要がある。


各プラットフォーム、ランタイムでの状況(最大同時接続数未設定時)

以下に表としてまとめる。なお、unityやmacも環境としては考えられるが、自分では持っていないため今回は調べていない。

実際に使ったコードは https://github.com/itn3000/multihttpclienttest にまとめてある。

OS
ランタイム
リクエスト方法
結果(OK=ソケットが1-2程度、NG=ソケットが大量)

windows-8.1
net461
HttpClient+マルチスレッド
NG

windows-8.1
net461
HttpWebRequest(同期)
OK

windows-8.1
net461
HttpWebRequest(非同期)
NG

windows-8.1
net461
HttpClient+シングルスレッド
OK

linux(docker)
mono-5.4
HttpClient+マルチスレッド
NG

linux(docker)
mono-5.4
HttpWebRequest
NG

linux(docker)
mono-5.4
HttpClient+シングルスレッド
NG

windows-8.1
netcoreapp2.0
HttpClient+マルチスレッド
NG

windows-8.1
netcoreapp2.0
HttpWebRequest
NG

windows-8.1
netcoreapp2.0
HttpClient+シングルスレッド
OK

linux(docker)
netcoreapp2.0
HttpClient+マルチスレッド
NG

linux(docker)
netcoreapp2.0
HttpWebRequest
NG

linux(docker)
netcoreapp2.0
HttpClient+シングルスレッド
OK

monoで試す場合の注意点としては、事前にMONO_THREADS_PER_CPU環境変数を2000等の大きい数字にしておかないと、かなり遅くなってしまうので、必ず実行前にはMONO_THREADS_PER_CPU環境変数を設定しておくこと。

なお、netcoreapp+win+HttpWebRequestでNGな事については、 https://github.com/dotnet/corefx/issues/15460 で既に報告されている。


最大同時接続数を設定したときの挙動比較


測定内容

それぞれ以下の三つの場合について、最大同時接続数を変化させた場合の完了までの時間を調べた。


  1. HttpWebRequest.GetResponseを使用した場合

  2. シングルスレッドでHttpClientを使用した場合

  3. マルチスレッドでHttpClientを使用した場合

また、フレームワークについては、以下の場合について調べた


  • Win+netcoreapp2.0

  • Win+net461

  • Linux+netcoreapp2.0

  • Linux+mono-5.4(net461と対応)

なお、実験に使用したWinとLinuxはマシン自体にかなり性能差があり(Winは実機、Linuxは仮想)、同じ量のリクエストを行うと時間がかなりかかってしまったため、処理量自体を変えてある。

そのため、WinとLinux間の同条件の速度差については今回は考慮しない。

また、HttpClientの場合はハンドラごとにまた違った結果を出すかもしれないが、今回は測定していない。

ソースは https://github.com/itn3000/multihttpclienttest を参照


測定結果

長くなったので、Google SpreadSheetにした。



  • Windows版


    • win+core+httpwebrequestは実行したらプロセスが終わらなくなったため、途中で強制終了した



  • Linux版


考察

net461の場合、WinでもLinuxでもHttpWebRequestはかなり安定した性能を出していた。

最大同時接続数を大きくした場合(100)は、場合によってはMultiThreadに一歩譲る場合もあるものの、それでも

HttpWebRequestはソケットをほとんど作らないのに対して、MultiThreadは

設定値分ソケットを作成していたので、安定性はやはりHttpWebRequestの方が上と言えると思う。

古くからあるクラスなので、それだけ枯れているということだろうか。

対してnetcoreapp2.0の場合、HttpWebRequestを使用するとWindowsで期待通り動作しないということが分かった。

HttpClient+MultiThreadは同時接続数を上げていくにつれ、性能が向上していった。

特にnetcoreapp2.0でもnet461でもLinux版でその傾向が顕著だった。


結論

多くの場合、 HttpClient+MultiThreadで、かつ最大同時接続数をある程度の値に設定するのが正解 だということになる。大きすぎてもソケットを作りすぎて動作が不安定になってしまうが、小さすぎても性能がかなり落ちてしまう(特にLinux版)ので、この辺りはそれぞれの環境で実測して調整をするのが良いと思う。

ただし、グローバルな静的変数を設定するのがNGで、かつ.net frameworkあるいはmonoで動かすのが分かっている場合は、HttpWebRequestで動かすというのも一つの手かもしれない。

ただし、 https://github.com/dotnet/corefx/issues/15460 の問題があり、かつ互換性のために残されているだけとのことなので、netcoreappなアプリ、及び今後何か新しく作る場合はHttpWebRequestは基本的に使用しない方がいいだろう。


終わりに

実際HTTPリクエストを並列で回さなければならない場合というのは限られてくるとは思う。

しかも、ソケットが大量に作られてもただちに影響はない場合も多いので、うっかり見過ごしてしまう場合も多い。

しかし、これが原因でハマるとなかなか辛い状況になるので、この辺りに気を使っても損はないと思う。

HTTPに限らず、TCP通信を使うプログラムは、ソケット数には気を付けようという話。