W kolejnym materiale o Pythonie przechodzimy do poznania kolejnego pakietu 🎉! Tym razem rzucimy okiem na pakiet "concurrent", który w języku Python oznacza możliwość programowania współbieżnego 😮! Poznasz sposoby tworzenia zadań asynchronicznych (przetwarzanych w jednym czasie), a także interpreterów przetwarzających i wykonujących polecenia 🔥! Zapowiada się gorący temat, zatem równie gorąco zapraszam do lektury 😊!

PAKIET "CONCURRENT" W JĘZYKU PYTHON SZEROKO "OTWARTY" NA WSPÓŁBIEŻNOŚĆ!

Kolejny kluczowym termin do wyjaśnienia: "współbieżność", także zaczniemy najpierw od niego, a potem przejdziemy do poznawania pakietu "concurrent" od strony praktycznej 🌟!

CZYM JEST WSPÓŁBIEŻNOŚĆ W PROGRAMOWANIU?

Współbieżność jest zdolnością programu do wykonywania wielu zadań w jednym czasie ⏩. Cechą takiego programu jest dynamiczne przydzielanie zasobów na każde z zadań umożliwiające "przełączanie się" na dane zadanie w czasie rzeczywistym ⌚. W ten sposób, jeden program może zająć się jednym zadaniem, następnie przejść na moment do innego zadania, wykonać w nim jakiś postęp i wrócić z powrotem do poprzedniego zadania ↩️. W ten sposób przekłada się super korzystnie na czas na wykonanie dużej operacji powodując, że staje się dużo krótszy ❤️. To jest mocno uproszczone wytłumaczenie całego postępowania, natomiast tak to funkcjonuje 👍.

W zależności od liczby rdzeni w procesorze, postępowanie współbieżne jest nieco odmienne.

Gdy procesor jest jednordzeniowy, to można go porównać do pracownika w firmie, który nadrabia zaległości i musi w ciągu kilku godzin skończyć wiele równie ważnych zadań ⌛. Wtedy jest zmuszony do tymczasowego przerwania pracy nad jednym zadaniem, aby przystąpić do innego i zrobić w nim jakiekolwiek postępy 📈. Czyni to tak długo, aż któreś z zadań zostanie wykonanych i wtedy może skupić się jeszcze bardziej na reszcie pozostałych. A jak zostanie mu tylko jedno zadanie, to wykorzystuje 100% koncentracji tylko na nim 😊.

Inaczej jest przy procesorze wielordzeniowym - wtedy już byśmy mieli grupę pracowników mających duże sterty zadań do wykonania, lecz mogą się dzielić między sobą porcjami swoich zadań. Ważne jest to, że nikt nie czeka na nikogo. Każdy z nich zawsze wykonuje jakieś swoje zadanie i w razie potrzeby przerywa nad nim prace, przekazuje to zadanie komuś innemu, a on przechodzi do innego ⏩.

Współbieżność to już "gruby" temat dla początkujących i trzeba w pierwszej kolejności dobrze rozumieć mechanizm teoretycznie, aby następnie zacząć praktykę w kodzie na czymś bardzo prostym i wreszcie samemu spróbować skonstruować coś większego 💥.

To tyle z samej teorii, a teraz przejdziemy do poznawania pakietu "concurrent" 😄!

CZYM JEST "CONCURRENT" W JĘZYKU PYTHON?

W Pythonie, "concurrent" jest pakietem zawierającym konstrukcje przeznaczone do programowania współbieżnego 🔁. Składa się z 2 modułów, a każdy z nich dotyczy czegoś innego 👇:

  1. futures (tworzenie zadań działających asynchronicznie),
  2. interpreters (tworzenie interpreterów do przetwarzania instrukcji, jak w powłoce Pythona).

Wyjaśnimy sobie krótko oba te moduły, tylko najpierw dowiedzmy się jak zacząć korzystać z samego pakietu 🙂!

JAK SKORZYSTAĆ Z PAKIETU "CONCURRENT" W JĘZYKU PYTHON?

Aby w Pythonie móc użyć któregoś z modułów pakietu "concurrent", trzeba w pierwszej kolejności go zaimportować ↩️. Mając otwarty skrypt, należy dopisać odpowiednią instrukcję na samej górze.

W zależności od tego, który moduł Cię interesuje, wprowadź odpowiednią nazwę używając jednego z poniższych zapisów 👇:

import concurrent.futures
import concurrent.interpreters

Tym sposobem importujemy moduł 👍. Z chwilą zaimportowania, natychmiast otwiera nam się dostęp do wszystkich zawartych w nim wartości i funkcji 🔓. Jeżeli interesuję Cię zaimportowanie tylko fragmentu modułu (jakaś konkretna wartość i/lub funkcja), musisz trochę zmodyfikować instrukcję importu pisząc wówczas w taki sposób 👇:

from concurrent.futures import [wartość/metoda występująca w module]
from concurrent.interpreters import [wartość/metoda występująca w module]

Pamiętaj, że obie formy zapisu mają dalszy wpływ na odwoływanie się do tych wartości i funkcji ⚠️! Szczegóły odnośnie obu zapisów importowania zostaną przedstawione w osobnych artykułach ℹ️. Ja posłużę się pierwszym zapisem na czas prezentacji dalszych kodów źródłowych 1️⃣.

Możemy zaczynać przedstawianie obu modułów 👀! Najpierw "futures" 🤖!

NA CZYM POLEGA MODUŁ "FUTURES" PAKIETU "CONCURRENT" W JĘZYKU PYTHON?

Moduł "futures" pakietu "concurrent" pozwala na tworzenie zadań asynchronicznych, czyli wykonujących się równolegle w obrębie jednego procesu 🔥. W odróżnieniu od wyjaśnionego wcześniej modułu "threading", tutaj "futures" korzysta ze specjalnej "puli" dla obiektów wątku z systemem podziału zadań na pracowników (tak jak tłumaczyłem w części teoretycznej) 💡. Stąd też najpierw tworzymy obiekt puli z określoną liczbą pracowników, a następnie do niego dodajemy konkretne zadania ℹ️.

Dlatego pokażę najpierw tworzenie samej puli, a potem jak się tworzy zadania 😊.

JAK STWORZYĆ PULĘ DLA ASYNCHRONICZNYCH ZADAŃ W JĘZYKU PYTHON?

Moduł "futures" posiada konstruktor klasy "ThreadPoolExecutor" i to nim musimy się posłużyć celem utworzenia obiektu puli dla zadań asynchronicznych 👇:

with concurrent.futures.ThreadPoolExecutor(max_workers=3) as thread_pool_executor:
	pass

Najlepiej podstawić tworzenie obiektu puli do instrukcji "with", aby przekazać zarządzanie inicjalizacją i zamknięciem tego zasobu samemu Pythonowi 👍. Krótki opis samego konstruktora 📜.

Do najbardziej podstawowego zdefiniowania puli wystarczy nam argument nazwany "max_workers", w którym określamy ilu "pracowników" ma występować. Konkretniej, oznacza to ile wątków maksymalnie może pracować nad zadaniami w tym samym czasie 💡. Jest więcej parametrów jakie może przyjąć konstruktor "ThreadPoolExecutor", natomiast najlepiej odwiedzić oficjalną dokumentację Pythona i tam o nich poczytać 📖.

Użycie konstruktora puli wątków z pakietu "concurrent" w języku Python

"ThreadPoolExecutor" jest klasą przeznaczoną do tworzenia puli wątków z możliwością określenia ile będzie przydzielonych wątków do wykonania wyznaczonych zadań.

Mamy obiekt puli wątków, to teraz przechodzimy do utworzenia asynchronicznego zadania 🔥!

JAK STWORZYĆ ASYNCHRONICZNE ZADANIE W JĘZYKU PYTHON?

Używając nowo stworzonej puli wątków (z klasy "ThreadPoolExecutor" ℹ️), sięgamy po metodę "submit" i wpisujemy jej wywołanie, które przyjmuje następującą postać 👇:

my_future = thread_pool_executor.submit([nazwa funkcji z instrukcjami do wykonania przez wątek/wątki], [pierwszy argument pozycyjny/nazwany], [drugi argument pozycyjny/nazwany], ..., [n-ty argument pozycyjny/nazwany])

Wyjaśniam postać metody 📣!

Metoda "submit" odpowiada za rejestrację funkcji do wykonania przez wątek (lub wątki), czyli tych naszych "pracowników". W pierwszym parametrze wpisujemy nazwę funkcji 1️⃣. Nie wywołanie, tylko nazwę ⚠️! Pamiętaj o świętej zasadzie, że gdy przekazujemy wątkowi coś do wykonania, to my tego sami nie wywołujemy! To się wykona samodzielnie w odpowiednim czasie ✅.

Zaskakujące mogą być kolejne parametry 👀! Od drugiego w kolejności przekazujemy parametry jakie trafią do wywołania funkcji 🚀! Co istotne, mogą to być zarówno argumenty pozycyjne (wprowadzanie wartości według kolejności ustalonej w nagłówku funkcji), jak również argumenty nazwane (wprowadzanie wartości według własnej kolejności poprzez poprzedzenie ich nazwą parametru) 😱!

Na przykładzie funkcji matematycznej "pow", zapis może wyglądać tak 👇:

my_future = thread_pool_executor.submit(pow, 2, 4)

albo tak 👇:

my_future = thread_pool_executor.submit(pow, base=2, exp=4)

natomiast przy korzystaniu z argumentów nazwanych pamiętaj, że nie wolno umieszczać argumentów pozycyjnych po argumentach nazwanych 👇:

my_future = thread_pool_executor.submit(pow, base=2, 4)

