Menggunakan API web asinkron dari WebAssembly

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

I/O dalam bahasa sistem

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

#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 contohnya tidak banyak membantu, contoh ini sudah menunjukkan sesuatu yang akan Anda temukan di aplikasi dalam berbagai ukuran: 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 {i> input-output<i}, yang juga disingkat menjadi I/O.

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

Sekilas fungsi tersebut terlihat cukup sederhana dan Anda tidak perlu berpikir dua kali tentang mesin yang diperlukan untuk membaca atau menulis data. Namun, tergantung pada lingkungannya, bisa jadi banyak hal yang terjadi di dalamnya:

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

Singkatnya, I/O bisa jadi lambat dan Anda tidak dapat memprediksi berapa lama panggilan tertentu akan berlangsung melihat sekilas kodenya. Ketika operasi tersebut berjalan, seluruh aplikasi akan tampak berhenti berfungsi dan tidak responsif kepada pengguna.

Ini tidak terbatas pada C atau C++ saja. Sebagian besar bahasa sistem menyajikan semua I/O dalam bentuk API sinkron. Misalnya, jika Anda menerjemahkan contoh ke Rust, API mungkin terlihat lebih sederhana, tetapi prinsip yang sama tetap berlaku. Anda cukup melakukan panggilan dan secara sinkron menunggunya menampilkan hasilnya, saat melakukan semua operasi yang mahal dan pada akhirnya mengembalikan hasilnya dalam satu pemanggilan:

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

Tetapi apa yang terjadi ketika Anda mencoba untuk mengompilasi salah satu contoh itu ke WebAssembly dan menerjemahkannya web? Atau, untuk memberikan contoh tertentu, apa yang bisa "{i>file read<i}" operasi penerjemahan? Ini akan perlu membaca data dari suatu penyimpanan.

Model web asinkron

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

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

Ini adalah salah satu properti inti dalam mengeksekusi kode di web: semua operasi yang memakan waktu, mencakup semua I/O, harus asinkron.

Alasannya adalah karena web secara historis memiliki thread tunggal, dan setiap kode pengguna yang menyentuh UI harus berjalan di thread yang sama dengan UI. Program itu harus bersaing dengan tugas-tugas penting lainnya seperti tata letak, rendering, dan penanganan peristiwa untuk waktu CPU. Anda tidak ingin bagian JavaScript atau WebAssembly agar dapat memulai "pembacaan file" operasi dan memblokir yang lainnya—seluruh tab, atau, di masa lalu, seluruh browser—selama rentang dari milidetik hingga beberapa detik, hingga semua ini berakhir.

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

Singkatnya, {i>browser<i} menjalankan semua potongan kode dalam semacam loop tanpa henti, dengan mengambilnya dari antrean satu per satu. Saat beberapa peristiwa dipicu, browser akan mengantrekan yang sesuai, dan pada iterasi loop berikutnya, itu diambil dari antrean dan dieksekusi. Mekanisme ini memungkinkan simulasi konkurensi dan menjalankan banyak operasi paralel hanya dengan menggunakan pada sebuah thread tunggal.

Hal penting yang harus diingat tentang mekanisme ini adalah, meskipun JavaScript kustom Anda (atau WebAssembly) dijalankan, loop peristiwa diblokir dan, meskipun demikian, tidak ada cara untuk bereaksi terhadap setiap pengendali eksternal, event, I/O, dll. Satu-satunya cara untuk mendapatkan kembali hasil I/O adalah dengan mendaftarkan callback, selesaikan eksekusi kode Anda, dan berikan kembali kontrol ke browser sehingga browser bisa menjaga memproses setiap tugas yang tertunda. Setelah I/O selesai, pengendali Anda akan menjadi salah satu dari tugas tersebut dan akan dieksekusi.

Misalnya, jika Anda ingin menulis ulang contoh di atas dalam JavaScript modern dan memutuskan untuk membaca dari URL jarak jauh, Anda akan 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 de-sugaring ini, yang sedikit lebih jelas, permintaan dimulai dan respons berlangganan dengan callback pertama. Setelah browser menerima respons awal—hanya HTTP —header akan memanggil callback ini secara asinkron. Callback mulai membaca isi sebagai teks menggunakan response.text(), dan berlangganan hasil dengan callback lain. Terakhir, setelah fetch memiliki mengambil semua konten, kode ini akan memanggil callback terakhir, yang mencetak "Hello, (username)!" ke konsol.

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

Sebagai contoh terakhir, bahkan API sederhana seperti "sleep" (tidur), yang membuat aplikasi menunggu jumlah detik, juga merupakan bentuk operasi I/O:

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

Tentu, Anda dapat menerjemahkannya dengan cara yang sangat mudah yang akan memblokir thread saat ini hingga waktunya berakhir:

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

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

Sebaliknya, versi yang lebih idiomatis dari "sleep" 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 metode bahasa sistem menggunakan API pemblokiran untuk I/O, sedangkan contoh yang setara untuk web asinkron API sebagai gantinya. Saat mengompilasi ke web, Anda perlu mengubah keduanya dan WebAssembly belum memiliki kemampuan bawaan untuk melakukannya.

