Threading (SharedArrayBuffer) — WebAssembly

Multi-Threading dengan Wasm WebAssembly mendukung shared memory dan threads melalui SharedArrayBuffer dan Web Workers. Ini memungkinkan parallel computation…

Threading artinya beberapa thread mengerjakan bagian pekerjaan yang sama pada waktu bersamaan, di core CPU yang berbeda. Di browser, thread tambahan itu Web Worker. Worker bisa menerima data dengan dua cara: dipindah (transfer), atau dipakai bersama lewat SharedArrayBuffer, dan Atomics yang menjaga supaya mereka gak saling menimpa. Bayangin foto Kamar Teras dipotong jadi 4 lembar memanjang, lalu 4 pembantu Bu Sri mengerjakan satu lembar masing-masing. Cara pertama: tiap pembantu membawa lembarnya ke meja sendiri, lalu mengembalikannya. Cara kedua: semua bekerja di satu meja besar, jadi gak ada yang perlu dibawa-bawa, tapi butuh aturan giliran, dan ruangannya harus dikunci dari tamu asing. Lembar foto itu potongan byte, meja sendiri itu transfer, meja besar itu SharedArrayBuffer, aturan gilirannya Atomics, dan kunci ruangannya header COOP dan COEP.

🧪 Tebakan cepat: blur σ 2 untuk foto 1200 × 800 butuh 50 ms di satu worker. Kalau dibagi ke 4 worker di laptop 10 core, jadi berapa: 12,5 ms, sekitar 15 ms, atau tetap 50 ms? Tebak dulu, jawabannya ada di bagian "Membagi foto jadi 4 potongan".

Worker: thread kedua di browser

Worker menjalankan file JavaScript-nya sendiri di thread lain. Dia gak bisa menyentuh DOM, dan bicara dengan halaman lewat pesan:

// worker.js: pasang olahfoto.wasm sekali, lalu blur tiap potongan yang dikirim
let x, base;
onmessage = (e) => {
  const d = e.data;
  if (d.bytes) {
    x = new WebAssembly.Instance(new WebAssembly.Module(d.bytes)).exports;
    base = x.memory.buffer.byteLength; // potongan ditaruh setelah memori awal modul
    return;
  }
  const n = d.potongan.byteLength, kernel = base + 2 * n;
  const kurang = kernel + 64 - x.memory.buffer.byteLength;
  if (kurang > 0) x.memory.grow(Math.ceil(kurang / 65536));
  new Uint8Array(x.memory.buffer).set(new Uint8Array(d.potongan), base);
  new Int32Array(x.memory.buffer, kernel, d.K.length).set(d.K);
  x.blur_h(base, base + n, d.W, d.tinggi, kernel, d.R);
  x.blur_v(base + n, base, d.W, d.tinggi, kernel, d.R);
  const hasil = x.memory.buffer.slice(base + d.buang * d.W * 4, base + (d.buang + d.baris) * d.W * 4);
  postMessage(hasil, [hasil]); // transfer: dipindah, bukan disalin
};

Membagi foto jadi 4 potongan

Foto dipotong per baris: 800 baris jadi 4 × 200. Blur tegak butuh 6 baris tetangga di atas dan di bawah (r = 6 untuk σ 2), jadi tiap potongan dikirim dengan 6 baris tambahan di tiap sisinya, lalu baris tambahan itu dibuang dari hasilnya:

function blurPotongan(n) {
  const tugas = [];
  for (let i = 0; i < n; i++) {
    const y0 = Math.floor((H * i) / n), y1 = Math.floor((H * (i + 1)) / n);
    const a = Math.max(0, y0 - R), b = Math.min(H, y1 + R); // plus 6 baris tetangga
    const potongan = foto.slice(a * W * 4, b * W * 4).buffer;
    tugas.push(new Promise((selesai) => {
      workers[i].onmessage = (e) => selesai({ y0, data: new Uint8Array(e.data) });
      workers[i].postMessage({ potongan, tinggi: b - a, buang: y0 - a, baris: y1 - y0, W, R, K }, [potongan]);
    }));
  }
  return Promise.all(tugas).then((bagian) => {
    const hasil = new Uint8Array(W * H * 4);
    bagian.forEach((p) => hasil.set(p.data, p.y0 * W * 4));
    return hasil;
  });
}

Diukur di MacBook M1 Pro (8 core performa + 2 core efisiensi) yang sedang sibuk menjalankan banyak proses lain, median 5 putaran setelah pemanasan, halaman tanpa isolasi:

Blur σ 2, 1200 × 800 Chromium 147 WebKit 26.4
1 worker 50,2 ms 49,0 ms
2 worker 26,0 ms (1,9×) 25,0 ms (2,0×)
4 worker 15,1 ms (3,3×) 14,0 ms (3,5×)

Hasil 4 worker sama byte demi byte dengan 1 worker. Jawaban tebakannya: sekitar 15 ms, bukan 12,5. Sisanya ongkos menyalin potongan dan baris tambahan, mengirim pesan, menyusun hasil, dan core yang sedang dipakai proses lain.

Untuk pekerjaan kecil, hitungannya terbalik. Grayscale JavaScript yang sama di halaman langsung: 1,88 ms di Chromium, 1,22 ms di WebKit. Lewat 4 worker: 1,62 ms dan 1,52 ms. Hampir gak ada untungnya, malah rugi di WebKit.

SharedArrayBuffer dan Atomics

