Programowanie współbieżne i rozproszone/PWR Ćwiczenia 4

From Studia Informatyczne

Spis treści

Tematyka laboratorium

  1. Łącza nienazwane w systemie Unix: deskryptory plików, tworzenie i zamykanie łączy, duplikowanie łączy, zapis i odczyt z łącza
  2. Łącza nienazwane jako realizacja komunikacji asynchronicznej

Literatura uzupełniająca

  1. M. Rochkind, Programowanie w systemie Unix dla zaawansowanych, rozdz. 6.2 i 6.3.
  2. W. R. Stevens, Programowanie zastowań sieciowych w systemie UNIX, rozdz. 2.3 i 3.4.
  3. M. J. Bach, Budowa systemu operacyjnego UNIX, rozdz. 7.1.
  4. man do poszczególnych funkcji systemowych.

Pliki z programami przykładowymi

  • Makefile
  • err.h plik nagłówkowy biblioteki obsługującej błędy
  • err.c biblioteka do obsługi błędów
  • parent_pipe.c program ilustrujący komunikację za pomocą łącza. Współpracuje z child_pipe
  • child_pipe.c program ilustrujący komunikację za pomocą łącza. Współpracuje z parent_pipe
  • parent_dup.c program ilustrujący duplikację łącza

Scenariusz zajęć

Deskryptory, otwarte pliki

Gdy proces otwiera plik, informacje o tym pliku (m.in.: tryb otwarcia, położenie na dysku, wskaźnik pliku, czyli pozycja w pliku, której będzie dotyczyć kolejna operacja wejścia/wyjścia itp.) są zapisywane w tablicy otwartych plików utrzymywanej przez system operacyjny. W całym systemie jest jedna taka tablica. Z kolei informacja o położeniu danych o pliku w systemowej tablicy plików otwartych (oraz pewne dodatkowe informacje) jest zapamiętywana w tablicy deskryptorów tego procesu, który plik otworzył. Każdy proces utrzymuje tablicę deskryptorów --- jest ona lokalna dla procesu, znajduje się w jego danych systemowych (a więc jest kopiowana przy wykonywaniu funkcji fork()). Indeks w tej tablicy to właśnie deskryptor pliku.

Rysunek

Do otwierania pliku służy funkcja systemowa

open.

W wyniku przekazuje ona deskryptor otwartego pliku lub informacje o błędzie.

Zadanie
Przeczytaj stronę man dotyczącą funkcji open

W wyniku otwarcia pliku, tworzy się nowa pozycja w tablicy otwartych plików. Oznacza to, że jeśli proces dwukrotnie otworzy ten sam plik, to w tablicy otwartych plików będą dwie różne pozycje dotyczące tego pliku. Proces będzie miał również dwa różne deskryptory odnoszące się do tego samego pliku. Ponieważ wskaźnik pliku jest pamiętany w systemowej tablicy otwartych plików, więc operacje wejścia/wyjścia wykonywane za pomocą różnych deskryptorów będą od siebie niezależne (wykonanie operacji odczytu za pośrednictwem jednego deskryptora nie ma wpływu na pozycję, z której odczytamy kolejne dane z pliku za pośrednictwem drugiego deskryptora).

Test wyboru

Przypuśćmy, że proces wykonuje następujący ciąg operacji: Załóżmy, że w pliku test.txt znajdują się po kolei znaki abcd. Zmienne tego procesu mają następujące wartości:

Gdy proces wykonuje funkcję systemową fork(), tablica deskryptorów jest kopiowana do procesu potomnego. Zatem potomek "dziedziczy" po procesie macierzystym wszystkie otwarte pliki. Co więcej, deskryptory o tych samych wartościach w procesie potomnym i macierzystym odnoszą się do tej samej pozycji w systemowej tablicy otwartych plików.

Rysunek

Wynika z tego, że rodzic i potomek współdzielą otwarte pliki, tj. każda operacja wejścia/wyjścia w procesie macierzystym przesuwa wskaźnik pliku i powoduje, że kolejna operacja wejścia/wyjścia w procesie potomnym będzie dotyczyć kolejnej porcji danych z pliku.

Test wyboru

Przypuśćmy, że proces wykonuje następujący ciąg operacji: Załóżmy, że w pliku test.txt znajdują się po kolei znaki abcd. Zmienne tego procesu mają następujące wartości:


Takie współdzielenie deskryptorów jest także możliwe w pojedynczym procesie. Aby stworzyć kopię deskryptora pliku, używamy funkcji:

int dup(int oldfd);
int dup2(int oldfd, int newfd);

Funkcja dup tworzy kopię deskryptora, która odnosi się do tego samego miejsca w tablicy otwartych plików, co oryginał. Jest to zatem zupełnie inna sytuacja niż przy wielokrotnym otwarciu tego samego pliku. Funkcja dup duplikuje deskryptor oldfd i przekazuje wartość nowego deskryptora. Wiadomo przy tym, że przekazywany deskryptor jest deskryptorem o najmniejszej wartości spośród aktualnie dostępnych. Funkcja dup2 zamyka deskryptor newfd, jeśli był on otwarty i duplikuje oldfd na tę właśnie wartość. Wynikiem dup2 jest numer nowego deskryptora (czyli newfd). W wypadku błędu obie funkcje dają w wyniku wartość -1.

Rysunek
Test wyboru

Przypuśćmy, że proces wykonuje następujący ciąg operacji: Załóżmy, że w pliku test.txt znajdują się po kolei znaki abcd. Zmienne tego procesu mają następujące wartości:

Zadanie
Przeczytaj man dup

Wykonanie funkcji systemowej exec() nie ma wpływu na postać tablicy deskryptorów.Po wykonaniu funkcji exec() proces zachowuje otwarte pliki (choć zapewne nie zna już wartości ich deskryptorów, bo zmienne, które przechowywały te wartości, przestały istnieć w chwili wykonania funkcji exec()).

Do czytania z pliku służy funkcja systemowa read, a do zapisu funkcja systemowa write. Funkcje te są niepodzielne. Oznacza to, że operacje read i/lub write wykonywane na tym samym pliku "jednocześnie" nie będą się przeplatać: druga rozpocznie się po zakończeniu pierwszej.

Zadanie
Przeczytaj man read i man 2 write

Po uruchomieniu każdego procesu rezerwowane są dla niego trzy specjalne deskryptory o numerach 0, 1 i 2. Są to deskryptory standardowego wejścia, standardowego wyjścia i standardowego wyjścia błędów procesu. Domyślnie są one związane z konsolą, czyli odczyt po standardowym deskryptorze wejściowym powoduje czytania klawiatury, zapis po standardowym deskryptorze wyjściowym i diagnostycznym powoduje zapis na ekran. Wejście, wyjście i wyjście błędów można przekierowywać z poziomu interpretera poleceń za pomocą operatorów < > >>, 2> oraz &>. Na przykład:

cat plik > kopia.pliku "wyświetli" plik nie na ekran lecz do pliku kopia.pliku

Łącza nienazwane

Zasada działania

Łącza nienazwane to rodzaj plików istniejących tylko wewnątrz jądra systemu operacyjnego - nie można ich znaleźć na dysku twardym. Służą one do komunikacji między procesami pokrewnymi. Dwa procesy nazwiemy pokrewnymi, jeśli mają wspólnego przodka (ojca, dziadka itd.) lub jeden jest przodkiem drugiego. Do łącza można zapisywać dane i odczytywać je za pomocą tych samych funkcji systemowych co w przypadku plików (read oraz write). W praktyce o łączu nienazwanym można myśleć jak o buforze utrzymywanym przez system operacyjny. Można do niego zapisywać dane i odczytywać, przy czym dane są odczytywane w kolejności ich zapisywania. Taki bufor jest oczywiście skończony, ale nie znamy jego pojemności. Wiadomo jedynie, że zmieści się w nim co najmniej 4KB danych.

W przypadku próby odczytu danych z pustego łącza proces jest (domyślnie) wstrzymywany, choć zachowanie to można zmienić. Łącza zachowują się zatem bardzo podobnie do abstrakcyjnego mechanizmu SendMessage i GetMessage przedstawionego na poprzednich ćwiczeniach. Jest to mechanizm asynchroniczny.

