8.5. المهل الزمنية والإلغاء

الإلغاء هو اسم asyncio لـ "أوقف تشغيل هذه الدالة التعاونية، وأثِر استثناءً داخلها كي تتاح لها فرصة التنظيف، وأزلها من المجدول". والمهل الزمنية هي أكثر الأسباب شيوعاً لإلغاء شيء ما؛ أما task.cancel() اليدوي فهو السبب الآخر.

8.5.1. إلغاء مهمة

يجدول استدعاء Task.cancel إثارة asyncio.CancelledError داخل الدالة التعاونية الجارية عند await التالي لها. والدالة التعاونية حرة في تجاهل الإلغاء (التقاط الاستثناء ومتابعة التشغيل) أو الاستجابة له -- والخيار المعتاد هو الاستجابة له بعد تشغيل شِفرة التنظيف:

async def network_worker(stream):
    try:
        while True:
            data = await stream.read(64)
            # ...
    finally:
        stream.close()

عبارة finally المجردة هي أنظف نمط: فالتنظيف يُشغَّل سواء خرجت الدالة التعاونية بشكل طبيعي، أو أثارت استثناءً غير ذي صلة، أو أُلغيت. وينتشر CancelledError صعوداً عبر finally، وترى الحلقة أن المهمة قد انتهت، ويرى مستدعي await task الإلغاء.

التقاط CancelledError بشكل صريح مقبول أيضاً عندما يريد التطبيق فعل شيء محدد به -- تسجيله، أو تسليم المورد بشكل نظيف، إلخ. والقاعدة هي: أعِد إثارته بعد تشغيل التنظيف. فابتلاع CancelledError يُبقي الدالة التعاونية حيّةً بعد أن طلب منها مستدعيها التوقف، وهو خطأ في جميع الأحوال تقريباً:

async def worker():
    try:
        await long_running_thing()
    except asyncio.CancelledError:
        log("worker cancelled, cleaning up")
        close_resources()
        raise          # << this line is the important one

8.5.2. المهل الزمنية باستخدام wait_for

تغلّف asyncio.wait_for() كائناً قابلاً للانتظار ضمن موعد نهائي. فإن انتهى الكائن القابل للانتظار خلال المهلة، تُرجَع نتيجته. وإن لم ينتهِ، يُلغى الكائن القابل للانتظار ويحصل المستدعي على asyncio.TimeoutError

try:
    frame = await asyncio.wait_for(grab_frame(), timeout=2)
except asyncio.TimeoutError:
    print("camera took too long")

تقبل وسيطة الثواني عدداً عشرياً. وللمواعيد النهائية بمقياس المللي ثانية تأخذ asyncio.wait_for_ms() عدداً صحيحاً من المللي ثانية -- وهو امتداد من MicroPython يتماشى مع مقابض التوقيت بدقة المللي ثانية في البرنامج الثابت.

داخلياً، تفعل wait_for بالضبط ما يفعله الإلغاء اليدوي: فعندما ينقضي الموعد النهائي تستدعي cancel() على المهمة المغلّفة، ويُثار CancelledError داخل الدالة التعاونية، وتُشغَّل عبارات finally، وحالما يكتمل التنظيف يُترجَم الاستثناء إلى TimeoutError للمستدعي.

هذا يعني أن الدالة التعاونية التي تلتقط CancelledError وتتجاهله ستُبطل المهلة الزمنية -- فالموعد النهائي انقضى، لكن الدالة التعاونية رفضت التوقف، ولا تستطيع wait_for إجبارها. والقاعدة السابقة تنطبق هنا أيضاً: التقط CancelledError فقط لتشغيل التنظيف، ثم أعِد إثارته.

8.5.3. الإلغاء عبر gather

ينتشر الإلغاء نزولاً عبر gather(). فإن أُلغيت المهمة التي تنتظر استدعاء gather، يُلغى أيضاً كل كائن قابل للانتظار لا يزال يعمل بداخل الـ gather -- ويحصل كل منها على فرصة للتنظيف عبر عبارات finally الخاصة به قبل أن يتدحرج الإلغاء صعوداً إلى المستدعي.

وبالاقتران مع المهل الزمنية، هذه هي الطريقة المعيارية لوضع موعد نهائي على مجموعة من العمليات:

await asyncio.wait_for(
    asyncio.gather(a(), b(), c()),
    timeout=5,
)

إما أن تنتهي كل عملية فرعية خلال خمس ثوانٍ، أو تُلغى جميعها معاً.

8.5.4. إغلاق تطبيق

الإلغاء هو أيضاً كيفية توقّف تطبيق حقيقي بشكل نظيف. والنمط متسق عبر البرامج النصية: يلتقط main مقابض المهام الخلفية طويلة العمر التي بدأها، ويشغّل عمله على المستوى الأعلى، ثم يلغي كل مقبض وينتظره في كتلة finally

async def main():
    sender = asyncio.create_task(uplink())
    watcher = asyncio.create_task(button_watcher())
    try:
        await snapshot_loop()
    finally:
        sender.cancel()
        watcher.cancel()
        await asyncio.gather(sender, watcher,
                             return_exceptions=True)

إن return_exceptions=True هي الحيلة التي تمنع الـ gather من إعادة إثارة CancelledError الذي توشك كل مهمة ابن على تسليمه، بحيث يكون سبب خروج التطبيق نفسه -- أياً كان ما أثاره snapshot_loop أو لم يثره -- هو ما يطفو خارج main.