Multi Threading #

TypeScript berjalan di atas JavaScript — bahasa yang secara fundamental dirancang sebagai single-threaded. Ini bukan kelemahan desain, melainkan keputusan yang disengaja untuk menyederhanakan pemrograman: tidak ada race condition, tidak ada deadlock, tidak ada kebutuhan untuk mutex atau semaphore. Model concurrency JavaScript menggunakan event loop — mekanisme non-blocking yang sangat efisien untuk I/O-bound work (operasi jaringan, baca-tulis file). Masalah baru muncul saat kamu butuh CPU-bound work — komputasi berat yang memblokir event loop dan membuat aplikasi tidak responsif. Inilah kegunaan Web Workers (browser) dan Worker Threads (Node.js): menjalankan komputasi berat di thread terpisah tanpa memblokir thread utama. Artikel ini membahas keduanya secara mendalam dari perspektif TypeScript.

Model Single-Threaded dan Event Loop #

Sebelum membahas multi-threading, penting memahami mengapa TypeScript bisa menangani ribuan operasi concurrent tanpa multiple threads:

flowchart LR
    A[Kode TypeScript] --> B[Call Stack\nThread Utama]
    B --> C{Operasi\nBlocking?}
    C -- Tidak I/O murni --> B
    C -- Ya fetch, fs, timer --> D[Web APIs /\nlibuv]
    D --> E[Callback Queue /\nMicrotask Queue]
    E --> F[Event Loop]
    F -- Stack kosong --> B

    style B fill:#339af0,color:#fff
    style D fill:#51cf66,color:#fff
    style F fill:#fcc419,color:#000

Event loop bekerja dengan sangat efisien untuk I/O-bound work karena operasi I/O sebenarnya menunggu di luar JavaScript (di sistem operasi) — thread utama bebas menangani hal lain. Tapi untuk CPU-bound work, thread utama harus aktif menghitung, dan ini memblokir segalanya:

// I/O-bound — TIDAK memblokir event loop (aman)
async function ambilData(): Promise<void> {
  const res = await fetch("https://api.example.com"); // Thread bebas saat menunggu
  const data = await res.json();
  console.log(data);
}

// CPU-bound — MEMBLOKIR event loop (berbahaya!)
function hitungPrimaBesar(batas: number): number[] {
  const prima: number[] = [];
  for (let n = 2; n <= batas; n++) {
    let adalahPrima = true;
    for (let i = 2; i * i <= n; i++) {
      if (n % i === 0) { adalahPrima = false; break; }
    }
    if (adalahPrima) prima.push(n);
  }
  return prima; // Dengan batas=10_000_000, ini bisa membekukan UI selama beberapa detik!
}

Web Workers — Parallelisme di Browser #

Web Workers memungkinkan kamu menjalankan kode JavaScript di thread terpisah di dalam browser. Thread worker tidak bisa mengakses DOM, tapi bisa melakukan komputasi berat tanpa membekukan antarmuka pengguna.

Struktur File dan Konfigurasi TypeScript #

src/
├── main.ts          # Thread utama — akses DOM, UI
├── workers/
│   └── komputasi.worker.ts  # Worker — komputasi berat
└── types/
    └── worker-messages.ts   # Tipe bersama antara main dan worker
// src/types/worker-messages.ts
// Tipe yang dibagi antara main thread dan worker — kunci type safety komunikasi

export type PesanKeWorker =
  | { tipe: "HITUNG_PRIMA"; batas: number; requestId: string }
  | { tipe: "KOMPRESI_DATA"; data: number[]; requestId: string }
  | { tipe: "BATALKAN"; requestId: string };

export type PesanDariWorker =
  | { tipe: "HASIL_PRIMA"; prima: number[]; requestId: string }
  | { tipe: "HASIL_KOMPRESI"; hasil: number[]; requestId: string }
  | { tipe: "PROGRESS"; persen: number; requestId: string }
  | { tipe: "ERROR"; pesan: string; requestId: string };

