Skip to main content
JavaScript 7 mins read Devs2.org

JavaScript Event Loop: Cơ Chế Bất Đồng Bộ và Vòng Lặp Sự Kiện Trong JavaScript

Khám phá JavaScript Event Loop — cơ chế giúp JavaScript xử lý bất đồng bộ dù là single-threaded. Tìm hiểu Call Stack, Web APIs, Microtask Queue, Macrotask Queue và cách chúng phối hợp với nhau.

#JavaScript #Event Loop #Async #Web Development #Frontend #JavaScript Internals

Minh họa JavaScript Event Loop — Call Stack, Web APIs, Microtask và Macrotask Queue

Bạn đã bao giờ tự hỏi: “Tại sao setTimeout(fn, 0) không chạy ngay lập tức?” hoặc “Sao Promise lại chạy trước setTimeout dù timeout = 0?”

Câu trả lời nằm ở JavaScript Event Loop — cơ chế cốt lõi giúp JavaScript, một ngôn ngữ single-threaded, vẫn có thể xử lý hàng tá tác vụ bất đồng bộ (API calls, timer, user interactions) một cách mượt mà.

Trong bài viết này, chúng ta sẽ mổ xẻ Event Loop từ A đến Z — từ Call Stack, Web APIs, đến Microtask và Macrotask Queue — với vô vàn ví dụ thực tế.

JavaScript là Single-threaded — Vậy thì sao?

JavaScript chạy trên một luồng duy nhất (single thread). Nói cách khác, tại bất kỳ thời điểm nào, chỉ có một đoạn code được thực thi.

console.log('Bắt đầu');
console.log('Giữa');
console.log('Kết thúc');
// Output: Bắt đầu → Giữa → Kết thúc

Điều này nghe có vẻ hạn chế, nhưng thực ra là một thiết kế tuyệt vời:

  • Không race conditions — Không cần lock, mutex, semaphore phức tạp
  • Đơn giản cho lập trình viên — Không phải lo về đồng bộ dữ liệu giữa các thread
  • Tương tác người dùng mượt — DOM manipulation an toàn vì chỉ một thread

Nhưng nếu chỉ có một thread, làm sao JavaScript xử lý được hàng loạt tác vụ cùng lúc — như fetch API, setTimeout, đọc file, xử lý sự kiện click?

Câu trả lời: Event Loop + Web APIs (trong browser) hoặc libuv (trong Node.js).

Cấu Trúc Của Event Loop

Event Loop không phải là một “vật” cụ thể — nó là một cơ chế phối hợp giữa các thành phần sau:

1. Call Stack (Ngăn xếp lời gọi)

Khi bạn gọi một hàm, nó được push vào Call Stack. Khi hàm kết thúc, nó được pop ra.

function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

console.log(square(5));
// Call Stack: 
// 1. Push console.log
// 2. Push square(5)
// 3. Push multiply(5, 5)
// 4. Pop multiply → trả về 25
// 5. Pop square → trả về 25
// 6. Pop console.log → in ra 25

Stack Overflow xảy ra khi Call Stack bị đầy — thường là do đệ quy vô hạn:

function infinite() {
  return infinite();
}
infinite(); // ❌ RangeError: Maximum call stack size exceeded

2. Web APIs (Browser APIs)

Đây là các API do browser cung cấp, không phải của JavaScript engine:

  • setTimeout / setInterval
  • fetch (AJAX)
  • DOM Events (click, keydown, …)
  • console
  • localStorage

Khi bạn gọi setTimeout(callback, 1000), JavaScript không đợi 1 giây trong Call Stack. Nó gửi callback sang Web APIs, và timer được browser quản lý ở một thread riêng.

3. Callback Queue (Macrotask Queue)

Khi một Web API hoàn thành (ví dụ: setTimeout hết 1 giây), nó không đưa callback ngay vào Call Stack (vì Call Stack có thể đang bận). Thay vào đó, callback được đưa vào Callback Queue (hay còn gọi là Macrotask Queue).

4. Microtask Queue (Job Queue)

Đây là queue có ưu tiên cao hơn Macrotask Queue. Các tác vụ sau được đưa vào Microtask Queue:

  • Promise.then(), Promise.catch(), Promise.finally()
  • queueMicrotask()
  • MutationObserver (trong browser)
  • process.nextTick() (trong Node.js — ưu tiên cao nhất)

5. Event Loop

Event Loop là vòng lặp vô tận kiểm tra:

while (true) {
  // 1. Xử lý hết tất cả synchronous code trong Call Stack
  // 2. Khi Call Stack rỗng:
  //    a. Dọn sạch TOÀN BỘ Microtask Queue
  //    b. Lấy MỘT macrotask từ Macrotask Queue
  //    c. Quay lại bước 1
  // (Trong browser: UI rendering xảy ra giữa các macrotask)
}

Quy Tắc Ưu Tiên (Priority Rules)

Đây là quy tắc quan trọng nhất cần nhớ:

  1. Synchronous code trong Call Stack luôn chạy trước
  2. Microtasks (Promise.then, queueMicrotask) — xử lý hết toàn bộ trước khi chuyển sang macrotask
  3. Macrotasks (setTimeout, setInterval, I/O) — xử lý một cái mỗi lần lặp
  4. UI Rendering (trong browser) — xảy ra sau khi microtask queue trống, trước macrotask tiếp theo

Ví Dụ Thực Tế

Ví dụ 1: Cơ bản nhất

console.log('1');

setTimeout(() => {
  console.log('2');
}, 0);

console.log('3');
// Output: 1 → 3 → 2

Giải thích từng bước:

  1. console.log('1') → push vào Call Stack, in ra 1, pop ra
  2. setTimeout(cb, 0) → push vào Call Stack, gửi callback sang Web APIs (timer 0ms), pop ra
  3. console.log('3') → push vào Call Stack, in ra 3, pop ra
  4. Call Stack rỗng → Event Loop kiểm tra Microtask Queue (rỗng)
  5. Event Loop lấy callback từ Macrotask Queue → in ra 2

Ví dụ 2: Promise vs setTimeout

console.log('1');

setTimeout(() => {
  console.log('2');
}, 0);

Promise.resolve().then(() => {
  console.log('3');
});

console.log('4');
// Output: 1 → 4 → 3 → 2

Giải thích:

  1. console.log('1') → in 1
  2. setTimeout(cb, 0) → gửi callback vào Web APIs
  3. Promise.resolve().then(cb) → callback được đưa vào Microtask Queue
  4. console.log('4') → in 4
  5. Call Stack rỗng → Event Loop dọn sạch Microtask Queue → in 3
  6. Event Loop lấy một macrotask từ Macrotask Queue → in 2

Đây là lý do Promise luôn chạy trước setTimeout!

Ví dụ 3: Nested Microtasks

console.log('1');

setTimeout(() => {
  console.log('2');
  Promise.resolve().then(() => {
    console.log('3');
  });
}, 0);

Promise.resolve().then(() => {
  console.log('4');
  setTimeout(() => {
    console.log('5');
  }, 0);
});

console.log('6');
// Output: 1 → 6 → 4 → 2 → 3 → 5

Giải thích từng bước:

BướcCall StackMicrotask QueueMacrotask QueueOutput
1console.log(1)1
2setTimeouttimeout cb
3Promise.thenpromise cb1timeout cb
4console.log(6)promise cb1timeout cb6
5(rỗng)promise cb1timeout cb
6Chạy promise cb1 → console.log(4) + setTimeout4
7setTimeout (trong promise cb1)timeout cb1, timeout cb2
8(rỗng)(rỗng)timeout cb1, timeout cb2
9Chạy timeout cb1 → console.log(2) + Promise.thentimeout cb22
10Promise.then (trong timeout cb1)promise cb2timeout cb2
11(rỗng)promise cb2timeout cb2
12Chạy promise cb2 → console.log(3)timeout cb23
13(rỗng)(rỗng)timeout cb2
14Chạy timeout cb2 → console.log(5)5

Ví dụ 4: Event Loop trong DOM Events

button.addEventListener('click', () => {
  console.log('Click handler 1');
  
  Promise.resolve().then(() => {
    console.log('Microtask từ click');
  });
});

