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

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.
  • requestAnimationFrame() (trên trình duyệt): Gọi callback trước mỗi khung hình (frame) mới.

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. Quên hiểu cơ chế bất đồng bộ dẫn tới kết quả “kỳ lạ”

Nếu ta tưởng setTimeout(callback, 0) sẽ chạy ngay lập tức và đặt code đằng sau nó với giả định hàm callback đã thực thi, thì kết quả thường sai so với mong đợi. Việc hiểu rõ macro task queue giúp ta hiểu thực tế callback này vẫn phải chờ đến khi Call Stack “rảnh”.

#4.2. Phòng tránh vòng lặp vô hạn

Khi đặt setInterval với khoảng thời gian quá ngắn hoặc logic xử lý phức tạp, chương trình có thể dễ dàng rơi vào vòng lặp vô hạn, khiến giao diện bị “đóng băng”. Cần luôn nhớ dọn dẹp setInterval khi không dùng nữa để giải phóng tài nguyên.

#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. Trên mobile, batch processing giúp giảm thời gian xử lý từ 5 giây xuống còn 1 giây cho 1000 items.

#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
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
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
window.addEventListener('scroll', throttle(() => {
  console.log('Scroll position:', window.scrollY);
}, 200));

Giải thích:

  • Callback chỉ chạy tối đa mỗi 200ms
  • 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é!