Zarządzanie procesami

ŁF

LINUX - Zarządzanie procesami.

1. Podstawowe zagadnienia

Proces pełni rolę podstawowej jednostki pracy systemu operacyjnego. System wykonuje programy użytkowników pod postacią procesów. Omówienie procesów z punktu widzenia użytkownika systemu Linux zostało zamieszczone w lekcji 2. Bez wnikania w budowę wewnętrzną samego systemu, przedstawiliśmy tam podstawowe pojęcia oraz metody uruchamiania procesów i sterowania ich działaniem.

Artykuł ten poświęcamy omówieniu, w jaki sposób jądro systemu Linux realizuje zarządzanie procesami. Początkowo prezentujemy podstawowe zagadnienia dotyczące zarządzania procesami w dowolnym systemie operacyjnym. Nastepnie przedstawiamy sposób reprezentacji procesów w systemie Linux oraz podstawowe atrybuty każdego procesu. Opisujemy działanie planisty przydziału procesora. Omawiamy mechanizmy tworzenia nowych procesów, kończenia procesów oraz uruchamiania programów, a także obsługi sygnałów w procesie. Prezentujemy również funkcje systemowe realizujące wspomniane operacje na procesach.

W wielozadaniowym systemie operacyjnym może działać jednocześnie wiele procesów. Podstawowe zadania związane z zarządzaniem tymi procesami obejmują:

  • tworzenie i usuwanie procesów,
  • wstrzymywanie i wznawianie procesów,
  • planowanie kolejności wykonania procesów,
  • dostarczanie mechanizmów synchronizacji i komunikacji procesów.

Zagadnienia realizacji tych zadań w systemie Linux zostaną omówione w tym i dalszych artykułach.

(1.1) Procesy

Pojęcie procesu zostało wprowadzone w lekcji 2. Przypomnijmy, że proces to wykonujący się program. Pełni on rolę podstawowej jednostki pracy systemu operacyjnego.

Kontekst procesu obejmuje zawartość logicznej przestrzeni adresowej procesu, rejestrów sprzętowych oraz struktur danych jądra związanych z procesem. W systemach Unix i Linux można wyróżnić trzy poziomy kontekstu:

  • kontekst poziomu użytkownika, na który składa się:
    • obszar kodu (instrukcji),
    • obszar danych,
    • obszar stosu,
    • obszary pamięci dzielonej,
  • kontekst poziomu rejestru zawierający:
    • licznik rozkazów,
    • rejestr stanu procesora,
    • wskaźnik stosu,
    • rejestry ogólnego przeznaczenia,
  • kontekst poziomu jądra zawierający:
    • struktury danych jądra opisujące proces,
    • stos jądra.
Proces zawsze wykonuje się w swoim kontekście. Jeśli proces wywoła funkcję systemową, to kod jądra realizujący tę funkcję również wykona się w kontekście bieżącego procesu.

Procesor przerywa wykonywanie kodu bieżącego procesu w sytuacji, gdy:

  • wystąpi przerwanie z czasomierza informujące o wykorzystaniu przydzielonego kwantu czasu procesora,
  • wystąpi dowolne przerwanie sprzętowe lub programowe (pułapka),
  • proces wywoła funkcję systemową.
Zachodzi wtedy konieczność zapamiętania kontekstu przerwanego procesu, aby można było później wznowić jego wykonywanie od miejsca, w którym zostało przerwane. Czynność tę określa się zachowaniem kontekstu procesu.

Każdy proces reprezentowany jest w systemie operacyjnym przez specjalną strukturę danych, określaną zwykle jako blok kontrolny procesu. Struktura ta przechowuje atrybuty procesu takie, jak:

  • aktualny stan procesu,
  • informacje związane z planowaniem procesów,
  • informacje o pamięci logicznej procesu,
  • informacje o stanie operacji wejścia - wyjścia,
  • informacje do rozliczeń,
  • zawartość rejestrów procesora.

Procesy działające współbieżnie w systemie wielozadaniowym mogą współpracować lub być niezależne od siebie. Współpracujące procesy wymagają od systemu dostarczenia mechanizmów komunikowania się i synchronizacji działania. Problematyka ta zostanie przedstawiona w lekcji 9.

(1.2) Wątki
</p>

Wątek to sekwencyjny przepływ sterowania w programie. Jeden program może być wykonywany jednocześnie przez jeden lub więcej wątków przy czym rzeczywista współbieżność jest ograniczona przez liczbę procesorów. Grupa współpracujących wątków korzysta z tej samej przestrzeni adresowej i zasobów systemu operacyjnego. Wątki dzielą dostęp do wszystkich danych z wyjątkiem licznika rozkazów, rejestrów procesora i stosu. Dzięki temu tworzenie i przełączanie wątków odbywa się znacznie szybciej niż tworzenie i przełączanie kontekstu procesów tradycyjnych.

Istnieją trzy modele implementacji wątków:

  1. wątki poziomu użytkownika,
  2. wątki poziomu jądra,
  3. wątki mieszane, tworzące dwupoziomowy system wątków.
