Menggunakan thread WebAssembly dari C, C++, dan Rust

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.

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:

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.