Do spisu treści tematu nadrzędnego
6.9.16 Zadanie laboratoryjne "Szybkie pliki
konfiguracyjne"
Spis treści:
Wprowadzenie
W środowiskach wielozadaniowych często zachodzi potrzeba
utrzymywania różnych globalnych parametrów, dzięki którym
grupy procesów realizujące jakieś wspólne zadanie mogłyby
synchronizować swoją pracę. Używanie do tego celu zaawansowanych
mechanizmów, takich jak pamięć dzielona, wydaje się marnotrawstwem
zasobów, których liczba jest przecież ograniczona. Szczególnie
wtedy, kiedy parametrów jest stosunkowo niewiele, ich rozmiar
jest niewielki (np. wartości prawda/fałsz lub liczby) a prędkość
nie odgrywa kluczowej roli.
Poza tym, na przykład, ustalenie wspólnego
klucza pamięci dzielonej również wymaga jakiejś synchronizacji
procesów. Zwykle okazuje się, że najlepszym wyjściem z sytuacji
jest odwołanie się do systemu plików, na przykład za pomocą łączy
nazwanych lub w ogóle bezpośrednio, tworząc pliki konfiguracyjne
o ustalonych nazwach w ustalonych katalogach, których zawartość
jest na bieżąco sprawdzana i uaktualniana przez procesy.
Wszystkie te rozwiązania wydają się niepotrzebnie skomplikowane
lub niezręczne. W poniższym zadaniu proponuję stworzenie nowego
rodzaju plików, które zawierałyby listy zmiennych i ich wartości,
ładowane byłyby do pamięci w razie potrzeby i byłyby dzielone
przez wszystkie procesy w systemie. Jądro utrzymywałoby listy
zmiennych i udostępniało ich wartości, zmieniało je lub
dynamicznie dodawało nowe na żądania procesów. Działoby się to
bezpośrednio w pamięci, więc nie byłaby konieczna komunikacja
z dyskiem. Proces, mając deskryptor takiego pliku, mógłby
odczytywać wartości zmiennych konfiguracyjnych i zmieniać je
bardzo szybko i w prosty sposób, przy tym nie byłyby potrzebne
dodatkowe metody wzajemnego wykluczania, ponieważ jądro jest
monitorem. Po zamknięciu pliku przez wszystkie procesy,
zawartość na dysku byłaby uaktualniana a struktury w pamięci
niszczone.
Szczegóły realizacji
Nowy rodzaj pliku fizycznie nie powinien różnić się od normalnego
pliku. Nie jest konieczna modyfikacja struktur dyskowych.
Istotą zadania jest zmiana działania interfejsu pomiędzy procesem
a systemem plików. Zakładamy, że dowolny plik może stać się
szybkim plikiem konfiguracyjnym, pod warunkiem, że zawiera
ciąg napisów postaci "coś=coś" rozdzielonych znakami końca wiersza
(jeśli nie, to otwarcie powinno zakończyć się błędem).
Wystarczy, że co najmniej jeden
proces otworzy go w trybie O_CONFIGSPACE. Wówczas na podstawie
zawartości tego pliku tworzone są struktury w pamięci. W zasadzie
inne procesy mogłyby korzystać z tego pliku normalnie.
Kiedy plik zostanie zamknięty przez wszystkie procesy,
które otworzyły go w trybie O_CONFIGSPACE, zawartość pliku
na dysku zostaje uaktualniona. Ponieważ jednak bloki pliku
reprezentowane są w pamięci przez pulę buforów, może być trudne
zrealizowanie tego w taki sposób, aby dane innych procesów,
które równocześnie zapisują do tego pliku jako do zwykłego,
nie zostały przemieszane z naszymi. Dlatego dla uproszczenia należy
założyć, że jeżeli plik jest używany przez procesy jako szybki
plik konfiguracyjny, to nie jest używany w normalny sposób.
A zatem, rezultatem następującego wywołania:
fd = open("/tmp/a", O_RDWR | O_CONFIGSPACE)
powinna być następująca sekwencja operacji:
- Jeżeli w pamięci nie ma jeszcze listy zmiennych dla pliku
o takiej nazwie:
- Utworzenie struktury reprezentującej nasz plik (wszystkie takie
struktury można trzymać na liście utrzymywanej przez jądro i
tę strukturę można dodać do listy).
- Zainicjalizowanie tej struktury, w szczególności utworzenie
w niej listy zmiennych (i ich wartości) zdefiniowanych przez
ten plik (należy je wczytać z pliku) oraz ustawienie licznika
dowiązań na 1.
- Zapisanie adresu tej struktury w deskryptorze pliku, tak, aby
później jądro mogło szybko do niego sięgnąć, gdy proces
będzie chciał czytać lub modyfikować zmienne (można do tego
celu użyć pola "private_data" deskryptora pliku).
Jeżeli w pamięci jest już lista zmiennych dla pliku o takiej
nazwie (jądro musi przejrzeć swoją listę i sprawdzić, czy któraś
ze struktur mapujących pliki nie mapuje szukanego pliku), to:
- Zapisanie adresu tej struktury w deskryptorze pliku.
- Zwiększenie licznika dowiązań.
Rezultatem wywołania instrukcji "close" powinno być zmniejszenie
licznika dowiązań i, jeżeli licznik dowiązań uzyska wartość zero,
uaktualnienie zawartości pliku na dysku i usunięcie mapującej
go struktury z listy utrzymywanej przez jądro.
Rezultatem wykonania instrukcji postaci:
write(fd, "zmienna=wartość", ?)
powinno być odnalezienie na liście w strukturze wskazywanej
przez deskryptor pliku zmiennej o danej nazwie i zmiana jej
wartości. Dla uproszczenia można założyc, że wszystkie zmienne
mają nazwy maksymalnie np. dziesięcioznakowe a wartości są
ciągami znaków o długości maksymalnej np. też dziesięć.
Jeżeli zmiennej nie ma na liście, należy ją dodać.
Rezultatem wywołania następującej instrukcji:
read(fd, bufor, długość_bufora)
powinno być odnalezienie zmiennej o nazwie takiej, jak nazwa
w buforze i zapisanie wyniku do bufora.
Wskazówki dotyczące implementacji
Dla uproszczenia można założyć, że plikami konfiguracyjnymi
mogą być tylko pliki zapisane w systemie plików ext2.
Modyfikację należy zacząć od funkcji systemowej "open".
W pliku "fs/open.c" znajduje się definicja funkcji
sys_open(),
będącej realizacją wyżej wymienionej funkcji systemowej.
W miejscu, w którym funkcja ta zwraca identyfikator deskryptora
pliku należy wstawić kod sprawdzający, czy plik jest otwarty
w trybie O_CONFIGSPACE i ewentualnie realizujący algorytm otwierania
szybkich plików konfiguracyjnych.
Wyszukiwaniem pliku i inicjalizowaniem deskryptora powinna się
zająć instrukcja open() w swojej dotychczasowej postaci
(pliki konfiguracyjne nie różnią się fizycznie od zwykłych),
dlatego ta modyfikacja musi być ostatnim kawałkiem kodu
wykonywanym przez open()
Definicje struktur można umieścić np. w pliku
"include/linux/fs.h",
natomiast zmienne globalne można umieścić np. w "fs/open.c".
Do dynamicznej alokacji pamięci należy używać funkcji
kmalloc() oraz kfree()
zdefiniowanych w "mm/kmalloc.c".
Warto tutaj pamiętać o tym, że alokacja bloków pamięci przez jądro
odbywa się z pewnym narzutem. Najlepiej byłoby nie alokować
wielu małych, kilkunastobajtowych struktur, lecz większe
bloki i w ramach bloku samodzielnie zarządzać zaalokowaną
przestrzenią. Dobrym ćwiczeniem może być napisanie małego,
prostego managera pamięci specjalnie na potrzeby tego zadania.
Można założyć, że pozycje listy opisujące zmienne mają stałą
długość (max. długość nazwy zmiennej plus max. długość wartości
plus jakaś stała). Realizaja managera pamięci dla takich bloków
o stałej długości jest rzeczą stosunkowo prostą.
Z drugiej strony, należy pamiętać o ograniczeniu wielkości
bloków alokowanych przez jądro. Jednorazowo jądro może zaalokować
zaledwie kilka kilobajtów. Należy uwzględnić to ograniczenie
przy alokacji.
Do wczytania
zawartości pliku należy posłużyć się funkcjami dostarczonymi
przez podsystem obsługi puli buforów: bread(),
brelse() (definicja w "fs/buffer.c"),
wait_on_buffer() (definicja inline
w "/include/linux/locks.h") etc. Ponieważ nie da się
zrealizować tego bez fazy oczekiwania na bufor, w czasie
której jądro jest udostępniane innym procesom, możliwe,
że w tym czasie inny proces też będzie chciał otworzyć ten plik
lub, co gorsza, czytać z niego lub do niego pisać. Można to
zaniedbać lub zablokować listę zmiennych w pamięci na czas
inicjalizaji jej wartościami z pliku. Procesy, które chciałyby
w tym czasie czytać ją dostałyby informację o błędzie.
Kolejna faza modyfikacji dotyczy instrukcji close().
Musimy
zapewnić, że po zamknięciu pliku przez ostatni używający go
proces struktury będą zniszczone a plik na dysku uaktualniony.
Definicja funkcji sys_close() również znajduje się w
pliku "fs/open.c". Należy, podobnie jak wyżej, wykryć,
czy plik jest szybkim plikiem konfiguracyjnym, i jeśli tak,
przeprowadzić proces usuwania struktur. Należy, podobnie jak
wyżej, posłużyć się funkcjami dostarczonymi przez podsystem
obsługi puli buforów: getblk(), brelse()
(plik "fs/buffer.c"),
mark_buffer_dirty(), mark_buffer_uptodate()
(inline w pliku "include/linux/fs.h") etc.
Jak wyżej, należy zwrócić uwagę na problem synchronizaji.
Można ten problem zaniedbać lub nie, ale przyjęte rozwiązanie
powinno być konsekwentne i nie powinno prowadzić do awarii systemu.
W szczególności należy podać warunki, przy jakich w podanym
rozwiązaniu może dojść do konfliktu między procesami i jakie
mogą być tego konsekwencje.
Ostatnią, najprostszą fazą realizacji jest zmiana działania
instrukcji read() i write(). Definicja głównej
części funkcji
write() dla systemu plików ext2 znajduje się w pliku
"fs/ext2/file.c" (funkcja ext2_file_write()),
zmiany najwygodniej jest wprowadzać właśnie tutaj. Funkcja
read()
dla systemu plików ext2 nie została specjalnie zdefiniowana,
struktura "file_operations" w polu określającym standardową funkcję
odczytu ma wartość "generic_file_read". Funkcja ta zdefiniowana
jest w pliku "mm/filemap.c". Dobrym ćwiczeniem może być
zmiana tej wartości. W pliku "fs/ext2/file.c" znajduje
się definicja globalnej zmiennej "ext2_file_operations", będącej
wersją struktury "file_operations" i definiującą operacje
specyficzne dla systemu plików ext2. Można zastąpić wartość
"generic_file_read" przez "ext2_file_read" i poniżej zdefiniować
własną wersję funkcji odczytującej. Powinna ona sprawdzać, czy
plik jest szybkim plikiem konfiguracyjnym, i jeśli tak, realizować
odpowiedni algorytm, a jeśli nie, wywoływać funkcję
"generic_file_read" z odpowiednimi parametrami.
Realizacja operacji read() i write()
nie powinna sprawiać
większych trudności. Do kopiowania porcji danych pomiędzy
obszarem użytkownika (bufor na argumenty i wynik) a jądrem
(struktury utrzymywane przez jądro) można użyć funkcji
memcpy_tofs() oraz memcpy_fromfs() zdefiniowanych
(inline) w pliku "include/asm-i386/segment.h".
Testy, obserwacje i wnioski
Po zaimplementowaniu wyżej opisanego mechanizmu warto porównać
go z innymi metodami synchronizacji procesów, np. z realizacją
na pamięci dzielonej i na plikach, badając czas, jaki zajmuje
zmiana i odczytanie wartości zmiennej przy tych trzech realizacjach
opisanego problemu. W przypadku pamięci dzielonej należy wykorzystać
jeden proces i sprawdzić, jak duża jest strata na prędkości
wynikająca z faktu, że musimy wywołać funkcję jądra. (Do raz
dołączonego bloku pamięci dzielonej można odwoływać się
bezpośrednio, więc działanie powinno być tutaj szybsze). W przypadku
zwykłych plików porównywanie prędkości zapisu i odczytu przez
jeden i ten sam proces nie ma oczywiście sensu, wynik tego
porównania jest z góry znany. Jednak w rzeczywistości procesy
realizujące dane zadanie nie zawsze działają jednocześnie.
W najbardziej niekorzystnej z możliwych sytuacji, czasy
działania procesów są rozłączne i jądro za każdym razem,
gdy jakiś proces zakończy działanie, niszczy struktury w pamięci
i uaktualnia zawartość pliku konfiguracyjnego na dysku.
Z tego punktu widzenia opisany mechanizm ma jednak pewne wady
w stosunku do zwykłych operacji na plikach, ponieważ wymaga
wielokrotnego budowania i niszczenia struktur w pamięci.
Porównując prędkość działania opisanego mechanizmu ze zwykłym
działaniem na plikach należy badać system
w takich właśnie warunkach.
Opisaną sytuację można zasymulowac na jednym procesie, który
w pętli otwiera i zamyka plik konfiguracyjny.
Z wyżej opisanych testów można wyciągnąć wnioski
odnośnie mocnych i słabych stron opisanego mechanizmu. Być
może godnym uwagi usprawnieniem byłaby modyfikacja jego działania
tak, aby po zamknięciu szybkiego pliku konfiguracyjnego
jądro jeszcze przez pewien czas utrzymywało jego zawartość
w pamięci i dopiero wtedy, gdy przez ustalony odcinek czasu
nie ma do niego odwołań, zrzucało go na dysk. Implementacja
tego usprawnienia i badanie jego skuteczności mogłaby
dostarczyć materiału do dalszych testów i być podstawą do
dalszych wniosków.
Bibliografia
- Pliki źródłowe Linuxa:
- linux/fs/read_write.c
- linux/fs/ext2/file.c
- linux/fs/ext2/buffer.c
- linux/fs/fat/file.c
- Projekt LabLinux
Autor: Krzysztof Ostrowski