1. ホーム
  2. c#

[解決済み] .Net 4.5の非同期HttpClientは負荷の高いアプリケーションには不向きか?

2022-06-28 08:40:51

質問

私は最近、古典的なマルチスレッド アプローチに対して非同期方式で生成できる HTTP 呼び出しスループットをテストするための簡単なアプリケーションを作成しました。

このアプリケーションは、あらかじめ定義された数の HTTP 呼び出しを実行することができ、最後にそれらを実行するために必要な合計時間を表示します。私のテストでは、すべての HTTP 呼び出しは私のローカルの IIS サーバーに対して行われ、小さなテキスト ファイル (サイズは 12 バイト) を取得しました。

非同期実装のコードの最も重要な部分を以下に示します。

public async void TestAsync()
{
    this.TestInit();
    HttpClient httpClient = new HttpClient();

    for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
    {
        ProcessUrlAsync(httpClient);
    }
}

private async void ProcessUrlAsync(HttpClient httpClient)
{
    HttpResponseMessage httpResponse = null;

    try
    {
        Task<HttpResponseMessage> getTask = httpClient.GetAsync(URL);
        httpResponse = await getTask;

        Interlocked.Increment(ref _successfulCalls);
    }
    catch (Exception ex)
    {
        Interlocked.Increment(ref _failedCalls);
    }
    finally
    { 
        if(httpResponse != null) httpResponse.Dispose();
    }

    lock (_syncLock)
    {
        _itemsLeft--;
        if (_itemsLeft == 0)
        {
            _utcEndTime = DateTime.UtcNow;
            this.DisplayTestResults();
        }
    }
}

マルチスレッドの実装で最も重要な部分を以下に示します。

public void TestParallel2()
{
    this.TestInit();
    ServicePointManager.DefaultConnectionLimit = 100;

    for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
    {
        Task.Run(() =>
        {
            try
            {
                this.PerformWebRequestGet();
                Interlocked.Increment(ref _successfulCalls);
            }
            catch (Exception ex)
            {
                Interlocked.Increment(ref _failedCalls);
            }

            lock (_syncLock)
            {
                _itemsLeft--;
                if (_itemsLeft == 0)
                {
                    _utcEndTime = DateTime.UtcNow;
                    this.DisplayTestResults();
                }
            }
        });
    }
}

private void PerformWebRequestGet()
{ 
    HttpWebRequest request = null;
    HttpWebResponse response = null;

    try
    {
        request = (HttpWebRequest)WebRequest.Create(URL);
        request.Method = "GET";
        request.KeepAlive = true;
        response = (HttpWebResponse)request.GetResponse();
    }
    finally
    {
        if (response != null) response.Close();
    }
}

テストを実行した結果、マルチスレッド版の方が高速であることがわかりました。10k リクエストを完了するのに約 0.6 秒かかり、非同期版では同じ量の負荷を完了するのに約 2 秒かかりました。非同期型の方が速いと思っていたので、これはちょっと驚きでした。おそらく、私のHTTPコールが非常に高速だったことが原因でしょう。現実世界のシナリオでは、サーバーはより意味のある操作を行うべきで、ネットワークの待ち時間もあるはずなので、結果は逆になるかもしれません。

しかし、私が本当に懸念しているのは、負荷が増加したときの HttpClient の動作です。10k のメッセージを配信するのに約 2 秒かかるので、10 倍のメッセージを配信するのに約 20 秒かかると思っていましたが、テストを実行すると、100k のメッセージを配信するのに約 50 秒必要であることがわかりました。さらに、通常 200k 通のメッセージを配信するには 2 分以上かかり、以下の例外を除いて数千通 (3-4k) のメッセージが失敗することがよくあります。

システムに十分なバッファ領域がない、またはキューが満杯であるため、ソケットに対する操作を実行できませんでした。

