Vòng lặp sự kiện (event loop) JavaScript điều khiển trang web của bạn hoạt động rất khác so với vòng lặp điều khiển máy chủ Node.js, và sự khác biệt này có thể làm đóng băng giao diện người dùng (UI) hoặc gây nghẽn I/O nếu bạn không cẩn thận. Hiểu rõ điểm khác biệt giữa hai môi trường này là điều thiết yếu cho bất kỳ ai viết mã bất đồng bộ (async code) chạy trên cả hai nền tảng.

Tại sao sự phân biệt này lại quan trọng

Vòng lặp sự kiện không được định nghĩa bởi đặc tả ECMAScript; nó tồn tại trong môi trường thực thi (host). Trình duyệt phải giữ cho trang web luôn phản hồi trong khi kết xuất (render) các khung hình, trong khi Node.js được xây dựng xoay quanh I/O không chặn (non-blocking I/O). Việc trộn lẫn các mô hình hoạt động ở môi trường này sang môi trường kia có thể tạo ra các lỗi rất khó tái hiện: một chuỗi promise dài có thể làm trì trệ quá trình vẽ lại (repaint) của trình duyệt, trong khi một vòng lặp process.nextTick không được kiểm soát có thể ngăn Node.js tiến tới các giai đoạn I/O của nó.

Vòng lặp theo lượt của trình duyệt

Trong trình duyệt, vòng lặp chạy một chu kỳ duy nhất xen kẽ giữa việc thực thi tác vụ (task), giải phóng microtask và kết xuất (rendering):

  1. Chạy một macrotask (một trình xử lý sự kiện click, một setTimeout, v.v.).
  2. Giải phóng tất cả microtask (promises, queueMicrotask).
  3. Nếu đến lượt kết xuất khung hình, thực hiện vẽ (paint) và tổng hợp (composite) để đạt mục tiêu 60 fps.
  4. Lặp lại.

Hai API cung cấp cho lập trình viên các điểm móc (hooks) rõ ràng vào chu kỳ này:

  • requestAnimationFrame – được gọi ngay trước khi trình duyệt vẽ. Đây là nơi phù hợp cho các công việc liên quan đến hoạt ảnh (animation) vì callback sẽ chạy sau các microtask hiện tại nhưng trước khung hình tiếp theo.
  • requestIdleCallback – được gọi khi trình duyệt không có công việc ưu tiên cao. Nó hữu ích cho các tác vụ ít ảnh hưởng như phân tích (analytics) hoặc tải trước dữ liệu (pre-loading).

Sai lầm thường gặp: microtask starvation (đói microtask)

Vì trình duyệt giải phóng hàng đợi microtask trước khi kết xuất, một chuỗi promise dài có thể khiến UI không bao giờ được vẽ. Call stack không bị chặn; trang web chỉ đơn giản là không bao giờ đạt đến bước kết xuất, khiến người dùng cảm thấy như bị đóng băng.

Vòng lặp do libuv điều khiển của Node

Node.js ủy thác vòng lặp của mình cho libuv, một thư viện C chia công việc thành các giai đoạn (phase) riêng biệt, mỗi giai đoạn có hàng đợi riêng:

  1. Timers – các callback từ setTimeoutsetInterval.
  2. Pending callbacks – các callback I/O bị trì hoãn đã hoàn thành ở cấp độ hệ điều hành.
  3. Poll – lấy các sự kiện I/O mới (đọc tệp, dữ liệu mạng).
  4. Check – chạy các callback setImmediate.
  5. Close callbacks – kích hoạt khi một socket hoặc handle đóng lại.

Có hai cấu trúc nằm ngoài thứ tự các giai đoạn này:

  • process.nextTick – chạy trước hàng đợi microtask, ngay sau khi thao tác hiện tại kết thúc.

Sai lầm thường gặp: I/O starvation (đói I/O)

Nếu một hàm liên tục lập lịch process.nextTick mà không nhường quyền điều khiển (yielding), Node sẽ không bao giờ tiến xa hơn bước "next-tick". Các yêu cầu mạng, đọc tệp và bộ hẹn giờ sẽ bị trì trệ, gây ra sự tăng vọt độ trễ ở phía máy chủ hoặc treo hoàn toàn.

setImmediate so với setTimeout trong thực tế

Cả hai đều lập lịch callback cho lần lặp tiếp theo, nhưng thứ tự tương đối của chúng phụ thuộc vào nơi chúng được gọi:

  • Mã ở cấp cao nhất (Top-level code) – thứ tự không được đảm bảo; nó phụ thuộc vào tốc độ khởi động của tiến trình.
  • Bên trong một callback I/O – thứ tự là xác định: setImmediate chạy trước setTimeout(fn, 0). Sau khi giai đoạn Poll kết thúc, libuv sẽ chuyển sang giai đoạn Check (nơi setImmediate tồn tại) trước khi quay lại giai đoạn Timers cho một timeout có độ trễ bằng 0.

Sự tinh tế này rất quan trọng khi bạn dựa vào trình tự chính xác, chẳng hạn như dọn dẹp một tài nguyên ngay sau khi việc đọc hoàn tất.

So sánh nhanh các điểm khác biệt chính

  • Mục tiêu: Trình duyệt ưu tiên cập nhật hình ảnh; Node ưu tiên sự sẵn sàng của I/O.
  • Điểm móc kết xuất: requestAnimationFrame (chỉ trình duyệt).
  • Điểm móc theo giai đoạn: setImmediate (chỉ Node, chạy trong giai đoạn Check).
  • Hàng đợi ưu tiên cao: process.nextTick (chỉ Node, chạy trước microtasks).
  • Nguy cơ gây nghẽn (starvation): Chuỗi promise dài trong trình duyệt; process.nextTick không giới hạn trong Node.

Những điều cần lưu ý tiếp theo

Nếu bạn duy trì một mã nguồn chạy trong cả hai môi trường (ví dụ: các thư viện isomorphic), hãy kiểm tra bất kỳ nơi nào bạn:

  • Chuỗi nhiều promise mà không nhường quyền điều khiển cho event loop. Hãy chèn await new Promise(r => setTimeout(r, 0)) hoặc sử dụng requestIdleCallback trong trình duyệt để tạo cơ hội cho bộ kết xuất.
  • Sử dụng process.nextTick cho các công việc có thể trì hoãn. Hãy ưu tiên setImmediate hoặc một promise thông thường khi bạn không cần sự khẩn cấp của "next-tick".
  • Giả định rằng setTimeout(fn, 0)setImmediate có thể thay thế cho nhau. Hãy kiểm tra thứ tự bên trong các callback I/O nếu trình tự là quan trọng.

Kết luận rút ra

Event loop là một bộ lập lịch đặc thù cho từng môi trường (host), không phải là một tính năng JavaScript phổ quát. Trình duyệt lồng ghép việc kết xuất (rendering) vào vòng lặp; trong khi Node tách biệt I/O vào các giai đoạn của libuv. Việc lạm dụng các cơ chế ưu tiên—như microtasks trong trình duyệt hay process.nextTick trong Node—có thể gây ra tình trạng "đói" (starvation) cho chính phần hệ thống mà môi trường đó được thiết kế để phục vụ. Hãy điều chỉnh các mô hình bất đồng bộ (async patterns) của bạn sao cho tương thích với mô hình vòng lặp của môi trường host, và bạn sẽ tránh được cả tình trạng đóng băng trang web lẫn tắc nghẽn máy chủ.