Tworzenie

Do tworzenia łączy nienazwanych służy funkcja systemowa

int pipe(int filedes[2]);

Funkcja ta tworzy nowe łącze nienazwane oraz umieszcza w tablicy podanej jako parametr, wartości dwóch deskryptorów. Pierwszy deskryptor służy do czytania, a drugi do pisania do utworzonego łącza.

Rysunek
Zadanie
Przeczytaj man pipe

Oczywiście informacje o łączu trafiają również do systemowej tablicy otwartych plików, więc rozważania przedstawione we wstępie pozostają w mocy także w odniesieniu do łączy.

Zapis do łącza

Jeśli fd jest deskryptorem służącym do zapisu do pewnego łącza, to funkcja systemowa

write (fd, buf, count)

próbuje zapisuje do łącza o deskryptorze fd count bajtów znajdujących się w tablicy buf. Ale łącza mają ograniczona pojemność. Proces, który próbuje zapisać do łącza, w którym nie ma miejsca na całą zapisywaną porcję, jest wstrzymany do czasu, aż z łącza zostanie odczytana taka ilość danych by znalazło się miejsce na wszystkie zapisywane dane. Zwróćmy uwagę: write zapisze wszystko albo nic. Jest jednak wyjątek od tej reguły. Gdy próbujemy na raz zapisać do łącza więcej niż jego pojemność, to proces zapisuje do łącza tyle danych, ile może, i jest wstrzymywany do momentu, aż znowu będzie mógł coś do łącza wpisać. Wynikiem funkcji write jest liczba zapisanych bajtów lub -1, jeśli nastąpił błąd. Jeśli nie próbujemy pisać więcej niż pojemność łącza, to wynikiem powinna być wartość count.

Zapis do łącza jest możliwy tylko wtedy, gdy jest ono otwarte (przez ten sam lub inny proces) do czytania. Jeśli proces spróbuje zapisać coś do łącza, które nie jest przez żaden proces otwarte do czytania, to zostanie przerwany sygnałem SIGPIPE (więcej o sygnałach wkrótce). Ten błąd najczęściej objawia się komunikatem broken pipe z poziomu interpretera poleceń.

Odczyt z łącza

Jeśli fd jest deskryptorem służącym do odczytu z pewnego łącza, to funkcja systemowa:

ssize_t read(int fd, void *buf, size_t count);

odczyta z łącza co najwyżej count bajtów i zapisze je pod adresem <ttbuf></tt>. Jeśli w łączu znajduje się mniej bajtów niż count (ale łącze nie jest puste), to funkcja read odczyta to, co jest w łączu i kończy się pomyślnie. Jej wynikiem jest wówczas liczba faktycznie odczytanych bajtów. Próba odczytu z pustego łącza wstrzymuje proces do czasu pojawienia się w łączu jakichkolwiek danych lub do chwili, gdy łącze nie będzie otwarte do zapisu. Kolejność odczytu jest zgodna z kolejnością zapisu (łącza są kolejkami prostymi).

Jeśli proces próbuje odczytać z łącza, które nie jest przez nikogo otwarte do zapisu, to funkcja read kończy się natychmiast przekazując w wyniku 0. Ta własność jest wykorzystywana do sprawdzania, czy jest coś jeszcze do odczytu.

Zamykanie łącza

Aby zamknąć łącze (lub plik) używamy funkcji systemowej

int close(int fd);

Funkcja przekaże -1 w przypadku błędu.

O zamykaniu otwartych, a niepotrzebnych już deskryptorów często się zapomina. Jednak jest to bardzo ważna operacja i to z dwóch powodów:

  1. Zamykając zbędne deskryptory natychmiast, gdy przestają być potrzebne zapobiegamy zbędnemu kopiowaniu deskryptorów (przy fork()) i nadmiernemu wzrostowi tablic deskryptorów. We wczesnych systemach uniksowych można było mieć jednocześnie otwartych tylko 20 deskryptorów.
  2. Zamknięcie wszystkich deskryptorów służących do zapisu do danego łącza jest często jedynym sposobem poinformowania procesu czytającego z tego łącza, że nie ma już więcej danych do odczytu w łączu.

