Spis treści
|
Tworzenie, zarządzanie i niszczenie procesu
Proces jest programem w czasie wykonania (a właściwie jednym z egzemplarzy tego programu). Składa się on z wykonywanego kodu (sekcji tekstu), ze zmiennych globalnych, zasobów (między innymi: otwartych plików i kolejkowanych sygnałów), przestrzeni adresowej oraz 1 lub kilku wątków wykonania. Na proces często mówi się również zadanie (ang. task), od czasu do czasu i my będziemy jej używali.
Wątki są obiektami reprezentującymi czynności wykonywane w ramach procesu. Przy czym jak później zobaczymy Linuxtraktuje wątki tak samo jak procesy.
Każdy proces posiada:
- Stan
- Kontekst: zawartości wszystkich rejestrów procesora (PC (licznik rozkazów), SP (wskaźnik stosu), PSW (rejestr stanu procesora), ogólnego przeznaczenia, zarządzania pamięcią i obliczeń zmiennopozycyjnych).
- Deskryptor procesu
Linux dostarcza 2 wirtualizacje każdemu procesowi w systemie:
- wirtualną pamięc
- wirtualnego procesora
Oznacza to że każdy proces uruchomiony pod Linuksem ma iluzję jakoby miał całą pamięć i cały czas procesora tylko dla siebie.
Kernel dostarcza środowisko wielozadaniowe; wiele zadań(procesów) może zostać uruchomionych równocześnie. Każdy proces rywalizuje o różne, dostępne zasoby sprzętowe. Kernel musi zapewnić ich sprawiedliwy podział. Wielozadaniowość realizowana jest przez przydzielenie każdemu procesowi, znajdującemu się w kolejce procesów gotowych do uruchomienia czasu procesora na krótko ale za to często . Proces, który zawłaszczył sobie procesor w danym momencie oznaczany jest jako bieżący. Procedura przełączania pomiędzy dwoma procesami jest nazywana "zmianą kontekstu". Zmiana kontekstu obejmuje zapisanie ( zrzut stanu procesora ) działającego procesu i załadowanie kontekstu następnego procesu. Zmiana kontekstu może pojawić się tylko gdy kernel kończy wykonywać jakąś pracę dla procesu użytkownika (wywołanie systemowe). Sam kernel też podlega wywłaszczaniu (przymusowemu odebraniu czasu procesora)
Proces każdego użytkownika uruchamia się w jego własnej przestrzeni adresowej czyli przydzielonej części dostępnej, całkowitej pamięci.
Przestrzenie adresowe (lub jej fragmenty) mogą być dzielone pomiędzy procesami na życzenie lub automatycznie jeśli kernel uzna to za stosowne.
Oddzielenie przestrzeni adresowej procesów zapobiega ingerencji jednego procesu pamięć innego procesu, czy nawet przed ingerencją w pamięć kernela.
Wątki jednego procesu działają we wspólnej przestrzeni adresowej lecz na oddzielnych procesorach (co jest oczywiście iluzją jeżeli procesorów jest mniej niż wątków do uruchomienia).
Oprócz normalnych procesów użytkownika uruchomionych w systemie, kilka procesów jest tworzonych podczas startu systemu i działa na stałe w trybie jądra wykonując różne pożytecznych zadania systemowe.
Desktyptor procesu
Deskryptor procesu to struktura struct task_struct, która zawiera wszystkie informacje o nim. Takie jak np. otwarte pliki przestrzeń adresowa, stan procesu. Deklarację znajdziesz w linux/include/linux/sched.h i zawiera ona 255 linijek kodu !!! (na x86 w pamięci zajmuje ok 1,7kB).
Linuks przechowuje wszystkie deskryptory połączone w dwukierunkową listę zwaną listą zadań (ang. task list).
Alokacja deskryptora procesu
Za przydzielenie pamięci pod deskryptor pamięci odpowiedzialny jest alokator plastrowy (ang. slab alocator).
Tworzenie procesu
Aby utworzyć nowy proces musisz skorzystać z kombinacji fork() i exec(). fork() tworzy proces potomny będący kopią procesu wywołującego. Proces potomny będzie się różnił wartością PID oraz zasobami nie dziedziczonymi np. statystykami użycia procesora. Wywołanie exec() ładuje nowy program do przestrzeni adresowej i go inicjuje. Więc dopiero połączenie fork() i exec() tworzy nowy proces.
Proces który powstał jest potomkiem procesu wywołującego fork(). Zaś sam proces wywołujący jest rodzicem nowo utworzonego procesu.
Zakańczanie procesu
Aby zakończyć proces przez wywołanie exit() którego kod znajduję się w /kernel/exit.c. Po zakończeniu tego wywołania proces pozostaje w stanie TASK_ZOMBIE.
Aby rodzic mógł uzyskać dane o procesie potomnym który się zakończył musi wywołać funkcję wait4(). Po czym potomek jest całkowicie usuwany z pamięci.
Stan procesu:
- runnable (gotowy do uruchomienia)
- interruptible (nie gotowy do uruchomienia jednak może odbierać sygnały),
- uninterruptible (nie gotowy i nie może odbierać sygnałów),
- stopped (zatrzymany),
- zombie (proces duch, proces zakończył się, a informacje o jego pracy przechowywane są dla procesu rodzica).
Manipulacja stanu procesu
set_task_state(zadanie,stan);
set_current_state(stan);
Zmieniają stan procesu podanego lub aktualnego na podany.
Szeregowanie procesów
Za szeregowanie procesów odpowiada część zwana Planistą. Odpowiada on za przydzielanie czasu procesora do poszczególnych procesów gotowych do uruchomienia.
Zadanie może odstąpić swój czas procesora przez systemowe wywołanie schedule() które natychmiast oddaje czas procesora innemu zadaniu.
Wywołania systemowe
Obsługa wyjątków i przerwań.
(Deferring Work: Tasklets and SoftIRQ)
Synchronizacja
Kernel jest systemem wielowejściowym, każdy proces może być wykonywany w trybie jądra w danej chwili. Oczywiście, na jednoprocesorowym systemie tylko jedno zadanie może być wykonywane w tym samym czasie, cała reszta jest zablokowana i czeka w kolejce. Na przykład: proces prosi o dostęp do pliku. Wirtualny system plików tłumaczy prośbę na niskopoziomową operację dyskową i przepuszcza ją do kontrolera dysku, w sprawie procesu. Zamiast czekać do czasu zakończenia operacji dyskowej ( wiele tysięcy cyklów procesora później ), proces po zakończeniu wykonywania prośby dobrowolnie oddaje CPU, a jądro zezwala innemu czekającemu procesowi na uruchomienie się i wykonanie jakiś czynności w trybie jądra. Gdy operacja dyskowa zostanie zakończona ( sygnalizowane przez przerwanie sprzętowe ), obecnie działający proces "oddaje" procesor współdziałającemu menadżerowi przerwań, początkowy proces zostaje zbudzony, a następnie kończy operację wykonywaną wcześniej.
By osiągnąć solidny wielowejściowy system ( przyp. kernel ), należy zapewnić regularność struktur danych jądra. Gdy jeden z procesów zmienia licznik odniesień innego czekającego procesu "do tyłu", rezultat potencjalnie może być katastrofalny. Podejmuje się następujące kroki by przeciwdziałać temu zjawisku:
- jeden proces może zastąpić inny w trybie jądra tylko wtedy gdy dobrowolnie "porzucił" procesor, zostawiając struktury danych zgodnym stanie, stąd jądro określa się mianem " nie prewencyjnego "[?]
- przerwania mogą być nie dostępne w krytycznych strefach, obszarach kodu który musi być wykonany bez przerwań,
- użycie blokad spinów [?] i semaforów do kontroli dostępu do struktur danych.
Semafory składają się z :
- zmiennej licznika ( typ całkowity ), o wartości początkowej 1
- podlinkowanej listy procesów czekających na dostęp do strukut danych
- dwie atomowe metody, up() i down() które odpowiednio zwiększają lub zmniejszają wartość licznika.
Gdy jądro sprawdza przy pomocy semafor ścieżkę dostępu do chronionych struktur danych wywołuje funkcję down() jeśli nowa wartość licznika jest zerem lub ma wartość pozytywną ( nie ujemną ) dostęp jest nadany, jeśli natomiast wartość licznika jest negatywna ( ujemna ? ) dostęp jest zablokowany i proces dodawany jest do listy zlinkowanych semafor czekających procesów. Podobnie, gdy proces skończył pracę z danymi, wywołana jest funkcja up() i następny proces w liście oczekujących uzyskuje dostęp.
W celu uniknięcia zastoju podejmuje się pewne środki ostrożności, gdzie kilka procesów posiada pojedynczy zasób, ale każdy czeka na zasób innego czekającego procesu. Jeśli lista czekających procesów zamyka koło, osiągamy zastój. Aby dogłębnie zrozumieć temat zastoju przeczytaj "The Dining Philosophers Problem" w internecie lub ebooku.
Zarządzanie czasem
Zarządzanie pamięcią
Linux wykorzystuje pamięć wirtualną, jest ona poziomem abstrakcji pomiędzy procesem żądającym dostępu do pamięci (adresowanie liniowe), a fizycznymi adresami umożliwiającymi spełnienie tych żądań. Takie rozwiązanie umożliwia:
- Działanie procesów, które wymagają więcej pamięci niż ilość pamięci RAM dostępna w systemie.
- Udostępnienie ciągłej przestrzeni adresowej, niezależnej od organizacji pamięci fizycznej.
- Stronicowanie na żądanie; w pamięci RAM przechowywana jest tylko porcja danych lub kodu, która jest obecnie używana lub wykonywana, strony nieużywane mogą być przenoszone do pamięci pomocniczej kiedy nie są potrzebne.
- Dzielenie fragmentów kodu programu i bibliotek, dzięki temu użycie pamięci jest bardziej efektywne.
- Niewidoczne dla procesu przenoszenie kodu w pamięci.
Przestrzeń adresowa jest podzielona na 4kB porcje nazywane stronami, tworzą podstawowe jednostki translacji całej pamięci wirtualnej. Pamięć fizyczna RAM również jest podzielona na 4kB porcje nazywane ramkami (ang. page frames), każda może przechowywać arbitralnie stronę. Ponieważ całą przestrzeń adresowa przekracza ilość dostępnej pamięci RAM tylko pewien podzbiór wszystkich dostępnych stron może być przechowywany w pamięci RAM w danym momencie. Jednak strona musi znajdować się w pamięci RAM by mógł jej użyć działający program jako obszar danych bądź kodu.
Ponieważ każda strona może być przeniesiona do dowolnej ramki, jądro musi przechowywać informacje o tym, gdzie używane strony dokładnie się znajdują. Informacja ta implementowana jest w formie tablicy stron, która używana jest do zamiany adresów logicznych (używanych do odnoszenia się w obrębie pamięci wirtualnej procesu) na adresy fizyczne (rzeczywiste adresy danych w pamięci RAM).
Linux używa modelu dwupoziomowej tablicy stron na architekturze Intel x86 by zredukować ilość pamięci przechowywanej w tablicy stron. By zamienić adres liniowy na adres fizyczny najpierw stosuje się Globalny Katalog Stron a następnie Tablicę Stron by uzyskać numer strony i przesunięcie w jej obrębie. Dlatego adres liniowy może być podzielony na trzy części: Katalog, Tablicę i Przesunięcie. Ponieważ Linux 2.2 może adresować przestrzeń pamięci o rozmiarze 4GB (używając 32 bitowego adresowania) i używa stron o rozmiarze 4kB, 10 najbardziej znaczących bitów odpowiada adresowi Katalogu, następne 10 odpowiada Tablicy (skutkiem tego jest wymóg identyfikacji strony) i 12 najmniej znaczących bitów odpowiada przesunięciu strony.
Wirtualny system plików
VFS jest to abstrakcyjna warstw która zapewnia jednolity i niezależny od systemy plików interfejs. Oczywiście systemy plików które nie obsługują pewnych cech wymaganych przez VFS muszą je symulować.
VFS jest odpowiedzialny za zapewnienie często używanego interface'u dla wszystkich znajdujących się pod nim systemów plików i źródeł magazynujacych ( dysk twardy dyskietka) w systemie."Popularny model plików" reprezentuje pliki w każdym systemie plików i źródeł z danymi.Systemy plików wspierane przez VFS należą do 3 kategorii:
- dyskowe - dysk twardy,dyskietka,cd-rom
- sieciowe - NFS,AFS,SMB
- specjalne(wirtualne) systemy plików - takie jak /proc lub /dev/pts
Popularny model plików może być przeglądany w postaci obiektowej z obiektami będącymi konstrukcjami software'owymi ( struktury danych i współpracujące metody/funkcje)
według poniższych typów:
- obiekty super bloku: przechowują informacje dotyczące zamontowanych systemów plików (dla dyskowych systemów plików)
- obiekty typu inode: przechowujące informacje dotyczące pojedynczego pliku (dla dyskowych systemów plików)
- obiekty typu plik: przechowujące informacje dotyczące interakcji otwartego pliku i procesu.Ten obiekt istnieje tak długo jak powiązany z nim plik jest otwarty ( podczas gdy proces wchodzi w interakcje z konkretnym plikiem)
- obiekt typu DENTRY: wiąże nazwę ścieżki z jej odpowiednią inodą na dysku
Ostatnio używane wpisy są przetrzymywane dentry cache??, aby przyspieszyć tłumaczenie z nazwy ścieżki do inody odpowiedniego pliku.dentry cache?? składa
się z typów danych:
- obiektów typu DENTRY w 2 stanach: stan w uzyciu oraz stanu typu nieużywany
- tablicy hashowej?? aby przyspieszyć tłumaczenie z nazwy ścieżki to inody
Block I/O Layer
Przestrzeń adresowa procesu
Page Cache
Komunikacja między procesami
Sygnał to krótka wiadomość, wymieniana między dwoma procesami lub między jądrem a procesem. Sygnały podzielić możemy na dwa typy:
- Asynchroniczne (np. SIGTERM, wysyłany do procesu przez terminal po naciśnięciu sekwencji Ctrl-C) - mogą docierać do procesu w dowolnym momencie,
- Synchroniczne lub wyjątki (SIGSEGV - wysyłany do procesu próbującego odnieść się do pamięci spoza przydzielonego zakresu) - są zależne od działań procesu,
Rozróżniamy około 20 różnych sygnałów zdefiniowanych w standardzie POSIX, niektóre z nich mogą być zignorowane. Niektóre sygnały nie mogą być zignorowane i nie są obsługiwane samodzielnie przez proces.
Linux używa komunikacji międzyprocesowej (IPC) z IPC Systemu V która składa się z:
- Semaforów (uzyskiwane są przez funkcję systemową )semget() .
- Kolejek komunikatów (komunikaty odbierane są przy pomocy funkcji msgget(), a wysyłanych przez msgsnd()).
- Pamięci dzielonej (pamięc alokujemy przy pomocy wywołania funkcji systemowej shmget(), dostęp do pamięci dzielonej uzyskujemy przez shmat(), a zaalokowaną pamięć zwalniamy przy pomocy funkcji shmdt()).
Odzyskiwanie Stron (Reclaiming Pages)
Accessing Directories and Files
Security Subsystem and SELinux
TCP/IP Stack and Netfilter
Moduły
Linux umożliwia ładowanie i usuwanie modułów w czasie uruchomienia i działania. Do zarządzania modułami wykorzystujemy poniższe polecenia:
insmod ładuje moduł bez uwzględniania zależności
modprobe ładuje moduł i wszystkie powiązane moduły
rmmod usuwa moduł z pamięci
Podczas pisania modułu należy wykorzystywać poniższe funkcje.
#include<linux/init.h> Określa plik nagłówkowy, co pozwala na wykorzystanie funkcji usuwania i inicjalizacji modułu.
module_init(init_function); Mówi Linuksowi aby podczas inicjalizacji modułu uruchomił tą właśnie funkcję
module_exit(cleanup_function) Mówi Linuksowi aby podczas usuwania modułu uruchomił tą właśnie funkcję
init_function i cleanup_function to dowolne funkcje wywoływane bez argumentów i zwracające typ int np.: int moja_fajna_funkcja(void)
init i exit są przydomkami funkcji mówiącymi, że mogą one być użyte tylko podczas ładowaniu i usuwaniu modułu. Z koleji initdata i exitdata nadają takie samo znaczenie danym. Powodują one dodatkowo, że kod ten zostanie po skompilowaniu umieszczony w specjalnej sekcji ELF kernela.
Plikami nagłówkowymi wymaganymi do istnienia przez wszystkie moduły:
#include <linux/module.h>
#include <linux/version.h>
Między innymi znajdziesz w nich:
EXPORT_SYMBOL(jakiś_symbol);
EXPORT_SYMBOL_GPL(jakiś_symbol_gpl);
Służą do eksportowania symboli do kernela.
MODULE_AUTHOR(autor_modułu);
MODULE_DESCRIPTION(opis_modułu);
MODULE_VERSION(srting_z_wersją_modułu_);
MODULE_DEVICE_TABLE(table_info);
MODULE_ALIAS(alternatywna_nazwa);
Umieszcza te informacje w kodzie obiektowym (tym generowanym przez kompilator!!).
#include <linux/moduleparam.h>
module_param(variable, type, perm);
Definiuję parametr który może być zmieniany przez użytkownika podczas ładowania modułu. Parametr mogą być typu:
bool, charp, int, invbool, long, short, ushort, uint, ulong, lub intarray.
Innym plikiem nagłówkowym wymaganym przez wszystkie niemal moduły jest:
#include <linux/sched.h>
który zawiera wiele funkcji API kernela.
Przykładowy moduł: (oryginał możesz zobaczyć tutaj)
/*
* hello-2.c - Demonstrating the module_init() and module_exit() macros.
* This is preferred over using init_module() and cleanup_module().
*/
#include <linux/module.h> /* Needed by all modules */
#include <linux/kernel.h> /* Needed for KERN_INFO */
#include <linux/init.h> /* Needed for the macros */
static int __init hello_2_init(void)
{
printk(KERN_INFO "Hello, world 2\n");
return 0;
}
static void __exit hello_2_exit(void)
{
printk(KERN_INFO "Goodbye, world 2\n");
}
module_init(hello_2_init);
module_exit(hello_2_exit);