Zarządzanie procesami w Linux 2.4.7

Agnieszka Harasimczuk
Maciej Makowski
Hubert Śledź
Anna Włudarska




spis treści

  1. Wybrane struktury danych i funkcje do obsługi procesów
    1. Standardowa implementacja list (list.h)
    2. Struktura task_struct
    3. task_union
    4. Makrodefinicja current
    5. Tablica pidhash
  2. Kolejka procesów gotowych
    1. Algorytm add_to_runqueue
    2. Algorytm del_from_runqueue
    3. Algorytm move_last_runqueue
    4. Algorytm move_first_runqueue
  3. Kolejki procesów oczekujących
    1. Algorytm add_wait_queue
    2. Algorytm remove_wait_queue
  4. Zmiany stanu procesu
    1. Algorytm sleep_on
    2. Algorytm try_to_wake_up
    3. Algorytm wake_up
  5. Szeregowanie procesów
    1. Typy procesów
    2. Klasy procesów
    3. Polityki szeregowania
    4. Wywołanie funkcji schedule
    5. Struktury danych związane z szeregowaniem
    6. Makro NICE_TO_TICKS
    7. Funkcja goodness
    8. Działanie funkcji szeregującej
    9. Funkcje systemowe związane z szeregowaniem
  6. Synchronizacja procesów na poziomie jądra
    1. Blokady
    2. Algorytm up
    3. Aglorytm down
  7. Przełączanie kontekstu
    1. Makro switch_to
  8. Algorytm exec



spis treści | => Agnieszka Harasimczuk

Wybrane struktury danych i funkcje do obsługi procesów

Aby zarządzać procesami jądro musi mieć pełne informacje na ich temat. Jest to zadanie deskryptora procesu, mającego strukturę typu task_struct, której pola zawierają informacje dotyczące jednego procesu.


spis treści | => Maciej Makowski

Standardowa implementacja list (list.h)

  • listy dwukierunkowe są w Linuksie 2.4 implementowane przy pomocy struktur i operacji zdefiniowanych w Linux/include/linux/list.h


  • podstawowa struktura:
    struct list_head {
            struct list_head *next, *prev;
    };

  • wskaźnik do elementu listy uzyskiwany jest na podstawie wskaźnika do list_head i wiedzy o typie elementu (arytmetyka wskaźników) przy pomocy makra list_entry()


  • dostępne są standardowe operacje na listach:
    • sprawdzanie czy lista jest pusta (list_empty())
    • wstawianie elementu - na początek (list_add()) albo na koniec (list_add_tail())
    • usuwanie elementu (list_del())
    • łączenie dwóch list (list_splice())
    • iteracja po elementach listy (list_for_each())

spis treści | => Agnieszka Harasimczuk

Struktura task_struct

struct task_struct *next_task, *prev_task - pozwalają powiązać wszystkie procesy w dwukierunkową listę cykliczną;

unsigned long flags - zmienna zawiera połączenie flag systemu:
 PF_ALIGNWARN /* jeszcze nie zaimplementowana */
 PF_STARTING /* proces tworzony */
 PF_EXITING /* proces kończony */
 PF_FORKNOEXEC /* proces potomny po operacji fork, jeszcze nie wykonywany */
 F_SUPERPRIV /* proces ma uprawnienia super użytkownika */
 PF_DUMPCORE /* proces "zrzuca" obszar pamięci */
 PF_SIGNALED /* uśmiercony sygnałem */
 PF_MEMALLOC /* proces w czasie alokacji pamięci */

struct mm_struct *mm - wskaźniki do deskryptorów obszarów pamięci

int has_cpu, processor - procesor, na jakim proces się wykonuje

unsigned long cpus_allowed - informacja, na których procesorach proces może się wykonywać

struct linux_binfmt *binfmt - dzięki tej zmiennej możemy uruchamiać w Linuksie programy przez napisanie ich nazw

/* identyfikator procesu */
pid_t pid - numer identyfikacyjny procesu
pid_t pgrp - numer identyfikacyjny grupy procesów
pid_t session - identyfikator sesji
int leader - wartość mówiąca, czy proces jest liderem grupy
int ngroups - ilość grup, do których należy proces
gid_t groups[NGROUPS] - struktury opisujące grupy, do których proces należy

struct task_struct *p_opptr, *p_pptr, *p_cptr, *p_ysptr, *p_osptr - zmienne te pokazują na:
 *p_opptr - oryginalnego rodzica
 *p_pptr - rodzica
 *p_cptr - najmłodszego potomka
 *p_ysptr - młodszego brata
 *p_osptr - starszego brata

