Khái quát về cơ chế Event Loop
JavaScript vận hành theo mô hình đơn luồng (single-threaded), nhưng khả năng xử lý các hoạt động không đồng bộ hiệu quả lại nhờ vào cơ chế Event Loop. Thành phần này chịu trách nhiệm điều phối luồng thực thi, thu thập sự kiện từ hệ thống và quản lý các tác vụ con nằm trong hàng đợi. Dù cùng nguyên lý cốt lõi, cách triển khai thực tế của trình duyệt và Node.js có những khác biệt nhất định về kiến trúc.
Mô hình đồng thời dựa trên Event Loop đảm bảo luồng chính luôn sẵn sàng xử lý, đồng thời điều phối các callback khi chúng sẵn sàng.
Cốt lõi của vòng lặp sự kiện thường được mô phỏng qua một cấu trúc lặp liên tục:
while (hàngĐợi.chờTinNhắn()) {
hàngĐợi.xửLýTinTiếpTheo();
}
Hàm chờ sẽ giữ trạng thái đồng bộ cho đến khi có tác vụ mới sẵn sàng được đẩy vào hệ thống.
Phân loại tác vụ đồng bộ và bất đồng bộ
Mọi đoạn mã JS đều được chia thành hai nhóm chính. Tác vụ đồng bộ thực thi ngay lập tức trên ngăn xếp lời gọi (call stack) theo trình tự từ trên xuống. Ngược lại, tác vụ bất đồng bộ sẽ được tạm giữ và chuyển callback của nó vào hàng đợi thích hợp khi quá trình bên ngoài hoàn tất. Chỉ khi ngăn xếp trống rỗng, Event Loop mới đẩy các callback này vào để thực thi.
Cấu trúc hàng đợi Macro Task và Micro Task
Hàng đợi bất đồng bộ được phân tách rõ ràng thành hai cấp độ ưu tiên khác nhau:
Macro Task (Tasks)
Các callback thuộc nhóm này sẽ lần lượt được đưa vào hàng đợi macro, chờ đợi lượt thực thi. Danh sách phổ biến bao gồm:
setTimeoutvàsetIntervalsetImmediate(độc quyền Node.js)requestAnimationFrame(độc quyền trình duyệt)- Hoạt động I/O và render giao diện (trình duyệt)
Micro Task (Jobs)
Nhóm này có độ ưu tiên cao hơn, các callback được xếp vào hàng đợi micro để chờ thực thi. Bao gồm:
process.nextTick(độc quyền Node.js)Promise(các phương thức.then(),.catch(),.finally())Object.observe(đã bị loại bỏ)MutationObserver(theo dõi thay đổi DOM)
Luồng thực thi cơ bản
- Thực thi toàn bộ mã đồng bộ trên call stack.
- Kiểm tra hàng đợi Micro Task. Nếu có, thực thi chúng theo trình tự FIFO cho đến khi cạn.
- Chuyển sang thực thi một Macro Task duy nhất từ hàng đợi.
- Ngay sau khi một Macro Task kết thúc, quay lại bước 2 để xử lý bất kỳ Micro Task mới được sinh ra trong quá trình đó.
- Lặp lại chu trình cho đến khi cả hai hàng đợi đều trống.
Minh họa qua ví dụ thực tế
Ví dụ 1: Tương tác giữa Promise và Timer
const ghiLog = (msg) => console.log(`[APP] ${msg}`);
ghiLog('khởi tạo chương trình');
setTimeout(() => {
ghiLog('hết giờ bộ đếm');
}, 0);
Promise.resolve()
.then(() => {
ghiLog('luồng promise mức 1');
return Promise.resolve();
})
.then(() => {
ghiLog('luồng promise mức 2');
});
ghiLog('kết thúc khối đồng bộ');
Kết quả in ra console:
[APP] khởi tạo chương trình
[APP] kết thúc khối đồng bộ
[APP] luồng promise mức 1
[APP] luồng promise mức 2
[APP] hết giờ bộ đếm
Giải thích chi tiết:
- Hệ thống thực thi hai lệnh
ghiLogđồng bộ ở đầu và cuối file. setTimeoutđược đăng ký và đẩy vào hàng đợi Macro Task.- Chuỗi
Promiseđược giải quyết ngay lập tức, các callback.thenđược xếp vào hàng đợi Micro Task. - Sau khi mã đồng bộ xong, Event Loop quét Micro Task, thực thi lần lượt
promise mức 1vàpromise mức 2. - Hàng đợi Micro Task trống, chuyển sang lấy một Macro Task là
setTimeoutđể thực thi.
Ví dụ 2: Sinh ra Micro Task mới trong Macro Task
console.log('B1');
setTimeout(() => {
console.log('B2');
Promise.resolve().then(() => console.log('B3'));
}, 0);
new Promise((xuLy) => {
console.log('B4');
xuLy('Dữ liệu trả về');
}).then(ketQua => console.log(ketQua));
setTimeout(() => console.log('B5'), 0);
console.log('B6');
Trình tự xuất hiện:
B1
B4
B6
Dữ liệu trả về
B2
B3
B5
Phân tích từng bước:
- Thực thi mã đồng bộ: in ra
B1,B4(khốinew Promisechạy đồng bộ khi khởi tạo),B6. HaisetTimeoutđược đẩy vào hàng đợi Macro Task. - Kiểm tra Micro Task: callback của
Promiseđược thực thi, in raDữ liệu trả về. - Hàng đợi Micro Task trống, lấy Macro Task đầu tiên (
setTimeoutđầu tiên). In raB2. - Trong quá trình thực thi Macro Task này, một Promise mới xuất hiện và callback của nó được đẩy vào hàng đợi Micro Task. Event Loop lập tức quay lại xử lý Micro Task, in ra
B3. - Hàng đợi Micro Task trống lần nữa, lấy Macro Task tiếp theo (
setTimeoutthứ hai), in raB5. - Toàn bộ hàng đợi cạn, chu kỳ kết thúc.
Điểm quan trọng cần lưu ý: Nếu trong quá trình xử lý Micro Task lại sinh ra Micro Task mới, hệ thống sẽ tiếp tục thêm vào cuối hàng đợi và xử lý luôn trong cùng chu kỳ hiện tại. Hành vi này có thể gây ra hiện tượng đói tài nguyên nếu tạo lặp vô hạn, nhưng môi trường thực thi thường đặt giới hạn (ví dụ: process.nextTick trong Node.js giới hạn ở mức 1000 lần liên tiếp).
Đặc thù Event Loop trong Node.js
Khác với môi trường trình duyệt, vòng lặp sự kiện trong Node.js được xây dựng dựa trên thư viện libuv. Thành phần này cung cấp lớp trừu tượng đa nền tảng, tập trung vào mô hình không chặn (non-blocking) và điều khiển sự kiện thông qua hàng đợi callback.
Hàng đợi Macro Task trong Node.js được chia nhỏ thành 6 giai đoạn tuần tự:
- Timers: Thực thi callback của
setTimeoutvàsetInterval. - I/O Callbacks: Xử lý hầu hết các callback I/O, ngoại trừ sự kiện đóng kết nối và các timer riêng biệt.
- Idle, Prepare: Dự trữ cho hệ thống nội bộ, thường không can thiệp trực tiếp từ mã người dùng.
- Poll: Thu thập sự kiện I/O mới từ hệ điều hành và điều chỉnh chặn luồng nếu cần thiết.
- Check: Chạy callback của
setImmediate. - Close Callbacks: Xử lý sự kiện đóng kết nối (ví dụ:
socket.on('close')).
Về cấu trúc hàng đợi, Node.js tách Macro Task thành nhiều pool riêng biệt (Timers, I/O, Check, Close), trong khi trình duyệt thường gom chung vào một danh sách duy nhất. Tương tự, Micro Task được chia làm hai nhóm rõ ràng: Next Tick Queue (chứa process.nextTick) và Other Micro Queue (chứa Promise và các API hiện đại khác).
Chu trình vận hành của Node.js tuân thủ quy luật sau:
- Chạy mã đồng bộ toàn cục.
- Xử lý sạch hàng đợi Micro Task (ưu tiên tuyệt đối cho
Next Tick Queue, sau đó mới đếnOther Micro Queue). - Di chuyển qua từng giai đoạn Macro Task. Tại mỗi giai đoạn, hệ thống sẽ thực thi toàn bộ tác vụ trong queue tương ứng.
- Ngay khi một giai đoạn Macro Task hoàn tất, máy trạng thái lập tức quay trở lại bước 2 để làm sạch Micro Task trước khi sang giai đoạn tiếp theo.
Chu kỳ này lặp vô tận: Timers -> Micro Tasks -> I/O -> Micro Tasks -> Check -> Micro Tasks -> Close -> Micro Tasks -> lặp lại. Cơ chế này đảm bảo rằng các tác vụ ưu tiên cao luôn được xử lý nhanh chóng, đồng thời ngăn ngừa hiện tượng đói tài nguyên khi hàng đợi Micro Task bị làm đầy liên tục trong cùng một chu kỳ.