Антишаблон неправильного создания экземпляров

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

Описание проблемы

Во многих библиотеках представлены абстракции внешних ресурсов. На внутреннем уровне эти классы обычно управляют собственными подключениями к ресурсу и выступают в качестве брокеров, которые клиенты могут использовать для доступа к ресурсу. Ниже приведены некоторые примеры классов брокера, относящиеся к приложениям Azure.

  • System.Net.Http.HttpClient. Взаимодействует с веб-службой, используя протокол HTTP.
  • Microsoft.ServiceBus.Messaging.QueueClient. Публикует и получает сообщения в очереди служебной шины.
  • Microsoft.Azure.Documents.Client.DocumentClient. Подключается к экземпляру Azure Cosmos DB.
  • StackExchange.Redis.ConnectionMultiplexer. Подключается к Redis, включая кэш Redis для Azure.

Для этих классов экземпляры создаются единожды, а затем используются на протяжении всего времени существования приложения. Однако распространенное заблуждение заключается в том, что эти классы нужно получать только по мере необходимости и быстро освобождать. (Перечисленные здесь библиотеки .NET, но шаблон не является уникальным для .NET.) В следующем ASP.NET примере создается экземпляр HttpClient для взаимодействия с удаленной службой. Полный пример см. здесь.

public class NewHttpClientInstancePerRequestController : ApiController
{
    // This method creates a new instance of HttpClient and disposes it for every call to GetProductAsync.
    public async Task<Product> GetProductAsync(string id)
    {
        using (var httpClient = new HttpClient())
        {
            var hostName = HttpContext.Current.Request.Url.Host;
            var result = await httpClient.GetStringAsync(string.Format("http://{0}:8080/api/...", hostName));
            return new Product { Name = result };
        }
    }
}

В веб-приложении этот метод не является масштабируемым. Объект HttpClient создается для каждого пользовательского запроса. В условиях большой нагрузки веб-сервер может исчерпать количество доступных сокетов, что приведет к ошибкам SocketException.

Эта проблема не ограничивается классом HttpClient. Другие классы (затратные при создании или создающие программу-оболочку) могут вызывать аналогичные проблемы. В следующем примере создается экземпляр класса ExpensiveToCreateService. Здесь проблема заключается не в исчерпании сокетов, а в том, сколько времени занимает создание каждого экземпляра. Непрерывное создание и уничтожение экземпляров этого класса может неблагоприятно повлиять на масштабируемость системы.

public class NewServiceInstancePerRequestController : ApiController
{
    public async Task<Product> GetProductAsync(string id)
    {
        var expensiveToCreateService = new ExpensiveToCreateService();
        return await expensiveToCreateService.GetProductByIdAsync(id);
    }
}

public class ExpensiveToCreateService
{
    public ExpensiveToCreateService()
    {
        // Simulate delay due to setup and configuration of ExpensiveToCreateService
        Thread.SpinWait(Int32.MaxValue / 100);
    }
    ...
}

Как исправить антишаблон неправильного создания экземпляров

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

В следующем примере используется статический экземпляр HttpClient, что позволяет запросам совместно использовать подключение.

public class SingleHttpClientInstanceController : ApiController
{
    private static readonly HttpClient httpClient;

    static SingleHttpClientInstanceController()
    {
        httpClient = new HttpClient();
    }

    // This method uses the shared instance of HttpClient for every call to GetProductAsync.
    public async Task<Product> GetProductAsync(string id)
    {
        var hostName = HttpContext.Current.Request.Url.Host;
        var result = await httpClient.GetStringAsync(string.Format("http://{0}:8080/api/...", hostName));
        return new Product { Name = result };
    }
}

Рекомендации

  • Ключевым элементом этого антишаблона является многократное создание и уничтожение экземпляров объекта совместного использования. Если класс не предназначен для общего использования (не потокобезопасен), то этот антишаблон не применяется.

  • Тип общего ресурса может определять, следует ли использовать одноэлементный экземпляр или создавать пул. Класс HttpClient предназначен для общего использования, а не применения в составе пула. Другие объекты могут поддерживать пулы, позволяя системе распределять рабочую нагрузку между несколькими экземплярами.

  • Объекты общего использования для нескольких запросов должны быть потокобезопасными. Класс HttpClient предназначен для использования подобным образом, но другие классы могут не поддерживать одновременные запросы, поэтому нужно ознакомиться с доступной документацией.

  • Будьте осторожны, выбирая свойства для совместно используемых объектов, так как это может привести к состоянию гонки. Например, настройка DefaultRequestHeaders для класса HttpClient перед каждым запросом может привести к состоянию гонки. Настраивать такие параметры следует один раз (например, во время запуска). Создавайте отдельные экземпляры, если вам нужно настроить разные параметры.

  • Некоторые типы ресурсов могут быть дефицитными, и их не нужно ставить на удержание. Примером выступают подключения к базе данных. Удерживание ненужного открытого подключения к базе данных может привести к тому, что другие параллельные пользователи не смогут получить доступ к базе данных.

  • На платформе .NET Framework многие объекты, устанавливающие подключения ко внешним ресурсам, созданы путем использования статических фабричных методов других классов, которые управляют этими подключениями. Эти объекты должны храниться и использоваться повторно, а не удаляться и создаваться заново. Например, в служебной шине Azure объект QueueClient создается через объект MessagingFactory. На внутреннем уровне MessagingFactory управляет подключениями. Дополнительные сведения см. в статье Best Practices for performance improvements using Service Bus Messaging (Рекомендации по повышению производительности с помощью обмена сообщениями через служебную шину).

