ساختن یک سیستم‌عامل از صفر، کاری به نظر می‌رسد که مخصوص هکرهای هسته (kernel hackers) و نوشتن به زبان C است. اما شما می‌توانید در یک بعدازظهر، یک شبیه‌سازی ساده‌شده را در Python راه‌اندازی کنید و به سرعت متوجه خواهید شد که منطق مدیریت فرآیند (process management) در یک زبان سطح بالا نیز به همان اندازه بی‌رحم است. من این را به سختی یاد گرفتم. نشستم تا یک شبیه‌ساز کوچک سیستم‌عامل بنویسم. هدف متواضعانه بود: ایجاد چند فرآیند، زمان‌بندی آن‌ها و علامت‌گذاری آن‌ها به عنوان تمام‌شده وقتی کارشان تمام شد. کد کوتاه بود. منطق آن شکست‌ناپذیر به نظر می‌رسید. سپس آن را اجرا کردم و هیچ‌چیز متوقف نمی‌شد.

چرا یک سیستم‌عامل کوچک را در Python بسازیم؟

یک سیستم‌عامل واقعی با مدیریت صفحه‌بندی حافظه (memory paging)، سیستم‌های فایل، وقفه‌های سخت‌افزاری (hardware interrupts) و درایورهای دستگاه سر و کله می‌زند. یک شبیه‌سازی تمام این‌ها را کنار می‌گذارد و به شما اجازه می‌دهد بر روی ایده اصلی تمرکز کنید: وضعیت (state). شما یک فرآیند را تعریف می‌کنید. این فرآیند دارای یک PID، یک زمان اجرا (burst time) و یک وضعیت چرخه حیات است: آماده (Ready)، در حال اجرا (Running)، تمام‌شده (Finished). یک حلقه زمان‌بند (scheduler loop)، کاندیدای بعدی را انتخاب می‌کند، وضعیت آن را تغییر می‌دهد، یک بازه زمانی (time slice) را شبیه‌سازی می‌کند و آن را به وضعیت تمام‌شده منتقل می‌کند.

Python ابزار بسیار خوبی برای این نوع آزمایش است، زیرا به شما اجازه می‌دهد از محاسبات اشاره‌گر (pointer arithmetic) و تراز کردن حافظه (memory alignment) صرف‌نظر کنید. یک لیست از دیکشنری‌ها به جدول فرآیند شما تبدیل می‌شود. یک حلقه while به زمان‌بند هسته شما تبدیل می‌شود. شما می‌توانید زمان‌بندی راند-رابین (round-robin) یا صف‌های اولویت‌دار را تنها با استفاده از ابزارهای کتابخانه استاندارد پیاده‌سازی کنید. این کار قابل دسترس به نظر می‌رسد، و دقیقاً به همین دلیل است که باگی که در پی آن آمد، بسیار خشم‌آور بود.

تنظیمات

شبیه‌سازی من از لیستی به نام process_table استفاده می‌کرد. هر ورودی یک دیکشنری با ساختار زیر بود:

{
    "pid": 1,
    "burst_time": 3,
    "status": "ready"
}

زمان‌بند یک حلقه ساده while را اجرا می‌کرد. این حلقه جدول را برای اولین فرآیندی که وضعیت آن "finished" نبود، جستجو می‌کرد. وقتی یکی پیدا می‌کرد، یک تابع کمکی به نام execute_tick(p) را برای اجرای آن فرآیند برای یک چرخه شبیه‌سازی‌شده فراخوانی می‌کرد. داخل execute_tick ، من وضعیت فرآیند را به "running" تغییر می‌دادم، زمان اجرا را کاهش می‌دادم و بررسی می‌کردم که آیا کار باقی‌مانده به صفر رسیده است یا خیر. اگر رسیده بود، وضعیت را به "finished" تغییر می‌دادم. قرار بود حلقه بیرونی زمانی متوقف شود که هر فرآیند به وضعیت تمام‌شده برسد.

روی کاغذ، جریان کار تمیز بود. پیدا کردن یک فرآیند آماده. اجرای آن. بررسی اتمام کار. تکرار تا پایان. من حتی دستورات print اضافه کردم تا کارکرد زمان‌بند را زیر نظر بگیرم. می‌توانستم انتخاب شدن فرآیندها را ببینم. حلقه همچنان در حال چرخش بود. با این حال، به نظر می‌رسید فرآیندها وارد یک زمان حال ابدی شده‌اند؛ همیشه در حال اجرا، و هرگز به مرحله بعد نمی‌روند.

نشانه

این بدترین نوع شکست است: نوع بی‌صدا. هیچ ردپایی از خطا (stack trace) در ترمینال ظاهر نشد. هیچ IndexError یا KeyError ای به من سرنخی برای دنبال کردن نداد. مفسر (interpreter) کاملاً راضی بود. برنامه صرفاً رفتار درستی نداشت. فرآیندها شروع می‌شدند، اما هرگز تمام نمی‌شدند. من ساعت‌ها وقت صرف ردیابی جریان برنامه کردم.

آیا شرط حلقه اشتباه بود؟ شاید در محاسبه زمان اجرا یک خطای off-by-one داشتم. آیا جدول فرآیند به جای به‌روزرسانی در محل، سایه‌گذاری یا کپی می‌شد؟ آیا شرط پایان من کلید اشتباهی را بررسی می‌کرد؟ دستورات print بیشتری اضافه کردم. تمام عبارات بولی (boolean) را بازبینی کردم. همه چیز را زیر سوال بردم، به جز آن یک خطی که واقعاً اهمیت داشت.

مقصر

سپس آن را دیدم. داخل execute_tick نوشته بودم:

p["status"] == "running"

دو علامت مساوی. یک مقایسه، نه یک انتساب (assignment). اصلاح آن تنها یک کاراکتر فاصله داشت:

p["status"] = "running"

در Python، عبارت p["status"] == "running" یک عبارت کاملاً معتبر است. این عبارت به True یا False ارزیابی می‌شود و سپس مفسر نتیجه را دور می‌اندازد زیرا من هرگز آن را به چیزی نسبت نداده بودم. این خط مطلقاً هیچ کار مفیدی انجام نمی‌دهد. ورودی دیکشنری بدون تغییر باقی می‌ماند، هر وضعیتی که قبلاً داشت را حفظ می‌کرد و فرآیند هرگز در چرخه حیات خود پیش نمی‌رفت.

من آن را به یک علامت مساوی تغییر دادم. اسکریپت را دوباره اجرا کردم. شبیه‌سازی جان گرفت. فرآیندها دقیقاً طبق برنامه از وضعیت‌های آماده، در حال اجرا و تمام‌شده عبور کردند. یک کلید اضافه، ساعت‌ها برای من هزینه داشت.

چرا این باگ‌ها پنهان می‌مانند

دلیل اینکه این موضوع اینقدر آزاردهنده است این است که Python یک عبارت (expression statement) را به عنوان خطا علامت‌گذاری نمی‌کند، مگر اینکه سینتکس آن کاملاً نامعتبر باشد. این باگ یک غلط تایپی معنایی (semantic typo) بود. برنامه وضعیت را مقایسه می‌کرد، یک مقدار بولی تولید می‌کرد و آن را دور می‌انداخت. از آنجایی که خودِ مقایسه می‌توانست False برگرداند، فرآیند در وضعیت قبلی خود گیر می‌کرد و حلقه بیرونی دلیلی برای شکستن (break) نداشت.

شما این موضوع را با سوگیری تأییدی (confirmation bias) تشدید می‌کنید. شما می‌دانید که یک عبارت انتساب (assignment) تایپ کرده‌اید، چون قصد داشتید یک انتساب بنویسید. وقتی برای پنجمین بار کد را می‌خوانید، مغز شما نماد را به‌صورت خودکار اصلاح می‌کند. به همین دلیل است که روش «اردک لاستیکی» (rubber ducking) جواب می‌دهد. این روش شما را مجبور می‌کند هر خط را آن‌قدر آهسته بیان کنید که شکاف بین آنچه نوشته شده و آنچه مد نظرتان بوده، آشکار شود.

باگ‌های کوچک از این دست، سخت‌تر از کرش‌های (crashes) بزرگ و چشمگیر پیدا می‌شوند. یک خطای segfault یا خطای سینتکس (syntax error) بلافاصله خود را نشان می‌دهد. یک دستور no-op بی‌صدا، صرفاً وضعیت (state) را خراب می‌کند و اجازه می‌دهد برنامه با لنگ‌لنگان به کار خود ادامه دهد. شکست در مراحل بعدی رخ می‌دهد و غریزه شما این است که به جای علت، نشانه‌ها را دیباگ کنید.

یک دفاع بهتر

شما نمی‌توانید تنها به چشمان خود اعتماد کنید. پس از این اتفاق، چند عادت را تغییر دادم که می‌توانست خطا را زودتر شناسایی کند.

اول اینکه، اگر در حال مدیریت وضعیت (state) در یک دیکشنری هستید، استفاده از یک dataclass یا enum.Enum را برای وضعیت‌های فرآیند (process states) در نظر بگیرید. وضعیت‌های خود را به عنوان ثابت (constant) یا اعضای enum تعریف کنید:

from enum import Enum

class ProcessState(Enum):
    READY = "ready"
    RUNNING = "running"
    FINISHED = "finished"

با استفاده از تایپ‌های صریح (explicit types)، ابزارهایی مانند mypy می‌توانند در طول تحلیل ایستا (static analysis)، مقایسه‌های مشکوک را علامت‌گذاری کنند. وقتی تایپ‌ها با انتظارات مطابقت نداشته باشند، تشخیص یک مقایسه تصادفی در جایی که باید یک انتساب (assignment) باشد، بسیار آسان‌تر می‌شود.

دوم اینکه، قبل از نوشتن منطق زمان‌بند (scheduler logic)، برای انتقال وضعیت‌ها (state transitions) تست واحد (unit test) بنویسید. یک تست ساده که فرآیندی با یک گام (tick) کاری ایجاد کند، زمان‌بند را اجرا کند و وضعیت نهایی را FINISHED بررسی کند، بلافاصله با خطا مواجه می‌شد. آن خطا، جستجو را به جای اینکه اجازه دهد من در کل حلقه سرگردان شوم، به منطق به‌روزرسانی وضعیت محدود می‌کرد.