آناتومی channelها در زبان برنامه نویسی Go


هر زمان از توسعه دهنده ها در مورد علت انتخاب زبان برنامه نویسی Go برای یادگیری یا توسعه در پروژه ها سوال می شه پاسخ هایی بعضا ساده، گاه بلند در توصیف نقاط قوت این زبان می شنویم. در تمامی این پاسخ ها همیشه دو مورد مشترک وجود داره که همه توسعه دهنده ها به اون اشاره می کنن: Concurrency و Performance. شاید ساده ترین و در عین حال کامل ترین پاسخ به این سوال یک جمله ساده باشه:

چون زبان برنامه نویسی Go سریعه.

فاکتورهای متنوعی رو میشه به عنوان علل سریع بودن زبان برنامه نویسی Go مطرح کرد که مهمترین و جذاب ترین اون قابلیت ذاتی concurrency در این زبان هستش. این قابلیت توسط سه تفنگدار concurrency در این زبان برآورده میشه. در واقع مثلث goroutine، channel و sync package نقش اصلی و در واقع ابزار دستیابی به concurrency در این زبانه.

سه رکن اساسی concurrency در زبان Go
سه رکن اساسی concurrency در زبان Go


قطعا شما، حتی اگه آشنایی کمی با این زبان داشته باشین، بر مبنای مدل پیاده سازی شده از CSP برای پشتیبانی از concurrency در این زبان، حتما این جمله رو در مورد فلسفه زبان Go شنیدید:

Do not communicate by sharing memory; Share memory by communicating.

همونطور که میدونید، برای رسیدن به این هدف channel ها پیشنهاد اول (نه لزوما بهترین) عموم توسعه دهنده های با تجربه در زبان Go هستش. در واقع channel ها دو وظیفه ساده رو برعهده دارن: برقراری ارتباط مابین goroutine ها و همچنین synchronization در دسترسی به حافظه مشترک. در این مقاله قصد آموزش و پرداختن به شیوه استفاده از channel ها رو ندارم و فرضم بر این اساسه که شما درک هرچند ساده و کم از channelها دارین، چون معتقدم هم خارج از حوصله ی خواننده ی این مقاله هستش و هم به تنهایی با یادگیری شیوه استفاده از channelها، هنر بکارگیریه concurrency به دست نمیاد. در این مقاله رویکرد متفاوتی رو دنبال می کنم و قصدم اینه که نگاه سطح پایین تری به شیوه پیاده سازی channel ها در این زبان داشته باشم و بررسی سطحی از چگونگی برخورد go runtime با channel ها داشته باشم.

یک مثال ساده

package main
import (
   &quotfmt&quot
   &quottime&quot
)
func main() {
	ch := make(chan interface{}, 5)
	go func() {
		defer close(ch)
		for i := 0; i < 10; i++ {
			ch <- i
		}
	}()
	for v := range ch {
                time.Sleep(3 * time.Second)
		fmt.Println(v)
	}
}

با مثالی ساده کار رو شروع کنیم: همونطور که مشخصه یه شبیه سازی ساده ای از یه buffered channel با سایز ۵ در نمونه کد وجود داره که در closure مرتبط با goroutine، در یک حلقه با مرتبه ۱۰، ایندکس حلقه به channel انتقال داده میشه و به صورت concurrent با range بر روی متغییر ch مقادیر در صورت در دسترس بودن و close نبودن channel در خروجی چاپ میشه. برای اینکه پردازش مقادیر متغییر v رو زمان بر جلوه بدیم۳ ثانیه در نمایش v تاخیر ایجاد کردیم.

عموما به تابعی که وظیفه ارسال داده به channel رو داره sender و به تابعی یا تکه کدی که وظیفه دریافت اطلاعات از channel رو داره receiver گفته میشه. من هم برای توضیح راحت تر از این دو اصطلاح استفاده خواهم کرد [در مورد اینکه چرا تنها یک تابع وظیفه ارسال یا دریافت رو باید بر عهده داشته باشه پیشنهاد می کنم اصل Confinement در زبان Go رو مطالعه کنین]. حالا با هم بررسی کنیم که چطور channel ها باعث میشن که یک ساختمان داده ی FIFO مابین goroutine ها اطلاعات رو به صورت safe منتقل کنه و با چه مکانیزمی با کمک go runtime می تونه goroutine ها را block و unblock می کنه.

