8.15. Pułapki

Te same wzorce, które czynią asyncio przyjemnym – brak wywłaszczania, jawne await – nadają mu też własny zestaw kształtów, które potrafią ugryźć. Ta strona jest katalogiem tych, które pojawiają się na tyle często, by warto było je znać.

8.15.1. Zapomnienie o await

Wywołanie funkcji async def zwraca obiekt korutyny. Nie uruchamia ono ciała funkcji. Aby faktycznie ją wykonać, na korutynie trzeba wykonać await lub opakować ją w zadanie:

async def main():
    send_request()              # bug: returns the coroutine, does nothing
    await send_request()        # right: run it to completion
    asyncio.create_task(send_request())  # right: run it concurrently

Błąd jest cichy – obiekt korutyny zostaje utworzony, porzucony i nigdy niewykonany. Aplikacja działa dalej, jakby wszystko zadziałało. MicroPython czasem rejestruje ostrzeżenie, że na korutynie nigdy nie wykonano await; czasem nie. Sprawdzaj brakujące await w każdym miejscu wywołania, które wygląda jak wywołanie funkcji.

8.15.2. Ciasne pętle bez await

Korutyna działająca w pętli i nigdy niewykonująca await zawłaszcza pętlę zdarzeń. Żadne inne zadanie nie robi postępów, dopóki pętla się nie zakończy lub nie odda sterowania:

async def counter():
    n = 0
    while True:
        n += 1               # bug: starves the loop

Naprawą jest oddanie sterowania (yield) wewnątrz pętli – zazwyczaj await asyncio.sleep_ms(0) – aby inne gotowe zadania dostały szansę na działanie. Obliczeniowo wymagająca praca również należy do tego schematu: pętla przetwarzania obrazu działająca po setki milisekund na iterację powinna oddawać sterowanie co najmniej raz na iterację, aby reszta programu się nie zacinała.

8.15.3. Połykanie CancelledError

Strona o anulowaniu omówiła to już szczegółowo. Powtarzamy to tutaj, ponieważ jest to najczęstsza przyczyna sytuacji „moja aplikacja nie chce się wyłączyć”: korutyna przechwytuje asyncio.CancelledError w celu sprzątania i zapomina go ponownie zgłosić. Zadanie działa dalej; wywołujący, który zażądał anulowania, wisi w nieskończoność, czekając na jego zakończenie. Zawsze ponownie zgłaszaj wyjątek po sprzątaniu lub użyj bloku try/finally zamiast jawnego except.

8.15.4. Mutowanie współdzielonego stanu pomiędzy await

Harmonogramowanie kooperatywne gwarantuje, że korutyna ma CPU dla siebie pomiędzy await, ale gdy tylko wykona await, może uruchomić się inna korutyna. Jeśli dwie korutyny modyfikują tę samą strukturę danych w krokach zawierających await, ich operacje mogą się przeplatać w sposób uszkadzający strukturę:

# bug: two tasks running do_work simultaneously can
# interleave around the await and corrupt items
async def do_work():
    n = len(items)
    await asyncio.sleep_ms(0)
    items.append(some_work(n))

Dla stanu mutowanego wyłącznie pomiędzy await wewnątrz jednej korutyny żadna synchronizacja nie jest potrzebna. Dla stanu mutowanego pomiędzy await i dostępnego z więcej niż jednej korutyny opakuj sekcję krytyczną w Lock.

8.15.5. await na poziomie modułu

await jest poprawne tylko wewnątrz ciała async def. Zapisanie go na poziomie modułu – poza jakąkolwiek korutyną – jest błędem składni:

# bug: not inside an async def
result = await fetch()

Naprawą jest umieszczenie pracy w korutynie i wywołanie jej z punktu wejścia programu asyncio.run().

8.15.6. Wielokrotne wywołania asyncio.run

MicroPython ma jedną pętlę zdarzeń. Dwukrotne wywołanie asyncio.run() z rzędu – raz na konfigurację, raz na główną pracę – nadal korzysta z tej samej pętli. Wywołanie go z wnętrza działającej korutyny jest błędem: pętla już działa. Oba przypadki pojawiają się najczęściej, gdy skrypt rozrasta się organicznie, a autor próbuje go rozszerzyć, dodając kolejne wywołania run() zamiast włączyć nową pracę do istniejącego main.

8.15.7. Użycie Event z przerwania

asyncio.Event.set() można bezpiecznie wywołać tylko z wnętrza pętli zdarzeń. Wywołanie go z funkcji obsługi przerwania GPIO grozi uszkodzeniem danych. Do wybudzania zadania z przerwania użyj zamiast tego ThreadSafeFlagstrona o nim omawia ten schemat.

8.15.8. Długie wywołania synchroniczne

Korutyna może wykonać await na własnych prymitywach oczekiwania asyncio; wszystko inne, co wywoła, działa synchronicznie i blokuje pętlę do momentu powrotu. Blokujący time.sleep() na 200 ms, zapis na kartę SD trwający 80 ms do opróżnienia, duża kompresja JPEG, wywołanie csi.CSI.snapshot() – każde z nich trzyma pętlę zdarzeń przez cały swój czas trwania. Naprawa zależy od wywołania:

  • Dla time.sleep: zastąp je przez await asyncio.sleep lub await asyncio.sleep_ms.

  • Dla csi.CSI.snapshot: użyj asynchronicznego wrappera zrzutu obrazu, który buduje strona o przechwytywaniu.

  • Dla długich obliczeń (przetwarzanie obrazu, kodowanie JPEG): zaakceptuj koszt lub podziel pracę na fragmenty wykonujące await pomiędzy iteracjami.

Asyncio nie może uczynić wywołania synchronicznego nieblokującym. Może jedynie pozwolić innym korutynom działać podczas, gdy coś innego oczekuje.