Использование WebAssembly JavaScript API

Если вы уже компилировали модуль из другого языка, используя такие инструменты как Emscripten, или загружали и запускали код, то следующим шагом будет углубленное изучение возможностей WebAssembly JavaScript API. Эта статья даст необходимые знания по этому вопросу.

Примечание: Если вы незнакомы с фундаментальными понятиями, упомянутыми в этой статье, и вам нужны дополнительные объяснения, то вам нужно сначала прочитать про Основы WebAssembly.

Несколько простых примеров

Давайте запустим несколько примеров, которые объяснят как использовать WebAssembly JavaScript API, и как использовать его для загрузки wasm-модуля в web-странице.

Примечание: Вы можете найти примеры кода в нашем репозитории webassembly-examples на GitHub.

Подготовка примера

  1. Для начала нам нужен wasm-модуль! Возьмите наш файл simple.wasm и сохраните копию в новой директории на своем локальном компьютере.
  2. Далее, давайте создадим в той же директории что и wasm-модуль простой HTML-файл и назовем его index.html (можно использовать HTML шаблон если вы этого еще не сделали).
  3. Теперь, для того чтобы понять что происходит в коде модуля, давайте взглянем на его текстовое представление (которое мы также встречали в Перевод из текстового формата WebAssembly в wasm):
    (module
      (func $i (import "imports" "imported_func") (param i32))
      (func (export "exported_func")
        i32.const 42
        call $i))
  4. Во второй строчке вы видите что import имеет двухуровневое пространство имен - внутренняя функция $i импортирована из imports.imported_func. Нам нужно создать это двухуровневое пространство имен в JavaScript-объекте, который будет импортирован в wasm-модуль. Создайте <script></script> элемент в своем HTML-файле, и добавьте следующий код:
    var importObject = {
      imports: { imported_func: arg => console.log(arg) }
    };

Загрузка wasm-модуля в потоке

Новшество в Firefox 58 - это возможность компилировать и создавать экземпляры (объекты) модулей WebAssembly непосредственно из исходников. Это достигается использованием методов WebAssembly.compileStreaming() и WebAssembly.instantiateStreaming(). Эти методы занимают меньше места чем их непотоковые аналоги, потому что они могут преобразовывать байт-код прямо в модуль или экземпляр модуля, исключая необходимость отдельного размещения ответа (Response) в объекте ArrayBuffer после загрузки файла.

Следующий пример (см. наш демонстрационный файл instantiate-streaming.html на GitHub и его работу вживую) показывает как использовать instantiateStreaming() чтобы загрузить wasm-модуль, импортировать JavaScript функцию в него, компилировать, создать его экземпляр и получить доступ к его экспортируемой функции. Все это в одном шаге.

Добавьте этот скрипт ниже первого блока кода:

WebAssembly.instantiateStreaming(fetch('simple.wasm'), importObject)
.then(obj => obj.instance.exports.exported_func());

В конце этого действия мы вызываем нашу экспортированную из WebAssembly-функцию exported_func, которая в свою очередь вызывает нашу импортированную JavaScript-функцию imported_func, которая выводит в консоль значение (42), что хранится внутри экземпляра модуля WebAssembly. Если вы сейчас сохраните пример кода и загрузите его в браузер, который поддерживает WebAssembly, вы увидите это в действии!

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

Загрузка wasm-модуля без потока

Если вы не можете или не хотите использовать методы описанные выше, то вы можете использовать вместо этого непотоковые методы WebAssembly.compile / WebAssembly.instantiate.

Эти методы не получают непосредственно доступ к байт-коду, так что требуется дополнительный шаг помещения ответа загрузки файла в объект ArrayBuffer перед компилированием и созданием экземпляра wasm-модуля.

Эквивалентный код будет выглядить так:

fetch('simple.wasm').then(response =>
  response.arrayBuffer()
).then(bytes =>
  WebAssembly.instantiate(bytes, importObject)
).then(results => {
  results.instance.exports.exported_func();
});

Просмотр wasm в инструментах разработчика

