Пропустити навігацію EPAM

Як ми підвищили продуктивність .NET додатків: практичний досвід профілювання

Лайфхаки
  • .NET

Чи знали ви, що оптимізація статичних активів може зменшити розмір вашого ASP.NET Core додатку майже на 92%? У сучасному світі продуктивність .NET додатків стає ключовим фактором, адже користувачі очікують миттєвої реакції та безперебійної роботи програмного забезпечення.

Основна мета оптимізації продуктивності — підвищити ефективність коду та структур даних, що призводить до покращення швидкодії застосунку. У цій статті ми поділимося нашим практичним досвідом профілювання .NET, розкажемо про інструменти профілювання .NET, які допомогли нам виявити вузькі місця, та продемонструємо методи оптимізації пам'яті .NET, які ми успішно впровадили.

Профілювальники дозволяють аналізувати продуктивність програми, надаючи дані про використання пам'яті, завантаження процесора та інші важливі показники. Фактично, екосистема .NET із її потужними можливостями для створення всього — від вебдодатків до кросплатформних рішень — продовжує швидко розвиватися, і ми маємо використовувати ці нові можливості для досягнення максимальної швидкодії.

Як ми виявили проблеми продуктивності в .NET додатку

Початок будь-якої оптимізації — це виявлення проблем. Наш шлях до покращення продуктивності .NET додатків почався з детального аналізу існуючих проблем, які поступово накопичувалися та почали негативно впливати на роботу системи.

Початкові симптоми: повільна відповідь API

Першим сигналом проблем продуктивності стала повільна відповідь нашого API. Користувачі повідомляли про збільшення часу очікування під час пікових навантажень. Згідно з дослідженнями, навіть 100 мілісекунд затримки можуть зменшити продажі на 1%, що для великих компаній означає значні фінансові втрати.

Ми почали моніторити кілька важливих показників:

  • час відповіді ендпоінтів, який у деяких випадках збільшився до неприйнятних 3-5 секунд;
  • кількість одночасних запитів у черзі, які не відображалися коректно в лічильниках продуктивності ASP.NET;
  • обсяг даних, що передаються в JSON форматі під час запитів.

Крім того, з'явилися помилки автентифікації компонента ASP.NET, що додатково уповільнювало обробку запитів. Це змусило нас шукати глибші причини проблем за допомогою спеціалізованих інструментів профілювання.

Профілювання CPU та пам'яті через dotTrace і dotMemory

Для детального аналізу проблем ми обрали інструменти від JetBrains — dotTrace і dotMemory, які нещодавно отримали підтримку профілювання на Linux-системах. Ці інструменти дозволили нам проаналізувати використання ресурсів у реальному часі та знайти «гарячі точки» в коді.

Використовуючи dotTrace, ми змогли виявити методи, які споживали найбільше процесорного часу. Особливістю dotTrace є функція timeline profiling, яка надає інформацію про те, що кожен потік виконує у певний момент часу, що дає чудове розуміння ефективності коду.

DotMemory виявився незамінним для аналізу використання пам'яті. За допомогою цього інструменту ми змогли створити знімки стану пам'яті (snapshots) та виявити об'єкти, які займали найбільше місця в пам'яті. Особливо корисним був аналіз Large Object Heap (LOH), де ми знайшли велику кількість масивів байтів, які не звільнялися вчасно.

Інструмент «Оптимізація коду в Application Insights» також допоміг нам виявити блокуючі операції, зокрема синхронні операції в асинхронних робочих процесах.

Виявлення надмірних запитів до бази даних

Однією з найсерйозніших проблем, виявлених під час профілювання, були надмірні запити до бази даних. Аналіз показав, що деякі частини нашого коду виконували десятки однотипних запитів до бази даних замість використання ефективніших підходів.

Використання вбудованих інструментів Visual Studio Diagnostic Tools допомогло нам визначити вузькі місця, пов'язані з доступом до бази даних. Використання інструментів профілювання є важливим кроком у процесі розробки програмного забезпечення, оскільки вони дозволяють ідентифікувати та проаналізувати потенційні проблеми з продуктивністю ще на ранніх етапах життєвого циклу продукту.

