web-dev-qa-db-ja.com

.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();
    }
}

テストを実行すると、マルチスレッドバージョンの方が高速であることがわかりました。 10kのリクエストを完了するのに約0.6秒かかりましたが、非同期のリクエストは同じ負荷で完了するのに約2秒かかりました。非同期の方が速いと思っていたので、これは少し驚きでした。たぶんそれは私のHTTP呼び出しが非常に高速だったという事実のためだった。サーバーがより意味のある操作を実行する必要があり、ネットワークの待ち時間もある実際のシナリオでは、結果が逆になる可能性があります。

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

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

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

対照的に、HTTP呼び出しを生成するマルチスレッドアプローチは、非常に予測可能な振る舞いをしました。 10kのメッセージで約0.6秒、100kのメッセージで約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に含まれていれば、非常に便利です。タスククラス(.Netスレッドプールに基づく)を使用する場合、同時スレッドの数を制限することにより、調整が自動的に実行されます。

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

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

この長いプレゼンテーションの後、私の質問は次のとおりです。Net4.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は信頼性を確保しているように見えます(少なくとも同じホストに対してすべての呼び出しが行われているシナリオでは)が、適切な調整メカニズムがないためにパフォーマンスが悪影響を受けているようです。同時リクエスト数を設定可能な値に制限し、残りをキューに入れることで、高いスケーラビリティのシナリオにより適したものになります。

129

質問で言及したテストに加えて、私は最近、はるかに少ないHTTPコール(以前の100万に比べて5000)を含む新しいテストを作成しましたが、実行にはるかに時間がかかったリクエスト(以前の約1ミリ秒に比べて500ミリ秒)で作成しました。 HttpWebRequestに基づく同期マルチスレッドアプリケーションとHTTPクライアントに基づく非同期I/Oアプリケーションの両方のテスターアプリケーションは、CPUの約3%と30 MBのメモリを使用して実行するのに約10秒かかりました。 2つのテスターの唯一の違いは、マルチスレッドの実行では310のスレッドを使用し、非同期のテストでは22を使用するだけだったということです。なぜなら、実際にそれを必要とするCPU操作を実行するスレッドにより多くのCPU時間が利用できるからです(I/O操作が完了するのを待っているスレッドは無駄になっています)。

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

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

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

64

結果に影響を与える可能性があることを考慮する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およびすべての厳密に型指定されたヘッダーなどの標準クラスです。それ自体はパフォーマンスの最適化ではありません。

27
Darrel Miller

これは、OPの質問の「非同期」部分に直接答えませんが、これは彼が使用している実装のエラーに対処します。

アプリケーションをスケーリングする場合は、インスタンスベースのHttpClientを使用しないでください。違いは巨大です!負荷に応じて、非常に異なるパフォーマンス値が表示されます。 HttpClientは、リクエスト間で再利用されるように設計されました。これは、それを書いたBCLチームのメンバーによって確認されました。

最近行ったプロジェクトは、非常に大規模で有名なオンラインコンピューター販売店が、新しいシステムのブラックフライデー/休日のトラフィックをスケールアウトするのを支援することでした。 HttpClientの使用に関するパフォーマンスの問題に遭遇しました。 IDisposableを実装しているため、開発者は、インスタンスを作成してusing()ステートメント内に配置することで、通常行うことを行いました。負荷テストを開始すると、アプリはサーバーをひざまずかせました-はい、サーバーはアプリだけではありません。その理由は、HttpClientのすべてのインスタンスがサーバー上のI/O完了ポートを開くためです。 GCの非決定的なファイナライズと、複数の OSIレイヤー にまたがるコンピューターリソースで作業しているという事実のため、ネットワークポートを閉じるには時間がかかる場合があります。実際、Windows OSitselfは、ポートを閉じるのに最大20秒かかります(Microsoftによる)。閉じられるよりも速くポートを開いていました-サーバーポートの枯渇によりCPUが100%に打撃を受けました私の修正は、HttpClientを静的インスタンスに変更して問題を解決することでした。はい、それは使い捨てのリソースですが、パフォーマンスの違いはオーバーヘッドを大きく上回ります。負荷テストを行って、アプリの動作を確認することをお勧めします。

以下のリンクでも回答しました:

WebAPIクライアントの呼び出しごとに新しいHttpClientを作成するオーバーヘッドは?

https://www.asp.net/web-api/overview/advanced/calling-a-web-api-from-a-net-client

17
Dave Black