unsigned long start_time - moment, w którym proces został stworzony

long per_cpu_utime[NR_CPUS], per_cpu_stime[NR_CPUS] - tablice te przechowują informacje, ile czasu proces spędził odpowiednio w trybie użytkownika i w trybie systemowym, na poszczególnych procesorach

struct user_struct *user - informacja o właścicielu
struct tty_struct *tty - terminal związany z procesem (jeżeli nie ma NULL)

struct sem_undo *semundo - informacja o zajmowanych semaforach

/* szeregowanie */
volatile long state - stan, w jakim znajduje się proces (-1 unrunnable, 0 runnable, >0 stopped)
volatile long need_resched - jeśli ta flaga jest ustawiona, oznacza to, że potrzebne jest wykonanie przeszeregowania procesów - wywołanie funkcji schedule();
long counter - liczba tyknięć zegara, które pozostały procesowi do zakończenia jego kwantu czasu; wywołanie funkcji update_process_times() zmniejsza o jeden zawartość tego pola;
long nice - wpływa na długość kwantu czasu przyznanego procesowi
(w starszych wersjach Linuksa jej rolę spełniało priority); zmienia się ona od -20 (najwyższy priorytet) do +19 (najniższy priorytet);
unsigned long policy - polityka szeregowania;
unsigned long rt_priority - statyczny priorytet procesów czasu rzeczywistego; przyjmuje wartości od 0 (proces zwykły) do 99 (najwyższy priorytet);

/* tablica pidhash */
struct task_struct *pidhash_next - wykorzystywane przez tablicę z hashowaniem
struct task_struct **pidhash_pprev - j.w.

/* pliki */
struct fs_struct *fs - tu znajdują się dane związane z systemem plików
struct files_struct *files - informacje o plikach otwartych

/* sygnały */
int exit_code, exit_signal - kod zakończenia procesu i sygnał wyjścia
spinlock_t sigmask_lock - ochrona i blokada sygnałów
struct signal_struct *sig - wskaźnik do tablicy przechowującej informacje na temat sposobu obsługi otrzymanych sygnałów
sigset_t blocked - zawiera maski bitowe sygnałów otrzymywanych, ale tych które są obecnie zablokowane, więc trzeba będzie je obsłużyć później




spis treści | => Agnieszka Harasimczuk

Task_union

Proces w trybie jądra używa stosu zawartego w segmencie danych jądra, który różni się od stosu używanego w trybie użytkownika. Ścieżki wykonania jądra rzadko używają tego stosu, więc 8KB jest wystarczającym obszarem na stos i deskryptor procesu. Taką strukturę wyrażono za pomocą następującej unii:

union task_union {
	struct task_struct task;
	unsigned long stack[2048];
};

Rejestr esp jest wskaźnikiem stosu używanym do określenia położenia wierzchołka stosu. W systemach Intela stos zaczyna się na końcu i rozszerza w kierunku początku obszaru pamięci, gdzie znajduje się deskryptor procesu. Zaraz po przełączeniu się z trybu użytkownika do trybu jądra, stos jądra procesu jest pusty i rejestr esp wskazuje na bajt znajdujący się tuż za obszarem pamięci.

free_task_struct() - Funkcja zwraca 8-kilobajtowe obszary pamięci task_union i umieszcza je w pamięci podręcznej do czasu zapełnienia.

alloc_task_struct() - Funkcja ta alokuje obszary pamięci dla task_union o wielkości 8 KB. (Uwaga: Z powodu wydajności jądro przechowuje te 8 KB jako dwa, znajdujące się obok siebie bloki stronicowe, gdzie pierwsza strona jest wyrównana do wielokrotności 213.)




spis treści | => Agnieszka Harasimczuk

Makrodefinicja current

Jądro może w prosty sposób określić wskaźnik deskryptora procesu aktualnie wykonującego się na podstawie wartości rejestru esp. (Obszar pamięci ma 213 bajtów, więc wystarczy, że jądro zamaskuje 13 najmniej znaczących bitów esp).




spis treści | => Agnieszka Harasimczuk

Tablica pidhash

Istnieją sytuacje, w których jądro powinno mieć możliwość pobrania wskaźnika deskryptora na podstawie numeru pid (np. przy wywołaniu funkcji systemowych związanych ze schedule()). W celu przyspieszenia przeszukiwania listy procesów i sprawdzania numerów pid w deskryptorach wprowadzono tablicę pidhash, która obecnie ma 1024 elementy. Pozycje tabeli zawierają wskaźniki do deskryptorów procesu. Pid jest tłumaczony na indeks tablicy za pomocą następującego makra:

#define pid_hashfn(x) ((((x) >> 8) ^ (x)) & (1023))

Zdarzają się sytuacje, że dwa numery dają ten sam indeks w tablicy. W tym celu Linux używa łańcuchów (każda pozycja tabeli to dwustronna lista kolidujących procesów, zrealizowana za pomocą pidhash_next i pidhash_pprev w deskryptorze procesu).
Do wstawiania i usuwania procesów z tablicy służą funkcje: hash_pid() i unhash_pid(). Funkcja find_task_by_pid() zwraca wskaźnik deskryptora procesu o zadanym pid.


spis treści | => Maciej Makowski

Kolejka procesów gotowych




spis treści | => Maciej Makowski

Algorytm add_to_runqueue

środowisko: nr_running - licznik aktywnych procesów

static inline void add_to_runqueue(struct task_struct * p)
{
        list_add(&p->run_list, &runqueue_head);
        nr_running++;
}

spis treści | => Maciej Makowski

Algorytm del_from_runqueue

środowisko: nr_running - licznik aktywnych procesów
  jiffies - licznik liczby taktów, która upłynęła od startu systemu

static inline void del_from_runqueue(struct task_struct * p)
{
        nr_running--;
        p->sleep_time = jiffies;
        list_del(&p->run_list);
        p->run_list.next = NULL;
}

spis treści | => Maciej Makowski

Algorytm move_last_runqueue

środowisko: runqueue_head - wskaźnik do pierwszego elementu kolejki procesów gotowych

static inline void move_last_runqueue(struct task_struct * p)
{
        list_del(&p->run_list);
        list_add_tail(&p->run_list, &runqueue_head);
}

spis treści | => Maciej Makowski

Algorytm move_first_runqueue

środowisko: runqueue_head - wskaźnik do pierwszego elementu kolejki procesów gotowych

static inline void move_first_runqueue(struct task_struct * p)
{
        list_del(&p->run_list);
        list_add(&p->run_list, &runqueue_head);
}

spis treści | => Maciej Makowski

Kolejki procesów oczekujących


spis treści | => Maciej Makowski

Algorytm add_wait_queue


spis treści | => Maciej Makowski

Algorytm remove_wait_queue


spis treści | => Maciej Makowski

Zmiany stanu procesu




spis treści | => Maciej Makowski

Algorytm sleep_on



spis treści | => Maciej Makowski

Algorytm try_to_wake_up




spis treści | => Maciej Makowski

Algorytm wake_up




spis treści | => Agnieszka Harasimczuk

Szeregowanie procesów

Algorytm szeregujący dzieli czas procesora na epoki. Na początku każdej z nich każdemu procesowi jest przypisany pewien kwant czasu. Proces w trakcie jednej epoki może mieć dostęp do procesora wielokrotnie, dopóki nie skończy mu się jego kwant. Przy każdym tyknięciu zegara, bieżącemu procesowi odejmuje się jedynkę od pozostałego kwantu czasu. Gdy cały kwant jest skończony proces jest wywłaszczany. Epoka kończy się, gdy wszystkie procesy gotowe do działania mają już wykorzystany swój czas, wtedy następuje przeliczenie kwantów (dla każdego procesu w systemie) i rozpoczęcie nowej epoki.




spis treści | => Agnieszka Harasimczuk

Typy procesów

Ze względu na czas reakcji na określone zdarzenie, procesy można podzielić na następujące grupy:




spis treści | => Agnieszka Harasimczuk

Klasy procesów

W systemie Linux są zaimplementowane następujące klasy procesów:




spis treści | => Agnieszka Harasimczuk

Polityki szeregowania

Jądro Linuksa w wersji 2.4.7 teoretycznie implementuje trzy polityki szeregowania. Z czego dwie pierwsze dotyczą tylko procesów czasu rzeczywistego, natomiast trzecia procesów zwykłych:




spis treści | => Agnieszka Harasimczuk

Wywołanie funkcji schedule

Funkcja ta jest zasadniczą częścią szeregowania procesów w systemie Linux. Jest ona wywoływana na dwa sposoby: bezpośrednio i pośrednio.




spis treści | => Agnieszka Harasimczuk

Struktury danych związane z szeregowaniem

Pola struktury task_struct:




spis treści | => Agnieszka Harasimczuk

Makro NICE_TO_TICKS

Powyższe makro jest zdefiniowane w następujący sposób:

#define NICE_TO_TICKS(nice) (TICK_SCALE(20-(nice))+1)

Makro TICK_SCALE zależy od wartości zmiennej HZ, gdzie HZ jest stałą, zależną od architektury komputera, określającą częstotliwość przerwań zegara. Dla architektury Intela i386 HZ wynosi 100. Z tego też względu w naszym przypadku makro TICK_SCALE(x) jest zdefiniowane jako x >> 2. Z tego wynika, że zwraca ono wartość ((20-nice) >> 2) + 1.

Przykład:


  1. Jeżeli proces ma wartość nice ustawioną na -20, wtedy:
    20-(-20)=40, w systemie binarnym: 101000. Następnie 101000 >> 2 otrzymujemy: 001010 czyli 8+2=10. Jeszcze 10+1=11, czyli dla wartości nice=-20 nasze makro zwróci wartość 11.

  2. Jeżeli proces ma wartość nice ustawioną na 0, wtedy:
    20-(0)=20, w systemie binarnym: 010100. Następnie 010100 >> 2 otrzymujemy: 000101 czyli 4+1=5. Jeszcze 5+1=6, czyli dla wartości nice=0 nasze makro zwróci wartość 6

  3. Jeżeli proces ma wartość nice ustawioną na +19, wtedy:
    20-(+19)=1, w systemie binarnym: 000001. Następnie 000001 >> 2 otrzymujemy: 000000 czyli 0. Jeszcze 0+1=1, czyli dla wartości nice=+19 nasze makro zwróci wartość 1.

Stąd wniosek, że NICE_TO_TICKS zwraca wartości od +11 do +1.

Ciekawostka: W starszych wersjach Linuksa makro to nie występowało, tak samo jak wartość nice, w miejsce której było priority. W trakcie przeliczania dynamicznego priorytetu w schedule() dodawaliśmy po prostu priority (zamiast NICE_TO_TICKS(nice)).


spis treści | => Agnieszka Harasimczuk

Funkcja goodness

Funkcja decyduje jak pożądany jest proces. "Ważność" procesu zależy od jego typu: wsadowy, interakcyjny, czy czasu rzeczywistego oraz od aktualnego procesora.

     
  1. Jeżeli proces ma ustawioną flagę SCHED_YIELD oznacza to, iż sam dobrowolnie zrezygnował z procesora, z tego względu jego waga jest ustawiana na -1, co jest równoznaczne temu, iż ten proces na pewno nie otrzyma procesora.

  2. Jeżeli proces jest typu SCHED_OTHER i skończył już swój kwant czasu ustawiana jest mu waga = 0. Natomiast, gdy jego pole counter jest różne od 0, wagą staje się wartość tego pola. Jednakże w pewnych przypadkach goodness() jest bardziej przychylna dla danego procesu (dodatkowo zwiększa jego wagę), tzn. wtedy, jeżeli proces pracował poprzednio na tym procesorze, waga jego jest dodatkowo zwiększana o stałą wartość równą PROC_CHANGE_PENALTY (ustawioną na stałe na 15); lub też, jeżeli proces ma tą samą przestrzeń adresową co bieżący, jego waga jest zwiększana o 1. Na zakończenie do wagi dodawana jest wartość (20 - nice).

  3. Jeżeli proces jest procesem czasu rzeczywistego jego wagą staje się 1000 zwiększony o priorytet czasu rzeczywistego, czyli rt_priority.

Wnioski: Waga dodatnia oznacza, że proces jest dobrym kandydatem na otrzymanie procesora (im wyższa waga tym chętniej przyznajemy CPU), natomiast waga zerowa, bądź ujemna oznacza, że proces nie może otrzymać procesora, gdyż nie ma na to odpowiednich warunków.




spis treści | => Agnieszka Harasimczuk

Działanie funkcji szeregującej

Funkcja schedule() działa według następującego algorytmu:

  1. Sprawdza, czy została wywołana podczas obsługi przerwania. Jeśli jest to prawdą wypisuje komunikat o błędzie i kończy swoje działanie. Takie wywołanie jest poważnym zagrożeniem dla systemu. W schedule() gwarantujemy, że przerwania są włączone. (Jednakże są momenty, w których musimy je wyłączyć).

  2. Jeśli obecny proces jest procesem czasu rzeczywistego, szeregowanym według strategii Round Robin (RR), któremu skończył się właśnie kwant czasu, to przyznawany jest mu nowy (wyliczany z makra NICE_TO_TICKS()) oraz taki proces jest ustawiany na końcu kolejki procesów gotowych.

  3. Jeśli obecny proces jest w stanie TASK_INTERRUPTIBLE schedule() sprawdza, czy nadszedł nieblokowany sygnał, jeżeli tak zmienia mu stan na TASK_RUNNING.
    Następnie, jeżeli proces nie jest w stanie TASK_RUNNING usuwa go z kolejki procesów gotowych. Zeruje wartość pola need_resched bieżącego procesu.

  4. Funkcja dokonuje wyboru najbardziej odpowiedniego procesu do przyznania mu procesora. Korzysta przy tym z funkcji goodness() opisanej powyżej. Wyboru dokonuje w kilku krokach.

    Pierwszym z możliwych kandydatów (który zawsze istnieje) jest proces idle. Jednakże otrzymuje on procesor tylko pod warunkiem, że w danej chwili nikt inny nie jest gotowy do działania. Z tego też względu jego "ważność" jest ustawiana na -1000 (Pozostałe procesy otrzymują wyższą wagę, co zapewnia poprawne działanie).

    Następnie schedule() sprawdza, czy bieżący proces, (pod warunkiem, że jest w stanie TASK_RUNNING) mógłby otrzymać jeszcze raz procesor, (badane jest, czy dany proces może pracować na tym procesorze poprzez sprawdzenie stanu jego pola cpus_allowed struktury task_struct), jeżeli tak - jest uznawany za lepszego kandydata aniżeli idle i jest brany jako pierwszy pod uwagę (schedule() zapamiętuje jego wagę). Dzięki tej operacji w przypadku, gdy kilka procesów dostanie najwyższą wartość - w tym także bieżący, to jemu zostanie przyznany procesor.
    Jednakże to rozwiązanie jest w pewnych sytuacjach błędne i nie spełnia przyjętych wymogów. Dlaczego???
    (Patrz rozwiązanie zadań)

    Teraz w celu znalezienia najlepszego pretendenta do pracy planista zaczyna przechodzenie kolejki procesów gotowych każdorazowo od początku (zawsze najpierw sprawdza, czy dany proces może pracować na danym procesorze). Przy czym stara się zapamiętać dotychczas najlepszego kandydata oraz jego wagę (przyznawaną przez goodness(). Waga kolejnych procesów jest porównywana z ostatnio zapamiętaną wartością, jeżeli jest wyższa to oznacza, ze ten proces jest lepszym kandydatem do procesora).
    	
    list_for_each(tmp, &runqueue_head) {
    		p = list_entry(tmp, struct task_struct, run_list);
    		if (can_schedule(p, this_cpu)) {
    			int weight = goodness(p, this_cpu, prev->active_mm);
    			if (weight > c)
    				c = weight, next = p;
    		}
    	}
    
    Po przejściu powyższej pętli mogą wystąpić trzy różne sytuacje. Po pierwsze może się okazać, że znaleźliśmy proces, który powinien otrzymać CPU - czyli zapamiętana wartość jest dodatnia. Po drugie może ona mieć wartość -1000, co jest równoznaczne, że żaden proces nie chce lub nie może działać, więc proces jest przyznany idle. (goodness() zwraca wartość 0 lub >0, dla każdego prawidłowego procesu, czyli gdyby w naszej kolejce runqueue, był jakiś odpowiedni proces to pamiętana wartość byłaby nieujemna). I po trzecie może być sytuacja, w której najwyższą wartością okazało się zero - a to oznacza, że wszystkie procesy skończyły już swoje kwanty czasu. W tej sytuacji należy przyznać wszystkim procesom nowe kwanty czasu (nie tylko tym z listy procesów bieżących). W tym celu korzystamy z następującego algorytmu:
        for_each_task(p)
                p->counter = (p->counter >> 1) + NICE_TO_TICKS(p->nice);
    
    Czyli do przeliczenia priorytetów dynamicznych, potrzebna jest znajomość czasu, który pozostał procesowi (wartość counter) z ostatniego przydziału oraz wartość wyliczona z priorytetu bazowego przy pomocy makra NICE_TO_TICKS.
    Interesujące jest, że przed przeliczeniem priorytetów dynamicznych schedule() zdejmuje blokadę z runqueue. Wydaje się to trochę dziwne, jednakże trzeba zauważyć, że przeliczeniu podlegają absolutnie wszystkie procesy, co może zająć stosunkowo dużo czasu, podczas gdy inny procesor mógłby chcieć wywołać funkcję schedule(), w celu wybrania najlepszego procesu na ten procesor.

    Po przeliczeniu priorytetów, schedule() wraca z powrotem na początek punktu 4.

  5. W tym momencie wiemy już, który proces będzie działał na tym procesorze, z tego też względu ustawiamy zmienne struktury task_struct: has_cpu na 1 i processor na this_cpu.

  6. Przygotowanie do zamiany procesów - ustawianie odpowiednich zmiennych do switch_to(), przełączenie kontekstu.

  7. Wywołanie funkcji reschedule_idle(), która to sprząta wszelkie pozostałe, niepoustawiane znaczniki po schedulerze (np. wyłącza flagę SCHED_YIELD, a w przypadku SMP ustawia pola dotyczące procesora). Funkcja ta w systemie wieloprocesorowym wywołuje też funkcję reschedule_idle() (oczywiście pod warunkiem, że wywłaszczony proces nie był procesem idle). reschedule_idle() jest bardzo ważną funkcją - często jest wywoływana w celu ustawienia flagi need_resched (w systemie jednoprocesorowym dotyczy to przypadku, gdy proces, dla którego ją wywołano ma większy priorytet od bieżącego). W Linuksie SMP sprawdza, czy proces, któremu właśnie odebrano procesor, nie mógłby wywłaszczyć kogoś z innego CPU.



spis treści | => Agnieszka Harasimczuk

Funkcje systemowe związane z szeregowaniem

Poniższe funkcje służą do zmiany priorytetów procesów i ich polityki szeregowania, oraz do odczytywania tych danych. Każdy użytkownik może dowolnie obniżać priorytety swoich procesów, ale tylko administrator może także je podwyższać.




spis treści | => Huber Śledź

Synchronizacja procesów na poziomie jądra

Jak wiadomo, funkcje jądra są wykonywane w następstwie żądań, które mogą być zgłaszane na dwa sposoby:

Sekwencja instrukcji, wykonywana w trybie jądra w celu obsłużenia żądania, jest nazywana ścieżką wykonania jądra.
Ścieżki wykonania jądra pełnią rolę podobną do procesów, są jednak prostsze. Przede wszystkim nie jest z nimi związany żaden deskryptor, poza tym nie są szeregowane za pomocą jednej funkcji, ale przez wstawienie do kodu jądra sekwencji instrukcji zatrzymujących lub wznawiających ścieżki.

W najlepszym przypadku procesor wykonuje ścieżki wykonania jądra sekwencyjnie, od pierwszej do ostatniej instrukcji. Niestety, jeżeli się zdarzy:

to procesor musi przeplatać ścieżki wykonania jądra. I tutaj trzeba uważać na struktury danych zawierające kilka powiązanych zmiennych. Wszystkie instrukcje operujące na takiej strukturze trzeba umieścić w jednej sekcji krytycznej.


W Linuksie są wyróżnione cztery techniki synchronizacji:










spis treści | => Huber Śledź

Blokady

W systemach jednoprocesorowych Linux oferuje jeden rodzaj blokad, tzw. semafory jądra.
W przypadku gdy ścieżka wykonania jądra chce pobrać zajęty, chroniony przez semafor jądra, zasób, to odpowiedni proces zostanie uśpiony. Zacznie swoje działanie wtedy, gdy zasób stanie się dostępny.

Semafory jądra są obiektami typu struct semaphore [
Linux/include/asm-i386/semaphore.h] i mają następujące pola:

Pole count jest zwiększane, gdy proces chce podnieść semafor, a zmniejszane gdy go opuszcza.

Jeżeli proces chce podnieść semafor, wywołuje funkcję up().




spis treści | => Huber Śledź

Algorytm up

Najpierw proces zwiększa wartość count i porównuje ją z zerem (porównanie i test są wykonywane atomowo). Jeżeli nowa wartość count jest większa od zera, to w kolejce oczekiwania semafora nie ma żadnego procesu, tak więc proces nic więcej nie robi. W przeciwnym wypadku, (count <=0) budzi pierwszy proces z kolejki procesów czekających na zasób (funkcja wake_up()). Wykonanie funkcji wake_up() jest chronione, tzn. przed wykorzystywaniem elementów kolejki zapamiętywane są flagi oraz wyłączane przerwania, a po zakończeniu działań na kolejce przerwania zostają włączane, a flagi odtwarzane.

 
void up(semaphore *sem)
{
    /* Sekcja krytyczna */
    sem->count ++;
   
    if (sem->count <= 0) /* wpp. kolejka oczekiwania jest pusta */
    {
        /* Koniec sekcji */

        Wstaw następny proces z  kolejki oczekiwania semafora do kolejki procesów  gotowych. 
        /* Budzenie procesu - funkcja  wake_up */

    }
}

Jeżeli proces chce opuścić semafor, wywołuje funkcję down().




spis treści | => Huber Śledź

Algorytm down

Analogicznie jak w funkcji up(), na początku proces zmniejsza wartość count i porównuje ją z zerem (operacja atomowa). Jeżeli nowa wartość count jest większa bądź równa zero, to procesowi udało się przejść pod semaforem i zająć zasób, w przeciwnym wypadku proces zmienia swój stan na TASK_UNINTERRUPTIBLE oraz wstawia się do kolejki wait - procesów czekających na zasób (funkcja add_wait_exclusive() - analogiczna ochrona jak w wake_up(), przy wykonywaniu tej funkcji). Następnie proces wyłącza przerwania (makro spin_lock_irq()) i zwiększa wartość sleepers. Zaczyna wykonywać pętlę. Atomowo zwiększa count o (sleepers - 1) (makro atomic_add_negative()) i sprawdza czy wartość count jest większa bądź równa zero. Jeśli tak, to może pobrać zasób. Ustawia sleepers na 0 (żeby zablokować inne procesy), wychodzi z pętli, odblokowuje przerwania (makro spin_unlock_irq()), usuwa się z kolejki wait (funkcja remove_wait_queue()), zmienia stan na TASK_RUNNING i budzi pierwszy proces z kolejki wait funkcją wake_up(). Jeśli count < 0, to musi zasnąć. Ustawia sleepers na 1, odblokowuje przerwania i wywołuje funkcje schedule(), która go usypia (wywłaszcza). Po obudzeniu powtarza pętlę aż do skutku.

void down(struct semaphore *sem)
{
    /*  Sekcja  krytyczna */
    sem->count--;
    if  (sem->count < 0)  /* wpp. udało się przejść pod  semaforem */
    {
        /* Koniec sekcji */

        Zmień stan procesu na TASK_UNINTERRUPTIBLE;

        Wstaw proces do  kolejki oczekiwania semafora. /* add_wait_queue_exclusive() */

        Wyłączenie  przerwań /* spin_lock_irq() */
        sem->sleepers++;
  
        for (;;){
         
            int sleepers = sem->sleepers;
            if ((sem->count += sleepers - 1) >= 0)/*!atomic_add_negative(sleepers-1, sem->count)*/
            {							  /* ktoś podniósł semafor */
                sem->sleepers = 0;
                break;  /* możemy pobrać zasób*/
            }

            sem->sleepers = 1;
            Włączenie przerwań /* spin_unlock_irq() */

            schedule(); /* proces zostaje uśpiony */

            Zmień stan procesu na TASK_UNINTERRUPTIBLE

            Wyłączenie  przerwań

        } /* for */
   
        Włączenie przerwań
  
        Usuń proces z  kolejki oczekiwania semafora /* remove_wait_queue() */

        Zmień stan procesu na TASK_RUNNING

        Wstaw pierwszy (jeśli jest) proces z  kolejki oczekiwania semafora
        do kolejki procesów gotowych. /* wake_up() - budzenie procesu */
    }
}



spis treści | => Huber Śledź

Przełączanie kontekstu

W skład kontekstu procesu wchodzą: zawartość rejestrów procesora i przestrzeni adresowej procesu oraz struktury jądra związane z danym procesem. Przełączanie kontekstu polega na zamianie aktualnie wykonującego się procesu.

Sytuacje w jakich dochodzi do przełączania kontekstu to:

Z każdym procesorem w systemie jest związana specjalna struktura TSS, określana mianem segmentu stanu zadania. W tej strukturze system przechowuje kontekst aktualnie działającego procesu.

W TSS przechowywane są następujące dane obecnie działającego procesu:

W deskryptorze procesu jest pole thread typu thread_struct, w którym znajdują się informacje o stanie tego procesu m.in. thread.esp0, esp, eip, fs, gs o znaczeniu analogicznym jak odpowiednie pola w TSS.


spis treści | => Huber Śledź

Makro switch_to

Makro switch_to() [Linux/include/asm-i386/system.h] wykonuje przełączenie procesów. Używa trzech parametrów oznaczonych jako prev, next i last. prev jest wskaźnikiem do deskryptora procesu, który ma być przełączony do tła, a next jest wskaźnikiem do deskryptora procesu, który ma być wykonywany przez procesor. Makro to jest wywoływane przez funkcję schedule().

