תכנות מיקרו-בקרים בשפת MicroPython · פרק 19 — תכנות אסינכרוני⌂📚🏠
פרק 19

תכנות אסינכרוני

Asynchronous Programming
3 שעות לימוד

אנחנו רוצים שהלוח יהבהב, יעקוב אחרי כפתור, יענה לדפדפן, וכל זאת בבת אחת. אבל יש לו תוכנית אחת שרצה שורה אחר שורה. בפרק 4 פתרנו בעיה קטנה כזאת בעזרת מונה זמן לכל משימה, ופתרון כזה מסתבך מהר. כאן נלמד את הכלי שנוצר בשביל זה: משימות שמוותרות על התור כשהן ממתינות, וכך נראה שהן רצות בו-זמנית.

בפרק הזה:
  • נסביר למה פקודות חוסמות משביתות את כל מה שסביבן.
  • נכתוב משימות בעזרת async ו-await, ונריץ אותן בפונקציות asyncio.run, gather ו-create_task.
  • נחשב את הזמן הכולל ונחזה את סדר האירועים בפלט.
  • נקשור שתי משימות בעזרת המחלקה Event.
  • נבנה מערכת שיש בה נורית, כפתור, חיישן ושרת.
  • נימנע משתי המלכודות: פקודה חוסמת בתוך משימה, ומשימה נשכחת.
  • נבחר בין פסיקה, טיימר ו-asyncio לפי הבעיה.

19.1 הבעיה: המתנה שעוצרת הכול (Blocking)

פקודה כמו time.sleep(1) היא פקודה חוסמת: כל עוד היא ממתינה, שום שורה אחרת אינה מתבצעת. נלחץ על הכפתור למשך 0.3 s בתוכנית שמהבהבת כל שנייה:

3V3board.POTboard.BTNGNDboard.LEDon the board
חיבורי הפרק: הפוטנציומטר והכפתור. הנורית נמצאת על הלוח.
הכפתור לחוץ מ-1.3 s עד 1.6 s, והתוכנית אינה רואה זאת לעולם.
import time
from machine import Pin
import board

led = Pin(board.LED, Pin.OUT)
btn = Pin(board.BTN, Pin.IN, Pin.PULL_UP)
t0 = time.ticks_ms()

for i in range(4):
    led.value(1 - led.value())
    time.sleep(1)
    now = time.ticks_diff(time.ticks_ms(), t0) / 1000
    print("%.1f s  button %d" % (now, btn.value()))
ESP32 + Pico W · MicroPython 1.29 · simulated board
1.0 s  button 1
2.0 s  button 1
3.0 s  button 1
4.0 s  button 1

התוכנית קוראת את הכפתור רק בכל שנייה שלמה, והלחיצה נפלה בין שתי קריאות. שימו לב שרוב זמנו של הלוח הולך כאן על המתנה שאינה דורשת שום חישוב. כל הרעיון במשפט אחד: מי שממתין מוותר על התור לאחרים.

19.2 משימה: async ו-await (Coroutines)

תכנות אסינכרוני: סגנון שבו כותבים את התוכנית כמשימות שרצות לסירוגין על מעבד אחד. כל משימה פועלת עד שהיא מגיעה ל-await, ושם היא מחזירה את התור.
מתזמן: חלק מן הספרייה asyncio ששומר רשימה של משימות ושל זמן ההתעוררות של כל אחת, ונותן את התור למשימה המוכנה.
פקודהמה היא עושה
async def f():פונקציה אסינכרונית. קריאה אליה אינה מריצה אותה, אלא נותנת לנו אובייקט שממתינים לו.
await xממתינים ל-x ומוותרים על התור בינתיים.
await asyncio.sleep(s)המתנה שאינה חוסמת: המשימה הזאת ישנה, ואחרות פועלות.
asyncio.run(main())מפעיל את המתזמן עד שהפונקציה main מסתיימת.
asyncio.gather(a, b)מריץ את המשימות יחד, מסתיים כשכולן מסתיימות, ומחזיר רשימה של תוצאותיהן.
שתי משימות עם מרווחים שונים. המספר בתחילת השורה הוא הזמן בשניות.
import asyncio
import time

t0 = time.ticks_ms()


def stamp():
    return time.ticks_diff(time.ticks_ms(), t0) / 1000


async def work(name, n, gap):
    for i in range(n):
        print("%.1f  %s %d" % (stamp(), name, i))
        await asyncio.sleep(gap)


async def main():
    await asyncio.gather(work("A", 3, 1.0), work("B", 3, 0.6))
    print("%.1f  done" % stamp())


asyncio.run(main())
ESP32 + Pico W · MicroPython 1.29 · simulated board
0.0  A 0
0.0  B 0
0.6  B 1
1.0  A 1
1.2  B 2
2.0  A 2
3.0  done
AB0.00.61.01.21.82.03.0t (s)
המלבנים הם הרגעים שבהם כל משימה פועלת, והקו המקווקו הוא שנתה. המעבד פנוי רוב הזמן.

כש-A מגיעה ל-await asyncio.sleep, תורה של B מתחיל, ובשעה 0.6 s B מתעוררת בלי להזדקק לדבר מ-A. אין כאן ביצוע מקבילי אמיתי: בכל רגע רצה שורה אחת בלבד.

הזמן הכולל
T = max(TA, TB)
סמלפירושיחידה
TAסכום זמני ההמתנה של המשימה הראשונה s
TBסכום זמני ההמתנה של המשימה השנייה s
דוגמה 19.1
בתוכנית הקודמת: 3 סבבים במרווח 1 s ו-3 סבבים במרווח 0.6 s. מה הזמן אילו הרצנו את שתי המשימות זו אחר זו? ומה הזמן עם gather?
פתרון:
TA = 3 · 1
TA = 3 s
TB = 3 · 0.6
TB = 1.8 s
Tseq = TA + TB
Tseq = 4.8 s
T = max(TA, TB)
T = 3 s

וזו השורה האחרונה בפלט התוכנית. זמני החישוב בין שתי ההמתנות קטנים, ולכן מזניחים אותם.

Tseq = 4.8 s, T = 3 s
שאלה 19.1: משנים את המרווח של B ל-1.5 s. מתי שתי המשימות מסתיימות?

19.3 יצירת משימה: create_task (create_task)

הפונקציה gather ממתינה לכולם. לפעמים רוצים להפעיל משימה ולהמשיך בעבודה, ולהמתין לה מאוחר יותר או לא להמתין לה כלל. זו העבודה של create_task. וכאן הלקח החשוב ביותר על הסדר:

קראו את סדר השורות: A אינה מתחילה ברגע יצירתה, אלא ב-await הראשון ב-main.
import asyncio


async def job(name):
    print(name, "starts")
    await asyncio.sleep(1)
    print(name, "ends")


async def main():
    t = asyncio.create_task(job("A"))
    print("main: task created")
    await asyncio.sleep(0.1)
    print("main: back after 0.1 s")
    await t
    print("main: A finished")


asyncio.run(main())
ESP32 + Pico W · MicroPython 1.29 · simulated board
main: task created
A starts
main: back after 0.1 s
A ends
main: A finished
אזהרה: אם שתי משימות מתעוררות בדיוק באותו רגע, אין לבנות את התוכנית על הסדר ביניהן. תוכניות הפרק רצות על מתזמן קטן בסימולציה, עם אותם שמות ועם שעון וירטואלי. בלוח עצמו המתזמן שונה, וזמן השינה הוא זמן מינימלי ולא זמן מדויק.
שאלה 19.2: אילו מחקנו מן התוכנית את השורה await asyncio.sleep(0.1), מהי השורה השנייה שהתוכנית מדפיסה? ולמה?

19.4 משימה אחת מעירה משימה אחרת: Event (Event)

משימה אחת עוקבת אחרי הכפתור, ומשימה אחרת מבצעת את העבודה. איך הראשונה מודיעה לשנייה בלי לשאול כל הזמן? בעזרת אירוע: אובייקט שיש בו דגל אחד, set() מרים אותו, wait() ממתין לו ו-clear() מוריד אותו.

שלוש לחיצות. קריאת הכפתור כל 20 ms מספיקה ללחיצה אנושית, והיא ארוכה מקפיצת המגע הרגילה, ולכן אין סופרים את הלחיצה פעמיים.
import asyncio
from machine import Pin
import board

led = Pin(board.LED, Pin.OUT)
btn = Pin(board.BTN, Pin.IN, Pin.PULL_UP)
pressed = asyncio.Event()


async def watch_button():
    last = 1
    while True:
        now = btn.value()
        if now == 0 and last == 1:
            pressed.set()
        last = now
        await asyncio.sleep_ms(20)


async def worker():
    while True:
        await pressed.wait()
        pressed.clear()
        led.value(1 - led.value())
        print("press: led", led.value())


