مدیریت خطا در asyncio

Please login to bookmark Close

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

چالش‌های منحصربه‌فرد asyncio در مدیریت خطا

asyncio در پایتون، با وجود سادگی ظاهری، چالش‌های خاص خود را در مدیریت خطا دارد. بیایید ابتدا این چالش‌ها را بشناسیم:

۱. ماهیت غیرخطی اجرا

در asyncio، چندین تسک به‌صورت هم‌روند در یک ترد واحد اجرا می‌شوند. این یعنی خطا در یک تسک، می‌تواند بر روی تسک‌های دیگر تأثیر بگذارد، مگر اینکه به‌درستی مدیریت شود.

۲. کنسل کردن (Cancellation)

asyncio امکان کنسل کردن تسک‌ها را فراهم می‌کند، اما این کار نیازمند مدیریت دقیق است. یک تسک کنسل‌شده، یک استثنای خاص به نام CancelledError پرتاب می‌کند که باید به‌درستی مدیریت شود.

۳. مدیریت استثناها در تسک‌های پس‌زمینه

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

۴. پیچیدگی دیباگ کردن

به دلیل ماهیت غیرخطی asyncio، ردیابی خطاها و دیباگ کردن آنها می‌تواند چالش‌برانگیزتر از کدهای ترتیبی باشد.

الگوی Timeout در asyncio

asyncio ابزارهای بسیار قدرتمندی برای مدیریت Timeout ارائه می‌دهد. برخلاف threading، در asyncio می‌توانید به‌راحتی یک تسک را پس از گذشت زمان مشخص، کنسل کنید.

روش اول: استفاده از asyncio.wait_for

سناریوی واقعی: یک اپلیکیشن آب‌وهوا دارید که اطلاعات لحظه‌ای را از یک API خارجی دریافت می‌کند. اگر API بیش از ۳ ثانیه پاسخ ندهد، می‌خواهید به کاربر پیام “سرویس در دسترس نیست” نمایش دهید و از داده‌های کش شده استفاده کنید.

import asyncio
import random

async def fetch_weather_data(city):
    """شبیه‌سازی دریافت داده‌های آب‌وهوا از API"""
    print(f"fetching weather data for {city}...")
    delay = random.uniform(0.5, 5.0)
    await asyncio.sleep(delay)

    if random.random() < 0.2:
        raise ValueError(f"invalid data received for {city}")

    return {"city": city, "temperature": random.randint(15, 35)}

async def get_weather_with_timeout(city, timeout_seconds=3.0):
    try:
        result = await asyncio.wait_for(fetch_weather_data(city), timeout=timeout_seconds)
        print(f"weather data for {city} received")
        return result
    except asyncio.TimeoutError:
        print(f"timeout fetching weather for {city}")
        return {"city": city, "temperature": "unavailable", "source": "cache"}
    except ValueError as e:
        print(f"data error for {city}: {e}")
        return {"city": city, "temperature": "error", "source": "cache"}

async def main():
    cities = ["tehran", "london", "newyork", "tokyo"]
    tasks = [get_weather_with_timeout(city, 3.0) for city in cities]
    results = await asyncio.gather(*tasks)

    for result in results:
        print(f"{result['city']}: {result['temperature']}°C")

asyncio.run(main())

نکته مهم: asyncio.wait_for به‌صورت داخلی از asyncio.TimeoutError استفاده می‌کند. اگر تسک در زمان مشخص شده به پایان نرسد، این استثنا پرتاب می‌شود.

روش دوم: استفاده از asyncio.timeout (قابل‌استفاده از پایتون ۳.۱۱ به بعد)

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

پایتون ۳.۱۱ به بعد، یک API جدید برای مدیریت Timeout معرفی کرده است که تمیزتر و خواناتر است:

import asyncio
import random

async def get_user_info(user_id):
    await asyncio.sleep(random.uniform(0.2, 1.5))
    if random.random() < 0.1:
        raise ValueError("user service unavailable")
    return {"id": user_id, "name": f"user_{user_id}"}

async def get_inventory(product_id):
    await asyncio.sleep(random.uniform(0.2, 1.5))
    if random.random() < 0.1:
        raise ValueError("inventory service unavailable")
    return {"product": product_id, "stock": random.randint(0, 100)}

async def get_payment_status(order_id):
    await asyncio.sleep(random.uniform(0.2, 1.5))
    if random.random() < 0.1:
        raise ValueError("payment service unavailable")
    return {"order": order_id, "status": "approved"}

async def process_order(user_id, product_id, order_id):
    try:
        async with asyncio.timeout(2.0):
            user_task = asyncio.create_task(get_user_info(user_id))
            inventory_task = asyncio.create_task(get_inventory(product_id))
            payment_task = asyncio.create_task(get_payment_status(order_id))

            user, inventory, payment = await asyncio.gather(
                user_task, inventory_task, payment_task
            )

            print(f"order {order_id} processed successfully")
            return {"user": user, "inventory": inventory, "payment": payment}

    except TimeoutError:  # asyncio.timeout پرتاب می‌کند
        print(f"order {order_id} timed out")
        return {"status": "timeout", "order_id": order_id}
    except ValueError as e:
        print(f"order {order_id} failed: {e}")
        return {"status": "error", "order_id": order_id, "error": str(e)}

async def main():
    orders = [
        {"user": 1, "product": 101, "order": 1001},
        {"user": 2, "product": 102, "order": 1002},
        {"user": 3, "product": 103, "order": 1003},
    ]

    tasks = [process_order(o["user"], o["product"], o["order"]) for o in orders]
    results = await asyncio.gather(*tasks)

    for result in results:
        if result.get("status") == "timeout":
            print(f"order {result['order_id']}: timeout - please try again")
        elif result.get("status") == "error":
            print(f"order {result['order_id']}: error - {result['error']}")
        else:
            print(f"order {result['order_id']}: success")

asyncio.run(main())

روش سوم: استفاده از asyncio.wait_for با return_exceptions

سناریوی واقعی: یک سرویس مقایسه‌ی قیمت دارید که باید قیمت یک محصول را از ۵ فروشگاه مختلف دریافت کند. می‌خواهید حداکثر ۲ ثانیه صبر کنید و هر فروشگاهی که در این زمان پاسخ دهد را دریافت کنید. فروشگاه‌هایی که پاسخ نمی‌دهند را نادیده بگیرید.

در این روش، می‌توانید خطاها را به‌صورت یکجا مدیریت کنید:

import asyncio
import random

async def fetch_price_from_store(store_name, product_id):
    """شبیه‌سازی دریافت قیمت از یک فروشگاه"""
    print(f"fetching price from {store_name}...")
    delay = random.uniform(0.3, 3.0)
    await asyncio.sleep(delay)

    if random.random() < 0.3:
        raise ConnectionError(f"{store_name} is not responding")

    return {store_name: random.randint(50, 200)}

async def compare_prices(product_id, stores, max_wait=2.0):
    """مقایسه قیمت از چند فروشگاه با Timeout"""
    tasks = [fetch_price_from_store(store, product_id) for store in stores]

    try:
        results = await asyncio.wait_for(
            asyncio.gather(*tasks, return_exceptions=True),
            timeout=max_wait
        )
    except asyncio.TimeoutError:
        print("overall comparison timed out")
        return {}

    prices = {}
    for result in results:
        if isinstance(result, Exception):
            print(f"error fetching price: {result}")
        else:
            prices.update(result)

    return prices

async def main():
    stores = ["amazon", "ebay", "walmart", "bestbuy", "newegg"]
    product_id = "PROD-123"

    prices = await compare_prices(product_id, stores, max_wait=2.5)

    if prices:
        cheapest = min(prices, key=prices.get)
        print(f"\nbest price for {product_id}: ${prices[cheapest]} at {cheapest}")
        print(f"all prices: {prices}")
    else:
        print("no prices available")

asyncio.run(main())

روش چهارم: استفاده از asyncio.shield برای محافظت از تسک‌ها

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

asyncio.shield از یک تسک در برابر کنسل شدن محافظت می‌کند:

import asyncio
import random

async def send_verification_email(email):
    """شبیه‌سازی ارسال ایمیل تأیید"""
    print(f"sending verification email to {email}...")
    await asyncio.sleep(3.0)
    print(f"verification email sent to {email}")
    return "email_sent"

async def save_user_to_database(user_data):
    """شبیه‌سازی ذخیره‌سازی کاربر در دیتابیس"""
    print("saving user to database...")
    await asyncio.sleep(random.uniform(0.5, 1.5))

    if random.random() < 0.1:
        raise ValueError("database connection failed")

    print("user saved to database")
    return {"user_id": 12345}

async def register_user(email, username):
    """ثبت‌نام کاربر با محافظت از ارسال ایمیل"""
    user_data = {"email": email, "username": username}

    # ارسال ایمیل با محافظت از کنسل شدن
    email_task = asyncio.create_task(send_verification_email(email))
    shielded_email = asyncio.shield(email_task)

    try:
        # کل فرآیند حداکثر ۲ ثانیه زمان دارد
        async with asyncio.timeout(2.0):
            user_task = asyncio.create_task(save_user_to_database(user_data))
            user_result, email_result = await asyncio.gather(
                user_task, shielded_email, return_exceptions=True
            )

            if isinstance(user_result, Exception):
                raise user_result

            return {
                "status": "success",
                "user": user_result,
                "email": "sent" if email_result == "email_sent" else "pending"
            }

    except TimeoutError:
        print("registration timed out, but email will continue")
        # برداشتن محافظت از ایمیل برای ادامه‌ی ارسال
        email_task = asyncio.create_task(send_verification_email(email))
        return {
            "status": "timeout",
            "email": "sending_in_background"
        }
    except Exception as e:
        print(f"registration failed: {e}")
        return {"status": "error", "error": str(e)}

