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

From Studia Informatyczne

Spis treści

Tematyka laboratorium

Sygnały i sygnały POSIX jako prymitywny mechanizm synchronizacyjny.

Materiały w tym module zostały przygotowane przez zespół prowadzących laboratorium Programowania Współbieżnego dla studentów II roku informatyki na Uniwersytecie Warszawskim.

Literatura

  1. M.K. Johnson, E.W. Troan, Oprogramowanie użytkowe w systemie Linux, rozdz. 13
  2. U. Vahalia, Jądro systemu UNIX, rozdz. 4
  3. W.R. Stevens, Programowanie zastosowań sieciowych w systemie Unix, rozdz. 2.4
  4. man do poszczególnych funkcji systemowych

Programy przykładowe

  • Makefile
  • err.c, err.h pliki do obsługi błędów
  • posix/signals.c program wypisujący informacje o wszystkich sygnałach i zmieniający

ich obsługę;

  • posix/cleanup.c program ilustrujący sposób tworzenia funkcji obsługi sygnałów w taki sposób, aby wykonywały one także obsługę standardową
  • posix/ctrl-c.c program ilustrujący trwałe blokowanie obsługi sygnałów
  • posix/child.c program ilustrujący wykorzystanie sygnału zakończenia procesu potomnego.
  • unix/takeover.c program służący do testowania semantyki funkcji signal()
  • unix/sigproc.c program ilustrujący wysyłanie sygnału do grupy procesów
  • unix/sigrace.c program ilustrujący wyścig procesów
  • unix/sigwait.c program ilustrujący zachowanie funkcji systemowej wait przy ignorowaniu sygnału SIGCLD
  • unix/timeout.c program ilustrujący przerywanie funkcji read przez sygnał.

Scenariusz zajęć

Wstęp

Sygnały są najprostszą metodą komunikacji międzyprocesowej opisaną przez standard POSIX. Umożliwiają one asynchroniczne przerwanie działania procesu przez inny proces (lub jądro), dzięki czemu przerwany proces może zareagować na pewne zdarzenia. Po obsłużeniu sygnału przerwany proces kontynuuje działanie od miejsca przerwania. Sygnały używa się do realizacji czynności takich jak kończenie działania procesów, czy informowanie procesów-demonów o tym, że mają odczytać ponownie pliki konfiguracyjne.

Kiedy proces otrzyma sygnał może zrobić jedną z trzech rzeczy:

  • zignorować go
  • spowodować, że jądro wykona specjalną część procesu zanim pozwoli procesowi kontynuować działanie (nazywamy to obsługą sygnału przez proces)
  • pozwolić, aby jądro wykonało domyślną akcję, która zależy od rodzaju wysłanego sygnału

Różne systemy uniksowe definiują różne (czasami niezgodne sematycznie) interfejsy programistyczne dotyczace sygnałów. Ponieważ standard definiowany przez POSIX jest obecnie przestrzegany przez niemal wszystkie wersje systemu Unix (również przez system Linux), dlatego kod powinien być tworzony zgodnie z tym interfejsem.

Sygnały zgodne z POSIX

Oryginalna wersja interfejsu sygnałów borykała się z problemem wielokrotnych sygnałów (nadejście sygnału w trakcie obsługi innego). Rozwiązaniem tego jest wstrzymanie dostarczenia drugiego sygnału dopóki proces nie zakończy obsługi pierwszego. Zapewnia to, że obydwa sygnały będą obsłużone i usuwa ryzyko przepełnienia stosu. Kiedy jądro przechowuje sygnał do późniejszego dostarczenia, o sygnale mówimy, że jest niezałatwiony. Jeżeli do procesu zostanie wysłany sygnał, wówczas gdy jakiś inny tego typu sygnał jest niezałatwiony, to tylko jeden z sygnałów zostanie dostarczony do procesu. Proces nie ma możliwości dowiedzenia się, ile razy dany sygnał był do niego wysyłany, ponieważ wiele sygnałów jest sklejanych w jeden.

Poza blokowaniem sygnałów w trakcie obsługi innych, wprowadzono możliwość jawnego blokowania sygnałów przez proces. Ułatwia to ochronę krytycznych części programu przy zachowaniu obsługi wszystkich wysłanych sygnałów. Taka ochrona pozwala funkcji obsługującej sygnały na operowanie na strukturach danych utrzymywanych przez inne fragmenty kodu przy zachowaniu prostej synchronizacji.

Proces może otrzymać sygnał w czasie oczekiwania na zajęcie zewnętrznego zdarzenia. W pierwotnej implementacji sygnałów wolne funkcje systemowe przekazywały w wyniku błąd EINTR, gdy ich działanie zostało przerwane przez sygnał, a szybkie funkcje systemowe kończyły działanie zanim sygnał został dostarczony. Obsługa błędu EINTR i wznowienie funkcji systemowej w razie konieczności było zadaniem procesu. Takie podejście choć zapewniało odpowiednią funkcjonalność, to jednak utrudniało pisanie funkcji obsługi sygnałów.

Standard POSIX nie określa właściwego zachowania, ale wszystkie popularne systemy zachowują się zgodnie. Dla każdego sygnału proces może ustawić flagę, która wskazuje, czy funkcja systemowa przerwana przez ten sygnał ma być automatycznie wznawiana przez system.

Wysyłanie sygnałów

Wysyłanie sygnałów z jednego procesu do innego jest realizowane przez funkcję systemową

int kill(pid_t pid, int sig);


Obsługa zbiorów sygnałów

Funkcje systemowe POSIX do obsługi sygnałów operują na zbiorach sygnałów. Zbiór sygnałów jest reprezentowany przez typ sigset\_t, a do operowania na nim służy zbiór makrodefinicji. Standard POSIX definiuje pięć funkcji do operowania na zbiorze sygnałów.

  1. int sigemptyset(sigset_t *zbior) --- zeruje zbiór sygnałów wskazywany przez parametr zbior.
  2. int sigfillset(sigset_t *zbior) --- dodaje do zbioru wszystkie dostępne sygnały.
  3. int sigaddset(sigset_t *zbior, int sygnal) --- dodaje sygnał do zbioru.
  4. int sigdelset(sigset_t *zbior, int sygnal) --- usuwa sygnal ze zbioru.
  5. int sigismember(const sigset_t *zbior, int sygnal) --- przekazuje w wyniku wartość niezerową, jeżeli sygnał jest w zbiorze, a w przeciwnym wypadku 0.

Obsługa sygnałów

Do zarejestrowania funkcji obsługi sygnału służy funkcja:

int * sigaction(int numer_sygnalu, struct sigaction * akcja,
                struct sigaction * poprzednia_akcja);

Funkcja ta określa, że sygnał numer_sygnalu będzie obsługiwany w sposób określony przez parametr akcja. Jeżeli parametr poprzednia_akcja jest różny od NULL, to po wykonaniu funkcji będzie wskazywać obsługę sygnału, jaka była przed wywołaniem sigaction. Funkcja sigaction przekazuje w wyniku 0 w przypadku pomyślnego zakończenia, a wartość niezerową w przypadku błędu.

Sposób obsługi sygnału przez jądro jest określony przez strukturę

struct sigaction {
        sighandler_t sa_handler;
        sigset_t sa_mask;
        unsigned long sa_flags;
        void (*sa_restorer)(void);
};

Pole sa_handler jest wskaźnikiem do funkcji o następującej deklaracji:

void handler(int numer_sygnalu);

gdzie numer_sygnalu jest numerem sygału, który spowodował wywołanie funkcji. Pole sa_handler może także przyjąć wartość SIG_IGN lub SIG_DFL. Pole sa_mask jest zbiorem sygnałów, które powinny być zablokowane w trakcie wywołania funkcji obsługi. Sygnał, który jest obsługiwany jest domyślnie zablokowany. Pole sa_flags pozwala procesom na zmianę zachowania przy obsłudze sygnałów. Składa się ona z jednej flagi lub bitowej alternatywy następujących flag:

  • SA_NOCLDSTOP. Jeżeli dla sygnału SIGCHLD jest podana flaga SA_NOCLDSTOP, to sygnał

jest generowany tylko wtedy, gdy proces zakończył działanie, a wstrzymanie procesów potomnych nie powoduje wysłania żadnego sygnału. SA_NOCLDSTOP nie ma wływu na inne sygnały.

  • SA_NOMASK. W trakcie wykonywania funkcji obsługi sygnału, sygnał nie jest automatycznie blokowany.
  • SA_ONESHOT. Kiedy sygnał jest wysłany, obsługa sygnału jest zerowana do SIG_DFL.
  • SA_RESTART. Kiedy sygnał jest wysłany do procesu, w czasie, gdy ten wykonuje wolną funkcję systemową, funkcja systemowa jest wznawiana po powrocie z funkcji obsługi sygnału.

Ostatnie pole struktury sigaction, sa_restorer, nie jest częścią specyfikacji POSIX i jest zarezerwowane na przyszłość.

Zmiana maski sygnałów

Funkcje opisane w POSIX pozwalają procesowi na blokowanie dowolnego zbioru sygnałów (z wyj. oczywiście sygnałów, które z definicji są nieblokowalne, np.: SIGKILL, SIGSTOP) otrzymywanych przez proces. Sygnały nie są wtedy gubione --- ich dostarczenie jest opóźnianie dopóki proces poprzez odblokowanie sygnałów nie wyrazi chęci ich otrzymania.

Zbiór sygnałów (typu sigset_t), które proces aktualnie blokuje nosi nazwę maski sygnałów. Funkcja sigprocmask() pozwala na zmianę aktualnej maski sygnałów.

int sigprocmask(int jak, const sigset_t * zbior, sigset_t * stary_zbior); 

Pierwszy parametr, opisuje w jaki sposób ma być zmieniana maska sygnałów i może przyjąć następujące wartości:

  • SIG_BLOCK --- sygnały ze zbioru są dodawane do aktualnej maski sygnałów.
  • SIG_UNBLOCK --- sygnały ze zbioru są usuwane z aktualnej maski sygnałów.
  • SIG_SETMASK --- tylko sygnały należące do zbioru są blokowane.

Następujące wywołanie pozwala uzyskać aktualną maskę sygnałów procesu:

sigprocmask(SIG_BLOCK, NULL, &aktualnaMaska); 

Odczytywanie zbioru niezałatwionych sygnałów

Uzyskanie informacji, które z sygnałów są aktualnie niezałatwione jest możliwe dzięki funkcji:

int sigpending(sigset_t * zbior);

Po wykonaniu funkcji zbiór wskazywany przez parametr zbior zawiera aktualnie niezałatwione sygnały.

Oczekiwanie na sygnał

Funkcja systemowa pause() realizuje oczekiwanie na sygnał.

int pause(void);

Funkcja pause() nie kończy działania dopóki proces nie otrzyma sygnału.

Funkcja systemowa sigsuspend() także umożliwia oczekiwanie na sygnał.

int sigsuspend(const sigset_t * maska);

Podobnie jak funkcja pause(), sigsuspend() wstrzymuje działanie procesu dopóki proces nie otrzyma sygnału. W odróżnieniu od funkcji pause(), sigsuspend() przed rozpoczęciem oczekiwania na sygnał tymczasowo zmienia maskę sygnałów procesu na wartość wskazywaną przez parametr maska.

Dobre rady

Ponieważ funkcje obsługi sygnałów mogą być wykonywane w dowolnej chwili, nie należy przy ich pisaniu przyjmować jakichkolwiek założeń co do tego, co w chwili ich wywołania robi program. Nie mogą one też dokonywać zmian, które mogłyby zakłócić wykonywanie dalszej części programu.

Jedną z najważniejszych rzeczy, na którą należy zwrócić uwagę, jest zmiana globalnych danych. Jeżeli nie jest wykonana wystarczająco uważnie, to może doprowadzić do sytuacji wyścigu. Najprostszym sposobem zapewnienia bezpieczeństwa globalnych danych jest nie stosowanie ich w programie. Inną metodą jest blokowanie wszystkich sygnałów, których obsługa modyfikuje struktury danych, na czas zmieniania struktur przez resztę programu, zapewniając w ten sposób, że w danej chwili tylko jeden fragment kodu operuje na tych strukturach.

Sygnały uniksowe