async def main():
    await asyncio.gather(watch_button(), worker())


asyncio.run(main())
ESP32 + Pico W · MicroPython 1.29 · simulated board
press: led 1
press: led 0
press: led 1
...
טעות נפוצה: אם שכחנו את pressed.clear(), הדגל נשאר מורם, ולכן wait() כבר אינה ממתינה, והעובד מסתובב בלי הפסקה.
הערה: כאן הכפתור נקרא בתוך משימה. אם רוצים להעיר משימה מתוך פסיקה, אין להשתמש ב-Event: התיעוד אומר ש-set() אינו בטוח לשימוש מתוך פסיקה, ומפנה אותנו למחלקה asyncio.ThreadSafeFlag. הסימולציה בספר הזה אינה מספקת אותה.
שאלה 19.3: שתי לחיצות רצופות, שהזמן ביניהן קצר ממחזור העבודה של המשימה worker, בזמן שהיא הייתה עסוקה. כמה פעמים תתבצע העבודה?

19.5 נורית, כפתור, חיישן ושרת

שלוש משימות, לכל אחת מקצב משלה, ושני מונים שמשותפים להן. אף משימה אינה יודעת דבר על המקצב של האחרת, וזה בדיוק מה שהיה קשה עם המונים של פרק 4:

דוח כל שתי שניות, בזמן שהנורית מהבהבת כל 300 ms וכל הלחיצות נספרות, גם הקצרות שבהן.
import asyncio
from machine import Pin
import board
from convert import to_percent

led = Pin(board.LED, Pin.OUT)
btn = Pin(board.BTN, Pin.IN, Pin.PULL_UP)
pot = board.analog_in(board.POT)
blinks = 0
presses = 0


async def blink():
    global blinks
    while True:
        led.value(1 - led.value())
        blinks += 1
        await asyncio.sleep_ms(300)


async def button():
    global presses
    last = 1
    while True:
        now = btn.value()
        if now == 0 and last == 1:
            presses += 1
        last = now
        await asyncio.sleep_ms(20)


async def report():
    while True:
        await asyncio.sleep(2)
        level = to_percent(pot.read_u16())
        print("blinks", blinks, "presses", presses, "pot", level)


async def main():
    await asyncio.gather(blink(), button(), report())


asyncio.run(main())
ESP32 + Pico W · MicroPython 1.29 · simulated board
blinks 7 presses 2 pot 50
blinks 14 presses 3 pot 50
...

והשרת הוא משימה נוספת. הספרייה נותנת לנו asyncio.start_server, והיא קוראת לפונקציה בעבור כל דפדפן שמתחבר, וכל פונקציה כזאת היא משימה עצמאית. התוכנית הבאה לא הורצה בזמן בניית הספר, כי הסימולציה שלנו אינה מספקת את הפונקציה הזאת, ובדקנו אותה בהידור בלבד:

שרת אסינכרוני לצד ההבהוב. דרך הכתיבה לפי התיעוד של asyncio.
import asyncio
from machine import Pin
import board
import wifi

led = Pin(board.LED, Pin.OUT)
blinks = 0


async def blink():
    global blinks
    while True:
        led.value(1 - led.value())
        blinks += 1
        await asyncio.sleep_ms(300)


async def handle(reader, writer):
    await reader.readline()
    while await reader.readline() not in (b"\r\n", b""):
        pass
    writer.write(b"HTTP/1.1 200 OK\r\n\r\n")
    writer.write(b"blinks %d\n" % blinks)
    await writer.drain()
    writer.close()
    await writer.wait_closed()


async def main():
    wifi.connect()
    await asyncio.start_server(handle, "0.0.0.0", 80)
    await blink()


asyncio.run(main())
שאלה 19.4: בדוח של התוכנית async_system.py: כמה הבהובים צפויים ב-2 s? השוו לפלט.

19.6 שתי מלכודות (Pitfalls)

פקודה חוסמת בתוך משימה:

המשימה slow משתמשת כאן ב-time.sleep במקום ב-await asyncio.sleep:

הפעימות מגיעות כל 0.5 s, ואז הזמן קופץ מ-1.0 ל-3.2.
import asyncio
import time

t0 = time.ticks_ms()


def stamp():
    return time.ticks_diff(time.ticks_ms(), t0) / 1000


async def beat():
    for i in range(6):
        print("%.1f  beat" % stamp())
        await asyncio.sleep(0.5)


async def slow():
    await asyncio.sleep(1.2)
    print("%.1f  slow starts" % stamp())
    time.sleep(2)
    print("%.1f  slow ends" % stamp())


async def main():
    await asyncio.gather(beat(), slow())


asyncio.run(main())
ESP32 + Pico W · MicroPython 1.29 · simulated board
0.0  beat
0.5  beat
1.0  beat
1.2  slow starts
3.2  slow ends
3.2  beat
3.7  beat
4.2  beat

המתזמן אינו קוטע אף משימה: הוא ממתין עד שתחזיר את התור. ואם נחליף את השורה ב-await asyncio.sleep(2), הפעימות יחזרו למקצב שלהן:

After the fix
0.0  beat
0.5  beat
1.0  beat
1.2  slow starts
1.5  beat
2.0  beat
2.5  beat
3.2  slow ends
טעות נפוצה: כל פקודה ארוכה חוסמת באותו אופן: requests.get מפרקים 15 ו-18, accept(), ולולאת while True בלי await. ואם הלולאה ארוכה ואין ברירה, מכניסים לתוכה await asyncio.sleep(0) כדי שתוותר על התור.

משימה נשכחת:

A אינה רצה כלל, ו-B מתחילה ואינה מסתיימת. ואין שום הודעת שגיאה.
import asyncio


async def job(name):
    print(name, "starts")
    await asyncio.sleep(1)
    print(name, "ends")


async def main():
    a = job("A")
    asyncio.create_task(job("B"))
    await asyncio.sleep(0.5)
    print("main ends")


asyncio.run(main())
print("program ends")
ESP32 + Pico W · MicroPython 1.29 · simulated board
B starts
main ends
program ends
שאלה 19.5: המשימה blink הפסיקה להבהב אחרי שהוספנו לה שורה שקוראת מ-UART בפקודה uart.readline() שממתינה לנתונים. מהי הסיבה האפשרית?

19.7 באיזה כלי משתמשים מתי?

הכלימתי הוא פועלמתאים למגבלות
פסיקה (פרק 5)מיד עם האירוע, וקוטעת את התוכניתאירוע קצר שאסור שיאבד: פולס, קצהמטפל קצר מאוד, ואין המתנה בתוכו
טיימר (פרק 4)במחזור קבוע, וקוטע את התוכניתעבודה קצרה סדירה: קריאה או הבהובמטפל קצר, ומספר הטיימרים מוגבל
ticks_diff בלולאהכשהלולאה מגיעה אליומשימה אחת או שתיים פשוטותמסתבך עם כל משימה חדשה
asyncioכשהמשימה מחזירה את התורהרבה משימות איטיות שממתינות: רשת, כפתור, מסךפקודה חוסמת אחת משביתה את כולן

והכלל המעשי: זמן התגובה ב-asyncio אינו קטן מן הקטע הארוך ביותר בין await ל-await. אם האירוע קצר מכך, הוא שייך לפסיקה, והפסיקה יכולה לרשום אותו במשתנה שמשימה תקרא אחר כך. לא פעם מחברים כמה כלים בתוכנית אחת.

_thread: במיקרו-פייתון יש גם המודול _thread, ואיננו משתמשים בו בספר הזה משלוש סיבות: התיעוד מתאר אותו כניסיוני מאוד וקובע שהממשק שלו עדיין לא התייצב; חוטים יכולים להתחלף בכל שורה, ולכן נתונים משותפים דורשים מנעולים ומתגלות תקלות שקשה לשחזר; והסימולציה שלנו אינה מריצה אותו, ואילו כל תוכנית בספר הזה נבדקת בהרצה.

עיקרי הפרק:

סיכום הכללים בפרק:

הכללהנוסחה
הזמן הכולל ברצףTseq = TA + TB
הזמן הכולל עם gatherT = max(TA, TB)

פקודות הפרק:

פקודהמה היא עושה
asyncio.run(main())מפעיל את המתזמן עד שהפונקציה main מסתיימת.
await asyncio.sleep(s)שינה שאינה חוסמת; ועמה sleep_ms.
asyncio.gather(a, b)ממתין לכולם ומחזיר רשימה של התוצאות.
asyncio.create_task(c)מפעיל משימה ומחזיר אובייקט שאפשר להמתין לו או לבטל אותו.
t.cancel()מבטל את המשימה.
asyncio.Event()דגל, ולו set, wait ו-clear.
asyncio.start_server(f, host, port)שרת שקורא ל-f(reader, writer) בכל חיבור.

