الأدلة التقنية

الذاكرة للمبرمجين: كيف تعمل ال Stack وال Heap

لماذا تتغير قائمة لم تطلب تغييرها، وكيف تتسرب الذاكرة في لغة فيها جامع قمامة Garbage Collector، بأمثلة حقيقية من عدة لغات.

الذاكرة للمبرمجين: كيف تعمل ال Stack وال Heap

كل متغير تكتبه وكل كائن تنشئه بالكلمة new يحتاج مكاناً في الذاكرة، ومعظم الوقت لا تفكر في هذا المكان لأن اللغة تتولاه عنك، إلى أن يظهر خطأ لا تفهمه: دالة غيرت قائمة لم تطلب منها أن تغيرها، أو برنامج انهار برسالة Stack overflow، أو خدمة يكبر استهلاكها للذاكرة كل يوم حتى يعاد تشغيلها في منتصف الليل، رغم أن اللغة «فيها جامع قمامة». وكل هذه المشاكل تفهمها بسهولة إذا عرفت أربعة أشياء: أين تعيش البيانات في ذاكرة البرنامج، وما الفرق بين ال Stack وال Heap، وما الذي ينسخه السطر b = a فعلاً، ومتى يقرر جامع القمامة أن الكائن لم يعد مطلوباً.

وهذا المقال من مرحلة أساسيات علوم الحاسب Computer Science Fundamentals في خارطة عرب رووت، ويكمل من حيث توقف مقالان فيها: مقال Hello World من الداخل الذي رأينا فيه خريطة ذاكرة البرنامج الحقيقية والذاكرة الافتراضية وهرم الذاكرة، ومقال الأنظمة العددية للمبرمجين الذي شرح البايتات وعناوين الـ Hex وأحجام الأنواع، فلن نعيد شرحها هنا. وسوف نناقش في هذا المقال ما يلي:

  • ترتيب ذاكرة البرنامج: الكود والبيانات وال Heap وال Stack.
  • ال Stack وإطارات الدوال Stack Frames، ولماذا ينهار البرنامج عند التكرار الذاتي Recursion العميق.
  • ال Heap ومن يحرر الكائنات فيه: أنت، أو جامع القمامة Garbage Collector، أو المترجم كما في Rust.
  • أنواع القيمة Value Types وأنواع المرجع Reference Types في C# وJava وPython وJavaScript، وماذا يحدث عند تمرير متغير إلى دالة.
  • المؤشرات Pointers في C وفي C# بالكود unsafe، والفرق بينها وبين المراجع References.
  • جامع القمامة بالأجيال Generational GC في .NET وJava وV8، وعد المراجع Reference Counting في Python.
  • تسرب الذاكرة Memory Leak في لغة فيها جامع قمامة، بتجربة حقيقية في ثلاث لغات.

أين تعيش هذه البيانات فعلاً؟ الذاكرة RAM باختصار

كل ما سوف نتحدث عنه في هذا المقال يعيش في النهاية في وحدات الذاكرة العشوائية RAM المركبة في جهازك، وهي شرائح DRAM تخزن كل بت كشحنة صغيرة في مكثف Capacitor تتسرب مع الوقت، ولذلك تحتاج إلى تحديث Refresh دوري وتفقد كل ما فيها عندما ينقطع التيار. ولكن برنامجك لا يرى هذه الشرائح أبداً، وإنما يرى عناوين افتراضية Virtual Addresses يترجمها المعالج ونظام التشغيل إلى أماكن حقيقية، ولذلك يكفيك هنا أن تعرف أنها موجودة. وإذا أردت أن ترى الوحدة من الخارج والداخل، من الشريحة إلى البنوك Banks إلى الخلية الواحدة، ودورة القراءة وتصحيح الأخطاء ECC وأجيال DDR والقنوات Channels، فقد خصصنا لها مقالاً مستقلاً في مرحلة العتاد هو كيف تعمل الذاكرة RAM من الداخل.

أين يعيش برنامجك في الذاكرة؟

عندما يشغل نظام التشغيل برنامجك فإنه يعطيه مساحة عناوين افتراضية Virtual Address Space خاصة به، ويقسمها إلى مناطق Regions لكل منها وظيفة وصلاحيات مختلفة، وقد رأينا هذه المناطق حقيقية في ملف /proc/<pid>/maps في مقال Hello World. والصورة التالية تبين الترتيب التقليدي لهذه المناطق في عملية واحدة:

عمود لمساحة العناوين من العناوين الصغيرة في الأسفل إلى الكبيرة في الأعلى: منطقة الكود text ثم BSS والبيانات ثم Heap يكبر للأعلى ثم مساحة فارغة ثم المكتبات المشتركة ثم مساحة فارغة ثم Stack يكبر للأسفل ثم مساحة النواة، وبجانب كل منطقة ما الذي يعيش فيها
كل منطقة لها وظيفة وصلاحيات، وال Heap وال Stack هما المنطقتان اللتان تتغيران أثناء التشغيل
  • الكود Text: تعليمات البرنامج نفسها بلغة الآلة، وهي للقراءة والتنفيذ فقط، فلا يستطيع البرنامج أن يكتب فوق كوده.
  • البيانات Data وBSS: المتغيرات العامة Global والثابتة Static، وتبقى طوال عمر البرنامج، والفرق أن Data للمتغيرات التي لها قيمة أولية وBSS للتي تبدأ بالصفر.
  • ال Heap: الذاكرة التي يطلبها البرنامج أثناء التشغيل بحجم لا يعرف مسبقاً، مثل الكائنات التي تنشئها بـ new أو malloc، وتكبر نحو العناوين الأكبر.
  • ال Stack: ذاكرة الدوال التي تعمل الآن، ومتغيراتها المحلية، وعنوان العودة لكل دالة، وتكبر نحو العناوين الأصغر، ولكل خيط Thread في البرنامج Stack خاص به كما شرحنا في مقال المعالج والخيوط.

ولاحظ أن الصورة تبين الفكرة وليس العناوين الحقيقية، فنظام التشغيل يغير أماكن هذه المناطق عشوائياً في كل تشغيل، وهذه الحماية اسمها Address Space Layout Randomization واختصاراً ASLR، والسبب أنها تصعب على المخترق أن يعرف مسبقاً أين يوجد الكود أو ال Stack. وقد رأينا ذلك في تجربتنا، فعنوان المتغير نفسه كان 0x1B7057E248 في تشغيل و0x433ABDE358 في التشغيل التالي.

ال Stack: ذاكرة الدالة التي تعمل الآن

ال Stack أو المكدس هو منطقة تعمل بمبدأ آخر من يدخل أول من يخرج Last In, First Out، تماماً مثل كومة الأطباق، فعندما تستدعي دالة يضاف لها إطار Stack Frame فوق الإطارات السابقة، فيه معاملاتها Parameters ومتغيراتها المحلية Local Variables وعنوان العودة Return Address، أي المكان الذي يكمل منه البرنامج بعد أن تنتهي. وعندما تعود الدالة يزال إطارها بالكامل في خطوة واحدة، فلا يحتاج أحد أن يبحث عما يجب تحريره.

وهذا يعطي ال Stack ثلاث خصائص يجب أن تعرفها:

  • سريع جداً: الحجز فيه مجرد تحريك لمؤشر واحد في المعالج اسمه مؤشر ال Stack، وهو المسجل rsp على x86-64، والبيانات فيه متجاورة وقريبة من بعضها فتبقى غالباً في ال Cache.
  • عمره عمر الدالة: المتغير المحلي يختفي عندما تعود الدالة، فلا يمكنك أن تحتفظ بشيء في ال Stack بعد أن تنتهي الدالة التي أنشأته.
  • حجمه محدود وثابت: كل خيط يحصل على Stack بحجم محدد عند إنشائه، فإذا استدعت دالة نفسها دون نهاية، أو بعمق كبير جداً، امتلأ ال Stack وانهار البرنامج، وهذا هو الخطأ المشهور Stack Overflow الذي أخذ منه الموقع المعروف اسمه.

وسلوك كل لغة عند امتلاء ال Stack مختلف، وقد جربنا الدالة نفسها التي تستدعي نفسها دون نهاية في ثلاث لغات. ففي Python توقفت عند حد العمق الذي يحدده sys.getrecursionlimit() وهو 1000 افتراضياً، ورمت استثناء يمكنك التقاطه:

recursion limit: 1000
RecursionError: maximum recursion depth exceeded

وفي Node.js رمى V8 استثناء يمكن التقاطه كذلك:

RangeError: Maximum call stack size exceeded

أما في .NET فالأمر مختلف تماماً، فـ StackOverflowException لا يمكن التقاطها بـ try/catch وتنهي العملية كلها كما في التوثيق، وهذه آخر أسطر المخرج عند تشغيل dotnet run memory.cs -- overflow:

recursing...
Stack overflow.
Repeated 24063 times:
--------------------------------
   at Program.<<Main>$>g__Recurse|0_5(Int32)
--------------------------------
   at Program.<Main>$(System.String[])

ولاحظ أن .NET طبع اسم الدالة التي تكررت وعدد مرات التكرار في المخرج، 24063 مرة في هذا التشغيل، وهذا أول ما تبحث عنه في السجلات إذا اختفت خدمة .NET فجأة دون أي استثناء في سجلات التطبيق. والدرس العملي أن التكرار الذاتي على بيانات لا تتحكم في عمقها، مثل شجرة JSON يرسلها المستخدم أو تعليقات متداخلة، يجب أن يكون له حد، أو يكتب بحلقة وStack تديره أنت في ال Heap.

ال Heap: ذاكرة الكائنات التي تعيش أطول

المشكلة في ال Stack أن كل شيء فيه يموت مع الدالة، ولكن البرامج الحقيقية تحتاج بيانات تعيش أطول من الدالة التي أنشأتها، مثل طلب Order تنشئه دالة وتحفظه في قائمة وتستخدمه دالة أخرى بعد دقائق، أو بيانات لا يعرف حجمها إلا أثناء التشغيل، مثل ملف يرفعه المستخدم. وهذا هو دور ال Heap، فهو منطقة كبيرة يحجز فيها البرنامج قطعة بالحجم الذي يحتاجه في أي وقت، وتبقى القطعة محجوزة حتى يقرر أحد أنه لم يعد يحتاجها. والصورة التالية تبين العلاقة بين الاثنين:

على اليسار ثلاثة إطارات في ال Stack هي Main ثم Checkout ثم Sum وهو الإطار الحالي، وفي كل إطار متغيرات بعضها يحمل قيمة مثل 3 و120.50 و2 وبعضها يحمل مرجعاً، وعلى اليمين ال Heap فيه كائن Order يشير إلى List وهي تشير إلى ثلاثة كائنات Item، وكائن string رمادي لا يشير إليه أحد مكتوب عليه garbage
المتغيرات المحلية في ال Stack، والكائنات في ال Heap، والكائن الذي لا يصل إليه أي مرجع يصبح قمامة

في الصورة أعلاه لاحظ التالي:

  • المتغير count يحمل القيمة 3 نفسها داخل الإطار، بينما المتغير order يحمل عنواناً فقط، والكائن نفسه في ال Heap.
  • الإطاران Main وCheckout يشيران إلى الكائن نفسه، فإذا غيرت Checkout شيئاً في الطلب فسوف تراه Main بعد العودة.
  • عندما تعود Sum يختفي إطارها، ولكن ال List تبقى لأن الطلب ما زال يشير إليها.
  • الكائن الرمادي لا يصل إليه أي مرجع، فلا يستطيع البرنامج استخدامه مرة أخرى، وهذا تعريف القمامة Garbage.

والسؤال المهم هنا: من يحرر القطعة من ال Heap عندما لا يحتاجها البرنامج؟ هناك ثلاث إجابات، وكل لغة اختارت واحدة منها، وقد لخصناها في جدول مقارنة اللغات:

  1. أنت، يدوياً: كما في C، تحجز بـ malloc وتحرر بـ free، وكل خطأ في ذلك خطأ في البرنامج، وسوف نرى أمثلة عليه في قسم المؤشرات.
  2. جامع القمامة Garbage Collector: كما في C# وJava وJavaScript وGo وPython، برنامج داخل ال Runtime يبحث عن الكائنات التي لم يعد يصل إليها أحد ويحررها.
  3. المترجم بقواعد الملكية Ownership: كما في Rust، حيث يقرر المترجم قبل التشغيل أين يحرر كل كائن.

نوع القيمة ونوع المرجع: ماذا ينسخ السطر b = a؟

