من رو در شبکههای اجتماعی با شناسه @2hamed پیدا کنید و در گیتهاب.
پیاده سازی پردازش همزمان ولی کنترل شده با Go
زبان برنامهنویسی Go با اینکه نسبت به زبانهایی مثل Java, Python, JavaScript و ... خیلی جوانتره، اما هنوز هیچی نشده سر و صدای زیادی به پا کرده و برنامهنویسان زیادی رو به خودش جذب کرده. یکی از ویژگیهایی که باعث شده Go انقدر محبوب بشه توانایی پیادهسازی برنامه نویسی همروند (Concurrent) به ساده ترین شکل ممکنه.
اینکار انقدر ساده است که کافیه شما قبل از اجرای هر تابعی کلمه کلیدی go رو قرار بدید تا اون تابع به صورت همروند و داخل یک goroutine جداگانه انجام بشه. ابزار دیگهای که goroutine ها رو خیلی جذاب و ساده میکنه بحث channel ها در گو هستش.
خوب اینجا قصد ندارم در مورد goroutine ها و channel ها توضیح اضافه بدم و اگر با این مباحث آشنا نیستید پیشنهاد میکنم ابتدا با کمی جستجو اونها رو به خوبی یاد بگیرید تا بتونید از این مبحث استفاده کنید.
بحث برنامه نویسی همروند در Go با اینکه بنظر خیلی ساده میاد اما ظرافتهای خاصی داره که با دونستن اونها شما میتونید سناریوهای خیلی جالبی رو پیاده سازی کنید و با ندونستنشون میتونید کدی بنویسید که نه تنها بهتر عمل نمیکنه بلکه از حالت ساده ترتیبی (Sequential) هم ضعیف تر باشه.
در این مطلب قصد دارم توضیح بدم که چطور میتونیم برای کارهایی که میشه به صورت همزان انجام داد از حداکثر توان پردازشی CPU استفاده کنیم و در عین حال مراقب باشیم که بیش از حد از منابع سیستم استفاده نشه و به اصطلاح از Starvation جلوگیری کنیم.
برای اینکه بتونیم راحتتر این مطلب رو درک کنیم، تصور کنید قراره برنامهای بنویسیم که فایلهای موجود درون یک پوشه رو با استفاده از الگوریتم MD5 هش کنیم و اونها رو درون یک فایل متنی همراه با اسم فایل ذخیره کنیم.
مرحله اول: خواندن فایلها
در مرحله اول خیلی راحت با استفاده از کد زیر میتونیم محتویات یک پوشه رو بخونیم.
برای راحتی کار مسیر پوشهای که قراره توش کار کنیم رو از ورودی shell میگیریم.
مرحله دوم: پیاده سازی MD5 Hashing
برای راحتی کار تابعی مینویسیم که وظیفه hash کردن فایلها رو انجام میده و به پاس دادن مسیر یک فایل هش md5ش رو بر میگردونه. تابع به صورت زیر خواه بود.
و فقط کافیه به اینصورت تابع مورد نظرمون رو فراخوانی کنیم.
مرحله سوم: نوشتن روی فایل
در این مرحله ما میخوایم که هش های تولید شده رو داخل فایلی که مشخص میکنیم بنویسیم. برای اینکار تابع دیگهای با نام writeToFile میسازیم با محتویات زیر:
و حالا کافیه که این تابع رو در کد اصلی فراخوانی کنیم.
تا اینجای کار کد ما تقریبا کامله و اون کاری که قراره انجام بده رو انجام میده. یعنی که اگر اجرا بشه تمام فایلهای داخل مسیر مشخص شده رو میخونه، هش میکنه و داخل فایلی که مشخص میکنیم ذخیره میکنه. اما وقتی داریم یه پوشه با هزاران فایل رو هش میکنیم قطعا مدت زمان زیادی رو منتظر خواهیم بود. هر چقدر هم که فایلها سنگین تر باشن مدت زمان هش کردنشون هم بیشتر میشه.
حالا بیاید این کد رو طوری تغییر بدیم که از تمام ظرفیت پردازندههای چند هستهای ماشینمون استفاده کنه و کار رو به مراتب سریعتر انجام بده.
شاید در نگاه اول با خودتون بگید خوب حالا همه کدها رو داخل goroutine اجرا میکنم و اینطوری خیلی سریعتر تموم میشه. یه چیزی شبیه به این.
پیشنهاد میکنم اینکار رو بکنید و خودتون نتیجه رو ببینید. قطعا مدت زمان کمتری صرف اجرای کد میشه ولی مشکلی که وجود داره اینه که خیلی زودتر از موعد به پایان میرسه. یعنی زمانی که هنوز تعدادی زیادی از فایلها هش نشدن و در فایل خروجی فقط تعداد محدودی از نتایج وجود داره.
این حالت به این دلیل اتفاق میوفته که گوروتین اصلی ما منتظر پایان بقیه گوروتینها نمیشه و هر زمان که خودش کارش تموم شه از برنامه خارج میشه.
این مشکل رو میشه میشه استفاده از sync.WaitGroup برطرف کرد ولی هدف اصلی ما این نیست. مشکل دیگهای که کد بالا داره اینه که اگر تعداد فایلها زیاد بشه با این حالت ما تمامی منابع سیستم رو اشغال میکنیم و اصطلاحا ماشین دچار Resource Starvation میشه و این یعنی که هیچ منابعی برای بقیه برنامههای در حال اجرای سیستم باقی نمیمونه.
روش بهتر و درستتر اینه که ما همیشه به طور همزمان فقط تعداد محدودی عملیات هش داشته باشیم. مثل همیشه حداکثر ۱۰ تا فایل رو به صورت همزمان هش کنیم. برای اینکار میخوایم مفهموم worker رو داخل برنامهمون پیاده سازی کنیم. به اینصورت که همزمان تعدادی worker منتظر میمونن که ما براشون کار ارسال کنیم و اونها هم انجام بدن.
همونطور که حدس زدید منظور از worker اینجا goroutine هایی هستن که روی چنلی که مخصوص ارسال job ها ایجاد شده، گوش وایسادن.
مرحله چهارم: ساختن کارگرها
برای ساختن کارگرها ما تابعی مینویسیم که به تعداد مشخصی goroutine استارت میزنه و داخلشون روی pipeline که یه channel از نوع receive-only هست یه حلقه for میزنیم.
مرحله پنجم: Fan Out یا تقسیم کارها
در این مرحله باید کارهای رو تقسیم کنیم. به این صورت که با رسیدن به هر فایلی به جای اینکه همونجا محتویات رو باز کنیم و هش کنیم، اون رو با استفاده از pipelineی که ساختیم به یکی از worker های بیکار میرسونیم. به این کار به اصطلاح Fan Out هم گفته میشه. تابع اصلی ما تا به اینجای کار به این صورت خواهد بود.
خوب حالا که کارها رو تقسیم کردیم باید نتایج رو داخل یک فایل بنویسیم. میتونیم توی هر worker به صورت جداگانه نتایج رو داخل فایل خروجی بنویسیم، اما مشکلی که اینجا بوجود میاد اینه که با احتمال خیلی بالا وضعیتی پیش میاد که دو یا بیشتر worker بخوان همزمان نتایج کار خودشون رو داخل فایل بنویسن که به این حالت به اصطلاح race condition گفته میشه که شما باید نهایت تلاشتون رو بکنید که از این حالت جلوگیری بشه. و اما چاره کار...
مرحله ششم: کارها رو جمع کن و به نوبت انجام بده (Fan In)
توی این مرحله ما به موجودیتی احتیاج داریم که نقش یک کتابدار رو برای ما انجام بده. یعنی هر چیزی که قرار ثبت بشه فقط و فقط از طریق این شخص انجام بشه و خوب چه چیزی بهتر از یک goroutine که این کار رو برامون انجام بده که راه ارتباطی ما هم یه channel دیگه خواهد بود.
باید یادمون باشه تا worker هامون رو هم به روز کنیم که کاراشون رو تحویل کتابدارمون بدن.
تابع اصلی تا اینجا به این صورت خواهد بود.
اما هنوز مشکلی که اول مطلب بهش اشاره کردیم وجود داره. یعنی هر زمان که گوروتین اصلی کارش تموم بشه بدون توجه با اینکه بقیه گوروتینها هنوز در حال انجام کار هستن، برنامه به پایان میرسونه.
رفع کردن این مشکل یکم نکته دار و tricky هست که سعی میکنم به بهترین نحو ممکن توضیح بدم.
مرحله هفتم: صبر کن تا همه کارشون تموم شه
برای حل این مشکل ابتدا باید بفهمیم که تقدم و تأخر هر کدوم از کارهامون به چه صورت هست به این معنی که کدوم باید قبل از اون یکی خاتمه پیدا کنه. خوب طبیعیه که آخرین کاری باید انجام بشه نوشتن داخل فایل هستش. یعنی قبل از اتمام عملیات نوشتن باید همه کارگرها کارهاشون رو تموم کرده باشن و خارج شده باشن.
پس کار ما ۳ مرحله خواهد داشت. اول اینکه به worker ها اطلاع بدیم که فایلها تموم شدن و دیگه فایلی برای پردازش نمونده و میتونن خارج بشن. برای اینکار کافیه که بعد از مرحله filepath.Walk چنل مربوط به pipeline رو ببندیم. تو کد بالا close(pipeline) دقیقا همین کار رو انجام میده و خوب خاصیت حلقه for با استفاده از range اینه که هر زمان چنل بسته بشه به صورت خودکار از حلقه خارج میشه.
مرحله ۲ اینه که یجوری چنل fanIn رو ببندیم و اینکار حتما باید بعد از اینکه تمام worker هامون خارج شدن انجام بشه. من میخوام اینکار با استفاده از sync.WaitGroup انجام بدم. به این صورت که هر کدوم از worker ها یه دونه به تعداد WaitGroupمون اضافه میکنن و هر کدوم بعد از اینکه کارشون تموم شد ازش کم میکنن.
و اما برای اینکه چنل fanIn رو ببندیم باید داخل یه گوروتین دیگه منتظر آزاد شدن WaitGroup باشیم.
و اما تابع اصلی تا به اینجای کار...
و در آخر هم باید گوروتین اصلی را تموم شدن کار کتابدار زنده نگه داریم. برای اینکار میتونیم از یک چنل کمکی استفاده کنیم. چنل writeDone که یه struct خالی قبول میکنه برای اینکار استفاده شده.
مرحله هشتم: Make it faster
اگه هنوز با من هستید بهتون تبریک میگم. برنامه شما کامله. تقریبا البته!
منظورم اینکه اگر برنامه رو اجرا کنید قطعا به صورت همزمان شروع به هش کردن فایلها میکنه و پرفورمانس بالاتری نسب با حالت ساده (ترتیبی) خواهید داشت. حتی میتونید مطمئن باشید که از وقوع Race Condition جلوگیری کردید و همچنین قطعا برنامه تمام فایلهای شما رو هش و در فایل مورد نظر ذخیره خواهد کرد.
اما ایراد کار کجاست؟!
مشکل اینجاست که الان سرعت خواندن و هش کردن فایلها چندین برابر شده، اما برنامه ما مجبوره برای اینکه نوشتن فایل با مشکل انجام نشه اون رو به ترتیب بنویسی و عملا برای سرعت اجرا bottleneck به وجود اومده.
یکی از کارهایی که میشه اینجا انجام جمع کردن تعدادی از عملیات نوشتن و انجام اونها در یک مرحله است. به این صورت که مثلا هر ۱۰۰۰ تا فایلی که هش شد یکجا نتایجش روی فایل نوشته بشه.
کافیه تابع writeToFile مون رو به صورت زیر تغییر بدیم.
و هنگام فراخوانی هم به صورت زیر عمل کنیم.
خوب دیگه واقعا تموم شد.
بد نیست کمی هم حالتهای مختلف رو از نظر زمان اجرا با هم مقایسه کنیم.
حالت اول: اجرای ترتیبی
تو این حالت که همه چی به ترتیب اجرا میشه رو لپتاپ من که ۸ تا هسته داره برای هش کردن تعداد بیش از ۶۴ هزار فایل نتیجه اجرای برنامه با دستور time رو ببینید.
real 9m20.475s
user 0m12.216s
sys 0m14.326s
حالت دوم: اجرای همروند بدون Batch Write
همونطور که حدس میزدیم گلوگاه اجرای برنامه ما و بدون نوشتن دستهای عملا بهبودی در عملکرد نداشتیم.
real 9m18.087s
user 0m18.455s
sys 0m23.135s
حالت سوم: اجرای ترتیبی همراه با Batch Write
جالب اینجاست با پیاده سازی تکنیک سادهای مثل Batch Writing خیلی خیلی زمان اجرامون بهبود پیدا میکنه.
real 0m4.545s
user 0m3.678s
sys 0m1.176s
از ۹ دقیقه رسیدیم به ۴ ثانیه! :)
و حالا...
حالت چهارم: اجرای همروند همراه با Batch Writing
real 0m1.610s
user 0m4.372s
sys 0m0.969s
زمان اجرا به کمتر ۲ ثانیه کاهش پیدا کرد.
نکته مهم: این مورد رو نباید پشت گوش انداخت که فایلهایی که من برای تست استفاده میکنم بسیار کوچک هستند و قدرت همروندی زمانی مشخص میشه که فایلهامون حجم نسبتا بالایی داشته باشند.
نتیجه این مطلب اینه که بفهمیم با استفاده درست از ابزار و تکنیکهایی که در دسترسمون هست چقدر میتونیم برنامههای سریعتر و بهینهتری بنویسیم و از سخت افزارمون نهایت استفاده رو ببریم.
و مثل همیشه میتونید کدهای مربوط به این مطلب رو در گیتهاب من بهش دسترسی داشته باشید.
پانوشت: اگر شما هم راه بهتر و بهینهتری رو میشناسید خوشحال میشم که در قسمت نظرات با من به اشتراک بذارید یا حتی بهتر ازون در موردش تو وبلاگ خودتون بنویسید.
مطلبی دیگر از این انتشارات
گولنگ: چرا باید گولنگ را در 1400 یاد بگیرید.
مطلبی دیگر از این انتشارات
خلاصه مختصر مفید GoLang (پارت اول)
مطلبی دیگر از این انتشارات
پیاده سازی یک سرویس قابل تست در Golang - قسمت ۱