Использование асинхронных веб-API из WebAssembly

Ингвар Степанян
Ingvar Stepanyan

API ввода-вывода в веб-приложениях асинхронны, но в большинстве системных языков они синхронны. При компиляции кода в WebAssembly необходимо связать один тип API с другим — и этим мостом является Asyncify. В этой статье вы узнаете, когда и как использовать Asyncify и как он работает «под капотом».

Ввод-вывод в системных языках

Начну с простого примера на языке C. Допустим, вы хотите прочитать имя пользователя из файла и поприветствовать его сообщением "Привет, (имя пользователя)!":

#include <stdio.h>

int main() {
    FILE *stream = fopen("name.txt", "r");
    char name[20+1];
    size_t len = fread(&name, 1, 20, stream);
    name[len] = '\0';
    fclose(stream);
    printf("Hello, %s!\n", name);
    return 0;
}

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

Для чтения имени файла из C вам потребуется как минимум два важных вызова функции ввода-вывода: fopen для открытия файла и fread для чтения данных из него. После получения данных вы можете использовать другую функцию ввода-вывода printf для вывода результата на консоль.

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

  • Если входной файл находится на локальном диске, приложению необходимо выполнить ряд обращений к памяти и диску, чтобы найти файл, проверить права доступа, открыть его для чтения, а затем читать по блокам, пока не будет получено запрошенное количество байтов. Это может быть довольно медленно, в зависимости от скорости вашего диска и запрошенного размера.
  • Или же входной файл может находиться в смонтированном сетевом расположении, в этом случае также будет задействован сетевой стек, что увеличит сложность, задержку и количество потенциальных повторных попыток для каждой операции.
  • Наконец, даже printf не гарантирует вывод информации в консоль и может быть перенаправлена ​​в файл или сетевое расположение, в этом случае ей придется пройти те же шаги, что и выше.

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

Это относится не только к C или C++. Большинство системных языков предоставляют все операции ввода-вывода в виде синхронных API. Например, если перевести пример на Rust, API может выглядеть проще, но принципы остаются теми же. Вы просто делаете вызов и синхронно ждёте возврата результата, в то время как он выполняет все ресурсоёмкие операции и в конечном итоге возвращает результат за один вызов:

fn main() {
    let s = std::fs::read_to_string("name.txt");
    println!("Hello, {}!", s);
}

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

Асинхронная модель сети

В интернете существует множество различных вариантов хранения данных, которые можно использовать, например, хранилище в оперативной памяти (объекты JavaScript), localStorage , IndexedDB , серверное хранилище и новый API доступа к файловой системе .

Однако только два из этих API — хранилище в оперативной памяти и localStorage — могут использоваться синхронно, и оба являются наиболее ограниченными вариантами с точки зрения того, что и как долго можно хранить. Все остальные варианты предоставляют только асинхронные API.

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

Причина в том, что веб-технологии исторически являются однопоточными, и любой пользовательский код, взаимодействующий с пользовательским интерфейсом, должен выполняться в том же потоке, что и сам интерфейс. Ему приходится конкурировать с другими важными задачами, такими как компоновка, рендеринг и обработка событий, за процессорное время. Вряд ли хотелось бы, чтобы фрагмент кода на JavaScript или WebAssembly мог начать операцию «чтения файла» и заблокировать всё остальное — всю вкладку или, в прошлом, весь браузер — на время от миллисекунд до нескольких секунд, пока операция не завершится.

Вместо этого коду разрешается планировать операцию ввода-вывода только вместе с функцией обратного вызова, которая будет выполнена после ее завершения. Такие функции обратного вызова выполняются в рамках цикла событий браузера. Я не буду вдаваться в подробности, но если вам интересно узнать, как работает цикл событий изнутри, ознакомьтесь со статьей «Задачи, микрозадачи, очереди и расписания» , где эта тема подробно объясняется.

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

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

Например, если бы вы захотели переписать приведенные выше примеры на современном JavaScript и решили бы прочитать имя с удаленного URL-адреса, вы бы использовали Fetch API и синтаксис async-await:

async function main() {
  let response = await fetch("name.txt");
  let name = await response.text();
  console.log("Hello, %s!", name);
}

Несмотря на синхронный характер, по сути, каждый await — это синтаксический сахар для обратных вызовов:

function main() {
  return fetch("name.txt")
    .then(response => response.text())
    .then(name => console.log("Hello, %s!", name));
}

В этом упрощенном примере, который немного понятнее, инициируется запрос, и на ответы подписываются с помощью первого коллбэка. Как только браузер получает первоначальный ответ — только HTTP-заголовки — он асинхронно вызывает этот коллбэк. Коллбэк начинает считывать тело запроса как текст, используя response.text() , и подписывается на результат с помощью другого коллбэка. Наконец, как только fetch получит все содержимое, он вызывает последний коллбэк, который выводит в консоль "Hello, (username)!".

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

В качестве заключительного примера, даже простые API-интерфейсы, такие как «sleep», заставляющие приложение ждать определенное количество секунд, также являются формой операции ввода-вывода:

#include <stdio.h>
#include <unistd.h>
// ...
printf("A\n");
sleep(1);
printf("B\n");

Конечно, можно перевести это очень простым способом, который заблокирует текущую ветку обсуждения до истечения указанного времени:

console.log("A");
for (let start = Date.now(); Date.now() - start < 1000;);
console.log("B");

На самом деле, именно так Emscripten и поступает в своей реализации функции «sleep» по умолчанию, но это очень неэффективно, блокирует весь пользовательский интерфейс и не позволяет обрабатывать другие события. В целом, не стоит так делать в рабочем коде.

Вместо этого, более идиоматический вариант использования оператора «сон» в JavaScript предполагал бы вызов setTimeout() и подписку на обработчик:

console.log("A");
setTimeout(() => {
    console.log("B");
}, 1000);

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

Преодоление разрыва с помощью Asyncify

Вот тут-то и пригодится Asyncify . Asyncify — это функция компиляции, поддерживаемая Emscripten, которая позволяет приостановить выполнение всей программы и асинхронно возобновить её позже.

Граф вызовов, описывающий вызов асинхронной задачи JavaScript -> WebAssembly -> веб-API, где Asyncify подключает результат асинхронной задачи обратно в WebAssembly.

Использование в C/C++ с Emscripten

If you wanted to use Asyncify to implement an asynchronous sleep for the last example, you could do it like this:

#include <stdio.h>
#include <emscripten.h>

EM_JS(void, async_sleep, (int seconds), {
    Asyncify.handleSleep(wakeUp => {
        setTimeout(wakeUp, seconds * 1000);
    });
});

puts("A");
async_sleep(1);
puts("B");

EM_JS — это макрос, позволяющий определять фрагменты кода JavaScript так, как если бы это были функции C. Внутри него используйте функцию Asyncify.handleSleep() , которая указывает Emscripten приостановить программу и предоставляет обработчик wakeUp() , который должен быть вызван после завершения асинхронной операции. В приведенном выше примере обработчик передается в setTimeout() , но его можно использовать в любом другом контексте, принимающем коллбэки. Наконец, вы можете вызывать async_sleep() где угодно, как и обычный sleep() или любой другой синхронный API.

При компиляции такого кода необходимо указать Emscripten активировать функцию Asyncify. Для этого передайте -s ASYNCIFY , а также -s ASYNCIFY_IMPORTS=[func1, func2] со списком функций, которые могут быть асинхронными, в виде массива.

emcc -O2 \
    -s ASYNCIFY \
    -s ASYNCIFY_IMPORTS=[async_sleep] \
    ...

Это позволяет Emscripten понять, что любые вызовы этих функций могут потребовать сохранения и восстановления состояния, поэтому компилятор внедрит вспомогательный код вокруг таких вызовов.

Теперь, при выполнении этого кода в браузере, вы увидите непрерывный вывод в лог, как и ожидалось, причём B появится через небольшую задержку после A.

A
B

Вы также можете возвращать значения из функций Asyncify . Для этого вам нужно вернуть результат функции handleSleep() и передать его в функцию обратного вызова wakeUp() . Например, если вместо чтения из файла вы хотите получить число из удаленного ресурса, вы можете использовать фрагмент кода, подобный приведенному ниже, чтобы отправить запрос, приостановить выполнение кода на C и возобновить его после получения тела ответа — все это выполняется плавно, как если бы вызов был синхронным.

EM_JS(int, get_answer, (), {
     return Asyncify.handleSleep(wakeUp => {
        fetch("answer.txt")
            .then(response => response.text())
            .then(text => wakeUp(Number(text)));
    });
});
puts("Getting answer...");
int answer = get_answer();
printf("Answer is %d\n", answer);

Фактически, для API на основе промисов, таких как fetch() , вы можете даже комбинировать Asyncify с функцией async-await в JavaScript вместо использования API на основе коллбэков. Для этого вместо Asyncify.handleSleep() вызовите Asyncify.handleAsync() . Затем, вместо того, чтобы планировать коллбэк wakeUp() , вы можете передать async функцию JavaScript и использовать await и return внутри, что сделает код еще более естественным и синхронным, не теряя при этом никаких преимуществ асинхронного ввода-вывода.

EM_JS(int, get_answer, (), {
     return Asyncify.handleAsync(async () => {
        let response = await fetch("answer.txt");
        let text = await response.text();
        return Number(text);
    });
});

int answer = get_answer();

Ожидание сложных значений

Но этот пример по-прежнему ограничивает вас только числами. А что, если вы хотите реализовать исходный пример, где я пытался получить имя пользователя из файла в виде строки? Что ж, это тоже возможно!

Emscripten предоставляет функцию Embind , которая позволяет обрабатывать преобразования между значениями JavaScript и C++. Она также поддерживает Asyncify, поэтому вы можете вызывать await() для внешних Promise , и это будет работать так же, как await в коде JavaScript с поддержкой async-await:

val fetch = val::global("fetch");
val response = fetch(std::string("answer.txt")).await();
val text = response.call<val>("text").await();
auto answer = text.as<std::string>();

При использовании этого метода вам даже не нужно передавать ASYNCIFY_IMPORTS в качестве флага компиляции, поскольку он уже включен по умолчанию.

Итак, всё это прекрасно работает в Emscripten. А как насчёт других наборов инструментов и языков?

Использование из других языков

Допустим, у вас в коде на Rust есть похожий синхронный вызов, который вы хотите сопоставить с асинхронным API в веб-среде. Оказывается, это тоже возможно!

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

extern {
    fn get_answer() -> i32;
}

println!("Getting answer...");
let answer = get_answer();
println!("Answer is {}", answer);

И скомпилируйте свой код в WebAssembly:

cargo build --target wasm32-unknown-unknown

Теперь необходимо добавить в файл WebAssembly код для хранения/восстановления стека. Для C/C++ это мог бы сделать Emscripten, но здесь он не используется, поэтому процесс немного более трудоемкий.

К счастью, преобразование Asyncify полностью не зависит от используемого инструментария. Оно может преобразовывать произвольные файлы WebAssembly, независимо от того, каким компилятором они созданы. Преобразование предоставляется отдельно как часть оптимизатора wasm-opt из инструментария Binaryen и может быть вызвано следующим образом:

wasm-opt -O2 --asyncify \
      --pass-arg=asyncify-imports@env.get_answer \
      [...]

Передайте параметр --asyncify , чтобы включить преобразование, а затем используйте --pass-arg=… для указания списка асинхронных функций, разделенных запятыми, для которых состояние программы должно быть приостановлено, а затем возобновлено.

Осталось лишь предоставить вспомогательный код среды выполнения, который будет фактически это делать — приостанавливать и возобновлять выполнение кода WebAssembly. Опять же, в случае C/C++ это было бы включено в Emscripten, но теперь вам нужен собственный код на JavaScript, который будет обрабатывать произвольные файлы WebAssembly. Мы создали библиотеку именно для этого.

Его можно найти на GitHub по адресу https://github.com/GoogleChromeLabs/asyncify или в npm под названием asyncify-wasm .

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

const { instance } = await Asyncify.instantiateStreaming(fetch('app.wasm'), {
    env: {
        async get_answer() {
            let response = await fetch("answer.txt");
            let text = await response.text();
            return Number(text);
        }
    }
});

await instance.exports.main();

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

Поскольку любая функция в модуле может выполнять асинхронный вызов, все экспортируемые функции также потенциально становятся асинхронными, поэтому они тоже оборачиваются. В приведенном выше примере вы могли заметить, что необходимо await результата вызова instance.exports.main() чтобы узнать, когда выполнение действительно завершилось.

Как всё это работает изнутри?

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

Это очень похоже на функцию async-await в JavaScript, которую я показывал ранее, но, в отличие от JavaScript, не требует какого-либо специального синтаксиса или поддержки во время выполнения языка, а работает путем преобразования обычных синхронных функций во время компиляции.

При компиляции приведенного ранее примера асинхронного сна:

puts("A");
async_sleep(1);
puts("B");

Asyncify берет этот код и преобразует его примерно в следующий вид (псевдокод, реальное преобразование гораздо сложнее):

if (mode == NORMAL_EXECUTION) {
    puts("A");
    async_sleep(1);
    saveLocals();
    mode = UNWINDING;
    return;
}
if (mode == REWINDING) {
    restoreLocals();
    mode = NORMAL_EXECUTION;
}
puts("B");

Изначально mode установлен на NORMAL_EXECUTION . Соответственно, при первом выполнении такого преобразованного кода будет вычислена только часть, предшествующая вызову async_sleep() . Как только асинхронная операция запланирована, Asyncify сохраняет все локальные переменные и разматывает стек, возвращаясь из каждой функции до самого верха, тем самым возвращая управление циклу событий браузера.

Затем, после завершения работы функции async_sleep() , код поддержки Asyncify переключится в mode REWINDING и снова вызовет функцию. На этот раз пропускается ветка "обычного выполнения" — поскольку она уже выполнилась в прошлый раз, и я хочу избежать двойного вывода "A" — и вместо этого сразу переходит к ветке "перемотки". Достигнув её, он восстанавливает все сохранённые локальные переменные, переключается обратно в режим "обычного выполнения" и продолжает выполнение, как если бы код изначально не был остановлен.

Затраты на трансформацию

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

График, показывающий накладные расходы на размер кода для различных бенчмарков, от почти 0% в условиях тонкой настройки до более 100% в худших случаях.

Это не идеальный вариант, но во многих случаях приемлемый, когда альтернативой является полное отсутствие необходимой функциональности или существенная переработка исходного кода.

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

Реальные демонстрации

Теперь, когда вы рассмотрели простые примеры, я перейду к более сложным сценариям.

Как упоминалось в начале статьи, одним из вариантов хранения данных в интернете является асинхронный API доступа к файловой системе . Он обеспечивает доступ к реальной файловой системе хоста из веб-приложения.

С другой стороны, существует де-факто стандарт WASI для ввода-вывода WebAssembly на стороне консоли и сервера. Он был разработан как цель компиляции для системных языков и предоставляет доступ ко всем видам операций с файловой системой и другим операциям в традиционной синхронной форме.

А что если бы можно было сопоставить одно с другим? Тогда можно было бы скомпилировать любое приложение на любом языке программирования с помощью любого набора инструментов, поддерживающего целевую платформу WASI, и запустить его в изолированной среде в интернете, при этом позволяя работать с реальными пользовательскими файлами! С Asyncify это возможно.

В этой демонстрации я скомпилировал крейт Rust coreutils с несколькими незначительными изменениями для WASI, передал его через преобразование Asyncify и реализовал асинхронные привязки из WASI к API доступа к файловой системе на стороне JavaScript. В сочетании с терминальным компонентом Xterm.js это обеспечивает реалистичную оболочку, работающую во вкладке браузера и взаимодействующую с реальными пользовательскими файлами — точно так же, как настоящий терминал.

Посмотрите трансляцию в прямом эфире по ссылке https://wasi.rreverser.com/ .

Сферы применения Asyncify не ограничиваются только таймерами и файловыми системами. Вы можете пойти дальше и использовать более специализированные API в веб-разработке.

Например, с помощью Asyncify можно сопоставить libusb — вероятно, самую популярную нативную библиотеку для работы с USB-устройствами — с WebUSB API , который обеспечивает асинхронный доступ к таким устройствам в интернете. После сопоставления и компиляции я получил стандартные тесты и примеры libusb для запуска на выбранных устройствах прямо в песочнице веб-страницы.

Скриншот отладочного вывода libusb на веб-странице, отображающий информацию о подключенной камере Canon.

Впрочем, это, пожалуй, тема для отдельной статьи в блоге.

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