Nie zamykamy sami deskryptorów 0, 1, 2, chyba że jest do tego istotny powód. Zawsze zamykamy to, co sami otworzyliśmy, choć tak naprawdę proces wykonujący funkcję systemową exit zamyka sam wszystkie otwarte łącza.

Zadanie

Przeczytaj man close

Typowy scenariusz

Typowy scenariusz użycia łącza jest zatem następujący. Proces tworzy łącze (funkcją pipe), następnie rozmnaża się (funkcja fork). Proces macierzysty zamyka deskryptor do zapisu a proces potomny zamyka deskryptor do odczytu (odziedziczony po ojcu). Teraz proces macierzysty może wykonywać funkcję read. Będzie ona czekać aż do chwili, gdy proces potomny coś zapisze w łączu. Wtedy przekaże w wyniku wartość niezerową i odczyta z łącza wiadomość. Gdy proces potomny zamknie deskryptor do zapisu, read w procesie macierzystym przekaże wartość zero. W ten sposób proces może wykryć koniec strumienia danych i na przykład zakończyć się.

Ćwiczenie
Co stało by się, gdyby proces potomny nie zamknął (niepotrzebnego mu) deskryptora do odczytu z łącza?
Co stało by się, gdyby proces macierzysty zamknął deskryptor do odczytu zanim jego potomek coś zapisze do łącza?
Czy założenie o niepodzielności read i write jest istotne dla realizacji tego schematu?
Jak zrealizować komunikację w dwie strony między procesami? Czy jedno łącze wystarczy?

Oczywiście do komunikacji w dwie strony potrzebne są dwa łącza. W przeciwnym razie proces mógłby odbierać to, co sam zapisze do łącza.

A oto przykładowy program ilustrujący ten scenariusz. Proces macierzysty:

#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include "err.h"
char message[] = "Hello from your parent!";
int main()
{
  int pipe_dsc[2];                                         
  char pipe_read_dsc_str[10];

Tworzymy teraz łącze nienazwane. Deskryptory są przekazywane jako pipe_dsc[0] i pipe_dsc[1]. Pierwszy z nich służy do odczytu, drugi do zapisu z łącza:

  if (pipe (pipe_dsc) == -1) syserr("Error in pipe\n"); 

Tworzymy nowy proces. Każdy z nich ma swoją kopię zmiennej pipe_dsc oraz otwarte deskryptory o wartościach pamiętanych w tej zmiennej, prowadzące do utworzonego łącza:

  switch (fork()) {
    case -1:
      syserr("Error in fork\n");
    case 0:

Proces potomny zamyka niepotrzebny mu deskryptor do zapisu:

      if (close (pipe_dsc[1]) == -1) syserr("Error in close (pipe_dsc[1])\n");

Następnie zamienia numer deskryptora na napis:

      sprintf(pipe_read_dsc_str, "%d", pipe_dsc[0]);

i przekazuje go jako parametr wywołania procesu potomnego:

      execl("./child_pipe", "child_pipe", pipe_read_dsc_str, 0);
      syserr("Error in execl\n");
   default:

Proces macierzysty zamyka niepotrzebny mu deskryptor do odczytu:

      if (close (pipe_dsc[0]) == -1) syserr("Error in close (pipe_dsc[0])\n");

Zapisuje komunikat do łącza za pomocą deskryptora pipe_dsc[1]. Zwróć uwagę na to, że nie korzystamy z żadnych konkretnych wartości. Parametrem funkcji write jest sizeof(message), a nie wartość 14!

      if (write (pipe_dsc[1], message, sizeof(message)) == -1)
        syserr("Error in close write\n");

Na koniec zamykamy łącze i czekamy na koniec potomka:

      if (close (pipe_dsc[1]) == -1) syserr("Error in close (pipe_dsc[1])\n");
      if (wait (0) == -1)
        syserr("Error in wait\n");
     exit (0);
 } /* switch */

}

Proces potomny wykonuje kod zawarty w pliku child_pipe:

#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include "err.h"
#define BUF_SIZE                1024
int main (int argc, char *argv[])
{
  int read_dsc, buf_len;
  char buf [BUF_SIZE];     /* Zarezerwowany bufor na komunikaty */

Najpierw sprawdzamy, czy program wywołano z jednym argumentem:

  if (argc != 2)
    fatal("Usage: %s <read_fd>\n", argv[0]);

Przekształcamy pierwszy argument na wartość. Jest to deskyptor do odczytu z łącza. Zauważmy, że gdybyśmy nie otrzymali go jako parametr wywołania, to nie znalibyśmy jego wartości, więc nie moglibyśmy korzystać z łącza:

  read_dsc = atoi(argv[1]);
  printf ("Reading data from descriptor %d\n", read_dsc);

Odczytujemy komunikat sprawdzając poprawność funkcji read. I znów nie umieszczamy konkretnych wartości, lecz posługujemy się stałymi:

  if ((buf_len = read (read_dsc, buf, BUF_SIZE - 1)) == -1)
    syserr("Error in read\n");

Jeśli na końcu odczytanego komunikatu nie było 0, to je dopisujemy:

 buf [buf_len < BUF_SIZE - 1 ? buf_len : BUF_SIZE - 1] = '\0';

Reagujemy na błędy:

 if (buf_len == 0)                          /* To nie powinno się zdarzyć */
   fatal("read: unexpected end-of-file\n");
 else                                       /* Udało się odczytać */
   printf ("Read %d byte(s): \"%s\"\n", buf_len, buf);

Zamykamy niepotrzebne już łącze:

 if (close (read_dsc) == -1)
   syserr ("Error in close\n");
 exit (0);

}

Zwróć uwagę na to, że proces potomny po wykonaniu funkcji exec() ma otwarty deskryptor do łącza, ale nie zna jego numeru (bo zmienna przechowująca ten numer przestała istnieć w chwili wykonania funkcji exec). Stąd konieczność przekazania tego numeru przez argumenty wywołania programu. Nie jest to elegancka technika, zajmiemy się teraz ładniejszym sposobem.

Przekierowywanie wejścia/wyjścia

Ciekawym i często stosowanym sposobem wykorzystania łączy jest podmiana standardowego wejścia i wyjścia procesu. Jest to bardzo ważna technika. Stosuje się ją w większości programów uniksowych. Idea polega na tym, aby

  1. Tworzone programy czytały zawsze za pomocą standardowego deskryptora wejściowego, a pisały za pomocą standardowego deskryptora wyjściowego
  2. Jeśli zachodzi taka potrzeba, te deskryptory (bez wiedzy programu) zostają przekierowane do plików, łączy itp. Efekt jest taki, że bez zmiany treści programu można za jego pomocą pisać do plików, łączy, na ekran, drukarkę itp.

Klasyczny przykład wykorzystania takiego scenariusza polega na zastosowaniu operatora | z poziomu interpretera poleceń. Na przykład program cat wypisuje zawartość pliku na standardowe wyjście. Program grep odczytuje standardowe wejście i wypisuje na standardowe wyjście te wiersze, które zawierają podany mu jako argument wzorzec. Przekierowując wyjście programu cat na jakieś, utworzone uprzednio łącze, interpreter może umieszczać w nim, a nie na ekranie wyniki działania programu cat. Jeśli teraz grep będzie odczytywał zamiast z klawiatury z tego samego łącza, to dane z cat powędrują bezpośrednio do grep. Tak właśnie działa polecenie

cat /etc/passwd | grep Michal

Oczywiście interpreter musi jakoś "oszukać" programy, tak aby mimo tego że korzystają ze standardowych deskryptorów, umieszczały wyniki swojego działania w łączu.

Robimy to tak:

  1. Proces tworzy łącze, następnie wykonuje fork().
  2. Proces macierzysty zamyka niepotrzebne mu deskryptory, po czym wykonuje się dalej używając otwartego łącza.
  3. Proces potomny zamyka niepotrzebne mu deskryptory oraz deskryptor 0 (jeśli chce czytać z łącza) lub 1 (jeśli chce pisać do łącza).
  4. Proces potomny duplikuje (funkcją dup) deskryptor łącza. Ponieważ zamknął uprzednio 0, a wiadomo, że funkcja dup daje w wyniku najmniejszy nie używany deskryptor, więc wynikiem dup będzie właśnie 0 i łącze staje się dostępne poprzez deskryptor 0.
  5. Proces potomny musi teraz zamknąć zduplikowany deskryptor i wykonać exec().

