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
เฉพาะ Node: process.nextTick และ setImmediate
หัวข้อที่มีชื่อว่า “เฉพาะ Node: process.nextTick และ setImmediate”Node.js เพิ่ม API การจัดคิวอีกสองตัวที่ต้องใช้ Node runtime:
// These only work in Node.js — not in browsersprocess.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
API จัดคิวทั้งสี่ตัวมารวมกัน
หัวข้อที่มีชื่อว่า “API จัดคิวทั้งสี่ตัวมารวมกัน”process.nextTick, queueMicrotask/Promise, setImmediate และ setTimeout(fn, 0) คือสี่วิธีที่ต่างกันในการพูดว่า “เดี๋ยวค่อยทำ” — โดยมีลำดับความสำคัญต่างกันสี่แบบ ที่บนสุดของสคริปต์ (“main module”) สนิปเป็ตนี้จัดคิวทั้งสี่ตัวแล้วพิมพ์ว่าตัวไหนทำงานก่อนจริง ๆ:
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 แล้วลำดับจะนิ่ง:
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 เท่านั้น ซึ่งเป็นเหตุผลว่าทำไมผลลัพธ์ระดับบนสุดข้างต้นถึงไม่ควรถูกมองว่าเป็นกฎทั่วไป
เดโมจัดลำดับที่รันได้
หัวข้อที่มีชื่อว่า “เดโมจัดลำดับที่รันได้”เดโมนี้พิมพ์สี่บรรทัด ลองทายลำดับก่อนรัน:
console.logแบบ synchronous ทำงานทันทีบน call stacksetTimeout(..., 0)จัดคิว macrotaskPromise.resolve().then(...)จัดคิว microtask (Promise resolve อยู่แล้ว reaction จึงถูกจัดคิวแบบ synchronous)queueMicrotask(...)จัดคิวอีก microtask — หลังจาก reaction ของ Promise ที่จัดคิวไปข้างบนแล้ว
หลังสคริปต์ synchronous ทำเสร็จ microtask ทั้งหมดจะระบายตามลำดับ แล้ว macrotask ค่อยทำงาน
ผลลัพธ์จะเป็นลำดับนี้เสมอ:
1 sync— call stack2 microtask (Promise)— microtask ตัวแรกที่จัดคิว3 microtask (queueMicrotask)— microtask ตัวที่สองที่จัดคิว4 macrotask— ทำงานหลังจาก microtask queue ว่างแล้วเท่านั้น
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ให้นึกภาพ scheduling API ทั้งสี่ตัวเป็นสี่แถวที่เคาน์เตอร์เดียวกัน ถูกเช็กตามลำดับตายตัวทุกครั้งที่เคาน์เตอร์ว่าง: แถวของ process.nextTick ก่อน แล้วตามด้วยแถว microtask (Promise/queueMicrotask) — เช็กทั้งสองแถวจนว่างสนิท และต่อเมื่อทั้งสองแถวว่างแล้วเท่านั้นเคาน์เตอร์ถึงจะให้บริการฝั่ง macrotask หนึ่งคิว ที่ซึ่ง setImmediate (check phase) กับ setTimeout (timers phase) อยู่คนละห้องที่ถูกเยี่ยมตามลำดับตายตัวต่อหนึ่งรอบ loop โค้ดที่ “รันครั้งแรก” ที่บนสุดของสคริปต์ยังไม่ได้ยืนอยู่ที่เคาน์เตอร์นั้นแบบปกติ — module system เองยังทำ setup อยู่ — นี่คือเหตุผลว่าทำไมลำดับตรงนั้นถึงไม่ใช่สิ่งที่พึ่งพาได้
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”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 เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”โค้ดที่ 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') ของมันเอง