Poczytaj mi Clojure, cz. 12: Współbieżność

Wykonywanie współbieżne pozwala spożytkować moc obliczeniową więcej niż jednego procesora lub rdzenia, pomaga też w lepszym zarządzaniu czasem obliczeniowym, gdy niektóre operacje muszą oczekiwać na obsługę komunikacji międzyprocesowej lub podsystemu wejścia/wyjścia. Clojure proponuje przejrzystą i wygodną implementację wykonywania współbieżnego, wykorzystującą typy referencyjne i programową pamięć transakcyjną.

Współbieżność

Wykonywanie współbieżne (ang. concurrent) to cecha systemów i programów komputerowych, w których te same obliczenia dokonywane są jednocześnie przez więcej niż jeden komponent, przy opcjonalnej komunikacji między komponentami.

Praktyczną zaletą wykonywania współbieżnego jest możliwość spożytkowania mocy więcej niż jednej centralnej jednostki obliczeniowej (ang. Central Processing Unit, skr. CPU) w tym samym czasie. Jednostką tą może być procesor lub rdzeń procesorowy (ang. CPU core). Zysk wydajnościowy możemy zaobserwować nawet wtedy, gdy mamy do czynienia z wieloma wątkami (ang. threads) realizowanymi przez ten sam układ: w przypadku obliczeń realizowanych w czasie zablokowania innego wątku podczas operacji wejścia/wyjścia, a także w procesorach superskalarnych z obsługą jednoczesnej wielowątkowości (ang. simultaneous multithreading, skr. SMT).

Istnieje wiele sposobów równoległego wykonywania zadań przez programy komputerowe i są one zależne nie tylko od przyjętych algorytmów czy używanego języka programowania, ale również od mechanizmów obsługi obecnych w systemie operacyjnym i/lub maszynie wirtualnej. Na przykład bardzo popularne przetwarzanie wielowątkowe (ang. multi-threaded computing) jest podatnym na występowanie błędów i przez to trudnym do zaprogramowania sposobem współbieżnego realizowania zadań. Dzieje się tak przede wszystkim ze względu na konieczność dbania przez programistę o to, aby zmiany we współdzielonych strukturach danych były wykonywane synchronicznie. Przykładem mogą być tu mechanizmy tzw. blokad (ang. locks), których użycie skutkować może niemiłymi niespodziankami w postaci tzw. zakleszczeń (ang. deadlocks). Oczywiście możliwe jest unikanie tego typu incydentów przez poświęcanie należytej uwagi procesom zarządzania zmianami w oprogramowaniu, jednak wymaga to inwestowania przez programistę czasu w myślenie o obsłudze technicznej przetwarzania równoległego, zabierając cenne minuty, które mógłby przeznaczyć na programowanie rozwiązań problemów związanych z logiką biznesową aplikacji.

Chociaż Clojure korzysta z systemu wątków maszyny wirtualnej Javy, programowanie współbieżne w tym języku jest łatwiejsze, niż konwencjonalne przetwarzanie wielowątkowe z kilku powodów. Po pierwsze dane są niemutowalnestruktury danych trwałe, więc większość informacji można przetwarzać niezależnie od tego, z jak wieloma równoległymi operacjami mamy do czynienia.

Gdy mówimy o bezpieczeństwie w kontekście przetwarzania współbieżnego z użyciem wątków, mamy na myśli dwa procesy:

  • sterowanie równoległym wykonywaniem,
  • sterowanie widocznością obiektów pamięciowych.

Pierwszy z nich dotyczy sposobów realizowania równoległych obliczeń: rozpoczynania i kończenia pracy, zdolności do zrównoleglania operacji i kolejności wartościowania wyrażeń. Każdy z omówionych niżej mechanizmów obsługi współbieżności w Clojure różni się nieco pod tym względem.

Drugi proces związany jest z tym, czy i w jaki sposób współdzielone wartości będą udostępniane innym wątkom, jeżeli chodzi o ich odczytywanie czy aktualizowanie. Warto przy okazji wspomnieć, że obsługa współbieżności w JVM, a także w procesorach, korzysta z mechanizmów buforowania zawartości pamięci operacyjnej, tzn. operacje na współdzielonych obiektach mogą czasami odwoływać się do kopii danych z RAM-u znajdujących się w pamięciach podręcznych procesora (ang. CPU cache), a nie do właściwych komórek. W Clojure większość mechanizmów obsługujących współbieżne i synchroniczne wykonywanie samodzielnie kontroluje dostęp do pamięci i nie pozwala na korzystanie z pamięci typu cache, chociaż istnieją w tym względzie wyjątki (np. omówione w dalszej części obiekty typu Volatile).

Typy referencyjne w Clojure

Zagnieżdżanie wywołań funkcji czy korzystanie z powiązań leksykalnych pozwalają tworzyć programy, które operują na stałych wartościach. Główna funkcja przyjmuje wartość lub zestaw wartości i przeprowadza na nich obliczenia, korzystając z wywołań innych funkcji. W ich ciałach mogą znaleźć się też konstrukcje, które wytworzą wartości tymczasowe, jednak one również będą niemodyfikowalne.

Takie podejście jest wystarczające, gdy pisany przez nas kod ma zrealizować jakieś (nawet skomplikowane) obliczenia, ale może nie być najlepszym rozwiązaniem, gdy potrzebujemy modelować rozwiązania bardziej skomplikowanych problemów otaczającego nas świata. Dlaczego? Mamy w nim często do czynienia ze zmiennymi stanami abstrakcyjnych obiektów o stałych tożsamościach. Innymi słowy potrzebujemy obiektów o ustalonych nazwach, które w miarę upływu czasu mogłyby wyrażać różne wartości.

Przykładem stałej tożsamości o zmiennych stanach może być stan magazynowy – aktualizowany z wykorzystaniem różnych operacji, które sprawiają, że pojawiać się w nim będą bądź znikać z niego jakieś obiekty składowe reprezentujące przedmioty. Wyrażając go w programie komputerowym, moglibyśmy użyć tylko argumentów funkcji i zwracanych przez nie wartości, ale byłoby to mało czytelne. Gdy dodamy do tego konieczność współbieżnej obsługi, możemy zauważyć, że kod będzie zagmatwany i podatny na pojawianie się w nim błędów.

Dzięki obecnym w Clojure typom referencyjnym (ang. reference types), możemy tworzyć globalne lub ograniczone kontekstem tożsamości, których stany mogą się zmieniać. W praktyce oznacza to obiekty pozwalające na odnoszenie się do umieszczonych w pamięci stałych wartości z możliwością aktualizowania tych odniesień na różne sposoby – zależnie od przyjętego modelu zarządzania równoległym wykonywaniem i widocznością obiektów.

Aktualną wartość skojarzoną z konkretnym obiektem typu referencyjnego będziemy nazywali jego wartością bieżącą lub wartością współdzieloną, ponieważ będzie to powiązania z nim wartość widoczna we wszystkich lub w wybranych wątkach programu.

Do dyspozycji mamy następujące klasy obiektów referencyjnych:

  • zmienne globalnedynamiczne – typ Var,
  • Atomy – typ Atom,
  • Agenty – typ Agent,
  • Refy – typ Ref,
  • Future’y – typ Future,
  • Promise’y – typ Promise,
  • Delay’e – typ Delay,
  • Volatile’e – typ Volatile.

Mechanizmy obsługi typów referencyjnych dostarczają odpowiedniej warstwy abstrakcji, pozwalającej tworzyć stałe tożsamości wyrażające zmienne stany, a także dbają o to, aby aktualizowanie tych stanów było bezpieczne w kontekście przetwarzania współbieżnego.

Sposoby dostępu

Typy referencyjne różnią się sposobami obsługi dostępu do wartości, do których przechowują odniesienia. Opisując je, będziemy korzystali z następujących określeń, które je charakteryzują:

  • Dostęp synchroniczny (ang. synchronous) to taki, gdzie operacja odczytu powiązanej wartości bieżącej lub aktualizacji odniesienia w obiekcie referencyjnym odbywa się do skutku, a wątek, w którym się ona wydarza nie wykonuje w tym czasie innych zadań. Przykładem takiego dostępu jest zwiększenie wartości współdzielonego licznika, która później w tym samym lub innym wątku będzie odczytana i użyta. Po wykonaniu operacji uaktualnienia mamy pewność, że wartość bieżąca licznika została zmieniona.

  • Dostęp asynchroniczny (ang. asynchronous) to taki, gdzie operacja odczytu powiązanej wartości bieżącej lub aktualizacji odniesienia w obiekcie referencyjnym może być zlecona do wykonania równolegle z innymi i nie powoduje zablokowania wykonywania realizującego ją wątku. Informacja o bieżącym stanie (a także o tym, czy doszło do aktualizacji) nie jest dostępna od razu, ale może być pozyskana z użyciem odpowiednich funkcji. W gestii programisty leży więc sprawdzanie czy doszło do poprawnej aktualizacji referencji, jeżeli taka wiedza jest potrzebna. Przykładem dostępu asynchronicznego jest zwiększanie wartości licznika sumującego liczbę wykonań jakichś czynności, który zostanie odczytany, gdy wątek lub cała aplikacją będzie kończyć pracę. Inny przykład to obsługa konwersacji w serwerze internetowych pogawędek, gdzie wymieniane przez użytkowników komunikaty są wysyłane do podprogramów działających w obrębie wątków obsługujących ich sesje.

  • Dostęp koordynowany (ang. coordinated) to taki, gdzie operacja odczytu powiązanej wartości bieżącej lub aktualizacji odniesienia w obiekcie referencyjnym zależy od rezultatów wykonania takich operacji na innych obiektach. Przykładem może być przelew z jednego rachunku bankowego na drugi, podczas którego należy w sposób atomowy zmienić stany obu rachunków lub zrezygnować, gdy ilość środków jest niewystarczająca.

  • Dostęp niekoordynowany (ang. uncoordinated) to taki, gdzie operacja odczytu powiązanej wartości bieżącej lub aktualizacji odniesienia w obiekcie referencyjnym odbywa się niezależnie od operacji na innych obiektach. Przykład to kapitalizacja odsetek na rachunku bankowym, która może być przeprowadzana równolegle na wielu rachunkach.

Zmienne globalne i dynamiczne

Zmienne globalne pozwalają utrzymywać stałą tożsamość, która będzie wyrażała bardzo rzadko zmieniający się lub niezmienny stan (nazywanie innych obiektów, np. funkcji używanych w programie czy stałych parametrów aplikacji). Zmiany stanów są współdzielone między wątkami (przy zmianie tzw. powiązania głównego, zwanego też wartością początkową).

Z powodu przezroczystego korzystania z obiektów typu Var możemy mieć do czynienia z operacjami blokującymi bieżący wątek lub nieblokującymi go – w zależności od charakterystyki obiektu, do którego zmienna globalna się odnosi.

Zmienne dynamiczne, będące wariantem zmiennych globalnych, umożliwiają to samo, co ich matczyne konstrukcje, jednak z tą różnicą, że możliwe jest tworzenie izolowanych w wątku przesłonięć referencji (dodatkowo w zasięgu dynamicznym) przy zachowaniu współdzielonego między wątkami powiązania głównego, wyrażającego wartość początkową.

Ze zmiennych dynamicznych korzysta się na przykład do określania elementów konfiguracji programów i przy tzw. programowaniu aspektowym, gdzie w zależności od kontekstu zmienia się charakter operacji wykonywanych na danych, przy czym zachowana jest stała tożsamość tych operacji – dochodzi po prostu do podmiany obiektów funkcyjnych przy zachowaniu ich symbolicznych nazw.

Zobacz także:

Zmienne lokalne

Zmienne lokalne pozwalają utrzymywać stałą tożsamość, która będzie wyrażała zmieniający się stan izolowany w wątku. Są odpowiednikiem konwencjonalnych zmiennych znanych z imperatywnie zakorzenionych języków programowania, a służą do wyrażania algorytmów, które trudno byłoby zapisać funkcyjnie.

Zmienne lokalne korzystają z nieinternalizowanych obiektów typu Var.

Zobacz także:

Atomy

Atom jest mechanizmem obsługi zmian stanów tożsamości, które są współdzielone między wątkami, przy czym modyfikacje odniesień odbywać się mogą w sposób synchronicznyniekoordynowany.

Aktualizacje wartości bieżących Atomów blokują bieżący wątek, natomiast odczytynieblokujące.

Z Atomów skorzystamy na przykład w implementacjach liczników czy w odpowiednikach semaforów, które strzegą dostępu do współdzielonej struktury danych (np. bufora pamięci podręcznej). Dają one pewność, że podczas zmiany współdzielonej wartości dojdzie do uwzględnienia poprzedniej, najbardziej aktualnej, ale nie mamy pewności, czy będzie to wartość ostatnio zlecona w którymś z wątków. Oznacza to, że w przypadku struktur złożonych z wielu elementów nie możemy polegać na kolejności ich modyfikowania.

Zobacz także:

Agenty

Agent to mechanizm referencyjny nieco podobny do Atomów. Również pomagają wyrażać częste, niekoordynowane zmiany współdzielonych stanów ustalonej tożsamości, jednak odbywa się to w sposób asynchroniczny.

Operacja aktualizacji odniesienia nie blokuje wykonywania się programu, lecz natychmiast trafia do specjalnego bufora, w którym oczekuje na realizację w odpowiednim czasie. Odczyty wartości bieżących są również nieblokujące.

Agenty wykorzystywane są tam, gdzie zależy nam na zmianie stanu współdzielonego obiektu, lecz nie zależy nam na tym kiedy do tej zmiany dojdzie, bo ważniejsza jest dalsza, niezakłócona praca bieżącego wątku. Zastosowania Agentów to wymiana danych w programowaniu polegającym na zarządzaniu zdarzeniami (np. w systemach gromadzenia raportów czy sterowania niezależnymi procesami).

Funkcja aktualizująca przekazywana w celu zmiany stanu Agenta nie jest wywoływana w bieżącym wątku, lecz umieszczana w specjalnej puli, gdzie oczekuje na realizację. Operując na Agentach, nie mamy więc pewności, czy aktualizacja wartości bieżącej nastąpi po aktualizacji dopiero zleconej, a nawet czy w ogóle do niej dojdzie, dopóki tego nie sprawdzimy, dokonując zbadania lub odczytu stanu obiektu.

Zobacz także:

Refy

Bardziej złożone operacje, wymagające koordynowania zmian stanów wybranej liczby ustalonych tożsamości, które są współdzielone między wątkami w sposób synchroniczny, umożliwiają referencje transakcyjne (skr. Refy). Operacje na nich korzystają z programowej pamięci transakcyjnej (ang. software transactional memory, skr. STM). Dzięki niej można odwoływać się do pamięciowych struktur w podobny sposób, w jaki dochodzi do wykonywania operacji na bazach danych – mamy na przykład do czynienia z systemem transakcji.

Podczas realizowania transakcji bieżący wątek jest blokowany do momentu jej zakończenia. Z kolei odczyty Refów są operacjami nieblokującymi, a zapisy trafiają do odpowiednich kolejek, gdzie po uszeregowaniu mogą wpływać na blokowanie zarówno transakcji bieżącej, jak i innych transakcji.

Modyfikacje różnych odniesień dokonywane w ramach pojedynczej transakcji są atomowe (mogą w całości udać się lub zostać odrzucone), natomiast odczyty referencji w jej obrębie sprawiają, że tworzone są tzw. migawki (ang. snapshots), czyli niezmienne wartości wyrażające stany tożsamości w konkretnych momentach. Referencje transakcyjne znajdują zastosowanie w komunikacji z zewnętrznymi źródłami danych, a także przy zarządzaniu zbiorami informacji, które cechować musi wewnętrzna spójność pod względem relacji między elementami.

Zobacz także:

Future’y

Operacje, które powinny być realizowane równolegle można zlecać tworząc obiekty typu Future. Jest to typ referencyjny, dzięki któremu daje się wyrazić niekoordynowaną, pojedynczą zmianę współdzielonego stanu, realizowaną w osobnym wątku, zaś obliczenia prowadzące do uzyskania wartości odbywały się będą w sposób asynchroniczny.

Próba odczytu wartości bieżącej spowoduje zablokowanie bieżącego wątku do momentu jej uzyskania, z kolei zapis jest nieblokujący (odbywa się zawsze w innym wątku).

W praktyce Future’ów użyjemy tam, gdzie chcemy, aby jakieś wyrażenie podane w wątku bieżącym było zrealizowane w osobnym wątku, aby potem odczytać wynik, dokonując dereferencji obiektu. Proces ten jest blokujący (wstrzymanie wykonywania bieżącego wątku do pojawienia się wyniku z określonym czasem oczekiwania lub bez niego) i opcjonalnie poprzedzony sprawdzaniem, czy rezultat jest już gotowy. Wynik obliczeń jest zapamiętywany, więc każda kolejna dereferencja spowoduje skorzystanie z zapisanej wcześniej wartości. Poza typowymi kalkulacjami Future’ów można też użyć do równoległej obsługi operacji wejścia/wyjścia, aby nie blokować nimi bieżącego wątku.

Zauważmy, że korzystając z Future’ów, nie możemy wybrać, w którym wątku zostaną przeprowadzone obliczenia. Wiemy tylko, że będzie to wątek osobny, różny od bieżącego. Realizowanie wyrażenia prowadzącego do uzyskania wyniku i ustawianie wartości bieżącej obiektu referencyjnego na jego podstawie będą realizowane równolegle do wątku, w którym zlecono stworzenie obiektu typu Future.

Zobacz także:

Promise’y

Operacje, które powinny być realizowane równolegle można też zlecać tworząc obiekty typu Promise. Jest to typ referencyjny, dzięki któremu daje się wyrazić niekoordynowaną, pojedynczą zmianę współdzielonego stanu, do której już doszło lub dopiero dojdzie w dowolnym wątku, zaś realizowanie obliczeń prowadzących do uzyskania wartości odbędzie się w sposób asynchroniczny.

Próba odczytu wartości bieżącej spowoduje zablokowanie bieżącego wątku do momentu jej uzyskania, natomiast zapis jest operacją nieblokującą.

Z Promise’ów skorzystamy, aby we wskazanym wątku obliczyć wartość, która będzie następnie współdzielona między wszystkimi wątkami, przy czym mogą one na nią oczekiwać. Jest to mechanizm komunikacji między wątkami, w którym jeden wątek spodziewa się rezultatu obliczeń dostarczanego przez inny.

Wątek oczekujący na rezultat może dokonać blokującej dereferencji, natomiast wątek realizujący obliczenia musi użyć funkcji wysyłającej wartość, tzn. dokonującej powiązania z nią obiektu typu Promise. Operacja ta może być przeprowadzona tylko raz, a każda dereferencja spowoduje skorzystanie z wcześniej ustawionej wartości.

Różnica w stosunku do Future’ów polega na tym, że mamy wpływ na to, w którym wątku dokonamy obliczeń, aby wszystkie inne wątki miały dostęp do ich rezultatów. W praktyce wygląda to tak, że w wybranym wątku samodzielnie operujemy na danych w celu uzyskania wartości, a następnie używamy jej do zmiany stanu obiektu typu Promise.

Zobacz także:

Delay’e

Operacje, które powinny być odłożone w czasie można zlecać, tworząc obiekty typu Delay. Jest to typ referencyjny, dzięki któremu daje się wyrazić niekoordynowaną, pojedynczą zmianę współdzielonego stanu. Aktualizacja referencji odbywa się w sposób synchroniczny.

Odczyty wartości bieżących obiektów typu Delayblokujące – pierwsza próba odczytu sprawia, że w bieżącym wątku rozpoczyna się wykonywanie odłożonych wcześniej obliczeń. Z kolei zapis jest operacją nieblokującą.

Delay’ów użyjemy tam, gdzie chcemy, aby obliczenie wartości wyrażenia zostało wykonane później i na żądanie. Pierwsza dereferencja obiektu typu Delay w dowolnym wątku sprawi, że zostanie on zablokowany do czasu zrealizowania obliczeń i uzyskania wartości. Ta ostatnia zostanie powiązana z obiektem referencyjnym, a operacja ta będzie wykonana tylko raz i tylko w obrębie jednego wątku w danym kwancie czasu. Wynik obliczeń jest zapamiętywany, więc każda kolejna dereferencja w dowolnym wątku spowoduje skorzystanie z zapisanej wcześniej wartości.

Możemy wybierać w którym wątku wyrażenie zostanie przeliczone i na podstawie rezultatu ustawiona będzie wartość bieżąca, jeśli tylko możemy odwołać się w nim do współdzielonego obiektu typu Delay.

Zobacz także:

Volatile’e

Volatile to mechanizm podobny do Atomów, jednak pozbawiony obsługi funkcji służących do badania poprawności wartości bieżących (walidatorów) i nie wyposażony w możliwość rejestrowania tzw. funkcji obserwujących. Jest to sposób utrzymywania lokalnego stanu zbliżony do zmiennych znanych z innych języków programowania, a wykorzystywany głównie w tzw. transduktorach.

Z uwagi na brak gwarancji, że operacje będą atomowe, nie powinno się używać Volatile’i do zarządzania współdzielonymi stanami, ale do szybkiej wymiany informacji w obrębie pojedynczego, izolowanego wątku z opcjonalnymi odczytami wartości współdzielonych w innych wątkach.

Obiekty typu Volatile zachowują się podobnie do zmiennych lokalnych, z tą różnicą, że izolowanie w wątku nie jest wymuszane.

Zobacz także:

Ten materiał jest częścią większej całości, którą można zobaczyć odwiedzając poniższy odnośnik:

Komentarze