Pelajari cara menghadirkan aplikasi multithread yang ditulis dalam bahasa lain ke WebAssembly.
Dukungan thread WebAssembly adalah salah satu tambahan performa yang paling penting untuk WebAssembly. Dengan ini, Anda dapat menjalankan bagian kode secara paralel pada inti terpisah, atau kode yang sama pada bagian data input independen, menskalakannya ke sebanyak inti yang dimiliki pengguna dan secara signifikan mengurangi keseluruhan waktu eksekusi.
Dalam artikel ini, Anda akan mempelajari cara menggunakan thread WebAssembly untuk menghadirkan aplikasi multi-thread yang ditulis dalam bahasa seperti C, C++, dan Rust ke web.
Cara kerja thread WebAssembly
Thread WebAssembly bukan fitur terpisah, tetapi merupakan kombinasi dari beberapa komponen yang memungkinkan aplikasi WebAssembly menggunakan paradigma multithreading tradisional di web.
Web Worker
Komponen pertama adalah
Pekerja reguler yang Anda kenal dan
sukai dari JavaScript. Thread WebAssembly menggunakan konstruktor new Worker
untuk membuat thread dasar
yang baru. Setiap thread memuat glue JavaScript, lalu thread utama menggunakan
metode Worker#postMessage
untuk
membagikan
WebAssembly.Module
yang telah dikompilasi serta
WebAssembly.Memory
bersama (lihat di bawah) dengan thread lainnya. Tindakan ini akan menjalin komunikasi dan memungkinkan semua thread tersebut
menjalankan kode WebAssembly yang sama pada memori bersama yang sama tanpa melalui JavaScript lagi.
Pekerja Web telah ada selama lebih dari satu dekade saat ini, didukung secara luas, dan tidak memerlukan tanda khusus.
SharedArrayBuffer
Memori WebAssembly direpresentasikan oleh objek WebAssembly.Memory
di JavaScript API. Secara default, WebAssembly.Memory
adalah wrapper di sekitar
ArrayBuffer
—buffer byte
raw yang hanya dapat diakses oleh satu thread.
> new WebAssembly.Memory({ initial:1, maximum:10 }).buffer
ArrayBuffer { … }
Untuk mendukung multithreading, WebAssembly.Memory
juga mendapatkan varian bersama. Jika dibuat dengan flag shared
melalui JavaScript API, atau oleh biner WebAssembly itu sendiri, flag tersebut akan menjadi wrapper di sekitar SharedArrayBuffer
. Ini adalah variasi ArrayBuffer
yang dapat dibagikan ke thread lain dan dibaca atau
diubah secara bersamaan dari salah satu sisi.
> new WebAssembly.Memory({ initial:1, maximum:10, shared:true }).buffer
SharedArrayBuffer { … }
Tidak seperti postMessage
, yang biasanya digunakan
untuk komunikasi antara thread utama dan Pekerja Web,
SharedArrayBuffer
tidak perlu menyalin data atau bahkan menunggu loop peristiwa mengirim dan menerima pesan.
Sebagai gantinya, setiap perubahan terlihat oleh semua thread hampir secara instan, yang menjadikannya target kompilasi
yang jauh lebih baik untuk primitif sinkronisasi tradisional.
SharedArrayBuffer
memiliki sejarah yang rumit. Fitur ini awalnya dikirim dalam beberapa browser pada pertengahan tahun 2017, tetapi harus dinonaktifkan pada awal tahun 2018 karena ditemukannya kerentanan Spectre. Alasan khususnya
adalah ekstraksi data di Spectre bergantung pada serangan pengaturan waktu, yaitu mengukur waktu eksekusi
bagian kode tertentu. Untuk membuat serangan semacam ini lebih sulit, browser mengurangi presisi API waktu standar seperti Date.now
dan performance.now
. Namun, memori bersama, dikombinasikan dengan
loop penghitung sederhana yang berjalan di thread terpisah, juga merupakan cara yang sangat andal untuk mendapatkan waktu
presisi tinggi, dan menjadi jauh lebih sulit untuk dimitigasi tanpa
membatasi performa runtime secara signifikan.
Sebagai gantinya, Chrome 68 (pertengahan 2018) mengaktifkan kembali SharedArrayBuffer
dengan memanfaatkan Isolasi
Situs—fitur yang menempatkan
situs yang berbeda ke dalam proses yang berbeda dan mempersulit penggunaan serangan
samping saluran seperti Spectre. Namun, mitigasi ini masih terbatas hanya untuk desktop Chrome, karena Isolasi
Situs adalah fitur yang cukup mahal, dan tidak dapat diaktifkan secara default untuk semua situs di
perangkat seluler bermemori rendah atau belum diterapkan oleh vendor lain.
Menjelang tahun 2020, Chrome dan Firefox memiliki implementasi Isolasi Situs, dan cara standar bagi situs untuk mengaktifkan fitur ini dengan header COOP dan COEP. Mekanisme keikutsertaan memungkinkan penggunaan Isolasi Situs bahkan pada perangkat berdaya rendah yang mengaktifkannya untuk semua situs akan menjadi terlalu mahal. Untuk ikut serta, tambahkan header berikut ke dokumen utama di konfigurasi server Anda:
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
Setelah memilih ikut serta, Anda akan mendapatkan akses ke SharedArrayBuffer
(termasuk WebAssembly.Memory
yang didukung oleh
SharedArrayBuffer
), timer yang presisi, pengukuran memori, dan API lain yang memerlukan origin
yang terisolasi untuk alasan keamanan. Lihat artikel Membuat situs Anda "diisolasi lintas origin" menggunakan COOP
dan COEP untuk detail selengkapnya.
Atom WebAssembly
Meskipun SharedArrayBuffer
memungkinkan setiap thread membaca dan menulis ke memori yang sama, untuk komunikasi yang benar, Anda perlu memastikan bahwa thread tidak melakukan operasi yang bertentangan secara bersamaan. Misalnya, satu thread
dapat mulai membaca data dari alamat bersama, sementara thread lain menulis
ke alamat tersebut, sehingga thread pertama kini akan mendapatkan hasil yang rusak. Kategori bug ini dikenal sebagai kondisi
race. Untuk mencegah kondisi race, Anda perlu menyinkronkan akses tersebut.
Di sinilah operasi atom berperan.
WebAssembly atomic adalah ekstensi untuk set petunjuk WebAssembly yang memungkinkan pembacaan dan penulisan sel kecil data (biasanya bilangan bulat 32 dan 64-bit) secara "atomik". Artinya, dengan cara yang menjamin bahwa tidak ada dua thread yang membaca atau menulis ke sel yang sama secara bersamaan, sehingga mencegah konflik tersebut pada tingkat yang rendah. Selain itu, atomik WebAssembly berisi dua jenis petunjuk lainnya—"wait" dan "notify"—yang memungkinkan satu thread tidur ("menunggu") pada alamat tertentu dalam memori bersama hingga thread lain mengaktifkannya melalui "pemberitahuan".
Semua primitif sinkronisasi di tingkat yang lebih tinggi, termasuk saluran, mutex, dan kunci baca-tulis dibuat berdasarkan petunjuk tersebut.
Cara menggunakan thread WebAssembly
Deteksi fitur
Atom WebAssembly dan SharedArrayBuffer
merupakan fitur yang relatif baru dan belum tersedia di semua browser yang memiliki dukungan WebAssembly. Anda dapat menemukan browser yang mendukung fitur WebAssembly baru dalam roadmap webassembly.org.
Untuk memastikan semua pengguna dapat memuat aplikasi, Anda harus menerapkan progressive enhancement dengan membuat dua versi Wasm yang berbeda—satu dengan dukungan multithreading dan satu tanpanya. Kemudian, muat versi yang didukung bergantung pada hasil deteksi fitur. Untuk mendeteksi dukungan thread WebAssembly saat runtime, gunakan library wasm-feature-detectdan muat modul seperti ini:
import { threads } from 'wasm-feature-detect';
const hasThreads = await threads();
const module = await (
hasThreads
? import('./module-with-threads.js')
: import('./module-without-threads.js')
);
// …now use `module` as you normally would
Sekarang mari kita lihat cara mem-build modul WebAssembly versi multi-thread.
C
Di C, khususnya pada sistem yang serupa dengan Unix, cara umum untuk menggunakan thread adalah melalui POSIX
Threads yang disediakan oleh library pthread
. Emscripten
menyediakan implementasi yang kompatibel dengan API
dari library pthread
yang dibangun di atas Web Worker, memori bersama, dan atomik, sehingga kode yang sama dapat
berfungsi di web tanpa perubahan.
Mari kita lihat contoh berikut:
example.c:
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
void *thread_callback(void *arg)
{
sleep(1);
printf("Inside the thread: %d\n", *(int *)arg);
return NULL;
}
int main()
{
puts("Before the thread");
pthread_t thread_id;
int arg = 42;
pthread_create(&thread_id, NULL, thread_callback, &arg);
pthread_join(thread_id, NULL);
puts("After the thread");
return 0;
}
Di sini, header untuk library pthread
disertakan melalui pthread.h
. Anda juga dapat melihat beberapa
fungsi penting untuk menangani thread.
pthread_create
akan membuat
thread latar belakang. Dibutuhkan tujuan untuk menyimpan handle thread, beberapa atribut pembuatan thread (di sini tidak meneruskan apa pun, jadi hanya NULL
), callback yang akan dieksekusi di thread baru (di sini thread_callback
), dan pointer argumen opsional untuk diteruskan ke callback tersebut jika Anda ingin membagikan beberapa data dari thread utama. Dalam contoh ini, kami membagikan pointer ke variabel arg
.
pthread_join
dapat dipanggil nanti
kapan saja untuk menunggu thread menyelesaikan eksekusi, dan mendapatkan hasil yang ditampilkan dari
callback. Fungsi ini menerima handle thread yang ditetapkan sebelumnya serta pointer untuk menyimpan hasilnya. Dalam
hal ini, tidak ada hasil apa pun sehingga fungsi tersebut menggunakan NULL
sebagai argumen.
Untuk mengompilasi kode menggunakan thread dengan Emscripten, Anda perlu memanggil emcc
dan meneruskan parameter -pthread
, seperti saat mengompilasi kode yang sama dengan Clang atau GCC di platform lain:
emcc -pthread example.c -o example.js
Namun, saat mencoba menjalankannya di browser atau Node.js, Anda akan melihat peringatan, lalu program akan hang:
Before the thread
Tried to spawn a new thread, but the thread pool is exhausted.
This might result in a deadlock unless some threads eventually exit or the code
explicitly breaks out to the event loop.
If you want to increase the pool size, use setting `-s PTHREAD_POOL_SIZE=...`.
If you want to throw an explicit error instead of the risk of deadlocking in those
cases, use setting `-s PTHREAD_POOL_SIZE_STRICT=2`.
[…hangs here…]
What happened? Masalahnya adalah, sebagian besar API yang memakan waktu di web bersifat asinkron dan mengandalkan loop peristiwa untuk dijalankan. Batasan ini merupakan perbedaan penting dibandingkan dengan lingkungan tradisional, tempat aplikasi biasanya menjalankan I/O secara sinkron dan memblokir. Lihat postingan blog tentang Menggunakan API web asinkron dari WebAssembly jika Anda ingin mempelajari lebih lanjut.
Dalam hal ini, kode secara sinkron memanggil pthread_create
untuk membuat thread latar belakang, dan
diikuti dengan panggilan sinkron lainnya ke pthread_join
yang menunggu thread latar belakang
menyelesaikan eksekusi. Namun, Pekerja Web, yang digunakan di balik layar saat kode ini dikompilasi
dengan Emscripten, bersifat asinkron. Jadi, pthread_create
hanya menjadwalkan thread Pekerja baru yang akan dibuat di loop peristiwa berikutnya, tetapi pthread_join
akan langsung memblokir loop peristiwa untuk menunggu Pekerja tersebut, dan dengan demikian mencegahnya dibuat. Ini adalah contoh klasik dari deadlock.
Salah satu cara untuk mengatasi masalah ini adalah dengan membuat kumpulan Pekerja terlebih dahulu, sebelum program
dimulai. Saat dipanggil, pthread_create
dapat mengambil Pekerja yang siap digunakan dari kumpulan, menjalankan callback yang disediakan di thread latar belakangnya, dan menampilkan Pekerja kembali ke kumpulan. Semua ini
dapat dilakukan secara sinkron, sehingga tidak akan ada deadlock selama kumpulan cukup
besar.
Inilah yang diizinkan Emscripten dengan opsi -s
PTHREAD_POOL_SIZE=...
. Hal ini memungkinkan untuk
menentukan sejumlah thread—baik angka tetap, atau ekspresi JavaScript seperti
navigator.hardwareConcurrency
untuk membuat thread sebanyak jumlah inti pada CPU. Opsi kedua berguna jika kode Anda
dapat diskalakan ke jumlah thread yang arbitrer.
Pada contoh di atas, hanya ada satu thread yang dibuat. Jadi, Anda cukup menggunakan -s PTHREAD_POOL_SIZE=1
, bukan mencadangkan semua core:
emcc -pthread -s PTHREAD_POOL_SIZE=1 example.c -o example.js
Kali ini, ketika Anda menjalankannya, semuanya akan berhasil:
Before the thread
Inside the thread: 42
After the thread
Pthread 0x701510 exited.
Namun ada masalah lain: lihat sleep(1)
tersebut dalam contoh kode? Library ini dijalankan di callback
thread, artinya berada di luar thread utama, sehingga seharusnya tidak ada masalah, bukan? sebenarnya tidak.
Saat pthread_join
dipanggil, thread utama juga harus menunggu hingga eksekusi thread selesai, yang berarti jika
thread yang dibuat masih melakukan tugas yang berjalan lama—dalam hal ini, tidur selama 1 detik—thread utama
juga harus memblokir selama durasi waktu yang sama hingga hasilnya kembali. Saat dijalankan di browser, JS ini akan memblokir UI thread selama 1 detik hingga callback thread muncul. Hal ini menyebabkan pengalaman pengguna yang buruk.
Ada beberapa solusi untuk hal ini:
pthread_detach
-s PROXY_TO_PTHREAD
- Pekerja Kustom dan Comlink
pthread_detach
Pertama, jika Anda hanya perlu menjalankan beberapa tugas di luar thread utama, tetapi tidak perlu menunggu
hasilnya, Anda dapat menggunakan pthread_detach
,
bukan pthread_join
. Tindakan ini akan membuat callback thread berjalan di latar belakang. Jika menggunakan opsi ini, Anda dapat menonaktifkan peringatan dengan -s
PTHREAD_POOL_SIZE_STRICT=0
.
PROXY_TO_PTHREAD
Kedua, jika mengompilasi aplikasi C, bukan library, Anda dapat menggunakan opsi -s
PROXY_TO_PTHREAD
, yang akan mengalihkan
kode aplikasi utama ke thread terpisah selain thread bertingkat yang dibuat oleh
aplikasi itu sendiri. Dengan cara ini, kode utama dapat memblokir dengan aman kapan saja tanpa membekukan UI.
Secara kebetulan, saat menggunakan opsi ini, Anda juga tidak perlu membuat kumpulan thread. Sebagai gantinya,
Emscripten dapat memanfaatkan thread utama untuk membuat Pekerja dasar yang baru, lalu memblokir
thread helper di pthread_join
tanpa deadlock.
Comlink
Ketiga, jika Anda mengerjakan library dan masih perlu memblokir, Anda dapat membuat Pekerja sendiri, mengimpor kode yang dihasilkan Emscripten, dan mengeksposnya dengan Comlink ke thread utama. Thread utama akan dapat memanggil metode apa pun yang diekspor sebagai fungsi asinkron, dan dengan cara ini juga akan menghindari pemblokiran UI.
Dalam aplikasi sederhana seperti contoh sebelumnya, -s PROXY_TO_PTHREAD
adalah opsi terbaik:
emcc -pthread -s PROXY_TO_PTHREAD example.c -o example.js
C++
Semua peringatan dan logika yang sama berlaku dengan cara yang sama pada C++. Satu-satunya hal baru yang Anda peroleh adalah akses
ke API dengan level lebih tinggi seperti std::thread
dan
std::async
, yang menggunakan library pthread
yang telah dibahas sebelumnya.
Jadi contoh di atas dapat ditulis ulang dalam C++ yang lebih idiomatis seperti ini:
example.cpp:
#include <iostream>
#include <thread>
#include <chrono>
int main()
{
puts("Before the thread");
int arg = 42;
std::thread thread([&]() {
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "Inside the thread: " << arg << std::endl;
});
thread.join();
std::cout << "After the thread" << std::endl;
return 0;
}
Jika dikompilasi dan dieksekusi dengan parameter yang serupa, kode ini akan berperilaku dengan cara yang sama seperti contoh C:
emcc -std=c++11 -pthread -s PROXY_TO_PTHREAD example.cpp -o example.js
Output:
Before the thread
Inside the thread: 42
Pthread 0xc06190 exited.
After the thread
Proxied main thread 0xa05c18 finished with return code 0. EXIT_RUNTIME=0 set, so
keeping main thread alive for asynchronous event operations.
Pthread 0xa05c18 exited.
Rust
Tidak seperti Emscripten, Rust tidak memiliki target web menyeluruh khusus, tetapi memberikan target wasm32-unknown-unknown
umum untuk output WebAssembly generik.
Jika Wasm dimaksudkan untuk digunakan di lingkungan web, setiap interaksi dengan JavaScript API akan diserahkan ke
library dan alat eksternal seperti wasm-bindgen
dan wasm-pack. Sayangnya, ini berarti
library standar tidak mengetahui Web Workers dan API standar seperti
std::thread
tidak akan berfungsi saat dikompilasi ke WebAssembly.
Untungnya, sebagian besar ekosistem bergantung pada library tingkat tinggi untuk menangani multithreading. Pada tingkat itu, jauh lebih mudah untuk menghilangkan semua perbedaan platform.
Secara khusus, Rayon adalah pilihan paling populer untuk paralelisme data di Rust. Dengan API ini, Anda dapat mengambil rantai metode pada iterator reguler dan, biasanya dengan perubahan satu baris, mengonversinya dengan cara yang berjalan secara paralel di semua thread yang tersedia, bukan secara berurutan. Contoh:
pub fn sum_of_squares(numbers: &[i32]) -> i32 {
numbers
.iter()
.par_iter()
.map(|x| x * x)
.sum()
}
Dengan perubahan kecil ini, kode akan membagi data input, menghitung jumlah x * x
dan sebagian dalam
thread paralel, dan pada akhirnya menjumlahkan hasil parsial tersebut bersama-sama.
Untuk mengakomodasi platform tanpa std::thread
yang berfungsi, Rayon menyediakan hook yang memungkinkan
menentukan logika kustom untuk memunculkan dan keluar dari thread.
wasm-bindgen-rayon memanfaatkan hook tersebut untuk memunculkan thread WebAssembly sebagai Web Worker. Untuk menggunakannya, Anda harus menambahkannya sebagai dependensi dan mengikuti langkah-langkah konfigurasi yang dijelaskan dalam docs. Contoh di atas akan terlihat seperti ini:
pub use wasm_bindgen_rayon::init_thread_pool;
#[wasm_bindgen]
pub fn sum_of_squares(numbers: &[i32]) -> i32 {
numbers
.par_iter()
.map(|x| x * x)
.sum()
}
Setelah selesai, JavaScript yang dihasilkan akan mengekspor fungsi initThreadPool
tambahan. Fungsi ini
akan membuat kumpulan Pekerja dan menggunakannya kembali sepanjang masa program untuk setiap
operasi multithread yang dilakukan oleh Rayon.
Mekanisme kumpulan ini mirip dengan opsi -s PTHREAD_POOL_SIZE=...
di Emscripten yang dijelaskan
sebelumnya, dan juga perlu diinisialisasi sebelum kode utama untuk menghindari deadlock:
import init, { initThreadPool, sum_of_squares } from './pkg/index.js';
// Regular wasm-bindgen initialization.
await init();
// Thread pool initialization with the given number of threads
// (pass `navigator.hardwareConcurrency` if you want to use all cores).
await initThreadPool(navigator.hardwareConcurrency);
// ...now you can invoke any exported functions as you normally would
console.log(sum_of_squares(new Int32Array([1, 2, 3]))); // 14
Perlu diperhatikan bahwa peringatan yang sama tentang
pemblokiran thread utama juga berlaku di sini. Bahkan contoh sum_of_squares
masih harus memblokir
thread utama untuk menunggu hasil parsial dari thread lain.
Proses ini mungkin memerlukan waktu tunggu yang sangat singkat atau lama, bergantung pada kompleksitas iterator dan jumlah thread yang tersedia. Namun, demi keamanan, mesin browser secara aktif mencegah pemblokiran thread utama dan kode tersebut akan menghasilkan error. Sebagai gantinya, Anda harus membuat Pekerja, mengimpor
kode yang dihasilkan wasm-bindgen
di sana, dan mengekspos API-nya dengan library seperti
Comlink ke thread utama.
Lihat contoh wasm-bindgen-rayon untuk demo menyeluruh yang menampilkan:
- Deteksi fitur thread.
- Membangun versi thread tunggal dan multi-thread dari aplikasi Rust yang sama.
- Memuat JS+Wasm yang dihasilkan oleh wasm-bindgen di Worker.
- Menggunakan wasm-bindgen-rayon untuk melakukan inisialisasi kumpulan thread.
- Menggunakan Comlink untuk mengekspos API Pekerja ke thread utama.
Kasus penggunaan dunia nyata
Kami secara aktif menggunakan thread WebAssembly di Squoosh.app untuk kompresi gambar sisi klien—khususnya, untuk format seperti AVIF (C++), JPEG-XL (C++), OxiPNG (Rust), dan WebP v2 (C++). Berkat multithreading saja, kami telah melihat kecepatan yang konsisten sebesar 1,5x-3x hingga thread WebAsmbly juga mampu mendorong
Google Earth adalah layanan terkenal lainnya yang menggunakan thread WebAssembly untuk versi webnya.
FFMPEG.WASM adalah versi WebAssembly dari toolchain multimedia FFmpeg populer yang menggunakan thread WebAssembly untuk mengenkode video secara efisien secara langsung di browser.
Ada banyak contoh menarik yang lebih banyak menggunakan thread WebAssembly. Pastikan untuk melihat demo dan membawa aplikasi serta library multithread Anda sendiri ke web.