بناء نظام تشغيل من الصفر يبدو وكأنه مهمة مخصصة لمبرمجي النواة (kernel hackers) الذين يكتبون بلغة C. ولكن يمكنك تشغيل محاكاة مبسطة باستخدام Python في غضون فترة ما بعد الظهيرة، وستكتشف سريعًا أن منطق إدارة العمليات لا يقل صرامة في اللغات عالية المستوى. لقد تعلمت هذا بالطريقة الصعبة. جلست لأكتب محاكي نظام تشغيل صغير للغاية. كان الهدف متواضعًا: إنشاء بضع عمليات، وجدولتها، وتحديدها كمكتملة عند انتهاء عملها. كان الكود قصيرًا، وبدا المنطق محصنًا ضد الأخطاء. ثم قمت بتشغيله، ولم ينتهِ أي شيء.
لماذا تبني نظام تشغيل مصغرًا باستخدام Python؟
يقوم نظام التشغيل الحقيقي بالتوفيق بين صفحات الذاكرة (memory paging)، وأنظمة الملفات، ومقاطعات الأجهزة (hardware interrupts)، وبرامج تشغيل الأجهزة. أما المحاكاة فتجرد كل ذلك وتسمح لك بالتركيز على الفكرة الجوهرية: الحالة (state). أنت تُعرف عملية ما؛ لها معرف عملية (PID)، ووقت تنفيذ (burst time)، وحالة دورة حياة: جاهزة (Ready)، قيد التشغيل (Running)، مكتملة (Finished). تقوم حلقة المجدول (scheduler loop) باختيار المرشح التالي، وتطوير حالته، ومحاكاة شريحة زمنية، ثم نقله إلى حالة الانتهاء.
تعد Python وسيلة ممتازة لهذا النوع من التجارب لأنها تتيح لك تجاهل حسابات المؤشرات (pointer arithmetic) ومحاذاة الذاكرة (memory alignment). تصبح قائمة القواميس (list of dictionaries) هي جدول العمليات الخاص بك. وتصبح حلقة while هي مجدول النواة (kernel scheduler). يمكنك تنفيذ جدولة Round-robin أو طوابير الأولويات (priority queues) باستخدام أدوات المكتبة القياسية فقط. يبدو الأمر سهل المنال، وهذا هو بالضبط سبب الإحباط الشديد الذي سببه الخطأ الذي تلا ذلك.
الإعداد
استخدمت محاكاتي قائمة تسمى process_table. كان كل إدخال عبارة عن قاموس بهذا الشكل:
{
"pid": 1,
"burst_time": 3,
"status": "ready"
}
كان المجدول يشغل حلقة while بسيطة. كان يمسح الجدول بحثًا عن أول عملية لم تكن حالتها "finished". وعندما يجد واحدة، يستدعي دالة مساعدة، execute_tick(p)، لتشغيل تلك العملية لدورة محاكاة واحدة. داخل execute_tick ، قمت بضبط حالة العملية على "running"، ونقصت وقت التنفيذ، وتحققت مما إذا كان العمل المتبقي قد وصل إلى الصفر. إذا حدث ذلك، قمت بتحديث الحالة إلى "finished". كان من المفترض أن تتوقف الحلقة الخارجية بمجرد وصول كل عملية إلى حالة الانتهاء.
على الورق، كان التدفق واضحًا. ابحث عن عملية جاهزة. شغلها. تحقق من الاكتمال. كرر العملية حتى الانتهاء. حتى أنني أضفت جمل طباعة (print statements) لمراقبة المجدول أثناء عمله. كنت أرى العمليات يتم اختيارها. كانت الحلقة تستمر في الدوران. ومع ذلك، بدت العمليات وكأنها دخلت في زمن حاضر أبدي، تعمل للأبد، ولا تنتقل أبدًا إلى المرحلة التالية.
الأعراض
هذا هو أسوأ أنواع الفشل: الفشل الصامت. لم يظهر أي تتبع للمكدس (stack trace) في الطرفية (terminal). لم تظهر أي أخطاء من نوع IndexError أو KeyError لتعطيني خيطًا لأتبعه. كان المفسر (interpreter) سعيدًا تمامًا. ببساطة، لم يتصرف البرنامج كما ينبغي. بدأت العمليات، لكنها لم تنتهِ أبدًا. قضيت ساعات في تتبع التدفق.
هل كان شرط الحلقة خاطئًا؟ ربما كان لدي خطأ بمقدار واحد (off-by-one error) في حساب وقت التنفيذ. هل كان جدول العمليات يتم حجبه أو نسخه بدلاً من تحديثه في مكانه؟ هل كان شرط الإنهاء الخاص بي يتحقق من المفتاح الخاطئ؟ أضفت المزيد من جمل الطباعة. راجعت كل تعبير منطقي (boolean expression). شككت في كل شيء باستثناء السطر الوحيد الذي كان يهم حقًا.
الجاني
ثم رأيته. داخل execute_tick ، كنت قد كتبت:
p["status"] == "running"
علامتا يساوي. إنها عملية مقارنة، وليست عملية تعيين (assignment). كان الإصلاح على بعد حرف واحد فقط:
p["status"] = "running"
في Python، يعد التعبير p["status"] == "running" تعبيرًا صالحًا تمامًا. فهو يُقيم إلى True أو False ، ثم يتخلص المفسر من النتيجة لأنني لم أقم بتعيينها لأي شيء. هذا السطر لا يفعل أي شيء مفيد على الإطلاق. ظل إدخال القاموس دون تغيير، محافظًا على الحالة التي كان يحملها من قبل، ولم تتقدم العملية أبدًا عبر دورة حياتها.
قمت بتغييره إلى علامة يساوي واحدة. قمت بتشغيل السكربت مرة أخرى. بدأت المحاكاة تتنفس. انتقلت العمليات عبر حالات الجاهزية، والتشغيل، والانتهاء تمامًا كما هو مخطط لها. ضغطة مفتاح واحدة إضافية كلفتني ساعات.
لماذا تختبئ هذه الأخطاء
السبب في أن هذا الأمر مؤلم للغاية هو أن Python لا تضع علامة خطأ على جملة التعبير (expression statement) ما لم تكن صياغتها (syntax) غير صالحة تمامًا. كان الخطأ خطأً دلاليًا (semantic typo). قام البرنامج بمقارنة الحالة، وأنتج قيمة منطقية، ثم رماها. ولأن المقارنة نفسها يمكن أن تعيد False ، ظلت العملية عالقة في حالتها السابقة، ولم يكن للحلقة الخارجية سبب للتوقف.
تزيد من تفاقم هذا الأمر بسبب الانحياز التأكيدي. فأنت تعلم أنك كتبت عملية إسناد لأنك كنت تنوي كتابة عملية إسناد. وعندما تقرأ الكود للمرة الخامسة، يقوم دماغك بتصحيح الرمز تلقائياً. وهذا هو سبب فعالية تقنية rubber ducking؛ فهي تجبرك على صياغة كل سطر ببطء كافٍ بحيث تصبح الفجوة بين ما هو مكتوب وما كنت تقصده واضحة.
الأخطاء البرمجية الصغيرة مثل هذه أصعب في العثور عليها من الانهيارات الكارثية. فخطأ الـ segfault أو خطأ الـ syntax error يعلنان عن نفسهما فوراً. أما الـ no-op الصامت، فهو ببساطة يفسد الحالة (state) ويجعل البرنامج يستمر في العمل بشكل متعثر. يظهر الفشل في مراحل لاحقة، وتكون غريزتك هي تصحيح العرض بدلاً من السبب.
دفاع أفضل
لا يمكنك الاعتماد على عينيك وحدهما. بعد هذه الواقعة، غيرتُ بعض العادات التي كانت ستكشف الخطأ في وقت أبكر.
أولاً، إذا كنت تقوم بإدارة الحالة (state) في قاموس (dictionary)، ففكر في استخدام dataclass أو enum.Enum لحالات العمليات. قم بتعريف حالاتك كثوابت أو كأعضاء في enum:
from enum import Enum
class ProcessState(Enum):
READY = "ready"
RUNNING = "running"
FINISHED = "finished"
مع الأنواع الصريحة (explicit types)، يمكن لأدوات مثل mypy تحديد المقارنات المشبوهة أثناء التحليل الساكن (static analysis). وتصبح المقارنة غير المقصودة في المكان الذي يجب أن تكون فيه عملية إسناد أسهل بكثير في اكتشافها عندما لا تتوافق الأنواع مع التوقعات.
ثانياً، اكتب اختبارات الوحدة (unit tests) لانتقالات الحالة قبل كتابة منطق المجدول (scheduler logic). إن اختباراً بسيطاً ينشئ عملية بـ tick واحد من العمل، ويشغل المجدول، ويتحقق من أن الحالة النهائية هي FINISHED كان سيفشل على الفور. وكان هذا الفشل سيحصر البحث في منطق تحديث الحالة بدلاً من تركي أتخبط في الحلقة (loop) بأكملها.
