software engineer
الگوهای concurrency (بخش ۱): for-select
همزمانی و همروندی یک راه حل برای عدم تطابق قدرت پردازنده ها با قانون ارایه شده توسط موور (Moore Law) است. آنچه که باعث شده تا طراحان پردازنده ها به فکر استفاده از چندین هسته در پردازنده ها یا بکارگیری چندین پردازنده در کامپیوتر ها باشند. یک قانون ساده وجود دارد: همواره مسایلی مطرح می شوند که نیازمند توان محاسباتی، فراتر از قدرت کنونی پردازنده هاست که در پیاده سازی و اجرا توسط یک پردازنده، هرچند هم قدرتمند، کارآمد نیست. این مسایل سرآغازی بر cloud computing و مفهوم web scale بودن نرم افزار را با خود همراه داشت. قاعدتا اگر نرم افزاری web scale باشد، احتمال فراوان توانایی پاسخگویی به صدها هزار وظیفه به صورت همزمان را می بایست داشته باشد و این نیاز، یکی از اصلی ترین نیازهای روز نرم افزار در قرن ۲۱ است. آنچه که Amdahl's Law سعی در مدل سازی آن داشته است. عمده دلیلی که از Go به عنوان زبان نرم افزارها در قرن ۲۱ یاد می شود بکارگیری همزمانی (concurrency) در توسعه و پیاده سازی نرم افزارهای web scale است (یکی از ۱۲ فاکتور پیشنهادی برای cloud native application که توسط Heroku مطرح شده است و به عنوان best practice سعی در پیروی از آن شده است).
قاعدتا استفاده از همزمانی نیازمند پارادایم فکری متفاوتی در برنامه نویسی است که شاید در زبان های برنامه نویسی که تاکنون تجربه کرده اید، وجود نداشته است. این مورد در زبان Go نیز صادق است. در کنار استفاده از ابزارهای توسعه همروند در زبان Go، نیازمند استفاده از الگوهایی بهینه و کارآمد در پیاده سازی همروند هستیم که در مواجه شدن با مسایل با الگوی فکری یکسان از آن استفاده نماییم. آنچه که از آن به عنوان concurrency Pattern یاد می شود. حال چرا باید از آنها استفاده کنیم؟ بدون آنها امکان توسعه web scale یا cloud native application وجود ندارد؟ پاسخ ساده است. چرا وجود دارد. ولی توجه به این نکته مهم است که تفکر مبتنی بر concurrency سخت و پیچیده است. در حل مسایل به شیوه همروند همواره عینک بدبینی به بروز مسایلی مانند: race condition, memory access synchronization, atomicity, deadlock, livelock, starvation بر چشم وجود دارد. با استفاده صحیح از الگوهای همروندی سعی در جلوگیری از بروز این دسته از موارد داریم. از طرفی استفاده از الگوهای همروندی در اصطلاح idiomatic است و طیف گسترده ای از مهندسین نرم افزار در Go آن را درک می کنند. لذا استفاده از این الگوها بسیار پیشنهاد می شود.
به عنوان مثال به وسیله الگوهای همروندی سوالاتی از این دست را پاسخ می دهیم:
- چگونه یک گو روتین را cancel کنیم یا حداکثر زمانی برای انجاک آن در نظر بگیریم (cancellation pattern و context pattern)؟
- چگونه دیتای گسسته را برای انجام یک وظیفه در چندین گام آماده کنیم (generator and pipeline pattern)؟
- چگونه در pipeline امکان بهینگی در یکی از گام ها را خواهیم داشت(fan out and fan in pattern)؟
- چگونه به روشی مناسب نتیجه n (تعداد n نامشخص) channel که به صورت موازی یا همروند در حال اجرا برای دسترسی به سریعترین جواب هستند را بدست آوریم (or channel)؟
- هنگامی که میخواهیم یک فقره داده به چندین عملیات فرستاده شود، به عنوان نمونه هم دیتا پردازش شده و هم لاگ از آن تهیه شود از چه الگویی برای آن استفاده نمود (tee pattern)؟
- زمانی که ترتیب برای ارسال و دریافت اطلاعات وجود دارد از چه الگوی همروندی میتوان استفاده نمود(bridge pattern همان که در بعضی از کدها از آن به عنوان channel of channels یاد می شود)؟
- چگونه مدت زمان locking در یک وظیفه را کاهش دهیم(queuing pattern)؟
- چگونه عملکردی مانند promise در زبان Go داشته باشیم(future pattern)؟
هدف و شیوه بررسی
در مجموعه ای از مقالات در نظر دارم که در برخی از الگوهای همروندی که در پیاده سازی بیشتر کاربرد داشته (حداقل برای خود من) را مورد بررسی قرار دهم و مطالب در مورد هر یک را در حد توان به اشتراک بگذارم. برای شرح الگو دو مورد رو مد نظر قرار خواهم داد. اول، به بیان حالتی که در آن می توان از الگوی مورد نظر استفاده نمود، خواهم پرداخت (نام این مرحله را situation قرار دادم). سپس الگوی مورد نظر را با جزییات بیشتر شرح داده (نام این مرحله را pattern details قرار دادم) و در نهایت نمونه ای کامل تر از مورد استفاده آن ارایه خواهم داد. در تمام مراحل بررسی الگوها فرض بر این است که با مفهوم done channel آشنایی وجود دارد. برای اشاره ای مختصر به این نوع channel می بایست گفت که از آن به عنوان نمونه ای ساده تری از context برای کنسل نمودن روند اجرای گوروتین استفاده می شود. به این معنی که در صورتی که بخواهیم روند اجرای یک گوروتین را کنسل کنیم از این channel استفاده می کنیم. استفاده از نوع {}struct برای این نوع channelها که صرفا نشان دهنده یک event و نه نوعی خاص هستند، idiomatic است.
شرایط محتمل (situation)
حالتی را در نظر بگیرید که به صورت متناوب در انتظار دریافت داده ای از یک channel هستید تا وظیفه خاصی را انجام دهید، تا در نهایت در شرایط خاصی آن channel بسته شود، یا توسط done channel یک event جهت توقف اجرای گوروتین صادر شود. در حالتی به نسبت مشابه، شرایطی را در نظر بگیرید که متناوب قصد انجام یک وظیفه را دارید تا زمانی که دستور توقف گوروتین به وسیله done channel صادر شود. برای روشن تر شدن این مورد دو تصویر در زیر نمایش داده شده است:
در شرایطی دیگر، مجموعه ای از داده های گسسته داریم که قصد داریم برای اجرای همروند (بدون در نظر گرفتن ترتیب) یک وظیفه بر روی هر یک از داده های آن مجموعه، آنها را برای مقداردهی به channel ها و ورود به pipeline آماده نماییم [به این عملیات به عنوان بخشی از الگوی pipeline، در اصطلاح generator گفته می شود که در زمان بررسی الگوی pipeline در آینده آن را بررسی خواهیم کرد. برای مشخص تر شدن مفهوم pipeline به صورت غیررسمی، بیان این نکته خالی از اهمیت نیست که انجام یک وظیفه در چندین گام ،که در آن، گام ها توسط channel با یکدیگر مرتبط هستند، را pipeline می گویند].
منظور از داده گسسته مجموعه داده ای است که مثلا توسط ورودی variadic function ها دریافت می شود، یا به شکل slice ای از داده ها به یک تابع وارد می شود.
شرح الگو for-select
در شرایطی همانند موارد ذکر شده در بالا، از الگویی متداول با عنوان for-select استفاده می شود. این الگو در عین سادگی، بسیار پرکاربرد است. حالت ساده استفاده از این الگو به صورت زیر است:
در این حالت، در یک حلقه بی پایان در انتظار توقف اجرای عملیات گوروتین توسط done channel هستیم و در زمانی که مقداری از doneدریافت نشده باشد، وظیفه مورد نظر (بخش کامنت گذاری شده) اجرا می شود. لازم به یادآوری است که برای اینکه select منجر به block گوروتین جاری نشود از default استفاده می نماییم.
نمونه کد فوق، می تواند به صورت مشابه زیر نیز نوشته شود. یعنی بدنه وظیفه به درون default انتقال یابد:
در حالتی دیگر، که خواهان این هستیم که در صورت وجود مقدار در یک channel یک عملیات، و در صورت نبود آن، عملیات دیگری اجرا شود، می توانیم این الگو را به صورت زیر بازنویسی نماییم [این شرایط همانند if-else در حلقه بی نهایت و به صورت همروند حاصل از مقادیر در channel شبیه سازی می شود]:
در شرایطی که مجموعه ای از داده گسسته داشته باشیم شرایط کمی متفاوت خواهد بود. در این حالت قصد داریم تا داده را برای ورود به pipeline پردازش آماده نماییم (صرف نظر از نوع پردازش batch یا discrete)، لذا به جای حلقه بی نهایت، از range بر روی داده های ورودی استفاده می کنیم:
در نمونه فوق در صورتی که doneمقدار داشته باشد، گوروتین خاتمه می یابد. و در صورتی که stream در انتظار دریافت داده باشد گوروتین در select می بایست block شود و case در آن خط از برنامه در انتظار دریافت داده از slice خواهد بود.
شرایط در دنیای واقعی
الگوی for-selectهمانطور که در بالا در مورد آن بحث شد، در تئوری پیچیدگی خاصی ندارد ولی آیا در عمل اینگونه است؟ پاسخ: خیر. نکته مهم در مورد نمونه کدهای بالا در این است که اجرای repetitive task در حلقه های بی نهایت، میزان clock بالایی از CPU را به خود تخصیص خواهد داد. این موضوع منجر به این خواهد شد که به احتمال بسیار زیاد توان اجرای گوروتین دیگری را نداشته باشد. این موضوع در runtime به وسیله work stealing حل شده است ولی هدف، اجرای single task نیست.
راه حل پیشنهادی چیست؟ در ساده ترین راه حل، جهت جلوگیری از این مشکل، استفاده از مکانیزم های interval یا sleep پیش از اجرای تکه کد non-preemptive رایج است. همانند آنچه که در زیر نمایش داده شده است:
مشکل دیگری که در عمل ممکن است رخ دهد، این نکته است که select در زمانی که امکان بیش از یک case را داشته باشد، به صورت تصادفی یکی از آنها را انتخاب و اجرا می کند. به عنوان نمونه هم done و هم case دیگری امکان اجرا داشته اند. این عمل می تواند بسیار تاثیرگذار باشد. مثلا توقع ذخیره سازی n رکورد را داشته اید ولی n+1 رکورد ذخیره شده است. در این زمان راه حل چیست؟ در مواقع این چنین راه حل ساده جدا سازی case ها با قابلیت همزمانی non-blocking بالا از یکدیگر و قرار دادن آن در دو select مجزا است. به عنوان نمونه به نمونه کد زیر توجه کنید
در نمونه بالا فرض بر این بوده که احتمال دریافت داده از stream و انجام عملیات non-preemptive بسیار بالا بوده است. لذا با اولویت در دریافت از stream و جدا سازی انها احتمال آن را بسیار کاهش می دهیم (همانند اینکه critical section را محدود تر کرده باشیم).معمولا این جدا سازی ها در شرایطی مورد کاربرد دارد که case خاتمه دهنده و case حالت نرمال در اجرا احتمال امکان اجرای بالایی داشته باشند.
خاتمه
از وقتی که برای مطالعه این مقاله قرار دادید متشکرم. ممنون میشم تا نظراتتون در مورد این مقاله رو با من به اشتراک بگذارید و در بهتر شدنش من رو کمک کنید. از اصلاحات پیشنهادی و راه حل های جایگزین استقبال خواهم کرد.
مطلبی دیگر از این انتشارات
آناتومی channelها در زبان برنامه نویسی Go
مطلبی دیگر از این انتشارات
پیشتاز مانند Delve در خطایابی برنامه به زبان Go
مطلبی دیگر از این انتشارات
الگوهای concurrency (بخش ۲): cancellation و context