Projektowanie oprogramowania systemów KOMUNIKACJA MIĘDZYPROCESOWA
plan Informacje ogólne Mechanizmy IPC Pliki i blokady Sygnały Gniazda i potoki Nazwane potoki Pamięć współdzielona i pliki mapowane w pamięci Semafory Kolejki komunikatów Message-passing interface Komunikaty okien (Windows)
IPC informacje ogólne Komunikacja międzyprocesowa (interprocess communications, IPC) to wymiana danych pomiędzy procesami Typowo wykorzystywana w modeli klient-serwer: klient żąda określonych danych z serwera, serwer wysyła dane do klienta Procesy mają izolowane przestrzenie adresowe, więc nie ma prostego sposobu aby komunikować procesy bezpośrednio muszą być używane specjalne obiekty na poziomie systemu operacyjnego IPC jest najczęściej używana w złożonych systemach serwerach baz danych, www, aplikacji (ale nie tylko: np. schowek) IPC może być używana także w odniesieniu do systemów sieciowych/rozproszonych, ale w tym wykładzie ograniczamy się do procesów na jednej maszynie
IPC jak działa? Nie ma sztywnych specyfikacji i reguł OS dostarcza niezbędnych konstrukcji (semafory, kolejki, potoki ), które są wykorzystywane do przekazywania komunikatów i dostarczania powiadomień do skomunikowanych stron Interpretacja komunikatów jest zależna od programu, każda aplikacja ma swoje własne kody, nazwy itd. (w przeciwieństwie do np. nagłówków ustandaryzowanych protokołów sieciowych) Komunikujące się aplikacje muszą stworzyć własny protokół komunikacyjny, który będzie obsługiwany przez obie strony Przekazywanie danych pomiędzy procesami zwykle wiąże się z przesyłaniem ich przez jądro OS-u czynnik ograniczający wydajność
Mechanizmy IPC Pliki i blokady Sygnały Gniazda i potoki Nazwane potoki Pamięć współdzielona i pliki mapowane w pamięci Semafory Kolejki komunikatów Message-passing interface Komunikaty okien (Windows)
Pliki Procesy komunikują się poprzez zapis i odczyt tego samego pliku o określonej ścieżce w systemie plików Zwykle jeden proces pisze do pliku, a inne odczytują (np. pliki PID na Uniksie, które służą do określenia, że dany demon działa i informują o jego identyfikatorze) Generalnie, nie istnieją sposoby zapewnienia, że dane są odczytywane atomowo i że nie są one zniekształcone/niekompletne: nie ma standardowych sposobów zapobiegnięcia wyścigom! Typowo, zakłada się, że odczyt/zapis porcji danych o rozmiarze nieprzekraczającym bloku bufora dysku odbywa się atomowo, ale nie jest to w ogólności standardem! Niektóre systemy operacyjne posiadają funkcje do blokowania plików, zapobiegając współbieżnemu dostępowi do momentu aż dane będą w dobrzezdefiniowanym stanie Używanie plików do realizacji IPC jest najprostszym, z reguły najwolniejszym i najmniej bezpiecznym mechanizmem warto go unikać w prawdziwych aplikacjach ;)
Blokowanie plików Najprostszy sposób zapewnienia synchronizacji pomiędzy kooperującymi procesami Jak to działa: Nakładamy blokadę na plik Odczyt/zapis chronionych danych Zdejmujemy blokadę Tylko jeden proces naraz może zablokować plik w efekcie operacja odczytu/zapisu staje się sekcją krytyczną Dwa modele: Blokada całego pliku (funkcja systemowa flock() na POSIX-ach) Blokada regionu w pliku (blokowanie rekordów) Kluczowy mechanizm dla tworzenia opartych na plikach systemów bazodanowych (SQLite, BTrieve )
Blokowanie plików
Sygnały AKA przerwania programowe (realizowane przez OS, w przeciwieństwie do przerwań sprzętowych, które realizowane są przez specjalny układ PIC w procesorze) Komunikat systemowy wysyłany z jednego procesu do drugiego, zwykle nie używany do transmisji danych ale raczej do przesyłania poleceń (brak mechanizmu do transmisji innych danych poza kodem sygnału) Szeroko używane w Uniksie, właściwie nieużywane na Windows Sygnał jest uruchamiany asynchronicznie w stosunku do kodu programu i przerywa jego regularne działanie Sygnały wysyłamy za pomocą funkcji systemowej kill() (patrz man 2 kill) i zwykle są używane przez OS aby powiadomić proces o sytuacji błędu (np. dzielenie przez zero albo naruszenie ochrony pamięci) Typowo, nieobsłużony sygnał spowoduje awaryjne zakończenie działania programu i crash ze zrzutem pamięci Program może zainstalować własne procedury obsługi sygnałów za pomocą funkcji sytemowej signal()
Użycie sygnałów dla IPC Istnieje szereg predefiniowanych identyfikatorów sygnałów w nagłówku <signal.h>, które są używane przez OS w określonych okolicznościach i nie powinny być wykorzystywane przez aplikacje w innych celach Poza powyższymi, systemy Unix posiadają 2 sygnały definiowane przez użytkownika: SIGUSR1 & SIGUSR2, których można używać w celu dostarczania komend do działającego procesu Użycie sygnałów dla IPC generalnie jest trudne, ponieważ obsługa sygnałów odbywa się asynchronicznie i nie ma prostego sposobu aby zakomunikować sygnał do zwykłego biegu programu Zakres operacji dozwolonych do wykonania w procedurze obsługi sygnału jest bardzo ograniczony: nie powinien on blokować działania na dłuższy czas, kod powinien być reentrant Nie ma sposobu na przekazanie dodatkowych danych: tylko (ograniczona liczba) kod sygnału jest istotny Na Windows sygnały są emulowane poprzez uruchomienie przez OS dodatkowego wątku, więc obsługa sygnału odbywa się współbieżnie do kodu programu (nie przerywa jego działania!)
Przykład obsługi sygnału Uruchamiamy program powyżej i sprawdzamy co się stanie, gdy wyślemy do niego sygnał SIGINT (naciskając Ctrl+C w konsoli)
Gniazda i potoki Gniazdo sieciowe jest punktem końcowym (endpoint) komunikacji międzyprocesowej poprzez sieć komputerową Istnieją również gniazda lokalne, które mogą być używane identycznie jak gniazda sieciowe, ale jedynie do zapewnienia komunikacji pomiędzy procesami na tej samej maszynie Para połączonych gniazd lokalnych (tzw. gniazd domeny Uniksa Unix-domain sockets) jest nazywana potokiem (pipe) Nie ma różnic pomiędzy używaniem gniazd sieciowych a lokalnych dla zapewnienia IPC (poza tym, że gniazda Uniksowe są jednokierunkowe, a sieciowe zwykle dwukierunkowe) Każdy program domyślnie startuje z minimum 3 potokami, które są używane do IPC z kontrolującą go powłoką (shell): stdin, stdout & stderr zwykle nazywamy je standardowymi plikami, ponieważ na Uniksie gniazdo lub endpoint potoku zachowuje się identycznie jak plik
Użycie gniazd do IPC Gniazda (i potoki) tworzą abstrakcję stream, który umożliwia przesłanie dużych porcji danych w sposób uporządkowany (w określonej kolejności) Użycie potoków do odbierania poleceń programu jest równie naturalne, co czytanie ze standardowego wejścia (stdin) W istocie jest możliwe w sposób programowy zastąpić standardowe wejście/wyjście jakimkolwiek innym wejściowym/wyjściowym potokiem lub otwartym gniazdem sieciowym tworzymy aplikację sieciową z niczego Obie komunikujące się strony muszą zgodzić się co do protokołu używanego do transmisji poleceń i danych Dla bezpieczeństwa, wszystkie dane pochodzące z potoków wejściowych muszą być walidowane i traktowane jako potencjalnie uszkodzone
Użycie gniazd do IPC Ilość danych możliwych do przesłania potokiem naraz jest ograniczona Jeśli proces piszący zapisuje dane szybciej niż proces czytający je konsumuje, zaś potok nie może buforować więcej danych, proces piszący jest blokowany tak długo, aż kolejny zapis stanie się możliwy Jeżeli proces czytający próbuje odczytać dane, a są one niedostępne, to blokuje on, aż dane zostaną zapisane do potoku Potok automatycznie synchronizuje obydwa procesy Potoki utworzone za pomocą funkcji systemowej pipe() są ograniczone do komunikacji pomiędzy procesem rodzicem i potomnym, ponieważ nie ma prostego sposobu na przekazanie dostępu do potoku z jednego procesu do drugiego (proces potomny dziedziczy deskryptor potoku z rodzica)
Potok
Łączenie niepowiązanych programów potokami za pomocą powłoki Zarówno na Uniksie jak i na Windows, powłoka systemowa (shell) umożliwia tworzenie potoków łączących niepowiązane programy za pomocą znaku (nazywanym z tego powodu pipe): Przykład: $ ps -a sort uniq grep -v sh Wyjście polecenia ps a jest przekierowywane jako standardowe wejście dla sort, którego wyjście staje się wejściem dla uniq, którego wyjście jest wejściem dla grep v sh (w skrócie: wylistuj działające procesy, posortuj je alfabetycznie, usuń powtórzenia i wystąpienia procesu sh ) Czas życia (anonimowego) potoku utworzonego w ten sposób jest zarządzany przez powłokę, która jest procesem-rodzicem dla wszystkich pozostałych i umożliwia im odziedziczenie otwartego potoku jako stdin/stdout Można również przekierować stderr używając & zamiast
Nazwane potoki Zwykłe potoki są anonimowe Istnieje sposób, aby powiązać potok z systemem plików, nadając mu nazwę jak typowemu plikowi Taki plik nazywamy zwykle FIFO (kolejka first-in, first-out), może on zostać otwarty przez każdy proces jak każdy inny plik Otwarcie FIFO do odczytu daje dostęp do endpointa tylko do odczytu potoku, otwarcie do zapisu endpoint tylko do zapisu Umożliwia to nawiązanie komunikacji pomiędzy procesami bez relacji rodzic/potomek FIFO mogą mieć wiele procesów piszących i wiele odczytujących może to zostać wykorzystane do dystrybucji zadań do wielu procesów roboczych system typu producent-konsument Poza faktem, że otwieramy FIFO jak plik, użycie nazywanych potoków na Uniksie nie różni się w żaden sposób od użycia gniazd sieciowych lub plików (z wykorzystaniem I/O blokującego lub nieblokującego)
Nazwane potoki na Windows Na Windows nazwane potoki stanowią inny mechanizm niż zwykłe potoki stworzone jako para gniazd (nie ma również gniazd lokalnych ani funkcji pipe()) Nazwane potoki na Windows nie mogą być powiązane z systemem plików, istnieją w odrębnej przestrzeni nazw jądra Mogą być używane do łączenia komputerów w sieci Są dwukierunkowe Pod względem cech są bardzo podobne do zwykłych gniazd sieciowych, poza tym, że wykorzystują inne API niż gniazda (co czyni je niekompatybilnymi) Nie ma sposobu stworzenia nazwanego potoku z potoku anonimowego (to są inne mechanizmy) Nazwane potoki zasadniczo nie dają zysku w stosunku do użycia gniazd sieciowych Są nieprzenośne, więc używamy ich tylko w hardkorowych aplikacjach dla Windows
Pamięć współdzielona Kilka procesów uzyskuje dostęp do tego samego bloku pamięci, który stanowi współdzielony bufor wymiany danych dla skomunikowanych procesów Brak narzutów związanych z wywoływaniem funkcji systemowych zapis i odczyt równie szybkie jak w przypadku każdego innego dostępu do pamięci Nadal jednak potrzebujemy obiektów jądra zapewniających wzajemną wyłączność aby uniknąć wyścigów
Pamięć współdzielona
Odwołania do pamięci współdzielonej w wielu procesach Utworzyliśmy blok pamięci współdzielonej w naszym kodzie, w jaki sposób teraz przekazać innym procesom informacje jak się do tego bloku dostać? Pamięci współdzielonej zwykle używamy w połączeniu z nazwą pliku, która może być wykorzystywana przez wszystkie procesy, zaś tworzenie pamięci współdzielonej to w rzeczywistości mapowanie zawartości pliku do pamięci Otwieramy plik o ustalonej nazwie Mapujemy region pliku do pamięci we wszystkich skomunikowanych procesach Wszystkie procesy widzą tą samą zawartość bloku pamięci w istocie ten sam blok pamięci, który jest używany również przez menadżera pamięci cache systemu plików Uprawnienia pliku określają kto może mieć dostęp do bloku pamięci Wszystkie zmiany w pamięci są synchronizowane do pliku Ta technika nazywa się pliki mapowane w pamięci i istnieje na POSIXach, Windows i wielu innych systemach Istnieje również możliwość stworzenia anonimowego bloku pamięci poprzez mapowanie zawartości pliku swap. Dostęp do takiego bloku może być odziedziczony przez proces potomny, lub przekazany przez inne metody IPC (np. przez potok)
Ograniczenia pamięci współdzielonej Ten sam blok pamięci w każdym procesie jest widoczny pod innym adresem możliwe jest tylko adresowanie względne w odniesieniu do adresu bazowego bloku Zazwyczaj nie jest możliwe konstruowanie wysokopoziomowych obiektów w pamięci współdzielonej, ponieważ wskaźniki będą prawidłowe tylko w jednym procesie Istnieje biblioteka Boost.Interprocess, która dostarcza alokator pamięci oparty na pamięci współdzielonej, umożliwiając w istocie współdzielenie obiektów C++ Bez tego typu dodatków, bezpiecznie można przechowywać w pamięci współdzielonej tylko typy POD (plain old data struktury i tablice typów podstawowych lub POD, bez żadnych wskaźników)
API pamięci współdzielonej Windows: CreateFileMapping()/MapViewOfFile() Unix/System V: shmat()/shmget() POSIX mmap() shm_open() Na samych systemach Unix-owych istnieją 3 różne API, więc dla najlepszej przenośności należy używać bibliotek tyakich jak Boost.Interprocess
Semafory Obiekty systemu operacyjnego, które umożliwiają kontrolę dostępu z poziomu wielu procesów do wspólnych zasobów w środowisku przetwarzania równoległego Już omówione na poprzednim wykładzie
Kolejki komunikatów Strumienie danych podobne do gniazd, zwykle dostarczane przez OS, umożliwiające wielu procesom odczyt i zapis komunikatów nie będąc bezpośrednio ze sobą połączonymi Kolejka komunikatów może być anonimowa (dziedziczona przez potomków) lub identyfikowana przez nazwę dostęp przez dowolne niepowiązane procesy Komunikaty umieszczone w kolejce są przechowywane dopóki odbiorca ich nie pobierze (również po śmierci procesów) Podobnie do gniazd datagramowych, komunikaty muszą być odebrane w całości podejście wszystko albo nic (komunikat jest niepodzielny) Podobnie do gniazd strumieniowych, transmisja komunikatów jest niezawodna
Kolejki komunikatów
Kolejki komunikatów Komunikat jest dowolnym, zdefiniowanym przez aplikację blokiem danych identyfikowanym przez kod (int) Rozmiar komunikatu jest ograniczony ustawieniami OS-a Niektóre systemy dostarczają również możliwość przechowywania komunikatów w bazie danych lub systemie plików są one w stanie wówczas przetrwać restart systemu Aplikacje mogą zarejestrować procedurę obsługi powiadomień o nowych komunikatach (UNIX: sygnały, Windows: zdarzenia)
API kolejkowania komunikatów Windows Cały interfejs graficzny Windows oparty jest na kolejkach komunikatów, do których wysyłane są powiadomienia o zdarzeniach takich jak akcje użytkownika - SendMessage()/PostMessage()/GetMessage() Microsoft Message Queuing podejście klient/serwer będące podstawą systemu DCOM, umożliwia kolejkowanie komunikatów poprzez sieć System V/Unix msgget()/msgsnd()/msgrcv() proste API oferujące niewielką kontrolę POSIX mq_open()/mq_send()/mq_receive() bardziej zaawansowane Przenośne: używaj Boost.Interprocess
Komunikaty Windows Każda aplikacja GUI Windows posiada wbudowaną kolejkę komunikatów, która stanowi kluczowy element konstrukcji pętli przetwarzania komunikatów Aplikacje otrzymują komunikaty w odpowiedzi na akcje użytkownika, zdarzenia systemu operacyjnego, zakończenie I/O, zegary lub jawne wywołanie funkcji PostMessage()/SendMessage() Komunikaty są dostarczane do określonego okna, każde okno ma procedurę obsługi komunikatów (procedurę okna), która podejmuje pewne akcje w odpowiedzi na zawartość komunikatu Każdy element UI (przyciski, pola edycji, etc) stanowi osobne okno i jest możliwym odbiorcą komunikatów Komunikaty Windows mogą być wysyłane również z innych procesów, tworząc metodę IPC
Pompa komunikatów Windows W zasadzie wygląda to podobnie w każdej aplikacji Windows
Komunikaty Windows jako metoda IPC Używamy funkcji RegisterWindowMessage()aby otrzymać unikatowy w skali systemu identyfikator komunikatu dla określonej nazwy, znanej wszystkim komunikującym się aplikacjom W aplikacji wsyłającej komunikat, używamy funkcji FindWindow(), żeby odnaleźć w systemie okno o określonej nazwie klasy, które będzie odbiorcą komunikatu Nadawca wywołuje PostMessage() podając jako cel odnalezione okno oraz zarejestrowane ID komunikatu Odbiorca otrzymuje komunikat w swojej pętli obsługi komunikatów
Ograniczenia komunikatów Windows Rozmiar danych do przesłania jest niewielki: 1 ID + 2 liczby 32-bitowe (WPARAM, LPARAM) Mechanizm kompletnie nieprzenośny