WebAssembly (WASM) — не серебряная пуля
- среда, 15 июля 2026 г. в 00:00:16
WebAssembly появился для браузерных задач как способ исполнять тяжелый код быстрее, чем это делает JavaScript. Но за последние годы он начал выходить далеко (иногда буквально далеко) за пределы вкладки браузера: серверы, edge-вычисления, плагины, песочницы. Появились рантаймы Wasmtime, WasmEdge, Wasmer, которые исполняют WASM-модули\компоненты прямо на ОС. Звучит красиво - собрал один бинарь, а запускаешь где угодно, да еще и в максимальной изоляции.
Но есть интересное наблюдение. Существуют проекты, которые строили свои виртуальные машины на WASM, но отказались от него. Самый заметный пример - это Polkadot. Они спроектировали собственную PolkaVM на базе набора инструкций RISC-V и переключили исполнительный слой смарт-контрактов на нее (при этом сам runtime Polkadot по-прежнему компилируется в WASM). Если WASM настолько хорош, как заявляется, то почему для нового движка выбрали не его, а регистровую машину?
Недавно в одном из тематических чатов разгорелась жаркая дискуссия на этот счет, что, собственно говоря, и побудило меня на написание этой статьи. Если тебе интересны такие темы - добро пожаловать к нам)
Давайте попробуем разобраться, чем стековая модель отличается от регистровой и где эта разница реально что-то меняет. А также рассмотрим, где же в WASM скрыты компромиссы.
Автор не претендует на истину, это попытка разобраться в сложных тезисах и аргументах "за" и " против". Поэтому призываю тебя, дорого читатель, к доле скептицизма, и, как всегда - зову тебя в комментарии)
Виртуальная машина - это программа, которая исполняет промежуточный байткод. Байткод - это компактные инструкции, не привязанные к конкретному процессору. Байткод потом либо интерпретируется (движок читает инструкцию за инструкцией и выполняет), либо компилируется в настоящий машинный код целевого процессора.
Любая инструкция вычисляет что-то над операндами. Вопрос в том, где эти операнды лежат. Здесь модели и расходятся.
Стековая машина. Операнды кладутся наверх стека и снимаются оттуда. Инструкция add снимает два верхних числа, складывает, кладет результат обратно наверх. У чисел нет имен, важна только их позиция в стопке. Так устроены JVM (Java), CLR (.NET), CPython, EVM ( Ethereum) и WASM.
Регистровая машина. Инструкция явно называет источники и получатель. "Сложи то, что лежит в x1 и x2, положи результат в x3" записывается как add x3, x1, x2. Каждое значение поименовано. Так устроены Lua 5+, Dalvik (Android), внутренний интерпретатор Ignition в движке V8, а также RISC-V как реальная аппаратная архитектура.
Главное отличие в том, что регистровый формат прямо в инструкции хранит информацию о потоке данных, что откуда берется и куда идет. Это видно в инструкции add x3, x1, x2. Стековый формат эту информацию стирает. Остается лишь "сними два верхних, положи один результат". И эту стертую информацию кому-то потом придется восстанавливать при исполнении.
На эту тему много статей, которые легко гуглятся. Оставлю здесь одну https://habr.com/ru/companies/intel/articles/254793/
Стоит присмотреться, а WASM вообще настоящая стек-машина? Спецификация говорит, что да. А на практике?
У классических стек-машин, той же JVM, есть инструкции для жонглирования стопкой: dup ( продублировать верхнее значение), swap (поменять два верхних местами), over, rot. Именно они делают машину по-настоящему стековой, позволяют гонять значения по стеку и переиспользовать их. В WASM таких инструкций нет.
WASM не может повторно использовать уже посчитанное значение, не заведя под него локальную переменную. То есть код на практике всегда выглядит, как посчитал - сложил результат в именованную local - достал его оттуда, когда он понадобился. А это поведение уже неотличимо от регистровой машины (точнее сказать в практической генерации кода locals начинают играть роль, близкую к виртуальным регистрам), ведь значения именованы и адресуемы.
Есть и побочные эффекты такого дизайна. Например, при входе в функцию локальные переменные приходится обнулять, если компилятор не может доказать, что в local записали значение до того, как ее впервые прочитали. Формат не хранит явно, жива ли переменная в данной точке, поэтому приходится подстраховываться.
Более подробно об этом можно почитать здесь (очень интересная статья, рекомендую).
Как мы видим грань между стеком и регистрами в реальности очень тонкая.
Что вынужден делать single-pass компилятор стекового WASM?
Single-pass компилятор
Тип компилятора, который обрабатывает исходный код программы всего за один проход. https://www.geeksforgeeks.org/compiler-design/single-pass-two-pass-and-multi-pass-compilers/
Байткод не говорит, в каком именно физическом регистре процессора лежит каждое значение, компилятор должен вычислить это сам, на лету, за один проход.
Он мысленно проигрывает стек. Ведет модель стопки и для каждого слота отслеживает, где значение сейчас физически: в регистре процессора, в виде константы или уже вытеснено в память (это вытеснение называется spill). Физических регистров у процессора мало (у x86-64 их 16), и когда значений на стеке больше, чем регистров, лишние приходится временно сбрасывать в память.
Самая неприятная часть это сверка на развилках. В конце блоков if, loop, block сходятся несколько Execution Path (нити исполнения). Компилятор обязан привести все Execution Paths к единому виду, вставив shuffle-код, чтобы дальше программа находила значения там, где ожидает.
Shuffle-код
Это вспомогательные инструкции, которые компилятор генерирует на стыке нескольких Execution Paths, чтобы синхронизировать распределение переменных по регистрам.
Валидация WASM гарантирует только, что высота стека на всех Execution Paths совпадает, а вот где именно лежат значения - это уже забота компилятора.
Вся эта машинерия - это задача baseline-компиляторов, например, Liftoff в V8 и Winch в Wasmtime.
Baseline-компилятор
Сверхбыстрый JIT-компилятор первого уровня (Tier 1), главная задача которого минимизировать время старта программы (low-latency startup) за счет полного отказа от тяжелых глобальных оптимизаций
Она работает, спору нет. Но это и есть настоящая сложность. Восстановление информации, которой в формате изначально нет. Обратите внимание, раз значения все равно гоняются через именованные locals, компилятор фактически восстанавливает регистровый подход. Это совgадает с выводом предыдущего абзаца.
В регистровом же формате add x3, x1, x2 все уже названо. Проигрывать стек не нужно, а сверка на развилках проще, потому что значение привязано к конкретной именованной ячейке.
Здесь может возникнуть разумное возражение. Ведь на разных процессорах разное число регистров, значит распределять их все равно придется, и для регистровой ISA это не бесплатно.
ISA
Instruction Set Architecture - контракт между компилятором и микросхемой процессора
PolkaVM, упомянутая в самом начале, обходит проблему дизайном формата, а не рантайма. Они взяли профиль RISC-V с 16 регистрами, потому что у x86-64 их тоже 16. В результате распределение регистров превращается в прямое соответствие one-to-one. Не нужен дорогой анализ, достаточно простого lookup. Задачу решили один раз, на этапе проектирования набора инструкций, вместо того чтобы решать ее при каждой компиляции.
И важная оговорка. Преимущество регистрового формата в скорости компиляции проявляется только при жестком лимите на время этой самой компиляции. Если взять медленный оптимизирующий компилятор без ограничения по времени, разница почти растворяется. А в WASM, как мне представляется, стек выбрали сознательно, потому что стековый байткод компактнее кодируется и быстро проверяется
Из прошлого абзаца напрашивается вывод - формат следует за способом исполнения. То, что интерпретируется, выгодно делать регистровым. То, что всегда компилируется, можно делать стековым.
Разберем на примере V8.
В V8 два разных конвейера. Для JavaScript исходник превращается в байткод интерпретатора Ignition, и он регистровый. Ignition этот байткод интерпретирует, а Hot Spots позже перекомпилирует оптимизатор TurboFan. Для WASM компилятор уже выдал стековый WASM, и V8 его не интерпретирует вообще, Liftoff сразу компилирует в машинный код за один проход.
Почему форматы разные:
JavaScript динамический, компилировать код, который, может, выполнится один раз, дорого по времени и по памяти. Основной способ на старте - это интерпретация. А для интерпретации регистровый формат удобнее. На каждую инструкцию тратится меньше диспетчеризаций.
WASM статически типизирован, типы известны заранее, поэтому baseline-компилятор сразу выдает приличный код, и интерпретатор просто не нужен. Тем не менее, стоит отметить, что для WASM существуют интерпретаторы - wasm3, wasmi и другие. Нужны они для другого класса задач (например, микроконтроллеры).
Lua это редкий чистый эксперимент, где язык сменил модель VM и авторы задокументировали результат. До версии 4.0 у Lua была стековая VM, с версии 5.0 - регистровая.
Что это дало? Регистровый код избегает множества операций push/pop, которые стековому нужны только ради перекладывания значений. В Lua это особенно дорого, потому что каждый раз копируется значение вместе с тегом типа. В итоге один оператор исходника компилируется в одну инструкцию Lua 5.0 вместо трех-четырех в Lua 4.0. При этом регистры Lua - это слоты кадра функции, живущие, по сути, в массиве памяти, а не аппаратные регистры процессора.
Важно
Если руки чешутся (как и у меня) сравнить Lua с JVM, то спешу вас предостеречь. Скорость JVM - это результат десятилетий вложений в JIT, а не свойство стековой модели. Так что Lua честно доказывает пользу регистрового формата именно для интерпретации.
Почему же Polkadot ушел с WASM? (с этого мы и начали статью).
В обычном приложении можно потратить немного времени на компиляцию при старте и закешировать результат (получить артефакт AOT-компиляции, переживающий перезапуски). Задержка тут это вопрос удобства.
В блокчейне все иначе, и именно поэтому Polkadot ушел с WASM. Узлы сети исполняют чужой, потенциально враждебный код (смарт-контракты).
Смарт-контракты
Алгоритм, который автоматически контролирует условия соглашения между сторонами внутри блокчейна
В этих условиях сама компиляция превращается в вектор атаки. Оптимизирующий компилятор может вести себя катастрофически медленно, и это бомба замедленного действия. Злоумышленник шлет контракт, компиляция которого длится непропорционально долго, слабые узлы не успевают, и система деградирует. Это классический DoS через исчерпание ресурсов.
Компиляция за один проход с предсказуемым, линейным по размеру входа временем это жесткое требование. Регистровый RISC-V-подобный формат с заранее зашитым распределением регистров под это подходит лучше стекового WASM, что и заложено в дизайн PolkaVM.
Наверное, важно понять, что RISC-V сам по себе скорость не гарантирует. Другая RISC-V-машина у Nervos по их же измерениям бывает медленнее WASM, потому что проектировалась под иные задачи.
Линейная память плоская. Внутри одного модуля нет защитных страниц между статическими данными, стеком и кучей. Но тут полезно вспомнить, что лидер по компиляции и тулчейну для WASM - это Rust, а там корректность памяти обеспечена на этапе компиляции. Реальная проблема возникает, когда в один модуль с общей памятью линкуют несколько небезопасных по памяти C/C++ библиотек.
Изоляция от внешнего мира - это фича, а не баг (и я не знаю, кто всерьез рассматривает это, как недостаток). По умолчанию модуль не видит ни файлов, ни сети, доступ выдается явными разрешениями через WASI. Ровно это и делает WASM привлекательным в мире изолированных систем.
Копирование на границах. В компонентной модели у каждого компонента своя память, поэтому сложные данные (строки, структуры) между компонентами копируются, а не передаются по указателю. Это осознанная цена безопасной композиции.
Чтобы понять, за что именно WASM здесь платит (и чем), полезно сравнить его с полной эмуляцией. Например, с тем, что делает QEMU, когда исполняет чужую архитектуру на вашем процессоре.
Полная эмуляция воспроизводит все железо. Это дорого, потому что нужно поведенчески повторять целую машину. Именно из-за этой дороговизны индустрия в свое время ушла от полной виртуализации к контейнерам. Контейнер использует ядро хоста напрямую и не платит за эмуляцию.
Песочница WASM же это еще более легкий, третий режим. Отличается тремя вещами.
Одна линейная память. Вся память модуля это единый непрерывный массив байт, растущий только вверх, с индексацией от нуля.
С внешним миром модуль общается только через явно разрешенные функции-импорты (интерфейс WASI), а не через эмуляцию железа.
Компиляция байткода в машинный код за один линейный проход, без построения тяжелого внутреннего представления.
Песочница WASM платит только за проверки границ памяти и за саму быструю трансляцию байткода. Плата за эту дешевизну - бедная модель внутри.
WASM подходит:
где нужна сильная изоляция недоверенного кода (плагины, multi-tenant edge, песочница для кода LLM)
где код статически типизирован и всегда компилируется, так что стековый формат не мешает
где важна широкая переносимость и опора на стандарты (браузер, WASI, Component Model)
WASM не подходит:
где критичен абсолютно максимальный performance без дополнительных ограничений песочницы (все же native выигрывает по производительности)
где нужно исполнять недоверенный код с жестким лимитом на время компиляции
где требуется широкий доступ к системе.
А раз уж мы обсуждаем WASM вне браузера, то хочется прокомментировать модную идею "заменить Docker на WASM". Главным аргументом против этого было то, что Docker решает не только "запустить приложение переносимо", но и задачи упаковки, распространения и оркестрации окружения. Поэтому в эту идею не очень верили скептики. Однако, с появлением проекта WasmCloud это стало вполне реальным (как минимум решать похожие задачи).
С учетом всего вышесказанного, считаю, что самая реалистичная ниша WASM вне браузера - это serverless с быстрым cold стартом и сильной изоляцией. А вы что думаете?