Một bạn trong team đẩy lên một bản refactor "vô hại": endpoint export giờ build report 200 nghìn dòng bằng một chuỗi helper async thay vì vòng lặp thường. Hổng có gọi network gì hết, toàn xử lý trong memory. Chưa tới một tiếng sau, health check bắt đầu timeout, p99 latency của mấy endpoint chẳng liên quan nhảy từ 40 ms lên 6 giây, liveness probe của Kubernetes restart luôn cái pod. CPU bận nhưng chưa max. Code không có vòng lặp vô hạn, không deadlock, function nào cũng return đàng hoàng.
Thủ phạm là event loop. Bản refactor đã biến một task đồng bộ dài thành hàng trăm nghìn microtask nối đuôi nhau, mà trong JavaScript thì microtask luôn được chạy trước khi loop được phép ngó tới timer, socket hay request HTTP kế tiếp. Hiểu rõ cái gì chạy lúc nào thì mấy bug kiểu này nhìn ra liền, còn mấy câu phỏng vấn "đoán thứ tự output" trở nên nhàm. Dưới đây là mô hình mình hay xài.
Một thread, một call stack
JavaScript chạy code của mình trên đúng một thread với một call stack. Gọi function thì push một frame, function return thì pop ra. Chừng nào stack còn frame thì không có gì khác được chạy: không timer, không promise, không click handler, không request mới trong Node. Mọi thứ "chạy song song" trong JavaScript thật ra là một hàng đợi callback ngồi chờ stack trống.
Phần chờ timer, socket, file là do host lo (trình duyệt hoặc libuv của Node), chạy ở thread khác hoặc trong kernel. Khi xong việc, host không chen ngang code của mình mà bỏ một callback vô hàng đợi. Event loop đơn giản là luật quyết định hàng đợi nào được phục vụ tiếp khi stack trống.
Có hai nhóm hàng đợi quan trọng:
- Macrotask (spec gọi là task):
setTimeout,setInterval,setImmediate(Node), callback I/O, message củaMessageChannel, event UI. Mỗi vòng loop chỉ lấy một cái. - Microtask: promise reaction (
.then,.catch,.finally, và phần code sauawait),queueMicrotask, callback củaMutationObservertrên trình duyệt. Sau mỗi macrotask, và sau script ban đầu, loop xả hết microtask, kể cả microtask mới được thêm vô trong lúc đang xả.
Node còn có thêm một hàng đợi đứng trước nữa: callback của process.nextTick, được xả trước cả promise.
Loop chọn callback kế tiếp ra sao
Đây là toàn bộ thuật toán, đơn giản đủ để đoán output và đủ chính xác để debug production:
Điểm bất đối xứng nằm ở bước C, D so với bước F. Microtask được xả tới khi hàng đợi trống. Macrotask thì lấy từng cái một, xong mỗi cái lại xả sạch microtask. Trình duyệt chỉ có cơ hội render giữa hai macrotask, không bao giờ render giữa lúc đang xả microtask.
Trong Node, bước F chi tiết hơn vì libuv đi qua nhiều phase (timers, pending callbacks, poll cho I/O, check cho setImmediate, close callbacks), nhưng từ Node 11 trở đi luật quan trọng giống trình duyệt: sau từng callback timer hoặc immediate, Node xả nextTick rồi tới microtask.
Đoán thứ tự output
Có mô hình rồi thì mấy câu đố kinh điển giải theo kiểu cơ học. Đọc script từ trên xuống, output đồng bộ thì ghi ra liền, callback nào thì bỏ vô đúng hàng đợi của nó.
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
queueMicrotask(() => console.log('D'));
console.log('E');
// Output: A E C D B
A và E là đồng bộ. C và D là microtask, vô hàng đợi theo thứ tự đó nên chạy theo thứ tự đó ngay khi script xong. B là macrotask, phải chờ microtask trống hết, dù delay là 0 (Node cũng clamp lên 1 ms, còn trình duyệt clamp timer lồng sâu lên ít nhất 4 ms).
async/await chỉ là promise với cú pháp dễ đọc hơn. Thân hàm async chạy đồng bộ tới await đầu tiên. Mọi thứ phía sau await được xếp thành microtask khi giá trị được await đã settle:
async function load() {
console.log(1);
await null; // everything below becomes a microtask
console.log(2);
}
console.log(0);
load();
Promise.resolve().then(() => console.log(3));
console.log(4);
// Output: 0 1 4 2 3
1 in ra đồng bộ bên trong load(). await null bọc null thành promise đã resolve và xếp phần tiếp theo vô hàng đợi. Rồi .then xếp 3. In 4, stack trống, microtask chạy theo FIFO: 2, rồi 3. Trước V8 7.2, await một native promise tốn ba tick microtask chứ không phải một, nên Node đời rất cũ có thể in 3 trước 2. Nếu tính đúng sai của code phụ thuộc vô chuyện đếm tick, thì code sai chứ không phải engine sai.
Luật thứ ba hay bị quên: microtask được tạo bên trong một macrotask sẽ chạy trước macrotask kế tiếp.
setTimeout(() => {
console.log('t1');
Promise.resolve().then(() => console.log('p1'));
}, 0);
setTimeout(() => console.log('t2'), 0);
// Output (Node 11+ and browsers): t1 p1 t2
Node trước bản 11 chạy hết các timer đã tới hạn rồi mới xả microtask, nên in t1 t2 p1. Sau này Node đổi lại cho giống trình duyệt, thêm một lý do để đừng viết logic phụ thuộc vô số timer chạy trong một phase.
Cuối cùng là mấy hàng đợi riêng của Node:
// main.cjs (CommonJS)
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'), 0);
// Typical output: nextTick promise timeout immediate
// (timeout vs immediate order is NOT guaranteed in the main module)
nextTick thắng promise vì Node xả tick queue trước. Còn setTimeout(fn, 0) so với setImmediate khi gọi từ main module thì tùy thời gian khởi động process so với ngưỡng 1 ms của timer, nên mỗi lần chạy có thể khác nhau. Bên trong một callback I/O thì setImmediate luôn chạy trước vì phase check nằm ngay sau poll. Thêm một chuyện: trong ES module, code top-level chạy như một phần của promise job, nên thứ tự promise với nextTick có thể khác CommonJS. Lại thêm lý do để đừng dựa vô nó.
Bỏ đói event loop
Vì microtask được xả tới khi trống, một microtask cứ tự đẻ thêm microtask sẽ chặn loop mãi mãi, dù từng callback nhỏ xíu:
// This never lets the timer fire: the microtask queue is never empty.
setTimeout(() => console.log('you will never see me'), 0);
function spin() {
Promise.resolve().then(spin);
}
spin();
Call stack không hề tăng, nên không có RangeError: Maximum call stack size exceeded. Mình chỉ thấy tab bị đứng hoặc process Node ngưng trả lời request trong khi một core CPU chạy 100%. process.nextTick đệ quy cũng y vậy, còn tệ hơn vì nó bỏ đói luôn cả promise.
Sự cố production ở đầu bài là phiên bản nhẹ hơn của chuyện này. Mỗi await lên một giá trị đã resolve tốn một microtask. Vòng lặp 200 nghìn helper có await là hơn 200 nghìn microtask chạy liền một mạch, và với event loop thì đó là một khối khổng lồ không chen ngang được. Cách sửa không phải là "thêm await cho nhiều". Đoạn này không giúp gì cho sự công bằng hết:
// Looks like yielding, but is NOT: await on a resolved promise is a microtask,
// so the loop never reaches the timers or poll phase.
for (const row of hugeArray) {
transform(row);
await Promise.resolve();
}
Muốn cho phần còn lại được chạy, mình phải nhường một macrotask: setImmediate trong Node, setTimeout(0) hoặc MessageChannel trên trình duyệt, hoặc scheduler.yield() ở chỗ nào có hỗ trợ (hiện là các trình duyệt nhân Chromium).
Cài đặt: xử lý chia lát theo thời gian
Pattern mình hay xài là time slicing: làm việc tới khi hết ngân sách thời gian, nhường một macrotask để loop phục vụ timer, I/O và request, rồi làm tiếp. Clock được inject vô để test được mà không phụ thuộc thời gian thật.
const { setImmediate: yieldToLoop } = require('node:timers/promises');
/**
* Process a large array without blocking the event loop.
* Runs `fn` on items until `budgetMs` is spent, then yields a macrotask
* so timers, I/O callbacks and incoming requests get a turn.
*/
async function processInChunks(items, fn, { budgetMs = 10, now = () => performance.now() } = {}) {
const results = [];
let sliceStart = now();
for (let i = 0; i < items.length; i++) {
results.push(fn(items[i], i));
if (now() - sliceStart >= budgetMs) {
await yieldToLoop(); // a real macrotask, not a microtask
sliceStart = now();
}
}
return results;
}
module.exports = { processInChunks };
Ngân sách 5 tới 15 ms mỗi lát là điểm khởi đầu ổn cho API server Node, giữ latency từng request trong tầm kiểm soát mà mỗi lát vẫn làm được việc thật. Trên trình duyệt thì nên dưới 16 ms nhiều để không rớt frame, và việc nào thật sự nặng thì cân nhắc đẩy qua Worker, ở đó nó không chặn UI được.
Với job tốn CPU cỡ vài giây thì slicing chỉ là băng keo cá nhân. Xài worker_threads trong Node hoặc Web Worker trên trình duyệt, hoặc đẩy job vô queue. Slicing hợp nhất với vùng ở giữa: việc mất 50 tới 500 ms và buộc phải nằm trên main thread vì đụng tới state của main thread.
Kiểm thử hành vi event loop
Thứ tự chạy test được bằng runner có sẵn node:test. Ghi sự kiện vô một mảng rồi assert chuỗi đó sau khi cho loop chạy xong một macrotask thật. Test thứ hai chứng minh processInChunks thật sự nhường loop: timer được đặt trước khi làm việc phải chạy giữa chừng chứ không phải sau khi xong hết. Fake clock làm ranh giới các lát trở nên tất định.
// event-loop.test.cjs -> node --test
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { processInChunks } = require('./process-in-chunks.cjs');
const tick = () => new Promise((resolve) => setTimeout(resolve, 5));
test('microtasks drain before the next timer', async () => {
const log = [];
setTimeout(() => log.push('timeout'), 0);
Promise.resolve().then(() => log.push('promise'));
queueMicrotask(() => log.push('microtask'));
log.push('sync');
await tick();
assert.deepEqual(log, ['sync', 'promise', 'microtask', 'timeout']);
});
test('processInChunks lets a timer run in the middle of the work', async () => {
const log = [];
setTimeout(() => log.push('timer'), 0);
let fakeTime = 0;
const now = () => fakeTime;
const items = Array.from({ length: 6 }, (_, i) => i);
const out = await processInChunks(items, (x) => {
fakeTime += 5; // pretend each item costs 5 ms
log.push(`item${x}`);
return x * 2;
}, { budgetMs: 10, now });
assert.deepEqual(out, [0, 2, 4, 6, 8, 10]);
const timerAt = log.indexOf('timer');
assert.ok(timerAt > 0 && timerAt < log.length - 1, `timer ran at ${timerAt}`);
});
Nếu ai đó đổi yieldToLoop() thành await Promise.resolve(), test thứ hai fail liền vì timer chỉ chạy được sau khi xử lý hết item. Đó đúng là kiểu regression mình muốn CI bắt được, chứ không phải để production bắt.
Những lỗi hay gặp
- Nghĩ
setTimeout(fn, 0)là "chạy liền". Nó nghĩa là "sau task hiện tại, sau toàn bộ microtask, và sau ít nhất 1 ms (4 ms khi lồng sâu trên trình duyệt)". Tab trình duyệt không active còn có thể bị throttle xuống mỗi giây một lần hoặc chậm hơn. - Xài
awaitlàm điểm nhường. Await một promise đã resolve, một hàm đồng bộ haynullđều là microtask. Nó không bao giờ cho timer, I/O hay render được chạy. - Đệ quy bằng
process.nextTickhoặc chuỗi promise để làm "đệ quy bất đồng bộ". Mấy cái này bỏ đói I/O. Đệ quy nào cần công bằng thì xàisetImmediate. - Tưởng hàm
asyncchạy ngầm. Thân hàm chạy đồng bộ tớiawaitđầu tiên. Hàmasynccó vòng lặp nặng CPU mà không cóawaitthì chặn y chang hàm thường. - Dựa vô thứ tự giữa các hàng đợi như
setTimeoutvớisetImmediate, haynextTickvới promise khi đổi kiểu module. Hãy thể hiện phụ thuộc rõ ràng bằngawaithoặc callback. - Unhandled rejection trong microtask. Promise bị reject mà không có handler sẽ thành
unhandledRejection, mặc định làm crash Node từ v15. Microtask không làm lỗi biến mất. - Đo sai chỗ. Độ trễ event loop coi được bằng
perf_hooks.monitorEventLoopDelay(). Nếu p99 delay lên tới vài trăm mili giây, tìm mấy đoạn đồng bộ dài hoặc chuỗi microtask dài trước khi đổ thừa database.
Khi nào cần quan tâm, khi nào không
Nên để ý chi tiết khi:
- service Node bị spike latency ở mấy endpoint nhẹ trong lúc một endpoint nặng đang chạy;
- mình viết library có schedule callback (emitter, cache, stream adapter) và cần cam kết "luôn bất đồng bộ" nhất quán (
queueMicrotasklà công cụ đúng cho chuyện này); - xử lý dataset lớn trong memory trên main thread, ở Node hay trình duyệt đều vậy;
- debug mấy test chập chờn, lúc pass lúc fail tùy timing của timer.
Đừng làm quá lên khi:
- việc chủ yếu là I/O và tự nhiên đã await network hoặc disk; trong lúc chờ thì loop vốn đã rảnh;
- job thật sự nặng CPU; đẩy qua worker hoặc queue thay vì ngồi chỉnh kích thước lát;
- mình bị cám dỗ dựa vô số tick chính xác. Viết code đúng với mọi kiểu xen kẽ, còn mô hình event loop chỉ dùng để suy luận về latency và sự công bằng.
Bản rút gọn để nhớ: code đồng bộ trước, rồi toàn bộ microtask, rồi một macrotask, lặp lại. Thứ gì đẻ việc nhanh hơn tốc độ nó làm xong sẽ bỏ đói mọi thứ phía sau.
Thử thách
Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.
Đề bài
Viết một bộ giả lập event loop nhỏ, tất định. Cài class MiniLoop với constructor(maxMicrotasksPerDrain = 10000) (Python: max_microtasks_per_drain) và field public `now` bắt đầu từ 0. queueMicrotask(fn) (Python: queue_microtask) thêm fn vô hàng đợi microtask FIFO. setTimeout(fn, delay = 0) (Python: set_timeout) đăng ký timer tới hạn ở now + delay, delay âm tính như 0, trả về id dạng số không trùng. drainMicrotasks() (Python: drain_microtasks) chạy microtask theo FIFO tới khi hàng đợi trống, kể cả microtask được thêm trong lúc đang xả; nếu một lần xả phải chạy nhiều hơn maxMicrotasksPerDrain callback thì throw MicrotaskStarvationError (đã khai báo sẵn). run(main) gọi main() đồng bộ, xả microtask, rồi lặp lại: lấy timer có due sớm nhất (bằng nhau thì theo thứ tự đăng ký), gán now bằng due đó, gọi nó, xả microtask lần nữa, tới khi hết timer. Timer đăng ký bên trong callback tính due theo now hiện tại. Không xài timer thật.
Code khởi tạo
class MicrotaskStarvationError extends Error {}
class MiniLoop {
constructor(maxMicrotasksPerDrain = 10000) {
this.maxMicrotasksPerDrain = maxMicrotasksPerDrain;
this.now = 0;
this.microtasks = [];
this.timers = [];
this.seq = 0;
}
queueMicrotask(fn) {
// TODO: append fn to the microtask queue
throw new Error('TODO');
}
setTimeout(fn, delay = 0) {
// TODO: register a timer due at this.now + delay (negative delay counts as 0),
// remember insertion order for ties, return a numeric id
throw new Error('TODO');
}
drainMicrotasks() {
// TODO: run microtasks FIFO until the queue is empty (including ones queued while draining);
// throw MicrotaskStarvationError if more than maxMicrotasksPerDrain run in one drain
throw new Error('TODO');
}
run(main) {
// TODO: call main(), drain microtasks, then repeatedly take the earliest timer
// (ties: insertion order), set this.now to its due time, run it, drain microtasks
throw new Error('TODO');
}
}
from typing import Callable
class MicrotaskStarvationError(Exception):
pass
class MiniLoop:
def __init__(self, max_microtasks_per_drain: int = 10000) -> None:
self.max_microtasks_per_drain = max_microtasks_per_drain
self.now = 0
self.microtasks: list[Callable[[], None]] = []
self.timers: list[tuple[int, int, Callable[[], None]]] = []
self.seq = 0
def queue_microtask(self, fn: Callable[[], None]) -> None:
# TODO: append fn to the microtask queue
raise NotImplementedError
def set_timeout(self, fn: Callable[[], None], delay: int = 0) -> int:
# TODO: register a timer due at self.now + delay (negative delay counts as 0),
# remember insertion order for ties, return a numeric id
raise NotImplementedError
def drain_microtasks(self) -> None:
# TODO: run microtasks FIFO until empty (including ones queued while draining);
# raise MicrotaskStarvationError if more than max_microtasks_per_drain run in one drain
raise NotImplementedError
def run(self, main: Callable[[], None]) -> None:
# TODO: call main(), drain microtasks, then repeatedly take the earliest timer
# (ties: insertion order), set self.now to its due time, run it, drain microtasks
raise NotImplementedError
Lời giải của bạn
Gợi ý
Đã mở hết gợi ýLưu mỗi timer kèm due time và một số thứ tự tăng dần để khi bằng nhau vẫn giữ thứ tự đăng ký
Xả microtask sau main() và sau TỪNG callback timer, không phải xả một lần cho cả nhóm timer
Đếm số microtask đã chạy trong lần xả hiện tại và throw trước khi chạy vượt giới hạn
class MicrotaskStarvationError extends Error {}
class MiniLoop {
constructor(maxMicrotasksPerDrain = 10000) {
this.maxMicrotasksPerDrain = maxMicrotasksPerDrain;
this.now = 0;
this.microtasks = [];
this.timers = [];
this.seq = 0;
}
queueMicrotask(fn) {
this.microtasks.push(fn);
}
setTimeout(fn, delay = 0) {
const id = ++this.seq;
this.timers.push({ id, fn, due: this.now + Math.max(0, delay) });
return id;
}
drainMicrotasks() {
let ran = 0;
while (this.microtasks.length > 0) {
if (ran >= this.maxMicrotasksPerDrain) {
throw new MicrotaskStarvationError('microtask queue never drained');
}
const fn = this.microtasks.shift();
ran++;
fn();
}
}
run(main) {
main();
this.drainMicrotasks();
while (this.timers.length > 0) {
let best = 0;
for (let i = 1; i < this.timers.length; i++) {
const t = this.timers[i];
const b = this.timers[best];
if (t.due < b.due || (t.due === b.due && t.id < b.id)) best = i;
}
const [timer] = this.timers.splice(best, 1);
this.now = Math.max(this.now, timer.due);
timer.fn();
this.drainMicrotasks();
}
}
}
import heapq
from collections import deque
from typing import Callable
class MicrotaskStarvationError(Exception):
pass
class MiniLoop:
def __init__(self, max_microtasks_per_drain: int = 10000) -> None:
self.max_microtasks_per_drain = max_microtasks_per_drain
self.now = 0
self.microtasks: deque[Callable[[], None]] = deque()
self.timers: list[tuple[int, int, Callable[[], None]]] = []
self.seq = 0
def queue_microtask(self, fn: Callable[[], None]) -> None:
self.microtasks.append(fn)
def set_timeout(self, fn: Callable[[], None], delay: int = 0) -> int:
self.seq += 1
heapq.heappush(self.timers, (self.now + max(0, delay), self.seq, fn))
return self.seq
def drain_microtasks(self) -> None:
ran = 0
while self.microtasks:
if ran >= self.max_microtasks_per_drain:
raise MicrotaskStarvationError("microtask queue never drained")
fn = self.microtasks.popleft()
ran += 1
fn()
def run(self, main: Callable[[], None]) -> None:
main()
self.drain_microtasks()
while self.timers:
due, _, fn = heapq.heappop(self.timers)
self.now = max(self.now, due)
fn()
self.drain_microtasks()

