Ở phần 1 và phần 2 chúng ta đã tìm hiểu về Child processes và Worker threads, cả 2 đều là giải pháp đưa tác vụ nặng ra khỏi luồng chính để tránh block các request khác. Cả 2 đều hoạt động dựa trên ý tưởng: “ê ku, ra chỗ khác mà chạy cái code tốn CPU của mày đi, đừng có đứng đây làm tắc đường”.

Cluster trong Nodejs

Còn với Cluster thì sao? Khác với 2 ông trên, Cluster không đưa tác vụ nặng ra chỗ khác, mà nó… nhân bản toàn bộ server của bạn lên. Mỗi bản sao chạy trên một process riêng, mỗi process xài một nhân CPU riêng. Và điều kỳ diệu là tất cả chúng cùng chia sẻ chung một cổng (port). Request vào sẽ được phân phối cho các worker xử lý thông qua thuật toán round-robin.

Nói cách khác, nếu Child processes và Worker threads giống như thuê thêm thợ phụ để làm mấy việc nặng cho mình, thì Cluster giống như… mở thêm 4 cái quán y chang cái quán hiện tại, cùng bán chung một mặt tiền, khách vào được chia đều cho các quán. Quán nào đang bận thì khách được đưa sang quán khác.

Quay lại bài toán tính số fibonacci từ phần 1, lần này thay vì fork ra child process hay tạo worker thread, chúng ta sẽ dùng cluster để fork ra nhiều instance của chính server đó.

Cách Cluster hoạt động

Đầu tiên cần hiểu cluster trong Nodejs không phải là load balancer như Nginx. Nó là một module cho phép bạn tạo ra nhiều process con (worker) từ một process cha (master). Tất cả worker cùng lắng nghe trên một port, master sẽ phân phối kết nối đến cho từng worker.

Module cluster có sẵn trong Nodejs, không cần cài thêm gì hết. Flow hoạt động cơ bản:

  1. Master process khởi động, fork ra N worker (thường là bằng số nhân CPU)
  2. Mỗi worker là một instance độc lập của ứng dụng
  3. Master lắng nghe port và phân phối request tới worker qua round-robin
  4. Nếu worker chết, master có thể tự động fork worker mới thay thế

Code thực tế

Chúng ta sẽ tách code thành 2 file: cluster.js (entry point cho master) và server.js (code server mà mỗi worker sẽ chạy).

File: cluster.js

import cluster from "cluster"
import os from "os"
import fs from "fs"

if (cluster.isMaster) {
  console.log("[Cluster] Master process đang bắt đầu chạy.", process.pid)

  // Fork worker dựa trên số nhân CPU
  for (var i = 0, coreCount = os.cpus().length; i < coreCount; i++) {
    cluster.fork()
  }

  // Khi worker chết (ví dụ do uncaught exception)
  // Master sẽ tự động tạo worker mới thay thế
  cluster.on("exit", (worker, code, signal) => {
    console.log("[Cluster] Worker kết thúc.", worker.process.pid)
    console.log("[Cluster] Tử ẹo:", worker.exitedAfterDisconnect)

    if (!worker.exitedAfterDisconnect) {
      // Worker chết bất đắc kỳ tử -> tạo mới
      cluster.fork()
    }
  })
} else {
  // Code trong nhánh này sẽ được chạy bởi worker
  eval(fs.readFileSync("./server.js", "utf8"))
  console.log("[Worker] Worker bắt đầu.", process.pid)
}

Một vài điểm cần lưu ý:

  • cluster.isMaster để phân biệt master process và worker process. Từ Node 16+ thì nên dùng cluster.isPrimary (do isMaster đã bị deprecated).
  • Worker chết vì lỗi sẽ được tự động fork lại. Cẩn thận nếu code worker có bug khiến nó crash ngay khi khởi tạo, bạn sẽ dính vòng lặp fork-chết-fork vô hạn, CPU lên 100% trong nháy mắt.
  • eval(fs.readFileSync(...)) là cách chạy code server trong worker mà không cần require/import động. Cách làm này không phải best practice nhưng tiện cho demo. Production thì nên tách hẳn worker thành file riêng và dùng cluster.setupMaster().

File: server.js

import http from "http"

http.createServer(
  function handleRequest(request, response) {
    response.writeHead(200, { "Content-Type": "text/html" })
    response.write("Hello world from process " + process.pid + ".")

    // Giữ request mở 300ms để demo round-robin
    setTimeout(response.end.bind(response), 300)
  }
).listen(8000)

Server đơn giản lắng nghe trên port 8000. Điều thú vị là mỗi worker đều gọi .listen(8000) nhưng không hề bị lỗi EADDRINUSE. Đây chính là “phép thuật” của cluster: các worker chia sẻ port thông qua IPC với master, master sẽ tiếp nhận connection và phân phối cho worker.

Chạy thử

node cluster.js

Mở trình duyệt, truy cập http://localhost:8000, refresh vài lần. Bạn sẽ thấy process.pid thay đổi qua mỗi lần refresh — minh chứng cho việc request đang được phân phối luân phiên qua các worker.

Vậy khi nào dùng gì?

Child processes Worker threads Cluster
Dùng khi Cần chạy code ngôn ngữ khác (Python, C++, …) Cần chia sẻ bộ nhớ giữa các thread Cần scale server theo chiều ngang trên cùng 1 máy
Bộ nhớ Độc lập, tốn RAM Chia sẻ (SharedArrayBuffer) Độc lập, tốn RAM
Giao tiếp IPC (send/on) postMessage, MessagePort Không trực tiếp (qua master)
Overhead Nặng (tạo process mới) Nhẹ hơn process Trung bình

Child processes và Worker threads là giải pháp cho bài toán “đưa code nặng ra khỏi luồng chính”. Còn Cluster là giải pháp cho bài toán “tận dụng hết CPU để xử lý nhiều request cùng lúc”. Chúng không thay thế nhau, mà bổ trợ cho nhau.

Thực tế, không ai cấm bạn dùng Cluster + Worker threads cùng lúc: mỗi worker trong cluster lại tự tạo worker threads riêng để xử lý tác vụ nặng. Khi đó bạn vừa scale được theo chiều ngang (cluster), vừa tối ưu được CPU-intensive task (worker threads). Đây cũng là cách mà các framework như NestJS hay các production app thường triển khai.

Nói đến đây tự nhiên nhớ hồi đi phỏng vấn, được hỏi “làm sao để Nodejs chạy đa luồng?”, mình trả lời “dùng cluster”, ông interviewer cười khẩy “cluster có phải đa luồng đâu”. Mình cũng cười theo vì thực ra ổng nói đúng, cluster là đa process chứ không phải đa luồng. Nhưng kệ đi, quan trọng là biết dùng đúng chỗ.

Hy vọng qua 3 phần các bạn đã có cái nhìn tổng quan về các phương án phân phối xử lý trong Nodejs. Tùy bài toán mà chọn vũ khí phù hợp, đừng có cầm súng săn voi đi bắn chuột.