.NET
1 октября

Подводные камни HttpClient в .NET

Продолжая серию статей о «подводных камнях» не могу обойти стороной 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, поэтому в рамках данной статьи рассматривать связанные с ней детали я не буду.
+50
15,6k 159
Комментарии 11
Похожие публикации
Популярное за сутки