Programowanie współbieżne i rozproszone

Forma zajęć

Wykład (30 godzin) + laboratorium/ćwiczenia (30 godzin)

Opis

Celem wykładu jest zaprezentowanie najważniejszych technik stosowanych do synchronizacji procesów i realizacji komunikacji między nimi, problemów, jakie stają przed programistą opracowującym programy współbieżne. Omówiony zostanie scentralizowany i rozproszony model programu współbieżnego. Problematyka zostanie zilustrowana przykładami klasycznych problemów współbieżności oraz mechanizmów synchronizacji procesów i wątków w systemie operacyjnym Linux. Przedstawione też zostaną metody weryfikacji programów współbieżnych oraz notacje do opisu współbieżności oraz podstawowe algorytmy rozproszone.

Sylabus

Autor

Wymagania wstępne

Zawartość

Wprowadzenie do programowania współbieżnego

Wstęp

Próba definicji
Praca współbieżna polega na tym, że składające się na nią zjawiska, czynności i działania odbywają się równocześnie. Istotny jest przy tym punkt widzenia obserwatora.

Zadaniem przedmiotu "Programowanie współbieżne" jest przedstawienie problematyki związanej z tworzeniem programów, w których wiele czynności może odbywać się równocześnie. Zajmiemy się przede wszystkim problematyką właściwej synchronizacji czynności składających się na program współbieżny i zagadnieniami związanymi z ich poprawnością.

Motywacja

Pisanie programów jest trudne. Jest to stwierdzenie prawdziwe już w przypadku programów sekwencyjnych, gdy na raz wykonuje się jedna instrukcja i nie trzeba rozważać wszystkich możliwych interakcji z innymi działającymi w tym samym czasie programami. Wprowadzenie współbieżności jeszcze bardziej utrudnia programowanie. Dlaczego zatem warto i należy rozważać programy współbieżne?

Odpowiedzieć na to pytanie można na wiele sposobów.

  1. Niektóre problemy są z natury współbieżne, ich rozwiązania dają się łatwo i elegancko wyrazić w postaci niezależnie wykonujących się procedur. Przypuśćmy, że chcemy napisać grę akcji, w której wiele postaci porusza się na ekranie wykonując pewne działania. Taką scenę możemy oprogramować sekwencyjnie rozważając akcje wszystkich postaci w jednym fragmencie kodu. Jednak znacznie elegantszym rozwiązaniem jest oprogramowanie z osobna każdej postaci (można to zrobić obiektowo, nadając każdej z nich pewne indywidualne cechy), a następnie uruchomienie współbieżne tylu procesów, ile postaci chcemy uzyskać na ekranie. Oczywiście wymaga to także prawidłowej synchronizacji działania wszystkich procesów, co obejmuje działania takie jak na przykład wykrywanie kolizji i zapobieganie im.
  2. Współczesne systemy operacyjne zezwalają na uruchamianie wielu procesów jednocześnie. Gdy pewien proces czeka na zakończenie operacji wejścia/wyjścia, to inny proces może być w tym czasie wykonywany. Ta możliwość współbieżnego wykonania sięga nawet dalej.
  3. System operacyjny z podziałem czasu potrafi wykonywać wiele procesów współbieżnie dzieląc czas procesora między wiele procesów. Odbywa się to w ten sposób, że proces otrzymuje pewien kwant czasu (rzędu milisekund), w czasie którego korzysta z procesora. Gdy kwant skończy się, system operacyjny "przełącza" procesy: odkłada ten, który się wykonywał na później i zajmuje się innym. Ponieważ przełączanie odbywa się często, więc użytkownicy komputera mają wrażenie, że komputer zajmuje się wyłącznie ich procesem. Co więcej, rozwiązując pewien problem, użytkownik może uruchomić "jednocześnie" dwa procesy, które ze sobą w pewien sposób współpracują. Jest to powszechnie stosowane na przykład w środowisku systemu operacyjnego Unix, który umożliwia wykonywanie programów w potoku, np.: ls -l | grep moje | more. Wszystkie trzy programy wchodzące w skład takiego potoku wykonują się współbieżnie, z tym że wyniki generowane przez pierwszy z nich są podawane na wejście drugiego, wyniki drugiego --- na wejście trzeciego itd.
  4. Malejące ceny sprzętu sprzyjają powstawaniu architektur wieloprocesorowych, w których można uzyskać prawdziwą współbieżność, tzn. wykonywać jednocześnie wiele procesów na różnych procesorach. Wtedy system operacyjny nie musi już "oszukiwać" dzieląc czas jedynego procesora między wiele procesów, lecz może wykonywać każdy z nich na innym procesorze (przynajmniej dopóki wystarczy procesorów). Nowoczesne układy zawierają udogodnienia takie jak: wielowątkowość (hyperthreading), czy wręcz kilka rdzeni w jednej kości procesora.
  5. Rozpowszechnienie się sieci komputerowych stwarza jeszcze inne możliwości współbieżnego wykonywania. Poszczególne obliczenia składające się na duże zadanie mogą być wykonywane na różnych komputerach połączonych siecią. Rozproszone systemy operacyjne potrafią zarządzać czasem wielu procesorów, znajdujących się w wielu różnych węzłach sieci.

A oto przykłady "prac", które można realizować współbieżnie.

  1. Przypuśćmy, że chcemy obliczyć wartość wyrażenia \(a \cdot b - (c + d ) \cdot (e - f)\). Oczywiście można to robić od lewej do prawej, ale można też takie wyrażenie obliczać

współbieżnie licząc na przykład jednocześnie wyrażenia w nawiasach.

  1. Kolejny przykład to sortowanie tablicy N liczb. Sortowanie to można wykonywać za pomocą algorytmu sortowania przez scalanie:
procedure sortuj (i, j) { sortuje tablicą A[i..j] }
 begin
   ...
   m := (i+j) div 2;
   sortuj (i, m);
   sortuj (m+1, j);
   scal (i, m, j);
 end

Zauważmy, że oba wywołania rekurencyjne procedury są tu niezależne od siebie --- dotyczą rozłącznych fragmentów tablicy. Nic nie stoi na przeszkodzie, aby były wykonywane w tym samym czasie: na różnych procesorach lub nawet na jednym z podziałem czasu. Taki równoległy algorytm możemy zapisać w postaci:

procedure sortuj (i, j) { sortuje tablicą A[i..j] }
begin
  ...
  m := (i+j) div 2;
  cobegin
    sortuj (i, m);
    sortuj (m+1, j);
  coend
  scal (i, m, j);
end 

Słowa kluczowe cobegin i coend oznaczają, że czynności znajdujące się między nimi można wykonywać współbieżnie. Algorytm ten dobrze ilustruje również problem właściwej synchronizacji. Co stałoby się, gdybyśmy także wywołanie scal umieścili między cobegin i coend? Wówczas scalanie odbywałoby się współbieżnie z sortowaniem obu fragmentów tablicy. Wtedy scalanie mogłoby działać na jeszcze nie uporządkowanych fragmentach tablicy, co spowodowałoby niepoprawne działanie całego algorytmu. Na marginesie zauważmy, że w trakcie tego przedmiotu nie będziemy układać algorytmów równoległych. Zajmiemy się jedynie zagadnieniami związanymi z synchronizacją: problemami, mechanizmami i notacjami.

  1. Współbieżność może także uprościć zapis programu. Na przykład przetwarzanie potokowe (wspomniane już uprzednio) łatwo zapisuje się współbieżnie:
cobegin
  while true do
  begin
    wczytaj znak z klawiatury;
    wypisz go do bufora
  end 
  while true do
  begin
    wczytaj znak z bufora;
    wypisz go na ekran
  end 
end

Równoległość a współbieżność

Wspomnieliśmy o tym, że akcje współbieżne mogą być wykonywane faktycznie jednocześnie lub też (pod kontrolą systemu operacyjnego z podziałem czasu) fragment po fragmencie naprzemiennie z akcjami innego procesu. Opisując wykonanie pewnego procesu będziemy używać następującej terminologii:

  1. Wykonanie sekwencyjne. Poszczególne akcje procesu są wykonywane jedna po drugiej. Dokładniej: kolejna akcja rozpoczyna się po całkowitym zakończeniu poprzedniej.
  2. Wykonanie równoległe. Kilka akcji jest wykonywanych w tym samym czasie. Jest to "prawdziwa" współbieżność, możliwa do uzyskania na komputerze z wieloma procesorami.
  3. Wykonanie w przeplocie. Choć jednocześnie odbywa się wykonanie tylko jednej akcji, to jednak jest wiele czynności rozpoczętych i wykonywanych na zmianę krótkimi fragmentami.
  4. Wykonanie współbieżne. Kolejna akcja rozpoczyna się przed zakończeniem poprzedniej. Zauważmy, że nie mówimy nic na temat tego, czy akcje te są wykonywane w tym samym czasie czy też w przeplocie. Tak naprawdę wykonanie współbieżne jest abstrakcją równoległości i zawiera w sobie zarówno wykonania równoległe jak i wykonania w przeplocie.


Proces a program

Zanim przejdziemy do dalszego ciągu wykładu, przypomnijmy różnicę między procesem a programem. Program to obiekt statyczny --- tekst wykonywanego przez proces kodu. Proces to obiekt dynamiczny, "żywy", to wykonanie programu w pewnym środowisku lub wstrzymane wykonanie (w oczekiwaniu na jakiś zasób). Proces ma przydzielone zasoby (pamięć, urządzenia we/wy) i rywalizuje o dostęp do procesora. Procesy wykonują się pod kontrolą systemu operacyjnego. Przypomnijmy kilka najważniejszych faktów dotyczących zarządzania procesami.

Założenia o środowisku działania programów

Każdy proces znajduje się w jednym z kilku możliwych stanów. Dokładna analiza tych stanów została przedstawiona na systemach operacyjnych. Do zrozumienia dalszego ciągu zajęć potrzebna nam będzie jednak wiedza o trzech możliwych stanach procesu:

  1. Proces może być gotowy do wykonania. Oznacza to, że ma wszystkie potrzebne zasoby z wyjątkiem procesora. Gdy tylko otrzyma procesor, może rozpocząć wykonanie.
  2. Proces może być wykonywany, jeśli jest gotowy i ma procesor. Jest tyle procesów wykonywanych, ile procesorów znajduje się w systemie.
  3. Proces wykonywany może zrzec się procesora i stać się wstrzymany, jeśli próbuje wykonać jakąś operacji, której nie może zrealizować natychmiast, bo na przykład nie ma niezbędnych zasobów. Klasycznym przykładem jest wykonanie operacji wejścia/wyjścia i konieczność poczekania aż niezbędne informacje zostaną sprowadzone z pamięci zewnętrznej. System operacyjny po sprowadzeniu niezbędnych zasobów zmieni stan procesu na gotowy.

Zauważmy, że w rywalizacji o przydział procesora biorą udział procesy gotowe. Zakładamy, że jeśli jakiś proces jest gotowy do wykonania, to w końcu stanie się wykonywany. Jest to bardzo ważne założenie, które będziemy nazywać sprawiedliwością systemu operacyjnego. Oznacza ono, że każdy proces, który ma niezbędne zasoby w skończonym czasie zacznie się wykonywać. Nie zdarzy się tak, że pewien proces P jest gotowy do wykonania i za każdym razem gdy dochodzi do przełączenia konktekstu, procesor otrzymuje inny proces. Bez założenia o sprawiedliwości systemu operacyjnego nie będziemy w stanie wykazać poprawności żadnego programu współbieżnego.

Zauważmy również, że procesy wstrzymane nie rywalizują o procesor --- nie są uwzględniane przy przełączaniu konktekstu. Jeśli zatem będziemy chcieli wstrzymać wykonanie pewnego procesu, będziemy przenosić go do kolejki procesów wstrzymanych za pomocą mechanizmów udostępnianych przez system operacyjny. O takim wstrzymaniu procesu mówimy, że jest nieaktywne. W odróżnieniu, oczekiwanie aktywne polega na tym. że wstrzymywany proces cały czas korzysta z procesora i na przykład w pętli sprawdza co jakiś czas warunek na kontynuowanie działania. Należy już w tym momencie podkreślić, że oczekiwanie aktywne jest niekorzystne i będziemy traktować je jako błąd synchronizacyjny. Nie ma bowiem sensu marnowanie czasu procesora, jeśli można odwołać się do mechanizmów systemu operacyjnego i wstrzymać proces w sposób nieaktywny.

Jest jeden wyjątek od powyższej zasady. Jeśli system operacyjny nie daje programiście żadnych mechanizmów synchronizacyjnych (a obecnie każdy system takie mechanizmy udostępnia), to aktywne oczekiwanie jest jedynym sposobem wstrzymania procesu. Wykorzystamy ten mechanizm na pierwszych ćwiczeniach. Czasem oczekiwanie aktywne stosuje sam system operacyjny. Dzieje się tak w sytuacjach, kiedy wiadomo, że proces zostaje wstrzymywany jedynie na chwilę i przenoszenie go do kolejki procesów wstrzymanych, a następnie znów do kolejki procesów gotowych trwałoby dłużej niż kilka obrotów aktywnej pętli.

Zakres tematyki

Program współbieżny składa się z kilku (co najmniej dwóch) współbieżnych procesów sekwencyjnych, które muszą się ze sobą komunikować lub synchronizować swoje działania. Nie interesują nas procesy rozłączne, czyli takie które działają niezależnie od siebie, nie wymagając żadnych działań synchronizacyjnych ani nie wymieniając między sobą danych.

Zajmiemy się omówieniem mechanizmów udostępnianych przez systemy operacyjne do synchronizacji procesów. Zwrócimy uwagę na pułapki, w jakie może wpaść programista. Nauczymy się także szukać przeplotów prowadzących do nieprawidłowych wykonań i ograniczać ich liczbę tak, aby z jednej strony nie ograniczać zbytnio współbieżności, a z drugiej strony nie dopuszczać do powstanie nadmiernej ich liczby.

Organizacja zajęć i zasady zaliczania

Zajęcia z programowania współbieżnego składają się z 15 wykładów i 15 ćwiczeń. Każdy wykład zawiera materiał przeznaczony do samodzielnego przeczytania i przyswojenia. Ćwiczenia zawieraja materiał do wykonania przez studenta. Są dwa rodzaje ćwiczeń:

  1. Ćwiczenia laboratoryjne polegają na przedstawieniu pewnego mechanizmu synchronizacyjnego udostępnianiego przez system operacyjny Unix. Jest to najczęściej pewna implementacja abstrakcyjnego mechanizmu przedstawionego na wykładzie (na przykład komunikacja asynchroniczna --- łącza nienazwane i kolejko komunikatów, Ada --- RPC, semafory klasyczne --- semafory uniksowe, monitory --- muteksty). Takie ćwiczenia przedstawiają technikalia związanie z korzystaniem z danego mechanizmu, a następnie ilustrują te zagadnienia konkretnymi gotowymi programami. Każde ćwiczenie kończy się zadaniem programistycznym przeznaczonym do wykonania przez studenta.
  2. Ćwiczenia niezwiązane z programowaniem. Ich zadaniem jest przećwiczenie technik stosowanych przy korzystaniu z mechanizmu synchronizacyjnego przedstawionego na wykładzie. Materiały ćwiczeniowe wymagają od studenta przedstawienia rozwiązania konkretnego zadania synchronizacyjnego "na sucho", czyli bez tworzenia programu działającego na konkretnej platformie. Każde takie ćwiczenie kończy się postawieniem zadania, którego rozwiązanie należy przedstawić prowadzącemu.

Poprawność programów współbieżnych

Tak jak w przypadku programów sekwencyjnych mówimy o poprawności programów współbieżnych. Pojęcia częściowej poprawności i całkowitej poprawności stosowane w analizie poprawności programów sekwencyjnych tracą jednak tutaj swój sens. Przypomnijmy, że poprawność częściowa to własność mówiąca, że jeśli program kończy się, to daje poprawne wyniki. Programy współbieżne jednak składają się z wielu procesów, z których każdy najczęściej działa w pętli nieskończonej. Nie jesteśmy tutaj zainteresowani uzyskaniem konkretnych wartości, ale raczej zapewnieniem poprawnej synchronizacji przez cały czas działania programu. Tym bardziej nie ma sensu wymaganie, aby program współbieżny kończył się, jak wymaga się tego, przy poprawności całkowitej.

W związku z tym musimy przedefiniować pojęcie poprawności.

Przeplot

Podstawowym pojęciem wykorzystywanym przy analizie programów współbieżnych jest przeplot. Zanim wyjaśnimy to pojęcie zajmijmy się przez chwilę procesem sekwencyjnym. Ciąg wykonawczy takiego procesu to ciąg złożony z kolejno wykonywanych przez niego akcji. Może on być skończony (tak zwykle bywało przy analizie poprawności programów sekwencyjnych) lub nieskończony (jeśli proces działa w pętli nieskończonej).

Jeśli rozpatrujemy program współbieżny składający się z pewnej liczby procesów sekwencyjnych, to musimy uwzględnić każdą możliwą do uzystkania kolejność potencjalnie współbieżnych akcji. Nie wolno nam zakładać, że jeden z procesów wykonuje się szybciej, a drugi wolniej. Musimy wziąć pod uwagę każde możliwe do uzyskania wykonanie. Takie wykonanie to właśnie przeplot. Zatem

Przeplot
jest to ciąg złożony z przeplecionych ciągów wykonawczych procesów składowych

Przeplot jest bardzo ważnym pojęciem. Jeśli chcemy pokazać, że program współbieżny nie działa poprawnie, to wystarczy pokazać jeden przeplot, przy którym dochodzi do sytuacji błędnej. Z kolei poprawny program współbieżny działa dobrze dla każdego przeplotu. Podkreślmy jeszcze raz: dla każdego przeplotu, nawet tego najbardziej nieprawdopodobnego.

Możemy także mówić o ciągu wykonawczym programu współbieżnego. W odróżnieniu od ciągu wykonawczego procesu sekwencyjnego jest to tym razem zbiór:

Ciąg wykonawczy programu współbieżnego
to zbiór wszystkich możliwych przeplotów.

Ze względu na to, że program współbieżny składa się z procesów, które działają w pętli nieskończonej, zbiór ten jest nieskończonym zbiorem złożonym z nieskończonych ciągów.

Takie podejście do poprawności niesie za sobą szereg problemów. Zauważmy, że testowanie programów współbieżnych staje się właściwie nieprzydatne. Jeśli program jest błędny, to może dawać podczas testowania poprawne wyniki. Błąd może ujawniać się raz na tysiąc uruchomień i wówczas bardzo trudno go zlokalizować.

Również zadanie formalnego pokazania poprawności programu współbieżnego jest trudne. Trzeba bowiem pokazać, że pewna własność zachodzi dla każdego możliwego wykonania programu. W dodatku, jak się za chwilę przekonamy, interestujące nas własności mogą mieć same dość skomplikowaną strukturę logiczną.

Własność bezpieczeństwa (zapewniania)

Program współbieżny musi mieć własnośćbezpieczeństwa zwaną również własnością zapewniania. Najkrócej tę własność można wyrazić następująco:

nigdy nie dojdzie do niepożądanej sytuacji

Popatrzmy na przykład. Przypuśćmy, że rozpatrujemy ruchliwe skrzyżowanie w środku dużego miasta. Procesy to samochody usiłujące przez to skrzyżowanie przejechać (na wprost). Własność bezpieczeństwa wyraża fakt, że nie dojdzie do kolizji. Możemy ją wyrazić tak:

nigdy na skrzyżowaniu nie będą jednocześnie samochody jadące w kierunku wschód-zachód i północ-południe.

Własność bezpieczeństwa zazwyczaj jest podawana w specyfikacji zadania, które należy rozwiązać. Stanowi ona odpowiednik częściowej poprawności.

W omawianym przykładzie skrzyżowania własność bezpieczeństwa można bardzo łatwo zagwarantować: nie wpuszczać na skrzyżowanie żadnego samochodu! Mamy wtedy bardzo bezpieczne rozwiązanie, ale ... trudno uznać je za poprawne. Z tego powodu poprawny program współbieżny powinien mieć także inną własność.

Własność żywotności

Własność żywotności można wyrazić tak:

jeśli proces chce coś zrobić, to w końcu mu się to uda

W przykładzie skrzyżowania oznacza to, że każdy samochód, który chce przez skrzyżowanie przejechać, w końcu przez to skrzyżowanie przejedzie.

Własność żywotności nie jest zazwyczaj podawana w specyfikacji problemu. Należy jednak pamiętać, że jest ona równie ważna, co własność zapewniania. W przypadku braku żywotności dochodzi do jednej z dwóch rodzajów błędów:

  1. Zakleszczenie. Jest to globalny brak żywotności. Nic się nie dzieje, system nie pracuje, oczekując na zajście zdarzenia, które nigdy nie zajdzie. Oto klasyczny przykład zakleszczenia. Przypuśćmy, że w systemie wykonują się dwa procesy, które w pewnym momencie muszą odczytać dane z dysku i wydrukować je na drukarce. Proces P1 prosi system o przydzielenie mu dysku i otrzymuje zgodę. Zanim jednak poprosi o drukarkę, proces P2 otrzymuje drukarkę. Teraz proces P1 czeka aż dostanie drukarkę, a proces P2 czeka na dysk. Żaden nie chce zrezygnować z zasobu, który już otrzymał, więc oczekiwanie będzie trwało w nieskończoność.
  2. Zagłodzenie. Jest to lokalny brak żywotności. Przypuśćmy, że w przedstawionym problemie skrzyżowania, procesy jadące mogą wjechać na skrzyżowanie zawsze wtedy, gdy są na nim inne procesy jadące w tym samym kierunku lub gdy skrzyżowanie jest puste. Załóżmy, że jako pierwszy zjawia się proces jadący na kierunku wschód-zachód. Oczywiście wjeżdża na skrzyżowanie. Jeśli teraz pojawi się proces jadący z północy, to oczywiści musi poczekać. Jednak jeśli zanim ze skrzyżowania zjedzie pierwszy proces pojawi się inny proces jadący ze wschodu, to będzie mógł wjechać na skrzyżowanie. Jeśli sytuacja się będzie ciągle powtarzać, to skrzyżowanie nigdy nie będzie puste --- będą na nim ciągle samochody jadące na kierunku wschód-zachód, co doprowadzi do zagłodzenia procesów drugiej grupy. W systemie coś się ciągle dzieje. Część procesów pracuje normalnie. Brak żywotności dotyka tylko niektóre procesy.

Zakleszczenie jest zjawiskiem, które dużo łatwiej wykryć i łatwiej jest go uniknąć. Zagłodzenie trudniej zauważyć i trudniej mu przeciwdziałać.

Program współbieżny jest zatem poprawny, jeśli dla każdego przeplotu ma własność bezpieczeństwa oraz własność żywotności. Aby pokazać, że program jest niepoprawny, wystarczy skonstruować przeplot prowadzący do braku bezpieczeństwa lub braku żywotności.

Inne pożądane własności programów współbieżnych

Zauważmy, że we wszystkich dotychczasowych sformułowaniach nie wystąpiła żadna miara czasu. Używamy terminu w końcu lub w skończonym czasie. I tak będzie do końca tych zajęć. Przygotowując program współbieżny nie przyjmujemy żadnym założeń dotyczących czasu. Nie interesuje nas jak długo będzie trwała dana czynność, a jedynie to, czy się skończonym czasie zakończy, czy wystąpi jakaś reakcja na nią itp.

Projektując program współbieżny w konkretnym języku programowania nie powinniśmy także nic zakładać na temat tego, czy dana instrukcja jest realizowana w sposób niepodzielny, czy nie. W szczególności pisząc

x++

nie wolno założyć, że zmienna x zostanie odczytana, zmieniona i zapisana w jednym cyklu procesora. Więcej na ten temat na następnym wykładzie.

Czasami narzucające się rozwiązanie problemu synchronizacyjnego jest bezpieczne i żywotne, ale jest nie do przyjęcia z innych powodów. Załóżmy, że w przykładzie ze skrzyżowaniem występują dwa procesy (jeden jadący ciągle z północy), drugi jadący ciągle ze wschodu. Przyjmijmy, że każdy z nich w pętli nieskończonej przejeżdża skrzyżowanie, po czym wraca inną drogą itd. Narzucające się rozwiązanie polega na tym, aby wpuszczać te samochody na skrzyżowanie na zmianę. Jeśli jednak jeden z nich chciałby przejeżdżać przez skrzyżowanie raz na dwa dni, a drugi raz na godzinę, to takie rozwiązanie powodowałoby, że drugi oczekiwałby bezczynnie przez prawie cały czas swojego działania. Taką sytuację nazywamy zbyt ścisłym powiązaniem procesów i będziemy ją również traktować jak błąd.

Klasyczne problemy synchronizacyjne

Wzajemne wykluczanie

Pierwszym klasycznym problemem synchronizacyjnym, chyba najczęściej pojawiającym się w praktyce, jest problem wzajemnego wykluczania. Przypuśćmy, że mamy procesy, z których każdy wykonuje następujący program:

process P;
begin
  while true do 
  begin
    własne_sprawy;
    protokół_wstępny;
    sekcja_krytyczna;
    protokół_końcowy;
  end
end;

Procedura własne_sprawy to fragment kodu, który nie wymaga żadnych działań synchronizacyjnych. Proces może wykonywać własne sprawy dowolnie długo (nawet nieskończenie długo), może ulec tam awarii. Sekcja_krytyczna to fragment programu, który może być jednocześnie wykonywany przez co najwyżej jeden proces. Zakładamy, że każdy proces, który wchodzi do sekcji krytycznej, w skończonym czasie z niej wyjdzie. Oznacza to w szczególności, że podczas pobytu procesu w sekcji krytycznej nie wystąpi błąd, proces nie zapętli się tam. Zadanie polega na napisaniu protokołu wstępnego i protokołu końcowego tak, aby:

  1. W sekcji krytycznej przebywał co najwyżej jeden proces jednocześnie (bezpieczeństwo).
  2. Każdy proces, który chce wykonać sekcję krytyczną w skończonym czasie do niej wszedł (żywotność).

Protokoły wstępny i końcowy konstruuje się korzystając z dostępnych mechanizmów synchronizacyjnych. W przypadku braku jakiegokolwiek wsparcia ze strony systemu operacyjnego należy zastosować jeden z algorytmów synchronizacyjnych, np. algorytm Petersona.

Czasem rozpatruje się też wariant problemu wzajemnego wykluczania, w którym jednocześnie może przebywać więcej procesów. Mamy na przykład N procesów działających, z których w sekcji może przebywać jednocześnie 0<K<N.

Producenci i konsumenci

W systemie działa P>0 procesów, które produkują pewne dane oraz K>0 procesów, które odbierają dane od producentów. Między producentami a konsumentami może znajdować się bufor o pojemności B, którego zadaniem jest równoważenie chwilowych różnic w czasie działania procesów. Procesy produkujące dane będziemy nazywać producentami, a procesy odbierające dane --- konsumentami. Zadanie polega na synchronizacji pracy producentów i konsumentów, tak aby:

  1. Konsument oczekiwał na pobranie danych w sytuacji, gdy bufor jest pusty. Gdybyśmy pozwolili konsumentowi odbierać dane z bufora zawsze, to w sytuacji, gdy nikt jeszcze nic w buforze nie zapisał, odebrana wartość byłaby bezsensowna.
  2. Producent umieszczając dane w buforze nie nadpisywał danych już zapisanych, a jeszcze nie odebranych przez żadnego konsumenta. Wymaga to wstrzymania producenta w sytuacji, gdy w buforze nie ma wolnych miejsc.
  3. Jeśli wielu konsumentów oczekuje, aż w buforze pojawią się jakieś dane oraz ciągle są produkowane nowe dane, to każdy oczekujący konsument w końcu coś z bufora pobierze. Nie zdarzy się tak, że pewien konsument czeka w nieskończoność na pobranie danych, jeśli tylko ciągle napływają one do bufora.
  4. Jeśli wielu producentów oczekuje, aż w buforze będzie wolne miejsce, a konsumenci ciągle coś z bufora pobierają, to każdy oczekujący producent będzie mógł coś włożyć do bufora. Nie zdarzy się tak, że pewien producent czeka w nieskończoność, jeśli tylko ciągle z bufora coś jest pobierane.

Rozpatruje się różne warianty tego problemu:

  1. Bufor może być nieskończony.
  2. Bufor cykliczny może mieć ograniczoną pojemność.
  3. Może w ogóle nie być bufora.
  4. Może być wielu producentów lub jeden.
  5. Może być wielu konsumentów lub jeden.
  6. Dane mogą być produkowane i konsumowane po kilka jednostek na raz.
  7. Dane muszą być odczytywane w kolejności ich zapisu lub nie.

Problem producentów i konsumentów jest abstrakcją wielu sytuacji występujących w systemach komputerowych, na przykład zapis danych do bufora klawiatury przez sterownik klawiatury i ich odczyt przez system operacyjny.

Czytelnicy i pisarze

W systemie działa C>0 procesów, które odczytują pewne dane oraz P>0 procesów, które zapisują te dane. Procesy zapisujące będziemy nazywać pisarzami, a procesy odczytujące --- czytelnikami, zaś moment, w którym procesy mają dostęp do danych, będziemy nazywać pobytem w czytelni.

Zauważmy, że jednocześnie wiele procesów może odczytywać dane. Jednak jeśli ktoś chce te dane zmodyfikować, to rozsądnie jest zablokować dostęp do tych danych dla wszystkich innych procesów na czas zapisu. Zapobiegnie to odczytaniu niespójnych informacji (na przykład danych częściowo tylko zmodyfikowanych). Ogólny schemat działania procesów jest więc następujący:

process Czytelnik;
begin
  repeat
    własne_sprawy;
    protokół_wstępny_czytelnika;
    CZYTANIE;
    protokół_końcowy_czytelnika
  until false
end
process Pisarz;
begin
  repeat
    własne_sprawy;
    protokół_wstępny_pisarza;
    PISANIE;
    protokół_końcowy_pisarza
  until false
end

Należy tak napisać protokoły wstępne i końcowe poszczególnych procesów, aby:

  1. Wielu czytelników powinno mieć jednocześnie dostęp do czytelni.
  2. Jeśli w czytelni przebywa pisarz, to nikt inny w tym czasie nie pisze ani nie czyta.
  3. Każdy czytelnik, który chce odczytać dane, w końcu je odczyta.
  4. Każdy pisarz, który chce zmodyfikowć dane, w końcu je zapisze.

Rozpatruje się różne warianty tego problemu:

  1. W czytelni może przebywać dowolnie wielu czytelników.
  2. Czytelnia może mieć ograniczoną pojemność.
  3. Pisarze mogą mieć pierwszeństwo przed czytelnikami (ale wtedy rezygnujemy z żywotności czytelników)
  4. Czytelnicy mogą mieć pierwszeństwo przed pisarzami (ale wtedy rezygnujemy z żywotności pisarzy)

Wzajemną interakcję czytelników i pisarzy demonstruje poniższy applet[1]:

Pięciu filozofów

Ten problem nie ma praktycznych analogii, jak w przypadku poprzednich klasycznych problemów, ale bardzo dobrze ilustruje problemy występujące przy tworzeniu programów współbieżnych.

Pięciu filozofów siedzi przy okrągłym stole. Przed każdym stoi talerz. Między talerzami leżą widelce. Pośrodku stołu znajduje się półmisek z rybą. Każdy filozof myśli. Gdy zgłodnieje sięga po widelce znajdujące się po jego prawej i lewej stronie, po czym rozpoczyna posiłek. Gdy się już naje, odkłada widelce i ponownie oddaje się myśleniu. Schemat działania filozofa jest więc następujący:

process Filozof (i: 0..4);
begin
  repeat
    myśli;
    protokół_wstępny;
    je;
    protokół_końcowy;
  until false
end; 

Należy tak napisać protokoły wstępne i końcowe, aby:

  1. Jednocześnie tym samym widelcem jadł co najwyżej jeden filozof.
  2. Każdy filozof jadł zawsze dwoma (i zawsze tymi, które leżą przy jego talerzu) widelcami.
  3. Żaden filozof nie umarł z głodu.

Chcemy ponadto, aby każdy filozof działał w ten sam sposób.

Spróbujmy zastanowić się, jak mogłyby wyglądać przykładowe protokoły wstępne i końcowe filozofa. Pierwszy pomysł polega na tym, aby każdy filozof, gdy zgłodnieje, sprawdzał czy widelec po jego lewej stronie jest wolny. Jeśli tak, to filozof powinien podnieść ten widelec i oczekiwać na dostępność drugiego.
Jak widać, po pewnym czasie dochodzi do zakleszczenia (można przyspieszyć jego wystąpienie skracając suwakiem czas jedzenia i rozmyślania filozofów). Dzieje się tak wówczas, gdy filozofowie jednocześnie zgłodnieją i każdy z nich w tej samej chwili podniesie lewy widelec. Każdy oczekuje teraz na prawy widelec, ale nigdy się na niego nie doczeka. A co stałoby się, jeśli każdy filozof podnosiłby na raz oba widelce, jeśli oba są wolne? Wtedy do zakleszczenia nie dochodzi.
Tym razem doszło do zagłodzenia filozofa. Mamy przykład braku żywotności, różny od zakleszczenia. Jeśli dwaj sąsiedzi pewnego filozofa "zmówią się" przeciw niemu, to mogą nie dopuścić do sytuacji, w której obydwa widelce będą dostępne. Poprawne rozwiązanie tego problemu przedstawimy w następnym wykładzie.

Komunikacja asynchroniczna w modelu rozproszonym

Modele współbieżności

Rozważając programy współbieżne, możemy analizować dwa modele środowiska, w którym wykonują się procesy.

  1. Procesy mają dostęp do tej samej przestrzeni adresowej. Oznacza to, że mogą korzystać ze wspólnych zmiennych umieszczonych we fragmencie pamięci dostępnej dla każdego z nich.
  2. Nie ma zmiennych współdzielonych. Każdy proces ma własną przestrzeń adresową i nie ma możliwości odwołania się do zmiennych innego procesu.

Taki podział należy przy tym rozważać na płaszczyźnie funkcjonalnej. Możemy na przykład mówić o modelu rozproszonym, choć procesy wykonują się na tym samym komputerze we wspólnej pamięci, która logicznie jest jednak podzielona na niezależne przestrzenie adresowe. Możemy także myśleć o modelu scentralizowanym, choć wspólna pamięć jest tak naprawdę realizowana za pomocą serwera segmentów pamięci dzielonej. Procesy nie muszą znać mechanizmów udostępniania tej pamięci. Z punktu widzenia procesu ważne jest jedynie to, czy istnieje możliwość odczytania/modyfikacji zmiennej współdzielonej, a nie sposób implementacji takiej zmiennej.

Model scentralizowany

Podstawową cechą modelu scentralizowanego jest możliwość stosowania zmiennych, które są dostępne do zapisu i odczytu przez wiele procesów. Takie zmienne będziemy nazywać zmiennymi dzielonymi lub zmiennymi współdzielonymi. Istnienie takich zmiennych w kontekście wykonujących się jednocześnie procesów może powodować pewne problemy. Trzeba na przykład odpowiedzieć na pytanie: co się dzieje, jeśli dwa procesy w tym samym momencie próbują zmodyfikować tę samą komórkę pamięci? Z reguły taki konflikt jest rozstrzygany przez układ sprzętowy, noszący nazwę arbitra pamięci, który w jakiś sposób szereguje takie żądania. Mamy więc gwarancję, że na skutek wykonania takiego "jednoczesnego" przypisania, zmienna dzielona będzie miała jedną z wartości, które próbowano ustawić, a nie jakąś przypadkową nieznaną nam wartość. Własność tę wykorzystaliśmy na poprzednich ćwiczeniach projektując algorytm Petersona.

Obecność zmiennych globalnych powoduje jednak często niezgodne z intuicją zachowania programów współbieżnych.

Zmienne globalne i problemy z nimi

Rozpatrzmy następujący program, złożony z dwóch identycznych procesów korzystających ze zmiennej współdzielonej x:

var
  x : integer;
process P;
var
  y, i: integer;
begin
  for i := 1 to 5 do
  begin
    y := x;
    y := y + 1;
    x := y
  end;
end;
process P;
var
  y, i: integer;
begin
  for i := 1 to 5 do
  begin
    y := x;
    y := y + 1;
    x := y
  end;
end;

Jak widać, każdy z procesów pięciokrotnie:

  1. Odczytuje zmienną globalną x kopiując jej wartość na swoją zmienną lokalną y.
  2. Zwiększa wartość zmiennej lokalnej o jeden.
  3. Zapisuje zmodyfikowaną wartość z powrotem na zmienną globalną.

Skutkiem jest zwiększenie zmiennej współdzielonej o pięć. I faktycznie, jeśli uruchomimy jeden z tych procesów, to tak się stanie. Podobnie jeśli uruchomimy drugi proces P po zakończeniu działania przez pierwszy proces --- wtedy zmienna zwiększy się o 10.

Co jednak zdarzy się, jeśli procesy uruchomimy współbieżnie? Powiedzmy, że początkową wartością zmiennej x jest 0. Jaka będzie wartość końcowa? Otóż okazuje się, że możemy otrzymać, w zależności od przeplotu akcji poszczególnych procesów, dowolną wartość między 5 a 10. Jak pokazuje ten przykład, korzystając ze zmiennych współdzielonych trzeba zachować dużą ostrożność. Omawiany program byłby programem prawidłowo zwiększającym wartość zmiennej współdzielonej, jeśli całe wnętrze pętli było sekcją krytyczną i obliczało się z wzajemnym wykluczaniem (tak się dzieje po zaznaczeniu "Synchronizuj"). Mielibyśmy wówczas pewność, że od chwili odczytania wartości zmiennej x do chwili zapisania jej zmodyfikowanej wartości, żaden proces nie odczyta starej (tj.: jeszcze niezmodyfikowanej) wartości. Ponieważ taka niepodzielność wnętrza pętli wydaje się tu bardzo krytyczna, przeanalizujmy inny przykład.

Niepodzielność instrukcji wysokopoziomowych

var
  x : integer;
process P;
var
  i: integer;
begin
  for i := 1 to 5 do
    x := x + 1
end;
process P;
var
  i: integer;
begin
  for i := 1 to 5 do
    x := x + 1
end;

Tym razem procesy zmieniają wartość zmiennej współdzielonej za pomocą pojedynczej instrukcji wysokopoziomowej. Czy gwarantuje to, że zwiększenie jest realizowane niepodzielnie? Otóż nie, jak wiemy program w języku wysokiego poziomu jest tłumaczony na język wewnętrzy. Programista nie wie, a nawet nie powinien wiedzieć, czy instrukcja x := x + 1 jest realizowana jako jeden rozkaz maszynowy, czy też rozdzielona między rozkazy odczytu pamięci, załadowania wartości do rejestru, zwiększenia rejestru i zapisu do pamięci. Zatem z naszego punktu widzenia powyższy program ma te same właściwości co poprzedni. W dalszym ciągu nie wiemy, jaka będzie końcowa wartość zmiennej x.

Zapamiętajmy. Pisząc program współbieżny nie wolno nic założyć o niepodzielności instrukcji języka, w którym ten program tworzymy.

Mechanizmy synchronizacyjne

Model scentralizowany jest trudniejszy dla programisty. Trudniej jest zapewnić poprawność programu współbieżnego korzystającego ze zmiennych współdzielonych. Z tego powodu odłożymy go na później. Już teraz jednak zapowiemy, że wśród omawianych mechanizmów synchronizacyjnych znajdą się semafory, monitory i muteksy.

Model rozproszony

Model rozproszony stosuje się najczęściej wtedy, gdy procesy składające się na program współbieżny wykonują się na różnych komputerach. Nie ma wtedy fizycznie miejsca, w którym można by składować zmienne współdzielone. Z tego powodu procesy w tym modelu komunikują się wyłącznie poprzez wymianę komunikatów. Jak wspomnieliśmy poprzednio, będziemy także mówić o modelu rozproszonym w sytuacji, gdy procesy działają na tym samym komputerze, ale ich przestrzenie adresowe tworzą logicznie odrębne jednostki i domyślnie procesy nie mogą współdzielić zmiennych globalnych. Takie środowisko wykonania procesów udostępnia m.in. system operacyjny Unix.

W modelu rozproszonym możemy mówić o komunikacji synchronicznej i asynchronicznej. Różne są też modele przekazywania/adresowania komunikatów oraz identyfikacji procesów.

Komunikacja synchroniczna

Z komunikacją synchroniczną mamy do czynienia wtedy, gdy chcące się ze sobą skomunikować procesy są wstrzymywane do chwili, gdy komunikacja będzie się mogła odbyć. Omówmy ten schemat na przykładzie procesów, z których jeden (zwany nadawcą) chce wysłać komunikat, a drugi (odbiorca) chce komunikat odebrać.

  1. Jeśli odbiorca chce otrzymać komunikat od nadawcy, to wstrzymuje swoje działanie, aż nadawca będzie chciał go wysłać. Najczęściej oznacza to po prostu oczekiwanie do chwili, aż sterowanie w procesie nadawcy dojdzie do instrukcji wysyłania.
  2. Podobnie dzieje się, jeśli nadawca chce wysłać komunikat do odbiorcy. Czeka wówczas, aż odbiorca będzie gotowy do odebrania tego komunikatu i dopiero go nadaje.
  3. Zauważmy, że ponieważ procesy oczekują na siebie, to przesłanie komunikatu może się odbyć bezpośrednio między zainteresowanymi, tzn. nie potrzebny jest żaden bufor, który przechowa wysłany komunikat do momentu jego odbioru przez odbiorcę.

Przykładem komunikacji synchronicznej jest na przykład rozmowa telefoniczna. Nadawca przekazuje swój komunikat dopiero wówczas, gdy odbiorca chce go wysłuchać. Podobnie sytuacja wygląda na tradycyjnym wykładzie z programowania współbieżnego, gdy wykładowca przekazuje komunikaty mając nadzieję, że docierają one bezpośrednio do odbiorców.

Możliwe są również inne schematy komunikacji synchronicznej, w której bierze udział wiele procesów jednocześnie, nie ograniczając się przy tym jedynie do przesyłania komunikatów.

Komunikacja asynchroniczna

Komunikacja asynchroniczna nie wymaga współistnienia komunikujących się procesów w tym samym czasie. Polega na tym, że nadawca wysyła komunikat nie czekając na nic. Komunikaty są buforowane w jakimś miejscu (odpowiada za to system operacyjny lub mechanizmy obsługi sieci) i stamtąd pobierane przez odbiorcę.

Mamy więc następujący schemat:

  1. Nadawca wysyła komunikat nie czekając na nic. Komunikat wędruje do bufora.
  2. Odbiorca odbiera komunikat z bufora. Jeśli bufor jest pusty, to odbiorca jest wstrzymywany (wersja blokująca) lub przekazuje w wyniku błąd (wersja nieblokująca).

Przykładem komunikacji asynchronicznej są wykłady zdalne, które właśnie czytasz. Nadawca - wykładowca umieścił komunikaty w buforze - Internecie, nie czekając na gotowość odbiorcy - studenta do ich odbioru.

Modele przekazywania komunikatów

W modelu rozproszonym istnieje też wiele sposobów przekazywania komunikatów.

  1. Komunikat może być przeznaczony dla konkretnego procesu. Nadawca zna nazwę odbiorcy, a odbiorca zna nazwę nadawcy. Mówimy wtedy o identyfikacji symetrycznej
  2. Komunikat może być przeznaczony dla konkretnego procesu. Jedna ze stron zna nazwę procesu, druga nie wie kogo obsługuje. Mówimy wtedy o identyfikacji asymetrycznej. Jest to model charakterystyczny dla architektury klient-serwer.
  3. Komunikat może być wysyłany po kanale komunikacyjnym. Wysyłający zna nazwę kanału, ale nie wie, do kogo trafi komunikat, bo nie musi wiedzieć z kim jest ten kanał połączony.
  4. Komunikat może być wstawiany do konkretnego bufora, z którego może go odebrać dowolny proces.

Komunikacja asynchroniczna

W ciągu dalszych dwóch wykładów zajmiemy się modelem asynchronicznym. Rozpatrzymy prosty schemat z udziałem bufora i nieco bardziej złożony mechanizm umożliwiający stosowanie tzw. wyboru selektywnego. Zilustrujemy ten mechanizm konkretnymi narzędziami dostępnymi w środowisku Unix.

Cechy komunikacji asynchronicznej

Przypomnijmy zatem najważniejsze cechy komunikacji asynchronicznej:

  1. Wysyłanie komunikatu nie wstrzymuje nadawcy (z pewnymi wyjątkami).
  2. Wysłane, ale jeszcze nieodebrane komunikaty znajdują się w buforze.
  3. Bufor może być nieograniczony (abstrakcyjnie) lub ograniczony. Proces próbujący umieścić komunikat w buforze pełnym może:
    • być wstrzymywany do chwili aż w buforze będzie miejsce (wersja blokująca)
    • wracać z funkcji wysyłającej natychmiast z sygnalizacją błędu
  4. Próba odebrania komunikatu z bufora pustego kończy się:
    • wstrzymaniem procesu (wersja blokująca)
    • błędem (wersja nieblokująca)

Notacja stosowana w dalszym ciągu

W dalszym ciągu wykładu będziemy abstrahować od konkretnych zawiłości technicznych, związanych z realizacją komunikacji asynchronicznej za pomocą konkretnego mechanizmu (na przykład łączy nienazwanych w środowisku Unix). Skupimy się jedynie na istotnych elementach synchronizacyjnych. W tym celu przyjmujemy następujące konwencje:

process P (i: integer);
begin
  ...
end
 cobegin
   P(1); P(1); P(2); Q;
 coend

Przykłady

Wzajemne wykluczanie dwóch procesów

Problem ten rozwiążemy wprowadzając jeden bufor, do którego początkowo włożymy jeden komunikat. Nie jest przy tym istotne, co jest tym komunikatem, przyjmijmy dla ustalenia uwagi, że jest to dowolna liczba całkowita. Komunikat ten pełni funkcję biletu. Proces, który chce wejść do sekcji krytycznej musi zdobyć bilet, zatrzymuje go przy sobie przez cały czas pobytu w sekcji krytycznej i oddaje po wyjściu z niej. Prowadzi to do następującego programu:

Zmienne globalne:

var
  buf: buffer;
  bilet: integer;

Treść procesu:

process P;
var
  bilet: integer;
begin
  repeat
    własne_sprawy;
    GetMessage (buf, bilet);  { bierzemy bilet }
    sekcja_krytyczna;
    SendMessage (buf, bilet); { oddajemy bilet }
  until false
end;

Program główny:

begin
  SendMessage (buf, bilet) { umieszczamy bilet w buforze }
  cobegin            { i uruchamiamy dwa egzemplarze procesu P } 
    P; P
  coend
end 

Własność bezpieczeństwa tego rozwiązania wynika z tego, że jest tylko jeden bilet, a operacje na buforze są niepodzielne. Jeśli pewien proces przebywa w sekcji krytycznej, to w buforze nie ma już biletu, więc drugi proces będzie musiał poczekać na instrukcji GetMessage(buf, bilet).

Żywotność gwarantuje sprawiedliwość operacji GetMessage oraz założenie, że żaden proces, który wszedł do sekcji krytycznej w skończonym czasie z niej wyjdzie. Przypomnijmy, że zakładamy, jak zwykle, sprawiedliwość systemu operacyjnego.

Pięciu filozofów. Wersja 1.

Spróbujmy teraz rozwiązać problem pięciu filozofów. Najpierw musimy się zastanowić, jakie procesy będą potrzebne w rozwiązaniu. Oczywiście musi działać pięć procesów reprezentujących filozofów. Jednak co z widelcami? Tutaj możliwych jest wiele rozwiązań. Zacznijmy od przykładu, w którym każdy widelec jest po prostu komunikatem. Początkowo znajduje się on w odpowiednim buforze, co oznacza, że widelec jest wolny. Buforów musi być jednak tyle, ile widelców, bo każdy filozof podnosi ściśle określone widelce, a nie dowolne. Filozof, który podniesie widelec po prostu wyjmuje komunikat z odpowiedniego bufora i zatrzymuje go, dopóki nie skończy jeść.


Definicje globalne są zatem następujące:

const
  N = 5;
var
  widelec: array [0..N-1] of buffer;
  m: integer;                                    { wartość nieistotna }                 

Treść filozofa jest bardzo krótka:

 process Filozof (i: 0..N-1);
 var
   m: integer;
 begin
   repeat
     myśli;
     GetMessage (widelec[i], m);                 { podniesienie widelca po lewej stronie }
     GetMessage (widelec[(i+1) mod N], m);       { podniesienie widelca po prawej stronie }
     je;
     GetMessage (widelec[i], m);                 { odłożenie widelca po lewej stronie }
     GetMessage (widelec[(i+1) mod N], m);       { odłożenie widelca po prawej stronie }
   until false;
 end

Program główny musi przygotować bufory

begin
  for i := 0 to N-1 do
    SendMessage (widelec[i], m);

i uruchomić odpowiednią liczbę filozofów:

  cobegin
    for i := 0 to N-1 do
      Filozof (i)
  coend
end.

Bardzo łatwo zauważyć, że powyższy program jest implementacją rozwiązania, w którym każdy filozof podnosi najpierw lewy widelec, a następnie prawy. Nie powinno nas zatem dziwić to, że może tu dojść do zakleszczenia. Przypomnijmy, jeśli wszyscy skończą myśleć w tym samym czasie i każdy podniesie lewy widelec, to nigdy nie doczeka się już na prawy.

Pięciu filozofów. Wersja 2.

Spróbujmy teraz postąpić inaczej. Zaimplementujemy widelce jako procesy, a filozofowie będą podnosić widelce w niedeterministycznej kolejności. Implementowanie obiektów statycznych (takich jak widelce, bufory w problemie producentów i konsumentów) w postaci procesów jest dość często stosowane w modelu rozproszonym.

