L2 - Procesy, sygnały i deskryptory

Tutorial 2 - Procesy, sygnały i deskryptory #

Ten tutorial zawiera wyjaśnienia działania funkcji wymaganych na laboratoriach oraz ich parametrów. Jest to jednak wciąż zbiór jedynie poglądowy najważniejszych informacji – należy koniecznie przeczytać wskazane strony manuala, aby dobrze poznać i zrozumieć wszystkie szczegóły.

Zarządzanie procesami #

Tworzenie procesów #

Stworzenie procesu potomnego wykonawane jest za pomocą polecenia fork. Przyjrzymy się definicji tej funkcji:

pid_t fork(void)

Jak widać, zwraca ona obiekt typu pid_t, jest to typ całkowitoliczbowy ze znakiem. W procesie rodzica funkcja zwraca identyfikator nowo utworzonego procesu, natomiast w utworzonym procesie zwracane jest 0, pozwala to łatwo rozdzielić logikę na tę wykonywaną przez dzieci oraz na tę wykonywaną prez rodzica.

Oczywiście stworzenie nowego procesu może się nie powieść (np. gdy systemowi zabraknie potrzebnych zasobów). W takim wypadku funkcja fork zwraca wartość -1 i ustawia odpowiednią wartość zmiennej errno.

Procesy utworzone przez dany proces nazywamy jego dziećmi, natomiast z perspektywy procesu potomnego, proces który go stworzył nazywamy rodzicem.

Więcej informacji można znaleźć w manualu:

man 3p fork

Identyfikacja procesów #

Każdy proces posiada unikatowy identyfikator typu pid_t. Aby uzyskać informację o identyfikatorze procesu używamy funkcji getpid(), natomiast aby dowiedzieć się jaki jest idenyfikator procesu rodzica funkcji getppid(). Ich definicje są następujące:

pid_t getpid(void)
pid_t getppid(void)

Jak widać nie przyjmują one żadnych argumentów i zwracają obiekt typu pid_t.:

Zgodnie ze standardem POSIX obie funkcję zawszę kończą się sukcesem (man 3p getpid).

Zadanie #

Napisz program tworzący n procesów potomnych (n jest parametrem na pozycji 1), każdy z tych procesów czeka przez losowy czas [5-10] sekund po czym wypisuje na ekran swój PID i się kończy. Proces rodzica co 3s ma wyświetlać na stdout ile jeszcze posiada pod-procesów. Narazie nie przejmuj się czekaniem na zakończenie procesów potomnych.

Nowe strony z manuala:

Rozwiązanie zadania #

rozwiązanie 1 etap prog13a.c:

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>

#define ERR(source) \
    (fprintf(stderr, "%s:%d\n", __FILE__, __LINE__), perror(source), kill(0, SIGKILL), exit(EXIT_FAILURE))

void child_work(int i)
{
    srand(time(NULL) * getpid());
    int t = 5 + rand() % (10 - 5 + 1);
    sleep(t);
    printf("PROCESS with pid %d terminates\n", getpid());
}

void create_children(int n)
{
    pid_t s;
    for (int i = 0; i < n; ++i)
    {
        if ((s = fork()) < 0)
            ERR("Fork:");

        if (s == 0)
        {
            child_work(n);
            exit(EXIT_SUCCESS);
        }
    }
}

void usage(char *name)
{
    fprintf(stderr, "USAGE: %s 0<n\n", name);
    exit(EXIT_FAILURE);
}

int main(int argc, char **argv)
{
    if (argc < 2)
        usage(argv[0]);

    int child_count = atoi(argv[1]);
    if (child_count <= 0)
        usage(argv[0]);

    create_children(child_count);
    return EXIT_SUCCESS;
}

Uwagi i pytania #

  • Upewnij się, że wiesz jak powstaje grupa procesów tzn. kiedy powłoka tworzy nową grupę i jakie procesy do niej należą.

  • Zwróć uwagę, że w makrze ERR dodano kill(0, SIGKILL). Chodzi o to aby w razie błędu zatrzymać cały program (wszystkie procesy danej grupy).

  • 0 jako argument kill jest bardzo użyteczne, odnosi się do wszystkich procesów danej grupy, nie musimy przechowywać listy PID’ów.

  • Zwróć uwagę, że nie analizujemy błędów funkcji kill w makrze ERR, to dla tego, że w przypadku sytuacji krytycznej podejmujemy jak najmniej działań, zresztą co mielibyśmy zrobić wywołać rekurencyjnie ERR?

  • Pytanie czemu po uruchomieniu wraca linia poleceń?

    Odpowiedź
    Proces rodzic nie czeka na zakończenie procesów potomnych, brak wywołania `wait` lub `waitpid`, naprawimy to w 2 etapie

  • Jak sprawdzić kto jest rodzicem procesów prog13a? Dlaczego właśnie ten proces?

    Odpowiedź
    Zaraz po uruchomieniu wykonać np.: `$ps -f`, rodzicem procesów będzie proces o PID 1 czyli init/systemd. Dzieje się tak dlatego, że proces rodzic kończy się bez czekania na procesy potomne a te nie mogą "wisieć" poza drzewem procesów. Nie przechodzą pod powłokę ale właśnie pod proces po PID=1

  • Zwróć uwagę na miejsce i sposób ustalenia ziarna liczb losowych. Czy jeśli przesuniemy srand z procesu dziecka do rodzica to zmieni to działanie programu? Jak?

    Odpowiedź
    Procesy potomne będą losowały te same liczby ponieważ zaczynają z tym samym ziarnem, tak być nie może.

  • Czy można jako argument srand podać time() zamiast getpid()?

    Odpowiedź
    Nie można, wywołania time() odbędą się w tej samej sekundzie wiec ziarna będą takie same, efekt taki sam jak poprzednio.

  • Zwróć uwagę na losowanie liczby z przedziału [A,B]. Niby oczywiste, ale warto opracować sobie prosty wzór.

  • Jak zachowa się program bez exit wywołanym zaraz po child_work w procesie potomnym?

    Odpowiedź
    Procesy potomne po zakończeniu wykonania kodu funkcji child_work przechodzą dalej w kodzie czyli wracają do pętli forkującej i tworzą własne procesy potomne, które to też mogą utworzyć swoje potomne itd. Niezły bałagan, do tego procesy dzieci nie czekają na swoje dzieci.

  • Ile procesów (w sumie) zostanie utworzonych jeśli jako parametr uruchomienia podamy 3 przy założeniu, że nie ma exit wspomnianego w poprzenim pytaniu?

    Odpowiedź
    1 rodzic 3 potomne 1 poziomu, 3 drugiego i 1 trzeciego poziomu, w sumie 8 procesów, narysuj sobie drzewko, opisz gałęzie wartością n w momencie wykonania forka.

  • Co zwraca sleep? Czy powinniśmy jakoś na to reagować?

    Odpowiedź
    zwraca czas "niedospany" w przypadku przerwania funkcją obsługi sygnału w tym programie proces dziecka nie dostaje sygnałów i ich nie obsługuje więc nie musimy się tym martwić. W innych programach często będziemy restartować sleep z nowym czasem spania tak aby w sumie przespać tyle ile było założone.

  • W kolejnym etapie dodamy czekanie i zliczanie procesów potomnych. Pytanie skąd mamy wiedzieć ile procesów potomnych w danej chwili istnieje?

    Odpowiedź
    Można próbować zliczać sygnały SIGCHLD ale to zawodny sposób bo mogą się "sklejać" czyli mniej ich dostaniemy niż potomków się na prawdę zakończyło. Jedyną pewną metodą jest zliczanie udanych wywołań wait i waitpid.