خوب اول باید به این نکته اشاره کنیم که در واقع وقتی شما یه channel رو با دستور make میسازین در واقع یه نمونه از یه struct به اسم hchan در heap ساخته می شه و اشاره گری به اون، برگردونده میشه (در واقع علت اینکه نوع داده ای chan رفتاری مثل اشاره گر ها داره و یکی از استثناها در قانون always pass by value در زبان Go هست همین مورده که در واقع یک اشاره گری به نقطه ای از heap هستش). این struct چند فیلد مهم داره که توی بررسی دقیقتر رفتار channel کمک کننده است:

ساختار ساده سازی شده ی hchan
ساختار ساده سازی شده ی hchan

فیلد buffer اشاره گری به یه صف حلقوی (recursive queue) هست که ابتدا و انتهای اون توسط sendx و recvx مشخص میشه. هر زمان که مقدار جدیدی قراره به buffered channel اضافه بشه در حقیقت چند استپ ساده اتفاق میوفته:

اول فیلد lock که یه mutex ه، برای ورود به critical section با لاک کردن یه دسترسی انحصاری بوجود میاره. دوم مقدار مورد نظر در صورتی که فضای کافی هنوز ساختمان داده ی buffer وجود داشته باشه کپی میشه. سوم مقادیر sendx برای نمایش ایندکس send جدید بروز رسانی میشه و در نهایت مقدار qcount که در لحظه تعداد عناصر در بافر رو داره بروز رسانی میشه. با اتمام این کارها، lock در نهایت release میشه [برای اینکه ببینید mutual exclusion چطوری عمل میکنه پیشنهاد میکنم به sync در کتابخونه استاندارد Go نگاه بیاندازین. نکته دیگه اینکه در صورتی که با ساختمان داده recursive queue آشنا نیستین یه نکته مهمی رو فقط مد نظر داشته باشین که پیاده سازی اون در Go بر مبنای slice هست که در صورتی که ایندکس sendx و recvx با هم برابر باشن به این معنی هستش که صف پر شده یا اصلا المانی درون اون وجود نداره]. همین عملیات عینا به صورت معکوس برای دریافت یک مقدار از channel هم اتفاق میوفته. یعنی ابتدا lock، سپس کپی مقدار به نتیجه channel و در نهایت بروزرسانی recvx و رها سازی lock [تابع مورد نظر در sync.Mutex به ترتیب Lock و Unlock هستن].

نکته مهمی که تا حالا با این بررسی ساده متوجه میشیم اینه که از اونجایی که عملیات enqueue و dequeue از buffer با کپی کردن اطلاعات انجام میشه، در نتیجه channelها با این روش memory safety رو بوجود میارن. در حقیقت پایبندی به فلسفه زبان رو به این مکانیزم یعنی بکارگیری mutex و کپی مقدار به جاری ارسال pointer به اون داده فراهم می کنن.

حال بیاین شرایط رو کمی پیچیده تر در نظر بگیریم. فرض کنین که مثل شبیه سازی ایی که در نمونه کد بالا انجام شده، عملیات پردازش در receiver زمان بر باشه. در این حالت چه اتفاقی میوفته؟ خیلی ساده در حالی که مقادیر اول تا ششم در حال انتقال به buffered channel هستش با دریافت اولین مقدار توسط receiver، هنوز پردازش اولین مورد در receiver هنوز به پایان نرسیده، بافر channel پر شده و در نتیجه عملیات sender بلاک میشه. همونطور که میدونین channel ها عامل در اصطلاح pause / resume شدن goroutine ها هستن. ولی چطور اینکار رو انجام میدن؟

روش ساده است. همونطور که می دونین goroutine ها نگارش انتزاعی سطح بالاتری از coroutine ها هستن که در حقیقت با یه نگارش M:N به تعدادی از thread ها در سطح OS در واقع map میشن، تا عملیات context switching در سطح سیستم عامل که عملیات هزینه بریه رو تعدیل کنن. این نگاشت M:N در واقع توسط بخشی از go runtime به نام runtime scheduler انجام میشه. در واقع برای اینکه بتونه این نگاشت رو نگه داره از مکانیزم با سه تا ساختار دیگه استفاده می کنه که توی تصویر نمایش دادم:

چگونگی نگاشت M:N در scheduler
چگونگی نگاشت M:N در scheduler

