Cơ chế vòng lặp sự kiện JavaScript và các câu hỏi phỏng vấn

JavaScript là ngôn ngữ đơn luồng, nghĩa là chỉ có một luồng thực thi chính. Mọi lệnh đều được xử lý tuần tự. Tuy nhiên, nếu một thao tác mất nhiều thời gian như setTimeout hay gọi Ajax, nó sẽ làm tắc nghẽn các tác vụ tiếp theo, khiến chương trình chạy chậm. Để khắc phục điều này, JavaScript có một cơ chế xử lý đặc biệt với nhiều "kênh" khác nhau.

Đầu tiên là call stack (ngăn xếp lời gọi), nơi xử lý các tác vụ ngắn và nhanh. Những tác vụ tốn thời gian hơn được đưa vào task queue (hàng đợi tác vụ). Task queue lại chia thành macro-taskmicro-task:

  • Micro-task: chứa các tác vụ như promise.then, async/await.
  • Macro-task: chứa các tác vụ như setTimeout, Ajax, sự kiện click.

Khi call stack rỗng, JavaScript sẽ kiểm tra micro-task queue trước, xử lý hết chúng, sau đó mới chuyển sang macro-task queue.

Về cấu trúc dữ liệu: Stack (ngăn xếp) hoạt động theo nguyên tắc LIFO (Last In, First Out) – vào sau ra trước. Queue (hàng đợi) hoạt động theo FIFO (First In, First Out) – vào trước ra trước.

Hãy xem xét một ví dụ đơn giản để hiểu thứ tự thực thi:

new Promise(resolve => {
  console.log('promise');
  resolve(5);
}).then(value => {
  console.log('then callback', value);
});

function func1() {
  console.log('func1');
}

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

func1();

Phân tích:

  • Lời gọi new Promise – hàm executor chạy ngay lập tức, in ra promise.
  • .then() được đưa vào micro-task queue.
  • func1 chưa được gọi, nên chưa vào stack.
  • setTimeout đưa callback vào macro-task queue.
  • Gọi func1() – hàm chạy và in ra func1.

Sau khi stack rỗng: micro-task queue in then callback 5, sau đó macro-task in setTimeout.

Kết quả: promisefunc1then callback 5setTimeout.

Ví dụ phức tạp hơn:

setTimeout(function () {
  console.log("set1");
  new Promise(function (resolve) {
    resolve();
  }).then(function () {
    new Promise(function (resolve) {
      resolve();
    }).then(function () {
      console.log("then4");
    });
    console.log("then2");
  });
});

new Promise(function (resolve) {
  console.log("pr1");
  resolve();
}).then(function () {
  console.log("then1");
});

setTimeout(function () {
  console.log("set2");
});

console.log(2);

queueMicrotask(() => {
  console.log("queueMicrotask1");
});

new Promise(function (resolve) {
  resolve();
}).then(function () {
  console.log("then3");
});

Phân tích từng bước:

  1. setTimeout("set1") → macro-task queue.
  2. Promise(pr1) chạy executor, in pr1. .then(then1) → micro-task queue.
  3. setTimeout("set2") → macro-task queue (sau set1).
  4. In 2 (từ call stack).
  5. queueMicrotask → micro-task queue (tương tự promise.then).
  6. Promise(then3) tương tự, .then(then3) → micro-task queue.

Sau khi stack rỗng, thực thi tuần tự micro-task queue: then1queueMicrotask1then3.

Tiếp đến macro-task: Lấy set1, chạy callback: in set1Promise(then inside set1).then(then2).then(then4) (đây lại là micro-task mới).

Kết quả cuối cùng:

pr1
2
then1
queueMicrotask1
then3
set1
then2
then4
set2

Ví dụ cuối với async/await:

async function async1 () {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}

async function async2 () {
  console.log('async2');
}

console.log('script start');

setTimeout(function () {
  console.log('setTimeout');
}, 0);

async1();

new Promise(function (resolve) {
  console.log('promise1');
  resolve();
}).then(function () {
  console.log('promise2');
});

console.log('script end');

Phân tích:

  • In script start.
  • setTimeout → macro-task queue.
  • Gọi async1(): in async1 start, gọi async2() (in async2), gặp await → đưa async1 end vào micro-task queue.
  • Promise(promise1) chạy executor, in promise1, .then(promise2) → micro-task queue.
  • In script end.

Sau stack: micro-task chạy async1 end rồi promise2. Cuối cùng macro-task in setTimeout.

Kết quả:

script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

Tổng kết các quy tắc quan trọng:

  • Hàm executor của Promise chạy ngay lập tức trong call stack, còn .then() được đưa vào micro-task queue.
  • Trong hàm async, code trước await chạy đồng bộ; code ngay sau await được chuyển thành micro-task.
  • Call stack rỗng → kiểm tra micro-task queue (xử lý hết) → sau đó mới lấy macro-task.

Thẻ: JavaScript event loop call stack micro-task macro-task

Đăng vào ngày 4 tháng 7 lúc 22:33