> ## 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.

# الذاكرة الافتراضية Virtual Memory: جداول الصفحات وخطأ الصفحة
- URL: https://arabroot.io/articles/الذاكرة-الافتراضية-virtual-memory-وجداول-الصفحات/
- Published: 2026-10-30T13:20:25.000Z
- Updated: 2026-10-30T23:17:55.000Z
- Description: نتابع العنوان من برنامجك إلى شريحة الذاكرة، ونفهم بأرقام حقيقية لماذا تخدعك أعمدة VIRT وRES، ومتى تسرع الصفحات الكبيرة برنامجك.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, أساسيات الحاسب والبرمجة, لينكس والسيرفرات

عندما تفتح `top` على سيرفرك وتنظر إلى خدمة بسيطة تعرف أنها لا تحمل أكثر من بضع مئات من الميغابايت، ثم تجد في العمود VIRT رقماً مثل 10 GB، فأول سؤال يأتي في ذهنك: من أين جاءت هذه العشرة؟ ثم تنظر إلى العمود RES فتجده رقماً آخر، ثم تجمع RES لكل العمليات فيظهر لك أكثر من الذاكرة الموجودة في السيرفر أصلاً. وفي يوم آخر يتوقف Container فجأة بالرمز 137 دون أي رسالة في سجلات التطبيق، ويقول لك أحد الزملاء إن الحل أن توقف الصفحات الكبيرة Huge Pages، ويقول آخر إن الحل أن تغير الإعداد `vm.overcommit_memory`، وكل منهما متأكد من كلامه.

