Избегайте этих ошибок в C# и станьте мастером производительности

Вы когда-нибудь чувствовали, что ваше приложение .NET движется в темпе улитки? Если ваша немедленная реакция - “да”, не волнуйтесь, вы не одиноки. Возможно, вы страдаете от последствий некоторых плохих практик, которые замаскированы под кажущиеся логичными методы.

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

Постоянный доступ к одним и тем же данным

В приложении на C# довольно часто приходится многократно запрашивать базу данных не только для получения оперативной информации, необходимой для выполнения сценария использования, но и для получения информации, которая меняется нечасто. Мы рассмотрим последний случай.

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

В последних версиях .NET это можно реализовать довольно просто и быстро. Рассмотрим следующий сценарий.

public async Task<List<AccountSenders>> GetAccountSenders()
{
  return await _context.AccountSenders.ToListAsync();
}

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

public async Task<List<AccountSenders>?> GetAccountSenders(IMemoryCache _cache)
{
    string cacheKey = "AccountSenders";

    if (!_cache.TryGetValue(cacheKey, out List<AccountSenders>? accountSenders))
    {
        accountSenders = await _context.AccountSenders.ToListAsync();

        var cacheEntryOptions = new MemoryCacheEntryOptions()
            .SetSlidingExpiration(TimeSpan.FromMinutes(5));

         _cache.Set(cacheKey, accountSenders, cacheEntryOptions);
    }
    return accountSenders;
}

Вы, наверное, думаете, что больше кода не обязательно приведет к повышению производительности, но давайте заглянем за кулисы…

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

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

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

Вот и все, это простой и понятный способ, который может значительно помочь вам в выполнении часто выполняемых запросов. Более того, это практичный способ начать внедрять кэширование в свои приложения .NET.

Плохой экземпляр HttpClient - молчаливый враг производительности

Рассматривая решение для связи с внешним сервисом по протоколу HTTP, мы почти наверняка придем к выводу об использовании HttpClient для достижения этой цели. Однако, что скрывается за созданием простого экземпляра HttpClient, который может стать молчаливым врагом производительности нашего приложения? Ну, почти все! Каждый раз, когда создается экземпляр HttpClient, открываются новые TCP-соединения, которые потребляют ресурсы и потенциально создают узкие места, особенно при высокой нагрузке.

public async Task<string> GetAsync(string url)
{
  using (var client = new HttpClient())
  {
    var response = await client.GetAsync(url);
    return await response.Content.ReadAsStringAsync();
  }
}

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

Рекомендуется использовать HttpClientFactory для создания одного общего экземпляра HttpClient.

public async Task<string> GetAsync(string url)
{
  using (var client = _httpClientFactory.CreateClient())
  {
    var response = await client.GetAsync(url);
    return await response.Content.ReadAsStringAsync();
  }
}

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

Подводя итог, можно сказать, что неправильное инстанцирование HttpClient или его использование без надлежащей конфигурации может негативно сказаться на производительности вашего приложения. Использование HttpClientFactory и правильная настройка, наоборот, могут значительно увеличить скорость и эффективность ваших HTTP-запросов.

Конкатенация строк

Конкатенация строк в .NET может повлиять на производительность по сравнению с использованием StringBuilder из-за способа работы со строками в памяти.

Когда вы конкатенируете строки с помощью оператора + или String.Concat() метода, .NET создает новый объект строки каждый раз, когда происходит операция конкатенации. Это означает, что выделение памяти происходит часто, особенно в сценариях, где несколько операций конкатенации выполняются в циклах или при масштабных манипуляциях со строками. Каждый новый строковый объект требует выделения и удаления памяти, что может привести к фрагментации памяти и увеличению ресурсов на сборку мусора.

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

Здесь приведен пример, иллюстрирующий разницу:

// Using string concatenation
string result = "";
for (int i = 0; i < 10000; i++) {
    result += i.ToString();  // Each concatenation creates a new string object
}

// Using StringBuilder
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    sb.Append(i.ToString());  // StringBuilder appends data to its internal buffer
}
string result = sb.ToString();  // Convert StringBuilder to string when needed

В приведенном выше примере многократное использование конкатенации строк (+=) внутри цикла создает новый объект строки для каждой операции конкатенации, что приводит к снижению производительности и увеличению объема используемой памяти. Однако использование метода StringBuilder's Append() эффективно добавляет данные во внутренний буфер, избегая ненужных выделений памяти и повышая производительность.

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

Исключения и NULL

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

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

Пример:

try {
    // Code that might throw exceptions
} catch (Exception ex) {
    // Exception handling logic
}

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

Неправильное использование возврата Null

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

Пример:

public string GetCustomerName(int customerId) {
    // Some logic to fetch customer name
    if (customerExists) {
        return customerName;
    } else {
        return null; // Incorrect usage if caller does not handle null properly
    }
}

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

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

  • Используйте исключения.
  • Используйте исключения для исключительных условий, а не для обычного потока управления.
  • Минимизируйте использование исключений в критических по производительности разделах кода.
  • Разрешайте ожидаемые условия ошибок с помощью кодов возврата или других механизмов вместо исключений.
  • Убедитесь, что методы, которые возвращают null, четко документируют возможность возврата нулевых значений, и вызыватели обрабатывают их должным образом.

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

Заключение

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

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

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

Счастливого кодирования!