<dl> <dt>Wątki użytkownika realizowane są przez funkcje biblioteczne w ramach procesu i nie są widoczne dla jądra. Jądro systemu obsługuje tylko tradycyjne procesy. Przełączanie kontekstu między wątkami powinno być zrealizowane w procesie. <dt>
<dt>Wątki jądra, zwane również procesami lekkimi, realizowane są przez jądro systemu. Jądro zajmuje się planowaniem wątków i przełączaniem kontekstu podobnie jak dla procesów tradycyjnych. <dt>
<dt>Wątki mieszane łączą cechy obydwu realizacji. Jądro zarządza wątkami jądra, które zawierają w sobie wątki użytkownika. <dt>
<dt>Dwupoziomowy system wątków został zrealizowany w systemie operacyjnym Solaris, komercyjnej wersji systemu Unix firmy Sun. Szczegółowe informacje można znaleźć w literaturze [3]. <dt>
Dla systemu Linux zaimplementowano niezależnie wątki jądra i wątki użytkownika. Jądro systemu stosuje ten sam mechanizm do tworzenia procesów i wątków jądra. Określając, które zasoby procesu macierzystego mają być współdzielone z procesem potomnym, decyduje o utworzeniu wątka lub tradycyjnego procesu. </dl>
(1.3) Planowanie procesów

W problemie planowania procesów wyróżnia się trzy zagadnienia:

  1. planowanie długoterminowe (planowanie zadań),
  2. planowanie krótkoterminowe (planowanie przydziału procesora),
  3. planowanie średnioterminowe (wymiana procesów).
Planowanie długoterminowe polega na wyborze procesów do wykonania i załadowaniu ich do pamięci. Stosowane jest przede wszystkim w systemach wsadowych do nadzorowania stopnia wieloprogramowości. W systemach wielozadaniowych (z podziałem czasu) w zasadzie nie jest stosowane.

Planowanie krótkoterminowe polega na wyborze jednego procesu z kolejki procesów gotowych do wykonania i przydzieleniu mu dostępu do procesora. Ten typ planowania dominuje w systemach z podziałem czasu, takich jak Unix, Linux, Windows NT.

Planowanie średnioterminowe polega na okresowej wymianie procesów pomiędzy pamięcią operacyjną i pomocniczą, umożliwiając w ten sposób czasowe zmniejszenie stopnia wieloprogramowości. Stanowi pośredni etap w planowaniu procesów, często stosowany w systemach wielozadaniowych.

(1.4) Planowanie przydziału procesora

Planowanie przydziału procesora nazywane jest również planowaniem krótkoterminowym lub szeregowaniem procesów. W dalszej części podręcznika nazwy te stosowane są zamiennie.

Istotą planowania krótkoterminowego jest sterowanie dostępem do procesora procesów gotowych do wykonania. Dokonując wyboru jednego procesu planista może stosować różne kryteria np.:

  • wykorzystanie procesora,
  • przepustowość systemu,
  • średni czas cyklu przetwarzania procesów,
  • średni czas oczekiwania procesów,
  • średni czas odpowiedzi procesów,
  • wariancja czasu odpowiedzi procesów.
Opracowano wiele różnych algorytmów planowania przydziału procesora. Najważniejsze z nich opisano w tablicy 6.1.

Algorytm Charakterystyka
planowanie metodą FCFS (First Comes First Starts) wybierany jest proces, który przyszedł jako pierwszy
planowanie metodą SJF (Shortest Jumps First) najkrótszy proces wskakuje na początek kolejki
planowanie priorytetowe wykonywany jest proces o najwyższym priorytecie
planowanie rotacyjne procesy są wykonywane "po kawałku" na zmianę
wielopoziomowe planowanie kolejek istnieje kilka kolejek (np.: dla procesów o różnych priorytetach)
wielopoziomowe planowanie kolejek
ze sprzężeniem zwrotnym

Większość z opisanych powyżej algorytmów może być zrealizowana w wersji wywłaszczającej lub niewywłaszczającej. Wywłaszczanie procesów polega na przerywaniu wykonywania bieżącego procesu w celu dokonania ponownego wyboru procesu do wykonania. Jedynie algorytm FCFS nie może być wywłaszczający. Z kolei algorytm rotacyjny zakłada przymusowe wywłaszczenie procesu po upłynięciu przydzielonego kwantu czasu procesora.

2. Procesy w systemie Linux

(2.1) Reprezentacja procesu
</p>

Każdy proces w systemie Linux jest reprezentowany przez strukturę task_struct. Struktura jest dynamicznie alokowana przez jądro w czasie tworzenia procesu. Jedynym odstępstwem jest tu proces INIT_TASK o identyfikatorze PID=0, który jest tworzony statycznie w czasie startowania systemu. Struktury task_struct reprezentujące wszystkie procesy zorganizowane są na dwa sposoby:

  1. w postaci tablicy,
  2. w postaci dwukierunkowej listy cyklicznej.
Starsze wersje jądra systemu (do wersji 2.2 włącznie) używały statycznej tablicy procesów o nazwie task[], zawierającej wskaźniki do struktur task_struct. Rozmiar tablicy był z góry ustalony, co powodowało ograniczenie maksymalnej liczby procesów w systemie. W nowszych wersjach jądra (od wersji 2.4) stosowana jest specjalna tablica o nazwie pidhash[] (Rys.6.1). Jest to tzw. tablica skrótów (ang. hash table). Jej rozmiar jest również ograniczony, ale każda pozycja może wskazywać jeden lub listę kilku procesów. Dzięki temu liczba procesów w systemie jest ograniczna tylko rozmiar dostępnej pamięci. Równomiernym rozmieszczaniem procesów w tablicy zajmuje się specjalna funkcja, która oblicza indeks tablicy wykonując zestaw operacji logicznych na wartości identyfikatora PID procesu. Procesy, dla których funkcja zwróci ten sam indeks tablicy, łączone są w listę. Ta sama funkcja umożliwia szybkie odnalezienie procesu w tablicy na podstawie wartości PID, unikając przeglądania wszystkich indeksów.

