Home / Fundamentals / Asynchronous JavaScript Và Macro Task Queue: Hiệu Suất Thực Tế
Fundamentals

Asynchronous JavaScript Và Macro Task Queue: Hiệu Suất Thực Tế

Khám phá cách JavaScript quản lý bất đồng bộ, macro task queue, và setTimeout(0) để viết mã tối ưu, hạn chế lỗi.

Xử lý bất đồng bộ trong JavaScript rất quan trọng, nhưng dễ gây lỗi nếu không hiểu rõ cách hoạt động. Macro Task Queue là chìa khóa để hiểu thứ tự thực thi của setTimeout, setInterval và các tác vụ I/O. Lạm dụng macro tasks không chỉ làm chậm ứng dụng mà còn gây “lag” đáng kể trên mobile.

Bài viết này giúp bạn nắm vững cơ chế macro task, tránh lỗi “treo” ứng dụng và tối ưu hiệu suất trong dự án thực tế.

1. Asynchronous trong JavaScript

JavaScript nổi tiếng với khả năng xử lý bất đồng bộ – đây là nền tảng giúp chúng ta xây dựng các ứng dụng giàu tính tương tác, không bị chậm hay “đóng băng” khi chạy. Nếu JavaScript chỉ chạy ở chế độ đồng bộ (synchronous), bất kỳ thao tác nặng hoặc đợi phản hồi từ máy chủ đều khiến ứng dụng “đứng yên” cho tới khi hoàn thành. Hiểu rõ cơ chế bất đồng bộ cũng như các thành phần liên quan sẽ giúp bạn tránh nhiều lỗi phổ biến trong quá trình phát triển.

2. Macro Task Queue và vòng lặp sự kiện (Event Loop)

2.1. Tầm quan trọng của Macro Task Queue

Trong JavaScript, cơ chế bất đồng bộ xoay quanh Event Loop và hai loại “hàng đợi” chính: Macro Task QueueMicro Task Queue. Mỗi khi bạn gọi setTimeout, setInterval hay thao tác I/O (đọc file, yêu cầu mạng), JavaScript sẽ đưa chúng vào Macro Task Queue.

Event Loop sẽ liên tục kiểm tra Call Stack (nơi các hàm đồng bộ đang thực thi). Chỉ khi Call Stack rỗng, JavaScript mới lấy một nhiệm vụ từ Macro Task Queue và đưa nó vào Call Stack để xử lý. Điều này nghĩa là tất cả code đồng bộ trong Call Stack (bao gồm các hàm, biến, lệnh console.log, v.v.) phải kết thúc trước, rồi callback trong setTimeout hay setInterval mới thực sự được chạy.

Macro Tasks và hiệu năng

Macro tasks (setTimeout, setInterval, I/O) ảnh hưởng trực tiếp đến hiệu năng ứng dụng. Event Loop chỉ xử lý một macro task mỗi lần, nên việc lạm dụng setTimeout(0) hoặc setInterval với interval ngắn có thể làm tắc nghẽn task queue, khiến UI bị lag.

Trong thực tế: Khi xử lý danh sách lớn (1000+ items), đừng tạo 1000 macro tasks riêng lẻ. Sử dụng batch processing để giảm số lượng tasks và tăng hiệu năng.

Để hiểu chi tiết về Micro Task Queue và tại sao Promise ưu tiên hơn setTimeout, xem bài Tại Sao Promise Chạy Trước setTimeout(0)

2.2. Ví dụ đơn giản về Macro Task

1
2
3
4
5
6
7
console.log("Bắt đầu");

setTimeout(() => {
  console.log("Thực thi bên trong setTimeout với 0ms");
}, 0);

console.log("Kết thúc");

Kết quả hiển thị trên console:

1
2
3
Bắt đầu
Kết thúc
Thực thi bên trong setTimeout với 0ms

Ở đây, dù bạn thiết lập setTimeout0ms, callback không chạy ngay tức khắc. Tại sao? Bởi vì JavaScript phải hoàn thành toàn bộ code đồng bộ hiện có trong Call Stack trước (gồm hai lệnh console.log("Bắt đầu")console.log("Kết thúc")). Sau khi Call Stack trống, Event Loop mới lấy nhiệm vụ setTimeout trong Macro Task Queue để thực hiện.

3. Tìm hiểu sâu hơn về setTimeout và các hàm macro task khác

3.1. setTimeout có thật sự là “0 mili-giây” không?

Khi dùng setTimeout(callback, 0), số 0ms biểu thị cho thời gian trễ tối thiểu để callback được xếp vào Macro Task Queue. Thực tế, thời gian chính xác callback này được chạy còn tùy thuộc vào việc Call Stack có đang “bận” hay không, cũng như các nhiệm vụ khác trong hàng đợi.

3.2. Những hàm khác cũng dùng Macro Task Queue

Bên cạnh setTimeout, còn nhiều hàm khác cũng xếp nhiệm vụ vào Macro Task Queue, chẳng hạn:

  • setInterval(): Thực thi callback theo chu kỳ nhất định.
  • Các tác vụ I/O: Trong Node.js, đọc/ghi file hoặc kết nối mạng cũng xếp vào macro task.

Lưu ý: requestAnimationFrame() (trên trình duyệt) thường bị nhầm là macro task, nhưng nó không nằm trong Macro Task Queue. Callback của nó chạy trong bước render của event loop, ngay trước khi trình duyệt vẽ frame mới — vì vậy nó không xếp hàng FIFO cùng setTimeout/setInterval, chạy tối đa một lần mỗi frame, và không chạy khi tab ở chế độ nền.

Ví dụ minh họa với setInterval:

1
2
3
4
5
6
7
8
9
10
11
12
console.log("Bắt đầu");

let count = 0;
const intervalId = setInterval(() => {
  count++;
  console.log(`setInterval callback lần thứ: ${count}`);
  if (count === 3) {
    clearInterval(intervalId);
  }
}, 0);

console.log("Kết thúc");

Kết quả:

1
2
3
4
5
Bắt đầu
Kết thúc
setInterval callback lần thứ: 1
setInterval callback lần thứ: 2
setInterval callback lần thứ: 3

Như bạn thấy, “Bắt đầu” và “Kết thúc” được in trước, rồi callback setInterval mới chạy.

4. Các lỗi thường gặp và tình huống thực tế

4.1. Hiểu sai cơ chế bất đồng bộ khiến kết quả chạy “khó hiểu”

Nhiều người nghĩ setTimeout(callback, 0) chạy ngay tức khắc, nên viết code phía sau với giả định callback đã chạy xong — và kết quả thường sai so với mong đợi. Thực chất, callback này luôn bị đưa vào macro task queue và phải chờ đến khi Call Stack “rảnh” mới được chạy.

4.2. Phòng tránh làm ngập task queue với setInterval

Khi bạn đặt setInterval với khoảng thời gian quá ngắn, hoặc callback xử lý quá nặng, các callback mới bị xếp vào hàng đợi nhanh hơn tốc độ xử lý chúng. Task queue vì thế ngập dần và UI bắt đầu giật lag. Đây không phải “vòng lặp vô hạn” trên Call Stack — mỗi callback vẫn là một task riêng và event loop vẫn chạy bình thường — mà là tình trạng hàng đợi bị dồn ứ. Ngoài ra, một setInterval không được dọn dẹp còn gây lãng phí tài nguyên, nên hãy luôn gọi clearInterval khi không dùng nữa.

4.3. Tối ưu mã trong dự án thực tế

4.3.1. Batch Processing - Xử lý hàng loạt cho danh sách lớn

Khi xử lý nhiều items với setTimeout, đừng tạo macro task riêng cho từng item - điều này làm tắc nghẽn task queue:

Cách không tối ưu:

1
2
3
4
5
6
7
8
9
// Tạo 1000 macro tasks - làm chậm browser
const items = Array.from({ length: 1000 }, (_, i) => i);

items.forEach((item) => {
  setTimeout(() => {
    processItem(item);
  }, 0);
});
// Browser phải xử lý 1000 tasks riêng lẻ!

Cách tối ưu - Batch Processing:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function batchProcess(items, batchSize = 50) {
  let index = 0;

  function processBatch() {
    const end = Math.min(index + batchSize, items.length);

    // Xử lý một batch trong cùng một macro task
    for (; index < end; index++) {
      processItem(items[index]);
    }

    // Nếu còn items, đặt batch tiếp theo vào macro task mới
    if (index < items.length) {
      setTimeout(processBatch, 0);
    }
  }

  processBatch();
}

// Sử dụng
const items = Array.from({ length: 1000 }, (_, i) => i);
batchProcess(items, 50);

Giải thích:

  • Thay vì 1000 macro tasks, chỉ cần 20 tasks (1000 ÷ 50)
  • Mỗi task xử lý 50 items, giảm overhead của Event Loop
  • UI vẫn responsive vì có khoảng nghỉ giữa các batch

