Приключения в портировании большой C++ кодовой базы на WASI WebAssembly
Фотография xiao huya с unsplash.com
Привет хакеры, сегодня мы с вами отправимся в путешествие в мир индустриального C++ и Webassembly. Недавно мне довелось поучаствовать в портировании такой сложной и большой кодовой базы на C/C++ как SpiderMonkey на WASI платформу и я хочу поделиться с вами этим опытом. Если вы ничего не слышали про Wasm, то прочитайте этот прекрасно написанный документ, а если ничего не слышали про WASI, то читайте статью Standardizing WASI: A system interface to run WebAssembly outside the web.
Введение
SpiderMonkey это JavaScript движок, который компилирует и выполняет JS код в браузере Firefox и еще в нескольких продуктах. Первые упоминания о SpiderMonkey датируются 1996 годом и следовательно, это очень древняя кодовая база, настолько, что вы можете найти там объявление C функции в стиле Кернигана и Ричи.
Вы можете задаться вопросом -, а зачем вообще кому-то могла понадобиться большая индустриальная JavaScript виртуальная машина в виде .wasm модуля? Про это вы можете прочитать в статье Лин Кларк, Making JavaScript run fast on WebAssembly, а в этой статье мы поговорим о технических моментах. Кстати, все патчи и весь прогресс по этой задаче открыты, так что не стесняйтесь смотреть в этот баг если у вас возникнут вопросы.
К слову, эта работа является сотрудничеством между Igalia, Fastly и Mozilla. Инженер из Fastly Chris Fallin сделал первый прототип, а инженеры Igalia Andy Wingo и я доделали прототип и сделали его частью кодовой базы SpiderMonkey.
Выбор инструментов и первые ограничения
SpiderMonkey умеет JIT компилировать JavaScript код, но на Wasm мы пока не можем сгенерировать новый код в рантайме и передать ему управление, по крайней мере без генерации и инстанциации отдельного модуля. Следовательно, наш первый порт SpiderMonkey будет состоять только из JavaScript интерпретатора написанного на C++.
Есть две опции чтобы скомпилировать C++ в WASI: использовать emscripten или использовать clang напрямую. Emscripten это лучший выбор если вы собираетесь запускать wasm модуль только в браузере, но для нашего случая это не подходит потому что мы хотим иметь зависимость только от WASI, что позволит запускать SpiderMonkey и на сервере тоже. Следовательно, мы выбираем использование clang напрямую. Преимущество такого подхода еще и в том, что мы минимизируем размер итогового .wasm модуля, но при этом все еще можем запускать наш итоговый модуль в браузере через WASI polyfill.
Приступаем к портированию. Мы конечно могли бы использовать просто свежую версию clang с --target=wasm32-unknown-wasi, но есть более удобный вариант — WASI sdk, которая содержит уже настроенный clang и стандартную библиотеку C для WASI. Добавляем в систему сборки SpiderMonkey новый cpu = wasm32 и запускаем сборку. Конечно, только от этого изменения SpiderMonkey.wasm не построится. Мы получаем много ошибок компиляции на отсутствие заголовочных файлов
Отсутствие syscall вызовов
WASI находится на своей ранней стадии развития и некоторая функциональность может отсутствовать. Например, SpiderMonkey использует отсутствующую функцию getpid (). Getpid () возвращает идентификатор текущего процесса, а так как процесс в Wasm у нас всего один, то просто заменяем эту функцию на заглушку с константой — https://phabricator.services.mozilla.com/D110070.
Потоки и сигналы
Запускаем компиляцию снова и разбираем новые сообщения об ошибках. На этот раз ошибка связана с отсутствием поддержки тредов и сигналов. К сожалению, в WASI еще не завезли треды, хотя работа по этому ведется. Чтобы починить эту ошибку мы конфигурируем SM на однопоточный режим, а сами пишем свою стаб реализацию для std: thread https://bugzilla.mozilla.org/show_bug.cgi? id=1701613 и std: atomic, последние в однопоточном режиме легко реализуются https://phabricator.services.mozilla.com/D110215.
С обработкой сигналов чуть-чуть сложнее, но в целом поступаем точно также и вырезаем всю функциональность которая без обработки сигналов вообще не работает https://phabricator.services.mozilla.com/D110216. К счастью, SpiderMonkey в режиме интерпретации вполне может работать без сигналов. Забавно, но сам код для выполнения Wasm в SpiderMonkey использует сигналы, так что его пришлось отключить.
Работа с памятью
Запускаем сборку, и опять ошибки. На этот раз это отсутствие mmap в WASI. Ну, раз mmap нету, то первым решением было просто использовать malloc, который есть в WASI. Это даже работало, но надо было вручную выравнивать запрашиваемый кусок памяти, потому что mmap возвращает выровненный на размер страницы указатель. На самом деле просто malloc«а без выравнивания было достаточно, и все работало, но из-за того, что в SpiderMonkey может быть код, который неявно считает что указатель из mmap выровнен было решено использовать функцию posix_memalign идеально подходящую для наших целей — https://phabricator.services.mozilla.com/D110075. К слову, аллокатор dlmalloc в WASI был специально оптимизирован для posix_memalign, так что решение использовать его не самое плохое.
Rust
После этого компиляция прошла успешно. Ура! Но тут же сборка упала на линковке. Ну конечно, оказывается SpiderMonkey использует компоненты написанные на Rust, которые после компиляции экспортируют символ exit. Точно такой же символ экспортирует libc из WASI sdk. В итоге происходит конфликт и ничего не работает. К счастью, в новой версии WASI sdk 12 этот символ переименовали и надо просто обновится. Ок, теперь все компилируется и линкуется.
Over Recursion: эмулированный C++ stack
Чтобы проверить что все работает как надо запускаем JavaScript тесты из SpiderMonkey на получившемся SM.wasm модуле. Видим что тесты, которые проверяют выброс исключения при слишком глубокой рекурсии падают. Начинаем разбираться почему.
После компиляции в Wasm у нас появляются два стека вместо одного нативного как это было раньше. Один стек это стек самого Wasm под локальные переменные и функции самого Wasm, а второй это эмулированный стек C++, который располагается в WebAssembly.Memory. Переполнение Wasm стека обрабатывается host«ом, который будет исполнять наш итоговый модуль, а вот для переполнение второго стека мы должны обрабатывать сами. Раскладка памяти внутри WebAssembly.Memory по умолчанию будет такой:
Почему раскладка именно такая вы можете посмотреть на рисунке 4 из статьи Everything Old is New Again: Binary Security of WebAssembly.
Нам хочется проверять что стек не залезает на секцию с данными и секцию с кучей. Чтобы это сделать мы меняем раскладку так, чтобы стек располагался прямо в самом начале. Так в случае переполнения хост сразу завершит нашу Wasm программу аварийно.
А SpiderMonkey будет проверять выход за границы стека со стороны секции с данными.
Over Recursion: WebAssembly stack
При компиляции C++ компилятор может использовать эмулированный стек, а может и оптимизировать его, например следующая тривиальная функция вообще не использует линейную память и все вычисления производит на Wasm стеке.
int foo(int a, int b) {
return a + b;
}
foo(int, int):
local.get 1
local.get 0
i32.add
end_function
В рантайме SpiderMonkey тоже могут быть такие функции, которые не используют эмулированный указатель стека, но используют стек самого Wasm. При переполнении Wasm стека хост просто завершит всю Wasm программу, а мы хотим получить исключение в JavaScript о слишком глубокой рекурсии. Поэтому для функций C++ рантайма был реализован AutoRecursioLimiter RAII класс, который считает вложенность вызовов и при достижении заранее определенного порогового значения сообщает SpiderMonkey о необходимости выбросить исключение — https://phabricator.services.mozilla.com/D111813. Отдельное спасибо Jan de Mooij из Mozilla за помощь с этим.
CI Build
После всех этих приседаний SM.wasm прошел все тесты и добрые люди из mozilla согласились еще и добавить к ним CI build чтобы проверять что ничего не поломалось https://bugzilla.mozilla.org/show_bug.cgi? id=1710358. На момент написания статьи билд включен, зелен и входит в Tier 2 platform для SpiderMonkey.
Online demo
Забавы ради я взял WASI polyfill для браузера на wasmer и запустил SpiderMonkey в вебе — https://dbezhetskov.dev/flying_monkey/. Это личная экспериментальная и неофициальная версия в которой вы можете запускать JS скрипты в вебе. Никогда такого конечно не было…
Заключение
Webassembly становится домкратом с помощью которого можно оживлять старые нативные приложения в вебе и я думаю что это очень хорошо. Инструменты уже подтягиваются на тот уровень где с ними уже удобно работать и на примере SM видно что даже самое старое ПО можно запустить в браузере. Вот лишь небольшая ностальгическая подборка того, что Webassembly уже позволил сделать: Doom3, Pokemon Blue, Baldur«s gate 2, Spy Fox и Diablo 1. Получается очень удобно, не надо ничего скачивать и устанавливать, просто открыл ссылку в браузере и все работает. Да, конечно процесс портирования может быть сложным, но WASI развивается и постепенно процесс становится легче. Как по мне, привносить инструменты в веб которые упрощают работу, не требуют установки и обновлений, да еще и экономят время на развертывание и работают на всем, на чем есть браузер это хорошо.
Спасибо за прочтение, и надеюсь я был вам полезен.