توسعه با asyncio

برنامه‌نویسی ناهمگام با برنامه‌نویسی «ترتیبی» کلاسیک متفاوت است.

این صفحه، اشتباهات و تله‌های رایج را فهرست می‌کند و نحوه‌ی اجتناب از آن‌ها را توضیح می‌دهد.

حالت اشکال‌زدایی

به‌طور پیش‌فرض، asyncio در حالت تولید اجرا می‌شود. برای سهولت در توسعه، asyncio یک حالت اشکال‌زدایی دارد.

راه‌های متعددی برای فعال کردن حالت اشکال‌زدایی asyncio وجود دارد:

علاوه بر فعال کردن حالت اشکال‌زدایی، این موارد را نیز در نظر داشته باشید:

  • برای تنظیم سطح گزارشِ asyncio logger روی logging.DEBUG، می‌توان برای مثال قطعه‌کد زیر را در زمان راه‌اندازی برنامه اجرا کرد:

    logging.basicConfig(level=logging.DEBUG)
    
  • پیکربندی ماژول warnings برای نمایش هشدارهای ResourceWarning. یکی از راه‌های انجام این کار، استفاده از گزینه‌ی خط فرمان -W default است.

هنگامی که حالت اشکال‌زدایی فعال است:

  • بسیاری از APIهای asyncio که غیرایمن از نظر نخ هستند (مانند متدهای loop.call_soon() و loop.call_at())، در صورت فراخوانی از یک نخ نادرست، استثنایی را پرتاب می‌کنند.

  • زمان اجرای انتخابگر I/O، در صورتی که انجام یک عملیات I/O بیش از حد طول بکشد، ثبت می‌شود.

  • کال‌بک‌هایی که بیش از ۱۰۰ میلی‌ثانیه طول می‌کشند، ثبت می‌شوند. ویژگی loop.slow_callback_duration را می‌توان برای تنظیم حداقل مدت زمان اجرا بر حسب ثانیه که «کند» محسوب می‌شود، استفاده کرد.

هم‌روندی و چندنخی

یک حلقه رویداد در یک نخ (معمولاً نخ اصلی) اجرا می‌شود و تمام کال‌بک‌ها و Taskها را در نخ خود اجرا می‌کند. در حالی که یک Task در حلقه رویداد در حال اجرا است، هیچ Task دیگری نمی‌تواند در همان نخ اجرا شود. هنگامی که یک Task یک عبارت await را اجرا می‌کند، Task در حال اجرا معلق می‌شود و حلقه رویداد Task بعدی را اجرا می‌کند.

برای زمان‌بندی یک کال‌بک از یک نخ دیگر سیستم‌عامل، باید از متد loop.call_soon_threadsafe() استفاده شود. مثال:

loop.call_soon_threadsafe(callback, *args)

تقریباً همه‌ی اشیای asyncio ایمن از نظر نخ نیستند، که این موضوع معمولاً مشکلی ایجاد نمی‌کند، مگر اینکه کدی از خارج از یک Task یا کال‌بک با آن‌ها کار کند. اگر لازم باشد چنین کدی یک API سطح پایین asyncio را فراخوانی کند، باید از متد loop.call_soon_threadsafe() استفاده شود، برای مثال:

loop.call_soon_threadsafe(fut.cancel)

برای زمان‌بندی یک شیء هم‌روال از یک نخ سیستم‌عاملی دیگر، باید از تابع run_coroutine_threadsafe() استفاده شود. این تابع یک concurrent.futures.Future برای دسترسی به نتیجه برمی‌گرداند:

async def coro_func():
     return await asyncio.sleep(1, 42)

# Later in another OS thread:

future = asyncio.run_coroutine_threadsafe(coro_func(), loop)
# Wait for the result:
result = future.result()

برای مدیریت سیگنال‌ها، حلقه رویداد باید در نخ اصلی اجرا شود.

می‌توان از متد loop.run_in_executor() به‌همراه یک concurrent.futures.ThreadPoolExecutor یا InterpreterPoolExecutor برای اجرای کد مسدودکننده در یک نخ سیستم‌عامل دیگر، بدون مسدود کردن نخ سیستم‌عاملی که حلقه رویداد در آن اجرا می‌شود، استفاده کرد.

در حال حاضر هیچ راهی برای زمان‌بندی هم‌روال‌ها یا کال‌بک‌ها مستقیماً از یک فرایند دیگر وجود ندارد (مانند فرایندی که با multiprocessing شروع شده است). بخش متدهای حلقه رویداد APIهایی را فهرست می‌کند که می‌توانند بدون مسدود کردن حلقه رویداد، از پایپ‌ها بخوانند و توصیف‌گرهای پرونده را پایش کنند. علاوه بر این، APIهای زیرفرایند در asyncio راهی برای شروع یک فرایند و برقراری ارتباط با آن از حلقه رویداد فراهم می‌کنند. در نهایت، متد یادشده loop.run_in_executor() را می‌توان نیز با یک concurrent.futures.ProcessPoolExecutor برای اجرای کد در یک فرایند دیگر به‌کار برد.

اجرای کد مسدودکننده

کد مسدودکننده (CPU-bound) نباید به‌صورت مستقیم فراخوانی شود. برای مثال، اگر تابعی یک محاسبه‌ی سنگین از نظر CPU را به مدت ۱ ثانیه انجام دهد، تمام Taskهای همزمان asyncio و عملیات‌های IO به اندازه‌ی ۱ ثانیه به تأخیر می‌افتند.

می‌توان از یک اجراکننده (executor) برای اجرای یک تکلیف در نخی دیگر، از جمله در مفسری دیگر، یا حتی در فرایندی دیگر استفاده کرد تا از مسدود شدن نخ سیستم‌عاملِ حلقه رویداد جلوگیری شود. برای جزئیات بیشتر، متد loop.run_in_executor() را ببینید.

گزارش‌گیری

asyncio از ماژول logging استفاده می‌کند و تمام گزارش‌گیری از طریق گزارش‌گیر "asyncio" انجام می‌شود.

سطح گزارش پیش‌فرض logging.INFO است که به‌سادگی می‌توان آن را تنظیم کرد:

logging.getLogger("asyncio").setLevel(logging.WARNING)

گزارش‌گیری شبکه می‌تواند حلقه رویداد را مسدود کند. توصیه می‌شود برای پردازش گزارش‌ها از یک نخ جداگانه استفاده کنید یا از ورودی/خروجی غیرمسدودکننده (non-blocking IO) استفاده کنید. برای مثال، مدیریت هندلرهایی که مسدود می‌کنند را ببینید.

تشخیص هم‌روال‌هایی که هرگز await نشده‌اند

هنگامی که یک تابع هم‌روال فراخوانی می‌شود، اما await نمی‌شود (برای مثال coro() به جای await coro()) یا هم‌روال با asyncio.create_task() زمان‌بندی نمی‌شود، asyncio یک RuntimeWarning را نشان میدهد:

import asyncio

async def test():
    print("never scheduled")

async def main():
    test()

asyncio.run(main())

خروجی:

test.py:7: RuntimeWarning: coroutine 'test' was never awaited
  test()

خروجی در حالت اشکال‌زدایی:

test.py:7: RuntimeWarning: coroutine 'test' was never awaited
Coroutine created at (most recent call last)
  File "../t.py", line 9, in <module>
    asyncio.run(main(), debug=True)

  < .. >

  File "../t.py", line 7, in main
    test()
  test()

راه‌حل معمول این است که یا هم‌روال را await کنید یا تابع asyncio.create_task() را فراخوانی کنید:

async def main():
    await test()

تشخیص استثناهای هرگز بازیابی‌نشده

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

مثالی از یک استثنای مدیریت‌نشده:

import asyncio

async def bug():
    raise Exception("not consumed")

async def main():
    asyncio.create_task(bug())

asyncio.run(main())

خروجی:

Task exception was never retrieved
future: <Task finished coro=<bug() done, defined at test.py:3>
  exception=Exception('not consumed')>

Traceback (most recent call last):
  File "test.py", line 4, in bug
    raise Exception("not consumed")
Exception: not consumed

حالت اشکال‌زدایی را فعال کنید تا ردگیری پشته‌ی محل ایجاد وظیفه را دریافت کنید:

asyncio.run(main(), debug=True)

خروجی در حالت اشکال‌زدایی:

Task exception was never retrieved
future: <Task finished coro=<bug() done, defined at test.py:3>
    exception=Exception('not consumed') created at asyncio/tasks.py:321>

source_traceback: Object created at (most recent call last):
  File "../t.py", line 9, in <module>
    asyncio.run(main(), debug=True)

< .. >

Traceback (most recent call last):
  File "../t.py", line 4, in bug
    raise Exception("not consumed")
Exception: not consumed

بهترین شیوه‌های تولیدگرهای ناهمگام

نوشتن کد asyncio صحیح و کارآمد، نیازمند آگاهی از برخی دام‌ها است. این بخش بهترین شیوه‌های ضروری را شرح می‌دهد که می‌تواند شما را از ساعت‌ها اشکال‌زدایی نجات دهد.

تولیدگرهای ناهمگام را به‌صورت صریح ببندید

توصیه می‌شود تولیدگر ناهمگام را به‌صورت دستی ببندید. اگر یک تولیدگر زودتر خارج شود، برای مثال، به دلیل پرتاب شدن یک استثنا در بدنه‌ی یک حلقه async for، ممکن است کد پاک‌سازی ناهمگام آن در زمینه‌ای غیرمنتظره اجرا شود. این ممکن است پس از کامل شدن وظایفی که به آن‌ها وابسته است، یا در حین خاموش شدن حلقه‌ی رویداد، هنگامی که قلاب زباله‌روبی تولیدگر ناهمگام فراخوانی می‌شود، رخ دهد.

برای جلوگیری از این موضوع، تولیدگر را با فراخوانی متد aclose() آن به‌صراحت ببندید، یا از مدیر زمینه contextlib.aclosing() استفاده کنید:

import asyncio
import contextlib

async def gen():
  yield 1
  yield 2

async def func():
  async with contextlib.aclosing(gen()) as g:
    async for x in g:
      break  # Don't iterate until the end

asyncio.run(func())

همان‌طور که در بالا ذکر شد، کد پاک‌سازی برای این تولیدگرهای ناهمگام به تعویق می‌افتد. مثال زیر نشان می‌دهد که نهایی‌سازی یک تولیدگر ناهمگام می‌تواند با ترتیبی غیرمنتظره رخ دهد:

import asyncio
work_done = False

async def cursor():
    try:
        yield 1
    finally:
        assert work_done

async def rows():
    global work_done
    try:
        yield 2
    finally:
        await asyncio.sleep(0.1) # imitate some async work
        work_done = True


async def main():
    async for c in cursor():
        async for r in rows():
            break
        break

asyncio.run(main())

برای این مثال، خروجی زیر را دریافت می‌کنیم:

unhandled exception during asyncio.run() shutdown
task: <Task finished name='Task-3' coro=<<async_generator_athrow without __name__>()> exception=AssertionError()>
Traceback (most recent call last):
  File "example.py", line 6, in cursor
    yield 1
asyncio.exceptions.CancelledError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "example.py", line 8, in cursor
    assert work_done
           ^^^^^^^^^
AssertionError

تولیدگر ناهمگام cursor() پیش از تولیدگر rows نهایی شد - رفتاری غیرمنتظره.

