افزایش سرعت برنامه های پایتون با استفاده از همزمانی – قسمت اول

افزایش سرعت برنامه های پایتون با استفاده از همزمانی

Speed Up Your Python Program With Concurrency

An-Overview-of-Concurrency-in-Python

 

برای آشنایی هرچه بهتر با این مسئله بهتر است ابتدا کمی به تعریف همزمانی و همپوشانی برپردازیم. در ادامه شما را به مطالعه این مفهوم دعوت می کنم:

منبع مطلب: سایت realpython

همزمانی یا Concurrency چیست؟

در فرهنگ لغت معنی کلمه concurrency به معنای رخ دادن همزمان است. زمانی که در پایتون پردازش ها به صورت همزمان اتفاق می افتند در حقیقت همزمانی رخ داده است که اسامی مختلفی دارند (thread, task, process) ولی در واقع در سطح بالاتر به صفی ار پردازشهای در حال اجرا اشاره می کند که به ترتیب اجرا خواهند شد.

اجازه دهید به آنها به عنوان قطاری از افکار مختلف نگاه کنیم. هر کدام از آنها می توانند در نقطه خاصی متوقف شوند و پردازشگر یا مغز که در حال پردازش آنها است به پردازش دیگری سوییچ می کند. status یا وضعیت هر یک از آنها نگهداری و ذخیره می شوند. در نتیجه هرزمان که نیاز باشد می توانیم به سادگی آن را بازیابی کنیم.

شاید شما از این که پایتون از کلمات متفاوت برای یک مفهوم استفاده می کند تعجب کنید. اگر از بالا به مسئله نگاه کنیم thread, task و process یک مفهوم هستند. ولی نکته آنجاست که زمانی که شروع به رفتن به سطوح پایین تر می کنید، همه چیز کمی متفاوت تر می شود.

اجازه دهید در ادامه در مورد بخش همزمان در تعریف بالا صحبت کنیم. بهتر است کمی بیشتر دقت کنید زیرا زمانی که وارد جزئیات می شوید، درواقع تنها multiprocessing است که این قطارهای فکر را به معنای واقعی کلمه در همان زمان اجرا می کند. Threading و asyncio هر دو در یک پردازنده اجرا می شوند و بنابراین تنها یک پردازش در یک زمان اجرا می شود. در حقیقت آنها هوشمندانه راه هایی را برای چرخش نوبت انجام پردازش ها برای سرعت بخشیدن به روند کلی پیدا می کنند. هرچند که همزمان قطارهای مختلف فکر را اجرا نمی کنند، ولی ما هنوز هم آن را concurrency می نامیم.

در خصوص نحوه گرفتن نوبت برای انجام task ها تفاوت بزرگی بین threading و asyncio وجود دارد. در threading، سیستم عامل در واقع در مورد همه Taskها اطلاع دارد و می تواند آن را در هر زمان برای شروع یک thread دیگر متوقف کند. از آنجا که سیستم عامل می تواند از اجرای task برای سوئیچ کردن میان آنها پیشگیری کند، این روش را pre-emptive multitasking می نامیم.

زمانی که از Pre-emptive multitasking استفاده می کنیم، نیاز به انجام کاری برای جابجایی میان task ها نداریم و این در هنگام کدنویسی بسیار مفید است. البته می تواند باعث دشواری هم بشود و دلیل آن هم رخ دادن در هر زمان است (at any time). این سوئیچ می تواند در وسط یک دستور بسیار ساده پایتون اتفاق بیفتد، حتی یک چیز بی اهمیت مانند x = x + 1.

از طرف دیگر Asyncio از cooperative multitasking استفاده می کند. taskها باید با اعلام زمان آماده شدن برای خروج با هم همکاری کنند. این به این معنی است که تغییراتی میی بایست در کد انجام شود تا این اتفاق بیفتد.

مزیت انجام این کارهای اضافی آن است که  شما همیشه می دانید که کجا task شما تعویض خواهد شد. بدین معنی که در میان یک دستور پایتون تغییر نخواهد کرد، مگر اینکه این این دستور قبلا علامت گذتری شده باشد. بعدا خواهید دید که چگونه می توانید از این روش به عنوان بخشی از طراحی خود به سادگی استفاده کنید.

Parallelism چیست؟

تا اینجا هر چی دیدم در حقیقت concurrency بود که تنها در یک پردازشگر اتفاق می افتاد. ولی سوالی که در ادامه پیش می آید این است که CPUهای جذابی که تعداد core های زیادی دارند، در آنها چه اتفاقی می افتد؟ ما چگونه می توانیم از آنها استفاده کنیم؟ پاسخ این سوال multiprocessing است.

با استفاده از multiprocessing، پایتون فرایند با process های جدیدی ایجاد می کند. یک فرایند یا process در اینجا می تواند به عنوان تقریبا یک برنامه کاملا متفاوتی در نظر گرفته شود، اما از لحاظ تکنیکی آنها معمولا به عنوان مجموعه ای از منابع تعریف می شوند که منابع شامل حافظه، دسته های فایل و موارد مشابه هستند. برای آنکه مفهوم آن را بهتر درک کنیم بهتر است که اینگونه به موضوع نگاه کنبم که هر process توسط مترجم پایتون خود، جدا از سایر processها اجرا می شود.

از آنجا که آنها processهای مختلف هستند، هر یک از قطارهای فکری در یک برنامه چند پردازشی می تواند بر روی هسته های مختلف اجرا شود. اجرا بر روی هسته های دیگر به این معنی است که آنها در واقع می توانند در همان زمان اجرا شوند، که این خود شگفت آور است. البته عوارضی ناشی از انجام این کار وجود دارد، اما Python کارهای بسیار خوبی در جهت هموار کردن این مشکلات در بیشتر موارد انجام می دهد.

اکنون که شما از دو مفهوم concurrency و parallelism به اندازه کافی ایده ای گرفتید، بیایید تفاوت های آنها را بررسی کنیم، و سپس می توانیم به این نکته بیشتر توجه کنیم که چرا این مفاهیم می توانند مفید باشند:

نوع همزمانیتصمیمگیری برای سوئیچینگتعداد پردازشگرها
Pre-emptive multitasking (threading)سیستم عامل برای سوییچ میان task ها تصمیم می گیرد.۱
Cooperative multitasking (asyncio)task تصمیم می گیرد که چه زمانی کنترل را بدست گیرد.۱
Multiprocessing (multiprocessing)taskها همگی در پردازنده های مختلف همزمان اجرا می شوند.Many

همزمانی یا concurrency چه زمانی مفید است؟

همزمانی به طور کلی می تواند تغییرات بزرگی در خصوص دو نوع متفاوت از مشکلات ایجاد کند. به طور کلی این مشکلات در دو دسته I/O و CPU دسته بندی می شوند.

مشکلات I/O به سرعت باعث کاهش سرعت اجرای برنامه شما می شوند زیرا به تناوب برنامه می بایست منتظر دریافت یا ارسال (input or output) از یک منبع خارجی باشد. این مشکل معمولا زمانی رخ می دهد که برنامه شما با منابعی که بسیار کندتر از CPU هستند درگیر می شود.

گروهی از موارد وجود داردند که از CPU شما کندتر هستند اما خوشبختانه برنامه شما با بیشتر آنها ارتباط برقرار نمی کند. قسمت های کند برنامه شما معمولا با فایل ها (file system) و با اتصالات شبکه (network connections) ارتباط برقرار می کنند.

لطفا به تصویر زیر توجه کنید:

IOBound

در تصویر بالا  باکس های آبی بیانگر زمانهایی هستند که برنامه شما مشغول انجام کار است و باکس های قرمز رنگ بیانگر زمان هایی است که برنامه شما در انتظار یک عملیات I/O است. دقت کنید که این نمودار در مقیاس واقعی نیست زیرا به طور مثال درخواست ها در محیط اینترنت می توانند چندین مرتبه طولانی تر از دستورالعمل های انجام شده توسط CPU باشند، بنابراین برنامه شما می تواند بیشتر وقت خود را در انتظار دریافت یا ارسال اطلاعات باشد. و این انتظار کشیدن در حقیقت کاری است که مرورگر شما در اکثر موارد مشغول آن است!

در سمت چپ، کلاس هایی از برنامه هایی هستند که محاسبات قابل توجهی را بدون صحبت کردن با شبکه یا دسترسی به یک فایل انجام می دهند. به این مدل برنامه ها برنامه های محدود شده با CPU  یا CPU-bound programs می گوییم، زیرا منابع محدود کننده سرعت برنامه شما سرعت CPU است، نه شبکه یا سیستم فایل ها.

در تصویر زیر یک نمونه برنامه محدود شده به CPU و یا  CPU-bound را می بینید:

CPUBound

همانطور که از طریق نمونه هایی که در ادامه آمده است، خواهید دید که انواع مختلف همپوشانی یا concurrency با برنامه های محدود شده CPU و I/O بهتر و یا بدتر کار می کنند. اضافه کردن همزمانی به برنامه شما، نیاز به اضافه کردن کدهای  اضافی توسط شما دارد، بنابراین شما باید تصمیم بگیرید که آیا تلاش برای افزودن سرعت یک تلاش اضافی است یا خیر. در انتهای این مقاله شما می بایست اطلاعات کافی برای گرفتن این تصمیم را داشته باشید.

لطفا در ادامه برای روشن شدن مفهوم به جدول خلاصه زیر دقت کنید:

I/O-Bound ProcessCPU-Bound Process
Your program spends most of its time talking to a slow device, like a network connection, a hard drive, or a printer.You program spends most of its time doing CPU operations.
Speeding it up involves overlapping the times spent waiting for these devices.Speeding it up involves finding ways to do more computations in the same amount of time.

اول به برنامه های I/O-Bound نگاهی می اندازیم. و سپس در ادامه سراغ برنامه های CPU-Bound خواهیم رفت.

چگونه سرعت برنامه های I/O-Bound را افزایش دهیم:

اجازه دهید با تمرکز بر روی برنامه های I/O-bound و بررسی یک مشکل رایج شروع کنیم: دانلود کردن یک محتوای از طریق شبکه. برای مثال ما، شما می خواهید صفحات وب را از تعداد کمی سایت دانلود کنید، اما در واقعا می تواند شامل هر ترافیکی در شبکه باشد. ما فقط جهت ساده تر شدن کار فرض می کنیم که مشکل تنها چند صفحه وب است.

نسخه همزمان یا Synchronous Version

برای شروع با یک نسخه غیر همزمان شروع خواهیم کرد. توجه داشته باشید که این برنامه به ماژول Requests نیاز دارد.طبیعتا ابتدا و با استفاده از دستور pip install requests آن را نصب نمائید. و همچنین ممکن است بخواهید از virtualenv. استفاده کنید. دقت کنید که در این نسخه همزمانی وجود ندارد:

import requests
import time

def download_site(url, session):
            with session.get(url) as response:
                        print(f"Read {len(response.content)} from {url}")

def download_all_sites(sites):
            with requests.Session() as session:
                        for url in sites:
                                    download_site(url, session)

if __name__ == "__main__":
            sites = [
                        "http://www.jython.org",
                        "http://olympus.realpython.org/dice",
            ] * ۸۰
            start_time = time.time()
            download_all_sites(sites)
            duration = time.time() - start_time
            print(f"Downloaded {len(sites)} in {duration} seconds")

همانطور که می بینید، این نرم افزار بسیار کوچک و ساده است. در حقیقت download_site محتوای آدرس های دادده شده را دانلود کرده و نهایتا سایز آن را print می کند. نکته ای که باید در نظر بگیرید ما از شیئ Session از کتابخانه requests استفاده کرده ایم.

برای نوشتن این کد می توانستیم تنها از متن get استفاده کنیم. ولی Session با استفاده از یک سری روش ها باعث بالارفتن سرعت می شود.

تابع download_all_site یک Session ایجاد می کند و سپس بر روی لیست سایت ها حرکت کرده و به نوبت هرکدام را دانلود می کند. و در انتها مدت زمان انجام این عملیات را print می کند. نتیجتا شما می توانید ببینید همزمانی چقدر می تواند مفید باشد.

نمودار اجرای برنامه مثال ما کاملا شبیه به نموداری است که در دیاگرام I/O-Bound دیدیم.

نکته: ترافیک شبکه به فاکتورها و پارامترهای زیادی وابسته است. در خصوص همین مثال ساده من کاهی با زمانهایی که تا دوبرابر شرایط معمول طول کشیده است (بسته به مشکلات شبکه ای) روبرو شده ام.

چرا نوشتن نسخه Synchronous فراگیر است؟

یکی از مهمترین ویژگی های کدی که در بالا دیدیم سادگی آن است. نوشتن و دیباگ کردن این کد بسیار ساده است. دقیقا به همان صورت که فکر می کنیم به جلو حرکت می کند. اگر به صورت نمادین به آن نگاه کنیم، فقط شامل یک قطار از دستورات یا همان فکر است نتیجتا شما به سادگی می توانید حرکات و اتفاقات بعدی را پیشبینی کنید.

مشکلات نسخه Synchronous چیست؟

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

$ ./io_non_concurrent.py
          [most output skipped]
Downloaded 160 in 14.289619207382202 seconds

توجه: نتایج شما ممکن است به طور قابل توجهی متفاوت باشد. هنگام اجرای این اسکریپت، نتایج حاصله از ۱۴٫۲ تا ۲۱٫۹ ثانیه متفاوت بود. برای این مقاله، از میان سه اجرا سریع ترین را به عنوان زمان اعلام کردم.

کند بودن همیشه هم مسئله بزرگی محسوب نمی شود. اگر برنامه شما با همین نسخه Synchronous در مدت زمان حدود ۲ ثانیه اجرا می شود و همچنین معمولا به ندرت اجرا می شود، احتمالا ارزش اضافه کردن concurrency را ندارد. می توانید همینجا کار را متوقف کنید.

