Продолжая серию статей о «подводных камнях» не могу обойти стороной 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\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\TcpTimedWaitDelay.

С ходу может возникнуть вопрос — а как ведет себя .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.

Если в указанный выше пример кода добавить в самом начале

ServicePointManager.DefaultConnectionLimit = 5;

то выполнение примера заметно ускорится, так как запросы будут выполняться пачками по 5.

И прежде чем перейти к тому, как это работает в .NET Core, следует чуть больше сказать о ServicePointManager. Рассмотренное выше свойство указывает количество соединений по умолчанию, которое будет использоваться при последующих соединениях с любым доменом. Но вместе с этим, есть возможность управления параметрами для каждого доменного имени индивидуально и делается это через класс ServicePoint:

var delayServicePoint = ServicePointManager.FindServicePoint(new Uri("http://slowwly.robertomurray.co.uk"));
delayServicePoint.ConnectionLimit = 3;
var habrServicePoint = ServicePointManager.FindServicePoint(new Uri("https://habr.com"));
habrServicePoint.ConnectionLimit = 5;

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

Здесь есть еще интересный нюанс — лимит количества соединений для локальных адресов (localhost) по умолчанию равен int.MaxValue. Просто посмотрите результаты выполнения этого кода, предварительно не устанавливая DefaultConnectionLimit:

var habrServicePoint = ServicePointManager.FindServicePoint(new Uri("https://habr.com"));
Console.WriteLine(habrServicePoint.ConnectionLimit);

var localServicePoint = ServicePointManager.FindServicePoint(new Uri("http://localhost"));
Console.WriteLine(localServicePoint.ConnectionLimit);

Теперь все-таки перейдем к .NET Core. Хоть ServicePointManager и по-прежнему существует в пространстве имен System.Net, на поведение HttpClient в .NET Core он не влияет. Вместо этого, параметрами HTTP подключения можно управлять с помощью HttpClientHandler (или SocketsHttpHandler, о котором поговорим позже):

static void Main(string[] args)
{
    var handler = new HttpClientHandler();
    handler.MaxConnectionsPerServer = 2;

    var client = new HttpClient(handler);

    var tasks = new List<Task>();

    for (int i = 0; i < 10; i++)
    {
        tasks.Add(SendRequest(client, "http://slowwly.robertomurray.co.uk/delay/5000/url/https://habr.com"));
    }

    Task.WaitAll(tasks.ToArray());

    Console.ReadLine();
}

private static async Task SendRequest(HttpClient client, string url)
{
    var response = await client.GetAsync(url);
    Console.WriteLine($"Received response {response.StatusCode} from {url}");
}

Приведенный выше пример будет себя вести точно также, как и начальный пример для обычного .NET Framework — устанавливать только 2 соединения одновременно. Но если убрать строчку с установкой свойства MaxConnectionsPerServer, количество одновременных соединений будет намного выше, так как по умолчанию в .NET Core значение этого свойства равно int.MaxValue.

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

Представим, что разрабатываемая нами система должна нормально работать без принудительного перезапуска в случае, если сервер, с которым она взаимодействует, перешел на другой IP адрес. Например, в случае переключения на другой датацентр из-за сбоя в текущем. Даже если постоянное соединение будет разорвано из-за сбоя в первом датацентре (что тоже может произойти небыстро), кеш DNS не позволит нашей системе быстро отреагировать на такое изменение. То же самое актуально для обращений к адресу, на котором балансировка нагрузки делается через DNS round-robin.

В случае «обычного» .NET фреймворка этим поведением можно управлять через ServicePointManager и ServicePoint (все приведенные ниже параметры принимают значения в миллисекундах):

  • ServicePointManager.DnsRefreshTimeout — указывает, сколько времени будет закеширован полученный IP адрес для каждого доменного имени, значение по умолчанию — 2 минуты (120000).
  • ServicePoint.ConnectionLeaseTimeout — указывает, сколько времени соединение может удерживаться открытым. По умолчанию лимита времени жизни для соединений нет, любое соединение может удержаться сколь угодно долго, так как этот параметр равен -1. Установка его в 0 приведет к тому, что каждое соединение будет закрываться сразу после выполнения запроса.
  • ServicePoint.MaxIdleTime — указывает, после какого времени бездействия соединение будет закрыто. Бездействие означает отсутствие передачи данных через соединение. По умолчанию значение этого параметра равно 100 секунд (100000).

Теперь для улучшения понимания этих параметров соединим их все в одном сценарии. Предположим, DnsRefreshTimeout и MaxIdleTime никто не менял и они равны 120 и 100 секунд соответственно. При этом ConnectionLeaseTimeout был установлен в 60 секунд. Приложение устанавливает всего одно соединение, через которое раз в 10 секунд посылает запросы.

