وقتی یک درخواست به چند سرویس وابسته است

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

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

طراحی محدوده کار و بودجه زمانی

پیش از نوشتن کد، یک بودجه زمانی برای کل درخواست تعیین کنید. اگر رابط کاربر دو ثانیه فرصت دارد، سه retry دوثانیه‌ای در هر سرویس با این بودجه سازگار نیست. زمان انتظار اتصال، خواندن پاسخ، تلاش مجدد و پاک‌سازی همگی از همین بودجه مصرف می‌کنند. در مثال، asyncio.timeout دور کل گروه قرار می‌گیرد و Semaphore تعداد کارهایی را که هم‌زمان وارد قسمت محدودشده می‌شوند کنترل می‌کند. داده‌ها ساختگی‌اند تا نمونه بدون اینترنت اجرا شود.

لغو یک کار به معنی پایان فوری هر اثر جانبی نیست. کتابخانه HTTP باید timeout و بستن اتصال را پشتیبانی کند و کد پاک‌سازی در finally قرار گیرد. CancelledError را بعد از پاک‌سازی دوباره منتشر کنید؛ بلعیدن آن می‌تواند رفتار ساختاریافته گروه را مختل کند. کار CPU سنگین یا کتابخانه مسدودکننده را مستقیماً در حلقه رویداد اجرا نکنید. انتخاب thread یا process باید بر اساس نوع کار و اندازه‌گیری انجام شود.

برای آزمایش پروژه واقعی، سه وضعیت را از هم جدا کنید: همه سرویس‌ها موفق‌اند، یک سرویس کند است و یک سرویس خطا می‌دهد. فقط زمان پاسخ موفق را گزارش نکنید؛ تعداد اتصال‌های باز، کارهای نیمه‌تمام و نسبت timeout نیز مهم‌اند. یک تأخیر کنترل‌شده در محیط آزمایش نشان می‌دهد آیا واقعاً بودجه زمانی رعایت می‌شود. در لاگ، شناسه درخواست را ثبت کنید و محتوای محرمانه پاسخ سرویس‌ها را کنار بگذارید.

نمونه کد و روش بررسی

پیش‌نیاز و خروجی مورد انتظار در توضیح مثال آمده است. ابتدا نمونه را در محیط آزمایشی اجرا کنید و سپس آن را با داده و محدودیت‌های پروژه خود تطبیق دهید.

1. Python 3.11 یا جدیدتر؛ بدون بسته جانبی

import asyncio

async def fetch(name, gate):
    async with gate:
        await asyncio.sleep(0.05)
        return name.upper()

async def main():
    gate = asyncio.Semaphore(2)
    async with asyncio.timeout(1):
        async with asyncio.TaskGroup() as group:
            jobs = [group.create_task(fetch(name, gate))
                    for name in ("stock", "payment", "delivery")]
    print([job.result() for job in jobs])

asyncio.run(main())

فایل را با python اجرا کنید؛ خروجی باید STOCK، PAYMENT و DELIVERY باشد. برای مشاهده TimeoutError، زمان sleep را به 2 تغییر دهید. مثال خطای timeout را عمداً به فراخواننده منتقل می‌کند؛ در سرویس واقعی آن را در مرز درخواست به پاسخ مناسب تبدیل کنید.

2. خطای یکی از کارها و پاک‌سازی گروه

این فایل مستقل را به نام group_failure.py اجرا کنید. خطای وابستگی باید به فراخواننده برسد و کار کندتر نیز فرصت پاک‌سازی داشته باشد؛ بنابراین finally بخشی از رفتار درست است، نه صرفاً یک پیام اضافی.

import asyncio

async def slow():
    try:
        await asyncio.sleep(30)
    finally:
        print("slow task cleaned up")

async def broken():
    await asyncio.sleep(0.01)
    raise ValueError("dependency failed")

async def main():
    try:
        async with asyncio.TaskGroup() as group:
            group.create_task(slow())
            group.create_task(broken())
    except* ValueError as errors:
        print("handled errors:", len(errors.exceptions))

asyncio.run(main())

انتظار دو پیام پاک‌سازی و handled errors: 1 را داریم. except* فقط ValueError را رسیدگی می‌کند و خطاهای دیگر پنهان نمی‌شوند. در برنامه واقعی، بستن اتصال یا آزادکردن فایل را جایگزین پیام پاک‌سازی کنید.

3. timeout با نتیجه مشخص در مرز درخواست

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

import asyncio

async def load_summary():
    await asyncio.sleep(0.2)
    return {"status": "ready"}

async def handle_request():
    try:
        async with asyncio.timeout(0.05):
            return await load_summary()
    except TimeoutError:
        return {"status": "unavailable", "reason": "deadline"}

result = asyncio.run(handle_request())
assert result == {"status": "unavailable", "reason": "deadline"}
print(result)

خروجی باید reason برابر deadline داشته باشد. زمان بارگیری را به 0.01 تغییر دهید تا مسیر موفق را هم ببینید و assertion را با انتظار همان سناریو هماهنگ کنید. تکرار خودکار نامحدود داخل این محدوده اضافه نکنید.

4. صف محدود برای ورودی بزرگ؛ بدون ساخت هزاران task

Semaphore فقط کار فعال را محدود می‌کند؛ اگر برای یک میلیون ورودی یک میلیون task بسازید، حافظه همچنان مصرف می‌شود. این نسخه مستقل با دو worker و صف چهارعضوی، backpressure را به تولیدکننده منتقل می‌کند.

import asyncio

async def worker(queue, results):
    while True:
        item = await queue.get()
        try:
            if item is None:
                return
            await asyncio.sleep(0.001)
            results.append(item * 2)
        finally:
            queue.task_done()

async def main():
    queue = asyncio.Queue(maxsize=4)
    results = []
    async with asyncio.TaskGroup() as group:
        for _ in range(2):
            group.create_task(worker(queue, results))
        for item in range(10):
            await queue.put(item)
        for _ in range(2):
            await queue.put(None)
        await queue.join()
    assert sorted(results) == list(range(0, 20, 2))
    print("processed:", len(results))

asyncio.run(main())

خروجی processed: 10 است و ترتیب پایان کارها را نباید قرارداد نتیجه بدانید. برای ورودی نامحدود، خود results هم باید به خروجی جریانی تبدیل شود. خطای worker در TaskGroup به کل دامنه منتقل می‌شود؛ کار دارای اثر جانبی به سیاست تلاش مجدد و ثبت نتیجه مستقل نیاز دارد.

از نمونه آموزشی تا سرویس قابل اتکا

کار را با یک مسیر خواندنی و تعداد محدودی وابستگی شروع کنید. خروجی مورد انتظار، حداکثر زمان انتظار و سیاست نتیجه ناقص را پیشاپیش بنویسید. سپس محدودیت هم‌زمانی را با ظرفیت مقصد تنظیم کنید و تغییر آن را با بار واقعی بسنجید. این روش برای پروژه دانشجویی هم مفید است: دانشجو علاوه بر async و await، مالکیت کار، رفتار خطا و معیار پذیرش را یاد می‌گیرد؛ مهارت‌هایی که با زیادکردن ساده تعداد task به دست نمی‌آیند.

چک‌لیست اجرای عملی

  • Python 3.11 یا جدیدتر را بررسی کنید.
  • timeout کل درخواست و محدودیت هم‌زمانی را جدا تعیین کنید.
  • سناریوی خطا و لغو را در کنار موفقیت آزمایش کنید.
  • کارهای مسدودکننده و منابع باز را بررسی کنید.

منابع

تدوین و راهنمای عملی: دانشنامه لیان.