- PVSM.RU - https://www.pvsm.ru -
Продолжая серию статей о «подводных камнях» не могу обойти стороной System.Net.HttpClient, который очень часто используется на практике, но при этом имеет несколько серьезных проблем, которые могут быть сразу не видны.
Достаточно частая проблема в программировании — то, что разработчики сфокусированы только на функциональных возможностях того или иного компонента, при этом совершенно не учитывают очень важную нефункциональную составляющую, которая может влиять на производительность, масштабируемость, легкость восстановления в случае сбоев, безопасность и т.д. Например, тот же HttpClient — вроде бы и элементарный компонент, но есть несколько вопросов: сколько он создает параллельных соединений к серверу, как долго они живут, как он себя поведет, если DNS имя, к которому обращался ранее, будет переключено на другой IP адрес? Попробуем ответить на эти вопросы в статье.
Первая проблема 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:DevelopmentExercises> 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_MACHINESYSTEMCurrentControlSetServicesTcpipParametersTcpTimedWaitDelay.
С ходу может возникнуть вопрос — а как ведет себя .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 (все приведенные ниже параметры принимают значения в миллисекундах):
Теперь для улучшения понимания этих параметров соединим их все в одном сценарии. Предположим, DnsRefreshTimeout и MaxIdleTime никто не менял и они равны 120 и 100 секунд соответственно. При этом ConnectionLeaseTimeout был установлен в 60 секунд. Приложение устанавливает всего одно соединение, через которое раз в 10 секунд посылает запросы.
С такими настройками соединение будет закрываться каждые 60 секунд (ConnectionLeaseTimeout) даже несмотря на то, что по нему периодически идет передача данных. Закрытие и пересоздание будет проиходить таким образом, чтобы не мешать корректному выполнению запросов — если время истекло, а в данный момент запрос еще выполняется, соединение будет закрыто после завершения запроса. При каждом пересоздании соединения соответствующий IP адрес в первую очередь будет браться из кеша, и только если время жизни его разрешения истекло (120 секунд), система пошлет запрос на DNS сервер.
Параметр MaxIdleTime в этом сценарии не будет играть роли, так как соединение не бездействует дольше чем 10 секунд.
Оптимальное соотношение этих параметров сильно зависит от конкретной ситуации и нефункциональных требований:
Ниже приведен пример кода, с помощью которого можно понаблюдать за поведением описанных выше параметров:
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 [4]. Причем помимо управления временем жизни соединений, новый компонент дает возможности по созданию типизированных клиентов, а также некоторые другие полезные вещи. В указанной статье и ссылках с нее достаточно информации и примеров по использованию HttpClientFactory, поэтому в рамках данной статьи рассматривать связанные с ней детали я не буду.
Автор: YuriyIvon
Источник [5]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/net/294438
Ссылки в тексте:
[1] Утечка соединений: #1
[2] Лимит одновременных соединений с сервером: #2
[3] Долгоживущие соединения и кеширование DNS: #3
[4] https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests: https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests
[5] Источник: https://habr.com/post/424873/?utm_campaign=424873
Нажмите здесь для печати.