✦

Event Loop Node.js — vì sao API async vẫn chặn cả server

•Phong Hy

Thử đoán xem chuyện gì đang xảy ra: một endpoint chỉ mất 40ms khi test một mình. Đẩy lên production, dưới 200 request đồng thời, nó thành 2 giây. Code không đổi, database không nghẽn, CPU chỉ chiếm đúng một core. Thủ phạm thường là event loop bị chặn — và trớ trêu là người chặn nó lại chính là chữ async mà ai cũng nghĩ là bất đồng bộ.

Event loop không chạy song song, nó chạy vòng

Node dùng một main thread cho JavaScript. Event loop đi qua các phase theo vòng lặp: timers → pending callbacks → poll → check → close callbacks. Mỗi phase có hàng đợi callback riêng, và trong một phase thì callback chạy nối tiếp nhau, xong hết mới sang phase sau.

Hệ quả rất trực tiếp: bất kỳ đoạn code đồng bộ nào chạy trên main thread cũng làm cả hàng đợi đứng yên. Không request nào được xử lý, timer không kịp bắn, health check cũng timeout theo.

Một phần việc nặng được đẩy xuống libuv threadpool (mặc định 4 thread) — fs, dns.lookup, crypto bản async, zlib. Nhưng threadpool chỉ giúp phần I/O. Phần tính toán trong JS thì không có ai giúp cả.

await chỉ nhường quyền đúng ở điểm chờ I/O

// ❌ trông có vẻ bất đồng bộ, nhưng vẫn chặn server
app.get('/export', async (req, res) => {
  const rows = await db.query('SELECT * FROM orders'); // nhường quyền ở đây
  const csv = rows.map(toCsvLine).join('\n');          // 800ms đồng bộ, cả server đứng
  res.type('text/csv').send(csv);
});

await chỉ tạm dừng hàm tại đúng chỗ chờ I/O. Khi db.query trả về, phần còn lại chạy thẳng một mạch trên main thread. Với 200.000 dòng, map cộng join ngốn gần một giây, và suốt thời gian đó mọi request khác nằm chờ trong hàng đợi.

Muốn chia nhỏ việc thì phải tự nhường quyền giữa các lô:

async function toCsvInChunks(rows) {
  const parts = [];
  for (let i = 0; i < rows.length; i += 1000) {
    parts.push(rows.slice(i, i + 1000).map(toCsvLine).join('\n'));
    await new Promise((resolve) => setImmediate(resolve)); // trả main thread về cho event loop
  }
  return parts.join('\n');
}

Những thứ chặn event loop mà ít ai để ý

  • fs.readFileSync hoặc JSON.parse file vài chục MB — 200 đến 400ms mỗi lần
  • crypto.pbkdf2Sync, bcrypt.hashSync nằm ngay trong luồng login
  • zlib.gzipSync khi nén response lớn
  • Regex backtracking: pattern kiểu /^(\w+\s?)+$/ gặp input xấu là treo hàng chục giây
  • console.log object lớn trong vòng lặp, hoặc JSON.stringify để ghi log
  • Vòng for duyệt một triệu phần tử ngay trong handler

Điểm chung: tất cả đều là code đồng bộ, và không có await nào cứu được.

Đo trước khi đoán

Node có sẵn thước đo, không cần cài gì:

const { monitorEventLoopDelay } = require('node:perf_hooks');

const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();

setInterval(() => {
  console.log('event loop lag p99:', (h.percentile(99) / 1e6).toFixed(1), 'ms');
  h.reset();
}, 5000).unref();

Ngưỡng tham khảo mình hay dùng: p99 lag dưới 20ms là khỏe, 50 đến 100ms bắt đầu có vấn đề, trên 200ms thì chắc chắn có code đồng bộ nặng. Đẩy con số này lên Prometheus rồi vẽ chart, lần sau không phải ngồi đoán nữa.

Với tải thật thì dùng công cụ bắn request, đừng curl một phát rồi kết luận:

npx autocannon -c 100 -d 20 http://localhost:3000/export

Sửa theo thứ tự này

  1. Thay bản sync bằng bản async — fs.promises.readFile, crypto.pbkdf2, zlib.gzip. Cùng kết quả, không chặn main thread.
  2. Chia lô và nhường quyền như đoạn setImmediate phía trên — cách rẻ nhất, hiệu quả rõ nhất với việc xử lý danh sách.
  3. Việc thật sự nặng thì đẩy sang worker thread — nén ảnh, tính hash, xử lý tài liệu:
const { Worker } = require('node:worker_threads');

function runInWorker(file, payload) {
  return new Promise((resolve, reject) => {
    const w = new Worker(file, { workerData: payload });
    w.once('message', resolve);
    w.once('error', reject);
  });
}
  1. Stream thay vì nạp hết vào RAM khi xử lý file lớn — đi theo luồng luôn nhẹ hơn đọc một cục.

Vài chỗ mình từng vấp

  • Log quá tay. Có lần service chậm cả ngày, cuối cùng chỉ vì console.log(req.body) cho mọi request upload vài MB. Xoá dòng log là xong.
  • Tưởng scale ngang là đủ. Thêm 8 instance, mỗi instance vẫn đứng 800ms trong lúc nén. Scale ngang che triệu chứng ở mức trung bình, nhưng p99 vẫn xấu và người dùng vẫn thấy khó chịu.
  • Nhìn avg thay vì p99. Trung bình đẹp long lanh mà p99 tệ chính là dấu hiệu kinh điển của event loop bị chặn. Xem p99 trước.
  • Tăng UV_THREADPOOL_SIZE như một cách chữa. Nó chỉ có tác dụng khi nghẽn thật sự nằm ở threadpool, còn code JS đồng bộ thì tăng bao nhiêu cũng vô ích.

Chốt lại

Event loop không chậm. Nó chỉ chờ ai đó viết xong code đồng bộ. Bất đồng bộ trong Node không nằm ở chữ async gõ ra, mà nằm ở việc có nhường main thread hay không.

Lần tới thấy API chậm bất thường, thử bật monitorEventLoopDelay trước khi lao vào soi database. Bạn từng bị event loop chặn vì thứ gì?

📋 Phụ lục thuật ngữ

Thuật ngữ Giải thích ngắn
Event loop Vòng lặp của Node lấy callback từ hàng đợi và chạy trên main thread
Phase Từng chặng trong một vòng loop: timers, poll, check, close callbacks
libuv threadpool Nhóm thread phụ lo I/O file, DNS, crypto, zlib (mặc định 4 thread)
setImmediate Đẩy callback sang phase check, nhường main thread cho việc khác
monitorEventLoopDelay API trong node:perf_hooks đo độ trễ của event loop theo percentile
Event loop lag Khoảng thời gian callback phải chờ lâu hơn dự kiến — chỉ số nghẽn chính
Worker thread Luồng JS riêng, dùng cho việc tính toán nặng mà không chặn main thread
p99 99% request nhanh hơn mốc này; phản ánh trải nghiệm tệ nhất tốt hơn số trung bình
✦