Concurenncy Part Ⅰ : OS scheduler 1

وقتی با Go کار میکنیم، خیلی راحت goroutine میسازیم و انتظار داریم Runtime خودش همهچیز رو مدیریت کنه. ولی پشت این سادگی، دو Scheduler مهم دارن با هم کار میکنن:
OS Scheduler و Go Scheduler
اگر نفهمیم هرکدوم دقیقاً چه کاری انجام میدن، نمیتوان مثل یک مهندس نرم افزار واقعی روی یک برنامه همروندی کار کرد. پس قبل از اینکه بریم سراغ Go Scheduler، اول باید بفهمیم OS Scheduler اصلاً چیکار میکنه.
خود طراحی و رفتار go scheduler باعث میشه که برنامه های مولتیترد کارآمد و پربازده باشند. دلیل این اتفاق هم هماهنگی و سازگاری مکانیکی بین گو اسکجولر و os اسکجولر است.
با این حال اگر طراحی و رفتار برنامه مولتیترد شما با نحوه کار این اسکجولر ها سازگار نباشه، دیگه این مزیت ها عملا بیفایده هست. پس برای طراحی همچین نرم افزار هایی شما باید یک درک خیلی خوب از مفاهیم و نحوه کار هردو اسکجولر یعنی : OS Scheduler و Go Scheduler داشته باشید.
خب این منبع من از مقالات ویلیام کندی هست که بنظرم یکی از بهترین منبعها هست برای درک کانکارنسی در Go و ویلیام کندی که نیاز به تعریف نداره یکی از بهترین مدرسهای golang هست، این نوشتهها چیزایی است که در ذهن من از این مقاله ها نقش بسته است.
1. خب بیایید با یک توضیح ریز از OS Schaduler شروع کنیم
اسکجولر های سیستم عامل، نرم افزار های بسیار بسیار پیچیدهای هستند. آن هارو باید ساختار و پیکربندی سیستمی که روی آن در حال اجرا هستند در نظر بگیرید.
این موارد شامل، اما نه محدود به:
- چندین Processor
- چندین Core
- CPU Cache
- NUMA است.
برای کارآمد عمل کردن، اسکجولر نیاز داره به این اطلاعات. برای درک os اسکجولر اصلا نیاز نیست وارد تمام جزئیات بشید. فقط ساز و کار بعضی از عوامل مهم رو درک کنید کافیست.
2. اول از همه: Thread چیست؟
خب Thread چیست؟
برنامهمون رو تصور کن که قرار هست کلی دستور اجرا کنه. سیستمعامل برای اینکه این دستورها واقعاً روی CPU اجرا بشن، Execution Contextهایی به نام Thread داره.
اجرای Thread تا زمانی ادامه پیدا میکند که دیگر Instructionای برای اجرا نداشته باشد. فرض کن یک برنامه داری که باید ۱۰ کار انجام دهد. سیستمعامل برای اجرای این کارها از Thread استفاده میکند پس فعلاً این رو توی ذهنت نگه دار:
Thread = یک مسیر اجرا (Path of Execution)
3. Process و Thread
هر برنامهای که اجرا میکنید یک Process ایجاد میکند. هر Process نیز در ابتدا یک ترد دریافت میکند. و تردها میتوانند تردهای بیشتری ایجاد کنند. هر Process میتونه چندین ترد داشته باشه و این تردها میتونن بهصورت مستقل Runnable یا Waiting بشن.
حالا ترد ها میتوانند:
concurrent(همزمان) به این معنی که هرکدام به نوبت رو یک Core اجرا شوند. یا میتوانند:
parallel(موازی) به این هرکدام واقعا در همان لحظه روی Coreهای مختلف اجرا شوند.
مثلاً اگر فقط یک Core داشته باشیم و دو ترد داشته باشیم، هر دو میتونن Concurrent باشن، ولی همزمان واقعاً اجرا نمیشن. اگر دو Core داشته باشیم، هر دو ترد میتونن واقعاً Parallel اجرا بشن.
تردها همچنین State مخصوص خودشون رو نگهمیدارند تا بتوانند Instructionهای خود رو به صورت امن، محلی و مستقل اجرا کنند.
4. پس Scheduler اینجا چیکارس؟
حالا خب ترد رو فهمیدیم چیه، حالا بریم ببینیم os اسکجولر اینجا چیکارس.
حالا فرض کن فقط ۲ صندلی داریم ولی ۱۰ نفر میخواهند بنشینند. یک نفر باید تصمیم بگیرد: «الان چه کسی روی کدام صندلی بنشیند؟» اسکجولر تقریباً همین کار را برای CPU انجام میدهد.
وظیفه OS Scheduler :
- ترد ها و core هارو تا حد امکان بیکار نگذارد
- این تصور رو ایجاد کند که هر ترد Runnable(قابل اجرا) همزمان درحال اجرا باشند
- ترد های دارای اولویت بالاتر رو نسبت به ترد های اولویت پایینتر رو در اولویت قرار دهد.
- اجازه ندهد تردهای با اولویت پایین برای همیشه از CPU محروم شوند.
- Scheduling Latency را تا حد امکان کاهش دهد.
کلا اینطوری در نظر بگیرید که CPU منبعی هست که اسکجولر باید تا جای ممکن درست ازش استفاده کنه.برای همین باید هیچ وقت نباید بیکار بماند و تاخیر نداشته باشد که اسکجولر این رو مدیریت میکنه.
اینم از وظایف یک OS اسکجولر، حالا بیاید برگردیم به ترد ها.
5. یک Thread سه حالت مهم دارد
هر ترد یک State دارد، State مشخص میکنه ترد الان در چه وضعیتی قرار داره و اسکجولر باهاش چه کاری میتونه انجام بده. یک ترد میتونه در سه حالت زیر باشد:
- Waiting
- Runnable
- Executing
1. Waiting
یعنی Thread متوقف شده و منتظر چیزی است تا بتواند ادامه دهد.
برای مثال ممکن است منتظر موارد زیر باشد:
- Hardware
- Disk
- Network
- Operating System System Call
- Synchronization
- Atomic
- Mutex
این نوع Latencyها یکی از دلایل اصلی Performance ضعیف هستند.
2. Runnable
یعنی Thread آماده است و میخواهد زمانی را روی یک Core به دست آورد تا دستور عملهای خود را اجرا کند.
اگر تعداد زیادی ترد وجود داشته باشند که همگی منتظر CPU هستند، مدت بیشتری طول میکشد تا هر ترد زمان اجرا دریافت کند.
همچنین هرچه تعداد Threadهای بیشتری برای CPU رقابت کنند، مقدار زمانی که هر ترد دریافت میکند کوتاهتر میشود.
این نوع Scheduling Latency نیز میتواند باعث Performance ضعیف شود.
3. Executing
یعنی ترد روی یک Core قرار گرفته و در حال اجرای دستور عملهای خود است.
در این حالت کار مربوط به Application واقعاً در حال انجام شدن است.
و این دقیقاً همان چیزی است که ما میخواهیم.
خب پس فهمیدم که یک ترد 3 حالت داره که این حالات مشخص میکند اسکجولر چه رفتاری با اون ترد انجام دهد.
6. خب CPU-Bound و IO-Bound یعنی چه؟
حالا ما انواع Work هم داریم یک ترد میتواند دو نوع کار داشته باشد:
- CPU-Bound
- IO-Bound
CPU-Bound
اینجا باعث نمیشود ترد وارد حالت Waiting شود. یعنی کاری که بیشتر زمانش صرف محاسبه روی CPU میشه. مثلاً محاسبهی تعداد خیلی زیادی از ارقام Pi. اینجا Thread معمولاً CPU میخواد و Work برای انجام دادن داره.
IO-Bound
کاری است که باعث میشود ترد وارد حالت Waiting شود. این نوع کار معمولاً شامل درخواست دسترسی به یک ریسورس از طریق Network یا انجام System Call در Operating System است. مثلاً Threadی که باید به Database دسترسی پیدا کند، یک کار IO-Bound انجام میدهد.
نویسنده همچنین Synchronizationهایی مثل:
- Mutex
- Atomic
را که ممکن است باعث Waiting شدن Thread شوند، در این دسته قرار میدهد.
خب بریم سراغ...
7. Context Switching
اگر از Linux، Mac یا Windows استفاده میکنید، سیستمعامل شما دارای یک Preemptive Scheduler است.
این موضوع چند نکتهی مهم دارد.
اول اینکه اسکجولر در مورد اینکه در هر لحظه کدام ترد اجرا شود، قابل پیشبینی نیست.
ترکیب مواردی مانند:
- Thread Priority
- دریافت Data از Network
- Eventهای مختلف
باعث میشود نتوانید دقیقاً مشخص کنید اسکجولر چه زمانی کدام ترد را انتخاب خواهد کرد. بنابراین نباید برنامهای بنویسید که به رفتاری وابسته باشد که صرفاً در سیستم شما بارها اتفاق افتاده ولی تضمین نشده است. اینکه چیزی را هزار بار به یک شکل دیدهاید، به معنی Guaranteed Behavior بودن آن نیست. اگر برای برنامه خود به رفتار ثابت نیاز دارید، باید Synchronization و Orchestration مربوط به Threadها را خودتان کنترل کنید.
که بعدا به این موضوع میپردازیم.
حالا اینارو گفتیم ولی نگفتیم کانتکس سوییچ دقیقا چیه خب Context Switch یعنی وقتی سیستمعامل اجرای یک ترد رو متوقف میکنه و Context اون رو نگه میداره تا بتونه ترد دیگهای رو اجرا کنه.
و Context Switch یک ترد در حالت Executing را از روی Core خارج کرده و یک ترد در حالت Runnable را جایگزین آن میکند.
حالا بزار اینجارو یک توضیح ریز با مثال بزنم. فرض کن Thread A داره کار میکنه و وسط کار باید از Disk چیزی بخونه. خب Disk قرار نیست به سرعت CPU جواب بده. پس A فعلاً کاری برای انجام دادن نداره و وارد حالت Waiting میشه. حالا اگر فقط یک Core داشته باشیم، آیا باید CPU رو هم بیکار کنیم و منتظر A بمونیم؟ نه.
Scheduler میگه: «A فعلاً کاری نداره، پس بذار Thread B رو اجرا کنم.» بنابراین:
A → Waiting B → Executing و Core همچنان مشغول کار میمونه. وقتی I/O مربوط به A تمام شد، A دوباره Runnable میشه و منتظر نوبت اجرای خودش میمونه.
و این رو در ذهنتون هک کنید که اگر Work قابل اجرا وجود داره، نباید Core بیدلیل بیکار بمونه. حالا بریم جلو تر میفهمیم ولی این رو داشته باشد در چنین موقعیت هایی که ما یک ترد یا گوروتین بلاک شده داریم حتما باید راه حلی داشته باشیم که اسکجولر منتظر نمونه.
8. چرا Context Switch هزینه دارد؟
حالا این کانتکس سوییچی که اسکجولر سیستم عامل انجام میده هزینه سنگینی دارد. منظورم از هزینه چیه؟ جابهجایی تردها روی Core زمان میبرد.
مقدار Latency(تاخیری) به عوامل مختلفی بستگی دارد، اما مقدار تقریبی آن میتواند چیزی حدود:
۱۰۰۰ تا ۱۵۰۰ نانوثانیه
باشد.
اگر فرض کنیم یک Core به طور متوسط بتواند حدود:
۱۲ دستور عمل در هر نانوثانیه
اجرا کند، یک Context Switch میتواند چیزی حدود:
۱۲٬۰۰۰ تا ۱۸٬۰۰۰ دستور عمل
هزینهی Latency ایجاد کند.
یعنی برنامه در هنگام کانتکس سوییچ فرصت اجرای تعداد قابلتوجهی دستور عمل را از دست میدهد.
و جلوتر میبینیم که گو اسکجولر میتونه با استفاده از Goroutineها، بسیاری از هزینههایی که در Context Switch بین ترد OSها داریم رو کاهش بده.
Context Switch و IO-Bound
اگر برنامهی شما بیشتر IO-Bound باشد، Context Switch میتواند مفید باشد.
فرض کنید یک Thread وارد حالت: Waiting شود.
در این صورت یک Thread دیگر که در حالت: Runnable قرار دارد میتواند جای آن را بگیرد. بنابراین Core همچنان مشغول انجام Work باقی میماند. این یکی از مهمترین اصول Scheduling است:
اگر Work برای انجام دادن وجود دارد، اجازه نده Core بیکار بماند.
Context Switch و CPU-Bound
اما اگر برنامهی شما بیشتر CPU-Bound باشد، Context Switch میتواند به یک کابوس Performance تبدیل شود. چرا؟ چون Thread دائماً Work برای انجام دادن دارد. بنابراین Context Switch باعث میشود اجرای آن Work متوقف شود. این کاملاً برخلاف چیزی است که در Workloadهای IO-Bound اتفاق میافتد
9. قانون بازی: Less Is More
پس Thread بیشتر همیشه بهتر نیست؟ نه!
این یکی از پیامهای اصلی مقاله است:
Less is More
یعنی:
کمتر، بیشتر است.
تعداد تردهایی که در Application خود استفاده میکنید، در کنترل شماست. هرچه Threadهای بیشتری داشته باشید، مخصوصاً وقتی IO-Bound Work نیز وجود دارد:
- Chaos بیشتر میشود.
- رفتار Non-deterministic بیشتر میشود.
- زمان Scheduling افزایش پیدا میکند.
- زمان اجرای Work افزایش پیدا میکند.
به همین دلیل یک قانون مهم وجود دارد:
Less is More
یعنی:
تردهای Runnable کمتر
→یعنی Scheduling Overhead کمتر
→ زمان بیشتر برای هر Thread
→یعنی Work بیشتر در طول زمان
تردهای Runnable بیشتر
→ زمان کمتر برای هر Thread
→ یعنی Scheduling Overhead بیشتر
→ و Work کمتری در طول زمان انجام میشود.
در کل Thread بیشتر = Performance بیشتر و تردهای بیشتر میتونن باعث Scheduling Overhead، رقابت بیشتر برای CPU و Context Switchهای بیشتر بشن. بنابراین هدف ما این نیست که تا جایی که میتونیم Thread بسازیم؛ هدف اینه که به اندازهی مناسب از Thread استفاده کنیم
10. حالا Cache را وارد داستان کنیم
این قسمت مقاله کمی سختتر است، ولی ایدهاش ساده است. خب CPU برای دسترسی به RAM باید نسبتاً زمان زیادی صرف کند. برای همین CPU حافظههای کوچک و سریعتری به نام Cache دارد. دادههایی که CPU زیاد به آنها نیاز دارد، میتوانند در Cache قرار بگیرند.
حالا Cache Line چیست؟
معمولاً CPU دادهها رو بین Cache و حافظه در واحدهایی به نام Cache Line جابهجا میکنه.
اندازهی Cache Line در بسیاری از CPUهای امروزی ۶۴ بایته.
تو فرض کن یک CPU دارای یک کش لاین هست که این کش لاین شامل 64 بایت دیتا است. حالا مشکل زمانی ایجاد میشود که چند ترد تصمیم بگیرند دیتا های نزدیک بهم رو به صورت همزمان تغییر دهند.
مشکل Cache Coherency
فرض کن دو Core داریم:
Core 1 → Cache → داده X Core 2 → Cache → داده X هر دو Core یک کپی از Cache Line مربوط به X دارند. حالا در Core 1 اتفاقی میافتد که مقدار X را تغییر میدهد. اسکجولر باید مطمئن شود Core 2 دیگر از نسخهٔ قدیمی استفاده نمیکند. پس هماهنگ کردن Cacheها هزینه دارد، به این موضوع Cache Coherency میگویند.
11. قسمت جالب این مقاله: Scheduler چه تصمیمی بگیرد؟
حالا که Thread، Core، Scheduler، Context Switch و Cache رو شناختیم، بیایید همهی اینها رو بندازیم وسط یک سناریوی واقعی.
فرض کن:
Core 1 ↓ Thread اصلی ↓ در حال کار
ترد اصلی یک ترد جدید ایجاد میکند. حالا اسکجولر سه انتخاب دارد:
گزینه ۱
ترد اصلی را متوقف کند و ترد جدید را اجرا کند.
A → خارج B → اجرا مزیت: B سریع اجرا میشود. عیب: A زودتر از موعد متوقف شده است.
گزینه ۲
میتواند B صبر کند تا A کارش تمام شود.
A → ادامه B → Waiting مزیت: A میتواند Time Slice خود را کامل کند. عیب: B باید صبر کند.
گزینه ۳
می تواند B را روی Core دیگری اجرا کند. Core 1 → A Core 2 → B مزیت: هر دو میتوانند اجرا شوند. عیب: Core جدید ممکن است Cache مناسب B را نداشته باشد.
اینجا تازه میفهمیم Scheduler فقط قرار نیست بگه «کدوم Thread اجرا بشه و نکتهٔ اصلی این است که Scheduler باید بین این عوامل مختلف تعادل برقرار کند.