زبان Go چگونه I/O در شبکه را مدیریت می‎کند؟

در این مطلب قصد دارم توضیح بدم که زبان Go چطور ورودی و خروجی (I/O) شبکه را مدیریت می‎کنه. بنابراین ممکنه این نوشتار برای کسانی که به دنبال کد مثال برای استفاده از زبان Go برای نوشتن یک سرور HTTP هستند مناسب نباشه! (برای این‌کار می‌تونید عبارت golang web frameworks را در گوگل سرچ کنید)

قبل از شروع مبحث اصلی لازمه که بدونیم Scheduler در زبان Go چطور کار می‎کنه.

نحوه کار Scheduler

در طراحی زبان Go تصمیم گرفته شده که به‌جای استفاده از Scheduler سیستم‌عامل برای مدیریت threadها یک Scheduler در User space برای Runtime نوشته بشه تا overhead کمتری داشته باشه، Garbage Collector را بتونه Optimize کنه و این قابلیت را برای کاربر ایجاد کنه که بتونه تعداد زیادی Goroutine در برنامه خودش داشته باشه. در مورد تفاوت‌های Goroutine و Thread نیاز هست که مطلب جداگانه‌ای نوشته بشه که خارج از مبحث این مطلب است.

عموما برای Threading از یکی از سه مدل زیر در زبان‎های برنامه‌نویسی استفاده میشه:

  • مدل N:1 که در آن N تا userspace thread روی یک thread سیستم‌عامل اجرا میشن. مزیت اصلی این مدل اینه که در آن context switch خیلی سریع اتفاق میفته اما امکان استفاده از مزیت‌های سیستم‌های چندهسته‌ای در این مدل وجود نداره.
  • مدل 1:1 که در آن هر userspace thread معادل یک thread سیستم‌عامل هستش که بر خلاف مدل قبل قابلیت استفاده از مزایای سیستم‌های چندهسته‌ای را داره اما context switch در آن به کندی انجام میشه. در زبان‌هایی مثل C و Rust از این مدل استفاده میشه.
  • مدل M:N که در زبان Go استفاده میشه می‌تونه M تا userspace thread را روی N تا thread سیستم‌عامل اجرا کنه. بنابراین مشابه مدل اول context switch سریع انجام میشه و مثل مدل دوم امکان استفاده از مزایای سیستم‌های چندهسته‌ای وجود داره. محدودیت اصلی این مدل پیچیدگی پیاده‌سازی این مدل در Scheduler هستش.

برای پیاده‌سازی Scheduler در زبان Go از سه entity استفاده میشه که در شکل۱ نشان داده شده:

شکل۱. سه entity اصلی در Scheduler زبان Go
شکل۱. سه entity اصلی در Scheduler زبان Go
  • مثلث M که بیانگر یک thread سیستم‌عامل هستش (M از ابتدای Machine گرفته شده).
  • دایره G که یک Goroutine را در کد Runtime نشان میده و شامل stack، instruction pointer و سایر اطلاعات مهم برای schedule کردن Goroutineها میشه.
  • مربع P که یک context برای scheduling هستش (P از ابتدای Processor گرفته شده). برای سادگی میشه این entity را بعنوان یک نسخه محلی (localized) از scheduler در نظر گرفت که کد Go را در یک thread اجرا می‌کنه. در واقع این همان قسمتیه که به کمکش می‌شه از یک مدل N:1 به یک مدل M:N رفت.
شکل۲. نحوه ارتباط entityهای scheduler
شکل۲. نحوه ارتباط entityهای scheduler

برای مثال در شکل۲ نحوه ارتباط entityهای scheduler نشان داده شده که در آن دو تا thread وجود داره که هر کدام یک Context داره و یک Goroutine را اجرا می‌کنه. نکته قابل توجه اینه که هر thraed برای اجرای Goroutineها حتما باید یک Context داشته باشه.

تعداد Contextها با توجه به مقدار GOMAXPROCS تعیین میشه که با تابع GOMAXPROCS در پکیج runtime قابل تغییر هستش.عموما مقدار این متغیر در وسط اجرای یک برنامه تغییر نمی‌کنه.

در شکل۲ دایره‌های طوسی رنگ (سه دایره‌ای که به سمت راست هر P متصل هستند) Goroutineهایی هستند که در حال اجرا نیستن اما آماده Schedule شدن هستن. این Goroutineها در یک لیست به نام runqueue قرار می‌گیرن. Goroutineها بعد از اجرای دستور go به انتهای این لیست (صف) اضافه میشن. وقتی‌که یک Context یک Goroutine را تا رسیدن به یک نقطه scheduling اجرا کنه، یک Goroutine را از runqueue جدا می‌کنه (pop)، سپس stack و instruction pointer را تنظیم می‌کنه و Goroutine را شروع به اجرا می‌کنه.

