Event loop trong Node.js: vì sao một luồng vẫn xử lý được hàng nghìn kết nối

  • author-image

    Nguyễn Đình Cường

  • blog-tag
  • blog-comment 0 comment
  • created-date 09 Nov, 2026
blog-thumbnail

Node.js chạy JavaScript trên một luồng chính, vậy mà vẫn phục vụ được rất nhiều kết nối cùng lúc. Bí quyết nằm ở event loop và mô hình I/O không chặn. Hiểu nó giúp bạn tránh được lỗi khiến cả server đứng hình chỉ vì một vòng lặp nặng.

Một luồng nhưng không chờ đợi

Hãy nghĩ đến một nhân viên pha chế giỏi. Anh ta nhận đơn, bật máy xay, rồi trong lúc chờ lại nhận thêm đơn khác thay vì đứng nhìn máy. Node.js làm tương tự: khi gặp thao tác I/O như đọc file, truy vấn database hay gọi mạng, nó giao việc cho hệ điều hành (qua thư viện libuv), tiếp tục chạy code khác, và khi việc xong thì một hàm callback được xếp hàng để chạy.

Vì vậy Node.js rất mạnh với tác vụ nhiều I/O (API, chat, proxy), nhưng yếu với tác vụ nặng về tính toán CPU liên tục vì chúng chiếm luôn luồng duy nhất.

Các hàng đợi trong event loop

Ở mức dễ hiểu, event loop lặp đi lặp lại qua các giai đoạn, mỗi giai đoạn xử lý một loại callback: bộ đếm giờ (setTimeout, setInterval), callback I/O, setImmediate... Bên cạnh đó có hai hàng đợi ưu tiên cao chạy xen kẽ sau mỗi thao tác: process.nextTick và hàng đợi microtask của Promise.

Thử đoán thứ tự in ra của đoạn mã sau:

console.log('1: đồng bộ');setTimeout(() => console.log('4: setTimeout'), 0);Promise.resolve().then(() => console.log('3: promise'));process.nextTick(() => console.log('2b: nextTick'));console.log('2: đồng bộ tiếp');

Kết quả: hai dòng đồng bộ in trước, sau đó nextTick, rồi microtask của Promise, và cuối cùng mới tới setTimeout. Quy tắc nhớ nhanh: code đồng bộ chạy hết trước, rồi tới nextTick, rồi microtask, rồi các callback của event loop.

Cạm bẫy: chặn event loop

Đoạn code sau làm cả server không phản hồi trong lúc chạy:

app.get('/heavy', (req, res) => {  let sum = 0;  for (let i = 0; i < 5e9; i++) sum += i; // chiếm luồng duy nhất  res.json({ sum });});

Trong lúc vòng lặp chạy, mọi request khác đều phải chờ. Các thủ phạm quen thuộc khác gồm: các hàm đồng bộ như fs.readFileSync trong request handler, JSON.parse trên dữ liệu khổng lồ, regex phức tạp gây backtracking nặng, và băm mật khẩu đồng bộ.

Cách xử lý việc nặng

  • Worker Threads: chuyển tác vụ CPU sang luồng riêng.
  • Cluster hoặc nhiều tiến trình: chạy nhiều bản Node, tận dụng mọi nhân CPU, thường kết hợp trình quản lý như PM2 hoặc container.
  • Hàng đợi tác vụ nền: đẩy việc nặng sang worker riêng, ví dụ qua Redis.
  • Chia nhỏ công việc: xử lý từng đợt và nhường lại cho event loop bằng setImmediate.
const { Worker } = require('node:worker_threads');function runHeavy(n) {  return new Promise((resolve, reject) => {    const w = new Worker('./heavy.js', { workerData: n });    w.on('message', resolve);    w.on('error', reject);  });}

Quan sát độ trễ của event loop

Khi nghi ngờ server bị chặn, hãy đo độ trễ vòng lặp. Cách thô sơ là dùng một bộ đếm giờ 100 ms và xem nó thực sự chạy trễ bao nhiêu; nếu độ trễ thường xuyên cao, có gì đó đang chiếm luồng. Các công cụ profiling tích hợp như node --inspect hay --prof giúp bạn tìm ra đúng hàm tốn thời gian.

Ví dụ: so sánh đồng bộ và bất đồng bộ

Để cảm nhận rõ khác biệt, hãy so sánh hai cách đọc file trong một server:

// Chặn event loop trong lúc đọcapp.get('/a', (req, res) => {  const text = fs.readFileSync('big.txt', 'utf8');  res.send(text);});// Không chặn: nhường luồng cho request khácapp.get('/b', async (req, res) => {  const text = await fs.promises.readFile('big.txt', 'utf8');  res.send(text);});

Với một người dùng bạn khó thấy khác biệt. Nhưng khi hàng trăm request đến cùng lúc, phiên bản đầu khiến chúng xếp hàng chờ nhau, còn phiên bản sau để Node phục vụ các request khác trong lúc hệ điều hành đọc file. Đó là lý do trong mã nguồn server, các hàm có hậu tố Sync chỉ nên xuất hiện lúc khởi động ứng dụng, không nằm trong luồng xử lý request.

Những hiểu lầm thường gặp

Nhiều người cho rằng Node.js "chỉ có một luồng" nên không thể chạy song song. Thực tế, mã JavaScript của bạn chạy trên một luồng, nhưng phía sau có nhóm luồng của libuv xử lý một số thao tác như đọc file, DNS, nén. Hiểu đúng điều này giúp bạn biết khi nào cần thêm tiến trình hoặc worker thread để tận dụng nhiều nhân CPU.

Kết luận

Node.js nhanh khi bạn để event loop rảnh. Hãy nhớ nguyên tắc: không chặn luồng chính, dùng phiên bản bất đồng bộ của các hàm I/O, và đẩy việc tính toán nặng sang nơi khác. Nắm chắc điều này, bạn sẽ viết server Node vừa nhanh vừa ổn định.

Nếu muốn luyện tập cùng bài tập thực tế, hãy ghé các khóa học tại codebem.com khi bạn sẵn sàng.

author_photo
Nguyễn Đình Cường

0 comment