ข้ามไปยังเนื้อหา

Microtasks & Macrotasks

event loop ประมวลผล callback แบบ async จากสองคิวที่แยกกันด้วยลำดับความสำคัญต่างกัน:

Microtask queue — ประมวลผลหลังจากทุก task ก่อน macrotask ถัดไป แหล่งที่มา:

  • callback ของ Promise .then / .catch / .finally
  • queueMicrotask(fn)

Macrotask queue (หรือเรียก task queue) — จัดกลุ่มเป็น phase (timers, I/O, check, …) Node รัน callback ที่ถึงกำหนดทั้ง phase ต่อหนึ่งรอบ event loop (เช่น timer ที่หมดอายุทุกตัว) — ไม่ใช่แค่ task เดียว แหล่งที่มา:

  • callback ของ setTimeout
  • callback ของ setInterval
  • callback ของ I/O (network, file system)

กฎง่าย ๆ: หลังทุก callback แต่ละตัว — ไม่ใช่แค่ครั้งเดียวต่อ phase — Node จะระบาย process.nextTick แล้วตามด้วย microtask queue ทั้งหมด ก่อนจะรัน callback ถัดไป

flowchart TB
  Run["Run one callback (or the initial script)"] --> Drain["Drain nextTick, then all microtasks (incl. new ones)"]
  Drain --> Next["Next callback in this phase, or next phase"]
  Drain -->|"new microtasks"| Drain
หลังทุก callback Node จะระบาย process.nextTick แล้วตามด้วย microtask queue ทั้งหมด — รวมถึง microtask ที่ถูกสร้างขึ้นใหม่ระหว่างทาง — ก่อนจะไปต่อ

Node.js เพิ่ม API การจัดคิวอีกสองตัวที่ต้องใช้ Node runtime:

// These only work in Node.js — not in browsers
process.nextTick(function() {
console.log('nextTick fires before other microtasks');
});
setImmediate(function() {
console.log('setImmediate fires after I/O callbacks in the current loop iteration');
});

callback ของ process.nextTick ถูกระบายก่อน microtask queue ส่วน setImmediate ทำงานในเฟส check ของ event loop ของ Node เนื่องจากทั้งสองต้องใช้ Node runtime จึงใช้ไม่ได้ในตัวรันใน browser ด้านล่าง — ให้รันในเครื่องหรือใน StackBlitz

หลักฐาน: timers phase รันได้หลาย callback โดยระบายหลังแต่ละตัว

หัวข้อที่มีชื่อว่า “หลักฐาน: timers phase รันได้หลาย callback โดยระบายหลังแต่ละตัว”

setTimeout(..., 0) สองตัวตกลงใน timers phase เดียวกัน — แต่ Node ก็ยังระบาย process.nextTick กับ microtask queue ระหว่างสองตัวนี้ ก่อนที่ callback ของ timer ตัวที่สองจะรัน:

setTimeout(function() {
console.log('timer 1');
process.nextTick(function() {
console.log(' nextTick inside timer 1');
});
Promise.resolve().then(function() {
console.log(' promise inside timer 1');
});
}, 0);
setTimeout(function() {
console.log('timer 2');
}, 0);
// Output (verified on Node 24.13, x3):
// timer 1
// nextTick inside timer 1
// promise inside timer 1
// timer 2

ถ้า “หนึ่ง macrotask ต่อหนึ่งรอบ” เป็นจริง timer 2 จะถูกจัดคิวไว้สำหรับรอบถัดไปและไม่ควรเห็นอะไรที่จัดคิวไว้ข้างใน timer 1 แต่ในความเป็นจริง timer ทั้งสองตัวรันใน pass ของ timers-phase เดียวกัน และการระบายเกิดขึ้นหลังทุก callback — ไม่ใช่แค่ครั้งเดียวต่อ phase

process.nextTick, queueMicrotask/Promise, setImmediate และ setTimeout(fn, 0) คือสี่วิธีที่ต่างกันในการพูดว่า “เดี๋ยวค่อยทำ” — โดยมีลำดับความสำคัญต่างกันสี่แบบ ที่บนสุดของสคริปต์ (“main module”) สนิปเป็ตนี้จัดคิวทั้งสี่ตัวแล้วพิมพ์ว่าตัวไหนทำงานก่อนจริง ๆ:

Node.js
Output (verified on Node 24)
sync
Promise microtask
process.nextTick
setTimeout(0)
setImmediate

สังเกตว่า process.nextTick ไม่ได้ชนะในที่นี้ ทั้งที่โดยทั่วไปคิวของ process.nextTick ถูกบันทึกไว้ว่าระบายก่อน microtask queue เสมอ ที่บนสุดของ module ก่อนที่ task จริงตัวไหนจะรันเลย การสอดประสานที่แน่ชัดระหว่าง bookkeeping ของ module system เองกับคิวเหล่านี้ไม่ใช่สิ่งที่เอกสารของ Node สัญญาไว้ ณ จุดนั้นของโปรแกรม — และมันเปราะบางจริง ๆ ด้วย: โค้ดสี่บรรทัดเดียวกันนี้ถ้าห่อในไฟล์ CommonJS แบบ require('node:module') แทนที่จะเป็น ES module ก็สามารถพิมพ์ process.nextTick ก่อน Promise microtask ได้ อย่าพึ่งพาลำดับที่คุณเห็นที่บนสุดของสคริปต์

สิ่งที่เอกสารของ Node รับประกัน จริง ๆ คือลำดับข้างใน I/O callback จริง — ลองเรียก scheduling ทั้งสี่ตัวจากข้างใน callback ของ fs.readFile แล้วลำดับจะนิ่ง:

Node.js
Output (verified on Node 24)
io callback: read 5 bytes
  process.nextTick
  Promise microtask
  setImmediate
  setTimeout(0)

ข้างใน callback ของ readFile process.nextTick ชนะเสมอ ตามด้วย Promise microtask แล้วค่อย setImmediate — และ setImmediate ชนะ setTimeout(0) เสมอ เพราะ callback ของ I/O รันอยู่ใน phase poll และ phase check ของ libuv (ที่ callback ของ setImmediate อยู่) คือ phase ถัดไปทันที ก่อนที่ loop จะวนกลับไปที่ timers ลำดับนี้ — nextTick → microtask → setImmediate → setTimeout — คือสิ่งที่เอกสารของ Node เองระบุว่ารับประกัน เฉพาะจากข้างใน I/O cycle เท่านั้น ซึ่งเป็นเหตุผลว่าทำไมผลลัพธ์ระดับบนสุดข้างต้นถึงไม่ควรถูกมองว่าเป็นกฎทั่วไป

เดโมนี้พิมพ์สี่บรรทัด ลองทายลำดับก่อนรัน:

  1. console.log แบบ synchronous ทำงานทันทีบน call stack
  2. setTimeout(..., 0) จัดคิว macrotask
  3. Promise.resolve().then(...) จัดคิว microtask (Promise resolve อยู่แล้ว reaction จึงถูกจัดคิวแบบ synchronous)
  4. queueMicrotask(...) จัดคิวอีก microtask — หลังจาก reaction ของ Promise ที่จัดคิวไปข้างบนแล้ว

หลังสคริปต์ synchronous ทำเสร็จ microtask ทั้งหมดจะระบายตามลำดับ แล้ว macrotask ค่อยทำงาน

JavaScript

ผลลัพธ์จะเป็นลำดับนี้เสมอ:

  • 1 sync — call stack
  • 2 microtask (Promise) — microtask ตัวแรกที่จัดคิว
  • 3 microtask (queueMicrotask) — microtask ตัวที่สองที่จัดคิว
  • 4 macrotask — ทำงานหลังจาก microtask queue ว่างแล้วเท่านั้น

ให้นึกภาพ scheduling API ทั้งสี่ตัวเป็นสี่แถวที่เคาน์เตอร์เดียวกัน ถูกเช็กตามลำดับตายตัวทุกครั้งที่เคาน์เตอร์ว่าง: แถวของ process.nextTick ก่อน แล้วตามด้วยแถว microtask (Promise/queueMicrotask) — เช็กทั้งสองแถวจนว่างสนิท และต่อเมื่อทั้งสองแถวว่างแล้วเท่านั้นเคาน์เตอร์ถึงจะให้บริการฝั่ง macrotask หนึ่งคิว ที่ซึ่ง setImmediate (check phase) กับ setTimeout (timers phase) อยู่คนละห้องที่ถูกเยี่ยมตามลำดับตายตัวต่อหนึ่งรอบ loop โค้ดที่ “รันครั้งแรก” ที่บนสุดของสคริปต์ยังไม่ได้ยืนอยู่ที่เคาน์เตอร์นั้นแบบปกติ — module system เองยังทำ setup อยู่ — นี่คือเหตุผลว่าทำไมลำดับตรงนั้นถึงไม่ใช่สิ่งที่พึ่งพาได้

Scheduling primitive สี่ตัวฟังดูเป็นตัวเลือกที่ไม่จำเป็น แต่แต่ละตัวตอบคำถามต่างกัน: process.nextTick สำหรับ “ก่อนอะไรทั้งหมดเลย แม้แต่ microtask อื่น” (อันตรายถ้าใช้เกินจำเป็น — มันทำให้ I/O อดตายได้ทั้งหมด); queueMicrotask/Promise สำหรับ “หลังงาน synchronous นี้ ก่อน I/O หรือ timer ใด ๆ”; setImmediate สำหรับ “หลัง callback ของ I/O ใน loop pass เดียวกันนี้”; setTimeout(fn, 0) สำหรับ “ที่ timers phase ถัดไป ไม่ว่าจะเป็นเมื่อไหร่” การเลือกผิดตัวจะทำให้บางอย่างที่ควรรันเร็วกว่าถูกเลื่อนออกไป หรือ — โดยเฉพาะ nextTick — การใช้แบบ recursive ผิด ๆ อาจครอบครองเธรด JS จนไม่ให้ I/O callback ได้ทำงานเลย

  • “ลำดับที่ผมเห็นตอนรันครั้งเดียวที่บนสุดของสคริปต์คือสิ่งที่รับประกันได้” ไม่ใช่ — ตามที่แสดงข้างบน คอร์สนี้พบว่า process.nextTick แพ้ Promise microtask ที่ระดับบนสุด ซึ่งขัดกับกฎทั่วไปที่ว่า nextTick ชนะก่อนเสมอ และไม่ใช่สิ่งที่เอกสารของ Node สัญญาไว้ ณ จุดนั้นในชีวิตของโปรแกรม
  • “setImmediate ชนะ setTimeout(fn, 0) เสมอ ไม่มีข้อยกเว้น” รับประกันเฉพาะข้างใน I/O callback เท่านั้น ที่ระดับบนสุดหรือข้างใน timer callback ธรรมดา ตัวไหนที่ libuv ไปถึงก่อนจริง ๆ ในแต่ละรอบอาจต่างกันได้
  • “queueMicrotask กับ process.nextTick เป็นแค่ชื่ออื่นของคิวเดียวกัน” ทั้งสองเป็นคิวแยกกันที่มีลำดับความสำคัญตายตัวระหว่างกัน — Node ระบายคิว nextTick จนหมดก่อนเสมอ แล้วค่อยระบาย microtask queue ทุกครั้งที่มีการเช็กคิวใดคิวหนึ่ง

โค้ดที่ AI สร้างบางครั้งใช้ Array.prototype.forEach พร้อม callback แบบ async เพื่อ “ประมวลผลแต่ละ item แล้วรอ” ซึ่งพังแบบเดียวกับการเข้าใจผิดเรื่องลำดับ macrotask ที่ถูกมองข้าม: โค้ดข้างนอกทำงานต่อทันที ทั้งที่จัดคิวงานที่มันจะไม่มีวันรอเลย

🐛 หา bug ในโค้ดนี้

async function importAll(records) {
console.log('importing', records.length, 'records');
records.forEach(async function (record) {
await saveRecord(record);
console.log('saved', record.id);
});
console.log('import finished');
}
เฉลย

importAll log 'import finished' แบบ synchronous ทันทีหลัง forEach return — ซึ่งเกิดขึ้นทันที เพราะ forEach ไม่เคยดู (หรือรอ) Promise ที่ callback แบบ async แต่ละตัวคืนมาเลย ไม่มีการเรียก saveRecord ตัวไหนที่รับประกันว่าจะเสร็จ หรือแม้แต่เริ่ม I/O ของมัน ตอนที่ 'import finished' พิมพ์ออกมา และผู้เรียกที่ await importAll(records) ก็จะถูกหลอกให้คิดว่าทุก record ถูกบันทึกแล้ว วิธีแก้คือใช้รูปแบบที่รอจริง ๆ: ลูป for...of พร้อม await สำหรับบันทึกทีละตัว หรือ await Promise.all(records.map(saveRecord)) สำหรับบันทึกพร้อมกัน — ไม่ว่าจะเลือกแบบไหน importAll ต้องมี await นำหน้าตัวที่เลือก ก่อน console.log('import finished') ของมันเอง

จากโค้ดนี้ — อะไรพิมพ์ก่อน: callback ของ Promise .then หรือ callback ของ setTimeout?
queueMicrotask(fn) ทำอะไร?
ถ้า callback ของ microtask เรียก queueMicrotask เอง microtask ใหม่นั้นทำงานเมื่อใด?
โค้ดนี้พิมพ์อะไร? (สมมติว่าไฟล์มีอยู่จริง และนี่รันข้างใน callback ของ fs.readFile จริง ๆ)
readFile('x', 'utf8', function (err, data) {
  setTimeout(function () { console.log('timeout'); }, 0);
  setImmediate(function () { console.log('immediate'); });
  process.nextTick(function () { console.log('nextTick'); });
});
ฟังก์ชันที่ AI เขียนทำ records.forEach(async function (r) { await saveRecord(r); }) ตามด้วย console.log('done') ทันที อะไรคือสิ่งที่รับประกันได้เกี่ยวกับเวลาของ "done" เทียบกับการบันทึก?