W celu zachowania zgodności wstecznej przedstawiamy takżę pierwotną implementację sygnałów. W implementacji tej do ustanawiania, w jaki sposób należy reagować na dany sygnał służy funkcja systemowa

void * signal(int numer_sygnalu, void * obsluga)

Parametr numer_sygnalu jest numerem sygnałem do obsłużenia, a obsluga definiuje akcję, jaka ma być wykonana, gdy sygnał jest wysyłany do procesu. Parametr obsluga jest wskaźnikiem do funkcji, której jedynym argumentem jest numer odebranego sygnału i która nie przekazuje w wyniku żadnej wartości. Kiedy sygnał jest wysyłany do procesu, jądro wykonuje funkcję obsługi tak szybko, jak jest to możliwe. Po powrocie z funkcji obsługi jądro wznawia wykonanie procesu w miejscu jego przerwania.

Parametr obsluga może przyjąć dwie specjalne wartości: SIG_IGN i SIG_DFL (obydwie zdefiniowane w <signal.h>). Jeżeli podamy SIG_IGN, to sygnał będzie ignorowany. SIG_DFL informuje jądro, że ma wykonać domyślną akcję związaną z tym sygnałem, która zazwyczaj polega na zakończeniu działania procesu lub ignorowaniu sygnału. Dwa sygnały, SIGKILL i SIGSTOP, nie mogą być obsługiwane. W przypadku tych dwóch sygnałów jądro zawsze wykonuje akcję domyślną, którą jest odpowiednio zakończenie i wstrzymanie działania procesu.

Funkcja signal() przekazuje w wyniku poprzedni sposób obsługi sygnału.


Co można zrobić po otrzymaniu sygnału?

  1. Zignorować go
main() 
 {
   signal(SIGINT, SIG_IGN);
   signal(SIGQUIT, SIG_IGN);
   /* itd */
 }
  1. Posprzątać i zakończyć działanie programu
int moje_dzieci;
 void porzadki(int typ_syg)
 
 {
  unlink("/tmp/plik_rob");
  kill(moje_dzieci, SIGTERM);
  wait(0);
  fprintf(stderr, "program konczy dzialanie ...\n");
  exit(1);
 }
 
 main() 
 {
  signal(SIGINT, porzadki);
  open("/tmp/plik_rob", O_RDWR | O_CREAT, 0644);
  moje_dzieci = fork();
  /* itd */
 }
  1. Dokonać dynamicznej rekonfiguracji
void czytaj_plik_konf (int typ_syg) 
{
  int fd;
  fd = open("moj_plik_konf", O_RDONLY);
 
  /* czytanie parametrow konfiguracyjnych */
  close(fd);
  signal(SIGUSR1, czytaj_plik_konf);
}
 
main() 
{
  czytaj_plik_konf();  /* inicjalna konfiguracja */
 
  while(1) {   /* obsluga w petli */
  ...
  }
}
  1. Przekazać raport nt stanu/dokonać zrzutu wewnętrznych tablic
int licznik;
 
void drukuj_info(int typ_syg)
{
  /* drukuj info o stanie */
  printf("liczba skopiowanych blokow: %d\n", licznik);
  signal(SIGUSR1, drukuj_info);
}
 
main () 
{
  signal(SIGUSR1, drukuj_info);
  for (licznik=0; licznik<DUZA_LICZBA; licznik++) {
    /* czytaj blok z tasmy wejsciowej */
    ...
    /* pisz blok na tasme wyjsciowa */
    ...
  }
}
  1. Włączyć/wyłączyć śledzenie
int flaga;
 
void przelacz_flage(int typ_syg) 
{
  flaga ^= 1;
  signal(SIGUSR1, przelacz_flage);
 
 
}
 
main() 
{
  /* inicjalnie wylacz sledzenie */
  flaga = 0;
  signal(SIGUSR1, przelacz_flage);
  /* zrob cos uzytecznego */
  /* wewnatrz kodu instrukcje implementujace sledzenie 
     powinny wygladac nastepujaco: */
  if (flaga) printf("cos uzytecznego\n");
}

Sygnały użytkownika

Sygnały SIGUSR1 i SIGUSR2 są zdefiniowane jako sygnały do wykorzystanie przez użytkownika (nigdy nie są generowane przez system).