Мы знаем много способов обнаружения проблем с производительностью, таких как чрезвычайно низкая скорость и высокое потребление памяти. Обычно тесты, разработчики или тестировщики выявляют недостатки таких приложений. В худшем случае пользователи находят слабые места и сообщают о них. Увы, обнаружение дефектов — это только первый шаг. Далее мы должны локализовать проблему. Иначе мы ее не решим. Возникает вопрос — как найти слабые места, приводящие к избыточному потреблению памяти и замедлению работы в большом проекте? Есть ли такие вообще? Может дело не в приложении? Итак, сейчас вы читаете рассказ о том, как разработчики PVS-Studio C# столкнулись с похожей проблемой и смогли ее решить.

Бесконечный анализ

Анализ больших проектов C# занимает некоторое время. Это неудивительно, ведь PVS-Studio глубоко погружается в исходный код и использует впечатляющий набор технологий: межпроцедурный анализ, анализ потоков данных и т. д. Но при этом анализ занимает не более нескольких часов даже для многих крупных проектов, которые мы встречаем. на гитхабе.

Возьмем, к примеру, Рослин. Более 200 проектов в ее решении! Почти все они написаны на C#. Каждый проект содержит гораздо больше, чем один файл. В свою очередь, в файлах мы видим гораздо больше, чем пару строк кода. PVS-Studio проверяет Roslyn примерно за 1,5–2 часа. Безусловно, некоторые проекты наших пользователей требуют гораздо больше времени для проверки. Но случаи однодневных проверок исключительны.

Так случилось с одним из наших клиентов. Он написал в нашу службу поддержки, что анализ его проекта не завершен за… 3 дня! Что-то явно было не так. Мы не могли оставить такую ​​проблему без внимания.

Подождите, а как насчет тестирования?!

Наверняка у читателя возникает логичный вопрос — почему вы не заметили проблему на этапе тестирования? Как вы позволили клиенту раскрыть это? Разве анализатор PVS-Studio C# не тестируется разработчиками?

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

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

Поиск причин

Свалка

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

Дамп памяти процесса анализатора может помочь. Что такое дамп? Короче говоря, дамп — это сегмент данных из оперативной памяти. С его помощью мы узнаем, какие данные загружаются в память процесса PVS-Studio. В первую очередь искали любые дефекты, которые могли вызвать сильное замедление работы.

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

Если вы не можете открыть файл дампа, от него мало толку. К счастью для пользователей, им не приходится с этим сталкиваться :). Что касается нас, то мы решили просмотреть данные дампа с помощью Visual Studio. Это довольно просто.

  • Откройте проект с исходными файлами приложения в Visual Studio.
  • В верхнем меню нажмите Файл->Открыть->Файл (или Ctrl+O).
  • Найдите файл дампа и откройте его.

Видим окно с разной информацией о процессе:

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

Примечание. Если вы хотите узнать больше об открытии дампов через Visual Studio для отладки, вам обязательно поможет официальная документация.

Итак, мы перешли в режим отладки. Отладка файла дампа — мощный механизм. Тем не менее есть некоторые ограничения:

  • нельзя возобновить процесс, выполнить код пошагово и так далее;
  • вы не можете использовать определенные функции в Quick Watch и Immediate Window. Например, вызов метода File.WriteAllText привел к исключению «Caracteres no válidos en la ruta de accesso!». Это потому, что дамп относится к среде, в которой он был взят.

Мы получили различные данные от отладки дампа. Ниже небольшая часть данных о процессе анализа на момент снятия дампа:

  • количество файлов в проекте: 1500;
  • приблизительное время анализа: 24 часа;
  • количество анализируемых файлов на данный момент: 12;
  • количество уже проверенных файлов: 1060.

Мы сделали некоторые выводы из работы с дампом. При снятии дампа анализатор проверил большинство файлов проекта. Замедление стало очевидным к концу анализа. У нас было предчувствие — факторы, приведшие к замедлению, могли накопиться.

Увы, разобраться в причинах замедления нам не удалось. Дефектов не обнаружено, и количество файлов в проекте вроде бы не выбивается из ряда. Аналогичный проект можно проверить примерно за 2 часа.

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

Воспроизведение проблемы наконец

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

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

Мы создали наш тестовый проект со следующими спецификациями пользовательского проекта:

  • количество файлов;
  • средний размер файла;
  • максимальный уровень вложенности и сложности используемых структур.

Скрестив пальцы, мы провели анализ и…

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

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

Поддержка клиентов — сложная работа, которая иногда требует невероятного упорства. Мы продолжали копать. Снова и снова мы пытались воспроизвести проблему и вдруг… Нам это удалось.

Анализ не удалось завершить на компьютере одного из наших коллег. Он использовал ту же версию анализатора и тот же проект. Какая тогда была разница?

Фурнитура была другой. Точнее, ОЗУ.

Какое это имеет отношение к оперативной памяти?

Наши автоматические тесты выполняются на сервере с 32 ГБ доступной оперативной памяти. Объем памяти варьируется на машинах наших сотрудников. Это не менее 16 ГБ, у большинства 32 ГБ или больше. Ошибка проявилась на ноутбуке с 8 ГБ оперативной памяти.

Возникает резонный вопрос — как все это относится к нашей проблеме? Мы решали проблему с замедлением, а не с большим потреблением памяти!

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

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

В общем, дело раскрыто. На этом мы могли бы прекратить наше расследование. Мы могли бы посоветовать пользователю увеличить объем доступной оперативной памяти и все. Однако это вряд ли удовлетворило бы клиента, да и нам самим такой вариант совсем не понравился. Поэтому мы решили более подробно остановиться на вопросе потребления памяти.

Решение проблемы

dotMemory и Dominator Graph

Мы использовали приложение dotMemory от JetBrains. Это профилировщик памяти для .NET. Вы можете запускать его как прямо из Visual Studio, так и как отдельный инструмент. Среди всех фич dotMemory нас больше всего интересовало профилирование процесса анализа.

Ниже показано окно, позволяющее прикрепиться к процессу:

Для начала нам нужно запустить соответствующий процесс, затем выбрать его и запустить профилирование кнопкой «Выполнить». Откроется новое окно:

Мы можем получить снимок состояния памяти в любое время. В процессе мы можем сделать несколько таких снимков — все они появятся в панели «Снимки памяти»:

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

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

Все эти действия мы проделали с процессом анализа специально созданного тестового проекта. Диаграмма доминатора для него выглядела так:

Чем ближе элемент к центру, тем выше позиция соответствующего класса. Например, единственный экземпляр класса SemanticModelCachesContainer находится на высоком уровне в иерархии доминаторов. На диаграмме также показаны дочерние объекты после соответствующего элемента. Например, на рисунке видно, что экземпляр SemanticModelCachesContainer содержит внутри себя ссылку на ConcurrentDictionary.

Объекты высокого уровня были не особо интересны — они не занимали много места. Внутренняя часть была намного значительнее. Какие объекты размножились настолько, что стали занимать столько места?

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

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

Кэш хранит рассчитанные диапазоны значений переменных для оптимизации работы. К сожалению, это приводит к серьезному увеличению объема потребляемой памяти. Несмотря на это, мы не можем удалить механизм кэширования! Межпроцедурный анализ будет идти значительно медленнее, если мы откажемся от кэширования.

Тогда мы можем сделать? Опять тупик?

Они не такие уж и разные

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

Мы внимательно рассмотрели значения в кеше. Оказалось, что PVS-Studio хранит большое количество одинаковых объектов. Вот пример. Анализатор не может оценить значения для многих переменных, потому что значения могут быть любыми в пределах их ограничений типа.

void MyFunction(int a, int b, int c ....)
{
  // a = ?
  // b = ?
  // c = ?
  ....
}

Каждая переменная соответствовала своему собственному объекту-значению. Таких предметов была целая куча, но они ничем не отличались друг от друга!

Идея пришла мгновенно — нужно было только избавиться от дублирования. Правда, реализация потребовала бы от нас внесения большого количества сложных правок…

Ну… Нет! На самом деле нужно всего несколько:

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

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

Вы, наверное, знаете подход, который я описываю. То, что мы сделали, является примером знаменитого паттерна Легкий вес. Его цель — оптимизировать работу с памятью. Как это работает? Мы должны предотвратить создание экземпляров элементов, имеющих общую сущность.