SharedArrayBuffer adalah memori yang dilihat beberapa thread sekaligus: dikirim lewat postMessage, tapi gak dipindah dan gak disalin. Masalahnya, dua thread bisa menulis tempat yang sama di waktu yang sama:

const hitung = new Int32Array(new SharedArrayBuffer(4));
// di tiap worker, 4 worker sekaligus, masing-masing 1.000.000 kali:
hitung[0]++;                 // baca, tambah, tulis: bisa saling menimpa
Atomics.add(hitung, 0, 1);   // satu langkah yang gak bisa dipotong thread lain

Diukur di Chromium 147, halaman yang cross-origin isolated: hitung[0]++ menghasilkan 1.128.847, 1.372.249, dan 1.439.138 dalam tiga percobaan, bukan 4.000.000. Itu yang disebut data race. Atomics.add memberi 4.000.000 tiap kali.

Kenapa SharedArrayBuffer butuh cross-origin isolation

Tahun 2018, serangan Spectre menunjukkan bahwa kode yang punya timer sangat presisi bisa menebak isi memori lain di proses yang sama. SharedArrayBuffer bisa dipakai membuat timer seperti itu, jadi browser cuma menyediakannya untuk halaman yang cross-origin isolated: halaman yang menolak dimuat bersama dokumen dari origin lain. Syaratnya dua header di dokumen:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Tanpa header itu, di Chromium 147, WebKit 26.4, dan Firefox 146: crossOriginIsolated bernilai false dan typeof SharedArrayBuffer bernilai "undefined". Yang mengejutkan, new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }) tetap bisa dibuat, dan buffer-nya memang SharedArrayBuffer. Yang gagal adalah mengirimnya ke worker:

Chromium 147: DataCloneError: Failed to execute 'postMessage' on 'Worker': SharedArrayBuffer transfer requires self.crossOriginIsolated.
WebKit 26.4:  DataCloneError: The object can not be cloned.
Firefox 146:  DataCloneError: Worker.postMessage: The WebAssembly.Memory object cannot be serialized. The Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy HTTP headers can be used to enable this.

Playground JS di BelajarWeb.dev gak mengirim header itu, jadi kode di Lab memakai transfer, dan menampilkan error ini apa adanya saat mencoba memori shared.

Wasm threads: memori shared dan instruksi atomic

Wasm gak punya instruksi untuk membuat thread. "Wasm threads" artinya: memori yang ditandai shared, instruksi atomic (i32.atomic.rmw.add, memory.atomic.wait32, memory.atomic.notify), dan satu instance di tiap Web Worker yang semuanya memakai memori yang sama.

;; tambah.wat: wat2wasm --enable-threads tambah.wat -> 64 byte
(module
  (memory (import "env" "memory") 60 60 shared) ;; memori shared wajib punya batas maksimum
  (func (export "tambah") (param $alamat i32) (result i32)
    local.get $alamat
    i32.const 1
    i32.atomic.rmw.add)) ;; tambah 1 secara atomic, kembalikan nilai lamanya

Emscripten membuat semua itu dari C biasa dengan pthread:

emcc -O3 -pthread -sPTHREAD_POOL_SIZE=8 -sINITIAL_MEMORY=33554432 blur-thread.c -o blur-thread.js

blur-thread.c membagi blur σ 2 ke N thread dengan pthread_create dan pthread_join: blur datar di semua thread, tunggu semuanya, lalu blur tegak. Emscripten 6.0.12 menghasilkan blur-thread.js (21.370 byte) dan blur-thread.wasm (31.048 byte). PTHREAD_POOL_SIZE=8 menyiapkan 8 worker sebelum main() jalan. Di halaman dengan COOP dan COEP:

Thread Chromium 147 WebKit 26.4
1 43,4 ms 44,2 ms
2 22,2 ms 21,8 ms
4 14,3 ms 11,2 ms
8 11,2 ms 9,9 ms

Checksum hasilnya sama dengan blur JavaScript. Halaman yang sama tanpa header gak pernah sampai ke main(): Chromium berhenti dengan DataCloneError yang sama seperti di atas. Untuk Rust, crate wasm-bindgen-rayon (1.3.0) memberi thread pool Rayon di atas Web Worker, tapi masih butuh Rust nightly dengan -Z build-std dan +atomics,+bulk-memory.

⚠️ Jebakan yang sering ketemu

TL;DR: Threading di browser = Web Worker. Data bisa dipindah (transferable ArrayBuffer, postMessage(data, [buffer])) atau dipakai bersama lewat SharedArrayBuffer, dengan Atomics supaya gak terjadi data race (hitung[0]++ dari 4 worker: sekitar 1,4 juta, Atomics.add: tepat 4 juta). Blur σ 2 dibagi ke 4 worker dengan 6 baris tambahan per sisi: 50 ms jadi 15 ms di Chromium 147 (3,3×), bukan 4×. SharedArrayBuffer cuma ada di halaman cross-origin isolated: Cross-Origin-Opener-Policy: same-origin dan Cross-Origin-Embedder-Policy: require-corp (atau credentialless, belum di Safari), cek crossOriginIsolated. Tanpa itu, memori Wasm shared tetap bisa dibuat, tapi postMessage-nya gagal dengan DataCloneError. Wasm threads = memori shared + instruksi atomic + satu instance per worker; Emscripten -pthread membuatnya dari C (blur 43 ms jadi 11 ms dengan 8 thread), Rust lewat wasm-bindgen-rayon dengan nightly. Atomics.wait dilarang di thread utama.

Yang akan kamu pelajari