DEV Community

Async — Macrotask Queue

Macrotask queue: timer không chính xác và timer drift

Macrotask là các đơn vị công việc mà event loop chạy một cái mỗi vòng: callback của setTimeout, setInterval, setImmediate, và I/O. Khác với microtask được vét cạn, macrotask xếp hàng và chờ tới lượt. Hệ quả thực tế quan trọng nhất là timer trong JavaScript không bao giờ chính xácsetTimeout(fn, 1000) nghĩa là "chạy fn sớm nhất sau 1000ms", không phải "đúng 1000ms". Độ trễ thật phụ thuộc event loop đang bận gì. Hiểu điều này tránh được bug timer drift tích lũy và việc tin nhầm vào độ chính xác của setInterval.

Cơ chế hoạt động

Khi gọi setTimeout(fn, delay), fn không được chạy sau đúng delay. Nó được xếp vào macrotask queue khi delay trôi qua, rồi chờ event loop rảnh để chạy. Nếu event loop đang bận (code đồng bộ nặng, hoặc đang vét microtask), callback bị hoãn thêm.

const start = Date.now()
setTimeout(() => console.log('trễ thực tế:', Date.now() - start), 100)
// một việc đồng bộ nặng chiếm 500ms ngay sau đó
heavySyncWork()
// callback in ra ~500ms, không phải 100ms — nó phải đợi heavySyncWork xong
Enter fullscreen mode Exit fullscreen mode

delay chỉ là độ trễ tối thiểu. Ngoài ra trình duyệt còn ép setTimeout lồng nhau sâu xuống tối thiểu 4ms, và tab nền bị throttle mạnh hơn nữa.

Vấn đề gặp trong production

Failure mode: timer drift với setInterval. setInterval(fn, 1000) không đảm bảo fn chạy mỗi giây một lần. Nếu một lần fn chạy lâu, hoặc event loop bận, các lần sau bị dồn và lệch dần. Tệ hơn, với việc async bên trong, setInterval không chờ lần trước xong đã kích lần sau, gây chồng lấn:

setInterval(async () => {
  await syncToRemote() // nếu việc này lâu hơn 1s, các lần gọi chồng lên nhau
}, 1000)
Enter fullscreen mode Exit fullscreen mode

Khi syncToRemote thỉnh thoảng mất 3s, setInterval vẫn bắn mỗi giây, dẫn tới nhiều lần sync chạy song song, đua nhau ghi, và tải dồn lên hệ thống đích. Pattern đúng cho việc lặp async là tự lên lịch lại sau khi xong bằng setTimeout, không dùng setInterval:

async function tick() {
  try { await syncToRemote() }
  finally { setTimeout(tick, 1000) } // chỉ hẹn lần sau SAU khi lần này xong
}
tick()
Enter fullscreen mode Exit fullscreen mode

Failure mode: dựa vào timer để đo thời gian thật. Dùng tổng các delay của setTimeout để tính thời gian đã trôi qua là sai vì drift tích lũy. Cần thời gian thật thì đọc Date.now()/performance.now() tại thời điểm chạy, đừng cộng dồn delay danh nghĩa.

Cách debug và monitor

Khi một việc định kỳ "thỉnh thoảng chạy trễ" hoặc chạy chồng lên nhau, kiểm tra ngay có dùng setInterval cho việc async không — đó gần như luôn là nguyên nhân. Log khoảng cách thực tế giữa hai lần chạy (Date.now() lần này trừ lần trước) thay vì tin vào delay cấu hình; khoảng cách lớn hơn delay đều đặn nghĩa là event loop đang bị chiếm. Theo dõi event loop lag: timer drift và lag thường đi cùng nhau. Với việc cần độ chính xác cao (animation), dùng requestAnimationFrame thay vì setTimeout; với scheduling phía server cần đúng giờ, đừng dựa vào setInterval mà dùng một scheduler có bù drift hoặc một hệ cron ngoài.

Tradeoff

Macrotask (setTimeout/setImmediate) luôn nhường lại event loop mỗi vòng nên an toàn cho việc lặp dài và không gây starvation như microtask — đổi lại độ trễ cao hơn và không chính xác về thời điểm. setInterval tiện vì khai báo một lần chạy mãi, nhưng đánh đổi là không chờ việc trước xong và drift tích lũy, nên không hợp cho việc async hoặc cần đúng nhịp. Quy tắc thực tế: việc định kỳ async → đệ quy setTimeout tự lên lịch lại sau khi xong; cần độ chính xác → đo bằng performance.now() chứ không tin delay; cần đúng giờ tuyệt đối → scheduler ngoài, vì timer của event loop chỉ là "sớm nhất là".

Câu hỏi phỏng vấn

setTimeout(fn, 0) có chạy fn ngay không, và vì sao setInterval không nên dùng cho việc async định kỳ?

setTimeout(fn, 0) không chạy ngay: nó xếp fn vào macrotask queue, và fn chỉ chạy sau khi toàn bộ code đồng bộ hiện tại và toàn bộ microtask đã xong, rồi tới lượt trong vòng event loop kế tiếp — 0 chỉ là độ trễ tối thiểu danh nghĩa, độ trễ thật phụ thuộc event loop đang bận gì. setInterval không nên dùng cho việc async định kỳ vì nó kích theo nhịp cố định mà không chờ lần trước hoàn thành: nếu một lần chạy lâu hơn interval, các lần gọi chồng lên nhau (nhiều việc song song đua ghi, dồn tải), và drift tích lũy. Điểm ăn điểm là pattern thay thế: hàm async tự gọi setTimeout(tick, delay) trong finally sau khi mỗi lần xong, để không bao giờ chồng lấn; và đo thời gian thật bằng performance.now() chứ không cộng dồn delay.

Hands-on

Dựng một job đồng bộ định kỳ bằng setInterval rồi cố tình cho mỗi lần chạy lâu hơn interval (ví dụ gọi một API chậm), quan sát các lần gọi chồng lên nhau bằng cách log id và thời điểm bắt đầu/kết thúc. Viết lại bằng pattern đệ quy setTimeout tự lên lịch trong finally, xác nhận không còn chồng lấn dù việc lâu hơn interval. Sau đó đo drift: chạy một interval danh nghĩa 1000ms trong khi thỉnh thoảng chèn việc đồng bộ nặng, log khoảng cách thực tế giữa các lần, và so sánh với cách tính bằng performance.now() để thấy vì sao không được tin vào delay cấu hình.

Top comments (0)