С такими настройками соединение будет закрываться каждые 60 секунд (ConnectionLeaseTimeout) даже несмотря на то, что по нему периодически идет передача данных. Закрытие и пересоздание будет проиходить таким образом, чтобы не мешать корректному выполнению запросов — если время истекло, а в данный момент запрос еще выполняется, соединение будет закрыто после завершения запроса. При каждом пересоздании соединения соответствующий IP адрес в первую очередь будет браться из кеша, и только если время жизни его разрешения истекло (120 секунд), система пошлет запрос на DNS сервер.

Параметр MaxIdleTime в этом сценарии не будет играть роли, так как соединение не бездействует дольше чем 10 секунд.

Оптимальное соотношение этих параметров сильно зависит от конкретной ситуации и нефункциональных требований:

  • Если вообще не предполагается прозрачное переключение IP адресов за доменным именем, к которому обращается ваше приложение, и при этом необходимо минимизировать затраты на сетевые подключения, то настройки по умолчанию выглядят хорошим вариантом.
  • Если есть надобность в переключении между IP адресами в случае сбоев, то можно поставить DnsRefreshTimeout в 0, а ConnectionLeaseTimeout — в подходящее вам неотрицательное значение. Какое конкретно — очень зависит от того, насколько быстро нужно переключиться на другой IP. Очевидно, что хочется иметь как можно более быструю реакцию на сбой, но здесь нужно найти оптимальное значение, которое, с одной стороны, обеспечивает допустимое время переключения, с другой стороны — не ухуджает пропускную способность и время отклика системы слишком частыми пересозданиями соединений.
  • Если нужна как можно более быстрая реакция на изменение IP адреса, например, как в случае балансировки через DNS round-robin, можно попробовать поставить DnsRefreshTimeout и ConnectionLeaseTimeout в 0, но это будет крайне расточительно: для каждого запроса сперва будет опрашиваться DNS сервер, после чего будет заново устанавливаться соединение с целевым узлом.
  • Возможно, есть и ситуации, когда установка ConnectionLeaseTimeout в 0 при ненулевом DnsRefreshTimeout может быть полезной, но я с ходу не могу придумать соответствующий сценарий. Логически это будет означать, что для каждого запроса соединения будут создаваться заново, но при этом IP адреса по возможности будут браться из кеша.

Ниже приведен пример кода, с помощью которого можно понаблюдать за поведением описанных выше параметров:
var client = new HttpClient();

ServicePointManager.DnsRefreshTimeout = 120000;
var habrServicePoint = ServicePointManager.FindServicePoint(new Uri("https://habr.com"));
habrServicePoint.MaxIdleTime = 100000;
habrServicePoint.ConnectionLeaseTimeout = 60000;

while (true)
{
    client.GetAsync("https://habr.com").Wait();
    Thread.Sleep(10000);
}

Во время работы тестовой программы можно в цикле запустить netstat через PowerShell для наблюдения за соединениями, которые она устанавливает.

Тут же следует сказать, как управлять описанными параметрами в .NET Core. Настройки из ServicePointManager, как и в случае с ConnectionLimit, работать не будут. В Core есть специальный тип HTTP обработчика, который реализует два из трех описанных выше параметров — SocketsHttpHandler:

var handler = new SocketsHttpHandler();
handler.PooledConnectionLifetime = TimeSpan.FromSeconds(60); //Аналог ConnectionLeaseTimeout
handler.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(100); //Аналог MaxIdleTime

var client = new HttpClient(handler);

Параметра, который управляет временем кеширования DNS записей, в .NET Core пока нет. Тестовые примеры показывают, что кеширование не работает — при создании нового соединения DNS разрешение выполняется заново, соответственно для нормальной работы в условиях, когда запрашиваемое доменное имя может переключаться между разными IP адресами, достаточно выставить PooledConnectionLifetime в нужное значение.

Вдобавок ко всему обязательно следует сказать, что все эти проблемы не могли быть незамеченными разработчиками из Microsoft, и поэтому начиная с .NET Core 2.1 появилась фабрика HTTP клиентов, позволяющая решить некоторые из них — https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests. Причем помимо управления временем жизни соединений, новый компонент дает возможности по созданию типизированных клиентов, а также некоторые другие полезные вещи. В указанной статье и ссылках с нее достаточно информации и примеров по использованию HttpClientFactory, поэтому в рамках данной статьи рассматривать связанные с ней детали я не буду.

