> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# إدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership؟
- URL: https://arabroot.io/articles/ادارة-الذاكرة-يدويا-ام-garbage-collector-ام-ownership/
- Published: 2026-10-30T18:53:41.000Z
- Updated: 2026-10-30T19:53:15.000Z
- Description: نقيس بأيدينا في C وGo وPython وRust من يحرر الذاكرة ومتى، وكم يكلف ذلك من الذاكرة والوقت، وما الأخطاء التي تبقى ممكنة في كل طريقة.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, أساسيات الحاسب والبرمجة

إذا كتبت بلغة Python أو JavaScript أو Java فأنت لم تكتب في حياتك سطراً يقول «انتهيت من هذا الكائن، أعد ذاكرته»، ومع ذلك تعود الذاكرة وحدها. ثم تفتح يوماً كوداً بلغة C فتجد السطر `free(p)` في كل مكان، وتقرأ تحذيرات من أن نسيانه مرة واحدة يملأ الذاكرة، وأن كتابته مرتين توقف البرنامج. ثم تسمع عن Rust فتجد أنها لا تحتاج `free` ولا جامع قمامة، وأن المترجم فيها يرفض برامج تبدو لك صحيحة تماماً بسبب شيء اسمه الملكية Ownership. وفي نفس الأسبوع يخبرك زميل أن خدمة مكتوبة بلغة Go تستهلك ذاكرة أكثر بكثير من نسختها القديمة بلغة C، وأن الحل كان متغير بيئة اسمه `GOGC`. فهل هذه أربع طرق لعمل نفس الشيء، أم أربع فلسفات مختلفة لكل منها ثمن؟

والإجابة أن كل لغة برمجة تجيب عن نفس السؤال: من يقرر أن قطعة من الذاكرة لم تعد مطلوبة، ومتى يقرر ذلك؟ وهذه الإجابة تحدد نوع الأخطاء التي ستقابلها، وكم من الذاكرة والمعالج سيستهلك برنامجك، وهل يتوقف أحياناً لجزء من الثانية دون سبب ظاهر. وقد شرحنا في مقال [الذاكرة للمبرمجين: كيف تعمل ال Stack وال Heap](https://arabroot.io/articles/%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D9%86-stack-%D9%88-heap-%D9%88%D8%A7%D9%84%D9%85%D8%A4%D8%B4%D8%B1%D8%A7%D8%AA/) أين تعيش البيانات، وفي مقال [أمان الذاكرة Memory Safety](https://arabroot.io/articles/%D8%A7%D9%85%D8%A7%D9%86-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-memory-safety-%D9%84%D9%85%D8%A7%D8%B0%D8%A7-%D8%AA%D8%AF%D9%81%D8%B9-%D8%A7%D9%84%D8%AD%D9%83%D9%88%D9%85%D8%A7%D8%AA-%D9%86%D8%AD%D9%88-rust/) لماذا صارت أخطاء الذاكرة قضية أمنية تتكلم عنها الحكومات. وهنا سوف نأخذ خطوة إلى الداخل: كيف تعمل كل طريقة فعلاً، وكم تكلف بأرقام قسناها بأنفسنا في أربع لغات على نفس الجهاز، وما الأخطاء التي تبقى ممكنة في كل منها.

وسوف نناقش في هذا المقال ما يلي:

- لماذا تحتاج الذاكرة من يحررها، والإجابات الأربع: الإدارة اليدوية، وجامع القمامة Garbage Collector، وعد المراجع Reference Counting، والملكية Ownership.
- الإدارة اليدوية في C: كيف يعمل `malloc`، وتسرب يكبر مع كل طلب، والأدوات Valgrind وAddressSanitizer.
- جامع القمامة في Go: كيف يعرف ما هو حي، وقراءة سجل الجمع، والمقبض `GOGC` بين الذاكرة والمعالج، وتسرب في لغة فيها جامع.
- عد المراجع في Python: تحرير فوري، ومشكلة الدوائر Cycles وجامعها.
- الملكية في Rust: متى تحرر القيمة بالضبط، وماذا يرفض المترجم، والتسرب الوحيد الذي تسمح به.
- نفس العمل في أربع لغات بالوقت وذروة الذاكرة، ثم جدول قرار والأخطاء الشائعة.

## لماذا تحتاج ذاكرة ال Heap من يحررها؟

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

ولهذا السؤال أربع إجابات كبرى تجمعها الصورة التالية، وكل قسم بعدها يأخذ إجابة ويقيسها:

![أربع طرق لإدارة الذاكرة: يدوياً في C وC++ حيث يحرر المبرمج بالسطر free ولا يوجد عمل إضافي وقت التشغيل والخطر تسرب واستخدام بعد التحرير وتحرير مرتين، وجامع القمامة في Go وJava وC# حيث تحرر بيئة التشغيل دورياً والثمن ذاكرة إضافية ووقت معالج، وعد المراجع في Python وSwift حيث يحرر الكائن فوراً عند وصول العداد إلى صفر والخطر الدوائر، والملكية في Rust حيث يضع المترجم التحرير عند نهاية نطاق المالك والخطر دوائر Rc](https://arabroot.io/content/images/2026/10/memory-management-01-models.webp)

نفس السؤال، وأربع إجابات، ولكل إجابة ثمن وخطر

- يدوياً Manual: المبرمج نفسه يقول متى، بالسطر `free` في C أو `delete` في C++. لا عمل إضافي وقت التشغيل، والثمن أن كل خطأ في هذا القرار خطأ في البرنامج.
- جامع القمامة Tracing Garbage Collector: بيئة التشغيل تبحث بين فترة وأخرى عن الكائنات التي لا يصل إليها البرنامج وتحررها. لا أخطاء تحرير، والثمن ذاكرة إضافية ووقت معالج للبحث.
- عد المراجع Reference Counting: كل كائن يحمل عداداً لمن يشير إليه، ويحرر لحظة وصول العداد إلى صفر. تحرير فوري ومتوقع، والثمن تحديث العداد مع كل نسخ ومشكلة الدوائر.
- الملكية Ownership: المترجم يحدد لكل قيمة مالكاً واحداً، ويضع التحرير بنفسه حيث ينتهي المالك، ويرفض أي برنامج لا يستطيع إثبات سلامته. لا عمل إضافي وقت التشغيل ولا أخطاء تحرير، والثمن قواعد يجب أن تتعلمها.

## تجهيز المختبر Lab Setup: أربع لغات في Container واحد

كل التجارب تعمل في Container واحد من Debian 13 فيه مترجم C ومترجم Rust وGo وPython، ومعها الأداة Valgrind والأداة `/usr/bin/time` من GNU التي تقيس الوقت وذروة الذاكرة. واستخدمنا الإصدارات التي تأتي مع Debian وليس أحدث إصدار من كل لغة، لأن الهدف المقارنة بين الطرق لا بين الإصدارات:

```bash
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev valgrind golang-go rustc python3 time procps && rm -rf /var/lib/apt/lists/*
WORKDIR /lab
```

```bash
docker build -t sbl-mem:1 .
docker run --rm sbl-mem:1 sh -c 'gcc --version|head -1; go version; rustc --version; valgrind --version; python3 --version'
```

```text
gcc (Debian 14.2.0-19) 14.2.0
go version go1.24.4 linux/amd64
rustc 1.85.1 (4eb161250 2025-03-15) (built from a source tarball)
valgrind-3.24.0
Python 3.13.5
```

والجهاز معالج فيه 8 أنوية منطقية وذاكرة 32 GB، ونواة Linux 5.15 داخل WSL2 تحت Docker Desktop. وكل البرامج في مجلد المختبر `lab/`، وكل سكربت يعمل بالأمر `docker run --rm -v "$PWD":/lab sbl-mem:1 sh /lab/run-c.sh` وما يشبهه.

## الإدارة اليدوية Manual Memory في C

في C تطلب الذاكرة بالدالة `malloc` وتعيدها بالدالة `free`، كما في صفحة [malloc(3)](https://man7.org/linux/man-pages/man3/malloc.3.html?ref=arabroot.io). والمهم أن تعرف أن `malloc` ليست طلباً إلى النواة في كل مرة، فالنواة تعطي البرنامج ذاكرة بقطع كبيرة، ومكتبة C في برنامجك هي التي تقسمها إلى قطع صغيرة، وتحتفظ بقوائم للقطع المحررة لتعيد استخدامها في الطلب التالي. ولهذا ثمنان لا يراهما المبرمج: كل قطعة تحمل بيانات إدارية بجانبها، وأصغر قطعة في مكتبة glibc على جهاز 64 بت تأخذ 32 بايت حتى لو طلبت 10 بايت، والبحث في القوائم والتحديث يأخذ وقتاً في كل طلب وكل تحرير. ولن تحتاج هذه التفاصيل يومياً، ولكنها ستظهر لك في الأرقام بعد قليل.

### التسرب Memory Leak: سطر free واحد منسي

البرنامج التالي يمثل دالة تعالج طلباً: تحجز مكاناً لجسم الطلب، وتنسخ منه نسخة للسجل بالدالة `strdup`، ثم تحرر الاثنين. والخطأ أن تحرير النسخة منسي في أحد الفروع، وهو خطأ يحدث كثيراً عندما يضيف أحدهم فرعاً جديداً لحالة خاصة ولا ينتبه إلى كل ما حجزته الدالة قبله:

```c
static void handle_request(int id, int forget) {
    char *body = malloc(4096);                 // the request body
    snprintf(body, 4096, "order %d", id);
    char *copy = strdup(body);                 // a copy for the audit log
    free(body);
    if (!forget) free(copy);                   // the bug: this line is missing in one branch
}
```

والبرنامج كاملاً في `lab/leak.c`، ويطبع ذاكرة العملية الحقيقية RSS كل ربع مليون طلب. والمخرج للنسختين:

```text
--- fixed version
start: RSS 1136 KB
after  250000 requests: RSS 1136 KB
after  500000 requests: RSS 1136 KB
after  750000 requests: RSS 1136 KB
after 1000000 requests: RSS 1136 KB
--- leaking version
start: RSS 1072 KB
after  250000 requests: RSS 9084 KB
after  500000 requests: RSS 17004 KB
after  750000 requests: RSS 24660 KB
after 1000000 requests: RSS 32580 KB
```

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

- النسخة الصحيحة ثابتة على 1136 KB بعد مليون طلب، لأن كل قطعة تحرر تعاد في الطلب التالي، فالبرنامج يستخدم نفس الذاكرة مرة بعد مرة.
- النسخة المسربة تزيد حوالي 8 MB كل ربع مليون طلب، أي حوالي 32 بايت لكل طلب، بينما النص المنسوخ `order 123456` لا يتجاوز 13 بايت. والفرق هو الحد الأدنى للقطعة الذي ذكرناه، فكل تسرب صغير يكلف أكثر من حجمه.
- لا يوجد أي خطأ ولا أي تحذير، والبرنامج يعمل بشكل صحيح تماماً من حيث النتيجة. وعلى سيرفر يستقبل مليون طلب في اليوم، يعني هذا 32 MB يومياً، أي حوالي 1 GB في شهر، حتى يقتله قاتل العمليات OOM Killer فجأة دون أن يعرف أحد السبب.

وأشهر أداة لاكتشاف هذا هي [Valgrind Memcheck](https://valgrind.org/docs/manual/mc-manual.html?ref=arabroot.io)، التي تشغل البرنامج في معالج افتراضي وتسجل كل حجز وكل تحرير، ثم تخبرك في النهاية بما لم يحرر ومن أين جاء. شغلناها على ألف طلب فقط، لأنها تبطئ البرنامج عشرات المرات:

```bash
gcc -g -O0 -o leak_dbg /lab/leak.c
valgrind --leak-check=full ./leak_dbg leak 1000
```

```text
    in use at exit: 9,893 bytes in 1,000 blocks
  total heap usage: 2,011 allocs, 1,011 frees, 4,117,469 bytes allocated
9,893 bytes in 1,000 blocks are definitely lost in loss record 1 of 1
   by 0x4906529: strdup (strdup.c:42)
   by 0x1092B6: handle_request (leak.c:13)
   by 0x10937C: main (leak.c:22)
   definitely lost: 9,893 bytes in 1,000 blocks
ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
```

والأداة لم تقل فقط إن هناك تسرباً، وإنما قالت إن 1000 قطعة بمجموع 9893 بايت ضاعت، وإنها جاءت من `strdup` التي استدعيت في السطر 13 من `leak.c`، وهو سطر النسخة بالضبط. والكلمات `definitely lost` تعني أنه لم يبق في البرنامج أي مؤشر إلى هذه القطع، فلا يمكن تحريرها أبداً. والخيار `-g` عند البناء هو الذي جعل الأداة تعرف أسماء الملفات وأرقام السطور.

### لماذا لم تنزل الذاكرة بعد free؟

لاحظ شيئاً في النسخة الصحيحة: الذاكرة بقيت 1136 KB طوال الوقت، ولم تنزل بعد كل تحرير. وهذا سلوك طبيعي يحير كثيرين عندما يراقبون برامجهم، فالدالة `free` تعيد القطعة إلى قوائم مكتبة C داخل البرنامج نفسه، لا إلى النواة، والسبب أن البرنامج غالباً سيطلب قطعة بنفس الحجم بعد لحظات، وإعادتها إلى النواة ثم طلبها من جديد أغلى بكثير. وتستثنى من ذلك القطع الكبيرة، فصفحة [malloc(3)](https://man7.org/linux/man-pages/man3/malloc.3.html?ref=arabroot.io) تقول إن glibc تحجز ما هو أكبر من حد اسمه `MMAP_THRESHOLD`، وقيمته الافتراضية 128 KB، بالطلب `mmap` مباشرة من النواة، فيعود إليها فوراً عند التحرير. والنتيجة العملية أن رقم الذاكرة الذي تراه في `top` لبرنامج C أو لأي لغة تحتها مكتبة C قد يبقى مرتفعاً بعد أن ينتهي من عمل كبير، دون أن يكون فيه تسرب، والفرق بين الحالتين أن البرنامج السليم يستقر عند رقم ثابت كما رأينا، والمسرب يكبر بلا توقف مع عدد الطلبات.

### الاستخدام بعد التحرير Use After Free والتحرير مرتين Double Free

التسرب يضيع الذاكرة، أما الخطآن التاليان فيفسدانها. وقد عرضنا أمثلتهما وأثرهما الأمني في مقال أمان الذاكرة، فلن نكرر ذلك هنا، وإنما نريد أن نرى كيف يبدوان عندما يحدثان وكيف تكشفهما. والبرنامج `lab/uaf.c` يحرر حساب عميل رصيده 500، ثم يحجز قطعة جديدة بنفس الحجم ويملؤها بحروف، ثم يقرأ رصيد الحساب المحرر:

```text
--- use after free, normal build
balance read after free: 0
exit 0
--- double free, normal build
free(): double free detected in tcache 2
Aborted (core dumped)
exit 134
```

والنتيجة الأولى هي الأخطر: البرنامج قرأ الرصيد 0 بدلاً من 500، ولم يتوقف، وخرج برمز 0 كأن كل شيء بخير. فالذاكرة المحررة لم تختف، وإنما صارت مكاناً تكتب فيه مكتبة C بياناتها الإدارية أو يأخذه حجز جديد، فتقرأ ما يصادف أن يكون هناك. أما التحرير مرتين فاكتشفته glibc هذه المرة لأن القطعة كانت في قائمتها الخاصة tcache، فأوقفت البرنامج برمز 134، أي الإشارة 6 وهي `SIGABRT`، ولا شيء يضمن أن تكتشفه في كل حالة. والأداة التي تكشف الأول هي AddressSanitizer، وهي خيار في المترجم نفسه `-fsanitize=address` كما في [توثيق GCC](https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html?ref=arabroot.io)، تضيف فحصاً حول كل قراءة وكتابة:

```text
==34==ERROR: AddressSanitizer: heap-use-after-free on address 0x503000000050 at pc 0x5615eb0f1297 bp 0x7ffc6991f250 sp 0x7ffc6991f248
READ of size 8 at 0x503000000050 thread T0
    #0 0x5615eb0f1296 in main /lab/uaf.c:12
freed by thread T0 here:
    #1 0x5615eb0f124d in main /lab/uaf.c:9
previously allocated by thread T0 here:
    #1 0x5615eb0f11f1 in main /lab/uaf.c:7
SUMMARY: AddressSanitizer: heap-use-after-free /lab/uaf.c:12 in main
```

والتقرير يعطي القصة كاملة بثلاثة أسطر: القراءة الخاطئة بحجم 8 بايت في السطر 12، والتحرير الذي سبقها في السطر 9، والحجز الأصلي في السطر 7\. والتطبيق العملي أن تبني الاختبارات دائماً بهذا الخيار، لأنه يحول الخطأ الصامت إلى توقف بتقرير واضح، ولا تستخدمه في الإنتاج لأنه يضاعف الذاكرة ويبطئ البرنامج. والصورة التالية تجمع الأخطاء الثلاثة:

![ثلاثة أخطاء في الإدارة اليدوية: التسرب حيث زادت الذاكرة من 1072 إلى 32580 KB مع مليون طلب بينما بقيت النسخة الصحيحة على 1136 KB وأشار Valgrind إلى السطر 13، والاستخدام بعد التحرير حيث قرأ البناء العادي الرصيد 0 بدلاً من 500 وخرج بنجاح بينما أظهر AddressSanitizer الخطأ بأسطره، والتحرير مرتين الذي أوقفته glibc برمز 134](https://arabroot.io/content/images/2026/10/memory-management-02-manual-bugs.webp)

في الإدارة اليدوية أخطر الأخطاء هي التي لا توقف البرنامج

## جامع القمامة Garbage Collector في Go

فكرة جامع القمامة قديمة، فقد وصف John McCarthy أول جامع من هذا النوع في [ورقته عن لغة Lisp عام 1960](https://www-formal.stanford.edu/jmc/recursive.pdf?ref=arabroot.io)، والفكرة لم تتغير في جوهرها: بدلاً من أن يحاول أحد معرفة متى انتهى كل كائن، ابحث بين فترة وأخرى عن الكائنات التي ما زال البرنامج يستطيع الوصول إليها، واعتبر كل ما عداها قمامة. والبحث يبدأ من الجذور Roots، وهي المتغيرات العامة ومتغيرات كل دالة تعمل الآن على ال Stack، ثم يتبع كل مؤشر من كائن إلى كائن ويعلم كل ما يصل إليه، وهذه مرحلة التعليم Mark، ثم يعيد كل ما لم يعلم إلى الذاكرة الحرة، وهذه مرحلة الكنس Sweep. ولهذا يسمى هذا النوع الجامع المتتبع Tracing GC.

والمشكلة القديمة في هذه الطريقة أن البرنامج إذا غير المؤشرات أثناء البحث قد يفوت الجامع كائناً حياً، ولذلك كانت الجوامع الأولى توقف البرنامج كله أثناء العمل، وهو التوقف الذي يسميه الناس Stop The World. أما جامع Go فيعمل أغلب الوقت بالتوازي مع البرنامج على أنوية أخرى، ولا يوقفه إلا لحظات قصيرة في بداية الجمع ونهايته. وكل هذا موثق في [دليل جامع القمامة الرسمي في Go](https://go.dev/doc/gc-guide?ref=arabroot.io)، وهو أفضل وثيقة كتبت عن جامع قمامة لمبرمج عادي. ولكي ترى الجامع يعمل يكفي المتغير `GODEBUG=gctrace=1`، فيطبع سطراً عن كل دورة:

```text
gc 1 @0.003s 8%: 0.074+2.0+0.097 ms clock, 0.59+0/2.8/0.006+0.77 ms cpu, 3->4->3 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 8 P
gc 2 @0.007s 8%: 0.015+2.7+0.006 ms clock, 0.12+0/2.7/1.2+0.048 ms cpu, 6->6->5 MB, 6 MB goal, 0 MB stacks, 0 MB globals, 8 P
gc 3 @0.012s 8%: 0.028+28+0.011 ms clock, 0.22+0.095/20/0.007+0.088 ms cpu, 11->11->9 MB, 11 MB goal, 0 MB stacks, 0 MB globals, 8 P
gc 4 @0.044s 8%: 0.028+5.4+0.005 ms clock, 0.23+0/5.2/0+0.046 ms cpu, 19->19->17 MB, 19 MB goal, 0 MB stacks, 0 MB globals, 8 P
```

ولنقرأ السطر الثالث جزءاً جزءاً، كما يشرح توثيق الحزمة [runtime](https://pkg.go.dev/runtime?ref=arabroot.io):

- `gc 3 @0.012s`: الدورة الثالثة، بعد 12 ملي ثانية من بدء البرنامج.
- `8%`: نسبة وقت المعالج التي أخذها الجامع منذ البداية.
- `0.028+28+0.011 ms clock`: ثلاث مراحل بالوقت الحقيقي، الأولى والثالثة هما التوقفان القصيران للبرنامج كله، 0.028 و0.011 ملي ثانية، والوسطى 28 ملي ثانية هي التعليم الذي حدث والبرنامج يعمل. فالدورة أخذت 28 ملي ثانية، ولكن البرنامج لم يتوقف منها إلا أقل من 0.04 ملي ثانية.
- `11->11->9 MB`: حجم ال Heap عند بداية الدورة ونهايتها، ثم الحجم الحي فعلاً بعدها، أي أن 9 MB من البيانات ما زالت مستخدمة.
- `11 MB goal`: الحجم الذي قرر الجامع أن يبدأ عنده هذه الدورة، و`8 P` عدد المعالجات المنطقية التي يستخدمها Go.

### المقبض GOGC: الذاكرة مقابل المعالج

متى يبدأ الجامع دورة جديدة؟ الدليل الرسمي يعطي المعادلة: الهدف يساوي الحجم الحي مضافاً إليه الحجم الحي مضروباً في `GOGC/100`، والقيمة الافتراضية 100، أي أن الجامع يسمح لل Heap أن يصل إلى ضعف ما هو حي قبل أن يجمع. فإذا رفعت القيمة جمع أقل وأخذ ذاكرة أكثر، وإذا خفضتها جمع أكثر وأخذ ذاكرة أقل. ولكي نرى ذلك بالأرقام كتبنا برنامجاً يمثل خدمة تحتفظ بثلاثة ملايين طلب مفتوح في الذاكرة، حوالي 216 MiB من البيانات الحية، ثم تعالج ثلاثين مليون طلب قصير ينشئ كل منها كائناً مؤقتاً. وشغلناه بقيم مختلفة:

```text
GOGC=50   work: 3.89 s, GC cycles: 34, GC CPU: 6.4%, total pause: 39.3 ms, heap from OS: 439 MiB, live orders: 3000000   peak RSS 465952 KB
GOGC=100  work: 2.99 s, GC cycles: 17, GC CPU: 3.9%, total pause: 2.3 ms, heap from OS: 563 MiB, live orders: 3000000   peak RSS 595836 KB
GOGC=400  work: 4.98 s, GC cycles: 5, GC CPU: 1.0%, total pause: 0.8 ms, heap from OS: 1291 MiB, live orders: 3000000   peak RSS 1357496 KB
GOGC=off  work: 4.19 s, GC cycles: 0, GC CPU: 0.0%, total pause: 0.0 ms, heap from OS: 2663 MiB, live orders: 3000000   peak RSS 2787556 KB
GOGC=off GOMEMLIMIT=400MiB work: 3.75 s, GC cycles: 26, GC CPU: 8.9%, total pause: 26.5 ms, heap from OS: 395 MiB, live orders: 3000000   peak RSS 413968 KB 
```

![أعمدة ذروة الذاكرة لنفس البرنامج: GOGC=50 بذروة 455 MiB، وGOGC=100 بذروة 582 MiB، وGOGC=400 بذروة 1326 MiB، وGOGC=off بذروة 2722 MiB، وGOGC=off مع GOMEMLIMIT=400MiB بذروة 404 MiB، وعدد دورات الجمع 34 و17 و5 و0 و26](https://arabroot.io/content/images/2026/10/memory-management-03-gogc.webp)

كل مضاعفة لهدف الجامع تشتريها بذاكرة

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

- عمود الذاكرة واضح ومتسق: من 455 MiB عند 50، إلى 582 عند 100، إلى 1326 عند 400، إلى 2722 MiB عندما أوقفنا الجامع، أي أن البرنامج أخذ في النهاية كل ما أنشأه من قمامة لأن أحداً لم يجمعها.
- عدد الدورات ينزل بنفس الترتيب: 34 ثم 17 ثم 5 ثم صفر، ومعها نسبة المعالج التي أخذها الجامع من 6.4% إلى 1% ثم صفر.
- عمود الوقت لا يتبع أي ترتيب: الأسرع كان 100 والأبطأ 400، والسبب أن الوقت هنا يتأثر بأشياء أخرى غير الجامع، فكل ذاكرة جديدة يأخذها البرنامج من النواة تكلف أخطاء صفحات عند أول لمس كما شرحنا في مقال [الذاكرة الافتراضية](https://arabroot.io/articles/%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D8%A7%D9%84%D8%A7%D9%81%D8%AA%D8%B1%D8%A7%D8%B6%D9%8A%D8%A9-virtual-memory-%D9%88%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B5%D9%81%D8%AD%D8%A7%D8%AA/)، فإيقاف الجامع لم يجعل البرنامج أسرع لأنه دفع الثمن في مكان آخر.
- السطر الأخير هو الحل الحديث: `GOMEMLIMIT` حد للذاكرة يجعل الجامع يعمل فقط عندما يقترب منه، فحتى مع `GOGC=off` بقيت الذروة 404 MiB مع 26 دورة. وهذا مناسب لخدمة في Container له حد ذاكرة معروف، فتعطي الجامع حرية حتى قرب الحد بدلاً من أن يجمع مبكراً بلا سبب، ولكن إذا كانت البيانات الحية نفسها قريبة من الحد فسوف يقضي الجامع وقته كله في الجمع.

⚠️

في التشغيل الأول لهذه التجربة انتهى العمل في 0.05 ثانية مع 16 دورة فقط في كل القيم، وهي أرقام لا معنى لها. والسبب أن مترجم Go لاحظ أن الكائن المؤقت لا يخرج من الحلقة، فوضعه على ال Stack بدلاً من ال Heap، وهذا اسمه تحليل الهروب Escape Analysis، فلم يتولد أي قمامة أصلاً. والحل كان متغيراً عاماً يحتفظ بآخر كائن لحظة، فيجبر المترجم على وضعه في ال Heap. وهذا درس مهم لأي قياس للذاكرة: تأكد أن ما تقيسه يحدث فعلاً، ومترجمات Go وJava تزيل كثيراً من الحجوزات قبل أن تصل إلى الجامع.

### أفضل قمامة هي التي لا تنشأ: تحليل الهروب Escape Analysis

المشكلة التي واجهتنا في التجربة السابقة تشرح كيف تقلل لغات الجامع ثمنه في الواقع. فقبل أن يصل أي كائن إلى الجامع يسأل مترجم Go سؤالاً بسيطاً: هل يمكن أن يبقى هذا الكائن بعد أن تنتهي الدالة التي أنشأته؟ فإذا كان الجواب لا، أي أن الكائن لا يرجع من الدالة ولا يحفظ في مكان أبعد منها، وضعه المترجم على ال Stack، فيختفي مع نهاية الدالة دون أن يراه الجامع أبداً. وإذا كان الجواب نعم، أو لم يستطع المترجم أن يثبت أنه لا، وضعه في ال Heap. ويخصص [دليل الجامع الرسمي](https://go.dev/doc/gc-guide?ref=arabroot.io) قسماً كاملاً لهذا التحليل، ويقترح الخيار `go build -gcflags=-m` لترى قرار المترجم لكل حجز. والدرس العملي أن تقليل الحجوزات في الأماكن الساخنة من الكود، مثل حلقة تعالج كل طلب، يقلل عمل الجامع أكثر من أي ضبط للمقبض `GOGC`، وأن تمرير المؤشرات وحفظها في متغيرات عامة بلا حاجة يجبر الكائنات على الهروب إلى ال Heap. وتفعل الآلة الافتراضية في Java شيئاً مشابهاً، فتحذف بعض الحجوزات التي تثبت أنها لا تهرب.

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

جامع القمامة يحرر ما لا يصل إليه أحد، ولا يحرر ما يصل إليه البرنامج حتى لو لم يعد يحتاجه. وأشهر صورة لذلك ذاكرة مؤقتة Cache في متغير عام لا يحذف منها شيء. والبرنامج `lab/goleak.go` يحتفظ لكل طلب بأول 1 KiB من بيانات طلب حجمها 10 KiB في خريطة عامة، ويطبع الذاكرة الحية بعد إجبار الجامع على العمل:

```go
func handle(id int) {
	buf := make([]byte, 10<<10) // 10 KiB per request, garbage after return
	sessions[id] = buf[:1024]   // the bug: keep a slice of it "for later"
}
```

```text
after  25000 requests: live heap after GC  245 MiB
after  50000 requests: live heap after GC  490 MiB
after  75000 requests: live heap after GC  737 MiB
after 100000 requests: live heap after GC  981 MiB
after clearing the map:  live heap after GC    0 MiB
```

كنا نتوقع حوالي 100 MiB، أي 1 KiB لمئة ألف طلب، فظهر 981 MiB، عشرة أضعاف. والسبب أن الشريحة Slice في Go ليست نسخة، وإنما نافذة على مصفوفة، كما يشرح [مقال Go الرسمي عن الشرائح](https://go.dev/blog/slices-intro?ref=arabroot.io)، فالشريحة `buf[:1024]` تشير إلى نفس المصفوفة ذات 10 KiB، وما دامت الشريحة حية فالمصفوفة كلها حية في نظر الجامع. والإصلاح أن نحفظ نسخة مستقلة:

```go
	sessions[id] = append([]byte(nil), buf[:1024]...) // keep a 1 KiB copy, not a window into 10 KiB
```

```text
after  25000 requests: live heap after GC   25 MiB
after  50000 requests: live heap after GC   51 MiB
after  75000 requests: live heap after GC   78 MiB
after 100000 requests: live heap after GC  102 MiB
after clearing the map:  live heap after GC    0 MiB
```

![إلى اليمين: sessions[id] = buf[:1024] تحفظ شريحة من 1 KiB تبقي المصفوفة كلها 10 KiB حية، فبعد 100 ألف طلب 981 MiB بعد الجمع. إلى اليسار: نسخة مستقلة بالدالة append تجعل المصفوفة الأصلية قمامة، فبعد 100 ألف طلب 102 MiB، وما زال تسرباً إذا لم تحذف الجلسات القديمة، وبعد تفريغ الخريطة صفر في الحالتين](https://arabroot.io/content/images/2026/10/memory-management-04-go-slice-leak.webp)

الجامع يرى المراجع لا النوايا

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

وماذا عن Java؟ فكرتها نفس الفكرة، ولكن جوامعها في الآلة الافتراضية JVM تقسم ال Heap إلى أجيال Generations، لأن أغلب الكائنات تموت صغيرة، فتجمع الجيل الصغير كثيراً وبسرعة، والجيل القديم نادراً. وفيها بدل المقبض `GOGC` حد صريح للحجم بالخيار `-Xmx` وعدة جوامع تختار بينها. ولم نقسها هنا لأن الهدف المقارنة بين الطرق لا بين جوامع القمامة، ولكن كل ما رأيته في Go من مقايضة بين الذاكرة والمعالج ينطبق عليها.

## عد المراجع Reference Counting في Python

Python لا يعتمد على جامع متتبع في الأساس، وإنما على عد المراجع: كل كائن يحمل عدداً يزيد كلما أشار إليه متغير أو قائمة أو كائن آخر، وينقص عند زوال الإشارة، ولحظة وصوله إلى صفر يحرر الكائن فوراً. وهذه الطريقة تعطيك شيئاً لا يعطيه الجامع المتتبع: تعرف بالضبط متى يحرر الكائن. والبرنامج `lab/refcount.py` يعطي كل فاتورة 1 MiB ويطبع رسالة عند تحريرها:

```text
1) no cycle
  refcount: 1
  refcount after b = a: 2
  after del a: still alive because b points to it
  freed invoice 1
  after del b
2) a cycle
  after del x, y: nothing printed, the cycle keeps both counts at 1
  freed invoice 2
  freed invoice 3
  gc.collect() found 2 unreachable objects
```

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

- الدالة `sys.getrefcount` أعطت 1 ثم 2 بعد `b = a`، وطرحنا 1 من كل نتيجة لأن تمرير الكائن إلى الدالة نفسه يضيف مرجعاً مؤقتاً، كما تقول صفحة [sys](https://docs.python.org/3/library/sys.html?ref=arabroot.io).
- بعد `del a` بقيت الفاتورة لأن `b` ما زال يشير إليها، وبعد `del b` حررت فوراً، وظهرت رسالة التحرير قبل السطر التالي في البرنامج. فالتحرير هنا ليس «يوماً ما» كما في Go، وإنما في نفس السطر.
- في الحالة الثانية تشير كل فاتورة إلى الأخرى، فبعد حذف المتغيرين بقي عداد كل منهما 1، ولم يحرر شيء. وهذه هي نقطة الضعف الكبرى في عد المراجع: الدوائر Cycles.
- ولهذا يملك Python جامعاً ثانياً للدوائر في الوحدة [gc](https://docs.python.org/3/library/gc.html?ref=arabroot.io)، يعمل دورياً أو عندما تستدعيه، فوجد الكائنين وحررهما.

ولكي نرى ثمن الدوائر عطلنا جامعها بالدالة `gc.disable()` وأنشأنا مئتي دائرة، ثم قسنا الذاكرة بالوحدة [tracemalloc](https://docs.python.org/3/library/tracemalloc.html?ref=arabroot.io):

```text
3) cycles with the collector disabled
  200 cycles later: 400 MiB still allocated
  gc.collect(): 398 objects in 2.4 ms, now 2 MiB
```

أربعمئة فاتورة بقيت في الذاكرة لا يصل إليها البرنامج، 400 MiB، وعندما أعدنا الجامع وجد 398 كائناً في 2.4 ملي ثانية فقط ونزلت الذاكرة إلى 2 MiB. ولاحظ أن الرقم 398 وليس 400، والفرق أن آخر دائرة ما زال المتغيران `p` و`q` في الحلقة يشيران إليها، فهي ليست قمامة بعد. والدرس العملي أن البرامج التي تبني بنى فيها دوائر كثيرة، مثل الأشجار التي يعرف فيها الابن أباه، تعتمد على جامع الدوائر، وتعطيله لتسريع البرنامج، وهو شيء تفعله بعض المكتبات، يجب أن يأتي مع استدعاء يدوي له في الوقت المناسب:

![بلا دائرة: الفاتورة 1 يشير إليها a وb فعدادها 2، وبعد del a يصبح 1 وتبقى، وبعد del b يصبح 0 وتحرر فوراً. مع دائرة: الفاتورتان 2 و3 تشير كل منهما إلى الأخرى فيبقى عداد كل منهما 1 بعد del، ويحررهما gc.collect، ومئتا دائرة مع تعطيل الجامع تركت 400 MiB حتى حرر gc.collect 398 كائناً في 2.4 ملي ثانية ونزلت الذاكرة إلى 2 MiB](https://arabroot.io/content/images/2026/10/memory-management-05-refcount.webp)

عد المراجع يحرر فوراً، إلا الدوائر

وعد المراجع له ثمن آخر لا يظهر هنا: كل نسخ لمرجع وكل حذف له يعني تحديث عداد في الذاكرة، وفي برنامج يعمل بعدة خيوط يجب أن يكون هذا التحديث آمناً، وهذا من أسباب وجود القفل العام GIL في Python لسنوات طويلة، كما شرحنا في قسم Python من مقال [التزامن Concurrency والخيوط](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%84%D8%AC-%D9%88%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-%D9%88%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/). ولغة Swift تستخدم نفس الطريقة، ولكن المترجم فيها يضيف تحديثات العداد بنفسه ويحذف ما لا يلزم منها.

## الملكية Ownership في Rust

Rust تجيب عن السؤال بطريقة ثالثة: لا جامع قمامة ولا عدادات ولا `free` يكتبه المبرمج، وإنما ثلاث قواعد يتحقق منها المترجم قبل أن يقبل البرنامج، كما يشرحها [فصل الملكية في كتاب Rust الرسمي](https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html?ref=arabroot.io): لكل قيمة مالك واحد، وفي كل لحظة يوجد مالك واحد فقط، وعندما يخرج المالك من نطاقه تحرر القيمة. ولأن المترجم يعرف أين ينتهي كل نطاق، فهو يضع استدعاء التحرير هناك بنفسه، وهو ما يسمى Drop. والبرنامج `lab/rust_own.rs` يجعل كل فاتورة تطبع رسالة عند تحريرها:

```text
1) scope ends, value is dropped
  end of scope
  dropped invoice 2
  dropped invoice 1
2) ownership moves into a function
  archive() now owns invoice 3
  dropped invoice 3
  back in main, c can no longer be used
```

- في الحالة الأولى حررت الفاتورتان بعد نهاية النطاق مباشرة، وبترتيب عكسي لإنشائهما، 2 ثم 1، وهذا الترتيب مضمون في كل مرة، كما في توثيق [Drop](https://doc.rust-lang.org/std/ops/trait.Drop.html?ref=arabroot.io).
- في الحالة الثانية انتقلت ملكية الفاتورة إلى الدالة `archive`، فحررت عند نهاية تلك الدالة وليس عند نهاية `main`، وطبعت رسالة التحرير قبل الرجوع.

وماذا لو حاولنا استخدام الفاتورة بعد نقلها؟ هذا بالضبط الاستخدام بعد التحرير الذي رأيناه في C، ولكن Rust لا تتركه يصل إلى التشغيل:

```text
error[E0382]: borrow of moved value: `c`
 --> /lab/rust_move.rs:7:20
  |
5 |     let c = Invoice { n: 3 };
  |         - move occurs because `c` has type `Invoice`, which does not implement the `Copy` trait
6 |     archive(c);
  |             - value moved here
7 |     println!("{}", c.n);
  |                    ^^^ value borrowed here after move
```

والمترجم لم يكتف برفض البرنامج، وإنما أشار إلى سطر الإنشاء وسطر النقل وسطر الاستخدام، واقترح في بقية الرسالة أن تجعل الدالة تستعير القيمة بدلاً من أن تأخذ ملكيتها. والاستعارة Borrowing بالرمز `&` هي الطريقة التي تعطي بها دالة إمكانية القراءة دون أن تنقل الملكية، ولها قواعدها الخاصة التي عرضنا أمثلتها في مقال أمان الذاكرة، وشرح هذا الخطأ بالتفصيل في صفحة [E0382](https://doc.rust-lang.org/error%5Fcodes/E0382.html?ref=arabroot.io).

### الملكية لا تخص الذاكرة وحدها: RAII

لاحظ أن ما رأيناه في Rust لا يحرر الذاكرة فقط، وإنما يستدعي الدالة `drop` التي كتبناها نحن، وهي التي طبعت الرسائل. ونفس الآلية تغلق الملفات وتفك الأقفال وتعيد اتصالات قاعدة البيانات إلى مجموعتها، فالملف المفتوح في Rust يغلق عندما ينتهي المتغير الذي يملكه، دون أي سطر `close`، ولا يمكن أن تنساه في فرع خطأ. وهذه الفكرة أقدم من Rust، فقد جاءت من C++ باسم «حجز المورد عند التهيئة» Resource Acquisition Is Initialization، واختصاراً RAII، حيث يحرر الكائن موارده في دالة الهدم Destructor عند خروجه من النطاق. وهنا يظهر فرق مهم مع جامع القمامة: الجامع يحرر الذاكرة يوماً ما، ولا يعرف أحد متى، ولذلك لا يمكن الاعتماد عليه لإغلاق ملف أو فك قفل، فتحتاج لغات الجامع إلى بنى منفصلة لذلك، مثل `defer` في Go و`with` في Python و`try-with-resources` في Java، وقد مررنا على بعضها في مقال [كيف تتعامل لغات البرمجة مع الأخطاء](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AA%D8%B9%D8%A7%D9%85%D9%84-%D9%84%D8%BA%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%85%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1/). أما في Rust وC++ فالتحرير المتوقع للذاكرة والإغلاق المتوقع للموارد آلية واحدة.

### التسرب الذي تسمح به Rust: دوائر Rc

بعض البيانات تحتاج أكثر من مالك فعلاً، مثل عقدة في رسم بياني يشير إليها عدة عقد. ولهذا تعطيك Rust النوع `Rc`، وهو عد مراجع مثل Python تماماً، وتعرف ماذا يعني ذلك: الدوائر. وفي الحالة الثالثة من البرنامج تشير العقدة `x` إلى `y` و`y` إلى `x`:

```text
3) Rc cycle: counted references that point at each other
  strong count of x: 2
  end of scope: x and y were never dropped (leaked)
4) same cycle with Weak on one side
  strong count of x: 1, weak: 1
  dropped node wy
  dropped node wx
  end of scope
```

في الحالة الثالثة كان عداد `x` اثنين، المتغير والعقدة الأخرى، وبعد نهاية النطاق نزل إلى واحد ولم يصل إلى صفر أبداً، فلم تطبع رسالة تحرير لأي عقدة، وبقيتا في الذاكرة حتى نهاية البرنامج. ولاحظ أن البرنامج ترجم دون أي تحذير، فقواعد الملكية تمنع الاستخدام بعد التحرير والتحرير مرتين، ولا تعد بمنع التسرب، وكتاب Rust يقول ذلك صراحة في فصل [دوائر المراجع](https://doc.rust-lang.org/book/ch15-06-reference-cycles.html?ref=arabroot.io). والحل في الحالة الرابعة مرجع ضعيف `Weak` في أحد الاتجاهين، لا يحسب في العداد القوي، فصار عداد `x` القوي واحداً وحررت العقدتان عند نهاية النطاق:

![أربع حالات من Rust: نهاية النطاق تحرر الفاتورتين 2 ثم 1 بترتيب عكسي، والنقل إلى دالة يجعل المالك الجديد يحرر عند نهايته، والاستخدام بعد النقل يرفضه المترجم بالخطأ E0382، ودائرة Rc بعداد قوي 2 لا تحرر أبداً بينما مع Weak حررت العقدتان wy وwx](https://arabroot.io/content/images/2026/10/memory-management-06-ownership.webp)

المترجم يضع التحرير بنفسه، والدائرة وحدها تفلت منه

## نفس العمل في أربع لغات: ماذا تكلف كل طريقة؟ القياس Benchmark

بعد أن رأينا الأخطاء، ننظر إلى الثمن. كتبنا نفس العمل بأربع لغات: مليونا سجل صغير، فيه رقم وسعر ورمز من 16 بايت، كل سجل في حجز مستقل في ال Heap، ثم جمع الأسعار، ثم تحرير كل شيء، خمس مرات. وفي C يحرر البرنامج كل سجل بالسطر `free`، وفي Go يترك القائمة للجامع، وفي Rust يضع كل سجل في `Box` فيحررها المترجم عند نهاية النطاق، وفي Python ينقص عد المراجع إلى صفر عند حذف القائمة. وهذه نسخة Rust مثلاً:

```rust
        {
            let mut list: Vec<Box<Rec>> = Vec::with_capacity(N);
            for i in 0..N {
                list.push(Box::new(Rec { id: i as i64, price: i as f64 * 0.5, sku: *b"SKU-000000000000" }));
            }
            sum = list.iter().map(|r| r.price).sum::<f64>();
            let _ = (list[0].id, list[0].sku[0]);
        } // <- list and every Box are dropped here, no GC, no free() written by hand
```

وشغلنا الأربعة تحت `/usr/bin/time`، ثم أعدنا تشغيل الثلاثة الأولى مرة ثانية، والنتائج كاملة في `lab/bench.out` و`lab/bench-run2.out`:

![جدول نتائج نفس العمل: C مع malloc وfree بزمن 3.84 ثم 1.99 ثانية وذروة 111 MB، وGo مع جامع القمامة بزمن 1.58 ثم 1.45 ثانية وذروة 155 MB وأطول توقف 0.35 أو 14 ملي ثانية، وRust مع Box بزمن 3.77 ثم 2.12 ثانية وذروة 112 MB، وPython بزمن 32.79 ثانية وذروة 276 MB، وتحته ملاحظة أن الفرق بين التشغيلين في نفس اللغة أكبر أحياناً من الفرق بين اللغات](https://arabroot.io/content/images/2026/10/memory-management-07-bench.webp)

الذاكرة متسقة بين التشغيلين، والوقت ليس كذلك

وهذه الأرقام تقول أشياء أكثر مما تبدو:

- ذروة الذاكرة ثابتة في كل تشغيل: C وRust 111 MB تقريباً، لأنهما يحرران كل سجل في لحظة معروفة وتستخدمان نفس مخصص الذاكرة من glibc. وGo 155 MB، أي حوالي 40% أكثر، وهي المساحة التي يتركها الجامع للقمامة بين دورة وأخرى كما رأينا مع `GOGC`. وPython 276 MB لأن كل سجل فيها كائن كامل بعداد ونوع ورأس، وليس 40 بايت متجاورة.
- Go كان الأسرع في التشغيلين، وهذا يفاجئ من يظن أن الجامع بطء دائماً. والسبب أن الجامع يحرر القمامة دفعة واحدة، وأن الحجز في Go سريع جداً، بينما C وRust يمران على مليوني استدعاء `malloc` ومليوني استدعاء `free` في كل دورة. والجامع لم يجعل البرنامج أبطأ هنا، وإنما جعله يأخذ ذاكرة أكثر.
- أطول توقف للجامع كان 0.35 ملي ثانية في التشغيل الأول و14 ملي ثانية في الثاني، والفرق على نفس البرنامج يبين أن التوقفات تتأثر بما يحدث على الجهاز في نفس اللحظة. و14 ملي ثانية لا تهم في تقرير ليلي، وتهم في لعبة أو نظام تداول.
- Python أبطأ من الباقين بين ثماني مرات وعشرين مرة بحسب التشغيل، وأغلب الفرق ليس من إدارة الذاكرة وحدها وإنما من المفسر نفسه، فإنشاء كائن في Python يمر بطبقات كثيرة.

والمشكلة التي واجهتنا هنا هي عمود الوقت نفسه: C أخذ 3.84 ثانية في التشغيل الأول و1.99 في الثاني، وRust 3.77 ثم 2.12، وفي التشغيل الأول أخذت دورة واحدة من Rust 1.6 ثانية بينما أخذت الدورات الأخرى نصف ثانية. ونفس الكود ونفس الجهاز، والفرق كله من الجهاز المشترك الذي تعمل عليه برامج أخرى في نفس اللحظة. فلو قسنا مرة واحدة لقلنا إن Rust أبطأ من C، أو إن C أبطأ بمرتين من Go، وكلاهما استنتاج من ضوضاء. ولهذا لا تثق بأي مقارنة أداء من تشغيل واحد، لا من مقال ولا من قياسك أنت، وكرر القياس وانظر إلى الفرق بين التشغيلات قبل الفرق بين اللغات. أما الذاكرة فكانت متسقة لأنها لا تتأثر بالمنافسة على المعالج، ولهذا هي الرقم الذي تعتمد عليه هنا.

## كيف تختار؟ جدول قرار Decision Table

إدارة الذاكرة نادراً ما تكون السبب الوحيد لاختيار لغة، ولكنها جزء من القرار، وقد شرحنا بقية العوامل في مقال [أنماط البرمجة وأنظمة الأنواع](https://arabroot.io/articles/%D8%A7%D9%86%D9%85%D8%A7%D8%B7-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%88%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/). والجدول التالي يجمع ما رأيناه في المختبر:

| ما يهمك                                  | الطريقة الأنسب                          | والسبب من المختبر                                     |
| ---------------------------------------- | --------------------------------------- | ----------------------------------------------------- |
| أقل ذاكرة ممكنة وتحكم كامل               | يدوياً أو الملكية                       | 111 MB في C وRust مقابل 155 في Go و276 في Python      |
| لا أخطاء تحرير ولا عمل إضافي وقت التشغيل | الملكية في Rust                         | المترجم رفض الاستخدام بعد النقل قبل التشغيل           |
| سرعة كتابة الكود وفريق متنوع الخبرة      | جامع القمامة                            | لا تحرير يدوي، والثمن ذاكرة إضافية يمكن ضبطها         |
| زمن استجابة ثابت دون توقفات              | الملكية أو يدوياً، أو جامع مضبوط بعناية | توقف الجامع وصل إلى 14 ms في أحد التشغيلين            |
| تحرير متوقع لحظة انتهاء الاستخدام        | عد المراجع أو الملكية                   | رسالة التحرير في Python وRust ظهرت في نفس السطر       |
| خدمة في Container بحد ذاكرة معروف        | جامع مع حد صريح                         | GOMEMLIMIT=400MiB أبقى الذروة 404 MiB                 |
| كود C موجود لا يمكن إعادة كتابته         | يدوياً مع الأدوات                       | Valgrind وAddressSanitizer وجدا الخطأين بأرقام السطور |

## قائمة تحقق Checklist قبل أن تشغل خدمتك في الإنتاج

- في C وC++: اختبارات تعمل بالخيار `-fsanitize=address`، وتشغيل Valgrind على سيناريو طويل مرة قبل كل إصدار كبير.
- في لغات الجامع: حد للذاكرة يعرفه الجامع، مثل `GOMEMLIMIT` في Go أو `-Xmx` في Java، أقل قليلاً من حد الـ Container.
- كل ذاكرة مؤقتة Cache لها حد للعدد أو الحجم ومدة صلاحية، وليست خريطة تكبر بلا نهاية.
- مراقبة الذاكرة الحية بعد الجمع لا الذاكرة الكلية فقط، لأن الثانية ترتفع وتنزل طبيعياً، والأولى هي التي تكشف التسرب.
- في Python: لا تعطل جامع الدوائر إلا مع استدعاء يدوي له، وتجنب الدوائر في البنى الكبيرة بالمراجع الضعيفة من الوحدة `weakref`.
- في Rust: أي `Rc` أو `Arc` يشير في الاتجاهين يحتاج `Weak` في أحدهما.
- قياسات الأداء والذاكرة تتكرر أكثر من مرة، وتقارن الذاكرة قبل الوقت لأنها أقل تأثراً بالضوضاء.

## أخطاء شائعة Common Mistakes

- الظن أن جامع القمامة يمنع التسرب: يمنع أخطاء التحرير فقط، والخريطة العامة في Go احتفظت بـ 981 MiB لأن البرنامج ما زال يشير إليها.
- تجاهل أن الشريحة أو النص الفرعي قد يمسك البيانات الأصلية كلها: 1 KiB أبقى 10 KiB حية في كل طلب.
- إيقاف الجامع لتسريع البرنامج: `GOGC=off` لم يجعل البرنامج أسرع وأخذ 2722 MiB.
- رفع `GOGC` لتقليل استهلاك المعالج دون حد للذاكرة: الذروة تتضاعف، والـ Container يقتل عند حده، والأفضل `GOMEMLIMIT` قريب من الحد.
- الاعتماد على أن الاستخدام بعد التحرير يوقف البرنامج: في المختبر قرأ رصيداً خاطئاً وخرج برمز 0.
- تشغيل الاختبارات في C وC++ دون AddressSanitizer، وتشغيل الإنتاج به.
- تعطيل جامع الدوائر في Python دون استدعائه يدوياً: 200 دائرة أبقت 400 MiB.
- الظن أن Rust لا تتسرب: دائرة `Rc` بقيت في الذاكرة دون أي تحذير، والحل `Weak`.
- قياس الأداء مرة واحدة: نفس برنامج C أخذ 3.84 ثانية مرة و1.99 مرة.
- قياس ذاكرة برنامج دون التأكد أن الحجز يحدث فعلاً: تحليل الهروب في Go أزال القمامة كلها في أول تجربة.

## حذف المختبر Cleanup

```bash
docker rmi sbl-mem:1
```

## الخلاصة

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

- كل لغة تجيب عن سؤال واحد: من يحرر الذاكرة ومتى، والإجابات أربع: المبرمج، أو الجامع المتتبع، أو عد المراجع، أو المترجم عبر الملكية.
- في C سطر `free` منسي كلف 32 بايت لكل طلب ولم يظهر أي خطأ، وValgrind أشار إلى السطر، والاستخدام بعد التحرير قرأ قيمة خاطئة بصمت حتى كشفه AddressSanitizer.
- الدالة `free` تعيد القطع الصغيرة إلى مكتبة C لا إلى النواة، فثبات الذاكرة على رقم مرتفع ليس تسرباً، والتسرب هو النمو المستمر مع عدد الطلبات.
- تحليل الهروب في Go يضع الكائنات التي لا تخرج من دالتها على ال Stack، فتقليل الحجوزات في الكود الساخن يخفف عمل الجامع أكثر من أي ضبط له.
- جامع Go يوقف البرنامج لأجزاء من الملي ثانية في الغالب، والمقبض `GOGC` يبادل الذاكرة بوقت المعالج: من 455 MiB و34 دورة إلى 1326 MiB و5 دورات.
- الجامع لا يمنع التسرب، وشريحة صغيرة في خريطة عامة أبقت عشرة أضعاف حجمها حية.
- Python يحرر فوراً بعد آخر مرجع، والدوائر تحتاج جامعها الخاص الذي حرر 400 MiB في 2.4 ملي ثانية.
- الملكية في Rust وRAII في C++ تغلق الملفات وتفك الأقفال بنفس آلية تحرير الذاكرة، بينما تحتاج لغات الجامع إلى `defer` و`with` لأن الجامع لا يعد بوقت.
- Rust تحرر في نهاية النطاق بترتيب مضمون، وترفض الاستخدام بعد النقل عند الترجمة، وتسمح بتسرب دوائر `Rc` وحلها `Weak`.
- في نفس العمل أخذ C وRust 111 MB وGo 155 MB وPython 276 MB، وكان Go الأسرع، والوقت تغير بين التشغيلات أكثر مما تغير بين بعض اللغات.

[![](https://arabroot.io/content/images/2026/10/memory-basics-cover-cover.webp)دليلالذاكرة للمبرمجين: كيف تعمل ال Stack وال Heapلماذا تتغير قائمة لم تطلب تغييرها، وكيف تتسرب الذاكرة في لغة فيها جامع قمامة Garbage Collector، بأمثلة حقيقية من عدة لغات.](https://arabroot.io/articles/%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D9%86-stack-%D9%88-heap-%D9%88%D8%A7%D9%84%D9%85%D8%A4%D8%B4%D8%B1%D8%A7%D8%AA/)[![](https://arabroot.io/content/images/2026/10/memory-safety-rust-cover-cover.webp)دليلأمان الذاكرة Memory Safety: لماذا تدفع الحكومات نحو Rust؟ماذا طلبت الحكومات فعلاً بشأن C وC++، ولماذا تسبب أخطاء الذاكرة أغلب الثغرات الخطيرة، ومتى يكون Rust هو الاختيار الصحيح.](https://arabroot.io/articles/%D8%A7%D9%85%D8%A7%D9%86-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-memory-safety-%D9%84%D9%85%D8%A7%D8%B0%D8%A7-%D8%AA%D8%AF%D9%81%D8%B9-%D8%A7%D9%84%D8%AD%D9%83%D9%88%D9%85%D8%A7%D8%AA-%D9%86%D8%AD%D9%88-rust/)[![](https://arabroot.io/content/images/2026/10/os-virtual-memory-cover-cover.webp)دليلالذاكرة الافتراضية Virtual Memory: جداول الصفحات وخطأ الصفحةنتابع العنوان من برنامجك إلى شريحة الذاكرة، ونفهم بأرقام حقيقية لماذا تخدعك أعمدة VIRT وRES، ومتى تسرع الصفحات الكبيرة برنامجك.](https://arabroot.io/articles/%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D8%A7%D9%84%D8%A7%D9%81%D8%AA%D8%B1%D8%A7%D8%B6%D9%8A%D8%A9-virtual-memory-%D9%88%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B5%D9%81%D8%AD%D8%A7%D8%AA/)[![](https://arabroot.io/content/images/2026/10/cpu-concurrency-cover-cover.webp)دليلالتزامن Concurrency والخيوط: ماذا يعني لسيرفرك وتطبيقاتكلماذا يكون المعالج عند 100% والتطبيق بطيئاً، ولا تسرع الخيوط كود Python، وكيف تضبط عدد العمال وحدود ال Containers.](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%84%D8%AC-%D9%88%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-%D9%88%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/threading-cover-cover.webp)دليلThreads وasync/await: كود لا يتجمد ولا يضيع بياناتهكيف تكتب كوداً يعمل في أكثر من خيط Thread دون أن تضيع بياناته أو يتجمد، ولماذا تظهر هذه الأخطاء على السيرفر ولا تظهر على جهازك.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-threads-%D9%88%D8%A7%D9%84%D8%A3%D9%82%D9%81%D8%A7%D9%84-%D9%88-async-%D9%84%D9%84%D9%85%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D9%86/)

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

- أكتوبر 2026: كتابة المقال، وتنفيذ التجارب داخل Container من Debian 13 (trixie) مع gcc 14.2.0 وGo 1.24.4 وrustc 1.85.1 وPython 3.13.5 وValgrind 3.24.0، على نواة Linux 5.15 (WSL2) ومعالج بثماني أنوية منطقية وذاكرة 32 GB، على Docker Engine 29.8.0.