Mateusz Mazurek – programista z pasją

Blog o Pythonie i kilku innych technologiach. Od developera dla wszystkich.

Inżynieria oprogramowania Programowanie Programowanie webowe

Python i programowanie asynchroniczne

Cześć,

dziś zapraszam Cię na artykuł o programowaniu asynchronicznym, czyli bardzo sprytnym sposobie dającym wrażenie współbieżności. Zaczynajmy.

Wstęp

Najpopularniejszym sposobem pisania programów jest programowanie synchronicznie, gdzie po prostu kolejne linijki kodu są wykonywane w konkretnej kolejności, zgodnie z ustalonym przepływem, sterowanym ifami, funkcjami czy pętlami.

W alternatywie do tego podejścia stoi programowanie asynchroniczne, która powoduje, że wspomniana wcześniej kolejność nie zostaje zachowana.

Czym jest programowanie asynchroniczne?

Idea jest dość prosta.

Wyobraźmy sobie, że mamy kod który wysyła żądania HTTP do zewnętrznego serwisu. Na potrzeby tego przykładu dodam, że ten serwis długo realizuje to żądanie. Co się dzieje w takim przypadku z kodem synchronicznym? Ano nic. Czeka grzecznie na odpowiedź na wysłane żądanie. Czy to nie jest marnotrawstwo czasu procesora? Wszak w tym momencie program mógłby robić coś innego a gdy odpowiedź z zewnętrznego serwisu wreszcie dotrze, to zacząć wykonywać kod, tam gdzie skończył to robić.

I to cała zasada działania programowania asynchronicznego. Kod zamiast aktywnie czekać na zakończenie operacji I/O (wejścia/wyjścia) typu żądania HTTP, selecty do bazy, czytanie pliku itp. – zajmuje się innymi zadaniami.

Zadania czekają na niego w kolejce. Natomiast wybieraniem z kolejki zajmuje się pętla zwana pętlą zdarzeń czyli eventloop.

Zadania które czekają w pętli zdarzeń nazywamy korutynami (ang. coroutines) lub koprocedurami. Termin ten ma polskie tłumaczenie i brzmi ono „współprogram”.

Idea sięgająca lat 50. XX wieku

Pomysł, by zawieszać wykonywanie się funkcji i w tym czasie uruchamiać inną jest dość stary. Pierwszy raz słowa coroutine użyto w 1958 (!) roku, a kilka lat później w 1963 roku pierwszy raz opisano korutyny. Aktualna moda na programowanie asynchroniczne, to po prostu kolejna powtórka z historii.

Python i programowanie asynchroniczne

Co ciekawe Python posiadał wsparcie dla programowania asynchronicznego już od wersji… 1.5.2. Miałem wtedy 5 lat, bo wg źródeł, kod owego wsparcia powstał w 1996 roku.

Pisząc „posiadał wsparcie” mam na myśli moduł asyncore który dziś jest już oczywiście przestarzały i istnieje w kodzie biblioteki Pythona tylko i wyłączenie w celu zapewnienia wstecznej kompatybilności.

Mimo wbudowanego wsparcia, asyncore nie podbił serc programistów. Tamte lata, pod względem programowania asynchronicznego w Pythonie, bezsprzecznie należały do biblioteki Twisted.

Najpopularniejsze Python’owe podejście do programowania asynchronicznego

Przez wiele lat biblioteki asynchroniczne były implementowane w oparciu o generatory. W tym miejscu bardzo zachęcam do przeczytania zlinkowanego artykułu, dobre zrozumienie tego, czym są i jak działają generatory, pozwoli Ci sprawniej pojąć, że praktycznie dostarczają najważniejszą rzecz, istotną przy implementacji asynchroniczności tj możliwość zawieszenia wykonywania się funkcji.

Najpierw stwórzmy dwa generatory i dodajmy je do kolejki:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from collections import deque


def first_coro_method():
    print(1)
    yield
    print(2)
    print(3)
    yield
    print(4)
    print(5)


def second_coro_method():
    print("aa")
    yield
    print("bb")
    print("bb")
    yield
    print("cc")
    print("cc")


queue = deque()
queue.appendleft(first_coro_method())
queue.appendleft(second_coro_method())

To są odpowiedniki korutyn. Teraz potrzeba nam jeszcze pętli zdarzeń.

W najprostszej postaci może ona wyglądać tak:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
while True:
    try:
        current_coro = queue.pop()
        if current_coro:
            try:
                next(current_coro)
            except StopIteration as e:
                pass
            else:
                queue.appendleft(current_coro)

    except IndexError as x:
        sleep(0.1)
    except Exception as x:
        pass

Jej sposób działania opiera się wprost o generatory. Za każdym obiegiem pętli pobieramy z kolejki generator, wykonujemy jego kawałek i jeśli nie zakończył się on exceptionem, to wrzucamy ponownie do kolejki. Sprawia to wrażenie współbieżności – wykonywanie się obu funkcji jest przeplatane. Program po uruchomieniu pokaże:

1
aa
2
3
bb
bb
4
5
cc
cc

Wyżej pokazany kawałek kodu, mimo, że nieźle pokazuje, jak działa pętla zdarzeń, to ma jedno szczególne uproszczenie. W tym przypadku zawsze wiemy, kiedy korutyna powinna wrócić do kolejki. W przypadku prawdziwych pętli – trzeba umieć określić, kiedy np. odczyt z pliku czy żądanie HTTP się zakończy. Ale to temat na inny artykuł, na razie zajmiemy się tylko ideą działania.


Czekaj, stop!

Podoba Ci się to co tworzę? Jeśli tak to zapraszam Cię do zapisania się na newsletter:
a w ramach prezentu otrzymasz całkowicie za darmo, dwa dokumenty PDF „6 (nie zawsze oczywistych) błędów popełnianych podczas nauki programowania” który jest jednym z efektów ponad siedmioletniej pracy i obserwacji rozwoju niejednego programisty oraz „Wstęp do testowania w Pythonie”, będący wprowadzeniem do biblioteki PyTest.
Jeśli to Cię interesuje to zapraszam również na swoje social media.

Przygotowanie do przykładu

Zanim przejdziemy do przykładów, przygotujmy sobie mały serwerek HTTP:

1
2
3
4
5
6
7
8
9
10
11
12
13
from flask import Flask
import time

app = Flask(__name__)


@app.route("/")
def hello_world():
    time.sleep(5)
    return "Hello!"


app.run(host='127.0.0.1', port=1234, processes=3, threaded=False)

Jak widać, odpowiada on na żądanie GET z 5 sekundowym opóźnieniem, zwracając „Hello!”. Klient synchroniczny mógłby wyglądać tak:

1
2
3
4
5
6
7
8
9
10
11
12
13
import requests
import time

REPEATS = 3

start = time.time()

for _ in range(REPEATS):
    requests.get('http://localhost:1234')

stop = time.time()

print("Exec time:", stop-start, "[s]")

Nie trudno przewidzieć, że kod bedzie wykonywał się około 15 sekund (3 żądania, każde przytrzymane przez Flaska na 5s). Output potwierdza przypuszczenia:

Exec time: 15.04454255104065 [s]

Asyncio

Od jakiegoś czasu Python dostarcza w ramach biblioteki standardowej libkę o nazwie asyncio, która pozwala na pracę z korutynami. W ramach bonusu, wprowadzono słowa kluczowe async i await. Ta pierwsza służy do definicji korutyny a druga do jej uruchomienia.

Pewna niedogodnością asyncio jest fakt, że żeby cokolwiek mogło z nim współpracować, musi być odpowiednio przygotowane. I tak dla przykładu, żeby móc wysłać żądanie HTTP w sposób asynchroniczny, to trzeba doinstalować bibliotekę aiohttp i dopiero z jej pomocą stworzyć kod asynchroniczny:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import time
import asyncio
import aiohttp

REPEATS = 3


async def _make_req(session):
    async with session.get('http://localhost:1234'):
        pass


async def main():
    async with aiohttp.ClientSession() as session:
        await asyncio.gather(*[_make_req(session) for x in range(REPEATS)])

start = time.time()

asyncio.run(main())

stop = time.time()

print("Exec time:", stop-start, "[s]")

Co daje wynik:

Exec time: 5.016620874404907 [s]

W pewnym stopniu można sprawić, żeby synchroniczny kod mógł nie blokować pętli. Wtedy asyncio uruchomi po prostu ten kod w puli wątków. Służą do tego executory i można je wykorzystać w taki sposób:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import requests
import time
import asyncio

REPEATS = 3


async def main():
    loop = asyncio.get_running_loop()
    await asyncio.gather(*[loop.run_in_executor(None, requests.get, 'http://localhost:1234') for _ in range(REPEATS)])

start = time.time()
asyncio.run(main())
stop = time.time()

print("Exec time:", stop-start, "[s]")

Co również wykonuje się w okolicach 5 sekund. Executory są ostatecznością służącą do zmuszenia kodu synchronicznego, by nie blokował pętli zdarzeń.

Kiedy stosować programowanie asynchroniczne?

Poprzedni akapit to przykład, który pokazuje, że stosując programowanie asynchroniczne, możemy sporo ugrać. Ale czy to znaczy, że zawsze powinniśmy je stosować? Ano nie! Możliwe, że już wywnioskowałeś kiedy powinniśmy w ten sposób programować, ale podsumujmy to. Podejście które przedstawiam sprawdzi się najlepiej tam, gdzie mamy dużo I/O – czyli komunikacji poza nasz proces np. do bazy danych, do plików czy do innych serwerów np. przez HTTP. Wynika to przede wszystkim z tego, że nie musimy biernie czekać aż owe I/O się zakończy.

Kiedy programowanie asynchroniczne nie ma sensu? Wszędzie tam gdzie intensywnie jeździmy po procesorze – czyli np. skomplikowane obliczenia. Tam nie będziemy mieli gdzie użyć await a więc nasz kod stanie się po prostu… Synchroniczny. Może kiedyś CPython pozbędzie się GILa i watki będą sobie takimi sytuacjami radziły, a póki co mamy multiprocessing.

Zmierzając do brzegu

Programowanie asynchronicznie to świetne rozwiązanie, znacznie zwiększające efektywność naszych programów. Jasno zdefiniowane miejsca wywłaszczeń, pozwalają na łatwiejszą pracę niż ta którą proponuje nam wielowątkowość.

Mimo wszystko, asyncio nie jest jedynym rozwiązaniem pozwalającym na asynchroniczną pracę w Pythonie. Mam w głowie pomysł, żeby napisać artykuł w którym zrobię takie tour de asynclibs. Prawie na pewno on powstanie. Stay tuned!

Dzięki za wizytę,
Mateusz Mazurek

A może wolisz nowości na mail?

Subskrybuj
Powiadom o
guest

Witryna wykorzystuje Akismet, aby ograniczyć spam. Dowiedz się więcej jak przetwarzane są dane komentarzy.

2 komentarzy
Inline Feedbacks
View all comments
Michał

Witam!
Świetny artykuł, ale te lata 60 XIX wieku mnie rozwaliły :-D
Już myślałem, że cofniemy się do prahistorii komputerów xD