الگوهای concurrency (بخش ۲): cancellation و context

در مقاله اول از مجموعه مقالات در ارتباط با الگوهای concurrency به یکی از ساده ترین در عین حال یکی از پرکاربردترین الگوها به نام for-select پرداختیم و بیان کردیم که چگونه از این الگو برای انتظار در انجام یا عدم انجام وظیفه ای خاص استفاده نماییم. مطرح کردیم که چگونه در واقعیت این مورد متفاوت از پیاده سازی الگوریتمیک آن است. در این مقاله قصد داریم تا به قطعه دیگری از پازل الگوهای concurrency پرداخته و این سوالات را مطرح کنیم که چگونه می بایست یک goroutine را متوقف نماییم؟ آیا امکان memory leak در عدم توقف goroutine ها وجود دارد؟ راه حل پیشنهادی در زبان Go با استفاده از standard packages چیست؟ برای درک بهتر از شیوه برخورد با این پارادایم فکری مطالعه و مرور سریع اولین مقاله پیشنهاد می شود.

شروع با طرح چند سوال

مطلب را با طرح چند سوال آغاز می کنیم. سوال اول: آیا نیاز به کنترل میزان حافظه مصرفی از جانب goroutine ها هستیم؟ سوال دوم: احتمال memory leak در اجرای goroutine ها وجود دارد؟
پاسخ به این سوالات ساده است. هرچند که میزان استفاده منابع برای اجرای goroutine ها بسیار کمتر از Thread هاست و عملیات context switching در لایه runtime در این زبان اتفاق می افتد، ولی باز هم منابعی را به خود تخصیص خواهد داد و این عمل بدون هزینه نیست. نکته حایز اهمیت در مورد goroutine ها در این است که آنها توسط runtime هیچگاه garbage collect نمی شوند. لذا همواره می بایست نگران مصرف حافظه از جانب goroutine ها باشیم (یکی از دلایل کنترل میزان حافظه از جانب goroutine ها و محدود سازی تعداد آنها الگوی concurrency با عنوان pooling است که در مورد آن در مقاله ای در آینده صحبت خواهیم کرد). در مورد سوال دوم نیز بیان این مطلب لازم است که، بله امکان memory leak وجود دارد. ممکن است یک goroutine به دلیل کد نویسی بعضا اشتباه هیچگاه از حافظه پاک نشود. سوالی که ممکن است مطرح شود که چگونه باید از حذف goroutine از حافظه اطمینان حاصل نماییم. یا به بیان ساده تر، چگونه مطمین شویم که یک goroutine خاتمه پذیر است؟ قاعدتا در هنگام خطا یا در هنگام خاتمه عملیات یک goroutine، حتما خاتمه می یابد. حال سوال اینجاست که در سایر موارد چگونه است؟ پاسخ در اصلی مهم در استفاده از goroutine هاست.

اصل مهم در Goroutine ها

پاسخ به آخرین سوال مطرح شده در بالا را با بیان اصلی مهم در کاربری goroutine ها آغاز می کنیم.

اگر goroutine ای وظیفه ساخت goroutine دیگری را داشته باشد، وظیفه مدیریت آن goroutine شامل امکان توقف آن goroutine را می بایست داشته باشد.

در ساده ترین حالت ممکن می توان این اصل را اینگونه تشریح کرد که ساخت هر goroutine همواره با استفاده از یک تابع رخ خواهد داد و این تابع عموما توسط تابعی دیگر در یک pipeline نمونه سازی و اجرا خواهد شد. اگر تابع فراخواننده را تابه والد (parent) و تابع فراخوانده شده را فرزند (child) بنامیم، نیازمند مکانیزمی هستیم که عمل مدیریت و متوقف سازی child توسط parent رخ دهد. در ساده ترین نوع پیاده سازی در زبان های برنامه نویسی مانند C و Go این روش با ارسال پارامترهای کنترلی، به صورت اشاره گر، از تابع والد به فرزند انجام می شود که نیازمند رفتار خاصی در تابع فرزند، حین وجود مقادیری خاص در این پارامترهای کنترلی است. به این مکانیزم در ساده ترین حالت ممکن signaling گفته می شود. نکته مهم در مورد سیگنال دهی این است که این سیگنال تنها جنبه اطلاع رسانی در وقوع یک رخداد را داشته و معنی دیگری ندارد.

عملیات سیگنال دهی از والد به فرزند
عملیات سیگنال دهی از والد به فرزند

حال که مفهوم سیگنال دهی را می دانیم، شیوه پیاده سازی کارآمد آن در زبان برنامه نویسی Go به منظور پیاده سازی مکانیزم توقف goroutine فرزند توسط goroutine والد چگونه خواهد بود؟
از آنجا که این عمل تنها نیازمند اطلاع رسانی از جانب والد به فرزند خواهد بود بهترین شیوه پیاده سازی پیشنهادی برای نیل به این هدف استفاده از یک channel ارتباطی با عنوان done به منظور سیگنال رسانی می باشد. از آنجا که هم اکنون نیازمند ارسال اطلاعات خاصی از طریق این channel نیستیم به راحتی می توانیم از نوع داده ای {}struct با قابلیت zero space برای پیاده سازی آن استفاده نماییم. همچنین استفاده از یک chan با صورت read only در پیاده سازی done در اصطلاح idiomatic و مرسوم است (در تصویر زیر done به صورت پارامتری read only تعریف شده است شده است). تصویر زیر نمونه ی سطح بالاتری از این مکانیزم را نمایش می دهد:

نمونه سطح بالاتری از پیاده سازی done channel
نمونه سطح بالاتری از پیاده سازی done channel

نحوه برخورد در child

حال باید به این سوال پاسخ داد که، نحوه برخورد و واکنش به سیگنال ارسالی از جانب والد به فرزند می بایست چگونه باشد؟ یا به بیان ساده تر چه عملیاتی می بایست در فرزند در برخورد با done پیاده سازی شود؟ پاسخ ساده است. بستگی دارد :). در اکثر مواقع این ارتباطات والد و فرزندی در قالب چندین گام یا در اصطلاح گرافی از فراخوانی goroutine ها و در قالب pipeline پیاده سازی می شود و این ارتباطات عموما به شکل زنجیره ای از channel ها رخ می دهد. لذا در زمانی که در انتظار دریافت مقادیر از آن channel ها هستیم از الگوی for-select می توانیم استفاده نماییم.به عنوان نمونه تکه کد زیر را مشاهده نمایید:

نمونه ای ساده از پیاده سازی for-select در تابع فرزند
نمونه ای ساده از پیاده سازی for-select در تابع فرزند

در نمونه کد بالا فرض بر این بوده است که در یک مرحله از pipeline داده از طریق val دریافت شده و پس از اعمال برخی عملیات (در بالا به صورت کامنت نمایش داده شده) داده را توسط stream به step بعدی در pipeline ارسال نمایید. در بدنه goroutine از الگوی for-select استفاده شده است که در صورتی که هریک از مقادیر val یا done از جانب goroutine والد مشخص و ارسال شده بود تکه کد مرتبط با آن اجرا گردد. نکته مهم در مورد done در این است که به سادگی تنها return برگردانده شده که این کار باعث اجرای دستور بستن stream خواهد شد. با بسته شدن stream قاعدتا این سیگنال به goroutine های پایین تر در گراف pipeline ارسال شده و آنها نیز متناسب با بسته شدن stream تصمیم مقتضی را خواهند گرفت. نکته مهم در این مورد این خواهد بود که رفتار توابع child بعدی در گام ها برای channel بسته شده و مقادیر واقعی می بایست متفاوت باشد. به خصوص در زمانی که نوع داده channel ورودی و خروجی عدد صحیح مانند int int32 int64 است. در تصویر بالا این رویکرد پیاده سازی نشده است و برخورد با channel بسته در دستیابی به مقدار val به شما واگذار می شود.