ولی چه اتفاقی می افتد اگر برنامه شما به دفعات اجرا می شود؟ یا اگر اجرای آن ساعت ها طول می کشد؟ اجازه دهید با استفاده از روش همزمانی و استفاده از threading کد خود را بازنویسی کنیم.

نسخه threading

همانطور که احتمالا حدس زده اید، نوشتن یک برنامه با استفاده از threading نیاز به کار بیشتری دارد. با این وجود ممکن است از این که برای انجام کارهای کوچک می بایست موارد ساده ای انجام می دهید متعجب خواهید شد. در ادامه  برنامه مشابه بالا را با استفاده از threading می بینید:

import concurrent.futures
import requests
import threading
import time

thread_local = threading.local()

def get_session():
            if not getattr(thread_local, "session", None):
                        thread_local.session = requests.Session()
            return thread_local.session

def download_site(url):
            session = get_session()
            with session.get(url) as response:
                        print(f"Read {len(response.content)} from {url}")

def download_all_sites(sites):
            with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
                        executor.map(download_site, sites)

if __name__ == "__main__":
            sites = [
                        "http://www.jython.org",
                        "http://olympus.realpython.org/dice",
            ] * ۸۰
            start_time = time.time()
            download_all_sites(sites)
            duration = time.time() - start_time
            print(f"Downloaded {len(sites)} in {duration} seconds")

زمانی که از threading استفاده می کنیم استراکچر کلی تغییری نمی کند و شما نیاز به اعمال تغییرات کمی دارید. تابع download_all_site در هنگام فراخوانی تغییر می کند. از یک ساختار ساده که فراخوانی به ازای هر سایت است به ساختاری که کمی پیچیده تر است تبدیل می شود.

در این نسخه، در حقیقت یک ThreadPoolExecutor ایجاد کرده ایم، که البته پیچیده به نظر می رسد. بیایید آنرا بشکنیم: ThreadPoolExecutor = Thread + Pool + Executor.

شما قبلا در مورد بخش Thread مطاله کرده اید. این فقط یک قطار فکر است که قبلا عنوان کردیم. قسمت Pool جایی است که داستان جالب می شود. این شی مجموعه ای از thread ها را ایجاد خواهد کرد که هر کدام می توانند همزمان اجرا شوند. در نهایت، Executor بخشی است که قصد کنترل هر زمان و هر موضوع در Pool را دارد. یعنی کلیه درخواست هایی را که در Pool قراردارند اجرا خواهد کرد.

با کمک کتابخانه استاندارد، ThreadPoolExecutor به عنوان context manager پیاده سازی شده است، بنابراین می توانید از دستور with برای مدیریت ایجاد و آزادسازی Poll  از استفاده کنید تا بتوانید ایجاد و آزاد کردن مجموعه ای از موضوعات را مدیریت کنید.

هنگامی که یک ThreadPoolExecutor دارید، می توانید به روش دستی از متد map استفاده کنید. این روش، توابع پاس داده شده در رابطه با هر یک از سایتهای موجود در لیست اجرا می شود. قسمت مهم این است که با استفاده از Pollی از threads ها، به طور خودکار آنها را به طور همزمان اجرا می کند.

کسانی که تجربه زبانهای دیگر برنامه نویسی را شاته اند و یا حتی با پایتون ۲ کار کرده اند، احتمالا تعجب می کنند که اشیاء و توابع معمولی زمانی که جزئیات مورد استفاده شما را هنگام threading مدیریت می کنند کجا هستند، به طور مثال Thread.start  Thread.join و همچنین صف.

این توابع هنوز هم وجود داشته و در دسترس هستند، و شما می توانید از آنها برای کنترل دقیق چگونگی اجرای موضوعات خود از آنها استفاده کنید. اما، با آمدن Python 3.2، یک کتابخانه استاندارد سطح بالا  به نام Executors در آن اضافه گردید که بسیاری از این جزئیات را برای شما مدیریت می کند. مخصوصا اگر شما نیازی به کنترل دقیق آنها نداشته باشید.

تغییر جالب دیگری که در مثال ما وجود دارد این است که هر یک از thread ها نیاز با ساخت شیئ requests.session خود را دارند. وقتی به مستندات کتابخانه requests نگاه می کنید، این کار لزوما آسان نیست، اما مطالعه این مشکل، کاملا واضح است که شما نیاز به یک Secession جداگانه برای هر thread دارید.

این یکی از مسائل جالب و دشوار در رابطه با threading است. از آنجائیکه سیستم عامل کنترل می کند که چه زمانی task شما متوقف شود و کار دیگری شروع شود، هر گونه اطلاعاتی که بین threadها به اشتراک گذاشته می شود بایستی محافظت شود یا thread-safe باشد.  requests.Session  متاسفانه thread-safe نیست.

استراتژی های متعددی برای ایجاد دسترسی thread-safe به داده ها بسته به اینکه داده ها چه هستند و چه استفاده ای از آن می کنید، وجود دارد. یکی از این روش ها استفاده از ساختار داده thread-safe مانند Queue از ماژول Queue پایتون است.

این اشیاء از ابتدا توابع سطح پایین مانند threading.Lock استفاده می کنند تا اطمینان حاصل شود که فقط یک thread می تواند در یک زمان به یک بلوک کد یا یک بیت حافظه، دسترسی داشته باشد. شما در زمان استفاده از این استراتژی به طور غیر مستقیم از شیء ThreadPoolExecutor استفاده می کنید.

استراتژی دیگری که در اینجا کاربرد دارد، thread local storage نام دارد. threading.local شیئی را ایجاد می کند که به نظر عمومی یا global است ولی در حقیقت برای هر thread جدا و خاص است. در مثال شما، این کار با استفاده از threadLocal و get_session انجام می شود:

def get_session():
            if getattr(threadLocal, "session", None) is None:
                        threadLocal.session = requests.Session()
            return threadLocal.session

ThreadLocal در ماژول threading برای حل همین مشکل خاص است. شاید کمی عجیب و غریب به نظر برسد، ولی در حقیقت شما به ساخت یکی از این اشیاء نیاز دارید، و نه برای هر thread یک شیئ جدا. این شیئ خود به تنهایی از دسترسی threadهای مختلف به داده های مختلف محافظت می کند.

وقتی session get_session فراخوانی می شود، Session ای که مربوط به thread خاصی است، اجرا می شود. بنابراین هر thread یک Session را برای اولین بار که call get_session را فراخوانی می کند، ایجاد می کند و سپس آن Session را در فراخوانی های بعدی در طول عمر خود به کار می گیرد.

در نهایت، یک توضیح کوچک در مورد تعداد threadها. همانطور که می ببینید در کد نمونه از ۵ thread استفاده شده. به راحتی و با تغییر این شماره می توانید ببینید که چگونه زمان کلی تغییر می کند. ممکن است انتظار داشته باشید که داشتن یک thread برای هر دانلود سریعتر باشد، اما حداقل در سیستم من اینطور نبود. من سریعترین نتایج را زمانی بدست آوردم که تعداد thread ها چیزی بین ۵ تا ۱۰ thread بود. اگر به هر تعداد بالاتر از آن بروید، سربار اضافی زمان ایجاد و نابود کردن thread ها، باعث می شود صرفه جویی ایجاد شده در زمان از بین برود.

البته پاسخ دادن به این سوال دشوار است چون از هر task به task دیگر، تعداد قابل قبول thread ها می تواند متفاوت باشد و حقیقتا به تجربه و آزمایش نیاز دارد.

چرا نسخه threading بهتر است

این نسخه واقعا سریع است. این اجرا سریع ترین نسخه اجرای کد برنامه من است. توجه کنید که نسخه قبلی یا همان non-concurrent برای اجرا چیزی حدود ۱۴ ثانیه یا بیشتر زمان نیاز داشت. به خروجی زیر توجه کنید:

$ ./io_threading.py
            [most output skipped]
Downloaded 160 in 3.7238826751708984 seconds

لطفا به نمودار تایمنیگ اجرای این کد در زیر توجه کنید:

 

Threading-Timing

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

مشکلات موجود در نسخه threading

بسیتر خوب، همانطور که در مثال ما مشخص است، برای رسیدن به این هدف کدنویسی بیشتری مورد نیاز است و همچنین نیاز به تفکری بیشتری دارید تا متوجه شوید چه بخش از data شما نیاز به اشتراک میان threadها دارد.

گاه thread ها در مسیر هایی مورد نیاز هستند که تشخیص آنها ظریف و سخت است. این تعاملات پیچیده می تواند شرایط ویژه ای را ایجاد کند که باعث بروز اختلالات متناوبی گردد و در نتیجه پیدا کردن این خطاها کار بسیار دشواری خواهد بود. چند پاراگراف بعد در رابطه با Race Conditions خواهد بود که در صورتی که به آن علاقه ندارید می توانید از آن بگذرید.

Race Conditions

Race conditions کلاسی شامل اشکالات ظریفی است که اغلب در زمان استفاده از thread رخ می دهد. این اتفاق عموما زمانی رخ می دهد که برنامه نویس دسترسی همزمان به داده ها را به درستی محدود نکرده است. هنگام نوشتن کدهای threading باید گام های اضافی را بردارید تا از thread-safe بودن کد خود مطمئن شوید.

