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

Agent to typ referencyjny podobny do Atomu. Dzięki niemu można wyrażać częste, niekoordynowane zmiany stanów współdzielonych tożsamości w asynchroniczny sposób. Wykorzystywane są na przykład do obsługi niezależnych zdarzeń czy jednokierunkowej komunikacji między komponentami realizującymi dostęp do jednego zasobu (równoległe pobieranie danych z sieci, zapis do jednego pliku przez wiele wątków itp.).

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 (w tym samym czasie) przez więcej niż jeden komponent, przy opcjonalnej komunikacji między komponentami.

Więcej o współbieżności w Clojure można przeczytać w odcinku 12.

Agenty

Agent to mechanizm, dzięki któremu można wyrażać częste, niekoordynowanewspółdzielone zmiany stanów ustalonej tożsamości. W przeciwieństwie do atomów podmiany wartości dokonywane są asynchronicznie, to znaczy operacja aktualizacji odniesienia nie blokuje wykonywania się programu, lecz staje się zleceniem, które trafia do odpowiedniego bufora, gdzie oczekuje na realizację w osobnym wątku.

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 zwykle obsługa jednokierunkowych strumieni komunikatów (bezpieczny zapis do plików czy równoległe pobieranie danych z kilku źródeł), a ogólnie mówiąc wymiana danych w procesach zarządzania zdarzeniami (np. w systemach gromadzenia raportów czy sterowania niezależnymi zadaniami). Specyficznym zastosowaniem może być też kontrola współbieżności w wywołaniach tych metod Javy, które same nie są bezpieczne pod tym względem.

Agent jest typem referencyjnym i przechowuje odniesienia, które mogą być zmieniane z użyciem odpowiednich funkcji. Operacje zmian powiązań obiektów tego typu z wartościami zachodzą w sposób atomowy – mogą w całości udać się lub nie. To znaczy, że nie wystąpi sytuacja częściowej modyfikacji lub modyfikacji dokonywanej w tym samym czasie przez więcej niż jeden wątek. Jeżeli zmiana z jakichś przyczyn się nie powiedzie, będzie można zdiagnozować, że tak się stało, lecz ewentualna reakcja na takie zdarzenie (np. ponowienie operacji) leży w gestii programisty. Mimo że same zmiany są atomowe, nie możemy przewidzieć w jakiej kolejności wystąpią.

Obiektowi typu Agent można zlecić zmianę powiązania z wartością bieżącą (zmianę stanu) przez przekazanie mu funkcji, która tego dokona. W odpowiednim czasie funkcja ta zostanie wywołana i dojdzie do podmiany wartości, z którą powiązany jest Agent. Nową wartością będzie ta zwrócona przez przekazaną funkcję. Możemy następnie monitorować Agenta: sprawdzać, czy zmiana się powiodła i/lub odczytywać jego bieżący stan.

Korzystanie z Agentów polega na utworzeniu odpowiedniego obiektu, a następnie aktualizowaniu jego powiązań z wartościami. Zazwyczaj obiekt typu Agent będzie dodatkowo w sposób stały utożsamiany z symbolem określonym zmienną globalną lub powiązaniem leksykalnym. W ten sposób można będzie identyfikować go z użyciem nazwy widocznej w odpowiednich obszarach programu.

Pule wątków

Aby asynchronicznie realizować zlecenia zmian stanów (powiązań referencyjnych z obliczanymi wartościami), Agenty korzystają z zaimplementowanego w maszynie wirtualnej Javy mechanizmu puli wątków (ang. thread pool).

Przypomnijmy, że wątek (ang. thread) to pewien fragment programu działający w obrębie jednego procesu, który może być wykonywany równolegle z innymi realizowanymi fragmentami tego procesu (innymi wątkami) i który współdzieli z nimi przestrzeń danych.

Pula wątków to, dokładniej rzecz ujmując, utworzona zgodnie z koncepcją tzw. wzorca puli obiektów (ang. object pool pattern) kolekcja obiektów reprezentujących wątki (służących do zarządzania nimi). Wątki w puli są inicjowane i gotowe do wykorzystania tuż po jej powstaniu, co eliminuje konieczność tworzenia ich za każdym razem, gdy zajdzie potrzeba użycia. Idea puli bazuje więc na tym, że istnieje określony zbiór wątków gotowych do realizacji zadań, które są z tego zbioru wyłączane, gdy pojawiają się zlecenia obsługi pewnych czynności, a kiedy kończą pracę, powracają do niego.

Każda pula jest wyposażona w kolejkę puli wątków (ang. thread pool queue), do której trafiają zadania przeznaczone do rozdzielenia między wolnymi wątkami. Kolejka jest potrzebna, ponieważ mogą zdarzać się sytuacje chwilowego wyczerpania puli (zajętości wszystkich wątków).

Z implementacyjnego punktu widzenia pula jest obiektem wyposażonym w jeden z wariantów obecnego w Javie interfejsu Executor (z tej przyczyny reprezentujący ją obiekt nazywany też bywa wykonawcą), natomiast wątki zadaniowe (ang. worker threads) reprezentowane są przez obiekty wyposażone w interfejs Runnable.

Spróbujmy przedstawić, jak krok po kroku wygląda korzystanie z pul w przypadku systemu Agentów obecnego w Clojure:

  1. Tworzony jest obiekt typu Agent.
  2. Wywoływana jest funkcja zmiany stanu tego obiektu, która jako argument przyjmuje funkcję odpowiedzialną za przeprowadzenie obliczeń na bieżącej wartości powiązanej z Agentem, a zwraca nową wartość.
  3. Przekazany obiekt funkcyjny nie jest wywoływany od razu, lecz trafia do kolejki puli wątków w celu realizacji we właściwym czasie.
  4. Gdy w puli znajduje się wolny wątek, a zlecenie zmiany stanu jest na początku kolejki, funkcja przekazywana jest do realizacji w tym wątku.
  5. W wątku wywoływana jest funkcja wyliczająca nową wartość na bazie bieżącej.
  6. Jeżeli nie wystąpił błąd, a nowa wartość jest akceptowalna, dochodzi do atomowej podmiany referencji w obiekcie typu Agent i zostaje on powiązany z tą wartością.
  7. Wątek przestaje realizować zadanie zmiany stanu i zostaje oznaczony jako wolny (wraca do puli).

Rodzaje pul

Ze względu na liczbę wątków pula może być z góry ustalona (ang. fixed thread pool) lub rozszerzalna (ang. expandable thread pool). Ta ostatnia jest w przypadku Agentów dodatkowo buforowana (ang. cached thread pool). Pula o stałej liczbie wątków ma ich 2 plus tyle, ile dostępnych jest maszynie wirtualnej jednostek obliczeniowych. Z kolei do puli rozszerzalnej mogą być dynamicznie dodawane nowe wątki, jeżeli wystąpi taka konieczność.

Podstawowa obsługa Agentów polega na korzystaniu z dwóch już utworzonych pul. Pierwsza jest obiektem reprezentującym pulę o ustalonej liczbie wątków, a druga instancją puli rozszerzalnej. To, z której gotowej puli skorzystamy, zlecając aktualizację wartości bieżącej Agenta, zależy od użytej w tym celu funkcji.

Pula o ustalonej liczbie wątków dobrze nadaje się do przeprowadzania krótkotrwałych obliczeń, korzystających z CPU. Zlecanie operacji zmiany stanu Agenta, przez wysłanie obiektu funkcyjnego przeznaczonego do wykonania przez jeden z wolnych wątków puli, odbywa się z użyciem funkcji send.

Pula o rozszerzalnej liczbie wątków przydaje się wtedy, gdy chcemy obsługiwać wiele czasochłonnych operacji, które mogą blokować wątki na dłużej. Przykładem może być tu interakcja z podsystemem wejścia/wyjścia czy mechanizmami komunikacji międzyprocesowej (terminal, pliki, gniazda sieciowe, pliki, systemowe kolejki FIFO itp.). Przy stałej puli czynności tego typu szybko doprowadziłyby do jej wyczerpania z powodu zajęcia wszystkich wolnych wątków zadaniami oczekującymi na dane wejściowe. Programista może zlecić operację zmiany stanu Agenta za pośrednictwem tej puli, używając funkcji send-off.

Wątki z puli rozszerzalnej cechuje określony z góry czas bezczynności (ang. timeout), po którym są one niszczone (domyślnie 1 minuta). Dodatkowo Clojure korzysta z wspomnianego, buforowanego wariantu tej puli. Oznacza to, że jeżeli jakiś wątek zakończy poprawnie pracę przed określonym czasem, a w kolejce pojawi się nowe zadanie do zrealizowania, to zamiast stwarzać nowy wątek, wykorzystane zostaną zasoby bieżącego, który nie został jeszcze zniszczony. W efekcie pule tego rodzaju nadają się również do realizowania zadań związanych z dużą liczbą krótkich obliczeń czy obsługą napływających komunikatów, ponieważ moc obliczeniowa nie jest marnotrawiona na operacja wielokrotnego stwarzania i niszczenia wątkowych obiektów.

Poza dwoma wbudowanymi pulami o odpowiednich dla większości problemów charakterystykach, możemy również skorzystać z puli wątków, którą wcześniej samodzielnie utworzyliśmy z użyciem odpowiednich wywołań metod Javy. Do obsługi takich przypadków służy funkcja send-via.

Zwalnianie pul

Wątki używane do obsługi Agentów w Clojure nie są tzw. demonami, zwanymi też wątkami demonowymi (ang. daemon threads). Oznacza to między innymi, że aktywne korzystanie z nich może wiązać się z tym, iż maszyna wirtualna Javy nie przestanie działać po przerwaniu lub zakończeniu pracy przez aplikację. W przypadku puli rozszerzalnej do zakończenia wprawdzie dojdzie, ale dopiero po upłynięciu maksymalnego czasu bezczynności ostatnio aktywnego wątku. Możemy temu zaradzić, niszcząc pule wątków używane do obsługi Agentów przez wywołanie funkcji shutdown-agents.

Tworzenie

Tworzenie obiektów typu Agent, agent

Do tworzenia obiektów typu Agent służy funkcja agent. Przyjmuje ona jeden obowiązkowy argument – wartość początkową (z którą powiązany zostanie Agent) i argumenty nieobowiązkowe opcji, wyrażane w sposób nazwany, czyli w postaci par klucz–wartość.

Funkcja zwraca obiekt typu Agent.

Użycie:

  • (agent wartość-początkowa & opcje).

Możliwe opcje to:

  • :meta mapa-metadanowa,
  • :validator funkcja,
  • :error-handler funkcja,
  • :error-mode klucz-trybu.

