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.