Event loop Node berjalan pada satu thread, sehingga perhitungan CPU yang panjang akan memblokir seluruh callback I/O selama perhitungan itu berlangsung. Untuk pekerjaan yang tidak dapat dipecah menjadi potongan singkat, Node menyediakan modul worker_threads yang menjalankan skrip JavaScript pada thread terpisah di dalam proses yang sama. Topik ini menjelaskan kapan worker_threads dipakai, cara membaca berkas worker, pola komunikasi dua arah lewat pesan, serta perbandingan langsung antara perhitungan di thread utama dan di worker dari sisi responsivitas event loop.
Kapan worker_threads dipakai#
worker_threads cocok untuk pekerjaan yang menghabiskan CPU dalam orde ratusan milidetik atau lebih, misalnya kompresi data, penguraian JSON raksasa, pengolahan citra, atau perhitungan kriptografi yang tidak tersedia sebagai API async pada modul crypto. Node tetap mempertahankan model single-thread event loop untuk I/O, sehingga thread worker hanya membantu ketika beban benar-benar terikat CPU, bukan ketika menunggu jaringan atau berkas.
Pemakaian worker tidak gratis. Spawning satu worker memuat instans V8 baru, memakan sekitar belasan megabyte memori, dan memerlukan biaya startup yang tidak kecil. Untuk pekerjaan yang berulang dalam request handling, lazim dipakai worker pool yang dipinjam dan dikembalikan, bukan spawn per permintaan.
Membuat worker pertama#
Sebuah worker dijalankan dari skrip utama yang membuat instans Worker dengan path ke berkas worker. Berkas worker memakai require("worker_threads") dan mengakses parentPort untuk berkomunikasi dengan thread utama. Berkas berikut sengaja diberi penjaga if (!parentPort) agar tidak melempar error ketika dijalankan sendiri tanpa konteks worker.
const { parentPort } = require("worker_threads");
if (!parentPort) {
console.log("skrip worker — jalankan melalui worker-cpu.js");
process.exit(0);
}
parentPort.on("message", (n) => {
const mulai = Date.now();
let total = 0;
for (let i = 0; i < n; i++) {
total += Math.sqrt(i);
}
parentPort.postMessage({ total: total.toFixed(4), ms: Date.now() - mulai });
parentPort.close();
});skrip worker — jalankan melalui worker-cpu.jsWorker menerima n dari thread utama, menjalankan perulangan Math.sqrt sebanyak n, lalu mengirim hasil kembali. parentPort.close() memberi sinyal bahwa worker selesai bekerja sehingga thread utama menerima exit event dengan kode nol. Tanpa close() atau terminate(), worker tetap hidup mendengarkan pesan baru dan event loop utama tidak akan pernah keluar.
Komunikasi worker dan thread utama#
Komunikasi dua arah dilakukan melalui metode postMessage di kedua sisi. Thread utama memanggil worker.postMessage(n) untuk mengirim, sedangkan worker memanggil parentPort.postMessage(hasil) untuk membalas. Hasil pesan disalin secara terstruktur sehingga objek biasa aman dikirim, tetapi objek dengan metode atau referensi kelas tidak dapat melewati batas thread. Berkas worker-cpu.js berikut membungkus pemanggilan worker ke dalam promise.
const { Worker } = require("worker_threads");
const path = require("path");
function hitungDiWorker(n) {
return new Promise((resolve, reject) => {
const worker = new Worker(path.join(process.cwd(), "worker-task.js"));
worker.on("message", (msg) => {
resolve(msg);
worker.terminate();
});
worker.on("error", reject);
worker.postMessage(n);
});
}
async function jalankan() {
const urutan = [];
setTimeout(() => urutan.push("ping timer"), 50);
const hasil = await hitungDiWorker(50_000_000);
urutan.push("worker selesai");
console.log("urutan kejadian:", urutan.join(" -> "));
console.log("total perhitungan:", hasil.total);
}
jalankan();urutan kejadian: ping timer -> worker selesai
total perhitungan: 235702256859.7696Pola new Promise yang membungkus worker.on("message", ...) mengubah callback menjadi promise yang dapat di-await, sehingga worker dapat dipanggil dengan gaya async/await yang sama dengan operasi I/O lain. Pemanggilan worker.terminate() pada blok resolve memastikan thread worker dibersihkan setelah hasil diterima.
Worker menjaga thread utama tetap responsif#
Manfaat paling penting worker terlihat saat membandingkan perhitungan yang sama dijalankan di thread utama dan di worker. Berkas blocking-cpu.js berikut menjadwalkan timer 50 milidetik, lalu menjalankan perulangan Math.sqrt sebanyak 50 juta iterasi di thread utama.
const urutan = [];
setTimeout(() => urutan.push("ping timer"), 50);
let total = 0;
for (let i = 0; i < 50_000_000; i++) {
total += Math.sqrt(i);
}
urutan.push("perhitungan selesai");
// Beri event loop kesempatan menguras timer sebelum cetak
setImmediate(() => {
console.log("urutan kejadian:", urutan.join(" -> "));
console.log("total perhitungan:", total.toFixed(4));
});urutan kejadian: perhitungan selesai -> ping timer
total perhitungan: 235702256859.7696Pada versi blocking, timer 50 milidetik tidak dapat dipanggil tepat waktu karena event loop sibuk menjalankan perulangan. Timer baru dipanggil setelah perhitungan selesai, sehingga urutan yang tercatat adalah perhitungan selesai -> ping timer. Sebaliknya, pada worker-cpu.js urutannya terbalik menjadi ping timer -> worker selesai karena perhitungan berjalan di thread lain dan thread utama tetap bebas memproses timer.
workerData dan berbagi data#
Selain postMessage, thread utama dapat meneruskan data awal ke worker melalui opsi workerData saat membuat instans Worker. Worker kemudian membaca data tersebut lewat workerData dari modul worker_threads. Berbeda dengan postMessage yang menyalin pesan secara terstruktur setiap kali dipanggil, workerData hanya disalin sekali saat worker di-spawn dan cocok untuk konfigurasi yang tidak berubah selama worker hidup.
Untuk data biner berukuran besar, pemindahan dapat dioptimalkan dengan transferList pada postMessage. Objek ArrayBuffer yang tercantum di transferList dipindahkan kepemilikannya tanpa disalin, sehingga buffer asal di thread pengirim menjadi detached, yaitu kepemilikannya hilang dan referensi asal menjadi kosong sehingga tidak dapat diakses lagi. Bila berbagi data dua arah antar-thread benar-benar dibutuhkan tanpa pemindahan kepemilikan, gunakan SharedArrayBuffer yang dapat diakses bersama. Sinkronisasi baca tulis pada SharedArrayBuffer harus dilakukan secara eksplisit dengan Atomics agar hasil dapat diprediksi.
Kesalahan umum#
Memakai worker untuk operasi I/O. I/O di Node sudah non-blocking dan didelegasikan ke libuv atau kernel, sehingga memindahkannya ke worker hanya menambah biaya tanpa manfaat. Worker hanya membantu ketika CPU yang menjadi penghalang, bukan ketika menunggu disk atau socket.
Lupa memanggil worker.terminate() atau parentPort.close(). Worker tetap hidup selama satu MessagePort terbuka, sehingga proses Node tidak akan keluar walaupun seluruh pekerjaan sudah selesai. Pada server berumur panjang, worker pool dengan pinjam-kembali lebih sesuai daripada spawn-terminate berulang.
Ringkasan#
worker_threadsmenjalankan JavaScript pada thread terpisah dalam proses yang sama, cocok untuk pekerjaan terikat CPU yang panjang.- Komunikasi antar-thread dilakukan lewat
postMessage, dengan data disalin secara terstruktur atau dibagi melaluiSharedArrayBuffer. - Worker tidak boleh dipakai untuk I/O murni yang sudah non-blocking di thread utama.
- Tanpa
parentPort.close()atauworker.terminate(), worker tetap hidup dan mempertahankan event loop utama.
Referensi#
- Worker threads — Node.js
— API resmi
Worker,parentPort, dan komunikasi pesan antar-thread. - Concurrency model and the event loop — MDN
— Latar belakang model single-thread yang membuat
worker_threadsdiperlukan untuk beban CPU berat. - cluster — Node.js — Alternatif multi-process untuk pemanfaatan banyak core pada server HTTP.