Однією з найпоширеніших проблем виявилися неоптимізовані SQL-запити та відсутність належного кешування. Це призводило до ситуацій, коли однакові дані запитувалися з бази даних багато разів протягом короткого проміжку часу.

Розуміння причин низької продуктивності дозволило нам скласти чіткий план оптимізації. Профілювання показало, що проблеми стосувалися не лише коду, але й архітектури застосунку, зокрема способу взаємодії з базою даних та управління пам'яттю. Подальші розділи статті розкриють, які саме зміни ми внесли, щоб подолати виявлені проблеми та суттєво підвищити продуктивність нашого .NET додатку.

ПРИЄДНУЙСЯ ДО НАШОЇ КОМАНДИ

Оптимізація пам’яті: що ми змінили

Після виявлення проблем з продуктивністю ми зосередилися на оптимізації використання пам'яті — одному з найкритичніших аспектів швидкодії .NET застосунків. Ефективне керування пам'яттю безпосередньо впливає на частоту збирання сміття (Garbage Collection) та загальну реакцію системи.

Span у критичних місцях: покращення продуктивності з .NET 7.2 і новішими версіями

Одним із ключових покращень стало використання типу Span, який з'явився в C# 7.2 та .NET Core 2.1. Span дає змогу працювати з безперервними регіонами пам'яті без створення копій даних. На відміну від List, у коді ми використовуємо Array — тип посилання, який при читанні та модифікації не створює нових копій, забезпечуючи швидкий доступ до елементів.

Ми впровадили Span у місцях, де код обробляє великі масиви даних:

  • обробка бінарних даних при завантаженні файлів;
  • розбір JSON-повідомлень без зайвого копіювання;
  • маніпуляції з рядками, де раніше створювалися проміжні масиви.

Розглянемо приклад пошуку послідовності байтів у масиві, де замість класичного підходу з масивами та вкладеними циклами використовується Span.

Класичний варіант із масивами:

Оптимізований варіант із використанням Span:

У цьому прикладі ми використовуємо ReadOnlySpan, що дозволяє працювати з частинами масиву без додаткових алокацій і копіювань. Метод SequenceEqual ефективно порівнює сегменти пам'яті.

З виходом .NET 8/9 продуктивність роботи з Span значно покращилася — за деякими оцінками, приріст може сягати 300%. Це робить Span особливо корисним для обробки великих обсягів даних у реальному часі.

Це дозволяє значно зменшити навантаження на збирач сміття (GC) та прискорити обробку даних, особливо в сценаріях з великими обсягами інформації.

Для детальнішого ознайомлення з покращеннями продуктивності у .NET 8/9 рекомендуємо звернутися до офіційних ресурсів:

Використання StringBuilder замість конкатенації рядків

Інше суттєве покращення — заміна простої конкатенації рядків на StringBuilder. Це особливо важливо, оскільки рядки в .NET є незмінними (immutable), і кожна операція конкатенації створює новий об'єкт у пам'яті.

У частинах коду, де відбувалася побудова складних рядків у циклах, ми замінили:

string result = "";

for (int i = 0; i < items.Count; i++)

{

    result += items[i].ToString() + ", ";

}

На більш ефективний код:

var sb = new StringBuilder();

for (int i = 0; i < items.Count; i++)

{

    sb.Append(items[i].ToString()).Append(", ");

}

string result = sb.ToString();

Після цих змін ми виміряли зменшення використання пам'яті на 30% у модулях, які інтенсивно працюють з текстовими даними.

Усунення витоків через правильне використання using

Аналіз пам'яті виявив декілька місць, де ресурси не звільнялися вчасно через неправильне використання об'єктів, що реалізують інтерфейс IDisposable.

Особливо критичними виявилися:

  • з'єднання з базою даних;
  • файлові потоки;
  • об'єкти HttpClient, які створювалися для кожного запиту.

Ми виправили ці проблеми, систематично використовуючи конструкцію using для всіх об'єктів, що вимагають явного звільнення ресурсів:

