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

ประสิทธิภาพและ perf_hooks

“การ optimize ก่อนเวลาอันควรคือต้นตอของความชั่วทั้งปวง” — Donald Knuth

ก่อนที่คุณจะ optimize อะไรก็ตาม ต้องวัดก่อนเสมอ โมดูล perf_hooks ของ Node ให้การจับเวลาแบบ sub-millisecond ผ่าน High Resolution Time API — performance.now() เดียวกับที่คุณรู้จักจาก browser แต่ใช้ได้ใน Node แบบ native และ PerformanceObserver สำหรับการรับ entry แบบ async

performance.now() คืนค่า float ในหน่วย milliseconds ที่มีความแม่นยำระดับ microsecond เป็น monotonic — ไม่เคยถอยหลัง แม้ระบบนาฬิกาจะถูกปรับ:

import { performance } from 'node:perf_hooks';
const start = performance.now();
// --- work to measure ---
let sum = 0;
for (let i = 0; i < 1_000_000; i++) {
sum += i;
}
// ----------------------
const elapsed = performance.now() - start;
console.log('Sum loop took: ' + elapsed.toFixed(3) + ' ms');

สำหรับการวัดที่มีชื่อและแสดงใน DevTools timelines:

import { performance, PerformanceObserver } from 'node:perf_hooks';
performance.mark('db-start');
// ... simulate a DB query ...
await new Promise(function(resolve) { setTimeout(resolve, 50); });
performance.mark('db-end');
performance.measure('database-query', 'db-start', 'db-end');
const entries = performance.getEntriesByName('database-query');
console.log(entries[0].duration.toFixed(2) + ' ms');

PerformanceObserver เรียก callback เมื่อมี performance entry ใหม่ถูกสร้างขึ้น มีประโยชน์สำหรับการวัด library code ที่คุณไม่ได้ควบคุม:

import { PerformanceObserver, performance } from 'node:perf_hooks';
const obs = new PerformanceObserver(function(list) {
for (const entry of list.getEntries()) {
console.log(entry.name + ': ' + entry.duration.toFixed(2) + ' ms');
}
});
obs.observe({ entryTypes: ['measure'] });
performance.mark('a');
// ... work ...
performance.mark('b');
performance.measure('my-operation', 'a', 'b');
// Always disconnect when done to avoid memory leaks
obs.disconnect();

entryTypes ไม่ได้จำกัดแค่ 'measure' เท่านั้น entry ประเภท 'function' มาจาก performance.timerify() ซึ่งห่อฟังก์ชันแล้วส่ง PerformanceEntry ทุกครั้งที่มันถูกเรียก — ไม่ต้องมีคู่ mark/measure เอง:

Node.js
Output (verified on Node 24)
observed entry types include function: true

entry ประเภท 'gc' ต้องใช้ --expose-gc (เพื่อเรียก global.gc() ตามต้องการ) และสังเกตอัตโนมัติทุกครั้งที่มี garbage collection รอบจริงเกิดขึ้น — มีประโยชน์เวลาเชื่อมโยง latency ที่พุ่งขึ้นกับ GC pause:

Terminal window
node --expose-gc gc-observer.mjs
gc entry seen: true
// @skip-verify requires --expose-gc, not passed by default here
import { PerformanceObserver } from 'node:perf_hooks';
const obs = new PerformanceObserver((list) => {
console.log('gc entry seen:', list.getEntries().length > 0);
obs.disconnect();
});
obs.observe({ entryTypes: ['gc'] });
global.gc();

performance.now() วัดงานที่คุณรู้อยู่แล้วว่าจะวัด monitorEventLoopDelay() ตอบคำถามที่ต่างออกไป — “มีอะไรกำลังบล็อก loop อยู่ตอนนี้หรือเปล่า แม้แต่โค้ดที่คุณไม่ได้ควบคุม” — โดย sample ช่องว่างระหว่างรอบของ event loop:

Node.js
Output (verified on Node 24)
saw event loop delay: true
min <= mean <= max: true

h.min/h.mean/h.max/h.percentile(N) เป็นหน่วยนาโนวินาทีทั้งหมด production server ที่ expose h.percentile(99) ไว้บน endpoint สำหรับ health/metrics จะบอกคุณเกี่ยวกับ lag ที่เกิดจากงาน synchronous ใดก็ตามทั่วทั้ง process — โค้ดของคุณ, dependency, แม้แต่ native addon — ไม่ใช่แค่ช่วงที่คุณห่อด้วย performance.mark() เท่านั้น

Node.js

Needs the Node.js runtime — open in StackBlitz to run.

CPU-bound loop ที่ทำงานนานกว่า ~50 ms จะบล็อก event loop — ไม่มี I/O, ไม่มี timer, ไม่มี request อื่นที่ประมวลผลได้ในระหว่างนั้น

// BAD — blocks the event loop for the entire duration
function blockingWork(data) {
const result = [];
for (const item of data) {
result.push(expensiveTransform(item)); // each call takes ~1ms, 10000 items = 10s block
}
return result;
}
// BETTER — yield control periodically with setImmediate
async function nonBlockingWork(data) {
const result = [];
for (let i = 0; i < data.length; i++) {
result.push(expensiveTransform(data[i]));
if (i % 100 === 0) {
// Yield to the event loop every 100 items
await new Promise(function(resolve) { setImmediate(resolve); });
}
}
return result;
}
// BEST for CPU-heavy work — offload to a Worker Thread (see below in this lesson)
import { Worker } from 'node:worker_threads';

flag เก่าอย่าง --prof/--prof-process ยังใช้ได้อยู่ แต่ flag สมัยใหม่ที่เรียบง่ายกว่าจะเขียนไฟล์ที่คุณลากเข้า panel Performance/Memory ของ Chrome DevTools ได้เลย — ไม่ต้องผ่านขั้นตอนแปลงข้อมูล:

Terminal window
node --cpu-prof --cpu-prof-dir=. --cpu-prof-name=demo.cpuprofile src/app.js
(writes demo.cpuprofile to the current directory once the process exits)
Terminal window
node --heap-prof --heap-prof-dir=. --heap-prof-name=demo.heapprofile src/app.js
(writes demo.heapprofile to the current directory once the process exits)

เปิดไฟล์ทั้งสองใน Chrome DevTools (chrome://inspect → “Open dedicated DevTools for Node” → แท็บ Profiler/Memory → Load) เพื่อดู flame graph หรือรายละเอียดการจอง memory โดยไม่ต้องออกจาก browser ที่คุณใช้ทำทุกอย่างอยู่แล้ว

การแบ่งงานด้วย setImmediate (ข้างบน) ทำให้ event loop ตอบสนองได้โดยสลับงานนั้นกับงานอื่น — CPU time รวมที่ใช้เท่าเดิม แค่กระจายออกไป ส่วน Worker ย้ายงานไปยังเธรดอื่นจริง ๆ ทำให้ main thread จัดการ I/O แบบขนานไปด้วยได้ แทนที่จะผลัดกันทำ:

Node.js
Output (verified on Node 24)
worker result: 832040
main thread was free to do other work while this ran

ไฟล์เดียวทำหน้าที่เป็นทั้ง entry point ของ main thread และ script ของ worker ได้ด้วยการแยก branch ตาม isMainThread — new Worker(new URL(import.meta.url), ...) รันไฟล์เดียวกันนี้ซ้ำในเธรดใหม่ และ workerData/parentPort.postMessage() คือช่องทางสื่อสารสองทิศทางเข้า-ออก สำหรับงานจริงให้แยก worker ไปเป็นไฟล์ของตัวเองเมื่อ logic เริ่มยาวเกินไม่กี่บรรทัด

โมดูล cluster ในตัวของ Node fork process ของคุณหลายชุด (โดยทั่วไปหนึ่งชุดต่อหนึ่ง CPU core) ที่แชร์ port การฟังร่วมกัน ทำให้ใช้ multi-core ได้โดยไม่ต้องมี load balancer แยกต่างหาก:

Node.js
Output (verified on Node 24)
primary pid, forking 2 workers
worker running, exiting immediately
worker running, exiting immediately
all workers exited, primary done

ในทางปฏิบัติ production ส่วนใหญ่ใช้ reverse proxy (nginx, cloud load balancer, หรือ container orchestrator ที่รัน N replica) แทนที่จะใช้ cluster โดยตรง: reverse proxy ยังจัดการ TLS termination, health check, rolling restart แบบไม่มี downtime, และ scale ในแนวนอนข้ามเครื่อง ไม่ใช่แค่ core บนเครื่องเดียว — cluster ช่วยแค่ให้ใช้ core ของเครื่องที่คุณมีอยู่แล้วเท่านั้น cluster ยังมีประโยชน์สำหรับ deployment เครื่องเดียว หรือเป็นกลไกที่ process manager (เช่น PM2) ใช้อยู่ข้างใน

สำหรับ load testing ทั้งสองแบบ autocannon (npx autocannon http://localhost:3000) คือเครื่องมือมาตรฐานของ Node ecosystem — มันไม่ได้มาในตัว แต่เป็นวิธีที่ใช้กันทั่วไปในการสร้าง HTTP load แบบ concurrent และดู request/sec กับ latency percentile ของ server ที่กำลังรันอยู่

flowchart LR
  Measure["Measure: performance.now() / monitorEventLoopDelay / --cpu-prof"] --> Identify["Identify the actual hot path"]
  Identify --> Decide{"What kind of cost?"}
  Decide -->|"blocks the loop, moderate"| Chunk["Chunk with setImmediate/await"]
  Decide -->|"blocks the loop, heavy CPU"| Offload["Offload to worker_threads"]
  Decide -->|"single-core limit under load"| Scale["cluster / reverse proxy replicas"]
  Chunk --> Measure
  Offload --> Measure
  Scale --> Measure
วัด หา hot path จริง แก้ไข แล้ววัดใหม่ — อย่าเดา

ทุกเทคนิคในหน้านี้จริง ๆ แล้วตอบคำถามหนึ่งในสองข้อ: “สิ่งนี้ใช้เวลานานแค่ไหน” (performance.now, mark/measure, --cpu-prof) หรือ “มีอะไรกำลังบล็อก loop อยู่ตอนนี้หรือเปล่า ที่ไหนก็ได้ในระบบ” (monitorEventLoopDelay) การ optimize ไม่ใช่สิ่งที่ทำครั้งเดียวจบ — มันคือ loop นี้: วัด หา hot path จริง (ไม่ใช่ตัวที่คุณคิดเอาเอง) แก้ไขสิ่งนั้น แล้ววัดใหม่อีกครั้ง

ตัวเลือกข้อดีต้นทุน
การแบ่งงานด้วย setImmediate/awaitง่าย ไม่ต้องมีเธรดใหม่ งานทั้งหมดยังอยู่บน core เดียวCPU time รวมเท่าเดิม แค่สลับกันทำ — ไม่ได้เพิ่ม throughput
worker_threadsขนานกันจริง main thread ยังตอบสนอง I/O ได้มี overhead ต่อ worker หนึ่งตัว ไม่มีการแชร์ memory เป็นค่าเริ่มต้น ต้องเขียนโค้ดประสานงานเพิ่ม
cluster (multi-process)ใช้ทุก core บนเครื่องเดียว เปลี่ยนโค้ดน้อยแต่ละ worker process มี memory ของตัวเอง (ไม่มี cache ที่แชร์ใน process) ยังเป็นเพดานของเครื่องเดียวอยู่ดี
Reverse proxy + N replicascale ข้ามเครื่องได้ จัดการ TLS/health check/rolling restart ให้ด้วยต้องมี infrastructure ให้รันและดูแล แยกจากโค้ดแอปพลิเคชัน
  • “การแบ่งงานด้วย setImmediate ทำให้งานเร็วขึ้น” — ความจริง: มันทำให้ event loop ตอบสนองมากขึ้นด้วยการสลับงานอื่นเข้ามาคั่น CPU time รวมของงานหนักตัวนั้นไม่ได้ลดลงเลย
  • “Worker thread แชร์ memory กับ main thread โดยอัตโนมัติ” — ความจริง: โดยค่าเริ่มต้น ข้อมูลที่ส่งเข้า/ออกจาก Worker ถูก structured-clone (copy) การแชร์ memory จริง ๆ ต้องใช้ SharedArrayBuffer และ Atomics อย่างชัดเจน
  • “cluster ให้ประโยชน์เหมือนกับ reverse proxy” — ความจริง: cluster ใช้แค่ core บนเครื่องเดียวเท่านั้น มันไม่ได้ช่วยเรื่อง TLS termination, scale ข้ามเครื่อง, หรือ zero-downtime deploy แบบที่ reverse proxy/load balancer ทำได้
  • “performance.now() วัดสิ่งที่ทั้ง server สัมผัส” — ความจริง: มันวัดแค่ช่วงที่คุณห่อด้วย mark/measure หรือ t0/t1 เท่านั้น สิ่งอื่นทั้งหมด (dependency, GC, request อื่น) ยังอาจเป็นต้นตอ latency จริงได้ — นั่นคือสิ่งที่ monitorEventLoopDelay มีไว้เพื่อจับ
  • โค้ดย้ายงานคำนวณไปยัง Worker สำหรับสิ่งที่ใช้เวลาแค่ไมโครวินาทีหรือไม่? overhead ของการสร้างเธรดและ copy ข้อมูลอาจแพงกว่าตัวงานคำนวณเอง
  • benchmark ใช้ Date.now() แทน performance.now() หรือวัดข้าม await โดยไม่นับงานอื่นที่อาจรันแทรกเข้ามาหรือไม่?
  • มีการสร้าง PerformanceObserver และ .observe() แต่ไม่เคย .disconnect() หรือไม่? มันจะรักษา reference ไว้และเก็บ entry ต่อไปตลอดอายุของ process
  • มีการเสนอ “เพิ่ม clustering” เป็นทางแก้เรื่อง scaling โดยไม่ถามด้วยว่า reverse proxy กับหลาย replica (ที่แก้ TLS, health check, และ scale ข้ามเครื่องได้ด้วย) เป็นคำตอบที่ครบถ้วนกว่าหรือไม่?

🐛 หา bug ในโค้ดนี้

import { PerformanceObserver, performance } from 'node:perf_hooks';
function handleRequest(req, res) {
const obs = new PerformanceObserver((list) => {
console.log(list.getEntries()[0].duration);
});
obs.observe({ entryTypes: ['measure'] });
performance.mark('start');
// ... handle the request ...
performance.mark('end');
performance.measure('request', 'start', 'end');
res.end('ok');
}
เฉลย

มีการสร้าง PerformanceObserver ใหม่และ .observe() ทุก request แต่ไม่เคย .disconnect() เลย แต่ละตัวยังฟัง entry 'measure' ใหม่ต่อไปตลอดอายุที่เหลือของ process — หลังจากผ่านไปพันครั้ง observer พันตัวจะทำงาน (และ log) พร้อมกันทุกครั้งที่มีการเรียก performance.measure() ที่ไหนก็ตามใน process ไม่ใช่แค่ตัวที่สร้าง observer นั้นขึ้นมา นี่คือรูปแบบ listener leak: output การ log เพิ่มขึ้นเรื่อย ๆ memory สำหรับ observer ที่ค้างอยู่โตไม่มีขีดจำกัด และระบบช้าลงเรื่อย ๆ ทางแก้: สร้าง observer แค่ครั้งเดียวนอก request handler (หรือใช้ performance.getEntriesByName() แบบ synchronous หลัง measure() แทน observer เลยเมื่อไม่ต้องการเก็บข้อมูลแบบ async) และเรียก .disconnect() เมื่อใช้เสร็จจริง ๆ

performance.now() คืนค่าในหน่วยอะไร?
ทำไม performance.now() ถึงเหมาะสำหรับการ benchmark มากกว่า Date.now()?
CPU-bound loop ที่ทำงานนานทำให้เกิดปัญหาอะไรใน Node.js?
เทคนิคใดที่ yield control กลับให้ event loop เป็นระยะระหว่าง loop ขนาดใหญ่?
โค้ดนี้พิมพ์อะไร (โดยประมาณ) และทำไม? const obs = new PerformanceObserver((list) => { console.log(list.getEntries().length > 0); }); obs.observe({ entryTypes: ['gc'] }); global.gc();
worker_threads Worker คำนวณค่าและเรียก parentPort.postMessage(result) main thread จะเห็นการเปลี่ยนแปลงโดยอัตโนมัติหรือไม่ ถ้า worker mutate object ธรรมดาที่มันจับไว้ใน closure ในภายหลัง?
ทำไม reverse proxy กับ N replica มักจะแทนที่ ไม่ใช่แค่เสริม การใช้ node:cluster เพียงอย่างเดียวใน production?