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

زبان برنامه‌نویسی Go با اینکه نسبت به زبان‌هایی مثل Java, Python, JavaScript و ... خیلی جوان‌تره، اما هنوز هیچی نشده سر و صدای زیادی به پا کرده و برنامه‌نویسان زیادی رو به خودش جذب کرده. یکی از ویژگی‌هایی که باعث شده Go انقدر محبوب بشه توانایی پیاده‌سازی برنامه نویسی همروند (Concurrent) به ساده ترین شکل ممکنه.

اینکار انقدر ساده است که کافیه شما قبل از اجرای هر تابعی کلمه کلیدی go رو قرار بدید تا اون تابع به صورت همروند و داخل یک goroutine جداگانه انجام بشه. ابزار دیگه‌ای که goroutine ها رو خیلی جذاب و ساده می‌کنه بحث channel ها در گو هستش.

گوفرها (gopher) به شدت مشغول کارند!
گوفرها (gopher) به شدت مشغول کارند!

خوب اینجا قصد ندارم در مورد goroutine ها و channel ها توضیح اضافه بدم و اگر با این مباحث آشنا نیستید پیشنهاد می‌کنم ابتدا با کمی جستجو اونها رو به خوبی یاد بگیرید تا بتونید از این مبحث استفاده کنید.

بحث برنامه نویسی همروند در Go با اینکه بنظر خیلی ساده میاد اما ظرافت‌های خاصی داره که با دونستن‌ اونها شما می‌تونید سناریوهای خیلی جالبی رو پیاده سازی کنید و با ندونستنشون می‌تونید کدی بنویسید که نه تنها بهتر عمل نمی‌کنه بلکه از حالت ساده ترتیبی (Sequential) هم ضعیف تر باشه.

در این مطلب قصد دارم توضیح بدم که چطور می‌تونیم برای کارهایی که میشه به صورت همزان انجام داد از حداکثر توان پردازشی CPU استفاده کنیم و در عین حال مراقب باشیم که بیش از حد از منابع سیستم استفاده نشه و به اصطلاح از Starvation جلوگیری کنیم.

برای اینکه بتونیم راحت‌تر این مطلب رو درک کنیم، تصور کنید قراره برنامه‌ای بنویسیم که فایل‌های موجود درون یک پوشه رو با استفاده از الگوریتم MD5 هش کنیم و اونها رو درون یک فایل متنی همراه با اسم فایل ذخیره کنیم.

مرحله اول: خواندن فایل‌ها

در مرحله اول خیلی راحت با استفاده از کد زیر می‌تونیم محتویات یک پوشه رو بخونیم.

reading files in a directory
reading files in a directory

برای راحتی کار مسیر پوشه‌ای که قراره توش کار کنیم رو از ورودی shell میگیریم.


مرحله دوم: پیاده سازی MD5 Hashing

برای راحتی کار تابعی می‌نویسیم که وظیفه hash کردن فایل‌ها رو انجام میده و به پاس دادن مسیر یک فایل هش md5ش رو بر میگردونه. تابع به صورت زیر خواه بود.

md5File func
md5File func

و فقط کافیه به اینصورت تابع مورد نظرمون رو فراخوانی کنیم.

Calling md5File
Calling md5File


مرحله سوم: نوشتن روی فایل

در این مرحله ما می‌خوایم که هش های تولید شده رو داخل فایلی که مشخص می‌کنیم بنویسیم. برای اینکار تابع دیگه‌ای با نام writeToFile می‌سازیم با محتویات زیر:

writeToFile func
writeToFile func

و حالا کافیه که این تابع رو در کد اصلی فراخوانی کنیم.

Calling writeToFile to write checksums
Calling writeToFile to write checksums

تا اینجای کار کد ما تقریبا کامله و اون کاری که قراره انجام بده رو انجام می‌ده. یعنی که اگر اجرا بشه تمام فایلهای داخل مسیر مشخص شده رو میخونه، هش می‌کنه و داخل فایلی که مشخص می‌کنیم ذخیره می‌کنه. اما وقتی داریم یه پوشه با هزاران فایل رو هش می‌کنیم قطعا مدت زمان زیادی رو منتظر خواهیم بود. هر چقدر هم که فایل‌ها سنگین تر باشن مدت زمان هش کردنشون هم بیشتر می‌شه.

حالا بیاید این کد رو طوری تغییر بدیم که از تمام ظرفیت پردازنده‌های چند هسته‌ای ماشینمون استفاده کنه و کار رو به مراتب سریعتر انجام بده.

شاید در نگاه اول با خودتون بگید خوب حالا همه کدها رو داخل goroutine اجرا می‌کنم و اینطوری خیلی سریعتر تموم میشه. یه چیزی شبیه به این.

Let's run it all inside a 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
spinupWorkers

مرحله پنجم: Fan Out یا تقسیم کارها

در این مرحله باید کارهای رو تقسیم کنیم. به این صورت که با رسیدن به هر فایلی به جای اینکه همونجا محتویات رو باز کنیم و هش کنیم، اون رو با استفاده از pipelineی که ساختیم به یکی از worker های بیکار میرسونیم. به این کار به اصطلاح Fan Out هم گفته میشه. تابع اصلی ما تا به اینجای کار به این صورت خواهد بود.

Fan Out
Fan Out

خوب حالا که کارها رو تقسیم کردیم باید نتایج رو داخل یک فایل بنویسیم. می‌تونیم توی هر worker به صورت جداگانه نتایج رو داخل فایل خروجی بنویسیم، اما مشکلی که اینجا بوجود میاد اینه که با احتمال خیلی بالا وضعیتی پیش میاد که دو یا بیشتر worker بخوان همزمان نتایج کار خودشون رو داخل فایل بنویسن که به این حالت به اصطلاح race condition گفته میشه که شما باید نهایت تلاشتون رو بکنید که از این حالت جلوگیری بشه. و اما چاره کار...

مرحله ششم: کارها رو جمع کن و به نوبت انجام بده (Fan In)

توی این مرحله ما به موجودیتی احتیاج داریم که نقش یک کتابدار رو برای ما انجام بده. یعنی هر چیزی که قرار ثبت بشه فقط و فقط از طریق این شخص انجام بشه و خوب چه چیزی بهتر از یک goroutine که این کار رو برامون انجام بده که راه ارتباطی ما هم یه channel دیگه خواهد بود.

Fan In
Fan In

باید یادمون باشه تا worker هامون رو هم به روز کنیم که کاراشون رو تحویل کتابدارمون بدن.

Updated workers now send their result to the fanIn channel
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
Workers with the WaitGroup

و اما برای اینکه چنل fanIn رو ببندیم باید داخل یه گوروتین دیگه منتظر آزاد شدن WaitGroup باشیم.

Wait for WaitGroup to close fanIn
Wait for WaitGroup to close fanIn

و اما تابع اصلی تا به اینجای کار...

و در آخر هم باید گوروتین اصلی را تموم شدن کار کتابدار زنده نگه داریم. برای اینکار می‌تونیم از یک چنل کمکی استفاده کنیم. چنل writeDone که یه struct خالی قبول می‌کنه برای اینکار استفاده شده.

writeDone channel to signal the main goroutine
writeDone channel to signal the main goroutine

مرحله هشتم: Make it faster

اگه هنوز با من هستید بهتون تبریک می‌گم. برنامه شما کامله. تقریبا البته!

منظورم اینکه اگر برنامه رو اجرا کنید قطعا به صورت همزمان شروع به هش کردن فایلها می‌کنه و پرفورمانس بالاتری نسب با حالت ساده (ترتیبی) خواهید داشت. حتی می‌تونید مطمئن باشید که از وقوع Race Condition جلوگیری کردید و همچنین قطعا برنامه تمام فایلهای شما رو هش و در فایل مورد نظر ذخیره خواهد کرد.

اما ایراد کار کجاست؟!

مشکل اینجاست که الان سرعت خواندن و هش کردن فایل‌ها چندین برابر شده، اما برنامه ما مجبوره برای اینکه نوشتن فایل با مشکل انجام نشه اون رو به ترتیب بنویسی و عملا برای سرعت اجرا bottleneck به وجود اومده.

یکی از کارهایی که میشه اینجا انجام جمع کردن تعدادی از عملیات نوشتن و انجام اونها در یک مرحله است. به این صورت که مثلا هر ۱۰۰۰ تا فایلی که هش شد یکجا نتایجش روی فایل نوشته بشه.

کافیه تابع writeToFile مون رو به صورت زیر تغییر بدیم.

Batching write operations
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 Writing

real    0m1.610s
user    0m4.372s
sys     0m0.969s

زمان اجرا به کمتر ۲ ثانیه کاهش پیدا کرد.

نکته مهم: این مورد رو نباید پشت گوش انداخت که فایلهایی که من برای تست استفاده می‌کنم بسیار کوچک هستند و قدرت همروندی زمانی مشخص میشه که فایل‌هامون حجم نسبتا بالایی داشته باشند.

نتیجه این مطلب اینه که بفهمیم با استفاده درست از ابزار و تکنیک‌هایی که در دسترسمون هست چقدر می‌تونیم برنامه‌های سریعتر و بهینه‌تری بنویسیم و از سخت افزارمون نهایت استفاده رو ببریم.

و مثل همیشه می‌تونید کدهای مربوط به این مطلب رو در گیتهاب من بهش دسترسی داشته باشید.

https://github.com/2hamed/md5-workers


پانوشت: اگر شما هم راه بهتر و بهینه‌تری رو می‌شناسید خوشحال میشم که در قسمت نظرات با من به اشتراک بذارید یا حتی بهتر ازون در موردش تو وبلاگ خودتون بنویسید.