Last modified: September 22, 2024
This article is written in: 🇵🇱
Programowanie asynchroniczne
Programowanie asynchroniczne to paradygmat, który umożliwia wykonywanie operacji w sposób nieblokujący, pozwalając na równoczesne przetwarzanie wielu zadań w ramach jednego wątku. W przeciwieństwie do tradycyjnego programowania synchronicznego, gdzie operacje są wykonywane sekwencyjnie i każda musi zakończyć się przed rozpoczęciem kolejnej, programowanie asynchroniczne pozwala na zawieszenie wykonania operacji, która oczekuje na wynik (np. operacji wejścia/wyjścia), i przełączenie się na wykonywanie innej operacji. Dzięki temu zasoby systemowe są wykorzystywane efektywniej, co jest szczególnie ważne w aplikacjach, które są ograniczone przez operacje I/O (tzw. I/O-bound).
Dlaczego programowanie asynchroniczne jest ważne?
- Pozwala na lepsze wykorzystanie czasu procesora poprzez przełączanie między zadaniami podczas oczekiwania na zakończenie operacji I/O.
- Umożliwia obsługę większej liczby jednoczesnych połączeń czy zadań bez konieczności tworzenia wielu wątków lub procesów.
- Pomaga uniknąć sytuacji, w których program jest zablokowany i nie może kontynuować pracy z powodu oczekiwania na zakończenie czasochłonnej operacji.
Problem z GIL w Pythonie
Python posiada mechanizm zwany Global Interpreter Lock (GIL), który uniemożliwia jednoczesne wykonywanie kodu Pythona w wielu wątkach. Oznacza to, że nawet jeśli stworzymy wiele wątków, tylko jeden z nich może wykonywać kod Pythona w danym momencie. asyncio
pozwala obejść ten problem, umożliwiając asynchroniczne wykonywanie operacji w jednym wątku, co eliminuje potrzebę zarządzania wieloma wątkami i synchronizacją między nimi.
Wprowadzenie do asyncio
asyncio
to biblioteka standardowa w Pythonie (od wersji 3.4), która wprowadza wsparcie dla programowania asynchronicznego za pomocą korutyn, pętli zdarzeń, zadań i przyszłości (ang. futures). Pozwala ona na pisanie jednowątkowego kodu asynchronicznego, który jest zarówno czytelny, jak i wydajny.
- Korutyny to specjalne funkcje oznaczone słowem kluczowym
async
, które mogą być zawieszone i wznowione, co umożliwia przełączanie między zadaniami bez blokowania wątku. - Pętla zdarzeń to mechanizm zarządzający wykonywaniem korutyn i obsługą zdarzeń asynchronicznych.
- Zadania (Tasks) są abstrakcją reprezentującą wykonywanie korutyn, która może być zarządzana przez pętlę zdarzeń.
Zalety asyncio
:
- Pozwala na obsługę tysięcy jednoczesnych połączeń bez znaczącego obciążenia systemu.
- Umożliwia pisanie asynchronicznego kodu w sposób zbliżony do kodu synchronicznego.
- Łatwo integruje się z innymi bibliotekami i frameworkami asynchronicznymi.
Podstawy asyncio
Aby funkcja mogła stać się korutyną, należy zadeklarować ją przy użyciu słowa kluczowego async def
. Taka funkcja zwraca obiekt korutyny, który reprezentuje jej przyszłe wykonanie.
Przykład:
async def moja_korutyna():
print("Witaj!")
Co zmienia się, gdy używamy async def
zamiast def
?
- Wywołanie funkcji zdefiniowanej jako
async def
nie powoduje jej natychmiastowego wykonania. Zamiast tego zwraca obiekt korutyny, który musi być uruchomiony przez pętlę zdarzeń. - Wewnątrz korutyn możemy używać
await
do zawieszania ich wykonania, oczekując na zakończenie innych korutyn lub operacji asynchronicznych. - Korutyny mogą być zawieszane w trakcie działania i wznawiane później, co pozwala na efektywne przełączanie między różnymi zadaniami.
Rola pętli zdarzeń
Pętla zdarzeń jest centralnym mechanizmem w asyncio
, odpowiedzialnym za planowanie i wykonywanie korutyn. Monitoruje ona stan wszystkich zadań i decyduje, które z nich mogą być wykonane w danym momencie. Dzięki temu możliwe jest równoczesne zarządzanie wieloma operacjami asynchronicznymi w jednym wątku.
Wywoływanie korutyn
Istnieje kilka sposobów na uruchomienie i zarządzanie korutynami. Omówimy trzy główne metody:
I. Wywoływanie za pomocą await
z innych funkcji asynchronicznych
Najprostszym sposobem uruchomienia korutyny jest użycie await
wewnątrz innej korutyny. Pozwala to na sekwencyjne wykonywanie operacji asynchronicznych.
Przykład:
import asyncio
async def moja_korutyna():
print("Początek korutyny")
await asyncio.sleep(1) # Zawieszenie korutyny na 1 sekundę
print("Koniec korutyny po 1 sekundzie")
async def main():
print("Rozpoczynam")
await moja_korutyna() # Czekamy na zakończenie korutyny
print("Zakończono")
asyncio.run(main())
- Funkcja asynchroniczna, która zawiesza swoje wykonanie na 1 sekundę przy użyciu
await asyncio.sleep(1)
. - Główna funkcja asynchroniczna, która wywołuje
moja_korutyna
za pomocąawait
. asyncio.run(main())
inicjuje pętlę zdarzeń i uruchamia korutynęmain
.
Dlaczego używamy await
?
Słowo kluczowe await
powoduje, że korutyna zostaje zawieszona do momentu zakończenia oczekiwanej operacji. W tym czasie pętla zdarzeń może przydzielić zasoby innym korutynom, co zwiększa efektywność programu.
Porównanie z kodem synchronicznym:
import time
def moja_funkcja():
print("Początek funkcji")
time.sleep(1) # Zawieszenie funkcji na 1 sekundę
print("Koniec funkcji po 1 sekundzie")
def main():
print("Rozpoczynam")
moja_funkcja() # Blokujemy wykonanie do zakończenia funkcji
print("Zakończono")
if __name__ == "__main__":
main()
W wersji synchronicznej, podczas wykonywania time.sleep(1)
, program jest zablokowany i nie może wykonywać innych operacji. W wersji asynchronicznej, pętla zdarzeń może przełączać się między różnymi korutynami.
II. Uruchamianie korutyn równolegle za pomocą asyncio.create_task()
Aby wykonywać korutyny równocześnie (w sensie asynchronicznym), możemy użyć asyncio.create_task()
, który tworzy zadanie zarządzane przez pętlę zdarzeń.
Przykład:
import asyncio
async def moja_korutyna():
print("Początek korutyny")
await asyncio.sleep(1)
print("Koniec korutyny po 1 sekundzie")
async def main():
print("Rozpoczynam")
task = asyncio.create_task(moja_korutyna()) # Tworzymy zadanie asynchroniczne
print("Inne działania w main()")
await task # Czekamy na zakończenie zadania
print("Zakończono")
asyncio.run(main())
asyncio.create_task(moja_korutyna())
informuje pętlę zdarzeń o konieczności wykonaniamoja_korutyna
w tle.- Po utworzeniu zadania, korutyna
main
kontynuuje wykonywanie bez oczekiwania na zakończeniemoja_korutyna
. await task
powoduje, żemain
zawiesza się do momentu zakończeniamoja_korutyna
.
Korzyści z użycia asyncio.create_task()
:
- Pozwala na uruchomienie wielu korutyn jednocześnie, co zwiększa efektywność programu.
- Możemy zarządzać zadaniami, np. anulować je, jeśli nie są już potrzebne.
Przykład z wieloma zadaniami:
import asyncio
async def zadanie(nr, czas):
print(f"Zadanie {nr} rozpoczęte")
await asyncio.sleep(czas)
print(f"Zadanie {nr} zakończone po {czas} sekundach")
async def main():
task1 = asyncio.create_task(zadanie(1, 2))
task2 = asyncio.create_task(zadanie(2, 3))
task3 = asyncio.create_task(zadanie(3, 1))
await task1
await task2
await task3
asyncio.run(main())
Wynik:
Zadanie 1 rozpoczęte
Zadanie 2 rozpoczęte
Zadanie 3 rozpoczęte
Zadanie 3 zakończone po 1 sekundach
Zadanie 1 zakończone po 2 sekundach
Zadanie 2 zakończone po 3 sekundach
W tym przykładzie wszystkie zadania są uruchamiane niemal jednocześnie, a ich zakończenie zależy od czasu trwania poszczególnych zadań.
III. Uruchamianie korutyn ze zwykłych funkcji za pomocą pętli zdarzeń
Jeśli chcemy uruchomić korutynę z funkcji synchronicznej (zwykłej funkcji), możemy bezpośrednio użyć pętli zdarzeń.
Przykład:
import asyncio
async def moja_korutyna():
print("Początek korutyny")
await asyncio.sleep(1)
print("Koniec korutyny po 1 sekundzie")
def main():
loop = asyncio.get_event_loop() # Pobieramy bieżącą pętlę zdarzeń
loop.run_until_complete(moja_korutyna()) # Uruchamiamy korutynę i czekamy na jej zakończenie
main()
asyncio.get_event_loop()
zwraca bieżącą pętlę zdarzeń lub tworzy nową, jeśli żadna nie istnieje.loop.run_until_complete(moja_korutyna())
uruchamia korutynę i blokuje wykonanie do jej zakończenia.
Uwaga:
- Od Pythona 3.7 zaleca się używanie
asyncio.run()
zamiast bezpośredniego manipulowania pętlą zdarzeń, chyba że istnieje konkretny powód. - Bezpośrednie użycie pętli zdarzeń jest przydatne w bardziej złożonych scenariuszach, np. w aplikacjach GUI czy serwerach, gdzie pętla zdarzeń jest zarządzana ręcznie.
Co zmienia async
? Wykonywanie synchroniczne vs asynchroniczne
Aby zrozumieć różnice między kodem synchronicznym a asynchronicznym, przeanalizujmy dwa przykłady ilustrujące ich działanie.
Kod synchroniczny
import time
def proste_zadanie():
print("Pracownik przetwarza zadania...")
time.sleep(3) # Symulacja czasochłonnego zadania
print("Pracownik skończył zadanie.")
return 42
def main():
print("Rozpoczynamy główne zadanie.")
wynik = proste_zadanie() # Blokujemy wykonanie do zakończenia zadania
print("Menadżer musiał czekać!")
print(f"Pracownik odpowiedział, że ukończył {wynik} zadań.")
if __name__ == "__main__":
main()
Wynik:
Rozpoczynamy główne zadanie.
Pracownik przetwarza zadania...
Pracownik skończył zadanie.
Menadżer musiał czekać!
Pracownik odpowiedział, że ukończył 42 zadań.
- Podczas wykonywania
time.sleep(3)
, program jest zablokowany i nie może wykonywać innych operacji. - Menadżer musi czekać, aż pracownik skończy zadanie, zanim może kontynuować.
Kod asynchroniczny
import asyncio
async def proste_zadanie():
print("Pracownik przetwarza zadania...")
await asyncio.sleep(3) # Asynchroniczna symulacja czasochłonnego zadania
print("Pracownik skończył zadanie.")
return 42
async def main():
print("Menadżer pyta pracownika o postęp.")
task = asyncio.create_task(proste_zadanie()) # Uruchamiamy zadanie asynchronicznie
print("Menadżer może wykonywać inne zadania w międzyczasie...")
wynik = await task # Oczekujemy na zakończenie zadania
print(f"Pracownik odpowiedział, że ukończył {wynik} zadań.")
asyncio.run(main())
Wynik:
Menadżer pyta pracownika o postęp.
Menadżer może wykonywać inne zadania w międzyczasie...
Pracownik przetwarza zadania...
Pracownik skończył zadanie.
Pracownik odpowiedział, że ukończył 42 zadań.
- Menadżer nie jest zablokowany podczas wykonywania
proste_zadanie
i może wykonywać inne operacje. - Asynchroniczność pozwala na efektywne wykorzystanie czasu oczekiwania.
Różnice między kodem synchronicznym a asynchronicznym
- W kodzie synchronicznym
time.sleep(3)
blokuje cały wątek. W kodzie asynchronicznymawait asyncio.sleep(3)
zawiesza tylko korutynę, pozwalając pętli zdarzeń na wykonywanie innych zadań. - Asynchroniczność pozwala na lepsze wykorzystanie czasu procesora przez przełączanie między zadaniami podczas oczekiwania na operacje I/O.
- Kod asynchroniczny jest bardziej skalowalny w kontekście obsługi wielu jednoczesnych zadań.
Przykład z wieloma pracownikami
Rozszerzmy przykład, aby pokazać, jak asynchroniczność pozwala na równoczesne wykonywanie wielu zadań.
import asyncio
async def pracownik(nr, czas):
print(f"Pracownik {nr} rozpoczął zadanie.")
await asyncio.sleep(czas)
print(f"Pracownik {nr} skończył zadanie po {czas} sekundach.")
return nr * 10
async def main():
print("Menadżer zleca zadania pracownikom.")
tasks = [
asyncio.create_task(pracownik(1, 2)),
asyncio.create_task(pracownik(2, 3)),
asyncio.create_task(pracownik(3, 1)),
]
print("Menadżer może wykonywać inne zadania w międzyczasie...")
wyniki = await asyncio.gather(*tasks)
print(f"Pracownicy ukończyli zadania z wynikami: {wyniki}")
asyncio.run(main())
Wynik:
Menadżer zleca zadania pracownikom.
Menadżer może wykonywać inne zadania w międzyczasie...
Pracownik 1 rozpoczął zadanie.
Pracownik 2 rozpoczął zadanie.
Pracownik 3 rozpoczął zadanie.
Pracownik 3 skończył zadanie po 1 sekundach.
Pracownik 1 skończył zadanie po 2 sekundach.
Pracownik 2 skończył zadanie po 3 sekundach.
Pracownicy ukończyli zadania z wynikami: [10, 20, 30]
Wykonywanie wielu korutyn równocześnie
Asynchroniczność w Pythonie, za pomocą biblioteki asyncio
, pozwala na równoczesne wykonywanie wielu korutyn. Jest to szczególnie przydatne, gdy mamy wiele niezależnych zadań, które mogą być wykonywane jednocześnie, takich jak żądania sieciowe, operacje na plikach czy interakcje z bazami danych. Dzięki temu możemy znacząco zwiększyć efektywność i wydajność naszej aplikacji.
Uruchamianie wielu korutyn za pomocą asyncio.gather
Funkcja asyncio.gather
umożliwia jednoczesne uruchomienie wielu korutyn i oczekiwanie na ich zakończenie. Przyjrzyjmy się temu na konkretnym przykładzie.
Przykład:
import asyncio
async def zadanie(numer, czas):
print(f"Zadanie {numer} rozpoczęte...")
await asyncio.sleep(czas)
print(f"Zadanie {numer} zakończone po {czas} sekundach.")
return f"Wynik zadania {numer}"
async def main():
print("Rozpoczynamy wykonywanie wielu zadań równocześnie.")
wyniki = await asyncio.gather(
zadanie(1, 2),
zadanie(2, 3),
zadanie(3, 1)
)
print("Wszystkie zadania zostały zakończone.")
for wynik in wyniki:
print(wynik)
if __name__ == "__main__":
asyncio.run(main())
Korutyna zadanie
:
- Funkcja
zadanie
przyjmuje dwa argumenty:numer
iczas
. - Wyświetla komunikat o rozpoczęciu zadania.
- Używa
await asyncio.sleep(czas)
, aby symulować czasochłonne operacje (np. żądania sieciowe). - Po upływie określonego czasu wyświetla komunikat o zakończeniu zadania.
- Zwraca wynik w postaci napisu.
Funkcja main
:
- Wyświetla komunikat o rozpoczęciu wykonywania zadań.
- Używa
asyncio.gather
do równoczesnego uruchomienia trzech instancji korutynyzadanie
z różnymi argumentami. asyncio.gather
zwraca listę wyników po zakończeniu wszystkich korutyn.- Po otrzymaniu wyników, wyświetla komunikaty o zakończeniu i wypisuje wyniki poszczególnych zadań.
Działanie programu:
- Wszystkie trzy zadania są uruchamiane niemal jednocześnie.
- Korutyny są wykonywane równolegle (w ramach jednego wątku), co oznacza, że czas wykonania całego zestawu zadań jest zbliżony do czasu najdłuższego pojedynczego zadania (w tym przypadku 3 sekundy).
- W trakcie oczekiwania na zakończenie jednego zadania, inne zadania mogą być wykonywane.
Wynik działania programu:
Rozpoczynamy wykonywanie wielu zadań równocześnie.
Zadanie 1 rozpoczęte...
Zadanie 2 rozpoczęte...
Zadanie 3 rozpoczęte...
Zadanie 3 zakończone po 1 sekundach.
Zadanie 1 zakończone po 2 sekundach.
Zadanie 2 zakończone po 3 sekundach.
Wszystkie zadania zostały zakończone.
Wynik zadania 1
Wynik zadania 2
Wynik zadania 3
Uruchamianie wielu korutyn za pomocą asyncio.create_task
Alternatywnym sposobem jest użycie funkcji asyncio.create_task
, która tworzy zadania asynchroniczne z korutyn. Pozwala to na większą kontrolę nad poszczególnymi zadaniami, np. możliwość ich anulowania czy monitorowania stanu.
Przykład:
import asyncio
async def zadanie(numer, czas):
print(f"Zadanie {numer} rozpoczęte...")
await asyncio.sleep(czas)
print(f"Zadanie {numer} zakończone po {czas} sekundach.")
return f"Wynik zadania {numer}"
async def main():
print("Rozpoczynamy wykonywanie wielu zadań równocześnie.")
task1 = asyncio.create_task(zadanie(1, 2))
task2 = asyncio.create_task(zadanie(2, 3))
task3 = asyncio.create_task(zadanie(3, 1))
# W tym miejscu możemy wykonywać inne operacje
print("Wykonuję inne operacje w main()...")
# Oczekiwanie na zakończenie zadań
wynik1 = await task1
wynik2 = await task2
wynik3 = await task3
print("Wszystkie zadania zostały zakończone.")
print(wynik1)
print(wynik2)
print(wynik3)
if __name__ == "__main__":
asyncio.run(main())
- Tworzymy zadania za pomocą
asyncio.create_task
, co natychmiast planuje ich wykonanie w pętli zdarzeń. - Możemy w międzyczasie wykonywać inne operacje w funkcji
main
. - Każde zadanie jest oczekiwane indywidualnie za pomocą
await taskX
, co daje możliwość kontrolowania kolejności oczekiwania na wyniki.
Zalety użycia asyncio.create_task
:
- Możemy kontrolować każde zadanie z osobna.
- Zadania można anulować za pomocą metody
cancel()
. - Możemy sprawdzać stan zadania (np. czy jest w trakcie wykonywania, zakończone czy anulowane).
Różnice między asyncio.gather
a asyncio.create_task
Cechy | asyncio.gather |
asyncio.create_task |
Sposób Uruchamiania Korutyn | Uruchamia wszystkie korutyny równocześnie i czeka na ich zakończenie. | Tworzy zadanie (Task ) do wykonania w pętli zdarzeń. |
Kolejność Wyników | Zwraca listę wyników w tej samej kolejności, w jakiej korutyny zostały przekazane. | Brak gwarantowanej kolejności wyników, zadania są wykonywane niezależnie. |
Obsługa Wyjątków | Przerywa działanie i propaguje wyjątek, chyba że użyto return_exceptions=True , wtedy zwraca wyjątki jako wyniki. |
Wyjątki w korutynach muszą być obsługiwane ręcznie, nie przerywa to działania innych zadań. |
Kontrola Zadania | Brak bezpośredniej kontroli nad zadaniami, oczekuje na zakończenie wszystkich korutyn. | Pozwala na anulowanie, sprawdzanie stanu zadania i dodawanie callbacków. |
Praktyczne zastosowania
- Gdy potrzebujemy prostego sposobu na równoczesne uruchomienie wielu korutyn i zebranie wyników używamy
asyncio.gather
. - Gdy potrzebujemy większej kontroli nad zadaniami używamy
asyncio.create_task
. - Np. gdy chcemy anulować zadanie po określonym czasie lub gdy chcemy reagować na jego zakończenie za pomocą callbacków.
Jak asyncio
zwiększa wydajność
Wykorzystanie asynchroniczności pozwala na efektywniejsze zarządzanie czasem procesora i operacjami I/O. W tradycyjnym podejściu synchronicznym, gdy program napotka operację I/O, taką jak żądanie sieciowe czy odczyt pliku, musi czekać na jej zakończenie, zanim przejdzie do kolejnej instrukcji. Oznacza to, że czas procesora jest marnowany na bezczynne oczekiwanie.
W asynchroniczności, podczas gdy jedno zadanie czeka na operację I/O, pętla zdarzeń asyncio
może przełączać się na wykonywanie innych korutyn, które są gotowe do działania. Dzięki temu maksymalizujemy wykorzystanie dostępnego czasu procesora i skracamy ogólny czas wykonywania programu.
Przykład: Porównanie wydajności żądań HTTP
Załóżmy, że chcemy wysłać 10 żądań HTTP do tego samego adresu URL.
Podejście synchroniczne:
import requests
import time
adresy_url = ["https://jsonplaceholder.typicode.com/posts/1" for _ in range(10)]
start = time.time()
for adres in adresy_url:
odpowiedz = requests.get(adres)
print(f"Status: {odpowiedz.status_code}")
end = time.time()
print(f"Synchronicznie: {end - start:.2f} sekund")
- Każde żądanie jest wysyłane po zakończeniu poprzedniego.
- Całkowity czas wykonania to suma czasów poszczególnych żądań.
- Jeśli każde żądanie trwa około 0.5 sekundy, to 10 żądań zajmie około 5 sekund.
Podejście asynchroniczne:
import aiohttp
import asyncio
import time
adresy_url = ["https://jsonplaceholder.typicode.com/posts/1" for _ in range(10)]
async def pobierz(adres, sesja):
async with sesja.get(adres) as odpowiedz:
print(f"Status: {odpowiedz.status}")
return await odpowiedz.text()
async def main():
async with aiohttp.ClientSession() as sesja:
zadania = [asyncio.create_task(pobierz(adres, sesja)) for adres in adresy_url]
await asyncio.gather(*zadania)
start = time.time()
asyncio.run(main())
end = time.time()
print(f"Asynchronicznie: {end - start:.2f} sekund")
- Wszystkie żądania są wysyłane niemal jednocześnie.
- Całkowity czas wykonania zbliża się do czasu najdłuższego pojedynczego żądania.
- Jeśli każde żądanie trwa około 0.5 sekundy, to 10 żądań zajmie około 0.5-1 sekundy.
Wnioski:
- Asynchroniczność znacząco skraca czas wykonania operacji I/O, gdyż pozwala na równoczesne oczekiwanie na wyniki wielu żądań.
- Program nie marnuje czasu na bezczynne oczekiwanie, lecz wykorzystuje go na wykonywanie innych zadań.
- Asynchroniczne programy mogą obsługiwać więcej zadań bez proporcjonalnego zwiększania zapotrzebowania na zasoby.
Dlaczego asyncio
jest szybsze?
- Podczas gdy jedno zadanie czeka na operację I/O, inne mogą być wykonywane.
- Asynchroniczność nie wymaga tworzenia nowych wątków czy procesów, co zmniejsza narzut związany z przełączaniem kontekstu i zużyciem pamięci.
asyncio
zarządza kolejnością wykonywania zadań, optymalizując wykorzystanie czasu procesora.
Kiedy używać asyncio
?
- Gdy aplikacja wysyła wiele żądań sieciowych lub obsługuje wiele połączeń (np. serwery HTTP, klienty API).
- Gdy potrzebujemy równocześnie czytać lub zapisywać wiele plików.
- Przy równoczesnym wykonywaniu wielu zapytań do bazy danych.
- Gdy wymagana jest szybka reakcja na zdarzenia (np. aplikacje IoT, komunikatory).
- Gdy głównym ograniczeniem jest czas oczekiwania na operacje I/O, a nie moc obliczeniowa CPU.
Przykłady zastosowań:
- Frameworki takie jak
aiohttp
,FastAPI
czySanic
wykorzystują asynchroniczność do obsługi wielu żądań HTTP jednocześnie. - Pisanie klienta, który komunikuje się z wieloma serwisami API w sposób efektywny.
- Obsługa połączeń WebSocket, gdzie utrzymujemy otwarte połączenia z wieloma klientami.
- Równoczesne pobieranie treści z wielu stron internetowych.