Microtask — process.nextTick, Promise, queueMicrotask và nạn "bỏ đói" event loop

Đây là nửa còn lại của câu hỏi event loop. Bài 2 nói về 6 phase của libuv. Bài này nói về hai hàng đợi không thuộc phase nào nhưng chạy xen vào giữa mọi thứ: hàng process.nextTick và hàng microtask của V8. Câu đố "đoạn code in ra gì" trong phỏng vấn hầu như luôn xoay quanh hai hàng này — và có một bẫy mà rất ít người biết: cùng một đoạn code, chạy dạng CommonJS và ES module cho ra thứ tự khác nhau.

Thuật ngữ dùng trong bài
macrotask (tên gọi không chính thức cho callback do event loop gọi: timer, I/O, immediate) · microtask (việc V8 chạy ngay khi call stack rỗng: Promise.then/catch/finally, phần sau await, queueMicrotask) · nextTick queue (hàng đợi riêng của Node cho process.nextTick, không phải của V8) · microtask checkpoint (điểm Node dừng lại xả microtask) · starvation (bỏ đói — một hàng đợi không bao giờ rỗng nên phần khác không được chạy) · unhandled rejection (Promise bị reject mà không có ai .catch) · Zalgo (tên lóng cho API lúc gọi callback đồng bộ, lúc bất đồng bộ).
Kiểm chứng: tài liệu qua Context7 (nodejs.org release notes v0.10, v14.17; Node docs v22). Code trích nguyên văn từ Node v22.23.1: lib/internal/process/task_queues.js, src/api/callback.cc, src/node.h. Mọi output là output thật trên Node 22.23.1 / Linux 6.8.

1. Bài toán gốc và bối cảnh ra đời

process.nextTick có từ những bản Node đầu tiên, trước khi JavaScript có Promise. Nó giải một bài toán rất cụ thể của API callback/EventEmitter:

const server = net.createServer();   // giả sử constructor phát 'listening' NGAY
server.on('listening', ...);          // gắn listener SAU khi sự kiện đã phát → mất sự kiện

Cần một cách nói "chạy việc này sau khi code hiện tại xong, nhưng trước bất kỳ I/O nào". Đó là nextTick. Bản 0.8 cài nó bằng một vòng quay trên event loop nên dưới tải cao I/O có thể chen trước → race condition. Bản 0.10 đổi hẳn ngữ nghĩa: "nextTick handlers are run right after each call from C++ into JavaScript" (release note v0.10.0, qua Context7) — và từ đó mới có rủi ro đệ quy nextTick bỏ đói I/O.

ES2015 mang Promise vào ngôn ngữ, kèm job queue (hàng microtask) do V8 quản lý. Node 11 thêm queueMicrotask, và từ v14.17 docs Node khuyên dùng queueMicrotask thay cho process.nextTick. Kết quả: Node có hai hàng đợi "chạy ngay sau code hiện tại" chồng lên nhau vì lý do lịch sử.

2. Mô hình tư duy cốt lõi

Sau mỗi callback JS mà Node gọi từ C++, Node xả hết hàng nextTick, rồi xả hết hàng microtask, lặp lại tới khi cả hai rỗng — rồi mới trả quyền về event loop. Không có "phase microtask": đây là một chốt chặn đặt ở cuối mọi callback.

Xả hàng nextTick rồi hàng microtask sau một callback Một callback I/O chạy xong. Hai nhiệm vụ trong hàng nextTick chạy trước, sau đó ba nhiệm vụ trong hàng microtask. Khi cả hai rỗng, quyền được trả về event loop. callback I/O chạy xong ① nextTick queue ② microtask queue (V8) call stack (1 thread) chạy từng cái tới hết tick A tick B then 1 await … queueMicro A, B then 1, await, qM ③ cả hai rỗng → trả về event loop (sang callback kế tiếp / phase kế tiếp)
Hình 1 — Thứ tự xả trong CommonJS: toàn bộ nextTick (xanh) trước, rồi toàn bộ microtask (vàng). Nếu một microtask lại gọi process.nextTick, vòng lặp quay lại ① sau khi ② rỗng. Chỉ khi cả hai rỗng thì loop mới được đi tiếp.

3. Internals và quyết định thiết kế

3.1 Ai gọi việc xả hàng đợi: InternalCallbackScope::Close

Mọi callback từ libuv vào JS (I/O, timer, immediate) đều đi qua InternalCallbackScope. Khi scope đóng lại:

// Node v22.23.1 — src/api/callback.cc (rút gọn)
void InternalCallbackScope::Close() {
  ...
  if (env_->async_callback_scope_depth() > 1 || skip_task_queues_) return;  // đang lồng: để scope ngoài xả

  if (!tick_info->has_tick_scheduled()) {
    context->GetMicrotaskQueue()->PerformCheckpoint(isolate);  // không có nextTick: chỉ xả microtask
  }
  if (!tick_info->has_tick_scheduled() && !tick_info->has_rejection_to_warn()) return;

  Local<Function> tick_callback = env_->tick_callback_function();   // = processTicksAndRejections
  tick_callback->Call(context, process, 0, nullptr);
}

Node đặt V8 ở chế độ MicrotasksPolicy::kExplicit (src/node.h, dòng 480): V8 không tự chạy microtask, Node quyết định lúc nào. Đây là điểm thiết kế cho phép Node chèn hàng nextTick của mình lên trước.

3.2 processTicksAndRejections — vòng lặp xả hai hàng

// Node v22.23.1 — lib/internal/process/task_queues.js
function processTicksAndRejections() {
  let tock;
  do {
    while ((tock = queue.shift()) !== null) {   // ① xả HẾT nextTick queue
      ...
      callback();                               //    (nextTick thêm trong lúc này cũng chạy luôn)
      ...
    }
    runMicrotasks();                            // ② xả HẾT microtask queue của V8
  } while (!queue.isEmpty() || processPromiseRejections());   // ③ microtask lại thêm nextTick? quay lại ①
  setHasTickScheduled(false);
  setHasRejectionToWarn(false);
}

Ba hệ quả đọc thẳng từ code:

  1. nextTick đặt trong nextTick chạy ngay trong cùng vòng while → đệ quy vô hạn = không bao giờ ra khỏi hàm này.
  2. Promise.then đặt trong microtask cũng chạy luôn (V8 xả tới khi rỗng) → đệ quy Promise cũng bỏ đói.
  3. Kiểm tra unhandled rejection (processPromiseRejections) nằm ngay trong vòng này — đó là lý do process chết ngay sau callback có Promise bị bỏ rơi, không phải lúc exit.

3.3 Lab: thứ tự đầy đủ

// 10-micro-order.js
console.log('1 sync');
setTimeout(() => console.log('7 setTimeout'), 0);
setImmediate(() => console.log('8 setImmediate'));
Promise.resolve().then(() => console.log('4 promise.then'));
queueMicrotask(() => console.log('5 queueMicrotask'));
process.nextTick(() => console.log('3 nextTick'));
(async () => { console.log('2 trong async fn (vẫn đồng bộ tới await đầu tiên)'); await null; console.log('6 sau await'); })();
$ node 10-micro-order.js
1 sync
2 trong async fn (vẫn đồng bộ tới await đầu tiên)
3 nextTick
4 promise.then
5 queueMicrotask
6 sau await
7 setTimeout
8 setImmediate

(Dòng 7/8 là cặp không xác định ở main module — xem bài 2 mục 5.2.)

3.4 Đan xen: microtask sinh nextTick, nextTick sinh microtask

// 11-interleave.js
Promise.resolve().then(() => {
  console.log('P1');
  process.nextTick(() => console.log('T-trong-P1'));
  Promise.resolve().then(() => console.log('P-trong-P1'));
});
process.nextTick(() => {
  console.log('T1');
  Promise.resolve().then(() => console.log('P-trong-T1'));
  process.nextTick(() => console.log('T-trong-T1'));
});
$ node 11-interleave.js
T1
T-trong-T1          ← ① nextTick queue xả HẾT, kể cả tick mới thêm
P1
P-trong-T1          ← ② microtask FIFO: P-trong-T1 vào hàng TRƯỚC P-trong-P1
P-trong-P1
T-trong-P1          ← ③ microtask rỗng rồi mới quay lại ① cho tick thêm trong lúc ②

3.5 Từ Node 11: xả sau MỖI timer/immediate, không phải sau cả phase

$ node 12-between-callbacks.js
immediate 1
  tick trong immediate 1
immediate 2
timeout 1
  promise trong timeout 1
timeout 2
  promise trong timeout 2

Node ≤ 10 sẽ in timeout 1, timeout 2 liền nhau rồi mới tới hai promise. Node 11 đổi cho giống browser: lib/internal/timers.js gọi runNextTicks() giữa từng timer (if (ranAtLeastOneTimer) runNextTicks();) và giữa từng immediate. (Hành vi Node 10 lấy từ changelog "timers: run nextTicks after each immediate and timer", không dựng lab bản cũ.)

3.6 Bẫy: CommonJS và ES module cho thứ tự khác nhau

Copy nguyên file trên sang .mjs:

$ cp 10-micro-order.js 10-micro-order.mjs && node 10-micro-order.mjs
1 sync
2 trong async fn (vẫn đồng bộ tới await đầu tiên)
4 promise.then
5 queueMicrotask
6 sau await
3 nextTick            ← nextTick bị đẩy xuống SAU mọi Promise
8 setImmediate
7 setTimeout

Chạy 200 lần: nextTick luôn đứng sau Promise (200/200); cặp timeout/immediate vẫn ngẫu nhiên (122 timeout trước / 78 immediate trước). Lý do: trong ESM, phần thân module được chạy bên trong quá trình xử lý Promise của module loader (loader là bất đồng bộ). Khi thân module chạy xong, V8 đang ở giữa lúc xả microtask, nên các .then vừa thêm chạy luôn trong cùng lượt; hàng nextTick chỉ được xả khi quyền về lại Node. (Kết quả đã verify bằng lab; phần giải thích "đang ở giữa lượt xả microtask" là suy luận từ hành vi, chưa lần tới dòng code của loader.)

Trả lời phỏng vấn: "nextTick chạy trước Promise" chỉ đúng khi code đang chạy từ một callback do Node gọi (CommonJS top-level, I/O callback, timer). Ở top-level của ES module thì ngược lại. Code production không nên phụ thuộc vào thứ tự giữa hai hàng này.

4. Đảm bảo và giới hạn

Đảm bảoĐúng?Ghi chú
Microtask chạy trước bất kỳ I/O/timer nào tiếp theo✅Chốt chặn ở cuối mọi callback.
Trong mỗi hàng: FIFO✅Lab 3.4.
nextTick trước Promise⚠️ Chỉ khi đang trong callback do Node gọiESM top-level ngược lại (3.6).
Microtask giới hạn số lượng mỗi lượt❌ Không có giới hạnKhác setImmediate (chụp danh sách) và pending của libuv (tối đa 8 lượt).
await x luôn nhường ít nhất một microtask✅Kể cả await null — dòng "6 sau await" chạy sau "5".
Promise bị reject không catch làm chết process✅ mặc định từ Node 15--unhandled-rejections=throw là mặc định.

5. Failure modes

5.1 Starvation: đệ quy nextTick / Promise chặn cả I/O

// 13-starvation.js — đệ quy 500ms bằng 3 cách, xem timer 0ms và readFile phải chờ bao lâu
const schedule = {
  tick: (fn) => process.nextTick(fn),
  promise: (fn) => Promise.resolve().then(fn),
  immediate: (fn) => setImmediate(fn),
}[mode];
(function spin() {
  n++;
  if (performance.now() - t0 < 500) schedule(spin);
  else console.log(`${mode}: đệ quy xong ${n} lần tại ...ms`);
})();
setTimeout(() => console.log(`  setTimeout 0 chạy lúc ...ms (n=${n})`), 0);
fs.readFile(__filename, () => console.log(`  readFile xong lúc ...ms (n=${n})`));
$ for m in tick promise immediate; do node 13-starvation.js $m; done
tick: đệ quy xong 711786 lần tại 502ms
  setTimeout 0 chạy lúc 528ms (n=711786)
  readFile xong lúc 528ms (n=711786)
promise: đệ quy xong 2392147 lần tại 500ms
  setTimeout 0 chạy lúc 503ms (n=2392147)
  readFile xong lúc 504ms (n=2392147)
  setTimeout 0 chạy lúc 1ms (n=1)
  readFile xong lúc 5ms (n=7)
immediate: đệ quy xong 80966 lần tại 500ms
Starvation do đệ quy microtask Bên trái: vòng microtask quay mãi, timer và I/O đứng chờ bên ngoài. Bên phải: setImmediate đệ quy, mỗi vòng loop vẫn cho timer và I/O đi qua. nextTick / Promise đệ quy microtask không rỗng timer, readFile, HTTP… đứng chờ 500ms setImmediate đệ quy poll check timer (1ms), readFile (5ms) vẫn qua
Hình 2 — Microtask không có "lượt": chừng nào hàng còn việc, loop không được đi tiếp. setImmediate đệ quy thì mỗi lần chỉ một bước, loop vẫn đi đủ vòng nên timer chạy ở 1ms và readFile ở 5ms — đúng số đo lab.

Hệ quả: không mất dữ liệu, nhưng toàn bộ I/O (kể cả request HTTP mới, healthcheck) bị treo suốt thời gian đệ quy. Dạng hay gặp ngoài đời không phải ai viết đệ quy cố ý, mà là: xử lý một mảng 1 triệu phần tử bằng chuỗi await không có I/O thật bên trong (vd for (const x of items) await validate(x) mà validate là async nhưng thuần CPU) — mỗi await là một microtask, loop không có cơ hội thở. Chữa: chia lô và nhường bằng await new Promise(r => setImmediate(r)) sau mỗi lô, hoặc đẩy ra worker.

5.2 Unhandled rejection làm chết cả server

$ node 14-unhandled.js     # setInterval "phục vụ" + 1 Promise.reject không catch ở 250ms
server vẫn đang phục vụ...
server vẫn đang phục vụ...
.../14-unhandled.js:3
setTimeout(() => { Promise.reject(new Error('quên catch')); }, 250);
                                  ^
Error: quên catch
    at Timeout._onTimeout (.../14-unhandled.js:3:35)
    at listOnTimeout (node:internal/timers:585:17)
    at process.processTimers (node:internal/timers:521:7)

Node.js v22.23.1
exit code: 1

Một request lỗi → cả process chết → mọi request đang xử lý khác cũng mất. Từ Node 15 đây là mặc định (--unhandled-rejections=throw); trước đó chỉ là warning.

5.3 async function làm listener của EventEmitter

// 18-async-listener.js
const bus = new EventEmitter(opt);
bus.on('order.created', async (o) => { await null; throw new Error(`gửi mail cho đơn ${o.id} lỗi`); });
bus.on('error', (e) => console.log('bắt được qua event error:', e.message));
bus.emit('order.created', { id: 42 });
setTimeout(() => console.log('process vẫn sống'), 50);
$ node 18-async-listener.js
Error: gửi mail cho đơn 42 lỗi
Node.js v22.23.1
exit=1                                            ← emitter không biết listener trả Promise → unhandled → chết

$ node 18-async-listener.js capture               # new EventEmitter({ captureRejections: true })
bắt được qua event error: gửi mail cho đơn 42 lỗi
process vẫn sống

5.4 Zalgo: callback lúc đồng bộ lúc bất đồng bộ

// 17-zalgo.js
function getConfig(path, cb) {
  if (cache.has(path)) return cb(cache.get(path));             // SAI: đồng bộ khi cache hit
  fs.readFile(path, 'utf8', (e, d) => { cache.set(path, d); cb(d); });
}
function load(label, next) {
  let ready = false;
  getConfig('/etc/hostname', () => { console.log(`${label}: callback thấy ready=${ready}`); next?.(); });
  ready = true;                                                 // tưởng code này luôn chạy trước callback
}
$ node 17-zalgo.js
lần 1 (cache miss): callback thấy ready=true
lần 2 (cache hit) : callback thấy ready=false     ← bug chỉ xuất hiện khi cache hit
== sửa: return queueMicrotask(() => cb(cache.get(path)));
lần 1 (cache miss): callback thấy ready=true
lần 2 (cache hit) : callback thấy ready=true

Bug dạng này nổ trễ: dev test lần đầu (cache miss) thấy đúng, production có cache thì sai. Đây chính là lý do nextTick được sinh ra. Với code async/await hiện nay, trả về Promise luôn là bất đồng bộ nên tự tránh được.

6. Vận hành trong production

7. So sánh và ma trận quyết định

APIChạy khi nàoNhường cho I/O?Rủi ro bỏ đóiDùng khi
process.nextTickNgay sau callback hiện tại, trước Promise (CJS)KhôngCaoViết thư viện kiểu EventEmitter cần phát sự kiện sau constructor. App code: hầu như không cần.
queueMicrotaskHàng microtask V8KhôngCaoHoãn việc nhỏ mà vẫn giữ thứ tự; chuẩn web, chạy được cả browser.
Promise.then / awaitHàng microtask V8KhôngCao (nếu chuỗi dài không có I/O)Mặc định cho code async.
setImmediateCheck phase, sau pollCóKhôngChia nhỏ việc dài để loop thở được.
setTimeout(fn, 0)Timers phase, ≥ 1msCóKhôngKhi cần độ trễ thật; không dùng để nhường.

Dùng microtask khi cần thứ tự chặt và việc rất ngắn. Tránh khi việc có thể lặp lại không giới hạn hoặc chạy lâu — lúc đó dùng setImmediate hoặc worker.

8. Góc nhìn tech lead: tác động tổ chức

