Является ли async HttpClient из .Net 4.5 плохим выбором для приложений с интенсивной нагрузкой?

Недавно я создал простое приложение для тестирования пропускной способности 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 тыс. Сообщений занимает около 2 секунд, я думал, что для доставки 10-кратного количества сообщений потребуется около 20 секунд, но тест показал, что для доставки 100 тыс. Сообщений требуется около 50 секунд. Кроме того, доставка сообщений по 200 тыс. Обычно занимает более 2 минут, и часто несколько тысяч (3-4 тыс.) Сбоев за исключением следующего:

Операция над сокетом не может быть выполнена, потому что системе не хватило буферного пространства или потому что очередь была переполнена.

Я проверил журналы IIS и операции, которые не удалось, так и не попал на сервер. Они потерпели неудачу в клиенте. Я запускал тесты на компьютере с Windows 7 с диапазоном эфемерных портов по умолчанию от 49152 до 65535. Запуск netstat показал, что во время тестов использовалось около 5-6k портов, поэтому теоретически должно было быть гораздо больше доступных. Если нехватка портов действительно была причиной исключений, это означает, что либо netstat должным образом не сообщил о ситуации, либо HttClient использует только максимальное количество портов, после которых он начинает выдавать исключения.

Напротив, многопоточный подход генерации HTTP-вызовов вел себя очень предсказуемо. Я взял около 0,6 секунд для 10k сообщений, около 5,5 секунд для 100k сообщений и, как и ожидалось, около 55 секунд для 1 миллиона сообщений. Ни одно из сообщений не удалось. Более того, во время работы он никогда не использовал более 55 МБ ОЗУ (согласно Windows Task Manager). Объем памяти, используемой при асинхронной отправке сообщений, увеличивался пропорционально нагрузке. Во время тестов 200k сообщений использовалось около 500 МБ ОЗУ.

Я думаю, что есть две основные причины вышеуказанных результатов. Во-первых, HttpClient, похоже, очень жадный в создании новых соединений с сервером. Большое количество используемых портов, о которых сообщает netstat, означает, что он, вероятно, не сильно выигрывает от поддержки HTTP.

Во-вторых, у 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) регулирование автоматически достигается путем ограничения количества одновременных потоков.

Для полного обзора я также создал версию асинхронного теста на основе HttpWebRequest, а не HttpClient, и мне удалось получить гораздо лучшие результаты. Для начала, это позволяет установить ограничение на количество одновременных подключений (с помощью ServicePointManager.DefaultConnectionLimit или через config), что означает, что у него никогда не заканчиваются порты и никогда не происходит сбой при любом запросе (HttpClient по умолчанию основан на HttpWebRequest , но, похоже, игнорирует настройку ограничения соединения).

Подход асинхронного HttpWebRequest все еще был примерно на 50 - 60% медленнее, чем многопоточный, но он был предсказуемым и надежным. Единственным недостатком было то, что он использовал огромное количество памяти под большой нагрузкой. Например, для отправки 1 миллиона запросов требовалось около 1,6 ГБ. Ограничив количество одновременных запросов (как я это делал выше для HttpClient), мне удалось сократить используемую память до 20 МБ и получить время выполнения всего на 10% медленнее, чем многопоточный подход.

После этой продолжительной презентации у меня возникли следующие вопросы: Является ли класс HttpClient из .Net 4.5 плохим выбором для приложений с интенсивной нагрузкой? Есть ли какой-нибудь способ его уменьшить, что должно решить проблемы, о которых я упоминаю? Как насчет асинхронной версии HttpWebRequest?

Обновление (спасибо @Stephen Cleary)

Как выясняется, HttpClient, как и HttpWebRequest (на котором он основан по умолчанию), может иметь количество одновременных подключений на одном хосте ограниченным с помощью ServicePointManager.DefaultConnectionLimit. Странно то, что согласноMSDNзначение по умолчанию для ограничения соединения равно 2. Я также проверил это с моей стороны, используя отладчик, который указал, что действительно 2 является значением по умолчанию. Однако, похоже, что если явно не задать значение для ServicePointManager.DefaultConnectionLimit, значение по умолчанию будет проигнорировано. Так как во время моих тестов HttpClient я не устанавливал для него значение явно, я думал, что оно игнорируется.

После установки значения ServicePointManager.DefaultConnectionLimit равным 100 HttpClient стал надежным и предсказуемым (netstat подтверждает, что используются только 100 портов). Он все еще медленнее, чем асинхронный HttpWebRequest (примерно на 40%), но, как ни странно, он использует меньше памяти. Для теста, который включает 1 миллион запросов, он использовал максимум 550 МБ по сравнению с 1,6 ГБ в асинхронном запросе HttpWebRequest.

Таким образом, хотя HttpClient в сочетании с ServicePointManager.DefaultConnectionLimit, по-видимому, обеспечивает надежность (по крайней мере, для сценария, когда все вызовы выполняются к одному и тому же хосту), он все равно выглядит так, что на его производительность отрицательно влияет отсутствие надлежащего механизма регулирования. То, что ограничит число одновременных запросов настраиваемым значением и поместит остаток в очередь, сделает его намного более подходящим для сценариев с высокой масштабируемостью.

Ответы на вопрос(3)

Ваш ответ на вопрос