الفصل المفقود في تعليم علوم الحاسوب › التنقيح وتوصيف الأداء 4 / 13 السابق التالي

التنقيح وتوصيف الأداء

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

تصفّي الأخطاء (Debugging)

تصفّي الأخطاء باستخدام الطباعة (Printf Debugging) وتسجيل السجلات (Logging)

"أداة تصفّي الأخطاء الأكثر فعالية ما زالت التفكير المدروس، مقترنًا بجمل طباعةٍ موضوعةٍ بعناية" — برايان كيرنيغان (Brian Kernighan)، Unix for Beginners.

المنهج الأول لتصفّي خطأٍ في برنامجٍ هو إضافة جمل طباعة (print statements) حول الموضع الذي رصدت فيه المشكلة، ثم مواصلة التكرار حتى تستخرج معلوماتٍ كافيةً لفهم ما يسبب المشكلة.

المنهج الثاني هو استخدام تسجيل السجلات (logging) في برنامجك، بدلًا من جمل الطباعة العشوائية. تسجيل السجلات هو في جوهره "طباعةٌ بمزيدٍ من العناية"، ويُنجَز عادةً عبر إطار عملٍ للسجلات (logging framework) يتضمّن دعمًا مدمجًا لأمورٍ مثل:

  • القدرة على توجيه السجلات (أو أجزاءٍ منها) إلى مواقع إخراجٍ أخرى؛
  • تحديد مستويات خطورة (severity levels) مثل INFO وDEBUG وWARN وERROR وغيرها، والسماح لك بتصفية المخرجات وفقًا لها؛
  • دعم تسجيلٍ بنيويّ (structured logging) للبيانات المرتبطة بمدخلات السجل، بحيث يمكن استخراجها بسهولةٍ أكبر لاحقًا.

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

سجلات الجهات الخارجية: يدعم كثيرٌ من البرامج الخيار -v أو --verbose لطباعة مزيدٍ من المعلومات عند تشغيلها. قد يكون هذا مفيدًا لاكتشاف سبب فشل أمرٍ معيّن. وبعضها يسمح بتكرار الخيار للحصول على تفاصيل أكثر. عند تصفية مشكلات الخدمات (قواعد البيانات، خوادم الويب، إلخ)، افحص سجلاتها — غالبًا في /var/log/ على لينكس. واستخدم journalctl -u <service> لعرض سجلات خدمات systemd. وبالنسبة للمكتبات من جهاتٍ خارجية، تحقّق مما إذا كانت تدعم تسجيل التصفية عبر متغيرات البيئة (environment variables) أو الإعدادات.

أدوات التصفية (Debuggers)

تعمل طباعة التصفية جيدًا عندما تعرف ما الذي تريد طباعته ويمكنك بسهولةٍ تعديل كودك وإعادة تشغيله. أما أدوات التصفية فتصبح قيّمةً عندما لا تكون متأكدًا من المعلومات التي تحتاجها، أو عندما يظهر الخطأ فقط في ظروفٍ يصعب إعادة إنتاجها، أو عندما يكون تعديل البرنامج وإعادة تشغيله مكلفًا (فترات إقلاعٍ طويلة، حالةٍ معقدةٍ لإعادة إنشائها، إلخ).

أدوات التصفية هي برامج تتيح لك التفاعل مع تنفيذ البرنامج أثناء حدوثه، ممّا يتيح لك:

  • إيقاف التنفيذ (halt execution) عند بلوغه سطرًا معيّنًا.
  • المرور تعليماتٍ واحدةً تلو الأخرى (step through).
  • فحص قيم المتغيرات بعد الانهيار.
  • إيقاف التنفيذ بشكلٍ مشروطٍ عند تحقق شرطٍ معيّن.
  • والكثير من الميزات المتقدمة الأخرى.

تدعم معظم لغات البرمجة شكلاً من أشكال أدوات التصفية (أو تأتي بها مدمجةً). والأكثر مرونةً هي أدوات التصفية العامة (general-purpose debuggers) مثل gdb (مصفّي أخطاء GNU) وlldb (مصفّي أخطاء LLVM)، والتي يمكنها تصفية أي برنامجٍ ثنائي أصلي (native binary). كما تمتلك لغاتٌ كثيرة أدوات تصفية خاصة باللغة تندمج بشكلٍ أوثق مع بيئة التشغيل (مثل pdb في بايثون أو jdb في جافا).

gdb هو مصفّي الأخطاء الفعلي المعياري (de-facto) للغات C وC++ وRust وغيرها من اللغات المترجمة. يتيح لك فحص أي عمليةٍ تقريبًا والحصول على حالتها الآلية الحالية: السجلات (registers)، المكدس (stack)، عدّاد البرنامج (program counter)، وغيرها.