کاربرد های الگوی cancellation

از جمله مهمترین دلایل نیاز به الگوی cancellation جلوگیری از ادامه غیر ضروری یک فرآیند است. یکی از مصداق های این مورد Timeout است. به عنوان نمونه نمی خواهیم بیش از یک مقدار زمان مشخص، برای پاسخگویی به کاربر تخصیص دهیم. در این زمان این الگو بسیار کارآمد خواهد بود. این مورد در کاربرد درخواست های HTTP به وفور یافت می شود. به عنوان یک نمونه تجربه خوب همواره پیشنهاد می شود تا درخواست های HTTP در سمت server همیشه با timeout همراه باشد. این الگو تا حد بسیار زیادی در استفاده از منابع اعم از CPU و RAM تاثیرگذار است. به عنوان نمونه ای از پیاده سازی مبتدی از timeout تکه کد زیر کمک کننده خواهد بود:

نمونه ای از برخورد با done
نمونه ای از برخورد با done

نمونه کد بالا پیاده سازی درستی از روش timeout در HTTP نیست. تنها عاملی کمک کننده است برای شیوه برخورد با done در تابع والد. در تکه کد فوق، بعد از دو ثانیه در صورتی که مقداری در terminate آماده خواندن نشده باشد، done بسته خواهد شد. همین رویکرد می تواند در درخواست ای HTTP وجود داشته باشد با این تفاوت که احتمالا نتیجه یک chan از controller با done می بایست ترکیب شود، که بهترین شیوه پیاده سازی آن، استفاده از select برای دستیابی به مقادیر از chan حاصل از controller و مقداردهی به done در هنگامی که time.After مقداری در channel خروجی خود قرار داد، می باشد.

راه حل پیشنهادی استاندارد

استفاده از الگوی cancellation و استفاده از done channel راه حلی مناسب برای عملیات سیگنال دهی بین goroutine های فرزند و والد می باشد. با این وجود این الگو دارای نقاط ضعف (به بیان بهتر، دارای کمبودهایی) است. از عمده ترین نقاط ضعف استفاده از done channel این است که چگونه به غیر از سیگنال رسانی اطلاعات دیگری در اختیار goroutine فرزند و والد قرار دهیم؟ یا اینکه چگونه در هر گام از یک pipeline رفتاری منحصر به فرد متناسب با آن مرحله انجام دهیم. به عنوان مثال چگونه در یک مرحله رفتار timeout و در مرحله دیگر deadline داشته باشیم؟ از نسخه 1.7 از زبان Go به صورت استاندارد context package به مجموعه پکیج های استاندارد اضافه شد که این مکانیزم را به نحو شایسته تری پیاده سازی و اجرا می نماید. در ادامه به شرح و چگونگی استفاده از context برای اهداف خود می پردازیم.

استفاده از context package

برای استفاده از context package به جای ارسال done channel به عنوان اولین پارامتر در توابع درون pipeline از نوع داده ای به نام Context استفاده می شود. این نوع داده ای بسیار شبیه به done عمل خواهد کرد. با مشاهده Context متوجه ساختار ساده آن خواهید شد که دارای چهار تابع است. در زمانی که قصد سیگنال دهی برای خاتمه یا کنسل Context را داشته باشیم از Done استفاده می کنیم. Err در صورتی که Done و کنسل رخ داده باشد دلیل آن را مشخص خواهد کرد. Deadline زمانی که این Context کنسل خواهد شد را تعیین میکند، البته در صورتی که برای وی زمان deadline را تعیین کرده باشیم. به منظور ارسال اطلاعات جانبی و اضافی در pipeline نیز می توان از Value استفاده نمود که عملیات پاس دادن اطلاعات مابین goroutine ها را تسهیل می کند.

نوع context.Context
نوع context.Context

برای استفاده از context در یک ساختار سلسله مراتبی از توابع goroutine در pipeline نیازمند این هستیم که در ابتدا نمونه ای از Context ساخته شود. برای این منظور دو تابع برای ساخت یک Context وجود دارد که به ترتیب Background و TODO نام دارند. نکته مهم در این است که در production همواره از Background استفاده می شود و از TODO تنها زمانی که هنوز از مراحل کامل در pipeline تصویر دقیقی نداریم، یا در pipeline مراحل بالادستی هنوز تکمیل نشده اند، استفاده می شود. در هر مرحله از اجرای یک pipeline می بایست نمونه Context ساخته شده را به لایه پایین تر پاس دهیم. در صورتی که در هر مرحله خواهان تغییر در شیوه برخورد در Context هستیم می بایست از یکی از توابع زیر استفاده نموده و نمونه حاصل از خروجی آن تابع را به لایه های پایین تر ارسال نماییم.

توابع مرتبط با context manipulation
توابع مرتبط با context manipulation

شرح عملیات هر کدام ار توابع بالا بسیار ساده است. تنها دو نکته حایز اهمیت است که می بایست در مورد آن دقت شود. نکته اول اینکه همه توابع به عنوان اولین پارامتر ورودی context والد خود را دریافت می کنند (دلیل این دریافت، انتقال اطلاعات به شیوه pass by value به صورت پیش فرض در ساختارها در Go است) و در نهایت نمونه Context جدیدی به عنوان خروجی اول در اختیار قرار می دهند. نکته مهم دیگر این است که تابع cancel در خروجی توابع هنگامی که فراخوانی شود عملیات cancellation برای آن نوع تابع رخ می دهد. به عبارت دیگر done channel آن Context بسته می شود.

به عنوان نمونه جهت بیشتر روشن شدن شیوه استفاده تکه کد زیر را در نظر بگیرید:

نمونه ای از استفاده از context Timeout
نمونه ای از استفاده از context Timeout

نمونه کد فوق یک pipeline در دو گام را نمایش می دهد که تابع generator آن در بدنه main نوشته شده است و گام بعدی به شکل تابعی با نام step1 توسعه داده شده است. در کد در بدنه main یک نمونه از context به صورت timeout قرار دارد که قرار است عملیات های بیش از یک ثانیه را کنسل نماید. در step1 نیز عملیاتی توسط time.After شبیه سازی شده است که دو ثانیه زمان به خود تخصیص خواهد داد. واضح است به دلیل اینکه حداکثر زمان یک ثانیه برای اجرای این pipeline تعبیه شده است همواره ctx.Done زودتر مقدار برای خواندن خواهد داشت و دو دستور Println در خروجی استاندارد چاپ خواهد شد.استفاده از دو تابع WithDeadline و WithCancel نیز بر همین اصول استوار است.

در مورد ارسال و دریافت اطلاعات جانبی از جانب والد به فرزندان نیز استفاده از تابع WithValue بسیار ساده است. تنها نکته حایز اهمیت این خواهد بود که برای دریافت اطلاعات در فرزندان تابع Value از Context می بایست مورد استفاده قرار گیرد. نمونه ای ساده از استفاده از آن تابع در تکه کد زیر نمایش داده شده است:

نمونه ای از استفاده از WithValue
نمونه ای از استفاده از WithValue

خاتمه

امیدوارم از مطالعه این مقاله مختصر لذت برده باشید و در ذهن خود پاسخ به این سوال را داده باشید که چرا یکسری از توابع از کتابخانه های مختلف (مثلا mongo driver) دارای پارامتر ورودی و ابتدایی Context در خود هستند. منتظر شنیدن نظرات شما برای رفع نواقص و بهبود این مقاله هستم.