Kode Worker #

// src/workers/komputasi.worker.ts
import type { PesanKeWorker, PesanDariWorker } from "../types/worker-messages";

// Di dalam worker, 'self' merujuk ke DedicatedWorkerGlobalScope
// TypeScript mengetahui ini jika lib dikonfigurasi dengan benar

function hitungBilPrima(batas: number, requestId: string): void {
  const prima: number[] = [];

  for (let n = 2; n <= batas; n++) {
    let adalahPrima = true;
    for (let i = 2; i * i <= n; i++) {
      if (n % i === 0) { adalahPrima = false; break; }
    }
    if (adalahPrima) prima.push(n);

    // Kirim progress setiap 100.000 iterasi
    if (n % 100_000 === 0) {
      const pesan: PesanDariWorker = {
        tipe: "PROGRESS",
        persen: Math.round((n / batas) * 100),
        requestId,
      };
      self.postMessage(pesan);
    }
  }

  const hasil: PesanDariWorker = {
    tipe: "HASIL_PRIMA",
    prima,
    requestId,
  };
  self.postMessage(hasil);
}

// Event listener dengan tipe yang aman
self.addEventListener("message", (event: MessageEvent<PesanKeWorker>) => {
  const pesan = event.data;

  switch (pesan.tipe) {
    case "HITUNG_PRIMA":
      hitungBilPrima(pesan.batas, pesan.requestId);
      break;
    case "KOMPRESI_DATA":
      // Implementasi kompresi...
      break;
    case "BATALKAN":
      // Worker tidak bisa benar-benar dibatalkan dari dalam,
      // tapi bisa menggunakan flag untuk berhenti lebih awal
      break;
  }
});

Menggunakan Worker di Main Thread #

// src/main.ts
import type { PesanKeWorker, PesanDariWorker } from "./types/worker-messages";

// Wrapper class yang type-safe untuk Web Worker
class WorkerPrima {
  private worker: Worker;
  private pendingRequests = new Map<
    string,
    {
      resolve: (prima: number[]) => void;
      reject: (error: Error) => void;
      onProgress?: (persen: number) => void;
    }
  >();

  constructor() {
    // Vite/webpack mengetahui cara menangani import Worker ini
    this.worker = new Worker(
      new URL("./workers/komputasi.worker.ts", import.meta.url),
      { type: "module" }
    );

    this.worker.addEventListener(
      "message",
      (event: MessageEvent<PesanDariWorker>) => {
        this.tanganiPesan(event.data);
      }
    );

    this.worker.addEventListener("error", (event) => {
      console.error("Worker error:", event.message);
    });
  }

  private tanganiPesan(pesan: PesanDariWorker): void {
    const pending = this.pendingRequests.get(pesan.requestId);
    if (!pending) return;

    switch (pesan.tipe) {
      case "HASIL_PRIMA":
        this.pendingRequests.delete(pesan.requestId);
        pending.resolve(pesan.prima);
        break;

      case "PROGRESS":
        pending.onProgress?.(pesan.persen);
        break;

      case "ERROR":
        this.pendingRequests.delete(pesan.requestId);
        pending.reject(new Error(pesan.pesan));
        break;
    }
  }

  hitungPrima(
    batas: number,
    onProgress?: (persen: number) => void
  ): Promise<number[]> {
    const requestId = crypto.randomUUID();

    return new Promise((resolve, reject) => {
      this.pendingRequests.set(requestId, { resolve, reject, onProgress });

      const pesan: PesanKeWorker = {
        tipe: "HITUNG_PRIMA",
        batas,
        requestId,
      };
      this.worker.postMessage(pesan);
    });
  }

  hentikan(): void {
    this.worker.terminate();
  }
}