Czekanie na procesy potomne #

Po zakończeniu wykonywania wszystkich swoich instrukcji proces potomny przechodzi w stan zombie (jego identyfikator dalej widnieje w tablicy procesów) i pozostaje w nim do momentu, aż proces rodzic odbierze informację o jego stanie (Status Information z man 3p wait). Dopiero wtedy zasoby procesu potomnego są całkowicie zwalniane z systemu.

Rodzic może odebrać informację o stanie wiszącego pod procesu korzystając z funkcji wait czekającą na dowolny proces potomny lub funkcji waitpid, która umożliwia określenie na które procesy potomne czekamy. Przyjrzyjmy się ich definicjom:

pid_t wait(int *stat_loc);
pid_t waitpid(pid_t pid, int *stat_loc, int options);

Jak można zauważyć, obie funkcje zwracają obiekt typu pid_t będący identyfikatorem procesu, o którego stanie dostaniemy informację.

Obie funkcje przyjmują argument stat_loc typu int*, który wskazuje miejsce w pamięci, do którego ma zostać zapisana informacja o odczytanym stanie procesu (jeżeli informacja ta nie będzie nam potrzebna możemy wstawić w to miejsce NULL).

Funkcja waitpid posiada dwa dodatkowe argumenty, kolejno pid typu pid_t oraz options typu int.

Argument pid określa, na które procesy chcemy czekać. Dla różnych wartości funkcja zachowuje się następująco:

  • pid == -1 - czekamy na dowolny proces potomny.
  • pid > 0 - czekamy na proces o identyfikatorze równym pid.
  • pid == 0 - czekamy na dowolny proces należący do grupy procesu wywołującego.
  • pid < -1 czekamy na dowolny proces którego identyfikator grupy jest równy wartości bezwzględnej pid.

argument options określa modyfikacje sposobu działania funkcji, i jest kombinacją następujących opcji:

  • WCONTINUED - funkcja powinna zwrócić również informacje o procesach, które zostały wznowione po zatrzymaniu.
  • WNOHANG - funkcja waitpid nie powinna zatrzymywać wywołania biężącego procesu jeżeli żaden z procesów na które czekamy nie może natychmiast powiadomić o swoim statusie. W takiej sytuacji funkcja zwaraca wartość 0.
  • WUNTRACED - funkcja powinna zwrócić również informacje o procesach, które zostały zatrzymane.

W ramach laboratorium wystarczy znajomość opcji WNOHANG.

Więcej informacji można znaleźć w manualu:

man 3p wait

Podsumowując możemy traktować funkcję waitpid jako bardziej rozbudowaną wersję funkcji wait - wywołanie funkcji wait(stat_loc) jest równażne wywołaniu waitpid(-1, stat_loc, 0).

Oczywiście obie te funkcję mogą się nie powieść, zwracają one wtedy -1 i ustawiają odpowiednią wartość zmiennej errno.

Uwaga: Jeżeli wywołamy funkcję wait lub waitpid i pula procesów potomnych na, które możemy czekać będzie pusta funkcja zwróci -1 i ustawi wartość zmiennej errno na ECHILD. Warto z tego skorzystać by mieć pewność że nie osierocimy żadnych procesów przed zakończeniem procesu.

Zadanie #

Rozbuduj program z poprzedniego zadania o poprawne czekanie na procesy potomne.

Nowe strony z manuala:

  • man 3p wait
  • man 3p waitpid

Rozwiązanie zadania #

rozwiązanie prog13b.c:

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>

#define ERR(source) \
    (fprintf(stderr, "%s:%d\n", __FILE__, __LINE__), perror(source), kill(0, SIGKILL), exit(EXIT_FAILURE))

void child_work(int i)
{
    srand(time(NULL) * getpid());
    int t = 5 + rand() % (10 - 5 + 1);
    sleep(t);
    printf("PROCESS with pid %d terminates\n", getpid());
}

void create_children(int n)
{
    pid_t s;
    for (int i = 0; i < n; ++i)
    {
        if ((s = fork()) < 0)
            ERR("Fork:");

        if (s == 0)
        {
            child_work(n);
            exit(EXIT_SUCCESS);
        }
    }
}

void usage(char *name)
{
    fprintf(stderr, "USAGE: %s 0<n\n", name);
    exit(EXIT_FAILURE);
}

int main(int argc, char **argv)
{
    if (argc < 2)
        usage(argv[0]);

    int n = atoi(argv[1]);
    if (n <= 0)
        usage(argv[0]);

    create_children(n);
    while (n > 0)
    {
        sleep(3);
        pid_t pid;
        while (1)
        {
            pid = waitpid(0, NULL, WNOHANG);
            if (pid > 0)
                n--;
            if (0 == pid)
                break;
            if (0 >= pid)
            {
                if (ECHILD == errno)
                    break;
                ERR("waitpid:");
            }
        }
        printf("PARENT: %d processes remain\n", n);
    }
    return EXIT_SUCCESS;
}

Uwagi i pytania #

  • Koniecznie trzeba rozróżnić kiedy waitpid informuje nas o chwilowym braku zakończonych potomków (wartość zwracana zero) od permanentnego braku potomków (błąd ECHILD). Ten ostatni przypadek nie jest w zasadzie błędem, tę sytuację trzeba normalnie obsłużyć.

  • Czemu wołamy waitpid w pętli?

    Odpowiedź
    Nie wiemy z góry ile procesów zombie czeka, może być 0 , 1 , 2 aż do n

  • Czemu wołamy waitpid z flagą WNOHANG?

    Odpowiedź
    Nie chcemy czekać nie wiadomo ile na jeszcze żyjące procesy, działamy w pętli, która co 3 sekundy ma wypisać dane

  • Czemu wołamy waitpid z pidem zero?

    Odpowiedź
    Czekamy na dowolny proces potomny, nie musimy znać jego PID, zero oznacza dowolny z potomków.

  • Czy w tym programie występują sygnały?

    Odpowiedź
    `SIGCHILD`, nie ma f. obsługi ale to nie szkodzi, w pewnym sensie jest obsługiwany przez waitpid

  • Czy tym razem nie powinniśmy sprawdzać co zwraca sleep skoro są sygnały?

    Odpowiedź
    Nie bo nie ma funkcji obsługi sygnału.

Sygnały #

Sygnały (ang. signals) są asynchronicznym mechanizmem obsługi zdarzeń w systemach operacyjnych z rodziny Unix. Umożliwiając powiadamianie procesów o wystąpieniu określonych zdarzeń systemowych, wyjątków lub żądań sterowania wykonaniem.

Wysyłanie sygnałów #

Do wysyłania sygnałów służy funkcja kill.

#include <signal.h>

int kill(pid_t pid, int sig);

Argument pid określa, do którego procesu lub grupy procesów kierowany jest sygnał:

  • pid > 0 - sygnał jest wysyłany do procesu o PID równym pid
  • pid = 0 - sygnał wysyłany jest do procesów należących do grupy procesów nadawcy (nadawca również otrzymuje sygnał)
  • pid = -1 - sygnał wysyłany do wszystkich procesów, do których nadawca ma uprawnienia (w tym do samego siebie)
  • pid < -1 - sygnał wysyłany jest do procesów o identyfikatorze grupy równym co do modułu pid

Argument sig specyfikuje jaki sygnał powinien być wysłany. Może on przyjmować następujące wartości:

  • jedno z makr zdefiniowanych w pliku nagłówkowym <signal.h> jak np. SIGTERM, SIGKILL czy SIGUSR1. Pełną liste można znaleźć w manualu
man 7 signal
  • wartość zero - w takim przypadku żaden sygnał nie zostanie wysłany, a jedynie zajdzie sprawdzenie potencjalnych błędów wykonania funkcji.

Funkcja zwraca 0 w przypadku poprawnego wykonania. W przeciwnym wypadu zwracana jest wartość -1 i ustawiana jest odpowiednia wartość zmiennej errno.

Więcej informacji można znaleźć w manualu

man 3p kill

Obsługa sygnałów #

Każdy sygnał posiada swój domyślny sposób obsługi przez proces. Liste sygnałów i ich domyślny sposób obsługi możesz sprawdzić w manualu

man 7 signal

Sposób obsługi danego sygnału jesteśmy w stanie sprawdzić lub zmienić za pomocą funkcji

#include <signal.h>

int sigaction(int sig, const struct sigaction *restrict act, struct sigaction *restrict oact);

Argumenty:

  • sig specyfikuje jaki o jaki sygnał nam chodzi i przyjmuje wartości makr z nagłówka <signal.h>.
  • act może ustawić nowy sposób obsługi sygnału jeśli wskazuje na strukture typu sigaction. Jeśli wartość jest równa NULL, to obsługa się nie zmienia.
  • oact - jeśli przy wywołaniu jest ustawiony na NULL, to argument ten jest ignorowany. W przeciwnym wypadku struktura na którą wskazuje ten wskaźnik jest ustawiana na stary sposób obsługi sygnału (gdy act nie jest NULLem) lub aktualny (gdy act jest NULLem)

Zgodnie z POSIXem struktura sigaction musi posiadać co najmniej następujące pola:

  • void(*) (int) sa_handler - wskaźnik na funkcje obsługującą sygnał lub jedna z wartości SIG_IGN lub SIG_DFL. Funkcja obsługująca musi przyjmować int (kod obsługiwanego sygnału) i nic nie zwracać. Makro SIG_IGN oznacza, że sygnał będzie ignorowany, a SIG_DFL domyślną obsługę sygnału.
  • sigset_t sa_mask - zbiór sygnałów, które będą blokowane na czas wywołania funkcji obsługującej sygnał
  • int sa_flags - specjalne flagi modyfikujące zachowanie sygnału
  • void(*) (int, siginfo_t *, void *) sa_sigaction - wskaźnik na funkcje obsługującą sygnał. Różni się od sa_handle przyjmowanymi argumentami. Domyślnie wywoływana jest funkcja sa_handle, aby to zmienić należy do sa_flags dodać flagą SA_SIGINFO.

Funkcja zwraca 0 w przypadku poprawnego wykonania. W przeciwnym wypadu zwracana jest wartość -1 i ustawiana jest odpowiednia wartość zmiennej errno.

Należy zaznaczyć, że procesy dzieci stworzone za pomocą funkcji fork dziedziczą sposób obsługi sygnału.

Więcej informacji można znaleźć w manualu

man 3p sigaction

Zadanie #

Program przyjmuje 4 parametry pozycyjne (n,k,p i r). Tworzy n procesów potomnych. Proces rodzic wysyła naprzemiennie sygnały SIGUSR1 i SIGUSR2 do wszystkich procesów potomnych w pętli po odpowiednio k i p sekundach. Kończy się gdy kończą się wszystkie procesy potomne. Każdy proces potomny losuje czas swojego spania z przedziału 5-10 sekund a następnie w pętli śpi i wypisuje na ekran SUKCES jeśli ostatnim otrzymanym przez niego sygnałem był SIGUSR1 lub FAILURE jeśli SIGUSER2. Taka pętla powtarza się r razy.

Co student musi wiedzieć:

  • man 7 signal
  • man 3p sigaction
  • man 3p nanosleep
  • man 3p alarm
  • man 3p memset
  • man 3p kill

Rozwiązanie zadania #

plik prog14.c:

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>

#define ERR(source) \
    (fprintf(stderr, "%s:%d\n", __FILE__, __LINE__), perror(source), kill(0, SIGKILL), exit(EXIT_FAILURE))

volatile sig_atomic_t last_signal = 0;

void sethandler(void (*f)(int), int sigNo)
{
    struct sigaction act;
    memset(&act, 0, sizeof(struct sigaction));
    act.sa_handler = f;

    if (-1 == sigaction(sigNo, &act, NULL))
        ERR("sigaction");
}

void sig_handler(int sig)
{
    printf("[%d] received signal %d\n", getpid(), sig);
    last_signal = sig;
}

void sigchld_handler(int sig)
{
    pid_t pid;

    while (1)
    {
        pid = waitpid(0, NULL, WNOHANG);

        if (pid == 0)
            return;

        if (pid <= 0)
        {
            if (errno == ECHILD)
                return;
            ERR("waitpid");
        }
    }
}

void child_work(int l)
{
    int t, tt;

    srand(getpid());
    t = rand() % 6 + 5;

    for (int i = 0; i < l; i++)
    {
        for (tt = t; tt > 0; tt = sleep(tt))
        {
        }

        if (last_signal == SIGUSR1)
            printf("Success [%d]\n", getpid());
        else
            printf("Failed [%d]\n", getpid());
    }
    printf("[%d] Terminates \n", getpid());
}

void parent_work(int k, int p, int l)
{
    struct timespec tk = {k, 0};
    struct timespec tp = {p, 0};

    sethandler(sig_handler, SIGALRM);
    alarm(l * 10);

    while (last_signal != SIGALRM)
    {
        nanosleep(&tk, NULL);
        if (kill(0, SIGUSR1) < 0)
            ERR("kill");

        nanosleep(&tp, NULL);
        if (kill(0, SIGUSR2) < 0)
            ERR("kill");
    }

    printf("[PARENT] Terminates \n");
}

void create_children(int n, int l)
{
    for (int i = 0; i < n; i++)
    {
        switch (fork())
        {
            case 0:
                sethandler(sig_handler, SIGUSR1);
                sethandler(sig_handler, SIGUSR2);
                child_work(l);
                exit(EXIT_SUCCESS);
            case -1:
                perror("Fork:");
                exit(EXIT_FAILURE);
        }
    }
}

void usage(void)
{
    fprintf(stderr, "USAGE: signals n k p l\n");
    fprintf(stderr, "n - number of children\n");
    fprintf(stderr, "k - Interval before SIGUSR1\n");
    fprintf(stderr, "p - Interval before SIGUSR2\n");
    fprintf(stderr, "l - lifetime of child in cycles\n");

    exit(EXIT_FAILURE);
}

int main(int argc, char **argv)
{
    int n, k, p, l;
    if (argc != 5)
        usage();

    n = atoi(argv[1]);
    k = atoi(argv[2]);
    p = atoi(argv[3]);
    l = atoi(argv[4]);
    if (n <= 0 || k <= 0 || p <= 0 || l <= 0)
        usage();

    sethandler(sigchld_handler, SIGCHLD);
    sethandler(SIG_IGN, SIGUSR1);
    sethandler(SIG_IGN, SIGUSR2);

    create_children(n, l);
    parent_work(k, p, l);

    while (wait(NULL) > 0)
    {
    }

    return EXIT_SUCCESS;
}

Do komunikacji pomiędzy funkcją obsługi sygnału a resztą programu musimy użyć zmiennych globalnych, proszę pamiętać, że jest to sytuacja wyjątkowa, zmienne globalne są jako takie niepożądane oraz, co powinno być oczywiste, ale czasem się studentom myli - nie są współdzielone pomiędzy procesami pokrewnymi.

Typ zmiennej globalnej last_signal nie jest przypadkowy, co więcej jest to jedyny BEZPIECZNY i POPRAWNY typ. Wynika to z asynchronicznej natury wywołania funkcji obsługi sygnału a dokładniej:

  • volatile oznacza wyłączenie optymizacji kompilatora, ważne żeby kompilator nie uznał wartości zmiennej za stałą bo jej zmiany nie wynikają z kodu i tak mogłoby się okazać, że czytelna dla nas pętla while(work) gdzie work jest zmienną globalną zmienia się na while(1) po optymizacji.
  • sig_atomic_t oznacza największy typ numeryczny, który jest przetwarzany w pojedynczej instrukcji CPU. Jeśli weźmiemy większy typ numeryczny przerwanie obsługą sygnału może zakłócić wartość wynikową nawet dla przykładowego prostego porównania a==0 o ile przerwanie wypadnie w trakcie porównania i zmieni już porównane bajty.

Z powyższego wynika, że nie przekazujemy pomiędzy funkcją obsługi a głównym kodem nic poza prostymi liczbami, stanami. Do tego dochodzi dobra praktyka nie przerywania programu na zbyt długo co pozostawia nam bardzo mało poprawnych, przenośnych i bezpiecznych rozwiązań w kwestii jak dzielić logikę programu pomiędzy główny kod a funkcję obsługi sygnału. Najprostsza zasada aby funkcje obsługi były ekstremalnie krótkie (przypisanie, inkrementacja zmiennej itp) a cała logika pozostała w głównym kodzie jest najlepsza.

Funkcja memset bywa konieczna a zazwyczaj jest użyteczna przy inicjowaniu nie w pełni znanych nam struktur. W tym przypadku POSIX wyraźnie mówi, że struktura sigaction może zawierać więcej pól niż jest to wymagane przez standard. W takim przypadku te dodatkowe pola, których wartości nie ustawilibyśmy (tutaj ze zerujemy za pomocą memset) mogą skutkować różnym działaniem na różnych systemach, a nawet różnym zachowaniem po między wywołaniami programu.

Czy podczas obsługi sygnału SIGCHLD można się spodziewać więcej niż jednego zakończonego procesu dziecka?

Odpowiedź
Tak, sygnały mogą się skleić, dziecko może się zakończyć akurat w trakcie obsługi `SIGCHLD`. Stąd pętla w funkcji obsługi tego.

Czy podczas obsługi sygnału SIGCHLD można się spodziewać braku zakończonego procesu dziecka? Zerknij na zakończenie main.

Odpowiedź
Tak, `wait` na końcu main może "podkradać" te czekające zombie tj. wywoła się poprawnie `wait` zanim wykona się funkcja obsługi.

Pamiętaj o możliwym KONFLIKCIE sleep i alarm - wg. POSIX sleep może używać w implementacji SIGALRM a nie ma jak zagnieżdżać sygnałów, nigdy zatem w kodzie oczekującym na alarm nie używamy sleep, można za to użyć nanosleep tak jak w kodzie powyżej.

W wysyłaniu sygnałów (kill) pojawia się jako PID zero, dzięki temu nie musimy znać pidów procesów potomnych ale też wysyłamy sygnał sami do siebie!

Miejsca ustawienia obsługi sygnałów i ich blokowania w tym programie są bardzo ważne, zwróć uwagę jak to działa i odpowiedz na pytania poniżej. Pamiętaj aby zawsze dobrze przemyśleć kolejność tych ustawień w swoim programie, z tym jest związanych sporo błędów w pracach studentów!

Zwróć uwagę na sleep, czemu jest w pętli? Czy czas spania jest/mógłby być dokładny?

Odpowiedź
sleep jest przerywane przez obsługę sygnału, zatem restart jest konieczny. Ponieważ sleep zwraca pozostały czas spania w sekundach to z uwagi na zaokrąglenia nie można po restarcie uzyskać dokładnego czasu spania.

Jaka jest domyślna dyspozycja znakomitej większości sygnałów (w tym SIGUSR1 i SIGUSR2)?

Odpowiedź
Zabicie procesu do którego wysyłamy, w tym programie brak reakcji (funkcji obsługi), blokowania lub ignorowania sygnałów SIGUSR1 i SIGUSR2 skutkowałoby przedwczesnym zabiciem procesów.

Jaka jest konsekwencja wysyłana przez proces rodzic sygnałów SIGUSR1/2 do całej grupy procesów?

Odpowiedź
Proces rodzic musi jakoś reagować na te sygnały mimo, że nie są one mu potrzebne do pracy, zatem je ignoruje.

Co by się stało, gdyby nie było włączone ignorowanie SIGUSR1 i SIGUSR2 w procesie rodzicu?

Odpowiedź
Proces rodzic zabiłby się pierwszym wysyłanym sygnałem.

Czy można przesunąć ustawienie ignorowania sygnałów za funkcję create_children? Procesy potomne przecież nie potrzebują tego ignorowania, mają od razu po starcie ustawianą funkcję obsługi?

Odpowiedź
Nie można, mogłoby się zdarzyć (choć rzadko), że procesy potomne zostałyby tylko utworzone (a nie zaczęte) a przydział CPU po tym utworzeniu przeszedłby do procesu rodzica. który zdążyłby wysłać sygnał SIGUSR1 do dzieci. Kolejny przydział CPU do procesu dziecka/dzieci spowodowałby najpierw obsługę sygnału a to oznaczałoby zabicie!

Czy można jakoś zmienić ten program tak aby wykluczyć ignorowanie sygnałów SIGUSR1 i SIGUSR2?

Odpowiedź
Ten akurat program może mieć identyczną reakcję na te sygnały w rodzicu i potomkach, można zatem ustawić obsługę od razu w procesie rodzicielskim przed fork.

A co się stanie jeśli za fork przeniesiemy obsługę SIGCHLD?

Odpowiedź
Jeśli jeden z procesów potomnych "umrze" zanim włączymy tą obsługę to będzie on "zombie" aż do momentu gdy kolejny w pod-procesów się zakończy. Nie jest to bardzo duży błąd ale warto i na takie zwracać uwagę.

Pytanie, czy wait na końcu main jest potrzebny? Przecież i tak funkcja parent_work() powinna działać co najmniej tyle czasu co najdłuższy z podprocesów?

Odpowiedź
Wyliczenie czasu w pętli rodzica nie wystarczy, w obciążonym systemie możliwe są dowolnie długie opóźnienia, bez `wait` powstaje zatem tzw. race condition - kto się pierwszy zakończy rodzic czy potomne procesy.

Czekanie na sygnał #

Często podczas pisania programów natkniemy sie na sytuację, w której proces, zanim wykona prace musi zostać poinformowany o tym, że inny proces zakończył swoje zadanie. Jak się pewnie domyślasz, ten problem można łatwo rozwiązać z wykorzystaniem sygnałów. Inspirując się poprzednim zadaniem, moglibyśmy napisać logikę, która opiera się na tym, że nasz proces śpi w pętli i sprawdza, czy ostatni otrzymany przez niego sygnał to ten, na który czeka. Niestety nie dość, że to rozwiązanie jest nieeleganckie, to jeszcze jest niepoprawne - mogło by dojść do sytuacji że sygnał na który czekamy zostanie “sklejony” z innym sygnałem i tak naprawdę nigdy nie dowiedzielibyśmy się, że nasz proces może rozpocząć pracę. Na szczęście system operacyjny dostarcza nam narzędzia, które pozwalają rozwiązać ten problem.

Aby zablokować program do momentu, gdy otrzyma on sygnał będziemy używać funkcji sigsuspend. Przyjrzyjmy się jej definicji:

int sigsuspend(const sigset_t *sigmask);

Jak możemy zauważyć funkcja ta zwaraca wartość typu int służącą do powiadomienia o potencjalnym błędzie, zwracane wtedy jest -1. Przyjmuje argument sigmask typu const sigset_t * jest to wskaźnik na zbiór sygnałów, na które funkcja będzie czekać.

Działanie tej funkcji jest następujące: ustawia ona maskę sygnałów na tę podaną w argumencie, czeka na przechwycenie jednego z tych sygnałów, po czym przywraca poprzednią maskę sygnałów i wznawia wykonywanie procesu.

Więcej informacji można znaleźć w manualu:

man 3p sigsuspend

Zarządzanie maską sygnałów #

Zbiór sygnałów będziemy nazywać maską sygnałów. Maskę sygnałów będziemy przechowywać jako obiekt o typie sigset_t. Standard nie określa w jaki sposób ma być zaimplementowany ten typ, może być to zarówno int, jak i struktura. W celu modyfikacji maski sygnałów będziemy używac funcji sigsemptyset, inicjalizującej maskę jako zbiór pusty, oraz sigaddset ,dodającej sygnał do maski. Przyjrzyjmy się ich definicją:

int sigemptyset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);

Jak możemy zauważyć, obie funkcję przyjmują jako pierwszy argument set typu sigset_t *, jest to wskaźnik na maskę, którą chcemy edytować. funkcja sigaddset dodatkowo przyjmuje argument signo będący kodem sygnału, który chcemy dodać do maski.

Obie funkcje zwracają wartość typu int, służącą do sygnalizacji przebiegu operacji: w razie sukcesu zwracają 0, a w razie błędu -1, ustawiając odpowiednią wartość zmiennej errno.

Więcej informacji znajdziesz w manualu:

man 3p sigaddset
man 3p sigemptyset

Zmiana maski sygnałów #

Skoro już zdefiniowaliśmy nową maskę sygnałów, chcielibśmy sprawić, by wpłynęła ona na działanie naszego procesu. W tym celu będziemy używać funkcji sigprocmask, która określa, w jaki sposób zdefiniowana przez nas maska sygnałów ma wpłynąć na aktulaną maskę sygnałów procesu. Przyjrzyjmy się jej definicji:

int sigprocmask(int how, const sigset_t *restrict set,
           sigset_t *restrict oset);

Jak możemy zauważyć funkcja ta przyjmuje kolejno argumenty:

  • how typu int określa w jaki sposób nowa maska ma wpłynąć na aktualną maskę. Dostępne opcje:

    • SIG_BLOCK - wynikowa maska sygnałów jest sumą zbiorów maski wskazanej przez set i aktualnej maski sygnałów (określamy jakie sygnały checmy dodać do maski).
    • SIG_SETMASK - wynikowa maska sygnałów jest maską sygnałów wskazywaną przez set.
    • SIG_UNBLOCK - wynikowa maska sygnałów jest przecięciem aktualnej maski i dopełnieiem zbioru maski wskazanej przez set (Określamy jakie sygnały chcemy usunąć z maski).
  • set typu const sigset_t to wskaźnik na maskę, na podstawie której chcemy modyfikować poprzednią maskę.

  • oset typu sigset_t * jest wskaźnikiem na obiekt do którego chcemy zapisać maskę sygnałów sprzed edycji.

Więcej informacji znajdziesz w manualu:

man 3p pthread_sigmask

Zadanie #

Napisz program, który tworzy jeden pod-proces, który co określony parametrami czas (w mikrosekundach) wysyła do procesu rodzica SIGUSR1, co n-ty raz (określony parametrami) wysyła SIGUSR2. Proces rodzic czeka na otrzymanie sygnału SIGUSR2. Oba procesy zliczają i wypisują na bieżąco ilości wysłanych/odebranych SIGUSR2. Używamy część funkcji z poprzedniego zadania.

Nowe strony z manuala

  • man 3p sigsuspend
  • Opis metod czekania na sygnał w glibc tutaj
  • man 3p pthread_sigmask (tylko opis sigprocmask)
  • man 3p sigaddset
  • man 3p sigemptyset

Rozwiązanie zadania #

rozwiązanie, plik prog15.c:

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>

#define ERR(source) \
    (fprintf(stderr, "%s:%d\n", __FILE__, __LINE__), perror(source), kill(0, SIGKILL), exit(EXIT_FAILURE))

volatile sig_atomic_t last_signal = 0;

void sethandler(void (*f)(int), int sigNo)
{
    struct sigaction act;
    memset(&act, 0, sizeof(struct sigaction));
    act.sa_handler = f;
    if (-1 == sigaction(sigNo, &act, NULL))
        ERR("sigaction");
}

void sig_handler(int sig) { last_signal = sig; }

void sigchld_handler(int sig)
{
    pid_t pid;
    while (1)
    {
        pid = waitpid(0, NULL, WNOHANG);
        if (pid == 0)
            return;
        if (pid <= 0)
        {
            if (errno == ECHILD)
                return;
            ERR("waitpid");
        }
    }
}

void child_work(int m, int p)
{
    int count = 0;
    struct timespec t = {0, m * 10000};
    while (1)
    {
        for (int i = 0; i < p; i++)
        {
            nanosleep(&t, NULL);
            if (kill(getppid(), SIGUSR1))
                ERR("kill");
        }
        nanosleep(&t, NULL);
        if (kill(getppid(), SIGUSR2))
            ERR("kill");
        count++;
        printf("[%d] sent %d SIGUSR2\n", getpid(), count);
    }
}

void parent_work(sigset_t oldmask)
{
    int count = 0;
    while (1)
    {
        last_signal = 0;
        while (last_signal != SIGUSR2)
            sigsuspend(&oldmask);
        count++;
        printf("[PARENT] received %d SIGUSR2\n", count);
    }
}

void usage(char *name)
{
    fprintf(stderr, "USAGE: %s m  p\n", name);
    fprintf(stderr,
            "m - number of 1/1000 milliseconds between signals [1,999], "
            "i.e. one milisecond maximum\n");
    fprintf(stderr, "p - after p SIGUSR1 send one SIGUSER2  [1,999]\n");
    exit(EXIT_FAILURE);
}

int main(int argc, char **argv)
{
    if (argc != 3)
        usage(argv[0]);

    int m = atoi(argv[1]);
    int p = atoi(argv[2]);
    if (m <= 0 || m > 999 || p <= 0 || p > 999)
        usage(argv[0]);

    sethandler(sigchld_handler, SIGCHLD);
    sethandler(sig_handler, SIGUSR1);
    sethandler(sig_handler, SIGUSR2);

    sigset_t mask, oldmask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGUSR1);
    sigaddset(&mask, SIGUSR2);
    sigprocmask(SIG_BLOCK, &mask, &oldmask);

    pid_t pid;
    if ((pid = fork()) < 0)
        ERR("fork");
    if (pid == 0)
        child_work(m, p);
    else
    {
        parent_work(oldmask);
        while (wait(NULL) > 0)
            ;
    }
    sigprocmask(SIG_UNBLOCK, &mask, NULL);
    return EXIT_SUCCESS;
}

Uwagi i pytania #

  • Program działa aż do SIGINT (C-c)

  • Uruchom z różnymi parametrami, z małą przerwą w mikrosekundach i częstymi SIGUSR2 różnica w licznikach powinna wzrastać szybciej niż gdy te parametry są większe. Za chwilę wyjaśni się skąd te błędy. Jeśli nie obserwujesz błędów spróbuj dać programowi trochę więcej czasu na działanie, około minuty powinno dać już różne wyniki.

  • Program jest celowo napisany tak, aby występowały pewne problemy, można go lepiej napisać i je wyeliminować, proszę miej to na względzie kopiując rozwiązania z tego kodu!

  • Zwróć uwagę na getppid, niby oczywiste ale widziałem prace gdzie studenci przekazywali pid rodzica do procesu potomnego.

  • Oczekiwanie na sygnał poprzez sigsuspend jest bardzo ważną technika, którą musisz rozumieć, dobrze omawia to dokumentacja GNU podlinkowana powyżej. Pamiętaj, że zasada jest taka, blokujemy oczekiwane sygnały w całej pętli ( większej części programu) i przywracamy ich dochodzenie tylko wtedy gdy możemy czekać czyli właśnie w wywołaniu sigsuspend. Mamy zatem w programie dobrze określone momenty w których obsługujemy sygnały, to spore ułatwienie.

  • Jeśli funkcje obsługujące sygnały mogą być wykonywane tylko w czasie nieaktywności programu głównego (czyli jak w tym przykładzie) można nieco rozluźnić zasady tego co wolno przekazywać przez zmienne globalne, oraz te dotyczące długości i czasu ich działania.

  • Który licznik jest niepoprawny, ten w procesie dziecka czy rodzica?

    Odpowiedź
    To musi być ten który jest mniejszy, nie mamy w tym kodzie możliwości zliczania nieistniejących sygnałów, możemy tylko gubić te wysłane. Gubić może oczywiście tylko odbiorca. Problem jest w procesie rodzica.

  • Pytanie czemu liczniki się różnią ?

    Odpowiedź
    1. sklejanie sygnałów ale to ma mały wpływ, 2. fakt, że sigsuspend NIE GWARANTUJE WYKONANIA MAKSYMALNIE JEDNEJ OBSŁUGI SYGNAŁU! To częsty błąd w rozumowaniu! Zaraz po wywołaniu obsługi SIGUSR2 jeszcze w obrębie jednego wykonania sigsuspend następuje obsługa SIGUSR1, zmienna globalna jest nadpisywana i proces rodzic nie ma szansy zliczyć SIGUSR2!!!

  • Jak uruchomić ten program aby zminimalizować szansę na sklejanie się SIGUSER2 i jednocześnie obserwować różnice w licznikach?

    Odpowiedź
    Uruchomić dla małych czasów i dużych ilości SIGUSR1 między SIGUSR2, teraz jest prawie zerowa szansa na sklejenie SIGUSR2, za to duża na wykonanie wiele razy funkcji obsługi sygnału w obrębie jednego sigsuspend

  • Popraw powyższy program tak aby wyeliminować problem wielu wywołań obsługi sygnału w obrębie jednego sigsuspend

    Odpowiedź
    Można to zrobić np. dodając drugą zmienną globalną tylko do obsługi SIGUSR2, zwiększanie zmiennej count też można przenieść do funkcji obsługi sygnału w ten sposób uniknie się potencjalnego problemu z obsługą dwóch SIGUSR2 w obrębie jednego sigsuspend. Trzeba jeszcze przebudować kod związany z wypisywaniem zmienionego licznika count w rodzicu i gotowe.

Operacje niskopoziomowe na plikach, a sygnały #

W tej części tutoriala na samym początku pokażemy z jakimi problemami można się spotkać przy okazji operacji na plikach przy jednoczesnej obsłudze sygnałów, a następnie pokażemy jak możemy sobie z nimi radzić.

Zadanie #

Zmodyfikować poprzedni program tak, aby proces rodzic odbierał sygnały SIGUSR1 wysyłane co zadany czas (parametr 1) i zliczał je. Dodatkowo proces główny tworzy plik o nazwie podanej jako parametr 4 o zadanej ilości bloków o zadanym rozmiarze (parametry 2 i 3). Zawartość pliku ma pochodzić z /dev/urandom. Każdy blok kopiujemy osobno, kontrolując rozmiary. Po skopiowaniu bloku należy podać na stderr realną ilość przepisanych bloków oraz stan liczników sygnałów.

Co student musi wiedzieć:

  • man 4 urandom

Niepoprawne rozwiązanie #

plik prog16a.c:

#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>

#define ERR(source) \
    (fprintf(stderr, "%s:%d\n", __FILE__, __LINE__), perror(source), kill(0, SIGKILL), exit(EXIT_FAILURE))

volatile sig_atomic_t sig_count = 0;

void sethandler(void (*f)(int), int sigNo)
{
    struct sigaction act;
    memset(&act, 0, sizeof(struct sigaction));
    act.sa_handler = f;

    if (-1 == sigaction(sigNo, &act, NULL))
        ERR("sigaction");
}

void sig_handler(int sig) { sig_count++; }

void child_work(int m)
{
    struct timespec t = {0, m * 10000};
    sethandler(SIG_DFL, SIGUSR1);

    while (1)
    {
        nanosleep(&t, NULL);
        if (kill(getppid(), SIGUSR1))
            ERR("kill");
    }
}

void parent_work(int b, int s, char *name)
{
    int i, in, out;
    ssize_t count;
    char *buf = malloc(s);
    if (!buf)
        ERR("malloc");

    if ((out = open(name, O_WRONLY | O_CREAT | O_TRUNC | O_APPEND, 0777)) < 0)
        ERR("open");

    if ((in = open("/dev/urandom", O_RDONLY)) < 0)
        ERR("open");

    for (i = 0; i < b; i++)
    {
        if ((count = read(in, buf, s)) < 0)
            ERR("read");

        if ((count = write(out, buf, count)) < 0)
            ERR("read");

        if (fprintf(stderr, "Block of %ld bytes transferred. Signals RX:%d\n", count, sig_count) < 0)
            ERR("fprintf");
    }

    if (close(in))
        ERR("close");
    if (close(out))
        ERR("close");
    free(buf);
    if (kill(0, SIGUSR1))
        ERR("kill");
}

void usage(char *name)
{
    fprintf(stderr, "USAGE: %s m b s \n", name);
    fprintf(stderr,
            "m - number of 1/1000 milliseconds between signals [1,999], "
            "i.e. one milisecond maximum\n");
    fprintf(stderr, "b - number of blocks [1,999]\n");
    fprintf(stderr, "s - size of of blocks [1,999] in MB\n");
    fprintf(stderr, "name of the output file\n");
    exit(EXIT_FAILURE);
}

int main(int argc, char **argv)
{
    int m, b, s;
    char *name;

    if (argc != 5)
        usage(argv[0]);

    m = atoi(argv[1]);
    b = atoi(argv[2]);
    s = atoi(argv[3]);
    name = argv[4];

    if (m <= 0 || m > 999 || b <= 0 || b > 999 || s <= 0 || s > 999)
        usage(argv[0]);

    sethandler(sig_handler, SIGUSR1);

    pid_t pid;
    if ((pid = fork()) < 0)
        ERR("fork");
    if (0 == pid)
        child_work(m);
    else
    {
        parent_work(b, s * 1024 * 1024, name);
        while (wait(NULL) > 0)
        {
        }
    }

    return EXIT_SUCCESS;
}

Pamiętaj, z pliku /dev/random możesz pobrać na prawdę losowe bajty ale w małych ilościach, z /dev/urandom odwrotnie, pseudo losowe liczby za to w dowolnych ilościach.
Zawsze gdy w poprawnym programie pojawia się alokacja pamięci musi być też jej zwalnianie!
Uprawnienia podawane w funkcji open mogą być także podane przy użyciu stałych (man 3p mknod), wyjątkowo ze względu na bardzo silne zakorzenienie notacji oktalnej u programistów i administratorów oraz na fakt, że łatwo takie liczby w kodzie wyszukać nie uznajemy tego za błąd stylu tzw. “magic numbers”.

Problemy #

Po wywołaniu programu z parametrami 1 20 40 out.txt powinieneś obserwować następujące problemy:

  • Kopiowanie krótszych bloków niż zadano, na moim laptopie nigdy nie przekraczam 33554431 a powinno być 40MB, ale pojawiają się też i krótsze, powód to przerwanie odczytu (W TRAKCIE) obsługą sygnału
  • Możliwe wystąpienie błędu fprintf: Interrupted system call - przerwanie funkcją obsługi sygnału funkcji fprintf ZANIM ta coś wyświetli
  • Analogiczne komunikaty dla open i close - może to być trudno zaobserwować w tym programie ale jest to możliwe wg. POSIX
  • Widać, że zliczamy w rodzicu mniej sygnałów niż wysyła potomek, ponieważ sumowanie odbywa się bezpośrednio w nieblokowanej obsłudze sygnału to łatwo się domyślić, że w grę wchodzi sklejanie się sygnałów, pytanie czemu w tym programie to sklejanie jest aż tak silne?
    Odpowiedź
    w tej architekturze (GNU/Linux) planista procesora blokuje uruchomienie obsługi sygnału podczas większych operacji IO, w tym czasie sygnały się sklejają.

Uwagi i pytania #

W jakim celu proces rodzic na zakończenie wysyła do całej grupy SIGUSR1?

Odpowiedź
Aby zakończyć proces potomny.

Jak proces potomny może się zakończyć po nadejściu SIGUSR1 skoro dziedziczy obsługę tego sygnału?

Odpowiedź
Zaraz po starcie potomka przywracana jest domyślna reakcja na ten sygnał, która właśnie zapewnia zabicie procesu.

Czemu proces rodzic nie zabija się sam tym sygnałem?

Odpowiedź
Ma włączoną obsługę tego sygnału zanim wyśle sygnał do grupy.

Czy taka strategia może się nie powieść?

Odpowiedź
Tak, jeśli proces rodzic upora się ze swoim zadaniem zanim proces potomny zmieni dyspozycję odnośnie `SIGUSR1` na domyślną.

Czy można to jakoś poprawić? Tzn. proces rodzic zawsze zabije potomka ale jednocześnie sam nie narazi się na przedwczesną śmierć?

Odpowiedź
Wyślij do potomka `SIGUSR2`.

Czy taka strategia zakończenia potomka zawsze jest poprawna i łatwa do przeprowadzenia?

Odpowiedź
Tylko jeśli proces zabijany nie posiada zasobów, jeśli by takowe posiadał to musisz dodać obsługę sygnału kończącego co nie musi być łatwe.

Czemu po wywołaniu alokacji pamięci sprawdzamy przydzielony wskaźnik czy aby nie jest NULL?

Odpowiedź
System może nie móc nam przydzielić więcej pamięci, musimy być na to przygotowani. Brak tego sprawdzania jest bardzo częstym błędem w pracach studentów.

Czy nie dałoby się tego bufora uczynić zmienną automatyczną i uniknąć kodu związanego z alokacją i zwalnianiem?

Odpowiedź
Nie ma w znanych mi architekturach tak dużych stosów aby można było na nich umieszczać tak duże zmienne (40MB dla przykładowych parametrów), zazwyczaj stos ma wymiar do kilku megabajtów, gdyby nasz bufor był mały (kilka KB) to moglibyśmy tak zrobić.

Czemu uprawnienia do nowego pliku są pełne (0777)?

Odpowiedź
umask zredukuje uprawnienia, jeśli nie chcemy mieć konkretnych ustawień to jest to dobra strategia

Rozwiązanie problemów #

W przypadu operacji I/O funkcje mogą być przerwane podczas swojego działania przez funkcję obługi sygnału. W takim wypadku funkcje zwracają wartość -1, która sygnalizuje błąd i ustwiają errno na EINTR. Standard POSIX mówi, że w takim przypadku wykananie funkcji zostaje przerwana zanim ta funkcja coś zrobi. Z tego powodu jak najbardziej poprawną i zalecaną reakcją na ten błąd jest restart funkcji z tymi samymi parametrami, jakie były podane przy pierwszym wywołaniu.

Ręczna obsługa tego błędu może być z czasem niewygodna (szczególnie, gdy wykonujemy dużo operacji I/O). Z tego powodu w tym celu wykorzystamy makro TEMP_FAILURE_RETRY, które jest rozszerzeniem biblioteki C projektu GNU. Tutaj przeczytasz więcej o tym makrze. Aby skorzystać makra musimy wcześniej zdefiniować makro _GNU_SOURCE, które daje nam dostęp do tego typu niestandardowych rozszerzeń.

rozwiązanie drugi etap, plik prog16b.c:

#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>

#define ERR(source) \
    (fprintf(stderr, "%s:%d\n", __FILE__, __LINE__), perror(source), kill(0, SIGKILL), exit(EXIT_FAILURE))

volatile sig_atomic_t sig_count = 0;

void sethandler(void (*f)(int), int sigNo)
{
    struct sigaction act;
    memset(&act, 0, sizeof(struct sigaction));
    act.sa_handler = f;
    if (-1 == sigaction(sigNo, &act, NULL))
        ERR("sigaction");
}

