Как стать автором
Обновить
VK
Технологии, которые объединяют

Exception Handling: сквозь мультивселенные интероперабельности

Время на прочтение10 мин
Количество просмотров2.4K


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

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

Контекст


Tarantool — это расширяемая Middleware-платформа, которая состоит из In-memory-базы данных и сервера приложений. С помощью Tarantool вы можете разрабатывать сложную бизнес-логику на языке программирования Lua в непосредственной близости к данным. В качестве среды исполнения Lua здесь используется свой форк LuaJIT.
Часть компонентов Tarantool написана на C, а часть на Lua, что при исполнении приводит к многослойным «сэндвичным» переходам между этими компонентами. Миры Lua и C могут быть связаны тремя способами:

  • модули для Lua, написанные на C;
  • вызов Lua-функций через Lua C API;
  • вызовы С-функций из Lua через FFI.

Обработка ошибок


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



Или, например, как в Python:



Ну или как в Lua:



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

В случае Tarantool все становится еще интереснее, когда мы вспоминаем, что у нас возможны сложные сэндвичи из Lua и C.

Рассмотрим вот такой модуль на С++:



В нем есть функция throwAnException, которая не делает ничего, кроме того, что выбрасывает исключение. С другой стороны, у нас будет такой Lua-скрипт, который будет исполняться в Tarantool:



Этот скрипт подгружает функцию throwAnException, используя FFI, а затем вызывает ее с использованием pcall. Автор скрипта логично размышляет: «Я в pcall завернул эту функцию, скрипт не должен взорваться».

А получается вот так: 



Ошибка нормально не обрабатывается, происходит падение. С одной стороны, пользователь сам виноват, потому что пытался поймать в Lua pcall C++ на exception, не подумав о том, что это разные сущности. Но, с другой стороны, такая конструкция выглядит очень естественной и удобной для пользователя, поэтому нам все-таки придется позаботиться о такой ситуации. 

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

Интероперабельность исключений


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



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

Внутреннее устройство исключений


Внешне алгоритм обработки исключений выглядит очень просто:

  1. Бросаем исключение.
  2. Ищем обработчик.
  3. Нашли — отдаем ему управление.
  4. Не нашли — завершаем процесс.

На самом же деле есть еще один этап, который стоит раньше всех этих, — обработчик надо отметить. Именно на этом этапе происходит все самое интересное.

Ретроспектива


Самым первым способом обработки исключений был так называемый SJLJ. Это способ обработки исключений прямиком из времени, когда исключения только начали появляться.

Для его реализации используются две функции стандартной библиотеки: int setjmp(jmp_buf env) и void longjmp(jmp_buf env, int val).

Функция setjmp позволяет сохранить контекст исполнения в точке вызова в jmp_buf. Впоследствии этот контекст можно восстановить. Для внешнего наблюдателя это восстановление будет выглядеть как второй возврат из setjmp. Парная для setjmp функция longjmp занимается как раз этим — восстанавливает контекст. Еще одним параметром ей можно указать возвращаемое значение, с которым будет осуществлен этот второй возврат из setjmp.
Основываясь на этом, мы можем построить простенький аналог try-catch:



При исполнении этого блока кода мы сохраним контекст с помощью setjmp, после чего зайдем в тело if. В теле произойдет вызов longjmp, который здесь играет роль throw. Мы снова выйдем из setjmp, но на этот раз с другим значением, и попадем в тело else.

С магией макросов это вообще можно привести к максимально похожему на try-catch виду:



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

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



Что упустили? Хотелось бы честно деструктурировать объекты, которые мы выделяем на стеке, C++ же. В качестве одного из классических решений здесь предлагается поддерживать еще и список объектов параллельно со списком контекстов. Тогда процесс обработки исключения принимает следующий вид:



Естественно, есть причина, почему сейчас этот способ используется только в очень экзотических случаях. Эта причина — очень высокие накладные расходы на такой подход.

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

DWARF exceptions


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



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

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

Фаза поиска


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

  1. Если обработчик не будет найден, то мы продолжим размотку. 
  2. Если будет — перейдем ко второй фазе. 
  3. Если же ни в одной фрейме обработчик не найден, то процесс завершается с ошибкой.



Фаза очистки


Эта фаза нужна для очистки разного рода ресурсов, которые в данный момент используются и перестанут быть доступны после покидания фрейма из-за исключения. На каждом фрейме в этой фазе снова вызывается personality routine, теперь уже с другими параметрами. В этот раз она занимается очисткой ресурсов для этого конкретного фрейма. Стек в этой фазе разматывается до фрейма, в котором мы нашли обработчик. После завершения этой фазы управление передается в так называемый landing pad. Это еще одна специальная прослойка, которая обычно производит какие-то дополнительные действия по выставлению контекста для обработчика, а также выбирает один из подходящих catch-блоков, если их несколько. В некоторых особенных ситуациях он может пробрасывать exception дальше, их мы обсудим чуть позже.



Построение интероперабельности


Теперь мы знаем, как работает обработка исключения, и можем построить с использованием этого механизма интероперабельность между Lua и C++. У виртуальной машины LuaJIT есть конкретная точка входа — lj_vm_call, в этой функции мы начинаем интерпретировать байткод. Так как вся виртуальная машина выглядит как один фрейм, мы можем задать свою personality routine для этой функции и реализовать в ней всю логику, нужную для интероперабельности. По сути, имплементировать DWARF exceptions, но на стеке виртуальной машины. Мы будем точно так же искать обработчик в стеке виртуальной машины и очищать Lua-ресурсы. Пример того, как это устроено, можно увидеть на схеме ниже:



Фаза очистки выглядит абсолютно аналогично.

Нюансы работы с внешними исключениями


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

Интероперабельность исключений в LuaJIT


Рассмотрим упрощенные листинги personality routine для LuaJIT. Фаза поиска выглядит следующим образом:



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

Если эта функция не нашла обработчик, то мы продолжаем размотку. Если же нашла, мы проверяем тип исключения: если это плюсовое исключение, то подготовим Lua-ошибку вместо него. После этого мы сообщаем, что обработчик найден, и завершаем фазу поиска.



В фазе очистки мы вновь смотрим на тип исключения, выставляем errcode в случае Lua-ошибок, а в случае C++-исключения удаляем его, так как это внешнее исключение для VM и вместо него мы уже создали Lua-ошибку. Наконец, мы проводим размотку стека VM, но теперь уже с очисткой ресурсов. После этого выставляем контекст для landing pad VM и отдаем управление туда.

Интероперабельность и JIT


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

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

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

Регистрация нового фрейма


Для регистрации DWARF-информации для нового фрейма в рантайме есть функция __register_frame. Информация ей на вход подается в виде буфера, который может содержать сущности двух видов: Frame Description Entry (FDE) и Common Information Entry (CIE). Frame Description Entry — это структура, в которой хранится информация о фрейме: какая функция является его personality routine, какого он размера и так далее. Common Information Entry — это еще одна структура, которая создана для экономии пространства. В нее вносятся те данные, которые для нескольких фреймов одинаковы, а их FDE будет содержать лишь различающиеся части и ссылку на CIE.

Нас интересует в первую очередь, что через FDE и CIE мы можем задать personality routine, чем мы и воспользуемся для регистрации информации о трассах.

Восстановление состояния VM


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



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

Рассмотрим пример простого Lua-цикла и его промежуточного представления в JIT-компиляторе. Для простоты понимания часть промежуточного представления опущена, а числовой индекс переменной x заменен на ее имя. 



Видим, что в цикле прибавляется единица к x, дальше у нас есть какой-то snap — это как раз снапшот. Дальше идет сравнение х с четверкой. В случае, когда условие цикла не выполнится и мы покинем трассу, этот снапшот будет использован для восстановления. В данном снапшоте x стоит на третьей позиции, а значит, он будет перемещен из соответствующего ему регистра в третий слот виртуального стека.

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

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

Пример реализации интероперабельности для JIT-скомпилированных участков


Фаза поиска выглядит тривиально: personality routine для трасс всегда сообщает, что обработчик ошибки найден. Это делается для того, чтобы выйти с трассы в виртуальную машину и перебросить исключение в нее.



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



Извлекается текущее положение на трассе, затем функция lj_trace_unwind находит наиболее подходящий снапшот для восстановления, и управление передается в landing pad.

Результат


Рассмотрим Lua-скрипт и FFI-модуль, выбрасывающий исключение, из начала статьи:





Если без интероперабельности он завершался с ошибкой:



то теперь пользователь получает наиболее ожидаемое и интуитивное поведение:



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



Если без интероперабельности он завершается с ошибкой:



то с интероперабельностью это исключение корректно обрабатывается:



Выводы


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

Достаточно сделать следующее:

  • Задать одну personality routine для точки входа в виртуальную машину.
  • Задать loading pad в местах виртуальной машины, где необходимы дополнительные действия для восстановления контекста. 
  • Для JIT-скомпилированного кода задавать personality routine в рантайме с помощью __register_frame и расставлять дополнительные снапшоты или их эквивалент для восстановления состояния виртуальной машины.

Полезные ссылки


  1. C-модули для Lua
  2. Lua C API
  3. LuaJIT FFI
  4. Стандарт DWARF
  5. Документация для секции .eh_frame
  6. Описание обработки исключений простым языком от  Aldy Hernandez
  7. Itanium C++ ABI Exception Handling:
Теги:
Хабы:
Всего голосов 38: ↑38 и ↓0+38
Комментарии0

Публикации

Информация

Сайт
team.vk.company
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия
Представитель
Руслан Дзасохов