C.W.K.
Stream
Lesson 04 of 05 · published

Microtask vs Macrotask — Scheduler 의 두 큐

~14 min · async, microtasks, scheduling, event-loop

Level 0노드 입문자
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Node 의 모든 async 가 두 큐 중 하나에 살아. 어느 건지 알면 순서 예측, starvation 디버그, `await` 가 '충분히 yield 안 한다' 는 놀람 멈출 수 있어."

두 큐, 한 루프

Node event loop 가 두 종류의 스케줄된 작업 사이 엄격한 분리 유지:

  • Macrotask (별칭 task) — event loop 의 여섯 단계. 각 단계가 자기 큐 처리: Timers 의 setTimeout/setInterval callback, Poll 의 I/O callback, Check 의 setImmediate callback, Close 의 close handler.
  • Microtask — 각 macrotask *사이* AND 각 단계 사이에 비워지는 별도 큐. queueMicrotask(fn), Promise.then handler, await continuation 다 여기. 거기에 process.nextTick 위한 sub-queue, Promise microtask 큐 *전에* 비워져.

Drain 규칙

매 단일 macrotask 후, AND 매 event loop 단계 사이에, Node 가 microtask 큐 전체를 완전히 비우고 나서 다음으로 이동. 그래서 같은 동기 블록에 스케줄한 어떤 I/O 나 타이머보다 Promise.resolve().then(fn) 이 항상 먼저 돌아 — microqueue 가 먼저 비워져.

함정: microtask 가 재귀적으로 더 많은 microtask 스케줄하면 루프가 microqueue 너머로 절대 진행 안 함. 서버가 응답 멈춰. 이게 microtask starvation; Lesson 3 의 nextTick starvation 의 async-await 버전.

실제 순서

Node 의 단일 동기 코드 블록에 대한 스케줄:

  1. 동기 코드 자체.
  2. 모든 process.nextTick callback (FIFO).
  3. 모든 Promise microtask — queueMicrotask, .then, await resume (FIFO).
  4. 다음 event loop 단계에서 macrotask 하나 (만료된 timer 있으면 Timers, 아니면 Poll/Check 등).
  5. 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 하는 방식.

Pippa 의 고백

오랫동안 난 모든 async 를 "event loop 가 알아서 해" 로 뭉뚱그렸어. 아빠가 실제 서버 stall 의 trace 보여줬어: 각자 작은 CPU 작업 하는 .then() 50 개 chain, 두 poll 단계 사이 microqueue 안에서 전부 처리. 총: 80ms 동안 I/O 안 받아짐. 해결책은 중간에 `setImmediate(...)` 하나로 chain 깨는 거였어. 각 handler 가 "빨라도" microqueue 가 CPU sink 가 될 수 있어. 이제 긴 .then chain 이나 빡빡한 `for await` 루프 쓸 때 물어: "이거 어딘가에서 I/O 한테 yield 해야 하나?" 보통 yes.

Code

Scheduler 여섯, 한 tick — 검증된 순서·javascript
// Predict the order. Then run it and check yourself.

console.log('1: sync start');

setTimeout(() => console.log('A: setTimeout 0'), 0);
setImmediate(() => console.log('B: setImmediate'));

process.nextTick(() => console.log('C: nextTick'));
queueMicrotask(() => console.log('D: queueMicrotask'));
Promise.resolve().then(() => console.log('E: promise.then'));

async function quick() {
  await Promise.resolve();
  console.log('F: after await');
}
quick();

console.log('2: sync end');

// Expected output (Node 26):
// 1: sync start
// 2: sync end
// C: nextTick
// D: queueMicrotask
// E: promise.then
// F: after await
// B: setImmediate    (or A first if timer is 'expired enough')
// A: setTimeout 0
긴 async 루프 안에서 I/O 한테 yield·javascript
// Microtask starvation in the wild
// This LOOKS innocent but blocks the event loop.
async function processAll(items) {
  for (const item of items) {
    await processOne(item);  // each iteration runs in the microqueue
  }
}

// 10000 items, each fast — but I/O is starved for the whole duration.
// Fix: yield to the loop periodically
async function processAllNice(items) {
  for (let i = 0; i < items.length; i++) {
    await processOne(items[i]);
    if (i % 100 === 0) {
      // Macrotask break — let I/O happen
      await new Promise(r => setImmediate(r));
    }
  }
}

External links

Exercise

작은 HTTP 서버 (createServer) route 하나 / 로 짜. handler 안에서 응답 전 동기 for 루프로 Math.random() 천만 번 호출. curl 로 동시 요청 둘 침. 둘 다 기다림. 이제 같은 거에 10 만 iteration 마다 await new Promise(r => setImmediate(r)) 로 루프 청크 분할. 다시 동시 요청 둘. 인터리브 됨. 그게 I/O-yielding superpower 가 눈에 보이는 거야.
Hint
pre-yield 버전은 본질적으로 동기 CPU 작업 — head-of-line blocking. Yield 버전이 동기 작업을 협력 청크로 바꿔, event loop 가 청크 사이 두 번째 connection 받고 작업 시작하게. 총 throughput 살짝 떨어짐 (yield 마다 overhead), 동시 요청의 latency 는 극적으로 개선.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.