Главная » Хабрахабр » Подводные камни HttpClient в .NET

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

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

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

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

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

public async Task<string> GetSomeText(Guid 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 на момент написания этой статьи.

И висеть они будут столько времени, сколько указано в ключе реестра HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\TcpTimedWaitDelay. Итого, мы видим, что несмотря на конструкцию using, и даже несмотря на то, что выполнение программы полностью завершилось, соединения с сервером остались «висеть».

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

Предположим, вы используете привычный вам . Вторая проблема HttpClient — неочевидный лимит одновременных соединений с сервером. 7, с помощью которого разрабатываете высоконагруженный сервис, где есть обращения к другим сервисам по HTTP. NET Framework 4. Что может быть не так? Потенциальная проблема с утечкой соединений учтена, поэтому для всех запросов используется один и тот же экземпляр 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 секунд.

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

MaxValue. Здесь есть еще интересный нюанс — лимит количества соединений для локальных адресов (localhost) по умолчанию равен int. Просто посмотрите результаты выполнения этого кода, предварительно не устанавливая 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.

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

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

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

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 в нужное значение.

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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

[Из песочницы] Разбор Memory Forensics с OtterCTF и знакомство с фреймворком Volatility

Привет, Хабр! Именно ее я хочу разобрать в этом посте, всем кому интересно — добро пожаловать под кат. Недавно закончился OtterCTF (для интересующихся — ссылка на ctftime), который в этом году меня, как человека, достаточно плотно связанного с железом откровенно ...

Манекен на турбореактивно-электрическом коптере-гибриде

Очередное свидетельство, что 2019 год будет годом хайпа турбореактивных штуковин. Американский стартап ElectraFly и вояки в 2019 на ракетном полигоне в Юте планируют испытания индивидуального квадрокоптера с турбореактивным двигателем. При наборе высоты турбореактивный движок будет помогать винтам, а потом давать ...