Skoro i filozofowie, i widelce są teraz procesami, musimy opracować pewien protokół do komunikacji między nimi. Kluczowe momenty w działaniu całego systemu to "zgłodnienie" któregoś filozofa oraz zakończenie jedzenia przez filozofa. Filozof musi zawiadomić o zaistnieniu tych faktów, procesy, których mogą one dotyczyć. W tym wypadku będą to sąsiednie widelce. Przyjmijmy więc, że filozof, który kończy jedzenie wysyła do obu widelców, które podniósł komunikat (nazwijmy go OdkładamCię), po odbiorze którego widelec wie, że jest wolny. Z kolei filozof, który zakończy myślenie wysyła do obu widelców komunikat (ChcęCię). Ale to nie wszystko. Czasem filozof będzie musiał poczekać, bo widelec jest zajęty --- je nim inny filozof. Zatem tuż po wysłaniu komunikatu ChcęCię filozof musi zostać wstrzymany w oczekiwaniu na komunikat WeźMnie, wysyłany przez wolny widelec w odpowiedzi na żądanie ChcęCię. Taki schemat żądań (w tym wypadku ChcęCię) i potwierdzeń (WeźMnie) jest dość charakterystyczny dla komunikacji asynchronicznej.

Zastanówmy się jeszcze nad tym, czy i tym razem wystarczy wysłanie komunikatu, czy potrzebna jest jakaś jego treść. Filozof chcący rozpocząć jedzenie wysyła komunikaty do sąsiednich widelców, które --- jeśli są wolne --- muszą na nie odpowiedzieć. Muszą zatem wiedzieć, do kogo wysłać potwierdzenie. Zatem w komunikacie zamawiającym widelec musi pojawić się numer filozofa. W przypadku potwierdzeń nie jest istotna ich treść, a jedynie sam fakt pojawienia się, a w komunikatach zwalniających wystarczy znów jedynie numer filozofa. Widelec, który jest w użyciu odróżni bowiem komunikat zamiawiający od komunikatu zwalniającego --- wystarczy, że będzie pamiętał, kto nim je. Tak więc w omawianym rozwiązaniu nie wystąpią komunikaty ChcęCię i OdkładamCię explicite, lecz będą po prostu komunikaty niosące ze sobą numer nadającego je filozofa.

Do ustalenia schematu komunikacji pozostaje jeszcze kwestia liczby buforów. Zauważmy, że każdy widelec odbiera komunikaty od dwóch filozofów. Nie wie przy tym, w jakiej przyjdą kolejności i powinien być w każdej chwili gotowy zareagować na komunikat od każdego z nich. Ponieważ bezczynny widelec powinien czekać nieaktywnie, więc powinien wykonać operację GetMessage na jakimś buforze. Komunikaty od filozofów powinny być umieszczane właśnie w tym buforze. Prowadzi to naturalnie do rozwiązania, w którym każdy widelec ma własny bufor, gromadzący komunikaty przesyłane do niego. Analogicznie, każdy filozof będzie miał swój bufor, do którego widelce będą wpisywać potwierdzenia.

A oto odpowiadające mu deklaracje zmiennych globalnych:

var
  w: array [0..N-1] of buffer { bufory odbiorcze widelców }
  f: array [0..N-1] of buffer { bufory odbiorcze filozofów }

Proces filozof działa następująco:

process Filozof (i: 0..4);
var
  potw: integer;
begin
  repeat
    myśli;
    SendMessage (w[i], i);   { komunikat z numerem filozofa oznaczający ChcęCię }
    GetMessage (f[i], potw); { czeka na potwierdzenie }
    SendMessage (w[(i+1) mod N], i); { to samo do prawego widelca }     
    GetMessage (f[i], potw); { czeka na potwierdzenie }
    je;
    SendMessage (w[i], i);   { komunikat z numerem filozofa oznaczający odkładamCię }
    SendMessage (w[(i+1) mod N], i);   { to samo do prawego widelca }
  until false
end;

Proces widelca jest nieco bardziej złożony. Będą w nim potrzebne trzy zmienne lokalne:

Widelec powinien oczekiwać na komunikat. Po jego odbiorze powinien wysłać potwierdzenie do nadawcy, a następnie oczekiwać na dwa kolejne komunikaty. Będą to: komunikat zwalniający, a następnie zamawiający albo odwrotnie: najpierw zamawiający (na który na razie nie odpowiadamy na niego), a potem zwalniający (i wtedy trzeba wysłać potwierdzenie zamawiającemu). W obu przypadkach schemat jest ten sam: oczekujemy na dwa komunikaty, po czym wysyłamy potwierdzenie na zamówienie. Zobaczmy jak schemat ten można zaimplementować w postaci programu wykonywanego przez proces Widelec:

 proces Widelec;
 var
   kto_je, k1, k2: integer;
 begin
   kto_je := 0; k2 := 0; { inicjacja na dowolne wartości, ale tak aby kto_je = k1 }
   repeat
     GetMessage (w[i], k1); 
     if k2 = kto_je then  { przy pierwszym obrocie prawda }
       kto_je := k1                   { zamówienie było od k1 }
     else 
       kto_je := k2;                  { zamówienie było od k2 }
     SendMessage (f[kto_je], i);      { potwierdzenie o dowolnej treści }
     GetMessage (w[i], k2);           { czekamy na komunikat }
   until false
 end;

Zauważmy, że jeśli pod koniec pętli k2=kto_je, to właśnie odebrany komunikat jest komunikatem zwalniającym i w kolejnym obrocie pętli będzie wykonane kto_je:=k1, bo kolejny komunikat będzie komunikatem zamawiającym. Jeśli jednak pod koniec pętli było kto_je=k1, to ostatnio odebrany komunikat był zamówieniem. Wtedy komunikat odebrany na początku kolejnego obrotu pętli jest zwolnieniem, po którym wykona się kto_je := k2 i zostanie wysłane zaległe potwierdzenie.

Zauważmy jednak, że zaimplementowanie widelców jako procesów nic nie zmieniło. W dalszym ciągu jest to ten sam algorytm powodujący zakleszczenie! Można teraz jednak próbować coś poprawić. Zmieńmy kolejność instrukcji w filozofie:

process Filozof (i: 0..4);
var
  potw: integer;
begin
  repeat
    myśli;
    SendMessage (w[i], i);   { komunikat z numerem filozofa oznaczający ChcęCię }
    SendMessage (w[(i+1) mod N], i); { to samo do prawego widelca }     
    GetMessage (f[i], potw); { czeka na potwierdzenie }
    GetMessage (f[i], potw); { czeka na potwierdzenie }
    je;
    SendMessage (w[i], i);   { komunikat z numerem filozofa oznaczający odkładamCię }
    SendMessage (w[(i+1) mod N], i);   { to samo do prawego widelca }
  until false
end;

Tym razem filozof najpierw zamawia widelce, a potem oczekuje na dwa potwierdzenia. Przyjdą one w nieznanej nam z góry kolejności --- w kolejności zwalniania się widelców. Mamy więc rozwiązanie, w którym kolejność podnoszenia widelcy jest niedeterministyczna: filozof podnosi najpierw ten widelec, który jest wolny.

Czy jednak taka zmiana coś zmienia? Nie, bo w dalszym ciągu możliwy jest ten sam, prowadzący do zakleszczenia przeplot.

Pięciu filozofów. Wersja 3.

Popróbujmy teraz zupełnie odmiennego rozwiązania. Przypuśćmy, że oprócz filozofów działa jeszcze jeden proces Serwer, który zarządza wszystkimi widelcami. Serwer pamięta stan każdego filozofa (a tym samym stan poszczególnych widelców). Każdy filozof może myśleć lub być głodny, gdy skończył myślenie, ale oczekuje na widelce, lub jeść, jeśli ma oba widelce. Tak jak poprzednio filozof informuje o kluczowych zdarzeniach (chęć rozpoczęcia jedzenia i jego zakończenie) oraz przed rozpoczęciem jedzenia oczekuje na potwierdzenie --- tym razem od serwera. Ponieważ filozof wysyła do serwera na zmianę komunikaty zamawiające oraz komunikaty zwalniające, serwer pamiętając stan każdego filozofa będzie wiedział, czego dotyczy komunikat.

A oto deklaracje buforów:

var
  serw: buffer; { do serwera }
  f: array [0..N-1] of buffer;

I proces filozofa:

process Filozof (i: 0..4);
var
  potw: integer;
begin
  repeat
    myśli;
    SendMessage (serw, i);   { komunikat z numerem filozofa oznaczający ChcęJeść }
    GetMessage (f[i], potw); { czeka na potwierdzenie }
    je;
    SendMessage (serw, i);   { komunikat z numerem filozofa oznaczający odkładamCię }
  until false
end;

Cała synchronizacja jest ukryta w procesie serwera:

process Serwer;
var
  stan: array [0..N-1] of (Myśli, Głodny, Je); 
  k: integer;

Wprowadźmy pomocniczą procedurę Sprawdź(k), której zadaniem jest sprawdzenie, czy k-ty filof jest głodny i czy można mu dać oba widelce. Procedura wysyła w takim przypadku potwierdzenie do filozofa o numerze k.