В Firefox 54+, в отладочной панели инструментов разработчика имеется возможность отображения текстового представления любого wasm-кода, включенного в веб-страницу. Для того чтобы просмотреть его, вы можете перейти на отладочную панель и нажать на пункт "wasm://".

В ближайших версиях в Firefox, в дополнении к просмотру wasm-кода как текста, разработчики будут иметь возможность отлаживать wasm используя текстовый формат (устанавливать точки останова, изучать стек вызовов, построчно переходить, и.т.д). См. WebAssembly debugging with Firefox DevTools в видео-анонсе.

Память

В низкоуровневой модели памяти WebAssembly, память представлена как диапазон смежных байт, называемых линейной памятью (Linear Memory), которая внутри модуля читается и записывается инструкциями загрузки и размещения значений. В этой модели памяти, любая инструкция загрузки или размещения может получить доступ к любому байту всей линейной памяти. Это необходимо для полноценного представления таких концепций языков C/C++ как указатели.

В отличии от C/C++ программы, где доступный диапазон памяти ограничен процессом, память доступная отдельному экземпляру WebAssembly ограничена до одного специфического (потенциально очень маленького) диапазона, который содержится в объекте памяти WebAssembly. Это позволяет единственному web-приложению использовать множество независимых библиотек (использующих WebAssembly) которые могут иметь отдельные и полностью изолированные друг от друга диапазоны памяти.

В JavaScript-коде, объект памяти WebAssembly можно считать объектом ArrayBuffer c изменяемыми размерами. Одно веб-приложение может создавать много таких независимых объектов памяти. Вы можете создать новый объект, используя конструктор WebAssembly.Memory(), который принимает аргументы начального и максимального размера буфера (опционально).

Давайте исследуем эту возможность рассмотрев небольшой пример.

Создайте еще одну простую HTML страницу (скопируйте HTML шаблон) и назовите её memory.html. Добавьте <script></script> элемент на страницу.

  1. Добавьте следующую строку в начало нашего скрипта, для создания экземпляра объекта памяти:

    var memory = new WebAssembly.Memory({initial:10, maximum:100});

    Единицы измерения начальной (initial) и максимальной (maximum) памяти имеют фиксированый размер в 64KB. Это означает, что в нашем случае объект памяти при создании имеет 640KB, а его максимальный возможный размер будет 6.4MB.

    Объект памяти WebAssembly предоставляет свой хранимый диапазон байт через getter/setter свойства buffer, которое возвращает объект ArrayBuffer. Для примера, чтобы записать число 42 в первое слово линейной памяти, вы можете сделать это:

    new Uint32Array(memory.buffer)[0] = 42;

    вы можете возвратить значение используя этот код:

    new Uint32Array(memory.buffer)[0]
  2. Попробуйте сделать это в вашем примере - сохраните то, что вы сделали, загрузите его в браузере, после чего попробуйте ввести вышеупомянутые строчки в JavaScript-консоль.

Расширение памяти

Объект памяти может быть расширен с помощью вызова метода Memory.prototype.grow(), где аргументом будет количество единиц (размером в 64KB) памяти WebAssembly:

memory.grow(1);

При превышении максимального значения, указанного при создании объекта памяти, будет выброшено исключение WebAssembly.RangeError. Движок использует предоставленные верхние границы для резервирования памяти заранее, что делает расширение памяти более эффективным.

Примечание: Так как размер объекта ArrayBuffer неизменен, после успешного вызова метода Memory.prototype.grow() свойство buffer объекта памяти будет возвращать уже новый объект ArrayBuffer (с новым размером в свойстве byteLength) и любые предыдущие объекты ArrayBuffer станут в некотором роде “отсоединенными”, или отключенными от низкоуровневой памяти, к которой они ранее относились.

Подобно функциям, диапазоны линейной памяти могут быть импортированы или определены внутри модуля. Также, модуль имеет возможность экспортировать свою память. Это означает, что JavaScript-код может получить доступ к объекту памяти WebAssembly либо c помощью создания нового объекта через конструктор WebAssembly.Memory и передачи его в импортируемый объект, либо с помощью получения объекта памяти через экспортируемый объект (через Instance.prototype.exports).

