DEV Community

Async — Event Loop

Event loop: vì sao thứ tự chạy không như đọc code, và microtask starvation

JavaScript chạy single-threaded: chỉ một dòng lệnh thực thi tại một thời điểm. Event loop là cơ chế cho phép một thread duy nhất xử lý hàng nghìn việc bất đồng bộ mà không block — nó liên tục lấy việc từ các hàng đợi và chạy. Mấu chốt là có hai loại hàng đợi với độ ưu tiên khác nhau: macrotask (timer, I/O callback) và microtask (Promise callback, queueMicrotask). Hiểu thứ tự chúng chạy giải thích vì sao output không theo thứ tự dòng code, và vì sao một vòng microtask bất tận có thể làm treo cả ứng dụng dù không có vòng lặp while(true) nào.

Cơ chế hoạt động

Mỗi vòng (tick) của event loop làm theo trình tự: chạy một macrotask, rồi làm cạn toàn bộ microtask queue trước khi chạm macrotask tiếp theo. Microtask luôn được ưu tiên vét sạch giữa hai macrotask.

console.log('1')                          // sync
setTimeout(() => console.log('2'), 0)     // macrotask
Promise.resolve().then(() => console.log('3')) // microtask
console.log('4')                          // sync
// in ra: 1, 4, 3, 2
Enter fullscreen mode Exit fullscreen mode

Code đồng bộ (1, 4) chạy trước, hết stack. Rồi microtask queue được vét (3). Cuối cùng mới tới macrotask setTimeout (2) — dù delay là 0. setTimeout(fn, 0) không nghĩa là "chạy ngay" mà là "xếp vào macrotask, chạy sau khi mọi việc đồng bộ và mọi microtask hiện có xong".

Trong Node có thêm các phase (timers, poll, check cho setImmediate, và process.nextTick ưu tiên cao hơn cả Promise microtask), nhưng nguyên tắc cốt lõi giống nhau: microtask vét sạch giữa các đơn vị công việc lớn hơn.

Vấn đề gặp trong production

Failure mode 1: chặn event loop bằng việc đồng bộ nặng. Vì chỉ một thread, một vòng lặp CPU nặng hoặc một thao tác đồng bộ chậm (đọc file sync, JSON.parse một payload khổng lồ, vòng lặp hàng triệu phần tử) chặn toàn bộ — không request nào khác được xử lý trong lúc đó:

app.get('/report', (req, res) => {
  const data = computeHugeReportSync() // chặn event loop vài giây
  res.json(data) // mọi request khác treo trong lúc này
})
Enter fullscreen mode Exit fullscreen mode

Trong một server, đây là lý do "một endpoint chậm làm cả service đơ": event loop bị một request giữ, các request khác xếp hàng. Cách xử lý là đẩy việc CPU nặng ra worker thread, hoặc chia nhỏ thành các đoạn nhường lại event loop giữa chừng.

Failure mode 2: microtask starvation. Vì microtask queue được vét hoàn toàn trước khi sang macrotask, một chuỗi microtask tự sinh microtask mới sẽ chạy mãi, không bao giờ để macrotask (timer, I/O, render) có cơ hội:

function loop() {
  Promise.resolve().then(loop) // mỗi microtask sinh microtask mới
}
loop() // event loop kẹt vét microtask, setTimeout/IO không bao giờ chạy
Enter fullscreen mode Exit fullscreen mode

Không có lỗi nào ném, không có vòng while nào — nhưng ứng dụng đơ, timer không kích hoạt, request mới không được nhận. Bug này tinh vi vì code "trông async". Nếu cần lặp lại việc gì đó liên tục mà vẫn để hệ thống thở, dùng macrotask (setTimeout/setImmediate) để mỗi vòng nhường lại event loop, thay vì microtask.

Cách debug và monitor

Triệu chứng event loop bị chặn: độ trễ tăng vọt trên mọi endpoint cùng lúc, không chỉ endpoint nặng; health check timeout dù CPU không kẹt ở mức process. Trong Node, đo event loop lag (thời gian một callback bị trễ so với lịch) — thư viện monitoring phơi ra chỉ số này; lag tăng đều là dấu hiệu có việc đồng bộ nặng hoặc microtask starvation. Để khoanh vùng, dùng --prof hoặc một profiler để tìm hàm đồng bộ chiếm nhiều thời gian trong một tick. Quy tắc phòng ngừa: không làm việc CPU nặng hay I/O đồng bộ trong request handler; chia nhỏ hoặc đẩy sang worker.

Tradeoff

Microtask ưu tiên cao cho phép phản hồi nhanh sau khi một Promise resolve (chạy ngay trong tick hiện tại, không đợi vòng sau), tốt cho việc nối tiếp logic async độ trễ thấp. Cái giá là chúng có thể làm starve macrotask nếu sinh ra không kiểm soát — chiếm trọn quyền chạy. Macrotask (setTimeout/setImmediate) thì luôn nhường lại event loop mỗi vòng, an toàn cho việc lặp dài, nhưng độ trễ cao hơn vì phải đợi tới phase tương ứng. Quy tắc thực tế: logic nối tiếp ngắn sau async → để microtask tự nhiên (Promise); việc lặp dài hoặc cần nhường nhịp cho I/O/timer → dùng macrotask; việc CPU nặng → ra khỏi event loop hẳn (worker thread).

Câu hỏi phỏng vấn

Event loop hoạt động thế nào, và vì sao Promise.then chạy trước setTimeout(fn, 0)?

Event loop chạy single-threaded: mỗi vòng nó chạy một macrotask rồi vét sạch toàn bộ microtask queue trước khi sang macrotask kế tiếp. Promise callback là microtask, setTimeout là macrotask; nên sau khi code đồng bộ chạy hết stack, microtask (.then) được vét trước, rồi mới tới macrotask (setTimeout) — kể cả delay 0, vì setTimeout(fn,0) chỉ là "xếp vào macrotask sau khi mọi việc đồng bộ và microtask hiện có xong". Điểm ăn điểm là nêu hai failure mode: việc CPU/I/O đồng bộ nặng chặn cả event loop (một endpoint chậm làm cả service đơ), và microtask tự sinh microtask gây starvation làm macrotask không bao giờ chạy; xử lý bằng worker thread, chia nhỏ việc, hoặc dùng macrotask để nhường nhịp, và theo dõi event loop lag.

Hands-on

Viết một đoạn dự đoán output trộn console.log đồng bộ, setTimeout(…,0), Promise.then, và (trong Node) process.nextTick/setImmediate, rồi chạy để kiểm tra dự đoán và nắm chắc thứ tự ưu tiên. Sau đó dựng một endpoint Express làm việc CPU nặng đồng bộ, bắn tải song song và quan sát mọi request khác bị treo; đo event loop lag để thấy nó tăng vọt, rồi đẩy việc nặng sang worker_threads và xác nhận các request khác lại phản hồi bình thường. Cuối cùng tạo một vòng microtask tự sinh để tái hiện starvation (timer không kích hoạt), rồi sửa bằng cách đổi sang setImmediate để mỗi vòng nhường lại event loop.

Top comments (0)