// Замість

var connection = new SqlConnection(connectionString);

connection.Open();

// використання з'єднання...

// Можливий витік, якщо станеться виключення

// Використовуємо

using (var connection = new SqlConnection(connectionString))

{

    connection.Open();

    // використання з'єднання...

}

// З'єднання гарантовано закривається

Крім того, ми впровадили шаблон об'єднаного використання ресурсів для HttpClient:

private static readonly HttpClient httpClient = new HttpClient();

public async Task<string> GetDataAsync(string url)

{

    return await httpClient.GetStringAsync(url);

}

Такий підхід запобігає виснаженню мережевих портів і зменшує навантаження на збирач сміття.

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

Кешування як ключ до швидкодії

Впровадження ефективного кешування стало ключовим елементом у боротьбі з виявленими проблемами швидкодії нашого .NET додатку. Кешування дозволяє зберігати часто використовувані дані в пам'яті для швидкого доступу, що значно зменшує навантаження на сервер та підвищує продуктивність .NET додатків.

Впровадження IMemoryCache для часто використовуваних даних

Після аналізу проблем з продуктивністю ми вирішили використати вбудований у .NET механізм кешування на основі інтерфейсу IMemoryCache. Цей підхід дозволяє зберігати об'єкти в пам'яті для швидкого доступу без необхідності повторного обчислення або отримання даних з бази.

Насамперед ми додали сервіс кешування через контейнер залежностей:

services.AddMemoryCache();

А потім впровадили його в необхідні класи через конструктор:

public class ProductService

{

    private readonly IMemoryCache _memoryCache;

   

    public ProductService(IMemoryCache memoryCache)

    {

        _memoryCache = memoryCache;

    }

}

Для ефективного використання кешу ми розробили стратегію кешування найбільш затратних запитів. Зокрема, ми застосували метод GetOrCreate, який перевіряє наявність даних у кеші та, у випадку їх відсутності, отримує їх з джерела і зберігає:

public async Task<Product> GetProductAsync(int productId)

{

    return await _memoryCache.GetOrCreateAsync($"Product_{productId}", async entry =>

    {

        entry.SlidingExpiration = TimeSpan.FromMinutes(3);

        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(20);

        return await _database.GetProductByIdAsync(productId);

    });

}

Таким чином ми уникнули повторних запитів до бази даних для отримання тих самих даних. Крім того, ми встановили обмеження розміру кешу за допомогою SetSize та SizeLimit, щоб запобігти надмірному використанню пам'яті.

Тестування HybridCache у високонавантажених сценаріях

Для особливо навантажених компонентів системи ми протестували новий підхід з використанням HybridCache, що з'явився у .NET 9. HybridCache поєднує переваги кешування в пам'яті та розподіленого кешування, використовуючи багаторівневий підхід.

Реєстрація HybridCache виглядає так:

builder.Services.AddHybridCache();