بعض أوامر GDB المفيدة:

  • run - تشغيل البرنامج
  • b {function} أو b {file}:{line} - تعيين نقطة توقف (breakpoint)
  • c - مواصلة التنفيذ
  • step / next / finish - الدخول إلى دالة / القفز فوقها / الخروج منها
  • p {variable} - طباعة قيمة متغير
  • bt - إظهار تتبّع الرجوع (backtrace / مكدس الاستدعاءات)
  • watch {expression} - التوقف عند تغيّر القيمة

فكّر في استخدام وضع واجهة النصوص (TUI mode) الخاص بـGDB (gdb -tui أو اضغط Ctrl-x a داخل GDB) للاطلاع على عرضٍ مقسوم الشاشة يعرض الكود المصدري جنبًا إلى جنب مع موجه الأوامر.

تصفية التسجيل وإعادة التشغيل (Record-Replay Debugging)

من أكثر الأخطاء إحباطًا ما يُسمّى أخطاء هيزنبرغ (Heisenbugs): وهي أخطاء تبدو وكأنها تختفي أو تغيّر سلوكها عندما تحاول ملاحظتها. حالات السباق (race conditions)، والأخطاء المعتمدة على التوقيت، والمشكلات التي تظهر فقط في ظروف نظامٍ معيّنة تقع في هذه الفئة. التصفية التقليدية غالبًا ما تكون غير مجدية هنا لأن تشغيل البرنامج مرةً أخرى يُنتج سلوكًا مختلفًا (فقد تعمل جمل الطباعة على إبطاء الكود بدرجةٍ تكفي لاختفاء السباق).

تصفية التسجيل وإعادة التشغيل تحلّ هذه المشكلة بتسجيل تنفيذ البرنامج والسماح لك بإعادة تشغيله بطريقةٍ حتمية (deterministically) عدد المرات الذي تحتاجه. والأفضل من ذلك، يمكنك الرجوع بالتنفيذ للخلف للعثور على النقطة التي حدث فيها الخطأ بالضبط.

rr أداةٌ قويةٌ للينكس تسجّل تنفيذ البرنامج وتتيح إعادة تشغيلٍ حتميةٍ مع قدرات تصفيةٍ كاملة. وهي تعمل مع GDB، لذا فأنت تعرف الواجهة مسبقًا.

الاستخدام الأساسي:

# سجّل تنفيذ برنامج
rr record ./my_program

# أعد تشغيل التسجيل (يفتح GDB)
rr replay

السحر يحدث أثناء إعادة التشغيل. ولأن التنفيذ حتمي، يمكنك استخدام أوامر التصفية العكسية (reverse debugging):

  • reverse-continue (rc) - التشغيل للخلف حتى الوصول إلى نقطة توقف
  • reverse-step (rs) - خطوةٌ واحدةٌ للخلف
  • reverse-next (rn) - خطوةٌ للخلف مع تجاوز استدعاءات الدوال
  • reverse-finish - التشغيل للخلف حتى الدخول في الدالة الحالية

هذا قويٌّ بشكلٍ لا يُصدَّق للتصفية. لنفترض حدوث انهيار — بدلًا من تخمين موضع الخطأ وتعيين نقاط توقف، يمكنك:

  1. التشغيل حتى الانهيار
  2. فحص الحالة المُفسدة
  3. تعيين نقطة مراقبة (watchpoint) على المتغير المُفسد
  4. استخدام reverse-continue للعثور على النقطة التي أُفسد فيها بالضبط

متى تستخدم rr:

  • الاختبارات المتقلّبة (flaky tests) التي تفشل بشكلٍ متقطّع
  • حالات السباق وأخطاء الخيوط (threading)
  • حالات الانهيار الصعبة إعادة الإنتاج
  • أي خطأ تتمنّى فيه أن تعود بالزمن للوراء

ملاحظة: يعمل rr على لينكس فقط ويتطلب عدّادات أداءٍ مادية (performance counters). لا يعمل في الأجهزة الافتراضية التي لا تُظهر هذه العدّادات، مثل معظم مثيلات AWS EC2، ولا يدعم الوصول إلى GPU. أما على نظام التشغيل macOS فاطّلع على Warpspeed.

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

تتبّع استدعاءات النظام (System Call Tracing)

أحيانًا تحتاج إلى فهم كيفية تفاعل برنامجك مع نظام التشغيل. تُصدر البرامج استدعاءات النظام لطلب خدماتٍ من النواة (kernel) — فتح الملفات، تخصيص الذاكرة، إنشاء العمليات، وغيرها. يمكن أن يكشف تتبّع هذه الاستدعاءات عن سبب تعليق البرنامج، أو الملفات التي يحاول الوصول إليها، أو أين يقضي وقته في الانتظار.

strace (لينكس) وdtruss (macOS)

يتيح لك strace ملاحظة كل استدعاء نظام يُصدره البرنامج:

# تتبّع جميع استدعاءات النظام
strace ./my_program

# تتبّع الاستدعاءات المرتبطة بالملفات فقط
strace -e trace=file ./my_program

# تتبّع العمليات الفرعية (مهمٌّ للبرامج التي تُشغّل برامج أخرى)
strace -f ./my_program

# تتبّع عمليةٍ قيد التشغيل
strace -p <PID>

# إظهار معلومات التوقيت
strace -T ./my_program

على macOS وBSD استخدم dtruss (الذي يغلّف dtrace) لوظيفةٍ مماثلة:

للغوص أعمق في strace، اطّلع على كتاب Julia Evans الممتاز strace zine.

bpftrace وeBPF

eBPF (مرشّح حزم بيركلي الموسّع) تقنيةٌ لينكسيةٌ قويةٌ تتيح تشغيل برامج معزولة (sandboxed) داخل النواة. ويوفر bpftrace صيغةً عالية المستوى لكتابة برامج eBPF. هذه برامج عشوائية تعمل داخل النواة، وبالتالي تمتلك قوةً تعبيريةً هائلة (وإن كان لها صيغةٌ تشبه awk بشكلٍ ما وغير أنيقةٍ تمامًا). الاستخدام الأكثر شيوعًا لها هو التحقيق في استدعاءات النظام التي تُستدعى، بما في ذلك التجميعات (مثل العدّادات أو إحصاءات زمن الاستجابة) أو الفحص الداخلي (أو حتى التصفية) لوسائط استدعاءات النظام.

# تتبّع فتحات الملفات على مستوى النظام (يُطبع فورًا)
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s %s\n", comm, str(args->filename)); }'

# عدّ استدعاءات النظام حسب الاسم (يُطبع ملخصًا عند Ctrl-C)
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_* { @[probe] = count(); }'

ومع ذلك، يمكنك أيضًا كتابة برامج eBPF مباشرةً بلغة C باستخدام مجموعة أدوات مثل bcc، والتي تأتي أيضًا مع أدوات مفيدةٍ كثيرة مثل biosnoop لطباعة توزيعات زمن الاستجابة لعمليات القرص أو opensnoop لطباعة جميع الملفات المفتوحة.

حيث يكون strace مفيدًا لأنه سهل "التشغيل مباشرةً دون عناء"، فإن bpftrace هو ما يجب أن تصل إليه عندما تحتاج إلى حملٍ منخفضٍ (low overhead)، أو تريد التتبّع عبر دوال النواة، أو تحتاج إلى أي نوعٍ من التجميع، إلخ. لاحظ أن bpftrace يجب أن يعمل بصلاحيات root، وأنه يراقب النواة بأكملها عمومًا، وليس عمليةً معيّنةً فقط. لاستهداف برنامجٍ محدد، يمكنك التصفية حسب اسم الأمر أو PID:

# التصفية حسب اسم الأمر (يُطبع ملخصًا عند Ctrl-C)
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_* /comm == "bash"/ { @[probe] = count(); }'

# تتبّع أمرٍ معيّن منذ الإقلاع باستخدام -c (cpid = معرف العملية الفرعية)
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_* /pid == cpid/ { @[probe] = count(); }' -c 'ls -la'

تشغّل الخيار -c الأمر المحدد وتضبط cpid على معرف عملية الأمر، وهو مفيد لتتبّع برنامجٍ منذ لحظة بدايته. وعند خروج الأمر المتتبَّع، يطبع bpftrace النتائج المجمّعة.

تصفية الشبكة

بالنسبة لمشكلات الشبكة، يتيح لك tcpdump وWireshark التقاط حزم الشبكة وتحليلها:

# التقاط الحزم على المنفذ 80
sudo tcpdump -i any port 80

# التقاط والحفظ في ملف لتحليله في Wireshark
sudo tcpdump -i any -w capture.pcap

بالنسبة لحركة HTTPS، يجعل التشفير أداة tcpdump أقل فائدةً. يمكن لأدوات مثل mitmproxy أن تعمل كبروكسي اعتراضٍ (intercepting proxy) لفحص الحركة المشفرة. وغالبًا ما تكون أدوات مطوّر المتصفح (تبويب Network) أسهل طريقةٍ لتصفية طلبات HTTPS من تطبيقات الويب — فهي تعرض بيانات الطلب/الاستجابة المفكوكة التشفير، والترويسات (headers)، والتوقيت.

تصفية الذاكرة

أخطاء الذاكرة — تجاوز سعة المخزن المؤقت (buffer overflows)، الاستخدام بعد التحرير (use-after-free)، تسرّب الذاكرة (memory leaks) — من أخطر الأخطاء وأصعبها في التصفية. غالبًا لا تسبب انهيارًا فوريًا بل تُفسد الذاكرة بطرقٍ تسبب مشكلاتٍ لاحقًا بكثير.

المُعقِّمات (Sanitizers)

أحد المناهج للعثور على أخطاء الذاكرة هو استخدام المُعقِّمات، وهي ميزات مترجم (compiler features) تُجهّز كودك للكشف عن الأخطاء في وقت التشغيل. على سبيل المثال، يكشف AddressSanitizer (ASan) المستخدم على نطاقٍ واسعٍ عن:

  • تجاوز سعة المخزن المؤقت (في المكدس والكومة والعالمية)
  • الاستخدام بعد التحرير (use-after-free)
  • الاستخدام بعد الإرجاع (use-after-return)
  • تسرّب الذاكرة
# الترجمة مع AddressSanitizer
gcc -fsanitize=address -g program.c -o program
./program

هناك مجموعةٌ متنوعةٌ من المُعقِّمات المفيدة:

  • ThreadSanitizer (TSan): يكشف سباقات البيانات في الكود متعدد الخيوط (-fsanitize=thread)
  • MemorySanitizer (MSan): يكشف قراءات الذاكرة غير المهيَّأة (-fsanitize=memory)
  • UndefinedBehaviorSanitizer (UBSan): يكشف السلوك غير المعرف مثل تجاوز الأعداد الصحيحة (-fsanitize=undefined)

تتطلب المُعقِّمات إعادة الترجمة لكنها سريعةٌ بما يكفي لاستخدامها في خطوط CI وفي أثناء التطوير المنتظم.

Valgrind: عندما لا تستطيع إعادة الترجمة

Valgrind بدلًا من ذلك يُشغّل برنامجك في شيءٍ يشبه آلةً افتراضيةً للكشف عن أخطاء الذاكرة. إنه أبطأ من المُعقِّمات لكنه لا يتطلب إعادة الترجمة:

valgrind --leak-check=full ./my_program

استخدم Valgrind عندما:

  • لا تملك الكود المصدري
  • لا تستطيع إعادة الترجمة (المكتبات من جهاتٍ خارجية)
  • تحتاج أدواتٍ محددةً غير متاحةٍ كمعقِّمات

Valgrind في الواقع بيئة تنفيذٍ مضبوطةٍ قويةٌ جدًا، وسنرى المزيد عنه لاحقًا عند الوصول إلى القياس!

الذكاء الاصطناعي لتصفية الأخطاء

أصبحت نماذج اللغة الكبيرة (LLMs) مساعدين مفاجئين الفائدة في تصفية الأخطاء. فهي تتفوق في مهام تصفيةٍ معيّنةٍ تكمل الأدوات التقليدية.

أين تتألّق نماذج اللغة الكبيرة:

  • شرح رسائل الأخطاء الغامضة: رسائل أخطاء المترجم، خاصةً من قوالب C++ أو مدقّق الاقتراض (borrow checker) في Rust، يمكن أن تكون غامضةً بشكلٍ سيئٍ مشهور. يمكن لنماذج اللغة الكبيرة ترجمتها إلى إنجليزيةٍ بسيطةٍ واقتراح إصلاحات.

  • عَبْر حدود اللغة والتجريد: إذا كنت تصفّي مشكلةً تمتد عبر لغاتٍ متعددة (مثلًا خطأٌ في مكتبة C يظهر من خلال ربطٍ ببايثون)، يمكن لنماذج اللغة الكبيرة المساعدة في التنقل بين الطبقات المختلفة. وهي جيدةٌ بشكلٍ خاص في فهم حدود FFI ومشكلات نظام البناء وتصفية الأخطاء عبر اللغات (مثلًا: برنامجي يُخطئ، لكنني أعتقد أن السبب خطأٌ في أحد التبعيات).

  • ربط الأعراض بالسبب الجذري: "برنامجي يعمل جيدًا لكنه يستهلك ذاكرةً أكثر من المتوقع بعشرة أضعاف" هو نوع الأعراض الغامضة التي يمكن لنماذج اللغة الكبيرة المساعدة في التحقيق فيها، باقتراح الأسباب المحتملة وما الذي يجب البحث عنه.

  • تحليل مخرجات الانهيار وتتبّعات المكدس: الصق تتبّع مكدسٍ واسأل عمّا قد يكون سببه.

ملاحظة حول رموز التصفية (debug symbols): للحصول على تتبّعات مكدسٍ ذات معنى، تأكد من أن ملفاتك الثنائية (وأي مكتباتٍ مرتبطة) مُترجَمةٌ برموز تصفية (الخيار -g). تُخزَّن معلومات التصفية عادةً بصيغة DWARF. بالإضافة إلى ذلك، فإن الترجمة بمؤشرات الإطار (frame pointers، الخيار -fno-omit-frame-pointer) تجعل تتبّعات المكدس أكثر موثوقيةً، خاصةً لأدوات القياس. بدون ذلك، قد تعرض تتبّعات المكدس عناوين ذاكرةٍ فقط أو تكون ناقصة. هذا الأمر أكثر أهميةً للبرامج المترجمة محليًا (C++، Rust) منه لبايثون أو جافا.