اولین ساختار همون M یا OS Thread هستش که با توجه به تعداد core ها قابل مدیریت توسط runtime هستش. M عامل اجرایی goroutine ها در Goهستش. هر یک از OS Thread ها یه لیستی از goroutine های آماده به اجرا دارن که در اصطلاح بهشون runQ گفته میشه. هر زمانی که یه thread قصد اجرا داره (در حقیقت خودش تصمیم نمی گیره، این scheduler ه که بهش میگه چکار کنه) در حقیقت باید یکی از goroutine های درون runQ رو dequeue کنه و به ساختار سوم (فکر کنم به رنگ صورتی) انتقال بده و اجرا رو شروع کنه. این ساختار سوم در حقیقت goroutine یه که الان در حال اجرا شدنه. نکته مهمی که باید مجددا تاکید بشه و در اینجا هم مشهوده که context switching در این سطح به صرفه تر از انجامش در سطح سیستم عامله.

حالا برسیم به اصل سوالی که مطرح شد. اگه buffered channel پر باشه و sender بخواد المان جدیدی رو توی بافر channel قرار بده چه اتفاقی میوفته؟ بله درسته sender توسط channel بلاک میشه. حالا واقعا چه اتفاقی میوفته؟

در واقع channel وقتی میبینه که پر شده یه فراخوانی به runtime scheduler انجام میده تا نشون بده goroutine یی که الان در حال اجراش هست باید pause بشه. این فراخوانی توسط تابعی به نام gopark اتفاق میوفته. وقتی scheduler این فراخوانی رو دریافت میکنه وضعیت goroutine رو به waiting تغییر میده و ارتباطش رو به عنوان goroutine در حال اجرا با OS Thread قطع میکنه. در این زمان OS Thread از runQ اطلاعات goroutine بعدی که برای اجرا آماده هست رو dequeue میکنه و به اجرا ادامه میده (بدون وقفه). این مراحل رو توی ۵ گام در تصویر زیر نشون دادم.

مراحل pause کردن یه goroutine در runtime توسط scheduler
مراحل pause کردن یه goroutine در runtime توسط scheduler


حالا چه اتفاقی برای goroutine یی که pause شده (بهش بگیم block) می افته؟ اگر به فیلد های hchan باز دقت کنین دو تا اشاره گر به نام sendq و recvq داره که در حقیقت لیستی از ارسال کننده ها و دریافت کننده های در انتظار رو نمایش می دن. وقتی یه goroutine در انتظار ارسال المانی به channel هستش در حقیقت در لیست sendq قرار میگیره و وقتی که در انتظار دریافت المان از channel باشه در recvq. با block شدن یه goroutine یه ساختار دیگه ای به نام sudog (بخونیمش سو دو جی) ساخته میشه و در sendq قرار میگیره. ساختار sudog پیجیده نیست در وقع یه اشاره گر به goroutine و یه {}interface برای ذخیره مقداری که قراره از طریق channel انتقال داده بشه. باز هم تاکید می کنم که memory safety با کپی کردن این مقدار در sudog اتفاق میوفته. توی تصویر پایین مراحلش رو توی چهار گام نمایش دادم.

مراحل پردازش goroutineهای در حال انتظار
مراحل پردازش goroutineهای در حال انتظار

هر زمان بافر مربوط به channel توسط یه receiver خالی بشه ابتدا از sendq در حال انتظار اولین sudog برداشته میشه و با تغییر وضعیت اون goroutine به runnable در buffer قرار داده میشه. وقتی این تغییر اتفاق میوفته channel به runtime scheduler یه event ی رو با فراخوانی تابعی به عنوان goready انجام میده که باعث میشه تا مجددا اون goroutine به انتهای لیست runQ اضافه بشه.

مراحل resuming یک goroutine بلاک شده توسط scheduler
مراحل resuming یک goroutine بلاک شده توسط scheduler


به نظرم با من موافقین که مکانیزمش جالبه.

اینکه چطور در حالتی که به عنوان مثال buffer خالیه و یه receiver انتظار دریافت مقدار اطلاعات از channel رو داره پیچیده نیست (یه نکته جالب داره پیشنهاد میکنم در موردش مطالعه کنین). فقط باید توجه داشت که صف انتظار توی این حالت recvq هستش.

خوب فکر کنم نباید از حوصله خارج بشه... ممنونم که وقت گذاشتین و این مقاله رو خوندین. مشتاقم نظرتون رو در این مورد بدونم تا بتونم بهتر و بهتر این مطلب رو کامل کنم و در صورت نیاز اصلاحش کنم.