132

私は最近、非同期で生成できる 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 の動作です。1万通配信するのに2秒くらいかかるので、10倍の配信に20秒くらいかかると思っていたのですが、テストしてみると10万通配信するのに50秒くらいかかりました。さらに、通常、20 万件のメッセージを配信するには 2 分以上かかり、多くの場合、数千件 (3 ~ 4k) のメッセージが次の例外で失敗します。

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

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

対照的に、HTTP 呼び出しを生成するマルチスレッド アプローチの動作は非常に予測可能でした。1 万メッセージで約 0.6 秒、10 万メッセージで約 5.5 秒、100 万メッセージで約 55 秒かかりました。どのメッセージも失敗しませんでした。さらに、実行中に 55 MB を超える 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 を使用するか、構成を介して)。これは、ポートが不足したり、要求が失敗したりすることがないことを意味します (HttpClient は、デフォルトで HttpWebRequest に基づいています)。 、接続制限設定を無視しているようです)。

非同期 HttpWebRequest アプローチは、マルチスレッド アプローチよりも約 50 ~ 60% 遅くなりましたが、予測可能で信頼性がありました。唯一の欠点は、大きな負荷がかかると大量のメモリを使用することでした。たとえば、100 万回のリクエストを送信するには、約 1.6 GB が必要でした。同時リクエストの数を制限することで (上記で 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 を使用しました。

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

4

3 に答える 3

64

質問で言及されているテストに加えて、私は最近、HTTP 呼び出しがはるかに少ない (以前の 100 万から 5000 に比べて 5000) が、実行にはるかに長い時間がかかる要求 (以前の約 1 ミリ秒から 500 ミリ秒) を含むいくつかの新しいテストを作成しました。同期マルチスレッド アプリケーション (HttpWebRequest に基づく) と非同期 I/O アプリケーション (HTTP クライアントに基づく) の両方のテスター アプリケーションで同様の結果が得られました。CPU の約 3% と 30 MB のメモリを使用して、実行に約 10 秒かかりました。2 つのテスターの唯一の違いは、マルチスレッド テスターは実行に 310 スレッドを使用したのに対し、非同期テスターは 22 スレッドしか使用しなかったことです。

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

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

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

于 2013-05-13T17:53:54.180 に答える
27

結果に影響を与える可能性があると考えられることの 1 つは、HttpWebRequest では ResponseStream を取得せず、そのストリームを消費していないことです。HttpClient を使用すると、デフォルトでネットワーク ストリームがメモリ ストリームにコピーされます。現在 HttpWebRquest を使用しているのと同じ方法で HttpClient を使用するには、次のことを行う必要があります。

var requestMessage = new HttpRequestMessage() {RequestUri = URL};
Task<HttpResponseMessage> getTask = httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead);

もう1つのことは、実際にテストしているスレッドの観点から、本当の違いが何であるかがよくわからないということです。HttpClientHandler を深く掘り下げると、非同期リクエストを実行するために Task.Factory.StartNew を実行するだけです。スレッド動作は、HttpWebRequest の例を使用した例とまったく同じ方法で同期コンテキストに委任されます。

間違いなく、HttpClient はデフォルトで HttpWebRequest をトランスポート ライブラリとして使用するため、いくらかのオーバーヘッドを追加します。したがって、HttpClientHandler を使用しながら、HttpWebRequest を直接使用することで常にパフォーマンスを向上させることができます。HttpClient がもたらす利点は、HttpResponseMessage、HttpRequestMessage、HttpContent などの標準クラスとすべての厳密に型指定されたヘッダーにあります。それ自体はパフォーマンスの最適化ではありません。

于 2013-04-25T20:04:02.940 に答える