Wróć na górę
Artykuły / Python

Python asyncio w praktyce: mechanizmy i debugowanie"wolnego” API

Autor: Sofia Romero 5 maja 2024 ok. 15 min czytania

Wielu programistów sięga po asyncio, gdy pojawiają się zapytania HTTP, zapytania do bazy czy operacje na plikach. W teorii ma to zwiększyć przepustowość API, ale w praktyce często słyszymy:"po migracji na asyncio mój endpoint jest wolniejszy niż wcześniej”.

Żeby uniknąć takiej pułapki, nie wystarczy znać składnię async / await. Trzeba zrozumieć, jak działa harmonogram zadań pod spodem – wtedy łatwiej zauważyć, gdzie tak naprawdę tracimy czas.

I. Trzy filary asyncio: korutyny, zadania i event loop

W modelu asynchronicznym CPU nie"stoi w kolejce” na odpowiedź z sieci czy dysku. Gdy jakiś fragment kodu musi poczekać, oddaje sterowanie do event loopa, który zajmuje się innymi zadaniami. To wszystko opiera się na trzech pojęciach:

1. Korutyna

Funkcja oznaczona async def. Jej wywołanie nie uruchamia kodu od razu, tylko zwraca obiekt korutyny. Wewnątrz korutyny można ją pauzować i wznawiać za pomocą await – to właśnie tam"oddajemy sterowanie”.

2. Zadanie (Task)

Task to korutyna opakowana w obiekt zarządzany przez event loop. Zawiera stan wykonania (oczekujące, w trakcie, zakończone). Tylko zadania są realnie planowane do wykonania.

3. Pętla zdarzeń (event loop)

To"dyrygent” całego systemu: w kółko sprawdza, które zadania są gotowe do uruchomienia, przekazuje im sterowanie, a gdy te się zatrzymają na await, wraca do obsługi kolejnych. Można go porównać do karuzeli, na której wagonikami są zadania.

II. Poprawne uruchamianie kodu asynchronicznego

Typowy refaktoring kodu synchronicznego do asynchronicznego przebiega w trzech krokach: definiujemy korutyny, opakowujemy je w zadania i uruchamiamy całość w event loopie.

Poniżej minimalny przykład, który pokazuje współbieżne wykonanie dwóch zadań I/O:

Python
import asyncio


async def fetch_data(task_id: int, delay: float) -> str:
    print(f"Task {task_id}: start pobierania danych...")
    # symulacja operacji I/O
    await asyncio.sleep(delay)
    print(f"Task {task_id}: dane pobrane")
    return f"Result {task_id}"


async def main():
    # współbieżne uruchomienie dwóch zadań
    results = await asyncio.gather(
        fetch_data(1, 2),
        fetch_data(2, 1),
    )
    print(f"Wszystkie wyniki: {results}")


if __name__ == "__main__":
    asyncio.run(main())

W tym przykładzie pierwsze zadanie trwa 2 sekundy, drugie 1 sekundę. Dzięki współbieżności całkowity czas wykonania wynosi około 2 sekund, a nie 3.

Błąd SyntaxError: 'await' outside function oznacza zwykle, że await pojawił się poza async def – np. w zwykłej funkcji albo na poziomie modułu.

III. Jak planować wiele zadań: gather vs as_completed

Gdy mamy więcej korutyn, możemy nimi zarządzać na dwa główne sposoby: "wszystko naraz i czekamy na komplet wyników” albo"przetwarzamy to, co już się zakończyło”.

  • asyncio.gather – przyjmuje wiele korutyn i zwraca listę wyników w tej samej kolejności, w jakiej zostały przekazane. Dobre do scenariuszy typu "zbierz wszystkie dane, a potem coś z nimi zrób”.
  • asyncio.as_completed – zwraca iterator, który daje wyniki w kolejności ukończenia zadań. Sprawdza się, gdy chcesz przetwarzać wyniki"na bieżąco”.
IV. Dlaczego po migracji na asyncio bywa wolniej niż wcześniej?

Jeśli po wprowadzeniu asyncio API reaguje wolniej, najczęściej winne są poniższe wzorce:

1. Blokujące operacje w środku korutyny

Użycie time.sleep(), synchronicznego klienta HTTP (requests) albo klasycznych driverów baz danych (np. psycopg2) w środku async def zatrzymuje cały event loop – wszystkie zadania czekają razem.

Rozwiązanie: używaj bibliotek z natywnym wsparciem dla asyncio (np. aiohttp, aiofiles, asynchroniczne drivery do DB).

2."Asynchroniczny” kod wykonywany sekwencyjnie

Sekwencja:

Antywzorzec
await task1()
await task2()
await task3()

to wciąż wykonywanie po kolei. Żeby naprawdę skorzystać z asynchroniczności, trzeba użyć asyncio.gather() albo jawnie tworzyć zadania przez asyncio.create_task().

3. Kod CPU‑intensywny w event loopie

asyncio pomaga przy I/O, nie przy ciężkich obliczeniach. Jeśli w środku korutyny wykonujesz np. złożoną serializację dużego JSON-a albo przetwarzanie danych, event loop nie ma kiedy przełączyć się na inne zadania.

4. Złe mostkowanie kodu synchronicznego

Stare, synchroniczne funkcje, których nie da się łatwo przepisać, nie powinny blokować event loopa. W takich przypadkach można je przenieść do osobnego wątku:

Python
import asyncio


def do_blocking_work(arg):
    # stara, blokująca funkcja
    ...


async def wrapper():
    result = await asyncio.to_thread(do_blocking_work, "parametr")
    return result

Dzięki temu ciężka funkcja nie blokuje głównego loopa.

5. Problemy architektoniczne: blokady, N+1, brak timeoutów
  • Locki i kolejki – agresywne użycie blokad (np. wokół Redisowych locków) potrafi"zdusić” przepustowość.
  • N+1 zapytań lub głębokie paginacje – jeśli w pętli strzelasz do API/DB bez indeksów, asynchroniczność nie pomoże – co najwyżej równolegle przyspieszysz zatykanie backendu.
  • Brak timeoutów – pojedyncze zawieszone połączenie bez limitu czasu potrafi na długo zająć zasoby.
V. Kilka praktycznych wskazówek na koniec

Asynchroniczność nie jest magicznym przyspieszaczem. Przed refaktorem do asyncio upewnij się, że Twój problem jest rzeczywiście I/O‑intensywny i że biblioteki, których używasz, mają dobre wsparcie asynchroniczne.

W trakcie pracy warto regularnie zadawać sobie trzy pytania kontrolne:

  • Czy wszystkie używane biblioteki mają wersje async?
    Jeżeli w async def wołasz requests.get() albo synchroniczne ORM-y, zacznij od ich wymiany.
  • Czy naprawdę uruchamiasz zadania współbieżnie?
    Sprawdź, czy w miejscach, gdzie chcesz mieć współbieżność, używasz asyncio.gather() lub create_task(), a nie sekwencji kilku await pod rząd.
  • Czy masz timeouty i izolację blokujących fragmentów?
    Dodaj limity czasu do zewnętrznych wywołań i wrzucaj nieuniknione, blokujące funkcje do asyncio.to_thread(), żeby event loop miał swobodę przełączania zadań.
Poprzedni artykuł Kompatybilność Pythona: od zmian w składni do C API 3.14
Następny artykuł Dlaczego po asyncio jest wolniej? Pułapki wydajności w kodzie asynchronicznym