Лекция №12. Профилирование программного обеспечения, диагностика утечек памяти и оптимизация выполнения алгоритмов.
§12.1. Почему тестировщик должен думать о производительности кода
Представьте, что вы написали программу. Она отлично справляется со своей задачей: считает налоги, отправляет отчеты, сохраняет данные. Всё работает.
Но работает медленно. Очень медленно. Пользователь нажимает кнопку — и ждет. И ждет. И ждет.
Заказчик говорит: «Программа работает». Но пользователи говорят: «Программа неудобная». Как так вышло? Просто функционально программа работает. А вот пользовательский опыт — страдает.
Обычно все начинают с функционального тестирования. Программа открывается, кнопки нажимаются, данные сохраняются. Всё по инструкции.
Пример функционального теста:
csharp
[Fact]
public void CalculateDiscount_ShouldReturnCorrectValue()
{
// Проверяем, что метод возвращает правильную скидку
var result = discountService.CalculateDiscount(100, 10);
Assert.Equal(90, result); // Тест проходит
}
Этот тест проверяет корректность возвращаемого результата, однако он никак не оценивает производительность метода — выполняется ли он достаточно быстро.
Далее идет тестирование производительности. Здесь мы уже замеряем скорость. Например: «При 100 пользователях заказ сохраняется за 2 секунды». Это уже лучше. Мы знаем, что есть проблема. Пример теста производительности (NBomber):
csharp
var scenario = Scenario.Create("test", async context =>
{
var request = Http.CreateRequest("GET", "/api/products");
var response = await Http.Send(httpClient, request);
return response;
})
.WithLoadSimulations(
Simulation.Inject(100, TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(30))
);
Тестирование производительности позволяет ответить на вопрос, выдерживает ли система нагрузку в 100 RPS, однако он не детализирует, какой именно компонент или метод является узким местом. Из-за этого остаётся открытым вопрос о том, что именно вызывает замедление: база данных, сетевой запрос или неоптимальный алгоритм.
Тесты производительности показывают, что плохо, но не показывают почему. Это как услышать стук в двигателе и сказать: «Да, стучит». А что стучит — непонятно.
Чтобы понять, где именно кроется баг или узкое место, разработчики используют специальный инструмент, который позволяет заглянуть «под капот» работающей программы, который называется профилирование.
Профилирование
— это как рентген для программы. Оно не просто говорит: «У вас температура». Оно показывает: «У вас воспаление легких». Конкретно — правое легкое, нижняя доля.
Пример отчета профилировщика:
wireframe
=================================================================
CPU Profiling Report
=================================================================
Top 5 slowest methods:
1. OrderService.SaveOrder() → 2,345 ms (45% of total)
2. PaymentService.ProcessPayment() → 1,234 ms (23% of total)
3. UserRepository.GetUser() → 890 ms (17% of total)
4. EmailService.SendNotification() → 456 ms (8% of total)
5. Logger.Log() → 234 ms (4% of total)
Memory Profiling Report
=================================================================
Top 5 memory allocations:
1. OrderService.Orders list → 256 MB
2. PaymentService.Transactions list → 128 MB
3. UserRepository.Cache → 64 MB
4. Logger.Buffer → 32 MB
5. EmailService.EmailQueue → 16 MB
Что мы видим:
1. SaveOrder() — самый медленный метод (2.3 секунды). На него уходит 45% времени.
2. Orders list — потребляет 256 МБ памяти.
Теперь понятно, что исправлять. Вместо «что-то тормозит» мы видим: «Метод SaveOrder выполняется 2 секунды, потому что он делает 1000 запросов к базе данных в цикле».
Сравнительная таблица функциональных тестов, тестов производительности и профилирования представлена ниже:
| Характеристика | Функциональные тесты | Тесты производительности | Профилирование |
|---|---|---|---|
| Что проверяет | «Работает или нет?» | «Как быстро?» | «Почему медленно?» |
| Когда | При каждом push | Регулярно (CI/CD) | По требованию (при проблемах) |
| Инструменты | xUnit, Jest | NBomber, k6 | Visual Studio Diagnostics, dotMemory, dotTrace |
| Результат | ✅ / ❌ | RPS, Latency, Error Rate | Отчет с методами и памятью |
| Кто использует | Разработчики, Тестировщики | Тестировщики | Разработчики, Тестировщики |
Давайте приведем простую аналогию в виде врача и пациента.
| Что делает | Аналогия в жизни |
|---|---|
| Функциональное тестирование | Врач спрашивает: «Что у вас болит?» |
| Тестирование производительности | Врач измеряет давление и пульс |
| Профилирование | Врач делает УЗИ и видит, что именно внутри |
УЗИ
— это, своего рода, профилирование. Только оно показывает реальную причину.
Почему тестировщику это важно? Тестировщик — это тот, кто смотрит на систему снаружи. Он видит, как пользователь взаимодействует с программой. И если пользователь недоволен скоростью — это проблема тестировщика.
Разработчик знает, как работает код. Тестировщик знает, что чувствует пользователь. Профилирование помогает соединить эти две картины.
§12.2. Что такое профилирование и зачем оно нужно
Профилирование
— это анализ работающей программы, который показывает, что именно происходит внутри неё во время выполнения.
Теперь проблема найдена. Можно исправлять.
Что измеряет профилировщик? Профилировщик измеряет три основные вещи:
1. Время выполнения методов (CPU Time)
Показывает, сколько времени тратится на выполнение каждого метода. Отчет профилировщика (CPU) выглядит примерно следующим образом:
wireframe
┌─────────────────────────────────────────────────────────────┐
│ Метод │ Время │ % от общего времени │
├──────────────────────────┼───────────┼──────────────────────┤
│ OrderService.SaveOrder() │ 2,345 мс │ 45% │
│ PaymentService.Pay() │ 1,234 мс │ 23% │
│ UserRepository.GetUser() │ 890 мс │ 17% │
│ ... остальные методы │ 800 мс │ 15% │
└─────────────────────────────────────────────────────────────┘
Мы видим, что метод SaveOrder() занимает почти половину всего времени. Это и есть узкое место.
2. Использование памяти (Memory Usage)
Показывает, сколько памяти потребляет программа и какие объекты её занимают. Отчет профилировщика (Memory) выглядит примерно следующим образом:
wireframe
┌─────────────────────────────────────────────────────────────┐
│ Объект / Структура │ Размер │ Количество │
├───────────────────────────┼───────────┼─────────────────────┤
│ List<Order> │ 256 МБ │ 1 объект │
│ List<Transaction> │ 128 МБ │ 1 объект │
│ User[] │ 64 МБ │ 1000 объектов │
│ ... остальное │ 32 МБ │ ... │
└─────────────────────────────────────────────────────────────┘
Мы видим, что List<Order> потребляет 256 МБ. Это много. Возможно, там хранятся данные, которые уже не нужны.
3. Количество вызовов методов
Показывает, сколько раз вызывается каждый метод. Отчет профилировщика (Вызовы) выглядит примерно следующим образом:
wireframe
┌─────────────────────────────────────────────────────────────┐
│ Метод │ Количество вызовов │
├──────────────────────────┼──────────────────────────────────┤
│ UserRepository.GetUser() │ 1,234 │
│ OrderService.Validate() │ 987 │
│ Logger.Log() │ 5,678 │
└─────────────────────────────────────────────────────────────┘
Мы видим, что метод GetUser() вызывается 1234 раза. Может быть, его можно вызывать реже?
Использование профилирования тестировщиками похоже на то, как врач ставит диагноз. Представьте, что вы пришли к врачу.
| Что происходит | Аналогия с программой |
|---|---|
| «У меня болит голова» | «Программа работает медленно» |
| Врач измеряет давление | Тестировщик запускает тест производительности |
| Врач делает УЗИ | Тестировщик запускает профилировщик |
| Врач видит: «У вас воспаление» | Профилировщик показывает: «Метод X тормозит» |
| Врач назначает лечение | Разработчик оптимизирует метод X |
| Голова перестает болеть | Программа ускоряется |
Только УЗИ (профилирование) показывает, что именно внутри.
Многие путают профилирование и отладку. Это разные вещи.
| Характеристика | Отладка (Debugging) | Профилирование |
|---|---|---|
| Что делает | Пошагово выполняет код | Анализирует работу в целом |
| Когда используется | Когда код падает с ошибкой | Когда код работает медленно или потребляет много памяти |
| Что показывает | Значения переменных в конкретный момент | Статистику: время, память, вызовы |
| Результат | Исправление конкретной ошибки | Понимание, что оптимизировать |
Простая аналогия:
- Отладка — это как смотреть на пациента под микроскопом, чтобы найти бактерию.
- Профилирование — это как сделать общий анализ крови и увидеть, что воспаление есть, но не знать, где именно.
После профилирования тестировщик получает:
1. Отчет с цифрами. Не «мне кажется, медленно», а «вот конкретные миллисекунды».
2. Доказательства. «Я знаю, какой метод тормозит, вот данные».
3. Приоритеты. «Метод А тормозит сильнее всего, его оптимизируем в первую очередь».
4. Инструмент для разговора с разработчиками. Не просто «у нас проблемы», а «вот конкретная проблема, вот где она, давайте исправлять».
Запомните
Тестировщик — это диагност. Он не лечит (не пишет код), но он находит проблему и говорит врачу (разработчику), где болит.
§12.3. Виды профилирования
Профилирование
— это не один инструмент, а целый набор подходов. В зависимости от того, что мы ищем, мы используем разные виды профилирования.
Не будем отходить далеко от предыдущих аналогий. Врач использует разные инструменты для диагностики:
- Термометр — для температуры.
- Тонометр — для давления.
- УЗИ — для внутренних органов.
- Анализ крови — для состава крови.
Так и в профилировании: каждый вид показывает свою картину.
Вид №1. CPU-профилирование
CPU - профилирование
- это анализ того, сколько времени процессор тратит на выполнение каждого метода.
Данный метод нам демонстрирует:
- Какие методы выполняются дольше всего.
- Какие методы вызываются чаще всего.
- Где процессор «буксует» (выполняет тяжелые вычисления).
Его нужно использовать тогда, когда программа работает медленно, «тормозит», но не падает. Пример отчета выглядит примерно следующим образом:
wireframe
CPU Profiling Report
=================================================================
Top 5 slowest methods:
1. OrderService.SaveOrder() → 2,345 ms (45% of total)
2. PaymentService.ProcessPayment() → 1,234 ms (23% of total)
3. ReportGenerator.Generate() → 890 ms (17% of total)
4. EmailService.SendNotification() → 456 ms (8% of total)
5. Logger.Log() → 234 ms (4% of total)
В данном случае, мы видим, что SaveOrder() занимает почти половину всего времени. Он и есть узкое место.
При использовании данного метода профилирования, мы ищем методы, которые выполняются дольше всего. Это кандидаты на оптимизацию, чтобы программа работала быстрее.
Вид №2. Memory-профилирование
Memory-профилирование
- это анализ того, сколько памяти потребляет программа и какие объекты её занимают.
Данный метод нам демонстрирует:
- Какие объекты занимают больше всего памяти.
- Какие объекты не освобождаются (утечки).
- Как меняется потребление памяти со временем.
Его нужно использовать тогда, когда программа потребляет слишком много памяти или падает с OutOfMemoryException. Пример отчета выглядит примерно следующим образом:
wireframe
Memory Profiling Report
=================================================================
Top 5 memory consumers:
1. List<Order> (OrderService.Orders) → 256 MB (40% of total)
2. Dictionary<int, User> (Cache) → 128 MB (20% of total)
3. Transaction[] (PaymentService) → 64 MB (10% of total)
4. Logger.Buffer → 32 MB (5% of total)
5. EmailQueue (EmailService) → 16 MB (2.5% of total)
В данном случае, мы видим, что List<Order> потребляет 256 МБ. Это много. Возможно, там хранятся старые заказы, которые уже не нужны.
Что в данном случае мы ищем:
- Объекты, которые занимают слишком много памяти.
- Объекты, которые не освобождаются (утечки).
- Объекты, которые растут бесконтрольно.
Вид №3. Allocation-профилирование
Allocation-профилирование
- это анализ того, где и когда создаются новые объекты.
Данный метод нам демонстрирует:
- Какие методы создают больше всего объектов.
- Где происходит самая активная аллокация (выделение памяти).
- Какие объекты создаются часто, но живут недолго.
Его нужно использовать тогда, когда программа работает медленно из-за частого создания объектов (нагрузка на GC — сборщик мусора). Пример отчета выглядит примерно следующим образом:
wireframe
Allocation Profiling Report
=================================================================
Top 5 methods by allocations:
1. OrderService.CreateOrder() → 12,345 objects (45% of total)
2. ReportGenerator.BuildReport() → 8,234 objects (30% of total)
3. UserService.GetUser() → 3,456 objects (12% of total)
4. Logger.Log() → 2,134 objects (8% of total)
5. PaymentService.Pay() → 1,234 objects (5% of total)
В данном случае, мы видим, что метод CreateOrder() создает 12 345 объектов. Это огромное количество.
Что в данном случае мы ищем:
- Методы, которые создают слишком много объектов.
- Объекты, которые создаются в циклах.
- Объекты, которые можно переиспользовать.
Для наглядности, таблица видов профилирования представлена ниже:
| Вид | Что измеряет | Когда использовать | Что ищем |
|---|---|---|---|
| CPU-профилирование | Время выполнения методов | Программа медленная | Медленные методы |
| Memory-профилирование | Потребление памяти | OutOfMemoryException | Утечки памяти |
| Allocation-профилирование | Создание объектов | Нагрузка на GC | Лишние аллокации |
При работе с профилированием мы три типа проблем:
Так же при работе с профилированием может возникнуть вопрос:
На практике поиск узких мест всегда идет по одному и тому же сценарию — от общей жалобы пользователя до конкретной строчки кода, которую нужно исправить. Давайте разберем этот процесс на простом примере.
Шаг №1. Слышим жалобу
Пользователь: «Программа стала тормозить после последнего обновления!».
Шаг №2. Запускаем CPU-профилирование
Отчет: OrderService.SaveOrder() — 45% времени. Поняли: это метод сохранения заказа.
Шаг №3. Смотрим внутрь метода
Внутри SaveOrder():
- Проверка: 1% времени
- Сохранение в БД: 44% времени ← проблема здесь!
Шаг №4. Проверяем, что происходит в БД
Оказывается, метод делает 1000 запросов к БД в цикле. Вместо одного запроса с массовой вставкой.
Шаг №5. Исправляем
Оптимизируем: один запрос вместо 1000. Время выполнения падает с 2.3 секунд до 50 мс.
Шаг №6. Профилируем снова
Отчет: SaveOrder() теперь — 2% времени. Программа ускорилась.
§12.4. Утечки памяти: как их найти и исправить
Утечка памяти
— это ситуация, когда программа выделяет память, но не освобождает её, даже когда она больше не нужна.
Представьте, что вы наливаете воду в ведро. Вода поступает (выделяется память). Но на дне ведра есть дыра, через которую вода не уходит (память не освобождается). Ведро переполняется, вода заливает пол (программа падает).
В C# ситуация сложнее, чем в языках без сборщика мусора (C, C++). В C# сборщик мусора (GC) автоматически освобождает память, когда объекты перестают использоваться.
Но сборщик мусора может ошибаться: он не освобождает объекты, если считает, что они всё еще нужны. Это и есть утечка памяти в управляемой среде.
Утечки памяти опасны, потому что они происходят постепенно. Программа может работать часы или дни, прежде чем память закончится. Схема роста памяти:

Что происходит на схеме:
| Время | Состояние |
|---|---|
| 0-1 час | Память стабильна (50 МБ) |
| 1-2 часа | Память растет (100 МБ) |
| 2-3 часа | Память быстро растет (500 МБ) |
| 3-4 часа | Память заканчивается → OutOfMemoryException |
Пользователь не понимает, почему программа упала. Она работала, работала, а потом вдруг упала. Никаких ошибок не было.
Причин утечек в C# может быть несколько.
Причина №1. Незакрытые ресурсы (Dispose)
Когда вы используете ресурсы (файлы, соединения с БД, сетевые потоки), они требуют явного освобождения. Если вы забыли вызвать Dispose(), ресурсы остаются в памяти. Пример:
csharp
// ❌ УТЕЧКА: файл не закрывается
public void ReadFile(string path)
{
var stream = new FileStream(path, FileMode.Open);
// ... работа с файлом ...
// Нет вызова stream.Dispose() или using!
// Файл остается открытым, память не освобождается
}
// ✅ ИСПРАВЛЕНО: файл автоматически закрывается
public void ReadFile(string path)
{
using var stream = new FileStream(path, FileMode.Open);
// ... работа с файлом ...
// При выходе из using вызывается Dispose()
}
Чтобы найти эту ошибку, взгляните на код: профилировщик памяти показывает, что объект FileStream не освобождается, хотя должен быть закрыт.
Причина №2. Подписка на события (Event Handlers)
Когда вы подписываетесь на событие, объект-подписчик держит ссылку на источник события.
Если вы не отписались, объект не может быть собран сборщиком мусора. Пример:
csharp
// ❌ УТЕЧКА: не отписались от события
public class Button
{
public event EventHandler Click;
}
public class Window
{
private readonly Button _button;
public Window(Button button)
{
_button = button;
_button.Click += OnButtonClick; // Подписались
}
private void OnButtonClick(object sender, EventArgs e)
{
// ... обработка ...
}
// Нет отписки от события!
// Window не может быть собран GC, пока жив Button
}
// ✅ ИСПРАВЛЕНО: отписываемся при закрытии окна
public class Window : IDisposable
{
private readonly Button _button;
public Window(Button button)
{
_button = button;
_button.Click += OnButtonClick;
}
public void Dispose()
{
_button.Click -= OnButtonClick; // Отписались!
}
}
Если профилировщик показывает, что объект Window продолжает жить в памяти, хотя должен быть уничтожен, причиной чаще всего является неудаленная подписка на событие.
Причина №3. Статические ссылки (Static References)
Статические поля живут всё время работы программы. Если вы положили объект в статическое поле, он никогда не будет удален. Пример:
csharp
// ❌ УТЕЧКА: объекты в статическом списке растут бесконечно
public static class Cache
{
public static List<Order> Orders = new List<Order>();
}
public class OrderService
{
public void SaveOrder(Order order)
{
// Каждый заказ сохраняется в статический список
Cache.Orders.Add(order);
// Список растет бесконечно, заказы никогда не удаляются
}
}
// ✅ ИСПРАВЛЕНО: использовать кеш с ограничением размера
public class Cache
{
private readonly List<Order> _orders = new List<Order>();
private readonly int _maxSize = 1000;
public void Add(Order order)
{
_orders.Add(order);
if (_orders.Count > _maxSize)
{
_orders.RemoveAt(0); // Удаляем старые
}
}
}
Если профилировщик показывает, что список Cache.Orders постоянно растет и занимает слишком много памяти, ищите место, куда данные бесконечно добавляются без очистки или ограничения по размеру.
Так же, как и причин, симптомов утечек может быть несколько.
Симптом №1. OutOfMemoryException
wireframe
System.OutOfMemoryException: Exception of type 'System.OutOfMemoryException' was thrown.
at System.Collections.Generic.List`1.Add(...)
at OrderService.SaveOrder(...)
В данном случае, программа попыталась выделить память, но её не осталось. Всё, что можно было выделить — уже занято.
Симптом №2. Замедление работы
wireframe
Программа работала быстро → через час стала медленной → и так далее
Когда памяти мало, система начинает использовать диск (swap). Это в сотни раз медленнее, чем работа с оперативной памятью.
Симптом №3. Рост потребления памяти
wireframe
Запуск: 50 МБ
Через 1 час: 100 МБ
Через 2 часа: 250 МБ
Через 3 часа: 500 МБ
→ Падение
В данном случае, необходимо следить за графиком потребления памяти. Если он постоянно растет — есть утечка.
Для того, чтобы найти утечку памяти, необходимо следовать следующим шагам.
Шаг №1. Запустить профилировщик памяти
Использовать Visual Studio Diagnostic Tools, dotMemory или PerfView.
Шаг №2. Сделать снимок (Snapshot) памяти
wireframe
Снимок 1: при запуске (50 МБ)
Снимок 2: через 1 час (100 МБ)
Снимок 3: через 2 часа (250 МБ)
Шаг №3. Сравнить снимки
Профилировщик показывает, какие объекты появились между снимками:
wireframe
Diff between Snapshot 1 and Snapshot 3:
┌─────────────────────────────────────────────────────────────┐
│ Тип объекта │ Новые объекты │ Занято памяти │
├──────────────────────────┼────────────────┼─────────────────┤
│ Order │ 10 000 │ 50 МБ │
│ FileStream │ 500 │ 10 МБ │
│ EventHandler │ 100 │ 5 МБ │
└─────────────────────────────────────────────────────────────┘
В данном случае, мы видим, что Order создал 10 000 новых объектов. Это явно утечка.
Шаг №4. Найти, кто создает эти объекты
Профилировщик показывает, какой метод создал объекты:
wireframe
Order objects created by:
┌─────────────────────────────────────────────────────────────┐
│ Метод │ Количество созданных объектов │
├─────────────────────────────┼───────────────────────────────┤
│ OrderService.SaveOrder() │ 10 000 │
└─────────────────────────────────────────────────────────────┘
Шаг №5. Исправить
Теперь понятно, что SaveOrder() создает объекты Order, которые никогда не удаляются. Нужно добавить механизм очистки.
Чтобы успешно бороться с утечками памяти, важно не только находить их с помощью профилировщика, но и знать типовые способы устранения. Основные причины проблем и методы их решения собраны в таблице ниже:
| Причина | Как исправить |
|---|---|
| Незакрытые ресурсы | Использовать using или try-finally с Dispose() |
| Подписка на события | Отписываться при уничтожении объекта (-=) |
| Статические ссылки | Ограничить размер коллекций, удалять старые объекты |
| Кеш без ограничений | Добавить максимальный размер, удалять по времени (TTL) |
Запомните
Если объект создан — он должен быть удален. Если вы создаете объект, вы отвечаете за его уничтожение.
§12.5. Инструменты профилирования для .NET
В мире .NET есть несколько инструментов для профилирования. Они делятся на две категории:
| Категория | Что делают | Примеры |
|---|---|---|
| Профилировщики | Анализируют работающую программу | Visual Studio Diagnostics, dotMemory, dotTrace, PerfView |
| Бенчмарки | Измеряют производительность кода в изоляции | BenchmarkDotNet |
Простая аналогия:
- Профилировщик — это как врач, который смотрит на пациента целиком и ищет проблему.
- Бенчмарк — это как лабораторный анализ, который проверяет конкретный показатель.
Инструмент №1. Visual Studio Diagnostic Tools
Visual Studio Diagnostic Tools
- это строенный инструмент прямо в
Visual Studio. Доступен в менюDebug → Windows → Show Diagnostic Tools.
Он умеет:
| Возможность | Что показывает |
|---|---|
| CPU Usage | Какие методы загружают процессор |
| Memory Usage | Какие объекты занимают память |
| Allocations | Где и когда создаются объекты |
Его нужно использовать тогда, когда нужно быстро проверить производительность, не устанавливая дополнительных инструментов. Для того, чтобы его запустить, необходимо следовать следующим шагам:
1. Открыть проект в Visual Studio.
2. Запустить отладку (F5).
3. В окне Diagnostic Tools выбрать нужный инструмент.
4. Профилировать программу.
5. Остановить и посмотреть отчет.
Плюсы:
- Бесплатно (входит в Visual Studio).
- Просто использовать.
- Достаточно для базового анализа.
Минусы:
- Ограниченные возможности.
- Не подходит для сложных случаев.
Инструмент №2. dotMemory (JetBrains)
dotMemory (JetBrains)
- это инструмент для анализа памяти от компании JetBrains (создатели
ReSharper,IntelliJ IDEA).
Он умеет:
| Возможность | Что показывает |
|---|---|
| Снимки памяти (Snapshots) | Состояние памяти в разные моменты времени |
| Сравнение снимков | Какие объекты появились между снимками |
| Поиск утечек | Объекты, которые не освобождаются |
| Анализ ссылок | Почему объект не удаляется |
Его нужно использовать тогда, когда Visual Studio Diagnostics не хватает, и нужно глубокое исследование утечек памяти. Его работа выглядит следующим образом (примерные шаги):
1. Запустить dotMemory.
2. Прикрепиться к запущенному приложению.
3. Сделать снимок (Snapshot).
4. Выполнить действия в программе.
5. Сделать второй снимок.
6. Сравнить снимки.
7. Увидеть, какие объекты появились и почему не удалились.
Плюсы:
- Мощный анализ памяти.
- Находит сложные утечки.
- Удобный интерфейс.
Минусы:
- Платный (но есть триал-версия).
- Требует времени на освоение.
Инструмент №3. dotTrace (JetBrains)
dotTrace (JetBrains)
- это инструмент для анализа CPU от той же компании JetBrains. Он умеет:
| Возможность | Что показывает |
|---|---|
| Время выполнения методов | Какой метод выполняется дольше всего |
| Количество вызовов | Какой метод вызывается чаще всего |
| Линейный профиль | Где время тратится в каждом методе |
| Горячие пути | Самые медленные участки кода |
Его необходимо использовать тогда, когда программа работает медленно, и нужно найти, какой метод тормозит. Его работа выглядит следующим образом (примерные шаги):
1. Запустить dotTrace.
2. Прикрепиться к запущенному приложению.
3. Начать профилирование.
4. Выполнить действия в программе.
5. Остановить профилирование.
6. Увидеть, какой метод занял больше всего времени.
Плюсы:
- Мощный анализ
CPU. - Находит узкие места в производительности.
- Интегрируется с
Visual Studio.
Минусы:
- Платный (но есть триал-версия).
- Требует времени на освоение.
Инструмент №4. PerfView (Microsoft)
PerfView (Microsoft)
- это бесплатный инструмент от Microsoft для глубокого анализа производительности.
Он умеет:
| Возможность | Что показывает |
|---|---|
| CPU анализ | Какие методы загружают процессор |
| Memory анализ | Утечки памяти, GC, аллокации |
| Событийный анализ | Что происходит в системе в моменты времени |
| Профилирование продакшена | Можно запускать на работающем сервере |
Его необходимо использовать тогда, когда нужно профилировать продакшен-окружение или глубоко копать в сложных случаях. Его работа выглядит следующим образом (примерные шаги):
1. Скачать PerfView с сайта Microsoft.
2. Запустить от имени администратора.
3. Выбрать Collect → Start Collection.
4. Выполнить действия в программе.
5. Остановить сбор.
6. Открыть отчет.
Плюсы:
- Бесплатный.
- Мощный (может профилировать продакшен).
- От
Microsoft.
Минусы:
- Сложный интерфейс.
- Сложно освоить новичкам.
Инструмент №5. BenchmarkDotNet
BenchmarkDotNet
- это библиотека для написания бенчмарков (измерения производительности кода в изоляции).
Отличие от профилировщиков заключается в следующем:
| Характеристика | Профилировщик | BenchmarkDotNet |
|---|---|---|
| Что делает | Анализирует работающую программу | Измеряет конкретный метод |
| Когда использовать | Когда программа медленная | Когда нужно сравнить варианты кода |
| Результат | Отчет о всех методах | Время выполнения конкретного метода |
| Точность | Приблизительная | Высокая (среднее по 1000 запусков) |
Простая аналогия:
- Профилировщик — это как посмотреть на трассу и увидеть, где пробка.
- BenchmarkDotNet — это как замерить скорость конкретной машины на гоночном треке.
Пример:
csharp
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser] // Покажет аллокации
public class MyBenchmarks
{
private readonly Calculator _calculator = new Calculator();
[Benchmark]
public int SumByLoop()
{
var result = 0;
for (int i = 0; i < 1000; i++)
result += i;
return result;
}
[Benchmark]
public int SumByLinq()
{
return Enumerable.Range(0, 1000).Sum();
}
}
// Запуск
class Program
{
static void Main() => BenchmarkRunner.Run<MyBenchmarks>();
}
Результат:
wireframe
┌─────────────────┬───────────┬──────────────┬─────────────────┐
│ Method │ Mean │ Error │ Allocated │
├─────────────────┼───────────┼──────────────┼─────────────────┤
│ SumByLoop │ 1.50 µs │ 0.03 µs │ 0 B │
│ SumByLinq │ 8.20 µs │ 0.15 µs │ 152 B │
└─────────────────┴───────────┴──────────────┴─────────────────┘
Мы видим, что SumByLoop быстрее и не создает объектов. SumByLinq медленнее и создает объекты.
Данный метод необходимо использовать тогда, когда нужно сравнить два варианта кода и выбрать быстрый.
Арсенал тестировщика полон специализированных утилит, но каждая из них лучше всего решает свою задачу. Чтобы не запутаться в выборе, воспользуйтесь шпаргалкой по подбору инструментов под конкретную проблему:
| Ситуация | Инструмент |
|---|---|
| Быстро проверить, что медленно | Visual Studio Diagnostic Tools |
| Найти утечку памяти | dotMemory |
| Найти медленный метод | dotTrace |
| Глубокий анализ продакшена | PerfView |
| Сравнить два варианта кода | BenchmarkDotNet |
§12.6. Профилирование в CI/CD: как не допустить деградацию
В предыдущих параграфах мы говорили о профилировании как о разовом действии: «Программа стала медленной — запустили профилировщик — нашли причину — исправили».
Но есть одна проблема: завтра кто-то снова сделает код медленным. И вы снова будете искать. И снова. И снова.
Как это выглядит в разработке:
wireframe
Месяц 1: Мы оптимизировали код. Всё быстро.
Месяц 2: Кто-то добавил новую фичу. Код стал чуть медленнее.
Месяц 3: Кто-то еще добавил фичу. Код стал еще медленнее.
Месяц 6: Программа снова тормозит. Мы не знаем, когда это произошло.
Проблема заключается в том, что деградация производительности происходит постепенно. Никто не замечает, что стало чуть хуже. А через полгода становится очевидно, что всё плохо.
Но существует решение! Необходимо автоматизировать профилирование в CI/CD. Каждый раз, когда разработчик отправляет код, конвейер не только запускает тесты, но и проверяет производительность. Схема конвейера с профилированием выглядит примерно следующим образом:

Что происходит при автоматизации профилирования в CI / CD:
1. Разработчик отправляет код (git push).
2. Конвейер собирает проект.
3. Конвейер запускает функциональные тесты (проверяет, что ничего не сломалось).
4. Конвейер запускает профилирование (замеряет время выполнения ключевых методов).
5. Конвейер сравнивает результаты с эталонными значениями.
6. Если время выполнения выросло — конвейер падает.
Чтобы автоматические проверки приносили пользу, а не просто создавали видимость работы, важно четко понимать метрики и цели контроля.
Что именно попадает под прицел автоматики и зачем это нужно проверять, показано в таблице ниже:
| Что замеряем | Как замеряем | Почему это важно |
|---|---|---|
| Время выполнения API | Запускаем ключевые эндпоинты | Проверяем, не стали ли они медленнее |
| Время выполнения метода | Запускаем бенчмарки (BenchmarkDotNet) | Проверяем, не стал ли метод медленнее |
| Потребление памяти | Замеряем память после выполнения | Проверяем, нет ли утечек |
| Время запуска приложения | Замеряем время старта | Проверяем, что приложение не запускается дольше |
Пример:
wireframe
Эталонное значение (из прошлого релиза):
- GET /api/products: 50 мс
- POST /api/orders: 200 мс
- Запуск приложения: 3 секунды
Текущее значение (после изменений):
- GET /api/products: 52 мс (+4%) ✅ OK
- POST /api/orders: 280 мс (+40%) ❌ ПАДЕНИЕ!
- Запуск приложения: 3.5 сек (+17%) ⚠️ Внимание!
Конвейер падает на POST /api/orders — время выросло на 40%, что больше порога в 20%.
Главный вопрос:
| Уровень | Что значит | Действие |
|---|---|---|
| < 10% | Изменение незначительное | Предупреждение, релиз разрешен |
| 10–20% | Изменение заметное | Предупреждение, релиз разрешен, но нужно разобраться |
| > 20% | Производительность сильно упала | Конвейер падает, релиз заблокирован |
Пример:
wireframe
Оригинальное время: 100 мс
- 20% = 120 мс → пользователь не заметит
- 50% = 150 мс → пользователь заметит
- 100% = 200 мс → пользователь раздражается
А разве у нас уже нет тестов производительности? Есть. Но они решают другую задачу.
| Что делают тесты производительности | Что делают профилирование и бенчмарки в CI/CD |
|---|---|
| Проверяют систему под большой нагрузкой | Проверяют отдельные методы и эндпоинты |
| Запускаются редко (в main) | Запускаются при каждом push |
| Находят проблемы на уровне всей системы | Находят проблемы в конкретном коде |
Они дополняют друг друга:
wireframe
Тесты производительности (NBomber, k6)
→ "Система стала медленнее"
→ Но не знаем, где именно
Профилирование / Бенчмарки в CI/CD
→ "Метод X стал медленнее на 30%"
→ Знаем конкретное место
Для разработчика это выглядит в виде нескольких сценариев.
Разработчик:
1. Видит красный конвейер.
2. Читает сообщение: «POST /api/orders выросло на 40%».
3. Проверяет изменения в OrderController.
4. Находит, что добавил новый запрос к БД в цикле.
5. Исправляет.
6. Отправляет снова.
7. Конвейер зеленый.
А что делать, если нет эталонных значений. В самом начале проекта эталонных значений нет. Их нужно создать. Первый шаг – запустить конвейер и сохранить текущие значения как эталон.
wireframe
Первый запуск: сохраняем значения
→ GET /api/products: 52ms
→ POST /api/orders: 205ms
→ Startup: 3.2s
Последующие запуски: сравниваем с эталоном
→ Если выросло более чем на 20% — конвейер падает
Если изменения в производительности неизбежны (например, добавили новую тяжелую фичу), обновляем эталон вручную.
§12.7. Антипаттерны в профилировании
Профилирование
— это мощный инструмент. Но только если вы используете его правильно. Есть несколько типичных ошибок, которые сводят на нет все усилия по оптимизации.
Главная мысль этого параграфа заключается в том, что профилирование – это про регулярный анализ и исправление того, что замедляет систему.
Антипаттерн №1. «Профилируем в продакшене»
Почему это опасно:
| Что может произойти | Последствия |
|---|---|
| Профилировщик замедляет работу сервера | Пользователи жалуются на тормоза |
| Профилировщик потребляет память | Сервер падает с OutOfMemoryException |
| Профилировщик записывает чувствительные данные | Утечка данных пользователей |
| Профилировщик меняет поведение программы | Пользователи видят странное поведение |
Почему профилировщик замедляет работу? Профилировщик не просто «смотрит» на программу. Он встраивается в неё:
- Записывает каждый вызов метода.
- Замеряет время выполнения каждой операции.
- Отслеживает каждое выделение памяти.
Это создает дополнительную нагрузку. На тестовой среде это нормально. На продакшене — катастрофа. Как должно быть правильно:
| Где профилировать | Почему |
|---|---|
| Staging (тестовая среда) | Копия продакшена, но без пользователей |
| Локально (dev) | Для быстрой проверки |
| Продакшен | Только в крайнем случае, с осторожностью, в нерабочее время |
Антипаттерн №2. «Запустили один раз и хватит»
Почему это опасно:
| Что происходит со временем | Последствия |
|---|---|
| Появляются новые фичи | Новый код может быть медленным |
| Меняются библиотеки | Обновления могут замедлить работу |
| Растет количество данных | Запросы становятся медленнее |
| Меняется бизнес-логика | Медленные методы перестают быть медленными, медленными становятся другие |
Пример:
wireframe
Месяц 1: Оптимизировали. Всё быстро.
Месяц 3: Добавили новую фичу. Код стал чуть медленнее.
Месяц 6: Добавили еще фичу. Код стал еще медленнее.
Месяц 9: Снова тормозит. Профилировщик не запускали полгода.
Как должно быть правильно:
| Что делать | Как часто |
|---|---|
| Запускать профилировщик | При каждом релизе |
| Проверять производительность ключевых методов | Еженедельно |
| Анализировать утечки памяти | Ежемесячно |
Антипаттерн №3. «Чиним код без профилировщика»
Почему это опасно:
| Что происходит | Последствия |
|---|---|
| Оптимизируют не тот метод | Тратят время на бесполезную работу |
| Оптимизируют не ту часть | Тратят время на бесполезную работу |
| Оптимизация ломает код | Вводят новые баги |
Разработчик потратил день на оптимизацию GetUser() и ничего не добился. Потому что проблема была в SaveOrder().
Как должно быть правильно:
| Что делать | Почему |
|---|---|
| Запустить профилировщик | Знать точно, что тормозит |
| Посмотреть отчет | Увидеть, какой метод занимает больше всего времени |
| Оптимизировать самый медленный метод | Получить максимальный эффект |
Антипаттерн №4. «Игнорируем предупреждения»
Почему это опасно:
| Что происходит со временем | Последствия |
|---|---|
| Метод был медленным | Стал еще медленнее |
| Его вызывают чаще | Проблема становится больше |
| Данных стало больше | Проблема становится больше |
| Пользователи жалуются | Но разработчик не знает, почему |
Пример:
wireframe
Профилировщик: ⚠️ SaveOrder() — 2 секунды.
Разработчик: "Ну, 2 секунды — не критично."
Через месяц:
Профилировщик: ⚠️ SaveOrder() — 4 секунды.
Разработчик: "Ну, пока работает."
Через полгода:
Профилировщик: ❌ SaveOrder() — 10 секунд. Конвейер упал.
Медленный код становится падающим кодом.
Как должно быть правильно:
| Что делать | Почему |
|---|---|
| Исправлять медленные методы | Пока они не стали падающими |
| Устанавливать пороговые значения | Если метод > 1 секунды — исправлять |
| Следить за тенденцией | Если время растет — разбираться сразу |
Избежать типичных ошибок при работе с производительностью помогут правила хорошего тона. Вот чего стоит придерживаться на практике, чтобы оптимизация была эффективной и безопасной:
| Что нужно делать | Как |
|---|---|
| Профилировать в staging | Не в продакшене |
| Профилировать регулярно | При каждом релизе, еженедельно |
| Сначала профилировать, потом оптимизировать | Не чинить вслепую |
| Исправлять медленные методы | Не игнорировать предупреждения |