قيود يجب تذكّرها:

  • يمكن لنماذج اللغة الكبيرة اختلاق تفسيراتٍ مقنعةٍ لكنها خاطئة
  • قد تقترح إصلاحاتٍ تُخفي الخطأ بدلًا من إصلاحه
  • تحقّق دائمًا من الاقتراحات باستخدام أدوات التصفية الفعلية
  • تعمل بشكلٍ أفضل كمكمّلٍ لفهم كودك، لا كبديلٍ عنه

هذا يختلف عن قدرات البرمجة الذكية العامة التي غُطّيت في محاضرة بيئة التطوير. هنا نتحدث تحديدًا عن استخدام نماذج اللغة الكبيرة كأداة مساعدةٍ في تصفية الأخطاء.

القياس (Profiling)

حتى لو كان كودك يتصرف وظيفيًا كما تتوقع، فقد لا يكون ذلك كافيًا إذا كان يستهلك كل معالجك أو ذاكرتك في هذه الأثناء. غالبًا ما تعلّم صفوف الخوارزميات تدوين O الكبير لكنها لا تعلّم كيفية العثور على النقاط الساخنة (hot spots) في برامجك. وبما أن التحسين المبكر هو أصل كل الشرور، فينبغي أن تتعلم عن أدوات القياس وأدوات المراقبة. ستساعدك على فهم الأجزاء التي تستهلك معظم الوقت و/أو الموارد في برنامجك حتى تتمكن من التركيز على تحسينها.

التوقيت (Timing)

أبسط طريقةٍ لقياس الأداء هي توقيت الأمور. في العديد من السيناريوهات قد يكون كافيًا طباعة الوقت الذي استغرقه كودك بين نقطتين.

مع ذلك، قد يكون وقت الساعة الجدارية (wall clock time) مضللًا لأن حاسوبك قد يكون مشغولًا بعملياتٍ أخرى في الوقت نفسه أو ينتظر حدوث أحداثٍ. يميّز الأمر time بين الوقت الفعلي (Real) و_المستخدم_ (User) و_النظام_ (Sys):

  • Real - وقت الساعة الجدارية من البداية إلى النهاية، بما في ذلك وقت الانتظار
  • User - الوقت الذي قضاه المعالج في تشغيل كود المستخدم
  • Sys - الوقت الذي قضاه المعالج في تشغيل كود النواة
$ time curl https://missing.csail.mit.edu &> /dev/null
real	0m0.272s
user	0m0.079s
sys	    0m0.028s

هنا استغرق الطلب نحو 300 مللي ثانية (الوقت الفعلي) لكن فقط 107 مللي ثانيةٍ من وقت المعالج (المستخدم + النظام). والباقي كان انتظارًا للشبكة.

مراقبة الموارد

أحيانًا تكون الخطوة الأولى نحو تحليل أداء برنامجك هي فهم استهلاكه الفعلي للموارد. غالبًا ما تعمل البرامج ببطءٍ عندما تكون محدودة الموارد.

  • المراقبة العامة: htop نسخةٌ محسّنةٌ من top تعرض إحصاءاتٍ متنوعةً للعمليات قيد التشغيل. اختصارات مفيدة: <F6> لفرز العمليات، t لعرض التسلسل الشجري، h لتبديل عرض الخيوط. يوجد أيضًا btop الذي يراقب أشياءَ أكثر بكثير.

  • عمليات الإدخال/الإخراج: iotop يعرض معلومات استخدام الإدخال/الإخراج الحي.

  • استخدام الذاكرة: free يعرض إجمالي الذاكرة الحرة والمستخدمة.

  • الملفات المفتوحة: lsof يسرد معلوماتٍ عن الملفات التي فتحتها العمليات. مفيدٌ للتحقق من العملية التي فتحت ملفًا معيّنًا.

  • اتصالات الشبكة: ss يتيح لك مراقبة اتصالات الشبكة. حالة استخدامٍ شائعة هي معرفة العملية التي تستخدم منفذًا معيّنًا: ss -tlnp | grep :8080.

  • استخدام الشبكة: nethogs وiftop أداتا واجهة أوامر تفاعليتان جيدتان لمراقبة استخدام الشبكة لكل عملية.

تصوّر بيانات الأداء

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

جعل البيانات قابلةً للرسم: عند إضافة جمل طباعةٍ أو تسجيلٍ لتصفية الأخطاء، فكّر في تنسيق المخرجات بحيث يسهل رسمها لاحقًا. طابع زمني بسيطٌ وقيمةٌ بصيغة CSV (1705012345,42.5) أسهل بكثير في الرسم من جملةٍ نثرية. كما يمكن تحليل السجلات المنظمة JSON ورسمها بأقل جهد. بعبارةٍ أخرى، سجّل بياناتك بطريقةٍ منظَّمة.