Menjembatani kesenjangan dengan Asyncify

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

Grafik panggilan
menjelaskan JavaScript -> WebAssembly -> API web -> pemanggilan tugas asinkron, dengan Asyncify terhubung
hasil tugas asinkron kembali ke WebAssembly

Penggunaan dalam C / C++ dengan Emscripten

Jika Anda ingin menggunakan Asyncify untuk menerapkan 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 yang memungkinkan penetapan cuplikan JavaScript seolah-olah cuplikan tersebut adalah fungsi C. Di dalam, 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 panggil async_sleep() di mana pun Anda mau seperti sleep() biasa atau API sinkron lainnya.

Saat mengompilasi kode tersebut, Anda perlu memberi tahu Emscripten untuk mengaktifkan fitur Asyncify. Lakukan dengan yang 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 memberi tahu Emscripten bahwa setiap panggilan ke fungsi tersebut mungkin memerlukan penyimpanan dan pemulihan , jadi compiler akan memasukkan kode pendukung di sekitar panggilan tersebut.

Sekarang, ketika Anda mengeksekusi kode ini di {i>browser<i}, Anda akan melihat log {i>output<i} yang mulus seperti yang Anda harapkan, dengan B muncul setelah penundaan singkat setelah A.

A
B

Anda dapat menampilkan nilai dari Asynchronousify juga. Apa yang perlu Anda lakukan adalah menampilkan hasil handleSleep(), lalu meneruskan hasilnya ke wakeUp() . Misalnya, jika, alih-alih membaca dari file, Anda ingin mengambil nomor dari remote Anda dapat menggunakan cuplikan seperti di bawah ini untuk mengeluarkan permintaan, menangguhkan kode C, dan dilanjutkan 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 mengombinasikan Asyncify dengan atribut JavaScript async-await, bukan menggunakan API berbasis callback. Untuk itu, alih-alih hanya Asyncify.handleSleep(), panggil Asyncify.handleAsync(). Kemudian, alih-alih harus menjadwalkan Callback wakeUp(), Anda dapat meneruskan fungsi JavaScript async serta menggunakan await dan return di dalamnya, membuat kode terlihat lebih alami dan sinkron, tanpa kehilangan manfaat apa pun dari 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 yang kompleks

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

Emscripten menyediakan fitur yang disebut Embind yang memungkinkan Anda untuk menangani konversi antara nilai JavaScript dan C++. Algoritma ini juga mendukung Asyncify, jadi Anda dapat memanggil await() di Promise eksternal dan kode ini akan berfungsi seperti await di async-await Kode 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>();

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

Jadi, semua ini berjalan dengan baik di Emscripten. Bagaimana dengan toolchain dan bahasa lainnya?

Penggunaan dari bahasa lain

Misalnya Anda memiliki panggilan sinkron yang serupa di suatu tempat dalam kode Rust yang ingin dipetakan ke async di web. Ternyata, Anda juga bisa melakukannya!

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

extern {
    fn get_answer() -> i32;
}

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

Dan 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 / Di {i>C++<i}, Emscripten akan melakukannya untuk kita, tetapi tidak digunakan di sini, sehingga prosesnya sedikit lebih manual.

Untungnya, transformasi Asyncify itu sendiri sepenuhnya kompatibel dengan toolchain. Dapat mengubah arbitrer WebAssembly, apa pun compiler yang digunakan. Transformasi disediakan secara terpisah sebagai bagian dari pengoptimal wasm-opt dari Binaryen toolchain 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 file yang dipisahkan koma daftar fungsi asinkron, di mana status program harus ditangguhkan dan kemudian dilanjutkan.

Terakhir, Anda hanya menyediakan kode runtime pendukung yang benar-benar akan melakukannya—menangguhkan dan melanjutkan kode WebAssembly. Sekali lagi, dalam kasus C / C++ ini akan disertakan oleh Emscripten, tetapi sekarang Anda perlu kode {i>glue<i} khusus JavaScript yang akan menangani file WebAssembly arbitrer. Kita telah membuat perpustakaan hanya untuk itu.

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

Alat ini menyimulasikan pembuatan instance WebAssembly standar API, tetapi dengan namespace-nya sendiri. Satu-satunya perbedaannya adalah, di bawah WebAssembly API reguler, Anda hanya dapat menyediakan fungsi sinkron impor asinkron, sedangkan pada 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 tersebut - seperti get_answer() dalam contoh di atas - dari di sisi WebAssembly, library akan mendeteksi Promise yang ditampilkan, menangguhkan dan menyimpan aplikasi WebAssembly, berlangganan untuk pelaksanaan janji, dan kemudian, setelah itu diselesaikan, memulihkan stack panggilan dan status dengan lancar, serta melanjutkan eksekusi seolah-olah tidak ada yang terjadi.

