Pelajari cara menghadirkan aplikasi multi-thread yang ditulis dalam bahasa lain ke WebAssembly.
Dukungan thread WebAssembly adalah salah satu penambahan performa yang paling penting untuk WebAssembly. Hal ini memungkinkan Anda menjalankan bagian kode secara paralel di core terpisah, atau kode yang sama di bagian data input yang independen, menskalakannya ke sebanyak core yang dimiliki pengguna dan secara signifikan mengurangi waktu eksekusi secara keseluruhan.
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 kombinasi dari beberapa komponen yang memungkinkan aplikasi WebAssembly menggunakan paradigma multithreading tradisional di web.
Web Worker
Komponen pertama adalah
Workers reguler yang Anda ketahui dan
sukai dari JavaScript. Thread WebAssembly menggunakan konstruktor new Worker
untuk membuat thread dasar
baru. Setiap thread memuat glue JavaScript, lalu thread utama menggunakan
metode Worker#postMessage
untuk
membagikan
WebAssembly.Module
yang dikompilasi serta
WebAssembly.Memory
yang dibagikan (lihat di bawah) dengan thread lain tersebut. Hal ini akan membangun komunikasi dan memungkinkan semua thread tersebut
menjalankan kode WebAssembly yang sama di memori bersama yang sama tanpa melalui JavaScript lagi.
Web Worker telah ada selama lebih dari satu dekade, didukung secara luas, dan tidak memerlukan flag khusus.
SharedArrayBuffer
Memori WebAssembly direpresentasikan oleh objek
WebAssembly.Memory
di JavaScript API. Secara default, WebAssembly.Memory
adalah wrapper di sekitar
ArrayBuffer
—buffer
byte mentah 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. Saat dibuat dengan
flag shared
melalui JavaScript API, atau oleh biner WebAssembly itu sendiri, flag tersebut akan menjadi wrapper
di sekitar
SharedArrayBuffer
. Ini adalah variasi dari ArrayBuffer
yang dapat dibagikan dengan thread lain dan dibaca atau
diubah secara bersamaan dari kedua sisi.
> new WebAssembly.Memory({ initial:1, maximum:10, shared:true }).buffer
SharedArrayBuffer { … }
Tidak seperti postMessage
, yang biasanya digunakan
untuk komunikasi antara thread utama dan Web Worker,
SharedArrayBuffer
tidak memerlukan penyalinan data atau bahkan menunggu loop peristiwa untuk mengirim dan menerima pesan.
Sebagai gantinya, setiap perubahan akan terlihat oleh semua thread hampir secara instan, sehingga menjadikannya target kompilasi yang jauh lebih baik
untuk primitif sinkronisasi tradisional.
SharedArrayBuffer
memiliki histori yang rumit. Fitur ini awalnya dikirimkan di beberapa browser
pada pertengahan 2017, tetapi harus dinonaktifkan pada awal 2018 karena ditemukannya kerentanan Spectre. Alasan
tertentu adalah ekstraksi data di Spectre bergantung pada serangan pengaturan waktu—mengukur waktu eksekusi
bagian kode tertentu. Untuk mempersulit serangan semacam ini, browser mengurangi presisi API
pemberian waktu standar seperti Date.now
dan performance.now
. Namun, memori bersama, yang dikombinasikan dengan loop penghitung sederhana
yang berjalan di thread terpisah juga merupakan cara yang sangat andal untuk mendapatkan waktu
yang presisi tinggi, dan jauh lebih sulit untuk dimitigasi tanpa
menghalangi performa runtime secara signifikan.
Sebagai gantinya, Chrome 68 (pertengahan 2018) mengaktifkan kembali SharedArrayBuffer
dengan memanfaatkan Isolasi
Situs—fitur yang menempatkan
berbagai situs ke dalam proses yang berbeda dan mempersulit penggunaan serangan saluran samping
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 dengan memori rendah, atau belum diterapkan oleh vendor lain.
Pada tahun 2020, Chrome dan Firefox memiliki implementasi Isolasi Situs, dan cara standar bagi situs untuk ikut serta dalam fitur ini dengan header COOP dan COEP. Mekanisme keikutsertaan memungkinkan penggunaan Isolasi Situs bahkan di perangkat dengan daya rendah yang mengaktifkannya untuk semua situs akan terlalu mahal. Untuk ikut serta, tambahkan header berikut ke dokumen utama dalam 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 akurat, pengukuran memori, dan API lainnya yang memerlukan origin
terisolasi karena alasan keamanan. Lihat Membuat situs Anda "terisolasi lintas origin" menggunakan COOP
dan COEP untuk mengetahui detail selengkapnya.
Atom WebAssembly
Meskipun SharedArrayBuffer
memungkinkan setiap thread membaca dan menulis ke memori yang sama, untuk komunikasi
yang benar, Anda ingin memastikannya 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 harus menyinkronkan akses tersebut.
Di sinilah operasi atomik berperan.
Atomic WebAssembly adalah ekstensi untuk set instruksi WebAssembly yang memungkinkan pembacaan dan penulisan sel data berukuran kecil (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 rendah. Selain itu, atom WebAssembly berisi dua jenis petunjuk lainnya—"wait" dan "notify"—yang memungkinkan satu thread tidur ("wait") di alamat tertentu dalam memori bersama hingga thread lain membangunkannya melalui "notify".
Semua primitif sinkronisasi tingkat tinggi, termasuk saluran, mutex, dan kunci baca-tulis dibuat berdasarkan petunjuk tersebut.
Cara menggunakan thread WebAssembly
Deteksi fitur
Atom WebAssembly dan SharedArrayBuffer
adalah fitur yang relatif baru dan belum tersedia di
semua browser dengan dukungan WebAssembly. Anda dapat menemukan browser yang mendukung fitur WebAssembly baru
di roadmap webassembly.org.
Untuk memastikan semua pengguna dapat memuat aplikasi, Anda harus menerapkan progressive enhancement dengan mem-build dua versi Wasm yang berbeda—satu dengan dukungan multithreading dan satu lagi tanpa dukungan tersebut. Kemudian, muat versi yang didukung bergantung pada hasil deteksi fitur. Untuk mendeteksi dukungan thread WebAssembly saat runtime, gunakan library wasm-feature-detect dan 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
Dalam C, terutama pada sistem mirip Unix, cara umum untuk menggunakan thread adalah melalui POSIX
Threads yang disediakan oleh library pthread
. Emscripten
menyediakan implementasi yang kompatibel dengan API
library pthread
yang dibuat di atas Web Workers, memori bersama, dan atom, sehingga kode yang sama dapat
berfungsi di web tanpa perubahan.
Mari kita lihat contohnya:
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. Ini memerlukan tujuan untuk menyimpan handle thread, beberapa atribut pembuatan
thread (di sini tidak meneruskan apa pun, jadi hanya NULL
), callback yang akan dijalankan 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, kita membagikan pointer ke
variabel arg
.
pthread_join
dapat dipanggil nanti
kapan saja untuk menunggu thread menyelesaikan eksekusi, dan mendapatkan hasil yang ditampilkan dari
callback. Metode ini menerima handle thread yang ditetapkan sebelumnya serta pointer untuk menyimpan hasilnya. Dalam
hal ini, tidak ada hasil sehingga fungsi 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 berhenti berfungsi:
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…]
Apa yang terjadi? Masalahnya, sebagian besar API yang memakan waktu di web bersifat asinkron dan mengandalkan loop peristiwa untuk dieksekusi. Batasan ini adalah 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 mempelajarinya lebih lanjut.
Dalam hal ini, kode secara sinkron memanggil pthread_create
untuk membuat thread latar belakang, dan
diikuti dengan panggilan sinkron lain ke pthread_join
yang menunggu thread latar belakang
menyelesaikan eksekusi. Namun, Web Worker, yang digunakan di balik layar saat kode ini dikompilasi dengan Emscripten, bersifat asinkron. Jadi, yang terjadi adalah, pthread_create
hanya menjadwalkan thread Pekerja baru
untuk dibuat pada loop peristiwa berikutnya yang dijalankan, tetapi pthread_join
segera memblokir
loop peristiwa untuk menunggu Pekerja tersebut, dan dengan demikian mencegahnya dibuat. Ini adalah
contoh klasik 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 pada thread latar belakangnya, dan menampilkan Pekerja kembali ke kumpulan. Semua ini
dapat dilakukan secara sinkron, sehingga tidak akan ada deadlock selama kumpulan cukup
besar.
Hal ini persis seperti yang diizinkan Emscripten dengan opsi -s
PTHREAD_POOL_SIZE=...
. Hal ini memungkinkan untuk menentukan jumlah thread—baik angka tetap, maupun ekspresi JavaScript seperti navigator.hardwareConcurrency
untuk membuat thread sebanyak core di CPU. Opsi terakhir ini berguna jika kode Anda
dapat diskalakan ke jumlah thread arbitrer.
Pada contoh di atas, hanya ada satu thread yang dibuat, jadi cukup gunakan -s PTHREAD_POOL_SIZE=1
untuk mencadangkan semua core:
emcc -pthread -s PTHREAD_POOL_SIZE=1 example.c -o example.js
Kali ini, saat Anda menjalankannya, semuanya berhasil:
Before the thread
Inside the thread: 42
After the thread
Pthread 0x701510 exited.
Namun, ada masalah lain: lihat sleep(1)
dalam contoh kode? Kode ini dieksekusi dalam callback
thread, yang berarti di luar thread utama, jadi tidak masalah, bukan? Tidak.
Saat dipanggil, pthread_join
harus menunggu eksekusi thread selesai, yang berarti bahwa jika
thread yang dibuat melakukan tugas yang berjalan lama—dalam hal ini, tidur selama 1 detik—thread
utama juga harus memblokir selama waktu yang sama hingga hasilnya kembali. Saat JS ini
dieksekusi di browser, JS akan memblokir UI thread selama 1 detik hingga callback thread
kembali. Hal ini menyebabkan pengalaman pengguna yang buruk.
Ada beberapa solusi untuk masalah ini:
pthread_detach
-s PROXY_TO_PTHREAD
- Pekerja Kustom dan Comlink
pthread_detach
Pertama, jika Anda hanya perlu menjalankan beberapa tugas dari 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 memindahkan
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.
Selain itu, saat menggunakan opsi ini, Anda juga tidak perlu membuat kumpulan thread terlebih dahulu—sebagai gantinya,
Emscripten dapat memanfaatkan thread utama untuk membuat Pekerja yang mendasarinya, lalu memblokir
thread helper di pthread_join
tanpa deadlock.
Comlink
Ketiga, jika Anda sedang mengerjakan library dan masih perlu memblokir, Anda dapat membuat Worker 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 itu 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 untuk C++. Satu-satunya hal baru yang Anda dapatkan adalah akses
ke API level yang lebih tinggi seperti std::thread
dan
std::async
, yang menggunakan library
pthread
yang telah dibahas sebelumnya di balik layar.
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;
}
Saat dikompilasi dan dijalankan dengan parameter yang serupa, kode ini akan berperilaku 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 menyediakan
target wasm32-unknown-unknown
generik untuk output WebAssembly generik.
Jika Wasm dimaksudkan untuk digunakan di lingkungan web, interaksi apa pun dengan JavaScript API akan diserahkan kepada
library dan alat eksternal seperti wasm-bindgen
dan wasm-pack. Sayangnya, hal ini berarti bahwa
library standar tidak mengetahui Web Worker 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 tersebut, akan jauh lebih mudah untuk memisahkan semua perbedaan platform.
Secara khusus, Rayon adalah pilihan paling populer untuk paralelisme data di Rust. Hal ini memungkinkan Anda mengambil rantai metode pada iterator reguler dan, biasanya dengan perubahan satu baris, mengonversinya dengan cara yang akan 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 x * x
dan jumlah parsial dalam
thread paralel, dan pada akhirnya menambahkan hasil parsial tersebut.
Untuk mengakomodasi platform tanpa std::thread
yang berfungsi, Rayon menyediakan hook yang memungkinkan
menentukan logika kustom untuk membuat dan keluar dari thread.
wasm-bindgen-rayon memanfaatkan hook tersebut untuk membuat thread WebAssembly sebagai Web Worker. Untuk menggunakannya, Anda perlu menambahkannya sebagai dependensi dan mengikuti langkah-langkah konfigurasi yang dijelaskan dalam dokumen. 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 selama masa aktif program untuk
operasi multi-thread apa pun 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
Perhatikan bahwa peringatan yang sama tentang
pemblokiran thread utama juga berlaku di sini. Bahkan contoh sum_of_squares
masih perlu memblokir
thread utama untuk menunggu hasil sebagian dari thread lain.
Waktu tunggunya mungkin sangat singkat atau lama, bergantung pada kompleksitas iterator dan jumlah
thread yang tersedia, tetapi, untuk berjaga-jaga, mesin browser secara aktif mencegah pemblokiran thread
utama secara keseluruhan dan kode tersebut akan menampilkan 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 melihat demo end-to-end yang menampilkan:
- Deteksi fitur thread.
- Mem-build versi single-thread 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 di dunia nyata
Kami secara aktif menggunakan thread WebAssembly di Squoosh.app untuk kompresi gambar sisi klien, terutama untuk format seperti AVIF (C++), JPEG-XL (C++), OxiPNG (Rust), dan WebP v2 (C++). Berkat multithreading saja, kami telah melihat peningkatan kecepatan 1,5x-3x yang konsisten (rasio pastinya berbeda per codec), dan dapat mendorong angka tersebut lebih jauh dengan menggabungkan thread WebAssembly dengan WebAssembly SIMD.
Google Earth adalah layanan penting lainnya yang menggunakan thread WebAssembly untuk versi web-nya.
FFMPEG.WASM adalah versi WebAssembly dari toolchain multimedia FFmpeg populer yang menggunakan thread WebAssembly untuk mengenkode video secara langsung di browser dengan efisien.
Ada banyak contoh menarik lainnya yang menggunakan thread WebAssembly. Pastikan untuk melihat demo dan membawa aplikasi dan library multi-thread Anda sendiri ke web.