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

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.

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:

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.