Комментарии (11)


  1. novi
    01.10.2018 15:49

    По этой теме еще вот www.nimaara.com/2016/11/01/beware-of-the-net-httpclient
    и вот aspnetmonsters.com/2016/08/2016-08-27-httpclientwrong с вариантами как исправить код.


  1. Arranticus
    01.10.2018 19:31

    А можно на пальцах рассказать, зачем для чисто серверных операций использовать именно HttpClient, а не HttpWebRequest? Мне всегда казалось, что HttpClient нужен чтоб странички грузить «как в браузере».


    1. YuriyIvon Автор
      01.10.2018 19:44
      +2

      С точки зрения HTTP протокола, между API, которое возвращает JSON, и обычными страницами, отдающими клиенту HTML, принципиальной разницы нет — это просто разные типы контента. Соответственно, нет и разделения на то, какие типы контента какими классами лучше всего получать. Что выбрать, HttpWebRequest или HttpClient, скорее зависит от удобства, гибкости и производительности который дает каждый их них.

      HttpWebRequest очень низкоуровневый, каждый запрос нужно конструировать заново, и на это уходит заметно больше кода, чем на те же действия через HttpClient. При этом, если надо в юнит-тестах сделать мок вместо реальных обращений по сети, то с HttpWebRequest это будет проблематичным: по-хорошему придется вокруг этого класса делать фабрику и в коде использовать ее методы вместо явного создания HttpWebRequest. По сути HttpClient — и есть своего рода фабрика запросов. Она конечно же добавляет своего оверхеда в плане производительности, но для 99% случаев я думаю этим можно пренебречь. В то же время HttpClient значительно упрощает реализацию многих сценариев работы с HTTP и по сути является фасадом к более низкоуровневым классам, таким как HttpWebRequest. Но как и в любой абстракции, в нем есть неявные моменты, которые и описаны в статье.


    1. shibaev
      02.10.2018 06:38

      HttpWebRequest не поддерживает Timeout для асинхронных операций
      stackoverflow.com/a/26214865/136138


    1. mayorovp
      02.10.2018 07:54

      Для загрузки страничек «как в браузере» есть headless браузеры.

      А HttpClient — это тот же HttpWebRequest, но с более удобным API.


  1. robert_ayrapetyan
    02.10.2018 02:05

    >Если нужна как можно более быстрая реакция на изменение IP адреса, например, как в случае балансировки через DNS round-robin
    Разве ДНС не возвращает сразу несколько ИП-ов, в которые резолвится домен (getaddrinfo)? Сам HttpClient не сможет переключаться между ними, без доп. запросов к ДНС?


    1. YuriyIvon Автор
      02.10.2018 09:15

      С DNS можно сразу получить несколько IP, но в общем случае round-robin работает на базе поведения DNS сервера — либо с каждым запросом возвращается другой IP адрес (по кругу), либо возвращается полный список, но каждый раз с разным порядком адресов (https://en.wikipedia.org/wiki/Round-robin_DNS, blogs.technet.microsoft.com/networking/2009/04/17/dns-round-robin-and-destination-ip-address-selection). Я на практике не наблюдал, чтобы HttpClient при работе с доменом, зарегистрированным на несколько IP адресов, сам бы перебирал их с каждым новым подключением.


  1. vinnipux1982
    02.10.2018 07:18

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


  1. zayg21
    02.10.2018 08:49

    Вот ещё интересное наблюдение ко всему, что наследуется от WebRequest.
    Задача:
    передать на сайт данные с помощью POST запроса с компьютера, с доступом в сеть через прокси;

    Выставляем параметры прокси и аутентификации из настроек сети (System.Net.WebProxy.GetDefaultProxy(), System.Net.CredentialCache.DefaultNetworkCredentials)

    Отправляем POST-запрос, если объем переданных данных меньше 100 кБ, все ОК (сначала возвращается 407, потом 200)
    Если объем больше — только 407, данные не передаются, на клиент возвращается только таймаут операции. Грешили на прокси, но там таких ограничений не стояло. Подозреваю, что это все же ошибка многопоточности в C#, но могу провести расследование, если кому интересно.


  1. CodyLuck
    03.10.2018 22:46

    Позвольте такой вопрос, косвенно связанный с темой:
    Есть, к примеру, 100 устройств, распределенных по сети, которые, допустим, два раза в секунду шлют данные на сервер. Данные — строка из ~20 символов. Сервер, в свою очередь, тоже шлет данные на все устройства раз в 3 сек- хертбит. Какую схему сетевого взаимодействия в этом случае корректнее будет применить — постоянно держать открытым tcp-канал, открывать tcp-канал для отправки сообщения и закрывать его за собой или перейти к http-запросам? Испытания/замеры скорости не показали радикальной разницы. Искал ответы на разных тематических форумах, но аргументированного ответа не нашел, решил, что раз наткнулся на эту тему, то это знак.


    1. YuriyIvon Автор
      03.10.2018 22:51

      На таком масштабе радикальной разницы не будет. Я так понимаю, подключение не использует SSL, т.е. дополнительные затраты на SSL handshake при установке соединения не идут. Сервер легко выдержит 100 одновременных постоянных подключений, а сдругой стороны — передача данных не настолько частая, чтобы установка соединения вносила какую-то заметную задержку (при условии что канал не настолько плохой, что и 20 байт не всегда быстро доходят, если доходят вообще :)).

      В целом, если нет перспективы роста на десятки-сотни тысяч соединений (и даже при такой перспективе тоже могут быть варианты с постоянными соединениями), я бы держал постоянно открытое TCP соединение и по нему бы общался, пересоздавая в случае сбоев.