Более сложный пример

Давайте сделаем вышеупомянутые утверждения понятнее, рассмотрев более сложный пример работы с памятью, где WebAssembly-модуль импортирует объект памяти, который мы определили ранее, после чего JavaScript-код наполняет его с помощью массива целых чисел, а экспортируемая wasm-функция суммирует их.

  1. Сопируйте файл memory.wasm в локальную директорию в которой вы работаете.

    Примечание: Вы можете увидеть текстовое представление модуля в файле memory.wat.

  2. Откройте ваш файл memory.html и добавьте следующий код снизу вашего основного скрипта для загрузки, компилирования и создания экземпляра wasm-модуля:

    WebAssembly.instantiateStreaming(fetch('memory.wasm'), { js: { mem: memory } })
    .then(results => {
      // add code here
    });
  3. Так как модуль экспортирует свою память, которая была передана экземпляру этого модуля при его создании, мы можем наполнить ранее импортированный массив прямо в линейной памяти экземпляра модуля (mem), и вызвать экспортированную функцию accumulate() для расчета суммы значений. Добавьте следующий код, в обозначенном месте: 

    var i32 = new Uint32Array(memory.buffer);
    
    for (var i = 0; i < 10; i++) {
      i32[i] = i;
    }
    
    var sum = results.instance.exports.accumulate(0, 10);
    console.log(sum);

Обратите внимание на то, что мы создаем представление данных Uint32Array с помощью свойства buffer объекта памяти (Memory.prototype.buffer), а не самого объекта памяти.

Импорт памяти почти такой же как импорт функций, только вместо JavaScript функций передаются объекты памяти. Импорт памяти полезен по двум причинам:

  • Он позволяет JavaScript-коду получать и создать начальное содержание памяти перед или одновременно с компиляцией модуля.
  • Он позволяет импортировать один объект памяти во множество экземпляров модулей, что является ключевым элементом для реализации динамического связывания в WebAssembly.

Примечание: Вы можете найти полную демонстрацию в файле memory.html (см. ее также вживую) — эта версия использует функцию fetchAndInstantiate().

Таблицы

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

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

Ссылки на функции необходимы для компиляции в таких языках как C/C++, которые имеют указатели на функции. В родной реализации C/C++, указатель на функцию представлен прямым адресом на код функции в виртуальном адресном пространстве процесса, и потому для ранее упомянутой безопасности, они не могут быть размещены прямо в линейной памяти. Вместо этого ссылки на функции размещаются в таблице, а её индексы, которые являются целыми числами могут быть размещены в линейной памяти и переданы куда угодно.

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

Таблицы могут изменятся с помощью метода Table.prototype.set(), который обновляет одно из значений в таблице, и метода Table.prototype.grow(), который увеличивает количество значений, которое может быть размещено в таблице. Это позволяет этому "непрямо-вызываемому набору функций" изменяться со временем, что необходимо для техник динамического связывания. Изменения немедленно становятся доступными с помощью метода Table.prototype.get() в JavaScript коде и wasm-модулях.

Пример таблицы

Давайте взглянем на простой пример таблицы - модуль WebAssembly, который создает и экспортирует таблицу с двумя элементами: элемент под индексом 0 возвращает 13, а элемент под индексом 1 возвращает 42.

  1. Сделайте локальную копию файла table.wasm в новой директории.

    Примечание: Вы можете посмотреть текстовое представление модуля в файле table.wat.

  2. Создайте новую копию нашего HTML шаблона в той же директории и назовите его table.html.

  3. Как и раньше загрузите, компилируйте, и создайте экземпляр вашего wasm-модуля, добавив следующий код в <script> элемент в тело документа:

    WebAssembly.instantiateStreaming(fetch('table.wasm'))
    .then(function(results) {
      // add code here
    });
  4. Теперь давайте получим доступ к данным в таблицах - добавим следующие строки в ваш код, в обозначенном месте:

    var tbl = results.instance.exports.tbl;
    console.log(tbl.get(0)());  // 13
    console.log(tbl.get(1)());  // 42

