libuv & Event Loop แบบลึกอีกขั้น
บทนี้ถือว่าคุณรู้ 6 phase จาก libuv & the Threadpool แล้ว — จะไม่ทำ diagram นั้นซ้ำ ที่นี่เราจะลึกลงไปอีกขั้น: timer ถูกเรียงลำดับจริง ๆ ยังไง, “poll phase block” มีค่าใช้จ่ายกี่มิลลิวินาทีจริง ๆ, และลำดับการ drain queue ที่แม่นยำระหว่าง process.nextTick กับ microtask
Timer คือ priority queue ไม่ใช่ list
หัวข้อที่มีชื่อว่า “Timer คือ priority queue ไม่ใช่ list”ภายใน uv_run เก็บ timer ที่รอไว้ใน data structure ที่เรียงตามเวลาครบกำหนดแบบ absolute (min-heap ใน implementation ของ libuv) ไม่ใช่ตามลำดับที่ลงทะเบียน timer สองตัวที่มี delay เท่ากัน ยังคงยิงตามลำดับที่ ถูกตั้งเวลา เพราะมันลงเอยด้วยเวลาครบกำหนดเท่ากันและ heap รักษาลำดับการใส่เข้าไว้เมื่อเสมอกัน — แต่ timer ที่มี delay สั้นกว่าจะแซงหน้า timer ที่ delay ยาวกว่าเสมอ ไม่ว่าตัวไหนจะถูกตั้งก่อน:
Output (verified on Node 24)
C: scheduled 3rd, delay 0ms (floor is ~1ms) -- shorter delay wins
A: scheduled 1st, delay 10ms
B: scheduled 2nd, delay 10mssetTimeout(fn, 0) ไม่ได้แปลว่า “รันทันที” — Node บังคับ floor ประมาณ 1ms และ callback ยังต้องรอโค้ด synchronous ปัจจุบันและ queue ของ microtask/nextTick drain ให้หมดก่อน แล้วค่อยรอให้ถึง timers phase
Poll phase block และ timer ที่รออยู่ทำให้มันสั้นลงยังไง
หัวข้อที่มีชื่อว่า “Poll phase block และ timer ที่รออยู่ทำให้มันสั้นลงยังไง”phase poll เป็น phase เดียวที่ block ได้จริง ๆ ขณะรอ I/O มันไม่ block ตลอดไป: ถ้ามี timer ที่ active อยู่ libuv จะคำนวณว่าอีกนานแค่ไหนกว่า timer นั้นจะครบกำหนด แล้ว block poll phase มากที่สุด เท่านั้น เพื่อไม่ให้ timer ที่ครบกำหนดถูกเลื่อนออกไปเพราะรอ I/O แบบไม่มีกำหนด ถ้าไม่มี I/O พร้อมและไม่มี timer ที่รออยู่เลย poll สามารถ block ได้จนกว่า I/O event ถัดไปจะมาโดยไม่มี timeout — นี่ก็เป็นเหตุผลที่ Node process ที่ไม่มีอะไรเหลือให้ทำ (ไม่มี timer, ไม่มี I/O ที่รออยู่, ไม่มี handle ที่เปิดอยู่) จบตัวเองแทนที่จะค้าง
setImmediate กับ process.nextTick แบบแม่นยำ
หัวข้อที่มีชื่อว่า “setImmediate กับ process.nextTick แบบแม่นยำ”process.nextTick และ microtask (Promise.then, queueMicrotask) ไม่ใช่ phase ของ libuv loop เลย — มันถูก drain โดย Node เองจนหมด ทันทีหลัง operation ปัจจุบันจบและก่อนที่ loop จะได้รับอนุญาตให้ไปยัง phase ถัดไป setImmediate ตรงข้ามกัน มันเป็น phase จริง ๆ ของ libuv (check) ซึ่งรันหลัง poll เท่านั้น ข้างใน callback ใดก็ตาม ลำดับการ drain คือ: queue ของ nextTick ทั้งหมดก่อน แล้ว queue ของ microtask ทั้งหมด วนซ้ำจนทั้งคู่ว่าง ก่อนที่อย่างอื่น (รวมถึง setImmediate) จะรัน:
Output (verified on Node 24)
1: sync code in the timer callback
2: nextTick (drained first, in full)
3: queueMicrotask (drained after nextTick, in full)
4: promise .then (also the microtask queue)
5: setImmediate (a real loop phase -- runs after nextTick/microtasks)internal เก่าของ Node ที่ชื่อ process._tickCallback หายไปแล้วใน Node สมัยใหม่ — queue นี้ถูกขับเคลื่อนภายในโดยไม่มี hook เฉพาะนั้น expose ออกมา แต่ behavior ที่สังเกตได้ข้างบน (nextTick drain หมดก่อน แล้ว microtask drain หมด หลังทุก callback) ไม่เปลี่ยนและยังเป็นวิธีคิดเรื่องลำดับที่ถูกต้อง มีจุดพลิกจริงหนึ่งจุด: ที่ top level ของ module แบบ ESM โดยเฉพาะ (ไม่ใช่ข้างใน callback ใด ๆ และไม่ใช่ใน CommonJS) การ evaluate module เองถูกห่อเป็น async job ซึ่งทำให้ microtask ระดับ top-level ดูเหมือนรันก่อน nextTick ระดับ top-level ได้ — ข้างใน callback จริงใด ๆ (timer, I/O event, immediate) ลำดับข้างบนเป็นจริงเสมอ
วัด loop lag ด้วย monitorEventLoopDelay
หัวข้อที่มีชื่อว่า “วัด loop lag ด้วย monitorEventLoopDelay”perf_hooks.monitorEventLoopDelay() เก็บ histogram ว่า loop ใช้เวลาจริง ๆ นานแค่ไหนระหว่างแต่ละรอบ ซึ่งเปลี่ยน “loop กำลังถูก block” จากการเดาให้กลายเป็นตัวเลข:
Output (verified on Node 24)
max delay landed in the expected ~100ms range: true
mean delay stayed well below the max (most iterations were fast): trueอะไร block loop จริง ๆ
หัวข้อที่มีชื่อว่า “อะไร block loop จริง ๆ”อะไรก็ตามที่ synchronous บน main thread จะ block ทุกอย่างที่เหลือ — ทุก socket, ทุก timer, ทุก promise ที่รออยู่ — นานเท่าที่มันรัน สามรูปแบบครอบคลุมกรณีจริงส่วนใหญ่:
fs แบบ synchronous ใน hot path readFileSync กับไฟล์ใหญ่ block ตลอดการอ่านทั้งหมด:
Output (verified on Node 24)
sync read of 50MB blocked the loop for over 5ms: trueJSON.parse ของ payload ใหญ่ การ parse เป็น synchronous เต็มตัว — ไม่มี JSON.parse แบบ async ใน Node:
Output (verified on Node 24)
payload size, rounded to nearest 10 MB: 30
parsed item count: 1000000
JSON.parse of a ~30MB payload blocked the loop for over 20ms: trueRegex backtracking แบบหายนะ pattern แบบ /^(a+)+$/ กับ input ที่จงใจสร้างให้ไม่ match ใช้เวลาแบบ exponential ทั้งหมดเป็น synchronous:
Output (verified on Node 24)
matched: false
took over 50ms from catastrophic backtracking on 25 characters: truestring ที่ควบคุมโดยผู้ใช้ไปเจอ regex ที่มีช่องโหว่คือ denial-of-service vector จริง (“ReDoS”): 25 ตัวอักษรกินเวลาหลายสิบมิลลิวินาทีแล้ว; ตัวอักษรที่เพิ่มมาแต่ละตัวเพิ่มเวลาเป็นสองเท่าโดยประมาณ
async_hooks และ AsyncLocalStorage มีต้นทุนจริง
หัวข้อที่มีชื่อว่า “async_hooks และ AsyncLocalStorage มีต้นทุนจริง”AsyncLocalStorage (สร้างบน async_hooks) คือวิธีมาตรฐานในการพก request ID หรือ trace context ข้าม await โดยไม่ต้องส่ง parameter ผ่านทุก function มันไม่ฟรี — การส่งต่อ context ผ่านทุก async continuation มี overhead ที่วัดได้เทียบกับโค้ดที่ไม่มี context เลย:
Output (verified on Node 24)
AsyncLocalStorage overhead per call, bucketed: large (>4x)
context is correctly scoped per call: trueoverhead นั้นคิดต่อ async continuation ไม่ใช่ต่อ request — สำหรับ HTTP handler ทั่วไป (await แค่ไม่กี่ตัว ไม่ใช่ Promise.resolve() เปล่า ๆ เป็นแสนครั้งใน loop แน่น ๆ) มันแทบไม่ใช่คอขวดเลย แต่มันไม่ใช่ศูนย์ และ “ห่อทุกอย่างด้วย AsyncLocalStorage” คือ trade-off จริง ไม่ใช่ abstraction ที่ฟรี
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Event loop คือ loop เดียวที่มีจุดจอดที่แน่นอนเรียงตามลำดับ (timers, pending callbacks, idle/prepare, poll, check, close) nextTick กับ microtask ไม่ใช่จุดจอดบนเส้นทางนั้นเลย — มันคือด่านเก็บเงินที่รันจนจบระหว่างทุกจุดจอด และแม้กระทั่งระหว่าง statement ภายในจุดจอดหนึ่ง ก่อนที่ loop จะได้รับอนุญาตให้ไปต่อ โค้ดที่ block คือรถที่จอดเสียกลางถนน: ไม่สำคัญว่ามันเกิดที่จุดจอดไหน ไม่มีอะไรข้างหลังมันขยับได้จนกว่ามันจะเคลียร์ เพราะมีถนนเส้นเดียวและรถวิ่งได้ทีละคันเท่านั้น
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”Loop แบบ single-threaded ทำให้การคิดเรื่องลำดับ deterministic — ไม่มี race จริงระหว่างโค้ด JS สองชิ้นของคุณแบบที่จะเกิดกับ thread จริงและ shared memory ต้นทุนคือไม่มีอะไรอื่นรันเลยขณะที่โค้ดคุณอยู่บน stack ดังนั้นงาน synchronous ใด ๆ ที่ “ช้าเกินไป” สำหรับ process ที่เสิร์ฟ request (JSON.parse ก้อนใหญ่, regex ที่มีช่องโหว่, fs แบบ sync ก้อนใหญ่) ไม่มีทางออกราคาถูกข้างใน main thread — วิธีแก้จริงคือ streaming แทนการโหลด-แล้ว-parse, regex library ที่ไม่ backtrack ซึ่งผ่านการตรวจสอบแล้ว, fs แบบ async, หรือย้ายงานไป worker thread ไปเลย (ดู Native Code & Workers)
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
setImmediateยิงก่อนsetTimeout(fn, 0)เสมอ” รับประกันได้เฉพาะข้างใน I/O callback (ผ่าน poll มาแล้ว ดังนั้นcheckคือ phase ถัดไปทันที) จาก main module หรือ timer callback ลำดับระหว่างสองอย่างนี้ไม่รับประกันและอาจสลับกันได้แต่ละครั้งที่รัน - “
process.nextTickกับ microtask คือ queue เดียวกัน” มันคือ queue แยกกันสองอัน ถูก drain ตามลำดับที่แน่นอน (nextTick แล้วค่อย microtask) หลังทุก operation — สับสนสองอย่างนี้นำไปสู่การทำนายที่ผิดเรื่องลำดับ interleave ที่แน่นอน แม้ว่าทั้งคู่จะ drain ก่อน loop phase ถัดไปเหมือนกันก็ตาม - “โค้ด async block event loop ไม่ได้”
async/awaitและ promise ควบคุมแค่ เมื่อไร continuation จะรัน ไม่ใช่ว่าโค้ดข้างในเป็น synchronous หรือเปล่าJSON.parseหรือ regex.test()ข้างใน async function block พอ ๆ กับตอนอยู่นอก async function
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”Request handler ที่ AI สร้างมักมี operation แบบ synchronous “เร็ว ๆ” ที่ดูไม่มีพิษภัยเมื่อมองแยก แต่จริง ๆ ไม่ใช่
🐛 หา bug ในโค้ดนี้
// AI-written request handlerapp.post('/import', (req, res) => {const data = JSON.parse(req.body); // req.body can be arbitrarily largeconst result = data.items.map(processItem);res.json({ count: result.length });});เฉลย
JSON.parse เป็น synchronous และไม่มีทางออกตามขนาด — สำหรับ request body ที่บังเอิญมีขนาดหลายสิบ MB (พิสูจน์ข้างบนแล้ว: ประมาณ 20ms ขึ้นไปสำหรับ payload ~30MB และมันสเกลตามขนาด) บรรทัดเดียวนี้ block event loop ทั้งหมด ทำให้ request อื่นที่กำลังทำอยู่บน process ล่าช้าเท่ากันหมด รวมกับการไม่มี body-size limit ก่อนหน้า (บั๊กแยกที่เกี่ยวข้องกัน) upload ก้อนใหญ่หรือที่เป็นอันตรายเพียงครั้งเดียวสามารถทำให้ server ทั้งตัวค้างได้ วิธีแก้คือ body-size limit ก่อน parse และสำหรับ payload ที่ใหญ่จริง ๆ ใช้ streaming JSON parser แทน JSON.parse บน body ที่ buffer ไว้ทั้งก้อน