Wykład 9: Pomiar czasu i synchronizacja względem czasu w Linuksie
Transkrypt
Wykład 9: Pomiar czasu i synchronizacja względem czasu w Linuksie
Systemy Operacyjne semestr drugi Wykład dziewiąty Pomiar czasu i synchronizacja względem czasu w Linuksie 1. Generowanie taktów i pomiar upływu czasu Wywołania wielu funkcji jądra są uzależnione nie wystąpieniem określonych zdarzeń lecz upływem czasu, dlatego jądro systemu musi mieć mechanizm pozwalający na pomiar upływu czasu. Ten pomiar jest również potrzebny aplikacjom przestrzeni użytkownika. Te ostatnie mogą wymagać informacji na temat bieżącej daty i czasu, jak również mogą dokonywać pomiaru czasu wykonania pewnych czynności. Głównym mechanizmem wyznaczającym dla jądra upływ kolejnych chwil jest zegar systemowy. Mechanizm ten generuje w określonych odstępach czasu przerwanie zwane przerwaniem zegarowym, które jest obsługiwane za pomocą specjalnej procedury obsługi przerwania. Zegar systemowy pracuje na podstawie impulsów zegarowych generowanych przez generator impulsów taktujących procesor lub przez inne urządzenia tego typu. Częstotliwość generowania kolejnych przerwań przez zegar systemowy jest określona stałą HZ. Znając częstotliwość można wyznaczyć długość okresu z jakim działa zegar systemowy. W większości współczesnych architektur stała HZ jest równa 100. Wyjątek stanowi między innymi architektura i368, dla której ta wartość wynosi 1000. Została ona zwiększona 10 krotnie podczas implementacji jądra w wersji 2.4. Stała ta ma oczywiście wpływ na szybkość działania systemu. Zwiększenie jej ma wiele zalet, ale również może powodować problemy. Do zalet należy zaliczyć zwiększenie rozdzielczości przerwania zegarowego i zwiększenie dokładności sterowania czasem, co pociąga za sobą lepszą obsługę wszystkich zdarzeń zależnych od czasu. Niestety nie można tej stałej w dowolny sposób modyfikować. Należy również uwzględnić fakt, że zwiększenie tej stałej pociąga za sobą zwiększenie częstotliwości występowania przerwania zegarowego i częstszego wywoływania jego procedury obsługi. Liczbę taktów zegara od momentu uruchomienia systemu przechowuje się w zmiennej jiffies, której wartość jest zwiększana o jeden podczas każdego taktu zegara. Czas pracy systemu (ang. uptime) mierzony jest jako iloraz jiffies/HZ. Zmienna jiffies przechowuje wartości naturalne, 64 bitowe. We wcześniejszych wersjach jądra zmienna ta, w architekturach i386 była 32 bitowa. Przy HZ=100 przepełnienie jej następowało co 497 dni. Kiedy od jądra 2.4 zaczęto stosować stałą HZ=1000, to okres ten skrócił się do 49,7 dnia, dlatego też twórcy jądra zastosowali rozwiązanie polegające na „nałożeniu” 32 bitowej zmiennej jiffies na zmienną jiffies_64, która ma rozmiar 64 bitów. Odwołanie się więc do zmiennej jiffies w architekturach 32 bitowych daje wartość jej młodszego słowa, w architekturach 64 bitowych jej pełną wartość. Dostęp do pełnej wartości tej zmiennej jest możliwy w obu przypadkach poprzez funkcję get_jiffies_64(). Aby uniknąć problemów jakie może spowodować przepełnienie tej zmiennej programiści jądra wprowadzili makrodefinicje, uwzględniające to zjawisko przy pomiarze czasu. Są to time_after, time_before, time_after_eq i time_before_eq. Do wszystkich z nich przekazywane są dwa parametry: wartość zmiennej jiffies i wartość z którą jest ona porównywana. Pierwsza makrodefinicja zwraca wartość różną od zera, jeśli pierwszy parametr reprezentuje moment występujący przed momentem reprezentowanym przez parametr drugi. Druga makrodefinicja działa odwrotnie. Dwie pozostałe dodatkowo uwzględniają przypadek, kiedy oba przekazane makrodefinicjom parametry są równe. Aby aplikacje użytkownika, które były napisane z myślą o wcześniejszych niż wersja 2.4 jądrach mogły działać bez przeszkód na jądrach 2.4 i późniejszych, wprowadzono stałą USER_HZ, która pełni rolę stałej HZ dla przestrzeni użytkownika. Ma ona zastosowanie głównie podczas skalowania wartości jiffies dla przestrzeni użytkownika. Tej konwersji dokonuje makrodefinicja jiffies_to_clock(). Jądro obsługuje również mechanizm zegara czasu rzeczywistego (RTC) z którego pracy korzystają głównie aplikacje użytkowników. Zegar ten przechowuje i aktualizuje informacje o bieżącej dacie i godzinie. Jego zawartość jest odczytywana i umieszczana w zmiennej xtime podczas rozruchu systemu. Jądro nie odczytuje już więcej zawartości zegara czasu rzeczywistego, ale samo aktualizuje zawartość tej zmiennej i ewentualnie może aktualizować zawartość samego RTC. Procedura obsługi przerwania zegarowego podzielona jest na część, która zależna jest od architektury sprzętowej i część, która jest niezależna. W pierwszej realizowane są cztery czynności: założenie blokady xtime_lock celem zabezpieczenia dostępu do zmiennej jiffies_64 i zmiennej xtime, potwierdzenie obsługi przerwania bądź wyzerowanie licznika dekrementacyjnego, okresowy zapis bieżącej wartości zmiennej xtime do RTC, wywołanie funkcji do_timer() Funkcja do_timer() realizuje następujące operacje: zwiększa o jeden wartość zmiennej jiffies_64, aktualizuje statystyki wykorzystania zasobów procesu użytkownika (głownie czas spędzony w przestrzeni użytkownika i przestrzeni systemu), dokonuje tego za pośrednictwem funkcji update_process_times()1, uruchamia procedur obsługi liczników dynamicznych, które zakończyły odliczanie, wywołuje funkcje scheduler_tick(), aktualizuje czas w zmiennej xtime, oblicza obciążenie systemu. Bieżący czas systemowy przechowywany jest w zmiennej xtime, której typ jest strukturą posiadającą dwa pola. Pierwsze z nich przechowuje ilość sekund, które upłynęły od 1 stycznia 1970 roku2, a drugie liczbę nanosekund, które upłynęły od początku bieżącej sekundy. Zapis i odczyt tej zmiennej wymagają założenia blokady sekwencyjnej xtime_lock. Dla aplikacji użytkownika ta zmienna jest dostępna poprzez wywołanie gettimeofday(). 2. Liczniki Liczniki, zwane licznikami dynamicznymi lub licznikami jądra pozwalają opóźnić wykonanie określonych czynnośći o ustaloną ilość czasu 1 2 To uaktualnienie nie jest dokładne, ale nie ma potrzeby zmieniać je na dokładniejsze Tę datę przyjmuje się za początek „epoki Uniksa”. 1 Systemy Operacyjne semestr drugi począwszy od chwili bieżącej. Nie jest to mechanizm precyzyjny i mogą zdarzać się niedokładności rzędu rozmiaru okresu, więc nie powinny one być stosowane do zadań czasu rzeczywistego. W większości przypadków są one odpowiednie dla większości zastosowań. Należy również pamiętać, że liczniki te nie są cykliczne, tzn. po upływie określonego czasu są zwalniane i nie jest wznawiana ich praca. Liczniki dynamiczne są reprezentowane strukturą timer_list. Aby skorzystać z licznika należy zadeklarować zmienną tego typu, zainicjalizować ją przy pomocy init_timer(), wypełnić bezpośrednio pola expires, data i function, oraz uaktywnić przy pomocy add_timer(). Pierwsze z wymienionych pól określa czas po którym ma zostać wywołana związana z licznikiem funkcja, drugie zawiera wartość parametru dla tej funkcji, a trzecie jej adres. Wymieniona wcześniej funkcja ma mieć następujący prototyp: void my_timer_function(unsigned long data); Ponieważ funkcja ta może być związana równocześnie z kilkoma licznikami, to wartość parametru pozwala jej ustalić na rzecz którego została wywołana. Jeśli zaistnieje konieczność modyfikacji momentu wyzwolenia licznika można użyć w tym celu funkcji mod_timer(). Funkcja ta może zostać użyta zarówno dla aktywnych, jak i nieaktywnych liczników, przy czym te ostatnie aktywuje. Jeśli chcemy usunąć aktywny licznik możemy to uczynić funkcja del_timer(). W systemach wieloprocesorowych, celem uniknięcia sytuacji hazardowych należy użyć del_timer_sync(). W każdych systemach należy unikać innej modyfikacji liczników niż przez funkcje mod_timer(), oraz należy pamiętać, aby zabezpieczyć zasoby do których dostęp współbieżny mają funkcje wywoływane przez liczniki oraz inne części kodu. Liczniki są powiązane w listę. Funkcje przez nich wywoływane są uruchamiane w kontekście dolnej połówki, z poziomu przerwania programowego, po zakończeniu obsługi przerwania zegarowego. Aby uniknąć sortowania listy liczników i kosztownego przeglądania są one podzielone na pięć grup, pod względem czasu po jakim trzeba będzie wywołać ich funkcje. 3. Opóźnianie wykonania Najprostszym sposobem opóźnienia wykonania kodu jest umieszczenie w nim pętli aktywnego oczekiwania. W Linuksie można to uczynić korzystając z funkcji time_before(), celem określenia warunku zakończenia takiej pętli. Wymaga to oczywiście odczytu zmiennej jiffies, co może rodzić obawy, czy podczas kompilacji nie zostanie ta operacja w niepożądany sposób zoptymalizowana. Aby zapobiec takim sytuacjom programiści jądra zadeklarowali ją jako volatile, co skutecznie powstrzymuje kompilator od optymalizowania operacji na tej zmiennej. Zalecanym jest, aby w takiej pętli wywołać cond_reached(), która powoduje przeszeregowanie zadań jeśli zachodzi taka potrzeba. Jeśli czas opóźnienia ma być krótki, możemy posłużyć się funkcjami udelay() i mdelay(). Pierwsza opóźnia wykonanie na jedną mikrosekundę wykonując określoną ilość razy pętlę złożoną z określonych instrukcji. Ilość iteracji tej pętli jest określona zmienną BogoMIPS inicjalizowaną przy uruchamianiu jądra. Funkcja mdelay() korzysta do odliczania czasu z ... funkcji udelay(). Ponieważ aktywne oczekiwania jest nieefektywne i niewskazane wykonanie procesów można opóźniać używając do tego funkcji schedule_timeout() i ustawiając stan procesu na TASK_INTERRUPTIBLE lub TASK_UNINTERRUPTIBLE. 2