المستوى المطلوب: متوسط — هذا المقال يفترض إنك تعرف JavaScript الأساسي و Node.js و فكرة الـ heap memory بشكل عام، ومش بتكتب لأول مرة في حياتك.
لو Node.js بتاعك بيستهلك 280MB بعد ساعة، وبعد 8 ساعات وصل 3.8GB، والـ traffic ما زادش ولا حتى 10%، إنت مش محتاج سيرفر أكبر. عندك memory leak. الـ Garbage Collector في V8 شغّال صح، بس الكود بتاعك بيمسك references مش لازمة فبيمنعه يحرر الذاكرة. المقال ده بيوريك ليه ده بيحصل، ازاي تكتشفه في 5 دقايق، وازاي تحلّه بـ 6 سطور كود.
Garbage Collection في JavaScript: ليه السيرفر بياكل ذاكرة بدون توقّف
المشكلة باختصار
JavaScript مش بتحمّلك إدارة الذاكرة يدويًا زي C أو Rust. الـ Garbage Collector (اختصارًا GC) بيعمل ده تلقائيًا. الفرضية الأساسية بسيطة: أي object مفيش حد بيشاور عليه = ممكن يتمسح. المشكلة بتبدأ لمّا الكود بتاعك يفضل ماسك مرجع للكائن بدون قصد — الـ GC بيشوفه "حي" وبيسيبه. النتيجة: الذاكرة بتزيد كل دقيقة لحد ما الـ process يقع بـ Out Of Memory الساعة 3 الصبح.
مثال بسيط للمبتدئين: مكتب الموظفين وعامل النظافة
تخيّل معاك شركة فيها 50 موظف، وكل موظف عنده درج أوراق على مكتبه. فيه عامل نظافة بيمر كل ساعة، شغلته إنه يجمع الأوراق اللي محدش طلبها. الموظف اللي رمى ورقة في سلة المهملات، النظافة بياخدها فورًا. بس لو الموظف نسي ورقة على المكتب من غير ما يحتاجها، النظافة هيسيبها — لأن "ممكن حد يحتاجها".
الـ GC في JavaScript بنفس الذكاء ونفس القيد. كل كائن في الـ memory ليه "متابعين": متغيرات، arrays، objects تانية بتشاور عليه. لما المتابعين كلهم يختفوا، الكائن يبقى مرشّح للحذف في الدورة الجاية. بس متغيّر واحد ناسي بيمسك الكائن = الكائن مكانه مكانه طول عمر التطبيق.
التعريف العلمي: ازاي V8 بيشتغل بالظبط
محرّك V8 (نفسه في Chrome و Node.js) بيستخدم خوارزمية Generational Mark-and-Sweep. الـ heap مقسوم لقسمين رئيسيين:
- Young Generation (الـ Scavenger): الكائنات الجديدة بتتحط هنا، وأغلبها بيموت بسرعة (الفرضية اسمها Generational Hypothesis). الـ GC هنا سريع جدًا (~1ms) وبيتم بـ Cheney's Algorithm.
- Old Generation (Mark-Compact): الكائنات اللي عاشت أكتر من دورتين Scavenge بتنتقل هنا. الـ GC هنا أبطأ (10–100ms) لكن بيتم بشكل تدريجي (incremental marking) عشان مايوقفش الـ event loop.
الـ root set هو نقاط البداية اللي الـ GC بيبدأ منها البحث: الـ global scope، stack المحلي، closures نشطة. أي كائن يقدر الـ GC يوصله من الـ root بسلسلة references، يبقى "reachable" — يعني حي. أي كائن مش reachable، يتمسح. الفكرة دي اسمها reachability analysis.
الأنماط الأربعة الشائعة لـ Memory Leak
1. Global Variables بدون قصد
function loadUser(id) {
// ناسي let/const — اتعمل global
cachedUser = fetchUser(id);
return cachedUser;
}
// cachedUser بيفضل في الـ global scope طول عمر التطبيق
الحل: ضيف 'use strict' في أول كل ملف، أو استخدم ES modules. V8 هيرمي ReferenceError بدل ما يخلق متغير global بهدوء.
2. Event Listeners اللي ما بتتشالش
function handleRequest(req) {
emitter.on('data', (chunk) => process(req, chunk));
}
// بعد 10,000 request: 10,000 listener كلهم لسه ماسكين req
الحل: استدعِ emitter.removeListener() بعد ما الشغل يخلص، أو استخدم once() لو الحدث هيحصل مرة واحدة، أو AbortController لإلغاء الـ listener تلقائيًا.
3. Closures بتمسك بيانات كبيرة بدون داعي
function processLargeData() {
const huge = new Array(1_000_000).fill('data'); // ~8MB
const oneItem = huge[42];
return () => oneItem; // الـ closure ماسك huge كله مش oneItem بس!
}
V8 بيحتفظ بالـ scope كاملًا للـ closure، مش بس المتغير المستخدم. الإصلاح:
function processLargeData() {
const huge = new Array(1_000_000).fill('data');
const oneItem = huge[42];
return (() => () => oneItem)(); // scope منفصل، huge بيتحرر
}
4. Maps و Sets بتنمو بدون حدود
const cache = new Map();
function getUserData(user) {
if (!cache.has(user)) {
cache.set(user, fetchData(user));
}
return cache.get(user);
}
الـ Map بيمسك الـ user كـ key بقوة. حتى لو الـ user object نفسه بقى مش مستخدم في أي مكان تاني في التطبيق، الـ Map لوحده بيمنع الـ GC من حذفه. كل request جديد بيضيف entry، صفر entries بتتشال.
الحل الجذري: WeakMap و WeakRef
الـ WeakMap بيمسك references "ضعيفة" — يعني مبتمنعش الـ GC من حذف الـ key. لو الـ user object ما بقاش له references تانية في التطبيق، الـ WeakMap بيشيله من نفسه بدون أي تدخل.
const cache = new WeakMap();
function getUserData(user) {
if (!cache.has(user)) {
cache.set(user, fetchData(user));
}
return cache.get(user);
}
// لما user يخرج من الـ scope، الـ entry بيختفي من cache تلقائيًا
القيد الوحيد: الـ key لازم يكون object، مش string ولا رقم. لو محتاج string keys، استخدم lru-cache بحجم مقيّد، أو Redis لو الـ scale أعلى من instance واحد.
قياس فعلي: قبل وبعد
على API بـ Express يخدم 5,000 طلب/دقيقة، استبدلت Map بـ WeakMap في session cache:
- قبل: استهلاك بدأ 180MB، وصل 3.4GB بعد 6 ساعات، الـ pod بيتقتل بـ OOMKilled في Kubernetes.
- بعد: استهلاك ثبت عند 220–280MB. صفر OOM في 14 يوم إنتاج متواصل.
- تحسّن في GC pause: P99 نزل من 180ms لـ 28ms.
الأرقام مقاسة على cluster Kubernetes بـ 4 pods، بـ process.memoryUsage().heapUsed كل دقيقة، و heap snapshot عبر --inspect كل ساعة.
ازاي تكتشف الـ Leak في 5 دقايق
- شغّل التطبيق بـ
node --inspect server.js(في staging مش إنتاج). - افتح
chrome://inspectفي Chrome، اضغط "inspect" على الـ target. - روح Memory tab، خد Heap snapshot الأول.
- سيب التطبيق يستقبل ترافيك حقيقي 30 دقيقة، خد snapshot تاني.
- قارن بـ "Comparison" view — الـ classes اللي #New بيها رقم كبير هي المشكلة.
- اضغط على object منها وراجع "Retainers" — هيوريك بالظبط مين ماسكه ومنعه من الحذف.
Trade-offs لازم تعرفها
- WeakMap بيكلّف 5–8% CPU زيادة عن Map العادي بسبب الـ weak reference tracking داخل V8. على load عالي جدًا ممكن تلاحظ الفرق في P99.
- Manual GC trigger (
global.gc()مع flag--expose-gc) بيوقف الـ event loop بالكامل. ممنوع في إنتاج إلا لقياس مؤقت. - الـ Heap snapshot بياخد ذاكرة ضعف الـ heap لحظة الالتقاط. لا تاخده على pod بـ 7GB استهلاك يشتغل على instance بـ 8GB — هيقع.
- WeakMap ما بيدعمش
for..ofولا.size. لو محتاج تعمل iterate أو تعرف عدد الـ entries، WeakMap مش الحل.
متى لا تستخدم WeakMap
لو الـ cache بتاعك key الـ string زي user ID رقمي ("12345")، WeakMap مش هينفع — لأن الـ keys في WeakMap لازم objects. استخدم مكتبة lru-cache مع حد أقصى للحجم، أو Redis لو الـ scale عالي. كذلك في تطبيقات short-lived (AWS Lambda، scripts قصيرة، CLI tools) — الـ process عمره ثواني أو دقايق، الـ leak مبيوصلش لمرحلة كارثية أصلًا، فالتعقيد الإضافي مش مبرر.
الخطوة التالية
افتح كود الإنتاج بتاعك ودوّر بـ grep على new Map(). لو لقيت Map بيتعبّى من request handlers، والـ key منهم object بيدخل ويخرج مع كل طلب، حوّله لـ WeakMap في 4 سطور وراقب heapUsed ساعة كاملة. لو الذاكرة نزلت أكتر من 30%، إنت لقيت الـ leak الرئيسي بتاعك.
المصادر
- V8 Blog — Trash talk: the Orinoco garbage collector — v8.dev/blog/trash-talk
- MDN Web Docs — Memory management — developer.mozilla.org
- Node.js Diagnostics — Memory — nodejs.org/en/learn/diagnostics/memory
- ECMAScript Specification (TC39) — WeakRef and FinalizationRegistry — tc39.es/ecma262
- Chrome DevTools Documentation — Record heap snapshots — developer.chrome.com