Как стать автором
Обновить

Как устроены условные точки останова

Время на прочтение9 мин
Количество просмотров2.8K
Автор оригинала: Andy Hippo
Условные точки останова (conditional breakpoints) – исключительно полезный инструмент. Но всем известно, насколько они замедляют работу кода, так, что из-за этого некоторые даже бросают ими пользоваться. В Visual Studio в своё время удалось значительно улучшить ситуацию с ними, что не помешало пользователю @ryanjfleury высмеивать их крайнюю медлительность. Но даже у raddbg уходит около 2 секунд на выполнение 10000 итераций простого цикла, если внутри него расставлены точки останова. Для сравнения: без точек останова тот же самый цикл выполняется менее чем за 1 мс. Почему же так чертовски медленно?

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

Обратите внимание: в этой статье речь идёт об отладчиках, работающих с нативным кодом – например, GDB, LLDB, Visual Studio C++. Отладчики для управляемых и скриптовых языков работают примерно так же, но могут отличаться детали реализации.

Просто проверить условие?


Говоря об «условных точках останова», я имею в виду самую обычную точку такого рода, в которой заложено определённое пользователем условие. Программа останавливается лишь в том случае, если данное условие результирует в true. Обычно условие определяется как выражение, написанное (почти) на таком же языке, что и оригинальная программа, причём, условие может обращаться к локальным и глобальным переменным. Следовательно, можно делать подобные вещи:

   1. func main() {
   2.   for i, user := range getUsers() {
🔴 3.     lastName := getLastName(user)        // condition: (i % 10 == 0)
🔴 4.     fmt.Printf("Hello, %s\n", lastName)  // condition: (lastName == "Hippo")
   5.   }
   6. }

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

Проследим код

Давайте проследим код, содержащийся в LLDB и посмотрим, как именно он реализован в данном случае. Чтобы получилось быстрее, давайте начнём с уровня API. Мы быстро заметим, что можно создавать точки останова самым разными способами (например, устанавливать их на строке, на символе или запрограммировать как регулярное выражение), но ни одним из этих способов мы, фактически, не можем описать условие (SBTarget.h#L596):

lldb::SBBreakpoint BreakpointCreateByLocation(
   const char *file, uint32_t line);
lldb::SBBreakpoint BreakpointCreateByName(
   const char *symbol_name, const char *module_name = nullptr);
lldb::SBBreakpoint BreakpointCreateByRegex(
   const char *symbol_name_regex, const char *module_name = nullptr);
lldb::SBBreakpoint BreakpointCreateForException(
   lldb::LanguageType language, bool catch_bp, bool throw_bp);

Правда, объект точки останова — это метод с характерным именем (SBBreakpoint.h#L76):

class LLDB_API SBBreakpoint {
public:
  void SetCondition(const char *condition);
};

Этот метод не так много делает. Он просто сохраняет условие как строку в опциях точки останова:

SBBreakpoint::SetCondition -> Breakpoint::SetCondition -> BreakpointOptions::SetCondition

Пока всё соответствует нашей первой версии: условная точка останова — это та же обычная точка останова, вслед за которой сохранено условие. Далее происходит именно то, что мы ожидаем: при срабатывании такой точки процесс останавливается и отладчик проверяет, ассоциировано ли с данной точкой останова какое-либо условие. Если да — то он пытается автоматически его интерпретировать и продолжает работу, если условие результирует в false. Код для этой операции достаточно длинный, поэтому приведу здесь лишь наиболее релевантный для нас фрагмент (слегка переформатирован на основе Target/StopInfo.cpp#L447):

if (bp_loc_sp->GetConditionText() == nullptr)
   actually_hit_any_locations = true;
else {
   Status condition_error;
   bool condition_says_stop =
   bp_loc_sp->ConditionSaysStop(exe_ctx, condition_error);
   ...
   if (condition_says_stop)
   actually_hit_any_locations = true;
   ...
}
...
if (!actually_hit_any_locations) {
   // В конце концов, у нас просто не оказалось таких мест, которые успешно прошли бы свои проверки на
   // "попали ли в меня". Поэтому мы и не останавливаемся.
   GetThread()->ResetStopInfo();
   LLDB_LOGF(log, "Process::%s all locations failed condition checks.", __FUNCTION__);
}

Здесь интересна функция ConditionSaysStop(). До сих пор мы не прояснили, что представляет собой условие, мы просто передали его как строку. Что ж, оказывается, что в качестве условия может выступать (почти) любой код на C++! В официальной документации это прописано не очень чётко, но в одном из примеров находим выражение, в составе которого — приведение и вызов функции. Как LLDB с этим справляется? Вслед за реализацией ConditionSaysStop() видим, что отладчик разбирает выражение, а затем выполняет его. Здесь предусмотрена приятная оптимизация, позволяющая кэшировать разобранное выражение между разными актами выполнения.

Для разбора выражения отладчик пользуется компилятором clang, при помощи которого поддаются обработке очень сложные выражения. Строковое выражение преобразуется в промежуточное представление LLVM, которое далее может быть отправлено на выполнение. Если полученное таким образом промежуточное представление получается “достаточно простым”, то его может интерпретировать сам отладчик. Но, если в нём содержатся какие-либо нетривиальные инструкции, то выражение динамически компилируется, внедряется в целевой процесс и уже там выполняется.

Почему так медленно?


Возможно, вы удивлены, но на самом деле именно так в большинстве современных отладчиков реализуются условные точки останова. Например, отладчик delve, применяемый в Golang, принимает в качестве условий Go-подобные выражения, а затем интерпретирует их всякий раз, когда программа попадает в точку останова. Другой пример — отладчик raddbg для C/C++, код которого недавно был выложен в открытый доступ. Он сохраняет условия как строки выражений, а затем интерпретирует их всякий раз, когда срабатывает конкретная точка останова.

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

Остановите мир

Быстро напомню, как действуют точки останова. Отладчик вносит патч в код процесса – пишет специальную инструкцию перехвата int3 по тому адресу, где процесс должен остановиться. Когда ЦП выполнит эту инструкцию, возникает прерывание, процесс останавливается, и управление передаётся отладчику. Об этом можно почитать здесь https://eli.thegreenplace.net/2011/01/27/how-debuggers-work-part-2-breakpoints или здесь http://www.nynaeve.net/?p/=80 — в этих статьях тема объяснена гораздо лучше.

Если приходится проверять условие при каждом фактическом попадании в точку останова, то возникает такая проблема: отладчик действительно должен всякий раз прекращать выполнение программы. Если впоследствии окажется, что условие результировало в false, мы всё равно несём издержки, связанные с остановкой и возобновлением процесса. Эти издержки не так заметны на тех путях кода, которые выполняются нечасто, но точки останова, расставленные в плотных циклах, определённо влияют на производительность. Даже в одном цикле «остановка-возобновление» отладчику приходится проделать следующие операции:
  1. (допустим, мы уже где-то установили точку останова)
  2. При попадании в точку останова – срабатывает прерывание, и процесс ставится на паузу
  3. Затираем int3 исходной инструкцией
  4. Возобновляем выполнение процесса в режиме «выполнение одной инструкции»
  5. Вновь записываем int3
  6. Возобновляем процесс
Ситуация значительно усугубляется, если требуется отлаживать удалённые процессы, поскольку в таком случае всерьёз встаёт проблема сетевых издержек. Допустим, у вас есть удалённый процесс, у которого на путь «туда и обратно» уходит 1 мс. Таким образом, на остановку и возобновление процесса уже уходит ~5 мс, без учёта времени на обработку. Если внутри цикла у вас находится точка останова, то вы никак не получите более 200 итераций в секунду 🐢🐢🐢.

Выполняем код, пока выполняется код

Не менее сложной задачей может оказаться интерпретация условий точки останова. В большинстве отладчиков такая функция уже предусмотрена в той или иной форме, поскольку она часто задействуется для работы со сценариями и визуализации данных. Как правило, поддерживается не весь «исходный» язык, а только его подмножество или даже какой-то похожий скриптовый язык. Увы, реализовать интерпретатор на полнофункциональном C++ просто для интерпретации условий — это далеко не тривиальная задача :D

Производительность при вычислении выражений СИЛЬНО варьируется от отладчика к отладчику. Как я уже упоминал выше, LLDB в самом деле пытается поддерживать (почти) любой валидный код на C++, при этом компилирует и выполняет его при помощи clang. Можете себе представить, как медленно это происходит. В delve содержатся условия, написанные на Go, а этот язык гораздо лучше поддаётся синтаксическому разбору, чем C++. Получающиеся в итоге абстрактные синтаксические деревья из выражений можно использовать многократно, но, если вы попадаете в длинное и «тяжёлое» выражение, то дела идут медленно. Я не знаю в подробностях, каков синтаксис того языка выражений, который поддерживается в raddbg, но, подозреваю, он не позволит вам никаких чрезмерных вольностей. Следовательно, на этом языке вполне реально всякий раз выполнять синтаксический разбор и вычисление выражения с нуля.

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

Путешествие к центру процесса


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

Теоретически ситуация выглядит отлично, но на практике, чтобы такого добиться, нужно решить ряд проблем:
  • Инструкция перехвата (int3) — это опкод размером всего в один байт, поэтому вставляется куда угодно. «Код для проверки условия» может быть большим (даже очень), поэтому его нельзя просто взять и поставить в любое место.
  • Код процесса — машинный, поэтому первым делом нам потребуется скомпилировать наше условное выражение до машинного кода. Эта задача сильно зависит от платформы, в принципе, нам здесь потребуется реализовать настоящий компилятор.
Для первой проблемы идеального решения, фактически, не существует. Максимум, что мы можем сделать — выделить в программе некоторое место, где программа могла бы хранить наш код, а затем внедрять инструкции jmp в тех точках, где нам потребуется проверить условие. На переход требуется гораздо больше одного байта, поэтому везде такой способ применяться не может, однако, во многих случаях этого может быть достаточно. Если выделять память в программе, то из-за этого могут возникать побочные эффекты, но, послушайте, ведь мы хотели всё сделать по-быстрому, верно?

Что касается второй проблемы — здесь есть варианты. В некоторых отладчиках предоставляются «агенты» или «прицепы», в которых содержится некоторая логика и которые можно внедрять в процесс. Хороший пример — GDB и предусмотренный в нём внутрипроцессный агент. Эта возможность проектировалась как раз для тех ситуаций, когда остановка процесса нежелательна, поэтому предпочтительнее будет в том или ином виде реализовать внутрипроцессное выполнение.

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

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

Если выполнять условия останова непосредственно в коде процесса, то можно радикально (я имею в виду, РАДИКАЛЬНО) повысить производительность. Отладчик UDB использует внутрипроцессный агент, уже известный нам из примера с GDB, и с его помощью интерпретирует условия останова. В таком случае измерения показывают, что производительность улучшается примерно в 1000x раз – https://www.youtube.com/watch?v=gcHcGeeJHSA.

Если хочется выжать производительность до капли и двигаться ещё быстрее — что ж, придётся компилировать выражения. Не знаю, есть ли какой-нибудь отладчик, в котором такая функция поддерживается нативно, но LLDB к этому очень близок. В нём есть встроенный динамический компилятор, который как раз используется для интерпретации условий в точках останова, но он всё равно работает по стандартной модели «остановить-интерпретировать-продолжить». Уже некоторое время витает в воздухе идея о том, чтобы при помощи динамического компилятора ускорять работу условных точек останова. Но, хотя подготовительная работа для этого уже во многом выполнена, остаётся доделать ещё немало нетривиальных деталей реализации. Может быть, когда-нибудь…


P.S. Обращаем ваше внимание на то, что у нас на сайте проходит распродажа.
Теги:
Хабы:
+10
Комментарии10

Публикации

Информация

Сайт
piter.com
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия