مفاهیم concurrency (در پایتون)

به طور کلی برنامه نویسی به دو روش انجام می شود: Synchronous و Asynchronous. در Synchronous پیش از شروع یک کار باید منتظر ماند که کار قبلی به پایان برسد. در Asynchronous کار ها را به صورت همزمان اجرا می کنیم، یعنی می توان قبل از پایان کار به سراغ کار دیگری رفت.

پیش از ادامه صحبت نیاز است تا با تعاریف و اصطلاحاتی آشنا شویم:

ترد (Thread):

کوچکترین واحد پردازشی است که می توان در یک سیستم عامل اجرا کرد. از یک شمارنده، stack و مجموعه ای از register ها تشکیل شده است. به عبارت ساده Thread مجموعه ای از چندین دستورالعمل در یک برنامه است که می تواند به صورت مستقل از کدهای دیگر اجرا شود.

استفاده از thread به اجرای همزمان task ها کمک می کند. اجرای thread ها و process ها از یک سیستم عامل به سیستم عامل دیگر متفاوت است اما در بیشتر موارد، یک thread در داخل یک process قرار دارد و در واقع thread بخشی از process است. چندین thread می توانند در یک process وجود داشته و منابعی مانند حافظه را به اشتراک بگذارند، در حالی که process های مختلف این منابع را به اشتراک نمی گذارند. thread ها اساسا process هایی هستند که در یک memory context اجرا می شوند.

یک thread شامل اطلاعات در یک Thread Control Block (TCB) است:

  • Thread Identifier:

یک شناسه منحصر به فرد (TID)

  • Stack pointer:

یک اشاره گر که به محل stack ترد

  • Program counter:

آدرس دستوری که در حال حاضر توسط رشته اجرا می شود را ذخیره می کند.

  • Thread state:

وضعیت thread را مشخص می کند و یکی از حالت های رو به رو را می پذیرد: ready, waiting, start, done.

  • Thread’s register set:

رجیسترهایی که برای محاسبات به رشته اختصاص داده شده اند.

  • Parent process Pointer:

یک اشاره گر به پروسسی که ترد در آن اجرا می شود (PCB).


فرآیند (Process)

یک instance از برنامه کامپیوتری است که در حال اجرا است که شامل کد برنامه و مجموعه فعالیت های آن است. بسته به سیستم عامل process ممکن است از چندین thread تشکیل شده باشد که همزمان دستورالعمل ها را اجرا می کند. هر process مجموعه کاملی از متغیرهای خاص خود را دارد. در آغاز برنامه ها بصورت فایلهایی بر روی هارد قرار دارند. برای اجرا شدن آنها، این فایلها از هارد به RAM منتقل می شوند و کتابخانه های مورد نیاز درون آن Load شده و سپس برنامه اجرا می گردد. در یک تعریف کلی میتوان گفت Process یک برنامه اجرا شده در سیستم عامل می باشد که خود از واحدی کوچکتر به نام Thread تشکیل شده که کوچکترین واحد پردازشی در سیستم عامل می باشد.


وظیفه (Task) مجموعه ای از دستورالعمل های برنامه است که در حافظه بارگیری می شوند.


تفاوت های Thread و Process

  • پراسس ها مستقل هستند به گونه ای که ‌PID مختص به خود را دارند در صورتی که وجود thread ها وابسته به process است و زیرمجموعه ای از آن ها به حساب می آیند.
  • پراسس توسط CPU کنترل میشه، ولی Thread توسط Process کنترل میشه.
  • می توان process ها را یک Task به حساب آورد ولی هر thread یک Light wight process است.
  • هر Process حافظه اختصاصی (Separate memory) خودش رو داره، ولی Thread ها از حافظه اشتراکی (Shared memory) و سایر منابع مشترک موجود در process استفاده می کنند.

در برنامه نویسی Synchronous یا همروند یک task تا زمانی که اجرای آن به پایان نرسیده پردازنده را در اختیار task دیگری قرار نمی دهد. Synchronous این اطمینان را به ما می‌دهد که تداخلی میان کار thread ها بوجود نخواهد آمد. مثلاً زمانی که یک thread در حال ویرایش و یا ایجاد یک فایل است، سایر thread ها امکان دسترسی به آن فایل را نداشته و باید تا پایان کار thread منتظر بماند. در مقابل، در برنامه نویسی Asynchronous یا غیر همروند می توان task های مختلف را بدون انتظار برای به پایان رسیدن task قبلی انجام داد. این کار توسط روش های مختلف و با مفهومی به نام Concurrency صورت می گیرد.

همزمانی (Concurrency):

امکان اجرای چندین task را به طور هم‌زمان به توسعه‌دهندگان می دهد. با استفاده از Concurrency می‌توانیم برنامهٔ خود را به چندین بخش مختلف که می‌توانند بدون نوبت اجرا شوند، تقسیم کنیم. همزمانی با روش های asyncio, parallelism, multiprocessing و threading پیاده سازی می شود.

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

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

به طور کلی مشکلاتی که همزمانی را پیش روی برنامه نویس قرار می گذارد به دو دسته تقسیم می شوند: I/O-bounding و CPU-bounding. مشکلات I/O-bounding از آنجا که باید منتظر ورودی یا خروجی باشد باعث کند شدن برنامه می شود مانند اتصال شبکه ، هارد دیسک یا چاپگر. راه حل برای افزایش سرعت در این موارد شامل همپوشانی زمان های انتظار برای این دستگاه ها است. در این موارد threading و asyncio گزینه های پیش رو خواهند بود.

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

تشخیص درست اینکه مشکل اصلی برنامه I/O ست یا قدرت پردازش بسیار مهم است و بر اساس آن از گزینه های در دست استفاده می کنیم چون همانگونه که گفته شد ممکن است با انتخاب اشتباه کمکی به افزایش سرعت صورت نگیرد.

مثال زیر اجرای یک برنامه synchronous است که در ادامه به روش های گوناگون آن را بازنویسی می کنیم:

import requests
import time


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


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


if __name__ == &quot__main__&quot:
    sites = [
        &quothttps://www.jython.org&quot,
        &quothttp://olympus.realpython.org/dice&quot,
    ] * 80
    start_time = time.time()
    download_all_sites(sites)
    duration = time.time() - start_time
    print(f&quotDownloaded {len(sites)} in {duration} seconds&quot)

در مثال بالا یک حلقه for آدرسی را به متد download_site ارسال می کند و در آن یک request از نوع get به آن آدرس زده می شود و محتوای آن را دریافت می کند.

مشکل برنامه های synchronous همواره کند بودن آنهاست ولی کدها تمیز تر و ساده تر هستند و دیباگ آنها آسان تر است. بنا براین تا مجبور به استفاده از asynchronous نشده ایم به سراغ آن نخواهیم رفت.

خروجی کد بالا در سیستم من

Downloaded 160 in 38.94426465034485 seconds

روش Multi Threading

در multi threading هر thread می تواند شامل مجموعه رجیستر و متغیرهای محلی خود باشد یا همه thread ها متغیرهای سراسری را به اشتراک بگذارند.

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

و حالا مثال پیاده سازی شده با threading:

import concurrent.futures
import requests
import threading
import time


thread_local = threading.local()


def get_session():
    if not hasattr(thread_local, &quotsession&quot):
        thread_local.session = requests.Session()
    return thread_local.session


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


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


if __name__ == &quot__main__&quot:
    sites = [
        &quothttps://www.jython.org&quot,
        &quothttp://olympus.realpython.org/dice&quot,
    ] * 80
    start_time = time.time()
    download_all_sites(sites)
    duration = time.time() - start_time
    print(f&quotDownloaded {len(sites)} in {duration} seconds&quot)

خروجی کد بالا در سیستم من. همانطور که مشاهده می کنید تفاوت چشمگیر است.

Downloaded 160 in 6.9670493602752686 seconds

نکات مربوط به مثال بالا:

با استفاده از threading.local و قرار دادن thread_local.session = requests.Session یک session برای thread مورد نظر set کردیم.

هدف استفاده از requests.Session به منظور افزایش سرعت در ریکوئست است که requests آن را هندل می کند.

ماژول concurrent.futuresیک اینترفیس برای callable های در حال اجرای همزمان ایجاد می کند. این callable ها می توانند توسط ThreadPoolExecutor و یا ProcessPoolExecutor اجرا شوند که ما از ThreadPoolExecutor استفاده کرده ایم که در واقع یک abstract class است که متدهایی را برای اجرای همزمان فراهم می کند و مستقیماً استفاده نمی شود بلکه از طریق زیر کلاس ها از آن استفاده می شود (مانند map).

در واقع این ماژول معادل Thread + Pool + Executor است.

قسمت Pool جایی است که این object قصد دارد مجموعه ای از thread ها را ایجاد کند که هر یک از آنها می توانند همزمان اجرا شوند. سرانجام، executor بخشی است که می خواهد نحوه و زمان اجرای هر یک از thread های pool را کنترل کند. این درخواست را در pool اجرا خواهد کرد.

تابع ()map تابع پاس داده شده را به همراه آرگومان ها به صورت همزمان در Pool اجرا خواهد کرد.

در آخر اینکه با استفاده از ماژول فوق نیازی به ()thread.start و ()thread.join وجود ندارد و Executor وظیفه مدیریت Thread ها در Pool را بر عهده دارد.

از آنجا که سیستم عامل تصمیم می گیرد که task ها کی اجرا شوند و کی بین task ها سويیچ اتفاق بیفتد، هر داده ای که بین thread ها به اشتراک گذاشته می شود باید محافظت شود و متاسفانه ()requests.Session، اینگونه نیست (thread-safe نیست). یکی از راه های thread-safe کردن استفاده از thread.local است. یک راه دیگر استفاده از threading.Lock است که در ThreadPoolExecutor پیاده سازی شده است.

به دلیل مشکلاتی که ممکن است در برنامه نویسی multi threading اتفاق بیفتد، توصیه می شود هر جا نیاز به thread احساس شد نخست به asyncio فکر کنید.

مشکلات احتمالی در برنامه نویسی multi threading:

همگام سازی thread به عنوان مکانیزمی تعریف می شود که تضمین می کند دو یا چند رشته همزمان به طور همزمان بخش خاصی از برنامه را که به عنوان Critical Section شناخته می شود اجرا نمی کنند. Critical Sectionرا نمی توان همزمان با بیش از یک فرآیند اجرا کرد بنابراین تمام پروسس ها باید منتظر بمانند تا در critical section خود اجرا شوند. سیستم عامل وظیفه مدیریت اجازه دادن و ممانعت از ورود فرآیندها به بخش بحرانی را بر عهده دارد. مدیریت Critical Section مانع به وجود آمدن Race Condition می گردد.

یک Race Condition زمانی رخ می دهد که دو یا چند رشته می توانند به داده های مشترک دسترسی داشته باشند و همزمان سعی می کنند آن را تغییر دهند. از آنجایی که الگوریتم زمان‌بندی رشته می‌تواند در هر زمانی بین رشته‌ها جابه‌جا شود، ممکن است ترتیبی که thread تلاش می‌کند به داده‌های مشترک دسترسی پیدا کند، دچار مشکل شده و دو thread همزمان به داده‌ها دسترسی پیدا کنند و آن را تغییر دهند.

بن بست (Dead Lock):

بن بست وضعیتی است که در آن دو یا چند thread برای همیشه منتظر در اختیار گرفتن lock برای دسترسی به داده های مورد نیاز می شوند. بن بست زمانی اتفاق می افتد که چندین thread به قفل های یکسانی نیاز دارند اما آنها را با ترتیب متفاوتی بدست می آورند.

روش asyncio

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

حال مثال بالا را این بار با asyncio پیاده سازی می کنیم:

import asyncio
import time
import aiohttp


async def download_site(session, url):
    async with session.get(url) as response:
        print(&quotRead {0} from {1}&quot.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__ == &quot__main__&quot:
    sites = [
        &quothttps://www.jython.org&quot,
        &quothttp://olympus.realpython.org/dice&quot,
    ] * 80
    start_time = time.time()
    asyncio.get_event_loop().run_until_complete(download_all_sites(sites))
    duration = time.time() - start_time
    print(f&quotDownloaded {len(sites)} sites in {duration} seconds&quot)

تابع asyncio.ensure_future()لیستی از task ها را در context manager ایجاد می کند و از آغاز کار آن ها اطمینان حاصل می کند. در زمانی که task ها ایجاد شدند متد asyncio.gather()سشن را تا زمان اتمام کار taskها باز نگه میدارد (alive).

متدهای get_event_loop()و run_until_complete() در __main__ وظیفه در اختیار گرفتن event loop و اجرای تابع پاس داده شده به آن را بر عهده دارند.

از مزایای asyncio نسبت به threading این است که مقیاس پذیری آن بهتر از thread است. همچنین ایجاد هر task در آن نسبت thread به منابع و زمان کمتری نیاز دارد.

تفاوت asyncio و threading

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

در صورتی که asyncio از Cooperative multitasking استفاده می کند. به این معنی که در آن سیستم عامل سوئیچ را از یک process در حال اجرا به یک process دیگر آغاز نمی کند. بلکه process ها بصورت داوطلبانه کنترل را به صورت دوره ای یا در حالت idle یا در اختیار می گیرند. به این نوع چند وظیفه ای مشارکتی گفته می شود.

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

برنامه نویسی موازی (Parallelism):

به معنی تقسیم یک مسئله به مسائل کوچکتر و سپردن آن ها به واحد های جداگانه برای پردازش است که این مسائل کوچک به صورت همزمان شروع به اجرا می کنند. مانند در اختیار داشتن دو thread است که روی دو هسته مختلف یک CPU به صورت همزمان در حال اجرای تسک خود است. فرم های مختلفی از Parallel مانند bit-level instruction-level، data و task وجود دارد. ‌Multi tasking یکی از روش های پیاده سازی Parallelism است.

روش Multitasking

بر خلاف رویکردهای قبلی، Multitasking از CPU های متعدد کامپیوتراستفاده می کند.

از آنجا که ‌multi threading و asyncio روی یک هسته پردازنده اجرا می شوند، بنابراین می توان گفت در پایتون تنها multi processing امکان اجرای چندین task به صورت همزمان را در اختیار قرار می دهد. اما در سایر روش ها روند اجرای task ها را طوری نوبت دهی می کند که شبیه سازی صورت گرفته را می توان همزمانی نامید.

در multiprocessing هر process را می توان به عنوان برنامه مجزا در نظر گرفت به صورتی که هر process منابع نرم افزاری و سخت افزاری مورد نیاز خود را اشغال می کند. در پایتون هر process حتی interpreter خود را دارد. multiprocessingروی core های مختلف یک CPU اجرا می شود و اجرا روی core های مختلف به این معنی ست که process ها به معنی واقعی همزمان اجرا می شوند.

import requests
import multiprocessing
import time

session = None


def set_global_session():
    global session
    if not session:
        session = requests.Session()


def download_site(url):
    with session.get(url) as response:
        name = multiprocessing.current_process().name
        print(f&quot{name}:Read {len(response.content)} from {url}&quot)


def download_all_sites(sites):
    with multiprocessing.Pool(initializer=set_global_session) as pool:
        pool.map(download_site, sites)


if __name__ == &quot__main__&quot:
    sites = [
        &quothttps://www.jython.org&quot,
        &quothttp://olympus.realpython.org/dice&quot,
    ] * 80
    start_time = time.time()
    download_all_sites(sites)
    duration = time.time() - start_time
    print(f&quotDownloaded {len(sites)} in {duration} seconds&quot)

این روش بسیار کوتاه تر از مثال asyncio است و در واقع کاملاً شبیه به مثال thread است. با multiprocessing همزمانی واقعی را تجربه خواهیم کرد ولی نسبت به روش های دیگر کند تر است.

تمام نمونه های همزمانی در این مقاله فقط در یک پردازنده یا core اجرا می شوند. دلایل این امر مربوط به طراحی Python و چیزی به نام Global Interpreter Lock یا GIL است. GIL به عبارت ساده، یک mutex یا قفل است که فقط به یک thread اجازه می دهد تا کنترل interpreter پایتون را کنترل کند یعنی در هر لحظه از زمان تنها یک thread می تواند در حالت اجرا باشد. تأثیر GIL برای توسعه دهندگانی که برنامه هایsingle thread را اجرا می کنند قابل مشاهده نیست، اما در برنامه های multi threading محسوس است. دلیل استفاده از GIL این است که پایتون برای مدیریت حافظه از memory management استفاده می کند. این بدان معناست که اشیا ایجاد شده در پایتون دارای یک متغیر شمارنده reference هستند که تعداد reference هایی را که به آن شی اشاره می کنند نگه می داررد. وقتی این تعداد به صفر می رسد، حافظه اشغال شده توسط شی آزاد می شود. مشکلی که GIL در صدد رفع آن بر آمده است این است که متغیر شمارش reference نیاز به محافظت در برابر شرایط race دارد (در آن دو thread به طور همزمان مقدار آن را افزایش یا کاهش می دهند). در این صورت احتمال این که حافظه هرگز آزاد نشود (leaked memory) یا حتی بدتر، حافظه به اشتباه آزاد شود وجود دارد. راه حل پایتون برای رفع این مشکل قراردادن یک قفل واحد بر روی مفسر است که یک rule اضافه می کند که اجرای هر کد بایت پایتون مستلزم دستیابی به GIL است که این امر برنامه Python را single thread می کند.

مثال زیر را در نظر بگیرید:

import sys

a = []
b = a
sys.getrefcount(a)

3 #Output

نکات پایانی:

در زمان تصمیم گیری برای استفاده از Concurrency گام اول این است که بفهمیم بار برنامه بر روی CPU است یا I/O. برنامه های I/O-bound به برنامه هایی گفته می شود که بیشتر وقت خود را در انتظار اتفاقات I/O می گذرانند در حالی که برنامه های CPU-bound وقت خود را صرف پردازش داده ها می کنند. مشکلات مربوط به CPU-bound تنها با استفاده از Multi processing به دست می آیند. threading و asyncio به هیچ وجه به این نوع مشکلات کمک نمی کند.

برای مشکلات I/O-bound، یک قانون کلی در انجمن پایتون وجود دارد: "هر زمان که می توانید از asyncio استفاده کنید، در صورت لزوم از thread استفاده کنید.

منبع:

با تشکر از Jim Anderson، منبع اصلی این مقاله:

https://realpython.com/python-concurrency