رسمٌ سريعٌ بـ gnuplot: لرسمٍ بسيطٍ من سطر الأوامر، يمكن لـgnuplot توليد رسومٍ بيانيةٍ مباشرةً من ملفات البيانات:

# رسم CSV بسيطٍ بالطابع الزمني والقيمة
gnuplot -e "set datafile separator ','; plot 'latency.csv' using 1:2 with lines"

استكشافٌ متكرّرٌ بـ matplotlib وggplot2: للتحليل الأعمق، يتيح matplotlib الخاص ببايثون وggplot2 الخاص بـR استكشافًا متكرّرًا. بخلاف الرسم لمرةٍ واحدة، تتيح لك هذه الأدوات تقسيم البيانات وتحويلها بسرعةٍ للتحقيق في الفرضيات. رسوم facet في ggplot2 قويةٌ بشكلٍ خاص — يمكنك تقسيم مجموعة بياناتٍ واحدةٍ عبر رسومٍ فرعيةٍ متعددةٍ حسب الفئة (مثل تقسيم زمن استجابة الطلبات حسب نقطة النهاية أو وقت اليوم) لاستخراج أنماطٍ كانت ستظل خفية.

أمثلة على حالات الاستخدام:

  • رسم زمن استجابة الطلبات عبر الزمن يكشف عن بطءٍ دوريّ (جمع القمامة، وظائف cron، أنماط الحركة) تُخفيه النسب المئوية الخام
  • تصوّر أوقات الإدراج لهيكل بياناتٍ ينمو يمكن أن يكشف مشكلات التعقيد الخوارزمي — سيعرض رسم إدراجات المتجه (vector) قممًا مميزةً عندما يتضاعف حجم المصفوفة الداعمة
  • تقسيم المقاييس حسب أبعادٍ مختلفة (نوع الطلب، فئة المستخدمين، الخادم) يكشف غالبًا أن المشكلة "على مستوى النظام" هي في الواقع معزولةٌ في فئةٍ واحدة

أدوات قياس المعالج

في أغلب الأحيان، عندما يشير الناس إلى أدوات القياس فإنهم يقصدون أدوات قياس المعالج (CPU). هناك نوعان رئيسيان:

  • أدوات القياس المتتبِّعة (Tracing profilers) تحتفظ بسجلٍ لكل استدعاء دالةٍ يُصدره برنامجك
  • أدوات القياس بالعينات (Sampling profilers) تفحص برنامجك دوريًا (شائعٌ كل مللي ثانية) وتسجّل مكدس البرنامج

أدوات القياس بالعينات ذات حملٍ أقل، وتُفضَّل عمومًا للاستخدام في الإنتاج.

perf: أداة القياس بالعينات

perf هي أداة القياس القياسية للينكس. يمكنها قياس أي برنامجٍ دون إعادة الترجمة:

يعطيك perf stat نظرةً عامةً سريعةً على أين يُقضى الوقت:

$ perf stat ./slow_program

 Performance counter stats for './slow_program':

         3,210.45 msec task-clock                #    0.998 CPUs utilized
               12      context-switches          #    3.738 /sec
                0      cpu-migrations            #    0.000 /sec
              156      page-faults               #   48.587 /sec
   12,345,678,901      cycles                    #    3.845 GHz
    9,876,543,210      instructions              #    0.80  insn per cycle
    1,234,567,890      branches                  #  384.532 M/sec
       12,345,678      branch-misses             #    1.00% of all branches

سيحتوي مخرج أداة القياس للبرامج في العالم الحقيقي على كمياتٍ كبيرةٍ من المعلومات. البشر كائناتٌ بصريةٌ وسيئون جدًا في قراءة كمياتٍ كبيرةٍ من الأرقام. رسوم اللهب (flame graphs) هي تصوّرٌ يجعل بيانات القياس أسهل بكثير في الفهم.

يعرض رسم اللهب تسلسلًا هرميًا لاستدعاءات الدوال على المحور Y والوقت المستغرق متناسبًا مع المحور X. وهي تفاعلية — يمكنك النقر للتكبير على أجزاءٍ محددةٍ من البرنامج.

رسم لهب

لتوليد رسم لهبٍ من بيانات perf:

# سجّل ملف تعريف
perf record -g ./my_program

# توليد رسم اللهب (يتطلب سكربتات flamegraph)
perf script | stackcollapse-perf.pl | flamegraph.pl > flamegraph.svg

فكّر في استخدام Speedscope لعارض رسوم لهبٍ تفاعليٍّ قائمٍ على الويب، أو Perfetto لتحليلٍ شاملٍ على مستوى النظام.

Callgrind من Valgrind: أداة القياس المتتبِّعة

callgrind أداة قياسٍ تسجّل تاريخ الاستدعاءات وعدّادات التعليمات لبرنامجك. بخلاف أدوات القياس بالعينات، يوفر أعداد استدعاءاتٍ دقيقةً ويمكنه إظهار العلاقة بين المستدعي والمُستدعى:

# التشغيل مع callgrind
valgrind --tool=callgrind ./my_program

# التحليل بـ callgrind_annotate (نصي) أو kcachegrind (واجهة رسومية)
callgrind_annotate callgrind.out.<pid>
kcachegrind callgrind.out.<pid>

Callgrind أبطأ من أدوات القياس بالعينات لكنه يوفر أعداد استدعاءاتٍ دقيقةً، ويمكنه اختياريًا محاكاة سلوك التخزين المؤقت (مع --cache-sim=yes) إذا كنت تحتاج تلك المعلومات.

إذا كنت تستخدم لغةً معيّنةً، فقد تكون هناك أدوات قياسٍ أكثر تخصصًا. على سبيل المثال، تمتلك بايثون cProfile وpy-spy، ويمتلك Go go tool pprof، ويمتلك Rust cargo-flamegraph (الذي يعمل في الواقع لأي برنامجٍ مترجم!).

أدوات قياس الذاكرة

تساعدك أدوات قياس الذاكرة على فهم كيفية استخدام برنامجك للذاكرة عبر الزمن وإيجاد تسرّبات الذاكرة.

Massif من Valgrind

يقيس massif استخدام ذاكرة الكومة (heap):

valgrind --tool=massif ./my_program
ms_print massif.out.<pid>

يعرض هذا استخدام الكومة عبر الزمن، مما يساعد في تحديد تسرّبات الذاكرة والتخصيص المفرط.

بالنسبة لبايثون، يوفر memory-profiler معلومات استخدام الذاكرة سطرًا بسطر.

الاختبار المعياري (Benchmarking)

عندما تحتاج إلى مقارنة أداء تنفيذاتٍ أو أدواتٍ مختلفة، فإن hyperfine ممتازٌ لاختبار برامج سطر الأوامر معياريًا:

$ hyperfine --warmup 3 'fd -e jpg' 'find . -iname "*.jpg"'
Benchmark #1: fd -e jpg
  Time (mean ± σ):      51.4 ms ±   2.9 ms    [User: 121.0 ms, System: 160.5 ms]
  Range (min … max):    44.2 ms …  60.1 ms    56 runs

Benchmark #2: find . -iname "*.jpg"
  Time (mean ± σ):      1.126 s ±  0.101 s    [User: 141.1 ms, System: 956.1 ms]
  Range (min … max):    0.975 s …  1.287 s    10 runs

Summary
  'fd -e jpg' ran
   21.89 ± 2.33 times faster than 'find . -iname "*.jpg"'

لتطوير الويب، تتضمن أدوات مطوّر المتصفح أدوات قياسٍ ممتازة. اطّلع على وثائق مقياس Firefox وChrome DevTools.

تمارين