Этот код получает доступ к каждой ссылке на функцию, которая размещена в таблице, после чего вызывает её и выводит хранимое значение в консоль. Обратите внимание, что каждая ссылка на функцию получена с помощью вызова метода Table.prototype.get(), после чего мы добавили пару круглых скобок для вызова самой функции.

Примечание: Вы можете найти нашу полную демонстрацию в файле table.html (см. ее также вживую) — эта версия использует функцию fetchAndInstantiate().

Глобальные переменные

WebAssembly имеет возможность создавать экземпляры глобальных переменных, доступных как в JavaScript так и в экземплярах модулей WebAssembly (WebAssembly.Module) посредством импорта или экспорта. Это очень полезная возможность, которая позволяет динамически связывать несколько модулей. Для создания глобальной переменной WebAssembly внутри вашего JavaScript-кода, используйте конструктор WebAssembly.Global(), который выглядит так:

const global = new WebAssembly.Global({value:'i32', mutable:true}, 0);

Вы можете видеть, что он принимает 2 параметра:

  • Обьект, который содержит 2 свойства, описывающих глобальную переменную:
    • value: это тип данных, который может быть одним из типов данных, позволенных внутри WebAssembly модуля — i32, i64, f32, или f64.
    • mutable: булево значение, определяющее что переменная может изменяться.
  • Значение, которое содержит текущее значение переменной. Это может быть любое значение для типа к которому оно относится.

Что мы будем с этим делать? В следующем примере мы определим глобальную, изменяемую переменную с типом i32 и значением 0.

Значение глобальной переменной будет изменено на число 42 используя свойство Global.value, а после на 43 используя экспортированную функцию incGlobal() из модуля global.wasm (это добавит 1 к установленному значению и возвратит новое).

const output = document.getElementById('output');

function assertEq(msg, got, expected) {
    output.innerHTML += `Testing ${msg}: `;
    if (got !== expected)
        output.innerHTML += `FAIL!<br>Got: ${got}<br>Expected: ${expected}<br>`;
    else
        output.innerHTML += `SUCCESS! Got: ${got}<br>`;
}

assertEq("WebAssembly.Global exists", typeof WebAssembly.Global, "function");

const global = new WebAssembly.Global({value:'i32', mutable:true}, 0);

WebAssembly.instantiateStreaming(fetch('global.wasm'), { js: { global } })
.then(({instance}) => {
    assertEq("getting initial value from wasm", instance.exports.getGlobal(), 0);
    global.value = 42;
    assertEq("getting JS-updated value from wasm", instance.exports.getGlobal(), 42);
    instance.exports.incGlobal();
    assertEq("getting wasm-updated value from JS", global.value, 43);
});

Примечание: Вы можете увидеть этот пример вживую на  GitHub; смотрите также исходники.

Множественность

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

  • Один модуль может иметь N экземпляров, точно так же как одно определение функции может произвести N замыканий.
  • Один экземпляр модуля может использовать от 0 до 1 объекта памяти, который предоставляет “адресное пространство” экземпляра модуля. Будущие версии WebAssembly позволят иметь 0–N экземпляров объектов на один экземпляр модуля (см. Несколько таблиц и объектов памяти).
  • Один экземпляр модуля может использовать от 0 до 1 объекта таблицы - это “адресное пространство для функций” экземпляра модуля используется для реализации С/С++ указателей на функции. Будущие версии WebAssembly позволят иметь 0–N экземпляров таблиц на один экземпляр модуля.
  • Один объект памяти или объект таблицы может быть использован в 0-N экземплярах модулей - эти все экземпляры будут разделять одно и то же адресное пространство, позволяя выполнять динамическое связывание.

Чтобы ознакомится с множественностью в действии, смотрите нашу объясняющую статью Изменяющиеся таблицы и динамическое связывание.

Резюме

В этой статье-путеводителе по основам WebAssembly JavaScript API вы включали модули WebAssembly в среду JavaScript, использовали их функции, объекты памяти, таблицы и глобальные переменные. Мы также затронули концепцию множественности.

Смотрите также