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 และการเรนเดอร์:

  1. รันหนึ่ง macrotask (เช่น click handler, setTimeout เป็นต้น)
  2. เคลียร์ all microtasks (promises, queueMicrotask)
  3. หากถึงเวลาต้องเรนเดอร์เฟรม ให้ทำการ paint และ composite เพื่อให้ได้ 60 fps ตามเป้าหมาย
  4. ทำซ้ำ

มี 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 ที่แบ่งงานออกเป็นเฟสต่างๆ โดยแต่ละเฟสจะมีคิวเป็นของตัวเอง:

  1. Timers – callback จาก setTimeout และ setInterval
  2. Pending callbacks – I/O callback ที่ถูกเลื่อนออกไปซึ่งเสร็จสิ้นแล้วในระดับ OS
  3. Poll – ดึงเหตุการณ์ I/O ใหม่ๆ (การอ่านไฟล์, ข้อมูลเครือข่าย)
  4. Check – รัน callback ของ setImmediate
  5. 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 คุณก็จะสามารถหลีกเลี่ยงทั้งปัญหาหน้าเว็บค้างและเซิร์ฟเวอร์ถูกบล็อกได้เช่นเดียวกัน