"Node 의 모든 async 가 두 큐 중 하나에 살아. 어느 건지 알면 순서 예측, starvation 디버그, `await` 가 '충분히 yield 안 한다' 는 놀람 멈출 수 있어."
두 큐, 한 루프
Node event loop 가 두 종류의 스케줄된 작업 사이 엄격한 분리 유지:
- Macrotask (별칭 task) — event loop 의 여섯 단계. 각 단계가 자기 큐 처리: Timers 의
setTimeout/setIntervalcallback, Poll 의 I/O callback, Check 의setImmediatecallback, Close 의 close handler. - Microtask — 각 macrotask *사이* AND 각 단계 사이에 비워지는 별도 큐.
queueMicrotask(fn),Promise.thenhandler,awaitcontinuation 다 여기. 거기에process.nextTick위한 sub-queue, Promise microtask 큐 *전에* 비워져.
Drain 규칙
Promise.resolve().then(fn) 이 항상 먼저 돌아 — microqueue 가 먼저 비워져.함정: microtask 가 재귀적으로 더 많은 microtask 스케줄하면 루프가 microqueue 너머로 절대 진행 안 함. 서버가 응답 멈춰. 이게 microtask starvation; Lesson 3 의 nextTick starvation 의 async-await 버전.
실제 순서
Node 의 단일 동기 코드 블록에 대한 스케줄:
- 동기 코드 자체.
- 모든
process.nextTickcallback (FIFO). - 모든 Promise microtask —
queueMicrotask,.then,awaitresume (FIFO). - 다음 event loop 단계에서 macrotask 하나 (만료된 timer 있으면 Timers, 아니면 Poll/Check 등).
- GOTO 2 (다음 macrotask 전 microqueue 다시 비워짐).
Step 5 가 결정적 디테일. 대부분 개발자가 "단계, 단계, 단계, 어딘가 microtask" 라고 생각해. 실제로는 "단계, microtask drain, 단계, microtask drain..." — microtask 가 단계보다 훨씬 자주 돌아.
await 가 동기적으로 느껴지는 이유
await 의 continuation 이 microtask 라서. const x = await something() 줄 후, 함수 나머지가 microqueue 로 스케줄. 런타임이 바로 다음으로 하는 게 (현재 동기 블록과 이전 microtask 후) 네 함수 재개. "다음 tick 까지 기다림" 없음 — "microqueue 비워질 때까지 기다림", 보통 마이크로초 단위.
결정적: await 뒤에 더 많은 동기 작업 따라오는 동기 블록은 I/O 한테 yield 안 해. await-후 동기 작업은 microqueue 에서 돌고, microqueue 가 다음 단계 막아. await 후 CPU 무거운 블록 있으면 여전히 event loop 막는 거야, 그냥 원래 동기 블록 대신 microtask 에서.
setImmediate vs queueMicrotask — 언제 뭐 쓸까
I/O 한테 돌 기회 주려면 setImmediate(fn) — Check 단계로 가, Poll iteration 후, 즉 새 connection 과 I/O callback 이 fn 전 fire. 현재 동기 블록 직후 BUT 어떤 I/O 보다 전에 작업 미루려면 queueMicrotask(fn) — .then 과 같은 우선순위.
영원히 안 막고 큰 청크 처리해야 하는 루프 짤 때 청크 사이 setImmediate 뿌려 — 그게 시스템 나머지에 협력적으로 yield 하는 방식.