async def main():
    result = await register_user("user@example.com", "john_doe")
    print(f"\nregistration result: {result}")

    # صبر می‌کنیم تا ایمیل در پس‌زمینه ارسال شود
    await asyncio.sleep(1.0)
    print("main program continues...")

asyncio.run(main())

الگوی Retry در asyncio

پیاده‌سازی Retry در asyncio بسیار شبیه به حالت عادی است، اما به‌جای time.sleep از asyncio.sleep استفاده می‌کنیم.

Retry با تأخیر ثابت (Fixed Delay)

سناریوی واقعی: یک ربات تلگرام دارید که پیام‌ها را به کاربران ارسال می‌کند. گاهی API تلگرام به‌دلیل محدودیت نرخ درخواست (Rate Limit)، خطای موقت برمی‌گرداند. می‌خواهید در صورت بروز این خطا، تا ۳ بار با فاصله‌ی ۱ ثانیه دوباره تلاش کنید.

import asyncio
import random

async def send_telegram_message(chat_id, message):
    """شبیه‌سازی ارسال پیام به تلگرام"""
    print(f"sending message to {chat_id}...")
    await asyncio.sleep(random.uniform(0.1, 0.5))

    if random.random() < 0.5:
        raise ValueError("rate limit exceeded")

    print(f"message sent to {chat_id}")
    return f"message_{chat_id}_sent"

async def retry_fixed_delay(operation, *args, max_retries=3, delay=1.0):
    for attempt in range(max_retries):
        try:
            print(f"attempt {attempt + 1} for {args[0]}")
            result = await operation(*args)
            return result
        except Exception as e:
            print(f"attempt {attempt + 1} failed: {e}")
            if attempt == max_retries - 1:
                print(f"all attempts failed for {args[0]}")
                raise
            print(f"waiting {delay} seconds before retry...")
            await asyncio.sleep(delay)

async def main():
    messages = [
        {"chat_id": 1001, "text": "hello user 1"},
        {"chat_id": 1002, "text": "hello user 2"},
        {"chat_id": 1003, "text": "hello user 3"},
    ]

    tasks = []
    for msg in messages:
        task = retry_fixed_delay(
            send_telegram_message, 
            msg["chat_id"], 
            msg["text"],
            max_retries=3,
            delay=1.0
        )
        tasks.append(task)

    results = await asyncio.gather(*tasks, return_exceptions=True)

    for result in results:
        if isinstance(result, Exception):
            print(f"message failed: {result}")
        else:
            print(f"message success: {result}")

asyncio.run(main())

Retry با تأخیر تصاعدی (Exponential Backoff)

سناریوی واقعی: یک سرویس پردازش ویدیو دارید که با API یوتیوب ارتباط برقرار می‌کند. یوتیوب گاهی با خطای “Too Many Requests” پاسخ می‌دهد. با Exponential Backoff، در صورت خطا، مدت انتظار را افزایش می‌دهید تا به API فشار نیاورید.

import asyncio
import random

async def upload_video_to_youtube(video_id):
    """شبیه‌سازی آپلود ویدیو به یوتیوب"""
    print(f"uploading video {video_id} to youtube...")
    await asyncio.sleep(random.uniform(0.3, 1.0))

    if random.random() < 0.4:
        raise ConnectionError("youtube API returned 429 Too Many Requests")

    print(f"video {video_id} uploaded successfully")
    return {"video_id": video_id, "status": "uploaded"}

async def retry_exponential_backoff(operation, *args, max_retries=5, base_delay=1.0):
    for attempt in range(max_retries):
        try:
            print(f"attempt {attempt + 1} for {args[0]}")
            result = await operation(*args)
            print(f"attempt {attempt + 1} succeeded")
            return result
        except Exception as e:
            print(f"attempt {attempt + 1} failed: {e}")
            if attempt == max_retries - 1:
                print("all attempts failed - youtube service might be down")
                raise

            # محاسبه‌ی تأخیر تصاعدی با Jitter
            delay = base_delay * (2 ** attempt)
            jitter = random.uniform(0, delay * 0.2)
            total_delay = delay + jitter
            print(f"waiting {total_delay:.2f} seconds before retry...")
            await asyncio.sleep(total_delay)