button.addEventListener('click', () => {
  console.log('Click handler 2');
});

// Khi người dùng click vào button:
// Output: Click handler 1 → Click handler 2 → Microtask từ click

Khi người dùng click, browser đưa cả hai event handlers vào Macrotask Queue như một shared macrotask (nếu cùng một sự kiện). Sau khi handler 1 chạy, microtask queue được dọn sạch trước khi handler 2 chạy?

Thực tế: Trong môi trường browser, sau khi click:

  1. Cả hai click handlers được đưa vào cùng một macrotask
  2. Handler 1 chạy → đưa Promise.then vào Microtask Queue
  3. Handler 2 chạy tiếp (vì vẫn còn trong cùng macrotask)
  4. Call Stack rỗng → dọn Microtask Queue → in “Microtask từ click”

Ví dụ 5: Async/Await và Event Loop

async function test() {
  console.log('1');
  await Promise.resolve('2');
  console.log('3'); // Phần này chạy như microtask
}

test();
console.log('4');
// Output: 1 → 4 → 3

Giải thích:

  1. test() được gọi → push vào Call Stack
  2. console.log('1') → in 1
  3. await Promise.resolve('2') → function tạm dừng, trả về Promise, phần còn lại (console.log(‘3’)) được đưa vào Microtask Queue
  4. console.log('4') → in 4
  5. Call Stack rỗng → dọn Microtask Queue → in 3
async function test() {
  console.log('A');
  await null; // await bất kỳ giá trị nào
  console.log('B');
}

test();
setTimeout(() => console.log('C'), 0);
Promise.resolve().then(() => console.log('D'));
console.log('E');
// Output: A → E → B → D → C
// (B và D đều là microtask, B được queue trước nên chạy trước)

Pattern Nâng Cao

Pattern 1: Chunking — Chia nhỏ tác vụ nặng

Khi bạn có một vòng lặp xử lý hàng triệu item, nó sẽ block Event Loop và làm page bị đơ:

// ❌ BAD: Block Event Loop ~5 giây
const items = Array.from({ length: 10_000_000 }, (_, i) => i);
items.forEach(item => {
  heavyProcessing(item); // Block!
});

Giải pháp: Chia nhỏ thành từng chunk, dùng setTimeout để nhường cho Event Loop:

// ✅ GOOD: Chunking với setTimeout
function processChunk(items, index, chunkSize = 1000) {
  const end = Math.min(index + chunkSize, items.length);
  
  for (let i = index; i < end; i++) {
    heavyProcessing(items[i]);
  }
  
  if (end < items.length) {
    // Nhường cho Event Loop xử lý UI, events, v.v.
    setTimeout(() => processChunk(items, end, chunkSize), 0);
  } else {
    console.log('✅ Hoàn thành xử lý tất cả items!');
  }
}

processChunk(items, 0);

Pattern 2: queueMicrotask cho ưu tiên cao

function saveData(data) {
  // Lưu ngay lập tức
  localStorage.setItem('data', JSON.stringify(data));
  
  // Đồng bộ lên server — không block, ưu tiên cao
  queueMicrotask(async () => {
    try {
      await fetch('/api/save', { method: 'POST', body: JSON.stringify(data) });
    } catch (e) {
      console.warn('Sync server thất bại, dữ liệu vẫn an toàn trong localStorage');
    }
  });
}

Pattern 3: requestAnimationFrame cho Animation

// ❌ BAD: Dùng setTimeout cho animation
function animateBad() {
  element.style.transform = `translateX(${pos}px)`;
  pos++;
  setTimeout(animateBad, 16); // ~60fps
}

// ✅ GOOD: Dùng requestAnimationFrame
function animateGood() {
  element.style.transform = `translateX(${pos}px)`;
  pos++;
  requestAnimationFrame(animateGood); // Đồng bộ với màn hình
}

requestAnimationFrame là một macrotask đặc biệt — nó chạy trước khi browser render frame tiếp theo, đảm bảo animation mượt mà.

Event Loop trong Node.js