// Penggunaan — tanpa membekukan UI
async function jalankan(): Promise<void> {
  const workerPrima = new WorkerPrima();

  console.log("Mulai menghitung... UI tetap responsif!");

  const prima = await workerPrima.hitungPrima(
    1_000_000,
    (persen) => console.log(`Progress: ${persen}%`)
  );

  console.log(`Ditemukan ${prima.length} bilangan prima`);
  workerPrima.hentikan();
}

Worker Threads di Node.js #

Node.js menyediakan modul worker_threads untuk menjalankan JavaScript di thread terpisah. API-nya mirip dengan Web Workers tapi dengan kemampuan tambahan seperti SharedArrayBuffer dan workerData.

Worker Dasar di Node.js #

// src/workers/kalkulasi.worker.ts — file worker Node.js
import { parentPort, workerData, isMainThread } from "worker_threads";

// Pastikan file ini dijalankan sebagai worker, bukan main thread
if (isMainThread) {
  throw new Error("File ini harus dijalankan sebagai worker thread!");
}

interface DataWorker {
  angka: number[];
  operasi: "jumlah" | "rata-rata" | "maks" | "min";
}

interface HasilWorker {
  hasil: number;
  durasi: number;
}

const { angka, operasi } = workerData as DataWorker;
const mulai = Date.now();

let hasil: number;

switch (operasi) {
  case "jumlah":
    hasil = angka.reduce((a, b) => a + b, 0);
    break;
  case "rata-rata":
    hasil = angka.reduce((a, b) => a + b, 0) / angka.length;
    break;
  case "maks":
    hasil = Math.max(...angka);
    break;
  case "min":
    hasil = Math.min(...angka);
    break;
}

const hasilAkhir: HasilWorker = {
  hasil,
  durasi: Date.now() - mulai,
};

parentPort?.postMessage(hasilAkhir);
// src/main.ts — Main thread Node.js
import { Worker, isMainThread } from "worker_threads";
import path from "path";

interface DataWorker {
  angka: number[];
  operasi: "jumlah" | "rata-rata" | "maks" | "min";
}

interface HasilWorker {
  hasil: number;
  durasi: number;
}

function jalankanWorker(data: DataWorker): Promise<HasilWorker> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(
      path.resolve(__dirname, "./workers/kalkulasi.worker.js"),
      { workerData: data }
    );

    worker.on("message", (hasil: HasilWorker) => resolve(hasil));
    worker.on("error", reject);
    worker.on("exit", (kode) => {
      if (kode !== 0) {
        reject(new Error(`Worker berhenti dengan kode: ${kode}`));
      }
    });
  });
}

// Jalankan beberapa kalkulasi secara paralel
async function main(): Promise<void> {
  const dataSet = Array.from({ length: 10_000_000 }, (_, i) => i + 1);

  console.log("Menjalankan kalkulasi paralel...");

  const [hasilJumlah, hasilRataRata, hasilMaks] = await Promise.all([
    jalankanWorker({ angka: dataSet, operasi: "jumlah" }),
    jalankanWorker({ angka: dataSet, operasi: "rata-rata" }),
    jalankanWorker({ angka: dataSet, operasi: "maks" }),
  ]);

  console.log(`Jumlah: ${hasilJumlah.hasil} (${hasilJumlah.durasi}ms)`);
  console.log(`Rata-rata: ${hasilRataRata.hasil} (${hasilRataRata.durasi}ms)`);
  console.log(`Maks: ${hasilMaks.hasil} (${hasilMaks.durasi}ms)`);
}

main().catch(console.error);

Worker Pool — Mengelola Multiple Workers #

Membuat worker baru untuk setiap task itu mahal. Worker pool mempertahankan kumpulan worker yang siap pakai:

// src/worker-pool.ts
import { Worker } from "worker_threads";
import path from "path";

interface Task<T, R> {
  data: T;
  resolve: (result: R) => void;
  reject: (error: Error) => void;
}

class WorkerPool<T, R> {
  private workers: Worker[] = [];
  private workerTersedia: number[] = [];
  private antrean: Task<T, R>[] = [];
  private readonly ukuranPool: number;