Dodatkowo struktury task_struct wszystkich procesów połączone są w dwukierunkową listę cykliczną (rys.6.2). Wskaźniki do poprzedniego i następnego procesu z listy zapisane są jako pola prev_task i next_task w strukturze każdego procesu.

RYS6_1.JPG

Rys. 6.1 Reprezentacja procesów w systemie Linux w postaci tablicy skrótów

RYS6_2.JPG

Rys. 6.2 Reprezentacja procesów w systemie Linux w postaci listy dwukierunkowej

</span>

(2.2) Atrybuty procesu

Atrybuty procesu zapisane w strukturze task_struct można podzielić na kilka kategorii:

stan procesu - stan, w którym znajduje się proces w obecnej chwili,
powiązania z procesami pokrewnymi - wskaźniki do procesu macierzystego, procesów rodzeństwa oraz do sąsiednich procesów na liście dwukierunkowej,
identyfikatory - numery identyfikatorów procesu, grupy, właściciela,
parametry szeregowania - informacje dla planisty procesów dotyczące polityki szeregowania, priorytetu i zużycia czasu procesora,
atrybuty związane z systemem plików - opis plików używanych aktualnie przez proces,
parametry pamięci wirtualnej procesu - opis odwzorowania pamięci wirtualnej procesu w pamięci fizycznej systemu,
odmierzanie czasu - informacje o czasie utworzenia procesu, wykorzystaniu czasu pracy procesora, czasie spędzonym w trybie jądra i w trybie użytkownika oraz o ustawieniach zegarów w procesie,
obsługa sygnałów - informacje o nadesłanych sygnałach oraz o blokowaniu i sposobach obsługi poszczególnych sygnałów w procesie,
kontekst poziomu rejestru - zawartość rejestrów sprzętowych procesora w chwili przerwania bieżącego procesu.

Wybrane kategorie zostaną szczegółowo omówione w dalszej części podręcznika.

(2.3) Identyfikatory

Każdy proces przechowuje w strukturze task_struct następujące identyfikatory procesów:

pid - własny identyfikator PID,
ppid - identyfikator swojego procesu macierzystego PPID,
pgrp - identyfikator grupy procesów, do której należy,
session - identyfikator sesji, do której należy.

Wartości te można odczytać przy pomocy funkcji systemowych:

pid_t getpid(void);
pid_t getppid(void);
pid_t getpgrp(void);
pid_t getpgid(pid_t pid);
Funkcja getpgid() zwraca identyfikator grupy procesu wskazanego argumentem pid.

Wartość pid pozostaje oczywiście niezmienna przez cały okres działania procesu. Identyfikator ppid może ulec zmianie jedynie w wyniku zakończeniu procesu macierzystego i adopcji przez proces init. Identyfikatory grupy i sesji mogą być zmienione przez proces.

Funkcja setpgid() umieszcza proces wskazany przez pid w grupie o identyfikatorze pgid, przy czym bieżący proces można wskazać wartością 0.

int setpgid(pid_t pid, pid_t pgid);

Funkcja setsid() tworzy nową grupę i nową sesję procesów oraz mianuje proces wywołujący ich liderem.

pid_t setsid(void);

Identyfikatory użytkownika i grupy użytkowników określają uprawnienia procesu. Proces przechowuje cztery pary takich identyfikatorów:

uid gid - identyfikatory rzeczywiste,
euid egid - identyfikatory obowiązujące,
suid sgid - identyfikatory zapamiętane,
fsuid fsgid - identyfikatory dostępu do systemu plików.

Identyfikatory rzeczywiste określają właściciela procesu, czyli użytkownika, który uruchomił proces. Można je odczytać przy pomocy funkcji:

uid_t getuid(void);
gid_t getgid(void);
Identyfikatory zapamiętane służą do przechowania wartości identyfikatorów rzeczywistych na czas zmiany ich oryginalnych wartości dokonanych za pomocą funkcji systemowych. Zapamiętanie identyfikatorów następuje automatycznie i nie wymaga dodatkowych działań ze strony procesu.

Identyfikatory obowiązujące wykorzystywane są do sprawdzania uprawnień procesu i z tego względu mają największe znaczenie. Pobranie wartości identyfikatorów obowiązujących umożliwiają odpowiednio funkcje:

uid_t geteuid(void);
gid_t getegid(void);
Zazwyczaj po utworzeniu procesu identyfikatory rzeczywiste, zapamiętane i obowiązujące przyjmują te same wartości równe identyfikatorom właściciela procesu. W trakcie działania każdy proces może zmienić swoje identyfikatory obowiązujące. Jednak tylko procesy administratora działające z euid równym 0 mogą dokonać dowolnej zmiany. Procesy zwykłych użytkowników mogą jedynie ustawić takie wartości, jakie przyjmują ich identyfikatory rzeczywiste lub zapamiętane. Jedyną możliwość zmiany identyfikatorów obowiązujących w zwykłych procesach stwarza wykorzystanie specjalnych bitów w atrybucie uprawnień dostępu do pliku z programem. Jeżeli właściciel programu ustawi bit ustanowienia użytkownika setuid lub bit ustanowienia grupy setgid w prawach dostępu do pliku, to każdy użytkownik będzie mógł uruchomić program z identyfikatorem obowiązującym właściciela programu i własnym identyfikatorem rzeczywistym. W ten sposób uzyska uprawnienia właściciela programu i zachowa możliwość przywrócenia własnych uprawnień oraz przełączania się pomiędzy nimi przy pomocy funkcji systemowych.

Funkcja setreiud() ustawia identyfikator rzeczywisty użytkownika na ruid, a obowiązujący na euid.

int setreuid(uid_t ruid, uid_t euid);
Funkcja setuid() wywołana w procesie zwykłego użytkownika, ustawia identyfikator obowiązujący na uid. W procesie administratora funkcja ustawia wszystkie trzy identyfikatory na uid.
int setuid(uid_t uid);
Funkcja seteuid() ustawia identyfikator obowiązujący użytkownika na euid.
int seteuid(uid_t euid);

Funkcje setregid(), setgid() i setegid() działają analogicznia na identyfikatorach grupy.

int setregid(gid_t rgid, gid_t egid);
int setgid(gid_t gid);
int setegid(gid_t egid);

Identyfikatory dostępu do systemu plików stanowią jakby uboższą wersję identyfikatorów obowiązujących. Używane są tylko do sprawdzania uprawnień dostępu procesu do systemu plików. Nie dają natomiast żadnych innych uprawnień, przez co ich użycie stwarza mniejsze zagrożenie dla bezpieczeństwa procesów i całego systemu. Identyfikatory te przyjmują nowe wartości przy każdej zmianie identyfikatorów obowiązujących. Są dzęki temu praktycznie niewidoczne dla większości programów. Istnieje ponadto możliwość niezależnej zmiany ich wartości za pomocą funkcji:

int setfsuid(uid_t fsuid);
int setfsgid(uid_t fsgid);
(2.4) Informacje dotyczące systemu plików

W strukturze task_struct procesu zdefiniowane są dwa atrybuty dotyczące plików wykorzystywanych przez proces:

files - wskaźnik do tablicy deskryptorów otwartych plików,
fs - wskaźnik do struktury zawierającej:
  • maskę uprawnień do tworzonych plików umask,
  • wskazanie na katalog główny (korzeniowy) procesu,
  • wskazanie na katalog bieżący procesu.

3. Planowanie przydziału procesora

W systemie Linux nie stosuje się planowania długoterminowego. Podstawowym mechanizmem jest planowanie krótkoterminowe, wykorzystujące koncepcję wielopoziomowego planowania kolejek.

(3.1) Wybór procesu i przełączanie kontekstu
</p>

Planista systemu Linux rozróżnia dwa typy procesów:

  • procesy zwykłe,
  • procesy czasu rzeczywistego.
Procesy czasu rzeczywistego muszą uzyskać pierwszeństwo wykonania przez zwykłymi procesami, które działają z podziałem czasu procesora. Z tego względu otrzymują zawsze wyższe priorytety

Planista może zostać wywołany w następujących sytuacjach:

  • po zakończeniu wykonywania bieżącego procesu,
  • po przejściu bieżącego procesu do stanu uśpienia w wyniku oczekiwania na operację wejścia/wyjścia,
  • przed zakończeniem funkcji systemowej lub obsługi przerwania, przed powrotem procesu z trybu jądra do trybu użytkownika.
W dwóch pierwszych przypadkach uruchomienie planisty jest konieczne. W trzecim przypadku konieczność taka występuje, gdy bieżący proces wykorzystał już przydzielony kwant czasu procesora lub pojawił się proces czasu rzeczywistego o wyższym priorytecie. W przeciwnym przypadku proces może być wznowiony.

Za każdym uruchomieniem planista wybiera jeden proces z kolejki procesów gotowych i przydziela mu kwant czasu procesora.

W przypadku wyboru nowego procesu pojawia się konieczność przełączenia kontekstu. Operacja ta polega na zachowaniu kontekstu bieżącego procesu i załadowaniu kontekstu procesu wybranego przez planistę.

(3.2) Algorytm planowania
</p>

Koncepcja wielopoziomowego planowania kolejek zakłada istnienie wielu kolejek procesów gotowych do wykonania. Procesy oczekujące na przydział procesora ustawiane są w konkretnej kolejce na podstawie ich priorytetu statycznego. Każda kolejka grupuje procesy z jednakową wartością priorytetu. Wszystkie procesy z kolejki o wyższym priorytecie statycznym muszą być wykonane wcześniej niż procesy z kolejek i niższym priorytecie. Każda kolejka ma ustaloną własną strategię szeregowania procesów. Niektóre algorytmy mogą korzystać z priorytetów dynamicznych procesów ustalanych na podstawie wykorzystania czasu procesora.

Planista procesów w systemie Linux realizuje koncepcję wielopoziomowego planowania kolejek w następujący sposób.

W strukturze task_struct każdego procesu zapisanych jest kilka atrybutów związanych z planowaniem przydziału procesora:

state - stan procesu,
policy - strategia szeregowania,
rt_priority - priorytet statyczny procesu,
priority - początkowy priorytet dynamiczny procesu,
counter - czas procesora przyznany procesowi, używany jako priorytet dynamiczny procesu.