Trong thực tế: Kỹ thuật này đặc biệt hiệu quả khi render danh sách lớn, xử lý data import/export, hoặc tính toán phức tạp. Cả hai cách trên đều nhường quyền cho trình duyệt giữa các task nên UI vẫn responsive, nhưng gộp 1000 task nhỏ thành 20 task lớn giúp giảm mạnh overhead lập lịch — thay vì đi qua event loop 1000 lần, ứng dụng chỉ đi 20 lần, nhờ đó xử lý xong nhanh hơn hẳn mà vẫn mượt, đặc biệt trên mobile.

Lưu ý về giới hạn nesting của timer

Vì mỗi batch đặt batch tiếp theo từ bên trong một timer callback, mức lồng (nesting level) của timer tăng dần. Theo đặc tả HTML, từ batch thứ 5 trở đi, delay 0ms sẽ bị nâng lên tối thiểu 4ms — với 20 batches, tổng cộng thêm khoảng 60–80ms. Nếu muốn tránh hoàn toàn giới hạn này, bạn có thể dùng các API hiện đại hơn: scheduler.yield() hoặc scheduler.postTask() (đạt Baseline trên Chromium/Firefox/Safari từ năm 2025), hoặc requestIdleCallback cho công việc nền không gấp.

4.3.2. Debounce và Throttle cho sự kiện liên tục

Khi xử lý sự kiện như scroll, resize, input, việc tạo macro task cho mỗi event có thể làm quá tải:

Debounce - Trì hoãn thực thi:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function debounce(callback, delay) {
  let timeoutId;
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => callback.apply(this, args), delay);
  };
}

// Sử dụng
const searchInput = document.querySelector("#search");
searchInput.addEventListener(
  "input",
  debounce((e) => {
    fetchSearchResults(e.target.value);
  }, 300),
);

Giải thích:

  • Mỗi lần user gõ, timer được reset lại
  • Chỉ gọi API sau khi user ngừng gõ 300ms
  • Giảm API calls từ hàng trăm lần xuống chỉ 1 lần

Throttle - Giới hạn tần suất:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function throttle(callback, limit) {
  let waiting = false;
  return function (...args) {
    if (!waiting) {
      callback.apply(this, args);
      waiting = true;
      setTimeout(() => {
        waiting = false;
      }, limit);
    }
  };
}

// Sử dụng
const onScroll = throttle((position) => {
  console.log("Scroll position:", position);
}, 100);

// Mô phỏng scroll fire liên tục - mỗi event là một macro task riêng
[40, 80, 120, 160, 200, 240].forEach((position, i) => {
  setTimeout(() => onScroll(position), i * 30);
});

// Trong trang thật, bạn gắn nó vào event scroll
window.addEventListener("scroll", () => onScroll(window.scrollY));

Giải thích:

  • Callback chỉ chạy tối đa một lần mỗi limit ms (ở đây là 100ms)
  • 6 event fire liên tục nhưng callback chỉ chạy 2 lần - throttle chặn phần còn lại
  • Phù hợp cho scroll, resize - sự kiện fire liên tục
  • Đảm bảo UI smooth, không bị lag

Trong thực tế:

  • Debounce: Search autocomplete, form validation, window resize
  • Throttle: Scroll tracking, mouse movement, game input

Vậy là bạn đã hiểu cách JavaScript quản lý bất đồng bộ với Macro Task Queue. Trong thực tế, nắm vững batch processing và debounce/throttle giúp tối ưu hiệu suất đáng kể, đặc biệt trên mobile. Hãy áp dụng vào dự án của bạn nhé!

← Previous
Tại Sao Promise Chạy Trước setTimeout(0) trong JavaScript
Table of Contents
  1. 1. Asynchronous trong JavaScript
  2. 2. Macro Task Queue và vòng lặp sự kiện (Event Loop)
    1. 2.1. Tầm quan trọng của Macro Task Queue
    2. 2.2. Ví dụ đơn giản về Macro Task
  3. 3. Tìm hiểu sâu hơn về setTimeout và các hàm macro task khác
    1. 3.1. setTimeout có thật sự là “0 mili-giây” không?
    2. 3.2. Những hàm khác cũng dùng Macro Task Queue
  4. 4. Các lỗi thường gặp và tình huống thực tế
    1. 4.1. Hiểu sai cơ chế bất đồng bộ khiến kết quả chạy “khó hiểu”
    2. 4.2. Phòng tránh làm ngập task queue với setInterval
    3. 4.3. Tối ưu mã trong dự án thực tế