15.6. JavaScript FFI в wasm-бэкенде
Бэкенд GHC wasm поддерживает функцию JavaScript FFI. Для проектов на Haskell, предназначенных для запуска в средах JavaScript, таких как браузеры или nodejs, JavaScript FFI позволяет:
- Вызывать JavaScript из Haskell с помощью foreign imports и наоборот — с помощью foreign exports.
- Представлять значения JavaScript как полноценные значения Haskell, собираемые сборщиком мусора и хранящиеся в куче Haskell.
- Ожидать асинхронные вычисления JavaScript в одном потоке Haskell, не блокируя всё время выполнения.
- Не платить за JavaScript, если он не используется; та же цепочка инструментов по-прежнему по умолчанию создаёт автономные модули
wasm32-wasi.
Реализация JavaScript FFI в бэкенде GHC wasm впервые появилась в проекте asterius и во многом вдохновлена GHCJS — предшественником бэкенда JavaScript для GHC. Несмотря на некоторое сходство, она всё же существенно отличается от реализации в бэкенде JavaScript для GHC. Остальная часть этого руководства — авторитетное справочное руководство по JavaScript FFI бэкенда GHC wasm, которое далее мы будем сокращённо называть JSFFI.
15.6.1. Маршалируемые типы и JSVal
JSFFI поддерживает все маршалируемые типы иностранных данных с боксингом из C FFI:
Bool Char -
Int / Word
-
Int8 / Int16 / Int32 / Int64
-
Word8 / Word16 / Word32 / Word64
-
Ptr / FunPtr / StablePtr
-
Float / Double
Перечисленные выше типы и их newtypes можно использовать в качестве типов аргументов и результатов в JSFFI. Следует учитывать несколько особенностей:
-
Bool маршалируется в 0 / 1, а не в false / true в JavaScript. Это связано с особенностями реализации JSFFI, которая построена поверх C FFI и имеет некоторые общие с ним характеристики. В большинстве случаев проблем не возникнет, поскольку при использовании в качестве логического значения происходит неявное преобразование в boolean. Также можно передавать JavaScript boolean в Haskell, поскольку сначала он будет неявно преобразован в число. - Аналогично,
Char маршалируется в 32-битное целое число, представляющее его кодовую точку Unicode. Не передавайте одиночный символ JavaScript string как Char, поскольку неявное преобразование в число даст NaN! Если вам непременно нужно использовать Char в качестве типа аргумента или результата JSFFI, вам придётся самостоятельно обрабатывать Chars как кодовые точки. Скорее всего, вам потребуется преобразовывать только между Haskell String или Text и JavaScript strings; для этого уже существуют функции преобразования. - 64-битные целочисленные типы маршалируются в JavaScript
bigints. В JavaScript смешивание bigint и обычных чисел в арифметических операциях приводит к ошибкам типов, поэтому учитывайте это. Int / Word имеют разрядность 32 бита, поскольку бэкенд GHC wasm основан на wasm32 . - JSFFI не поддерживает небоксированные типы иностранных данных, такие как
Int#, ByteArray# и т. д., даже если включён UnliftedFFITypes.
Помимо перечисленных выше типов, JSFFI поддерживает тип JSVal и его newtypes в качестве типов аргументов и результатов. JSVal определён в GHC.Wasm.Prim в ghc-experimental и представляет собой непрозрачную ссылку на значение JavaScript.
JSVals — это полноценные значения Haskell в куче Haskell. Их можно получить в качестве результатов foreign import или аргументов foreign export, хранить в структурах данных Haskell и передавать между Haskell и JavaScript. Сборку мусора для них выполняет GHC RTS:
- Несколько
JSVals могут указывать на одно и то же значение JavaScript. Пока в куче Haskell остаётся хотя бы один живой JSVal, это значение JavaScript будет оставаться живым в куче JavaScript. - Если больше не осталось ни одного живого
JSVal, указывающего на значение JavaScript, то после сборки мусора в Haskell среда выполнения перестанет хранить на него ссылки, что в конечном итоге позволит среде выполнения JavaScript также собрать его мусор.
Помимо сборки мусора, GHC.Wasm.Prim также экспортирует freeJSVal :: JSVal -> IO (), позволяющий пользователю немедленно удалить ссылку на JavaScript из среды выполнения. Рекомендуется использовать freeJSVal, когда срок жизни JSVal вам известен, особенно для временных JSVals. Это поможет сократить потребление памяти во время выполнения.
Обратите внимание, что freeJSVal идемпотентен, и его безопасно вызывать более одного раза. После его вызова любая последующая попытка использовать этот JSVal, передав его стороне JavaScript, приведёт к аварийному завершению среды выполнения.
15.6.2. Foreign imports
В объявление foreign import можно встроить фрагмент кода JavaScript, который будет выполняться при вызове функции foreign import:
import GHC.Wasm.Prim
foreign import javascript unsafe "console.log($1)"
js_print :: JSString -> IO ()
foreign import javascript unsafe "typeof $1 === 'object'"
js_is_obj :: JSVal -> Bool
foreign import javascript unsafe "let acc = 1; for (let i = 1; i <= $1; ++i) acc *= i; return acc;"
js_fac :: Word -> Word
Фрагмент кода импорта JSFFI может быть как одним выражением JavaScript, так и последовательностью инструкций JavaScript в теле функции; во втором случае для возврата результата импорта можно использовать return. Фрагмент кода импорта имеет доступ к:
- Значениям аргументов импорта, связанным с аргументами
$1, $2 и т. д. - Связке
__export, содержащей все экспорты wasm-модуля. Например, можно использовать __exports.memory, чтобы получить доступ к объекту WebAssembly.Memory и копировать с его помощью двоичные данные между сторонами Haskell и JavaScript. Экспорт memory существует по умолчанию. - Всем Web API, доступным в глобальной области видимости JavaScript.
Существует два вида импортов JSFFI: синхронные и асинхронные. unsafe обозначает синхронные импорты, для которых действуют следующие ограничения:
- Вызывающий поток, а также всё время выполнения блокируются в ожидании результата импорта.
- Если код JavaScript выбрасывает исключение, среда выполнения аварийно завершается с той же ошибкой. Здесь исключение JavaScript нельзя обработать как исключение Haskell, поэтому при необходимости нужно явно использовать JavaScript
catch. - В отличие от
unsafe импортов C, повторный вход поддерживается: импортированный код JavaScript может снова вызвать Haskell, если соответствующая функция Haskell экспортирована как синхронная.
Если импорт JSFFI помечен как safe / interruptible или в нём отсутствует аннотация безопасности, он считается асинхронным. Асинхронные импорты JSFFI объединяют модель конкурентности Haskell и цикл событий JavaScript, позволяя коду Haskell работать с асинхронными вычислениями JavaScript, не блокируя всё время выполнения.
import Control.Exception
foreign import javascript safe "new Promise(res => setTimeout(res, $1))"
js_sleep :: Int -> IO ()
sleep :: Int -> IO ()
sleep t = evaluate =<< js_sleep t
foreign import javascript safe "const r = await fetch($1); return r.text();"
js_fetch :: JSString -> IO JSString
Код асинхронного импорта оборачивается в асинхронные функции JavaScript, поэтому также поддерживается await. Асинхронные функции JavaScript всегда возвращают Promises; кроме того, можно явно создать и вернуть Promise, который разрешается в конечный результат асинхронного вычисления.
При вызове асинхронного импорта JSFFI функция Haskell немедленно возвращает управление, как только асинхронная функция JavaScript вернёт Promise. Возвращаемое функцией Haskell значение — это thunk. Когда thunk будет вычислен позднее, среда выполнения приостановит вычисляющий поток и возобновит его после того, как Promise действительно разрешится или будет отклонён.
По сравнению с синхронными импортами JSFFI, асинхронные импорты JSFFI имеют следующие существенные преимущества и недостатки:
- Ожидание результата блокирует только один поток Haskell; остальные потоки могут продолжать работу, и сборка мусора по-прежнему может выполняться.
- Если
Promise отклоняется, код Haskell может перехватить ошибки JavaScript как JSExceptions. - Накладные расходы выше, чем у синхронных импортов JSFFI.
Инкапсуляция результата Promise в thunk позволяет организовать конкурентное выполнение с меньшими затратами: не нужно даже создавать потоки Haskell исключительно для ожидания возврата множества асинхронных вызовов. Как и в случае с ленивым вводом-выводом, удобство сопряжено с некоторыми оговорками: нужно не забыть вычислить результирующий thunk до закрытия базового ресурса. Даже если тип результата — (), это всё равно thunk, который необходимо явно вычислить, чтобы убедиться, что Promise действительно разрешился. Поэтому для таких случаев, как sleep, скорее всего, потребуется написать пару функций worker/wrapper.
Существует также особый вид импорта JSFFI, позволяющий преобразовать вызываемый JSVal в функцию Haskell:
type Logger = JSString -> IO ()
type JSFunction = JSVal
foreign import javascript unsafe "s => console.log(s)"
js_logger :: JSFunction
foreign import javascript unsafe "dynamic"
js_logger_to_hs :: JSFunction -> Logger
Подобно foreign import ccall "dynamic", который оборачивает указатель на функцию C в функцию Haskell, foreign import javascript "dynamic" оборачивает JSVal, представляющий функцию JavaScript, в функцию Haskell. Возвращённая функция Haskell сохраняет ссылку на этот JSVal, а аннотация unsafe / safe указывает, является ли эта функция JavaScript синхронной или асинхронной.
Разумеется, и без foreign import javascript "dynamic" можно легко реализовать подобную функциональность:
foreign import javascript unsafe "$1($2)"
js_logger_to_hs :: JSFunction -> JSString -> IO ()
Именно так это и реализовано внутри. Функция обрабатывается как импорт JSFFI с автоматически сгенерированным фрагментом кода, который вызывает первый аргумент, передавая ему остальные аргументы.
15.6.3. Foreign exports
С помощью foreign export javascript можно экспортировать привязку верхнего уровня Haskell как экспорт wasm-модуля, который можно вызывать из JavaScript:
foreign export javascript "my_fib"
fib :: Word -> Word
Если задано fib :: Word -> Word, объявление выше экспортирует fib как my_fib. Это функция-экспорт wasm-модуля без какой-либо обёртки JavaScript; если экземпляр wasm правильно инициализирован, можно вызвать await instance.exports.my_fib(10), чтобы вызвать экспортированную функцию Haskell и получить результат.
Экспорты JSFFI по умолчанию асинхронны. Вызов асинхронного экспорта возвращает в JavaScript Promise, который необходимо обработать с помощью await, чтобы получить фактический результат. Если функция Haskell выбрасывает исключение, Promise отклоняется с ошибкой WebAssembly.RuntimeError, а поле message содержит строку JavaScript с описанием исключения Haskell.
Также поддерживаются синхронные экспорты: для этого вместо "my_fib" используется "my_fib sync". Если добавить sync к имени экспортируемой функции, функция JavaScript будет возвращать результат синхронно. Пока что синхронные экспорты не поддерживают передачу неперехваченных исключений Haskell в исключение JavaScript в месте вызова.
Выше описан статический вариант экспортов JSFFI. Также можно экспортировать динамически созданное замыкание функции Haskell как функцию JavaScript и получить его JSVal:
type BinOp a = a -> a -> a
foreign import javascript "wrapper"
js_func_from_hs :: BinOp Int -> IO JSVal
Это также похоже на foreign import ccall "wrapper", который оборачивает замыкание функции Haskell в указатель на функцию C. Обратите внимание, что здесь аннотация unsafe / safe игнорируется, поскольку JSVal, представляющий экспортированную функцию, всегда возвращается синхронно. Аналогично, вместо "wrapper" можно использовать "wrapper sync", чтобы указать, что возвращаемая функция JavaScript синхронная, а не асинхронная.
Обратные вызовы JSVal, созданные динамическими экспортами JSFFI, можно передать остальной части мира JavaScript, чтобы вызвать их позднее. Но постойте, разве мы не говорили ранее, что сборщик мусора собирает JSVals? Разве здесь не грозит ошибка при использовании после освобождения памяти, если JSVal будет собран в Haskell, а обратный вызов JavaScript вызовут позже?
Обычные JSVals, созданные результатами импортов JSFFI или аргументами экспортов JSFFI, управляют только одним видом ресурса: значением JavaScript, на которое они ссылаются. А вот JSVals, созданные динамическими экспортами JSFFI, управляют двумя видами ресурсов: обратным вызовом JavaScript, на который они ссылаются, и стабильным указателем, удерживающим замыкание функции Haskell. Если этот JSVal будет собран сборщиком мусора, среда выполнения Haskell перестанет удерживать обратный вызов JavaScript, однако сторона JavaScript всё ещё может хранить этот обратный вызов и намереваться вызвать его позднее, поэтому замыкание функции Haskell по умолчанию продолжит удерживаться.
Тем не менее среда выполнения может постепенно освобождать эти удерживаемые ресурсы, используя FinalizationRegistry для вызова финализаторов, которые освободят базовые стабильные указатели после того, как обратные вызовы JavaScript будут повторно использованы.
Остаётся рассмотреть один крайний случай — циклические ссылки между двумя кучами: если обратный вызов JavaScript удерживается только посредством JSVal, а этот JSVal удерживается только замыканием функции Haskell, которое экспортируется, возникает циклическая ссылка, которую невозможно автоматически освободить. На сегодня это фундаментальное ограничение бэкенда GHC wasm: куча Haskell находится в линейной памяти, отдельно от кучи хоста JavaScript, а координация между двумя кучами всегда представляет собой нетривиальную задачу. Тем не менее цикл можно разорвать с помощью freeJSVal. Если применить freeJSVal к JSVal, представляющему обратный вызов JavaScript, созданный динамическим экспортом JSFFI, оба вида ресурсов будут освобождены одновременно: и обратный вызов JavaScript, и замыкание функции Haskell.
15.6.4. Определение использования JSFFI
Если вы пишете библиотеку Haskell, вам может потребоваться определить, использует ли итоговый слинкованный модуль логику JSFFI или по-прежнему является автономным модулем wasm32-wasi. Одна из очевидных причин — реализации wasi в средах JavaScript часто неполны и не поддерживают некоторые функции (например, системный вызов poll_oneoff), поэтому имеет смысл выбирать код в зависимости от ситуации.
GHC.Wasm.Prim экспортирует isJSFFIUsed :: Bool, которое можно использовать для этой цели. Если в итоговый wasm-модуль слинкован хотя бы один импорт или экспорт JSFFI либо что-либо, связанное с JSVal, он будет True. Он будет False тогда и только тогда, когда пользовательский код вообще не имеет транзитивных зависимостей от чего-либо, связанного с JSFFI; в этом случае слинкованный wasm-модуль будет автономным модулем wasm32-wasi. Если что-либо компилировалось с бэкендом GHC wasm до добавления функции JSFFI, но isJSFFIUsed всё ещё True, значит, это определённо ошибка.
15.6.5. Взаимодействие с асинхронными исключениями
Поток, заблокированный в ожидании возврата вызова асинхронного импорта JSFFI, может быть прерван асинхронным исключением Haskell с учётом следующих особенностей:
- Асинхронное исключение не отменяет
Promise само по себе. В общем случае JavaScript Promises и так нельзя отменить. Существуют сторонние библиотеки Promise, предоставляющие интерфейс «отмены», который лишь гарантирует, что зарегистрированные в этом Promise продолжения .then() больше не будут вызваны. Пока что мы не используем их ради простоты реализации. - Когда поток ожидает завершения
Promise, блокируя асинхронные исключения, throwTo будет блокировать вызывающий поток до завершения Promise. Если целевой поток не блокирует асинхронные исключения, throwTo отменит его блокировку на Promise и возобновит выполнение.
15.6.6. Взаимодействие с C FFI
Пользовательский код может одновременно использовать JSFFI и C FFI, а также сторонний код на C/C++, если он работает с wasm32-wasi. Однако при взаимодействии JSFFI и C FFI следует учитывать одно важное ограничение:
Поток Haskell не может принудительно вычислить thunk асинхронного импорта JSFFI, если он представляет функцию Haskell, экспортированную через C FFI. Такая попытка вызовет WouldBlockException.
Например, предположим, что мы используем привязки Haskell для некоторой библиотеки C, и некоторые функции C ожидают, что вызывающий передаст указатель на функцию C в качестве аргумента обратного вызова. Да, мы можем использовать foreign import ccall "wrapper", чтобы обернуть замыкание функции Haskell и передать его этой функции C. Обёрнутая функция Haskell может даже вызывать синхронные импорты JSFFI, но она не может вызывать асинхронный импорт JSFFI и блокироваться в ожидании результата.
Обратное направление работает без проблем. Независимо от того, является ли импорт C unsafe или safe, его можно вызвать в потоке Haskell, представляющем функцию Haskell, экспортированную в JavaScript. Имейте в виду, что сейчас мы используем однопоточную среду выполнения, поэтому, кроме поддержки повторного входа, safe вызовы C не дают дополнительных преимуществ по сравнению с unsafe.
Как упоминалось ранее, JSFFI внутри построен поверх C FFI, и они используют одно и то же пространство имён символов C. В большинстве случаев связанные с JSFFI символы генерируются автоматически, поэтому не могут конфликтовать, но для каждого foreign export javascript "my_func" будет существовать внешне видимый символ C my_func, поэтому нужно следить за тем, чтобы не дублировать символы со стороны C.
15.6.7. API JavaScript
При компоновке wasm-модуля, использующего JSFFI, необходимо передать GHC правильные аргументы компоновки; их нужно настраивать отдельно для каждого проекта:
ghc -no-hs-main -optl-mexec-model=reactor -optl-Wl,--export=my_func
Почему?
Рассмотрим случай ghc hello.hs, когда hello.hs — всего лишь старая добрая main = putStrLn "hello world". По умолчанию ghc экспортирует main как функцию C, компилирует и компонуёт небольшой файл-заглушку C, содержащий собственно main, которая инициализирует среду выполнения и вызывает main Haskell, а clang компонует всё как командный модуль wasm32-wasi.
Концептуально командный модуль wasm32-wasi похож на исполняемый файл: у него одна точка входа, он предназначен для однократного вызова с последующим завершением. Однако с помощью некоторых аргументов компоновки можно указать clang, что целевым объектом должен быть реакторный модуль wasm32-wasi. Реакторный модуль похож на разделяемую библиотеку: у него есть внутренние функции и определённые пользователем точки входа, которые после инициализации можно вызывать столько раз, сколько потребуется.
Учитывая природу JSFFI, если проект использует JSFFI, он наверняка предназначен для создания реакторного модуля wasm32-wasi. При этом разумной точки входа по умолчанию нет — даже main. Необходимо явно передать имена требуемых экспортов в аргументах компоновки; в противном случае эти экспорты будут отсутствовать в результирующем wasm-модуле из-за удаления неиспользуемого кода компоновщиком.
Предположим, wasm-модуль уже скомпонован и использует JSFFI. Такой wasm-модуль будет содержать пользовательские секции ghc_wasm_jsffi, в полезной нагрузке которых находятся, например, фрагменты кода импорта JSFFI и арности функций. Следующий шаг — запустить небольшой скрипт посткомпоновки, который разберёт wasm-модуль и создаст модуль JavaScript. Скрипт посткомпоновки написан на nodejs, однако созданный модуль JavaScript не содержит ничего специфичного для nodejs и работает также в браузерах.
$ $(wasm32-wasi-ghc --print-libdir)/post-link.mjs -i hello.wasm -o hello.js
Сгенерированный модуль JavaScript содержит экспорт по умолчанию — функцию. Эта функция принимает аргумент __exports и генерирует импорты wasm ghc_wasm_jsffi. Здесь возникает взаимная зависимость: получить доступ к экспортам wasm-модуля можно только после создания его экземпляра, однако импорты ghc_wasm_jsffi, необходимые для создания экземпляра, должны иметь доступ к экспортам! JavaScript — не ленивый язык, но менее элегантно решить эту задачу взаимной зависимости можно с помощью мутации:
let __exports = {};
const { instance } = await WebAssembly.instantiateStreaming(
fetch(wasm_url),
{
ghc_wasm_jsffi: (await import(js_url)).default(__exports),
wasi_snapshot_preview1: ...
}
);
Object.assign(__exports, wasm_instance.exports);
Таким образом, импорты ghc_wasm_jsffi получат доступ ко всем экспортам экземпляра wasm.
После создания экземпляра wasm необходимо выполнить инициализацию:
wasi.initialize(wasm_instance);
ABI реакторного модуля wasm32-wasi определяет экспортированную функцию _initialize, автоматически генерируемую при компоновке. Её необходимо вызвать ровно один раз до вызова любых других экспортов wasm. Правильный способ её вызова зависит от поставщика реализации wasi в JavaScript.
Наконец, в JavaScript можно использовать await __exports.my_func(), чтобы вызвать экспортированную функцию my_func и получить её результат, передать аргументы, обработать ошибки и т. д.
Для каждого асинхронного экспорта возвращаемое значение Promise содержит обратный вызов promise.throwTo(). Значение, переданное в promise.throwTo(), будет обёрнуто в JSException и передано как асинхронное исключение в этом потоке. Это может пригодиться для прерывания вычислений Haskell из JavaScript. promise.throwTo() не блокирует вызывающий код JavaScript, как это делает Haskell throwTo. Это не обязательно приведёт к отклонению promise, поскольку поток Haskell может обработать асинхронное исключение; кроме того, этот вызов можно выполнять несколько раз. Он не оказывает никакого действия, если соответствующий поток Haskell уже завершил работу.