آنچه در اینجا اتفاق می افتد آن است که سیستم عامل زمانی که threadهای شما اجرا می شود کنترل زمان اجرا و همچنین جابجایی آن را با thread های دیگر کنترل می کند. این مبادله میان این threadها می تواند در هر زمانی رخ دهد، حتی در حال اجرای یک زیر دستور از یک دستور پایتون. به عنوان یک مثال سریع، به این تابع نگاه کنید:

import concurrent.futures

counter = 0

def increment_counter(fake_value):
            global counter
            for _ in range(100):
                        counter += 1

if __name__ == "__main__":
            fake_data = [x for x in range(5000)]
            counter = 0

            with concurrent.futures.ThreadPoolExecutor(max_workers=5000) as executor:
                        executor.map(increment_counter, fake_data)

این کد کاملا شبیه ساختار مورد استفاده شما در مثال بالا است. تفاوت این است که هر یک از threadها دسترسی به همان متغیر  global counter را داشته و قابلیت  افزایش آن را دارد. شمارنده به هیچ وجه محافظت نمی شود، بنابراین thread-safe نیست.

به منظور افزایش شمارنده، هر یک از threadها نیاز به خواندن ارزش فعلی، اضافه کردن یک واحد به آن، و ذخیره مقدار آن در متغیر دارند. این حین این اتفاق، خط رخ می دهد: counter + = 1..

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

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

به عنوان مثال دیگربه شما یادآوری می کنم که requests.session از جمله مواردی بود که thread-safe نیست. و در نتیجه اگر از جنس تعاملات بالا در این محل ها رخ دهد، می توانند همگی بر روی یک Session اتفاق بیفتند. و همانطور که اشاره شد رفع اینگونه موارد می تواند پیچیده باشد.

نسخه asyncio

اجازه دهید پیش از آن که مستقیما به بررسی کد بپردازیم کمی به  شیوه عملکرد این روش دقت کنیم.

مبانی asyncio

این یک نسخه ساده از asycio خواهد بود. جزئیات زیادی وجود دارد که ممکن است از گفتن آنها چشم پوشی کنیم، ولی هنوز هم ایده چگونگی کار آن را ارائه می کنیم.

مفهوم کلی asyncio این است که یک شی پایتون، که حلقه رویداد یا event loop نامیده می شود، به تنهایی کنترل می کند که چگونه و چه زمانی هر کار اجرا شود. حلقه رویداد از هر کار یا task آگاه است و می داند چه وضعیتی دارد. در حقیقت، taskها نی توانند در وضعیت ها بسیاری باشند، اما اکنون فقط یک حلقه ساده که دو حالت دارد را تصور کنید.

حالت آماده یا ready نشان می دهد که task کاری برای انجام دادن دارد و آماده انجام یا اجرای آن است. و وضعیت یا حالت انتظار یا waiting بیانگر این موضوع است که task در انتظار به اتمام رسیدن یک عملیات خارجی مانند network و … است.

حلقه رویداد یا همان event loop ساده شده شما دو لیست از کارها را نگهداری می کند، یک لیست برای هر یک از این وضعیت ها. سپس یکی از taskهای آماده را انتخاب می کند و شروع به اجرای آن می کند. این task در کنترل کامل است تا اینکه با همکاری کامل کنترل را به event loop برگرداند.

هنگامی که task در حال اجرا کنترل را به حلقه رویداد یا event loop باز می گرداند، حلقه رویداد این task را به لیست آماده یا انتظار منتقل می کند و سپس از طریق هر یک از taskها در لیست انتظار جستجو می کند تا ببیند آیا taskی توسط یک عملیات I / O آماده شده است، سپس آن را به لیست ready بر می گرداند، زیرا می دانند که هنوز اجرا نشده اند.

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

یک نکته مهم در asyncio این است که taskها هرگز بدون کنترل، قاطعانه و در وسط یک عملیات متوقف نمی شوند. این به ما اجازه می دهد منابع را به آسانی در asyncio به اشتراک بگذاریم. شما لازم نیست نگران باشید که کد خود را امن کنید.

این یک نگاه سطح بالا به آنچه با استفاده از  asyncio اتفاق می افتد است. اگر می خواهید جزئیات بیشتری در این رابطه بدانید، این پاسخ در StackOverflow جزئیات خوبی را در اختیار شما قرار می دهد.

async و await

خوب اجازه دهید راجع به دو کلید واژه یعنی async و await که به پایتون اضافه شده است صحبت کنیم. با توجه به مباحث گفته شده در بالا، می توانید ببینید که await جادویی است که به task ها اجازه می دهد که به حلقه تکرار بازگردند. زمانی که کد شما در انتظار فراخوانی است، نشان دهنده این واقعیت است که خود فراخوانی نیز چیزی زمان بر است و نتیجتا task می بایست کنترل را رها کند.

