ประสิทธิภาพและ 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()
หัวข้อที่มีชื่อว่า “performance.now()”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');performance.mark() และ performance.measure()
หัวข้อที่มีชื่อว่า “performance.mark() และ performance.measure()”สำหรับการวัดที่มีชื่อและแสดงใน 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 — การเก็บข้อมูลแบบ async
หัวข้อที่มีชื่อว่า “PerformanceObserver — การเก็บข้อมูลแบบ async”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 leaksobs.disconnect();entryTypes ไม่ได้จำกัดแค่ 'measure' เท่านั้น entry ประเภท 'function' มาจาก performance.timerify() ซึ่งห่อฟังก์ชันแล้วส่ง PerformanceEntry ทุกครั้งที่มันถูกเรียก — ไม่ต้องมีคู่ mark/measure เอง:
Output (verified on Node 24)
observed entry types include function: trueentry ประเภท 'gc' ต้องใช้ --expose-gc (เพื่อเรียก global.gc() ตามต้องการ) และสังเกตอัตโนมัติทุกครั้งที่มี garbage collection รอบจริงเกิดขึ้น — มีประโยชน์เวลาเชื่อมโยง latency ที่พุ่งขึ้นกับ GC pause:
node --expose-gc gc-observer.mjsgc entry seen: true// @skip-verify requires --expose-gc, not passed by default hereimport { 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();วัด event loop lag ด้วย monitorEventLoopDelay
หัวข้อที่มีชื่อว่า “วัด event loop lag ด้วย monitorEventLoopDelay”performance.now() วัดงานที่คุณรู้อยู่แล้วว่าจะวัด monitorEventLoopDelay() ตอบคำถามที่ต่างออกไป — “มีอะไรกำลังบล็อก loop อยู่ตอนนี้หรือเปล่า แม้แต่โค้ดที่คุณไม่ได้ควบคุม” — โดย sample ช่องว่างระหว่างรอบของ event loop:
Output (verified on Node 24)
saw event loop delay: true
min <= mean <= max: trueh.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() เท่านั้น
Demo แบบ Live — จับเวลา CPU-bound loop
หัวข้อที่มีชื่อว่า “Demo แบบ Live — จับเวลา CPU-bound loop”Needs the Node.js runtime — open in StackBlitz to run.
การรักษา event loop ไม่ให้ถูกบล็อก
หัวข้อที่มีชื่อว่า “การรักษา event loop ไม่ให้ถูกบล็อก”CPU-bound loop ที่ทำงานนานกว่า ~50 ms จะบล็อก event loop — ไม่มี I/O, ไม่มี timer, ไม่มี request อื่นที่ประมวลผลได้ในระหว่างนั้น
// BAD — blocks the event loop for the entire durationfunction 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 setImmediateasync 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';Profiling ด้วย --cpu-prof และ --heap-prof
หัวข้อที่มีชื่อว่า “Profiling ด้วย --cpu-prof และ --heap-prof”flag เก่าอย่าง --prof/--prof-process ยังใช้ได้อยู่ แต่ flag สมัยใหม่ที่เรียบง่ายกว่าจะเขียนไฟล์ที่คุณลากเข้า panel Performance/Memory ของ Chrome DevTools ได้เลย — ไม่ต้องผ่านขั้นตอนแปลงข้อมูล:
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)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 ที่คุณใช้ทำทุกอย่างอยู่แล้ว
worker_threads สำหรับงานที่ใช้ CPU หนัก
หัวข้อที่มีชื่อว่า “worker_threads สำหรับงานที่ใช้ CPU หนัก”การแบ่งงานด้วย setImmediate (ข้างบน) ทำให้ event loop ตอบสนองได้โดยสลับงานนั้นกับงานอื่น — CPU time รวมที่ใช้เท่าเดิม แค่กระจายออกไป ส่วน Worker ย้ายงานไปยังเธรดอื่นจริง ๆ ทำให้ main thread จัดการ I/O แบบขนานไปด้วยได้ แทนที่จะผลัดกันทำ:
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 เทียบกับ reverse proxy
หัวข้อที่มีชื่อว่า “cluster เทียบกับ reverse proxy”โมดูล cluster ในตัวของ Node fork process ของคุณหลายชุด (โดยทั่วไปหนึ่งชุดต่อหนึ่ง CPU core) ที่แชร์ port การฟังร่วมกัน ทำให้ใช้ multi-core ได้โดยไม่ต้องมี load balancer แยกต่างหาก:
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 ที่กำลังรันอยู่
Profiling loop
หัวข้อที่มีชื่อว่า “Profiling loop”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 แบบจำลองความคิด (Mental Model)
หัวข้อที่มีชื่อว่า “แบบจำลองความคิด (Mental Model)”ทุกเทคนิคในหน้านี้จริง ๆ แล้วตอบคำถามหนึ่งในสองข้อ: “สิ่งนี้ใช้เวลานานแค่ไหน” (performance.now, mark/measure, --cpu-prof) หรือ “มีอะไรกำลังบล็อก loop อยู่ตอนนี้หรือเปล่า ที่ไหนก็ได้ในระบบ” (monitorEventLoopDelay) การ optimize ไม่ใช่สิ่งที่ทำครั้งเดียวจบ — มันคือ loop นี้: วัด หา hot path จริง (ไม่ใช่ตัวที่คุณคิดเอาเอง) แก้ไขสิ่งนั้น แล้ววัดใหม่อีกครั้ง
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ต้นทุน |
|---|---|---|
การแบ่งงานด้วย 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 replica | scale ข้ามเครื่องได้ จัดการ 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มีไว้เพื่อจับ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- โค้ดย้ายงานคำนวณไปยัง
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() เมื่อใช้เสร็จจริง ๆ