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: 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

  1. Pliki źródłowe Linuxa:
  2. Projekt LabLinux

Autor: Krzysztof Ostrowski