9. Anti-patterns và bài học thực tế

  1. Floating promise — gọi hàm async không await, không .catch: lỗi thành unhandled rejection → crash (5.2).
  2. array.forEach(async x => …) — forEach không chờ Promise; code sau vòng lặp chạy trước khi xử lý xong, lỗi bị thả nổi.
  3. Async listener cho EventEmitter không bắt lỗi (5.3).
  4. Chuỗi await thuần CPU trên tập lớn — tưởng là "async nên không chặn" (5.1).
  5. Callback lúc sync lúc async (Zalgo, 5.4).
  6. Test đoán thứ tự nextTick vs Promise — gãy khi đổi sang ESM (3.6).
  7. Bọc callback API bằng Promise mà callback có thể gọi 2 lần — lần 2 bị nuốt im lặng vì Promise chỉ resolve một lần; dùng util.promisify hoặc bản /promises có sẵn.

Bài học có tính lịch sử: Isaac Z. Schlueter (người duy trì npm/Node thời đầu) viết bài "Designing APIs for Asynchrony" (2013) — nguồn gốc của cụm "don't release Zalgo". Quy tắc từ bài đó vẫn đúng: một API hoặc luôn đồng bộ, hoặc luôn bất đồng bộ.

10. Câu hỏi phụ interviewer hay hỏi tiếp

nextTick và setImmediate — tên có bị ngược không?
Có, docs Node tự thừa nhận: nextTick chạy "ngay" (trước cả event loop tiếp theo), setImmediate chạy ở vòng loop kế. Đổi tên thì vỡ cả hệ sinh thái nên giữ nguyên.
await có tạo thread không?
Không. await chỉ tách hàm thành phần trước/sau; phần sau là một microtask chạy trên cùng main thread khi Promise xong.
Vì sao Promise đệ quy nhanh hơn nextTick (2,39 triệu vs 0,71 triệu lần/500ms)?
Promise chạy trong V8 (C++ tối ưu sẵn), còn mỗi nextTick là một object JS đi qua queue.shift() kèm async hooks, AsyncContextFrame. Con số là của máy lab dùng chung, chỉ xem tỉ lệ.
Lỗi throw trong nextTick thì sao?
Thành uncaughtException — không có try/catch nào bọc được vì call stack gốc đã xong. Mặc định process chết.
Có nên bắt uncaughtException rồi chạy tiếp?
Không. Docs Node nói rõ trạng thái process sau lỗi đó là không xác định; chỉ nên log, dọn dẹp đồng bộ và thoát.
queueMicrotask khác Promise.resolve().then thế nào?
Cùng hàng đợi. queueMicrotask không tạo Promise (rẻ hơn) và lỗi throw bên trong thành uncaughtException thay vì rejection.

Phụ lục: lab đã chạy

Môi trường: Node v22.23.1 (V8 12.4), Linux 6.8, 4 vCPU dùng chung. Code đầy đủ các lab nằm ở các mục trên; chép ra file và chạy bằng node, không cần package.

node 10-micro-order.js
cp 10-micro-order.js 10-micro-order.mjs && node 10-micro-order.mjs
for i in $(seq 200); do node 10-micro-order.mjs | sed -n 6,8p | tr '\n' '|'; echo; done | sort | uniq -c
#    122 3 nextTick|7 setTimeout|8 setImmediate|
#     78 3 nextTick|8 setImmediate|7 setTimeout|
node 11-interleave.js
node 12-between-callbacks.js
for m in tick promise immediate; do node 13-starvation.js $m; done
node 14-unhandled.js; echo "exit code: $?"
node 17-zalgo.js
node 18-async-listener.js; node 18-async-listener.js capture

# đọc source
curl -sL https://raw.githubusercontent.com/nodejs/node/v22.23.1/lib/internal/process/task_queues.js | sed -n 71,107p
curl -sL https://raw.githubusercontent.com/nodejs/node/v22.23.1/src/api/callback.cc | sed -n 113,190p
// 12-between-callbacks.js
setTimeout(() => { console.log('timeout 1'); Promise.resolve().then(() => console.log('  promise trong timeout 1')); }, 0);
setTimeout(() => { console.log('timeout 2'); Promise.resolve().then(() => console.log('  promise trong timeout 2')); }, 0);
setImmediate(() => { console.log('immediate 1'); process.nextTick(() => console.log('  tick trong immediate 1')); });
setImmediate(() => console.log('immediate 2'));

// 14-unhandled.js
setInterval(() => console.log('server vẫn đang phục vụ...'), 100);
setTimeout(() => { Promise.reject(new Error('quên catch')); }, 250);

Chưa verify: hành vi Node ≤ 10 (xả microtask sau cả phase); dòng code chính xác của ESM loader gây ra thứ tự ở 3.6; chi phí AsyncLocalStorage.