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

The 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 ทั้งหมดจะทำงานจนเสร็จก่อนที่อะไรในคิว 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

ลองรัน:

JavaScript

ถ้าคุณรันงาน synchronous ที่ยาวนาน — การคำนวณหนัก, ลูปขนาดใหญ่, การอ่านไฟล์แบบ synchronous — call stack จะถูกครอบครองตลอดช่วงเวลานั้น ไม่มีโค้ดอื่นทำงานได้จนกว่า stack จะว่าง

// This blocks for ~200ms — nothing else can happen during this time
var start = Date.now();
while (Date.now() - start < 200) {
// spinning
}
console.log('Finally unblocked');

ในเซิร์ฟเวอร์ นี่หมายความว่าทุก request ที่เข้ามาต้องรอระหว่างที่บล็อก จงทำให้งาน synchronous สั้นเข้าไว้

“ทำให้ handler แบบ synchronous สั้นเข้าไว้” พูดง่ายแต่บังคับใช้ยากถ้าไม่มีตัวเลข perf_hooks.monitorEventLoopDelay() ของ Node สุ่มตัวอย่างว่า loop ใช้เวลานานแค่ไหนกว่าจะกลับไปเช็ก timer ทำให้คุณวัด lag ได้แทนที่จะเดา เดโมด้านล่างตั้งเวลาให้ busy-loop แบบ synchronous ยาว 200 ms เริ่มที่ 50 ms แล้วรายงาน delay ที่แย่ที่สุดที่สังเกตได้:

Node.js
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() ยุติโปรเซสทันที แบบ synchronous กลางคำสั่ง มันไม่รัน finally ที่ค้างอยู่ ไม่ระบาย process.nextTick และไม่ยิง timer หรือ I/O callback ที่ต่อคิวไว้:

Node.js
Output (verified on Node 24)
before exit

มีแค่ before exit ที่พิมพ์ออกมา ทั้ง finally, nextTick และ timer ยังอยู่บน stack หรือในคิวตอนที่ image ของโปรเซสถูกทำลาย — ไม่มีตัวไหนได้ทำงานเลย

ให้นึกภาพ call stack เป็นโต๊ะเดียวที่มีคนงานคนเดียวและถาดงานเข้าถาดเดียว ทุก statement แบบ synchronous — ของคุณเอง, ของ callback, หรือของ Promise reaction — ต้องอยู่บนโต๊ะนั้นจนกว่าจะเสร็จ ไม่มีอะไรบนโต๊ะขยับได้ระหว่างนั้น monitorEventLoopDelay คือนาฬิกาจับเวลาที่ติดไว้บนโต๊ะนั้น — มันบอกว่าคนงานจมอยู่กับงานหนึ่งชิ้นนานแค่ไหนแทนที่จะเช็กถาดงานเข้า ส่วน process.exit() คือคนงานลุกเดินออกกลางประโยค — อะไรที่ต่อคิวไว้ข้างหลังเขา รวมถึง finally ของตัวเขาเอง จะไม่มีวันได้พูดจนจบ

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 สร้างมักเรียก 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 string
console.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 (และการเขียนที่ค้างอยู่) ระบายก่อนที่จะออกเอง

โครงสร้างข้อมูลแบบใดอธิบายวิธีที่ call stack ประมวลผลการเรียกฟังก์ชัน?
callback ของ setTimeout เกิดอะไรขึ้นขณะที่ลูป synchronous ที่ยาวนานกำลังทำงาน?
เฟรมของฟังก์ชันถูกนำออกจาก call stack เมื่อใด?
โค้ดนี้พิมพ์อะไร?
function a() { throw new Error('boom'); }
function b() { a(); }
function c() {
  try { b(); } catch (e) { console.log('caught:', e.message); }
}
c();
console.log('after');
โค้ดนี้พิมพ์อะไร?
console.log('before exit');
process.nextTick(function () { console.log('nextTick'); });
try {
  process.exit(0);
} finally {
  console.log('finally');
}
ทำไม output จาก console.log บรรทัดสุดท้ายของสคริปต์อาจหายไปเมื่อ pipe ไปไฟล์ แต่ไม่เคยหายเมื่อรันตรงใน terminal?