.Net 4.5 の非同期 HttpClient は、負荷の高いアプリケーションには適していませんか? 質問する

.Net 4.5 の非同期 HttpClient は、負荷の高いアプリケーションには適していませんか? 質問する

私は最近、従来のマルチスレッド アプローチと比較して非同期方式で生成できる 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();
    }
}

テストを実行すると、マルチスレッド バージョンの方が高速であることがわかりました。10,000 件のリクエストを完了するのに約 0.6 秒かかりましたが、非同期バージョンでは同じ量の負荷で完了するのに約 2 秒かかりました。非同期バージョンの方が高速であると予想していたので、これは少し意外でした。おそらく、HTTP 呼び出しが非常に高速だったためでしょう。実際のシナリオでは、サーバーはより意味のある操作を実行し、ネットワーク レイテンシも発生するはずなので、結果は逆になる可能性があります。

しかし、私が本当に心配しているのは、負荷が増加したときの HttpClient の動作です。10,000 個のメッセージを配信するのに約 2 秒かかるため、10 倍の数のメッセージを配信するのに約 20 秒かかると考えていましたが、テストを実行すると、100,000 個のメッセージを配信するのに約 50 秒かかることがわかりました。さらに、通常、200,000 個のメッセージを配信するのに 2 分以上かかり、多くの場合、数千個 (3 ~ 4,000 個) のメッセージが次の例外で失敗します。

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

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

対照的に、HTTP 呼び出しを生成するマルチスレッド アプローチの動作は非常に予測可能でした。10,000 件のメッセージで約 0.6 秒、100,000 件のメッセージで約 5.5 秒、100 万件のメッセージで予想どおり約 55 秒かかりました。メッセージはどれも失敗しませんでした。さらに、実行中は 55 MB を超える RAM は使用されませんでした (Windows タスク マネージャーによる)。メッセージを非同期に送信するときに使用されるメモリは、負荷に比例して増加しました。200,000 件のメッセージ テスト中は約 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 または構成経由)。つまり、ポートが不足したり、リクエストが失敗したりすることはありません (HttpClient はデフォルトで HttpWebRequest に基づいていますが、接続制限設定を無視するようです)。

非同期 HttpWebRequest アプローチは、マルチスレッド アプローチよりも 50 - 60% ほど遅いですが、予測可能で信頼性があります。唯一の欠点は、負荷が大きい場合に大量のメモリを使用することです。たとえば、100 万のリクエストを送信するには約 1.6 GB 必要でした。同時リクエストの数を制限することで (上記の HttpClient で行ったように)、使用メモリを 20 MB に削減し、マルチスレッド アプローチよりも実行時間を 10% だけ遅くすることができました。

この長いプレゼンテーションの後、私の質問は次のとおりです。.Net 4.5 の HttpClient クラスは、負荷の高いアプリケーションには適していませんか? 私が言及した問題を解決するために、これを調整する方法はありますか? HttpWebRequest の非同期フレーバーはどうですか?

更新 (@Stephen Cleary に感謝)

結局のところ、HttpClientは、HttpWebRequest(デフォルトでベースになっている)と同様に、ServicePointManager.DefaultConnectionLimitによって同じホスト上の同時接続数を制限することができます。奇妙なことに、マイクロソフト、接続制限のデフォルト値は 2 です。デバッガーを使用して自分の側でも確認したところ、確かに 2 がデフォルト値であることが示されました。ただし、ServicePointManager.DefaultConnectionLimit に明示的に値を設定しない限り、デフォルト値は無視されるようです。HttpClient テスト中に明示的に値を設定しなかったので、無視されていると思いました。

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

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

ベストアンサー1

質問で言及されているテストのほかに、最近、HTTP 呼び出しが大幅に減少 (以前の 100 万回に対して 5000 回) しながら、実行にかなり長い時間がかかるリクエスト (以前の約 1 ミリ秒に対して 500 ミリ秒) を含む新しいテストをいくつか作成しました。同期マルチスレッドのテスター アプリケーション (HttpWebRequest ベース) と非同期 I/O のテスター アプリケーション (HTTP クライアント ベース) の両方で、同様の結果が得られました。実行に約 10 秒かかり、CPU の約 3% とメモリ 30 MB を使用しました。2 つのテスターの唯一の違いは、マルチスレッドのテスターでは実行に 310 スレッドが使用されたのに対し、非同期のテスターでは 22 スレッドしか使用されなかったことです。そのため、I/O バウンド操作と CPU バウンド操作の両方を組み合わせたアプリケーションでは、非同期バージョンの方が、実際に CPU 時間を必要とする CPU 操作を実行するスレッドに使用できる CPU 時間が多くなるため、より良い結果が得られます (I/O 操作の完了を待機しているスレッドは無駄になります)。

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

非同期 HTTP 呼び出しは、I/O 操作が完了するまで待機してスレッドがビジー状態にならないため、長時間の I/O 操作または長時間になる可能性のある I/O 操作を処理する場合に適したオプションです。これにより、アプリケーションで使用されるスレッドの総数が減り、CPU バインド操作に費やす CPU 時間を増やすことができます。さらに、限られた数のスレッドのみを割り当てるアプリケーション (Web アプリケーションの場合など) では、非同期 I/O によって、I/O 呼び出しを同期的に実行した場合に発生する可能性のあるスレッド プールのスレッド枯渇を防ぐことができます。

したがって、非同期 HttpClient は、負荷の高いアプリケーションではボトルネックにはなりません。その性質上、非常に高速な HTTP リクエストにはあまり適していませんが、長いリクエストや、長くなる可能性のあるリクエストには最適です。特に、利用できるスレッド数が限られているアプリケーションでは最適です。また、ServicePointManager.DefaultConnectionLimit で同時実行性を制限することをお勧めします。この値は、並列処理のレベルを十分に確保できるほど高く、一時的なポートの枯渇を防ぐのに十分な低さにしてください。この質問に対して提示されたテストと結論の詳細については、こちらをご覧ください。ここ

おすすめ記事