این مثال را می‌توان با بستن صریح تولیدگرهای ناهمگام cursor و rows اصلاح کرد:

async def main():
    async with contextlib.aclosing(cursor()) as cursor_gen:
        async for c in cursor_gen:
            async with contextlib.aclosing(rows()) as rows_gen:
                async for r in rows_gen:
                    break
            break

تولیدگرهای ناهمگام را فقط زمانی ایجاد کنید که حلقه رویداد در حال اجرا است

توصیه می‌شود تولیدگرهای ناهمگام تنها پس از ایجاد حلقه رویداد ایجاد شوند.

برای اطمینان از اینکه تولیدگرهای ناهمگام به‌طور قابل‌اطمینان بسته شوند، حلقه رویداد از تابع sys.set_asyncgen_hooks() برای ثبت توابع کال‌بک استفاده می‌کند. این کال‌بک‌ها فهرست تولیدگرهای ناهمگام در حال اجرا را به‌روزرسانی می‌کنند تا آن را در وضعیت سازگار نگه دارند.

هنگامی که تابع loop.shutdown_asyncgens() فراخوانی می‌شود، تولیدگرهای در حال اجرا به‌صورت مناسب متوقف می‌شوند و فهرست پاک می‌شود.

تولیدگر ناهمگام، قلاب سیستم مربوطه را در نخستین تکرار خود فراخوانی می‌کند. در همان زمان، تولیدگر ثبت می‌کند که قلاب فراخوانی شده است و دیگر آن را فراخوانی نمی‌کند.

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

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

import asyncio

async def agenfn():
    try:
        yield 10
    finally:
        await asyncio.sleep(0)


with asyncio.Runner() as runner:
    agen = agenfn()
    print(runner.run(anext(agen)))
    del agen

خروجی:

10
Exception ignored while closing generator <async_generator object agenfn at 0x000002F71CD10D70>:
Traceback (most recent call last):
  File "example.py", line 13, in <module>
    del agen
        ^^^^
RuntimeError: async generator ignored GeneratorExit

این مثال را می‌توان به‌صورت زیر اصلاح کرد:

import asyncio

async def agenfn():
    try:
        yield 10
    finally:
        await asyncio.sleep(0)

async def main():
    agen = agenfn()
    print(await anext(agen))
    del agen

asyncio.run(main())

از پیمایش و بستن همزمان یک تولیدگر یکسان خودداری کنید

تولیدگرهای ناهمگام ممکن است در حالی که فراخوانی دیگری از __anext__() یا athrow() یا aclose() در حال انجام است، دوباره وارد شوند. این امر ممکن است به وضعیت ناسازگار تولیدگر ناهمگام منجر شود و باعث ایجاد خطا شود.

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

import asyncio

async def consumer():
    for idx in range(100):
        await asyncio.sleep(0)
        message = yield idx
        print('received', message)

async def amain():
    agenerator = consumer()
    await agenerator.asend(None)

    fa = asyncio.create_task(agenerator.asend('A'))
    fb = asyncio.create_task(agenerator.asend('B'))
    await fa
    await fb

asyncio.run(amain())

خروجی:

received A
Traceback (most recent call last):
  File "test.py", line 38, in <module>
    asyncio.run(amain())
    ~~~~~~~~~~~^^^^^^^^^
  File "Lib/asyncio/runners.py", line 204, in run
    return runner.run(main)
           ~~~~~~~~~~^^^^^^
  File "Lib/asyncio/runners.py", line 127, in run
    return self._loop.run_until_complete(task)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
  File "Lib/asyncio/base_events.py", line 719, in run_until_complete
    return future.result()
           ~~~~~~~~~~~~~~~
  File "test.py", line 36, in amain
    await fb
RuntimeError: anext(): asynchronous generator is already running

بنابراین، توصیه می‌شود از استفاده از تولیدگرهای ناهمگام در وظایف موازی یا بین چندین حلقه رویداد خودداری کنید.