بخشی از Putting the “You” in CPU : کاوشی عمیق در نحوهٔ اجرای برنامهها توسط کامپیوتر شما.
فصل 2: بُرش بزن اون زمانو ویرایش در گیتهاب
فرض کنید دارید یک سیستمعامل مینویسید و میخواید کاربرها بتونن چند برنامه رو همزمان اجرا کنن. اما پردازندهی چند هستهای خفنی ندارید، در نتیجه پردازندهتون فقط میتونه یک دستورالعمل رو در هر لحظه اجرا کنه!
خوشبختانه، شما یک برنامهنویس سیستمعامل خیلی باهوشین. به این نتیجه میرسین که میتونید با دادن نوبت به پروسسها روی CPU، ادای موازیسازی (parallelism) رو دربیارید. اگه بین پروسسها بچرخید و از هر کدوم چندتا دستورالعمل اجرا کنین، همهشون میتونن پاسخگو باشن بدون اینکه هیچ فرایند خاصی CPU رو به تنهایی قُرُق کنه.
اما چطور کنترل رو از کد برنامه پس میگیرین تا فرایندها رو عوض کنین؟ بعد از کمی تحقیق، متوجه میشین که بیشتر کامپیوترها یک تراشهی تایمر (timer chips) دارن. میتونید این تراشه تایمر رو طوری برنامهریزی کنین که بعد از گذشت مدت زمان مشخصی، یک وقفه (interrupt) ایجاد کنه و کنترل رو به یک بخش مخصوص در سیستمعامل به نام «کنترلکننده وقفه» (interrupt handler) بده.
وقفههای سختافزاری (Hardware Interrupts)
قبلتر، از این گفتیم که چطور از وقفههای نرمافزاری برای انتقال کنترل از یک برنامهی سطح کاربر (userland) به سیستمعامل استفاده میشه. به اینها میگن وقفهی «نرمافزاری» چون به صورت داوطلبانه توسط خود برنامه فعال میشن — یعنی کدهای ماشین که پردازنده طبق چرخهی عادی fetch-execute cycle پردازش میکنه، بهش دستور میدن که کنترل رو به کرنل منتقل کنه.

زمانبندهای سیستمعامل (OS schedulers) از تراشههای تایمر مثل PITها (تایمرهای بازهای قابل برنامهریزی) برای ایجاد وقفههای سختافزاری جهت انجام مولتیتسکینگ استفاده میکنن:
- قبل از پریدن به کد برنامه، سیستمعامل، تراشهی تایمر رو طوری تنظیم میکنه که بعد از یک مدت زمان مشخص، وقفه ایجاد کنه.
- سیستمعامل به حالت کاربر (user mode) میره و به دستورالعمل بعدی برنامه میپره.
- وقتی زمان تایمر تموم میشه، یک وقفهی سختافزاری ایجاد میکنه تا به حالت کرنل (kernel mode) بره و کنترل رو به کد سیستمعامل بده.
- حالا سیستمعامل میتونه وضعیت فعلی برنامه (جایی که متوقف شده) رو ذخیره کنه، یک برنامهی دیگه رو بارگذاری کنه و این فرایند رو تکرار کنه.
به این میگن چندوظیفگی پیشدستانه (preemptive multitasking)؛ عمل متوقف کردن یک فرایند رو پیشدستی (preemption) میگن. اگه شما، مثلاً، دارید این مقاله رو توی مرورگر میخونین و همزمان روی همون دستگاه موسیقی گوش میدین، احتمالاً کامپیوتر خودتون داره دقیقاً همین چرخه رو هزاران بار در ثانیه تکرار میکنه.
محاسبهی برش زمانی (Timeslice Calculation)
برش زمانی (timeslice) مدت زمانیه که زمانبند (scheduler) سیستمعامل به یک فرایند اجازه میده اجرا بشه، قبل از اینکه به طور پیشدستانه متوقفش کنه. سادهترین راه برای انتخاب برشهای زمانی اینه که به همهی فرایندها یک برش زمانی یکسان، مثلاً در محدودهی ۱۰ میلیثانیه، اختصاص بدیم و به ترتیب بین وظایف بچرخیم. به این روش زمانبندی چرخشی نوبتی با برش زمانی ثابت (fixed timeslice round-robin) میگن.
نکتهی باحال در مورد اصطلاحات تخصصی!
میدونستین که به برشهای زمانی اغلب «کوانتوم» هم میگن؟ حالا دیگه میدونین و میتونید دوستای گیکتون رو تحت تأثیر قرار بدین. فکر کنم کلی تشویق لازم دارم که تو این مقاله هر دو جمله یه بار نگفتم کوانتوم.
حالا که صحبت از اصطلاحات برش زمانی شد، توسعهدهندگان کرنل لینوکس از واحد زمانی جیفی (jiffy) برای شمارش تیکهای تایمر با فرکانس ثابت استفاده میکنن. بین چیزای دیگه، از جیفیها برای اندازهگیری طول برشهای زمانی هم استفاده میشه. فرکانس جیفی در لینوکس معمولاً ۱۰۰۰ هرتزه ولی موقع کامپایل کردن کرنل میشه تغییرش داد.
برای بهبود جزئی زمانبندی با برش زمانی ثابت، میشه یه حد نهایی برای زمان پاسخدهی (target latency) تعریف کرد. این حد، ایدهآلترین و طولانیترین زمانیه که انتظار میره یه فرآیند واکنش نشون بده. این حد نهایی در واقع زمانیه که طول میکشه تا یه فرآیند بعد از اینکه نوبتش ازش گرفته شد، دوباره اجرا بشه (البته با فرض اینکه تعداد فرآیندها منطقی و معقول باشه). تصور کردن این موضوع یه کم سخته! ولی نگران نباشید، به زودی یه نمودار هم برای توضیحش میاد.
برشهای زمانی اینجوری محاسبه میشن که حد نهایی زمان پاسخدهی رو به تعداد کل تسکها تقسیم میکنیم. این روش از زمانبندی با برش زمانی ثابت بهتره، چون جلوی جابجاییهای بیمورد بین تسکها رو وقتی تعداد فرآیندها کمه، میگیره. مثلاً اگه حد نهایی زمان پاسخدهی ۱۵ میلیثانیه باشه و ۱۰ تا فرآیند داشته باشیم، به هر فرآیند ۱۵/۱۰ یعنی ۱.۵ میلیثانیه زمان برای اجرا میرسه. حالا اگه فقط ۳ تا فرآیند داشته باشیم، به هر کدوم یه برش زمانی طولانیتر یعنی ۵ میلیثانیه میرسه و همزمان اون حد نهایی زمان پاسخدهی هم رعایت میشه.
جابجا کردن فرآیندها (Process Switching) از نظر محاسباتی هزینهبره، چون لازمه که کل وضعیت برنامه فعلی ذخیره بشه و وضعیت یه برنامه دیگه بازیابی (load) بشه. اگه برش زمانی از یه حدی کوچیکتر بشه، میتونه باعث مشکلات عملکردی بشه، چون فرآیندها بیش از حد سریع جابجا میشن. برای همین، معمولاً برای مدت برش زمانی یه حد پایین تعریف میکنن (که بهش حداقل دانهبندی یا minimum granularity هم میگن). البته این کار یه معنی دیگه هم داره: اگه تعداد فرآیندها اونقدر زیاد بشه که مجبور شیم از این حد پایین استفاده کنیم، اون وقت دیگه به اون حد نهایی زمان پاسخدهی (target latency) نمیرسیم (و زمان پاسخدهی از هدف ما بیشتر میشه).
در زمانی که این مقاله نوشته میشه، زمانبندِ (scheduler) سیستمعامل لینوکس از یه حد نهایی زمان پاسخدهی ۶ میلیثانیهای و یه حداقل دانهبندی (حد پایین) ۰.۷۵ میلیثانیهای استفاده میکنه.

