Cơ chế hoạt động của Event Loop trong Trình duyệt
Hiểu sâu sắc về Task queues, Microtasks, Checkpoints, Rendering pipeline, starvation và sự khác biệt căn bản so với Node.js.
Bản đồ học tập phát triển phần mềm bởi Tran Trong Thuc · Về dự án Atlas · Cập nhật lần cuối: 9 thg 9, 2026
Tóm tắt nhanh (TL;DR)
- Trình duyệt không có một hàng đợi FIFO đơn lẻ. Event Loop của trình duyệt phối hợp nhiều Task Queues (hàng đợi tác vụ) riêng biệt được cấp nguồn từ các nguồn khác nhau (timers, user input, network, v.v.).
- Microtasks luôn được ưu tiên vét sạch. Tại mỗi Microtask Checkpoint (ngay sau khi một task hoàn thành hoặc call stack rỗng), trình duyệt sẽ chạy toàn bộ microtask queue cho đến khi trống rỗng 100%, bao gồm cả các microtask mới được sinh ra bên trong checkpoint.
- Vẽ giao diện (Rendering) không diễn ra sau mỗi task. Trình duyệt chỉ cập nhật giao diện tại các Rendering Opportunity (thường là 60Hz hoặc 120Hz), và
requestAnimationFrame()chạy ngay TRƯỚC các bước tính toán Style và Layout của frame sắp vẽ. - Tránh nghẽn Main Thread bằng Microtask Starvation: Vòng lặp đệ quy
queueMicrotask()sẽ làm "đóng băng" giao diện trình duyệt hoàn toàn dù không có vòng lặpwhile(true)đồng bộ nào. - Event Loop của Trình duyệt khác hoàn toàn Node.js: Trình duyệt tuân theo đặc tả WHATWG HTML (có chu trình render và task sources), trong khi Node.js vận hành dựa trên kiến trúc libuv với các phases riêng biệt (timers, poll, check/setImmediate).
Ba vùng làm việc: Call Stack, Task Queues và Microtask Queue
🖼️ [Illustration Placeholder: Cấu trúc bộ 3 Event Loop Trình duyệt]
Mô tả hình minh họa: Sơ đồ trực quan thể hiện 3 vùng làm việc chính của JavaScript Runtime trên Main Thread trình duyệt:
- Call Stack: Ngăn xếp thực thi mã đồng bộ (Single-threaded).
- Microtask Queue (VIP lane): Hàng đợi ưu tiên cao cho Promise Jobs (
.then(),.catch()),queueMicrotask(), và MutationObserver callbacks.- Task Queues (Macrotask Lanes): Nhiều hàng đợi song song phân loại theo nguồn: Timer source (
setTimeout), User interaction source (click,keypress), Network I/O source (fetchresponse).- Mũi tên điều phối của Event Loop chỉ rõ: Main thread chỉ nhặt 1 Task từ Task Queue, thực thi xong -> Vét sạch Microtask Queue -> Đánh giá Rendering Opportunity -> Lặp lại.
Chu trình làm việc của Microtask Checkpoint
🖼️ [Illustration Placeholder: Cơ chế xả Microtask Checkpoint]
Mô tả hình minh họa: Sơ đồ luồng trạng thái của một Microtask Checkpoint:
- Bắt đầu Checkpoint: Kiểm tra
microtaskQueue.length > 0.- Lấy microtask đầu tiên ra khỏi hàng đợi và thực thi.
- Nếu microtask đó lại gọi
queueMicrotask(): Đẩy tác vụ mới vào CUỐI hàng đợi hiện tại.- Tiếp tục lặp cho đến khi hàng đợi rỗng hoàn toàn (
empty).- Cảnh báo trực quan: Nếu tác vụ mới liên tục được sinh ra nhanh hơn tốc độ xử lý, checkpoint sẽ rơi vào vòng lặp vô tận (Starvation) và không bao giờ thoát ra để nhường chỗ cho Render hoặc Task khác.
console.log('Script bắt đầu');
setTimeout(() => {
console.log('Timer callback');
}, 0);
Promise.resolve().then(() => {
console.log('Promise microtask 1');
}).then(() => {
console.log('Promise microtask 2 (lồng nhau)');
});
console.log('Script kết thúc');Thứ tự in ra màn hình:
Script bắt đầu
Script kết thúc
Promise microtask 1
Promise microtask 2 (lồng nhau)
Timer callbackĐường ống kết xuất giao diện (Rendering Pipeline)
Một sai lầm rất phổ biến là nghĩ rằng trình duyệt sẽ vẽ lại màn hình ngay sau mỗi tác vụ hay microtask. Thực tế, việc vẽ lại hoàn toàn độc lập và được điều phối bởi chu kỳ quét của màn hình (Refresh Rate).
🖼️ [Illustration Placeholder: Chu trình một Frame Render của Trình duyệt]
Mô tả hình minh họa: Sơ đồ vòng đời một frame render của trình duyệt (ví dụ ở màn hình 60Hz ~ 16.6ms):
- Main thread xử lý Task và Microtask checkpoint xong.
- Rendering Opportunity: Trình duyệt kiểm tra xem có cần update frame hay không (nếu tab background hoặc không có thay đổi DOM/CSS thì bỏ qua).
- Nếu có: Chạy theo thứ tự: Resize/Scroll events ->
requestAnimationFrame()callbacks -> Style recalculation -> Layout (Reflow) -> Paint -> Composite -> Gửi sang GPU.- Nhấn mạnh điểm quan trọng: rAF chạy TRƯỚC Style & Layout của frame sắp render, chứ không phải chạy sau khi vừa kết thúc mỗi microtask ngẫu nhiên.
Phòng thí nghiệm Event Loop (Interactive Lab)
Trải nghiệm trực quan cách Event Loop điều phối giữa Call Stack, Microtasks, Timers và Rendering Pipeline:
Phòng thực hành Event Loop
console.log('A');
setTimeout(() => console.log('timer'), 0);
Promise.resolve().then(() => console.log('promise'));
console.log('B');Tác vụ đang thực thi
initial script
Hàng đợi Microtasks
Trống
Tác vụ sẵn sàng theo nguồn
Mã kịch bản (Script)
Trống
Bộ đếm giờ (Timer)
Trống
Tương tác người dùng
Trống
Mạng (Networking)
Trống
Dựng hình (Rendering)
Trống
Các luồng này gom nhóm công việc theo nguồn tác vụ phục vụ giải thích. Điều này không có nghĩa là mỗi nguồn tác vụ tương ứng 1-1 với một hàng đợi tác vụ của trình duyệt.
Công việc liên quan đến dựng hình
Trạng thái dựng hình: Đang thực thi tác vụ
Các hàm gọi lại requestAnimationFrame
Trống
Nhật ký kết quả
Chưa có kết quả
Giải thích bước này
The initial script is the currently selected work.
Hiện tượng Starvation và Đóng băng Main Thread
🖼️ [Illustration Placeholder: Đóng băng Main Thread: Long Task vs Microtask Loop]
Mô tả hình minh họa: So sánh trực quan 2 cơ chế làm nghẽn Main Thread:
- Kịch bản A (Long Synchronous Task): Một hàm tính toán CPU nặng chiếm Call Stack suốt 400ms -> User click chuột hoặc scroll màn hình bị đẩy vào Task Queue nhưng hoàn toàn không được xử lý -> DevTools Performance tab xuất hiện vệt đỏ "Long Task (>50ms)", INP tăng vọt.
- Kịch bản B (Unbounded Microtask Chain): Mỗi microtask chỉ tốn 0.5ms nhưng liên tục gọi đệ quy
queueMicrotask()-> Microtask queue không bao giờ cạn (empty) -> Trình duyệt bị kẹt cứng trong checkpoint, hoàn toàn không thể thoát ra để nhường chỗ cho Macrotask hoặc Render frame -> Màn hình đơ cứng dù CPU không chạy một vòng lặpwhile(true)đồng bộ nào.
Sự cố thực tế: Đóng băng giao diện "vô hình" do Microtask
Một ứng dụng web chat realtime xử lý hàng loạt tin nhắn nhận về qua WebSocket. Khi một lô tin nhắn 500 items ùa về, lập trình viên sử dụng một hàm đệ quy để xử lý từng tin nhắn:
function processMessageBatch(messages: Message[]) {
if (messages.length === 0) return;
const msg = messages.shift()!;
renderMessage(msg);
// Cố gắng "chia nhỏ" bằng queueMicrotask thay vì xử lý loop đồng bộ:
queueMicrotask(() => processMessageBatch(messages));
}- Hậu quả: Dù không có một vòng lặp
fordài gây blocking Call Stack, UI vẫn bị "đóng băng" suốt 1.2 giây! User click vào nút "Gửi" hay thanh input đều hoàn toàn bất động. Chỉ số INP (Interaction to Next Paint) báo đỏ nghiêm trọng (>1000ms). - Nguyên nhân cốt lõi: Lập trình viên nhầm lẫn giữa
queueMicrotask()và việc nhường luồng (yielding) cho trình duyệt.queueMicrotask()không bao giờ nhường quyền điều khiển cho Task Queue hay Rendering Pipeline; nó tiếp tục giữ chặt checkpoint cho đến khi queue rỗng 100%. - Cách khắc phục chuẩn: Nếu muốn nhường luồng cho trình duyệt vẽ lại UI và nhận input click, phải đẩy công việc ra Macrotask hoặc sử dụng scheduler chuẩn:
// Giải pháp hiện đại: scheduler.yield() nếu hỗ trợ, fallback sang setTimeout 0 async function processMessageBatch(messages: Message[]) { for (const msg of messages) { renderMessage(msg); if ('scheduler' in window && 'yield' in (window as any).scheduler) { await (window as any).scheduler.yield(); } else { await new Promise((resolve) => setTimeout(resolve, 0)); } } }
So sánh Event Loop giữa Trình duyệt và Node.js
| Đặc tính | Trình duyệt (Browser) | Node.js |
|---|---|---|
| Đặc tả quy chuẩn | WHATWG HTML Standard | Node.js Runtime Architecture (dựa trên libuv) |
| Giao diện & Hiển thị | Có Rendering Pipeline và requestAnimationFrame() | Không có rendering pipeline |
| API hoãn tác vụ cấp hệ thống | scheduler.yield(), requestIdleCallback() | setImmediate(), process.nextTick() |
| Quy tắc ưu tiên | Ưu tiên tính phản hồi của người dùng và khung hình | Ưu tiên thông lượng I/O và tính toán server |
Bài tập kiểm tra tư duy
Hãy dự đoán thứ tự in ra console của đoạn mã sau:
console.log('A');
setTimeout(() => {
console.log('timer');
}, 0);
Promise.resolve().then(() => {
console.log('promise');
queueMicrotask(() => {
console.log('nested microtask');
});
});
queueMicrotask(() => {
console.log('queued microtask');
});
console.log('B');Xem giải thích chi tiết
Thứ tự in ra:
A
B
promise
queued microtask
nested microtask
timerGiải thích:
- Đồng bộ chạy trước:
A, rồiB. - Microtask Checkpoint bắt đầu:
- Microtask đầu tiên: in
promise, đồng thời đẩynested microtaskvào cuối hàng đợi microtask. - Microtask tiếp theo đã xếp hàng sẵn từ trước: in
queued microtask. - Microtask mới sinh: in
nested microtask.
- Microtask đầu tiên: in
- Chỉ sau khi Microtask Queue cạn kiệt hoàn toàn, Event Loop mới chuyển sang nhặt task từ Timer Queue: in
timer.
Checklist rà soát mã nguồn cho Kỹ sư
- Thời lượng tác vụ (Task Duration): Mọi tác vụ trên Main Thread có hoàn thành dưới ngưỡng 50ms (ngưỡng Long Task trong DevTools) không?
- Giới hạn Microtask: Các lời gọi
queueMicrotask()hoặc chuỗi Promise đệ quy có điểm dừng rõ ràng để thoát khỏi checkpoint không? - Không phỏng đoán thứ tự Task Source: Tránh dựa vào giả định rằng timer sẽ luôn chạy trước hay sau một sự kiện người dùng không liên quan.
- Lên lịch hoạt ảnh chuẩn: Các thao tác cập nhật DOM liên tục hoặc animation đã được đưa vào
requestAnimationFrame()chưa? - Phân tách ranh giới môi trường: Đảm bảo không mang các API đặc thù của Node (
process.nextTick,setImmediate) sang code chạy trên trình duyệt.
Tài liệu quy chuẩn
- WHATWG HTML Standard — Event Loops — Đặc tả quy chuẩn thế giới về Event Loop, hàng đợi tác vụ và microtask checkpoint trên trình duyệt.
- W3C Prioritized Task Scheduling API — Đặc tả chuẩn về
scheduler.yield()vàscheduler.postTask().
Tránh bẫy Thác nước bất đồng bộ (Async Waterfalls)
Tối ưu hóa độ trễ bằng cách chạy đan xen các tác vụ bất đồng bộ độc lập mà không phá vỡ các chuỗi phụ thuộc dữ liệu thực tế.
Promises: Giải quyết trạng thái, Nối chuỗi và Xử lý lỗi
Xây dựng mô hình tư duy chuẩn xác về trạng thái Promise, resolution, chaining, khôi phục lỗi, adoption, combinators và các API hiện đại.