Эта статья — рассказ о том, как мы однажды решили улучшить наш внутренний инструмент SelfTester, который мы применяем для проверки качества анализатора PVS-Studio. Улучшение было простым и вроде бы полезным, но доставило нам некоторые неприятности. Позже оказалось, что лучше отказаться от этой идеи.

самотестер

Мы разрабатываем и продвигаем статический анализатор кода PVS-Studio для языков C, C++, C# и Java. Для проверки качества нашего анализатора мы используем внутренние инструменты, обычно называемые SelfTester. Мы создали отдельную версию SelfTester для каждого поддерживаемого языка. Это связано со спецификой тестирования, да и просто удобнее. Таким образом, на данный момент у нас в компании есть три внутренних инструмента SelfTester для C\C++, C# и Java соответственно. Далее я расскажу о Windows-версии SelfTester для проектов C\C++ Visual Studio, назвав ее просто SelfTester. Этот тестер был первым в линейке подобных внутренних инструментов, он самый продвинутый и сложный из всех.

Как работает самотестер? Идея проста: взять пул тестовых проектов (мы используем настоящие проекты с открытым исходным кодом) и проанализировать их с помощью PVS-Studio. В результате для каждого проекта создается журнал анализатора. Этот журнал сравнивается с эталонным журналом того же проекта. При сравнении журналов SelfTester создает сводку журналов в удобном для разработчиков виде.

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

На основе сводки сравнения логов разработчик вносит изменения в ядро ​​анализатора (например, при создании нового диагностического правила) и сразу же контролирует результат своих правок. Если у разработчика больше нет проблем со сравнением обычного журнала, он создает текущий журнал предупреждений ссылка для проекта. В противном случае работа продолжается.

Итак, задача SelfTester — работать с пулом тестовых проектов (кстати, для C/C++ их более 120). Проекты для пула отбираются в виде решений Visual Studio. Это сделано для того, чтобы дополнительно проверить работу анализатора на различных версиях Visual Studio, поддерживающих анализатор (на данный момент от Visual Studio 2010 до Visual Studio 2019).

Примечание:далее я буду разделять понятия решение и проект, рассматривая проект как часть решения.

Интерфейс SelfTester выглядит следующим образом:

Слева список решений, справа — результаты проверки для каждой версии Visual Studio.

Серые метки «Не поддерживается» означают, что решение не поддерживает выбранную версию Visual Studio или не было преобразовано для этой версии. Некоторые решения имеют конфигурацию в пуле, которая указывает конкретную версию Visual Studio для проверки. Если версия не указана, решение будет обновлено для всех последующих версий Visual Studio. Пример такого решения на скриншоте — «smart_ptr_check.sln» (проверка делается для всех версий Visual Studio).

Зеленая метка «ОК» указывает на то, что регулярная проверка не выявила расхождений с эталонным журналом. Красная метка «Diff» указывает на различия. Этим этикеткам следует уделить особое внимание. После двойного щелчка по нужной метке выбранное решение будет открыто в соответствующей версии Visual Studio. Там же будет открыто окно с журналом предупреждений. Кнопки управления внизу позволяют повторно запустить анализ выбранного или всех решений, сделать выбранный журнал (или все сразу) эталонным и т.д.

Результаты SelfTester всегда дублируются в html-отчете (diffs report).

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

Почему важна скорость:

  • Производительность каждого шага очень важна с точки зрения ночных тестовых прогонов. Очевидно, чем быстрее пройдут тесты, тем лучше. На данный момент среднее время работы SelfTester превышает 2 часа;
  • При запуске SelfTester в течение дня разработчику приходится меньше ждать результата, что повышает производительность его труда.

Именно ускорение производительности и стало поводом для доработок на этот раз.

Многопоточность в SelfTester

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

Чтобы сделать работу более эффективной, SelfTester использует интеллектуальный планировщик задач, который устанавливает строго ограниченное значение параллельных потоков и поддерживает его.

Планировщик используется на двух уровнях. Первый — уровень решений, используется для начала тестирования решения .sln с помощью утилиты PVS-Studio_Cmd.exe. Тот же планировщик, но с другой настройкой степени параллелизма, используется внутри PVS-Studio_Cmd.exe (на уровне тестирования исходных файлов).

Степень параллелизма — это параметр, указывающий, сколько параллельных потоков должно выполняться одновременно. Для степени параллелизма решений и уровня файлов были выбраны четыре и восемь значений по умолчанию соответственно. Таким образом, количество параллельных потоков в этой реализации должно быть равно 32 (4 одновременно тестируемых решения и 8 файлов). Эта настройка представляется нам оптимальной для работы анализатора на восьмиядерном процессоре.

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

Примечание. давайте далее предположим, что мы имеем дело со степенью параллелизма по умолчанию.

Планировщик LimitedConcurrencyLevelTaskScheduler унаследован от System.Threading.Tasks.TaskScheduler и усовершенствован для обеспечения максимального уровня параллелизма при работе с ThreadPool. Иерархия наследования:

LimitedConcurrencyLevelTaskScheduler : PausableTaskScheduler
{ .... }
PausableTaskScheduler: TaskScheduler
{ .... }

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

Причины уточнений

Описанный выше процесс имеет недостаток: он не оптимален при работе с растворами разных размеров. А размеры решений в тестовом пуле очень разнообразны: от 8 КБ до 4 ГБ — размер папки с решением и от 1 до нескольких тысяч файлов исходного кода в каждой.

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

Но представим довольно частую ситуацию, когда тестируется несколько небольших решений. Например, одно решение большое и содержит 50 файлов (будет использовано максимальное количество потоков), а остальные три решения содержат по три, четыре, пять файлов. В этом случае мы будем использовать только 20 потоков (8 + 3 + 4 + 5). Получаем недоиспользование процессорного времени и снижение общей производительности.

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

Улучшения

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

Давайте снова рассмотрим наш пример проверки четырех решений со следующим количеством файлов в каждом: 50, 3, 4 и 5. Задача, которая проверяет решение из трех файлов, скорее всего, отработает быстрее всего. Лучше вместо него добавить решение с восемью и более файлами (чтобы использовать максимум из доступных потоков для этого решения). Таким образом, мы будем использовать 25 потоков одновременно (8 + 8 + 4 + 5). Неплохо. Однако семь потоков по-прежнему не задействованы. И тут приходит идея еще одной доработки, заключающейся в том, чтобы убрать ограничение в четыре потока при тестировании решений. Потому что теперь мы можем добавлять не одно, а несколько решений, используя 32 потока. Представим, что у нас есть еще два решения по три и четыре файла каждое. Добавление этих задач полностью закроет «пробел» неиспользуемых тредов, и их будет 32 (8 + 8 + 4 + 5 + 3 + 4) из них.

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

Нам нужно было переработать класс задачи: наследование от System.Threading.Tasks.Task и присвоение поля «вес». Мы используем простой алгоритм присвоения веса решению: если количество файлов меньше восьми, вес равен этому числу (например, 5). Если число больше или равно восьми, вес будет равен восьми.

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

Наконец, нам понадобился предварительный шаг для анализа всех решений в пуле (оценка с использованием MSBuild API), чтобы оценить и установить вес решений (получение количества файлов с исходным кодом).

Результат

Думаю, после такого длинного вступления вы уже догадались, что ничего не вышло.

Хорошо хоть, что улучшения были простыми и быстрыми.

Вот и настала та часть статьи, где я собираюсь рассказать вам о том, что «доставило нам много неприятностей» и обо всем, что с этим связано.

Побочные эффекты

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

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

А потом…

Один из разработчиков пожаловался на сбой SelfTester. Что ж, в жизни бывает. Чтобы эта ошибка не потерялась, мы создали внутренний инцидент (тикет) с названием «Исключение при работе с SelfTester». Ошибка произошла при оценке проекта. Хотя большое количество окон с ошибками указывало на проблему еще в обработчике ошибок. Но это быстро устранили, и в течение следующей недели ничего не вылетало. Внезапно другой пользователь пожаловался на SelfTester. Опять ошибка оценки проекта:

На этот раз стек содержал много полезной информации — ошибка была в формате xml. Вероятно, при обработке файла проекта Proto_IRC.vcxproj (его xml-представления) что-то произошло с самим файлом, поэтому XmlTextReader не смог его обработать.

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

Для начала мы проанализировали последний крах. К сожалению, мы не нашли ничего подозрительного. На всякий случай мы попросили разработчиков (пользователей SelfTester) следить и сообщать о возможных ошибках.

Важный момент: ошибочный код был повторно использован в SelfTester. Изначально он использовался для оценки проектов в самом анализаторе (PVS-Studio_Cmd.exe). Поэтому внимание к проблеме возросло. Однако таких сбоев в анализаторе не было.

Тем временем тикет о проблемах с SelfTester пополнился новыми ошибками:

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

  • Оценка проектов в ходе предварительного расчета весов решений: новый шаг, изначально вызвавший подозрения;
  • Обновление проектов до необходимых версий Visual Studio: выполняется непосредственно перед тестированием (проекты не мешают) и не должно влиять на рабочий процесс.
  • Оценка проектов во время тестирования: хорошо зарекомендовавший себя потокобезопасный механизм, повторно используемый из PVS-Studio_Cmd.exe;
  • Восстановление файлов проекта (замена измененных файлов .vcxproj исходными эталонными файлами) при выходе из SelfTester, т.к. файлы проекта могут обновляться до необходимых версий Visual Studio в процессе работы. Это последний шаг, который не влияет на другие механизмы.

Подозрение пало на новый код, добавленный для оптимизации (расчет веса). Но исследование его кода показало, что если пользователь запускает анализ сразу после запуска SelfTester, тестер всегда корректно дожидается окончания предварительной оценки. Это место выглядело безопасным.