Wszystkie procesy gotowe do wykonania (znajdujące się w stanie TASK_RUNNING) ustawione są w jednej kolejce do procesora. Planista oblicza wartość specjalnej wagi, którą można uznać za priorytet efektywny, dla każdego procesu z kolejki a następnie wybiera proces o najwyższej wadze. Procesy czasu rzeczywistego rozpoznawane są przez planistę po niezerowych wartościach priorytetu statycznego rt_priority. Uprzywilejowanie tych procesów odbywa się poprzez specjalny sposób obliczania wag:

waga = 1000 + rt_priority - dla procesów czasu rzeczywistego,
waga = counter - dla zwykłych procesów.

Dzięki temu wszystkie procesy czasu rzeczywistego uzyskają dużo wyższe wagi niż zwykłe procesy i będą wykonane wcześniej. O kolejności ich wykonania zadecyduje wartość priorytetu statycznego rt_priority, a przy równych priorytetach - strategia szeregowania policy.

Planista dopuszcza dwie strategie szeregowania procesów czasu rzeczywistego:

SCHED_FIFO - planowanie metodą "pierwszy zgłoszony - pierwszy obsłużony" (FCFS), w którym procesy wykonywane są w kolejności zgłoszenia do kolejki bez ograniczeń czasowych,
SCHED_RR - planowanie rotacyjne, w którym procesy wykonywane są w kolejności zgłoszenia do kolejki, ale po upływie przydzielonego kwantu czasu proces zwalnia procesor i jest ustawiany na końcu kolejki.

W obydwu przypadkach wykonywany proces może być wywłaszczony przez proces z wyższym priorytetem statycznym.

Zwykłe procesy mają zerowy priorytet statyczny rt_priority i domyślną strategię szeregowania:

SCHED_OTHER - planowanie priorytetowe z przyznawanym czasem procesora.

O wyborze procesu do wykonania decyduje priorytet dynamiczny counter, który oznacza jednocześnie przyznany czas procesora. Wybierany jest proces z najwyższym priorytetem. Wartość początkowa tego priorytetu:

counter = priority
jest ustalana przez system i może zostać zmieniona poprzez zmianę wartości nice procesu. W czasie, gdy proces jest wykonywany przez procesor jego priorytet dynamiczny jest zmniejszany o 1 w każdym takcie zegara. W ten sposób procesy intensywnie wykorzystujące procesor uzyskują coraz niższe wartości priorytetu. Gdy counter osiągnie wartość 0, proces nie będzie już wykonywany aż do ponownego przeliczenia priorytetów. Jesli wszystkie procesy z kolejki procesów gotowych wykorzystają przydzielone kwanty czasu procesora, nastąpi przeliczenie priorytetów dynamicznych wszystkich procesów w systemie (nie tylko procesów gotowych) zgodnie ze wzorem:
counter = counter / 2 + priority

(3.3) Funkcje systemowe
</p>

Dzięki temu procesy, które dotychczas rzadziej korzystały z procesora i nie wyczerpały jeszcze przydzielonego czasu, uzyskają wyższe priorytety od procesów bardziej aktywnych. W ten sposób realizowany jest mechanizm postarzania procesów.

Funkcje systemowe umożliwiają zmianę niektórych parametrów szeregowania procesu: priorytetu statycznego i dynamicznego oraz strategii szeregowania. Uprawnienia zwykłych użytkowników są tu jednak poważnie ograniczone i sprowadzają się do obniżenia priorytetu dynamicznego własnych procesów. Procesy z uprawnieniami administratora mogą również podwyższać priorytet dynamiczny oraz zmieniać swój priorytet statyczny i strategię szeregowania.

Aktualną wartość priorytetu dynamicznego procesu można uzyskać funkcją getpriority(). Funkcje nice() i setpriority() umożliwiają zmianę tego priorytetu.

int nice(int inc);
int getpriority(int which, int who);
int setpriority(int which, int who, int prio);

Funkcja sched_getparam() odczytuje priorytet a funkcja sched_getscheduler() strategię szeregowania dowolnego procesu.

int sched_getparam(pid_t pid, struct sched_param *p);
int sched_getscheduler(pid_t pid);

Funkcje sched_setparam() i sched_setscheduler() pozwalają ustawić powyższe parametry szeregowania.

int sched_setscheduler(pid_t pid, int policy, const struct sched_param *p);
int sched_setparam(pid_t pid, const struct sched_param *p);

Opisane wyżej funkcje korzystają ze struktury sched_param do przekazania lub odczytania parametrów szeregowania:

`struct sched_param {
   ...
   int sched_priority;
   ...
};`

Wartość sched_priority traktowana jest jako priorytet statyczny lub dynamiczny w zależności od używanej strategii szeregowania.

4. Operacje na procesach

(4.1) Tworzenie procesu
</p>

Pierwszy proces w systemie o identyfikatorze PID = 0 zostaje utworzony przez jądro podczas inicjalizacji systemu. Wszystkie pozostałe procesy powstają jako kopie swoich procesów macierzystych w wyniku wywołania jednej z funkcji systemowych: fork(), vfork() lub clone().

pid_t fork(void);
pid_t vfork(void);