وكل هذه الأرقام والقرارات تعود إلى فكرة واحدة اسمها الذاكرة الافتراضية Virtual Memory، أي أن البرنامج لا يرى شرائح الذاكرة RAM الحقيقية أبداً، وإنما يرى عناوين افتراضية تترجمها النواة والمعالج معاً إلى أماكن حقيقية عند كل وصول. وقد مررنا على هذه الفكرة سريعاً في قسم [التحميل في الذاكرة الافتراضية](https://arabroot.io/articles/%D8%B1%D8%AD%D9%84%D8%A9-hello-world-%D9%85%D9%86-%D9%84%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D9%81%D8%A7%D8%AA%D9%8A%D8%AD-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%B4%D8%A7%D8%B4%D8%A9/#loading) من مقال Hello World، ورأينا في مقال [الذاكرة للمبرمجين](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/#layout) كيف يقسم البرنامج مساحته إلى Stack وHeap، أما هنا فسوف ننزل تحت ذلك: كيف تترجم العناوين فعلاً، وماذا يحدث في اللحظة التي يلمس فيها برنامجك صفحة لأول مرة، ولماذا يمكن أن تحجز 200 GB على جهاز فيه 32 GB، وكيف تقرأ أرقام الذاكرة دون أن تخدعك. وكل رقم في هذا المقال خرج من تجربة نفذناها بأنفسنا، والأوامر نفسها تعمل على سيرفرك.

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

- لماذا لا ترى البرامج الذاكرة الحقيقية، وكيف وصلنا إلى التقسيم إلى صفحات Paging.
- الصفحة والإطار وجدول الصفحات بأربعة مستويات، وكم تكلف الجداول نفسها من الذاكرة.
- ذاكرة الترجمات TLB والصفحات الكبيرة Transparent Huge Pages، وتجربة تجعل نفس الكود أسرع ثلاث مرات.
- خطأ الصفحة Page Fault الصغير والكبير، ولماذا يكون أغلبه طبيعياً، وماذا يحدث عندما تلغي القراءة المسبقة.
- الطلب `mmap` وربط الملفات بالذاكرة، والنسخ عند الكتابة Copy-on-Write بعد `fork` بأرقام الإطارات الحقيقية.
- كيف تقرأ VSZ وRSS وPSS، ولماذا يكذب مجموع RSS.
- الحجز الزائد Overcommit وقاتل العمليات OOM Killer داخل Container.
- الأخطاء الشائعة، وجدول قرار لأكثر الأسئلة تكراراً.

## تجهيز المختبر

نستخدم نفس ال Image التي بنيناها في مقال العمليات والجدولة من هذه المرحلة، وهي Debian 13 مع `gcc` و`perf` والأداة `/usr/bin/time` من GNU التي تعد أخطاء الصفحات. وإذا لم تبنها بعد فهذا ملفها:

```bash
mkdir vm-lab && cd vm-lab
cat > Dockerfile <<'EOF'
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends procps strace time util-linux e2fsprogs gcc libc6-dev python3-minimal linux-perf psmisc && rm -rf /var/lib/apt/lists/*
WORKDIR /lab
EOF
docker build -t sos-tools:1 .
```

والبرامج كلها بلغة C، والسبب أن الذاكرة في لغات مثل Java وPython وNode.js تمر عبر مدير ذاكرة خاص باللغة يحجز ويعيد الذاكرة على طريقته، فلا ترى بوضوح ما تفعله النواة، أما في C فكل طلب `mmap` نكتبه يصل إلى النواة كما هو. والجهاز معالج Intel Core i7-7700K مع 32 GB من الذاكرة، ونواة Linux 5.15 داخل WSL2 تحت Docker Desktop.

## لماذا لا ترى البرامج الذاكرة الحقيقية؟

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

والحل الثاني كان أن يعطي النظام كل برنامج قطعة واحدة متصلة، ويضيف المعالج إلى كل عنوان يطلبه البرنامج رقم بداية قطعته، ويرفض أي عنوان خارج حدودها، وهذه فكرة السجل الأساسي والحد Base and Limit. وهذا الحل يحمي البرامج من بعضها ويحل مشكلة المكان، ولكنه لا يحل التجزئة، فالبرنامج ما زال يحتاج قطعة متصلة كاملة، ولا يستطيع أن يكبر إذا كان بعده برنامج آخر.

والحل الذي بقي حتى اليوم هو التقسيم إلى صفحات Paging: تقسم مساحة عناوين كل برنامج إلى صفحات Pages متساوية الحجم، وتقسم الذاكرة الحقيقية إلى إطارات Frames بنفس الحجم، ثم يحتفظ النظام لكل برنامج بجدول صفحات Page Table يقول أين يوجد كل صفحة افتراضية في الذاكرة الحقيقية، إن كانت موجودة أصلاً. وبهذا الشكل يرى البرنامج مساحة متصلة كبيرة، بينما صفحاته الحقيقية متفرقة في أي مكان، ولا يرى إطارات غيره لأنها ليست في جدوله. ويذكر توثيق [جداول الصفحات في النواة](https://docs.kernel.org/mm/page%5Ftables.html?ref=arabroot.io) أن هذه الفكرة ظهرت عام 1962 في حاسب Ferranti Atlas، وأنها دخلت معالجات Intel مع المعالج 80386 عام 1985، وهو المعالج الذي كتب عليه Linux 1.0\. والصورة التالية تبين الفكرة:

![عمليتان لكل منهما مساحة عناوين افتراضية متصلة مقسمة إلى صفحات، وجدول صفحات لكل عملية يربط صفحاتها بإطارات متفرقة في الذاكرة الحقيقية، وبعض الصفحات غير موجودة في الذاكرة، وإطار واحد مشترك بين العمليتين لمكتبة مشتركة](https://arabroot.io/content/images/2026/10/os-virtual-memory-01-paging.webp)

كل عملية ترى مساحة متصلة، وجدول الصفحات يربط صفحاتها بإطارات متفرقة في RAM

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

## الصفحة Page والإطار Frame وجدول الصفحات Page Table

حجم الصفحة في أغلب السيرفرات 4096 بايت، أي 4 KB، والأمر `getconf PAGESIZE` يعرضه، وقد ظهر في تجربتنا `4096`. وأي عنوان افتراضي في معالج x86-64 بأربعة مستويات طوله الفعلي 48 بت، يقسمها المعالج إلى خمسة أجزاء: آخر 12 بت هي المكان داخل الصفحة، لأن 2 أس 12 يساوي 4096، وكل جزء من الأجزاء الأربعة الأولى طوله 9 بت، وهو رقم مدخل في جدول من مستوى. فالجدول الأعلى يشير إلى جدول في المستوى الثاني، وهذا يشير إلى ثالث، والثالث إلى رابع، والرابع فيه أخيراً رقم الإطار الحقيقي. وكل جدول من هذه الجداول صفحة واحدة بحجم 4 KB فيها 512 مدخلاً، لأن كل مدخل 8 بايت و2 أس 9 يساوي 512\. وأسماء هذه المستويات في كود Linux هي PGD ثم PUD ثم PMD ثم PTE، ويوجد بين الأول والثاني مستوى خامس P4D يستخدم فقط في المعالجات التي تدعم خمسة مستويات بعناوين 57 بت كما في [توثيق الصفحات بخمسة مستويات](https://docs.kernel.org/arch/x86/x86%5F64/5level-paging.html?ref=arabroot.io)، ونواتنا مبنية بأربعة مستويات كما في الإعداد `CONFIG_PGTABLE_LEVELS=4`.

![عنوان افتراضي من 48 بت مقسم إلى أربعة أجزاء من 9 بت وجزء من 12 بت، وكل جزء يختار مدخلاً في جدول من مستوى: PGD ثم PUD ثم PMD ثم PTE، والمدخل الأخير يعطي رقم الإطار الحقيقي، ثم يضاف إليه المكان داخل الصفحة، والسجل CR3 يشير إلى الجدول الأول](https://arabroot.io/content/images/2026/10/os-virtual-memory-02-page-walk.webp)

ترجمة عنوان افتراضي بأربعة مستويات، وكل مستوى جدول من 512 مدخلاً

وقد يتساءل البعض: لماذا هذه المستويات كلها، ولماذا لا يكون جدولاً واحداً كبيراً؟ والإجابة في الحساب: مساحة 48 بت فيها 2 أس 36 صفحة، أي قرابة 68 مليار صفحة، ولو كان لكل عملية جدول واحد بمدخل لكل صفحة لاحتاج كل جدول 512 GB. أما مع المستويات فالجداول تبنى فقط للأجزاء المستخدمة من المساحة، فإذا كان برنامجك يستخدم بضع مناطق متفرقة احتاج بضع عشرات من الجداول الصغيرة. وهذا أيضاً يفسر حجم المساحة المتاحة للبرامج: توثيق [خريطة الذاكرة في x86-64](https://docs.kernel.org/arch/x86/x86%5F64/mm.html?ref=arabroot.io) يعطي مساحة المستخدم 128 TB في أربعة مستويات، والنصف الآخر من 256 TB للنواة.

وكل مدخل في الجدول الأخير ليس رقم إطار فقط، وإنما فيه بتات صغيرة يقرؤها المعالج عند كل وصول: بت الوجود Present الذي يقول هل الصفحة في الذاكرة الآن، وبت الكتابة الذي يقول هل الكتابة مسموحة، وبت المستخدم الذي يمنع البرامج من صفحات النواة، وبت منع التنفيذ NX الذي يمنع تنفيذ البيانات كأنها كود، وبتان يكتبهما المعالج بنفسه: بت الوصول Accessed عند أي قراءة، وبت التعديل Dirty عند أي كتابة. وهذان البتان هما ما تستخدمه النواة لاحقاً لتعرف أي الصفحات لم يلمسها أحد منذ فترة فتستطيع أن تتخلص منها، وأي صفحة تحتاج أن تكتب إلى القرص قبل ذلك.

### كم تكلف جداول الصفحات نفسها؟

الجداول نفسها ذاكرة، وتعرض النواة حجمها لكل عملية في السطر `VmPTE` من الملف `/proc/<pid>/status`. والبرنامج التالي يحجز مساحة بالحجم الذي تعطيه، ثم يلمس بايتاً واحداً في كل صفحة منها، ويطبع قبل الحجز وبعده وبعد اللمس عدد أخطاء الصفحات وأرقام الذاكرة من النواة:

```c
/* touch.c: reserve N MB of anonymous memory, then touch one byte in every 4 KB page.
   Usage: touch <MB> <thp|nothp>. Prints what the kernel recorded before and after. */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <time.h>

static void show(const char *when)
{
    char line[256];
    struct rusage ru;
    getrusage(RUSAGE_SELF, &ru);
    printf("%-14s minflt=%-7ld majflt=%-3ld", when, ru.ru_minflt, ru.ru_majflt);
    FILE *f = fopen("/proc/self/status", "r");
    while (fgets(line, sizeof line, f))
        if (!strncmp(line, "VmSize", 6) || !strncmp(line, "VmRSS", 5) || !strncmp(line, "VmPTE", 5)) {
            line[strcspn(line, "\n")] = 0;
            char *p = line; while (*p && *p != ':') p++; *p = 0; p++; while (*p == ' ' || *p == '\t') p++;
            printf(" %s=%s", line, p);
        }
    fclose(f);
    f = fopen("/proc/self/smaps_rollup", "r");
    while (f && fgets(line, sizeof line, f))
        if (!strncmp(line, "AnonHugePages", 13)) { line[strcspn(line, "\n")] = 0; printf(" %s", line); }
    if (f) fclose(f);
    printf("\n");
}

int main(int argc, char **argv)
{
    size_t mb = argc > 1 ? atol(argv[1]) : 256;
    int thp = argc > 2 && !strcmp(argv[2], "thp");
    size_t len = mb << 20;
    show("start");
    char *p = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (p == MAP_FAILED) { perror("mmap"); return 1; }
    madvise(p, len, thp ? MADV_HUGEPAGE : MADV_NOHUGEPAGE);
    show("after mmap");
    struct timespec a, b;
    clock_gettime(CLOCK_MONOTONIC, &a);
    for (size_t i = 0; i < len; i += 4096) p[i] = 1;
    clock_gettime(CLOCK_MONOTONIC, &b);
    show("after touch");
    printf("touching %zu pages took %.1f ms\n", len / 4096,
           (b.tv_sec - a.tv_sec) * 1e3 + (b.tv_nsec - a.tv_nsec) / 1e6);
    return 0;
}
```

ونشغله على 1 GB مرتين: مرة نطلب فيها من النواة ألا تستخدم الصفحات الكبيرة بالخيار `MADV_NOHUGEPAGE`، ومرة نطلبها بالخيار `MADV_HUGEPAGE`، وكلاهما من الطلب `madvise` الذي تشرحه [صفحته](https://man7.org/linux/man-pages/man2/madvise.2.html?ref=arabroot.io):

```bash
docker run --rm --name sos-vm -v "$PWD:/lab" sos-tools:1 sh -c 'gcc -O2 -o /usr/local/bin/touch-mem touch.c && getconf PAGESIZE && cat /sys/kernel/mm/transparent_hugepage/enabled && touch-mem 1024 nothp && echo && touch-mem 1024 thp'
```

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

```
4096
[always] madvise never
start          minflt=56      majflt=0   VmSize=2552 kB VmRSS=1096 kB VmPTE=44 kB AnonHugePages:         0 kB
after mmap     minflt=63      majflt=0   VmSize=1051128 kB VmRSS=1096 kB VmPTE=44 kB AnonHugePages:         0 kB
after touch    minflt=262210  majflt=0   VmSize=1051128 kB VmRSS=1050272 kB VmPTE=2096 kB AnonHugePages:         0 kB
touching 262144 pages took 1456.7 ms

start          minflt=54      majflt=0   VmSize=2552 kB VmRSS=1084 kB VmPTE=44 kB AnonHugePages:         0 kB
after mmap     minflt=62      majflt=0   VmSize=1051128 kB VmRSS=1084 kB VmPTE=44 kB AnonHugePages:         0 kB
after touch    minflt=1088    majflt=0   VmSize=1051128 kB VmRSS=1050088 kB VmPTE=2096 kB AnonHugePages:   1046528 kB
touching 262144 pages took 686.7 ms
```

وسوف نرجع إلى هذا المخرج أكثر من مرة في المقال، ولكن لاحظ الآن السطر `VmPTE` في التشغيل الأول: كان 44 KB وصار 2096 KB بعد لمس 1 GB، والحساب يطابق ذلك تماماً: 262144 صفحة، لكل صفحة مدخل من 8 بايت، أي 2 MB من الجداول في المستوى الأخير وحده. وهذه الكلفة صغيرة لعملية واحدة، ولكنها تصبح مهمة عندما يكون عندك مئات العمليات التي تربط نفس الذاكرة المشتركة الكبيرة، مثل قاعدة بيانات تشغل مئات الاتصالات كل منها عملية مستقلة، فكل عملية تبني جداولها الخاصة لنفس الذاكرة. ولاحظ أن VmPTE لم ينقص في التشغيل الثاني مع الصفحات الكبيرة، والسبب أن النواة تحجز جدولاً احتياطياً من المستوى الأخير مع كل صفحة كبيرة حتى تستطيع أن تقسمها لاحقاً إلى صفحات عادية دون أن تحتاج ذاكرة في تلك اللحظة.

## ذاكرة الترجمات TLB والصفحات الكبيرة Huge Pages

إذا كان كل وصول للذاكرة يحتاج قراءة أربعة جداول قبل قراءة البيانات نفسها، فالبرنامج يبطئ إلى الخمس، لذلك يحتفظ المعالج بذاكرة صغيرة جداً للترجمات الحديثة اسمها Translation Lookaside Buffer واختصاراً TLB، فإذا وجد ترجمة العنوان فيها لم يقرأ الجداول أصلاً. والمشكلة أن هذه الذاكرة صغيرة، فهي تحمل مئات أو بضعة آلاف من الترجمات، وكل ترجمة لصفحة من 4 KB، أي أن ما تغطيه ببضعة ميغابايت فقط، فإذا كان برنامجك يقفز في مساحة أكبر من ذلك عشوائياً، مثل جدول Hash كبير أو فهرس في الذاكرة، فكل انتقال تقريباً إلى موقع جديد يحتاج المرور على الجداول، وهذا المرور اسمه Page Walk.

والحل هو الصفحات الكبيرة Huge Pages: صفحة من 2 MB تعني أن المعالج يتوقف في المرور عند المستوى الثالث PMD ولا يحتاج المستوى الأخير، وأن ترجمة واحدة في ال TLB تغطي 512 ضعف ما تغطيه الترجمة العادية. ويقدم Linux طريقتين لذلك كما يشرح توثيق [مفاهيم الذاكرة](https://docs.kernel.org/admin-guide/mm/concepts.html?ref=arabroot.io): نظام الملفات `hugetlbfs` الذي تحجز فيه الصفحات الكبيرة مسبقاً وتطلبها البرامج صراحة، والصفحات الكبيرة الشفافة Transparent Huge Pages واختصاراً THP، التي تستخدمها النواة تلقائياً دون تعديل البرنامج. وإعداد THP في نواتنا `[always]`، أي أن النواة تحاول أن تستخدمها لكل ذاكرة مجهولة Anonymous كبيرة بما يكفي، والخيار `madvise` يجعلها لمن يطلبها فقط، و`never` يوقفها.

وفي مخرج التجربة السابقة ترى أثرها الأول مباشرة: لمس 1 GB احتاج 262147 خطأ صفحة مع الصفحات العادية، و1026 خطأ فقط مع الصفحات الكبيرة، أي خطأ لكل 2 MB تقريباً، والسطر `AnonHugePages: 1046528 kB` يقول إن كل الذاكرة تقريباً صارت صفحات كبيرة، والوقت نزل من 1456 إلى 686 ملي ثانية. ولكن توثيق [THP](https://docs.kernel.org/admin-guide/mm/transhuge.html?ref=arabroot.io) نفسه يقول إن هذا الأثر الأول غير مهم، لأنه يحدث مرة واحدة عند أول لمس، وإن الأثر الذي يبقى طوال عمل البرنامج هو أن الإخفاق في ال TLB يصبح أسرع، وأن ترجمة واحدة تغطي مساحة أكبر. ولكي نرى ذلك كتبنا برنامجاً يلمس 1 GB كاملة أولاً، ثم يقرأ منها 20 مليون مرة من أماكن عشوائية، ولا يقيس إلا القراءة:

```c
/* tlb.c: 20 million reads at random places in a 1 GB area, with 4 KB pages or with huge pages.
   Usage: tlb <thp|nothp>. The area is touched first, so no page faults happen during the timed part. */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <time.h>

int main(int argc, char **argv)
{
    size_t len = 1UL << 30;
    int thp = argc > 1 && !strcmp(argv[1], "thp");
    char *p = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    madvise(p, len, thp ? MADV_HUGEPAGE : MADV_NOHUGEPAGE);
    memset(p, 1, len);
    unsigned long x = 88172645463325252UL, sum = 0;
    struct timespec a, b;
    clock_gettime(CLOCK_MONOTONIC, &a);
    for (int i = 0; i < 20000000; i++) {
        x ^= x << 13; x ^= x >> 7; x ^= x << 17;     /* xorshift: a cheap random number */
        sum += p[x & (len - 1)];
    }
    clock_gettime(CLOCK_MONOTONIC, &b);
    printf("%-5s 20M random reads: %.0f ms (sum %lu)\n", argv[1],
           (b.tv_sec - a.tv_sec) * 1e3 + (b.tv_nsec - a.tv_nsec) / 1e6, sum);
    return 0;
}
```

ونشغله أربع مرات بالتناوب تحت `perf stat` حتى نعد أحداث ال TLB أيضاً، ولذلك نحتاج الصلاحية `CAP_PERFMON` فقط، لأن ملف الحماية الافتراضي في Docker يمنع الطلب الذي تستخدمه `perf`:

```bash
docker run --rm --name sos-vm --cap-add PERFMON -v "$PWD:/lab" sos-tools:1 sh -c 'gcc -O2 -o /usr/local/bin/tlb tlb.c && for m in nothp thp nothp thp; do perf stat -e dTLB-loads,dTLB-load-misses,page-faults tlb $m 2>&1 | grep -E "random|dTLB|faults|elapsed"; done'
```

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

```
nothp 20M random reads: 2326 ms (sum 20000000)
         265581413      dTLB-loads
          21614574      dTLB-load-misses                 #    8.14% of all dTLB cache accesses
            262206      page-faults
       4.090911557 seconds time elapsed
thp   20M random reads: 760 ms (sum 20000000)
          27008020      dTLB-loads
          20191387      dTLB-load-misses                 #   74.76% of all dTLB cache accesses
              1085      page-faults
       1.143609578 seconds time elapsed
nothp 20M random reads: 1555 ms (sum 20000000)
         265618288      dTLB-loads
          21598664      dTLB-load-misses                 #    8.13% of all dTLB cache accesses
            262205      page-faults
       2.197775282 seconds time elapsed
thp   20M random reads: 512 ms (sum 20000000)
          26789946      dTLB-loads
          20147177      dTLB-load-misses                 #   74.76% of all dTLB cache accesses
              1085      page-faults
       0.788217437 seconds time elapsed
```

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

- نفس القراءات العشوائية أخذت 1555 و2326 ملي ثانية بالصفحات العادية، و512 و760 ملي ثانية بالصفحات الكبيرة، أي أسرع بقرابة ثلاث مرات في التشغيلين. والفرق بين التشغيلين الأول والثاني لنفس النوع من الضجيج على الجهاز، ولذلك كررنا التجربة.
- عدد الإخفاقات `dTLB-load-misses` متقارب في الحالتين، قرابة 21.6 مليون و20.2 مليون، وهذا يعني أن الصفحات الكبيرة لم تمنع الإخفاق هنا، فمساحة 1 GB بالصفحات الكبيرة ما زالت 512 صفحة، وهي أكبر مما يحمله هذا المستوى من ال TLB. والذي تغير هو كلفة كل إخفاق، وهذا بالضبط ما يقوله توثيق THP: الإخفاق يصبح أسرع. والسبب في حسابنا السابق نفسه: جداول المستوى الأخير للمساحة بالصفحات العادية 2 MB لا تتسع لها ذاكرة ال Cache القريبة من المعالج، فكل مرور على الجداول يذهب غالباً إلى RAM، أما مع الصفحات الكبيرة فيتوقف المرور مستوى أعلى، وكل مداخل المستوى الثالث لهذه المساحة 512 مدخلاً، أي 4 KB فقط تبقى في ال Cache.
- العدادات تشمل البرنامج كله، ومنه مرحلة `memset` الأولى، لذلك ترى `page-faults` كما رأيناها في التجربة السابقة: 262206 مقابل 1085\. ولا تقارن نسبة `dTLB-loads` بين الحالتين لهذا السبب.

![مقارنة بين صفحات 4 كيلوبايت وصفحات 2 ميغابايت لنفس 20 مليون قراءة عشوائية: 2326 و1555 ملي ثانية مقابل 760 و512، وإخفاقات TLB متقاربة 21.6 مليون و20.2 مليون، وأخطاء صفحات 262147 مقابل 1026](https://arabroot.io/content/images/2026/10/os-virtual-memory-05-thp.webp)

الصفحات الكبيرة لم تقلل الإخفاقات، وإنما جعلت كل إخفاق أرخص

وقد يبدو من ذلك أن الصفحات الكبيرة مكسب دائم، ولكن لها كلفة ترى أثرها في وثائق قواعد البيانات نفسها، فدليل [إعداد Redis على Linux](https://redis.io/docs/latest/operate/oss%5Fand%5Fstack/management/admin/?ref=arabroot.io) يطلب أن توقفها بالقيمة `never`، ويرجع ذلك إلى استهلاك الذاكرة وزمن الاستجابة. والسبب سوف تراه في قسم النسخ عند الكتابة: عندما ينسخ Redis نفسه بالطلب `fork` ليحفظ البيانات على القرص، فأي كتابة بعد ذلك تنسخ صفحة كاملة، وهي 2 MB مع الصفحات الكبيرة بدلاً من 4 KB. وبالتالي فالقرار يعتمد على البرنامج: برنامج يقرأ كثيراً من مساحة كبيرة ولا ينسخ نفسه يربح من THP، وبرنامج ينسخ نفسه ويكتب في أماكن متفرقة قد يخسر، والخيار `madvise` هو الوسط الذي يترك القرار لكل برنامج.

## خطأ الصفحة Page Fault ليس خطأ

ارجع إلى مخرج `touch.c` وانظر إلى السطر `after mmap`: طلبنا 1 GB، فزاد `VmSize` بقدر 1 GB تقريباً، أما `VmRSS` فبقي 1096 KB كما هو، وعدد أخطاء الصفحات زاد سبعة فقط. والسبب أن الطلب `mmap` لا يعطي البرنامج ذاكرة حقيقية، وإنما يسجل في النواة أن هذا المدى من العناوين صار مسموحاً، ولا يبني حتى جدول صفحاته. ثم عندما يلمس البرنامج أول بايت في صفحة ما يبحث المعالج عن ترجمتها فلا يجدها، فيرفع استثناء اسمه خطأ الصفحة Page Fault وينتقل إلى النواة، فتنظر النواة في سجلات العملية وتجد أن هذا العنوان مسموح، فتأخذ إطاراً فارغاً من الذاكرة وتملؤه بالأصفار وتضع ترجمته في الجدول، ثم ترجع إلى البرنامج فيعيد المعالج نفس التعليمة وتنجح هذه المرة. وهذا ما يسمى التحميل عند الطلب Demand Paging، والبرنامج لا يعرف أصلاً أن شيئاً حدث، وكل ما تراه هو أن السطر `after touch` فيه 262147 خطأ إضافياً و`VmRSS` صار 1 GB.

ولهذا يسمي البعض الخطأ هنا «استثناء» أكثر منه «خطأ»، وتوجد منه ثلاثة أنواع، وتعدها الأداة `/usr/bin/time -v` كما في [صفحتها](https://man7.org/linux/man-pages/man1/time.1.html?ref=arabroot.io) والطلب `getrusage` كما في [صفحته](https://man7.org/linux/man-pages/man2/getrusage.2.html?ref=arabroot.io):

- الخطأ الصغير Minor Fault: الصفحة لا تحتاج قراءة من القرص، إما لأنها ذاكرة جديدة تملؤها النواة بالأصفار، أو لأن محتواها موجود أصلاً في الذاكرة في ذاكرة الملفات المؤقتة Page Cache، فتربطها النواة فقط. وكلفته ميكروثانية أو أقل.
- الخطأ الكبير Major Fault: محتوى الصفحة على القرص، في ملف أو في مساحة Swap، فتقرؤه النواة والبرنامج ينتظر، وكلفته من عشرات الميكروثانية على SSD إلى ملي ثانية على القرص الدوار.
- الخطأ غير المسموح: العنوان خارج أي منطقة مسموحة أو العملية تكتب على صفحة للقراءة فقط، فترسل النواة الإشارة `SIGSEGV`، وهذه هي رسالة Segmentation Fault المشهورة.

والصورة التالية تجمع الطرق الثلاثة مع الأرقام التي سوف تراها في تجارب هذا القسم:

![البرنامج يقرأ عنواناً، والمعالج لا يجد ترجمته فيرفع خطأ صفحة إلى النواة، والنواة تختار خطأ صغيراً بإطار جديد أو صفحة من ذاكرة الملفات، أو خطأ كبيراً بقراءة من القرص، أو ترسل SIGSEGV لعنوان غير مسموح، مع أرقام التجربة لكل حالة](https://arabroot.io/content/images/2026/10/os-virtual-memory-04-faults.webp)

الأنواع الثلاثة لخطأ الصفحة، والأرقام من تجاربنا في هذا المقال

ونفس البرنامج تحت `/usr/bin/time -v`:

```bash
/usr/bin/time -v touch-mem 1024 nothp 2>&1 | grep -E "Maximum resident|page faults|Elapsed|Command"
```

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

```
	Command being timed: "touch-mem 1024 nothp"
	Elapsed (wall clock) time (h:mm:ss or m:ss): 0:00.87
	Maximum resident set size (kbytes): 1050284
	Major (requiring I/O) page faults: 0
	Minor (reclaiming a frame) page faults: 262233
```

وهذا الأمر أسهل طريقة تعرف بها سلوك أي برنامج مع الذاكرة دون تعديله: أعلى RSS وصل إليه، وعدد الأخطاء من كل نوع. ولاحظ أن التشغيل كله هنا أخذ 0.87 ثانية، بينما أخذ اللمس وحده في التشغيل الأول 1456 ملي ثانية، فالوقت نفسه يتغير بين تشغيل وآخر على نفس الجهاز، بينما بقي عدد الأخطاء ثابتاً تقريباً، لذلك احكم على سلوك البرنامج من عدد الأخطاء ومن أعلى RSS، ولا تحكم على كلفة الذاكرة من وقت تشغيل واحد.

### الخطأ الكبير Major Fault: عندما تكون الصفحة على القرص

لكي نرى الخطأ الكبير نحتاج ملفاً ليس في الذاكرة، ثم نربطه بالذاكرة بالطلب `mmap` ونقرؤه. والبرنامج التالي يطلب من النواة أولاً أن تتخلص من صفحات الملف من ذاكرة الملفات المؤقتة بالطلب `posix_fadvise` كما في [صفحته](https://man7.org/linux/man-pages/man2/posix%5Ffadvise.2.html?ref=arabroot.io)، وهذا متاح لأي مستخدم ولا يحتاج صلاحيات، ثم يقرأ بايتاً من كل صفحة مرتين:

```c
/* filemap.c: map a 256 MB file and read one byte from every page, twice.
   Before the first pass the file is dropped from the page cache with posix_fadvise. */
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <sys/stat.h>
#include <time.h>

static double ms(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return t.tv_sec * 1e3 + t.tv_nsec / 1e6; }

int main(int argc, char **argv)
{
    int fd = open(argv[1], O_RDONLY);
    struct stat st; fstat(fd, &st);
    fdatasync(fd);
    posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);      /* ask the kernel to drop this file's cached pages */
    char *p = mmap(NULL, st.st_size, PROT_READ, MAP_SHARED, fd, 0);
    if (argc > 2) madvise(p, st.st_size, MADV_RANDOM);  /* "random": no read-ahead */
    for (int pass = 1; pass <= 2; pass++) {
        struct rusage a, b; volatile long sum = 0;
        getrusage(RUSAGE_SELF, &a); double t = ms();
        for (off_t i = 0; i < st.st_size; i += 4096) sum += p[i];
        double d = ms() - t; getrusage(RUSAGE_SELF, &b);
        printf("pass %d: %ld pages  major=%ld  minor=%ld  %.1f ms\n", pass, (long)(st.st_size / 4096),
               b.ru_majflt - a.ru_majflt, b.ru_minflt - a.ru_minflt, d);
        if (pass == 1) { munmap(p, st.st_size); p = mmap(NULL, st.st_size, PROT_READ, MAP_SHARED, fd, 0); }
    }
    return 0;
}
```

والملف نضعه في Docker Volume، لأن الملفات داخل طبقة ال Container تمر عبر نظام الملفات المتراكب overlayfs، ونريد هنا نظام ملفات ext4 عادياً كما على سيرفرك:

```bash
docker run --rm --name sos-vm -v sos-vm-data:/data -v "$PWD:/lab" sos-tools:1 sh -c 'gcc -O2 -o /usr/local/bin/filemap filemap.c && dd if=/dev/urandom of=/data/big.bin bs=1M count=256 status=none && ls -l /data/big.bin && df -T /data | tail -1 && cat /sys/block/sdc/queue/read_ahead_kb; echo "-- default read-ahead" && filemap /data/big.bin && echo "-- MADV_RANDOM" && filemap /data/big.bin random; rm /data/big.bin'
docker volume rm sos-vm-data
```

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

```
-rw-r--r-- 1 root root 268435456 Oct 30 13:32 /data/big.bin
/dev/sdc       ext4 1055762868 157321352 844738044  16% /data
128
-- default read-ahead
pass 1: 65536 pages  major=2050  minor=4100  887.7 ms
pass 2: 65536 pages  major=0  minor=4097  23.6 ms
-- MADV_RANDOM
pass 1: 65536 pages  major=65536  minor=4  8099.8 ms
pass 2: 65536 pages  major=0  minor=4097  15.0 ms
```

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

- الملف 256 MB، أي 65536 صفحة، ومع ذلك احتاجت القراءة الأولى 2050 خطأ كبيراً فقط، أي خطأ لكل 32 صفحة تقريباً. والسبب السطر الثالث `128`، وهو قيمة القراءة المسبقة Read-Ahead للقرص بالكيلوبايت: عندما ترى النواة أن البرنامج يقرأ بالترتيب تقرأ 128 KB دفعة واحدة، أي 32 صفحة، فتصبح الصفحات التالية في الذاكرة قبل أن يطلبها البرنامج، ويكون الوصول إليها خطأ صغيراً فقط.
- القراءة الثانية أخذت 23.6 ملي ثانية بدلاً من 887 ملي ثانية، ولا يوجد فيها أي خطأ كبير، لأن الملف كله صار في ذاكرة الملفات المؤقتة، ولم يحتج البرنامج إلا أن تربط النواة الصفحات بجدوله. ولاحظ أن الأخطاء الصغيرة 4097 فقط وليست 65536، أي أن النواة ربطت 16 صفحة مجاورة عند كل خطأ، وهي ميزة اسمها Fault-Around تستغل أن الصفحات المجاورة موجودة أصلاً في الذاكرة.
- ثم جاء سؤال «ماذا يحدث إذا»: ماذا لو أخبرنا النواة أن القراءة عشوائية بالخيار `MADV_RANDOM`؟ هنا توقفت القراءة المسبقة، فصار كل صفحة خطأ كبيراً مستقلاً، 65536 خطأ، والوقت ارتفع إلى 8.1 ثانية، أي قرابة 124 ميكروثانية لكل صفحة، وهذه هي كلفة الذهاب إلى القرص فعلاً على هذا الجهاز.

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

## الطلب mmap: الملف كأنه ذاكرة

في التجارب السابقة استخدمنا `mmap` بطريقتين، وهو الطلب الذي تبنى عليه أغلب الذاكرة في أي عملية، حتى `malloc` نفسه يستخدمه للحجوزات الكبيرة. وتشرح [صفحته](https://man7.org/linux/man-pages/man2/mmap.2.html?ref=arabroot.io) نوعين من الربط في بعدين:

|                    | خاص MAP\_PRIVATE                               | مشترك MAP\_SHARED                              |
| ------------------ | ---------------------------------------------- | ---------------------------------------------- |
| مجهول Anonymous    | ال Heap والذاكرة العادية، ما فعلناه في touch.c | ذاكرة مشتركة بين عملية وأبنائها بعد fork       |
| من ملف File-backed | الكود والمكتبات، والكتابة لا تصل إلى الملف     | ما فعلناه في filemap.c، والكتابة تصل إلى الملف |

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

## النسخ عند الكتابة Copy-on-Write بعد fork

شرحنا في مقال Hello World أن الصدفة تنسخ نفسها بالطلب `fork` قبل أن تشغل أي برنامج، فإذا كانت العملية تحمل 10 GB فهل ينسخ النظام 10 GB عند كل `fork`؟ لو فعل لاحتاج ثوان ولضاعف الذاكرة. والذي يحدث أن النواة تنسخ جداول الصفحات فقط، وتجعل كل الصفحات الخاصة للقراءة فقط في العمليتين، فتشترك العمليتان في نفس الإطارات. ثم عندما تكتب إحداهما على صفحة يحدث خطأ صفحة، فترى النواة أن الصفحة مشتركة، فتنسخها إلى إطار جديد لهذه العملية فقط وتسمح بالكتابة عليه، وهذا هو النسخ عند الكتابة Copy-on-Write كما تذكر صفحة [fork](https://man7.org/linux/man-pages/man2/fork.2.html?ref=arabroot.io).

ولكي نرى ذلك بأرقام الإطارات الحقيقية كتبنا برنامجاً يملأ 64 MB ثم ينسخ نفسه، والابن يقرأ كل الذاكرة ثم يكتب على نصفها، وفي كل خطوة يقرأ رقم الإطار الحقيقي Page Frame Number للصفحة الأولى من الملف `/proc/<pid>/pagemap`، ويطبع من `smaps_rollup` أرقام الذاكرة للأب والابن:

```c
/* cow.c: copy-on-write after fork. The parent fills 64 MB, forks, and the child
   reads it, then writes to half of it. pagemap shows the physical frame (PFN) of page 0. */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <sys/wait.h>

#define LEN (64UL << 20)

static uint64_t pfn(pid_t pid, void *addr)
{
    char path[64];
    uint64_t e = 0;
    snprintf(path, sizeof path, "/proc/%d/pagemap", pid);
    int fd = open(path, O_RDONLY);
    pread(fd, &e, 8, ((uintptr_t)addr / 4096) * 8);
    close(fd);
    return e & ((1ULL << 55) - 1);                    /* bits 0-54: page frame number */
}

static long minflt(void) { struct rusage r; getrusage(RUSAGE_SELF, &r); return r.ru_minflt; }

static void rollup(const char *who, pid_t pid)
{
    char path[64], line[128];
    snprintf(path, sizeof path, "/proc/%d/smaps_rollup", pid);
    FILE *f = fopen(path, "r");
    printf("%-7s", who);
    while (fgets(line, sizeof line, f))
        if (!strncmp(line, "Rss:", 4) || !strncmp(line, "Pss:", 4) || !strncmp(line, "Shared_Dirty", 12) || !strncmp(line, "Private_Dirty", 13)) {
            line[strcspn(line, "\n")] = 0;
            char *k = strtok(line, ":"), *v = strtok(NULL, " ");
            printf(" %s=%s", k, v);
        }
    printf(" kB\n");
    fclose(f);
}

int main(void)
{
    setvbuf(stdout, NULL, _IONBF, 0);                 /* no stdio buffer: see the note in the article */
    char *p = mmap(NULL, LEN, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    madvise(p, LEN, MADV_NOHUGEPAGE);
    memset(p, 7, LEN);
    pid_t parent = getpid();
    printf("parent page 0 -> PFN 0x%llx\n", (unsigned long long)pfn(parent, p));
    int go[2]; pipe(go);
    pid_t child = fork();
    if (child == 0) {
        long f0 = minflt(); volatile long sum = 0;
        for (size_t i = 0; i < LEN; i += 4096) sum += p[i];
        long f1 = minflt();
        printf("child  page 0 -> PFN 0x%llx (after reading 64 MB, %ld new faults)\n", (unsigned long long)pfn(getpid(), p), f1 - f0);
        rollup("child", getpid()); rollup("parent", parent);
        for (size_t i = 0; i < LEN / 2; i += 4096) p[i] = 9;
        long f2 = minflt();
        printf("child  page 0 -> PFN 0x%llx (after writing 32 MB, %ld new faults)\n", (unsigned long long)pfn(getpid(), p), f2 - f1);
        rollup("child", getpid()); rollup("parent", parent);
        _exit(0);
    }
    waitpid(child, NULL, 0);
    printf("parent page 0 -> PFN 0x%llx, value %d\n", (unsigned long long)pfn(parent, p), p[0]);
    return 0;
}
```

شغلناه أولاً في Container عادي:

```bash
docker run --rm --name sos-vm -v "$PWD:/lab" sos-tools:1 sh -c 'gcc -O2 -o /usr/local/bin/cow cow.c 2>/dev/null; cow'
```

```
parent page 0 -> PFN 0x0
child  page 0 -> PFN 0x0 (after reading 64 MB, 0 new faults)
child   Rss=66948 Pss=33308 Shared_Dirty=65608 Private_Dirty=40 kB
parent  Rss=67140 Pss=33432 Shared_Dirty=65608 Private_Dirty=36 kB
child  page 0 -> PFN 0x0 (after writing 32 MB, 8208 new faults)
child   Rss=67012 Pss=49736 Shared_Dirty=32840 Private_Dirty=32808 kB
parent  Rss=67140 Pss=49816 Shared_Dirty=32840 Private_Dirty=32804 kB
parent page 0 -> PFN 0x0, value 7
```

كل أرقام الإطارات أصفار، وهذا ليس خطأ في البرنامج: توثيق [pagemap](https://docs.kernel.org/admin-guide/mm/pagemap.html?ref=arabroot.io) يقول إنه منذ Linux 4.2 تملأ النواة حقل رقم الإطار بالأصفار لمن لا يملك الصلاحية `CAP_SYS_ADMIN`، والسبب أن معرفة أماكن الإطارات الحقيقية تساعد في هجمات Rowhammer التي تقلب بتات في الذاكرة بالوصول المتكرر إلى صفوف مجاورة. لذلك أعدنا التشغيل في المختبر وحده بالخيار `--cap-add SYS_ADMIN`، وهذه صلاحية واسعة لا تعطها لأي Container في الإنتاج:

```bash
docker run --rm --name sos-vm --cap-add SYS_ADMIN -v "$PWD:/lab" sos-tools:1 sh -c 'gcc -O2 -o /usr/local/bin/cow cow.c 2>/dev/null; cow'
```

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

```
parent page 0 -> PFN 0x6643c1
child  page 0 -> PFN 0x6643c1 (after reading 64 MB, 0 new faults)
child   Rss=67000 Pss=33326 Shared_Dirty=65612 Private_Dirty=40 kB
parent  Rss=67204 Pss=33424 Shared_Dirty=65612 Private_Dirty=36 kB
child  page 0 -> PFN 0x623051 (after writing 32 MB, 8210 new faults)
child   Rss=67092 Pss=49770 Shared_Dirty=32844 Private_Dirty=32808 kB
parent  Rss=67204 Pss=49808 Shared_Dirty=32844 Private_Dirty=32804 kB
parent page 0 -> PFN 0x6643c1, value 7
```

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

- بعد النسخ رأى الابن الصفحة الأولى في نفس الإطار `0x6643c1` الذي يراه الأب، وقرأ 64 MB كاملة دون أي خطأ صفحة جديد، لأن النواة نسخت الجداول مع الترجمات، فلا يحتاج الابن خطأ حتى يقرأ.
- كل عملية تقول إن RSS عندها قرابة 67 MB، ولو جمعتهما لظننت أن العمليتين تستهلكان 134 MB، والحقيقة أن الذاكرة 64 MB مشتركة، والسطر `Shared_Dirty=65612` يقول ذلك. أما Pss فهو 33 MB لكل واحدة، لأن Pss يقسم كل صفحة مشتركة على عدد العمليات التي تشترك فيها، ومجموعه هو الاستهلاك الحقيقي.
- عندما كتب الابن على 32 MB، أي 8192 صفحة، حدث 8210 خطأ، أي خطأ لكل صفحة كتب عليها، وتغير إطار الصفحة الأولى عنده إلى `0x623051` بينما بقي عند الأب `0x6643c1` وبقيت قيمتها عند الأب 7 وليس 9.
- بعد الكتابة نزلت الذاكرة المشتركة إلى 32 MB وصار لكل عملية 32 MB خاصة، وارتفع Pss لكل واحدة إلى قرابة 50 MB، فالمجموع الآن قرابة 100 MB، وهو الاستهلاك الحقيقي بعد النسخ.

وفي أول تشغيل لهذا البرنامج لم تظهر أي سطور من الابن أصلاً، فقط سطرا الأب. والسبب أن المخرج كان أنبوباً إلى Docker وليس طرفية، فمكتبة C جمعت ما يطبعه الابن في ذاكرة مؤقتة داخل العملية، ثم خرج الابن بالطلب `_exit` الذي لا يفرغ هذه الذاكرة، فضاع كل ما طبعه. وهذا نفس ما شرحناه في قسم [لماذا لا تطلب printf من النواة عند كل حرف](https://arabroot.io/articles/%D8%B1%D8%AD%D9%84%D8%A9-hello-world-%D9%85%D9%86-%D9%84%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D9%81%D8%A7%D8%AA%D9%8A%D8%AD-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%B4%D8%A7%D8%B4%D8%A9/#buffering)، وله وجه ثان بعد `fork`: لو طبع الأب شيئاً قبل النسخ وبقي في ذاكرته المؤقتة لورثه الابن وطبعه مرة ثانية. لذلك أضفنا السطر `setvbuf(stdout, NULL, _IONBF, 0)` في أول البرنامج حتى يذهب كل سطر إلى النواة فوراً.

وهذه الآلية هي التي يعتمد عليها Redis في الحفظ على القرص في الخلفية: ينسخ نفسه، والابن يكتب نسخة ثابتة من البيانات بينما يكمل الأب خدمة الطلبات، ولا تكلف النسخة إلا الصفحات التي تتغير أثناء الحفظ. وهذا أيضاً سبب أن الصفحات الكبيرة تضره، فتغيير بايت واحد يكلف نسخ 2 MB بدلاً من 4 KB.

## كيف تقرأ VSZ وRSS وPSS؟

بعد كل ما سبق نستطيع أن نرجع إلى الأرقام التي بدأنا بها المقال ونقرأها بدقة، والمصدر الرسمي لكل منها توثيق [نظام الملفات proc](https://docs.kernel.org/filesystems/proc.html?ref=arabroot.io) وصفحة [smaps](https://man7.org/linux/man-pages/man5/proc%5Fpid%5Fsmaps.5.html?ref=arabroot.io):

- الحجم الافتراضي VSZ أو VIRT أو VmSize: مجموع كل المناطق المسموحة في مساحة العناوين، سواء لمسها البرنامج أم لا. رأينا أن `mmap` لمساحة 1 GB زاده 1 GB دون أي إطار حقيقي، لذلك فهو لا يقول شيئاً عن الاستهلاك، وأغلب البرامج الحديثة تحجز مساحات كبيرة مسبقاً، مثل مدير الذاكرة في Java وGo، والمكتبات التي تحجز مساحة لكل خيط.
- الحجم المقيم RSS أو RES أو VmRSS: الإطارات الحقيقية المربوطة بجدول هذه العملية الآن، ويشمل المكتبات المشتركة والصفحات المشتركة بعد `fork` وصفحات الملفات المربوطة. وهذا هو الرقم الذي تقارنه بالذاكرة، ولكن لا تقم بجمعه لكل العمليات، والسبب أن كل صفحة مشتركة تحسب عند كل من يشترك فيها، كما رأينا في 67 MB التي ظهرت مرتين.
- الحجم المتناسب PSS: كل صفحة مشتركة مقسومة على عدد من يشترك فيها، فمجموعه لكل العمليات هو الاستهلاك الحقيقي، وهو أفضل رقم عندما تسأل كم تستهلك مجموعة عمليات مثل خدمة تعمل بعدة عمال Workers. وتقرؤه من `/proc/<pid>/smaps_rollup`.
- الذاكرة الخاصة USS، أي مجموع `Private_Clean` و`Private_Dirty`: ما سوف يعود إلى النظام إذا انتهت العملية، وهو الرقم المناسب عندما تسأل هل قتل هذه العملية يحرر ذاكرة.

![عمليتان أب وابن بعد fork، بينهما 64 ميغابايت مشتركة، وكل عملية تقول إن RSS عندها 67 ميغابايت فيظن من يجمع أن المجموع 134، بينما PSS لكل منهما 33 ميغابايت ومجموعه 66، وبعد أن كتب الابن على 32 ميغابايت صار PSS لكل منهما قرابة 50 ميغابايت](https://arabroot.io/content/images/2026/10/os-virtual-memory-03-rss-pss.webp)

مجموع RSS يحسب الصفحات المشتركة مرتين، ومجموع PSS هو الاستهلاك الحقيقي، والأرقام من تجربة النسخ عند الكتابة

وفي ال Containers الرقم الذي يهم أكثر من كل ذلك هو ما تحسبه مجموعة الذاكرة cgroup، فهو يشمل ذاكرة كل العمليات في ال Container، ويشمل أيضاً جزءاً من ذاكرة الملفات المؤقتة التي قرأها ال Container، وهو الرقم الذي يقارن بالحد عند تشغيل قاتل العمليات، وقد شرحنا في مقال [تحليل الأداء](https://arabroot.io/articles/%D8%AA%D8%AD%D9%84%D9%8A%D9%84-%D8%A7%D8%AF%D8%A7%D8%A1-%D8%B3%D9%8A%D8%B1%D9%81%D8%B1-linux-%D9%81%D9%8A-60-%D8%AB%D8%A7%D9%86%D9%8A%D8%A9/#swap) كيف تقرؤه من `memory.stat`.

## الحجز الزائد Overcommit

إذا كان `mmap` لا يعطي ذاكرة حقيقية حتى يلمسها البرنامج، فماذا يمنع برنامجاً من حجز أكثر من الذاكرة الموجودة؟ هذا ما يقرره الإعداد `vm.overcommit_memory`، وتوثيق [محاسبة الحجز الزائد](https://docs.kernel.org/mm/overcommit-accounting.html?ref=arabroot.io) يشرح قيمه الثلاث:

- القيمة 0، وهي الافتراضية في النواة: تخمين يرفض الحجوزات الواضح أنها مستحيلة، ويسمح بالباقي.
- القيمة 1: يقبل كل حجز مهما كان، ويناسب برامج تحجز مصفوفات ضخمة لا تلمس منها إلا القليل.
- القيمة 2: لا يقبل أن يتجاوز مجموع الحجوزات في النظام حداً يساوي Swap مع نسبة من الذاكرة تحددها `vm.overcommit_ratio`، وافتراضياً 50%. وفي هذا الوضع يفشل الحجز نفسه بخطأ بدلاً من أن يقتل البرنامج لاحقاً عند اللمس.

والبرنامج التالي يطلب مساحات كبيرة دون أن يلمسها، ثم يطبع حجمه الافتراضي وأرقام الحجز في النظام من `/proc/meminfo`:

```c
/* overcommit.c: ask for large anonymous mappings without touching them. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/mman.h>

int main(int argc, char **argv)
{
    for (int i = 1; i < argc; i++) {
        size_t gb = atol(argv[i]);
        void *p = mmap(NULL, gb << 30, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
        if (p == MAP_FAILED) printf("%4zu GB: failed (%s)\n", gb, strerror(errno));
        else printf("%4zu GB: ok at %p\n", gb, p);
    }
    char line[128];
    FILE *f = fopen("/proc/self/status", "r");
    while (fgets(line, sizeof line, f)) if (!strncmp(line, "VmSize", 6) || !strncmp(line, "VmRSS", 5)) fputs(line, stdout);
    f = fopen("/proc/meminfo", "r");
    while (fgets(line, sizeof line, f))
        if (!strncmp(line, "MemTotal", 8) || !strncmp(line, "SwapTotal", 9) || !strncmp(line, "CommitLimit", 11) || !strncmp(line, "Committed_AS", 12)) fputs(line, stdout);
    return 0;
}
```

```bash
docker run --rm --name sos-vm -v "$PWD:/lab" sos-tools:1 sh -c 'gcc -O2 -o /usr/local/bin/overcommit overcommit.c && cat /proc/sys/vm/overcommit_memory /proc/sys/vm/overcommit_ratio && overcommit 16 32 48 200'
```

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

```
1
50
  16 GB: ok at 0x7f5b363d6000
  32 GB: ok at 0x7f53363d6000
  48 GB: ok at 0x7f47363d6000
 200 GB: ok at 0x7f15363d6000
VmSize:	310381048 kB
VmRSS:	    1096 kB
MemTotal:       32804872 kB
SwapTotal:       8388608 kB
CommitLimit:    24791044 kB
Committed_AS:   314382884 kB
```

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

- أول سطر هو المفاجأة: القيمة في جهازنا 1 وليست 0 الافتراضية، أي أن الجهاز الافتراضي الذي يشغل Docker Desktop مضبوط على قبول كل حجز. لذلك قبلت النواة 296 GB من الحجوزات في عملية واحدة على جهاز فيه 32 GB من الذاكرة و8 GB من Swap، والسطر `VmRSS` يقول إن العملية لم تأخذ إلا 1 MB حقيقياً. وهذا يعلمك أن تقرأ القيمة على سيرفرك قبل أن تفترضها، فبعض صور الأنظمة ومنصات ال Containers تغيرها.
- الرقم `CommitLimit` هو الحد الذي كانت ستطبقه القيمة 2: نصف الذاكرة مع ال Swap، أي قرابة 15.6 GB مع 8 GB، والمجموع 24791044 KB. أما `Committed_AS` فهو مجموع ما حجزته كل العمليات في النظام الآن، وهو أكبر من الحد بأكثر من عشر مرات بسبب برنامجنا، ولو كانت القيمة 2 لرفض الحجز الأول نفسه.
- لم نغير القيمة إلى 0 أو 2 في التجربة، لأن هذا الإعداد ليس خاصاً بال Container وإنما بنواة الجهاز كله، وتغييره من داخل Container بصلاحيات كاملة يغير سلوك كل البرامج الأخرى على نفس الجهاز.

وأي قيمة هي الأفضل؟ الإجابة تعتمد على ما تفضله عند نفاد الذاكرة: مع القيمتين 0 و1 ينجح الحجز ثم قد يقتل قاتل العمليات برنامجاً ما عندما تلمس البرامج أكثر من الموجود، ومع القيمة 2 يفشل الحجز في البرنامج الذي طلبه فيستطيع أن يتعامل مع الخطأ، ولكن برامج كثيرة تحجز أكثر مما تلمس بكثير فتفشل دون داع. ولهذا يطلب دليل Redis الذي ذكرناه القيمة 1، والسبب أن `fork` لعملية فيها 10 GB يحسب في الوضع 0 كحجز جديد بنفس الحجم، فقد يرفضه التخمين، بينما النسخ عند الكتابة يجعل الكلفة الحقيقية أصغر بكثير.

## عندما تنفد الذاكرة فعلاً: قاتل العمليات OOM Killer داخل Container

الحجز الزائد معناه أن الوعد قد لا يتحقق، فإذا لمست البرامج صفحات أكثر من الذاكرة الحقيقية وال Swap، أو أكثر من حد مجموعة الذاكرة في Container، تبحث النواة عن عملية تقتلها لتحرر ذاكرتها، وهذا هو قاتل العمليات OOM Killer. والبرنامج التالي يحجز ويلمس 32 MB في كل مرة حتى يصل إلى الحجم الذي تعطيه، ويطبع ما وصل إليه بعد كل خطوة:

```c
/* eat.c: allocate and touch memory 32 MB at a time, printing the total as it grows. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char **argv)
{
    int max = argc > 1 ? atoi(argv[1]) : 512;
    setvbuf(stdout, NULL, _IONBF, 0);
    for (int mb = 32; mb <= max; mb += 32) {
        char *p = malloc(32 << 20);
        memset(p, 1, 32 << 20);
        printf("%d MB\n", mb);
    }
    return 0;
}
```

ونشغله في Container حده 256 MB دون Swap، فالخيار `--memory-swap` بنفس قيمة `--memory` يعني عدم السماح بال Swap كما في توثيق [قيود الموارد في Docker](https://docs.docker.com/engine/containers/resource%5Fconstraints/?ref=arabroot.io)، ثم نسأل Docker كيف انتهى:

```bash
docker run --name sos-vm-oom --memory=256m --memory-swap=256m -v "$PWD:/lab" sos-tools:1 sh -c 'gcc -O2 -o /usr/local/bin/eat eat.c && cat /sys/fs/cgroup/memory/memory.limit_in_bytes && eat 512'
docker inspect sos-vm-oom --format 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}'
docker rm sos-vm-oom
```

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

```
268435456
32 MB
64 MB
96 MB
128 MB
160 MB
192 MB
224 MB
Killed
OOMKilled=true ExitCode=137
```

الحد 268435456 بايت، أي 256 MB بالضبط كما في الملف `memory.limit_in_bytes` من cgroup v1، والبرنامج وصل إلى 224 MB ثم قتل قبل أن يكمل 256 MB، لأن الحد يشمل أيضاً الصدفة والمترجم والمكتبات وكل ما في ال Container. والرمز 137 يساوي 128 مع رقم الإشارة 9، أي `SIGKILL`، و`OOMKilled=true` هي الطريقة الوحيدة الواضحة لتعرف السبب من خارج ال Container، لأن البرنامج نفسه لا يستطيع أن يكتب شيئاً في سجلاته عندما يقتل بهذه الإشارة. وهذا هو التوقف الصامت بالرمز 137 الذي بدأنا به المقال، وقد شرحنا في قسم [قاتل العمليات وكيف تقرأ سجله](https://arabroot.io/articles/%D8%AA%D8%AD%D9%84%D9%8A%D9%84-%D8%A7%D8%AF%D8%A7%D8%A1-%D8%B3%D9%8A%D8%B1%D9%81%D8%B1-linux-%D9%81%D9%8A-60-%D8%AB%D8%A7%D9%86%D9%8A%D8%A9/#oom) كيف تقرأ رسائل النواة عنه على السيرفر وكيف تحمي العمليات المهمة بالإعداد `oom_score_adj`.

⚠️

إذا كان تطبيقك يعمل بلغة لها مدير ذاكرة مثل Java أو Node.js فاضبط حد ذاكرة اللغة أقل من حد ال Container بهامش واضح، والسبب أن مدير ذاكرة اللغة لا يعرف أن هناك حداً حتى يقتل البرنامج، فإذا كان يظن أن أمامه مساحة لم يجمع القمامة في الوقت المناسب، والذاكرة خارج ال Heap مثل الخيوط والمكتبات وذاكرة الشبكة تحسب أيضاً في الحد.

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

- قراءة VIRT على أنه استهلاك: رأينا عملية حجمها الافتراضي 296 GB وذاكرتها الحقيقية 1 MB.
- جمع RSS لكل العمليات لمعرفة استهلاك خدمة: الصفحات المشتركة تحسب مرة لكل عملية، والرقم الصحيح مجموع PSS.
- القلق من عدد أخطاء الصفحات وحده: الأخطاء الصغيرة جزء طبيعي من عمل أي برنامج، فلمس 1 GB أحدث 262 ألف خطأ صغير في أقل من ثانية ونصف، والذي يستحق المراقبة هو الأخطاء الكبيرة، لأنها تعني انتظار القرص.
- إيقاف الصفحات الكبيرة أو تفعيلها لكل السيرفر لأن مقالاً قال ذلك: رأينا أنها جعلت القراءة العشوائية أسرع ثلاث مرات، ورأينا أن Redis يطلب إيقافها، والخيار `madvise` يترك القرار لكل برنامج.
- افتراض قيمة `vm.overcommit_memory`: كانت 1 في جهازنا وليست 0، فاقرأها قبل أن تفسر سلوك الحجز.
- الاعتماد على رسائل التطبيق لمعرفة سبب توقفه بالرمز 137: البرنامج لا يكتب شيئاً عند `SIGKILL`، والسبب في `docker inspect` أو في سجل النواة.
- استخدام `_exit` أو الخروج بعد `fork` دون تفريغ ذاكرة الطباعة: تضيع السطور أو تتكرر، كما حدث لنا في تجربة النسخ عند الكتابة.
- إعطاء Container الصلاحية `CAP_SYS_ADMIN` لقراءة pagemap أو غيره خارج المختبر: هذه الصلاحية تفتح أبواباً كثيرة غير ما تحتاجه.

## جدول قرار Decision Table: ماذا تفعل عندما ترى…؟

| ما تراه                                  | معناه غالباً                                | ماذا تفعل                                                           |
| ---------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------- |
| VIRT كبير جداً وRES صغير                 | حجوزات لم تلمس بعد                          | لا شيء، راقب RES وPSS                                               |
| مجموع RES أكبر من الذاكرة                | صفحات مشتركة محسوبة أكثر من مرة             | اجمع Pss من smaps\_rollup                                           |
| أخطاء كبيرة كثيرة في /usr/bin/time -v    | البرنامج ينتظر القرص لملفات أو Swap         | ذاكرة أكثر لذاكرة الملفات المؤقتة، أو نمط قراءة متتابع، أو أقل Swap |
| البرنامج بطيء بعد إعادة التشغيل ثم يسرع  | ذاكرة الملفات المؤقتة فارغة في البداية      | طبيعي، أو قراءة مسبقة للملفات المهمة عند البدء                      |
| قراءات عشوائية كثيرة على مساحة كبيرة     | إخفاقات TLB مكلفة                           | صفحات كبيرة لهذا البرنامج بالخيار madvise                           |
| برنامج ينسخ نفسه ويكتب كثيراً، مثل Redis | النسخ عند الكتابة يكلف صفحة كاملة لكل تغيير | الصفحات الكبيرة على never أو madvise بحسب توصية البرنامج            |
| Container ينتهي بالرمز 137               | تجاوز حد الذاكرة وقتلته النواة              | تأكد من OOMKilled، ثم ارفع الحد أو اضبط حد اللغة تحته               |
| فشل fork بخطأ ذاكرة في برنامج كبير       | تخمين الحجز الزائد رفض نسخة بنفس الحجم      | اقرأ overcommit\_memory وتوصية البرنامج، مثل القيمة 1 لدى Redis     |

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

كل ال Containers عملت بالخيار `--rm` إلا Container قاتل العمليات الذي حذفناه بعد قراءة حالته، وحذفنا ال Volume بعد تجربة الملف، وتبقى ال Image إذا لم تكن ستكمل مع بقية مقالات هذه المرحلة:

```bash
docker rmi sos-tools:1
```

## الخلاصة

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

- البرامج لا ترى RAM، وإنما صفحات افتراضية من 4 KB يربطها جدول صفحات بأربعة مستويات بإطارات حقيقية متفرقة، وهذا ما يعزل البرامج ويحل مشكلة المكان والتجزئة.
- الجداول نفسها ذاكرة: 1 GB من الصفحات العادية احتاج 2 MB من الجداول في المستوى الأخير.
- ال TLB يحفظ الترجمات الحديثة، والصفحات الكبيرة تجعل الإخفاق فيها أرخص، وفي تجربتنا صارت القراءة العشوائية أسرع ثلاث مرات مع نفس عدد الإخفاقات تقريباً.
- الطلب `mmap` يعطي عناوين فقط، والإطار يأتي عند أول لمس بخطأ صفحة صغير، والخطأ الكبير هو الذي يعني انتظار القرص.
- القراءة المسبقة جعلت قراءة ملف من القرص 0.9 ثانية بدلاً من 8.1 ثانية، فنمط الوصول جزء من الأداء.
- بعد `fork` تشترك العمليتان في نفس الإطارات حتى تكتب إحداهما، وكل صفحة تكتب عليها تكلف خطأ ونسخة.
- VSZ لا يقول شيئاً عن الاستهلاك، وRSS يحسب المشترك أكثر من مرة، ومجموع PSS هو الاستهلاك الحقيقي.
- الحجز الزائد يجعل الحجز ينجح ثم قد يقتل قاتل العمليات أحداً لاحقاً، والرمز 137 مع `OOMKilled=true` هو أثره في Docker.

[![](https://arabroot.io/content/images/2026/10/os-processes-scheduling-cover-cover.webp)دليلالعمليات Processes وجدولة المعالج CPU Scheduling في Linuxنرى من داخل النواة كيف يختار المجدول من يعمل الآن، ولماذا يبطئ سكربت ليلي واجهتك البرمجية، وكيف يعيدها سطر واحد إلى سرعتها.](https://arabroot.io/articles/%D8%A7%D9%84%D8%B9%D9%85%D9%84%D9%8A%D8%A7%D8%AA-%D9%88%D8%AC%D8%AF%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%84%D8%AC-%D9%81%D9%8A-linux/)[![](https://arabroot.io/content/images/2026/10/kernel-user-syscalls-cover-cover.webp)دليلفضاء النواة والمستخدم: كم يكلف استدعاء النظام System Call؟نعبر الحد بين برنامجك والنواة بأيدينا دون أي مكتبة، ونقيس كلفة كل عبور، ونرى لماذا يأخذ نسخ ملف ببايت واحد في كل طلب عشرات الثواني بدلاً من أقل من ملي ثانية.](https://arabroot.io/articles/%D9%81%D8%B6%D8%A7%D8%A1-%D8%A7%D9%84%D9%86%D9%88%D8%A7%D8%A9-%D9%88%D9%81%D8%B6%D8%A7%D8%A1-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%AE%D8%AF%D9%85-%D9%88%D8%A7%D8%B3%D8%AA%D8%AF%D8%B9%D8%A7%D8%A1%D8%A7%D8%AA-%D8%A7%D9%84%D9%86%D8%B8%D8%A7%D9%85/)[![](https://arabroot.io/content/images/2026/10/os-filesystems-io-cover-cover.webp)دليلنظام الملفات File System: Inodes والسجل Journal وfsyncنتابع الكتابة من برنامجك حتى القرص داخل ext4، ونقطع الكهرباء عن نظام ملفات يعمل لنرى ماذا يبقى فعلاً، ومتى تحتاج fsync وكم يكلف.](https://arabroot.io/articles/%D9%86%D8%B8%D8%A7%D9%85-%D8%A7%D9%84%D9%85%D9%84%D9%81%D8%A7%D8%AA-%D9%85%D9%86-%D8%A7%D9%84%D8%AF%D8%A7%D8%AE%D9%84-inodes-%D9%88%D8%A7%D9%84%D8%B3%D8%AC%D9%84-%D9%88-fsync/)[![](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/ram-internals-cover-cover.webp)دليلكيف تعمل الذاكرة RAM من الداخل، ولماذا تحتاج التحديث Refreshنفتح وحدة الذاكرة من الشريحة إلى الخلية، لتفهم ما تعنيه الأرقام المكتوبة عليها عندما تشتري جهازاً أو تختار سيرفراً.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%B9%D9%85%D9%84-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-ram-%D9%85%D9%86-%D8%A7%D9%84%D8%AF%D8%A7%D8%AE%D9%84/)

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

- أكتوبر 2026: كتابة المقال، وتنفيذ التجارب داخل Container من Debian 13 (trixie) مع gcc 14.2 وperf 6.12 وGNU time، على نواة Linux 5.15 (WSL2) ومعالج Intel Core i7-7700K وذاكرة 32 GB، على Docker Engine 29.8.0.