Menggunakan API web asinkron dari WebAssembly

API I/O di web bersifat asinkron, tetapi bersifat sinkron di sebagian besar bahasa sistem. Saat mengompilasi kode ke WebAssembly, Anda perlu menjembatani satu jenis API ke API lainnya—dan jembatan ini adalah Asyncify. Dalam postingan ini, Anda akan mempelajari kapan dan cara menggunakan Asyncify serta cara kerjanya di balik layar.

I/O dalam bahasa sistem

Saya akan memulai dengan contoh sederhana dalam C. Misalnya, Anda ingin membaca nama pengguna dari file, dan menyapa mereka dengan pesan "Halo, (nama pengguna)":

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

Meskipun contoh ini tidak banyak melakukan hal, contoh ini sudah menunjukkan sesuatu yang akan Anda temukan dalam aplikasi berukuran berapa pun: contoh ini membaca beberapa input dari dunia eksternal, memprosesnya secara internal, dan menulis output kembali ke dunia eksternal. Semua interaksi tersebut dengan dunia luar terjadi melalui beberapa fungsi yang biasa disebut fungsi input-output, yang juga disingkat menjadi I/O.

Untuk membaca nama dari C, Anda memerlukan minimal dua panggilan I/O penting: fopen, untuk membuka file, dan fread untuk membaca data darinya. Setelah mengambil data, Anda dapat menggunakan fungsi I/O lain printf untuk mencetak hasilnya ke konsol.

Sekilas fungsi tersebut terlihat cukup sederhana dan Anda tidak perlu memikirkan mesin yang digunakan untuk membaca atau menulis data. Namun, bergantung pada lingkungannya, mungkin ada banyak hal yang terjadi di dalamnya:

  • Jika file input berada di drive lokal, aplikasi perlu melakukan serangkaian akses memori dan disk untuk menemukan file, memeriksa izin, membukanya untuk dibaca, lalu membaca blok demi blok hingga jumlah byte yang diminta diambil. Proses ini bisa sangat lambat, bergantung pada kecepatan disk dan ukuran yang diminta.
  • Atau, file input mungkin berada di lokasi jaringan yang terpasang, dalam hal ini, stack jaringan juga akan terlibat, sehingga meningkatkan kompleksitas, latensi, dan jumlah potensi percobaan ulang untuk setiap operasi.
  • Terakhir, printf juga tidak dijamin akan mencetak sesuatu ke konsol dan mungkin dialihkan ke file atau lokasi jaringan, dalam hal ini, file atau lokasi jaringan harus melalui langkah-langkah yang sama di atas.

Singkatnya, I/O dapat lambat dan Anda tidak dapat memprediksi berapa lama panggilan tertentu akan berlangsung dengan melihat kode secara sekilas. Saat operasi tersebut berjalan, seluruh aplikasi Anda akan tampak membeku dan tidak responsif terhadap pengguna.

Hal ini juga tidak terbatas pada C atau C++. Sebagian besar bahasa sistem menampilkan semua I/O dalam bentuk API sinkron. Misalnya, jika Anda menerjemahkan contoh ke Rust, API mungkin terlihat lebih sederhana, tetapi prinsip yang sama berlaku. Anda hanya melakukan panggilan dan menunggunya secara sinkron untuk menampilkan hasil, sembari melakukan semua operasi yang mahal dan pada akhirnya menampilkan hasil dalam satu pemanggilan:

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

Namun, apa yang terjadi jika Anda mencoba mengompilasi salah satu contoh tersebut ke WebAssembly dan menerjemahkannya ke web? Atau, untuk memberikan contoh spesifik, apa yang dapat diterjemahkan oleh operasi "file read"? Aplikasi tersebut harus membaca data dari beberapa penyimpanan.

Model asinkron web

Web memiliki berbagai opsi penyimpanan yang dapat Anda petakan, seperti penyimpanan dalam memori (objek JS), localStorage, IndexedDB, penyimpanan sisi server, dan File System Access API baru.

Namun, hanya dua dari API tersebut—penyimpanan dalam memori dan localStorage—yang dapat digunakan secara sinkron, dan keduanya adalah opsi yang paling membatasi dalam hal yang dapat Anda simpan dan berapa lama. Semua opsi lainnya hanya menyediakan API asinkron.

Ini adalah salah satu properti inti dari mengeksekusi kode di web: setiap operasi yang memakan waktu, yang menyertakan I/O, harus asinkron.

