JavaScript event loop ที่ขับเคลื่อนหน้าเว็บของคุณทำงานแตกต่างอย่างมากจากตัวที่ขับเคลื่อน Node.js server และความไม่สอดคล้องกันนี้อาจทำให้ UI ค้างหรือทำให้ I/O ติดขัดหากคุณไม่ระวัง การรู้ว่าทั้งสองมีความแตกต่างกันตรงไหนเป็นสิ่งสำคัญสำหรับใครก็ตามที่เขียนโค้ด async ที่ต้องรันในทั้งสองสภาพแวดล้อม
ทำไมความแตกต่างนี้จึงสำคัญ
Event loop ไม่ได้ถูกกำหนดโดยสเปก ECMAScript แต่มันอาศัยอยู่ใน host (สภาพแวดล้อมที่รันโค้ด) เบราว์เซอร์ต้องรักษาความลื่นไหลของหน้าเว็บในขณะที่เรนเดอร์เฟรม ในขณะที่ Node.js ถูกสร้างขึ้นรอบๆ I/O แบบ non-blocking การนำรูปแบบที่ใช้ได้ใน host หนึ่งไปใช้อีก host หนึ่งอาจทำให้เกิดบั๊กที่ยากต่อการจำลองสถานการณ์ เช่น การต่อสายโซ่ของ promises ที่ยาวเกินไปอาจทำให้การ repaint ของเบราว์เซอร์หยุดชะงัก ในขณะที่ลูป process.nextTick ที่ไม่มีการควบคุมอาจขัดขวางไม่ให้ Node เข้าสู่ขั้นตอน I/O ได้เลย
ลูปแบบแบ่งรอบของเบราว์เซอร์
ในเบราว์เซอร์ ลูปจะทำงานหนึ่งรอบซึ่งสลับกันระหว่างการประมวลผล task, การเคลียร์ microtask และการเรนเดอร์:
- รันหนึ่ง macrotask (เช่น click handler,
setTimeoutเป็นต้น) - เคลียร์ all microtasks (promises,
queueMicrotask) - หากถึงเวลาต้องเรนเดอร์เฟรม ให้ทำการ paint และ composite เพื่อให้ได้ 60 fps ตามเป้าหมาย
- ทำซ้ำ
มี API สองตัวที่ช่วยให้นักพัฒนาสามารถเข้าถึงวงจรนี้ได้อย่างชัดเจน:
requestAnimationFrame– ถูกเรียกใช้ก่อนที่เบราว์เซอร์จะทำการ paint เป็นจุดที่เหมาะสมสำหรับงานด้านแอนิเมชัน เพราะ callback จะทำงานหลังจาก microtasks ปัจจุบันเสร็จสิ้น แต่ก่อนเฟรมถัดไปจะเริ่มขึ้นrequestIdleCallback– ถูกเรียกใช้เมื่อเบราว์เซอร์ไม่มีงานที่มีลำดับความสำคัญสูง เป็นประโยชน์สำหรับงานที่มีผลกระทบต่ำ เช่น การเก็บข้อมูล analytics หรือการโหลดข้อมูลล่วงหน้า (pre-loading)
Pitfall: microtask starvation
เนื่องจากเบราว์เซอร์จะเคลียร์ microtask queue ให้หมด ก่อน ที่จะเรนเดอร์ การต่อสายโซ่ของ promises ที่ยาวเกินไปอาจทำให้ UI ไม่สามารถทำการ paint ได้เลย แม้ว่า call stack จะไม่ถูกบล็อก แต่หน้าเว็บก็แค่ไม่ไปถึงขั้นตอนการเรนเดอร์ ซึ่งจะทำให้ผู้ใช้รู้สึกเหมือนหน้าเว็บค้าง
ลูปที่ขับเคลื่อนด้วย libuv ของ Node
Node.js มอบหมายการทำงานของลูปให้กับ libuv ซึ่งเป็นไลบรารี C ที่แบ่งงานออกเป็นเฟสต่างๆ โดยแต่ละเฟสจะมีคิวเป็นของตัวเอง:
- Timers – callback จาก
setTimeoutและsetInterval - Pending callbacks – I/O callback ที่ถูกเลื่อนออกไปซึ่งเสร็จสิ้นแล้วในระดับ OS
- Poll – ดึงเหตุการณ์ I/O ใหม่ๆ (การอ่านไฟล์, ข้อมูลเครือข่าย)
- Check – รัน callback ของ
setImmediate - Close callbacks – ทำงานเมื่อ socket หรือ handle ปิดลง
มีโครงสร้างสองอย่างที่อยู่นอกลำดับเฟสเหล่านี้:
process.nextTick– ทำงาน ก่อน microtask queue ทันทีหลังจาก operation ปัจจุบันเสร็จสิ้น
Pitfall: I/O starvation
หากฟังก์ชันมีการเรียกใช้ process.nextTick ซ้ำๆ โดยไม่มีการหยุดพัก (yielding) Node จะไม่สามารถก้าวข้ามขั้นตอน "next-tick" ไปได้เลย ทำให้การร้องขอเครือข่าย, การอ่านไฟล์ และตัวจับเวลา (timers) ต้องหยุดรอ ส่งผลให้เกิด latency spike ในฝั่งเซิร์ฟเวอร์หรือทำให้ระบบค้างไปเลย
setImmediate vs. setTimeout ในการใช้งานจริง
ทั้งคู่ใช้สำหรับกำหนดตารางเวลา callback สำหรับรอบถัดไป แต่ลำดับที่สัมพันธ์กันจะขึ้นอยู่กับว่าพวกมันถูกเรียกจากที่ไหน:
- โค้ดระดับบนสุด (Top-level code) – ลำดับไม่แน่นอน ขึ้นอยู่กับว่า process เริ่มต้นทำงานเร็วแค่ไหน
- ภายใน I/O callback – ลำดับจะแน่นอน (deterministic):
setImmediateจะทำงานก่อนsetTimeout(fn, 0)หลังจากเฟส Poll เสร็จสิ้น libuv จะย้ายไปยังเฟส Check (ซึ่งเป็นที่อยู่ของsetImmediate) ก่อนที่จะกลับเข้าสู่เฟส Timers สำหรับ timeout ที่ไม่มีการหน่วงเวลา (zero-delay)
ความละเอียดอ่อนนี้มีความสำคัญเมื่อคุณต้องพึ่งพาลำดับขั้นตอนที่แม่นยำ เช่น การเคลียร์ทรัพยากรทันทีหลังจากที่การอ่านข้อมูลเสร็จสิ้น
สรุปความแตกต่างที่สำคัญ
- เป้าหมาย: เบราว์เซอร์ให้ความสำคัญกับการอัปเดตภาพ; Node ให้ความสำคัญกับความพร้อมของ I/O
- Rendering hook:
requestAnimationFrame(เฉพาะเบราว์เซอร์) - Phase-specific hook:
setImmediate(เฉพาะ Node, ทำงานในเฟส Check) - High-priority queue:
process.nextTick(เฉพาะ Node, ทำงานก่อน microtasks) - ความเสี่ยงในการเกิด starvation: การต่อสายโซ่ promises ที่ยาวในเบราว์เซอร์; การใช้
process.nextTickแบบไม่จำกัดใน Node
สิ่งที่ควรระวังต่อไป
หากคุณดูแล codebase ที่รันในทั้งสองสภาพแวดล้อม (เช่น isomorphic libraries) ให้ตรวจสอบจุดที่คุณ:
- ต่อสายโซ่ promises จำนวนมากโดยไม่มีการคืนการควบคุม (yielding) ให้กับ event loop ควรแทรก
await new Promise(r => setTimeout(r, 0))หรือใช้requestIdleCallbackในเบราว์เซอร์เพื่อให้โอกาสกับตัวเรนเดอร์ - ใช้
process.nextTickสำหรับงานที่สามารถเลื่อนออกไปได้ ควรเลือกใช้setImmediateหรือ promise ปกติเมื่อคุณไม่ต้องการความเร่งด่วนระดับ "next-tick" - ทึกทักเอาเองว่า
setTimeout(fn, 0)และsetImmediateสามารถใช้แทนกันได้ ให้ทดสอบลำดับการทำงานภายใน I/O callback หากลำดับขั้นตอนมีความสำคัญ
สรุปประเด็นสำคัญ
Event loop คือตัวจัดตารางเวลา (scheduler) ที่ขึ้นอยู่กับ host เฉพาะเจาะจง ไม่ใช่ฟีเจอร์สากลของ JavaScript เบราว์เซอร์จะผสานการเรนเดอร์ (rendering) เข้ากับ loop ในขณะที่ Node จะแยก I/O ออกเป็นเฟสต่างๆ ของ libuv การใช้กลไกการจัดลำดับความสำคัญอย่างไม่ถูกต้อง เช่น microtasks ในเบราว์เซอร์ หรือ process.nextTick ใน Node อาจทำให้ส่วนประกอบของระบบที่สภาพแวดล้อมนั้นๆ ถูกสร้างมาเพื่อรองรับเกิดสภาวะขาดแคลนทรัพยากร (starvation) ได้ หากคุณปรับรูปแบบการเขียน async ให้สอดคล้องกับโมเดล loop ของ host คุณก็จะสามารถหลีกเลี่ยงทั้งปัญหาหน้าเว็บค้างและเซิร์ฟเวอร์ถูกบล็อกได้เช่นเดียวกัน