IIS ログを確認したところ、失敗した操作はサーバーに届かなかったことがわかりました。それらはクライアント内で失敗しました。私は、49152 から 65535 のデフォルトのエフェメラル ポートの範囲で、Windows 7 マシンでテストを実行しました。netstatを実行すると、テスト中に約5〜6kのポートが使用されていることが分かりましたので、理論的にはもっと多くのポートが使用可能なはずです。ポートの不足が実際に例外の原因であった場合、netstat が状況を適切に報告しなかったか、HttClient が例外をスローし始めるポートの最大数を使用したかのどちらかであることを意味します。

対照的に、HTTP 呼び出しを生成するマルチスレッド アプローチは、非常に予測可能な挙動を示しました。10k のメッセージで約 0.6 秒、100k のメッセージで約 5.5 秒、そして予想どおり 100 万のメッセージで約 55 秒かかりました。どのメッセージも失敗しませんでした。さらに、実行中に55MB以上のRAMを使用することはありませんでした(Windowsタスクマネージャによる)。非同期でメッセージを送信するときに使用されるメモリは、負荷に比例して増加しました。200k メッセージのテストでは、約 500 MB の RAM を使用しました。

私は、上記の結果には 2 つの主な理由があると思います。1 つ目は、HttpClient がサーバーとの新しい接続を作成する際に非常に貪欲であるように思われることです。netstat によって報告された使用ポートの数が多いということは、おそらく HTTP キープアライブからあまり利益を得られないということです。

2 つ目は、HttpClient がスロットル機構を持っていないようだということです。実際、これは非同期操作に関連する一般的な問題のように思われます。非常に多くの操作を実行する必要がある場合、それらはすべて一度に開始され、それらの継続はそれらが利用可能であるときに実行されます。非同期処理では外部システムに負荷がかかるため、理論上は問題ないはずですが、上記で証明されたように、これは完全に正しいとは言えません。一度に多くのリクエストを開始すると、メモリの使用量が増え、実行全体の速度が低下します。

私は、シンプルだが原始的な遅延メカニズムで非同期リクエストの最大数を制限することで、メモリと実行時間の面でより良い結果を得ることに成功しました。

public async void TestAsyncWithDelay()
{
    this.TestInit();
    HttpClient httpClient = new HttpClient();

    for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
    {
        if (_activeRequestsCount >= MAX_CONCURENT_REQUESTS)
            await Task.Delay(DELAY_TIME);

        ProcessUrlAsyncWithReqCount(httpClient);
    }
}

HttpClient に同時リクエストの数を制限するメカニズムが含まれていれば、本当に便利です。Task クラス (これは .Net のスレッド プールに基づく) を使用する場合、同時実行スレッドの数を制限することにより、スロットリングが自動的に実現されます。

完全な概要として、私は、HttpClient ではなく HttpWebRequest に基づいた非同期テストのバージョンも作成し、はるかに良い結果を得ることに成功しました。まず、同時接続数の制限を設定できます (ServicePointManager.DefaultConnectionLimit または config を使用)。つまり、ポートが不足することはなく、どの要求でも失敗しません (HttpClient はデフォルトで HttpWebRequest に基づいていますが、接続制限設定を無視するようです)。

非同期 HttpWebRequest アプローチは、マルチスレッドのアプローチよりもまだ約 50 ~ 60% 遅いですが、予測可能で信頼性の高い方法でした。唯一の欠点は、大きな負荷がかかると膨大な量のメモリを使用することでした。例えば、100万件のリクエストを送信するのに約1.6GBが必要でした。同時リクエストの数を制限することにより (HttpClient に対して上記で行ったように)、使用するメモリをわずか 20 MB に減らし、マルチスレッド アプローチよりもわずか 10% 遅い実行時間を得ることに成功しました。

この長いプレゼンテーションの後、私の質問は以下の通りです。.Net 4.5 の HttpClient クラスは、集中的な負荷アプリケーションには不適切な選択でしょうか? 私が言及した問題を解決するために、それをスロットルする方法はありますか? HttpWebRequestの非同期フレーバーはどうでしょうか?

更新 (@Stephen Cleary に感謝)