Alasannya adalah web secara historis memiliki thread tunggal, dan kode pengguna apa pun yang menyentuh UI harus berjalan di thread yang sama dengan UI. Waktu CPU harus bersaing dengan tugas-tugas penting lainnya seperti tata letak, rendering, dan penanganan peristiwa. Anda tidak ingin bagian JavaScript atau WebAssembly dapat memulai operasi "file read" dan memblokir semua yang lain—seluruh tab, atau, sebelumnya, seluruh browser—untuk rentang dari milidetik hingga beberapa detik, hingga selesai.

Sebagai gantinya, kode hanya diizinkan untuk menjadwalkan operasi I/O bersama dengan callback yang akan dijalankan setelah selesai. Callback tersebut dieksekusi sebagai bagian dari loop peristiwa browser. Saya tidak akan membahas detailnya di sini, tetapi jika Anda tertarik untuk mempelajari cara kerja loop peristiwa di balik layar, baca artikel Tugas, tugas mikro, antrean, dan jadwal yang menjelaskan topik ini secara mendalam.

Singkatnya, browser menjalankan semua potongan kode dalam loop tanpa henti, dengan mengambilnya dari antrean satu per satu. Saat beberapa peristiwa dipicu, browser akan mengantrekan pengendali yang sesuai, dan pada iterasi loop berikutnya, pengendali akan dikeluarkan dari antrean dan dieksekusi. Mekanisme ini memungkinkan simulasi konkurensi dan menjalankan banyak operasi paralel sekaligus hanya menggunakan satu thread.

Hal penting yang perlu diingat tentang mekanisme ini adalah, saat kode JavaScript kustom (atau WebAssembly) Anda dieksekusi, loop peristiwa akan diblokir dan, saat itu terjadi, tidak ada cara untuk bereaksi terhadap pengendali eksternal, peristiwa, I/O, dll. Satu-satunya cara untuk mendapatkan kembali hasil I/O adalah dengan mendaftarkan callback, menyelesaikan eksekusi kode, dan memberikan kontrol kembali ke browser sehingga dapat terus memproses tugas yang tertunda. Setelah I/O selesai, pengendali Anda akan menjadi salah satu tugas tersebut dan akan dijalankan.

Misalnya, jika Anda ingin menulis ulang contoh di atas dalam JavaScript modern dan memutuskan untuk membaca nama dari URL jarak jauh, Anda dapat menggunakan sintaksis Fetch API dan async-await:

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

Meskipun terlihat sinkron, setiap await pada dasarnya adalah sugar sintaksis untuk callback:

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

Dalam contoh yang dihapus gulanya ini, yang sedikit lebih jelas, permintaan dimulai dan respons di-subscribe dengan callback pertama. Setelah menerima respons awal—hanya header HTTP—browser akan memanggil callback ini secara asinkron. Callback mulai membaca isi sebagai teks menggunakan response.text(), dan berlangganan hasil dengan callback lain. Terakhir, setelah fetch mengambil semua konten, fetch akan memanggil callback terakhir, yang mencetak "Halo, (nama pengguna)!" ke konsol.

Berkat sifat asinkron dari langkah-langkah tersebut, fungsi asli dapat mengembalikan kontrol ke browser begitu I/O dijadwalkan, dan membuat seluruh UI responsif dan tersedia untuk tugas lain, termasuk rendering, scroll, dan sebagainya, saat I/O dieksekusi di latar belakang.

Sebagai contoh terakhir, bahkan API sederhana seperti "sleep", yang membuat aplikasi menunggu beberapa detik tertentu, juga merupakan bentuk operasi I/O:

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

Tentu saja, Anda dapat menerjemahkannya dengan cara yang sangat sederhana yang akan memblokir thread saat ini hingga waktu habis:

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

Faktanya, itulah yang dilakukan Emscripten dalam implementasi default "sleep"-nya, tetapi hal itu sangat tidak efisien, akan memblokir seluruh UI dan tidak akan mengizinkan peristiwa lain ditangani sementara itu. Umumnya, jangan lakukan hal itu dalam kode produksi.

Sebagai gantinya, versi "sleep" yang lebih idiomatis di JavaScript akan melibatkan pemanggilan setTimeout(), dan berlangganan dengan pengendali:

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

Apa yang umum untuk semua contoh dan API ini? Dalam setiap kasus, kode idiomatis dalam bahasa sistem asli menggunakan API pemblokiran untuk I/O, sedangkan contoh yang setara untuk web menggunakan API asinkron. Saat mengompilasi ke web, Anda harus mengubah antara kedua model eksekusi tersebut, dan WebAssembly belum memiliki kemampuan bawaan untuk melakukannya.

Menjembatani kesenjangan dengan Asyncify