Jądro realizuje funcję fork() w następujący sposób:

  • przydziela nowemu procesowi pozycję w tablicy procesów i tworzy nową strukturę task_struct,
  • przydziela nowemu procesowi identyfikator PID,
  • tworzy logiczną kopię kontekstu procesu macierzystego:
    • SEGMENT instrukcji jest współdzielony,
    • inne dane są kopiowane dopiero przy próbie modyfikacji przez proces potomny - polityka kopiowania przy zapisie (ang. copy-on-write),
  • zwiększa otwartym plikom liczniki w tablicy plików i tablicy i-węzłów,
  • kończy działanie funkcji w obydwu procesach zwracając następujące wartości:
    • PID potomka w procesie macierzystym,
    • 0 w procesie potomnym.
Po zakończeniu funkcji fork() obydwa procesy, macierzysty i potomny, wykonują ten sam kod programu. Proces potomny rozpoczyna a proces macierzysty wznawia wykonywanie od instrukcji następującej bezpośrednio po wywołaniu funkcji fork(). Różnica w wartościach zwracanych przez fork() pozwala rozróżnić obydwa procesy w programie i przeznaczyć dla nich różne fragmenty kodu. Ilustruje to poniższy przykład.

Różnica w działaniu funkcji vfork() polega na tym, że proces potomny współdzieli całą pamięć z procesem macierzystym. Ponadto proces macierzysty zostaje wstrzymany do momentu, gdy proces potomny wywoła funkcję systemową _exit() lub execve(), czyli zakończy się lub rozpocznie wykonywanie innego programu. Funkcja vfork() pozwala zatem zaoszczędzić zasoby systemowe w sytuacji, gdy nowy proces jest tworzony tylko po to, aby zaraz uruchomić w nim nowy program funkcją execve().

Funkcja clone() udostępnia najogólniejszy interfejs do tworzenia procesów. Pozwala bowiem określić, które zasoby procesu macierzystego będą współdzielone z procesem potomnym. Głównym zastosowaniem funkcji jest implementacja wątków poziomu jądra. Bezpośrednie użycie funkcji clone() we własnych programach nie jest jednak zalecane. Funkcja ta jest specyficzna dla Linux-a i nie występuje w innych systemach uniksowych, co zdecydowanie ogranicza przenośność programów. Dlatego zaleca się stosowanie funkcji z bibliotek implementujących wątki np. biblioteki Linux Threads.

(4.2) Kończenie procesu

Proces może zakończyć swoje działanie w wyniku wywołania jednej z funkcji:

void _exit(int status);
void exit(int status);
gdzie:
status - status zakończenia procesu.

Funkcja _exit() jest funkcją systemową, która powoduje natychmiastowe zakończenie procesu. Funkcja exit(), zdefiniowana w bibliotece, realizuje normalne zakończenie procesu, które obejmuje dodatkowo wywołanie wszystkich funkcji zarejestrowanych przez atexit() przed właściwym wywołaniem _exit().

int atexit(void (*function)(void));
Jeżeli nie zarejestrowano żadnych funkcji, to działanie exit() sprowadza się do _exit().

Funkcja exit() jest wołana domyślnie przy powrocie z funkcji main(), tzn. wtedy, gdy program się kończy bez jawnego wywołania funkcji.

Realizacja funkcji przez jądro wygląda następująco:

  • wyłączana jest obsługa sygnałów,
  • jesli proces jest przywódcą grupy, jądro wysyła sygnał zawieszenia do wszystkich procesów z grupy oraz zmienia numer grupy na 0,
  • zamykane są wszystkie otwarte pliki,
  • następuje zwolnienie SEGMENTów pamięci procesu,
  • stan procesu zmieniany jest na zombie,
  • status wyjscia oraz sumaryczny czas wykonywania procesu i jego potomków są zapisywane w strukturze task_struct,
  • wszystkie procesy potomne przekazywane są do adopcji procesowi init,
  • sygnał smierci potomka SIGCHLD wysyłany jest do procesu macierzystego,
  • następuje przełączenie kontekstu procesu.
Pomimo zakończenia proces pozostaje w stanie zombie dopóki proces macierzysty nie odczyta jego statusu zakończenia jedną z funkcji wait().

(4.3) Oczekiwanie na zakończenie procesu potomnego
</p>

Proces macierzysty nie traci kontaktu ze stworzonym procesem potomnym. Może wstrzymać swoje działanie w oczekiwaniu na jego zakończenie i debrać zwrócony status. Pozwalają na to funkcje systemowe:

pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int flags);
gdzie:
status - status zakończenia procesu potomnego,
pid - identyfikator PID procesu potomnego,
flags - flagi.

Funkcja wait() wstrzymuje działanie procesu dopóki nie zakończy się dowolny z jego procesów potomnych. Funkcja waitpid() powoduje oczekiwanie na zakończenie konkretnego potomka wskazanego przez argument pid.

Realizacja funkcji przez jądro obejmuje trzy przypadki:

  1. Jeżeli nie ma żadnego procesu potomnego, to funkcja zwraca błąd.
  2. Jeżeli nie ma potomka w stanie zombie, to proces macierzysty jest usypiany i oczekuje na sygnał śmierci potomka SIGCHLD. Reakcja procesu na sygnał jest uzależniona od ustawionego sposobu obsługi. Jeśli ustawione jest ignorowanie, to proces zostanie obudzony dopiero po zakończeniu ostatniego procesu potomnego. Przy domyślnej lub własnej obsłudze proces zostanie obudzony od razu, gdy tylko zakończy się ktoryś z procesów potomnych.
  3. Jeżeli istnieje potomek w stanie zombie, to jądro wykonuje następujące operacje:
    • dodaje czas procesora zużyty przez potomka w strukturze task_struct procesu macierzystego,
    • zwalnia strukturę task_struct procesu potomnego,
    • zwraca identyfikator PID jako wartość funkcji oraz status zakończenia procesu potomnego.