Jak działa to makro?

Jak działa __switch_to()?

Teraz jądro działa już w kontekście nowego procesu. Po powrocie ze __switch_to() makro switch_to odtwarza wartości rejestrów ESI, EDI, EBP.


spis treści | => Huber Śledź

Algorytm exec

Zadaniem tego algorytmu jest zastąpienie zawartości pamięci procesu przez nowy program. Algorytm ten załadowuje plik binarny do pamięci i rozpoczyna jego wywołanie, zastępując segment kodu, danych i stosu. W szczególności jeśli procesowi powiedzie się wywołanie funkcji exec, to nie tworzy się nowy proces, tylko ten sam proces działa dalej jednak ma już inny kod oraz „podmieniony” segment danych i stosu. Nie zmienia mu się PID, dziedziczy wszystkie deskryptory otwartych plików, które nie mają opcji zamykania podczas exec oraz zostaje przywrócona standardowa obsługa sygnałów, chociaż sygnały ignorowane przed wywołaniem funkcji nadal będą ignorowane.

Jednakże algorytm exec wykorzystuje tylko już istniejące funkcje w systemie, służące do ładownia plików binarnych.

Na początku zostaje wywołana funkcja execve(). [
Linux/include/asm-i386/unistd.h]
Deklaracja:
static inline int execve(const char *file, char **argv, char **envp);
gdzie:
file - nazwa programu do wykonania
argv - tablica zawierająca argumenty dla programu
envp - tablica zawierająca ustawione zmienne środowiskowe

Funkcja ta wywołuje przerwanie (SYSCALL_VECTOR) i przekazuje swoje parametry do procedury obsługi tego przerwania (sys_call). Z kolei ta procedura uruchamia funkcję sys_execve() [Linux/arch/i386/kernel/process.c], której parametrem jest struktura zawierająca stan rejestrów procesora (struct pt_regs [Linux/include/asm-i386/ptrace.h]).

Funkcja sys_execve() sprawdza, czy podany plik jest wykonywalny i czy nie jest katalogiem lub urządzeniem - jeśli tak zwraca błąd i funkcja exec() na zmiennej errno zapisuje kod błędu.
W przeciwnym wypadku zostaje wywołana funkcja do_execve() z parametrami takimi jak execve(), oraz dodatkowo z parametrem z sys_execve().

Struktura struct linux_binprm [Linux/include/linux/binfmts.h] zawiera m.in. pola:

i jest wykorzystywana przez do_execve().

Funkcja do_execve() wykonuje następujące operacje:

  1. Statycznie alokuje strukturę danych linux_binprm, która zostanie wypełniona danymi, dotyczącymi nowego pliku wykonywalnego
  2. Wywołuje funkcję open_exec() w celu pobrania obiektu pozycji katalogu. W przypadku niepowodzenia zwraca odpowiedni kod błędu.
  3. Wywołuje funkcję prepare_binprm(),po to by wypełnić strukturę linux_binprm. Funkcja ta wykonuje operacje:
  4. Kopiuje ścieżkę dostępu argumentu linii poleceń i zmienne środowiskowe do jednego lub więcej nowo zaalokowanych bloków stronicowych.
  5. Wywołuje funkcję search_binary_handler(), która zajmuje się właściwym uruchamianiem programu. Na początku pobiera wskaźnik do początku listy dostępnych formatów dzięki makru formats. Następnie dla każdego formatu próbuje zastosować jego funkcję służącą do ładowania programu (load_binary()), przekazując jej strukturę danych linux_binprm. Przeszukiwanie listy formats kończy się gdy tylko jakaś funkcja load_binary() potwierdzi format wykonywalny pliku.
  6. Jeżeli format pliku wykonywalnego nie znajduje się na liście formats, zwalnia wszystkie zaalokowane bloki stronicowe i zwraca kod błędu. Linux nie może rozpoznać formatu pliku wykonywalnego.
  7. W przeciwnym wypadku ustawia flagę w strukturze procesu mówiącą o tym, że udało się wykonać exec() i zwraca kod zwrócony przez funkcję load_binary(), związaną z formatem pliku wykonywalnego.

    Niektóre błędy jakie może zgłosić funkcja exec():

     EACCES - zabroniony dostęp do pliku
     ENOMEM - nowy proces wymaga zbyt dużo pamięci
     EAGAIN - ilość pamięci podczas operacji we/wy jest niewystarczająca
     ENOENT - błędna ścieżka dostępu do pliku