procedure Sprawdź (k: 0..N-1);
begin
  if (s[k] = Głodny) and 
           (s[(k-1) mod N) <> Je and 
           (s[(k+1) mod N) <> Je  then
  begin
    s[k] := je;
    SendMessage (f[k], k)   { potwierdzenie o dowolnej treści }
  end
end

Właściwa treść filozofa zaczyna się od inicjacji tablicy stanów poszczególnych filozofów:

begin
  for k := 0 to N-1 do
    stan[k] := Myśli;

W głównej pętli serwera sprawdzamy czy przyszło zamówienie czy zwolnienie i podejmujemy stosowne działania:

  repeat
    GetMessage (serw, k);
    if stan[k] = Myśli then   { jest to zamówienie }
    begin
      stan[k] := Głodny;
      Sprawdź (k)
    end else
    begin
      stan[k] := Myśli;
      Sprawdź ((k-1) mod N);        { być może można obudzić sąsiadów }
      Sprawdź ((k+1) mod N)
    end
   until false;
 end;

Tym razem dało się nam uniknąć zakleszczenia. Ale ... No właśnie, mamy rozwiązanie, w którym filozofowie podnoszą oba widelce na raz. Jak widzieliśmy to poprzednio, możemy w ten sposób doprowadzić do zagłodzenia filozofa. Zatem to rozwiązanie jest również niepoprawne.

Pięciu filozofów. Wersja 4.

Powróćmy do pierwszej wersji rozwiązania (tej z zakleszczeniem), ale wprowadźmy dodatkowo "bilety do stołu". Filozof, który zechce jeść, musi najpierw otrzymać jeden z czterech (nie pięciu!) biletów do stołu. Potem postępuje tak jak w rozwiązaniu pierwszym.

const
  N = 5;
var
  widelec: array [0..N-1] of buffer;
  bilety : buffer
  m: integer;                                    { wartość nieistotna }                 
 process Filozof (i: 0..N-1);
 var
   m: integer;
 begin
   repeat
     myśli;
     GetMessage (bilety, m);
     GetMessage (widelec[i], m);                 { podniesienie widelca po lewej stronie }
     GetMessage (widelec[(i+1) mod N], m);       { podniesienie widelca po prawej stronie }
     je;
     SendMessage (widelec[i], m);                 { odłożenie widelca po lewej stronie }
     SendMessage (widelec[(i+1) mod N], m);       { odłożenie widelca po prawej stronie }
     SendMessage (bilety, m);       
   until false;
 end

Program główny:

begin
  for i := 1 to N-1 do
    SendMessage (bilety, m);
  for i := 0 to N-1 do
    SendMessage (widelec[i], m);
  cobegin
    for i := 0 to N-1 do
      Filozof (i)
  coend
end.

Tym razem otrzymujemy rozwiązanie poprawne. Zakleszczenia unikamy, gdyż przy stole może być co najwyżej czterech filozofów jednocześnie. Zatem na pewno któryś z nich otrzyma dwa widelce i będzie mógł jeść.

Zwróćmy uwagę, że taka modyfikacja nie zadziałałaby w przypadku poprzedniego rozwiązania. W przeplocie prowadzącym do zagłodzenia bierze udział bowiem trzech filozofów.


Implementacje tego mechanizmu w systemie Unix

Jak wspomnieliśmy na początku, wprowadzona tu notacja jest abstrakcją rzeczywistych mechanizmów komunikacji asynchronicznej dostępnych na różnych platformach sprzętowych i pod różnymi systemami operacyjnymi. Przyjrzyjmy się teraz niektórym mechanizmom komunikacji asynchronicznej dostępnych dla platformy uniksowej.

Jednym z mechanizmów są łącza nienazwane (ang. pipe). Za pomocą łączy nienazwanych mogą komunikować się ze sobą spokrewnione procesy. Łącza nienazwane to po prostu bufory utrzymywane przez system operacyjny. Są one ograniczone i pojemność każdego z nich wynosi co najmniej 4KB. Odpowiednikiem operacji SendMessage(b, m) jest funkcja systemowa

write (b, m, sizeof(m)),

przy czym b jest deskryptorem przeznaczonym do zapisu do uprzednio utworzonego łącza. Wszystkie właściwości funkcji SendMessage są zachowywane przez funkcję systemową write do łącza. Mamy więc gwarancję niepodzielności, nieblokujące działanie w sytuacji, gdy komunikat mieści się w łączu, dyscyplinę kolejki prostej. Różnice w obsłudze łącza ujawniają się przy próbie zapisu do łącza, gdy komunikat nie mieści się w nim w całości. Wtedy nadawca jest wstrzymywany (z pewnymi wyjątkami, szczegóły na ćwiczeniach). Odpowiednikiem funkcji GetMessage (b, m) jest funkcja systemowa

read (b, m, sizeof(m))

przy czym b jest deskryptorem przeznaczonym do odczytu z uprzednio utworzonego łącza. I tak jak w przypadku GetMessage funkcja systemowa read powoduje wstrzymanie procesu wykonującego ją, jeśli łącze jest puste.

Tak jak w przypadku abstrakcyjnej notacji, komunikacja za pomocą łączy nakłada na programistę obowiązek dbania o zgodność typów komunikatów wysyłanych i odbieranych. W przypadku łączy komunikat to po prostu strumień bajtów, który programista interpretuje w dogodny dla siebie sposób.

Oczywiście użycie łączy wymaga odpowiednich technicznych zabiegów, na przykład utworzenia go za pomocą funkcji systemowej pipe(fd), której wynikiem jest para deskryptorów fd, z których jeden służy do zapisywania do łącza, a drugi do odczytu z niego. Istnieją też pewne ograniczenia, np. nie można zapisywać do łącza, które nie jest przez nikogo otwarte do odczytu. Gdyby pominąć jednak wszystkie technikalia, to programowanie za pomocą łączy przypomina programowanie za pomocą mechanizmu abstrakcyjnego.

Podobnym do łączy nienazwanych mechanizmem dostępnym w środowisku uniksowym jest mechanizm łączy nazwanych. Tak jak w przypadku łączy nienazwanych takie łącza należy najpierw utworzyć (funkcja mkfifo). Następnie korzysta się z nich za pomocą funkcji systemowych read i write. W przypadku stosowania łączy nazwanych istnieją dodatkowe możliwości synchronizacji procesów na etapie otwierania łącza do odczytu i zapisu.

Trzecim popularnym mechanizmem komunikacji asynchronicznej stosowanym w systemie Unix są kolejki komunikatów. I znów są to bufory utrzymywane przez system operacyjny, do których procesy mogą zapisywać komunikaty (funkcja systemowa sndmsg) i z których komunikaty odbierają (funkcja getmsg). I tak jak poprzednio, są to kolejki komunikatów, choć istnieje również możliwość traktowania ich jak kolejek priorytetowych lub wręcz wyboru selektywnego - komunikaty mogą mieć typy, po których dokonuje się selekcji. Funkcja wysłania komunikatu w normalnych warunkach jest nieblokująca, a funkcje odbierająca wstrzymuje proces usiłujący pobrać coś z pustej kolejki. W odróżnieniu od łączy odbiór i wysyłanie komunikatów odbywa się tu jednak nie na poziomie strumieni bajtów, lecz całych komunikatów.

Linda

Pojęcie krotki i przestrzeni krotek

Inne podejście do asynchronicznego modelu komunikacji zaproponował w latach osiemdziesiątych Gelernter. Wprowadził on notację o nazwie Linda, która udostępnia tzw. przestrzeń krotek, czyli coś w rodzaju nieskończonego bufora, za pomocą którego porozumiewają się ze sobą procesy. Jest jedna wielka, globalna przestrzeń krotek. Procesy porozumiewają się ze sobą, umieszczając w przestrzeni krotek komunikaty i pobierając je z niej. Procesy nie muszą wiedzieć nic o sobie nawzajem, co więcej, mogą nie istnieć w tym samym czasie. Nadawca może odebrać komunikat od procesu, który już dawno zakończył swoje działanie.

Każdy komunikat przesyłany przez procesy to tzw. krotka.

Krotka
to ciąg (skończony), którego kolejnymi elementami są dane o określonych typach.

Przykładami krotek są:

Każda krotka ma statycznie określoną długość. Nie może tworzyć krotek o dynamicznie zmienianej bądź wyliczanej długości. Typy poszczególnych elementów krotki mogą być dowolne typy proste lub typy złożone (tablice, rekordy). Nie ma jednak typu, którego wartościami byłyby krotki, więc krotka nie może być elementem krotki.

Sygnatura krotki
to ciąg, którego elementami są typy poszczególnych elementów krotki.

Sygnaturą krotki ('c', true, 3.14, "żeton") jest na przykład (char, boolean, real, string). W niektórych operacjach na przestrzeni krotek wymaga się podania sygnatury krotki.

Proste operacje na przestrzeni krotek

Linda nie jest tak naprawdę językiem programowania, ale raczej czymś w rodzaju biblioteki oferującej pewne operacje na przestrzeni krotek. Są to:

Output(t)
Input (s) oraz Read (s)
Try_Input (s) oraz Try_Read (s)

Omówimy teraz po kolei powyższe operacji.

Wstawianie krotki

Ponieważ Linda jest rozproszonym mechanizmem asynchronicznym z nieograniczonym buforem, więc operacja wstawiania krotki do przestrzeni jest operacją nieblokującą. Ma ona postać

Output (t)

i w jej wyniku krotka t zostaje umieszczona w przestrzeni krotek. Jeśli taka krotka już się w niej znajdowała, to zostanie umieszczony kolejny egzemplarz takiej samej krotki. Przestrzeń krotek należy traktować jak multizbiór, a nie zwykły zbiór krotek . Przykładowa operacja Output ('c', "ala", 4, 3.14) wstawi do przestrzeni krotek krotkę ('c', "ala", 4, 3.14) o sygnaturze (char, string, integer, real).

Wyjmowanie krotki

Do wyjmowania krotki z przestrzeni krotek służą dwie procedury blokujące:

input(s).

Argumentem obu tej procedury jest sygnatura krotki, na przykład:

input (x:integer, c: char)

wyjmuje z przestrzeni krotek krotkę o sygnaturze integer, char. Jeśli w danej chwili krotki o podanej sygnaturze nie ma w przestrzeni krotek, to proces wykonujący procedurę Input jest wstrzymywany tak długo, aż żądana krotka pojawi się w przestrzeni krotek. Na skutek wykonania operacji Input z przestrzeni krotek jest usuwana jedna krotka o podanej sygnaturze, a poszczególne jej elementy są przypisywane na zmienne występujące w opisie sygnatury. W omawianym przykładzie pierwszy element krotki zostanie przypisany na zmienną x a drugi na zmienną c.

Jeśli w przestrzeni krotek jest wiele krotek o podanej sygnaturze, to wybór usuwanej krotki jest niedeterministyczny. Niedeterminizm ten jest jednak ograniczany zasadą sprawiedliwości: jeśli operacja Input z podaną sygnaturą jest wykonywana nieskończenie często, to w końcu każda krotka o tej sygnaturze zostanie wyjęta z przestrzeni krotek.

Podobna zasada obowiązuje przy budzeniu procesów oczekujących na operacji Input. Jeśli na krotkę o pewnej sygnaturze czeka ich wiele i krotka pojawi się w przestrzeni, to wznawiany jest któryś z oczekujących procesów. Nie można zatem zakładać, że oczekujące procesy są kolejkowane, ale można oczekiwać sprawiedliwości. Mamy gwarancję, że jeśli krotka pojawi się nieskończenie wiele razy w przestrzeni, to każdy z oczekujących procesów w końcu zostanie obudzony.

Odczytywanie krotki

Procedura Read jest operacją bardzo podobną do Input. Jedyna różnica polega na tym, że krotka nie jest usuwana z przestrzeni krotek. Podobnie jak w przypadku Input proces wykonujący Read oczekuje aż w przestrzeni zjawi się krotka o podanej sygnaturze. Również procesy oczekujące są budzone w sposób sprawiedliwy.

Zauważmy, że po pojawieniu się krotki o sygnaturze, na którą czekają procesy wykonujące Read, mogą zostać obudzone wszystkie procesy oczekujące, bo każdy z nich jedynie odczytuje krotkę z przestrzeni pozostawiając ją tam. Jeśli jednak na taką samą krotkę oczekują procesy i na operacji Read i na operacji Input, to wtedy na skutek niederministycznego wyboru budzonych procesów możliwe są różne sytuacje. Może zdarzyć się tak, że zostanie obudzony tylko jeden proces oczekujący na Input, może też obudzić się kilka (niekoniecznie wszystkie) procesy czekające na operacji Read, a po nich dopiero proces oczekujący na Input.

Wersje nieblokujące

Oprócz omówionych procedur, do wyjmowania/odczytywania krotek z przestrzeni krotek można wykorzystać dwie funkcje logiczne:

Try_Input(s) oraz Try_Read(s)

Funkcje te działają dokładnie tak jak ich blokujące odpowiedniki: Input oraz Read z tym, że jeśli krotki o podanej sygnaturze nie ma w przestrzeni krotek, to proces nie jest wstrzymywany, a funkcje przekazują natychmiast w wyniku wartość false. Jeśli udało się pobrać/odczytać krotkę, to wynikiem jest wartość true.

Wersji nieblokujących można używać do sprawdzania, czy krotka o podanej sygnaturze w danej chwili znajduje się w przestrzeni krotek. Należy jednak przy tym uważać. Fragment programu:

if Try_Read (x:integer) then
  Input (x: integer);

nie oznacza, że proces nie zostanie zatrzymany na operacji Input. W chwili wykonywania operacji Try_Read (x) w przestrzeni krotek mogła bowiem znajdować się jakaś krotka o sygnaturze (integer), ale zanim proces rozpoczął wykonanie Input, krotka ta mogą zostać usunięta przez inny wykonujący się współbieżnie proces.

Wybór selektywny

Istotnym rozszerzeniem omawianego mechanizmu jest możliwość wykonywania tzw. wyboru selektywnego. Polega on na możliwości wyspecyfikowania wartości konkretnych elementów krotki, którą chcemy wyjąć/odczytać z przestrzeni krotek. Przykładowo można zażyczyć sobie wyjęcia z przestrzeni krotek krotki o sygnaturze (integer, char, integer), której pierwszym elementem jest wartość 3. Odpowiednia instrukcja wygląda następująco:

Input (3, c:char, x: integer)

Zwróćmy uwagę na bardzo ważną rolę, jaką gra składnia w Lindzie. Porównajmy następujące wywołania procedury Input:

Input (x, c) Input (x: integer, c) Input (x, c: char) Input (x: integer, c: char)

Ostatnie z nich jest żądaniem wyjęcia dowolnej krotki o sygnaturze (integer, char) i zapisania wartości poszczególnych składowych wyjmowanej krotki w zmiennych x oraz c. Pozostałe wywołania to wybór selektywny. W pierwszym przypadku chcemy wyjąć z przestrzeni krotek krotkę, której pierwszy element ma wartość taką jak aktualna wartość zmiennej x, a drugi element jest równy co do wartości zmiennej c. Drugie i trzecie wywołanie to wybór selektywny odpowiednio po drugiej i pierwszej współrzędnej. Na przykład Input (x: integer, c) oznacza wyjęcie z przestrzeni krotek krotki, której druga współrzędna ma wartość taką jak zmienna c, a pierwsza współrzędna jest dowolną liczbą całkowitą, którą zapamiętujemy w zmiennej x.

Wybór selektywny jest bardzo przydatny i upraszcza rozwiązanie wielu problemów synchronizacyjnych. Należy jednak pamiętać o tym, że tak naprawdę jest on dość ograniczony. Nie można na przykład bezpośrednio zrealizować wyjęcia z przestrzeni krotek krotki, której dane elementy spełniają dowolny warunek logiczny. Selektywość jest ograniczana jedynie do sprawdzania równości podanych elementów krotki.

Krotki dziurawe

W lindzie wprowadzone jeszcze jedno rozszerzenie. W przestrzeni krotek mogą znajdować się krotki "dziurawe". Dziura to "wartość" określonego typu, która "pasuje" do dowolnej innej wartości tego typu. Dziury będziemy w dalszej części oznaczać gwiazdką wraz z typem. Na przykład krotka:

(3, *: char, 'a', *:integer)

jest krotką o sygnaturze

(integer, char, char, integer)

zawierającą dwie dziury: na drugiej i czwartej pozycji. Taką krotkę można pobrać z przestrzeni krotek wykonując każdą z następujących operacji:

Input (3, 'c', x: char, 8) Input (i: integer, x: char, 'a', j: integer)

W tym drugim przypadku wartość zmiennej j jest nieokreślona. Zauważmy, że nawet krotki dziurawe mają jednoznacznie określoną sygnaturę, tj. każda dziura ma typ.

Do umieszczanie w przestrzeni krotek krotek z dziurą służy także procedura Output. W miejscu dziury podajemy po prostu sygnaturę (z nazwą zmiennej, która jednak nie ma tu znaczenia):

Output (3, c:char)

umieści w przestrzeni krotek krotkę, która na drugiej współrzędnej ma dziurę.

Przykłady

Projektując program współbieżny w Lindzie, będziemy zakładać, że procesy wchodzące w jego skład są jedynymi procesami korzystającymi z przestrzeni krotek. Metoda postępowania jest przy tym następująca:

  1. Projektujemy postać wszystkich potrzebnych nam krotek: sygnaturę, znaczenie poszczególnych elementów.
  2. Opisujemy początkową zawartość przestrzeni: jakie krotki, w jakiej liczbie.
  3. Dopiero teraz przystępujemy do projektowania poszczególnych procesów.

Realizacja komunikacji między procesami za pomocą przestrzeni krotek

Spróbujmy zaprojektować schemat komunikacji między procesami za pomocą przestrzeni krotek. W tym celu przyjmijmy, że każdy proces ma jednoznaczny identyfikator typu integer. Załóżmy, że przestrzeń krotek jest początkowo pusta, a w trakcie działania procesów będą pojawiać się w niej krotki postaci:

(nadawca: integer, odbiorca: integer, kom: Komunikat)

przy czym definicja typu Komunikat wynika z konkretnego zastosowania.

  1. Jeśli proces o identyfikatorze ID zechce wysłać do procesu o identyfikatorze ODB komunikat kom, to umieści w przestrzeni krotek krotkę:
    (ID, ODB, kom)
  2. Proces o identyfikatorze ID odbiera komunikaty w sposób selektywny tak, aby docierały do niego jedynie komunikaty, których jest adresatem:
    Input (nad: integer, ID, kom: Komunikat)
  3. Jeśli proces chce wysłać komunikat do dowolnego procesu, to umieszcza w przestrzeni krotek krotkę dziurawą:
    (ID: integer, *: integer, kom: Komunikat)
  4. Rozgłaszanie, czyli wysłanie komunikatu do wszystkich procesów można zrealizować tak, że procesy odbierające wykonują Read zamiast Input. Pozostaje jednak wtedy problem z usunięciem komunikatu po odczytaniu go przez ostatni proces. Jeśli liczba procesów jest znana, można go rozwiązać wprowadzając w takim komunikacie licznik procesów, które go już odebrały.

Czytelnicy i pisarze. Wersja 1.

W przestrzeni krotek będzie zawsze przebywać co najwyżej jedna krotka postaci (c: integer). Jest to licznik czytelników przebywających w czytelni. Pisarz wchodząc do czytelni zabiera krotkę i oddaje ją dopiero po zakończeniu pisania. Zatem w trakcie pisania przestrzeń krotek jest pusta. Początkowo w przestrzeni krotek umieszczamy krotkę (0). Treści poszczególnych procesów są teraz natychmiastowe:

process Czytelnik;
var
  c: integer;
begin
  repeat
    własne_sprawy;
    Input (c: integer);    
    Output (c+1);
    CZYTANIE;
    Input (c: integer);    
    Output (c-1);
  until false
end;
process Pisarz;
begin
  repeat
    własne_sprawy;
    Input (0);             
    PISANIE;
    Output (0);            
  until false
end;

Przedstawione rozwiązanie ma własność bezpieczeństwa. Gorzej jest z żywotnością. Pisarz, który chce rozpocząć pisanie, musi poczekać aż nikogo nie będzie w czytelni. Do takiej sytuacji może jednak nigdy nie dojść, gdyż nowy czytelnik może zawsze dołączyć do przebywających w czytelni.

Zauważmy, że jeśli pisarz wejdzie do czytelni, to po jego wyjściu w przestrzeni krotek pojawi się krotka 0. Może ją pobrać albo kolejny pisarz, albo któryś z oczekujących czytelników. Co się dokładnie stanie --- nie wiemy, gdyż działa niedeterminizm. Mamy jednak gwarancję, że jeśli tylko nieskończenie wiele razy będzie się pojawiać krotka 0, to każdy z oczekujących procesów ją w końcu weźmie. Problem w tym wypadku polega jednak na tym, że taka krotka może się w ogólne nie pojawić (poza sytuacją początkową).

Czytelnicy i pisarze. Wersja 2.

process Czytelnik;
var
  c: integer;
begin
  repeat
    własne_sprawy;
    Input (c: integer, 0);    
    Output (c+1, 0);
    CZYTANIE;
    Input (c: integer, p: integer);    
    Output (c-1, p);
  until false
end;
process Pisarz;
begin
  repeat
    własne_sprawy;
    Input (c: integer, 0); 
    Output (c, 1);            
    PISANIE;
    Output (0, 0);            
  until false
end;


Komunikacja synchroniczna na przykładzie Ady

Komunikacja synchroniczna

Przypomnijmy podstawowe cechy komunikacji synchronicznej. Procesy synchronizują ze sobą: nadawca czeka z wysłaniem do momentu, aż odbiorca będzie gotowy do odbioru i na odwrót: odbiorca jest wstrzymywany na instrukcji odbioru do chwili, aż sterowanie w nadawcy dojdzie do instrukcji wysłania.

Czasem trudno jest jednak wprowadzić jednoznaczne rozgraniczenie na proces nadający i odbierający, gdyż w czasie pojedynczej synchronizacji procesów może dojść do wymiany komunikatów w obie strony. Tak właśnie dzieje się to w Adzie.

Spotkania (randki) w Adzie

Mechanizm synchronizacyjny udostępniany przez Adę to tzw. spotkania albo randki. W spotkaniu uczestniczą dwa (lub więcej) procesy, które w Adzie noszą nazwę zadań. Zacznijmy od omówienia randki między dwoma zadaniami.

Zadanie aktywne i pasywne

Spośród zadań uczestniczących w randce jedno jest zadaniem aktywnym, a drugie zadaniem pasywnym. Zadanie aktywne inicjuje randkę, ale w czasie jej trwania nie robi nic. Zadanie pasywne jest zadaniem, które udostępnia pewne wejścia. Wejścia te mogą być wywoływane przez zadanie aktywne, które chce zainicjować randkę. Gdy dojdzie do randki, zadanie pasywne zajmuje się jej obsługą, wykonując określony fragment programu, podczas gdy zadanie aktywne jest wstrzymywane w oczekiwaniu na zakończenie randki.

Zadania w Adzie składają się z dwóch części. Pierwsza część to tzw. specyfikacja zadania. Specyfikacja określa jakie wejścia są udostępniane przez zadanie i jakie są ich argumenty. Właściwa treść zadania jest określona w osobnym fragmencie kodu.

Zacznijmy zatem od specyfikacji zadania.

Wejścia

Każde zadanie może udostępniać na zewnątrz pewne wejścia. Są to jakby "procedury", które mogą być wywołane przez inne zadania i realizowane "w ich imieniu" przez zadanie bierne. Każde wejście ma nazwę i, jeśli jest taka potrzeba, parametry. Te same zasady dotyczą zresztą procedur. Każdy parametr w Adzie specyfikuje się, podając jego identyfikator, typ oraz sposób przekazania. Są trzy sposoby przekazywania parametrów w Adzie:

  1. Parametry wejściowe to parametry określane za pomocą słowa kluczowego in. Ich wartości mogą być w trakcie spotkania (lub wewnątrz procedury/funkcji) odczytywane, ale nie można nic na nie zapisać. W szczególności nie mogą więc występować po lewej stronie instrukcji przypisania.
  2. Parametry wyjściowe są określane za pomocą słowa kluczowego out. Można na nie zapisywać wewnątrz procedury, ale ich wartości początkowe są nieokreślone.
  3. Parametry wejściowo-wyjściowe specyfikowane za pomocą słowa kluczowego inout. Są one połączeniem parametrów wejściowych i wyjściowych. Są one odpowiednikiem parametrów przekazywanych przez zmienną z języka Pascal.

Kierunek przekazywania parametrów jest zawsze określany z punktu widzenia specyfikowanego procesu. Przypuśćmy na przykład, że chcemy rozwiązać problem producentów i konsumentów. Chcemy, aby producenci umieszczali wyprodukowane dane w buforze, skąd pobierają je konsumenci. Pierwsze pytanie, na jakie musimy odpowiedzieć to jak zaimplementować bufor? Zastosujemy dość standardową technikę w modelu rozproszonym, wprowadzając dodatkowy proces. Proces ten o nazwie Bufor będzie procesem biernym zarówno przy komunikacji z producentem, jak i konsumentem. Wynika to z tego, że bufor nie ma żadnej możliwości stwierdzenia, kiedy przechowywana przez niego informacja będzie potrzebna konsumentowi, ani kiedy ma skomunikować się z producentem w celu odebrania od niego kolejnej porcji danych. To producent i konsument są stronami inicjującymi spotkanie.

Wyspecyfikujmy więc zadanie Bufor z dwoma wejściami:

Odpowiedni fragment kodu wygląda tak:

task Bufor is
  entry Weź (x: in Integer);
  entry Daj (x: out Integer);
end Bufor;

Zauważmy, że parametr wejścia Weź jest wejściowy, bo to wywołujący przekazuje liczbę całkowitą do włożenia do bufora. Analogicznie parametr wejścia Daj jest wyjściowy, bo za jego pomocą proces aktywny dostaje liczbę z bufora.

Semantyka randki

Jak wygląda randka w Adzie? Aby w ogóle do niej doszło, proces aktywny musi wywołać pewne wejście udostępniane przez proces pasywny. Przykładowo, konsument wywołuje wejście Daj bufora za pomocą instrukcji:

Bufor.Daj (x)

przy czym x jest pewną zmienną lokalną konsumenta, na której zostanie zapisana odczytana z bufora liczba.

Wywołanie wejścia zadania biernego powoduje wstrzymanie procesu wywołującego aż do chwili, gdy sterowanie w procesie biernym dotrze do specjalnej instrukcji akceptującej. Proces wywołujący jest przy tym wstrzymywany w kolejce związanej z danym wejściem. Gdy sterowanie w zadaniu biernym dochodzi do instrukcji akceptującej --- jej składnię omówimy za chwilę --- dochodzi do randki między zadaniem biernym a zadaniem aktywnym znajdującym się na początku kolejki zadań oczekujących na danym wejściu. Należy od razu zwrócić uwagę, że może zdarzyć się także inny scenariusz. Sterowanie w procesie biernym może dojść do instrukcji akceptującej wejście, zanim zostanie ono wywołane przez jakiekolwiek zadanie aktywne. Jak przystało na model synchroniczny, zadanie bierne jest wtedy również wstrzymywane do chwili wywołania wejścia przez jakieś zadanie.

Sama randka ma następujący przebieg:

  1. Zadanie aktywne przekazuje do zadania biernego wartości wszystkich parametrów wejściowych i wejściowo-wyjściowych.
  2. Zadanie aktywne zostaje wstrzymane do czasu zakończenia randki.
  3. Zadanie bierne wykonuje instrukcje stanowiące treść instrukcji akceptującej.
  4. Po zakończeniu instrukcji akceptującej zadanie bierne przekazuje do zadania aktywnego wartości wszystkich parametrów out oraz inout i przechodzi do kolejnej instrukcji za instrukcją akceptującą.
  5. Po odebraniu wartości parametrów wyjściowych zadanie aktywne wznawia swoje działanie.

Z powyższego opisu wynika, że zadanie aktywne po przekazaniu parametrów do zadania biernego pozostaje bezczynne aż do końca randki. Na taki mechanizm można więc patrzeć jak na zwykłe wywołanie procedury, z tym że procedura jest wykonywana nie przez wywołującego, lecz przez inny proces. Zadanie aktywne to wywołujący, który po wywołaniu czeka aż zadanie bierne wykona treść procedury i przekaże wartości wynikowe i dopiero wznawia wykonanie.

Kluczową instrukcją w opisywanym mechanizmie jest instrukcja akceptująca. Ma ona następującą składnię:

accept <nazwa wejścia> (<parametry>) do
  <ciąg instrukcji>;
end <nazwa wejścia>;

W trakcie spotkania wykonują się wszystkie instrukcje między accept a end. Jeśli ten ciąg jest pusty, to można użyć uproszczonej składni pisząc:

accept <nazwa wejścia>; 

Popatrzmy jak taki mechanizm można wykorzystać do realizacji wzajemnego wykluczania dwóch procesów. Napiszmy proces Serwer z dwoma wejściami:

task Serwer is
  entry Chce;
  entry Zwalnia;
end Serwer;
task body Serwer is
  loop
    accept Chce;
    accept Zwalniam;
  end loop;
end Serwer;

Jak widać serwer działa w pętli nieskończone, którą w Adzie zapisuje się w postaci loop ... end loop. W pętli na przemian wykonuje instrukcję akceptującą wejście Chce i instrukcję akceptującą wejście Zwalniam. W efekcie serwer najpierw chce koniecznie zaakceptować proces wywołujący wejście Chce. Gdy jednak już dojdzie do randki (pustej), to serwer przechodzi do kolejnej instrukcji, którą jest accept Zwalniam. Czeka zatem na tej instrukcji aż ktoś --- a może to być jedynie proces, który zwalnia sekcję krytyczną, wywoła wejście Zwalniam nie reagując na wywoływanie wejścia Chce.

Oczekiwanie selektywne

Czasami zachodzi potrzeba zawieszania zadania przyjmującego w oczekiwaniu na zajście jednego z wielu zdarzeń. Stosujemy wtedy specjalną instrukcję select, której składnia jest następująca:

select
  when warunek1 => accept wejście1 do 
                                 instrukcje-w-trakcie-randki;
                               end wejście1;
                               instrukcje-poza-randką;
or 
  when warunek2 => accept wejście2 do 
                                 instrukcje-w-trakcie-randki;
                               end wejście2;
                               instrukcje-poza-randką;
  ...
else
  instrukcje;
end select;

Poszczególne elementy tej konstrukcji, oddzielane od siebie za pomocą słów kluczowych or nazywa się gałęziami. Ostatnia gałąź (po słowie kluczowym else) jest opcjonalna i można ją pominąć. Każda gałąź składa się z dozoru, instrukcji accept oraz instrukcji wykonywanych poza randką. Każdy z tych trzech elementów, z wyjątkiem instrukcji akceptującej, jest opcjonalny i w pewnych okolicznościach można go pominąć. I tak, dozór zbudowany ze słowa kluczowego when warunku logicznego oraz strzałki => pomija się, jeśli warunek jest zawsze prawdziwy (czyli jest nim po prostu true). Instrukcję poza ranką pomija się jeśli jest ona pusta.

Semantyka

Działanie instrukcji select omówimy przyjmując najpierw, że po else nie ma żadnych instrukcji. Jej wykonanie przebiega wówczas w następujących krokach:

  1. Szukamy otwartych gałęzi. Gałąź jest otwarta, jeśli występujący w niej dozór jest prawdziwy. Zauważmy, że gałęzie, w których pominięto dozór, są zawsze prawdziwe. Jeśli nie ma gałęzi otwartych, to instrukcja wyboru powoduje błąd.
  2. Sprawdzamy, czy są zadania oczekujące w kolejkach związanych z wejściami występującymi w instrukcjach accept otwartych gałęzi. Przypomnijmy, że instrukcja accept jest obowiązkowa w każdej gałęzi --- nie można jej pominąć. Co więcej występuje ona na początku każdej gałęzi (ewentualnie bezpośrednio po dozorze).
  3. Jeśli jest proces oczekujący na wejściu w pewnej otwartej gałęzi, to rozpoczyna się randka z pierwszym zadaniem w niedeterministycznie wybranej gałęzi. Podkreślmy: zadania oczekujące na randkę zbierają się na konkretnym wejściu formując kolejkę prostą. Spośród zadań oczekujących na tym samym wejściu jako pierwsze zostanie obsłużone to, które zgłosiło się jako pierwsze. Jednak jeśli są procesy oczekujące na różnych wejściach umieszczonych w otwartych gałęziach instrukcji select, to decyzja o tym, która gałąź się wykona jest podejmowana niedeterministycznie.
  4. Jeśli wszystkie kolejki w gałęziach otwartych są puste, to zadanie przyjmujące zostaje wstrzymane do chwili wywołania przez pewne zadanie aktywne wejścia w otwartej gałęzi. Wtedy rozpoczyna się randka z tym zadaniem.
  5. W trakcie randki wykonywane są instrukcje znajdujące się wewnątrz instrukcji accept obsługującej randkę. Instrukcje te są wykonywane jak zwykle przez zadanie przyjmujące, a zadanie wywołujące w tym czasie jest wstrzymywane.
  6. Gdy sterowanie w zadaniu przyjmującym dojdzie do instrukcji end kończącej randkę, zadanie aktywne jest wznawiane, a zadanie przyjmujące wykonuje instrukcje poza randką. Zwróćmy uwagę, że o ile podczas wykonywania instrukcji znajdujących się wewnątrz instrukcji accept zadanie aktywne jest wstrzymywane, to w trakcie wykonywania instrukcji poza randką zadanie aktywne już się wykonuje.
  7. Instrukcja select kończy się po wykonaniu instrukcji znajdujących się w jednej gałęzi.

W przypadku obecności części z else działanie instrukcji select jest nieco inne. Różnice w stosunku do powyższego opisu są przy tym następujące:

  1. Jeśli nie ma otwartych gałęzi, to wykonują się od razu instrukcje po else. Sytuacja taka nie powoduje tym razem błędu.
  2. Jeśli są otwarte gałęzie, ale nie ma oczekujących procesów w żadnej z nich, to zadanie nie jest wstrzymywane, ale także wykonuje się część po instrukcji else.

Niedeterminizm

W przedstawionym powyżej opisie działania instrukcji select występuje ważne zjawisko: niedeterminizm. Jeśli są procesy oczekujące na wejściach znajdujących się w różnych gałęziach otwartych, to rozpoczynamy randkę na wejściu z niedeterministycznie wybranej gałęzi otwartej. Należy jednak pamiętać, że niedetermizm dotyczy wyboru gałęzi, a nie procesu oczekującego w kolejce związanej z wejściem występującym w tej gałęzi.

Niedeterminizm, o którym mowa, nie jest jednak niczym nieograniczony. Zachowujemy, tak jak było to w przypadku Lindy, warunek sprawiedliwości. Zakładamy mianowicie, że jeśli instrukcja select jest wykonywana nieskończenie wiele razy (na przykład w pętli) oraz pewna gałąź z niepustą kolejką jest otwarta nieskończenie wiele razy, to w końcu zostanie ona wybrana do wykonania. Takie założenia pozwala uzyskać żywotność rozwiązań zapisanych w Adzie.

Zliczanie oczekujących

Omawiając instrukcję select należy zwrócić uwagę na jeszcze jeden element. Otóż w Adzie istnieje możliwość sprawdzenia, ile procesów oczekuje na danym wejściu. Służy do tego konstrukcja o następującej składni:

wejście'count

Będziemy mówić, że count jest atrybutem wejścia, przekazującym liczbę procesów aktualnie oczekujących na tym wejściu.

Przykłady

Producenci i konsumenci

Oto zapis w Adzie rozwiązania problemu producentów i konsumentów. Wejścia są udostępniane jedynie przez proces implementujący bufor (cykliczny i ograniczony). Jedno Weź służy do wstawiania porcji do bufora, a drugie Daj do odbierania ich z bufora.

task Bufor is
  entry Weź (x: in Integer);
  entry Daj (x: out Integer);
end Bufor;

Procesy producenta i konsumenta nie udostępniają wejść. Ponieważ chcemy mieć dużo producentów (niech ich liczba wynosi P, gdzie P jest pewną z góry ustaloną stałą), i dużo (K) konsumentów, więc uwzględniamy to w specyfikacji zadań w następujący sposób:

 task Producent (1..P) is
 end Producent;
 task Konsument (1..K) is
 end Konsument;

Zapiszmy najpierw treść producenta i konsumenta:

task body Producent is 
   liczba: Integer;
  loop
    produkuj (liczba);
    Bufor.Weź (liczba);
  end loop
end Producent;
task body Konsument is 
   liczba: Integer;
  loop
    Bufor.daj (liczba);
    konsumuj (liczba);
  end loop
end Konsument;

Treść procesu bufor jest nieco bardziej złożona. Porcje będziemy pamiętać w tablicy indeksowanej od 0 do B-1, gdzie B jest pewną stałą oznaczającą pojemność bufora. Będziemy ponadto pamiętać pozycję w tablicy, na której znajduje się pierwsza liczba do wyjęcia oraz liczbę elementów w buforze:

task body Bufor is
  pierwsza: integer := 0;
  ile : integer := 0;
  buf: array [0..B-1] of Integer;
 

Bufor działa w pętli nieskończonej. Ponieważ nie da się przewidzieć w jakiej kolejności będą nadchodzić zlecenia od producenta i konsumenta bufor powinien być prawie zawsze gotowy do komunikacji z każdym z nich. No właśnie: prawie zawsze. Nie można bowiem przyjąć kolejnej porcji od producenta, jeśli w buforze nie ma już wolnego miejsca oraz nie można wysłać nic konsumentowi, jeśli bufor jest pusty. Daje to następujący fragment kodu:

 loop
   select
     when ile <> 0 => 
       accept Daj (x: out Integer) do
         x := buf[pierwsza];
         ile := ile - 1;
         pierwsza := (pierwsza + 1) mod B; 
       end Daj;
   or 
     when ile <> B => 
       accept Weź (x: in Integer) do
         buf[(pierwsza+ile) mod B] := x;
         ile := ile + 1;
       end Weź;
   end select;
 end loop;
end Bufor;    

Zwróćmy uwagę, że ten sam efekt można uzyskać zmieniając nieco kolejność instrukcji:

 loop
   select
     when ile <> 0 => 
       accept Daj (x: out Integer) do
         x := buf[pierwsza];
       end Daj;
       ile := ile - 1;
       pierwsza := (pierwsza + 1) mod B; 
   or 
     when ile <> B => 
       accept Weź (x: in Integer) do
         buf[(pierwsza+ile) mod B] := x;
       end Weź;
       ile := ile + 1;
   end select;
 end loop;
end Bufor;    

Powyższe dwa programy mają tę samą funkcjonalność, oba są poprawne, a różnią się jednym szczegółem. W pierwszym modyfikacja wszystkich zmiennych bufora odbywa się podczas nieaktywności producenta/konsumenta. W drugim producent i konsument są wstrzymywani jedynie na czas przekazywania wyprodukowanej porcji. Modyfikacja zmiennych bufora odbywa się już po wznowieniu działania producenta/konsumenta, czyli poza randką. Otrzymujemy w ten sposób rozwiązanie bardziej efektywne.

Communicating Sequential Processes

Instrukcja wejścia i instrukcja wyjścia

Instrukcja wejścia i instrukcja wyjścia tworzą synchroniczny mechanizm komunikacyjny CSP. Składnia instrukcji wejścia jest następująca:

<wejście> ::= <źródło> ? <zmienna docelowa>
<źródło>  ::= <nazwa procesu> 
<nazwa procesu> ::= <nazwa> | <nazwa> ( <indeksy> )
<indeksy> ::= <wyrażenie całkowite> {, <wyrażenie całkowite>}

Przypuśćmy, że w procesie P wystąpiła instrukcja wejścia postaci Q?x. Oznacza ona, że proces P chce odebrać od procesu Q wiadomość i zapisać ją na zmienną x. Typ tej zmiennej wyznacza zarazem typ komunikatu. Co się dzieje z procesem P, jeśli sterowanie dojdzie do takiej instrukcji? Będzie on oczekiwał aż sterowanie w procesie Q dojdzie do pasującej instrukcji wyjścia. Popatrzmy więc teraz na składnię instrukcji wyjścia:

 <wyjście> ::= <przeznaczenie> ! <wyrażenie> 
 <przeznaczenie> ::= <nazwa procesu> 

Jeśli więc w procesie Q wystąpi instrukcja P!6, to oznacza ona, że proces Q chce wysłać do procesu P wartość 6. I tak jak w przypadku instrukcji wejścia proces będzie oczekiwał aż sterowanie w P dojdzie do pasującej instrukcji wejścia.

Co dokładnie oznacza słowo pasująca?

  1. Nazwy procesów muszą sobie odpowiadać.
  2. Wyrażenie w nadawcy musi pasować (w sensie opisanym przy okazji instrukcji przypisania) do zmiennej docelowej odbiorcy.

Gdy dojdzie do sytuacji, w której jeden proces wykonuje instrukcję wyjścia, a drugi pasującą do niej instrukcję wejścia, to dochodzi do komunikacji i zmienna docelowa w odbiorcu otrzymuje wartość wyrażenia.

Na opisany powyżej schemat można patrzeć jak na ... zwykłe przypisanie. "Jedyna" różnica polega na tym, że zmienna docelowa (na którą przypisujemy), znajduje się w innym wyrażeniu niż wyrażenie, którego wartość chcemy jej przypisać.

Przypomnijmy raz jeszcze, że opisywany schemat jest mechanizmem synchronicznym: procesy biorące udział w komunikacji muszą się zsynchronizować, aby doszła ona do skutku. Identyfikacja jest przy tym symetryczna, bo zarówno nadawca musi znać nazwę odbiorcy, jak i odbiorca musi znać nazwę nadawcy.

Popatrzmy na przykład:

 [ Producent :: 
    x: integer;
    *[ true -> produkuj (x);
               Konsument!x
     ]
 || Konsument ::
    x : integer;
    *[ true -> Producent?x;
               konsumuj(x)
    ]
 ]

Jest to poprawny zapis komunikacji między producentem a konsumentem bez bufora. Producent po wyprodukowaniu nowej wartości swojej zmiennej lokalnej x próbuje wysłać tę wartość do konsumenta. Do komunikacji dojdzie, gdy konsument będzie do tego gotowy, czyli gdy sterowanie dojdzie w nim do instrukcji wejścia. Po przekazaniu wartości konsumentowi, producent zajmuje się produkcją, a konsument --- konsumpcją. Jeśli producent wyprodukuje nową porcję zanim konsument zakończy procedurę konsumuj, to będzie oczekiwał na instrukcji wyjścia. Podobnie konsument, który zakończy procedurę konsumuj, zanim producent wyprodukuję nową porcję będzie oczekiwał na instrukcji wejścia. Nie dojdzie zatem ani do zagubienia pewnych informacji ani do pobrania porcji nie wyprodukowanej uprzednio przez producenta.

Zarówno instrukcja wejścia jak i instrukcja wyjścia mogą zakończyć się błędem. Stanie się tak, jeśli proces próbuje skomunikować się z procesem, który już zakończył swoje działanie. Od tej reguły jest jednak wyjątek: instrukcja wejścia w dozorze zachowuje się nieco inaczej.

Instrukcja wejścia w dozorze

Składnia alternatywy/iteracji sugerowała, że dozorem oprócz wyrażenia logicznego może być także instrukcja wejścia. Omówimy teraz znaczenie tego rodzaju konstrukcji. Zanim to jednak uczynimy podkreślmy bardzo wyraźnie: jedynie instrukcja wejścia może wystąpić w dozorze. Instrukcja wyjścia nigdy nie występuje w dozorze.

Aby umieszczenie instrukcji wejścia w dozorze miało sens, należy wyjaśnić jaka jest jej wartość logiczna. Przypuśćmy, że mamy instrukcję postaci:

*[ P?x -> y := x + 8 ]

Mamy trzy możliwości:

  1. Proces P zakończył się już. Wtedy dozór wylicza się natychmiast do wartości fałsz.
  2. Proces P działa i sterowanie w nim jest w pasującej do P?x instrukcji wyjścia. Wtedy dochodzi do komunikacji i dozór wylicza się do prawdy.
  3. Proces P działa, ale sterowanie w nim nie znajduje się w pasującej instrukcji wyjścia. Wtedy wyliczanie dozoru jest wstrzymywane. Jeśli są inne dozory do wyliczenie (w tym przykładzie --- nie ma), to są one wyliczane. Jeśli nie zostaną znalezione żadne prawdziwe dozory, to wyliczenie instrukcji alternatywy/pętli jest wstrzymywane w oczekiwaniu na "wyjaśnienie sytuacji", czyli zakończenie procesu P lub jego gotowość do komunikacji.


Przykłady

Wzajemne wykluczanie

Przypuśćmy, że procesy S(1..100) rywalizują o dostęp do sekcji krytycznej. Dostęp do niej zrealizujemy wprowadzając dodatkowy proces P. Każdy z procesów S przed wejściem do sekcji krytycznej informuje o tym proces P wysyłając mu komunikat. Ponieważ nie jest przekazywana żadna wartość (jest ważny jedynie fakt zgłoszenia chęci wejścia do sekcji krytycznej) wykorzystamy do tego celu sygnały (wyrażenia strukturalne bez argumentów). Proces S nie musi oczekiwać na zgodę procesu P --- proces ten nie będzie słuchał próśb, jeśli ktoś jest w sekcji krytycznej. Po wyjściu z sekcji krytycznej proces S informuje o tym proces zarządzający:

[S(i: 1..100):: 
   *[ true -> własne_sprawy;
              P!Chce();
              sekcja_krytyczna;
              P!Zwalniam()
    ]
||
  P::
    *[(i: 1..100) S(i)? Chce() -> S(i)?Zwalniam()]
]

Wzajemne wykluczanie z większą liczbą procesów w sekcji krytycznej

Przypuśćmy, że chcemy wpuszczać do sekcji krytycznej nie jeden proces, ale co najwyżej 10. Zmieniamy kod procesu zarządzającego P tak, aby:

  1. Zliczał procesy przebywające w sekcji krytycznej.
  2. Był przygotowany na to, że komunikaty zamawiające sekcję i zwalniające ją będą nadchodzić w niedającej się przewidzieć kolejności.
  3. Przestał słuchać próśb o wejście do sekcji krytycznej, gdy nie ma w niej miejsca. W tym celu użyjemy dozorów złożonych.
[S(i: 1..100):: 
   *[ true -> własne_sprawy;
              P!Chce();
              sekcja_krytyczna;
              P!Zwalniam()
    ]
||
  P::
    wolnych: integer;
    wolnych := 10;  
    *[(i: 1..100) wolnych > 0; S(i)? Chce() -> wolnych := wolnych - 1
     |(i: 1..100) S(i)? Zwalniam() -> wolnych := wolnych + 1
     ]
]
 

Pięciu filozofów

Wykorzystamy 3 rodzaje procesów: filozofów, widelce i lokaja. Filozof przed wejściem do sekcji krytycznej informuje o tym lokaja, po czym próbuje podnieść najpierw jeden widelec, potem drugi. Lokaj dba o to, aby przy stole przebywało jednocześnie co najwyżej 4 filozofów. Widelec dba o to, aby mógł nim jeść co najwyżej jeden filozof jednocześnie:

[  Filozof (i: 0..4)::
     *[ true -> mysli;
                Lokaj!Chce();
                Widelec(i)!Chce();
                Widelec((i+1) mod 5)!Chce();
                je;
                Widelec((i+1) mod 5)!Odkłada();
                Widelec(i)!Odkłada();
                Lokaj!Wychodzę()
      ]
|| Widelec (i: 0..4)::
   *[ Filozof(i)?Chce() -> Filozof(i)?Odklada()
    | Filozof((i+4) mod 5)?Chce() -> Filozof((i+4) mod 5)?Odklada()
    ]
|| Lokaj :: wolnych : integer;
   wolnych := 4;
   *[(i:0..4) wolnych > 0; Filozof(i)?Chce() -> wolnych := wolnych - 1
    |(i:0..4) Filozof(i)?Wychodze() -> wolnych := wolnych + 1
    ]
]  

Silnia

A oto przykład wykorzystania potoku procesów do realizacji obliczeń rekurencyjnych. Chcemy obliczać silnię z argumentem co najwyżej równym MAX (jest to pewna stała). Tworzymy potok procesów, z których każdy odbiera od poprzedniego argument oblicza silnię (być może wykorzystując dalsze procesy) i odsyła wynik:

[silnia (i: 1..MAX):: 
    n, w : integer;
    *[silnia(i-1)?n -> [n=0 -> silnia (i-1)!1
                       |n>0 -> silnia (i+1)!(n-1);
                               silnia (i+1)?w; 
                               silnia (i-1)!w*n
                       ]
    ]
]

Proces, w którym chcemy obliczać silnię nazywamy silnia(0) i umieszczamy w nim instrukcję silnia(1)!n, gdzie n jest wartością, której silnię chcemy obliczyć.

CSP cd.

Instrukcja wejścia i instrukcja wyjścia

Instrukcja wejścia i instrukcja wyjścia tworzą synchroniczny mechanizm komunikacyjny CSP. Składnia instrukcji wejścia jest następująca:

<wejście> ::= <źródło> ? <zmienna docelowa>
<źródło>  ::= <nazwa procesu> 
<nazwa procesu> ::= <nazwa> | <nazwa> ( <indeksy> )
<indeksy> ::= <wyrażenie całkowite> {, <wyrażenie całkowite>}

Przypuśćmy, że w procesie P wystąpiła instrukcja wejścia postaci Q?x. Oznacza ona, że proces P chce odebrać od procesu Q wiadomość i zapisać ją na zmienną x. Typ tej zmiennej wyznacza zarazem typ komunikatu. Co się dzieje z procesem P, jeśli sterowanie dojdzie do takiej instrukcji? Będzie on oczekiwał aż sterowanie w procesie Q dojdzie do pasującej instrukcji wyjścia. Popatrzmy więc teraz na składnię instrukcji wyjścia:

 <wyjście> ::= <przeznaczenie> ! <wyrażenie> 
 <przeznaczenie> ::= <nazwa procesu> 

Jeśli więc w procesie Q wystąpi instrukcja P!6, to oznacza ona, że proces Q chce wysłać do procesu P wartość 6. I tak jak w przypadku instrukcji wejścia proces będzie oczekiwał aż sterowanie w P dojdzie do pasującej instrukcji wejścia.

Co dokładnie oznacza słowo pasująca?

  1. Nazwy procesów muszą sobie odpowiadać.
  2. Wyrażenie w nadawcy musi pasować (w sensie opisanym przy okazji instrukcji przypisania) do zmiennej docelowej odbiorcy.

Gdy dojdzie do sytuacji, w której jeden proces wykonuje instrukcję wyjścia, a drugi pasującą do niej instrukcję wejścia, to dochodzi do komunikacji i zmienna docelowa w odbiorcu otrzymuje wartość wyrażenia.

Na opisany powyżej schemat można patrzeć jak na ... zwykłe przypisanie. "Jedyna" różnica polega na tym, że zmienna docelowa (na którą przypisujemy), znajduje się w innym wyrażeniu niż wyrażenie, którego wartość chcemy jej przypisać.

Przypomnijmy raz jeszcze, że opisywany schemat jest mechanizmem synchronicznym: procesy biorące udział w komunikacji muszą się zsynchronizować, aby doszła ona do skutku. Identyfikacja jest przy tym symetryczna, bo zarówno nadawca musi znać nazwę odbiorcy, jak i odbiorca musi znać nazwę nadawcy.

Popatrzmy na przykład:

 [ Producent :: 
    x: integer;
    *[ true -> produkuj (x);
               Konsument!x
     ]
 || Konsument ::
    x : integer;
    *[ true -> Producent?x;
               konsumuj(x)
    ]
 ]

Jest to poprawny zapis komunikacji między producentem a konsumentem bez bufora. Producent po wyprodukowaniu nowej wartości swojej zmiennej lokalnej x próbuje wysłać tę wartość do konsumenta. Do komunikacji dojdzie, gdy konsument będzie do tego gotowy, czyli gdy sterowanie dojdzie w nim do instrukcji wejścia. Po przekazaniu wartości konsumentowi, producent zajmuje się produkcją, a konsument --- konsumpcją. Jeśli producent wyprodukuje nową porcję zanim konsument zakończy procedurę konsumuj, to będzie oczekiwał na instrukcji wyjścia. Podobnie konsument, który zakończy procedurę konsumuj, zanim producent wyprodukuję nową porcję będzie oczekiwał na instrukcji wejścia. Nie dojdzie zatem ani do zagubienia pewnych informacji ani do pobrania porcji nie wyprodukowanej uprzednio przez producenta.

Zarówno instrukcja wejścia jak i instrukcja wyjścia mogą zakończyć się błędem. Stanie się tak, jeśli proces próbuje skomunikować się z procesem, który już zakończył swoje działanie. Od tej reguły jest jednak wyjątek: instrukcja wejścia w dozorze zachowuje się nieco inaczej.

Instrukcja wejścia w dozorze

Składnia alternatywy/iteracji sugerowała, że dozorem oprócz wyrażenia logicznego może być także instrukcja wejścia. Omówimy teraz znaczenie tego rodzaju konstrukcji. Zanim to jednak uczynimy podkreślmy bardzo wyraźnie: jedynie instrukcja wejścia może wystąpić w dozorze. Instrukcja wyjścia nigdy nie występuje w dozorze.

Aby umieszczenie instrukcji wejścia w dozorze miało sens, należy wyjaśnić jaka jest jej wartość logiczna. Przypuśćmy, że mamy instrukcję postaci:

*[ P?x -> y := x + 8 ]

Mamy trzy możliwości:

  1. Proces P zakończył się już. Wtedy dozór wylicza się natychmiast do wartości fałsz.
  2. Proces P działa i sterowanie w nim jest w pasującej do P?x instrukcji wyjścia. Wtedy dochodzi do komunikacji i dozór wylicza się do prawdy.
  3. Proces P działa, ale sterowanie w nim nie znajduje się w pasującej instrukcji wyjścia. Wtedy wyliczanie dozoru jest wstrzymywane. Jeśli są inne dozory do wyliczenie (w tym przykładzie --- nie ma), to są one wyliczane. Jeśli nie zostaną znalezione żadne prawdziwe dozory, to wyliczenie instrukcji alternatywy/pętli jest wstrzymywane w oczekiwaniu na "wyjaśnienie sytuacji", czyli zakończenie procesu P lub jego gotowość do komunikacji.


Przykłady

Wzajemne wykluczanie

Przypuśćmy, że procesy S(1..100) rywalizują o dostęp do sekcji krytycznej. Dostęp do niej zrealizujemy wprowadzając dodatkowy proces P. Każdy z procesów S przed wejściem do sekcji krytycznej informuje o tym proces P wysyłając mu komunikat. Ponieważ nie jest przekazywana żadna wartość (jest ważny jedynie fakt zgłoszenia chęci wejścia do sekcji krytycznej) wykorzystamy do tego celu sygnały (wyrażenia strukturalne bez argumentów). Proces S nie musi oczekiwać na zgodę procesu P --- proces ten nie będzie słuchał próśb, jeśli ktoś jest w sekcji krytycznej. Po wyjściu z sekcji krytycznej proces S informuje o tym proces zarządzający:

[S(i: 1..100):: 
   *[ true -> własne_sprawy;
              P!Chce();
              sekcja_krytyczna;
              P!Zwalniam()
    ]
||
  P::
    *[(i: 1..100) S(i)? Chce() -> S(i)?Zwalniam()]
]

Wzajemne wykluczanie z większą liczbą procesów w sekcji krytycznej

Przypuśćmy, że chcemy wpuszczać do sekcji krytycznej nie jeden proces, ale co najwyżej 10. Zmieniamy kod procesu zarządzającego P tak, aby:

  1. Zliczał procesy przebywające w sekcji krytycznej.
  2. Był przygotowany na to, że komunikaty zamawiające sekcję i zwalniające ją będą nadchodzić w niedającej się przewidzieć kolejności.
  3. Przestał słuchać próśb o wejście do sekcji krytycznej, gdy nie ma w niej miejsca. W tym celu użyjemy dozorów złożonych.
[S(i: 1..100):: 
   *[ true -> własne_sprawy;
              P!Chce();
              sekcja_krytyczna;
              P!Zwalniam()
    ]
||
  P::
    wolnych: integer;
    wolnych := 10;  
    *[(i: 1..100) wolnych > 0; S(i)? Chce() -> wolnych := wolnych - 1
     |(i: 1..100) S(i)? Zwalniam() -> wolnych := wolnych + 1
     ]
]
 

Pięciu filozofów

Wykorzystamy 3 rodzaje procesów: filozofów, widelce i lokaja. Filozof przed wejściem do sekcji krytycznej informuje o tym lokaja, po czym próbuje podnieść najpierw jeden widelec, potem drugi. Lokaj dba o to, aby przy stole przebywało jednocześnie co najwyżej 4 filozofów. Widelec dba o to, aby mógł nim jeść co najwyżej jeden filozof jednocześnie:

[  Filozof (i: 0..4)::
     *[ true -> mysli;
                Lokaj!Chce();
                Widelec(i)!Chce();
                Widelec((i+1) mod 5)!Chce();
                je;
                Widelec((i+1) mod 5)!Odkłada();
                Widelec(i)!Odkłada();
                Lokaj!Wychodzę()
      ]
|| Widelec (i: 0..4)::
   *[ Filozof(i)?Chce() -> Filozof(i)?Odklada()
    | Filozof((i+4) mod 5)?Chce() -> Filozof((i+4) mod 5)?Odklada()
    ]
|| Lokaj :: wolnych : integer;
   wolnych := 4;
   *[(i:0..4) wolnych > 0; Filozof(i)?Chce() -> wolnych := wolnych - 1
    |(i:0..4) Filozof(i)?Wychodze() -> wolnych := wolnych + 1
    ]
]  

Silnia

A oto przykład wykorzystania potoku procesów do realizacji obliczeń rekurencyjnych. Chcemy obliczać silnię z argumentem co najwyżej równym MAX (jest to pewna stała). Tworzymy potok procesów, z których każdy odbiera od poprzedniego argument oblicza silnię (być może wykorzystując dalsze procesy) i odsyła wynik:

[silnia (i: 1..MAX):: 
    n, w : integer;
    *[silnia(i-1)?n -> [n=0 -> silnia (i-1)!1
                       |n>0 -> silnia (i+1)!(n-1);
                               silnia (i+1)?w; 
                               silnia (i-1)!w*n
                       ]
    ]
]

Proces, w którym chcemy obliczać silnię nazywamy silnia(0) i umieszczamy w nim instrukcję silnia(1)!n, gdzie n jest wartością, której silnię chcemy obliczyć.

Rozproszony algorytm uzgadniania

Model scentralizowany

Cechy

Przechodzimy teraz do mechanizmów synchronizacji w systemach ze wspólną pamięcią. Model ten różni się od dotychczasowych przede wszystkim tym, że można stosować zmienne współdzielone, czyli dostępne dla wielu procesów, i za ich pomocą wymieniać informacje.

Korzystanie ze zmiennych współdzielonych wymaga jednak dużej ostrożności, jak widzieliśmy to podczas pierwszego wykładu. Niekontrolowane niczym modyfikowanie takich zmiennych w wielu procesach jednocześnie może dawać niedające się przewidzieć efekty. Z tego powodu większość z omawianych w dalszym ciągu mechanizmów gwarantuje atomowość lub niepodzielność pewnych operacji.

Mechanizmy

W dalszej części wykładu omówimy szczegółowo dwie metody synchronizacji procesów w środowisku scentralizowanym: semafory i monitory. Wspomnimy także o mechanizmie zamków (muteksów). Zanim jednak to nastąpi na przykładzie wzajemnego wykluczania wspomnijmy o dwóch innych metodach synchronizacji.

Pierwsza z tym metod jest stosowana wtedy, gdy nie dysponujemy żadnym wsparciem ani ze strony sprzętu ani oprogramowania. Korzystając jedynie z wysokopoziomowych instrukcji języka programowania można zapewnić wzajemne wykluczanie w dostępie do sekcji krytycznej za pomocą m.in. algorytmu Petersona. Jego dokładne omówienie znalazło się w materiałach dotyczących pierwszych ćwiczeń. Przypomijmy jego wady: statycznie znana liczba procesów, koszt, a przede wszystkim aktywne oczekiwanie.

Czasem możemy liczyć na wsparcie sprzętowe. Może to być na przykład specjalny rozkaz maszynowy realizujący niepodzielny zapis i odczyt. Rozpatrzmy dla przykładu rozkaz Test_and_Set, który odczytuje pewien bit G i ustawia go na jeden. Działanie takiego rozkazu można by zapisać w wysokopoziomowym języku następująco:

procedure Test_and_Set (var L: 0..1);
{ Ta procedura jest atomowa! }
begin
  L := G;
  G := 1;
end   

Problem wzajemnego wykluczania można wówczas rozwiązać następująco:

var
  wolny: 0..1 := 0;
process P;
var
  i: 0..1;
begin
  repeat
    własne_sprawy;
    repeat
      Test_and_Set (i);
    until i = 0;
    sekcja_krytyczna;
    wolny := 0;
  until false
end; 

To rozwiązanie także nie jest pozbawione wad. Pierwszą z nich jest oczywiście aktywne oczekiwanie, drugą możliwość zagłodzenia procesu.

Semafory jako abstrakcyjny typ danych

Omówienie mechanizmów udostępnianych programowo rozpoczniemy od semaforów. Semafor jest to abstrakcyjny typ danych, na którym można wykonywać jedynie dwie operacje:

Powyższe operacje są niepodzielne i wzajemnie się wykluczają. Ponadto semafor można raz, poza procesami, zainicjować na dowolną wartość nieujemną.

O semaforze myślimy jak o pewnej zmiennej całkowitej o wartościach nieujemnych. Programista nie ma jednak dostępu do wartości tej zmiennej (poza początkową inicjacją), a operacje na niej wykonuje za pomocą operacji P i V. Ich działanie można zdefiniować na dwa sposoby.

Klasyczna definicja semafora

Klasyczna definicja semafora definiuje semantykę operacji P następująco:

P(S): 
zaczekaj aż wartość semafora S będzie dodatnia;
zmniejsz S o 1

Jeśli semafor S ma od razu wartość większą niż 0, to operacja P nie wstrzymuje wywołującego ją procesu. Jeśli S ma wartość równą 0, to proces wykonujący P jest wstrzymywany w sposób nieangażujący procesora. W czasie, gdy proces oczekuje inne procesy mogą rozpocząć wykonanie operacji P (i zostaną także wstrzymane) lub też wykonać operację V (co, jak za chwilę zobaczymy, powoduje zwiększenie wartości zmiennej semaforowej i w konsekwencji może doprowadzić do obudzenia jednego z oczekujących na operacji P procesów).

Operację V definiuje się po prostu tak:

 V(S):  
 S := S + 1

Powyższe definicje wymagają kilku wyjaśnień. Niepodzielność instrukcji V jest zrozumiała: zwiększenie semafora ma się po prostu odbyć w jednym kroku. Co jednak oznacza niepodzielność instrukcji P? Jeśli proces rozpoczyna wykonanie tej operacji i zastaje dodatnią wartość semafora, to zmniejsza jego wartość o 1, a niepodzielność gwarantuje, że operacja ta zostanie wykonana w całości zanim inny proces rozpocznie wykonanie V lub P. Jeśli jednak proces zastanie semafor równy zero to jest wstrzymany. Niepodzielność P nie gwarantuje zatem tego, że proces, który rozpoczął jej wykonanie zakończy je bez wstrzymania swojego działania. Jednak proces wstrzymany oddaje dostęp do operacji P i V, dzięki czemu inny proces może zwiększyć semafor. Któryś z oczekujących procesów (jeden z nich) może "zauważyć" tę zmianę (nie zajmujemy się sposobem implementacji tego mechanizmu) i wznowić wykonanie operacji P. I tutaj ponownie korzystamy z niepodzielności, która gwarantuje, że proces zmniejszy wartość semafora zanim ktokolwiek inny rozpocznie wykonanie P lub V.

Podkreślmy raz jeszcze: P i V to jedyne operacje na semaforze. W szczególności nie ma możliwości sprawdzenia wartości zmiennej semaforowej!.

Operacja V może spowodować obudzenie jednego spośród procesów oczekujących na semaforze. Definicja nie precyzuje jednak, który to będzie proces. Nie można w szczególności założyć, że procesy oczekują na semaforze w kolejce. Aby zapewnić żywotność programów korzystających z semaforów przyjmuje się jednak pewne założenie dotyczące oczekiwania: jest ono sprawiedliwe. Każdy proces oczekujący zostanie w skończonym czasie obudzony, jeśli tylko semafor zostanie podniesiony dostatecznie dużo razy.

W dalszym ciągu wykładu będziemy mówić, że semafor jest zamknięty, jeśli wartość zmiennej semaforowej wynosi 0, w przeciwnym razie semafor będziemy nazywać otwartym. Sformułowanie przejść pod semaforem będzie oznaczać zakończenie wykonania operacji P, a podniesienie semafora to wykonanie na nim operacji V.

Definicja uproszczona

Czasem wpowadza się nieco inną definicję operacji P i V:

P(S):
jeśli S > 0, to S := S - 1, w przeciwnym razie wstrzymaj działanie  
V(s):
jeśli są procesy oczekujące na S, to obudź jeden z nich, w przeciwnym razie 
S := S + 1

Podstawowa różnica polega na tym, że w definicji uproszczonej nie podnosi się semafora, jeśli są procesy oczekujące. Zamiast tego proces wykonujący operację V budzi jeden z nich. Semafor zostanie podniesiony jedynie wówczas, gdy nikt pod nim nie czeka. Pozostałe założenia dotyczące niepodzielności, atomowości i sprawiedliwości operacji semaforowej pozostają w mocy.

Nazwy operacji semaforowych pochodzą z języka holenderskiego:

Pojawiające się w literaturze nazwu wait i signal rezerwujemy dla operacji na zmiennych warunkowych.

Semafor binarny

Szczególnym przypadkiem semafora jest semafor binarny. Przyjmuje on jedynie wartość 0 lub 1 (zamknięty lub otwarty), a operacje na nim wykonuje się następująco:

P(S):
jeśli S > 0, to S := 0, w przeciwnym razie wstrzymaj działanie  
V(S):
jeśli są procesy oczekujące na S, to obudź jeden z nich, w przeciwnym razie 
S := 1

Semafor binarny jest oszczędniejszy pamięciowo niż semafor ogólny, gdyż wymaga tylko jednego bitu.

Zauważmy, że powyższa definicja semafora binarnego powoduje, że otwarcie otwartego semafora binarnego nie ma żadnego efektu. Może to prowadzić do subtelnych błędów w programach. Dlatego też niektórzy przyjmują, że operacja V na otwartym semaforze binarnym powoduje błąd.

Semafory będziemy deklarować używając typu semaphore lub binary semaphore. Deklaracje będziemy umieszczać poza procesami. Semafory będą inicjowane od razu w miejscu deklaracji.

Przykład. Wzajemne wykluczanie

Rozwiązanie problemu wzajemnego wykluczania jest bardzo proste:

mutex: binary semaphore := 1;
process P;
begin
  repeat
    własne_sprawy;
    P (mutex);
    sekcja_krytyczna;
    V (mutex)
  until false
end

Jeśli dostęp do sekcji krytycznej może uzyskać jednocześnie co najwyżej k procesów (dla k > 1), zastosujemy semafory ogólne:

mutex: semaphore := k;
process P;
begin
  repeat
    własne_sprawy;
    P (mutex);
    sekcja_krytyczna;
    V (mutex)
  until false
end

Przykład. Produceni i konsumenci

Przypuśćmy, że mamy typ danych Bufor z operacjami Wstaw i Pobierz, implementujący bufor o pojemności N. Chcemy za pomocą semaforów rozwiązać problem wielu producentów i wielu konsumentów dbając o właściwą synchronizację dostępu do bufora.

W tym celu wprowadzimy trzy semafory. Semafor mutex będzie dbać o wzajemne wykluczanie operacji na buforze (nie można jednocześnie wstawiać danych do bufora, żeby dane nie trafiły na to samo miejsce). Oprócz tego za pomocą semafora wolne będziemy "zliczać" wolne miejsca w buforze, a za pomocą semafora zajęte --- będziemy zliczać zajęte miejsca. Przyjmijmy, że bufor jest początkowo pusty. Naturalnym sposobem inicjacji semaforów jest:

 mutex: binary semaphore := 1;
 wolne: semaphore := N;
 zajete: semaphore := 0;
 b: Buffer;  

Producent przed wstawieniem danych do bufora musi się upewnić, że w buforze jest miejsce. Jeśli tak, to liczba wolnych miejsc się zmniejszy, a zwiększy się liczba miejsc zajętych. Naturalne jest więc zaimplementowanie producenta w następujący sposób:

process Producent;
var
  x: integer;
begin
  repeat
    produkuj (x);
    P (wolne);
    P (mutex);
    wstaw (b, x);
    V (mutex);
    V (zajete)
  until false;
end

Proces konsumenta jest symetryczny:

process Konsument;
var
  x: integer;
begin
  repeat
    P (zajete);
    P (mutex);
    x := pobierz (b);
    V (mutex);
    V (wolne)
  until false;
end

Symulacja semafora ogólnego binarnymi

W dotychczasowych rozważaniach wprowadziliśmy semafory ogólne i binarne. Powstaje naturalne pytanie, który z tych mechanizmów ma większą siłę wyrazu. Czy program zapisany z wykorzystaniem semaforów binarnych daje się zapisać korzystając jedynie z semaforów ogólnych (i być może dodatkowych "zwykłych" zmiennych) i na odwrót.

Ponieważ każdy semafor binarny jest zarazem semaforem ogólny, więc zamiast semaforów binarnych można zawsze użyć semaforów ogólnych (zakładamy, że nie dochodzi do próby otwarcia otwartego semafora binarnego). Również program korzystający z semaforów ogólnych daje się napisać z użyciem semaforów ogólnych i pewnych dodatkowych zmiennych, jednak stwierdzenie tego faktu nie jest już tak oczywiste.

Aby się o tym przekonać dokonajmy "symulacji" semafora ogólnego S za pomocą zmiennej całkowitej N oraz dwóch semaforów binarnych mutex i delay. Symulacja polega na zaimplementowaniu operacji P(S) oraz V (S) za pomocą operacji na semaforach mutex i delay i zmiennej całkowitej N.

Aby zaimplementować operację P(S) musimy umieć odczytać wartość zmiennej semaforowej. To jednak jest niemożliwe. Dlatego też wartość tę będziemy pamiętać na zmiennej N. Tak naprawdę zmienna ta będzie pełnić dwie funkcje. Jeśli jest dodatnia, to zapamiętuje wartość zmiennej semaforowej, jeśli jest ujemna, to jej wartość bezwzględna oznacza liczbę procesów oczekujących pod semaforem N.

Początkowe wartości poszczególnych zmiennych są następujące:

var
  mutex: binary semaphore := 1;
  delay: binary semaphore := 0;
  N := początkowa wartość symulowanego semafora S

Operację P(S) implementujemy następująco:

 P (mutex);
 N := N - 1;
 if N <= -1 then
 begin
   V (mutex);
   P (delay)
 end else
   V (mutex);

Przyjrzyjmy się tej procedurze. Opuszczenie semafora ogólnego S ma być atomowe i wykluczać się z innymi operacjami na tym semaforze. Procedura zaczyna się więc od zamknięcia semafora mutex, gwarantującego wzajemne wykluczania. Następnie jest zmniejszana zmienna N. Jeśli na skutek tego zmniejszenia otrzyma ona wartość ujemną, to znaczy, że semafor jest zamknięty. Zgodnie z przedstawionymi intuicjami wartość -N oznacza wtedy liczbę procesów oczekujących, więc zmniejszenie N jest uzasadnione. W takiej sytuacji proces musi jednak poczekać. W tym celu wykonuje operację P na zamkniętym semaforze delay. Ale zanim to uczyni musi podnieść semafor mutex, aby otworzyć dostęp do operacji P(S) i V(S) dla innych procesów (gdybyśmy nie podnieśli semafora mutex doszłoby do zakleszczenia)!

Jeśli semafor był otwarty, to proces kontynuuje swoje działanie bez oczekiwania, więc nie wykonuje operacji P(delay) a jedynie V(mutex).

Operacja V(S) wygląda następująco:

P (mutex);
N := N + 1;
if N <= 0 then V (delay);
V (mutex);  

I znów semafor mutex dba o wzajemne wykluczanie i niepodzielność. Zwiększenie zmiennej N jest potrzebne niezależnie od tego czy przed semaforem nikt nie czekał (N >= 0) i w związku z tym trzeba zwiększyć wartość zmiennej semaforowej (czyli zwiększyć N), czy też były procesy oczekujące (N < 0) i jeden z nich należy obudzić, czyli zmniejszyć wartość -N (zwiększając N). Jeśli jednak N było początkowo mniejsze niż zero, to trzeba obudzić jeden z procesów oczekujących na semaforze delay --- stąd operacja V(delay).

Ta z pozoru sensowna symulacja zawiera jednak pewien subtelny błąd. Aby go znaleźć prześledźmy symulację następującego programu:

var
  S: semaphore := 2;
proces P(i: 1..4);
begin
    własne_sprawy;
    P (S);
    sekcja_krytyczna;
    V (S)
end

Widzimy, że jest to rozwiązanie problemu wzajemnego wykluczania, przy czym w sekcji krytycznej mogą przebywać jednocześnie co najwyżej dwa procesy.

Przepiszmy powyższy program zastępując operacje na semaforze S ich implementacją:

var
  N: integer := 2;
  mutex : binary semaphore := 1;
  delay : binary semaphore := 0;
proces P(i: 1..4);
begin
  własne_sprawy;
  P (mutex);
  N := N - 1;
  if N <= -1 then
  begin
    V (mutex);
    P (delay)  {*}
  end else
    V (mutex);
  sekcja_krytyczna;
  P (mutex);
  N := N + 1;
  if N <= 0 then V (delay);
  V (mutex);  
end

Przypuśćmy, że do sekcji krytycznej usiłują wejść dwa procesy: P(1) i P(2). Oczywiście udaje im się to. Zmienna N zostaje zmniejszona o 2 ma więc wartość 0, semafor delay jest nadal zamknięty, a mutex otwarty. Popatrzmy, co się stanie, jeśli proces P(3) będzie usiłował wejść do sekcji krytycznej. Zamyka on semafor mutex, zmniejsza N o 1 stwierdza, że N = -1 więc podnosi semafor mutex i ma się zawiesić na semaforze delay. Jest to zgodne z oczekiwaniami, gdyż sekcja krytyczna jest już zajęta przez dwa procesy. Przypuśmy jednak, że zanim proces zawiesi się w wierszu oznaczonym * analogiczny ciąg instrukcji wykona proces P(4). Mamy wówczas następującą sytuację:

Procesy P(1) i P(2) są w sekcji krytycznej.  
Procesy P(3) i P(4) są tuż przed wykonaniem instrukcji (*).
Zmienna N ma wartość -2.
Semafor mutex jest otwarty.
Semafor delay jest zamknięty. 

Przypuśmy, że teraz procesy P(1) i P(2) po kolei wychodzą z sekcji krytycznej. Proces P(1) zamyka semafor mutex , zwiększa N o jeden na -1, wykonuje operację V (delay) i V(mutex). Sytuacja jest następująca:

Proces P(2) jest w sekcji krytycznej.  
Procesy P(3) i P(4) są tuż przed wykonaniem instrukcji (*).
Zmienna N ma wartość -1.
Semafor mutex jest otwarty.
Semafor delay jest otwarty. 

Teraz proces P(2) wykonuje analogiczny fragment kodu: zamyka semafor mutex, zwiększa N o 1 i ... podnosi semafor delay! Ale semafor ten jest już otwarty. W zależności od semantyki powoduje to błąd lub nie daje efektu. Przypuśćmy, że nic się nie zmienia. Proces P(2) kontynuuje otwierając semafor mutex, co prowadzi do:

Procesy P(3) i P(4) są tuż przed wykonaniem instrukcji (*).
Zmienna N ma wartość 0.
Semafor mutex jest otwarty.
Semafor delay jest otwarty. 

Teraz swoje działanie kontynuuje proces P(3). Wykonuje operację P (delay) i wchodzi do sekcji krytycznej:

Proces P(3) jest w sekcji krytycznej.
Proces P(4) jest tuż przed wykonaniem instrukcji (*).
Zmienna N ma wartość 0.
Semafor mutex jest otwarty.
Semafor delay jest zamknięty. 

Gdy proces P(4) spróbuje kontynuować swoje działanie, to wykona operację P(delay) i zostanie wstrzymany, mimo że w sekcji krytycznej jest jeszcze miejsce!. Pokazaliśmy zatem scenariusz ilustrujący błędne (to znaczy niezgodne z oryginalnym programem) działanie symulacji.

Dziedziczenie sekcji krytycznej

Opisaną powyżej symulację na szczęście można łatwo poprawić. Zaskakujący jest przy tym sposób ---- z pozoru bezsensowny, a w rzeczywistości prowadzący do ważnej techniki konstruowania programów wykorzystujących semafory.

Zestawmy dotychczasową implementację operacji P(S) i V(S) z nową, poprawną wersją:

 własne_sprawy;
 P (mutex);
 N := N - 1;
 if N <= -1 then
 begin
   V (mutex);
   P (delay) 
 end else
   V (mutex);
 sekcja_krytyczna;
 P (mutex);
 N := N + 1;
 if N <= 0 then V (delay);
 V (mutex);  
 własne_sprawy;
 P (mutex);
 N := N - 1;
 if N <= -1 then
 begin
   V (mutex);
   P (delay)  
 end;
 V (mutex);
 sekcja_krytyczna;
 P (mutex);
 N := N + 1;
 if N <= 0 then V (delay) else
  V (mutex);  

Przyjrzyjmy się implementacji operacji P(S):

 P (mutex);
 N := N - 1;
 if N <= -1 then
 begin
   V (mutex);
   P (delay)  
 end;
 V (mutex);

Jeśli warunek jest spełniony, to semafor mutex jest zamykany raz a otwierany dwukrotnie! Analogicznie w V(S):

 P (mutex);
 N := N + 1;
 if N <= 0 then V (delay) else
   V (mutex);  

może się zdarzyć opuszczenie semafora mutex bez jego podniesienia!

Pomimo tego implementacja jest poprawna. Przyjrzyjmy się bliżej zastosowanemu trikowi, który polega na założeniu, że proces budzony z semafora delay zastanie zamknięty semafor mutex. Przy takim założeniu jasna staje się konieczność bezwarunkowego podniesienia semafora mutex na końcu operacji P(S). Z drugiej strony w kodzie operacji V(S) należy zagwarantować, że obudzony proces faktycznie zastanie opuszczony semafor mutex. Aby to zapewnić w implementacji V(S) mutex jest podnoszony jedynie wówczas, gdy nikogo nie budzimy. Jeśli jednak podnosimy semafor delay, to mutex pozostaje zamknięty --- właśnie po to aby proces obudzony z semafora delay zastał zamknięty semafor mutex.

Co daje taka technika? Wyjaśnijmy to na przykładzie scenariusza, który prowadził do błędnego działania poprzedniej symulacji. Zacznijmy od sytuacji, w której procesy P(1) i P(2) są już w sekcji krytycznej, a P(3) i P(4) tuż przed zawieszeniem się na operacji P(delay). Semafor mutex jest otwarty, a semafor delay --- zamknięty. Zmienna N ma wartość -2. Przypuśćmy, że proces P(1) wychodzi teraz z sekcji krytycznej. Zamyka semafor mutex, zwiększa zmienną N na -1 i podnosi semafor delay. Ale mutex pozostaje zamknięty! Z tego powodu zanim proces P(2) będzie mógł rozpocząć wykonanie kodu za swoją sekcją krytyczną, któryś z procesów P(3) lub P(4) (albo oba) muszą wykonać operację V(delay). Ponieważ delay jest w tym momencie otwarty, więc jeden z procesów przejdzie przez niego, zamykając go za sobą i wykona operację V(mutex). Dopiero wtedy dostęp do fragmentów kodu chronionych semaforem mutex staje się otwarty, a scenariusz powodujący błędne działanie poprzedniej symulacji nie jest już możliwy.

Opisaną technikę w ogólności można ująć w następujący schemat. Zawieszanie procesu realizujemy w następujący sposób (dodatkowa zmienna ile_czeka zlicza ile procesów oczekuje na początkowo zamkniętym semaforze sem:

P(mutex);
...
if warunek then 
begin
  ile_czeka := ile_czeka + 1;
  V(mutex);
  P(sem); { obudzony proces zastanie zamknięty mutex }
  ile_czeka := ile_czeka -1
  ...
end;
...
V(mutex) 

Budzenie procesu realizujemy następująco:

P(mutex)
...
if ile_czeka > 0 then 
  V (sem)
else
  V (mutex)

Opisana technika nosi nazwę dziedziczenia sekcji krytycznej, bo budzony z semafora sem proces dziedziczy wyłączny dostęp do sekcji krytycznej chronionej semaforem mutex po procesie budzącym. Umiejętne stosowanie tej techniki pozwala ograniczyć liczbę możliwych przeplotów i uniknąć wielu błędów synchronizacyjnych poprzez wymuszenie niepodzielności akcji budzenia i wznowienia działania przez proces obudzony.

Mechanizmy scentralizowane. Semafory

Model scentralizowany

Cechy

Przechodzimy teraz do mechanizmów synchronizacji w systemach ze wspólną pamięcią. Model ten różni się od dotychczasowych przede wszystkim tym, że można stosować zmienne współdzielone, czyli dostępne dla wielu procesów, i za ich pomocą wymieniać informacje.
Korzystanie ze zmiennych współdzielonych wymaga jednak dużej ostrożności, jak widzieliśmy to podczas pierwszego wykładu. Niekontrolowane niczym modyfikowanie takich zmiennych w wielu procesach jednocześnie może dawać niedające się przewidzieć efekty. Z tego powodu większość z omawianych w dalszym ciągu mechanizmów gwarantuje atomowość lub niepodzielność pewnych operacji.

Mechanizmy

W dalszej części wykładu omówimy szczegółowo dwie metody synchronizacji procesów w środowisku scentralizowanym: semafory i monitory. Wspomnimy także o mechanizmie zamków (muteksów). Zanim jednak to nastąpi na przykładzie wzajemnego wykluczania wspomnijmy o dwóch innych metodach synchronizacji.
Pierwsza z tym metod jest stosowana wtedy, gdy nie dysponujemy żadnym wsparciem ani ze strony sprzętu ani oprogramowania. Korzystając jedynie z wysokopoziomowych instrukcji języka programowania można zapewnić wzajemne wykluczanie w dostępie do sekcji krytycznej za pomocą m.in. algorytmu Petersona. Jego dokładne omówienie znalazło się w materiałach dotyczących pierwszych ćwiczeń. Przypomijmy jego wady: statycznie znana liczba procesów, koszt, a przede wszystkim aktywne oczekiwanie.
Czasem możemy liczyć na wsparcie sprzętowe. Może to być na przykład specjalny rozkaz maszynowy realizujący niepodzielny zapis i odczyt. Rozpatrzmy dla przykładu rozkaz
Test_and_Set, który odczytuje pewien bit G i ustawia go na jeden. Działanie takiego rozkazu można by zapisać w wysokopoziomowym języku następująco:

procedure Test_and_Set (var L: 0..1);
{ Ta procedura jest atomowa! }
begin
L := G;
G := 1;
end

Problem wzajemnego wykluczania można wówczas rozwiązać następująco:

var
wolny: 0..1 := 0;

process P;
var
i: 0..1;
begin
repeat
własne_sprawy;
repeat
Test_and_Set (i);
until i = 0;
sekcja_krytyczna;
wolny := 0;
until false
end;

To rozwiązanie także nie jest pozbawione wad. Pierwszą z nich jest oczywiście aktywne oczekiwanie, drugą możliwość zagłodzenia procesu.

Semafory jako abstrakcyjny typ danych

Omówienie mechanizmów udostępnianych programowo rozpoczniemy od semaforów. Semafor jest to abstrakcyjny typ danych, na którym można wykonywać jedynie dwie operacje:

Powyższe operacje są niepodzielne i wzajemnie się wykluczają.
Ponadto semafor można raz, poza procesami, zainicjować na dowolną wartość nieujemną.
O semaforze myślimy jak o pewnej zmiennej całkowitej o wartościach nieujemnych. Programista nie ma jednak dostępu do wartości tej zmiennej (poza początkową inicjacją), a operacje na niej wykonuje za pomocą operacji P i V. Ich działanie można zdefiniować na dwa sposoby.

Klasyczna definicja semafora

Klasyczna definicja semafora definiuje semantykę operacji P następująco:

P(S):
zaczekaj aż wartość semafora S będzie dodatnia;
zmniejsz S o 1

Jeśli semafor S ma od razu wartość większą niż 0, to operacja P nie wstrzymuje wywołującego ją procesu. Jeśli S ma wartość równą 0, to proces wykonujący P jest wstrzymywany w sposób nieangażujący procesora. W czasie, gdy proces oczekuje inne procesy mogą rozpocząć wykonanie operacji P (i zostaną także wstrzymane) lub też wykonać operację V (co, jak za chwilę zobaczymy, powoduje zwiększenie wartości zmiennej semaforowej i w konsekwencji może doprowadzić do obudzenia jednego z oczekujących na operacji P procesów).
Operację V definiuje się po prostu tak:

V(S):
S := S + 1

Powyższe definicje wymagają kilku wyjaśnień. Niepodzielność instrukcji V jest zrozumiała: zwiększenie semafora ma się po prostu odbyć w jednym kroku. Co jednak oznacza niepodzielność instrukcji P? Jeśli proces rozpoczyna wykonanie tej operacji i zastaje dodatnią wartość semafora, to zmniejsza jego wartość o 1, a niepodzielność gwarantuje, że operacja ta zostanie wykonana w całości zanim inny proces rozpocznie wykonanie V lub P. Jeśli jednak proces zastanie semafor równy zero to jest wstrzymany. Niepodzielność P nie gwarantuje zatem tego, że proces, który rozpoczął jej wykonanie zakończy je bez wstrzymania swojego działania. Jednak proces wstrzymany oddaje dostęp do operacji P i V, dzięki czemu inny proces może zwiększyć semafor. Któryś z oczekujących procesów (jeden z nich) może "zauważyć" tę zmianę (nie zajmujemy się sposobem implementacji tego mechanizmu) i wznowić wykonanie operacji P. I tutaj ponownie korzystamy z niepodzielności, która gwarantuje, że proces zmniejszy wartość semafora zanim ktokolwiek inny rozpocznie wykonanie P lub V.
Podkreślmy raz jeszcze: P i V to jedyne operacje na semaforze. W szczególności nie ma możliwości sprawdzenia wartości zmiennej semaforowej!.
Operacja V może spowodować obudzenie jednego spośród procesów oczekujących na semaforze. Definicja nie precyzuje jednak, który to będzie proces. Nie można w szczególności założyć, że procesy oczekują na semaforze w kolejce. Aby zapewnić żywotność programów korzystających z semaforów przyjmuje się jednak pewne założenie dotyczące oczekiwania: jest ono sprawiedliwe. Każdy proces oczekujący zostanie w skończonym czasie obudzony, jeśli tylko semafor zostanie podniesiony dostatecznie dużo razy.
W dalszym ciągu wykładu będziemy mówić, że semafor jest zamknięty, jeśli wartość zmiennej semaforowej wynosi 0, w przeciwnym razie semafor będziemy nazywać otwartym. Sformułowanie przejść pod semaforem będzie oznaczać zakończenie wykonania operacji P, a podniesienie semafora to wykonanie na nim operacji V.

Definicja uproszczona

Czasem wpowadza się nieco inną definicję operacji P i V:

P(S):
jeśli S > 0, to S := S - 1, w przeciwnym razie wstrzymaj działanie

V(s):
jeśli są procesy oczekujące na S, to obudź jeden z nich, w przeciwnym razie
S := S + 1

Podstawowa różnica polega na tym, że w definicji uproszczonej nie podnosi się semafora, jeśli są procesy oczekujące. Zamiast tego proces wykonujący operację V budzi jeden z nich. Semafor zostanie podniesiony jedynie wówczas, gdy nikt pod nim nie czeka. Pozostałe założenia dotyczące niepodzielności, atomowości i sprawiedliwości operacji semaforowej pozostają w mocy.
Nazwy operacji semaforowych pochodzą z języka holenderskiego:

Pojawiające się w literaturze nazwu wait i signal rezerwujemy dla operacji na zmiennych warunkowych.

Semafor binarny

Szczególnym przypadkiem semafora jest semafor binarny. Przyjmuje on jedynie wartość 0 lub 1 (zamknięty lub otwarty), a operacje na nim wykonuje się następująco:

P(S):
jeśli S > 0, to S := 0, w przeciwnym razie wstrzymaj działanie

V(S):
jeśli są procesy oczekujące na S, to obudź jeden z nich, w przeciwnym razie
S := 1

Semafor binarny jest oszczędniejszy pamięciowo niż semafor ogólny, gdyż wymaga tylko jednego bitu.
Zauważmy, że powyższa definicja semafora binarnego powoduje, że otwarcie otwartego semafora binarnego nie ma żadnego efektu. Może to prowadzić do subtelnych błędów w programach. Dlatego też niektórzy przyjmują, że operacja V na otwartym semaforze binarnym powoduje błąd.
Semafory będziemy deklarować używając typu semaphore lub binary semaphore. Deklaracje będziemy umieszczać poza procesami. Semafory będą inicjowane od razu w miejscu deklaracji.

Przykład. Wzajemne wykluczanie

Rozwiązanie problemu wzajemnego wykluczania jest bardzo proste:

mutex: binary semaphore := 1;

process P;
begin
repeat
własne_sprawy;
P (mutex);
sekcja_krytyczna;
V (mutex)
until false
end

Jeśli dostęp do sekcji krytycznej może uzyskać jednocześnie co najwyżej k procesów (dla k > 1), zastosujemy semafory ogólne:

mutex: semaphore := k;

process P;
begin
repeat
własne_sprawy;
P (mutex);
sekcja_krytyczna;
V (mutex)
until false
end

Przykład. Produceni i konsumenci

Przypuśćmy, że mamy typ danych Bufor z operacjami Wstaw i Pobierz, implementujący bufor o pojemności N. Chcemy za pomocą semaforów rozwiązać problem wielu producentów i wielu konsumentów dbając o właściwą synchronizację dostępu do bufora.
W tym celu wprowadzimy trzy semafory. Semafor mutex będzie dbać o wzajemne wykluczanie operacji na buforze (nie można jednocześnie wstawiać danych do bufora, żeby dane nie trafiły na to samo miejsce). Oprócz tego za pomocą semafora wolne będziemy "zliczać" wolne miejsca w buforze, a za pomocą semafora zajęte --- będziemy zliczać zajęte miejsca. Przyjmijmy, że bufor jest początkowo pusty. Naturalnym sposobem inicjacji semaforów jest:

mutex: binary semaphore := 1;
wolne: semaphore := N;
zajete: semaphore := 0;
b: Buffer;

Producent przed wstawieniem danych do bufora musi się upewnić, że w buforze jest miejsce. Jeśli tak, to liczba wolnych miejsc się zmniejszy, a zwiększy się liczba miejsc zajętych. Naturalne jest więc zaimplementowanie producenta w następujący sposób:

process Producent;
var
x: integer;
begin
repeat
produkuj (x);
P (wolne);
P (mutex);
wstaw (b, x);
V (mutex);
V (zajete)
until false;
end

Proces konsumenta jest symetryczny:

process Konsument;
var
x: integer;
begin
repeat
P (zajete);
P (mutex);
x := pobierz (b);
V (mutex);
V (wolne)
until false;
end

Symulacja semafora ogólnego binarnymi

W dotychczasowych rozważaniach wprowadziliśmy semafory ogólne i binarne. Powstaje naturalne pytanie, który z tych mechanizmów ma większą siłę wyrazu. Czy program zapisany z wykorzystaniem semaforów binarnych daje się zapisać korzystając jedynie z semaforów ogólnych (i być może dodatkowych "zwykłych" zmiennych) i na odwrót.
Ponieważ każdy semafor binarny jest zarazem semaforem ogólny, więc zamiast semaforów binarnych można zawsze użyć semaforów ogólnych (zakładamy, że nie dochodzi do próby otwarcia otwartego semafora binarnego). Również program korzystający z semaforów ogólnych daje się napisać z użyciem semaforów ogólnych i pewnych dodatkowych zmiennych, jednak stwierdzenie tego faktu nie jest już tak oczywiste.
Aby się o tym przekonać dokonajmy "symulacji" semafora ogólnego S za pomocą zmiennej całkowitej N oraz dwóch semaforów binarnych mutex i delay. Symulacja polega na zaimplementowaniu operacji P(S) oraz V (S) za pomocą operacji na semaforach mutex i delay i zmiennej całkowitej N.
Aby zaimplementować operację P(S) musimy umieć odczytać wartość zmiennej semaforowej. To jednak jest niemożliwe. Dlatego też wartość tę będziemy pamiętać na zmiennej N.
Tak naprawdę zmienna ta będzie pełnić dwie funkcje. Jeśli jest dodatnia, to
zapamiętuje wartość zmiennej semaforowej, jeśli jest ujemna, to jej wartość bezwzględna oznacza liczbę procesów oczekujących pod semaforem N.
Początkowe wartości poszczególnych zmiennych są następujące:

var
mutex: binary semaphore := 1;
delay: binary semaphore := 0;
N := początkowa wartość symulowanego semafora S

Operację P(S) implementujemy następująco:

P (mutex);
N := N - 1;
if N <= -1 then
begin
V (mutex);
P (delay)
end else
V (mutex);

Przyjrzyjmy się tej procedurze. Opuszczenie semafora ogólnego S ma być atomowe i wykluczać się z innymi operacjami na tym semaforze. Procedura zaczyna się więc od zamknięcia semafora mutex, gwarantującego wzajemne wykluczania. Następnie jest zmniejszana zmienna N. Jeśli na skutek tego zmniejszenia otrzyma ona wartość ujemną, to znaczy, że semafor jest zamknięty. Zgodnie z przedstawionymi intuicjami wartość -N oznacza wtedy liczbę procesów oczekujących, więc zmniejszenie N jest uzasadnione.
W takiej sytuacji proces musi jednak poczekać. W tym celu wykonuje operację P na zamkniętym semaforze delay. Ale zanim to uczyni musi podnieść semafor mutex, aby otworzyć dostęp do operacji P(S) i V(S) dla innych procesów (gdybyśmy nie podnieśli semafora mutex doszłoby do zakleszczenia)!
Jeśli semafor był otwarty, to proces kontynuuje swoje działanie bez oczekiwania, więc nie wykonuje operacji P(delay) a jedynie V(mutex).
Operacja V(S) wygląda następująco:

P (mutex);
N := N + 1;
if N <= 0 then V (delay);
V (mutex);

I znów semafor mutex dba o wzajemne wykluczanie i niepodzielność. Zwiększenie zmiennej N jest potrzebne niezależnie od tego czy przed semaforem nikt nie czekał (N >= 0) i w związku z tym trzeba zwiększyć wartość zmiennej semaforowej (czyli zwiększyć N), czy też były procesy oczekujące (N < 0) i jeden z nich należy obudzić, czyli zmniejszyć wartość -N (zwiększając N). Jeśli jednak N było początkowo mniejsze niż zero, to trzeba obudzić jeden z procesów oczekujących na semaforze delay --- stąd operacja V(delay).
Ta z pozoru sensowna symulacja zawiera jednak pewien subtelny błąd. Aby go znaleźć prześledźmy symulację następującego programu:

var
S: semaphore := 2;

proces P(i: 1..4);
begin
własne_sprawy;
P (S);
sekcja_krytyczna;
V (S)
end

Widzimy, że jest to rozwiązanie problemu wzajemnego wykluczania, przy czym w sekcji krytycznej mogą przebywać jednocześnie co najwyżej dwa procesy.
Przepiszmy powyższy program zastępując operacje na semaforze S ich implementacją:

var
N: integer := 2;
mutex : binary semaphore := 1;
delay : binary semaphore := 0;

proces P(i: 1..4);
begin
własne_sprawy;
P (mutex);
N := N - 1;
if N <= -1 then
begin
V (mutex);
P (delay) {*}
end else
V (mutex);
sekcja_krytyczna;
P (mutex);
N := N + 1;
if N <= 0 then V (delay);
V (mutex);
end

Przypuśćmy, że do sekcji krytycznej usiłują wejść dwa procesy: P(1) i P(2). Oczywiście udaje im się to. Zmienna N zostaje zmniejszona o 2 ma więc wartość 0, semafor delay jest nadal zamknięty, a mutex otwarty. Popatrzmy, co się stanie, jeśli proces P(3) będzie usiłował wejść do sekcji krytycznej. Zamyka on semafor mutex, zmniejsza N o 1 stwierdza, że N = -1 więc podnosi semafor mutex i ma się zawiesić na semaforze delay. Jest to zgodne z oczekiwaniami, gdyż sekcja krytyczna jest już zajęta przez dwa procesy. Przypuśmy jednak, że zanim proces zawiesi się w wierszu oznaczonym * analogiczny ciąg instrukcji wykona proces P(4). Mamy wówczas następującą sytuację:

Procesy P(1) i P(2) są w sekcji krytycznej.
Procesy P(3) i P(4) są tuż przed wykonaniem instrukcji (*).
Zmienna N ma wartość -2.
Semafor mutex jest otwarty.
Semafor delay jest zamknięty.

Przypuśmy, że teraz procesy P(1) i P(2) po kolei wychodzą z sekcji krytycznej. Proces P(1) zamyka semafor mutex , zwiększa N o jeden na -1, wykonuje operację V (delay) i V(mutex). Sytuacja jest następująca:

Proces P(2) jest w sekcji krytycznej.
Procesy P(3) i P(4) są tuż przed wykonaniem instrukcji (*).
Zmienna N ma wartość -1.
Semafor mutex jest otwarty.
Semafor delay jest otwarty.

Teraz proces P(2) wykonuje analogiczny fragment kodu: zamyka semafor mutex, zwiększa N o 1 i ... podnosi semafor delay! Ale semafor ten jest już otwarty. W zależności od semantyki powoduje to błąd lub nie daje efektu. Przypuśćmy, że nic się nie zmienia.
Proces P(2) kontynuuje otwierając semafor mutex, co prowadzi do:

Procesy P(3) i P(4) są tuż przed wykonaniem instrukcji (*).
Zmienna N ma wartość 0.
Semafor mutex jest otwarty.
Semafor delay jest otwarty.

Teraz swoje działanie kontynuuje proces P(3). Wykonuje operację P (delay) i wchodzi do sekcji krytycznej:

Proces P(3) jest w sekcji krytycznej.
Proces P(4) jest tuż przed wykonaniem instrukcji (*).
Zmienna N ma wartość 0.
Semafor mutex jest otwarty.
Semafor delay jest zamknięty.

Gdy proces P(4) spróbuje kontynuować swoje działanie, to wykona operację P(delay) i zostanie wstrzymany, mimo że w sekcji krytycznej jest jeszcze miejsce!. Pokazaliśmy zatem scenariusz ilustrujący błędne (to znaczy niezgodne z oryginalnym programem) działanie symulacji.

Dziedziczenie sekcji krytycznej

Opisaną powyżej symulację na szczęście można łatwo poprawić. Zaskakujący jest przy tym sposób ---- z pozoru bezsensowny, a w rzeczywistości prowadzący do ważnej techniki konstruowania programów wykorzystujących semafory.
Zestawmy dotychczasową implementację operacji P(S) i V(S) z nową, poprawną wersją:

własne_sprawy;
P (mutex);
N := N - 1;
if N <= -1 then
begin
V (mutex);
P (delay)
end else
V (mutex);
sekcja_krytyczna;
P (mutex);
N := N + 1;
if N <= 0 then V (delay);
V (mutex);

własne_sprawy;
P (mutex);
N := N - 1;
if N <= -1 then
begin
V (mutex);
P (delay)
end;
V (mutex);
sekcja_krytyczna;
P (mutex);
N := N + 1;
if N <= 0 then V (delay) else
V (mutex);

Przyjrzyjmy się implementacji operacji P(S):

P (mutex);
N := N - 1;
if N <= -1 then
begin
V (mutex);
P (delay)
end;
V (mutex);

Jeśli warunek jest spełniony, to semafor mutex jest zamykany raz a otwierany dwukrotnie! Analogicznie w V(S):

P (mutex);
N := N + 1;
if N <= 0 then V (delay) else
V (mutex);

może się zdarzyć opuszczenie semafora mutex bez jego podniesienia!
Pomimo tego implementacja jest poprawna. Przyjrzyjmy się bliżej zastosowanemu trikowi, który polega na założeniu, że proces budzony z semafora delay zastanie zamknięty semafor mutex. Przy takim założeniu jasna staje się konieczność bezwarunkowego podniesienia semafora mutex na końcu operacji P(S). Z drugiej strony w kodzie operacji V(S) należy zagwarantować, że obudzony proces faktycznie zastanie opuszczony semafor mutex. Aby to zapewnić w implementacji V(S) mutex jest podnoszony jedynie wówczas, gdy nikogo nie budzimy. Jeśli jednak podnosimy semafor delay, to mutex pozostaje zamknięty --- właśnie po to aby proces obudzony z semafora delay zastał zamknięty semafor mutex.
Co daje taka technika? Wyjaśnijmy to na przykładzie scenariusza, który prowadził do błędnego działania poprzedniej symulacji. Zacznijmy od sytuacji, w której procesy P(1) i P(2) są już w sekcji krytycznej, a P(3) i P(4) tuż przed zawieszeniem się na operacji P(delay). Semafor mutex jest otwarty, a semafor delay --- zamknięty. Zmienna N ma wartość -2. Przypuśćmy, że proces P(1) wychodzi teraz z sekcji krytycznej. Zamyka semafor mutex, zwiększa zmienną N na -1 i podnosi semafor delay. Ale mutex pozostaje zamknięty! Z tego powodu zanim proces P(2) będzie mógł rozpocząć wykonanie kodu za swoją sekcją krytyczną, któryś z procesów P(3) lub P(4) (albo oba) muszą wykonać operację V(delay). Ponieważ delay jest w tym momencie otwarty, więc jeden z procesów przejdzie przez niego, zamykając go za sobą i wykona operację V(mutex). Dopiero wtedy dostęp do fragmentów kodu chronionych semaforem mutex staje się otwarty, a scenariusz powodujący błędne działanie poprzedniej symulacji nie jest już możliwy.
Opisaną technikę w ogólności można ująć w następujący schemat. Zawieszanie procesu realizujemy w następujący sposób (dodatkowa zmienna ile_czeka zlicza ile procesów oczekuje na początkowo zamkniętym semaforze sem:

P(mutex);
...
if warunek then
begin
ile_czeka := ile_czeka + 1;
V(mutex);
P(sem); { obudzony proces zastanie zamknięty mutex }
ile_czeka := ile_czeka -1
...
end;
...
V(mutex)

Budzenie procesu realizujemy następująco:

P(mutex)
...
if ile_czeka > 0 then
V (sem)
else
V (mutex)

Opisana technika nosi nazwę dziedziczenia sekcji krytycznej, bo budzony z semafora sem proces dziedziczy wyłączny dostęp do sekcji krytycznej chronionej semaforem mutex po procesie budzącym. Umiejętne stosowanie tej techniki pozwala ograniczyć liczbę możliwych przeplotów i
uniknąć wielu błędów synchronizacyjnych poprzez wymuszenie niepodzielności akcji budzenia i wznowienia działania przez proces obudzony.

Semafory cd.

Semafory

Przykład. Pięciu filozofów. Wersja 1.

Popatrzmy teraz, jak za pomocą semaforów można zapisać różne wersje rozwiązań problemu pięciu filozofów. Zacznijmy od wersji najprostszej. Role widelców grają semafory binarne, przy czym semafor otwarty oznacza, że widelec jest dostępny, a semafor zamknięty, że widelcem ktoś już się posługuje. Każdy filozof najpierw bierze widelec po swojej lewej stronie, potem po stronie prawej:

var
  wid: array [0..4] of binary semaphore := (1, 1, 1, 1, 1); 

process Filozof (i: 0..4);
begin
  repeat
    Myśli;
    P (wid[i]);
    P (wid[(i+1) mod 5];
    Je;
    V (wid[i]);
    V (wid[(i+1) mod 5];
  until false
end;
   

Oczywiście z powodów już uprzednio omawianych ten program powoduje zakleszczenie filozofów.

Przykład. Pięciu filozofów. Wersja 2.

Przypomnijmy, że rozwiązanie poprawne polega na dopuszczeniu do stołu co najwyżej 4 filozofów. W tym celu do poprzedniego przykładu dodamy semafor ogólny lokaj zainicjowany na 4:

var
  lokaj: semaphore := 4;
  wid: array [0..4] of binary semaphore := (1, 1, 1, 1, 1); 

process Filozof (i: 0..4);
begin
  repeat
    Myśli;
    P (lokaj);
    P (wid[i]);
    P (wid[(i+1) mod 5];
    Je;
    V (wid[i]);
    V (wid[(i+1) mod 5];
    V (lokaj)
  until false
end;

Przykład. Pięciu filozofów. Wersja 3.

Spróbujmy jeszcze zapisać rozwiązanie, w którym filozofowie podnoszą jednocześnie oba widelce. Przypomnijmy, że taki algorytm może prowadzić do zagłodzenia filozofa przez jego sąsiadów. Zobaczmy jednak, jakiego rodzaju problemy techniczne występują przy zapisie tego rozwiązania za pomocą semaforów.

Po pierwsze wprowadzamy tablicę stanów poszczególnych filozofów:

stan: array [0..4] of (myśli, głodny, je) 
  := (myśli, myśli, myśli, myśli, myśli);
mutex: binary semaphore := 1;
furtka: array [0..4] of binary semaphore
  := (0, 0, 0, 0, 0);

Każdy filozof ma swój własny semafor furtka. Jest on początkowo zamknięty. Każdy filozof sprawdza, czy może rozpocząć jedzenie i jeśli tak, to otwiera sobie ten semafor. Po zakończeniu jedzenia i odłożeniu widelców, filozof sprawdza, czy jego sąsiedzi są głodni i mają już komplet widelców. Jeśli tak, to ich budzi.

Procedura test zajmuje się sprawdzeniem, czy i-ty filozof może rozpocząć jedzenie:

procedure test (i: 0..4);
begin
  if stan[i] = głodny and 
           stan[(i+1) mod 5] <> je and
           stan[(i+4) mod 5] <> je then
  begin
    stan[i] := je;
    V (furtka[i]
  end
end; 

Treść procesu filozof wygląda następująco:

process Filozof (i:0..4);
begin
  repeat
    myśli;
    P(mutex);
    stan[i] := głodny;
    test (i);
    V(mutex);
    P (furtka[i]);
    je;
    stan[i] := myśli;
    test ((i+4) mod 5);
    test ((i+1) mod 5);
    V (mutex);
  until false
end

Semafory w systemie Unix

System operacyjny Unix oferuje implementację semaforów w ramach pakietu InterProcess Communication (IPC). Funkcjonalność semaforów uniksowych znacznie wykracza poza klasyczne funkcje. Przede wszystkim operacje podnoszenia i opuszczania semaforów można wykonywać nie tylko o jedną jednostkę, ale o dowolną wartość. Abstrahując od rzeczywistego sposobu zapisu operacji na semaforach w języku C, oznaczmy operacje zwiększenia i zmniejszenia semafora uniksowego S o n przez odpowiednio V(S, n) i P (S, n).

Operacja V(S, n) jest nieblokująca. Proces zawsze może ją wykonać. Operacja P(S, n) zatrzymuje proces, jeśli aktualna wartość semafora jest mniejsza niż S, a zmienna semaforowa nie zmienia się. Dopiero gdy proces doczeka się na wartość co najmniej n, to zmniejsza wartość semafora o n i kończy wykonanie operacji P(S, n).

Przypuśćmy, że aktualną wartością zmiennej semaforowej jest m. Przypuśćmy, że pewien proces oczekuje na wykonanie operacji P (S, n) (bo m < n). Jeśli teraz inny proces wywoła operację P(S, k), przy k < m, to wykona on operację P(s, k) bez zawieszania. Taka implementacja może prowadzić do głodzenia procesów oczekujących na operacji P (S, n) z dużym n.

Dodatkową operacją, którą można wykonywać na semaforach uniksowych jest operacja oczekiwania aż semafor będzie miał wartość 0. Oznaczmy tę operację przez Z(S).

Zestawy semaforów i jednoczesne operacje na nich

Semafory uniksowe występują w zestawach złożonych z jednego lub więcej semaforów. Unix umożliwia wykonywanie jednoczesnych operacji na semaforach należących do tego samego zestawu. Takie jednoczesne operacje będziemy w dalszym ciągu zapisywać ujmując je w nawiasy kwadratowe. Semantyka operacji jednoczesnych [op(1), op(2), ..., op(n)] jest następująca.

System operacyjny próbuje wykonać operacje w kolejności op(1), op(2), ..., op(n). Jeśli i-ta operacja nie powoduje zawieszenia procesu, to przechodzi do wykonania operacji i+1. Jeśli jednak i-ta operacja powoduje wstrzymanie procesu, to system anuluje już wykonane operacje op(1), ..., op(i-1) przed zawieszeniem procesu. Z punktu widzenia procesu efekt jest taki, jakby zostały wykonane wszystkie operacje albo żadna. Po obudzeniu proces ponownie próbuje wykonać wszystkie operacje od op(1) do op(n).

Z powyższego opisu wynika, że kolejność operacji przeznaczonych do jednoczesnego wykonania jest istotna. Popatrzmy na przykład. Operacja [V(S, 1), P(S, 1)] jest nieblokująca: najpierw bowiem otwieramy semafor, a potem próbujemy przejść pod otwartym semaforem, co się zawsze uda. Oczywiście taka operacja nie ma żadnego efektu na semafor S. Z kolei [P(S, 1), V(S, 1)] również nie zmieni wartości semafora S, ale wstrzyma proces, jeśli semafor jest zamknięty!

Operacje P i Z na semaforach uniksowych mają także swoje nieblokujące wersje. Polegają one na tym, że zamiast wstrzymywać proces, kończą się one natychmiast przekazując informacje o niepowodzeniu. Ponadto istnieje możliwość ustawienia wartości semafora i odczytania go oraz sprawdzenia, ile procesów oczekuje na poszczególnych operacjach.

Przykład. Czytelnicy i pisarze. Wersja 1

Zilustrujmy działanie jednoczesnych operacji semaforowych do rozwiązania problemu czytelników i pisarza. Przypuśćmy, że mamy zestaw semaforów złożony z dwóch semaforów: CZYT oraz PIS. Semafor CZYT zlicza czytelników przebywających w czytelni, a semafor PIS --- pisarzy w czytelni. Czytelnik przed wejściem do czytelni powinien niepodzielnie upewnić się, że nie ma w niej pisarza i zwiększyć licznik czytelników. Prowadzi to do następującego kodu:

'process Czytelnik;
begin
  repeat
    własne_sprawy;
    [Z(PIS), V(CZYT)]
    czytanie;
    P(CZYT)
  until false
end

Proces pisarza musi upewnić się, że w czytelni nie ma nikogo i dopiero wówczas może do niej wejść zwiększając licznik pisarzu:

'process Pisarz;
begin
  repeat
    własne_sprawy;
    [Z(PIS), Z (CZYT), V(PIS)]
    czytanie;
    P(PIS)
  until false
end

Niestety, przedstawione rozwiązanie, choć ma własność bezpieczeństwa, nie gwarantuje żywotności pisarzy. Może się bowiem zdarzyć, że ciągły napływ czytelników spowoduje, że czytelnia nigdy nie będzie pusta i pisarze będą w nieskończoność oczekiwać na dostęp do czytelni.

Przykład. Czytelnicy i pisarze. Wersja 2

Spróbujmy poprawić rozwiązanie. Niech semafor CZYT w dalszym ciągu zlicza czytających czytelników. Zamiast semafor zliczającego pisarzy tym razem zastosujemy semafor POCZ chroniący dostęp do poczekalni. Każdy proces musi najpierw przejść przez poczekalnie. Proces pisarza po wejściu do poczekalni blokuje do niej dostęp, czeka aż wszyscy czytający czytelnicy opuszczą czytelnię, rozpoczyna pisanie i po jego zakończeniu zwalnia dostęp do poczekalni. Początkowo semafor CZYT jest równy zero, a semafor POCZ jest inicjowany na 1.

'process Pisarz;
begin
  repeat
    własne_sprawy;
    P(POCZ);
    Z(CZYT);
    pisanie;
    V(POCZ)
  until false
end

Proces czytelnika wchodzi do poczekalni nie blokując dostępu do niej dla innych i rozpoczyna czytanie zwiększając licznik czytelników. Po wyjściu z czytelni zmniejsza semafor CZYT.

'process Czytelnik;
begin
  repeat
    własne_sprawy;
    [P(POCZ), V(POCZ), V(CZYT)]
    czytanie;
    P(CZYT)
  until false
end

Inne rodzaje semaforów

W literaturze pojawiają się też inne rodzaje semaforów. Omówmy najważniejsze z nich.

Semafory dwustronnie ograniczone

Inspiracją dla semaforów dwustronnie ograniczonych był problem producenta i konsumenta. Semafory dwustronnie ograniczone charakteryzują się symetrycznymi operacjami P i V. Obie operacje mogą spowodować wstrzymanie procesu. Dodatkowym parametrem semafora dwustronnie ograniczonego jest maksymalna wartość k, jaką może przyjmować zmienna semaforowa.

Operacje na semaforze dwustronnie ograniczonym definiuje się w następujący sposób:

 Czekaj aż S >0;
 S := S - 1;
 Czekaj aż S < k;
 S := S + 1

Oczywiście założenia dotyczące niepodzielności i ich wzajemnym wykluczaniu się pozostają w mocy.

Semafory dwustronnie ograniczone nie zwiększają mocy wyrazu zwykłych semaforów --- można je zaimplementować za pomocą dwóch semaforów ogólnych.

Semafory uogólnione

Semafor uogólniony w odróżnieniu od semafora ogólnego umożliwia wykonywanie operacji P i V o większą liczbę jednostek, a niekoniecznie o 1.

 Czekaj aż S > n;
 S := S - n;
 S := S + n

Jak już wspomnieliśmy przy omówieniu semaforów uniksowych, dość ostrożnie należy podchodzić do implementacji takich semaforów ze względu na możliwość głodzenia procesów, próbujących wykonać operację P(S, n) z dużą wartością n. Implementację gwarantującą żywotność przedstawiamy na ćwiczeniach.

Semafory Agerwali

Semafory Agerwali wprowadzają jednoczesne operacje na zwykłych semaforach, przy czym wykonanie operacji zamykania zależy od stanu innych semaforów. Operacja P jako argument bierze dwie grupy semaforów:

P(S1, S2, ..., Sn; T1, T2, ..., Tm):
 Czekaj aż wszystkie semafory S będą otwarte, a wszystkie semafory T zamknięte;
 for i := 1 to n do
   Si := Si - 1

Operacja V podnosi wszystkie przekazane jej jako argumenty semafory:

V(S1, ..., Sn): 
 for i := 1 to n do
   Si := Si + 1
 

Motywacją dla semaforów Agerwali było m.in. wprowadzenie priorytetowego dostępu do zasobów. Przypuśćmy, że w systemie znajduje się jeden zasób, z którego korzystają procesy P(1..N). Jednocześnie z zasoby może korzystać co najwyżej jeden proces. Jeśli zasób staje się wolny, a oczekuje na niego wiele procesów, to pierwszeństwo ma ten o mniejszej wartości parametru.

Rozwiązanie polega na wykorzystaniu semaforów Agerwali. Z każdym procesem związany jest jego prywatny semafor chce. Jego podniesienie oznacza, że proces chce skorzystać z zasobu i jeszcze go nie otrzymał. Oprócz tego korzystamy z semafora mutex gwarantującego wyłączny dostęp do zasobu:

var
  chce: array [1..N] of binary semaphore 
    := (0, 0, ..., 0);
  mutex: binary semaphore := 1;

Tekst procesu możemy wówczas zapisać następująco:

process P (i: 1..N);
begin
  repeat
    własne_sprawy;
    V (chce[i]);
    P (mutex; chce[1], ..., chce[i-1]);
    P (chce[i]);
    sekcja_krytyczna;
    V (mutex)
  until false
end

Monitory

Monitory

Podstawową wadą semafora jest to, że nie jest to mechanizm strukturalny, przez co trudno jest analizować programy współbieżne i ogarnąć wszystkie możliwe przeploty. Dochodzą do tego częste błędy polegające chociażby na niezamierzonej zamianie operacji P i V ze sobą.

Monitor jako moduł programistyczny

Powyższych wad jest pozbawiony monitor, który stanowi połączenie modułu programistycznego z sekcją krytyczną. Monitor jest po prostu modułem zawierającym deklaracje stałych, zmiennych, funkcji i procedur. Wszystkie te obiekty, z wyjątkiem jawnie wskazanych funkcji i procedur są lokalne w monitorze i nie są widoczne na zewnątrz niego. Wskazane funkcje i procedury (tzw. eksportowane) są widoczne na zewnątrz monitora. Mogą je wywoływać procesy i za ich pośrednictwem manipulować danymi ukrytymi w monitorze. Monitor zawiera też kod, który służy do jego inicjacji, na przykład do ustawienia wartości początkowych zmiennych deklarowanych w monitorze.

Monitor będziemy definiować korzystając z następującej składni:

monitor Nazwa;
  definicje stałych i typów;
  deklaracje zmiennych lokalnych;
  deklaracje procedur i funkcji;
begin
  instrukcje inicjujące monitor;
end;

Definiując stałe, typy, zmienne i procedury będziemy stosować składnię Pascala z tym, że procedury i funkcje udostępniane na zewnątrz będziemy poprzedzać słowem kluczowym export.

Aby wywołać eksportowaną przez monitor M procedurę P w pewnym procesie użyjemy notacji:

 M.P (parametry)

Z powyższego opisu wynika, że monitor jest po prostu modułem, ukrywającym część informacji i udostępniającym na zewnątrz pewne funkcje i procedury. Ale to nie są jeszcze wszystkie własności monitora.

Monitor jako sekcja krytyczna

Monitor jest z definicji sekcją krytyczną. Co to oznacza? Otóż jednocześnie co najwyżej jeden proces może być w trakcie wykonania kodu znajdującego się w monitorze. Innymi słowy: jeśli jakiś proces wywoła procedurę eksportowaną przez monitor M i rozpocznie jej wykonanie, to do czasu powrotu z tej procedury żaden inny proces nie może rozpocząć wykonania tej ani żadnej innej procedury/ funkcji monitora. O procesie, który wywołał funkcję/procedurę monitora i nie zakończył jeszcze jej wykonania, będziemy mówić, że znajduje się w monitorze. Zatem jednocześnie w monitorze może przebywać co najwyżej jeden proces.

Taka definicja narzuca sposób naturalny sposób korzystania ze zmiennych współdzielonych przez procesy. Po prostu należy umieścić je w monitorze i dostęp do nich realizować za pomocą eksportowanych procedur i funkcji. Programista korzystający w taki właśnie sposób ze zmiennej dzielonej nie musi myśleć o zapewnianiu wyłączności w dostępie do niej --- robi to za niego automatycznie sam monitor.

Co się jednak dzieje, jeśli pewien proces wywoła procedurę monitora w chwili, gdy inny proces już się w nim znajduje? Otóż taki proces musi poczekać. Z każdym monitorem jest związana kolejka procesów oczekujących na "wejście" do niego. Jest to kolejka prosta: proces, który zgłosił się jako pierwszy i został wstrzymany przed wejściem do monitora, jako pierwszy zostanie wznowiony. Zaznaczmy, niezależnie od tego, którą procedurę czy funkcję monitora proces wywołał, zostanie on w razie konieczności wstrzymany i umieszczony w tej samej kolejce. Jest jedna kolejka procesów oczekujących na wejście do monitora, a nie tak jak w Adzie, osobne kolejki do poszczególnych procedur eksportowanych.

Gdy proces, który przebywał w monitorze, opuści go, czyli gdy następuje powrót z wywołanej funkcji/procedury monitora do procesu, pierwszy proces w kolejce procesów oczekujących na wejście do monitora jest automatycznie budzony. I znów dzieje się to automatycznie, programista nie musi się o to martwić.

Zmienne warunkowe

Często oprócz zapewnienia wyłącznego dostępu do zmiennych dzielonych zachodzi także potrzeba synchronizacji procesów w inny sposób. Proces musi na przykład poczekać na dostępność pewnych zasobów lub zakończenie przetwarzania przez inne procesy. Aby realizować tego typu zadania, monitor oferuje specjalny typ danych, tzw. zmienne warunkowe.

Zmienne warunkowe deklarujemy jako zmienne typu condition. Mogą one wystepować jedynie wewnątrz moniora. Na zmiennej warunkowej c proces może wykonać następujące operacje:

  1. wait (c). Powoduje wstrzymanie działania procesu, który ją wykonał. Proces opuszcza chwilowo monitor (tym samym może do niego wejść inny proces z kolejki procesów oczekujących na wejście) i zostaje wstrzymany w kolejce prostej związanej ze zmienną warunkową c.
  2. empty(c). Jest to funkcja logiczna przekazująca w wyniku informacje, czy są procesy oczekujące na zmiennej warunkowej c.
  3. signal(c). Powoduje obudzenie pierwszego procesu z kolejki procesów oczekujących na zmiennej warunkowej c. Jeśli kolejka jest pusta, to instrukcja nie daje żadnego efektu. Obudzony proces natychmiast wznawia swoje działanie w monitorze od kolejnej instrukcji za wait, w którym został wstrzymany.

Uproszczona semantyka instrukcji signal

Bliższa analiza działania operacji signal prowadzi do niepokojących spostrzeżeń. Zgodnie z powyższą semantyką signal (c) budzi pierwszy proces z kolejki procesów oczekujących w kolejce związaną ze zmienną c. Obudzony proces ma natychmiast wznowić działanie w monitorze. Ale przecież w monitorze jest już inny proces: ten, który wykonał signal. Mamy więc dwa procesy w monitorze, co przeczy temu, że monitor jest sekcją krytyczną.

Aby poradzić sobie z tym problemem przyjmijmy na razie, że signal musi być ostatnią instrukcją wykonywaną przez proces w monitorze. Wtedy proces po wykonaniu operacji signal wychodzi z monitora oddając go obudzonemu procesowi. Podkreślmy to raz jeszcze. Jeśli proces wykonuje signal na niepustej kolejce i wychodzi z monitora, to do monitora wróci obudzony proces przed jakimikolwiek procesami oczekującymi na wejście do monitora. Jest to bardzo ważna i pożyteczna cecha monitora.

Przykład. Wzajemne wykluczanie.

Popatrzmy teraz, jak zazwyczaj korzysta się z monitorów. W zasadzie każdy problem synchronizacyjny można przedstawić w postaci następującego schematu procesu:

process P;
begin
  repeat
    praca nie wymagająca synchronizacji;
    protokół wstępny;
    fragment wymagający synchronizacji;
    protokół końcowy
  until false;
end;

Protokoły wstępny i końcowy zazwyczaj wymagają wzajemnego wykluczania. Naturalnym podejściem jest zatem umieszczenie ich w monitorze w postaci eksportowanych procedur. Zmienne potrzebne do zapewnienia właściwej synchronizacji umieszczamy także w tym samym monitorze, jako zmienne lokalne.

Spójrzmy, jak takie podejście sprawdza się przy problemie dostępu do sekcji krytycznej. Przypuśćmy, że z sekcji krytycznej może jednocześnie korzystać co najwyżej k procesów. Kod procesu jest oczywisty:

process P;
begin
  repeat
    własne_sprawy;
    M.Chce;
    sekcja_krytyczna;
    M.Zwalniam; 
  until false;
end;
 

W monitorze M umieścimy zmienną zliczającą aktualną liczbę wolnych miejsc w sekcji krytycznej, zmienną warunkową czekaj służącą do wstrzymywania procesów, gdy nie ma dla nich miejsca w sekcji krytycznej:

monitor M; 
var
  wolnych: integer;
  czekaj: condition;

Następnie implementujemy procedurę Chce. Przygotowując procedury monitorowe, a zwłaszcza protokoły wstępne, dobrze jest wychwycić warunek na wstrzymanie procesu i zastosować instrukcję warunkową, która przy prawdziwym warunku powoduje zawieszenie procesu. Dalsze czynności wykonywane przez proces w protokole wstępnym często nie zależą od tego, czy proces czekał, czy nie. Z technicznego punktu widzenia oznacza to, że we wspomnianej instrukcji warunkowej najczęściej nie jest potrzebna część po else.

W omawianym przekładzie proces powinien poczekać, jeśli nie ma wolnych miejsc. Gdy będą już wolne miejsca, to należy zmniejszyć ich liczbę i wyjść z monitora:

export procedure Chce;
begin
  if wolnych = 0 then
    wait (czekaj);
  wolnych := wolnych - 1
end;

Protokół końcowy zwalnia jedno miejsce i budzi proces oczekujący na wejście do sekcji krytycznej:

export procedure Zwalniam;
begin
  wolnych := wolnych + 1;
  signal (czekaj)
end;

Monitor wymaga również zainicjowania zmiennych:

 begin
   wolnych := k
 end.

Zwróćmy uwagę, że w procedurze Zwalniam sygnał jest wysyłany bezwarunkowo. Jeśli nikt nie czekał na warunku czekaj, to nie stanie się nic złego, gdyż taki sygnał nie powoduje żadnego efektu.

Przykład. Czytelnicy i pisarze

Przeanalizujmy jeszcze rozwiązanie problemu czytelników i pisarzy. Tak jak w poprzednim przykładzie, protokoły wstępne i końcowe czytelnika i pisarza umieścimy w monitorze, co prowadzi do następującego kodu procesów:

proces Czytelnik;
begin
  repeat
    własne_sprawy;
    Czytelnia.ChceCzytać;
    czytanie;
    Czytelnia.KoniecCzytania;
  until false;
end;
proces Pisarz;
begin
  repeat
    własne_sprawy;
    Czytelnia.ChcePisać;
    pisanie;
    Czytelnia.KoniecPisania;
  until false;
end;

Monitor Czytelnia zwiera dwie zmienne warunkowe: do wstrzymywania czytelników i pisarzy. Oprócz tego jest potrzebna zmienna pamiętająca ilu czytelników czyta i ilu pisarzy pisze. Ta druga zmienna mogłaby być zmienną logiczną, ale dla symetrii niech obie zmienne będą zmiennymi całkowitymi:

monitor Czytelnia;
var
  iluczyta: integer;
  ilupisze: integer;
  czytelnicy: condition;
  pisarze: condition;

Rozpocznijmy od protokołu wstępnego pisarza. Pisarz musi czekać, jeśli ktokolwiek jest w czytelni:

export procedure ChcePisać;
begin
  if iluczyta+ilupisze> 0 then
    wait (pisarze);
  ilupisze := ilupisze + 1
end;

Protokół wstępny czytelnika jest podobny. Czytelnik musi poczekać, jeśli ktoś pisze lub --- aby nie zagłodzić pisarzy --- są pisarze oczekujący na pisanie. Instrukcję oznaczoną gwiazdką wyjaśnimy nieco później:

export procedure ChceCzytać;
begin
  if (ilupisze > 0) or not empty (pisarze) then
    wait (czytelnicy);
  iluczyta := iluczyta + 1;
  signal (czytelnicy)   {*}
end;

Protokół końcowy czytelnik jest prosty. Jeśli jest on ostatnim wychodzącym z czytelni, to budzi pisarza:

export procedure KoniecCzytania;
begin
  iluczyta := iluczyta - 1;
  if iluczyta = 0 then
    signal (pisarze)   
end;

Z protokołem końcowym pisarza jest jednak pewien problem. Umówiliśmy się, że operacja signal jest ostatnią operacją wykonywaną w monitorze. Tymczasem pisarz powinien wpuścić wszystkich oczekujących czytelników, o ile są jacyś, lub jednego pisarza, jeśli nie czekają czytelnicy. Wpuszczenie wszystkich oczekujących czytelników wymagałoby wykonanie operacji signal w pętli, a to powoduje, że signal nie jest ostatnią operacją monitora. Spróbujmy zastosować inne rozwiązanie. Niech pisarz budzi jednego czytelnika, o ile są oczekujący czytelnicy. Zauważmy, że obudzony czytelnik, dzięki dodanej do protokołu wstępnego instrukcji oznaczonej {*} obudzi kolejnego czytelnika, ten kolejny czytelnik obudzi następnego itd. aż do obudzenia wszystkich. Taką technikę będziemy nazywać kaskadowym zwalnianiem:

export procedure KoniecPisania;
begin
  ilupisze := ilupisze - 1;
  if not empty (czytelnicy) then
    signal (czytelnicy) 
  else
    signal (pisarze)   
end;
  

Pozostała jeszcze część inicjująca monitor:

begin
  iluczyta := 0; ilupisze := 0;
end.

Pełna semantyka instrukcji signal

Umieszczenie operacji signal na końcu kodu procesu nie jest jednak zawsze możliwe. Aby móc stosować signal także wewnątrz procedury monitora trzeba uściślić semantykę operacji signal w następujący sposób. Signal(c) powoduje obudzenie pierwszego procesu z kolejki procesów oczekujących na zmiennej warunkowej c. Jeśli kolejka jest pusta, to instrukcja nie daje żadnego efektu. W przeciwnym razie obudzony proces natychmiast wznawia swoje działanie w monitorze od kolejnej instrukcji za wait, w którym został wstrzymany, a proces wykonujący operację signal opuszcza tymczasowo monitor i zostaje umieszczony na początku kolejki procesów oczekujących na wejście do monitora.

Dzięki temu, że proces budzący opuszcza monitor, proces obudzony może wznowić swoje działanie bez obawy, że nie będzie jedynym procesem w monitorze. Z drugiej strony, umieszczenie budzącego na początku kolejki na wejściu do monitora zapewnia, że zostanie on wznowiony przed procesami oczekującymi na wejście do monitora, gdy tylko obudzony przez niego proces wyjdzie z niego.

Taka semantyka powoduje jednak pewne problemy. Kolejność instrukcji w, z pozoru niepodzielnej procedurze monitora, zaczyna mieć istotne znaczenie. Aby to zilustrować przyjrzyjmy się następującemu rozwiązaniu problemu wzajemnego wykluczania (z jednym procesem w sekcji krytycznej):

monitor M;
var
  wolne: boolean; 
  czekaj: condition;

export procedure Chce;
begin
  if not wolne then
    wait (czekaj);
  wolne := false
end;
export procedure Zwalniam;
begin
  wolne := true;
  signal (czekaj)
end;
begin
  wolne := true
end.

Powyższe rozwiązanie jest poprawne. Zamieńmy jednak kolejność instrukcji w procedurze Zwalniam:

export procedure Zwalniam;
begin
  signal (czekaj);
  wolne := true
end;

Program przestał być poprawny! Przypuśćmy, że proces P(1) wykona procedurę Chce. Spowoduje to ustawienie zmiennej wolne na false, a proces P(1) kończy wykonanie procedury Chce i wchodzi do sekcji krytycznej. Jeśli teraz proces P(2) wywoła procedurę Chce, to zostanie wstrzymany na zmiennej czekaj. Niech proces P(1) opuści teraz sekcję krytyczną. Wywoła wówczas procedurę Zwalniam. Pierwszą instrukcją jest signal. Zatem proces P(1) wyjdzie na chwilę z monitora, a obudzony zostanie proces P(2), który wznowi swoje działanie w procedurze Chce i ustawi zmienną wolne na true, po czym wyjdzie z monitora i przejdzie do sekcji krytycznej. Ponieważ monitor jest pusty, więc wraca do niego proces P(1), który wykona wolne := true. Efekt: proces P(2) jest w sekcji krytycznej, a zmienna wolne ma wartość true. Jeśli teraz proces P(1) ponownie wywoła procedurę Chce, to zostanie wpuszczony do sekcji krytycznej, mimo że przebywa tam już inny proces.


Specyfikowanie i weryfikacja własności programów współbieżnych. CTL

Poprawność programów współbieżnych

Do tej pory nie zajmowaliśmy się w sposób formalny poprawnością programów współbieżnych. Wspomnieliśmy jedynie, że rozważa się dwie formy poprawności: bezpieczeństwo (albo inaczej zapewnianie) oraz żywotność.

Obecnie zaprezentujemy jedno z wielu możliwych podejść do weryfikacji programów współbieżnych. Pokażemy, w jaki sposób można specyfikować własności bezpieczeństwa i żywotności w logice temporalnej o nazwie Computational Tree Logic. Ponieważ jest to logika rozstrzygalna pokażemy też w jaki sposób, poprzez badanie modeli (ang. model chcecking) można weryfikować poprawność programów współbieżnych.

Własność bezpieczeństwa

Przypomnijmy, że własność bezpieczeństwa wyraża fakt, że nigdy nie dojdzie do sytuacji niepożądanej: nigdy dwa procesy nie będą jednocześnie w sekcji krytycznej, producent nie nadpisze danych w buforze itp. Własność bezpieczeństwa jest własnością statyczną w tym sensie, że pojawia się w specyfikacji problemu.

Własność żywotności

Własność żywotności to własność dynamiczna. Ogólnie można powiedzieć, że warunek żywotności wyraża następujący fakt: Jeśli proces chce wykonać pewną akcję, to w skończonym czasie mu się to uda. Przykładem własności żywotności jest żądanie, aby proces, który chce wejść do sekcji krytycznej w końcu mógł do niej wejść. Podobnie czytelnik, który chce rozpocząć czytanie powinien w skończonym czasie wejść do czytelni.

Przejawem braku żywotności jest zakleszczenie albo zagłodzenie. Zjawiska te polegają na tym, że proces (lub procesy) nie mogą wykonać żadnej pożytecznej pracy. Jeśli jest to zjawisko globalne mówimy o zakleszczeniu, jeśli brak żywotności dotyczy pojedynczego procesu (pojedynczych procesów) mówimy o zagłodzeniu.

Abstrakcyjny model procesu

Dla potrzeb tej części wykładu wprowadźmy abstrakcyjny model procesu.

Definicja

Proces modelujemy jako trójkę: \((S, R, I)\), gdzie:


Przeplot

Mając powyższy model procesu można formalnie zdefiniować ciąg wykonawczy procesu:

Niech \(P = (S, R, I)\) będzie procesem. Określmy następujące zbiory:

Wówczas ciągiem wykonawczym procesu nazwiemy dowolną ścieżkę \(p\in\bigcup_{s\in I}\,{Path}_s\).

Zauważmy, że zbiór wszystkich ciągów wykonawczych danego procesu jest drzewem.

Computational Tree Logic (CTL)

Computational Tree Logic jest zdaniową logiką temporalną. Można w niej wyrażać nie tylko proste fakty typu własność p zachodzi w pewnym stanie, ale także stwierdzenia zawierające kwantyfikatory dotyczące czasu: kiedyś w przyszłości, zawsze w przyszłości oraz kwantyfikatory dotyczące możliwości: na pewno, może się zdarzyć. Zauważmy, że własności, które chcemy formułować jako warunki poprawności zawierają takie właśnie kwantyfikatory: na pewno nigdy w przyszłości dwa procesy nie będą w sekcji krytycznej.

Składnia

Aby zdefiniować logikę powinniśmy określić co najmniej jej składnię i semantykę. Zaczniemy od definicji składni. Formuły w CTL dzielą się na dwie kategorie: formuły stanowe i formuły ścieżkowe. Ich definicja jest wzajemnie rekurencyjna.

Niech \(V=\{v_1, v_2,\ldots\}\) będzie przeliczalnym zbiorem zmiennych zdaniowych.

  1. Zbiór formuł stanowych \(\textit{FS}\) to najmniejszy zbiór, taki że:
    • \(V\subseteq \textit{FS}\)
    • \(\perp\in\textit{FS}\)
    • jeśli \((\phi \rightarrow \psi)\in \textit{FS}\)
    • jeśli \((A\alpha), (E\alpha)\in\textit{FS}\)
  2. Zbiór formuł ścieżkowych \(\textit{FP}\) to najmniejszy zbiór, taki że:
    • jeśli \((\phi U\psi)\in \textit{FP}\)
    • jeśli \((G\phi), (F\phi), (X\phi)\in\textit{FS}\)

Spójniki \(\neg, \wedge, \vee, \leftrightarrow\) definiuje się na formułach stanowych za pomocą \(\rightarrow\)

Struktura Kripkego

Prawdziwość formuł w logice CTL będziemy wyliczać w strukturach Kripkego. Intuicyjnie, struktura Kripkego to proces wzbogacony o wartościowanie zmiennych zdaniowych w każdym stanie. Innymi słowy: z każdym stanem procesu związujemy zbiór faktów (zmiennych zdaniowych), które w tym stanie są prawdziwe. Ponieważ proces wykonuje się (zmienia stany zgodnie z relacją przejścia) to prawdziwość zmiennych zdaniowych zmienia się w czasie.

Struktura Kripkego to czwórka uporządkowana \((S, R, I, \delta)\), gdzie

Funkcję \(\delta\) można uważać za wartościowanie zmiennych zdaniowych, ale wartościowanie to zależy od stanu, w którym znajduje się proces.

Semantyka

Mając strukturę Kripkego i formułę CTL możemy rozstrzygać, czy jest ona prawdziwa w tej strukturze. Najpierw jednak musimy zdefiniować prawdziwość formuły stanowej w określonym zadanym stanie oraz prawdziwość formuły ścieżkowej dla zadanej ścieżki.

Niech \(s\in S\) dowolnym stanem.

  1. Definiujemy relację \(\phi\):
    • dla \(v\in\delta(s)\)
    • nieprawda, że zachodzi \(K, s \vDash \perp\)
    • \(K, s \vDash \psi\))
    • \(K, p \vDash \alpha\)
    • \(K, p \vDash \alpha\)
  2. Definiujemy \(\alpha\):
    • \(\forall_{i\in\nat} K, p(i) \vDash \phi\)
    • \(\exists_{i\in\nat} K, p(i) \vDash \phi\)
    • \(K, p(1) \vDash \phi\)

Powiemy, że struktura Kripkego \(K, s \vDash \phi\).

Przykłady formuł i ich znaczenie

A oto przykładowe formuły i ich sens:

Weryfikacja modelowa

Dana jest struktura Kripkego \(\phi\). Sprawdzić, czy \(K \vDash \phi\).

Można pokazać, że zadanie to sprowadza się do wielokrotnego przeszukiwania grafu reprezentującego proces. Zatem problem sprawdzenia, czy dana struktura Kripkego jest modelem dla zadanej formuły jest rozstrzygalny. Można to wykorzystać proponując następujący sposób weryfikacji programów współbieżnych:

  1. Zidentyfikuj kluczowe stany procesów, które składają się na program współbieżny.
  2. Utwórz strukturę Kripkego modelującą przeplot ciągów wykonawczych tych procesów.
  3. Wyraź własności żywotności i bezpieczeństwa w postaci formuł logiki CTL.
  4. Sprawdź, czy struktura Kripkego utworzona w punkcie 2 jest modelem dla tych formuł.

Zadanie to można przeprowadzić za pomocą narzędzi wspomagających takich jak na przykład SMV. A oto formalizacja algorytmu Petersona z pierwszego wykładu w postaci kodu w SMV.

Weryfikacja poprawności algorytmu Petersena

MODULE main
VAR
  chce1 : boolean;
  chce2 : boolean;
  kto : {1, 2};
  proc1 : process proc1(chce1,chce2,kto);
  proc2 : process proc2(chce1,chce2,kto);
ASSIGN
  init(chce1) := 0;
  init(chce2) := 0;
SPEC
  AG (! (proc1.stan = sekcja & proc2.stan = sekcja))


MODULE proc1 (chce1,chce2,kto) 
VAR
  stan : {wlasne,chce,zmkto,while1,while2,sekcja,wyjscie}; 
ASSIGN
  init(stan) := wlasne;
  next(stan) := 
   case 
     stan = wlasne :  {wlasne, chce};
     stan = chce : zmkto;
     stan = zmkto : while1; 
     stan = while1 & !chce2 : sekcja;
     stan = while1 & chce2 : while2;
     stan = while2 & kto!=1 : sekcja;
     stan = while2 & kto=1 : while1;
     stan = sekcja : {sekcja, wyjscie};
     stan = wyjscie : wlasne;
     1: stan;
   esac;
 next(chce1) := 
   case
     stan = chce : 1;
     stan = wyjscie : 0;
     1 : chce1;
   esac;
 next (kto) := 
   case 
     stan = zmkto : 1;
     1 : kto;
   esac;
SPEC
  AG (stan = chce -> AF stan = sekcja)
FAIRNESS
  running 
FAIRNESS
  stan != sekcja
MODULE proc2 (chce1,chce2,kto)
VAR
  stan : {wlasne,chce,zmkto,while1,while2,sekcja,wyjscie};
ASSIGN
 init(stan) := wlasne;
 next(stan) := 
   case 
     stan = wlasne :  {wlasne, chce};
     stan = chce : zmkto;
     stan = zmkto : while1; 
     stan = while1 & !chce1 : sekcja;
     stan = while1 & chce1 : while2;
     stan = while2 & kto!=2 : sekcja;
     stan = while2 & kto=2 : while1;
     stan = sekcja : {sekcja, wyjscie};
     stan = wyjscie : wlasne;
     1: stan;
   esac;
 next(chce2) := 
   case
     stan = chce : 1;
     stan = wyjscie : 0;
     1 : chce2;
   esac;
 next (kto) := 
   case 
     stan = zmkto : 2;
     1 : kto;
   esac;
SPEC
  AG (stan = chce -> AF stan = sekcja)
FAIRNESS
  running 
FAIRNESS
  stan != sekcja

Ćwiczenia

Laboratoria