Membangun Worker Pool untuk Web Workers: Mengelola Multithreading Browser dengan Efisien dan Tangguh
1. Pendahuluan
Pernahkah Anda mengalami aplikasi web yang “nge-freeze” atau terasa lambat saat melakukan tugas berat seperti memproses gambar, menghitung data besar, atau menjalankan algoritma kompleks? Ini adalah masalah umum yang disebabkan oleh main thread blocking. Di browser, JavaScript secara default berjalan di satu thread utama (main thread) yang juga bertanggung jawab untuk rendering UI dan menangani interaksi pengguna. Ketika thread ini sibuk dengan tugas komputasi intensif, UI akan berhenti merespons, menciptakan pengalaman pengguna yang buruk.
Untuk mengatasi ini, Web Workers hadir sebagai solusi. Web Workers memungkinkan Anda menjalankan skrip JavaScript di background, terpisah dari main thread, sehingga UI tetap responsif. Namun, bagaimana jika Anda memiliki banyak tugas berat yang perlu di-offload? Membuat satu worker untuk setiap tugas bisa jadi tidak efisien karena overhead inisialisasi worker. Di sinilah Worker Pool menjadi sangat berguna.
💡 Worker Pool adalah sebuah pola desain di mana Anda membuat dan mengelola sejumlah tetap Web Workers, siap untuk menerima dan memproses tugas. Bayangkan seperti sebuah “kolam” pekerja yang siaga. Ketika ada tugas baru, tugas tersebut akan diberikan kepada pekerja yang sedang menganggur di kolam tersebut. Jika semua pekerja sibuk, tugas akan masuk ke antrean dan menunggu hingga ada pekerja yang bebas. Pola ini menjaga efisiensi dengan menghindari pembuatan dan penghancuran worker secara terus-menerus.
Artikel ini akan memandu Anda membangun Worker Pool sederhana, memahami konsep di baliknya, dan menerapkan praktik terbaik untuk menjaga aplikasi web Anda tetap cepat dan responsif.
2. Kenapa Kita Butuh Worker Pool?
Mengapa tidak cukup hanya dengan membuat Web Worker baru setiap kali dibutuhkan? Ada beberapa alasan kuat untuk menggunakan Worker Pool:
- Mengurangi Overhead Inisialisasi: Membuat
new Worker()memiliki overhead. Dengan pool, worker sudah diinisialisasi dan siap pakai, menghemat waktu dan sumber daya. - Membatasi Konkurensi: Anda dapat mengontrol berapa banyak tugas berat yang berjalan secara paralel. Ini penting untuk mencegah browser kehabisan sumber daya atau CPU pengguna kewalahan.
- Manajemen Tugas yang Lebih Baik: Worker Pool menyediakan mekanisme antrean (queue) untuk tugas. Ini memastikan tugas diproses secara berurutan atau berdasarkan prioritas, dan tidak ada tugas yang “tertinggal”.
- Peningkatan Ketahanan (Resilience): Jika salah satu worker mengalami error, worker lain di pool tetap bisa berfungsi. Anda juga bisa menerapkan logika untuk me-restart worker yang gagal.
- Optimasi Sumber Daya: Worker yang sudah dibuat dapat digunakan kembali untuk banyak tugas, mengoptimalkan penggunaan memori dan CPU.
📌 Contoh Kasus Nyata:
- Pemrosesan Gambar: Mengubah ukuran, menerapkan filter, atau mengompresi banyak gambar yang diunggah pengguna.
- Perhitungan Data Besar: Menganalisis dataset yang di-load dari server atau IndexedDB.
- Enkripsi/Dekripsi Data: Menjalankan operasi kriptografi yang intensif secara komputasi.
- Simulasi: Menjalankan simulasi fisika atau algoritma kompleks di background.
3. Konsep Dasar Worker Pool
Untuk membangun Worker Pool, kita perlu memahami beberapa komponen kunci:
- Worker: Setiap instance Web Worker yang akan menjalankan tugas komputasi. Mereka adalah “pekerja” individual kita.
- Pool: Kumpulan atau koleksi dari worker-worker ini. Ukuran pool (jumlah worker) biasanya ditentukan di awal.
- Task Queue: Antrean tempat semua tugas yang perlu dieksekusi menunggu. Ketika sebuah tugas ditambahkan, ia akan masuk ke antrean ini.
- Scheduler: Ini adalah logika di dalam Worker Pool yang bertanggung jawab untuk:
- Mencari worker yang sedang
idle(menganggur) di pool. - Mengambil tugas dari
Task Queue. - Menugaskan tugas ke worker yang
idletersebut. - Menandai worker sebagai
busy(sibuk) saat mengerjakan tugas. - Menandai worker kembali sebagai
idlesetelah tugas selesai.
- Mencari worker yang sedang
Alur kerjanya mirip dengan antrean di bank atau kasir: Anda datang dengan tugas (misal: setor tunai), masuk antrean. Ketika ada teller (worker) yang bebas, Anda maju dan tugas Anda diproses. Setelah selesai, teller siap melayani pelanggan berikutnya.
4. Membangun Worker Pool Sederhana
Mari kita bangun Worker Pool sederhana menggunakan JavaScript. Kita akan memiliki tiga file:
index.html: File HTML utama yang memuat aplikasi frontend.worker.js: Skrip yang akan dijalankan oleh setiap Web Worker (logika tugas berat).WorkerPool.js: Kelas JavaScript yang mengelola pool dan distribusi tugas.main.js: Skrip utama frontend yang menggunakanWorkerPool.
worker.js (Logika Tugas Berat)
File ini berisi kode yang akan dieksekusi oleh setiap worker. Untuk contoh ini, kita akan membuat worker yang menghitung faktorial dari sebuah angka.
// worker.js
// Setiap kali worker menerima pesan, ia akan menjalankan fungsi ini.
onmessage = (e) => {
const { id, payload } = e.data; // id untuk identifikasi tugas, payload berisi data tugas
console.log(`[Worker ${self.name}] Mulai memproses tugas ${id} dengan angka: ${payload.number}`);
// ✅ Simulasi tugas berat: Menghitung faktorial
// Ini akan memblokir worker thread, bukan main thread!
let result = 1;
for (let i = 2; i <= payload.number; i++) {
result *= i;
}
// Mengirim hasil kembali ke main thread
postMessage({ id, result, workerName: self.name });
console.log(`[Worker ${self.name}] Selesai memproses tugas ${id}.`);
};
Dalam worker.js, self.name adalah nama yang kita berikan ke worker saat inisialisasi, berguna untuk debugging. onmessage adalah event handler yang dipanggil saat worker menerima pesan dari main thread. postMessage digunakan untuk mengirim hasil kembali.
WorkerPool.js (Manajemen Pool)
Ini adalah inti dari Worker Pool kita. Kelas ini akan mengelola pembuatan worker, antrean tugas, dan distribusi tugas.
// WorkerPool.js
class WorkerPool {
constructor(workerScript, size) {
this.workerScript = workerScript; // Path ke skrip worker
this.size = size; // Ukuran pool (jumlah worker)
this.workers = []; // Array untuk menyimpan semua worker
this.idleWorkers = []; // Array untuk worker yang sedang menganggur
this.taskQueue = []; // Antrean tugas yang menunggu dieksekusi
this.nextTaskId = 0; // ID unik untuk setiap tugas
this._initializeWorkers();
}
_initializeWorkers() {
for (let i = 0; i < this.size; i++) {
const worker = new Worker(this.workerScript, { name: `Worker-${i}` });
worker.id = i; // ID unik untuk instance worker
worker.isBusy = false; // Status awal: tidak sibuk
// Event listener untuk pesan dari worker
worker.onmessage = (e) => this._onWorkerMessage(worker, e);
// Event listener untuk error dari worker
worker.onerror = (e) => this._onWorkerError(worker, e);
this.workers.push(worker);
this.idleWorkers.push(worker); // Awalnya semua worker idle
}
console.log(`✅ Worker Pool diinisialisasi dengan ${this.size} worker.`);
}
_onWorkerMessage(worker, e) {
const { id: taskId, result, workerName } = e.data;
console.log(`[Worker Pool] Tugas ${taskId} selesai oleh ${workerName}.`);
// Temukan dan selesaikan Promise yang terkait dengan tugas ini
const task = this.taskQueue.find(t => t.id === taskId);
if (task) {
task.resolve(result); // Selesaikan Promise dengan hasil
// Hapus tugas dari antrean setelah selesai
this.taskQueue = this.taskQueue.filter(t => t.id !== taskId);
}
worker.isBusy = false;
this.idleWorkers.push(worker); // Kembalikan worker ke pool idle
this._scheduleNextTask(); // Coba jalankan tugas berikutnya jika ada
}
_onWorkerError(worker, e) {
console.error(`⚠️ Error dari worker ${worker.name}:`, e);
// Temukan dan reject Promise yang terkait dengan tugas yang gagal
const task = this.taskQueue.find(t => t.workerId === worker.id);
if (task) {
task.reject(new Error(`Worker ${worker.name} gagal memproses tugas ${task.id}: ${e.message}`));
this.taskQueue = this.taskQueue.filter(t => t.id !== task.id);
}
// ❌ Worker mungkin rusak. Pertimbangkan untuk me-restart worker atau log error lebih lanjut.
// Untuk contoh sederhana ini, kita hanya menandai worker sebagai tidak sibuk dan mencoba melanjutkan.
worker.isBusy = false;
this._scheduleNextTask();
}
_scheduleNextTask() {
// Jika ada tugas di antrean dan ada worker yang menganggur
if (this.taskQueue.length > 0 && this.idleWorkers.length > 0) {
const worker = this.idleWorkers.shift(); // Ambil worker idle pertama
worker.isBusy = true;
const nextTask = this.taskQueue[0]; // Ambil tugas pertama dari antrean (jangan dihapus dulu)
nextTask.workerId = worker.id; // Simpan ID worker yang menangani tugas ini (untuk error handling)
// Kirim tugas ke worker
worker.postMessage({ id: nextTask.id, payload: nextTask.payload });
console.log(`🎯 Menugaskan tugas ${nextTask.id} ke worker ${worker.name}`);
}
}
runTask(payload) {
return new Promise((resolve, reject) => {
const taskId = this.nextTaskId++;
// Tambahkan tugas ke antrean, beserta resolve/reject Promise-nya
this.taskQueue.push({ id: taskId, payload, resolve, reject });
this._scheduleNextTask(); // Coba jalankan tugas
});
}
terminate() {
this.workers.forEach(worker => worker.terminate());
console.log('❌ Worker Pool dihentikan. Semua worker dimatikan.');
this.workers = [];
this.idleWorkers = [];
this.taskQueue = []; // Hapus semua tugas yang belum selesai
}
}
index.html dan main.js (Penggunaan Pool)
Sekarang, mari kita gunakan WorkerPool dari main thread.
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Worker Pool Demo</title>
<style>
body { font-family: sans-serif; margin: 20px; }
button { padding: 10px 15px; margin: 5px; cursor: pointer; }
#results { margin-top: 20px; border: 1px solid #eee; padding: 10px; min-height: 50px; background: #f9f9f9; }
.loading { color: gray; }
.success { color: green; }
.error { color: red; }
</style>
</head>
<body>
<h1>Web Worker Pool Demo</h1>
<p>Ini adalah demo Worker Pool. Klik "Mulai Tugas Berat" untuk menjalankan banyak perhitungan faktorial di Web Workers tanpa memblokir UI.</p>
<button id="startTasks">Mulai Tugas Berat (10x)</button>
<button id="terminatePool">Hentikan Pool</button>
<button id="resetPool">Reset Pool</button>
<div id="results"></div>
<script type="module" src="main.js"></script>
</body>
</html>
// main.js (di frontend utama)
import { WorkerPool } from './WorkerPool.js';
let pool; // Deklarasikan pool di luar agar bisa diakses oleh event listener
const resultsDiv = document.getElementById('results');
const startButton = document.getElementById('startTasks');
const terminateButton = document.getElementById('terminatePool');
const resetButton = document.getElementById('resetPool');
function initializePool() {
if (pool) {
pool.terminate(); // Pastikan pool sebelumnya dihentikan
}
pool = new WorkerPool('./worker.js', 4); // Inisialisasi pool dengan 4 worker
console.log('Pool siap digunakan.');
resultsDiv.innerHTML = '';
startButton.disabled = false;
terminateButton.disabled = false;
resetButton.disabled = true; // Nonaktifkan reset sampai ada tugas
}
// Inisialisasi pool saat halaman dimuat
initializePool();
startButton.addEventListener('click', async () => {
console.log('Memulai tugas-tugas...');
resultsDiv.innerHTML = '<p class="loading">Memulai tugas berat di background...</p>';
startButton.disabled = true; // Nonaktifkan tombol saat tugas berjalan
resetButton.disabled = false;
// Contoh banyak tugas yang akan di-offload
const numbersToProcess = [10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95];
try {
// Buat array Promise, satu untuk setiap tugas yang dijalankan di pool
const promises = numbersToProcess.map(num => pool.runTask({ number: num }));
// Tunggu semua tugas selesai
const allResults = await Promise.all(promises);
resultsDiv.innerHTML = '<h3 class="success">Semua tugas selesai!</h3>';
allResults.forEach((res, index) => {
resultsDiv.innerHTML += `<p>Faktorial ${numbersToProcess[index]} = ${res}</p>`;
});
console.log('Semua tugas selesai:', allResults);
} catch (error) {
resultsDiv.innerHTML = `<h3 class="error">Ada tugas yang gagal!</h3>