← Lập trình JavaScript cơ bản

Bài 8 · Nâng cao · 26 phút· Cập nhật 11/06/2026

Bất đồng bộ

Biên soạn bởi Nguyễn Anh Tuấn

Lập trình bất đồng bộ (asynchronous) trong JavaScript: callback → Promise → async/await, và event loop với microtask/macrotask - vì sao JS không đứng hình.

JavaScript chạy trên một luồng (một call stack): mỗi lúc chỉ làm một việc. Nếu một việc chạy lâu theo kiểu đồng bộ, mọi thứ khác phải chờ - trang web “đứng hình”. Giải pháp: giao tác vụ chậm (mạng, timer, file) cho môi trường lo, và nhận kết quả sau qua callback - không chặn luồng.

đồng bộ (chặn) vs bất đồng bộ (không chặn)

// DONG BO: treo luong cho toi khi xong
console.log("A");
let s = 0;
for (let i = 0; i < 1e9; i++) s += i;   // nang -> moi thu phai cho
console.log("B");

// BAT DONG BO: giao viec cham, khong cho
console.log("C");
setTimeout(() => console.log("D (sau)"), 1000);  // chay o nen
console.log("E");   // in NGAY, khong doi D
  • JS đơn luồng: một call stack, mỗi lúc một việc.
  • Code đồng bộ chặn luồng tới khi xong → có thể làm “đứng hình”.
  • Tác vụ chậm giao cho môi trường + callback khi xong → không chặn.

Callback là hàm bạn truyền vào để được gọi LẠI khi việc hoàn tất. Hợp cho một việc, nhưng lồng nhiều lớp sẽ thành callback hell - khó đọc, khó bắt lỗi.

callback và mớ lồng

function taiDuLieu(xong) {          // "xong" la callback
  setTimeout(() => xong("du lieu"), 500);
}
taiDuLieu((kq) => console.log("nhan:", kq));

// Long nhieu lop -> "callback hell":
buoc1(() => {
  buoc2(() => {
    buoc3(() => console.log("xong het"));
  });
});
  • Callback = hàm gọi-lại khi tác vụ xong.
  • Tốt cho một việc; lồng sâu → khó đọc, khó xử lý lỗi (callback hell).
  • Promise và async/await sinh ra để làm phẳng mớ lồng này.

Promise là object đại diện cho một kết quả sẽ có trong tương lai. Nó đi từ pending (đang chờ) sang fulfilled (thành công) hoặc rejected (lỗi). Dùng .then/.catch, và nối chuỗi thay cho lồng:

tạo & dùng Promise

const p = new Promise((resolve, reject) => {
  setTimeout(() => resolve("OK"), 300);   // xong -> resolve
});

p.then((kq) => console.log(kq))           // "OK"
 .catch((e) => console.log("loi:", e));

// Noi chuoi (thay cho long callback):
taiUser()
  .then((u) => taiBaiViet(u.id))
  .then((bv) => console.log(bv))
  .catch((e) => console.log(e));
  • Ba trạng thái: pending → fulfilled (value) hoặc rejected (lỗi).
  • .then xử lý thành công; .catch bắt lỗi cho cả chuỗi.
  • Mỗi .then trả về một Promise mới → nối chuỗi phẳng, dễ đọc.

async/await là cách viết gọn hơn (syntactic sugar) bọc ngoài Promise. async đánh dấu hàm luôn trả về Promise; await chờ một Promise xong rồi lấy giá trị - đọc tuần tự như code đồng bộ, nhưng vẫn không chặn luồng. Lỗi bắt bằng try/catch:

cùng việc ở Bước 3, viết bằng async/await

async function tai() {
  try {
    const u = await taiUser();           // "cho" promise xong
    const bv = await taiBaiViet(u.id);   // tuan tu, de doc
    console.log(bv);
  } catch (e) {
    console.log("loi:", e);              // bat moi loi trong chuoi
  }
}
tai();
  • async: hàm luôn trả về Promise. await: chờ Promise xong (không chặn luồng).
  • try/catch bắt lỗi y như code đồng bộ.
  • Đọc tuần tự, dễ hơn chuỗi .then dài.

Chạy song song khi có thể

await chỉ dùng được TRONG hàm async (ngoại lệ: cấp ngoài cùng của một module ESM cũng cho phép - gọi là “top-level await”). Nhiều việc ĐỘC LẬP thì đừng await lần lượt (chậm) - gộp bằng Promise.all([...]) để chúng chạy cùng lúc.

Tất cả ráp lại ở event loop. Khi call stack rảnh, nó lấy việc từ hàng đợi để chạy - nhưng theo thứ tự ưu tiên: đồng bộ trước, rồi vét HẾT microtask (Promise.then), cuối cùng mới tới macrotask (setTimeout). Bấm “Bước” để xem thứ tự in ra:

