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

Dlaczego po asyncio jest wolniej? Pułapki wydajności w kodzie asynchronicznym

Autor: Aisha Mensah 25 maja 2024 ok. 12 min czytania

Po asyncio sięgamy zwykle wtedy, gdy chcemy przyspieszyć operacje I/O: zapytania HTTP, dostęp do bazy danych, pracę z plikami. Główny zysk polega na tym, że gdy jedno zadanie czeka na odpowiedź, event loop może obsługiwać inne.

W praktyce jednak często zdarza się, że po"przepisaniu na asyncio” API staje się wolniejsze, a zużycie zasobów serwera rośnie. To zwykle nie jest wina samego asyncio, tylko sposobu, w jaki łączymy je z istniejącym kodem i infrastrukturą. Poniżej zebrane są najczęstsze kierunki diagnozy.

I. Blokujące operacje w środku kodu asynchronicznego

Warunkiem skuteczności asyncio jest to, żeby operacje I/O oddawały sterowanie (tzw. yield). Event loop jest jedno­wątkowy – jeżeli w środku korutyny wywołasz klasyczną, blokującą funkcję, zatrzymasz cały loop.

Typowe wpadki:

  • time.sleep() użyte"dla testu” w async def,
  • zapytania HTTP przez requests zamiast aiohttp/httpx,
  • wywołania klasycznych driverów DB (np. pymysql, psycopg2) bezpośrednio z korutyny.
Python – antywzorzec i poprawka
import asyncio
import time


async def fetch_data_blocking():
    # Zły przykład: blokuje event loop na 2 sekundy
    time.sleep(2)
    return {"status": "ok"}


async def fetch_data_async():
    # Dobry przykład: oddaje sterowanie do event loopa
    await asyncio.sleep(2)
    return {"status": "ok"}

Jeśli z różnych powodów musisz korzystać z biblioteki synchronicznej, rozważ uruchamianie jej w osobnym wątku/procesie (np. przez asyncio.to_thread() lub run_in_executor()), tak aby nie zatrzymywać głównego event loopa.

II."Pseudo‑asynchroniczność”: kod nadal wykonuje się sekwencyjnie

Samo dodanie async/await nie oznacza jeszcze współbieżności. Jeżeli wywołujesz kilka niezależnych operacji I/O w kolejnych liniach z osobnymi await, to de facto dalej robisz to po kolei.

Python – sekwencyjnie vs współbieżnie
# Zły przykład: zadania wykonają się jedno po drugim
async def process_sequential():
    r1 = await fetch_task1()
    r2 = await fetch_task2()
    return r1, r2


# Dobry przykład: fetch_task1 i fetch_task2 lecą współbieżnie
async def process_concurrent():
    r1, r2 = await asyncio.gather(
        fetch_task1(),
        fetch_task2(),
    )
    return r1, r2

W diagnostyce zacznij od przejrzenia miejsc, gdzie masz kilka kolejnych await. Jeśli dane nie zależą od siebie, to kandydaci do gather() lub create_task().

III. Zadania CPU‑intensywne w korutynach

asyncio rozwiązuje problem czekania na I/O – nie przyspiesza ciężkich obliczeń. Jeżeli w korutynach wykonujesz np. serializację ogromnych JSON-ów lub inne CPU‑intensywne operacje, event loop nie będzie miał kiedy przełączyć się na pozostałe zadania.

W takich przypadkach warto wydzielić obliczenia do procesów (np. multiprocessing) albo przynajmniej używać asyncio.to_thread(), by przerzucić je poza główny loop.

IV. Locki, N+1 i inne problemy architektoniczne przy wysokiej współbieżności

Przy rosnącej liczbie zapytań nawet niewielkie braki w architekturze stają się poważnym hamulcem.

  • Konflikty na blokadach (lockach) – np. wiele korutyn jednocześnie walczy o ten sam lock w Redisie i agresywnie powtarza próby (polling), co generuje duży ruch i blokuje inne operacje.
  • Problem N+1 zapytań – pętla, w której dla każdego elementu robisz osobne wywołanie API/DB. Asynchroniczność może tylko zakamuflować problem, ale go nie usuwa – warto przeprojektować to na zapytania batchowe.
  • Głębokie paginowanie bez indeksów – zapytania SQL typu LIMIT ... OFFSET ... na dużych tabelach, bez odpowiednich indeksów, będą wolne niezależnie od tego, czy uruchamia je kod sync czy async.
V. Problemy konfiguracyjne i zależności zewnętrzne

Część spadków wydajności nie wynika z samego Pythona, ale z konfiguracji usług, z którymi nasz kod rozmawia.

  • Brak timeoutów – zapytanie bez limitu czasu potrafi wisieć bardzo długo, zajmując gniazdo sieciowe i miejsce w pulach połączeń. W asyncio można użyć asyncio.wait_for() lub wbudowanych timeoutów klientów HTTP/DB.
  • Złe parametry puli wątków – jeśli część pracy musi trafić do ThreadPool/ProcessPool, zbyt mała liczba wątków tworzy kolejkę zadań, a zbyt duża powoduje nadmierny koszt przełączania kontekstu.
  • Ograniczenia po stronie DB / zewnętrznych API – zbyt wiele równoległych zapytań może trafić w limity rate‑limit lub saturację zasobów serwera bazodanowego.
VI. Trzy rzeczy do sprawdzenia przed głębszą analizą
  • Czy biblioteki naprawdę wspierają async?
    Nigdy nie wywołuj synchronicznych ORM‑ów ani requests z wnętrza async def. To najczęstszy i najbardziej kosztowny błąd.
  • Czy niezależne wywołania I/O są opakowane w gather()?
    Jeżeli dwa zapytania nie muszą czekać na siebie nawzajem, nie powinny stać jedno za drugim w kodzie – przejrzyj sekwencje await.
  • Czy wszystkie zewnętrzne wywołania mają timeout?
    Brak limitu czasu sprawia, że pojedyncze"zawieszone” połączenie może stopniowo wyczerpać zasoby procesu, a ostatecznie nawet całego hosta.
Poprzedni artykuł Python asyncio w praktyce: mechanizmy i debugowanie"wolnego” API
Następny artykuł Korutyny w Pythonie: event loop oraz asyncio.gather i as_completed