Запустить нативный Rust-код в Cloudflare Workers мешало не отсутствие компилятора, а конфликт двух инструментов сборки. Emscriptenнабор инструментов для компиляции C и C++ в WebAssembly, управляющий сборкой, загрузкой модуля и генерацией сопутствующего JavaScript и wasm-bindgenинструмент, генерирующий привязки между Rust и JavaScript для WebAssembly-модулей оба считали, что именно они управляют загрузкой модуля, взаимодействием с JavaScript и генерацией итоговых JS и Wasm. Разберём, как этот конфликт разрешили, что именно меняется внутри wasm-bindgen при включении режима Emscripten и какая механика стоит за работой Tokio, сокетов и файловой системы в среде Workers.
Зачем понадобился новый таргет
Проблема была сформулирована так: оба инструмента предполагают, что именно они отвечают за загрузку и взаимодействие с JavaScript и за генерацию итоговых JS и Wasm. Из-за этого пользователям пришлось бы выбрать один тулчейннабор инструментов для сборки, компиляции и упаковки кода и никогда не использовать оба, а добавление второго набора инструментов удвоило бы поверхность поддержки.
История началась с внутренней потребности Google: команда хотела использовать wasm-bindgen для взаимодействия со своим JavaScript, при этом линкер Emscripten (wasm-ld) должен был подключать нужные C++-зависимости. Внутри Google Emscripten применяется в C++-кодовых базах для генерации JavaScript вместе с остальными частями приложений. Emscripten умел подключать Rust-код как зависимость, но не мог обеспечить надёжную систему привязок между Rust и JavaScript, как это делает wasm-bindgen.
Решение сделало инструменты совместимыми: Emscripten продолжает управлять сборкой, загружать Wasm-модуль и предоставлять сопутствующий JS, а wasm-bindgen создаёт меньшую переносимую версию своих JavaScript-привязок в формате, который можно напрямую включить в библиотечную систему Emscripten. Совместимость включается конфигурацией -sWASM_BINDGEN — это флаг сборки emcc, который переключает инструменты в режим совместной работы, и работает в обе стороны:
- C++-код Emscripten, управляемый компилятором Emscripten, собирается против статического Rust-кода wasm-bindgen с полной поддержкой слоя привязок wasm-bindgen наряду со слоем привязок Emscripten.
- Rust-приложения с wasm-bindgen, управляемые компилятором Rust, собираются под таргетцелевую платформу, под которую компилируется код Emscripten с полной поддержкой слоя привязок Emscripten наряду со слоем привязок wasm-bindgen.
Для Workers это дало возможность запускать нативный Rust-код и даже приложения на Tokio, потому что Emscripten виртуализирует нативные платформенные возможности — таймеры, файловые операции и сокеты — поверх существующих Node.js API Workers.
Что даёт Emscripten-таргет по сравнению с wasm32-unknown-unknown
Многие библиотеки заработали без изменений, включая низкоуровневые системные, поскольку Emscripten уже поддерживает target_family = unix в Rust. Некоторым библиотекам, не знавшим об Emscripten, потребовались патчи — libc, socket2 и Mio, — но патчи в основном сводились к добавлению Emscripten в существующие платформенные гейты.
Дальше доступ к системным API устроен так:
- Сокеты и epoll. Опция сборки
-sNODERAWSOCKETS— это режим компиляции Emscripten, который включают при сборке, чтобы приложение получило поддержку epoll, TCP, UDP и Unix-сокетов поверхnode:netAPI. Под JSPIмеханизм, позволяющий приостанавливать и возобновлять стек WebAssembly при ожидании асинхронной операции вызовepoll_wait()просто приостанавливает стек до готовности, поэтому I/O-драйвер Tokio работает как на нативном коде. ДляLocalEventLoopготовность доставляется Wakerобъект, через который задача сообщает исполнителю, что её нужно опросить снова из JS-колбэка через предложенный APIemscripten_epoll_add_listener. - Файловая система.
std::fsработает через-sNODERAWFS: файловые вызовы перенаправляются вnode:fs. Библиотека worker-fs-mount позволяет смонтировать совместимую сnode:fsфайловую систему с бэкендом durable-object-fs, хранящим файлы как строки в SQLite-хранилище Durable Objectизолированный объект с собственным состоянием и хранилищем, к которому направляются запросы по имени. - Потоки. Настоящих OS-потоков нет: в Durable Object ровно один поток, поэтому потоки Pumpkin превращаются в кооперативные задачи на event loop, а Tokio интегрируется через JSPI или LocalEventLoop.
Как включается OutputMode::Emscripten
Режим Emscripten не выбирается через switch_mode. Он принудительно включается в generate_output, если во входном Wasm-модуле найден __wasm_bindgen_emscripten_marker:
if module
.customs
.remove_raw("__wasm_bindgen_emscripten_marker")
.is_some()
{
// Force the internal configuration to Emscripten mode.
self.mode = OutputMode::Emscripten;
}Отдельно есть сеттер force_enable_abort_handler, который просто сохраняет переданное булево значение в поле self.force_enable_abort_handler.
В generate_output при режиме Emscripten меняется два шага подготовки модуля. Во-первых, пропускается подготовка потоков: thread_count = None, потому что Emscripten сам управляет bootstrap многопоточности. Во-вторых, не удаляются неиспользуемые LLD-экспорты: шаг, который вычищает из модуля экспорты линкера, оставшиеся без потребителей, выполняется только при !self.mode.emscripten() — в Emscripten-режиме эти экспорты нужны среде выполнения.
В кодогенерации режим Emscripten активирует несколько отдельных веток:
- В
generate_wasm_loadingветкаOutputMode::Emscriptenвызываетgenerate_emscripten_wasm_loading, которая формируетaddToLibraryс$initBindgen, перечисляя зависимости черезadapter_depsиemscripten_global_deps. - Только в этом режиме
generate_import_identifierдобавляет префикс__wbg_к именам импортов. expose_text_encoderиexpose_text_decoderрегистрируютcachedTextEncoder/cachedTextDecoderвemscripten_global_depsиadapter_depsи не генерируют собственные определенияTextEncoder/TextDecoder.
Sidecar-файл и библиотека Emscripten
Sidecar-файл — это содержимое поля emscripten_extern_pre_js структуры Generated, которое emcc загружает через --extern-pre-js. Оно содержит ESM-инструкции import, обязанные находиться на верхнем уровне модуля, и пусто для всех остальных режимов.
В режиме Emscripten пользовательские импорты эмитируются как ESM-импорты в этом sidecar-файле, попадая на верхний уровень модуля рядом с рантаймом emcc. Импортируемое имя берётся дословно из JS-модуля пользователя, поэтому локальное имя получает префикс __wbg_, чтобы избежать коллизий с произвольными именами (Module, HEAP8, wasm, ...), а сам импорт алиасируется как import { Foo as __wbg_Foo }.
Функция emscripten_library накапливает строки в self.emscripten_library: обрезает пробелы, игнорирует пустую строку, добавляет разделитель \n\n между непустыми фрагментами и завершает каждый переводом строки. Через неё же hoist_emscripten_export формирует addToLibrary({...}) с $<identifier>__deps, $<identifier>__postset и, для публичных экспортов, $<identifier>__export: true и $<identifier>__force: true, чтобы emscripten включил символ и выпустил его как именованный экспорт.
Как работает hoist_emscripten_export
Функция выносит «чистый» экспорт в отдельный top-level символ addToLibrary, чтобы emscripten мог выпустить его как именованный ESM-экспорт. Список зависимостей начинается с $initBindgen, и к каждой extra_deps добавляется префикс $:
let mut deps = vec!["$initBindgen".to_string()];
deps.extend(extra_deps.iter().map(|d| format!("${d}")));Если public=true, добавляется присваивание Module[identifier] = identifier и атрибуты __export/__force, чтобы emscripten включил символ и выпустил его как именованный экспорт. Листья пространств имён выносятся приватно (public = false) и остаются достижимыми через __deps корня.
Для class-выражений значение передаётся jsifier как строка с префиксом =, потому что живое значение класса было бы выпущено как объявление class, а export class ломает обработку ExportNamedDeclaration в acorn-optimizer emcc.
Функция hoist_emscripten_export_with_tree вызывает hoist_emscripten_export с public = !is_namespaced && !private, а для namespaced-экспортов регистрирует запись в дереве экспортов через define_export, чтобы корень пространства имён собрал ns.<name> = <identifier> и форма .d.ts разрешала typeof <identifier>. В write_namespace для emscripten-режима лист является собственным вынесенным символом, поэтому он записывается как зависимость корня; в других режимах объявление встраивается.
Чем generate_emscripten_imports отличается от остальных режимов
В Emscripten-режиме generate_emscripten_imports не создаёт JS-модуль с экспортами, а формирует фрагменты библиотеки Emscripten: переписывает модули-заглушки на "env", добавляет emscripten_library и генерирует вызовы addToLibrary с телами импортов, зависимостями __deps и постсетами __postset.
Для сравнения, другие режимы устроены иначе:
generate_esm_cjs_importsсоздаёт функцию__wbg_get_imports, которая возвращает объект с ключом"./{module_name}_bg.js".generate_bundler_importsпишет настоящиеexport function/export const/export { ... }в отдельный модуль.generate_web_loadingгенерирует самостоятельный загрузчик с__wbg_load,initSyncи__wbg_init.generate_emscripten_wasm_loadingлишь добавляет в библиотеку$initBindgenс__deps,__postsetи__force, чтобы Emscripten сам вызвал инициализацию.
Поэтому в Emscripten-режиме нельзя просто реэкспортировать биндинг: код биндинга встраивается в библиотеку Emscripten через addToLibrary, а не оформляется как ES-модуль с export.
Имена экспортов и hoisted-символы
Функция emscripten_mangle формирует JS-идентификатор, под которым код emcc (assignWasmExports) привязывает экспорт wasm. Это зеркало shared.asmjs_mangle из emscripten: внутренние символы сохраняют своё имя, всё остальное получает префикс _. Без изменений остаются memory, __indirect_function_table, __asyncify_data, __asyncify_state и всё, начинающееся с dynCall_.
Связь с hoisted-символамисимволами, которые выносятся на верхний уровень библиотеки Emscripten отдельной записью addToLibrary, а не объявляются на месте проявляется в write_namespace: в Emscripten-режиме для каждого листа-определения его идентификатор добавляется в leaf_ids, потому что лист является самостоятельным hoisted-символом и учитывается как зависимость корня. В остальных режимах вместо этого объявление встраивается на месте через export_def. Сам hoist выполняет hoist_emscripten_export, который оборачивает значение в addToLibrary({...}) с $<identifier>__deps, а для публичных символов добавляет Module['<identifier>'] = <identifier>; и атрибуты __export/__force.
Инициализация: почему $initBindgen инлайнит тела
generate_emscripten_wasm_loading формирует библиотечный символ $initBindgen, тело которого содержит вызовы _initialize (если модуль экспортирует этот символ) и __wbindgen_start (если needs_manual_start), а также переданные classes_and_exports.
Поскольку $initBindgen инлайнит тела всех Export/Adapter, его __deps должен перечислять все библиотечные символы, на которые ссылается любой адаптер: tree-shaker emcc не отслеживает ссылки по «голым» именам внутри тел функций, поэтому зависимости перечисляются явно. init_deps собирается как объединение addOnInit, adapter_deps и emscripten_global_deps — так подтягиваются и символы, объявленные только глобально (например, $heap).
Атрибут $initBindgen__force: true удерживает $initBindgen и через его __deps все глобальные хелперы в сборке, хотя скомпилированный код на него не ссылается. Порядок инициализации задаётся постсетом:
$initBindgen__postset: 'addOnInit(initBindgen);',То есть initBindgen вызывается через addOnInit после определения символа.
Память и heap в Emscripten-режиме
В режиме Emscripten memview не создаёт собственную функцию доступа к памяти, а выбирает имя глобальной переменной Emscripten по типу представления: Int8Array → HEAP8, Uint8Array/Uint8ClampedArray → HEAPU8, Int16Array → HEAP16, Uint16Array → HEAPU16, Int32Array → HEAP32, Uint32Array → HEAPU32, Float32Array → HEAPF32, Float64Array → HEAPF64, DataView → HEAP_DATA_VIEW, иначе HEAPU8.
Для HEAP_DATA_VIEW дополнительно регистрируется зависимость, потому что Emscripten объявляет её только при SUPPORT_BIG_ENDIAN, а в обычном little-endian случае её нужно объявить самим и пересоздавать при росте памяти. Метод access у MemView для Emscripten возвращает просто имя глобальной переменной, а иначе — вызов вида {self}(), то есть getUint8Memory0() и подобные.
В не-Emscripten режиме memview_memory даёт имени вид get{kind}Memory, а memview затем генерирует функцию-кэш, которая при значении null или при обнаружении роста/отсоединения буфера создаёт новый {kind}({mem}.buffer). Проверка роста различается по типу памяти:
- для shared-памяти сравниваются
buffer; - для
DataViewпроверяетсяdetachedлибо сравнениеbuffer; - иначе проверяется
byteLength === 0.
memview_table получает индекс таблицы через table_indices и возвращает MemView с заданным именем.
Глобальные heap, heap_next и stack_pointer
expose_global_heap создаёт массив heap, изначально заполненный undefined до INITIAL_HEAP_OFFSET, а затем добавляет значения INITIAL_HEAP_VALUES; в режиме Emscripten вместо этого регистрируется зависимость heap. expose_global_heap_next сначала вызывает expose_global_heap, затем создаёт heap_next, равный длине heap, то есть индексу первого свободного слота; в Emscripten регистрируется зависимость heap_next. expose_global_stack_pointer создаёт stack_pointer, изначально равный INITIAL_HEAP_OFFSET; в Emscripten регистрируется зависимость stack_pointer.
expose_borrowed_objects использует stack_pointer как указатель на место записи объектов стека. addBorrowedObject уменьшает stack_pointer перед записью в heap, а если stack_pointer == 1, выбрасывает 'out of js stack':
function addBorrowedObject(obj) {
if (stack_pointer == 1) throw new Error('out of js stack');
heap[--stack_pointer] = obj;
return stack_pointer;
}expose_add_heap_object использует heap_next как начало связного списка свободных слотов: если heap_next === heap.length, массив расширяется через heap.push(heap.length + 1), затем idx = heap_next, heap_next = heap[idx], и объект записывается в heap[idx].
Время жизни объектов
expose_take_object создаёт takeObject, которая сначала читает объект через getObject(idx), затем освобождает слот через dropObject(idx) и возвращает объект — то есть забирает владение из heap. Освобождение слота выполняет dropObject из expose_drop_ref: при idx < INITIAL_HEAP_OFFSET + INITIAL_HEAP_VALUES.len() функция сразу возвращает управление, иначе heap[idx] = heap_next; heap_next = idx, то есть слот возвращается в связный список свободных.
Отдельный механизм нужен потому, что в Emscripten-режиме expose_global_heap и expose_global_heap_next не создают собственные heap и heap_next, а лишь регистрируют их как внешние зависимости. Аналогично expose_handle_error в этом режиме добавляет "addHeapObject" в adapter_deps — значит, heap и heap_next предоставляются средой Emscripten, а не генерируются самим биндингом.
Маршалинг массивов
Функции expose_pass_array8_to_wasm и родственные (array16/32/64, f32/f64) сначала получают представление памяти нужного типа, а затем вызывают pass_array_to_wasm с именем, этим представлением и размером элемента (1, 2, 4 или 8 байт). В pass_array_to_wasm генерируется JS-функция, которая выделяет память через malloc(arg.length * size, size), копирует массив в память через view_access.set(arg, ptr / size) и сохраняет длину в WASM_VECTOR_LEN.
Режим Emscripten определяется как matches!(self.config.mode, OutputMode::Emscripten), и от него зависит способ доступа к памяти: в Emscripten это глобальные идентификаторы (HEAP8, HEAPF64, HEAP_DATA_VIEW и т.п.), обновляемые updateMemoryViews, а в остальных режимах — функции-кеши (getUint8Memory0()). Для HEAP_DATA_VIEW в Emscripten добавляется зависимость emscripten_global_deps.
Аналогично expose_wasm_vector_len в Emscripten регистрирует WASM_VECTOR_LEN как глобальную зависимость и intrinsic с пустым телом, а в остальных режимах генерирует let WASM_VECTOR_LEN = 0;.
Строки и TextEncoder/TextDecoder
expose_get_string_from_wasm создаёт getStringFromWasm(ptr, len), которая просто вызывает decodeText(ptr, len) для декодирования строки из памяти WASM. expose_get_cached_string_from_wasm создаёт getCachedStringFromWasm(ptr, len), которая поддерживает и &str, и Option<&str>: если ptr === 0, то len — это указатель на кэшированный JsValue, и возвращается getObject(len) (или get_from_externref_table); иначе вызывается getStringFromWasm(ptr, len). Для случая None, когда ptr и len оба равны 0, используется гарантия, что getObject(0) возвращает undefined.
Проблему отсутствия TextEncoder/TextDecoder решает write_text_processor:
- для shared-памяти (audio worklets) генерируется проверка
typeof, и если конструкторundefined, cached-переменная остаётсяundefined, а инициализация выполняется только при наличии cached-объекта; - для не-shared памяти создаётся безусловно
new TextEncoder()/new TextDecoder(...).
В режиме Emscripten write_text_processor ничего не добавляет к библиотеке, а зависимости cachedTextEncoder/cachedTextDecoder добавляются в emscripten_global_deps и adapter_deps.
Owned и borrowed срезы JS-значений
Обе функции читают массив 32-битных индексов из памяти DataView и превращают каждый индекс в JS-значение, но различаются тем, освобождают ли они слот после чтения.
expose_get_array_js_value_from_wasm — путь для owned-срезов: при наличии externrefТип ссылки WebAssembly, используемый для хранения внешних (JS) значений в таблице.-таблицы и drop-функции она берёт значение через {table}.get(...) и затем вызывает {drop}(ptr, len), а без externref использует takeObject, который сам освобождает слот.
expose_get_array_js_value_view_from_wasm — путь для borrowed-срезов: она не вызывает drop и использует getObject вместо takeObject, то есть только читает значение, не освобождая слот.
Отдельный путь через addToExternrefTable нужен на стороне записи (в expose_pass_array_jsvalue_to_wasm), потому что при externref-таблице каждый JS-объект надо сначала положить в таблицу и получить индекс через addToExternrefTable, тогда как без externref используется addHeapObject.
Дескрипторы и цепочка прототипов
expose_get_vector_from_wasm не содержит никакой обработки переноса дескрипторов по цепочке прототипов: она лишь выбирает конкретную функцию экспонирования массива в зависимости от типа VectorKind, например для строк вызывает expose_get_string_from_wasm, а для I8 — expose_get_array_i8_from_wasm.
expose_get_inherited_descriptor создаёт intrinsic get_inherited_descriptor, который определяет JS-функцию GetOwnOrInheritedPropertyDescriptor(obj, id): она ищет собственный дескриптор свойства у объекта, а если его нет — поднимается по цепочке прототипов, пока дескриптор не найдётся; если цепочка исчерпана, поиск заканчивается ничем. Это сделано потому, что некоторые браузеры переносят дескрипторы вверх по цепочке свойств, что может сломать сгенерированный wasm-bindgen код, который ищет точные функции-дескрипторы, а не полагается на цепочку прототипов.
expose_is_like_none создаёт intrinsic is_like_none с JS-функцией isLikeNone(x), которая просто возвращает x === undefined || x === null, и никак не связана с обходом цепочки прототипов.
Обработка ошибок и JSTag
expose_handle_error создаёт интринсик handleError, который оборачивает вызов импортированной JS-функции в try/catch: при исключении объект e помещается в externref-таблицу через add (или addHeapObject) и его индекс сохраняется вызовом store (это __wbindgen_exn_store), после чего исключение не пробрасывается дальше.
expose_wrap_error создаёт wrapError, который при перехвате исключения проверяет, является ли оно уже WebAssembly.Exception, и если нет — оборачивает его:
if (e instanceof WebAssembly.Exception) throw e;
throw new WebAssembly.Exception(__wbindgen_jstag_polyfill, [e], { traceStack: true });Уже обёрнутые исключения пробрасываются без изменений, чтобы сохранить различение recoverable/abort в __wbg_handle_catch.
expose_log_error создаёт logError, который при исключении формирует строку (message+stack для Error, иначе toString), пишет её через console.error с пометкой, что импортированная JS-функция не помечена как catch, и затем повторно бросает исходное e.
generate_jstag_import находит импорт тега с именем __wbindgen_jstag и в не-Emscripten режиме без legacy_exception_handling связывает его с JSTagвстроенный тег исключений WebAssembly, представляющий JS-значение, брошенное из JavaScript, а при legacy_exception_handling создаёт константу __wbindgen_jstag_polyfill = new WebAssembly.Tag({ parameters: ['externref'] }) и использует её как GlobalRef. generate_wrapped_jstag_import находит импорт тега по wrapped_js_tag и связывает его с константой __wbindgen_wrapped_jstag = new WebAssembly.Tag({ parameters: ['externref'] }); в Emscripten-режиме добавляет в библиотеку __wbg_handle_catch, который для исключения, являющегося WebAssembly.Exception и проходящего e.is(__wbindgen_wrapped_jstag), бросает e.getArg(__wbindgen_wrapped_jstag, 0), иначе бросает e. generate_rethrow_critical_import находит импорт функции rethrow_critical и подставляет реализацию (cause) => { throw new Error("Critical error", { cause }); }, а в Emscripten-режиме — аналогичную функцию в addToLibrary.
Обёртки catch_handler.rs
generate_catch_wrapper выбирает генератор обёртки по версии обработки исключений eh_version. Вариант Modernсовременный механизм обработки исключений WebAssembly на основе инструкции try_table и ModernButWithPanicAbortсовременный механизм обработки исключений, дополненный аварийным прерыванием при панике используют современную обёртку, Legacyустаревший механизм обработки исключений WebAssembly на основе инструкций try и catch — устаревшую, а Noneобработка исключений отключена, и построение обёртки невозможно приводит к unreachable.
Modern-обёртка строится на try_table с двумя блоками: catch $jstag направляет externref в catch_block_id, а CatchAllRef направляет exnref в catch_all_block_id; в try-теле сначала кладутся параметры, затем call $original, потом emit_termination_guard и return.
Legacy-обёртка использует Try с LegacyCatch::Catch для js_tag и LegacyCatch::CatchAll, где catch-обработчик получает externref и возвращает результаты, а catch_all-обработчик выполняет Rethrow { relative_depth: 0 }.
emit_termination_guard загружает i32 по адресу ctx.terminated_addr через i32_const + load и, если значение ненулевое, выполняет unreachable, чтобы завершение инстанса не было проглочено внешним catch-обработчиком. Адрес terminated_addr берётся из экспортированного глобала __instance_terminated функцией get_terminated_addr, которая требует, чтобы это был локальный i32-константный глобал, иначе возвращает ошибку. В состоянии terminated guard срабатывает как trap (unreachable).
emit_catch_handler различает виды обёрток: для CatchWrapper сохраняет externref в таблицу, вызывает exn_store и кладёт значения по умолчанию; для NonAbortingWrapper делает Throw с wrapped_js_tag; для Aborting вызывает rethrow_critical и затем unreachable.
Реинициализация и hard abort
maybe_generate_call_guard не создаёт guard, если нет hard abort и реинициализации. Иначе создаётся JS-функция __wbg_call_guard, которая при включённом режиме реинициализации (generate_reinit) вызывает expose_reinit_scheduled и добавляет проверку: если флаг __wbg_reinit_scheduled истинен, выполняется __wbg_reset_state() и происходит возврат. expose_reinit_scheduled объявляет этот флаг как локальную переменную со значением false.
generate_reinit_wrappers формирует тело __wbg_reset_state: увеличивает __wbg_instance_id, обнуляет кэши памяти, сбрасывает счётчики декодирования и векторной длины, пересоздаёт heap, а затем создаёт новый WebAssembly.Instance и вызывает wasm.__wbindgen_start().
Hard abort включается, когда используется js exception tagging (wrapped_js_tag) или принудительно через force_enable_abort_handler: тогда генерируются __wbg_call_abort_hook и __wbg_handle_catch, а в __wbg_call_guard добавляется проверка флага завершения и выброс 'Module terminated'.
Почему Tokio не может просто запустить свой event loop
Когда у хоста уже есть собственный event loop, цикл Tokio не может работать внутри него без блокировки хоста. Обычный runtime Tokio в цикле опрашивает задачи, пока ничего не готово, а затем ждёт, паркуя поток в I/O-драйвере до готовности сокета, истечения таймера или пробуждения другим потоком. Именно парковка делает его рантаймом, а не библиотекой.
Обходной путь — разделить цикл Tokio на две части, интегрируемые с хостом: явную операцию drive(), которая выполняет одну партию готовых задач и возвращается, и замену парковки на wake, при которой runtime сообщает хосту о наличии работы, а хост вызывает drive(), когда готов.
LocalEventLoop — это LocalRuntime, у которого ожидание заменено на пробуждение; он строится со стандартным std::task::Waker, принадлежащим хосту, и этот waker используется runtime'ом, чтобы сигнализировать, что сам runtime скоро нужно продвинуть. Создаётся он так:
let el = Builder::new_current_thread()
.enable_all()
.build_local_event_loop(Default::default(), host_waker)?;spawn_local немедленно возвращает управление, и хост-цикл отвечает за доведение задачи до конца через вызовы el.drive(). Когда данных в потоке нет, runtime просто возвращается, отдавая управление хосту, а когда сокет становится читаемым, Tokio использует host_waker, чтобы сообщить, что runtime нужно продвинуть.
Проблема подменённого стека
Всё, что разбудило бы поток нативного рантайма — spawn, задача, разбуженная из другого потока, ставший читаемым сокет, истечение таймера — будит вместо этого хост.
Проблема в том, что Rust не знает, что его стек подменяют. Контекст рантайма Tokio отслеживается через thread local, а переключение стека JSPI — не переключение потока, поэтому приостановленный и новый стек делят один и тот же thread-local контекст рантайма. В результате рантайм паникует, так как уже entered.
Для поддержки полностью реентерабельного JSPI thread-local контекст должен подменяться на каждом JSPI enter, exit, suspend и resume — по сути это кооперативная мультиплексированная по времени многопоточность, где каждый приостановленный стек несёт свой собственный контекст рантайма.
LocalEventLoop::block_on существует и продвигает future настолько, насколько хватает готовой работы, но там, где обычный runtime парковался бы, LocalEventLoop::block_on паникует: ничто не может разбудить future изнутри вызова, поскольку его ожидание принадлежит хосту.
Сокеты и epoll
Сокеты и epoll реализованы через мост к API node:net, поддерживаемому в слое совместимости с Node.js на Workers. Emscripten получил режим -sNODERAWSOCKETS, аналогичный существовавшему -sNODERAWFS, который даёт приложениям поддержку epoll, TCP, UDP и Unix-сокетов в Node.js, а поскольку Workers реализует тот же node:net API — и на самих Workers.
При использовании JSPI вызов epoll_wait() просто приостанавливает стек до готовности, поэтому I/O-драйвер Tokio работает как на нативной платформе. Для LocalEventLoop готовность вместо этого должна доходить до Waker через JS-кол
Где смотреть в коде
- mod.rs: generate_emscripten_wasm_loading
- mod.rs: memview
- mod.rs: expose_borrowed_objects
- mod.rs: expose_drop_ref
- mod.rs: expose_pass_array8_to_wasm
- mod.rs: export_destructor
- mod.rs: expose_get_vector_from_wasm
- mod.rs: pass_array_to_wasm