یک‌بار برای همیشه - Parallelism 2

سلام! توی این قسمت از یکبار برای همیشه ادامه‌ی قسمت قبلی در رابطه با موازی‌سازی در پایتون رو در پیش داریم. در یک‌بار برای همیشه بحثی رو مطرح می‌کنیم که خیلی در نگاه اول شاید راحت بنظر نرسه، و این‌جا سعی می‌کنیم با بیان ساده‌تر و مثال‌های خیلی خیلی ابتدایی اون مطلب رو به راحتی به مخاطب انتقال بدیم.

هدف اصلی ما توی این قسمت معرفی یکی از راه‌حل‌هایی هست که به کمک اون می‌تونیم به آقای GIL غلبه کنیم.

مقدمه

در قسمت قبل در این رابطه صحبت کردیم که مفسر پایتون thread-safe نیست در نتیجه امکان حداکثر بهره‌وری از منابع پردازشی سیستم توسط مالتی‌تردینگ برای ما میسر نیست. به این موضوع هم اشاره کردیم که این روند روی اجرای برنامه های I/O bound خیلی تاثیری منفی نداره و تاثیر اون بیشتر در برنامه‌های CPU bound هست که اجازه کار با تمام هسته‌های پرازنده‌مون از ما گرفته شده است.

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

راه حلِ Multiprocessing

راه‌‌حلی که کمک می‌کنه تا بتونیم از دستان پر توانِ GIL فرار کنیم، استفاده از پراسس به جای ترد هست.

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

در قسمت قبل گفتیم که مفسرِ پایتون در حالت single-thread به خاطر وجود GIL نسبت به زبان‌های برنامه‌نویسی دیگه خیلی کارآمد‌تر عمل می‌کنه و همین دلیل باعث میشه تا توی حالت مالتی‌پراسس هم تا حد زیادی از تمام منابع پردازشی‌ سیستم بهره ببریم.

خوشبختانه توسعه‌دهنده‌های پایتون یک ماژول تر و تمیز برای این کار نوشتند که اسم اون ماژول multiprocessing هست و این ماژول بشدت شبیه threading هست. تو آموزش قبلی یک مثالِCPU bound داشتیم که حدوداً ۶ ثانیه اجرای اون طول می‌کشید و سعی کردیم با اضافه کردن ترد بهترش کنیم اما تاثیرِ مثبتی نداشت! کد اولیه‌مون این شکلی بود:

استفاده از پراسس در پایتون

برای تعریف یک پراسس توی پایتون کافیه از کلاس Process از ماژولِ multiprocessing کمک بگیریم.

همون‌طور که می‌بینید نحوه ساخت و متدهای اون بشدت شبیه ماژول threading هست. ساخت و کنترل پراسس برای ما تنبل‌ها کار سختیه، پس بریم سراغ یک راهِ حل ساده‌تر!

استفاده از ProcessPool

پراسس‌پول، در واقع مجموعه‌ای از پراسس‌ها هستند که منتظر دریافت کار می‌مونند و جواب‌ها به ما بر می‌گردونند. توی مثال پایین ما یک پراسس‌پول ساختیم و کارمون رو به پراسس‌ها به کمک متدِ map وصل کردیم.

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

دقت کنیم وقتی یک instance از Pool می‌سازیم خودش به صورت پیش‌فرض تعداد پراسس‌ها رو به اندازه هسته‌های cpu می‌گیره (فقط به خاطر جنبه‌ی آموزشی داخل پرانتز تعداد پراسس‌ها رو مشخص کردیم).

با کمک ماژولِ multiprocessing، این زمان اجرا برنامه ۵۰‌درصد کاهش داشته و توی ۳ ثانیه اجرا شده، نشون میده که دوست خوبی می‌تونه توی این مسیر برای ما باشه.

تا این زمانی که من دارم این مطلب رو می‌نویسم، به کمک چهار روش(متد) میشه کار‌ها رو به یک ترد‌پول تقسیم کنیم. نکته دوم، این که تمام این متد‌ها به صورت پیش‌فرض باعث بلاک شدن ادامه‌ی اجرای برنامه میشن، یعنی بقیه کدهای پایین این متد‌ها اجرا نمیشه تا این‌ متد‌ها کارشون تموم بشه یا به عبارت دیگه synchronously اجرا میشن. به همین خاطر تمام این متد‌ها همشون یک نسخه‌ی non-blocking هم دارند. برای این‌کار کافیه به انتهای اسم این متد‌ها async_ اضافه بشه تا به صورت asynchronously اجرا بشن به طور مثال map_async.

