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.