سلام به همه دوستانِ پایتونی! توی این قسمت از یکبار برای همیشه سراغِ مبحثِ پیچیدهتری به اسم Parallelism رفتیم. در یکبار برای همیشه بحثی رو مطرح میکنیم که خیلی در نگاه اول شاید راحت بنظر نرسه، و اینجا سعی میکنیم با بیان سادهتر و مثالهای خیلی خیلی ابتدایی اون مطلب رو به راحتی به مخاطب انتقال بدیم.
هدف اصلی ما، معرفی چالشهای موجود در پایتون در بخش Parallelism و همچنین معرفی راهحلهایی برای غلبه بر اون چالشهاست. اما خب مثل همیشه رسیدن به اون نقطه پیشنیازش تعریف خیلی دیگه از چیزها هست. هدف این آموزش استفاده از ترد یا پراسس نیست و فقط یک معرفی ساده و مختصر در رابطه با اونها داریم. در این آموزش نه اینقدر عمیق میشیم که فکر کنیم کلاس درسِ دانشگاهاس نه اینقدر سطحی میریم جلو که نفهمیم چی به چی هست!
همهی ما میدونیم که پایتون یک زبان مفسری (Interpreted language) هست، یعنی وقتی برنامه داره اجرا میشه شبیهِ این هست که یک آدم کد رو از بالا میخونه به هر خط کد که میرسه اون خط رو اجرا میکنه . اما اجرای برنامهها به صورت موازی یا Parallelism یکم تفاوت ایجاد میکنه. اول از همه بریم با تعریف این مفهوم آشنا بشیم:
این مفهوم به ما این فرصت رو میده که یک کار بزرگ رو به چند بخش تقسیم کنیم و بتونیم با این تکنیک سرعت اجرای برنامهمون رو بالا ببریم. نکتهای که باید مدنظر باشه این هست که ما باید یککار داشته باشیم که قابلیت اجرا به صورت موازی رو داشته باشه و ثانیاً منابعای رو هم که برای اجرا نیاز داره رو هم در اختیار داشته باشیم و اون موقع میشه گفت که ما داریم کارها به صورت همزمان(موازی) اجرا میکنیم.
مثل همیشه تصویر این کارممکن درک موضوع رو راحتتر کنه، حالت اول یک کار داریم و کار مورد نظر توسط چیزی (ترد یا پراسس) در حالِ اجرا شدن هست:
حالت دوم همون کار رو که قابلیت موازی سازی داشته و همینطور با کمک منابع مورد نیازش به صورت موازی اجرا میشه:
اصولاً ابزارهایی از جنس سیستمعامل که به ما در موازی اجرا کردن برنامهها کمک میکنند پراسسها و تردها هستند.
به هر برنامهای که در حال اجرا باشه توی دنیایی کامپیوتر Process گفته میشه. هر Process یک سری اطلاعات راجعبه خودش نگه میداره که چیزی شبیه شناسامهی اون هست به این بخش (Process Control Block) یا PCB گفته میشه و هر Process یک شناسهی یکتا داره که به اون PID گفته میشه که اون رو داخل همین شناسامه با اطلاعات دیگه نگه داری میکنه.
هر Thread هم یک شماره داره که سیستم عامل با اون شمارهها اینارو کنترل میکنه که بهشTID یا (Thread IDentifier) گفته میشه. هر Thread میدونه فرزند کدوم پروسس بوده این کار رو توسط یه اشاره گر به پدرش به خاطر میسپاره که اصطلاحا بهش (Parent Process Pointer) گفته میشه.
مهمترین نکته: هر Process میتونه چندین Thread داشته باشه، که همهی این تردها میتونن به منابعی که پروسس دسترسی داره دسترسی داشته باشن و یک حافظه مشترک داشته باشند. مهمترین مزیتی که باعث میشه ما از تردها خوشمون بیاد این حافظه مشترکشون هست که دسترسی به این حافظه مشترک خیلی هزینه کمی داره و بدترین چیزیم که تردها دارن همین حافظه مشترک هست چون مشترک هست ممکنِ توی مدیریتاش به مشکل بخوریم و خرابکاری زیادی به بار بیاد که به این وضعیت race condition گفته میشه.
پس میشه به نوعی گفت که پراسسها به نظر شبیه یک محیطی برای نگهداری تردها هستند (Container).
از طرفی میشه برنامههای که توسط کامپیوترها اجرا میشن رو به دو دسته تقسیمبندی کرد:
برنامهای CPU bound هست که اگر ما پردازندهی قویتری داشته باشیم اون برنامه سریعتر اجرا بشه، به عبارت دیگه برنامهی ما بیشتر وقتاش رو با پردازنده درگیر هست. از طرفِ دیگه برنامهای I/O bound هست که اگر ما منابع I/O مثلِ هارد دیسک، کارتهای شبکه قویتر و سریعتری داشتیم اون برنامه خیلی سریعتر اجرا میشد. پس فقط کافیِ نگاه کنیم با اضافه کردن کدوم یکی از منابع سرعت اجرای برنامه بیشتر میشه.
وقتی کامپیوترها تازه اختراع شده بودند، هر CPU فقط امکان اجرای یک پروسس رو داشت. یعنی شما اگه داشتید با Word کار میکردید دیگه نمیتونستید alt-tab بزنید برید روی پروسس دیگه. در نتیجه تنها راهی که داشتید بستنِ برنامه اول و باز کردن برنامه دوم بود. این عیب باعث شد چیزی به اسم context switching به سیستم عامل اضافه بشه. این دوست عزیزمون این اجازه رو به ما میده که بتونیم وضعیت فعلی پروسسرو یه جایی ذخیره کنیم و سوئیچ کنیم روی یک برنامهی دیگه و اگه خواستیم برگردیم ادامه کارمون رو بدیم، سیستم عامل وضعیت اون برنامه رو میخوند و ادامهی داستان. این سوئیچها وقتی رخ میده که یک وقفه رخ بده (معروف به Interrupt). نکتهی اصلیتر اینِ که سیستم عامل اینقدر سریع اینکارو انجام میده که کاربر فکر میکنه در هر لحظه تمام پروسسها در حال اجرا هستند. درنتیجه به یک مفهوم زیبایی به اسم multitasking داریم میرسیم.
داستان به اینجا ختم نشد؛ با گذشت زمان بازم با مشکل مواجهه شدیم اما اینبار در ابعاد کوچکتر. فرض کنید قصد داریم یک بخش از داکیومنتی رو چاپ کنیم تنها راهحل باز کردن یک پروسس دیگه بود که خیلی هزینهبر هست، این مساله باعث ورودِ مفهوم ترد به دنیای کامپیوتر شد. پس میشه گفت که هر ترد یک پروسس خیلی خیلی سبک شدهاست. ساخت و استفاده از تردها از نظر هزینه محاسباتی خیلی خیلی به صرفهتر از پروسس به صورت پیش فرض هست.
نکته: یادمون باشه، روی یک پردازنده تکهستهای هم میتونیم multitasking داشته باشیم فقط کافی هست یک کسی باشه که این سوئیچها رو مدیریت کنه!
اکثر برنامههای امروزی معمولا از چند تا ترد استفاده میکنند. مثلا برنامهی gedit اگر باز کنید، میبینید که حداقل از سه تردِ فعال داره:
تا الان یادگرفتیم سیر تاریخی دنیای کامپیوتر چی بوده، که خیلی خلاصهاش این بوه که ما همیشه به دنبال بهرهبردن بیشتر از منابعمون بودیم و این کار روشهای مختلفی رو طلبیده تا به ترد رسیدیم. و همینطور گفتیم که تردها در حقیقت بخشی از پراسس محسوب میشن و کمک میکنن که خیلی بهتر از منابعمون استفاده کنیم. اما بنظرم دیگه داستان و تاریخ کافیه بریم سراغ اینکه چطور میتونیم با تردها توی پایتون کار کنیم.
یکی از پادکستهای خیلی خوب در رابطه با پایتون، پادکستِ Talk Python to me هست که واقعاً بنظرم خیلی باحال هم هست. خب چیمیشه شمایی که مثلاً الان باهاش آشنا شدی دوست داشته باشید تمام قسمتهای اون رو دانلود کنید؟ این برنامه اگر با یک ترد اجرا بشه بهتره یا چندتا؟ آیا طبق افسانهها استفاده از ترد در پایتون هیچ سودی نداره؟
خب توی این بخش یک برنامهی خیلی ساده نوشتیم که اول از همه لینک همه قسمتها رو از وب سایت پادکست بدست میاره و بعد هر لینک رو جداگانه باز میکنه تا آدرس فایل صوتی پادکست از اون آدرس استخراج کنه و نهایتاً همه لینک همهی قسمتهای پادکست رو به ما برمیگردونه. برای سادهسازی ما دیگه از بخش دانلودش میگذریم ( به کد برنامه از اینجا میتونید دسترسی داشته باشید). برنامه به این صورت هست:
همونطور که میبینید دو تا تابع اصلی داریم:
مشخصات کامپیوتری که در این قسمت استفاده شده:
اجرای این برنامه به صورت عادی (یعنی با یک پراسس و یک ترد اصلی) تقریباً 120 تا 160 ثانیه طول زمان میبره تا اجرا بشه.
اما حالا که با مفهوم ترد آشنا هستیم، بریم اولین قدم رو برداریم تا سرعت برنامهمون رو بهبود بدیم. اول از همه برای دوستانی که آشنا نیستند، برای کار با ترد در پایتون یک ماژول سطح بالا داریم به اسم threading که امکاناتِ خیلی خوبی به ما میده، یکی از ابتداییترینِ این امکانات ساخت سادهی ترد هست، مثال (لینک):
اما بریم توی برنامهی خودمون با استفاده از ترد سعی کنیم کاری بکنیم که برنامه سریعتر از قبل اجرا بشه ( برای اینکه کدها توی یک عکس جا بشن، بخشهای که هیچ تغییری نکردن رو من دیگه اینجا نشون ندادم! لینک) :
گفتیم اولین قدم توی موازی سازی، اینِ هست که برنامهی ما قابلیت اجرا به صورت موازی رو داشته باشه (تا حدی مستقل از هم باشند). برای اینکه برنامه ما موازی اجرا بشه تعداد تردها رو یک عدد خاص در نظر گرفتیم که توی متغییرِ num_of_threads ذخیره میشه.
در قدم دوم باید کارها ( لینکها) رو بین اونا تقسیم کنیم. توی این زمانی که من دارم این مقاله آموزشی رو مینویسم ۱۸۵ قسمت از این پادکست منتشر شده، در نتیجه نیاز داریم تا این لینکها رو بین تردها تقسیم کنیم، که این تقسیم کردن توسط حلقه forای که میبینیم انجام میشه.
در نهایت این لینکها به تابع جدیدِ get_bunch_audio_links داده شدند و هر ترد مسئول اجرای این تابع است. بنظر شما آیا اضافه کردن ترد تاثیری توی کاهش زمان اجرای برنامه داره؟
توی جدول پایین من تعداد تردها و میزان زمانی که صرف شده رو گذاشتم(لینک):
با شروع کار تقریبا زمان رو به نصف زمان قبلی کاهش دادیم(هر بار برنامه برای اطمینان بیشتر دوبار اجرا شده است). در ادامه وقتی دیدیم که تردها چقدر خوب دارن کار میکنن سعی کردیم تعداد رو هی بیشتر کنیم تا زمان اجرا در مقابلش کمتر بشه. اما این کار تا یک جایی ادامه داشته و از یک نقطهای به بعد دیگه میشه گفت هیچ تاثیرِ مثبتی! نداشته و ما رو به قانون امدال رسونده.
مهمترین نکته این هست که تشخیص بدیم برنامهی ما بیشتر با پردازنده سروکار داره یا یا منابع I/O؟ مثال بالا دیتایی رو از وب دریافت میکرد و لینکی مورد نظر رو از اون دیتا بدست میآورد. طبق تعریفی که در بخش اول داشتیم پس این کار قطعاً IO bound هست. ممکنِ تا اینجای کار بعضی از خواننده با خودشون فکر کنند که چرا برخلاف باوری که میگن پایتون برای مالتیتردینگ خوب نیست نتایجِ بدست اومده چیزی دیگهای میگن؟؟ دلیل اصلی اینِ که ما یک کار IO bound رو داشتیم اجرا میکردیم. (خب بازم این که نشد جواب؟) هنوز چرای ما پابرجاست.
نکتهی دوم که شاید خیلی بهش دقت نشه، اضافه کردن بیش از اندازه تردها بود و چون ما خیلی یک برنامهی خاص منظوره برای نشون دادن منظورمون رو داشتیم متوجهی بدیهای این کار نشدیم. تعداد زیاد ترد مساوی هست با مصرف زیاد حافظه و همچنین زیاد کردن context switching.
با کمک دستور time منابع مصرف شده رو وقتی که با ۵ تا ترد برنامهمون رو اجرا کردیم:
در مقابل وقتی که ۱۰۰تا ترد استفاده شده:
قبل از اینکه بریم سراغ مباحث پیچیدهتر به یک نکتهای در رابطه با تردها توی پایتون اشاره کنم. معمولا انتظار نمیره شما به صورت مستقیم برای کارهای روزمرهتون از threading استفاده کنید. چرا؟ فرض کنید چهارتا ترد نیاز دارید. اولاً، شما باید چهار ترد بسازید. ثانیاً، کار مورد نظر رو باید بهشون بدید و تردها رو اجرا کنید. نهایتاً، بررسی کنید که کامل اجرا شدند یا خیر؟ راه حل بهتر هم وجود داره!
مفهوم به اسم ThreadPool؟ تردپول که در حقیقت مجموعهای از تردها است که همیشه منتظر ورودی برای پردازش هستند و کار کردن با اونها به مراتب تمیزتر و سادهتر از روشی هست که بالا انجام دادیم.
این چیز خیلی باحال توی ماژول concurrent قرار داده، کد جدیدمون این میشه (لینک):
بنظر من که حسابی خوشگلتر شده ;-) دیگه نه نیاز به یک تابع اضافی مثل get_bunch_audio_links بوده و نه کارها (لینکها) رو تقسیم کردیم.
وقتی شما یک برنامه پایتون رو اجرا میکنید، یک پراسس پایتون اجرا میشه که روی یک هسته از پردازنده اجرا میشه. این پراسس به صورت پیشفرض یک ترد داره و همینطور گفتیم که امکان ساخت تردهای بیشتر هم وجود داره. اما اینکار چه مزیتی برای ما داره؟ چه تفاوتی بین یک کامپیوتر با پردازنده تک هستهای و وقتی که چندین هسته داره وجود داره؟ در کامپیوتر تک هستهای وقتی چندین ترد بسازیم معادل این هست که به کامپیوتر بگیم بین تردهای مختلف سوئیچ کن. اما وقتی یک پردازندهی چند هستهای داریم، اون موقع چی؟ آیا بازم هدفمون این هست که تردهای جدید روی همون هستهای اجرا بشن که ترد اصلی هست یا هدف این هست که از هستههای دیگه هم استفاده کنیم؟
بدلیل نحوهی پیادهسازی پایتون، شما نمیتونید دو یا چندتا ترد از یک پراسس رو به صورت همزمان روی چند هسته از پردازندهتون در حال اجرا داشته باشید! چرا؟ چون هر پراسس از پایتون یک منبع خاصی رو میسازه که هر ترد برای اجرا به اون منبع خاص احتیاج داره. اما اون منبع (به دلیل اینکه) فقط یه دونهاس در نتیجه ما چند ترد از یک پراسس رو نمیتونیم به صورت همزمان در حال اجرا داشته باشیم. اسم اون منبع GIL (Global Interpreter Lock) هست. یک اصطلاحی معرفی هست به اسم Thread-safe و به پیادهسازی گفته میشه که موقع اجرای چندین تردِ همزمان باعث جلوی ناسازگاری یا race condition رو به طریقی بگیره. برای رسیدن به یک وضعیت Thread-safe توی پایتون ما نیاز به استفاده از مفهوم لاک Lock رو داشتیم که اسم این لاک رو GIL گذاشتیم. یا به عبارت دیگه چون روشِ memory management سیپایتون thread-safe نیست برای رسیدن به این وضعیت از Lock استفاده شده .
روشی که برای مدیریت حافظه در پایتون استفاد شده reference counting اسمش هست. که به این معنی هست که به ازای هر آبجکت داخل پایتون، یک reference count داریم که تعداد ارجاعها به این آبجکت رو نگه میداره. هر موقع هم این مقدار صفر بشه، یعنی بخشی از حافظه که توسط این آبجکت اشغال شده آزاد شده.
مثال:
مشکل از اینجا شروع میشه که اگر چند ترد همزمان به این reference countها دسترسی داشته باشند، این مقدارها افزایش یا کاهش غیرانتظاری، به خاطر ذات موازیسازی، پیدا میکنند. راه حل این بوده که یا از چندین Lock برای هر آبجکت استفاده کنیم یا اینکه یک Lock سراسری داشته باشیمو با انتخاب یک Lock سراسری هر بایتکد که بخواد اجرا بشه باید بتونه این Lockرو در دست بگیره.
برنامه ساده پایین رو وقتی به صورت معمولی( یک ترد اصلی) اجرا میکنیم(لینک):
چیزی حدود ۶ ثانیه زمان میبره تا اجرا بشه. برای سریعتر شدنش میریم که یک ترد دیگه به برنامهمون اضافه کنیم و کار رو بین این دوتا ترد تقسیم میکنیم(لینک):
برخلاف انتظار ما اینبار این برنامه ۸ ثانیه زمان میبره تا اجرا بشه. یعنی استفاده از ترد نه تنها کمکی به ما نکرده بلکه سرباری هم به برنامه اضافه کرده! دلیلاش هم واضح باید باشه، چون گیل اینجا جلوی اجرای تردها به صورت موازی رو از ما گرفته.
توی کارهای I/O bound هم گیل بین تردها در حال جابجایی هست اما چون تردها بیشتر زمانشون رو منتظر گرفتن جوابهایی از جنس I/O هستند، تاثیر گیل خیلی به چشم نمیاد.
چون پایتون از C extensionهای مختلفی استفاده میکنه که خیلیهاشون thread-safe نیستند و از طرفی حذف گیل مساوی هست با افزایش زمان اجرای برنامههای single thread و برنامههای I/O bound.
اگر بازم دوست دارید بیشتر راجعبه گیل بدونید این ویدیو از آقای David Bazely میتونه خیلی به شما کمک کنه: لینک.
با توجه به توضحیات بالا، ترد توی پایتون باید برای کارهای خیلی ساده استفاده بشه. کارهایی که خیلی زمان پردازنده رو نگیرند یا خیلی خیلی زمان کوتاهی رو بهخودشون اختصاص بدن. به صورت خیلی مختصر میشه گفت برای کارهای زیر میتونه استفاده بشه:
هدف از این قسمت، آشنایی با چالشهای موجود در پایتون در بخش Parallelsim بود. در قسمت بعدی راهکارهایی برای حل این چالش رو بررسی میکنیم. این راهکارها عموماً یا استفاده از چندین پراسس به جای ترد یا رفتن به سمت به سمت مفاهیمی مثل Co-operative multi-tasking هست که در رابطه با این مفاهیم توی قسمت بعدی بیشتر صحبت میکنیم.
اگر از این قسمت خوشتون اومده، حتما نظرتون رو در بخش نظرات بنویسید و همینطور این آموزشها رو با دوستای پایتونی خودتون به اشتراک بذارید.
در پایین هم منابع مختلفی که به من در نوشتن این آموزش کمک کردند رو آوردم و هم لینکهای مفیدی که میتونید تو هر بخش چیزهای بیشتری رو یاد بگیرید.
کدهای استفاده شده در این قسمت:
تردها در پایتون:
چندتا ترد باید بسازم؟
معرفی ThreadPool
آشنایی بیشتر با گیل و فلسفه وجودیِ اون