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

始めに

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もの並列タスクで同時展開するというのはあまり無い状況だとは思うが、問題が出る閾値が場合によっては異なるので、実際注意は必要。

対処法

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

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

コメントで教えていただいた事だが、HttpClient(というより多分ハンドラだと思うけど)の最大同時接続数を設定するという方法が一つ。
ドキュメントにはデフォルト2と書かれているような気もするが、自分の環境では明示的に設定するのとしないのとでは明らかに挙動が異なったので、書いておく。

.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などからも間接的に参照されている静的プロパティなので、アプリケーション全体に影響する項目であることは注意する必要がある。
ライブラリで設定すると、アプリケーション側の動作にも影響を及ぼすかもしれないので、ライブラリ側ではあまり設定しない方がいいだろう。
ただし、現行のほとんどの環境で使用できるやり方となるため、実際はこちらを使用することの方が多いかもしれない。

HttpWebRequestを使う

とりあえずの対処法としては、HttpWebRequestを使用するというものがある。
実際似たような処理をHttpWebRequestに置き換えたところ、大体の環境ではnetstat -nで観測できるソケット数はほぼ1-2程度に収まった。

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があるとかなり実装も楽になるが、まだcorefxのmasterにマージされたばかりなので、実際に使えるのはもう少し先になるだろう。

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

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

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

OS ランタイム リクエスト方法 結果(OK=ソケットが1-2程度、NG=ソケットが大量)
windows-8.1 net461 HttpClient+マルチスレッド NG
windows-8.1 net461 HttpWebRequest OK
windows-8.1 net461 HttpClient+シングルスレッド OK
linux(docker) mono-5.4 HttpClient+マルチスレッド NG
linux(docker) mono-5.4 HttpWebRequest OK
linux(docker) mono-5.4 HttpClient+シングルスレッド OK
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 OK
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を使用した場合
  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通信を使うプログラムは、ソケット数には気を付けようという話。
というかSystem.Threading.Channels早く来てほしいなぁ。

55contribution

下記を使えば HttpClient で同時接続数制御できるそうですよ。

ちなみにですが、HttpClient のプラットフォーム毎のバックエンドは Microsoft Docs に記載がありますので参考にしてみて下さい(既にご存知でしたらごめんなさい)。

105contribution

どうやらそれらのオプションを失念していたようです。設定したらちゃんと制御できました。教えて頂きありがとうございます。
色々数字をいじったら性能特性が見えてきたので、そちらの方を中心にして大幅改定したいと思います。

105contribution

加筆しました。

55contribution

@skitoy4321 さん
お疲れ様です。
関心のある分野なので、楽しく拝見させて頂いてます。

コードを読んだり実行して遊んでいた所、1点気になるところがありました。

for (int i = 0; i < 10; i++)
{
    try
    {
        // using HttpWebRequest
        await DoRequestWeb();
        break;
    }
    catch (Exception e)
    {
        Console.WriteLine($"error({idx},{i}): {e}");
    }
}

https://gist.github.com/itn3000/d30b43a6d2b2f8586fbb12a9d2659397#file-usinghttpclientconcurrentcore-cs-L183

上記箇所で break してしまうと for 内の処理が1度しか実行されないように思うのですが、いかがでしょうか。

105contribution

すみません、リポジトリの方は直していたのですが、gistの方は直していませんでした。
指摘ありがとうございます。

55contribution

すみません。もう一点気づいたのですが、.NET Framework 版にて DoRequestWeb() を 1,000 回呼び出す際の doneXX の出力が順序良くカウンティングされていました。
https://gist.github.com/itn3000/d30b43a6d2b2f8586fbb12a9d2659397#file-usinghttpclientconcurrentnet-cs-L184
本来は DoRequest() のように順不定(非同期)で数字が出力される事が期待されるのだと思います。

これは主に GetRespoonse() にて同期的に処理されている事が要因だと思いますので、GetResponseAsync() に変更したところ非同期に実行されるようになりました。

ただこの変更によって WebRequest でもソケットが大量に開かれるようになってしまったのですが、これは .NET Framework(の ServicePoint)が、ローカルアドレス又はループバックアドレスの場合に ConnectionLimit を Int32.MaxValue にしてしまう事が原因でした。
https://referencesource.microsoft.com/#System/net/System/Net/ServicePoint.cs,697

例えば http://example.com にアクセスする際には ConnectionLimit は 2 になり、ソケット数も抑えられます。
この違いは下記のコードで確認できますので、よろしければ .NET Framework で試してみてください。

var requestUrl = "http://localhost:10001";    // ServicePoint.ConnectionLimit is 2147483647
//requestUrl = "http://example.com";            // ServicePoint.ConnectionLimit is 2
var sp = ServicePointManager.FindServicePoint(new Uri(requestUrl));

Console.WriteLine(requestUrl);
Console.WriteLine($"{sp.CurrentConnections} / {sp.ConnectionLimit} (defmax {ServicePointManager.DefaultConnectionLimit})");
var req = WebRequest.CreateHttp(requestUrl);
using (var res = (HttpWebResponse)req.GetResponse()) {
}
Console.WriteLine($"{sp.CurrentConnections} / {sp.ConnectionLimit} (defmax {ServicePointManager.DefaultConnectionLimit})");
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.