Scheduling Part Ⅱ : Go Scheduler

خب حالا که فهمیدیم اسکجولر سیستم عامل چطور کار میکنه وقتشه بریم سراغ اسکجولر گو.
اینجا مفهمیم که صف اجرای سراسری و صف اجرای محلی چی هستن، مفهمیم که چطور یک گو روتین اول در یک صف قرار میگیره و چطور مدیریت میشه که در نهایت به یک ترد سپرده بشه و همچنین با کانستکس سوییچ و worl stealing سر وکار داریم.
درک این مفاهیم به شما کمک میکند که تصمیم های مهندسی بهتری در همزمانی و بهینه سازی برنامه های گو انجام دهید.
این مطلب دومین پست از یک مجموعهٔ سهقسمتی است که هدف آن ارائهٔ درکی از سازوکار و مفاهیم پشت زمانبند Go است. این قسمت روی Go Scheduler تمرکز دارد. زمانبند Go یک سیستم پیچیده است و جزئیات مکانیکی کوچک آن چندان مهم نیستند. چیزی که اهمیت دارد، داشتن یک مدل ذهنی مناسب از نحوهٔ کار و رفتار آن است. این موضوع به شما کمک میکند تصمیمهای مهندسی بهتری بگیرید.
1. شروع اجرای برنامه شما
اول یک نکته رو توجه کن: هر پردازنده ممکنه چند هسته فیزیکی (Physical Core) داشته باشه. مثلا CPU خودم رو مثال میزنم، Intel Core i3-12100F، 4 هسته داره که هر هسته شامل 8 رشتهٔ پردازشی (Logical Threads) است. یعنی هر هسته میتونه دو کار رو همزمان انجام بده، که به این میگوینئ: Hyper-Threading. سیستمعامل این ۸ تا Thread سختافزاری رو به عنوان ۸ CPU منطقی میبینه.
حالا گو این رو چطور میبینه و باهاش برخورد میکنه؟ وقتی برنامهٔ Go شروع میشه، رانتایم میاد میپرسه: سیستم چند تا CPU منطقی در اختیار من گذاشته؟ مثلا میتونی با این دستور runtime.NumCPU() شما ببینید که چند تا هستهٔ مجازی دارید. این دستور رو سیستم من 8 رو نشون میده.
حالا P وارد ماجرا میشه. گو اسکجولر به تعداد CPUهای منطقی، P میسازه. حالا P چیه؟ جلوتر کامل متوجه میشید که P چیه و رابطش با G و M چیه. فقط این رو در نظر بگیرید که به ازای هر هستهٔ مجازی (Virtual Core) که روی ماشین میزبان شناسایی شده، یک پردازندهٔ منطقی (Logical Processor یا P) دریافت میکند. مثلا اگر : NumCPU() = 8 یعنی P = 8
پس تا اینجا داشته باشید چی شد.
حالا بریم سراغ
2. رابطه P، M و G
پس فهمیدید که هر P به یک ترد سیستم عامل اختصاص داده میشه، که این ترد ها رو M مینامند. حرف M مخفف Machine است. این رو داشته باشید که هر ترد توسط سیستم عامل مدیریت میشه و سیستم عامل مسئول قرار دادن اون ترد رو یک هسته برای اجراست. یعنی مربوط به OS Scheduler. بنابراین در مثال ما، ۸ Thread داریم که میتوانند کارها را اجرا کنند و هرکدام به یک P متصل هستند. هر برنامهٔ Go همچنین یک Goroutine اولیه (G) دریافت میکند.
فقط این 3 تارو تو ذهنت بسپار:
G = کاری که باید انجام بشه
M = کارگری که اون کار رو اجرا میکنه
P = چیزی که مشخص میکنه این کارگر چطور و از کجا کار بگیره
فرض کن 4 تا گوروتین(G) داریم و دو تا M (OS Thread) و هر M به یک P متصل است: P1 → M1 P2 → M2 حالا این گوروتین ها میخواهند اجرا شوند: P1 → M1 → G1 P2 → M2 → G2 و G3 و G4 هم منتظر میمونن تا نوبتشون بشه.
پس قضیه اینطوری شد که: P خودش کار رو اجرا نمیکنه.
M همون OS Thread هست که واقعاً روی CPU اجرا میشه.
G هم کاریه که باید اجرا بشه.
پس اگر بخوای خیلی خلاصه حفظش کنی:
G = کار M = اجراکنندهٔ کار P = مدیر/هماهنگکنندهٔ اجرای کارها
حالا یادتونه در OS Scheduler ما کانتکس سوییچ داشتیم که کارشون مدیریت یک ترد رو هسته بود؟ اینجا Goroutineها نیز روی یک M Context Switch میشوند.
فرض کن: M1 → G1 و G1 یه مدت اجرا میشه، بعد مثلاً منتظر چیزی میمونه. اینجا Scheduler میگه: خب G1 فعلاً کاری نداره، بذار G2 رو اجرا کنم. قبل: M1 → G1
بعد: M1 → G2
یعنی همان M1 به جای G1، حالا G2 رو اجرا میکنه. این همون Context Switch بین Goroutineها است.
3. صفهای اجرا
قطعه مهم بعدی ما در این پازل،Run Queueها هستند. در گو اسکجولر دو نوع صف اجرا وجود دارد:
- Global Run Queue (GRQ) — صف اجرای سراسری
- Local Run Queue (LRQ) — صف اجرای محلی
1. GRQ — صف عمومی
فرض کن چندتا Goroutine جدید ساخته شده: GRQ: G1 G2 G3 G4
اینها فعلا در صف عمومی هستند و هنوز به یک P مشخص داده نشدهاند.
2. LRQ — صف محلی
هر P یک صف مخصوص خودش دارد.
مثلاً:
P1 → LRQ: G1 G2 G3
P2 → LRQ: G4 G5
یعنی P1 میگه:
«این Gها کارهای من هستن؛ M متصل به من میتونه اینا رو اجرا کنه.»
پس جریان کار تقریباً این شکلیه:

یعنی: G اول ممکنه بره داخل GRQ → بعد Scheduler اون رو به LRQ یک P منتقل میکنه → M متصل به اون P، G رو اجرا میکنه.
چرا اصلاً LRQ داریم؟
چون اگر همهٔ Goroutineها توی یک صف بزرگ باشند، همه باید مدام سراغ همون صف برن و این میتونه مدیریت کارها رو سختتر و پرهزینهتر کنه.
پس Go میگه: «هر P یه صف خودش رو داشته باشه و بیشتر کارها از همون صف برداشته بشن.»
4. Cooperating Scheduler
ما در بخش قبل به این موضوع پی بردیم که زمانبند سیستمعامل یک Preemptive Scheduler است. یعنی نمیتوان پیشبینی کرد زمانبند در هر لحظه چه تصمیمی خواهد گرفت. ببین در حالت عادی برنامه وقتی رو سیستم عامل اجرا میشه، کنترل مستقیمی نداره رو اتفاقات داخل کرنل، مگر اینکه از ابزارهایی مانند Atomic و Mutex استفاده کنند.
حالا اینجا جالب میشه که گو اسکجولر بخشی از گورانتایم هست و گورانتایم داخل خود برنامه قرار دارد. پس گو اسکجولر در فضای کاربر و بالاتر از کرنل محسوب میشود. نتیجه؟ پیادهسازی گو اسکجولر، یک زمانبند Preemptive نبود، بلکه یک Cooperating Scheduler بود.
اول فرق این دو تارو خوب بیایید بشکافیم: Preemptive Scheduler یعنی: سیستمعامل خودش هر وقت لازم بدونه، اجرای یک Thread رو متوقف میکنه و یکی دیگه رو اجرا میکنه. خود برنامه لازم نیست اجازه بده؛ سیستمعامل تصمیم میگیره.
اما Cooperating Scheduler چیه؟
در مدل Cooperating، Scheduler منتظر یکسری موقعیت مناسب در برنامه میمونه تا بتونه تصمیم بگیره. موقعیت های مناسب رو Safe Point میگیوند.
Safe Point یعنی چی؟
خیلی ساده:
Safe Point = یک نقطهٔ مناسب در اجرای برنامه که Scheduler میتونه اونجا دخالت کنه و اجرای Goroutine رو جابهجا کنه
مثلا اینجا ولیام کندی میگه یکی از این موقعیتها میتونه Function Call باشه.
حالا باید توجه داشته باشیم که گو اسکجولر قبلا بیشتر شبیه به این بود اما با توجه به اپدیت های گو و همچنین اپدیت ذهنیتهای مهندسهای نرم افزار موضوع کمی فرق کرده. الان نمیگن گو اسکجولر از Cooperative پیروی میکنه، به جاش میگن گو اسکجولر یک asynchronous preemption هست.
یعنی Go از هر دو مکانیزم استفاده میکنه:
- Cooperative → خود Goroutine در نقاط مشخصی مثل call، channel operation، blocking I/O و... فرصت اجرای Goroutineهای دیگر رو فراهم میکنه.
- Preemptive → اگر یک Goroutine مدت زیادی CPU رو در اختیار بگیره، Scheduler میتونه اجباری (بدون همکاری خود Goroutine) اون رو متوقف کنه و Goroutine دیگری رو اجرا کنه.
پس خوبه به اینم توجه کرد.
5. چهار نوع رویداد مهم برای زمانبندی
چهار موقعیت مهم وجود داره که Go Scheduler میتونه بگه: «خب، بذار یه نگاه به وضعیت Goroutineها بندازم و ببینم باید کاری بکنم یا نه.»
ولی دقت کن: این رویدادها لزوماً به معنی Context Switch نیستن. فقط اسکجولر فرصت پیدا میکنه تصمیم بگیره.
1. استفاده از go
وقتی مینویسی: go doSomething() داری یک گوروتین جدید میسازی.
مثلاً:
قبل:
M1 → G1
بعد:
M1 → G1
صف → G2
Scheduler میگه: «اوه، یه G جدید ساخته شد. ببینم اینو کی و کجا اجرا کنم.» ممکنه G2 سریع اجرا بشه، ممکنه G1 ادامه پیدا کنه، یا ممکنه G2 بره داخل صف.
2. System Call
این یکی خیلی مهمه. فرض کن: M1 → G1 و G1 میگه: «من باید یه System Call انجام بدم.» حالا اگر این System Call باعث بشه M1 قفل/Block بشه، نمیخوایم کل M1 همینطوری بیکار بمونه.
Scheduler میتونه بگه: «خب G1 فعلاً گیر کرده؛ بذار یه G دیگه رو اجرا کنم.» مثلاً: قبل: M1 → G1 بعد: G1 → Block شده M1 → G2 یعنی G1 منتظر میمونه، ولی M میتونه بره سراغ G2. در بعضی شرایط هم اگر M1 واقعاً بلاک شده باشه، Go ممکنه یک M جدید وارد ماجرا کنه تا P بدون کار نمونه، به اینجا هم خواهیم رسید.
3. Synchronization و Orchestration
این یکی رو با یه مثال خیلی ساده بگیریم.
فرض کن G1 و G2 داریم: G1 → منتظر G2 G2 → در حال انجام کار مثلاً G1 روی یک Channel منتظر اطلاعاته:
data := <-ch
اگر هنوز چیزی داخل چنل نباشه، G1 نمیتونه ادامه بده.
پس Scheduler میگه:
«G1 فعلاً کاری نمیتونه بکنه؛ بذار G2 رو اجرا کنم.»
پس: G1 → Waiting M1 → G2
وقتی G2 اطلاعات رو داخل چنل گذاشت: G2 → ch → G1
G1 دوباره میتونه اجرا بشه:
G1 → Runnable بعداً Scheduler دوباره G1 رو روی یک M اجرا میکنه. همین ایده برای چیزهایی مثل:
- Mutex`
- Atomic`
- Channe هم مطرح میشه.
و مهمترین نکته: این ۴ مورد نمیگن «حتماً Context Switch انجام بده»؛ فقط میگن اسکجولر یک فرصت پیدا میکنه وضعیت رو بررسی کنه و تصمیم بگیره.
6. System Callهای غیرهمزمان (Asynchronous)
اول مشکل رو بفهمیم. فرض کن: M1 → G1
G1 داره یک درخواست شبکه انجام میده؛ مثلاً میخواد از یک سرور جواب بگیره.
حالا سؤال:
آیا باید M1 رو هم معطل کنیم تا جواب شبکه بیاد؟
اگر این کار رو بکنیم:
M1 → G1 → منتظر جواب شبکه...⏳
M1 هم عملاً بیکار میشه.
این چیز خوبـی نیست. درواقع هرچی تا الان میگفتیم که ترد منتظر یک گوروتین نمیمونه و میره سراغ گوروتین دیگه اینجا واضح تر میشه که چرا همچین چیزی میگفتیم و درک بهتری میکنیم که دقیقا چی میشه که ترد میره سراغ گوروتین دیگه.
Network Poller میاد وسط
Go میگه: «لازم نیست M رو معطل کنیم؛ G رو بسپار به Network Poller.» پس اتفاق تقریباً این شکلی میشه:
M1 → G1 ↓ Network Request ↓ انتظار
به جای اینکه M1 منتظر بمونه: G1 ↓ Network Poller ↓ منتظر جواب شبکه
M1 ↓ G2 ↓ ادامهٔ کار یعنی G1 منتظر میمونه، ولی M1 بیکار نمیمونه.
یک مثال واقعیتر
فرض کن ۳ تا Goroutine داریم: G1 → درخواست شبکه → منتظر جواب G2 → کار CPU G3 → کار CPU اگر Network Poller نداشته باشیم، ممکنه: M1 → G1 → منتظر شبکه
ولی با Network Poller M بیکار نمیمونه، وقتی جواب شبکه رسید دوباره G1 به حالت رانیبل و در LRQ خودش منتظر اجرا شدن میرود. G1 دوباره برمیگرده توی صف تا وقتی نوبتش شد اجرا بشه.
پس Network Poller دقیقاً چه کار میکنه؟
خیلی ساده: Network Poller مسئول اینه که منتظر اتفاقات شبکه بمونه، بدون اینکه یک M رو هم معطل کنه. مثلاً G1 میگه: «من منتظرم سرور جواب بده.» Network Poller میگه: «باشه، تو برو منتظر بمون. من حواسم به شبکه هست.» بعد M1: «خب من که بیکار نیستم، برم G2 رو اجرا کنم.»
epoll و kqueue و iocp چی هستن؟
اینها مکانیزمهایی هستن که سیستمعامل برای مدیریت کارهای I/O به شکل غیرهمزمان در اختیار برنامهها میذاره. Linux → epoll macOS → kqueue Windows → iocp لازم نیست فعلاً درگیر جزئیاتشون بشیم. فقط بدونیم: Go از قابلیتهای سیستمعامل استفاده میکنه تا بفهمه چه زمانی عملیات شبکه آماده شده، بدون اینکه M رو بیخود Block کنه.
7. System Callهای همزمان (Synchronous)
اول تفاوتش با قبلی
در Asynchronous داشتیم که اگر G منتظر شبکه ماند، این انتظار روی نتورک پولر انجام بشه و M یا همون ترد خودمون آزاد بشه بره سراغ G های دیگه اما در Synchronous قضیه فرق میکنه
Synchronous یعنی چی؟
فرض کن G1 داره توسط M1 اجرا میشه. G1 میخواد یک کاری انجام بده که نمیشه به Network Poller سپرد. مثلاً یک File I/O. اینجا G1 میگه: «باید از فایل بخونم.» حالا فرض کن خواندن فایل مدتی طول بکشه.در این حالت، علاوه بر G1 M1 هم بلاک میشه. یعنی دیگه نمیتونیم مثل حالت Network I/O بگیم، G1 تو برو منتظر بمون M1 تو برو سراغ G2 و منتظر نمون. چون این System Call باعث Block شدن M1 شده.
پس Go چه کار میکنه؟
اینجا یه نکتهٔ خیلی مهم داریم. فرض کن، G1 روی M1 که توسط P1 مدیریت میشه، درحال اجراست. G1 یهو میگه من باید یک فایل بخونم و اینطور هم G هم M بلاک میشوند. اسکجولر متوجه میشه: «ای بابا، M1 گیر کرده! ولی P1 هنوز Gهای دیگهای برای اجرا داره.» پس M1 رو از P جدا میکنه،بعد یک M دیگه میاره. یعنی: اینجا M1 گیر کرده، ولی P1 رو هم متوقف نمیکنیم. و M2 رو وارد P1 میکنیم و میندازیمشون به جون G ها.
ویلیام کندی میگه: در ویندوز ها بعضی File System Callها قابلیت Async شدن دارن. بنابراین در ویندوز ، از نظر فنی میتوان در بعضی موارد از Network Poller برای File I/O هم استفاده کرد. ولی فعلاً لازم نیست وارد جزئیات Windows بشی. پس فرق Async و Sync میشه M منتظر نمیمونه و M منتظر میمونه.
8. Work Stealing
این یکی هم خیلی قشنگه فرض کن دو تا P داریم که هرکدوم M خود و صف LRQ خود که شامل چندین G است. حالا فرض کن P1 خیلی سریع کارهاشو انجام داده و دیگه G نداره که M درحال کار باشه و اینجا باز M بیکار میشه. اینجا Work Stealing وارد میشه. اینجا P1 میگه: «من بیکارم، از P2 G میدزدم» و نصف کارها رو برمیداره، حالا P1 هم G برای بکار گیری M1 داره.
چرا این کار خیلی مهمه؟
فرض کن ۸ تا P داشته باشی. اگر یکی از Pها کار نداشته باشه ولی P دیگه ۱۰۰ تا Goroutine منتظر داشته باشه، احمقانه است که یکی بیکار باشه و اون یکی زیر بار کار له بشه
Work Stealing = دزدیدن کار از صف Pهای دیگه برای اینکه منابع بیکار نمونن.
حالا GRQ هم وارد داستان میشه
فرض کنیم این وضعیت رو داریم: دوتا P که هردو خالی هستن و G ها به اتمام رسیده و هردو P بهم نگاه میکنند و میبینند که چیزی برای دزدیدن از هم وجود نداره. پس میرن سراغ GRQ و G های موجود رو سرقت میکنند.