در نسخه‌های قبل از Go 1.1 به‌جای استفاده از یک runqueue برای هر context، فقط از یک لیست کلی برای همه threadها استفاده شده بود که برای محافظت از آن لیست واحد از یک mutex استفاده میشد. بنابراین threadها باید منتظر می‌موندن تا قفل mutex باز بشه (unlock) و بتونن به لیست دسترسی داشته باشن که برای یک سیستم با تعداد هسته بالا مشکل ایجاد می‌کرد. اما در نسخه‌های بعدی با اضافه شدن context و لیست محلی برای آن نیازی به استفاده از Mutex نیست.

شاید الان این سوال پیش بیاد که چرا اصلا به Context برای اجرای Goroutineها نیاز هستش! آیا نمیشد runqueue را مستقیم داخل thread تعریف کرد؟ در حقیقت خیر. دلیل وجود Context این هست که وقتی یک thread در حال اجرا بدلیلی نیاز داشت block بشه (برای مثال وقتی یک syscall صدا زده میشه)، آن Context را به یک thread دیگه بشه منتقل کرد تا سایر Goroutineها بتونن اجرا بشن.

توجه کنید که یک thread نمی‌تونه همزمان هم برای دریافت پاسخ یک syscall به حالت block در بیاد و هم کار دیگه‌ای را در این زمان انجام بده. در این حالت با انتقال Context به یک thread دیگه امکان ادامه اجرا فراهم میشه (شکل۳).

شکل۳. سمت چپ: یک syscall اتفاق میفته و thread نیاز داره که برای دریافت پاسخ block بشه. سمت راست: Context به یک thread جدید منتقل میشه و thread قبلی block میشه.
شکل۳. سمت چپ: یک syscall اتفاق میفته و thread نیاز داره که برای دریافت پاسخ block بشه. سمت راست: Context به یک thread جدید منتقل میشه و thread قبلی block میشه.

همانطور که در شکل۳ مشخصه، یک thread قبل از block شدن، Context خودش را آزاد می‌کنه تا یک thread دیگه بتونه آن Context را در اختیار بگیره و بقیه Goroutineها را اجرا کنه. یکی از کارهای مهم Scheduler اینه که تعداد کافی thread برای اجرای همه Contextها فراهم کنه. بنابراین در شکل۳، M1 ساخته میشه تا بتونه Context مربوط به M0 را در اختیار بگیره (گاهی بجای ساختن یک thread جدید یک thread از thread cache توسط scheduler انتخاب میشه). در ادامه M0 تا وقتی‌که پاسخ syscall آماده بشه G0 را در اختیار خودش نگه میداره. وقتی که جواب syscall آماده شد، M0 برای ادامه کار و اجرای کد باید یک Context را از threadهای دیگه (که مثلا نیاز به block شدن دارن) در اختیار بگیره. اما اگر M0 نتونه یک Context برای خودش پیدا (steal) کنه، در این صورت G0 را به یک Global runqueue که بین همه threadها مشترکه منتقل می‌کنه و خودش به thread cache اضافه میشه. وقتی که runqueue هر Context خالی شد و همچنین در فواصل زمانی مشخص (برای جلوگیری از starvation صف)، Global runqueue توسط Contextها بررسی میشه تا اگر Goroutineای داخل آن بود از آن صف خارج و توسط Context اجرا بشه.

وقتی که یک Context همه Goroutineهای خودش را اجرا کنه و runqueue خودش را خالی کنه و همچنین هیچ Goroutineای داخل Global runqueue نباشه، سعی می‌کنه تا نصف Goroutineهای یک Context دیگه را تحت اختیار بگیره (Steal work). با این کار Scheduler مطمئن میشه که همه threadها با ماکزیمم ظرفیت خودشون در حال اجرای کد هستن (شکل۴).

شکل۴. سمت چپ: یک Context کاری برای انجام نداره. سمت راست: نصف کارهای Context دیگه را در اختیار میگیره.
شکل۴. سمت چپ: یک Context کاری برای انجام نداره. سمت راست: نصف کارهای Context دیگه را در اختیار میگیره.

حالا که با نحوه کار Scheduler در زبان Go آشنا شدیم، می‌تونیم به مبحث اصلی این مطلب برگردیم. در بخش بعدی نحوه کار netpoller را توضیح میدم.

