Menu

Event loop trong Node.js: phase, nextTick và setImmediate

Event loop của Node.js chạy callback theo các phase (timers, poll, check, close), và giữa mỗi callback nó làm rỗng hàng đợi process.nextTick rồi đến hàng đợi promise. Chạy các ví dụ để thấy thứ tự chính xác.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Event loop của Node.js là vòng lặp quyết định callback nào chạy tiếp theo sau khi code đồng bộ của bạn đã chạy xong. Nó đi qua các phase theo một chu kỳ cố định: timers (setTimeout, setInterval), pending callbacks, poll (I/O như đọc tệp và dữ liệu mạng), check (setImmediate) và close callbacks. Sau mỗi callback, Node làm rỗng hàng đợi process.nextTick trước, rồi đến hàng đợi promise, nên hai hàng đợi này luôn chạy trước khi vòng lặp đi tiếp.

Kết quả in ra là sync, nextTick, promise, setTimeout, ngược với thứ tự chúng được viết. Code đồng bộ luôn chạy xong trước, rồi Node làm rỗng hàng đợi nextTick, rồi đến hàng đợi promise, và chỉ sau đó vòng lặp mới bắt đầu và đến phase timers.

Các phase của event loop

Vòng lặp của Node đến từ libuv, thư viện C mà Node dùng cho I/O. Trước vòng đầu tiên, nó chạy các timer đã đến hạn, rồi mỗi vòng chạy lần lượt các phase sau:

  1. Pending callbacks: một vài callback hệ thống bị hoãn từ vòng trước, chẳng hạn một số lỗi mạng.
  2. Poll: các sự kiện I/O mới và callback của chúng (đọc tệp xong, có dữ liệu đến socket). Khi không có gì khác được lên lịch, vòng lặp chờ ở đây.
  3. Check: các callback của setImmediate.
  4. Close callbacks: các sự kiện 'close', như một socket vừa bị hủy.
  5. Timers: callback của setTimeout và setInterval đã đến giờ.

Nhiều hướng dẫn liệt kê timers đầu tiên trong mỗi vòng, đó là cách Node hoạt động trước phiên bản 20 (libuv 1.45). Dù theo cách nào, các callback vẫn chạy cùng một thứ tự: timers của một vòng được nối tiếp bởi poll của vòng sau.

Có hai hàng đợi nằm ngoài các phase: hàng đợi nextTick và hàng đợi microtask (callback của promise và queueMicrotask). Node làm rỗng cả hai sau mỗi callback, ở bất kỳ phase nào, nextTick trước. Vòng lặp tiếp tục quay chừng nào còn thứ để chờ (một timer, một tệp hay socket đang mở, một máy chủ) và tiến trình kết thúc khi không còn gì.

Đây là vòng lặp riêng của Node. Trình duyệt có chung ý tưởng về task và microtask nhưng không có phase, không có setImmediate và không có process.nextTick; trang event loop của JavaScript trình bày bức tranh ở cấp độ ngôn ngữ.

process.nextTick và promise

Cả hai đều chạy trước khi vòng lặp đi tiếp, nhưng chúng không bị trộn lẫn. Node chạy toàn bộ hàng đợi nextTick, kể cả các tick được thêm vào trong lúc chạy, rồi mới đến hàng đợi promise. Hãy đoán kết quả đoạn này:

Thứ tự là tick 1, tick 2, tick from tick, promise 1, promise 2. Tick được lên lịch bên trong một tick sẽ vào cùng hàng đợi và vẫn chạy trước mọi promise, dù promise được tạo trước.

Ở cấp cao nhất của một ES module (tệp dùng import), hai hàng đợi đổi chỗ: chính code của module chạy bên trong một promise job, nên các callback promise của nó hoàn tất trước khi Node đến hàng đợi nextTick. Bên trong callback, quy tắc nextTick chạy trước vẫn đúng ở cả hai hệ thống module.

setImmediate và setTimeout(fn, 0)

setTimeout(fn, 0) thực ra có nghĩa là "sau ít nhất 1 ms, trong phase timers". setImmediate(fn) có nghĩa là "trong phase check tiếp theo". Cái nào chạy trước phụ thuộc vào chỗ bạn gọi chúng.

Bên trong một callback I/O, thứ tự là cố định. Callback chạy trong phase poll, phase check đến ngay sau đó, và phase timers chỉ đến sau nữa:

Kết quả in ra là nextTick, setImmediate, setTimeout, lần nào cũng vậy. Điều đó khiến setImmediate là lựa chọn đúng cho việc "chạy cái này sau công việc I/O hiện tại".

Trong module chính, bên ngoài mọi callback, thứ tự của hai hàm không cố định:

// Order depends on how fast the process started
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));

Nếu đã trôi qua ít nhất 1 ms khi vòng lặp bắt đầu, timer đã đến hạn và được in trước; nếu không, setImmediate thắng. Đừng viết code phụ thuộc vào bất kỳ kết quả nào trong hai.

Chạy khiDùng cho
process.nextTick(fn)Ngay sau thao tác hiện tại, trước promisePhát một sự kiện hoặc lỗi sau khi nơi gọi đã gắn listener
Promise.then, queueMicrotaskSau khi hàng đợi nextTick đã rỗngCode bất đồng bộ thông thường
setImmediate(fn)Phase check, sau các callback I/ONhường lượt cho vòng lặp giữa các phần công việc
setTimeout(fn, 0)Phase timers, sau ít nhất 1 msĐộ trễ, khi thời điểm chính xác không quan trọng

Chặn event loop

Vì chỉ có một luồng JavaScript, một callback chạy lâu sẽ làm dừng mọi thứ khác: timer, callback I/O, và trong một máy chủ là mọi request khác. Độ trễ của timer là mức tối thiểu, không phải sự đảm bảo.

loop finished được in trước, rồi đến true: timer đến hạn sau 10 ms nhưng không thể chạy cho đến khi vòng lặp bận rộn trả lại luồng. Trong một máy chủ, cùng vòng lặp đó bên trong một route handler sẽ làm đóng băng mọi request khác trong nửa giây.

Cách xử lý công việc dài là chia nhỏ nó và nhường một lượt cho vòng lặp giữa các phần bằng setImmediate:

Timer chen vào được sau phần 1. Thay setImmediate(() => work(part + 1)) bằng work(part + 1) rồi chạy lại: cả ba phần chạy một mạch và timer được in cuối cùng.

Với công việc nặng từ đầu đến cuối, như xử lý ảnh, chia nhỏ là chưa đủ: hãy chuyển nó ra khỏi luồng chính bằng worker_threads.

Những lỗi thường gặp

Gọi process.nextTick đệ quy. Một callback nextTick cứ liên tục lên lịch thêm nextTick sẽ không bao giờ cho vòng lặp đi tiếp, khiến timer và I/O bị bỏ đói. Hãy dùng setImmediate cho công việc lặp lại.

Dùng API đồng bộ trong máy chủ. fs.readFileSync, crypto.pbkdf2Sync và các lời gọi JSON.parse lớn chặn vòng lặp đối với mọi người dùng. Hãy dùng phiên bản bất đồng bộ bên trong các request handler.

Nghĩ rằng setTimeout(fn, 0) sẽ chạy ngay. Nó chạy sau toàn bộ code đồng bộ, mọi nextTick và mọi callback promise, và chỉ khi vòng lặp đến phase timers.

Câu hỏi thường gặp

Node.js có phải đơn luồng không?

Code JavaScript của bạn chạy trên một luồng, nên hai callback không bao giờ chạy cùng lúc. Bản thân Node dùng nhiều luồng hơn: libuv giữ một pool (mặc định bốn luồng) cho việc đọc ghi hệ thống tệp, tra cứu DNS và một số tác vụ mã hóa, rồi trả kết quả về cho vòng lặp.

Khi nào một chương trình Node.js kết thúc?

Khi event loop không còn gì để chờ: không còn timer, không còn I/O đang chờ, không còn máy chủ hay socket nào đang mở. Một setInterval đang chạy hoặc một máy chủ đang lắng nghe sẽ giữ tiến trình sống cho đến khi bạn xóa hoặc đóng nó.

Event loop của Node.js có giống của trình duyệt không?

Ý tưởng thì giống nhau, một luồng chạy các callback trong hàng đợi, nhưng chi tiết thì khác. Trình duyệt không có setImmediate hay process.nextTick và chen việc vẽ giao diện vào giữa các task, còn Node chạy các phase của libuv. Trang event loop của JavaScript trình bày phần chung ở cấp độ ngôn ngữ.

Làm sao biết event loop đang bị chặn?

Hãy đo xem nó chạy trễ bao nhiêu: monitorEventLoopDelay() trong perf_hooks ghi lại độ trễ, và giá trị trung bình lên tới hàng chục mili giây nghĩa là có callback nào đó giữ luồng quá lâu. Chạy Node với --cpu-prof để biết đó là hàm nào.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu