Профилирование .NET-сервисов в Docker: dotMemory и dotTrace

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

1. Сбор снапшотов в Docker и Production

Подготовка инфраструктуры для профилирования в Docker

Профилирование высоконагруженных сервисов в изолированных контейнерах требует предварительной подготовки. Инструменты dotTrace и dotMemory используют нативные библиотеки (Profiler Agent), которые внедряются в процесс через CLR Profiling API. Чтобы безопасно снять снапшот (снимок состояния приложения) в среде Linux, необходимо обеспечить доступ профилировщика к файловой системе контейнера.

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

Пример настройки директорий на хост-машине:

После скачивания и распаковки архивов JetBrains.dotTrace.CommandLineTools и JetBrains.dotMemory.Console в соответствующие папки, можно выполнять команды профилирования через docker exec, указывая PID процесса (обычно 1 для главного процесса в контейнере).

Альтернативные способы снятия снапшотов без консоли

Работа с консолью внутри Docker не всегда удобна, особенно если доступ к production-серверам ограничен. В современных версиях инструментов существуют альтернативные подходы:

  • Удаленное профилирование через JetBrains Rider. IDE позволяет подключиться к удаленной машине по SSH, выбрать запущенный Docker-контейнер и процесс внутри него. Rider сам загрузит необходимые агенты, снимет снапшот и скачает его на локальную машину для анализа. Это работает как для Linux-контейнеров, так и для Windows-сервисов.
  • Использование dotnet-monitor. Это официальный инструмент от Microsoft, который запускается как sidecar-контейнер (соседний контейнер в том же поде Kubernetes или сети Docker). Он предоставляет HTTP API для снятия дампов памяти и трейсов производительности без необходимости заходить в консоль целевого контейнера.
  • Встроенные утилиты .NET CLI. Инструменты dotnet-trace, dotnet-dump и dotnet-gcdump могут быть включены в базовый образ. Они генерируют файлы в форматах nettrace и gcdump, которые затем можно открыть в dotTrace и dotMemory соответственно.
  • Выбор режима профилирования производительности

    Безопасность production-среды напрямую зависит от выбранного режима профилирования. Неправильный выбор может привести к деградации производительности (высокий overhead) или падению сервиса.

    | Режим | Принцип работы | Влияние на систему | Когда использовать | | :--- | :--- | :--- | :--- | | Sampling | Периодический опрос стеков вызовов (например, каждые 10 мс). | Минимальное. Безопасно для production. | Поиск "горячих" методов, оценка общей загрузки CPU. | | Timeline | Регистрация событий (ETW на Windows, LTTng на Linux) и состояний потоков. | Низкое. Безопасно для production. | Анализ блокировок, работы с БД, асинхронного кода и пауз сборщика мусора (GC). | | Tracing | Запись времени начала и окончания каждого вызова метода. | Очень высокое. Только для локальной разработки. | Детальный анализ алгоритмической сложности, точный подсчет количества вызовов. | | Line-by-Line | Анализ времени выполнения каждой строки кода. | Критически высокое. | Микрооптимизация конкретных функций. |

    > Для production-систем используйте только Sampling или Timeline. Режимы Tracing и Line-by-line замедляют выполнение приложения в десятки раз, что неминуемо приведет к тайм-аутам у клиентов.

    Анализ производительности: локализация узких мест

    При анализе снапшота в dotTrace важно правильно интерпретировать метрики. Одна из самых частых ошибок — слепая вера показателю .NET total.

    Почему показатель .NET total может обманывать

    Метрика .NET total (или Total Time) показывает общее время, прошедшее от входа в метод до выхода из него. Однако это время не всегда означает активную работу процессора. Если метод делает HTTP-запрос к стороннему API или выполняет SQL-запрос, поток переходит в состояние ожидания (Waiting). В этот момент процессор свободен, но Total Time продолжает расти.

    Чтобы понять, где кроется проблема — в коде приложения, в базе данных или во взаимных блокировках (deadlocks), необходимо использовать режим Timeline и анализировать состояния потоков:

    * Running: Поток активно выполняет инструкции на CPU. Проблема в самом коде (сложные вычисления, бесконечные циклы). * Waiting: Поток ждет завершения операции ввода-вывода (I/O), ответа от сети или базы данных. Проблема во внешних зависимостях. * Blocked: Поток пытается захватить блокировку (например, lock в C#), которая уже занята другим потоком. Это признак проблем с синхронизацией или взаимных блокировок.

    Поиск медленных методов и агрегация потоков

    Современные .NET-приложения активно используют асинхронное программирование (async/await). Из-за этого выполнение одного HTTP-запроса пользователя может начинаться на одном потоке из пула потоков (Thread Pool), приостанавливаться при обращении к БД, а возобновляться уже на совершенно другом потоке.

    Именно поэтому при анализе производительности требуется агрегировать вызовы со всех потоков. Если смотреть историю только одного потока, вы увидите разорванные фрагменты разных пользовательских запросов. Агрегация по логическому стеку вызовов (Call Tree) позволяет собрать асинхронный запрос воедино и найти конкретный медленный вызов среди множества повторов.

    Для поиска самых медленных методов контроллера используйте представление Hotspots (Горячие точки). Оно инвертирует дерево вызовов, показывая методы, которые суммарно заняли больше всего времени (по показателю Own Time — времени выполнения самого метода без учета вызванных им подпрограмм).

    Группировка по Namespace и активность системы

    Группировка вызовов по Namespace (пространству имен) — мощный инструмент для поиска архитектурных ошибок. Например, если вы сгруппировали вызовы и видите, что пространство имен Microsoft.EntityFrameworkCore потребляет 80% времени в слое представления (Views или Controllers), это явный признак проблемы N+1 (выполнение множества мелких SQL-запросов в цикле вместо одного большого).

    Чтобы понять, что в момент снятия снапшота делал пользователь, операционная система или СУБД, режим Timeline собирает системные события. Вы можете увидеть моменты выделения памяти, паузы сборщика мусора (GC) и операции файлового ввода-вывода. Если приложение "зависло", но CPU не загружен, а потоки находятся в состоянии Waiting, проблема находится за пределами приложения.

    Мониторинг базы данных

    Как определить допустимую длительность SQL-запроса? Универсального ответа нет, так как это зависит от SLA (соглашения об уровне обслуживания). Однако для OLTP-систем (онлайн-обработка транзакций) запросы длительностью более 100-200 мс обычно считаются подозрительными.

    Если dotTrace показывает, что приложение просто ждет ответа от БД, профилирование .NET-кода заканчивается. Необходимо переходить к инструментам СУБД. Например, в Microsoft SQL Server следует использовать динамические административные представления (DMV), такие как sys.dm_exec_requests для просмотра текущих выполняющихся запросов, или инструмент SQL Server Profiler / Extended Events для анализа планов выполнения и поиска отсутствующих индексов.

    Анализ памяти: поиск утечек в dotMemory

    Утечка памяти в управляемой среде .NET означает, что объекты больше не используются логикой приложения, но сборщик мусора (Garbage Collector) не может их удалить, так как на них остались активные ссылки (например, забытая подписка на событие или статическая коллекция).

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

    Кто удерживает сущности в памяти?

    Чтобы выяснить, кто именно является причиной утечки, в dotMemory используются два ключевых инструмента:

  • Граф удержания (Retention Graph). Показывает цепочку ссылок от выбранного объекта до GC Root (корня сборки мусора). Корень сборки — это статические переменные, локальные переменные в выполняющихся потоках или объекты в очереди финализации. Граф наглядно демонстрирует, через какие классы тянется ссылка, не дающая удалить объект.
  • Доминаторы (Dominators). Доминатор — это объект, который эксклюзивно удерживает в памяти другие объекты. Если удалить доминатор, сборщик мусора сможет очистить весь граф объектов, зависящих от него. Представление Dominators позволяет быстро найти "верхушку айсберга" — один массив или кэш-менеджер, который держит гигабайты данных.
  • Сравнительный анализ двух снапшотов (до выполнения бизнес-операции и после ее завершения) — самый надежный способ найти утечку. Если после завершения операции объекты, созданные во время ее выполнения, остались в памяти (появились в секции Survived), необходимо изучить их граф удержания.

    2. Анализ памяти и поиск утечек

    Природа утечек памяти в управляемой среде

    В языках с ручным управлением памятью (например, C или C++) утечка возникает, когда разработчик выделяет память, но забывает её освободить. В .NET работает сборщик мусора (Garbage Collector, GC), который автоматически очищает память от неиспользуемых объектов. Поэтому утечка памяти в .NET имеет иную природу — это ситуация, когда объекты больше не нужны для логики приложения, но сборщик мусора не может их удалить, так как на них остались активные ссылки.

    Сборщик мусора начинает свою работу от корней сборки (GC Roots). К ним относятся статические переменные, локальные переменные в выполняющихся потоках и объекты в очереди финализации. Если от GC Root можно проложить путь по ссылкам до объекта, этот объект считается живым.

    > Утечка в .NET — это всегда логическая ошибка проектирования, при которой время жизни ссылки на объект превышает время жизни самого объекта.

    Симптомы проблем с памятью в Docker

    При работе в изолированных контейнерах Linux проблемы с памятью проявляются иначе, чем на виртуальных машинах. Контейнер имеет жесткие лимиты (например, 1 ГБ RAM). Когда приложение достигает этого лимита, ядро Linux мгновенно убивает процесс с ошибкой OOMKilled (Out Of Memory).

    Однако перед самым падением вы заметите резкую деградацию производительности. Это связано с тем, что сборщик мусора видит нехватку памяти и начинает непрерывно запускать полную сборку (Full GC), пытаясь освободить хоть что-то. В этот момент все потоки приложения приостанавливаются. Если в профилировщике dotTrace в режиме Timeline вы видите, что потоки находятся в состоянии ожидания, а системные события показывают 90% времени в фазе Garbage Collection, проблема кроется не в медленном коде, а в утечке памяти или избыточном аллоцировании.

    Безопасное снятие дампа памяти в Production

    Снятие полного дампа памяти (снапшота) — это ресурсоемкая операция. В момент сохранения дампа процесс приложения полностью замораживается. Если куча (Heap) занимает 5 ГБ, заморозка может длиться 5–10 секунд, что приведет к тайм-аутам у клиентов.

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

  • Использование dotnet-monitor. Этот sidecar-контейнер предоставляет HTTP API. Вызов эндпоинта /dump позволяет безопасно снять дамп в формате gcdump. Формат gcdump весит в десятки раз меньше полного дампа, так как содержит только граф ссылок между объектами, без самих значений строк или массивов. Это идеальный компромисс для поиска утечек в production.
  • Триггеры по лимитам. В dotnet-monitor можно настроить автоматическое снятие дампа, если потребление памяти превысило определенный порог (например, 800 МБ из доступного 1 ГБ). Это позволяет поймать утечку прямо перед падением контейнера.
  • Методология поиска утечек: Сравнение снапшотов

    Анализ одного снапшота редко дает ответы. Вы увидите миллионы объектов и не сможете понять, какие из них должны там находиться, а какие — «застряли». Главный инструмент в dotMemory — это сравнение двух состояний.

    Правильный алгоритм локализации утечки:

  • Запустить приложение и прогреть его (выполнить базовые запросы, чтобы заполнились кэши).
  • Снять Снапшот А (базовое состояние).
  • Выполнить бизнес-операцию, которая подозревается в утечке (например, загрузить большой Excel-отчет 10 раз).
  • Принудительно вызвать сборку мусора (кнопка Force GC в профилировщике), чтобы удалить временные объекты.
  • Снять Снапшот B.
  • В dotMemory выбрать функцию Compare Snapshots.
  • Вас будет интересовать колонка Survived (Выжившие). Это объекты, которые были созданы между снапшотами А и B, но не были удалены после принудительной сборки мусора. Именно среди них находится утечка.

    Кто удерживает сущности: Доминаторы и Граф удержания

    Когда вы нашли подозрительные выжившие объекты (например, 10 000 экземпляров класса UserProfile), возникает главный вопрос: кто именно не дает сборщику мусора их удалить?

    Для ответа на этот вопрос dotMemory предоставляет два аналитических представления.

    Доминаторы (Dominators)

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

    Представьте дерево: ствол — это доминатор, а листья — утекшие объекты. Представление Dominators сортирует объекты не по их собственному размеру, а по размеру удерживаемой ими памяти (Retained Size).

    Пример: Вы видите, что массив Dictionary<string, UserProfile> сам по себе занимает всего КБ, но его Retained Size составляет МБ. Это означает, что именно этот словарь является доминатором и причиной утечки. Найдя доминатор, вы находите «верхушку айсберга».

    !Схема графа удержания памяти и доминаторов

    Граф удержания (Retention Graph)

    Если доминатор найден, нужно понять, как он связан с корнем сборки. Граф удержания визуализирует всю цепочку ссылок от выбранного объекта до GC Root.

    Анализируя граф, вы читаете его снизу вверх (от объекта к корню). Вы можете увидеть следующую картину: Экземпляр UserProfile удерживается в List<UserProfile> который является полем класса ReportGenerator который подписан на статическое событие SystemEvents.OnReportFinished.

    Статическое событие является корнем сборки. Пока вы явно не отпишетесь от него (используя оператор -=), весь класс ReportGenerator и все его списки будут жить в памяти вечно.

    Поколения кучи и фрагментация

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

    | Поколение | Описание | Частота очистки | Типичные объекты | | :--- | :--- | :--- | :--- | | Gen 0 | Самые новые объекты. Сборка происходит мгновенно. | Очень часто | Локальные переменные в методах, DTO для HTTP-запросов. | | Gen 1 | Буферная зона. Объекты, пережившие одну сборку Gen 0. | Реже | Объекты с чуть более долгим жизненным циклом (например, транзакции БД). | | Gen 2 | Долгоживущие объекты. Сборка (Full GC) очень дорогая и останавливает приложение. | Редко | Статические кэши, синглтоны, пулы соединений. |

    Если приложение постоянно создает объекты, которые живут достаточно долго, чтобы попасть в Gen 2, но затем становятся не нужны, возникает кризис среднего возраста (Mid-life crisis). Сборщику мусора приходится часто запускать дорогую очистку Gen 2, что убивает производительность.

    Куча больших объектов (LOH)

    Любой объект размером более байт (обычно это большие массивы или строки) сразу попадает в специальную кучу — Large Object Heap (LOH).

    Особенность LOH в том, что по умолчанию сборщик мусора не сжимает эту память после удаления объектов (чтобы не тратить процессорное время на копирование огромных массивов). Из-за этого возникает фрагментация.

    Пример: Вы выделили массив на КБ, затем еще один на КБ. Первый удалили. Появилась «дыра» в КБ. Если теперь вам нужен массив на КБ, он не влезет в эту дыру, и ОС придется выделить новую память. Со временем куча может вырасти до ГБ, хотя реально занято всего МБ.

    В dotMemory фрагментация LOH видна на вкладке Heap Fragmentation. Если вы видите много свободного места между занятыми блоками в LOH, необходимо пересмотреть архитектуру: использовать ArrayPool<T> для переиспользования массивов или MemoryStream с пулированием вместо постоянного создания больших строк.

    3. Режимы профилирования производительности

    Режимы профилирования в dotTrace

    Профилирование производительности — это процесс измерения времени выполнения кода и потребления ресурсов процессора. В отличие от анализа памяти, где мы ищем «застрявшие» объекты, здесь наша цель — найти узкие места (bottlenecks), из-за которых приложение работает медленно или потребляет слишком много CPU.

    Инструмент JetBrains dotTrace предлагает три основных режима профилирования. Выбор неправильного режима может привести к искажению результатов или падению production-сервера.

    | Режим | Принцип работы | Нагрузка (Overhead) | Применение | | :--- | :--- | :--- | :--- | | Sampling | Опрашивает стек вызовов с заданной частотой (например, каждые 10 мс). | Низкая (~5-10%) | Быстрая оценка CPU-нагрузки в production. | | Tracing | Внедряет счетчики в начало и конец каждого метода. | Огромная (до 1000%) | Локальный поиск алгоритмических проблем в конкретном алгоритме. | | Timeline | Собирает события ОС (ETW на Windows, LTTng на Linux) и состояния потоков. | Средняя (~15-30%) | Стандарт для Docker/Linux. Анализ асинхронного кода, I/O и блокировок. |

    Режим Tracing работает как секундомер для каждого шага. Он дает точное количество вызовов (например, метод отработал ровно раз), но из-за накладных расходов время выполнения искажается. Маленький метод, который в реальности выполняется 1 наносекунду, под профилировщиком может выполняться 50 наносекунд. В production этот режим использовать категорически запрещено.

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

    Режим Timeline — это современный стандарт. Он не просто смотрит на стек вызовов, но и записывает, чем именно занят поток: считает ли он данные, ждет ли ответа по сети или заблокирован другим потоком.

    Иллюзия метрики .NET Total и состояния потоков

    Открыв снапшот в режиме Timeline, вы увидите дерево вызовов (Call Tree) и время выполнения каждого метода. Самая частая ошибка новичков — сортировка по столбцу .NET total и попытка оптимизировать метод с самым большим значением.

    Метрика .NET total показывает Wall Time (астрономическое время) — сколько времени прошло на часах с момента входа в метод до выхода из него.

    Общее время выполнения метода складывается из трех компонентов:

    Где: — общее время (тот самый .NET total*). * — время, когда процессор реально выполнял инструкции (состояние Running). * — время ожидания внешних событий: ответа от БД, HTTP-запроса или таймера (состояние Waiting). * — время простоя из-за синхронизации потоков, например, ожидание lock (состояние Blocked).

    Пример: Метод контроллера GetUserData делает запрос к базе данных. Запрос выполняется 5 секунд. Значение .NET total для этого метода будет 5000 мс. Но если вы посмотрите на состояние потока, вы увидите, что составляет всего 2 мс, а — 4998 мс.

    Оптимизировать код C# в этом методе бессмысленно — проблема кроется в базе данных или сети. Если же вы видите большое значение , это означает, что потоки выстроились в очередь и ждут друг друга (взаимные блокировки или узкое горлышко в виде lock).

    Агрегация потоков в эпоху async/await

    Исторически один HTTP-запрос обрабатывался одним потоком от начала до конца. В современном .NET используется асинхронная модель (async/await), которая кардинально меняет картину.

    Когда асинхронный метод делает запрос к БД (await dbContext.Users.ToListAsync()), текущий поток не блокируется. Он возвращается в пул потоков (Thread Pool) и берет другую задачу. Когда БД отвечает, продолжение метода (continuation) может быть подхвачено любым другим свободным потоком.

    !Схема выполнения асинхронного запроса в .NET

    Если в dotTrace вы будете анализировать конкретный поток (Thread 1), вы увидите лишь обрывки разных запросов от разных пользователей. Чтобы увидеть целостную картину одного бизнес-процесса, необходимо использовать функцию Async Causality (или группировку по Tasks). Профилировщик склеит фрагменты выполнения из разных потоков в единый логический стек вызовов, показывая, как запрос перетекал из потока в поток.

    Поиск архитектурных проблем: Группировка по Namespace

    Когда вы открываете Call Tree крупного приложения, вы видите миллионы вызовов базовых методов .NET: String.Concat, List.Add, Task.Run. Они находятся на вершине списка по потреблению CPU, но эта информация бесполезна. Вы не можете переписать String.Concat.

    Чтобы найти прикладную ошибку, нужно переключить вид дерева вызовов на группировку по Namespace (пространствам имен) или Subsystems.

    Вместо россыпи системных методов вы увидите архитектурные блоки вашего приложения:

  • MyCompany.Billing.Calculators — 45% CPU
  • MyCompany.Catalog.Search — 30% CPU
  • Microsoft.EntityFrameworkCore — 15% CPU
  • Это сразу дает понимание: проблема не в том, что приложение медленно складывает строки, а в том, что модуль биллинга выполняет слишком тяжелые вычисления. Раскрыв конкретный Namespace, вы найдете бизнес-метод, который является источником нагрузки.

    Локализация медленных методов и SQL-запросов

    Чтобы найти конкретный медленный вызов среди тысяч быстрых, используется фильтрация по времени. В dotTrace Timeline есть временная шкала. Выделив на ней пик нагрузки (например, спайк CPU до 100%), вы отфильтруете дерево вызовов только для этого момента.

    Если проблема кроется в базе данных (вы видите большое состояние Waiting на методах Entity Framework или Dapper), возникает вопрос: какая длительность SQL-запроса считается допустимой?

    Для OLTP-систем (онлайн-обработка транзакций) нормой считается выполнение простых запросов за 1–10 мс. Если запрос выполняется дольше 50–100 мс, это повод для расследования.

    Важно понимать границу ответственности инструментов:

  • dotTrace показывает, какой именно метод C# вызвал запрос и сколько времени приложение ждало ответа.
  • dotTrace НЕ показывает, почему БД работала медленно.
  • Если вы локализовали проблему до конкретного SQL-запроса, профилирование .NET заканчивается. Далее необходимо переходить к инструментам самой СУБД. Например, в PostgreSQL нужно использовать расширение pg_stat_statements или EXPLAIN ANALYZE, а в MS SQL Server — Extended Events или Query Store, чтобы проверить наличие индексов, актуальность статистики и планы выполнения.

    Разделение ответственности: Память vs Производительность

    Часто возникает путаница между утечками памяти и просадками производительности.

    Если вы хотите выяснить, кто именно удерживает сущности в памяти и не дает сборщику мусора их удалить (утечка), вам нужен dotMemory и его инструмент Dominators (Доминаторы) или Retention Graph (Граф удержания).

    Однако в dotTrace есть вкладка Memory Allocation. Она не показывает утечки. Она показывает трафик памяти — какие методы создают больше всего временных объектов (Gen 0). Если метод в цикле создает миллионы строк, они будут быстро удалены и не вызовут утечку (dotMemory покажет, что все чисто). Но постоянное создание и удаление объектов заставит сборщик мусора (Garbage Collector) работать непрерывно. В dotTrace вы увидите, что потоки приложения приостанавливаются, а системный процесс GC потребляет 80% CPU. В этом случае оптимизация аллокаций напрямую приведет к росту производительности.

    4. Поиск узких мест в коде

    Поиск узких мест в коде

    Профилирование производительности в современных микросервисных архитектурах — это не просто поиск медленного цикла for. Когда .NET-сервис работает в Docker-контейнере на ОС Linux, на его скорость влияют ограничения оркестратора, сетевые задержки, работа сборщика мусора и пул потоков. Чтобы не гадать, почему приложение тормозит, необходимо использовать профилировщики, такие как JetBrains dotTrace и dotMemory.

    Безопасное профилирование в Production

    Запуск профилировщика с графическим интерфейсом прямо на production-сервере невозможен и опасен. Современный подход в экосистеме .NET заключается в использовании легковесных CLI-утилит, которые собирают данные и сохраняют их в файл (снапшот) для последующего анализа на локальной машине разработчика.

    Для безопасного снятия снапшотов без использования консоли внутри самого контейнера применяется инструмент dotnet-monitor. Он запускается как отдельный процесс (sidecar-контейнер) и предоставляет HTTP API. Вы можете отправить POST-запрос на определенный эндпоинт, и dotnet-monitor аккуратно соберет дамп памяти или трейс производительности, не останавливая работу основного сервиса. Для Windows-сервисов также доступны утилиты dotnet-trace и dotnet-dump, которые работают через командную строку, но потребляют минимум ресурсов.

    Выбор режима профилирования

    Инструмент dotTrace предлагает три основных режима работы. Ошибка в выборе режима сделает собранные данные бесполезными или приведет к падению production-сервера из-за перегрузки.

    | Режим | Принцип работы | Нагрузка (Overhead) | Применение | | :--- | :--- | :--- | :--- | | Sampling | Опрашивает стек вызовов с заданной частотой (например, каждые 10 мс). | Низкая (~5-10%) | Быстрая оценка CPU-нагрузки в production. | | Tracing | Внедряет счетчики в начало и конец каждого метода. | Огромная (до 1000%) | Локальный поиск алгоритмических проблем в конкретном алгоритме. Категорически запрещен в production. | | Timeline | Собирает события ОС (ETW на Windows, LTTng на Linux) и состояния потоков. | Средняя (~15-30%) | Стандарт для Docker/Linux. Анализ асинхронного кода, I/O и блокировок. |

    Режим Timeline является предпочтительным для большинства задач, так как он записывает не только то, какой код выполнялся, но и то, чем был занят поток в моменты простоя.

    Иллюзия метрики .NET Total и состояния потоков

    Открыв снапшот в режиме Timeline, вы увидите дерево вызовов (Call Tree). Самая частая ошибка — отсортировать методы по столбцу .NET total и пытаться оптимизировать метод с самым большим значением.

    Метрика .NET total показывает Wall Time (астрономическое время) — сколько времени прошло на реальных часах с момента входа в метод до выхода из него.

    Общее время выполнения метода складывается из трех компонентов:

    Где: — общее время выполнения метода (.NET total*). * — время, когда процессор реально выполнял инструкции кода (состояние Running). * — время ожидания внешних событий: ответа от базы данных, HTTP-запроса или таймера (состояние Waiting). * — время простоя из-за синхронизации потоков, например, ожидание освобождения lock (состояние Blocked).

    Пример: Метод контроллера GetUserData делает запрос к базе данных. Запрос выполняется 5 секунд. Значение .NET total для этого метода будет 5000 мс. Но если вы посмотрите на состояние потока, вы увидите, что составляет всего 2 мс, а — 4998 мс.

    Оптимизировать код C# в этом методе бессмысленно — проблема кроется в базе данных или сети. Если же вы видите большое значение , это означает, что потоки выстроились в очередь и ждут друг друга (взаимные блокировки или узкое горлышко в виде lock). Анализ состояний потоков позволяет точно понять, что делало приложение в момент снятия снапшота: считало данные (Running), ждало СУБД (Waiting) или конфликтовало за ресурсы (Blocked).

    Агрегация потоков в эпоху async/await

    Исторически один HTTP-запрос обрабатывался одним потоком от начала до конца. В современном .NET используется асинхронная модель (async/await), которая кардинально меняет картину.

    Когда асинхронный метод делает запрос к БД (await dbContext.Users.ToListAsync()), текущий поток не блокируется. Он возвращается в пул потоков (Thread Pool) и берет другую задачу. Когда БД отвечает, продолжение метода (continuation) может быть подхвачено любым другим свободным потоком.

    !Схема выполнения асинхронного запроса в .NET

    Если в dotTrace вы будете анализировать конкретный поток (например, Thread 1), вы увидите лишь обрывки разных запросов от разных пользователей. Чтобы увидеть целостную картину одного бизнес-процесса, необходимо использовать функцию Async Causality (или группировку по Tasks). Профилировщик склеит фрагменты выполнения из разных потоков в единый логический стек вызовов, показывая, как запрос перетекал из потока в поток. Без этой агрегации найти медленный метод контроллера в асинхронном приложении практически невозможно.

    Поиск архитектурных проблем: Группировка по Namespace

    Когда вы открываете Call Tree крупного приложения, вы видите миллионы вызовов базовых методов .NET: String.Concat, List.Add, Task.Run. Они находятся на вершине списка по потреблению CPU, но эта информация бесполезна. Вы не можете переписать системный метод String.Concat.

    Чтобы найти прикладную ошибку, нужно переключить вид дерева вызовов на группировку по Namespace (пространствам имен) или Subsystems.

    Вместо россыпи системных методов вы увидите архитектурные блоки вашего приложения:

  • MyCompany.Billing.Calculators — 45% CPU
  • MyCompany.Catalog.Search — 30% CPU
  • Microsoft.EntityFrameworkCore — 15% CPU
  • Это сразу дает понимание: проблема не в том, что приложение медленно складывает строки, а в том, что модуль биллинга выполняет слишком тяжелые вычисления. Раскрыв конкретный Namespace, вы найдете бизнес-метод, который является источником нагрузки.

    Локализация медленных методов и SQL-запросов

    Чтобы найти конкретный медленный вызов среди тысяч быстрых повторов, используется фильтрация по времени. В dotTrace Timeline есть временная шкала. Выделив на ней пик нагрузки (например, спайк CPU до 100% или долгую паузу), вы отфильтруете дерево вызовов только для этого конкретного момента времени.

    Если проблема кроется в базе данных (вы видите большое состояние Waiting на методах Entity Framework или Dapper), возникает вопрос: какая длительность SQL-запроса считается допустимой?

    Для OLTP-систем (онлайн-обработка транзакций) нормой считается выполнение простых запросов за 1–10 мс. Если запрос выполняется дольше 50–100 мс, это повод для расследования.

    Важно понимать границу ответственности инструментов:

  • dotTrace показывает, какой именно метод C# вызвал запрос и сколько времени приложение ждало ответа.
  • dotTrace НЕ показывает, почему БД работала медленно.
  • Если вы локализовали проблему до конкретного SQL-запроса, профилирование .NET заканчивается. Далее необходимо переходить к инструментам самой СУБД. Например, в MS SQL Server нужно использовать Extended Events или Query Store, а в PostgreSQL — расширение pg_stat_statements или команду EXPLAIN ANALYZE, чтобы проверить наличие индексов, актуальность статистики и планы выполнения.

    Разделение ответственности: Память vs Производительность

    Часто возникает путаница между утечками памяти и просадками производительности.

    Если вы хотите выяснить, кто именно удерживает сущности в памяти и не дает сборщику мусора их удалить (утечка), вам нужен dotMemory. В нем используются инструменты Dominators (Доминаторы — объекты, эксклюзивно удерживающие другие объекты) и Retention Graph (Граф удержания — цепочка ссылок от объекта до корня сборки мусора).

    Однако в dotTrace есть вкладка Memory Allocation. Она не показывает утечки. Она показывает трафик памяти — какие методы создают больше всего временных объектов (Gen 0). Если метод в цикле создает миллионы строк, они будут быстро удалены и не вызовут утечку (dotMemory покажет, что все чисто). Но постоянное создание и удаление объектов заставит сборщик мусора (Garbage Collector) работать непрерывно. В dotTrace вы увидите, что потоки приложения приостанавливаются, а системный процесс GC потребляет 80% CPU. В этом случае оптимизация аллокаций (например, использование StringBuilder или ArrayPool) напрямую приведет к росту производительности.

    5. Диагностика взаимодействия с БД

    Диагностика взаимодействия с БД

    При профилировании .NET-сервисов, работающих в Docker-контейнерах, разработчики чаще всего сталкиваются с одной и той же дилеммой: приложение работает медленно, но кто в этом виноват — неоптимизированный код C# или база данных?

    В микросервисной архитектуре на ОС Linux сетевые задержки, пулы соединений и блокировки транзакций сливаются в единый ком проблем. Инструменты JetBrains dotTrace и dotMemory позволяют точно провести границу между проблемами приложения и проблемами инфраструктуры, не прибегая к гаданию на логах.

    Анатомия времени: почему .NET Total обманывает

    Когда вы снимаете снапшот производительности с production-сервера (используя dotnet-monitor или dotnet-trace в режиме Timeline) и открываете его в dotTrace, первым инстинктом является сортировка дерева вызовов по столбцу .NET total. Это самая распространенная ошибка новичков.

    Показатель .NET total отражает Wall Time (астрономическое время) — то есть время, прошедшее на настенных часах с момента входа в метод до выхода из него.

    В реальности время выполнения любого метода складывается из трех компонентов:

    Где: — общее время выполнения метода (.NET total*). * — процессорное время, когда ядро CPU физически выполняло инструкции вашего кода. * — время ожидания внешних событий (ответ от СУБД, HTTP-запрос к другому микросервису, таймер). * — время простоя из-за синхронизации (ожидание освобождения lock, нехватка потоков в пуле).

    Представьте метод GetOrderDetailsAsync, который делает запрос к PostgreSQL. Запрос выполняется 2 секунды. Значение .NET total для этого метода составит 2000 мс. Однако, если вы посмотрите на распределение времени, то увидите, что равно всего 1 мс, а — 1999 мс.

    Оптимизировать циклы, строки или алгоритмы внутри этого метода абсолютно бессмысленно. Процессор почти не тратил на него ресурсы — приложение просто «спало», ожидая ответа по сети.

    !Распределение времени между приложением и базой данных

    Анализ состояний потоков

    Чтобы понять, что именно делало приложение в момент снятия снапшота, необходимо анализировать состояния потоков (Thread States). В dotTrace Timeline они выделены разными цветами на временной шкале.

    | Состояние | Цвет в dotTrace | Что означает | Где искать проблему | | :--- | :--- | :--- | :--- | | Running | Зеленый | Поток активно выполняет код на CPU. | Тяжелые алгоритмы, бесконечные циклы, агрессивная работа сборщика мусора (GC). | | Waiting | Желтый | Поток ждет завершения I/O операции. | Медленные SQL-запросы, сетевые задержки, медленные внешние API, чтение с диска. | | Blocked | Красный | Поток заблокирован другим потоком. | Взаимные блокировки (Deadlocks), узкие места в виде lock, исчерпание Thread Pool. |

    Если график вашего приложения преимущественно зеленый, проблема кроется в коде C#. Если он желтый — проблема в инфраструктуре (БД, сеть). Если красный — у вас архитектурная проблема с конкурентным доступом к ресурсам.

    Проблема агрегации в асинхронном коде

    Исторически в синхронном коде один HTTP-запрос обрабатывался одним потоком от начала до конца. В современном .NET с использованием async/await эта парадигма разрушена.

    Когда вы вызываете await dbContext.Orders.FirstOrDefaultAsync(), текущий поток не блокируется в ожидании ответа от СУБД. Он возвращается в пул потоков (Thread Pool) и начинает обслуживать запросы других пользователей. Когда база данных присылает ответ, продолжение вашего метода (continuation) подхватывается любым другим свободным потоком.

    > Асинхронность в .NET похожа на работу официанта в ресторане. Официант (поток) принимает заказ у столика (HTTP-запрос), передает его на кухню (База данных) и не стоит у плиты в ожидании блюда. Он идет обслуживать другие столики. Когда блюдо готово, его может принести клиенту уже другой свободный официант.

    Если в профилировщике вы попытаетесь проанализировать один конкретный поток (например, Thread 5), вы увидите хаотичный набор обрывков разных методов от разных пользователей.

    Чтобы увидеть целостную картину одного бизнес-процесса, необходимо использовать функцию Async Causality (или группировку по Tasks). Профилировщик использует события ETW/LTTng, чтобы «склеить» фрагменты выполнения из разных потоков в единый логический стек вызовов. Только после включения этой агрегации вы сможете найти, какой именно метод контроллера инициировал долгий вызов к БД.

    Границы ответственности профилировщика

    Допустим, вы отфильтровали снапшот по времени (выделили пик нагрузки), сгруппировали вызовы по Namespace (чтобы отсеять системные методы и увидеть свои бизнес-модули) и нашли конкретный метод репозитория, который находится в состоянии Waiting 500 мс.

    Какая длительность SQL-запроса считается допустимой? Для типичных OLTP-систем (онлайн-обработка транзакций, например, интернет-магазины) нормой считается выполнение простых запросов за 1–10 мс. Если запрос выполняется дольше 50–100 мс, это повод для расследования.

    Здесь важно понимать жесткую границу ответственности инструментов:

  • dotTrace показывает, какой именно метод C# вызвал запрос, какой SQL-текст был отправлен (при использовании Entity Framework) и сколько времени .NET ждал ответа.
  • dotTrace НЕ показывает, почему база данных работала медленно.
  • Как только вы локализовали проблему до конкретного SQL-запроса, профилирование .NET заканчивается. Дальнейшая диагностика должна проводиться инструментами самой СУБД.

    Если проблема не в приложении, вам необходимо перейти к мониторингу активности БД: В Microsoft SQL Server используйте Query Store или Extended Events*, чтобы посмотреть план выполнения запроса, нехватку индексов или ожидание блокировок таблиц. * В PostgreSQL используйте расширение pg_stat_statements для поиска самых ресурсоемких запросов и команду EXPLAIN ANALYZE для проверки того, использует ли запрос индексы (Index Scan) или сканирует всю таблицу целиком (Seq Scan).

    Трафик памяти vs Утечки при работе с БД

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

    Если вы загружаете из БД миллион записей через ToList(), Entity Framework создаст миллион объектов в памяти. Это вызовет резкий скачок потребления RAM.

    Как определить природу проблемы:

  • Трафик памяти (Memory Traffic): Объекты создаются, используются для сериализации в JSON и тут же становятся ненужными. Сборщик мусора (GC) быстро их удаляет. Утечки нет, но постоянная работа GC потребляет до 80% CPU, из-за чего приложение тормозит. Эту проблему диагностируют во вкладке Memory Allocation в dotTrace. Решение: использование пагинации (Skip/Take) или асинхронных стримов (IAsyncEnumerable).
  • Утечка памяти (Memory Leak): Вы загрузили справочник из БД и случайно сохранили его в статическую коллекцию (static Dictionary), которая никогда не очищается. Объекты удерживаются ссылками, ведущими к корням сборки (GC Roots). Память контейнера в Docker неуклонно растет, пока не произойдет падение с ошибкой OOMKilled. Эту проблему ищут в dotMemory с помощью графа удержания (Retention Graph) и доминаторов (Dominators), сравнивая два снапшота памяти.
  • Понимание разницы между ожиданием сети, нехваткой CPU и перегрузкой сборщика мусора позволяет разработчику не тратить дни на оптимизацию кода, когда достаточно просто добавить один индекс в базу данных.