هذا القسم هو مصدر أكثر الأخطاء التي يقع فيها المبتدئ والمحترف في يوم متعب: دالة تعدل قائمة لم تكن تقصد تعديلها، أو تعديل لا يظهر رغم أنك متأكد أنك قمت به. والسبب أن هناك نوعين من المتغيرات:

  • نوع القيمة Value Type: المتغير يحمل القيمة نفسها، فإذا نسخته إلى متغير آخر نسخت القيمة كاملة، وكل منهما مستقل عن الآخر.
  • نوع المرجع Reference Type: المتغير يحمل مرجعاً Reference، أي عنواناً يشير إلى الكائن في ال Heap، فإذا نسخته نسخت العنوان فقط، فيصل المتغيران إلى الكائن نفسه، وكل تعديل من أحدهما يراه الآخر.

وهذا التعريف بالنص في توثيق C#: متغير نوع القيمة يحتوي نسخة من النوع، ومتغير نوع المرجع يحتوي مرجعاً إليها. والصورة التالية تبين النتيجة الحقيقية من التجربة، حيث أنشأنا struct وclass بنفس الحقل ثم نسخنا كلاً منهما وغيرنا النسخة:

على اليسار متغيران p1 وp2 من نوع struct كل منهما صندوق مستقل، p1 فيه X = 1 وp2 فيه X = 99 بعد النسخ والتعديل، وعلى اليمين متغيران c1 وc2 من نوع class يشيران بسهمين إلى كائن واحد فيه X = 99، وفي الأسفل جدول بما ينسخ وما يشترك في C# وJava وJavaScript وPython
نوع القيمة ينسخ القيمة كاملة، ونوع المرجع ينسخ العنوان فيصل الاسمان إلى الكائن نفسه

وكل لغة تقسم أنواعها بطريقة مختلفة قليلاً:

  • C#: الأرقام وbool وstruct وenum أنواع قيمة، وclass والمصفوفات وstring وما شابهها أنواع مرجع، ولاحظ أن string نوع مرجع ولكنه غير قابل للتعديل Immutable، فيتصرف كأنه قيمة.
  • Java: مواصفة اللغة تقسم الأنواع إلى أنواع أساسية Primitive Types مثل int وdouble وboolean، وأنواع مرجع لكل ما عداها، بما فيها المصفوفات وString وInteger.
  • JavaScript: القيم الأساسية Primitives مثل number وstring وboolean وbigint تنسخ، والكائنات والمصفوفات والدوال تشترك بالمرجع، ولنسخ كائن نسخة مستقلة كاملة استخدم structuredClone.
  • Python: كل شيء كائن، وكل اسم مرجع إلى كائن كما في نموذج البيانات، فلا يوجد نوع قيمة أصلاً. ولكن بعض الكائنات غير قابلة للتعديل مثل int وstr وtuple، فإذا كتبت y += 1 لا يتغير الكائن وإنما يشير y إلى كائن جديد، ولذلك تبدو الأرقام كأنها تنسخ، بينما list وdict قابلة للتعديل وتشترك بين كل الأسماء التي تشير إليها.

وقد يتساءل البعض: إذا مررت قائمة إلى دالة، هل تمر بالقيمة أم بالمرجع؟ والإجابة في أغلب هذه اللغات أن كل المعاملات تمرر بالقيمة By Value، ولكن «القيمة» في نوع المرجع هي المرجع نفسه. ولذلك في تجربة C# أضافت الدالة AddItem(list) عنصراً ظهر في القائمة الأصلية لأنها عدلت الكائن نفسه، بينما الدالة ReplaceList(list) التي كتبت l = new List<int>() لم تغير شيئاً خارجها، لأنها غيرت نسختها المحلية من المرجع فقط، وبقي Count=2. والنتيجة نفسها ظهرت في Python مع add_item وreplace. وإذا أردت فعلاً أن تغير المتغير الأصلي من داخل الدالة ففي C# الكلمة ref، كما رأينا مع AddOneRef(ref n) التي غيرت n من 5 إلى 6.

💡
العبارة الشائعة «أنواع القيمة في ال Stack وأنواع المرجع في ال Heap» تقريب مفيد للبداية وليست قاعدة، فحقل int داخل كائن من نوع class يعيش في ال Heap مع الكائن، والمترجم قد يضع كائناً من نوع مرجع في ال Stack إذا أثبت أنه لا يخرج من الدالة، وهذا ما يسمى تحليل الهروب Escape Analysis، وقد وسعته .NET 10 كما في ما الجديد في Runtime. الفرق الذي يجب أن تعتمد عليه في الكود هو معنى النسخ، أي هل ينسخ السطر b = a القيمة أم المرجع، وليس مكان التخزين.

المؤشرات Pointers والمراجع References

المؤشر Pointer متغير قيمته عنوان في الذاكرة، أي رقم مثل 0x1B7057E248 يدل على مكان متغير آخر، ومن خلاله تستطيع أن تقرأ ذلك المتغير أو تكتب فيه. وهذه هي الطريقة التي تعمل بها لغة C مباشرة، والمثال التالي يبين الأساسيات، ولم نشغله في هذه التجربة لأن الجهاز لا يحتوي على مترجم C، وقد بنينا وشغلنا برنامج C كاملاً بـ gcc في مقال Hello World:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void) {
    int a = 42;
    int *p = &a;                 /* p holds the address of a */
    *p = 7;                      /* writes a through the pointer */
    printf("a=%d p=%p\n", a, (void *)p);

    char *name = malloc(16);     /* 16 bytes on the heap */
    if (name == NULL) return 1;
    strcpy(name, "ArabRoot");    /* 8 letters + the final '\0' = 9 bytes */
    printf("%s\n", name);
    free(name);                  /* nobody frees it for you */
    name = NULL;                 /* so it cannot be used after free */
    return 0;
}

في الكود أعلاه لاحظ التالي:

  • &a تعني عنوان a، وint *p تعني أن p مؤشر إلى int، و*p تعني القيمة الموجودة في العنوان الذي يحمله p.
  • malloc(16) تحجز 16 بايتاً في ال Heap وترجع عنوان أولها، أو NULL إذا فشلت، ولا يوجد أي شيء يحرر هذه البايتات إلا free.
  • بعد free يبقى العنوان نفسه في المتغير name، ولذلك نكتب name = NULL، لأن استخدام المؤشر بعد التحرير خطأ لا ينبهك إليه المترجم.

والصورة التالية تبين نفس الفكرة بالعناوين الحقيقية التي طبعتها تجربة C# في الكود unsafe، حيث تسمح C# بالمؤشرات داخل كتلة unsafe فقط وبعد تفعيل الخاصية AllowUnsafeBlocks:

في الأعلى المتغير p بحجم 8 بايتات يحمل العنوان 0x1B7057E248 وسهم منه إلى أربع خانات للمتغير a فيها 2A 00 00 00 أي 42، وتحت ذلك معنى *p و&a، وفي الأسفل مصفوفة من ثلاث خانات 10 و20 و30 في العناوين DE40 وDE44 وDE48 مع arr وarr + 1 وarr + 2، وعلامة حمراء على arr + 3 خارج المصفوفة
المؤشر رقم يحمل عنواناً، والجمع عليه يتحرك بحجم النوع، ولا يمنعك أحد من الخروج خارج المصفوفة

ولاحظ في الصورة أن a مخزنة كـ 2A 00 00 00، أي 42 بترتيب Little-endian الذي شرحناه في مقال الأنظمة العددية، وأن arr + 1 لم يضف بايتاً واحداً إلى العنوان وإنما 4 بايتات، لأن المؤشر من نوع int، وهذا ما يسمى حساب المؤشرات Pointer Arithmetic. ولاحظ أيضاً أن لا شيء يمنعك من كتابة *(arr + 3) أو *(arr + 1000)، فالمؤشر سوف يقرأ أو يكتب أي ذاكرة يصل إليها دون أن يعرف أنها ليست لك، وهذا هو مصدر أخطر فئة من الثغرات في البرمجيات:

  • تجاوز حدود الذاكرة Buffer Overflow: كتابة أو قراءة بعد نهاية المصفوفة، فتفسد بيانات أخرى أو تقرأ بيانات لا تخصك.
  • الاستخدام بعد التحرير Use After Free: استخدام مؤشر بعد free، والذاكرة ربما أعطيت لكائن آخر.
  • التحرير مرتين Double Free ونسيان التحرير الذي يسبب تسرب الذاكرة.

وهذه ليست مشاكل نظرية، فمشروع Chromium يذكر في صفحة أمان الذاكرة أن نحو 70% من ثغراته الأمنية عالية الخطورة هي مشاكل في أمان الذاكرة، أي أخطاء في مؤشرات C وC++، ونصفها تقريباً من نوع الاستخدام بعد التحرير.

🩸
في أبريل 2014 أعلنت ثغرة Heartbleed في مكتبة OpenSSL، وهي المكتبة التي كانت تحمي اتصالات HTTPS في عدد كبير من السيرفرات، والسبب كما في وصف CVE-2014-0160 أن الإصدارات من 1.0.1 حتى 1.0.1f لا تتعامل بشكل صحيح مع رسائل Heartbeat، ونشرة OpenSSL الأمنية تصف الخطأ بأنه فحص حدود ناقص Missing Bounds Check، فكان المخترق يرسل رسالة صغيرة ويدعي أن طولها كبير، فتنسخ المكتبة من الذاكرة بالطول الذي ادعاه وترجعه له، وهذه قراءة بعد حدود الذاكرة Buffer Over-read، وبهذه الطريقة أمكن قراءة المفاتيح الخاصة للسيرفرات. وبحسب صفحة الثغرة كان الخطأ موجوداً منذ الإصدار 1.0.1 في مارس 2012 حتى أصلحه الإصدار 1.0.1g في 7 أبريل 2014، وكل طلب واحد يكشف حتى 64 كيلوبايت، ويمكن تكرار الطلب بلا حد. والدرس أن سطراً واحداً ينسى التحقق من الطول في لغة فيها مؤشرات قد يكشف ذاكرة السيرفر كاملة.

أما المرجع Reference في C# وJava وJavaScript وPython فهو مؤشر «آمن»، فهو أيضاً يحمل مكان الكائن، ولكن اللغة لا تسمح لك بأن تعرف العنوان أو تجمع عليه أو تشير به إلى مكان عشوائي، ولا يمكن أن يشير إلى كائن محرر لأن جامع القمامة لا يحرر كائناً ما زال أحد يشير إليه. وبالمقابل يستطيع جامع القمامة أن ينقل الكائنات في الذاكرة ليرتبها، ثم يحدث كل المراجع إليها دون أن تشعر، وهذا مستحيل مع مؤشر حقيقي يحمل رقماً، ولهذا تستخدم C# الكلمة fixed لتثبيت الكائن عندما تأخذ مؤشراً إليه، والذاكرة التي تحجزها stackalloc في ال Stack لا يحركها أحد أصلاً. والقاعدة العملية: لا تستخدم unsafe في كود التطبيقات العادية، فقد كتبناها هنا لنرى العناوين فقط.

جامع القمامة Garbage Collector: كيف يعرف ما الذي لم تعد تحتاجه؟

جامع القمامة لا يعرف ما الذي «تحتاجه»، وإنما يعرف شيئاً أبسط: ما الذي يمكن الوصول إليه Reachable. فهو يبدأ من الجذور Roots، أي المتغيرات المحلية في ال Stack لكل الخيوط، والمتغيرات الثابتة Static، والمسجلات، ثم يتبع كل مرجع منها إلى كائن، ثم كل مرجع من ذلك الكائن، وهكذا، ويعلم Mark كل كائن يصل إليه. وفي النهاية كل كائن لم يعلم لا يمكن لبرنامجك أن يستخدمه أبداً، فيحرر مكانه، وغالباً ينقل الكائنات الحية لتتجاور ويضغط Compact الذاكرة حتى لا تبقى فيها فراغات.

والمشكلة في الطريقة البسيطة أن فحص ال Heap كاملاً في كل مرة مكلف جداً عندما يكون حجمه عدة جيجابايتات، والحل الذي تستخدمه أغلب الأنظمة الحديثة يقوم على ملاحظة يسميها فريق V8 فرضية الأجيال The Generational Hypothesis، وهي أن أغلب الكائنات تموت صغيرة، أي تنشأ ثم تصبح قمامة بعد وقت قصير جداً، مثل الكائنات المؤقتة داخل حلقة أو داخل طلب HTTP واحد. فيقسم ال Heap إلى أجيال Generations، ويجمع الجيل الجديد كثيراً لأنه صغير وأغلب ما فيه ميت، ويرقي Promote ما ينجو إلى جيل أقدم يجمع نادراً. والصورة التالية تبين ذلك كما في .NET:

على اليسار الجذور: متغيرات ال Stack والحقول الثابتة والمسجلات، تشير بأسهم إلى كائنين حيين في صندوق gen 0 بينما بقية الكائنات فيه عليها علامة خطأ حمراء، ثم سهم من الكائن الناجي إلى gen 1 ثم إلى gen 2، وتحتها أربع خطوات مرقمة وصف أرقام التجربة
الجيل 0 يجمع كثيراً وبسرعة لأن أغلب ما فيه ميت، وما ينجو يرقى إلى جيل أقدم يجمع نادراً
  • .NET: ثلاثة أجيال 0 و1 و2 كما في أساسيات جامع القمامة، والكائنات التي حجمها 85,000 بايت أو أكثر تذهب مباشرة إلى كومة الكائنات الكبيرة Large Object Heap التي تجمع مع الجيل 2، كما في توثيق LOH. وفي تجربتنا بدأ الكائن في الجيل 0 ثم انتقل إلى 1 بعد أول جمع ثم إلى 2 بعد الثاني، وظهرت المصفوفة byte[85000] في الجيل 2 من البداية بينما byte[84000] في الجيل 0، وإنشاء 5 ملايين كائن مؤقت سبب 28 جمعاً للجيل 0 فقط دون أي جمع للجيلين الآخرين.
  • Java: جامعات HotSpot تستخدم الأجيال مع التقادم Generational Scavenging كما في دليل ضبط جامع القمامة، والجامع الافتراضي هو G1 الذي يقسم ال Heap إلى مناطق Regions لجيل صغير Young وجيل قديم Old.
  • JavaScript في V8: جيل صغير Young Generation يجمع بطريقة النسخ السريع Scavenge، وجيل قديم Old Generation، كما يشرح مقال فريق V8، وقد رأينا المساحتين new_space وold_space في مخرج v8.getHeapSpaceStatistics().
  • Python: الآلية الأساسية فيها مختلفة، وهي عد المراجع Reference Counting، فكل كائن يحمل عدد المراجع إليه، وعندما يصل إلى الصفر يحرر فوراً، وقد رأينا العدد يزيد من 2 إلى 4 مع sys.getrefcount ثم يعود بعد del. ولكن عد المراجع لا يحرر الحلقات Cycles، أي كائنين يشير كل منهما إلى الآخر، ولهذا يوجد جامع إضافي في الوحدة gc يكمل عد المراجع كما يقول توثيقها، وهو بثلاثة أجيال، ووجد في تجربتنا 2000 كائن في 1000 حلقة لم يحررها عد المراجع. ولاحظ أن Python 3.14.0 حتى 3.14.4 جاءت بجامع تدريجي جديد ثم عادت إلى جامع 3.13 في الإصدار 3.14.5 بسبب استهلاك الذاكرة، كما في ما الجديد في Python 3.14، فراجع إصدارك إذا كنت تضبط عتبات الجمع.

وقد يتساءل البعض: إذا كان جامع القمامة يتولى كل شيء، فلماذا أهتم؟ والإجابة أن الجمع له ثمن، فهو يستهلك المعالج وقد يوقف خيوط التطبيق Pause لفترة قصيرة، وكلما أنشأت كائنات أكثر جمع أكثر، فالكود الذي ينشئ ملايين الكائنات المؤقتة في مسار ساخن يدفع الثمن في زمن الاستجابة. والأهم من ذلك أن جامع القمامة لا يحميك من تسرب الذاكرة، وهو موضوع القسم التالي.

تسرب الذاكرة Memory Leak في لغة فيها جامع قمامة

في C يحدث التسرب عندما تنسى free، أما في اللغات التي فيها جامع قمامة فيحدث لسبب معاكس: كائن لا تحتاجه، ولكن ما زال هناك مرجع إليه من مكان يعيش طويلاً، فجامع القمامة يراه قابلاً للوصول ولا يستطيع أن يحرره. وأشهر الأماكن التي تحتفظ بهذه المراجع ثلاثة: متغير static أو حدث Event عام، وCache لا حد لحجمه، وقائمة مستمعين Listeners لا يزال منها أحد. والصورة التالية تبين أول حالة كما جربناها في C#:

حدث static اسمه Settings.Changed كجذر برتقالي يشير إلى قائمة مشتركين فيها عشرة آلاف عنصر، وكل عنصر يشير إلى نافذة Window ومعها مصفوفة byte[1024]، وعلى اليمين ثلاثة حلول: إلغاء الاشتراك، والمراجع الضعيفة، والـ Cache محدود الحجم، وفي الأسفل أرقام التجربة في C# وNode.js وPython
جامع القمامة يحرر فقط ما لا يصل إليه أحد، ومرجع واحد من حدث عام يكفي لإبقاء آلاف الكائنات

وفي التجربة فتحنا 10,000 نافذة كل منها تشترك في حدث static ولم نستدع Close()، فبقي +11,188 KB في الذاكرة حتى بعد جمع كامل بـ GC.GetTotalMemory(true)، وعدد المشتركين 10,000. وفي Node.js ملأنا Map بـ 200 ألف عنصر فارتفع heapUsed من 4.1 إلى 38.5 MB، ثم عاد إلى 4.1 بعد cache.clear()، وفي Python أظهرت الوحدة tracemalloc أن سطراً واحداً في دالة handle_request أضاف 10.4 MiB في Cache لا يحذف منه شيء. والحلول هي:

  1. ألغ الاشتراك Unsubscribe: كل += على حدث يقابله -=، وكل كائن يملك موارد ينفذ Dispose ويستخدم مع using، وكل addEventListener يقابله removeEventListener.
  2. المراجع الضعيفة Weak References: مرجع لا يمنع جامع القمامة من تحرير الكائن، مثل WeakReference في .NET، وقد رأيناه في التجربة يصبح IsAlive = False بعد الجمع، وWeakMap في JavaScript، وweakref في Python، وهي مناسبة لبيانات إضافية مرتبطة بكائن يملكه غيرك.
  3. Cache له حدود: حدد عدد العناصر أو مدة الصلاحية Expiry، مثل MemoryCache مع SizeLimit في .NET أو مكتبة LRU في Node.js، ولا تستخدم Dictionary أو Map عاماً كـ Cache أبداً.

وعلامة التسرب على السيرفر أن الذاكرة لا تعود إلى مستوى ثابت بعد انتهاء الضغط، وإنما تصعد مثل الدرج مع الوقت حتى يقتل نظام التشغيل العملية أو يعيد تشغيلها ال Orchestrator. ولقياسها تملك كل منصة أداة: dotnet-counters لمراقبة حجم ال Heap وعدد مرات الجمع لكل جيل، وprocess.memoryUsage() ولقطات ال Heap في Node.js، وtracemalloc في Python.

Rust والملكية Ownership باختصار

لغة Rust اختارت الطريق الثالث، فلا يوجد فيها جامع قمامة ولا free تكتبه بنفسك، وإنما ثلاث قواعد يفحصها المترجم قبل التشغيل كما في كتاب Rust الرسمي: لكل قيمة مالك Owner واحد، ولا يمكن أن يكون لها أكثر من مالك في الوقت نفسه، وعندما يخرج المالك من النطاق Scope تحرر القيمة تلقائياً. فإذا أسندت قيمة في ال Heap إلى متغير آخر فإنها تنتقل Move إليه ويصبح المتغير الأول غير صالح، وإذا أردت أن تستخدمها دون أن تملكها فإنك تستعيرها Borrow بمرجع يتحقق المترجم من أنه لن يعيش أطول من المالك. والنتيجة أن أخطاء الاستخدام بعد التحرير والتحرير مرتين لا تصل إلى التشغيل أصلاً، وثمنها قواعد يتعلمها المبتدئ ببطء. وهذا المقال هو الأساس فقط، أما المقارنة التفصيلية بين الإدارة اليدوية وجامع القمامة والملكية فهي موضوع مقال مخطط في مرحلة لغات البرمجة بعنوان «إدارة الذاكرة: يدوياً أم Garbage Collector أم الملكية Ownership في Rust».

التجربة العملية: الكود والمخرجات كاملة

كل الأرقام في هذا المقال من ثلاثة برامج شغلناها على جهاز Windows 11 بمعالج x86-64، باستخدام .NET SDK 10.0.400 مع Runtime 10.0.11، وPython 3.13.3، وNode.js 22.14.0. والبرنامج الأول ملف C# واحد يعمل بالأمر dotnet run memory.cs دون مشروع، والسطر الأول فيه يفعل الكود unsafe لهذا الملف فقط:

#:property AllowUnsafeBlocks=true
using System;
using System.Collections.Generic;

// 1) value type vs reference type: what does assignment copy?
var p1 = new PointStruct { X = 1 };
var p2 = p1;            // copies the whole value
p2.X = 99;
var c1 = new PointClass { X = 1 };
var c2 = c1;            // copies the reference only
c2.X = 99;
Console.WriteLine($"struct: p1.X={p1.X} p2.X={p2.X} | class: c1.X={c1.X} c2.X={c2.X} same object? {ReferenceEquals(c1, c2)}");

// 2) passing to a method: by value, by ref
int n = 5; AddOne(n); Console.WriteLine($"after AddOne(n): {n}");
AddOneRef(ref n); Console.WriteLine($"after AddOneRef(ref n): {n}");
var list = new List<int> { 1 }; AddItem(list); Console.WriteLine($"after AddItem(list): Count={list.Count}");
ReplaceList(list); Console.WriteLine($"after ReplaceList(list): Count={list.Count}");

// 3) pointers (unsafe): an address, dereference, pointer arithmetic
unsafe
{
    int a = 42;
    int* p = &a;
    Console.WriteLine($"a={a} &a=0x{(ulong)p:X} *p={*p} sizeof(int)={sizeof(int)} sizeof(long)={sizeof(long)} pointer size={sizeof(nint)}");
    *p = 7;
    Console.WriteLine($"after *p = 7: a={a}");
    int* arr = stackalloc int[3] { 10, 20, 30 };
    Console.WriteLine($"arr[0] at 0x{(ulong)arr:X}, arr+1 at 0x{(ulong)(arr + 1):X}, *(arr+2)={*(arr + 2)}");
    int b = 0;
    Console.WriteLine($"two locals on the stack: &a=0x{(ulong)&a:X} &b=0x{(ulong)&b:X}");
}

// 4) GC generations: new objects start in gen 0 and are promoted when they survive
var survivor = new byte[1000];
Console.WriteLine($"new object: gen {GC.GetGeneration(survivor)}");
GC.Collect(); Console.WriteLine($"after 1st GC: gen {GC.GetGeneration(survivor)}");
GC.Collect(); Console.WriteLine($"after 2nd GC: gen {GC.GetGeneration(survivor)}");
var big = new byte[85_000];
Console.WriteLine($"byte[85000] starts in gen {GC.GetGeneration(big)} (large object heap); byte[84000] starts in gen {GC.GetGeneration(new byte[84_000])}");
GC.KeepAlive(survivor);

// 5) garbage: millions of short-lived objects, counted per generation
int g0 = GC.CollectionCount(0), g1 = GC.CollectionCount(1), g2 = GC.CollectionCount(2);
long sum = 0;
for (int i = 0; i < 5_000_000; i++) { var tmp = new PointClass { X = i }; sum += tmp.X; }
Console.WriteLine($"5M temporary objects: gen0 GCs={GC.CollectionCount(0) - g0} gen1={GC.CollectionCount(1) - g1} gen2={GC.CollectionCount(2) - g2}");

// 6) a leak in a managed language: a static event keeps every subscriber alive
long before = GC.GetTotalMemory(true);
for (int i = 0; i < 10_000; i++) { var w = new Window(); w.Open(); /* w.Close() forgotten */ }
long after = GC.GetTotalMemory(true);
Console.WriteLine($"10,000 windows opened, never closed: +{(after - before) / 1024} KB still reachable, subscribers={Settings.Subscribers}");
var weak = MakeWeak();
GC.Collect();
Console.WriteLine($"WeakReference alive after GC? {weak.IsAlive}");

// 7) stack overflow cannot be caught in .NET
if (args.Length > 0 && args[0] == "overflow") { Console.WriteLine("recursing..."); Recurse(0); }

static WeakReference MakeWeak() => new WeakReference(new byte[1000]);
static void AddOne(int x) => x++;
static void AddOneRef(ref int x) => x++;
static void AddItem(List<int> l) => l.Add(2);
static void ReplaceList(List<int> l) { l = new List<int>(); }
static int Recurse(int depth) => Recurse(depth + 1) + 1;

struct PointStruct { public int X; }
class PointClass { public int X; }
static class Settings
{
    public static event EventHandler? Changed;
    public static int Subscribers => Changed?.GetInvocationList().Length ?? 0;
}
class Window
{
    private readonly byte[] _buffer = new byte[1024];
    public void Open() => Settings.Changed += OnChanged;
    public void Close() => Settings.Changed -= OnChanged;
    private void OnChanged(object? s, EventArgs e) { }
}

والمخرج سوف يكون كما يلي:

struct: p1.X=1 p2.X=99 | class: c1.X=99 c2.X=99 same object? True
after AddOne(n): 5
after AddOneRef(ref n): 6
after AddItem(list): Count=2
after ReplaceList(list): Count=2
a=42 &a=0x1B7057E248 *p=42 sizeof(int)=4 sizeof(long)=8 pointer size=8
after *p = 7: a=7
arr[0] at 0x1B7057DE40, arr+1 at 0x1B7057DE44, *(arr+2)=30
two locals on the stack: &a=0x1B7057E248 &b=0x1B7057E230
new object: gen 0
after 1st GC: gen 1
after 2nd GC: gen 2
byte[85000] starts in gen 2 (large object heap); byte[84000] starts in gen 0
5M temporary objects: gen0 GCs=28 gen1=0 gen2=0
10,000 windows opened, never closed: +11188 KB still reachable, subscribers=10000
WeakReference alive after GC? False

في المخرج أعلاه لاحظ التالي:

  • السطر الأول هو الصورة الثالثة: النسخة من struct مستقلة، والنسخة من class هي الكائن نفسه.
  • AddOne(n) لم يغير n لأنه أخذ نسخة، وref غيره، وReplaceList لم يغير القائمة الأصلية.
  • المتغيران المحليان a وb في عنوانين متقاربين جداً، والثاني أصغر من الأول، لأن ال Stack يكبر نحو العناوين الأصغر، والمؤشر حجمه 8 بايتات على 64 بت.
  • أرقام الأجيال وال LOH وعدد مرات الجمع هي التي في الصورة الخامسة، والتسرب 11,188 KB هو الذي في الصورة السادسة، ولاحظ أن أرقام الذاكرة قد تختلف قليلاً بين تشغيل وآخر، فقد ظهر 11,192 KB في تشغيل آخر.

والبرنامج الثاني في Python:

import sys, gc, tracemalloc

# names are references to objects
a = [1, 2, 3]
b = a
b.append(4)
print("a =", a, " a is b:", a is b, " id(a) == id(b):", id(a) == id(b))
c = list(a)
print("copy: c is a:", c is a, " c == a:", c == a)

x = 10
y = x
y += 1
print("ints are immutable: x =", x, " y =", y)

def add_item(lst): lst.append("new")
def replace(lst): lst = ["other"]
add_item(a); replace(a); print("after add_item and replace:", a)

# reference counting
obj = object()
print("refcount:", sys.getrefcount(obj))
holder = [obj, obj]
print("refcount after 2 more refs:", sys.getrefcount(obj))
del holder
print("refcount after del:", sys.getrefcount(obj))

# cycles need the cyclic GC
print("gc thresholds:", gc.get_threshold(), " generations:", len(gc.get_count()))
class Node:
    def __init__(self): self.other = None
gc.collect()
for _ in range(1000):
    n1, n2 = Node(), Node()
    n1.other, n2.other = n2, n1
del n1, n2
print("unreachable objects found by gc.collect():", gc.collect())

# a leak: a module-level cache that is never cleared
tracemalloc.start()
_cache = {}
def handle_request(i):
    _cache[i] = bytearray(1000)   # forgotten: nothing ever removes old keys
    return len(_cache)
s1 = tracemalloc.take_snapshot()
for i in range(10_000): handle_request(i)
s2 = tracemalloc.take_snapshot()
top = s2.compare_to(s1, "lineno")[0]
print("top growth:", str(top).replace(__file__, "mem.py"))

# deep recursion
def deep(n): return deep(n + 1)
print("recursion limit:", sys.getrecursionlimit())
try:
    deep(0)
except RecursionError as e:
    print("RecursionError:", e)

والمخرج سوف يكون كما يلي:

a = [1, 2, 3, 4]  a is b: True  id(a) == id(b): True
copy: c is a: False  c == a: True
ints are immutable: x = 10  y = 11
after add_item and replace: [1, 2, 3, 4, 'new']
refcount: 2
refcount after 2 more refs: 4
refcount after del: 2
gc thresholds: (2000, 10, 10)  generations: 3
unreachable objects found by gc.collect(): 2000
top growth: mem.py:43: size=10.4 MiB (+10.4 MiB), count=20002 (+20002), average=543 B
recursion limit: 1000
RecursionError: maximum recursion depth exceeded

في المخرج أعلاه لاحظ التالي:

  • a is b صحيح لأن b = a لم ينسخ القائمة، أما list(a) فأنشأت قائمة جديدة مساوية لها وليست هي.
  • عتبات الجمع في Python 3.13.3 هي (2000, 10, 10)، أي يبدأ جمع الجيل الأول عندما يزيد عدد الكائنات المنشأة على المحررة بـ 2000.
  • في المحاولة الأولى كتبنا القيمة في ال Cache كنص ثابت "x" * 1000، فأظهر tracemalloc زيادة 304 KiB فقط، والسبب أن Python حسبت النص الثابت مرة واحدة واستخدمت الكائن نفسه لكل العناصر، فغيرناه إلى bytearray(1000) حتى يكون لكل عنصر كائن مستقل كما في الحياة الحقيقية.

والبرنامج الثالث في Node.js، ويحتاج الخيار --expose-gc حتى نستطيع طلب الجمع يدوياً بـ gc() قبل كل قياس:

import v8 from 'node:v8';

// primitives are copied, objects are shared by reference
let n1 = 1; let n2 = n1; n2++;
const o1 = { x: 1 }; const o2 = o1; o2.x = 99;
console.log('primitive:', n1, n2, '| object:', o1.x, o2.x, 'same?', o1 === o2);
const copy = structuredClone(o1); copy.x = 5; console.log('structuredClone:', o1.x, copy.x);

// the V8 heap is split into spaces: new (young generation) and old
const spaces = v8.getHeapSpaceStatistics().map(s => `${s.space_name}=${(s.space_size / 1024 / 1024).toFixed(1)}MB`);
console.log(spaces.join(' '));

// a leak: a Map used as a cache with no limit
const MB = (b) => (b / 1024 / 1024).toFixed(1) + ' MB';
const cache = new Map();
globalThis.gc?.();
const before = process.memoryUsage().heapUsed;
for (let i = 0; i < 200_000; i++) cache.set('user:' + i, { id: i, name: 'user' + i, tags: ['a', 'b'] });
globalThis.gc?.();
const after = process.memoryUsage().heapUsed;
console.log('cache of 200k entries: heapUsed', MB(before), '->', MB(after));
cache.clear(); globalThis.gc?.();
console.log('after cache.clear() + gc:', MB(process.memoryUsage().heapUsed));

// WeakMap does not keep its keys alive
const meta = new WeakMap();
let user = { id: 1 }; meta.set(user, { lastSeen: Date.now() });
user = null; // the entry can now be collected with the key

// stack overflow
function deep(n) { return deep(n + 1) + 1; }
try { deep(0); } catch (e) { console.log(e.constructor.name + ':', e.message); }

وعند تشغيله بالأمر node --expose-gc mem.mjs سوف يكون المخرج كما يلي:

primitive: 1 2 | object: 99 99 same? true
structuredClone: 99 5
read_only_space=0.0MB new_space=1.0MB old_space=3.0MB code_space=0.3MB shared_space=0.0MB trusted_space=1.0MB new_large_object_space=0.0MB large_object_space=0.3MB code_large_object_space=0.0MB shared_large_object_space=0.0MB trusted_large_object_space=0.0MB
cache of 200k entries: heapUsed 4.1 MB -> 38.5 MB
after cache.clear() + gc: 4.1 MB
RangeError: Maximum call stack size exceeded

ولاحظ أن structuredClone أنشأ نسخة مستقلة فلم يتأثر o1 بتغيير النسخة، وأن ال Heap في V8 مقسم إلى مساحات منها new_space للجيل الصغير وold_space للجيل القديم وlarge_object_space للكائنات الكبيرة، وهي الفكرة نفسها التي رأيناها في .NET.

أخطاء شائعة

  1. تعديل قائمة أو كائن داخل دالة دون قصد: المعامل يشير إلى الكائن نفسه، فإذا كانت الدالة لا يجب أن تغيره فانسخه أولاً أو استخدم نوعاً غير قابل للتعديل مثل IReadOnlyList في C# أو tuple في Python.
  2. القيمة الافتراضية القابلة للتعديل في Python: def f(items=[]) تنشئ القائمة مرة واحدة عند تعريف الدالة، فتشترك فيها كل الاستدعاءات، واستخدم None ثم أنشئ القائمة داخل الدالة.
  3. struct كبير يتغير: نوع القيمة ينسخ كاملاً في كل إسناد وتمرير، فإذا عدلت نسخة ظننت أنها الأصل ضاع التعديل، لذلك اجعل ال struct صغيراً وغير قابل للتعديل readonly struct.
  4. التكرار الذاتي على بيانات المستخدم دون حد: في .NET ينهي العملية كلها ولا يمكن التقاطه، فضع حداً للعمق أو استخدم حلقة.
  5. Cache أو حدث static بلا إلغاء: وهو أشهر سبب للتسرب في التطبيقات التي تعمل أياماً.
  6. استدعاء GC.Collect() أو gc() لحل مشكلة أداء: استخدمناه في التجربة فقط لنقيس، أما في التطبيق فالجمع اليدوي يوقف التطبيق في وقت لم يختره جامع القمامة، ونادراً ما يكون هو الحل.
  7. المؤشرات وunsafe في كود التطبيقات: اتركها للحالات النادرة التي تحتاجها فعلاً بعد القياس، مثل التعامل مع مكتبة C.

تدريب عملي

  • اكتب دالة تستقبل قائمة وتضيف إليها عنصراً، ثم دالة تسند للمعامل قائمة جديدة، في اللغة التي تعمل بها يومياً، وتوقع النتيجة قبل التشغيل.
  • شغل خدمة تعمل بها على بيئة التطوير، وراقب الذاكرة بـ dotnet-counters أو process.memoryUsage() أثناء ضغط بسيط ثم بعده، وتأكد أنها تعود إلى مستوى ثابت.
  • ابحث في مشروعك عن كل static من نوع Dictionary أو List أو Map، واسأل لكل واحد: من يحذف منه؟

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • ذاكرة البرنامج مقسمة إلى الكود والبيانات وال Heap وال Stack، والعناوين افتراضية وتتغير في كل تشغيل.
  • ال Stack لكل خيط، سريع ومحدود، ويختفي إطار الدالة عند عودتها، وامتلاؤه يرمي استثناء في Python وJavaScript وينهي العملية في .NET.
  • ال Heap للكائنات التي تعيش أطول من الدالة، ويحررها أنت في C، أو جامع القمامة في C# وJava وJavaScript وPython، أو المترجم بقواعد الملكية في Rust.
  • نوع القيمة ينسخ كاملاً ونوع المرجع ينسخ العنوان، والمعاملات تمرر بالقيمة لكن قيمة المرجع هي المرجع، وهذا يفسر أغلب مفاجآت التعديل.
  • المؤشر رقم يحمل عنواناً ولا يمنعك من الخروج عن الحدود، وهذا مصدر ثغرات مثل Heartbleed، والمرجع مؤشر آمن تديره اللغة.
  • جامع القمامة يحرر ما لا يصل إليه أحد من الجذور، ويعمل بالأجيال لأن أغلب الكائنات تموت صغيرة، ولا يمنع التسرب إذا بقي مرجع من static أو Cache أو حدث.

وفي مرحلة أساسيات علوم الحاسب نفسها سوف نتحدث بإذن الله عن هياكل البيانات Data Structures، حيث نرى كيف تبنى المصفوفة والقائمة المرتبطة والجدول فوق ما تعلمناه هنا عن المؤشرات والمراجع وال Heap.

دليلالأنظمة العددية للمبرمجين: من Binary إلى Floating Pointلماذا يظهر القرص 465 بدل 500، ويصبح العداد سالباً فجأة، ولا يساوي 0.1 + 0.2 القيمة 0.3؟ الإجابة في طريقة تخزين الأرقام.دليلماذا يحدث عندما تشغل برنامج Hello World؟نتتبع برنامج C صغيراً من المحرر إلى الشاشة، فترى كل طبقة يمر بها في الجهاز حتى تظهر الجملة أمامك.دليلالتزامن Concurrency والخيوط: ماذا يعني لسيرفرك وتطبيقاتكلماذا يكون المعالج عند 100% والتطبيق بطيئاً، ولا تسرع الخيوط كود Python، وكيف تضبط عدد العمال وحدود ال Containers.دليلأنماط البرمجة وأنظمة الأنواع: لماذا تختلف اللغات؟نحل مسألة واحدة بعدة أساليب برمجة، ونرى هل تكتشف اللغة أخطاءك قبل التشغيل أم بعده، وماذا يعني ذلك عند اختيار لغة لمشروعك.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ الأمثلة على Windows 11 (x86-64) مع .NET SDK 10.0.400 (Runtime 10.0.11) وPython 3.13.3 وNode.js 22.14.0، ومثال C من توثيق malloc دون تشغيل.
  • أكتوبر 2026: نقل تشريح الذاكرة RAM بصوره ورسوماته إلى مقال مستقل هو «كيف تعمل الذاكرة RAM من الداخل»، وبقيت هنا فقرة قصيرة ترسل إليه.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.