結局のところ、HttpClient は、HttpWebRequest (デフォルトでベースとなっている) と同様に、ServicePointManager.DefaultConnectionLimit によって同一ホスト上での同時接続数を制限することが可能です。不思議なのは MSDN によると、接続制限のデフォルト値は 2 であることです。また、デバッガを使用して私の側で確認したところ、確かに 2 がデフォルト値であることがわかりました。しかし、ServicePointManager.DefaultConnectionLimitに明示的に値を設定しない限り、デフォルト値は無視されるようです。HttpClientのテスト中に明示的に値を設定しなかったので、無視されると思いました。

ServicePointManager.DefaultConnectionLimit を 100 に設定した後、HttpClient は信頼性が高く予測可能になりました (netstat は、100 ポートのみが使用されることを確認します)。非同期HttpWebRequestよりもまだ遅い(約40%)ですが、不思議なことに、より少ないメモリを使用します。100 万回の要求を含むテストでは、非同期 HttpWebRequest の 1.6 GB に対して、最大 550 MB を使用しました。

ServicePointManager.DefaultConnectionLimit を組み合わせた HttpClient は信頼性を保証するように見えますが (少なくとも、すべての呼び出しが同じホストに向かって行われるシナリオの場合)、適切なスロットリング メカニズムがないため、パフォーマンスに悪影響があるように見えます。同時リクエスト数を設定可能な値に制限し、残りをキューに入れるようなものがあれば、高いスケーラビリティのシナリオにはるかに適したものになります。

どのように解決するのですか?

質問で言及されたテストに加えて、私は最近、はるかに少ない HTTP 呼び出し (以前の 100 万に対して 5000) を含むいくつかの新しいものを作成しましたが、実行にはるかに時間がかかるリクエスト (以前の約 1 ミリ秒に対して 500 ミリ秒) がありました。同期マルチスレッド (HttpWebRequest ベース) と非同期 I/O (HTTP クライアントベース) の両方のテスターアプリケーションで、CPU の約 3% と 30MB のメモリを使用して約 10 秒間実行するという同じ結果が得られました。2つのテスターの唯一の違いは、マルチスレッドでは実行に310スレッドを使用したのに対し、非同期ではわずか22スレッドを使用したことです。つまり、I/O処理とCPU処理の両方を行うアプリケーションでは、実際にCPU処理を必要とするスレッドに対してより多くのCPU時間が与えられるため、非同期版の方が良い結果が得られたのです(I/O処理の完了を待つスレッドは単なる浪費です)。

私のテストの結論として、非常に高速なリクエストを扱う場合、非同期 HTTP 呼び出しは最良の選択肢ではありません。その背後にある理由は、非同期 I/O 呼び出しを含むタスクを実行するとき、タスクが開始されたスレッドは、非同期呼び出しが行われるとすぐに終了し、タスクの残りはコールバックとして登録されるからです。そして、I/O操作が完了すると、コールバックは最初に利用可能なスレッドでの実行のためにキューに入れられる。このすべてがオーバーヘッドを生み出し、高速なI/Oオペレーションは、それを開始したスレッド上で実行されるとより効率的になります。

非同期 HTTP 呼び出しは、I/O 操作が完了するのを待つためにどのスレッドも忙しくしないので、長いまたは潜在的に長い I/O 操作を扱うときに良いオプションです。これは、アプリケーションによって使用されるスレッドの全体的な数を減少させ、より多くの CPU 時間を CPU 拘束の操作に費やすことができるようにします。さらに、限られた数のスレッドしか割り当てられないアプリケーション (Web アプリケーションの場合など) では、非同期 I/O により、同期的に I/O 呼び出しを実行する場合に起こりうるスレッド プールのスレッドの枯渇を防ぐことができます。

したがって、非同期 HttpClient は、負荷の高いアプリケーションのボトルネックにはなりません。その代わりに、長い、または潜在的に長い HTTP リクエスト、特に利用可能なスレッドの数が限られているアプリケーションの内部で理想的なものです。また、ServicePointManager.DefaultConnectionLimit で並列性を制限するには、十分な並列性を確保しつつ、エフェメラルポートが枯渇しないように十分低い値を設定するのがよい方法と言えます。この質問に対するテストと結論の詳細については、以下を参照してください。 ここで .