Mapa metadanowa po słowie kluczowym :meta powinna być mapą wyrażoną z użyciem powiązanego z nią symbolu lub osadzoną w kodzie jako tzw. mapowe S-wyrażenie. Podane asocjacje staną się metadanymi tworzonego obiektu referencyjnego.

Funkcja podawana po słowie kluczowym :validator powinna być jednoargumentowym obiektem funkcyjnym, który nie generuje efektów ubocznych, albo wartością nil. Funkcja ta będzie wywoływana za każdym razem, gdy zażądano zmiany stanu obiektu typu Agent i przekazywana jej będzie proponowana, nowa wartość. Powinna ona zwracać false lub zgłaszać wyjątek, gdy wartość ta jest nieakceptowalna. Operację taką nazywamy walidacją, a wyrażającą ją funkcję walidatorem. Pierwsze wywołanie walidatora będzie testowało wartość początkową Agenta i natychmiastowo komunikowało błąd. Kolejne wywołania, z racji asynchronicznej natury Agentów, będą zwracały obiekt typu Agent niezależnie od tego czy nowa wartość była poprawnie zweryfikowana. Zamiast tego zostanie uruchomiony kod specjalnej funkcji, której zadaniem jest obsługa sytuacji wyjątkowych – oczywiście pod warunkiem, że została ona wcześniej zarejestrowana.

Wspomnianą funkcję, która będzie służyła do obsługi sytuacji wyjątkowych, można skojarzyć z Agentem przez podanie jej obiektu po słowie kluczowym :error-handler. Będzie ona wywołana wtedy, gdy nowa wartość bieżąca nie spełni kryteriów walidacji lub wystąpi innego rodzaju błąd i stan Agenta nie zostanie zaktualizowany. Funkcja obsługująca błędy powinna przyjmować dwa argumenty: jako pierwszy przekazywany jej będzie obiekt obsługiwanego Agenta, a jako drugi obiekt wyjątku, który został zgłoszony przy nieudanej próbie zmiany stanu. Podanie funkcji obsługującej sytuacje wyjątkowe sprawia, że automatycznie ustawiana jest opcja :error-mode, a jej wartością jest :continue.

Jeżeli podamy słowo kluczowe :error-mode, musimy umieścić po nim kolejne słowo kluczowe, aby wybrać jeden z dwóch sposobów reakcji Agenta na usterki:

  • :continue – kontynuacja pracy,
  • :fail – wstrzymanie przyjmowania zleceń zmiany stanu.

Jeżeli podano również funkcję obsługującą usterki (z użyciem :error-handler), domyślnym sposobem reakcji na sytuację wyjątkową jest kontynuowanie pracy bez oznaczania Agenta jako wadliwego (odpowiednik :continue). Jeżeli jednak nie zarejestrowano takiej funkcji, reakcją na błąd podczas aktualizowania stanu Agenta będzie oznaczenie Agenta jako wadliwego i zablokowanie dalszej realizacji zleceń zmiany wartości bieżącej (odpowiednik :fail) do momentu wywołania funkcji restart-agent. Złożone w tym czasie zlecenia będą oczekiwały na ten moment w odpowiedniej kolejce.

Przykład użycia funkcji agent
1
2
3
4
5
(agent  0                     )  ; => #<[email protected]: 0>
(agent  0         :meta {:a 1})  ; => #<[email protected]: 0>
(agent                 [1 2 3])  ; => #<[email protected]: [1 2 3]>
(agent 10 :validator #(> %1 1))  ; => #<[email protected]: 10>
(agent  0 :validator #(> %1 1))  ; => java.lang.IllegalStateException

Wyrażenie z linii nr 1 tworzy obiekt referencyjny typu Agent, który odnosi się do wartości 0.

Wyrażenie drugie (z linii nr 2) działa tak samo, lecz dodatkowo ustawia metadaną.

Kolejne wyrażenie przykładu (z linii nr 3) ustawia wartość początkową na wektor stworzony z użyciem wektorowego S-wyrażenia.

Dwa ostatnie wyrażenia dotyczą sytuacji, w których przekazywana jest funkcja walidująca. Ostatnie wywołanie powoduje zgłoszenie wyjątku, ponieważ podana wartość początkowa nie jest poprawna.

Identyfikowanie Agentów

Agenty są obiektami, które z którymi, podobnie jak z innymi pamięciowymi wartościami, można powiązać symboliczne identyfikatory i w ten sposób określać ich tożsamości. Możemy więc stwarzać odnoszące się do obiektów typu Agent globalne zmienne, jak również korzystać z innych rodzajów powiązań (np. leksykalnych).

Przykłady tworzenia powiązań z obiektami typu Agent
1
2
(def nasz-agent (agent 0))         ; zmienna globalna
(let [agencik (agent 0)] agencik)  ; powiązanie leksykalne

Pobieranie wartości

Odczytywanie wartości Agentów, deref

Żeby odczytać wartość Agenta możemy użyć funkcji deref. Przyjmuje ona jeden argument, którym powinien być obiekt typu Agent, a zwraca wartość, do której odniesienie jest przechowywane w tym obiekcie.

Użycie:

  • (deref agent).
Przykład użycia funkcji deref
1
2
3
(def nasz-agent (agent 0))
(deref nasz-agent)
; => 0

Dereferencja Agentów, makro @

Makro czytnika @ (znak małpki) umieszczone przed wyrażeniem sprawia, że jeśli zwracany przez nie obiekt jest typem referencyjnym, to wywołana zostanie na nim funkcja odpowiedzialna za odczyt wskazywanej wartości. W przypadku Agentów będzie to omówiona wyżej funkcja deref.

Użycie:

  • @agent.
Przykład dereferencji Agenta z użyciem makra czytnika @
1
2
3
(def nasz-agent (agent 0))
@nasz-agent
; => 0

Zmiany wartości

Aktualizowanie wartości, send

Zmiana stanu obiektu typu Agent, czyli aktualizacja odniesienia do wartości, możliwa jest z zastosowaniem funkcji send.

Pierwszy przyjmowany argument powinien być symbolem identyfikującym obiekt typu Agent lub inne wyrażenie, które przeliczone zwróci ten obiekt. Drugim argumentem powinna być funkcja, która jako pierwszy argument przyjmie wartość bieżącą (do której odnosi się Agent) i na jej podstawie obliczy nową wartość. Zostanie ona użyta do zastąpienia poprzedniego referencyjnego odniesienia w Agencie. Opcjonalnie możemy podać dodatkowe argumenty, które zostaną przekazane jako kolejne argumenty wywoływanej funkcji.

Funkcja zwracająca nową wartość bieżącą Agenta powinna być nieblokująca, ponieważ jej wartość będzie obliczana w wątku należącym do puli o ustalonej liczbie wątków.

Gdyby któryś z kroków pośrednich (między odczytem a aktualizacją referencji) nie powiódł się, będziemy mieli do czynienia z usterką Agenta, czyli sytuacją wyjątkową. Może ona wystąpić z powodu błędu lub niespełnienia wymogów skojarzonej z Agentem funkcji walidującej. W takim przypadku Agent przestaje realizować kolejne zlecenia zmiany stanu, chociaż są one kolejkowane (jeżeli są poprawne). Można zmienić ten domyślny sposób reakcji na usterki, rejestrując funkcję obsługi błędów, lub ustawiając odpowiednią opcję trybu reakcji na usterki.

Wartością zwracaną przez funkcję send jest obiekt typu Agent, do którego wysłano zlecenie zmiany stanu.

Użycie:

  • (send agent funkcja & argument…).
Przykład użycia funkcji send
1
2
3
4
5
(def nasz-agent (agent 0))

@nasz-agent            ; => 0
(send nasz-agent inc)  ; => #<[email protected]: 0>
@nasz-agent            ; => 1

Długotrwałe aktualizowanie wartości, send-off

Zmiana stanu obiektu typu Agent, czyli aktualizacja odniesienia do wartości, gdy funkcja wyliczająca nową wartość zajmuje dłuższy czas, możliwa jest z zastosowaniem funkcji send-off.

Pierwszy przyjmowany argument powinien być symbolem identyfikującym obiekt typu Agent lub inne wyrażenie, które przeliczone zwróci ten obiekt. Drugim argumentem powinna być funkcja, która jako pierwszy argument przyjmie wartość bieżącą (do której odnosi się Agent) i na jej podstawie obliczy nową wartość. Zostanie ona użyta do zastąpienia poprzedniego referencyjnego odniesienia w Agencie. Opcjonalnie możemy podać dodatkowe argumenty, które zostaną przekazane jako kolejne argumenty wywoływanej funkcji.

Funkcja zwracająca nową wartość bieżącą Agenta może być blokująca, np. związana z przetwarzaniem danych wejścia/wyjścia czy obsługą komunikacji międzyprocesowej, ponieważ jej wartość będzie obliczana w wątku należącym do puli o rozszerzalnej liczbie wątków.

Gdyby któryś z kroków pośrednich (między odczytem a aktualizacją referencji) nie powiódł się, będziemy mieli do czynienia z usterką Agenta, czyli sytuacją wyjątkową. Może ona wystąpić z powodu błędu lub niespełnienia wymogów skojarzonej z Agentem funkcji walidującej. W takim przypadku Agent przestaje realizować kolejne zlecenia zmiany stanu, chociaż są one kolejkowane (jeżeli są poprawne). Można zmienić ten domyślny sposób reakcji na usterki, rejestrując funkcję obsługi błędów, lub ustawiając odpowiednią opcję trybu reakcji na usterki.

Wartością zwracaną przez funkcję send-off jest obiekt typu Agent, do którego wysłano zlecenie zmiany stanu.

Użycie:

  • (send-off agent funkcja & argument…).
Przykład użycia funkcji send-off
1
2
3
4
5
(def nasz-agent (agent 0))

@nasz-agent                ; => 0
(send-off nasz-agent inc)  ; => #<[email protected]: 0>
@nasz-agent                ; => 1

Aktualizowanie wartości z pulą własną, send-via

Dzięki funkcji send-via możemy zmienić stan obiektu typu Agent, posługując się własnym obiektem wykonawcy, czyli samodzielnie utworzoną pulą wątków, do której trafi zlecenie wyliczenia nowej wartości bieżącej Agenta.

Pierwszy przyjmowany argument powinien być obiektem implementującym interfejs Executor, a kolejny symbolem określającym obiekt typu Agent lub inne wyrażenie, które przeliczone zwróci ten obiekt. Jako trzeci argument należy podać funkcję, która jako pierwszy argument przyjmie wartość bieżącą (do której odnosi się Agent) i na jej podstawie obliczy nową wartość. Zostanie ona użyta do zastąpienia poprzedniego referencyjnego odniesienia w Agencie. Opcjonalnie możemy podać dodatkowe argumenty, które zostaną przekazane jako kolejne argumenty wywoływanej funkcji.

Gdyby któryś z kroków pośrednich (między odczytem a aktualizacją referencji) nie powiódł się, będziemy mieli do czynienia z usterką Agenta, czyli sytuacją wyjątkową. Może ona wystąpić z powodu błędu lub niespełnienia wymogów skojarzonej z Agentem funkcji walidującej. W takim przypadku Agent przestaje realizować kolejne zlecenia zmiany stanu, chociaż są one kolejkowane (jeżeli są poprawne). Można zmienić ten domyślny sposób reakcji na usterki, rejestrując funkcję obsługi błędów, lub ustawiając odpowiednią opcję trybu reakcji na usterki.

Wartością zwracaną przez funkcję send-via jest obiekt typu Agent, do którego wysłano zlecenie zmiany stanu.

Użycie:

  • (send-via wykonawca agent funkcja & argument…).
Przykład użycia funkcji send-via
1
2
3
4
5
6
7
8
(import java.util.concurrent.Executors)

(def nasza-pula (Executors/newFixedThreadPool 32))
(def nasz-agent (agent 0))

@nasz-agent                           ; => 0
(send-via nasza-pula nasz-agent inc)  ; => #<[email protected]: 0>
@nasz-agent                           ; => 1

Zobacz także:

* “Class Executors”, dokumentacja Javy SE

Walidatory

Agenty można opcjonalnie wyposażyć w mechanizmy testujące poprawność ustawianych wartości bieżących. Służą do tego funkcje set-validator!get-validator.

Walidator (ang. validator) to w tym przypadku czysta (wolna od efektów ubocznych), jednoargumentowa funkcja, która przyjmuje wartość poddawaną sprawdzaniu. Jeśli jest ona niedopuszczalna, funkcja powinna zwrócić wartość false lub wygenerować wyjątek.

Zarządzanie walidatorami, set-validator!

Funkcja set-validator! umożliwia ustawianie lub usuwanie walidatora dla podanego jako pierwszy argument obiektu typu Agent.

Jako drugi argument należy podać funkcję, która powinna przyjmować jeden argument i nie generować efektów ubocznych. Będzie ona wywoływana za każdym razem, gdy dojdzie do zmiany stanu referencji i jako argument przekazywana będzie jej wartość, z którą zażądano powiązania. Funkcja ta powinna zwracać wartość false lub zgłaszać wyjątek, gdy przyjmowana wartość jest nieakceptowalna.

Pierwsze sprawdzenie stanu jest dokonywane już w momencie ustawiania walidatora i w wątku, w którym doszło do wywołania funkcji. Bieżąca wartość wskazywana referencją musi więc już wtedy być odpowiednia. Kolejne wywołania walidatora odbywają się asynchronicznie, w wątkach odpowiedzialnych za realizację zleceń podmiany wartości bieżącej. Jeżeli sprawdzanie poprawności nie powiedzie się, Agent zostanie oznaczony jako wadliwy i nowe zlecenia nie będą realizowane, lecz kolejkowane do momentu obsługi sytuacji wyjątkowej w programie. Wyjątkiem od tej zasady jest obsługa ich natychmiast, przez zarejestrowanie specjalnej funkcji obsługującej reakcje na błędy – albo podczas tworzenia obiektu typu Agent, albo korzystając z funkcji set-error-handler!set-error-mode!.

Jeżeli zamiast obiektu funkcyjnego podamy wartość nil, to walidator zostanie odłączony od podanego obiektu typu Agent.

Funkcja set-validator! zwraca wartość nil, jeżeli udało się ustawić walidator.

Przykład użycia funkcji set-validator!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(def nasz-agent (agent 1))
(set-validator! nasz-agent pos?)   ; akceptujemy tylko liczby dodatnie

(send nasz-agent (constantly 2))   ; #<[email protected]: 1>
@nasz-agent                        ; => 2

(send nasz-agent (constantly -1))  ; #<[email protected] FAILED: 2>
@nasz-agent                        ; => 2

(send nasz-agent (constantly 3))
; >>      java.lang.RuntimeException: Agent is failed, needs restart
; >> java.lang.IllegalStateException: Invalid reference state
@nasz-agent                        ; => 2

(restart-agent nasz-agent 4)       ; => 4
@nasz-agent                        ; => 4

Pobieranie walidatora, get-validator

Mając obiekt typu Agent, możemy pobrać funkcyjny obiekt jego walidatora, posługując się funkcją get-validator. Przyjmuje ona jeden argument, który powinien być obiektem typu Agent, a zwraca obiekt funkcji lub nil, jeśli walidatora nie ustawiono.

Funkcja zwraca wartość nil.

Użycie:

  • (get-validator agent).
Przykład użycia funkcji get-validator
1
2
3
4
5
6
7
8
(def nasz-agent (agent 0))
(def nasz-walidator #(< % 5))

(set-validator! nasz-agent nasz-walidator)
; => nil

(get-validator nasz-agent)
; => #<user$nasz_walidator [email protected]>

Obsługa usterek

Agent, do którego trafiło zlecenie podmiany wartości bieżącej, może napotkać błąd podczas realizowania tego zadania. W takim wypadku zostanie on oznaczony jako wadliwy (ang. failed). Błąd może wynikać z usterki podczas działania podprogramu funkcji obliczającej nową wartość, albo być spowodowany tym, że nowa wartość nie spełnia założonych kryteriów poprawności. Funkcję decydującą o tym, jakie wartości są poprawne, można skojarzyć z Agentem podczas tworzenia jego obiektu lub później, wywołując set-validator!.

Gdy Agent zostanie oznaczony jako wadliwy, nadal przyjmuje zlecenia zmian stanów, jednak nie są one realizowane, ale oczekują w kolejce do momentu, aż wyjątkowa sytuacja zostanie obsłużona w programie, a Agent oznaczony jako poprawny. Wywołania funkcji odpowiedzialnych za składanie zleceń podmiany wartości bieżącej (np. z użyciemsend, send-off czy send-via), gdy Agent oznaczony jest jako wadliwy, będą powodowały zgłoszenie wyjątku: tego samego, który został wygenerowany podczas próby obliczenia i zmiany wartości bieżącej.

Zachowanie Agenta w kwestii obsługi sytuacji wyjątkowych można zmieniać, rejestrując własną funkcję – będzie ona wywoływana za każdym razem, gdy dojdzie do usterki. Podpięcia takiej funkcji można dokonać, ustawiając odpowiednią opcję reakcji na błąd, podczas tworzenia obiektu typu Agent lub później, korzystając z funkcji set-error-handler!.

Istnieje również możliwość ustawienia specjalnej opcji Agenta, która sprawia, że w przypadku wystąpienia usterki lub próby powiązania z niepoprawną wartością nie będzie on oznaczany jako wadliwy. Można ją ustawić podczas tworzenia obiektu typu Agent lub korzystając z funkcji set-error-mode!.

Sprawdzanie błędnego stanu, agent-error

Dzięki funkcji agent-error możemy sprawdzić, czy wyrażony pierwszym argumentem Agent jest oznaczony jako wadliwy, tzn. czy wymaga obsłużenia sytuacji wyjątkowej.

Funkcja zwraca obiekt wyjątku, który doprowadził do oznaczenia Agenta jako wadliwego lub wartość nil, jeżeli Agent nie został w taki sposób oznaczony – nie wystąpiła usterka, walidator nie zgłosił niepoprawności, albo ustawiono ignorowanie takich sytuacji z użyciem set-error-mode!.

Użycie:

  • (agent-error agent).
Przykład użycia funkcji agent-error
1
2
3
4
5
6
7
(def nasz-agent (agent 10 :validator pos?))

(send nasz-agent (constantly -1))
; => #<[email protected] FAILED: 10>

(agent-error nasz-agent)
; >> #<IllegalStateException java.lang.IllegalStateException: Invalid reference state>

Ponowne uruchamianie, restart-agent

Funkcja restart-agent pozwala dokonać ponownego uruchomienia Agenta, który został oznaczony jako wadliwy i powiązać go z nową wartością bieżącą (wyrażoną formułą stałą, a nie obiektem funkcyjnym). Przyjmuje ona dwa obowiązkowe argumenty: obiekt typu Agent i nową wartość.

Nowa wartość musi pozytywnie przejść test walidacji. W przeciwnym razie zgłoszony zostanie wyjątek, a oznaczenie Agenta jako wadliwy nie zostanie usunięte.

Nawet jeżeli z obiektem Agenta skojarzono podpięcia w postaci funkcji obserwujących, to nie będą one wywoływane. Poza tym próba wywołania funkcji na obiekcie, który nie został oznaczony jako wadliwy spowoduje zgłoszenie wyjątku java.lang.RuntimeException, komunikującego, że nie ma potrzeby ponownego uruchamiania.

Jako kolejne, niewymagane argumenty nazwane możemy podać opcje wyrażone parami złożonymi ze słów kluczowych i przypisanych im wartości.

Wartością zwracaną przez funkcję restart-agent jest wartość nowo ustawionego powiązania.

Użycie:

  • (restart-agent agent wartość & opcje).

Gdzie możliwe opcje to:

  • :clear-actions przełącznik.

Jeżeli podamy opcję :clear-actions, a przełącznik ustawimy na wartość reprezentującą logiczną prawdę (nie false i nie nil), to każde oczekujące w kolejce zlecenie wykonania zadania na obiekcie Agenta, który uległ awarii, zostanie usunięte. Domyślnym działaniem jest zrealizowanie wszystkich zleceń zmiany stanu.

Przykład użycia funkcji restart-agent
1
2
3
4
5
6
7
8
(def nasz-agent (agent 10 :validator pos?))

@nasz-agent                        ; => 10
(send nasz-agent (constantly -1))  ; => #<[email protected] FAILED: 10>
@nasz-agent                        ; => 10

(restart-agent nasz-agent 5)       ; => 5
@nasz-agent                        ; => 5

Ustawianie funkcji obsługującej błędy, set-error-handler!

Funkcja set-error-handler! rejestruje funkcję, która będzie służyła do obsługi sytuacji wyjątkowych. Będzie ona wywołana wtedy, gdy nowa wartość bieżąca nie spełni kryteriów walidacji lub wystąpi innego rodzaju błąd i stan Agenta nie zostanie zaktualizowany.

Przyjmuje ona jeden argument, którym powinna być wspomniana funkcja, a zwraca wartość nil.

Użycie:

  • (set-error-handler! agent funkcja).

Funkcja obsługująca błędy powinna przyjmować dwa argumenty: jako pierwszy przekazywany jej będzie obiekt Agenta, a jako drugi obiekt wyjątku, który został zgłoszony przy nieudanej próbie zmiany stanu.

Przykład użycia funkcji set-error-handler!
1
2
3
4
5
6
7
8
9
10
11
12
13
(def nasz-agent (agent 10 :validator pos?))
(set-error-handler! nasz-agent #(println (apply str (interpose ",\n" %&))))

(send nasz-agent (constantly -1))
; => #<[email protected]: 10>
; >> [email protected],
; >> java.lang.IllegalStateException: Invalid reference state

@nasz-agent
; => 10

(restart-agent nasz-agent 4)
; => 4

Ustawianie trybu reakcji na błędy, set-error-mode!

Funkcja set-error-mode! ustawia opcję Agenta decydującą o sposobie reagowania na błędy podczas próby zmiany wartości bieżącej (niedopuszczenie przez walidator lub błąd w realizowaniu obliczeń przez funkcję obliczającą nową wartość).

Użycie:

  • (set-error-mode! agent tryb).

Gdzie tryb to jedno ze słów kluczowych:

  • :continue – kontynuacja pracy,
  • :fail – wstrzymanie przyjmowania zleceń zmiany stanu.

Podanie słowa kluczowego :fail sprawi, że w przypadku wystąpienia sytuacji wyjątkowej podczas aktualizowania wartości bieżącej Agenta (np. z użyciem send, send-off czy send-via) zostanie on oznaczony jako wadliwy, a realizowanie zleceń zmiany stanów będzie wstrzymane do momentu wywołania restart-agent. Jest to domyślny sposób reakcji na usterki.

Podanie słowa kluczowego :continue zmienia sposób reagowania na sytuacje wyjątkowe. W przypadku ich wystąpienia Agent nie będzie oznaczony jako wadliwy, a zlecenia zmiany stanów będą realizowane.

Przykład użycia funkcji set-error-mode!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
(def nasz-agent (agent 10 :validator pos?))
(set-error-mode! nasz-agent :continue)

(send nasz-agent (constantly -1))
; => #<[email protected]: 10>
; >> [email protected],
; >> java.lang.IllegalStateException: Invalid reference state

@nasz-agent
; => 10

(send nasz-agent (constantly 4))
@nasz-agent
; => 4

Pobieranie funkcji obsługującej błędy, error-handler

Funkcja error-handler umożliwia odczytanie obiektu funkcyjnego skojarzonego z agentem w celu obsługi sytuacji wyjątkowych. Przyjmuje ona jeden obowiązkowy argument, którym powinien być obiekt typu Agent, a zwraca funkcję lub wartość nil, jeżeli funkcji obsługującej błędy dla danego Agenta nie ustawiono.

Użycie:

  • (error-handler agent).
Przykład użycia funkcji error-handler
1
2
3
4
5
6
7
8
(def nasz-agent (agent 0))

(error-handler nasz-agent)
; => nil

(set-error-handler! nasz-agent identity)
(error-handler nasz-agent)
; => #<core$identity [email protected]>

Pobieranie trybu reakcji na błędy, error-mode

Funkcja error-mode umożliwia odczytanie wartości opcji Agenta określającej tryb reakcji na sytuacje wyjątkowe. Przyjmuje ona jeden obowiązkowy argument, którym powinien być obiekt typu Agent, a zwraca słowo kluczowe, które określa sposób reakcji na błędy.

Użycie:

  • (error-mode agent).
Przykład użycia funkcji error-mode
1
2
3
4
5
6
7
8
(def nasz-agent (agent 0))

(error-mode nasz-agent)
; => :fail

(set-error-mode! nasz-agent :continue)
(error-mode nasz-agent)
; => :continue

Podpięcia

Obiekty typu Agent można wyposażać w podpięcia (ang. hooks) w postaci tzw. funkcji obserwujących (ang. watch functions). Wywoływane są one, gdy zmienia się stan referencji. Służą do wykonywania dodatkowych, niezależnych operacji w reakcji na zmiany stanów.

Dodawanie obserwatorów, add-watch

Do obiektu typu Agent możemy dodawać funkcję obserwującą z użyciem add-watch. Funkcja ta przyjmuje trzy argumenty. Pierwszym powinien być Agent, do którego chcemy podłączyć funkcję, drugim unikatowy klucz identyfikujący podpięcie, a ostatnim wspomniana funkcja. Unikatowość powinna być zachowana w obrębie pojedynczego, nadzorowanego obiektu.

Funkcja add-watch zwraca obiekt typu Agent, do którego podpięto funkcję obserwującą.

Funkcja obserwująca powinna przyjmować 4 argumenty: klucz, obiekt typu Agent, a także poprzednią wartość oraz nową wartość wskazywaną przez referencję. Zostanie ona wywołana w sposób asynchroniczny za każdym razem, gdy dojdzie do zmiany stanu Agenta, przy czym może się to zdarzyć również w czasie jej wywoływania. W związku z tym należy polegać na przekazanych jako dwa ostatnie argumenty wartościach, a nie dokonywać dereferencji obiektu typu Agent.

Możemy zarejestrować wiele funkcji obserwujących, a w przypadku korzystania z tego samego funkcyjnego obiektu jesteśmy w stanie rozróżniać wywołania, korzystając z przekazywanego jako argument klucza.

Wywołanie add-watch z takim samym kluczem, jak już podany, zastępuje poprzednią funkcję obserwującą.

Użycie:

  • (add-watch agent klucz funkcja).
Przykład użycia funkcji add-watch
1
2
3
4
5
6
(def nasz-agent (agent 0))
(add-watch nasz-agent :debug #(println (apply str (interpose ", " %&))))

(send nasz-agent inc)  ; >> :debug, [email protected], 0, 1
(send nasz-agent inc)  ; >> :debug, [email protected], 1, 2
(send nasz-agent inc)  ; >> :debug, [email protected], 2, 3

Usuwanie obserwatorów, remove-watch

Funkcja remove-watch pozwala usunąć funkcję obserwującą przypisaną do obiektu typu Agent. Przyjmuje ona dwa obowiązkowe argumenty: obiekt referencyjny i unikatowy klucz identyfikujący podpięcie.

Wartością zwracaną jest obiekt typu Agent, od którego odłączono funkcję obserwującą.

Użycie:

  • (remove-watch atom klucz).
Przykład użycia funkcji remove-watch
1
2
3
4
5
6
7
8
9
10
11
12
(def nasz-agent (agent 0))
(defn reporter [& args] (println (first args)))
(add-watch nasz-agent :debug reporter)
(add-watch nasz-agent :inna  reporter)

(send nasz-agent inc)
; >> :debug
; >> :inna

(remove-watch nasz-agent :inna)
(send nasz-agent inc)
; >> :debug

Przykłady zastosowań

Globalne tożsamości

Korzystając ze zmiennych globalnych, jesteśmy w stanie tworzyć stałe tożsamości dla wyrażających zmienne stany (przez odniesienia do różnych wartości) obiektów typu Agent. W ten sposób możemy w całym programie odwoływać się do konkretnego Agenta z użyciem nadanej mu symbolicznej nazwy o globalnym zasięgu.

Spróbujmy zaimplementować przykładowy licznik w postaci funkcji, która za każdym wywołaniem będzie zwiększała jego wartość o jeden, a jeżeli podamy argument, dokonane zostanie przypisanie konkretnej wartości początkowej:

Przykład globalnego licznika i wieloczłonowej funkcji obsługującej
1
2
3
4
5
6
7
8
9
10
11
12
13
(defonce agent-licznika (agent -1))

(defn licznik
  ([x] (send agent-licznika (constantly (int x))))
  ([]  (send agent-licznika inc)))

(licznik)        ; => #<[email protected]: 0>
(licznik)        ; => #<[email protected]: 0>
(licznik)        ; => #<[email protected]: 2>
@agent-licznika  ; => 2

(licznik 100)    ; => 100
@agent-licznika  ; => 100

Implementacja raportowania zdarzeń

Spróbujmy stworzyć implementację procesu zapisującego komunikaty dotyczące zdarzeń w pliku. Zakładamy, że funkcja realizująca zadanie będzie mogła być wywoływana równolegle przez wiele wątków.

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
(use 'clojure.java.io)

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; obsługa wywołań Javy (zapisywanie do plików)

(defn- zapisz-log
  "Zapisuje podany komunikat do pliku, używając obiektu typu
  BufferedWriter podanego jako pierwszy argument."
  [pisak komunikat]
  (doto pisak (.write komunikat)))

(defn- zamknij-log
  "Zamyka otwarty plik skojarzony z obiektem typu
  BufferedWriter."
  [pisak]
  (doto pisak (.close)))

(defn- aktualny-czas
  "Zwraca łańcuch znakowy reprezentujący aktualny czas."
  []
  (let [data (bean (java.util.Date.))
        h    (:hours    data)
        m    (:minutes  data)
        s    (:seconds  data)
        dy   (:year     data)
        dm   (:month    data)
        dd   (:day      data)
        r    (str (+ dy 1900) "-" dm "-" dd " " h ":" m ":" s)]
    r))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; obsługa raportowania

(defn otwórz-raport
  "Otwiera plik o podanej nazwie. Zwraca obiekt typu
  Agent, którego wartością bieżącą jest skojarzony
  z otwartym plikiem obiekt typu BufferedWriter."
  [nazwa-pliku]
  (agent (writer nazwa-pliku :append true)))

(defn do-raportu
  "Korzystając z podanego obiektu typu Agent, dodaje do
  skojarzonego z nim pliku linię określoną łańcuchem
  znakowym przekazanym jako drugi argument."
  [agent-pliku komunikat]
  (let [czas (aktualny-czas)
        wpis (str czas " " komunikat)]
    (send agent-pliku zapisz-log wpis)))

(defn zamknij-raport
  "Zamyka plik wskazywany podanym obiektem typu Agent"
  [agent-pliku]
  (send agent-pliku zamknij-log))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; funkcje i wartości pomocnicze

(def log      (otwórz-raport "/tmp/test-agent"))
(def raportuj (partial do-raportu log))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; logika aplikacji

(raportuj "Start\n")
(raportuj "Stop\n")
(zamknij-raport log)
(shutdown-agents)

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

Komentarze