ساده ترین راهی که می توانیم به async فکر کنیم آن است که آن را به عنوان یک پرچم در نظر بگیرم گه به پایتون می گوید که تعریف تابع  به گونه ایست که از await استفاده می کند. گاهی مواردی وجود دارد که این قضیه در رابطه با آن کاملا صدق نمی کند، مانند asynchronous و generators، اما برای اکثر موارد مشکلی وجود ندارد و همچنین در هنگام شروع به شما یک مدل ارائه ساده می دهد.

استثنایی که در کدی که در ادامه می آید خواهید دید دستور async with است که یک context manager از شیئی که به طور معمول منتظر آن هستید ایجاد می کند. در حالی که معنا کمی متفاوت به نظر می رسد، ولی ایده همان است: به عنوان flag این مدیر زمینه یا همان context manager به عنوان چیزی است که می تواند swapped شود.

مطمئنم که می توانید تصور کنید، در مدیریت ارتباط بین event loop و taskها، پیچیدگی هایی وجود دارد. برای توسعه دهندگانی که از asyncio شروع می کنند، این جزئیات مهم نیستند، اما باید به یاد داشته باشید که هر functionی که await را فراخوانی می کند، باید با async مشخص شود. در غیر اینصورت خطای syntax error دریافت خواهید کرد.

بازگشت به کد

حال که شما به یک درک پایه ای از asyncio دست پیدا کردید، بیایید کد مثال خود را با استفاده از asyncioپیاده سازی و نحوه کار آن را بررسی کنیم. توجه داشته باشید که این نسخه به کتابخانه aiohttp نیاز دارد. شما باید قبل از اجرا آن را باستفاده از pip را نصب کنید (pip install aiohttp):

import asyncio
import time
import aiohttp

async def download_site(session, url):
            async with session.get(url) as response:
                        print("Read {0} from {1}".format(response.content_length, url))

async def download_all_sites(sites):
            async with aiohttp.ClientSession() as session:
                        tasks = []
                        for url in sites:
                                    task = asyncio.ensure_future(download_site(session, url))
                                    tasks.append(task)
                        await asyncio.gather(*tasks, return_exceptions=True)

if __name__ == "__main__":
            sites = [
                        "http://www.jython.org",
                        "http://olympus.realpython.org/dice",
            ] * ۸۰
            start_time = time.time()
            asyncio.get_event_loop().run_until_complete(download_all_sites(sites))
            duration = time.time() - start_time
            print(f"Downloaded {len(sites)} sites in {duration} seconds")

این نسخه کمی به نسبت دو نسخه گذشته پیچیده تر است. ساختار کلی همان است ولی کمی تنظیمات بیشتری به نسبت ایجاد ThreadPoolExecutor نیاز دارد. اجازه دهید از ابتدای کد شروع کنیم.

download_site

این تابع که در ابتدای کد آمده در نسخه threading هم وجود داشت با این تفاوت که از کلمه async در خط تعریف function استفاده شده است و همچنین async with که دقیقا زمان فراخوانی session.get از آن استفاده می شود. در ادامه خواهید دید که چرا به جای استفاده از thread-local می توانیم از session استفاده کنیم.

download_all_sites

در این تابع شما بزرگتین میران تغییرات به نسبت نسخه threading را خواهید داشت.

شما می توانید Session را در تمام taskها به اشتباک بگذارید. نتیجتا session در اینجا نقش context manager را دارد. از آنجا که همه task ها در یک thread اجرا می شوند در نتیجه می توانند به صورت مشترک از session استفاده کنند. در نتیجه زمانی که session در وضعیت مناسبی نباشد هیچ راهی برای متوقف کردن task دیگری ندارد.

در داخل context manager، با استفاده از دستور asyncio.ensure_future لیستی از taskها ایجاد می کند، و همچنین از زمان و نحوه اجرای آنها مراقبت می کند. زمانی که کلیه taskها ایجاد شد، این function از دستور asyncio.gather برای برای زنده نگاه داشتن session تا زمان تکمیل شدن کلیه taskها استفاده می کند.

نمونه کد threading نیز چیز مشابهی داشت، اما جزئیات به سادگی در ThreadPoolExecutor مدیریت می شد. در این روش AsyncioPoolExecutor  وجود ندارد.

با این وجود یک تغییر کوچک اما مهم در جزئیات وجود دارد. باید به یاد داشته باشید که چگونه ما در مورد تعداد taskهای ایجاد شده صحبت کردیم؟ در مثال threading مشخص نیست که تعداد بهینه taskها چه عددی است؟