console.log("1")
setTimeout(() => console.log("2"), 0)
Promise.resolve().then(() => console.log("3"))
console.log("4")
Đồng bộ (call stack)
14
Microtask (.then)
3
Macrotask (setTimeout)
2
Thứ tự in ra:

câu đố kinh điển

console.log("1");                                // dong bo
setTimeout(() => console.log("2"), 0);           // macrotask
Promise.resolve().then(() => console.log("3"));  // microtask
console.log("4");                                // dong bo

// In ra: 1, 4, 3, 2

Kết quả khi chạy

1
4
3
2
  • Event loop chạy callback từ hàng đợi khi call stack rảnh.
  • Thứ tự: đồng bộ → vét hết microtask → một macrotask (rồi lặp lại).
  • setTimeout(…, 0) vẫn chạy SAU toàn bộ code đồng bộ và microtask.

Trung thực: đây là mô hình rút gọn

Công cụ trên giả định mọi callback được đặt lịch trong pha đồng bộ (đúng cho câu đố kinh điển). Thực tế, sau MỖI macrotask event loop lại vét microtask, và một macrotask có thể sinh thêm micro/macro. Nhưng quy tắc “đồng bộ → microtask → macrotask” đủ để đoán đúng phần lớn trường hợp.

Tiếp theo

Đã hiểu cách JS xử lý việc chậm. Bài sau - “DOM & sự kiện” - dùng JavaScript điều khiển trang web thật: chọn phần tử, đổi nội dung, và lắng nghe click bằng addEventListener.

Câu hỏi thường gặp

JS chạy MỘT luồng (một call stack). Tác vụ chậm (mạng, timer, đọc file) được giao cho MÔI TRƯỜNG (trình duyệt/Node) xử lý ở nền; khi xong, callback được đưa vào hàng đợi và event loop chạy nó khi call stack rảnh. Nhờ vậy giao diện không “đứng hình”.

Đa luồng & đa tiến trình - khoá JS nâng cao →

Là khi lồng nhiều callback vào nhau (mỗi việc chờ việc trước) khiến code thụt sâu, khó đọc và khó xử lý lỗi. Promise (nối chuỗi) và async/await ra đời để “làm phẳng” mớ lồng đó.

Promise là một object đại diện cho kết quả TƯƠNG LAI. Ba trạng thái: pending (đang chờ) → rồi fulfilled (thành công, có value) hoặc rejected (lỗi). Dùng .then cho thành công, .catch cho lỗi; và nối chuỗi nhiều .then.

async đánh dấu một hàm LUÔN trả về Promise. await “tạm dừng” bên trong hàm async cho tới khi Promise xong rồi lấy giá trị - viết như đồng bộ nhưng KHÔNG chặn luồng. Lỗi bắt bằng try/catch như code thường.

Vì callback của setTimeout là một MACROTASK: nó phải đợi toàn bộ code đồng bộ chạy xong (và microtask vét hết) mới tới lượt. “0ms” chỉ nghĩa là “sớm nhất có thể”, không phải “ngay bây giờ”.

microtask (Promise.then, queueMicrotask) được ƯU TIÊN: sau mỗi mẩu code đồng bộ, event loop VÉT HẾT microtask trước khi lấy MỘT macrotask (setTimeout, sự kiện). Vì vậy Promise.then chạy trước setTimeout dù cùng “0ms”.

Bản chất: event loop & libuv - khoá JS nâng cao →

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

JavaScript đơn luồng vẫn xử lý được nhiều việc nhờ điều gì?

  1. 1

    Chặn luồng

    Viết một vòng lặp nặng (cộng dồn tới 1e9) rồi log sau nó. Code sau có chạy được trong lúc lặp không?

    Hoàn thành khi: Không - code đồng bộ chặn luồng tới khi vòng lặp xong mới log.

  2. 2

    Callback

    In "trước"; setTimeout in "sau" sau 1s; in "cuối" ngay. Thứ tự in ra?

    Hoàn thành khi: "trước", "cuối" in ngay; "sau" in sau ~1s - dù đặt giữa.

  3. 3

    Promise

    Tạo Promise.resolve(42).then(in). Và một Promise.reject("x").catch(in lỗi).

    Hoàn thành khi: then nhận 42; catch bắt được "x" (không làm sập chương trình).

  4. 4

    async/await

    Viết hàm asyncawait một Promise (vd delay 300ms), in trước & sau await; bọc try/catch.

    Hoàn thành khi: Dòng sau await chạy SAU khi promise xong; lỗi rơi vào catch.

  5. 5

    Đoán thứ tự

    Dùng công cụ ở Bước 5: trước khi bấm, đoán 1/2/3/4 in ra theo thứ tự nào.

    Hoàn thành khi: 1, 4, 3, 2 - đồng bộ → microtask → macrotask.

  6. 6

    micro vs macro

    Cùng lúc đặt một Promise.then và một setTimeout(…, 0). Cái nào in trước? Vì sao?

    Hoàn thành khi: Promise.then (microtask) trước setTimeout (macrotask) - micro được vét trước.