Microtask queue: hàng đợi ưu tiên cao và cái bẫy starvation
Microtask queue là hàng đợi mà event loop vét sạch hoàn toàn sau mỗi macrotask, trước khi chạm macrotask kế tiếp. Promise callback (.then/.catch/.finally), queueMicrotask, và await (phần sau khi resume) đều xếp vào đây. Vì được ưu tiên vét cạn, microtask cho độ trễ thấp — logic nối tiếp sau một Promise chạy ngay trong tick hiện tại. Nhưng chính cơ chế "vét cạn" đó là con dao hai lưỡi: một chuỗi microtask tự sinh microtask mới sẽ chiếm trọn event loop, làm timer và I/O không bao giờ tới lượt.
Cơ chế hoạt động
So với macrotask (setTimeout, I/O callback) chạy mỗi tick một cái, microtask queue được làm cạn trọn vẹn giữa hai macrotask. Thứ tự: code đồng bộ hết stack → vét toàn bộ microtask hiện có (kể cả microtask sinh ra trong lúc vét) → mới sang macrotask tiếp theo.
queueMicrotask(() => console.log('micro 1'))
Promise.resolve().then(() => console.log('promise'))
setTimeout(() => console.log('macro'), 0)
console.log('sync')
// in ra: sync, micro 1, promise, macro
queueMicrotask là API tường minh để xếp một microtask, dùng khi cần hoãn một việc tới sau khi stack hiện tại xong nhưng trước mọi timer/I/O — ví dụ gom nhiều thay đổi rồi phát một sự kiện duy nhất ở cuối tick, thay vì phát nhiều lần.
Vấn đề gặp trong production
Failure mode: microtask starvation. Đây là lỗi tinh vi nhất liên quan tới microtask. Nếu một microtask lên lịch một microtask mới, và việc đó lặp vô hạn, queue không bao giờ rỗng — event loop kẹt trong pha vét microtask, không bao giờ tới macrotask:
let pending = 0
function drain() {
if (pending > 0) {
pending--
Promise.resolve().then(drain) // mỗi lần lại sinh microtask mới
}
}
Với pending lớn (hoặc một điều kiện luôn đúng), setTimeout, render, và việc nhận request mới đều bị treo, dù không có vòng while(true) nào và không lỗi nào ném. Triệu chứng giống hệt "treo ứng dụng" nhưng nguyên nhân ẩn trong code trông rất async. Quy tắc: việc lặp lại nhiều lần hoặc không có biên rõ ràng phải dùng macrotask (setTimeout/setImmediate) để mỗi vòng nhường lại event loop, không dùng microtask để tự lặp.
Hệ quả thứ hai: thứ tự thực thi ngoài dự đoán. Vì microtask chen trước macrotask, trộn Promise.then với setTimeout cho thứ tự không theo dòng code. Trong production điều này gây bug khi một đoạn giả định "callback timer chạy trước Promise" (hoặc ngược lại) — ví dụ một flag được set trong setTimeout nhưng bị một .then đọc trước. Sửa bằng cách không dựa vào thứ tự chéo giữa hai loại queue: nếu hai việc có quan hệ thứ tự, đặt chúng cùng loại hoặc nối tường minh bằng await.
Cách debug và monitor
Microtask starvation biểu hiện như event loop bị chặn: mọi endpoint chậm cùng lúc, timer không kích hoạt, health check timeout — nhưng profiler CPU không chỉ ra một hàm đồng bộ nặng nào, thay vào đó thấy cùng một hàm chạy lặp qua microtask. Đo event loop lag: lag tăng vọt mà không có hàm sync nặng là dấu hiệu starvation. Để khoanh vùng, tìm các chỗ .then/queueMicrotask gọi đệ quy hoặc lên lịch lại chính nó không có điều kiện dừng rõ ràng. Quy tắc phòng ngừa khi review: bất kỳ vòng lặp dựng trên Promise/microtask đều phải hỏi "việc này nhường lại event loop ở đâu".
Tradeoff
Microtask cho độ trễ thấp nhất để nối tiếp logic async — chạy ngay cuối tick hiện tại, không đợi vòng event loop sau như macrotask. Cái giá là chúng có thể starve mọi thứ khác nếu sinh ra không kiểm soát, vì được ưu tiên vét cạn. Macrotask ngược lại: luôn nhường nhịp cho I/O/timer mỗi vòng nên an toàn cho việc lặp dài, nhưng độ trễ cao hơn. Quy tắc thực tế: nối tiếp ngắn, một lần, sau async → để microtask tự nhiên (Promise); việc lặp hoặc scheduling dài → macrotask để nhường nhịp. Dùng microtask để tự lặp là cách chắc chắn nhất làm treo ứng dụng.
Câu hỏi phỏng vấn
Microtask khác macrotask thế nào, và vì sao một vòng lặp dựng trên
Promise.thencó thể làm treo ứng dụng?
Microtask (Promise callback, queueMicrotask, phần resume của await) được event loop vét sạch hoàn toàn sau mỗi macrotask và trước macrotask kế tiếp; macrotask (setTimeout, setInterval, I/O callback) chỉ chạy một cái mỗi vòng. Vì microtask queue được làm cạn trọn vẹn — kể cả microtask sinh ra trong lúc vét — một vòng lặp mà mỗi .then lại lên lịch một .then mới sẽ giữ queue không bao giờ rỗng, event loop kẹt trong pha vét microtask và không bao giờ tới macrotask: timer, I/O, request mới đều treo dù không có lỗi hay vòng while nào. Điểm ăn điểm là cách xử lý: việc lặp dài hoặc không có biên phải dùng macrotask (setTimeout/setImmediate) để mỗi vòng nhường lại event loop, và theo dõi event loop lag để phát hiện sớm.
Hands-on
Viết một đoạn dự đoán thứ tự output trộn code đồng bộ, queueMicrotask, Promise.then, và setTimeout(…,0), chạy để kiểm chứng. Sau đó dựng một "scheduler" xử lý một hàng việc bằng cách mỗi việc lên lịch việc kế qua Promise.resolve().then và cho hàng đủ lớn để tái hiện starvation: quan sát một setTimeout đặt song song không bao giờ chạy và event loop lag tăng. Sửa bằng cách đổi sang setImmediate (Node) hoặc setTimeout(…,0) cho mỗi bước, xác nhận timer lại kích hoạt và hàng việc vẫn xử lý xong — đối chiếu thông lượng và độ phản hồi giữa hai cách.
Top comments (0)