В этом контексте также приходит на ум интернирование строк. На самом деле это одно и то же. Если строки одинаковы по значению, они фактически будут представлены одним и тем же объектом. В C# строковые литералы интернируются автоматически. Для других строк мы можем использовать методы String.Intern и String.IsInterned. Бит это не так просто. Даже этот механизм нужно использовать с умом. Если вам интересна тема, вам подойдет статья Скрытые подводные камни в String Pool, или еще одна причина дважды подумать перед интернированием экземпляров класса String в C#».

Полученная память

Мы внесли несколько незначительных правок, внедрив шаблон Flyweight. Как насчет результатов?

Они были невероятны! Пиковое потребление оперативной памяти при проверке тестового проекта уменьшилось с 14,55 до 4,73 гигабайта. Такое простое и быстрое решение позволило снизить потребление памяти примерно на 68%! Мы были в шоке и очень довольны результатом. Клиент тоже был взволнован — теперь оперативной памяти его компьютера хватило. Это означает, что анализ стал занимать обычное время.

Правда, результат порадовал, но…

Нам нужно больше оптимизаций!

Да, нам удалось снизить потребление памяти. А ведь изначально мы хотели ускорить анализ! Что ж, у нашего клиента действительно был прирост скорости, как и у других машин, которым не хватало оперативной памяти. Но на наших мощных машинах мы не прибавили в скорости — мы только уменьшили потребление памяти. Раз уж мы так глубоко залезли в кроличью нору… Почему бы не продолжить?

точкаТрейс

Итак, мы начали искать потенциал оптимизации. В первую очередь нас интересовало — какие части приложения работают дольше всего? Какие именно операции тратят время?

dotTrace, неплохой профилировщик производительности для .NET-приложений, мог бы дать ответы на наши вопросы и предоставить ряд интересных фич. Интерфейс этого приложения довольно сильно напоминает dotMemory:

Примечание. Как и в случае с dotMemory, в этой статье не будет подробного руководства по использованию dotTrace для работы с этим приложением. Документация здесь, чтобы помочь вам с деталями. Моя история о действиях, которые мы предприняли, чтобы обнаружить возможности оптимизации.

С помощью dotTrace мы провели анализ одного крупного проекта. Ниже приведен пример окна, отображающего графики использования памяти и процессора в реальном времени:

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

Здесь нам доступно много различной информации. Прежде всего, это время работы отдельных методов. Также может быть полезно знать время выполнения потоков. Вы также можете просмотреть общий отчет. Для этого нажмите Вид-›Обзор снимков в верхнем меню или воспользуйтесь комбинацией Ctrl+Shift+O.

Усталый сборщик мусора

Что мы узнали с помощью dotTrace? В очередной раз мы убедились, что C# анализатор не потребляет и половины мощности процессора. PVS-Studio C# — многопоточное приложение. По идее нагрузка на процессор должна быть ощутимой. Несмотря на это, при анализе загрузка ЦП часто падала до 13–15% от общей мощности ЦП. Очевидно, мы работаем неэффективно. Почему?

dotTrace показал нам забавную вещь. Большую часть времени работает даже не само приложение. Это сборщик мусора! Возникает закономерный вопрос — как так?

Дело в том, что сборка мусора блокировала потоки анализатора. После завершения сбора анализатор немного поработает. Затем снова запускается сборка мусора, и PVS-Studio «отдыхает».

Мы поняли суть проблемы. Следующим шагом было найти места, где память наиболее активно выделяет новые объекты. Затем нам пришлось проанализировать все найденные фрагменты и внести изменения в оптимизацию.

Это не наша вина, это все их DisplayPart!

Трассировщик показал, что чаще всего память выделяется объектам типа DisplayPart. При этом существуют они недолго. Это означает, что они требуют частого выделения памяти.

Мы могли бы отказаться от использования этих объектов, если бы не одно предостережение. DisplayPart даже не упоминается в исходных файлах нашего анализатора C#! Как оказалось, этот тип играет особую роль в используемом нами Roslyn API.

Roslyn (или .NET Compiler Platform) — основа C# анализатора PVS-Studio. Он предоставляет нам готовые решения для ряда задач:

  • преобразует исходный файл в синтаксическое дерево;
  • удобный способ обхода синтаксического дерева;
  • получает различную (в том числе семантическую) информацию о конкретном узле дерева;
  • и другие.

Roslyn — это платформа с открытым исходным кодом. Это упростило понимание того, что такое DisplayPart и зачем вообще нужен этот тип.

Выяснилось, что объекты DisplayPart активно используются при создании строковых представлений так называемых символов. В двух словах, символ — это объект, содержащий семантическую информацию о некотором объекте в исходном коде. Например, символ метода позволяет получить данные о параметрах этого метода, родительском классе, типе возвращаемого значения и др. Более подробно эта тема раскрыта в статье Введение в Roslyn и его использование в разработке программ. Я настоятельно рекомендую прочитать ее всем, кто интересуется статическим анализом, вне зависимости от предпочитаемого языка программирования.

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

Обычно локализация проблемы = 90% ее решения. Поскольку вызовы ToString доставляют столько хлопот, может, нам не стоит их делать?

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

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

Правка, которую я описал, показалась мне очень многообещающей. Несмотря на это, изменение не сильно увеличило нагрузку на ЦП — всего на несколько процентов. Однако PVS-Studio стал работать значительно быстрее. Один из наших тестовых проектов ранее анализировался 2,5 часа, а после правок — всего 2. Ускорение на 20% нас очень порадовало.

Перечислитель в штучной упаковке

Объекты List‹T›.Enumerator, используемые для обхода коллекций, заняли второе место по объему выделенной памяти. Итератор списка — это структура. Это означает, что он создается в стеке. Во всяком случае, трассировка показывала, что таких объектов скопилось огромное количество! Нам пришлось с этим смириться.

Объект стоимостного типа может попасть в кучу из-за упаковки. Бокс реализуется, когда объект значения преобразуется в Object или реализованный интерфейс. Итератор списка реализует интерфейс IEnumerator. Приведение к этому интерфейсу приводило к тому, что итератор попадал в кучу.

Метод GetEnumerator используется для получения объекта Enumerator. Все мы знаем, что этот метод определен в интерфейсе IEnumerable. Глядя на его сигнатуру, мы можем заметить, что возвращаемый тип этого метода — IEnumerator. Всегда ли вызов GetEnumerator приводит к боксу?

Ну… Нет! GetEnumerator, определенный в классе List, возвращает структуру:

Бокс будет или нет? Ответ зависит от типа ссылки, из которой вызывается GetEnumerator:

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

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

Примечание. Обычно мы не вызываем GetEnumerator напрямую. Но довольно часто приходится использовать цикл foreach. Этот цикл получает итератор «под капот». Если ссылка List передается в foreach, итератор, используемый в foreach, будет в стеке. Вот еще один случай, когда foreach помогает пройти абстрактный IEnumerable. Таким образом, итератор будет находиться в куче, тогда как foreach будет работать со ссылкой IEnumerator. Описанное выше поведение относится к другим коллекциям, которые содержат GetEnumerator, возвращающий итератор типа значения.

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

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

А Вы, LINQ?!

Методы расширения, определенные в пространстве имен System.Linq, повсеместно используются для работы с коллекциями. Достаточно часто они действительно позволяют упростить код. Почти в каждом приличном проекте есть всеми любимые методы Where, Select и другие. Анализатор C# PVS-Studio не стал исключением.

Что ж, красота и удобство методов LINQ дорого нам обошлись. Это стоило так дорого, что мы решили не использовать их в пользу простого foreach. Как так вышло?

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

List<int> sourceList = ....
var enumeration = sourceList.Where(item => item > 0)
                            .Select(item => someArray[item])
                            .Where(item => item > 0)
                            .Take(5);

Сколько итераторов мы получим при его выполнении? Давай посчитаем! Давайте откроем исходный файл System.Linq, чтобы понять, как все это работает. Получить их на github по ссылке.

При вызове Where будет создан объект WhereListIterator. Это специальная версия итератора Where, оптимизированная для работы с List. Аналогичная оптимизация есть и для массивов. Этот итератор хранит ссылку на список внутри. При обходе коллекции WhereListIterator сохранит внутри себя итератор списка и будет использовать его при работе. Поскольку WhereListIterator разработан специально для списка, итератор не будет приводиться к типу IEnumerator. WhereListiterator сам по себе является классом, что означает, что его экземпляры попадут в кучу. Следовательно, исходный итератор в любом случае не будет находиться в стеке.

Вызов Select создаст объект класса WhereSelectListIterator. Очевидно, он будет храниться в куче.

Последующие вызовы Where и Take приведут к созданию итераторов и выделенной памяти для них.

Что мы получаем? Выделенная память для 5 итераторов. Сборщик мусора должен будет освободить его позже.

Теперь посмотрите на фрагмент, написанный с помощью foreach:

List<int> sourceList = ....
List<int> result = new List<int>();
foreach (var item in sourceList)
{
  if (item > 0)
  {
    var arrayItem = someArray[item];
    if (arrayItem > 0)
    {
      result.Add(arrayItem);
      if (result.Count == 5)
        break;
    }
  }
}

Давайте проанализируем и сравним подходы с foreach и LINQ.

  • Преимущества варианта с вызовами LINQ:
  • короче, приятнее и проще для чтения;
  • не требует коллекции для хранения результата;
  • значения будут вычисляться только при доступе к элементам;
  • в большинстве случаев доступный объект хранит только один элемент последовательности.
  • Недостатки варианта с LINQ-вызовами:
  • память в куче выделяется гораздо чаще: в первом примере 5 объектов, а во втором — только 1 (результат список);
  • повторные обходы последовательности приводят к повторному обходу, вызывающему все указанные функции. Случаи, когда такое поведение действительно полезно, довольно редки. Конечно, можно использовать такие методы, как ToList. Но это сводит на нет преимущества варианта LINQ-вызовов (кроме первого преимущества).

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

При всем при этом мы заметили, что по большей части нас не интересовало отложенное исполнение. Это был вызов ToList для получения результата операции LINQ. Либо код запроса выполнялся несколько раз при повторных обходах — что нежелательно.

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

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

Что мы получили в итоге?

Выгода!

Оптимизация PVS-Studio успешно завершена! Мы уменьшили потребление памяти, значительно увеличили скорость анализа. Кстати, у некоторых проектов скорость увеличилась более чем на 20%, а пиковое потребление памяти снизилось почти на 70%! А началось все с непонятного рассказа клиента о том, как он за три дня не смог проверить свой проект! Тем не менее, мы продолжим оптимизировать инструмент и искать новые способы улучшения PVS-Studio.

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

Это еще не конец

Да, мы решили проблему клиента и даже ускорили работу анализатора в целом, но… Работает он явно далеко не так быстро, как мог бы. PVS-Studio по-прежнему не использует процессорную мощность активно. Проблема не совсем в алгоритмах анализа — проверка каждого файла в отдельном потоке позволяет обеспечить достаточно высокий уровень параллелизма. Основная беда анализатора C# с производительностью — это сборщик мусора, который очень часто блокирует работу всех потоков — отсюда и замедления. Даже если анализатор использует сотни ядер, скорость работы будет снижена из-за частой блокировки потоков сборщиком. Последний не может использовать всю доступную мощность в своих задачах из-за некоторых алгоритмических ограничений.

Однако это не тупик. Это просто еще одно препятствие, которое мы должны преодолеть. Некоторое время назад я получил секретную информацию о планах реализации процесса анализа… в нескольких процессах! Это поможет обойти существующие ограничения. Сборка мусора в одном из процессов не повлияет на анализ, выполняемый в другом. Такой подход позволит нам эффективно использовать большое количество ядер, а также использовать Incredibuild. Кстати, анализатор C++ уже работает подобным образом. Давно используется распределенный анализ.

Откуда еще берутся проблемы с производительностью?

Есть еще один заметный недостаток производительности. Речь идет не о запросах LINQ или что-то в этом роде — это распространенные ошибки в коде. «всегда истинные» условия, из-за которых метод работает дольше, опечатки и другие — все это влияет как на производительность, так и на приложение в целом.

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

PVS-Studio — один из таких анализаторов. В нем используются усовершенствованные технологии, такие как межпроцедурный анализ или анализ потоков данных, которые позволяют значительно повысить надежность кода любого приложения. Вот еще один приоритет компании — поддержка пользователей, решение их вопросов и возникающих проблем. В некоторых случаях мы даже добавляем новые функции по запросу клиента. Смело пишите нам по всем возникающим вопросам! Нажмите ссылку, чтобы попробовать анализатор в действии. Наслаждайтесь использованием!