Как определить антишаблон неправильного создания экземпляров

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

  • увеличение исключений, которые указывают на нехватку системных ресурсов (например, сокетов, подключений к базе данных, дескрипторов файлов и т. д.);
  • повышение потребления памяти и сборки мусора;
  • увеличение активности базы данных, диска или сети.

Чтобы определить эту проблему, сделайте следующее:

  1. Выполните мониторинг обработки в рабочей системе, чтобы определить точки, когда время отклика замедляется или происходит сбой системы из-за нехватки ресурсов.
  2. Проанализируйте данные телеметрии, полученные в этих точках, чтобы определить, какие операции могут создавать и уничтожать ресурсоемкие объекты.
  3. Проведите нагрузочный тест для каждой подозрительной операции в управляемой тестовой среде, а не в рабочей системе.
  4. Просмотрите исходный код и проверьте, как управляются объекты брокера.

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

Пример диагностики

В следующих разделах эти шаги применяются к примеру приложения, описанному ранее.

Определение точек замедления или сбоя

На следующем рисунке показаны результаты, сформированные с помощью New Relic APM, в которых отображены операции, имеющие высокое время отклика. В этом случае нужно дополнительно изучить метод GetProductAsync в контроллере NewHttpClientInstancePerRequest. Обратите внимание, что частота ошибок также увеличивается при выполнении операций.

Панель мониторинга New Relic, где показан пример приложения, создающий экземпляр объекта HttpClient для каждого запроса

Изучение данных телеметрии и поиск связи

На следующем рисунке показаны данные, полученные с помощью профилирования потока, за период времени, соответствующие предыдущему рисунку. Система тратит немало времени, открывая подключения к сокетам, и еще больше времени, закрывая их и обрабатывая исключения.

Профилировщик потоков New Relic: пример приложения, создающего экземпляр объекта HttpClient для каждого запроса

Выполнение нагрузочного тестирования

Нагрузочное тестирование следует использовать для имитации стандартных операций, которые могут выполнять пользователи. Оно позволяет определить, какие части системы пострадали из-за исчерпания ресурсов под различными нагрузками. Выполняйте эти тесты в управляемой среде, а не в рабочей системе. На следующем графике показана пропускная способность запросов, обрабатываемых контроллером NewHttpClientInstancePerRequest, при увеличении пользовательской нагрузки до 100 одновременных пользователей.

Пропускная способность примера приложения, создающего экземпляр объекта HttpClient для каждого запроса

Сначала объем выполняемых в секунду запросов возрастает по мере увеличения рабочей нагрузки. Однако, когда имеется примерно 30 пользователей, объем успешных запросов достигает предела и система начинает создавать исключения. После этого объем исключений постепенно возрастает вместе с пользовательской нагрузкой.

В результатах нагрузочного тестирования эти сбои указаны как ошибки HTTP 500 (внутренний сервер). Анализ телеметрии показал, что эти ошибки были вызваны тем, что система исчерпала ресурсы сокетов, поскольку создавалось все больше объектов HttpClient.

На следующем графике показан подобный тест для контроллера, который создает настраиваемый объект ExpensiveToCreateService.

Пропускная способность примера приложения, создающего экземпляр объекта ExpensiveToCreateService для каждого запроса

На этот раз контроллер не создает никаких исключений, но пропускная способность все еще достигает плато, тогда как среднее время отклика увеличивается в 20 раз. (Граф использует логарифмический масштаб для времени отклика и пропускной способности.) Телеметрия показала, что создание новых экземпляров ExpensiveToCreateService является основной причиной проблемы.

Реализация решения и проверка результатов

После переключения метода GetProductAsync для совместного использования одного экземпляра HttpClient результаты второго нагрузочного тестирования показали повышение производительности. Ошибки не найдены, и система смогла обработать увеличение нагрузки до 500 запросов в секунду. Среднее время отклика было сокращено вдвое по сравнению с предыдущим тестом.

Пропускная способность примера приложения, которое повторно использует один и тот же экземпляр объекта HttpClient для каждого запроса

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

Профилировщик потоков New Relic: пример приложения, создающего один экземпляр объекта HttpClient для всех запросов

На следующем графике показано такое же нагрузочное тестирование с использованием общего экземпляра объекта ExpensiveToCreateService. Объем обработанных запросов увеличивается в соответствии с пользовательской нагрузкой, тогда как среднее время отклика остается низким.

Схема с демонстрацией похожего теста нагрузки с использованием общего экземпляра объекта ExpensiveToCreateService.