Karena fungsi apa pun dalam modul mungkin melakukan panggilan asinkron, semua ekspor berpotensi asinkron, sehingga mereka juga terbungkus. Anda mungkin telah memperhatikan contoh di atas bahwa Anda perlu await hasil instance.exports.main() untuk mengetahui kapan eksekusi benar-benar selesai.

Bagaimana cara kerja semua ini di balik layar?

Saat mendeteksi panggilan ke salah satu fungsi ASYNCIFY_IMPORTS, Asyncify akan memulai aktivitas asinkron menyimpan seluruh status aplikasi, termasuk stack panggilan dan semua status lokal, dan setelahnya, ketika operasi itu selesai, memulihkan semua memori dan tumpukan panggilan dan dilanjutkan 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 tunjukkan sebelumnya, tetapi tidak seperti JavaScript satu, tidak memerlukan sintaksis khusus atau dukungan runtime dari bahasa, dan sebagai gantinya bekerja dengan mengubah fungsi sinkron polos 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 kurang lebih seperti berikut ini (kode semu, kode asli yang lebih kompleks):

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

Awalnya, mode ditetapkan ke NORMAL_EXECUTION. Sejalan dengan itu, pertama kali kode yang diubah seperti itu dieksekusi, hanya bagian yang mengarah ke async_sleep() yang akan dievaluasi. Segera setelah asinkron dijadwalkan, Asyncify menyimpan semua lokal, dan melepaskan tumpukan dengan kembali dari setiap fungsi sampai ke bagian atas, sehingga memberikan kontrol kembali ke browser {i>loop <i}peristiwa.

Kemudian, setelah async_sleep() selesai, kode dukungan Asynchronousify akan mengubah mode menjadi REWINDING, dan memanggil fungsi itu lagi. Kali ini, "eksekusi normal" cabang dilewati - karena sudah dilakukan pekerjaan itu terakhir kali dan saya ingin menghindari mencetak "A" dua kali - dan sebagai gantinya, langsung masuk ke "memundurkan" . Setelah tercapai, tindakan 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 tumpukan panggilan di mode yang berbeda dan seterusnya. Mencoba mengubah hanya fungsi yang ditandai sebagai asinkron pada perintah baris, serta pemanggil potensial mereka, namun {i>overhead<i} ukuran kode mungkin masih bertambah hingga sekitar 50% sebelum kompresi.

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

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

Pastikan untuk selalu mengaktifkan pengoptimalan pada build akhir agar tidak lebih tinggi. Anda dapat lihat juga Pengoptimalan khusus asinkron opsi untuk mengurangi overhead sebesar membatasi transformasi hanya untuk fungsi yang ditentukan dan/atau hanya panggilan fungsi langsung. Tersedia juga sedikit biaya untuk performa runtime, tetapi hal ini terbatas pada panggilan asinkron itu sendiri. Namun, dibandingkan dengan biaya pekerjaan yang sebenarnya, itu biasanya dapat diabaikan.

Demo di dunia nyata

Sekarang setelah Anda melihat contoh sederhana, saya akan beralih ke skenario yang lebih rumit.

Seperti yang disebutkan di awal artikel, salah satu opsi penyimpanan di web adalah File System Access API asinkron. Health Connect memberikan akses ke sistem file {i>host<i} yang sebenarnya dari aplikasi web.

Di sisi lain, ada standar de-facto yang disebut WASI untuk WebAssembly I/O di konsol dan sisi server. Alat ini dirancang sebagai target kompilasi untuk bahasa sistem, dan mengekspos segala macam sistem file dan operasi lain atau bentuk sinkron.

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 memungkinkannya untuk beroperasi pada file pengguna nyata! Anda dapat melakukannya dengan Asyncify.

Dalam demo ini, saya telah mengumpulkan peti coreutils Rust dengan beberapa patch kecil ke WASI, diteruskan melalui transformasi Asyncify dan diterapkan secara binding dari WASI ke File System Access API pada sisi JavaScript. Setelah dikombinasikan dengan Xterm.js, ini menyediakan shell realistis yang berjalan di tab {i>browser<i} dan beroperasi pada file pengguna nyata - seperti terminal yang sebenarnya.

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

Kasus penggunaan asinkron tidak terbatas hanya pada timer dan sistem file. Anda dapat melangkah lebih jauh dan menggunakan API yang lebih khusus di web.

Misalnya, dengan bantuan Asyncify, kita bisa memetakan libusb—mungkin library native yang paling populer untuk digunakan Perangkat USB—ke WebUSB API, yang memberikan akses asinkron ke perangkat tersebut di web. Setelah dipetakan dan dikompilasi, saya mendapatkan pengujian dan contoh libusb standar untuk dijalankan terhadap perangkat langsung di {i>sandbox<i} halaman web.

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

Mungkin ini adalah cerita untuk postingan blog lain.

Contoh-contoh tersebut menunjukkan betapa ampuhnya Asyncify untuk menjembatani kesenjangan dan mentransfer semua data semacam aplikasi ke web, yang memungkinkan Anda untuk mendapatkan akses lintas platform, {i>sandbox<i}, dan keamanan, semuanya tanpa kehilangan fungsinya.