Di sinilah Asyncify berperan. Asyncify adalah fitur waktu kompilasi yang didukung oleh Emscripten yang memungkinkan menjeda seluruh program dan melanjutkannya secara asinkron nanti.

Grafik panggilan
yang menjelaskan pemanggilan tugas asinkron JavaScript -> WebAssembly -> API web ->, tempat Asyncify menghubungkan
hasil tugas asinkron kembali ke WebAssembly

Penggunaan di C / C++ dengan Emscripten

Jika ingin menggunakan Asyncify untuk mengimplementasikan tidur asinkron untuk contoh terakhir, Anda dapat melakukannya seperti ini:

#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 adalah makro yang memungkinkan penentuan cuplikan JavaScript seolah-olah itu adalah fungsi C. Di dalamnya, gunakan fungsi Asyncify.handleSleep() yang memberi tahu Emscripten untuk menangguhkan program dan menyediakan pengendali wakeUp() yang harus dipanggil setelah operasi asinkron selesai. Pada contoh di atas, pengendali diteruskan ke setTimeout(), tetapi dapat digunakan dalam konteks lain yang menerima callback. Terakhir, Anda dapat memanggil async_sleep() di mana saja yang Anda inginkan, seperti sleep() reguler atau API sinkron lainnya.

Saat mengompilasi kode tersebut, Anda harus memberi tahu Emscripten untuk mengaktifkan fitur Asyncify. Lakukan hal tersebut dengan meneruskan -s ASYNCIFY serta -s ASYNCIFY_IMPORTS=[func1, func2] dengan daftar fungsi seperti array yang mungkin asinkron.

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

Hal ini memungkinkan Emscripten mengetahui bahwa setiap panggilan ke fungsi tersebut mungkin memerlukan penyimpanan dan pemulihan status, sehingga compiler akan memasukkan kode pendukung di sekitar panggilan tersebut.

Sekarang, saat menjalankan kode ini di browser, Anda akan melihat log output yang lancar seperti yang diharapkan, dengan B muncul setelah penundaan singkat setelah A.

A
B

Anda juga dapat menampilkan nilai dari fungsi Asyncify. Yang perlu Anda lakukan adalah menampilkan hasil handleSleep(), dan meneruskan hasilnya ke callback wakeUp(). Misalnya, jika ingin mengambil angka dari resource jarak jauh, alih-alih membaca dari file, Anda dapat menggunakan cuplikan seperti di bawah ini untuk mengajukan permintaan, menangguhkan kode C, dan melanjutkan setelah isi respons diambil—semuanya dilakukan dengan lancar seolah-olah panggilan tersebut sinkron.

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

Bahkan, untuk API berbasis Promise seperti fetch(), Anda bahkan dapat menggabungkan Asyncify dengan fitur async-await JavaScript, bukan menggunakan API berbasis callback. Untuk itu, panggil Asyncify.handleAsync(), bukan Asyncify.handleSleep(). Kemudian, daripada harus menjadwalkan callback wakeUp(), Anda dapat meneruskan fungsi JavaScript async dan menggunakan await dan return di dalamnya, sehingga kode terlihat lebih alami dan sinkron, tanpa kehilangan manfaat I/O asinkron.

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();

Menunggu nilai kompleks

Namun, contoh ini masih membatasi Anda hanya pada angka. Bagaimana jika Anda ingin menerapkan contoh asli, saat saya mencoba mendapatkan nama pengguna dari file sebagai string? Anda juga bisa melakukannya.

Emscripten menyediakan fitur bernama Embind yang memungkinkan Anda menangani konversi antara nilai JavaScript dan C++. Library ini juga memiliki dukungan untuk Asyncify, sehingga Anda dapat memanggil await() pada Promise eksternal dan akan berfungsi seperti await dalam kode 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>();

Saat menggunakan metode ini, Anda bahkan tidak perlu meneruskan ASYNCIFY_IMPORTS sebagai flag kompilasi, karena sudah disertakan secara default.

Oke, jadi semuanya berfungsi dengan baik di Emscripten. Bagaimana dengan toolchain dan bahasa lainnya?

Penggunaan dari bahasa lain

Misalnya, Anda memiliki panggilan sinkron serupa di suatu tempat dalam kode Rust yang ingin dipetakan ke API asinkron di web. Ternyata, Anda juga dapat melakukannya.

Pertama, Anda perlu menentukan fungsi tersebut sebagai impor reguler melalui blok extern (atau sintaksis bahasa yang Anda pilih untuk fungsi asing).

extern {
    fn get_answer() -> i32;
}

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

