پیاده سازی پردازش همزمان ولی کنترل شده با Go

زبان برنامه‌نویسی Go با اینکه نسبت به زبان‌هایی مثل Java, Python, JavaScript و ... خیلی جوان‌تره، اما هنوز هیچی نشده سر و صدای زیادی به پا کرده و برنامه‌نویسان زیادی رو به خودش جذب کرده. یکی از ویژگی‌هایی که باعث شده Go انقدر محبوب بشه توانایی پیاده‌سازی برنامه نویسی همروند (Concurrent) به ساده ترین شکل ممکنه.اینکار انقدر ساده است که کافیه شما قبل از اجرای هر تابعی کلمه کلیدی go رو قرار بدید تا اون تابع به صورت همروند و داخل یک goroutine جداگانه انجام بشه. ابزار دیگه‌ای که goroutine ها رو خیلی جذاب و ساده می‌کنه بحث channel ها در گو هستش.گوفرها (gopher) به شدت مشغول کارند!خوب اینجا قصد ندارم در مورد goroutine ها و channel ها توضیح اضافه بدم و اگر با این مباحث آشنا نیستید پیشنهاد می‌کنم ابتدا با کمی جستجو اونها رو به خوبی یاد بگیرید تا بتونید از این مبحث استفاده کنید.بحث برنامه نویسی همروند در Go با اینکه بنظر خیلی ساده میاد اما ظرافت‌های خاصی داره که با دونستن‌ اونها شما می‌تونید سناریوهای خیلی جالبی رو پیاده سازی کنید و با ندونستنشون می‌تونید کدی بنویسید که نه تنها بهتر عمل نمی‌کنه بلکه از حالت ساده ترتیبی (Sequential) هم ضعیف تر باشه.در این مطلب قصد دارم توضیح بدم که چطور می‌تونیم برای کارهایی که میشه به صورت همزان انجام داد از حداکثر توان پردازشی CPU استفاده کنیم و در عین حال مراقب باشیم که بیش از حد از منابع سیستم استفاده نشه و به اصطلاح از Starvation جلوگیری کنیم.برای اینکه بتونیم راحت‌تر این مطلب رو درک کنیم، تصور کنید قراره برنامه‌ای بنویسیم که فایل‌های موجود درون یک پوشه رو با استفاده از الگوریتم MD5 هش کنیم و اونها رو درون یک فایل متنی همراه با اسم فایل ذخیره کنیم.مرحله اول: خواندن فایل‌هادر مرحله اول خیلی راحت با استفاده از کد زیر می‌تونیم محتویات یک پوشه رو بخونیم.reading files in a directoryبرای راحتی کار مسیر پوشه‌ای که قراره توش کار کنیم رو از ورودی shell میگیریم.مرحله دوم: پیاده سازی MD5 Hashingبرای راحتی کار تابعی می‌نویسیم که وظیفه hash کردن فایل‌ها رو انجام میده و به پاس دادن مسیر یک فایل هش md5ش رو بر میگردونه. تابع به صورت زیر خواه بود.md5File funcو فقط کافیه به اینصورت تابع مورد نظرمون رو فراخوانی کنیم.Calling md5Fileمرحله سوم: نوشتن روی فایلدر این مرحله ما می‌خوایم که هش های تولید شده رو داخل فایلی که مشخص می‌کنیم بنویسیم. برای اینکار تابع دیگه‌ای با نام writeToFile می‌سازیم با محتویات زیر:writeToFile funcو حالا کافیه که این تابع رو در کد اصلی فراخوانی کنیم.Calling writeToFile to write checksumsتا اینجای کار کد ما تقریبا کامله و اون کاری که قراره انجام بده رو انجام می‌ده. یعنی که اگر اجرا بشه تمام فایلهای داخل مسیر مشخص شده رو میخونه، هش می‌کنه و داخل فایلی که مشخص می‌کنیم ذخیره می‌کنه. اما وقتی داریم یه پوشه با هزاران فایل رو هش می‌کنیم قطعا مدت زمان زیادی رو منتظر خواهیم بود. هر چقدر هم که فایل‌ها سنگین تر باشن مدت زمان هش کردنشون هم بیشتر می‌شه.حالا بیاید این کد رو طوری تغییر بدیم که از تمام ظرفیت پردازنده‌های چند هسته‌ای ماشینمون استفاده کنه و کار رو به مراتب سریعتر انجام بده.شاید در نگاه اول با خودتون بگید خوب حالا همه کدها رو داخل goroutine اجرا می‌کنم و اینطوری خیلی سریعتر تموم میشه. یه چیزی شبیه به این.Let's run it all inside a goroutineپیشنهاد می‌کنم اینکار رو بکنید و خودتون نتیجه رو ببینید. قطعا مدت زمان کمتری صرف اجرای کد میشه ولی مشکلی که وجود داره اینه که خیلی زودتر از موعد به پایان میرسه. یعنی زمانی که هنوز تعدادی زیادی از فایلها هش نشدن و در فایل خروجی فقط تعداد محدودی از نتایج وجود داره.این حالت به این دلیل اتفاق میوفته که گوروتین اصلی ما منتظر پایان بقیه گوروتین‌ها نمیشه و هر زمان که خودش کارش تموم شه از برنامه خارج می‌شه.این مشکل رو میشه میشه استفاده از sync.WaitGroup برطرف کرد ولی هدف اصلی ما این نیست. مشکل دیگه‌ای که کد بالا داره اینه که اگر تعداد فایلها زیاد بشه با این حالت ما تمامی منابع سیستم رو اشغال می‌کنیم و اصطلاحا ماشین دچار Resource Starvation میشه و این یعنی که هیچ منابعی برای بقیه برنامه‌های در حال اجرای سیستم باقی نمی‌مونه.روش بهتر و درست‌تر اینه که ما همیشه به طور همزمان فقط تعداد محدودی عملیات هش داشته باشیم. مثل همیشه حداکثر ۱۰ تا فایل رو به صورت همزمان هش کنیم. برای اینکار می‌خوایم مفهموم worker رو داخل برنامه‌مون پیاده سازی کنیم. به اینصورت که همزمان تعدادی worker منتظر می‌مونن که ما براشون کار ارسال کنیم و اونها هم انجام بدن.همونطور که حدس زدید منظور از worker اینجا goroutine هایی هستن که روی چنلی که مخصوص ارسال job ها ایجاد شده، گوش وایسادن.مرحله چهارم: ساختن کارگرهابرای ساختن کارگرها ما تابعی می‌نویسیم که به تعداد مشخصی goroutine استارت میزنه و داخلشون روی pipeline که یه channel از نوع receive-only هست یه حلقه for میزنیم.spinupWorkersمرحله پنجم: Fan Out یا تقسیم کارهادر این مرحله باید کارهای رو تقسیم کنیم. به این صورت که با رسیدن به هر فایلی به جای اینکه همونجا محتویات رو باز کنیم و هش کنیم، اون رو با استفاده از pipelineی که ساختیم به یکی از worker های بیکار میرسونیم. به این کار به اصطلاح Fan Out هم گفته میشه. تابع اصلی ما تا به اینجای کار به این صورت خواهد بود.Fan Outخوب حالا که کارها رو تقسیم کردیم باید نتایج رو داخل یک فایل بنویسیم. می‌تونیم توی هر worker به صورت جداگانه نتایج رو داخل فایل خروجی بنویسیم، اما مشکلی که اینجا بوجود میاد اینه که با احتمال خیلی بالا وضعیتی پیش میاد که دو یا بیشتر worker بخوان همزمان نتایج کار خودشون رو داخل فایل بنویسن که به این حالت به اصطلاح race condition گفته میشه که شما باید نهایت تلاشتون رو بکنید که از این حالت جلوگیری بشه. و اما چاره کار...مرحله ششم: کارها رو جمع کن و به نوبت انجام بده (Fan In)توی این مرحله ما به موجودیتی احتیاج داریم که نقش یک کتابدار رو برای ما انجام بده. یعنی هر چیزی که قرار ثبت بشه فقط و فقط از طریق این شخص انجام بشه و خوب چه چیزی بهتر از یک goroutine که این کار رو برامون انجام بده که راه ارتباطی ما هم یه channel دیگه خواهد بود.Fan Inباید یادمون باشه تا worker هامون رو هم به روز کنیم که کاراشون رو تحویل کتابدارمون بدن.Updated workers now send their result to the fanIn channelتابع اصلی تا اینجا به این صورت خواهد بود.اما هنوز مشکلی که اول مطلب بهش اشاره کردیم وجود داره. یعنی هر زمان که گوروتین اصلی کارش تموم بشه بدون توجه با اینکه بقیه گوروتین‌ها هنوز در حال انجام کار هستن، برنامه به پایان می‌رسونه.رفع کردن این مشکل یکم نکته دار و tricky هست که سعی می‌کنم به بهترین نحو ممکن توضیح بدم.مرحله هفتم: صبر کن تا همه کارشون تموم شهبرای حل این مشکل ابتدا باید بفهمیم که تقدم و تأخر هر کدوم از کارهامون به چه صورت هست به این معنی که کدوم باید قبل از اون یکی خاتمه پیدا کنه. خوب طبیعیه که آخرین کاری باید انجام بشه نوشتن داخل فایل هستش. یعنی قبل از اتمام عملیات نوشتن باید همه کارگرها کارهاشون رو تموم کرده باشن و خارج شده باشن.پس کار ما ۳ مرحله خواهد داشت. اول اینکه به worker ها اطلاع بدیم که فایل‌ها تموم شدن و دیگه فایلی برای پردازش نمونده و می‌تونن خارج بشن. برای اینکار کافیه که بعد از مرحله filepath.Walk چنل مربوط به pipeline رو ببندیم. تو کد بالا close(pipeline) دقیقا همین کار رو انجام میده و خوب خاصیت حلقه for با استفاده از range اینه که هر زمان چنل بسته بشه به صورت خودکار از حلقه خارج می‌شه.مرحله ۲ اینه که یجوری چنل fanIn رو ببندیم و اینکار حتما باید بعد از اینکه تمام worker هامون خارج شدن انجام بشه. من می‌خوام اینکار با استفاده از sync.WaitGroup انجام بدم. به این صورت که هر کدوم از worker ها یه دونه به تعداد WaitGroupمون اضافه می‌کنن و هر کدوم بعد از اینکه کارشون تموم شد ازش کم می‌کنن.Workers with the WaitGroupو اما برای اینکه چنل fanIn رو ببندیم باید داخل یه گوروتین دیگه منتظر آزاد شدن WaitGroup باشیم.Wait for WaitGroup to close fanIn و اما تابع اصلی تا به اینجای کار...و در آخر هم باید گوروتین اصلی را تموم شدن کار کتابدار زنده نگه داریم. برای اینکار می‌تونیم از یک چنل کمکی استفاده کنیم. چنل writeDone که یه struct خالی قبول می‌کنه برای اینکار استفاده شده.writeDone channel to signal the main goroutineمرحله هشتم: Make it fasterاگه هنوز با من هستید بهتون تبریک می‌گم. برنامه شما کامله. تقریبا البته!منظورم اینکه اگر برنامه رو اجرا کنید قطعا به صورت همزمان شروع به هش کردن فایلها می‌کنه و پرفورمانس بالاتری نسب با حالت ساده (ترتیبی) خواهید داشت. حتی می‌تونید مطمئن باشید که از وقوع Race Condition جلوگیری کردید و همچنین قطعا برنامه تمام فایلهای شما رو هش و در فایل مورد نظر ذخیره خواهد کرد.اما ایراد کار کجاست؟!مشکل اینجاست که الان سرعت خواندن و هش کردن فایل‌ها چندین برابر شده، اما برنامه ما مجبوره برای اینکه نوشتن فایل با مشکل انجام نشه اون رو به ترتیب بنویسی و عملا برای سرعت اجرا bottleneck به وجود اومده.یکی از کارهایی که میشه اینجا انجام جمع کردن تعدادی از عملیات نوشتن و انجام اونها در یک مرحله است. به این صورت که مثلا هر ۱۰۰۰ تا فایلی که هش شد یکجا نتایجش روی فایل نوشته بشه.کافیه تابع writeToFile مون رو به صورت زیر تغییر بدیم.Batching write operationsو هنگام فراخوانی هم به صورت زیر عمل کنیم.خوب دیگه واقعا تموم شد.بد نیست کمی هم حالت‌های مختلف رو از نظر زمان اجرا با هم مقایسه کنیم.حالت اول: اجرای ترتیبیتو این حالت که همه چی به ترتیب اجرا میشه رو لپتاپ من که ۸ تا هسته داره برای هش کردن تعداد بیش از ۶۴ هزار فایل نتیجه اجرای برنامه با دستور 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 Writingreal 0m1.610s user 0m4.372s sys 0m0.969sزمان اجرا به کمتر ۲ ثانیه کاهش پیدا کرد.نکته مهم: این مورد رو نباید پشت گوش انداخت که فایلهایی که من برای تست استفاده می‌کنم بسیار کوچک هستند و قدرت همروندی زمانی مشخص میشه که فایل‌هامون حجم نسبتا بالایی داشته باشند.نتیجه این مطلب اینه که بفهمیم با استفاده درست از ابزار و تکنیک‌هایی که در دسترسمون هست چقدر می‌تونیم برنامه‌های سریعتر و بهینه‌تری بنویسیم و از سخت افزارمون نهایت استفاده رو ببریم. و مثل همیشه می‌تونید کدهای مربوط به این مطلب رو در گیتهاب من بهش دسترسی داشته باشید. https://github.com/2hamed/md5-workers پانوشت: اگر شما هم راه بهتر و بهینه‌تری رو می‌شناسید خوشحال میشم که در قسمت نظرات با من به اشتراک بذارید یا حتی بهتر ازون در موردش تو وبلاگ خودتون بنویسید.