تصفّي الأخطاء

  1. صفّي خوارزمية فرزٍ: الكود الزائف التالي ينفّذ فرز الدمج (merge sort) لكنه يحتوي على خطأٍ ما. نفّذه بلغةٍ من اختيارك، ثم استخدم أداة تصفيةٍ (gdb، lldb، pdb، أو أداة تصفية بيئة التطوير) للعثور على الخطأ وإصلاحه.

    function merge_sort(arr):
        if length(arr) <= 1:
            return arr
        mid = length(arr) / 2
        left = merge_sort(arr[0..mid])
        right = merge_sort(arr[mid..end])
        return merge(left, right)
    
    function merge(left, right):
        result = []
        i = 0, j = 0
        while i < length(left) AND j < length(right):
            if left[i] <= right[j]:
                append result, left[i]
                i = i + 1
            else:
                append result, right[i]
                j = j + 1
        append remaining elements from left and right
        return result
    

    متجه الاختبار: merge_sort([3, 1, 4, 1, 5, 9, 2, 6]) يجب أن يُرجع [1, 1, 2, 3, 4, 5, 6, 9]. استخدم نقاط التوقف والمرور خطوةً بخطوة عبر دالة merge للعثور على الموضع الذي يُختار فيه العنصر غير الصحيح.

  2. ثبّت rr واستخدم التصفية العكسية للعثور على خطأ إفسادٍ (corruption). احفظ هذا البرنامج باسم corruption.c:

    #include <stdio.h>
    
    typedef struct {
        int id;
        int scores[3];
    } Student;
    
    Student students[2];
    
    void init() {
        students[0].id = 1001;
        students[0].scores[0] = 85;
        students[0].scores[1] = 92;
        students[0].scores[2] = 78;
    
        students[1].id = 1002;
        students[1].scores[0] = 90;
        students[1].scores[1] = 88;
        students[1].scores[2] = 95;
    }
    
    void curve_scores(int student_idx, int curve) {
        for (int i = 0; i < 4; i++) {
            students[student_idx].scores[i] += curve;
        }
    }
    
    int main() {
        init();
        printf("=== Initial state ===\n");
        printf("Student 0: id=%d\n", students[0].id);
        printf("Student 1: id=%d\n", students[1].id);
    
        curve_scores(0, 5);
    
        printf("\n=== After curving ===\n");
        printf("Student 0: id=%d\n", students[0].id);
        printf("Student 1: id=%d\n", students[1].id);
    
        if (students[1].id != 1002) {
            printf("\nERROR: Student 1's ID was corrupted! Expected 1002, got %d\n",
                   students[1].id);
            return 1;
        }
        return 0;
    }
    

    ترجمه بالأمر gcc -g corruption.c -o corruption وشغّله. يتلف معرف الطالب 1، لكن الإفساد يحدث في دالةٍ تلامس الطالب 0 فقط. استخدم rr record ./corruption ثم rr replay للعثور على الجاني. اضبط نقطة مراقبةٍ على students[1].id واستخدم reverse-continue بعد الإفساد للعثور على السطر الذي استبدله بالضبط.

  3. صفّ خطأ ذاكرةٍ باستخدام AddressSanitizer. احفظ هذا باسم uaf.c:

    #include <stdlib.h>
    #include <string.h>
    #include <stdio.h>
    
    int main() {
        char *greeting = malloc(32);
        strcpy(greeting, "Hello, world!");
        printf("%s\n", greeting);
    
        free(greeting);
    
        greeting[0] = 'J';
        printf("%s\n", greeting);
    
        return 0;
    }
    

    أولًا ترجم وشغّل دون معقِّمات: gcc uaf.c -o uaf && ./uaf. قد يبدو أنه يعمل. الآن ترجم مع AddressSanitizer: gcc -fsanitize=address -g uaf.c -o uaf && ./uaf. اقرأ تقرير الخطأ. ما الخطأ الذي يكشفه ASan؟ أصلح المشكلة التي يحددها.

  4. استخدم strace (لينكس) أو dtruss (macOS) لتتبّع استدعاءات النظام التي يصدرها أمرٌ مثل ls -l. ما استدعاءات النظام التي يصدرها؟ جرّب تتبّع برنامجٍ أكثر تعقيدًا وانظر ما الملفات التي يفتحها.

  5. استخدم نموذج لغةٍ كبيرةٍ للمساعدة في تصفية رسالة خطأٍ غامضة. جرّب نسخ خطأ مترجمٍ (خاصةً من قوالب C++ أو Rust) واطلب تفسيرًا وإصلاحًا. جرّب وضع بعض مخرجات strace أو معقِّم العناوين فيه.

القياس

  1. استخدم perf stat للحصول على إحصاءات أداءٍ أساسيةٍ لبرنامجٍ من اختيارك. ماذا تعني العدّادات المختلفة؟

  2. قس بقياس perf record. احفظ هذا باسم slow.c:

    #include <math.h>
    #include <stdio.h>
    
    double slow_computation(int n) {
        double result = 0;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < 1000; j++) {
                result += sin(i * j) * cos(i + j);
            }
        }
        return result;
    }
    
    int main() {
        double r = 0;
        for (int i = 0; i < 100; i++) {
            r += slow_computation(1000);
        }
        printf("Result: %f\n", r);
        return 0;
    }
    

    ترجمه برموز تصفية: gcc -g -O2 slow.c -o slow -lm. شغّل perf record -g ./slow ثم perf report لترى أين يُقضى الوقت. جرّب توليد رسم لهبٍ باستخدام سكربتات flamegraph.

  3. استخدم hyperfine لقياس تنفيذين مختلفين لنفس المهمة معياريًا (مثل find مقابل fd، أو grep مقابل ripgrep، أو نسختين من كودك الخاص).

  4. استخدم htop لمراقبة نظامك أثناء تشغيل برنامجٍ مكثف الموارد. جرّب استخدام taskset لتقييد المعالجات التي يمكن لعمليةٍ استخدامها: taskset --cpu-list 0,2 stress -c 3. لماذا لا يستخدم stress ثلاثة معالجات؟

  5. مشكلةٌ شائعةٌ هي أن المنفذ الذي تريد الاستماع عليه مشغولٌ بالفعل بعمليةٍ أخرى. تعلّم كيفية اكتشاف تلك العملية: أولًا نفّذ python -m http.server 4444 لتشغيل خادم ويبٍ صغيرٍ على المنفذ 4444. في طرفيةٍ منفصلة شغّل ss -tlnp | grep 4444 للعثور على العملية. أنهِها بالأمر kill <PID>.