Lalu kompilasi kode Anda ke WebAssembly:

cargo build --target wasm32-unknown-unknown

Sekarang Anda perlu melengkapi file WebAssembly dengan kode untuk menyimpan/memulihkan stack. Untuk C/C++, Emscripten akan melakukannya untuk kita, tetapi tidak digunakan di sini, sehingga prosesnya sedikit lebih manual.

Untungnya, transformasi Asyncify itu sendiri sepenuhnya tidak bergantung pada toolchain. WebAssembly dapat mengubah file WebAssembly arbitrer, apa pun compiler yang dihasilkannya. Transformasi disediakan secara terpisah sebagai bagian dari pengoptimal wasm-opt dari toolchain Binaryen dan dapat dipanggil seperti ini:

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

Teruskan --asyncify untuk mengaktifkan transformasi, lalu gunakan --pass-arg=… untuk memberikan daftar fungsi asinkron yang dipisahkan koma, tempat status program harus ditangguhkan lalu dilanjutkan.

Yang tersisa hanyalah menyediakan kode runtime pendukung yang benar-benar akan melakukannya—menangguhkan dan melanjutkan kode WebAssembly. Sekali lagi, dalam kasus C/C++, hal ini akan disertakan oleh Emscripten, tetapi sekarang Anda memerlukan kode glue JavaScript kustom yang akan menangani file WebAssembly arbitrer. Kami telah membuat library khusus untuk hal tersebut.

Anda dapat menemukannya di GitHub di https://github.com/GoogleChromeLabs/asyncify atau npm dengan nama asyncify-wasm.

API ini menyimulasikan API pembuatan instance WebAssembly standar, tetapi dalam namespace-nya sendiri. Satu-satunya perbedaan adalah, dalam WebAssembly API reguler, Anda hanya dapat menyediakan fungsi sinkron sebagai impor, sedangkan dalam wrapper Asyncify, Anda juga dapat menyediakan impor asinkron:

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();

Setelah Anda mencoba memanggil fungsi asinkron - seperti get_answer() dalam contoh di atas - dari sisi WebAssembly, library akan mendeteksi Promise yang ditampilkan, menangguhkan dan menyimpan status aplikasi WebAssembly, berlangganan ke penyelesaian promise, dan kemudian, setelah terselesaikan, pulihkan stack panggilan dan status serta melanjutkan eksekusi seolah-olah tidak ada yang terjadi.

Karena setiap fungsi dalam modul dapat melakukan panggilan asinkron, semua ekspor juga berpotensi asinkron, sehingga dienkapsulasi juga. Anda mungkin telah melihat dalam contoh di atas bahwa Anda harus await hasil instance.exports.main() untuk mengetahui kapan eksekusi benar-benar selesai.

Bagaimana cara kerjanya di balik layar?

Saat mendeteksi panggilan ke salah satu fungsi ASYNCIFY_IMPORTS, Asyncify akan memulai operasi asinkron, menyimpan seluruh status aplikasi, termasuk stack panggilan dan lokal sementara, lalu, setelah operasi tersebut selesai, memulihkan semua memori dan stack panggilan, serta melanjutkan dari tempat yang sama dan dengan status yang sama seolah-olah program tidak pernah berhenti.

Ini sangat mirip dengan fitur async-await di JavaScript yang saya tampilkan sebelumnya, tetapi, tidak seperti JavaScript, tidak memerlukan sintaksis atau dukungan runtime khusus dari bahasa, dan sebagai gantinya berfungsi dengan mengubah fungsi sinkron biasa pada waktu kompilasi.

Saat mengompilasi contoh tidur asinkron yang ditampilkan sebelumnya:

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

Asyncify mengambil kode ini dan mengubahnya menjadi kira-kira seperti kode berikut (pseudo-code, transformasi sebenarnya lebih rumit dari ini):

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

Awalnya, mode disetel ke NORMAL_EXECUTION. Demikian pula, saat pertama kali kode yang diubah tersebut dijalankan, hanya bagian yang mengarah ke async_sleep() yang akan dievaluasi. Segera setelah operasi asinkron dijadwalkan, Asyncify akan menyimpan semua lokal, dan memutar ulang stack dengan kembali dari setiap fungsi hingga ke bagian atas, sehingga memberikan kontrol kembali ke loop peristiwa browser.

Kemudian, setelah async_sleep() selesai, kode dukungan Asynchronousify akan mengubah mode menjadi REWINDING, dan memanggil fungsi tersebut lagi. Kali ini, cabang "eksekusi normal" dilewati - karena sudah melakukan tugas terakhir kali dan saya ingin menghindari pencetakan "A" dua kali - dan sebagai gantinya langsung ke cabang "memundurkan". Setelah dicapai, kode ini akan memulihkan semua lokal yang disimpan, mengubah mode kembali ke "normal", dan melanjutkan eksekusi seolah-olah kode tidak pernah dihentikan sejak awal.

Biaya transformasi

Sayangnya, transformasi Asyncify tidak sepenuhnya gratis karena harus memasukkan cukup banyak kode pendukung untuk menyimpan dan memulihkan semua lokal tersebut, menavigasi stack panggilan dalam mode yang berbeda, dan seterusnya. Fungsi ini mencoba mengubah hanya fungsi yang ditandai sebagai asinkron di command line, serta setiap pemanggil potensialnya, tetapi overhead ukuran kode mungkin masih bertambah hingga sekitar 50% sebelum kompresi.

Grafik yang menunjukkan overhead
ukuran kode untuk berbagai benchmark, dari hampir 0% dalam kondisi yang disesuaikan dengan baik hingga lebih dari 100% dalam kasus
terburuk

Hal ini tidak ideal, tetapi dalam banyak kasus dapat diterima jika alternatifnya adalah tidak memiliki fungsi sama sekali atau harus melakukan penulisan ulang yang signifikan pada kode asli.

Pastikan untuk selalu mengaktifkan pengoptimalan untuk build akhir agar tidak lebih tinggi lagi. Anda juga dapat memeriksa opsi pengoptimalan khusus Asyncify untuk mengurangi overhead dengan membatasi transformasi hanya ke fungsi yang ditentukan dan/atau hanya panggilan fungsi langsung. Selain itu, ada biaya kecil untuk performa runtime, tetapi hal ini terbatas pada panggilan asinkron itu sendiri. Namun, dibandingkan dengan biaya pekerjaan yang sebenarnya, biaya tersebut biasanya dapat diabaikan.

Demo di dunia nyata

Setelah Anda melihat contoh sederhana, saya akan melanjutkan ke skenario yang lebih rumit.

Seperti yang disebutkan di awal artikel, salah satu opsi penyimpanan di web adalah File System Access API asinkron. Fitur ini menyediakan akses ke sistem file host sebenarnya dari aplikasi web.

Di sisi lain, ada standar de facto yang disebut WASI untuk I/O WebAssembly di konsol dan sisi server. Library ini dirancang sebagai target kompilasi untuk bahasa sistem, dan mengekspos semua jenis sistem file dan operasi lainnya dalam bentuk sinkron tradisional.

Bagaimana jika Anda dapat memetakan satu ke yang lain? Kemudian, Anda dapat mengompilasi aplikasi apa pun dalam bahasa sumber apa pun dengan toolchain yang mendukung target WASI, dan menjalankannya dalam sandbox di web, sambil tetap mengizinkannya untuk beroperasi di file pengguna sungguhan. Dengan Asyncify, Anda dapat melakukannya.

Dalam demo ini, saya telah mengompilasi Rust coreutils crate dengan beberapa patch minor ke WASI, yang diteruskan melalui transformasi Asyncify dan menerapkan binding asinkron dari WASI ke File System Access API di sisi JavaScript. Setelah digabungkan dengan komponen terminal Xterm.js, ini akan memberikan shell realistis yang berjalan di tab browser dan beroperasi pada file pengguna yang sebenarnya - seperti terminal yang sebenarnya.

Lihat live di https://wasi.rreverser.com/.

Kasus penggunaan asinkron tidak terbatas hanya pada timer dan sistem file. Anda dapat melakukan lebih banyak hal dan menggunakan API khusus lainnya di web.

Misalnya, juga dengan bantuan Asyncify, Anda dapat memetakan libusb—mungkin library native paling populer untuk menggunakan perangkat USB—ke WebUSB API, yang memberikan akses asinkron ke perangkat tersebut di web. Setelah dipetakan dan dikompilasi, saya mendapatkan contoh dan pengujian libusb standar untuk dijalankan pada perangkat yang dipilih langsung di sandbox halaman web.

Screenshot output debug libusb
di halaman web, yang menampilkan informasi tentang kamera Canon yang terhubung

Namun, mungkin ini adalah cerita untuk postingan blog lain.

Contoh-contoh tersebut menunjukkan betapa canggihnya Asyncify untuk menjembatani kesenjangan dan mem-port semua jenis aplikasi ke web, sehingga Anda dapat mendapatkan akses lintas platform, sandboxing, dan keamanan yang lebih baik, semuanya tanpa kehilangan fungsi.