Cześć,
dziś zapraszam Cię na artykuł o programowaniu asynchronicznym, czyli bardzo sprytnym sposobie dającym wrażenie współbieżności. Zaczynajmy.
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.
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”.
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.
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.
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.
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]
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ń. Znacznie bardziej polecam używać bibliotek które mają wsparcie dla asyncio.
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.
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!
Oj daaawnoo mnie tu nie było. Ale wakacje to był czas dużej liczby intensywnych wyjazdów i tak naprawdę, dopiero jakoś… Read More
Cześć! Zapraszam na krótkie podsumowanie kwietnia. Wyjazd do Niemiec A dokładniej pod granicę z Francją. Chrześnica miała pierwszą komunię. Po… Read More
Ostatnio tygodnie były tak bardzo wypełnione, że nie udało mi się napisać nawet krótkiego podsumowanie. Więc dziś zbiorczo podsumuję luty… Read More
Zapraszam na krótkie podsumowanie miesiąca. Książki W styczniu przeczytałem "Homo Deus: Historia jutra". Książka łudząco podoba do wcześniejszej książki tego… Read More
Cześć! Zapraszam na podsumowanie roku 2023. Książki Zacznijmy od książek. W tym roku cel 35 książek nie został osiągnięty. Niemniej… Read More
Zapraszam na krótkie podsumowanie miesiąca. Książki W grudniu skończyłem czytać Mein Kampf. Nudna książka. Ciekawsze fragmenty można by było streścić… Read More
Pokaż komentarze
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
Hahaha dopiero po Twoim komentarzu skapnąłem się, że to XX wiek i lata 50te :D
Prosta gra planszowa: Token może przemieszczać się w dowolnym kierunku po planszy o jedno pole, wtedy gdy ma określoną ilość punktów akcji. Punkt akcji doda się gdy zapętlony licznik osiągnie przykładowo 5/5 sekundę. Pytanie: Czy właśnie tutaj przyda się programowanie asynchroniczne, żeby ten licznik mógł działać "w tle"?
To zależy od tylu czynników, że ciężko jednoznacznie odpowiedzieć: