UNIX
Cel Przedmiotu Doskonalenie umiejętności poprawnego programowania w systemach klasy UNIX Nabycie i rozwinięcie technik pisania przenośnego kodu w C Opanowanie standardu POSIX w zakresie funkcji systemowych (API jądra) Poznanie poprawnych i niepoprawnych rozwiązań typowych zadań programistycznych (np. jak poprawnie używać funkcji write)
Nie Jest Celem Przedmiotu Powtarzanie podstawowych informacji z zakresu Systemów Operacyjnych Nauka poprawnego kodowania w C Nauka poprawnego pisania procedur Nauka używania Linuksa Nauka używania poleceń konsolowych To wszystko student powinien już umieć, ewentualnie samodzielnie takie braki uzupełnić.
Nie Jest Celem Przedmiotu Tłumaczenie różnic pomiędzy systemami operacyjnymi Przekonywanie o wyższości systemów UNIX/Linux nad innymi systemami Prowadzenie dyskusji na temat sensu kodowania w C podczas laboratoriów Opowiadanie masy ciekawych ale nie przekładających się na praktykę programowania informacji o UNIX'ie, POSIX'ie itp. Przytaczanie składni funkcji z man'a
Ocena Ocena z przedmiotu jest oceną z laboratorium Nie będzie testów ani zaliczeń wykładu Wykład ma pomóc w prawidłowym napisaniu kolejnych ćwiczeń Bez systematycznej samodzielnej pracy dramatycznie obniżają się szanse na zaliczenie! W poprzednich latach zaliczało: 80% studentów (2006),68% studentów (2007) 70% studentów (2008),76% studentów (2009)
Plan Wykładu Wstęp Sygnały i procesy Pliki i katalogi Pipe i FIFO IPC Socket'y unix tcp udp Wątki Asynchroniczne I/O
Materiały Dodatkowe Książki: UNIX. Programowanie usług sieciowych - tom 1 - API: gniazda i XTI, WNT Richard W. Stevens UNIX. Programowanie usług sieciowych - tom 2 - Komunikacja międzyprocesowa, WNT Richard W. Stevens Programowanie w systemie UNIX dla zaawansowanych, WNT 2007!!! Marc J. Rochkind (słabe tłumaczenie) Materiały elektroniczne są podane w instrukcji do laboratoriów Quiz'y (Ang.) : https://e.mini.pw.edu.pl/
Uwagi o Książkach Zawierają błędy, zwłaszcza w przykładach (podane zawierają ich mniej) Ze względów dydaktycznych zawierają daleko idące uproszczenia Są zawsze mało aktualne Książka Rochkinda w starszym wydaniu jest dla tego przedmiotu kompletnie nieprzydatna Nie spotkałem się z publikacją uwzględniającą od początku sygnały, standaryzację i przenośność
Uwagi o Przenośności Różnice w implementacjach UNIX'ów są na tyle duże, że bardziej złożony program napisany w C nie może być w 100% przenośny W praktyce stosuje się warstwy pośrednie w dostępie do zasobów systemu lub definiuje się skomplikowane konstrukcje #define sterowane odpowiednimi testami konfiguracji systemu przed kompilacją
Uwagi o Przenośności Standardy, takie jak POSIX pozwalają na zmniejszanie nakładu pracy na warstwy pośrednie lub #define, zwiększają też czytelność oraz ułatwiają zrozumienie kodu Podczas laboratorium postaramy się osiągnąć możliwie dużą zgodność z standardem POSIX. Tam, gdzie to nie będzie gwarantowało jednocześnie przenośności i poprawności skorzystamy z rozwiązań GNU
Uwagi o Modularyzacji Poprawnie napisane funkcje można używać w wielu programach, niepoprawnie - tylko w jednym. Szczególnie niemodularne rozwiązania będą skutkowały karą punktową Własna biblioteka funkcji pomocniczych znacznie ułatwi proces pisania rozwiązań do kolejnych zadań!
Uwagi o API Jądra Funkcje mogą kończyć się błędem, nawet jeśli wydaje się to mało prawdopodobne. Należy zawsze sprawdzać jaki jest status wykonania funkcji Powyższa uwaga jest szczególnie ważna w odniesieniu do funkcji systemowych. Błąd na tym poziomie może oznaczać poważne problemy w dalszym prawidłowym działaniu naszego programu. W takiej sytuacji lepiej z gracją program zakończyć niż pozwolić mu się zawiesić.
Uwagi o API Jądra W praktyce często spotyka się kod ignorujący błędy. Dobrze, jeśli jest to świadomie podjęte ryzyko a nie niewiedza programisty. Trzeba znać reguły aby móc je potem czasem łamać Na laboratorium obowiązuje reżim sprawdzania każdego krytycznego błędu! Wolno ignorować tylko takie błędy, które na pewno (100%) nigdy nie będą mogły wystąpić Przykład: funkcja kill (man 2 kill, man 3p kill):
Uwagi o API Jądra EINVAL An invalid signal was specified. EPERM The process does not have permission to send the signal to any of the target processes. ESRCH The pid or process group does not exist. Note that an existing process might be a zombie, a process which already committed termination, but has not yet been wait(2)ed for. Błąd EINVAL możemy zazwyczaj wykluczyć, o ile numer sygnału generujemy sami lub jest on stały Podobnie EPERM w typowych przypadkach Nie można wykluczyć błędu ESRCH, zatem sprawdzanie błędów funkcji kill jest niezbędne
Uwagi o API Jądra Sprawdzenie ewentualnego błędu funkcji powinno przebiegać dwuetapowo: Sprawdzenie czy funkcja zgłasza błąd najczęściej funkcja zwraca wartość -1 czasem (f. malloc) jest to NULL informacja jak błąd jest sygnalizowany jest zawsze opisana w man Sprawdzenie jaki błąd wystąpił poprzez analizę zmiennej globalnej errno (opcjonalnie)
Uwagi o API Jądra Sama analiza errno nie wystarcza, konieczne jest sprawdzenie, czy w ogóle błąd wystąpił. Zatem poprawny fragment kodu wygląda następująco: if (-1 == sigaction(signo, &act, NULL)) { } fprintf(stderr, "%s:%d\n", FILE, LINE ); perror("sigaction"); kill(0, SIGINT); exit(exit_failure); Jak widać - nie zawsze musimy wnikać w przyczynę błędu. Ważne, że błąd wystąpił.
Uwagi o API Jądra Typową reakcją na błąd (w programie laboratoryjnym) jest zakończenie działania programu. Jedynym wyjątkiem jest błąd EINTR, który tak naprawdę błędem nie jest (p. sygnały) W praktyce czasem błąd będzie można naprawić Błędy należy sprawdzać po każdym wywołaniu funkcji, zatem warto napisać sobie własną funkcję lub makro do ich obsługi Co z błędami występującymi podczas obsługi błędu? Z konieczności zostaną zignorowane
Uwagi o API Jądra Błąd EINTR: To nie jest błąd Wiele funkcji, zwłaszcza tych czekających, może być przerwanych obsługą sygnału zanim tak naprawdę cokolwiek zostanie wykonane. W takiej sytuacji najczęściej musimy powtórzyć wywołanie przerwanej funkcji Zamiast mozolnie pisać pętle przy każdej funkcji wygodniej użyć makra GNU:
Uwagi o API Jądra #define _GNU_SOURCE #include <unistd.h>... TEMP_FAILURE_RETRY(wait(NULL)); Można też to makro (licencja LGPL) skopiować aby uniknąć pogarszającego przenośność _GNU_SOURCE: #define TEMP_FAILURE_RETRY(expression) \ ( extension ({ long int result; \ do result = (long int) (expression);\ while ( result == -1L && errno == EINTR);\ result; }))
Uwagi o API Jądra Błąd EINTR, a także inne błędy, mogą być opisane tylko w man standardu POSIX Przykładowo man 3 fflush wspomina tylko o jednym błędzie, podczas gdy man 3p fflush wymienia ich aż 10! w tym EINTR Obowiązują manuale POSIX! standard POSIX mówi, że funkcje rodziny printf mogą zgłaszać także te same błędy co fputc, zatem także EINTR, taka sytuacja bardzo komplikuje zaawansowane użycie tych funkcji. Podczas laboratorium dopuszczalne jest ignorowanie tego typu błędów ale tylko w przypadku pisania na stdout lub stderr
Uwagi o Zasobach Zasoby to nie tylko procesor i pamięć. Limitowanymi zasobami w szczególności są ilość procesów oraz liczba otwartych deskryptorów Pamięć Tam gdzie to możliwe należy używać zmiennych automatycznych (prawie wszystkie zadania można zrobić opierając się o takie zmienne) Dynamicznie przydzieloną pamięć należy zawsze zwalniać (para malloc - free)
Uwagi o Zasobach CPU Unikać tzw. busy waiting Przykład bardzo negatywny: while (0 == waitpid(0, NULL, WNOHANG)); Przykład mniej negatywny (dalej niepoprawny) while (0 == waitpid(0, NULL, WNOHANG)) sleep(1); Przykład poprawny (p. sygnały) while (TEMP_FAILURE_RETRY(wait(NULL)) > 0);
Uwagi o Zasobach Deskryptory Reprezentują nie tylko pliki, ale także i socket'y, FIFO, pipe, directory stream. Zamykamy, gdy tyko przestają być potrzebne (f. close i closedir) Procesy Aby zapobiec wyczerpaniu limitu procesów (per user) należy unikać tzw. procesów zombie, obsługując sygnał SIGCHLD (p. sygnały)
Uwagi o Zasobach Zwalnianie zasobów podczas krytycznego wyjścia z programu polega na wysłaniu sygnału kończącego inne procesy w aplikacji i wywołaniu funkcji exit, która zwalnia pamięć i deskryptory. Wywołanie funkcji exit nie ma wpływu na procesy potomne, które po zakończeniu procesu rodzica przejęte zostają przez proces init, w wyniku czego działają dalej - stąd konieczność zasygnalizowania im, że czas kończyć pracę.
Uwagi o Zasobach Normalne zakończenie programu wymaga zazwyczaj jednak innego podejścia: Zasoby należy zwalniać, gdy tylko przestają być potrzebne, czekanie na koniec programu jest tu błędem Myśląc przyszłościowo można założyć, że to, co jest teraz końcem funkcji main, może kiedyś stać się końcem modułu, gdzie zasoby trzeba zwolnić normalnie. Na procesy potomne trzeba poczekać (f. wait)
Uwagi o Zmiennych Globalnych Bez bardzo ważnego powodu nie używa się zmiennych globalnych, gdyż prowadzą one do niejawnych zależności pomiędzy funkcjami Jednym z takich powodów, dla którego skorzystamy ze zmiennej globalnej są funkcje obsługi sygnałów. Zmienne te łatwo rozpoznać po typie (p. sygnały): volatile sig_atomic_t Inne wyjątki: funkcja ftw (rzadko) nieuzasadnione zmienne globalne będą ujemnie punktowane podczas laboratorium
UNIX Procesy i sygnały
Procesy Powtórzmy podstawowe fakty Procesy tworzą drzewo (f. pid, ppid) Proces potomny musi być zwolniony (f. wait, waitpid) przez proces - rodzica Proces rodzic jest informowany o zakończeniu procesu potomnego sygnałem SIGCHLD Proces potomny który zakończył działanie a nie został zwolniony przez rodzica nazywamy zombie (<defunct>) Zasoby zombie są zwalniane (poza minimalną informacją), ale sam proces liczy się do limitu procesów
Procesy Jeśli proces - rodzic zakończy się przed procesami potomnymi, procesy potomne są adoptowane przez proces init(1) - także zombie. Nie jest to jednak sytuacja naturalna i należy jej unikać init oraz shell czekają na zakończenie procesów potomnych (wykonują wait) Funkcja fork tworzy proces potomny (również f. system, ale w innych zastosowaniach) Proces potomny rozpoczyna wykonanie w tym samym stanie i miejscu co proces rodzic (czyli od momentu zwrócenia statusu przez funkcję fork)
Procesy Analiza wartości zwracanej przez fork pozwala na zróżnicowanie dalszego działania obu procesów Proces potomny i proces - rodzic są niezależne i niezwiązane za wyjątkiem: pipe pozycji karetki w otwartych plikach Typowe błędy popełniane po wykonaniu fork to: śmieci w buforach stdin i stdout (f. fflush) niezwalnianie nieużywanych zasobów dziwne efekty spowodowane wspólną pozycją karetki w pliku
Procesy Proces potomny różni się od rodzica: pid, ppid własne deskryptory plików (uwaga na karetkę) ma wyzerowane liczniki czasu procesora ma wyzerowane alarmy (p. funkcja alarm) nie dziedziczy blokad plików (p. pliki i katalogi) nie dziedziczy czekających (blokowanych) sygnałów
Procesy Procesy wynikające z wykonania pojedynczej komendy tworzą process group w obrębie takiej grupy łatwiej wysyłać sygnały (f. kill) i czekać na zakończenie (f. waitpid) wysłanie SIGINT z terminala (C-c) oznacza właśnie rozesłanie sygnału do całej grupy można zmienić grupę do której należy proces (f. setpgid), ale nie będziemy tego robić podczas laboratorium odczyt numeru grupy (f. getpgid) więcej na temat grup procesów można przeczytać w dziale Job Control dokumentacji glibc
Sygnały Powtórzmy podstawowe fakty Sygnały to najprostszy sposób komunikacji międzyprocesowej używany do informowania procesu o pewnych zdarzeniach: Systemowych, np.: zakończenie procesu potomka (SIGCHLD) błąd obliczeń np. dzielenia przez 0 (SIGFPE) błąd segmentacji (SIGSEGV) systemowy stoper (SIGALRM) asynchroniczne I/O (SIGIO) zerwane połączenie (SIGPIPE)
Sygnały Przeznaczonych do implementacji przez programistę (f. kill) SIGUSR1 i SIGUSR2 SIGRTMIN do SIGRTMAX Generowanych przez użytkownika z konsoli lub przy użyciu polecenia kill SIGINT (C-c), SIGQUIT (C-\ ) SIGSTOP (C-z) SIGKILL
Sygnały W programie sygnały możemy generować niejawnie: funkcjami alarm i setitimer (nie wolno ich mieszać) używając f. sleep (może używać SIGALRM) kończąc działanie procesu (sygnał dla rodzica) operacje aio (p. aio) Oraz jawnie f. kill może wysłać sygnał do pojedynczego procesu lub do grupy procesów f. sigqueue wysyła sygnał do pojedynczego procesu, dotyczy sygnałów Real Time wysyłanych wraz z daną
Sygnały Reakcja na sygnał domyślna (zazwyczaj zakończenie programu lub ignorowanie), są to dobrze dobrane zachowania i bez wyraźnej potrzeby ich nie zmieniamy ustawione ignorowanie ustawiona funkcja obsługi domyślna reakcja na sygnały SIGKILL i SIGSTOP nie może być zmieniona Ustawianie obsługi sygnału (f. sigaction) pozwala na wywołanie funkcji obsługi sygnału Nie używamy starszej funkcji signal
Sygnały Funkcja obsługi sygnału: nie przyjmuje dodatkowych parametrów, zatem komunikacja z resztą programu musi odbywać się poprzez zmienne globalne ze względu na możliwe skutki uboczne zmienne takie muszą mieć zapewnioną atomowość operacji. Jedyny typ danych gwarantujący takie zachowanie to : volatile sig_atomic_t ze względu na blokowanie reszty programu wykonanie funkcji obsługi musi być jak najkrótsze; poważnym błędem jest użycie w funkcji obsługi funkcji czekających (np. sleep) aktualnie obsługiwany sygnał jest na czas wykonania funkcji obsługi blokowany, inne sygnały można opcjonalnie zablokować - o ile zostanie to odpowiednio uzasadnione
Sygnały Najprostsza i najlepsza funkcja obsługi sygnału zmienia jedynie globalną zmienną stanu, pozostawiając właściwą reakcję na sygnał głównemu programowi. Użyteczne flagi sigaction: SA_NODEFER nie blokuje aktualnie obsługiwanego sygnału SA_RESTART powoduje niepojawianie się EINTR, ale: nie działa z funkcjami do IPC czasem właśnie chcemy być poinformowani o przybyciu sygnału f. sigsuspend SA_NOCLDWAIT ustawiona dla SIGCHLD oznacza, że po zakończeniu procesy potomne nie staną się zombie, a funkcję wait można wywołać tylko raz - na końcu programu
Sygnały Sygnały klasyczne (nie Real Time) podlegają sklejaniu (z ang. merge), czyli nie wolno polegać na ich zliczaniu, może się okazać że dwa (lub więcej) identyczne sygnały odbierzemy jako jeden Kolejność dostarczenia sygnałów może być inna niż kolejność ich wysłania Obie powyższe sytuacje mogą zachodzić, gdy sygnały były wysłane w krótkim odstępie czasowym lub gdy są one blokowane Wiele systemów implementuje wszystkie sygnały tak, jakby były typu Real Time, ale POSIX tego nie gwarantuje.
Sygnały Sygnały można maskować Tylko te, które w danym momencie są niepożądane, nigdy wszystkie Funkcja sigprocmask Oczekiwanie na wybrany sygnał (f. sigsuspend) Niektóre funkcje posiadają warianty pozwalające na podobną podmianę zbioru blokowanych sygnałów (np.: pselect) Dwie powyższe funkcje często występują razem (patrz przykłady)
Sygnały Sygnały czekające (pending) otrzymanie sygnału, który jest aktualnie maskowany nie ma wpływu na działanie programu, ale w momencie usunięcia maskowania sygnał taki zostanie obsłużony jeśli obsługa sygnału jest ustawiona na ignorowanie to maskowanie nie ma znaczenia - sygnał taki zostaje od razu zignorowany
Sygnały Przerywanie działania funkcji czekających przez nadejście sygnału (EINTR) Nie dotyczy sygnałów ignorowanych i blokowanych Blokowanie sygnałów (zwłaszcza wszystkich) w celu uniknięcia takiej sytuacji jest błędem, należy użyć makra TEMP_FAILURE_RETRY lub flagi SA_RESTART Przerywanie operacji I/O Funkcje rodziny read / write mogą zostać przerwane w trakcie ich wykonywania, co będzie oznaczać fizyczny odczyt/zapis mniejszej ilości danych, niż zlecono (p. Pliki)
Sygnały Real-Time Sygnały Real Time (RT) Służą wyłącznie komunikacji międzyprocesowej Numery od SIGRTMIN do SIGRTMAX Są kolejkowane (ale kolejka ta ma ograniczony rozmiar) Kolejność obsługi sygnałów odzwierciedla kolejność ich odbioru przez proces (kolejka FIFO) Przenoszą prostą daną liczbową (int lub pointer) Dodatkowa funkcję sigqueue używamy, gdy: trzeba przesłać daną możliwe jest przepełnienie kolejki sygnałów (błąd EAGAIN)
Procesy i Sygnały - przykłady Jak spać : W sekundach: int tt, t = 5; for (tt = t; tt > 0; tt = sleep(tt)); Nie wolno mieszać takiego spania z funkcją alarm! Spanie wykorzystuje sygnał SIGALRM - nie wolno go blokować na czas spania W nanosekundach (nie wpływa na SIGALRM): struct timespec tt, t = {0, 200}; for(tt=t;nanosleep(&tt,&tt);) if(eintr!=errno) ERR("nanosleep:");
Procesy i Sygnały przykłady Jak eliminować procesy zombie Handler do SIGCHLD void sigchld_handler(int sig) { } pid_t pid; for (;;) { pid = waitpid(0, NULL, WNOHANG); if (0 == pid) return; if (0 >= pid) { if (ECHILD == errno) return; ERR("waitpid:"); } }
Procesy i Sygnały przykłady Na końcu programu i tak trzeba poczekać: while (TEMP_FAILURE_RETRY(wait(NULL)) > 0); Nie wiadomo, który wait zadziała jako pierwszy, oba muszą być obecne Jak zablokować wybrane sygnały: sigset_t s; for (sigemptyset(&s), i = SIGRTMIN; i <= SIGRTMAX; i++) sigaddset(&s,i); sigprocmask(sig_block, &s, NULL);
Procesy i Sygnały przykłady Jak czekać na konkretny sygnał (SIGUSR1): Przykład z opisu biblioteki glibc Wartość zmiennej usr_interrupt jest zmieniana w funkcji obsługi sygnału sigset_t mask, oldmask; sigemptyset(&mask); sigaddset(&mask, SIGUSR1); sigprocmask(sig_block, &mask, &oldmask);... while(!usr_interrupt) sigsuspend(&oldmask); sigprocmask(sig_unblock, &mask, NULL);