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?

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.

Zalety asyncio:

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?

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())

  1. Funkcja asynchroniczna, która zawiesza swoje wykonanie na 1 sekundę przy użyciu await asyncio.sleep(1).
  2. Główna funkcja asynchroniczna, która wywołuje moja_korutyna za pomocą await.
  3. 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())

Korzyści z użycia asyncio.create_task():

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()

Uwaga:

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ń.

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ń.

Różnice między kodem synchronicznym a asynchronicznym
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 main:

Działanie programu:

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())

Zalety użycia asyncio.create_task:

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

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")

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")

Wnioski:

Dlaczego asyncio jest szybsze?
Kiedy używać asyncio?

Przykłady zastosowań:

Spis Treści

    Programowanie asynchroniczne
    1. Dlaczego programowanie asynchroniczne jest ważne?
      1. Problem z GIL w Pythonie
    2. Wprowadzenie do asyncio
      1. Zalety asyncio:
      2. Podstawy asyncio
      3. Co zmienia się, gdy używamy async def zamiast def?
      4. Rola pętli zdarzeń
      5. Wywoływanie korutyn
      6. Co zmienia async? Wykonywanie synchroniczne vs asynchroniczne
      7. Wykonywanie wielu korutyn równocześnie
      8. Jak asyncio zwiększa wydajność