Источник: Habr.com

Дата: 2019-09-08


Продолжая серию статей о «подводных камнях» не могу обойти стороной System.Net.HttpClient, который очень часто используется на практике, но при этом имеет несколько серьезных проблем, которые могут быть сразу не видны.

Достаточно частая проблема в программировании — то, что разработчики сфокусированы только на функциональных возможностях того или иного компонента, при этом совершенно не учитывают очень важную нефункциональную составляющую, которая может влиять на производительность, масштабируемость, легкость восстановления в случае сбоев, безопасность и т.д. Например, тот же HttpClient — вроде бы и элементарный компонент, но есть несколько вопросов: сколько он создает параллельных соединений к серверу, как долго они живут, как он себя поведет, если DNS имя, к которому обращался ранее, будет переключено на другой IP адрес? Попробуем ответить на эти вопросы в статье.

  1. Утечка соединений
  2. Лимит одновременных соединений с сервером
  3. Долгоживущие соединения и кеширование DNS

Первая проблема HttpClient — неочевидная утечка соединений. Достаточно часто мне приходилось встречать код, где он создается на выполнение каждого запроса:

    public async Task<string> GetSomeText(Guid textId)    {        using (var client = new HttpClient())        {            return await client.GetStringAsync($"<http://someservice.com/api/v1/some-text/{textId}>");        }    }

К сожалению, такой подход приводит к большой трате ресурсов и высокой вероятности получить переполнение списка открытых соединений. Для того, чтобы наглядно показать проблему, достаточно выполнить следующий код:

    static void Main(string[] args)    {        for(int i = 0; i < 10; i++)        {            using (var client = new HttpClient())            {                client.GetStringAsync("<https://habr.com>").Wait();            }        }    }

И по завершении посмотреть список открытых соединений через netstat:

PS C:\\Development\\Exercises> netstat -n | select-string -pattern "178.248.237.68"

  TCP    192.168.1.13:43684     178.248.237.68:443     TIME_WAIT
  TCP    192.168.1.13:43685     178.248.237.68:443     TIME_WAIT
  TCP    192.168.1.13:43686     178.248.237.68:443     TIME_WAIT
  TCP    192.168.1.13:43687     178.248.237.68:443     TIME_WAIT
  TCP    192.168.1.13:43689     178.248.237.68:443     TIME_WAIT
  TCP    192.168.1.13:43690     178.248.237.68:443     TIME_WAIT
  TCP    192.168.1.13:43691     178.248.237.68:443     TIME_WAIT
  TCP    192.168.1.13:43692     178.248.237.68:443     TIME_WAIT
  TCP    192.168.1.13:43693     178.248.237.68:443     TIME_WAIT
  TCP    192.168.1.13:43695     178.248.237.68:443     TIME_WAIT

Здесь ключ -n использован для того, чтобы ускорить вывод результата, так как в противном случае netstat для каждого IP будет искать доменное имя, а 178.248.237.68 — IP адрес habr.com на момент написания этой статьи.

Итого, мы видим, что несмотря на конструкцию using, и даже несмотря на то, что выполнение программы полностью завершилось, соединения с сервером остались «висеть». И висеть они будут столько времени, сколько указано в ключе реестра HKEY_LOCAL_MACHINE. С ходу может возникнуть вопрос — а как ведет себя .NET Core в таких случаях? Что в Windows, что в Linux — точно также, потому что подобное удержание соединений происходит на уровне системы, а не на уровне приложения. Статус TIME_WAIT является специальным состоянием сокета после его закрытия приложением, и нужно это для обработки пакетов, которые все еще могут идти по сети. Для Linux длительность такого состояния указана в секундах в /proc/sys/net/ipv4/tcp_fin_timeout, и ее, конечно же, можно менять, если нужно.

Вторая проблема HttpClient — неочевидный лимит одновременных соединений с сервером. Предположим, вы используете привычный вам .NET Framework 4.7, с помощью которого разрабатываете высоконагруженный сервис, где есть обращения к другим сервисам по HTTP. Потенциальная проблема с утечкой соединений учтена, поэтому для всех запросов используется один и тот же экземпляр HttpClient. Что может быть не так? Проблему легко можно увидеть, выполнив следующий код:

    static void Main(string[] args)    {        var client = new HttpClient();        var tasks = new List<Task>();        for (var i = 0; i < 10; i++)        {            tasks.Add(SendRequest(client, "<http://slowwly.robertomurray.co.uk/delay/5000/url/https://habr.com>"));        }        Task.WaitAll(tasks.ToArray());    }    private static async Task SendRequest(HttpClient client, string url)    {        var response = await client.GetAsync(url);        Console.WriteLine($"Received response {response.StatusCode} from {url}");    }

Указанный в ссылке ресурс позволяет задержать ответ сервера на указнное время, в данном случае — 5 секунд.

Как несложно заметить после выполнения приведенного выше кода — через каждые 5 секунд приходит всего по 2 ответа, хотя было создано 10 одновременных запросов. Связано это с тем, что взаимодействие с HTTP в обычном .NET фреймворке, помимо всего прочего, идет через специальный класс System.Net.ServicePointManager, контролирующий различные аспекты HTTP соединений. В этом классе есть свойство DefaultConnectionLimit, указывающее, сколько одновременных подключений можно создавать для каждого домена. И так исторически сложилось, что по умолчанию значение свойства равно 2. Если в указанный выше пример кода добавить в самом начале