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
};
postMessage(data, [buffer]): argumen kedua adalah daftar transferable.ArrayBufferdi daftar itu dipindah ke thread lain dalam waktu hampir nol, dan di pengirim panjangnya jadi 0.- Tiap worker punya instance Wasm sendiri, dengan memori sendiri. Fungsinya
blur_hdanblur_vdari pelajaran "Image Processing di Browser". navigator.hardwareConcurrencymemberi jumlah core yang dilaporkan browser: 10 di Chromium 147 di laptop ini, 8 di WebKit 26.4 di laptop yang sama.
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.
Atomics.load,Atomics.store,Atomics.add,Atomics.sub,Atomics.compareExchange: baca dan tulis yang utuh, gak terpotong thread lain.Atomics.wait(arr, i, nilai)membuat thread tidur sampaiAtomics.notify(arr, i)membangunkannya. Di thread utama dia dilarang, supaya halaman gak beku: Chromium 147 melemparTypeError: Atomics.wait cannot be called in this context. Di thread utama, pakaiAtomics.waitAsync, yang mengembalikan Promise.
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
COOP: same-originmemutus hubungan dengan jendela dari origin lain (popup,window.opener).COEP: require-corphanya mengizinkan sumber dari origin lain (gambar CDN, script, iframe) yang mengirim headerCross-Origin-Resource-Policyatau lolos CORS. Yang gak, diblokir.COEP: credentiallessmemuat sumber itu tanpa cookie sebagai gantinya; ini jalan di Chromium 147 dan Firefox 146, tapi WebKit 26.4 menolaknya (crossOriginIsolatedtetapfalse).- Cek di console:
crossOriginIsolatedharustrue.
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
- Memorinya dibuat JavaScript,
new WebAssembly.Memory({ initial: 60, maximum: 60, shared: true }), lalu dikirim ke tiap worker dan dipakai sebagai importenv.memorydi semua instance. - wat2wasm 1.0.42 menolak tanpa
--enable-threads(error: opcode not allowed: i32.atomic.rmw.add), dan(memory 1 shared)tanpa maksimum ditolak denganerror: shared memories must have max sizes.
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
- Lupa header COOP dan COEP.
SharedArrayBufferjadiundefined, dan build-pthreadgagal denganDataCloneError. CekcrossOriginIsolated. require-corpmemblokir gambar dari CDN lain. Sumber dari origin lain harus mengirimCross-Origin-Resource-Policyatau CORS, atau pakaicredentialless(belum di Safari).- Menulis memori shared tanpa Atomics. 4 × 1.000.000 penambahan jadi sekitar 1,1 sampai 1,4 juta.
Atomics.waitdi thread utama.TypeError. PakaiAtomics.waitAsync, atau tunggu di worker.- Worker untuk pekerjaan kecil. Grayscale 1,2 ms gak jadi lebih cepat dibagi 4. Bagi pekerjaan yang butuh puluhan milidetik.
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.