FA-TOOLS — Header Component

آموزش asyncio در پایتون — برنامه‌نویسی ناهمزمان

Asyncio در یک نگاه: از A تا Z

آموزش asyncio در پایتون — برنامه‌نویسی ناهمزمان — تصویر 2

Asyncio کتابخانه‌ای قدرتمند در پایتون برای نوشتن برنامه‌های همزمان (concurrent) با استفاده از نحو async و await است. این کتابخانه بر پایه یک حلقه رویداد (Event Loop) تک‌رشته‌ای کار می‌کند و به شما امکان می‌دهد تا عملیات‌های I/O-bound (مانند درخواست‌های شبکه، دسترسی به دیسک یا پایگاه داده) را بدون مسدود کردن ترد اصلی برنامه، به‌طور کارآمد مدیریت کنید.

  • چرا Asyncio؟ افزایش کارایی در عملیات‌های I/O-bound، بهبود پاسخگویی برنامه، کاهش مصرف منابع (نسبت به Threading).
  • مفاهیم کلیدی: کوروتین (async defawait، حلقه رویداد، تسک (Task)، فیوچر (Future).
  • شروع سریع: یک تابع async def تعریف کنید و آن را با asyncio.run() اجرا کنید.
  • اجرای موازی (عملاً همزمان): با asyncio.create_task() تسک بسازید و با asyncio.gather() منتظر همه آن‌ها بمانید.
  • کاربردهای رایج: وب سرورها (FastAPI, Aiohttp)، کلاینت‌های شبکه، اسکرپینگ، ربات‌های تلگرام و دیسکورد.
  • نکته مهم: برای کارهای CPU-bound، بهتر است از multiprocessing یا asyncio.to_thread() استفاده کنید.

برنامه‌نویسی ناهمزمان چیست؟ چرا به آن نیاز داریم؟

آموزش asyncio در پایتون — برنامه‌نویسی ناهمزمان — تصویر 3

در دنیای برنامه‌نویسی، کدهای ما معمولاً به‌صورت “همزمان” (Synchronous) اجرا می‌شوند؛ یعنی هر خط کد منتظر می‌ماند تا خط قبلی کاملاً تمام شود و سپس نوبت به خودش برسد. این مدل برای بسیاری از برنامه‌ها عالی کار می‌کند، اما در سناریوهایی که عملیات‌های زمان‌بر داریم (مثل درخواست‌های شبکه، دسترسی به فایل یا پایگاه داده)، می‌تواند منجر به کندی و عدم پاسخگویی برنامه شود.

تصور کنید یک سرور وب دارید که هر درخواست کاربر را به‌صورت همزمان پردازش می‌کند. اگر یک درخواست نیاز به ۱۰ ثانیه زمان داشته باشد (مثلاً برای واکشی داده از یک API دیگر)، سرور مجبور است برای این ۱۰ ثانیه کاملاً بیکار بماند و نمی‌تواند به درخواست‌های دیگر پاسخ دهد. اینجاست که برنامه‌نویسی ناهمزمان (Asynchronous Programming) وارد میدان می‌شود.

برنامه‌نویسی ناهمزمان به برنامه اجازه می‌دهد تا در حین انتظار برای تکمیل یک عملیات زمان‌بر (معمولاً عملیات‌های I/O-bound)، به سراغ کارهای دیگر برود. به‌جای مسدود کردن تمام برنامه، عملیات ناهمزمان “منتظر” می‌ماند و وقتی کارش تمام شد، برنامه از جایی که رها کرده بود، ادامه می‌دهد. این کار باعث می‌شود برنامه پاسخگو باقی بماند و بتواند در یک زمان به تعداد بیشتری از درخواست‌ها رسیدگی کند، که نتیجه آن کارایی و مقیاس‌پذیری بالاتر است.

آشنایی با مفاهیم کلیدی در asyncio

Asyncio بر پایه چند مفهوم اصلی بنا شده است که درک آن‌ها برای کار با این کتابخانه حیاتی است:

1. حلقه رویداد (Event Loop)

قلب هر برنامه asyncio، حلقه رویداد است. این حلقه به عنوان مدیر ارکستر برنامه‌ی ناهمزمان شما عمل می‌کند و وظیفه زمان‌بندی و اجرای کوروتین‌ها، نظارت بر عملیات‌های I/O و توزیع رویدادها را برعهده دارد. حلقه رویداد یک ترد (thread) واحد دارد و کارهای ناهمزمان را به‌صورت نوبتی مدیریت می‌کند.

2. کوروتین (Coroutine)

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


import asyncio

async def my_coroutine():
    print("شروع کوروتین")
    await asyncio.sleep(1) # شبیه‌سازی یک عملیات I/O زمان‌بر
    print("پایان کوروتین")

# برای اجرای یک کوروتین:
# asyncio.run(my_coroutine())

3. async و await

  • async: این کلمه کلیدی برای تعریف یک تابع کوروتین استفاده می‌شود (async def my_function():).
  • await: این کلمه کلیدی فقط داخل توابع async قابل استفاده است. با await می‌توانید اجرای یک کوروتین دیگر را به حالت تعلیق درآورید تا زمانی که آن کوروتین کارش را تمام کند یا خودش را به حالت تعلیق درآورد (مثلاً با await asyncio.sleep(1)). در این مدت، حلقه رویداد می‌تواند کارهای دیگر را انجام دهد.

4. تسک (Task)

یک Task روشی برای اجرای کوروتین‌ها به‌صورت “پس‌زمینه” در حلقه رویداد است. وقتی یک کوروتین را به یک Task تبدیل می‌کنید، حلقه رویداد آن را زمان‌بندی کرده و اجرا می‌کند. این امکان را می‌دهد که چندین کوروتین به‌طور همزمان (با مفهوم ناهمزمان) در حال اجرا باشند.


import asyncio

async def worker():
    print("کارگر مشغول است...")
    await asyncio.sleep(2)
    print("کارگر کارش تمام شد.")

async def main():
    task = asyncio.create_task(worker()) # ایجاد یک تسک
    print("کارهای دیگری انجام می‌دهم.")
    await asyncio.sleep(1)
    print("منتظر اتمام کارگر هستم.")
    await task # منتظر اتمام تسک می‌مانم

asyncio.run(main())

5. فیوچر (Future)

یک Future آبجکتی است که نتیجه یک عملیات ناهمزمان را در آینده (وقتی کامل شود) نشان می‌دهد. Taskها زیرمجموعه‌ای از Futureها هستند. معمولاً نیازی به کار مستقیم با Futureها نیست و Taskها بیشتر مورد استفاده قرار می‌گیرند.

شروع کار با asyncio: اولین کوروتین شما

برای شروع، بیایید یک مثال ساده از یک کوروتین ایجاد و اجرا کنیم.


import asyncio

# تعریف یک تابع کوروتین
async def hello_world():
    print("سلام")
    await asyncio.sleep(1) # منتظر می‌ماند اما CPU را بلاک نمی‌کند
    print("دنیا!")

# اجرای کوروتین با asyncio.run()
# این تابع یک حلقه رویداد جدید ایجاد کرده، کوروتین را اجرا می‌کند و پس از اتمام آن را می‌بندد.
asyncio.run(hello_world())

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

اجرای چندین عملیات ناهمزمان به‌صورت موازی

یکی از قدرت‌های اصلی asyncio، توانایی آن در مدیریت چندین عملیات ناهمزمان به‌طور “همزمان” است. برای این کار از asyncio.create_task() و asyncio.gather() استفاده می‌کنیم.


import asyncio
import time

async def fetch_data(delay, data):
    print(f"شروع واکشی {data} با تاخیر {delay} ثانیه...")
    await asyncio.sleep(delay)
    print(f"پایان واکشی {data}")
    return f"داده {data} واکشی شد."

async def main():
    start_time = time.time()

    # ایجاد تسک‌ها برای اجرای همزمان
    task1 = asyncio.create_task(fetch_data(3, "کاربران"))
    task2 = asyncio.create_task(fetch_data(1, "محصولات"))
    task3 = asyncio.create_task(fetch_data(2, "سفارشات"))

    print("تمام تسک‌ها ایجاد شدند، منتظر نتیجه هستیم...")

    # await asyncio.gather() منتظر می‌ماند تا تمام تسک‌های داده شده کامل شوند.
    results = await asyncio.gather(task1, task2, task3)

    print("nتمام نتایج:")
    for res in results:
        print(res)

    end_time = time.time()
    print(f"nکل زمان اجرا: {end_time - start_time:.2f} ثانیه")

asyncio.run(main())

اگر این کد را اجرا کنید، خواهید دید که با وجود تاخیرهای ۳، ۱ و ۲ ثانیه‌ای، کل برنامه در حدود ۳ ثانیه (زمان طولانی‌ترین عملیات) اجرا می‌شود، نه مجموع آن‌ها (۶ ثانیه). این نشان‌دهنده کارایی بالای asyncio در عملیات‌های I/O-bound است.

کار با I/O ناهمزمان (مثال عملی)

در مثال‌های قبل، از asyncio.sleep() برای شبیه‌سازی عملیات I/O استفاده کردیم. در دنیای واقعی، عملیات I/O شامل درخواست‌های شبکه (HTTP، Socket)، دسترسی به دیسک (فایل) و پایگاه داده می‌شود.

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

  • درخواست‌های HTTP: از aiohttp استفاده کنید (به‌جای requests).
  • پایگاه داده: از درایورهای ناهمزمان مانند aiomysql، asyncpg یا motor (برای MongoDB) استفاده کنید.
  • عملیات فایل: پایتون از aiofiles برای دسترسی ناهمزمان به فایل‌ها پشتیبانی می‌کند.

**مثال: واکشی چندین URL با aiohttp**


# ابتدا aiohttp را نصب کنید: pip install aiohttp

import asyncio
import aiohttp
import time

async def fetch_url(session, url):
    async with session.get(url) as response:
        return await response.text()

async def main():
    urls = [
        "https://jsonplaceholder.typicode.com/posts/1",
        "https://jsonplaceholder.typicode.com/todos/1",
        "https://jsonplaceholder.typicode.com/users/1"
    ]
    
    start_time = time.time()
    
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_url(session, url) for url in urls]
        responses = await asyncio.gather(*tasks)
        
        for i, response_text in enumerate(responses):
            print(f"محتوای URL {i+1} (بخش اول): {response_text[:50]}...") # نمایش 50 کاراکتر اول
            
    end_time = time.time()
    print(f"nکل زمان واکشی: {end_time - start_time:.2f} ثانیه")

asyncio.run(main())

در این مثال، هر سه درخواست HTTP تقریباً به‌طور همزمان آغاز شده و در زمان نزدیک به هم (تقریباً به اندازه طولانی‌ترین درخواست) تکمیل می‌شوند. این تفاوت عمده‌ای با اجرای سنکرون دارد که در آن هر درخواست باید منتظر تکمیل درخواست قبلی بماند.

مدیریت استثناءها و زمان‌بندی (Timeouts)

مدیریت خطاها در asyncio همانند کدهای سنکرون با try...except انجام می‌شود. اما یک مفهوم مهم دیگر، مدیریت زمان‌بندی (Timeouts) است که برای جلوگیری از توقف بی‌نهایت برنامه در انتظار یک عملیات ضروری است.

مدیریت استثناءها

اگر یک استثناء در یک کوروتین اتفاق بیفتد و مدیریت نشود، به کوروتین فراخواننده (آن که آن را await کرده) منتقل می‌شود. اگر در یک Task اتفاق بیفتد و await نشود، asyncio آن را به عنوان یک خطای کنترل‌نشده در حلقه رویداد گزارش می‌کند.


import asyncio

async def might_fail():
    await asyncio.sleep(1)
    raise ValueError("این یک خطای عمدی است!")

async def main_with_error_handling():
    try:
        await might_fail()
    except ValueError as e:
        print(f"یک خطا رخ داد: {e}")

asyncio.run(main_with_error_handling())

مدیریت زمان‌بندی (Timeouts)

برای تعیین حداکثر زمان انتظار برای یک عملیات، از asyncio.wait_for() استفاده می‌کنیم. این تابع اگر عملیات در زمان مشخص شده کامل نشود، یک asyncio.TimeoutError ایجاد می‌کند.


import asyncio

async def long_running_task():
    print("شروع وظیفه طولانی...")
    await asyncio.sleep(5) # این وظیفه 5 ثانیه طول می‌کشد
    print("وظیفه طولانی کامل شد.")
    return "نتیجه موفقیت‌آمیز"

async def main_with_timeout():
    try:
        # منتظر long_running_task می‌ماند، حداکثر 2 ثانیه
        result = await asyncio.wait_for(long_running_task(), timeout=2)
        print(f"نتیجه: {result}")
    except asyncio.TimeoutError:
        print("خطا: زمان انتظار به پایان رسید!")
    except Exception as e:
        print(f"خطای دیگری رخ داد: {e}")

asyncio.run(main_with_timeout())

در این مثال، long_running_task هرگز به پایان نمی‌رسد، زیرا main_with_timeout فقط 2 ثانیه منتظر می‌ماند و سپس یک asyncio.TimeoutError پرتاب می‌کند.

استفاده از asyncio در وب (فریم‌ورک‌های محبوب)

Asyncio به دلیل کارایی بالا در مدیریت عملیات‌های I/O، در فریم‌ورک‌های مدرن وب پایتون بسیار محبوب شده است. این فریم‌ورک‌ها می‌توانند همزمان به هزاران درخواست پاسخ دهند، که آن‌ها را برای ساخت APIهای با کارایی بالا و وب‌سایت‌های مقیاس‌پذیر ایده‌آل می‌کند.

  • FastAPI: یک فریم‌ورک وب مدرن و سریع برای ساخت APIها با پایتون ۳.۷+ بر پایه Starlette و Pydantic. از async/await به‌طور کامل پشتیبانی می‌کند و با استفاده از تایپ‌هینت‌ها، اعتبارسنجی داده و مستندات خودکار را فراهم می‌آورد.
  • Aiohttp: هم یک کلاینت HTTP ناهمزمان و هم یک فریم‌ورک وب سرور برای asyncio است. برای پروژه‌هایی که به انعطاف‌پذیری و کنترل بیشتری در سطح پایین نیاز دارند، مناسب است.
  • Sanic: یک فریم‌ورک وب بسیار سریع که برای ارائه‌ی عملکرد سریع طراحی شده است. از نحو async/await پشتیبانی می‌کند و با uvloop ترکیب شده تا سرعت خارق‌العاده‌ای را ارائه دهد.

تفاوت asyncio با Threading و Multiprocessing

همزمانی (Concurrency) در پایتون به سه روش اصلی قابل دستیابی است: asyncio، Threading و Multiprocessing. هر کدام برای سناریوهای خاصی بهینه هستند:

ویژگی Asyncio Threading Multiprocessing
مدل همزمانی تک‌رشته‌ای، تک‌پردازشی، بر اساس حلقه رویداد و کوروتین‌ها. چند رشته‌ای (Multiple threads) در یک پردازش. چند پردازشی (Multiple processes)، هر پردازش دارای رشته‌های خود.
بهترین برای عملیات‌های I/O-bound (شبکه، دیسک، پایگاه داده). عملیات‌های I/O-bound با نیاز به کد legacy یا سادگی بیشتر. عملیات‌های CPU-bound (پردازش‌های سنگین محاسباتی).
GIL (Global Interpreter Lock) تحت تأثیر GIL قرار نمی‌گیرد زیرا تک‌رشته‌ای است. به‌شدت تحت تأثیر GIL قرار می‌گیرد، عملکرد CPU-bound را محدود می‌کند. GIL را دور می‌زند زیرا هر پردازش GIL خود را دارد.
اشتراک‌گذاری حافظه آسان (همه کوروتین‌ها یک فضای حافظه را به اشتراک می‌گذارند). آسان (تمام رشته‌ها یک فضای حافظه را به اشتراک می‌گذارند). پیچیده (نیاز به مکانیسم‌های IPC مانند صف‌ها یا حافظه مشترک).
پیچیدگی کد نیاز به درک مدل async/await، نیاز به کتابخانه‌های ناهمزمان. نسبتاً ساده، اما مدیریت Lock و Race Conditionها می‌تواند پیچیده باشد. پیچیده‌تر به دلیل سربار ایجاد پردازش و ارتباط بین پردازشی.

در کل، اگر برنامه شما بیشتر درگیر انتظار برای عملیات‌های بیرونی (I/O) است، asyncio بهترین انتخاب است. اما اگر عملیات‌های سنگین محاسباتی دارید که تمام هسته‌های CPU را مشغول می‌کنند، Multiprocessing کارآمدتر خواهد بود.

نکات مهم برای بهینه‌سازی و بهترین شیوه‌ها

برای استفاده حداکثری از asyncio و جلوگیری از مشکلات رایج، رعایت نکات زیر ضروری است:

  • از عملیات‌های مسدودکننده (Blocking Operations) خودداری کنید: مهم‌ترین اصل در asyncio این است که هرگز نباید عملیات مسدودکننده (مانند یک حلقه بی‌نهایت یا فراخوانی تابع سنکرون requests.get()) را مستقیماً در یک کوروتین اجرا کنید، زیرا کل حلقه رویداد را مسدود می‌کند.
  • برای کارهای CPU-bound از asyncio.to_thread() استفاده کنید: اگر مجبور به اجرای عملیات‌های مسدودکننده یا CPU-bound هستید، آن‌ها را در یک Executor (معمولاً یک ThreadPoolExecutor) اجرا کنید. asyncio.to_thread() (پایتون ۳.۹+) این کار را برای شما ساده می‌کند.
  • کوروتین‌ها را به Task تبدیل کنید: برای اینکه یک کوروتین به‌صورت همزمان با کوروتین‌های دیگر اجرا شود، حتماً آن را با asyncio.create_task() به یک Task تبدیل کنید. صرفاً فراخوانی my_coroutine() یک کوروتین را ایجاد می‌کند، اما آن را زمان‌بندی نمی‌کند.
  • از async with برای مدیریت منابع استفاده کنید: برای منابعی که نیاز به بسته شدن دارند (مثل aiohttp.ClientSession یا فایل‌های ناهمزمان)، همیشه از async with استفاده کنید تا از آزادسازی صحیح منابع اطمینان حاصل کنید.
  • logging مناسب: استفاده از سیستم logging پایتون به شما کمک می‌کند تا جریان اجرای برنامه ناهمزمان خود را درک و اشکال‌زدایی کنید.

عیب‌یابی سریع (Troubleshooting)

مشکلات رایج و راه‌حل‌ها

  • خطا: RuntimeError: Event loop is already running

    توضیح: این خطا زمانی رخ می‌دهد که شما سعی می‌کنید asyncio.run() را در حالی که یک حلقه رویداد در حال اجراست (مثلاً در یک محیط interactive مانند Jupyter Notebook یا از یک تابع async def دیگر) فراخوانی کنید.

    راه‌حل:
    ۱. اگر در Jupyter هستید، از nest_asyncio استفاده کنید: import nest_asyncio; nest_asyncio.apply().
    ۲. اگر در حال فراخوانی از یک کوروتین دیگر هستید، به‌جای asyncio.run() از await کردن آن کوروتین یا asyncio.create_task() استفاده کنید.

  • خطا: TypeError: object X can't be used in 'await' expression

    توضیح: شما سعی کرده‌اید چیزی را await کنید که یک “awaitable” (یعنی یک کوروتین، تسک، فیوچر) نیست. معمولاً این اتفاق زمانی می‌افتد که شما یک تابع async def را فراخوانی کرده‌اید اما آن را await نکرده‌اید، یا سعی کرده‌اید یک تابع عادی (سنکرون) را await کنید.

    راه‌حل: اطمینان حاصل کنید که هر چیزی که بعد از await می‌آید، یک awaitable باشد. اگر یک تابع async def را فراخوانی می‌کنید، همیشه آن را await کنید. اگر یک تابع سنکرون است و مسدودکننده است، از asyncio.to_thread() استفاده کنید.

  • مشکل: کوروتین‌ها به‌صورت همزمان اجرا نمی‌شوند یا زمان‌بندی نادرست است.

    توضیح: احتمالاً کوروتین‌های خود را به Task تبدیل نکرده‌اید یا به‌درستی از asyncio.gather() استفاده نکرده‌اید.

    راه‌حل: اطمینان حاصل کنید که هر کوروتینی که می‌خواهید به‌صورت همزمان با دیگران اجرا شود، با asyncio.create_task() به یک Task تبدیل شده باشد و سپس با await asyncio.gather(*tasks) منتظر تمام آن‌ها بمانید.

پرسش‌های متداول (FAQ)

آیا asyncio می‌تواند از چندین هسته پردازشی استفاده کند؟

خیر، asyncio به طور پیش‌فرض روی یک هسته پردازشی (تک‌رشته‌ای) عمل می‌کند. دلیل آن GIL (Global Interpreter Lock) پایتون است. برای استفاده از چندین هسته، باید از multiprocessing استفاده کنید یا عملیات‌های CPU-bound خود را با asyncio.to_thread() به یک ترد جداگانه منتقل کنید.

تفاوت اصلی بین asyncio و Threading چیست؟

asyncio یک مدل همزمانی تک‌رشته‌ای با استفاده از یک حلقه رویداد است که برای عملیات‌های I/O-bound (انتظار برای رویدادهای خارجی) بهینه است. در حالی که Threading از رشته‌های سیستم عامل استفاده می‌کند و به دلیل GIL برای CPU-bound مناسب نیست، اما برای برخی I/O-bound ها می‌تواند مفید باشد.

آیا می‌توانم یک تابع سنکرون را در یک تابع async فراخوانی کنم؟

بله، اما اگر آن تابع سنکرون یک عملیات مسدودکننده (blocking) انجام دهد، کل حلقه رویداد را مسدود می‌کند. برای جلوگیری از این اتفاق، از await asyncio.to_thread(sync_function, *args) استفاده کنید تا تابع سنکرون در یک ترد جداگانه اجرا شود و حلقه رویداد بلاک نشود.

چه زمانی باید از asyncio استفاده کنم؟

وقتی که برنامه شما شامل تعداد زیادی عملیات I/O-bound می‌شود (مانند واکشی داده از وب‌سایت‌ها، کار با پایگاه داده، ساخت وب سرورهای با کارایی بالا یا سرویس‌های مبتنی بر سوکت). asyncio به برنامه شما اجازه می‌دهد تا در حین انتظار برای این عملیات‌ها، به کارهای دیگر بپردازد و کارایی کلی را افزایش دهد.

نتیجه‌گیری

Asyncio یک ابزار قدرتمند و انعطاف‌پذیر در پایتون برای ساخت برنامه‌های همزمان (concurrent) است، به‌ویژه برای آن‌هایی که با حجم بالایی از عملیات‌های I/O-bound سروکار دارند. با درک مفاهیم کلیدی مانند Event Loop، Coroutines، Tasks و استفاده صحیح از async و await، می‌توانید برنامه‌هایی بنویسید که نه تنها سریع‌تر و کارآمدتر هستند، بلکه مقیاس‌پذیریی بهتری هم دارند.

اگرچه منحنی یادگیری asyncio ممکن است در ابتدا کمی شیب‌دار به نظر برسد، اما با تمرین و استفاده از منابع و کتابخانه‌های مناسب، می‌توانید به سرعت در آن مسلط شوید و از مزایای بی‌شمار آن در پروژه‌های خود بهره‌مند شوید. فراموش نکنید که همیشه بهترین رویکرد این است که ابزار مناسب را برای کار مناسب انتخاب کنید. برای کارهای I/O-bound، asyncio یک گزینه عالی است.

[END_OF_ARTICLE]

Table of Contents

آخرین نوشته‌ها