async def main():
    videos = ["vid_001", "vid_002", "vid_003", "vid_004"]
    tasks = []

    for vid in videos:
        task = retry_exponential_backoff(
            upload_video_to_youtube,
            vid,
            max_retries=4,
            base_delay=1.0
        )
        tasks.append(task)

    results = await asyncio.gather(*tasks, return_exceptions=True)

    for result in results:
        if isinstance(result, Exception):
            print(f"upload failed: {result}")
        else:
            print(f"upload success: {result}")

asyncio.run(main())

Retry با شرط (Conditional Retry)

سناریوی واقعی: یک سیستم پردازش صورتحساب دارید که باید فاکتورها را از یک سرویس مالی دریافت کند. اگر خطای مربوط به “Invalid Token” رخ دهد، دیگر تلاش مجدد فایده‌ای ندارد (توکن باید تجدید شود). اما اگر خطای شبکه یا خطای “Service Unavailable” باشد، می‌توانید دوباره تلاش کنید.

import asyncio
import random

class RetryableError(Exception):
    """خطاهایی که قابل بازیابی هستند"""
    pass

class FatalError(Exception):
    """خطاهایی که نباید دوباره تلاش شوند"""
    pass

async def fetch_invoice(invoice_id):
    """شبیه‌سازی دریافت فاکتور از سرویس مالی"""
    rand = random.random()
    await asyncio.sleep(0.5)

    if rand < 0.2:
        raise RetryableError("service temporarily unavailable")
    elif rand < 0.4:
        raise FatalError("invalid authentication token")
    elif rand < 0.6:
        raise RetryableError("connection timeout")

    return f"invoice_{invoice_id}_data"

async def retry_on_specific_errors(operation, *args, max_retries=3):
    for attempt in range(max_retries):
        try:
            print(f"attempt {attempt + 1} for invoice {args[0]}")
            result = await operation(*args)
            print(f"attempt {attempt + 1} succeeded")
            return result
        except RetryableError as e:
            print(f"retryable error on attempt {attempt + 1}: {e}")
            if attempt == max_retries - 1:
                print("max retries reached, marking invoice as pending")
                raise
            delay = 0.5 * (2 ** attempt)
            await asyncio.sleep(delay)
        except FatalError as e:
            print(f"fatal error, stopping retry: {e}")
            print("authentication required")
            raise
        except Exception as e:
            print(f"unexpected error: {e}")
            raise

async def main():
    invoices = ["INV-001", "INV-002", "INV-003", "INV-004"]

    for invoice in invoices:
        try:
            result = await retry_on_specific_errors(
                fetch_invoice, 
                invoice, 
                max_retries=3
            )
            print(f"invoice {invoice}: {result}")
        except FatalError:
            print(f"invoice {invoice}: authentication needed")
        except RetryableError:
            print(f"invoice {invoice}: pending - will retry later")
        except Exception as e:
            print(f"invoice {invoice}: unexpected error: {e}")

asyncio.run(main())

ترکیب Timeout و Retry در asyncio

سناریوی واقعی: یک سرویس تبدیل ارز دارید که باید نرخ تبدیل لحظه‌ای را از یک API دریافت کند. API گاهی کند می‌شود. می‌خواهید تا ۳ بار تلاش کنید و هر بار حداکثر ۱.۵ ثانیه صبر کنید. اگر همه‌ی تلاش‌ها ناموفق بود، از نرخ تبدیل دیروز استفاده کنید.

import asyncio
import random

async def fetch_exchange_rate(from_currency, to_currency):
    """شبیه‌سازی دریافت نرخ تبدیل از API"""
    delay = random.uniform(0.5, 3.0)
    print(f"fetching rate from {from_currency} to {to_currency}...")
    await asyncio.sleep(delay)

    if random.random() < 0.25:
        raise ValueError("api returned invalid data")

    return random.uniform(0.5, 1.5)

async def get_exchange_rate_with_retry(from_curr, to_curr, max_retries=3, timeout_per_attempt=1.5):
    for attempt in range(max_retries):
        try:
            print(f"\nattempt {attempt + 1} for {from_curr}/{to_curr}")

            rate = await asyncio.wait_for(
                fetch_exchange_rate(from_curr, to_curr),
                timeout=timeout_per_attempt
            )

            print(f"attempt {attempt + 1} succeeded")
            return {"rate": rate, "source": "live"}

        except asyncio.TimeoutError:
            print(f"attempt {attempt + 1} timed out")
        except Exception as e:
            print(f"attempt {attempt + 1} failed with error: {e}")

        if attempt == max_retries - 1:
            print("all attempts failed - using cached rate")
            return {"rate": 1.0, "source": "cached"}

        # تأخیر تصاعدی بین تلاش‌ها
        delay = 0.3 * (2 ** attempt)
        print(f"waiting {delay:.2f} seconds before next attempt...")
        await asyncio.sleep(delay)

async def main():
    currencies = [
        ("USD", "EUR"),
        ("USD", "GBP"),
        ("EUR", "JPY"),
        ("USD", "IRR"),
    ]

    tasks = []
    for from_curr, to_curr in currencies:
        task = get_exchange_rate_with_retry(
            from_curr, 
            to_curr,
            max_retries=3,
            timeout_per_attempt=1.5
        )
        tasks.append(task)

    results = await asyncio.gather(*tasks)

    print("\n--- exchange rates ---")
    for i, (from_curr, to_curr) in enumerate(currencies):
        rate = results[i]
        source = rate.get("source", "unknown")
        value = rate.get("rate", 0)
        print(f"{from_curr}/{to_curr}: {value:.4f} ({source})")

asyncio.run(main())

مدیریت استثناها در تسک‌های asyncio

یکی از چالش‌های مهم در asyncio، مدیریت استثناهایی است که در تسک‌های پس‌زمینه رخ می‌دهند. اگر استثنا در یک تسک مدیریت نشود، آن تسک از کار می‌افتد اما Event Loop به کار خود ادامه می‌دهد.

روش اول: استفاده از asyncio.gather با return_exceptions

سناریوی واقعی: یک سیستم نظارت بر سرور دارید که هر ۳۰ ثانیه، وضعیت ۲۰ سرور را بررسی می‌کند. می‌خواهید اگر بررسی یک سرور با خطا مواجه شد، خطا را لاگ کنید اما بررسی سایر سرورها ادامه پیدا کند.

import asyncio
import random

async def check_server_health(server_name):
    """شبیه‌سازی بررسی سلامت یک سرور"""
    print(f"checking {server_name}...")
    await asyncio.sleep(random.uniform(0.2, 0.8))

    if random.random() < 0.3:
        raise ConnectionError(f"{server_name} is not responding")
    if random.random() < 0.1:
        raise ValueError(f"invalid response from {server_name}")

    return {server_name: "healthy"}

async def monitor_servers(servers):
    """نظارت بر سرورها با مدیریت خطا"""
    tasks = [check_server_health(server) for server in servers]

    # return_exceptions=True باعث می‌شود خطاها به‌عنوان مقدار برگردانده شوند
    results = await asyncio.gather(*tasks, return_exceptions=True)

    healthy = {}
    errors = []

    for result in results:
        if isinstance(result, Exception):
            errors.append(str(result))
        else:
            healthy.update(result)

    return healthy, errors

async def main():
    servers = [
        "web-01", "web-02", "web-03",
        "db-01", "db-02", 
        "cache-01", "cache-02",
        "api-01", "api-02"
    ]

    healthy, errors = await monitor_servers(servers)

    print(f"\nhealthy servers ({len(healthy)}):")
    for server, status in healthy.items():
        print(f"  {server}: {status}")

    print(f"\nunhealthy servers ({len(errors)}):")
    for error in errors:
        print(f"  {error}")

asyncio.run(main())

روش دوم: استفاده از asyncio.create_task با Callback خطا

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

import asyncio
import random

async def process_video(video_id):
    """شبیه‌سازی پردازش ویدیو"""
    print(f"processing video {video_id}...")
    duration = random.uniform(1.0, 3.0)
    await asyncio.sleep(duration)

    if random.random() < 0.3:
        raise ValueError(f"video {video_id} is corrupted")

    print(f"video {video_id} processed successfully")
    return f"processed_{video_id}"

def handle_video_error(video_id, exception):
    """مدیریت خطای پردازش ویدیو"""
    print(f"ERROR processing video {video_id}: {exception}")
    # در اینجا می‌توانید خطا را به دیتابیس یا سیستم لاگ ارسال کنید

def handle_video_done(task):
    """Callback پس از اتمام پردازش ویدیو"""
    try:
        result = task.result()
        print(f"video processing completed: {result}")
    except Exception as e:
        # دریافت شناسه ویدیو از نام تسک
        video_id = task.get_name()
        handle_video_error(video_id, e)

async def main():
    videos = ["vid_001", "vid_002", "vid_003", "vid_004", "vid_005"]

    # ایجاد تسک‌ها با نام
    tasks = []
    for video_id in videos:
        task = asyncio.create_task(process_video(video_id))
        task.set_name(video_id)
        task.add_done_callback(handle_video_done)
        tasks.append(task)

    # منتظر اتمام همه‌ی تسک‌ها (حتی آنهایی که خطا دارند)
    await asyncio.gather(*tasks, return_exceptions=True)

    print("\nall video processing completed")

asyncio.run(main())

روش سوم: استفاده از Task Group (پایتون ۳.۱۱ به بعد)

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

پایتون ۳.۱۱ به بعد، TaskGroup را معرفی کرده است که مدیریت تسک‌ها را بسیار تمیزتر می‌کند:

import asyncio
import random

async def search_engine_query(engine_name, query):
    """شبیه‌سازی جست‌وجو در یک موتور جست‌وجو"""
    print(f"searching {engine_name} for '{query}'...")
    await asyncio.sleep(random.uniform(0.5, 2.0))

    if random.random() < 0.3:
        raise ConnectionError(f"{engine_name} is not responding")

    results = [f"{engine_name}_result_{i}" for i in range(random.randint(1, 5))]
    return {engine_name: results}

async def multi_search(query):
    """جست‌وجو در چند موتور با TaskGroup"""
    engines = ["google", "bing", "duckduckgo", "yahoo", "yandex"]
    results = {}

    try:
        async with asyncio.TaskGroup() as group:
            tasks = []
            for engine in engines:
                task = group.create_task(search_engine_query(engine, query))
                tasks.append((engine, task))

            # صبر می‌کنیم تا همه‌ی تسک‌ها تمام شوند
            # اگر یک تسک خطا بدهد، TaskGroup بقیه را کنسل می‌کند
            # برای جلوگیری از کنسل شدن، باید خطاها را در داخل تسک مدیریت کنیم

    except ExceptionGroup as eg:
        # مدیریت خطاهای گروهی
        for exc in eg.exceptions:
            print(f"search error: {exc}")

    # جمع‌آوری نتایج تسک‌های موفق
    for engine, task in tasks:
        if not task.cancelled() and not task.exception():
            result = task.result()
            results.update(result)
        elif task.exception():
            print(f"engine {engine} failed: {task.exception()}")

    return results

async def main():
    query = "python programming"
    results = await multi_search(query)

    print(f"\nsearch results for '{query}':")
    for engine, engine_results in results.items():
        print(f"  {engine}: {len(engine_results)} results")
        for result in engine_results[:3]:
            print(f"    - {result}")

asyncio.run(main())

کنسل کردن تسک‌ها (Cancellation)

یکی از قابلیت‌های مهم asyncio، امکان کنسل کردن تسک‌ها است. اما این کار نیازمند مدیریت دقیق است.

سناریوی واقعی: یک اپلیکیشن جست‌وجوی فایل دارید که چندین دایرکتوری را به‌صورت هم‌روند جست‌وجو می‌کند. کاربر می‌تواند با کلیک روی دکمه‌ی “لغو”، جست‌وجو را متوقف کند. تسک‌ها باید به‌درستی کنسل شوند و منابع آزاد شوند.

import asyncio
import random

async def search_directory(directory, pattern, stop_event):
    """شبیه‌سازی جست‌وجو در یک دایرکتوری"""
    print(f"searching in {directory}...")

    for i in range(10):  # شبیه‌سازی ۱۰ فایل
        if stop_event.is_set():
            print(f"search in {directory} cancelled")
            raise asyncio.CancelledError(f"search cancelled in {directory}")

        await asyncio.sleep(0.2)  # شبیه‌سازی بررسی یک فایل

        if random.random() < 0.1:
            print(f"found match in {directory} at file {i}")
            return {directory: f"file_{i}"}

    return {directory: "no_match"}

async def search_with_cancellation(directories, pattern):
    """جست‌وجو با قابلیت لغو"""
    stop_event = asyncio.Event()

    # ایجاد تسک‌ها
    tasks = []
    for directory in directories:
        task = asyncio.create_task(search_directory(directory, pattern, stop_event))
        tasks.append(task)

    # یک تایمر برای شبیه‌سازی لغو توسط کاربر
    async def simulate_user_cancel():
        await asyncio.sleep(1.5)
        print("\nuser cancelled search!")
        stop_event.set()
        # کنسل کردن همه‌ی تسک‌ها
        for task in tasks:
            if not task.done():
                task.cancel()

    cancel_task = asyncio.create_task(simulate_user_cancel())

    # منتظر اتمام تسک‌ها با مدیریت CancelledError
    results = []
    for task in tasks:
        try:
            result = await task
            results.append(result)
        except asyncio.CancelledError:
            print(f"task cancelled: {task.get_name()}")
        except Exception as e:
            print(f"task error: {e}")

    cancel_task.cancel()

    return results

async def main():
    directories = [
        "/home/user/docs",
        "/home/user/pictures",
        "/home/user/music",
        "/home/user/videos",
        "/var/log"
    ]

    results = await search_with_cancellation(directories, "*.txt")

    print(f"\nsearch completed with {len(results)} results:")
    for result in results:
        print(f"  {result}")

asyncio.run(main())

Idempotency در asyncio

سناریوی واقعی: یک سیستم پردازش پرداخت دارید که با درگاه بانکی ارتباط برقرار می‌کند. اگر درخواست پرداخت به‌دلیل قطعی شبکه با خطا مواجه شود، سیستم به‌طور خودکار آن را دوباره اجرا می‌کند. اما اگر پرداخت قبلاً انجام شده باشد، نباید دوباره انجام شود تا از پرداخت دوبار‌ه جلوگیری شود.

import asyncio
import uuid
import random

class PaymentProcessor:
    def __init__(self):
        self.processed_transactions = set()
        self.lock = asyncio.Lock()

    async def process_payment(self, amount, idempotency_key):
        """پردازش پرداخت با قابلیت Idempotency"""
        async with self.lock:
            if idempotency_key in self.processed_transactions:
                print(f"payment {idempotency_key} already processed")
                return "already_processed"

            print(f"processing payment of ${amount}...")
            await asyncio.sleep(0.5)

            if amount > 1000:
                raise ValueError("amount exceeds daily limit")

            self.processed_transactions.add(idempotency_key)
            print(f"payment {idempotency_key} processed successfully")
            return "success"

async def process_with_retry(processor, amount, max_retries=3):
    """پردازش با Retry و Idempotency"""
    idempotency_key = str(uuid.uuid4())

    for attempt in range(max_retries):
        try:
            result = await processor.process_payment(amount, idempotency_key)
            return result
        except Exception as e:
            print(f"attempt {attempt + 1} for payment failed: {e}")
            if attempt == max_retries - 1:
                raise
            await asyncio.sleep(0.5 * (2 ** attempt))

    return "failed"

async def main():
    processor = PaymentProcessor()

    # پرداخت‌های مختلف
    payments = [500, 1200, 300, 700]

    # پردازش هم‌روند
    tasks = []
    for amount in payments:
        task = process_with_retry(processor, amount, max_retries=3)
        tasks.append(task)

    results = await asyncio.gather(*tasks, return_exceptions=True)

    for i, result in enumerate(results):
        if isinstance(result, Exception):
            print(f"payment ${payments[i]}: failed - {result}")
        else:
            print(f"payment ${payments[i]}: {result}")

asyncio.run(main())

مدیریت چندین تسک و جمع‌آوری نتایج

سناریوی واقعی: یک سیستم Crawler وب دارید که باید از ۱۰ سایت مختلف، اطلاعات محصولات را دریافت کند. هر سایت ممکن است کند پاسخ دهد یا با خطا مواجه شود. می‌خواهید در نهایت، نتایج موفق را جمع‌آوری کنید و خطاهای هر سایت را لاگ کنید.

import asyncio
import random

class WebCrawler:
    def __init__(self, timeout_per_site=2.0, max_retries=2):
        self.timeout = timeout_per_site
        self.max_retries = max_retries

    async def crawl_site(self, site_name):
        """شبیه‌سازی Crawl کردن یک سایت"""
        print(f"crawling {site_name}...")
        delay = random.uniform(0.3, 3.0)
        await asyncio.sleep(delay)

        if random.random() < 0.2:
            raise ConnectionError(f"cannot connect to {site_name}")
        if random.random() < 0.15:
            raise ValueError(f"invalid data from {site_name}")

        return {site_name: f"data_content_from_{site_name}"}

    async def crawl_with_retry(self, site_name):
        """Crawl با Retry و Timeout"""
        for attempt in range(self.max_retries):
            try:
                result = await asyncio.wait_for(
                    self.crawl_site(site_name),
                    timeout=self.timeout
                )
                return result
            except asyncio.TimeoutError:
                print(f"timeout crawling {site_name}, attempt {attempt + 1}")
            except Exception as e:
                print(f"error crawling {site_name}, attempt {attempt + 1}: {e}")

            if attempt < self.max_retries - 1:
                await asyncio.sleep(0.5 * (2 ** attempt))

        return {site_name: f"error: all retries failed"}

    async def crawl_all(self, sites, max_concurrent=5):
        """Crawl کردن همه‌ی سایت‌ها به‌صورت هم‌روند"""
        # استفاده از Semaphore برای محدود کردن تعداد تسک‌های هم‌روند
        semaphore = asyncio.Semaphore(max_concurrent)

        async def crawl_with_semaphore(site):
            async with semaphore:
                return await self.crawl_with_retry(site)

        tasks = [crawl_with_semaphore(site) for site in sites]
        results = await asyncio.gather(*tasks, return_exceptions=True)

        successful = {}
        errors = []

        for result in results:
            if isinstance(result, Exception):
                errors.append(str(result))
            else:
                successful.update(result)

        return successful, errors

async def main():
    crawler = WebCrawler(timeout_per_site=2.0, max_retries=2)
    sites = [
        "site1.com", "site2.com", "site3.com",
        "site4.com", "site5.com", "site6.com",
        "site7.com", "site8.com", "site9.com",
        "site10.com"
    ]

    successful, errors = await crawler.crawl_all(sites, max_concurrent=4)

    print(f"\nsuccessful crawls ({len(successful)}):")
    for site, data in successful.items():
        print(f"  {site}: {data}")

    print(f"\nfailed crawls ({len(errors)}):")
    for error in errors:
        print(f"  {error}")

asyncio.run(main())

بهترین شیوه‌ها (Best Practices) در مدیریت خطای asyncio

۱. همیشه از asyncio.wait_for یا asyncio.timeout برای محدود کردن زمان استفاده کنید

این کار از توقف بی‌نهایت برنامه جلوگیری می‌کند.

  1. از asyncio.gather با return_exceptions=True برای مدیریت خطاهای چند تسک استفاده کنید این روش به شما اجازه می‌دهد همه‌ی تسک‌ها را اجرا کنید و خطاها را به‌عنوان مقدار برگردانده شده دریافت کنید.
  2. از asyncio.TaskGroup (پایتون ۳.۱۱+) برای مدیریت گروهی تسک‌ها استفاده کنید این API مدیریت تسک‌ها را بسیار تمیزتر و ایمن‌تر می‌کند.
  3. همیشه CancelledError را مدیریت کنید وقتی تسکی را کنسل می‌کنید، ممکن است CancelledError پرتاب شود. این استثنا را مدیریت کنید تا بتوانید منابع را پاک‌سازی کنید.
  4. از Exponential Backoff با Jitter در Retry استفاده کنید این کار از هجوم به سرویس‌های خارجی جلوگیری می‌کند.
  5. اطمینان از Idempotency قبل از استفاده از Retry اگر عملیات Idempotent نیست، برای Retry کردن باید از شناسه‌های یکتا (Idempotency Key) استفاده کنید.
  6. از asyncio.shield برای محافظت از تسک‌های حیاتی استفاده کنید اگر تسکی دارید که نباید کنسل شود (مانند ارسال ایمیل تأیید)، از shield استفاده کنید.
  7. از Semaphore برای محدود کردن تعداد تسک‌های هم‌روند استفاده کنید این کار از فشار بیش از حد به سرویس‌های خارجی جلوگیری می‌کند.
  8. لاگ‌های دقیق نگه دارید در هر مرحله از Timeout و Retry، لاگ بنویسید تا بتوانید مشکلات را ردیابی کنید.
  9. از asyncio.create_task با add_done_callback برای مدیریت پس‌زمینه استفاده کنید این روش به شما اجازه می‌دهد تسک‌ها را در پس‌زمینه اجرا کنید و خطاهای آنها را مدیریت کنید.

جمع‌بندی

asyncio ابزارهای بسیار قدرتمندی برای مدیریت خطا ارائه می‌دهد که بسیاری از آنها در threading در دسترس نیستند. امکاناتی مانند wait_for، timeout، shield، TaskGroup و قابلیت کنسل کردن تسک‌ها، asyncio را به انتخابی عالی برای برنامه‌های I/O-bound تبدیل کرده است.

الگوهای اصلی که در این فصل بررسی کردیم عبارتند از:

  • Timeout: با استفاده از asyncio.wait_for، asyncio.timeout و ترکیب با gather
  • Retry: با تأخیر ثابت، تأخیر تصاعدی و شرطی
  • ترکیب Timeout و Retry: برای ساخت برنامه‌های مقاوم در برابر خطا
  • مدیریت استثناها: با gather(return_exceptions=True)، TaskGroup و Callback
  • کنسل کردن تسک‌ها: با cancel() و مدیریت CancelledError
  • Idempotency: برای اطمینان از ایمنی Retry کردن
  • محدود کردن هم‌روندی: با Semaphore

با رعایت این اصول و الگوها، می‌توانید برنامه‌های asyncio بنویسید که در برابر خطاهای موقت، طولانی شدن عملیات‌ها، کنسل شدن توسط کاربر و شرایط غیرمنتظره، مقاوم باشند.

Please login to bookmark Close
پیشرفت شما در «دوره آموزش کانکارنسی در پایتون» (69%)
نظرات

دیدگاهتان را بنویسید

69%
پیشرفت

سرفصل دوره

فهرست مطالب

سرفصل دوره

تمرین

این قسمت تمرین ندارد!

پاسخ تمرین ها

هنوز برای تمرین‌های این قسمت پاسخی ثبت نشده است!

اشتراک گذاری

چرا بهتره از فیلترشکن استفاده کنید؟

من همه ویدئو ها و پادکست های کُدباز رو توی یوتیوب و ساندکلود و پلتفرم هایی آپلود می‌کنم که اغلب فیلتر هستند.

اغلب آموزش‌ها ویدئو و پادکست دارند. پس اگر می‌خواهید از محتوای سایت بیشترین استفاده رو ببرید نیاز به فیلتر شکن دارید.

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

تنظیمات

انتخاب زبان
تغییر تم