یکی از مزایای جذاب Asyncio این است که به مراتب بهتر از threading است. هر task هزینه منابع کمتری به خود اختصاص داده و همچنین زمان کمتری برای ایجاد یک task مورد نیاز است، بنابراین ایجاد و اجرای تعداد بیشتری از taskها به خوبی کار می کند. این مثال فقط یک task جداگانه برای هر سایت برای دانلود ایجاد می کند که بسیار خوب کار می کند.

__main__

و در نهایت، ماهیت asyncio به این معنی است که شما باید یک event loop را راه اندازی کنید و آن را برای انجام task ها فراخوانی کنید. بخش __main__ در پایین فایل کد برای get_event_loop و سپس run_until_complete است. اگر چیز دیگری باقی نماند، درنتیجه کار بسیار خوبی در نامگذاری این توابع انجام داده اند.

اگر شما به پایتون ۳٫۷ به روز شده اید، توسعه دهندگان هسته پایتون نحوه پیاده سازی این کار را برای شما ساده تر کرده اند. به جای asyncio.get_event_loop.run_until_complete که بسیار پیچیده ات، شما فقط می توانید از asyncio.run استفاده کنید.

مزایای نسخه asyncio

این نسخه واقعا سریع است. در نسخه اجرا شده بر روی سیستم من، سریع ترین نسخه با فاصله قابل قبولی این نسخه است. به خروجی توجه کنید:

$ ./io_asyncio.py
            [most output skipped]
Downloaded 160 in 2.5727896690368652 seconds

نمودار زمانبندی اجرا بسیار مشابه نسخه threading است. تنها تفاوت آن است که درخواست های I/O توسط یک thread مشابه اجرا می شود.

Asyncio Version

عدم وجود بسته بندی خوب در کد نویسی که در ThreadPoolExecutor وجود داشت باعث می شود که این کد کمی پیچیده تر از مثال های threading باشد. این یکی از مواردی است که شما باید در نظر داشته باشید زیرا کار بیشتری برای به دست آوردن عملکرد بسیار بهتر مورد نیاز است.

همچنین یک بحث دیگر وجود دارد که نیاز به اضافه کردن async و await در مکان های مناسب کار سخت اضافی است. البته تا حدی این مسئله درست است. قسمتی که باعث تردید در این استدلال می شود این است که شما را مجبور به فکر کردن درباره زمانیکه یک task جابجا می شود وادار می کند، که خود این تفکر می تواند به شما کمک کند طراحی بهتر و سریعتری را ایجاد کنید.

مسئله scaling issue (پوسته پوسته شدن) نیز یکی از مشکلات بزرگ است. در اجرای مثال با استفاده از threading با ایجاد یک thread برای هر سایت به میزان قابل ملاحظه ای کندتر از اجرای آن با تعداد انگشت شماری از threadها است. اجرای مثال asyncio با صد ها task، به هیچ وجه با کندی روبرو نشد.

مشکلات نسخه acyncio

چندین مسئله در زمان استفاده از Asyncio وجود دارد. برای به دست آوردن مزیت های کامل asycio، شما نیاز به نسخه خاصی از کتابخانه  async دارید. اگر شما از requests برای دانلود سایت ها استفاده می کنید، احتمالا بسیار کندتر بوده است زیرا requests اینگونه طراحی نشده است که به event loop اطلاع دهد که مسدود شده است. این مسئله با گذر زمان کوچکتر و کوچکتر خواهد شد، زیرا در گذر زمان، کتابخانه های بیشتری از acyncio پشتیبانی خواهند کرد.

یکی دیگر از مسائل ساده تر این است که اگر تنها یکی از taskها همکاری لازم با acyncio را نداشته باشد، تمام مزایای cooperative multitasking از بین می رود. یک اشتباه جزئی در کد نویسی می تواند یک task را برای مدت طولانی در وضعیت انجام و پردازنده را در حالت hold نگاه دارد، و taskهای دیگری که نیاز به اجرا دارند، بیش از حد در انتظار می مانند. اگر یک task کنترل را پس ندهد هیچ راهی برای شکسته شدن event loop وجود ندارد.

امیدوارم تا به اینجای کار توانسته باشیم تا حد امکان شما را با مفاهیم و روش های concurrency آشنا کرده باشیم. در ادامه و با در نظر گرفتن این موارد، در مقاله ای دیگر به یک رویکرد کاملا متفاوت برای همزمان سازی یعنی multiprocessing خواهیم پرداخت.

لطفا با ارسال نظرات خود ما را در ارائه بهتر مطالب یاری کنید.

 

2 دیدگاه در “افزایش سرعت برنامه های پایتون با استفاده از همزمانی – قسمت اول

پاسخی بگذارید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *