私は最近、非同期で生成できる 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 を使用すると信頼性が確保されるように見えますが (少なくとも、すべての呼び出しが同じホストに対して行われるシナリオでは)、適切なスロットリング メカニズムがないためにパフォーマンスに悪影響が及ぶように見えます。リクエストの同時数を構成可能な値に制限し、残りをキューに入れるものは、スケーラビリティの高いシナリオにより適したものになります。