نکته سوم، اکثر این متد‌ها فقط یک آرگومان برای ورودی تابع موردِ اجرا قبول می‌کنند که در حقیقت می‌تونیم یک لیست از tuple به عنوان ورودی بدیم تا داخل تابع‌ مورد نظرمون اون tuple رو unpack کنیم.

  • متد map: این متد و بقیه دو‌ستانش که پایین‌تر راجعبه‌ اونا توضیح می‌دم، هدف‌شون این هست که مجموعه‌ای از کارهای‌ ورودی که iterable هستند رو به پراسس‌های مختلف وصل کنند. این متد ترتیب رو رعایت می‌کنه، به هر طریقی که ورودی رو بهش بدید همون‌طور هم خروجی رو به شما تحویل میده. برای درک بیشتر مثال زیر را بررسی کنید.

خروجی:

  • متد imap: تنها تفاوت‌ متدِ imap با متد map اینِ که imap هر جوابی که آماده بشه رو به شما میده، منتظر تمام شدنِ کل کارها نمی‌مونه. در حقیقت میشه گفت حرف iای ابتدای map علامت iterator بودن اون هست. پس هر موقع نیاز دارید هر result ای که آماده شده سریع بهش دسترسی داشته باشید و ترتیب هم برای شما اهمیت داده imap دوست خوب شماست. مثال:

خروجی‌اش با مثال قبلی هیچ‌ تفاوتی نداره مهم memory efficient بودنش هست.

  • متد imap_unordered: کاملاً مشابه imap فقط ترتیب کار‌ها دیگه اینجا اهمیت نداره! برای کارهای مستقل از هم مناسب هست. مثال:

خروجی:

  • متد startmap: این متد فقط یک تفاوت با متد map داره، اون این هست که ورودی‌های تابع موردِ اجرا رو برای ما unpack می‌کنه در نتیجه دیگه نیازی نیست این کار رو خودمون انجام بدیم.
ماژول concurrent.futures هم یک پراسس‌پول در اختیار ما می‌ذاره اما چون رابط کاربری اون و هم‌چنین کارایی اون ساده‌تر و کندتر از ماژول multiproessing بود، ترجیح من معرفی یکی از این دو تا ماژول بود.

جمع‌بندی

به طور کلی میشه گفت مزیت اصلی این روش دور زدن GIL و به کارگیری تمام منابع پردازشی سیستم هست. اما خب حتما این روش هم معایبی داره! بزرگترین عیبی که میشه به این روش گرفت نبود یک حافظه مشترک بین پراسس‌ها هست (Shared memory). توی قسمت قبل، پراسس با چندین ترد داشتیم، که همه در یک حافظه مشترک (توی پراسس اصلی) فعالیت می‌کردند. تصویر یک پراسس با چندین ترد با حافظه مشترک:

Image is from https://www.toptal.com
Image is from https://www.toptal.com

اما وقتی وارد دنیای مالتی‌پراسس می‌شیم، فضا کاملاً متفاوت میشه، این‌جا پایتون باید برای هر پراسس یک کپی از فضای حافظه پراسس اصلی بسازه، تا این پراسس‌ جدید بتونه فعالیت مورد نظر ما رو پردازش کنه، تصویر نحوه کارِ مالتی‌پراسس:

Image is from https://www.toptal.com
Image is from https://www.toptal.com

ارتباط بین پراسس‌ها یا همون inter-process communication هم با استفاده از ماژولِ multiprocessing امکان‌پذیر هست که این توسط multiprocessing.Queue انجام میشه.

پس به طور کلی معایب این کار عبارتند از:

  • هزینه بالای ارتباط به پرداسس‌ها
  • عیب‌ دوم که در حقیقت دنباله‌ی همون عیب اول هست، مصرف زیاد حافظه اصلیِ ( راه حل‌هایی هم هست لینک).

مشکل دیگه‌ای که شاید بشه به multiprocessing گرفت، scalable نبودنش هست که ما رو مجبور می‌کنه به سمت چیزایی مثل Celery بریم. به عنوان جمع‌بندی بهترِ گفت که هر مساله‌ای راه‌حل خودش رو داره و ماژول multiprocessing توی خیلی از مواقع راه‌حل کم‌هزینه و مناسبی هست.

این بود پایان این قسمت از یک‌بار برای همیشه! اگر از این قسمت‌ خوشتون اومده نظر خودتون رو در پایین با ما به اشتراک بذارید و این قسمت رو با دوستانِ پایتونی خودتون در شبکه‌های اجتماعی به اشتراک بذارید!

از این‌جا کجا برم

مطالعه بیشتر

https://blog.sopticek.net/2017/06/03/concurrent-and-parallel-programming-in-python-part-2/

https://medium.com/apteo/multi-processing-in-python-ee0ce73a459b

https://docs.python.org/3/library/multiprocessing.html

کدهای استفاده شده

https://github.com/GreatBahram/OnceForEver/tree/master/code_snippets/parallelism/part2-multiprocessing