>
>
>
Какая стратегия освобождения памяти исп…

Андрей Карпов
Статей: 674

Какая стратегия освобождения памяти используется в C и С++ ядре PVS-Studio?

Так получилось, что в различных обсуждениях мы уже несколько раз комментировали, как C и C++ модуль PVS-Studio работает с памятью. А раз так, пришло время оформить этот ответ в виде маленькой статьи.

На момент публикации анализатор PVS-Studio содержит в себе три консольных модуля, осуществляющих анализ кода программ на языках:

  • С++, а также на языке C и ряде диалектов, таких как C++/CLI, C++/CX;
  • C#;
  • Java.

Эти модули мы ещё называем ядрами анализатора.

Итак, ядро C# анализатора написано на C#, а Java анализатора — на Java. В этих языках освобождением памяти занимается сборщик мусора, поэтому тут и так всё понятно. Конечно, есть разные моменты, связанные с оптимизацией. Например, в статьях [1, 2, 3] мои коллеги рассматривают уменьшение количества создаваемых временных объектов, настройку сборщика мусора, интернирование и т.д. Но интереснее всего, как же дело обстоит в C и C++ ядре, написанном на C++?

Общий принцип работы ядра

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

Для анализа каждой единицы трансляции (.c, .cpp файла) запускается новый процесс. Это позволяет легко распараллеливать анализ проекта. Отсутствие распараллеливания внутри процесса означает, что нет надобности что-то синхронизировать. Это в свою очередь уменьшает сложность разработки.

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

Прежде чем файл начинает анализироваться, происходит его препроцессирование. Для этого используется внешний препроцессор (компилятор). Мы никак не управляем временем работы препроцессора. Условно предположим, что препроцессор работает 3 секунды и ещё 3 секунды выполняется собственно анализ. Добавим ещё одну условную секунду, которая тратится на сбор информации о файле, запуск процессов, чтение файлов и другие не распараллеливаемые или плохо распараллеливаемые операции. Итого 7 секунд.

Представим, что будет реализовано внутреннее распараллеливание и собственно анализ будет выполняться не 3 секунды, а 0.5 секунды. Тогда общее время проверки одного файла сократится с условных 7 секунд до 4.5 секунд. Это приятно, но ничего кардинально не изменилось. При анализе нескольких файлов такое распараллеливание не имеет смысла, так как будет распараллелен анализ файлов, что, кстати, эффективнее. А проверка одного единичного файла, если такое требуется, ускорится несущественно. Зато за это небольшое ускорение придётся заплатить дорогую цену написанием сложного механизма распараллеливания алгоритмов и синхронизации при доступах к общим объектам.

Примечание. А как же PVS-Studio выполняет межмодульный анализ, если каждый процесс обрабатывает только одну единицу компиляции? Анализ выполняется в два прохода. Вначале собирается необходимая информация и записывается в специальный файл. Затем файлы повторно анализируются с учётом ранее собранной информации [4].

Стратегия освобождения памяти

Распараллеливание анализа на уровне обработки файлов имеет ещё одно важное следствие, которое относится уже к теме использования памяти.

Мы не освобождаем память в С++ ядре PVS-Studio до конца выполнения процесса. Это осознанное полезное решение.

В общем, наш единорог всегда только жрёт память :).

Ладно-ладно, это не совсем так. Естественным образом уничтожаются локальные объекты, созданные на стеке, и освобождается память в куче, которую они выделяли для своих нужд.

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

Однако есть три вида данных, которые только создаются, но не уничтожаются до конца работы процесса:

  • Абстрактное синтаксическое дерево;
  • Различные данные, собираемые в процессе обхода дерева;
  • "Виртуальные значения", используемые для анализа потока данных и символьного выполнения [5].

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

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

Отсутствие удаления объектов безопасно с практической точки зрения. Все эти "забытые" в памяти объекты не содержат каких-то финализаторов. Их деструкторы не выводят сообщения, не записывают логи, не удаляют файлы и так далее. Это весьма простые классы, которые содержат только какие-то числа, строки и указатели/ссылки на другие подобные объекты.

Итак, поскольку каждый процесс обрабатывает одну единицу компиляции, можно не заботиться о том, нужны в процессе работы какие-то данные или уже точно нет. Проще хранить всё до конца. Это увеличивает потребление памяти, но с точки зрения современной компьютерной техники эти объёмы не критичны. Зато это немного упрощает разработку и уменьшает время работы. По нашим приблизительным замерам, если самостоятельно освобождать память в конце, скорость работы замедлится где-то на 5%.

Обработка внутренних ошибок

А что будет, если память кончится? Поскольку каждый файл обрабатывается отдельно, сбой в одном из процессов мало влияет на общую картину.

Сам сбой может произойти по разнообразным причинам. Например, анализируемый файл может содержать некомпилируемый код или вообще мусор. И тогда, например, один из процессов может начать потреблять слишком много памяти или работать недопустимо долго (V006). При таком неблагоприятном событии процесс будет просто убит, а анализ проекта будет продолжен.

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

Итак, что произойдёт, если не хватит памяти и очередной вызов оператора new сгенерирует исключение std::bad_alloc? Исключение будет перехвачено на верхнем уровне, и ядро спокойно завершит работу, выдав соответствующее предупреждение.

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

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

В рассказе фигурируют такие интересные сущности, как строковые литералы на 26 мегабайт и функция длиной более 800 000 строк.

Юрий Минаев. Конференция CoreHard 2019. Не связывайтесь с поддержкой C++ программистов.

Дополнительные ссылки