Đơn luồng, nhưng không tuần tự
JavaScript chạy trên một luồng. Chỉ có một call stack, và tại mỗi thời điểm, đúng một hàm đang thực thi. Không bao giờ có hai dòng code của bạn chạy song song trong cùng một realm.
Nghe có vẻ hạn chế, cho đến khi bạn nhớ ra JavaScript thực sự làm gì: tải dữ liệu, chờ người dùng nhấp chuột, đọc tệp. Phần lớn "công việc" là chờ đợi. Event loop là mẹo giúp việc chờ đợi trở nên rẻ: code của bạn giao một việc cho trình duyệt hoặc Node, quay lại làm việc khác, và được báo khi có kết quả.
first và second in ra theo thứ tự. third in ra sau, dù thời gian chờ là 0. Khoảng cách đó chính là event loop đang làm việc, và hiểu được tại sao là mục đích của cả trang này.
Call stack
Mỗi lời gọi hàm đẩy một frame lên call stack. Khi hàm trả về, frame của nó bị lấy ra. Stack chỉ là một ngăn xếp: vào sau, ra trước.
Khi outer() chạy, Node đẩy outer, rồi inner, rồi lấy inner ra khi nó trả về "done", rồi lấy outer ra. Stack lại trống. Khoảnh khắc "stack trống" đó chính là thứ event loop đang chờ.
Code đồng bộ chạy từ đầu đến cuối trên stack. Không gì bất đồng bộ có thể chen ngang. Nếu bạn viết một vòng lặp while (true), stack không bao giờ trống và trang bị treo: không nhận click, không có bộ đếm giờ, không có callback của promise. Event loop không có việc gì làm vì nó không bao giờ đến lượt.
Việc bất đồng bộ thực sự nằm ở đâu
Bản thân JavaScript không biết cách gửi request mạng hay chờ 100 mili giây. Các API đó thuộc về môi trường chủ: trình duyệt hoặc Node. Khi bạn gọi setTimeout(fn, 100), đây là những gì xảy ra:
- Bộ đếm giờ được đăng ký với môi trường chủ.
setTimeouttrả về ngay lập tức. Stack tiếp tục chạy.- 100ms sau, môi trường chủ đưa
fnvào một hàng đợi. - Khi stack trống, event loop lấy
fnra khỏi hàng đợi và chạy nó.
Callback của bộ đếm giờ không thể chạy cho đến khi vòng lặp for và console.log("end") xong, vì stack vẫn chưa trống. Bộ đếm giờ là độ trễ tối thiểu, không phải một sự đảm bảo.
Hai hàng đợi: task và microtask
Không chỉ có một hàng đợi. Có hai, và sự khác biệt giữa chúng giải thích hầu hết những bất ngờ về event loop.
- Hàng đợi task (đôi khi gọi là hàng đợi macrotask):
setTimeout,setInterval, callback I/O, sự kiện giao diện. - Hàng đợi microtask: callback của promise (
.then,.catch,.finally), phần tiếp nối sauawait, và mọi thứ được lên lịch bằngqueueMicrotask.
Quy tắc mà event loop tuân theo:
- Chạy một task từ hàng đợi task.
- Xử lý toàn bộ hàng đợi microtask: mọi microtask, kể cả những cái được lên lịch trong lúc đang xử lý.
- Vẽ lại giao diện nếu cần (trên trình duyệt).
- Quay lại bước 1.
Microtask luôn chạy trước task tiếp theo. Đó là lý do đoạn này khiến nhiều người bất ngờ:
Thứ tự in ra: sync 1, sync 2, promise, timeout. Code đồng bộ chạy trước. Sau đó stack trống. Rồi event loop xử lý các microtask (promise). Chỉ sau đó nó mới lấy task của bộ đếm giờ (timeout).
Microtask có thể bỏ đói task
Vì hàng đợi microtask được xử lý hết trước task tiếp theo, một microtask liên tục lên lịch thêm microtask sẽ chặn hàng đợi task mãi mãi:
Bộ đếm giờ sẽ không bao giờ chạy, vì mỗi microtask lại đưa thêm một microtask vào hàng đợi, và vòng lặp không bao giờ để hàng đợi trống. Chuỗi promise thì an toàn vì mỗi .then chỉ lên lịch một phần tiếp nối, nhưng những vòng lặp microtask tự viết là cái bẫy đáng biết.
await là cú pháp rút gọn cho một microtask
Khi bạn await một promise, hàm tạm dừng và phần còn lại của nó được lên lịch như một microtask để chạy khi promise hoàn tất. Không có phép màu nào cả, bên dưới chỉ là .then.
Kết quả: A, C, B. await trả quyền điều khiển về cho nơi gọi. console.log("C") chạy trên stack hiện tại. Sau đó hàng đợi microtask được xử lý và phần còn lại của demo tiếp tục, in ra B.
Hãy nhớ điều này khi đọc code bất đồng bộ. await không chặn mà là nhường lượt.
Ví dụ chi tiết: sắp xếp mọi thứ
Ghép tất cả các phần lại:
Thứ tự:
1: sync: chạy trên stack.6: sync: vẫn trên stack.- Stack trống. Hàng đợi microtask được xử lý:
3: promise,5: microtask, rồi4: nested microtask(được lên lịch trong lúc đang xử lý, nhưng vẫn được lấy ra chạy). - Task tiếp theo chạy:
2: timeout.
Kết quả cuối cùng: 1, 6, 3, 5, 4, 2. Nếu bạn lần theo được ví dụ này, bạn đã hiểu event loop.
Node có nhiều giai đoạn hơn
Event loop của Node là một phiên bản mở rộng của mô hình trên trình duyệt. Nó có các giai đoạn riêng biệt (timers, pending I/O callbacks, poll, check, close) và microtask được xử lý giữa mỗi giai đoạn. setImmediate chạy trong giai đoạn check, process.nextTick chạy trước các microtask thông thường (nó có hàng đợi riêng với độ ưu tiên còn cao hơn).
Bạn không cần thuộc lòng sơ đồ các giai đoạn ngay từ ngày đầu. Điều cốt lõi vẫn giống trên trình duyệt: code đồng bộ chạy đến hết, rồi microtask được xử lý, rồi vòng lặp lấy callback tiếp theo trong hàng đợi.
Tại sao điều này quan trọng
Khi đã nắm được mô hình, rất nhiều code bất đồng bộ không còn bí ẩn nữa:
- Một vòng lặp
fordài làm treo giao diện vì event loop không đến lượt. setTimeout(fn, 0)là cách hoãn công việc đến sau khi task hiện tại và các microtask chạy xong.- Một callback
.thenchạy "ngay lập tức" sau một promise đã resolve vẫn phải chờ code đồng bộ hiện tại kết thúc. awaitbên trong vòng lặp khiến công việc chạy tuần tự, vì mỗi lần lặp đều nhường lượt cho hàng đợi microtask rồi mới tiếp tục.
Debug code bất đồng bộ phần lớn là hỏi "lúc này trên stack có gì, và trong hàng đợi có gì?" Event loop chính là câu trả lời.
Tiếp theo: callback
Trước khi có promise và async/await, công cụ duy nhất của JavaScript cho việc bất đồng bộ là callback: một hàm bạn đưa cho API để nó gọi lại sau. Callback vẫn có mặt khắp nơi (trình lắng nghe sự kiện, các API lõi của Node), và hiểu chúng là nền tảng cho mọi thứ khác trong chương này.
Câu hỏi thường gặp
Event loop trong JavaScript là gì?
Đó là cơ chế cho phép JavaScript đơn luồng chạy các việc bất đồng bộ mà không bị chặn. Vòng lặp này theo dõi call stack: khi stack trống, nó lấy callback tiếp theo trong hàng đợi ra và chạy. Bộ đếm giờ, I/O và phần tiếp nối của promise đều nằm trong các hàng đợi mà event loop xử lý lần lượt từng mục một.
Tại sao JavaScript là đơn luồng?
Đặc tả ngôn ngữ định nghĩa một call stack cho mỗi realm, nên code của bạn chạy trên một luồng duy nhất. Tính đồng thời đến từ môi trường chủ (trình duyệt hoặc Node) giao việc cho các API chạy nền (bộ đếm giờ, mạng, đọc ghi tệp) và đưa một callback vào hàng đợi khi chúng xong. Bạn không bao giờ thấy hai đoạn JS chạy cùng lúc trong cùng một ngữ cảnh.
Microtask và macrotask khác nhau thế nào?
Microtask đến từ promise (.then, await) và queueMicrotask. Macrotask đến từ setTimeout, setInterval, I/O và sự kiện giao diện. Sau mỗi macrotask, event loop xử lý toàn bộ hàng đợi microtask rồi mới chạy macrotask tiếp theo. Đó là lý do một Promise.resolve().then(...) luôn chạy trước một setTimeout(..., 0) được lên lịch cùng lúc.
Tại sao setTimeout với 0ms không chạy ngay lập tức?
setTimeout(fn, 0) không có nghĩa là 'chạy ngay bây giờ' mà là 'đưa fn vào hàng đợi như một macrotask, sớm nhất sau 0ms.' Code đồng bộ hiện tại phải chạy xong, hàng đợi microtask phải được xử lý hết, rồi event loop mới lấy callback của bộ đếm giờ ra. Vậy nên 0 là mức tối thiểu, không phải lời hứa.