Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top
close form

Заполните форму в два простых шага ниже:

Ваши контактные данные:

Шаг 1
Поздравляем! У вас есть промокод!

Тип желаемой лицензии:

Шаг 2
Team license
Enterprise license
** Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности
close form
Запросите информацию о ценах
Новая лицензия
Продление лицензии
--Выберите валюту--
USD
EUR
RUB
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Бесплатная лицензия PVS‑Studio для специалистов Microsoft MVP
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

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

close form
Мне интересно попробовать плагин на:
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
check circle
Ваше сообщение отправлено.

Мы ответим вам на


Если вы так и не получили ответ, пожалуйста, проверьте, отфильтровано ли письмо в одну из следующих стандартных папок:

  • Промоакции
  • Оповещения
  • Спам

Вебинар: C# разработка и статический анализ: в чем практическая польза? - 18.11

>
>
>
Оптимизация .NET приложения: как просты…

Оптимизация .NET приложения: как простые правки позволили ускорить PVS-Studio и уменьшить потребление памяти на 70%

15 Июн 2021

Проблемы с производительностью, такие как аномально низкая скорость работы и высокое потребление памяти, могут быть обнаружены самыми разными способами. Такие недостатки приложения выявляются тестами, самими разработчиками или тестировщиками, а при менее удачном раскладе – пользователями. Увы, но обнаружение аномалий – лишь первый шаг. Далее проблему необходимо локализовать, ведь в противном случае решить её не получится. Тут возникает вопрос – как найти в большом проекте причины, приводящие к излишнему потреблению памяти и замедлению работы? Есть ли они вообще? Быть может, дело и не в приложении вовсе? Эта статья посвящена истории о том, как разработчики C#-анализатора PVS-Studio столкнулись с подобной проблемой и смогли решить её.

0836_Fixing_performance_in_NET_apps_ru/image1.png

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

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

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

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

Стоп, а как же тестирование?!

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

Тестируется и тщательно! Для нас тестирование является неотъемлемой частью процесса разработки. Корректность работы анализатора постоянно проверяется, ровно как проверяется и корректность работы отдельных его частей. Без преувеличения можно сказать, что unit-тесты диагностических правил и внутренних механизмов составляют примерно половину от общего объёма исходного кода проекта C#-анализатора. Кроме того, каждую ночь на сервере производится анализ большого набора проектов и проверка корректности формируемых анализатором отчётов. При этом автоматически определяется как скорость работы анализатора, так и объём потребляемой памяти. Более-менее существенные отклонения от нормы мгновенно обнаруживаются и исследуются.

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

Поиск причин

Дамп

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

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

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

0836_Fixing_performance_in_NET_apps_ru/image2.png

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

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

В результате появится окошко с кучей различной информации о процессе:

0836_Fixing_performance_in_NET_apps_ru/image3.png

Нас в первую очередь интересовала возможность перехода в своеобразный режим отладки дампа. Для этого нужно нажать кнопку Debug With Managed Only.

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

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

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

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

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

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

Тем не менее, понять причины замедления нам не удалось. Мы не обнаружили каких-либо аномалий или ошибок, а количество файлов в проекте не казалось чем-то из ряда вон выходящим. Анализ проекта похожего объёма обычно занимает у нас около 2 часов.

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

Наконец-то, воспроизведение проблемы

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

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

Мы создали свой тестовый проект, стараясь повторить следующие характеристики проекта пользователя:

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

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

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

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

Поддержка клиентов – сложная работа, порой требующая невероятного упорства. Мы продолжили копать. Снова и снова мы пытались воспроизвести проблему и вдруг... У нас получилось.

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

А отличие было в железе. Точнее говоря, в ОЗУ.

Казалось бы, при чём тут ОЗУ?

Наши автоматизированные тесты проводятся на сервере с 32 Гб доступной оперативной памяти. На компьютерах сотрудников её объём различается, но везде есть по крайней мере 16 гигабайт, а у большинства – 32 и более. Воспроизвести же баг удалось на ноутбуке, объём оперативной памяти которого составлял 8 Гб.

Возникает логичный вопрос – к чему это всё? Мы же решали проблему замедления работы, а не высокого потребления памяти!

Дело в том, что высокое потребление памяти действительно может приводить к замедлению работы приложения. Это происходит в тех случаях, когда процессу не хватает памяти, установленной на устройстве. В таких случаях активируется особый механизм – memory paging (другое название – "swapping"). При его работе часть данных из оперативной памяти переносится во вторичное хранилище (диск). При необходимости система загружает данные с диска. Благодаря данному механизму приложения могут использовать оперативную память в большем объёме, чем доступно в системе. Увы, но у этого чуда есть своя цена.

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

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

Решаем проблему

dotMemory и диаграмма доминаторов

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

Ниже представлено окно присоединения к процессу:

0836_Fixing_performance_in_NET_apps_ru/image5.png

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

0836_Fixing_performance_in_NET_apps_ru/image7.png

В любой момент времени можно получить снимок состояния памяти. За время работы процесса можно сделать несколько таких снимков – все они появятся на панели "Memory Snapshots":

0836_Fixing_performance_in_NET_apps_ru/image9.png

Далее нам необходимо детально изучить полученный снимок (для этого нужно кликнуть по его идентификатору). Открывшееся окно будет содержать большое количество различных элементов:

0836_Fixing_performance_in_NET_apps_ru/image10.png

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

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

0836_Fixing_performance_in_NET_apps_ru/image12.png

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

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

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

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

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

Что же тогда делать? Неужели опять тупик?

А не такие уж они и разные

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

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

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

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

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

А вот и нет! На самом деле, нужно совсем немного:

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

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

Вполне возможно, вам знаком описанный подход. Сделанное нами – пример реализации известного паттерна Flyweight. Цель его применения — оптимизация работы с памятью путём предотвращения создания экземпляров элементов, имеющих общую сущность.

Кроме того, можно вспомнить и такое понятие, как интернирование строк. По сути – то же самое: если строки одинаковы по значению, то фактически они будут представлены одним и тем же объектом. В C# строковые литералы интернируются автоматически. Для прочих строк можно использовать методы string.Intern и string.IsInterned. Однако не всё так просто. Даже этим механизмом нужно пользоваться с умом. Если вам интересна данная тема, то предлагаю к прочтению статью "Подводные камни в бассейне строк, или ещё один повод подумать перед интернированием экземпляров класса String в C#".

Выигранная память

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

Они были невероятны! Пиковое потребление оперативной памяти при проверке тестового проекта уменьшилось с 14,55 до 4,73 гигабайт. Столь простое и быстрое решение позволило уменьшить расход памяти примерно на 68%! Мы были шокированы и очень довольны результатом. Доволен был и клиент – теперь ОЗУ его компьютера хватало, а значит, и анализ начал проходить за адекватное время.

0836_Fixing_performance_in_NET_apps_ru/image14.png

Достигнутый результат действительно радовал, но...

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

Да, мы смогли уменьшить потребление памяти. Однако изначально мы же хотели ускорить анализ! Конечно, он действительно ускорился у клиента, как и на других машинах, где не хватало ОЗУ. Но ускорения на мощных компьютерах мы не добились – только сократили потребление памяти. А раз уж мы столь глубоко погрузились в эту тему... Почему бы не продолжить?

dotTrace

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

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

0836_Fixing_performance_in_NET_apps_ru/image15.png

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

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

0836_Fixing_performance_in_NET_apps_ru/image17.png

Чтобы начать "запись" данных о работе приложения, нужно нажать Start (по умолчанию процесс сбора данных начинается сразу). Подождав некоторое время, нажимаем "Get Snapshot And Wait". Перед нами отображается окно с собранными данными. Например, для простого консольного приложения это окно выглядит так:

0836_Fixing_performance_in_NET_apps_ru/image19.png

Здесь нам доступно большое количество различной информации. В первую очередь интересно время работы отдельных методов. Также может быть полезно узнать время работы потоков. Доступна и возможность рассмотрения общего отчёта – для этого нужно кликнуть в верхнем меню View->Snapshot Overview или использовать комбинацию Ctrl+Shift+O.

0836_Fixing_performance_in_NET_apps_ru/image21.png

Уставший сборщик мусора

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

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

Дело в том, что запуск сборки блокировал потоки анализатора. Сборка завершалась, анализатор немного поработал – и снова запускается сборка мусора, а PVS-Studio "отдыхает".

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

Мы не виноваты, это всё их DisplayPart!

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

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

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

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

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

Оказалось, что объекты DisplayPart активно используются при создании строковых представлений так называемых символов. Если не погружаться в детали, то символ – это объект, содержащий семантическую информацию о некоторой сущности в исходном коде. К примеру, символ метода позволяет получить данные о параметрах данного метода, классе-родителе, возвращаемом типе и т.д. Более подробно данная тема освещена в статье "Введение в Roslyn. Использование для разработки инструментов статического анализа". Очень рекомендую к прочтению всем, кто интересуется статическим анализом (вне зависимости от предпочитаемого языка программирования).

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

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

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

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

Описанная правка, вопреки ожиданиям, практически не увеличила загрузку процессора (изменение составляло буквально несколько процентов). Тем не менее, PVS-Studio стал работать значительно быстрее: один из наших тестовых проектов ранее анализировался 2,5 часа, а после правок анализ проходил всего за 2. Ускорение работы на 20% действительно радовало.

Упакованный Enumerator

На втором месте по количеству выделяемой памяти были объекты типа List<T>.Enumerator, используемые при обходе соответствующих коллекций. Итератор списка является структурой, а значит, создаётся на стеке. Тем не менее, трассировка показывала, что такие объекты в больших количествах попадали в кучу! С этим нужно было разобраться.

Объект значимого типа может попасть в кучу в результате упаковки (boxing). Она выполняется при приведении объекта значимого типа к object или реализуемому интерфейсу. Итератор списка реализует интерфейс IEnumerator, и именно приведение к этому интерфейсу вело к попаданию итератора в кучу.

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

А вот и нет! Метод GetEnumerator, определённый в классе List, возвращает структуру:

0836_Fixing_performance_in_NET_apps_ru/image23.png

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

0836_Fixing_performance_in_NET_apps_ru/image24.png

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

Конечно, разница невелика, если такой Enumerator создаётся лишь пару сотен раз за время работы программы. Однако при анализе более-менее объёмного проекта в нашем C#-анализаторе эти объекты создаются миллионы или даже десятки миллионов раз. В таких случаях разница становится весьма ощутимой.

Примечание. Как правило, мы не вызываем GetEnumerator напрямую. Зато достаточно часто приходится использовать цикл foreach. Именно он "под капотом" получает итератор. Если в foreach передана ссылка типа List, то и итератор, используемый в foreach, будет лежать на стеке. Если же с помощью foreach производится обход абстрактного IEnumerable, то итератор будет сохранён в куче, а foreach будет работать со ссылкой типа IEnumerator. Описанное поведение актуально и для других коллекций, в которых присутствует GetEnumerator, возвращающий итератор значимого типа.

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

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

И ты, LINQ?!

0836_Fixing_performance_in_NET_apps_ru/image25.png

Методы расширения, определённые в пространстве имён 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 (список result);
    • повторные обходы последовательности приводят к повторному выполнению обхода с выполнением всех указанных функций. Случаи, когда такое поведение действительно полезно, довольно редки. Конечно, можно использовать методы типа ToList, однако это сводит преимущества варианта с LINQ-вызовами на нет (кроме первого).

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

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

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

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

Что же в итоге?

Успех!

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

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

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

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

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

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

Кстати, стоит сказать, что проблемы с производительностью часто бывают связаны не с чрезмерным использованием LINQ-запросов или чем-то подобным, а с самыми обыкновенными ошибками в коде. Какие-нибудь "always true"-условия, заставляющие метод работать гораздо дольше, чем необходимо, опечатки и прочее – всё это может негативно сказаться как на производительности, так и на корректности работы приложения в целом.

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

PVS-Studio – один из таких анализаторов. Он использует серьёзные технологии вроде межпроцедурного анализа или анализа потока данных, что позволяет значительно повысить надёжность кода любого приложения. Кроме того, одним из наиболее приоритетных направлений работы компании является поддержка пользователей, решение их вопросов и возникающих проблем, а в некоторых случаях мы даже добавляем по просьбе клиента новый функционал :). Смело пишите нам по всем возникающим вопросам! А чтобы попробовать анализатор в деле вы можете перейти по ссылке. Удачного использования!

Популярные статьи по теме


Комментарии (0)

Следующие комментарии next comments
close comment form