bo wtedy Python zgłosi błąd typu "SyntaxError" i dojdzie do przerwania działania programu, jeśli nie przechwytujesz wyjątku ❌!

W dwóch pierwszych fragmentach efektem będzie wyznaczenie liczby 2 do potęgi 4, czyli wynik będzie równy 16 🙂.

Metoda "submit" obiektu puli wątków z pakietu "concurrent" w języku Python

Aby zarejestrować zadanie do wykonania przez wątki z puli, należy skorzystać z metody "submit" przyjmującej za parametry nazwę funkcji oraz parametry jakie przyjmuje (od drugiego parametru).

A teraz pokażę Ci jak zwrócić taki wynik z funkcji przekazanej wątkowi, bo w pakiecie "concurrent" jest to możliwe 😉!

JAK ZWRÓCIĆ WARTOŚĆ REZULTATU WYKONANIA ASYNCHRONICZNEGO ZADANIA W JĘZYKU PYTHON?

Wątki z modułu "futures" wspierają zwracanie wartości z funkcji, którą otrzymały jako wynik ich wykonania ❤️! Do otrzymania tejże wartości służy metoda "result" 👇:

print(my_future.result())

To daje dokładnie ten sam efekt, jak przy przypisaniu wartości w miejsce parametru pochodzącej z funkcji 👇:

print(pow(2, 4))

Trzeba mieć świadomość, że to zwróci wartość wynikową wyłącznie, gdy zadanie przypisane wątkowi zostało w pełni wykonane ✅.

Pamiętaj, że tutaj opieramy się na przetwarzaniu równoległym w czasie rzeczywistym 💥. To oznacza, że różne rzeczy mogą się zdarzyć podczas wykonywania zadania 😳. Może ono zostać ukończone, ale też wstrzymane lub przerwane w wyniku jakiegoś zdarzenia np. wciśnięcia kombinacji "Ctrl+C" przez Ciebie ⚠️! Także miej to na uwadze, że gdy wkraczamy na współbieżność, musimy zmienić myślenie co do pisania kodu i brać pod uwagę wszystkie okoliczności jakie mogą zajść na przedziale całego postępu zadania od 0 do 100%, a nie tylko "przed i po" 🫵!

Metoda pozwala na dodanie opcjonalnego parametru o nazwie "timeout", czyli czas w sekundach oznaczający "cierpliwe odczekanie" na wynik ⌚. Po jego przekroczeniu, Python wywołuje błąd "TimeoutError" i przerywa dalsze działanie programu, jeśli wyjątek tego typu nie został przechwycony ⛔!

Metoda "result" obiektu "future" z pakietu "concurrent" w języku Python

Aby pobrać wynik działania funkcji zwracającej wartość wywołanej przez wątek z puli, wystarczy wywołać metodę "result".

To wszystko czego potrzebujesz do tworzenia i obsługi asynchronicznie przetwarzanych zadań, więc czas na praktyczny przykład 🚀!

PRZYKŁAD UŻYCIA ASYNCHRONICZNEGO ZADANIA W JĘZYKU PYTHON

Przykład będzie prezentował utworzenie wielu zadań rozdzielonych na 3 wątki, co będzie symulować sytuację, w której każdy z pracowników zajmuje się pojedynczym zadaniem, a po jego zakończeniu, bierze się za następne 🔁. Pracownicy funkcjonują w taki sposób tak długo, aż wszystkie zadania zostaną ukończone ☑️. Ostrzegam, że tu będzie dużo kodu, aczkolwiek rozdzieliłem go na poszczególne części przez co nie powinno być takie trudne nadążanie za tym, co będę tłumaczył 🙂.

Do przykładu potrzebny Ci będzie moduł "time", gdyż na jego potrzeby korzystam z funkcji "sleep" służącej do tymczasowego zawieszenia programu (a w tym przypadku wątku), więc wystarczy dodać tę instrukcję importu 👇:

import time

Od tej pory, pokazuję fragmenty kodu do całego przykładu i opisuję każdą linijkę ℹ️.

Najpierw popatrzmy na samą funkcję dla wątku, która będzie stanowić pojedyncze zadanie 👇:

def do_task(number):
	number_as_string = str(number)
	number_of_progress_steps = 4

	print('Rozpoczynam zadanie nr ' + number_as_string)

	for progress_step in range(1, number_of_progress_steps + 1):
		print('Postęp zadania nr ' + number_as_string + ': ' + str((progress_step / number_of_progress_steps)*100) + '%')
		time.sleep(1 / number_of_progress_steps)

	return 'Zakończono zadanie nr ' + number_as_string

Funkcja przyjmuje jeden parametr całkowitoliczbowy (bez ułamka) jako numer porządkowy zadania 🔢. To ma na celu tylko wypisanie w konsoli jakie to jest zadanie w kolejności. W środku funkcji mamy dwie zmienne 👇:

  1. liczbę skonwertowaną na łańcuch znaków,
  2. liczbę oznaczającą na ile "kroków" ma być podzielone 100% postępu (zrobiłem tak, gdyż w dalszej części jest część dotyczącą wypisywania w konsoli aktualnego postępu).

Tuż po zmiennych mamy wywołanie funkcji "print" jako oznaczenie przystąpienia do prac ▶️. Zaraz po niej znajduje się pętla "for", w której odbywa się symulowane rejestrowanie postępu ukończenia zadania. Korzysta z zakresu liczbowego ("range") od 1 do liczby "kroków" podziału na 100% postępu. Występuje tu zwiększenie w górnej granicy o jeden, ponieważ "range" nie uwzględnia wartości maksymalnej podanej w drugim parametrze (tzw. "exclusive end") 💥!

W środku pętli mamy tylko 2 instrukcje 👇:

  1. wywołanie funkcji "print" z odpowiednio zbudowanym łańcuchem, aby otrzymać komunikat na temat postępu zadania o określonym numerze porządkowym,
  2. wywołanie funkcji "sleep" z modułu "time", aby na krótki moment wstrzymać dalsze wykonywanie instrukcji symulując potrzebny czas na dokonanie postępu w zadaniu.

Na końcu funkcji mamy zwrócenie innego łańcucha znaków z treścią o zakończeniu wykonywania danego zadania ✅. To zostanie przechwycone przez metodę "result" obiektu zadania, z której korzystamy w dalszej części przykładu 👍.

Teraz kolejna funkcja 🚀! Ta będzie odpowiedzialna za przygotowanie odpowiedniej liczby obiektów zadania ("Future") do wykonania na podstawie podanej liczby 🏭! Oto ona 👇:

def get_futures(thread_pool_executor):
	futures = []
	number_of_tasks = 10

	for task_number in range(1, number_of_tasks + 1):
		future_instance = thread_pool_executor.submit(do_task, task_number)

		futures.append(future_instance)

	return futures

Ona również przyjmuje jeden parametr, lecz tym razem pulę wątków. Instrukcje wewnątrz funkcji zaczynają się od utworzenia dwóch następujących zmiennych 👇:

  1. lista przechowująca obiekty zadań asynchronicznych,
  2. liczba całkowita (bez ułamka) określająca ile zadań ma być przeznaczonych do wykonania na wszystkich pracowników.

W dalszej części mamy pętlę "for", która utworzy odpowiednią liczbę obiektów zadań zgodną z liczbą podaną w drugiej zmiennej 🏭. W jej wnętrzu mamy tylko 2 instrukcje 👇:

  1. tworzenie obiektu zadania asynchronicznego przy użyciu metody "submit" z obiektu puli wątków, której przekazujemy nazwę poprzednio opisanej funkcji i parametr liczbowy określający numer porządkowy zadania,
  2. dodawanie obiektu zadania asynchronicznego do listy przy użyciu metody "append".

Ostatnią instrukcją tej funkcji jest zwrócenie tak skonstruowanej listy obiektów zadań asynchronicznych na wyjściu i to jest koniec ✔️!

Została nam ostatnia część przykładu kodu do wytłumaczenia, czyli ta "uruchomieniowa" 🔥! Spójrz niżej 👇:

number_of_threads_in_pool = 3

with concurrent.futures.ThreadPoolExecutor(max_workers=number_of_threads_in_pool) as thread_pool_executor:
	not_done_tasks_futures = get_futures(thread_pool_executor)
	number_of_tasks_to_complete = len(not_done_tasks_futures)

	while number_of_tasks_to_complete > 0:
		timeout_in_seconds = 0.1
		done_tasks_futures, not_done_tasks_futures = concurrent.futures.wait(not_done_tasks_futures, timeout=timeout_in_seconds)
		number_of_tasks_to_complete = len(not_done_tasks_futures)
		
		for done_task_future in done_tasks_futures:
			print(done_task_future.result())

	print('Koniec')

Na początku określamy w zmiennej ile ma być wątków w puli przypadających na wszystkie zadania. Potem używając wygodnej instrukcji "with" tworzymy sobie obiekt puli wątków, podając w parametrze wartość zmiennej 🔥. Reszta kodu znajduje się już w obrębie instrukcji "with".

Na samym początku tworzymy sobie zmienną jako listę asynchronicznych zadań uznanych za nieukończone i w pierwszym etapie przyjmie ona listę zwracaną przez poprzednio ukazaną funkcję ❤️. Dalej, w drugiej zmiennej, znajduje się przypisanie długości tej listy, czyli liczby wszystkich zadań 🔢.

Po zainicjowaniu obu zmiennych trafiamy na pętlę "while", w której wykonujemy instrukcje związane z obserwowaniem aktualnych wyników i rejestrowaniem bieżącego stanu wykonalności zadań 👀. Dopóki wciąż są pewne zadania do ukończenia (wartość zmiennej oznaczającej liczbę zadań do ukończenia jest większa od zera), będzie wykonywać następujące instrukcje 👇:

  1. tworzy zmienną określającą czas oczekiwania na określenie bieżącego stanu wszystkich zadań jako całości w postaci liczby zmiennoprzecinkowej (z ułamkiem), czyli "patrzenie" na postępy w zadaniach będzie odbywać się co 1/10 sek.,
  2. wywołuje funkcję "wait" z modułu "futures" oczekującą na postęp zadań podanych w pierwszym parametrze (wszystkie nasze zadania) i zwraca dwie krotki podzielone na ukończone i nieukończone zadania (szczegóły odnośnie zachowania tej funkcji są przedstawione w oficjalnej dokumentacji 📖),
  3. przypisuje zaktualizowaną liczbę pozostałych nieukończonych zadań tej samej zmiennej do tego przeznaczonej,
  4. wykonuje pętlę "for" dla każdego ukończonego zadania i wewnątrz niej wywołuje dla każdego z nich metodę "result", a wynik jest osadzony w parametrze funkcji "print" (to będzie ten łańcuch znaków wstawiony po słowie kluczowym "return" 💡!).

Pętla powtarza się tak długo, aż wszystkie zadania asynchroniczne zostaną ukończone w 100% 🔥. Wtedy już tylko jest wypisanie komunikatu o zakończeniu działania wszystkich pracowników i to jest koniec całego przykładowego programu 😅!

Kiedy uruchomisz go sobie w całości (wszystkie 3 fragmenty razem wzięte), to zobaczysz symulację grupy pracowników, która ma stertę zadań do wykonania, sięga po jedno z nich i każdy zajmuje się nim, aż do ukończenia go w całości ❤️. Zwróć wtedy uwagę na charakterystyczny układ tych komunikatów, w których wszystkie wątki w tym samym czasie pobierają zadanie, postęp wykonuje się również w tym samym czasie, natomiast numeracja tych zadań będzie nieregularna 😱! Może być na przykład kolejność: {1, 2, 3}, a innym razem taka: {2, 3, 1}!

Współbieżność opiera się na tym, że wątki wykonują swoje zadania równolegle i "ścigają się" między sobą ⏩! Efektem takiego działania jest nieprzewidywalność który z nich pierwszy skończy dane zadanie 💥. Można oczywiście narzucać w pewien sposób ograniczenia stosując tzw. "blokowanie wątku" (ang. lock) 🔒, które często jest stosowane celem zapewnienia spójności danych pomiędzy wątkami (synchronizacja), natomiast niezależnie od sprzętu komputerowego nie będziemy nigdy mieli pewności, że akurat ten wątek zawsze będzie szybszy od drugiego ❌.

Można pisać naprawdę długo na temat programowania współbieżnego, natomiast nie chcę dalej rozwijać tego tematu, gdyż i tak wyszło już sporo treści jak na ten przykład ✋. Wspomnę tylko o temacie, którego nie pokrywa ten przykład: przerywanie prac nad aktualnym zadaniem. W przypadku wątków możemy mówić o 2 sytuacjach 👇:

  1. przerwanie automatyczne przez zbyt długo oczekujący inny wątek o równie ważnym bądź wyższym priorytecie,
  2. przerwanie przez użytkownika poprzez wysłanie sygnału przerwania (najbardziej powszechny sposób to wciśnięcie kombinacji "Ctrl+C").

Gdyby interesował Cię mechanizm opisany w pkt. 1, wtedy musisz skorzystać z obiektu "Event" z modułu "threading" i utworzyć kolejkę priorytetową, czyli strukturę danych do przechowywania elementów posortowanych rosnąco lub malejąco według liczby całkowitej (bez ułamka) przypisanej każdemu z elementów 💥. To zdecydowanie odbiega od niniejszego materiału i musisz już sam(a) poszukać informacji na ten temat z osobnych źródeł 🔍.

Pkt. 2 dotyczy przerwania wykonywania zadań przez otrzymany sygnał przerwania poprzez wciśnięcie przez nas kombinacji "Ctrl+C". Gdy to się stanie, zgłaszany jest błąd typu "KeyboardInterrupt", natomiast program nie jest przerywany 😮! To oznacza, że abyś mógł/mogła poprawnie obsługiwać program działający współbieżnie, musisz uwzględnić przypadek przerwania przez użytkownika i sprawić, aby program na to zezwalał i zatrzymywał wszystkie instrukcje, aby nie dochodziło do fali błędów z powodu wysłania sygnału przerwania 🔥! To również zagadnienie będące poza tematem artykułu.

Pierwszy z dwóch modułów pakietu "concurrent" w języku Python wyjaśniony ✅! Przechodzimy do drugiego: "interpreters" 🔥!

NA CZYM POLEGA MODUŁ "INTERPRETERS" PAKIETU "CONCURRENT" W JĘZYKU PYTHON?

W pakiecie "concurrent", moduł "interpreters" składa się z funkcji do tworzenia niezależnych obiektów interpreterów, czyli odtworzenie działania identycznego do działania Pythona 🎉! W tym przypadku akurat, możesz utworzyć wiele takich obiektów działających niezależnie 🔥! Za ich pomocą możesz przekazywać instrukcje jakie mają się wykonać, dokładnie tak jak zwykle piszesz kod w skrypcie ❤️! Z tego powodu nazywane są też "subinterpreterami", ponieważ działają w interpreterze samego języka 😄!

Interpretery tworzy się w celu osiągnięcia współbieżnego wykonywania instrukcji i ominięcia występującej tzw. "globalnej blokady interpretera" (ang. Global Interpreter Lock, w skrócie GIL ℹ️), która obowiązuje w Pythonie 🧨. Ona zapobiega wykonywaniu kodu współbieżnie, aby uniknąć wszelkich nieprzyjemności w przypadku uruchomienia na komputerze, który nie posiada możliwości wykonywania programu w sposób współbieżny 💡. Czyli jednym zdaniem, interpretery tworzy się po to, aby osiągnąć to samo funkcjonowanie, lecz z możliwością działania współbieżnego 😊.

Przyjrzyjmy się na początek jak stworzyć taki interpreter 🔍!

JAK STWORZYĆ INTERPRETER W JĘZYKU PYTHON?

Gdy zaimportujemy sobie moduł "interpreters" z pakietu "concurrent", wystarczy że skorzystamy z funkcji "create" i przypiszemy jej wynik do nowo stworzonej zmiennej 👇:

my_interpreter = concurrent.interpreters.create()

W taki sposób obiekt interpretera zostanie utworzony i to wszystko 🎯! On będzie działał niezależnie od tego głównego interpretera Pythona ℹ️.

Tworzenie interpretera funkcją "create" z pakietu "concurrent" w języku Python

Obiekt interpretera tworzymy za pomocą funkcji "create".

Teraz możemy wprowadzać polecenia do interpretera pisane dokładnie tak samo, jak piszesz kod w Pythonie 😉! Trzeba tylko wiedzieć jaka metoda jest do tego przeznaczona i jak w jakiej formie pisze się takie instrukcje 🧠!

JAK WPROWADZIĆ POLECENIE DO INTERPRETERA W JĘZYKU PYTHON?

Mając interpreter powstały z modułu "interpreters", wywołujemy sobie metodę "exec" (skrót od "execute" ℹ️), a w parametrze wprowadzamy instrukcję, lecz uwaga, w postaci łańcucha znaków 😲! A to dlatego, że tak naprawdę przekazujemy do metody fragment kodu jaki ma zostać przetworzony i "zinterpretowany" 💡.

Oto przykład wywołania funkcji "print" poprzez przekazanie go metodzie "exec" 👇:

my_interpreter.exec("print('Mój łańcuch znaków')")

Ponieważ ten interpreter działa dokładnie tak samo jak ten występujący w Pythonie, efekt będzie identyczny co do wywołania tej samej funkcji pisanego tradycyjnie 👇:

print('Mój łańcuch znaków')

To jest równoważne 🙂. Możemy też wstawić inne instrukcje np. tworzenie zmiennej 👇:

my_interpreter.exec('my_variable = 5')
my_interpreter.exec('print(my_variable)')

Pierwsze wywołanie utworzy zmienną o wartości całkowitoliczbowej 5 (bez ułamka), a drugie, wypisze ją w konsoli ✅. Jedyne o czym trzeba pamiętać, to że nie ma możliwości wstawiania wielu instrukcji do jednego wywołania 🚫. Postępujemy tutaj "liniowo" ➡️.

Trzeba mieć świadomość, że utworzenie zmiennej w taki sposób sprawi, że ona będzie widoczna, lecz tylko dla tego interpretera 😳! Gdy spróbujesz wyświetlić jej wynik używając funkcji "print" z poziomu głównego interpretera 👇:

print(my_variable)

to wyświetli Ci się błąd typu "NameError", bo Python nie będzie wiedział o co Ci chodzi 😱! Dojdzie tylko do awarii programu i jego natychmiastowego zakończenia (jeśli nie przechwytujesz wyjątku tego typu) ⛔.

Metoda "exec" obiektu interpretera z pakietu "concurrent" w języku Python

Metoda "exec" pobiera wpisaną w parametr instrukcję typu łańcuchowego, przetwarza ją na składnię zrozumiałą przez Pythona i wykonuje ją (jeśli poprawnie napisana). Instrukcja może dotyczyć każdej konstrukcji znanej z Pythona.

To jest sposób na wykonywanie instrukcji oraz funkcji bez zwracania wartości. A co, gdybyśmy chcieli skorzystać z wartości wynikowej 🙂? Wtedy sięgamy po inną metodę 😊!

JAK ZWRÓCIĆ WARTOŚĆ FUNKCJI WYWOŁANEJ PRZEZ INTERPRETER W JĘZYKU PYTHON?

Jeżeli chcemy wywołać funkcję za pomocą interpretera utworzonego przez moduł "interpreters", a ona zwraca wartość i mamy potrzebę uzyskać do niej dostęp, wówczas korzystamy z metody "call", która wspiera taką możliwość 👍.

To jest przykład użycia na jednej z funkcji matematycznych, "pow" 👇:

print(my_interpreter.call(pow, 2, 4))

Układ parametrów jest dokładnie taki sam, jak w przypadku metody "submit" w module sąsiednim pakietu "concurrent" ("futures"), zatem to wywołanie akceptuje też argumenty nazwane 👇:

print(my_interpreter.call(pow, base=2, exp=4))

Ten zapis przyniesie to samo działanie 😉. Pamiętaj tylko, aby nie wstawiać argumentów pozycyjnych po argumentach nazwanych ⛔! To w tym języku jest niedopuszczalne 👇:

print(my_interpreter.call(pow, base=2, 4))

Napisanie w ten sposób zakończy się zgłoszeniem błędu typu "SyntaxError" i jeżeli nie przechwytujemy wyjątku tego typu, to program zostanie przerwany ❌!

Metoda "call" obiektu interpretera z pakietu "concurrent" w języku Python

Metoda "call" pozwala na wywołanie funkcji przez obiekt interpretera. Pozwala na zwrócenie wartości wynikowej wywołania wskazanej funkcji.

Mamy wyjaśnione podstawy korzystania z interpretera 👍. Została ostatnia rzecz jaką trzeba wiedzieć ⚠️!

JAK PRAWIDŁOWO ZAKOŃCZYĆ DZIAŁANIE INTERPRETERA W JĘZYKU PYTHON?

Gdy skończymy pisać wszelkie instrukcje odnoszące się do interpretera modułu "interpreters", należy koniecznie go "zamknąć" używając metody "close" 👇:

my_interpreter.close()

To spowoduje, że obiekt zostanie usunięty i zwolni się pamięć 🙂.

W przeciwieństwie do obiektu puli wątków z modułu "futures", tu nie możemy skorzystać z instrukcji "with" 🚫! Interpreter nie posiada definicji metody "__exit__", która jest niezbędna do użycia instrukcji "with". Szczegóły odnośnie tego tematu zostały przedstawione w załączonym artykule ℹ️.

Metoda "close" obiektu interpretera z pakietu "concurrent" w języku Python

Metoda "close" zwalnia zasoby przeznaczone na utrzymanie obiektu interpretera.

To wszystkie podpunkty dotyczące korzystania z interpretera! Na sam koniec, przykład użycia 😊!

PRZYKŁAD UŻYCIA INTERPRETERA W JĘZYKU PYTHON

Przykład korzystania z "subinterpretera" w Pythonie jaki przygotowałem pokaże sytuację przekazania zadania policzenia silni liczb od A do B osobnemu interpreterowi 😮. Silnia jest iloczynem kolejnych liczb naturalnych nie większych od zadanej, czyli dla przykładu 3! = 1*2*3 = 6 ℹ️.

Zamiast tradycyjnego podejścia, w którym korzystamy z głównego interpretera Pythona do wyznaczenia wyniku, zostanie on policzony przez osobny interpreter (powstały z modułu "interpreters") 🔥! Następnie, gdy wyznaczy on silnię wszystkich liczb, "przekaże" je głównemu interpreterowi, a on tylko wyświetli je w konsoli 😊.

Do przekazania wyników pomiędzy interpreterami, potrzebny nam będzie dodatkowy element, którego wcześniej nie wymieniłem: kolejka (ang. queue) 💥! To jest struktura danych charakteryzująca się obsługą elementów w kolejności zgodnej z kolejką w świecie rzeczywistym, czyli osoba stojąca na początku zostanie "obsłużona" jako pierwsza i jako pierwsza odchodzi od kolejki jak już załatwi swoją sprawę ✅. W informatyce określa się to popularnym terminem "First In, First Out" (w skrócie FIFO) 🔥.

Oto kod, w którym odbywa się policzenie silni liczb od 1 do 100 przez osobny interpreter, zamiast głównego 👇:

interpreter = concurrent.interpreters.create()
minimum_number = 1
maximum_number = 100
interpreter_queue = concurrent.interpreters.create_queue()

interpreter.prepare_main(minimum_number=minimum_number, maximum_number=maximum_number, interpreter_queue=interpreter_queue)
interpreter.exec("""
for number in range(minimum_number, maximum_number + 1):
	result = 1

	for i in range(1, number + 1):
		result *= i
	
	interpreter_queue.put(result)
""")

for number in range(minimum_number, maximum_number + 1):
	print(str(number) + '! = ' + str(interpreter_queue.get()))

interpreter.close()

Tłumaczę każdą instrukcję po kolei 📣!

Na sam początek, definiujemy sobie kilka następujących zmiennych 👇:

  1. obiekt interpretera,
  2. minimalną liczbę zakresu liczb dla wyznaczania silni tych liczb,
  3. maksymalną liczbę zakresu liczb dla wyznaczania silni tych liczb,
  4. kolejkę z danymi do przekazywania pomiędzy interpreterami (nie tworzymy jej jako zwykłej listy, tylko korzystamy z funkcji "create_queue" ⚠️!).

W dalszym ciągu kodu natrafiamy na wywołanie metody "prepare_main", która oznacza "powiązanie" podanych danych występujących w głównym interpreterze z tym odrębnym 💡. Innymi słowy, każda zmienna przekazana jako parametr tego wywołania zostanie "przeteleportowana" do wnętrza "subinterpretera", co sprawi, że będzie można z niej skorzystać w jej wnętrzu 😱! W tym przykładzie przekazujemy minimalną i maksymalną liczbę dla zakresu do wyznaczenia silni liczb oraz kolejkę dla interpreterów ➡️.

Jest to konieczny zabieg, aby te zmienne były "widoczne" dla "subinterpretera" i zwyczajnie się nie powtarzać! Pamiętaj, że każdy osobny interpreter, to jest jak osobna "czysta kartka"! Jeżeli utworzysz sobie dowolną zmienną, tak jak zwykle ją piszesz 👇:

my_variable = 5

to ona nie pojawi się w "subinterpreterze" 😳! Musisz ją "przekazać" metodą "prepare_main" albo utworzyć ją od nowa, używając metody "exec" ⚠️!

A skoro o niej mowa, to zobacz jak ona jest rozbudowana 👀! Treść kodu jaki ma zostać wykonany przez "subinterpreter", to wyliczenie silni liczb od 1 do 100 oraz przekazanie wyniku działania do kolejki! To wywołanie 👇:

interpreter_queue.put(result)

oznacza wstawienie policzonego wyniku do kolejki interpreterów, czyli tego "pośrednika" utrzymującego łączność pomiędzy interpreterami 📶!

Warto wiedzieć, że nie wszystkie typy danych możemy "przenosić" pomiędzy interpreterami 🔥! Na przykład zakres liczbowy ("range") nie zostanie dopuszczony 🚫! To dlatego został napisany dwukrotnie zarówno w metodzie "exec", jak i na samym końcu kodu 👇:

range(minimum_number, maximum_number + 1)

Przechodząc właśnie do tej części, niżej już tylko znajduje się pętla "for" odpowiedzialna za wypisanie wyników działania odrębnego interpretera. Korzystamy z zakresu liczbowego powstałego z dwóch liczb wyznaczających nam granicę 👍. Zauważyłeś(-aś) już zapewne, że tu jest zwiększenie górnej granicy o jeden 📈. Nie bez kozery 😳. Konstruktor typu "range" nie obejmuje podanego górnego zakresu i stąd określany jest jako "exclusive end", czyli "wykluczony" spoza zakresu 🧨. Stąd należy go zwiększyć o jeden do góry 💡!

Do pobrania następnej wartości w kolejności z kolejki korzystamy z metody "get" ↩️. A ponieważ wstawiona do niej wartości nie musi być łańcuchem znaków, musimy ją przekonwertować na ten typ, aby wywołanie funkcji "print" zakończyło się sukcesem 🚀!

Ostatnią instrukcją z całego przykładu jest "zamknięcie" interpretera poprzez zwolnienie zasobów komputera metodą "close" i na tym prezentacja przykładu dobiega końca 😄! Po uruchomieniu tego kodu, ujrzysz w konsoli wypisanie silni liczb od 1 do 100, czyli efektem nie różni się niczym od wykonania tego zadania przez główny interpreter 😉. Natomiast tu chciałem pokazać samo wykonanie i że można "oddelegować" zadania odrębnym interpreterom, co też jest przykładem współbieżności ❤️!

Na sam koniec warto wspomnieć o tym, że moduł "futures" udostępnia odrębny rodzaj puli, który ma ścisły związek z przedstawianymi interpreterami: "InterpreterPoolExecutor" ℹ️. Działa na takiej samej zasadzie co opisana pula wątków, natomiast nie będę tego tutaj opisywał 🙂.


To już wszystko na ten temat 😁. Poza faktem, że zrobił mi się cholernie duży materiał 🤣, inny wniosek płynie taki, że w Pythonie możemy zrealizować przynajmniej częściowo programowanie współbieżne, stosując pakiet "concurrent"! W zależności od potrzeb, udostępnia on wątki oraz interpretery, a obie te struktury pozwalają na przetwarzanie równoległe i przekazanie im zadań do wykonania w jednym czasie 🏆.

PODOBNE ARTYKUŁY