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

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;
}

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

Чтобы прочитать имя из 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 и перевести их в Интернет? Или, если привести конкретный пример, во что может быть переведена операция «чтение файла»? Ему нужно будет прочитать данные из какого-то хранилища.

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

В Интернете есть множество различных вариантов хранения, которые вы можете сопоставить, например, хранилище в памяти (объекты JS), 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 извлекает все содержимое, он вызывает последний обратный вызов, который печатает «Привет, (имя пользователя)!» на консоль.

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

В качестве последнего примера: даже простые 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 делает в своей реализации «спящего режима» по умолчанию, но это очень неэффективно, блокирует весь пользовательский интерфейс и не позволяет одновременно обрабатывать какие-либо другие события. Как правило, не делайте этого в рабочем коде.

Вместо этого более идиоматическая версия «сна» в 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

Если вы хотите использовать Asyncify для реализации асинхронного сна в последнем примере, вы можете сделать это следующим образом:

#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-интерфейсов на основе Promise, таких как fetch() , вы даже можете комбинировать Asyncify с функцией асинхронного ожидания 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 с асинхронным ожиданием:

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, подпишется на завершение обещания и позже , как только проблема будет решена, плавно восстановите стек вызовов и состояние и продолжите выполнение, как будто ничего не произошло.

Поскольку любая функция в модуле может выполнить асинхронный вызов, все экспорты также становятся потенциально асинхронными, поэтому они также переносятся. Возможно, вы заметили в приведенном выше примере, что вам нужно 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 не является полностью бесплатным, поскольку оно должно внедрить немало вспомогательного кода для хранения и восстановления всех этих локальных значений, навигации по стеку вызовов в различных режимах и так далее. Он пытается изменить только функции, помеченные как асинхронные в командной строке, а также любые их потенциальные вызывающие программы, но накладные расходы на размер кода все равно могут составить примерно 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 для устранения разрыва и переноса всех видов приложений в Интернет, позволяя вам получить кросс-платформенный доступ, изолированную программную среду и лучшую безопасность, и все это без потери функциональности.