Programowanie współbieżne i rozproszone/PWR Ćwiczenia 4
Tematyka laboratorium
- Łącza nienazwane w systemie Unix: deskryptory plików, tworzenie i zamykanie łączy, duplikowanie łączy, zapis i odczyt z łącza
- Łącza nienazwane jako realizacja komunikacji asynchronicznej
Literatura uzupełniająca
- M. Rochkind, Programowanie w systemie Unix dla zaawansowanych, rozdz. 6.2 i 6.3
- W. R. Stevens, Programowanie zastowań sieciowych w systemie UNI, rozdz. 2.3 i 3.4
- M. J. Bach, Budowa systemu operacyjnego UNIX, rozdz. 7.1
- 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 domomentu 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>. 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 zamykanie otwartych, a niepotrzebnych już deskryptorów często się zapomina. Jednak jest to bardzo ważna operacja i to z dwóch powodów:
- 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.
- 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.
Przekierowywanie wejścia/wyjścia
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:
- pierwszy z nich w pętli:
- odczytuje ze standardowego wejścia komunikaty co najwyżej 100 znakowe zakończone znakiem końca wiersza
- przesyła ten komunikat za pomocą standardowego deskryptora wyjściowego do łącza, z którego odczyta go drugi proces
- powtarza kroki 1-2 do chwili odczytania pustego komunikatu
- wszystkie z wyjątkiem ostatniego:
- odbierają z łącza od poprzedniego procesu za pomocą standardowego deskryptora wejściowego komunikat
- przesyłają go za pomocą standardowego deskryptora wyjściowego do kolejnego procesu
- ostatni proces przesyła odebierane komunikaty do pierwszego, który wypisuje je na ekranie.
Funkcja ta tworzy nowe łącze nienazwane oraz umieszcza w tablicy podanej jako parametr, numery dwóch deskryptorów. Pierwszy deskryptor służy do czytania, a drugi do pisania do utworzonego łącza.
- Przeanalizuj plik źródłowy parent_pipe.c oraz child_pipe.c.
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, ładniejszy sposób znajduje się w punkcie 6.
6. Podmiana standardowego wejścia/wyjścia
-------------------------------------- Ciekawym sposobem wykorzystania łączy jest podmiana standardowego wejścia i wyjścia procesu. Jest to BARDZO WAZNA technika, gdyż tak właśnie działa większość programów uniksowych. Scenariusz jest taki:
Proces tworzy łącze, następnie wykonuje fork(). Proces macierzysty zamyka niepotrzebne mu deskryptory, po czym wykonuje się dalej uzywając otwartego łącza. Proces potomny zamyka duplikuje (funkcja dup lub dup2) deskryptory łącza na standardowe wejście lub wyjście w zależności od potrzeb, a następnie zamyka niepotrzebne deskryptory. Teraz wykonuje exec(). Zwróćmy uwagę, że na skutek zduplikowania standardowe wejście/wyjście zostało teraz przekierowane do utworzonego łącza. Zatem tekst programu wywoływanego w funkcji exec piszemy "standardowo": czytanie 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 terminala lecz uprzednio utworzonego łącza.
* Przeanalizuj plik źródłowy parent_dup.c
Klasycznym przykładem użycia komunikacji jednostronnej przy użyciu łączy z podmiana wejścia i wyjścia jest operator | w shell-u
np. cat /etc/passwd | grep Michal | more
ZADANIE 3
-->