Zwracany status jest liczbą całkowitą. Jeśli proces potomny zakończył się normalnie, to starszy bajt liczby przechowuje wartość zwróconą przez funkcję exit(), a młodszy bajt wartość 0. Jeśli proces został przerwany sygnałem, to w młodszym bajcie zostanie zapisany jego numer, a w starszym bajcie wartość 0. Ilustruje to rys. 6.3.
RYS6_3.JPG

Rys. 6.3 Status zakończenia procesu potomnego odebrany za pomocą funkcji wait() w procesie macierzystym

</span>

Znając ten sposób reprezentacji, wartość odczytaną przez wait() można samodzielnie przesunąć o 8 bitów lub wykorzystać specjalne makrodefinicje do prawidłowego odczytania przyczyny zakończenia procesu oraz zwróconej wartości statusu. Szczegółowy opis można znależć w dokumentacji funkcji wait().

Przykład
Przedstawiamy kod programu, w którym proces macierzysty uruchamia 10 procesów potomnych, a następnie oczekuje na ich zakończenie i wypisuje status zakończenia każdego z potomków.

#include <signal.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>

main(int argc, char *argv[])
{
        int i,p;
        int pid,status;

        for (i=0; i<10; i++)
        {
                if ((p=fork())==0)
                {
                        printf("PID = %d\n", getpid());
                        sleep(5);
                        exit(i);
                }
                else if (p<0)
                        perror("fork");
        }
        while ((pid=wait(&status)) > 0)
                printf("PID = %d, status = %d\n", pid, (status>>8));
        exit(0);
}
 

(4.4) Uruchamianie programów

Programy wykonywane są w postaci procesów. Program do wykonania określony jest już w momencie tworzenia procesu. Jest to ten sam program, który wykonywał proces macierzysty z tym, że proces potomny kontynuje wykonywanie od miejsca, w którym wystąpiła funkcja fork(). Możliwość wykonania innego programu daje rodzina funkcji exec(). Rodzina składa się z sześciu funkcji, ale tylko jedna z nich execve() jest funkcją systemową.

int execve(const char *path, char *const argv[], char *const envp[]);
gdzie:
path - pełna nazwa ścieżkowa programu,
argv[] - tablica argumentów wywołania programu,
envp[] - tablica zmiennych środowiska w postaciciągówzmienna=wartość.

Pozostałe funkcje rodziny zdefiniowane zostały w bibliotece w oparciu o funkcję execve(). Podstawowe różnice, dotyczące sposobu wywołania i interpretacji argumentów, zebrano w tablicy 6.1.

Tablica 6.1 Charakterystyka rodziny funkcji exec()</span></caption> Funkcja Przekazywanie argumentów Przekazywanie zmiennych środowiska Wskazanie położenia programu
`execv`
tablica zmienna globalna environ nazwa ścieżkowa pliku
`execve`
tablica tablica nazwa ścieżkowa pliku
`execvp`
tablica zmienna globalna environ ścieżka poszukiwań PATH
`execl`
lista zmienna globalna environ nazwa ścieżkowa pliku
`execle`
lista tablica nazwa ścieżkowa pliku
`execlp`
lista zmienna globalna environ ścieżka poszukiwań PATH </table>

Ze względu na zmienną długość, zarówno tablice jak i listy argumentów we wszystkich funkcjach muszą być zakończone wskaźnikiem NULL.

Pomimo różnic w wywołaniu, wszystkie funkcje realizują to samo zadanie. Polega ono na zastąpieniu kodu bieżącego programu w procesie kodem nowego programu. Realizacja tego zadania przebiega następująco:

  • odszukanie i-węzła pliku z programem wykonywalnym,
  • kontrola możliwości uruchomienia,
  • kopiowanie argumentów wywołania i środowiska,
  • rozpoznanie formatu pliku wykonywalnego:
    • skrypt powłoki
    • Java
    • a.out
    • ELF
  • usunięcie poprzedniego kontekstu procesu (zwolnienie SEGMENTów pamięci kodu, danych i stosu),
  • załadowanie kodu nowego programu,
  • uruchomienie nowego programu (wznowienie wykonywania bieżącego procesu).
Po zakończeniu wykonywania nowego programu proces się kończy, gdyż kod starego programu został usunięty z pamięci i nie ma już możliwości powrotu.

5. Obsługa sygnałów w procesie

(5.1) Atrybuty procesu

Informacje dotyczące sygnałów przechowywane są w postaci trzech atrybutów procesu (trzy pola struktury task_struct procesu):

signal - maska nadesłanych sygnałów w postaci liczby 32-bitowej, w której każdy sygnał jest reprezentowany przez 1 bit,
blocked - maska blokowanych sygnałów w postaci liczby 32-bitowej,
sig - wskażnik do struktury signal_struct zawierającej tablicę 32 wskaźników do struktur sigaction opisujących sposoby obsługi poszczególnych sygnałów.