Node.js cũng có Event Loop, nhưng có một số khác biệt:

  1. Không có Web APIs — Thay vào đó là libuv (C library)
  2. Các phase (giai đoạn): timers → I/O callbacks → idle/prepare → poll → check (setImmediate) → close callbacks
  3. process.nextTick() — ưu tiên cao hơn cả Microtask Queue (Promise.then)
// Node.js specific
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
// Output: nextTick → Promise

Quy tắc trong Node.js:

nextTick → Promise (Microtask) → timers (setTimeout) → I/O → setImmediate → close

Lỗi Thường Gặp Và Cách Tránh

1. “setTimeout(fn, 0) không chạy ngay” 🚫

// Nhầm tưởng: setTimeout(fn, 0) chạy ngay sau synchronous code
setTimeout(() => {
  console.log('setTimeout');
}, 0);

// Thực tế: Nó chỉ chạy SAU KHI microtask queue trống
Promise.resolve().then(() => {
  console.log('Promise'); // Chạy TRƯỚC setTimeout
});

2. Block Event Loop với vòng lặp nặng 🚫

// ❌ Sẽ làm page đơ (freeze)
while (true) {
  // Không bao giờ kết thúc → Event Loop không bao giờ chạy
}

Giải pháp: Luôn dùng async patterns hoặc Web Workers cho tác vụ nặng.

3. Quên xử lý lỗi Promise 🚫

// ❌ Unhandled Promise Rejection
fetch('/api/data').then(data => {
  // Nếu fetch thất bại, không ai catch lỗi
});

// ✅ Luôn có catch
fetch('/api/data')
  .then(data => console.log(data))
  .catch(err => console.error('Lỗi:', err));

4. Starvation (Bỏ đói) Macrotask Queue 🚫

function microtaskHell() {
  Promise.resolve().then(() => {
    // Microtask này lại tạo thêm microtask khác
    microtaskHell();
  });
}

microtaskHell();
setTimeout(() => {
  console.log('Không bao giờ chạy!');
}, 1000);
// ❌ Macrotask (setTimeout) không bao giờ được xử lý
// vì Microtask Queue không bao giờ trống

So Sánh Các Async Patterns

PatternQueueƯu tiênDùng khi
Synchronous codeCall Stack🔴 Cao nhấtCode thông thường
Promise.then()Microtask🟠 CaoSau async operation
queueMicrotask()Microtask🟠 CaoCần chạy ngay sau sync code
MutationObserverMicrotask🟠 CaoTheo dõi DOM changes
setTimeout(fn, 0)Macrotask🟡 ThấpChia nhỏ tác vụ, defer work
setIntervalMacrotask🟡 ThấpTác vụ lặp định kỳ
I/O (fetch, file)Macrotask🟡 ThấpNetwork/File operations
requestAnimationFrameRender🟢 Trước renderAnimation, UI updates
postMessageMacrotask🟡 ThấpCross-window communication
process.nextTick() (Node)nextTick🔴 Cao nhất (Node)Before next event loop phase

Kết Luận

JavaScript Event Loop là trái tim của toàn bộ hệ thống bất đồng bộ trong JavaScript. Hiểu rõ nó giúp bạn:

  • Debug dễ dàng hơn — Biết chính xác thứ tự thực thi code
  • Tránh blocking — Không làm page đơ với tác vụ nặng
  • Tối ưu hiệu năng — Biết khi nào dùng microtask vs macrotask
  • Viết code đúng — Không bị ngạc nhiên bởi thứ tự output
  • Phỏng vấn tự tin — Event Loop là câu hỏi kinh điển trong phỏng vấn JavaScript

Nguyên tắc vàng cần nhớ:

Synchronous chạy trước → Microtask (Promise) chạy giữa → Macrotask (setTimeout) chạy cuối

Hãy thử nghiệm với các đoạn code trong bài viết này bằng cách mở Console trong DevTools (F12) và chạy thử. Cách tốt nhất để hiểu Event Loop là tự tay viết code và quan sát output!

Bạn muốn tìm hiểu sâu hơn về một khía cạnh cụ thể nào của Event Loop không? Hãy để lại bình luận bên dưới! 🚀

Recently Used Tools