Proces to program w czasie wykonania; wykonanie przebiega sekwencyjnie, zgodnie z kolejnością instrukcji w przestrzeni adresowej procesu. Można patrzeć na proces jak na abstrakcyjnego agenta, który wykonuje program użytkowy i dostarcza środowisko operacyjne na potrzeby tego wykonania.
Przestrzeń adresowa procesu to zbiór adresów pamięci, do których proces może się odwoływać podczas wykonania.
Kontekst procesu to jego środowisko operacyjne. Obejmuje ono zawartość rejestrów ogólnych i sterujących procesora, w szczególności:
Linux jest systemem wieloprogramowym, tzn. wiele procesów może działać równocześnie i niezależnie od siebie. Jądro musi dynamicznie przydzielać zasoby niezbędne procesom do działania oraz zapewniać bezpieczeństwo. Korzysta w tym celu ze wsparcia sprzętowego:
Proces użytkownika zleca jądru wykonanie pewnych czynności (np. operacji we-wy) w jego imieniu. Wywołuje w tym celu funkcje systemowe. Do wykonania funkcji jądra może dojść w następstwie żądań zgłaszanych na dwa możliwe sposoby:
proces wykonujący się w trybie użytkownika wywołuje funkcję systemową lub zgłasza wyjątek (przerwanie wewnętrzne),
Szczegóły wywołania funkcji systemowej zależą od architektury (rysunek ilustruje i386). Do przekazania numeru wywoływanej funkcji wykorzystuje się rejestr eax. Przerwanie programowe x80 powoduje przełączenie kontekstu i wywołanie funkcji jądra system_call. Funkcja po wykonaniu testów przekazuje sterowanie do właściwej funkcji systemowej (korzysta z tabeli system_call_table, zawartość rejestru eax traktując jako indeks w tabeli). Po powrocie z funkcji systemowej jest wykonywana funkcja syscall_exit, wywołanie funkcji resume_userspace powoduje przekazanie sterowania z powrotem do przestrzeni użytkownika.
urządzenie zewnętrzne zgłasza sygnał do programowalnego kontrolera przerwań PIC, a odpowiednie przerwania nie są zamaskowane (przerwania zewnętrzne).
Przestrzeń adresowa systemu lub przestrzeń jądra to kod i struktury danych jądra. Są one odwzorowywane w przestrzeń adresową każdego procesu, ale dostęp do nich jest możliwy jedynie w trybie systemowym. Ponieważ jest tylko jedno jądro, więc wszystkie procesy współdzielą pojedynczą przestrzeń adresową jądra. Jądro ma bezpośredni dostęp do przestrzeni adresowej bieżącego procesu (dzięki rejestrom zarządzania pamięcią, MMU). Okazjonalnie może także sięgać do przestrzeni adresowej innego procesu niż bieżący.
Jądro Linuksa jest wielowejściowe (ang. re-entrant), co oznacza, że może współbieżnie obsługiwać różne procesy. Zatem każdy proces potrzebuje własnego stosu jądra, do śledzenia sekwencji wywołań funkcji podczas wykonania w trybie jądra. Stos jądra jest zwykle zaalokowany w przestrzeni adresowej procesu, ale nie ma do niego dostępu w trybie pracy użytkownika.
Do przeplotu obsługi żądań pochodzących od różnych procesów i urządzeń dochodzi w następujący sposób:
w wyniku wykonania procedury schedule następuje przełączenie kontekstu i przejście od obsługi jednego procesu do obsługi innego,
nadejście niezamaskowanego przerwania powoduje zachowanie bieżącego kontekstu i przejście do wykonania procedury obsługi tego przerwania.
Ważnym pojęciem jest kontekst wykonania. Funkcje jądra mogą się wykonywać albo w kontekście procesu, albo w kontekście systemu.
W kontekście procesu jądro działa w imieniu bieżącego procesu (np. wykonując funkcję systemową), może sięgać do przestrzeni adresowej i stosu procesu. Może także zablokować bieżący proces, jeśli musi on poczekać na zasoby.
Czasami jądro musi wykonać pewne ogólnosystemowe czynności, jak np. przeliczenie priorytetów lub obsługa przerwania zewnętrznego. Takie czynności nie są wykonywane w imieniu żadnego konkretnego procesu i dlatego odbywają się w kontekście systemu. W kontekście systemu jądro nie sięga do przestrzeni adresowej czy stosu bieżącego procesu, nie może się również zablokować.
Przełączenie kontekstu polega na zapamiętaniu kontekstu bieżącego procesu (w strukturze stanowiącej część przestrzeni adresowej procesu) i załadowanie do rejestrów procesora kontekstu innego procesu. Czas przełączenia kontekstu jest narzutem na działanie systemu i zależy od wsparcia ze strony sprzętu (rzędu 1 mikrosekundy).
Podsumowując:
Proces podczas wykonywania zmienia stan. Podstawowe stany procesu to:
Rysunek - źródło: U. Vahalia, Jądro systemu UNIX
Proces przebywa w wielu kolejkach, często równocześnie. Implementacja kolejek i pomocniczych struktur danych będzie tematem ćwiczeń nr 2.
Wątki jądra: to standardowe procesy, które istnieją jedynie w przestrzeni jądra i służą jądru do wykonywania pewnych operacji w tle. Różnica między wątkami jądra i normalnymi procesami polega na tym, że dla wątków jądra wskaźnik mm jest równy NULL. Działają one tylko w przestrzeni jądra i nigdy nie przełączają kontekstu do przestrzeni użytkownika. Podlegają szeregowaniu i można je wywłaszczać - tak jak zwykłe procesy. Są tworzone np. przy inicjalnym starcie systemu, ładowaniu niektórych modułów, podpinaniu niektorych urządzeń (np. przez USB), podmontowaniu niektórych systemów plików.
int kernel_thread(int (*fn)(void *), void * arg, unsigned long flags)
Zwykle wątki jądra wykonują swoją funkcję w pętli nieskończonej.
W Linuksie wszystkie wątki są zaimplementowane jako zwykłe procesy, które współdzielą pewne zasoby z innymi procesami (takie jak np. przestrzeń adresowa).
Ponieważ jądro jest wielowejściowe, więc w dowolnym czasie w jądrze może być aktywnych kilka procesów. Wszystkie one współdzielą tę samą kopię struktur danych jądra. Konieczna jest zatem synchronizacja podczas dostępu do tych struktur.
Jak może dojść do współbieżnego wykonania tego samego kodu jądra:
W wyniku przerwania - przerwanie może pojawić się asynchronicznie, w zupełnie dowolnym momencie (priorytety przerwań, maskowanie przerwań).
W wyniku dobrowolnego zrzeczenia się procesora przez proces wykonywany w trybie jądra i wybraniu przez planistę do wykonania innego procesu.
Zwykle zdarza się to, gdy proces ma się zawiesić w oczekiwaniu na zasób lub zdarzenie bądź gdy zakończył właśnie pracę w trybie jądra i zaraz wróci do trybu użytkownika. Teoretycznie proces może zapewnić, że struktury danych jądra pozostaną spójne (bo przecież znamy z góry ten moment). Czasem jednak synchronizacja jest konieczna, np. proces oczekujący na zakończenie operacji wejścia-wyjścia zwalnia procesor, w międzyczasie inne procesy mogą chcieć skorzystać z tego samego bufora wejścia-wyjścia co proces oczekujący.
W wyniku wywłaszczenia kodu jądra (od wersji 2.6 jądro Linuksa jest wywłaszczalne). Planista może w dowolnym momencie wywłaszczyć proces wykonywany w trybie jądra i rozpocząć wykonanie innego.
W systemach wieloprocesorowych (SMP) w wyniku wykonania tego samego kodu jądra na różnych procesorach (obsługa SMP została wprowadzona do Linuksa w wersji jądra 2.0). Jądro musi zakładać blokady podczas dostepu do globalnych struktur danych.
Kluczowym zadaniem podczas pisania kodu jądra jest rozpoznanie KTÓRE fragmenty kodu są podatne na wyścig procesów i wymagają ochrony. Trzeba też zwracać uwagę na granularność zakładanych blokad w kontekście skalowalności systemu.
Operacje niepodzielne. To operacje, które można wykonać za pomocą jednej instrukcji asemblera w sposób niepodzielny, czyli bez możliwości przerwania w trakcie wykonania. Na procesorach z rodziny x86:
Operacje niepodzielne w C na argumentach typu int korzystają ze specjalnego typu danych atomic_t:
typedef struct { volatile int counter; } atomic_t;
Przykładowe operacje (plik np. include/asm-i386/atomic.h):
atomic_read(v) |
atomic_set(v, i) |
atomic_add(i, v) |
atomic_inc(v) |
atomic_dec_and_test(v) |
Część z nich to proste makra, np.:
#define atomic_read(v) ((v)->counter)
Także niektóre operacje na bitach są niepodzielne, np.:
set_bit(nr, addr) |
clear_bit(nr, addr) |
test_and_set_bit(nr, addr) |
Wyłączanie przerwań. Jądro nie może wykonywać operacji blokujących z wyłączonymi przerwaniami, bo może to spowodować zawieszenie systemu.
Makra włączające i wyłączające przerwania w systemie jednoprocesorowym (niektóre, plik np. include/asm-i386/spinlock.h, dokładniej o nich za chwilę):
spin_lock_irq(lock) |
spin_unlock_irq(lock) |
spin_lock_irqsave(lock, flags) |
spin_unlock_irqrestore(lock, flags) |
Nie należy wyłączać przerwań na długo, gdyż w tym czasie blokowana jest jakakolwiek komunikacja między procesorem a kontrolerami urządzeń wejścia-wyjścia.
Blokowanie (ryglowanie, ang. locking). Linux oferuje dwa mechanizmy blokowania: semafory systemowe (używane w systemach jednoprocesorowych i wieloprocesorowych) oraz wirujące blokady (ang. spinlocks) (używane w systemach wieloprocesorowych).
Wirujące blokady są zaimplementowane w pliku include/asm-i386/spinlock.h, a interfejs jest dostępny w pliku include/linux/spinlock.h. Nazwa pochodzi stąd, że czekanie na zdjęcie blokady jest aktywne. Wirujące blokady zależą od architektury i są zaimplementowane w asemblerze.
W Linuksie istnieją dwa typy wirujących blokad:
Zapewniają, że otoczony nimi fragment kodu wykona się w tym samym czasie tylko na jednym procesorze.
Pozwalają na tworzenie sekcji krytycznej typu czytelnicy-pisarze.
Makra obsługujące wirujące blokady w systemie wieloprocesorowym (niektóre):
spin_lock_init(lock) | Inicjuje dany obiekt typu spinlock_t |
spin_lock(lock) | Zakłada klucz |
spin_unlock(lock) | Zdejmuje klucz |
spin_lock_irq(lock) | Wyłącza lokalne przerwania i zakłada klucz |
spin_unlock_irq(lock) | Zdejmuje klucz i włącza lokalne przerwania |
spin_lock_irqsave(lock, flags) | Zapamiętuje bieżący stan przerwań, wyłącza lokalne przerwania i zakłada klucz |
spin_unlock_irqrestore(lock, flags) | Zdejmuje klucz i odtwarza zapamiętany stan lokalnych przerwań |
Makra obsługujące wirujące blokady typu czytelnicy-pisarze w systemie wieloprocesorowym (niektóre):
read_lock_irq(lock) | Wyłącza lokalne przerwania i zakłada klucz do czytania |
read_unlock_irq(lock) | Zdejmuje klucz do czytania i włącza lokalne przerwania |
write_lock_irq(lock) | Wyłącza lokalne przerwania i zakłada klucz do pisania |
write_unlock_irq(lock) | Zdejmuje klucz do pisania i włącza lokalne przerwania |
Wirujące blokady typu czytelnicy-pisarze faworyzują czytelników, więc mogą doprowadzić do zagłodzenia pisarzy.
Uwaga: w nowszych wersjach jądra semafory zostały zastapione przez muteksy (muteksty pojawiły się w 2.6.16, a semafory znikły w 2.6.24).
Semafory to mechanizm synchronizacji stosowany często zarówno w systemach jedno-, jak i wieloprocesorowych.
Semafory systemowe to obiekty typu struct semaphore (plik źródłowy include/asm-i386/semaphore.h):
struct semaphore {
atomic_t count;
int sleepers;
wait_queue_head_t wait;
};
Pole count pełni rolę licznika. Wartość większa od zera oznacza, że semafor jest podniesiony, a mniejsza lub równa zero - semafor opuszczony. Pole sleepers służy do modyfikacji i poprawiania pola count. Pole wait to kolejka procesów oczekujących.
Znaczenia pola count:
Znaczenia pola sleepers:
Podstawowe operacje semaforowe to up i down. Ich implementacja jest dość złożona i częściowo wykonana w asemblerze.
static inline void sema_init (struct semaphore *sem, int val)
static inline void down(struct semaphore * sem)
static inline int down_interruptible(struct semaphore * sem)
static inline int down_trylock(struct semaphore * sem)
static inline void up(struct semaphore * sem)
Janina Mincer-Daszkiewicz |