نحوه کار netpoller

در زبان Go همه عملیات‌های I/O بصورت blocking انجام میشن. اکوسیستم زبان Go با این ایده ساخته شده که برنامه‌نویس کد خودش را بصورت blocking می‌نویسه و سپس Concurrency را بوسیله Goroutineها و Channelها فراهم می‌کنه (به‌جای استفاده از Callback و Future).

بعنوان مثال پکیج net/http را در نظر بگیرین؛ هر وقت که یک Connection جدید توسط سرور HTTP این پکیج پذیرفته میشه، یک Goroutine برای مدیریت همه Requestهایی که روی این Connection اتفاق میفته، ساخته میشه. حالا فرض کنید که قرار باشه این Requestها توسط Blocking I/O در سیستم‌عامل مدیریت بشن، چه اتفاقی میفته؟ طبق مطلبی که در بخش قبل دیدیم، باید یک thread برای دریافت پاسخ block بشه که نیاز هست یه thread جدید توسط Scheduler ساخته بشه و Context مربوط به Thread بلاک شده به آن thread جدید منتقل بشه. اما اگر تعداد زیادی Connection داشته باشیم که روی هر کدام تعداد زیادی Request اتفاق بیفته، عملا سیستم مورد استفاده برای Scheduling از کار میفته! تعداد زیادی thread بلاک خواهیم داشت که توانایی در اختیار گرفتن Context و اجرای کد را ندارن.

در زبان Go برای رفع این مشکل از Asynchronous I/O سیستم‌عامل استفاده میشه (در لینوکس از epoll، در BSD و OSX از kqueue و در ویندوز از IoCompletionPort). به کمک این API، برنامه می‌تونه وضعیت شبکه را با بازدهی بالا poll کنه. توجه کنید که Goroutineای که قراره Request را مدیریت کنه به حالت Block درمیاد اما thread بلاک نمیشه و بقیه Goroutineها توسط Context اجرا میشن (زیاد شدن تعداد Goroutineها overhead زیادی برای سیستم نداره).

در زبان Go بخشی که Asynchronous I/O را به Blocking I/O تبدیل می‌کنه (فراموش نکنید که در زبان Go،عملیات I/O سمت برنامه‌نویس بصورت blocking انجام میشه)، netpoller نام داره. برای netpoller یک thread مجزا ساخته میشه و eventها را از Goroutineهایی که قصد I/O روی شبکه دارن، دریافت می‌کنه. سپس netpoller از Asynchronous I/O سیستم‌عامل برای polling شبکه بصورت non-blocking استفاده می‌کنه (درحالیکه Goroutine درخواست‌دهنده I/O برای دریافت پاسخ از netpoller بلاک شده).

وقتی‌که یک Connection در زبان Go ساخته (active connect) یا پذیرفته (passive connect) میشه، File Descriptor پشت این Connection به حالت non-blocking تنظیم میشه. این بدین معنی هستش که اگر روی این File descriptor یک عملیات I/O انجام بشه و آماده (ready) نباشه، به‌جای block شدن خطای WouldBlock برگردانده میشه. حالا وقتی‌که یک Goroutine بخواد از روی یک Connection بخونه یا روی آن بنویسه، کد شبکه تا وقتی‌که با این خطا مواجه نشده به کار خودش ادامه میده، بعد از مواجهه با این خطا یک event به netpoller ارسال می‌کنه و از آن می‌خواد تا وقتی‌که دوباره امکان I/O فراهم شد، به Goroutine خبر بده تا از حالت Block خارج بشه. زمانی که یک Goroutine بلاک بشه، از thread خارج میشه تا یک Goroutine دیگه به‌جای آن بتونه روی همان thread اجرا بشه. زمانی‌که netpoller یک اعلان از سیستم‌عامل دریافت می‌کنه که امکان I/O روی یک File Descriptor فراهم شده، به Goroutineای که منتظر این اتفاق بوده (و block شده) خبر میده تا بتونه عملیات I/O را ادامه بده.

مطالعه بیشتر

اگر دوست دارید اطلاعات بیشتری در این‌باره کسب کنید، این مطلب ترجمه مختصری بود از دو مقاله زیر:

https://morsmachine.dk/go-scheduler

https://morsmachine.dk/netpoller

در لینک زیر می‌تونید جزییات تغییری را که در ساختار Scheduler در نسخه Go 1.1 پیشنهاد شد، مطالعه کنید:

https://docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw