این پُست از وبلاگ توسعه دهندگان blog.snix.ir کپی برداری شده است
خب امروز توی یکی از گروه های گولنگ دیدم در مورد عملکرد گوروتین ها و تفاوت هاشون با ترد یا نخ های سیستم عامل بحثی مطرح شده بود که تصمیم گرفتم یه بار واسه همیشه این پست رو اوکی کنم تا ابهامات در این مورد برطرف بشه.. توی این پست در وحله اول تفاوت های thread های هسته با goroutine ها رو برسی میکنیم و بعد از اون مدل زمان بند MPG رو.. خب کارمون رو از پایه شروع میکنیم قبل از اون اگه درس سیستم عامل رو پاس کرده باشید (با یه استاد درست حسابی) قائدتا این چیزایی که قراره توضیح بدیم رو بهتر متوجه میشید. البته نگران نباشید، من همه تلاشم رو میکنم که به زبون ساده این موارد رو مطرح کنم.
اوکی برگردیم به خونه اول: مفهوم ترد یا نخ یا thread چیه؟ نخ ها به زبون ساده متون اجرایی هستند که شامل اطلاعاتی میشه که یه cpu واسه اجرای یک سری از دستورالعمل ها بهشون نیاز داره.. فرض کنید دارید یه کتابی رو میخونید، اما لازمه که یه استراحتی بکنید. برای اینکه بتونید بعدا خوندن این کتاب رو ادامه بدید لازمه که شماره صفحه و خط و کلمه ای که تا اینجای کار خوندید رو یادتون باشه. پس متون اجرایی یا به اصطلاح execution context این کتاب میشه شماره صفحه، شماره خط و شماره کلمه.. اگه یه همکلاسی داشته باشید که از همین روش واسه خوندن این کتاب استفاده کنه، پس وقتی که شما دارید استراحت میکنید این همکلاسی شما میتونه بدون اینکه مشکلی برای شما پیش بیاد از کتاب استفاده کنه.. مفهوم thread ها هم دقیقا همینه.. یک cpu (وقتی میگیم یک cpu یعنی یک واحد پردازشی نه یه cpu با ۲۰ تا هسته.. منظورمون یکی از هسته هاشه) با استفاده از همین روش و اجرای یه مقدار از دستورالعمل های این thread ها در بازه زمانی خیلی کم، توهم اجرای همزمان همه task ها در یک زمان رو ایجاد میکنه، درصورتی که اینطور نیست. در یک زمان فقط یک نخ میتونه از cpu استفاده کنه یا به اصطلاح به سی پی یو dispatch بشه.. اما cpu باید یادش باشه که کار فلان thread رو تا کجا ادامه داده که واسه دفعه بعدی از همونجا شروع به پردازش کنه.. به ذخیره کردن این اطلاعات و رفتن به سراغ thread بعدی میگن تعویض متن یا context switching.. خیلی ساده هست نه؟ با این روش ساده نخ ها میتونن در مدت زمان های بسیار کوتاه به سی پی یو dispatch و پردازش بشن جوری که انگار همه با هم در حال اجرا شدن هستن (همروندی)
اما thread با پروسه چه فرقی میکنه؟ به طور خلاصه پروسه ها شامل حداقل یک و حداکثر X نخ میشن.. نخ های یک پروسه همه با هم یک فضای حافظه مجازی رو به اشتراک میزارن.. تعویض متن کوتاه تر و ساده تری نسبت به پروسه ها دارن و ارتباط دادنشون باهم ساده هست.. البته نخ ها استک اختصاصی خودشون رو هم دارن که توی اون فضای حافظه اشتراکی ایجاد میشه.. ما برای thread ها و زمان بند های هسته لینوکس یه پست دیگه اوکی کردیم که قبل از این پست پیشنهاد میشه یه نگاهی هم به اون بندازید..
زمان بند چیه؟ خب گفتیم که نخ ها در مدت زمان کوتاهی به سی پی یو dispatch و پردازش میشن.. اما بر اساس چه ترتیبی؟ کدوم نخ اولویت بیشتری داره؟ کدوم نخ باید مدت زمان بیشتری پردازش بشه؟ و ... همه این سوالات رو زمان بند پاسخ میده، به طور خلاصه کارش مدیریت این نخ ها و dispatch کردنشون به cpu هست..
خب بریم سراغ بحث اصلی: تفاوت نخ های هسته و نخ های سطح کاربر یا همون goroutine ها توی go چیه؟
نخ های سطح هسته:
نخ های سطح کاربر:
خیلی خب تا اینجا فهمیدیم که تفاوت نخ های سیستم عامل و goroutine ها چیا هستن، از این به بعد الگورتیم زمان بندی M-P-G رانتایم گولنگ رو بررسی میکنیم و خواهیم فهمید که goroutine ها چطور توسط رانتایم گولنگ مدیریت و اجرا میشن...
همونطور که از اسم این مدل زمانبندی مشخص هست شامل سه استراکت M و P و G میشه.. این سه حرف مخفف:
نکته در مورد GOMAXPROCS: تغییر این مقدار در زمان اجرا باعث میشه که روند اجرای برنامه به صورت همروند متوقف بشه.. یه اصطلاحی روش گذاشتن: دنیا واسه یه لحظه stop/start میشه.. (شروع روند gc هم برای یک لحظه همین کارو میکنه)
// GOMAXPROCS sets the maximum number of CPUs that can be executing // simultaneously and returns the previous setting. It defaults to // the value of runtime.NumCPU. If n < 1, it does not change the current setting. // This call will go away when the scheduler improves. func GOMAXPROCS(n int) int { if GOARCH == "wasm" && n > 1 { n = 1 // WebAssembly has no threads yet, so only one CPU is possible. } lock(&sched.lock) ret := int(gomaxprocs) unlock(&sched.lock) if n <= 0 || n == ret { return ret } stopTheWorldGC("GOMAXPROCS") // newprocs will be processed by startTheWorld newprocs = int32(n) startTheWorldGC() return ret }
تصویر زیر نمای دیداری کلی از زمان بند رانتایم هست.. اگه دقت کنید متوجه میشید ک شامل دو نوع صف global run queue و local run queue میشه..
در این صف ها که از نوع fifo هستن شامل گوروتین هایی میشن که در حالت runnable هستن.. قبل از اینکه ادامه بدیم.. حالت های گوروتین ها از نظر اجرا:
اما بلاک شدن یک گوروتین میتونه دلایل مختلفی داشته باشه.. لیست زیر یک سری از دلایلی هست که ممکنه گوروتین بخاطرش بلاک و روند اجراش متوقف شده باشه
زمان بند توی تایم اسلایس هایی در حدود هر 10 میلی ثانیه برای جلوگیری از به وجود اومدن گرسنگی گوروتین در حال اجرا رو به صف runnable منتقل میکنه.. سوال اصلی اینه که چرا باید اینکارو انجام بدیم؟ خب یه مثال ساده.. فرضا یه گوروتین دارید که به این صورت تعریف شده..
func main() { go infLoop() } func infLoop() { for {} }
توی مثال بالا گوروتین infLoop واسه هیچی بلاک نمیشه.. پس به همین دلیل همیشه به M یا همون thread سیستم عامل attach میمونه و باعث گرسنگی میشه.. به همین دلیل متوقف کردنش بعد از یک مدت ضروریه، از اونجایی که گوروتین ها context switch کم هزینه ای دارن این مدت زمان 10 میلی ثانیه مشکل ساز نمیشه
مثال بالا رو میتونید به صورت زیر زمان بند فرندلی کنید.. به زبون دیگه میتونید به زمان بند بگید که هوای بقیه گوروتین ها رو هم داشته باش.. البته همونطور که گفتم زمان بند بعد از 10 میلی ثانیه بیخیال این گوروتین میشه و از حالت در حال اجرا به runnable تبدیلش میکنه..
func main() { go infLoop() } func infLoop() { for { runtime.Gosched() } }
خب تقریبا یکم با زمان بند رانتایم اشنا شدیم.. بیاید یکم جزئیات رو برسی کنیم.. همونطور که گفته شد زمان بند شامل دو صف گلوبال و لوکال میشه که هر P صف لوکال خودشو داره، این صف از نوع ارایه ای به طول 256 هست، پس طول صف لوکال 256 المنت میشه.. این فیلد که با نام runq تعریف شده رو میتونید اینجا ببینید.. البته ساختار P فقط شامل این صف نیست. درواقع علاوه بر این صف یک فیلد دیگه هم تعریف شده به اسم runnext که فقط یک گوروتین توش قرار میگیره.. و گوروتینی که توی این فیلد قرار میگیره همیشه گوروتین بعدی برای اجرا (attach کردن به M) هست.. (این فیلد میتونه nil هم باشه) .. اما سوالی که پیش میاد اینه که چرا ما به این فیلد نیاز داریم؟ برای جواب دادن باید یه مثالیو مطرح کنیم..
var ch = make(chan string) func a() { go b() <-ch } func b() { ch <- doIO() }
توی این مثال گوروتین a گوروتین b رو میسازه و اجرا میکنه، بعد خودش بلاک میشه برای سیگنال روی چنل که باید از b بیاد.. حالا b هم خودش یکار io باندیو انجام میده .. بدون runnext قائدتا b باید بره تو ته صف گلوبال یا ته صف لوکال همین P .. خب اگه این اتفاق بیوفته شاهد افت پرفورمنس هستیم .. چرا؟ چون a که بلاک هست و b ته صف .. a فقط تو بلاکی مونده به خاطر اینکه b نرسیده اول صف هنوز، پس این b بهتره که اول از همه اجرا شه که حداقل بیخود a توی حالت بلاک نباشه.. البته اینجا شاید بگین خب این باعث گرسنگی میشه.. چون اگه دوتا گوروتین همدیگه رو به اصطلاح spawn کنن پس همیشه توی این فیلد میمونن و بقیه گوروتین ها اجرا نمیشن.. خب قانون 10 میلی ثانیه رو یادتونه؟ دقیقا اینجا هم صدق میکنه و b فقط برای 10 میلی ثانیه اجرا میشه و بعدش دیگه میره تو صف گلوبال یا لوکال و فیلد runnext مقدارش nil میشه.. وقتی این فیلد nil باشه پروسسور یا همون P گوروتینی رو از صف گلوبال یا لوکال اجرا میکنه.. پس اینجا هم با اینکه preemption صورت میگیره باز هم گرسنگی پیش نمیاد..
از طرفی صف گلوبال چون صفی هست که همه پروسسور ها بهش سر میزنن پس باید واسه دسترسی بهش یک قفلی هم اوکی کنیم.. منظور از قفل mutex هست، احتمالا با mutex اشنا باشید و ازش استفاده کرده باشید، دلیل وجود این قفل اینه که چون صف گلوبال منبع عمومی هست ممکنه دو پروسسور یا P به صورت هم زمان یک گوروتین رو روی دو thread یا همون M اجرا کن پس با این mutex مطمعن میشیم که این مشکل به وجود نمیاد..
اما گوروتین ها بر چه اساسی توی این صف ها قرار میگیرن؟ مثلا من اگه یه گروروتین اوکی کنم تو کدوم صف میره؟
و سوال بعدی اینه که P ها چطور گوروتین ها رو از توی این صف ها انتخاب و اجرا میکنند؟ واسه جواب به این سوال لازمه یه قسمت از سورس رانتایم رو برسی کنیم، کد زیر قسمتی از فانکشن ()schedule هست
if gp == nil { // Check the global runnable queue once in a while to ensure fairness. // Otherwise two goroutines can completely occupy the local runqueue // by constantly respawning each other. if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 { lock(&sched.lock) gp = globrunqget(_g_.m.p.ptr(), 1) unlock(&sched.lock) } } if gp == nil { gp, inheritTime = runqget(_g_.m.p.ptr()) // We can see gp != nil here even if the M is spinning, // if checkTimers added a local goroutine via goready. } if gp == nil { gp, inheritTime = findrunnable() // blocks until work is available } // This thread is going to run a goroutine and is not spinning anymore, // so if it was marked as spinning we need to reset it now and potentially // start a new spinning M. if _g_.m.spinning { resetspinning() }
قبل از اینکه کد رو برسی کنیم باید بدونید که برای زمان بند یک کنتور یا counter تعریف شده به اسم schedtick که هر باری که زمان بندی انجام بشه این یکی اضافه میشه..
خب به شرط اول دقت کنید.. منظور از gp همون گوروتین هست.. شرط اول میگه اگه گوروتین نداشتی وارد بلاک شو .. وقتی وارد بلاک شرط اول بشیم میبینیم که یک شرط دیگه هست.. این شرط میگه اگه counter زمان بند باقی ماندش به 61 صفر و طول صف گلوبال بیشتر از 0 بود بیا گوروتینی که میخوای اجرا کنی رو از صف گلوبال بردار .. همونطوری که میبینید عمل lock و unlock کردن mutex هم انجام میشه.. توی شرط دوم باز میاد gp رو چک میکنه که مبادا nil باشه.. اگه اینطور بود پس میاد یه گوروتین از صف لوکال همون P برمیداره.. در نهایت توی شرط سوم اگه بازم چیزی واسه اجرا نداشت، استراتژی work stealing و spinning در پیش گرفته میشه.. اینا رو در ادامه برسی میکنیم قبلش بیاید به صورت کلی این شروطی که بررسی کردیم رو مرور کنیم:
از مورد پنجم به بعد M این P یا همون نخ سیستم عاملی که زیر دست این P هست به حالت spinning thread در میاد.. این بحث رو بعد از بررسی استراتژی hands off بیشتر توضیح میدیم
توی مورد سوم گفتیم دزدی اتفاق میفته، اما P ها چطور گوروتین های همدیگه رو کش میرن؟ خیلی ساده به صورت رندوم یه P رو انتخاب و نصف گوروتین های صف لوکالش رو برای خودشون انتقال میدن
خب قبل از hand off و spinning و netpoller لازمه system call یا syscall ها رو بررسی کنیم.. system call ها چین؟ سیسکال ها درواقع درخواست انجام کار یا سرویسی هست که توسط user-space برای هسته سیستم عامل ارسال میشه.. خوندن فایل، بازکردن کانکشن، خاموش کردن سیستم و .. غیره همه با استفاده از syscall ها انجام میشن.. قبلا گفتیم که یکی از دلایلی که باعث بلاک شدن یک گوروتین میشه همین زدن سیستم کال هست.. اما یکم داستان پیچیده تر هست.. وقتی سیستم کال یک گوروتین توسط M یا نخ سیستم عامل اجرا بشه علاوه بر گوروتین، این M هم توی هسته بلاک میشه تا وقتی که syscall کامل بشه (مثلا فایل از رو دیسک سخت افزاری پیدا و باز بشه) خب از اونجایی که برنامه ما توی user-space ران میشه نمیتونیم بفهمیم الان این M یی که گیر انداختیم تو کرنل تا کی کارش تموم میشه، اوکی خب تا الان شاید با خودتون گفته باشید که پس تکلیف این P و گوروتین هایی که باید با این M اجرا بشن چی میشه؟ اینجاست که استراتژی hands off در پیش گرفته میشه
استراتژی hands off: قبل از اینکه M بخواد syscall یی رو اجرا کنه، باید M جایگزین دیگه ای پیدا کنه که جاشو پُر کنه.. تو این حالت P به اصطلاح assignment میشه به این M جدید و گوروتین ها میتونن اجرا شن.. الان M اصلی میتونه با خیال راحت syscall رو اجرا کنه.. بعد از کامل شدن syscall گوروتینی که براش بلاک شده بود به حالت runnable میره، اما تکلیف این M اصلی که syscall رو کامل کرده چی میشه؟ رانتایم به اصطلاح این M رو به حالت spinning یا park در میاره. از اونجایی که درخواست thread از سیستم عامل و از بین بردنش پرهزینه و زمان بره کار عاقلانه هم همین هست..
پس وقتی syscall زده میشه نخ یا M جدید از کجا پیداش میشه؟
نخ های پارک شده چی هستن؟ نخ هایی هستن که توسط رانتایم از بین برده میشن
چه زمانی thread ها در حالت spinning هستن؟
و netpoller چیه؟ به صورت خلاصه یه نوع چک کننده نوتیفیکیشن یا event checker هست.. سرور شما میتونه به دو حالت کانکشن رو accept کنه، blocking mode یا non-blocking mod .. توی حالت blocking نخ یا thread در انتظار اینکه file descriptor کانکشن مورد نظر اماده بشه بلاک میمونه.. تو این حالت برای هر کانکشن یک نخ لازمه (وب سرور اپاچی توی مود fork)
در حالت non-blocking که در لینوکس با epoll (سیسکال epoll_create) و در bsd ها با kqueue شناخته میشه، شما لازم نیست تا زمانی که file descriptor اماده بشه براش بلاک بمونید.. خود کرنل وقتی فلان file descriptor یا fd اماده خوندن شد بهتون خبر میده.. شما کافیه epoll_wait رو توی یک حلقه بینهایت ران کنید.. این epoll_wait وقتی event یی روی fd خاصی صورت بگیره خروجی میده بهتون .. و شما میتونید روی اون fd یا fd ها مثلا read انجام بدین .. همچنین epoll_ctl هم قابل استفاده هست ..
توی گولنگ ما یک netpoller داریم که درواقع همین حلقه for هست که توش epoll_wait تعریف شده.. وقتی netpoller نوتیفیکیشنی از هسته دریافت میکنه که میگه میتونی عملیات io روی فلان fd انجام بدی، به اطلاعاتی که از قبل داره روجوع میکنه که ببینه ایا گوروتین هایی برای این fd بلاک شدن یا نه .. و بهشون اطلاع میده .. لازم به ذکره که netpoller کلا thread خودشو داره..
به نظرتون میتونیم زمان بند رانتایم رو debug کنیم؟ یکم بفهمیم چی به چیه.. با ست کردن scheddetail=1,schedtrace=1000 برای env var یا همون متغیر GODEBUG میتونید اینکارو انجام بدید..
تستش کنیم ببینیم چطور میشه.. یه برنامه hello-world اوکی میکنیم
package main import "fmt" func main() { fmt.Println("hello world") }
و در نهایت کامپایل و اجراش میکنیم..
# go build main.go # GODEBUG=scheddetail=1,schedtrace=1000 ./main SCHED 0ms: gomaxprocs=8 idleprocs=5 threads=5 spinningthreads=1 idlethreads=0 runqueue=0 gcwaiting=0 nmidlelocked=0 stopwait=0 sysmonwait=0 P0: status=1 schedtick=0 syscalltick=0 m=4 runqsize=1 gfreecnt=0 timerslen=0 P1: status=1 schedtick=0 syscalltick=0 m=2 runqsize=0 gfreecnt=0 timerslen=0 P2: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0 timerslen=0 P3: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0 timerslen=0 P4: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0 timerslen=0 P5: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0 timerslen=0 P6: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0 timerslen=0 P7: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0 timerslen=0 M4: p=0 curg=-1 mallocing=0 throwing=0 preemptoff= locks=1 dying=0 spinning=false blocked=false lockedg=-1 M2: p=1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=3 dying=0 spinning=false blocked=false lockedg=-1 M1: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=2 dying=0 spinning=false blocked=false lockedg=-1 M0: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=1 dying=0 spinning=false blocked=false lockedg=1 G1: status=1(chan receive) m=-1 lockedm=0 G2: status=1() m=-1 lockedm=-1 G3: status=1() m=-1 lockedm=-1 G4: status=4(GC scavenge wait) m=-1 lockedm=-1 hello world
لاین اول یک سری اطلاعات مهمی بهمون میده که به صورت خلاصه بررسیشون میکنیم
به status گوروتین ها یا G ها توجه کنید.. این اعداد که از صفر تا 9 هستن مشخص کننده حالت گوروتین هستن.
خیلی خب میشه گفت به صورت کلی تقریبا با زمان بند رانتایم گولنگ اشنا شدیم، البته خیلی از جزئیات مطرح نشدن چون در حدود این پُست نیستن و باید بیشتر به صورت تخصصی بهشون پرداخته بشه.. در نهایت امیدوارم از این پست لذت برده باشید.. کامنت و نظر رو فراموش نکنید.
این پُست از وبلاگ توسعه دهندگان blog.snix.ir کپی برداری شده است