Поиск и анализ высокой загрузки ЦП в приложениях .NET

Опубликовано: 2022-03-11

Разработка программного обеспечения может быть очень сложным процессом. Мы, как разработчики, должны учитывать множество различных переменных. Некоторые из них не находятся под нашим контролем, некоторые неизвестны нам в момент фактического выполнения кода, а некоторые контролируются нами напрямую. И разработчики .NET не являются исключением.

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

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

Как уже упоминалось, когда программное обеспечение запускается, что-то может пойти не так, и код может начать выполняться не так, как мы планировали. Мы можем оказаться в ситуации, когда нам придется решать проблемы, не имея возможности отладить или точно знать, что происходит. Что мы можем сделать в этом случае?

Высокая загрузка ЦП — это когда процесс использует более 90% ЦП в течение длительного периода времени — и у нас проблемы.

Если процесс использует более 90% ЦП в течение длительного периода времени, у нас проблемы.
Твитнуть

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

Использование ЦП и потребление памяти — широко обсуждаемые темы. Обычно очень сложно точно знать, какой объем ресурсов (ЦП, ОЗУ, ввод-вывод) должен использовать конкретный процесс и в течение какого периода времени. Хотя одно можно сказать наверняка — если процесс использует более 90% ЦП в течение длительного периода времени, у нас проблемы только из-за того, что сервер не сможет обработать любой другой запрос при этом обстоятельстве.

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

Все операционные системы имеют несколько различных инструментов для мониторинга того, что происходит на сервере. Серверы Windows специально имеют диспетчер задач, монитор производительности, или, в нашем случае, мы использовали серверы New Relic, которые являются отличным инструментом для мониторинга серверов.

Первые симптомы и анализ проблемы

После того, как мы развернули наше приложение, в течение первых двух недель мы начали видеть, что сервер имеет пики использования ЦП, из-за чего сервер не отвечает. Нам пришлось перезапустить его, чтобы снова сделать доступным, и это событие произошло три раза за это время. Как я упоминал ранее, мы использовали New Relic Servers в качестве монитора сервера, и он показал, что процесс w3wp.exe использовал 94% ЦП в момент сбоя сервера.

Рабочий процесс Internet Information Services (IIS) — это процесс Windows ( w3wp.exe ), который запускает веб-приложения и отвечает за обработку запросов, отправляемых на веб-сервер для определенного пула приложений. Сервер IIS может иметь несколько пулов приложений (и несколько разных процессов w3wp.exe ), которые могут вызывать проблему. Основываясь на пользователе, который был у процесса (это было показано в отчетах New Relic), мы определили, что проблема была в нашем устаревшем приложении веб-формы .NET C#.

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

Сбор информации

Самый простой способ собрать дампы процессов пользовательского режима — использовать Debug Diagnostic Tools v2.0 или просто DebugDiag. DebugDiag имеет набор инструментов для сбора данных (DebugDiag Collection) и анализа данных (DebugDiag Analysis).

Итак, приступим к определению правил сбора данных с помощью Debug Diagnostic Tools:

  1. Откройте коллекцию DebugDiag и выберите Performance .

    Инструмент диагностики отладки

  2. Выберите Performance Counters и нажмите « Next ».
  3. Щелкните Add Perf Triggers .
  4. Разверните объект Processor (не Process ) и выберите % Processor Time . Обратите внимание: если вы используете Windows Server 2008 R2 и у вас более 64 процессоров, выберите объект Processor Information вместо объекта Processor .
  5. В списке экземпляров выберите _Total .
  6. Нажмите « Add а затем нажмите OK .
  7. Выберите только что добавленный триггер и нажмите « Edit Thresholds .

    Счетчики производительности

  8. Выберите Above в раскрывающемся списке.
  9. Измените порог на 80 .
  10. Введите 20 для количества секунд. Вы можете изменить это значение, если это необходимо, но будьте осторожны, чтобы не указать небольшое количество секунд, чтобы предотвратить ложные срабатывания.

    Свойства триггера системного монитора

  11. Нажмите OK .
  12. Нажмите Next .
  13. Щелкните Add Dump Target .
  14. В раскрывающемся списке выберите Web Application Pool .
  15. Выберите свой пул приложений из списка пулов приложений.
  16. Нажмите OK .
  17. Нажмите Next .
  18. Нажмите Next еще раз.
  19. Введите имя для вашего правила, если хотите, и запишите место, где будут сохраняться дампы. При желании вы можете изменить это местоположение.
  20. Нажмите Next .
  21. Выберите Activate the Rule Now и нажмите « Finish ».

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

Как только у нас будут файлы дампа в выбранной папке, мы будем использовать инструмент анализа DebugDiag для анализа собранных данных:

  1. Выберите Анализаторы производительности.

    Инструмент анализа DebugDiag

  2. Добавьте файлы дампа.

    Файлы дампа платных анализов DebugDiag

  3. Начать анализ.

DebugDiag займет несколько (или несколько) минут, чтобы разобрать дампы и предоставить анализ. Когда он завершит анализ, вы увидите веб-страницу со сводкой и большим количеством информации о тредах, подобную следующей:

Резюме анализа

Как вы можете видеть в сводке, есть предупреждение, в котором говорится: «Высокая загрузка ЦП между файлами дампа была обнаружена в одном или нескольких потоках». Если мы нажмем на рекомендацию, мы начнем понимать, в чем проблема с нашим приложением. Наш пример отчета выглядит так:

Топ 10 потоков по средней загрузке ЦП

Как видно из отчета, существует закономерность в отношении использования ЦП. Все потоки с высокой загрузкой ЦП относятся к одному классу. Прежде чем перейти к коду, давайте взглянем на первый.

Стек вызовов .NET

Это детали для первого потока с нашей проблемой. Нам интересна следующая часть:

Подробная информация о стеке вызовов .NET

Здесь у нас есть вызов нашего кода GameHub.OnDisconnected() , который инициировал проблемную операцию, но перед этим вызовом у нас есть два вызова словаря, которые могут дать представление о том, что происходит. Давайте посмотрим на код .NET, чтобы увидеть, что делает этот метод:

 public override Task OnDisconnected() { try { var userId = GetUserId(); string connId; if (onlineSessions.TryGetValue(userId, out connId)) onlineSessions.Remove(userId); } catch (Exception) { // ignored } return base.OnDisconnected(); }

У нас явно проблемы. В стеке вызовов отчетов говорится, что проблема была со словарем, и в этом коде мы обращаемся к словарю, и, в частности, строка, вызывающая проблему, такова:

 if (onlineSessions.TryGetValue(userId, out connId))

Это объявление словаря:

 static Dictionary<int, string> onlineSessions = new Dictionary<int, string>();

В чем проблема с этим кодом .NET?

Каждый, кто имеет опыт объектно-ориентированного программирования, знает, что статические переменные будут общими для всех экземпляров этого класса. Давайте подробнее рассмотрим, что означает статика в мире .NET.

Согласно спецификации .NET С#:

Используйте модификатор static для объявления статического члена, который принадлежит самому типу, а не конкретному объекту.

Вот что говорится в спецификациях языка .NET C# относительно статических классов и членов:

Как и в случае со всеми типами классов, информация о типе для статического класса загружается общеязыковой средой выполнения (CLR) .NET Framework при загрузке программы, которая ссылается на класс. Программа не может точно указать, когда загружается класс. Тем не менее, он гарантированно загружается, и его поля инициализируются, а его статический конструктор вызывается до первого обращения к классу в вашей программе. Статический конструктор вызывается только один раз, а статический класс остается в памяти на время существования домена приложения, в котором находится ваша программа.

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

Это означает, что статические члены принадлежат самому типу, а не объекту. Они также загружаются в домен приложения средой CLR, поэтому статические члены принадлежат процессу, в котором размещается приложение, а не конкретным потокам.

Учитывая тот факт, что веб-среда является многопоточной средой, поскольку каждый запрос представляет собой новый поток, порождаемый процессом w3wp.exe ; и учитывая, что статические члены являются частью процесса, у нас может быть сценарий, в котором несколько разных потоков пытаются получить доступ к данным статических (общих для нескольких потоков) переменных, что в конечном итоге может привести к проблемам многопоточности.

Документация словаря в разделе безопасность потоков гласит следующее:

Dictionary<TKey, TValue> может одновременно поддерживать несколько программ чтения, если коллекция не изменяется. Тем не менее, перечисление в коллекции по своей сути не является потокобезопасной процедурой. В редком случае, когда перечисление сталкивается с доступом для записи, коллекция должна быть заблокирована в течение всего перечисления. Чтобы разрешить доступ к коллекции нескольким потокам для чтения и записи, необходимо реализовать собственную синхронизацию.

Это утверждение объясняет, почему у нас может возникнуть эта проблема. Судя по информации из дампов, проблема была в методе FindEntry словаря:

Подробная информация о стеке вызовов .NET

Если мы посмотрим на реализацию словаря FindEntry, то увидим, что метод перебирает внутреннюю структуру (сегменты), чтобы найти значение.

Таким образом, следующий код .NET перечисляет коллекцию, что не является потокобезопасной операцией.

 public override Task OnDisconnected() { try { var userId = GetUserId(); string connId; if (onlineSessions.TryGetValue(userId, out connId)) onlineSessions.Remove(userId); } catch (Exception) { // ignored } return base.OnDisconnected(); }

Заключение

Как мы видели в дампах, есть несколько потоков, пытающихся одновременно итерировать и изменять общий ресурс (статический словарь), что в конечном итоге привело к тому, что итерация вошла в бесконечный цикл, в результате чего поток потреблял более 90% ресурсов ЦП. .

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

Следующим шагом в решении этой проблемы будет анализ кода и поиск оптимального решения для этого. Можно провести рефакторинг кода: новый класс ConcurrentDictionary может решить эту проблему, поскольку он блокируется только на уровне корзины, что улучшит общую производительность. Хотя это большой шаг, и потребуется дальнейший анализ.