زمانبندی چرخشی با همین روش ساده برای محاسبه برش زمانی، خیلی شبیه به کاریه که بیشتر کامپیوترهای امروزی انجام میدن. البته این روش هنوز یه کم سادهانگارانهست؛ بیشتر سیستمعاملها معمولاً زمانبندهای پیچیدهتری دارن که اولویتها و ددلاینهای فرایندها رو هم در نظر میگیرن. از سال ۲۰۰۷، لینوکس از یک زمانبند به نام زمانبند کاملاً منصفانه (Completely Fair Scheduler یا CFS) استفاده میکنه. CFS یک سری کارهای خیلی خفن علوم کامپیوتری انجام میده تا وظایف رو اولویتبندی کنه و زمان CPU رو تقسیم کنه.
هر بار که سیستمعامل یک فرایند رو به طور پیشدستانه متوقف میکنه، باید بافت اجرایی (execution context) ذخیره شدهی برنامهی جدید، از جمله محیط حافظهاش، رو بارگذاری کنه. این کار اینطوری انجام میشه که به پردازنده میگه از یه «جدول صفحه» (page table) متفاوت استفاده کنه؛ جدول صفحه، در واقع نگاشت (mapping) آدرسهای «مجازی» به آدرسهای فیزیکیه. این همون سیستمیه که مانع از دسترسی برنامهها به حافظهی همدیگه میشه؛ توی فصلهای ۵ و ۶ این مقاله بیشتر به این ماجرا میپردازیم.
نکته ۱: قابلیت پیشدستانه بودن کرنل (Kernel Preemptability)
تا اینجا ما فقط داشتیم در مورد توقف و زمانبندی فرآیندهای «فضای کاربری» (userland) حرف میزدیم. کدهای خود کرنل هم میتونن باعث بشن برنامهها لگ بزنن، اگه مثلاً رسیدگی به یه فراخوانی سیستمی (syscall) یا اجرای کد یه درایور، بیش از حد طول بکشه.
کرنلهای مدرن، از جمله لینوکس، هستههای پیشدستانه (preemptive kernels) هستن. این یعنی طوری برنامهریزی شدن که خود کدهای هسته هم میتونن درست مثل فرایندهای فضای کاربری متوقف و زمانبندی بشن.
دونستن این موضوع خیلی مهم نیست، مگه اینکه خودتون در حال نوشتن یه هسته یا چیزی شبیه به اون باشید، ولی خب تقریباً هر مقالهای که من خوندم بهش اشاره کرده، برای همین گفتم منم بگم! دانش اضافه معمولاً چیز بدی نیست.
نکته ۲: یه درس از تاریخ
سیستمعاملهای خیلی قدیمی، مثل نسخههای کلاسیک سیستمعامل مک و نسخههای ویندوز خیلی قبلتر از NT، از یه شکل اولیه از چندوظیفگی پیشدستانه (preemptive multitasking) استفاده میکردند. در اون روش، به جای اینکه سیستمعامل تصمیم بگیره کی نوبت یه برنامه رو بگیره، خودِ برنامهها انتخاب میکردن که کنترل رو به سیستمعامل واگذار کنن (yield). اونا یه وقفهی نرمافزاری (software interrupt) ایجاد میکردن و میگفتن: «هی، از الان میتونی بذاری یه برنامه دیگه اجرا بشه.» این واگذاریهای داوطلبانه، تنها راهی بود که سیستمعامل کنترل رو دوباره به دست بگیره و به فرایند زمانبندی شدهی بعدی سوییچ کنه.
به این میگن چندوظیفگی مشارکتی. این روش چندتا ایراد اساسی داره: برنامههای مخرب یا حتی اونایی که بد طراحی شدن میتونن به راحتی کل سیستمعامل رو قفل کنن، و تقریباً غیرممکنه که بشه برای کارهای لحظهای (realtime) یا حساس به زمان، پایداری زمانی رو تضمین کرد. به همین دلایل، دنیای فناوری خیلی وقت پیش به سمت چندوظیفگی پیشدستانه رفت و دیگه هم به عقب نگاه نکرد.
ادامه خواندن توی فصل 3: How to Run a Program