Bài coroutine nói unit có thể suspend / resume. Còn thiếu một câu: ai là người bấm resume?

Trên Node, browser, asyncio (và họ hàng): hầu hết lúc đó là event loop — một vòng lặp trên một thread (JS main thread / loop thread) quyết định “tiếp theo chạy đoạn code nào”. Không phải OS tự cắt async function như cắt thread.

Coroutine / async task  =  việc dang dở (có state)
Event loop              =  điều phối viên: khi nào chạy tiếp việc nào
OS thread               =  chỗ loop đang ngồi (thường 1 cho JS)

Demo (1 loop, 3 handler — await vs sync block): event loop · resume vs kẹt.

Hình dung: 1 bếp trưởng, nhiều phiếu order

Loop ≈ một đầu bếp. Mỗi request/handler là phiếu:

  • Đang làm (ôm bếp) — chỉ một phiếu tại một thời điểm trên thread đó.
  • Chờ lò / chờ shipper (I/O) — phiếu ghim “khi chuông reo thì làm tiếp”, bếp đi phiếu khác.
  • Chuông reo — không nhảy vào giữa nhát dao; xếp hàng đợi, bếp xong việc hiện tại (và hết microtask) mới lấy.

Đó là dealing with trên 1 core: nhiều phiếu mở, không phải nhiều đầu bếp (doing parallel).

Ba chỗ việc sống

1) Call stack     — đang chạy đồng bộ (ôm loop)
2) Hàng đợi task  — timer, I/O xong, message… (macrotask / phase)
3) Microtask      — Promise reaction, queueMicrotask (JS)
                    · asyncio: ready callbacks tương tự “chạy hết trước khi sleep”

Mô hình tinh thần (JS; Node có thêm phase libuv nhưng ý giống):

while (còn việc) {
  // A. Lấy 1 macrotask (timer / I/O callback / …)
  chạy đồng bộ đến hết stack

  // B. Drain microtask: trong khi còn microtask
  //    chạy hết — kể cả microtask mới sinh ra lúc này
  //    → trước khi macrotask / render tiếp theo

  // C. (browser) có thể render; (Node) phase khác: check, close…
}

Hệ quả hay quên: microtask có thể “đói” macrotask nếu bạn liên tục queueMicrotask / chain Promise không nhường. Timer 0ms không chạy xen khi microtask còn đầy.

await làm gì với loop?

async function handle() {
  const a = 1 + 1           // ① stack, ôm loop
  const row = await db.get() // ②
  return row.id             // ③
}
  1. Chạy sync trên stack — không ai chen ngang (cooperative).
  2. await một Promise chưa settle:
    • function suspend (continuation = “chạy từ dòng sau await”),
    • nhả stack / nhả loop — loop đi macrotask hoặc microtask khác,
    • khi db.get xong: runtime xếp continuation (thường qua microtask khi Promise fulfill) — không cướp loop giữa chừng đoạn sync khác.
  3. Chỉ chạy sau khi loop chọn resume continuation đó.

Vậy “ai resume sau await?” = event loop (sau khi Promise settle + đến lượt queue), không phải thread pool tự động (trừ khi library offload bên dưới).

Nối non-blocking: await chỉ “non-block loop” nếu lúc chờ, loop thật sự rảnh để deal việc khác — tức I/O không ôm thread JS.

Ai bị kẹt khi sync?

Mọi thứ trên cùng loop serialize trên 1 stack. Trong lúc bạn:

// 1) CPU nặng trên main
for (let i = 0; i < 1e9; i++) crunch(i)

// 2) I/O sync
fs.readFileSync('big')  // Node: block thread loop

// 3) JSON.parse multi-MB, crypto sync, regex thảm họa

thì:

  • Không chạy handler khác.
  • Không resume await đã ready (data về rồi vẫn xếp hàng).
  • Timer trễ; WebSocket/heartbeat trễ; health check chết.
  • “Concurrent trên giấy” — serial trên thực tế. Giống blocking cả deal.

Demo cột phải cố ý ôm loop vài tick: các resume khác đứng chờ dù I/O đã xong.

Microtask vs macrotask — ví dụ một dòng

setTimeout(() => console.log('timer'), 0)
Promise.resolve().then(() => console.log('micro'))
console.log('sync')

// In:  sync  →  micro  →  timer
// Vì: hết stack → drain microtask → mới macrotask timer

await Promise đã resolved vẫn lịch continuation microtask (nhường một nhịp) — không chạy ngay như hàm sync. Đó là chỗ người ta nhầm “await luôn tiếp liền không qua loop”.

Node: libuv, không chỉ “một queue”

Node loop (đơn giản hóa): timers → pending → idle/prepare → poll (I/O) → check → close; giữa các phase vẫn đụng microtask/nextTick. fs.readFile non-sync thường worker pool libuv; callback “I/O xong” vào phase phù hợp rồi mới chạy JS.

Bạn hiếm khi cần thuộc hết phase. Cần thuộc: JS của bạn vẫn một thread; block thread đó = block mọi callback/async continuation.

Offload — khi việc không được ôm loop

  • CPU: worker_threads, process pool, job queue riêng — xem hướng thread pool / offloadparallel doing.
  • Đừng “fake async”: await Promise.resolve(heavySync()) vẫn chạy heavy trước khi await nhường — loop vẫn kẹt đúng đoạn sync.
  • Đếm việc bay: in-flight inventory — loop delay / event loop lag là metric proxy “nợ” trên 1 thread.

Checklist debug “async mà chậm / đơ”

  1. CPU một core 100%, event loop delay tăng? → sync CPU trên loop.
  2. *Sync, parse lớn, crypto sync trong request path?
  3. Promise/microtask loop vô hạn? (hiếm hơn block CPU nhưng đói timer)
  4. Dependency chậm nhưng loop rảnh? → không phải kẹt loop; là I/O / pool — connection pool, timeout budget.
  5. Cần hủy khi client cút? Signal xuống I/O — cancellation — loop resume path lỗi abort, đừng để query mồ côi.

Đọc tiếp

Một câu để nhớ

Event loop = điều phối viên resume trên một thread.
await xếp “làm tiếp sau”; sync ôm loop = không ai được resume.

Async/await không tạo parallel. Nó chỉ cho phép nhiều việc dang dở trong lúc một đoạn sync chạy. Giữ đoạn đó ngắn — hoặc offload — là giữ concurrency sống.