  constructor(
    private readonly workerScript: string,
    ukuranPool?: number
  ) {
    // Default: jumlah CPU yang tersedia, maksimal 4 worker
    const { cpus } = require("os");
    this.ukuranPool = ukuranPool ?? Math.min(cpus().length, 4);
    this.inisialisasi();
  }

  private inisialisasi(): void {
    for (let i = 0; i < this.ukuranPool; i++) {
      const worker = new Worker(this.workerScript);
      const id = this.workers.push(worker) - 1;
      this.workerTersedia.push(id);

      worker.on("message", (hasil: R) => {
        const task = this.antrean.shift();
        if (task) {
          task.resolve(hasil);
        } else {
          this.workerTersedia.push(id);
        }
      });

      worker.on("error", (error) => {
        const task = this.antrean.shift();
        if (task) {
          task.reject(error);
        }
        this.workerTersedia.push(id);
      });
    }
  }

  eksekusi(data: T): Promise<R> {
    return new Promise((resolve, reject) => {
      const task: Task<T, R> = { data, resolve, reject };

      const workerId = this.workerTersedia.shift();
      if (workerId !== undefined) {
        this.workers[workerId].postMessage(data);
        // Simpan resolve/reject untuk digunakan saat message diterima
        // (Implementasi penuh perlu mengelola mapping workerId → task)
      } else {
        // Semua worker sibuk — masukkan ke antrean
        this.antrean.push(task);
      }
    });
  }

  async hentikanSemua(): Promise<void> {
    await Promise.all(this.workers.map((w) => w.terminate()));
  }

  get statistik() {
    return {
      total: this.ukuranPool,
      tersedia: this.workerTersedia.length,
      sibuk: this.ukuranPool - this.workerTersedia.length,
      antrean: this.antrean.length,
    };
  }
}

SharedArrayBuffer dan Transfer Ownership #

Komunikasi antar thread melalui postMessage secara default melakukan structured clone — data disalin, bukan dibagi. Untuk data besar, ini bisa lambat. Ada dua cara lebih efisien:

Transferable — Transfer Ownership #

// Transfer ArrayBuffer ke worker — tanpa menyalin data
// Setelah transfer, buffer tidak bisa lagi diakses di main thread

const buffer = new ArrayBuffer(1024 * 1024); // 1 MB
const view = new Uint8Array(buffer);
view.fill(42);

// Transfer ownership ke worker
worker.postMessage({ buffer }, [buffer]);

// Setelah ini, buffer sudah tidak valid di main thread
// view.byteLength === 0 — buffer sudah ditransfer

// Di worker, terima dan gunakan buffer
self.addEventListener("message", (event) => {
  const { buffer } = event.data as { buffer: ArrayBuffer };
  const data = new Uint8Array(buffer);
  // Proses data...
  // Transfer kembali ke main thread
  self.postMessage({ hasil: buffer }, [buffer]);
});

SharedArrayBuffer — Memori Bersama #

// SharedArrayBuffer — buffer yang bisa diakses dari main thread DAN worker secara bersamaan
// PERHATIAN: Memerlukan header keamanan di server (COOP + COEP)

const buffer = new SharedArrayBuffer(4); // 4 byte = 1 integer 32-bit
const counter = new Int32Array(buffer);

// Main thread
worker.postMessage({ counter }); // Tidak disalin — DIBAGI

// Worker
self.addEventListener("message", (event) => {
  const { counter } = event.data as { counter: Int32Array };

  // Gunakan Atomics untuk operasi yang aman secara konkuren
  Atomics.add(counter, 0, 1); // Atomically increment counter[0]
  Atomics.notify(counter, 0); // Beritahu thread yang menunggu
});

// Main thread: tunggu perubahan
Atomics.wait(counter, 0, 0); // Tunggu hingga counter[0] bukan 0
console.log("Counter:", counter[0]); // 1
SharedArrayBuffer memerlukan header HTTP khusus di server untuk mencegah serangan Spectre: Cross-Origin-Opener-Policy: same-origin dan Cross-Origin-Embedder-Policy: require-corp. Tanpa header ini, SharedArrayBuffer tidak tersedia di browser modern. Di Node.js, tidak ada batasan ini.

Kapan Menggunakan Worker Threads #

Ini adalah pertanyaan yang sering dijawab salah. Worker threads ada overhead — pembuatan thread, serialisasi data, komunikasi. Manfaatnya hanya nyata untuk pekerjaan yang cukup berat:

flowchart TD
    A{Jenis Pekerjaan?} --> B[I/O-bound\nfetch, file, DB]
    A --> C[CPU-bound ringan\n< 50ms]
    A --> D[CPU-bound berat\n> 100ms]

    B --> E[async/await + event loop\nSudah optimal, TIDAK perlu worker]
    C --> F[Mungkin tidak perlu worker\nOverhead bisa lebih besar dari manfaat]
    D --> G{Bisa diparalelkan?}

    G -- Ya, data independen --> H[Worker Threads\nSangat Disarankan]
    G -- Tidak, sequential --> I[Optimalkan algoritma\nWorker tidak membantu]

    style E fill:#51cf66,color:#fff
    style F fill:#fcc419,color:#000
    style H fill:#339af0,color:#fff
    style I fill:#ff6b6b,color:#fff

Kasus yang Tepat untuk Worker Threads #

✓ Pemrosesan gambar atau video (resize, filter, encode)
✓ Komputasi matematika intensif (machine learning inference, kriptografi)
✓ Parsing file besar (CSV/JSON besar, XML kompleks)
✓ Kompresi/dekompresi data
✓ Pencarian teks dalam corpus yang besar
✓ Rendering kompleks (3D, raytracing)

Kasus yang Tidak Perlu Worker Threads #

✗ Operasi database (sudah async via driver)
✗ HTTP requests (async via fetch/axios)
✗ File I/O kecil (Node.js fs sudah async)
✗ Transformasi data sederhana (map, filter pada array kecil)
✗ Query string parsing, JSON.parse untuk data kecil

Ringkasan #

  • JavaScript/TypeScript adalah single-threaded secara fundamental — event loop menangani concurrency untuk I/O-bound work dengan sangat efisien tanpa multiple threads.
  • Worker threads hanya perlu untuk CPU-bound work — jika kamu menunggu jaringan atau file, async/await sudah cukup; worker threads hanya membantu saat thread utama sibuk menghitung.
  • Definisikan tipe pesan yang dibagi antara main thread dan worker dalam file terpisah — ini adalah satu-satunya cara mendapatkan type safety penuh pada komunikasi antar thread karena worker dan main thread tidak berbagi type scope secara langsung.
  • Buat wrapper class yang type-safe untuk worker — alih-alih menggunakan postMessage dan addEventListener langsung, bungkus dalam class yang mengeksposes API Promise yang familiar.
  • Worker Pool lebih efisien dari worker per-task — membuat worker baru untuk setiap tugas ada overhead; pertahankan pool worker yang siap pakai untuk tugas-tugas berulang.
  • Transferable untuk data besar — gunakan transfer ownership alih-alih structured clone untuk ArrayBuffer yang besar; ini menghindari penyalinan memori yang mahal.
  • SharedArrayBuffer + Atomics untuk state bersama — hanya dibutuhkan untuk kasus ekstrem di mana worker dan main thread perlu berbagi memori; perlukan header HTTP khusus di browser.
  • Ukur sebelum mengoptimalkan — overhead worker thread (pembuatan, serialisasi, komunikasi) bisa lebih besar dari manfaatnya untuk tugas yang ringan; profile dulu sebelum menambahkan kompleksitas worker threads.

← Sebelumnya: Vendoring   Berikutnya: I/O →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact