The Call Stack
Call stack คืออะไร?
หัวข้อที่มีชื่อว่า “Call stack คืออะไร?”Call stack เป็นโครงสร้างข้อมูลแบบ LIFO (Last In, First Out) ที่ติดตามฟังก์ชันที่กำลังทำงานอยู่ ทุกครั้งที่ฟังก์ชันถูกเรียก จะมี เฟรม (frame) ใหม่ถูก push ขึ้น stack เมื่อฟังก์ชัน return เฟรมนั้นจะถูก pop ออก
function greet(name) { return 'Hello, ' + name;}
function main() { var msg = greet('Node'); console.log(msg);}
main();// Stack at peak: [main] → [main, greet]// After greet returns: [main]// After main returns: []เอนจินทำงานได้ ทีละเฟรมเท่านั้น ขณะที่เฟรมหนึ่งกำลังทำงาน ไม่มีอย่างอื่นทำงานได้ — ไม่ว่าจะเป็น callback ของ I/O, callback ของ timer หรืออะไรก็ตาม
การทำงานแบบ synchronous
หัวข้อที่มีชื่อว่า “การทำงานแบบ synchronous”โค้ด synchronous ทั้งหมดจะทำงานจนเสร็จก่อนที่อะไรในคิว async จะได้ทำงาน ลองดูตัวอย่างการนับถอยหลัง:
function countdown(n) { if (n <= 0) { console.log('done'); return; } console.log(n); countdown(n - 1);}
countdown(5);การเรียกซ้ำแต่ละครั้ง push เฟรมใหม่ ผลลัพธ์เป็นแบบ deterministic อย่างสมบูรณ์: 5, 4, 3, 2, 1, done
ลองรัน:
ทำไมการบล็อกจึงสำคัญ
หัวข้อที่มีชื่อว่า “ทำไมการบล็อกจึงสำคัญ”ถ้าคุณรันงาน synchronous ที่ยาวนาน — การคำนวณหนัก, ลูปขนาดใหญ่, การอ่านไฟล์แบบ synchronous — call stack จะถูกครอบครองตลอดช่วงเวลานั้น ไม่มีโค้ดอื่นทำงานได้จนกว่า stack จะว่าง
// This blocks for ~200ms — nothing else can happen during this timevar start = Date.now();while (Date.now() - start < 200) { // spinning}console.log('Finally unblocked');ในเซิร์ฟเวอร์ นี่หมายความว่าทุก request ที่เข้ามาต้องรอระหว่างที่บล็อก จงทำให้งาน synchronous สั้นเข้าไว้
วัด lag แทนการเดา
หัวข้อที่มีชื่อว่า “วัด lag แทนการเดา”“ทำให้ handler แบบ synchronous สั้นเข้าไว้” พูดง่ายแต่บังคับใช้ยากถ้าไม่มีตัวเลข perf_hooks.monitorEventLoopDelay() ของ Node สุ่มตัวอย่างว่า loop ใช้เวลานานแค่ไหนกว่าจะกลับไปเช็ก timer ทำให้คุณวัด lag ได้แทนที่จะเดา เดโมด้านล่างตั้งเวลาให้ busy-loop แบบ synchronous ยาว 200 ms เริ่มที่ 50 ms แล้วรายงาน delay ที่แย่ที่สุดที่สังเกตได้:
Output (verified on Node 24)
busy loop finished
max event loop delay (rounded): ~200msตัวเลขถูกปัดเศษเป็นหลักร้อย ms เพื่อให้เดโมนี้พิมพ์ผลเดิมทุกเครื่อง ส่วน API จริงคืนค่าละเอียดระดับ nanosecond ทั้ง h.min, h.max, h.mean และ percentile (h.percentile(99)) — พอจะเอาไปทำ metric “loop lag” ใส่ health check ได้
process.exit() ไม่รอใครทั้งนั้น
หัวข้อที่มีชื่อว่า “process.exit() ไม่รอใครทั้งนั้น”process.exit() ยุติโปรเซสทันที แบบ synchronous กลางคำสั่ง มันไม่รัน finally ที่ค้างอยู่ ไม่ระบาย process.nextTick และไม่ยิง timer หรือ I/O callback ที่ต่อคิวไว้:
Output (verified on Node 24)
before exitมีแค่ before exit ที่พิมพ์ออกมา ทั้ง finally, nextTick และ timer ยังอยู่บน stack หรือในคิวตอนที่ image ของโปรเซสถูกทำลาย — ไม่มีตัวไหนได้ทำงานเลย
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ให้นึกภาพ call stack เป็นโต๊ะเดียวที่มีคนงานคนเดียวและถาดงานเข้าถาดเดียว ทุก statement แบบ synchronous — ของคุณเอง, ของ callback, หรือของ Promise reaction — ต้องอยู่บนโต๊ะนั้นจนกว่าจะเสร็จ ไม่มีอะไรบนโต๊ะขยับได้ระหว่างนั้น monitorEventLoopDelay คือนาฬิกาจับเวลาที่ติดไว้บนโต๊ะนั้น — มันบอกว่าคนงานจมอยู่กับงานหนึ่งชิ้นนานแค่ไหนแทนที่จะเช็กถาดงานเข้า ส่วน process.exit() คือคนงานลุกเดินออกกลางประโยค — อะไรที่ต่อคิวไว้ข้างหลังเขา รวมถึง finally ของตัวเขาเอง จะไม่มีวันได้พูดจนจบ
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”Call stack เดียวแปลว่าไม่ต้องใช้ lock ล้อมรอบ state ของ JavaScript ของคุณเอง — แต่ก็แปลว่าไม่มีที่ว่างให้กับงาน synchronous ที่ยาวนานเลย: JSON.parse ที่ช้าหรือ crypto call เดียวทำให้ request อื่นทุกตัวในโปรเซสค้าง ไม่ใช่แค่ตัวที่ทริกเกอร์มัน การวัด lag ด้วย monitorEventLoopDelay แทบไม่มีต้นทุนและคุ้มที่จะต่อเข้ากับ health check หรือ endpoint /metrics ไว้ก่อนที่จะต้องใช้มันตอนเกิดเหตุจริง process.exit() แลกการปิดระบบแบบสง่างามที่รอให้ทุกอย่างระบายก่อน กับการปิดแบบทันทีทื่อ ๆ — เหมาะกับสคริปต์ CLI ที่เขียนทุกอย่างที่ต้องเขียนเสร็จแล้ว แต่อันตรายในโปรเซสเซิร์ฟเวอร์ที่อาจยังมี response, log line หรือการเขียนฐานข้อมูลค้างอยู่
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ลูป
whileที่เสร็จในไม่ถึงวินาทีก็โอเค” โอเคเทียบกับอะไร? การบล็อกแบบ synchronous 200 ms ทำให้ request, timer และ I/O callback อื่นที่ค้างอยู่ทุกตัวในโปรเซสช้าไป 200 ms — บนเซิร์ฟเวอร์ที่ยุ่งนั่นถือว่าพลาด SLA ได้ง่าย ๆ ทั้งที่ไม่มีอะไร “ค้าง” ในความหมายทั่วไป - “ผม log ผลลัพธ์แล้ว ดังนั้น
process.exit()ต่อท้ายคือการ cleanup ที่ปลอดภัย” ปลอดภัยแค่บางครั้งprocess.exit()ไม่รอสิ่งที่ต่อคิวไว้เลย — ไม่รอfinally, ไม่รอnextTickและ (ดู SpotTheBug ด้านล่าง) ไม่จำเป็นต้องรอแม้แต่console.logที่คุณเพิ่งเรียก - “Call stack กับ event loop เป็นสิ่งเดียวกัน” สองอย่างนี้เกี่ยวข้องกันแต่ไม่ใช่สิ่งเดียวกัน: call stack คือที่ที่เฟรม synchronous ทำงานทีละเฟรม ส่วน event loop คือกลไกที่ตัดสินใจว่าอะไรจะถูก push ขึ้น stack ต่อไป จากคิวไหน ตามลำดับใด
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”โค้ดที่ AI สร้างมักเรียก process.exit() ทันทีหลัง console.log สุดท้าย โดยคิดว่ามันคือ “พิมพ์ผลลัพธ์ แล้ว cleanup ทันที” นั่นปลอดภัยเมื่อ output ไปที่ terminal (การเขียนที่นั่นเป็น synchronous) แต่สคริปต์เดียวกันเมื่อถูก pipe ไปไฟล์หรือโปรเซสอื่น (node report.js > out.txt, หรือรันภายใต้ process manager, Docker, หรือ CI ที่ stdout เป็น pipe) อาจทำให้ output สุดท้ายหายไปเงียบ ๆ เพราะการเขียนไปยัง stdout ที่ไม่ใช่ TTY อาจเป็นแบบ asynchronous — และ process.exit() ไม่รอให้การเขียนที่ค้างอยู่เสร็จ
🐛 หา bug ในโค้ดนี้
function main() {const report = generateReport(); // a large stringconsole.log(report); // AI comment: "print the report, then exit cleanly"process.exit(0);}
main();เฉลย
โค้ดนี้ดูถูกต้องเมื่อรันใน terminal ที่การเขียน stdout เป็น synchronous แต่พอ pipe สคริปต์เดียวกันไปไฟล์หรือ process อื่น (node report.js > out.txt, node report.js | gzip > out.gz, log driver ของ Docker, CI runner) stdout จะกลายเป็นแบบ non-blocking — การเขียนอาจยังค้างอยู่ตอนที่ process.exit() ทำลายโปรเซส ทำให้ report บางส่วนหรือทั้งหมดหายไปเงียบ ๆ วิธีแก้คือไม่ต้องเรียก process.exit() ตรงนี้เลย — ตั้ง process.exitCode = 0 แล้วปล่อยให้สคริปต์ return ตามปกติ เพื่อให้ Node รอ event loop (และการเขียนที่ค้างอยู่) ระบายก่อนที่จะออกเอง