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

Korutyny w Pythonie: event loop oraz asyncio.gather i as_completed

Autor: Leo Tanaka 10 czerwca 2024 ok. 15 min czytania

Przy pracy z I/O – HTTP, bazami danych, kolejkami – Python oferuje kilka sposobów na współbieżność. Dla wielu osób zaczynających przygodę z nowoczesnym Pythonem (FastAPI, asyncio) wybór między wątkami, procesami a korutynami bywa trudny.

W skrócie:

  • Multiprocessing omija GIL i jest dobry dla zadań CPU‑intensywnych, ale ma wysoki koszt uruchomienia i komunikacji.
  • Threading sprawdza się przy I/O, ale nadal podlega ograniczeniom systemowych wątków i GIL‑owi.
  • Korutyny (asyncio) działają w jednym wątku, są bardzo lekkie pamięciowo i świetnie nadają się do obsługi tysięcy połączeń sieciowych.

W tym tekście skupimy się na asyncio: jak działa event loop, jak definiować korutyny oraz jak zarządzać wieloma zadaniami za pomocą gather i as_completed.

I. Jak działa event loop?

Event loop możesz traktować jak jednego kucharza w restauracji: zamówienia to zadania, a piekarnik czy mikrofalówka to operacje I/O. Kucharz wkłada danie do piekarnika (wysyła zapytanie HTTP), ustawia timer i nie czeka przy drzwiczkach – wraca do innych zamówień. Gdy piekarnik"dingnie”, wraca do tego konkretnego dania.

W kodzie tę pauzę reprezentuje await. Zatrzymuje ono bieżącą korutynę i oddaje sterowanie do event loopa, który zajmuje się pozostałymi zadaniami.

II. Definiowanie i uruchamianie podstawowej korutyny

Funkcja staje się korutyną, gdy dodasz przed nią słowo kluczowe async. Jej wywołanie nie wykonuje kodu od razu, tylko zwraca obiekt korutyny.

Python
import asyncio


async def fetch_data(task_id: int, delay: float) -> str:
    print(f"Zadanie {task_id} startuje...")
    await asyncio.sleep(delay)  # symulacja I/O
    return f"Zadanie {task_id} zakończone"


async def main():
    result = await fetch_data(1, 1.5)
    print(result)


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

fetch_data jest korutyną – nie można jej"po prostu wywołać”. Musi zostać uruchomiona z poziomu innej korutyny za pomocą await albo przez asyncio.run() jako główny punkt wejścia programu.

III. Zarządzanie wieloma zadaniami: gather vs as_completed

W realnych systemach zwykle nie pobieramy jednej rzeczy na raz, tylko wiele – np. kilku obrazów, kilku API albo wielu rekordów z bazy. Czyste for ... await jedno po drugim kładzie współbieżność. Tu z pomocą przychodzą dwie funkcje z asyncio.

1. asyncio.gather – czekamy na komplet danych

Dobre tam, gdzie sens ma tylko zestaw pełnych wyników – np. wszystkie elementy strony muszą być gotowe, zanim ją wyrenderujesz.

Python
async def run_gather():
    print("--- test gather ---")
    results = await asyncio.gather(
        fetch_data("obróbka obrazu", 2),
        fetch_data("analiza tekstu", 1),
    )
    print(f"Wyniki: {results}")

gather uruchamia korutyny współbieżnie, ale zwraca listę wyników w dokładnie takiej kolejności, w jakiej przekazałeś argumenty.

2. asyncio.as_completed – przetwarzaj na bieżąco

Gdy ważniejsze jest"kto pierwszy, ten lepszy” – np. streaming wielu źródeł, albo chcesz od razu obsługiwać szybko odpowiadające API – bardziej pasuje as_completed.

Python
async def run_as_completed():
    print("--- test as_completed ---")
    tasks = [
        fetch_data("obróbka obrazu", 2),
        fetch_data("analiza tekstu", 1),
    ]

    for coro in asyncio.as_completed(tasks):
        result = await coro
        print(f"Odebrano: {result}")

W tym przypadku najpierw zobaczysz wynik zadania z opóźnieniem 1 s, a dopiero później z 2 s – niezależnie od ich kolejności na liście.

IV. Częste problemy środowiskowe i jak na nie reagować
  • Brak await
    Zamiast wyniku widzisz w logach coś w stylu <coroutine object fetch_data at 0x...> – to znak, że korutyna nigdy nie została odczekana ani zamieniona na zadanie.
  • RuntimeError: asyncio.run() cannot be called from a running event loop
    Typowe w Jupyterze/IPythonie, gdzie event loop już działa w tle. Tam wywołujesz po prostu await main(), zamiast asyncio.run(main()).
  • Brak zysku wydajności
    Kiedy w async def użyjesz time.sleep(), requests.get() albo synchronicznego klienta DB, blokujesz jednowątkowy event loop. Zastąp to asyncio.sleep() lub bibliotekami z natywnym wsparciem async (aiohttp/httpx, async ORM itp.).
V. Kiedy warto, a kiedy nie warto sięgać po korutyny?

Korutyny najlepiej sprawdzają się tam, gdzie dużo czekasz na sieć lub dysk – zapytania HTTP, mikroserwisy, intensywne korzystanie z baz i kolejek. W takich scenariuszach asyncio potrafi znacząco zwiększyć przepustowość.

Jeżeli Twoja aplikacja głównie"mieli” dane (np. transkodowanie wideo, ciężkie ETL), dużo lepszym wyborem będzie multiprocessing albo przeniesienie krytycznych fragmentów do C/Rusta.

VI. Trzy krótkie checklisty na koniec
  • Czy Twoje I/O naprawdę jest async‑friendly?
    Sprawdź, czy biblioteki używane w korutynach mają natywne wsparcie async. requests w async def to czerwone światło.
  • Czy poprawnie wywołujesz gather()?
    Jeśli masz listę korutyn, pamiętaj o *: asyncio.gather(*task_list), inaczej przekażesz listę jako jeden argument.
  • Czy rozumiesz różnicę między korutyną a Taskiem?
    Jeśli chcesz, żeby coś"szło w tle”, twórz Task przez asyncio.create_task(); samo await zawsze zatrzymuje bieżącą korutynę do czasu zakończenia wywołania.
Poprzedni artykuł Dlaczego po asyncio jest wolniej? Pułapki wydajności w kodzie asynchronicznym
Następny artykuł "Przepisałem kernel na Rust i nic nie przyspieszyło” – o pułapkach optymalizacji między językami