Na skutek zduplikowania standardowe wejście/wyjście zostało teraz przekierowane do utworzonego łącza. Kod programu wywoływanego w funkcji exec jest więc "standardowy": czytanie odbywa się ze standardowego wejścia, zapis na standardowe wyjście. Przekierowane dokonane przed wywołaniem funkcji exec powoduje, że te standardowe deskryptory dotyczyć będą już nie konsoli, lecz utworzonego łącza. Proces jednak nie musi sobie z tego nawet zdawać sprawy!

A oto konkretna implementacja tego mechanizmu:

#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include "err.h"
char message[] = "Hello from your parent!\n";
int main (int argc, char *argv[])
{
  int pipe_dsc [2];
  if (pipe (pipe_dsc) == -1) syserr("Error in pipe\n");
  switch (fork ()) {
    case -1:
      syserr("Error in fork\n");

Do tego momentu nic się nie zmieniło. Teraz jednak kodujemy proces potomny, który:

    case 0:

Zamyka standardowy deskryptor do odczytu:

      if (close (0) == -1)            syserr("child, close (0)");

i duplikuje na niego pipe_dsc[0]:

      if (dup (pipe_dsc [0]) != 0)    syserr("child, dup (pipe_dsc [0])");

Zduplikowany deskryptor nie jest już potrzebny, podobnie deskryptor do zapisu:

      if (close (pipe_dsc [0]) == -1) syserr("child, close (pipe_dsc [0])");
      if (close (pipe_dsc [1]) == -1) syserr("child, close (pipe_dsc [1])");

Jeśli program wywołano z parametrem, to teraz następuje wywołanie programu, przekazanego jako pierwszy argument. Jako argumenty jego wywołania są brane kolejne argumenty:

      if (argc >= 2) {

argv+1 zawiera to co argv z pominięciem argv[0]. I oto nam chodzi. Wołamy program argv[1], którego argumenty są w tablicy argv+1

         execvp (argv[1], argv + 1);
         syserr ("child, execvp");
      }
      exit (0);
    default:

Proces macierzysty postępuje standardowo:

      if (close (pipe_dsc [0]) == -1) syserr("parent, close (pipe_dsc [0])");
      if (write (pipe_dsc [1], message, sizeof(message) - 1) == -1)
        syserr("write");
      if (close (pipe_dsc [1]) == -1) syserr("parent, close (pipe_dsc [1])");
      if (wait (0) == -1)
        syserr("wait");
      exit (0);
  } 

}

Efekt działania tego kodu jest taki, że napis Hello from your parent! wysyłany przez proces macierzysty trafia do programu, którego nazwa jest argumentem wywołania programu parent_dup. Warunek jest tylko taki, aby ten program odczytywał dane korzystając ze standardowego deskryptora wejściowego.

Ćwiczenie
Skompiluj ten program i uruchom go z różnymi argumentami, np: parent_dup cat albo parent_dup wc -c.

Implementacja czytelników i pisarzy za pomocą łączy

Zadanie zaliczeniowe

Napisz program pierścień tworzący N procesów, z których każdy działa następująco:

  1. pierwszy z nich w pętli:
    1. odczytuje ze standardowego wejścia komunikaty co najwyżej 100 znakowe zakończone znakiem końca wiersza
    2. przesyła ten komunikat za pomocą standardowego deskryptora wyjściowego do łącza, z którego odczyta go drugi proces
    3. powtarza kroki 1-2 do chwili odczytania pustego komunikatu
  2. wszystkie z wyjątkiem ostatniego:
    1. odbierają z łącza od poprzedniego procesu za pomocą standardowego deskryptora wejściowego komunikat
    2. przesyłają go za pomocą standardowego deskryptora wyjściowego do kolejnego procesu
  3. ostatni proces przesyła odebierane komunikaty do pierwszego, który wypisuje je na ekranie.