Współbieżne i asynchroniczne operacje w Pythonie
Styczeń 4, 2024 | #python , #async
000001
010110
110100
Operacje I/O-bound vs CPU-bound
Kluczowym w doborze metody równoległego wykonywania zadań jest rozróżnienie charakteru wykonywanych operacji. W programowaniu komputerowym podstawowe rozróżnieniem operacji jest ich podział na:
- Operacje I/O-bound (Input/Output bound)
- Operacje CPU-bound
Operacje wejścia/wyjścia
To takie operacje, które są ograniczone głównie przez szybkość operacji wejścia/wyjścia, takich jak odczyt lub zapis danych na dysku, komunikacja sieciowa, lub interakcje z innymi urządzeniami zewnętrznymi. Cechą charakterystyczną operacji I/O-bound jest to, że czas przetwarzania zależy bardziej od szybkości systemu I/O niż od szybkości samego procesora.
Operacje związane z obciążeniem procesora
Operacje CPU-bound to zadania w programowaniu, które są ograniczone głównie przez szybkość przetwarzania procesora. W przypadku operacji CPU-bound, kluczowym czynnikiem wpływającym na czas wykonania jest szybkość, z jaką CPU może przetwarzać dane lub wykonywać obliczenia.
Więcej na ten temat można przeczytać w artykule pt. An intro to I/O-bound and CPU-bound solutions
Podstawowe sposoby zrównoleglania pracy w Python-ie
- Wielowątkowość (multithreading)
- Multiprocesing
- Koprocedury (coroutines)
- Klastry
Ograniczona wielowątkowość Python-a
Kiedy mówimy o wątkach w Pythonie nie sposób nie wspomnieć o GIL (Global Interpreter Lock), czyli globalnej blokadzie, która pozwala w jednym procesie - nawet na wielordzeniowych procesorach - tylko jednemu wątkowi wykonywać kod bajtowy Pythona naraz. Jest to jeden z mechanizmów używanych w implementacji Pythona znanej jako CPython - będącej domyślną i najczęściej używaną. Głównym celem GIL jest uproszczenie zarządzania pamięcią w Pythonie, zwłaszcza w odniesieniu do systemu zarządzania pamięcią dynamiczną znanego jako garbage collector. Blokada ta zapobiega jednoczesnym modyfikacjom współdzielonej pamięci przez różne wątki, co zmniejsza ryzyko wystąpienia błędów związanych z pamięcią, ale ma też negatywny wpływ na wydajność programów wielowątkowych.
Istnieją alternatywne implementacje Pythona, które nie używają GIL, np. Jython, IronPython, czy PyPy. W tych implementacjach wielowątkowość może być bardziej efektywna ale ich przeznaczenie i kompatybilność ze standardową implementacją Pythona ogranicza ich użycie.
Pseudo współbieżność w CPython?
GIL pozwala tylko jednemu wątkowi na wykonanie kodu bajtowego Pythona w danym momencie, zatem kiedy jeden wątek wykonuje kod Pythona, inne wątki są zablokowane. Wątki są regularnie przełączane przez GIL, co umożliwia pozorną równoczesność. Standardowo, przełączanie wątków następuje co określoną liczbę instrukcji wykonywania (co może być modyfikowane).
Poniżej znajduje się przykład kodu Pythona, który demonstruje użycie wielowątkowości za pomocą modułu threading
.
import threading
import time
import random
def my_task():
print(f"Rozpoczęcie zadania w wątku {threading.current_thread().name}")
# Symulacja zadania trwającego od 2 do 7 skund
sleep_time = random.randint(2, 7)
time.sleep(sleep_time)
print(f"Zakończenie zadania w wątku {threading.current_thread().name}")
if __name__ == "__main__":
# Lista do przechowywania wątków
threads = []
# Tworzenie i uruchamianie 5 wątków
for i in range(5):
thread = threading.Thread(target=my_task, name=f"Wątek_{i+1}")
threads.append(thread)
thread.start()
# Oczekiwanie na zakończenie wszystkich wątków
for thread in threads:
thread.join()
print("Wszystkie wątki zakończyły pracę")
Wpływ GIL na wydajność operacji wielowątkowych
Dla operacji I/O-bound, gdzie wątki spędzają dużo czasu na oczekiwaniu na operacje wejścia/wyjścia, GIL ma mniejszy wpływ, ponieważ wątki mogą być przełączane podczas oczekiwania.
Dla operacji CPU-bound, GIL może być problematyczny, ponieważ uniemożliwia równoczesne wykonywanie obliczeń na wielu rdzeniach procesora. W takich przypadkach użycie modułu multiprocessing
, który tworzy oddzielne procesy omijające GIL, może być bardziej efektywne.
Multiprocessing metodą obejścia ograniczeń GIL
"Multiprocessing" w kontekście Pythona odnosi się zarówno do modułu, jak i do ogólnej metodologii rozdzielania pracy na wiele procesów.
Ogólnie pojęcie, "multiprocessing" odnosi się do techniki w programowaniu i przetwarzaniu danych, polegającej na użyciu wielu procesów (a niekoniecznie wielu procesorów) do wykonania zadań równolegle. Ta metodologia pozwala na lepsze wykorzystanie zasobów obliczeniowych, zwłaszcza na maszynach wielordzeniowych, gdzie każdy proces może być wykonywany na osobnym rdzeniu. W przypadku CPythona, każdy proces ma swój własny interpreter Pythona i własny GIL. Oznacza to, że każdy proces może wykonywać instrukcje Pythona równolegle na różnych rdzeniach procesora.
W Pythonie multiprocessing
jest wbudowanym modułem, który umożliwia tworzenie nowych procesów, komunikację między nimi oraz synchronizację. Moduł ten dostarcza API, które ułatwia przetwarzanie równoległe, pozwalając na wykonywanie zadań w osobnych procesach.
Użycie modułu multiprocessing
Tworzenie procesów w module multiprocessing
odbywa się za pomocą klasy Process
. Przy jej inicjalizacji przekazuje się funkcję, która będzie wykonana w nowym procesie. Procesy mają oddzielne przestrzenie pamięci. Jest to bezpieczniejsze z punktu widzenia izolacji danych, ale wymaga stosowania mechanizmów komunikacji międzyprocesowej (IPC), takich jak kolejki (Queue
) czy potoki (Pipe
), do wymiany danych. Do synchronizacji procesów wykorzystywane są mechanizmy takie jak blokady (Lock
), semafory (Semaphore
) i zdarzenia (Event
). Moduł multiprocessing
umożliwia także dzielenie stanu między procesami za pomocą współdzielonych zmiennych (Value
, Array
) lub serwerów menedżera (Manager
). Z kolei klasa Pool
pozwala na łatwe zarządzanie grupą roboczą procesów do wykonania wielu zadań równolegle, zwracając wyniki w miarę ich gotowości.
Poniżej znajduje się przykładowy kod, który ilustruje podstawowe użycie modułu multiprocessing
w Pythonie. Kod ten tworzy dwa procesy, które wykonują prostą funkcję:
import multiprocessing
def print_cube(num):
print("Cube: {}".format(num * num * num))
def print_square(num):
print("Square: {}".format(num * num))
if __name__ == "__main__":
# tworzenie procesów
p1 = multiprocessing.Process(target=print_square, args=(10,))
p2 = multiprocessing.Process(target=print_cube, args=(10,))
# uruchomienie procesów
p1.start()
p2.start()
# oczekiwanie na zakończenie procesów
p1.join()
p2.join()
print("Zakończono oba procesy")
Moduł concurrent.futures
Jest częścią standardowej biblioteki i służy do asynchronicznego wykonywania wywołań funkcji. Moduł ten został wprowadzony w Pythonie 3.2 i zapewnia wysokopoziomowy interfejs do asynchronicznego wykonywania wywołań za pomocą puli wątków (ThreadPoolExecutor) lub puli procesów (ProcessPoolExecutor).
from concurrent.futures import ThreadPoolExecutor
import urllib.request
URLS = ['http://www.example.com', 'http://www.python.org', '...']
def load_url(url):
with urllib.request.urlopen(url) as conn:
return conn.read()
with ThreadPoolExecutor(max_workers=5) as executor:
future_to_url = {executor.submit(load_url, url): url for url in URLS}
for future in concurrent.futures.as_completed(future_to_url):
url = future_to_url[future]
try:
data = future.result()
except Exception as exc:
print(f'{url} generowany wyjątek: {exc}')
else:
print(f'{url} strona ma {len(data)} bajtów')
W tym przykładzie ThreadPoolExecutor
jest używany do asynchronicznego pobierania treści z różnych stron internetowych.
Ciężar i efektywność multiprocessingu
Pod względem zasobów systemowych tworzenie nowych wątków jest zazwyczaj lżejsze niż tworzenie nowych procesów, co czyni wielowątkowość bardziej efektywną dla zadań lekkich i szybkich. Z kolei multiprocessing zapewnia zdolność prawdziwie równoległego wykonania zadań z możliwością wykorzystania pełnej mocy obliczeniowej wielordzeniowych procesorów dlatego jest preferowany w przypadku zadań intensywnie wykorzystujących procesor.
Wiele narzędzi Pythona implementuje asynchroniczność z użyciem multiprocessingu np. Celery (domyślnie), RQ (Redis Queue) czy Joblib - to biblioteka używana głównie w kontekście naukowych i obliczeniowych, która umożliwia łatwe równoległe wykonywanie zadań, szczególnie w obszarze uczenia maszynowego i przetwarzania danych.
Koprocedury i inne implementacje asynchroniczności w Python-ie
Koprocedury lub współprogramy (coroutines) to koncepcja programowania asynchronicznego realizowana w ramach jednego wątku. Bazuje ona na mechanizmie umożliwiającym wstrzymanie działania jednej funkcji, przekazanie kontroli do innej, po czym powrót do wykonania pierwotnej funkcji od miejsca, w którym nastąpiło wstrzymanie.
Za twórcę pojęcia coroutine
uważa się Melvin Conway-a, który zaproponował ten termin w 1958 roku zatem nie jest to pomysł nowy i w Pythonie od dawna podejmowano próby jego implementacji.
Koprocedury bazujące na generatorach
Pythonie 2.5 wprowadzono rozszerzenie generatorów, które pozwalało im na uzyskiwanie wartości z zewnątrz oraz wysyłanie wartości do generatora. Funkcje takie jak yield
i send
umożliwiały tworzenie prostych koprocedur (czytaj Dead Simple Python: Generators and Coroutines). Pozwalało to na wstrzymywanie i wznawianie wykonania funkcji, co było podstawą do tworzenia asynchronicznego kodu.
Moduł yield from
W Pythonie 3.3 wprowadzono składnię yield from
, która upraszczała łańcuchowanie generatorów i była prekursorem dla async/await
w asyncio
. Umożliwiała ona delegowanie części operacji do innego generatora (czytaj Making sense of generators, coroutines, and “yield from” in Python).
Frameworki Twisted i Tornado
Twisted i Tornado to dwa popularne frameworki asynchroniczne w Pythonie, które powstały na długo przed pojawieniem się asyncio
i sposób, w jaki implementują asynchroniczność, różni się od podejścia opartego na corutynach stosowanego przez ich młodszego brata. Twisted opiera się na wzorcu reaktora, który jest centralną pętlą zdarzeń monitorującą wszystkie operacje I/O. Asynchroniczność w Twisted jest realizowana głównie za pomocą callbacków. Tornado używa własnej implementacji pętli zdarzeń zwanej "IOLoop". Podobnie jak Twisted, asynchroniczność w Tornado początkowo również była realizowana za pomocą callbacków i nieblokujących operacji I/O. Jednak w nowszych wersjach, Tornado dodało wsparcie dla corutyn z użyciem składni async/await
.
Obecnie oba frameworki zostały wyparte przez nowsze rozwiązania ale mają swoje niszowe zastosowania z uwagi na obsługę wielu protokołów, w tym HTTP, SMTP, POP3, FTP, TCP, UDP i innych. Tornado np. świetnie się sprawdza do budowy aplikacji wymagających długotrwałych połączeń, jak aplikacje czatowe lub aplikacje oparte na WebSocket - systemy biletowe.
Zielone wątki (green threads)
Frameworki takie jak gevent
, eventlet
używają modelu opartego na "zielonych wątkach", co jest formą współbieżności lekkiej wagi. W przeciwieństwie do asyncio
, nie wymagają one jawnego oznaczania funkcji jako asynchronicznych lub oczekiwania na operacje asynchroniczne. Często używają techniki zwaną "monkey patching", aby automatycznie zamienić standardowe blokujące operacje I/O (takie jak te w bibliotece socket
czy os
) na ich nieblokujące odpowiedniki. To umożliwia łatwą adaptację istniejącego kodu do modelu asynchronicznego.
Z tego powodu gevent
, eventlet
stanowią jedną z opcji wykonawczych w popularnym systemie kolejkowania Celery. Domyślnie Celery wykorzystuje multiprocesing do obsługi zadań w tle, ale jako pulę wykonawczą (execution pool) można w nim także wybrać wątki albo właśnie "zielone wątki" - świetne w wykonywaniu zadań I/O-bound dzięki ich lekkiej naturze i minimalnemu narzutowi przy przełączaniu kontekstu.
celery -A your_application worker --pool=gevent -c 20
W przypadku tego rozwiązania należy jednak pamiętać o tym, że nie każdy kod będzie bezproblemowo współdziałać z gevent
, eventlet
.
Moduł asyncio
Wprowadzone w Pythonie 3.4 asyncio
, zrewolucjonizowało asynchroniczne programowanie w Pythonie, dostarczając jednolity i zintegrowany framework. Z użyciem nowej składni async def
i await
(wprowadzonej w Pythonie 3.5), zarządzanie asynchronicznymi operacjami stało się bardziej intuicyjne i czytelne.
Przykładowy prosty kod asynchroniczny w Pythonie używający asyncio
może wyglądać następująco:
import asyncio
async def main():
# Utworzenie obiektu Future
future = asyncio.Future()
# ... (w innym miejscu kodu Future może być uzupełniony wynikiem)
# Oczekiwanie na wynik Future
await future
# Sprawdzenie wyniku
result = future.result()
print(result)
# Uruchomienie pętli zdarzeń i corutyny
asyncio.run(main())
W przeciwieństwie do "zielonych wątków" programowanie z użyciem asyncio
wymaga w pełni świadomej implementacji rozwiązań asynchronicznych. Definicja funkcji asynchronicznej musi być poprzedzona słowem async
a await
umożliwia określenie punktu, w którym funkcja będzie czekać na zakończenie innych asynchronicznych zadań. W funkcjach asynchronicznych możliwe jest zdefiniowanie obiektu Future
będącego reprezentacją oczekiwanego wyniku operacji. Future
może przyjmować kilka stanów takich jak Pending
, Done
, Cancelled
. Gdy asynchroniczna operacja kończy się, jej wynik jest ustawiany w obiekcie Future
. Może to być wartość lub wyjątek. Task
służy do zaplanowania wykonania funkcji asynchronicznej w pętli zdarzeń, a asyncio.run()
(od Pythona 3.7+) uruchamia pętlę zdarzeń (Event Loop) i ją zamyka po zakończeniu wszystkich asynchronicznych zadań.
Moduł asyncio
zapewnia asynchroniczne warianty wielu standardowych operacji wejścia/wyjścia, takich jak operacje na plikach, połączenia sieciowe, a także można je integrować z asynchronicznymi bibliotekami zewnętrznymi. Oferuje także mechanizmy synchronizacji takie jak semafory, blokady i zdarzenia, które są przystosowane do asynchronicznego programowania.
Chociaż asyncio
nie używa bezpośrednio mechanizmów wielowątkowości ani multiprocesingu, można go integrować z tymi mechanizmami, jeśli jest to konieczne, na przykład do wykonania zadań obciążających procesor (CPU-bound) w tle.
Większość nowoczesnych asynchronicznych frameworków w Pythonie używa w tle asyncio
. Do najpopularniejszych należą: FastAPI, Sanic, AIOHTTP.
Zastosowanie asynchroniczności
Asynchroniczne programowanie jest szczególnie przydatne w przypadkach, gdy jest wiele operacji I/O, które mogą być wykonywane równolegle bez blokowania głównego wątku, jak np. w aplikacjach sieciowych, serwerach HTTP, czy w interakcjach z bazami danych.
Klastry - kiedy jeden komputer to za mało
Wykorzystywanie klastrów do realizacji zadań równoległych oznacza używanie grupy połączonych ze sobą komputerów (zwanych węzłami klastrowymi), aby wspólnie pracować nad zadaniami, które wymagają dużej mocy obliczeniowej lub przetwarzania dużej ilości danych. Klaster umożliwia równoległe przetwarzanie zadań, rozdzielając je na wiele węzłów, co zwiększa wydajność, skalowalność i niezawodność aplikacji.
Klastry zaprzęga się do pracy w przypadku obliczeń wysokowydajnych (HPC) wykonywanych w naukach przyrodniczych, inżynierii i finansach, gdzie potrzebne jest przetwarzanie dużych zbiorów danych lub skomplikowanych symulacji. W Big Data i Analizie Danych też jest duże zapotrzebowanie na moc obliczeniową, dzięki której klastry umożliwiają szybką obróbkę i analizę.
W Pythonie mamy kilka narzędzi umożliwiających współbieżne uruchamianie kodu na wielu węzłach klastra w tym np:
Dask: umożliwia równoległe przetwarzanie dużych zbiorów danych i jest często używany do analizy danych i obliczeń naukowych. Dask może skalować się od pojedynczej maszyny do klastra.
Ray: jest frameworkiem przeznaczonym do skalowalnego równoległego i rozproszonego przetwarzania. Jest często stosowany w zastosowaniach związanych z uczeniem maszynowym, przetwarzaniem dużych danych i sztuczną inteligencją.
PySpark: To interfejs Apache Spark dla Pythona, który umożliwia przetwarzanie danych w sposób rozproszony i jest często wykorzystywany do przetwarzania dużych zbiorów danych oraz analiz Big Data. Działa na klastrach, wykorzystując model obliczeń rozproszonych, a równoległość jest osiągana przez rozdzielanie zadań na węzłach klastra.
Podsumowanie
Python nie był projektowany z myślą o współbieżności. Jego standardowa implementacja CPython idzie w tym względzie na kompromis na rzecz bezpieczeństa. Wyspecjalizowane biblioteki przeznaczone choćby do obliczeń naukowych takich jak Numpy i SciPy - w celu obejścia ograniczeń Pythona wykorzystują zoptymalizowany kod C/C++ do przetwarzania równoległego na wielu rdzeniach.
Na przestrzeni lat Python dorobił się jednak kilku mechanizmów, wbudowanych modułów, frameworków i narzędzi, które ułatwiają programowanie asynchroniczne nawet na wielką skalę. Obecnie mamy cały wachlarz możliwości i pozostaje jedynie odpowiedni dobór rozwiązań.