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() עוטף awaitable במועד אחרון. אם ה-awaitable מסתיים בתוך פסק הזמן, התוצאה שלו מוחזרת. אם לא, ה-awaitable מבוטל והקורא מקבל 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 מבוטלת, כל awaitable שעדיין רץ בתוך ה-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.