void sig_handler(int sig) { sig_count++; }

void child_work(int m)
{
    struct timespec t = {0, m * 10000};
    sethandler(SIG_DFL, SIGUSR1);

    while (1)
    {
        nanosleep(&t, NULL);
        if (kill(getppid(), SIGUSR1))
            ERR("kill");
    }
}

ssize_t bulk_read(int fd, char *buf, size_t count)
{
    ssize_t c;
    ssize_t len = 0;

    do
    {
        c = TEMP_FAILURE_RETRY(read(fd, buf, count));
        if (c < 0)
            return c;

        if (c == 0)
            return len;  // EOF

        buf += c;
        len += c;
        count -= c;
    } while (count > 0);

    return len;
}

ssize_t bulk_write(int fd, char *buf, size_t count)
{
    ssize_t c;
    ssize_t len = 0;

    do
    {
        c = TEMP_FAILURE_RETRY(write(fd, buf, count));
        if (c < 0)
            return c;

        buf += c;
        len += c;
        count -= c;
    } while (count > 0);

    return len;
}

void parent_work(int b, int s, char *name)
{
    int i, in, out;
    ssize_t count;

    char *buf = malloc(s);
    if (!buf)
        ERR("malloc");

    if ((out = TEMP_FAILURE_RETRY(open(name, O_WRONLY | O_CREAT | O_TRUNC | O_APPEND, 0777))) < 0)
        ERR("open");

    if ((in = TEMP_FAILURE_RETRY(open("/dev/urandom", O_RDONLY))) < 0)
        ERR("open");

    for (i = 0; i < b; i++)
    {
        if ((count = bulk_read(in, buf, s)) < 0)
            ERR("read");

        if ((count = bulk_write(out, buf, count)) < 0)
            ERR("read");

        if (TEMP_FAILURE_RETRY(fprintf(stderr, "Block of %ld bytes transferred. Signals RX:%d\n", count, sig_count)) <
            0)
            ERR("fprintf");
    }

    if (TEMP_FAILURE_RETRY(close(in)))
        ERR("close");
    if (TEMP_FAILURE_RETRY(close(out)))
        ERR("close");
    free(buf);
    if (kill(0, SIGUSR1))
        ERR("kill");
}

void usage(char *name)
{
    fprintf(stderr, "USAGE: %s m b s \n", name);
    fprintf(stderr,
            "m - number of 1/1000 milliseconds between signals [1,999], "
            "i.e. one milisecond maximum\n");
    fprintf(stderr, "b - number of blocks [1,999]\n");
    fprintf(stderr, "s - size of of blocks [1,999] in MB\n");
    fprintf(stderr, "name of the output file\n");
    exit(EXIT_FAILURE);
}

int main(int argc, char **argv)
{
    int m, b, s;
    char *name;

    if (argc != 5)
        usage(argv[0]);

    m = atoi(argv[1]);
    b = atoi(argv[2]);
    s = atoi(argv[3]);
    name = argv[4];

    if (m <= 0 || m > 999 || b <= 0 || b > 999 || s <= 0 || s > 999)
        usage(argv[0]);

    sethandler(sig_handler, SIGUSR1);

    pid_t pid;
    if ((pid = fork()) < 0)
        ERR("fork");
    if (0 == pid)
        child_work(m);
    else
    {
        parent_work(b, s * 1024 * 1024, name);
        while (wait(NULL) > 0)
        {
        }
    }

    return EXIT_SUCCESS;
}

Uruchamiamy jak poprzednio - błędy znikają.

Uwagi i pytania #

Jakie inne przerwania w programie może spowodować funkcja obsługi sygnału?

Odpowiedź
Może przerwać operacje IO lub spanie, nie jest to raportowane przez EINTR, w obu przypadkach reakcja na takie zdarzenie nie jest prosta.

Skąd wiemy, które funkcje mogą być przerwane zanim coś osiągną (EINTR)?

Odpowiedź
Strony man pages, dział o zwracanych błędach. Łatwo zgadnąć, że to te funkcje, które mogą/muszą czekać zanim coś zrobią.

Jako ważne ćwiczenie przeanalizuj jak działa bulk_read i bulk_write. Musisz rozumieć czemu uwzględniają tak dużo przypadków, jakie to przypadki, kiedy operacja IO może być przerwana, jak rozpoznać EOF. W przeciwieństwie do laboratorium L1, na L2 i kolejnych trzeba używać tych funkcji (lub analogicznych) gdy używasz read lub write (ponieważ w programie mamy już sygnały). I brak będzie powodował odejmowanie punktów.

Obie funkcje bulk_ mogą być pomocne nie tylko gdy chodzi o ochronę przed sygnałami lub sklejanie dużych transferów I/O, ale także tam gdzie dane nie są dostępne w sposób ciągły - pipe/fifo/gniazda które poznamy nieco później.

Podobnie jak read/write zachowują się wszystkie funkcje pokrewne takie jak fread/fwrite czy send/recv

Warto sobie uświadomić czemu użycie flagi SA_RESTART w sa_flags podczas ustawiania funkcji obsługi sygnału nie rozwiązuje nam problemu z EINTR:

  • Z góry musimy wiedzieć jakie sygnały będą obsługiwane w naszym programie i wszystkie one muszą być włączone z tą flagą, wystarczy jeden bez tej niej i problem EINTR powraca. Łatwo o taki błąd jeśli powrócimy do starszego kodu, łatwo zapomnieć o tym wymogu.

  • Jeśli chcemy napisać sobie funkcję biblioteczną (np. bulk_read) to nie możemy nic zakładać o obsłudze sygnałów w programie używającym naszej biblioteki.

  • Nie możemy łatwo przenieść takiego kodu, w programie docelowym musiałaby być dokładnie taka sama obsługa sygnałów.

  • Czasem zależy nam na tym, aby właśnie być informowanym o przerwaniu, jaskrawym przykładem jest funkcja sigsuspend, która z tą flagą traci sens!

Po wywołaniu fprintf nie sprawdzamy błędów innych niż EINTR, czemu?

Odpowiedź
Jeśli nie możemy pisać na stderr (zapewne ekran) to i tak nie zaraportujemy błędu.

Zwróć uwagę, że naprawdę duże (f)printf’y mogą być przerwane także w trakcie wypisywania! Trudno będzie coś mądrego z tym zrobić, zwłaszcza jeśli do tego wypisywania używamy skomplikowanych formatów. Co prawda funkcja zwróci ile znaków wypisała ale jak to potem zrestartować? Zazwyczaj nie łatwo jest nam policzyć ile znaków powinno być wypisane, tym bardziej nie mamy jak wyznaczyć od którego momentu trzeba wznowić wypisywanie. Dlatego funkcji tej rodziny unikamy tam gdzie restartowanie będzie krytyczne i musimy wypisać dużo danych. Tam gdzie błąd braku restartu nie jest krytyczny ( wypisywanie na ekran) lub gdy ilość danych jest mała tam nie ma tego problemu.

Przykładowe zadania #

Wykonaj przykładowe zadania. Podczas laboratorium będziesz miał więcej czasu oraz dostępny startowy kod, jeśli jednak wykonasz poniższe zadania w przewidzianym czasie, to znaczy, że jesteś dobrze przygotowany do zajęć.

Kody źródłowe z treści tutoriala #