Dla każdego nadesłanego sygnału jądro ustawia odpowiadający mu bit w atrybucie signal. Jeżeli odbieranie sygnału jest blokowane przez proces poprzez ustawienie bitu w atrybucie blocked, to jądro przechowuje taki sygnał do momentu usunięcia blokady i dopiero wtedy ustawia właściwy bit. Proces okresowo sprawdza wartość atrybutu signal i obsługuje nadesłane sygnały w kolejności ich numerów.

(5.2) Funkcje systemowe

Funkcja kill() umożliwia wysyłanie sygnałów do procesów.

int kill(pid_t pid, int sig);
gdzie:
pid - identyfikator PID procesu,
sig - numer lub nazwa sygnału.

Argument pid może przyjąć jedną z następujących wartości:

pid > 0 Sygnał wysyłany jest do procesu o identyfikatorze pid
pid = 0 Sygnał wysyłany jest do wszystkich procesów w grupie procesu wysyłającego
pid = -1 Sygnał wysyłany jest do wszystkich procesów w systemie z wyjątkiem procesu init (PID=1)
pid < -1 Sygnał wysyłany jest do wszystkich procesów w grupie o identyfikatorze pgid = -pid

Funkcje signal() i sigaction() umożliwiają zmianę domyślnego sposobu obsługi sygnałół w procesie. Pozwalają zdefiniować własną funkcję obsługi lub spowodować, że sygnały będą ignorowane. Oczywiście obowiązuje tu ograniczenie, podane w lekcji 2, że sygnały SIGKILL i SIGSTOP zawsze są obsługiwane w sposób domyślny.

Funkcja signal() ma zdecydowanie prostszą składnię:

void (*signal(int signum, void (*sighandler)(int)))(int);
gdzie:
signum - numer sygnału,
sighandler - sposób obsługi sygnału.

Ustawia jednak tylko jednokrotną obsługę wskazanego sygnału, ponieważ jądro przywraca ustawienie SIG_DFL przed wywołaniem funkcji obsługującej sygnał.

Argument sighandler może przyjąć jedną z następujących wartości:

  • SIG_DFL,
  • SIG_IGN,
  • wskaźnik do własnej funkcji obsługi.
Ustawienie SIG_DFL oznacza domyślną obsługę przez jądro. Wartość SIG_IGN oznacza ignorowanie sygnału.

Funkcja sigaction() ma znacznie większe możliwości. Nie tylko zmienia obsługę sygnału na stałe (do następnej świadomej zmiany), ale również ustawia jego blokowanie na czas wykonywania funkcji obsługi. Pozwala ponadto zablokować w tym czasie inne sygnały.

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
gdzie:
signum - numer sygnału,
act - struktura typu sigaction opisująca nowy sposób obsługi,
oldact - struktura typu sigaction przeznaczona do zapamiętania starego sposobu obsługi.

Struktura sigaction zdefiniowana jest następująco:

`struct sigaction {`
`void (*sa_handler)(int);` - sposób obsługi sygnału
`sigset_t sa_mask;` - maska sygnałów blokowanych na czas obsługi odebranego sygnału
`int sa_flags;` - flagi
`void (*sa_restorer)(void);` - pole nieużywane
`}`

Funkcja sigaction() blokuje wybrane sygnały jedynie na czas obsługi nadesłanego sygnału. Istnieje możliwość ustawienia maski blokowanych sygnałów na dowolnie długi okres aż do ponownej zmiany przy pomocy funkcji:

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
gdzie:
how - sposób zmiany maski,
set - zbiór sygnałów przeznaczony do zmiany aktualnej maski,
oldset - zbiór sygnałów przeznaczony do zapamiętania starej maski.

Argument how określa operację, jaka będzie wykonana na aktualnej masce blokowanych sygnałów i może przyjmować trzy wartości:

SIG_BLOCK - sygnały ustawione w zbiorze set są dodawane do maski,
SIG_UNBLOCK - sygnały ustawione w zbiorze set są usuwane z maski,
SIG_SETMASK - wyłącznie sygnały ustawione w zbiorze set są ustawiane w masce.

Sygnały blokowane nie są dostarczane do procesu, ale nie giną bezpowrotnie. Jądro przechowuje takie niezałatwione sygnały do momentu, gdy proces odblokuje ich odbieranie. Uzyskanie informacji o niezałatwionych sygnałach umożliwia funkcja:

int sigpending(sigset_t *set);

Proces może zawiesić swoje działanie w oczekiwaniu na sygnał. Funkcja pause() usypia proces do momentu nadejścia dowolnego sygnału.

int pause(void);

Z kolei funkcja sigsuspend() czasowo zmienia maskę blokowanych sygnałów i oczekuje na jeden z sygnałów, które nie są blokowane. Dzięki temu umożliwia oczekiwanie na konkretny sygnał poprzez zablokowanie wszystkich pozostałych.

int sigsuspend(const sigset_t *mask);

Autorami powyższego artykułu są mgr inż. A. Wielgus i dr Z. Jaworski. Drobnych zmian i poprawek dokonał Ł. Fronczyk
W dziale źródła/C++ znajdują się przykładowe programy do tego i pozostałych artykułów z tego cyklu.
Wszelkie pytania proszę kierować na adres [email protected]

2 komentarzy

to popraw...

Czy tu czegoś brakuje czy tylko mi sie zdaje że sąwolne przestrzenie i to doożo :] niewygodne do nauki