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:

Aby potwierdzić swoją subskrypcję, odbierz pocztę i kliknij w link potwierdzający:) jeśli maila nie ma to poczekaj chwile i/lub sprawdź folder spam/inne/oferty itp :)

Aby potwierdzić swoją subskrypcję, odbierz pocztę i kliknij w link potwierdzający:) jeśli maila nie ma to poczekaj chwile i/lub sprawdź folder spam/inne/oferty itp :)
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.

Jak i do ewentualnego postawienia mi kawy :)

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ń. Znacznie bardziej polecam używać bibliotek które mają wsparcie dla asyncio.

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
Mateusz M.

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ć:

Ostatnie wpisy

Podsumowanie: maj, czerwiec, lipiec i sierpień 2024

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

2 miesiące ago

Podsumowanie: kwiecień 2024

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

6 miesięcy ago

Podsumowanie: luty i marzec 2024

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

7 miesięcy ago

Podsumowanie: styczeń 2024

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

9 miesięcy ago

Podsumowanie roku 2023

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

10 miesięcy ago

Podsumowanie: grudzień 2023

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

10 miesięcy ago