Опять же, нам не удалось определить источник проблемы.

Боль

Весь следующий месяц SelfTester продолжал то и дело падать. Тикет продолжал заполняться данными, но было непонятно, что с этими данными делать. Большинство сбоев было связано с одним и тем же XmlException. Иногда возникало что-то еще, но с тем же повторно используемым кодом из PVS-Studio_Cmd.exe.

Традиционно к внутренним инструментам не предъявляются очень высокие требования, поэтому мы продолжали разбираться с ошибками SelfTester по остаточному принципу. Время от времени привлекались разные люди (за все время инцидента над проблемой работали шесть человек, в том числе два стажера). Однако нам пришлось отвлечься на эту задачу.

Наша первая ошибка. На самом деле, на этом этапе мы могли бы решить эту проблему раз и навсегда. Как? Было ясно, что ошибка вызвана новой оптимизацией. Ведь до этого все работало хорошо, а переиспользованный код явно не может быть таким уж плохим. Кроме того, эта оптимизация не принесла никакой пользы. Итак, что нужно было сделать? Удалить эту оптимизацию. Как вы, наверное, понимаете, это не было сделано. Мы продолжали работать над проблемой, которую создали сами. Мы продолжили искать ответ: «КАК???» Как это происходит? Вроде правильно написал.

Наша вторая ошибка. Другие люди были вовлечены в решение проблемы. Это очень, очень большая ошибка. Это не только не решило проблему, но и потребовало дополнительных затрат ресурсов. Да, новые люди принесли новые идеи, но на реализацию (даром) этих идей уходило много рабочего времени. В какой-то момент наши стажеры писали тестовые программы, эмулирующие оценку одного и того же проекта в разных потоках с параллельной модификацией проекта в другом проекте. Это не помогло. Мы только узнали, что MSBuild API был потокобезопасным внутри, о чем мы уже знали. Мы также добавили автоматическое сохранение мини-дампа при возникновении исключения XmlException. У нас был кто-то, кто отлаживал все это. Бедный парень! Были дискуссии, мы занимались другими ненужными вещами.

Наконец, третья ошибка. Знаете ли вы, сколько времени прошло с момента возникновения проблемы с SelfTester до момента ее решения? Ну, вы сами можете посчитать. Тикет был создан 17.09.2018 и закрыт 20.02.2019. Было более 40 комментариев! Ребята, это много времени! Мы позволили себе пять месяцев заниматься ЭТИМ. Параллельно мы были заняты поддержкой Visual Studio 2019, добавлением поддержки языка Java, введением стандарта MISRA C/C++, улучшением анализатора C#, активным участием в конференциях, написанием кучи статей и т.д. На все эти мероприятия ушло меньше времени. разработчиков из-за глупой ошибки в SelfTester.

Ребята, учитесь на наших ошибках и никогда так не поступайте. Мы тоже не будем.

Вот и все, я закончил.

Ладно, это была шутка, расскажу, в чем была проблема с SelfTester :)

Бинго!

К счастью, среди нас нашелся человек с ясным взглядом (мой коллега Сергей Васильев), который просто посмотрел на проблему совсем под другим углом (и еще — ему повезло). Что делать, если внутри SelfTester все в порядке, но что-то извне крашит проекты? Обычно у нас ничего не запускалось с помощью SelfTester, в некоторых случаях мы строго контролировали среду выполнения. В данном случае этим самым «что-то» может быть сам SelfTester, но другой экземпляр.

При выходе из SelfTester поток, восстанавливающий файлы проекта из эталонов, некоторое время продолжает работать. В этот момент тестер может быть запущен снова. Защита от одновременного запуска нескольких экземпляров SelfTester была добавлена ​​позже и теперь выглядит следующим образом:

Но на тот момент у нас его не было.

Гайка, но факт — за почти полгода мучений никто не обратил на это внимания. Восстановление проектов из референсов — довольно быстрая фоновая процедура, но, к сожалению, недостаточно быстрая, чтобы не мешать повторному запуску SelfTester. И что происходит, когда мы запускаем его? Правильно, вычисление весов решений. Один процесс перезаписывает файлы .vcxproj, а другой пытается их прочитать. Передайте привет XmlException.

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

После этого SelfTester перезапускается. А раньше, видимо, пользователи как-то сами эмулировали проблему, снова запуская тестер.

Обвинения и выводы

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

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

  • Мы должны отслеживать и оценивать сложность задачи;
  • Иногда нам нужно остановиться в какой-то момент;
  • Попробуйте посмотреть на проблему шире. Со временем можно получить туннельное видение дела, в то время как для этого требуется свежий взгляд.
  • Не бойтесь удалять старый или ненужный код.

Вот и все, на этот раз я точно закончил. Спасибо, что дочитали до конца. Желаю вам безглючного кода!