מילון מונחים:

עבריתEnglishערבית
תכנות אסינכרוניAsynchronous Programmingالبرمجة غير المتزامنة
משימהTaskمهمة
מתזמןSchedulerالمجدول
פקודה חוסמתBlocking Callأمر حاجب
אירועEventحدث
התשובות הסופיות לשאלות הפרק
19.1 T = max(3, 4.5) = 4.5 s.
19.2 main: back after 0.1 s ולא A starts, כי המשימה A אינה מקבלת תור לפני ה-await הראשון ב-main, ועכשיו זה await t.
19.3 פעם אחת: Event הוא דגל ולא מונה.
19.4 ההבהוב ב-0, 0.3, ..., 1.8, כלומר 7 פעמים.
19.5 פקודה שחוסמת בתוך משימה: המתזמן אינו מקבל בחזרה את התור עד שהפקודה מסתיימת.

שאלות לתרגול:

רמה ראשונה — בסיס
1.
מה ההבדל בין time.sleep(1) ל-await asyncio.sleep(1)?
תשובות: הראשון עוצר את כל התוכנית, והשני עוצר את המשימה הנוכחית ומוותר על התור לאחרות.
2.
שלוש משימות עם gather, וזמניהן 1 s, 4 s ו-2.5 s. מה הזמן הכולל? ומה הוא ברצף?
תשובות: T = 4 s, וברצף 7.5 s.
3.
מה מדפיסה השורה asyncio.run(main()) אם main קוראת ל-job() בלי await ואין בה דבר אחר?
תשובות: אינה מדפיסה דבר: הקריאה יוצרת אובייקט בלבד ואינה מריצה את הפונקציה.
רמה שנייה — יישום
4.
כתבו תוכנית שמהבהבת בשתי נוריות: LED כל 500 ms ו-LED_EXT כל 130 ms, בפונקציה אחת.
תשובות:
import asyncio
from machine import Pin
import board


async def blink(pin, ms):
    led = Pin(pin, Pin.OUT)
    while True:
        led.value(1 - led.value())
        await asyncio.sleep_ms(ms)


async def main():
    await asyncio.gather(blink(board.LED, 500),
                         blink(board.LED_EXT, 130))


asyncio.run(main())
5.
בתוכנית async_first.py משנים את המרווח של A ל-0.5 s. סדרו את שורות הפלט לפני ההרצה.
תשובות: 0.0 A 0
0.0 B 0
0.5 A 1
0.6 B 1
1.0 A 2
1.2 B 2
1.8 done
6.
בתוכנית async_system.py מוחקים את await asyncio.sleep_ms(20) מן המשימה button. מה קורה?
תשובות: המשימה אינה מוותרת על התור לעולם: ההבהוב והדוח נעצרים, ונשארת רק קריאת הכפתור.
רמה שלישית — העמקה
7.
כתבו תוכנית שבה הכפתור משנה את מהירות ההבהוב בין 500 ms ל-100 ms, בעזרת משתנה משותף. למה אין צורך במנעול?
תשובות:
import asyncio
from machine import Pin
import board

led = Pin(board.LED, Pin.OUT)
btn = Pin(board.BTN, Pin.IN, Pin.PULL_UP)
period = 500


async def blink():
    while True:
        led.value(1 - led.value())
        await asyncio.sleep_ms(period)


async def button():
    global period
    last = 1
    while True:
        now = btn.value()
        if now == 0 and last == 1:
            period = 100 if period == 500 else 500
        last = now
        await asyncio.sleep_ms(20)


async def main():
    await asyncio.gather(blink(), button())


asyncio.run(main())
כי התור מתחלף רק ב-await, ולכן הערך אינו נכתב ונקרא באותו רגע.
8.
רוצים לצרף את הבוט מפרק 18 להבהוב במרווח 300 ms. מה קורה להבהוב בכל בקשה? ומה עושים?
תשובות: ההבהוב נעצר לכל משך הבקשה, כי requests חוסמת. מקבלים זאת ומאריכים את זמן התשאול, או מעבירים את ההבהוב לטיימר ובודקים את התוצאה בלוח, כי זמן הבקשה האמיתי אינו ידוע מן הסימולציה.
נוסחאון הספר: כל נוסחאות הפרק הזה מרוכזות, עם סרטוטיהן וטבלאות הסמלים שלהן, בפרק 25 — נוסחאון הספר.