Bài 1 · Nâng cao · 24 phút· Cập nhật 11/06/2026
Bản chất: event loop & libuv
Biên soạn bởi Nguyễn Anh Tuấn
V8 + libuv và non-blocking I/O - vì sao Node.js một luồng vẫn nhanh; các phase timers/poll/check, hàng nextTick & microtask - kèm mô phỏng từng bước.
Ở khoá Cơ bản (bài Bất đồng bộ), bạn đã có mô hình ba tầng: code đồng bộ chạy hết → vét microtask → rồi tới macrotask. Mô hình đó đúng - nhưng thử đặt nó trước câu hỏi này:
hai 'macrotask' - cái nào chạy trước?
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate")); Cả hai đều là "macrotask", mô hình cũ hết câu trả lời. Muốn trả lời, phải mở Node ra xem macrotask thật ra không phải MỘT hàng đợi - nó là nhiều hàng đợi, mỗi hàng gắn với một phase của vòng lặp. Và người quản lý các phase đó không phải V8, mà là một thư viện C tên libuv.
- ▸V8: máy ảo chạy code JavaScript - biết thực thi, không biết chờ I/O.
- ▸libuv: thư viện C chứa event loop (implementation của vòng lặp), nói chuyện với hệ điều hành để chờ I/O không chặn.
- ▸Node.js = V8 + libuv + các module chuẩn (fs, http, crypto…) nối hai bên lại.
Trực giác bảo: muốn phục vụ 1000 kết nối thì cần 1000 luồng. Node làm ngược lại nhờ non-blocking I/O: khi cần đọc mạng/tệp, Node KHÔNG đứng chờ - nó đưa cho hệ điều hành một danh sách "chờ giúp tôi mấy việc này", rồi quay về chạy code khác. Hệ điều hành có sẵn cơ chế theo dõi hàng nghìn kết nối cùng lúc rất rẻ (epoll trên Linux, kqueue trên macOS); việc nào xong, libuv nhặt callback của việc đó đưa lại cho V8 chạy.
Trung thực: vẫn có một nhóm luồng sau cánh gà
- ▸Chờ I/O gần như miễn phí: hệ điều hành theo dõi giúp, Node chỉ nhận kết quả.
- ▸Vậy nên 1 luồng + event loop đủ cho hàng nghìn kết nối I/O-bound - đây là lý do Node nhanh.
- ▸Điểm yếu đối xứng: một phép tính nặng chặn luồng là CHẶN TẤT CẢ - cách xử lý nằm ở bài Đa luồng & đa tiến trình.
Mỗi vòng (tick) của event loop đi qua các phase theo thứ tự cố định. Ba phase quyết định mọi câu đố thứ tự bạn sẽ gặp:
| Phase | Chạy callback của |
|---|---|
| timers | setTimeout / setInterval đã "chín" (đến hạn) |
| poll | I/O đã xong (đọc tệp, dữ liệu mạng đến…) - và là chỗ vòng lặp ĐỨNG CHỜ khi rảnh |
| check | setImmediate |
Còn hai hàng đợi không thuộc phase nào và được ưu tiên hơn tất cả: sau mỗi một callback, Node vét cạn hàng process.nextTick trước, rồi vét cạn hàng microtask (Promise.then), xong mới chạy callback kế. Đủ để giải câu đố kinh điển:
cau-do.cjs - hai dòng CUỐI là cuộc đua, chạy nhiều lần sẽ thấy chúng thỉnh thoảng đổi chỗ; 4 dòng đầu thì bất biến
console.log("1");
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("2"); Kết quả khi chạy
1 2 nextTick promise immediate timeout
- ▸Thứ tự ưu tiên: code đồng bộ → hàng nextTick → hàng microtask → rồi mới tới các phase.
- ▸timeout vs immediate ở TOP-LEVEL là cuộc đua thật: setTimeout(0) bị nâng lên 1ms - vòng lặp khởi động kịp trong 1ms thì check chạy trước, không kịp thì timers chạy trước.
- ▸Vét nextTick/microtask xảy ra sau MỖI callback - không phải mỗi vòng một lần.
Ba kịch bản dưới đây mỗi cái dạy một luật. Đoán thứ tự in trước, rồi bấm Bước ▶ xem từng callback rời hàng đợi nào, ở vòng lặp thứ mấy:
console.log("1");
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("2");Trung thực
Cuộc đua timeout-vs-immediate ở top-level chỉ là chuyện vui. Nhưng đặt cả hai bên trong một I/O callback thì thứ tự thành ĐẢM BẢO:
io.cjs - chạy bao nhiêu lần cũng ra đúng thứ tự này
const fs = require("node:fs");
fs.readFile(__filename, () => {
console.log("doc xong");
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
}); Kết quả khi chạy
doc xong immediate timeout
Vì sao chắc chắn? I/O callback chạy ở phase poll. Nhìn lại thứ tự phase: ngay sau poll là check - immediate vào đó, chạy luôn vòng này. Còn timer mới đặt thì phải đợi vòng lặp quay về phase timers của vòng sau. Không có cuộc đua nào cả - chỉ có thứ tự phase cố định của vòng lặp.
- ▸Trong I/O callback: setImmediate LUÔN chạy trước setTimeout(0) - luật đảm bảo, dùng được trong code thật.
- ▸Cần "chạy ngay sau I/O này, trước mọi timer"? setImmediate là công cụ đúng.
- ▸Đây là ví dụ điển hình của việc hiểu phase: biến một câu đố hên xui thành một luật chắc chắn.
Một bất ngờ mà tài liệu cũ ít nhắc: cùng hai dòng code, đổi đuôi tệp là đổi thứ tự in.
thutu.cjs - CommonJS: nextTick thắng (đúng tài liệu kinh điển)
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick")); Kết quả khi chạy
nextTick promise
thutu.mjs - ESM: promise thắng! Module ESM được thực thi BÊN TRONG một microtask, nên hàng microtask đang chạy dở được ưu tiên vét nốt
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick")); Kết quả khi chạy
promise nextTick
- ▸Code ESM (import/export, đuôi .mjs) top-level chạy trong ngữ cảnh microtask → promise xếp trước nextTick.
- ▸Bên trong callback (timer, I/O…) thì hai môi trường như nhau: nextTick vẫn thắng microtask.
- ▸Bài học lớn hơn: thứ tự tinh vi là thứ DỄ VỠ - code đúng đắn không nên phụ thuộc nextTick-vs-promise.
Bài tiếp theo
Câu hỏi thường gặp
Không - libuv là một thư viện viết bằng C, sinh ra cho Node.js (nay nhiều dự án khác cũng dùng). Event loop nằm trong libuv - nó là implementation của vòng lặp: nói chuyện với hệ điều hành qua epoll (Linux) / kqueue (macOS) / IOCP (Windows) để chờ I/O không chặn, và nuôi một threadpool nhỏ cho những việc không chờ kiểu đó được. V8 chạy code JS của bạn; libuv quyết định KHI NÀO code đó được chạy.
Vì 0ms chỉ là "sớm nhất có thể, không sớm hơn 1ms" (Node nâng 0 lên 1). Callback phải đợi: code đồng bộ xong, hàng nextTick + microtask cạn, rồi vòng lặp đi tới phase timers VÀ timer đã chín. Máy đang bận thì còn lâu hơn nữa - setTimeout là lời hẹn "không sớm hơn", chưa bao giờ là "đúng lúc".
Tên gọi gây hiểu lầm nổi tiếng: nó chạy NGAY sau callback hiện tại, TRƯỚC cả microtask - tức là sớm nhất trong mọi cách xếp hàng, không phải "tick sau". Chính tài liệu Node thừa nhận tên này đặt sai; setImmediate (chạy ở phase check) mới gần nghĩa "tick sau" hơn. Hai cái tên gần như đổi chỗ cho nhau.
Có - hàng nextTick được vét CẠN trước khi vòng lặp đi tiếp, nên một hàm nextTick tự xếp thêm nextTick sẽ bỏ đói (starve) toàn bộ phần còn lại: timer không chạy, I/O không được nhận. Tài liệu Node khuyên: việc "chạy sau nhưng sớm" hãy ưu tiên queueMicrotask (lưu ý: đệ quy microtask cũng bỏ đói vòng lặp y như vậy); muốn NHƯỜNG vòng lặp thật sự thì dùng setImmediate. nextTick chỉ dành cho vài tình huống hẹp (vd phát lỗi sau khi trả về để người gọi kịp gắn listener).
Không y hệt. Mô hình "sync → microtask → macrotask" của khoá Cơ bản đúng cho cả hai môi trường; nhưng phase poll/check và process.nextTick/setImmediate là chuyện riêng của Node (libuv). Trình duyệt có vòng lặp riêng với render xen giữa các task - cùng triết lý, khác bộ máy.
Threadpool là 4 luồng C (mặc định, đổi được qua UV_THREADPOOL_SIZE) mà libuv tự dùng cho fs, dns.lookup, crypto, nén - code JS của bạn không chạy trên đó. Worker Threads (có bài riêng trong khoá này: Đa luồng & đa tiến trình) là luồng chạy JS thật do BẠN tạo. Một cái là hậu trường của I/O, một cái là công cụ song song của bạn.
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Trong kiến trúc Node.js, libuv đảm nhiệm việc gì?
- 1
Chạy lại câu đố kinh điển
Chép chương trình "Câu đố kinh điển" (Bước 3) vào tệp
.cjs, chạy 10 lần liên tiếp (vòngfortrong shell) và quan sát hai dòng cuối.Hoàn thành khi: Thấy timeout/immediate thỉnh thoảng đảo chỗ giữa các lần chạy - còn 4 dòng đầu thì không bao giờ.
- 2
Thứ tự đảm bảo trong I/O
Chạy ví dụ
fs.readFileở Bước 5 mười lần.setImmediatecó lần nào thuasetTimeoutkhông? Giải thích bằng phase.Hoàn thành khi: immediate trước timeout cả 10 lần: immediate vào phase check của vòng HIỆN TẠI, timer phải đợi vòng sau.
- 3
Đổi đuôi tệp, đổi thứ tự
Lấy 2 dòng
Promise.then+process.nextTick(Bước 6), lưu thành.cjsrồi.mjs, chạy cả hai.Hoàn thành khi:
.cjsin nextTick trước;.mjsin promise trước - và mèo con giải thích được vì sao (module ESM chạy trong ngữ cảnh microtask). - 4
Bỏ đói vòng lặp bằng nextTick
Viết hàm
nextTicktự gọi lại chính nó quaprocess.nextTick(đếm số lần), kèm mộtsetTimeoutin "toi duoc chay!" sau 0ms. Chạy thử 2 giây rồi Ctrl+C.Hoàn thành khi:
setTimeoutkhông bao giờ in được - hàngnextTickcạn mới tới phase timers, mà nó không bao giờ cạn. - 5
Đo threadpool
Dùng
crypto.pbkdf2(bản callback) chạy 4 việc rồi 8 việc cùng lúc, đo thời gian xong của từng việc.Hoàn thành khi: 4 việc đầu xong cùng nhau; sang việc thứ 5-8 phải chờ đợt sau - vì threadpool mặc định có 4 luồng.
- 6
Vẽ lại từ trí nhớ
Không nhìn bài, mèo con vẽ sơ đồ một vòng lặp: 3 phase chính + chỗ hàng
nextTick/microtask được vét. Rồi đối chiếu với công cụ Bước 4.Hoàn thành khi: Sơ đồ có timers → poll → check, và mũi tên "vét
nextTickrồi microtask" sau MỖI callback.