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

V8 & Memory

Garbage collector ของ V8 ไม่ได้ปฏิบัติกับทุก object เหมือนกัน มันแบ่ง heap ออกเป็น space ต่าง ๆ และสองตัวที่สำคัญสำหรับการคิดในชีวิตประจำวันคือ new_space (young generation) กับ old_space (old generation) — นี่คือชื่อเป๊ะ ๆ ที่ v8.getHeapSpaceStatistics() รายงานบน Node 24.13:

flowchart LR
Alloc["new object
allocated"] --> New["new_space
young generation"]
New -->|"Scavenge
survives one collection"| New
New -->|"survives a few Scavenges"| Old["old_space
old generation"]
Old -->|"Mark-Compact
(mark-sweep-compact)"| Old
object อยู่ที่ไหนขณะที่มันรอดจากการเก็บขยะแต่ละครั้ง

object ส่วนใหญ่ตายตั้งแต่ยังเด็ก — array ชั่วคราว, object ที่ผูกกับ request เดียว — และ V8 optimize สำหรับสิ่งนี้: การเก็บขยะแบบ Scavenge รันบ่อยบน new_space ที่เล็ก และถูกมาก เพราะสิ่งที่มันดูส่วนใหญ่เป็นขยะอยู่แล้ว object ที่รอดจาก Scavenge ไปได้สองสามครั้งจะถูก promote ไปที่ old_space ซึ่งถูกเก็บด้วย pass แบบ Mark-Compact (mark-sweep-compact) — ช้ากว่า เพราะต้อง trace object ที่ยังมีชีวิตข้าม space ที่ใหญ่กว่ามาก แต่มันรันน้อยกว่ามาก นี่คือเหตุผลที่ leak ซึ่งแตะแค่ object อายุสั้นเป็นเรื่องหายาก — leak จริงเกือบทุกครั้งหมายความว่ามีบางอย่างถือ reference ที่ทำให้ object รอดเข้าไปใน old_space แล้วไม่เคยถูกปล่อยที่นั่นเลย

Node process ทุกตัว expose memory footprint ปัจจุบันของมันตรง ๆ:

Node.js
Output (verified on Node 24)
rss (total OS memory for this process, MB): 40
heapTotal (V8 heap currently reserved, MB): 10
heapUsed (V8 heap currently live, MB): 0
external (C++ objects bound to JS, e.g. some Buffers, MB): true
arrayBuffers (a subset of external, MB): true

rss (resident set size) คือ process ทั้งหมดตามที่ OS มองเห็น — V8 heap, native code, และทุกอย่างที่เหลือ heapTotal กับ heapUsed คือ JavaScript memory ที่ V8 จัดการเท่านั้น external กับ arrayBuffers คือคู่ที่สำคัญสำหรับใครก็ตามที่ทำงานกับ binary data: memory ของ Buffer ถูก allocate นอก V8 heap และโผล่มาตรงนี้ ไม่ใช่ใน heapUsed — script ที่ allocate แค่ Buffer ก้อนใหญ่สามารถมี heapUsed เล็กนิดเดียวแต่ rss ใหญ่ และมีแค่ external/arrayBuffers เท่านั้นที่จะแสดงมัน

สิ่งเหล่านี้ไม่ใช่เรื่องสมมติ แต่ละอันคือรูปแบบที่โค้ดที่ AI เขียนสร้างออกมาตลอด และแต่ละอันวัดได้ด้วย process.memoryUsage() เพียงอย่างเดียว

1. Closure ที่ครอบ array ที่โตขึ้นเรื่อย ๆ counter, cache, หรือ logger ที่ capture array ไว้ใน closure แล้วมีแต่ push เข้าไปเรื่อย ๆ:

Node.js
Output (verified on Node 24)
heapUsed before (MB): 0
heapUsed after 500,000 tracked events (MB): 140
grew by well over 10 MB with nothing ever freed: true

วิธีแก้แทบทุกครั้งคือใส่ขนาด cap หรือ TTL: จำกัด history.length แล้วทิ้ง entry เก่าสุด หรือแทนที่ด้วยอะไรที่มีขอบเขตตั้งแต่ต้น (ring buffer หรือ metrics library จริงจัง)

2. Listener ที่เพิ่มต่อ request แล้วไม่เคยเอาออก EventEmitter เตือนเรื่องนี้โดยเฉพาะ:

Node.js
Output (verified on Node 24)
listener count after 15 simulated requests: 15
EventEmitter default max listeners: 10
MaxListenersExceededWarning fired: true

MaxListenersExceededWarning ที่ listener เกิน 10 ตัวสำหรับ event เดียวไม่ใช่ noise ที่ปิดเสียงด้วย setMaxListeners(0) — มันคือ Node กำลังบอกว่ามันสงสัยว่านี่คือ leak pattern แบบนี้เป๊ะ ๆ วิธีแก้คือลงทะเบียน listener แค่ครั้งเดียว (นอก path ที่รันต่อ request) หรือเรียก emitter.off()/removeListener() เมื่อ request จบ

3. Map ที่ไม่มีขอบเขตใช้เป็น cache ทุก key ที่ต่างกันกลายเป็น entry ถาวร:

Node.js
Output (verified on Node 24)
cache size: 200000
heapUsed before (MB): 0
heapUsed after (MB): 100

Cache ที่ไม่มีขอบเขตคือ leak ที่มีขั้นตอนเพิ่มมานิดเดียว วิธีแก้คือ cache จริงจัง: LRU ที่มีขนาดสูงสุด, TTL, หรือ (เมื่อ cache เป็นแค่การ optimize ประสิทธิภาพและ value เข้าถึงได้จากที่อื่นอยู่แล้ว) WeakMap ที่ key เป็น object identity เพื่อให้ entry หายไปเองเมื่อไม่มีอะไรอ้างอิง key นั้นแล้ว

ทั้งสามอย่างมีอยู่เพื่อให้ cache หรือ lookup table ถือ reference ได้โดยไม่ต้องเป็นเหตุผลที่ทำให้ object นั้นยังมีชีวิตอยู่:

Node.js
Output (verified on Node 24)
deref while still referenced elsewhere: cached value
typeof deref after dropping the strong reference: object

สังเกตบรรทัดที่สอง: ทันทีหลังจากตัด reference ทิ้ง object นั้นมีโอกาสสูงที่จะยังไม่ถูกเก็บ (GC รันตามตารางของตัวเอง) ดังนั้น deref() ยังคืนมันได้อยู่ — typeof จะเป็น 'object' ทั้งสองกรณี (ถ้าถูกเก็บจริงมันจะเป็น 'undefined' แทน) ความไม่แน่นอนนั้นแหละคือประเด็นทั้งหมด: WeakRef กับ FinalizationRegistry (ซึ่งรัน cleanup callback สักช่วงหนึ่ง หลัง object ถูกเก็บ โดยไม่มีการรับประกันเรื่องเวลา) ถูกต้องเฉพาะสำหรับการทำความสะอาดแบบ best-effort ที่เป็นทางเลือกเท่านั้น — ไม่ใช่สำหรับอะไรก็ตามที่ความถูกต้องของโปรแกรมคุณต้องพึ่งพา WeakMap/WeakSet คือกรณีทั่วไปที่หลีกเลี่ยงความคลุมเครือนี้ไปเลย: ใช้มันตรง ๆ เป็น cache ที่ key คือ object identity แล้วปล่อยให้ entry หายไปเองเมื่อไม่มีอะไรถือ key นั้นอีก โดยไม่ต้องจัดการ WeakRef เองเลย

การจำกัด heap โดยตั้งใจคือวิธีหา leak ก่อนที่ production จะเจอมัน รัน closure-leak pattern ข้างบน แต่รันตลอดไป กับ heap limit เล็ก ๆ:

Terminal window
node --max-old-space-size=48 leaky-forever.js
<--- Last few GCs --->
[pid] Mark-Compact (reduce) ~38 (46) -> ~38 (46) MB, external memory pressure; GC in old space requested
[pid] Mark-Compact (reduce) ~38 (46) -> ~38 (46) MB, external memory pressure; GC in old space requested
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
----- Native stack trace -----
... (omitted -- V8/libuv internal frames)
$ echo $?
134

--max-old-space-size (หน่วย MB) จำกัด old_space โดยเฉพาะ “Ineffective mark-compacts near heap limit” หมายความว่า V8 รัน Mark-Compact ซ้ำ ๆ ปล่อยคืนแทบไม่ได้เลยแต่ละครั้ง แล้วยอมแพ้แทนที่จะวนไปเรื่อย ๆ — รูปแบบนี้ (ขนาด heap แทบไม่เปลี่ยนตลอด Mark-Compact ติดต่อกัน ก่อน crash) คือลายเซ็นของ leak จริง ตรงข้ามกับ process ที่แค่ใช้ memory เยอะด้วยเหตุผลที่ถูกต้อง

--heap-prof เขียนไฟล์ .heapprofile (โหลดได้ใน tab Memory ของ Chrome DevTools) โดย sample การ allocate ตลอดการรัน ซึ่งเป็นวิธีเปลี่ยนจาก “มี leak” ไปเป็น “function นี้แหละคือที่มาของการ allocate ที่โตขึ้นเรื่อย ๆ”:

Terminal window
node --heap-prof allocate-a-lot.js
ls *.heapprofile
Heap.<timestamp>.<pid>.0.001.heapprofile

ลองนึกภาพ heap เป็นสองห้อง ห้องหน้า (new_space) เล็กและถูกกวาดสะอาดตลอดเวลา — ของส่วนใหญ่ที่ทิ้งไว้ที่นั่นเป็นขยะไปแล้วตอนที่การกวาดมาถึง ดังนั้นการกวาดมันจึงเร็ว อะไรก็ตามที่รอดจากการกวาดไปสองสามรอบจะถูกย้ายไปห้องหลัง (old_space) ซึ่งถูกทำความสะอาดเป็นครั้งคราวเท่านั้น ด้วย pass ที่ช้ากว่าและละเอียดกว่าที่ต้องตรวจทุกชิ้นเทียบกับ inventory ที่ใหญ่กว่ามาก leak ไม่ใช่ “ของในห้องหน้าเยอะเกินไป” — มันคือบางอย่างในห้องหลังที่ส่วนที่ถูกต้องของโปรแกรมยังถือ reference อยู่ ทำให้ pass ทำความสะอาดที่ละเอียดนั้นเว้นมันไว้ถูกต้องทุกครั้งไป

Generational GC คือการพนัน: object ส่วนใหญ่ตายตั้งแต่เด็ก ดังนั้นการจ่ายแทบไม่มีต้นทุนเพื่อตรวจ young generation ตลอดเวลา แล้วจ่ายมากขึ้นเพื่อตรวจ old generation นาน ๆ ครั้ง ชนะโดยเฉลี่ย มันแพ้เมื่อ pattern การ allocate จริงของโปรแกรมไม่ตรงกับการพนันนั้น — เช่น อะไรที่ allocate object อายุปานกลางจำนวนมหาศาลอาจจบด้วยการ promote มากกว่าปกติ ทำให้ Mark-Compact รันบ่อยกว่า workload ทั่วไป --max-old-space-size แลกเพดานที่แน่นอน (failure mode ที่คาดเดาได้: OOM crash ที่คุณดักจับใน monitoring ได้) กับความเสี่ยงที่จะ crash process ที่อาจฟื้นตัวเองได้ การปล่อยไว้ที่ค่า default แลกความแน่นอนนั้นกับ process ที่โดยหลักการแล้วโตไปเรื่อย ๆ จนกว่า OS จะฆ่ามันด้วยเหตุผลที่คลุมเครือกว่ามาก

  • “heapUsed คือ memory usage ทั้งหมดของ process” มันคือ JS heap ของ V8 เท่านั้น Buffer และข้อมูลที่ backed ด้วย C++ นอก heap อื่น ๆ โผล่ใน external/arrayBuffers; ยอดรวมระดับ OS คือ rss process ที่ allocate แค่ Buffer ก้อนใหญ่สามารถดูโอเคตาม heapUsed แต่ยังใช้ memory เยอะอยู่
  • “WeakRef รับประกันว่า object จะถูกเก็บ” มันรับประกันทิศตรงข้ามเท่านั้น: การถือ WeakRef ไม่เคยป้องกันการเก็บขยะ ส่วนว่าจะถูกเก็บเมื่อไรและถูกเก็บหรือไม่ขึ้นอยู่กับตารางของ GC เองทั้งหมด
  • “--max-old-space-size ที่ใหญ่ขึ้นแก้ memory leak ได้” มันแค่เลื่อนเวลา crash ออกไป — อัตราการโตไม่เปลี่ยน ดังนั้น leak จริงยังไงก็ไปถึงเพดานใดก็ตามในที่สุด วิธีแก้ที่ถูกต้องคือหาว่าอะไรถือ reference อยู่ ไม่ใช่ยกเพดานขึ้น

โค้ด caching ที่ AI สร้างเป็นแหล่งบั๊กประเภทนี้บ่อย เพราะ “cache ผลลัพธ์ไว้” เป็น pattern ที่สร้างง่าย และ “จำกัดขอบเขตของ cache” ก็ตกหล่นง่ายโดยที่โค้ดยังดูถูกต้อง

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

// AI-written response cache for an API client
const responseCache = new Map();
async function getWithCache(url) {
if (responseCache.has(url)) return responseCache.get(url);
const data = await fetch(url).then((r) => r.json());
responseCache.set(url, data);
return data;
}
เฉลย

ไม่มีการ evict ที่ไหนเลย: responseCache โตขึ้นทีละ entry ต่อ URL ที่ต่างกันทุกตัวที่เคยถูก request ตลอดอายุของ process โดยไม่มีขนาดสูงสุดและไม่มี TTL สำหรับ server ที่รันนานและถูกยิงด้วย URL ที่ต่างกันจำนวนมาก (รวมถึงที่มี query string หรือ user id ฝังอยู่) นี่คือ leak pattern แบบ “Map cache ที่ไม่มีขอบเขต” จากบทนี้ ซึ่งพิสูจน์ข้างบนแล้วว่าโตไปถึงหลักร้อย MB ในสเกลจริงได้ วิธีแก้คือ cache ที่มีขอบเขต — LRU ที่มีจำนวน entry สูงสุด, TTL ต่อ entry, หรือถ้าข้อมูลเก่าเก็บไว้ไม่ได้เลย ก็ไม่ต้องมี cache เลยดีกว่ามีแบบไม่มีขอบเขต

Object ที่เพิ่งถูก allocate และอายุสั้น เริ่มต้นอยู่ใน heap space ไหนของ V8?
Script นี้รันโค้ดนี้ใน loop และไม่แตะ JS object ปกติเลย: คุณควรคาดหวังจะเห็นอะไรจาก process.memoryUsage()?
const buf = Buffer.allocUnsafe(10 * 1024 * 1024); // 10 MB, repeated many times
"Ineffective mark-compacts near heap limit" ใน OOM crash บ่งชี้อะไร?
ทำไม --max-old-space-size ถึงไม่แก้ memory leak แค่ปิดบังมันเท่านั้น?
Cache ที่ AI เขียนนี้ไม่เคยลบ entry ออกเลย: วิธีแก้ที่ถูกต้องคืออะไร?
const responseCache = new Map();
async function getWithCache(url) {
  if (responseCache.has(url)) return responseCache.get(url);
  const data = await fetch(url).then((r) => r.json());
  responseCache.set(url, data);
  return data;
}