Kodokon kodokon.com

อีเวนต์ลูปของ Node: เฟสต่าง ๆ และกับดักที่พบบ่อย

มาทำความรู้จักเฟสต่าง ๆ ของ libuv ความแตกต่างที่แท้จริงระหว่าง setImmediate กับ setTimeout และวิธีตรวจจับอีเวนต์ลูปที่ถูกบล็อก

10 นาที · 3 คำถาม

เปิดบทเรียนนี้ใน Kodokon

Node.js มอบหมายอีเวนต์ลูปของมันให้ libuv ซึ่งจัดระเบียบมันออกเป็นเฟส (phase) ที่แยกจากกันชัดเจน ได้แก่ timers (setTimeout, setInterval), pending callbacks, idle/prepare (ภายใน), poll (I/O เช่น ซ็อกเก็ต ไฟล์), check (setImmediate) และ close callbacks ในแต่ละรอบ (tick) ลูปจะเดินผ่านเฟสเหล่านี้ตามลำดับนั้น มีรายละเอียดที่นักพัฒนาน้อยคนจะรู้ นั่นคือ ระหว่างแต่ละคอลแบ็ก Node จะระบายคิว process.nextTick ก่อน แล้วจึงตามด้วยไมโครทาสก์ของ promise และเป็นเช่นนี้มาตั้งแต่ Node 11 คือหลังจากทุกคอลแบ็ก ไม่ใช่แค่ระหว่างเฟสเท่านั้น

JAVASCRIPT
import { readFile } from "node:fs";

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

readFile(new URL(import.meta.url), () => {
  setTimeout(() => console.log("io timeout"), 0);
  setImmediate(() => console.log("io immediate"));
});
ลำดับนี้ถูกรับประกันเฉพาะภายในคอลแบ็ก I/O เท่านั้น

เมื่อสคริปต์เริ่มทำงาน ลำดับระหว่าง setTimeout(fn, 0) กับ setImmediate(fn) นั้น ไม่แน่นอน (non-deterministic) เพราะจริง ๆ แล้ว setTimeout(fn, 0) ถูกบังคับให้เป็นอย่างน้อย 1 มิลลิวินาที และถ้าลูปมาถึงเฟส timers ในเวลาน้อยกว่าหนึ่งมิลลิวินาที ตัวจับเวลานั้นก็ยังไม่พร้อมทำงาน แต่ภายในคอลแบ็ก I/O คุณกำลังอยู่ในเฟส poll ซึ่งเฟส check (setImmediate) จะมาก่อนการกลับไปยังเฟส timers เสมอ ดังนั้น io immediate จึงถูกพิมพ์ก่อน io timeout อย่างสม่ำเสมอ

JAVASCRIPT
setTimeout(() => {
  process.nextTick(() => console.log("nextTick"));
  Promise.resolve().then(() => console.log("promise"));
  queueMicrotask(() => console.log("microtask"));
  console.log("sync");
}, 0);
// sync, nextTick, promise, microtask
ภายในคอลแบ็ก nextTick จะทำงานก่อนไมโครทาสก์

กับดักที่ละเอียดอ่อน คือที่ ระดับบนสุดของ ES module ลำดับนี้จะสลับกัน คือไมโครทาสก์ของ promise จะทำงานก่อน process.nextTick เหตุผลก็คือ การประเมิน ES module นั้นตัวมันเองถูกรันเป็น promise job อยู่แล้ว ดังนั้นคิวไมโครทาสก์จึงกำลังถูกระบายอยู่ตอนที่โค้ดแบบซิงโครนัสของคุณทำงานเสร็จ ส่วนใน CommonJS นั้น nextTick ยังคงลำดับความสำคัญของมันแม้ที่ระดับบนสุด จงตรวจสอบลำดับแบบนี้เสมอในรูปแบบโมดูลที่คุณนำไปใช้งานจริง

อีเวนต์ลูปทำงานบนเธรดเดียว การคำนวณแบบซิงโครนัสที่ใช้เวลานานใด ๆ ไม่ว่าจะเป็น JSON.parse ของ payload ขนาดใหญ่ นิพจน์ปกติ (regular expression) ที่หายนะ หรือการเข้ารหัสแบบซิงโครนัส ล้วนทำให้คำขอที่กำลังดำเนินอยู่ ทั้งหมด ค้าง ในระบบจริง จงวัดความหน่วงของลูปด้วย monitorEventLoopDelay ค่า p99 ที่สูงเกินกว่าไม่กี่สิบมิลลิวินาทีเป็นสัญญาณของการค้าง โค้ดต่อไปนี้จงใจกระตุ้นให้เกิดการค้างแล้วจึงวัดมัน

JAVASCRIPT
import { monitorEventLoopDelay }
  from "node:perf_hooks";

const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();

const end = Date.now() + 200;
while (Date.now() < end) {}

setTimeout(() => {
  h.disable();
  const ms = h.max / 1e6;
  console.log(`max delay: ${ms.toFixed(1)} ms`);
}, 300);
ฮิสโทแกรมเผยให้เห็นการค้าง 200 มิลลิวินาที

ทดสอบความรู้

ตรวจสอบว่าคุณจำประเด็นสำคัญของบทเรียนนี้ได้ครบถ้วน

  1. ภายในคอลแบ็ก I/O (เช่น fs.readFile) คุณตั้งเวลา setTimeout(fn, 0) และ setImmediate(fn) อันไหนทำงานก่อน?
    • setTimeout เสมอ
    • setImmediate เสมอ
    • ลำดับเป็นแบบสุ่ม เหมือนตอนที่สคริปต์เริ่มทำงาน
  2. หลังจากคอลแบ็ก Node ระบายคิวลำดับความสำคัญตามลำดับใด?
    • ไมโครทาสก์ของ promise ก่อน
    • คิวทั้งสองถูกระบายสลับกันอย่างเคร่งครัด
    • คิว process.nextTick ถูกระบายก่อน แล้วจึงตามด้วยไมโครทาสก์
  3. เครื่องมือเนทีฟใดวัดการค้างของอีเวนต์ลูปในระบบจริงได้อย่างแม่นยำ?
    • console.time ครอบแต่ละคำขอ
    • monitorEventLoopDelay จาก node:perf_hooks
    • process.memoryUsage ที่เรียกภายใน setInterval
    • โมดูล node:trace_events