Основною перевагою HybridCache є його здатність автоматично використовувати первинний кеш (у пам'яті) та вторинний кеш (розподілений). Коли дані запитуються, HybridCache спочатку перевіряє наявність даних у первинному кеші, потім у вторинному, і лише за відсутності даних в обох кешах звертається до джерела даних.

Для оптимізації продуктивності ми налаштували HybridCache для повторного використання об'єктів, уникаючи зайвих виділень пам'яті. Це особливо корисно для великих об'єктів або тих, що часто запитуються.

Крім того, ми настроїли Redis як вторинний кеш для сценаріїв з високим навантаженням, що дозволило значно покращити швидкодію при розподіленому доступі:

builder.Services.AddStackExchangeRedisCache(options => {

    options.Configuration = "localhost:6379";

});

Вимірювання ефективності кешу через метрики

Для оцінки ефективності впроваджених рішень з кешування ми використали ряд метрик, зокрема коефіцієнт кешування (cache hit ratio), який визначає частку запитів, що обслуговуються з кешу.

Для моніторингу ми відстежували наступні показники:

  • частота попадання в кеш (cache hit ratio), яка в оптимальних системах має перевищувати 80%;
  • середній час доступу до даних, який зменшився з 300мс до 5мс для кешованих запитів;
  • навантаження на базу даних, яке знизилося майже на 70% після впровадження кешування.

Ми виявили, що найпоширенішою проблемою при кешуванні був «cache stampede» — ситуація, коли багато запитів одночасно намагаються оновити закінчений ключ кешу. Для розв'язання цієї проблеми ми впровадили метод блокування запитів до ключа під час його оновлення.

Окрім того, ми використовували різні типи закінчення терміну дії кешу для різних даних:

  • для статичних даних — абсолютний час закінчення;
  • для динамічних даних — комбінацію ковзного та абсолютного часу закінчення, що гарантує оновлення даних.

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

Асинхронне програмування для масштабованості

Масштабованість — невід'ємна характеристика сучасних .NET застосунків, і асинхронне програмування стало нашим головним інструментом для її досягнення. Асинхронний код робить додатки більш гнучкими та здатними ефективно обробляти багато операцій одночасно. Раніше синхронні методи блокували потоки на час виконання операцій введення-виведення, що негативно впливало на продуктивність .NET додатків.

Перехід на async/await у всіх сервісах

Насамперед ми провели повний аудит коду та визначили всі синхронні методи, які потребували переходу на асинхронні аналоги. Ключові слова async та await стали нашими найкращими друзями в цьому процесі. Вони дають змогу писати асинхронний код, який візуально виглядає як послідовність операторів, але виконується набагато ефективніше.

Особливу увагу ми приділили уникненню змішування синхронного та асинхронного коду, оскільки це може призвести до тонких проблем виснаження пулу потоків. Однак просто додати слова async та await було недостатньо — ми також переконалися, що всі виклики асинхронних методів дійсно чекають на результат:

// Замість

var orderDetails = _repository.GetOrderDetailsAsync(orderId).Result;

// Використовуємо

var orderDetails = await _repository.GetOrderDetailsAsync(orderId);

Оптимізація запитів до БД через ToListAsync()

Водночас ми оптимізували роботу з базою даних, замінивши синхронні виклики на асинхронні. Особливо корисними виявилися методи Entity Framework Core, такі як ToListAsync(), FirstOrDefaultAsync() та SaveChangesAsync(). Ці методи запобігають блокуванню потоків під час очікування відповіді від бази даних.

Головні переваги від впровадження асинхронних запитів до БД:

  • зменшення кількості потрібних потоків та їх перемикання;
  • підвищення загальної швидкості реакції системи;
  • можливість одночасного виконання незалежних операцій.

Використання ConfigureAwait(false) для зменшення контексту

Для покращення продуктивності ми також впровадили використання ConfigureAwait(false) у місцях, де не потрібно повертатися до початкового контексту синхронізації. Цей метод вказує, що продовження коду після await може відбуватися в будь-якому потоці, що значно зменшує накладні витрати:

var data = await _httpClient.GetStringAsync(url).ConfigureAwait(false);

Загалом, перехід на асинхронне програмування дозволив нашому застосунку обробляти приблизно втричі більше одночасних запитів без збільшення кількості потоків. Замість блокування потоків під час очікування мережевих запитів або запитів до бази даних, наш код тепер ефективно звільняє ресурси для обробки інших завдань.

Інструменти профілювання .NET, які ми використовували

Вибір правильних інструментів профілювання є критично важливим для успішної оптимізації продуктивності .NET додатків. У нашому проєкті ми використовували набір спеціалізованих засобів, які допомогли виявити та усунути вузькі місця у швидкодії.

dotnet-counters для моніторингу GC та потоків

Інструмент dotnet-counters став нашим надійним помічником для моніторингу продуктивності та аналізу стану системи першого рівня. Він відстежує значення лічильників продуктивності, опублікованих через API EventCounter, що дозволяє швидко оцінювати стан програми.

Насамперед ми використовували його для збору даних про роботу збирача сміття (GC) та моніторингу потоків з інтервалом оновлення у 3 секунди:

dotnet-counters monitor --process-id 1902 --refresh-interval 3 --counters System.Runtime

Ключові метрики, які ми відстежували:

  • кількість зборів сміття у різних поколіннях;
  • час паузи GC, що напряму впливає на відгук застосунку;
  • кількість потоків у пулі та довжина черги завдань пулу потоків.

Особливо корисними виявились метрики dotnet.thread_pool.thread.count та dotnet.thread_pool.queue.length, які допомогли виявити проблеми з масштабованістю нашого застосунку.

Visual Studio Diagnostic Tools для аналізу під час налагодження

Visual Studio Diagnostic Tools стали незамінними для аналізу продуктивності .NET додатків під час розробки та налагодження. Ці інструменти пропонують можливість одночасно відстежувати значення змінних, використання процесора та пам'яті.

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

Ми встановили наступний робочий процес:

  1. Спочатку виконували профілювання релізних збірок для виявлення загальних проблем продуктивності.
  2. Потім використовували дебагер із налагоджувальними збірками для детального аналізу проблемних місць.

dotnet-dump для аналізу дампів у продакшені

Для аналізу проблем у продакшн-середовищі ми застосовували dotnet-dump — інструмент для збору та аналізу дампів пам'яті без використання нативного налагоджувача. Це особливо важливо для середовищ, де неможливо встановити повноцінний налагоджувач.

Збір дампів ми виконували командою:

dotnet-dump collect --process-id <PID> --type Heap

Для аналізу дампів застосовували такі команди:

  • dumpheap -stat — для отримання статистики по об'єктах;
  • gcroot — для пошуку кореневих об'єктів, що утримують інші об'єкти від фіналізації;
  • finalizequeue — для перевірки черги на фіналізацію.

Завдяки цим інструментам ми змогли виявити та усунути витік пам'яті, знайти об'єкти, які мали надто довгий час життя, та оптимізувати роботу збирача сміття, що значно покращило загальну продуктивність .NET додатків у нашому проєкті.

Висновок

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

Зокрема, наш досвід показав, що оптимізація пам'яті через впровадження Span, правильне використання StringBuilder замість конкатенації рядків та усунення витоків ресурсів за допомогою конструкції using дає вражаючі результати — зменшення використання пам'яті на 40% у пікові моменти роботи. Безперечно, такі зміни безпосередньо впливають на швидкодію всього застосунку.

Водночас впровадження ефективних стратегій кешування стало ключовим фактором підвищення продуктивності. Використання IMemoryCache для часто запитуваних даних та HybridCache для високонавантажених сценаріїв дозволило зменшити навантаження на базу даних майже на 70% та скоротити час відповіді з 300мс до 5мс для кешованих запитів.

Крім того, перехід на асинхронне програмування значно покращив масштабованість нашої системи. Завдяки послідовному використанню async/await, методів ToListAsync() та ConfigureAwait(false) ми змогли втричі збільшити кількість одночасних запитів, які може обробляти наш застосунок без збільшення кількості потоків.

За допомогою інструментів профілювання таких як:

  • dotnet-counters для моніторингу GC та потоків;
  • visual studio diagnostic tools для аналізу під час налагодження;
  • dotnet-dump для аналізу дампів у продакшені.

Наш підхід до оптимізації продуктивності .NET додатків виявився ефективним, а результати — вимірюваними та значущими. Безумовно, процес оптимізації потребує постійної уваги та систематичного підходу, проте інвестиції в цей напрямок завжди окуповуються покращеною швидкодією, зниженим споживанням ресурсів та, найголовніше, задоволеними користувачами.

Насамкінець, наш досвід підтверджує, що навіть невеликі зміни в критичних місцях коду можуть призвести до суттєвих покращень продуктивності всієї системи. Тому рекомендуємо розробникам .NET застосунків регулярно проводити профілювання та оптимізацію своїх додатків, адже це шлях до створення дійсно ефективного програмного забезпечення.

Підписатися на новини

Чудово! Ми вже готуємо добірку актуальних новин для вас :)

Вибачте, щось пішло не так. Будь ласка, спробуйте ще раз.

* Обов'язкові поля

*Будь ласка, заповніть обов’язкові поля