Szeregowanie procesów w linuxie - trendy rozwojowe
Autorzy: Łukasz Chmielewski, Daniel Hans, Mateusz Łupiński
Wstęp
Algorytm szeregowania (ang. scheduler - planista) to algorytm rozwiązujący jedno z
najważniejszych zagadnień informatyki - jak rozdzielić czas procesora i dostęp do innych zasobów
pomiędzy zadania, które w praktyce zwykle o te zasoby konkurują.
- Wikipedia
Szeregowanie procesów jest jedną z najważniejszych cech systemu operacyjnego.
To ono właśnie zapewnia wieloprogramowość: poprzez odpowiednio częstą zmianę aktualnie
wykonywanego procesu użytkownik systemu ma wrażenie, że wszystkie wykonują się w tym samym czasie.
Scheduler, czyli część systemu odpowiedzialna za to szeregowanie musi więc być zaprojektowana z wielką
starannością. Wkracza on bowiem do akcji za każdym razem kiedy jakiś inny proces traci procesor,
kończy się lub pojawia się proces o wyższym priorytecie. Jest jasne, że dzieje się to bardzo często, a
więc pojedyńcze przeszeregowanie musi odbywać się bardzo szybko.
Nie istnieje optymalny algorytm szeregujący
Co więcej trudno byłoby nawet ustalić do dokładnie miałaby znaczyć optymalność dla szeregowania procesów.
Względem czego? Łącznego czasu wykonania wszystkich procesów? Średniego czasu wykonania pojedyńczego procesu?
Minimalizacja takich współczynników jest niemożliwa do wykonania chociażby dlatego, że procesy napływają
dynamicznie.
Wymyślane są więc (z różnym skutkiem) rozmaite herystyki.
Projektanci systemów operacyjnych kładą nacisk na to, aby działały one jak najbardziej wydajnie:
- przydzielały odpowiednim procesom odpowiednio długie kwanty czasu
- ich czas wykonywania był jak najkrótszy
Scheduler w początkowych wersjach systemu Linux
Początkowa niezmienniczość
Chociaż sposób szeregowania jest tak bardzo ważnym aspektem systemu, to w systemie Linux przez bardzo
długi okres ten element nie był poważniej modyfikowany. Generalnie od samego powstania systemu (mam tu
na myśli wydanie jądra 1.0, a nawet wcześniej),do wersji 2.4 idea,
na której opierało się działanie schedulera pozostawała
niezmieniona (co jakiś czas zmieniane były jedynie szczegóły imlplementacyje niektórych procedur).
Poniżej zostanie omówiony scheduler z wersji jądra 2.4.
Idea działania
Jest bardzo prosta. Zawsze kiedy proces traci procesor uruchamiany jest scheduler, który dla każdego
z aktualnych procesów oblicza jego dynamiczny priorytet w danej chwili i wybiera ten,
który uzna za najlepszy.
Podział na epoki
Działanie algorytmu szeregującego podzielone jest na epoki. Na początku każdej z nich moduł szeregujący
przydziela każdemu procesowi liczbę taktów procesora wynikającą z jego priorytetu.
Epoka kończy się, gdy wszystkie procesy wykorzystają już swoje takty lub dobrowolnie
zrezygnują z procesora.
Klasy procesów
Procesy podzielone są na trzy klasy wobec których moduł szeregujący stosuje różne strategie szeregowania.
Wyróżnia się:
Dla procesów czasu rzeczywistego:
Dla procesów zwykłych:
Procesy czasu rzeczywistego to takie, które będą wykonywały się przed wszystkimi procesami zwykłymi.
Również mają swoją pulę priorytetów (od 1 do 99).
Proces SCHED_FIFO jak już dostanie procesor, to wykonuje się aż do zakończenia, chyba że:
- przyjdzie proces czasu rzeczywistego o wyższym priorytecie.
- proces sam, dobrowolnie odda procesor
- proces zablokuje się na operacji wejścia/wyjścia
Jak widać mechanizm ten jest dobry, ale jeżeli jest kilka procesów o takim samym priorytecie i wśród
nich proces, który zaczął się wykonywać w nieskończoność, to reszta procesów zostaje zagłodzona (nigdy nie
otrzyma procesowa).
Aby temu zaradzić można używać procesów SCHED_RR (round-robin), które różnią się tym, że proces czasu
rzeczywistego, który dostaje procesor, otrzymuje jedynie kwant czasu i po jego wykorzystaniu zostaje
przeniesiony na koniec kolejki procesów oczekujących o takim samym priorytecie. W tej strategii zostanie
on ponownie wybrany tylko wtedy, gdy jest jedynym procesem o takim priorytecie.
Struktury procesu wykorzystywane przez algorytm szeregujący
W strukturze task_struct znajduje się kilka pól wykorzystywanych przez algorytm szeregujący.
Najważniejsze z nich to:
- counter - mówi przez ile jeszcze taktów proces może używać procesora w aktualnej epoce
- need_resched - wartość mówiąca, czy potrzebne jest przeszeregowanie. Algorytm szeregujący
zostanie uruchomiony tak szybko jak to możliwe
- nice - określa statyczny priorytet procesu. Może przyjmować wartości od -20 (najważniejszy)
do +19 (najmniej ważny). Wartość ta jest używana przy liczeniu dynamicznego priorytetu
procesu oraz przy wyliczaniu kwantu czasu na początku każdej epoki.
- policy - określa klasę procesu, a tym samym "politykę" jaką algorytm szeregujący ma wobec
niego stosować
- rt_priority - dla procesów czasu rzeczywistego określa ich priorytet
- state - określa aktualny stan procesu
Funkcja schedule()
Najistotniejszą ze względu na sposób działania algorytmu szeregującego jest funkcja schedule().
Może być ona wywołana na dwa sposoby:
- bezpośrednio
Wywołanie takie ma miejsce, gdy:
- proces kończy się (poprzez funkcję do_exit())
- proces zawiesza się na operacji wejścia/wyjścia lub oczekuje zasobu, który w danej chwili
jest dla niego niedostępny
- pośrednio
Wywołanie takie następuje, jeżeli dla danego procesu wartość need_resched ma wartość 1. Dochodzi
do tego gdy:
- proces wykorzystał już swój kwant czasu w danej epoce.
Sprawdza to funkcja update_process_times, która jest wywoływana po każdym takcie przez
funkcję do_timer (z /kernel/timer.c)
- zjawił się proces o wyższym priorytecie (przeszedł do stanu TASK_RUNNING)
Sprawdza to funkcja reschedule_idle wywoływana przez funkcję budzącą dany proces:
wake_up_process
Funkcja schedule() ma za zadanie na podstawie ostatnio wykonywanego zadania, ktore zapamietuje
na zmiennej prev, wyznaczenie kolejnego, które zostanie zapisane na zmienną next.
Sam algorytm szeregowania działa w skrócie następująco:
- Zakładane są wszelkie blokady oraz sprawdzane jest, czy wywołanie schedulera w danym momencie
jest dozwolone, czyli czy np. nie znajdujemy się w obsłudze przerwania.
Następnie sprawdzane jest, czy wykonywany dotychczas proces nie jest
procesem czasu rzeczywistego typu Robin Round. Jeżeli tak, to jest on wstawiany
na koniec kolejki oraz jezeli zwolnienie przez niego procesora nastąpiło w wyniku
wyczerpania całego kwantu, to dodatkowo jest on odnawiany.
if (prev->policy == SCHED_RR)
if (!prev->counter) {
prev->counter = NICE_TO_TICKS(prev->nice);
move_last_runqueue(prev);
}
- Sprawdzany jest stan aktualnego procesu. Jeżeli jest on inny niż TASK_RUNNING, to
jest on usuwany z kolejki procesów oczekujących. Wyjątkiem jest, gdy stan ma wartość
TASK_INTERRUPTIBLE. Wtedy, jeżeli proces ma nadal jakieś nieobsłużone sygnały, to
"dostaje szansę" na ich obsłużenie: nie tylko nie jest usuwany z kolejki procesów
oczekujących, ale także jego stan zmieniany jest na TASK_RUNNING.
switch (prev->state) {
case TASK_INTERRUPTIBLE:
if (signal_pending(prev)) {
prev->state = TASK_RUNNING;
break;
}
default:
del_from_runqueue(prev);
case TASK_RUNNING:
}
- Zerowana jest flaga need_resched.
prev->need_resched = 0;
- Domyślnie, jako nowy proces do wykonania bierze się proces idle, a jeżeli ostatnio
wykonywany proces nadal jest w stanie TASK_RUNNING, to bierze się jego. Na zmiennej
c ustawiana jest waga wybranego domyślnie procesu.
next = idle_task(this_cpu);
c = -1000;
if (prev->state == TASK_RUNNING) {
c = goodness(prev, this_cpu, prev->active_mm);
next = prev;
}
- Teraz dla każdego procesu z kolejki procesów oczekujących wyliczana jest jest
jego waga. Jeżeli jest dla jakiegoś procesu ona lepsza, niż aktualnie największa waga,
to nowym kandydatem do wykonania (zapamietanym na zmiennej next) staje sie właśnie
ten proces.
list_for_each(tmp, &runqueue_head) {
p = list_entry(tmp, struct task_struct, run_list);
if (can_schedule(p, this_cpu)) {
int weight = goodness(p, this_cpu, prev->active_mm);
if (weight > c)
c = weight, next = p;
}
}
- Jeżeli nie został znaleziony żaden proces, który mógłby zostać wykonany, tzn. że wszystkim aktywnym
skończyły się kwanty czasu. Należy przydzielić procesom nowe kwanty czasu rozpoczynając
tym samym nową epokę, a w algorytmie powrót do punktu czwartego (zerowanie flagi need_resched).
if (!c)
{
struct task_struct *p;
for_each_task(p)
p->counter = (p->counter >> 1) + NICE_TO_TICKS(p->nice);
}
- Jeżeli najlepszy znaleziony proces, czyli ten, który za chwilę dostanie procesor, nie jest
aktualnym procesem, to następuje zmiana kontekstu. W szczególności aktualizowane są wszystkie
zmienne globalne, w tym current.
switch_to(prev, next, prev);
- Jeżeli nowy proces ma ustawioną flagę need_resched, to cały algorytm wykonywany jest
od początku.
if (current->need_resched)
goto poczatek;
return;
Funkcja goodness()
Jest to bardzo ważna funkcja, która dla danego procesu p ublicza jego priorytet w danej chwili.
Również przez cały czas istnienia omawianego schedulera doczekała się jedynie drobnych modyfikacji.
Jako wynik rezultat ona wartość całokowtoliczbową. Im jest ona większa, tym proces jest bardziej
"odpowiedni" do tego, aby dostać procesor.
Wartość wynikowa obliczana jest według następującego schematu:
- Jeżeli proces p sam zrezygnował z procesora, to zwracana jest dla niego wartość -1
- Dla zwykłego procesu zwracana jest pozostała liczba taktów w danej epoce powiększona o
pewne "bonusy":
- wynikające z wartości nice
- jeżeli nowe strony nie będą musiały zostać załadowane do pamięci
- dla wielu procesorów: jeżeli proces dotychczas wykonywał się na danym procesorze
- Dla procesów czasu rzeczywistego zwracana jest wartość 1000 + p->rt_priority
Kod tej funkcji jest tak samo krótki i prosty jak zasada jej działania.
static inline int goodness(struct task_struct * p, int this_cpu, struct mm_struct *this_mm)
{
int weight;
weight = -1;
/* Przypadek, że proces sam oddał procesor: ustawiona flaga SCHED_YIELD */
if (p->policy & SCHED_YIELD)
goto out;
if (p->policy == SCHED_OTHER) {
weight = p->counter;
if (!weight)
goto out;
/* Przypadek dla wielu procesorów */
#ifdef CONFIG_SMP
if (p->processor == this_cpu)
weight += PROC_CHANGE_PENALTY;
#endif
if (p->mm == this_mm || !p->mm)
weight += 1;
weight += 20 - p->nice;
goto out;
}
/* Przypadek dla procesów czasu rzeczywistego */
weight = 1000 + p->rt_priority;
out:
return weight;
}
Podsumowanie schedulera z wersji jądra 2.4
Scheduler stosowany w systemie operacyjnym Linux aż do wersji jądra 2.4 posiadał niewątpliwie jedną
fundamentalną zaletę: był prosty.
Nie wystarczło to jednak do zaspokojenia potrzeb użytkowników, ponieważ posiadał on również wiele
istotnych wad i ograniczeń.
- Złożoność liniowa!
Jest to chyba największa wada tego algorytmu szeregującego. Każde jego wywołanie miało złożoność
liniową względem aktualnej liczby procesów. Dla małej liczby zadań nie był to problem, ale im więcej
było wykonywanych, tym dłużej trwało jedno przeszeregowanie, a jak wpomniane zostało wcześniej,
szeregowanie w systemach wieloprogramowych musi być wykonywane bardzo często. Dla bardzo dużej liczby
procesów może dojść do paradoksu, w którym procesor spędza więcej czasu na szeregowaniu procesów
niż wynosi przydzielany przez niego kwant czasu.
- Zakładanie blokady na jedyną kolejkę procesów nawet w systemach wieloprocesorowych
Dla zwiększenia czytelności kodu nie zostało to pokazane, ale na wszystkie sekcje krytyczne w
funkcji schedule() (oraz w innych wykorzystywanych funkcjach) zakładana jest blokada. Problem
polega na tym, że jest tylko jedna blokada. Wobec tego, jeśli jeden procesor wybiera kolejny proces
do wykonania i zjawiają się kolejne procesory, które również chciałyby to zrobić, to muszą one
bezczynnie czekać, aż pierwszy procesor zakończy swoje operacje.
- Zwiększanie znaczenia procesów wykonująych częste operacje wejścia wyjścia
Kiedy rozpoczynana jest nowa epoka, scheduler odnawia każdemu procesowi wartość pozostałej liczby
taktów. Jeżeli proces w poprzedniej, właśnie zakończonej epoce nie wykorzystał wszystkich swoich
taktów (np. ponieważ zawiesił się na operacji wejścia wyjścia), to dostaje nie tylko liczbę taktów
wynikającą ze swojego priorytetu, ale także dodaje się do niej połowę liczby taktów niewykorzystanej
we właśnie zakończonej epoce.
Założenie było zapewne takie, aby podwyższyć priorytet procesów interaktywnych, które nie powinny się
zacinać, ale oprócz tego bardzo często podwyższany był priorytet procesów, które wcale tego nie
potrzebowały.
- Słabe wsparcie dla procesów czasu rzeczywistego
Spowodowane to było głównie tym, że jądro nie mogło zostać wywłaszczone, a przez to nie było żadnej
gwarancji, że proces czasu rzeczywistego wykona się w jakichkolwiek nałożonych na niego ograniczeniach
czasowych.
Opisane problemy wskazywane były w różnego rodzaju pracach oraz artykułach naukowych.
Do roku 2001 ukazało się bardzo dużo takowych, z których warto zwrócić uwagę np. na "Enhancing Linux
Scheduler Scalability", którego autorem jest Mike Kravetz z IBM Linux Technology Center. Podane wady
schedulera z wersji jądra 2.4 zostały poparte testami:
Measurements using Java benchmarks show that the scheduler can consume up to 25% of the total
system time for workloads with a large number of tasks. Another study has observed run queue
lock contention to be as high as 75% on a 32-way SMP.
Co więcej, praca ta nie tylko wskazywała błędy, ale też podsuwała gotowe rozwiązania w celu ich
wyeliminowania lub przynajmniej minimalizacji.
Opisany jest:
- Priority Level Scheduler - stara się zmiejszyć liczbę procesów sprawdzanych
podczas pojedyńczego szeregowania
- Multi-Queue Scheduler - stara się zmiejszyć liczbę sytuacji, w których procesory
czekają na przeszeregowanie z powodu założonej blokady na kolejce.
Wobec wszystkich wymienionych powyżej wad (a także na pewno innych, które nie zostały wymienione)
jasne było, że musi pojawić się nowa wersja modułu szeregującego procesy. Szczególnie duże znaczenie
miał przy tym fakt, iż Linux stawał się coraz bardziej popularnym systemem dla komuterów domowych,
które jak wiadomo mają inne, bardziej interaktywne typy procesów, które muszą być wykonywane płynnie.
Dla przeciętnego użtykownika komputera osobistego najważniejsze znaczenie mają m.in.: płynnie
dziłające środowisko graficzne oraz płynnie odtwarzające się filmy i muzyka.
Jakikolwiek nowy scheduler musiał być ukłonem także w strone takich właśnie użytkowników.
Zupełnie nowy scheduler
W styczniu 2002 roku ogłoszony został zupełnie nowy scheduler nazwany O(1).
Jego autorem jest węgier Ingo Molnar.
Z powodu swoich niewątpliwych zalet, bardzo szybko został wprowadzony do oficjalnej linii jądra
2.5. Co więcej, okazał się on tak dobry, że został wprowadzony do kolejnej stabilnej wersji jądra.
Pojawił się od wydania 2.6.8.
Scheduler ten został szczegółowo omówiony na wykładzie.
Jego najważniejsze cechy to:
- Czas jednego przeszeregowania procesów jest stały względem ich liczby. Stad wzięła się
właśnie jego nazwa O(1).
- Zapewniona została skalowalność względem liczby procesorów w architekturach SMP. Teraz
większa liczba procesorów nie prowadzi już do bezproduktywnych przestojów.
- Lepiej obsługiwane są procesy interaktywne, czyli w szczególności takie z których
korzystają domowi użytkownicy, jak odtwarzanie filmów, muzyki oraz środowisko graficzne.
W założeniu procesy powinny wykonywać się płynnie nawet przy dużej liczbie innych
procesów.
- Jest bardziej sprawiedliwy - procesy nie są głodzone i otrzymują krótsze kawnty czasu
przez co złudzenie równoległości jest większe.
Oczywiście rozwiązywał on większość problemów, z którymi nie radził sobie poprzedni scheduler.
Jako dowód wystarczy spojrzeć na wyniki testów (z wykładu):
Oraz uzyskany efekt dla architektur wieloprocesorowych:
Powstanie scheduler'ów sprawiedliwych
Źródło: lwn.net, ck.wikia.com
Po co?
Szeregowanie procesów jest jednym z tych elementów systemu, nad którym praca nigdy się nie kończy. Programiści mogą pracować na schedulerem stale go usprawniając, jednak zawsze istnieją przypadki, w których planista nie działa tak dobrze jak by mógl, lub tak dobrze, jak życzyłby sobie tego użytkownik. Jeśli chodzi o systemy interaktywne, dla użytkownika najważniejszy jest jak najkrótszy czas, w jakim system reaguje na jego działanie. Aby zapewnić tą cechę ostatni planista, zwany O(1) wyposażany był w coraz to nowe heurystyki, które miały za zadanie wykryć które procesy są rzeczywiście interaktywne i zapewnić im wyższy priorytet w użyciu procesora. Powodowało to, że jego kod się rozrastał i stawał się coraz bardziej skomplikowany, jednak planista nadal nie działał idealnie.
Główne wady schedulera O(1)
- Posiada rozbudowany kod
- Nie zapewnia pełnej interaktywności
- Może osiągać słabe wyniki dla aplikacji poziomu użytkownika, które nie wykorzystują technik zapewniających wysoką wydajność, manipulacji wątkami itp, na przyklad dla gier, przede wszystkim dla gier odpalanych przez wine, gdzie nie mamy kontroli nad oryginalnym kodem
- Możliwość zagłodzenia procesów programów poziomu użytkownika (It's not the occasional stalls in userspace that are a problem but real world workloads that cause complete starvation of some userspace programs at extremely light loads (mainline is starveable at loads of 1). That is singularly unacceptable.)
Pierwsze próby - Staircase scheduler
W odpowiedzi na skargi wielu użytkowników dotyczace nadmiernej złożoności obecnego planisty, w 2004 roku Con Kolivas stworzył patch, zwany "staircase scheduler". Patch ten w sporej mierze uprościł planistę, usuwając 400 liniu kodu, dodając mniej niż 200. Dodać należy, że większość usuniętego kodu stanowiły "magiczne" obliczenia ustalające stopień interaktywności procesu.
Sposób działania
"Staircase scheduler" implementował pojedynczą tablicę, indeksowaną priorytetami, dla każdego procesora. Początkowo każdy proces trafiał w miejsce określone przez jego bazowy priorytet(wyliczany na podstawie nice). Wtedy proces o najwyższym priorytecie był lokalizowany i przydzielany mu był procesor (póki co podobnie jak w O(1)). Główna różnica polegała na tym, gdzie wędruje proces po zużyciu swojego timeslice'u (równego w pierwszej wersji 10ms * liczba_cpu). W O(1) proces następnie trafiał do drugiej tablicy expired, tu natomiast trafia z powrotem do początkowej tablicy, jednakże z priorytetem o 1 mniejszym. Dzieje się tak do momentu, gdy skończy się tablica. Wtedy proces wskakuje do miejsca o 1 niższego niż jego początkowe położenie, jednak na czas działania na tym poziomie dostaje o 1 wiecej timeslice niż miał na początku. Na kolejnych schodach znów dostaje po jednym timeslice i, podobnie jak wcześniej, schodzi tak do końca.
Wygląda to tak:
Priority rank
Iteration Base -1 -2 -3 -4 -5 -6 -7 -8 -9 ...
1 1 1 1 1 1 1 1 1 1 1
2 2 1 1 1 1 1 1 1 1
3 3 1 1 1 1 1 1 1
Jeśli proces śpi przez pewien określony czas, zostaje wepchnięty na górę o jeden stopień. Dzięki temu mechanizmowi procesy interaktywne, które standardowo śpią przez jakiś czas, praktycznie stale będą pozostawały na szczycie "schodów", co sprawia, że będą lepiej i szybciej obsługiwane.
Chociaz przy kolejnych iteracjach proces zaczynał z niższym priorytetem, to dostawał więcej czasu, zatem w pewien spoób różnice priorytetów pomiędzy procesami pozostawały zachowane.
Główne zalety:
- Interaktywny poprzez mechanizm działania, a nie skomplikowane algorytmy rozpoznawania interaktywności
- Niewielkie opóźnienia dla normalnych procesów
- Prosta budowa
Pierwszy "całkiem" sprawiedliwy - The Rotating Staircase Deadline Scheduler
Mimo, iż patch Con'a Kolivas'a nie wszedł do głównej gałęzi rozwojowej, Con dalej pracował nad zwiększeniem interaktywności schedulerów. Efektem jego pracy był RSDL (The Rotating Staircase Deadline Scheduler) przemianowany później na SD (Staircase Deadline Scheduler). Scheduler ten powstał na podstawie, zarówno pomysłów zaczerpniętych z jego wczesniejszego dziecka - Staircase Schedulera, oraz starego, dobrego O(1), jak i kilku nowych.
Podstawowe cechy:
- Odporny na zagłodzenia
- Brak jakichkolwiek oszacowań "stopnia interaktywności" procesu, ani też związanych z tym bonusów
- Całkowita sprawiedliwość oparta jedynie na nice procesu
- Niewielkie opóźnienia(low latency)
- Duża interaktywność w granicach wyznaczonych przez powyższe cechy
Sposób działania:
Podobnie jak większości scheduler'ów RSDL posiada tablicę priorytetów. Na każdym poziomie widać listę procesów aktualnie czekających. żeby rozpocząć działanie z danym priorytetem. Każdy z procesów ma przydział czasu, jaki może działać z danym priorytetem.
Wygląda mniej więcej tak:
Staircase
Procesy z najwyższym priorytetem otrzymują timeslice'y (zależne jedynie od RR_INTERVAL - jedyna modyfikowalna zmienna, od której zależne są wszystkie przedziały czasowe), po czym scheduler zaczyna działać używając klasycznego algorytmu round-robin. Gdy dany proces zużyje swój przydział czasu, przesuwa się na tablicy priorytetów o jeden niżej i otrzymuje nowy przydział czasu (wielkość przydziału zależna jest od RR_INTRVAL i obecnego priorytetu)
Tutaj podobnie jak procesy o niższym priorytecie, czeka, aż scheduler dojdzie do odpowiedniego "piętra". W rezultacie także procesy o najniższym priorytecie otrzymają w koncu przynajmniej trochę czasu procesora.
Minor rotation
Ciekawą cechą tego planisty jest, iż każdy poziom o danym priorytecie ma również pewien przydział czasu. Gdy przydział czasu danego poziomu sie skończy, wszystkie procesy należące do niego zostają przesunięte o 1 w dół, bez względu na to, czy zużyły swoje indywidualne przedziały czy nie. Rezultatem tego mechanizmu, zwanego "minor rotation" jest fakt, iż dla każdego procesu wystarczy, że upłynie określony czas, żeby procesy o wyższych priorytetach "zeszły" do jego priorytetu. Maksymalne okres oczekiwania dla każdego procesu da się łatwo wyliczyć, zatem nie jest możliwe zagłodzenie.
Major rotation
Po tym jak dany proces dojdzie na sam dół listy priorytetów, wędruje do drugiej tablicy zwanej expired (podobnie jak w O(1)) na to samo miejsce, które zajmował oryginalnie w pierwszej tablicy. Gdy w tablicy pierwszej nie będzie już zadnego aktywnego procesu, lub procesy "wypadną" z tablicy w wyniku działania "minor rotation". Wtedy zachodzi, podobnie jak w standardowym O(1) tzw. "major rotation", tzn. tablica aktywnych procesów i tablica procesów expired są zamieniane i wszystkie wyżej opisane wydarzenia zachodzą ponownie.
Standardowy scheduler przed zajściem "major rotation" stara sie na podstawie m.in. informacji o tym jak często proces spał, wyliczyć które procesy są interaktywne. Procesy te dostają bonus do swojego priorytetu. W RSDL całe te wyliczenia są pominięte. Sam jego mechanizm działania sprawia, że procesy śpiące dłużej będą dłużej pozostawały na wyższych piętrach, przez co będą lepiej obsługiwane.
Modelling Deadline Behaviour
Jako że działanie tego algorytmu nie zależy od różnych wyliczanych dynamicznie współczynników interaktywności, czy bonusów na priorytetach, czas trwania jednego obrotu dużej pętli ("major epoch") zależy wyłącznie od liczby procesów, jakie działają oraz ich współczynnika nice, dzięki czemu możemy go łątwo wyliczyć. Podobnie jest z czasem oczekiwania danego procesu na CPU. Więdząc jakie procesy, o wyższych priorytetach niż dany, działają możemy, posługując się prostą matematyką, wyliczyć opóźnienie procesu w dostępie do procesora (latency).
Kolejny sprawiedliwy - CFS
RSDL vs. CFS
Pomimo iż SD działał dużo lepiej od oryginalnego O(1) oraz tego, że był od niego znacznie prostszy, nie wszedl do głównej linii rozwojowej jądra. Do jądra wszedl natomiast, stworzony przez jednego z głównych developerów linuxa Ingo Molnara CFS. Wzbudziło to wiele kontrowersji. Nie pomagał fakt, iż Ingo skorzystał z ideii całkowitej sprawiedliwości, której użyteczność wykazał właśnie pominięty SD. Jeszcze więcej sporów wzbudził fakt, iż w niektórych testach CFS wypadal gorzej niż RSDL, a także to, iż jego kod był znacznie bardziej złożony.
Uzasadnienie Torvalds'a
Linus Torvalds, uzasadniając fakt, iż do jądra nie wszedl RSDL, nie powoływał sie tak naprawde na argumenty merytoryczne. Uważał, że Con zamiast usprawniać schedulera, kłócił się z ludźmi, którzy zgłaszali błędy. Jego porównanie wydajności obu schedulerów sprowadziło się do stwierdzenia, iż na podstawie testów można ustalić z pewnością, iż oba schedulery były dużo lepsze od starego, jednak pomiędzy nimi nie widać znaczącej różnicy.
Kwestia nierozstrzygalna
Oczywiscie, jak można się domyślić, użytkownicy linuxa podzielili się na dwa obozy. Pro RSDL i pro CFS i tak naprawde, śledząc ich dyskusje na różnych forach i portalach nie możliwe jest obiektywne stwierdzenie kto miał rację.
Zarówno ideę jak i implementacje schedulera CFS przedstawi jednak kolega.
CFS - Completely Fair Scheduler
Jak się to wszystko zaczęło?
"Modular Scheduler Core and Completely Fair Scheduler"
W kwietniu roku 2007 po 62 godzinach kodowania Ingo Molnar wydał zestaw patchy zatytułowany: "Modular Scheduler Core and Completely Fair Scheduler", a dokładniej jego pierwszą wersje v1. Nazwa patch nie do końca oddaje ideę, gdyż w praktyce kod schedulera został napisany zupełnie na nowo od zera, zbierając różne pomysły pojawiające sie w czasie królowania O(1). Wprowadzona została rozszerzalna hierarchia modułów schedulera. Każdy moduł ma prezentować pewien sposób szeregowania czyli policy, a obsługiwane są przez scheduler-core, który nie zakłada praktycznie żadnych szczegółów o ich działaniu. Należy się tu jednak pewne wyjaśnienie. Nie jest to żaden pełen framework do wygodnego i sprawnego podmieniania schedulerów. Całość działa na zasadzie tego, że kompilując jądro wybieramy plik sched_*.c implemetujący daną politykę szeregowania, a później możemy ew. wybrać by przy uruchomieniu załadował się inny scheduler, niż ten który wybraliśmy przy instalacji. Nie jest możliwe podmienianie tego schedulera w runtime, a wspomniane ustawienia wymagają modyfikacji pewnych flag. [Even if I make that sched_sd.c patch, people cannot use SD as their default scheduler unless they hack SCHED_FAIR 0 to read SCHED_SD 0 or similar. The same goes for original staircase cpusched, nicksched, zaphod, spa_ws, ebs and so on.]
Jednym z modułów jest też sched_fair.c, implementujący CFS. (zastępuje dotychczasowy SCHED_OTHER). Autor Ingo Molnar prezentując go szczególnie dziękował i chwalił Cona Kolivasa, z którego RSDL/SD, zaczerpnięto zasadniczą ideę: Fairness (uczciwość, sprawiedliwość, bezstronność :) ), jednocześnie zaznaczając, że CFS jest implementacyjnie zupełnie różny od RSDL/SD. Na nim skupi się właśnie ta część prezentacji.
Ale zanim to nastąpi wymienię inne zmiany wprowadzenie jednocześnie:
nowy sched_rt.c obsługujący procesy typu real-time, zmiany w obsłudze SMP load-balncing (rozkładanie wykonywania procesów na procesory w procesorach wielordzeniowych), ogólnie kod scheduler-core dzięki modularyzacji zmalał do ok 700linii. Wydawałoby się, że robi to wrażenie, ale tak naprawdę poprostu kod który do tej pory znajdował się w sched.c dotyczący polityk, został przerzucony do plików implementujących konkretne polityki, więc defacto nie ubyło go znowu zbyt wiele.
80% of CFS's design can be summed up in a single sentence: CFS basically models an "ideal, precise multi-tasking CPU" on real hardware.
te słowa znajdujemy na początku patcha
Co nowego wprowadza CFS?
- Podziękowania dla Cona Kolivasa za pokazanie swoim SD, że idea Fairness ma sens i może być efektywna :)
- Przede wszystkim zmieniono strukturę danych, stare wysłużone kolejki (runqueues) zostały zastąpione przez drzewo czerwonow-czarne (rbtree) modelujące kolejność wykonywania.
- Wprowadzono granularyzację bazującą na nanosekundach, nie zaś na jiffies/HZ. Do dostosowywania granularyzacji służy:
/proc/sys/kernel/sched_granularity_ns
pozwala to dostosowywać scheduler do działania np. na komputerach domowych, gdzie oczekujemy jak najmniejszych opóźnień związanych z aplikacjami, lub też w drugą stronę do optymalizacji dla serwerów dla dobrego grupowania (good batching). Standardowo mamy opcje dla komputerów desktopowych
- W prowadzone rozwiązania czynią system odpornym na znane dotej pory sposoby zaburzania pracy schedular poprzez np dobieranie procesów złośliwie wbrew stosowanej przez niego heurystyce
- Silniejsza obsługa poziomów nice oraz SCHED_BATCH, jeszcze większe rozróżnienie pomiędzy procesami obliczeniowymi, a normalnymi
CFS rozwijał się bardzo dynamicznie, już po 4rech dniach pojawiła się kolejna wersja v2, poprawiająca wiele szczegółów, w tym możliwość pewnych zagłodzeń związanych z poziomamii nice w bardzo specyficznych przypadkach, uruchamianie dzieci z zerowymi statystykami, bez dziedziczenia ich po rodzicach (podobno zwiększa to wydajność przy duzych obciążeniach), uniezależnienie poziomów nice od wartości granularity_level, dodanie nowych plików i nowych pozycji w plikach z rodziny /proc/ dla lepszego podglądu pracy schedulera i jeszcze kilka innych.
W tej chwili najnowsze implementacje korzystają z pomysłów przedstawionego pod koniec wakacji 2007 Really Fair Schedulera autorstwa Romana Zippela, i ponownie jak z Conem Kolivasem, pojawia się spór, że z całej pracy niektórych osób Ingo wybiera tylko najciekawsze rzeczy i przepisuje je do swojego CFS.
problemy: obecnie w wydaniach jadra mamy do czynienia z podstawowa wersja schedulera (z lipca 2007), choc opracowano juz -v24 (24tą) wersję tegoż schedulera, jej dodanie jest planowane wraz z wydaniem stabilnej wersji jadra 2.6.24, wiadomo, że:
wydanie z wrzesnia odchudzające kod i wprowadzające pomysły z RFS:
http://kerneltrap.org/mailarchive/linux-kernel/2007/9/11/230691
a to już treść maila dotyczącego najnowszego wydania -v24 z 19.listopada 2007
There are countless improvements in -v24 (see the shortlog further below
for details), but here are a few highlights:
- improved interactivity via Peter Ziljstra's "virtual slices" feature.
As load increases, the scheduler shortens the virtual timeslices that
tasks get, so that applications observe the same constant latency for
getting on the CPU. (This goes on until the slices reach a minimum
granularity value)
- CONFIG_FAIR_USER_SCHED is now available across all backported
kernels and the per user weights are configurable via
/sys/kernel/uids/. Group scheduling got refined all around.
- performance improvements
- bugfixes
99% of these changes are already upstream in Linus's git tree and they
will be released as part of v2.6.24. (there are 4 pending commits that
are in the small 2.6.24-rc3-v24 patch.)
Mówimy o cechach, pomówmy o implementacji...
Ideowo proces działania CFS jest bardzo prosty: dla każdego procesu sumujemy czas, przez jaki pozostawał w uśpieniu (oczekując na wznowienie) dzielony przez liczbę aktywnych i oczekujących w tym czasie procesów. Wg tych sum trzymamy procesy w drzewie rbtree. W momencie przełączenia konktekstu, gdy mamy zdecydować który proces mamy wybrac do działania decydujemy się na ten o największej wartości (ścieżka maksymalnie "w prawo" w naszym drzewie). Gdy dany proces zostaje odłączony (przechodzi w stan oczekiwania), ale nie kończy swego działania, od jego sumy odejmowana jest wartość równa długości czasu przez jaki ten proces był obsługiwany przez procesor.
opisuje to Ingo Molnar (autor) w swoim liscie:
http://people.redhat.com/mingo/cfs-scheduler/sched-design-CFS.txt
"Ideal multi-tasking CPU" is a (non-existent :-)) CPU that has 100%
physical power and which can run each task at precise equal speed, in
parallel, each at 1/nr_running speed. For example: if there are 2 tasks
running then it runs each at 50% physical power - totally in parallel.
On real hardware, we can run only a single task at once, so while that
one task runs, the other tasks that are waiting for the CPU are at a
disadvantage - the current task gets an unfair amount of CPU time. In
CFS this fairness imbalance is expressed and tracked via the per-task
p->wait_runtime (nanosec-unit) value. "wait_runtime" is the amount of
time the task should now run on the CPU for it to become completely fair
and balanced.
( small detail: on 'ideal' hardware, the p->wait_runtime value would
always be zero - no task would ever get 'out of balance' from the
'ideal' share of CPU time. )
CFS's task picking logic is based on this p->wait_runtime value and it
is thus very simple: it always tries to run the task with the largest
p->wait_runtime value. In other words, CFS tries to run the task with
the 'gravest need' for more CPU time. So CFS always tries to split up
CPU time between runnable tasks as close to 'ideal multitasking
hardware' as possible.
Most of the rest of CFS's design just falls out of this really simple
concept, with a few add-on embellishments like nice levels,
multiprocessing and various algorithm variants to recognize sleepers.
In practice it works like this: the system runs a task a bit, and when
the task schedules (or a scheduler tick happens) the task's CPU usage is
'accounted for': the (small) time it just spent using the physical CPU
is deducted from p->wait_runtime. [minus the 'fair share' it would have
gotten anyway]. Once p->wait_runtime gets low enough so that another
task becomes the 'leftmost task' of the time-ordered rbtree it maintains
(plus a small amount of 'granularity' distance relative to the leftmost
task so that we do not over-schedule tasks and trash the cache) then the
new leftmost task is picked and the current task is preempted.
The rq->fair_clock value tracks the 'CPU time a runnable task would have
fairly gotten, had it been runnable during that time'. So by using
rq->fair_clock values we can accurately timestamp and measure the
'expected CPU time' a task should have gotten. All runnable tasks are
sorted in the rbtree by the "rq->fair_clock - p->wait_runtime" key, and
CFS picks the 'leftmost' task and sticks to it. As the system progresses
forwards, newly woken tasks are put into the tree more and more to the
right - slowly but surely giving a chance for every task to become the
'leftmost task' and thus get on the CPU within a deterministic amount of
time.
Some implementation details:
- the introduction of Scheduling Classes: an extensible hierarchy of
scheduler modules. These modules encapsulate scheduling policy
details and are handled by the scheduler core without the core
code assuming about them too much.
- sched_fair.c implements the 'CFS desktop scheduler': it is a
replacement for the vanilla scheduler's SCHED_OTHER interactivity
code.
I'd like to give credit to Con Kolivas for the general approach here:
he has proven via RSDL/SD that 'fair scheduling' is possible and that
it results in better desktop scheduling. Kudos Con!
The CFS patch uses a completely different approach and implementation
from RSDL/SD. My goal was to make CFS's interactivity quality exceed
that of RSDL/SD, which is a high standard to meet :-) Testing
feedback is welcome to decide this one way or another. [ and, in any
case, all of SD's logic could be added via a kernel/sched_sd.c module
as well, if Con is interested in such an approach. ]
CFS's design is quite radical: it does not use runqueues, it uses a
time-ordered rbtree to build a 'timeline' of future task execution,
and thus has no 'array switch' artifacts (by which both the vanilla
scheduler and RSDL/SD are affected).
CFS uses nanosecond granularity accounting and does not rely on any
jiffies or other HZ detail. Thus the CFS scheduler has no notion of
'timeslices' and has no heuristics whatsoever. There is only one
central tunable:
/proc/sys/kernel/sched_granularity_ns
which can be used to tune the scheduler from 'desktop' (low
latencies) to 'server' (good batching) workloads. It defaults to a
setting suitable for desktop workloads. SCHED_BATCH is handled by the
CFS scheduler module too.
Due to its design, the CFS scheduler is not prone to any of the
'attacks' that exist today against the heuristics of the stock
scheduler: fiftyp.c, thud.c, chew.c, ring-test.c, massive_intr.c all
work fine and do not impact interactivity and produce the expected
behavior.
the CFS scheduler has a much stronger handling of nice levels and
SCHED_BATCH: both types of workloads should be isolated much more
agressively than under the vanilla scheduler.
( another detail: due to nanosec accounting and timeline sorting,
sched_yield() support is very simple under CFS, and in fact under
CFS sched_yield() behaves much better than under any other
scheduler i have tested so far. )
- sched_rt.c implements SCHED_FIFO and SCHED_RR semantics, in a simpler
way than the vanilla scheduler does. It uses 100 runqueues (for all
100 RT priority levels, instead of 140 in the vanilla scheduler)
and it needs no expired array.
- reworked/sanitized SMP load-balancing: the runqueue-walking
assumptions are gone from the load-balancing code now, and
iterators of the scheduling modules are used. The balancing code got
quite a bit simpler as a result.