← Lập trình JavaScript nâng cao

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à

Vài loại việc không chờ kiểu non-blocking được (đọc tệp trên nhiều hệ điều hành, dns.lookup, crypto nặng). libuv xử lý chúng trên một threadpool 4 luồng C (đổi được qua UV_THREADPOOL_SIZE). Code JS của bạn vẫn một luồng - nhưng "Node hoàn toàn đơn luồng" là nói quá. Hệ quả của chuyện này - chờ I/O là việc của hậu trường, còn TÍNH nặng thì phải tự mở luồng - sẽ học kỹ ở bài Đa luồng & đa tiến trình của khoá này.
  • 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:

PhaseChạy callback của
timerssetTimeout / setInterval đã "chín" (đến hạn)
pollI/O đã xong (đọc tệp, dữ liệu mạng đến…) - và là chỗ vòng lặp ĐỨNG CHỜ khi rảnh
checksetImmediate

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");
Hàng nextTick
process.nextTick(cb)
Hàng microtask
Promise.then(cb) / queueMicrotask
Phase timers
setTimeout(cb, 0)
Phase poll (I/O)
fs.readFile(…, cb) - I/O xong
Phase check
setImmediate(cb)
0/6 bước

Trung thực

Mô hình trên lược bớt vài phase phụ của libuv (pending callbacks, idle/prepare, close callbacks) - chúng hiếm khi đổi kết quả các câu đố thứ tự. Mọi kịch bản trong công cụ đã được chạy kiểm bằng Node thật (CommonJS); bước gắn là cuộc đua có thật, máy bạn có thể ra ngượ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

Bạn đã thấy bộ máy quyết định khi nào code chạy. Bài kế vẫn ở trong phòng máy: bộ nhớ & garbage collector - V8 cất object của mèo con ở đâu, dọn rác lúc nào, và vì sao server Node có thể “phình” bộ nhớ nếu code giữ tham chiếu vô tội vạ.

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.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Trong kiến trúc Node.js, libuv đảm nhiệm việc gì?

  1. 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òng for trong 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. 2

    Thứ tự đảm bảo trong I/O

    Chạy ví dụ fs.readFile ở Bước 5 mười lần. setImmediate có lần nào thua setTimeout khô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. 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 .cjs rồi .mjs, chạy cả hai.

    Hoàn thành khi: .cjs in nextTick trước; .mjs in promise trước - và mèo con giải thích được vì sao (module ESM chạy trong ngữ cảnh microtask).

  4. 4

    Bỏ đói vòng lặp bằng nextTick

    Viết hàm nextTick tự gọi lại chính nó qua process.nextTick (đếm số lần), kèm một setTimeout in "toi duoc chay!" sau 0ms. Chạy thử 2 giây rồi Ctrl+C.

    Hoàn thành khi: setTimeout không bao giờ in được - hàng nextTick cạn mới tới phase timers, mà nó không bao giờ cạn.

  5. 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. 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 nextTick rồi microtask" sau MỖI callback.