A. Tanenbaum, H. Bos - Systemy Operacyjne
A. Tanenbaum, H. Bos - Systemy Operacyjne
A. Tanenbaum, H. Bos - Systemy Operacyjne
pl
Tytuł oryginału: Modern Operating Systems (4th Edition)
ISBN: 978-83-283-1425-2
Authorized translation from the English language edition, entitled: MODERN OPERATING SYSTEMS; Fourth Edition,
ISBN 013359162X; by Andrew S. Tanenbaum; and by Herbert Bos; published by Pearson Education, Inc, publishing as Prentice Hall.
Copyright © 2015, 2008 by Pearson Education, Inc.
All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical,
including photocopying, recording or by any information storage retrieval system, without permission from Pearson Education, Inc.
Polish language edition published by HELION S.A., Copyright © 2016.
AMD, the AMD logo, and combinations thereof are trademarks of Advanced Micro Devices, Inc.
Android and Google Web Search are trademarks of Google Inc.
Apple and Apple Macintosh are registered trademarks of Apple Inc.
ASM, DESPOOL, DDT, LINK-80, MAC, MP/M, PL/1-80 and SID are trademarks of Digital Research.
® ® ®
BlackBerry . RIM , Research In Motion and related trademarks, names and logos are the property of Research
In Motion Limited and are registered and/or used in the U.S. and countries around the world.
Blu-ray Disc™ is a trademark owned by Blu-ray Disc Association.
CD Compact Disk is a trademark of Phillips.
CDC 6600 is a trademark of Contral Data Corporation.
CP/M and CP/NET are registered trademarks of Digital Research.
DEC and POP are registered trademarks of Digital Equipment Corporation.
eCosCentric is the owner of the eCos Trademark and eCos LoGo, in the US and other countries. The marks were acquired
from the Free Software Foundation on 26th February 2007. The Trademark and Logo were previously owned by Red Hat.
The GNOME logo and GNOME name are registered trademarks or trademarks of GNOME Foundation in the United States
or other countries.
® ®
Firefox and Firefox OS are registered trademarks of the Mozilla Foundation.
Fortran is a trademark of IBM Corp.
FreeBSD is a registered trademark of the FreeBSD Foundation.
GE 645 is a trademark of General Electric Corporation.
Intel Core is a trademark of Intel Corporation in the U.S. and/or other countries.
Java is a trademark of Sun Microsystems, Inc., and refers to Sun's Java programming language.
®
Linux is the registered trademark of Linus Torvalds in the U.S. and other countries.
MS-DOS and Windows are registered trademarks of Microsoft Corporation in the United States and/or other countries.
TI Silent 700 is a trademark of Texas Instruments Incorporated.
UNIX is a registered trademark of The Open Group.
Zilog and Z80 are registered trademarks of Zilog, Inc.
Wszelkie prawa zastrzeżone. Nieautoryzowane rozpowszechnianie całości lub fragmentu niniejszej publikacji
w jakiejkolwiek postaci jest zabronione. Wykonywanie kopii metodą kserograficzną, fotograficzną, a także kopiowanie
książki na nośniku filmowym, magnetycznym lub innym powoduje naruszenie praw autorskich niniejszej publikacji.
Wszystkie znaki występujące w tekście są zastrzeżonymi znakami firmowymi bądź towarowymi ich właścicieli.
Autor oraz Wydawnictwo HELION dołożyli wszelkich starań, by zawarte w tej książce informacje były kompletne
i rzetelne. Nie biorą jednak żadnej odpowiedzialności ani za ich wykorzystanie, ani za związane z tym ewentualne
naruszenie praw patentowych lub autorskich. Autor oraz Wydawnictwo HELION nie ponoszą również żadnej
odpowiedzialności za ewentualne szkody wynikłe z wykorzystania informacji zawartych w książce.
Wydawnictwo HELION
ul. Kościuszki 1c, 44-100 GLIWICE
tel. 32 231 22 19, 32 230 98 63
e-mail: [email protected]
WWW: http://helion.pl (księgarnia internetowa, katalog książek)
Drogi Czytelniku!
Jeżeli chcesz ocenić tę książkę, zajrzyj pod adres
http://helion.pl/user/opinie/sysop4_ebook
Możesz tam wpisać swoje uwagi, spostrzeżenia, recenzję.
Przedmowa 23
O autorach 27
1 Wprowadzenie 29
5 Wejście-wyjście 349
6 Zakleszczenia 443
9 Bezpieczeństwo 593
Skorowidz 1119
Czwarte wydanie tej książki różni się od trzeciego pod wieloma względami. Aby materiał był
aktualny, wprowadzono sporo niewielkich zmian w całej treści książki. Dziedzina systemów
operacyjnych nie stoi bowiem w miejscu. Rozdział o multimedialnych systemach operacyjnych
przeniesiono do internetu, przede wszystkim po to, aby zrobić miejsce dla nowego materiału
i zapobiec nadmiernemu rozrostowi rozmiarów książki. Rozdział poświęcony systemowi Windows
Vista całkowicie usunięto. Vista nie odniosła bowiem takiego sukcesu, jaki firma Microsoft
miała nadzieję osiągnąć z tym systemem. Rozdział o systemie Symbian został również usu-
nięty, ponieważ Symbian nie jest już szeroko dostępny. Jednak materiał poświęcony systemowi
Vista zastąpiono materiałem o Windows 8, natomiast materiał o Symbianie zastąpiono rozdzia-
łem na temat systemu Android. Ponadto dodano całkowicie nowy rozdział dotyczący wirtuali-
zacji i chmury. Oto przegląd wprowadzonych zmian rozdział po rozdziale.
Rozdział 1. znacznie zmodyfikowano i zaktualizowano w wielu miejscach, ale z wyjątkiem
nowego podrozdziału na temat komputerów przenośnych nie wprowadzono zbyt wielu
nowych fragmentów ani nie usunięto zbyt wielu starych.
Rozdział 2. zaktualizowano, usunięto starszy materiał i dodano trochę nowego. Dołożono
np. opis futeksów — nowego prymitywu synchronizacji — oraz podrozdział o tym, jak
uniknąć całkowitego zablokowania w przypadku operacji odczyt-kopiowanie-aktualizacja.
W rozdziale 3. położono większy nacisk na nowoczesny sprzęt, a mniejszy na segmen-
tację i system MULTICS.
W rozdziale 4. usunięto opis poświęcony napędom CD-ROM, ponieważ nie są już one zbyt
powszechne. Materiał ten zastąpiono opisem bardziej nowoczesnych rozwiązań (takich
jak dyski flash). Do podrozdziału poświęconego RAID dodaliśmy opis RAID 6.
W rozdziale 5. wprowadzono wiele zmian. Usunięto materiał na temat starszych urządzeń,
takich jak monitory CRT i płyty CD-ROM. Dodano natomiast opis nowych technologii —
np. ekranów dotykowych.
23
Rozdział 6. jest prawie niezmieniony. Tematyka zakleszczeń jest dość stabilna. Wpro-
wadzono jedynie opis kilku nowych wyników badań.
Rozdział 7. jest całkiem nowy. Opisano w nim ważne zagadnienia dotyczące wirtualizacji
i chmury. Dodano podrozdział poświęcony VMware w formie studium przypadku.
Rozdział 8. jest poprawioną wersją starego materiału o systemach wieloprocesorowych.
Większy nacisk położono na systemy wielordzeniowe typu multicore i manycore ze względu
na to, że w ciągu ostatnich kilku lat ich znaczenie bardzo wzrosło. Ostatnio większą
wagę przykłada się do spójności pamięci podręcznej, dlatego trochę miejsca poświęcono
tej tematyce.
Rozdział 9. gruntownie zmodyfikowano i zreorganizowano. Zamieszczono znaczną ilość
nowego materiału na temat eksploitów błędów kodowania, złośliwego oprogramowania
oraz mechanizmów ochrony przed nimi. Bardziej szczegółowo opisano ataki bazujące na
odwołaniach do pustego wskaźnika (ang. null pointer dereferences) oraz przepełnieniach
bufora. Szczegółowo omówiono mechanizmy obronne, w tym tzw. kanarki (ang. canaries),
bity NX oraz randomizację przestrzeni adresów, ponieważ napastnicy często starają się
je pokonać.
W rozdziale 10. wprowadzono znaczące zmiany. Zaktualizowano materiał poświęcony
systemom UNIX i Linux, ale przede wszystkim wprowadzono obszerny materiał dotyczący
systemu operacyjnego Android, który jest bardzo popularny na smartfonach i tabletach.
W trzecim wydaniu rozdział 11. był poświęcony systemowi Windows Vista. Zastąpiono go
rozdziałem o systemie Windows 8, a dokładniej Windows 8.1. Zmieniony rozdział pre-
zentuje całkiem nowe podejście do systemu Windows.
Rozdział 12. jest poprawioną wersją rozdziału 13. z poprzedniego wydania.
W rozdziale 13. zamieszczono gruntownie zaktualizowaną listę proponowanych lektur.
Dodatkowo zaktualizowano listę referencji. Wprowadzono wpisy na temat 223 nowych
prac, które pojawiły się już po opublikowaniu trzeciego wydania.
Rozdział 7. z poprzedniego wydania przeniesiono na stronę internetową dotyczącą książki,
aby niepotrzebnie nie powiększać rozmiarów.
Ponadto gruntownie przebudowano punkty dotyczące badań, tak by uwzględniały naj-
nowsze prace naukowe poświęcone systemom operacyjnym. Oprócz tego do wszystkich
rozdziałów dodano nowe pytania.
Uzupełnieniem tej książki są liczne pomoce dydaktyczne. Dodatki dla instruktorów można zna-
leźć pod adresem http://www.prenhall.com/tanenbaum. Znalazły się tu m.in. prezentacje PowerPoint,
narzędzia programistyczne do badania systemów operacyjnych, ćwiczenia laboratoryjne dla studen-
tów oraz dodatkowe materiały do wykorzystania podczas prowadzenia zajęć z systemów ope-
racyjnych. Wykładowcy korzystający z niniejszej książki powinni koniecznie zapoznać się z tymi
materiałami.
W przygotowanie czwartego wydania było zaangażowanych wiele osób. Przede wszystkim do li-
sty współautorów dodano prof. Herberta Bosa z Uniwersytetu Vrije w Amsterdamie. Jest on eks-
pertem w dziedzinie zabezpieczeń, systemu UNIX i innych systemów operacyjnych. To wspaniale
mieć go na pokładzie. Napisał większość nowego materiału, poza tym, co wymieniono poniżej.
Nasza redaktor Tracy Johnson jak zwykle wykonała wspaniałą pracę — od zebrania mate-
riałów od wszystkich autorów, poprzez łączenie tekstów, „gaszenie pożarów”, po dbanie, by
projekt był realizowany zgodnie z harmonogramem. Mieliśmy również szczęście znów powitać
w zespole pracującą z nami wcześniej przez wiele lat redaktor produkcyjną Camille Trentaco-
ste. Jej umiejętności w wielu dziedzinach niejednokrotnie pozwoliły nam uniknąć problemów.
Bardzo cieszymy się z jej powrotu po kilku latach nieobecności. Wspaniałą pracę polegającą na
koordynowaniu działań różnych osób zaangażowanych w przygotowanie książki wykonała Carole
Snyder.
Materiał zamieszczony w rozdziale 7. poświęconym VMware (w podrozdziale 7.12) został
napisany przez Edouarda Bugniona z EPFL w Lozannie w Szwajcarii. Edouard był jednym
z założycieli firmy VMware i zna ten materiał lepiej niż ktokolwiek inny na świecie. Bardzo mu
dziękujemy za udostępnienie wyników swojej pracy.
Ada Gavrilovska z Georgia Tech — ekspert w dziedzinie wewnętrznych mechanizmów
działania systemu Linux uaktualniła rozdział 10. Zawarty w tym rozdziale materiał poświęcony
Androidowi napisała Dianne Hackborn z firmy Google — jedna z głównych twórczyń tego
systemu. Android jest wiodącym systemem operacyjnym na smartfony, jesteśmy więc bardzo
wdzięczni Dianne za to, że zdecydowała się nam pomóc. Rozdział 10. jest teraz dość rozbudo-
wany i szczegółowy, ale fani Uniksa, Linuksa i Androida mogą się z niego wiele nauczyć. Warto
zwrócić uwagę, że najbardziej obszerny i najbardziej techniczny rozdział w książce został napi-
sany przez dwie kobiety. Męskiej części autorów pozostało to, co łatwe.
Nie zaniedbaliśmy również systemu Windows. Dave Probert z firmy Microsoft zaktualizował
rozdział 11. Tym razem w rozdziale szczegółowo opisano Windows 8.1. Dave posiada olbrzy-
mią wiedzę o systemie Windows oraz wystarczający dystans do tego, by móc ocenić różnice
pomiędzy tymi miejscami, w których firma Microsoft zaprojektowała system właściwie, a tymi,
w których popełniła błędy. Fanom systemu Windows z pewnością spodoba się ten rozdział.
Dzięki pracy wszystkich wymienionych autorów-ekspertów ta książka stała się o wiele
lepsza. Chcemy jeszcze raz podziękować im za nieocenioną pomoc.
Mieliśmy szczęście współpracować z kilkoma korektorami, którzy przeczytali rękopis, a dodat-
kowo zaproponowali nowe pytania wyszczególniane na końcu rozdziałów. Do tych osób należą
Trudy Levine, Shivakant Mishra, Krishna Sivalingam i Ken Wong. Steve Armstrong wykonał
arkusze PowerPointa dla wykładowców prowadzących zajęcia z wykorzystaniem książki.
Zazwyczaj nie zamieszcza się podziękowań dla korektorów językowych i weryfikatorów, ale
Bob Lentz (korektor językowy) i Joe Ruddick (weryfikator) wykonali wyjątkową pracę. W szcze-
gólności Joe, z 20 metrów potrafi dostrzec różnicę pomiędzy kropką czcionki Roman a kursywą.
Niemniej jednak autorzy ponoszą pełną odpowiedzialność za wszystkie błędy, które pozostały
w książce. Czytelników, którzy zauważą jakiekolwiek błędy, prosimy o kontakt z jednym
z autorów.
Na koniec, co nie umniejsza ich zasług, pragnę podziękować Barbarze i Marvinowi. Jesteście
wspaniali, jak zwykle. Każde z Was w unikatowy i specjalny sposób. Daniel i Matylde świetnie
uzupełniają naszą rodzinę, Aron i Nathan to wspaniali ludzie, a Olivia to skarb. Oczywiście
chcę także podziękować Suzanne za jej miłość i cierpliwość, nie wspominając już o druiven
i kersen, które ostatnimi czasy zastąpiły sinaasappelsap, a także za inne produkty rolne. (A.S.T.)
Przede wszystkim chcę podziękować Marieke, Duko i Jipowi. Marieke, dziękuję Ci za miłość
i za to, że znosiłaś wszystkie te noce, gdy pracowałem nad książką. Duko i Jipowi dziękuję za odry-
wanie mnie od pracy i pokazywanie, że w życiu są ważniejsze rzeczy, np. Minecraft. (H.B.)
Andrew S. Tanenbaum
Herbert Bos
Andrew S. Tanenbaum posiada tytuł licencjata MIT oraz doktora Uniwersytetu Kalifornij-
skiego w Berkeley. Obecnie jest profesorem informatyki w Uniwersytecie Vrije w Amsterda-
mie, gdzie pełni funkcję kierownika grupy roboczej Computer Systems Group. Wcześniej był
dziekanem Advanced School for Computing and Imaging międzyuczelnianego instytutu zaj-
mującego się badaniami nad zaawansowanymi systemami obliczeń równoległych i rozproszonych
oraz systemami przetwarzania obrazów. Ponadto jest profesorem Królewskiej Holenderskiej
Akademii Sztuk i Nauk, co uratowało go przed zostaniem biurokratą. Jest również beneficjen-
tem prestiżowego European Research Council Advanced Grant.
W przeszłości prowadził badania z zakresu kompilatorów, systemów operacyjnych, sieci
komputerowych i rozległych systemów rozproszonych. Głównym obszarem jego zainteresowań
są niezawodne i bezpieczne systemy operacyjne. Rezultat prowadzonych przez niego projek-
tów badawczych to ponad 140 artykułów opublikowanych w czasopismach i referatów wygło-
szonych na konferencjach. Tanenbaum jest również autorem lub współautorem pięciu książek.
Przetłumaczono je na 20 języków, od baskijskiego po tajski. Z tych publikacji korzystają wyższe
uczelnie na całym świecie. Ogółem istnieją 163 wersje jego książek (kombinacje język+wydanie).
Prof. Tanenbaum posiada spory dorobek programistyczny. Do jego głównych dokonań
można zaliczyć stworzenie systemu MINIX — niewielkiego klona Uniksa. System ten stał się
bezpośrednią inspiracją Linuksa oraz platformą, w której system Linux był początkowo two-
rzony. Aktualna wersja systemu MINIX, znana jako MINIX 3, została zaprojektowana z naciskiem
na niezawodność i bezpieczeństwo. Tanenbaum uzna swoją pracę za wykonaną, jeśli z kom-
puterów zniknie przycisk reset. MINIX 3 jest projektem open source, w którym może wziąć
udział każdy. Wystarczy wejść na stronę www.minix3.org, aby pobrać darmową kopię systemu
i dowiedzieć się, co się dzieje w projekcie. Dostępne są wersje tego systemu operacyjnego zarówno
na platformę x86, jak i ARM.
Osoby, które napisały doktorat pod kierownictwem Tanenbauma, zdobyły wielką popularność.
Profesor jest z nich bardzo dumny. Pod tym względem jest opiekuńczy… jak troskliwa kwoka.
27
Tanenbaum jest członkiem komitetu ACM, stowarzyszenia IEEE oraz Królewskiej Holen-
derskiej Akademii Sztuk i Nauk. Wielokrotnie otrzymywał rozmaite nagrody i wyróżnienia
naukowe od takich organizacji jak ACM, IEEE i USENIX. Czytelnicy, którzy chcieliby się zapo-
znać z listą tych nagród, mogą odwiedzić poświęconą profesorowi stronę w Wikipedii. Tanenbaum
ma też dwa doktoraty honoris causa.
Herbert Bos uzyskał tytuł magistra na Uniwersytecie Twente, natomiast tytuł doktora w Com-
puter Laboratory Uniwersytetu Cambridge w Wielkiej Brytanii. Od tamtej pory intensywnie
pracował nad rozwojem niezawodnych i wydajnych architektur wejścia-wyjścia dla takich sys-
temów operacyjnych jak Linux. Prowadził też prace badawcze związane z systemem MINIX 3.
Obecnie jest profesorem w zakresie bezpieczeństwa systemów i sieci w Zakładzie Informatyki
Uniwersytetu Vrije w Amsterdamie. Głównym obszarem jego badań są zabezpieczenia syste-
mów. Wraz ze swoimi studentami pracuje nad nowatorskimi metodami wykrywania i blokowania
ataków, analizą i wsteczną inżynierią szkodliwego oprogramowania, a także unieszkodliwia-
niem botnetów (złośliwej infrastruktury, która może obejmować wiele milionów komputerów).
W 2011 roku uzyskał ERC Starting Grant na prowadzenie badań w zakresie inżynierii wstecz-
nej. Troje jego studentów zdobyło Nagrodę Rogera Needhama za najlepszą w Europie pracę
doktorską poświęconą systemom operacyjnym.
Nowoczesny komputer składa się z jednego lub kilku procesorów, pamięci głównej, dysków,
drukarek, klawiatury, myszy, monitora, kart sieciowych oraz różnych innych urządzeń wej-
ściowo-wyjściowych. Jednym słowem, jest to bardzo złożony system. Gdyby każdy programista
aplikacji musiał rozumieć w szczegółach, jak działają te wszystkie elementy, nigdy nie powstałby
żaden kod. Co więcej, zarządzanie wszystkimi komponentami i optymalne posługiwanie się
nimi to niezwykle wymagające zadanie. Z tych powodów komputery są wyposażone w warstwę
oprogramowania nazywaną systemem operacyjnym. Jej zadaniem jest dostarczanie programom
użytkowym lepszego, prostego i bardziej przejrzystego modelu komputera oraz obsługa zarzą-
dzania wszystkich wymienionych przed chwilą zasobów. Systemy te stanowią temat niniejszej
książki.
Większość Czytelników z pewnością ma jakieś doświadczenia z takimi systemami opera-
cyjnymi jak Windows, Linux, FreeBSD lub Mac OS X, choć pozory mogą być mylące. Programy,
z którymi komunikuje się użytkownik, zwykle wywołu powłokę, jeśli ich interfejs jest tekstowy,
lub graficzny interfejs użytkownika — GUI (ang. Graphical User Interface), jeśli używają ikon.
Nie są one jednak częścią systemu operacyjnego, choć do realizacji swoich zadań wykorzystują
system operacyjny.
Prostą ilustrację głównych komponentów, które tutaj omawiamy, pokazano na rysunku 1.1.
Widać na nim, że sprzęt znajduje się w najniższej warstwie. Składa się on z układów scalonych,
płyt, dysków, klawiatury, monitora oraz im podobnych obiektów fizycznych. Powyżej warstwy
sprzętu działa oprogramowanie. Większość komputerów ma dwa tryby działania: tryb jądra oraz
tryb użytkownika. System operacyjny jest najbardziej podstawowym oprogramowaniem i działa
w trybie jądra (nazywanym także trybem nadzorcy — ang. supervisor mode). W tym trybie ma
pełny dostęp do całego sprzętu i może uruchomić każdą instrukcję, jaką komputer jest zdolny
wykonać. Pozostała część oprogramowania działa w trybie użytkownika, w którym dostępny
jest jedynie podzbiór rozkazów maszynowych. W szczególności instrukcje mające wpływ na
zarządzanie maszyną lub wykonywanie operacji wejścia-wyjścia są zabronione dla programów
29
Bardzo trudno powiedzieć dokładnie, czym jest system operacyjny, poza stwierdzeniem, że to
oprogramowanie działające w trybie jądra. Nawet takie stwierdzenie nie zawsze okazuje się
prawdziwe. Częściowo problem polega na tym, że systemy operacyjne spełniają dwie, ogólnie
rzecz biorąc, niezwiązane ze sobą funkcje: dostarczają programistom aplikacji (i oczywiście pro-
gramom aplikacyjnym) czytelnego, abstrakcyjnego zbioru zasobów będących odpowiednikami
sprzętu oraz zarządzają tymi zasobami sprzętowymi. W zależności od tego, kto opowiada o sys-
temach operacyjnych, możemy usłyszeć więcej informacji o jednej lub drugiej funkcji. Spróbujmy
przyjrzeć się im obu.
interfejs graficzny. Chociaż abstrakcje interfejsu użytkownika mogą być podobne do tych, które
są dostarczane przez system operacyjny, nie zawsze tak jest. Aby ten punkt stał się czytelniejszy,
rozważmy standardowy pulpit Windowsa oraz wiersz poleceń systemu operacyjnego. Oba są
programami działającymi w systemie operacyjnym Windows i wykorzystują abstrakcje dostar-
czane przez Windows, ale oferują bardzo różne interfejsy użytkownika. Na podobnej zasadzie
użytkownik Linuksa korzystający ze środowiska GNOME lub KDE widzi zupełnie odmienny
interfejs od użytkownika Linuksa pracującego bezpośrednio z systemem X Window (tekstowym),
choć w obu przypadkach wykorzystywane są te same abstrakcje systemu operacyjnego.
W niniejszej książce przestudiujemy dokładnie abstrakcje dostarczane programom aplika-
cyjnym, natomiast powiemy bardzo niewiele o interfejsach użytkownika. Jest to obszerne i ważne
zagadnienie, marginalnie jednak związane z systemami operacyjnymi.
tylko sprzęt, ale także informacje (pliki, bazy danych itp.). Krótko mówiąc, zgodnie z tym wido-
kiem systemu operacyjnego zakłada się, że jego zasadniczym zadaniem jest śledzenie informa-
cji na temat tego, które programy korzystają z jakich zasobów. Ma to na celu realizowanie żądań
zasobów, uwzględnianie zajętości zasobów oraz rozstrzyganie kolidujących ze sobą żądań od róż-
nych programów i użytkowników.
Zarządzanie zasobami obejmuje zwielokrotnianie (współdzielenie) zasobów na dwa różne
sposoby: za pomocą czasu i przestrzeni. W przypadku zasobu, który jest zwielokrotniany w cza-
sie, różne programy lub użytkownicy korzystają z niego po kolei. Najpierw jeden z nich używa
zasobu, potem następny itd. Kiedy np. w komputerze jest jeden procesor i chce z niego skorzy-
stać wiele programów, system operacyjny najpierw przydziela procesor do jednego programu,
następnie, kiedy program ten działa już przez odpowiednio długi czas, procesor jest przydzie-
lany innemu programowi, później następnemu, i w końcu pierwszy program ponownie uzyskuje
dostęp do procesora. Określenie sposobu czasowego przydziału zasobu — kto otrzyma go jako
następny i na jak długo — jest zadaniem systemu operacyjnego. Innym przykładem czasowego
zwielokrotniania zasobów jest współdzielenie drukarki. Kiedy w kolejce pojedynczej drukarki
zostanie zapisanych wiele zadań drukowania, trzeba podjąć decyzję, które zadanie ma być wydru-
kowane jako następne.
Drugi rodzaj zwielokrotniania polega na wykorzystaniu przestrzeni. Zamiast korzystać
z zasobu po kolei, każdy klient otrzymuje jego część. I tak główna pamięć jest standardowo
podzielona pomiędzy kilka działających programów — to znaczy, że w tym samym czasie może
być w pamięci kilka programów (np. po to, by mogły po kolei korzystać z procesora). Jeśli zało-
żyć, że w komputerze jest wystarczająca ilość pamięci do tego, by zapisać w niej wiele progra-
mów, bardziej efektywne okazuje się jednoczesne utrzymywanie w niej kilku programów niż
przydzielanie całej pamięci jednemu programowi — zwłaszcza gdy program potrzebuje tylko
jej niewielkiego fragmentu. Oczywiście to stwarza problemy sprawiedliwości podziału, ochrony
itp., a ich rozwiązanie należy do systemu operacyjnego. Innym zasobem, który jest zwielokrot-
niany za pomocą przestrzeni, są dyski. W licznych systemach na jednym dysku mogą być w danym
momencie zapisane pliki wielu użytkowników. Przydział przestrzeni dyskowej i śledzenie tego,
kto używa których bloków dysków, to typowe zadanie systemu operacyjnego.
Systemy operacyjne ewoluowały przez wiele lat. W poniższych punktach zwięźle opiszemy kilka
najważniejszych zagadnień z historii ich rozwoju. Ponieważ systemy operacyjne były od zawsze
ściśle powiązane z architekturą komputerów, na których one działają, przyjrzymy się kolejnym
generacjom komputerów, aby zobaczyć, jakie były ich systemy operacyjne. To odwzorowanie
generacji systemów operacyjnych na generacje komputerów ma zgubny charakter, niemniej jed-
nak pozwala nakreślić pewną strukturę.
Progresja opisana poniżej ma w większości charakter chronologiczny, choć na drodze roz-
woju zdarzały się „wyboje”. Rozpoczęcie nowego etapu nie następowało dopiero wtedy, kiedy
skończył się etap poprzedni. Poszczególne etapy wielokrotnie nakładały się na siebie, zdarzało
się wiele falstartów i martwych zakończeń. Poniższy podział należy traktować informacyjnie,
a nie jako ostateczną klasyfikację.
Pierwszy w pełni cyfrowy komputer został zaprojektowany przez angielskiego matematyka
Charlesa Babbage’a (1792 – 1871). Choć Babbage poświęcił większość swojego życia i majątku
na próby stworzenia „silnika analitycznego”, nigdy nie udało mu się doprowadzić do jego prawi-
dłowego działania, ponieważ był on w pełni mechaniczny, a technika w tamtym okresie nie pozwa-
lała na wyprodukowanie potrzebnych kół, przekładni i zębatek zapewniających właściwą dokład-
ność. Nie należy się zatem dziwić, że maszyna analityczna nie miała systemu operacyjnego.
Ciekawostką jest fakt, że Babbage zdawał sobie sprawę z potrzeby oprogramowania dla
swojej maszyny analitycznej. W związku z tym zatrudnił młodą kobietę Adę Lovelace, córkę
znanego brytyjskiego poety Lorda Byrona, jako pierwszą na świecie programistkę. Nazwa języka
programowania Ada® pochodzi od jej imienia.
Maszyny te, określane teraz terminem mainframe, były zamknięte w specjalnych klimaty-
zowanych pomieszczeniach, a nad ich działaniem czuwał zespół profesjonalnych operatorów. Na
zapłacenie ceny wielu milionów dolarów mogły sobie pozwolić tylko duże korporacje, najważ-
niejsze agencje rządowe lub uniwersytety. Aby uruchomić zadanie (tzn. program lub zbiór pro-
gramów), programista najpierw pisał program na papierze (w języku Fortran lub asemblerze),
a następnie dziurkował go na kartach. Potem przynosił zbiór kart do pokoju wprowadzania danych,
wręczał jednemu z operatorów i szedł na kawę, by umilić sobie oczekiwanie na gotowe wyniki.
Kiedy komputer skończył wykonywanie zadania, operator szedł do drukarki, oddzierał wynik
działania programu i zanosił do pokoju wyników, skąd programista mógł go sobie później ode-
brać. Następnie brał jeden z zestawów kart, które zostały przyniesione do pokoju wprowadzania
danych, i wczytywał go. Jeśli był potrzebny kompilator Fortrana, operator brał go z szafy i wczy-
tywał do komputera. Duża część czasu komputera była marnotrawiona na chodzenie operato-
rów po pokoju komputerowym.
Jeśli wziąć pod uwagę wysoki koszt sprzętu, nie dziwi fakt, że wkrótce zaczęto poszukiwać
sposobów ograniczenia marnotrawionego czasu. Powszechnie przyjętym rozwiązaniem były
systemy wsadowe. Idea ich działania polegała na pobraniu pełnego zasobnika zadań w pokoju
wprowadzania danych i zapisaniu ich na taśmie magnetycznej za pomocą mniejszego (relatyw-
nie) i tańszego komputera. Przykładem takiej maszyny był IBM 1401, który dość dobrze reali-
zował zadania czytania kart, kopiowania taśm i drukowania wyników, ale zupełnie nie nadawał
się do wykonywania obliczeń numerycznych. Do wykonywania faktycznych obliczeń wykorzy-
stywano znacznie droższe maszyny, np. IBM 7094. Typową sytuację pokazano na rysunku 1.3.
Rysunek 1.3. Wczesny system wsadowy: (a) programiści przynoszą karty do komputera 1401;
(b) 1401 wczytuje plik zadań na taśmę; (c) operator przenosi taśmę wejściową do 7094; (d) 7094
wykonuje obliczenia; (e) operator przenosi taśmę wyjściową do 1401; (f) 1401 drukuje wyniki
Po mniej więcej godzinie zbierania zadań wsadowych karty były czytane na taśmę magne-
tyczną, którą trzeba było przenieść do pokoju komputerowego. Tam montowano ją w napędzie
taśm. Następnie operator ładował specjalny program (protoplastę dzisiejszych systemów ope-
racyjnych), który odczytywał pierwsze zadanie z taśmy i je uruchamiał. Zamiast drukowania wynik
był zapisywany na drugiej taśmie. Po zakończeniu każdego z zadań system operacyjny automa-
tycznie wczytywał następne zadanie z taśmy i zaczynał je uruchamiać. Po zakończeniu przetwa-
rzania całego wsadu operator wyjmował taśmy wejściową i wyjściową, wymieniał taśmę wej-
ściową na następny wsad i przynosił taśmę wyjściową do komputera 1401 w celu wydrukowania
wyniku w trybie offline (tzn. bez połączenia z komputerem głównym).
Strukturę typowego zadania wprowadzania danych pokazano na rysunku 1.4. Zaczyna się
ono od karty $JOB, która określa maksymalny czas działania w minutach, numer konta do obcią-
żenia oraz nazwisko programisty. Następnie jest karta $FORTRAN, która zleca systemowi opera-
w których bieżące zadanie było wstrzymywane do czasu zakończenia operacji z taśmą lub innej
operacji wejścia-wyjścia, procesor główny pozostawał bezczynny do momentu zakończenia tej
operacji. W przypadku obliczeń naukowych intensywnie wykorzystujących procesor operacje
wejścia-wyjścia nie są częste, zatem nie powodowało to znaczącego marnotrawstwa czasu. W przy-
padku komercyjnego przetwarzania danych czas oczekiwania związany z operacjami wejścia-wyj-
ścia często wynosił 80 – 90% całkowitego czasu, zatem trzeba było coś zrobić, aby uniknąć tak
wielkiego stopnia bezczynności drogiego procesora głównego.
W związku z tym pojawiło się rozwiązanie polegające na podzieleniu pamięci na kilka części
i umieszczeniu w każdej z nich osobnego zadania w sposób pokazany na rysunku 1.5. Podczas
gdy jedno zadanie oczekiwało na zakończenie operacji wejścia-wyjścia, drugie mogło korzystać
z procesora. Jeśli pamięć główna zdołałaby pomieścić wystarczającą liczbę zadań, procesor główny
mógłby być zajęty prawie 100% czasu. Bezpieczne przechowywanie w pamięci wielu zadań naraz
wymagało specjalnego sprzętu, który chroniłby każde z zadań przed „szpiegowaniem” oraz mody-
fikowaniem jednego zadania przez drugie, ale komputery serii 360 oraz inne systemy trzeciej
generacji były wyposażone w taki sprzęt.
Inną ważną właściwością systemów operacyjnych trzeciej generacji była zdolność czytania
zadań z kart na dyski natychmiast po ich przyniesieniu do pokoju komputerowego. Dzięki temu,
za każdym razem, kiedy komputer zakończył wykonywanie zadania, system operacyjny mógł
załadować nowe zadanie z dysku do pustej już partycji pamięci i je uruchomić. Technika ta nosi
nazwę spooling (ang. Simultaneous Peripheral Operation On Line — jednoczesne działanie
podłączonych urządzeń). Wykorzystywano ją również do buforowania wyników. Dzięki zastoso-
waniu spoolingu komputery 1401 przestały być potrzebne. W większości zniknęła też potrzeba
przenoszenia taśm.
Chociaż systemy operacyjne trzeciej generacji były dobrze przystosowane do wykonywa-
nia obliczeń naukowych i masowego przetwarzania danych, w gruncie rzeczy były to systemy
wsadowe. Wielu programistów tęskniło za czasami komputerów pierwszej generacji, kiedy mieli
maszynę dla siebie na kilka godzin, dzięki czemu mogli szybko debugować swoje programy.
W przypadku systemów trzeciej generacji czas pomiędzy złożeniem zadania a otrzymaniem
wyników często wynosił kilka godzin, zatem jeden przecinek postawiony w nieodpowiednim
miejscu mógł spowodować niepowodzenie kompilacji, a programista marnował pół dnia. Progra-
mistom nie bardzo się to podobało.
Potrzeba szybkiej odpowiedzi otworzyła ścieżkę dla techniki podziału czasu (ang. timesha-
ring) — odmiany systemów wieloprogramowych, w których każdy użytkownik posiadał podłą-
czony do komputera terminal. Jeśli w systemach z podziałem czasu było zalogowanych 20
użytkowników, z których 17 myślało, rozmawiało lub piło kawę, można było po kolei przydzielić
procesor trzem zadaniom wymagającym obsługi. Ponieważ podczas debugowania programów
zwykle wydaje się krótkie polecenia (np. skompiluj pięciostronicową procedurę ), a nie długie
(np. posortuj plik zawierający milion rekordów), komputer może zapewnić szybką, interaktywną
obsługę wielu użytkownikom, a także pracować nad złożonymi zadaniami wsadowymi w tle w cza-
sie, w którym w systemach bez podziału czasu procesor był bezczynny. Pierwszy komputer
ogólnego przeznaczenia z podziałem czasu — CTSS (Compatible Time-Sharing System) zbudo-
wano w MIT na bazie specjalnie zmodyfikowanego komputera IBM 7094 [Corbató et al., 1962].
Systemy te nie zyskały jednak zbytniej popularności do czasu rozpowszechnienia potrzebnego
sprzętu zabezpieczającego wprowadzonego w systemach trzeciej generacji.
Po sukcesie systemu CTSS firmy: MIT, Bell Labs i General Electric (wówczas jeden z głów-
nych producentów komputerów) zdecydowały o rozpoczęciu prac nad „narzędziem kompute-
rowym” (ang. computer utility), maszyną, która byłaby zdolna obsłużyć kilkuset użytkowników
jednocześnie. Wzorowano się na systemie sieci elektrycznej — kiedy potrzebujemy energii
elektrycznej, wkładamy wtyczkę do gniazda ściennego i jeśli to możliwe, otrzymujemy tyle energii,
ile jest nam potrzebne. Projektanci tego systemu, znanego pod nazwą MULTICS (Multiplexed
Information and Computing Service), zamierzali stworzyć jedną rozbudowaną maszynę, która
byłaby zdolna do dostarczania mocy obliczeniowej wszystkim osobom w rejonie Bostonu. Myśl
o tym, że zaledwie za 40 lat miliony osób będą kupowały maszyny 10 tysięcy razy szybsze od ich
komputera mainframe GE-645 (za sporo poniżej 1000 dolarów), była wtedy czystą fantastyką
naukową. Była równie nieprawdopodobna, jak dziś myśl o naddźwiękowej kolei pod dnem
Atlantyku.
MULTICS odniósł częściowy sukces. Zaprojektowano go do obsługi setek użytkowników
na maszynie o tylko nieznacznie większej mocy obliczeniowej od PC-386, choć komputer ten miał
znacznie większe możliwości obsługi operacji wejścia-wyjścia. Nie jest to tak szalone, jak się
z pozoru wydaje, ponieważ wówczas wiedziano, w jaki sposób pisać niewielkie, wydajne pro-
gramy — umiejętność ta całkowicie zanikła w późniejszym czasie. Było wiele powodów, dla
których MULTICS nie podbił świata. Nie bez znaczenia okazał się fakt, że napisano go w języku
PL/I, tymczasem powstanie kompilatora tego języka opóźniło się o kilka lat, a kiedy już powstał,
zawierał wiele niedociągnięć. Co więcej, projekt MULTICS był nazbyt ambitny jak na owe czasy —
podobnie jak maszyna analityczna Charlesa Babbage’a w XIX wieku.
Krótko rzecz ujmując, prace nad systemem MULTICS wprowadziły wiele istotnych pojęć
do literatury komputerowej, ale przekształcenie go w poważny produkt i osiągnięcie istotnego
sukcesu komercyjnego okazało się znacznie trudniejsze, niż ktokolwiek przypuszczał. Firma
Bell Labs wycofała się z projektu, a General Electric całkowicie porzuciła branżę komputerową.
Prace w MIT jednak trwały i ostatecznie system MULTICS stał się faktem. Ostatecznie jako
produkt komercyjny był sprzedawany przez firmę Honeywell, która przejęła dział komputerów
od firmy GE. System MULTICS zainstalowano w przeszło 80 dużych firmach i uniwersytetach
na całym świecie. Chociaż jego użytkowników nie było zbyt wielu, okazali się oni niezwykle
lojalni wobec systemu; np. General Motors, Ford oraz U.S. National Security Agency zrezygno-
wały z niego dopiero w drugiej połowie lat dziewięćdziesiątych, 30 lat po jego powstaniu oraz
po wielu latach nacisków na firmę Honeywell, by podjęto próby zmodernizowania sprzętu.
Pod koniec XX wieku pojęcie narzędzia komputerowego wcale nie zanikło, ale odrodziło
się w formie przetwarzania w chmurze (ang. cloud computing) — technologii, w której stosun-
kowo niewielkie komputery (w tym smartfony, tablety itp.) są podłączone do serwerów w rozle-
głych i odległych centrach danych, gdzie odbywa się całe przetwarzanie, a komputer lokalny
obsługuje tylko interfejs użytkownika. Motywacja powstania takiego systemu może wynikać
z tego, że większość ludzi nie chce administrować bardzo złożonymi systemami komputero-
wymi i woli, aby zajmował się tym zespół profesjonalistów pracujących w firmie będącej właści-
cielem serwera. Branża e-commerce już dziś rozwija się w tym kierunku. Wiele firm prowadzi
wspólną dla systemów MINIX i UNIX. Czytelnicy zainteresowani szczegółową historią Linuksa
oraz ruchu oprogramowania ze swobodnym dostępem do kodu źródłowego (ang. open source)
mogą sięgnąć do książki Glyna Moody’ego [Moody, 2001]. Większa część zagadnień dotyczą-
cych systemu UNIX, które będą opisane w tej książce, dotyczy wersji: System V, MINIX, Linux,
a także innych wersji i klonów systemu UNIX.
Gates zatrudnił autora systemu DOS, Tima Patersona, jako pracownika nowej firmy Gatesa —
Microsoft. Poprawiony system operacyjny przemianowano na MS-DOS (MicroSoft Disk Opera-
ting System). W niedługim czasie zdominował on rynek komputerów klasy IBM PC. Kluczowym
powodem tej sytuacji była decyzja Gatesa (z perspektywy lat możemy powiedzieć, że była ona
bardzo mądra), aby sprzedawać system MS-DOS firmom komputerowym, które miałyby dołą-
czać go do produkowanego przez siebie sprzętu. Podejście to znacząco różniło się od prób Kil-
dalla, który zamierzał sprzedawać system CP/M użytkownikom docelowym po jednej kopii
(przynajmniej początkowo). Wkrótce Kildall nagle i nieoczekiwanie zmarł z powodów, które
nigdy nie zostały do końca wyjaśnione.
W momencie pojawienia się w 1983 roku następcy komputera IBM PC — IBM PC/AT, wypo-
sażonego w mikroprocesor Intel 80286 CPU, system MS-DOS miał mocną pozycję na rynku,
natomiast sytuacja systemu CP/M była zdecydowanie chwiejna. Systemu MS-DOS powszechnie
używano później w komputerach 80386 i 80486. Choć pierwsze wersje systemu MS-DOS były
dość prymitywne, kolejne wersje zawierały bardziej zaawansowane funkcje, w tym wiele wzoro-
wanych na systemie UNIX (firma Microsoft była doskonale poinformowana o systemie UNIX;
w początkowych latach swojego istnienia sprzedawała nawet wersję Uniksa dla mikrokompute-
rów, znaną jako Xenix).
CP/M, MS-DOS i inne systemy operacyjne wczesnych mikrokomputerów bazowały na zało-
żeniu, że użytkownicy wprowadzają polecenia za pomocą klawiatury. Ostatecznie sytuacja ta
zmieniła się dzięki badaniom wykonanym w latach sześćdziesiątych przez Douga Engelbarta
ze Stanford Research Institute. Engelbart wynalazł graficzny interfejs użytkownika (Graphical
User Interface — GUI) złożony z okien, ikon, menu i myszy. Pomysły te zostały zaadaptowane
przez naukowców z Xerox PARC i wprowadzone w produkowanych przez nich maszynach.
Pewnego dnia Steve Jobs, który w swoim garażu współtworzył komputer Apple, odwiedził
firmę PARC, zobaczył interfejs GUI i natychmiast dostrzegł jego potencjalną wartość — coś,
czego nie widziało kierownictwo firmy Xerox. Ta strategiczna gafa o gigantycznych proporcjach
była inspiracją do powstania książki zatytułowanej Fumbling the Future [Smith i Alexander,
1988]. Jobs przystąpił wówczas do budowy komputera Apple bazującego na interfejsie GUI.
Efektem tych prac było powstanie komputera Lisa, który jednak okazał się zbyt drogi i nie odniósł
handlowego sukcesu. Komputer Apple Macintosh — druga próba Jobsa — okazał się gigantycz-
nym sukcesem, nie tylko dlatego, że był znacznie tańszy od komputera Lisa, ale również dlatego,
że był przyjazny użytkownikom. Oznacza to, że opracowano go z przeznaczeniem dla użytkow-
ników, którzy nie tylko nic nie wiedzieli o komputerach, ale — co ważniejsze — absolutnie nie
mieli zamiaru niczego się uczyć. W kreatywnym świecie projektantów grafiki, profesjonalnej
fotografii cyfrowej oraz profesjonalnych cyfrowych produkcji wideo komputery Macintosh są
powszechnie używane, a ich użytkownicy wyrażają się o nich entuzjastycznie. W 1999 roku
firma Apple zaadaptowała jądro pochodzące z opracowanego na Uniwersytecie Carnegie Mellon
mikrojądra, które pierwotnie miało zastąpić jądro systemu BSD UNIX. Zatem Mac OS X, pomimo
zupełnie odmiennego interfejsu, jest systemem operacyjnym bazującym na Uniksie.
Kiedy firma Microsoft zdecydowała, że będzie tworzyć następcę systemu MS-DOS, była
pod silnym wpływem sukcesu Macintosha. Wyprodukowała więc system z interfejsem GUI pod
nazwą Windows, który początkowo działał jako nakładka systemu MS-DOS (tzn. działał bar-
dziej jako nakładka niż jako rzeczywisty system operacyjny). Przez jakieś 10 lat — od 1985 do
1995 roku — Windows był jedynie graficznym środowiskiem działającym w systemie MS-DOS.
Jednak w 1995 roku wyprodukowano samodzielną wersję Windowsa, Windows 95, która miała
wiele wbudowanych własności systemu operacyjnego. System MS-DOS był w niej używany
tylko do rozruchu oraz do uruchamiania starych programów MS-DOS. W 1998 roku wydano
nieco zmodyfikowaną wersję tego systemu, pod nazwą Windows 98. Niemniej jednak zarówno
Windows 95, jak i Windows 98 w dalszym ciągu zawierały sporo 16-bitowego kodu asemblera
procesorów Intel.
Innym systemem operacyjnym firmy Microsoft był Windows NT (od New Technology), który
na pewnym poziomie jest zgodny z Windows 95, ale w rzeczywistości został napisany od podstaw.
Jest to system w pełni 32-bitowy. Wiodący projektant systemu Windows NT to David Cutler,
który jednocześnie był jednym z projektantów systemu operacyjnego VAX VMS. W związku
z tym niektóre pomysły z systemu VMS zostały zastosowane w systemie NT. Rozwiązań prze-
jętych z systemu VMS było tak wiele, że właściciel VMS, firma DEC, pozwał Microsoft. Proces
został jednak wycofany z sądu za kwotę pieniędzy, której wyrażenie wymaga bardzo wielu zer.
W firmie Microsoft spodziewano się, że pierwsza wersja systemu NT przebije MS-DOS oraz
wszystkie inne wersje systemu Windows, ponieważ był to znacznie bardziej zaawansowany
technologicznie system, ale przewidywania te okazały się nietrafione. Dopiero wersja Windows
NT 4.0 była wdrażana na szeroką skalę, zwłaszcza w sieciach dużych firm. Na początku 1999
roku wersję 5 systemu Windows NT przemianowano na Windows 2000. System ten miał być
następcą zarówno systemów Windows 98, jak i Windows NT 4.0.
To przewidywanie również się nie sprawdziło, dlatego firma Microsoft opracowała nową
wersję systemu Windows 98 i nazwała go Windows Me (Millennium). W 2001 roku wydano nieco
zmodyfikowaną wersję systemu Windows 2000, pod nazwą Windows XP. Wersja ta była główną
wersją systemu znacznie dłużej niż poprzednie (sześć lat) i w zasadzie zastąpiła wszystkie
poprzednie wersje Windowsa.
Tempo powstawania nowych wersji stale słabło. Po wydaniu Windowsa 2000 Microsoft podzie-
lił rodzinę systemów Windows na linię systemów klienckich i serwerowych. Linia systemów
klienckich bazowała na systemie XP i jego następcach, podczas gdy linia systemów serwero-
wych zawierała Windows Server 2003 i Windows 2008. Trzecia linia — dotycząca systemów
wbudowanych — pojawiła się nieco później. Dla wszystkich tych wersji systemu Windows
pojawiły się odmiany w postaci dodatków Service Pack. To wystarczyło, by doprowadzić nie-
których administratorów (i autorów podręczników na temat systemów operacyjnych) do utraty
równowagi psychicznej.
Następnie, w styczniu 2007 roku, firma Microsoft ostatecznie wydała następcę systemu
Windows XP, pod nazwą Vista. Wyposażono ją w nowy graficzny interfejs, ulepszone zabez-
pieczenia oraz wiele nowych lub uaktualnionych programów użytkowych. W firmie Microsoft
sądzono, że wersja ta całkowicie zastąpi system Windows XP, co jednak nigdy nie nastąpiło.
Zamiast tego wersja spotkała się z ostrą krytyką i miała złą prasę. Krytykowano głównie wysokie
wymagania systemu, restrykcyjne warunki udzielania licencji oraz wsparcie dla technologii zarzą-
dzania prawami cyfrowymi (ang. Digital Rights Management — DRM), która utrudniała użyt-
kownikom kopiowanie materiałów chronionych prawami autorskimi.
Po pojawieniu się systemu Windows 7 — nowej i wymagającej znacznie uboższych zasobów
wersji systemu operacyjnego — sporo osób zdecydowało się całkowicie zrezygnować z Visty.
W systemie Windows 7 nie wprowadzono zbyt wielu nowych funkcji. Była to jednak wersja
stosunkowo niewielka i dość stabilna. W niespełna trzy tygodnie system Windows 7 uzyskał
większy udział w rynku niż Vista w ciągu siedmiu miesięcy. W 2012 roku Microsoft opublikował
następcę systemu Windows 7 — Windows 8 — system operacyjny z zupełnie nowym wyglą-
dem oraz interfejsem przystosowanym do obsługi ekranów dotykowych. Firma miała nadzieję,
że dzięki nowemu projektowi stanie się on dominującym systemem operacyjnym dla znacznie
szerszej gamy urządzeń: komputerów desktop, laptopów, notebooków, tabletów, telefonów i mul-
timedialnych komputerów pełniących funkcję kina domowego. Jednak jak dotąd penetracja rynku
jest dużo wolniejsza w porównaniu z systemem Windows 7.
Innym ważnym konkurentem w świecie systemów operacyjnych komputerów osobistych
jest UNIX (oraz szereg jego odmian). UNIX ma silniejszą pozycję w sieciach i serwerach kor-
poracyjnych, ale używa się go też coraz częściej w komputerach desktop, notebookach, table-
tach i smartfonach. W komputerach z procesorami x86 popularną alternatywą dla systemu
Windows jest Linux. Wykorzystują go przede wszystkim studenci, ale coraz częściej użytkow-
nicy korporacyjni.
Na marginesie — w niniejszej książce będziemy używać terminu „x86” jako wspólnej nazwy
dla wszystkich nowoczesnych procesorów bazujących na architekturze ISA (ang. Instruction-Set
Architectures), którym początek dał procesor 8086 wyprodukowany w latach siedemdziesiątych
ubiegłego wieku. Jest wiele takich procesorów. Ich producentami są m.in. takie firmy jak AMD
i Intel. Procesory te często znacznie różnią się pomiędzy sobą pod względem zastosowanych
rozwiązań. Procesory mogą być 32- lub 64-bitowe; wyposażone w wiele rdzeni i potoków (ang.
pipeline); głębokie lub płytkie; itd. Z punktu widzenia programisty wszystkie one wyglądają
bardzo podobnie — nadal mogą wykonywać kod przeznaczony na platformę 8086, napisany 35 lat
temu. Niemniej jednak tam, gdzie istotne znaczenie mają różnice, będziemy jawnie odwoływać
się do konkretnych modeli — użyjemy pojęć x86-32 oraz x86-64 na określenie odmian 32-
i 64-bitowych.
Popularną odmianą systemu UNIX wywodzącą się z projektu BSD na Uniwersytecie Berkeley
jest FreeBSD. Zmodyfikowana wersja systemu FreeBSD (OS X) działa we wszystkich nowo-
czesnych odmianach komputerów Macintosh. UNIX jest również standardem na stacjach robo-
czych wykorzystujących wysokowydajne układy RISC. Jego pochodne są szeroko stosowane
w urządzeniach mobilnych, takich jak te, na których działają systemy operacyjne iOS 7 lub
Android.
Wielu użytkowników Uniksa, zwłaszcza doświadczonych programistów, preferuje interfejs
poleceń zamiast środowiska GUI. W związku z tym niemal wszystkie odmiany systemu UNIX
obsługują uproszczony system okienkowy X Window System (znany też jako X11) produkowany
w MIT. System ten zapewnia podstawowe zarządzanie oknami — pozwala użytkownikom na
tworzenie, usuwanie, przemieszczanie i zmianę rozmiarów okien, a także używanie myszy.
Dostępne są również kompletne interfejsy GUI, np. GNOME lub KDE, które działają na bazie
systemu X11. Dzięki takim środowiskom użytkownicy, którzy tego oczekują, mogą uzyskać
komfort pracy w Uniksie zbliżony do tego, jaki mają użytkownicy komputerów Macintosh lub
maszyn z systemem Microsoft Windows.
W połowie lat osiemdziesiątych rozpoczął się interesujący trend rozwijania sieci komputerów
osobistych wyposażonych w sieciowe systemy operacyjne albo rozproszone systemy operacyjne
[Tanenbaum i van Steen, 2007]. W sieciowych systemach operacyjnych użytkownicy są świa-
domi występowania wielu komputerów, mogą logować się do zdalnych maszyn i kopiować pliki
pomiędzy komputerami w sieci. Na każdej maszynie działa oddzielny, lokalny system operacyjny
i w każdej z nich jest lokalny użytkownik (lub użytkownicy).
Sieciowe systemy operacyjne nie różnią się zasadniczo od jednoprocesorowych systemów
operacyjnych. Oczywiście wymagają one kontrolera interfejsu sieciowego oraz niskopoziomowego
oprogramowania, które nim zarządza. Potrzebują także programów pozwalających na zdalne
logowanie się oraz zdalny dostęp do plików, ale te dodatki nie zmieniają zasadniczej struktury
systemu operacyjnego.
Z kolei rozproszony system operacyjny to taki, który z punktu widzenia jego użytkowni-
ków wygląda jak tradycyjny system jednoprocesorowy, mimo że w rzeczywistości składa się
z wielu procesorów. Użytkownicy nie powinni być świadomi tego, gdzie są uruchamiane ich
programy, ani tego, gdzie znajdują się ich pliki. Te problemy powinny być wydajnie i automatycz-
nie obsługiwane przez system operacyjny.
Prawdziwy rozproszony system operacyjny wymaga więcej niż tylko dodania kodu do jed-
noprocesorowego systemu operacyjnego, ponieważ rozproszone i scentralizowane systemy
różnią się kilkoma kluczowymi cechami. I tak systemy rozproszone często pozwalają działać
aplikacjom na kilku procesorach jednocześnie. W związku z tym optymalizacja współbieżności
wymaga bardziej złożonych algorytmów szeregowania przydziału procesorów.
Opóźnienia komunikacyjne występujące w sieci często oznaczają, że te (i inne) algorytmy
muszą działać z niekompletnymi, przestarzałymi lub nawet nieprawidłowymi informacjami.
Sytuacja ta drastycznie różni się od sytuacji systemów jednoprocesorowych, w których system
operacyjny posiada kompletne informacje na temat stanu systemu.
smartfonów w 2002 roku) oraz iOS Apple’a (opublikowany dla pierwszego urządzenia iPhone
w 2007 roku) zmniejszyły popularność systemu operacyjnego Symbian na rynku. Wiele osób
oczekiwało, że RIM będzie dominować w biznesie, podczas gdy iOS będzie królem urządzeń
konsumenckich. Udziały systemu operacyjnego Symbian w rynku znacznie spadły. W 2011 roku
Nokia porzuciła Symbiana i ogłosiła, że podstawową platformą w produkowanych przez nią urzą-
dzeniach będzie Windows Phone. Przez pewien czas dominowały systemy firm Apple i RIM
(choć ich dominacja pod żadnym względem nie była porównywalna z tą, którą miał Symbian).
Nie minęło jednak zbyt wiele czasu, a Android, system operacyjny bazujący na systemie Linux,
wydany przez Google’a w 2008 roku, wyprzedził wszystkich rywali.
Z punktu widzenia producentów telefonów Android miał tę zaletę, że był systemem typu
open source, dostępnym na warunkach liberalnej licencji. W rezultacie firmy mogły modyfiko-
wać jego kod i z łatwością dostosowywać do potrzeb własnego sprzętu. Ponadto istniała ogromna
liczba deweloperów piszących aplikacje — głównie w znanym języku programowania Java.
Mimo to ostatnie lata pokazały, że dominacja nie musi trwać wiecznie, a konkurenci Androida
ostrzą zęby, aby odzyskać pewne udziały w rynku. Z systemem Android w szczegółach zapo-
znamy się w podrozdziale 10.8.
1.3.1. Procesory
„Mózgiem” komputera jest jego procesor (Central Processing Unit — CPU). Pobiera on instrukcje
z pamięci i je uruchamia. Podstawowy cykl każdego procesora CPU polega na pobraniu pierw-
szej instrukcji z pamięci, zdekodowanie jej w celu określenia jej typu i operandów, uruchomie-
nie jej, a następnie pobranie, zdekodowanie i uruchomienie kolejnych instrukcji. Cykl jest powta-
rzany do czasu, kiedy program zakończy działanie. W ten sposób są uruchamiane wszystkie
programy.
Każdy procesor ma specyficzny zestaw instrukcji. Tak więc procesor x86 nie jest w stanie
wykonywać programów ARM, natomiast procesor ARM nie może wykonywać programów x86.
Ponieważ odwołanie się do pamięci w celu pobrania instrukcji lub słowa danych zajmuje znacznie
więcej czasu niż uruchomienie instrukcji, wszystkie procesory CPU mają rejestry wewnętrzne
przeznaczone do przechowywania wartości najważniejszych zmiennych i tymczasowych wyników.
Zatem zestaw instrukcji, ogólnie rzecz biorąc, zawiera instrukcje załadowania słowa z pamięci
do rejestru oraz zapisania słowa z rejestru do pamięci. Pozostałe instrukcje wykorzystują dwa
operandy z rejestrów, pamięci lub obu tych lokalizacji i generują wynik. Może to być np. doda-
nie dwóch słów do siebie i zapisanie wyniku w rejestrze lub pamięci.
Oprócz rejestrów ogólnego przeznaczenia służących do przechowywania zmiennych i tym-
czasowych wyników większość komputerów jest wyposażona w kilka rejestrów specjalnych
widocznych dla programisty. Jednym z nich jest licznik programu, zawierający adres pamięci
następnej instrukcji do pobrania. Po pobraniu instrukcji licznik programu jest aktualizowany —
wskazuje następną instrukcję.
Innym rejestrem jest wskaźnik stosu, który wskazuje wierzchołek bieżącego stosu w pamięci.
Stos zawiera po jednej ramce dla każdej procedury, której wykonywanie się rozpoczęło, ale
jeszcze się nie zakończyło. Ramka stosu procedury zawiera te parametry wejściowe, zmienne
lokalne i zmienne tymczasowe, które nie są przechowywane w rejestrach.
Jeszcze innym rejestrem jest słowo stanu programu (Program Status Word — PSW). Rejestr
ten zawiera bity kodu warunku ustawiane przez instrukcje porównań, priorytet procesora CPU,
tryb (użytkownika lub jądra) oraz kilka innych bitów kontrolnych. Programy użytkownika mogą
standardowo czytać cały rejestr PSW, choć zwykle zapisują tylko niektóre spośród jego pól.
Rejestr PSW odgrywa ważną rolę w wywołaniach systemowych oraz operacjach wejścia-wyjścia.
System operacyjny musi być świadomy istnienia wszystkich rejestrów. W przypadku współ-
dzielenia procesora na bazie czasu system operacyjny często zatrzymuje działający program
w celu uruchomienia (wznowienia) innego programu. Za każdym razem, kiedy system opera-
cyjny zatrzyma działający program, system operacyjny musi zapisać wszystkie rejestry, tak by
można je było odtworzyć, gdy program zostanie uruchomiony później.
W celu poprawy wydajności projektanci procesorów dawno porzucili prosty model pobiera-
nia, dekodowania i uruchamiania po jednej instrukcji na raz. Wiele nowoczesnych procesorów
posiada mechanizmy pozwalające na uruchamianie więcej niż jednej instrukcji na raz. Procesor
CPU np. może być wyposażony w oddzielne jednostki do pobierania, dekodowania i uruchamia-
nia instrukcji. Dzięki temu, kiedy procesor uruchamia instrukcję n, może w tym samym czasie
dekodować instrukcję n+1 i pobierać instrukcję n+2. Taka organizacja nosi nazwę potoku.
Zilustrowano ją na rysunku 1.7(a), na którym pokazano potok złożony z trzech faz. Powszechnie
wykorzystywane są znacznie dłuższe potoki. W większości projektów potoków, po pobraniu
instrukcji do potoku, musi ona być uruchomiona, nawet jeśli poprzedziła ją instrukcja warunkowa.
1
„Prawo malejących przychodów” dotyczy ekonomii i polega na tym, że w przypadku zwiększania
nakładów na jeden czynnik w pewnym momencie osiąga się punkt, od którego dalsze zwiększanie ilości
tego czynnika powoduje pogarszanie efektów. W tym przypadku chodzi o to, że zwiększanie pojemności
pamięci cache w pewnym momencie będzie przynosić obniżenie wydajności zamiast poprawy — przyp. tłum.
konfigurację jako cztery procesory CPU. Jeśli pracy jest tylko tyle, aby w określonym momen-
cie czasu były zajęte dwa procesory, system operacyjny może nieumyślnie zaplanować dwa
wątki tego samego procesora, podczas gdy drugi pozostanie całkowicie bezczynny. Taki wybór
jest znacznie mniej wydajny od użycia po jednym wątku na każdym z procesorów.
Oprócz wielowątkowości istnieją układy CPU z dwoma, czterema procesorami lub większą
liczbą osobnych procesorów, czyli inaczej rdzeni. Wielordzeniowe układy pokazane na rysunku 1.8
zawierają w sobie po cztery miniukłady — każdy z nich zawiera swój własny, niezależny pro-
cesor CPU (pamięci podręczne — ang. cache — będą omówione później). W niektórych proce-
sorach, takich jak Xeon Phi firmy Intel, TILEPro firmy Tilera, już stosuje się ponad 60 rdzeni
w jednym układzie. Wykorzystanie takiego wielordzeniowego układu z całą pewnością wymaga
wieloprocesorowego systemu operacyjnego.
Rysunek 1.8. (a) Układ czterordzeniowy ze współdzieloną pamięcią cache 2. poziomu; (b) procesor
czterordzeniowy z osobnymi pamięciami cache 2. poziomu
Nawiasem mówiąc, pod względem liczby rdzeni nic nie przebije nowoczesnych procesorów
graficznych (ang. Graphics Processing Unit — GPU). Układy GPU to procesory zawierające
dosłownie tysiące niewielkich rdzeni. Są bardzo dobre do wykonywania wielu prostych obli-
czeń przeprowadzanych równolegle — np. renderowania wielokątów w aplikacjach graficznych.
Nie są już tak dobre do zadań wykonywanych szeregowo. Są również trudne do zaprogramowa-
nia. Chociaż procesory GPU mogą być przydatne do wykorzystania przez systemy operacyjne
(np. do szyfrowania lub przetwarzania ruchu sieciowego), to nie jest prawdopodobne, aby duża
część kodu systemu operacyjnego działała na procesorach GPU.
1.3.2. Pamięć
Drugim głównym komponentem występującym we wszystkich komputerach jest pamięć.
W idealnej sytuacji pamięć powinna być nadzwyczaj szybka (szybsza od uruchamiania instrukcji,
tak aby procesor CPU nie był wstrzymywany przez pamięć), bardzo pojemna i tania. Współ-
czesna technika nie jest w stanie usatysfakcjonować wszystkich tych celów, dlatego przyjęto
inne podejście. System pamięci jest skonstruowany w postaci hierarchii warstw, tak jak poka-
zano na rysunku 1.9. Wyższe warstwy są szybsze, mają mniejszą pojemność i większe koszty
bitu w porównaniu z pamięciami niższych warstw. Często różnice sięgają rzędu miliarda razy
lub więcej.
Najwyższa warstwa składa się z wewnętrznych rejestrów procesora. Są one wykonane z tego
samego materiału co procesor i są niemal tak samo szybkie jak procesor. W związku z tym
nie ma opóźnień w dostępie do rejestrów. Pojemność rejestrów zazwyczaj wynosi 32×32
bity w procesorze 32-bitowym oraz 64×64 bity w procesorze 64-bitowym. W obu przypadkach
pojemność rejestrów nie przekracza 1 kB. Programy muszą same zarządzać rejestrami (tzn.
oprogramowanie decyduje o tym, co ma być w nich zapisane).
W następnej warstwie znajduje się pamięć podręczna, która w większości jest zarządzana
przez sprzęt. Pamięć podręczna jest podzielona na linie pamięci podręcznej (ang. cache lines)
zazwyczaj o pojemności 64 bajtów, o adresach od 0 do 63 w linii pamięci 0, adresach od 64 do
127 w linii pamięci 1 itd. Najbardziej intensywnie wykorzystywane linie pamięci podręcznej są
umieszczone wewnątrz procesora lub bardzo blisko procesora. Kiedy program chce odczytać
słowo pamięci, sprzęt obsługujący pamięć podręczną sprawdza, czy potrzebna linia znajduje się
w pamięci podręcznej. Jeśli tak jest, co określa się terminem trafienie pamięci podręcznej (ang.
cache hit), żądanie jest spełniane z pamięci podręcznej i przez magistralę systemową nie jest
kierowane do pamięci głównej żadne dodatkowe żądanie. Trafienia pamięci podręcznej zwykle
zajmują około dwóch cykli zegara. W przypadku braku trafienia pamięci podręcznej żądania
muszą być skierowane do pamięci głównej, co wiąże się ze znaczącą zwłoką czasową. Rozmiar
pamięci podręcznej jest ograniczony ze względu na jej wysoką cenę. W niektórych maszynach
występują dwa lub nawet trzy poziomy pamięci podręcznej. Każda kolejna jest wolniejsza i więk-
sza od poprzedniej.
Buforowanie odgrywa ważną rolę w wielu obszarach techniki komputerowej. Nie jest to
narzędzie, które stosuje się wyłącznie do magazynowania linii pamięci RAM. Wszędzie, gdzie
występuje duży zasób, który można podzielić na mniejsze, i jeśli niektóre części są wykorzysty-
wane częściej niż inne, stosuje się buforowanie w celu poprawy wydajności. W systemach ope-
racyjnych technika buforowania jest wykorzystywana powszechnie; np. w większości systemów
operacyjnych często używane pliki (lub ich fragmenty) są przechowywane w pamięci głównej.
W ten sposób unika się konieczności wielokrotnego pobierania ich z dysku. Podobnie można
zbuforować rezultaty konwersji długich ścieżek dostępu, np.
/home/ast/projects/minix3/src/kernel/clock.c
na adresy dyskowe, gdzie są umieszczone pliki. W ten sposób unika się powtarzania operacji
konwersji. Wreszcie można zbuforować do późniejszego wykorzystania wynik konwersji adresu
URL strony WWW na adres IP. Istnieje wiele innych zastosowań buforowania.
W dowolnym systemie buforowania należy odpowiedzieć na kilka pytań:
1. Kiedy umieścić nową pozycję w pamięci podręcznej?
2. W której linii pamięci podręcznej umieścić nową pozycję?
3. Którą pozycję usunąć z pamięci podręcznej, jeśli jest potrzebne miejsce?
4. Gdzie umieścić świeżo usuniętą pozycję w pamięci o większym rozmiarze?
Pamięć EEPROM (Electrically Erasable PROM) oraz pamięć flash także są nieulotne, ale
w odróżnieniu od pamięci ROM można je kasować i ponownie zapisywać. Zapisywanie ich zaj-
muje jednak o rząd wielkości więcej czasu niż zapisywanie pamięci RAM. W związku z tym są
one używane w taki sam sposób, w jaki używa się pamięci ROM. Różnica polega na tym, że
w przypadku pamięci EEPROM istnieje możliwość korygowania błędów w programach, które są
w nich zapisane. Można to zrobić poprzez ponowne zapisanie pamięci zainstalowanej w kom-
puterze.
Pamięci flash są również powszechnie używane jako nośnik w przenośnych urządzeniach
elektronicznych. Spełniają np. rolę filmów w aparatach cyfrowych oraz dysków w przenośnych
odtwarzaczach muzycznych. Pamięci flash są szybsze od dysków i wolniejsze od pamięci RAM.
Od dysków różnią się również tym, że po wielokrotnym kasowaniu się zużywają.
Jeszcze innym rodzajem są pamięci CMOS, które są ulotne. Pamięci CMOS wykorzystuje się
w wielu komputerach do przechowywania bieżącej daty i godziny. Pamięć CMOS oraz obwód
zegara, który liczy w niej czas, są zasilane za pomocą niewielkiej baterii. Dzięki temu czas jest
prawidłowo aktualizowany, nawet gdy komputer jest wyłączony. W pamięci CMOS mogą być
również zapisane parametry konfiguracyjne — np. dysk, z którego ma nastąpić rozruch. Pamięci
CMOS używa się m.in. z tego powodu, że zużywają one tak mało pamięci, że oryginalna bateria
zainstalowana przez producenta często wystarcza na kilka lat. Jeśli jednak zacznie zawodzić,
komputer zaczyna cierpieć na amnezję. Zapomina rzeczy, które znał od lat — np. z którego dysku
należy załadować system.
1.3.3. Dyski
Następne w hierarchii są dyski magnetyczne (dyski twarde). Pamięć dyskowa jest o dwa rzędy
wielkości tańsza od pamięci RAM, jeśli chodzi o cenę bitu, a jednocześnie często nawet do
dwóch rzędów wielkości bardziej pojemna. Jedyny problem polega na tym, że czas losowego
dostępu do danych zapisanych na dyskach magnetycznych jest blisko trzy rzędy wielkości dłuż-
szy. Ta niska prędkość wynika stąd, że dyski są urządzeniami mechanicznymi. Strukturę dysku
zaprezentowano na rysunku 1.10.
Dysk składa się z jednego lub kilku metalowych talerzy obracających się z szybkością 5400,
7200 lub 10 800 obrotów na minutę. Mechaniczne ramię przesuwa się nad talerzami podobnie do
ramienia starego fonografu obracającego się podczas odtwarzania winylowych płyt z szybkością
33 obrotów na minutę. Informacje są zapisywane na dysk w postaci ciągu koncentrycznych okrę-
gów. W każdej pozycji ramienia każda z głowic może odczytać pierścieniowy region dysku
zwany ścieżką. Wszystkie ścieżki dla wybranej pozycji ramienia tworzą cylinder.
Każda ścieżka jest podzielona na kilka sektorów. Zazwyczaj każdy sektor ma rozmiar 512
bajtów. W nowoczesnych dyskach cylindry zewnętrzne zawierają więcej sektorów niż cylindry
wewnętrzne. Przesunięcie ramienia z jednego cylindra do następnego zajmuje około 1 milise-
kundy (ms). Przesunięcie go do losowego cylindra zwykle zajmuje od 5 do 10 ms, w zależności
od napędu. Kiedy ramię znajdzie się nad właściwą ścieżką, napęd musi poczekać, aż pod głowicą
obróci się potrzebny sektor. To wiąże się z dodatkową zwłoką rzędu 5 – 10 ms, w zależności
od szybkości obrotowej napędu. Kiedy sektor znajdzie się pod głowicą, następuje odczyt lub
zapis z szybkością od 50 MB/s w przypadku wolnych dysków oraz około 160 MB/s w przypadku
szybszych dysków.
Czasami można się spotkać z terminem „dysk” użwanym na określenie urządzeń, które
w rzeczywistości nie są dyskami — np. SSD (ang. Solid State Disks). W dyskach SSD nie ma
ruchomych części — nie zawierają one talerzy w kształcie dysków. Dane są przechowywane
w pamięci flash. Dyski przypominają jedynie tym, że również przechowują dużo danych, które
nie będą utracone po wyłączeniu komputera.
W wielu komputerach występuje mechanizm znany jako pamięć wirtualna, który omówimy
bardziej szczegółowo w rozdziale 3. Mechanizm ten umożliwia uruchamianie programów
większych od rozmiaru pamięci fizycznej. Aby to było możliwe, są one umieszczane na dysku,
a pamięć główna jest wykorzystywana jako rodzaj pamięci podręcznej dla najczęściej wykorzy-
stywanych fragmentów. Korzystanie z tego mechanizmu wymaga remapowania adresów pamięci
„w locie”. Ma to na celu konwersję adresu wygenerowanego przez program na fizyczny adres
w pamięci RAM, gdzie jest umieszczone żądane słowo. Mapowanie to realizuje komponent pro-
cesora CPU znany jako MMU (Memory Management Unit — moduł zarządzania pamięcią). Poka-
zano go na rysunku 1.6.
Wykorzystanie pamięci podręcznej i modułu MMU może mieć istotny wpływ na wydajność.
W systemie wieloprogramowym, podczas przełączania z jednego do drugiego programu, co czasem
określa się jako przełączanie kontekstowe, niekiedy zachodzi konieczność opróżnienia wszyst-
kich zmodyfikowanych bloków z pamięci podręcznej i zmiany rejestrów mapowania w module
MMU. Obie te operacje są kosztowne, dlatego programiści starają się ich unikać. Pewne implika-
cje wynikające ze stosowanych przez nich taktyk omówimy później.
nad którym cylindrem znajduje się ramię dysku, i przekazać do niego polecenie w celu przesu-
nięcia w głąb lub na zewnątrz o wymaganą liczbę cylindrów. Musi poczekać, aż właściwy sektor
obróci się pod głowicę, a następnie zacząć czytanie i zapamiętywanie bitów z napędu, po czym
usunąć zbędne bity i obliczyć sumy kontrolne. Na koniec musi złożyć odczytane bity w słowa
i zapisać je w pamięci. Kontrolery często zawierają niewielkie wbudowane komputery zaprogra-
mowane do wykonania całej tej pracy.
Druga część to samo urządzenie. Urządzenia mają stosunkowo proste interfejsy, zarówno
dlatego, że nie pozwalają na wykonywanie zbyt wielu operacji, jak i dlatego, by można było je
standaryzować. Standardyzacja jest potrzebna po to, aby np. dowolny kontroler dysku SATA był
w stanie obsłużyć dowolny dysk SATA. SATA to akronim od Serial ATA, z kolei ATA oznacza
AT Attachment. Co oznacza AT? Nazwa pochodzi od komputera firmy IBM drugiej generacji
znanego jako PC AT (ang. Personal Computer Advanced Technology), zbudowanego na bazie
wówczas ekstremalnie mocnego procesora 80286 z zegarem 6 MHz, który firma wprowadziła
na rynek w 1984 roku. Nauka, jaka z tego płynie, jest taka, że w branży komputerowej istnieje
zwyczaj ciągłego „ozdabiania” istniejących akronimów nowymi przedrostkami i przyrostkami.
Można się również nauczyć, aby przymiotnik „zaawansowany” (ang. advanced) stosować z wielką
ostrożnością. W przeciwnym razie możemy wyglądać głupio za następnych 30 lat.
SATA jest obecnie standardowym typem dysku w wielu komputerach. Ponieważ właściwy
interfejs urządzenia jest ukryty za kontrolerem, system operacyjny widzi jedynie interfejs kon-
trolera, który może znacząco się różnić w stosunku do interfejsu samego urządzenia.
Ponieważ każdy typ kontrolera jest inny, do zarządzania każdego z nich jest potrzebne inne
oprogramowanie. Oprogramowanie, które komunikuje się z kontrolerem, przekazując do niego
polecenia i odbierając odpowiedzi, określa się terminem sterownik urządzenia. Producenci kon-
trolerów muszą dostarczyć sterowniki dla wszystkich obsługiwanych systemów operacyjnych.
W związku z tym do skanera mogą być dołączone sterowniki przeznaczone np. dla systemów:
OS X, Windows 7, Windows 8 i Linux.
Aby można było skorzystać ze sterownika, musi on być dołączony do systemu operacyjnego,
tak by mógł działać w trybie jądra. Sterowniki mogą faktycznie działać poza jądrem, a systemy
operacyjne — np. Linux i Windows — oferują już pewne wsparcie takiego sposobu działania.
Jednak zdecydowana większość sterowników wciąż działa w granicach jądra. Tylko w nielicz-
nych współczesnych systemach, np. MINIX 3, wszystkie sterowniki działają w przestrzeni
użytkownika. Sterowniki działające w przestrzeni użytkownika muszą mieć kontrolowany dostęp
do urządzenia, co nie jest oczywiste.
Istnieją trzy sposoby załadowania sterownika do jądra. Pierwszy wymaga konsolidacji jądra
z nowym sterownikiem i ponownego uruchomienia systemu. W ten sposób działa wiele starszych
wersji systemu UNIX. Drugi wymaga stworzenia zapisu w pliku systemu operacyjnego z infor-
macją o wymaganym sterowniku, a następnie ponownego uruchomienia systemu. W momencie
rozruchu system operacyjny znajduje potrzebne sterowniki i je ładuje. W taki sposób działa
system Windows. Trzeci sposób umożliwia akceptację nowych sterowników przez system
operacyjny w czasie działania i instalację ich „w locie” bez potrzeby ponownego uruchamiania
systemu. Dawniej ten sposób był stosowany bardzo rzadko, ale ostatnio jest coraz bardziej popu-
larny. Urządzenia podłączane na gorąco, np. z interfejsem USB, lub IEEE 1394 (omówione poni-
żej) zawsze wymagają dynamicznie ładowanych sterowników.
Każdy kontroler posiada niewielką liczbę rejestrów używanych do komunikacji ze sterow-
nikiem. I tak minimalny kontroler dysku może posiadać rejestry do określenia adresu dysko-
wego, adresu pamięci, numeru sektora oraz kierunku (odczyt lub zapis). W celu aktywacji
kontrolera sterownik otrzymuje polecenie z systemu operacyjnego, a następnie przekształca
je na odpowiednie wartości, które mają być zapisane do rejestrów urządzenia. Zbiór wszystkich
rejestrów urządzenia tworzy przestrzeń portów wejścia-wyjścia — do tego zagadnienia powrócimy
w rozdziale 5.
W niektórych komputerach rejestry urządzeń są odwzorowywane w przestrzeni adresowej
systemu operacyjnego (adresy możliwe do wykorzystania), dzięki czemu można je zapisywać
i odczytywać z nich informacje tak samo, jak w przypadku zwykłych słów pamięci. W takich
komputerach nie są wymagane specjalne instrukcje wejścia-wyjścia, a programom użytkowym
można zakazać dostępu do sprzętu dzięki temu, że te adresy pamięci są umieszczone poza zasię-
giem programów (np. za pomocą rejestrów bazowych — I/O Base — i ograniczających — I/O
Limit). W innych komputerach rejestry urządzeń są umieszczone w specjalnej przestrzeni
portów wejścia-wyjścia, przy czym każdemu rejestrowi jest przypisany adres portu. W takich
komputerach w trybie jądra są dostępne specjalne instrukcje IN i OUT, które umożliwiają ste-
rownikom odczyt i zapis rejestrów. Pierwszy z mechanizmów eliminuje potrzebę specjalnych
instrukcji wejścia-wyjścia, ale wymaga wykorzystania pewnej części przestrzeni adresowej.
W drugim mechanizmie nie wykorzystuje się przestrzeni adresowej, ale są potrzebne specjalne
instrukcje. Obydwa systemy stosuje się powszechnie.
Wyjście i wyjście może być realizowane na trzy różne sposoby. W najprostszej z metod pro-
gram użytkowy wydaje wywołanie systemowe, które jądro przekształca na wywołanie proce-
dury dla właściwego sterownika. Następnie sterownik rozpoczyna operację wejścia-wyjścia
i uruchamia się w pętli co jakiś czas, odpytując, czy urządzenie zakończyło operację (zwykle
dostępny jest bit, który wskazuje na to, czy urządzenie jest zajęte). Po zakończeniu operacji wej-
ścia-wyjścia sterownik umieszcza dane (jeśli takie są) tam, gdzie są potrzebne, i kończy działa-
nie. Następnie system operacyjny zwraca sterowanie do procesu wywołującego. Metodę tę
określa się jako oczekiwanie aktywne (ang. busy waiting). Jego wada polega na tym, że procesor
jest związany z odpytywaniem urządzenia do czasu zakończenia operacji wejścia-wyjścia.
Druga z metod polega na tym, że sterownik uruchamia urządzenie i żąda od niego wygene-
rowania przerwania, kiedy operacja zostanie zakończona. W tym momencie sterownik kończy
działanie. Wtedy system operacyjny blokuje proces wywołujący, jeśli jest taka potrzeba, a następ-
nie poszukuje innej pracy do wykonania. Kiedy kontroler wykryje koniec transferu, generuje
przerwanie w celu zasygnalizowania tego faktu.
Przerwania są bardzo ważne w systemach operacyjnych, spróbujmy zatem nieco bliżej przyj-
rzeć się temu zagadnieniu. Na rysunku 1.11(a) widzimy proces operacji wejścia-wyjścia skła-
dający się z czterech kroków. W kroku 1. sterownik informuje kontroler, co należy zrobić —
zapisuje dane do jego rejestrów. Następnie kontroler uruchamia urządzenie. Kiedy zakończy
odczyt lub zapis takiej liczby bajtów, jaka miała być przetransferowana, wykonuje krok 2. pole-
gający na zasygnalizowaniu tego faktu układowi kontroli przerwań. Do tego celu wykorzystuje
określone linie magistrali. Jeśli kontroler przerwań jest gotowy do akceptacji przerwania (nie
jest gotowy, jeśli realizuje przerwanie o wyższym priorytecie), wykonuje krok 3. — ustawia pin
układu CPU, informując go o gotowości. W kroku 4. kontroler przerwań umieszcza numer urzą-
dzenia na magistrali. Dzięki temu procesor może go odczytać i w ten sposób dowiaduje się, które
z urządzeń zakończyło operację (jednocześnie może działać wiele urządzeń wejścia-wyjścia).
Kiedy procesor zdecyduje się na obsługę przerwania, zwykle przesyła licznik programu
i rejestr PSW na stos, a procesor przełącza się do trybu jądra. Numer urządzenia może być
wykorzystany jako indeks pewnej części pamięci w celu odszukania adresu procedury obsługi
przerwania dla wybranego urządzenia. Ta część pamięci nosi nazwę wektora przerwań. Kiedy
zacznie działać procedura obsługi przerwania (część sterownika urządzenia, które wygenerowało
przerwanie), zdejmuje ze stosu licznik programu oraz rejestr PSW i je zapisuje. Następnie odpytuje
urządzenie w celu poznania jego stanu. Kiedy procedura obsługi przerwania zakończy działanie,
zwraca sterowanie do wcześniej uruchomionego programu użytkowego — do pierwszej instrukcji,
która jeszcze nie została wykonana. Czynności te pokazano na rysunku 1.11(b).
Trzecia metoda realizacji operacji wejścia-wyjścia polega na wykorzystaniu specjalnego sprzętu:
układu DMA (Direct Memory Access — bezpośredni dostęp do pamięci), który steruje przepły-
wem bitów pomiędzy pamięcią a kontrolerem bez ciągłej interwencji procesora. Procesor usta-
wia układ DMA, informując go o liczbie bajtów do przetransferowania, adresach urządzenia
i pamięci biorących udział w operacji oraz kierunku przesyłania. Na tym jego rola się kończy.
Kiedy układ DMA zakończy pracę, generuje przerwanie, które jest obsługiwane w sposób opisany
powyżej. Sprzęt DMA oraz urządzenia wejścia-wyjścia zostaną omówione bardziej szczegółowo
w rozdziale 5.
Przerwania często zdarzają się w bardzo nieodpowiednich momentach — np. w czasie kiedy
działa inna procedura obsługi przerwania. Z tego względu procesor CPU ma możliwość wyłą-
czania i włączania obsługi przerwań. Podczas gdy przerwania są wyłączone, urządzenia, które
zakończyły operacje wejścia-wyjścia, w dalszym ciągu ustawiają sygnały przerwań, ale proce-
sor CPU nie przerywa działania do chwili, kiedy przerwania zostaną ponownie włączone. Jeśli
w czasie, gdy przerwania są wyłączone, więcej niż jedno urządzenie zakończy operację wej-
ścia-wyjścia, kontroler przerwań decyduje o tym, które przerwanie będzie obsłużone w pierw-
szej kolejności. Zazwyczaj robi to na podstawie statycznego priorytetu przypisanego do każdego
z urządzeń. W pierwszej kolejności jest obsługiwane przerwanie pochodzące od urządzenia
o najwyższym priorytecie. Pozostałe muszą czekać.
1.3.5. Magistrale
Organizacja pokazana na rysunku 1.6 była używana przez wiele lat w minikomputerach, a także
w oryginalnej wersji komputera IBM PC. Jednak w miarę jak procesory i pamięci stawały się
coraz szybsze, zdolność jednej magistrali (zwłaszcza magistrali IBM PC) do obsługi całego ruchu
stawała się bardzo ograniczona. Potrzebne było jakieś rozwiązanie. W rezultacie dodano nowe
magistrale — zarówno dla szybszych urządzeń wejścia-wyjścia, jak i dla szybszego ruchu pomiędzy
procesorem a pamięcią. W wyniku tej ewolucji duże systemy bazujące na procesorach x86 mają
obecnie architekturę podobną do tej, którą pokazano na rysunku 1.12.
System ten ma wiele magistral (pamięci podręcznej, lokalną, pamięci głównej, PCIe, PCI,
USB, SATA i DMI). Każda z nich charakteryzuje się inną szybkością transferu oraz innym
przeznaczeniem. System operacyjny musi być świadomy istnienia wszystkich magistral, aby
było możliwe ich konfigurowanie i zarządzanie. Główna jest magistrala PCIe (ang. Peripheral
Component Interconnect Express).
Magistrala PCIe została opracowana przez firmę Intel jako następca starszej magistrali PCI,
która z kolei była zamiennikiem oryginalnej magistrali ISA (ang. Industry Standard Architecture).
Magistrala PCIe jest znacznie szybsza niż jej poprzedniczki. Umożliwia przesyłanie dziesiątek
gigabitów na sekundę. Ma także zupełnie inny charakter. Do momentu jej powstania w 2004 roku
magistrale w większości były równoległe i współdzielone. Architektura współdzielonej magi-
strali (ang. shared bus architecture — SBA) oznacza, że wiele urządzeń korzysta z tych samych
kabli do przesyłania danych. Tak więc gdy wiele urządzeń ma dane do wysłania, potrzebny jest
arbitraż w celu ustalenia, które z nich może skorzystać z magistrali. Dla odróżnienia w przy-
padku magistrali PCIe używa się specjalnych połączeń punkt-punkt. Architektura równoległej
magistrali (ang. parallel bus architecture — PBA), taka jakiej używa się w tradycyjnej magi-
strali PCI, oznacza, że każde słowo danych jest wysyłane za pośrednictwem wielu przewodów.
I tak w standardowej magistrali PCI pojedyncza, 32-bitowa liczba jest przesyłana za pośrednic-
twem 32 równoległych przewodów. Dla odróżnienia w magistrali PCIe wykorzystywana jest
architektura SBA. W niej wszystkie bity w wiadomości są przesyłane za pośrednictwem jednego
połączenia, znanego jako pasmo (ang. lane) — podobnie do pakietu sieciowego. Jest to o wiele
prostsze, ponieważ nie istnieje potrzeba zapewniania, aby wszystkie 32 bity dotarły do miejsca
docelowego dokładnie w tym samym czasie. Współbieżność jest nadal używana, ponieważ może
istnieć wiele równoległych pasm. Można np. użyć 32 pasm do równoległej transmisji 32 wia-
domości. Ponieważ szybkość urządzeń peryferyjnych, takich jak karty sieciowe i karty graficzne,
gwałtownie wzrasta, standard PCIe jest aktualizowany co 3 – 5 lat. I tak 16 pasm magistrali PCIe
2.0 gwarantuje szybkość transmisji 64 gigabity na sekundę. Aktualizacja do standardu PCIe 3.0
pozwala na podwojenie tej prędkości, a w przypadku PCIe 4.0 następuje kolejne podwojenie.
Tymczasem wciąż istnieje wiele starszych urządzeń wykorzystujących standard PCI. Jak
można zobaczyć na rysunku 1.12, urządzenia te są podłączone do oddzielnego procesora-kon-
centratora. W przyszłości, gdy uznamy, że standard PCI jest nie tylko stary, ale wręcz antyczny,
istnieje możliwość, że wszystkie urządzenia PCI zostaną podłączone do jeszcze innego kon-
centratora, który z kolei będzie podłączony do koncentratora głównego. W ten sposób stworzy
się drzewo magistral.
W tej konfiguracji procesor komunikuje się z pamięcią za pośrednictwem szybkiej magi-
strali DDR3, z zewnętrznym urządzeniem graficznym przez magistralę PCIe, natomiast ze
wszystkimi innymi urządzeniami za pośrednictwem koncentratora podłączonego do magistrali
DMI (ang. Direct Media Interface). Z kolei koncentrator łączy wszystkie inne urządzenia za
pomocą magistrali USB (ang. Universal Serial Bus), magistrali SATA do interakcji z dyskami
twardymi i napędami DVD oraz PCIe w celu przekazywania ramek Ethernet. Wcześniej
wspominaliśmy o starszych urządzeniach PCI wykorzystujących tradycyjną magistralę PCI.
Ponadto każdy z rdzeni posiada dedykowaną pamięć podręczną i znacznie większy bufor, który
jest współdzielony pomiędzy nimi. Każda z tych pamięci podręcznych wprowadza inną magistralę.
Magistralę USB (Universal Serial Bus) opracowano w celu podłączania do komputera wszyst-
kich wolnych urządzeń wejścia-wyjścia, takich jak klawiatura i mysz. Jednak nazywanie „wol-
nym” nowoczesnego urządzenia USB 3.0 działającego z szybkością 5 Gb/s może wydawać się
nienaturalne dla pokolenia, które dorastało z 8-megabitową magistralą ISA jako główną szyną
w pierwszych komputerach IBM PC. USB wykorzystuje niewielkie złącze z 4 – 11 przewodami
(w zależności od wersji). Niektóre z tych przewodów dostarczają energię elektryczną do urzą-
dzeń USB lub doprowadzają masę. USB jest scentralizowaną magistralą, w której koncentrator
odpytuje co 1 ms urządzenia wejścia-wyjścia, aby zobaczyć, czy generują one jakiś ruch. Magi-
strala USB 1.0 była w stanie obsłużyć całkowity ruch o szybkości 12 Mb/s, standard USB 2.0
zapewniał szybkość transmisji 480 Mb/s, natomiast USB 3.0 pozwala na transmisję nie wol-
niejszą niż 5 Gb/s. Dowolne urządzenia USB można podłączyć do komputera bez konieczności
ponownego uruchamiania — procesu koniecznego w przypadku urządzeń sprzed epoki USB, co
wprowadzało konsternację wśród pokolenia sfrustrowanych użytkowników.
Magistrala SCSI (Small Computer System Interface) to wysokowydajna magistrala przezna-
czona do podłączania szybkich dysków, skanerów oraz innych urządzeń wymagających dosyć
szerokiego pasma. Obecnie jest zainstalowana głównie w serwerach i stacjach roboczych. Magi-
strala może działać z szybkością do 640 Mb/s.
Aby była możliwa praca w środowisku podobnym do pokazanego na rysunku 1.12, system
operacyjny musi wiedzieć, jakie urządzenia peryferyjne są podłączone do komputera, i je skonfi-
gurować. To wymaganie skłoniło firmy Intel i Microsoft do zaprojektowania w komputerach
PC systemu znanego pod nazwą plug and play (dosł. włącz i używaj). Mechanizm ten bazował
na podobnej koncepcji zaimplementowanej wcześniej w komputerach Macintosh firmy Apple.
Przed powstaniem techniki plug and play każda karta wejścia-wyjścia miała przypisany stały
numer żądania przerwania (IRQ) oraz stałe adresy rejestrów; np. klawiatura korzystała z prze-
rwania nr 1 i używała adresów wejścia-wyjścia od 0x60 do 0x64, kontroler stacji dyskietek
wykorzystywał przerwanie 6. i używał adresów wejścia-wyjścia od 0x3F0 do 0x3F7, drukarka
korzystała z przerwania 7. i adresów wejścia-wyjścia 0x378 do 0x37A itd.
Do pewnego momentu wszystko przebiegało bez kłopotów. Problem pojawiał się choćby
wtedy, kiedy użytkownik kupił kartę dźwiękową i kartę modemową, które wykorzystywały to
samo przerwanie — np. przerwanie nr 4. W tej sytuacji występował konflikt i karty te nie mogły
pracować razem. Rozwiązaniem było wyposażanie kart wejścia-wyjścia w przełączniki DIP lub
zworki. W ten sposób użytkownik mógł wybrać numer przerwania i adresy wejścia-wyjścia,
które nie kolidowały z innymi urządzeniami w jego systemie. Zadanie to potrafili wykonywać
bezbłędnie nastoletni użytkownicy, którzy poświęcili swoje życie na poznawanie osobliwości
sprzętu PC. Niestety, nikt inny tego nie potrafił, co doprowadziło do chaosu.
Zadaniem systemu plug and play było automatyczne pobieranie informacji na temat urządzeń
wejścia-wyjścia, centralne przydzielanie numerów przerwań i adresów wejścia-wyjścia oraz
informowanie każdej karty, jakie zasoby zostały jej przydzielone. Działania te są ściśle powią-
zane z rozruchem komputera. Przyjrzyjmy się zatem nieco bliżej temu procesowi. Nie jest on
tak prosty, jak mogłoby się wydawać.
Systemy operacyjne są w użyciu już prawie pół wieku. W tym czasie opracowano wiele ich
odmian. Nie wszystkie są powszechnie znane. W tym podrozdziale zwięźle opiszemy dziewięć
spośród nich. Niektóre spośród różnych typów systemów zostaną bardziej szczegółowo omó-
wione w dalszych rozdziałach tej książki.
1.5.1. Procesy
Kluczowym pojęciem we wszystkich systemach operacyjnych jest proces. Ogólnie proces to
wykonujący się program. Z każdym procesem jest związana jego przestrzeń adresowa — lista
lokalizacji pamięci od 0 do pewnego maksimum — z której system może czytać i do której
może zapisywać informacje. Przestrzeń adresowa zawiera program wykonywalny, dane pro-
gramu oraz jego stos. Z każdym procesem jest również związany zbiór zasobów, zwykle obej-
mujący rejestry (w tym licznik programu i wskaźnik stosu), listę otwartych plików, zaległych
alarmów, powiązanych procesów oraz wszystkie inne informacje potrzebne do uruchomienia
Rysunek 1.13. Drzewo procesów. Proces A utworzył dwa procesy potomne: B i C. Proces B
utworzył trzy procesy potomne: D, E i F
1.5.3. Pliki
Inne kluczowe pojęcie występujące niemal we wszystkich systemach operacyjnych to system
plików. Jak wspomniano wcześniej, główną funkcją systemu operacyjnego jest ukrywanie oso-
bliwości dysków oraz innych urządzeń wejścia-wyjścia i dostarczanie programistom wygodnego
i czytelnego abstrakcyjnego modelu niezależnych plików. Oczywiście są potrzebne wywołania
systemowe do tworzenia, usuwania, czytania i zapisywania plików. Przed odczytaniem pliku
musi być on zlokalizowany na dysku i otwarty. Po odczytaniu danych trzeba go zamknąć.
W związku z tym system operacyjny udostępnia wywołania do wykonywania tych operacji.
Dla zapewnienia miejsca przechowywania plików w większości systemów operacyjnych
komputerów PC występuje pojęcie katalogu jako sposobu grupowania plików. I tak student
może utworzyć katalog dla każdego przedmiotu, który studiuje (w celu zapisania programów
niezbędnych do zaliczenia tego przedmiotu), inny katalog przeznaczony na pocztę elektro-
niczną, a jeszcze inny na swoją macierzystą stronę internetową. W związku z tym są potrzebne
wywołania systemowe do tworzenia i usuwania katalogów. Dostępne są również wywołania
umieszczenia istniejącego pliku w katalogu oraz usunięcia pliku z katalogu. Katalogi mogą zawie-
rać pliki lub inne katalogi. Model ten tworzy hierarchię — system plików. Jej przykład pokazano
na rysunku 1.14.
Zarówno hierarchie procesów, jak i plików są zorganizowane w postaci drzew, ale na tym
podobieństwa się kończą. Hierarchie procesów zwykle nie są zbyt głębokie (hierarchie obejmu-
jące więcej niż trzy procesy należą do rzadkości), natomiast hierarchie plików zazwyczaj mają
głębokość czterech, pięciu lub nawet większej liczby poziomów. Czas życia hierarchii proce-
sów zwykle jest krótki — co najwyżej mierzony w minutach — natomiast hierarchia katalogów
może istnieć wiele lat. Prawa własności i zabezpieczenia również są inne dla procesów, a inne dla
plików. Zazwyczaj tylko proces-rodzic może zarządzać, a nawet uzyskać dostęp do procesu-dziecka.
Z kolei w przypadku plików prawie zawsze istnieje mechanizm umożliwiający ich czytanie przez
szerszą grupę użytkowników.
Każdy plik w obrębie hierarchii katalogów może być określony za pomocą nazwy ścieżki,
jeśli liczyć od szczytu hierarchii katalogów — katalogu głównego. Nazwy bezwzględnych ście-
żek składają się z listy katalogów, które trzeba przejść od katalogu głównego, aby dostać się do
pliku. Poszczególne komponenty są od siebie oddzielone ukośnikami. Na rysunku 1.14 ścieżka
Systemu plików na płycie CD-ROM nie można jednak używać, ponieważ nie ma sposobu zde-
finiowania ścieżki, która by do niego prowadziła. W systemie UNIX nie ma możliwości poprze-
dzania nazw ścieżek prefiksem w postaci nazwy lub numeru napędu. Byłby to rodzaj zależności
od urządzeń, które systemy operacyjne powinny eliminować. Zamiast tego polecenie montowania
systemu plików pozwala na dołączenie systemu plików na płycie CD-ROM do głównego systemu
plików. Na rysunku 1.15(b) system plików na płycie CD-ROM zamontowano w katalogu b. W ten
sposób stał się możliwy dostęp do plików /b/x i /b/y. Gdyby w katalogu b znajdowały się dowolne
pliki, nie byłyby one dostępne w czasie, gdy jest zamontowany napęd CD-ROM, ponieważ
ścieżka /b odnosi się wtedy do głównego katalogu na płycie CD-ROM (brak możliwości dostępu
do tych plików nie jest tak poważnym problemem, jak wydaje się na pierwszy rzut oka: systemy
plików prawie zawsze są montowane w pustych katalogach). Jeśli system zawiera wiele dysków
twardych, wszystkie one również można zamontować w pojedynczą strukturę drzewa.
Innym ważnym pojęciem w systemie UNIX jest plik specjalny. Pliki specjalne służą do tego,
aby urządzenia wejścia-wyjścia wyglądały tak jak pliki. Dzięki temu można je odczytywać i zapi-
sywać z wykorzystaniem tych samych wywołań systemowych, jakie wykorzystuje się do odczy-
tywania i zapisywania plików. Istnieją dwa rodzaje plików specjalnych: blokowe pliki specjalne
oraz znakowe pliki specjalne. Blokowe pliki specjalne służą do modelowania urządzeń składają-
cych się z kolekcji losowo adresowalnych bloków, takich jak dyski. Dzięki otwarciu blokowego
pliku specjalnego i odczytaniu np. bloku 4. program może uzyskać bezpośredni dostęp do
czwartego bloku na urządzeniu bez względu na strukturę systemu plików na tym urządzeniu.
Na podobnej zasadzie znakowe pliki specjalne są używane do modelowania drukarek, mode-
mów i innych urządzeń, które akceptują lub wyprowadzają strumień znaków. Zgodnie z konwen-
cją pliki specjalne są przechowywane w katalogu /dev; np. urządzenie /dev/lp może być odpo-
wiednikiem drukarki (dawniej określanej jako drukarka wierszowa — ang. line printer).
Ostatnim mechanizm, który omówimy w tym opisie, jest związany zarówno z procesami,
jak i z plikami: są to potoki. Potok jest rodzajem pseudopliku, który można wykorzystać do połą-
czenia dwóch procesów w sposób pokazany na rysunku 1.16. Jeśli procesy A i B chcą się ze
sobą komunikować przez potok, muszą go wcześniej ustanowić. Kiedy proces A chce przesłać
dane do procesu B, zapisuje informacje w potoku tak, jakby był on plikiem wynikowym. W rze-
czywistości implementacja potoku bardzo przypomina implementację pliku. Proces B może
czytać dane poprzez czytanie potoku w taki sposób, jakby był to plik wejściowy. Tak więc komu-
nikacja między procesami w Uniksie wygląda bardzo podobnie do standardowych operacji odczytu
i zapisu plików. Co więcej, jedynym sposobem na to, aby proces mógł wykryć, że plik wynikowy,
do którego zapisuje informacje, nie jest rzeczywistym plikiem, okazuje się skorzystanie ze spe-
cjalnego wywołania systemowego. Systemy plików są bardzo ważne. Więcej informacji na ten
temat zamieścimy w rozdziale 4., a następnie w rozdziałach 10. i 11.
1.5.4. Wejście-wyjście
Wszystkie komputery mają urządzenia fizyczne do pobierania danych wejściowych i genero-
wania danych wynikowych. W końcu do czego miałyby służyć komputery, gdyby użytkownicy
nie mogli im zlecić, co mają zrobić, i nie mogli uzyskać wyników po wykonaniu żądanej pracy?
Istnieje wiele rodzajów urządzeń wejścia i wyjścia — należą do nich m.in. klawiatury, monitory,
drukarki. Zarządzanie tymi urządzeniami jest zadaniem systemu operacyjnego.
W konsekwencji każdy system operacyjny jest wyposażony w podsystem wejścia-wyjścia
przeznaczony do zarządzania swoimi urządzeniami wejścia-wyjścia. Niektóre oprogramowanie
wejścia-wyjścia jest niezależne od sprzętu — tzn. równie dobrze można je wykorzystywać dla
wielu lub wszystkich urządzeń wejścia-wyjścia. Inne jego części, np. sterowniki urządzeń, są
specyficzne dla wybranych urządzeń wejścia-wyjścia. Oprogramowaniem wejścia-wyjścia zaj-
miemy się w rozdziale 5.
1.5.5. Zabezpieczenia
Komputery zawierają duże ilości informacji, które użytkownicy często chcą ochronić i zacho-
wać poufność. Mogą to być wiadomości e-mail, biznesplany, zwroty podatków i wiele innych.
Dbanie o bezpieczeństwo systemu jest zadaniem systemu operacyjnego. Musi on zadbać, aby
np. pliki były dostępne tylko dla uprawnionych użytkowników.
W roli prostego przykładu, aby zobrazować, w jaki sposób działają zabezpieczenia, wykorzy-
stajmy system UNIX. Pliki w Uniksie są chronione poprzez przypisanie każdemu z nich 9-bitowego
binarnego kodu zabezpieczającego. Kod zabezpieczający składa się z trzech 3-bitowych pól —
jednego dla właściciela, drugiego dla członków grupy, do której należy właściciel (użytkownicy
są dzieleni na grupy przez administratora systemu), oraz trzeciego dla wszystkich pozostałych
użytkowników. W każdym polu jest bit określający prawo dostępu do odczytu, drugi bit okre-
ślający prawo dostępu do zapisu oraz trzeci oznaczający prawo dostępu do uruchamiania. Te trzy
bity określa się jako bity rwx. I tak kod zabezpieczenia rwxr-x-x oznacza, że właściciel może czytać,
zapisywać lub uruchamiać plik, inni członkowie grupy mogą czytać lub uruchamiać plik (ale nie
mogą go zapisywać), natomiast wszyscy pozostali mogą uruchamiać (ale nie mogą czytać ani
zapisywać) plik. W przypadku katalogów bit x oznacza prawo do przeszukiwania. Myślnik występu-
jący na danej pozycji oznacza brak wybranego uprawnienia.
Oprócz zabezpieczeń plików istnieje wiele innych problemów bezpieczeństwa. Jednym
z nich jest zabezpieczanie systemu przed intruzami, zarówno ludźmi, jak i programami (np. wiru-
sami). Różne problemy zabezpieczeń omówimy w rozdziale 9.
1.5.6. Powłoka
System operacyjny jest kodem, który realizuje wywołania systemowe. Edytory, kompilatory,
asemblery, linkery, programy narzędziowe czy interpretery poleceń niewątpliwie nie są czę-
ścią systemu operacyjnego, choć są ważne i przydatne. Ryzykując pewne zaciemnienie obrazu,
w tym punkcie zwięźle omówimy interpreter poleceń systemu UNIX — tzw. powłokę. Chociaż
nie jest ona częścią systemu operacyjnego, intensywnie wykorzystuje wiele własności systemu
operacyjnego, a tym samym służy za dobry przykład tego, jak można wykorzystać wywołania
systemowe. Jest to również podstawowy interfejs pomiędzy użytkownikiem siedzącym przy
terminalu a systemem operacyjnym (o ile użytkownik nie korzysta ze środowiska graficznego).
Dostępnych jest wiele powłok, w tym sh, csh, ksh i bash. Wszystkie one obsługują własności
opisane poniżej. Wywodzą się z pierwotnej powłoki (sh).
Kiedy dowolny użytkownik loguje się do systemu, uruchamia się powłoka. Powłoka wyko-
rzystuje terminal jako standardowe urządzenie wejściowe oraz standardowe urządzenie wyj-
ściowe. Powłoka rozpoczyna od wyświetlenia symbolu zachęty (ang. prompt) — np. znaku dolara,
który informuje użytkownika, że powłoka oczekuje na przyjęcie polecenia. Jeżeli użytkownik
wpisze teraz np.:
date
to powłoka utworzy proces potomny i uruchomi program date. Podczas gdy proces potomny
działa, powłoka oczekuje na jego zakończenie. Kiedy proces potomny zakończy działanie, powłoka
ponownie wyświetli symbol zachęty i spróbuje odczytać następny wiersz.
Użytkownik może wskazać, że standardowe wyjście ma być przekierowane do pliku, np.:
date >plik
Powyższe polecenie wywołuje program sort z danymi wejściowymi pobranymi z pliku plik1,
natomiast wyniki są kierowane do pliku plik2.
Wyjście jednego programu można wykorzystać jako wejście innego, poprzez połączenie ich
za pomocą potoku. Tak więc polecenie:
cat plik1 plik2 plik3 | sort >/dev/lp
wywołuje program cat w celu konkatenacji trzech plików i wysyła wynik do programu sort
w celu ułożenia wszystkich wierszy w porządku alfabetycznym. Wynik działania programu sort
jest przekierowywany do pliku /dev/lp, który zwykle oznacza drukarkę.
Jeśli użytkownik umieści znak & za poleceniem, powłoka nie będzie czekała na zakończenie
jego działania. Zamiast tego natychmiast wyświetli symbol zachęty. W konsekwencji polecenie:
cat plik1 plik2 plik3 | sort >/dev/lp &
zainicjuje program sort jako zadanie działające w tle. Dzięki temu użytkownik może kontynu-
ować normalną pracę w czasie, kiedy działa polecenie sort. Powłoka ma kilka innych intere-
sujących własności, których nie omówimy w tej książce ze względu na ograniczone miejsce.
Powłokę opisano dość szczegółowo w większości publikacji na temat Uniksa, np. [Kernighan
i Pike, 1984], [Quigley, 2004], [Robbins, 2005].
Wiele współczesnych komputerów osobistych korzysta z interfejsu GUI. W rzeczywistości
GUI, podobnie jak powłoka, jest programem działającym jako nakładka systemu operacyjnego.
W systemach linuksowych fakt ten jest oczywisty, ponieważ użytkownik ma do wyboru co naj-
mniej dwa interfejsy GUI: GNOME, KDE. Może również całkowicie zrezygnować ze środowi-
ska graficznego (wykorzystać okno terminala w systemie X11). W systemie Windows również
można zastąpić standardowy pulpit graficzny (Windows Explorer) innym programem. W tym
celu wystarczy zmienić kilka parametrów w rejestrze, ale robi to bardzo niewiele osób.
sieciowe są tak znaczne, że to one mają dominujące znaczenie. Tak więc historia pomiędzy
bezpośrednim uruchamianiem a interpretacją już zatoczyła kilka cykli, a w przyszłości sytuacja
znów może się zmienić.
Zabezpieczenia sprzętowe
Pierwsze komputery mainframe, np. IBM 7090/7094, nie posiadały zabezpieczeń sprzętowych,
dlatego w danym momencie działał na nich tylko jeden program. Program zawierający błąd
mógł zniszczyć system operacyjny i z łatwością doprowadzić do awarii maszyny. Wraz z powsta-
niem komputera IBM 360 stały się dostępne prymitywne formy zabezpieczeń sprzętowych.
Maszyny te były zdolne do przechowywania kilku programów w pamięci jednocześnie i cyklicz-
nego ich uruchamiania (wieloprogramowość). Systemy jednoprogramowe uznano za przestarzałe.
Działo się tak co najmniej do czasu pojawienia się pierwszych minikomputerów, które były
pozbawione zabezpieczeń sprzętowych, zatem działanie wielu programów jednocześnie było
niemożliwe. Choć komputery PDP-1 i PDP-8 nie miały zabezpieczeń sprzętowych, komputer
PDP-11 je miał, a zatem możliwe stało się skorzystanie z wieloprogramowości, co w efekcie
doprowadziło do powstania Uniksa.
Pierwsze mikrokomputery wykorzystywały układ mikroprocesora 8080, który był pozba-
wiony zabezpieczeń sprzętowych, zatem powrócono do trybu jednoprogramowego — w okre-
ślonym czasie w pamięci był tylko jeden program. Było tak do chwili powstania układu Intel
80286, do którego dodano zabezpieczenia sprzętowe, dzięki czemu stał się możliwy tryb wie-
loprogramowy. Do dzisiejszego dnia wiele systemów wbudowanych nie posiada zabezpieczeń
sprzętowych, dlatego działa w nich tylko jeden program.
Przyjrzyjmy się teraz systemom operacyjnym. Pierwsze komputery mainframe nie posia-
dały sprzętu zabezpieczającego i nie obsługiwały wieloprogramowości, dlatego działały na nich
proste systemy operacyjne, które w danym momencie obsługiwały jeden ręcznie ładowany pro-
gram. Później w tych komputerach zastosowano odpowiedni sprzęt, a w systemach operacyj-
nych zaimplementowano możliwość jednoczesnej obsługi wielu programów. W końcu systemy
te uzyskały pełne możliwości technologii podziału czasu.
Kiedy po raz pierwszy pojawiły się minikomputery, również nie miały one zabezpieczeń sprzę-
towych i działał na nich jeden ręcznie załadowany program, choć w owym czasie w świecie
komputerów mainframe wieloprogramowość miała dobrze ugruntowaną pozycję. Stopniowo
w minikomputerach zaczęto wprowadzać zabezpieczenia sprzętowe, a systemy te zyskały moż-
liwość uruchamiania dwóch lub większej liczby programów jednocześnie. Pierwsze mikro-
komputery też mogły uruchamiać tylko jeden program na raz, ale później uzyskały możliwość
stosowania wieloprogramowości. Komputery podręczne i karty chipowe przechodziły tę samą
ścieżkę.
We wszystkich przypadkach rozwój oprogramowania był podyktowany technologią, np.
pierwsze mikrokomputery miały około 4 kB pamięci i nie posiadały zabezpieczeń sprzęto-
wych. Stosowanie języków wysokopoziomowych i wieloprogramowości po prostu przekraczało
możliwości tak niewielkich systemów. Kiedy mikrokomputery przekształciły się w nowoczesne
komputery osobiste, uzyskały niezbędny sprzęt, a później oprogramowanie potrzebne do obsługi
bardziej zaawansowanych funkcji. Jest wysoce prawdopodobne, że sytuacja będzie się rozwijała
w podobny sposób w ciągu następnych lat. Być może w innych dziedzinach również obowiązuje
to koło reinkarnacji, ale jak się wydaje, w branży komputerowej obraca się ono znacznie szybciej.
Dyski
W pierwszych komputerach mainframe używano głównie taśm magnetycznych. Komputery te
czytały program z taśmy, kompilowały go, uruchamiały, a następnie zapisywały wyniki na innej
taśmie. Nie było dysków i nie istniało pojęcie systemu plików. Sytuacja ta zaczęła się zmie-
niać, kiedy w 1956 roku firma IBM wprowadziła na rynek pierwszy dysk twardy — RAMAC
(RAndoM ACcess). Zajmował on powierzchnię około 4 m2 i mógł pomieścić 5 milionów 7-bitowych
znaków — objętość wystarczającą do pomieszczenia jednego zdjęcia cyfrowego o średniej roz-
dzielczości. Jeśli wziąć pod uwagę cenę rocznego wynajmu powierzchni potrzebnej na zbudo-
wanie takiej liczby dysków, która byłaby zdolna pomieścić liczbę zdjęć odpowiadającą jednej rolce
filmu — 35 tysięcy dolarów — trzeba przyznać, że było to przedsięwzięcie dość kosztowne.
Ostatecznie jednak ceny spadły i powstały prymitywne systemy plików.
Nowe osiągnięcia techniki zastosowano w systemie CDC 6600, opublikowanym w 1964 roku.
Przez wiele lat był on zdecydowanie najszybszym komputerem na świecie. Jego użytkownicy
mogli tworzyć tzw. „trwałe pliki” poprzez nadawanie im nazw. Kiedy użytkownik podejmował
próbę nadania nazwy plikowi, musiał zadbać o to, by była ona unikatowa. Tak więc nadając pli-
kowi nazwę dane, musiał liczyć, że nikt inny nie uzna jej za odpowiednią do nazwania swojego
pliku. Był to jednopoziomowy katalog. Ostatecznie w komputerach mainframe opracowano zło-
żone, hierarchiczne systemy plików. Ich kulminacją okazał się system plików MULTICS.
Kiedy w użyciu pojawiły się minikomputery, po jakimś czasie również zaczęto w nich stoso-
wać dyski twarde. Standardowym dyskiem w komputerze PDP-11 w momencie jego powstania
w 1970 roku był dysk RK05 o pojemności 2,5 MB. To mniej więcej połowa objętości dysku
RAMAC firmy IBM, ale dysk ten miał tylko około 40 cm średnicy i 5 cm wysokości. Jednak
także na tym dysku początkowo stosowano jednopoziomowe katalogi. Kiedy pojawiły się mikro-
komputery, początkowo dominującym systemem operacyjnym był na nich CP/M. Także ten
system obsługiwał tylko jeden katalog na dysku (dyskietce elastycznej).
Pamięć wirtualna
Pamięć wirtualna (którą omówiono w rozdziale 3.) daje możliwość uruchamiania programów
większych niż fizyczna objętość pamięci. Jest to możliwe dzięki przesyłaniu fragmentów pomiędzy
pamięcią RAM a dyskiem. Także pamięć wirtualna przechodziła podobne cykle rozwoju — naj-
pierw pojawiła się w komputerach mainframe, a następnie zaczęto ją stosować w mini- i mikro-
komputerach. Pamięć wirtualna wprowadziła również możliwość dynamicznej konsolidacji
biblioteki w fazie działania programu, bez konieczności jej kompilacji. Pierwszym systemem,
w którym stało się to możliwe, był MULTICS. Ostatecznie idea uległa propagacji w dół i obec-
nie jest powszechnie używana w większości systemów UNIX i Windows.
W historii rozwoju wszystkich tych dziedzin widzimy idee, które powstały w jednym kon-
tekście, później je zarzucono, kiedy kontekst się zmienił (programowanie w asemblerze, jedno-
programowość, katalogi jednopoziomowe itp.), a ostatecznie pojawiły się ponownie, w innym
kontekście, często o dekadę później. Z tego względu w niniejszej książce będziemy czasami
zajmować się pomysłami i algorytmami, które dziś, w dobie wielogigabajtowych komputerów
osobistych, mogą wydawać się przestarzałe. Mogą one jednak powrócić w systemach wbudowa-
nych lub na kartach chipowych.
Pokazaliśmy, że systemy operacyjne spełniają dwie główne funkcje: dostarczają abstrakcji pro-
gramom użytkownika oraz zarządzają zasobami komputera. Większość interakcji pomiędzy pro-
gramami użytkownika a systemem operacyjnym — np. tworzenie, zapisywanie, czytanie i usu-
wanie plików — dotyczy pierwszej funkcji. Funkcja zarządzania zasobami jest w dużym stopniu
przezroczysta dla użytkowników i realizowana automatycznie. A zatem interfejs pomiędzy
programami użytkownika a systemem operacyjnym dotyczy przede wszystkim abstrakcji. Aby
naprawdę zrozumieć działania wykonywane przez systemy operacyjne, musimy dokładnie
przeanalizować ten interfejs. Wywołania systemowe dostępne w interfejsie są różne w różnych
systemach operacyjnych (choć pojęcia, które się pod nimi kryją, są podobne).
Jesteśmy zatem zmuszeni do dokonania wyboru pomiędzy (1) używaniem mglistych uogólnień
(„w systemach operacyjnych są wywołania systemowe do czytania plików”) oraz (2) posługi-
waniem się przykładem konkretnego systemu („w systemie UNIX jest wywołanie systemowe
read z trzema parametrami: jeden określa plik, drugi mówi, gdzie mają być umieszczone dane,
a trzeci informuje, ile bajtów ma być przeczytanych”).
W tej książce wybraliśmy to drugie podejście. Wiąże się z tym więcej pracy, ale w ten spo-
sób uzyskamy lepszy obraz tego, co faktycznie robi system operacyjny. Choć ta dyskusja jest
specyficzna dla POSIX (ISO/IEC 9945-1), a więc do Uniksa, Systemu V, BSD, Linuksa, MINIX 3
itp., większość nowoczesnych systemów operacyjnych oferuje wywołania systemowe realizu-
jące te same funkcje, choć różniące się szczegółami. Ponieważ mechanizm wydawania wywołań
systemowych jest w dużym stopniu zależny od maszyny i często musi być wyrażony w kodzie
asemblera, trzeba korzystać z biblioteki procedur. Dzięki temu można wydawać wywołania sys-
temowe z poziomu programów w języku C, a często także z poziomu innych języków.
względnego lub bezwzględnego adresu, pod którym jest umieszczona procedura. W zależności
od architektury instrukcja wykonuje skok do ustalonej lokalizacji — w instrukcji jest 8-bitowe
pole określające indeks tabeli w pamięci zawierającej adresy skoku.
Kod jądra, który zaczyna działać za instrukcją TRAP, sprawdza numer wywołania systemo-
wego, a następnie przesyła go do właściwej procedury obsługi wywołań systemowych — zwy-
kle za pośrednictwem tabeli wskaźników do procedur obsługi wywołań systemowych poindek-
sowanej według numeru wywołania systemowego (krok 7.). W tym momencie uruchamiane są
procedury obsługi wywołań systemowych (krok 8.). Kiedy procedura obsługi wywołania sys-
temowego zakończy pracę, sterowanie może być zwrócone do procedury bibliotecznej prze-
strzeni użytkownika — do następnej instrukcji za instrukcją TRAP (krok 9.). Następnie proce-
dura ta zwraca sterowanie do programu użytkownika w sposób, w jaki standardowo następuje
powrót sterowania z wywołań procedur (krok 10.).
W celu zakończenia zadania program użytkownika musi wyczyścić stos tak, jak po każdym
wywołaniu procedury (krok 11.). Przy założeniu, że stos rośnie od dołu tak, jak to często się
dzieje, skompilowany kod inkrementuje wskaźnik stosu dokładnie o taką wartość, jaka jest
potrzebna do usunięcia parametrów odłożonych na stos przed wywołaniem instrukcji read. Pro-
gram może teraz wykonywać dowolne dalsze instrukcje.
W kroku 9., opisywanym powyżej, nieprzypadkowo powiedzieliśmy: „może być zwrócone
do procedury bibliotecznej przestrzeni użytkownika”. Wywołanie systemowe może zabloko-
wać proces wywołujący i nie pozwolić na kontynuowanie jego działania. Jeśli np. wywołanie
systemowe próbuje czytać informacje z klawiatury, a użytkownik jeszcze nic nie wpisał, pro-
ces wywołujący musi zostać zablokowany. W takim przypadku system operacyjny sprawdza,
czy w następnej kolejności można uruchomić jakiś inny proces. Później, kiedy będą dostępne
pożądane dane wejściowe, system zajmie się zablokowanym procesem i zostaną wykonane
kroki 9. – 11.
Tabela 1.1. Niektóre z głównych wywołań systemowych POSIX. Jeśli zwrócony kod s
ma wartość –1, oznacza to, że wystąpił błąd. Znaczenie kodów powrotu jest następujące: pid
oznacza identyfikator procesu, fd to deskryptor pliku, n oznacza liczbę bajtów, position określa
przesunięcie wewnątrz pliku, a seconds to czas, który upłynął. Parametry zostały objaśnione
w tekście
Zarządzanie procesami
Wywołanie Opis
pid = fork() Tworzy proces potomny identyczny z procesem-rodzicem
pid = waitpid(pid, &statloc, options) Oczekuje na zakończenie procesu potomnego
s = execve(name, argv, environp) Uruchamia proces
exit(status) Kończy działanie procesu i zwraca jego stan
Zarządzanie plikami
Wywołanie Opis
fd = open(file, how, ...) Otwiera plik do odczytu, zapisu lub do odczytu i zapisu jednocześnie
s = close(fd) Zamyka otwarty plik
n = read(fd, buffer, nbytes) Odczytuje dane z pliku do bufora
n = write(fd, buffer, nbytes) Zapisuje dane z bufora do pliku
position = lseek(fd, offset, whence) Przesuwa wskaźnik pliku
s = stat(name, &buf) Odczytuje informacje dotyczące statusu pliku
Różne
Wywołanie Opis
s = chdir(dirname) Zmienia katalog roboczy
s = chmod(name,mode) Zmienia bity oznaczające prawa dostępu do pliku
s = kill(pid, signal) Wysyła sygnał do procesu
seconds = time(&seconds) Odczytuje czas, który upłynął od 1 stycznia 1970 roku
jest ustawiany na wartość statusu wyjścia procesu-dziecka (zakończenie w trybie zwykłym lub
nadzwyczajnym i kod wyjścia). Dostępne są również różne opcje, określone za pomocą trze-
ciego parametru, np. natychmiastowe zwrócenie sterowania, jeżeli żaden proces-dziecko jeszcze
nie zakończył działania.
Rozważmy teraz, w jaki sposób powłoka korzysta z wywołania fork. Po wpisaniu polecenia
powłoka, wykorzystując wywołanie fork, tworzy nowy proces. Utworzony proces-dziecko ma
za zadanie uruchomienie polecenia użytkownika. W tym celu wykorzystuje wywołanie syste-
mowe execve, które powoduje zastąpienie całego obrazu pamięci procesu (ang. core image) zawar-
gdzie argc oznacza liczbę elementów w wierszu polecenia włącznie z nazwą programu; np.
w powyższym przykładzie argument argc ma wartość 3.
Drugi parametr, argv, zawiera wskaźnik do tablicy. Element numer i w tej tablicy jest
wskaźnikiem do i-tego ciągu znaków w wierszu poleceń. W naszym przykładzie argv[0] wskazuje
na ciąg „cp”, argv[1] wskazuje na ciąg „plik1”, natomiast argv[2] wskazuje na ciąg „plik2”.
Trzeci parametr funkcji main — envp — to wskaźnik do tablicy zmiennych środowiskowych.
Zawiera ona pary postaci nazwa = wartość. Wykorzystuje się je do przekazywania do programów
takich informacji, jak typ terminala czy nazwa katalogu macierzystego. Dostępne są procedury
biblioteczne, które program może wywołać w celu uzyskania wartości zmiennych środowi-
skowych. Często wykorzystuje się je w celu dostosowania sposobu wykonywania określonych
działań do indywidualnych potrzeb użytkownika (np. domyślna drukarka). W kodzie z listingu 1.1
do procesu-dziecka nie przekazano żadnych zmiennych środowiskowych, dlatego trzecim para-
metrem wywołania execve jest zero.
Jeśli polecenie exec wydaje się Czytelnikowi zbyt skomplikowane, nie ma powodu do zała-
mywania się. Jest ono (semantycznie) najbardziej złożonym spośród wszystkich wywołań syste-
mowych POSIX. Wszystkie pozostałe są znacznie prostsze. Przykładem prostego wywołania
systemowego jest exit. Procesy wykorzystują je podczas kończenia swojego działania. Wywoła-
nie ma jeden parametr — status wyjścia (od 0 do 255) — wartość zwracaną do procesu-rodzica
za pomocą parametru statloc w wywołaniu systemowym waitpid.
Pamięć procesów w systemie UNIX jest podzielona na trzy segmenty: segment tekstu (tzn.
kod programu), segment danych (tzn. zmienne) i segment stosu. Segment danych rośnie w górę,
natomiast stos rośnie w dół, tak jak pokazano na rysunku 1.18. Pomiędzy nimi jest luka nie-
używanej przestrzeni adresowej. Stos wzrasta w sposób automatyczny, w miarę potrzeb, nato-
miast zwiększenie rozmiaru segmentu danych jest wykonywane jawnie za pomocą wywołania
systemowego brk. Wywołanie to określa nowy adres, w którym ma się zakończyć segment danych.
Wywołanie to nie jest jednak zdefiniowane przez standard POSIX. Programistom zaleca się
wykorzystanie procedury bibliotecznej malloc do dynamicznego zarządzania pamięcią. Imple-
mentację funkcji malloc uznano jednak za niezbyt nadającą się do standaryzacji, gdyż niewielu
programistów używa jej bezpośrednio. Poza tym mało osób wie o tym, że wywołanie brk nie jest
częścią standardu POSIX.
który określa w nim bieżącą pozycję. Podczas sekwencyjnego odczytu (zapisu) zwykle wska-
zuje on na następny bajt do odczytania (zapisania). Wywołanie lseek zmienia wartość pozycji
wskaźnika, dzięki czemu kolejne wywołania read lub write mogą się rozpocząć w dowolnym
miejscu pliku.
Polecenie lseek ma trzy parametry: pierwszy oznacza deskryptor pliku, drugi pozycję pliku,
a trzeci informuje o tym, czy pozycja pliku jest oznaczona względem początku pliku, pozycji
bieżącej, czy końca pliku. Wartość zwracana przez lseek to bezwzględna pozycja w pliku (wyra-
żona w bajtach) po zmianie wskaźnika.
System UNIX dla każdego pliku śledzi jego tryb (plik zwykły, specjalny, katalog itd.), roz-
miar, czas ostatniej modyfikacji oraz inne informacje. Programy mogą żądać tych informacji za
pomocą wywołania systemowego stat. Pierwszy parametr określa plik do inspekcji, drugi jest
wskaźnikiem do struktury, w której mają być umieszczone informacje. Tę samą operację dla
otwartego pliku przeprowadzają wywołania fstat.
to plik nota w katalogu jerzy trafi do katalogu adam pod nazwą komunikat. Po wykonaniu tej
operacji ścieżki /usr/jerzy/nota i /usr/adam/komunikat będą odwoływać się do tego samego pliku.
Na marginesie dodajmy, że decyzja o tym, czy katalogi użytkowników będą przechowywane
w katalogu /usr, /user, /home, czy gdzieś indziej, należy do lokalnego administratora systemu.
Rysunek 1.19. (a) Dwa katalogi przed wykonaniem wywołania link pliku /usr/jerzy/nota do katalogu
użytkownika adam; (b) te same katalogi po wykonaniu operacji
Spróbujmy przyjrzeć się nieco bliżej sposobowi działania operacji link. Każdy plik w sys-
temie UNIX ma przypisany unikatowy numer — tzw. i-numer, który identyfikuje plik. Numer
ten jest indeksem do tabeli i-węzłów, zawierającej po jednym wpisie na plik. Informują one, kto
jest właścicielem pliku, gdzie znajdują się bloki na dysku itd. Katalog jest po prostu plikiem
zawierającym zbiór par (i-numer, nazwa ASCII). W pierwszych wersjach Uniksa każda pozycja
katalogu miała 16 bajtów — 2 bajty były przeznaczone na i-węzeł oraz 14 bajtów na nazwę pliku.
Obecnie do obsługi długich nazw plików jest wymagana bardziej złożona struktura, ale poję-
ciowo katalog w dalszym ciągu jest zbiorem par (i-węzeł, nazwa ASCII). Na rysunku 1.19 plik
poczta ma i-numer równy 16. Wywołanie link tworzy w katalogu nowy wpis zawierający nazwę
ASCII (która może być różna od wyjściowej) oraz i-numer istniejącego pliku. Na rysunku 1.19(b)
dwóm wpisom odpowiada ten sam i-numer (70), a zatem odnoszą się one do tego samego pliku.
Jeśli dowolny z nich zostanie później usunięty za pomocą wywołania systemowego unlink, drugi
pozostanie. Jeżeli oba będą usunięte, UNIX zobaczy, że nie istnieją żadne wpisy związane z pli-
kiem (pole w i-węźle śledzi liczbę wpisów w katalogach wskazujących na plik), a zatem plik zostaje
usunięty z dysku.
Jak wspominaliśmy wcześniej, wywołanie systemowe mount pozwala na połączenie dwóch
systemów plików w jeden. W typowej konfiguracji na twardym dysku (partycji) znajduje się główny
system plików zawierający binarne (wykonywalne) wersje popularnych poleceń oraz innych,
często używanych plików, natomiast pliki użytkownika są zapisane na innej partycji. Ponadto
użytkownik może włożyć dysk USB z plikami do odczytania.
Dzięki wywołaniu systemowemu mount system plików na dysku USB można dołączyć do
głównego systemu plików, tak jak pokazano na rysunku 1.20. Typowa instrukcja w języku C,
która realizuje operację mount, ma następującą postać:
mount("/dev/sdb0", "/mnt", 0);
Rysunek 1.20. (a) System plików przed wykonaniem instrukcji mount. (b) System plików
po wykonaniu operacji mount
Pierwszy parametr oznacza nazwę blokowego pliku specjalnego dla napędu 0, drugi ozna-
cza miejsce w drzewie, gdzie ma on być zamontowany, natomiast trzeci informuje, czy system
plików ma być zamontowany w trybie odczytu-zapisu, czy w trybie tylko do odczytu.
Po wywołaniu polecenia mount można uzyskać dostęp do pliku na dysku 0 poprzez posłuże-
nie się jego ścieżką z katalogu głównego lub katalogu roboczego, bez względu na to, na którym
dysku się on znajduje. Co więcej, w dowolnym miejscu drzewa mogą być zamontowane także
drugi, trzeci i czwarty napęd. Wywołanie mount umożliwia integrację wymiennych nośników
danych w pojedynczą, zintegrowaną hierarchię plików, której użytkownik nie musi się przejmo-
wać, na którym urządzeniu znajduje się plik. Choć omawiany przykład dotyczy płyt CD, w taki
sam sposób można montować części dysków twardych (często nazywane partycjami), a także
zewnętrzne dyski twarde, dyski pendrive itp. Kiedy system plików przestaje być potrzebny, można
go odmontować za pomocą wywołania systemowego umount.
operacja open wykonana w odniesieniu do pliku xyz spowoduje otwarcie pliku /usr/ ast/test/xyz.
Pojęcie katalogu roboczego eliminuje potrzebę ciągłego wpisywania (długich) bezwzględnych
nazw ścieżek.
W systemie UNIX każdemu plikowi jest przypisany tryb, który spełnia rolę zabezpieczeń. Tryb
zawiera bity opisujące prawa do odczytu, zapisu i uruchamiania dla właściciela, grupy i pozo-
stałych. Wywołanie systemowe chmod umożliwia zmianę trybu pliku. I tak, aby plik był dostępny
tylko do odczytu dla wszystkich oprócz właściciela, można skorzystać z następującego polecenia:
chmod("plik", 0644);
Tabela 1.2. Wywołania Win32 API, które w przybliżeniu odpowiadają wywołaniom systemu
UNIX zaprezentowanym w tabeli 1.1. Warto podkreślić, że w systemie Windows istnieje bardzo
dużo innych wywołań systemowych, z których większość nie posiada odpowiedników w Uniksie
I ostatnia uwaga dotycząca interfejsu Win32: nie jest to zbyt jednolity ani spójny interfejs.
Główną przesłanką podczas jego tworzenia było zachowanie zgodności wstecz z 16-bitowym
interfejsem stosowanym w systemach Windows 3.x.
Teraz, kiedy zobaczyliśmy, jak wyglądają systemy operacyjne z zewnątrz (tzn. interfejs pro-
gramisty), nadszedł czas, by zajrzeć do środka. Aby uzyskać możliwie pełny obraz możliwości,
w kolejnych punktach omówimy sześć różnych struktur systemów operacyjnych, które były
stosowane w historii. Nie jest to w żadnym razie pełny zbiór, ale daje wyobrażenie o projektach,
które stosowano w praktyce. Sześć wspomnianych projektów to systemy monolityczne, war-
stwowe, mikrojądra, klient-serwer, maszyny wirtualne i egzojądra.
W takim modelu dla każdego wywołania systemowego istnieje jedna procedura obsługi, która
je realizuje. Procedury narzędziowe wykonują działania wymagane przez niektóre procedury
usługowe — np. pobieranie danych z programów użytkowników. Podział procedur na trzy war-
stwy pokazano na rysunku 1.21.
Warstwa Funkcja
5 Operator
4 Programy użytkownika
3 Zarządzanie wejściem-wyjściem
2 Komunikacja pomiędzy operatorem a procesami
1 Zarządzanie pamięcią główną i bębnową
0 Przydział procesora i wieloprogramowość
strumieni informacji przepływających pomiędzy nimi. Powyżej warstwy 3 każdy proces mógł
posługiwać się abstrakcyjnymi urządzeniami wejścia-wyjścia z wygodnymi właściwościami
zamiast urządzeniami fizycznymi z wieloma osobliwościami. W warstwie 4 działały programy użyt-
kownika. Programy te nie musiały przejmować się zarządzaniem procesami, pamięcią, konsolą
czy też operacjami wejścia-wyjścia. Proces operatora systemu był umieszczony w warstwie 5.
Dalsze uogólnienie koncepcji warstw zastosowano w systemie MULTICS. Zamiast warstw
użyto w nim pojęcia pierścieni. System był złożony z szeregu koncentrycznych pierścieni.
Wewnętrzne były bardziej uprzywilejowane od zewnętrznych (co jest równoznaczne ze strukturą
warstw występującą w systemie THE). Kiedy procedura znajdująca się na zewnętrznym pier-
ścieniu chciała wywołać procedurę w pierścieniu wewnętrznym, musiała wykonać odpowied-
nik wywołania systemowego — tzn. instrukcję TRAP. Przed faktyczną realizacją wywołania były
uważnie sprawdzane jej parametry pod kątem poprawności. Chociaż w systemie MULTICS
cały system operacyjny był częścią przestrzeni adresowej każdego z procesów użytkownika,
sprzęt pozwalał na wyznaczanie indywidualnych procedur (właściwie segmentów pamięci) jako
zabezpieczonych przed czytaniem, zapisywaniem lub uruchamianiem.
O ile schemat warstw występujący w systemie THE był w rzeczywistości jedynie pomocą
projektową, ponieważ wszystkie części systemu były ze sobą połączone w pojedynczy program
wykonywalny, o tyle w systemie MULTICS mechanizm pierścieni występował także w fazie
działania programu i był wymuszany przez sprzęt. Zaleta mechanizmu pierścieni polega na tym,
że można go łatwo rozszerzyć na strukturę podsystemów użytkowników; np. profesor może
napisać program do testowania i oceniania programów studentów i uruchomić go w pierścieniu
n, natomiast programy studentów działają w pierścieniu n+1, dzięki czemu studenci nie mogą
zmieniać swoich ocen.
1.7.3. Mikrojądra
W systemach o budowie warstwowej, projektanci mogli zdecydować, gdzie należy wykreślić
granicę jądro – użytkownik. Tradycyjnie wszystkie warstwy były umieszczane w jądrze, ale to
nie było konieczne. W rzeczywistości można postarać się o to, aby w trybie jądra znalazło się
jak najmniej funkcji, ponieważ błędy w jądrze mogą przyczynić się do natychmiastowej awarii
całego systemu. Dla kontrastu procesy użytkownika można skonfigurować tak, by miały mniej-
sze możliwości. Dzięki temu występujące w nich błędy nie muszą być krytyczne.
Przeprowadzano wiele badań dotyczących liczby błędów przypadających na 1000 wierszy
kodu, np. [Basili i Perricone, 1984], [Ostrand i Weyuker, 2002]. Gęstość błędów zależy od roz-
miaru modułu, jego wieku oraz innych czynników, jednak w systemach przemysłowych przyj-
muje się wartość 10 błędów na 1000 wierszy kodu. Oznacza to, że w monolitycznym systemie
operacyjnym składającym się z 5 milionów wierszy kodu może występować około 50 tysięcy
błędów jądra. Oczywiście nie wszystkie są krytyczne. Niektóre błędy mogą dotyczyć np. wyświe-
tlania nieprawidłowego komunikatu o błędzie w rzadko występującej sytuacji. Niemniej jednak
systemy operacyjne są wystarczająco awaryjne, aby producenci komputerów wyposażyli je w przy-
ciski Reset (często na przednim panelu). Zwróćmy uwagę, że nie zdecydowali się na to produ-
cenci odbiorników telewizyjnych, zestawów audio czy samochodów, mimo że na tych urządze-
niach działa wiele oprogramowań.
Podstawowa idea projektu mikrojądra to dążenie do osiągnięcia wysokiej niezawodności
poprzez podzielenie systemu operacyjnego na niewielkie, dobrze zdefiniowane moduły, z których
tylko jeden — mikrojądro — działa w trybie jądra, natomiast pozostałe działają jako zwykłe
procesy użytkownika o relatywnie małych możliwościach. W szczególności dzięki uruchomie-
niu każdego sterownika urządzenia i systemu plików jako oddzielnych procesów użytkownika,
błąd w jednym z nich może doprowadzić do awarii tego komponentu, ale nie jest w stanie dopro-
wadzić do awarii całego systemu. A zatem błąd w sterowniku dźwięku spowoduje zniekształ-
cenia dźwięku lub jego zanik, ale nie spowoduje awarii komputera. Dla kontrastu w systemach
monolitycznych, w których wszystkie sterowniki działają w jądrze, błąd w sterowniku dźwię-
kowym może spowodować odwołanie do nieprawidłowego adresu w pamięci i doprowadzić do
natychmiastowego zatrzymania systemu.
Przez dziesięciolecia zaimplementowano i wdrożono wiele systemów operacyjnych o struk-
turze mikrojąder ([Accetta et al., 1986], [Haertig et al., 1997], [Heiser et al., 2006], [Herder et al.,
2006], [Hildebrand, 1992], [Kirsch et al., 2005], [Liedtke, 1993, 1995, 1996], [Pike et al., 1992],
[Zuberi et al., 1999]). Z wyjątkiem systemu OS X, który bazuje na mikrojądrze Mach [Accetta
et al., 1986], w popularnych systemach operacyjnych komputerów typu desktop mikrojądra nie
są używane. Jednakże występują one bardzo często w aplikacjach czasu rzeczywistego, prze-
myśle, lotnictwie oraz aplikacjach wojskowych o kluczowym znaczeniu i bardzo wysokich wyma-
ganiach w zakresie niezawodności. Kilka spośród bardziej znanych systemów o strukturze mikro-
jądra to Integrity, K42, L4, PikeOS, QNX, Symbian i MINIX 3. Poniżej zwięźle opiszemy system
MINIX 3, w którym do granic możliwości wykorzystano modularność — większą część systemu
operacyjnego podzielono na szereg niezależnych procesów działających w trybie użytkownika.
MINIX 3 jest zgodny ze standardem POSIX i dostępny za darmo (razem z kompletnym kodem
źródłowym) w internecie, pod adresem www.minix3.org ([Giuffrida et al., 2012], [Giuffrida et al.,
2013], [Herder et al., 2006], [Herder et al., 2009], [Hruby et al., 2013]).
Mikrojądro systemu MINIX 3 składa się tylko z około 3200 wierszy kodu w języku C oraz
800 wierszy kodu asemblera. Ten ostatni wykorzystano do implementacji niskopoziomowych
funkcji, takich jak przechwytywanie przerwań i przełączanie procesów. Kod w języku C wyko-
rzystano do zarządzania i szeregowania procesów, obsługi komunikacji między procesami (poprzez
przesyłanie pomiędzy nimi komunikatów). W kodzie tym zaimplementowano również około 40
wywołań jądra, które umożliwiają wykonywanie zadań pozostałej części systemu operacyjnego.
Wywołania te realizują takie funkcje jak przypisywanie procedur obsługi do przerwań, przeno-
szenie danych pomiędzy przestrzeniami adresowymi oraz instalowanie map pamięci dla nowych
procesów. Strukturę procesów w systemie MINIX 3 pokazano na rysunku 1.22. Procedury obsługi
wywołań jądra oznaczono etykietą Sys. Sterownik urządzenia dla zegara również znajduje się
w jądrze, ponieważ mechanizm szeregowania ściśle z nim współpracuje. Wszystkie pozostałe
sterowniki urządzeń działają jako oddzielne procesy użytkowników.
Poza jądrem system ma strukturę trzech warstw procesów. Wszystkie one działają w trybie
użytkownika. Najniższa warstwa zawiera sterowniki urządzeń. Ponieważ działają one w trybie
użytkownika, nie mają fizycznego dostępu do przestrzeni portów wejścia-wyjścia i nie mogą
Ostatnio rośnie liczba systemów, w których użytkownicy posługują się swoimi domowymi
komputerami PC jako klientami, natomiast duże maszyny działające w trybie zdalnym speł-
niają rolę serwerów. W ten sposób działa większość systemów w internecie. Komputer PC
wysyła żądanie strony WWW do serwera. W odpowiedzi serwer przesyła żądaną stronę. Jest to
typowe zastosowanie modelu klient-serwer w sieci.
duży i zbyt wolny. W związku z tym zaledwie kilka ośrodków zdecydowało się na przejście na
ten system. Ostatecznie wycofano się z tego projektu w momencie, gdy koszty prac nad nim
pochłonęły około 50 milionów dolarów [Graham, 1970]. Jednak w należącym do IBM ośrodku
Scientific Center w Cambridge w stanie Massachusetts opracowano zupełnie odmienny sys-
tem, który firma IBM ostatecznie zaakceptowała jako swój produkt. Liniowy potomek tego
systemu, znany pod nazwą z/VM, jest obecnie powszechnie używany we współczesnych kompute-
rach mainframe firmy IBM — maszynach zSeries. Są one używane w wielu dużych ośrodkach
obliczeniowych, np. w roli serwerów e-commerce obsługujących setki lub tysiące transakcji na
sekundę i korzystających z baz danych o rozmiarach rzędu milionów gigabajtów.
VM/370
System ten, pierwotnie znany jako CP/CMS i później przemianowany na VM/370 [Seawright
i MacKinnon, 1979], bazował na prostej obserwacji: system z podziałem czasu udostępnia
(1) funkcje wieloprogramowości oraz (2) rozszerzoną maszynę z wygodniejszym interfejsem
od tego, który oferuje sprzęt. Sedno systemu VM/370 polega na całkowitym odseparowaniu tych
dwóch funkcji.
Serce systemu — monitor maszyny wirtualnej — działa bezpośrednio na sprzęcie i reali-
zuje funkcję wieloprogramowości, dostarczając do wyższej warstwy (rysunek 1.24) nie jednej,
ale kilku maszyn wirtualnych. Jednak w odróżnieniu od wszystkich innych systemów opera-
cyjnych te maszyny wirtualne nie są maszynami rozszerzonymi z plikami i innymi wygodnymi
mechanizmami. Zamiast tego są to dokładne kopie czystego sprzętu, włącznie z trybami jądra
i użytkownika, wejściem-wyjściem, przerwaniami i wszystkim, w co jest wyposażona maszyna
fizyczna.
Ponieważ każda maszyna wirtualna jest identyczna z fizycznym sprzętem, na każdej może
działać dowolny system operacyjny zdolny do działania na maszynie fizycznej. Na różnych maszy-
nach wirtualnych mogą działać różne systemy operacyjne (i często tak właśnie jest). W orygi-
nalnym systemie VM/370 na niektórych maszynach wirtualnych działał system OS/360 lub jeden
z innych dużych wsadowych systemów operacyjnych albo systemów przetwarzania transakcji,
natomiast na innych działał jednoużytkownikowy, interaktywny system o nazwie CMS (Conver-
sational Monitor System), przeznaczony do interaktywnej obsługi użytkowników systemów
z podziałem czasu. Ten drugi system był popularny wśród programistów.
Kiedy program CMS uruchamiał wywołanie systemowe, było ono przechwytywane przez
system operacyjny w jego własnej maszynie wirtualnej, a nie przez system VM/370 — tak jakby
program działał na maszynie fizycznej, a nie wirtualnej. Następnie system CMS wydawał nor-
malne sprzętowe instrukcje wejścia-wyjścia do czytania dysku wirtualnego lub wykonania innych
operacji niezbędnych do realizacji wywołania. Te instrukcje wejścia-wyjścia były przechwyty-
wane przez system VM/370, który następnie wykonywał je jako część symulacji fizycznego
Rysunek 1.25. (a) Hipernadzorca typu 1; (b) czysty hipernadzorca typu 2; (c) hipernadzorca typu 2
w praktyce
monitor maszyny wirtualnej, tak by instrukcje mogły być emulowane programowo. W niektó-
rych procesorach — w tym w procesorze Pentium, jego poprzednikach i klonach — próby
uruchamiania uprzywilejowanych instrukcji w trybie użytkownika są ignorowane. W związku
z tą cechą uruchamianie maszyn wirtualnych na tym sprzęcie było niemożliwe, co wyjaśnia brak
zainteresowania wirtualizacją w świecie komputerów x86. Oczywiście były interpretery dla
procesora Pentium działające w systemach Pentium (np. Bochs), ale z powodu obniżonej wydaj-
ności nie nadawały się do poważnej pracy.
Ta sytuacja zmieniła się w efekcie kilku akademickich projektów badawczych prowadzo-
nych w latach dziewięćdziesiątych; w szczególności znaczące efekty przyniósł projekt Disco
prowadzony na Uniwersytecie Stanforda [Bugnion et al., 1997], a także projekt Xen prowadzony
na Uniwersytecie Cambridge [Barham et al., 2003]. Doprowadziły one do powstania produktów
komercyjnych (np. VMware Workstation i Xen) oraz odrodzenia się zainteresowania maszy-
nami wirtualnymi. Oprócz systemów VMware i Xen popularnymi systemami typu hipernadzorca
są dziś KVM (dla jądra Linux), VirtualBox (firmy Oracle) oraz Hyper-V (firmy Microsoft).
W wyniku przeprowadzenia niektórych spośród tych wczesnych projektów badawczych
poprawiła się wydajność takich interpreterów jak Bochs. Poprawę osiągnięto dzięki tłumacze-
niu bloków kodu „w locie”, zapisywaniu ich w wewnętrznej pamięci podręcznej, a następnie
ponownym ich wykorzystywaniu, w przypadku gdy były ponownie uruchamiane. Poprawa wydaj-
ności była na tyle znacząca, że powstały tzw. symulatory maszyn (rysunek 1.25(b)). Chociaż zasto-
sowanie tej techniki (znanej jako tłumaczenie binarne — ang. binary translation) poprawiło
sytuację, to uzyskane w ten sposób systemy, choć były wystarczająco dobre, by publikować
dokumenty na ich temat na konferencjach naukowych, wciąż nie działały na tyle szybko, aby
korzystać z nich w środowiskach komercyjnych, w których wydajność odgrywa kluczową rolę.
Kolejnym krokiem na drodze do poprawy wydajności było dodanie modułu jądra, którego
zadaniem było wykonanie „zgrubnego liftingu”; jego miejsce zaprezentowano na rysunku 1.25(c).
W praktyce wszystkie współcześnie dostępne na rynku systemy typu hipernadzorca, takie jak
VMware Workstation, korzystają z opisanej strategii hybrydowej (a także z wielu innych ulepszeń).
Wszyscy nazywają je hipernadzorcami typu 2, dlatego (z pewną niechęcią) również będziemy
używać tej nazwy w dalszej części książki. Wolelibyśmy nazywać je hipernadzorcami typu 1.7,
ponieważ ta nazwa odzwierciedla fakt, że nie są one w pełni programami trybu użytkownika.
Szczegółowy opis działania systemu VMware Workstation oraz jego części zamieszczono
w rozdziale 7.
W praktyce prawdziwą różnicę pomiędzy hipernadzorcą typu 1 a hipernadzorcą typu 2 jest
to, że hipernadzorca typu 2 do tworzenia procesów, zapisywania plików itp. wykorzystuje sys-
tem operacyjny gospodarza i jego system plików. Hipernadzorca typu 1 nie ma takiego wsparcia
i wszystkie te operacje musi realizować samodzielnie.
Po uruchomieniu hipernadzorca typu 2 czyta instalacyjną płytę CD-ROM (lub obraz płyty)
wybranego systemu operacyjnego-gościa i instaluje wirtualny dysk, który jest po prostu dużym
plikiem w systemie plików systemu operacyjnego gospodarza. Hipernadzorca typu 1 nie może
tego zrobić, ponieważ nie ma dostępu do systemu operacyjnego gospodarza, gdzie mógłby
przechowywać pliki. Musi samodzielnie zarządzać własną pamięcią masową na surowej party-
cji dyskowej.
Podczas rozruchu system operacyjny gościa wykonuje te same czynności, które wykonuje
fizyczny sprzęt, zwykle uruchamia pewne procesy tła, a następnie interfejs GUI. Z punktu widze-
nia użytkownika system operacyjny gościa zachowuje się tak samo, jakby działał na maszynie
fizycznej, mimo że w tym przypadku tak nie jest.
Innym sposobem postępowania z instrukcjami sterującymi jest zmodyfikowanie systemu ope-
racyjnego w taki sposób, by były usuwane. Takie podejście nie jest rzeczywistą wirtualizacją,
ale parawirtualizacją. Wirtualizację opiszemy bardziej szczegółowo w rozdziale 7.
1.7.6. Egzojądra
Zamiast klonować maszynę fizyczną, tak jak to się robi w przypadku maszyn wirtualnych, można
zastosować inną strategię — jej podział — czyli mówiąc inaczej, przydzielić każdemu użyt-
kownikowi podzbiór zasobów. W ten sposób jedna maszyna wirtualna może uzyskać bloki dysku
od 0 do 1023, następna bloki od 1024 do 2047 itd.
W najniższej warstwie, która działa w trybie jądra, jest program znany jako egzojądro [Engler
et al., 1995]. Jego zadaniem jest przydział zasobów do maszyn wirtualnych, a następnie czuwanie
nad ich właściwym używaniem, tak by żadna z maszyn wirtualnych nie mogła używać zasobów,
które do niej nie należą. Na każdej maszynie wirtualnej poziomu użytkownika może działać
osobny system operacyjny, tak jak w systemie VM/370 oraz w wirtualnych maszynach 8086
w systemie Pentium, z tą różnicą, że każda z nich może używać tylko tych zasobów, które zostały
jej przydzielone.
Zaletą struktury egzojądra jest to, że nie wymaga ona stosowania warstwy mapowania.
W innych architekturach każda maszyna wirtualna zachowuje się tak, jakby miała własny dysk,
z blokami od zera do pewnego maksimum. W związku z tym monitor maszyny wirtualnej musi
1.8.1. Język C
Niniejszy punkt nie jest przewodnikiem po języku C, ale krótkim zestawieniem najważniej-
szych różnic pomiędzy nim a takimi językami jak Python oraz przede wszystkim Java. Java bazuje
na języku C, zatem pomiędzy tymi dwoma językami jest wiele podobieństw. Python jest nieco
inny, ale wciąż dość podobny. Dla wygody skoncentrujemy się na Javie. Java, Python i C są języ-
kami imperatywnymi, w których występują typy danych, zmienne i instrukcje sterujące. Ele-
mentarne typy danych występujące w języku C to liczby całkowite (integer) — w tym krótkie
(short) i długie (long) — znaki (char) oraz liczby zmiennoprzecinkowe (float). Można również
tworzyć złożone typy danych za pomocą tablic, struktur i unii. Instrukcje sterujące w języku C
są podobne do tych w Javie. Dostępne są instrukcje if, switch, for i while. Funkcje i parametry
są w przybliżeniu takie same w obydwu językach.
Jedną z własności, które występują w języku C, ale nie występują w Javie i Pythonie, są
jawne wskaźniki. Wskaźnik jest zmienną, która wskazuje (tzn. zawiera adres) zmienną lub struk-
turę danych. Przeanalizujmy poniższe instrukcje:
char c1, c2, *p;
c1 = ’x’;
p = &c1;
c2 = *p;
Instrukcje te deklarują zmienne c1 i c2 jako zmienne znakowe oraz p jako zmienną wskazującą
na znak (tzn. zawierającą jego adres). Pierwsza operacja przypisania powoduje zapisanie kodu
ASCII znaku c w zmiennej c1. Druga przypisuje adres zmiennej c1 do zmiennej wskaźnikowej p.
Trzecia przypisuje zawartość zmiennej wskazywanej przez p do zmiennej c2. Tak więc po wyko-
naniu tych instrukcji zmienna c2 także zawiera kod ASCII litery c. Teoretycznie wskaźniki
mają przypisane typy, zatem nie powinno się przypisywać adresu liczby zmiennoprzecinkowej
do wskaźnika zmiennej znakowej, ale w praktyce kompilatory akceptują takie przypisania, choć
czasami generują przy tym ostrzeżenie. Wskaźniki są konstrukcją, która ma bardzo duże możli-
wości, ale która może stać się źródłem błędów w przypadku nieostrożnego posługiwania się nimi.
Do elementów, których nie ma w języku C, można zaliczyć wbudowane ciągi znaków, wątki,
pakiety, klasy obiekty, bezpieczeństwo typów oraz tzw. „odzyskiwanie pamięci” (ang. garbage
collection). Ten ostatni mechanizm w kontekście systemów operacyjnych zasługuje na szcze-
gólną uwagę. Cała pamięć w języku C albo jest statyczna, albo jest jawnie przydzielana i zwal-
niana przez programistę — zazwyczaj za pomocą funkcji malloc i free. To właśnie całkowita
kontrola programisty nad pamięcią w połączeniu z jawnymi wskaźnikami powoduje, że język C
jest atrakcyjnym narzędziem pisania systemów operacyjnych. Systemy operacyjne, nawet te
ogólnego przeznaczenia, są do pewnego stopnia systemami czasu rzeczywistego. Kiedy zacho-
dzi przerwanie, system operacyjny ma zaledwie kilka mikrosekund na wykonanie pewnych
działań. Jeśli tego nie zrobi, straci kluczowe informacje. Zezwolenie na to, aby mechanizm odśmie-
cania zaczynał działać w dowolnym momencie, jest niedopuszczalne.
umożliwiające programistom nadawanie nazw stałym. Dzięki temu, jeśli w kodzie zostanie
użyta nazwa ROZMIAR_BUFORA, będzie ona zastąpiona podczas kompilacji wartością 4096. Dobrą
praktyką programowania w języku C jest nadawanie nazw wszystkim stałym, poza 0, 1 i −1.
Czasami nazwy są nadawane także tym wartościom. Makra mogą mieć parametry, np.:
#define max(a, b) (a > b ? a : b)
to otrzyma:
i = (j > k+1 ? j : k+1)
Powyższy kod kompiluje się do wywołania funkcji intel_int_ack, jeśli zdefiniowano makro
PENTIUM. Jeżeli tego makra nie zdefiniowano, jest zastępowany pustą instrukcją. Kompilację
warunkową często wykorzystuje się w celu wyizolowania kodu zależnego od architektury.
W ten sposób określony kod wstawia się tylko wtedy, gdy system jest kompilowany w kompute-
rze Pentium, inny — gdy kompilujemy system w architekturze SPARC itd. Do pliku .c można
włączać pliki nagłówkowe za pomocą dyrektywy #include. Istnieje również wiele plików nagłów-
kowych wspólnych prawie dla wszystkich plików .c. Są one zapisane w centralnym katalogu.
Rysunek 1.26. Proces kompilacji języka C i plików nagłówkowych w celu utworzenia pliku
wykonywalnego
inicjowany określonymi wartościami, ale może się zmienić, a jego rozmiary w miarę potrzeb
mogą wzrosnąć. Stos początkowo jest pusty, ale rozrasta się i kurczy, w miarę jak są wywoły-
wane funkcje, a następnie sterowanie powraca do procesu wywołującego. Często segment tekstu
zostaje umieszczony w pobliżu dolnej części pamięci, segment danych znajduje się bezpośred-
nio powyżej z możliwością rozrastania się w górę, a segment stosu jest umieszczony pod wy-
sokimi adresami wirtualnymi z możliwością rozrastania się w dół. Jest to jednak tylko przykła-
dowa konfiguracja, a różne systemy działają w różny sposób.
We wszystkich przypadkach kod systemu operacyjnego jest bezpośrednio wykonywany przez
sprzęt. Nie ma interpretera ani dynamicznej kompilacji, tak jak w Javie.
Informatyka jest dynamicznie rozwijającą się dziedziną. Trudno przewidzieć, dokąd zmierza.
Naukowcy na uniwersytetach i w przemysłowych laboratoriach badawczych przez cały czas
opracowują nowe pomysły. Z niektórych nic nie wynika, ale inne stanowią podwaliny przyszłych
produktów i mają olbrzymi wpływ na branżę i użytkowników. Rozróżnienie jednych od drugich
jest łatwiejsze po fakcie niż w czasie rzeczywistym. Oddzielenie ziarna od plew okazuje się
szczególnie trudne, ponieważ rozwinięcie niektórych pomysłów często zajmuje nawet 20 – 30 lat.
Kiedy np. prezydent Eisenhower powołał w 1958 roku agencję ARPA (Advanced Research
Projects Agency), chciał nie dopuścić do zrujnowania marynarki wojennej i lotnictwa z powodu sum,
jakie Pentagon wydawał na prace badawcze. Nie miał na celu wynajdywania internetu. Jednak
jednym z przedsięwzięć, które zrealizowała agencja ARPA, było sfinansowanie kilku uniwer-
sytetom badań dotyczących wtedy bardzo mglistego pojęcia przełączania pakietów. W rezulta-
cie powstała pierwsza, eksperymentalna sieć z przełączaniem pakietów — ARPANET. Sieć
ARPANET zaczęła działać w 1969 roku. Nie minęło zbyt wiele czasu, a do sieci ARPANET przy-
łączono inne sieci badawcze finansowane przez agencję ARPA i tak narodził się Internet. Przez
następnych 20 lat był on używany przez naukowców akademickich do przesyłania do siebie
wiadomości e-mail. W początkach lat dziewięćdziesiątych Tim Berners-Lee z laboratorium CERN
w Genewie opracował sieć WWW, a Marc Andreesen z Uniwersytetu w Illinois napisał dla niej
graficzną przeglądarkę. Nagle Internet zapełnił się gawędzącymi ze sobą nastolatkami. Prezy-
dent Eisenhower prawdopodobnie przewraca się w grobie.
Badania związane z systemami operacyjnymi doprowadziły również do dramatycznych zmian
w praktycznych systemach. Jak powiedzieliśmy wcześniej, wszystkie pierwsze komercyjne
systemy komputerowe stanowiły systemy wsadowe. Było tak aż do chwili wynalezienia w MIT
w początkach lat sześćdziesiątych interaktywnych systemów z podziałem czasu. Komputery
były tekstowe, aż do chwili, kiedy Doug Engelbart wynalazł mysz i graficzny interfejs użytkow-
nika w Stanford Research Institute w końcu lat sześćdziesiątych. Kto wie, co wydarzy się dalej?
W tym podrozdziale oraz w podobnych do niego częściach niniejszej książki będziemy się
przyglądać niektórym badaniom związanym z systemami operacyjnymi, które miały miejsce
w ciągu ostatnich 5 – 10 lat. W ten sposób uzyskamy obraz tego, co może pojawić się na hory-
zoncie. To wprowadzenie nie jest oczywiście wyczerpujące i bazuje przede wszystkich na arty-
kułach opublikowanych w najważniejszych magazynach i na konferencjach. To dlatego, że pomysły
te, aby mogły zostać opublikowane, musiały co najmniej przejść przez rygorystyczny proces
korekty. Zwróćmy uwagę, że w branży komputerowej — inaczej niż w innych dziedzinach nauko-
wych — większość badań jest publikowana na konferencjach, a nie w czasopismach. Znaczna
część artykułów cytowanych w podrozdziałach poświęconych badaniom została opublikowana
przez instytucje ACM, IEEE Computer Society lub USENIX. Są one dostępne przez internet dla
członków tych organizacji (studentów). Więcej informacji na temat wymienionych instytucji
i ich cyfrowych bibliotek można znaleźć pod adresami:
ACM http://www.acm.org/
IEEE Computer Society http://www.computer.org/
USENIX http://www.usenix.org/
Niemal wszyscy naukowcy zajmujący się systemami operacyjnymi zdają sobie sprawę z tego,
że współczesne systemy operacyjne są rozbudowane, nieelastyczne, zawodne, niezabezpie-
czone i roją się od błędów — przy czym niektóre w większym stopniu niż pozostałe (nazwy
usunięto, aby zapobiec oskarżeniom). W konsekwencji istnieje wiele poglądów na to, jak zbudo-
wać lepsze systemy operacyjne. Niedawno opublikowano prace dotyczące m.in. takich tematów
jak: błędy i debugowanie ([Renzelmann et al., 2012], [Zhou et al., 2012]), odtwarzanie po awarii
([Correia et al., 2012], [Ma et al., 2013], [Ongaro et al., 2011], [Yeh i Cheng, 2012]), zarządzanie
energią ([Pathak et al., 2012], [Petrucci i Loques, 2012], [Shen et al., 2013]), systemy plików
i pamięć masowa ([Elnably i Wang, 2012], [Nightingale et al., 2012], [Zhang et al., 2013a]), wysoka
wydajność operacji wejścia-wyjścia ([de Bruijn et al., 2011], [Li et al., 2013a], [Rizzo, 2012]),
hiperwątkowość i wielowątkowość ([Liu et al., 2011]), aktualizacja „na żywo” ([Giuffrida et al.,
2013]), zarządzanie układami GPU ([Rossbach et al., 2011]), zarządzanie pamięcią ([Jantz et
al., 2013], [Jeong et al., 2013]), wielordzeniowe systemy operacyjne ([Baumann et al., 2009],
[Kapritsos, 2012], [Lachaize et al., 2012], [Wentzlaff et al., 2012]), poprawność systemów opera-
cyjnych ([Elphinstone et al., 2007], [Yang et al., 2006], [Klein et al., 2009]), niezawodność syste-
mów operacyjnych ([Hruby et al., 2012], [Ryzhyk et al., 2009, 2011], [Zheng et al., 2012]), pry-
watność i zabezpieczenia ([Dunn et al., 2012], [Giuffrida et al., 2012], [Li et al., 2013b], [Lorch
et al., 2013], [Ortolani i Crispo, 2012], [Slowinska et al., 2012], [Ur et al., 2012]), monitorowanie
użycia i wydajności ([Harter et al., 2012], [Ravindranath et al., 2012]), wirtualizacja ([Agesen
et al., 2012], [Ben-Yehuda et al., 2010], [Colp et al., 2011], [Dai et al., 2013], [Tarasov et al.,
2013], [Williams et al., 2012]).
Aby uniknąć nieporozumień, warto wyraźnie stwierdzić, że w tej książce, jak i w całej branży
komputerowej używa się jednostek metrycznych zamiast tradycyjnych miar angielskich (tzw.
systemu FSF — Furlong-Stone-Fortnight). Podstawowe prefiksy metryczne zestawiono
w tabeli 1.4. W skróconej notacji zwykle używa się pierwszych liter prefiksów, przy czym
począwszy od Mega, są one zapisywane wielkimi literami. Tak więc baza danych o rozmiarze
1 terabajta zajmuje 1012 bajtów, natomiast zegar o dokładności 100 pikosekund (lub 100 ps) tyka
co 10−10 s. Ponieważ zarówno prefiks „mili”, jak i „mikro” zaczynają się na literę „m”, trzeba było
je jakoś rozróżnić. Standardowo litera „m” oznacza „mili-”, natomiast litera „μ” (grecka litera mi)
oznacza prefiks „mikro-”.
Warto również zwrócić uwagę na to, że dla potrzeb mierzenia rozmiaru pamięci jednostki
mają nieco inne znaczenie. Przedrostek „kilo” oznacza 210 (1024), a nie 103 (1000), ponieważ roz-
miar pamięci zawsze jest potęgą liczby dwa. Tak więc pamięć o rozmiarze 1 kB ma 1024 bajty,
a nie 1000 bajtów. Podobnie pamięć o rozmiarach 1 MB ma 220 (1 048 576) bajtów, natomiast
pamięci 1 GB mają 230 (1 073 741 824) bajtów. Jednak linia komunikacyjna o szybkości 1 kb/s
przesyła 1000 bitów na sekundę, a sieć LAN 10 Mb/s działa z szybkością 10 000 000 bitów/s,
ponieważ szybkości te nie są wyrażone za pomocą potęg liczby dwa. Niestety, wiele osób miesza
te dwa systemy, zwłaszcza w odniesieniu do rozmiarów dysków. Aby uniknąć niejednoznacz-
ności, w niniejszej książce będziemy używać symboli: kB, MB i GB dla określenia odpowied-
nio: 210, 220 i 230 bajtów, natomiast symbole: kb/s, Mb/s i Gb/s będą oznaczały odpowiednio: 103,
106 i 109 bitów na sekundę.
1.12. PODSUMOWANIE
1.12.
PODSUMOWANIE
Systemy operacyjne można postrzegać dwojako: jako menedżery zasobów oraz rozszerzone
maszyny. Zadaniem systemu operacyjnego jako menedżera zasobów jest wydajne zarządzanie
różnymi częściami systemu. Z kolei w widoku maszyny rozszerzonej zadaniem systemu jest
dostarczenie użytkownikom abstrakcji, które są wygodniejsze do posługiwania się od samej
maszyny. Dotyczy to procesów, przestrzeni adresowych i plików.
Systemy operacyjne mają długą historię, od czasów, w których zastąpiły operatora, do współ-
czesnych systemów wieloprogramowych. Należy wspomnieć systemy wsadowe pierwszych kom-
puterów, systemy wieloprogramowe oraz systemy komputerów osobistych.
Ponieważ systemy operacyjne ściśle współpracują ze sprzętem, do ich zrozumienia przydaje
się pewna wiedza dotycząca sprzętu komputerowego. Komputery składają się z procesorów,
pamięci i urządzeń wejścia-wyjścia. Części te są ze sobą połączone za pomocą magistral.
Do podstawowych komponentów, z których składają się wszystkie systemy operacyjne,
należą procesy, mechanizmy zarządzania pamięcią, system plików i zabezpieczenia. Każdy z tych
tematów zostanie omówiony dokładniej w kolejnych rozdziałach.
Sercem każdego systemu operacyjnego jest zbiór wywołań systemowych, które system
obsługuje. Decydują one o tym, co system operacyjny robi naprawdę. W systemie UNIX prze-
analizowaliśmy cztery grupy wywołań systemowych. Pierwsza grupa wywołań systemowych
jest związana z tworzeniem procesów i ich niszczeniem. Druga dotyczy czytania i zapisywania
plików. Trzecia jest związana z zarządzaniem katalogami. Czwarta grupa zawiera różne
wywołania.
Istnieje kilka rodzajów struktury systemów operacyjnych. Najpopularniejsze są systemy
monolityczne, hierarchia warstw, mikrojądra, architektura klient-serwer, maszyny wirtualne
i egzojądra.
PYTANIA
1. Jakie są dwie główne funkcje systemu operacyjnego?
2. W podrozdziale 1.4 opisano dziewięć różnych typów systemów operacyjnych. Podaj listę
zastosowań dla każdego z tych systemów (jedno zastosowanie dla jednego typu systemu
operacyjnego).
3. Jaka jest różnica między systemami z podziałem czasu a wieloprogramowością?
4. Aby było możliwe używanie pamięci podręcznej, pamięć główna jest podzielona na linie
pamięci podręcznej, zazwyczaj o rozmiarze 32 lub 64 bajtów. Do pamięci podręcznej jest
przenoszona cała linia pamięci podręcznej. Jaka jest korzyść z buforowania całej linii
zamiast pojedynczego bajta lub słowa?
5. W pierwszych komputerach wszystkie przeczytane lub zapisane bajty danych były obsłu-
giwane przez procesor (co oznacza, że nie było DMA). Jakie implikacje wynikają z tego
dla wieloprogramowości?
6. Instrukcje związane z dostępem do urządzeń wejścia-wyjścia są zazwyczaj uprzywile-
jowane — tzn. mogą być wykonywane w trybie jądra, ale nie mogą w trybie użytkownika.
Podaj powód, dlaczego te instrukcje są uprzywilejowane.
7. Idea rodziny komputerów narodziła się w latach sześćdziesiątych, wraz z powstaniem
komputerów mainframe IBM System/360. Czy ta idea jest dziś martwa, czy w dalszym
ciągu jest obecna w branży komputerowej?
8. Jednym z powodów początkowego wolnego tempa akceptacji interfejsów GUI był koszt
sprzętu potrzebnego do ich obsługi. Ile pamięci operacyjnej wideo potrzeba do obsługi
monochromatycznego ekranu o rozmiarach 25 wierszy na 80 kolumn? A ile potrzeba jej
do obsługi 24-bitowej kolorowej bitmapy o rozmiarach 1024×768 pikseli? Jaki był koszt tej
pamięci operacyjnej przy cenach obowiązujących w latach osiemdziesiątych (5 USD/kB)?
Ile wynosi ten koszt teraz?
9. Podczas tworzenia systemów operacyjnych jest kilka celów projektowych: np. wykorzy-
stanie zasobów, terminowość, rozbudowane możliwości itp. Podaj przykład dwóch celów
projektowych, które mogą być wzajemnie sprzeczne.
10. Jaka jest różnica pomiędzy trybem jądra a trybem użytkownika? Wyjaśnij, dlaczego ist-
nienie dwóch niezależnych trybów pomaga w projektowaniu systemu operacyjnego.
11. Dysk o rozmiarze 255 GB zawiera 65 536 cylindrów podzielonych na 255 sektorów na
ścieżkę oraz 512 bajtów na sektor. Ile talerzy i głowic zawiera dysk? Oblicz średni czas
odczytu 400 kB z jednego sektora przy założeniu, że średni czas szukania cylindra wynosi
11 ms, przeciętne opóźnienie związane z obrotem to 7 ms, a tempo odczytu to 100 MB/s.
12. Która z poniższych instrukcji powinna być dozwolona tylko w trybie jądra?
(a) Wyłączenie wszystkich przerwań.
(b) Odczyt zegara.
(c) Ustawienie zegara.
(d) Modyfikacja mapy pamięci.
13. Rozważmy system złożony z dwóch fizycznych procesorów, z których każdy obsługuje dwa
wątki (hiperwątkowość). Załóżmy, że uruchomiono trzy programy, P0, P1 i P2, o cza-
sach działania odpowiednio 5, 10 i 20 ms. Ile czasu zajmie wykonanie tych programów?
Przy założeniu, że wszystkie trzy programy w 100% zajmują procesor, nie blokują się
podczas działania i nie zmieniają procesora, który został im raz przydzielony.
14. Komputer posiada potok o czterech fazach. Wykonanie każdej fazy zajmuje tyle samo
czasu — mianowicie 1 nanosekundę (ns). Ile instrukcji na sekundę może wykonać ta
maszyna?
15. Rozważmy system komputerowy zawierający pamięć podręczną, pamięć główną (RAM)
i dysk oraz załóżmy, że system operacyjny wykorzystuje pamięć wirtualną. Dostęp do
słowa z pamięci podręcznej zajmuje 2 ns, do słowa z pamięci RAM — 10 ns, natomiast
do słowa z dysku — 10 ms. Ile wynosi średni czas dostępu do słowa, jeśli współczynnik
trafień pamięci podręcznej wynosi 95%, a współczynnik trafień pamięci głównej (po chy-
bieniu pamięci podręcznej) wynosi 99%?
16. Kiedy program użytkownika wykonuje wywołanie systemowe w celu odczytu lub zapisu
pliku dyskowego, przekazuje informację o tym, który plik go interesuje, wraz ze wskaź-
nikiem do bufora danych i licznikiem. Następnie sterowanie jest przekazywane do sys-
temu operacyjnego, który wywołuje właściwy sterownik. Załóżmy, że sterownik uru-
chamia dysk i zatrzymuje się do czasu wystąpienia przerwania. W przypadku czytania
z dysku proces wywołujący będzie musiał być zablokowany (ponieważ nie ma dla niego
danych). A co z przypadkiem zapisywania na dysk? Czy proces wywołujący musi być
zablokowany podczas oczekiwania na komunikację z dyskiem?
17. Czym jest rozkaz pułapki? Wyjaśnij jego zastosowanie w systemach operacyjnych.
18. Dlaczego w systemie z podziałem czasu jest potrzebna tabela procesów? Czy jest ona
potrzebna również w komputerach osobistych z systemem UNIX lub Windows oraz jed-
nym użytkownikiem?
19. Czy jest jakiś powód do tego, aby zamontować system plików w niepustym katalogu?
Jeśli tak, to jaki?
20. Dla każdego z podanych wywołań systemowych podaj warunki, które powodują niepo-
wodzenie ich wykonania: fork, exec i unlink.
21. Jakiego rodzaju zwielokrotnienie (czasu, przestrzeni lub obu) może być używane do współ-
dzielenia następujących zasobów: CPU, pamięci, dysku, karty sieciowej, drukarki, klawia-
tury i monitora?
22. Czy wywołanie:
ile = write(fd, bufor, nbajtow);
może zwrócić inną wartość w zmiennej ile niż nbajtow? Jeśli tak, to dlaczego?
23. Plik, którego deskryptor to fd, zawiera następującą sekwencję bajtów: 3, 1, 4, 1, 5, 9, 2,
6, 5, 3, 5. Wykonano następujące wywołania systemowe:
lseek(fd, 3, SEEK SET);
read(fd, &buffer, 4);
przy czym wywołanie lseek wykonuje operację seek do 3. bajtu pliku. Jaka jest zawar-
tość zmiennej buffer po wykonaniu wywołania read?
24. Załóżmy, że plik o rozmiarze 10 MB jest zapisany na dysku na jednej ścieżce (ścieżka
nr 50) w kolejnych sektorach. Ramię dysku znajduje się obecnie nad ścieżką numer 100.
Ile czasu zajmie pobranie tego pliku z dysku? Załóżmy, że przesunięcie ramienia od
wybranego cylindra do następnego zajmuje około 1 ms, a żeby sektor, w którym rozpo-
czyna się plik, znalazł się pod głowicą, potrzeba około 5 ms. Dodatkowo załóżmy, że
odczyt odbywa się z szybkością 100 MB/s.
25. Jaka jest zasadnicza różnica pomiędzy blokowym plikiem specjalnym a znakowym plikiem
specjalnym?
26. W przykładzie pokazanym na rysunku 1.17 procedura biblioteczna ma nazwę read i wywo-
łanie systemowe również ma nazwę read. Czy to ważne, aby ich nazwy były takie same?
Jeśli nie, to która z nich jest ważniejsza?
27. W nowoczesnych systemach operacyjnych przestrzeń adresowa procesu jest oddzielona
od fizycznej pamięci komputera. Podaj dwie zalety takiego rozwiązania.
28. Z punktu widzenia programisty wywołanie systemowe wygląda jak dowolne wywołanie
do procedury bibliotecznej. Czy dla programisty ma znaczenie to, jakie wywołania pro-
cedur bibliotecznych skutkują wywołaniami systemowymi? W jakich okolicznościach
i dlaczego?
29. W tabeli 1.2 widać, że niektóre wywołania systemu UNIX nie mają odpowiedników
w Win32 API. Jakie konsekwencje dla programisty zajmującego się konwersją progra-
mów uniksowych na postać działającą w systemie Windows ma każde z wywołań niepo-
siadające odpowiednika w interfejsie Win32 API?
30. Przenośny system operacyjny to taki, który można przenieść z jednej architektury sys-
temowej do innej bez żadnych modyfikacji. Wyjaśnij, dlaczego zbudowanie systemu ope-
racyjnego, który byłby całkowicie przenośny, jest niewykonalne. Opisz dwie wysokopo-
ziomowe warstwy w projekcie systemu operacyjnego, które charakteryzują się wysokim
stopniem przenośności.
31. Wyjaśnij, dlaczego oddzielenie strategii od mechanizmu pomaga w budowaniu systemów
operacyjnych bazujących na mikrojądrze.
32. Maszyny wirtualne stały się bardzo popularne z wielu powodów. Mimo że mają kilka wad.
Wymień jedną.
33. Oto kilka ćwiczeń w konwersji jednostek:
(a) Ile wynosi nanorok w sekundach?
(b) Mikrometry są często nazywane mikronami. Jaką długość ma megamikron?
(c) Ile bajtów ma pamięć o rozmiarze 1 PB?
(d) Masa Ziemi wynosi 6000 yottagramów. Ile to jest w kilogramach?
34. Napisz powłokę podobną do pokazanej na listingu 1.1, ale zawierającą wystarczającą
ilość kodu, aby działała praktycznie. Możesz również dodać kilka mechanizmów, takich
jak przekierowania wejścia i wyjścia, potoki oraz zadania wykonywane w tle.
35. Jeśli dysponujesz osobistym systemem uniksopodobnym (Linux, MINIX, FreeBSD
itp.), którym możesz bezpiecznie zawiesić i zrestartować komputer, to napisz skrypt
powłoki próbujący stworzyć nieograniczoną liczbę procesów-dzieci. Zaobserwuj, co się
stanie. Przed uruchomieniem eksperymentu wpisz polecenie sync, aby opróżnić bufory
systemu plików na dysk. Dzięki temu unikniesz uszkodzenia systemu plików. Ten
eksperyment możesz również bezpiecznie wykonać na maszynie wirtualnej. Uwaga:
nie wykonuj tego ćwiczenia w systemie współdzielonym, jeśli nie otrzymasz wcześniej
pozwolenia od administratora systemu. Konsekwencje eksperymentu będą natychmiast
widoczne, zatem mogą zostać zastosowane wobec Ciebie określone sankcje.
36. Zbadaj i spróbuj zinterpretować zawartość uniksowego lub windowsowego katalogu za
pomocą takiego narzędzia jak uniksowy program od. Wskazówka: sposób wykonania tego
ćwiczenia zależy od tego, na co pozwala system operacyjny. Można spróbować utworzyć
katalog na dyskietce w jednym systemie operacyjnym, a następnie odczytać surowe
dane z dysku przy użyciu innego systemu operacyjnego — takiego, który zezwala na
dostęp.
Zanim rozpocznie się szczegółowe studium tego, w jaki sposób systemy operacyjne są zapro-
jektowane i skonstruowane, warto przypomnieć, że kluczowym pojęciem we wszystkich sys-
temach operacyjnych jest proces: abstrakcja działającego programu. Wszystkie pozostałe
elementy systemu operacyjnego bazują na pojęciu procesu, dlatego jest bardzo ważne, aby
projektant systemu operacyjnego (a także student) jak najszybciej dobrze zapoznał się z poję-
ciem procesu.
Procesy to jedne z najstarszych i najważniejszych abstrakcji występujących w systemach
operacyjnych. Zapewniają one możliwość wykonywania (pseudo-) współbieżnych operacji nawet
wtedy, gdy dostępny jest tylko jeden procesor. Przekształcają one pojedynczy procesor CPU
w wiele wirtualnych procesorów. Bez abstrakcji procesów istnienie współczesnej techniki kom-
puterowej byłoby niemożliwe. W niniejszym rozdziale przedstawimy szczegółowe informacje
na temat tego, czym są procesy oraz ich pierwsi kuzynowie — wątki.
2.1. PROCESY
2.1.
PROCESY
Wszystkie nowoczesne komputery bardzo często wykonują wiele operacji jednocześnie. Osoby
przyzwyczajone do pracy z komputerami osobistymi mogą nie być do końca świadome tego faktu,
zatem kilka przykładów pozwoli przybliżyć to zagadnienie. Na początek rozważmy serwer WWW.
Żądania stron WWW mogą nadchodzić z wielu miejsc. Kiedy przychodzi żądanie, serwer spraw-
dza, czy potrzebna strona znajduje się w pamięci podręcznej. Jeśli tak, jest przesyłana do klienta.
Jeśli nie, inicjowane jest żądanie dyskowe w celu jej pobrania. Jednak z perspektywy procesora
obsługa żądań dyskowych zajmuje wieczność. W czasie oczekiwania na zakończenie obsługi
żądania na dysk może nadejść wiele kolejnych żądań. Jeśli w systemie jest wiele dysków nie-
które z żądań może być skierowanych na inne dyski na długo przed obsłużeniem pierwszego
żądania. Oczywiste, że potrzebny jest sposób zamodelowania i zarządzania tą współbieżnością.
Do tego celu można wykorzystać procesy (a w szczególności wątki).
109
Rysunek 2.1. (a) Cztery programy uruchomione w trybie wieloprogramowym; (b) pojęciowy model
czterech niezależnych od siebie procesów sekwencyjnych; (c) w wybranym momencie jest aktywny
tylko jeden program
który pobiera dane i umieszcza je we współdzielonym buforze, oraz drugi proces, który usuwa
dane z bufora i je przetwarza. W systemie wieloprocesorowym, w którym każdy z procesów
może działać na innym procesorze, zadanie może być wykonane w krótszym czasie.
W systemach interaktywnych użytkownicy mogą uruchomić program poprzez wpisanie
polecenia lub kliknięcie (ewentualnie dwukrotne kliknięcie) ikony. Wykonanie dowolnej z tych
operacji inicjuje nowy proces i uruchamia w nim wskazany program. W systemach uniksowych
bazujących na systemie X Window nowy proces przejmuje okno, w którym został uruchomiony.
W systemie Microsoft Windows po uruchomieniu procesu nie ma on przypisanego okna. Może
on jednak stworzyć jedno (lub więcej) okien i większość systemów to robi. W obydwu syste-
mach użytkownicy mają możliwość jednoczesnego otwarcia wielu okien, w których działają jakieś
procesy. Za pomocą myszy użytkownik może wybrać okno i komunikować się z procesem, np.
podawać dane wejściowe wtedy, kiedy są potrzebne.
Ostatnia sytuacja, w której są tworzone procesy, dotyczy tylko systemów wsadowych
w dużych komputerach mainframe. Rozważmy działanie systemu zarządzania stanami magazy-
nowymi sieci sklepów pod koniec dnia. W systemach tego typu użytkownicy mogą przesyłać do
systemu zadania wsadowe (czasami zdalnie). Kiedy system operacyjny zdecyduje, że ma zasoby
wystarczające do uruchomienia innego zadania, tworzy nowy proces i uruchamia następne zada-
nie z kolejki.
Z technicznego punktu widzenia we wszystkich tych sytuacjach proces tworzy się poprzez
zlecenie istniejącemu procesowi wykonania wywołania systemowego tworzenia procesów.
Może to być działający proces użytkownika, proces systemowy, wywołany z klawiatury lub za
pomocą myszy, albo proces zarządzania zadaniami systemowymi. Proces ten wykonuje wywoła-
nie systemowe tworzące nowy proces. To wywołanie systemowe zleca systemowi operacyj-
nemu utworzenie nowego procesu i wskazuje, w sposób pośredni lub bezpośredni, jaki program
należy w nim uruchomić.
W systemie UNIX istnieje tylko jedno wywołanie systemowe do utworzenia nowego pro-
cesu: fork. Wywołanie to tworzy dokładny klon procesu wywołującego. Po wykonaniu instrukcji
fork procesy rodzic i dziecko mają ten sam obraz pamięci, te same zmienne środowiskowe oraz
te same otwarte pliki. Po prostu są identyczne. Wtedy zazwyczaj proces-dziecko uruchamia
wywołanie execve lub podobne wywołanie systemowe w celu zmiany obrazu pamięci i uru-
chomienia nowego programu. Kiedy użytkownik wpisze polecenie w środowisku powłoki, np.
sort, powłoka najpierw tworzy proces-dziecko za pomocą wywołania fork, a następnie proces-
dziecko wykonuje polecenie sort. Powodem, dla którego dokonuje się ten dwuetapowy pro-
ces, jest umożliwienie procesowi-dziecku manipulowania deskryptorami plików po wykonaniu
wywołania fork, ale przed wywołaniem execve w celu przekierowania standardowego wejścia,
standardowego wyjścia oraz standardowego urządzenia błędów.
Dla odróżnienia w systemie Windows jedna funkcja interfejsu Win32 — CreateProcess —
jest odpowiedzialna zarówno za utworzenie procesu, jak i załadowanie odpowiedniego programu
do nowego procesu. Wywołanie to ma 10 parametrów. Są to program do uruchomienia, para-
metry wiersza polecenia przekazywane do programu, różne atrybuty zabezpieczeń, bity decy-
dujące o tym, czy otwarte pliki będą dziedziczone, informacje dotyczące priorytetów, specyfi-
kacja okna, jakie ma być utworzone dla procesu (jeśli proces ma mieć okno), oraz wskaźnik do
struktury, w której są zwracane do procesu wywołującego informacje o nowo tworzonym pro-
cesie. Oprócz wywołania CreateProcess interfejs Win32 zawiera około 100 innych funkcji do
zarządzania i synchronizowania procesów oraz wykonywania powiązanych z tym operacji.
Zarówno w systemie UNIX, jak i Windows po utworzeniu procesu rodzic i dziecko mają
osobne przestrzenie adresowe. Jeśli dowolny z procesów zmieni słowo w swojej przestrzeni
adresowej, zmiana nie jest widoczna dla drugiego procesu. W systemie UNIX początkowa prze-
strzeń adresowa procesu-dziecka jest kopią przestrzeni adresowej procesu-rodzica. Są to jednak
całkowicie odrębne przestrzenie adresowe. Zapisywalna pamięć nie jest współdzielona pomię-
dzy procesami (w niektórych implementacjach Uniksa tekst programu jest współdzielony pomiędzy
procesami rodzica i dziecka, ponieważ nie może on być modyfikowany). Alternatywnie proces-
-dziecko może współużytkować pamięć procesu-rodzica, ale w tym przypadku pamięć jest współ-
dzielona w trybie kopiuj przy zapisie (ang. copy-on-write). To oznacza, że zawsze, gdy jeden z dwóch
procesów chce zmienić część pamięci, najpierw fizycznie ją kopiuje, aby mieć pewność, że mody-
fikacja następuje w prywatnym obszarze pamięci. Tak jak wcześniej, nie ma współdzielenia zapi-
sywalnych obszarów pamięci. Nowo utworzony proces może jednak współdzielić niektóre inne
zasoby procesu swojego twórcy — np. otwarte pliki. W systemie Windows przestrzenie adre-
sowe procesów rodzica i dziecka od samego początku są różne.
w celu skompilowania programu foo.c, a taki plik nie istnieje, to kompilator po prostu skończy
działanie. Procesy interaktywne wyposażone w interfejsy ekranowe zwykle nie kończą działania,
jeśli zostaną do nich przekazane błędne parametry. Zamiast tego wyświetlają okno dialogowe
z prośbą do użytkownika o ponowienie próby.
Trzecim powodem zakończenia pracy jest błąd spowodowany przez proces — często wyni-
kający z błędu w programie. Może to być uruchomienie niedozwolonej instrukcji, odwołanie
się do nieistniejącego obszaru pamięci lub dzielenie przez zero. W niektórych systemach (np.
w Uniksie) proces może poinformować system operacyjny, że sam chce obsłużyć określone
błędy. W takim przypadku, jeśli wystąpi błąd, proces otrzymuje sygnał (przerwanie), zamiast
zakończyć pracę.
Czwartym powodem, dla którego proces może zakończyć działanie, jest wykonanie wywoła-
nia systemowego, które zleca systemowi operacyjnemu zniszczenie innego procesu. W Uniksie
można to zrobić za pomocą wywołania systemowego kill. Odpowiednikiem tego wywołania
w interfejsie Win32 API jest TerminateProcess. W obu przypadkach proces niszczący musi posia-
dać odpowiednie uprawnienia do niszczenia innych procesów. W niektórych systemach zakoń-
czenie procesu — niezależnie od tego, czy jest wykonywane dobrowolnie, czy przymusowo —
wiąże się z zakończeniem wszystkich procesów utworzonych przez ten proces. Jednak w taki
sposób nie działa ani UNIX, ani Windows.
pierwszy proces uruchamia polecenie cat, łączy i wyprowadza trzy pliki. Drugi proces uru-
chamia polecenie grep, wybiera wszystkie wiersze zawierające słowo „drzewo”. W zależności
od względnej szybkości obu procesów (co z kolei zależy zarówno od względnej złożoności pro-
gramów, jak i tego, ile czasu procesora każdy z nich ma do dyspozycji) może się zdarzyć, że
polecenie grep będzie gotowe do działania, ale nie będą na nie czekały żadne dane wejściowe.
Proces będzie się musiał zablokować do czasu, aż będą one dostępne.
Proces blokuje się, ponieważ z logicznego punktu widzenia nie może kontynuować działania.
Zazwyczaj dzieje się tak dlatego, że oczekuje na dane wejściowe, które jeszcze nie są dostępne.
Jest również możliwe, że proces, który jest gotowy i zdolny do działania, zostanie zatrzymany
ze względu na to, że system operacyjny zdecydował się przydzielić procesor na pewien czas
jakiemuś innemu procesowi. Te dwie sytuacje diametralnie różnią się od siebie. W pierwszym
przypadku wstrzymanie pracy jest ściśle związane z charakterem problemu (nie można prze-
tworzyć wiersza poleceń wprowadzanego przez użytkownika do czasu, kiedy użytkownik go
nie wprowadzi). W drugim przypadku to techniczne aspekty systemu (niewystarczająca liczba
procesorów do tego, aby każdy proces otrzymał swój prywatny procesor). Na rysunku 2.2 poka-
zano diagram stanów prezentujący trzy stany, w jakich może znajdować się proces:
1. Działanie (rzeczywiste korzystanie z procesora w tym momencie).
2. Gotowość (proces może działać, ale jest tymczasowo wstrzymany, aby inny proces mógł
działać).
3. Blokada (proces nie może działać do momentu, w którym wydarzy się jakieś zewnętrzne
zdarzenie).
Rysunek 2.2. Proces może byś w stanie działania, blokady lub gotowości. Na rysunku pokazano
przejścia pomiędzy tymi stanami
Z logicznego punktu widzenia pierwsze dwa stany są do siebie podobne. W obu przypadkach pro-
ces chce działać, ale w drugim przypadku chwilowo brakuje dla niego czasu procesora. Trzeci
stan różni się od pierwszych dwóch w tym sensie, że proces nie może działać nawet wtedy,
gdy procesor w tym czasie nie ma innego zajęcia.
Tak jak pokazano na rysunku, pomiędzy tymi trzema stanami możliwe są cztery przejścia.
Przejście nr 1 występuje wtedy, kiedy system operacyjny wykryje, że proces nie może konty-
nuować działania. W niektórych systemach proces może wykonać wywołanie systemowe, np.
pause, w celu przejścia do stanu zablokowania. W innych systemach, w tym w Uniksie, kiedy
proces czyta dane z potoku lub pliku specjalnego (np. terminala) i dane wejściowe są niedo-
stępne, jest automatycznie blokowany.
Przejścia nr 2 i nr 3 są realizowane przez program szeregujący (ang. process scheduler) —
część systemu operacyjnego, a procesy nie są o tym nawet informowane. Przejście nr 2 zachodzi
wtedy, gdy program szeregujący zdecyduje, że działający proces działał wystarczająco długo i nad-
szedł czas, by przydzielić czas procesora jakiemuś innemu procesowi. Przejście nr 3 zachodzi
wtedy, gdy wszystkie inne procesy skorzystały ze swojego udziału i nadszedł czas na to, by
pierwszy proces otrzymał procesor i wznowił działanie. Zadanie szeregowania procesów — tzn.
decydowania o tym, który proces powinien się uruchomić, kiedy i na jak długo — jest bardzo
ważne. Przyjrzymy się mu bliżej w dalszej części tego rozdziału. Opracowano wiele algoryt-
mów mających na celu zapewnienie równowagi pomiędzy wymaganiami wydajności systemu
jako całości oraz sprawiedliwego przydziału procesora do indywidualnych procesów. Niektóre
z tych algorytmów omówimy w dalszej części niniejszego rozdziału.
Przejście nr 4 występuje wtedy, gdy zachodzi zewnętrzne zdarzenie, na które proces ocze-
kiwał (np. nadejście danych wejściowych). Jeśli w tym momencie nie działa żaden inny proces,
zajdzie przejście nr 3 i proces rozpocznie działanie. W innym przypadku może być zmuszony
do oczekiwania w stanie gotowości przez pewien czas, aż procesor stanie się dostępny i nadejdzie
jego kolejka.
Wykorzystanie modelu procesów znacznie ułatwia myślenie o tym, co dzieje się wewnątrz
systemu. Niektóre procesy uruchamiają programy realizujące polecenia wprowadzane przez
użytkownika. Inne procesy są częścią systemu i obsługują takie zadania, jak obsługa żądań usług
plikowych lub zarządzanie szczegółami dotyczącymi uruchamiania napędu dysku lub taśm.
Kiedy zachodzi przerwanie dyskowe, system podejmuje decyzję o zatrzymaniu działania bieżą-
cego procesu i uruchamia proces dyskowy, który był zablokowany w oczekiwaniu na to prze-
rwanie. Tak więc zamiast myśleć o przerwaniach, możemy myśleć o procesach użytkownika,
procesach dysku, procesach terminala itp., które blokują się w czasie oczekiwania, aż coś się
wydarzy. Kiedy nastąpi próba czytania danych z dysku albo użytkownik przyciśnie klawisz, pro-
ces oczekujący na to zdarzenie jest odblokowywany i może wznowić działanie.
Ten stan rzeczy jest podstawą modelu pokazanego na rysunku 2.3. W tym przypadku na
najniższym poziomie systemu operacyjnego znajduje się program szeregujący, zarządzający
zbiorem procesów występujących w warstwie nad nim. Cały mechanizm obsługi przerwań
i szczegółów związanych z właściwym uruchamianiem i zatrzymywaniem procesów jest ukryty
w elemencie nazwanym tu zarządcą procesów. Element ten w rzeczywistości nie zawiera zbyt
wiele kodu. Pozostała część systemu operacyjnego ma strukturę procesów. W praktyce jednak
istnieje bardzo niewiele systemów operacyjnych, które miałyby tak przejrzystą strukturę.
Teraz, kiedy przyjrzeliśmy się tabeli procesów, możemy wyjaśnić nieco dokładniej to, w jaki
sposób iluzja wielu sekwencyjnych procesów jest utrzymywana w jednym procesorze (lub każ-
dym z procesorów). Z każdą klasą wejścia-wyjścia wiąże się lokalizacja (zwykle pod ustalonym
adresem w dolnej części pamięci) zwana wektorem przerwań. Jest w niej zapisany adres proce-
dury obsługi przerwania. Załóżmy, że w momencie wystąpienia przerwania związanego z dyskiem
ma działać proces użytkownika nr 3. Sprzęt obsługujący przerwania odkłada na stos licznik pro-
gramu procesu użytkownika nr 3, słowo stanu programu i czasami jeden lub kilka rejestrów.
Następnie sterowanie przechodzi pod adres określony w wektorze przerwań. To jest wszystko,
co robi sprzęt. Od tego momentu obsługą przerwania zajmuje się oprogramowanie — w szcze-
gólności procedura obsługi przerwania.
Obsługa każdego przerwania rozpoczyna się od zapisania rejestrów — często pod pozycją
tabeli procesów odpowiadającą bieżącemu procesowi. Następnie informacje odłożone na stos
przez mechanizm obsługi przerwania są z niego zdejmowane, a wskaźnik stosu jest ustawiany
na adres tymczasowego stosu używanego przez procedurę obsługi procesu. Takich działań, jak
zapisanie rejestrów i ustawienie wskaźnika stosu, nawet nie można wyrazić w językach wysoko-
poziomowych, np. w C. W związku z tym operacje te są wykonywane przez niewielką procedurę
w języku asemblera. Zazwyczaj jest to ta sama procedura dla wszystkich przerwań, ponieważ
zadanie zapisania rejestrów jest identyczne, niezależnie od tego, co było przyczyną przerwania.
Kiedy ta procedura zakończy działanie, wywołuje procedurę w języku C, która wykonuje
resztę pracy dla tego konkretnego typu przerwania (zakładamy, że system operacyjny został
napisany w języku C — w tym języku napisana jest większość systemów operacyjnych). Kiedy
procedura ta wykona swoje zadanie (co może spowodować, że pewne procesy uzyskają goto-
wość do działania), wywoływany jest program szeregujący, który ma sprawdzić, jaki proces
powinien zostać uruchomiony w następnej kolejności. Następnie sterowanie jest przekazywane
z powrotem do kodu w asemblerze, który ładuje rejestry i mapę pamięci nowego bieżącego
procesu oraz rozpoczyna jego działanie. Obsługę przerwań i szeregowanie podsumowano
w tabeli 2.2. Warto zwrócić uwagę, że różne systemy nieco się różnią pewnymi szczegółami.
Tabela 2.2. Szkielet działań wykonywanych przez najniższy poziom systemu operacyjnego
w momencie wystąpienia przerwania
1. Sprzęt odkłada na stos licznik programu itp.
2. Sprzęt ładuje nowy licznik programu z wektora przerwań
3. Procedura w języku asemblera zapisuje rejestry
4. Procedura w języku asemblera ustawia nowy stos
5. Uruchamia się procedura obsługi przerwania w C (zazwyczaj czyta i buforuje dane wejściowe)
6. Program szeregujący decyduje o tym, który proces ma być uruchomiony w następnej kolejności
7. Procedura w języku C zwraca sterowanie do kodu w asemblerze
8. Procedura w języku asemblera uruchamia nowy bieżący proces
Proces może być przerwany tysiące razy w trakcie działania, ale kluczową ideą jest to, że
po każdym przerwaniu proces powraca dokładnie do tego stanu, w jakim był przed wystąpie-
niem przerwania.
Z rysunku jasno wynika, że jeśli procesy spędzają 80% czasu w oczekiwaniu na operacje
wejścia-wyjścia, to aby współczynnik marnotrawienia procesora utrzymać na poziomie poniżej
10%, w pamięci musi być jednocześnie co najmniej 10 procesów. Kiedy zdamy sobie sprawę ze
stanu, w którym proces interaktywny oczekuje, aż użytkownik wpisze na terminalu jakieś dane,
stanie się oczywiste, że czasy oczekiwania na wejścia-wyjścia rzędu 80% i więcej nie są niczym
niezwykłym. Nawet na serwerach procesy wykonujące wiele dyskowych operacji wejścia-wyjścia
często charakteryzują się tak wysokim procentem.
Dla ścisłości należy dodać, że model probabilistyczny opisany przed chwilą jest tylko przy-
bliżeniem. Zakłada on niejawnie, że wszystkie n procesów jest niezależnych. Oznacza to, że
w przypadku systemu z pięcioma procesami w pamięci dopuszczalnym stanem jest to, aby trzy
z nich działały, a dwa czekały. Jednak przy jednym procesorze nie ma możliwości jednoczesnego
działania trzech procesów. W związku z tym proces, który osiąga gotowość w czasie, gdy pro-
cesor jest zajęty, będzie musiał czekać. Tak więc procesy nie są niezależne. Dokładniejszy
model można stworzyć z wykorzystaniem teorii kolejkowania, jednak teza, którą sformułowa-
liśmy — wieloprogramowość pozwala procesom wykorzystywać procesor w czasie, gdy w innej
sytuacji byłby on bezczynny — jest oczywiście w dalszym ciągu prawdziwa. Faktu tego nie
zmieniłaby nawet sytuacja, w której rzeczywiste krzywe stopnia wieloprogramowości nieco
odbiegałyby od tych pokazanych na rysunku 2.4.
Mimo że model z rysunku 2.4 jest uproszczony, można go wykorzystywać w celu tworze-
nia specyficznych, jednak przybliżonych prognoz dotyczących wydajności procesora. Przypu-
śćmy, że komputer ma 8 GB pamięci, przy czym system operacyjny zajmuje 2 GB, a każdy
program użytkownika również zajmuje do 2 GB. Te rozmiary pozwalają na to, aby w pamięci
jednocześnie znajdowały się trzy programy użytkownika. Przy średnim czasie oczekiwania na
operacje wejścia-wyjścia wynoszącym 80% mamy procent wykorzystania procesora (pomijając
narzut systemu operacyjnego) na poziomie 1 – 0,83 czyli około 49%. Dodanie kolejnych 8 GB
pamięci operacyjnej umożliwia przejście systemu z trójstopniowej wieloprogramowości do
siedmiostopniowej, co przyczyni się do wzrostu wykorzystania procesora do 79%. Mówiąc inaczej,
dodatkowe 8 GB pamięci podniesie przepustowość o 30%.
Dodanie kolejnych 8 GB spowodowałoby zwiększenie stopnia wykorzystania procesora
z 79 % do 91 %, a zatem podniosłoby przepustowość tylko o 12 %. Korzystając z tego modelu,
właściciel komputera może zadecydować, że pierwsza rozbudowa systemu jest dobrą inwestycją,
natomiast druga nie.
2.2. WĄTKI
2.2.
WĄTKI
Rozważmy teraz, co się zdarzy, kiedy użytkownik nagle usunie jedno zdanie z pierwszej strony
800-stronicowego dokumentu. Po sprawdzeniu poprawności zmodyfikowanej strony zdecydo-
wał, że chce wykonać inną zmianę na stronie 600 i wpisuje polecenie zlecające edytorowi przej-
ście do tej strony (np. poprzez wyszukanie frazy, która znajduje się tylko tam). Edytor tekstu jest
w tej sytuacji zmuszony do natychmiastowego przeformatowania całej książki do strony 600,
ponieważ nie będzie wiedział, jaką treść ma pierwszy wiersz na stronie 600, dopóki nie prze-
tworzy wszystkich poprzednich stron. Zanim będzie można wyświetlić stronę 600, może powstać
znaczące opóźnienie, co doprowadzi do niezadowolenia użytkownika.
W takim przypadku może pomóc wykorzystanie wątków. Załóżmy, że edytor tekstu jest
napisany jako program składający się z dwóch wątków. Jeden wątek zajmuje się komunikacją
z użytkownikiem, a drugi przeprowadza w tle korektę formatowania. Natychmiast po usunięciu
zdania ze strony 1 wątek komunikacji z użytkownikiem informuje wątek formatujący o koniecz-
ności przeformatowania całej książki. Tymczasem wątek komunikacji z użytkownikiem konty-
nuuje nasłuchiwanie klawiatury i myszy i odpowiada na proste polecenia, takie jak przegląda-
nie strony 1. W tym samym czasie drugi z wątków w tle wykonuje intensywne obliczenia. Przy
odrobinie szczęścia zmiana formatu zakończy się, zanim użytkownik poprosi o przejście na
stronę 600. Jeśli tak się stanie, przejście na stronę 600 będzie mogło się odbyć bezzwłocznie.
Kiedy już jesteśmy przy edytorach, odpowiedzmy sobie na pytanie, dlaczego by nie dodać
trzeciego wątku. Wiele edytorów tekstu jest wyposażonych w mechanizm automatycznego zapi-
sywania całego pliku na dysk co kilka minut. Ma to zapobiec utracie całodniowej pracy w przy-
padku awarii programu, awarii systemu lub problemów z zasilaniem. Trzeci wątek może obsłu-
giwać wykonywanie kopii zapasowych na dysku, nie przeszkadzając w działaniu pozostałym
dwóm. Sytuację z trzema wątkami pokazano na rysunku 2.5.
Gdyby program zawierał jeden wątek, to każde rozpoczęcie wykonywania kopii zapasowej
na dysk powodowałoby, że polecenia z klawiatury i myszy byłyby ignorowane do czasu zakoń-
czenia wykonywania kopii zapasowej. Użytkownik z pewnością by to zauważył jako obniżoną
wydajność. Alternatywnie zdarzenia związane z klawiaturą i myszą mogłyby przerwać wyko-
nywanie kopii zapasowej na dysk, co pozwoliłoby na zachowanie dobrej wydajności, ale prowa-
dziłoby do skomplikowanego modelu programowania bazującego na przerwaniach. W przypadku
zastosowania trzech wątków model programowania jest znacznie prostszy. Pierwszy wątek
Kiedy wątek się obudzi, sprawdza, czy jest w stanie spełnić żądanie z pamięci podręcznej
strony WWW, do której mają dostęp wszystkie wątki. Jeśli tak nie jest, rozpoczyna operację
odczytu w celu pobrania strony z dysku i przechodzi do stanu „zablokowany”, trwającego do
chwili zakończenia operacji dyskowej. Kiedy wątek zablokuje się na operacji dyskowej, inny
wątek zaczyna działanie, np. dyspozytor, którego zadaniem jest przyjęcie jak największej liczby
żądań, albo inny pracownik, który jest gotowy do działania.
W tym modelu serwer może być zapisany w postaci kolekcji sekwencyjnych wątków. Pro-
gram dyspozytora zawiera pętlę nieskończoną, w której jest pobierane żądanie pracy, później
wręczane pracownikowi. Kod każdego pracownika zawiera pętlę nieskończoną, w której jest
akceptowane żądanie od dyspozytora i następuje sprawdzenie, czy żądana strona jest dostępna
w pamięci podręcznej serwera WWW. Jeśli tak, strona jest zwracana do klienta, a pracownik
blokuje się w oczekiwaniu na nowe żądanie. Jeśli nie, pracownik pobiera stronę z dysku, zwraca
ją do klienta i blokuje się w oczekiwaniu na nowe żądanie.
W uproszczonej formie kod przedstawiono na listingu 2.1. W tym przypadku, podobnie jak
w pozostałej części tej książki, założono, że TRUE odpowiada stałej o wartości 1. Natomiast buf
i strona są strukturami do przechowywania odpowiednio żądania pracy i strony WWW.
Listing 2.1. Uproszczona postać kodu dla struktury serwera z rysunku 2.6: (a) wątek dyspozytora,
(b) wątek pracownika
(a) (b)
while (TRUE){ while (TRUE){
pobierz_nast_zadanie(&buf); czekaj_na_prace(&buf)
przekaz_prace(&buf); szukaj_strony_w_pamieci_cache(&buf, &strona);
} if ((&strona))
czytaj_strone_z_dysku(&buf, &strona);
zwroc_strone(&strona);
}
Zastanówmy się, jak mógłby być napisany serwer WWW, gdyby nie było wątków. Jedna
z możliwości polega na zaimplementowaniu go jako pojedynczego wątku. W głównej pętli ser-
wera WWW następowałyby pobieranie żądania, jego analiza i realizacja. Dopiero potem serwer
WWW mógłby pobrać następne żądanie. Podczas oczekiwania na zakończenie operacji dysko-
wej serwer byłby bezczynny i nie przetwarzałby żadnych innych przychodzących żądań. Jeśli
serwer WWW działa na dedykowanej maszynie, tak jak to zwykle bywa, w czasie oczekiwania
serwera WWW na dysk procesor pozostałby bezczynny. W efekcie końcowym można by było
przetworzyć znacznie mniej żądań na sekundę. A zatem skorzystanie z wątków pozwala na uzy-
skanie znaczącego zysku wydajności, ale każdy z wątków jest programowany sekwencyjnie —
w standardowy sposób.
Do tej pory omówiliśmy dwa możliwe projekty: wielowątkowy serwer WWW i jednowąt-
kowy serwer WWW. Załóżmy, że wątki nie są dostępne, ale projektanci systemu uznali obni-
żenie wydajności spowodowane istnieniem pojedynczego wątku za niedopuszczalne. Jeśli jest
dostępna nieblokująca wersja wywołania systemowego read, możliwe staje się trzecie podej-
ście. Kiedy przychodzi żądanie, analizuje go jeden i tylko jeden wątek. Jeżeli żądanie może być
obsłużone z pamięci podręcznej, to dobrze, ale jeśli nie, inicjowana jest nieblokująca operacja
dyskowa.
Serwer rejestruje stan bieżącego żądania w tabeli, a następnie pobiera następne zdarzenie
do obsługi. Może to być żądanie nowej pracy albo odpowiedź dysku dotycząca poprzedniej ope-
racji. Jeśli jest to żądanie nowej pracy, rozpoczyna się jego obsługa. Jeśli jest to odpowiedź
Trzecim przykładem zastosowania wątków są aplikacje, które muszą przetwarzać duże ilości
danych. Normalne podejście polega na przeczytaniu bloku danych, przetworzeniu go, a następ-
nie ponownym zapisaniu. Problem w takim przypadku polega na tym, że jeśli dostępne są tylko
blokujące wywołania systemowe, proces blokuje się, kiedy dane przychodzą oraz kiedy są wysy-
łane na zewnątrz. Doprowadzenie do sytuacji, w której procesor jest bezczynny w czasie, gdy
jest wiele obliczeń do wykonania, to oczywiste marnotrawstwo i w miarę możliwości należy
unikać takiej sytuacji.
Rozwiązaniem problemu jest wykorzystanie wątków. Wewnątrz procesu można wydzielić
wątek wejściowy, wątek przetwarzania danych i wątek wyprowadzania danych. Wątek wej-
ściowy czyta dane do bufora wejściowego. Wątek przetwarzania danych pobiera dane z bufora
wejściowego, przetwarza je i umieszcza wyniki w buforze wyjściowym. Wątek wyprowadzania
danych zapisuje wyniki z bufora wyjściowego na dysk. W ten sposób wprowadzanie danych, ich
wyprowadzanie i przetwarzanie mogą być realizowane w tym samym czasie. Oczywiście model
ten działa tylko wtedy, kiedy wywołanie systemowe blokuje wyłącznie wątek wywołujący, a nie
cały proces.
można skorzystać z wątków. Najpierw przyjrzymy się klasycznemu modelowi wątków. Następnie
omówimy model wątków Linuksa, w którym linia pomiędzy wątkami i procesami jest rozmyta.
Jednym ze sposobów patrzenia na proces jest postrzeganie go jako sposobu grupowania
powiązanych ze sobą zasobów. Proces dysponuje przestrzenią adresową zawierającą tekst pro-
gramu i dane, a także inne zasoby. Do zasobów tych można zaliczyć otwarte pliki, procesy-
dzieci, nieobsłużone alarmy, procedury obsługi sygnałów, informacje rozliczeniowe i wiele innych.
Dzięki pogrupowaniu ich w formie procesu można nimi łatwiej zarządzać.
W innym pojęciu proces zawiera wykonywany wątek — zwykle w skrócie używa się samego
pojęcia wątku. Wątek zawiera licznik programu, który śledzi to, jaka instrukcja będzie wyko-
nywana w następnej kolejności. Posiada rejestry zawierające jego bieżące robocze zmienne.
Ma do dyspozycji stos zawierający historię działania — po jednej ramce dla każdej procedury,
której wykonywanie się rozpoczęło, ale jeszcze się nie zakończyło. Chociaż wątek musi reali-
zować jakiś proces, wątek i jego proces są pojęciami odrębnymi i można je traktować osobno.
Procesy są wykorzystywane do grupowania zasobów, wątki są podmiotami zaplanowanymi do
wykonania przez procesor.
Wątki dodają do modelu procesu możliwość realizacji wielu wykonań w tym samym środo-
wisku procesu, w dużym stopniu w sposób wzajemnie od siebie niezależny. Równoległe działanie
wielu wątków w obrębie jednego procesu jest analogiczne do równoległego działania wielu
procesów w jednym komputerze. W pierwszym z tych przypadków, wątki współdzielą przestrzeń
adresową i inne zasoby. W drugim przypadku procesy współdzielą pamięć fizyczną, dyski, dru-
karki i inne zasoby. Ponieważ wątki mają pewne właściwości procesów, czasami nazywa się je
lekkimi procesami. Do opisania sytuacji, w której w tym samym procesie może działać wiele
wątków używa się także terminu wielowątkowość. Jak widzieliśmy w rozdziale 1., niektóre pro-
cesory mają bezpośrednią obsługę sprzętową wielowątkowości i pozwalają na przełączanie
wątków w skali czasowej rzędu nanosekund.
Na rysunku 2.7(a) widać trzy tradycyjne procesy. Każdy proces ma swoją własną przestrzeń
adresową oraz pojedynczy wątek sterowania. Dla odmiany w układzie z rysunku 2.7(b) widzimy
jeden proces z trzema wątkami sterowania. Chociaż w obu przypadkach mamy trzy wątki, w sytu-
acji z rysunku 2.7(a) każdy z nich działa w innej przestrzeni adresowej, podczas gdy w sytuacji
z rysunku 2.7(b) wszystkie współdzielą tę samą przestrzeń adresową.
Rysunek 2.7. (a) Trzy procesy, z których każdy posiada jeden wątek; (b) jeden wątek z trzema
wątkami
pomiędzy wieloma procesami system daje iluzję oddzielnych procesów sekwencyjnych działa-
jących współbieżnie. Wielowątkowość działa w taki sam sposób. Procesor przełącza się w szyb-
kim tempie pomiędzy wątkami, dając iluzję, że wątki działają współbieżnie — chociaż na wol-
niejszym procesorze od fizycznego. Przy trzech wątkach obliczeniowych w procesie wątki będą
sprawiały wrażenie równoległego działania, ale tak, jakby każdy z nich działał na procesorze
o szybkości równej jednej trzeciej szybkości fizycznego procesora.
Różne wątki procesu nie są tak niezależne, jak różne procesy. Wszystkie wątki posługują się
dokładnie tą samą przestrzenią adresową, co również oznacza, że współdzielą one te same zmienne
globalne. Ponieważ każdy wątek może uzyskać dostęp do każdego adresu pamięci w obrębie
przestrzeni adresowej procesu, jeden wątek może odczytać, zapisać, a nawet wyczyścić stos
innego wątku. Pomiędzy wątkami nie ma zabezpieczeń, ponieważ (1) byłyby one niemożliwe do
realizacji, a (2) nie powinny być potrzebne. W odróżnieniu od różnych procesów, które poten-
cjalnie należą do różnych użytkowników i które mogą być dla siebie wrogie, proces zawsze
należy do jednego użytkownika, który przypuszczalnie utworzył wiele wątków, a zatem powinny
one współpracować, a nie walczyć ze sobą. Oprócz przestrzeni adresowej wszystkie wątki mogą
współdzielić ten sam zbiór otwartych plików, procesów-dzieci, alarmów, sygnałów itp., tak jak
pokazano w tabeli 2.4. Tak więc organizacja pokazana na rysunku 2.7(a) mogłaby zostać użyta,
jeśli trzy procesy są ze sobą niezwiązane, natomiast organizacja z rysunku 2.7(b) byłaby wła-
ściwa w przypadku, gdyby trzy wątki były częścią tego samego zadania i gdyby aktywnie i ściśle
ze sobą współpracowały.
Tabela 2.4. W pierwszej kolumnie wyszczególniono cechy wspólne dla wszystkich wątków
w procesie. W drugiej kolumnie zamieszczone niektóre elementy prywatne dla każdego wątku
Komponenty procesu Komponenty wątku
Przestrzeń adresowa Licznik programu
Zmienne globalne Rejestry
Otwarte pliki Stos
Procesy-dzieci Stan
Zaległe alarmy
Sygnały i procedury obsługi sygnałów
Informacje dotyczące statystyk
Elementy w pierwszej kolumnie są właściwościami procesu, a nie wątku. Jeśli np. jeden
wątek otworzy plik, będzie on widoczny dla innych wątków w procesie. Wątki te będą mogły
czytać dane z pliku i je zapisywać. To logiczne, ponieważ właśnie proces, a nie wątek jest jed-
nostką zarządzania zasobami. Gdyby każdy wątek miał własną przestrzeń adresową, otwarte pliki,
nieobsłużone alarmy itd., byłby osobnym procesem. Wykorzystując pojęcie wątków, chcemy,
aby wiele wątków mogło współdzielić zbiór zasobów. Dzięki temu mogą one ze sobą ściśle współ-
pracować w celu wykonania określonego zadania.
Podobnie jak tradycyjny proces (czyli taki, który zawiera tylko jeden wątek), wątek może
znajdować się w jednym z kilku stanów: „działający”, „zablokowany”, „gotowy” lub „zakończony”.
Działający wątek posiada dostęp do procesora i jest aktywny. Zablokowany wątek oczekuje na
jakieś zdarzenie, by mógł się odblokować. Kiedy np. wątek realizuje wywołanie systemowe
odczytujące dane z klawiatury, jest zablokowany do czasu, kiedy użytkownik wpisze dane wej-
ściowe. Wątek może się blokować w oczekiwaniu na wystąpienie zdarzenia zewnętrznego lub
może oczekiwać, aż odblokuje go inny wątek. Wątek gotowy jest zaplanowany do uruchomienia
i zostanie uruchomiony, kiedy nadejdzie jego kolej. Przejścia pomiędzy stanami wątków są iden-
tyczne jak przejścia pomiędzy stanami procesów. Zilustrowano je na rysunku 2.2.
Istotne znaczenie ma zdanie sobie sprawy, że każdy wątek posiada własny stos, co zilu-
strowano na rysunku 2.8. Stos każdego wątku zawiera po jednej ramce dla każdej procedury,
która została wywołana, a z której jeszcze nie nastąpił powrót. Ramka ta zawiera zmienne lokalne
procedury oraz adres powrotu, który będzie wykorzystany po zakończeniu obsługi wywołania
procedury. Jeśli np. procedura X wywoła procedurę Y, a procedura Y wywoła procedurę Z, to
w czasie, kiedy działa procedura Z, na stosie będą ramki dla procedur X, Y i Z. Każdy wątek,
ogólnie rzecz biorąc, będzie wywoływał inne procedury, a zatem będzie miał inną historię wywo-
łań. Dlatego właśnie każdy wątek potrzebuje własnego stosu.
tak by inne wątki miały szanse na działanie. Są również inne wywołania — np. pozwalające na
to, aby jeden wątek poczekał, aż następny zakończy jakąś pracę, lub by ogłosił, że właśnie zakoń-
czył jakąś pracę itd.
Chociaż wątki często się przydają, wprowadzają także szereg komplikacji do modelu pro-
gramowania. Na początek przeanalizujmy efekty na uniksowe wywołanie systemowej fork. Jeśli
proces-rodzic ma wiele wątków, to czy proces-dziecko również powinien je mieć? Jeśli nie, to
proces może nie działać prawidłowo, ponieważ wszystkie wątki mogą mieć istotne znaczenie.
Tymczasem gdy proces-dziecko otrzyma tyle samo wątków co rodzic, to co się stanie, jeśli
wątek należący do rodzica zostanie zablokowany przez wywołanie read, powiedzmy, z klawia-
tury? Czy teraz dwa wątki są zablokowane przez klawiaturę — jeden w procesie-rodzicu i drugi
w dziecku? Kiedy użytkownik wpisze wiersz, to czy kopia pojawi się w obu wątkach? A może
tylko w wątku rodzica? Lub tylko w wątku dziecka? Ten sam problem występuje dla twardych
połączeń sieciowych.
Inna klasa problemów wiąże się z faktem współdzielenia przez wątki wielu struktur danych.
Co się dzieje, jeśli jeden wątek zamyka plik, podczas gdy inny ciągle z niego czyta? Przypuśćmy,
że jeden z wątków zauważa, że jest za mało pamięci, i rozpoczyna alokowanie większej ilości
pamięci. W trakcie tego działania następuje przełączenie wątku. Nowy wątek również zauważa,
że jest za mało pamięci i także rozpoczyna alokowanie dodatkowej pamięci. Pamięć prawdopo-
dobnie będzie alokowana dwukrotnie. Przy odrobinie wysiłku można rozwiązać te problemy,
jednak poprawna praca programów wykorzystujących wielowątkowość wymaga dokładnych prze-
myśleń i dokładnego projektowania.
Wszystkie wątki pakietu Pthreads mają określone właściwości. Każdy z nich posiada identyfi-
kator, zbiór rejestrów (łącznie z licznikiem programu) oraz zbiór atrybutów zapisanych w pew-
nej strukturze. Do atrybutów tych należy rozmiar stosu, parametry szeregowania oraz inne
elementy potrzebne do korzystania z wątku.
Nowy wątek tworzy się za pomocą wywołania pthread_create. Jako wartość funkcji zwra-
cany jest identyfikator nowo utworzonego wątku. Wywołanie to nieprzypadkowo przypomina
if (status != 0) {
printf("Oops. Funkcja pthread_create zwróciła kod błędu %d\n, status);
exit(-1);
}
}
exit(NULL);
}
Rysunek 2.9. (a) Pakiet obsługi wątków na poziomie użytkownika; (b) pakiet obsługi wątków
zarządzany przez jądro
Jeśli wątki są zarządzane w przestrzeni użytkownika, każdy proces potrzebuje swojej pry-
watnej tabeli wątków, która ma na celu śledzenie wątków w tym procesie. Tabela ta jest analo-
giczna do tabeli procesów w jądrze. Różnica polega na tym, że śledzi ona właściwości tylko na
poziomie wątku — np. licznik programu każdego z wątków, wskaźnik stosu, rejestry, stan itp.
Tabela wątków jest zarządzana przez środowisko wykonawcze. Kiedy wątek przechodzi do stanu
gotowości lub zablokowania, informacje potrzebne do jego wznowienia są zapisywane w tabeli wąt-
ków, dokładnie w taki sam sposób, w jaki jądro zapisuje informacje o procesach w tabeli procesów.
Kiedy wątek wykona operację, która może spowodować jego lokalne zablokowanie, np.
oczekuje, aż inny wątek w tym samym procesie wykona jakąś pracę, wykonuje procedurę ze
środowiska wykonawczego. Procedura ta sprawdza, czy wątek musi być przełączony do stanu
zablokowania. Jeśli tak, to zapisuje rejestry wątku (tzn. własne) w tabeli wątków, szuka w tabeli
wątku gotowego do działania i ładuje rejestry maszyny zapisanymi wartościami odpowiadają-
cymi nowemu wątkowi. Po przełączeniu wskaźnika stosu i licznika programu nowy wątek auto-
matycznie powraca do życia. Jeśli maszyna posiada instrukcję zapisującą wszystkie rejestry oraz
inną instrukcję, która je wszystkie ładuje, przełączenie wątku można przeprowadzić za pomocą
zaledwie kilku instrukcji. Przeprowadzenie przełączania wątków w taki sposób jest co najmniej
o jeden rząd wielkości szybsze od wykonywania rozkazu pułapki do jądra. To silny argument
przemawiający za implementacją pakietu zarządzania wątkami na poziomie przestrzeni użyt-
kownika.
Jest jednak jedna zasadnicza różnica w porównaniu z procesami. Kiedy wątek zakończy na
chwilę działanie, np. gdy wywoła funkcję thread_yield, kod funkcji thread_yield może zapisać
informacje dotyczące wątku w samej tabeli wątków. Co więcej, może on następnie wywołać
zarządcę wątków w celu wybrania innego wątku do uruchomienia. Procedura zapisująca stan
wątku oraz program szeregujący są po prostu lokalnymi procedurami, zatem wywołanie ich
jest znacznie bardziej wydajne od wykonania wywołania jądra; m.in. nie jest potrzebny rozkaz
pułapki, nie trzeba przełączać kontekstu, nie trzeba opróżniać pamięci podręcznej. W związku
z tym zarządzanie wątkami odbywa się bardzo szybko.
Implementacja wątków na poziomie przestrzeni użytkownika ma także inne zalety. Dzięki
temu każdemu procesowi można przypisać własny, spersonalizowany algorytm szeregowania.
W przypadku niektórych aplikacji, np. zawierających wątek mechanizmu odśmiecania, brak
konieczności przejmowania się możliwością zatrzymania się wątku w nieodpowiednim momencie
jest zaletą. Takie rozwiązanie okazuje się również łatwiejsze do skalowania, ponieważ wątki
zarządzane na poziomie jądra niewątpliwie wymagają przestrzeni na tabelę i stos w jądrze, a to,
w przypadku dużej liczby wątków, może być problemem.
Pomimo lepszej wydajności implementacja wątków na poziomie przestrzeni użytkownika ma
również istotne wady. Pierwsza z nich dotyczy sposobu implementacji blokujących wywołań
systemowych. Przypuśćmy, że wątek czyta z klawiatury, zanim zostanie wciśnięty jakikolwiek
klawisz. Zezwolenie wątkowi na wykonanie wywołania systemowego jest niedopuszczalne,
ponieważ spowoduje to zatrzymanie wszystkich wątków. Trzeba pamiętać, że jednym z pod-
stawowych celów korzystania z wątków jest umożliwienie wszystkim wątkom używania wywo-
łań blokujących, a przy tym niedopuszczenie do tego, by zablokowany wątek miał wpływ na inne.
W przypadku blokujących wywołań systemowych trudno znaleźć łatwe rozwiązanie pozwalające
na spełnienie tego celu.
Wszystkie wywołania systemowe można zmienić na nieblokujące (np. odczyt z klawiatury
zwróciłby 0 bajtów, gdyby znaki nie były wcześniej zbuforowane), ale wymaganie zmian w sys-
temie operacyjnym jest nieatrakcyjne. Poza tym jednym z argumentów przemawiających za
zaproponować sensownego rozwiązania problemu wyliczania liczb pierwszych lub grania w szachy
z wykorzystaniem wątków, ponieważ realizacja tych programów w ten sposób nie przynosi
istotnych korzyści.
W przypadku skorzystania z takiego podejścia programista może określić, ile wątków jądra
chce wykorzystać oraz na ile wątków poziomu użytkownika ma być zwielokrotniony każdy z nich.
Taki model daje największą elastyczność.
Przy tym podejściu jądro jest świadome istnienia wyłącznie wątków poziomu jądra i tylko
nimi zarządza. Niektóre spośród tych wątków mogą zawierać wiele wątków poziomu użytkow-
nika, stworzonych na bazie wątków jądra. Wątki poziomu użytkownika są tworzone, niszczone
i zarządzane identycznie, jak wątki na poziomie użytkownika działające w systemie operacyj-
nym bez obsługi wielowątkowości. W tym modelu każdy wątek poziomu jądra posiada pewien
zbiór wątków na poziomie użytkownika. Wątki poziomu użytkownika po kolei korzystają z wątku
poziomu jądra.
Celem działania mechanizmu aktywacji zarządcy jest naśladowanie funkcji wątków jądra,
ale z zapewnieniem lepszej wydajności i elastyczności — cech, które zwykle charakteryzują
pakiety zarządzania wątkami zaimplementowane w przestrzeni użytkownika. W szczególności
wątki użytkownika nie powinny wykonywać specjalnych, nieblokujących wywołań systemo-
wych lub sprawdzać wcześniej, czy wykonanie określonych wywołań systemowych jest bez-
pieczne. Niemniej jednak, kiedy wątek zablokuje się na wywołaniu systemowym lub sytuacji
braku strony, powinien mieć możliwość uruchomienia innego wątku w ramach tego samego
procesu, jeśli jakiś jest gotowy do działania.
Wydajność osiągnięto dzięki uniknięciu niepotrzebnych przejść pomiędzy przestrzenią użyt-
kownika a przestrzenią jądra. Jeśli np. wątek zablokuje się w oczekiwaniu na to, aż inny wątek
wykona jakieś działania, nie ma powodu informowania o tym jądra. Dzięki temu unika się
kosztów związanych z przejściami pomiędzy przestrzeniami jądra i użytkownika. Środowisko
wykonawcze przestrzeni użytkownika może samodzielnie zablokować wątek synchronizujący
i zainicjować nowy.
Kiedy jest wykorzystywany mechanizm aktywacji zarządcy, jądro przypisuje określoną liczbę
procesorów wirtualnych do każdego procesu i umożliwia środowisku wykonawczemu (prze-
strzeni użytkownika) na przydzielanie wątków do procesorów. Mechanizm ten może być rów-
nież wykorzystany w systemie wieloprocesorowym, w którym zamiast procesorów wirtualnych
są procesory fizyczne. Liczba procesorów wirtualnych przydzielonych do procesu zazwyczaj
początkowo wynosi jeden, ale proces może poprosić o więcej, a także zwrócić procesory, których
już nie potrzebuje. Jądro może również zwrócić wirtualne procesory przydzielone wcześniej, w celu
przypisania ich procesom bardziej potrzebującym.
Podstawowa zasada działania tego mechanizmu polega na tym, że jeśli jądro dowie się
o blokadzie wątku (np. z powodu uruchomienia blokującego wywołania systemowego lub braku
strony), to powiadamia o tym środowisko wykonawcze procesu. W tym celu przekazuje na stos
w postaci parametrów numer zablokowanego wątku oraz opis zdarzenia, które wystąpiło. Powia-
domienie może być zrealizowane dzięki temu, że jądro uaktywnia środowisko wykonawcze znaj-
dujące się pod znanym adresem początkowym. Jest to mechanizm w przybliżeniu analogiczny
do sygnałów w Uniksie. Mechanizm ten określa się terminem wezwanie (ang. upcall).
Po uaktywnieniu w taki sposób środowisko wykonawcze może zmienić harmonogram dzia-
łania swoich wątków. Zazwyczaj odbywa się to poprzez oznaczenie bieżącego wątku jako zablo-
kowany oraz pobranie innego wątku z listy wątków będących w gotowości, ustawienie jego
rejestrów i wznowienie działania. Później, kiedy jądro dowie się, że poprzedni wątek może ponow-
nie działać (np. potok, z którego czytał dane, zawiera dane lub brakująca strona została pobrana
z dysku), wykonuje kolejne wezwanie do środowiska wykonawczego w celu poinformowania go
o tym zdarzeniu. Środowisko wykonawcze może wówczas we własnej gestii natychmiast zre-
startować zablokowany wątek lub umieścić go na liście wątków do późniejszego uruchomienia.
Jeżeli wystąpi przerwanie sprzętowe, gdy działa wątek użytkownika, procesor przełącza się
do trybu jądra. Jeśli przerwanie jest spowodowane przez zdarzenie, którym przerwany proces
nie jest zainteresowany — np. zakończenie operacji wejścia-wyjścia innego procesu — to kiedy
procedura obsługi przerwania zakończy działanie, umieszcza przerwany wątek w tym samym
stanie, w jakim znajdował się on przed wystąpieniem przerwania. Jeśli jednak proces jest zainte-
resowany przerwaniem — np. nadejście strony wymaganej przez jeden z wątków procesu —
przerwany proces nie jest wznawiany. Zamiast tego jest on zawieszany, a na tym samym wir-
tualnym procesorze zaczyna działać środowisko wykonawcze — stan przerwanego wątku jest
w tym momencie umieszczony na stosie. W tym momencie środowisko wykonawcze podej-
muje decyzję o tym, jakiemu wątkowi przydzielić dany procesor: przerwanemu, nowo przygoto-
wanemu do działania czy jakiemuś innemu.
Wadą mechanizmu aktywacji zarządcy jest całkowite poleganie na wezwaniach — jest to
pojęcie, które narusza wewnętrzną strukturę każdego systemu warstwowego. Zwykle war-
stwa n oferuje określone usługi, które może wywołać warstwa n+1, warstwa n nie może jednak
wywoływać procedur w warstwie n+1. Mechanizm wezwań narusza tę podstawową zasadę.
Rysunek 2.11. Tworzenie nowego wątku po przybyciu pakietu: (a) zanim nadejdzie komunikat;
(b) po nadejściu komunikatu
może łatwo uzyskać dostęp do wszystkich tabel i urządzeń wejścia-wyjścia jądra, co może być
potrzebne do przetwarzania przerwań. Z drugiej strony działający błędnie wątek jądra może
zrobić więcej szkód niż błędnie działający wątek przestrzeni użytkownika. Jeśli np. działa zbyt
długo i nie ma możliwości jego wywłaszczenia, wchodzące dane mogą zostać utracone.
Możliwych jest wiele rozwiązań tego problemu. Jedno z nich polega na całkowitym wyłą-
czeniu zmiennych globalnych. Choć mogłoby się wydawać, że jest to rozwiązanie idealne, koli-
duje ono z większością istniejących programów. Inne rozwiązanie to przypisanie każdemu
wątkowi własnych, prywatnych zmiennych globalnych, tak jak pokazano na rysunku 2.13. W ten
sposób każdy wątek będzie miał własną, prywatną kopię zmiennej errno i innych zmiennych
globalnych, co pozwoli na uniknięcie konfliktów. Przyjęcie tego rozwiązania tworzy nowy poziom
zasięgu: zmienne widoczne dla wszystkich procedur wątku. Poziom ten występuje obok istnieją-
cych poziomów: zmienne widoczne tylko dla jednej procedury oraz zmienne widoczne w każ-
dym punkcie programu.
Dostęp do prywatnych zmiennych globalnych jest jednak nieco utrudniony, ponieważ więk-
szość języków programowania zapewnia sposób wyrażania zmiennych lokalnych i zmiennych
globalnych, ale nie ma form pośrednich. Można zaalokować fragment pamięci na zmienne glo-
balne i przekazać go do każdej procedury w wątku w postaci dodatkowego parametru. Chociaż
nie jest to zbyt eleganckie rozwiązanie, okazuje się skuteczne.
Alternatywnie można stworzyć nowe procedury biblioteczne do tworzenia, ustawiania i czyta-
nia tych zmiennych globalnych na poziomie wątku. Pierwsze wywołanie może mieć następującą
postać:
create_global("bufptr");
Wywołanie to alokuje pamięć dla wskaźnika o nazwie bufptr na stercie lub w specjalnym obszarze
pamięci zarezerwowanym dla wywołującego wątku. Niezależnie od tego, gdzie jest zaalokowa-
na pamięć, tylko wywołujący wątek ma dostęp do zmiennej globalnej. Jeśli inny wątek utworzy
zmienną globalną o tej samej nazwie, otrzyma inną lokalizację w pamięci — taką, która nie koli-
duje z istniejącą.
Do dostępu do zmiennych globalnych potrzebne są dwa wywołania: jedno do ich zapisywania
i drugie do odczytu. Do zapisywania potrzebne jest wywołanie postaci:
set_global("bufptr", &buf);
Zwraca ono adres zapisany w zmiennej globalnej. Dzięki temu można uzyskać dostęp do jej
danych.
Procesy często muszą się komunikować z innymi procesami. Przykładowo w przypadku potoku
w powłoce wyjście pierwszego procesu musi być przekazane do drugiego procesu, i tak dalej,
do niższych warstw. Tak więc występuje potrzeba komunikacji między procesami. Najlepiej,
gdyby miała ona czytelną strukturę i gdyby nie korzystano w niej z przerwań. W poniższych
punktach przyjrzymy się niektórym problemom związanym z komunikacją międzyprocesową
(ang. InterProcess Communication — IPC).
Mówiąc w skrócie: wiążą się z tym trzy problemy. O pierwszym była mowa już wcześniej:
w jaki sposób jeden proces może przekazywać informacje do innego? Drugi polega na zapobie-
ganiu sytuacji, w której dwa procesy (lub większa liczba procesów) wchodzą sobie wzajemnie
w drogę; np. dwa procesy w systemie rezerwacji biletów jednocześnie próbują przydzielić ostat-
nie miejsce w samolocie — każdy innemu klientowi. Trzeci wiąże się z odpowiednim kolejkowa-
niem, w przypadku gdy występują zależności: jeśli proces A generuje dane, a proces B je dru-
kuje, przed rozpoczęciem drukowania proces B musi czekać, aż proces A wygeneruje jakieś
dane. Wszystkie trzy wymienione problemy omówimy, począwszy od następnego punktu.
Warto również wspomnieć o tym, że dwa spośród tych problemów mają w równym stopniu
zastosowanie do wątków. Pierwszy z nich — przekazywanie informacji — jest łatwy w odnie-
sieniu do wątków, ponieważ wykorzystują one wspólną przestrzeń adresową — wątki w róż-
nych przestrzeniach adresowych, które muszą się komunikować, można zaliczyć do tej samej
klasy problemów, do których należy komunikacja pomiędzy procesami. Jednak pozostałe dwa
problemy — powstrzymanie od „skakania sobie do oczu” i kolejkowanie — mają zastosowanie
do procesów w takim samym stopniu, jak do wątków. Występują te same problemy i można
zastosować takie same rozwiązania. Poniżej omówimy te problemy w kontekście procesów.
Pamiętajmy jednak o tym, że te same problemy i rozwiązania mają zastosowanie także do wątków.
2.3.1. Wyścig
W niektórych systemach operacyjnych procesy, które ze sobą pracują, mogą wykorzystywać
pewien wspólny obszar pamięci, do którego wszystkie mogą zapisywać i z którego wszystkie
mogą czytać dane. Wspólne miejsce może znajdować się w pamięci głównej (np. w strukturze
danych jądra) lub we współdzielonym pliku. Lokalizacja wspólnej pamięci nie zmienia natury
komunikacji ani występujących problemów. Aby zobaczyć, jak wygląda komunikacja między pro-
cesami w praktyce, rozważmy prosty, ale klasyczny przykład: spooler drukarki. Kiedy proces
chce wydrukować plik, wpisuje nazwę pliku do specjalnego katalogu spoolera. Inny proces, demon
drukarki, okresowo sprawdza, czy są jakieś pliki do wydrukowania. Jeśli są, drukuje je, a następ-
nie usuwa ich nazwy z katalogu.
Wyobraźmy sobie, że katalog spoolera ma bardzo dużą liczbę gniazd ponumerowanych
0, 1, 2, … Każde z nich może przechowywać nazwę pliku. Wyobraźmy sobie również, że ist-
nieją dwie zmienne współdzielone: out — wskazująca na następny plik do wydrukowania oraz
in — wskazująca na następne wolne gniazdo w katalogu. Te dwie zmienne równie dobrze mogą
być przechowywane w pliku o objętości dwóch słów, który byłby dostępny dla wszystkich pro-
cesów. W określonym momencie gniazda 0 – 3 są puste (te pliki zostały już wydrukowane),
natomiast gniazda 4 – 6 są zajęte (nazwy plików zostały umieszczone w kolejce do wydruku).
Mniej więcej w tym samym czasie procesy A i B zdecydowały, że chcą umieścić plik w kolejce
do wydruku. Sytuację tę pokazano na rysunku 2.14.
Rysunek 2.14. Dwa procesy w tym samym czasie chcą uzyskać dostęp do wspólnej pamięci
W przypadkach, w których mają zastosowanie prawa Murphy’ego1, może się zdarzyć opisana
poniżej sytuacja. Proces A czyta zmienną in i zapisuje wartość 7 w zmiennej lokalnej next_free_
slot. W tym momencie zachodzi przerwanie zegara, a procesor decyduje, że proces A działał
wystarczająco długo, dlatego przełącza się do procesu B. Proces B również czyta zmienną in
i także uzyskuje wartość 7. On też zapisuje ją w lokalnej zmiennej next_free_slot. W tym
momencie oba procesy uważają, że następne wolne gniazdo ma numer 7.
Proces B kontynuuje działanie. Zapisuje nazwę swojego pliku w gnieździe nr 7 i aktualizuje
zmienną in na 8. Następnie wykonuje inne czynności.
W końcu znów uruchamia się proces A, zaczynając w miejscu, w którym przerwał działa-
nie. Odczytuje zmienną next_free_slot, znajduje tam wartość 7 i zapisuje swój plik w gnieź-
dzie nr 7, usuwając nazwę, którą przed chwilą umieścił tam proces B. Następnie oblicza war-
tość next_free_slot+1, co wynosi 8 i ustawia zmienną in na 8. Katalog spoolera jest teraz
wewnętrznie spójny, dlatego demon drukarki nie zauważy niczego złego. Jednak proces B nigdy
nie otrzyma żadnych wyników.
Użytkownik B będzie się kręcił w pobliżu pokoju drukarek przez lata, bezskutecznie cze-
kając na wydruk, który nigdy nie nadejdzie. Taka sytuacja, kiedy dwa procesy (lub większa
liczba procesów) czytają lub zapisują współdzielone dane, a rezultat zależy od tego, który pro-
ces i kiedy będzie działał, jest nazywana wyścigiem (ang. race condition). Debugowanie pro-
gramów, w których występują sytuacje wyścigu, w ogóle nie jest zabawne. Wyniki większości
testów wychodzą poprawnie, ale od czasu do czasu zdarza się coś dziwnego i trudnego do wyja-
śnienia. Niestety, wraz ze wzrostem wykorzystania współbieżności, ze względu na rosnącą
liczbę rdzeni instalowanych w komputerach, sytuacje wyścigu są coraz bardziej powszechne.
1
Jeśli coś może się nie udać, to się nie uda.
Wyłączanie przerwań
W systemie jednoprocesorowym najprostszym rozwiązaniem jest spowodowanie, aby każdy z pro-
cesów zablokował wszystkie przerwania natychmiast po wejściu do swojego regionu krytycz-
nego i ponownie je włączył bezpośrednio przed opuszczeniem regionu krytycznego. Jeśli prze-
rwania są zablokowane, nie można wygenerować przerwania zegara. W końcu procesor jest
przełączany od procesu do procesu w wyniku przerwania zegara lub innych przerwań. Przy
wyłączonych przerwaniach procesor nie może się przełączyć do innego procesu. Tak więc, jeśli
proces zablokuje przerwania, może czytać i aktualizować współdzieloną pamięć bez obawy o to,
że inny proces ją zmieni.
Takie podejście jest, ogólnie rzecz biorąc, nieatrakcyjne, ponieważ udzielenie procesom użyt-
kownika prawa do wyłączania przerwań nie jest zbyt rozsądne. Przypuśćmy, że jakiś proces
wyłączył przerwania i nigdy ich nie włączył. To byłby koniec systemu. Co więcej, w systemie
wieloprocesorowym (z dwoma procesorami lub ewentualnie większą ich liczbą) wyłączenie
przerwań dotyczy tylko tego procesora, który uruchomił instrukcję disable. Inne procesory
będą kontynuowały działanie i mogą skorzystać ze współdzielonej pamięci.
Z drugiej strony zablokowanie przerwań na czas wykonywania kilku instrukcji — np. aktu-
alizacji zmiennych lub list — jest często wygodne dla samego jądra. Gdyby przerwanie wystą-
piło w czasie, gdy lista gotowych procesów znajduje się w stanie niespójnym, mogłoby dojść do
sytuacji wyścigu. Konkluzja jest następująca: zablokowanie przerwań często jest przydatną
techniką wewnątrz samego systemu operacyjnego, ale nie nadaje się jako mechanizm wzajem-
nego wykluczania ogólnego przeznaczenia dla procesów użytkownika.
Prawdopodobieństwo osiągnięcia warunków wzajemnego wykluczania za pomocą blokowa-
nia przerwań — nawet w obrębie jądra — staje się coraz mniejsze ze względu na rosnącą liczbę
wielordzeniowych układów nawet w tanich komputerach PC. Dwa rdzenie występują już
Blokowanie zmiennych
W drugiej kolejności przeanalizujmy rozwiązanie programowe. Rozważmy sytuację, w której
mamy pojedynczą, współdzieloną zmienną (blokada), która początkowo ma wartość 0. Kiedy
proces chce wejść do regionu krytycznego, najpierw sprawdza zmienną blokada. Jeśli blokada
ma wartość 0, proces ustawia ją na 1 i wchodzi do regionu krytycznego. Jeśli blokada ma war-
tość 1, proces czeka do chwili, kiedy będzie ona miała wartość 0. Tak więc wartość 0 oznacza, że
żaden proces nie znajduje się w swoim regionie krytycznym, natomiast wartość 1 oznacza, że nie-
które procesy są w swoich regionach krytycznych.
Niestety, ten pomysł ma tę samą krytyczną wadę, jaką miał katalog spoolera. Załóżmy, że
proces przeczytał zmienną blokada i zauważył, że ma ona wartość 0. Zanim ustawił zmienną na 1,
zaczął działać inny proces i ustawił zmienną blokada na 1. Kiedy pierwszy proces wznowi działa-
nie, również ustawi zmienną blokada na 1 i dwa procesy znajdą się w swoich regionach kry-
tycznych w tym samym czasie.
Można by sądzić, że problem da się obejść poprzez odczytanie wartości zmiennej blokada,
a następnie ponowne sprawdzenie jej wartości bezpośrednio przed modyfikacją, ale w rzeczywi-
stości to nie pomaga. Znów występuje sytuacja wyścigu, jeśli drugi proces zmodyfikuje zmienną
bezpośrednio po tym, jak pierwszy proces zakończył drugi test.
Ścisła naprzemienność
Trzecie podejście do problemu wzajemnego wykluczania zaprezentowano na listingu 2.3. Frag-
ment tego programu, podobnie jak prawie wszystkie w tej książce, został napisany w języku C.
Wybrano go, ponieważ rzeczywiste systemy operacyjne zwykle są napisane w języku C (lub
czasami w C++), a nader rzadko w takich językach jak Java, Python czy Haskell. Język C ma
rozbudowane możliwości, jest wydajny i przewidywalny — są to cechy o kluczowym znaczeniu
dla pisania systemów operacyjnych. Java nie jest przewidywalna. Może jej bowiem zabraknąć
pamięci w kluczowym momencie, co spowoduje konieczność wywołania procesu odśmiecania
w celu odzyskania pamięci w najmniej odpowiednim czasie. Nie może się to zdarzyć w języku C,
ponieważ proces odśmiecania w języku C nie występuje. Porównanie ilościowe języków C, C++,
Java i czterech innych można znaleźć w [Prechelt, 2000].
W kodzie na listingu 2.3 o możliwości wejścia procesu do regionu krytycznego w celu odczy-
tania lub aktualizacji współdzielonej pamięci decyduje zmienna turn, która początkowo ma
wartość 0. Najpierw proces 0 bada zmienną turn, odczytuje, że ma ona wartość 0 i wchodzi do
regionu krytycznego. Proces 1 również odczytuje, że ma ona wartość 0, dlatego pozostaje w pętli
i co jakiś czas bada zmienną turn, aby trafić na moment, w którym osiągnie ona wartość 1. Ciągłe
testowanie zmiennej do czasu, aż osiągnie ona pewną wartość, nosi nazwę aktywnego oczeki-
wania. Należy raczej unikać stosowania tej techniki, ponieważ jest ona marnotrawstwem czasu
procesora. Stosuje się ją tylko wtedy, kiedy można się spodziewać, że oczekiwanie nie będzie
trwało zbyt długo. Blokadę wykorzystującą aktywne oczekiwanie określa się terminem blokady
pętlowej (ang. spin lock).
Listing 2.3. Proponowane rozwiązanie dla problemu regionów krytycznych: (a) proces 0,
(b) proces 1. W obu przypadkach należy zwrócić uwagę na średniki kończące instrukcje while
(a) (b)
while (TRUE){ while (TRUE) {
while (turn != 0) /* pętla */ ; while (turn != 1) /* pętla */;
region_krytyczny( ); region_krytyczny( );
turn = 1; turn = 0;
region_niekrytyczny(); region_niekrytyczny();
} }
Kiedy proces 0 opuszcza region krytyczny, ustawia zmienną turn na 1. Dzięki temu proces 1
może wejść do swojego regionu krytycznego. Załóżmy, że proces 1 szybko opuścił swój region
krytyczny, tak że oba procesy znajdują się teraz w regionach niekrytycznych, a zmienna turn
ma wartość 0. Teraz proces 0 szybko uruchamia swoją pętlę, opuszcza swój region krytyczny
i ustawia zmienną turn na 1. Od tej chwili oba procesy działają poza regionami krytycznymi.
Nagle proces 0 kończy działanie w swoim regionie niekrytycznym i powraca na początek
pętli. Niestety, w tym momencie nie jest uprawniony do wejścia do regionu krytycznego, ponie-
waż zmienna turn ma wartość 1, a proces 1 jest zajęty działaniem w regionie niekrytycznym.
Proces 0 oczekuje zatem w pętli while do czasu, aż proces 1 ustawi zmienną turn na 0. Mówiąc
inaczej, działanie po kolei nie jest dobrym pomysłem, jeśli jeden z procesów jest znacznie wol-
niejszy niż drugi.
Sytuacja ta narusza warunek nr 3 sformułowany powyżej: proces 0 jest blokowany przez
proces, który nie znajduje się w swoim regionie krytycznym. Wróćmy do katalogu spoolera
omówionego powyżej — jeśli teraz powiązalibyśmy region krytyczny z czytaniem i zapisywa-
niem katalogu spoolera, proces 0 nie mógłby drukować innego pliku, ponieważ proces 1 jest
zajęty czymś innym.
W rzeczywistości rozwiązanie to wymaga, aby dwa procesy ściśle naprzemiennie wchodziły
do swoich regionów krytycznych, np. plików w spoolerze. Żaden z procesów nie ma prawa do
skorzystania ze spoolera dwa razy z rzędu. Podczas gdy ten algorytm pozwala na uniknięcie
wszystkich sytuacji wyścigu, nie jest to poważne rozwiązanie, ponieważ narusza ono warunek 3.
Rozwiązanie Petersona
Dzięki połączeniu idei kolejki ze zmiennymi blokującymi i ostrzegawczymi holenderski mate-
matyk Thomas Dekker po raz pierwszy opracował programowe rozwiązanie wzajemnego
wykluczania, niewymagające ścisłej naprzemienności. Opis algorytmu Dekkera można znaleźć
w [Dijkstra, 1965].
W 1981 roku Gary L. Peterson znalazł znacznie prostszy sposób osiągnięcia wzajemnego
wykluczania. Dzięki temu rozwiązanie Dekkera stało się przestarzałe. Algorytm Petersona
pokazano na listingu 2.4. Algorytm ten składa się z dwóch procedur napisanych w ANSI C.
Oznacza to, że dla wszystkich zdefiniowanych i używanych funkcji muszą być dostarczone
prototypy funkcji. Jednak dla zaoszczędzenia miejsca w tym i w kolejnych przykładach nie poka-
żemy prototypów.
Instrukcja TSL
Teraz przyjrzyjmy się rozwiązaniu wymagającemu trochę pomocy ze strony sprzętu. Niektóre
komputery, zwłaszcza te, które zaprojektowano do pracy z wieloma procesorami, mają instrukcję
następującej postaci:
TSL REGISTER,LOCK
Instrukcja TSL (Test and Set Lock — testuj i ustaw blokadę) działa w następujący sposób: odczytuje
zawartość słowa pamięci lock do rejestru RX, a następnie zapisuje niezerową wartość pod adresem
pamięci lock. Dla operacji czytania słowa i zapisywania go jest zagwarantowana niepodzielność
— do zakończenia instrukcji żaden z procesorów nie może uzyskać dostępu do słowa pamięci.
Procesor, który uruchamia instrukcję TSL, blokuje magistralę pamięci. W ten sposób uniemoż-
liwia innym procesorom korzystanie z pamięci, dopóki sam nie zakończy z nią operacji.
Warto zwrócić uwagę na fakt, że zablokowanie magistrali pamięci bardzo się różni od wyłą-
czenia przerwań. W przypadku zablokowania przerwań, jeśli po wykonaniu operacji odczytu na
słowie pamięci będzie wykonany zapis, drugi procesor korzystający z magistrali w dalszym ciągu
ma możliwość dostępu do słowa pamięci pomiędzy odczytem a zapisem. Zablokowanie prze-
rwań w procesorze 1 nie ma żadnego wpływu na procesor 2. Jedynym sposobem na to, by zablo-
kować procesorowi 2 dostęp do pamięci do chwili zakończenia pracy przez procesor 1, jest zablo-
kowanie magistrali. To wymaga specjalnego mechanizmu sprzętowego (dokładniej ustawienia
linii informującej o tym, że magistrala jest zablokowana i nie jest dostępna dla procesorów, poza
tym, który ją zablokował).
Aby skorzystać z instrukcji TSL, użyjemy współdzielonej zmiennej lock, pozwalającej na
koordynację dostępu do współdzielonej pamięci. Jeśli zmienna lock ma wartość 0, dowolny
proces może ustawić ją na 1 za pomocą instrukcji TSL, a następnie czytać lub zapisywać współ-
dzieloną pamięć. Po zakończeniu operacji proces ustawia zmienną lock z powrotem na 0, korzy-
stając ze standardowej instrukcji move.
W jaki sposób można skorzystać z tej instrukcji w celu uniemożliwienia dwóm procesom
jednoczesnego dostępu do swoich regionów krytycznych? Rozwiązanie pokazano na listingu 2.5.
Pokazano tam procedurę składającą się z czterech instrukcji w fikcyjnym (ale typowym) języku
asemblera. Pierwsza instrukcja kopiuje starą wartość zmiennej lock do rejestru, po czym ustawia
zmienną lock na 1. Następnie stara wartość jest porównywana z wartością 0. Wartość różna od
zera oznacza, że wcześniej ustawiono blokadę, dlatego program wraca do początku i testuje
zmienną jeszcze raz. Prędzej czy później zmienna przyjmie wartość 0 (kiedy proces znajdujący
się w danej chwili w regionie krytycznym zakończy w nim pracę), a procedura zwróci sterowa-
nie, wcześniej ustawiwszy blokadę. Usuwanie blokady jest bardzo proste. Program po prostu
zapisuje 0 w zmiennej lock. Nie są potrzebne żadne specjalne instrukcje synchronizacji.
leave_region:
MOVE LOCK,#0 | Zapisanie 0 w zmiennej lock
RET | Zwrócenie sterowania do wywołującego
Jedno z rozwiązań problemu regionu krytycznego jest teraz proste. Przed wejściem do regionu
krytycznego proces wywołuje funkcję enter_region. Funkcja ta realizuje aktywne oczekiwanie
do chwili, kiedy blokada będzie zwolniona. Następnie ustawia blokadę i zwraca sterowanie. Po
opuszczeniu regionu krytycznego proces wywołuje procedurę leave_region, która zapisuje 0
w zmiennej lock. Podobnie jak w przypadku wszystkich rozwiązań, które bazują na regionach
krytycznych, aby metoda mogła działać, procesy muszą w odpowiednich momentach wywołać
instrukcje enter_region i leave_region. Jeśli jakiś proces będzie „oszukiwał”, warunek wzajem-
nego wykluczania nie będzie mógł być spełniony. Inaczej mówiąc, regiony krytyczne działają
tylko wtedy, gdy procesy współpracują.
Alternatywą dla instrukcji TSL jest XCHG. Jej działanie polega na zamianie zawartości dwóch
lokalizacji — np. rejestru i słowa pamięci. Kod bazujący na rozkazie XCHG zaprezentowano na
listingu 2.6. Jak można zauważyć, zasadniczo jest on identyczny jak rozwiązanie z instrukcją TSL.
Niskopoziomową synchronizację w oparciu o rozkaz XCHG wykorzystują wszystkie procesory
x86 firmy Intel.
Problem producent-konsument
W celu zaprezentowania przykładu użycia tych prymitywów rozważmy problem producent-kon-
sument (znany także jako problem ograniczonego bufora — ang. bounded-buffer). Dwa procesy
współdzielą bufor o stałym rozmiarze. Jeden z nich, producent, umieszcza informacje w buforze,
natomiast drugi, konsument, je z niego pobiera (można również uogólnić problem dla m produ-
centów i n konsumentów; my jednak będziemy rozważać przypadek tylko jednego producenta
i jednego konsumenta, ponieważ to założenie upraszcza rozwiązania).
Problemy powstają w przypadku, kiedy producent chce umieścić nowy element w buforze,
który jest już pełny. Rozwiązaniem dla producenta jest przejście do stanu uśpienia i zamówie-
nie „budzenia” w momencie, kiedy konsument usunie z bufora jeden lub kilka elementów. Na
podobnej zasadzie, jeśli konsument zechce usunąć element z bufora i zobaczy, że bufor jest
pusty, przechodzi do stanu uśpienia i pozostaje w nim dopóty, dopóki producent nie umieści
jakichś elementów w buforze i nie obudzi konsumenta.
To podejście wydaje się dość proste, ale prowadzi do sytuacji wyścigu, podobnej do tych,
z jakimi mieliśmy do czynienia wcześniej, podczas omawiania katalogu spoolera. Do śledzenia
liczby elementów w buforze potrzebna będzie zmienna count. Jeśli maksymalna liczba elemen-
tów, jakie mogą się zmieścić w buforze, wynosi N, w kodzie producenta trzeba będzie najpierw
sprawdzić, czy count równa się N. Jeśli tak, to producent przechodzi do stanu uśpienia. Jeśli
nie, producent dodaje element do bufora i inkrementuje zmienną count.
Kod konsumenta jest podobny: najpierw testowana jest zmienna count w celu sprawdzenia,
czy ma wartość 0. Jeśli tak, przechodzi do stanu uśpienia. Jeśli ma wartość niezerową, usuwa
element z bufora i dekrementuje licznik. Każdy z procesów sprawdza również, czy należy obu-
dzić inny proces. Jeśli tak, to go budzi. Kod dla producenta i konsumenta zaprezentowano na
listingu 2.7.
W celu wyrażenia wywołań systemowych, takich jak sleep i wakeup w języku C, pokażemy je
jako wywołania do procedur bibliotecznych. Nie są one częścią standardowej biblioteki C, ale
przypuszczalnie będą dostępne w każdym systemie, w którym są wykorzystywane wspomniane
wywołania systemowe. Procedury insert_item i remove_item, których nie pokazano, obsługują
operacje umieszczania elementów w buforze i pobierania elementów z bufora.
Teraz powróćmy na chwilę do sytuacji wyścigu. Może się ona zdarzyć ze względu na to, że
dostęp do zmiennej count jest nieograniczony. W konsekwencji prawdopodobna wydaje się nastę-
pująca sytuacja: bufor jest pusty, a konsument właśnie przeczytał zmienną count i dowiedział
się, że ma ona wartość 0. W tym momencie program szeregujący zadecydował, że czasowo
przerwie działanie konsumenta i uruchomi producenta. Producent wstawił element do bufora,
przeprowadził inkrementację zmiennej count i zauważył, że teraz ma ona wartość 1. Na pod-
stawie tego, że zmienna count wcześniej miała wartość 0, producent sądzi, że konsument jest
uśpiony, a w związku z tym wywołuje wakeup w celu zbudzenia go.
Niestety, konsument nie jest jeszcze logicznie uśpiony, zatem sygnał pobudki nie zadziała.
Kiedy konsument ponownie zadziała, sprawdzi wartość zmiennej count, którą przeczytał wcze-
śniej, dowie się, że ma ona wartość 0 i przejdzie do stanu uśpienia. Prędzej czy później produ-
cent wypełni bufor i również przejdzie do uśpienia. Oba procesy będą spały na zawsze.
Sedno tego problemu polega na tym, że sygnał wakeup wysłany do procesu, który jeszcze nie
spał, został utracony. Gdyby nie został utracony, wszystko działałoby jak należy. Szybkim roz-
wiązaniem problemu jest modyfikacja reguł polegająca na dodaniu bitu oczekiwania na sygnał
wakeup. Bit ten jest ustawiany w przypadku, gdy sygnał wakeup zostanie wysłany do procesu,
który nie jest uśpiony. Kiedy proces spróbuje później przejść do stanu uśpienia, to w przypadku
gdy jest ustawiony bit oczekiwania na sygnał wakeup, zostanie on wyłączony, ale proces nie
przejdzie do stanu uśpienia. Bit oczekiwania na sygnał wakeup jest skarbonką pozwalającą na
przechowywanie sygnałów wakeup. Konsument zeruje bit oczekiwania na sygnał wakeup w każdej
iteracji pętli.
O ile pojedynczy bit oczekiwania na sygnał wakeup rozwiązuje problem w tym prostym
przykładzie, o tyle łatwo skonstruować przykłady z trzema procesami lub większą ich liczbą,
w których jeden bit oczekiwania na sygnał wakeup nie wystarczy. Można by stworzyć kolejną
łatkę i dodać jeszcze jeden bit oczekiwania na sygnał wakeup lub stworzyć ich 8, albo nawet 32,
ale zasadniczy problem i tak pozostanie.
2.3.5. Semafory
Taka była sytuacja w 1965 roku, kiedy Dijkstra zaproponował użycie zmiennej całkowitej do
zliczania liczby zapisanych sygnałów wakeup. W swojej propozycji przedstawił nowy typ zmien-
nej, którą nazwał semaforem. Semafor może mieć wartość 0, co wskazuje na brak zapisanych
sygnałów wakeup, lub jakąś wartość dodatnią, gdyby istniał jeden zaległy sygnał wakeup lub więcej
takich sygnałów.
Dijkstra zaproponował dwie operacje: down i up (odpowiednio uogólnienia operacji sleep
i wakeup). Operacja down na semaforze sprawdza, czy wartość zmiennej jest większa od 0. Jeśli
tak, dekrementuje tę wartość (tzn. wykonuje operację up z argumentem 1 dla zapisanych sygna-
łów wakeup) i kontynuuje. Jeśli wartość wynosi 0, proces jest przełączany na chwilę w stan
uśpienia bez wykonywania operacji down. Sprawdzanie wartości, modyfikowanie jej i ewentual-
nie przechodzenie do stanu uśpienia jest wykonywane w pojedynczej i niepodzielnej akcji. Ist-
nieje gwarancja, że kiedy rozpocznie się operacja na semaforze, żaden inny proces nie będzie mógł
uzyskać do niego dostępu, aż operacja zakończy się lub zostanie zablokowana. Ta niepodziel-
ność ma absolutnie kluczowe znaczenie dla rozwiązywania problemów synchronizacji i unika-
nia sytuacji wyścigu. Niepodzielne akcje, w których grupa powiązanych operacji albo jest wyko-
nywana bez przerwy, albo nie jest wykonywana wcale, są niezwykle ważne w wielu obszarach
informatyki.
Operacja up inkrementuje wartość wskazanego semafora. Jeśli na tym semaforze był uśpiony
jeden proces lub więcej procesów, które nie mogły wykonać wcześniejszej operacji down, to
system wybiera jeden z nich (np. losowo) i zezwala na dokończenie operacji down. Tak więc po
wykonaniu operacji up na semaforze, na którym były uśpione procesy, semafor w dalszym ciągu
będzie miał wartość 0, ale będzie na nim uśpiony o jeden proces mniej. Operacja inkrementacji
semafora i budzenia jednego procesu również jest niepodzielna. Żaden proces nigdy nie blo-
kuje wykonania operacji up, podobnie jak w poprzednim modelu żaden proces nie mógł bloko-
wać operacji wakeup.
Tak na marginesie — w oryginalnym artykule Dijkstra zamiast nazw operacji down i up użył
odpowiednio nazw P i V. Ponieważ nie mają one znaczenia mnemonicznego dla ludzi nieznają-
cych języka holenderskiego i niewielkie znaczenie dla tych, którzy go znają — Proberen (pró-
buj) i Verhogen (podnieś) — zamiast nich będziemy używać nazw down i up. Po raz pierwszy
operacje te wprowadzono w języku programowania Algol 68.
}
void consumer(void)
{
int item;
while (TRUE) { /* pętla nieskończona */
down(&full); /* dekrementacja licznika zajętych */
down(&muteks); /* wejście do regionu krytycznego */
item = remove_item( ); /* pobranie elementu z bufora */
up(&muteks); /* opuszczenie regionu krytycznego */
up(&empty); /* inkrementacja licznika pustych miejsc */
consume_item(item); /* wykonanie operacji z elementem */
}
}
Należy zdać sobie sprawę z tego, że użycie instrukcji TSL lub XCHG w celu uniemożliwienia
kilku procesorom korzystania z semafora w tym samym czasie różni się od aktywnego oczeki-
wania producenta lub konsumenta na opróżnienie lub wypełnienie bufora. Operacja na semafo-
rze zajmuje tylko kilka mikrosekund, podczas gdy oczekiwanie producenta lub konsumenta
mogło trwać dowolnie długo.
W pokazanym rozwiązaniu użyto trzech semaforów: semafor full służy do zliczania gniazd,
które są zajęte, semafor empty służy do zliczania gniazd, które są puste, natomiast semafor mutex
zapewnia, aby producent i konsument nie korzystali z bufora jednocześnie. Semafor full począt-
kowo ma wartość 0, empty ma początkową wartość równą liczbie gniazd w buforze, natomiast
mutex początkowo ma wartość 1. Semafory inicjowane wartością 1 i używane przez dwa procesy
lub większą ich liczbę po to, by zyskać pewność, że tylko jeden z nich może wejść do swojego
regionu krytycznego w tym samym czasie, nazywają się semaforami binarnymi. Jeśli proces
wykona operację down bezpośrednio przed wejściem do swojego regionu krytycznego i up bez-
pośrednio po jego opuszczeniu, wzajemne wykluczanie jest zapewnione.
Teraz, kiedy dysponujemy dobrymi prymitywami komunikacji między procesami, powróćmy
na chwilę do sekwencji przerwań pokazanej na rysunku 2.5. W systemie, który używa semafo-
rów, naturalnym sposobem ukrycia przerwań jest powiązanie semafora, początkowo ustawio-
nego na 0, z każdym urządzeniem wejścia-wyjścia. Bezpośrednio po uruchomieniu urządzenia
wejścia-wyjścia, proces zarządzający wykonuje operację down na powiązanym z nim semaforze,
a tym samym natychmiast się blokuje. Kiedy nadejdzie przerwanie, procedura obsługi przerwa-
nia wykonuje operację up na powiązanym semaforze. Dzięki temu proces jest gotowy do ponow-
nego uruchomienia. W tym modelu krok 5. z rysunku 2.5 składa się z wykonania operacji up na
semaforze powiązanym z urządzeniem. Dzięki temu w kroku 6. program szeregujący może
uruchomić menedżera urządzeń. Oczywiście w przypadku, gdy kilka procesów będzie goto-
wych, program szeregujący będzie mógł uruchomić w następnej kolejności ważniejszy proces.
Niektóre z wykorzystanych algorytmów szeregowania omówimy w dalszej części niniejszego
rozdziału.
W przykładzie z listingu 2.8 użyliśmy semaforów na dwa sposoby. Różnica pomiędzy nimi
jest na tyle ważna, że należy ją wyjaśnić. Semafor mutex jest wykorzystywany do wzajemnego
wykluczania. Służy do tego, by można było zagwarantować, że tylko jeden proces w danym czasie
odczytuje bufor i powiązane z nim zmienne. To wzajemne wykluczanie jest wymagane w celu
przeciwdziałania chaosowi. Zagadnienie wzajemnego wykluczania oraz sposobów osiągnięcia
tego stanu omówimy w następnym punkcie.
Poza wzajemnym wykluczaniem semafory wykorzystuje się do synchronizacji. Semafory
full i empty są potrzebne do tego, by zagwarantować, że określone sekwencje zdarzeń wystąpią
lub nie. W tym przypadku zapewniają one, że producent przestanie działać, kiedy bufor będzie
pełny, oraz że konsument przestanie działać, kiedy bufor będzie pusty. To zastosowanie różni
się od realizacji wzajemnego wykluczania.
2.3.6. Muteksy
Jeśli nie jest potrzebna właściwość zliczania, czasami używa się uproszczonej wersji semaforów
zwanych muteksami (ang. mutex). Muteksy nadają się wyłącznie do zarządzania wzajemnym
wykluczaniem niektórych współdzielonych zasobów lub fragmentu kodu. Są one łatwe i wydajne
do implementacji. Dzięki temu okazują się szczególnie przydatne w pakietach obsługi wątków,
które w całości są implementowane w przestrzeni użytkownika.
Muteks jest zmienną, która może znajdować się w jednym z dwóch stanów: „odblokowany”
lub „zablokowany”. W efekcie do jego zaprezentowania jest potrzebny tylko 1 bit. W praktyce
w tej roli często wykorzystuje się dane integer, przy czym wartość 0 oznacza „odblokowany”,
natomiast wszystkie inne wartości oznaczają „zablokowany”. Z muteksami wykorzystuje się dwie
procedury. Kiedy wątek (lub proces) potrzebuje dostępu do regionu krytycznego, wywołuje
funkcję mutex_lock. Jeśli muteks jest już odblokowany (co oznacza, że jest dostępny region kry-
tyczny), wywołanie kończy się sukcesem i wątek wywołujący może wejść do regionu krytycznego.
Z drugiej strony, jeśli muteks jest już zablokowany, wątek wywołujący zablokuje się do
czasu, kiedy wątek znajdujący się w regionie krytycznym zakończy w nim działania i wywoła
funkcję mutex_unlock. Jeśli na muteksie jest zablokowanych wiele wątków, losowo wybierany
jest jeden z nich i otrzymuje zgodę na założenie blokady.
Ponieważ muteksy są tak proste, można je z łatwością zaimplementować w przestrzeni użyt-
kownika, pod warunkiem że będą dostępne instrukcje TSL lub XCHG. Kod operacji mutex_lock
i mutex_unlock, które można wykorzystać z pakietem obsługi wątków poziomu użytkownika
pokazano na listingu 2.9. Rozwiązanie z instrukcją XCHG jest w zasadzie takie samo.
Kod operacji mutex_lock jest podobny do kodu operacji enter_region z listingu 2.5 z jedną
zasadniczą różnicą. Kiedy funkcja enter_region nie zdoła wejść do regionu krytycznego, wielo-
krotnie powtarza testowanie blokady (aktywne oczekiwanie). Kiedy skończy się przydzielony
czas, zaczyna działać inny proces. Prędzej czy później proces utrzymujący blokadę zacznie dzia-
łać i ją zwolni.
W przypadku zastosowania wątków (użytkownika) sytuacja jest inna, ponieważ nie ma zegara,
który zatrzymuje zbyt długo działające wątki. W konsekwencji wątek chcący uzyskać blokadę
poprzez aktywne oczekiwanie będzie wykonywał się w pętli nieskończonej. W związku z tym
nigdy nie uzyska blokady, ponieważ nigdy nie pozwoli żadnemu innemu wątkowi na uruchomie-
nie się i zwolnienie blokady.
W tym miejscu ujawnia się różnica pomiędzy funkcjami enter_region i mutex_lock. Kiedy tej
drugiej nie uda się ustawić blokady, wywołuje funkcję thread_yield po to, by przekazać procesor
do innego wątku. W konsekwencji nie ma aktywnego oczekiwania. Kiedy wątek uruchomi się
następnym razem, ponownie analizuje blokadę.
Ponieważ thread_yield to wywołanie do procesu zarządzającego wątkami w przestrzeni użyt-
kownika, jest ono bardzo szybkie. W konsekwencji ani wywołanie mutex_lock, ani mutex_unlock
nie wymagają żadnych wywołań jądra. Dzięki ich wykorzystaniu wątki poziomu użytkownika
mogą się synchronizować w całości w przestrzeni użytkownika, z wykorzystaniem procedur
wymagających zaledwie kilku instrukcji.
Opisany powyżej system muteksa jest prymitywnym zbiorem wywołań. W przypadku każdego
oprogramowania zawsze występuje potrzeba dodatkowych własności. Prymitywy synchronizacji
nie są tu wyjątkiem — np. czasami w pakiecie obsługi wątków jest wywołanie mutex_trylock,
które albo ustanawia blokadę, albo zwraca kod błędu, ale nie blokuje się. Wywołanie to daje
wątkowi możliwość decydowania o tym, co zrobić w następnej kolejności, jeśli istnieją jakieś
alternatywy do oczekiwania.
Istnieje pewien subtelny problem, który na razie przemilczeliśmy, a który warto jawnie
przedstawić. W przypadku pakietu obsługi wątków działającego w przestrzeni użytkownika nie
ma problemu z tym, że do tego samego muteksa ma dostęp wiele wątków, ponieważ wszystkie
wątki działają we wspólnej przestrzeni adresowej. Jednak w przypadku większości wcześniej
omawianych rozwiązań takich problemów — np. algorytmu Petersona i semaforów — przyj-
muje się założenie, że przynajmniej do fragmentu współdzielonej pamięci (np. do określonego
słowa) ma dostęp wiele procesów. Jeśli procesy posługują się rozdzielnymi przestrzeniami adre-
sowymi, tak jak powiedzieliśmy, to w jaki sposób mogą one współdzielić zmienną turn z algo-
rytmu Petersona, semafory albo wspólny bufor?
Są dwie odpowiedzi. Po pierwsze niektóre ze współdzielonych struktur danych, np. sema-
fory, mogą być przechowywane w jądrze, a dostęp do nich jest możliwy tylko za pomocą wywo-
łań systemowych. Takie podejście eliminuje problem. Po drugie w większości systemów opera-
cyjnych (włącznie z systemami UNIX i Windows) istnieje mechanizm, który pozwala procesom
współdzielić pewną część swojej przestrzeni adresowej z innymi procesami. W ten sposób
bufory i inne struktury danych mogą być współdzielone. W najgorszej sytuacji, kiedy nie jest moż-
liwe nic innego, można wykorzystać współdzielony plik.
Jeśli dwa procesy lub większa ich liczba współdzielą większość lub całość swoich prze-
strzeni adresowych, różnica pomiędzy procesami a wątkami staje się w pewnym stopniu roz-
myta, niemniej jednak istnieje. Dwa procesy, które współdzielą przestrzeń adresową, posługują
się różnymi otwartymi plikami, licznikami czasu alarmów i innymi właściwościami procesów,
podczas gdy wątki w obrębie pojedynczego procesu je współdzielą. Ponadto wiele procesów
współdzielących przestrzeń adresową nie dorównuje wydajnością wielu wątkom działającym
w przestrzeni użytkownika, ponieważ w ich zarządzaniu aktywny udział bierze jądro.
Futeksy
Wraz ze wzrostem znaczenia współbieżności istotne stają się skuteczne mechanizmy synchro-
nizacji i blokowania, ponieważ zapewniają wydajność. Blokady pętlowe (ang. spin locks) są szyb-
kie, jeśli czas oczekiwania jest krótki, w przeciwnym razie powodują marnotrawienie cykli
procesora. Z tego powodu, w przypadku gdy rywalizacja jest duża, bardziej wydajne jest zablo-
kowanie procesu i zlecenie jądru, aby odblokowanie go nastąpiło dopiero wtedy, gdy blokada
zostanie zwolniona. Niestety, to powoduje odwrotny problem: sprawdza się w przypadku dużej
rywalizacji, ale ciągłe przełączanie do jądra jest kosztowne, gdy rywalizacji nie ma zbyt wiele.
Co gorsza, to, ile będzie rywalizacji o blokady, nie jest łatwe do przewidzenia.
Ciekawym rozwiązaniem, które stara się połączyć najlepsze cechy z obu światów, są tzw.
futeksy czyli szybkie muteksy w przestrzeni użytkownika (ang. fast user space muteks). Futeks
jest własnością Linuksa, która implementuje podstawowe blokowanie (podobnie jak muteks),
ale unika odwoływania się do jądra, jeśli nie jest to bezwzględnie konieczne. Ponieważ przełą-
czanie się do jądra i z powrotem jest dość kosztowne, zastosowanie futeksów znacznie popra-
wia wydajność. Futeks składa się z dwóch części: usługi jądra i biblioteki użytkownika. Usługa
jądra zapewnia „kolejkę oczekiwania”, która umożliwia oczekiwanie na blokadę wielu procesom.
Procesy nie będą działać, jeśli jądro wyraźnie ich nie odblokuje. Umieszczenie procesu w kolejce
oczekiwania wymaga (kosztownego) wywołania systemowego, dlatego należy go unikać. Z tego
powodu, w przypadku braku rywalizacji, futeks działa w całości w przestrzeni użytkownika.
W szczególności procesy współdzielą zmienną blokady — to wyszukana nazwa dla 32-bitowej
liczby integer, spełniającej rolę blokady. Załóżmy, że początkowo blokada ma wartość 1 — co
zgodnie z założeniem oznacza, że blokada jest wolna. Wątek przechwytuje blokadę przez wyko-
nanie atomowej operacji „dekrementacji ze sprawdzeniem” (atomowe funkcje w Linuksie skła-
dają się z wywołania asemblerowego inline wewnątrz funkcji C i są zdefiniowane w plikach
nagłówkowych). Następnie wątek sprawdza wynik, aby przekonać się, czy blokada jest wolna.
Jeśli nie była w stanie zablokowanym, nie ma problemu — wątek z powodzeniem przechwycił
blokadę. Jeśli jednak blokada jest utrzymywana przez inny wątek, to wątek starający się o blo-
kadę musi czekać. W tym przypadku biblioteka obsługi futeksu nie wykonuje pętli, ale używa
wywołania systemowego w celu umieszczenia wątku w kolejce oczekiwania w jądrze. W tej
sytuacji koszt przełączenia do jądra jest uzasadniony, ponieważ wątek i tak był zablokowany.
Gdy wątek zakończy operację wymagającą blokady, zwalnia ją, wykonując atomową operację
„inkrementacji ze sprawdzaniem”. Następnie sprawdza wynik, aby zobaczyć, czy jakieś procesy
nadal są zablokowane w kolejce oczekiwania w jądrze. Jeśli tak, informuje jądro, że może ono
teraz odblokować jeden lub więcej spośród tych procesów. Jeśli nie ma rywalizacji, jądro w ogóle
nie wykonuje żadnych operacji.
}
int main(int argc, char **argv)
{
pthread_t pro, con;
pthread_mutex_init(&the_mutex, 0);
pthread_cond_init(&condc, 0);
pthread_cond_init(&condp, 0);
pthread_create(&con, 0, consumer, 0);
pthread_create(&pro, 0, producer, 0);
pthread_join(pro, 0);
pthread_join(con, 0);
pthread_cond_destroy(&condc);
pthread_cond_destroy(&condp);
pthread_mutex_destroy(&the_mutex);
}
2.3.7. Monitory
W przypadku użycia semaforów i muteksów komunikacja między procesami wydaje się łatwa.
Zgadza się? Nic bardziej mylnego. Przyjrzyjmy się dokładniej kolejności operacji down przed
wstawieniem lub usunięciem elementów z bufora w kodzie na listingu 2.8. Załóżmy, że dwie
operacje down w kodzie producenta zamieniono miejscami. W związku z tym zmienna mutex została
poddana dekrementacji przed wykonaniem operacji empty, a nie po niej. Gdyby bufor był w całości
wypełniony, producent by się zablokował, ustawiając zmienną mutex na 0. W konsekwencji
przy następnej próbie dostępu konsumenta do bufora, wykonałby on operację down w odniesieniu
do zmiennej mutex (teraz o wartości 0) i też by się zablokował. Oba procesy pozostałyby zabloko-
wane na zawsze i nigdy nie wykonałyby żadnej pracy.
Ta niefortunna sytuacja nazywa się zakleszczeniem (ang. deadlock). Zakleszczenia będziemy
omawiać bardziej szczegółowo w rozdziale 6.
Problem ten wskazano po to, by pokazać, jak bardzo trzeba być ostrożnym podczas pracy
z semaforami. Wystarczy popełnić jeden subtelny błąd i wszystko się zatrzymuje. To tak jak
programowanie w języku asemblera, tylko że jeszcze trudniejsze, ponieważ błędami są sytuacje
wyścigu, zakleszczenia i inne formy nieprzewidywalnych i trudnych do powtórzenia zachowań.
Aby pisanie prawidłowych programów było łatwiejsze, [Brinch Hansen, 1973] i [Hoare, 1974]
zaproponowali prymityw synchronizacji wyższego poziomu, zwany monitorem. Ich propozycje
nieco się różniły, co opisano poniżej. Monitor jest kolekcją procedur, zmiennych i struktur danych
pogrupowanych ze sobą w specjalnym rodzaju modułu lub pakietu. Procesy mogą wywoływać
procedury w monitorze, kiedy tylko tego chcą, ale z poziomu procedur zadeklarowanych poza
monitorem nie mogą bezpośrednio korzystać z wewnętrznych struktur danych monitora. Na
listingu 2.11 zilustrowano monitor napisany w wymyślonym języku Pidgin Pascal. Nie można tu
użyć języka C, ponieważ monitory są konstrukcjami języka, a język C ich nie posiada.
. . .
end;
end monitor;
Monitory mają ważną właściwość, dzięki której przydają się jako mechanizm implementacji
wzajemnego wykluczania: w dowolnym momencie w monitorze może być aktywny tylko jeden
proces. Monitory są konstrukcją języka programowania. Dzięki temu kompilator wie, że mają one
specjalny charakter, i wywołania do procedur monitora może obsługiwać inaczej niż wywołania
innych procedur. Zazwyczaj kiedy proces wywoła procedurę monitora, w kilku pierwszych instruk-
cjach procedury następuje sprawdzenie, czy w obrębie monitora jest aktywny jakiś inny pro-
ces. Jeśli tak, to proces wywołujący zostanie zawieszony do czasu opuszczenia monitora przez
inny proces. Jeżeli żaden inny proces nie korzysta z monitora, proces wywołujący może do
niego wejść.
Implementacja wzajemnego wykluczania dla procedur monitora leży w gestii kompilatora,
ale powszechnie stosowanym sposobem jest użycie muteksa lub semafora binarnego. Ponieważ
to kompilator, a nie programista zapewnia wzajemne wykluczanie, istnieje znacznie mniejsze
ryzyko wystąpienia problemów. Osoba pisząca monitor nie musi wiedzieć, w jaki sposób kompi-
lator zapewnia wzajemne wykluczanie. Wystarczy wiedzieć, że dzięki przekształceniu wszyst-
kich regionów krytycznych w procedury monitora żadne dwa procesy nigdy jednocześnie nie
wejdą do swoich regionów krytycznych.
Chociaż, jak widzieliśmy powyżej, monitory zapewniają łatwy sposób osiągnięcia wzajemnego
wykluczania, to nie wystarcza. Potrzebny jest również sposób na to, by procesy się blokowały w cza-
sie, gdy nie mogą kontynuować działania. W przypadku problemu producent-konsument można
łatwo umieścić wszystkie testy sprawdzające, czy bufor jest pełny lub czy jest on pusty w procedu-
rach monitora. Jak jednak powinien zablokować się producent, jeśli się okaże, że bufor jest pełny?
Rozwiązaniem jest wprowadzenie zmiennych warunkowych razem z dwiema operacjami,
które są na nich wykonywane: wait i signal. Kiedy procedura monitora wykryje, że nie może
kontynuować działania (np. producent odkryje, że bufor jest pełny), wykonuje operację wait na
wybranej zmiennej warunkowej, np. full. Operacja ta powoduje zablokowanie procesu wywołu-
jącego. Pozwala ona również innemu procesowi, który wcześniej nie mógł wejść do monitora,
aby teraz do niego wszedł. Zmienne warunkowe oraz wspomniane operacje omawialiśmy wcze-
śniej, w kontekście pakietu Pthreads.
Inny proces, np. konsument, może obudzić swojego uśpionego partnera poprzez przesłanie
sygnału z wykorzystaniem zmiennej warunkowej, na którą jego partner oczekuje. Aby uniknąć
jednoczesnego występowania dwóch aktywnych procesów w monitorze, potrzebna jest reguła,
która informuje o tym, co się dzieje po wykonaniu operacji signal. Charles A.R. Hoare zapro-
ponował umożliwienie działania przebudzonemu procesowi i zawieszenie drugiego z nich. Per
Brinch Hansen zaproponował uściślenie problemu poprzez wymaganie od procesu wykonują-
cego operację signal natychmiastowego opuszczenia monitora. Inaczej mówiąc, instrukcja signal
może występować w procedurze monitora tylko jako ostatnia. My skorzystamy z propozycji
Brincha Hansena, ponieważ jest ona pojęciowo prostsza, a poza tym łatwiejsza do zaimplemen-
towania. Jeśli operacja signal zostanie wykonana na zmiennej warunkowej, na którą oczekuje
kilka procesów, tylko jeden z nich — określony przez systemowego zarządcę procesów — zosta-
nie wznowiony.
Na marginesie warto dodać, że istnieje trzecie rozwiązanie, którego nie zaproponował ani
Hoare, ani Brinch Hansen. Polega ono na umożliwieniu procesowi wysyłającemu sygnał kon-
tynuowania działania i pozwolenie procesowi oczekującemu na rozpoczęcie działania dopiero
wtedy, gdy proces wysyłający sygnał opuści monitor.
Zmienne warunkowe nie są licznikami. Nie akumulują one sygnałów do późniejszego wyko-
rzystania tak, jak to robią semafory. W związku z tym, jeśli zostanie wysłany sygnał do zmien-
nej warunkowej, na który nikt nie czeka, zostanie on utracony na zawsze. Inaczej mówiąc, opera-
cja wait musi być wykonana przed operacją signal. Dzięki tej regule implementacja staje się
znacznie prostsza. W praktyce nie jest to problem, ponieważ jeśli jest taka potrzeba, można
z łatwością śledzić stan wszystkich procesów z wykorzystaniem zmiennych. Proces, który
chce wysłać sygnał, może sprawdzić zmienne i zobaczyć, że ta operacja nie jest konieczna.
Szkielet problemu producent-konsument z wykorzystaniem monitorów pokazano na lis-
tingu 2.12. Rozwiązanie zaprezentowano w wymyślonym języku Pidgin Pascal. Zaleta zasto-
sowania go w tym przypadku polega na tym, że jest on prosty i dokładnie odzwierciedla model
Hoare’a i Brincha Hansena.
count := 0;
end monitor;
procedure producer;
begin
while true do
begin
item = produce item;
ProducerConsumer.insert(item)
end
end;
procedure consumer;
begin
while true do
begin
item = ProducerConsumer.remove;
consume item(item)
end
end;
Można by sądzić, że operacje wait i signal są podobne do operacji sleep i wakeup, które
omawialiśmy wcześniej i które powodowały sytuację wyścigu. To prawda, one są bardzo podobne,
ale z jedną zasadniczą różnicą: operacje sleep i wakeup zawodzą, kiedy jeden proces próbuje przejść
w stan uśpienia, natomiast drugi próbuje go obudzić. W przypadku monitorów to nie może się
zdarzyć. Automatyczne wzajemne wykluczanie procedur monitora gwarantuje, że jeśli np. pro-
ducent wewnątrz monitora odkryje, że bufor jest pełny, to będzie mógł wykonać operację wait
bez obawy o to, że program szeregujący zechce przełączyć się do konsumenta bezpośrednio
przed zakończeniem wykonywania operacji wait. Konsument nie zostanie nawet wpuszczony
do monitora, zanim operacja wait się nie zakończy, a producent zostanie oznaczony jako nie-
zdolny do działania.
Chociaż Pidgin Pascal jest językiem wymyślonym, istnieją rzeczywiste języki programo-
wania obsługujące monitory. Nie zawsze jednak są one zaimplementowane w takiej formie, jaką
zaproponowali Hoare i Brinch Hansen. Jednym z takich języków jest Java. To język obiektowy
obsługujący wątki na poziomie użytkownika. Pozwala również na grupowanie metod (proce-
dur) w klasy. Dzięki dodaniu słowa kluczowego synchronized w deklaracji metody Java gwaran-
tuje, że kiedy dowolny wątek zacznie uruchamiać tę metodę, żaden inny wątek nie będzie mógł
uruchomić żadnej innej metody tego obiektu zadeklarowanej ze słowem kluczowym synchronized.
Bez słowa kluczowego synchronized nie ma gwarancji przeplatania.
Rozwiązanie problemu producent-konsument z wykorzystaniem monitorów w Javie poka-
zano na listingu 2.13. Rozwiązanie składa się z czterech klas. Klasa zewnętrzna — Producer
Consumer — tworzy i uruchamia dwa wątki — p i c. Druga i trzecia klasa, odpowiednio producer
i consumer, zawierają kod producenta i konsumenta. Wreszcie — klasa our_monitor jest moni-
torem. Zawiera dwa zsynchronizowane wątki wykorzystywane do wstawiania elementów do
współdzielonego bufora i do pobierania ich z niego. W odróżnieniu od poprzednich przykładów
na listingu pokazano kompletny kod operacji insert i remove.
int item;
while (true) { // pętla konsumenta
item = mon.remove( );
consume_item(item);
}
}
private void consume_item(int item) { ... } // skonsumowanie elementu
}
static class our_monitor { // to jest monitor
private int buffer[ ] = new int[N];
private int count = 0, lo = 0, hi = 0; // liczniki i indeksy
public synchronized void insert(int val) {
if (count == N) go_to_sleep( ); // jeśli bufor jest pełny, wątek przechodzi
// w stan uśpienia
buffer [hi] = val; // wstawienie elementu do bufora
hi = (hi + 1) % N; // miejsce, w którym będzie umieszczony następny element
count = count + 1; // teraz w buforze znajduje się o jeden element więcej
if (count == 1) notify( ); // obudzenie konsumenta, jeśli był uśpiony
}
public synchronized int remove( ) {
int val;
if (count == N) go_to_sleep( ); // jeśli bufor jest pusty, wątek przechodzi
// w stan uśpienia
val = buffer [lo]; // pobranie elementu z bufora
lo = (lo + 1) % N; // miejsce, z którego będzie pobrany następny element
count = count - 1; // teraz w buforze znajduje się o jeden element mniej
if (count == N − 1) notify( ); // obudzenie producenta, jeśli był uśpiony
return val;
}
private void go_to_sleep( ) { try{wait( );}
catch(InterruptedException exc) {};}
}
}
metodę wait można przerwać. Do tego właśnie służy otaczający ją kod. W języku Java obsługa
wyjątków musi być jawna. Dla naszych celów wyobraźmy sobie, że metoda go_to_sleep przenosi
wątek do stanu uśpienia.
Dzięki temu, że wzajemne wykluczanie regionów krytycznych w przypadku zastosowania
monitorów jest automatyczne, możliwość popełnienia błędów w programowaniu współbieżnym
jest znacznie mniejsza niż w przypadku wykorzystania semaforów. Pomimo to monitory także
mają pewne wady. Nie bez powodu nasze dwa przykłady monitorów napisano w języku Pidgin
Pascal, a nie w języku C, jak inne przykłady w tej książce. Jak powiedzieliśmy wcześniej, monito-
ry są konstrukcją języka programowania. Kompilator musi je rozpoznać i w jakiś sposób zor-
ganizować wzajemne wykluczanie. W językach C, Pascalu i większości innych języków nie ma
monitorów, zatem nie można oczekiwać od kompilatorów tych języków wymuszania reguł wza-
jemnego wykluczania. W rzeczywistości kompilator nie ma możliwości stwierdzenia, które pro-
cedury były w monitorach, a które nie.
W wymienionych językach nie ma również semaforów, ale dodanie semaforów jest łatwe:
wystarczy dodać do biblioteki dwie krótkie procedury asemblerowe służące do wydawania
wywołań systemowych up i down. Kompilatory nie muszą nawet wiedzieć, że takie wywołania
istnieją. Oczywiście systemy operacyjne muszą mieć informacje o semaforach. Jeśli jednak dys-
ponujemy systemem operacyjnym bazującym na semaforach, to możemy dla nich napisać pro-
gramy użytkowe w językach C i C++ (lub nawet w języku asemblera, jeśli ktoś ma skłonności
do masochizmu). W przypadku monitorów potrzebujemy języka, który ma tę konstrukcję wbu-
dowaną.
Innym problemem dotyczącym monitorów, a także semaforów, jest to, że zostały one zapro-
jektowane do rozwiązywania problemu wzajemnego wykluczania dla jednego lub kilku proce-
sorów mających dostęp do wspólnej pamięci. Dzięki umieszczeniu semaforów we wspólnej
pamięci i zabezpieczeniu ich za pomocą instrukcji TSL lub XCHG możemy uniknąć wyścigu. W przy-
padku systemu rozproszonego, składającego się z wielu procesorów połączonych w sieci lokal-
nej, gdzie każdy dysponuje prywatną pamięcią, prymitywy te stają się nieodpowiednie. Wniosek
jest następujący: semafory są zbyt niskopoziomowe, a z monitorów, z wyjątkiem kilku języków
programowania, nie można korzystać. Żaden z prymitywów nie zezwala również na wymianę
informacji pomiędzy maszynami. Potrzebne jest inne rozwiązanie.
oraz:
receive(source, &message);
Pierwsza wysyła komunikat do określonej lokalizacji docelowej, natomiast druga odbiera komu-
nikat z określonego źródła (lub z dowolnego, jeśli odbiorcy jest wszystko jedno). Jeśli nie jest
dostępny żaden komunikat, odbiorca może się zablokować do czasu nadejścia jakiegoś komuni-
katu. Alternatywnie może on natychmiast zwrócić sterowanie, przekazując kod błędu.
Jeśli producent pracuje szybciej niż konsument, to wszystkie komunikaty się zapełnią.
W oczekiwaniu na konsumenta producent będzie zablokowany do momentu, kiedy nadejdzie
pusty komunikat. Jeśli konsument pracuje szybciej, zachodzi sytuacja odwrotna: wszystkie
komunikaty zostają opróżnione w oczekiwaniu, aż producent je zapełni. Konsument będzie zablo-
kowany do momentu, kiedy nadejdzie pełny komunikat.
Przy przekazywaniu komunikatów jest możliwych wiele wariantów. Na początek przyjrzyjmy
się sposobowi adresowania komunikatów. Jednym ze sposobów jest przypisanie każdemu pro-
cesowi unikatowego adresu i adresowanie komunikatów za pomocą procesów. Innym sposo-
bem jest utworzenie nowej struktury danych, zwanej skrzynką pocztową. Skrzynka pocztowa
jest miejscem przeznaczonym na buforowanie określonej liczby komunikatów, zwykle okre-
ślonych w momencie tworzenia skrzynki. W przypadku użycia skrzynek pocztowych parame-
trami adresowymi w wywołaniach send i receive są skrzynki pocztowe, a nie procesy. Kiedy
proces podejmuje próbę wysłania komunikatu do pustej skrzynki pocztowej, jest zawieszany do
momentu, kiedy komunikat zostanie pobrany ze skrzynki i powstanie w niej miejsce na nowy.
W przypadku problemu producent-konsument, zarówno producent, jak i konsument tworzą
skrzynki pocztowe wystarczająco duże, by pomieścić N komunikatów. Producent wysyła komu-
nikaty zawierające dane do skrzynki pocztowej konsumenta, a konsument wysyła puste komu-
nikaty do skrzynki pocztowej producenta. W przypadku użycia skrzynek pocztowych mechanizm
buforowania jest czytelny: docelowa skrzynka pocztowa zawiera komunikaty, które zostały
wysłane do procesu docelowego, ale jeszcze nie zostały zaakceptowane.
Przeciwległym ekstremum do posiadania skrzynek pocztowych jest całkowite wyelimino-
wanie buforowania. W przypadku zastosowania tego podejścia, jeśli zostanie wykonana opera-
cja send przed wykonaniem operacji receive, proces wysyłający będzie zablokowany do chwili
wykonania operacji receive. W tym momencie komunikat może być skopiowany bezpośrednio
od nadawcy do odbiorcy bez buforowania. Na podobnej zasadzie, jeśli najpierw zostanie wyko-
nana operacja receive, odbiorca jest blokowany do momentu wykonania operacji send. Strategię
tę często określa się terminem rendez-vous (z fr. spotkanie). Jest ona łatwiejsza do zaimplemen-
towania od mechanizmu z buforowaniem, ale mniej elastyczna, ponieważ nadawca i odbiorca są
zmuszeni do działania w trybie naprzemiennym.
Przekazywanie komunikatów jest mechanizmem powszechnie stosowanym w systemach
programowania równoległego; np. jednym ze znanych systemów przekazywania komunikatów jest
MPI (Message-Passing Interface). Jest on powszechnie wykorzystywany do obliczeń naukowych.
Więcej informacji na ten temat można znaleźć w następujących pozycjach: [Gropp et al., 1994],
[Snir et al., 1996].
2.3.9. Bariery
Ostatni mechanizm synchronizacji, który omówimy, jest przeznaczony w większym stopniu
dla grup procesów niż dla sytuacji dwóch procesów typu producent-konsument. Niektóre apli-
kacje są podzielone na fazy i przestrzegają reguły, według której proces nie może przejść do
następnej fazy, jeśli wszystkie procesy nie są gotowe do przejścia do następnej fazy. Takie dzia-
łanie można uzyskać dzięki umieszczeniu bariery na końcu każdej fazy. Kiedy proces osiągnie
barierę, jest blokowany do czasu, aż wszystkie procesy osiągną barierę. Pozwala to grupom pro-
cesów na synchronizację. Działanie bariery zilustrowano na rysunku 2.16.
Rysunek 2.16. Wykorzystanie bariery: (a) procesy zbliżające się do bariery; (b) wszystkie
procesy oprócz jednego zablokowane na barierze; (c) kiedy ostatni proces dotrze do bariery,
wszystkie są przepuszczane
Na rysunku 2.16(a) widać cztery procesy zbliżające się do bariery. Oznacza to, że procesy
te wykonują obliczenia i jeszcze nie osiągnęły końca bieżącej fazy. Po pewnym czasie pierwszy
proces kończy obliczenia pierwszej fazy. Następnie uruchamia prymityw bariery — ogólnie
rzecz biorąc, poprzez wywołanie procedury bibliotecznej. Następnie proces jest zawieszany.
Nieco później drugi, a następnie trzeci proces kończą pierwszą fazę i także uruchamiają pry-
mityw bariery. Sytuację tę pokazano na rysunku 2.16(b). Na koniec, kiedy ostatni proces — C —
dotrze do bariery, wszystkie procesy są zwalniane, tak jak pokazano na rysunku 2.16(c).
Jako przykład problemu wymagającego barier rozważmy typowy problem relaksacji znany
z fizyki lub inżynierii. Zwykle mamy macierz, która zawiera pewne wartości początkowe. Wartości
Jest jednak pewien problem. Tak długo, jak nie mamy pewności, że nie ma więcej czytelni-
ków węzłów B lub D, nie możemy ich usunąć. Ile zatem powinniśmy czekać? Minutę? Dzie-
sięć minut? Musimy czekać, aż ostatni czytelnik opuści te węzły. W operacjach RCU dokładnie
określa się maksymalny czas, przez który czytelnik może utrzymywać referencję do struktury
danych. Po upływie tego okresu możemy bezpiecznie odzyskać pamięć. W szczególności pro-
cesy-czytelnicy uzyskują dostęp do struktury danych w tzw. sekcji krytycznej strony odczytu
(ang. read-side critical section), która może zawierać dowolny kod, pod warunkiem że nie wyko-
nuje on blokady (ang. lock) lub uśpienia (ang. sleep). W takim przypadku dokładnie znamy mak-
symalny czas oczekiwania. Ustalamy zwłaszcza okres karencji (ang. grace period) jako dowolny
czas, o którym wiemy, że każdy wątek jest poza sekcją krytyczną strony odczytu co najmniej raz.
Wszystko będzie dobrze, jeśli przed odzyskaniem pamięci będziemy czekać przez okres równy
co najmniej karencji. Ponieważ kod w sekcji krytycznej strony odczytu nie może wykonywać
operacji lock ani sleep, prosty warunek polega na poczekaniu tak długo, aż wszystkie wątki
zrealizują przełączenie kontekstu.
2.4. SZEREGOWANIE
2.4.
SZEREGOWANIE
jest dostępny, trzeba dokonać wyboru, który proces ma się uruchomić w następnej kolejności.
Ta część systemu operacyjnego, która dokonuje wyboru, nazywa się programem szeregującym
(ang. scheduler), a algorytm, który ona wykorzystuje, nazywa się algorytmem szeregowania. Tematy
te będą przedmiotem kolejnych punktów.
Wiele problemów, które dotyczą szeregowania procesów, dotyczy również szeregowania
wątków, choć niektóre różnią się pomiędzy sobą. Jeśli jądro zarządza wątkami, szeregowanie
zwykle jest wykonywane na poziomie wątków. W tym przypadku nie ma wielkiego znaczenia lub
zupełnie nie ma znaczenia to, do którego procesu należy określony wątek. Najpierw skoncentru-
jemy się na problemach szeregowania, które dotyczą zarówno procesów, jak i wątków. Później
jawnie zajmiemy się szeregowaniem wątków oraz pewnymi unikatowymi problemami, jakie są
z tym związane. W rozdziale 8. zajmiemy się układami wielordzeniowymi.
wybiera pomiędzy uruchomieniem procesu zbierającego dzienne statystyki a takim, który obsłu-
guje żądania użytkowników, użytkownik będzie o wiele bardziej zadowolony, jeśli to ten drugi
uzyska przydział procesora w pierwszej kolejności.
Cecha „obfitości zasobów” nie dotyczy również wielu urządzeń mobilnych, takich jak smart-
fony (może z wyjątkiem najmocniejszych modeli), oraz węzłów w sieciach sensorowych. W tego
rodzaju urządzeniach procesory CPU bywają słabe, a ilość pamięci jest niewielka. Ponadto ze
względu na to, że w tych urządzeniach żywotność baterii jest jednym z najważniejszych ograni-
czeń, niektóre programy szeregujące dążą do optymalizacji zużycia energii.
Oprócz wyboru właściwego procesu do uruchomienia program szeregujący musi również
dbać o wydajne wykorzystanie procesora, ponieważ przełączanie procesów jest kosztowną ope-
racją. Na początek musi nastąpić przełączenie z trybu użytkownika do trybu jądra. Następnie
należy zapisać stan bieżącego procesu, włącznie z zapisaniem jego rejestrów w tabeli proce-
sów, tak by mogły być ponownie załadowane później. W wielu systemach trzeba zapisać także
mapę pamięci (np. bity odwołań do pamięci w tabeli stron). Następnie trzeba wybrać nowy
proces poprzez uruchomienie algorytmu szeregującego. Potem należy ponownie załadować
moduł MMU z wykorzystaniem mapy pamięci nowego procesu. Na koniec trzeba uruchomić
nowy proces. Oprócz tego wszystkiego przełączenie procesu zazwyczaj dezaktualizuje całą
pamięć cache, co wymusza jej dynamiczne ładowanie z pamięci głównej. Operacja ta musi być
wykonana dwukrotnie (przy wejściu do trybu jądra i podczas jego opuszczania). Podsumujmy:
wykonywanie zbyt wielu operacji przełączania procesów w ciągu sekundy może doprowadzić
do zużycia znaczącej ilości czasu procesora. W związku z tym zalecana jest ostrożność.
Zachowanie procesów
Niemal wszystkie procesy naprzemiennie wykonują obliczenia z (dyskowymi) żądaniami wej-
ścia-wyjścia, co pokazano na rysunku 2.18. Zwykle procesor działa nieprzerwanie przez jakiś
czas, a następnie wykonywane jest wywołanie systemowe do odczytania danych z pliku lub zapi-
sania danych do pliku. Kiedy obsługa wywołania systemowego się zakończy, procesor ponownie
wykonuje obliczenia do czasu, aż będzie potrzebował więcej danych lub będzie musiał zapisać więcej
danych itd. Warto zwrócić uwagę, że niektóre operacje wejścia-wyjścia liczą się jako obliczenia.
Kiedy np. procesor kopiuje fragmenty do pamięci wideo w celu aktualizacji ekranu, to wykonuje
obliczenia, a nie operacje wejścia-wyjścia, ponieważ wykorzystuje do tego procesor. Operacja wej-
ścia-wyjścia w sensie, w jakim rozumiemy to w niniejszym przykładzie, zachodzi wtedy, gdy proces
wchodzi do stanu zablokowania w oczekiwaniu na to, aż urządzenie zewnętrzne zakończy pracę.
Ważną rzeczą, na którą należy zwrócić uwagę na rysunku 2.18, jest to, że niektóre procesy,
np. ten z rysunku 2.18(a), poświęcają większość czasu na obliczenia, podczas gdy inne, jak ten
z rysunku 2.18(b), przez większość czasu oczekują na zakończenie operacji wejścia-wyjścia.
Pierwsze określa się jako zorientowane na obliczenia, drugie to procesy zorientowane na
wejście-wyjście. Procesy zorientowane na obliczenia zazwyczaj mają długie serie wykorzystania
procesora, a w związku z tym rzadko oczekują na operacje wejścia-wyjścia, natomiast procesy
zorientowane na wejście-wyjście mają krótkie serie wykorzystania procesora, a zatem często
oczekują na zakończenie operacji wejścia-wyjścia. Zwróćmy uwagę, że kluczowym czynnikiem
jest długość trwania serii wykorzystania procesora, a nie serii wykorzystania wejścia-wyjścia.
Procesy zorientowane na wejście-wyjście są takie dlatego, że pomiędzy żądaniami wejścia-wyjścia
nie wykonują zbyt wielu obliczeń, a nie dlatego, że ich żądania wejścia-wyjścia są szczególnie
długotrwałe. Wydanie sprzętowego żądania odczytania bloku dysku zajmuje tyle samo czasu
niezależnie od tego, jak dużo lub jak mało czasu zajmie przetworzenie danych, kiedy nadejdą.
Rysunek 2.18. Serie wykorzystania procesora CPU przeplatają się z okresami oczekiwania
na zakończenie operacji wejścia-wyjścia. (a) Proces zorientowany na obliczenia; (b) proces
zorientowany na operacje wejścia-wyjścia
Warto zwrócić uwagę na to, że w miarę jak procesory stają się coraz szybsze, procesy w coraz
większym stopniu są zorientowane na wejście-wyjście. Efekt ten występuje dlatego, że postęp
w dziedzinie procesorów jest znacznie szybszy niż w dziedzinie dysków. Oczywiście w przypadku,
gdy kilka procesów będzie gotowych, zarządca będzie mógł uruchomić w następnej kolejności
ważniejszy proces. Podstawowa idea w tym przypadku polega na tym, że jeśli proces zorien-
towany na wejście-wyjście chce działać, powinien szybko otrzymać swoją szansę. Dzięki temu
będzie mógł wysłać swoje żądanie operacji dyskowej, przez co zadba o to, by dysk miał co robić.
Jak widzieliśmy na rysunku 2.4, kiedy procesy są zorientowane na wejście-wyjście, potrzeba ich
dość dużo, aby procesor był przez cały czas zajęty.
mogą być teraz gotowe do działania. Do kompetencji programu szeregującego należy decyzja
o tym, czy należy uruchomić proces, który właśnie uzyskał gotowość, ten, który działał w czasie
wystąpienia przerwania, czy jakiś inny.
Jeśli zegar sprzętowy dostarcza okresowych przerwań z częstotliwością 50 lub 60 Hz lub
jakąś inną, decyzje szeregowania mogą być podejmowane z każdym przerwaniem zegara lub co
k-te przerwanie zegara. Algorytmy szeregowania można podzielić na dwie kategorie, w zależ-
ności od sposobu postępowania z przerwaniami zegara. Algorytm szeregowania bez wywłaszcza-
nia (ang. nonpreemptive) wybiera proces do uruchomienia, a następnie pozwala mu działać do
czasu zablokowania (na operacji wejścia-wyjścia lub w oczekiwaniu na inny proces) albo do
momentu, kiedy proces z własnej woli zwolni CPU. Nawet jeśli proces będzie działał przez wiele
godzin, nie będzie zmuszony do zawieszenia. W rezultacie podczas przerwań zegara nie są podej-
mowane decyzje dotyczące szeregowania. Po zakończeniu przetwarzania przerwania zegarowego
wznawiany jest proces działający przed wystąpieniem przerwania, chyba że właśnie upłynął
wymagany czas oczekiwania procesu o wyższym priorytecie.
Dla odróżnienia algorytm szeregowania z wywłaszczaniem (ang. preemptive) wybiera proces
i pozwala mu działać maksymalnie przez ustalony czas. Jeśli na końcu przydzielonego prze-
działu czasu proces dalej działa, jest zawieszany i program szeregujący wybiera inny proces do
uruchomienia (jeśli jest dostępny). Aby była możliwa realizacja szeregowania z wywłaszczaniem,
na końcu przedziału czasowego musi nastąpić przerwanie zegara. Dzięki temu program szere-
gujący odzyskuje kontrolę nad procesorem. Jeśli nie jest dostępne przerwanie zegara, jedyną opcją
okazuje się szeregowanie bez wywłaszczania.
może zablokować możliwość działania innym niechcący — z powodu błędu w programie. Wywłasz-
czanie jest potrzebne w celu zapobiegania takim zachowaniom. Do tej kategorii należą również
serwery, ponieważ standardowo obsługują one wielu (zdalnych) użytkowników, którzy — wszy-
scy — bardzo się spieszą. Użytkownicy komputerów zawsze się spieszą.
W systemach z ograniczeniami czasu rzeczywistego, choć może się to wydawać dziwne,
wywłaszczanie czasami nie jest potrzebne. Procesy wiedzą bowiem, że nie mogą działać przez
długi czas, dlatego zwykle wykonują swoją pracę i szybko się blokują. Różnica w porównaniu
z systemami interaktywnymi polega na tym, że w systemach czasu rzeczywistego działają
wyłącznie takie programy, których celem jest wspomaganie jednej aplikacji. Systemy interak-
tywne są ogólnego przeznaczenia i mogą w nich działać dowolne programy, które nie tylko ze
sobą nie współpracują, ale nawet są wobec siebie złośliwe.
Innym ogólnym celem jest dbanie o to, aby wszystkie elementy systemu były zajęte zawsze,
kiedy to możliwe. Jeśli procesor i wszystkie urządzenia wejścia-wyjścia będą działać przez cały
czas, system wykona więcej pracy na sekundę w porównaniu z sytuacją, kiedy niektóre z kom-
ponentów pozostają bezczynne. Przykładowo w systemie wsadowym program szeregujący ma
kontrolę nad tym, które zadania będą przesłane do pamięci w celu uruchomienia. Załadowanie
do pamięci kilku procesów zorientowanych na procesor razem z kilkoma zorientowanymi na
operacje wejścia-wyjścia jest lepszym pomysłem niż załadowanie najpierw wszystkich zadań
zorientowanych na procesor, a następnie, kiedy zostaną one zakończone, załadowanie i urucho-
mienie wszystkich zadań zorientowanych na wejścia-wyjścia. W przypadku zastosowania tej
drugiej strategii, jeśli będą działać procesy zorientowane na procesor, wszystkie one będą walczyły
o procesor. W tej sytuacji dysk będzie bezczynny. Kiedy później zostaną załadowane zadania
zorientowane na operacje wejścia-wyjścia, będą one walczyły o dysk i procesor pozostanie bez-
czynny. Lepszym rozwiązaniem jest uważne dobranie procesów, tak by działał cały system.
Menedżerowie dużych centrów obliczeniowych, w których uruchamianych jest wiele zadań
wsadowych, oceniając wydajność swoich systemów, zazwyczaj biorą pod uwagę trzy metryki:
przepustowość, czas cyklu przetwarzania oraz wykorzystanie procesora. Przepustowość określa
liczbę zadań zrealizowanych przez system w ciągu godziny. W końcu wykonanie 50 zadań w ciągu
godziny jest lepsze od wykonania 40 zadań w ciągu godziny. Czas cyklu przetwarzania to staty-
stycznie średni czas od momentu, kiedy zadanie wsadowe zostanie przekazane do realizacji, do
chwili, kiedy zostanie ono zakończone. Parametr ten mierzy, jak długo przeciętny użytkownik
musi czekać na wyniki. W tym przypadku reguła brzmi: małe jest piękne.
Algorytm szeregowania, który maksymalizuje przepustowość, niekoniecznie musi minimali-
zować czas cyklu przetwarzania. I tak w przypadku gdy w systemie występują zadania krótko-
trwałe i długotrwałe, program szeregujący, który zawsze uruchamia krótkotrwałe zadania i unika
uruchamiania długotrwałych, może osiągnąć doskonałą przepustowość (wiele krótkotrwałych
zadań na godzinę), ale kosztem bardzo wysokiego czasu cyklu przetwarzania zadań długo-
trwałych. Jeśli zadania krótkotrwałe będą napływać w stałym tempie, zadania długotrwałe mogą
nie dostać szansy na uruchomienie. W ten sposób średni czas cyklu przetwarzania będzie nie-
skończony, a przepustowość wysoka.
Często w systemach wsadowych wykorzystuje się procesor. W rzeczywistości jednak nie
jest to zbyt dobra metryka. Prawdziwe znaczenie ma to, ile zadań w systemie będzie wykona-
nych (przepustowość) oraz ile czasu zajmie wykonanie zadania przekazanego do obliczeń (czas
cyklu przetwarzania). Użycie wskaźnika wykorzystania procesora jako metryki przypomina
ocenę samochodów na podstawie tego, ile obrotów na godzinę wykona silnik. Z drugiej strony,
jeśli wiadomo, kiedy wykorzystanie procesora zbliża się do 100%, wiadomo też, kiedy należy
pomyśleć o dodatkowej mocy obliczeniowej.
Dla systemów interaktywnych stosuje się inne cele. Najważniejszym jest minimalizacja czasu
odpowiedzi — czyli czasu od wydania polecenia do otrzymania wyników. W komputerze osobistym,
w którym działa proces drugoplanowy (np. czytający i zapisujący wiadomości e-mail z sieci),
żądanie użytkownika uruchomienia programu lub otwarcia pliku powinno mieć pierwszeństwo
przed zadaniem drugoplanowym. Udzielenie pierwszeństwa wszystkim interaktywnym żąda-
niom będzie poostrzegane jako dobra obsługa.
W pewnym stopniu powiązana z czasem odpowiedzi jest metryka, którą można by nazwać
proporcjonalnością. Użytkownicy mają wewnętrzne poczucie (często nieprawidłowe) tego, ile
powinna zająć określona operacja. Kiedy żądanie postrzegane jako złożone zajmuje dużo czasu,
użytkownicy to akceptują, ale jeśli zadanie uważane za proste zajmuje dużo czasu, irytują się.
Jeśli np. po kliknięciu ikony, która uruchamia operację wgrania pliku wideo o rozmiarze 500 MB
na serwer w chmurze, zadanie zostaje wykonane po 60 s, użytkownik najprawdopodobniej zaak-
ceptuje to jako obowiązujący fakt, ponieważ nie spodziewa się, że operacja przesyłania na serwer
zajmie 5 s. Wie, że to musi potrwać.
Z drugiej strony, jeśli użytkownik klika ikonę operacji przerwania połączenia z chmurą po
przesłaniu pliku wideo, ma zupełnie odmienne oczekiwania. Jeżeli operacja nie zakończy się po
30 s, użytkownik będzie coś mruczał pod nosem, natomiast po 60 s będzie miał pianę na ustach.
Takie zachowanie wynika z powszechnej opinii użytkowników, że wysyłanie dużej ilości danych
powinno zająć więcej czasu niż zwykłe przerwanie połączenia. W niektórych przypadkach (takich
jak ten) program szeregujący nie może nic zrobić z czasem odpowiedzi. Czasami jednak może,
zwłaszcza kiedy opóźnienie wynika z przyjęcia niewłaściwej kolejności procesów.
Systemy czasu rzeczywistego charakteryzują się innymi właściwościami niż systemy inter-
aktywne, dlatego program szeregujący musi spełniać inne cele. Często są one charakteryzowane
przez ścisłe terminy, które muszą, albo co najmniej powinny, być dotrzymane. Jeśli np. kom-
puter steruje urządzeniem, które generuje dane w stałym tempie, to niepowodzenie uruchomie-
nia procesu zbierania danych na czas może skutkować utratą danych. Tak więc najważniejszym
wymaganiem w systemach czasu rzeczywistego jest dotrzymanie wszystkich (lub większości)
terminów.
W niektórych systemach czasu rzeczywistego, zwłaszcza tych, które wykorzystują multi-
media, ważna jest przewidywalność. Niedotrzymanie jednego z terminów nie ma kluczowego
znaczenia, ale jeśli proces obsługi dźwięku działa nieprawidłowo, jakość dźwięku gwałtownie się
pogorszy. Wideo również jest problemem, ale ucho jest znacznie czulsze na zniekształcenia niż
oko. Aby uniknąć tego problemu, szeregowanie procesów musi być przewidywalne i regularne.
Algorytmy szeregowania w systemach wsadowych i interaktywnych przeanalizujemy w tym
rozdziale. Zagadnienia szeregowania procesów w systemach czasu rzeczywistego nie zostały
omówione w tym rozdziale. Są jednak opisane w ramach dodatku dotyczącego multimedialnych
systemów operacyjnych znajdującego się na końcu książki.
Wielką zaletą tego algorytmu jest to, że łatwo go zrozumieć i równie łatwo zaprogramować.
Jest on również sprawiedliwy w takim samym sensie, jak sprawiedliwa jest sprzedaż nowiutkich
iPhone’ów osobom, które chcą stać w kolejce od drugiej w nocy. W przypadku zastosowania
tego algorytmu do przechowywania wszystkich gotowych procesów wykorzystywana jest jedno-
kierunkowa lista. Wybranie procesu do uruchomienia wymaga usunięcia jednego procesu
z początku kolejki. Dodanie nowego procesu lub niezablokowanego procesu wymaga dołączenia
go na koniec kolejki. Czy może być coś prostszego do zrozumienia i zaimplementowania?
Niestety, algorytm pierwszy zgłoszony, pierwszy obsłużony ma również istotną wadę. Przy-
puśćmy, że w systemie jest jeden proces zorientowany na procesor, który jednorazowo działa
przez 1 s, oraz wiele procesów zorientowanych na operacje wejścia-wyjścia, które zużywają
mało czasu procesora, ale każdy z nich podczas realizacji musi wykonać 1000 odczytów dysku.
Proces zorientowany na obliczenia działa przez 1 s, a następnie czyta blok danych z dysku. Teraz
zaczynają po kolei działać wszystkie procesy wejścia-wyjścia, odczytując dane z dysku. Kiedy
proces zorientowany na obliczenia otrzyma żądany blok danych z dysku, zostanie uruchomiony
na kolejną sekundę, a za nim, w bezpośrednim następstwie, zostaną uruchomione wszystkie
procesy zorientowane na operacje wejścia-wyjścia.
W efekcie końcowym każdy z procesów zorientowanych na wejścia-wyjścia będzie czytał 1 blok
na sekundę, a zatem jego wykonanie zajmie 1000 s. W przypadku zastosowania algorytmu sze-
regowania, który wywłaszczałby proces zorientowany na procesor co 10 ms, realizacja proce-
sów zorientowanych na wejścia-wyjścia zajęłaby 10 s zamiast 1000 s, a spowolnienie procesu
zorientowanego na obliczenia nie byłoby zbyt duże.
Rysunek 2.19. Przykład algorytmu szeregowania: najpierw krótsze zadania; (a) uruchamianie
zadań w kolejności pierwotnej; (b) uruchamianie zadań według zasady „najpierw krótsze zadanie”
udział w średniej niż pozostałe czasy, zatem powinno to być najkrótsze zadanie, później b, następ-
nie c i na koniec d — zadanie najdłuższe, które ma wpływ tylko na własny czas cyklu przetwa-
rzania. To samo rozumowanie można zastosować do dowolnej liczby zadań.
Warto dodać, że algorytm „najpierw najkrótsze zadanie” jest optymalny tylko wtedy, kiedy
wszystkie zadania są dostępne jednocześnie. W roli kontrprzykładu rozważmy pięć zadań, od A
do E, o czasach działania odpowiednio 2, 4, 1, 1 i 1. Ich czasy nadejścia to 0, 0, 3, 3 i 3. Począt-
kowo mogą być wybrane tylko zadania A lub B, ponieważ inne zadania jeszcze nie dotarły.
Przy użyciu algorytmu „najpierw najkrótsze zadanie” będziemy uruchamiać zadania w kolejności
A, B, C, D, E — co daje średnią oczekiwania 4,6 s. Natomiast uruchomienie ich w kolejności B,
C, D, E, A daje średnią oczekiwania wynoszącą 4,4 s.
Szeregowanie cykliczne
Jednym z najstarszych, najprostszych, najbardziej sprawiedliwych i najczęściej używanych algo-
rytmów szeregowania jest szeregowanie cykliczne. Każdemu procesowi jest przydzielany prze-
dział czasu, nazywany kwantem, podczas którego proces może działać. Jeśli po zakończeniu
kwantu proces dalej działa, procesor jest wywłaszczany i przekazywany do innego procesu. Jeżeli
proces zablokował się lub zakończył, zanim upłynął kwant, następuje przełączenie procesora.
Cykliczny algorytm szeregowania jest łatwy do zaimplementowania. Program szeregujący musi
jedynie utrzymywać listę procesów do uruchomienia, podobną do pokazanej na rysunku 2.20. Kiedy
proces wykorzysta swój kwant, jest umieszczany na końcu listy, co pokazano na rysunku 2.20(b).
Rysunek 2.20. Szeregowanie cykliczne: (a) lista procesów do uruchomienia; (b) lista procesów
do uruchomienia po tym, jak proces B wykorzystał swój kwant
zegara. Jeśli działanie to spowoduje obniżenie priorytetu poniżej priorytetu następnego w kolej-
ności procesu, następuje przełączenie procesów. Alternatywnie każdemu procesowi może być
przydzielony maksymalny kwant czasu, przez który może on działać. Kiedy ten kwant zostanie
wykorzystany, szansę na działanie otrzymuje następny proces w kolejności priorytetów.
Priorytety mogą być przypisywane procesom w sposób statyczny lub dynamiczny. W kom-
puterze wojskowym procesy uruchamiane przez generałów mogą mieć początkowy priorytet 100,
procesy uruchamiane przez pułkowników — 90, majorów — 80, kapitanów — 70, poruczni-
ków — 60 itd. Alternatywnie w komercyjnym centrum obliczeniowym zadania o wysokim prio-
rytecie mogą kosztować 100 dolarów na godzinę, o średnim priorytecie — 75 dolarów na godzinę,
natomiast zadania o niskim priorytecie — 50 dolarów na godzinę. W systemie UNIX istnieje
polecenie nice, które pozwala użytkownikowi dobrowolnie obniżyć priorytet swojego procesu,
aby wykazać się uprzejmością w odniesieniu do innych użytkowników. Nikt nigdy go nie użył.
System może także przydzielać priorytety dynamicznie w celu osiągnięcia określonych
celów. Niektóre procesy np. są ściśle zorientowane na operacje wejścia-wyjścia i przez większość
czasu oczekują na zakończenie wykonywania operacji wejścia-wyjścia. Za każdym razem, kiedy
taki proces chce uzyskać dostęp do procesora, powinien go otrzymać natychmiast. Dzięki temu
będzie on mógł uruchomić swoje następne żądanie wejścia-wyjścia, które będzie realizowane
równolegle z innym procesem wykonującym obliczenia. Zmuszanie procesu zorientowanego
na wejścia-wyjścia na długotrwałe oczekiwanie na procesor będzie oznaczało, że niepotrzebnie
zajmie on pamięć przez długi czas. Prosty algorytm zapewniający dobrą obsługę dla procesów
zorientowanych na wejścia-wyjścia polega na ustawieniu priorytetu na wartość 1/f, gdzie f
oznacza fragment ostatniego kwantu wykorzystanego przez proces. Proces, który wykorzystał
tylko 1 ms z kwantu o długości 50 ms, otrzymuje priorytet 50, proces, który przed zablokowa-
niem działał 25 ms, otrzymałby priorytet 2, natomiast proces, który wykorzystał cały kwant,
otrzymuje priorytet 1.
Często wygodne jest pogrupowanie procesów na klasy priorytetów i wykorzystanie szere-
gowania bazującego na priorytetach pomiędzy klasami przy zastosowaniu szeregowania cyklicz-
nego w obrębie każdej z klas. Na rysunku 2.21 pokazano system z czterema klasami prioryte-
tów. Algorytm szeregowania jest następujący: o ile istnieją procesy możliwe do uruchomienia
w 4. klasie priorytetów, należy uruchomić po jednym w każdym kwancie w sposób cykliczny i nie
przejmować się niższymi klasami priorytetów. Jeśli 4. klasa priorytetów jest pusta, urucha-
miamy cyklicznie procesy klasy 3. Jeśli zarówno klasa 4., jak i 3. są puste, to cyklicznie są uru-
chamiane procesy klasy 2. itd. Jeśli priorytety nie będą czasami korygowane, procesy o niższych
priorytetach mogą nie dostać szansy na działanie.
Wielokrotne kolejki
Jednym z pierwszych systemów, w których zastosowano program szeregujący z wykorzysta-
niem priorytetów, był zbudowany w MIT system CTSS (Compatible Time Sharing System)
działający na komputerze IBM 7094 [Corbató et al., 1962]. W systemie CTSS problemem było
bardzo powolne przełączanie procesów, ponieważ komputer 7094 mógł przechowywać w pamięci
tylko jeden proces. Każde przełączenie oznaczało zapisanie bieżącego procesu na dysk i odczyta-
nie nowego z dysku. Projektanci systemu CTSS szybko doszli do wniosku, że wydajniejszym
rozwiązaniem będzie przydzielenie procesom zorientowanym na obliczenia większego kwantu
co jakiś czas niż częste przydzielanie im krótkich kwantów. Z drugiej strony przydzielenie dużych
kwantów wszystkim procesom oznaczałoby długie czasy odpowiedzi (o czym przekonaliśmy
się wcześniej). Przyjęto rozwiązanie polegające na skonfigurowaniu klas priorytetów. Procesy
należące do najwyższej klasy działały przez jeden kwant. Procesy należące do kolejnej klasy
w hierarchii działały przez dwa kwanty. Procesy należące do kolejnej klasy działały przez cztery
kwanty itd. Zawsze, gdy proces wykorzystał wszystkie kwanty, które zostały do niego przydzie-
lone, był przenoszony w dół o jedną klasę.
Dla przykładu rozważmy proces, który musiał realizować obliczenia przez 100 kwantów.
Początkowo otrzyma jeden kwant, a następnie zostanie przeniesiony na dysk. Następnym razem
otrzyma dwa kwanty, po których zostanie przeniesiony na dysk. W kolejnych uruchomieniach
uzyska 4, 8, 16, 32 i 64 kwanty, chociaż do zakończenia pracy potrzeba będzie tylko 37 z przy-
dzielonych 64 kwantów. Potrzebne byłoby tylko 7 przesunięć procesu pomiędzy pamięcią a dys-
kiem (włącznie z początkowym załadowaniem) zamiast 100 w przypadku klasycznego algo-
rytmu cyklicznego. Co więcej, w miarę jak proces wchodzi coraz głębiej w kolejki priorytetów,
działa coraz rzadziej. Dzięki temu procesor może być przydzielany krótkim, interaktywnym
procesom.
Niżej opisaną strategię zastosowano, aby zapobiec sytuacji, w której proces potrzebujący
działać przez długi czas przy pierwszym uruchomieniu, a potem zmieniający się w proces inte-
raktywny, nie był zablokowany na zawsze. Każdorazowe wciśnięcie na terminalu znaku powrotu
karetki (klawisza Enter) powoduje przeniesienie procesu należącego do tego terminala do naj-
wyższej klasy priorytetów z założeniem, że proces ten przekształci się w interaktywny. Pew-
nego dnia użytkownik procesu mocno zorientowanego na obliczenia odkrył, że siedzenie przy
terminalu i losowe wciskanie klawisza Enter znacząco poprawia czasy odpowiedzi. O swoim
odkryciu opowiedział kolegom. Oni z kolei opowiedzieli swoim kolegom. Jaki jest morał tej
historii? Rozwiązanie problemu w praktyce jest znacznie trudniejsze od opracowania zasady
jego rozwiązania.
Szeregowanie gwarantowane
Całkowicie inne podejście do szeregowania polega na złożeniu użytkownikom obietnic dotyczą-
cych wydajności, a następnie spełnienie ich. Jedna z obietnic, którą można realistycznie złożyć
i łatwo dotrzymać, jest następująca: jeśli jest n użytkowników zalogowanych podczas pracy,
każdy z nich otrzyma 1/n mocy procesora. Na podobnej zasadzie w systemie z jednym użyt-
kownikiem, gdy działa n równoprawnych procesów, każdy z nich powinien otrzymać 1/n cykli
procesora. Algorytm ten wydaje się sprawiedliwy.
Aby dotrzymać tej obietnicy, system musi śledzić, ile czasu procesora miał każdy z proce-
sów od momentu utworzenia. Następnie oblicza czas procesora, do jakiego każdy z procesów
jest uprawniony — w tym celu dzieli czas, jaki upłynął od utworzenia przez n. Współczynnik 0,5
oznacza, że proces otrzymał tylko połowę z tego, co powinien był dostać, natomiast współczyn-
nik 2,0 oznacza, że proces otrzymał dwa razy więcej niż to, do czego był uprawniony. Następ-
nie program szeregujący uruchamia proces z najniższym współczynnikiem do czasu, kiedy
współczynnik wzrośnie powyżej jego najbliższego konkurenta. Proces spełniający ten warunek
jest uruchamiany jako następny.
Szeregowanie loteryjne
O ile składanie obietnic użytkownikom, a następnie ich dotrzymywanie jest dobrym pomy-
słem, o tyle odpowiadający temu algorytm jest trudny do zaimplementowania. Można jednak
użyć innego algorytmu i uzyskać podobnie przewidywalne wyniki przy znacznie prostszej imple-
mentacji. Algorytm ten nazywa się szeregowaniem loteryjnym [Waldspurger i Weihl, 1994].
Podstawowa idea polega na przydzieleniu procesom biletów loteryjnych na różne zasoby
systemowe, takie jak czas procesora. Zawsze, kiedy ma być podjęta decyzja dotycząca szere-
gowania, wybierany jest losowo bilet loteryjny, a zasób otrzymuje proces będący w posiadaniu
tego biletu. W przypadku szeregowania procesora system może przeprowadzać losowanie 50 razy
na sekundę i w nagrodę przydzielać zwycięzcy 20 ms czasu procesora.
Sprawiedliwe szeregowanie
Do tej pory zakładaliśmy, że każdy proces jest szeregowany „na własny rachunek”, bez względu
na to, kto jest jego właścicielem. W rezultacie, jeśli użytkownik nr 1 uruchomił 9 procesów,
a użytkownik nr 2 tylko 1 proces, to przy szeregowaniu cyklicznym lub przy równych priory-
tetach, użytkownik 1 otrzymałby 90% czasu procesora, a użytkownik 2 tylko 10%.
Aby zabezpieczyć się przed taką sytuacją, niektóre algorytmy szeregowania przed dokona-
niem przydziału uwzględniają, do kogo należy proces. W tym modelu każdemu użytkownikowi
przydzielany jest pewien fragment czasu procesora, a program szeregujący wybiera procesy
w taki sposób, aby ten podział został uwzględniony. Tak więc, jeśli każdemu z dwóch użytkowni-
ków obiecano po 50% czasu procesora, to każdy po tyle otrzyma, niezależnie od tego, ile uru-
chomili procesów.
Dla przykładu rozważmy system z dwoma użytkownikami, z których każdemu obiecano po
50% czasu procesora. Użytkownik nr 1 ma 4 procesy: A, B, C i D, a użytkownik 2 ma tylko 1 pro-
ces — E. Gdyby zastosowano szeregowanie cykliczne, to możliwa sekwencja szeregowania,
która spełniłaby wszystkie ograniczenia, mogłaby mieć następującą postać:
AEBECEDEAEBECEDE…
Jeśli natomiast użytkownik nr 1 byłby uprawniony do uzyskania dwa razy tyle czasu proce-
sora co użytkownik 2, moglibyśmy otrzymać następującą sekwencję:
ABECDEABECDE…
Oczywiście istnieje wiele innych możliwości, które można wykorzystać. Wszystko zależy od
tego, co rozumiemy pod pojęciem sprawiedliwości.
System czasu rzeczywistego, spełniający to kryterium, określa się jako szeregowalny (ang.
schedulable). Oznacza to, że może być praktycznie zaimplementowany. Proces, który nie przejdzie
tego testu, nie może być zrealizowany, ponieważ całkowity czas procesora, którego procesy łącznie
potrzebują, wynosi więcej, niż procesor CPU może dostarczyć.
Dla przykładu rozważmy miękki system czasu rzeczywistego z trzema okresowymi zda-
rzeniami, o okresach odpowiednio 100, 200 i 500 ms. Jeśli zdarzenia te wymagają odpowiednio
50, 30 i 100 ms czasu procesora na zdarzenie, to system jest szeregowalny, ponieważ
0,5+0,15+0,2 < 1. Jeśli zostanie dodane czwarte zdarzenie o okresie 1 s, to system pozosta-
nie szeregowalny, o ile zdarzenie to nie będzie wymagało więcej niż 150 ms czasu procesora
na zdarzenie. W tym obliczeniu przyjmuje się niejawne założenie, że koszt przełączania kon-
tekstu jest tak niewielki, że można go pominąć.
Algorytmy szeregowania w systemach czasu rzeczywistego mogą być statyczne lub dyna-
miczne. Pierwsze z nich podejmują decyzje dotyczące szeregowania, zanim system rozpocznie
działanie. Drugie podejmują decyzje o szeregowaniu podczas działania systemu. Statyczne szere-
gowanie działa tylko wtedy, gdy z góry istnieją dokładne informacje o tym, jakie prace są do
wykonania oraz jakich terminów należy dotrzymać. Dynamiczne algorytmy szeregowania nie
mają takich ograniczeń. Omówienie konkretnych algorytmów szeregowania w systemach czasu
rzeczywistego odłożymy do rozdziału 7., w którym będziemy omawiać multimedialne systemy
czasu rzeczywistego.
50 ms. W konsekwencji każdy będzie działał przez chwilę, a następnie zwróci procesor do pro-
gramu szeregującego wątki. Może to doprowadzić do sekwencji A1, A2, A3, A1, A2, A3, A1, A2,
A3, A1, po której jądro przełącza się do procesu B. Sytuację tę pokazano na rysunku 2.22(a).
Istotną rolę odgrywa również to, że wątki zarządzane na poziomie użytkownika mogą wyko-
rzystywać mechanizm szeregowania specyficzny dla aplikacji. Rozważmy dla przykładu serwer
WWW z rysunku 2.6. Załóżmy, że wątek pracownika właśnie się zablokował, a wątek dyspo-
zytora i dwa wątki pracowników są gotowe. Który powinien zadziałać jako następny? Środowisko
wykonawcze, wiedząc o tym, co robią wszystkie wątki, może z łatwością wybrać wątek dyspo-
zytora, tak aby mógł uruchomić następnego pracownika. Taka strategia maksymalizuje współ-
czynnik współbieżności w środowisku, w którym wątki pracowników często blokują się na
dyskowych operacjach wejścia-wyjścia. Gdyby zostały zastosowane wątki na poziomie jądra,
jądro nigdy nie wiedziałoby, co robi każdy z wątków (chociaż można by im było przypisać różne
priorytety). Jednak ogólnie rzecz biorąc, mechanizmy szeregowania wątków na poziomie aplikacji
potrafią dostroić aplikację lepiej, niż potrafi to zrobić jądro.
Literatura dotycząca systemów operacyjnych pełna jest interesujących problemów, które były
szeroko dyskutowane i analizowane przy użyciu różnych metod synchronizacji. W poniższych
punktach przeanalizujemy trzy z bardziej znanych problemów.
Życie filozofów składa się z naprzemiennych okresów jedzenia i rozmyślania (jest to pewna
abstrakcja nawet w przypadku filozofów, ale inne działania nie są tutaj ważne). Kiedy filozof zaczyna
czuć głód, próbuje sięgnąć po widelce z lewej i prawej strony — po jednym na raz i w dowolnej
kolejności. Jeśli uda mu się zdobyć dwa widelce, je przez chwilę, a następnie odkłada widelce
i kontynuuje rozmyślanie. Zasadnicze pytanie brzmi: czy potrafisz napisać program dla każdego
z filozofów, który będzie wykonywał wymagane operacje i nigdy się nie zablokuje? (Wymaganie
posiadania dwóch widelców jest trochę sztuczne; być może należałoby przerzucić się z kuchni
włoskiej na chińską i zastąpić spaghetti ryżem, a widelce pałeczkami).
Oczywiste rozwiązanie pokazano na listingu 2.15. Procedura take_fork oczekuje, aż określony
widelec stanie się dostępny, a następnie go podnosi. Niestety, oczywiste rozwiązanie jest
błędne. Przypuśćmy, że wszystkich pięciu filozofów jednocześnie podniosło swoje lewe widelce.
Żaden z nich nie będzie mógł podnieść swojego prawego widelca i powstanie zakleszczenie.
Moglibyśmy zmodyfikować program w taki sposób, aby po wzięciu lewego widelca spraw-
dzał, czy prawy widelec jest dostępny. Jeśli nie, filozof powinien odłożyć lewy widelec, poczekać
jakiś czas, a następnie powtórzyć cały proces. Propozycja ta również nie rozwiązuje problemu.
Tym razem z innego powodu. Przy odrobinie pecha wszyscy filozofowie mogliby zacząć algorytm
jednocześnie. Wzięliby widelce znajdujące się z lewej strony, zorientowaliby się, że po prawej
stronie widelce są niedostępne, odłożyli widelce z lewej, poczekali, znów jednocześnie podnieśli
widelce z lewej strony, i tak dalej, w nieskończoność. Taka sytuacja, w której wszystkie programy
działają w nieskończoność bez żadnego postępu, nazywa się zagłodzeniem (nazywa się zagło-
dzeniem nawet wtedy, gdy akcja nie rozgrywa się we włoskiej czy też chińskiej restauracji).
Można by sądzić, że jeśli po nieudanej próbie podniesienia widelca z prawej strony filozo-
fowie będą czekać przez losowy czas, zamiast zawsze taki sam, szansa na to, że system się zablo-
kuje na długo, jest bardzo mała. Ta obserwacja okazuje się słuszna i niemal we wszystkich apli-
kacjach ponowienie próby za jakiś czas nie stanowi problemu. Jeśli np. w popularnej lokalnej
sieci komputerowej Ethernet dwa komputery jednocześnie wyślą pakiet, każdy z nich czeka
losowy czas i ponawia próbę. W praktyce takie rozwiązanie się sprawdza. W niektórych zasto-
sowaniach potrzebne jest jednak rozwiązanie, które działa zawsze i nie może zawieść z powodu
nieznanej serii liczb losowych. Wystarczy pomyśleć o systemie bezpieczeństwa w elektrowni
atomowej.
Aby usprawnić kod z listingu 2.15 w taki sposób, by pozbawić go problemu zakleszczeń
i zagłodzenia, wystarczy zabezpieczyć pięć instrukcji następujących po wywołaniu operacji
think semaforem binarnym. Przed przystąpieniem do podnoszenia widelców filozof mógłby
wykonać operację down na zmiennej mutex. Po odłożeniu widelców powinien on wykonać ope-
W pokazanym rozwiązaniu pierwszy czytelnik, który chce uzyskać dostęp do bazy danych,
wykonuje operację down na semaforze db. Kolejni czytelnicy jedynie inkrementują licznik rc.
Kiedy czytelnicy przestają korzystać z bazy danych, dekrementują licznik. Ostatni z nich wyko-
nuje operację up na semaforze, pozwalając na skorzystanie z bazy danych zablokowanemu pisa-
rzowi, jeśli taki jest.
2.7. PODSUMOWANIE
2.7.
PODSUMOWANIE
W celu ukrycia efektu przerwań systemy operacyjne dostarczają pojęciowego modelu składają-
cego się z sekwencyjnych procesów działających współbieżnie. Procesy można tworzyć i nisz-
czyć dynamicznie. Każdy proces ma własną przestrzeń adresową.
W przypadku niektórych aplikacji przydatne jest istnienie wielu wątków sterowania w obrę-
bie pojedynczego procesu. Wątki te są szeregowane niezależnie, a każdy z nich ma własny stos,
choć wszystkie wątki w procesie współdzielą wspólną przestrzeń adresową. Wątki mogą być
implementowane na poziomie przestrzeni użytkownika lub na poziomie jądra.
Procesy mogą się ze sobą komunikować z wykorzystaniem prymitywów komunikacji mię-
dzy procesami, takich jak semafory, monitory lub komunikaty. Prymitywy te wykorzystuje się
po to, by zapewnić, że żadne dwa procesy nigdy nie znajdą się w swoich regionach krytycznych
w tym samym czasie — taka sytuacja prowadzi bowiem do chaosu. Proces może działać, być
w stanie gotowości do działania lub zablokowania. Status procesu może się zmienić, kiedy ten
proces lub jakiś inny proces wykonają jeden z prymitywów komunikacji między procesami. Na
podobnej zasadzie działa komunikacja między wątkami.
Prymitywy komunikacji między procesami można wykorzystać do rozwiązywania takich
problemów, jak producent-konsument, pięciu filozofów oraz czytelnik-pisarz. Nawet w przy-
padku stosowania tych prymitywów należy zachować ostrożność w celu uniknięcia błędów
i zakleszczeń.
W niniejszym rozdziale przeanalizowaliśmy wiele algorytmów szeregowania. Niektóre z nich
są używane głównie w systemach wsadowych — np. szeregowanie w pierwszej kolejności
najkrótszego zadania. Inne wykorzystuje się powszechnie zarówno w systemach wsadowych,
jak i w interaktywnych. Do tej grupy należy szeregowanie cykliczne, szeregowanie bazujące
na priorytetach, wielopoziomowe kolejki, szeregowanie gwarantowane oraz szeregowanie według
sprawiedliwego przydziału. W niektórych systemach istnieje czytelna granica pomiędzy mecha-
nizmami szeregowania a strategią szeregowania. Dzięki temu podziałowi użytkownicy mogą
kontrolować algorytm szeregowania.
PYTANIA
1. Na rysunku 2.2 pokazano trzy stany procesu. Teoretycznie przy trzech stanach może
być sześć przejść — po dwa dla każdego ze stanów. Pokazano jednak tylko cztery przej-
ścia. Czy istnieją jakieś okoliczności, w których może wystąpić jedno brakujące przejście
lub oba takie przejścia?
2. Przypuśćmy, że masz zaprojektować zaawansowaną architekturę komputerową, w któ-
rej przełączanie procesów jest realizowane na poziomie sprzętu, a nie przerwań. Jakich
informacji będzie potrzebował procesor? Opisz, w jaki sposób może działać sprzętowe
przełączanie procesów.
3. We wszystkich współczesnych komputerach przynajmniej pewna część procedur obsługi
przerwań jest napisana w języku asemblera. Dlaczego?
4. Kiedy przerwanie lub wywołanie systemowe przekazują sterowanie do systemu opera-
cyjnego, zazwyczaj używany jest obszar stosu jądra oddzielny od stosu przerwanego pro-
cesu. Dlaczego?
5. System komputerowy ma wystarczająco dużo miejsca w pamięci głównej, aby pomie-
ścić pięć programów. Przez połowę czasu programy te są w stanie oczekiwania na wej-
ście-wyjście. Jaki ułamek czasu procesora jest marnotrawiony?
6. Komputer jest wyposażony w 4 GB pamięci RAM, z której system operacyjny zajmuje
512 MB. Każdy proces zajmuje 256 MB (dla uproszczenia). Wszystkie procesy mają te
same własności. Jaki jest maksymalny czas oczekiwania na wejście-wyjście, jeśli celem
jest 99% wykorzystania procesora?
7. Jeśli wiele zadań działa współbieżnie, ich realizacja może zakończyć się szybciej w porów-
naniu z sytuacją, kiedy działałyby one sekwencyjnie. Przypuśćmy, że dwa zadania, z któ-
rych każde wymaga 10 min czasu procesora, rozpoczyna się równocześnie. Ile czasu
zajmie wykonanie ostatniego, jeśli będą działały sekwencyjnie? A ile, jeśli będą działały
współbieżnie? Zakładany czas oczekiwania na urządzenia wejścia-wyjścia wynosi 50%.
8. Rozważmy system wieloprogramowy 6. stopnia (tzn. w tym samym czasie w pamięci
jest sześć programów). Załóżmy, że każdy proces spędza 40% swojego czasu w oczeki-
waniu na wejście-wyjście. Ile wynosi procent wykorzystania procesora?
9. Załóżmy, że próbujesz pobrać z internetu duży plik o rozmiarze 2 GB. Plik jest dostępny
z kilku serwerów lustrzanych, z których każdy może dostarczyć podzbiór bajtów pliku.
Zakładamy, że w określonym żądaniu są określone początkowe i końcowe bajty pliku.
Wyjaśnij, w jaki sposób można wykorzystać wątki do poprawy czasu pobierania.
10. W tekście rozdziału powiedziano, że model z rysunku 2.7(a) nie był odpowiedni dla ser-
wera plików wykorzystującego cache w pamięci. Dlaczego nie? Czy każdy proces mógłby
mieć własną pamięć cache?
11. Jeśli nastąpi rozwidlenie wielowątkowego procesu, problem występuje w przypadku, gdy
proces-dziecko otrzyma kopie wszystkich wątków procesu-rodzica. Załóżmy, że jeden
z wyjściowych wątków oczekiwał na dane wejściowe z klawiatury. Teraz na dane z kla-
wiatury czekają dwa wątki — po jednym w każdym procesie. Czy ten problem kiedy-
kolwiek występuje w przypadku procesów jednowątkowych?
12. Na rysunku 2.6 pokazano serwer WWW z obsługą wielu wątków. Jeśli jedynym sposobem
czytania z pliku jest normalne blokujące wywołanie systemowe read, to jak sądzisz, czy
dla serwera WWW są wykorzystywane wątki zarządzane na poziomie użytkownika, czy na
poziomie jądra? Dlaczego?
13. W tekście rozdziału opisaliśmy wielowątkowy serwer WWW i pokazaliśmy, dlaczego
jest on lepszy od jednowątkowego serwera oraz serwera działającego na zasadzie automatu
o skończonej liczbie stanów. Czy istnieją jakieś okoliczności, w których jednowątkowy
serwer może być lepszy? Podaj przykład.
14. W tabeli 2.4 zbiór rejestrów wyszczególniono jako komponent wątku, a nie procesu.
Dlaczego? W końcu maszyna ma tylko jeden zbiór rejestrów.
15. Dlaczego wątek miałby kiedykolwiek dobrowolnie oddać procesor za pomocą wywołania
thread_yield? Przecież skoro nie ma okresowych przerwań zegara, to może się zdarzyć,
że nigdy nie odzyska procesora.
16. Czy wątek może być wywłaszczony za pomocą przerwania zegara? Jeśli tak, to w jakich
okolicznościach? Jeśli nie, to dlaczego?
17. Twoim zadaniem jest porównanie operacji czytania z pliku z wykorzystaniem jednowąt-
kowego serwera plików oraz serwera wielowątkowego. Pobranie żądania pracy, przy-
dzielenie go i wykonanie reszty obliczeń, przy założeniu, że potrzebne dane znajdują się
w bloku pamięci cache, zajmuje 15 ms. Jeśli jest potrzebna operacja dyskowa, co zdarza
się w jednej trzeciej przypadków, potrzeba kolejnych 75 ms, w ciągu których wątek jest
uśpiony. Ile żądań na sekundę może obsłużyć serwer, jeśli jest jednowątkowy? A ile,
jeśli jest wielowątkowy?
18. Jaka jest największa zaleta implementacji wątków w przestrzeni użytkownika? A jaka
jest największa wada tego sposobu implementacji?
19. W kodzie na listingu 2.2 operacje tworzenia wątków i wyświetlania komunikatów przez
wątki losowo przeplatają się. Czy istnieje sposób wymuszenia następującej sekwencji
operacji: utworzenie wątku 1, wątek 1 wyświetla komunikat, wątek 1 kończy działanie,
utworzenie wątku 2, wątek 2 wyświetla komunikat, wątek 2 kończy działanie itd.? Jeśli tak,
to jak to można zrobić? Jeśli nie, to dlaczego?
20. Podczas omawiania zmiennych globalnych w wątkach użyliśmy procedury create_global
w celu zaalokowania pamięci na wskaźnik do zmiennej zamiast do samej zmiennej. Czy
ma to istotne znaczenie, czy też procedury mogą równie dobrze działać na samych
wartościach?
21. Rozważmy system, w którym wątki są implementowane w całości w przestrzeni użyt-
kownika, gdzie środowisko wykonawcze obsługuje przerwanie zegara co sekundę. Przy-
puśćmy, że przerwanie zegarowe występuje w momencie, kiedy w środowisku wykonaw-
czym działa jakiś inny wątek. Jaki problem może się zdarzyć? Czy możesz zaproponować
sposób jego rozwiązania?
22. Przypuśćmy, że w systemie operacyjnym nie ma czegoś takiego, jak wywołanie syste-
mowe select, które może sprawdzić, czy odczyt z pliku, potoku lub urządzenia jest
bezpieczny, ale istnieje możliwość ustawiania zegarów alarmowych, które przerywają
zablokowane wywołania systemowe. Czy w takich warunkach istnieje możliwość zaim-
plementowania pakietu obsługi wątków w przestrzeni użytkownika? Uzasadnij.
/* inny kod */
34. Czy dwa wątki w tym samym procesie można zsynchronizować z wykorzystaniem sema-
fora w jądrze, jeśli wątki są zarządzane na poziomie jądra? A co w przypadku zaimple-
mentowania ich w przestrzeni użytkownika? Załóżmy, że żaden wątek należący do innego
procesu nie ma dostępu do semafora. Uzasadnij swoje odpowiedzi.
35. W synchronizacji w obrębie monitorów wykorzystuje się zmienne warunkowe oraz
dwie specjalne operacje: wait i signal. W synchronizacji w bardziej ogólnej postaci powi-
nien występować pojedynczy prymityw, waituntil, do którego byłby przekazywany para-
metr w postaci dowolnego predykatu typu Boolean. Można by zatem użyć następującej
operacji:
waituntil x < 0 or y + z < n
Prymityw signal przestałby być potrzebny. Ten mechanizm jest znacznie bardziej ogólny
od mechanizmu Hoare’a lub Brincha Hansena, ale nie jest wykorzystywany. Dlaczego
nie? Wskazówka: pomyśl o implementacji.
36. W restauracji fast food zatrudniono pracowników czterech typów: (1) zbieraczy zamó-
wień, którzy przyjmują zamówienia od klientów; (2) kucharzy, którzy przygotowują jedze-
nie; (3) specjalistów od pakowania, którzy pakują jedzenie w torebki oraz (4) kasjerów,
którzy wręczają torebki klientom i biorą od nich pieniądze. Każdego pracownika można
uznać za proces sekwencyjny komunikujący się z innymi procesami. Jaką formę komu-
nikacji międzyprocesowej wykorzystują procesy? Porównaj ten model do procesów
w Uniksie.
37. Przypuśćmy, że mamy system przekazywania wiadomości korzystający ze skrzynek
pocztowych. Podczas wysyłania wiadomości do pełnej skrzynki pocztowej lub przy pró-
bie odbierania wiadomości z pustej skrzynki pocztowej proces się nie blokuje. Zamiast
tego otrzymuje kod błędu. Proces odpowiada na błąd poprzez wielokrotne ponawianie
próby — tak długo, aż się powiedzie. Czy taki schemat prowadzi do sytuacji wyścigu?
38. Komputery CDC 6600 mogą obsłużyć jednocześnie do 10 procesów wejścia-wyjścia
z wykorzystaniem interesującej formy szeregowania cyklicznego, zwanej współdzieleniem
procesora. Po każdej instrukcji wystąpiło przełączenie procesów, zatem instrukcja 1 pocho-
dziła z procesu 1, instrukcja 2 pochodziła z procesu 2 itp. Przełączanie procesów było
realizowane za pomocą specjalnego sprzętu, a koszt obliczeniowy tej operacji był zerowy.
Jeśli w warunkach braku rywalizacji wykonanie procesu wymagało T sekund, to ile czasu
wymagałoby, gdyby wykorzystano współdzielenie procesora z n procesami?
39. Rozważmy następujący fragment kodu w języku C:
void main( ) {
fork( );
fork( );
exit();
}
41. Czy na podstawie analizy kodu źródłowego można stwierdzić, czy proces jest zorientowany
na procesor, czy na operacje wejścia-wyjścia? W jaki sposób można to sprawdzić na etapie
działania programu?
42. Wyjaśnij, jaki wpływ mają na siebie wartość kwantu czasu i czas przełączania kontekstu
w cyklicznym algorytmie szeregowania.
43. Pomiary wykonane w pewnym systemie pokazały, że przeciętny proces działa przez
czas T, a następnie blokuje się na operacji wejścia-wyjścia. Przełączenie procesu wymaga
czasu S, który jest tracony (koszty obliczeniowe). Dla szeregowania cyklicznego o kwan-
cie Q podaj wzór na wydajność procesora dla każdej z poniższych sytuacji:
(a) Q =
(b) Q > T
(c) S < Q < T
(d) Q = S
(e) Q jest bliskie 0.
44. Na uruchomienie oczekuje pięć zadań. Ich spodziewane czasy działania wynoszą 9, 6, 3, 5
i X. W jakiej kolejności powinny one działać, aby zminimalizować średni czas odpowiedzi
(odpowiedź zależy od X)?
45. Pięć zadań wsadowych od A do E wpłynęło do ośrodka obliczeniowego niemal w tym
samym czasie. Szacowany czas ich działania wynosi odpowiednio 10, 6, 2, 4 i 8 min. Ich
priorytety (określane zewnętrznie) wynoszą odpowiednio 3, 5, 2, 1 i 4, przy czym 5 ozna-
cza najwyższy priorytet. Dla każdego z poniższych algorytmów szeregowania określ
średni czas przełączania cyklu procesu. Zignoruj koszty obliczeniowe związane z przełą-
czaniem procesów.
(a) Szeregowanie cykliczne.
(b) Szeregowanie bazujące na priorytetach.
(c) Pierwszy zgłoszony — pierwszy obsłużony (uruchamianie w porządku 10, 6, 2, 4, 8).
(d) Najpierw najkrótsze zadanie.
Dla przypadku (a) załóż, że system jest wieloprogramowy oraz że każde zadanie otrzy-
muje sprawiedliwy przydział procesora. Dla przypadków od (b) do (d) załóż, że w okre-
ślonym czasie działa tylko jedno zadanie do momentu, aż się zakończy. Wszystkie za-
dania są całkowicie zorientowane na obliczenia.
46. Proces działający w systemie CTSS wymaga do realizacji 30 kwantów. Ile razy będzie
musiał być wymieniany pomiędzy dyskiem a pamięcią, jeśli uwzględnić pierwszą wymianę
(jeszcze przed uruchomieniem)?
47. Rozważmy system czasu rzeczywistego z dwoma połączeniami głosowymi co 5 ms każde
z czasem procesora na połączenie wynoszącym 1 ms i jednym strumieniem wideo co 33 ms
z czasem procesora na wywołanie wynoszącym 11 ms. Czy ten system jest szeregowalny?
48. Czy w systemie opisanym powyżej można dodać kolejny strumień wideo, jeśli system
nadal ma być szeregowalny?
49. Do przewidywania czasów działania procesów wykorzystywany jest algorytm starzenia
z a = 1/2. Czasy poprzednich czterech uruchomień — od najstarszego do najświeższego —
to odpowiednio 40, 20, 40 i 15 ms. Jaka jest prognoza następnego czasu uruchomienia?
50. W miękkim systemie czasu rzeczywistego występują cztery okresowe zdarzenia o okre-
sach 50, 100, 200 i 250 ms każdy. Przypuśćmy, że cztery zdarzenia wymagają odpowied-
nio 35, 20, 10 i x ms czasu procesora. Jaka jest największa wartość x dla systemu szere-
gowalnego?
51. Załóżmy, że do rozwiązania problemu pięciu filozofów zastosowano następującą proce-
durę: filozof oznaczony parzystym numerem zawsze podnosi widelec z lewej strony
przed podniesieniem tego z prawej, natomiast filozof oznaczony nieparzystym nume-
rem zawsze podnosi widelec z prawej strony przed podniesieniem tego z lewej. Czy ta
procedura gwarantuje działanie bez zakleszczeń?
52. W miękkim systemie czasu rzeczywistego występują cztery okresowe zdarzenia o okre-
sach 50, 100, 200 i 250 ms każdy. Przypuśćmy, że cztery zdarzenia wymagają odpo-
wiednio 35, 20, 10 i x ms czasu procesora. Jaka jest największa wartość x dla systemu
szeregowalnego?
53. Rozważ system, w którym pożądane jest oddzielenie strategii od mechanizmu szerego-
wania wątków zarządzanych na poziomie jądra. Zaproponuj sposoby osiągnięcia tego celu.
54. Dlaczego w rozwiązaniu problemu pięciu filozofów (listing 2.16) zmienną state w pro-
cedurze take_forks ustawiono na wartość HUNGRY?
55. Przeanalizuj procedurę put_forks z listingu 2.16. Przypuśćmy, że zmienną state[i] usta-
wiono na THINKING po dwóch wywołaniach funkcji test, a nie przed. W jaki sposób ta zmiana
wpłynie na rozwiązanie?
56. Problem czytelników i pisarzy można sformułować na kilka sposobów w zależności od
tego, kiedy powinny się uruchomić poszczególne kategorie procesów. Uważnie opisz
trzy różne odmiany problemu dla przypadków faworyzowania poszczególnych kategorii
procesów. Dla każdej odmiany określ, co się stanie, kiedy czytelnik lub pisarz osiągnie
gotowość dostępu do bazy danych, a co się stanie, kiedy proces zakończy korzystanie
z bazy danych.
57. Napisz skrypt powłoki, który generuje plik sekwencyjnych liczb poprzez odczytanie
ostatniej liczby w pliku, dodanie do niej jedynki, a następnie dołączenie jej do pliku.
Uruchom jeden egzemplarz skryptu w tle i jeden na pierwszym planie, tak aby każdy
z nich korzystał z tego samego pliku. Ile czasu upłynie, zanim da o sobie znać sytuacja
wyścigu? Co jest regionem krytycznym? Zmodyfikuj skrypt w taki sposób, aby zapobiec
wyścigowi. (Wskazówka: skorzystaj z polecenia
ln file file.lock
stwo od zasady polegające na tym, że jeśli w łazience jest kobieta, to mogą do niej wejść
inne kobiety, ale nie mogą wchodzić mężczyźni, i na odwrót. Znak na drzwiach łazienki
wskazuje, w jakim spośród trzech możliwych stanów się ona znajduje:
Wolna.
Zajęta przez kobiety.
Zajęta przez mężczyzn.
W dowolnie wybranym języku programowania napisz następujące procedury: kobieta_
chce_wejsc, mezczyzna_chce_wejsc, kobieta_wychodzi, mezczyzna_wychodzi. Możesz
wykorzystać dowolne liczniki i techniki synchronizacji.
61. Przepisz program z listingu 2.3 w taki sposób, aby obsługiwał więcej niż dwa procesy.
62. Napisz program rozwiązujący problem producent-konsument. Program powinien wyko-
rzystywać wątki i wspólny bufor. Do kontrolowania współdzielonych danych nie korzy-
staj z semaforów ani innych prymitywów synchronizacji. Po prostu pozwól wątkom na
korzystanie z tych danych, jeśli tego zażądają. Wykorzystaj operacje sleep i wakeup do
obsługi warunków „pełny” i „pusty”. Zobacz, ile czasu upłynie, zanim wystąpi sytuacja
wyścigu. Możesz np. polecić producentowi, by co jakiś czas wyświetlał liczbę. Nie wyświe-
tlaj więcej niż jednej liczby co minutę, ponieważ operacje wejścia-wyjścia mogą wpłynąć
na sytuację wyścigu.
63. Proces może być wprowadzony do kolejki cyklicznej więcej niż jeden raz w celu nadania
mu wyższego priorytetu. Taki sam skutek może mieć uruchomienie wielu wystąpień
programu, z których każde pracuje na innej części puli danych. Najpierw napisz program,
który sprawdza, czy wartości z listy są liczbami pierwszymi. Następnie opracuj metodę,
która umożliwia wielu instancjom programu na jednoczesne działanie w taki sposób,
aby żadne dwa wystąpienia programu nie sprawdzały tej samej wartości. Czy równoległe
uruchomienie wielu kopii programu pozwala na szybsze przetwarzanie listy? Zwróćmy
uwagę, że wyniki będą zależały od tego, jakie inne operacje wykonuje komputer. W kom-
puterze osobistym, na którym działa tylko jedno wystąpienie programu, nie należy spo-
dziewać się poprawy, ale w systemie z innymi procesami w ten sposób powinno udać się
uzyskać większy udział czasu procesora.
64. Celem tego ćwiczenia jest implementacja wielowątkowego rozwiązania sprawdzania,
czy podana wartość jest liczbą idealną. N jest liczbą idealną, jeśli suma wszystkich jej
dzielników, z wyłączeniem jej samej, wynosi N. Przykładami takich liczb są 6 i 28. Dane
wejściowe to liczba N. Algorytm zwraca true, jeśli liczba jest idealna i false w przeciw-
nym razie. Główny program będzie czytać liczby N i P z wiersza poleceń. Główny pro-
ces utworzy zbiór P wątków. Liczby o wartościach od 1 do N zostaną podzielone mię-
dzy te wątki w taki sposób, aby żadne dwa wątki nie przetwarzały tej samej liczby. Dla
każdego numeru z tego zbioru wątek sprawdzi, czy liczba jest dzielnikiem N. Jeśli tak
jest, doda tę liczbę do wspólnego bufora, w którym są przechowywane dzielniki N. Pro-
ces nadrzędny będzie czekać, aż wszystkie wątki zakończą działanie. Do rozwiązania
zadania zastosuj odpowiedni prymityw synchronizacji. Proces macierzysty określi, czy
liczba jest idealna, tzn. czy N jest sumą wszystkich jej podzielników, a następnie wyświe-
tli odpowiedni komunikat. (Uwaga: Aby przyspieszyć obliczenia, można ograniczyć zakres
przeszukiwanych liczb: od 1 do pierwiastka kwadratowego z N).
65. Napisz program zliczający częstość słów w pliku tekstowym. Plik tekstowy jest podzie-
lony na N segmentów. Każdy segment jest przetwarzany przez oddzielny wątek, który
zwraca pośrednie wartości liczby częstości dla segmentu. Główny proces czeka na zakoń-
czenie wszystkich wątków. Następnie oblicza łączną częstość danego słowa na podstawie
wyników poszczególnych wątków.
Pamięć główna (RAM — Random Access Memory) jest bardzo ważnym zasobem, którym trzeba
uważnie zarządzać. Chociaż obecnie przeciętny komputer domowy ma 10 tysięcy razy więcej
pamięci od IBM 7094 — największego komputera na świecie w początkach lat sześćdziesią-
tych — programy rozrastają się znacznie szybciej od pamięci. Sparafrazujmy tu prawo Parkin-
sona: „Programy rozszerzają się, wypełniając pamięć dostępną do ich przechowywania”. W niniej-
szym rozdziale opiszemy, w jaki sposób systemy operacyjne tworzą abstrakcje związane z pamięcią
oraz jak nimi zarządzają.
Marzeniem każdego programisty jest prywatna, nieskończenie rozbudowana i nieskończe-
nie szybka pamięć, która dodatkowo jest nieulotna — tzn. nie traci zawartości w przypadku
odłączenia energii elektrycznej. Skoro już jesteśmy przy pamięci — dlaczego by nie zadbać
dodatkowo i o to, aby była ona tania? Niestety, współczesna technika nie pozwala na produkowa-
nie takich pamięci. Być może Czytelnikowi uda się znaleźć sposób ich wytwarzania.
Jaka jest druga możliwość? Przez lata opracowywano koncepcję hierarchii pamięci. Zgodnie
z nią komputery są wyposażone w kilka megabajtów bardzo szybkiej i drogiej ulotnej pamięci
podręcznej, kilka gigabajtów średnio szybkiej i średnio drogiej ulotnej pamięci głównej oraz kilka
terabajtów wolnej, taniej, nieulotnej pamięci dyskowej lub SSD. Dodatkowo są jeszcze nośniki
wymienne, takie jak płyty DVD i pamięci pendrive podłączane przez USB. Zadaniem systemu
operacyjnego jest przekształcenie tej abstrakcji w użyteczny model, a następnie zarządzanie nią.
Komponent systemu operacyjnego, który zarządza (częścią) hierarchią pamięci, nazywa się
menedżerem pamięci. Jego zadaniem jest wydajne zarządzanie pamięcią: śledzenie, jakie części
pamięci są wykorzystywane, przydzielanie pamięci procesom, jeśli tego potrzebują, i zwalnianie
pamięci, kiedy przestanie być potrzebna.
W niniejszym rozdziale przeanalizujemy kilka różnych mechanizmów zarządzania pamięcią —
począwszy do bardzo prostych, skończywszy na bardzo zaawansowanych. Ponieważ zarządzanie
najniższym poziomem pamięci podręcznej jest standardowo wykonywane przez sprzęt, w tym
rozdziale skoncentrujemy się na modelu programistycznym pamięci głównej oraz skutecznych
201
sposobach jej zarządzania. Abstrakcje dotyczące pamięci trwałej — dysku — a także zagadnie-
nia związane z zarządzaniem nim będą przedmiotem następnego rozdziału. Rozpoczniemy od
początku. Najpierw przeanalizujemy najprostsze możliwe mechanizmy, a następnie będziemy
stopniowo przechodzić do coraz bardziej złożonych.
Najprostszą abstrakcją dotyczącą pamięci jest całkowity brak abstrakcji. W pierwszych kom-
puterach mainframe (przed 1960 rokiem), pierwszych minikomputerach (przed rokiem 1970)
oraz pierwszych komputerach osobistych (przed 1980 rokiem) nie było abstrakcji pamięci.
Każdy program widział pamięć fizyczną. Kiedy program wykonywał instrukcję postaci:
MOV REGISTER1,1000
Rysunek 3.1. Trzy proste sposoby organizacji pamięci z systemem operacyjnym i jednym
procesem użytkownika. Istnieją także inne możliwości
Jeśli system jest zorganizowany w taki sposób, to ogólnie rzecz biorąc, może działać tylko
jeden proces na raz. Kiedy użytkownik wpisze polecenie, system operacyjny kopiuje żądany
program z dysku do pamięci i go uruchamia. Gdy proces zakończy działanie, system operacyjny
wyświetla symbol zachęty i oczekuje na nowe polecenie. Kiedy otrzyma polecenie, ładuje do
pamięci nowy program, nadpisując starą zawartość pamięci.
Jednym ze sposobów zastosowania mechanizmów współbieżności w systemie bez abstrakcji
pamięci jest programowanie z wieloma wątkami. Ponieważ wszystkie wątki w procesie powinny
widzieć ten sam obraz pamięci, fakt, że są do tego zmuszone, nie stanowi problemu. Chociaż
ten pomysł się sprawdza, ma ograniczone zastosowanie, ponieważ często występuje potrzeba jed-
noczesnego działania programów niezwiązanych ze sobą — czegoś, czego abstrakcja wątków
nie zapewnia. Ponadto każdy system, który jest na tyle prymitywny, że nie zapewnia abstrakcji
pamięci, raczej nie zapewnia również abstrakcji wątków.
Rysunek 3.2. Ilustracja problemu relokacji. (a) Program o rozmiarze 16 kB; (b) inny program
o rozmiarze 16 kB; (c) dwa programy załadowane kolejno do pamięci
16384. Pierwszą instrukcją tego programu jest JMP 28. To skok do instrukcji ADD w pierwszym
programie, zamiast do instrukcji CMP. Najprawdopodobniej program zawiesi się grubo przed
upływem sekundy.
Zasadniczym problemem w tym przypadku jest to, że obydwa programy odwołują się do
bezwzględnych adresów pamięci fizycznej. Taka sytuacja jest zupełnie niepożądana. Chcieliby-
śmy, aby każdy program odwoływał się do prywatnego zbioru adresów — lokalnego dla każdego
z nich. Sposób osiągnięcia takiego stanu omówimy wkrótce. W komputerze IBM 360 problem
rozwiązano w ten sposób, że drugi z programów był modyfikowany „w locie” podczas ładowania
do pamięci, z wykorzystaniem techniki znanej jako statyczna relokacja. Mechanizm ten działał
następująco: kiedy program był ładowany pod adresem 16384, podczas procesu ładowania do
każdego adresu programu była dodawana stała 16384 (zatem instrukcja JMP 28 była przekształ-
cana na JMP 16412 itd.). Chociaż ten mechanizm działa prawidłowo, nie jest to rozwiązanie zbyt
ogólne, a poza tym posiada wadę polegającą na spowolnieniu procesu ładowania. Co więcej,
wymaga dodatkowych informacji we wszystkich programach wykonywalnych po to, aby wskazać,
jakie słowa zawierają adresy (podlegające relokacji), a które nie. Trzeba pamiętać o tym, że
liczba „28” z rysunku 3.2(b) podlega relokacji, ale dla instrukcji
MOV REGISTER1,28
która umieszcza liczbę 28 w rejestrze REGISTER1, nie wolno jej wykonać. Program ładujący potrze-
buje sposobu poinformowania go, co jest adresem, a co stałą.
Jak powiedzieliśmy w rozdziale 1., w świecie komputerów historia lubi się powtarzać. O ile
bezpośrednie adresowanie fizycznej pamięci jest odległą przeszłością w komputerach mainframe,
minikomputerach, komputerach biurkowych i notebookach, o tyle brak abstrakcji pamięci
w dalszym ciągu okazuje się powszechny w systemach wbudowanych oraz na kartach chipowych.
Takie urządzenia jak odbiorniki radiowe, pralki czy kuchenki mikrofalowe są obecnie wypeł-
nione oprogramowaniem (w pamięci ROM). W większości przypadków to oprogramowanie adre-
suje pamięć w sposób bezwzględny. Mechanizm ten działa dlatego, że programy są znane z góry,
a użytkownicy nie mają możliwości uruchamiania własnego oprogramowania na swoich tosterach.
O ile wysokiej klasy systemy wbudowane (np. telefony komórkowe) korzystają z rozbudo-
wanych systemów operacyjnych, o tyle prostsze systemy są ich pozbawione. W niektórych
przypadkach istnieje system operacyjny, ale jest to jedynie biblioteka powiązana z aplikacją,
która dostarcza wywołań systemowych do realizowania operacji wejścia-wyjścia oraz innych
popularnych zadań. Przykładem popularnego systemu operacyjnego działającego w formie biblio-
teki jest system e-cos.
Tak czy owak, udostępnianie procesom pamięci fizycznej ma kilka istotnych wad. Po pierwsze,
jeśli program użytkownika może zaadresować każdy bajt pamięci, może także z łatwością
uszkodzić system operacyjny. Zrobi to celowo lub przypadkowo i w efekcie doprowadzi system
do zawieszenia (chyba że istnieje specjalny sprzęt — np. mechanizm blokad i kluczy z systemu
IBM 360). Problem ten istnieje nawet wtedy, gdy działa tylko jeden program użytkownika
(jedna aplikacja). Po drugie w przypadku zastosowania tego modelu uruchomienie kilku pro-
gramów na raz (działających na przemian, jeśli jest tylko jeden procesor) jest trudne. W kom-
puterach osobistych sytuacja, w której jest otwartych kilka programów jednocześnie, występuje
powszechnie (np. edytor tekstu, klient e-mail i przeglądarka WWW), przy czym jeden nich jest
aktywny w danym momencie, a inne można reaktywować po kliknięciu myszą. Ponieważ taka
sytuacja jest trudna do osiągnięcia, gdy nie istnieje abstrakcja pamięci fizycznej, trzeba było jakoś
zaradzić temu problemowi.
modemów i faksów przestrzeń ta staje się zbyt mała. Powstaje zatem konieczność zastosowania
większej liczby cyfr. Przestrzeń adresowa dla portów wejścia-wyjścia w komputerach Pentium
biegnie od 0 do 16383. Adresy IPv4 są liczbami 32-bitowymi. Dlatego ich przestrzeń adresowa
biegnie od 0 do 232−1 (tak jak poprzednio, niektóre adresy są zarezerwowane).
Przestrzenie adresowe nie muszą być liczbami. Zbiór domen internetowych .com to także
przestrzeń adresowa. Zawiera ona wszystkie ciągi znaków o długości od 2 do 63, jakie można
utworzyć z liter, cyfr i myślników, zakończone ciągiem .com. W tym momencie Czytelnik powi-
nien zrozumieć ideę. Jest ona dosyć prosta.
Trochę trudniejsze okazuje się przydzielenie każdemu programowi własnej przestrzeni adre-
sowej, tak aby adres 28 w jednym programie oznaczał inną lokalizację fizyczną niż adres 28
w innym programie. Poniżej omówimy prosty sposób rozwiązania tego problemu. Dawniej był
on używany powszechnie, ale zaprzestano tego z powodu zastosowania bardziej złożonych
(i lepszych) mechanizmów w nowoczesnych układach procesorów.
Rysunek 3.3. Rejestry bazowy i limitu można wykorzystać w celu przydzielenia każdemu
procesowi osobnej przestrzeni adresowej
implementacjach rejestry bazowy i limitu są zabezpieczone w taki sposób, że tylko system ope-
racyjny może je modyfikować. Tak było w przypadku procesora CDC 6600, ale nie w przypadku
układu Intel 8088, który nie miał nawet rejestru limitu. Miał jednak kilka rejestrów bazowych,
co pozwalało na niezależną relokację tekstu programu i jego danych. Nie posiadał jednak zabez-
pieczeń przed odwołaniami do pamięci wykraczającymi poza dozwolony zakres.
Wadą relokacji z wykorzystaniem rejestrów bazowego i limitu jest konieczność wykony-
wania dodawania i porównania przy każdym odwołaniu do pamięci. Porównania są wykonywane
szybko, ale dodawanie okazuje się wolne z powodu propagacji przeniesienia, o ile procesor nie
jest wyposażony w specjalny sprzęt sumujący.
procesów w pamięci przez cały czas wymaga olbrzymich ilości pamięci i nie może być zrealizo-
wane, jeśli rozmiar pamięci na to nie pozwala.
Przez lata opracowywano dwa ogólne rozwiązania problemu przeładowania pamięci. Naj-
prostsza strategia, zwana wymianą (ang. swapping), polega na załadowaniu określonego procesu
w całości, uruchomieniu go przez pewien czas, a następnie umieszczeniu z powrotem na dysku.
Bezczynne procesy zwykle są zapisane na dysku, zatem wtedy, gdy nie działają, w ogóle nie zaj-
mują pamięci (choć niektóre z nich okresowo się budzą w celu wykonania swojej pracy, a następ-
nie ponownie przechodzą w stan uśpienia). Inna strategia, zwana pamięcią wirtualną, umożli-
wia programom działanie nawet wtedy, gdy częściowo są zapisane w pamięci głównej. Poniżej
przestudiujemy mechanizmy wymiany, natomiast w następnym podrozdziale omówimy pamięć
wirtualną.
Działanie systemu wymiany zilustrowano na rysunku 3.4. Początkowo w pamięci jest tylko
proces A. Następnie zostają utworzone (tzn. załadowane z dysku) procesy B i C. Na rysunku 3.4(d)
pokazano sytuację, w której proces A został ponownie zapisany na dysk. Następnie ładowany
jest proces D, a proces B wędruje na dysk. Na koniec ponownie jest ładowany proces A. Ponie-
waż proces A znajduje się teraz w innej lokalizacji, adresy w nim zapisane muszą być reloko-
wane — albo przez oprogramowanie w czasie ładowania do pamięci, albo (co bardziej prawdo-
podobne) przez sprzęt, podczas działania programu. W tym przypadku można z powodzeniem
wykorzystać rejestry bazowy i limitu.
Rysunek 3.4. Alokacja pamięci zmienia się, w miarę jak procesy wchodzą do pamięci i ją opuszczają.
Regiony zacieniowane oznaczają nieużywaną pamięć
Kiedy w wyniku wymiany w pamięci tworzą się duże luki, można je scalić w jeden ciągły
blok poprzez przeniesienie wszystkich procesów tak daleko w dół, jak się da. Proces ten nazywa
się kompaktowaniem pamięci (ang. memory compaction). Zwykle się tego nie robi, ponieważ ope-
racja ta wymaga dużo czasu procesora. I tak na maszynie z pamięcią główną o rozmiarze 1 GB,
która potrafi skopiować 4 bajty w ciągu 20 ns, kompaktowanie całej pamięci zajmuje 5 s.
Warto zwrócić uwagę na to, ile pamięci należy przydzielić procesowi podczas jego tworze-
nia lub wymiany z dyskiem. Jeśli są tworzone procesy o stałym rozmiarze, który się nigdy nie
zmienia, alokacja jest prosta: system operacyjny alokuje dokładnie tyle pamięci, ile potrzeba —
ani więcej, ani mniej.
Jeśli jednak segmenty danych procesów mogą się rozrastać — np. poprzez dynamiczną
alokację pamięci ze sterty, co jest możliwe w wielu językach programowania, przy każdej pró-
bie wzrostu rozmiaru procesu występuje problem. Jeżeli z procesem sąsiaduje blok wolnej
pamięci, można go zaalokować i proces może się rozrosnąć oraz zająć ten blok. Jeśli natomiast
proces sąsiaduje z innym procesem, rozrastający się proces będzie musiał być przeniesiony do
bloku wolnej pamięci o odpowiednim rozmiarze albo kilka procesów (lub jeden) będzie musiało
zostać wymienionych z dyskiem, tak aby powstał wystarczająco duży blok wolnej pamięci. Jeśli
proces nie będzie miał możliwości zwiększenia swojego rozmiaru w pamięci, a obszar wymiany
na dysku jest zapełniony, proces będzie musiał być zawieszony do czasu zwolnienia miejsca (lub
może być zniszczony).
Jeśli spodziewamy się, że większość procesów będzie się rozrastała podczas swojego dzia-
łania, warto zaalokować nieco więcej pamięci w czasie wymiany procesu z dyskiem lub jego
przenoszenia. Dzięki temu możliwe stanie się zmniejszenie kosztów związanych z przenosze-
niem lub wymianą procesów niemieszczących się w bloku pamięci, która została dla nich zaalo-
kowana. Podczas wymiany procesów na dysk należy pamiętać, że wymianie powinna podlegać
tylko pamięć faktycznie używana. Wymiana przy okazji dodatkowej pamięci jest marnotrawstwem.
Na rysunku 3.5(a) można zobaczyć konfigurację pamięci, w której dla dwóch procesów uwzględ-
niono miejsce na rozrost.
Rysunek 3.5. (a) Alokacja miejsca dla rozrastającego się segmentu danych; (b) alokacja miejsca
dla rosnącego stosu oraz rosnącego segmentu danych
Jeśli procesy mogą mieć dwa rozrastające się segmenty — np. segment danych używany
jako sterta dla zmiennych alokowanych i zwalnianych dynamicznie oraz segment stosu dla
zwykłych zmiennych lokalnych i adresów powrotu — to alternatywny układ sam się sugeruje;
pokazano go na rysunku 3.5(b). Na tym rysunku widzimy, że każdy proces występujący na ilu-
stracji ma na początku zaalokowanej pamięci stos, który wzrasta w dół, oraz segment danych,
bezpośrednio poniżej tekstu programu, wzrastający w górę. Pamięć występująca pomiędzy nimi
może być używana przez dowolny segment. Jeśli jej zabraknie, proces będzie musiał być prze-
niesiony do wolnego bloku o wystarczająco dużym rozmiarze, wymieniony z dyskiem do czasu,
kiedy będzie można utworzyć odpowiednio duży blok, lub zniszczony.
Rysunek 3.6. (a) Fragment pamięci z pięcioma procesami i trzema blokami wolnymi; kreski
pokazują jednostki alokacji pamięci; regiony zacienione (w mapie bitowej 0) są wolne; (b) mapa
bitowa odpowiadająca pamięci; (c) te same informacje w postaci listy
Ponieważ miejsce w tabeli procesów dla procesu końcowego będzie standardowo wskazy-
wało na pozycję na liście odpowiadającą samemu procesowi, wygodniej jest posługiwać się listą
dwukierunkową niż jednokierunkową, tak jak na rysunku 3.6(c). Taka struktura umożliwia łatwiej-
sze znalezienie poprzedniej pozycji i sprawdzenie, czy jest możliwe scalenie.
Kiedy procesy i bloki wolne są umieszczone na liście posortowanej według adresu, można
wykorzystać kilka algorytmów w celu alokacji pamięci dla utworzonego procesu (lub wymiany
istniejącego procesu z dyskiem). Zakładamy, że menedżer pamięci wie, ile pamięci należy zaalo-
kować. Najprostszy algorytm to pierwszy pasujący (ang. first fit). Menedżer pamięci skanuje listę
segmentów tak długo, aż znajdzie wolny blok o odpowiedniej wielkości. Ten blok jest następ-
nie dzielony na dwie części — jedna zostaje przeznaczona na proces, natomiast druga na nie-
używaną pamięć, chyba że wystąpi mało prawdopodobny przypadek, w którym wolny blok będzie
dokładnie odpowiadał rozmiarowi procesu. Algorytm „pierwszy pasujący” jest szybki, ponieważ
operacje wyszukiwania są w nim ograniczone do minimum.
Odmianą tego algorytmu jest algorytm następny pasujący (ang. next fit). Algorytm ten działa
w taki sam sposób, jak „pierwszy pasujący”, poza tym, że zapamiętuje miejsce, w którym został
znaleziony wolny blok o odpowiedniej wielkości. Kiedy algorytm zostanie wywołany następnym
razem w celu znalezienia wolnego bloku, rozpoczyna wyszukiwanie od miejsca, w którym zakoń-
czył szukanie ostatnim razem, a nie zawsze od początku, jak w przypadku algorytmu „pierwszy
pasujący”. Symulacje przeprowadzone przez Baysa (1977) pokazały, że algorytm „następny
pasujący” gwarantuje nieco gorszą wydajność niż „pierwszy pasujący”.
Chociaż można wykorzystać rejestry bazowy i limitu do stworzenia abstrakcji przestrzeni adre-
sowych, jest inny problem, który trzeba rozwiązać: zarządzanie programami o olbrzymich rozmia-
rach (ang. bloatware). Rozmiary pamięci rosną bardzo szybko, ale rozmiary programów powięk-
szają się znacznie szybciej. W latach osiemdziesiątych na wielu uniwersytetach działały systemy
z podziałem czasu, gdzie dziesiątki (mniej lub bardziej zadowolonych) użytkowników pracowało
jednocześnie na komputerze VAX wyposażonym w pamięć 4 MB. Obecnie firma Microsoft
zaleca co najmniej 2 GB pamięci dla 64-bitowej wersji systemu Windows 8. Z powodu tendencji
do korzystania z multimediów wymagania względem pamięci stają się jeszcze większe.
W konsekwencji tego rozwoju istnieje potrzeba uruchamiania programów, które są zbyt
duże, by mogły się zmieścić w pamięci. Z całą pewnością występuje również potrzeba istnienia
systemów zdolnych do uruchamiania wielu programów jednocześnie, gdzie pojedyncze pro-
gramy co prawda mieszczą się w pamięci, ale razem przekraczają jej objętość. Wymiana z dyskiem
nie jest atrakcyjną opcją, ponieważ typowy dysk SATA osiąga szybkość transferu co najwyżej
kilkuset megabajtów na sekundę. Oznacza to, że przeniesienie na dysk programu o rozmiarze
1 GB zajmuje co najmniej 10 s, a kolejne 10 s zajmuje wczytanie z dysku do pamięci innego pro-
gramu o rozmiarze 1 GB.
Problem programów o rozmiarach przekraczających objętość pamięci istnieje od początku
historii komputerów, choć w przeszłości istniał on głównie w wybranych obszarach, takich jak
obliczenia naukowe i inżynieryjne (symulacja powstania wszechświata lub nawet symulacja dzia-
łania nowego samolotu zajmuje dużo pamięci). W latach sześćdziesiątych przyjęto rozwiązanie
problemu polegające na podzieleniu programów na niewielkie fragmenty zwane nakładkami.
W momencie uruchamiania programu do pamięci był ładowany tylko menedżer nakładek, który
natychmiast ładował się do pamięci i uruchamiał nakładkę 0. Po wykonaniu nakładki 0 menedżer
nakładek otrzymywał polecenie załadowania nakładki 1, powyżej nakładki 0 (jeśli było miejsce
w pamięci) lub zamiast nakładki 0 (jeśli nie było miejsca). Niektóre systemy nakładek były
bardzo złożone — pozwalały na jednoczesne istnienie w pamięci wielu nakładek. Nakładki były
przechowywane na dysku i przenoszone do i z pamięci za pomocą menedżera nakładek.
Chociaż właściwą pracę związaną z przenoszeniem nakładek do i z pamięci wykonywał
system operacyjny, zadanie podzielenia programu na fragmenty musiało być wykonane ręcznie,
przez programistę. Dzielenie dużych programów na małe, modularne fragmenty było czaso-
chłonne, nudne i stwarzało ryzyko popełnienia licznych błędów. Niewielu programistów dobrze
radziło sobie z tym problemem. Nie minęło zbyt wiele czasu, zanim ktoś wymyślił sposób prze-
kazania całego zadania komputerowi.
Metodę, opracowaną przez [Fotheringham, 1961], określono terminem pamięci wirtualnej.
Podstawowa idea pamięci wirtualnej polega na przydzieleniu każdemu programowi oddzielnej
przestrzeni adresowej, podzielonej na fragmenty zwane stronami. Każda strona zawiera ciągły
zakres adresów. Strony te są mapowane na pamięć fizyczną, ale nie wszystkie strony muszą znaj-
dować się w pamięci fizycznej, aby można było uruchomić program. Kiedy program odwoła się
do części przestrzeni adresowej znajdującej się w pamięci fizycznej, sprzęt wykonuje konieczne
mapowanie „w locie”. Kiedy program odwołuje się do części przestrzeni adresowej, która znajduje
się poza pamięcią fizyczną, powiadamiany jest system operacyjny — jego zadaniem jest pobranie
brakujących informacji i ponowne uruchomienie nieudanej instrukcji.
Pamięć wirtualna w pewnym sensie jest uogólnieniem idei rejestrów bazowego i limitu. W pro-
cesorze 8088 są osobne rejestry bazowe dla tekstu programu i danych (ale nie ma rejestrów
limitu). W przypadku systemu z pamięcią wirtualną, zamiast stosowania oddzielnej relokacji dla
segmentów tekstu i danych, całą przestrzeń adresową można zmapować do pamięci fizycznej
w stosunkowo niewielkich jednostkach. Sposób implementacji pamięci wirtualnej pokażemy
poniżej.
Pamięć wirtualna sprawdza się równie dobrze w systemie wieloprogramowym, gdzie w pamięci
jednocześnie występują fragmenty wielu programów. W czasie kiedy program oczekuje na wczy-
tanie części swojego kodu do pamięci, procesor można przydzielić innemu procesowi.
3.3.1. Stronicowanie
Większość systemów pamięci wirtualnej wykorzystuje technikę zwaną stronicowaniem, którą
za chwilę opiszemy. W dowolnym komputerze program odwołuje się do zbioru adresów pamięci.
Kiedy program wykonuje instrukcję postaci:
MOV REG,1000
robi to po to, aby skopiować zawartość adresu pamięci 1000 do rejestru REG (lub odwrotnie,
w zależności od komputera). Adresy mogą być generowane z wykorzystaniem indeksów, rejestrów
bazowych, rejestrów segmentowych i innych.
Adresy generowane przez programy są nazywane adresami wirtualnymi i tworzą wirtualną
przestrzeń adresową. W komputerach bez pamięci wirtualnej adresy wirtualne są umieszczane
bezpośrednio na magistrali pamięci, co powoduje odczyt lub zapis słowa pamięci fizycznej o tym
samym adresie. W przypadku zastosowania pamięci wirtualnej adresy wirtualne nie są prze-
syłane bezpośrednio na magistralę pamięci. Zamiast tego są one przesyłane do jednostki zarzą-
dzania pamięcią MMU (Memory Management Unit), która odwzorowuje adresy wirtualne na
adresy pamięci fizycznej, tak jak pokazano na rysunku 3.8.
Rysunek 3.8. Umiejscowienie i funkcja jednostki MMU; na tym rysunku jednostkę MMU
pokazano jako część układu procesora, ponieważ obecnie tak często się ją realizuje (logicznie
mógłby to jednak być osobny układ i tak było w przeszłości)
Bardzo prosty przykład tego, w jaki sposób działa to odwzorowanie, pokazano na rysunku 3.9.
W tym przykładzie mamy komputer, który generuje adresy 16-bitowe — od 0 do 64 kB. Są to
adresy wirtualne. Ten komputer ma jednak tylko 32 kB pamięci fizycznej. A zatem chociaż można
napisać program o objętości 64 kB, nie można go w całości załadować do pamięci i uruchomić.
Pełna kopia obrazu pamięci programu, o rozmiarze do 64 kB, musi jednak być zapisana na dysku,
zatem można ładować fragmenty zgodnie z potrzebami.
Rysunek 3.9. Relację pomiędzy adresami wirtualnymi a adresami pamięci fizycznej zilustrowano
w postaci tablicy stron; każda strona zaczyna się pod adresem będącym wielokrotnością 4096 i kończy
się pod adresem o 4095 wyższym; zatem 4 kB – 8 kB w rzeczywistości oznacza adresy 4096 – 8191,
natomiast 8 kB – 12 kB oznacza adresy 8192 – 12 287
to do jednostki MMU przesyłany jest wirtualny adres 0. Jednostka MMU sprawdza, czy ten adres
wirtualny wypada na stronie 0 (0 – 4095), co zgodnie z mapą odpowiada ramce nr 2 (8192 – 12287).
W związku z tym przekształca ten adres na 8192 i wysyła na magistralę adres 8192. Pamięć
nie wie nic o istnieniu jednostki MMU i widzi jedynie żądanie odczytu adresu 8192, który hono-
ruje. Tak więc jednostka MMU odwzorowuje wszystkie adresy z zakresu 0 – 4095 na adresy
fizyczne 8192 – 12287.
Podobnie instrukcja:
MOV REG,8192
ponieważ adres wirtualny 8192 (na wirtualnej stronie 2) zostanie zmapowany na adres 24576
(w fizycznej ramce strony nr 6). I trzeci przykład — adres wirtualny 20500 wypada 20 bajtów
od początku wirtualnej strony 5 (adresy wirtualne od 20480 do 24575) i jest odwzorowany na
fizyczny adres 12288+20 = 12308.
Sama zdolność mapowania 16 wirtualnych stron na dowolną z ośmiu ramek stron poprzez
odpowiednie ustawienie mapy jednostki MMU nie rozwiązuje problemu polegającego na tym,
że wirtualna przestrzeń adresowa jest większa od pamięci fizycznej. Ponieważ mamy tylko
osiem fizycznych ramek stron, tylko osiem wirtualnych stron z rysunku 3.9 będzie odwzorowa-
nych na pamięć fizyczną. Pozostałe, oznaczone na rysunku krzyżykiem, nie zostaną odwzorowane.
To, które strony są fizycznie obecne w pamięci, kontroluje sprzętowy bit Obecna/nieobecna.
Co się dzieje, kiedy program odwołuje się do nieodwzorowanych adresów, np. za pomocą
instrukcji:
MOV REG,32780
Adres ten oznacza 12. bajt wewnątrz wirtualnej strony 8 (począwszy od adresu 32768). Jed-
nostka MMU zauważy, że strona nie jest odwzorowana (na rysunku jest oznaczona krzyżykiem),
i spowoduje, że procesor wykona rozkaz pułapki do systemu operacyjnego. Ta pułapka nosi
nazwę brak strony (ang. page fault). System operacyjny wybiera mało używaną ramkę strony
i zapisuje jej zawartość na dysk (jeśli nie została zapisana tam wcześniej). Następnie pobiera
stronę, do której przed chwilą program się odwoływał, na miejsce strony przed chwilą zwol-
nionej, modyfikuje mapę i wznawia przerwaną instrukcję.
Jeśli np. system operacyjny zdecydowałby się na wyeksmitowanie strony 1, mógłby zała-
dować wirtualną stronę 8 pod fizyczny adres 8192 i wprowadzić dwie zmiany w mapie MMU.
Najpierw oznaczyłby pozycję wirtualnej strony 1 jako nieodwzorowaną. Dzięki temu wszystkie
przyszłe odwołania do adresów wirtualnych 4096 – 8191 byłyby przechwytywane przez system
operacyjny. Następnie zastąpiłby krzyżyk na pozycji wirtualnej strony 8 wartością 1. Dzięki
temu po wznowieniu przechwyconej instrukcji wirtualny adres 32780 zostałby odwzorowany na
adres fizyczny 4108 (4096+12).
Spróbujmy teraz zajrzeć do wnętrza jednostki MMU, aby zobaczyć, jak działa i dlaczego
wybraliśmy rozmiar strony będący potęgą liczby 2. Na rysunku 3.10 możemy zobaczyć przy-
kład wirtualnego adresu 8196 (dwójkowo 0010000000000100), który odwzorowano za pomocą
mapy MMU z rysunku 3.9. Wchodzący 16-bitowy adres wirtualny jest dzielony na 4-bitowy
numer strony i 12-bitowe przesunięcie. Przy czterech bitach numeru strony możemy zaadreso-
wać 16 stron, natomiast 12 bitów przesunięcia umożliwia zaadresowanie wszystkich 4096 bajtów
na stronie.
Numer strony jest wykorzystywany jako indeks do tabeli stron, która zwraca numer ramki
strony odpowiadającej tej stronie wirtualnej. Ustawienie bitu Obecna/nieobecna na 0 powoduje
wykonanie rozkazu pułapki do systemu operacyjnego. Jeśli bit ma wartość 1, to numer ramki
strony znaleziony w tabeli stron jest kopiowany do 3 górnych bitów rejestru wyjściowego. Nato-
miast 12-bitowe przesunięcie jest kopiowane z wchodzącego adresu wirtualnego w postaci nie-
zmodyfikowanej. Razem tworzą one 15-bitowy adres fizyczny. Rejestr wyjściowy jest następnie
umieszczany na magistrali pamięci jako adres pamięci fizycznej.
numer ramki strony. Jest on dołączany do adresu na pozycje bitów wyższego rzędu i zastępuje
numer strony wirtualnej. Razem z przesunięciem tworzy adres fizyczny, który jest przesyłany
do pamięci.
Tak więc celem tabeli stron jest odwzorowanie stron wirtualnych na ramki stron. Z mate-
matycznego punktu widzenia tabela stron jest funkcją, w której numer wirtualnej strony sta-
nowi argument, a numer strony fizycznej — wynik. Dzięki wykorzystaniu wyniku tej funkcji
można zastąpić pole numeru strony w adresie wirtualnym polem ramki strony i w ten sposób
utworzyć adres w pamięci fizycznej.
W tym rozdziale zajmujemy się tylko pamięcią wirtualną, a nie pełną wirtualizacją.
Inaczej mówiąc: jeszcze nie interesują nas maszyny wirtualne. W rozdziale 7. pokażemy, że
każda maszyna wirtualna wymaga własnej pamięci wirtualnej. W rezultacie organizacja tablicy
stron staje się znacznie bardziej skomplikowana — obejmuje tablice stron-cienie, tabele zagnież-
dżone itp. Nawet bez tych tajemniczych konfiguracji, jak się wkrótce przekonamy, zagadnienia
stronicowania i pamięci wirtualnej są dość złożone.
Rozmiar może się różnić dla różnych komputerów, ale 32 bity to często spotykany rozmiar.
Najważniejszym polem jest Numer ramki strony. Celem tworzenia odwzorowania pomiędzy
stronami jest uzyskanie tej wartości. Obok niego znajduje się bit Obecna/nieobecna. Jeśli ten
bit ma wartość 1, pozycja w tabeli jest ważna i można ją wykorzystać. Jeśli ma wartość 0, to
strona wirtualna, której dotyczy ta pozycja w tabeli, obecnie znajduje się poza pamięcią. Próba
dostępu do pozycji w tabeli stron w przypadku ustawienia tego bitu na 0 powoduje błąd braku
strony.
Bity Zabezpieczenia informują o tym, jakiego rodzaju dostęp jest dozwolony. W najprostszej
formie pole to zawiera 1 bit — wartość 0 pozwala na dostęp do odczytu i zapisu, natomiast war-
tość 1 na dostęp tylko do odczytu. W bardziej zaawansowanej konfiguracji pole to składa się
z 3 bitów — po jednym dla dostępu do czytania, pisania i uruchamiania strony.
Pola Zmodyfikowano i W użyciu służą do zarządzania wykorzystaniem strony. Kiedy strona
jest zapisywana, sprzęt automatycznie ustawia bit Zmodyfikowano. Ten bit ma sens wtedy, gdy
system operacyjny zdecyduje się na odzyskanie ramki strony. Jeśli strona była modyfikowana
(tzn. jest „zabrudzona”), musi być ponownie zapisana na dysk. Jeśli nie była modyfikowana (tzn.
jest „czysta”), może być porzucona, ponieważ na dysku znajduje się prawidłowa kopia. Ten bit
jest czasami nazywany bitem zabrudzenia, ponieważ odzwierciedla stan strony.
Bit W użyciu jest ustawiany zawsze wtedy, gdy strona jest używana — następuje z niej odczyt
albo jest zapisywana. Wartość bitu pomaga w podjęciu decyzji o tym, którą stronę wyekspe-
diować na dysk w przypadku wystąpienia błędu braku strony. Strony, które nie są używane,
okazują się lepszymi kandydatami od tych, które są używane. Bit W użyciu pełni ważną rolę
w kilku algorytmach wymiany stron, które omówimy w dalszej części tego rozdziału.
Ostatni z bitów — Buforowanie wyłączone — pozwala na wyłączenie buforowania strony.
Własność ta ma znaczenie dla stron odwzorowywanych na rejestry urządzeń, a nie na pamięć.
Jeśli system operacyjny działa w pętli, oczekując na to, aż jakieś urządzenie wejścia-wyjścia
odpowie na polecenie, ważne jest, aby sprzęt pobierał kolejne słowa z urządzenia, a nie używał
starej kopii umieszczonej w buforze. Dzięki temu bitowi można wyłączyć buforowanie. Maszyny,
które posiadają oddzielną przestrzeń wejścia-wyjścia i nie korzystają z wejścia-wyjścia odwzo-
rowanego w pamięci, nie potrzebują tego bitu.
Zwróćmy uwagę, że adres dyskowy użyty do przechowywania strony w czasie, gdy nie jest
ona w pamięci, nie jest częścią tabeli stron. Powód okazuje się prosty. W tabeli stron są tylko
te informacje, których sprzęt potrzebuje do przekształcenia adresu wirtualnego na fizyczny.
Informacje, których system operacyjny potrzebuje do obsługi błędów braku stron, są przechowy-
wane w tabelach programowych, wewnątrz systemu operacyjnego. Sprzęt ich nie potrzebuje.
Zanim wejdziemy w dalsze szczegóły implementacyjne, warto jeszcze raz podkreślić, że
podstawowym zadaniem wykonywanym przez pamięć wirtualną jest tworzenie nowej abstrak-
cji — przestrzeni adresowej — to abstrakcja pamięci fizycznej, podobnie jak proces będąca
abstrakcją fizycznego procesora (CPU). Pamięć wirtualną można zaimplementować poprzez
podzielenie przestrzeni adresów wirtualnych na strony i odwzorowanie każdej strony na ramkę
pamięci fizycznej (lub czasowe pozostawienie strony bez odwzorowania). Tak więc niniejszy roz-
dział dotyczy głównie abstrakcji utworzonej przez system operacyjny oraz sposobu zarządzania
tą abstrakcją.
Należy przy tym pamiętać, że każdy proces wymaga własnej tabeli stron (ponieważ posiada wła-
sną wirtualną przestrzeń adresową).
Potrzeba rozbudowanego i szybkiego mechanizmu odwzorowywania stron jest znaczącym
ograniczeniem dla sposobu budowania komputerów. W najprostszym układzie (przynajmniej
pojęciowo) występuje pojedyncza tabela stron zawierająca tablicę szybkich rejestrów sprzęto-
wych, po jednej pozycji dla każdej strony wirtualnej, poindeksowanych za pomocą numeru
strony wirtualnej, tak jak pokazano na rysunku 3.10. W momencie uruchomienia procesu sys-
tem operacyjny ładuje rejestry tabelą stron procesu pobraną z kopii przechowywanej w pamięci
głównej. W czasie wykonywania procesu dla tabeli stron nie są wymagane żadne inne odwoła-
nia do pamięci. Zaletami tej metody są jej prostota oraz brak konieczności odwoływania się do
pamięci podczas odwzorowywania. Wada to wysokie koszty obliczeniowe, w przypadku gdy tabela
stron ma duże rozmiary. Najczęściej koszty te są po prostu zbyt duże, aby rozwiązanie było
praktyczne. Inna wada to obniżenie wydajności spowodowane koniecznością załadowania pełnej
tabeli stron przy każdym przełączeniu kontekstu.
Innym skrajnym rozwiązaniem jest przechowywanie tabeli stron całkowicie w pamięci
głównej. Wszystko, czego potrzebuje sprzęt w tej sytuacji, to pojedynczy rejestr, który wskazuje
na początek tabeli stron. Przy takim projekcie modyfikacja odwzorowania adresów wirtualnych
na fizyczne przy przełączeniu kontekstu sprowadza się do załadowania pojedynczego rejestru.
Oczywiście ta metoda ma wadę — wymaga jednego odwołania lub większej liczby odwołań do
pamięci w celu czytania pozycji w tabeli stron podczas uruchamiania każdej instrukcji. W związku
z tym jest bardzo wolna.
Bufory TLB
Przyjrzyjmy się teraz powszechnie implementowanym mechanizmom przyspieszania stroni-
cowania oraz obsługi bardzo dużych przestrzeni adresów wirtualnych. Rozpoczniemy od tych
pierwszych. Punktem wyjścia większości technik optymalizacji jest umieszczenie tabeli stron
w pamięci. Takie rozwiązanie teoretycznie powinno znacznie poprawić wydajność. Dla przy-
kładu rozważmy 1-bajtową instrukcję, która kopiuje jeden rejestr do innego. W przypadku braku
stronicowania ta instrukcja wykonuje tylko jedno odwołanie do pamięci — w celu pobrania instruk-
cji. W przypadku zastosowania stronicowania dostęp do tabeli stron wymaga co najmniej jednego
dodatkowego odwołania do pamięci. Ponieważ szybkość uruchamiania jest zwykle ograniczona
tempem, w jakim procesor może pobierać instrukcje i dane z pamięci, konieczność wykonania
dwóch odwołań do pamięci przy jednym odwołaniu do adresu obniża wydajność o połowę. W tych
okolicznościach nikt nie korzystałby ze stronicowania.
Projektanci komputerów wiedzą o tym problemie od lat i znaleźli jego rozwiązanie. Bazuje
ono na obserwacji, zgodnie z którą większość programów zazwyczaj wykonuje wiele odwołań
do małej liczby stron. Tak więc tylko niewielka część pozycji w tabeli stron jest czytana często —
pozostałe prawie wcale nie są wykorzystywane.
Opracowane rozwiązanie polega na wyposażeniu komputerów w niewielkie urządzenie sprzę-
towe służące do odwzorowywania adresów wirtualnych na fizyczne bez konieczności sięgania
do tabeli stron. Urządzenie to, nazywane buforem TLB (Translation Lookaside Buffer) lub cza-
sami pamięcią asocjacyjną, zilustrowano w tabeli 3.1. Zwykle jest ono zlokalizowane wewnątrz
jednostki MMU i składa się z niewielkiej liczby pozycji — w tym przypadku ośmiu — ale
rzadko więcej niż 64. Każda pozycja zawiera informacje dotyczące jednej strony. Obejmują one
numer strony wirtualnej, bit ustawiany w przypadku gdy strona jest modyfikowana, kod zabez-
pieczeń (uprawnienia czytania/pisania/uruchamiania) oraz fizyczną ramkę strony wskazującą na
lokalizację strony w pamięci fizycznej. Pola te odpowiadają jeden do jednego polom w tabeli
stron. Wyjątkiem jest numer strony wirtualnej, który nie jest potrzebny w tabeli stron. Kolejny
bit wskazuje na to, czy pozycja jest ważna (tzn. jest w użyciu), czy nie.
Przykładem procesu, który mógłby wygenerować bufor TLB z tabeli 3.1, jest proces wyko-
nujący się w pętli i obejmujący strony wirtualne 19, 20 i 21. W związku z tym pozycje bufora TLB
zawierają kody zabezpieczeń dla odczytu i uruchamiania. Główne dane będące w bieżącym
użytku (np. przetwarzana macierz) znajdują się na stronach 129 i 130. Na stronie 140 znajdują
się indeksy wykorzystywane w obliczeniach macierzowych. Wreszcie — na stronach 860 i 861
jest stos.
Przyjrzyjmy się teraz sposobowi działania buforu TLB. Po przesłaniu adresu wirtualnego
do jednostki MMU, aby dokonać translacji, sprzęt najpierw sprawdza, czy w buforze TLB znaj-
duje się numer wirtualnej strony. W tym celu jednocześnie (tzn. współbieżnie) porównuje go
ze wszystkimi pozycjami. Do tego celu potrzebuje specjalnego sprzętu, w który są wyposażone
wszystkie jednostki MMU z TLB. Jeśli zostanie znaleziona pasująca pozycja, a dostęp do niej nie
narusza reguł ustanowionych przez bity zabezpieczeń, ramka strony jest pobierana bezpośred-
nio z bufora TLB, bez potrzeby odwoływania się do tabeli stron. Jeśli numer strony wirtualnej
znajduje się w buforze TLB, ale instrukcja próbuje zapisać stronę tylko do odczytu, generowany
jest błąd chybionego odwołania.
Interesujący przypadek zachodzi w czasie, kiedy numeru wirtualnej strony nie ma w bufo-
rze TLB. Jednostka MMU wykrywa ten brak i wykonuje standardową operację wyszukiwania
w tabeli. Następnie usuwa jedną z pozycji z bufora TLB i zastępuje go odczytaną przed chwilą
pozycją z tabeli stron. W ten sposób, jeśli strona zostanie ponownie użyta w niedługim czasie,
odwołanie do niej za drugim razem będzie trafione — strona będzie obecna w buforze TLB. Pod-
czas gdy pozycja jest usuwana z bufora TLB, bit Zmodyfikowano zostaje skopiowany do tabeli
stron w pamięci. Pozostałe wartości już się tam znajdują — z wyjątkiem bitu W użyciu. Kiedy
bufor TLB jest ładowany z tabeli stron, wszystkie pola są pobierane z pamięci.
W istocie sprawa jest jeszcze bardziej złożona. Chybienie nie jest tylko miękkie lub twarde.
Niektóre chybienia są nieco miększe (lub nieco twardsze) niż inne. Dla przykładu załóżmy, że
w wyniku przeszukiwania tablicy stron nie znaleziono strony w tablicy stron procesu i z tego
powodu doszło do powstania błędu. Są trzy możliwości. Po pierwsze strona może fizycznie być
przechowywana w pamięci, ale nie ma jej w tablicy stron procesu. Strona mogła być np. zała-
dowana do pamięci z dysku przez inny proces. W tym przypadku nie ma potrzeby, by sięgać do
dysku ponownie. Wystarczy odpowiednio zmapować stronę w tablicy stron. Jest to stosunkowo
miękkie chybienie, znane jako drobny błąd strony (ang. minor page fault). Po drugie może się
zdarzyć poważny błąd strony (ang. major page fault) — gdy zachodzi konieczność sprowadzenia
strony z dysku. Po trzecie istnieje możliwość, że w programie nastąpiła próba dostępu do niepra-
widłowego adresu i w ogóle nie ma potrzeby dodawania mapowania do bufora TLB. Wówczas
system operacyjny zazwyczaj zabija program z powodu błędu segmentacji (ang. segmentation
fault). Tylko w tym przypadku program wykonał nieprawidłową operację. Wszystkie pozostałe
błędy są automatycznie naprawiane przez sprzęt i (lub) system operacyjny — kosztem wydajności.
Rysunek 3.12. (a) Adres 32-bitowy z dwoma polami tablicy stron; (b) dwupoziomowe tablice stron
Dla przykładu rozważmy 32-bitowy adres wirtualny 0×00403004 (dziesiętnie 4 206 596),
który występuje przesunięty o 12 292 bajtów na stronie danych. W tym adresie wirtualnym
TS 1 = 1, TS 2 = 2, a Przesunięcie = 4. Jednostka MMU najpierw wykorzystuje fragment TS1
w celu zaindeksowania tabeli stron najwyższego poziomu i uzyskuje tam pozycję 1 odpowia-
dającą adresom od 4 MB do 8 MB. Następnie wykorzystuje TS2 w celu zaindeksowania znale-
zionej przed chwilą tabeli stron drugiego poziomu i wyodrębnia pozycję 3 odpowiadającą adre-
som od 12288 do 16383 we fragmencie 4 MB (tzn. adresom bezwzględnym od 4 206 592 do
4 210 687). Ta pozycja zawiera numer ramki strony z adresem wirtualnym 0×00403004. Jeśli
tej strony nie ma w pamięci, to bit Obecna/nieobecna na odpowiedniej pozycji w tabeli stron ma
wartość 0, co powoduje błąd braku strony. Jeśli strona jest w pamięci, to numer ramki strony
pobrany z tabeli stron drugiego poziomu jest łączony z przesunięciem i (4) tworzy adres fizyczny.
Ten adres jest umieszczany na magistrali i przesyłany do pamięci.
Interesujące w sytuacji przedstawionej na rysunku 3.12 jest to, że chociaż przestrzeń adre-
sowa zawiera ponad milion stron, to są potrzebne tylko cztery tabele stron: tabela najwyższego
poziomu oraz tabele drugiego poziomu od 0 do 4 MB (dla tekstu programu), 4 MB do 8 MB
(dla danych) oraz najwyższe 4 MB (dla stosu). Bity Obecna/nieobecna na 1021 pozycjach tabeli
stron najwyższego poziomu są ustawione na 0, co powoduje błąd braku strony w przypadku
próby dostępu. Jeśli coś takiego się zdarzy, system operacyjny zauważy, że proces próbuje
odwoływać się do pamięci, do której nie powinien się odwoływać, i podejmie stosowne działa-
nia — np. wyśle sygnał lub zniszczy proces. W tym przykładzie wybraliśmy okrągłe liczby dla
różnych rozmiarów oraz wartość TS1 równą TS2, ale w rzeczywistości oczywiście są możliwe
także inne liczby.
Dwupoziomowy system tabeli stron z rysunku 3.12 można rozwinąć do trzech, czterech
lub większej liczby poziomów. Dodatkowe poziomy dają większą elastyczność. Przykładowo
32-bitowy procesor Intel 80386 (pojawił się na rynku w 1985 roku) był w stanie zaadresować do
4 GB. Do tego celu wykorzystywał dwupoziomową tabelę stron. Składała się ona z katalogu
stron. Pozycje tego katalogu wskazywały na tablice stron, a te na właściwe ramki stron o rozmiarze
4 kB. Zarówno katalog stron, jak i tablice stron zawierały po 1024 pozycje, co daje łącznie
210×210×212 = 232 adresowalnych bajtów.
Dziesięć lat później w procesorze Pentium Pro wprowadzono kolejny poziom: tablicę wskaź-
ników katalogu stron (ang. page directory pointer table). Ponadto rozszerzono każdy wpis na
wszystkich poziomach hierarchii tablicy stron z 32 do 64 bitów. Dzięki temu stało się możliwe
zaadresowanie pamięci powyżej granicy 4 GB. Ponieważ tabela wskaźników katalogu stron
zawierała tylko 4 pozycje, 512 w każdym katalogu stron i 512 w każdej tablicy stron, całkowita
ilość pamięci możliwa do zaadresowania nadal była ograniczona do maksymalnie 4 GB. Gdy do
rodziny x86 dodano właściwe wsparcie adresacji 64-bitowej (pierwotnie przez AMD), dodat-
kowy poziom mógł być nazwany „wskaźnikiem tabeli wskaźników katalogów stron” lub w podobnie
okropny sposób. Pozostawałoby to w idealnej zgodzie z konwencjami nazewnictwa stosowa-
nymi przez producentów układów. Na szczęście zrezygnowano z takiej nazwy. Alternatywą
było powstanie terminu mapa strony poziomu 4 (ang. page map level 4). Ta nazwa być może rów-
nież nie jest zbyt chwytliwa, ale jest przynajmniej krótka i nieco bardziej zrozumiała. W każ-
dym razie te procesory korzystają teraz ze wszystkich 512 wpisów we wszystkich tabelach, co
pozwala na zaadresowanie 29×29×29×29×212 = 248 bajtów. Mógłby powstać kolejny poziom,
ale prawdopodobnie założono, że 256 TB wystarczy na jakiś czas.
Sposobem na rozwiązanie tego dylematu jest wykorzystanie bufora TLB. Jeśli w buforze
TLB można zapisać wszystkie często wykorzystywane strony, przekształcenie może być wyko-
nywane równie szybko, jak w przypadku standardowych tabel stron. Jednak w przypadku chybio-
nego odwołania do bufora TLB trzeba programowo przeszukiwać odwróconą tabelę stron. Jed-
nym z możliwych sposobów realizacji tego wyszukiwania jest utrzymywanie tablicy skrótów
(ang. hash table) uporządkowanej według skrótów adresów wirtualnych. Wszystkie wirtualne
strony znajdujące się aktualnie w pamięci o tym samym skrócie są łączone ze sobą w łańcuch
w sposób pokazany na rysunku 3.13. Jeśli tablica skrótów będzie miała tyle samo miejsc, ile fizycz-
nych stron ma komputer, to średni łańcuch będzie miał długość tylko jednej pozycji, co znacznie
przyspieszy odwzorowywanie. Po znalezieniu numeru ramki strony do bufora TLB jest wprowa-
dzana nowa para (adres wirtualny, adres fizyczny).
Kiedy wystąpi błąd braku strony, system operacyjny musi wybrać stronę do wysłania na dysk
(usunięcia z pamięci) po to, by zrobić miejsce na stronę wchodzącą. Jeśli strona przeznaczona
do usunięcia została zmodyfikowana w czasie, gdy była przechowywana w pamięci, trzeba ją
ponownie zapisać na dysk, tak aby kopia przechowywana na dysku była aktualna. Jeśli jednak
strona nie była modyfikowana (np. gdy zawiera tekst programu), to kopia na dysku jest już
aktualna, zatem nie ma potrzeby jej ponownego zapisywania. Strona wczytywana z dysku po
prostu nadpisuje stronę usuwaną z pamięci.
Chociaż istnieje możliwość wybrania losowej strony do usunięcia z pamięci przy każdym
błędzie braku strony, wydajność systemu będzie znacznie lepsza, jeśli zostanie wybrana strona,
która nie jest zbyt często wykorzystywana. W przypadku usunięcia często wykorzystywanej
strony istnieje prawdopodobieństwo, że okaże się ona potrzebna w niedalekiej przyszłości, co
Dzięki temu można porównać wydajność wykonalnego algorytmu z najlepszym możliwym. Jeśli
system operacyjny osiągnie wydajność, np. 1% gorszą od wydajności algorytmu optymalnego, wysi-
łek poświęcony na szukanie lepszego algorytmu przyniesie co najwyżej jednoprocentową poprawę.
W celu uniknięcia ewentualnych niejasności należy wyraźnie stwierdzić, że rejestr odwo-
łań do stron odnosi się tylko do jednego programu, dla którego wykonano pomiar — i to tylko
dla jednego, konkretnego zestawu danych wejściowych. W związku z tym algorytm zastępo-
wania stron zaimplementowany tą metodą dotyczy tylko tego jednego programu i jednego kon-
kretnego zestawu danych wejściowych. Chociaż taka metoda jest przydatna do oceny algorytmów
zastępowania stron, nie ma zastosowania w praktycznych systemach. Poniżej opiszemy algorytmy,
które są przydatne w praktycznych systemach.
Algorytm NRU (od ang. Not Recently Used) usuwa losowo stronę z niepustej klasy o jak
najniższym numerze. W tym algorytmie obowiązuje niejawna zasada, zgodnie z którą lepiej
jest usunąć zmodyfikowaną stronę, do której nie było odwołań co najmniej w ciągu ostatniego
taktu zegara (zazwyczaj około 20 ms), niż usunąć stronę, która jest wykorzystywana często.
Głównymi zaletami algorytmu NRU jest to, że jest ona łatwa do zrozumienia, umiarkowanie
wydajna do zaimplementowania oraz gwarantuje wydajność, która choć nie jest optymalna, to
można ją zaakceptować.
Rysunek 3.14. Działanie algorytmu drugiej szansy: (a) strony posortowane w porządku FIFO;
(b) lista stron w przypadku, gdy błąd braku strony zajdzie w momencie 20, a strona A będzie miała
ustawiony bit R; liczby powyżej stron oznaczają ich czasy ładowania
wyzerowany, strona ta zostanie usunięta z pamięci. Może to się odbyć poprzez zapisanie jej na
dysk (jeśli jest „zabrudzona”) lub porzucenie (jeśli jest „czysta”). Z drugiej strony, jeśli bit R jest
ustawiony, strona A zostanie umieszczona na końcu listy, a jej „czas załadowania” zostanie zre-
setowany do chwili bieżącej (20). Bit R również zostanie wyzerowany. Wyszukiwanie odpowied-
niej strony będzie kontynuowane dla strony B.
W algorytmie drugiej szansy wyszukiwana jest stara strona, do której nie było odwołania
w ostatnim przedziale czasowym. Jeśli do wszystkich stron były odwołania, algorytm drugiej
szansy degeneruje się do klasycznego algorytmu FIFO. W szczególności wyobraźmy sobie, że
wszystkie strony z rysunku 3.15(a) mają ustawione bity R. System operacyjny będzie po kolei
przenosił strony na koniec listy, zerując bit R za każdym razem, kiedy strona zostanie dodana
na koniec listy. Ostatecznie powróci do strony A, która teraz ma wyzerowany bit R. W tym
momencie strona A zostaje usunięta z pamięci. Tak więc algorytm zawsze się zakończy.
Kiedy nastąpi błąd braku strony, analizowana jest strona pokazywana przez wskazówkę.
Jeśli jej bit R jest ustawiony na 0, strona jest usuwana z pamięci, a nowa strona jest wstawiana do
zegara na jej miejsce, natomiast wskazówka przesuwa się o jedną pozycję. Jeśli bit R ma war-
tość 1, jest zerowany, a wskazówka jest przesuwana do następnej strony. Proces powtarza się
do momentu znalezienia strony, dla której bit R = 0. Nic dziwnego, że ten algorytm nosi nazwę
algorytmu zegarowego.
Rysunek 3.16. Algorytm postarzania programowo symuluje algorytm LRU; pokazano sześć stron
dla pięciu taktów zegara; pięć taktów zegara jest reprezentowanych przez ilustracje od (a) do (e)
Kiedy wystąpi błąd braku strony, z pamięci jest usuwana strona, której licznik ma naj-
mniejszą wartość. To oczywiste, że licznik strony, do której nie było odwołań np. w ciągu czte-
rech taktów zegara, będzie miał cztery wiodące zera, a zatem będzie miał niższą wartość od
licznika strony, do której nie było odwołań przez trzy takty zegara.
Ten algorytm różni się od algorytmu LRU pod dwoma względami. Rozważmy strony 3 i 5
na rysunku 3.16(e). Do żadnej z nich nie było odwołań w ciągu ostatnich dwóch taktów zegara.
Do obydwu były odwołania jeden takt wcześniej. W przypadku konieczności zastąpienia strony,
zgodnie z algorytmem LRU, należałoby wybrać jedną z tych dwóch stron. Problem polega na
tym, że nie wiemy, do której z nich było ostatnie odwołanie w przedziale pomiędzy pierwszym
a drugim taktem zegara. Z powodu rejestrowania tylko jednego bitu w przedziale czasu straci-
liśmy zdolność rozróżniania odwołań, które zachodziły w przedziale zegarowym wcześniej, od
tych, które wystąpiły później. Możemy usunąć stronę 3, ponieważ do strony 5 było odwołanie dwa
takty wcześniej, a do strony 3 nie było odwołania.
Druga różnica pomiędzy algorytmem LRU a algorytmem postarzania polega na tym, że w tym
drugim liczniki mają skończoną liczbę bitów (w naszym przykładzie 8), co ogranicza możliwości
analizy w przeszłości. Przypuśćmy, że każda z dwóch stron ma wartość licznika 0. Jedyne, co
można zrobić, to losowo wybrać jedną z nich. W praktyce może być tak, że do jednej ze stron
było odwołanie dziewięć taktów wcześniej, a do drugiej 1000 taktów wcześniej. Nie ma sposobu, by
to stwierdzić. Jednak w praktyce, jeśli takt zegara następuje co 20 ms, zazwyczaj 8 bitów wystarcza.
Jeśli do strony nie było odwołań w ciągu 160 ms, to prawdopodobnie nie jest ona zbyt ważna.
Od dawna wiadomo, że większość programów nie odwołuje się równomiernie do całej swojej
przestrzeni adresowej, ale że odwołania koncentrują się na niewielkiej liczbie stron. Odwołanie
do pamięci może być związane z pobraniem instrukcji, danych lub zapisaniem danych. W każdym
momencie czasu t istnieje zbiór składający się z wszystkich stron wykorzystywanych w ostat-
nich k odwołaniach do pamięci. Ten zbiór w(k, t) to zbiór roboczy. Ponieważ w k = l ostatnich
odwołań musiały być używane wszystkie strony użyte w k > l ostatnich odwołań oraz ewentu-
alnie inne, to w(k, t) jest monotoniczną i niemalejącą funkcją k. Granica funkcji w(k, t) przy
wzrastającym k jest skończona, ponieważ program nie może odwoływać się do większej liczby
stron, niż zawiera jego przestrzeń adresowa, a niewiele programów używa wszystkich stron.
Wykres rozmiaru zbioru roboczego w funkcji k pokazano na rysunku 3.17.
Fakt, że większość programów losowo korzysta z niewielkiej liczby stron — przy czym ten
zbiór powoli zmienia się w czasie — wyjaśnia początkowy gwałtowny wzrost wartości funkcji,
a następnie wolniejszy dla większych wartości k. I tak program, w którym wykonuje się pętla
zajmująca dwie strony i wykorzystująca dane z czterech stron, może odwoływać się do wszyst-
kich sześciu stron co 1000 instrukcji, ale ostatnie odwołanie do innej strony mogło mieć miej-
sce milion instrukcji wcześniej, podczas fazy inicjalizacji. Ze względu na ten asymptotyczny
charakter zawartość zbioru roboczego nie jest wrażliwa na wybraną wartość k. Mówiąc inaczej,
istnieje szeroki zakres wartości k, dla których zbiór roboczy pozostaje niezmienny. Ponieważ
zbiór roboczy wolno zmienia się w czasie, istnieje możliwość racjonalnego odgadnięcia tego, jakie
strony będą potrzebne przy wznowieniu programu, na podstawie zawartości zbioru roboczego
w momencie jego ostatniego zatrzymania. Stronicowanie wstępne obejmuje załadowanie tych
stron przed wznowieniem procesu.
Rysunek 3.17. Zbiór roboczy to zbiór stron używanych w k ostatnich odwołaniach do pamięci.
Wartością funkcji w(k, t) jest rozmiar zbioru roboczego w czasie t
Aby system operacyjny mógł zaimplementować model zbioru roboczego, musi mieć możli-
wość śledzenia stron znajdujących się w zbiorze roboczym. Posiadanie tych informacji w bezpo-
średni sposób prowadzi do możliwego algorytmu zastępowania stron: kiedy wystąpi błąd braku
strony, należy znaleźć stronę, której nie ma w zbiorze roboczym i usunąć ją z pamięci. Aby można
było zaimplementować taki algorytm, potrzebny jest dokładny sposób stwierdzenia, czy strona
znajduje się w zbiorze roboczym. Z definicji wynika, że zbiór roboczy jest grupą stron używa-
nych w k ostatnich odwołaniach do pamięci (niektórzy autorzy mówią o k ostatnich odwołaniach
do stron — można przyjąć dowolne określenie). Aby można było zaimplementować dowolny
algorytm bazujący na zbiorze roboczym, należy z góry wybrać pewną wartość k. Po wybraniu
pewnej wartości, po każdym odwołaniu do pamięci można w unikatowy sposób wyznaczyć zbiór
stron używanych w k ostatnich odwołaniach do pamięci.
Istnienie formalnej definicji zbioru roboczego oczywiście nie oznacza, że można znaleźć
skuteczny sposób wyznaczania go podczas wykonywania programu. Można by sobie wyobrazić
rejestr przesuwny o długości k. Każde odwołanie do pamięci powodowałoby przesunięcie reje-
stru w lewo o jedną pozycję i wstawienie z prawej strony numeru strony, do której było ostatnie
odwołanie. Zbiorem roboczym byłaby grupa wszystkich k numerów stron w rejestrze prze-
suwnym. Teoretycznie w momencie wystąpienia błędu braku strony można by odczytać zawar-
tość rejestru przesuwnego i ją posortować. Po wykonaniu sortowania można by usunąć duplikaty.
W wyniku otrzymalibyśmy zbiór roboczy. Utrzymywanie rejestru przesuwnego i przetwarzanie
go w momencie wystąpienia błędu braku strony byłoby jednak bardzo kosztowne, dlatego techniki
tej się nie wykorzystuje.
Zamiast niej stosuje się różne przybliżenia. Powszechnie używanym przybliżeniem jest porzu-
cenie idei zliczania k odwołań do pamięci i wykorzystanie zamiast tego czasu wykonania. Zamiast
np. definiować zbiór roboczy jako strony używane podczas poprzednich 10 milionów odwołań
do pamięci, można go zdefiniować jako zbiór stron używanych podczas ostatnich 100 ms działania
programu. W praktyce taka definicja jest równie dobra i o wiele łatwiejsza do wykorzystania.
Należy zwrócić uwagę, że dla każdego procesu liczy się tylko jego własny czas wykonania. Tak
więc, jeśli proces rozpocznie działanie w czasie T i otrzyma 40 ms czasu procesora, to w czasie
rzeczywistym T+100 ms dla potrzeb wyznaczania zbioru roboczego jego czas wynosi 40 ms.
Czas procesora zużyty przez proces od momentu jego uruchomienia często jest nazywany jego
bieżącym czasem wirtualnym. Przy takim przybliżeniu zbiór roboczy procesu oznacza zestaw
stron, do których proces się odwoływał w czasie ostatnich τ sekund czasu wirtualnego.
Przyjrzyjmy się teraz algorytmowi zastępowania stron bazującemu na zbiorze roboczym.
Podstawowa idea polega na znalezieniu strony, której nie ma w zbiorze roboczym, i usunięciu jej
z pamięci. Na rysunku 3.18 pokazano fragment tabeli stron dla pewnego komputera. Ponieważ
tylko te strony, które znajdują się w pamięci, są uznawane za kandydatki do usunięcia, strony
znajdujące się poza pamięcią są w tym algorytmie ignorowane. Każda pozycja zawiera (co naj-
mniej) dwie kluczowe informacje: przybliżony czas od ostatniego użycia strony oraz bit R (od
ang. Referenced). Pusty biały prostokąt symbolizuje inne pola, które nie są potrzebne w tym algo-
rytmie, np. numer ramki strony, bity zabezpieczeń oraz bit M (od ang. Modified).
Zasada działania algorytmu jest następująca: zakłada się, że bity R i M są ustawiane przez
sprzęt, tak jak napisano wcześniej. Na podobnej zasadzie zakłada się, że przerwanie zegara
powoduje uruchomienie oprogramowania, które zeruje bit R przy każdym takcie zegara. Przy
każdym wystąpieniu błędu braku strony następuje skanowanie tabeli stron w celu znalezienia
odpowiedniej strony do usunięcia z pamięci.
Podczas przetwarzania każdej pozycji badany jest bit R. Jeśli ma on wartość 1, bieżący czas
wirtualny jest zapisywany do pola Czas ostatniego użycia w tabeli stron, co wskazuje na to, że strona
była używana w momencie wystąpienia błędu. Ponieważ do strony było odwołanie podczas bie-
żącego zegara strony, oczywiście jest ona w zbiorze roboczym i nie jest kandydatką do usunięcia
(zakłada się, że τ obejmuje wiele taktów zegara).
Jeśli R ma wartość 0, oznacza to, że do strony nie było odwołania podczas bieżącego taktu
zegara, w związku z czym może ona być kandydatką do usunięcia. W celu stwierdzenia, czy należy
ją usunąć, czy nie, wyliczany jest jej wiek (bieżący czas wirtualny pomniejszony o Czas ostat-
niego użycia) i porównywany z wartością τ. Jeśli wiek jest większy niż τ, strona nie należy już do
zbioru roboczego i zostaje zastąpiona przez nową stronę. Proces skanowania jest kontynuowany
w celu aktualizacji pozostałych pozycji.
Jeśli jednak R wynosi 0, ale wiek jest mniejszy bądź równy τ, strona w dalszym ciągu należy
do zbioru roboczego. Strona chwilowo pozostaje w pamięci, ale system zapamiętuje stronę
o największej wartości wieku (najmniejszej wartości Czasu ostatniego użycia). Jeśli po przeska-
nowaniu całej tabeli nie zostanie znaleziona kandydatka do usunięcia z pamięci, oznacza to, że
wszystkie strony są w zbiorze roboczym. W takim przypadku, jeśli znaleziono jedną lub więcej
stron, dla których R wynosi 0, usuwana jest strona o największej wartości wieku. W najgorszym
wypadku podczas ostatniego taktu zegara odwoływano się do wszystkich stron (a zatem dla
wszystkich R = 1). W związku z tym do usunięcia wybierana jest losowa strona, najlepiej jeśli
jest czysta.
Rysunek 3.19. W części (a) i (b) podano przykład tego, co się dzieje, gdy R = 1; w części (c) i (d)
pokazano przykład dla R = 0
Zastanówmy się teraz, co się stanie, jeśli wskazywana strona ma bit R ustawiony na 0, tak
jak pokazano na rysunku 3.19(c). Jeśli jej wiek jest większy niż τ, a strona jest czysta, strony
nie ma w zbiorze roboczym, a aktualna kopia znajduje się na dysku. System żąda ramki strony
i nowa strona jest umieszczana w tym miejscu, tak jak pokazano na rysunku 3.19(d). Z kolei
jeśli strona jest zabrudzona, nie można jej bezpośrednio zażądać, ponieważ na dysku nie ma
aktualnej kopii. W celu uniknięcia przełączania procesu system planuje zapis na dysk, ale wska-
zówka jest przesuwana do następnej strony i dla niej jest realizowany algorytm. Na dalszej pozy-
cji może być przecież stara czysta strona, którą można wykorzystać natychmiast.
Zgodnie z tą zasadą w jednym cyklu wokół zegara dla wszystkich stron mogą być zaplano-
wane dyskowe operacje wejścia-wyjścia. W celu ograniczenia ruchu z dyskiem można ustanowić
limit — tzn. zezwolić na ponowny zapis na dysk tylko n stron. Po osiągnięciu tego limitu nie są
planowane nowe operacje zapisu.
Co się dzieje, kiedy wskazówka przejdzie wokół tarczy i dotrze do punktu wyjścia?
Algorytm FIFO śledzi kolejność stron, w jakiej były one ładowane do pamięci, poprzez przecho-
wywanie ich na liście jednokierunkowej. Usunięcie najstarszej strony jest trywialne, ale ta strona
może być w dalszym ciągu używana, zatem wybranie algorytmu FIFO okazuje się nie najlepsze.
Algorytm drugiej szansy to modyfikacja algorytmu FIFO. Polega ona na tym, że przed usu-
nięciem strony następuje sprawdzenie, czy strona jest w użyciu. Jeśli nie, to strona nie jest
usuwana. Taka modyfikacja bardzo poprawia wydajność. Algorytm zegarowy stanowi po prostu
inną implementację algorytmu drugiej szansy. Ma takie same właściwości wydajnościowe, ale
wykonanie go zajmuje nieco mniej czasu.
LRU to doskonały algorytm, ale nie można go zaimplementować bez specjalnego sprzętu.
Jeśli ten sprzęt nie jest dostępny, algorytm nie może być używany. NFU to próba przybliżenia
algorytmu LRU. Nie jest zbyt dobry. Algorytm postarzania okazuje się jednak znacznie lepszym
przybliżeniem algorytmu LRU i można go wydajnie zaimplementować. To dobry wybór.
Dwa ostatnie algorytmy wykorzystują zbiór roboczy. Algorytm bazujący na zbiorze roboczym
zapewnia sensowną wydajność, ale jest dość kosztowny do zaimplementowania. WSClock sta-
nowi odmianę tego algorytmu, która nie tylko zapewnia dobrą wydajność, ale także jest łatwa
do zaimplementowania.
Podsumujmy: dwa najlepsze algorytmy to postarzanie i WSClock. Bazują one odpowiednio
na algorytmie LRU i zbiorze roboczym. Oba zapewniają dobrą wydajność stronicowania i można
je skutecznie zaimplementować. Istnieje kilka innych algorytmów, ale wymienione dwa mają
największe zastosowanie praktyczne.
Rysunek 3.20. Lokalny a globalny algorytm zastępowania stron: (a) oryginalna konfiguracja;
(b) lokalny algorytm zastępowania stron; (c) globalny algorytm zastępowania stron
zgodnie ze wskazaniami bitów starzenia, ale takie podejście nie zawsze jest w stanie przeciw-
działać przeładowaniu. Zbiór roboczy może zmienić rozmiar w ciągu kilku mikrosekund, nato-
miast bity starzenia są niedokładną metryką rozproszoną pomiędzy wieloma taktami zegara.
Inne podejście polega na zastosowaniu algorytmu alokacji ramek do procesów. Jednym ze
sposobów jest okresowe wyznaczenie liczby uruchomionych procesów i przydzielenie każdemu
procesowi równego przydziału. Tak więc przy 12 416 dostępnych ramkach stron (tzn. nienale-
żących do systemu operacyjnego) i 10 procesach każdy proces otrzymuje 1241 ramek. Pozo-
stałe sześć jest przekazywanych do puli wykorzystywanej w momencie wystąpienia błędu braku
strony.
Chociaż ta metoda wydaje się sprawiedliwa, przydzielanie równych części pamięci dla procesu
o rozmiarze 10 kB i 300 kB nie ma wielkiego sensu. Zamiast tego strony można przydzielać pro-
porcjonalnie do całkowitego rozmiaru każdego procesu. Zgodnie z tą zasadą 300-kilobajtowy
proces powinien otrzymać 30 razy więcej pamięci od procesu 10-kilobajtowego. Wydaje się roz-
sądne, aby każdy proces otrzymał pewną minimalną ilość pamięci, tak aby mógł działać nawet
wtedy, gdy jego rozmiar jest bardzo mały. W niektórych komputerach np. prosta instrukcja
składająca się z dwóch operandów może wymagać sześciu stron, ponieważ sama instrukcja —
operand źródłowy i operand docelowy — może przekraczać rozmiar jednej strony. W przypadku
przydzielenia tylko pięciu stron programy zawierające takie instrukcje w ogóle nie mogą działać.
W przypadku użycia globalnego algorytmu można uruchomić procesy z przydzieloną pewną
liczbą stron, proporcjonalną do rozmiaru procesu, ale przydział ten musi być aktualizowany
dynamicznie podczas działania procesu. Jednym ze sposobów zarządzania alokacją jest zastoso-
wanie algorytmu częstości błędów braku stron PFF (od ang. Page Fault Frequency). Algorytm
ten informuje o tym, czy należy zwiększyć czy zmniejszyć alokację stron do procesu, ale nie
mówi niczego o tym, którą stronę należy zastąpić w przypadku wystąpienia błędu braku strony.
Algorytm ten kontroluje tylko rozmiar zbioru alokacji.
W przypadku dużej liczby algorytmów zastępowania stron, w tym algorytmu LRU, wiadomo,
że w miarę przydzielania większej liczby stron współczynnik błędów zmniejsza się. Takie wła-
śnie założenie obowiązuje w algorytmie PFF. Tę właściwość zilustrowano na rysunku 3.21.
Pomiar współczynnika błędów braku stron jest prosty: wystarczy policzyć liczbę błędów braku
strony w ciągu sekundy, z uwzględnieniem również średniej liczby błędów w ciągu ostatnich sekund.
Rysunek 3.21. Współczynnik błędów braku strony jako funkcja liczby przydzielonych ramek stron
Łatwym sposobem na wyliczenie tej wartości jest dodanie liczby błędów braku stron, które
wystąpiły w ciągu poprzedniej sekundy, do wartości z bieżącej sekundy i podzielenie tej sumy
przez dwa. Linia przerywana oznaczona jako A odpowiada współczynnikowi błędów braku
stron, który jest zbyt wysoki. A zatem proces, w którym wystąpił błąd, otrzyma więcej ra-
mek stron w celu zmniejszenia wartości współczynnika błędów. Linia przerywana oznaczo-
na jako B odpowiada współczynnikowi błędów braku stron o tak małej wysokości, że można
założyć, iż proces ma zbyt dużo pamięci. W takim przypadku można odebrać procesowi pewną
część ramek stron. Tak więc celem stosowania algorytmu PFF jest utrzymanie współczynnika
stronicowania dla każdego procesu w akceptowalnych granicach.
Należy zwrócić uwagę na to, że niektóre algorytmy zastępowania stron mogą wykorzysty-
wać lokalną lub globalną strategię zastępowania. Przykładowo algorytm FIFO może zastępo-
wać najstarszą stronę w całej pamięci (algorytm globalny) lub najstarszą stronę należącą do
bieżącego procesu (algorytm lokalny). Na podobnej zasadzie algorytm LRU lub jego przybliże-
nie może zastępować ostatnio używaną stronę w całej pamięci (algorytm globalny) lub ostatnio
używaną stronę należącą do bieżącego procesu (algorytm lokalny). Wybór algorytmu lokalnego
bądź globalnego w niektórych przypadkach nie zależy od typu algorytmu.
Z drugiej strony istnieją algorytmy zastępowania stron, dla których tylko lokalna strategia
ma sens. W szczególności algorytmy bazujące na zbiorze roboczym oraz algorytm WSClock
odnoszą się do specyficznego procesu i muszą być stosowane w tym kontekście. Nie istnieje
coś takiego, jak zbiór roboczy dla maszyny jako całości, a próba wykorzystania unii wszystkich
zbiorów roboczych doprowadziłaby do utraty własności lokalności i nie działałaby prawidłowo.
może działać w ten sposób przez jakiś czas. Jeśli nie minie, inny proces może być przeniesiony
na dysk — i tak dalej, aż do chwili, kiedy stan przeładowania się zakończy. Tak więc nawet
przy zastosowaniu stronicowania w dalszym ciągu jest potrzebna wymiana z dyskiem. Różnica
polega na tym, że wymiana jest używana w celu zmniejszenia potencjalnych wymagań pamię-
ciowych, a nie do żądania brakujących stron.
Wymiana procesów z dyskiem w celu zmniejszenia obciążenia pamięci przypomina szere-
gowanie dwupoziomowe, gdzie niektóre procesy są umieszczane na dysku, a do szeregowania
pozostałych procesów jest wykorzystywany krótkoterminowy program szeregujący. Te dwie kon-
cepcje można ze sobą połączyć i wymienić z dyskiem wystarczającą liczbę procesów do tego,
aby współczynnik liczby błędów braku stron osiągnął akceptowalną wysokość. Okresowo
pewne procesy są ładowane z dysku, podczas gdy inne są usuwane z pamięci na dysk.
Innym czynnikiem do rozważenia jest stopień wieloprogramowości. Kiedy liczba procesów
w pamięci głównej jest zbyt niska, procesor może być bezczynny przez znaczący okres. W związku
z tym przy podejmowaniu decyzji o tym, który z procesów usunąć na dysk, należy wziąć pod
uwagę nie tylko rozmiar procesu i współczynnik stronicowania, ale także charakterystykę pro-
cesu — np. czy jest on zorientowany na obliczenia, czy na operacje wejścia-wyjścia — oraz
charakterystykę pozostałych procesów.
Ponadto niewielkie strony zajmują znaczącą część cennego miejsca w buforze TLB. Przy-
puśćmy, że program używa 1 MB pamięci, a zestaw roboczy ma rozmiar 64 kB. Przy stronach
o rozmiarze 4 kB program zajmie co najmniej 16 pozycji w buforze TLB. Przy stronach o roz-
miarze 2 MB wystarczyłaby jedna pozycja w buforze TLB (w teorii może się okazać, że chcemy
oddzielić dane od instrukcji). Ponieważ istnieje ograniczona liczba pozycji w buforze TLB i mają
one kluczowe znaczenie dla wydajności, to jeśli jest taka możliwość, opłaca się używać dużych
stron. Aby zrównoważyć wszystkie te kompromisy, systemy operacyjne czasami używają różnych
rozmiarów stron dla różnych części systemu, np. jądro korzysta ze stron o dużym rozmiarze,
natomiast na potrzeby procesów użytkownika stosowane są mniejsze strony.
W niektórych komputerach tabela stron musi zostać załadowana do rejestrów sprzętowych
za każdym razem, kiedy procesor przełączy się z jednego procesu do innego. Posługiwanie się
stronami o małym rozmiarze w tych maszynach oznacza, że czas potrzebny do załadowania
rejestrów stron wydłuża się w miarę zmniejszania się rozmiaru strony. Co więcej, miejsce zaj-
mowane przez tabelę stron zwiększa się, w miarę jak rozmiar strony maleje.
Ostatnie stwierdzenie można przeanalizować matematycznie. Niech średni rozmiar procesu
wynosi s bajtów, a średni rozmiar strony wynosi p bajtów. Dodatkowo załóżmy, że każdy wpis
dotyczący strony w tabeli stron wymaga e bajtów. Liczba stron wymaganych przez proces wynosi
przeciętnie s/p, a zatem strony wymagają se/p bajtów w tabeli stron. Zmarnowana pamięć na
ostatniej stronie procesu z powodu wewnętrznej fragmentacji wynosi p/2. Tak więc całkowite
koszty spowodowane stratami w tabeli stron oraz wewnętrzną fragmentacją można wyznaczyć
jako sumę poniższych dwóch wyrazów:
koszty = se/p+p/2
Pierwszy wyraz (rozmiar tabeli stron) ma dużą wartość, gdy rozmiar strony jest mały.
Drugi wyraz (wewnętrzna fragmentacja) jest duży, kiedy rozmiar strony jest mały. Wartość
optymalna musi znajdować się gdzieś pośrodku. Po obliczeniu pierwszej pochodnej względem
p i przyrównaniu jej do zera otrzymujemy równanie:
−se/p2+1/2 = 0
Z powyższego równania można wyprowadzić wzór na optymalny rozmiar strony (uwzględniający
tylko tę pamięć, która została stracona z powodu fragmentacji oraz rozmiaru tabeli stron). Oto
postać tego wzoru:
p 2 se
Dla s = 1 MB i e = 8 bajtów na pozycję w tabeli stron optymalny rozmiar strony wynosi 4 kB.
Komputery dostępne na rynku wykorzystują rozmiary stron od 512 bajtów do 64 kB. Dawniej
typową wartością był 1 kB, ale ostatnio częściej używa się stron o rozmiarach 4 B lub 8 kB.
Rysunek 3.22. (a) Pojedyncza przestrzeń adresowa; (b) osobne przestrzenie I oraz D
Rysunek 3.23. Dwa procesy tego samego programu współdzielą tabelę stron
Kiedy dwa procesy (lub większa ich liczba) współdzielą kod, występuje pewien problem ze
współdzielonymi stronami. Przypuśćmy, że oba procesy A oraz B wykonują edytor i współ-
dzielą swoje strony. Jeśli program szeregujący zdecyduje o usunięciu procesu A z pamięci, co
wiąże się z usunięciem z pamięci wszystkich jego stron i wypełnieniem pustych ramek stron
innym programem, spowoduje to, że proces B wygeneruje wiele błędów braku stron i strony te
będą musiały być ponownie załadowane do pamięci.
Na podobnej zasadzie — kiedy proces A zakończy działanie, ważne jest, aby system potrafił
wykryć, że strony są w dalszym ciągu w użyciu, i by ich przestrzeń dyskowa nie została przy-
padkowo zwolniona. Przeszukiwanie wszystkich tabel stron w celu stwierdzenia, czy strona
jest współdzielona, okazuje się zwykle zbyt kosztowne. W związku z tym potrzebne są specjalne
struktury danych do śledzenia współdzielonych stron, zwłaszcza jeśli współdzieloną jednostką
jest pojedyncza strona (lub ciąg stron), a nie cała tabela stron.
Współdzielenie danych okazuje się trudniejsze od współdzielenia kodu, ale nie jest niemożliwe.
W szczególności w systemie UNIX, po wykonaniu wywołania systemowego fork, potrzebny
jest proces-rodzic i proces-dziecko w celu współdzielenia zarówno tekstu programu, jak i danych.
W systemie ze stronicowaniem często przydziela się każdemu z tych procesów własną tabelę
stron, przy czym obie wskazują na ten sam zbiór stron. W związku z tym w momencie wyko-
nywania wywołania fork nie są kopiowane żadne strony. Wszystkie strony danych zostają jed-
nak oznaczone w obu procesach jako TYLKO DO ODCZYTU.
Sytuacja ta może trwać tak długo, jak długo oba procesy tylko czytają dane — bez ich modyfi-
kowania. Kiedy dowolny z procesów zaktualizuje słowo pamięci, naruszenie zasady tylko do
odczytu stanie się przyczyną rozkazu pułapki do systemu operacyjnego. W tym momencie jest
wykonywana kopia strony powodującej konflikt i od tego momentu każdy proces dysponuje
własną, prywatną kopią. Obie kopie mają teraz ustawiony tryb ODCZYT-ZAPIS, zatem kolejne
operacje zapisu w dowolnej kopii mogą być wykonywane bez przeszkód. Taka strategia oznacza,
że te strony, które nigdy nie są modyfikowane (w tym wszystkie strony z kodem programu),
nie muszą być kopiowane. Kopiowane muszą być tylko te strony danych, które są faktycznie
modyfikowane. Takie podejście, określane jako kopiowanie przy zapisie, poprawia wydajność,
ponieważ zmniejsza liczbę potrzebnych operacji kopiowania.
które łączy wszystkie pliki .o (obiektowe) w bieżącym katalogu, a następnie skanuje dwie biblio-
teki — /usr/lib/libc.a i /usr/lib/libm.a. Wszystkie funkcje, które są wywoływane w plikach obiek-
towych, a które w nich nie występują (np. printf), są określane jako niezdefiniowane wywołania
zewnętrzne i są poszukiwane w bibliotekach. Jeśli zostaną znalezione, zostają dołączone do wyko-
nywalnego pliku binarnego. Wszystkie funkcje, które one wywołują, a których do tej pory nie
zdefiniowano, również są traktowane jako niezdefiniowane wywołania zewnętrzne. Przykła-
dowo funkcja printf potrzebuje funkcji write, zatem jeśli funkcja write nie została jeszcze dołą-
czona, linker będzie jej szukał i dołączy ją do programu po znalezieniu. Kiedy linker zakończy
swoją pracę, zapisze wykonywalny plik binarny zawierający wszystkie potrzebne funkcje na
dysku. Funkcje występujące w bibliotekach, ale niewywoływane w programie nie zostają dołą-
czone. Kiedy program jest ładowany do pamięci i uruchamiany, wszystkie funkcje, których on
potrzebuje, są w pamięci.
Przypuśćmy teraz, że standardowe programy wykorzystują 20 – 50 MB funkcji graficznych
oraz funkcji obsługi interfejsu użytkownika. Statyczne łączenie setek programów z wszystkimi
tymi bibliotekami doprowadziłoby do marnotrawstwa olbrzymich ilości miejsca na dysku, a także
miejsca w pamięci RAM po ich załadowaniu do pamięci, ponieważ system nie byłby w stanie
się dowiedzieć, że biblioteki te można współdzielić. Właśnie po to wykorzystuje się mecha-
nizm bibliotek współdzielonych. Podczas łączenia programu z bibliotekami współdzielonymi
(które nieco się różnią od statycznych), zamiast dołączać wywoływaną funkcję, linker dołącza
niewielką namiastkę funkcji, a ta dołącza wywoływaną funkcję w fazie wykonywania programu.
W zależności od systemu oraz szczegółów konfiguracji biblioteki współdzielone są ładowane
w momencie ładowania programu lub przy pierwszym wywołaniu funkcji, które są w nich zawarte.
Oczywiście jeśli inny program wcześniej załadował współdzieloną bibliotekę, nie ma potrzeby,
by ładować ją ponownie — na tym polega sedno mechanizmu współdzielenia. Należy zwrócić
uwagę na to, że kiedy współdzielona biblioteka jest ładowana lub używana, cała biblioteka nie
zostaje wczytana do pamięci za jednym razem. Biblioteka jest w miarę potrzeb dzielona na strony,
dzięki czemu funkcje, które nie są wywoływane, nie zostaną załadowane do pamięci RAM.
Oprócz tego, że biblioteki współdzielone wpływają na zmniejszenie rozmiaru plików wyko-
nywalnych i pozwalają na oszczędności miejsca w pamięci, mają również inną zaletę: jeśli
funkcja należąca do biblioteki współdzielonej zostanie zaktualizowana w celu usunięcia błędu,
nie ma potrzeby ponownej kompilacji programów, które wywoływały tę funkcję. Stare binaria
w dalszym ciągu działają. Właściwość ta okazuje się szczególnie ważna dla oprogramowania
komercyjnego, kiedy kod źródłowy nie jest dostarczany do klienta. Jeśli np. firma Microsoft znaj-
dzie i poprawi błąd zabezpieczeń w jednej ze standardowych bibliotek DLL, mechanizm Windows
Update pobierze nową bibliotekę DLL i zastąpi nią starą. Przy następnym uruchomieniu wszystkie
programy, które korzystały z tej biblioteki DLL, automatycznie skorzystają z nowej wersji.
Z wykorzystaniem bibliotek współdzielonych wiąże się jeden niewielki problem, który
trzeba rozwiązać. Zilustrowano go na rysunku 3.24. Mamy na nim dwa procesy, które współ-
dzielą bibliotekę o rozmiarze 20 kB (przy założeniu, że każdy prostokąt ma 4 kB). Biblioteka jest
jednak umieszczona pod innym adresem w każdym procesie. Najprawdopodobniej dlatego, że
programy mają różny rozmiar. W procesie 1 biblioteka rozpoczyna się pod adresem 36 kB,
natomiast w procesie 2 — pod adresem 12 kB. Przypuśćmy, że pierwszą operacją wykonywaną
przez pierwszą funkcję w bibliotece jest skok pod adres 16 w bibliotece. Gdyby biblioteka nie
była współdzielona, można by ją relokować „w locie” podczas ładowania. Dzięki temu skok
(w procesie 1) byłby wykonywany pod adres wirtualny 36 kB+16. Zwróćmy uwagę, że adres
fizyczny w pamięci RAM, gdzie jest umieszczona biblioteka, nie ma znaczenia, ponieważ sprzęt
MMU odwzorowuje wszystkie adresy stron z wirtualnych na fizyczne.
Ponieważ jednak biblioteka jest współdzielona, relokacja „w locie” nie zadziała. Kiedy w końcu
zostanie wywołana pierwsza funkcja w procesie 2 (pod adresem 12 kB), instrukcja skoku musi
przejść pod adres 12 kB+16, a nie 36 kB+16. Jest z tym pewien problem. Jeden ze sposobów
rozwiązania polega na skorzystaniu z techniki kopiowania przy zapisie i stworzeniu nowych stron
dla każdego procesu współdzielącego bibliotekę. Dzięki temu ich relokacja nastąpi „w locie” pod-
czas tworzenia stron. Ten mechanizm podaje jednak w wątpliwość sens współdzielenia biblioteki.
Lepszym rozwiązaniem jest kompilowanie współdzielonych bibliotek z użyciem specjalnej
flagi, która instruuje kompilator, by nie tworzył żadnych instrukcji wykorzystujących adreso-
wanie bezwzględne. Zamiast nich są używane tylko instrukcje z adresowaniem względnym. Pra-
wie zawsze występuje instrukcja, która poleca wykonanie skoku w przód (lub w tył) o n bajtów
(w odróżnieniu od instrukcji, która przekazuje specjalny adres, pod który ma być wykonany
skok). Instrukcja ta działa prawidłowo niezależnie od tego, gdzie w wirtualnej przestrzeni adre-
sowej jest umieszczona biblioteka współdzielona. Problem można rozwiązać poprzez unikanie
adresowania bezwzględnego. Kod, który wykorzystuje tylko względne przesunięcia, nazywa
się kodem niezależnym od pozycji.
Kiedy wskazuje na zabrudzoną stronę, zostaje ona zapisana na dysk, a wskazówka posuwa się
w przód. Kiedy wskazuje na czystą stronę, jest tylko przesuwana. Wskazówka godzinowa jest
wykorzystywana do zastępowania stron, tak jak w standardowym algorytmie stronicowania. Teraz
jednak prawdopodobieństwo tego, że wskazówka godzinowa będzie pokazywała czystą stronę,
staje się większe z powodu pracy demona stronicowania.
MOV.L #6(A1),2(A0)
ma 6 bajtów (patrz rysunek 3.25). W celu wznowienia instrukcji system operacyjny musi określić,
gdzie znajduje się pierwszy bajt instrukcji. Wartość licznika programu w momencie wystąpie-
nia pułapki zależy od tego, który z operandów spowodował błąd, oraz tego, w jaki sposób zaim-
plementowano mikrokod procesora.
Na rysunku 3.25 pokazano instrukcję rozpoczynającą się pod adresem 1000, która wykonuje
trzy odwołania do pamięci: pobranie samego słowa instrukcji oraz dwóch adresów przesunięć
dla operandów.
W zależności od tego, które z tych trzech odwołań do pamięci spowodowało błąd braku
strony, licznik programu w momencie wystąpienia błędu może zawierać wartość 1000, 1002 lub
1004. System operacyjny często nie ma możliwości jednoznacznego stwierdzenia, gdzie zaczyna
się instrukcja. Jeśli licznik programu ma wartość 1002 w momencie wystąpienia błędu braku
strony, system operacyjny nie ma możliwości stwierdzenia, czy słowo pod adresem 1002 jest
adresem pamięci powiązanym z instrukcją pod adresem 1000 (np. oznaczającym lokalizację ope-
randu), czy kodem operacji tej instrukcji.
Choć ten problem wydaje się poważny, może być jeszcze gorzej. W niektórych trybach adre-
sacji procesora 680x0 wykorzystuje się autoinkrementację. Oznacza to, że efektem ubocznym
uruchomienia instrukcji jest inkrementacja jednego lub większej liczby rejestrów. Instrukcje,
które wykorzystują tryb autoinkrementacji, również mogą spowodować błąd braku strony.
W zależności od szczegółów mikrokodu inkrementacja może być wykonana przed odwołaniem do
pamięci — wówczas system operacyjny przed wznowieniem instrukcji musi programowo zde-
krementować rejestr. Autoinkrementacja może być również wykonana po wykonaniu odwoła-
nia do pamięci. W tym przypadku nie będzie ona wykonana w momencie wykonywania rozkazu
pułapki i system operacyjny nie będzie zmuszony do cofania jej skutków. Istnieje również tryb
automatycznej dekrementacji, który powoduje podobne problemy. Szczegóły dotyczące tego, czy
autoinkrementacja czy autodekrementacja została wykonana przed odwołaniem do pamięci lub po
nim, mogą się różnić w odniesieniu do odmiennych instrukcji oraz różnych modeli procesorów.
Na szczęście projektanci procesorów w niektórych maszynach zapewniają rozwiązanie —
zazwyczaj w formie ukrytego wewnętrznego rejestru, do którego jest kopiowany licznik pro-
gramu bezpośrednio przed wykonaniem każdej instrukcji. Maszyny te mogą być również wypo-
sażone w drugi rejestr informujący o tym, które rejestry zostały poddane automatycznej inkre-
mentacji lub automatycznej dekrementacji i o ile. Na podstawie tych informacji system
operacyjny może jednoznacznie cofnąć wszystkie skutki instrukcji, która spowodowała błąd,
dzięki czemu można ją wznowić. Jeśli te informacje nie są dostępne, system operacyjny musi
podjąć działania zmierzające do ustalenia tego, co się stało i w jaki sposób można to naprawić.
Można to porównać do sytuacji, w której projektanci sprzętu nie potrafili rozwiązać problemu,
zatem załamali ręce i przekazali problem do rozwiązania autorom systemu operacyjnego. Mili
ludzie.
W tym prostym modelu jest jednak problem: procesy mogą zwiększać swój rozmiar po
uruchomieniu. Choć tekst programu jest zazwyczaj stały, obszar danych może czasami się roz-
rastać, a stos rośnie zawsze. W konsekwencji czasami lepiej zarezerwować oddzielne obszary
wymiany na tekst, dane i stos i zezwolić na to, by każdy z tych obszarów zawierał więcej niż
jeden fragment na dysku.
Innym ekstremalnym rozwiązaniem jest rezygnacja z alokowania czegokolwiek z góry.
W tym przypadku dla każdej strony usuwanej z pamięci miejsce na dysku jest alokowane podczas
usuwania jej z pamięci. Miejsce to jest zwalniane, gdy strona jest z powrotem ładowana do
pamięci. W ten sposób procesy znajdujące się w pamięci nie wiążą żadnego miejsca w obszarze
wymiany. Wadą tego rozwiązania okazuje się potrzeba przechowywania w pamięci adresu dys-
kowego w celu śledzenia wszystkich stron zapisanych na dysku. Inaczej mówiąc, dla każdego
procesu musi istnieć tabela, w której dla każdej strony na dysku jest informacja o tym, gdzie ta
strona się znajduje. Dwa alternatywne rozwiązania pokazano na rysunku 3.26.
Rysunek 3.26. (a) Stronicowanie w statycznym obszarze wymiany; (b) dynamiczna wymiana stron
Na rysunku 3.26(a) pokazano tabelę stron zawierającą osiem stron. Strony 0, 3, 4 i 6 znaj-
dują się w pamięci głównej. Strony 1, 2, 5 i 7 znajdują się na dysku. Obszar wymiany na dysku
jest równy co do rozmiaru z wirtualną przestrzenią adresową procesu (osiem stron), przy czym
każda strona ma stałą lokalizację, gdzie jest zapisywana w przypadku jej usuwania z pamięci
głównej. Obliczenie tego adresu wymaga jedynie wiedzy na temat tego, gdzie rozpoczyna się
obszar stronicowania procesu, ponieważ strony są zapisane w ciągłym bloku w kolejności nume-
rów stron wirtualnych. Dla strony znajdującej się w pamięci zawsze istnieje jej kopia na dysku
(na rysunku zacieniowana). Kopia ta może być jednak przestarzała, jeśli strona została zmody-
fikowana od momentu załadowania. Miejsca zacieniowane w pamięci oznaczają strony, których
nie ma w pamięci. Strony zacieniowane na dysku są (w zasadzie) wypierane przez kopie w pamię-
ci, chociaż jeśli strona z pamięci ma być umieszczona na dysku i nie została zmodyfikowana od
momentu załadowania, to zostanie użyta kopia z dysku (zacieniowana).
W sytuacji z rysunku 3.26(b) strony nie mają ustalonych adresów na dysku. W momencie usu-
wania strony z pamięci wybierana jest „w locie” strona na dysku, następnie zostaje odpowied-
nio zaktualizowana mapa dyskowa (zawierająca po jednym miejscu na adres dyskowy dla każdej
strony wirtualnej). Stronie w pamięci nie odpowiada kopia na dysku. Pozycje mapy dyskowej
odpowiadające tym stronom zawierają nieprawidłowy adres dyskowy lub bit, oznaczający je jako
niebędące w użyciu.
Posiadanie stałej partycji wymiany nie zawsze jest możliwe, np. w systemie komputerowym
może nie być wolnej partycji. W takim przypadku w tej roli można wykorzystać jeden lub wię-
cej dużych plików, wstępnie zaalokowanych w standardowym systemie plików. Takie podej-
ście zastosowano w systemie Windows. W tym przypadku można jednak wykorzystać techniki
optymalizacji w celu zmniejszenia ilości potrzebnego miejsca na dysku. Ponieważ tekst pro-
gramu każdego procesu pochodzi z jakiegoś pliku (wykonywalnego) w systemie plików, w roli
obszaru wymiany można wykorzystać plik wykonywalny. Co więcej, ponieważ tekst programu
jest, ogólnie rzecz biorąc, tylko do odczytu, to w przypadku braków w pamięci, jeśli strony będą
musiały być z niej usuwane, można z nich zrezygnować i wczytać ponownie z pliku wykony-
walnego, gdy okażą się potrzebne. W ten sposób działają również biblioteki współdzielone.
3.7. SEGMENTACJA
3.7.
SEGMENTACJA
Pamięć wirtualna, którą omawialiśmy do tej pory, była jednowymiarowa. Adresy wirtualne zaczy-
nały się od zera i zwiększały się do pewnego adresu maksymalnego. W przypadku wielu pro-
blemów występowanie dwóch lub większej liczby oddzielnych wirtualnych przestrzeni adre-
sowych może być znacznie korzystniejsze od występowania tylko jednej takiej przestrzeni.
Przykładowo kompilator posiada wiele tabel, które tworzą się w miarę postępów procesu kom-
pilacji. Mogą to być następujące tabele:
1. Tekst źródłowy zapisywany w celu stworzenia drukowanego listingu (w systemach wsa-
dowych).
2. Tabela symboli zawierająca nazwy i atrybuty zmiennych.
3. Tabela zawierająca wszystkie wykorzystywane stałe całkowite i zmiennoprzecinkowe.
Zastanówmy się nad tym, co się stanie, jeśli program będzie miał więcej niż zwykle zmien-
nych, ale normalną liczbę innych elementów. Fragment przestrzeni adresowej zaalokowanej dla
tabeli symboli może się zapełnić, choć w innych tabelach pozostanie sporo miejsca. Kompilator
mógłby oczywiście wyświetlić komunikat, że kompilacja nie może być kontynuowana z powodu
zbyt dużej liczby zmiennych, ale wybór takiego rozwiązania nie wydaje się zbyt dobry, skoro
w innych tabelach jest sporo nieużywanego miejsca.
Inną możliwością jest zabawienie się w Robin Hooda — zabranie miejsca tabelom, które mają
go dużo, i oddanie tym, które mają go mało. Taki mechanizm da się zrealizować, ale jest on ana-
logiczny do zarządzania własnymi nakładkami — w najlepszym razie jest kłopotliwy, a w naj-
gorszym sprowadza się do żmudnej i nieopłacalnej pracy.
Trzeba znaleźć sposób, aby zwolnić programistę z obowiązku zarządzania rozszerzającymi
się i ścieśniającymi tabelami, tak jak zastosowanie pamięci wirtualnej eliminuje konieczność
organizowania programu jako zestawu nakładek.
Prostym i niezwykle uniwersalnym rozwiązaniem jest umożliwienie korzystania z wielu,
całkowicie niezależnych przestrzeni adresowych zwanych segmentami. Każdy segment składa
się z liniowej sekwencji adresów — od 0 do pewnego maksimum. Rozmiar każdego segmentu
może być dowolną liczbą z zakresu od 0 do dozwolonego maksimum. Różne segmenty mogą
mieć (i zwykle tak jest w praktyce) różne rozmiary. Co więcej, rozmiary segmentów mogą się
zmieniać w czasie działania programu. Rozmiar stosu może się zwiększać za każdym razem,
kiedy na stos są odkładane jakieś dane, i zmniejszać, kiedy są one z niego zdejmowane.
Ponieważ każdy segment tworzy osobną przestrzeń adresową, różne segmenty mogą się
rozrastać i maleć niezależnie od siebie, bez wzajemnego wpływu na siebie. Jeśli stos w jakimś
segmencie potrzebuje więcej przestrzeni adresowej, może ją dostać, ponieważ w jego przestrzeni
adresowej nie ma niczego, na co mógłby „wpaść”. Oczywiście segment może się zapełnić, ale
segmenty są zazwyczaj bardzo duże, zatem taka sytuacja występuje rzadko. Aby określić adres
w takiej posegmentowanej (dwuwymiarowej) pamięci, program musi posługiwać się dwuczło-
nowymi adresami składającymi się z numeru segmentu oraz adresu wewnątrz segmentu. Na
rysunku 3.29 pokazano użycie posegmentowanej pamięci dla przypadku tabel kompilatora oma-
wianych wcześniej. Na ilustracji zaprezentowano pięć niezależnych segmentów.
Rysunek 3.29. Pamięć podzielona na segmenty pozwala każdej z tabel na rozrastanie się
lub ścieśnianie niezależnie od innych tabel
Rysunek 3.30. (a) – (d) Rozwój szachownicowania; (e) rozwiązanie problemu szachownicowania
poprzez kompaktowanie
System MULTICS działał na komputerach Honeywell 6000 i ich potomkach. Każdy program
miał do dyspozycji pamięć wirtualną złożoną maksymalnie z 218 segmentów, z których każdy
miał rozmiar do 65 536 (36-bitowych) słów. W celu zaimplementowania takiej konfiguracji pro-
jektanci MULTICS postanowili traktować każdy segment jako pamięć wirtualną i ją stronicować.
W ten sposób połączono zalety stronicowania (standardowy rozmiar stron oraz brak koniecz-
ności utrzymywania całego segmentu w pamięci w przypadku używania tylko jego części)
z zaletami segmentacji (łatwość programowania, modularność, zabezpieczenia, współdzielenie).
Każdy program w systemie MULTICS zawierał tabelę segmentów, w której występowało
po jednym deskryptorze na segment. Ponieważ istniało potencjalnie więcej niż ćwierć miliona
pozycji w tabeli, tabela segmentów sama była segmentem i podlegała stronicowaniu. Deskryptor
segmentu zawierał informację o tym, czy segment znajdował się w pamięci głównej, czy nie.
Jeśli dowolna część segmentu była w pamięci, segment był uważany za przechowywany w pamięci,
a jego tabela stron znajdowała się w pamięci. Jeśli segment był w pamięci, jego deskryptor zawie-
rał 18-bitowy wskaźnik do tablicy stron, co pokazano na rysunku 3.31(a). Ponieważ adresy fizyczne
były 24-bitowe, a strony wyrównywane w 64-bajtowych granicach (najmłodsze 6 bitów adresu
strony to 000000), do przechowywania adresu tablicy strony w deskryptorze potrzebne było
tylko 18 bitów. Deskryptor zawierał również rozmiar segmentu, bity zabezpieczeń oraz kilka
innych elementów. Na rysunku 3.31(b) pokazano deskryptor segmentu w systemie MULTICS.
Adres segmentu w pamięci pomocniczej nie znajdował się w deskryptorze segmentu, ale w innej
tabeli używanej przez mechanizm obsługi błędów braku stron.
Każdy segment był zwykłą wirtualną przestrzenią adresową i był stronicowany w taki sam
sposób jak pamięć stronicowana niepodzielona na segmenty, którą opisano we wcześniejszej
części tego rozdziału. Normalny rozmiar strony wynosił 1024 słowa (choć dla oszczędności fizycz-
nej pamięci sam system MULTICS wykorzystywał kilka mniejszych segmentów, które nie były
stronicowane lub były stronicowane w jednostkach po 64 słowa).
Adres w systemie MULTICS składa się z dwóch części: adresu segmentu oraz adresu
przesunięcia wewnątrz segmentu. Adres wewnątrz segmentu jest z kolei podzielony na numer
strony oraz słowo wewnątrz strony, tak jak pokazano na rysunku 3.32. Kiedy następuje odwo-
łanie do pamięci, realizowany jest poniższy algorytm.
1. Numer segmentu jest wykorzystywany do znalezienia deskryptora segmentu.
2. System sprawdza, czy tabela stron segmentu jest w pamięci.
Jeśli tak, lokalizuje ją. Jeśli nie, powstaje błąd braku segmentu. W przypadku naru-
szenia zabezpieczeń generowany jest błąd (rozkaz pułapki).
3. System analizuje pozycję tabeli stron odpowiadającą żądanej stronie wirtualnej. Jeśli samej
strony nie ma w pamięci, system generuje błąd braku strony. Jeśli strona jest w pamięci,
to z pozycji tablicy stron wyodrębniany jest adres początku strony w pamięci głównej.
4. Adres przesunięcia jest dodawany do adresu segmentu, z którego pochodziła strona.
W ten sposób obliczany jest adres w pamięci głównej, pod którym znajduje się żądane
słowo.
5. Na koniec wykonywany jest odczyt lub zapis.
Proces ten zilustrowano na rysunku 3.33. Dla uproszczenia pominięto fakt stronicowania
samego segmentu deskryptorów. W pokazanym schemacie do zlokalizowania tablicy stron seg-
mentu deskryptorów wykorzystywany był rejestr (bazowy rejestr deskryptora), a ten z kolei
wskazywał na strony segmentu deskryptora. Po znalezieniu deskryptora potrzebnego segmentu
wykonywana była dalsza część adresowania, tak jak pokazano na rysunku 3.33.
Rysunek 3.31. Pamięć wirtualna w systemie MULTICS: (a) segment deskryptorów wskazuje
na tablice stron; (b) deskryptor segmentu; liczby oznaczają rozmiary pól
Jak z pewnością większość Czytelników odgadła, gdyby system operacyjny realizował przy-
toczony algorytm przy okazji wykonywania każdej instrukcji, programy nie działałyby zbyt
szybko. W rzeczywistości sprzęt systemu MULTICS zawiera 16-słowowy bufor TLB, który
jest w stanie równolegle przeszukiwać wszystkie zapisy w poszukiwaniu określonego klucza.
Był to pierwszy system wyposażony w bufor TLB — mechanizm używany również w nowo-
czesnych architekturach. Zilustrowano go na rysunku 3.34. W momencie przesłania adresu do
komputera mechanizm adresujący najpierw sprawdza, czy adres wirtualny znajduje się w buforze
TLB. Jeśli tak, to pobiera numer ramki bezpośrednio z bufora TLB i formuje właściwy adres
bez konieczności zaglądania do segmentu deskryptorów lub tabeli stron.
Rysunek 3.34. Uproszczona wersja bufora TLB w systemie MULTICS; z powodu istnienia
dwóch rozmiarów stron implementacja bufora TLB jest bardziej złożona
Jeden z bitów selektora informuje o tym, czy segment jest lokalny, czy globalny (tzn. czy
znajduje się w tablicy LDT, czy GDT). Trzynaście innych bitów określa numer pozycji w tablicy
LDT lub GDT. W związku z tym każda z tych tabel posiada ograniczenie przechowywania 8K
deskryptorów segmentów. Pozostałe 2 bity są związane z zabezpieczeniami i zostaną opisane
później. Deskryptor 0 jest zabroniony. Można go bezpiecznie załadować do rejestru segmento-
wego, aby pokazać, że rejestr segmentowy nie jest obecnie dostępny. Użycie tego deskryptora
powoduje wykonanie rozkazu pułapki.
Gdy selektor zostaje załadowany do rejestru segmentowego, odpowiadający mu deskryptor
jest pobierany z tablicy LDT lub GDT i zapisywany w rejestrach mikroprogramowych, dzięki
czemu możliwy staje się szybki dostęp do niego. Jak pokazano na rysunku 3.36, deskryptor
składa się z ośmiu bajtów — zawiera adres bazowy segmentu, rozmiar oraz inne informacje.
Rysunek 3.36. Deskryptor segmentu kodu w systemie x86; segmenty danych nieco się różnią
Format selektora został wybrany w taki sposób, aby ułatwić lokalizowanie deskryptora. Naj-
pierw, na podstawie 2. bitu selektora, system wybiera tablicę LDT albo GDT. Następnie selektor
jest kopiowany do wewnętrznego rejestru roboczego, a jego 3 najmłodsze bity są ustawiane na 0.
Na koniec dodawany jest do niego adres tablicy LDT lub GDT — w efekcie powstaje bezpośredni
wskaźnik do deskryptora; np. selektor 72 odnosi się do 9. pozycji w tablicy GDT, która jest umiesz-
czona pod adresem GDT+72.
Spróbujmy prześledzić krok po kroku konwersję pary (selektor, przesunięcie) na adres
fizyczny. Kiedy mikroprogram uzyska informację o tym, który rejestr segmentowy będzie uży-
wany, może znaleźć kompletny deskryptor odpowiadający temu selektorowi w wewnętrznych
rejestrach. Jeśli segment nie istnieje (selektor 0) lub w danym momencie znajduje się poza
pamięcią, wykonywany jest rozkaz pułapki.
Następnie sprzęt wykorzystuje pole Limit w celu sprawdzenia, czy przesunięcie znajduje
się poza granicami segmentu. Jeśli tak się dzieje, to także jest wykonywany rozkaz pułapki.
Z logicznego punktu widzenia w deskryptorze powinno być 32-bitowe pole zawierające rozmiar
segmentu, ale jest tam tylko 20 bitów, dlatego też jest wykorzystywany inny schemat. Jeśli bit G
(od ang. Granularity — ziarnistość) ma wartość 0, to pole Limit zawiera dokładny rozmiar
segmentu — maksymalnie 1 MB. Jeśli ma on wartość 1, to pole Limit jest wyrażone jako liczba
stron, a nie bajtów. Przy stronie o rozmiarze 4 kB 20 bitów wystarczy do tworzenia segmentów
o rozmiarze do 232 bajtów.
Zakładając, że segment jest obecny w pamięci, a przesunięcie mieści się w zakresie, proce-
sor Pentium dodaje 32-bitowe pole Adres bazowy z deskryptora do przesunięcia i w ten sposób
tworzy tzw. adres liniowy, tak jak pokazano na rysunku 3.37. Pole adresu bazowego jest podzie-
lone na trzy części i rozmieszczone wewnątrz deskryptora. Ma to na celu zapewnienie zgodności
z systemami 286, w których adres bazowy miał tylko 24 bity. W rezultacie pole Adres bazowy
pozwala wszystkim segmentom na to, by rozpoczynały się w dowolnym miejscu 32-bitowej linio-
wej przestrzeni adresowej.
Jeśli stronicowanie jest wyłączone (za pomocą bitu w globalnym rejestrze kontrolnym), adres
liniowy zostaje zinterpretowany jako adres fizyczny i przesłany do pamięci w celu realizacji
odczytu lub zapisu. Tak więc przy wyłączonym stronicowaniu mamy klasyczny schemat seg-
mentacji — adres bazowy każdego segmentu jest podany w deskryptorze. Nie ma mechanizmu
przeciwdziałającego nakładaniu się segmentów, prawdopodobnie dlatego, że sprawiałby on zbyt
wiele kłopotów, a weryfikacja tego, czy wszystkie segmenty są rozłączne, zajmowałaby zbyt
dużo czasu.
Z drugiej strony, jeśli stronicowanie jest włączone, adres liniowy jest interpretowany jako
adres wirtualny i odwzorowany na adres fizyczny z wykorzystaniem tabel stron — w sposób
Na rysunku 3.38(a) widzimy adres liniowy podzielony na trzy pola: Katalog, Strona i Prze-
sunięcie. Pole Katalog służy do stworzenia indeksu do katalogu stron w celu zlokalizowania
wskaźnika do właściwej tabeli stron. Pole Strona pełni rolę indeksu do tabeli stron i pozwala na
znalezienie fizycznego adresu ramki strony. Na koniec do adresu ramki strony jest dodawane
Przesunięcie. W ten sposób obliczany jest adres fizyczny potrzebnego bajta lub słowa.
Każda pozycja w tabeli stron ma 32 bity, z których 20 zawiera numer ramki strony. Pozostałe
bity zawierają bity dostępu i bity zabrudzenia, które są ustawiane przez sprzęt i pełnią rolę bitów za-
bezpieczeń oraz innych bitów narzędziowych wykorzystywanych przez system operacyjny.
Każda tabela stron zawiera pozycje dla 1024 4-kilobajtowych ramek stron, zatem pojedyncza
tabela stron obsługuje 4 megabajty pamięci. Segment mniejszy niż 4M zawiera katalog stron
z pojedynczą pozycją — wskaźnikiem do jedynej tabeli stron. W ten sposób koszt krótkiego
segmentu sprowadza się do zaledwie dwóch zamiast miliona stron, które byłyby potrzebne
w przypadku jednopoziomowej tabeli stron.
W celu uniknięcia wielokrotnego wykonywania tych samych odwołań do pamięci w proce-
sorze Pentium, podobnie jak w systemie MULTICS, jest niewielki bufor TLB, który bezpośred-
nio odwzorowuje najczęściej używane kombinacje Katalog − Strona na fizyczny adres ramki
strony. Tylko wtedy, gdy bieżąca kombinacja nie jest obecna w buforze TLB, wykorzystany
zostaje mechanizm z rysunku 3.38 oraz aktualizowany bufor TLB. Jeśli przypadki chybionych
odwołań do bufora TLB są rzadkie, wydajność jest dobra.
Warto również zwrócić uwagę, że jeśli jakaś aplikacja nie potrzebuje segmentacji, ale wystar-
czy jej pojedyncza, stronicowana 32-bitowa przestrzeń adresowa, wykorzystanie tego modelu
jest możliwe. Wszystkie rejestry segmentowe można skonfigurować z tym samym selekto-
rem. Jego deskryptor będzie miał ustawienie Adres bazowy = 0 oraz Limit ustawiony na war-
tość maksymalną. Przesunięcie instrukcji będzie wtedy adresem liniowym, wykorzystującym
tylko jedną przestrzeń adresową — w rezultacie będzie to zwykłe stronicowanie. W rzeczywi-
stości w ten sposób działają wszystkie systemy operacyjne dla procesorów Pentium. System
OS/2 był jedynym, który wykorzystywał wszystkie możliwości architektury układu MMU
firmy Intel.
Dlaczego więc Intel zrezygnował z tego, co było odmianą dobrego modelu pamięci systemu
MULTICS wspieranego przez blisko trzy dekady? Prawdopodobnie głównym powodem jest to,
że ani w systemie UNIX, ani Windows nigdy z tego modelu nie korzystano, mimo że był on
bardzo skuteczny. System ten eliminował bowiem wywołania systemowe, zamieniając je w bły-
skawicznie działające wywołania procedur do odpowiedniego adresu wewnątrz chronionego
segmentu systemu operacyjnego. Żaden z deweloperów żadnej odmiany systemu UNIX ani
Windows nie chciał zmieniać modelu pamięci na mechanizm specyficzny dla platformy x86,
ponieważ spowodowałoby to problemy z przenoszeniem na inne platformy. Ponieważ w opro-
gramowaniu nie używano tej funkcji, w firmie Intel wyciągnięto wniosek, że wspieranie jej jest
marnotrawstwem miejsca w układzie, dlatego usunięto ją z procesorów 64-bitowych.
Tak czy inaczej, należy wyrazić uznanie dla projektantów systemu Pentium. Jeśli wziąć pod
uwagę kolidujące ze sobą cele implementacji czystego stronicowania, czystej segmentacji oraz
segmentów ze stronicowaniem, a jednocześnie zapewnienia kompatybilności z układami 286
oraz odpowiedniej wydajności, należy przyznać, że uzyskany projekt jest zaskakująco prosty
i czytelny.
3.9. PODSUMOWANIE
3.9.
PODSUMOWANIE
PYTANIA
1. W komputerze IBM 360 wykorzystywano system blokowania 2-kilobajtowych bloków
poprzez przypisanie każdemu z nich 4-bitowego klucza. Procesor porównywał klucz
przy każdym odwołaniu pamięci z 4-bitowym kluczem w słowie PSW. Wymień dwie, nie-
wskazane w tekście książki, wady tego systemu.
2. Na rysunku 3.3 rejestry adresu bazowego i limitu zawierają tę samą wartość — 16384.
Czy to przypadek, czy zawsze są one takie same? Jeśli tylko przypadek, to dlaczego w tym
przykładzie mają one taką samą wartość?
3. W systemie wymiany luki w pamięci są eliminowane poprzez kompaktowanie. Ile zajmie
kompaktowanie pamięci o rozmiarze 4 GB przy założeniu, że następuje losowa dystrybucja
wielu luk w wielu segmentach danych oraz że czas odczytu lub zapisu 32-bitowego słowa
pamięci jest równy 10 ns? Dla uproszczenia załóżmy, że słowo 0 jest częścią luki, a słowo
o najwyższym adresie w pamięci zawiera prawidłowe dane.
4. Rozważmy system wymiany, w którym pamięć zawiera bloki wolnej pamięci o następują-
cych rozmiarach i w następującym porządku: 10 kB, 4 kB, 20 kB, 18 kB, 7 kB, 9 kB, 12 kB
i 15 kB. Które wolne bloki zostaną wybrane przy kolejnych żądaniach segmentów o roz-
miarach:
(a) 12 MB
(b) 10 MB
(c) 9 MB
w przypadku zastosowania algorytmu „pierwszy pasujący”? Odpowiedz na to samo pytanie
w odniesieniu do algorytmów „najlepszy pasujący” oraz „następny pasujący”.
5. Jaka jest różnica pomiędzy adresem fizycznym a adresem wirtualnym?
6. Dla każdego z wymienionych poniżej dziesiętnych adresów wirtualnych oblicz numer
strony wirtualnej i przesunięcie dla przypadku stron o rozmiarze 4 kB i 8 kB: 20000,
32768, 60000.
7. Korzystając z tablicy stron z rysunku 3.9, podaj adres fizyczny odpowiadający każdemu
z poniższych adresów wirtualnych.
(a) 20
(b) 4100
(c) 8300
8. Procesor Intel 8086 nie ma układu MMU i nie obsługuje pamięci wirtualnej. Niemniej
jednak niektóre firmy sprzedawały w przeszłości niezmodyfikowany procesor 8086, który
realizował stronicowanie. Zastanów się nad tym, w jaki sposób to robiono. Wskazówka:
weź pod uwagę logiczną lokalizację jednostki MMU.
9. Jakiego rodzaju wsparcie sprzętowe jest potrzebne do działania stronicowanej pamięci
wirtualnej?
10. Kopiowanie przy zapisie to interesująca koncepcja wykorzystywana w systemach ser-
werowych. Czy zastosowanie jej w smartfonie ma jakiś sens?
11. Przeanalizuj poniższy program w języku C:
int X[N];
int step = M; /* M jest pewną predefiniowaną stałą */
for (int i = 0; i < N; i += step) X[i] = X[i] + 1;
(a) Jeśli ten program zostanie uruchomiony na maszynie, w której strony mają rozmiar
4 kilobajtów, a bufor TLB ma rozmiar 64 pozycji, to jakie wartości M i N spowodują błąd
chybionego odwołania do bufora TLB dla każdego wykonania wewnętrznej pętli?
(b) Czy odpowiedź udzielona w części (a) byłaby inna, gdyby pętle powtórzono wiele
razy? Wyjaśnij.
12. Ilość miejsca na dysku, która musi być dostępna do składowania strony, jest związana
z maksymalną liczbą procesów n, liczbą bajtów w wirtualnej przestrzeni adresowej v oraz
liczbą bajtów w pamięci RAM r. Podaj wyrażenie dla najgorszego przypadku wymagań
miejsca na dysku. Na ile realna jest ta ilość?
13. Podaj wzór na obliczenie efektywnego czasu instrukcji przy założeniu, że błędy stron
pojawiają się co k instrukcji, wykonanie instrukcji zajmuje 1 ns, a błąd strony zajmuje
dodatkowo n ns.
14. Maszyna ma 32-bitową przestrzeń adresową i wykorzystuje strony o rozmiarze 8 kB.
Tabela stron jest zaimplementowana w całości sprzętowo, przy czym jedna pozycja zaj-
muje 32-bitowe słowo. Kiedy proces się uruchomi, tabela stron jest kopiowana z pamięci
do rejestrów sprzętowych z szybkością jednego słowa co 100 ns. Jeśli każdy proces
działa przez 100 ms (włącznie z czasem załadowania tabeli stron), to jaka część czasu pro-
cesora zostanie poświęcona na załadowanie tabel stron?
15. Załóżmy, że w komputerze są wykorzystywane 48-bitowe adresy wirtualne i 32-bitowe
adresy fizyczne.
(a) Gdy strony mają rozmiar 4 kB, to ile pozycji znajduje się w tabeli stron, jeśli ma ona
tylko jeden poziom? Wyjaśnij.
(b) Przypuśćmy, że ten sam system jest wyposażony w bufor TLB (od ang. Translation
Lookaside Buffer) o 32 pozycjach. Ponadto załóżmy, że program zawiera instrukcje
mieszczące się na jednej stronie, które sekwencyjnie czytają elementy typu long integer
z tablicy zajmującej kilka tysięcy stron. Na ile skuteczny będzie bufor TLB dla tego
przypadku?
16. Dane są następujące parametry systemu pamięci wirtualnej:
(a) Bufor TLB może przechowywać 1024 pozycje i można do niego uzyskać dostęp
w 1 cyklu zegarowym (1 ns).
(b) Odnalezienie pozycji w tablicy stron zajmuje 100 cykli zegarowych, czyli 100 ns.
(c) Średni czas zastępowania strony wynosi 6 ms.
Jaki jest właściwy czas translacji adresu, jeśli w 99% przypadków odwołania do stron są
obsługiwane przez bufor TLB, a tylko w 0,01% przypadków dochodzi do błędu strony?
17. Załóżmy, że w komputerze są wykorzystywane 38-bitowe adresy wirtualne i 32-bitowe
adresy fizyczne.
(a) Jaka jest główna przewaga wielopoziomowej tablicy stron nad jednopoziomową?
(b) Ile bitów należy zaalokować dla pola tabeli stron najwyższego poziomu, a ile dla pola
tabeli stron następnego poziomu dla dwupoziomowej tabeli stron, stron o rozmiarze
16 kB oraz pozycjach w tabeli stron o rozmiarze 4 bajtów? Wyjaśnij.
18. W punkcie 3.3.4 stwierdzono, że w procesorze Pentium Pro każdą pozycję w hierarchii
tablicy stron rozszerzono do 64 bitów, ale układ nadal mógł zaadresować maksymalnie
tylko 4 GB pamięci. Wyjaśnij, jak to możliwe, aby to zdanie było prawdziwe, jeśli pozy-
cje w tablicy stron mają 64 bity.
(a) Dlaczego standardowe algorytmy zastępowania stron (LRU, FIFO, zegarowy) nie
będą skuteczne do obsługi obciążenia alokacji strony krótszej od sekwencji stron?
(b) Opisz sposób realizacji problemu zastępowania stron. Algorytm powinien być znacz-
nie wydajniejszy od algorytmów LRU, FIFO lub zegarowego, dla przypadku alokacji
przez ten program 500 ramek stron.
28. Gdy zostanie użyty algorytm zastępowania stron FIFO z czterema ramkami stron i ośmioma
stronami, to ile błędów braku stron wystąpi z ciągiem odwołania 0172327103, jeśli cztery
ramki są początkowo puste? To samo zadanie wykonaj dla algorytmu LRU.
29. Rozważmy sekwencję stron z rysunku 3.14(b). Przypuśćmy, że bity R dla stron od B do A
to odpowiednio 11011011. Która strona zostanie usunięta w wyniku wykonania algorytmu
drugiej szansy?
30. Mały komputer na karcie smart ma cztery ramki stron. Przy pierwszym takcie zegara
bity R mają postać 0111 (dla strony 0 jest to wartość 0, dla pozostałych — 1). W kolej-
nych taktach zegara wartości wynoszą 1011, 1010, 1101, 0010, 1010, 1100 i 0001. Podaj
wartości czterech liczników po ostatnim takcie, jeśli zostanie użyty algorytm postarza-
nia z 8-bitowym licznikiem.
31. Podaj prosty przykład sekwencji odwołań do stron, tak aby pierwsza strona wybrana do
zastąpienia była inna dla algorytmu zastępowania stron LRU, a inna dla algorytmu zega-
rowego. Załóżmy, że do procesu są przydzielone 3 ramki, a ciąg odwołania zawiera numery
stron dla zbioru 0, 1, 2 i 3.
32. W algorytmie WSClock z rysunku 3.19(c) wskazówka pokazuje stronę z R = 0. Czy ta
strona zostanie usunięta, jeśli τ = 400? A co się stanie w przypadku, gdy τ = 1000?
33. Przypuśćmy, że algorytm zastępowania stron WSClock wykorzystuje τ = 2 cykle, nato-
miast system ma następujący stan:
Strona Znacznik czasu V R M
0 6 1 0 1
1 9 1 1 0
2 9 1 1 1
3 7 1 0 0
4 4 0 0 0
gdzie trzy bity flag V, R i M oznaczają odpowiednio (Valid — ważny, Referenced — z odwo-
łaniami oraz Modified — zmodyfikowany).
(a) Podaj zawartość nowych pozycji w tabeli, jeśli w takcie 10 występuje przerwanie
zegarowe. Wyjaśnij (możesz pominąć wpisy, które pozostają bez zmian).
(b) Załóżmy, że zamiast przerwania zegarowego w takcie 10 występuje błąd strony
spowodowany żądaniem odczytu strony 4. Podaj zawartość nowych pozycji w tabeli.
Objaśnij (możesz pominąć wpisy, które pozostają bez zmian).
34. Student napisał, że „podstawowe algorytmy zastępowania stron (FIFO, LRU, optymalny)
na poziomie abstrakcji są identyczne; wyjątkiem jest atrybut używany do wybrania strony,
która ma być zastąpiona”.
(a) Jaki jest ten atrybut dla algorytmu FIFO? Jaki dla algorytmu LRU? A jaki dla opty-
malnego?
(b) Sformułuj ogólny algorytm wymienionych algorytmów zastępowania stron.
35. Ile czasu zajmuje załadowanie 64-kilobajtowego programu z dysku, w którym średni
czas wyszukiwania wynosi 5 ms, którego czas obrotu wynosi 5 ms oraz którego ścieżka
mieści 1 MB danych:
(a) dla strony o rozmiarze 2 kB,
(b) dla strony o rozmiarze 4 kB?
Strony są rozmieszczone na dysku w sposób losowy, a liczba cylindrów jest tak duża, że
szansa występowania dwóch stron w tym samym cylindrze okazuje się pomijalnie mała.
36. Komputer wykorzystuje cztery ramki stron. Czas załadowania, czas ostatniego użycia oraz
bity R i M dla każdej strony pokazano poniżej (czasy zostały wyrażone w taktach zegara):
Strona Załadowana Ostatnie użycie R M
0 126 280 1 0
1 230 265 0 1
2 140 270 0 0
3 110 285 1 1
Przypuśćmy, że system wykorzystuje cztery ramki stron, a każda ramka ma 128 słów
(zmienna typu integer zajmuje jedno słowo). Programy przetwarzające tablicę X zaj-
mują dokładnie jedną stronę i zawsze jest nią strona 0. Dane pozostałych trzech ramek
są wymieniane pomiędzy pamięcią a dyskiem. Tablica X jest zapisana w porządku rosną-
cych wierszy (tzn. element X[0][1] występuje w pamięci za wierszem X[0][0]). Który
z dwóch fragmentów kodu pokazanych poniżej spowoduje wygenerowanie najmniejszej
liczby błędów braku stron? Objaśnij i wylicz całkowitą liczbę błędów braku stron.
Fragment A
for (int j = 0; j < 64; j++)
for (int i = 0; i < 64; i++) X[i][j] = 0;
Fragment B
for (int i = 0; i < 64; i++)
for (int j = 0; j < 64; j++) X[i][j] = 0;
39. Otrzymałeś zlecenie od oferującej usługi w chmurze firmy, która instaluje tysiące ser-
werów na każdym ze swoich centrów danych. W firmie tej ostatnio dowiedziano się, że
lepiej by było obsłużyć błąd strony na serwerze A poprzez odczytanie strony z pamięci
RAM jakiegoś innego serwera niż z lokalnego dysku.
(a) Jak można zrealizować ten pomysł?
(b) W jakich warunkach takie podejście byłoby opłacalne? A w jakich byłoby możliwe?
40. W jednej z pierwszych maszyn z podziałem czasu — PDP-1 — pamięć składała się z 4K
18-bitowych słów. W pamięci zachodził jeden proces na raz. Kiedy program szeregujący
decydował się na uruchomienie innego procesu, proces występujący w pamięci był zapi-
sywany do pamięci bębnowej, przy czym na obwodzie bębna mieściło się 4 K 18-bitowych
słów. Zapis (lub odczyt) bębna mógł się rozpocząć od dowolnego słowa — nie tylko od
słowa 0. Dlaczego Twoim zdaniem zdecydowano się na wybór takiej pamięci bębnowej?
41. Komputer przydziela każdemu procesowi 65 536 bajtów przestrzeni adresowej podzielo-
nej na strony po 4096 bajtów. Pewien program ma rozmiar kodu równy 32 768 bajtów,
danych — 16 386 bajtów i stosu — 15 870 bajtów. Czy ten program zmieści się w prze-
strzeni adresowej? Czy zmieściłby się, gdyby strona miała 512 bajtów, a nie 4096? Należy
pamiętać, że strona musi zawierać tekst, dane lub stos — nie może zawierać kombina-
cji dwóch czy trzech segmentów.
42. Zaobserwowano, że liczba instrukcji wykonanych pomiędzy wystąpieniem dwóch błędów
braku stron jest wprost proporcjonalna do liczby ramek stron zaalokowanych do pro-
gramu. Jeśli ilość dostępnej pamięci podwoi się, średni okres pomiędzy wystąpieniami
błędów braku strony także się podwoi. Przypuśćmy, że normalna instrukcja zajmuje 1 ms,
ale w przypadku wystąpienia błędu braku strony zajmuje ona 2001 μs (tzn. obsługa błędu
zajmuje 2 ms). Jeśli wykonanie programu zajmuje 60 s i podczas tego okresu występuje
15 000 błędów braku stron, to ile czasu zajęłoby wykonanie programu, gdyby było dostępne
dwa razy tyle pamięci?
43. Grupa projektantów systemu operacyjnego z firmy Oszczędne Systemy Komputerowe
próbuje znaleźć sposoby zmniejszenia objętości pamięci zewnętrznej wymaganej przez
tworzony nowy system operacyjny. Lider grupy właśnie zasugerował, aby całkiem zrezy-
gnować z zapisywania tekstu programu w obszarze wymiany, ale stronicować go bezpo-
średnio z pliku binarnego zawsze, kiedy będzie potrzebny. W jakich okolicznościach ta
koncepcja będzie skuteczna w odniesieniu do tekstu programu? A w jakich będzie ona
skuteczna w odniesieniu do danych?
44. Rozkaz maszynowy załadowania 32-bitowego słowa do rejestru zawiera 32-bitowy adres
ładowanego słowa. Jaka jest maksymalna liczba błędów braku stron, jakie może spowo-
dować ta instrukcja?
45. Wyjaśnij różnicę pomiędzy fragmentacją wewnętrzną a zewnętrzną. Która z nich jest
wykorzystywana w systemach stronicowania? A która w systemach z czystą segmentacją?
55. Napisz program, który można wykorzystać do porównania skuteczności dodawania pola
znacznika do wpisów w buforze TLB, gdy sterowanie jest przełączane pomiędzy dwoma
programami. Pole znacznika służy do oznakowania każdego wpisu identyfikatorem procesu.
Należy zwrócić uwagę, że nieoznakowany bufor TLB można zasymulować poprzez
wymaganie, aby wszystkie wpisy w buforze TLB w danej chwili miały ten sam znacznik.
Dane wejściowe:
Liczba dostępnych pozycji w buforze TLB.
Interwał przerwania zegarowego wyrażony w postaci liczby odwołań do pamięci.
Plik zawierający sekwencję pozycji (proces, odwołania do stron).
Koszt aktualizacji jednej pozycji w buforze TLB.
(a) Opisz podstawowe struktury danych i algorytmy w Twojej implementacji.
(b) Pokaż, że symulacja zachowuje się zgodnie z oczekiwaniami dla prostego (ale nie
trywialnego) przykładu danych wejściowych.
(c) Podaj liczbę aktualizacji bufora TLB na 1000 odwołań.
279
4.1. PLIKI
4.1.
PLIKI
Na kilku kolejnych stronach przyjrzymy się plikom z punktu widzenia użytkownika — omó-
wimy sposób ich wykorzystywania oraz opowiemy, jakie mają właściwości.
Wiele systemów operacyjnych obsługuje dwuczłonowe nazwy plików, przy czym poszcze-
gólne człony są od siebie oddzielone kropką, tak jak w nazwie prog.c. Część występująca za
kropką jest określana jako rozszerzenie i zwykle informuje o pewnych właściwościach pliku. I tak
w systemie MS-DOS nazwy plików mają rozmiar od 1 do 8 znaków plus opcjonalnie rozszerze-
nie składające się z 1 do 3 znaków. W systemie UNIX rozmiar rozszerzenia, jeśli takie występuje,
zależy od użytkownika. Plik może nawet mieć dwa rozszerzenia lub większą ich liczbę, np.
stronaglowna.html.zip, gdzie .html oznacza stronę WWW w języku HTML, natomiast .zip wska-
zuje na to, że plik (stronaglowna.html) skompresowano za pomocą programu zip. Bardziej popu-
larne rozszerzenia plików i ich znaczenie zamieszczono w tabeli 4.1.
Rysunek 4.1. Trzy rodzaje plików: (a) sekwencja bajtów; (b) sekwencja rekordów; (c) drzewo
Sytuacja, w której system operacyjny postrzega pliki wyłącznie jako sekwencję bajtów, zapew-
nia największą elastyczność. Programy użytkownika mogą umieszczać w plikach dowolne infor-
macje i nadawać plikom dowolne nazwy. System operacyjny im w tym nie pomaga, ale też i nie
przeszkadza. Dla użytkowników, którzy chcą wykonywać niestandardowe operacje, ta ostatnia
cecha jest bardzo istotna. Model pliku jest stosowany we wszystkich wersjach systemów UNIX
(w tym w Linuksie i OS X) i Windows.
Pierwszy krok w górę tej struktury pokazano na rysunku 4.1(b). W tym modelu plik jest
sekwencją rekordów o stałym rozmiarze — każdy ma pewną wewnętrzną strukturę. Centralne
znaczenie dla koncepcji, według której plik jest sekwencją rekordów, ma to, że operacja odczytu
zwraca jeden rekord, natomiast operacja zapisu nadpisuje lub dołącza jeden rekord. Na margi-
nesie — w ubiegłych dziesięcioleciach, kiedy królowały 80-kolumnowe karty perforowane,
systemy plików wielu systemów operacyjnych (w komputerach mainframe) bazowały na pli-
kach składających się z 80-znakowych rekordów — czyli obrazach kart. Systemy te obsługiwały
również pliki składające się z 132-znakowych rekordów przeznaczonych dla drukarek wier-
szowych (w tamtych czasach były to duże drukarki łańcuchowe o 132 kolumnach). Programy
wczytywały dane wejściowe w blokach po 80 znaków i zapisywały je w blokach po 132 znaki, choć
ostatnie 52 mogły oczywiście być spacjami. Żaden z systemów ogólnego przeznaczenia obecnie
nie stosuje takiego modelu do implementacji podstawowego systemu plików. Jednak w czasach
80-kolumnowych kart perforowanych i papieru drukarek wierszowych o rozmiarze 132 kolumn
był to model powszechnie wykorzystywany na komputerach typu mainframe.
Trzeci rodzaj struktury pliku przedstawiono na rysunku 4.1(c). W tej organizacji plik składa
się z drzewa rekordów, niekoniecznie tej samej długości. Każdy rekord zawiera pole klucza na
stałej pozycji w rekordzie. Drzewo jest posortowane według pola kluczowego. Dzięki temu moż-
liwe staje się wyszukiwanie określonej wartości klucza.
Podstawową operacją w tym przypadku nie jest pobranie „następnego” rekordu, choć to
również jest możliwe, ale pobranie rekordu o określonej wartości klucza. W przypadku pliku
zoo z rysunku 4.1(c) ktoś mógłby zażądać od systemu operacyjnego pobrania rekordu, którego
klucz ma np. wartość kucyk, nie martwiąc się dokładną pozycją tego rekordu w pliku. Co więcej,
do pliku można dodawać nowe rekordy, przy czym to system operacyjny, a nie użytkownik,
decyduje o tym, gdzie je umieścić. Taki typ plików oczywiście bardzo różni się od pozbawionych
struktury strumieni bajtów używanych w Uniksie i Windowsie, ale jest powszechnie używany
w dużych komputerach mainframe ciągle wykorzystywanych w pewnych przemysłowych syste-
mach przetwarzania danych.
Drugi przykład pliku binarnego, także pochodzący z Uniksa, to archiwum. Składa się ono
z kolekcji procedur bibliotecznych (modułów), które są skompilowane, ale nie są połączone.
Każdy jest poprzedzony nagłówkiem zawierającym informacje o nazwie, dacie utworzenia, wła-
ścicielu, kodzie zabezpieczeń i rozmiarze. Tak jak w przypadku pliku wykonywalnego, nagłówki
modułów pełne są liczb binarnych. Skopiowanie ich na drukarkę spowodowałoby powstanie
kompletnie niezrozumiałego ciągu symboli.
Każdy system operacyjny musi rozpoznawać co najmniej jeden typ plików: własny plik wyko-
nywalny. Niektóre systemy rozpoznają jednak więcej. W starym systemie TOPS-20 (dla kom-
putera DECsystem 20) posunięto się tak daleko, że analizowano czas utworzenia dowolnego
pliku przeznaczonego do uruchomienia. Następnie system ten ładował plik źródłowy i sprawdzał,
czy źródło było modyfikowane od czasu utworzenia binariów. Jeśli tak, automatycznie rekom-
pilował kod źródłowy. Zgodnie z zasadami systemu UNIX program make był wbudowany w powłoce.
Rozszerzenia plików były obowiązkowe, dlatego system operacyjny mógł stwierdzić, który pro-
gram binarny pochodził z jakich źródeł.
Taka ścisła kontrola typów stwarza problemy zawsze wtedy, kiedy użytkownik wykona coś,
czego projektanci systemu się nie spodziewają. Jako przykład przeanalizujmy system, w któ-
rym pliki wynikowe programu mają rozszerzenie .dat (pliki danych). Jeśli użytkownik napisze
program formatujący kod źródłowy, który czyta plik .c (program w języku C), przekształca go
(np. poprzez konwersję do standardowego układu z wcięciami), a następnie zapisuje przekształcony
plik jako wynik, to plik wynikowy będzie typu .dat. Jeśli użytkownik spróbuje przekazać ten plik
do kompilatora języka C, system go odrzuci z powodu nieprawidłowego rozszerzenia. Próby
skopiowania pliku plik.dat do pliku plik.c zostaną odrzucone przez system jako niedozwolone
(aby zabezpieczyć użytkowników przed błędami).
Chociaż taki rodzaj „przyjazności dla użytkownika” może pomóc nowicjuszom, stawia doświad-
czonych użytkowników pod ścianą, ponieważ muszą oni włożyć wiele wysiłku w to, by
obejść postrzeganie przez system operacyjny tego, co jest rozsądne, a co nie.
W ten sposób program archiwizujący może stwierdzić, czy pliki wymagają archiwizacji. Flaga
pliku tymczasowego pozwala na zaznaczenie pliku do automatycznego usunięcia w momencie
zakończenia procesu, który utworzył plik.
Pola długości rekordu, pozycji klucza i długości klucza występują tylko w tych plikach, któ-
rych rekordy można wyszukiwać za pomocą klucza. Pola te dostarczają informacji wymaganych
do znalezienia kluczy.
Rozmaite pola czasowe śledzą czas utworzenia pliku, czas ostatniego dostępu oraz ostatniej
modyfikacji. Pola te są przydatne do różnych celów; np. plik źródłowy zmodyfikowany po utwo-
rzeniu odpowiadającego mu pliku obiektowego musi być poddany ponownej kompilacji. Pola cza-
sowe dostarczają niezbędnych informacji pozwalających na wykonywanie tego rodzaju działań.
Pole bieżącego rozmiaru informuje o tym, jak duży jest plik. Niektóre systemy operacyjne
starych komputerów mainframe wymagały podania maksymalnego rozmiaru pliku w momencie
jego tworzenia. Miało to na celu umożliwienie systemowi operacyjnemu zarezerwowania zawczasu
odpowiedniej ilości miejsca. Systemy operacyjne stacji roboczych i komputerów osobistych są
na tyle inteligentne, aby obyć się bez tej właściwości.
10. Set attributes. Niektóre atrybuty mogą być ustawiane przez użytkownika i zmieniane
po utworzeniu pliku. Jest to możliwe dzięki temu wywołaniu systemowemu. Oczywi-
stym przykładem są informacje dotyczące trybu zabezpieczeń. Do tej kategorii można
zaliczyć także większość flag.
11. Rename. Często się zdarza, że użytkownik ma potrzebę zmiany nazwy istniejącego pliku.
Można to zrobić za pomocą tego wywołania systemowego. Nie zawsze jest to niezbędne,
ponieważ plik można skopiować do nowego pliku z nową nazwą, a następnie usunąć
stary plik.
co spowoduje skopiowanie pliku abc do pliku xyz. Jeśli plik xyz wcześniej był na dysku, zostanie
nadpisany. W przeciwnym wypadku będzie utworzony. Program musi być wywołany dokładnie
z dwoma argumentami. Oba muszą być prawidłowymi nazwami plików. Pierwszy argument
oznacza plik źródłowy, drugi — plik wynikowy.
Cztery instrukcje #include na początku programu powodują włączenie wielu definicji i pro-
totypów funkcji. Są one potrzebne po to, aby program był zgodny z odpowiednimi standardami
międzynarodowymi, ale nie będą nas nazbyt interesowały. Następny wiersz zawiera prototyp
funkcji main — jest on wymagany przez standard ANSI C, również nieistotny dla naszych celów.
Pierwsza instrukcja #define jest makrem, które definiuje ciąg znaków BUF_SIZE jako liczbę
4096. Program będzie odczytywał i zapisywał fragmenty po 4096 bajtów. Nadawanie stałym
nazw w taki sposób oraz używanie nazw zamiast stałych jest uważane za dobrą praktykę programi-
styczną. Taka konwencja nie tylko ułatwia czytanie programu, ale jednocześnie ułatwia jego
pielęgnację. Druga instrukcja #define określa, kto może uzyskać dostęp do pliku wynikowego.
Program główny nosi nazwę main i posiada dwa argumenty — argc i argv. Są one dostar-
czane przez system operacyjny w momencie wywołania programu. Pierwszy oznacza liczbę
elementów występujących w wierszu polecenia użytym do wywołania programu, włącznie z nazwą
programu. Powinien on mieć wartość 3. Drugi zawiera tablicę wskaźników do argumentów.
W przykładowym wywołaniu pokazanym powyżej elementy tej tablicy zawierałyby wskaźniki
o następujących wartościach:
argv[0] = "copyfile"
argv[1] = "abc"
argv[2] = "xyz"
identyfikuje plik, drugi zwraca bufor, a trzeci informuje o tym, ile bajtów odczytać. Wartość
przypisana do zmiennej rd_count zwraca liczbę odczytanych bajtów. W normalnych warunkach
będzie to liczba 4096, chyba że w pliku pozostanie mniej bajtów. W przypadku osiągnięcia końca
pliku będzie to wartość 0. Jeśli zmienna rd_count kiedykolwiek osiągnie wartość zero lub wartość
ujemną, operacja kopiowania nie może być kontynuowana. W związku z tym wykonywana jest
instrukcja break, która kończy pętlę (w przeciwnym wypadku byłaby to pętla nieskończona).
Wywołanie funkcji write powoduje wyprowadzenie bufora do pliku docelowego. Pierwszy para-
metr identyfikuje plik, drugi przekazuje bufor, a trzeci informuje o tym, ile bajtów zapisać (analo-
gicznie do funkcji read). Należy zwrócić uwagę, że licznik bajtów oznacza liczbę bajtów faktycznie
odczytanych, a nie wartość stałej BUF_SIZE. Ta uwaga ma istotne znaczenie, ponieważ ostatnia
operacja odczytu nie zwróci 4096 bajtów, o ile rozmiar pliku nie będzie wielokrotnością 4 kB.
Po przetworzeniu całego pliku pierwsze wywołanie poza koniec pliku spowoduje ustawie-
nie zmiennej rd_count na 0, co w efekcie spowoduje wyjście z pętli. W tym momencie nastąpi
zamknięcie obu plików, a program zakończy działanie, zwracając status wskazujący na normalne
zakończenie pracy.
Chociaż wywołania systemowe w Windows różnią się od tych z Uniksa, ogólna struktura
windowsowego programu do kopiowania plików działającego w wierszu polecenia jest podobna
do tego z listingu 4.1. Wywołania dla systemu Windows 8 omówimy w rozdziale 11.
4.2. KATALOGI
4.2.
KATALOGI
Do śledzenia plików systemy plików zwykle wykorzystują katalogi lub inaczej foldery. W wielu
systemach są to również pliki. W tym podrozdziale omówimy katalogi, ich organizację, zabez-
pieczenia oraz operacje, jakie można na nich wykonać.
Windows \usr\ast\mailbox
UNIX /usr/ast/mailbox
MULTICS >usr>ast>mailbox
Niezależnie od użytego znaku, w przypadku gdy pierwszym znakiem nazwy ścieżki jest sepa-
rator, ścieżka jest bezwzględna.
Innym rodzajem są względne nazwy ścieżek. Używa się ich w połączeniu z pojęciem katalogu
roboczego (nazywanego także katalogiem bieżącym). Użytkownik może wskazać jeden katalog
jako bieżący katalog roboczy. W takim przypadku wszystkie nazwy ścieżek, które nie rozpo-
czynają się od katalogu głównego, są interpretowane w odniesieniu do katalogu roboczego. Jeśli
np. bieżącym katalogiem roboczym jest /usr/ast, to do pliku, którego ścieżką bezwzględną jest
/usr/ast/mailbox, można się odwołać poprzez użycie samej nazwy mailbox. Inaczej mówiąc, w przy-
padku gdy katalogiem roboczym jest /usr/ast, polecenie systemu UNIX:
cp /usr/ast/mailbox /usr/ast/mailbox.bak
oraz polecenie:
cp mailbox mailbox.bak
wykonują dokładnie to samo działanie. Ścieżki względne często są wygodniejsze, jednak poza
tym nie ma różnicy, którą formą się posłużymy.
Niektóre programy wymagają dostępu do określonego pliku bez względu na to, jaki jest
bieżący katalog roboczy. W takim przypadku zawsze powinny korzystać z bezwzględnych ście-
żek dostępu. Przykładowo program sprawdzający pisownię, w celu wykonania swojej pracy,
może wymagać czytania katalogu /usr/lib/dictionary. W tym przypadku powinien użyć pełnej,
bezwzględnej ścieżki dostępu, ponieważ program ten nie wie, jaki będzie katalog roboczy
w momencie, gdy zostanie wywołany. Bezwzględna ścieżka dostępu zawsze zadziała, niezależ-
nie od tego, jaki jest katalog roboczy.
Oczywiście jeśli program sprawdzający pisownię potrzebuje dużej liczby plików z katalogu
/usr/lib, alternatywnym podejściem będzie skorzystanie z wywołania systemowego w celu zmiany
katalogu roboczego na /usr/lib, a następnie użycie samej nazwy dictionary w roli pierwszego
parametru funkcji open. Dzięki jawnej zmianie katalogu roboczego program wie na pewno,
gdzie znajduje się w drzewie katalogów, dlatego może posługiwać się ścieżkami względnymi.
Każdy proces posiada własny katalog roboczy, zatem kiedy go zmieni, a następnie zakończy
działanie, nie będzie to miało wpływu na żadne inne procesy, a w systemie plików nie pozostaną
żadne ślady tej zmiany. W związku z tym zmiana katalogu roboczego przez proces, zawsze
kiedy to potrzebne, jest bezpieczna. Z drugiej strony, jeśli procedura biblioteczna zmieni katalog
roboczy i nie zmieni go z powrotem na katalog wyjściowy po zakończeniu swojej pracy, reszta
programu może przestać działać prawidłowo, ponieważ założenia co do bieżącej lokalizacji mogą
być odtąd błędne. Z tego powodu procedury biblioteczne rzadko zmieniają katalog roboczy, a kiedy
muszą to robić, zawsze zmieniają go z powrotem przed przekazaniem sterowania.
W większości systemów operacyjnych, w których wykorzystywany jest hierarchiczny sys-
tem katalogów, występują dwie specjalne pozycje w każdym katalogu — „.” i „..” (kropka i dwie
kropki). Kropka odnosi się do bieżącego katalogu, natomiast dwie kropki do jego katalogu nad-
rzędnego (wyjątkiem są dwie kropki w katalogu głównym, które odnoszą się do niego samego).
Aby sprawdzić, jak posługiwać się tymi specjalnymi pozycjami, rozważmy przykład drzewa plików
w systemie UNIX przedstawionego na rysunku 4.5. Pewien proces wykorzystuje katalog /usr/ast
w roli swojego katalogu roboczego. Aby przejść w górę drzewa, może on wykorzystać symbol ...
Może np. skopiować plik /usr/lib/dictionary do własnego katalogu za pomocą polecenia:
cp ../lib/dictionary .
Pierwsza ścieżka zleca systemowi przejście do wyższego katalogu (jest nim katalog usr), a następ-
nie przejście do katalogu lib w celu znalezienia pliku dictionary.
Drugi argument (kropka) odnosi się do katalogu bieżącego. Kiedy polecenie cp odczyta nazwę
katalogu (łącznie z kropką) w roli ostatniego argumentu, skopiuje do tego katalogu wszystkie
pliki. Oczywiście bardziej naturalnym sposobem wykonania kopii byłoby posłużenie się pełną,
bezwzględną ścieżką do pliku źródłowego:
cp /usr/lib/dictionary .
1. Create. Utworzenie katalogu. Katalog po utworzeniu jest pusty. Zawiera jedynie kropkę
i dwie kropki, które system (lub w niektórych przypadkach polecenie mkdir) umieszcza
w nim automatycznie.
2. Delete. Usunięcie katalogu. Można usunąć tylko pusty katalog. Katalog zawierający tylko
kropkę i dwie kropki jest uważany za pusty, ponieważ pozycji tych zwykle nie można
usunąć.
3. Opendir. Umożliwienie czytania katalogu. Aby np. wyświetlić listę plików w katalogu,
program, który to robi, musi otworzyć katalog w celu przeczytania wszystkich nazw
plików, które on zawiera. Aby katalog mógł być czytany, musi być otwarty, analogicznie
do otwierania i czytania pliku.
4. Closedir. Po przeczytaniu katalogu należy go zamknąć, aby zwolnić miejsce w wewnętrz-
nych tabelach.
5. Readdir. To wywołanie zwraca następną pozycję w otwartym katalogu. Dawniej można
było czytać katalogi z wykorzystaniem standardowego wywołania systemowego read,
ale to podejście ma wadę — zmusza programistę do znajomości wewnętrznej struktury
katalogów. Dla odróżnienia wywołanie readdir zawsze zwraca jedną pozycję w standardo-
wym formacie, niezależnie od tego, która z możliwych struktur katalogów jest używana.
6. Rename. Pod wieloma względami katalogi zachowują się dokładnie tak jak pliki. W związku
z tym można im zmieniać nazwy tak samo jak plikom.
7. Link. Dowiązania są techniką pozwalającą na to, by plik był wyświetlany w więcej niż
jednym katalogu. To wywołanie systemowe specyfikuje istniejący plik i nazwę ścieżki,
a następnie tworzy dowiązanie z istniejącego pliku do pliku określonego przez ścieżkę.
W taki sposób ten sam plik może być wyświetlany w wielu katalogach. Dowiązanie tego
rodzaju — zwiększające licznik w i-węźle pliku (w celu śledzenia liczby katalogów zawie-
rających ten plik) — czasami jest nazywane dowiązaniem twardym.
8. Unlink. Usunięcie katalogu. Jeśli usuwany plik występuje tylko w jednym katalogu (stan-
dardowy przypadek), jest on usuwany z systemu plików. Jeśli plik występuje w więcej
niż jednym katalogu, usuwana jest tylko wprowadzona ścieżka dostępu. Inne pozostają
nienaruszone. W systemie UNIX wywołaniem systemowym do usuwania plików (które
omawialiśmy wcześniej) jest właśnie unlink.
Powyższa lista prezentuje najważniejsze wywołania, ale istnieją również inne — np. do zarzą-
dzania informacjami o zabezpieczeniach powiązanymi z katalogami.
Wariantem koncepcji dowiązań są dowiązania symboliczne. Zamiast wykorzystywać dwie
nazwy wskazujące na tę samą wewnętrzną strukturę danych reprezentującą plik, można stworzyć
nazwę wskazującą niewielki plik z nazwą innego pliku. Kiedy pierwszy z plików jest używany
(np. otwarty), to system plików podąża za ścieżką i w końcu znajduje nazwę. Następnie rozpo-
czyna proces wyszukiwania, wykorzystując nową nazwę. Zaletą dowiązań symbolicznych jest
to, że pozwalają one na przekraczanie granic pojedynczego dysku. W ten sposób można odwo-
ływać się do plików umieszczonych nawet na zdalnych komputerach. Ich implementacja jest
jednak nieco mniej wydajna od implementacji twardych dowiązań.
Dalej mogą występować informacje o blokach wolnych w systemie plików — np. w formie
mapy bitowej lub listy wskaźników. Za nimi mogą znajdować się i-węzły — tablica struktur
danych, po jednej dla każdego pliku, która zawiera wszystkie informacje na jego temat. Następnie
jest katalog główny — wierzchołek drzewa systemu plików. Pozostała część dysku zawiera
wszystkie inne katalogi i pliki.
Ciągła alokacja
Najprostszy mechanizm alokacji polega na zapisaniu każdego pliku w postaci ciągłego zbioru
bloków dyskowych. Tak więc na dysku zawierającym 1-kilobajtowe bloki plikowi o rozmiarze
50 kB zostałoby zaalokowanych 50 kolejnych bloków. W przypadku bloków 2-kilobajtowych
potrzebnych byłoby tylko 25 kolejnych bloków.
Przykład ciągłej alokacji miejsca na dysku pokazano na rysunku 4.7(a). W tym przypadku
pokazano pierwsze 40 bloków dyskowych, począwszy od bloku 0 z lewej strony. Początkowo
dysk był pusty. Następnie na dysku został zapisany plik A o długości czterech bloków. Plik ten
został umieszczony na początku (blok 0). Za nim zapisano plik B o długości sześciu bloków, który
rozpoczyna się bezpośrednio za końcem pliku A.
Rysunek 4.7. (a) Ciągła alokacja miejsca na dysku dla siedmiu plików; (b) stan dysku po usunięciu
plików D i F
Zwróćmy uwagę, że każdy plik rozpoczyna się na początku nowego bloku, a zatem gdyby
plik A miał tylko 31/2 bloku, pewna część miejsca na końcu ostatniego bloku byłaby zmarno-
trawiona. Na rysunku pokazano w sumie siedem plików. Każdy z nich rozpoczyna się od bloku
następującego bezpośrednio za końcowym blokiem pliku poprzedniego. Cieniowanie wykorzy-
stano po to, aby ułatwić rozróżnienie plików od siebie. Jeśli chodzi o sposób przechowywania infor-
macji, nie ma on znaczenia.
Ciągła alokacja miejsca na dysku ma dwie istotne zalety. Po pierwsze jest prosta do zaim-
plementowania, ponieważ śledzenie lokalizacji bloków należących do pliku sprowadza się do
pamiętania dwóch liczb: adresu dyskowego pierwszego bloku oraz liczby bloków w pliku. Jeśli
zna się numer pierwszego bloku, można wyliczyć numer dowolnego innego bloku z wykorzy-
staniem prostego dodawania.
Po drugie wydajność odczytu jest doskonała, ponieważ cały plik można wczytać z dysku
w pojedynczej operacji. Potrzebne jest tylko jedno wywołanie operacji seek (do pierwszego bloku).
Po jej wykonaniu nie są potrzebne dalsze operacje seek ani opóźnienia związane z obrotami,
więc dane z dysku są przesyłane z pełną przepustowością. A zatem ciągła alokacja jest prosta do
zaimplementowania i charakteryzuje się wysoką wydajnością.
Niestety, ciągła alokacja ma także znaczącą wadę: z czasem dysk ulega fragmentacji. Aby
zobaczyć, jak do tego dochodzi, wystarczy przeanalizować sytuację z rysunku 4.7(b). W tym przy-
padku usunięto dwa pliki: D i F. W momencie usunięcia pliku jego bloki są w sposób naturalny
zwalniane, co powoduje pozostawienie ciągu wolnych bloków na dysku. Dysk nie jest podda-
wany natychmiastowemu kompaktowaniu w celu ścieśnienia luki, ponieważ wiązałoby się to
z koniecznością kopiowania wszystkich bloków występujących za luką — potencjalnie może
ich być wiele milionów. W rezultacie dysk po jakimś czasie zaczyna składać się z plików i luk,
tak jak pokazano na rysunku.
Początkowo taka fragmentacja nie stanowi problemu, ponieważ każdy nowy plik można zapi-
sać na końcu dysku, bezpośrednio za plikiem poprzednim. W końcu jednak dysk się zapełni
i stanie się konieczne skompaktowanie dysku (co jest bardzo kosztowne czasowo) lub wykorzy-
stanie wolnego miejsca w lukach. Wykorzystanie wolnego miejsca w lukach wymaga utrzy-
mywania listy luk, co nie jest trudne. Kiedy jednak trzeba utworzyć nowy plik, konieczna staje
się znajomość jego końcowego rozmiaru, tak by można było wybrać lukę o rozmiarze pozwa-
lającym na umieszczenie w niej pliku.
Spróbujmy wyobrazić sobie konsekwencje takiego projektu. Użytkownik uruchamia proce-
sor tekstu w celu sporządzenia dokumentu. Pierwsza rzecz, o którą program zapyta, to liczba
bajtów, jaką będzie miał wynikowy dokument. Jeśli użytkownik nie odpowie na to pytanie, pro-
gram nie będzie dalej działał. Jeśli podana liczba w efekcie końcowym okaże się zbyt mała,
program będzie musiał się przedwcześnie zakończyć, ponieważ luka na dysku się zapełni i nie
będzie miejsca na pozostałą część pliku. Jeśli użytkownik spróbuje uniknąć tego problemu poprzez
podanie nierealistycznie dużego ostatecznego rozmiaru, np. 100 MB, edytor może mieć kło-
poty ze znalezieniem tak dużej luki i zgłosi informację, że pliku nie można utworzyć. Oczywiście
użytkownik będzie mógł uruchomić program jeszcze raz i tym razem podać wartość 50 MB —
może postępować w ten sposób tak długo, aż znajdzie odpowiednią lukę. Także w tym przy-
padku użycie takiego mechanizmu raczej nie uszczęśliwi użytkowników.
Istnieje jednak pewna sytuacja, w której ciągła alokacja jest dopuszczalna i w rzeczywisto-
ści często stosowana: na płytach CD-ROM. W tym przypadku rozmiar jest z góry znany i nigdy
się nie zmienia w czasie wykorzystywania systemu plików na płycie CD-ROM. Najbardziej popu-
larny system plików na płycie CD-ROM przeanalizujemy w dalszej części niniejszego rozdziału.
Sytuacja z płytami DVD jest nieco bardziej skomplikowana. 90-minutowy film w zasadzie
można zakodować jako pojedynczy plik o rozmiarze około 4,5 GB, jednak system plików wyko-
rzystywany na płytach DVD — UDF (Universal Disk Format) wykorzystuje 30-bitową liczbę
do reprezentowania długości pliku, co ogranicza rozmiar plików do 1 GB. W konsekwencji filmy
DVD zazwyczaj są zapisywane w postaci trzech lub czterech 1-gigabajtowych plików, z których
każdy jest ciągły. Te fizyczne części pojedynczego pliku logicznego (filmu) są określane mianem
obszarów (ang. extent).
W odróżnieniu od algorytmu ciągłej alokacji w tej metodzie mogą być używane dowolne
bloki dysku. Problem marnotrawienia miejsca na dysku z powodu fragmentacji nie występuje
(poza wewnętrzną fragmentacją w ostatnim bloku). Poza tym wystarczy, jeśli wpis w katalogu
będzie zawierał adres dyskowy tylko pierwszego bloku. Resztę można znaleźć, począwszy od
wskazanego miejsca.
Z drugiej strony, choć sekwencyjny odczyt pliku jest prosty, dostęp losowy okazuje się bardzo
wolny. Aby dostać się do bloku n, system operacyjny musi zacząć od początku i przeczytać
wcześniej jeden po drugim n–1 bloków. Wykonywanie tak wielu operacji odczytu jest, z oczywi-
stych względów, bardzo wolne.
Ponadto ilość miejsca na dane w bloku nie jest już potęgą liczby dwa, ponieważ kilka bajtów
zajmuje wskaźnik. Niestandardowy rozmiar nie jest co prawda bardzo poważnym problemem,
ale powoduje spadek wydajności, ponieważ wiele programów czyta i zapisuje dane w blokach,
których rozmiar jest potęgą liczby dwa. Ze względu na to, że w każdym bloku pierwszych kilka
bajtów zajmuje wskaźnik do następnego bloku, odczyt bloku o pełnym rozmiarze wymaga wydo-
bycia i konkatenacji informacji pochodzących z dwóch bloków dyskowych. Ze względu na koniecz-
ność kopiowania operacja ta generuje dodatkowe koszty czasowe.
Dzięki wykorzystaniu takiej organizacji cały blok jest dostępny dla danych. Co więcej, losowy
dostęp okazuje się znacznie łatwiejszy. Chociaż znalezienie potrzebnego przesunięcia w pliku
ciągle wymaga podążania wzdłuż łańcucha, łańcuch jest w całości umieszczony w pamięci. Podob-
nie jak w poprzedniej metodzie, w tym przypadku także wystarczy, aby wpis w katalogu zawie-
rał tylko jedną liczbę całkowitą (numer początkowego bloku). To zupełnie wystarczy do zlokali-
zowania wszystkich bloków, niezależnie od tego, jak duży jest plik.
Główna wada tej metody polega na tym, że aby metoda zadziałała, cała tabela musi przez
cały czas znajdować się w pamięci. W przypadku 1-terabajtowego dysku i 1-kilobajtowych blo-
ków tabela wymaga miliarda wpisów — po jednym dla każdego z miliarda bloków. Każdy wpis to
minimum 3 bajty. Aby wyszukiwanie zostało przyspieszone, wpisy muszą mieć rozmiar 4 baj-
tów. Tak więc tabela przez cały czas będzie zajmowała 3 GB lub 2,4 GB, w zależności od tego,
czy system jest zoptymalizowany pod kątem miejsca w pamięci, czy czasu. Nie jest to zbyt dobre
rozwiązanie. Wyraźnie widać, że koncepcja tablic FAT nie skaluje się dobrze w odniesieniu do
dużych dysków. To był pierwotny system plików MS-DOS, który nadal jest w pełni obsługi-
wany we wszystkich wersjach systemu Windows.
I-węzły
Ostatnią metodą śledzenia tego, który z bloków należy do którego pliku, jest powiązanie z każ-
dym plikiem struktury danych nazywanej i-węzłem (węzłem indeksowym), w której są zapisane atry-
buty i adresy dyskowe bloków należących do pliku. Prosty przykład pokazano na rysunku 4.10.
Na podstawie i-węzła można znaleźć wszystkie bloki należące do pliku.
Rysunek 4.11. (a) Prosty katalog zawierający wpisy o stałym rozmiarze zawierające adresy
dyskowe i atrybuty; (b) katalog w każdym wpisie odnosi się do i-węzła
Rysunek 4.12. Dwa sposoby obsługi długich nazw plików w katalogu; (a) razem z innymi
atrybutami; (b) na stercie
Wada tej metody polega na tym, że kiedy plik zostanie usunięty, w katalogu pozostaje luka
o zmiennym rozmiarze, w której może się nie zmieścić następny plik wprowadzany do katalogu.
Problem ten jest analogiczny do tego, z jakim mieliśmy do czynienia w przypadku ciągłych plików
dyskowych. Teraz jednak kompaktowanie katalogu jest wykonalne, ponieważ katalog w całości
mieści się w pamięci. Inny problem polega na tym, że pojedynczy wpis w katalogu może obejmować
wiele stron, zatem podczas czytania nazwy pliku może dojść do wystąpienia błędu braku strony.
Innym sposobem poradzenia sobie z nazwami o zmiennej długości jest zastosowanie wpi-
sów w katalogu o stałej długości oraz utrzymywanie nazw plików na stercie na końcu katalogu,
tak jak pokazano na rysunku 4.12(b). Taka metoda ma tę zaletę, że w przypadku usunięcia wpisu
następny wprowadzany plik zawsze mieści się w luce. Oczywiście stertą trzeba zarządzać, a pod-
czas przetwarzania nazw plików mogą wystąpić błędy braku stron. W tym przypadku pewną
zaletą jest to, że nie istnieje już realna potrzeba, by nazwy plików zaczynały się w granicach
słowa, dlatego na rysunku 4.12(b) nie widzimy znaków wypełniaczy za nazwami plików, tak jak
na rysunku 4.12(a).
Rysunek 4.14. (a) Sytuacja poprzedzająca tworzenie dowiązania; (b) sytuacja po stworzeniu
dowiązania; (c) sytuacja po usunięciu pliku przez właściciela
Jeśli użytkownik C spróbuje usunąć plik, system staje przed problemem. Jeśli usunie plik
i wyczyści i-węzeł, użytkownik B będzie dysponował wpisem do katalogu wskazującym na niepra-
widłowy i-węzeł. Jeśli i-węzeł zostanie później przypisany do innego pliku, dowiązanie użytkownika
B będzie wskazywało na nieprawidłowy plik. Na podstawie licznika w i-węźle system może stwier-
dzić, że plik jest w dalszym ciągu używany, ale nie istnieje łatwy sposób znalezienia wszystkich
Projektowi LFS przyświeca idea, zgodnie z którą w miarę powstawania coraz szybszych
procesorów i rozrastania się pamięci RAM pamięci podręczne również gwałtownie się rozra-
stają. W konsekwencji jest możliwa obsługa bardzo znaczącej części żądań odczytu bezpośrednio
z pamięci podręcznej systemu plików — bez konieczności dostępu do dysku. Wynika to z obser-
wacji, że w przyszłości większość operacji dostępu do dysku będą stanowiły zapisy, zatem mecha-
nizm czytania zawczasu wykorzystywany w niektórych systemach plików w celu pobrania blo-
ków, zanim będą potrzebne, nie powoduje już istotnego wzrostu wydajności.
Na domiar złego w większości systemów plików operacje zapisu są wykonywane w bardzo
niewielkich fragmentach. Zapis niewielkich fragmentów staje się bardzo nieefektywny, ponie-
waż trwający 50 μs zapis na dysk często poprzedza operacja wyszukiwania trwająca 10 ms oraz
opóźnienie spowodowane obrotem, trwające 4 ms. Przy takich parametrach sprawność dysku
spada do mniej niż 1%.
Aby się przekonać, skąd pochodzą wszystkie operacje zapisu małych fragmentów, rozważmy
tworzenie nowego pliku w systemie operacyjnym UNIX. W celu zapisania tego pliku trzeba
zapisać i-węzeł katalogu, blok katalogu, i-węzeł pliku oraz sam plik. Chociaż te operacje zapisu
można opóźnić, podjęcie takiej decyzji naraziłoby system plików na poważne problemy spójności,
gdyby wystąpiła awaria przed wykonaniem zapisu. Z tego powodu zapisy i-węzłów zwykle są
wykonywane natychmiast.
Wziąwszy pod uwagę przytoczone powyżej uwarunkowania, projektanci systemu LFS zde-
cydowali się na zmodyfikowanie implementacji systemu plików w systemie UNIX w taki sposób,
aby wykorzystać pełną przepustowość dysku — nawet w warunkach obciążenia składającego
się w większości z losowych operacji zapisu o małej objętości. Podstawowa idea polega na nada-
niu całemu dyskowi struktury dziennika. Okresowo oraz wtedy, gdy występuje specjalna potrzeba,
wszystkie zaległe operacje zapisu zbuforowane w pamięci są zbierane i zapisywane na dysku
w postaci pojedynczego ciągłego segmentu na końcu dziennika. Segment ten zawiera zatem
pomieszane ze sobą i-węzły, bloki katalogów oraz bloki danych. Na początku każdego segmentu
jest spis, który informuje, co można znaleźć w tym segmencie. Jeśli rozmiar przeciętnego seg-
mentu ustawi się na około 1 MB, to można wykorzystać prawie całą przepustowość dysku.
W tym projekcie i-węzły w dalszym ciągu istnieją i mają taką samą strukturę jak w Uniksie,
ale są rozproszone w całym dzienniku, zamiast być zapisane pod stałą pozycją na dysku. Nie-
mniej jednak podczas wyszukiwania i-węzła bloki są wyszukiwane w zwykły sposób. Oczywiście
znalezienie i-węzła jest teraz znacznie trudniejsze, ponieważ nie można, tak jak w Uniksie,
wyliczyć adresu na podstawie numeru i-węzła. Aby można było znaleźć i-węzły, system plików
utrzymuje mapę i-węzłów poindeksowaną według numeru i-węzła. Pozycja i w tej mapie
wskazuje na i-węzeł na dysku. Mapa jest utrzymywana na dysku, ale pozostaje także w pamię-
ci podręcznej, dlatego najczęściej używane fragmenty przez większość czasu są w pamięci.
Podsumujmy to, co powiedzieliśmy do tej pory: wszystkie operacje zapisu są początkowo
buforowane w pamięci, a okresowo wszystkie zbuforowane operacje zapisu są zapisywane na
dysku w postaci pojedynczego segmentu — na końcu dziennika. Podczas otwierania pliku naj-
pierw wykorzystywana jest mapa w celu zlokalizowania i-węzła pliku. Kiedy i-węzeł zostanie
zlokalizowany, można z niego odczytać adresy bloków. Wszystkie bloki są zapisywane w seg-
mentach, w pewnym miejscu dziennika.
Gdyby pojemność dysków była nieskończenie duża, powyższy opis stanowiłby koniec opo-
wieści. Fizyczne dyski są jednak skończone, dlatego w końcu dziennik będzie zajmował cały
dysk. W tym momencie nie będzie można do niego dopisać nowych segmentów. Na szczęście
wiele istniejących segmentów może zawierać bloki, które nie są już potrzebne — np. jeśli plik
zostanie nadpisany, to jego i-węzeł będzie wskazywał na nowe bloki, ale stare w dalszym ciągu
będą zajmowały miejsce w segmentach zapisanych wcześniej.
W celu rozwiązania tego problemu w systemie LFS występuje wątek sprzątacza (ang. cle-
aner), który cyklicznie skanuje dziennik, aby go skompaktować. Rozpoczyna od odczytania spisu
na początku pierwszego segmentu w dzienniku, aby zobaczyć, jakie i-węzły i pliki można w nim
znaleźć. Następnie sprawdza bieżącą mapę i-węzłów, aby zobaczyć, czy i-węzły są aktualne
oraz czy bloki plików są w dalszym ciągu wykorzystywane. Jeśli nie, informacje te są porzucane.
I-węzły i bloki, które pozostają w dalszym ciągu w użyciu, są ładowane do pamięci w celu zapi-
sania w następnym segmencie. Oryginalny segment jest następnie oznaczany jako wolny, dzięki
czemu dziennik może go wykorzystać do zapisu nowych danych. W ten sposób sprzątacz poru-
sza się wzdłuż dziennika, wyrzucając stare segmenty z końca dziennika i ładując aktywne dane
do pamięci w celu zapisania w następnym segmencie. W konsekwencji dysk jest dużym cyklicz-
nym buforem z wątkiem zapisu, który dodaje nowe segmenty na początku, oraz z wątkiem
sprzątacza, który usuwa stare segmenty z końca.
Rejestracja transakcji nie jest w tym przypadku trywialna, ponieważ jeśli blok pliku zosta-
nie zapisany do nowego segmentu, to trzeba zlokalizować i-węzeł pliku (znajdujący się gdzieś
w dzienniku), zaktualizować go oraz umieścić w pamięci w celu zapisania go w następnym seg-
mencie. Następnie trzeba zaktualizować mapę i-węzłów, tak by wskazywała na nową kopię.
Niemniej jednak wykonanie operacji administracyjnych jest możliwe, a efekty wydajnościowe
pokazują, że ta złożoność się opłaca. Z pomiarów opisanych w artykułach przytoczonych powy-
żej wynika, że system plików LFS jest wydajniejszy od standardowego systemu plików Uniksa
o rząd wielkości w przypadku zapisu małych fragmentów oraz jest tak samo wydajny lub nawet
wydajniejszy od standardowego systemu plików UNIX dla operacji odczytu oraz zapisu dużych
fragmentów.
funkcji, które system VFS może skierować do poszczególnych systemów plików w celu wyko-
nania pracy. Aby zatem stworzyć nowy system plików, który działa z VFS, projektanci nowego
systemu plików muszą się upewnić, że obsługuje on wszystkie wywołania funkcji, których
wymaga system VFS. Oczywistym przykładem takiego wywołania jest funkcja, która odczytuje
specyficzny blok z dysku, umieszcza go w podręcznej pamięci buforowej i zwraca wskaźnik,
który do niego prowadzi. Tak więc system VFS ma dwa odrębne interfejsy: wyższy do procesów
użytkownika i niższy do konkretnych systemów plików.
O ile większość systemów plików działających pod kontrolą VFS reprezentuje partycje na
lokalnym dysku, o tyle nie zawsze tak jest. Pierwotną motywacją, dla której firma Sun stwo-
rzyła system VFS, była obsługa zdalnych systemów plików z wykorzystaniem protokołu NFS
(od ang. Network File System). Zgodnie z projektem systemu VFS, o ile konkretny system plików
dostarcza funkcji wymaganych przez VFS, system ten nie wie i nie dba o to, gdzie dane są zapi-
sane, ani o to, jaki system plików nimi zarządza.
Wewnętrznie większość implementacji VFS wykorzystuje strukturę obiektową, pomimo
że są one napisane w języku C, a nie C++. Standardowo obsługiwanych jest kilka kluczowych
typów obiektowych. Należą do nich blok identyfikacyjny (ang. superblock) opisujący system
plików, v-węzeł (opisujący plik) oraz katalog (opisujący katalog systemu plików). Z każdym
z nich są powiązane operacje (metody), które muszą obsługiwać konkretne systemy plików.
Dodatkowo system VFS ma pewne wewnętrzne struktury danych do własnego użytku. Są to
m.in. tablica montowania oraz tablica deskryptorów plików służąca do śledzenia wszystkich otwar-
tych plików w procesach użytkownika.
Aby zrozumieć sposób działania systemu VFS, spróbujmy krok po kroku przeanalizować
przykład. Podczas uruchamiania w systemie VFS rejestruje się główny system plików. Dodat-
kowo w momencie montowania innych systemów plików — w czasie startu systemu lub pod-
czas działania — one również muszą się zarejestrować w systemie VFS. Rejestracja systemu
plików w gruncie rzeczy polega na dostarczeniu listy adresów funkcji wymaganych przez sys-
tem VFS — w zależności od wymagań w postaci jednego długiego wektora wywołań (tablicy)
lub kilku wektorów, po jednym dla każdego obiektu VFS. Kiedy zatem system plików zareje-
struje się w systemie VFS, system ten wie, jak — przykładowo — przeczytać z niego blok. Po
prostu wywołuje odpowiednią funkcję w wektorze dostarczonym przez system plików. Na podob-
nej zasadzie system VFS wie również, w jaki sposób wykonać wszystkie inne funkcje, które
musi dostarczyć konkretny system plików: po prostu wywołuje funkcję, której adres został
dostarczony podczas rejestracji systemu plików.
Po zamontowaniu systemu plików można z niego skorzystać. Jeśli np. system plików został
zamontowany w katalogu /usr, a proces wykona następujące wywołanie:
open("/usr/include/unistd.h", O_RDONLY)
podczas analizy składniowej ścieżki, to system VFS zobaczy, że zamontowano system plików
w katalogu /usr i zlokalizuje jego blok identyfikacyjny poprzez przeszukanie listy bloków
identyfikacyjnych zamontowanych systemów plików. Po wykonaniu tej czynności system VFS
odnajdzie katalog główny zamontowanego systemu plików i poszuka w niej ścieżki include/unistd.h.
Następnie system VFS stworzy v-węzeł i odwoła się do konkretnego systemu plików, a ten
zwróci informacje w i-węźle pliku. Informacje te zostaną następnie skopiowane do v-węzła
(w pamięci RAM), razem z innymi danymi, przede wszystkim wskaźnikiem do tablicy funkcji
potrzebnych do wywoływania operacji na v-węzłach — read, write, close itp.
Po utworzeniu v-węzła system VFS tworzy wpis w tablicy deskryptorów pliku, dotyczący
procesu wywołującego, i ustawia go w taki sposób, by wskazywał na nowy v-węzeł (puryści
pewnie zauważą, że deskryptor pliku właściwie wskazuje na inną strukturę danych zawierającą
bieżącą pozycję pliku oraz wskaźnik do v-węzła, ale te szczegóły nie są ważne dla naszych celów).
Na koniec system VFS zwraca deskryptor pliku do procesu wywołującego. Dzięki temu może
go wykorzystać do czytania, pisania i zamykania pliku.
Później, kiedy proces będzie realizował odczyt za pomocą deskryptora pliku, system VFS
zlokalizuje v-węzeł z procesu i tabel deskryptora i podąży za wskaźnikiem do tablicy funkcji.
Wszystkie one są adresami wewnątrz konkretnego systemu plików, w którym rezyduje żądany
plik. W tym momencie system wywołuje funkcję obsługującą odczyt, a kod wewnątrz konkret-
nego systemu plików odczytuje żądany blok. System VFS nie ma pojęcia, czy dane pochodzą
z lokalnego dysku, zdalnego systemu plików w sieci, napędu CD-ROM, dysku pendrive, czy
jakiegoś innego nośnika. Wykorzystywane struktury danych zaprezentowano na rysunku 4.16.
Począwszy od numeru procesu wywołującego oraz deskryptora pliku, lokalizowane są po kolei
v-węzeł, wskaźnik do funkcji odczytującej oraz funkcji dostępu w obrębie konkretnego systemu
plików.
Rysunek 4.16. Uproszczony widok struktur danych i kodu używany przez system VFS
oraz specyficzny system plików w celu realizacji odczytu
Dzięki takiej strukturze dodawanie nowych systemów plików jest stosunkowo proste.
Przed stworzeniem systemu plików jego projektanci najpierw pobierają listę wywołań funkcji
oczekiwanych przez system VFS, a następnie piszą swój system plików, który je wszystkie
dostarcza. Alternatywnie, jeśli system plików już istnieje, muszą oni dostarczyć funkcje opa-
kowujące, wykonujące operacje oczekiwane przez system VFS. W tym celu wykonują jedno
rodzime wywołanie konkretnego systemu plików lub kilka takich wywołań.
Stworzenie działającego systemu plików to jedno, a stworzenie takiego systemu plików, który
działa wydajnie i spełnia swoją rolę w praktyce, to coś całkiem innego. W poniższych punktach
przyjrzymy się niektórym problemom związanym z zarządzaniem dyskami.
Rozmiar bloku
Po podjęciu decyzji o zapisaniu plików w blokach o stałym rozmiarze powstaje pytanie o to, jak
duże powinny być bloki. Jeśli wziąć pod uwagę sposób, w jaki są zorganizowane dyski, oczywi-
stymi kandydatami na jednostki alokacji wydają się sektor, ścieżka i cylinder (choć wszystkie
one są zależne od urządzenia, co jest ich minusem). W systemie ze stronicowaniem ważnym
konkurentem jest również rozmiar strony.
Wybór bloku o dużych rozmiarach oznacza, że każdy plik, nawet 1-bajtowy, zajmuje cały
cylinder. Oznacza to również, że niewielkie pliki marnotrawią duże ilości miejsca na dysku.
Z drugiej strony niewielki rozmiar bloku oznacza, że większość plików zajmuje wiele bloków.
W związku z tym ich odczyt wymaga wielu operacji wyszukiwania. Do tego dochodzą opóźnie-
nia związane z obrotami dysku, a to wpływa na obniżenie wydajności. A zatem jeśli jednostka
alokacji jest zbyt duża, tracimy miejsce na dysku, jeśli jest zbyt mała, tracimy czas.
Dokonanie dobrego wyboru wymaga posiadania informacji na temat dystrybucji rozmiaru
plików. W pracy [Tanenbaum et al., 2006] przedstawiono badania nad dystrybucją rozmiaru
plików na Wydziale Informatyki dużego uniwersytetu (Uniwersytet Stanu Virginia) w 1984 roku
i później w 2005, a także na komercyjnym serwerze WWW, na którym działał serwis polityczny
(www.electoral-vote.com). Wyniki pokazano w tabeli 4.3. Każdemu rozmiarowi plików odpowia-
dającemu potędze liczby dwa przyporządkowano procent wszystkich plików mniejszych lub
równych dla każdego z trzech zestawów danych. Przykładowo w 2005 roku 59,13% plików
w systemie Uniwersytetu Virginia miało rozmiar 4 kB lub mniejszy, a 90,84% wszystkich plików
miało rozmiar 64 kB lub mniejszy. Średni rozmiar pliku wynosił 2475 bajtów. Niektórym osobom
ten mały rozmiar plików może się wydawać zaskakujący.
Jakie wnioski można wyciągnąć na podstawie tych danych? Z jednej strony przy bloku
o rozmiarze 1 kB tylko 30 – 50% wszystkich plików zmieści się w pojedynczym bloku, podczas
gdy w przypadku bloku o rozmiarze 4 kB procent plików, które mieszczą się w bloku, wzrasta
do poziomu 60 – 70%. Inne dane zamieszczone w artykule pokazują, że przy 4-kilobajtowym
bloku 93% bloków dyskowych jest używanych przez 10% największych plików. Oznacza to, że
marnotrawstwo pewnej ilości miejsca na końcu każdego z małych plików ma niewielkie zna-
czenie, ponieważ dysk jest wypełniony wieloma dużymi plikami (klipami wideo), a całkowita
ilość miejsca zajmowanego przez niewielkie pliki prawie wcale nie ma znaczenia. Nawet podwo-
jenie miejsca zajmowanego przez 90% najmniejszych plików byłoby ledwie dostrzegalne.
Z drugiej strony wykorzystywanie niewielkich bloków oznacza, że każdy plik będzie się
składał z wielu bloków. Odczyt każdego bloku standardowo wymaga wykonania operacji wyszu-
kiwania oraz jest związany z opóźnieniem spowodowanym obrotami dysku. W związku z tym
odczyt pliku składającego się z wielu małych bloków będzie wolny.
Dla przykładu rozważmy sytuację dysku o pojemności 1 MB na ścieżkę, z czasem obrotu
8,33 ms oraz średnim czasem wyszukiwania 5 ms. Czas odczytania bloku k bajtów (w milise-
kundach) będzie sumą opóźnień związanych z wyszukiwaniem, obrotami oraz transferem:
5+4,165+(k/1000000)×8,33
Ciągła krzywa z rysunku 4.17 pokazuje szybkość czytania danych dla takiego dysku w funkcji
rozmiaru bloku. W celu obliczenia wydajności miejsca na dysku trzeba przyjąć założenie doty-
czące średniego rozmiaru pliku. Dla uproszczenia przyjmijmy, że wszystkie pliki mają rozmiar
4 kB. Chociaż liczba ta jest nieco większa od danych zmierzonych na Uniwersytecie Virginia,
studenci prawdopodobnie mają więcej małych plików, niż można znaleźć w centrum oblicze-
niowym skali przemysłowej, zatem taka wartość będzie lepszym przybliżeniem. Linia przery-
wana na rysunku 4.17 pokazuje ekonomiczność wykorzystania miejsca na dysku w funkcji
rozmiaru bloku.
Rysunek 4.17. Krzywa przerywana (skala z lewej strony) dotyczy szybkości transferu danych
dysku; krzywa ciągła (skala z prawej strony) dotyczy ekonomiczności wykorzystania miejsca
na dysku; wszystkie pliki mają rozmiar 4 kB
Vogels przeprowadził badania mające na celu sprawdzenie, czy wykorzystanie plików w sys-
temie Windows NT różni się od wykorzystania plików w systemie UNIX. W związku z tym
wykonał pomiary plików na Uniwersytecie Cornell [Vogels, 1999]. Zaobserwował, że korzysta-
nie z plików w systemie Windows NT jest bardziej skomplikowane niż w Uniksie. Sformułował
następujący wniosek:
Kiedy wpiszemy kilka znaków w edytorze tekstu Notatnik, to próba zapisania go w pliku
spowoduje zainicjowanie 26 wywołań systemowych, włącznie z 3 nieudanymi próbami
otwarcia pliku, 1 nadpisaniem pliku oraz 4 dodatkowymi sekwencjami otwierania i zamy-
kania.
Zaobserwował on średni rozmiar plików tylko odczytywanych — 1 kB, plików tylko zapi-
sywanych — 2,3 kB, a plików odczytywanych i zapisywanych — 4,2 kB. Jeśli wziąć pod uwagę
różne techniki pomiaru zbioru danych, a także rok, wyniki te można uznać za zgodne z wynikami
uzyskanymi na Uniwersytecie Virginia.
Inna technika zarządzania wolnym miejscem na dysku polega na wykorzystaniu mapy bito-
wej. Dysk zawierający n bloków wymaga mapy bitowej zawierającej n bitów. Bloki wolne na
mapie są reprezentowane przez jedynki, bloki przydzielone — przez zera (lub odwrotnie). Dla
przytoczonego przykładu 1-terabajtowego dysku potrzeba 1 miliarda bitów na mapę, co wymaga
zapisania nieco poniżej 130 tysięcy 1-kilobajtowych bloków. Nie dziwi fakt, że mapa bitowa
wymaga mniej miejsca, ponieważ używa 1 bitu na blok, a nie 32 bitów, jak w przypadku modelu
bazującego na liście jednokierunkowej. Mechanizm z listami jednokierunkowymi będzie wyma-
gał mniej bloków niż w przypadku modelu z mapą bitową tylko wtedy, gdy dysk będzie prawie
pełny (tzn. będzie miał mało wolnych bloków).
Jeśli wolne bloki występują w formie długich sekwencji kolejnych bloków, to system listy
wolnych bloków można zmodyfikować w taki sposób, aby śledzić sekwencje bloków zamiast
pojedynczych bloków. Z każdym blokiem można powiązać 8-, 16- lub 32-bitowy licznik, który
informuje o liczbie kolejnych wolnych bloków. W najlepszym przypadku, jeśli dysk będzie pusty,
można go opisać za pomocą dwóch liczb: adresu pierwszego wolnego bloku oraz liczby wolnych
bloków. Z drugiej strony, jeśli dysk będzie poważnie pofragmentowany, śledzenie sekwencji blo-
ków okaże się mniej wydajne niż śledzenie indywidualnych bloków, ponieważ będzie wymagało
zapisania nie tylko adresu, ale także licznika.
Ta sytuacja ilustruje problem, z jakim często borykają się projektanci systemów operacyj-
nych. Istnieje wiele struktur danych i algorytmów, które można wykorzystać do rozwiązania
problemu. Wybór najlepszego wymaga posiadania danych, których projektanci nie mają i nie
będą mieć do czasu, kiedy system zostanie wdrożony i będzie intensywnie wykorzystywany.
A nawet wtedy potrzebne dane mogą być niedostępne. Przykładowo nasze własne pomiary roz-
miaru plików, pomiary wykonane na Uniwersytecie Virginia w latach 1984 i 1985, dane z serwera
WWW oraz z Uniwersytetu Cornell to tylko cztery próbki. Choć to znacznie więcej niż nic, nie
mamy pojęcia, czy są one reprezentatywne także dla komputerów domowych, korporacyjnych,
rządowych i innych. Przy odrobinie wysiłku moglibyśmy zebrać kilka próbek z innych typów
komputerów, ale nawet wtedy ekstrapolacja do wszystkich pomierzonych komputerów byłaby
niewłaściwa.
Wróćmy na chwilę do metody listy wolnych bloków — w tym przypadku w pamięci głównej
musi być przechowywany tylko jeden blok wskaźników. W momencie tworzenia pliku potrzebne
bloki są pobierane z bloku wskaźników. Kiedy ten się wyczerpie, z dysku wczytywany jest
nowy blok wskaźników. Na podobnej zasadzie, kiedy plik zostanie usunięty, jego bloki są zwal-
niane i dołączane do bloku wskaźników w pamięci głównej. Kiedy ten blok się zapełni, zostaje
zapisany na dysk.
W pewnych okolicznościach metoda ta prowadzi do wykonywania niepotrzebnych operacji
wejścia-wyjścia. Rozważmy sytuację z rysunku 4.19(a), w której w bloku wskaźników w pamięci
jest miejsce tylko na dwa dodatkowe wpisy. Jeśli zostanie zwolniony plik składający się z trzech
bloków, blok wskaźników przepełni się i będzie musiał być zapisany na dysk, co doprowadzi do
sytuacji z rysunku 4.19(b). Jeśli teraz zostanie zapisany na dysk plik składający się z trzech
bloków, pełny blok wskaźników będzie musiał być wczytany ponownie, co doprowadzi do sytu-
acji z rysunku 4.19(a). Jeśli zapisany przed chwilą plik złożony z trzech bloków był tymczasowy,
to w momencie jego zwalniania potrzebny będzie dodatkowy zapis na dysk — w celu zapisania
pełnego bloku wskaźników. Krótko mówiąc, kiedy blok wskaźników jest prawie pusty, obsługa
kilku plików tymczasowych może spowodować konieczność wykonywania wielu operacji wej-
ścia-wyjścia.
Alternatywnym podejściem pozwalającym uniknąć wykonywania większości operacji wej-
ścia-wyjścia jest podział pełnego bloku wskaźników. Tak więc zamiast przechodzić z sytuacji
Rysunek 4.19. (a) Prawie pełny blok wskaźników do zwolnienia bloków dyskowych w pamięci
i trzy bloki wskaźników na dysku; (b) rezultat zwolnienia pliku składającego się z trzech bloków;
(c) alternatywna strategia obsługi trzech bloków wolnych; wpisy wyróżnione szarym kolorem
reprezentują wskaźniki do wolnych bloków dyskowych
Limity dyskowe
Aby przeciwdziałać trendowi zużywania zbyt dużej ilości miejsca na dysku przez pojedynczych
użytkowników, w wielodostępnych systemach operacyjnych często wykorzystuje się mecha-
nizm wymuszania limitów dyskowych. Idea jest taka, że administrator systemu przypisuje
każdemu użytkownikowi maksymalny przydział plików i bloków, a system operacyjny dba o to,
aby użytkownicy nie przekraczali ustalonych limitów. Typowy mechanizm opisano poniżej.
Kiedy użytkownik otwiera plik, system operacyjny lokalizuje atrybuty i adresy dyskowe
i umieszcza je w tablicy otwartych plików w pamięci głównej. Wśród atrybutów jest wpis infor-
mujący o tym, kim jest właściciel. Każde zwiększenie rozmiaru pliku wiąże się z obciążeniem
konta właściciela.
W drugiej tablicy jest zapisany rekord limitu dla wszystkich użytkowników, którzy mają
w danym momencie otwarte pliki — nawet jeśli otworzył je ktoś inny. Tablicę tę przedstawiono
na rysunku 4.20. Jest to fragment pliku limitu na dysku dla użytkowników, których pliki są
w danym momencie otwarte. Kiedy wszystkie pliki zostaną zamknięte, rekord jest zapisywany
z powrotem do pliku limitów.
Kiedy w tablicy otwartych plików jest tworzony nowy wpis, wprowadzony zostaje do niej
wskaźnik do rekordu limitu właściciela. Dzięki temu można łatwo zlokalizować różne limity.
Za każdym razem, kiedy do pliku zostaje dodany blok, całkowita liczba bloków przypisanych do
właściciela jest inkrementowana i odbywa się sprawdzenie zarówno twardych, jak i miękkich
limitów. Miękki limit może być przekroczony, ale twardy nie. Próba dodania informacji do pliku
po osiągnięciu twardego limitu bloków spowoduje błąd. Analogiczne testy są wykonywane w odnie-
sieniu do liczby plików, aby uniemożliwić użytkownikowi wykorzystanie wszystkich i-węzłów.
Kiedy użytkownik podejmuje próbę logowania, system analizuje plik limitów, by zobaczyć,
czy użytkownik przekroczył miękki limit zarówno co do liczby plików, jak i liczby bloków dys-
kowych. Jeśli dowolny limit zostanie przekroczony, wyświetlane jest ostrzeżenie, a licznik pozo-
stałych ostrzeżeń zostaje zmniejszony o jeden. Jeśli licznik kiedykolwiek osiągnie zero, będzie
to oznaczało, że użytkownik zignorował ostrzeżenie o jeden raz za dużo i nie jest uprawniony do
zalogowania się. Uzyskanie ponownego uprawnienia do zalogowania się będzie wymagało roz-
mowy z administratorem systemu.
Opisana metoda pozwala użytkownikom przekraczać miękkie limity podczas sesji logowa-
nia, pod warunkiem że przed wylogowaniem usuną nadmiar. Twarde limity nigdy nie mogą być
przekroczone.
znikną na zawsze, mogą ponieść bolesne konsekwencje. Choć system plików nie jest w stanie
zapewnić żadnego zabezpieczenia przed fizyczną destrukcją sprzętu i nośników, może pomóc
chronić informacje. Czynność ta zdaje się dość łatwa: wystarczy wykonywać kopie zapasowe.
Nie jest to jednak tak proste, jak się wydaje. Spróbujmy przyjrzeć się bliżej temu problemowi.
Większość osób uważa, że wykonywanie kopii zapasowych własnych plików nie jest warte
czasu ani wysiłków. Myślą tak aż do chwili, w której ich dysk nagle przestaje działać. Wtedy
nagle uświadamiają sobie swój błąd. Z kolei firmy (zazwyczaj) zdają sobie sprawę z wartości
swoich danych i przeważnie wykonują kopie zapasowe przynajmniej raz dziennie — zwykle na
taśmie. Nowoczesne taśmy są w stanie pomieścić setki gigabajtów danych, a cena 1 gigabajta
jest bardzo niska. Niemniej jednak wykonywanie kopii zapasowych nie jest tak trywialne, jak
mogłoby się wydawać na pierwszy rzut oka, dlatego poniżej przeanalizujemy niektóre problemy
z tym związane.
Kopie zapasowe na taśmach zazwyczaj wykonuje się w celu rozwiązania jednego z dwóch
potencjalnych problemów, którymi są:
1. Odtwarzanie danych po awarii.
2. Odtwarzanie danych utraconych w wyniku własnej głupoty.
Pierwszy przypadek obejmuje przywrócenie komputera do działania po awarii dysku, pożarze,
powodzi lub innej naturalnej katastrofie. W praktyce takie rzeczy nie zdarzają się zbyt często,
dlatego sporo osób rezygnuje z wykonywania kopii zapasowych. Z tego samego powodu osoby te
rezygnują z ubezpieczania swoich domów na wypadek pożaru.
Drugi problem polega na tym, że użytkownicy zwykle przypadkowo usuwają pliki, których
później znów potrzebują. Problem ten występuje tak często, że „usunięcie” pliku w systemie
Windows wcale nie powoduje jego usunięcia, tylko przeniesienie do specjalnego katalogu — kosza,
skąd można go bez trudu odzyskać. Mechanizm kopii zapasowych rozwija tę ideę i umożliwia
odzyskiwanie plików usuniętych wiele dni, a nawet tygodni wcześniej.
Wykonywanie kopii zapasowych zajmuje dużo czasu i wymaga dużej ilości miejsca, dlatego
wykonywanie tej czynności w sposób skuteczny i wygodny ma istotne znaczenie. Z przytoczo-
nych uwarunkowań wynikają opisane poniżej problemy. Po pierwsze, czy należy wykonywać
kopię zapasową całego systemu plików, czy tylko jego części. W wielu instalacjach programy
wykonywalne (binarne) są przechowywane w ograniczonej przestrzeni drzewa systemu plików.
Tworzenie kopii zapasowych tych plików nie jest konieczne, ponieważ wszystkie można zain-
stalować z witryny WWW producenta lub dostarczonej płyty DVD. Większość systemów jest
również wyposażonych w katalog na pliki tymczasowe. Zazwyczaj także dla tych plików nie ma
potrzeby tworzenia kopii zapasowych. W systemie UNIX wszystkie pliki specjalne (urządzenia
wejścia-wyjścia) są przechowywane w katalogu /dev. Tworzenie kopii zapasowej tego katalogu
nie tylko okazuje się niekonieczne, ale jest też niebezpieczne, ponieważ program tworzący kopię
zapasową zawiesiłby się, gdyby spróbował odczytać wszystkie zapisane w nim pliki. Krótko
mówiąc, zazwyczaj pożądane jest wykonanie kopii zapasowej tylko wskazanych katalogów razem
z całą ich zawartością, a nie całych systemów plików.
Po drugie tworzenie kopii zapasowych plików, które nie zmieniły się od czasu wykonania
poprzedniej kopii zapasowej, jest marnotrawstwem — to prowadzi do koncepcji kopii przyro-
stowych. Najprostsza forma kopii przyrostowych polega na okresowym wykonywaniu pełnego
zrzutu (kopii zapasowej), np. co tydzień lub co miesiąc, a następnie wykonaniu codziennego
zrzutu tylko tych plików, które zostały zmodyfikowane od czasu wykonania ostatniej pełnej kopii
zapasowej. Jeszcze lepiej jest wykonać zrzut tylko tych plików, które zmieniły się od czasu
wykonania ich ostatniej kopii zapasowej. Choć taki mechanizm minimalizuje czas wykonywa-
nia kopii, sprawia, że odtwarzanie staje się bardziej złożone, ponieważ najpierw trzeba odtwo-
rzyć ostatnią pełną kopię zapasową, a następnie wszystkie kopie przyrostowe w odwróconej
kolejności. W celu ułatwienia odtwarzania często wykorzystuje się bardziej zaawansowane
mechanizmy tworzenia zrzutów przyrostowych.
Po trzecie, ze względu na to, że zwykle tworzy się kopie zapasowe bardzo dużych ilości
danych, przed zapisaniem ich na taśmę warto poddać je kompresji. Jednak w przypadku wielu
algorytmów kompresji pojedynczy błąd na taśmie z kopią zapasową może uniemożliwić prze-
prowadzenie dekompresji i spowodować, że cały plik lub nawet cała taśma staną się niemożli-
we do odczytania. Z tego powodu należy dokładnie rozważyć decyzję o kompresji strumienia
kopii zapasowej.
Dalej: przeprowadzenie kopii zapasowej aktywnego systemu plików jest trudne. Jeśli pod-
czas procesu wykonywania kopii zapasowej pliki i katalogi są dodawane, usuwane i modyfiko-
wane, uzyskana kopia zapasowa może być niespójna. Ponieważ jednak wykonywanie kopii zapa-
sowej może zająć wiele godzin, czasami konieczne staje się wyłączenie systemu na większą część
nocy — a to nie zawsze jest do przyjęcia. Z tego powodu opracowano algorytmy wykonywania
szybkich migawek stanu systemu plików poprzez skopiowanie krytycznych struktur danych.
Zmiany w plikach i katalogach po wykonaniu migawki wymagają kopiowania bloków zamiast
aktualizowania ich na miejscu [Hutchinson et al., 1999]. W ten sposób system plików jest zamro-
żony według stanu z chwili wykonania migawki, dzięki czemu kopia zapasowa może być wyko-
nana później, w dogodnym czasie.
I wreszcie — wykonywanie kopii zapasowych stwarza firmom wiele problemów niezwią-
zanych z techniką. Najlepszy system zabezpieczeń online na świecie może okazać się bezuży-
teczny, jeśli administrator systemu przechowuje wszystkie taśmy z kopiami zapasowymi
w swoim biurze, które jest otwarte i niezabezpieczone, kiedy administrator maszeruje przez hall
po kawę. Wystarczy, że intruz wśliźnie się na sekundę do biura administratora, włoży malutką
taśmę do kieszeni i szybko zniknie. Do widzenia, zabezpieczenia. Codzienne wykonywanie kopii
zapasowych będzie miało niewielki sens, jeśli ogień, który spali komputery, spali także wszyst-
kie taśmy z kopiami zapasowymi. Z tego względu kopie zapasowe powinny być przechowywane
na zewnątrz. To jednak wprowadza dodatkowe zagrożenia (ponieważ teraz trzeba zabezpieczyć
dwa ośrodki). Dokładny opis tych i innych praktycznych problemów administracyjnych można
znaleźć w [Nemeth et al., 2013]. Poniżej omówimy tylko techniczne problemy związane z wyko-
nywaniem kopii zapasowych systemów plików.
Podczas wykonywania kopii zapasowej dysku na taśmę można zastosować dwie strategie:
zrzut fizyczny lub zrzut logiczny. Zrzut fizyczny rozpoczyna się od bloku 0 na dysku i polega na
zapisaniu po kolei wszystkich bloków dyskowych na wyjściowej taśmie. Zrzut kończy się po
skopiowaniu ostatniego bloku. Programy, które realizują takie zrzuty, są tak proste, że można
bez trudu zapewnić, aby działały w stu procentach bezbłędnie — coś, czego zwykle nie można
zapewnić dla żadnego innego programu użytkowego.
Niemniej jednak warto usłyszeć kilka uwag na temat tworzenia fizycznych zrzutów. Z jed-
nej strony nie ma zbyt wiele pożytku z tworzenia kopii zapasowych nieużywanych bloków
dyskowych. Gdyby program realizujący kopię mógł uzyskać dostęp do struktury danych, gdzie
są zapisane informacje o wolnych blokach, mógłby uniknąć zrzucania nieużywanych bloków.
Pomijanie nieużywanych bloków wymaga jednak zapisania numeru każdego bloku przed blo-
kiem (lub jego odpowiednikiem), ponieważ w tym przypadku blok k na taśmie nie jest już blo-
kiem k na dysku.
Drugi problem to zrzucanie uszkodzonych bloków. Wyprodukowanie dużego dysku bez żadnych
defektów jest prawie niemożliwe. Na każdym dysku znajduje się pewna liczba uszkodzonych
Rysunek 4.21. System plików, dla którego ma być wykonana kopia zapasowa; kwadraty oznaczają
katalogi, a kółka pliki; elementy wyróżnione szarym kolorem były modyfikowane od czasu wykonania
ostatniej kopii zapasowej; każdy katalog i plik jest oznaczony swoim numerem i-węzła
(właściciel, godziny itp.), dzięki czemu te dane można odtworzyć. Na koniec, w fazie 4., pliki ozna-
czone na rysunku 4.22(d) również są umieszczane w kopii zapasowej. Tym razem także wraz
z atrybutami. Na tym wykonywanie kopii zapasowej się kończy.
Odtwarzanie systemu plików z taśm kopii zapasowej jest proste. Na początek na dysku two-
rzony jest pusty system plików. Później odtwarzana jest ostatnia pełna kopia zapasowa. Ponie-
waż na taśmie najpierw są umieszczone katalogi, wszystkie one są odtwarzane w pierwszej
kolejności i w ten sposób tworzy się szkielet systemu plików. Następnie odtwarzane są same
pliki. Proces ten jest następnie powtarzany dla pierwszej przyrostowej kopii zapasowej wyko-
nanej po kopii pełnej, następnie uwzględniana jest kolejna kopia przyrostowa itd.
Chociaż tworzenie logicznych kopii zapasowych jest proste, istnieje kilka trudnych elemen-
tów. Po pierwsze, ponieważ lista wolnych bloków nie jest plikiem, nie zostaje umieszczona
w kopii zapasowej i dlatego musi być zrekonstruowana od podstaw po odtworzeniu wszystkich
kopii zapasowych. Wykonanie tej czynności zawsze jest możliwe, ponieważ zbiór wolnych bloków
jest po prostu uzupełnieniem zbioru bloków umieszczonych we wszystkich plikach razem.
Innym problemem są dowiązania. Jeśli plik jest dowiązany do dwóch katalogów lub więk-
szej ich liczby, ważne staje się to, aby został odtworzony tylko raz i aby wszystkie katalogi, które
mają na niego wskazywać, faktycznie na niego wskazywały.
Jeszcze innym problemem jest to, że pliki systemu UNIX mogą zawierać luki. Całkowicie
prawidłowe jest otwarcie pliku, zapisanie kilku bajtów, przejście do odległego przesunięcia pliku
i zapisanie kolejnych kilku bajtów. Bloki znajdujące się pomiędzy tymi adresami nie należą do
pliku, zatem nie trzeba ich umieszczać w kopii zapasowej i odtwarzać. Pliki zrzutu pamięci
często mają lukę pomiędzy segmentem danych a stosem sięgającą setek megabajtów. Jeśli ta
luka nie zostanie obsłużona poprawnie, to każdy odtworzony plik zrzutu będzie wypełniał ten
obszar zerami, a zatem będzie miał taki sam rozmiar, jak wirtualna przestrzeń adresowa (np.
232 bajtów lub, jeszcze gorzej, 264 bajtów).
Trzeba pamiętać, że pliki specjalne, nazwane potoki i tym podobne elementy nigdy nie powinny
być umieszczane w kopii zapasowej, niezależnie od tego, w jakim katalogu się znajdują (nieko-
niecznie muszą one znajdować się w katalogu /dev). Więcej informacji na temat kopii zapasowych
systemów plików można znaleźć w [Chervenak et al., 1998] oraz [Zwicky, 1991].
czowe znaczenie, jeśli niektóre bloki, niezapisane na dysk, są blokami i-węzłów, blokami katalo-
gów lub blokami zawierającymi listę bloków wolnych.
W celu rozwiązania problemu niespójnych systemów plików większość komputerów zawiera
program narzędziowy służący do sprawdzania spójności systemu plików; np. w Uniksie jest
program fsck, a w Windowsie — sfc (oraz inne). Program ten można uruchomić każdorazowo
po uruchomieniu systemu, szczególnie po wystąpieniu awarii. Program ten można uruchomić
każdorazowo po uruchomieniu systemu, szczególnie po wystąpieniu awarii. Sposób działania
programu fsck opisano poniżej; sfc działa nieco inaczej, ponieważ pracuje na innym systemie
plików, ale ogólna koncepcja wykorzystania wewnętrznej redundancji systemu plików do jego
naprawy w dalszym ciągu obowiązuje. Wszystkie programy sprawdzające weryfikują każdy z sys-
temów plików (partycję dyskową) niezależnie od innych.
Można wykonać dwa rodzaje sprawdzania spójności: na poziomie bloków i plików. W celu
sprawdzenia spójności na poziomie bloków program tworzy dwie tabele. W każdej z nich jest
licznik dla każdego bloku, który początkowo jest ustawiony na 0. Liczniki w pierwszej tabeli
śledzą liczbę wystąpień każdego z bloków w pliku. Liczniki w drugiej tabeli rejestrują częstość
występowania każdego bloku na liście bloków wolnych (lub mapie bitowej bloków wolnych).
Następnie program czyta wszystkie i-węzły, wykorzystując surowe urządzenie i ignorując
strukturę plików. W wyniku tej operacji zwraca wszystkie bloki dyskowe, począwszy od 0. Wycho-
dząc od i-węzła, można stworzyć listę wszystkich numerów bloków wykorzystywanych w danym
pliku. Po odczytaniu każdego numeru bloku jego licznik w pierwszej tabeli jest inkremento-
wany. Następnie program analizuje listę bloków wolnych lub mapę bitową w celu odszukania
wszystkich bloków, które nie są wykorzystywane. Każde wystąpienie bloku na liście bloków
wolnych skutkuje inkrementacją jego licznika w drugiej tabeli.
Jeśli system plików jest spójny, każdy blok będzie miał 1 w pierwszej lub w drugiej tabeli,
tak jak pokazano na rysunku 4.23(a). Jednak w wyniku awarii tabele mogą mieć taką postać, jak
pokazano na rysunku 4.23(b), gdzie blok 2 nie występuje w żadnej tabeli. Program zgłosi go
jako brakujący blok. Chociaż brakujące bloki nie powodują żadnych praktycznych szkód, przy-
czyniają się do marnotrawstwa miejsca, a tym samym powodują zmniejszenie objętości dysku.
Rozwiązanie problemu brakujących bloków jest proste: program weryfikujący poprawność sys-
temu plików po prostu dodaje je do listy bloków wolnych.
Rysunek 4.23. Stany systemu plików: (a) spójny; (b) brakujący blok; (c) zdublowany blok na liście
wolnych bloków; (d) zdublowany blok danych
Inną możliwą sytuację pokazano na rysunku 4.23(c). Widzimy tam blok (nr 4), który dwu-
krotnie występuje na liście bloków wolnych (duplikaty mogą wystąpić tylko wtedy, gdy lista
bloków wolnych jest rzeczywiście listą; w przypadku mapy bitowej jest to niemożliwe). Roz-
wiązanie w tym przypadku także okazuje się proste: należy odtworzyć listę wolnych bloków.
Najgorsze, co się może zdarzyć, to sytuacja, w której ten sam blok danych występuje w dwóch
lub większej liczbie plików, co pokazano na rysunku 4.23(d) dla bloku 5. Jeśli dowolny z tych
plików zostanie usunięty, blok 5 zostanie umieszczony na liście bloków wolnych, co doprowa-
dzi do sytuacji, w której ten sam blok będzie jednocześnie w użyciu i wolny. Jeśli obydwa pliki
zostaną usunięte, blok zostanie umieszczony na liście wolnych bloków dwukrotnie.
Właściwe działanie programu sprawdzającego system plików to przydzielenie wolnego bloku,
skopiowanie do niego zawartości bloku nr 5, a następnie wstawienie kopii do jednego z plików.
W ten sposób zawartość informacyjna plików pozostanie bez zmian (chociaż jest niemal pewne,
że jeden z nich będzie zaśmiecony), ale struktura systemu plików będzie spójna. Należy zgłosić
błąd, aby umożliwić użytkownikowi zbadanie uszkodzenia.
Oprócz skontrolowania prawidłowego przydzielenia każdego bloku, program sprawdzający
poprawność systemu plików powinien również sprawdzić system katalogów. Tutaj także jest
wykorzystywana tabela liczników, ale są one na poziomie plików, a nie bloków. Program rozpo-
czyna sprawdzanie w katalogu głównym i rekurencyjnie schodzi w dół drzewa, badając poszcze-
gólne katalogi w systemie plików. Dla każdego i-węzła w każdym katalogu inkrementowany
jest licznik użycia danego pliku. Należy pamiętać, że ze względu na istnienie twardych dowią-
zań, plik może występować w dwóch katalogach lub większej ich liczbie. Dowiązania symbo-
liczne się nie liczą i nie powodują inkrementacji licznika dla docelowego pliku.
Kiedy program sprawdzający poprawność systemu plików zakończy działanie, będzie posiadał
listę, poindeksowaną według i-węzła, zawierającą informacje o tym, w ilu katalogach występuje
każdy plik. Następnie liczby te zostaną porównane z licznikami dowiązań zapisanymi w samych
i-węzłach. Licznik ma początkową wartość 1 w momencie utworzenia pliku i jest inkrementowany
za każdym razem, gdy do pliku zostanie utworzone twarde dowiązanie. W spójnym systemie
plików oba liczniki będą ze sobą zgodne. Mogą jednak wystąpić dwa rodzaje błędów: licznik
dowiązań w i-węźle może mieć za wysoką lub za niską wartość.
Jeśli licznik dowiązań ma większą wartość od liczby wpisów w katalogach, to nawet jeśli
wszystkie pliki zostaną usunięte z katalogów, licznik będzie miał ciągle niezerową wartość,
a i-węzeł nie zostanie usunięty. Ten błąd nie jest poważny, ale przyczynia się do marnotrawstwa
miejsca na dysku z powodu plików, których nie ma w żadnym katalogu. Aby poprawić błąd,
należy ustawić licznik dowiązań w i-węźle na prawidłową wartość.
Katastrofalne skutki może mieć inny błąd. Jeśli dwa wpisy w katalogach są dowiązane do
pliku, a w i-węźle znajduje się informacja, że jest tylko jeden, to w przypadku usunięcia dowol-
nego wpisu w katalogu wartość licznika w i-węźle spadnie do zera. Kiedy licznik w i-węźle
spadnie do zera, system plików oznaczy go jako nieużywany i zwolni wszystkie jego bloki.
W wyniku tego działania jeden z katalogów będzie wskazywał na nieużywany i-węzeł, a jego bloki
mogą być wkrótce przypisane do innych plików. W tym przypadku rozwiązaniem jest wymu-
szenie wartości licznika dowiązań w i-węźle, tak aby pokazywała rzeczywistą liczbę wpisów
w katalogu.
Te dwie operacje — sprawdzanie bloków i sprawdzanie katalogów — z powodów wydajno-
ściowych często są łączone w jedną (tzn. wymagany jest tylko jeden przebieg przez i-węzły).
Możliwe są również inne testy. Katalogi np. mają ustalony format z numerami i-węzłów i nazwami
ASCII. Jeśli numer i-węzła jest większy niż liczba i-węzłów na dysku, oznacza to, że katalog
został uszkodzony.
Ponadto każdy i-węzeł ma tryb. Niektóre z nich są poprawne, ale dziwne — np. 0007, który nie
daje właścicielowi i jego grupie żadnych praw dostępu, a umożliwia użytkownikom z zewnątrz
czytanie, zapisywanie i wykonywanie pliku. Przydałaby się możliwość przynajmniej zgłaszania
plików, które dają osobom z zewnątrz więcej uprawnień niż właścicielowi. Katalogi, które zawie-
rają np. więcej niż 1000 wpisów, również są podejrzane. Pliki umieszczone w katalogach użyt-
kowników, których właścicielem jest superużytkownik i które mają ustawiony bit SETUID, rów-
nież zwiastują potencjalne problemy z bezpieczeństwem, ponieważ takie pliki, kiedy zostaną
uruchomione przez zwykłego użytkownika, zdobywają uprawnienia superużytkownika. Przy
odrobinie wysiłku można stworzyć dość długą listę technicznie poprawnych, ale niecodziennych
sytuacji, które program sprawdzający powinien odnotować.
W poprzednich akapitach opisano problemy zabezpieczania użytkowników przed awariami.
Niektóre systemy plików dbają również o zabezpieczenia użytkowników przed nimi samymi.
Jeżeli użytkownik ma zamiar wpisać polecenie:
rm *.o
Buforowanie
Najpopularniejszą techniką wykorzystywaną w celu skrócenia czasu dostępu do dysku jest blo-
kowa pamięć podręczna lub buforowa pamięć podręczna (angielska nazwa pamięci podręcznej —
cache wywodzi się z francuskiego słowa cacher, co oznacza „ukryć”). W tym kontekście pamięć
podręczna jest kolekcją bloków, które logicznie należą do dysku, ale są przechowywane w pamięci
z powodów wydajnościowych.
Do zarządzania pamięcią podręczną można zastosować różne algorytmy. Najbardziej popu-
larny polega na sprawdzeniu wszystkich żądań odczytu, w celu przekonania się, czy potrzebny
blok jest dostępny w pamięci podręcznej. Jeśli tak, to żądanie odczytu może być spełnione bez
dostępu do dysku. Jeśli bloku nie ma w pamięci podręcznej, jest on najpierw do niej ładowany,
a następnie kopiowany zawsze, gdy będzie potrzebny. Kolejne żądania tego samego bloku mogą
być realizowane z pamięci podręcznej.
Działanie pamięci podręcznej pokazano na rysunku 4.24. Ponieważ w pamięci podręcznej
znajduje się wiele (czasami kilka tysięcy) bloków, potrzebny jest sposób szybkiego stwierdze-
nia, czy określony blok występuje w pamięci podręcznej. Standardowy sposób to obliczenie
skrótu urządzenia i adresu dyskowego oraz wyszukanie wyniku w tabeli skrótów. Wszystkie
bloki o tej samej wartości skrótu są przechowywane na liście jednokierunkowej, dzięki czemu
można przeszukać właściwą listę.
Kiedy blok musi zostać załadowany do pamięci podręcznej, która jest pełna, jakiś blok powi-
nien zostać z niej usunięty (i ponownie zapisany na dysk, jeśli został zmodyfikowany od czasu
załadowania). Sytuacja ta przypomina stronicowanie i można tu zastosować wszystkie standar-
dowe algorytmy zastępowania stron, opisane w rozdziale 3., takie jak FIFO, drugiej szansy czy
LRU. Jedna z przyjemnych różnic pomiędzy stronicowaniem a pamięcią podręczną polega na
tym, że odwołania do pamięci podręcznej są stosunkowo rzadkie, dlatego można przechowywać
wszystkie bloki w kolejności czasu ostatniego użycia na jednokierunkowej liście.
Na rysunku 4.24 widzimy, że oprócz łańcuchów kolizji rozpoczynających się od tabeli
skrótów istnieje również lista dwukierunkowa, która biegnie przez wszystkie bloki w kolejno-
ści ich użycia. Najdawniej używany blok znajduje się na początku tej listy, natomiast ostatnio
używany blok — na jej końcu. W momencie odwołania do bloku można go usunąć z jego pozycji na
dwukierunkowej liście i umieścić na końcu. Dzięki temu możliwe staje się utrzymanie dokład-
nej kolejności według czasu ostatniego użycia.
Niestety, jest pewien problem. Teraz, kiedy mamy sytuację, w której można zastosować
dokładną kolejność zgodną z czasem ostatniego użycia, okazuje się, że zastosowanie algorytmu
LRU nie jest pożądane. Problem ten wiąże się z awariami i spójnością systemu plików, opisaną
w poprzednim podrozdziale. Jeśli blok krytyczny, np. blok i-węzła, zostanie załadowany do pamięci
podręcznej i zmodyfikowany, ale nie zostanie zapisany z powrotem na dysk, awaria pozostawi
system plików w niespójnym stanie. Jeśli blok i-węzła zostanie umieszczony na końcu łańcucha
LRU, może upłynąć sporo czasu, zanim osiągnie on początek i zostanie ponownie zapisany na dysk.
Ponadto do niektórych bloków, takich jak bloki i-węzłów, rzadko występują dwa odwołania
w ciągu krótkiego czasu. Względy te prowadzą do zmodyfikowanego schematu LRU, w którym
brane są pod uwagę dwa czynniki:
1. Czy istnieje prawdopodobieństwo ponownego użycia bloku w bliskiej przyszłości?
2. Czy blok ma istotne znaczenie dla zachowania spójności systemu plików?
W celu udzielenia odpowiedzi na obydwa pytania bloki można podzielić na takie kategorie jak
bloki i-węzłów, bloki pośrednie, bloki katalogów, pełne bloki danych i częściowo zapełnione
bloki danych. Bloki, które prawdopodobnie nie będą potrzebne w bliskiej przyszłości, zostaną
umieszczone na początku listy zamiast na końcu, dzięki czemu ich bufory będą szybko ponow-
nie wykorzystane. Bloki, które wkrótce mogą być znów potrzebne, np. częściowo zapełnione
bloki, które są zapisywane, trafiają na koniec listy, dzięki czemu pozostaną w pamięci podręcz-
nej przez długi czas.
Drugie pytanie jest niezależne od pierwszego. Jeśli blok ma kluczowe znaczenie dla spój-
ności systemu plików (wszystkie bloki oprócz bloków danych) i został zmodyfikowany, to należy
zapisać go na dysk natychmiast, niezależnie od tego, na którym końcu kolejki LRU został umiesz-
czony. Dzięki szybkiemu zapisowi kluczowych bloków znacznie zmniejsza się prawdopodo-
bieństwo tego, że awaria doprowadzi do uszkodzenia systemu plików. Choć użytkownik może
być zmartwiony, jeśli jeden z jego plików zostanie zniszczony w wyniku awarii, będzie znacznie
bardziej zmartwiony, jeśli utraci cały system plików.
Nawet jeśli wziąć pod uwagę dążenie do utrzymania integralności systemu plików, nie jest
pożądane, aby bloki danych znajdowały się w pamięci podręcznej zbyt długo przed ich ponow-
nym zapisaniem na dysk. Zastanówmy się, jakie kłopoty będzie miał ktoś, kto chce używać kom-
putera osobistego do napisania książki. Choćby pisarz okresowo zlecał edytorowi zapisanie
edytowanego pliku na dysk, istnieje obawa, że wszystko zostanie zapisane do pamięci podręcz-
nej, a nic na dysk. Jeśli nastąpi awaria systemu, struktura systemu plików nie zostanie uszko-
dzona, ale pisarz utraci efekty swojej całodniowej pracy.
Taka sytuacja wcale nie musi zdarzać się bardzo często, abyśmy mieli wielu niezadowolonych
użytkowników. W systemach operacyjnych możliwe są dwa sposoby poradzenia sobie z tą sytu-
acją. Sposób uniksowy polega na zastosowaniu wywołania systemowego sync, które wymusza
natychmiastowy zapis wszystkich zmodyfikowanych bloków na dysk. Podczas uruchamiania
systemu zwykle w tle otwiera się program o nazwie update, który wykonuje się w nieskończo-
nej pętli i co 30 s wykonuje wywołania sync. W rezultacie awaria nie spowoduje utraty więcej
niż 30 s pracy.
Chociaż w systemie Windows istnieje obecnie wywołanie równoważne wywołaniu sync —
znane jako FlushFileBuffers, w przeszłości go nie było. Zamiast niego stosowano inną strategię,
która pod pewnymi względami była lepsza od podejścia stosowanego w systemie UNIX (a pod
pewnymi względami gorsza). Technika ta polegała na zapisywaniu na dysk każdego zmodyfi-
kowanego bloku, natychmiast po jego zapisaniu do pamięci podręcznej. Pamięci podręczne,
w których wszystkie zmodyfikowane bloki są zapisywane natychmiast na dysk, określa się jako
pamięci podręczne z natychmiastowym zapisem (ang. write-through caches). Wymagają one więcej
dyskowych zasobów wejścia-wyjścia niż pamięci podręczne wstrzymujące zapis.
Różnicę pomiędzy tymi dwoma podejściami można zaobserwować, kiedy program zapisuje
po jednym znaku cały 1-kilobajtowy blok. W systemie UNIX wszystkie znaki zostaną zapisane
do pamięci podręcznej, a blok będzie zapisywany co 30 s lub za każdym razem, kiedy blok zosta-
nie usunięty z pamięci podręcznej. W przypadku pamięci podręcznej bez wstrzymywania zapisu
dostęp do dysku będzie następował przy zapisaniu każdego znaku. Oczywiście większość pro-
gramów stosuje wewnętrzne buforowanie, zatem standardowo w każdym wywołaniu systemo-
wym write nie zapisują one pojedynczych znaków, ale wiersz lub większą jednostkę.
Konsekwencja tej różnicy w strategii buforowania jest taka, że wyjęcie dyskietki (odłącze-
nie dysku) z systemu UNIX bez wykonania operacji sync prawie zawsze doprowadzi do utraty
danych, a często także do uszkodzenia systemu plików. W przypadku buforowania bez wstrzy-
mywania zapisu nie ma żadnych problemów. Omówione różne strategie wybrano dlatego, że system
UNIX tworzono w środowisku, w którym wszystkie dyski były dyskami twardymi, niewymiennymi,
natomiast pierwszy system plików w Windows wywodził się z systemu MS-DOS, który powsta-
wał w świecie dyskietek elastycznych. Ponieważ dyski twarde stały się normą, normą stało się
również podejście uniksowe, które okazuje się wydajniejsze (ale mniej niezawodne) i jest sto-
sowane także w systemie Windows do obsługi dysków twardych. W systemie NTFS w celu poprawy
niezawodności zastosowano jednak inne mechanizmy (księgowanie), o czym mówiliśmy wcześniej.
W niektórych systemach operacyjnych zintegrowano buforowe pamięci podręczne ze stroni-
cowanymi. Jest to szczególnie atrakcyjne, kiedy system obsługuje pliki odwzorowane w pamięci.
Jeśli plik jest odwzorowany w pamięci, to niektóre z jego stron mogą być w pamięci, ponieważ
żądano ich wczytania. Takie strony niewiele się różnią od bloków pliku w buforowej pamięci
podręcznej. W takim przypadku można je traktować w taki sam sposób — stosować pojedynczą
pamięć podręczną zarówno dla bloków pliku, jak i stron.
Jednak nawet w przypadku listy wolnych bloków można dokonać podziału na klastry w pew-
nej formie. Sztuka polega na śledzeniu ilości miejsca na dysku nie w blokach, ale w grupach
kolejnych bloków. Jeśli wszystkie sektory składają się z 512 bajtów, system może wykorzy-
stywać 1-kilobajtowe bloki (2 sektory), ale przydzielać miejsce na dysku w jednostkach po 2 bloki
(4 sektory). To nie to samo, co posługiwanie się 2-kilobajtowymi blokami dyskowymi, ponie-
waż pamięć podręczna w dalszym ciągu będzie wykorzystywała bloki 1-kilobajtowe, a transfer
również będzie realizowany w blokach po 1 kB. Jednak przy sekwencyjnym czytaniu pliku liczba
operacji wyszukiwania spadnie dwukrotnie, co przyczyni się do znacznej poprawy wydajności.
Odmiana tego samego mechanizmu polega na wzięciu pod uwagę pozycjonowania rotacyjnego.
Przy przydzielaniu bloków system próbuje umieścić kolejne bloki w pliku w tym samym cylindrze.
Inną problematyczną kwestią w odniesieniu do systemów wykorzystujących i-węzły lub
inny podobny mechanizm jest to, że czytanie nawet krótkiego pliku wymaga dwóch dostępów
do dysku: jednego w celu odczytania i-węzła i drugiego w celu odczytania bloku. Standardowe
rozmieszczenie i-węzłów pokazano na rysunku 4.25(a). W tym przypadku i-węzły są blisko
początku dysku. W związku z tym średnia odległość pomiędzy i-węzłami a jego blokami wyniesie
około połowy liczby cylindrów, co będzie wymagało długich operacji wyszukiwania.
Rysunek 4.25. (a) I-węzły umieszczone na początku dysku; (b) dysk podzielony na grupy cylindrów,
każda z własnymi blokami i i-węzłami
Łatwym sposobem na poprawę wydajności jest umieszczenie i-węzłów w środku dysku zamiast
na początku, przez co średni czas wyszukiwania pomiędzy wyszukiwaniem a pierwszym blo-
kiem spadnie o połowę. Inny pomysł, który pokazano na rysunku 4.25(b), polega na podziele-
niu dysku na grupy cylindrów — każda z własnymi i-węzłami, blokami i listą bloków wolnych
[McKusick et al., 1984]. Podczas tworzenia nowego pliku można wybrać dowolny i-węzeł, ale
system próbuje znaleźć blok w tej samej grupie cylindrów, w której znajduje się i-węzeł. Jeśli
nie ma w niej dostępnego bloku, wykorzystywany jest blok w bliskiej grupie cylindrów.
Oczywiście ruch ramienia dysku i czas obrotu mają znaczenie tylko wtedy, kiedy dysk je ma.
Coraz więcej komputerów jest wyposażonych w dyski SSD, które w ogóle nie mają ruchomych
części. Dla tych dysków, zbudowanych na bazie tej samej technologii co karty pamięci flash,
losowy dostęp do danych jest tak samo szybki jak dostęp sekwencyjny, a wiele problemów doty-
czących dysków tradycyjnych nie istnieje. Niestety, pojawiają się nowe problemy.
Przykładowo dyski SSD mają dziwne właściwości dotyczące czytania, pisania i usuwania.
Choćby to, że każdy blok może być zapisany tylko ograniczoną liczbę razy. Z tego powodu trzeba
zwrócić szczególną uwagę, aby przestrzeń na dysku była wykorzystywana równomiernie.
Wpis w katalogu zawiera również datę i godzinę utworzenia pliku lub jego ostatniej mody-
fikacji. Godziny mogą być określone z dokładnością do ±2 sekund, ponieważ są zapisane w polu
o rozmiarze 2 bajtów. W związku z tym pozwala ono na zapisanie tylko 65 536 unikatowych
wartości (dzień zawiera 86 400 sekund). Pole Godzina jest podzielone na sekundy (5 bitów),
minuty (6 bitów) i godziny (5 bitów). Data jest liczona w dniach z wykorzystaniem trzech pól
pomocniczych: dzień (5 bitów), miesiąc (4 bity), rok −1980 (7 bitów). Przy 7-bitowym numerze
roku i początkowym roku 1980 największą wartością roku, jaką można wyrazić, jest rok 2107.
Tak więc system MS-DOS ma wbudowany problem roku 2108. W celu uniknięcia katastrofy
użytkownicy systemu MS-DOS powinni spełnić warunki Y2108 tak szybko, jak to możliwe.
Gdyby w systemie MS-DOS wykorzystano połączone pola daty i godziny jako 32-bitowy licznik
sekund, można by reprezentować czas co do sekundy i opóźnić katastrofę do roku 2116.
W systemie MS-DOS rozmiar pliku jest przechowywany w postaci liczby 32-bitowej, zatem
teoretycznie pliki mogą osiągnąć rozmiar 4 GB. Jednak z powodu innych ograniczeń (opisanych
poniżej) maksymalny rozmiar pliku wynosi 2 GB. Zaskakująco duża część wpisu (10 bajtów)
pozostaje nieużywana.
System MS-DOS śledzi wolne bloki za pośrednictwem tablicy alokacji plików umieszczonej
w pamięci głównej. Wpis w katalogu zawiera numer pierwszego bloku w pliku. Numer ten jest
wykorzystywany jako indeks do tablicy FAT o pojemności 64 kB, umieszczonej w pamięci głów-
nej. Idąc zgodnie z łańcuchem, można znaleźć wszystkie bloki. Działanie tablicy FAT zilustro-
wano na rysunku 4.9.
System plików FAT jest dostępny w trzech wersjach: FAT-12, FAT-16 i FAT-32, w zależ-
ności od tego, ile bitów zawiera adres dyskowy. Nazwa FAT-32 jest trochę myląca, ponieważ
w rzeczywistości wykorzystywanych jest tylko 28 mniej znaczących bitów adresu. System plików
powinien nazywać się FAT-28, ale potęgi liczby dwa brzmią o wiele przyjemniej.
Inną odmianą systemu plików FAT jest exFAT, który firma Microsoft wprowadziła dla dużych
urządzeń przenośnych. Licencję systemu exFAT zakupiła firma Apple, dzięki czemu jest to
nowoczesny system plików, który może być używany do przesyłania plików pomiędzy kompute-
rami Windows i OS X. Ponieważ exFAT jest zastrzeżonym systemem operacyjnym, a firma
Microsoft nie opublikowała jego specyfikacji, nie będziemy omawiać go dokładniej w tej książce.
We wszystkich odmianach systemu FAT bloki dyskowe można ustawić na wielokrotność
512 bajtów (może ona być różna dla każdej partycji), a zbiór dozwolonych rozmiarów bloków
(nazywanych przez Microsoft rozmiarami klastrów) jest różny dla każdego wariantu. W pierwszej
wersji systemu MS-DOS wykorzystywano system plików FAT-12 oraz bloki o rozmiarze 512 baj-
tów. W związku z tym maksymalny rozmiar partycji wynosił 212×512 bajtów (właściwie tylko
4086×512 bajtów, ponieważ 10 adresów dyskowych było wykorzystywanych jako specjalne
znaczniki, np. koniec pliku, uszkodzony blok itp.). Przy takich parametrach maksymalny roz-
miar partycji dyskowej wynosił około 2 MB, a rozmiar tablicy FAT w pamięci wynosił 4096
pozycji po 2 bajty każda. Wykorzystanie 12-bitowego wpisu w tablicy byłoby zbyt wolne.
Ten system działał prawidłowo dla dysków elastycznych, ale kiedy pojawiły się dyski twarde,
powstał problem. Firma Microsoft rozwiązała go poprzez umożliwienie wykorzystywania dodat-
kowych rozmiarów bloków: 1 kB, 2 kB i 4 kB. Zmiana ta pozwoliła na zachowanie struktury i roz-
miaru tablicy FAT-12, ale umożliwiła też tworzenie partycji dyskowych o rozmiarze do 16 MB.
Ponieważ system MS-DOS obsługiwał cztery partycje dyskowe, nowy system plików FAT-12
obsługiwał dyski o maksymalnym rozmiarze 64 MB. Aby była możliwa obsługa większych dys-
ków, potrzebne okazało się inne rozwiązanie. Wprowadzono zatem system plików FAT-16
z 16-bitowymi wskaźnikami dyskowymi. Dodatkowo pozwolono na stosowanie bloków o roz-
miarach 8 kB, 16 kB i 32 kB (32 768 to największa potęga liczby dwa, którą można reprezento-
wać za pomocą 16 bitów). Tablica FAT-16 przez cały czas zajmowała 128 kB pamięci głównej,
ale przy dostępnych większych pamięciach w czasie powstania systemu plików FAT-16 był on
powszechnie używany i szybko zastąpił system plików FAT-12. Największa partycja dyskowa,
którą może obsłużyć system plików FAT-16, ma 2 GB (65 536 wpisów po 32 kB każdy), a naj-
większy dysk ma rozmiar 8 GB — dokładniej cztery partycje po 2 GB każda. Przez dość długi
czas takie ograniczenie nie stwarzało żadnych problemów.
Nic jednak nie jest wieczne. Gdy chodzi o przechowywanie pism urzędowych, ten limit nie
jest problemem, ale jeśli dysk miałby służyć do przechowywania cyfrowych klipów wideo w stan-
dardzie DV, to w 2-gigabajtowy plik można zapisać zaledwie 9-minutowe nagranie. W konsekwen-
cji faktu, że dysk komputera PC może obsłużyć tylko cztery partycje, najdłuższe nagranie wideo,
jakie można by było zapisać na dysku, miałoby około 38 min, niezależnie od tego, jak duży byłby
dysk. Limit ten oznacza także, że największy klip wideo możliwy do edycji online ma mniej niż
19 min, ponieważ potrzebne są zarówno plik wejściowy, jak i wyjściowy.
Począwszy od drugiego wydania systemu Windows 95, wprowadzono system plików FAT-32,
w którym wykorzystano 28-bitowe adresy dyskowe, a wersje MS-DOS, na których bazie dzia-
łał system Windows 95, przystosowano do obsługi FAT-32. W tym systemie plików partycje
mogły teoretycznie mieć 228×215 bajtów, ale w praktyce ich rozmiar jest ograniczony do 2 TB
(2048 GB), ponieważ wewnętrznie system śledzi rozmiary partycji w 512-bajtowych sektorach,
wykorzystując 32-bitową liczbę, a 29×232 bajtów to 2 TB. Maksymalne rozmiary partycji dla
różnych rozmiarów bloków oraz wszystkich trzech typów systemów plików FAT zestawiono
w tabeli 4.4.
Tabela 4.4. Maksymalne rozmiary partycji dla różnych rozmiarów bloków; puste pola reprezentują
niedozwolone kombinacje
Rozmiar bloku FAT-12 FAT-16 FAT-32
0,5 kB 2 MB
1 kB 4 MB
2 kB 8 MB 128 MB
4 kB 16 MB 256 MB 1 TB
8 kB 512 MB 2 TB
16 kB 1024 MB 2 TB
32 kB 2048 MB 2 TB
Oprócz tego, że system plików FAT-32 obsługuje większe dyski, ma także dwie inne zalety
w porównaniu z systemem FAT-16. Po pierwsze 8-gigabajtowy dysk z systemem FAT-32 może
mieć tylko jedną partycję. W przypadku systemu plików FAT-16 taki dysk trzeba było podzie-
lić na cztery partycje, co użytkownicy systemu Windows widzieli jako logiczne dyski C:, D:, E:
i F:. Na użytkowniku spoczywał obowiązek decydowania o tym, na którym napędzie umieścić
plik, oraz śledzenia tego, gdzie co jest.
Inną własnością, dla której system plików FAT-32 ma przewagę nad systemem FAT-16,
jest to, że dla partycji dyskowej o określonym rozmiarze można wykorzystać mniejszy rozmiar
bloku. I tak przy 2-gigabajtowej partycji dyskowej system FAT-32 musi wykorzystywać bloki
po 32 kB. W przeciwnym wypadku przy 65 536 dostępnych adresach dyskowych nie dałoby się
zaadresować całej partycji. Dla odróżnienia w systemie FAT-32 można wykorzystać 4-kilobajtowe
bloki dla 2-gigabajtowej partycji. Zaleta bloków o mniejszych rozmiarach wynika stąd, że więk-
szość plików ma rozmiary znacznie mniejsze niż 32 kB. Jeśli blok ma rozmiar 32 kB, to plik
Podobnie do i-węzła z rysunku 4.10 i-węzły w systemie UNIX zawierają pewne atrybuty.
Atrybuty te obejmują rozmiar pliku, trzy znaczniki czasu (utworzenie, ostatni dostęp i ostatnia
modyfikacja), właściciela, grupę, informacje o zabezpieczeniach oraz licznik wpisów w katalogu
wskazujących na ten i-węzeł. Ostatnie pole jest potrzebne ze względu na dowiązania. Zawsze,
kiedy zostaje utworzone nowe dowiązanie do i-węzła, licznik w i-węźle jest inkrementowany.
W przypadku usunięcia łącza licznik jest dekrementowany. Kiedy osiągnie 0, i-węzeł może być
wykorzystany ponownie, a bloki dyskowe są zwracane na listę bloków wolnych.
Śledzenie bloków dyskowych zrealizowano poprzez uogólnienie sytuacji z rysunku 4.10.
Dzięki temu jest możliwa obsługa bardzo dużych plików. Pierwszych 10 adresów dyskowych jest
zapisanych w samym i-węźle. Dzięki temu, w przypadku małych plików, wszystkie potrzebne
informacje znajdują się bezpośrednio w i-węźle, który jest pobierany z dysku do pamięci głównej
w momencie otwierania pliku. W przypadku nieco większych plików jeden z adresów w i-węźle
to adres bloku dyskowego zwanego blokiem jednopośrednim (ang. single indirect block). Ten
blok zawiera dodatkowe adresy dyskowe. Jeśli to w dalszym ciągu nie wystarcza, inny adres
umieszczony w i-węźle, zwany blokiem dwupośrednim (ang. double indirect block), zawiera adres
bloku obejmującego listę bloków jednopośrednich. Każdy z bloków jednopośrednich wskazuje
na kilkaset bloków danych. Jeśli to jeszcze nie wystarcza, można także wykorzystać blok trójpo-
średni (ang. triple indirect block). Pełny obraz pokazano na rysunku 4.28.
Kiedy plik jest otwierany, system plików musi pobrać dostarczoną nazwę pliku i zlokalizo-
wać jego bloki dyskowe. Spróbujmy przeanalizować sposób przeszukiwania ścieżki o nazwie
/usr/ast/mbox. W przykładzie posłużymy się systemem UNIX, ale algorytm jest w zasadzie taki
sam dla wszystkich hierarchicznych systemów katalogów. Najpierw system plików wyszukuje
katalog główny. W systemie UNIX jego i-węzeł znajduje się w ustalonym miejscu na dysku.
Na podstawie informacji zapisanych w tym i-węźle system plików wyszukuje katalog główny.
Może on być w dowolnym miejscu na dysku, ale załóżmy, że znajduje się on w bloku 1.
Następnie system plików czyta katalog główny i wyszukuje w nim pierwszy komponent
ścieżki — usr. W ten sposób odnajduje i-węzeł pliku /usr. Zlokalizowanie i-węzła na podstawie
jego numeru jest proste, ponieważ każdy i-węzeł ma stałą lokalizację na dysku. Na podstawie
informacji zapisanych w tym i-węźle system odnajduje katalog /usr i szuka w nim następnego
komponentu — ast. Kiedy znajdzie wpis dla komponentu ast, dysponuje i-węzłem katalogu
/usr/ast. system plików może znaleźć potrzebny katalog i szuka pliku mbox. I-węzeł tego pliku
jest następnie wczytywany do pamięci i przechowywany tam aż do zamknięcia pliku. Proces
wyszukiwania zilustrowano na rysunku 4.29.
Ścieżki względne przeszukuje się w taki sam sposób jak bezwzględne, tyle że punktem wyj-
ścia jest katalog roboczy zamiast głównego. W każdym katalogu są wpisy . i .., które zostają tam
umieszczone w czasie tworzenia katalogu. Pozycja . zawiera numer i-węzła bieżącego katalogu,
natomiast pozycja .. zawiera numer i-węzła dla katalogu nadrzędnego. Tak więc procedura wyszu-
kiwania pliku ../dick/prog.c polega na wyszukaniu pozycji .. w bieżącym katalogu, znalezieniu
numeru i-węzła dla katalogu nadrzędnego, a następnie przeszukaniu tego katalogu w celu znale-
zienia pozycji dick. Do obsługi tych nazw nie jest potrzebny żaden specjalny mechanizm. Z punktu
widzenia systemu obsługi katalogów są to zwyczajne ciągi ASCII, takie same jak dowolne inne
nazwy. Jedyną szczególną własnością jest to, że pozycja .. w katalogu głównym wskazuje na
samą siebie.
zostaną opisane poniżej. Jednym z celów opracowania nowego standardu było umożliwienie
czytania każdej płyty CD-ROM na każdym komputerze, niezależnie od porządku bajtów oraz
wykorzystywanego systemu operacyjnego. W konsekwencji wprowadzono pewne ograniczenia
dla systemu plików, aby umożliwić odczytanie go w najsłabszych używanych wówczas systemach
operacyjnych (np. MS-DOS).
Na płytach CD-ROM nie ma koncentrycznych cylindrów, takich jak te, które są obecne na
dyskach magnetycznych. Zamiast nich jest pojedyncza ciągła spirala zawierająca bity w linio-
wej sekwencji (chociaż wyszukiwanie w poprzek spirali jest możliwe). Bity wzdłuż spirali są
podzielone na logiczne bloki (nazywane także logicznymi sektorami) po 2352 bajtów. Niektóre
z nich służą jako nagłówki, są wykorzystywane do korekcji błędów oraz innych nadmiarowych
danych. Część przeznaczona na właściwe dane w każdym logicznym bloku wynosi 2048 bajtów.
Płyty CD używane do nagrywania muzyki zawierają informacje początkowe ścieżki (leadin),
informacje końcowe ścieżki (leadout) oraz przerwy między ścieżkami. Elementy te nie są jed-
nak używane dla płyt CD-ROM z danymi. Pozycja bloku wzdłuż spirali jest często wyrażana
w minutach i sekundach. Wartość tę można przekształcić na liniowy numer bloku za pomocą
współczynnika konwersji 1 s = 75 bloków.
Standard ISO 9660 obsługuje zestawy płyt CD-ROM zawierające do 216−1 płyt CD w zbiorze.
Indywidualne płyty CD-ROM można także podzielić na woluminy logiczne (partycje). Jednak poni-
żej skoncentrujemy się na systemie plików ISO 9660 dla pojedynczej płyty CD-ROM bez partycji.
Każdy CD-ROM rozpoczyna się od 16 bloków, których funkcje nie zostały zdefiniowane
w standardzie ISO 9660. Producent płyty CD-ROM może wykorzystać ten obszar do umiesz-
czenia na nim programu rozruchowego w celu umożliwienia uruchamiania komputera z płyty
CD-ROM. Może także wykorzystać go do innych celów. Dalej jest blok z głównym deskryptorem
woluminu, zawierającym pewne ogólne informacje na temat płyty CD-ROM. Informacje te zawie-
rają identyfikator systemu (32 bajty), identyfikator woluminu (32 bajty), identyfikator wydawcy
(128 bajtów) oraz identyfikator mechanizmu przygotowującego dane (128 bajtów). Producent
może wypełnić te pola w dowolny pożądany sposób. Jedyne ograniczenia to konieczność posłu-
giwania się tylko wielkimi literami i bardzo niewielką liczbą znaków przestankowych — ma to
na celu zapewnienie zgodności pomiędzy platformami.
Podstawowy deskryptor woluminu zawiera również nazwy trzech plików, które mogą zawie-
rać odpowiednio streszczenie, notkę o prawach autorskich oraz informacje bibliograficzne. Dodat-
kowo jest też kilka istotnych liczb, takich jak rozmiar logicznego bloku (zwykle 2048, choć w nie-
których przypadkach dozwolone są również wartości 4096, 8192 oraz wyższe potęgi liczby
dwa), liczba bloków na płycie CD-ROM oraz daty utworzenia i ważności płyty CD-ROM. Podsta-
wowy deskryptor woluminu zawiera również wpis dla katalogu głównego, który informuje o loka-
lizacji głównego katalogu na płycie CD-ROM (tzn. od którego bloku się rozpoczyna). Począw-
szy od tego katalogu, można zlokalizować pozostałą zawartość systemu plików.
Oprócz głównego deskryptora woluminu na płycie CD-ROM może być zapisany pomocniczy
deskryptor woluminu. Zawiera on informacje podobne do tych, które można znaleźć w głównym
deskryptorze, ale to nie będzie nas interesowało w tym miejscu.
Katalog główny, a także wszystkie inne katalogi składają się ze zmiennej liczby wpisów.
Ostatni zawiera bit, który informuje, że więcej wpisów nie ma. Same wpisy w katalogach rów-
nież mają zmienny rozmiar. Każdy wpis katalogowy składa się z 10 – 12 pól. Niektóre z nich są
w formacie ASCII, natomiast inne to binarne pola numeryczne. Pola binarne są kodowane dwu-
krotnie — jeden raz w formacie little-endian (wykorzystywanym np. w systemach Pentium)
i raz w formacie big-endian (wykorzystywanym np. w systemach SPARC). Tak więc dla liczby
16-bitowej potrzeba 4 bajtów, natomiast dla liczby 32-bitowej — 8 bajtów.
Zastosowanie tego nadmiarowego kodowania było konieczne po to, by „nie ranić niczyich
uczuć” w czasie, gdy standard powstawał. Gdyby standard dyktował użycie formatu little-endian,
to pracownicy firm wytwarzających produkty wykorzystujące format big-endian czuliby się jak
obywatele drugiej kategorii i nie zaakceptowaliby standardu. Emocjonalną zawartość płyty
CD-ROM można zatem zmierzyć i dokładnie wyrazić w kilobajtach (godzinach) zmarnowanego
miejsca.
Format wpisu katalogowego systemu plików ISO 9660 pokazano na rysunku 4.30. Ponieważ
wpisy katalogowe są zmiennej długości, pierwsze pole to bajt, który informuje o tym, jaki jest
rozmiar wpisu. W celu uniknięcia niejednoznaczności ten bajt ma bardziej znaczące bity z lewej
strony.
Wpisy w katalogu opcjonalnie mogą mieć rozszerzone atrybuty. W przypadku gdy ta własność
jest wykorzystywana, drugi bajt informuje o tym, jaką długość mają rozszerzone atrybuty.
Dalej jest początkowy blok samego pliku. Pliki są zapisane jako ciągły zestaw bloków, zatem
lokalizacja pliku jest w całości określona przez blok początkowy i rozmiar, który jest zapisany
w następnym polu.
W następnym polu jest zapisana data i godzina zapisania płyty CD-ROM, przy czym rok,
miesiąc, dzień, godzina, minuta, sekunda i strefa czasowa zostają zapisane w osobnych bajtach.
Lata są liczone, począwszy od 1900 roku. Oznacza to, że płyty CD-ROM dotyka problem roku
2156, bowiem rok następny po 2155 będzie interpretowany jako 1900. Projektanci standardu
mogli opóźnić ten problem poprzez zdefiniowanie roku początkowego jako 1988 (rok, w którym
przyjęto standard). Gdyby to zrobiono, problem opóźniłby się do roku 2244. Każde 88 dodat-
kowych lat się liczy.
Pole Flagi zawiera kilka różnych bitów. Jeden z nich służy do ukrywania wpisu na listin-
gach (własność skopiowana z systemu MS-DOS), inny do odróżniania wpisu będącego plikiem
od wpisu oznaczającego katalog. Kolejny bit umożliwia wykorzystywanie rozszerzonych atry-
butów, a jeszcze inny oznaczanie ostatniego wpisu w katalogu. W tym polu występuje jeszcze
kilka innych bitów, ale tutaj nie będą nas one interesowały. Kolejne pole ma związek z przeplo-
tem fragmentów plików. Ponieważ mechanizm ten nie jest wykorzystywany w najprostszej wersji
standardu ISO 9660, nie będziemy się nim bliżej zajmować.
Kolejne pole informuje o tym, na której płycie CD-ROM znajduje się plik. Dozwolona jest
sytuacja, w której wpis w katalogu na jednej płycie CD-ROM odnosi się do pliku znajdującego
się na innej płycie CD-ROM w zestawie. Dzięki temu można stworzyć główny katalog na
pierwszej płycie CD-ROM, w którym są wymienione wszystkie pliki na wszystkich płytach
CD-ROM w całym zestawie.
Pole oznaczone literą R na rysunku 4.30 oznacza rozmiar nazwy pliku w bajtach. Za nim
występuje właściwa nazwa pliku. Nazwa pliku składa się z nazwy bazowej, kropki, rozszerzenia,
średnika oraz binarnego numeru wersji (1 bajt lub 2 bajty). W nazwie bazowej i rozszerzeniu
można wykorzystywać wielkie litery, cyfry 0 – 9 oraz znak podkreślenia. Wszystkie inne znaki
są zabronione. Dzięki temu można mieć pewność, że każdy komputer poprawnie zinterpretuje
dowolną nazwę pliku. Nazwa bazowa może mieć długość do ośmiu znaków. Rozszerzenie może
mieć maksymalnie trzy znaki. Taka konfiguracja jest podyktowana koniecznością zachowania
zgodności z systemem operacyjnym MS-DOS. Nazwa pliku może występować w katalogu wiele
razy, o ile za każdym razem jest opatrzona innym numerem wersji.
Ostatnie dwa pola nie zawsze występują. Pole Wypełnienie jest wykorzystywane po to, by
każdy wpis w katalogu składał się z parzystej liczby bajtów. Ma to na celu wyrównanie pól
numerycznych w kolejnych wpisach. Jeśli jest potrzebne wypełnienie, wykorzystany zostaje
bajt o wartości 0. Na końcu znajduje się pole Sys. Jego przeznaczenie i rozmiar są niezdefinio-
wane. Wymagane jest tylko, aby pole to składało się z parzystej liczby bajtów. W różnych syste-
mach jest ono wykorzystywane w różny sposób, np. w komputerach Macintosh służy do prze-
chowywania flagi programu Finder.
Wpisy w katalogu są wyszczególnione w porządku alfabetycznym, poza dwoma pierwszymi
wpisami. Pierwszy wpis dotyczy samego katalogu. Drugi oznacza jego katalog nadrzędny. Pod
tym względem owe wpisy przypominają pozycje . i .. w katalogach systemu UNIX. Właściwe
pliki nie muszą być zapisane w takiej kolejności, w jakiej występują w katalogu.
Nie istnieje jawne ograniczenie liczby wpisów w katalogu. Istnieje jednak ograniczenie głę-
bokości zagnieżdżania. Maksymalna liczba poziomów zagnieżdżania wynosi osiem. Wartość tę
wybrano po to, aby uprościć niektóre implementacje.
W standardzie ISO 9660 zdefiniowano tzw. trzy poziomy. Poziom 1. jest najbardziej restryk-
cyjny i wskazuje, że nazwy plików są ograniczone do rozmiaru 8+3 znaki, zgodnie z tym, co
napisaliśmy, oraz że pliki muszą być ciągłe. Ponadto określa on, że nazwy katalogów powinny być
ograniczone do ośmiu znaków i nie mogą zawierać rozszerzenia. Zastosowanie tego poziomu
maksymalizuje szanse na to, że płyta CD-ROM będzie mogła być odczytana na każdym kom-
puterze.
Na poziomie 2. ograniczenie co do długości nazwy pliku nie jest już tak ścisłe. Pliki i kata-
logi mogą mieć nazwy o długości do 31 znaków, ale muszą one pochodzić z tego samego zestawu
znaków.
Na poziomie 3. wykorzystano takie same ograniczenia dla nazw, jak na poziomie 2., ale czę-
ściowo złagodzono założenie, że pliki muszą być ciągłe. Na tym poziomie plik może się składać
z kilku części (ang. extents), z których każda jest ciągłym zbiorem bloków. Ten sam zbiór może
występować w pliku wiele razy i może również występować w dwóch plikach lub większej ich
liczbie. Na poziomie 3. wprowadzono także pewne optymalizacje dotyczące miejsca na dysku,
użyteczne w przypadku, gdy duże fragmenty danych są powtórzone w kilku plikach — na tym
poziomie dane nie muszą być zapisane na płycie kilka razy.
Rozszerzenia Joliet
Społeczność Uniksa nie była jedyną grupą, której nie podobał się standard ISO 9660 i która dążyła
do jego rozszerzenia. Firma Microsoft również uznała go za zbyt restrykcyjny (choć to właśnie
produkt Microsoft — MS-DOS — był przyczyną większości ograniczeń). Z tego powodu firma
Microsoft opracowała pewne rozszerzenia, którym nadano nazwę Joliet. Opracowano je po to,
by umożliwić kopiowanie na płytę CD-ROM i późniejsze odtwarzanie windowsowych systemów
plików, czyli dokładnie w takim samym celu, w jakim opracowano rozszerzenia Rock Ridge
dla systemu UNIX. Rozszerzenia Joliet obsługują prawie wszystkie programy, które działają
w systemie Windows i korzystają z płyt CD-ROM, w tym także programy do nagrywania płyt
CD-R. Zazwyczaj w programach tych istnieje możliwość wyboru pomiędzy różnymi poziomami
standardu ISO oraz rozszerzeniami Joliet.
Systemy plików od zawsze były częstszym przedmiotem badań niż inne części systemu opera-
cyjnego. Tak jest i dziś. Plikom i systemom ich magazynowania są poświęcone całe konferencje,
m.in. FAST, MSST i NAS. Choć standardowe systemy plików zostały dobrze poznane, w dal-
szym ciągu prowadzi się badania nad kopiami zapasowymi ([Smaldone et al., 2013], [Wallace et
al., 2012]), pamięcią podręczną ([Koller et al. 2012], [Oh, 2012], [Zhang et al., 2013a]), bez-
piecznym usuwaniem danych ([Wei et al., 2011]), kompresją plików ([Harnik et al., 2013]),
systemem plików na dyskach flash ([No, 2012], [Park i Shen, 2012], [Narayanan, 2009]), błę-
dami wydajności ([Leventhal, 2013], [Schindler et al., 2011]), RAID ([Moon i Reddy, 2013]),
niezawodnością i odtwarzaniem po awariach ([Chidambaram et al., 2013], [Ma et al., 2013],
[McKusick, 2012], [van Moolenbroek et al., 2012]), systemami plików poziomu użytkownika
([Rajgarhia i Gehani, 2010]), weryfikacją spójności ([Fryer et al., 2012]) oraz systemami plików
z kontrolą wersji ([Mashtizadeh et al., 2013]). Jednym z tematów badań jest również to, co dzieje
się wewnątrz systemu plików — [Harter et al., 2012].
Zagadnieniem, które wzbudza zainteresowanie od wielu lat, jest bezpieczeństwo ([Botelho
et al., 2013], [Li et al., 2013c], [Lorch et al., 2013]). Nowością w badaniach są systemy plików
w chmurze ([Mazurek et al., 2012], [Vrable et al., 2012]). Innym obszarem, który szczególnie
interesuje badaczy, jest pochodzenie informacji — śledzenie historii włącznie z informacjami o źró-
dle ich pochodzenia, właścicielu oraz sposobu transformacji ([Ghoshal i Plale, 2013], [Sultana
i Bertino, 2013]). Kwestią bezpieczeństwa przechowywania danych i utrzymania ich użytecz-
ności od dziesięcioleci zajmują się firmy, które są zobowiązane do tego przepisami prawnymi —
[Baker et al., 2006]. Wreszcie przedmiotem badań są nowe sposoby organizacji stosu systemu
plików — [Appuswamy et al., 2011].
4.7. PODSUMOWANIE
4.7.
PODSUMOWANIE
Jeśli spojrzeć z zewnątrz, system plików okazuje się kolekcją plików i katalogów oraz operacji,
które są na nich wykonywane. Pliki można odczytywać i zapisywać, katalogi można tworzyć
i niszczyć, a pliki można przenosić z katalogu do katalogu. Większość współczesnych systemów
plików obsługuje hierarchiczny system katalogów, w których katalogi mogą zawierać podka-
talogi, a te z kolei mogą zawierać inne katalogi i tak w nieskończoność.
Jeśli spojrzymy od wewnątrz, system plików wygląda zupełnie inaczej. Projektanci systemu
plików muszą brać pod uwagę sposób przydzielania miejsca oraz śledzenia, które bloki należą
do których plików. Do dostępnych możliwości można zaliczyć pliki ciągłe, listy jednokierun-
kowe, tablice alokacji plików oraz i-węzły. W różnych systemach katalogi mają różną strukturę.
Atrybuty mogą być zapisane w katalogu lub w innym miejscu (np. w i-węźle). Miejscem na dysku
można zarządzać za pomocą list wolnych bloków lub map bitowych. Niezawodność systemu
plików można poprawić poprzez wykonywanie przyrostowych kopii zapasowych oraz posługi-
wanie się programami, które potrafią naprawić uszkodzone systemy plików. Wydajność systemu
plików ma istotne znaczenie i można ją poprawić na kilka sposobów — poprzez włączenie bufo-
rowania, odczytu zawczasu oraz uważne umieszczanie blisko siebie bloków należących do pliku.
Zastosowanie systemów plików o strukturze dziennika także poprawia wydajność, dzięki temu,
że zapis jest wykonywany w dużych jednostkach.
Przykładem systemów plików są ISO 9660, MS-DOS i UNIX. Różnią się one pod wieloma
względami, odmienne są m.in. sposób śledzenia przynależności bloków do plików, struktura
katalogów oraz zarządzanie wolnym miejscem.
PYTANIA
1. Podaj pięć różnych nazw ścieżek do pliku /etc/passwd (Wskazówka: weź pod uwagę wpisy
opisujące katalogi . i ..).
2. Gdy użytkownik systemu Windows kliknie dwukrotnie plik wyświetlony przez Eksplo-
ratora Windows, następuje uruchomienie programu, a nazwa pliku jest przekazywana jako
parametr. Podaj dwa różne sposoby, dzięki którym system operacyjny może się dowie-
dzieć, który program uruchomić.
3. W pierwszych systemach uniksowych pliki wykonywalne (a.out) rozpoczynały się od
specyficznej liczby magicznej, która nie była wybierana losowo. Pliki te zaczynały się
od nagłówka, za którym następowały segmenty tekstu i danych. Dlaczego, Twoim zda-
niem, dla plików wykonywalnych wybrano specyficzną liczbę magiczną, podczas gdy
pierwszym słowem w plikach innych typów była liczba magiczna wybierana w bardziej
lub mniej losowy sposób?
4. Czy wywołanie systemowe open w systemie UNIX jest bezwzględnie konieczne? Jakie
byłyby konsekwencje, gdyby go nie było?
5. W systemach obsługujących pliki sekwencyjne zawsze jest dostępna operacja przewijania
plików. Czy taka operacja jest potrzebna także w systemach zawierających pliki o loso-
wym dostępie?
6. W niektórych systemach operacyjnych jest dostępne wywołanie systemowe rename,
które służy do nadawania plikom nowych nazw. Czy jest jakaś różnica pomiędzy wyko-
rzystaniem tego wywołania do zmiany nazwy pliku, a skopiowaniem tego pliku do nowego
pliku pod nową nazwą, a następnie skasowaniem pliku poprzedniego?
29. Przypuśćmy, że plik 21 na rysunku 4.21 nie był modyfikowany od czasu wykonywania
ostatniej kopii zapasowej. Jakie będą różnice pomiędzy czterema mapami bitowymi
z rysunku 4.22?
30. Zaproponowano, aby pierwsza część każdego pliku w systemie UNIX była zapisana w tym
samym bloku dyskowym, co jego i-węzeł. Jakie korzyści z tego wynikają?
31. Spójrz na rysunek 4.23. Czy istnieje możliwość, aby dla pewnego konkretnego numeru
bloku liczniki na obu listach miały wartość 2? W jaki sposób należy poprawić ten problem?
32. Wydajność systemu plików zależy od współczynnika trafień w pamięci podręcznej (czę-
ści bloków znalezionych w pamięci podręcznej). Podaj wzór na średni czas wymagany
do spełnienia żądania dla współczynnika trafień h, jeśli spełnienie żądania z pamięci pod-
ręcznej zajmuje 1 ms, a 40 ms w przypadku gdy jest potrzebny odczyt z dysku. Narysuj
wykres tej funkcji dla wartości h z zakresu od 0 do 1,0.
33. Jaki rodzaj pamięci podręcznej jest bardziej odpowiedni dla zewnętrznego dysku twar-
dego USB dołączanego do komputera: pamięć podręczna z buforowaniem bez wstrzy-
mywania zapisu (ang. write-through cache) czy blokowa pamięć podręczna?
34. Rozważmy aplikację, w której informacje dotyczące studentów są przechowywane w pliku.
Aplikacja pobiera identyfikator studenta jako dane wejściowe, a następnie czyta, aktu-
alizuje i zapisuje odpowiedni rekord studenta. Proces jest powtarzany do czasu, aż aplika-
cja zakończy pracę. Czy w tej aplikacji będzie przydatna technika czytania bloku zawczasu
(ang. read ahead)?
35. Rozważmy dysk, na którym jest 10 bloków danych, od bloku 14 do 23. Załóżmy, że na
dysku są dwa pliki: f1 i f2. W strukturze katalogów są informacje, zgodnie z którymi pierw-
sze bloki danych plików f1 i f2 znajdują się odpowiednio pod adresami 22 i 16. Jakie bloki
danych zostały przydzielone do plików f1 i f2 przy założeniu, że w tabeli FAT znajdują
się następujące pozycje:
(14,18); (15,17); (16,23); (17,21); (18,20); (19,15); (20, −1); (21, −1); (22,19); (23,14)?
W powyższej notacji (x, y) oznacza, że wartość zapisana w pozycji tabeli x wskazuje na
blok danych y.
36. Zastosuj ideę z rysunku 4.17 dla dysku o średnim czasie wyszukiwania równym 8 ms,
szybkości obrotowej 15 000 rpm oraz gęstości zapisu 262 144 bajtów na ścieżkę. Jaka
będzie szybkość przesyłania danych dla bloków danych o rozmiarach odpowiednio 1 kB,
2 kB i 4 kB?
37. Pewien system plików wykorzystuje 2-kilobajtowe bloki dyskowe. Średni rozmiar pliku
wynosi 1 kB. Jaka część miejsca na dysku byłaby stracona, gdyby wszystkie pliki miały
rozmiar dokładnie 1 kB? Czy sądzisz, że straty dla rzeczywistego systemu plików byłyby
większe od tej liczby, czy mniejsze? Uzasadnij swoją odpowiedź.
38. Jaki jest największy dostępny rozmiar pliku (w bajtach), do którego można uzyskać dostęp
za pomocą 10 bezpośrednich adresów i jednego bloku pośredniego, jeśli wziąć pod uwagę,
że rozmiar bloku dyskowego wynosi 4 kB, a wartość adresu wskaźnika bloku wynosi
4 bajty?
39. Pliki w systemie MS-DOS muszą rywalizować o miejsce w tabeli FAT-16 umieszczonej
w pamięci. Jeśli jeden plik zużywa k pozycji, te k pozycji nie jest dostępnych dla żadnego
innego pliku. Jakie ograniczenie wprowadza to na całkowity rozmiar wszystkich plików?
40. System plików w systemie UNIX wykorzystuje 1-kilobajtowe bloki i 4-bajtowe adresy
dyskowe. Jaki jest maksymalny rozmiar pliku, jeśli każdy i-węzeł zawiera 10 bezpośred-
nich wpisów oraz po jednym jednopośrednim, dwupośrednim i trójpośrednim?
41. Ile operacji dyskowych potrzeba do pobrania i-węzła dla pliku /usr/ast/courses/os/handout.t?
Załóżmy, że i-węzeł dla katalogu głównego znajduje się w pamięci, ale nie ma w niej
i-węzłów dla innych katalogów w ścieżce. Załóżmy także, że wszystkie katalogi miesz-
czą się w jednym bloku dyskowym.
42. W wielu systemach uniksowych i-węzły są umieszczone na początku dysku. Alterna-
tywny projekt polega na przydzieleniu i-węzła w momencie tworzenia pliku i umiesz-
czeniu i-węzła na początku pierwszego bloku pliku. Omów argumenty za tym rozwiąza-
niem i przeciw niemu.
43. Napisz program, który odwraca bajty w pliku w taki sposób, że ostatni bajt jest pierw-
szym, a pierwszy bajt ostatnim. Program powinien działać z plikami o dowolnym roz-
miarze. Postaraj się, aby był stosunkowo wydajny.
44. Napisz program, który począwszy od określonego katalogu, schodzi w dół drzewa i reje-
struje rozmiary wszystkich znalezionych plików. Po zakończeniu tych działań powinien
wyświetlać histogram rozmiarów plików, wykorzystując szerokość koszyka podaną jako
parametr (np. dla wartości 1024 w jednym koszyku mieszczą się pliki o rozmiarach
0 – 1023 bajtów, w drugim 1024 – 2047 itd.).
45. Napisz program, który skanuje wszystkie katalogi w systemie plików UNIX oraz znaj-
duje i lokalizuje wszystkie i-węzły o liczniku twardych dowiązań równym dwa lub wię-
cej. Dla każdego takiego pliku program wyświetla wszystkie nazwy plików wskazujące
na plik.
46. Napisz nową wersję uniksowego programu ls. W tej wersji program pobiera jako argu-
ment nazwę jednego katalogu lub większej liczby katalogów i dla każdego z nich wyświe-
tla wszystkie pliki w tym katalogu, po jednym wierszu na plik. Każde pole powinno być
odpowiednio sformatowane, zgodnie z jego typem. Należy wyświetlić tylko pierwszy
adres dyskowy.
47. Napisz program do pomiaru wpływu rozmiarów bufora na poziomie aplikacji na czas
odczytu. Program wykorzystuje zapis i odczyt do dużego pliku (powiedzmy o rozmiarze
2 GB). Wypróbuj różne rozmiary bufora (np. od 64 bajtów do 4 kB). Do zmierzenia czasu
dla różnych rozmiarów bufora wykorzystaj procedury do mierzenia czasu (np. gettimeofday
oraz getitimer w systemie UNIX). Przeanalizuj wyniki i sformułuj wnioski z przeprowa-
dzonych badań: czy rozmiar bufora wpływa na ogólny czas zapis i czas pojedynczej ope-
racji zapisu?
48. Zaimplementuj symulowany system plików, który będzie się w całości zawierał w poje-
dynczym, zwykłym pliku zapisanym na dysku. Ten plik dyskowy będzie zawierał kata-
logi, i-węzły, informacje o blokach wolnych, bloki danych pliku itp. Wybierz odpowiednie
algorytmy do przechowywania informacji dotyczących wolnych bloków oraz przydziela-
nia bloków danych (ciągły, indeksowany, lista jednokierunkowa). Program powinien przyj-
mować od użytkownika polecenia systemowe tworzenia i usuwania katalogów, tworzenia,
usuwania i otwierania plików, czytania i pisania z (do) wybranego pliku oraz wyświetla-
nia zawartości katalogu.
Oprócz dostarczania abstrakcji, takich jak procesy (oraz wątki), przestrzenie adresowe i pliki,
system operacyjny zarządza również wszystkimi urządzeniami wejścia-wyjścia. Musi wydawać
polecenia do urządzeń, przechwytywać przerwania i obsługiwać błędy. Powinien także dostarczać
prosty i łatwy do posługiwania się interfejs pomiędzy urządzeniami i resztą systemu. W miarę
możliwości interfejs powinien być taki sam dla wszystkich urządzeń (w celu zapewnienia nieza-
leżności od urządzeń). Kod obsługi wejścia-wyjścia reprezentuje istotną część całego systemu
operacyjnego. Tematem tego rozdziału jest sposób, w jaki system operacyjny zarządza urządze-
niami wejścia-wyjścia.
Niniejszy rozdział zorganizowano w następujący sposób: po pierwsze przeanalizujemy pewne
warunki, jakie musi spełniać sprzętowa część wejścia-wyjścia, a następnie ogólnie omówimy
oprogramowanie wejścia-wyjścia. Oprogramowanie wejścia-wyjścia ma strukturę warstwową,
przy czym zadanie każdej warstwy jest dokładnie zdefiniowane. W tym rozdziale omówimy
poszczególne warstwy oprogramowania wejścia-wyjścia. Pokażemy, za co są odpowiedzialne
i w jaki sposób tworzą całość.
Po tym wprowadzeniu szczegółowo omówimy kilka urządzeń wejścia-wyjścia: dyski, zegary,
klawiatury i monitory. Przy okazji prezentacji każdego urządzenia scharakteryzujemy związany
z nim sprzęt i oprogramowanie. Na końcu omówimy zagadnienia związane z zarządzaniem energią.
Różni ludzie patrzą na sprzęt wejścia-wyjścia w różny sposób. Inżynierowie elektrycy analizują go
pod kątem układów, przewodów, zasilaczy, silników oraz wszystkich innych fizycznych kompo-
nentów. Programiści patrzą na interfejs udostępniany dla programów — polecenia, które
sprzęt przyjmuje, funkcje, jakie realizuje, oraz błędy, które może zgłaszać. W niniejszej książce
349
Tabela 5.1. Typowe szybkości przesyłania danych dla urządzeń, sieci i magistrali
Urządzenie Szybkość przesyłania danych
Klawiatura 10 bajtów/s
Mysz 100 bajtów/s
56K modem 7 kB/s
Skaner pracujący w rozdzielczości 300 dpi 1 MB/s
Kamera cyfrowa 3,5 MB/s
Dysk Blu-ray o szybkości 4x 18 MB/s
Sieć bezprzewodowa 802.11n 37,5 MB/s
USB 2.0 60 MB/s
FireWire 800 100 MB/s
Sieć Gigabit Ethernet 125 MB/s
Napęd dysku SATA 3 600 MB/s
USB 3.0 625 MB/s
Magistrala SCSI Ultra 5 640 MB/s
Jednopasmowa magistrala PCIe 3.0 985 MB/s
Magistrala Thunderbolt 2 2,5 GB/s
Sieć SONET OC-768 5 GB/s
Kontroler monitora LCD pracuje jako bitowe urządzenie szeregowe na równie niskim pozio-
mie. Czyta bajty zawierające znaki do wyświetlenia z pamięci i na tej podstawie generuje sygnały
używane do modulowania polaryzacji podświetlenia pikseli, które mają być wyświetlone na ekra-
nie. Gdyby nie kontroler monitora LCD, programista systemu operacyjnego musiałby jawnie
programować pole elektryczne dla wszystkich pikseli. Dzięki kontrolerowi system operacyjny
inicjuje kontroler kilkoma parametrami, takimi jak numer znaku lub liczba pikseli w wierszu,
i powierza kontrolerowi właściwą obsługę sterowania polem elektrycznym.
W bardzo krótkim czasie monitory LCD całkowicie zastąpiły stare monitory CRT (ang.
Cathode Ray Tube). W monitorach CRT wiązka elektronów jest kierowana na fluorescencyjny
ekran. Urządzenie, wykorzystując pola magnetyczne, potrafi zagiąć wiązkę i narysować piksele
na ekranie. W porównaniu z ekranami LCD monitory CRT były nieporęczne, zużywały dużo
energii i łatwo ulegały awariom. Ponadto rozdzielczość współczesnych ekranów LCD (Retina)
jest tak dobra, że ludzkie oko nie jest w stanie rozróżnić poszczególnych pikseli. Trudno sobie
dzisiaj wyobrazić, że w przeszłości laptopy były wyposażone w niewielkie ekrany CRT, z których
powodu miały ponad 20 cm grubości, a ich waga sięgała 12 kg.
procesor może wczytać rejestr sterujący PORT i zapisać wynik w rejestrze procesora REG. Na
podobnej zasadzie, korzystając z instrukcji:
OUT PORT,REG
procesor może zapisać zawartość swojego rejestru REG do rejestru sterującego urządzenia. W ten
sposób działała większość wczesnych komputerów, włącznie z prawie wszystkimi komputerami
mainframe, np. IBM 360, a także wszystkimi jego potomkami.
W tym schemacie przestrzenie adresowe dla pamięci i wejścia-wyjścia są różne, co poka-
zano na rysunku 5.1(a): W tym projekcie instrukcje:
IN R0,4
oraz:
MOV R0,4
Rysunek 5.1. (a) Osobne przestrzenie wejścia-wyjścia i pamięci; (b) urządzenia wejścia-wyjścia
odwzorowane w pamięci; (c) rozwiązanie mieszane
Rysunek 5.2. (a) Architektura z pojedynczą magistralą; (b) architektura pamięci z podwójną
magistralą
się inny mechanizm znany jako bezpośredni dostęp do pamięci (ang. Direct Memory Access — DMA).
Dla uproszczenia założymy, że procesor ma dostęp do wszystkich urządzeń i pamięci za pomocą
jednej magistrali systemowej, która łączy procesor, pamięć oraz urządzenia wejścia-wyjścia tak,
jak pokazano na rysunku 5.3. Wiemy już, że organizacja w nowoczesnych systemach jest bar-
dziej skomplikowana, ale wszystkie zasady są takie same. System operacyjny może skorzystać
z DMA tylko wtedy, gdy sprzęt jest wyposażony w kontroler DMA. Takie kontrolery są obecne
w większości systemów. Czasami taki kontroler jest zintegrowany z kontrolerami dysku oraz
innymi kontrolerami, ale taki mechanizm wymaga oddzielnego kontrolera DMA dla każdego
urządzenia. Częściej dostępny jest pojedynczy kontroler DMA (np. na płycie głównej), który
realizuje zadanie regulacji transmisji do wielu urządzeń, często równolegle.
Kontroler DMA inicjuje transfer poprzez wydanie żądania odczytu przez magistralę do
kontrolera dysku (krok 2.). To żądanie odczytu wygląda tak jak każde inne żądanie, a kontroler
dysku nie wie ani nie dba o to, czy pochodzi ono z procesora, czy z kontrolera DMA. Adres pamięci,
pod który ma nastąpić zapis, zwykle znajduje się na liniach adresowych magistrali. Dzięki temu
kiedy kontroler dysku pobierze następne słowo z pamięci wewnętrznej, będzie wiedział, gdzie
je zapisać. Zapis do pamięci jest kolejnym standardowym cyklem magistrali (krok 3.). Po zakoń-
czeniu zapisu kontroler wysyła sygnał potwierdzenia do kontrolera DMA, także poprzez magi-
stralę (krok 4.). Następnie kontroler DMA inkrementuje wykorzystywany adres pamięci i dekre-
mentuje licznik bajtów. Jeśli licznik bajtów w dalszym ciągu ma wartość większą od 0, kroki
2. – 4. są powtarzane tak długo, aż licznik osiągnie wartość 0. W tym momencie kontroler DMA
generuje przerwanie do procesora w celu poinformowania go, że transfer został zakończony.
Kiedy system operacyjny się uruchomi, nie będzie musiał kopiować bloku dyskowego do pamięci,
ponieważ potrzebny blok już tam jest.
Kontrolery DMA znacznie się różnią pomiędzy sobą stopniem złożoności. Najprostsze z nich
obsługują jedną operację transferu na raz, tak jak opisano powyżej. Bardziej złożone można
zaprogramować w taki sposób, aby obsługiwały wiele operacji transferu jednocześnie. Takie
kontrolery są wyposażone w wiele zestawów wewnętrznych rejestrów — po jednym dla każ-
dego kanału. Procesor rozpoczyna pracę od załadowania każdego zbioru rejestrów wraz z para-
metrami. W każdym transferze musi być wykorzystywany kontroler innego urządzenia. Po zakoń-
czeniu transferu każdego słowa (kroki 2. – 4. na rysunku 5.3) kontroler DMA podejmuje
decyzję o tym, które urządzenie będzie obsłużone w następnej kolejności. Kontroler może
wykorzystywać algorytm cykliczny lub posługiwać się mechanizmem priorytetów, który fawo-
ryzuje pewne urządzenia. W tym samym czasie może oczekiwać na obsługę wiele żądań do
różnych kontrolerów urządzeń, pod warunkiem że istnieje sposób jednoznacznego rozróżniania
potwierdzeń. Często się zdarza, że do tego celu dla każdego kanału DMA używana jest oddzielna
linia potwierdzająca na magistrali.
Wiele magistrali może działać w dwóch trybach: trybie przesyłania słowo po słowie i trybie
blokowym. Niektóre kontrolery DMA mogą również działać w obu tych trybach. W pierwszym
trybie transfer przebiega w sposób opisany powyżej: kontroler DMA żąda transferu jednego słowa
i żądanie jest spełnione. Jeśli procesor także chce korzystać z pamięci w tym samym czasie, musi
czekać. Mechanizm ten określa się terminem zabierania cykli (ang. cycle stealing), ponieważ
kontroler urządzenia co jakiś czas „wkrada się” na magistralę i zabiera kilka cykli procesorowi,
przy okazji nieco go spowalniając. W trybie blokowym kontroler DMA nakazuje urządzeniu prze-
jęcie magistrali, wykonanie ciągu operacji przesyłania, a następnie zwolnienie magistrali. Ope-
racja w takiej formie nosi nazwę trybu wiązki (ang. burst mode). Jest on wydajniejszy od trybu
zabierania cykli, ponieważ przejęcie magistrali zajmuje czas, a przy okazji jednego przejęcia
magistrali można przesłać wiele słów. Wadą trybu wiązki jest to, że jeśli jest przesyłana długa
wiązka, może dojść do zablokowania procesora i innych urządzeń na znaczny czas.
W omawianym modelu, czasami nazywanym trybem przelotu (ang. fly-by mode), kontroler DMA
nakazuje kontrolerowi urządzenia transfer danych bezpośrednio do pamięci głównej. Alterna-
tywnym trybem wykorzystywanym przez niektóre kontrolery DMA jest nakazanie kontrolerowi
urządzenia przesłania słowa do kontrolera DMA. Następnie kontroler DMA wysyła na magi-
stralę drugie żądanie zapisania słowa w miejscu, gdzie powinno być ono zapisane. Taki mechanizm
wymaga dodatkowego cyklu magistrali dla każdego przesyłanego słowa, ale jest bardziej ela-
styczny, ponieważ pozwala również na kopiowanie danych pomiędzy urządzeniami, a nawet
pomiędzy lokalizacjami pamięci (poprzez wywołanie operacji czytania do pamięci, a następnie
pisania do pamięci pod innym adresem).
Jeśli żadne z przerwań nie czeka na obsługę, kontroler przerwań przetwarza przerwanie
natychmiast. Jeśli akurat trwa obsługa innego przerwania lub jeśli inne urządzenie zażądało prze-
rwania na linii żądania przerwania o wyższym priorytecie, urządzenie jest przez chwilę igno-
rowane. W takim przypadku kontynuuje ustawianie sygnału przerwania na magistrali do czasu
obsłużenia go przez procesor.
Aby obsłużyć przerwanie, kontroler umieszcza numer na linii adresowej, określając, które
urządzenie wymaga uwagi, i ustawia sygnał mający na celu przerwanie pracy procesora.
Sygnał przerwania powoduje, że procesor zatrzymuje operację, którą wykonywał, i zaczyna
robić coś innego. Numer na liniach adresowych jest wykorzystywany jako indeks do tabeli
znanej jako wektor przerwań i służy do pobrania nowej wartości licznika programu. Ta wartość
licznika programu wskazuje na początek odpowiedniej procedury obsługi przerwania. Zazwyczaj
od tego momentu rozkazy pułapek i przerwań korzystają z tego samego mechanizmu, a często
współdzielą ten sam wektor przerwań. Wektor przerwań może być zaszyty „na sztywno” w sprzę-
cie lub może znajdować się w dowolnym miejscu pamięci, a rejestr procesora (ładowany przez
system operacyjny) wskazuje na jego początek.
Wkrótce po rozpoczęciu działania procedura obsługi przerwania potwierdza przerwanie po-
przez zapisanie określonej wartości w jednym z portów wejścia-wyjścia kontrolera przerwań.
Potwierdzenie to jest informacją dla kontrolera, że może on wygenerować inne przerwanie. Dzięki
temu, że procesor opóźnia wysłanie potwierdzenia do momentu uzyskania gotowości do obsługi
następnego przerwania, można uniknąć sytuacji wyścigu spowodowanych występowaniem wielu
(prawie jednoczesnych) przerwań. Na marginesie warto dodać, że niektóre (starsze) komputery
nie mają scentralizowanego kontrolera przerwań, zatem kontroler każdego urządzenia żąda
własnych przerwań.
Przed uruchomieniem procedury obsługi sprzęt zawsze zapisuje pewne informacje. Rodzaj
zapisywanych informacji oraz miejsce, gdzie zostają one zapisane, są różne dla różnych proce-
sorów. Całkowitym minimum jest zapisanie licznika programu. Dzięki temu można wznowić
przerwany proces. W drugim skrajnym podejściu zapisywane są wszystkie widoczne rejestry,
a także wiele rejestrów wewnętrznych.
Osobnym problemem jest miejsce, w którym te informacje mają być zapisane. Jedna z opcji
polega na umieszczeniu ich w wewnętrznych rejestrach, tak by system operacyjny mógł je odczytać
w miarę potrzeb. Problem przy takim rozwiązaniu polega na tym, że kontroler przerwań nie może
uzyskać potwierdzenia do czasu przeczytania wszystkich istotnych informacji, nie mówiąc już
o tym, że drugie przerwanie, zapisując swój stan, może nadpisać wewnętrzne rejestry. Taka
strategia prowadzi do długiego czasu zablokowania przerwań, a także możliwości utraty przerwań
i ewentualnie utraty danych.
Przerwanie, które pozostawia maszynę w dobrze zdefiniowanym stanie, określa się terminem
przerwania precyzyjnego [Walker i Cragon, 1995]. Takie przerwanie ma cztery właściwości:
1. Rejestr licznika programu (ang. Program Counter — PC) jest zapisany w znanym miejscu.
2. Wszystkie instrukcje przed tą, która jest wskazana przez rejestr PC, zostały w pełni
wykonane.
3. Żadna z instrukcji za tą, która jest wskazywana przez rejestr PC, nie została wykonana.
4. Stan wykonania instrukcji wskazywanej przez rejestr PC jest znany.
Zwróćmy uwagę, że nie ma zakazu rozpoczęcia wykonywania instrukcji występujących za instruk-
cją wskazywaną przez rejestr PC. Jedyne ograniczenie polega na tym, że zmiany w rejestrach
lub pamięci, wprowadzane przez te instrukcje, muszą być cofnięte, zanim zostanie wykonane
przerwanie. Instrukcja wskazywana przez rejestr PC może być wykonana. Dozwolony jest
również taki stan, w którym nie została ona wykonana.
Musi być jednak jasne, który przypadek zachodzi. Często się zdarza, że jeśli przerwanie
dotyczy wejścia-wyjścia, to instrukcja nie jest jeszcze rozpoczęta. Jeśli jednak przerwanie jest
w rzeczywistości spowodowane rozkazem pułapki lub błędem braku strony, to rejestr PC
zwykle wskazuje na instrukcję, która spowodowała błąd. Dzięki temu można ją później zrestar-
tować. Przerwanie precyzyjne zaprezentowano na rysunku 5.5(a). Wszystkie instrukcje aż do
licznika programu (316) zostały wykonane i żadna za tym adresem się nie rozpoczęła (lub została
cofnięta w celu odwrócenia jej efektów).
Przerwanie, które nie spełnia tych wymagań, określa się jako przerwanie nieprecyzyjne. Prze-
rwania tego typu bardzo uprzykrzają życie autorowi systemu operacyjnego, który musi się dowie-
dzieć, co się wydarzyło i co ma się jeszcze wydarzyć. Na rysunku 5.5(b) pokazano nieprecyzyjne
przerwanie, w którym różne instrukcje w pobliżu licznika programu są w różnych fazach wyko-
nania, przy czym stopień wykonania starszych niekoniecznie jest bardziej zaawansowany niż
młodszych. Komputery z nieprecyzyjnymi przerwaniami zazwyczaj umieszczają na stosie duże
ilości informacji dotyczących ich wewnętrznego stanu. Dzięki temu system operacyjny może
stwierdzić, co się stało. Kod potrzebny do zrestartowania maszyny zwykle jest niezwykle skom-
plikowany. Także zapisywanie dużych ilości informacji do pamięci wraz z każdym przerwaniem
sprawia, że przerwania są obsługiwane wolno, a wznawianie działania po przerwaniu okazuje
się jeszcze trudniejsze. Prowadzi to do komicznej sytuacji, w której bardzo szybki procesor
superskalarny nie jest w stanie wykonywać pracy w czasie rzeczywistym z powodu bardzo
wolnych przerwań.
Niektóre komputery zostały zaprojektowane w taki sposób, że niektóre przerwania i rozkazy
pułapek są precyzyjne, a inne nie. Jeśli np. przerwania wejścia-wyjścia są precyzyjne, natomiast
odpowiada któremu urządzeniu. Na bazie np. katalogu /usr/ast/backup można zamontować pendrive
USB w taki sposób, że kopiowanie pliku do katalogu /usr/ast/backup/monday spowoduje skopio-
wanie pliku na pendrive. W ten sposób wszystkie pliki i urządzenia są adresowane w taki sam
sposób: według nazwy ścieżki.
Innym ważnym problemem dla oprogramowania wejścia-wyjścia jest obsługa błędów. Ogólnie
rzecz biorąc, błędy powinny być obsługiwane tak blisko sprzętu, jak to możliwe. Jeśli kontroler
wykryje błąd dysku, powinien w miarę możliwości spróbować sam naprawić błąd. Jeśli błędu
się nie da naprawić, to powinien spróbować go obsłużyć sterownik urządzenia — np. poprzez
podjęcie ponownej próby przeczytania bloku. Wiele błędów ma charakter przejściowy, np. błędy
odczytu spowodowane kurzem na głowicy odczytu. Często błędy te znikają, jeśli operacja zosta-
nie powtórzona. Górne warstwy powinny być poinformowane o problemie tylko wtedy, gdy dolne
warstwy nie potrafią sobie z nim poradzić. W wielu przypadkach obsługę błędów można prze-
prowadzić w sposób przezroczysty na niskim poziomie, a wyższe warstwy mogą nawet nie
wiedzieć, że wystąpił błąd.
Jeszcze innym kluczowym problemem jest rodzaj transferu. Można wyróżnić transfery syn-
chroniczne (blokujące) oraz asynchroniczne (sterowane przerwaniami). Większość fizycznych
urządzeń wejścia-wyjścia jest asynchronicznych — procesor rozpoczyna transfer i zaczyna
zajmować się czymś innym. Robi to, dopóki nie nadejdzie przerwanie. Programy użytkownika są
o wiele łatwiejsze do napisania, jeśli operacje wejścia-wyjścia są blokujące — po wydaniu wywo-
łania systemowego read program jest automatycznie zawieszany do czasu, aż w buforze będą
dostępne dane. Zadanie systemu operacyjnego polega na takim przygotowaniu operacji sterowa-
nych przerwaniami, by dla programów użytkownika wyglądały na blokujące. Jednak niektóre
aplikacje bardzo wysokiej wydajności muszą kontrolować wszystkie szczegóły wejścia-wyjścia,
dlatego w niektórych systemach operacyjnych są dostępne asynchroniczne transfery wejścia-
-wyjścia.
Kolejnym problemem dla oprogramowania wejścia-wyjścia jest buforowanie. Często się zdarza,
że dane wysyłane z urządzenia nie mogą być bezpośrednio zapisane w docelowej lokalizacji.
Kiedy np. z sieci nadejdzie pakiet, system operacyjny nie będzie wiedział, gdzie go umieścić,
do czasu zapisania pakietu w jakimś miejscu i przeanalizowania go. Niektóre urządzenia mają
również ścisłe ograniczenia czasu rzeczywistego (np. cyfrowe urządzenia audio). W związku
z tym dane muszą być umieszczone w buforze wyjściowym zawczasu w celu oddzielenia tempa,
w jakim bufor jest zapełniany, od tempa, w jakim jest opróżniany. Ma to zapobiec błędowi zbyt
wczesnego opróżniania bufora (ang. buffer underrun). Buforowanie wymaga intensywnego kopio-
wania i często ma istotny wpływ na wydajność operacji wejścia-wyjścia.
Ostatnim pojęciem, które omówimy w tym punkcie, będzie zestawienie urządzeń współ-
dzielonych z dedykowanymi. Niektóre urządzenia wejścia-wyjścia, np. dyski, mogą być używane
przez wielu użytkowników jednocześnie. Otwarcie plików na tym samym dysku i w tym samym
czasie nie sprawia żadnych problemów. Inne urządzenia, jak drukarki, muszą być dedykowane
dla pojedynczego użytkownika i przydzielone do niego do czasu zakończenia pracy. Dopiero
potem inny użytkownik może skorzystać z drukarki. Jeśli dwóch użytkowników (lub większa
ich liczba) zacznie na przemian losowo zapisywać bloki na tej samej stronie, z pewnością wystąpią
problemy. Wprowadzenie dedykowanych (niewspółdzielonych) urządzeń także powoduje szereg
problemów, np. zakleszczenia. Również w tym przypadku system operacyjny musi mieć możli-
wość obsługi zarówno współdzielonych, jak i dedykowanych urządzeń w taki sposób, by uniknąć
problemów.
W tym momencie system operacyjny oczekuje, aż drukarka będzie gotowa. Kiedy to się
stanie, wydrukuje następny znak, tak jak pokazano na rysunku 5.6(c). Pętla ta jest powtarzana aż
do chwili wydrukowania całego ciągu znaków. Następnie sterowanie powraca do procesu użyt-
kownika.
Działania wykonywane przez system operacyjny zestawiono na listingu 5.1. Najpierw dane
są kopiowane do jądra. Następnie system operacyjny rozpoczyna pętlę wyświetlania znaków —
po jednym w iteracji. Kluczowym aspektem programowanego wejścia-wyjścia, który w czytelny
sposób zilustrowano na tym listingu, jest to, że po wyprowadzeniu znaku procesor sprawdza,
czy urządzenie jest gotowe do zaakceptowania kolejnego znaku. Takie działanie określa się jako
odpytywanie lub aktywne oczekiwanie.
Programowane wejście-wyjście jest proste, ale ma wadę polegającą na tym, że procesor jest
przez cały czas związany — tak długo, aż operacja wejścia-wyjścia się zakończy. Jeśli czas na
„wydrukowanie” znaku jest bardzo krótki (ponieważ drukarka tylko kopiuje nowy znak do
wewnętrznego bufora), to aktywne oczekiwanie sprawdza się. Ponadto w systemach wbudowanych,
w których procesor nie ma nic więcej do zrobienia, aktywne oczekiwanie ma sens. Jednak
w bardziej złożonych systemach, gdzie procesor ma inną pracę do wykonania, aktywne oczeki-
wanie okazuje się nieefektywne. Potrzebna jest lepsza metoda implementacji wejścia-wyjścia.
Jeśli nie ma więcej znaków do wydrukowania, procedura obsługi przerwania podejmuje działania
zmierzające do odblokowania użytkownika. W przeciwnym wypadku procedura wyprowadza
następny znak, potwierdza przerwanie i zwraca sterowanie do procesu, który działał bezpośrednio
przed wystąpieniem przerwania, w miejscu, w którym proces wtedy działał.
Listing 5.3. Drukowanie ciągu znaków z wykorzystaniem DMA; (a) kod wykonywany w czasie,
kiedy jest realizowane wywołanie systemowe print; (b) procedura obsługi przerwania
(a) (b)
copy_from_user(buffer, p, count); acknowledge_interrupt( );
set_up_DMA_controller(); unblock_user( );
scheduler(); return_from_interrupt( );
Wielką zaletą zastosowania DMA jest zmniejszenie liczby przerwań z jednego na znak do
jednego na wydrukowany bufor. Jeśli jest wiele znaków, a przerwania działają wolno, może to
być znaczące usprawnienie. Z drugiej strony kontroler DMA bywa zazwyczaj znacznie wolniejszy
od głównego procesora. Jeśli kontroler DMA nie jest w stanie sterować urządzeniem z pełną
szybkością lub jeśli procesor podczas oczekiwania na przerwanie DMA zwykle nie ma innego
zajęcia, to mechanizm wejścia-wyjścia sterowany przerwaniami lub nawet mechanizm progra-
mowanego wejścia-wyjścia może okazać się lepszy. Jednak w większości przypadków wyko-
rzystanie DMA się opłaca.
Czasem jednak zupełnie różne urządzenia bazują na tej samej technologii. Najbardziej znanym
przykładem jest prawdopodobnie USB, technologia szeregowej magistrali, która nieprzypad-
kowo została nazwana uniwersalną. Urządzeniami USB mogą być dyski, karty pamięci, aparaty
fotograficzne, myszy, klawiatury, miniaturowe wentylatory, bezprzewodowe karty sieciowe,
roboty, czytniki kart kredytowych, golarki z akumulatorkami, niszczarki dokumentów, skanery
kodów kreskowych, kule dyskotekowe i przenośne termometry. Wszystkie one korzystają z USB,
mimo że wykonują bardzo różne rzeczy. Jest to możliwe, ponieważ sterowniki USB są zazwy-
czaj ułożone w stos, podobnie jak stos TCP/IP w sieciach. Dolna warstwa, zwykle zrealizowana
sprzętowo, to warstwa łącza USB (szeregowego wejścia-wyjścia), która obsługuje operacje
związane ze sprzętem — np. sygnalizację i dekodowanie strumienia sygnałów na pakiety USB.
Pakiety są wykorzystywane przez wyższe warstwy, które obsługują dane oraz wspólną dla więk-
szości urządzeń funkcję USB. Powyżej tej warstwy działają interfejsy API wyższego poziomu —
służące do obsługi pamięci masowej, kamer itp. Tak więc wciąż mamy oddzielne sterowniki
urządzeń — choć częściowo współdzielą stos protokołów.
W celu uzyskania dostępu do sprzętu urządzenia, tzn. rejestrów kontrolera, sterownik urzą-
dzenia musi być częścią jądra systemu operacyjnego (przynajmniej tak jest w bieżących archi-
tekturach). Można też stworzyć sterowniki działające w przestrzeni użytkownika, które posługują
się wywołaniami systemowymi w celu odczytu i zapisu rejestrów urządzenia. Taki projekt
izoluje jądro od sterowników, a sterowniki od siebie, co eliminuje główne źródło awarii systemu —
błędnie działające sterowniki, które w taki czy inny sposób zakłócają pracę jądra. Jeśli celem
jest tworzenie wysoce niezawodnych systemów, to jest z całą pewnością właściwa konstrukcja.
Przykładem systemu, w którym sterowniki urządzeń działają jako procesy użytkownika, jest
MINIX 3 (www.minix3.org). Ponieważ jednak większość innych systemów operacyjnych kom-
puterów typu desktop oczekuje od sterowników, że będą one działały w jądrze, to właśnie ten
model omówimy w tym miejscu.
Jako że projektanci wszystkich systemów operacyjnych wiedzą, że będą w nich instalowane
fragmenty kodu (sterowniki) pisane przez zewnętrznych producentów, systemy operacyjne muszą
mieć architekturę, która na to pozwoli. Oznacza to konieczność stworzenia ściśle zdefiniowanego
modelu tego, co robi sterownik i w jaki sposób komunikuje się z resztą systemu operacyjnego.
Sterowniki urządzeń są zwykle umieszczone poniżej reszty systemu operacyjnego, co pokazano
na rysunku 5.8.
Systemy operacyjne zazwyczaj przydzielają sterownik do jednej z kilku kategorii. Najpopu-
larniejsze kategorie to urządzenia blokowe — np. dyski, które zawierają wiele bloków danych
i które można adresować niezależnie — oraz urządzenia znakowe — np. klawiatury i drukarki,
które generują lub akceptują strumienie znaków.
Większość systemów operacyjnych definiuje standardowy interfejs, który muszą obsługiwać
wszystkie sterowniki blokowe, oraz drugi standardowy interfejs, który muszą obsługiwać wszyst-
kie sterowniki znakowe. Interfejsy te składają się z szeregu procedur, które mogą być wywo-
ływane z pozostałej części systemu operacyjnego po to, by sterownik wykonał zleconą mu pracę.
Do typowych procedur należy odczyt bloku (urządzenia blokowe) lub zapis ciągu znaków (urzą-
dzenia znakowe).
W niektórych systemach system operacyjny jest pojedynczym programem binarnym, w któ-
rym są wbudowane wszystkie potrzebne sterowniki. Taki układ był normą przez wiele lat
w systemach UNIX, ponieważ zwykle działały one w ośrodkach obliczeniowych, a urządzenia
wejścia-wyjścia rzadko się zmieniały. W przypadku dodania nowego urządzenia administrator
systemu jeszcze raz kompilował jądro z nowym sterownikiem i tworzył nowy obraz binarny.
Rysunek 5.9. (a) Bez standardowego interfejsu sterownika; (b) ze standardowym interfejsem
sterownika
ników do funkcji. Dzięki temu, jeśli potrzebuje wywołania jednej z funkcji, może skorzystać
z pośredniego wywołania, poprzez tę tabelę. Tabela wskaźników funkcji definiuje interfejs
pomiędzy sterownikiem a resztą systemu operacyjnego. Muszą go implementować wszystkie
urządzenia określonej klasy (dyski, drukarki itp.).
Innym aspektem jednolitego interfejsu jest sposób nadawania nazw urządzeniom wej-
ścia-wyjścia. Oprogramowanie niezależne od sprzętu zajmuje się odwzorowaniem symbolicznych
nazw urządzeń na właściwy sterownik. W systemie UNIX np. nazwa urządzenia postaci /dev/disk0
w unikatowy sposób określa i-węzeł specjalnego pliku. Ten i-węzeł zawiera z kolei główny numer
urządzenia używany do zlokalizowania właściwego sterownika. I-węzeł zawiera również pomoc-
niczy numer urządzenia, który jest przekazywany do sterownika jako parametr w celu określenia
jednostki do odczytu lub zapisu. Główne i pomocnicze numery są przypisane do każdego z urzą-
dzeń. Dostęp do każdego sterownika odbywa się za pośrednictwem głównego numeru urządze-
nia — właśnie w ten sposób można wybrać sterownik.
Zagadnieniem blisko powiązanym z nazwami są zabezpieczenia. W jaki sposób system chroni
urządzenia przed dostępem do nich nieuprawnionych użytkowników? Zarówno w systemach
UNIX, jak i Windows urządzenia w systemie plików występują jako nazwane obiekty. Oznacza
to, że zwykłe reguły zabezpieczeń dla plików mają również zastosowanie dla urządzeń
wejścia-wyjścia. Administrator systemu może następnie ustawić odpowiednie uprawnienia dla
każdego urządzenia.
Buforowanie
Innym problemem dotyczącym zarówno urządzeń blokowych, jak i znakowych jest buforowanie.
W celu przeanalizowania jednego z kłopotów rozważmy proces, który chce czytać dane z modemu
ADSL (ang. Asymmetric Digital Subscriber Line) — urządzenia wykorzystywanego dziś przez
wiele osób do połączenia z internetem. Jedną z możliwych strategii postępowania z wchodzą-
cymi znakami jest zlecenie procesowi użytkownika wykonania wywołania systemowego read,
a następnie zablokowanie się w oczekiwaniu na jeden znak. Każdy wchodzący znak powoduje
przerwanie. Procedura obsługi przerwania przekazuje znak do procesu użytkownika i odbloko-
wuje go. Po umieszczeniu znaku w jakimś buforze proces czyta kolejny znak i ponownie się
blokuje. Taki model pokazano na rysunku 5.10(a).
Kłopot z takim podejściem polega na tym, że trzeba uruchomić proces użytkownika dla
każdego wchodzącego znaku. Zezwolenie procesowi na to, by wielokrotnie się uruchamiał na
krótki czas, jest niewydajne, zatem ten projekt nie jest dobry.
Usprawnienie zaprezentowano na rysunku 5.10(b). W tym przypadku proces użytkownika
udostępnia bufor o pojemności n znaków w przestrzeni użytkownika i jest w stanie czytać n
znaków. Procedura obsługi przerwania umieszcza wchodzące znaki w buforze tak długo, aż
bufor się zapełni. Następnie budzi proces użytkownika. Mechanizm ten jest znacznie wydajniejszy
od poprzedniego, ale ma wadę: co się stanie, jeśli w momencie nadejścia znaku strona z buforem
będzie się znajdować w pliku wymiany? Bufor można by zablokować w pamięci, ale jeśli wiele
procesów zacznie blokować strony w pamięci, pula dostępnych stron zmniejszy się, co dopro-
wadzi do obniżenia wydajności.
Jeszcze inny sposób polega na utworzeniu bufora wewnątrz jądra i nakazaniu procedurze
obsługi przerwania umieszczania w nim znaków, tak jak pokazano na rysunku 5.10(c). Kiedy
ten bufor się zapełni, do pamięci zostanie załadowana strona z buforem użytkownika, a następnie
w jednej operacji zostanie do niej skopiowany bufor z przestrzeni jądra. Taki mechanizm okazuje
się znacznie wydajniejszy.
Jednak nawet ten ulepszony mechanizm nie jest pozbawiony problemów: co się dzieje ze
znakami nadchodzącymi, gdy strona z buforem użytkownika jest ładowana z dysku? Ponieważ
bufor jest pełny, nie ma miejsca na umieszczanie w nim znaków. Problem można rozwiązać
poprzez użycie drugiego bufora jądra. Po zapełnieniu się pierwszego bufora, ale przed jego opróż-
nieniem, używany jest drugi bufor, tak jak pokazano na rysunku 5.10(d). Kiedy zapełni się drugi
bufor, stanie się on dostępny do skopiowania do przestrzeni użytkownika (przy założeniu, że
użytkownik o to prosił). Podczas gdy drugi bufor jest kopiowany do przestrzeni użytkownika,
pierwszy może być używany do przyjmowania nowych znaków. W ten sposób dwa bufory są
wykorzystywane na zmianę: gdy jeden jest kopiowany do przestrzeni użytkownika, drugi zbiera
nowe dane wejściowe. Mechanizm buforowania podobny do tego nazywa się podwójnym bufo-
rowaniem.
Inną powszechnie używaną formą buforowania jest bufor cykliczny. Składa się on z obszaru
pamięci oraz dwóch wskaźników. Jeden wskaźnik wskazuje na następne nowe słowo, w którym
można umieścić nowe dane. Drugi wskaźnik wskazuje na pierwsze słowo danych, którego
jeszcze nie usunięto. W wielu sytuacjach sprzęt inkrementuje pierwszy wskaźnik w miarę doda-
wania nowych danych (np. otrzymanych przed chwilą z sieci), a system operacyjny inkremen-
tuje drugi wskaźnik w miarę usuwania i przetwarzania danych. Oba wskaźniki „zawijają się” —
wracają na koniec, kiedy osiągną początek.
Buforowanie jest również bardzo ważne na wyjściu. Zastanówmy się np., w jaki sposób
byłoby realizowane wyjście do modemu bez wykorzystania buforowania zgodnego z modelem
z rysunku 5.10(b). Proces użytkownika wykonuje wywołanie systemowe write w celu wypro-
wadzenia n znaków. W tym momencie system ma dwie możliwości wyboru. Może zablokować
użytkownika do czasu zapisania wszystkich znaków, ale to może zająć bardzo dużo czasu
w przypadku wolnych łączy telefonicznych. Może również natychmiast zwolnić użytkownika
i przeprowadzić operacje wejścia-wyjścia w czasie, gdy użytkownik wykonuje inne obliczenia.
To prowadzi jednak do jeszcze poważniejszego problemu: w jaki sposób proces użytkownika
będzie wiedział, że wyjście się zakończyło i można ponownie skorzystać z bufora. System
mógłby wygenerować sygnał lub przerwanie sprzętowe, ale taki styl programowania jest trudny
i podatny na sytuacje wyścigu. Znacznie lepszym rozwiązaniem dla jądra okazuje się skopiowanie
danych do bufora w jądrze, analogicznego do tego, który pokazano na rysunku 5.10(c) (ale w inny
sposób), i natychmiastowe zwolnienie wywołującego. Teraz nie ma znaczenia, kiedy wykonano
właściwą operację wejścia-wyjścia. Użytkownik może bez przeszkód ponownie wykorzystać
bufor natychmiast po jego odblokowaniu.
Buforowanie jest powszechnie stosowaną techniką, ale ma również wady. Jeśli dane są bufo-
rowane zbyt wiele razy, obniża się wydajność. Rozważmy dla przykładu sieć z rysunku 5.11.
W pokazanym przypadku użytkownik realizuje wywołanie systemowe w celu zapisu danych do
sieci. Jądro kopiuje pakiet do bufora jądra w celu umożliwienia użytkownikowi natychmiasto-
wego kontynuowania pracy (krok 1.). W tym momencie program użytkownika może ponownie
wykorzystać bufor.
Rysunek 5.11. Przy pracy w sieci może być wykorzystywanych wiele kopii pakietu
Po wywołaniu sterownik kopiuje pakiet do kontrolera w celu jego zwrócenia (krok 2.). Powo-
dem, dla którego sterownik nie wyprowadza danych bezpośrednio do wyjścia z pamięci jądra,
jest to, że po rozpoczęciu transmisji musi być ona kontynuowana ze stałą szybkością. Sterownik
nie może zapewnić dostępu do pamięci ze stałą szybkością, ponieważ kanały DMA oraz inne
urządzenia wejścia-wyjścia mogą „podkradać” wiele cykli. Jeśli słowo nie dotarłoby na czas,
pakiet stałby się bezużyteczny. Dzięki buforowaniu pakietu wewnątrz kontrolera można uniknąć
tego problemu.
Po skopiowaniu pakietu do wewnętrznego bufora kontrolera pakiet jest kopiowany do sieci
(krok 3.). Bity przychodzą do odbiorcy niedługo po ich przesłaniu, zatem w chwilę po wysłaniu
ostatniego bitu dociera on do odbiorcy — tam, gdzie zbuforowano pakiet w kontrolerze. Następnie
pakiet jest kopiowany do bufora jądra odbiorcy (krok 4.). Na koniec zostaje skopiowany do bufora
procesu odbierającego (krok 5.). Zazwyczaj wtedy odbiorca przesyła potwierdzenie. Kiedy
nadawca otrzyma potwierdzenie, może przesłać następny pakiet. Należy jednak zaznaczyć, że
wspomniane kopiowanie znacznie spowalnia szybkość transmisji, ponieważ wszystkie kroki muszą
być wykonane sekwencyjnie.
Raportowanie błędów
Błędy w kontekście wejścia-wyjścia występują znacznie częściej niż w innych kontekstach.
Kiedy wystąpią, system operacyjny musi je obsłużyć najlepiej, jak to tylko możliwe. Jest wiele
błędów specyficznych dla urządzeń, które musi obsłużyć odpowiedni sterownik, ale szkielet
obsługi błędów pozostaje niezależny od urządzenia.
Jedną z klas błędów wejścia-wyjścia są błędy programistyczne. Występują one w przypadku,
gdy proces prosi o coś, co jest niemożliwe — np. zapis do urządzenia wejściowego (klawiatury,
skanera myszy itp.) lub odczyt z urządzenia wyjściowego (drukarka, ploter itp.). Do innych
błędów można zaliczyć dostarczenie nieprawidłowego adresu bufora lub innych parametrów czy
też wskazanie niepoprawnego urządzenia (np. dysku nr 3, jeśli system jest wyposażony tylko
w dwa dyski). Działanie, które należy podjąć w odpowiedzi na te błędy, jest oczywiste: należy
zwrócić kod błędu do wywołującego.
Inną klasą błędów są właściwe błędy wejścia-wyjścia — np. próba zapisania bloku dyskowego,
który został uszkodzony, czy też próba odczytu z kamery wideo, którą wyłączono. W tych oko-
licznościach podjęcie decyzji o odpowiednim sposobie działania należy do sterownika. Jeśli ste-
rownik nie wie, co zrobić, może przekazać problem do oprogramowania niezależnego od urządzeń.
Działanie tego programu zależy od środowiska oraz natury błędu. Jeśli jest to prosty błąd
odczytu i jest dostępny interaktywny użytkownik, możliwe, że wyświetli się okno dialogowe
z pytaniem do użytkownika o dalsze kroki. Jeśli użytkownik nie jest dostępny, prawdopodobnie
jedyną realną opcją staje się zakończenie wywołania systemowego i zgłoszenie kodu błędu.
Niektórych błędów nie można jednak obsłużyć w ten sposób. Jeśli zostanie zniszczona klu-
czowa struktura danych, np. katalog główny lub lista wolnych bloków, to system powinien wyświe-
tlić komunikat o błędzie i zakończyć działanie. Nie może zrobić nic więcej.
Instrukcja ta formatuje ciąg znaków składający się z 15-znakowego łańcucha „Kwadrat liczby”,
za którym występuje wartość i w postaci 3-znakowego łańcucha, dalej znajdują się 8-znakowy
łańcuch „wynosi”, wartość i2 zapisana w postaci sześciu znaków i — na koniec — znak przejścia
do nowego wiersza.
Przykładem podobnej procedury dla wejścia jest scanf. Instrukcja ta działa w ten sposób, że
czyta wejście i zapisuje do zmiennych opisanych w ciągu formatującym. Składnia ciągu formatu-
jącego jest taka sama jak w przypadku instrukcji printf. Standardowa biblioteka wejścia-wyjścia
zawiera wiele procedur wejścia-wyjścia. Wszystkie one działają w ramach programów użytkownika.
Nie wszystkie programy wejścia-wyjścia poziomu użytkownika składają się z procedur biblio-
tecznych. Inną ważną kategorią jest system spoolingu. Spooling jest sposobem postępowania
z dedykowanymi urządzeniami wejścia-wyjścia w systemach wieloprogramowych. Rozważmy
przykład typowego urządzenia wykorzystującego spooling: drukarki. Chociaż z technicznego
punktu widzenia zezwolenie procesowi użytkownika na otwarcie specjalnego pliku znakowego
dla drukarki jest łatwe, przypuśćmy, że proces otworzy ten plik, a następnie nie będzie robił nic
przez kilka godzin. Żaden inny proces nie będzie mógł niczego wydrukować.
Zamiast tego tworzy się więc specjalny proces zwany demonem i specjalny katalog zwany
katalogiem spoolera. Aby wydrukować plik, proces najpierw generuje cały plik do wydruku,
a następnie umieszcza go w katalogu spoolera. Drukowanie plików znajdujących się w tym kata-
logu to zadanie demona, który jest jedynym procesem posiadającym prawo do używania specjal-
nego pliku drukarki. Uniemożliwienie bezpośredniego wykorzystania pliku przez użytkowników
powoduje, że eliminuje się problem niepotrzebnego utrzymywania otwartego bufora przez zbyt
długi czas.
Technika spoolingu jest wykorzystywana nie tylko w przypadku drukarek. Wykorzystuje
się ją również do innych operacji wejścia-wyjścia. Przykładowo do transmisji plików w sieci
często wykorzystuje się demona sieci. W celu wysłania pliku użytkownik umieszcza go w kata-
logu spoolera sieci. Demon sieciowy później go przejmuje i przesyła. Jednym ze szczególnych
zastosowań transmisji plików bazującej na spoolerze jest system news USENET. Ta sieć składa
się z milionów komputerów na całym świecie, które komunikują się ze sobą przez internet.
Istnieją tysiące grup news poświęconych rozmaitym tematom. Aby opublikować news, użyt-
kownik wywołuje specjalny program, który pobiera wiadomość do opublikowania, a następnie
umieszcza ją w katalogu spoolera w celu późniejszej transmisji do innych maszyn. System news
w całości działa poza systemem operacyjnym.
System wejścia-wyjścia podsumowano na rysunku 5.12. Pokazano na nim wszystkie warstwy
oraz zasadnicze funkcje każdej z nich. Kolejne warstwy, zaczynając od dołu, to sprzęt, procedury
obsługi przerwań, sterowniki urządzeń, oprogramowanie niezależne od sprzętu i — na koniec —
procesy użytkownika.
Rysunek 5.12. Warstwy systemu wejścia-wyjścia oraz główne funkcje poszczególnych warstw
Strzałki zaprezentowane na rysunku 5.12 pokazują przepływ sterowania. Kiedy np. program
użytkownika próbuje przeczytać blok z pliku, wywoływany jest system operacyjny w celu reali-
zacji odpowiedniej funkcji. Może to być wyszukanie bloku w podręcznej pamięci buforowej. Za
tę czynność jest odpowiedzialne oprogramowanie niezależne od sprzętu. Jeśli żądanego bloku
tam nie ma, system operacyjny wywołuje sterownik urządzenia w celu skierowania żądania do
sprzętu, aby blok został pobrany z dysku. Następnie proces się blokuje do czasu zakończenia
operacji dyskowej, czyli momentu, gdy dane są bezpieczne i dostępne w buforze procesu wywo-
łującego.
Kiedy dysk zakończy pracę, sprzęt generuje przerwanie. Uruchamiana jest procedura obsługi
przerwania, która sprawdza, co się stało — czyli które urządzenie żąda natychmiastowej obsługi.
Następnie pobiera status urządzenia i budzi uśpiony proces w celu zakończenia żądania wej-
ścia-wyjścia i umożliwienia procesowi użytkownika kontynuacji działania.
5.4. DYSKI
5.4.
DYSKI
5.4.1. Sprzęt
Dyski są dostępne w wielu różnych typach. Do najpopularniejszych należą dyski magnetyczne.
Ich cechą charakterystyczną jest jednakowa szybkość odczytu i zapisu. Ze względu na to idealnie
nadają się do zastosowania w roli pomocniczej pamięci (stronicowanie, systemy plików itp.). Cza-
sami wykorzystuje się macierze złożone z dysków tego typu, co zapewnia wysoce niezawodne
składowanie danych. Dla dystrybucji programów, danych i filmów istotne znaczenie mają różne
rodzaje dysków optycznych (płyty DVD i Blu-ray). Wreszcie coraz bardziej popularne są dyski
SSD, ponieważ są szybkie i nie zawierają ruchomych części. W poniższych punktach w roli
przykładowego sprzętu omówimy dyski magnetyczne, a następnie ogólnie opiszemy oprogramo-
wanie dla urządzeń dyskowych.
Dyski magnetyczne
Dyski magnetyczne są zorganizowane w cylindry. Każdy z nich zawiera tyle ścieżek, ile głowic
w pionie ma dysk. Ścieżki dzielą się na sektory. Liczba ścieżek na obwodzie zazwyczaj wynosi
8 – 32 dla dyskietek elastycznych oraz do kilkuset w przypadku dysków twardych. Liczba głowic
waha się od 1 do około 16.
Starsze dyski zawierają niewiele elektroniki i jedynie dostarczają prostych, szeregowych
strumieni bitowych. Na tych dyskach większość zadań wykonuje kontroler. Na innych dyskach,
w szczególności IDE (ang. Integrated Drive Electronics) i SATA (Serial ATA), sam dysk jest
wyposażony w mikrokontroler, który wykonuje znaczącą część zadań i pozwala rzeczywistemu
kontrolerowi na wydawanie poleceń wyższego poziomu. Kontroler często realizuje buforowanie
ścieżek, zmianę odwzorowania uszkodzonych bloków oraz wiele innych operacji.
Istotny wpływ na działanie sterownika dysku ma zdolność kontrolera do wykonywania ope-
racji seek jednocześnie na dwóch napędach lub większej ich liczbie. Są to tzw. operacje seek zacho-
dzące na siebie (ang. overlapped seeks). Kiedy kontroler i oprogramowanie czekają na zakończenie
operacji seek na jednym napędzie, kontroler może zainicjować operację seek na innym napędzie.
Wiele kontrolerów potrafi również czytać lub zapisywać dane na jednym dysku i jednocześnie
wykonywać operację seek na jednym dysku lub kilku innych dyskach. Kontroler dysków elastycz-
nych nie potrafi jednak pisać ani zapisywać danych na dwóch dyskach jednocześnie (odczyt lub
zapis wymaga od kontrolera przesyłania bitów w skali mikrosekund, zatem jedna operacja
transmisji wykorzystuje większą część jego mocy obliczeniowej). Sytuacja jest nieco odmienna
w przypadku dysków twardych wyposażonych w zintegrowane kontrolery. W systemie wyposa-
żonym w więcej niż jeden taki dysk twardy mogą one działać równolegle — przynajmniej na
poziomie przesyłania danych pomiędzy dyskiem a pamięcią buforową kontrolera. W danym
momencie jest jednak możliwy tylko jeden transfer pomiędzy kontrolerem a pamięcią główną.
Zdolność do wykonywania dwóch lub większej liczby operacji w tym samym czasie może znacznie
skrócić średni czas dostępu.
Aby pokazać, jak bardzo dyski zmieniły się w ciągu ostatnich trzech dekad, w tabeli 5.3 porów-
nano parametry standardowego nośnika pamięci trwałej w oryginalnym komputerze IBM PC
z parametrami dysku wyprodukowanego 30 lat później. Należy zwrócić uwagę, że nie wszystkie
parametry poprawiły się w równym stopniu. Średni czas wyszukiwania jest dziewięciokrotnie
krótszy, niż był wcześniej, szybkość transferu jest aż 16 tysięcy razy większa, natomiast współ-
czynnik poprawy pojemności sięga 800 tysięcy. Wyniki te są rezultatem stopniowego postępu
w dziedzinie technologii mechanicznych, ale w znacznie większym stopniu odzwierciedlają postęp
w pracach nad gęstością zapisu na nośnikach pamięci.
Rysunek 5.13. (a) Fizyczna geometria dysku zawierającego dwie strefy; (b) możliwa wirtualna
geometria dysku
W celu obejścia tego ograniczenia wszystkie nowoczesne dyski obsługują teraz system znany jako
logiczne adresowanie bloków, w którym sektory dyskowe są ponumerowane po kolei, począwszy
od 0, bez względu na geometrię dysku.
RAID
Wydajność procesorów w ciągu ostatniej dekady zwiększa się wykładniczo. W przybliżeniu podwaja
się co 18 miesięcy. W przypadku dysków wyniki nie są tak dobre W latach siedemdziesiątych
przeciętny czas wyszukiwania na dyskach minikomputerów wynosił 50 – 100 ms. Obecnie czas
wyszukiwania nadal wynosi kilka milisekund. W większości działów techniki (np. motoryzacji
i przemyśle lotniczym) poprawa wydajności rzędu 5 – 10 razy w ciągu dwóch dekad byłaby
wielkim osiągnięciem (wyobraźmy sobie samochody jeżdżące z szybkością 500 km/h), ale w branży
komputerowej ten wynik jest niezadowalający. W związku z tym przepaść pomiędzy wydajnością
procesorów a wydajnością dysków z upływem czasu znacznie się pogłębiła. Czy można temu
jakoś zaradzić?
Tak! Jak się dowiedzieliśmy z wcześniejszej części książki, w celu poprawy wydajności
procesorów coraz częściej wykorzystuje się przetwarzanie równoległe. W związku z tym kon-
struktorzy urządzeń wejścia-wyjścia pomyśleli, że także w ich dziedzinie można by zastosować
przetwarzanie równoległe. W artykule z 1988 roku [Patterson et al., 1988] zasugerowano sześć
organizacji dysków, które można by zastosować do poprawy ich wydajności, niezawodności lub obu
tych parametrów. Pomysły te bardzo szybko zostały przyjęte w branży i stały się inspiracją do
stworzenia nowej klasy urządzeń wejścia-wyjścia znanych jako RAID. David Patterson zdefi-
niował RAID jako Redundantną Macierz Tanich Dysków (ang. Redundant Array of Inexpensive
Disks), ale branża przemianowała słowo Inexpensive (tanie) na Independent (niezależne) — być
może po to, by móc podnosić ceny. Ponieważ w każdej historii musi być czarny charakter (tak jak
RISC i CISC — również z powodu Pattersona), tutaj złym chłopcem będzie SLED (od ang. Single
Large Expensive Disk), czyli pojedynczy, duży i drogi dysk.
Podstawową ideą w technologii RAID było zainstalowanie szafy pełnej dysków obok kom-
putera, zwykle dużego serwera, zastąpienie karty kontrolera dysku kontrolerem RAID, skopio-
wanie danych na macierz RAID i kontynuowanie normalnej pracy. Inaczej mówiąc, RAID powinien
wyglądać dla systemu operacyjnego jak SLED, ale powinien mieć przy tym lepszą wydajność
i większą niezawodność. W przeszłości macierze RAID składały się prawie wyłącznie z kontrolera
SCSI RAID oraz zbioru dysków SCSI. Wydajność takiej macierzy była dobra, a nowoczesne
kontrolery SCSI obsługują do 15 dysków na pojedynczy kontroler. Obecnie wielu producentów
oferuje także (tańsze) macierze RAID bazujące na SATA. Dzięki temu posługiwanie się macie-
rzami RAID nie wymaga zmian w oprogramowaniu, co sprawia, że są one łakomym kąskiem dla
wielu administratorów systemów.
Oprócz tego, że macierze RAID wyglądają z poziomu oprogramowania jak pojedynczy dysk,
mają tę własność, że rozprowadzają dane pomiędzy napędami, dzięki czemu pozwalają na ich
równoległe działanie. Kilka różnych sposobów realizacji tego mechanizmu zdefiniowali w swoim
artykule Patterson i współpracownicy. Współcześnie większość producentów opisuje siedem
standardowych konfiguracji jako RAID poziomu 0 do RAID poziomu 6. Ponadto istnieje kilka
innych mniej popularnych poziomów, które pominiemy w naszym opisie. Określenie „poziom”
nie jest zbyt dobrą nazwą, ponieważ nie istnieje żadna hierarchia — po prostu jest możliwych
sześć różnych organizacji.
RAID poziomu 0 pokazano na rysunku 5.14(a). Na tym poziomie wirtualny pojedynczy dysk
symulowany za pomocą macierzy RAID jest podzielony na tzw. paski (ang. strips) po k sektorów
każdy. Przy czym sektory od 0 do k−1 tworzą pasek 0, sektory od k do 2k−1 tworzą pasek 1 itd.
Dla k = 1 każdy pasek jest pojedynczym sektorem, dla k = 2 — pasek to dwa sektory itp.
W macierzach RAID poziomu 0 kolejne paski są zapisywane na dyskach w sposób cykliczny,
tak jak pokazano na rysunku 5.14(a) dla macierzy RAID złożonej z czterech napędów dysków.
Taka dystrybucja danych pomiędzy wiele dysków jest określana terminem paskowanie (ang.
striping). Jeśli np. oprogramowanie wyda polecenie przeczytania bloku danych składającego się
z czterech kolejnych pasków, który rozpoczyna się na granicy paska, to kontroler RAID rozbije to
polecenie na cztery oddzielne polecenia — po jednym dla każdego z czterech dysków — i uru-
chomi je równolegle. Dzięki temu mamy równoległe wejście-wyjście, a oprogramowanie nawet
o tym nie wie.
RAID poziomu 0 najlepiej działa z dużymi żądaniami — im większe, tym lepsze. Jeśli żądanie
jest większe od liczby dysków pomnożonej przez rozmiar paska, to niektóre dyski otrzymają
wiele żądań. Dzięki temu gdy zakończą obsługę pierwszego żądania, rozpoczną obsługę drugiego.
Rozbicie żądania na mniejsze oraz przydzielenie odpowiednich żądań do odpowiednich dysków
w odpowiedniej kolejności, a następnie prawidłowe scalenie wyników w pamięci to zadanie
kontrolera. Wydajność takiego mechanizmu jest doskonała, a implementacja prosta.
Prawdziwą macierzą RAID jest następna opcja — RAID poziomu 1, którą pokazano na
rysunku 5.14(b). W tej organizacji wszystkie dyski są zdublowane, zatem istnieją cztery podsta-
wowe dyski i cztery dyski zapasowe. Podczas zapisu każdy pasek jest zapisywany dwukrotnie.
Przy odczycie wykorzystywana jest dowolna kopia, dzięki czemu obciążenie rozkłada się na
więcej dysków. W konsekwencji wydajność zapisu nie jest lepsza niż w przypadku pojedynczego
dysku, ale wydajność odczytu może być dwukrotnie lepsza. Tolerancja awarii jest doskonała.
Jeśli dysk ulegnie awarii, wykorzystywana jest kopia. Odtwarzanie sprowadza się do zainstalo-
wania nowego dysku i skopiowania na niego całego dysku zapasowego.
W odróżnieniu od RAID poziomu 0 i 1, które pracują z paskami składającymi się z sektorów,
RAID poziomu 2 działa na poziomie słów, a nawet na poziomie bajtów. Wyobraźmy sobie, co by było,
gdyby każdy bajt pojedynczego wirtualnego dysku został podzielony na dwa 4-bitowe półbajty.
Do każdego z nich dołączamy kod Hamminga w celu utworzenia 7-bitowego słowa, w którym bity
1, 2 i 4 są bitami parzystości. Wyobraźmy sobie dodatkowo, że siedem dysków z rysunku 5.14(c)
zsynchronizowano na poziomie pozycji ramienia oraz pozycji obrotowej. Można by wtedy zapisać
7-bitowe słowo zakodowane kodem Hamminga na siedmiu dyskach — po jednym bicie na dysk.
Aby dysk mógł być używany, każdy talerz musi zostać poddany formatowaniu niskopozio-
mowemu wykonywanemu przez oprogramowanie. Dysk składa się z ciągu koncentrycznych
ścieżek, z których każda zawiera pewną liczbę sektorów z niewielkimi odstępami pomiędzy
sektorami. Format sektora pokazano na rysunku 5.15.
wynosi 200×109 bajta. Taki dysk może być sprzedawany jako dysk 200 GB. Jednak po sforma-
towaniu dla danych dostępnych jest np. tylko 170×109 bajta. Na domiar złego system opera-
cyjny prawdopodobnie zgłosi ten dysk jako 158 GB, a nie 170 GB, ponieważ oprogramowanie
uznaje pamięć 1 GB jako 230 (1 073 741 824) bajtów, a nie 109 (1 000 000 000) bajtów. Byłoby
lepiej, gdyby pojemność takiego dysku była zgłaszana jako 158 GB.
Do tego wszystkiego w świecie transmisji danych 1 Gb/s oznacza 1 000 000 000 bitów/s,
ponieważ prefiks giga w rzeczywistości oznacza 109 (kilometr to przecież 1000 m, a nie 1024).
Tylko w odniesieniu do rozmiarów pamięci i dysków przedrostki: kilo-, mega- i tera- oznaczają
odpowiednio: 210, 220, 230 i 240.
Aby uniknąć nieporozumień, niektórzy autorzy używają przedrostków: kilo-, mega-, giga-
i tera- na określenie odpowiednio: 103, 106, 109 i 1012, natomiast do określenia wartości: 210,
220, 230 i 240 stosują przedrostki: kibi-, mebi-, gibi- i tebi-. Przedrostek bi- jest jednak stosunkowo
rzadko używany. Gdyby ktoś z Czytelników lubił naprawdę duże liczby, to istnieją także prefiksy
większe niż tebi-. Są to: pebi-, exbi-, zebi- i yobi-. Tak więc 1 yobibyte to cała masa bajtów
(dokładnie 280).
Formatowanie ma również wpływ na wydajność. Jeśli dysk o szybkości obrotowej 10 tysięcy
obrotów na minutę ma 300 sektorów na ścieżkę, a każda ścieżka ma 512 bajtów, to przeczytanie
153 600 bajtów na ścieżce zajmuje 6 ms dla szybkości przesyłania danych 25 600 000 bajtów/s,
czyli 24,4 MB/s. Nie ma możliwości szybszej transmisji, niezależnie od tego, jaki zastosuje się
interfejs. Nawet jeśli jest to interfejs SCSI działający z szybkością 80 MB/s lub 160 MB/s.
Ciągły odczyt z taką szybkością wymaga pojemnego bufora w kontrolerze. Rozważmy przykład
kontrolera zawierającego jednosektorowy bufor, do którego przekazano polecenie przeczytania
dwóch kolejnych sektorów. Po przeczytaniu pierwszego sektora z dysku i obliczeniu kodu ECC
dane trzeba przesłać do pamięci głównej. Podczas gdy odbywa się ta transmisja, następny sektor
przesuwa się pod głowicą. Kiedy wykonywanie kopiowania do pamięci się zakończy, kontroler
będzie musiał czekać przez czas prawie całego obrotu, aż kolejny sektor pojawi się ponownie.
Problem ten można wyeliminować poprzez ponumerowanie sektorów z przeplotem w czasie
formatowania dysku. Na rysunku 5.17(a) pokazano standardowy wzorzec numerowania (w tym
przypadku zignorowano przekos cylindrów). Na rysunku 5.17(b) widać pojedynczy przeplot, który
daje kontrolerowi pewien odstęp pomiędzy kolejnymi sektorami, pozwalający na wykonanie
kopiowania bufora do pamięci głównej.
Rysunek 5.17. (a) Brak przeplotu; (b) pojedynczy przeplot; (c) podwójny przeplot
Jeśli proces kopiowania jest bardzo wolny, może być potrzebny podwójny przeplot, który
pokazano na rysunku 5.17(c). Jeśli kontroler posiada bufor o rozmiarze tylko jednego sektora,
to nie ma znaczenia, czy kopiowanie z bufora do pamięci zostanie wykonane przez kontroler, pro-
cesor główny, czy układ DMA — tak czy inaczej, operacja ta zajmuje pewien czas. Aby uniknąć
konieczności przeplotu, kontroler powinien mieć możliwość buforowania całej ścieżki. Więk-
szość współczesnych kontrolerów spełnia tę funkcję.
Po wykonaniu niskopoziomowego formatowania dysk jest podzielony na partycje. Z logicz-
nego punktu widzenia partycja nie różni się od osobnego dysku. Partycje są potrzebne po to, aby
na jednym komputerze mogło działać kilka systemów operacyjnych. W niektórych przypadkach
partycję można wykorzystać jako pamięć wirtualną. W systemach x86 oraz większości komputerów
innych typów, w sektorze 0 znajduje się główny rekord rozruchowy (ang. Master Boot Record —
MBR) zawierający kod ładujący system operacyjny, a na końcu tablicę partycji. Rekord MBR,
a tym samym obsługa tablicy partycji, po raz pierwszy pojawił się w komputerach IBM PC w 1983
roku do obsługi ogromnego, jak na tamte czasy, 10-megabajtowego dysku twardego mon-
towanego w komputerze PC XT. Od tamtej pory pojemność dysków nieco wzrosła. Ponieważ
wpisy dotyczące partycji w MBR w większości systemów są ograniczone do 32 bitów, maksy-
malny rozmiar dysku, który może być obsługiwany przy sektorach o rozmiarze 512 B, wynosi 2 TB.
Z tego powodu większość współczesnych systemów operacyjnych obsługuje również nową
tablicę GPT (ang. GUID Partition Table), która obsługuje pojemności dysku do 9,4 ZB
(9 444 732 965 739 290 426 880 bajtów). W czasie gdy powstawała ta książka, było to bardzo dużo.
Z tablicy partycji można odczytać sektor startowy oraz rozmiar każdej partycji. W syste-
mach x86 tablica partycji wewnątrz rekordu MBR ma miejsce na cztery partycje. Jeśli wszystkie
one są windowsowe, będą określane literami: C:, D:, E: i F: i traktowane jak oddzielne dyski.
Jeśli trzy z nich są windowsowe, a jedna uniksowa, partycje windowsowe będą określane literami:
C:, D: i E:. Jeśli podłączymy dysk USB, zostanie mu przypisana litera F:. Aby możliwe było
załadowanie systemu operacyjnego z dysku twardego, jedna partycja z tabeli partycji musi być
oznaczona jako aktywna.
Ostatnim krokiem przygotowującym dysk do użycia jest wykonanie wysokopoziomowego
formatowania każdej partycji (osobno). Operacja ta tworzy blok rozruchowy, mechanizm admi-
nistrowania wolnym miejscem (lista wolnych bloków lub mapa bitowa), katalog główny oraz
pusty system plików. Umieszcza również kod w pozycji tablicy partycji, który informuje, jaki
system plików jest używany w jakiej partycji, ponieważ wiele systemów operacyjnych obsługuje
wiele niezgodnych ze sobą systemów plików (z powodów historycznych). W tym momencie
można uruchomić system.
Po włączeniu zasilania uruchamia się BIOS. Następnie czyta główny rekord rozruchowy i do
niego przechodzi. Program rozruchowy sprawdza następnie, która partycja jest aktywna. Po
wykonaniu tej czynności czyta sektor rozruchowy z tej partycji i go uruchamia. Sektor rozru-
chowy zawiera niewielki program, który ładuje większy program ładujący. Ten ostatni prze-
szukuje system plików w celu znalezienia jądra systemu operacyjnego. Jądro jest następnie łado-
wane do pamięci i uruchamiane.
sekwencji ruchy ramienia wynoszą 1, 3, 7, 15, 33 i 2, co daje w sumie 61 cylindrów. Ten algo-
rytm, określany jako SSF (od ang. Shortest Seek First — najpierw najkrótsze wyszukiwanie),
ogranicza całkowity ruch ramienia w porównaniu z algorytmem FCFS.
Niestety, z wykorzystaniem algorytmu SSF wiąże się pewien problem. Przypuśćmy, że
w czasie przetwarzania żądań z rysunku 5.18 nadchodzą kolejne żądania. Jeśli np. po przejściu
do cylindra 16 nadejdzie nowe żądanie o cylinder 8, żądanie to będzie miało priorytet przed cylin-
drem 1. Jeśli następnie nadejdzie żądanie o cylinder 13, ramię przejdzie do cylindra 13 zamiast
do 1. Przy mocno obciążonym dysku przez większość czasu ramię będzie operować w środku
dysku, zatem żądania o początkowe lub końcowe sektory będą musiały czekać tak długo, aż nie
będzie żądań o cylindry w pobliżu środka dysku. Poziom obsługi żądań znajdujących się daleko
od środka może być niski. W tym przypadku cel minimalnego czasu odpowiedzi wchodzi w kon-
flikt z zapewnieniem sprawiedliwej obsługi żądań.
Podobny problem występuje w wysokich budynkach. Planowanie ruchu windy w wysokim
budynku przypomina problem planowania ruchu ramienia. Przez cały czas nadchodzą losowe
żądania przywołujące windę na poszczególne piętra. Komputer zarządzający windą może z łatwo-
ścią śledzić kolejność, w jakiej klienci wciskali przycisk przywołujący windę, i obsługiwać ich
z wykorzystaniem algorytmu FCFS lub SSF.
W większości wind stosuje się jednak inny algorytm, którego celem jest pogodzenie inte-
resów wydajności ze sprawiedliwością. Windy kontynuują ruch w tym samym kierunku dopóty,
dopóki w tym kierunku nie ma zaległych żądań. Dopiero wtedy zmieniają kierunek ruchu. Algo-
rytm ten, który zarówno w świecie dysków, jak i wind nosi nazwę algorytmu windy, wymaga od
oprogramowania utrzymywania 1 bitu: bitu bieżącego kierunku — UP (góra) lub DOWN (dół).
Kiedy zakończy się obsługa żądania, sterownik dysku lub windy sprawdza ten bit. Jeśli ma on
wartość UP, ramię dysku (lub kabina) jest przenoszona do kolejnego, wyższego zaległego żą-
dania. Jeśli nie ma zaległych żądań na wyższych pozycjach, sterownik odwraca bit kierunku.
Jeśli bit jest ustawiony na wartość DOWN, ruch odbywa się w kierunku najniższej żądanej pozycji.
Jeśli nie ma żadnych zaległych żądań, to po prostu zatrzymuje się i czeka.
Na rysunku 5.19 zaprezentowano algorytm windy z wykorzystaniem tych samych siedmiu
żądań, które pokazano na rysunku 5.18, przy założeniu, że bit kierunku miał początkowo war-
tość UP. Kolejność obsługi cylindrów to teraz 12, 16, 34, 36, 9 i 1. Powoduje to ruchy o 1, 4, 18,
2, 27 i 8 cylindrów, czyli razem 60 cylindrów. W tym przypadku algorytm windy jest nieco lepszy
niż SSF, choć zwykle bywa gorszy. Ciekawą własnością algorytmu windy jest to, że przy dowol-
nej kolekcji żądań górna granica całkowitego ruchu pozostaje stała: jest równa podwojonej
liczbie cylindrów.
Jeśli ten sam kontroler obsługuje kilka napędów, system operacyjny powinien utrzymywać
tablicę zaległych żądań dla każdego napędu osobno. Za każdym razem, kiedy napęd jest bez-
czynny, kontroler powinien wykonać operację seek w celu przesunięcia ramienia do cylindra,
który będzie potrzebny w następnej kolejności (przy założeniu, że kontroler pozwala na nakłada-
jące się na siebie operacje wyszukiwania). Po zakończeniu realizacji bieżącego transferu można
przeprowadzić test sprawdzający, czy dowolny z napędów wskazuje pozycję właściwego cylindra.
Jeśli jest tak dla jednego lub kilku napędów, można rozpocząć następny transfer dla napędu, który
w danym momencie wskazuje na odpowiedni cylinder. Jeśli żadne z ramion nie znajduje się na
właściwym miejscu, sterownik powinien wykonać następną operację wyszukiwania dla dysku,
który właśnie zakończył transfer, i odczekać do następnego przerwania, aby zobaczyć, które
ramię dotarło do swojego miejsca docelowego w pierwszej kolejności.
Należy zwrócić uwagę, że wszystkie z powyższych algorytmów zarządzania dyskiem zakła-
dają, że rzeczywista geometria dysku jest identyczna z geometrią wirtualną. Jeśli tak nie jest,
to żądania szeregowania żądań dostępu do dysku nie mają sensu, ponieważ system operacyjny
nie ma możliwości stwierdzenia, czy bliżej cylindra 39 znajduje się cylinder 40, czy 200. Z dru-
giej strony, jeśli kontroler dysku potrafi akceptować wiele żądań, może wewnętrznie używać tych
algorytmów szeregowania. W takim przypadku algorytmy są poprawne, tyle że o jeden poziom
w dół — wewnątrz kontrolera.
Rysunek 5.20. (a) Ścieżka dysku z błędnym sektorem; (b) zastąpienie błędnego sektora
zapasowym; (c) przesunięcie wszystkich sektorów w celu pominięcia błędnego
Aby zrealizować operację seek, wydaje polecenie do silnika poruszającego ramieniem w celu
przesunięcia ramienia do nowego cylindra. Kiedy ramię dotrze do miejsca przeznaczenia, kon-
troler czyta właściwy numer cylindra z preambuły następnego sektora. Jeśli ramię znajdzie się
w złym miejscu, będzie to oznaczało, że wystąpił błąd wyszukiwania.
Większość kontrolerów dysków twardych automatycznie koryguje błędy wyszukiwania, ale
większość kontrolerów starych dyskietek elastycznych używanych w latach osiemdziesiątych
i dziewięćdziesiątych jedynie ustawiała bit błędu, a resztę pozostawiała sterownikowi. Sterownik
obsługiwał ten błąd poprzez wydanie polecenia recalibrate, które przesuwało ramię tak daleko,
jak się da, i resetowało wewnętrzną wartość bieżącego cylindra na 0. Zwykle to rozwiązywało
problem. Jeśli problemu nie można było rozwiązać w ten sposób, trzeba było naprawić napęd.
Jak się przekonaliśmy, kontroler jest w rzeczywistości specjalizowanym niewielkim kom-
puterem, zawierającym oprogramowanie, zmienne, bufory, a od czasu do czasu również błędy.
Nieoczekiwana sekwencja zdarzeń, jak np. przerwanie do jednego napędu występujące równo-
cześnie z wykonywaniem polecenia recalibrate dla innego napędu, mogą spowodować błąd oraz
sprawić, że kontroler wejdzie w pętlę i straci kontrolę nad wykonywanymi działaniami. Projek-
tanci kontrolerów zwykle przygotowują się na najgorsze i udostępniają pin w układzie, który
w przypadku zwarcia powoduje, że kontroler zapomina to, co robił, i się resetuje. Jeśli wszystkie
inne sposoby zawiodą, sterownik dysku może ustawić bit w celu wywołania tego sygnału i zrese-
towania kontrolera. Jeśli i to nie pomoże, jedyne, co sterownik może zrobić, to wyświetlenie
komunikatu o błędzie i poddanie się.
Rekalibracji dysku towarzyszą zabawne dźwięki, ale poza tym nie przeszkadza ona użyt-
kownikowi. Istnieje jednak jedna sytuacja, w której rekalibracja stwarza poważny problem: w sys-
temach z ograniczeniami czasu rzeczywistego. Kiedy z dysku twardego jest odtwarzany klip wideo
(lub jest z niego serwowany) albo jeśli pliki z dysku twardego są wypalane na płycie Blu-ray,
istotne znaczenie ma to, aby bity napływały z dysku twardego w jednolitym tempie. W tych oko-
licznościach rekalibracja powoduje przerwy w strumieniu bitów i dlatego nie może być akcep-
towana. Dla takich zastosowań są dostępne specjalne napędy — tzw. dyski AV (od ang. Audio
Visual disks), które nigdy nie wykonują rekalibracji.
Bardzo przekonującą demonstrację tego, jak zaawansowane stały się kontrolery dysków,
przedstawił holenderski haker Jeroen Domburg, który włamał się do nowoczesnego kontrolera
dysku po to, by uruchomić na nim niestandardowy kod. Okazuje się, że kontroler dysku jest
wyposażony w dość wydajny wielordzeniowy procesor ARM i ma wystarczająco dużo zasobów
do tego, by uruchomić Linuksa. Jeśli złośliwym użytkownikom uda się opanować dysk twardy
w ten sposób, będą oni mogli obejrzeć i zmodyfikować wszystkie dane przesyłane na dysk i z dysku.
Nawet instalacja systemu operacyjnego od podstaw nie spowoduje usunięcia infekcji, ponieważ to
kontroler dysku zawiera złośliwy kod i służy jako trwały backdoor. Alternatywnie można zdobyć
zbiór zepsutych dysków twardych z lokalnego ośrodka recyklingu i za darmo zbudować własny
komputer-klaster.
2. Stabilny odczyt. W operacji stabilnego odczytu najpierw jest czytany blok z dysku 1. Jeśli
wygeneruje on nieprawidłowy kod ECC, operacja odczytu jest powtarzana — aż do n prób.
Jeśli wszystkie operacje odczytu zwrócą nieprawidłowy kod ECC, odpowiedni blok jest
czytany z dysku 2. Jeżeli wziąć pod uwagę to, że po pomyślnym stabilnym zapisie powstają
dwie prawidłowe kopie bloku, oraz przyjąć nasze założenie, że prawdopodobieństwo
spontanicznego uszkodzenia bloku na obu dyskach w sensownym przedziale czasu jest
pomijalnie małe, można stwierdzić, że stabilny odczyt zawsze kończy się sukcesem.
3. Odtwarzanie po awarii. Po awarii program odtwarzający dane skanuje oba dyski, porów-
nując odpowiadające sobie bloki. Jeśli para bloków jest prawidłowa i identyczna, nic się
nie dzieje. Jeżeli jeden z bloków generuje błędny kod ECC, błędny blok jest zastępowany
odpowiadającym mu blokiem prawidłowym. Jeśli oba bloki generują prawidłowy kod
ECC, ale są różne, blok z dysku 1. jest zapisywany na miejsce bloku z dysku 2.
W warunkach braku błędów procesora ten mechanizm działa, ponieważ stabilny zapis zawsze
pozostawia dwie prawidłowe kopie każdego bloku, a zgodnie z założeniem spontaniczne błędy
nigdy nie wystąpią na obu odpowiadających sobie dyskach jednocześnie. A co z sytuacją awarii
procesora podczas stabilnego zapisu? To zależy od tego, w którym momencie zdarzy się awaria.
Istnieje pięć możliwości, co pokazano na rysunku 5.21.
Na rysunku 5.21(a) awaria procesora następuje przed zapisaniem jakiejkolwiek kopii bloku.
Podczas odtwarzania nic nie zostanie zmienione. Pozostaną stare wartości, co jest dozwolone.
Na rysunku 5.21(b) awaria procesora następuje podczas zapisu na dysk 1. i powoduje
zniszczenie zawartości bloku. Program odtwarzający wykrywa jednak ten błąd i przywraca blok
na dysku 1. z dysku 2. Tak więc efekt awarii został zlikwidowany i nastąpiło pełne przywrócenie
poprzedniego stanu.
Na rysunku 5.21(c) awaria procesora następuje po zapisie na dysk 1., ale przed zapisem na
dysk 2. W tym przypadku osiągnęliśmy punkt, z którego nie ma powrotu: program odtwarzający
kopiuje blok z dysku 1. na dysk 2. Operacja kończy się sukcesem.
Sytuacja z rysunku 5.21(d) jest podobna do sytuacji z rysunku 5.21(b): podczas odtwarzania
prawidłowy blok nadpisuje błędny blok. Tak jak poprzednio, oba bloki mają ostatecznie nową
wartość.
Na koniec w sytuacji z rysunku 5.21(e) program odtwarzający stwierdza, że oba bloki są takie
same, zatem nie zmienia żadnego. W tym przypadku operacja zapisu także kończy się sukcesem.
Istnieje szereg optymalizacji i usprawnień tego mechanizmu. Po pierwsze porównywanie
wszystkich bloków parami po wystąpieniu awarii jest wykonalne, ale kosztowne. Dużym
usprawnieniem jest śledzenie bloków zapisanych podczas stabilnego zapisu, tak aby podczas
odtwarzania trzeba było sprawdzić tylko jeden blok. Niektóre komputery są wyposażone
w niewielką ilość nieulotnej pamięci RAM — specjalnej pamięci CMOS podtrzymywanej za pomocą
litowej baterii. Takie baterie zachowują sprawność przez wiele lat — czasami nawet przez cały
czas życia komputera. W odróżnieniu od pamięci głównej, która po awarii ulega utracie, nieulotna
pamięć RAM nie zostaje utracona w czasie awarii. Zwykle jest w niej przechowywana data
i godzina (inkrementowana przez specjalny układ). Dlatego właśnie komputery pamiętają, która
jest godzina, nawet jeśli są wyłączone.
Przypuśćmy, że kilka bajtów nieulotnej pamięci RAM jest dostępnych na potrzeby systemu
operacyjnego. W operacji stabilnego zapisu przed rozpoczęciem modyfikowania bloków można by
zapisać w nieulotnej pamięci RAM numer bloku, który ma być zaktualizowany. Po pomyślnym
wykonaniu stabilnego zapisu numer bloku w nieulotnej pamięci RAM zostałby nadpisany nie-
prawidłowym numerem bloku, np. −1. W takich warunkach program odtwarzający mógłby
sprawdzić po awarii nieulotną pamięć RAM i zobaczyć, czy podczas awarii była wykonywana
operacja stabilnego zapisu. Jeśli tak, to można by odczytać, który blok był zapisywany w czasie
awarii. Następnie można by porównać dwie kopie bloku i sprawdzić je pod kątem poprawności
i spójności.
Jeśli nieulotna pamięć RAM nie jest dostępna, można ją zasymulować w następujący sposób.
W momencie rozpoczęcia operacji stabilnego zapisu ustalony blok na dysku 1. jest nadpisywany
numerem bloku, którego ma dotyczyć stabilny zapis. Ten blok zostałby następnie odczytany
w celu weryfikacji. Po pomyślnym wykonaniu zapisu bloku na dysku 1. zostałby zapisany i zwe-
ryfikowany odpowiadający mu blok na dysku 2. Po prawidłowym wykonaniu operacji stabilnego
zapisu oba bloki zostałyby nadpisane nieprawidłowym numerem bloku i zweryfikowane. Zasto-
sowanie takiego mechanizmu pozwala także po awarii sprawdzić, czy podczas jej trwania była
przeprowadzana operacja stabilnego zapisu. Opisana technika wymaga ośmiu dodatkowych
operacji dyskowych w celu zapisania stabilnego bloku. W związku z tym należy używać jej roz-
sądnie.
Warto jeszcze zwrócić uwagę na jeden element. Założyliśmy, że w ciągu jednego dnia w parze
bloków może nastąpić tylko jedna spontaniczna zmiana bloku prawidłowego na nieprawidłowy.
Po upływie odpowiedniej liczby dni drugi blok także może ulec uszkodzeniu. W związku z tym raz
dziennie musi być wykonane kompletne skanowanie obu dysków w celu naprawy wszystkich
uszkodzeń. Dzięki temu na początku każdego dnia dyski zawsze będą identyczne. Nawet jeśli
oba bloki w parze uszkodzą się w ciągu kilku dni, wszystkie błędy zostaną naprawione.
5.5. ZEGARY
5.5.
ZEGARY
Zegary (ang. clocks lub timers) z wielu powodów mają kluczowe znaczenie dla działania każdego
systemu wieloprogramowego, m.in. przechowują datę i godzinę oraz nie pozwalają na to, aby
jeden proces miał monopol na używanie procesora. Oprogramowanie zegara może przyjąć postać
sterownika urządzenia, mimo że zegar nie jest ani urządzeniem blokowym, tak jak dysk, ani
znakowym, tak jak mysz. Naszą analizę zegarów przeprowadzimy w sposób analogiczny do
poprzedniego punktu: najpierw omówimy sprzęt obsługi zegara, a następnie przeanalizujemy
oprogramowanie.
Programowalne zegary zwykle mają kilka trybów działania. W trybie przerwań na żądanie
(ang. one-shot mode) po uruchomieniu zegara wartość rejestru podtrzymującego jest kopiowana
do licznika, a następnie każdy impuls kryształu powoduje dekrementację licznika. Kiedy licznik
osiągnie zero, generuje przerwanie i zatrzymuje się do chwili ponownego jawnego uruchomienia
z poziomu oprogramowania. W trybie fali prostokątnej (ang. square-wave mode), kiedy zegar dojdzie
do zera i spowoduje przerwanie, rejestr podtrzymujący zostaje automatycznie skopiowany do
licznika i cały proces jest powtarzany od nowa, w nieskończoność. Okresowe przerwania są
nazywane taktami zegara (ang. clock ticks).
Zaletą programowalnego zegara jest możliwość sterowania częstotliwością przerwań z poziomu
oprogramowania. W przypadku użycia kryształu o częstotliwości drgań 500 MHz impuls do licz-
nika jest generowany co 2 ns. W przypadku rejestrów 32-bitowych (bez znaku) można zapro-
gramować przerwania w taki sposób, by były generowane w przedziale od 2 ns do 8,6 s. Układy
programowalnych zegarów zwykle zawierają dwa lub trzy niezależne programowalne zegary,
a także dostarczają wielu innych opcji (np. zliczanie w górę zamiast w dół, blokowanie prze-
rwań itp.).
W celu niedopuszczenia do utraty ustawień bieżącej godziny, w czasie gdy zasilanie kom-
putera jest wyłączone, większość komputerów jest wyposażonych w zasilany bateryjnie zegar
zapasowy, zaimplementowany z użyciem obwodu o niskiej mocy, podobnego do takich, jakich
używa się w zegarkach cyfrowych. Wskazania zegara zasilanego bateryjnie mogą być odczytane
w czasie uruchamiania komputera. Jeśli zegar zapasowy nie jest dostępny, oprogramowanie może
zapytać użytkownika o bieżącą datę i godzinę. W systemach sieciowych istnieje również stan-
dardowy sposób pobierania bieżącego czasu ze zdalnego hosta. W każdym przypadku wskaza-
nia zegara są następnie przekształcane na liczbę taktów zegara, jakie upłynęły od godziny 0:00
w strefie UTC (od ang. Universal Coordinated Time) — wcześniej znanej jako strefa GMT (od
ang. Greenwich Mean Time) — w dniu 1 stycznia 1970 (dla systemów typu UNIX) lub od jakiejś
innej chwili porównawczej. W systemie Windows czas jest liczony od 1 stycznia 1980 roku.
Wraz z każdym taktem zegara czas rzeczywisty jest inkrementowany o jeden. Zazwyczaj są
dostępne programy narzędziowe pozwalające na ręczne ustawienie zegara systemowego i zegara
zapasowego oraz do ręcznej synchronizacji obu zegarów.
zegarowym sterownik zegara dekrementuje licznik kwantu o 1. Kiedy licznik osiągnie zero,
sterownik zegara wywołuje program szeregujący w celu skonfigurowania innego procesu.
Trzecia funkcja zegarowa służy do rozliczania czasu procesora. Najbardziej dokładny sposób
polega na uruchomieniu drugiego licznika, oddzielnego od licznika czasu systemowego, za każ-
dym razem, kiedy proces wznawia działanie. W momencie zatrzymania tego procesu można
odczytać licznik zegara i powiedzieć, jak długo proces działał. Aby to obliczenie było dokładne,
drugi licznik czasu powinien być zapisany w momencie wystąpienia przerwania, a następnie
odtworzony.
Mniej dokładnym, ale prostszym sposobem rozliczania jest utrzymywanie wskaźnika na
pozycji w tabeli procesów odpowiadającej aktualnie uruchomionemu procesowi w zmiennej glo-
balnej. Przy każdym takcie zegara następuje inkrementacja pola w obrębie wpisu dla bieżącego
procesu. W ten sposób każdy takt zegara jest „naliczany” procesowi działającemu w czasie
wystąpienia tego taktu. Niewielki problem w zastosowaniu tej strategii polega na tym, że jeśli
wystąpi wiele przerwań w czasie działania procesu, w dalszym ciągu będzie on obciążony za
pełny takt, mimo że proces nie wykonał w tym czasie zbyt wiele pracy. Właściwe rozliczanie
czasu procesora podczas przerwań jest zbyt kosztowne i rzadko się je wykonuje.
W wielu systemach operacyjnych proces może zażądać od systemu operacyjnego udzielenia
ostrzeżenia po upływie wskazanego przedziału czasu. Ostrzeżenie to ma zwykle postać sygnału,
przerwania, komunikatu lub podobnego mechanizmu. Jedną z aplikacji wymagających takich
ostrzeżeń jest sieć. Jeśli pakiet nie zostanie potwierdzony w ciągu określonego przedziału czasu,
trzeba ponowić jego transmisję. Innym zastosowaniem są testy komputerowe. Jeśli uczeń nie
udzieli odpowiedzi w określonym czasie, system wyświetli prawidłową odpowiedź.
Gdyby sterownik zegara miał do dyspozycji odpowiednią liczbę układów zegarowych, mógłby
przydzielić oddzielny zegar dla każdego żądania. Ponieważ tak nie jest, musi symulować wiele
wirtualnych zegarów z wykorzystaniem jednego zegara fizycznego. Jednym ze sposobów takiej
symulacji jest utrzymywanie tabeli, w której są przechowywane czasy sygnału dla wszystkich
zaległych liczników czasu wraz ze zmienną zawierającą czas następnego licznika. Przy każdej
aktualizacji godziny sterownik sprawdza, czy został wygenerowany najbliższy sygnał. Jeśli tak,
to system poszukuje w tabeli następnego.
Jeśli spodziewanych jest wiele sygnałów, bardziej wydajne okazuje się symulowanie wielu
liczników poprzez utworzenie łańcucha wszystkich zaległych żądań zegara. Są one posortowane
według czasu na liście jednokierunkowej, tak jak pokazano na rysunku 5.24. Każda pozycja na
liście informuje, ile taktów zegara — jeśli liczyć od poprzedniego sygnału — trzeba odczekać, aby
wygenerować kolejny sygnał. W tym przykładzie sygnały będą generowane przy wskazaniach:
4203, 4207, 4213, 4215 i 4216.
Jeśli częstotliwość przerwań jest niska, nie ma problemu z używaniem tego drugiego licznika
czasu dla celów specyficznych dla aplikacji. Problem występuje w przypadku, gdy częstotliwość
zegara specyficznego dla aplikacji jest bardzo wysoka. Poniżej zwięźle omówimy mechanizm
zegara programowego, który dobrze działa w różnych warunkach — nawet przy stosunkowo
wysokich częstotliwościach. Ideę tego zegara opracowali Mohit Aron i Peter Druschel [Aron
i Druschel, 1999]. Więcej informacji na ten temat można znaleźć w przytoczonym powyżej artykule.
Ogólnie rzecz biorąc, są dwa sposoby zarządzania operacjami wejścia-wyjścia: przerwania
i odpytywanie. Z przerwaniami są związane niskie opóźnienia, to znaczy, że występują bezpo-
średnio po samym zdarzeniu, bez opóźnień lub z niewielkimi opóźnieniami. Z drugiej strony
w nowoczesnych procesorach z przerwaniami wiąże się istotny koszt obliczeniowy ze względu na
potrzebę przełączania kontekstu, a także ich wpływ na obsługę potoku, bufora TLB i pamięci
podręcznej.
Sposobem alternatywnym do przerwań jest powierzenie aplikacji samodzielnego odpyty-
wania o oczekiwane zdarzenie. W ten sposób można uniknąć przerwań, ale mogą powstać znaczące
opóźnienia, ponieważ istnieje prawdopodobieństwo wystąpienia zdarzenia bezpośrednio po odpy-
tywaniu. W takim przypadku musi ono czekać prawie cały przedział odpytywania. Przeciętnie
opóźnienie wynosi połowę czasu trwania okresu odpytywania.
Opóźnienie związane z przerwaniami jest dziś tylko nieco mniejsze od tego, jakim charak-
teryzowały się komputery z lat siedemdziesiątych; np. w większości minikomputerów przerwa-
nie zajmowało cztery cykle magistrali: odłożenie na stos licznika programu i słowa PSW oraz
załadowanie do pamięci nowego licznika programu razem ze słowem PSW. We współczesnych
komputerach obsługa potoku, jednostki MMU, bufora TLB i pamięci podręcznej znacznie zwięk-
sza koszty obliczeniowe. Koszty obsługi tych urządzeń prawdopodobnie w przyszłości jeszcze
się zwiększą, co niweluje zyski związane z szybszymi zegarami. Niestety, w przypadku niektórych
aplikacji niekorzystne są zarówno narzuty związane z obsługą przerwań, jak i opóźnienia wyni-
kające z odpytywania.
Zegary programowe pozwalają na uniknięcie kosztów związanych z przerwaniami. Zamiast nich
za każdym razem, kiedy działa jądro z jakiegoś innego powodu niż przerwanie, bezpośrednio
przed powrotem do trybu użytkownika jest sprawdzany zegar czasu rzeczywistego. W ten sposób
system bada, czy programowy licznik czasu osiągnął zero. Jeśli skończył się czas kontrolowany
przez licznik czasu, realizowane jest zaplanowane zdarzenie (np. transmisja pakietu lub spraw-
dzenie wchodzącego pakietu). Nie ma potrzeby przełączania się do trybu jądra, ponieważ system
już działa w tym trybie. Po wykonaniu pracy programowy zegar jest resetowany i zaczyna działać
na nowo. Trzeba jedynie skopiować bieżącą wartość zegara do licznika czasu i dodać do niej prze-
dział limitu czasu.
Zegary czasowe działają w rytm wywołań jądra z różnych powodów. Do tych powodów należą:
1. Wywołania systemowe.
2. Zdarzenia chybionych odwołań do bufora TLB.
3. Błędy braku strony.
4. Przerwania wejścia-wyjścia.
5. Przejścia procesora w stan bezczynności.
Aby zobaczyć, jak często zachodzą takie zdarzenia, Aron i Druschel wykonali pomiary dla kilku
obciążeń procesora. Uwzględnili takie konfiguracje, jak w pełni obciążony serwer WWW, serwer
WWW wykonujący intensywne obliczenia w tle, komputer odtwarzający dźwięk z internetu
w czasie rzeczywistym oraz wykonujący kompilację jądra systemu UNIX. Przeciętna częstość
wejść do jądra wahała się w granicach od 2 μs do 18 μs, a około połowy tych wejść było zwią-
zanych z wywołaniami systemowymi. Tak więc zegar programowy, który wchodzi do jądra co
12 μs, jest dobrym przybliżeniem, choć czasami mogą mu się zdarzać spóźnione reakcje. Spóźnie-
nie o 10 μs od czasu do czasu jest lepsze niż strata 35% czasu procesora na obsługę przerwań.
Oczywiście zdarzą się okresy, kiedy nie będzie wywołań systemowych, błędów chybienia
buforów TLB lub błędów braku stron. W takim przypadku żaden z zegarów programowych nie
zadziała. W celu ustanowienia górnej granicy dla tych przedziałów można wykorzystać drugi
zegar sprzętowy, który będzie generował przerwanie np. co 1 ms. Jeśli dla aplikacji wystarcza
tempo wysyłania 1000 pakietów/s w przydzielonych okresach, to kombinacja programowych
zegarów z zegarem sprzętowym o niskiej częstotliwości może być lepsza niż obsługa wejścia-
wyjścia zarządzana wyłącznie w oparciu o przerwania lub wyłącznie o odpytywanie.
Każdy komputer ogólnego przeznaczenia posiada klawiaturę i monitor (a zwykle także mysz).
Dzięki tym urządzeniom możliwa jest komunikacja z użytkownikami. Chociaż klawiatura i monitor
stanowią z technicznego punktu widzenia oddzielne urządzenia, są one ze sobą ściśle związane.
W komputerach mainframe zwykle jest wielu zdalnych użytkowników — każdy posiada urządzenie
składające się z klawiatury z podłączonym do niej monitorem. Z powodów historycznych urządze-
nia te są nazywane terminalami. Nazwa ta jest często używana również dziś, nawet w odnie-
sieniu do klawiatur i monitorów komputerów osobistych (zwykle z braku lepszego określenia).
Oprogramowanie klawiatury
Liczba, która znajduje się w porcie wejścia-wyjścia, to kod skanowania, a nie kod ASCII. Stan-
dardowe klawiatury mają mniej niż 128 klawiszy, zatem do reprezentacji numeru klucza potrzeba
tylko 7 bitów. Ósmy bit jest ustawiony na 0, gdy klawisz jest wciśnięty, oraz na wartość 1, kiedy
jest zwolniony. Zadanie śledzenia statusu każdego klawisza (wciśnięty lub zwolniony) należy
do sterownika. Zatem zadanie sprzętu sprowadza się do generowania przerwań „naciśnięty”
i „zwolniony”. Resztę robi oprogramowanie.
Dla przykładu w momencie wciśnięcia klawisza A do rejestru wejścia-wyjścia trafia kod
skanowania (30). Zadaniem sterownika jest stwierdzenie, czy to mała litera, wielka litera, kom-
binacja Ctrl+A, Alt+A, Ctrl+Alt+A, czy jeszcze inna. Ponieważ sterownik może stwierdzić,
które klawisze zostały wciśnięte, ale jeszcze nie zostały zwolnione (np. Shift), posiada wystar-
czającą ilość informacji do wykonania pracy.
Przykładowo sekwencja klawiszy:
WCIŚNIĘTY SHIFT, WCIŚNIĘTY A, ZWOLNIONY A, ZWOLNIONY SHIFT
także oznacza wielką literę A. Chociaż taki interfejs klawiatury całą pracę zleca oprogramowaniu,
jest niezwykle elastyczny; np. programy użytkownika mogą być zainteresowane odpowiedzią
na pytanie, czy wpisana cyfra pochodzi z górnego rzędu klawiszy, czy z klawiatury numerycznej.
Ogólnie rzecz biorąc, sterownik potrafi dostarczyć takich informacji.
Dla sterownika istnieje możliwość zaadaptowania dwóch filozofii. W pierwszej zadaniem
sterownika jest zaakceptowanie wejścia i przekazanie go dalej bez modyfikacji. Program czy-
tający dane z klawiatury otrzymuje nieprzetworzoną sekwencję kodów ASCII (przekazywanie
programom użytkownika kodów skanowania jest zbyt prymitywne, a także w dużej mierze zależy
od klawiatury).
Taka filozofia nadaje się do zastosowania w zaawansowanych edytorach ekranowych, np.
emacs. Pozwalają one użytkownikowi na dowiązanie dowolnej akcji do znaku lub sekwencji
znaków. Oznacza ona jednak, że jeśli użytkownik wpisze ciąg dsta zamiast data, a następnie
poprawi błąd poprzez trzykrotne wciśnięcie klawisza BackSpace oraz wpisanie ciągu ata zakoń-
czonego znakiem powrotu karetki, to do programu użytkownika zostanie przekazanych 11 kodów
ASCII w następującej kolejności:
d s t a←←←a t a CR
Jeśli klawiatura działa w trybie kanonicznym (ugotowanym), to znaki muszą być zapisywane
tak długo, aż będzie gotowy cały wiersz. Użytkownik w każdej chwili może przecież zdecydować
się na usunięcie jego części. Nawet jeśli klawiatura działa w trybie surowym, program może
nie potrzebować danych od razu. W związku z tym znaki muszą być buforowane, aby było
możliwe wcześniejsze wprowadzanie danych. Można wykorzystać dedykowany bufor lub zaalo-
kować go z puli. W pierwszym przypadku jest z góry określony stały limit na liczbę znaków
wpisywanych z klawiatury, w drugim nie ma takiego limitu. Problem ten ujawnia się najwyraź-
niej, kiedy użytkownik wpisuje dane w oknie powłoki (wierszu polecenia w systemie Windows)
i właśnie wprowadził polecenie (np. kompilacji), które jeszcze nie zostało wykonane. Kolejne
wpisywane znaki muszą być buforowane, ponieważ powłoka nie jest gotowa na ich czytanie.
Projektanci systemów, którzy nie zezwalają użytkownikom na wpisywanie danych zawczasu,
pewnie są niepocieszeni lub, co gorsza, zmuszeni do stosowania własnego systemu.
Chociaż klawiatura i monitor to logicznie oddzielne urządzenia, wielu użytkowników przy-
zwyczaiło się do tego, że znaki wpisywane z klawiatury są natychmiast widoczne na ekranie. Taki
proces określa się jako echo.
Pewną komplikacją w działaniu echa jest to, że program może zapisywać dane na ekran
w czasie, gdy użytkownik wpisuje dane z klawiatury (tym razem także proponuję Czytelnikowi,
by wyobraził sobie, że wpisuje dane w oknie powłoki). Sterownik klawiatury musi co najmniej
zdecydować o tym, gdzie będzie umieszczał nowe dane wejściowe, tak by nie zostały nadpisane
przez wyjście generowane przez program.
Echo klawiatury komplikuje się również wtedy, gdy w oknie składającym się wierszy po 80
znaków trzeba wyświetlić więcej niż 80 znaków. W zależności od aplikacji w takim przypadki
można zastosować zawijanie do następnego wiersza. Niektóre sterowniki obcinają wiersze do 80
znaków poprzez usunięcie wszystkich znaków poza kolumną 80.
Innym problemem jest obsługa tabulacji. Zwykle to sterownik musi wyliczyć bieżącą pozycję
kursora. Musi wziąć pod uwagę zarówno wyjście programów, jak i wyjście echa i na tej pod-
stawie obliczyć odpowiednią liczbę spacji, która ma być wyprowadzona.
W tym momencie dochodzimy do problemu równoważności urządzeń. Z logicznego punktu
widzenia na końcu wiersza tekstu jest spodziewany znak powrotu karetki w celu przeniesienia
kursora do kolumny 1. oraz znak wysuwu wiersza w celu przejścia do następnego wiersza.
Wymaganie od użytkowników wpisywania obu znaków na końcu każdego wiersza nie byłoby
dobrze postrzegane. To sterownik urządzenia musi przekształcić otrzymane dane wejściowe na
format używany przez system operacyjny. W systemie UNIX klawisz Enter jest konwertowany
na znak wysuwu wiersza i w ten sposób przechowywany w pamięci masowej. W systemie Win-
dows jest on konwertowany na sekwencję znaku powrotu oraz wysuwu wiersza.
Jeśli formą standardową ma być zapisywanie znaku wysuwu wiersza (konwencja systemu
UNIX), to znaki powrotu karetki (utworzone w wyniku wciśnięcia klawisza Enter) należy prze-
kształcić na znaki wysuwu wiersza. Jeśli format standardowy wymaga zapisania obu znaków
(konwencja systemu Windows), to sterownik musi wygenerować znak wysuwu wiersza, gdy
otrzyma znak powrotu karetki, oraz znak powrotu karetki, jeśli otrzyma znak wysuwu wiersza.
Niezależnie od wewnętrznej konwencji monitor może wymagać wyprowadzania zarówno znaku
wysuwu wiersza, jak i powrotu karetki po to, by zapewnić prawidłowe aktualizowanie ekranu.
W systemach wielodostępnych, takich jak systemy mainframe, różni użytkownicy mogą posługiwać
się różnymi terminalami. Zadaniem sterownika klawiatury jest konwersja różnych kombinacji
znaków powrotu karetki i wysuwu wiersza na wewnętrzny standard systemu oraz dbanie o to,
by echo było realizowane prawidłowo.
Podczas działania w trybie kanonicznym niektóre znaki wejściowe mają specjalne znaczenie.
W tabeli 5.4 wyszczególniono wszystkie znaki specjalne wymagane w standardzie POSIX. War-
tości domyślne to wszystkie znaki sterujące, które nie powinny kolidować z wprowadzanym
tekstem ani z kodami używanymi przez programy. Wszystkie poza ostatnimi dwoma można zmienić
programowo.
Znak ERASE umożliwia użytkownikowi usunięcie znaku, który został wprowadzony przed
chwilą. Zwykle jest to znak Backspace (Ctrl+H). Nie jest on dodawany do kolejki znaków, ale
powoduje usunięcie poprzedniego znaku z kolejki. Aby usunąć poprzedni znak z ekranu, należy
go wyprowadzić na monitor jako sekwencję trzech znaków: Backspace, spacja i Backspace. Jeśli
poprzednim znakiem była tabulacja, jej usunięcie zależy od sposobu postępowania z nią w momen-
cie wpisywania. Jeżeli tabulacja była bezpośrednio rozwijana na spacje, potrzebne są informacje
dodatkowe w celu stwierdzenia, o ile należy się cofnąć. Jeśli sama tabulacja jest zapisana
w kolejce wejściowej, można ją usunąć i wyprowadzić cały wiersz jeszcze raz. W większości sys-
temów wciśnięcie znaku Backspace powoduje usuwanie znaków tylko w bieżącym wierszu. Nie
powoduje usunięcia znaku powrotu karetki i powrotu do poprzedniego wiersza.
Kiedy użytkownik zauważy błąd na początku wpisywanego wiersza, przyda się usunięcie
całego wiersza i rozpoczęcie od nowa. Znak KILL powoduje usunięcie całego wiersza. W więk-
szości systemów usunięty wiersz znika z ekranu. Jednak w kilku starszych systemach wiersz
się wyświetla razem ze znakami powrotu karetki i wysuwu wiersza, ponieważ niektórzy użyt-
kownicy wolą, jeśli wyświetla się stary wiersz. W konsekwencji sposób wyprowadzania znaku KILL
jest sprawą gustu. Podobnie jak w przypadku znaku ERASE, zwykle nie jest możliwe cofnięcie się
więcej niż o jeden wiersz. Jeśli zostanie usunięty blok znaków, czasami sterownik podejmuje
działania w celu zwrócenia buforów do puli. W niektórych przypadkach taka operacja okazuje
się jednak nieopłacalna.
Czasami znaki ERASE lub KILL muszą być wprowadzone tak jak zwykłe dane. Znak LNEXT spełnia
rolę tzw. znaku ucieczki (ang. escape character), czyli znaku poprzedzającego sekwencję steru-
jącą w celu jej „unieszkodliwienia”. W systemie UNIX domyślnym znakiem wykorzystywanym
w tej roli jest Ctrl+V. Dla przykładu rozważmy następujący przypadek. W starszych systemach
UNIX używano znaku @ dla symbolu KILL, ale w systemach pocztowych stosowanych w inter-
necie wykorzystuje się adresy w postaci [email protected]. Ktoś, kto czuje się bardziej
komfortowo, stosując starsze konwencje, mógłby zdefiniować symbol KILL jako @, ale wtedy
musiałby literalnie wprowadzać znak @ w adresach e-mail. Można to zrobić poprzez wprowa-
dzenie sekwencji Ctrl+V @. Sam znak Ctrl+V można wprowadzić literalnie, poprzez wpisanie
dwukrotnie sekwencji Ctrl+V. Kiedy sterownik wykryje znak Ctrl+V, ustawia flagę, która infor-
muje o tym, że następny znak jest zwolniony ze specjalnego przetwarzania. Sam znak LNEXT nie
jest wprowadzany do kolejki znaków.
Użytkownik ma możliwość zablokowania przewijania zawartości ekranu, tak by obraz nie
znikał poza widoczny obszar — służą do tego kody sterujące do blokowania ekranu i późniejszego
jego restartowania. W systemie UNIX są to odpowiednio znaki STOP, (Ctrl+S) i START, (Ctrl+Q).
Nie są one zapisywane, ale zamiast tego są wykorzystywane do ustawiania i zerowania flagi
w strukturze danych klawiatury. Przy każdej próbie wyjścia jest badana flaga. Jeśli zostanie
ustawiona, znaki nie będą wyprowadzane. Zwykle echo zostaje zatrzymane razem z wyjściem
programu.
Często konieczne jest zabicie debugowanego programu, który się zawiesił. Do tego celu
można wykorzystać znaki INTR (Del) i QUIT (Ctrl+\). W systemie UNIX wciśnięcie klawisza Del
wysyła sygnał SIGINT do wszystkich procesów uruchomionych z tej klawiatury. Implementacja
klawisza Del może być dość trudna, ponieważ system UNIX od początku zaprojektowano z myślą
o jednoczesnej obsłudze wielu użytkowników. W związku z tym w ogólnym przypadku może być
wiele procesów działających w imieniu wielu użytkowników, a klawisz Del musi wysyłać sygnał
wyłącznie do własnych procesów użytkownika. Trudność sprawia przekazanie informacji ze ste-
rownika do tej części systemu, która obsługuje sygnały, a ta nie prosiła przecież o owe informacje.
Znak Ctrl+\ ma podobne działanie do Del, poza tym, że wysyła sygnał SIGQUIT, który jeśli nie
zostanie przechwycony albo zignorowany, wymusza zrzut pamięci. Kiedy zostanie wciśnięty
dowolny z tych klawiszy, sterownik powinien wyprowadzić znaki powrotu karetki i wysuwu
wiersza, a następnie porzucić zakumulowane wejście w celu zainicjowania „świeżego” startu.
Domyślną wartością znaku INTR często jest sekwencja Ctrl+C zamiast Del, ponieważ wiele pro-
gramów używa do edycji znaku Del zamiennie z BackSpace.
Innym specjalnym znakiem jest EOF (Ctrl+D), który w Uniksie powoduje obsłużenie zawar-
tością bufora wszystkich zaległych żądań odczytu danego terminala, nawet wtedy, gdy bufor
jest pusty. Wpisanie sekwencji Ctrl+D na początku wiersza powoduje, że program odczytuje
0 bajtów. Zgodnie z konwencją jest to interpretowane jako koniec wiersza i powoduje, że więk-
szość programów działa tak, jakby wykryła znak końca pliku wejściowego.
Oprogramowanie myszy
Większość komputerów PC jest wyposażonych w mysz, a czasami urządzenie trackball, czyli
odwróconą mysz. Typowa mysz ma wewnątrz gumową kulkę, która wystaje przez otwór
w dolnej części i obraca się w miarę przesuwania myszy po powierzchni. Kiedy użytkownik
porusza myszą, kulka napędza rolki umieszczone na prostopadłych do siebie wałkach. Ruch
w kierunku wschód – zachód powoduje obracanie się wałka równoległego do osi y, natomiast
ruch w kierunku północ – południe powoduje obracanie się wałka równoległego do osi x.
Innym popularnym typem myszy jest mysz optyczna, która w dolnej części zawiera jedną
lub kilka diod LED oraz fotodetektorów. Pierwsze modele takich myszy musiały znajdować się
na specjalnej podkładce z nakreśloną na niej siatką prostopadłych linii. Dzięki temu mysz mogła
zliczać linie i wykrywać ruch w określonym kierunku. Nowoczesne myszy optyczne są wypo-
sażone w układ przetwarzania obrazu. Przez cały czas wykonują fotografie podłoża, po którym
porusza się mysz, i obserwują zmiany.
Przy każdym ruchu myszy na dowolną odległość i w dowolnym kierunku, a także w momencie
wciśnięcia lub zwolnienia przycisku jest przesyłany sygnał do komputera. Minimalna odległość
wynosi 0,1 mm (chociaż można ją ustawić programowo). Tę jednostkę odległości niektórzy nazy-
wają miki. Myszy mogą mieć jeden, dwa lub trzy przyciski w zależności od tego, jak projektant
oceniał intelektualną zdolność użytkowników do śledzenia więcej niż jednego przycisku. Nie-
które myszy są wyposażone w kółka, których ruch powoduje wysyłanie do komputera dodat-
kowych danych. Myszy bezprzewodowe działają podobnie do przewodowych, z tą różnicą, że
zamiast wysyłać dane do komputera przewodowo, używają nadajników radiowych niskiej mocy,
np. w standardzie Bluetooth.
Komunikat przesyłany do komputera składa się z trzech elementów: Δx, Δy i przycisków.
Pierwsza wartość określa zmianę położenia x w stosunku do ostatniego komunikatu. Dalej
w komunikacie występuje zmiana w pozycji y od ostatniego komunikatu. Na końcu dołączany jest
status przycisków. Format komunikatu zależy od systemu oraz liczby przycisków, jakie posiada
mysz. Zazwyczaj komunikat ma rozmiar 3 bajtów. Większość myszy odpowiada maksymalnie
40 razy na sekundę. W związku z tym pozycja myszy od ostatniego raportu może się znacznie
zmienić.
Warto zwrócić uwagę, że mysz przekazuje do komputera tylko zmianę pozycji, a nie bez-
względne położenie. Jeśli mysz zostanie podniesiona w górę i delikatnie położona na podłoże,
tak że kulka się nie poruszy, do komputera nie zostanie przesłany żaden komunikat.
Niektóre środowiska graficzne rozróżniają jednokrotne kliknięcia przycisku myszy od dwu-
krotnych. Jeśli dwa kliknięcia są wystarczająco blisko siebie w przestrzeni (odległość w miki)
oraz w czasie (liczba milisekund pomiędzy kliknięciami), mysz sygnalizuje dwukrotne kliknięcie.
Maksymalna wartość odległości „wystarczająco blisko” jest ustawiana programowo. Podobnie
można ustawić programowo czas pomiędzy kolejnymi kliknięciami.
Okna tekstowe
Wyjście jest prostsze od wejścia, jeśli jest sekwencyjne, a informacje są wyprowadzane z użyciem
jednej czcionki, jednego rozmiaru i koloru. W większości przypadków program wysyła znaki
do bieżącego okna i tam są one wyświetlane. Zazwyczaj jedno wywołanie systemowe powoduje
zapisanie bloku znaków — np. wiersza.
Edytory ekranowe oraz wiele innych zaawansowanych programów muszą mieć możliwość
aktualizacji ekranu w skomplikowany sposób — np. zastąpienia jednego wiersza w środku ekranu.
Aby było możliwe spełnienie tego wymagania, większość sterowników wyjścia obsługuje ciąg
poleceń do przesuwania kursora, wstawiania i usuwania znaków lub wierszy w miejscu wska-
zywanym przez kursor itp. Polecenia te często są nazywane sekwencjami sterującymi (ang. escape
sequences). W czasach terminali znakowych 25×80 dostępne były setki typów terminali, a każdy
z nich obsługiwał własne sekwencje sterujące. W konsekwencji trudno było napisać oprogramo-
wanie, które działałoby na więcej niż jednym typie terminala.
Jednym z rozwiązań, zaproponowanym w systemie Berkeley UNIX, była baza danych ter-
minali znana jako termcap. Ten pakiet oprogramowania definiował szereg podstawowych ope-
racji — np. przesuwanie kursora do pozycji (wiersz, kolumna). W celu przemieszczenia kursora
do określonej lokalizacji oprogramowanie — np. edytor — wykorzystywało uniwersalną sekwen-
cję sterującą, która następnie była przekształcana na konkretną sekwencję sterującą odpowia-
dającą terminalowi, na którym było generowane wyjście. Dzięki temu edytor mógł działać na
dowolnym terminalu, dla którego istniał wpis w bazie danych termcap. W taki sposób do dziś
działa większość oprogramowania w systemie UNIX — nawet na komputerach osobistych.
W końcu branża dostrzegła potrzebę standaryzacji sekwencji sterujących. W związku z tym
opracowano standard ANSI. Niektóre z wartości zestawiono w tabeli 5.5.
Tabela 5.5. Sekwencje ucieczki ANSI akceptowane przez sterownik terminala na wyjściu;
ESC oznacza znak sterujący ASCII (0x1B), natomiast n, m i s to opcjonalne parametry numeryczne
Sekwencja ucieczki Znaczenie
ESC[nA Przesunięcie w górę o n wierszy
Zastanówmy się, w jaki sposób sekwencje sterujące mogą być używane przez edytor tekstu.
Przypuśćmy, że użytkownik wpisał polecenie, które nakazuje edytorowi usunięcie całego 3.
wiersza, a następnie pozbycie się luki pomiędzy wierszami 2. i 4. Edytor mógłby przesłać do
terminala za pośrednictwem łącza szeregowego następującą sekwencję sterującą:
ESC [ 3 ; 1 H ESC [ 0 K ESC [ 1 M
(spacje w tym przykładzie zostały użyte tylko do oddzielenia symboli; normalnie nie są one
przesyłane). Powyższa sekwencja przesuwa kursor na początek 3. wiersza, usuwa cały wiersz,
a następnie usuwa pusty wiersz. Dzięki temu wszystkie wiersze, począwszy od 5., przesuwają
się w górę o 1 wiersz. Dlatego to, co było wierszem 4., staje się wierszem 3., to, co było 5., staje
się 4. itd. Analogicznych sekwencji sterujących można użyć w celu dodania tekstu w środku
ekranu. W podobny sposób można dodawać lub usuwać słowa.
System X Window
Niemal wszystkie systemy UNIX opierają swój interfejs użytkownika na systemie X Window
(często nazywanym po prostu X), opracowanym w MIT w ramach projektu Atena w latach
osiemdziesiątych. System ten charakteryzuje się wysokim stopniem przenośności i działa całko-
wicie w przestrzeni użytkownika. Pierwotnie miał on służyć do łączenia wielu zdalnych terminali
użytkownika z centralnym serwerem obliczeniowym. W związku z tym jest on logicznie podzielony
na oprogramowanie klienckie oraz część działającą po stronie hosta. Obie części mogą poten-
cjalnie działać na różnych komputerach. W nowoczesnych komputerach osobistych obie części
działają na tym samym komputerze. W systemach Linux popularne środowiska pulpitów graficz-
nych GNOME i KDE działają na bazie środowiska X.
Kiedy na komputerze działa system X, to oprogramowanie, które zbiera dane wejściowe
z klawiatury i myszy oraz zapisuje je na ekranie, określa się nazwą serwer X. Oprogramowanie to
musi śledzić, które okno jest wybrane w danym momencie (gdzie znajduje się wskaźnik myszy),
w związku z tym wie, do którego klienta wysłać nowe dane wejściowe wprowadzane na klawia-
turze. Oprogramowanie to komunikuje się z działającymi programami (często przez sieć) zwa-
nymi klientami X. Serwer przesyła do nich dane wejściowe z klawiatury i myszy i akceptuje od
nich polecenia wyświetlania.
Na pierwszy rzut oka może się wydawać dziwne, że serwer X zawsze znajduje się wewnątrz
komputera użytkownika, natomiast klient X może być umieszczony na zewnątrz, w zdalnym
komputerze. Należy jednak pamiętać o głównym zadaniu serwera X: wyświetlaniu bitów na
ekranie — z tego powodu sensowne jest, że komponent ten jest blisko użytkownika. Z punktu
widzenia programu to klient nakazuje serwerowi wykonywanie operacji, np. wyświetlanie tekstu
i figur geometrycznych. Serwer (umieszczony w lokalnym komputerze PC) robi to, o co go po-
proszą, tak jak robią wszystkie serwery.
Organizację klienta i serwera dla przypadku, w którym klient X i serwer X działają na róż-
nych maszynach, pokazano na rysunku 5.25. Jeśli jednak środowiska GNOME lub KDE dzia-
łają na jednej maszynie, to klient jest programem aplikacyjnym używającym biblioteki X, która
komunikuje się z serwerem X na tej samej maszynie (ale korzysta z połączenia TCP poprzez
gniazdo, tak jak w przypadku zdalnym).
Powodem, dla którego można uruchomić system X Window na bazie systemu UNIX (lub
innego systemu operacyjnego) na pojedynczej maszynie lub przez sieć, jest to, że system X
w rzeczywistości tylko definiuje protokół pomiędzy klientem X a serwerem X, tak jak pokazano
na rysunku 5.25. Nie ma znaczenia, czy klient i serwer są na tej samej maszynie, są oddalone
od siebie o 100 m w sieci lokalnej, czy też dzielą je tysiące kilometrów i połączenie przez inter-
net. Protokół i działanie systemu we wszystkich przypadkach jest identyczne.
X to po prostu system okienkowy. Nie jest kompletnym graficznym interfejsem GUI. Aby
utworzył on kompletny interfejs GUI, muszą działać na jego bazie inne warstwy oprogramowania.
Jedną z nich tworzy Xlib — zbiór procedur bibliotecznych umożliwiających dostęp do zestawu
własności X. Procedury te tworzą podstawę systemu X Window i są tym, co przeanalizujemy
poniżej. Okazują się jednak zbyt prymitywne dla większości programów użytkownika, aby można
było korzystać z nich bezpośrednio. Przykładowo każde kliknięcie myszą jest zgłaszane oddzielnie.
W związku z tym stwierdzenie, że dwa kliknięcia tworzą dwukrotne kliknięcie, musi zostać ziden-
tyfikowane powyżej warstwy Xlib.
Aby programowanie w X było łatwiejsze, częścią systemu X jest zestaw narzędzi o nazwie
Intrinsics. Warstwa ta zarządza przyciskami, paskami przewijania oraz innymi elementami GUI
zwanymi widżetami. W celu stworzenia rzeczywistego interfejsu GUI, gwarantującego jednolity
wygląd i wrażenie, potrzebna jest kolejna warstwa (lub kilka warstw). Jednym z przykładów
takiej warstwy, którą pokazano na rysunku 5.25, jest Motif. Stanowi on podstawę środowiska
CDE (od ang. Common Desktop Environment) używanego w systemie Solaris oraz innych komer-
cyjnych systemach UNIX. Większość aplikacji korzysta z wywołań do biblioteki Motif zamiast
do Xlib. GNOME i KDE mają podobną strukturę do tej, którą pokazano na rysunku 5.25, ale
stosują inne biblioteki. Środowisko GNOME wykorzystuje bibliotekę GTK+, natomiast środo-
wisko KDE wykorzystuje bibliotekę Qt. To, czy lepsze jest wykorzystywanie dwóch środowisk
GUI, czy jednego, pozostaje kwestią do dyskusji.
Warto również zwrócić uwagę na to, że zarządzanie oknami nie jest częścią samego sys-
temu X. Decyzja o wydzieleniu tego komponentu była całkowicie celowa. Zamiast tego zarzą-
dzaniem tworzenia, usuwania i poruszania oknami po ekranie zajmuje się inny proces — mene-
dżer okien. W celu zarządzania oknami proces ten wysyła polecenia do serwera X i informuje
o tym, co należy zrobić. Często działa na tej samej maszynie, co klient X, ale teoretycznie może
działać gdziekolwiek.
Zastosowanie takiego modularnego projektu, składającego się z kilku warstw i wielu pro-
gramów, powoduje, że system X jest niezwykle elastyczny. Przeniesiono go do większości wersji
systemu UNIX — włącznie z systemem Solaris, wszystkimi odmianami BSD, AIX, Linux itp.
Dzięki temu programiści aplikacji uzyskali standardowy interfejs użytkownika dla wielu platform.
System ten został również przeniesiony do innych systemów operacyjnych. Dla odróżnienia
w systemie Windows menedżer okien i środowisko GUI są ze sobą połączone — wspólnie tworzą
interfejs GDI, który jest zlokalizowany w jądrze. Dzięki temu są one trudniejsze w pielęgnacji i,
co oczywiste, nie są przenośne.
Spróbujmy teraz krótko przeanalizować system X z poziomu biblioteki Xlib. Kiedy zaczyna
działać program X, otwiera on połączenie do jednego lub kilku serwerów X — nazwijmy je stacjami
roboczymi — mimo że mogą działać na tej samej maszynie, co sam program X. Środowisko X
uznaje takie połączenie za niezawodne w tym sensie, że komunikaty utracone i zdublowane są
obsługiwane przez oprogramowanie sieciowe. W związku z tym system X nie musi się przej-
mować błędami komunikacji. Do komunikacji pomiędzy klientem a serwerem zwykle jest uży-
wany protokół TCP/IP.
int running = 1;
disp = XOpenDisplay("display_name"); /* nawiązanie połączenia z serwerem X */
win = XCreateSimpleWindow(disp, ... ); /* przydzielenie pamięci dla nowego okna */
XSetStandardProperties(disp, ...); /* poinformowanie menedżera okien
o istnieniu okna */
gc = XCreateGC(disp, win, 0, 0); /* utworzenie graficznego kontekstu */
XSelectInput(disp, win, ButtonPressMask | KeyPressMask | ExposureMask);
XMapRaised(disp, win); /* wyświetlenie okna; wysłanie zdarzenia Expose */
while (running) {
XNextEvent(disp, &event); /* pobranie następnego zdarzenia */
switch (event.type) {
case Expose: . ..; break; /* odświeżenie okna */
case ButtonPress: ...; break; /* przetwarzanie kliknięcia myszą */
case Keypress: ...; break; /* przetwarzanie wejścia z klawiatury */
}
}
XFreeGC(disp, gc); /* zwolnienie kontekstu graficznego */
XDestroyWindow(disp, win); /* dealokacja przestrzeni pamięci
przydzielonej dla okna */
XCloseDisplay(disp); /* zniszczenie połączenia sieciowego */
}
i powiedział coś w guście: „O święta Petronelo, to jest przyszłość komputerów”. Środowisko GUI
stało się dla niego inspiracją do stworzenia nowego komputera, któremu nadano nazwę Apple Lisa.
Komputer Lisa był zbyt drogi i okazał się komercyjnym niewypałem, ale jego następca —
Macintosh — odniósł olbrzymi sukces.
Kiedy firma Microsoft otrzymała prototyp Macintosha, aby opracować system Microsoft
Office na tę platformę, zaczęto prosić firmę Apple, by ta sprzedała licencję na interfejs, tak by
mógł on się stać nowym standardem branżowym (firma Microsoft zarobiła znacznie więcej
pieniędzy na pakiecie Office niż MS-DOS, dlatego była gotowa porzucić projekt MS-DOS, by
mieć lepszą platformę dla pakietu Office). Dyrektor zarządzający odpowiedzialny za Macintosha,
Jean-Louis Gassée, odmówił, a Steve’a Jobsa nie było już w firmie i nikt nie mógł uchylić tej
decyzji. Ostatecznie firma Microsoft otrzymała licencję na elementy interfejsu. To stworzyło
podstawy systemu Windows. Kiedy system Windows zaczął zdobywać pozycję na rynku, firma
Apple pozwała firmę Microsoft, oskarżając ją o przekroczenie uprawnień wynikających z licencji.
Gdyby Gassée zgodził się z wieloma ludźmi z Apple, którzy chcieli sprzedawać licencje na
oprogramowanie Macintosh wszystkim chętnym, firma Apple bardzo by się wzbogaciła na
opłatach licencyjnych, a system Windows teraz by nie istniał.
Pomińmy na razie interfejsy dotykowe, a zostańmy przy GUI: interfejs składa się z czterech
podstawowych elementów, które można opisać skrótem WIMP. Litery te pochodzą odpowiednio
od Windows (okna), Icons (ikony), Menus (menu) oraz Pointing device (urządzenie wskazujące).
Okna są prostokątnymi blokami ekranu używanymi do uruchamiania programów. Ikony to
niewielkie symbole, których kliknięcie powoduje wykonanie jakiegoś działania. Menu są listami
operacji, z których można wybrać jedną. Na koniec — urządzenie wskazujące to mysz, trackball
lub inne urządzenie sprzętowe używane do przesuwania kursora po ekranie w celu wybierania
elementów.
Oprogramowanie GUI można zaimplementować w kodzie poziomu użytkownika, tak jak
w systemach typu UNIX, lub w samym systemie operacyjnym, tak jak w systemie Windows.
Do wprowadzania danych w systemach GUI w dalszym ciągu wykorzystywane są klawiatura
i mysz, ale wyjście zawsze jest wyprowadzane do specjalnej karty sprzętowej określanej jako
adapter graficzny (lub po prostu karta graficzna). Karta graficzna jest wyposażona w specjalną
pamięć znaną jako wideo RAM (w której znajdują się zapisane obrazy wyświetlające się na
ekranie). Karty graficzne wysokiej klasy często są wyposażone w mocne 32- lub 64-bitowe proce-
sory oraz do 1 GB własnej pamięci RAM, która jest oddzielona od głównej pamięci komputera.
Każda karta graficzna obsługuje kilka rozdzielczości ekranu. Popularne rozdzielczości to
1024×768, 1280×960, 1600×1200 oraz 1920×1200. Wszystkie te parametry, poza 1920×1200,
mają współczynnik szerokość – wysokość jak 4:3, pasujący do współczynnika telewizji NTSC
i PAL. W związku z tym generują kwadratowe piksele na takich samych monitorach, jakie są
wykorzystywane w odbiornikach telewizyjnych. Wyższe rozdzielczości są przeznaczone dla
monitorów o szerokim ekranie, o odpowiednim współczynniku proporcji szerokość : wysokość.
Przy rozdzielczości 1920×1080 (Full HD) kolorowy monitor z 24 bitami na piksel wymaga
około 6,5 MB pamięci RAM tylko w celu przechowywania obrazu. Dlatego przy 256 MB pamięci
lub więcej karta graficzna może przechowywać w pamięci wiele obrazów jednocześnie. Jeśli
pełny ekran jest odświeżany z szybkością 75 razy na sekundę, to pamięć wideo musi być zdolna
do ciągłego dostarczania danych z szybkością 445 MB/s.
Oprogramowanie wyjścia dla środowisk GUI jest bardzo rozległym tematem. O samym
tylko środowisku GUI systemu Windows napisano wiele opasłych książek (np. [Petzold, 2013],
[Simon, 1997], [Rector i Newcomer, 1997]). Z oczywistych względów w tym punkcie możemy
jedynie dotknąć zagadnienia i zaprezentować kilka pojęć. Aby dyskusja stała się konkretna,
opiszemy interfejs Win32 API obsługiwany przez wszystkie 32-bitowe wersje Windows. Opro-
gramowanie wyjścia dla innych środowisk GUI jest w przybliżeniu porównywalne w sensie
ogólnym, ale może się różnić szczegółami.
Podstawowym elementem ekranu jest prostokątny obszar zwany oknem. Pozycja okna i jego
rozmiar są w sposób unikatowy określone za pomocą współrzędnych dwóch przeciwległych
narożników (współrzędne są określone w pikselach). Okno może zawierać pasek tytułu, pasek
menu, pasek narzędzi oraz paski przewijania — pionowy i poziomy. Typowe okno pokazano na
rysunku 5.26. Zwróćmy uwagę, że w układzie współrzędnych systemu okien początek układu
znajduje się w górnym lewym rogu, a współrzędna y rośnie ku dołowi. Pod tym względem układ
ten różni się od kartezjańskiego układu współrzędnych wykorzystywanego w matematyce.
Podczas tworzenia okna podaje się parametry, które informują, czy użytkownik może prze-
mieszczać je po ekranie, zmieniać jego rozmiar czy przewijać (poprzez przeciąganie uchwytu
paska przewijania). Główne okno większości programów może być przesuwane po ekranie, może
być zmieniany jego rozmiar lub przewijana zawartość. Ma to znaczny wpływ na sposób pisania
programów działających w systemie Windows. W szczególności programy muszą być informo-
wane o zmianach rozmiaru okien oraz muszą być przygotowane do ponownego wykreślania
zawartości swoich okien w dowolnym czasie, nawet gdy tego najmniej oczekują.
W konsekwencji programy windowsowe są zorientowane na komunikaty. Działania użyt-
kowników związane z klawiaturą lub myszą zostają przechwycone przez system Windows
i skonwertowane na komunikaty do programów, które są właścicielami okien. Każdy program
jest wyposażony w kolejkę komunikatów, do której zostają przesłane komunikaty związane
z wszystkimi jego oknami. Główna pętla programu składa się z przechwycenia następnego komu-
nikatu i przetworzenia go poprzez wywołanie wewnętrznej procedury dla danego typu komuni-
katu. W niektórych przypadkach sam system Windows może wywoływać te procedury bezpo-
średnio, pomijając kolejkę komunikatów. Ten model różni się od modelu UNIX zawierającego
kod proceduralny wykonujący wywołania systemowe w celu interakcji z systemem operacyjnym.
System X jest jednak zorientowany na zdarzenia.
Aby objaśnić ten model programowania, rozważmy przykład z listingu 5.5. Zamieszczono
na nim szkielet głównego programu dla systemu Windows. Nie jest to kompletny program i nie
zawiera mechanizmów korekcji błędów, ale ma wystarczająco dużo szczegółów dla naszych
celów. Rozpoczyna się od włączenia pliku nagłówkowego — windows.h — który zawiera wiele
makr, typów danych, stałych, prototypów funkcji oraz innych informacji wymaganych przez
programy w systemie Windows.
switch (message) {
case WM_CREATE: ... ; return ... ; /* utworzenie okna */
case WM_PAINT: ... ; return ... ; /* ponowne „narysowanie” zawartości okna */
case WM_DESTROY: ... ; return ... ; /* zniszczenie okna */
}
return(DefWindowProc(hwnd, message, wParam, lParam)); /* domyślnie */
}
Główny program uruchamia się od deklaracji określającej nazwę wraz z parametrami. Makro
WINAPI jest instrukcją kompilatora do używania określonej konwencji przekazywania parametrów.
Więcej nie będziemy się nią zajmować. Pierwszy parametr, h, to uchwyt do egzemplarza, uży-
wany w celu identyfikacji programu dla pozostałej części systemu. Pod pewnymi względami
środowisko Win32 jest zorientowane obiektowo. Oznacza to, że system zawiera obiekty (tzn.
programy, pliki i okna), które charakteryzują się pewnymi stanami oraz mają związany z nimi
kod — tzw. metody, służące do wykonywania operacji na tych stanach. Do obiektów można się
odwoływać za pomocą uchwytów. W tym przypadku uchwyt h identyfikuje program. Drugi para-
metr występuje wyłącznie ze względu na zachowanie zgodności wstecz. Nie jest on wykorzy-
stywany w nowych wersjach systemu. Trzeci parametr, szCmd, to ciąg znaków zakończony zna-
kiem o kodzie zero, który zawiera wiersz polecenia służący do uruchomienia programu (mimo
że program nie jest uruchamiany z wiersza polecenia). Czwarty parametr, iCmdShow, informuje
o tym, czy początkowe okno programu powinno zajmować cały ekran, część ekranu, czy też nie
powinno w ogóle z niego korzystać (zadanie widoczne tylko na pasku zadań).
Pokazana deklaracja ilustruje powszechnie używaną w firmie Microsoft konwencję znaną
jako notacja węgierska. Format nazwy wywodzi się od notacji polskiej — systemu przyrostków
opracowanego przez polskiego matematyka Jana Łukasiewicza do reprezentowania formuł alge-
braicznych bez użycia pierwszeństwa działań ani nawiasów. Notacja węgierska została opracowana
przez węgierskiego programistę Charlesa Simonyiego pracującego w firmie Microsoft. Jej zasadą
jest to, że do określenia typu stosuje ona kilka pierwszych znaków identyfikatora. Dozwolone
litery i typy to c (znak), w (słowo — obecnie oznacza 16-bitową liczbę całkowitą bez znaku),
i (32-bitowa liczba całkowita ze znakiem), l (long — również 32-bitowa liczba całkowita ze
znakiem), s (ciąg znaków), sz (ciąg znaków zakończony bajtem zerowym), p (wskaźnik), fn (funk-
cja) oraz h (uchwyt). Zgodnie z tym np. szCmd jest ciągiem znaków zakończonych bajtem zero-
wym, natomiast iCmdShow to liczba całkowita (integer). Wielu programistów sądzi, że kodowa-
nie typu w nazwach zmiennych w taki sposób ma niewielkie znaczenie, a ponadto sprawia, że kod
Windows staje się niezwykle trudny do czytania. W systemie UNIX nie istnieje analogiczna
konwencja.
Z każdym oknem musi być powiązany obiekt klasy okna, który definiuje jego właściwości.
Na listingu 5.5 jest nim wndclass. Obiekt typu WNDCLASS ma 10 pól. Cztery z nich zostały zaini-
cjowane na listingu 5.5. W działającym programie trzeba by także zainicjować kolejnych sześć.
Najbardziej istotnym polem jest lpfnWndProc — wskaźnik typu long (tzn. 32-bitowy) do funkcji
obsługującej komunikaty kierowane do okna. Inne pola inicjowane w tym miejscu informują
o tym, jakiej nazwy i ikony użyć w pasku tytułu oraz jakich symboli używać dla kursora myszy.
Po zainicjowaniu obiektu wndclass następuje wywołanie metody RegisterClass w celu
przekazania informacji o oknie do systemu Windows. Po tym wywołaniu system Windows będzie
wiedział, którą procedurę wywołać, gdy zajdą określone zdarzenia, które nie przechodzą przez
kolejkę komunikatów. Następna metoda, CreateWindow, przydziela pamięć dla struktur danych okna
i zwraca uchwyt pozwalający na późniejsze odwoływanie się do okna. Następnie program wyko-
nuje po kolei dwa dodatkowe wywołania w celu umieszczenia na ekranie obrysu okna, a później
jego całkowitego wypełnienia.
W tym momencie dochodzimy do głównej pętli programu składającej się z otrzymania komu-
nikatu, wykonania na nim kilku translacji, a następnie przekazania go do systemu Windows po
to, by ten wywołał procedurę WndProc i obsłużył komunikat. Odpowiedź na pytanie, czy ten
mechanizm można by zrealizować prościej, brzmi „tak”, ale wykonano go w ten sposób z powodów
historycznych i teraz jesteśmy na niego skazani.
Za programem głównym jest procedura WndProc obsługująca różne komunikaty, które można
wysłać do okna. Użycie CALLBACK w tym miejscu, tak samo jak WINAPI wcześniej, określa sekwencję
wywołań, która zostanie wykorzystana w odniesieniu do parametrów. Pierwszy parametr określa
uchwyt okna, które będzie wykorzystane. Drugi oznacza typ komunikatu. Trzeci i czwarty
parametr można wykorzystać w celu dostarczenia dodatkowych informacji wtedy, kiedy będą
potrzebne.
Komunikaty typu WM_CREATE i WM_DESTROY są wysyłane odpowiednio na początku i na końcu
programu. Dają one programowi możliwość alokacji pamięci dla struktur danych, a następnie
zwolnienia pamięci.
Trzeci typ komunikatu, WM_PAINT, jest instrukcją wypełnienia okna w programie. Komunikat
ten jest wywoływany nie tylko przy pierwszym wyświetleniu okna, ale często także podczas
działania programu. W odróżnieniu od systemów tekstowych, w Windowsie program nie może
założyć, że cokolwiek, co zostanie wykreślone na ekranie, pozostanie na nim do czasu, aż pro-
gram to usunie. Na okno mogą być przeciągnięte inne okna, mogą się na nim rozwinąć menu,
część okna mogą przykryć okna dialogowe i etykietki ekranowe. Kiedy te elementy zostaną
usunięte, okno musi być narysowane od początku. System Windows rysuje okno na nowo poprzez
wysłanie do niego komunikatu WM_PAINT. Dla ułatwienia dostarcza również informacji na temat
tego, która część okna została nadpisana, na wypadek gdyby łatwiej było regenerować tę część
okna, zamiast rysować okno w całości od nowa.
Istnieją dwa sposoby na to, by system Windows skłonił program do wykonania określonych
operacji. Jeden sposób polega na umieszczeniu komunikatu w kolejce komunikatów. Ta metoda
jest używana do wprowadzania danych z klawiatury, wprowadzania danych za pomocą myszy oraz
obsługi liczników czasowych. Drugi sposób, wysłanie komunikatu do okna, wymaga od systemu
Windows bezpośredniego wywołania funkcji WndProc. Ta metoda jest wykorzystywana dla
wszystkich innych zdarzeń. Ponieważ Windows otrzymuje powiadomienie w momencie, gdy
komunikat zostanie w pełni obsłużony, może powstrzymać się od generowania nowego wywo-
łania do czasu zakończeniu obsługi poprzedniego. W ten sposób można uniknąć sytuacji wyścigu.
Istnieje znacznie więcej typów komunikatów. Aby uniknąć błędnego działania w przypadku
nadejścia nieoczekiwanego komunikatu, program powinien wywołać funkcję DefWindowProc na
końcu procedury WndProc. W ten sposób umożliwia obsługę innych przypadków przez domyślną
procedurę obsługi.
Podsumujmy: program systemu Windows zwykle tworzy jedno lub więcej okien. Każdemu
z nich odpowiada obiekt klasy. Z każdym programem jest powiązana kolejka komunikatów oraz
zbiór procedur obsługi. Działaniem programu sterują wchodzące zdarzenia obsługiwane przez
procedury obsługi zdarzeń. Jest to całkowicie odmienny model świata od bardziej proceduralnego
podejścia przyjętego w systemie UNIX.
Właściwe rysowanie na ekranie jest obsługiwane przez pakiet składający się z setek procedur
tworzących wspólnie interfejs urządzenia graficznego GDI (od ang. Graphics Device Interface).
Może on obsłużyć tekst oraz wszystkie rodzaje grafiki i jest tak zaprojektowany, aby był nie-
zależny od platformy i urządzenia. Zanim program zacznie rysować w oknie, musi uzyskać
kontekst urządzenia — wewnętrzną strukturę danych zawierającą właściwości okna, takie jak
bieżąca czcionka, kolor tekstu, kolor tła itp. Większość wywołań GDI wykorzystuje kontekst
urządzenia do rysowania lub do pobierania lub ustawiania właściwości.
Istnieją różne sposoby zdobycia kontekstu urządzenia. Oto prosty przykład zdobycia kon-
tekstu urządzenia i jego użycia:
hdc = GetDC(hwnd);
TextOut(hdc, x, y, psText, iLength);
ReleaseDC(hwnd, hdc);
spowoduje narysowanie prostokąta pokazanego na rysunku 5.27. Szerokość linii, a także kolor
obrysu i kolor wypełnienia są pobierane z kontekstu urządzenia. Inne wywołania GDI są podobne.
Mapy bitowe
Procedury GDI to przykłady grafiki wektorowej. Są wykorzystywane do umieszczenia na ekranie
figur geometrycznych i tekstu. Można je również łatwo skalować do mniejszych i większych
ekranów (pod warunkiem że liczba pikseli na ekranie jest taka sama). Są one również stosun-
kowo mało zależne od urządzeń. Kolekcję wywołań do procedur GDI można zestawić w pliku
Rysunek 5.27. Przykład prostokąta narysowanego za pomocą funkcji Rectangle; każda kratka
reprezentuje jeden piksel
pokazano na rysunku 5.28. Zwróćmy uwagę, że skopiowany został cały obszar litery A o roz-
miarach 5×7 —włącznie z kolorem tła.
Działanie funkcji BitBlt nie ogranicza się tylko do kopiowania map bitowych. Ostatni para-
metr daje możliwość wykonywania operacji logicznych pozwalających na łączenie źródłowej mapy
Rysunek 5.28. Kopiowanie map bitowych za pomocą funkcji BitBlt: (a) przed; (b) po
bitowej z docelową. Można np. wykonać funkcję OR dla map bitowych źródłowej i docelowej,
aby je scalić. Można również wykonać operację XOR, która zachowuje charakterystykę zarówno
mapy źródłowej, jak i docelowej.
Główny problem w przypadku map bitowych to skalowanie. Znak w prostokącie 8×12 pikseli
na ekranie o rozdzielczości 640×480 wygląda rozsądnie. Jeśli jednak ta mapa bitowa zostanie
skopiowana na stronę drukowaną w rozdzielczości 1200 punktów na cal, co odpowiada siatce
10 200×13 200 bitów, to szerokość znaku (8 pikseli) będzie wynosiła 8/1200 cala, czyli 0,17 mm. Co
więcej, kopiowanie pomiędzy urządzeniami z różnymi właściwościami kolorów lub pomiędzy
urządzeniami monochromatycznymi i kolorowymi nie będzie działało dobrze.
Z tego powodu w systemie Windows występuje również struktura danych znana jako DIB
(od ang. Device Independent Bitmap), czyli mapa bitowa niezależna od urządzenia. Pliki korzysta-
jące z tego formatu stosują rozszerzenie .bmp. W plikach tych przed pikselami są nagłówki pliku
oraz nagłówki informacyjne. Owe informacje ułatwiają przenoszenie map bitowych pomiędzy
urządzeniami różniącymi się od siebie.
Czcionki
W wersjach Windows przed 3.1 znaki były reprezentowane w postaci map bitowych i kopiowane
na ekran lub drukarkę za pomocą funkcji BitBlt. Problem z takim projektem, o czym przeko-
naliśmy się przed chwilą, polega na tym, że bitmapa mająca sens na ekranie jest zbyt mała do
tego, by mogła być wyświetlona na drukarce. Poza tym potrzebna jest inna mapa bitowa dla
każdego znaku i każdego rozmiaru. Inaczej mówiąc, na podstawie mapy bitowej litery A o wiel-
kości 10 punktów w żaden sposób nie da się wyliczyć mapy bitowej o wielkości 12 punktów. Ponie-
waż każdy znak każdej czcionki może być potrzebny w rozmiarach od 4 punktów do 120
punktów, potrzebna była olbrzymia liczba map bitowych. Cały system zarządzania tekstem był
po prostu zbyt kłopotliwy.
Problem rozwiązano poprzez wprowadzenie czcionek TrueType, które nie są mapami bito-
wymi, tylko obrysami znaków. Każdy znak TrueType jest zdefiniowany jako sekwencja punktów
wokół jego obrzeży. Wszystkie punkty są określone względem początku o współrzędnych (0, 0).
Wykorzystanie tego systemu pozwala z łatwością skalować znaki w górę lub w dół. Trzeba jedynie
pomnożyć każdą współrzędną przez ten sam współczynnik skali. Dzięki temu znak TrueType
można skalować w górę lub w dół, do punktu o dowolnych rozmiarach. Można nawet stosować
ułamkowe rozmiary punktów. Kiedy znak ma już właściwy rozmiar, punkty można ze sobą
połączyć z wykorzystaniem dobrze znanego algorytmu „połącz kropki”, którego wszyscy nauczy-
liśmy się w przedszkolu. Po wykonaniu obrysu znaki można wypełnić. Przykład znaków skalo-
wanych do trzech różnych rozmiarów punktów pokazano na rysunku 5.29.
Kiedy wypełniony znak jest już dostępny w postaci matematycznej, można przeprowadzić
rasteryzację — czyli konwersję do mapy bitowej o pożądanej rozdzielczości. Dzięki temu, że
najpierw jest wykonywane skalowanie, a potem rasteryzacja, zyskujemy pewność, że znaki
wyświetlane na ekranie oraz te, które pojawią się na wydruku, będą maksymalnie zbliżone
wyglądem i będą się różniły tylko błędem kwantyzacji. Aby jeszcze poprawić jakość, w każdym
znaku można osadzić wskazówki, które mówią o sposobie przeprowadzenia rasteryzacji; np.
oba szeryfy w górnej części litery „T” powinny być identyczne. Czasami może to być trudne
z powodu błędów związanych z zaokrąglaniem. Wskazówki poprawiają ostateczny wygląd
Ekrany dotykowe
Coraz częściej ekran jest używany również jako urządzenie wejściowe. Zwłaszcza na smartfonach,
tabletach i innych ultraprzenośnych urządzeniach wygodne jest dotykanie i wskazywanie ele-
mentów na ekranie palcem (lub rysikiem). Komfort pracy użytkownika jest większy, a obsługa
programów bardziej intuicyjna niż w przypadku korzystania z urządzeń podobnych do myszy,
ponieważ użytkownik operuje bezpośrednio na obiektach wyświetlonych na ekranie. Z badań
wynika, że nawet orangutany i inne naczelne oraz małe dzieci są zdolne do obsługiwania urządzeń
wyposażonych w interfejsy dotykowe.
Urządzenie dotykowe nie musi być ekranem. Urządzenia dotykowe można podzielić na dwie
kategorie: nieprzezroczyste i przezroczyste. Typowym urządzeniem nieprzezroczystym jest
touchpad w komputerach przenośnych. Przykładem urządzenia przezroczystego jest ekran doty-
kowy w smartfonie lub tablecie. Jednak w tym punkcie ograniczymy się do ekranów dotykowych.
Podobnie jak wiele rzeczy, które stały się modne w branży komputerowej, ekrany dotykowe
nie są do końca nowością. Już w 1965 roku E.A. Johnson z British Royal Radar Establishment
opisał wyświetlacz dotykowy (pojemnościowy). Choć był on bardzo prosty, posłużył jako pre-
kursor wyświetlaczy, które są instalowane we współczesnych urządzeniach. Większość współ-
czesnych ekranów dotykowych to ekrany rezystywne lub pojemnościowe.
Ekrany rezystywne są pokryte elastyczną warstwą z tworzywa sztucznego. Samo tworzywo
sztuczne nie ma w sobie niczego specjalnego poza tym, że jest bardziej odporne na zarysowania
od zwykłych plastikowych folii używanych np. w ogrodnictwie. Jednak pod wierzchnią warstwą
z folii są cienkie linie nadrukowane za pomocą ITO (ang. Indium Tin Oxide — tlenku indu
domieszkowanego tlenkiem cyny) lub podobnego materiału przewodzącego. Poniżej tej warstwy,
ale bez styczności z nią, jest druga warstwa, także pokryta ITO. W górnej warstwie ładunek prze-
pływa w kierunku pionowym, a połączenia przewodzące są na górze i na dole. W warstwie dolnej
ładunek przepływa poziomo, a połączenia są po lewej i po prawej stronie. Kiedy użytkownik
dotyka ekranu, naciska plastik tak, że górna warstwa ITO styka się z dolną. Znalezienie dokład-
nej pozycji palca lub rysika wymaga zmierzenia rezystancji w obu kierunkach — we wszystkich
punktach poziomych warstwy dolnej oraz wszystkich pozycjach pionowych warstwy górnej.
Ekrany pojemnościowe składają się z dwóch twardych powierzchni, zazwyczaj ze szkła, pokry-
tych ITO. W typowej konfiguracji ITO na każdej powierzchni jest drukowane w postaci równo-
ległych linii, przy czym w górnej warstwie są one prostopadłe do tych w warstwie dolnej. Górna
warstwa może być pokryta cienkimi liniami w kierunku pionowym, natomiast dolna ma nadru-
kowane podobne linie w kierunku poziomym. Te dwie naładowane powierzchnie, rozdzielone
powietrzem, tworzą siatkę małych kondensatorów. Napięcia są przykładane naprzemiennie do
linii poziomych i pionowych, a wartości napięcia, na które ma wpływ pojemność każdego prze-
cięcia, są odczytywane po drugiej stronie. Umieszczenie palca na ekranie powoduje zmianę lokal-
nej pojemności. Dzięki bardzo dokładnym pomiarom minimalnych zmian napięcia można odczytać
pozycję palca na ekranie. Operacja ta jest powtarzana wiele razy na sekundę, a współrzędne
punktów dotyku są podawane do sterownika urządzenia jako strumień par (x, y). Dalsze prze-
twarzanie — np. określenie, czy nastąpiło wskazanie, szczypanie, rozszerzanie, czy przesuwa-
nie — wykonuje system operacyjny.
Zaletą ekranów rezystywnych jest to, że na wynik pomiarów wpływa sam dotyk. W związku
z tym ekran będzie działać, nawet jeśli użytkownik nosi rękawiczki. W przypadku ekranów
pojemnościowych jest inaczej — jeśli nie mamy specjalnych rękawiczek, nie możemy obsługiwać
ekranu. Takie specjalne rękawiczki można wykonać, wszywając nić przewodzącą (np. z posre-
brzanego nylonu) w palce rękawiczek. Jeśli ktoś nie lubi szyć, może kupić gotowe rękawiczki.
Alternatywnie można odciąć końce palców zwykłych rękawiczek, co nie zajmie więcej niż 10 s.
Wadą ekranów rezystywnych jest to, że zazwyczaj nie obsługują one wielodotyku — techniki
pozwalającej wykryć wiele punktów dotyku jednocześnie. Technika ta umożliwia manipulowanie
obiektami na ekranie za pomocą dwóch lub większej liczby palców. Ludzie (a być może również
orangutany) lubią wielodotyk, ponieważ umożliwia on stosowanie gestów szczypania i rozwijania
dwoma palcami w celu powiększania lub zmniejszania obrazu czy dokumentu. Wyobraźmy sobie,
że dwa palce są w pozycjach (3, 3) i (8, 8). W rezultacie ekran rezystywny może wykryć zmiany
rezystancji na liniach pionowych x = 3 i x = 8 oraz na liniach poziomych y = 3 i y = 8. Roz-
ważmy teraz inny scenariusz: palce dotykają punktów (3, 8) i (8, 3), które są przeciwległymi
narożnikami prostokąta wyznaczonego przez punkty: (3, 3), (8, 3), (8, 8) i (3, 8). Zmieniła się
rezystancja na dokładnie tych samych liniach, więc oprogramowanie nie ma sposobu na stwier-
dzenie, który z opisanych wyżej dwóch scenariuszy miał miejsce. Ten problem określa się jako
tzw. ghosting (dosł. zjawy). Ponieważ ekrany pojemnościowe wysyłają strumień współrzędnych
(x, y), są lepiej przystosowane do obsługi wielodotyku.
Manipulowanie ekranem dotykowym tylko jednym palcem nadal przypomina interfejs WIMP —
jedynie wskaźnik myszy został zastąpiony palcem lub rysikiem. Wielodotyk jest nieco bardziej
skomplikowany. Dotykanie ekranu pięcioma palcami można porównać do jednoczesnego wci-
śnięcia pięciu wskaźników myszy w pięciu punktach ekranu, co wyraźnie zmienia sytuację dla
menedżera okien. Ekrany obsługujące wielodotyk stały się wszechobecne. Są coraz bardziej
czułe i dokładniejsze. Niemniej jednak nie jest jasne, czy technika Five Point Palm Exploding Heart
Technique1 nie ma przypadkiem wpływu na CPU.
Przez lata główny paradygmat pracy na komputerach oscylował pomiędzy przetwarzaniem scen-
tralizowanym i zdecentralizowanym. Pierwsze komputery, np. ENIAC, mimo że duże, były
w istocie komputerami osobistymi, ponieważ w danym momencie mogła z nich korzystać tylko
jedna osoba. Następnie nadszedł czas systemów ze współdzieleniem czasu — wtedy wielu zdal-
nych użytkowników, używając prostych terminali współużytkowało duży komputer centralny.
Później nadeszła era komputerów PC, w której użytkownicy znów mieli własne komputery.
Zdecentralizowany model komputerów PC ma swoje zalety, ma również istotne wady, które
należy traktować poważnie. Prawdopodobnie największy problem polega na tym, że każdy
komputer PC jest wyposażony w pojemny dysk twardy i złożone oprogramowanie, którymi
trzeba zarządzać. Kiedy np. zostanie opublikowana nowa wersja systemu operacyjnego, trzeba
włożyć wiele pracy, aby przeprowadzić aktualizację każdej maszyny z osobna. W większości
firm koszt robocizny związanej z tego rodzaju pielęgnacją oprogramowania przekracza koszty
samego sprzętu i oprogramowania. W przypadku użytkowników domowych robocizna nic nie
kosztuje, ale jest niewiele osób, które potrafią wykonać te czynności poprawnie, a jeszcze mniej
osób lubi to robić. Gdy system jest scentralizowany, trzeba zaktualizować tylko kilka maszyn.
Poza tym komputery te są obsługiwane przez ekspertów, którzy potrafią prawidłowo prze-
prowadzić aktualizację.
Inną sprawą jest to, że użytkownicy powinni wykonywać regularne kopie zapasowe swoich
wielogigabajtowych systemów plików, ale niewielu z nich to robi. W przypadku awarii większość
osób załamuje ręce. Przy systemie scentralizowanym kopie zapasowe mogą być wykonane co noc
przez zautomatyzowane roboty.
Inną zaletą systemów scentralizowanych jest łatwiejsze współdzielenie zasobów. W systemie,
w którym jest 256 zdalnych użytkowników i każdy ma do dyspozycji 256 MB pamięci RAM,
przez większość czasu duża część tej pamięci będzie bezczynna. Przy scentralizowanym sys-
temie z 64 GB pamięci RAM nigdy się nie zdarzy, że użytkownik, który czasowo potrzebuje dużo
pamięci RAM, nie może jej uzyskać, ponieważ znajduje się ona na komputerze PC innego użyt-
kownika. Te same argumenty dotyczą miejsca na dysku i innych zasobów.
Ostatnio można zauważyć przejście od przetwarzania, w którym centralną rolę odgrywał
komputer PC, na przetwarzanie skupione wokół internetu. Jedną z dziedzin, w której to przejście
okazuje się bardzo zaawansowane, jest poczta elektroniczna. Dawniej użytkownicy pobierali
pocztę na swoje domowe komputery i tam ją czytali. Obecnie wiele osób loguje się do serwisów
Gmail, Hotmail lub Yahoo! i tam czyta swoją pocztę. W następnym kroku użytkownicy będą
logowali się do innych serwisów internetowych w celu redagowania tekstu, tworzenia arkuszy
1
Legendarna technika z gry komputerowej Kill Bill (bazującej na filmie pod tym samym tytułem), która
powodowała eksplozję serca po dotknięciu przeciwnika pięcioma palcami jednocześnie — przyp. tłum.
kalkulacyjnych oraz wykonywania innych operacji, które wcześniej wymagały stosowania opro-
gramowania działającego na komputerach PC. Możliwe jest nawet to, że w końcu jedynym
oprogramowaniem działającym na komputerach PC będą przeglądarki internetowe (choć być
może nawet nie).
Można powiedzieć, że większość użytkowników chce korzystać z wysokowydajnego inte-
raktywnego przetwarzania, ale nie chce administrować komputerem. Ten wniosek skłonił pro-
jektantów do ponownego zwrócenia uwagi na systemy z podziałem czasu i nieinteligentnymi
terminalami (które teraz są nazywane cienkimi klientami). Systemy o takiej architekturze są
zgodne z oczekiwaniami współczesnych użytkowników komputerów. Krokiem w tym kierunku
było powstanie systemu X. Dedykowane terminale X były popularne przez jakiś czas, ale wyszły
z użycia, ponieważ kosztują tyle samo co komputery PC, mają mniejsze możliwości i wymagają
stosowania oprogramowania zarządzającego. Złotym środkiem byłby wysokowydajny interak-
tywny system komputerowy, w którym maszyny użytkowników w ogóle nie mają oprogramo-
wania. Okazuje się, że ten cel jest osiągalny.
Jednym z najbardziej znanych cienkich klientów jest Chromebook. Jest on aktywnie promo-
wany przez Google, ale dostępnych jest wiele modeli różnych producentów. Na tym specyficznym
notebooku działa system operacyjny ChromeOS, który bazuje na systemie Linux i przeglądarce
Chrome i z założenia jest przez cały czas online. Większość innych programów jest dostępna
za pośrednictwem strony internetowej w formie aplikacji sieci Web, dzięki czemu stos opro-
gramowania na komputerach Chromebook jest znacznie cieńszy w porównaniu z większością
tradycyjnych notebooków. Z drugiej strony systemu, na którym działa pełny stos systemu Linux
i przeglądarka Chrome, nie można nazwać „anorektycznym”.
Pierwszy komputer ogólnego przeznaczenia, ENIAC, miał 18 000 lamp i zużywał 140 000 W
energii. W rezultacie jego właściciele musieli płacić pokaźne rachunki za energię. Po wynalezieniu
tranzystora zużycie mocy spadło bardzo znacząco, a branża komputerowa straciła zainteresowanie
wymaganiami skoncentrowanymi na mocy. Dziś jednak, z różnych powodów, zarządzanie energią
na nowo staje się przedmiotem zainteresowania, a istotną rolę odgrywa tu system operacyjny.
Rozpocznijmy od komputerów PC typu desktop. Komputer PC tego typu często jest wypo-
sażony w zasilacz o mocy 200 W (zwykle wydajny w 85% — tzn. traci 15% energii na ciepło).
Gdy na świecie jest włączonych jednocześnie 100 milionów tych maszyn, razem zużywają one
20 000 MW energii elektrycznej. Odpowiada to całkowitej mocy 20 przeciętnych elektrowni
nuklearnych. Gdyby zapotrzebowanie na moc obniżyć o połowę, można by pozbyć się 10 takich
elektrowni. Z punktu widzenia ochrony środowiska pozbycie się choćby 10 elektrowni nukle-
arnych (lub odpowiednio większej liczby elektrowni na paliwa kopalne) jest olbrzymim zwycię-
stwem, do którego warto dążyć.
Kwestia energii odgrywa także istotną rolę w odniesieniu do komputerów zasilanych bate-
ryjnie, włącznie z notebookami, komputerami PDA i Webpadami. Sedno problemu polega na tym,
że nie można naładować baterii do tego stopnia, aby było możliwe zasilanie komputera przez
długi czas. W najlepszym wypadku można zasilać komputer przez kilka godzin. Co więcej, pomimo
wielu badań prowadzonych przez firmy produkujące baterie, firmy komputerowe oraz firmy
produkujące urządzenia elektroniki konsumenckiej postęp w tej dziedzinie nie jest zbyt duży.
W branży przyzwyczajonej do podwajania wydajności co 18 miesięcy (prawo Moore’a) całkowity
brak postępu może wydawać się naruszeniem obowiązujących praw fizyki. Taka jest jednak
obecna sytuacja. W konsekwencji dążenie do wytwarzania komputerów zużywających mniej
prądu, tak by istniejące baterie mogły działać dłużej, jest ważnym tematem na liście zainteresowań
wielu podmiotów. System operacyjny odgrywa w tym bardzo ważną rolę, co pokażemy poniżej.
Na najniższym poziomie producenci sprzętu dążą do tego, by ich urządzenia w bardziej wydajny
sposób korzystały z energii. Stosuje się m.in. takie techniki jak zmniejszanie rozmiaru tranzy-
storów, dynamiczne skalowanie napięcia, magistrale niskoczęstotliwościowe i adiabatyczne itp.
Ich opis wykracza poza ramy tej książki. Zainteresowani Czytelnicy mogą znaleźć więcej infor-
macji na ten temat w artykule [Venkatachalam i Franz, 2005].
Istnieją dwa ogólne podejścia zmierzające do redukcji zużycia energii. Pierwsze polega na
wyłączeniu przez system operacyjny niektórych nieużywanych urządzeń komputera (przede
wszystkim urządzeń wejścia-wyjścia). Wiadomo bowiem, że urządzenie, które nie jest używane,
zużywa niewiele energii lub nie zużywa jej wcale. Drugie podejście polega na dążeniu do tego,
by programy aplikacyjne zużywały mniej energii. Wydłużenie czasu pracy z użyciem baterii
może odbywać się kosztem pogorszenia komfortu pracy użytkownika. Poniżej przyjrzymy się
bliżej obu tym podejściom. Najpierw jednak opowiemy o rozwiązaniach sprzętowych w odnie-
sieniu do wykorzystania energii.
Tabela 5.6. Zużycie energii przez różne części komputerów typu notebook
Urządzenie Li et al. (1994) Lorch i Smith (1998)
Monitor 68% 39%
Procesor 12% 18%
Dysk twardy 20% 12%
Modem 6%
Dźwięk 2%
Pamięć 0,5% 1%
Inne 22%
Wyświetlacz
Przyjrzyjmy się teraz urządzeniom, które najbardziej rozrzutnie wydają energetyczny budżet,
aby zobaczyć, co można zrobić z każdym z nich. Największym wydatkiem w każdym budżecie
energetycznym jest wyświetlacz. Uzyskanie jasnego i ostrego obrazu wymaga podświetlenia
ekranu, a do tego potrzeba znaczących ilości energii. Wiele systemów operacyjnych próbuje
oszczędzać energię zużywaną przez wyświetlacze poprzez wyłączanie ich w przypadku, gdy
użytkownik nie wykona żadnych działań przez określoną liczbę minut. Często system pozostawia
użytkownikowi wolną rękę w podejmowaniu decyzji dotyczącej przedziału, po którym ma na-
stępować zamknięcie. W ten sposób system ceduje na użytkownika obowiązek wypracowania
kompromisu pomiędzy częstym miganiem ekranu a szybkim zużywaniem baterii (czego użyt-
kownik prawdopodobnie nie chce). Wyłączenie wyświetlacza jest stanem uśpienia, ponieważ
można go odtworzyć (z pamięci wideo) niemal natychmiast po wciśnięciu klawisza lub przemiesz-
czeniu kursora myszy.
Jedno z możliwych usprawnień zaproponowali [Flinn i Satyanarayanan, 2004]. Zasugerowali,
aby wyświetlacz składał się z pewnej liczby stref, które będą niezależnie zasilane lub wyłączane.
Na rysunku 5.30 pokazano 16 stref oddzielonych od siebie liniami przerywanymi. Kiedy kursor
znajduje się w oknie nr 2, tak jak pokazano na rysunku 5.30(a), tylko cztery strefy w dolnym
prawym rogu muszą być podświetlone. Pozostałych 12 może być ciemnych, co pozwala zaosz-
czędzić 3/4 energii zużywanej przez wyświetlacz.
Rysunek 5.30. Wykorzystanie stref do podświetlania wyświetlacza: (a) kiedy zostanie wybrane
okno nr 2, nie będzie przemieszczone; (b) w przypadku wybrania okna nr 1 będzie ono przesunięte
w celu zmniejszenia liczby podświetlonych stref
5.30(b). Aby osiągnąć redukcję z 9/16 pełnej mocy do 4/16 pełnej mocy, menedżer okien musi
„znać się” na zarządzaniu energią lub przyjmować instrukcje od innego fragmentu systemu, który
się na tym zna. Jeszcze bardziej zaawansowanym rozwiązaniem byłoby częściowe podświetlenie
ekranu, który nie jest całkowicie wypełniony (np. okno po prawej stronie, zawierające krótkie
wiersze tekstu, mogłoby być ciemne).
Dysk twardy
Innym „przestępcą” jest dysk twardy. Pobiera znaczącą ilość energii w celu obracania się z dużymi
prędkościami, nawet wtedy, gdy nie są wykonywane odwołania do dysku. Wiele komputerów,
zwłaszcza notebooków, wyłącza wirowanie dysku po określonej liczbie sekund lub minut braku
aktywności. Kiedy dysk jest potrzebny ponownie, zostaje na nowo włączony. Niestety, zatrzy-
many dysk jest raczej zahibernowany niż uśpiony, ponieważ ponowne wprawienie go w ruch
obrotowy zajmuje kilka sekund, a to powoduje opóźnienie, które jest dla użytkownika zauważalne.
Poza tym restart dysku zużywa znaczącą ilość dodatkowej energii. Z tego względu każdy dysk
ma charakterystyczny czas progowy Td, z reguły w zakresie od 5 s do 15 s. Przypuśćmy, że
następny dostęp do dysku ma nastąpić w pewnym czasie t w przyszłości. Jeśli t < Td, to utrzy-
manie obracającego się dysku zużywa mniej energii od sytuacji, w której miałby on być zatrzy-
many, a następnie ponownie włączony. Jeśli t > Td, to ze względu na oszczędności energii opłaca
się zatrzymać dysk, a następnie uruchomić go po upływie znaczącego czasu. Gdyby można
było stworzyć dobre prognozy (np. na podstawie historii dostępu w przeszłości), system opera-
cyjny mógłby trafnie przewidywać czasy zatrzymywania dysku i dzięki temu oszczędzać energię.
W praktyce większość systemów to systemy konserwatywne, które zatrzymują dysk zaledwie
po kilku minutach braku aktywności.
Innym sposobem oszczędzania energii dysku jest utrzymywanie znaczącej dyskowej pamięci
podręcznej wewnątrz pamięci RAM. Jeśli potrzebny blok znajduje się w pamięci podręcznej,
nie trzeba restartować bezczynnego dysku w celu obsługi odczytu. Na podobnej zasadzie — jeśli
można zbuforować w pamięci podręcznej operacje zapisu na dysk — nie trzeba wznawiać zatrzy-
manego dysku tylko po to, by obsłużyć zapis. Dysk może pozostawać wyłączony tak długo, aż
pamięć podręczna się zapełni lub odwołanie do pamięci podręcznej okaże się nieskuteczne.
Innym sposobem na to, by system operacyjny mógł uniknąć niepotrzebnego wznawiania
pracy dysku, jest informowanie działających programów o stanie dysku poprzez przesyłanie do
nich komunikatów lub sygnałów. Niektóre programy mają zaplanowane operacje zapisu, które
można pominąć lub opóźnić. Można np. skonfigurować edytor tekstu w taki sposób, by co kilka
minut zapisywał modyfikowany plik na dysk. Jeśli edytor tekstu wie, że dysk jest wyłączony
w chwili, kiedy ma wykonać operację zapisu, może opóźnić ten zapis do momentu ponownego
włączenia dysku lub wstrzymać się jeszcze przez pewien czas.
Procesor
Zarządzanie energią może dotyczyć również procesora. Procesor notebooka można programowo
przełączyć w stan uśpienia i zmniejszyć tym samym zużycie energii niemal do zera. Jedyne, co
procesor może zrobić w tym stanie, to obudzić się w momencie nadejścia przerwania. Z tego
względu za każdym razem, kiedy procesor przechodzi w stan bezczynności — ponieważ oczekuje
na operację wejścia-wyjścia lub ze względu na to, że nie ma nic do zrobienia — jest przełączany
w stan uśpienia.
Rysunek 5.31. (a) Działanie z pełną szybkością zegara; (b) obniżenie napięcia do połowy dwukrotnie
zmniejsza szybkość zegara, a czterokrotnie konsumpcję energii
Nie jest to jednak rozwiązanie atrakcyjne, ponieważ powoduje zwiększone zużycie energii — czyli
efekt dokładnie odwrotny do zamierzonego. Znacznie bardziej atrakcyjnym rozwiązaniem jest
uruchomienie stosu sieciowego na wolniejszym rdzeniu, tak aby był ciągle zajęty (i tym samym
nigdy nie przechodził do stanu uśpienia). Takie rozwiązanie równocześnie zmniejsza zużycie
energii. Jeśli rdzeń sieci zostanie starannie spowolniony, jego wydajność stanie się lepsza
w porównaniu z konfiguracją, w której wszystkie rdzenie działają maksymalnie szybko.
Pamięć
Istnieją dwie możliwości oszczędzania energii zużywanej przez pamięć. Po pierwsze można
opróżnić pamięć podręczną, a następnie ją wyłączyć. Zawsze można ją załadować na nowo
z pamięci głównej bez utraty informacji. Ponowne załadowanie można zrealizować dynamicznie
i szybko, dzięki czemu wyłączenie pamięci podręcznej powoduje przejście do stanu uśpienia.
Bardziej drastyczną opcją jest zapisanie zawartości głównej pamięci na dysk, a następnie
wyłączenie pamięci głównej. Jest to stan hibernacji, ponieważ pozwala na całkowite odcięcie
pamięci od energii, kosztem znacznego czasu ponownego ładowania. Czas ten jest szczególnie
wydłużony, jeśli dysk także jest wyłączony. W przypadku odcięcia energii procesor także musi
się wyłączyć lub uruchomić się z pamięci ROM. Jeśli procesor jest wyłączony, to przerwanie,
które go budzi, musi spowodować, by przeszedł do kodu w pamięci ROM, tak by można było na
nowo załadować pamięć przed jej wykorzystaniem. Pomimo wszystkich niedogodności wyłą-
czenie pamięci na długi okres (np. kilka godzin) może być opłacalne, jeśli restart nastąpi w ciągu
kilku sekund. Często jest to lepsze i bardziej pożądane niż ponowne załadowanie systemu ope-
racyjnego z dysku, co zwykle zajmuje minutę lub nawet więcej.
Komunikacja bezprzewodowa
Coraz więcej komputerów przenośnych jest wyposażonych w połączenie bezprzewodowe ze
światem zewnętrznym (np. z internetem). Nadajnik i odbiornik radiowy często zużywają bardzo
dużo energii. W szczególności gdy odbiornik radiowy jest przez cały czas włączony w celu
nasłuchiwania nadchodzących wiadomości e-mail, bateria może się wyczerpać bardzo szybko.
Z drugiej strony, jeśli radio byłoby wyłączane np. po 1 min bezczynności, użytkownik mógłby nie
zauważyć momentu nadejścia wiadomości, co z oczywistych względów jest niepożądane.
Propozycję skutecznego rozwiązania tego problemu przedstawili [Kravets i Krishnan, 1998].
Ich rozwiązanie wykorzystuje fakt, że komputery przenośne komunikują się ze stacjonarnymi
stacjami bazowymi, które mają pojemne pamięci i dyski i nie mają ograniczeń energetycznych.
Zaproponowali oni, aby bezpośrednio przed wyłączeniem radia komputer przenośny wysłał
komunikat do stacji bazowej. Od tego momentu stacja bazowa zaczyna buforować na swoim
dysku nadchodzące komunikaty. Komputer przenośny może jawnie określić, jak długo planuje
pozostawać uśpiony, lub po prostu poinformować stację bazową o ponownym włączeniu radia.
W tym momencie może ona przesłać komunikaty zbierane dla komputera przenośnego w czasie
jego nieaktywności.
Komunikaty wychodzące generowane w czasie, gdy radio jest wyłączone, są buforowane na
komputerze przenośnym. Jeśli zachodzi obawa, że bufor się zapełni, komputer przenośny włącza
radio i przesyła kolejkę komunikatów do stacji bazowej.
Kiedy należy wyłączyć radio? Jedną z możliwości jest powierzenie tej decyzji użytkownikowi
lub aplikacji. Inna możliwość to wyłączenie radia po określonym czasie bezczynności. Kiedy
należy ponownie włączyć radio? Także w tym przypadku może o tym decydować użytkownik
lub program. Można również włączać radio okresowo w celu sprawdzania obecności ruchu
wchodzącego i przesyłać komunikaty umieszczone w kolejce. Oczywiście radio należy włączyć
także wtedy, gdy bufor wyjściowy jest bliski zapełnienia. Możliwe są także różne inne sposoby
postępowania.
Przykładem technologii bezprzewodowej obsługującej taki system zarządzania energią mogą
być sieci 802.11 (Wi-Fi). W sieciach 802.11 komputer przenośny może powiadomić punkt dostę-
powy, że ma zamiar przejść do uśpienia, ale obudzi się, zanim stacja bazowa prześle następną
ramkę nawigacyjną (ang. beacon frame). Punkt dostępowy wysyła takie ramki okresowo. W tym
momencie punkt dostępowy może poinformować komputer przenośny, że ma dane do przesłania.
Jeśli nie ma danych, komputer przenośny może ponownie przejść do uśpienia, aż do następnej
ramki nawigacyjnej.
Zarządzanie temperaturą
Nieco innym problemem, choć także związanym z energią, jest zarządzanie temperaturą. Nowo-
czesne procesory bardzo się nagrzewają z powodu dużej szybkości ich działania. Komputery typu
desktop zwykle są wyposażone w wewnętrzny wentylator, który wydmuchuje gorące powietrze
z obudowy. Ponieważ redukcja zużycia energii zwykle nie jest najważniejszą kwestią w przypadku
komputerów desktop, zazwyczaj wentylator jest włączony przez cały czas.
W przypadku notebooków sytuacja jest zupełnie inna. System operacyjny przez cały czas
musi monitorować temperaturę. Kiedy zbliży się ona do maksymalnego akceptowalnego poziomu,
system operacyjny ma kilka możliwości. Może włączyć wentylator, co generuje hałas i zużywa
energię. Alternatywnie może zmniejszyć zużycie energii poprzez redukcję podświetlenia ekranu,
spowolnienie procesora, bardziej agresywną strategię spowalniania dysku itp.
W tym przypadku dużą wartość mają wskazówki udzielone przez użytkownika; np. może on
z góry zastrzec, że szum wentylatora jest niedopuszczalny i żeby system operacyjny, zamiast
włączać wentylator, zmniejszył zużycie energii.
Zarządzanie bateriami
W dawnych czasach baterie dostarczały energii aż do wyczerpania. Po wyczerpaniu zatrzymywały
się. Dziś już tak nie jest. W laptopach montuje się inteligentne baterie, które są zdolne do komu-
nikowania się z systemem operacyjnym. Na żądanie są w stanie poinformować system opera-
cyjny o takich parametrach, jak maksymalne napięcie, maksymalny poziom naładowania, bie-
żący stan naładowania, maksymalny współczynnik poboru mocy, bieżący współczynnik poboru
mocy itp. Większość notebooków jest wyposażonych w programy, które mogą być uruchomione
w celu generowania zapytań i wyświetlania wszystkich tych parametrów. Do inteligentnych
baterii można również kierować polecenia zmiany różnych parametrów pracy kontrolowanych
przez system operacyjny.
Niektóre notebooki mają kilka baterii. Kiedy system operacyjny wykryje, że jedna z baterii
jest bliska wyczerpania, może przełączyć zasilanie do następnej baterii w taki sposób, by użyt-
kownik nawet tego nie zauważył. Kiedy ostatnia z baterii jest bliska wyczerpania, zadaniem
systemu operacyjnego jest ostrzeżenie użytkownika i przeprowadzenie kontrolowanego zamknięcia
systemu w taki sposób, by nie został uszkodzony system plików.
Jedną z form degradacji jest porzucenie informacji o kolorach i wyświetlanie wideo w trybie
czarno-białym. Inną formą degradacji jest zmniejszenie współczynnika ramek, co prowadzi do
migania i sprawia, że film ma niską jakość. Jeszcze innym rodzajem degradacji jest zmniejszenie
liczby pikseli w obu kierunkach — poprzez obniżenie rozdzielczości lub zmniejszenie wyświetlanego
obrazu. Z pomiarów wynikało, że zabiegi tego typu pozwalają na zaoszczędzenie około 30% energii.
Drugą aplikacją poddaną pomiarom był program do rozpoznawania mowy. Program ten
próbkował mikrofon w celu stworzenia przebiegu w kształcie fali. Przebieg ten można było
analizować na notebooku lub przesłać do analizy łączem radiowym do komputera stacjonarnego.
Ten sposób pozwalał na oszczędność energii procesora, ale wymagał energii do obsługi nadaj-
nika radiowego. Degradację zrealizowano poprzez użycie mniejszego zakresu słownictwa oraz
prostszego modelu akustycznego. W ten sposób udało się zaoszczędzić około 35% energii.
Następnym przykładem aplikacji była przeglądarka map, która pobierała mapę przez łącze
radiowe. Degradacja polegała na obcięciu mapy do mniejszych rozmiarów lub nakazaniu zdal-
nemu serwerowi pominięcia mniejszych dróg. Dzięki temu trzeba było przesłać mniej bitów.
Także w tym przypadku zanotowano około 35% oszczędności.
Czwarty eksperyment polegał na transmisji obrazów JPEG do przeglądarki WWW. Standard
JPEG umożliwia stosowanie różnych algorytmów pozwalających na uzyskiwanie plików o bardzo
małych rozmiarach kosztem jakości obrazu. W tym przypadku uzyskane oszczędności wyniosły
przeciętnie zaledwie 9%. Pomimo wszystko eksperymenty dowiodły, że jeśli użytkownik zgodzi
się na pewną degradację jakości, to będzie mógł dłużej korzystać z określonej baterii.
Na temat wejścia-wyjścia przeprowadza się wiele badań. Niektóre skupiają się na specyficznych
urządzeniach, a nie na ogólnych problemach, inne na całej infrastrukturze wejścia-wyjścia; np.
architektura Streamline ma na celu dostarczenie mechanizmów wejścia-wyjścia dostosowanych
do aplikacji, które minimalizują obciążenia związane z kopiowaniem, przełączaniem kontekstu,
sygnalizacją oraz ze złym wykorzystaniem pamięci podręcznej i bufora TLB [DeBruijn et al.,
2011]. Koncepcja architektury bazuje na pojęciu obwodnicy buforów (ang. beltway buffers), zaawan-
sowanych buforów cyklicznych, które są bardziej wydajne od istniejących systemów buforowania
[DeBruijn i Bos, 2008]. Architektura Streamline jest szczególnie przydatna dla wymagających
aplikacji sieciowych. Megapipe [Han et al., 2012] to kolejna sieciowa architektura wejścia-wyjścia
przeznaczona dla ruchu sieciowego zorientowanego na komunikaty. Jej podstawą są dwukierun-
kowe kanały na poziomie rdzeni pomiędzy przestrzeniami jądra i użytkownika. Na ich bazie dzia-
łają abstrakcje systemowe, takie jak lekkie gniazda. Gniazda nie są w całości zgodne z POSIX,
więc aplikacje muszą być dostosowane do korzystania z bardziej wydajnych mechanizmów
wejścia-wyjścia.
Często celem badań jest poprawa wydajności konkretnych urządzeń. Jedno z najczęściej
badanych zagadnień dotyczy systemów dyskowych. Niezwykle popularnym przedmiotem badań
są algorytmy zarządzania ramieniem dysku. Czasami badania koncentrują się na poprawie wydaj-
ności ([Gonzalez-Ferez et al., 2012], [Prabhakar et al., 2013], [Zhang et al., 2012b]), a innym razem
na niższym zużyciu energii ([Krish et al., 2013], [Nijim et al., 2013], [Zhang et al., 2012a]). Wraz
ze wzrostem popularności konsolidacji serwerów przy użyciu maszyn wirtualnych gorącym
tematem badań stało się szeregowanie dysków dla systemów zwirtualizowanych ([Jin et al.,
2013], [Ling et al., 2012]).
Nie wszystkie tematy jednak są nowe. Wiele uwagi nadal przyciąga technologia RAID ([Chen
et al., 2013], [Moon i Reddy, 2013], [Timcenko i Djordjevic, 2013]), a także dyski SSD ([Dayan et al.,
2013], [Kim et al., 2013], [Luo et al., 2013]). W obszarze badań teoretycznych zainteresowanie wzbu-
dza modelowanie systemów dyskowych w różnych obciążeniach ([Li et al., 2013b], [Shen i Qi, 2013]).
Dyski nie są jedynymi urządzeniami wejścia-wyjścia skupiającymi uwagę. Kolejnym kluczo-
wym obszarem badań dotyczących zagadnień wejścia-wyjścia są sieci. Do badanych zagadnień
należą zużycie energii ([Hewage i Voigt, 2013], [Hoque et al., 2013]), sieci centrów danych
([Haitjema, 2013], [Liu et al., 2103], [Sun et al., 2013]), jakość usług ([Gupta, 2013], [Hemkumar
i Vinaykumar, 2012], [Lai i Tang, 2013]), a także wydajność ([Han et al., 2012], [Soorty, 2012]).
Jeśli wziąć pod uwagę liczbę specjalistów z branży komputerowej posługujących się note-
bookami oraz mikroskopijny czas życia baterii w większości z nich, nie powinno nikogo dziwić,
że coraz większe zainteresowanie zdobywa wykorzystywanie technik programowych do redukcji
zużycia energii. Wśród tematów specjalistycznych można znaleźć następujące zagadnienia:
równoważenie szybkości taktowania na różnych rdzeniach w celu osiągnięcia maksymalnej
wydajności bez marnowania energii — [Hruby 2013]; zużycie energii a jakość usług — [Holm-
backa et al., 2013]; szacowanie zużycia energii w czasie rzeczywistym — [Dutta et al., 2013];
dostarczanie usług systemu operacyjnego w celu zarządzania zużyciem energii — [Weissel,
2012]; badanie kosztów energetycznych zabezpieczeń — [Kabri i Seret, 2009]; a także szere-
gowanie w systemach multimedialnych — [Wei et al., 2010].
Jednak nie wszyscy badacze interesują się notebookami. Niektórzy zajmują się badaniami na
wielką skalę, zmierzającymi do oszczędności wielu megawatów w centrach przetwarzania danych
([Fetzer i Knauth, 2012], [Schwartz et al., 2012], [Wang et al., 2013b], [Yuan et al., 2012]).
Na drugim końcu spektrum bardzo gorącym tematem jest wykorzystanie energii w sieciach
sensorowych ([Albath et al., 2013], [Mikhaylov i Tervonen, 2013], [Rasaneh i Banirostam, 2013],
[Severini et al., 2012]).
Pewnym zaskoczeniem może być duże zainteresowanie badaczy skromnym zegarem. W celu
zapewnienia dobrej rozdzielczości niektóre systemy operacyjne wykorzystują zegar o często-
tliwości 1000 HZ, co prowadzi do znaczących kosztów obliczeniowych. Badania są prowadzone
pod kątem obniżenia tych kosztów — [Tsafir et al., 2005].
Podobnie opóźnienia przerwań nadal są problemem rozwiązywanym przez wiele grup
badawczych, szczególnie w dziedzinie systemów operacyjnych czasu rzeczywistego. Ponie-
waż opóźnienia przerwań są często osadzone w krytycznych systemach (np. systemach hamul-
cowych i układach kierowniczych), zezwolenie na przerwania tylko w bardzo konkretnych
punktach wywłaszczenia pozwala systemom na kontrolę możliwych przeplotów i umożliwia
stosowanie weryfikacji formalnej w celu poprawy niezawodności — [Blackham et al., 2012].
Bardzo aktywnym obszarem badań są nadal sterowniki urządzeń. Wiele awarii systemów
operacyjnych jest spowodowanych przez wadliwe sterowniki. Symdrive jest frameworkiem do
testowania sterowników urządzeń bez potrzeby fizycznej komunikacji ze sprzętem — [Renzel-
mann et al., 2012]. [Rhyzik et al., 2009] prezentują alternatywne podejście — pokazują sposób
automatycznego konstruowania sterowników urządzeń na podstawie specyfikacji, co zmniejsza
ryzyko popełnienia błędów.
Cienkie klienty również są przedmiotem zainteresowania. Szczególnie uwagę przyciągają
urządzenia mobilne podłączone do chmury ([Hocking, 2011], [Tuan-Anh et al., 2013]). Na koniec
warto wspomnieć o artykułach na nietypowe tematy, np. poświęcone budynkom jako dużym
urządzeniom wejścia-wyjścia — [Dawson-Haggerty et al., 2013].
5.10. PODSUMOWANIE
5.10.
PODSUMOWANIE
Zagadnienia wejścia-wyjścia są często zaniedbywane, choć to bardzo istotny obszar. Duża część
każdego systemu operacyjnego dotyczy wejścia-wyjścia. Można je realizować na jeden z trzech
sposobów. Po pierwsze istnieje programowane wejście-wyjście. W przypadku wykorzystywania
tego mechanizmu procesor wprowadza albo wyprowadza każdy bajt lub słowo i przechodzi do
stanu oczekiwania w pętli, w której pozostaje tak długo, aż będzie możliwy odbiór lub wysłanie
następnego bajta lub słowa. Po drugie istnieje wejście-wyjście sterowane przerwaniami, w któ-
rym procesor rozpoczyna operację transferu wejścia-wyjścia znaku lub słowa i przechodzi do
wykonywania innych zadań do czasu nadejścia przerwania sygnalizującego wykonanie zadania
wejścia-wyjścia. Po trzecie istnieje DMA, w którym oddzielny układ w całości zarządza transfe-
rem bloku danych i generuje przerwanie dopiero wtedy, gdy cały blok zostanie przesłany.
System wejścia-wyjścia może mieć strukturę czteropoziomową, na którą składają się pro-
cedury obsługi przerwań, sterowniki urządzeń, oprogramowanie wejścia-wyjścia niezależne od
urządzeń oraz biblioteki wejścia-wyjścia i spoolery działające w przestrzeni użytkownika. Sterow-
niki urządzeń są odpowiedzialne za szczegóły komunikacji z urządzeniami i dostarczenie jedno-
litego interfejsu do pozostałej części systemu operacyjnego. Oprogramowanie wejścia-wyjścia
niezależne od urządzeń jest odpowiedzialne za takie operacje, jak buforowanie i zgłaszanie błędów.
Dyski są dostępne w wielu różnych typach. Istnieją dyski magnetyczne, macierze RAID,
a także różnego rodzaju dyski optyczne. W celu poprawy wydajności dysków zawierających
elementy mechaniczne można skorzystać z algorytmów zarządzania ramieniem dysków, choć
problem komplikuje się przez istnienie geometrii wirtualnych. Dzięki połączeniu w parę dwóch
dysków można stworzyć stabilne urządzenie pamięci masowej charakteryzujące się pewnymi
użytecznymi właściwościami.
Zegary są wykorzystywane do mierzenia czasu rzeczywistego, ograniczania czasu, przez jaki
mogą działać długotrwałe procesy, obsługiwania dozorujących liczników czasu oraz rozliczania.
Z obsługą terminali znakowych jest związana cała gama problemów dotyczących specjalnych
znaków, które mogą być wprowadzane, oraz specjalnych sekwencji sterujących, które mogą
być wyprowadzane. Wejście może być realizowane w trybie „surowym” lub „ugotowanym”,
w zależności od tego, jaką kontrolę program chce mieć nad wprowadzanymi danymi. Sekwencje
sterujące na wyjściu sterują ruchami kursora oraz pozwalają na wstawianie albo usuwanie tekstu
na ekranie.
Większość systemów UNIX korzysta ze środowiska X Window jako bazy dla interfejsu
użytkownika. Środowisko to składa się z programów powiązanych ze specjalnymi bibliotekami,
wydającymi polecenia rysowania, oraz z serwera X, który pisze informacje na wyświetlaczu.
Wiele komputerów osobistych korzysta z interfejsu GUI do wyprowadzania informacji. Inter-
fejsy tego typu bazują na paradygmacie WIMP: okna, ikony, menu i urządzenie wskazujące.
Programy z interfejsem GUI są zwykle sterowane zdarzeniami. Zdarzenia związane z klawiaturą,
myszą oraz innymi urządzeniami są przesyłane do programów w celu ich przetwarzania natych-
miast po tym, jak się pojawią. W systemach typu UNIX środowiska GUI prawie zawsze działają
na bazie systemu X.
Cienkie klienty mają pewne zalety w porównaniu ze standardowymi komputerami PC. Naj-
większe z nich to prostota oraz mniejsze nakłady związane z pielęgnacją.
Wreszcie: zarządzanie energią jest poważnym problemem dotyczącym telefonów, tabletów
i notebooków — ze względu na ograniczony czas życia baterii — a także komputerów stacjonarnych
PYTANIA
1. Postęp w technologii wytwarzania układów umożliwił umieszczenie całego kontrolera
włącznie z logiką dostępu do magistrali w niedrogim układzie scalonym. W jaki sposób
wpływa to na model pokazany na rysunku 1.5?
2. Czy przy szybkościach pokazanych w tabeli 5.1 można skanować dokumenty na skanerze
i przesyłać je z pełną szybkością w sieci 802.11g? Uzasadnij swoją odpowiedź.
3. Na rysunku 5.2(b) pokazano jeden ze sposobów realizacji wejścia-wyjścia odwzorowanego
w pamięci, nawet w warunkach występowania osobnych magistral dla pamięci i urządzeń
wejścia-wyjścia. Dokładniej mówiąc, najpierw sondowana jest magistrala pamięci, a gdy
to zawiedzie, sprawdzona zostaje magistrala wejścia-wyjścia. Inteligentny student infor-
matyki wymyślił usprawnienie tej koncepcji: równoległe sprawdzanie obu magistral
w celu przyspieszenia procesu dostępu do urządzeń wejścia-wyjścia. Co sądzisz o tej
koncepcji?
4. Wyjaśnij zalety i wady stosowania przerwań precyzyjnych i nieprecyzyjnych na maszynie
superskalarnej.
5. Kontroler DMA ma pięć kanałów. Jest w stanie żądać 32-bitowego słowa co 40 ns. Odpo-
wiedź zajmuje równie dużo czasu. Jak szybka musi być magistrala, aby uniknąć wąskiego
gardła?
6. Przypuśćmy, że system używa DMA w celu transmisji danych z kontrolera dysku do
pamięci głównej. Załóżmy także, że uzyskanie dostępu do magistrali zajmuje przeciętnie
t1 ns, natomiast przesłanie jednego słowa przez magistralę zajmuje t2 ns (t1 >> t2). Jeśli
procesor zaprogramował kontroler DMA, to ile czasu zajmie przesłanie 1000 słów z kon-
trolera dysku do pamięci głównej, jeśli (a) wykorzystywany jest tryb słowo po słowie, (b)
wykorzystywany jest tryb wiązki. Załóżmy, że zarządzanie kontrolerem dysku wymaga
uzyskania dostępu do magistrali w celu przesłania jednego słowa oraz że potwierdzenie
transferu także wymaga uzyskania dostępu do magistrali w celu przesłania jednego słowa.
7. W jednym z trybów wykorzystywanych przez niektóre kontrolery DMA kontroler urzą-
dzenia wysyła słowo do kontrolera DMA, a ten następnie wysyła drugie żądanie na magi-
stralę w celu realizacji zapisu do pamięci. Jak można skorzystać z tego trybu do realizacji
kopiowania z pamięci do pamięci? Omów zalety i wady korzystania z tej metody zamiast
wykorzystania procesora do wykonywania kopii z pamięci do pamięci.
8. Przypuśćmy, że komputer może odczytać lub zapisać słowo pamięci w ciągu 5 ns. Przy-
puśćmy także, że kiedy wystąpi przerwanie, wszystkie 32 rejestry procesora razem
z licznikiem programu i rejestrem PSW zostaną odłożone na stos. Jaka jest maksymalna
liczba przerwań na sekundę, którą zdoła przetworzyć taka maszyna?
9. Architekci procesorów wiedzą, że programiści piszący systemy operacyjne nie znoszą
nieprecyzyjnych przerwań. Jednym ze sposobów, by zadowolić specjalistów w dziedzinie
systemów operacyjnych, nie jest zatrzymanie wysyłania nowych instrukcji przez procesor
w czasie, gdy jest sygnalizowane przerwanie, ale umożliwienie zakończenia aktualnie
wykonywanych instrukcji i dopiero potem wymuszenie przerwania. Czy takie podejście
ma jakieś wady? Uzasadnij swoją odpowiedź.
10. W sytuacji z listingu 5.2(b) przerwanie nie jest potwierdzone do momentu wysłania na-
stępnego znaku do drukarki. Czy równie dobrze można by potwierdzić przerwanie na
początku procedury obsługi przerwania? Jeśli tak, podaj jeden powód, dla którego warto
to robić na końcu — tak jak na listingu. A jeśli nie, to dlaczego?
11. Komputer wykorzystuje potok trójfazowy podobny do tego, który pokazano na rysunku
1.7(a). W każdym takcie zegara spod adresu pamięci wskazywanego przez rejestr PC jest
pobierana nowa instrukcja i umieszczana w potoku. Po wykonaniu tej operacji następuje
inkrementacja rejestru PC. Każda instrukcja zajmuje dokładnie jedno słowo pamięci.
Każda z instrukcji, które już znajdują się w potoku, postępuje o jedną fazę. Kiedy wystę-
puje przerwanie, bieżąca wartość rejestru PC jest odkładana na stos, a następnie rejestr
PC zostaje ustawiony na wartość adresu procedury obsługi przerwania. Następnie potok
przesuwa się w prawo o jedną fazę, a do potoku jest pobierana pierwsza instrukcja pro-
cedury obsługi przerwania. Czy ta maszyna wykorzystuje przerwania precyzyjne? Uza-
sadnij swoją odpowiedź.
12. Typowa drukowana strona tekstu składa się z 50 wierszy po 80 znaków. Wyobraźmy
sobie, że pewna drukarka może drukować 6 stron na minutę oraz że czas zapisywania
znaku do rejestru wyjściowego drukarki jest pomijalnie krótki. Czy uruchamianie tej
drukarki z wykorzystaniem mechanizmów wejścia-wyjścia sterowanych przerwaniami
ma sens, jeśli wydrukowanie każdego znaku wymaga dodania 50 μs do czasu obsługi?
13. Wyjaśnij, w jaki sposób system operacyjny może zrealizować instalację nowego urządzenia
bez konieczności ponownej kompilacji systemu.
14. W której z czterech warstw oprogramowania wejścia-wyjścia wykonywana jest każda
z poniższych operacji:
(a) Obliczanie ścieżki, sektora i głowicy w celu realizacji odczytu z dysku.
(b) Zapis poleceń do rejestrów urządzenia.
(c) Sprawdzenie, czy użytkownik jest uprawniony do używania urządzenia.
(d) Konwersja binarnych liczb całkowitych na format ASCII w celu ich wydrukowania.
15. Lokalna sieć komputerowa jest wykorzystywana w sposób opisany poniżej. Użytkownik
wydaje wywołanie systemowe w celu zapisania pakietów danych do sieci. Następnie sys-
tem operacyjny kopiuje dane do bufora jądra. Po wykonaniu tej operacji kopiuje dane na
kartę kontrolera sieci. Kiedy wszystkie bajty są już bezpieczne wewnątrz kontrolera, są
wysyłane przez sieć z szybkością 10 megabitów/s. Kontroler sieciowy urządzenia odbie-
rającego zapisuje każdy bit po upływie mikrosekundy od jego wysłania. Kiedy nadejdzie
ostatni bit, generowane jest przerwanie do procesora docelowego, a jądro kopiuje nowo
odebrany pakiet do bufora jądra w celu jego zbadania. Po stwierdzeniu, do którego
użytkownika przeznaczony jest pakiet, jądro kopiuje dane do przestrzeni użytkownika.
Jaka jest maksymalna szybkość, z jaką jeden proces może zasilać danymi inny proces,
jeśli założymy, że każde przerwanie i powiązane z nim przetwarzanie zajmuje 1 ms, pakiety
mają rozmiar 1024 bajtów (pomijając nagłówki) oraz że kopiowanie bajtu zajmuje 1 μs?
Załóżmy, że nadawca jest zablokowany do czasu otrzymania potwierdzenia, które jest
wysyłane po zakończeniu operacji po stronie odbiorcy. Dla uproszczenia załóżmy, że
czas potrzebny do uzyskania potwierdzenia jest tak krótki, że można go zignorować.
16. Dlaczego pliki wyjściowe przeznaczone dla drukarki przed wydrukowaniem są umiesz-
czone w spoolerze na dysku?
17. Jaki przekos cylindrów jest potrzebny dla dysku o prędkości obrotowej 7200 rpm, jeśli
czas przejścia pomiędzy kolejnymi ścieżkami wynosi 1 ms? Dysk ma 200 sektorów po
512 bajtów na każdej ścieżce.
18. Dysk obraca się z prędkością 7200 rpm. Ma 500 sektorów po 512 bajtów na zewnętrznym
cylindrze. Jak długo trwa odczyt sektora?
19. Oblicz maksymalną prędkość danych w bajtach na sekundę dla dysku opisanego w poprzed-
nim pytaniu.
20. Macierz RAID poziomu 3 jest w stanie korygować błędy na pojedynczych bitach z wyko-
rzystaniem tylko jednego dysku parzystości. Jaki jest sens istnienia macierzy RAID
poziomu 2? Przecież macierz tego typu również może korygować tylko pojedyncze błędy,
a potrzebuje do tego więcej napędów.
21. Macierz RAID może ulec awarii, jeśli dwa (lub większa liczba) dyski wchodzące w jej skład
ulegną awarii w krótkim odcinku czasu. Przypuśćmy, że prawdopodobieństwo tego, że
jeden z dysków ulegnie awarii w ciągu godziny wynosi p. Jakie jest prawdopodobieństwo
awarii macierzy RAID złożonej z k dysków w ciągu godziny?
22. Porównaj macierze RAID poziomów od 0 do 5 pod względem wydajności odczytu, wydaj-
ności zapisu, kosztów miejsca i niezawodności.
23. Ile pebibajtów zawiera zebibajt?
24. Dlaczego optyczne urządzenia pamięci masowej mają możliwość zapisywania danych
o większej gęstości niż magnetyczne urządzenia pamięci masowych? Uwaga: rozwiązanie
tego problemu wymaga pewnej wiedzy z fizyki z zakresu szkoły średniej na temat spo-
sobu generowania pól magnetycznych.
25. Jakie są zalety i wady dysków optycznych w porównaniu z dyskami magnetycznymi?
26. Jeśli kontroler dysku zapisuje do pamięci bajty otrzymane z dysku tak szybko, jak je
odbiera, bez wewnętrznego buforowania, to czy przeplot może się do czegoś przydać?
Uzasadnij.
27. Jeśli dla dysku zastosowano podwójny przeplot, to czy trzeba dla niego zastosować rów-
nież przekos cylindrów, aby zapobiec przypadkom braku danych podczas realizacji wyszu-
kiwania ścieżka po ścieżce? Uzasadnij swoją odpowiedź.
28. Rozważmy dysk magnetyczny składający się z 16 głowic i 400 cylindrów. Ten dysk jest
podzielony na cztery strefy po 100 cylindrów, przy czym cylindry w różnych strefach
zawierają odpowiednio 160, 200, 240 i 280 sektorów. Załóżmy, że każdy sektor zawiera
512 bajtów, przeciętny czas wyszukiwania pomiędzy sąsiednimi cylindrami wynosi 1 ms,
a dysk obraca się z szybkością 7200 obrotów na minutę. Oblicz (a) pojemność dysku, (b)
optymalny przekos ścieżek i (c) maksymalną szybkość transmisji danych.
29. Producent dysków produkuje dwa dyski 5,25 cala, z których każdy ma po 10 000 cylindrów.
W nowszym podwojono liniową gęstość zapisu w porównaniu ze starszym. Które wła-
ściwości dysku są lepsze na nowszym dysku, a które są takie same? Czy istnieją właści-
wości nowszego dysku, które są gorsze od właściwości dysku starszego?
30. Firma produkująca komputery zdecydowała się na modyfikację tablicy partycji na dysku
twardym systemu x86, aby można było wykorzystywać więcej niż cztery partycje. Jakie
będą niektóre konsekwencje takiej zmiany.
31. Do sterownika dysku nadeszły żądania o cylindry: 10., 22., 20., 2., 40., 6. i 38. — dokład-
nie w takiej kolejności. Operacja wyszukiwania zajmuje 6 ms dla przemieszczenia o każdy
cylinder. Ile będzie wynosił czas wyszukiwania dla każdego z poniższych algorytmów:
(a) Pierwszy zgłoszony, pierwszy obsłużony (FCFS).
(b) Najpierw najbliższy cylinder.
(c) Algorytm windy (przy początkowym przemieszczaniu w górę).
We wszystkich przypadkach ramię dysku początkowo znajduje się nad cylindrem 20.
32. Niewielką modyfikacją algorytmu windy dla szeregowania żądań dysku jest skanowanie
zawsze w tym samym kierunku. Pod jakimi względami ten zmodyfikowany algorytm jest
lepszy od algorytmu windy?
33. Podczas wizyty na uniwersytecie w południowo-zachodniej części Amsterdamu przed-
stawiciel firmy produkującej komputery osobiste zakomunikował, że podjęła ona wysiłki
zmierzające do tego, by ich wersja Uniksa stała się bardzo szybka. Na potwierdzenie
swoich słów podał przykład, że w sterowniku dysku zastosowano algorytm windy oraz
kolejkowanie wielu żądań w ramach cylindra w kolejności sektorów. Student Henryk
Haker był pod wrażeniem tej prelekcji i zakupił egzemplarz komputera. Kiedy zainstalował
go w domu, napisał program, który losowo czyta 10 000 bloków rozmieszczonych w róż-
nych miejscach dysku. Ku swojemu zdumieniu zauważył, że zmierzona wydajność była
identyczna z tą, której można było oczekiwać przy zastosowaniu algorytmu pierwszy
zgłoszony, pierwszy obsłużony. Czy handlowiec kłamał?
34. Podczas dyskusji na temat stabilnej pamięci masowej z wykorzystaniem nieulotnej pamięci
RAM poruszono następujący problem: co się stanie, jeśli stabilny zapis się zakończy,
a awaria wystąpi, zanim system operacyjny zdoła zapisać nieprawidłowy numer bloku
w nieulotnej pamięci RAM? Czy taka sytuacja wyścigu zniszczy abstrakcję stabilnej
pamięci masowej? Uzasadnij swoją odpowiedź.
35. Podczas omawiania stabilnej pamięci masowej pokazano, że jeśli podczas zapisu nastąpi
awaria procesora, to można odtworzyć dysk do spójnego stanu (zapis albo musi się zakoń-
czyć, albo nie jest wykonywany wcale). Czy ta własność będzie zachowana, jeśli awaria
procesora wystąpi ponownie podczas procedury odtwarzania? Uzasadnij swoją odpowiedź.
36. W opisie stabilnej pamięci trwałej kluczowym założeniem jest to, że awaria procesora,
która niszczy sektor, prowadzi do nieprawidłowego kodu korekcyjnego ECC. Jakie pro-
blemy mogą powstać w pięciu scenariuszach awarii i odzyskiwania pokazanych na
rysunku 5.21., jeżeli to założenie nie jest prawdziwe?
37. Procedura obsługi zegara na pewnym komputerze wymaga 2 ms (włącznie z kosztami
przełączania procesów) dla każdego taktu zegara. Zegar działa z częstotliwością 60 Hz.
Jaka część mocy procesora jest poświęcona dla zegara?
38. Komputer wykorzystuje programowalny zegar w trybie fali prostokątnej. Jaka powinna
być — w przypadku użycia kryształu o częstotliwości drgań 500 MHz — wartość rejestru
podtrzymującego w celu osiągnięcia następujących dokładności zegara:
(a) 1 ms (takt zegara co 1 ms)?
(b) 100 μs?
39. System symuluje użycie wielu układów zegara poprzez połączenie w łańcuch wszystkich
nieobsłużonych żądań zegarowych, tak jak pokazano na rysunku 5.28. Załóżmy, że bieżąca
wartość zegara wynosi 5000 i istnieją nieobsłużone żądania zegarowe dla taktów: 5008,
5012, 5015, 5029 i 5037. Jakie będą wartości Początek listy liczników, Aktualny czas
i Następny sygnał dla taktu 5023?
40. W wielu wersjach systemu UNIX wykorzystuje się 32-bitową liczbę całkowitą bez znaku
do przechowywania czasu w postaci liczby sekund, które upłynęły od pewnego momentu.
Podaj rok i miesiąc, kiedy nastąpi przepełnienie tych systemów. Czy spodziewasz się, że
taka sytuacja zdarzy się naprawdę?
41. Terminal graficzny ma rozdzielczość 1600×1200 pikseli. W celu przewinięcia okna pro-
cesor (lub kontroler) musi przesunąć wszystkie wiersze tekstu w górę poprzez sko-
piowanie ich bitów z jednej części pamięci RAM wideo do innej. Pewne okno ma wyso-
kość 66 wierszy i szerokość 80 znaków (razem 6400 znaków), a ramka, w której mieści się
znak, ma szerokość 8 pikseli i wysokość 16 pikseli. Ile czasu zajmie przewijanie całego
okna przy szybkości kopiowania 50 ns na bajt? Jaka jest szybkość transmisji terminala,
jeśli wszystkie wiersze mają po 80 znaków? Umieszczenie znaku na ekranie trwa 5 μs.
Ile linii na sekundę można wyświetlić?
42. Po otrzymaniu znaku DEL (SIGINT) sterownik ekranu anuluje wyjście umieszczone w tym
momencie w kolejce dla tego wyświetlacza. Dlaczego?
43. Użytkownik terminala wydaje edytorowi polecenie usunięcia słowa w wierszu 5., zaj-
mującego pozycje znaków od 7. do 12. włącznie. Jaką sekwencję ucieczki ANSI powinien
wysłać edytor, aby usunąć słowo, przy założeniu, że w momencie wydawania polecenia
kursor nie znajduje się w wierszu 5.?
44. Projektanci systemu komputerowego oczekiwali, że mysz będzie mogła przemieszczać
się z maksymalną szybkością 20 cm/s. Jaka jest maksymalna szybkość transmisji danych
przez myszkę, jeśli miki wynosi 0,1 mm, a każdy komunikat myszy ma rozmiar 3 bajtów
(przy założeniu, że każda jednostka miki jest zgłaszana oddzielnie)?
45. Podstawowe składowe kolorów to czerwony, zielony i niebieski, co oznacza, że za pomocą
liniowej superpozycji tych trzech kolorów można stworzyć dowolny kolor. Czy istnieje
możliwość istnienia kolorowej fotografii, której nie da się przedstawić z wykorzysta-
niem pełnej 24-bitowej palety kolorów?
46. Jednym ze sposobów umieszczenia znaku na ekranie graficznym jest wykorzystanie
operacji bitblt dla tablicy czcionek. Załóżmy, że określona czcionka wykorzystuje znaki
o wymiarach 16×24 pikseli na palecie RGB true color.
(a) Ile miejsca w tablicy czcionek zajmuje każdy znak?
(b) Jeśli kopiowanie bajta zajmuje 100 ns włącznie z kosztami dodatkowymi, to jaka jest
wyjściowa szybkość transmisji danych na ekran wyrażona w znakach/s?
47. Ile czasu zajmie całkowite przepisanie odwzorowanego w pamięci ekranu w trybie teksto-
wym, składającego się z 80 znaków na 25 wierszy, przy założeniu, że skopiowanie bajtu
zajmuje 10 ns? A ile czasu będzie trzeba, aby przepisać do pamięci graficzny obraz o roz-
dzielczości 1024×768 pikseli i 24-bitowej palecie kolorów?
48. Na listingu 5.5 jest klasa do wywołania RegisterClass. W przedstawionym tam odpo-
wiedniku tego kodu dla systemu X Window niczego takiego nie ma. Dlaczego?
49. W tekście daliśmy przykład sposobu wykreślenia prostokąta na ekranie z wykorzystaniem
mechanizmu Windows GDI:
Rectangle(hdc, xleft, ytop, xright, ybottom);
Czy istnieje jakakolwiek realna potrzeba występowania pierwszego parametru (hdc), a jeśli
tak, to jaka? Przecież współrzędne prostokąta zostały jawnie podane za pomocą innych
parametrów.
50. Terminal THINC jest wykorzystywany do wyświetlania strony WWW zawierającej ani-
mowaną kreskówkę o rozmiarach 400×160 pikseli działającą z szybkością 10 ramek/s.
Jaki ułamek pasma karty Fast Ethernet o szybkości 100 Mb/s zajmuje wyświetlanie
kreskówki?
51. W testach zaobserwowano, że system THINC działa poprawnie w sieci o przepustowości
1 Mb/s. Czy w systemach wielodostępnych można oczekiwać jakichś problemów? Wska-
zówka: porównaj sytuacje, w których wielu użytkowników ogląda program w telewizji,
z sytuacją, gdy tyle samo użytkowników przegląda stronę WWW.
52. Podaj dwie zalety i dwie wady korzystania z cienkich klientów.
53. Jeśli maksymalne napięcie zasilania procesora V zostanie obniżone do V/n, jego zużycie
energii spadnie do 1/n2 pierwotnej wartości, a szybkość zegara spadnie do 1/n wyjściowej
wartości. Przypuśćmy, że użytkownik wpisuje dane z szybkością 1 znak/s, ale czas pro-
cesora wymagany do przetworzenia każdego znaku wynosi 100 ms. Jaka jest optymalna
wartość n i ile wynoszą w procentach oszczędności energii, w porównaniu z sytuacją
obniżenia napięcia? Załóżmy, że bezczynny procesor CPU w ogóle nie zużywa energii.
54. Notebooka skonfigurowano w taki sposób, by w maksymalnym stopniu oszczędzał
energię. Włączono m.in. takie funkcje jak wyłączenie wyświetlacza i dysku twardego po
pewnym okresie braku aktywności. Użytkownik czasami uruchamia programy systemu
UNIX w trybie tekstowym, a innym razem wykorzystuje środowisko X Window. Jest
zdziwiony, że bateria wytrzymuje znacznie dłużej, jeśli korzysta z programów działających
wyłącznie w trybie tekstowym. Dlaczego tak się dzieje?
55. Napisz program, który symuluje stabilną pamięć masową. W celu zasymulowania dwóch
dysków wykorzystaj dwa duże pliki o stałym rozmiarze.
56. Napisz program, który implementuje trzy algorytmy zarządzania ramieniem dysku. Napisz
program sterownika, który losowo generuje sekwencję numerów cylindrów (0 – 999),
uruchamia trzy algorytmy dla tej sekwencji, a następnie wyświetla całkowitą odległość
(liczbę cylindrów), jaką musi przebyć ramię dysku przy zastosowaniu każdego z tych
algorytmów.
57. Napisz program, który implementuje wiele liczników czasu przy wykorzystaniu jednego
układu zegara. Dane wejściowe dla tego programu składają się z sekwencji czterech typów
poleceń (S <int>, T, E <int>, P): S <int> ustawia bieżący czas na <int>; T określa takt
zegara; E <int> planuje wystąpienie sygnału w czasie <int>; P drukuje wartości Bieżący
czas, Następny sygnał i Początek listy liczników. Program powinien także drukować instruk-
cję za każdym razem, kiedy nadchodzi czas wygenerowania sygnału.
Systemy komputerowe są pełne zasobów, które mogą być używane tylko przez jeden proces
na raz. Do znanych przykładów należą drukarki, napędy taśmowe do tworzenia kopii zapasowych
danych oraz gniazda w wewnętrznych tablicach systemowych. Gdyby dwa procesy zaczęły
jednocześnie zapisywać dane na drukarkę, powstały wydruk byłby niezrozumiały. Z kolei gdyby
dwa procesy chciały niezależnie od siebie skorzystać z tej samej pozycji w tablicy systemu plików,
z pewnością doszłoby do uszkodzenia systemu plików. W związku z tym wszystkie systemy
operacyjne mają zdolność (czasowego) przydzielania procesowi dostępu do wskazanych zasobów
na wyłączność.
W wielu aplikacjach proces wymaga dostępu na wyłączność nie do jednego zasobu, ale do
kilku. Załóżmy, że dwa procesy chcą nagrać zeskanowany dokument na dysku Blu-ray. Proces
A żąda zezwolenia na używanie skanera i uzyskuje je. Proces B jest zaprogramowany inaczej.
Najpierw żąda dostępu do nagrywarki Blu-ray i także go otrzymuje. Teraz proces A chce uzyskać
dostęp do nagrywarki Blu-ray, ale żądanie jest odrzucane do czasu, aż proces B zwolni nagry-
warkę. Niestety, zamiast zwolnić nagrywarkę Blu-ray, proces B żąda dostępu do skanera. W tym
momencie oba procesy są zablokowane i pozostaną w tym stanie na zawsze. Sytuacja ta nazywa
się zakleszczeniem (ang. deadlock).
Zakleszczenia mogą się również zdarzyć pomiędzy komputerami; np. w wielu firmach
działają lokalne sieci komputerowe, do których jest podłączonych wiele komputerów. Bardzo
często takie urządzenia jak skanery, nagrywarki Blu-ray lub DVD, drukarki i napędy taśm są
podłączone do sieci jako współdzielone zasoby, dostępne dla każdego użytkownika na dowolnej
maszynie. Jeśli urządzenia te można rezerwować zdalnie (tzn. ze zdalnego komputera użytkow-
nika), to może wystąpić taki sam rodzaj zakleszczenia jak ten, który opisaliśmy powyżej. Bardziej
skomplikowane sytuacje mogą spowodować zakleszczenia obejmujące trzy, cztery urządzenia
lub więcej i tyluż użytkowników.
Zakleszczenia mogą również wystąpić w różnych innych sytuacjach. Przykładowo w systemie
bazy danych program może zablokować kilka rekordów, których używa, aby uniknąć wyścigu.
443
Jeśli proces A zablokuje rekord R1, proces B zablokuje rekord R2, a następnie każdy z procesów
spróbuje zablokować rekord innego procesu, także będziemy mieli do czynienia z zakleszcze-
niem. Zakleszczenia mogą występować w odniesieniu do zasobów zarówno sprzętowych, jak
i programowych.
W niniejszym rozdziale przyjrzymy się kilku typom zakleszczeń. Powiemy, w jaki sposób
powstają, i przeanalizujemy kilka sposobów ich eliminowania. Chociaż niniejszy materiał dotyczy
zakleszczeń w kontekście systemów operacyjnych, mogą one występować także w systemach
baz danych oraz wielu innych kontekstach w informatyce. W związku z tym materiał przedsta-
wiony w niniejszym rozdziale ma zastosowanie do szerokiego spektrum systemów współbieżnych.
Na temat zakleszczeń napisano wiele artykułów i książek. Dwa artykuły na ten temat poja-
wiły się w magazynie „Operating Systems Review”. Warto do nich zajrzeć w celu szerszego
zapoznania się z materiałami [Newton, 1979], [Zobel, 1983]. Chociaż są to stare materiały, więk-
szość prac dotyczących zakleszczeń przeprowadzono grubo przed 1980 rokiem, zatem w dalszym
ciągu są one aktualne.
6.1. ZASOBY
6.1.
ZASOBY
Główna klasa zakleszczeń dotyczy zasobów, do których określonym procesom przyznano wyłączny
dostęp. Do tych zasobów należą urządzenia, rekordy danych, pliki itp. Aby dyskusja na temat
zakleszczeń była możliwie jak najbardziej ogólna, przydzielone obiekty będziemy nazywać zaso-
bami. Zasobem może być urządzenie sprzętowe (np. napęd Blu-ray) lub pewien blok informacji
(np. zablokowany rekord w bazie danych). Komputer zwykle ma wiele zasobów, do których
proces może uzyskać dostęp. W przypadku niektórych zasobów mogą być dostępne trzy identyczne
egzemplarze — np. trzy napędy Blu-ray. Kiedy dostępnych jest kilka kopii zasobów, można
użyć dowolnej z nich, w celu spełnienia żądania o ten zasób. Krótko mówiąc, zasobem nazywamy
wszystko to, co trzeba uzyskać, wykorzystać i zwolnić.
Listing 6.1. Korzystanie z semaforów w celu zabezpieczenia zasobów: (a) jeden zasób;
(b) dwa zasoby
(a) (b)
typedef int semaphore; typedef int semaphore;
semaphore resource_1; semaphore resource_1;
semaphore resource_2;
Rozważmy teraz sytuację z dwoma procesami, A i B, oraz dwoma zasobami. Dwa możliwe
scenariusze pokazano na listingu 6.2(a). W tym przypadku oba procesy żądają zasobu w tej samej
kolejności. Na listingu 6.2(b) żądają zasobu w innej kolejności. Może się wydawać, że różnica
jest niewielka, ale tak nie jest.
Listing 6.2. (a) Kod wolny od zakleszczeń; (b) kod z potencjalnym zakleszczeniem
(a) (b)
typedef int semaphore;
semaphore resource_1; semaphore resource_1;
semaphore resource_2; semaphore resource_2;
W sytuacji z listingu 6.2(a) jeden z procesów zdobędzie pierwszy zasób przed drugim. Następ-
nie proces ten pomyślnie zdobędzie drugi z zasobów i wykona swoją pracę. Jeśli drugi proces
spróbuje uzyskać zasób 1, zanim zostanie on zwolniony, drugi proces się zablokuje do czasu, aż
zasób ten stanie się dostępny.
Na listingu 6.2(b) sytuacja jest inna. Może się zdarzyć, że jeden z procesów zdobędzie oba
zasoby i zablokuje inny proces do czasu zakończenia ich wykorzystywania. Może się jednak
zdarzyć i taka sytuacja, że proces A uzyska zasób 1, a proces B uzyska zasób 2. Każdy z nich
zablokuje się przy próbie zdobycia drugiego z zasobów. Żaden z procesów nie będzie mógł
wznowić działania. Zła wiadomość: taka sytuacja to zakleszczenie.
Na podstawie tego przykładu można się przekonać, jak niewielka różnica w stylu kodowania
(czyli to, który zasób zostanie zdobyty jako pierwszy) przekłada się na to, że raz program działa,
a drugi raz nie działa z przyczyn trudnych do wykrycia. Ze względu na to, że do zakleszczeń
może dojść tak łatwo, prowadzonych jest wiele badań dotyczących sposobów postępowania z nimi.
W niniejszym rozdziale szczegółowo omówiono zakleszczenia i pokazano, w jaki sposób można
sobie z nimi radzić.
Warto zwrócić uwagę, że każdy z warunków wiąże się ze strategią, którą system może reali-
zować lub nie. Czy określony zasób może być przypisany do więcej niż jednego procesu na raz?
Czy proces posiadający zasób może żądać następnego? Czy zasoby mogą być wywłaszczane?
Czy mogą występować sytuacje cyklicznego oczekiwania? W dalszej części niniejszego roz-
działu pokażemy sposoby walki z zakleszczeniami polegające na próbie usunięcia jednego
z powyższych warunków.
Rysunek 6.1. Grafy alokacji zasobów. (a) posiadający zasób; (b) żądający zasobu; (c) zakleszczenie
Skierowany łuk prowadzący od procesu do zasobu oznacza, że proces jest obecnie zablo-
kowany w oczekiwaniu na ten zasób. Na rysunku 6.1(b) proces B oczekuje na zasób S. Na
rysunku 6.1(c) mamy do czynienia z zakleszczeniem: proces C czeka na zasób T, który obecnie
jest w posiadaniu procesu D. Proces D nie ma zamiaru zwolnienia zasobu T, ponieważ czeka
na zasób U będący w posiadaniu procesu C. Oba procesy będą czekać w nieskończoność. Cykl
w grafie oznacza, że istnieje zakleszczenie obejmujące procesy i zasoby w cyklu (przy założeniu,
że jest jeden zasób każdego rodzaju). W pokazanym przykładzie występuje cykl C−T−D−U−C.
Przyjrzyjmy się teraz przykładowi użycia grafów zasobów. Wyobraźmy sobie, że mamy
trzy procesy: A, B i C oraz trzy zasoby R, S i T. Żądania i zwolnienia trzech procesów pokazano
na rysunkach 6.2(a) – (c). System operacyjny może swobodnie uruchamiać dowolny niezablo-
kowany proces w każdym momencie. Mógłby zatem zdecydować się na uruchomienie procesu
A do chwili, aż proces A zakończy pracę, następnie uruchomić proces B aż do zakończenia i na
koniec uruchomić proces C.
Taka kolejność nie prowadzi do żadnych zakleszczeń (ponieważ nie ma rywalizacji o zasoby),
ale w tym przypadku nie ma również współbieżności. Oprócz żądania i zwalniania zasobów,
procesy wykonują obliczenia i realizują operacje wejścia-wyjścia. Kiedy procesy działają sekwen-
cyjnie, nie ma możliwości, aby w czasie gdy jeden oczekuje na zakończenie operacji wejścia-wyjścia,
drugi korzystał z procesora. W związku z tym uruchamianie procesów ściśle sekwencyjnie nie
jest optymalne. Z drugiej strony, jeśli żaden z procesów nie wykonuje operacji wejścia-wyjścia,
algorytm szeregowania zadań „najpierw najkrótsze zadanie” jest lepszy od cyklicznego. Dlatego
w pewnych okolicznościach sekwencyjne działanie wszystkich procesów może być najlepsze.
Rysunek 6.2. Przykład tego, jak dochodzi do zakleszczenia i jak można go uniknąć
Jak już jednak wspomnieliśmy, system operacyjny nie musi uruchamiać procesów w żadnym
specjalnej kolejności. Szczególnie jeśli obsługa określonego żądania może doprowadzić do zaklesz-
czenia, system operacyjny może zawiesić proces (po prostu nie zaplanuje wykonania procesu)
do czasu, aż jego wykonanie będzie bezpieczne. Gdyby w sytuacji z rysunku 6.2 system wiedział
o grożącym zakleszczeniu, to mógłby zawiesić proces B, zamiast przydzielić mu zasób S. Poprzez
uruchomienie samych tylko procesów A i C żądania i zwolnienia miałyby taką postać, jak pokazano
na rysunku 6.2(k), a nie taką, jak na rysunku 6.2(d). Ta sekwencja prowadzi do wykresów korzy-
stania z zasobów z rysunków 6.2(l) – (q), gdzie nie ma zakleszczeń.
Po wykonaniu kroku (q) proces B może uzyskać zasób S, ponieważ proces A zakończył
działanie, a proces C ma wszystko, czego potrzebuje. Nawet jeśli proces B w końcu wstrzyma
działanie i zażąda zasobu T, nie wystąpi zakleszczenie. Proces B po prostu zaczeka, aż proces C
zakończy działanie.
W dalszej części niniejszego rozdziału przestudiujemy szczegółowy algorytm podejmowania
decyzji w zakresie przydziału i zwalniania zasobów w taki sposób, aby nie doszło do powstania
zakleszczeń. Na razie należy zapamiętać, że grafy zasobów to narzędzie, które pozwala zobaczyć,
czy określona sekwencja przydziału/zwalniania zasobów prowadzi do zakleszczenia, czy nie.
Żądania i zwalnianie zasobów będziemy przeprowadzać krok po kroku. Po każdym kroku
sprawdzimy wykres, aby zobaczyć, czy zawiera jakieś cykle. Jeżeli graf zawiera cykl, oznacza
to, że doszło do zakleszczenia, jeśli nie, to zakleszczenia nie ma. Chociaż w naszej analizie grafów
zasobów przyjęliśmy, że istnieje pojedynczy zasób każdego typu, wykresy zasobów mogą być
również uogólniane, tak by za ich pomocą można było obsłużyć wiele zasobów tego samego typu
[Holt, 1972].
Ogólnie rzecz biorąc, przy postępowaniu z zakleszczeniami wykorzystuje się cztery strategie.
1. Ignorowanie problemu. Jeśli my go zignorujemy, być może on zignoruje nas.
2. Wykrywanie i podejmowanie czynności zaradczych. Pozwalamy, by doszło do zakleszczenia,
po czym je wykrywamy i podejmujemy odpowiednie działania.
3. Dynamiczne unikanie zakleszczeń poprzez ostrożną alokację zasobów.
4. Prewencja, poprzez strukturalną negację jednego z czterech wymaganych warunków.
Każdą z tych metod omówimy po kolei, w następnych czterech podrozdziałach.
Najprostsze podejście to algorytm strusia (ang. ostrich algorithm). Wkładamy głowę w piasek
i udajemy, że problemu nie ma1. Różne osoby reagują na tę strategię w różny sposób. Matema-
tycy uważają, że jest całkowicie nie do przyjęcia i mówią, że zakleszczeniom należy przeciw-
działać wszelkimi siłami. Inżynierowie pytają, jak często można się spodziewać problemu, jak
często system ulega awarii z innych powodów oraz jak poważne jest zakleszczenie. Jeśli zaklesz-
czenia występują przeciętnie co pięć lat, a system ulega awarii z powodu błędów sprzętowych,
błędów kompilatora lub błędów systemu operacyjnego średnio raz na tydzień, to większość inży-
nierów nie będzie uważało za stosowne, by poświęcać wydajność lub wygodę po to, by wyeli-
minować zakleszczenia.
Aby bardziej podkreślić ten kontrast, rozważmy sytuację systemu operacyjnego, który blo-
kuje wywołującego w przypadku, gdy nie można wykonać wywołania systemowego open dla
urządzenia sprzętowego, np. sterownika Blu-ray lub drukarki, dlatego, że urządzenie jest zajęte.
Zazwyczaj to sterownik urządzenia decyduje o tym, jakie działania należy podjąć w tych oko-
licznościach. Dwie oczywiste możliwości to zablokowanie lub zwrócenie kodu o błędzie. Jeśli
1
W rzeczywistości takie postrzeganie strusi jest nonsensem. Strusie potrafią biegać z szybkością 60 km/h,
a ich dziób jest na tyle silny, że jest w stanie zabić każdego lwa, który wykazuje chęć zjedzenia na obiad tego
„dużego kurczaka”
jeden proces pomyślnie otworzy napęd Blu-ray, drugi pomyślnie otworzy drukarkę, a następnie
każdy z procesów spróbuje otworzyć drugie z urządzeń i zablokuje się przy tej próbie, będziemy
mieli do czynienia z zakleszczeniem. Większość współczesnych systemów nie wykryje takiej
sytuacji.
Rysunek 6.3. (a) Graf zasobów; (b) cykl wyodrębniony z części (a)
Chociaż zauważenie cyklu w prostym grafie na podstawie analizy wzrokowej jest stosun-
kowo proste, wykrywanie zakleszczonych procesów w rzeczywistych systemach wymaga zasto-
sowania formalnego algorytmu. Znanych jest wiele algorytmów wykrywania cykli w grafach
skierowanych. Poniżej prezentujemy prosty algorytm, który analizuje graf i kończy działanie
w przypadku, gdy znajdzie cykl albo kiedy uzyska informację, że cykl nie istnieje. Algorytm
wykorzystuje jedną dynamiczną strukturę danych — L — listę węzłów razem z listą łuków.
W czasie wykonywania algorytmu można zaznaczyć łuki, aby wskazać, że już je zbadano. Dzięki
temu można zapobiec powtórnemu badaniu.
Działanie algorytmu można opisać za pomocą następujących kroków:
1. Dla każdego węzła N w grafie wykonaj poniższe pięć kroków, rozpocznij od węzła N.
2. Stwórz pustą listę L, a wszystkie łuki określ jako niezaznaczone.
3. Dodaj bieżący węzeł na koniec listy L i sprawdź, czy węzeł występuje teraz na liście L dwa
razy. Jeśli tak, to graf zawiera cykl (w postaci listy L). Działanie algorytmu się kończy.
4. Sprawdź, czy z tego węzła wychodzą dowolne niezaznaczone łuki. Jeśli tak, to przejdź do
kroku 5., jeśli nie, przejdź do kroku 6.
5. Losowo wybierz niezaznaczony wychodzący łuk i go zaznacz. Następnie przejdź wzdłuż
niego do następnego węzła i skocz do kroku 3.
6. Jeśli jest to węzeł początkowy, graf nie zawiera żadnych cykli i algorytm kończy działanie.
W innym przypadku osiągnęliśmy martwy koniec. Usuń węzeł z listy i przejdź do poprzed-
niego węzła, czyli tego, który był bieżący przed aktualnie analizowanym węzłem. Oznacz
go jako bieżący i przejdź do kroku 3.
Działanie powyższego algorytmu polega na analizowaniu kolejnych węzłów w roli korzenia
struktury, która zgodnie z oczekiwaniami jest drzewem, i wykonywaniu na niej wyszukiwania
w głąb. Jeśli podczas tego wyszukiwania kiedykolwiek wrócimy do węzła, który już był na liście,
mamy do czynienia z cyklem. Jeśli zostaną poddane analizie wszystkie gałęzie prowadzące z wybra-
nego węzła, następuje powrót do poprzedniego węzła. Jeśli w ten sposób zostanie osiągnięty
korzeń i nie będzie można przejść dalej, będzie to oznaczało, że podgraf osiągalny z bieżącego
węzła nie zawiera żadnych cykli. Jeśli taką samą właściwość mają wszystkie węzły, w całym grafie
nie ma cykli, zatem system nie jest zakleszczony.
Aby zobaczyć, jak ten algorytm działa w praktyce, spróbujmy go zastosować dla grafu z ry-
sunku 6.3(a). Kolejność przetwarzania węzłów jest dowolna, zatem spróbujmy je analizować od
C
i 1
ij Aj E j
Inaczej mówiąc, jeśli dodamy wszystkie egzemplarze zasobu j, które zostały przydzielone,
i do tego dodamy wszystkie egzemplarze, które są dostępne, w efekcie otrzymamy liczbę istnie-
jących egzemplarzy tej klasy zasobów.
Rysunek 6.4. Cztery struktury danych wymagane przez algorytm wykrywania zakleszczeń
napędu Blu-ray. Drugiego także nie można spełnić, ponieważ nie ma dostępnego skanera. Na
szczęście trzecie żądanie można spełnić. Dzięki temu proces 3. działa i zwraca wszystkie swoje
zasoby. W efekcie otrzymujemy:
A = (2 2 2 0)
W tym momencie proces 2. może zacząć działać i także zwraca swoje zasoby. Mamy zatem:
A = (4 2 2 1)
Teraz mogą działać pozostałe procesy. W systemie nie ma zakleszczeń.
Wprowadźmy teraz pewną zmianę do sytuacji z rysunku 6.5. Przypuśćmy, że proces 2. po-
trzebuje napędu Blu-ray, a także dwóch napędów taśm i plotera. Żadnego z tych żądań nie
można spełnić, zatem cały system jest zakleszczony. Nawet gdybyśmy przydzielili procesowi 3.
dwa napędy taśm i ploter, doszłoby do zakleszczenia w momencie zażądania napędu Blu-ray.
Teraz, kiedy wiemy, w jaki sposób wykrywa się zakleszczenia (przynajmniej w przypadku
statycznych żądań zasobów, które są znane zawczasu), powstaje pytanie o to, kiedy należy ich
szukać. Jedna z możliwości to sprawdzanie za każdym razem, kiedy są wykonywane żądania
zasobów. W ten sposób zyskujemy pewność, że zostaną wykryte tak szybko, jak to możliwe, ale
jest potencjalnie kosztowne, jeśli chodzi o czas procesora. Alternatywną strategią jest spraw-
dzanie co k minut lub np. wtedy, kiedy współczynnik wykorzystania procesora spadnie poniżej
pewnej wartości. Stopień wykorzystania procesora warto brać pod uwagę dlatego, że w przypadku
gdy jest zakleszczonych dużo procesów, pozostaje niewiele procesów możliwych do urucho-
mienia. W związku z tym procesor często będzie bezczynny.
Aby np. zabrać drukarkę laserową jej właścicielowi, operator powinien zabrać wszystkie już
wydrukowane arkusze i odłożyć na bok. Można wtedy zawiesić proces (oznaczyć, że jest nie-
gotowy do działania). W tym momencie można przydzielić drukarkę innemu procesowi. Kiedy ten
proces zakończy działanie, stos wydrukowanych kartek można z powrotem umieścić na „wyj-
ściowej tacy” drukarki, po czym wystarczy wznowić przerwany proces.
Możliwość zabrania zasobu procesowi, przydzielenia go innemu procesowi, a następnie
zwrócenia w taki sposób, aby proces nawet tego nie zauważył, w dużym stopniu zależy od cha-
rakteru zasobu. Takie usuwanie zakleszczeń często jest utrudnione, a niekiedy wręcz niemożliwe.
Wybór procesu do zawieszenia w dużym stopniu zależy od tego, które procesy są w posiadaniu
zasobów łatwych do zabrania.
sprowadza się do czytania pliku źródłowego i tworzenia pliku obiektowego. Jeśli proces kom-
pilacji zostanie zabity przed zakończeniem, pierwsze uruchomienie kompilacji nie ma wpływu
na drugie.
Z kolei proces, który aktualizuje bazę danych, nie zawsze można bezpiecznie uruchomić po raz
drugi. Jeśli proces doda 1 do pewnego pola w tabeli bazy danych, uruchomienie go raz, zabicie,
a następnie uruchomienie jeszcze raz spowoduje dodanie wartości 2 do pola, co jest nieprawidłowe.
Podczas omawiania wykrywania zakleszczeń niejawnie przyjęliśmy, że kiedy proces żąda zasobów,
żąda ich wszystkich na raz (macierz R z rysunku 6.4). Jednak w większości systemów procesy
żądają zasobów pojedynczo. System musi mieć możliwość decydowania o tym, czy przydzielenie
zasobu jest bezpieczne, czy nie, i przydzielać go tylko wtedy, kiedy to jest bezpieczne. W związku
z tym powstaje pytanie: czy istnieje algorytm, który pozwala na stuprocentowe unikanie zaklesz-
czeń dzięki podejmowaniu właściwych decyzji za każdym razem? Odpowiedź brzmi: tak, ale
pod pewnym warunkiem — można uniknąć zakleszczeń, ale tylko wtedy, gdy określone informacje
są dostępne z góry. W poniższych punktach omówimy sposoby unikania zakleszczeń dzięki
uważnemu przydziałowi zasobów.
Każdy punkt na diagramie reprezentuje połączony stan dwóch procesów. W stanie począt-
kowym, w chwili p, żaden z procesów nie wykonał żadnej instrukcji. Jeśli program szeregujący
zdecyduje, aby najpierw zaczął działać proces A, przechodzimy do punktu q, w którym proces
A wykonał kilka instrukcji, ale proces B nie wykonał jeszcze żadnej. W punkcie q trajektoria
staje się pionowa. Oznacza to, że program szeregujący zlecił działanie procesowi B. W przypadku
pojedynczego procesora wszystkie ścieżki muszą być poziome lub pionowe. Nigdy nie powinny
być ukośne. Co więcej, ruch zawsze odbywa się z północy lub wschodu, nigdy z południa lub
zachodu (ponieważ, co oczywiste, procesy nie mogą cofać się w czasie).
Kiedy proces A przekroczy linię I1 ścieżki od r do s, żąda drukarki i ją otrzymuje. Kiedy
proces B osiągnie punkt t, żąda plotera.
Na szczególną uwagę zasługują regiony, które są zacieniowane. Obszar oznaczony liniami
ukośnymi od południowego wschodu do północnego wschodu reprezentuje stan, w którym oba
procesy są w posiadaniu drukarki. Ze względu na regułę wzajemnego wykluczania do tego regionu
nie można wejść. Na podobnej zasadzie region oznaczony liniami ukośnymi w przeciwnym kierunku
reprezentuje stan, w którym oba procesy mają ploter. Podobnie jak poprzedni jest on niemożliwy
do osiągnięcia.
Jeśli system kiedykolwiek znajdzie się w ramce wyznaczonej przez chwile I1 i I2 z lewej
i prawej strony oraz I5 i I6 z góry i z dołu, dojdzie do zakleszczenia w chwili przecięcia momentów
I2 i I6. W tym stanie proces A żąda plotera, natomiast proces B żąda drukarki. Oba te zasoby są
już przydzielone. Cały obszar jest niebezpieczny i nie wolno do niego wchodzić. W chwili t jedyną
bezpieczną operacją jest uruchomienie procesu A do chwili osiągnięcia punktu I4. Poza tym dozwo-
lona jest dowolna trajektoria prowadząca do punktu u.
Warto zauważyć, że w punkcie t proces B żąda zasobu. System musi zadecydować, czy
przydzielić ten zasób, czy nie. Jeśli zasób zostanie przydzielony, system wejdzie do niebezpiecz-
nego obszaru, co w efekcie doprowadzi do zakleszczenia. Aby uniknąć zakleszczenia, należy
zawiesić proces B do chwili, kiedy proces A najpierw zażąda plotera, a następnie go zwolni.
z rysunku 6.7(e). Teraz proces A może uzyskać sześć egzemplarzy zasobu, którego potrzebuje,
i także zakończyć działanie. Tak więc stan z rysunku 6.7(a) jest bezpieczny, ponieważ system,
dzięki uważnemu szeregowaniu, może uniknąć zakleszczeń.
Przypuśćmy teraz, że mamy stan początkowy z rysunku 6.8(a), ale tym razem proces A żąda
i uzyskuje inny zasób. W rezultacie otrzymujemy sytuację z rysunku 6.8(b). Czy można znaleźć
sekwencję, która na pewno zadziała? Spróbujmy. Program szeregujący mógłby uruchomić proces
B do momentu zażądania od niego wszystkich zasobów, tak jak pokazano na rysunku 6.8(c).
W końcu proces B kończy działanie i uzyskujemy stan pokazany na rysunku 6.8(d). W tym
momencie dochodzi do zakleszczenia. Mamy tylko cztery dostępne egzemplarze zasobu, a każdy
z aktywnych procesów potrzebuje pięciu. Nie istnieje sekwencja, która gwarantowałaby zakoń-
czenie działania procesów. W związku z tym decyzja przydziału, która przeniosła system
z rysunku 6.8(a) do rysunku 6.8(b), spowodowała przejście ze stanu bezpiecznego do stanu nie-
bezpiecznego. Uruchomienie w następnej kolejności procesu A lub C, począwszy od rysunku 6.8(b),
również nie działa. Można zatem wyciągnąć wniosek, że żądanie procesu A nie powinno było być
spełnione.
Warto zwrócić uwagę na to, że stan niebezpieczny nie jest stanem zakleszczenia. Jeśli wyj-
dziemy od rysunku 6.8(b), system przez jakiś czas będzie działał. W rzeczywistości jeden proces
może nawet się zakończyć. Co więcej, istnieje możliwość, że proces A zwolni zasób, zanim poprosi
o dodatkowe. W związku z tym proces C będzie mógł się zakończyć i nie dojdzie do zakleszczenia.
A zatem różnica pomiędzy stanem bezpiecznym a stanem niebezpieczeństwa jest taka, że wycho-
dząc od stanu bezpiecznego, system może zagwarantować, że wszystkie procesy się zakończą.
W przypadku wyjścia od stanu niebezpieczeństwa takiej gwarancji nie można udzielić.
nie pożyczały pieniędzy, jeśli nie nabrały pewności, że pożyczki mogą być spłacane). Algorytm
sprawdza, czy spełnienie żądania prowadzi do stanu niebezpieczeństwa. Jeśli tak, to żądanie jest
odrzucane. Jeśli spełnienie żądania prowadzi do stanu bezpiecznego, jest realizowane. W sytuacji
z rysunku 6.9(a) widzimy czterech klientów: A, B, C i D. Każdemu z nich przydzielono określoną
liczbę jednostek kredytowych (1 jednostka może odpowiadać np. 1000 zł). Bankier wie, że nie
wszyscy klienci będą natychmiast potrzebowali swojego maksymalnego kredytu, dlatego do ich
obsługi zarezerwował 10 jednostek, a nie 22 (zgodnie z tą analogią klienci są procesami, jed-
nostki są np. napędami taśm, natomiast bankier to system operacyjny).
Rysunek 6.9. Trzy stany przydziału zasobów: (a) bezpieczny; (b) bezpieczny; (c) niebezpieczny
Klienci realizują swoje sprawy i od czasu do czasu składają żądania pożyczek (tzn. żądają
zasobów). W pewnym momencie sytuacja przypomina stan z rysunku 6.9(b). Jest to stan bez-
pieczny, ponieważ przy dwóch jednostkach, jakie pozostały, bankier może opóźnić wszystkie
żądania poza żądaniem klienta C. W związku z tym klient C może zakończyć działanie i zwolnić
wszystkie cztery przydzielone mu zasoby. Mając do dyspozycji cztery jednostki, bankier może
przydzielić potrzebne jednostki klientowi D albo klientowi B itd.
Zastanówmy się, co by się stało, gdyby w sytuacji z rysunku 6.9(b) zostało spełnione żądanie
klienta B o jedną dodatkową jednostkę. Mielibyśmy wtedy do czynienia z sytuacją z rysunku 6.9(c),
która jest stanem niebezpieczeństwa. Gdyby wszyscy klienci nagle poprosili o pożyczki w maksy-
malnej wysokości, bankier nie mógłby spełnić żadnej z nich i mielibyśmy zakleszczenie. Stan
niebezpieczny nie musi prowadzić do zakleszczenia, ponieważ klienci niekoniecznie będą potrze-
bowali wszystkich środków dostępnych na swoich liniach kredytowych. Bankier nie może jednak
liczyć na to, że klienci zachowają umiar.
Algorytm bankiera analizuje każde żądanie natychmiast po jego pojawieniu się i sprawdza,
czy spełnienie go prowadzi do stanu bezpiecznego. Jeśli tak, żądanie jest spełniane, w przeciwnym
wypadku jest opóźniane. Aby zobaczyć, czy stan jest bezpieczny, bankier sprawdza, czy ma wystar-
czającą ilość zasobów do spełnienia żądania klienta. Jeśli tak, zakłada, że te pożyczki są spłacone.
Wtedy sprawdza klienta bliższego osiągnięcia swojego limitu itd. Jeżeli wszystkie pożyczki mogą
ostatecznie być spłacone, stan jest bezpieczny i można spełnić pierwotne żądanie.
jak w przypadku algorytmu z pojedynczym zasobem, procesy muszą sformułować swoje cał-
kowite wymagania w zakresie zasobów już przed uruchomieniem. Dzięki temu system będzie
mógł obliczyć macierz z prawej strony w dowolnym momencie.
Trzy wektory z prawej strony rysunku pokazują odpowiednio istniejące zasoby — E, zasoby
przydzielone — P oraz zasoby dostępne — A. Z wektora E można odczytać, że system jest
wyposażony w sześć napędów taśm, cztery drukarki i dwa napędy Blu-ray. Spośród nich w tym
momencie przydzielonych jest pięć napędów taśm, trzy plotery, dwie drukarki i dwa napędy
Blu-ray. Można to ustalić poprzez dodanie czterech kolumn zasobów w macierzy po lewej stronie.
Wektor dostępnych zasobów pokazuje różnicę pomiędzy tym, co system posiada, a tym, co jest
w użyciu w danym momencie.
Można teraz sformułować algorytm sprawdzający, czy stan jest bezpieczny.
1. Weźmy pod uwagę wiersz R, którego wszystkie niespełnione potrzeby zasobów są mniej-
sze lub równe wartościom wiersza A. Jeśli nie ma takiego wiersza, w systemie w końcu
dojdzie do zakleszczenia, ponieważ żaden proces nie może działać aż do zakończenia
(przy założeniu, że procesy zatrzymują wszystkie zasoby, aż do zakończenia swojego
działania).
2. Przypuśćmy, że proces odpowiadający wybranemu wierszowi żąda wszystkich zasobów,
których potrzebuje (taką możliwość system gwarantuje), i kończy działanie. Oznaczamy
ten proces jako zakończony i dodajemy wszystkie jego zasoby do wektora A.
3. Powtarzamy kroki 1. i 2. tak długo, aż wszystkie procesy będą oznaczone jako zakończone
(w tym przypadku stan wyjściowy był bezpieczny) lub nie pozostanie żaden proces, któ-
rego żądania zasobów trzeba spełnić (w takim przypadku system nie był bezpieczny).
Jeśli w kroku 1. można wybrać kilka procesów, nie ma znaczenia, który zostanie wybrany: pula
dostępnych zasobów albo się powiększy, albo w najgorszym przypadku pozostanie niezmieniona.
Powróćmy teraz do rysunku 6.10. Bieżący stan jest bezpieczny. Przypuśćmy, że teraz proces
B realizuje żądanie drukarki. Żądanie to można spełnić, ponieważ wynikowy stan w dalszym
ciągu będzie bezpieczny (proces D może się zakończyć, a po nim proces A, proces E i reszta).
Wyobraźmy sobie teraz, że po przydzieleniu procesowi B jednej z dwóch pozostałych dru-
karek proces E żąda ostatniej drukarki. Spełnienie tego żądania doprowadziłoby do zmniejszenia
wektora dostępnych zasobów do wartości (1 0 0 0), a to doprowadziłoby do zakleszczenia, zatem
żądanie procesu E trzeba odroczyć na pewien czas.
Algorytm bankiera został po raz pierwszy opublikowany przez Dijkstrę w 1965 roku. Od
tego czasu opisywano go szczegółowo niemal w każdej książce dotyczącej systemów operacyjnych.
Napisano niezliczone artykuły na temat różnych jego aspektów. Niestety, niewielu autorów
ośmieliło się wytknąć twórcy algorytmu, że choć teoretycznie algorytm jest wspaniały, w praktyce
jest właściwie bezużyteczny, ponieważ rzadko się zdarza, aby procesy wiedziały z góry, jakie
będą ich maksymalne potrzeby w zakresie zasobów. Poza tym liczba procesów nie jest stała,
ale zmienia się dynamicznie, w miarę jak nowi użytkownicy logują się i wylogowują. Co więcej,
zasoby, które uważano za dostępne, mogą nagle zniknąć (napędy taśm mogą ulec uszkodzeniu).
W związku z tym w praktyce istnieje bardzo niewiele systemów (jeśli w ogóle takie istnieją),
które stosowałyby algorytm bankiera w celu unikania zakleszczeń. Jednak w niektórych sys-
temach do unikania zakleszczeń stosowane są algorytmy heurystyczne, podobne do algorytmu
bankiera. Przykładowo w sieciach mogą być stosowane ograniczenia komunikacji w przypadku,
gdy bufor wykorzystania osiągnie więcej niż, powiedzmy, 70% (przy założeniu, że pozostałe 30%
wystarczy bieżącym użytkownikom do realizacji usług i zwrócenia zasobów).
Przekonaliśmy się, że unikanie zakleszczeń jest w gruncie rzeczy niemożliwe. Wymaga ono
bowiem informacji na temat przyszłych żądań, a te są nieznane. Jak w związku z tym unika się
zakleszczeń w rzeczywistych systemach? Odpowiedź wymaga powrotu do czterech warunków
sformułowanych w pracy [Coffman et al., 1971]. Gdyby można było zapewnić, że co najmniej
jeden z wymienionych warunków nigdy nie zostanie spełniony, to zakleszczenia byłyby niemoż-
liwe [Havender, 1968].
Rysunek 6.11. (a) Zasoby uporządkowane według numerów; (b) graf zasobów
Przy zastosowaniu takiej reguły graf alokacji zasobów nigdy nie może mieć cykli. Przekonajmy
się, dlaczego to jest prawda w przypadku dwóch procesów z rysunku 6.11(b). Do zakleszczenia
może dojść tylko wtedy, gdy proces A zażąda zasobu j, a proces B zażąda zasobu i. Jeżeli założyć,
że i i j są odrębnymi zasobami, będą one miały różne numery. Jeśli i > j, to A nie będzie mógł
zażądać zasobu j, ponieważ liczba ta jest mniejsza od numeru zasobu, który już posiada proces
A. Jeśli i < j, to proces B nie będzie mógł zażądać zasobu i, ponieważ liczba ta jest mniejsza od
numeru zasobu, który już posiada proces B. W każdym przypadku zakleszczenie nie będzie
możliwe.
Ta sama logika obowiązuje dla więcej niż dwóch procesów. W każdym momencie jeden
z przypisanych zasobów będzie miał najwyższy numer. Proces będący w posiadaniu tego zasobu
nigdy nie zażąda zasobu, który został wcześniej przydzielony. Proces albo się zakończy, albo
w najgorszym przypadku zażąda zasobu o wyższym numerze, a wszystkie takie zasoby są dostępne.
Ostatecznie zakończy działanie i zwolni swoje zasoby. W tym momencie jakiś inny proces będzie
w posiadaniu zasobu o najwyższym numerze i również będzie mógł się zakończyć. Krótko
mówiąc, istnieje scenariusz, w którym wszystkie procesy mogą się zakończyć, a zatem nie
występuje zakleszczenie.
Wariantem tego algorytmu jest porzucenie wymagania, aby zasoby były przydzielane w ściśle
rosnącej kolejności. Wówczas pozostaje jedynie wymaganie, aby żaden proces nie żądał zasobu
o niższym numerze niż ten, który aktualnie posiada. Jeśli proces początkowo żąda zasobów 9 i 10,
a następnie obydwa zwalnia, to w zasadzie rozpoczyna wszystko od początku. W związku z tym
nie ma powodu, aby zabraniać mu żądania zasobu 1.
Chociaż numerowanie zasobów eliminuje problem zakleszczeń, znalezienie kolejności, która
satysfakcjonowałaby wszystkich, bywa niemożliwe. Jeśli do zasobów zalicza się miejsce w tablicy
procesów, miejsce w spoolerze dysku, zablokowane rekordy bazy danych oraz inne abstrakcyjne
zasoby, liczba potencjalnych zasobów i różnych zastosowań może być tak duża, że żadna kolejność
nie będzie mogła działać.
Różne sposoby zapobiegania zakleszczeniom zestawiono w tabeli 6.1.
W tym podrozdziale omówimy kilka różnych problemów dotyczących zakleszczeń. Opiszemy bloko-
wanie dwufazowe, zakleszczenia niezwiązane z zasobami oraz tzw. „zagłodzenia” (ang. starvation).
z napędem Blu-ray, ale w tym przykładzie każdy proces pomyślnie zdobywał zasób (jeden
z semaforów) i zakleszczał się przy próbie uzyskania innego (drugiego semafora). Taka sytuacja
to klasyczne zakleszczenie dotyczące zasobów.
Jednak jak wspomnieliśmy na początku niniejszego rozdziału, o ile zakleszczenia związane
z zasobami należą do najpopularniejszych, o tyle nie jest to jedyny typ zakleszczeń. Do innego
rodzaju zakleszczeń dochodzi w systemach komunikacyjnych (np. sieciach), w których dwa (lub
więcej) procesy komunikują się ze sobą poprzez przesyłanie komunikatów. W popularnym ukła-
dzie proces A wysyła komunikat z żądaniem do procesu B, a następnie blokuje się do czasu, aż
proces B zwróci komunikat z odpowiedzią. Przypuśćmy, że komunikat z żądaniem został utra-
cony. Proces A blokuje się w oczekiwaniu na odpowiedź. Proces B blokuje się w oczekiwaniu
na żądanie wykonania jakiejś operacji. Mamy zakleszczenie.
Nie jest to jednak klasyczne zakleszczenie dotyczące zasobów. Proces A nie posiada pewnych
zasobów żądanych przez proces B i vice versa. W rzeczywistości w ogóle nie ma mowy o żadnych
zasobach. Ale jest to zakleszczenie zgodnie z naszą formalną definicją. Mamy bowiem zbiór
(dwóch) procesów i każdy jest zablokowany w oczekiwaniu na zdarzenie, które może spowo-
dować tylko drugi z nich. Tę sytuację określa się mianem zakleszczenia komunikacyjnego, w odróż-
nieniu od bardziej popularnych zakleszczeń zasobów. Zakleszczenia komunikacyjne są anomalią
synchronizacji współpracy. Procesy w tego typu zakleszczeniach nie mogłyby ukończyć usługi,
gdyby były wykonywane niezależnie.
Zakleszczeniom komunikacyjnym nie da się przeciwdziałać poprzez żądanie zasobów (ponie-
waż ich nie ma). Nie da się ich też uniknąć poprzez uważne szeregowanie (ponieważ nie ma takich
momentów w czasie, kiedy można by opóźnić żądanie). Na szczęście istnieje inna technika,
którą zazwyczaj można zastosować w celu przerwania zakleszczeń komunikacyjnych: limity czasu
(ang. timeouts). W większości sieciowych systemów komunikacyjnych, za każdym razem, gdy
jest wysyłany komunikat wymagający odpowiedzi, jednocześnie uruchamia się licznik czasu.
Jeśli licznik czasu dojdzie do zera, zanim nadejdzie odpowiedź, nadawca komunikatu zakłada, że
komunikat został utracony i wysyła go ponownie (a jeśli potrzeba, to jeszcze kilka razy). W ten
sposób można uniknąć zakleszczeń. Mówiąc inaczej, limit czasu spełnia rolę heurystycznego
mechanizmu wykrywania zakleszczeń i umożliwia reagowanie na ten stan. Ten mechanizm ma
również zastosowanie do zakleszczeń zasobów. Mogą z niego korzystać użytkownicy wadliwie
działających sterowników urządzeń, które mogą spowodować zakleszczenie, a następnie za-
wieszenie systemu.
Oczywiście jeśli pierwszy komunikat nie został utracony, a jedynie odpowiedź jest opóź-
niona, to zamierzony odbiorca może otrzymać komunikat dwa lub większą liczbę razy. Konsekwen-
cje tych działań mogą być niepożądane. Wyobraźmy sobie system bankowości elektronicznej,
w którym komunikat zawiera instrukcje dokonania płatności. Jest oczywiste, że komunikatu
nie należy powtarzać (ani wykonywać) wiele razy tylko dlatego, że sieć jest wolna, a limit czasu
zbyt krótki. Projektowanie reguł komunikacyjnych, które określa się terminem protokołu, po to,
by wszystko działało prawidłowo, to złożone zagadnienie. Jego szczegółowe omówienie wykracza
poza zakres niniejszej książki. Czytelnicy zainteresowani protokołami sieciowymi mogą sięgnąć
do książki jednego ze współautorów niniejszego opracowania — Sieci komputerowe [Tanenbaum
i Wetherall, 2010].
Nie wszystkie zakleszczenia występujące w systemach komunikacyjnych lub sieciach to
zakleszczenia komunikacyjne. W sieciach mogą również zdarzać się zakleszczenia zasobów. Dla
przykładu rozważmy sieć z rysunku 6.12. Rysunek ten przedstawia uproszczoną postać internetu.
Bardzo uproszczoną. Internet składa się z dwóch rodzajów komputerów: hostów i routerów. Host
jest komputerem użytkownika — może nim być komputer osobisty użytkownika domowego,
służbowy komputer osobisty lub serwer korporacyjny. Hosty wykonują pracę dla ludzi. Router
jest specjalizowanym komputerem komunikacyjnym, który przenosi pakiety danych od źródła
do miejsca docelowego. Każdy host jest połączony do jednego lub kilku routerów — przez łącze
DSL, połączenie telewizji kablowej, sieć LAN, łącze modemowe, sieć bezprzewodową, świa-
tłowodową lub inną.
Kiedy nadchodzi pakiet do routera od jednego z jego hostów, router umieszcza go w buforze
do czasu kolejnej transmisji do innego routera, a później do następnego, aż w końcu pakiet dotrze
do miejsca przeznaczenia. Bufory, o których mowa, są zasobami. Routery dysponują skończoną
ilością miejsca w buforach. Na listingu 6.3 każdy router dysponuje tylko ośmioma buforami
(w praktyce są ich miliony, ale to nie ma wpływu na naturę potencjalnych zakleszczeń, a jedynie
na ich częstotliwość). Przypuśćmy, że wszystkie pakiety w routerze A muszą być wysłane do
routera B, a wszystkie pakiety w routerze B muszą trafić do routera C. Z kolei wszystkie pakiety
z routera C powinny trafić do routera D, a wszystkie pakiety w routerze D mają trafić do A.
Pakiety nie mogą się przemieszczać, ponieważ z drugiej strony nie ma bufora i mamy do czy-
nienia z klasycznym zakleszczeniem zasobów, choć występuje ono w systemie komunikacyjnym.
void process_A(void) {
acquire_lock(&resource_2);
while (try_lock(&resource_1) == FAIL) {
release lock(&resource_2);
wait_fixed_time();
acquire_lock(&resource_2);
}
use_both_resources();
release_lock(&resource 1);
release_lock(&resource 2);
}
6.7.3. Uwięzienia
W pewnych sytuacjach proces „stara się być uprzejmy” i zwalnia blokadę, którą już uzyskał, za
każdym razem, gdy zauważy, że nie jest w stanie uzyskać kolejnej potrzebnej blokady. Następnie
czeka kilka milisekund i ponawia próbę. Ogólnie rzecz biorąc, to dobra zasada, która powinna
przyczynić się do wykrycia i uniknięcia zakleszczeń. Jeśli jednak inny proces będzie robił to
samo w dokładnie tym samym czasie, oba procesy znajdą się w sytuacji podobnej do tej, gdy dwie
osoby starają się przejść obok siebie na ulicy. W pewnym momencie obie grzecznie robią krok
w bok. Pomimo to wciąż nie mogą przejść, ponieważ robią krok w bok dokładnie w tym samym
kierunku i tym samym czasie.
Rozważmy prymityw operacji try_lock, w którym proces wywołujący testuje muteks i albo go
zdobywa, albo zwraca błąd. Mówiąc inaczej, nigdy się nie blokuje. Programiści mogą go używać
wraz z prymitywem acquire_lock, który również próbuje uzyskać blokadę, ale blokuje się, jeśli
blokada nie jest dostępna. Wyobraźmy sobie teraz parę procesów działających współbieżnie (być
może na różnych rdzeniach), korzystających z dwóch zasobów, tak jak pokazano na listingu 6.3.
Każdy potrzebuje dwóch zasobów. W celu uzyskania potrzebnych blokad procesy wykorzystują
prymityw try_lock. Jeśli próba zakończy się niepowodzeniem, proces rezygnuje z blokady,
którą posiada, i podejmuje kolejną próbę. W sytuacji z listingu 6.3 proces A uruchamia się
i zdobywa zasób 1. W tym samym czasie uruchamia się proces B i zdobywa zasób 2. Następnie
oba starają się bezskutecznie uzyskać kolejne blokady. „Przez grzeczność” oba rezygnują
z posiadanych blokad i podejmują kolejną próbę. Ta procedura powtarza się tak długo, aż znudzony
użytkownik (lub jakiś inny podmiot) wybawi jeden z tych procesów z jego nieszczęścia. Wyraźnie
żaden proces nie jest zablokowany. Moglibyśmy nawet powiedzieć, że oba działają, zatem nie
jest to zakleszczenie. Jednak postęp nie jest możliwy, więc mamy do czynienia z czymś, co jest
równoważne zakleszczeniu: uwięzieniem (ang. livelock).
Do uwięzień i zakleszczeń może dochodzić w zaskakujących okolicznościach. W niektórych
systemach całkowitą liczbę dozwolonych procesów określa liczba wpisów w tablicy procesów.
Zatem miejsca w tablicy procesów są skończonymi zasobami. Jeśli polecenie fork się nie powie-
dzie ze względu na to, że tablica jest pełna, sensownym działaniem programu wykonującego
polecenie fork jest odczekanie losowej chwili i podjęcie ponownej próby.
Wyobraźmy sobie teraz, że tablica procesów w systemie UNIX ma 100 pozycji. Działa w nim
10 programów, z których każdy wymaga utworzenia 12 podprocesów. Kiedy każdy proces utwo-
rzy 9 procesów, pierwotne 10 procesów i 90 nowych wyczerpie miejsce w tablicy. Każdy z 10
początkowych procesów wykonuje się teraz w nieskończonej pętli — próbuje wykonać wywo-
łanie fork, które kończy się niepowodzeniem. Mamy zatem do czynienia z zakleszczeniem.
Prawdopodobieństwo wydarzenia się takiej sytuacji jest nikłe, ale zdarzenie jest możliwe. Czy
należy anulować procesy i wywołania fork, aby wyeliminować problem?
Maksymalna liczba otwartych plików jest w podobny sposób ograniczona rozmiarem tablicy
i-węzłów. W związku z tym, kiedy ta tablica się zapełni, występuje taki sam problem. Przestrzeń
wymiany na dysku jest kolejnym ograniczonym zasobem. W rzeczywistości niemal każda tablica
w systemie operacyjnym reprezentuje skończone zasoby. Czy należy je wszystkie porzucić
tylko dlatego, że może dojść do sytuacji, w której każdy z kolekcji n procesów zażąda 1/n całkowitej
liczby dostępnych zasobów, a następnie podejmie próbę żądania kolejnych? Wydaje się, że to
nie jest dobry pomysł.
W większości systemów operacyjnych, w tym UNIX i Windows, problem jest po prostu
ignorowany. Zakłada się w nich, że większość użytkowników woli, jeśli od czasu do czasu wystąpi
uwięzienie (lub nawet zakleszczenie), niż miałaby obowiązywać reguła ograniczająca każdego
6.7.4. Zagłodzenia
Problemem blisko związanym z zakleszczeniami i uwięzieniami są zagłodzenia. W dynamicznym
systemie żądania zasobów są wykonywane przez cały czas. Potrzebna jest pewna strategia
w celu podjęcia decyzji o tym, kto otrzyma określony zasób i kiedy. Strategia ta, choć z pozoru
rozsądna, może prowadzić do sytuacji, w której pewne procesy nigdy nie uzyskają obsługi, nawet
jeśli nie są zakleszczone.
Dla przykładu rozważmy przypadek przydziału drukarki. Wyobraźmy sobie, że system korzy-
sta z pewnego algorytmu po to, by zapewnić, że przydział drukarki nie doprowadzi do zaklesz-
czenia. Przypuśćmy teraz, że drukarkę chce uzyskać kilka procesów na raz. Który powinien ją
otrzymać?
Jednym z możliwych algorytmów jest przydzielenie drukarki temu procesowi, który ma
najmniejszy plik do wydrukowania (przy założeniu, że takie informacje są dostępne). Takie podej-
ście maksymalizuje liczbę zadowolonych klientów i wydaje się sprawiedliwe. Zastanówmy się
teraz, co się zdarzy w zajętym systemie, w którym jeden z procesów ma do wydrukowania duży
plik. Za każdym razem, gdy drukarka będzie wolna, system będzie próbował znaleźć proces,
który ma najmniejszy plik do wydrukowania. W przypadku gdy w systemie będzie występował
ciągły napływ procesów z małymi plikami, procesom chcącym wydrukować duży plik nigdy nie
zostanie przydzielona drukarka. Procesy te zagłodzą się na śmierć (będą w nieskończoność odra-
czane, pomimo że nie są zablokowane).
Zagłodzeń można uniknąć dzięki zastosowaniu strategii alokacji zasobów FCFS. Przy takim
podejściu w następnej kolejności jest obsługiwany proces, który czeka najdłużej. Z biegiem czasu
każdy proces w końcu będzie najstarszy i uzyska żądany zasób.
Warto wspomnieć, że niektóre osoby nie rozróżniają uwięzień i zakleszczeń, ponieważ
w żadnym z przypadków nie jest możliwy dalszy postęp. Inni uważają, że są to kompletnie różne
pojęcia. Można bowiem tak zaprogramować proces, aby próbował wykonać jakąś operację n razy,
a kiedy wszystkie próby się nie powiodą, podjął inne działanie. Zablokowany proces nie ma takiej
możliwości.
W początkach systemów operacyjnych temat zakleszczeń był bardzo intensywnie badany. Wykry-
wanie zakleszczeń jest bowiem prostym problemem teorii grafów, w który może „wbić zęby”
każdy uzdolniony matematycznie absolwent wyższej uczelni i który może „żuć” przez 3 lub 4 lata.
Wymyślono rozmaite rodzaje algorytmów — każdy z nich był bardziej egzotyczny i mniej prak-
tyczny od poprzedniego. Większość prac porzucono, ciągle jednak publikowanych jest sporo
artykułów poświęconym zakleszczeniom.
Najnowsze prace na ten temat obejmują badania dotyczące odporności zakleszczeń — [Jula
et al., 2011]. Główna idea tego podejścia polega na tym, że aplikacja wykrywa zakleszczenia,
jeśli one występują, a następnie zapisuje „sygnatury”, które pozwalają uniknąć takiego samego
zakleszczenia w przyszłości. Z kolei [Marino et al., 2013] pokazują sposób wykorzystania kon-
troli współbieżności w celu zablokowania możliwości powstawania zakleszczeń. Inny kierunek
badań dotyczy starań wykrywania zakleszczeń.
Najnowsze prace poświęcone wykrywaniu zakleszczeń został przedstawione przez [Pyla
i Varadarajan, 2012]. W pracy [Cai i Chan, 2012] zaprezentowano nowy, dynamiczny mechanizm
wykrywania zakleszczeń bazujący na iteracyjnym usuwaniu zależności blokad, do których nie
prowadzą żadne przychodzące ani wychodzące krawędzie.
Problem zakleszczeń dotyczy wielu dziedzin. [Wu et al., 2013] opisali system kontroli zaklesz-
czeń w zautomatyzowanym systemie produkcji. Takie systemy modeluje się przy użyciu sieci
Petriego. Pozwala to na znalezienie warunków koniecznych i wystarczających do stworzenia
liberalnej kontroli zakleszczeń.
Prowadzi się również liczne badania nad rozproszonym wykrywaniem zakleszczeń — zwłasz-
cza w systemach wysokiej wydajności. Wiele prac poświęcono np. zagadnieniom szeregowania
bazującego na wykrywaniu zakleszczeń. [Wang i Lu, 2013] opublikowali algorytm szeregowania
dla obliczeń przepływu pracy w przypadku występowania ograniczeń co do pamięci trwałej.
[Hilbrich et al., 2013] opisują wykrywanie zakleszczeń w fazie wykonywania programu dla pro-
tokołu MPI (ang. Message Passing Interface). Wreszcie istnieje ogromna liczba prac teoretycz-
nych poświęconych wykrywaniu zakleszczeń rozproszonych. Tematyką tą nie będziemy się
jednak zajmować w tej książce, ponieważ (1) wykracza to poza jej zakres i (2) żaden z wymie-
nionych tematów nie dotyczy już rzeczywistych systemów. Główną funkcją tych badań wydaje
się używanie teorii grafów, która w przypadku braku badań byłaby „bezrobotna”.
6.9. PODSUMOWANIE
6.9.
PODSUMOWANIE
PYTANIA
1. Podaj przykład zakleszczenia — posłuż się analogią ze świata polityki.
2. Studenci pracują na indywidualnych komputerach PC w laboratorium. Przesyłają pliki
do wydruku na serwer, a ten buforuje pliki na dysku twardym. W jakich okolicznościach
może dojść do zakleszczenia, jeśli miejsce na dysku przeznaczone na spooler jest ograni-
czone? W jaki sposób można uniknąć zakleszczenia?
3. Które zasoby z poprzedniego pytania mogą być wywłaszczane, a które nie?
4. W kodzie z listingu 6.1 zasoby są zwracane w odwrotnej kolejności do ich uzyskiwania.
Czy zwalnianie ich w innej kolejności będzie równie dobrym rozwiązaniem?
5. Aby wystąpiło zakleszczenie zasobów, konieczne są cztery warunki (wzajemne wyklu-
czanie, wstrzymanie i oczekiwanie, brak wywłaszczania oraz cykliczne oczekiwanie). Podaj
przykład, który pokazuje, że te warunki nie są wystarczające do wystąpienia zakleszczenia
zasobów. Kiedy te warunki będą wystarczające do wystąpienia tego typu zakleszczenia?
6. Ulice miasta są podatne na stan cyklicznego zakleszczenia określanego terminem gridlock
(dosł. blokada sieciowa). Polega ono na tym, że skrzyżowania są zablokowane przez
samochody, które blokują samochody za nimi; te z kolei blokują samochody starające
się wjechać na poprzednie skrzyżowanie itd. Wszystkie skrzyżowania w mieście są wypeł-
nione pojazdami blokującymi ruch wchodzący w sposób cykliczny.
Blokada gridlock jest zakleszczeniem zasobów i problemem synchronizacji rywalizacji.
Algorytm przeciwdziałania blokadom tego rodzaju w Nowym Jorku nosi nazwę „don’t
block the box”. Opiera się on na zasadzie niezezwalania na wjazd na skrzyżowanie, jeśli nie
ma za nim wystarczająco dużo wolnego miejsca. Jakiego rodzaju jest ten algorytm? Czy
potrafisz podać inne algorytmy zapobiegania blokadom gridlock?
7. Do skrzyżowania jednocześnie podjeżdżają cztery samochody z czterech różnych kie-
runków. Przy każdym wjeździe na skrzyżowanie stoi znak stopu. Załóżmy, że przepisy
ruchu wymagają, że gdy dwa samochody podjeżdżają do dwóch sąsiadujących ze sobą
znaków stopu w tym samym czasie, to samochód po lewej stronie ma pierwszeństwo
w stosunku do samochodu po prawej stronie. W związku z tym, kiedy cztery samochody
podjadą do swoich znaków stopu, wszystkie będą czekały (w nieskończoność), aż prze-
jedzie samochód z lewej strony. Czy ta anomalia jest zakleszczeniem komunikacyjnym?
Czy to jest zakleszczenie zasobów?
8. Czy jest możliwe, aby zakleszczenie zasobów obejmowało wiele jednostek tego samego
typu i pojedynczą jednostkę innego typu? Jeśli tak, podaj przykład.
9. Na rysunku 6.1 zaprezentowano koncepcję grafu zasobów. Czy istnieją nieprawidłowe
grafy — czyli takie, które strukturalnie naruszają reguły zastosowanego modelu wyko-
rzystania zasobów? Jeśli istnieją, podaj przykład takiego grafu.
10. Rozważmy rysunek 6.2. Załóżmy, że w kroku (o) proces C zażądał zasobu S, zamiast
zażądać zasobu R. Czy doprowadziłoby to do zakleszczenia? A co by się stało, gdyby zażą-
dał zarówno zasobu S, jak i R.
21. Przyjrzyj się uważnie rysunkowi 6.9(b). Jeśli proces D zażąda jeszcze jednej jednostki,
czy doprowadzi to do stanu bezpiecznego, czy niebezpiecznego? A co będzie, jeśli żądanie
będzie pochodziło od procesu C zamiast od D?
22. System posiada dwa procesy i trzy identyczne zasoby. Każdy proces potrzebuje maksy-
malnie dwóch zasobów. Czy jest możliwe zakleszczenie? Uzasadnij swoją odpowiedź.
23. Przeanalizuj poprzedni problem jeszcze raz. Teraz jednak przyjmij, że występuje p pro-
cesów, z których każdy potrzebuje maksymalnie m zasobów, a w sumie jest dostępnych
r zasobów. Jaki warunek musi zachodzić, aby system był wolny od zakleszczeń?
24. Przypuśćmy, że proces A z rysunku 6.10 żąda ostatniego napędu taśm. Czy to działanie
doprowadzi do zakleszczenia?
25. W systemie, w którym jest m klas zasobów i n procesów, działa algorytm bankiera. Ogra-
niczeniem dla dużych wartości m i n jest to, że liczba operacji, które należy wykonać
w celu sprawdzenia bezpieczeństwa stanu, jest proporcjonalna do manb. Co oznaczają war-
tości a i b?
26. System posiada cztery procesy i pięć zasobów do przydzielenia. Bieżące i maksymalne
potrzeby przydziału przedstawia poniższa tablica:
Przydzielone Maksymalne Dostępne
Proces A 10211 11213 00x11
Proces B 20110 22210
Proces C 11010 21310
Proces D 11110 11221
Jaka jest najmniejsza wartość x, dla której system jest w stanie bezpiecznym?
27. Jednym ze sposobów eliminacji cyklicznego oczekiwania jest stosowanie reguły, zgodnie
z którą w danym momencie proces jest uprawniony do żądania tylko jednego zasobu.
Podaj przykład, który pokazuje, że takie ograniczenie w wielu przypadkach jest niedo-
puszczalne.
28. Każdy z dwóch procesów, A i B, potrzebuje trzech rekordów z bazy danych 1, 2 i 3. Jeśli
proces A zażąda ich w kolejności 1, 2, 3, a B zażąda ich w tej samej kolejności, to zaklesz-
czenie nie będzie możliwe. Jeśli jednak proces B zażąda ich w kolejności 3, 2, 1, to zaklesz-
czenie będzie możliwe. Przy trzech zasobach istnieje 3!, czyli sześć możliwych kombi-
nacji sposobów, na jakie każdy proces może żądać zasobów. Dla jakiej części wszystkich
kombinacji występuje gwarancja braku zakleszczeń?
29. Rozproszony system korzystający ze skrzynek pocztowych dysponuje dwoma prymity-
wami IPC: send i receive. Drugi z prymitywów określa proces, z którego mają pochodzić
informacje. W przypadku gdy komunikaty pochodzące od tego procesu nie są dostępne,
oczekujący proces się blokuje, nawet jeśli istnieją oczekujące komunikaty od innych
procesów. Nie ma współdzielonych zasobów, ale jednym z wymagań jest to, aby procesy
często komunikowały się pomiędzy sobą. Czy jest możliwe zakleszczenie? Uzasadnij.
30. W elektronicznym systemie przelewów pieniężnych istnieją setki identycznych procesów
działających w następujący sposób: każdy proces czyta wiersz wejściowy, w którym jest
określona kwota pieniędzy oraz konta obciążenia i uznania. Następnie oba konta są blo-
kowane, następuje przelew środków, a po wykonaniu tej operacji blokady są zwalniane.
W przypadku wielu procesów działających równolegle zachodzi realne zagrożenie, że po
zablokowaniu konta x nie będzie można zablokować konta y, ponieważ konto y zostało
wcześniej zablokowane przez proces, który teraz oczekuje na konto x. Opracuj sposób
unikania zakleszczeń. Nie zwalniaj blokady rekordu konta, dopóki nie zakończysz trans-
akcji (inaczej mówiąc, rozwiązania polegające na zablokowaniu jednego konta, a następnie
natychmiastowym jego zwolnieniu, gdy drugie jest zablokowane, są niedozwolone).
31. Jednym ze sposobów zapobiegania zakleszczeniom jest wyeliminowanie warunku wstrzy-
mania i oczekiwania. W tekście zaproponowano, by przed żądaniem nowego zasobu proces
musiał najpierw zwolnić zasoby, które już posiada (przy założeniu, że jest to możliwe).
Wykonanie takiej operacji wprowadza jednak zagrożenie, że proces co prawda uzyska
nowy zasób, ale straci niektóre z posiadanych na rzecz procesów rywalizujących. Zapro-
ponuj usprawnienie tego mechanizmu.
32. Student informatyki, któremu przydzielono pracę dotyczącą zakleszczeń, wymyślił poniż-
szy doskonały sposób eliminacji zakleszczeń. Kiedy proces żąda zasobu, określa limit
czasowy. Jeśli proces się zablokuje ze względu na to, że zasób jest niedostępny, uru-
chamia się licznik czasowy. Jeżeli limit czasu zostanie przekroczony, proces jest zwal-
niany, a system pozwala mu działać od nowa. Gdybyś był profesorem, jak oceniłbyś taką
propozycję i dlaczego?
33. W systemach wymiany oraz systemach pamięci wirtualnej jednostki pamięci głównej są
wywłaszczane. Procesor jest wywłaszczany w środowiskach z podziałem czasu. Czy
sądzisz, że te metody wywłaszczania opracowano w celu obsługi zakleszczeń zasobów, czy
do innych celów? Jak duże są związane z tym koszty?
34. Wyjaśnij różnice pomiędzy zakleszczeniem, uwięzieniem a zagłodzeniem.
35. Załóżmy, że dwa procesy wydają polecenie seek w celu zmiany położenia mechanizmu
dostępu do dysku i włączenia polecenia odczytu. Przed wykonaniem odczytu każdy z pro-
cesów zostaje przerwany i odkrywa, że drugi przesunął ramię dysku. Następnie oba pona-
wiają polecenie seek, ale ponownie każdy jest przerywany przez drugi. Ta sekwencja
zdarzeń jest powtarzana. Czy mamy do czynienia z zakleszczeniem zasobu, czy z uwię-
zieniem? Jakie metody obsługi anomalii mógłbyś polecić?
36. W sieciach lokalnych wykorzystywana jest metoda dostępu do mediów o nazwie CSMA/CD.
Jej działanie polega na tym, że stacje współdzielące magistralę mogą testować medium
w celu wykrywania transmisji, a także kolizji. W protokole Ethernet stacje żądające współ-
użytkowanego kanału nie przekazują ramki, jeśli wyczują, że nośnik jest zajęty. Gdy taka
transmisja się zakończy, każda ze stacji oczekujących przekazuje swoje ramki. Dwie ramki,
które są przekazywane w tym samym czasie, będą ze sobą kolidować. Jeśli stacje po
wystąpieniu kolizji natychmiast podejmą ponowną próbę transmisji i będą ją podejmować
wielokrotnie, kolizje będą występowały w nieskończoność.
(a) Czy mamy do czynienia z zakleszczeniem zasobu, czy z uwięzieniem?
(b) Czy potrafisz zaproponować rozwiązanie tej anomalii?
(c) Czy w tym scenariuszu może wystąpić zagłodzenie?
37. Program zawiera błąd kolejności wywoływania mechanizmów współpracy i rywalizacji.
W efekcie proces konsumenta blokuje muteks (semafor z wzajemnym wykluczaniem),
zanim zablokuje się na pustym buforze. Proces producenta blokuje się na muteksie, zanim
uzyska możliwość umieszczenia wartości w pustym buforze i obudzi konsumenta. Zatem
obydwa procesy są zablokowane na zawsze: producenta czeka odblokowanie muteksa,
Bywa, że instytucja ma system wielokomputerowy, ale właściwie z niego nie korzysta. Typowym
przykładem jest firma, która utrzymuje serwer pocztowy, serwer WWW, serwer FTP, kilka
serwerów e-commerce oraz parę innych serwerów. Każdy z nich działa na osobnym komputerze
zainstalowanym w tej samej szafie sprzętowej. Wszystkie są połączone ze sobą szybką siecią.
Słowem, system wielokomputerowy. Jednym z powodów, dla którego te serwery działają na
oddzielnych maszynach, może być to, że jedna maszyna nie jest w stanie obsłużyć obciążenia, ale
drugi powód to niezawodność: kierownictwo po prostu nie wierzy, że system operacyjny będzie
w stanie bezawaryjnie działać 24 godziny na dobę przez 365 lub 366 dni w roku. Dzięki
umieszczeniu każdego serwisu na oddzielnym komputerze w przypadku awarii jednego serwera
co najmniej ten drugi będzie mógł działać bezawaryjnie. Jest to bardzo ważne także ze względów
bezpieczeństwa. Nawet jeśli jakiemuś intruzowi uda się złamać zabezpieczenia serwera WWW,
nie uzyska od razu dostępu do poufnych wiadomości e-mail — tę właściwość czasami określa się
jako tryb piaskownicy (ang. sandboxing). Chociaż w ten sposób można uzyskać tolerancję na
błędy, rozwiązanie jest kosztowne i sprawia problemy w zarządzaniu, ponieważ bierze w nim udział
zbyt wiele komputerów.
To tylko dwa z wielu powodów, dlaczego usługi powinny działać na odrębnych maszynach.
Dla przykładu w organizacjach do codziennego użytku często wykorzystywany jest więcej niż
jeden system operacyjny: serwer WWW działa na systemie Linux, serwer poczty działa na sys-
temie Windows, serwer e-commerce wykorzystuje system OS X, a kilka innych usług jest
uruchomionych na różnych odmianach Uniksa. Jak wcześniej — takie rozwiązanie jest sku-
teczne, ale z pewnością nie jest tanie.
Co wtedy należy zrobić? Możliwym (i popularnym) rozwiązaniem jest zastosowanie tech-
nologii maszyn wirtualnych. Nazwa brzmi bardzo nowocześnie, ale koncepcja jest stara, sięga lat
sześćdziesiątych. Mimo że pomysł nie jest nowy, dziś realizujemy go w zupełnie nowy sposób.
477
Główna idea jest taka, że monitor VMM (ang. Virtual Machine Monitor) tworzy iluzję wielu maszyn
(wirtualnych) działających na tym samym sprzęcie fizycznym. Monitor VMM jest również nazy-
wany hipernadzorcą (ang. hypervisor). Zgodnie z tym, co napisaliśmy w punkcie 1.7.5, możemy
wyróżnić hipernadzorców typu 1, działających na fizycznym sprzęcie, oraz nadzorców typu 2,
mogących korzystać ze wszystkich usług i abstrakcji oferowanych przez macierzysty system
operacyjny. Tak czy inaczej, dzięki wirtualizacji pojedynczy komputer może być hostem dla wielu
maszyn wirtualnych. Na każdej z nich może potencjalnie działać zupełnie inny system operacyjny.
Rozwiązanie to ma taką zaletę, że awaria jednej maszyny wirtualnej nie powoduje automa-
tycznie awarii pozostałych. W systemie z wirtualizacją różne serwery mogą działać na różnych
maszynach wirtualnych. W związku z tym jest utrzymany model częściowej odporności na awarie,
typowy dla systemu wielokomputerowego, ale znacznie niższym kosztem; poza tym jest on
łatwiejszy w utrzymaniu. Ponadto możemy teraz uruchomić wiele systemów operacyjnych na
tym samym sprzęcie, korzystać z izolacji maszyny wirtualnej w przypadku ataków i cieszyć się
z innych zalet.
Oczywiście, taką konsolidację serwerów można porównać do umieszczenia „wszystkich jajek
w jednym koszyku”. Jeśli serwer, na którym działają wszystkie maszyny wirtualne, ulegnie awarii,
rezultaty będą jeszcze bardziej katastrofalne w porównaniu z awarią pojedynczego, dedykowanego
serwera. Powodem, dla którego wirtualizacja się jednak sprawdza, jest to, że przyczyną więk-
szości awarii usług nie jest wadliwy sprzęt, ale nadmiernie rozbudowane, błędnie działające
oprogramowanie, zwłaszcza systemy operacyjne. W przypadku zastosowania technologii maszyn
wirtualnych jedynym programem działającym w jądrze jest hipernadzorca — system, którego kod
źródłowy zawiera o dwa rzędy wielkości wierszy mniej niż pełny system operacyjny. W związku
z tym ma o dwa rzędy wielkości mniej błędów. Hipernadzorca jest prostszy od systemu opera-
cyjnego, ponieważ realizuje tylko jedną funkcję: emuluje wiele kopii fizycznego sprzętu (naj-
częściej architektury Intel x86).
Oprócz ścisłej izolacji uruchamianie oprogramowania w maszynach wirtualnych ma także
inne zalety. Jedna z nich jest taka, że mniej fizycznych maszyn pozwala zaoszczędzić pieniądze na
sprzęt i elektryczność oraz wymaga mniej przestrzeni biurowej. Dla takich firm jak Amazon lub
Microsoft, które mają kilkaset tysięcy serwerów wykonujących wiele różnych zadań, zmniejszenie
fizycznych wymagań na centra danych wiąże się z olbrzymimi oszczędnościami finansowymi.
W rzeczywistości firmy udostępniające serwery często umieszczają swoje centra danych na
kompletnym pustkowiu — tylko po to, by być blisko np. tam wodnych (a tym samym taniej
energii). Wirtualizacja pomaga również w testowaniu nowych pomysłów. Zazwyczaj w dużych
firmach poszczególne działy opracowują interesujące pomysły, a następnie kupują serwer, aby je
zaimplementować. Jeśli pomysł się przyjmie i będą potrzebne setki czy tysiące serwerów,
korporacyjne centra danych się rozwiną. Często są trudności z przeniesieniem oprogramowania
na istniejące komputery, ponieważ każda aplikacja wymaga odmiennej wersji systemu operacyj-
nego, własnych bibliotek, plików konfiguracyjnych i wielu innych. W przypadku maszyn wirtu-
alnych każda aplikacja może działać we własnym środowisku.
Inną zaletą maszyn wirtualnych jest to, że sprawdzanie i migracja maszyn wirtualnych (np.
w celu równoważenia obciążenia pomiędzy wiele serwerów) jest znacznie łatwiejsza w porów-
naniu z migracją procesów działających w normalnym systemie operacyjnym. W tym drugim
przypadku w tablicach systemu operacyjnego przechowywanych jest sporo kluczowych infor-
macji na temat każdego procesu. Należą do nich informacje związane z otwartymi plikami,
alarmami, procedurami obsługi sygnałów i innymi. W przypadku migracji do maszyny wirtualnej
wszystkie te informacje muszą zostać przeniesione do obrazu pamięci, ponieważ trzeba także
przenieść wszystkie tablice systemu operacyjnego.
7.1. HISTORIA
7.1.
HISTORIA
Pomimo szumu wokół wirtualizacji w ostatnich latach czasami zapominamy, że według stan-
dardów obowiązujących w internecie maszyny wirtualne są „starożytne”. Już w latach sześć-
dziesiątych firma IBM eksperymentowała nie tylko z jednym, ale z dwoma niezależnymi syste-
mami typu hipernadzorca — SIMMON i CP-40. Chociaż CP-40 był projektem badawczym,
został zaimplementowany jako CP-67 w ramach programu CP/CMS — systemu operacyjnego
maszyn wirtualnych dla komputera IBM System/360 Model 67. Później, w 1972 roku, została
stworzona kolejna implementacja, jako system VM/370 dla serii System/370. W latach dzie-
więćdziesiątych firma IBM zastąpiła linię System/370 przez System/390. Była to w zasadzie
zmiana nazwy, ponieważ architekturę, w celu zachowania zgodności wstecz, pozostawiono bez
zmian. Oczywiście poprawiono technologię sprzętową. Ponadto nowsze maszyny były większe
i szybsze od starszych, ale jeśli chodzi o obsługę wirtualizacji, nic się nie zmieniło. W 2000 roku
firma IBM opublikowała linię Z-Series — komputery, które obsługiwały przestrzenie 64-bitowych
adresów wirtualnych, ale poza tym były kompatybilne z komputerami System/360. Wszystkie
te systemy obsługiwały wirtualizację dziesięciolecia wcześniej, zanim zyskała ona popularność
na platformie x86.
W 1974 roku dwóch naukowców z UCLA, Gerald Popek i Robert Goldberg, opublikowało
artykuł Formal Requirements for Virtualizable Third Generation Architectures, w którym dokładnie
wyszczególniono warunki, jakie powinna spełniać architektura komputera, aby mogła skutecznie
obsługiwać wirtualizację [Popek i Goldberg, 1974]. Nie da się napisać rozdziału poświęconego
wirtualizacji bez odwoływania się do ich pracy i terminologii. Co ciekawe, znana architektura
x86, która również pochodzi z lat siedemdziesiątych, nie spełniała tych wymogów przez wiele
dziesięcioleci. Nie była w tym osamotniona. Prawie żadna architektura od czasów komputerów
typu mainframe nie przechodziła testu. Lata siedemdziesiąte były bardzo wydajne. Były czasem
narodzin Uniksa, Ethernetu, komputera Cray-1, firm Microsoft i Apple. Zatem, wbrew temu, co
mówią nasi rodzice, lata siedemdziesiąte nie były tylko czasem muzyki disco!
W rzeczywistości prawdziwa rewolucja Disco rozpoczęła się w latach dziewięćdziesiątych,
kiedy naukowcy z Uniwersytetu Stanforda opracowali nowego hipernadzorcę o tej nazwie
i stworzyli firmę VMware — giganta wirtualizacji, który oferuje systemy hipernadzorców typu 1
i typu 2 i osiąga przychody rzędu miliardów dolarów [Bugnion et al., 1997], [Bugnion et al., 2012].
Nawiasem mówiąc, rozróżnienie pomiędzy hipernadzorcami „typu 1” i „typu 2” także pochodzi
z lat siedemdziesiątych [Goldberg, 1972]. Firma VMware opublikowała swoje pierwsze rozwiąza-
nie wirtualizacji dla platformy x86 w 1999 roku. W ślad za nim powstało wiele innych produktów,
m.in. Xen, KVM, VirtualBox, Hyper-V, Parallels. Wydaje się, że dla wirtualizacji nadszedł dobry
czas, chociaż teoria była znana już w 1974 roku, a firma IBM przez dziesięciolecia sprzeda-
wała komputery, które obsługiwały i intensywnie wykorzystywały wirtualizację. W 1999 roku
wirtualizacja stała się popularna wśród mas, ale nowością nie była.
Maszyny wirtualne działają podobnie jak prawdziwy McCoy. W szczególności muszą być zdolne
do uruchamiania się tak jak fizyczne komputery i zapewniać możliwość instalowania na nich
dowolnych systemów operacyjnych. Zapewnienie tej iluzji w wydajny sposób jest zadaniem hiper-
nadzorcy. Systemy hipernadzorców powinny mieć wysoką ocenę w trzech wymiarach:
1. Bezpieczeństwo: hipernadzorca powinien mieć pełną kontrolę nad wirtualnymi zasobami.
2. Wierność: zachowanie programu na maszynie wirtualnej powinno być identyczne z zacho-
waniem takiego samego programu działającego na fizycznym sprzęcie.
3. Wydajność: większość kodu na maszynie wirtualnej powinna działać bez interwencji hiper-
nadzorcy.
Bez wątpienia bezpiecznym sposobem uruchamiania instrukcji jest przetwarzanie każdej
instrukcji po kolei za pomocą interpretera (np. Bochs) i wykonywanie dokładnie tych operacji,
które są wymagane przez daną instrukcję. Niektóre instrukcje mogą być uruchamiane bezpośred-
nio, ale nie ma ich zbyt wiele. Interpreter np. może być w stanie w prosty sposób wykonać instruk-
cję INC (inkrementację), ale instrukcje, które nie są bezpieczne do bezpośredniego wykonywania,
muszą być symulowane przez interpreter. Przykładowo nie można pozwolić systemowi opera-
stosowana w praktyce. Do dobrze znanych przykładów można zaliczyć warstwy zgodności WINE,
pozwalające aplikacjom systemu Windows na działanie w systemach zgodnych z POSIX, takich
jak Linux, BSD i OS X, a także wersję poziomu procesu emulatora QEMU, który umożliwia
uruchamianie aplikacji napisanych dla jednej architektury w innej architekturze.
[Goldberg, 1972] wyróżnia dwa podejścia do wirtualizacji. Jeden rodzaj hipernadzorcy, nazywany
hipernadzorcą typu 1, pokazano na rysunku 7.1(a). Z technicznego punktu widzenia przypomina on
system operacyjny, ponieważ jest jedynym programem, który działa w najbardziej uprzywilejowa-
nym trybie. Jego zadaniem jest obsługa wielu kopii sprzętu zwanych maszynami wirtualnymi.
Są one podobne do procesów obsługiwanych przez standardowe systemy operacyjne.
Dla odróżnienia hipernadzorca typu 2, pokazany na rysunku 7.1(b), to coś zupełnie innego.
Jest to program, który do alokowania lub szeregowania zasobów wykorzystuje np. system Win-
dows lub Linux — w sposób bardzo podobny do zwykłego procesu. Oczywiście hipernadzorcy
typu 2 również udają pełny komputer — z procesorem CPU i różnymi urządzeniami. Oba rodzaje
hipernadzorców muszą wykonywać zbiór instrukcji maszynowych w bezpieczny sposób. Przy-
kładowo system operacyjny działający pod kontrolą hipernadzorcy może zmodyfikować lub nawet
zmienić porządek własnej tabeli stron, ale nie może tego zrobić z tabelami innych.
System działający pod kontrolą hipernadzorcy w obu przypadkach jest nazywany systemem
operacyjnym-gościem. W przypadku hipernadzorcy typu 2 system operacyjny działający na
sprzęcie jest nazywany systemem operacyjnym-gospodarzem. Pierwszym na rynku hipernadzorcą
typu 2 przeznaczonym na platformę x86 był system VMware Workstation [Bugnion et al., 2012].
W tym podrozdziale zaprezentujemy ogólną koncepcję tego systemu. Studium dotyczące sys-
temu VMware zamieszczono w podrozdziale 7.12.
Większość funkcji hipernadzorców typu 2, czasami określanych terminem hipernadzorcy na
hoście (ang. hosted hypervisor), zależy od systemu operacyjnego-gospodarza, np. Windows, Linux
lub OS X. Kiedy taki program uruchamia się po raz pierwszy, działa w sposób podobny do nowo
uruchamianego komputera. Oczekuje znalezienia dysku DVD, USB lub CD-ROM zawierającego
system operacyjny. Jednak tym razem dysk może być urządzeniem wirtualnym. Można np. zapisać
obraz jako plik ISO na dysku twardym hosta. Z punktu widzenia hipernadzorcy typu 2 odczyt
z tego pliku będzie interpretowany jak odczyt z prawidłowego dysku DVD. Następnie hipernad-
zorca typu 2 instaluje system operacyjny na swoim wirtualnym dysku (w rzeczywistości jest to
Możliwość wirtualizacji i wydajność to ważne kwestie. Spróbujmy więc przyjrzeć im się trochę
bliżej. Przyjmijmy na chwilę, że mamy hipernadzorcę typu 1, który obsługuje jedną maszynę
wirtualną (tak jak pokazano na rysunku 7.2). Podobnie jak wszystkie systemy hipernadzorców
typu 1, działa on na fizycznym sprzęcie. Maszyna wirtualna działa jako proces użytkownika w trybie
użytkownika. W związku z tym nie ma prawa uruchamiania wrażliwych instrukcji (w sensie Popka
i Goldberga). Jednak na maszynie wirtualnej działa system operacyjny-gość, który „myśli”, że jest
w trybie jądra (choć oczywiście w rzeczywistości nie działa w tym trybie). Taki tryb będziemy
nazywali trybem wirtualnego jądra. Maszyna wirtualna uruchamia także procesy użytkownika,
które także „myślą”, że są w trybie użytkownika (i rzeczywiście w nim są).
Rysunek 7.2. Jeśli jest dostępna technika wirtualizacji, to w przypadku gdy system operacyjny
na maszynie wirtualnej uruchamia instrukcję tylko dla jądra, w rzeczywistości wykonuje rozkaz
pułapki do hipernadzorcy
Co się dzieje, gdy system operacyjny-gość (który „myśli”, że jest w trybie jądra) wykonuje
instrukcję, która jest dozwolona tylko wtedy, gdy procesor naprawdę działa w trybie jądra? Zazwy-
czaj w procesorach bez mechanizmu VT wykonanie instrukcji kończy się niepowodzeniem,
a system operacyjny się zawiesza. W procesorach z mechanizmem VT, kiedy system opera-
cyjny-gość wykona wrażliwą instrukcję, wykonywany jest rozkaz pułapki do jądra, tak jak poka-
zano na rysunku 7.2. Hipernadzorca może następnie zbadać instrukcję, aby przekonać się, czy
była wydana przez system operacyjny-gościa na maszynie wirtualnej, czy też przez program
użytkownika na maszynie wirtualnej. W pierwszym przypadku przygotowuje się do wykonania
instrukcji. W drugim — emuluje działania rzeczywistego sprzętu w konfrontacji z wrażliwą instruk-
cją uruchamianą w trybie użytkownika.
zmieniają przepływ sterowania, z wyjątkiem ostatniej instrukcji, która właśnie to robi. Tuż przed
wykonaniem podstawowego bloku hipernadzorca skanuje go po raz pierwszy, aby sprawdzić,
czy nie zawiera on wrażliwych instrukcji (w sensie Popka i Goldberga). Jeśli tak, to zastępuje je
wywołaniem procedury hipernadzorcy, która obsługuje takie instrukcje. Przekazanie sterowania
w ostatniej instrukcji jest również zastępowane wywołaniem funkcji hipernadzorcy (aby uzyskać
pewność, że może powtórzyć procedurę dla następnego bloku podstawowego). Dynamiczne
tłumaczenie i emulacja brzmi jak kosztowna operacja, ale zazwyczaj taka nie jest. Tłumaczone
bloki są buforowane, dlatego w przyszłości tłumaczenie nie jest potrzebne. Ponadto większość
bloków kodu nie zawiera instrukcji wrażliwych lub uprzywilejowanych i dlatego mogą być one
uruchamiane w sposób natywny. W szczególności, o ile hipernadzorca dokładnie konfiguruje
sprzęt (jak to się dzieje np. w systemach VMware), tłumacz binarny może zignorować wszystkie
procesy użytkownika — one i tak wykonują się w nieuprzywilejowanym trybie.
Po zakończeniu wykonywania bloku podstawowego sterowanie jest zwracane do hipernad-
zorcy, który wyszukuje kolejny blok podstawowy. Jeśli ten kolejny blok już został przetłumaczony,
to może być wykonany natychmiast. W przeciwnym razie jest najpierw tłumaczony, następnie
buforowany i na koniec uruchamiany. Ostatecznie większość programów znajdzie się w pamięci
podręcznej i będzie działała z szybkością bliską pełnej. W systemie tym stosowanych jest szereg
optymalizacji. Jeśli np. podstawowy blok kończy się skokiem (lub wywołaniem) innego podsta-
wowego bloku, ostatnią instrukcję można zastąpić skokiem lub bezpośrednio wywołać prze-
tłumaczony podstawowy blok. W ten sposób eliminuje się koszty związane z wyszukiwaniem
następnego bloku. Nie ma również potrzeby zastępowania wrażliwych instrukcji w programach
użytkownika. Sprzęt i tak je zignoruje.
Z drugiej strony często tłumaczenie binarne jest wykonywane na całym kodzie systemu ope-
racyjnego-gościa działającego w pierścieniu 1. Zastępowane są nawet uprzywilejowane wrażliwe
instrukcje, które w zasadzie można by obsłużyć za pomocą pułapek. Powodem jest to, że obsługa
pułapek jest bardzo kosztowna, a stosowanie tłumaczenia binarnego prowadzi do lepszej
wydajności.
Dotychczas opisaliśmy hipernadzorcę typu 1. Mimo że hipernadzorcy typu 2 koncepcyjnie
różnią się od typu 1, w większości przypadków dla obu typów stosowane są te same techniki.
Przykładowo w systemie VMware ESX Server (hipernadzorcy typu 1, który pojawił się po raz
pierwszy w 2001 roku) używano dokładnie takiego samego tłumaczenia binarnego jak w pierw-
szych wersjach systemu VMware Workstation (hipernadzorcy typu 2 wydanego dwa lata wcześniej).
Jednak natywne uruchomienie kodu systemu operacyjnego-gościa i wykorzystanie dokład-
nie tych samych technik wymaga od hipernadzorcy typu 2 manipulowania sprzętem na najniższym
poziomie — tzn. wykonywania operacji, które nie mogą być wykonane z poziomu przestrzeni
użytkownika. Przykładowo deskryptory segmentów dla kodu systemu operacyjnego-gościa muszą
być dokładnie ustawione na odpowiednią wartość. Aby wirtualizacja była wierna, system opera-
cyjny-gość powinien „mieć przekonanie”, że jest prawdziwym i jedynym „królem gór”, z pełną
kontrolą wszystkich zasobów maszyny oraz dostępem do całej przestrzeni adresowej (4 GB na
komputerach 32-bitowych). Gdy król spotka innego króla (jądro hosta) hasającego w jego prze-
strzeni adresowej, nie będzie zachwycony.
Niestety, to jest dokładnie to, co się dzieje, gdy system operacyjny-gość działa jako proces
użytkownika w zwykłym systemie operacyjnym. W systemie Linux np. proces użytkownika ma
dostęp do tylko 3 GB z 4-gigabajtowej przestrzeni adresowej, a pozostały 1 GB jest zarezerwo-
wany dla jądra. Każda próba dostępu do pamięci jądra prowadzi do pułapki. W zasadzie możliwe
jest przechwycenie pułapki i emulacja odpowiednich działań, ale jest to kosztowne i zazwyczaj
wymaga instalowania odpowiednich procedur obsługi pułapek w jądrze hosta. Innym (oczywistym)
sposobem rozwiązania problemu dwóch królów jest modyfikacja konfiguracji systemu w taki
sposób, że system operacyjny hosta jest usuwany, a system-gość otrzymuje do dyspozycji całą
przestrzeń adresową. Jednak realizacja tego sposobu z przestrzeni użytkownika — co oczywiste —
także jest niemożliwa.
Hipernadzorca musi obsłużyć przerwania — np. gdy dysk wygeneruje przerwanie lub wystąpi
błąd strony. Ponadto, jeśli hipernadzorca chce skorzystać ze sposobu pułapka i emulacja w odnie-
sieniu do uprzywilejowanych instrukcji, musi mieć możliwość przechwycenia pułapek. Insta-
lowanie obsługi pułapki (przerwania) w jądrze przez procesy użytkownika nie jest możliwe.
Z tego względu większość nowoczesnych hipernadzorców typu 2 jest wyposażona w moduł
jądra działający w pierścieniu 0. Pozwala on wykonywać operacje sprzętowe z wykorzystaniem
uprzywilejowanych instrukcji. Oczywiście, manipulowanie sprzętem na najniższym poziomie
i udzielenie systemowi operacyjnemu gościa dostępu do pełnej przestrzeni adresowej jest dobre,
ale w pewnym momencie hipernadzorca musi po sobie posprzątać i przywrócić oryginalny kon-
tekst procesora. Załóżmy, że w czasie kiedy działa system operacyjny-gość, przychodzi prze-
rwanie z urządzenia zewnętrznego. Ponieważ hipernadzorcy typu 2 do obsługi przerwań wyko-
rzystują sterowniki urządzeń hosta, zachodzi potrzeba ponownego skonfigurowania sprzętu
w celu uruchomienia kodu systemu operacyjnego hosta. Gdy sterownik urządzenia zaczyna działać,
wszystko działa tak, jak system tego oczekuje. Zachowanie hipernadzorcy można porównać do
postępowania nastolatków, którzy urządzają przyjęcie, gdy wyjadą rodzice. Można dowolnie
poprzestawiać meble, o ile wszystko będzie poukładane tak, jak było, zanim rodzice powrócą
do domu. Przejście od konfiguracji sprzętowej dla jądra hosta do konfiguracji systemu operacyjnego
gościa określa się terminem przełączanie światów (ang. world switch). Mechanizm ten opiszemy
szczegółowo przy okazji omawiania systemu VMware w podrozdziale 7.12.
W tym momencie powinno być jasne, dlaczego hipernadzorcy typu 2 działają nawet na sprzęcie
bez mechanizmu wirtualizacji: wszystkie wrażliwe instrukcje są zastępowane przez wywołania
do procedur, które emulują te instrukcje. Żadne wrażliwe instrukcje wydawane przez system
operacyjny-gościa nigdy nie są wykonywane przez fizyczny sprzęt. Są one przekształcane na
wywołania hipernadzorcy, który następnie je emuluje.
są stosowane głębokie potoki i uruchamianie instrukcji nie po kolei, może zajmować wiele dzie-
siątek cykli. Do tej pory powinno być jasne, że jeśli system operacyjny-gość chce wyłączyć
przerwania, nie znaczy to, że hipernadzorca naprawdę powinien je wyłączyć dla całej maszyny.
Zatem hipernadzorca powinien wyłączyć je dla systemu operacyjnego-gościa, ale bez wyłączania
ich naprawdę. Aby to zrobić, może śledzić dedykowaną flagę IF (ang. Interrupt Flag — dosł.
flaga przerwań) na poziomie wirtualnej struktury danych procesora (dbając o to, aby do maszyny
wirtualnej nie docierały żadne przerwania do czasu ich ponownego włączenia). Każde wystą-
pienie instrukcji CLI w kodzie systemu operacyjnego-gościa zostanie zastąpione przez instrukcję
postaci VirtualCPU.IF = 0 — która jest bardzo „tanią” instrukcją przesunięcia zajmującą od
jednego do trzech cykli. Dzięki temu przetłumaczony kod jest szybszy. Pomimo to w przypadku
nowoczesnych mechanizmów VT zazwyczaj sprzęt ma przewagę nad oprogramowaniem.
Z drugiej strony, jeśli system operacyjny gościa modyfikuje swoje tabele stron, jest to bardzo
kosztowne. Problem polega na tym, że każdy system operacyjny-gość na maszynie wirtualnej
sądzi, że „jest właścicielem” maszyny i ma swobodę mapowania dowolnej strony wirtualnej na
fizyczną stronę w pamięci. Jednak jeśli jedna maszyna wirtualna chce użyć strony fizycznej, która
jest już używana przez inną maszynę wirtualną (lub hipernadzorcę), ktoś musi ustąpić. Jak dowiemy
się z podrozdziału 7.6, aby rozwiązać ten problem, można wprowadzić dodatkowy poziom tabel
stron w celu mapowania „fizycznych stron gościa” na właściwe fizyczne strony na hoście. Nic
dziwnego, że stosowanie wielu poziomów tabel stron nie jest tanie.
nimi nie jest podstawowa [Heiser et al., 2006]. Inni sugerują, że w porównaniu z mikrojądrami
hipernadzorcy nie nadają się do budowy bezpiecznych systemów, i twierdzą, że należałoby je
rozszerzyć o takie funkcje jądra jak przekazywanie komunikatów i współdzielenie pamięci
[Hohmuth et al., 2004]. Wreszcie niektórzy badacze twierdzą, że na temat hipernadzorców nie ma
nawet „prawidłowo przeprowadzonych badań” [Roscoe et al., 2007]. Ponieważ nikt nie powiedział
nic o prawidłowych (lub nieprawidłowych) podręcznikach o systemach (przynajmniej na razie),
uważamy, że robimy właściwie, analizując nieco dokładniej podobieństwa między hipernadzorcami
a mikrojądrami.
Głównym powodem, dla którego pierwsze systemy hipernadzorców emulowały kompletne
maszyny, był brak dostępności kodu źródłowego systemów operacyjnych-gości (np. Windows)
lub duża liczba odmian (np. Linux). Być może w przyszłości interfejs API hipernadzorcy (mikro-
jądra) zostanie ustandaryzowany, a kolejne systemy operacyjne będą projektowane tak, by korzy-
stały z tych wywołań, zamiast używać wrażliwych instrukcji. Dzięki temu technologia maszyn
wirtualnych będzie łatwiejsza w obsłudze i użytkowaniu.
Różnicę pomiędzy rzeczywistą wirtualizacją a parawirtualizacją zilustrowano na rysunku 7.4.
Można na nim zobaczyć dwie maszyny wirtualne obsługiwane na sprzęcie wyposażonym
w mechanizm VT. Z lewej strony widzimy niezmodyfikowaną wersję systemu Windows w roli
systemu operacyjnego-gościa. W momencie wykonania wrażliwej instrukcji sprzęt generuje
pułapkę do hipernadzorcy, a ten emuluje ją i zwraca sterowanie. Z prawej strony pokazano
zmodyfikowaną wersję Linuksa, która nie zawiera już żadnych wrażliwych instrukcji. Zamiast
tego, w momencie gdy musi wykonać operację wejścia-wyjścia lub zmodyfikować kluczowe
rejestry wewnętrzne (np. te, które wskazują na tablicę stron), wykonuje wywołanie hipernad-
zorcy. Pod tym względem działa podobnie do aplikacji korzystającej z wywołań systemowych
w standardowym systemie Linux.
ale staje się coraz bardziej jasne, że program działający w trybie jądra na „gołym sprzęcie”
powinien być niewielki i niezawodny oraz składać się z co najwyżej kilku tysięcy, a nie wielu milio-
nów linii kodu.
Parawirtualizacja systemów operacyjnych-gości stwarza szereg problemów. Po pierwsze:
czy wrażliwe instrukcje są zastępowane przez hiperwywołania oraz czy system operacyjny
działa na fizycznym sprzęcie? Ostatecznie sprzęt nie rozumie tych hiperwywołań. A po drugie:
co się stanie, jeśli na rynku będzie wiele systemów typu hipernadzorca — np. VMware, system
Xen typu open source pochodzący z Uniwersytetu Cambridge oraz Hyper-V Microsoft — i wszyst-
kie one będą wykorzystywały nieco zmodyfikowany zbiór wywołań API hipernadzorcy? Jak
można zmodyfikować jądro, aby działało na nich wszystkich?
Rozwiązanie zaproponowano w pracy [Amsden et al., 2006]. W tym modelu jądro zostało
zmodyfikowane w taki sposób, aby wywoływało specjalne procedury zawsze wtedy, kiedy trzeba
wykonać jakąś wrażliwą operację. Wspólnie te procedury są określane jako VMI (od ang. Virtual
Machine Interface) i tworzą warstwę niższego poziomu, która komunikuje się ze sprzętem lub
z hipernadzorcą. Zaprojektowano je tak, by były uniwersalne. Nie są związane ze sprzętem ani
z żadnym konkretnym hipernadzorcą.
Przykład tej techniki dla parawirtualizowanej wersji Linuksa, znanej jako VMI Linux (VMIL),
pokazano na rysunku 7.5. Kiedy system VMI Linux działa na „gołym sprzęcie”, trzeba go powią-
zać z rzeczywistymi (wrażliwymi) instrukcjami, które są wymagane do wykonania pracy w spo-
sób pokazany na rysunku 7.5(a). Podczas działania w systemie hipernadzorcy, np. VMware lub
Xen, system operacyjny-gość jest powiązany z różnymi bibliotekami, wykonującymi właściwe
(różne) wywołania odpowiedniego hipernadzorcy. W ten sposób rdzeń systemu operacyjnego
pozostaje przenośny, a hipernadzorca jest wygodny i w dalszym ciągu wydajny.
Rysunek 7.5. System VMI Linux działający (a) na „gołym sprzęcie”, (b) w systemie VMware,
(c) w systemie Xen
Opracowano także inne propozycje dla interfejsu maszyny wirtualnej. Popularnym podejściem
jest mechanizm paravirt ops. Idea jest koncepcyjnie podobna do tej, którą opisaliśmy powyżej,
choć różni się w szczegółach. Ogólnie rzecz biorąc, grupa dostawców Linuksa, obejmująca takie
firmy jak IBM, VMware, Xen i Red Hat, opowiada się za interfejsem dla Linuksa, który byłby
agnostyczny, jeśli chodzi o hipernadzorcę. Interfejs zawarty w głównej linii jądra, począwszy
od wersji 2.6.23, pozwala mu się komunikować z dowolnym hipernadzorcą zarządzającym fizycz-
nym sprzętem.
stron najwyższego poziomu oraz tablicę stron, na którą sam wskazuje, jako „tylko do odczytu”.
Kolejne próby zmodyfikowania dowolnych spośród tych informacji, podejmowane przez system
operacyjny-gościa, powodują błąd braku strony i przekazanie sterowania do hipernadzorcy.
Może on przeanalizować strumień instrukcji, ocenić, co system operacyjny-gość próbuje zrobić,
i odpowiednio zaktualizować tablice stron-cienie. Nie jest to rozwiązanie piękne, ale wykonalne.
Innym, równie niezgrabnym rozwiązaniem jest dokładnie odwrotne postępowanie. W tym
przypadku hipernadzorca po prostu pozwala systemowi operacyjnemu-gościowi swobodnie
dodawać nowe mapowania do jego tablicy stron. Kiedy to się dzieje, w tablicach stron-cieniach nic
się nie zmienia. W rzeczywistości hipernadzorca nawet nie jest tego świadomy. Jednak kiedy
tylko gość spróbuje uzyskać dostęp do dowolnych nowych stron, występuje błąd i sterowanie
powraca do hipernadzorcy. Funkcja hipernadzorcy sprawdza tablice stron gościa w celu stwier-
dzenia, czy istnieje mapowanie, które należy dodać. Jeśli tak, dodaje je i ponawia próbę uru-
chomienia instrukcji, która spowodowała błąd. Co się dzieje, gdy gość usunie mapowanie ze
swojej tablicy stron? Jest oczywiste, że hipernadzorca nie może czekać na wystąpienie błędu
strony, ponieważ taki błąd nie wystąpi. Usunięcie mapowania z tablicy stron jest realizowane
za pomocą instrukcji INVLPG (której rzeczywistym zadaniem jest unieważnienie wpisu w buforze
TLB). Dlatego hipernadzorca przechwytuje tę instrukcję i usuwa również mapowanie z tablicy
stron-cienia. Nie jest to zbyt piękne rozwiązanie, ale działa.
Obie te techniki powodują wiele błędów stron, a błędy stron są kosztowne. Zazwyczaj można
rozróżnić „normalne” błędy stron spowodowane przez programy systemu operacyjnego-gościa,
próbujące uzyskać dostęp do strony, która została usunięta z pamięci RAM, i błędy stron zwią-
zane z zapewnieniem synchronizacji pomiędzy tablicami stron-cieniami a tablicami stron gości.
Pierwsze to błędy stron wywołane przez gości (ang. guest-induced page faults). Chociaż zostały one
przechwycone przez hipernadzorcę, muszą być na nowo „wstrzyknięte” do gości. Taka operacja
nie jest wcale tania. Drugie to błędy stron wywołane przez hipernadzorcę (ang. hypervisor-induced
page faults). Są one obsługiwane poprzez aktualizację tabeli stron-cieni.
Błędy stron zawsze są kosztowne, ale szczególnie w środowiskach zwirtualizowanych, ponie-
waż prowadzą do tzw. wyjść VM (ang. VM exits). Zakończenie VM to sytuacja, w której hiper-
nadzorca odzyskuje sterowanie. Zastanówmy się, co musi zrobić procesor w celu obsługi wyj-
ścia VM. Po pierwsze rejestruje przyczynę wyjścia VM, aby hipernadzorca wiedział, co robić.
Rejestruje także adres instrukcji gościa, która spowodowała zakończenie. Następnie wykony-
wane jest przełączenie kontekstu, które obejmuje zapisanie wszystkich rejestrów. Potem ładowany
jest stan procesora hipernadzorcy. Dopiero wtedy może on rozpocząć obsługę błędu strony, co —
jak już wspominaliśmy — jest kosztowne. Aha, a kiedy to wszystko jest zrobione, należy wyko-
nać czynności wymienione wcześniej w odwrotnej kolejności. Cały proces może zająć wiele
dziesiątek tysięcy cykli lub więcej. Nic dziwnego, że podejmowane są usilne starania, by zmniej-
szyć liczbę wyjść.
W parawirtualnym systemie operacyjnym sytuacja jest inna. W tym przypadku parawirtu-
alizowany system operacyjny-gość wie, że kiedy skończy modyfikować tablicę stron pewnego
procesu, będzie musiał poinformować hipernadzorcę. W konsekwencji najpierw całkowicie zmienia
tablicę stron, a następnie wykonuje hiperwywołanie, informując go o nowej tablicy stron. Tak
więc zamiast uzyskania błędu zabezpieczeń przy każdej aktualizacji tablicy stron istnieje jedno
hiperwywołanie po zaktualizowaniu całości — jest to oczywiście bardziej wydajny sposób wyko-
nywania działań.
Rysunek 7.6. Rozszerzone (zagnieżdżone) tablice stron są przeglądane przy każdym dostępie
do fizycznego adresu gościa — włącznie z żądaniami każdego poziomu tablic stron gościa
Niestety, potrzeba przeglądania zagnieżdżonych tabel stron przez sprzęt może być częstsza,
niż można by oczekiwać. Załóżmy, że adresy wirtualne gości nie zostały zbuforowane i wymagają
pełnego przeglądania tablicy stron. Każdy poziom w hierarchii stronicowania wnosi konieczność
wyszukiwania w zagnieżdżonych tablicach stron. Innymi słowy, liczba odwołań do pamięci rośnie
w tempie wykładniczym wraz ze wzrostem głębokości hierarchii. Pomimo to zastosowanie EPT
znacznie zmniejsza liczbę wyjść VM. Hipernadzorcy nie muszą już mapować tablicy stron gościa
w trybie tylko do odczytu i mogą obyć się bez obsługi tablicy stron-cieni. Co więcej, przełączanie
maszyn wirtualnych powoduje jedynie zmianę mapowania w taki sam sposób, w jaki system
operacyjny zmienia mapowanie podczas przełączania procesów.
Odzyskiwanie pamięci
Istnienie wielu maszyn wirtualnych na tym samym sprzęcie fizycznym z własnymi stronami
pamięci oraz myśleniem, że wszystkie one są „królami gór”, jest w porządku — do chwili gdy
będziemy potrzebowali odzyskać pamięci. Jest to szczególnie ważne w przypadku stosowania
mechanizmu memory overcommitment, gdy hipernadzorca udaje, że całkowita ilość pamięci dla
wszystkich maszyn wirtualnych jest większa od całkowitej ilości pamięci fizycznej zainstalowanej
w systemie. Ogólnie rzecz biorąc, to jest dobra koncepcja, ponieważ dzięki niej hipernadzorca może
jednocześnie obsługiwać więcej mocniejszych maszyn wirtualnych. Przykładowo na komputerze
z 32 GB pamięci może uruchomić trzy maszyny wirtualne, z których każda „sądzi”, że ma do
dyspozycji 16 GB. Oczywiście suma się nie zgadza. Jednak być może wszystkie trzy maszyny
nie będą potrzebowały jednocześnie maksymalnej ilości pamięci fizycznej. Mogą one również
współdzielić strony, które mają taką samą treść (np. jądro Linuksa) w różnych maszynach wirtu-
alnych (w przypadku stosowania techniki optymalizacji zwanej deduplikacją). W takim przypadku
trzy maszyny wirtualne wykorzystują całkowitą ilość pamięci, która jest mniejsza niż 3 razy 16 GB.
Technikę deduplikacji omówimy w dalszej części tej książki. Na razie zapamiętajmy, że to, co
w danej chwili sprawia wrażenie dobrej dystrybucji pamięci, może być złą dystrybucją w sytuacji,
gdy wzrośnie obciążenie. Możliwe, że jedna maszyna wirtualna potrzebuje więcej pamięci,
natomiast drugiej wystarczy kilka stron. W takim przypadku byłoby dobrze, gdyby hipernadzorca
mógł przekazywać zasoby z jednej maszyny wirtualnej do drugiej. Byłoby to korzystne dla całego
systemu. Powstaje jednak pytanie: w jaki sposób można bezpiecznie zabrać strony pamięci, jeśli
ta pamięć już została przydzielona do maszyny wirtualnej?
W zasadzie można by wykorzystać jeszcze jeden poziom stronicowania. W razie braku pamięci
hipernadzorca mógłby wyrzucić z pamięci niektóre strony maszyny wirtualnej, na podobnej
zasadzie, jak system operacyjny może wyrzucać niektóre strony aplikacji. Wadą tego podejścia
jest to, że taką operację powinien przeprowadzić hipernadzorca, który nie ma pojęcia o tym, które
strony są najbardziej wartościowe dla gościa. Istnieje duże prawdopodobieństwo, że hipernadzorca
wyrzuci złą stronę. Nawet jeśli wybierze właściwe strony do wymiany (tzn. te, które wybrałby
również system operacyjny-gość), nadal występuje problem.
Załóżmy, że hipernadzorca wymienił stronę P. Nieco później system operacyjny-gość również
zdecydował się na wymianę tej strony z dyskiem. Niestety, przestrzeń wymiany hipernadzorcy
i przestrzeń wymiany systemu operacyjnego-gościa to nie to samo. Mówiąc inaczej, hipernad-
zorca musi załadować zawartość strony do pamięci tylko po to, aby przekonać się, że system
operacyjny-gość natychmiast wyrzuci tę stronę na dysk. Nie jest to zbyt wydajny sposób działania.
Popularnym rozwiązaniem tego problemu jest zastosowanie sztuczki zwanej balonikowaniem
(ang. balooning), polegającej na załadowaniu niewielkiego modułu-balona w każdej maszynie
wirtualnej jako pseudosterownika urządzenia, który komunikuje się z hipernadzorcą. Moduł
balonu może być „pompowany” na żądanie hipernadzorcy poprzez przydzielanie coraz większej
liczby stron i może być „wypuszczane z niego powietrze” poprzez cofanie przydziału tych stron.
Podczas pompowania balonu zwiększa się niedobór pamięci w systemie operacyjnym-gościa.
System operacyjny-gość zareaguje na tę sytuację wyrzuceniem z pamięci stron, które uzna za
najmniej wartościowe — czyli zrobi to, czego chcieliśmy. Z kolei w miarę „wypuszczania powie-
trza” z balonu coraz więcej pamięci staje się dostępne dla gościa. Innymi słowy, hipernadzorca
użył podstępu wobec systemu operacyjnego, aby skłonić go do podejmowania trudnych decyzji.
Takie działanie często jest stosowane w polityce. Polega na zrzucaniu odpowiedzialności na innych
(ang. passing the buck).
Domeny urządzeń
Inny sposób obsługi wejścia-wyjścia polega na dedykowaniu jednej z maszyn wirtualnych do
uruchamiania standardowego systemu operacyjnego i odbicie wszystkich wywołań wejścia-wyjścia
z innych maszyn wirtualnych na tę maszynę wirtualną. Ulepszony wariant takiego podejścia
występuje w przypadku użycia parawirtualizacji. W takiej sytuacji polecenie wydane hipernadzorcy
informuje o tym, czego chce system operacyjny-gość (np. czytaj blok 1403 z dysku 1). Nie musi
to być ciąg poleceń zapisujących informacje do rejestrów urządzenia, gdzie hipernadzorca ma
grać rolę Sherlocka Holmesa i próbować dociekać, co system operacyjny-gość chce zrobić. Takie
podejście wykonywania operacji wejścia-wyjścia zastosowano w systemie Xen. Maszyna wirtu-
alna realizująca wejście-wyjście w tym systemie jest określana jako domena 0.
Wirtualizacja wejścia-wyjścia jest obszarem, w którym hipernadzorca typu 2 ma praktyczną
przewagę nad hipernadzorcami typu 1: system operacyjny-gospodarz zawiera sterowniki urzą-
dzeń dla wszystkich urządzeń wejścia-wyjścia dołączonych do komputera. Kiedy aplikacja próbuje
uzyskać dostęp do dziwnego urządzenia wejścia-wyjścia, tłumaczony kod może wywołać istniejący
sterownik urządzenia w celu wykonania pracy. W przypadku hipernadzorcy typu 1 albo on sam musi
zawierać sterownik, albo powinien wywołać sterownik w domenie 0, co w pewnym stopniu przypo-
mina system operacyjny-gospodarza. Można się spodziewać, że kiedy technologia maszyn wirtu-
alnych się rozwinie, to w przyszłości będzie pozwalała programom aplikacyjnym na bezpośrednie
korzystanie ze sprzętu w bezpieczny sposób. Oznacza to, że sterowniki urządzeń będą mogły być
bezpośrednio połączone z kodem aplikacji lub umieszczone na oddzielnych serwerach pracujących
w trybie użytkownika (tak jak w systemie MINIX 3). To powinno wyeliminować problem.
Wirtualizacja SR-IOV
Bezpośrednio przypisanie urządzenia do maszyny wirtualnej nie jest zbyt skalowalne. W przy-
padku czterech fizycznych sieci w ten sposób można obsługiwać nie więcej niż cztery maszyny
wirtualne. Aby obsłużyć osiem maszyn wirtualnych, potrzeba ośmiu kart sieciowych, a żeby
uruchomić 128 maszyn wirtualnych — cóż, wystarczy powiedzieć, że trudno byłoby nam znaleźć
komputer schowany za tymi wszystkimi kablami sieciowymi.
Współdzielenie urządzeń pomiędzy wielu hipernadzorców w oprogramowaniu jest możliwe,
ale często nie jest optymalne, ponieważ warstwa emulacji (lub domena urządzeń) umieszcza się
pomiędzy sprzętem, sterownikami a systemami operacyjnymi-gośćmi. Emulowane urządzenie
często nie implementuje wszystkich zaawansowanych funkcji obsługiwanych przez sprzęt. Byłoby
idealnie, gdyby technologia wirtualizacji oferowała mechanizm równoważny z przekazywaniem
urządzeń. Dzięki temu byłoby możliwe przekazanie pojedynczego urządzenia do wielu hipernad-
zorców bez żadnych kosztów. Wirtualizacja jednego urządzenia w taki sposób, by wszystkie
maszyny wirtualne „sądziły”, że mają wyłączny dostęp do własnego urządzenia, jest znacznie
łatwiejsze, jeśli to sprzęt zrealizuje wirtualizację. Na magistrali PCIe taka technologia nazywa się
wirtualizacją SR-IOV (ang. Single Root I/O Virtualization).
Wirtualizacja SR-IOV pozwala na pominięcie udziału hipernadzorcy w komunikacji pomiędzy
sterownikiem a urządzeniem. Urządzenia, które obsługują SR-IOV, zapewniają niezależną prze-
strzeń w pamięci, przerwania i strumienie DMA każdej maszynie wirtualnej korzystającej
z urządzenia [Intel, 2011]. Urządzenie wygląda jak kilka oddzielnych urządzeń. Każde może być
skonfigurowane przez oddzielne maszyny wirtualne; np. każde ma oddzielny rejestr adresu
bazowego i osobną przestrzeń adresową. Maszyna wirtualna odwzorowuje jeden z tych obszarów
pamięci (używany np. do skonfigurowania urządzenia) na swoją przestrzeń adresową.
Technologia SR-IOV zapewnia dostęp do urządzenia na dwa sposoby: PF (ang. Physical Func-
tions — dosł. funkcje fizyczne) oraz VF (ang. Virtual Functions — dosł. funkcje wirtualne). PF
są w całości funkcjami PCIe. Umożliwiają konfigurowanie urządzenia w sposób, w jaki admini-
strator uważa za stosowne. Funkcje fizyczne nie są dostępne dla systemów operacyjnych-gości.
VF to lekkie funkcje PCIe, które nie oferują takich możliwości konfiguracji. Idealnie nadają się do
maszyn wirtualnych. Podsumowując, technologia SR-IOV pozwala urządzeniom na wirtualizację
setek funkcji wirtualnych. Dzięki nim maszyny wirtualne sądzą, że są wyłącznym właścicielem
urządzenia. W przypadku np. interfejsu sieciowego obsługującego technologię SR-IOV maszyna
wirtualna może obsługiwać swoją wirtualną kartę sieciową tak, jakby to była karta fizyczna. Co
więcej, wiele nowoczesnych kart sieciowych jest wyposażonych w oddzielne (cykliczne) bufory
do wysyłania i odbierania danych, dedykowane do maszyn wirtualnych. Przykładowo karty sieciowe
serii Intel I350 są wyposażone w osiem kolejek wysyłki oraz osiem kolejek odbiorczych.
Maszyny wirtualne oferują interesujące rozwiązanie problemu, który od dawna nękał użyt-
kowników, zwłaszcza tych korzystających z oprogramowania open source: w jaki sposób instalować
nowe aplikacje? Problem polega na tym, że wiele aplikacji zależy od różnorodnych innych aplikacji
i bibliotek, a te z kolei same są zależne od hosta innych pakietów oprogramowana itd. Co więcej,
istnieje wiele zależności od konkretnych wersji kompilatorów, języków skryptowych oraz sys-
temów operacyjnych.
Dzięki obecnie dostępnym maszynom wirtualnym programista może skonstruować maszynę
wirtualną dokładnie według potrzeb — załadować ją wymaganym systemem operacyjnym, kom-
pilatorami, bibliotekami i kodem aplikacji, a następnie zamrozić całą jednostkę gotową do działania.
Ten obraz maszyny wirtualnej może być następnie umieszczony na płycie CD-ROM lub serwisie
WWW, skąd użytkownicy mogą go pobrać i zainstalować. Takie podejście oznacza, że tylko twórca
oprogramowania musi rozumieć wszystkie zależności. Klienci otrzymują kompletny pakiet —
działający i całkowicie niezależny od systemu operacyjnego, w którym działa, oraz od innego
zainstalowanego oprogramowania, pakietów i bibliotek. Te „szyte na miarę” maszyny wirtualne
często są nazywane urządzeniami wirtualnymi. Dla przykładu w chmurze Amazon EC2 dostęp-
nych jest wiele gotowych wirtualnych urządzeń oferujących klientom wygodne usługi progra-
mowe (oprogramowanie jako usługa — ang. Software as a Service — SaaS).
nieuprawnionej maszynie wirtualnej. Dla firm, które uruchamiają całe swoje oprogramowanie
wyłącznie na maszynach wirtualnych, może to być poważny problem. Pozostaje zagadką, czy
takie zastrzeżenia mają znaczenie dla sądu oraz w jaki sposób odpowiedzą na nie użytkownicy.
stycznego oprogramowania, np. Microsoft Office 365 lub Google Apps, a także wiele innych
rodzajów AAS (ang. as a Service). Jednym z przykładów chmury IAAS jest Amazon EC2 —
usługa bazująca na hipernadzorcy Xen, która obejmuje wiele setek tysięcy maszyn fizycznych.
Wystarczy dysponować odpowiednią sumą pieniędzy, aby mieć tyle mocy obliczeniowej, ile trzeba.
Chmury mogą zmienić sposób, w jaki w firmach są wykorzystywane komputery. Ogólnie
rzecz biorąc, konsolidacja zasobów obliczeniowych w niewielkiej liczbie miejsc (dogodnie zlo-
kalizowanych w pobliżu tanich źródeł zasilania i chłodzenia) przynosi korzyści ekonomiczne.
Outsourcing przetwarzania oznacza, że nie trzeba martwić się zbytnio zarządzaniem infrastruk-
turą IT, kopiami zapasowymi, konserwacją, amortyzacją, skalowalnością, niezawodnością, wydaj-
nością ani zabezpieczeniami. Wszystko to jest robione w jednym miejscu i — jeśli założymy, że
dostawca chmury jest kompetentny — wykonywane dobrze. Można by pomyśleć, że menedże-
rowie IT są dziś szczęśliwsi niż 10 lat temu. Kiedy jednak znikają jedne zmartwienia, pojawiają
się nowe. Czy naprawdę możemy ufać dostawcy chmury, że nasze poufne dane są bezpieczne?
Czy konkurent korzystający z tej samej infrastruktury nie zdoła wywnioskować informacji,
które chcielibyśmy zachować w tajemnicy? Jakie prawo ma zastosowanie do danych? (Jeśli np.
dostawca usług w chmurze jest ze Stanów Zjednoczonych, to czy dane podlegają ustawie PATRIOT
nawet wtedy, gdy firma będąca właścicielem danych ma siedzibę w Europie?) Czy po umieszczeniu
wszystkich danych w chmurze X będziemy mogli pobrać je ponownie, czy też będziemy przy-
wiązani do tej chmury i jej dostawcy na zawsze? (To coś, co określa się jako blokadę dostawcy).
1
Zdolność do przydzielania większej liczby zasobów, niż jest fizycznie dostępnych — przyp. tłum.
Od 1999 roku firma VMware jest wiodącym dostawcą komercyjnych rozwiązań wirtualizacji.
Dostarcza produkty przeznaczone do komputerów stacjonarnych, serwerów, chmury, a teraz
nawet telefonów komórkowych. Oferuje nie tylko hipernadzorców, ale również oprogramowanie,
które zarządza maszynami wirtualnymi na dużą skalę.
Niniejsze studium przypadku zaczniemy od krótkiej historii firmy. Następnie omówimy
system VMware Workstation — hipernadzorcę typu 2 i pierwszy produkt firmy. Opiszemy
wyzwania w jego konstrukcji oraz kluczowe elementy rozwiązania, a także ewolucję, jaką prze-
szedł system VMware Workstation przez lata swojego istnienia. Na koniec zamieścimy opis
systemu ESX Server — hipernadzorcy typu 1 firmy VMware.
Rysunek 7.7. Składniki wysokiego poziomu monitora maszyny wirtualnej VMware (w przypadku
braku obsługi sprzętowej)
Oczywiście, istnieją pewne powikłania i subtelności. Ważnym aspektem projektu jest zapew-
nienie integralności piaskownicy wirtualizacji, czyli zadbanie o to, aby żadne oprogramowanie
działające wewnątrz maszyny wirtualnej (w tym oprogramowanie złośliwe) nie mogło mani-
pulować modułem VMM. Ten problem jest powszechnie znany jako izolacja awarii oprogramo-
wania (ang. software fault isolation). Jeśli rozwiązanie jest zaimplementowane w oprogramo-
waniu, to wprowadza ono narzut czasowy do każdego dostępu do pamięci. W tym przypadku
również firma VMware wykorzystała dla swojego modułu VMM podejście sprzętowe. Polega
ono na podzieleniu przestrzeni adresowej na dwa rozłączne obszary. VMM rezerwuje sobie na
własny użytek do 4 MB przestrzeni adresowej. Powoduje to zwolnienie pozostałej części (czyli
4 GB – 4 MB, ponieważ mówimy o architekturze 32-bitowej) do wykorzystania przez maszynę
wirtualną. Następnie moduł VMM konfiguruje segmentację sprzętu tak, aby żadne instrukcje
maszyny wirtualnej (włącznie z tymi, które są generowane przez tłumacza binarnego) nie mogły
uzyskać dostępu do górnego, 4-megabajtowego regionu przestrzeni adresowej.
Tabela 7.2. Opcje konfiguracji wirtualnego sprzętu w pierwszych wersjach systemu VMware
Workstation (około 2000 roku)
Wirtualny sprzęt (fronton) Zaplecze
Zwielo- 1 wirtualny procesor x86 o tych samym Szeregowane przez system operacyjny hosta
krotnianie rozszerzeniach zestawu instrukcji co procesor w środowisku jednoprocesorowym
sprzętowy lub wieloprocesorowym
Do 512 MB ciągłego obszaru pamięci DRAM Alokowane i zarządzane przez system
operacyjny-gospodarza (strona po stronie)
Po drugie z punktu widzenia użytkownika produkt można było zainstalować tak jak zwykłą apli-
kację, dzięki czemu jego przyjęcie było łatwiejsze. Podobnie jak w przypadku zwykłych aplikacji,
program instalacyjny systemu VMware Workstation po prostu zapisuje pliki swoich komponen-
tów do istniejącego hosta systemu plików, bez konieczności zmian w konfiguracji sprzętowej
(formatowania dysku, tworzenia partycji dysku lub zmian ustawień BIOS-u). W rzeczywistości
system VMware Workstation mógł być zainstalowany i pozwalał na uruchamianie maszyn
wirtualnych nawet bez konieczności restartu systemu operacyjnego — przynajmniej na hostach
działających w systemie Linux.
Jednak zwykła aplikacja nie ma haków i interfejsów API wymaganych przez hipernadzorcę
do zwielokrotnienia zasobów procesora i pamięci, co jest niezbędne, by zapewnić wydajność
bliską natywnej. W szczególności podstawowa technologia wirtualizacji platformy x86, opisana
powyżej, działa tylko wtedy, gdy moduł VMM działa w trybie jądra i pozwala dodatkowo kon-
trolować wszystkie aspekty procesora bez żadnych ograniczeń. Dotyczy to również zdolności
do zmiany przestrzeni adresowej (tworzenia tablic stron-cieni) w celu modyfikacji tablic segmen-
tów, a także do zmiany wszystkich przerwań i procedur obsługi wyjątków.
Sterownik urządzenia ma bardziej bezpośredni dostęp do sprzętu, szczególnie jeśli działa
w trybie jądra. Chociaż mógłby (teoretycznie) wydawać dowolne instrukcje uprzywilejowane,
w praktyce od sterownika urządzenia oczekuje się interakcji ze swoim systemem operacyjnym
przy użyciu dobrze zdefiniowanych interfejsów API i nie ma (i nigdy nie powinno być) potrzeby
swobodnego konfigurowania sprzętu. I chociaż hipernadzorcy wymagają masowej rekonfiguracji
sprzętu (włącznie z całą przestrzenią adresową, tablicami segmentów, obsługą wyjątków
i przerwaniami), uruchamianie hipernadzorców w postaci sterowników urządzeń również było
nierealistyczne.
Ponieważ żadne z tych założeń nie jest obsługiwane przez systemy operacyjne-gospodarzy,
uruchomienie hipernadzorcy w formie sterownika urządzenia (w trybie jądra) również nie wcho-
dziło w rachubę.
Te rygorystyczne wymagania doprowadziły do rozwoju platformy VMware Hosted Architecture.
W tej architekturze, jak pokazano na rysunku 7.8, oprogramowanie jest podzielone na trzy odrębne
i niezależne komponenty.
Rysunek 7.8. Platforma VMware Hosted Architecture i jej trzy komponenty: VMX, sterownik
VMM i moduł VMM
Uważni Czytelnicy pewnie się zastanawiają: co z przestrzenią adresową jądra systemu ope-
racyjnego-gościa? Otóż jest ona po prostu częścią przestrzeni adresowej maszyny wirtualnej
i występuje podczas działania w kontekście VMM. W związku z tym system operacyjny-gość
może używać całej przestrzeni adresowej, a w szczególności tych samych lokalizacji w pamięci
wirtualnej co system operacyjny-gospodarz. Dokładnie tak się dzieje, gdy system operacyjny-gość
i system operacyjny-gospodarz są takie same (np. oba są systemem Linux). Oczywiście to
wszystko „po prostu działa” ze względu na dwa niezależne konteksty oraz przełączanie świata
pomiędzy nimi.
Oto kolejne pytanie, jakie się nasuwa: co z obszarem VMM na samym wierzchołku przestrzeni
adresowej? Jak wspominaliśmy wcześniej, jest ona zarezerwowana dla modułu VMM, a te części
przestrzeni adresowej nie mogą być bezpośrednio wykorzystywane przez maszynę wirtualną.
Na szczęście ta niewielka, 4-megabajtowa część nie jest często wykorzystywana przez systemy
operacyjne-gości, ponieważ każdy dostęp do tego fragmentu pamięci musi być indywidualnie
emulowany i wprowadza widoczny narzut programowy.
Powróćmy na chwilę do rysunku 7.8: pokazano na nim różne czynności, które występują
w czasie, kiedy nadejdzie przerwanie dyskowego podczas działania modułu VMM (krok (i)).
Oczywiście moduł VMM nie może obsłużyć przerwania, ponieważ nie ma sterownika urządzenia
zaplecza. W kroku (ii) moduł VMM wykonuje przełączenie świata z powrotem do systemu
operacyjnego-gospodarza. W szczególności kod realizujący przełączanie świata zwraca stero-
wanie do sterownika VMware, który w kroku (iii) emuluje to samo przerwanie, wygenerowane
przez dysk. Zatem w kroku (iv) procedura obsługi przerwania systemu operacyjnego-gospodarza
uruchamia swoją logikę, jakby przerwanie dyskowe miało miejsce w czasie, gdy działa sterownik
VMware (ale nie VMM!). Na koniec, w kroku (v), sterownik VMware zwraca sterowanie do
aplikacji VMX. W tym momencie system operacyjny-gospodarz może zaplanować działanie innego
procesu lub kontynuować działanie procesu VMX platformy VMware. Jeśli proces VMX kon-
tynuuje działanie, to następnie wznowi działanie maszyny wirtualnej poprzez specjalne wywo-
łanie do sterownika urządzenia, który wygeneruje przełączenie świata z powrotem do kontekstu
VMM. Jak można zauważyć, jest to sprytna sztuczka, dzięki której cały moduł VMM i maszyna
wirtualna są ukryte przed systemem operacyjnym-gospodarzem. Co ważniejsze, zapewnia
modułowi VMM pełną swobodę do przeprogramowania sprzętu w taki sposób, jaki uzna za
stosowny.
ESX Server zawiera standardowy podsystem dostępny w systemie operacyjnym, taki jak program
szeregujący procesora, menedżera pamięci oraz podsystem wejścia-wyjścia, przy czym każdy
podsystem jest zoptymalizowany do uruchamiania maszyn wirtualnych.
Brak systemu operacyjnego-gospodarza stworzył potrzebę bezpośredniego rozwiązania
opisanych wcześniej problemów różnorodności peryferii oraz wygody użytkownika. W celu
rozwiązania problemu różnorodności peryferii firma VMware wprowadziła ograniczenie dla sys-
temu ESX Server, by mógł działać tylko na dobrze znanych i certyfikowanych platformach
serwerowych, dla których były dostępne sterowniki urządzeń. Jeśli chodzi o wygodę użytkow-
ników, system ESX Server (w przeciwieństwie do VMware Workstation) wymagał od nich
zainstalowania obrazu nowego systemu na partycji rozruchowej.
Mimo wad kompromis był sensowny w przypadku dedykowanych instalacji wirtualizacji
w centrach danych składających się z setek lub tysięcy serwerów fizycznych i często (wielu)
tysięcy maszyn wirtualnych Takie instalacje są dziś czasami określane jako prywatne chmury.
W takich środowiskach architektura ESX Server zapewnia istotne korzyści, jeśli chodzi o wydaj-
ność, skalowalność, możliwości zarządzania i mnogość funkcji. Przykładowo:
1. Program szeregujący procesora zapewnia, aby każda maszyna wirtualna uzyskała spra-
wiedliwą część zasobów procesora (w celu uniknięcia zagłodzenia). Jest również zapro-
jektowany tak, aby działanie różnych procesorów wirtualnych danej wieloprocesorowej
maszyny wirtualnej było zaplanowane w tym samym czasie.
2. Menedżer pamięci jest zoptymalizowany pod kątem skalowalności, w szczególności
w celu zapewnienia możliwości wydajnego uruchomienia maszyn wirtualnych nawet wtedy,
gdy potrzebują więcej pamięci, niż jest rzeczywiście dostępne w komputerze. Aby osią-
gnąć taki efekt, w systemie ESX Server po raz pierwszy wprowadzono pojęcie baloni-
kowania oraz przezroczystego współdzielenia stron dla maszyn wirtualnych [Waldspur-
ger, 2002].
3. Podsystem wejścia-wyjścia jest zoptymalizowany pod kątem wydajności. Mimo że sys-
temy VMware Workstation i ESX Server często współdzielą te same komponenty emu-
lacji w obrębie frontonów, zaplecza są zupełnie inne. W przypadku VMware Workstation
wszystkie operacje wejścia-wyjścia przechodzą przez system operacyjny-gospodarza
i jego API, które często wprowadza dodatkowy narzut. Sprawdza się to zwłaszcza w przy-
padku urządzeń obsługi sieci i pamięci masowej. W przypadku systemu ESX Server te
sterowniki urządzeń działają bezpośrednio w ramach hipernadzorcy ESX, bez koniecz-
ności przełączania świata.
4. Zaplecza zazwyczaj bazowały na abstrakcjach dostarczonych przez system operacyjny
hosta. I tak w systemie VMware Workstation obrazy maszyn wirtualnych są przechowy-
wane jako standardowe (choć bardzo duże) pliki w systemie plików hosta. Natomiast ESX
Proces może wejść w tryb Dune — nieodwracalne przejścia, które daje niskopoziomowy dostęp
do sprzętu. Niemniej jednak nadal jest to proces, który może komunikować się z jądrem i z niego
korzystać. Jedyną różnicą jest to, że korzysta z instrukcji VMCALL do wykonywania wywołań
systemowych.
PYTANIA
1. Podaj powód, dlaczego wirtualizacja może być interesująca w centrach danych.
2. Podaj powód, dlaczego firma może być zainteresowana uruchomieniem hipernadzorcy
na komputerze, który był używany przez pewien czas.
3. Uzasadnij, dlaczego deweloper oprogramowania może korzystać z wirtualizacji na kom-
puterze desktop wykorzystywanym do rozwijania aplikacji.
4. Podaj powód, dlaczego wirtualizacja może być interesująca dla indywidualnych użytkow-
ników domowych.
5. Dlaczego Twoim zdaniem minęło tak wiele czasu, zanim wirtualizacja stała się popularna?
Ostatecznie najważniejszy artykuł został napisany w 1974 roku, a komputery mainframe
już w latach siedemdziesiątych były wyposażone w niezbędny sprzęt i oprogramowanie.
6. Wymień dwa rodzaje instrukcji, które są wrażliwe w sensie Popka i Goldberga.
7. Wymień trzy instrukcje maszynowe, które nie są wrażliwe w sensie Popka i Goldberga.
8. Jaka jest różnica między pełną wirtualizacją a parawirtualizacją? Która Twoim zdaniem
jest trudniejsza do zaimplementowania? Uzasadnij odpowiedź.
9. Czy parawirtualizacja systemu operacyjnego ma sens, jeśli jest dostępny kod źródłowy?
A co zrobić, jeśli nie jest dostępny?
10. Rozważmy hipernadzorcę typu 1, który może obsłużyć maksymalnie n maszyn wirtual-
nych w tym samym czasie. Komputery PC mogą mieć maksymalnie cztery podstawowe
partycje dysku. Czy wartość n może być większa niż 4? Jeśli tak, to gdzie można skła-
dować dane?
11. Objaśnij krótko pojęcie wirtualizacji na poziomie procesu.
12. Dlaczego istnieją hipernadzorcy typu 2? Przecież nie pełnią żadnych funkcji, których
nie mogą pełnić hipernadzorcy typu 1 (ogólnie rzecz biorąc, bardziej wydajne).
13. Czy wykorzystanie wirtualizacji ma jakikolwiek sens dla hipernadzorców typu 2?
14. Dlaczego opracowano tłumaczenie binarne? Czy uważasz, że ta technologia ma przyszłość?
Uzasadnij odpowiedź.
15. Wyjaśnij, jak można wykorzystać cztery pierścienie ochrony platformy x86 do obsługi
wirtualizacji.
16. Podaj jeden powód, dlaczego podejście sprzętowe z zastosowaniem procesorów korzy-
stających z CPU może być mało wydajne w porównaniu z podejściem bazującym na
tłumaczeniu oprogramowania.
17. Podaj jeden przypadek, w którym przetłumaczony kod może być szybszy niż oryginalny
kod w systemie korzystającym z techniki tłumaczenia binarnego.
18. System VMware wykonuje binarne tłumaczenie jednego podstawowego bloku na raz.
Następnie uruchamia ten blok i rozpoczyna tłumaczenie następnego. Czy mógłby prze-
tłumaczyć cały program zawczasu, a potem go uruchomić? Jeśli tak, to jakie są zalety
i wady każdej z technik?
521
z „normalną” szybkością (cokolwiek miałoby to znaczyć w danej chwili), ale wspólnie mają znacznie
większą moc obliczeniową niż pojedynczy komputer. Obecnie na rynku są już dostępne kompu-
tery zawierające dziesiątki tysięcy procesorów. Systemy z milionem procesorów „na pokładzie”
już się buduje w laboratoriach [Furber et al., 2013]. O ile możliwe są inne potencjalne sposoby
uzyskiwania większej szybkości — np. komputery biologiczne — o tyle w tym rozdziale skoncen-
trujemy się na systemach z wieloma konwencjonalnymi procesorami.
Komputery równoległe są często wykorzystywane do wykonywania intensywnych obliczeń
numerycznych. Takie problemy, jak prognozowanie pogody, modelowanie przepływu powietrza
wokół skrzydła samolotu, symulacja procesów gospodarczych czy też zrozumienie interakcji leków
z receptorami w ludzkim mózgu, to zadania wymagające bardzo intensywnych obliczeń. Roz-
wiązanie tego rodzaju problemów wymaga długich przebiegów wielu procesorów na raz. Sys-
temy wieloprocesorowe omówione w niniejszym rozdziale są powszechnie używane m.in. dla tych
i podobnych problemów w nauce i inżynierii.
Na uwagę zasługuje również niezwykły rozwój internetu. Pierwotnie zaprojektowano go jako
prototyp odpornego na błędy wojskowego systemu dowodzenia, później zyskał popularność
w akademickich ośrodkach komputerowych, a jeszcze później zaczęto go wykorzystywać w wielu
innych dziedzinach. Jedną z tych dziedzin jest połączenie mocy obliczeniowej tysięcy komputerów
na całym świecie w celu wspólnego rozwiązywania skomplikowanych problemów naukowych. Pod
pewnymi względami system składający się z 1000 komputerów rozproszonych po całym świecie
nie różni się zbytnio od jednego systemu złożonego z 1000 komputerów umieszczonych w jednym
pokoju. Różne są jedynie opóźnienia oraz inne charakterystyki techniczne. O takich systemach
także opowiemy w tym rozdziale.
Umieszczenie miliona niepołączonych ze sobą komputerów w pokoju jest dość łatwe, pod
warunkiem że dysponujemy odpowiednią kwotą pieniędzy i wystarczająco dużym pokojem. Roz-
proszenie miliona komputerów po całym świecie okazuje się jeszcze łatwiejsze, ponieważ
w tym przypadku drugi problem rozwiązuje się sam. Problem zaczyna się wtedy, kiedy chcemy,
aby komputery te komunikowały się ze sobą w celu wspólnego rozwiązywania pojedynczego
problemu. W związku z tym prowadzi się liczne prace nad technologią połączeń. Z kolei różne
technologie połączeń doprowadziły do powstania jakościowo różnych systemów oraz różnych
organizacji programowych.
Komunikacja pomiędzy komponentami elektronicznymi (lub optycznymi) zawsze sprowadza
się do przesyłania pomiędzy nimi komunikatów — ściśle zdefiniowanych ciągów bitów. Wystę-
pujące różnice dotyczą skali czasowej, skali odległości oraz organizacji logicznej. Na jednym eks-
tremum są systemy wieloprocesorowe ze współdzieloną pamięcią, w których od dwóch do około
1000 procesorów komunikuje się za pośrednictwem współdzielonej pamięci. W tym modelu
każdy procesor ma równy dostęp do całej fizycznej pamięci i może czytać oraz zapisywać indywi-
dualne słowa za pomocą instrukcji LOAD i STORE. Dostęp do słowa pamięci zazwyczaj zajmuje
od 1 do 10 ns. Jak się dowiemy, obecnie powszechnie umieszcza się więcej niż jeden rdzeń
w pojedynczym chipie procesora. Kilka rdzeni współdzieli dostęp do pamięci głównej (czasami
nawet współużytkują pamięć podręczną). Innymi słowy, model systemu wielokomputerowego
ze współdzieloną pamięcią można zaimplementować, używając fizycznie odrębnych procesorów,
wielu rdzeni jednego procesora lub kombinacji powyższych sposobów. Chociaż ten model (poka-
zany na rysunku 8.1(a)) wydaje się prosty, faktyczna implementacja nie jest już tak prosta. Zwykle
wymaga przekazywania wielu komunikatów, co wkrótce wyjaśnimy. Tymczasem wspomniane
przekazywanie komunikatów jest niewidoczne dla programistów.
Kolejny model pokazano na rysunku 8.1(b). W tym przypadku pewna liczba par procesor –
– pamięć jest połączona ze sobą za pomocą szybkiego łącza. Taki rodzaj systemu to tzw. wielokom-
puter z przekazywaniem komunikatów (ang. message-passing multicomputer). Każdy procesor ma
lokalną pamięć, do której tylko on ma dostęp. Procesory komunikują się ze sobą, przesyłając
komunikaty poprzez łącze transmisyjne. Przy dobrym łączu krótkie komunikaty mogą być prze-
syłane w ciągu 10 – 50 μs. Pomimo wszystko jest to znacznie dłużej, niż wynosi czas dostępu
do pamięci w systemie z rysunku 8.1(a). W tym układzie nie występuje współdzielona, globalna
pamięć. Wielokomputery (tzn. systemy przekazywania komunikatów) są znacznie łatwiejsze do
tworzenia od systemów wieloprocesorowych (bazujących na współdzielonej pamięci), ale oka-
zują się znacznie trudniejsze do programowania. W związku z tym każdy rodzaj systemów ma
swoich fanów.
Trzeci model, pokazany na rysunku 8.1(c), łączy kompletne systemy komputerowe przez
sieć rozległą, np. internet, i tworzy w ten sposób system rozproszony. Każdy system ma swoją
pamięć, a poszczególne systemy komunikują się pomiędzy sobą poprzez przekazywanie komuni-
katów. Jedyna faktyczna różnica pomiędzy systemami z rysunku 8.1(b) i 8.1(c) polega na tym, że
w tym drugim występują kompletne komputery, a czas przekazywania komunikatów często wynosi
10 – 100 ms. Ze względu na tak duże opóźnienia luźno związane systemy w rodzaju tego, który
pokazano na rysunku 8.1(c), muszą być wykorzystywane inaczej niż ściśle związane systemy
podobne do tych, które pokazano na rysunku 8.1(b). Trzy wspomniane typy systemów różnią się
opóźnieniami o mniej więcej trzy rzędy wielkości. Podobna różnica występuje pomiędzy jednym
dniem a trzema latami.
Ten rozdział składa się z czterech głównych podrozdziałów odpowiadających trzem modelom
z rysunku 8.1. Dodatkowo jeden podrozdział poświęcono wirtualizacji — programowemu spo-
sobowi stwarzania iluzji występowania większej liczby procesorów. Każdy podrozdział rozpocz-
niemy od zwięzłego wprowadzenia w tematykę dotyczącą sprzętu. Następnie przejdziemy do
oprogramowania. Omówimy przede wszystkim problemy występujące w systemach operacyjnych
systemów takiego typu. Jak się przekonamy, w każdym przypadku występują inne problemy
i wymagane jest inne podejście.
Rysunek 8.2. Trzy rodzaje wieloprocesorów bazujących na magistrali: (a) bez pamięci
podręcznej; (b) z pamięcią podręczną; (c) z pamięcią podręczną i pamięciami prywatnymi
Jeśli w momencie, gdy procesor chce odczytać lub zapisać pamięć, magistrala jest zajęta, czeka
do czasu, aż stanie się znów wolna. W tym właśnie tkwi problem z takim projektem. Dla dwóch
lub trzech procesorów rywalizacja o magistralę jest możliwa do zarządzania. W przypadku gdy
jest ich 32 lub 64, staje się to niemożliwe. System jest ograniczony przepustowością magistrali,
a większość procesorów przez znaczny czas będzie bezczynna.
Rozwiązaniem tego problemu może być dodanie pamięci podręcznej do każdego procesora,
tak jak pokazano na rysunku 8.2(b). Pamięć podręczna może być umieszczona wewnątrz układu
procesora, obok układu procesora, na płycie procesora lub w dowolnej konfiguracji wszystkich
trzech możliwości. Ponieważ z lokalnej pamięci podręcznej można teraz obsłużyć wiele operacji
odczytu, na magistrali będzie mniejszy ruch, a system będzie w stanie obsłużyć więcej procesorów.
Ogólnie rzecz biorąc, buforowanie nie jest wykonywane na poziomie indywidualnych słów, ale
na poziomie bloków o rozmiarze 32 lub 64 bajtów. Gdy następuje odwołanie do słowa, do pamięci
podręcznej procesora, który żąda do niego dostępu, trafia cały blok nazywany wierszem pamięci
podręcznej (ang. cache line).
Każdy blok pamięci jest oznaczony jako tylko do odczytu (wtedy może występować w wielu
pamięciach podręcznych jednocześnie) lub do odczytu i zapisu (wtedy nie może występować
w żadnych innych pamięciach podręcznych). Jeśli procesor próbuje dokonać zapisu słowa znaj-
dującego się w jednej pamięci podręcznej lub kilku takich pamięciach, sprzęt magistrali wykrywa
zapis i umieszcza sygnał na magistrali, informując o zapisie wszystkie inne pamięci podręczne.
Jeśli inne pamięci podręczne dysponują „czystą” kopią — czyli dokładną kopią tego, co jest
w pamięci — mogą odrzucić swoje kopie i zlecić procesorowi zapisującemu pobranie bloku
z pamięci przed jego zmodyfikowaniem. Jeśli jakaś inna pamięć podręczna dysponuje „brudną”
(tzn. zmodyfikowaną) kopią, to albo musi najpierw zapisać ją z powrotem do pamięci, zanim zapis
będzie mógł być kontynuowany, albo przesłać przez magistralę bezpośrednio do procesora zapi-
sującego. Ten zbiór reguł nazywa się protokołem koherencji pamięci podręcznych i jest jednym
z wielu dostępnych protokołów tego rodzaju.
Jeszcze inna możliwość polega na zastosowaniu projektu rysunku 8.2(c), w którym każdy
procesor dysponuje nie tylko pamięcią podręczną, ale także lokalną, prywatną pamięcią, do
której uzyskuje dostęp przez dedykowaną (prywatną) magistralę. Aby skorzystać z tej konfigu-
racji w optymalny sposób, kompilator powinien umieścić cały tekst programu, ciągi znaków, stałe
i inne dane tylko do odczytu, a także stosy oraz zmienne lokalne w pamięciach prywatnych. Pamięć
współdzielona jest wówczas używana tylko do zapisywalnych zmiennych współdzielonych.
W większości przypadków taka konfiguracja znacznie zmniejszy ruch na magistrali, ale wymaga
ona aktywnej współpracy ze strony kompilatora.
Rysunek 8.3. (a) Przełącznik krzyżowy 8×8; (b) otwarty punkt krzyżowy; c) zamknięty punkt
krzyżowy
Jedna z najbardziej wartościowych cech przełącznika krzyżowego jest taka, że to sieć nieblo-
kująca, co oznacza, że system nigdy nie odmówi żadnemu procesorowi wymaganego połączenia
ze względu na to, że jakiś punkt krzyżowy lub linia są już zajęte (przy założeniu, że sam moduł
pamięci jest dostępny). Nie wszystkie połączenia charakteryzują się taką znakomitą właściwością.
Co więcej, nie jest potrzebne zaawansowane planowanie. Nawet jeśli wcześniej zostanie skon-
figurowanych siedem dowolnych połączeń, zawsze jest możliwe podłączenie pozostałych proceso-
rów do pozostałych układów pamięci.
Oczywiście w dalszym ciągu jest możliwa rywalizacja o pamięć. Występuje ona wtedy, gdy dwa
procesory chcą w tym samym czasie uzyskać dostęp do tego samego modułu pamięci. Niemniej
jednak dzięki podziałowi pamięci na n jednostek rywalizacja w porównaniu z modelem z ry-
sunku 8.2 zmniejsza się n razy.
Jedną z najgorszych właściwości przełącznika krzyżowego jest to, że liczba punktów krzy-
żowych wzrasta w tempie n2. Przy 1000 procesorach i 1000 modułach pamięci potrzeba miliona
punktów krzyżowych. Wykonanie tak dużego przełącznika krzyżowego jest niemożliwe. Prze-
łączniki krzyżowe można jednak zastosować dla systemów o średnich rozmiarach.
Pole Moduł informuje o tym, jaki moduł pamięci ma być wykorzystany. Pole Adres określa adres
w obrębie modułu. Pole Opkod specyfikuje operację, np. READ lub WRITE. Na koniec opcjonalne
pole Wartość może zawierać operand, np. 32-bitowe słowo, które ma być zapisane w operacji WRITE.
Przełącznik analizuje pole Module i wykorzystuje je do stwierdzenia, czy należy przesłać komu-
nikat na linię X, czy na linię Y.
Rysunek 8.4. (a) Przełącznik 2×2 z dwoma liniami wejściowymi A i B oraz dwoma liniami
wyjściowymi X i Y; (b) format komunikatu
Nasze przełączniki 2×2 można zorganizować na wiele sposobów w celu stworzenia większej
wielostopniowej sieci przełączania [Adams et al., 1987], [Bhuyan et al., 1989], [Kumar i Reddy,
1987]. Jedną z możliwości jest zwykła sieć omega zilustrowana na rysunku 8.5. W tym przypadku
podłączono osiem procesorów do ośmiu modułów pamięci za pomocą 12 przełączników.
W bardziej ogólnym przypadku dla n procesorów i n modułów pamięci potrzeba log2n stopni —
n/2 przełączników na stopień. Razem potrzeba by było (n/2)log2n przełączników, co jest war-
tością znacznie mniejszą od n2 punktów krzyżowych, zwłaszcza dla dużych wartości n.
Wzorzec okablowania sieci omega często nazywa się doskonałym tasowaniem, ponieważ
mieszanie sygnałów na każdym stopniu przypomina koszulki kart przeciętych na połowę, a następ-
nie tasowanych karta po karcie. Aby przyjrzeć się sposobowi działania sieci omega, przypuśćmy,
że procesor CPU 011 chce przeczytać słowo z modułu pamięci 110. Procesor wysyła komunikat
READ do przełącznika 1D zawierającego wartość 110 w polu Moduł. Przełącznik pobiera pierwszy
(tzn. skrajnie lewy) bit wartości 110 i wykorzystuje go na potrzeby routingu. Wartość 0 kieruje
sygnał do górnej linii wyjściowej, natomiast wartość 1 do dolnej. Ponieważ ten bit ma wartość 1,
komunikat jest kierowany za pośrednictwem niższego wyjścia do przełącznika 2D.
Wszystkie przełączniki drugiego stopnia, włącznie z 2D wykorzystują drugi bit do routingu.
Ten bit także ma wartość 1, zatem komunikat jest przekazywany za pośrednictwem dolnego
wyjścia do przełącznika 3D. W tym przełączniku jest testowany trzeci bit. System stwierdza,
Komputery NUMA mają trzy zasadnicze cechy, które odróżniają je od innych systemów
wieloprocesorowych:
1. Występuje pojedyncza przestrzeń adresowa widoczna dla wszystkich procesorów.
2. Dostęp do zdalnej pamięci jest realizowany za pośrednictwem instrukcji LOAD i STORE.
3. Dostęp do zdalnej pamięci jest wolniejszy od dostępu do pamięci lokalnej.
Jeśli czas dostępu do zdalnej pamięci nie jest ukryty (ponieważ nie ma pamięci podręcznej), to
system nosi nazwę NC-NUMA (od ang. No Cache NUMA). W przypadku występowania spójnych
pamięci podręcznych system nosi nazwę CC-NUMA (od ang. Cache-Coherent NUMA).
Najbardziej popularnym sposobem budowania dużych wieloprocesorowych systemów
CC-NUMA jest wykorzystanie katalogowych systemów wieloprocesorowych. Idea polega na utrzy-
mywaniu bazy danych z informacjami na temat miejsca występowania poszczególnych linii pamięci
podręcznej oraz ich statusu. Przy odwołaniu do linii pamięci podręcznej wykonywane jest zapy-
tanie do bazy danych w celu sprawdzenia, gdzie jest dana linia oraz czy jest czysta, czy zabru-
dzona (zmodyfikowana). Ponieważ ta baza danych musi być odpytywana przy każdej instrukcji,
która odwołuje się do pamięci, powinna być przechowywana sprzętowo w specjalnym układzie
zdolnym do udzielenia odpowiedzi w ciągu ułamka cyklu magistrali.
Aby nieco urealnić ideę systemu wieloprocesorowego bazującego na katalogach, rozważmy
prosty (hipotetyczny) przykład — system złożony z 256 węzłów. Każdy węzeł składa się
z jednego procesora i 16 MB pamięci RAM połączonych z procesorem za pośrednictwem lokal-
nej magistrali. Całkowita ilość pamięci wynosi 232 bajtów, które są podzielone na 226 linii pamięci
podręcznej po 64 bajty każda. Pamięć jest rozdzielana statycznie pomiędzy węzły, przy czym
obszar 0 – 16 MB trafia do węzła 0, obszar 16 – 32 MB do węzła 1 itd. Węzły są połączone za
pomocą sieci wewnętrznej w sposób pokazany na rysunku 8.6(a). Każdy węzeł dodatkowo utrzymuje
wpisy katalogowe dla 218 64-bajtowych linii pamięci podręcznej. Linie te stanowią jego pamięć
złożoną z 224 bajtów pamięci. Na chwilę załóżmy, że linia może być utrzymywana w co najwy-
żej jednej pamięci podręcznej.
Aby pokazać, w jaki sposób działa katalog, spróbujmy prześledzić instrukcję LOAD z procesora
o numerze 20, która odwołuje się do linii pamięci podręcznej. Najpierw procesor wydający instruk-
cję przedstawia ją do swojego układu MMU, a ten przekształca ją na adres fizyczny — powiedzmy
0x24000108. Układ MMU dzieli ten adres na trzy części pokazane na rysunku 8.6(b). Dziesięt-
nie te trzy części oznaczają węzeł 36, linię 4 i przesunięcie 8. Jednostka MMU widzi, że słowo
pamięci, do którego jest kierowane odwołanie, pochodzi z węzła 36, a nie 20, dlatego wysyła
komunikat z żądaniem poprzez sieć połączenia do macierzystego węzła linii — 36 — z pytaniem,
czy linia 4 jest dostępna w pamięci podręcznej, a jeśli tak, to w której.
Kiedy żądanie dotrze do węzła 36 poprzez wewnętrzną sieć połączeń, jest kierowane do
sprzętu obsługującego katalog. Sprzęt przeszukuje swoją tablicę 218 wpisów (po jednym dla każdej
linii pamięci podręcznej) i znajduje wpis 4. Z rysunku 8.6(c) widać, że linia jest niedostępna
w pamięci podręcznej, dlatego sprzęt pobiera linię 4 z lokalnej pamięci RAM, przesyła ją do węzła
20 i aktualizuje wpis katalogu 4 w celu wskazania, że linia jest teraz buforowana w węźle 20.
Rozważmy teraz drugie żądanie. Tym razem zażądamy linii 2 węzła 36. Z rysunku 8.6(c)
widać, że żądana linia jest w pamięci podręcznej w węźle 82. W tym momencie sprzęt mógłby
zaktualizować wpis katalogowy 2 w celu poinformowania, że linia znajduje się teraz w węźle 20,
a następnie przesłać komunikat do węzła 82, aby nakazać mu przekazanie linii do węzła 20 i zdez-
aktualizować swoją pamięć podręczną. Zwróćmy uwagę na to, że nawet w tzw. „systemie wielopro-
cesorowym bazującym na współdzielonej pamięci” w tle przekazywanych jest wiele komunikatów.
Rysunek 8.6. (a) System wieloprocesorowy bazujący na katalogach, złożony z 256 węzłów;
(b) podział 32-bitowego adresu pamięci na pola; (c) katalog w węźle 36
Układy wielordzeniowe
W miarę postępu w technologii tranzystory mają coraz to mniejsze rozmiary. Dzięki temu
można zmieścić ich więcej w jednym układzie. Tę obserwację często określa się jako prawo
Moore’a — od nazwiska współzałożyciela firmy Intel Gordona Moore’a, który zauważył ją jako
pierwszy. W 1974 roku układ Intel 8080 zawierał nieco ponad 2 tysiące tranzystorów, podczas
gdy procesor Xeon Nehalem EX ma ponad 2 miliardy tranzystorów.
Oczywiste pytanie brzmi: co robić z aż tyloma tranzystorami? Jak powiedzieliśmy w punk-
cie 1.3.1, jednym z zastosowań jest dodanie do układu wielu megabajtów pamięci podręcznej.
Układy ultrawielordzeniowe
Układ wielordzeniowy (ang. multicore) po prostu oznacza, że układ posiada więcej niż jeden rdzeń,
ale gdy liczba rdzeni grubo przekracza wartość, którą da się policzyć na palcach, można używać
innej nazwy. Układy ultrawielordzeniowe (ang. manycore chips) to układy wielordzeniowe zawie-
rające dziesiątki, setki lub nawet tysiące rdzeni. Chociaż nie istnieje ścisły próg, po którego
przekroczeniu układy wielordzeniowe stają się ultrawielordzeniowymi, to proste rozróżnienie
jest takie, że jeśli przestajemy dbać o utratę jednego lub dwóch rdzeni, prawdopodobnie korzy-
stamy z układu ultrawielordzeniowego.
Karty akceleratorów, takie jak Xeon Phi Intela, mają ponad 60×86 rdzeni. Inni producenci
przekroczyli barierę 100 rdzeni i stosują rdzenie spełniające różne funkcje. Być może niedługo
pojawią się układy zawierające tysiąc rdzeni ogólnego przeznaczenia. Niełatwo sobie wyobrazić,
co można zrobić z tysiącem rdzeni, nie mówiąc już o możliwościach ich zaprogramowania.
Kolejnym problemem z ekstremalnie dużą liczbą rdzeni jest to, że układy potrzebne do
utrzymania spójności ich pamięci podręcznej są bardzo skomplikowane i drogie. Wielu inżynierów
obawia się, że w przypadku kilkuset rdzeni mogą pojawić się problemy ze skalowalnością. Nie-
którzy nawet opowiadają się za całkowitą rezygnacją z produkcji takich układów. Obawiają się,
że koszty sprzętowe protokołów koherencji będą tak wysokie, że wszystkie te błyszczące, nowe
rdzenie nie pomogą we wzroście wydajności, ponieważ procesor będzie zbyt zajęty utrzymaniem
pamięci podręcznej w spójnym stanie. Co gorsza, aby osiągnąć ten cel, trzeba będzie zużyć
zbyt dużo pamięci na utrzymywanie (szybkiego) katalogu. Wspomniany problem określa się jako
ścianę spójności.
Dla przykładu rozważmy omówione powyżej nasze rozwiązanie spójności pamięci podręcznej,
bazujące na katalogach. Jeśli każdy wpis w katalogu zawiera wektor bitów wskazujący na to,
które rdzenie zawierają określoną linię pamięci podręcznej, to wpis w katalogu dla procesora
z 1024 rdzeniami będzie miał co najmniej 128 bajtów. Ponieważ linie pamięci podręcznej same
są rzadko większe niż 128 bajtów, prowadzi to do nietypowej sytuacji, że wpis w katalogu jest
większy od linii pamięci podręcznej, którą ten wpis śledzi. Chyba nie o to nam chodzi.
Niektórzy inżynierowie twierdzą, że jedyny model programowania, który okazał się łatwo
skalowalny do bardzo dużej liczby procesorów, to taki, w którym wykorzystano przekazywanie
komunikatów i rozproszoną pamięć. Właśnie tego należy się spodziewać od ultrawielordze-
niowych układów przyszłości. Istnieją już procesory eksperymentalne, np. 48-rdzeniowy układ
SCC Intela, w którym porzucono techniki spójności pamięci podręcznej, a zamiast nich zasto-
sowano sprzętowe wsparcie dla szybszego przekazywania komunikatów. Z drugiej strony inne
procesory nadal zapewniają spójność pamięci podręcznej, pomimo większej liczby rdzeni.
Możliwe są również modele hybrydowe, np. 1024-rdzeniowy układ może być podzielony na 64
wyspy po 16 rdzeni spójnych na poziomie pamięci podręcznej. Jednocześnie rezygnuje się ze
spójności pamięci podręcznej pomiędzy wyspami.
Tysiące rdzeni w układzie to dziś nic specjalnego. Najczęściej stosowane układy ultrawie-
lordzeniowe — karty graficzne — występują niemal w każdym systemie komputerowym, który
nie jest systemem wbudowanym i ma monitor. Układy GPU to procesory zawierające dedy-
kowaną pamięć i dosłownie tysiące niewielkich rdzeni. W porównaniu z procesorami ogólnego
przeznaczenia w układach GPU większość „budżetu tranzystorów” jest zużywana na obwody, które
wykonują obliczenia, a mniej na pamięć podręczną i logikę sterowania. Są bardzo dobre do wyko-
nywania wielu prostych obliczeń przeprowadzanych równolegle — np. renderowania wielokątów
w aplikacjach graficznych. Nie sprawdzają się już tak dobrze w zadaniach wykonywanych sze-
regowo. Ponadto są trudne do zaprogramowania. Chociaż procesory GPU mogą być przydatne
do wykorzystania przez systemy operacyjne (np. do szyfrowania lub przetwarzania ruchu sie-
ciowego), nie jest prawdopodobne, aby duża część kodu systemu operacyjnego działała na pro-
cesorach GPU.
Coraz częściej procesory GPU są wykorzystywane do wykonywania innych zadań oblicze-
niowych. Dotyczy to zwłaszcza zadań wymagających obliczeniowo, powszechnych w obliczeniach
naukowych. Przetwarzanie ogólne wykonywane na układach GPU jest określane terminem
GPGPU (od ang. general-purpose processing on GPU). Niestety, wydajne programowanie proce-
sorów graficznych jest bardzo trudne. Wymaga stosowania specjalnych języków programowania,
takich jak OpenGL lub CUDA firmy NVIDIA. Istotna różnica między programowaniem proce-
sorów graficznych a programowaniem ogólnego przeznaczenia polega na tym, że układy GPU,
ogólnie rzecz biorąc, są typu SIMD (ang. Single Instruction, Multiple Data), co oznacza, że duża
liczba rdzeni wykonuje dokładnie tę samą instrukcję, ale na różnych składnikach danych. Ten
model programowania dobrze nadaje się do uzyskania współbieżności danych, ale nie zawsze
jest wygodny w innych stylach programowania (np. współbieżności zadań).
Zaprezentowany mechanizm jest i tak lepszy niż układ złożony z n oddzielnych kompute-
rów, ponieważ pozwala wszystkim komputerom współdzielić zbiór dysków i innych urządzeń
wejścia-wyjścia. Pozwala również na elastyczne współdzielenie pamięci. Nawet przy statycznej
alokacji jeden procesor może otrzymać bardzo dużą część pamięci. Dzięki temu może wydajnie
obsłużyć duże programy. Poza tym procesy mogą wydajnie komunikować się ze sobą, ponieważ
producent może zapisywać dane bezpośrednio do pamięci. Dzięki temu konsument może pobierać
dane z miejsca, w którym producent je zapisał. Pomimo wszystko, z perspektywy systemu ope
racyjnego, posiadanie własnego procesora przez każdy z systemów operacyjnych jest rozwią-
zaniem bardzo prymitywnym.
Warto wspomnieć o czterech aspektach tego projektu, które mogą być niejasne. Po pierwsze,
kiedy proces wykonuje wywołanie systemowe, jest ono przechwytywane i obsługiwane na pozio-
mie jego własnego procesora, z wykorzystaniem struktur danych w tablicach tego systemu
operacyjnego.
Po drugie, ponieważ każdy system operacyjny ma swoje własne tablice, ma również swój własny
zbiór procesów, który samodzielnie szereguje. Nie ma współdzielenia procesów. Kiedy użytkownik
loguje się do procesora 1, wszystkie jego procesy działają na procesorze 1. W konsekwencji może się
zdarzyć, że procesor 1 będzie bezczynny, podczas gdy procesor 2 będzie załadowany pracą.
Po trzecie nie ma współdzielenia fizycznych stron. Może się zdarzyć, że procesor 1 ma strony
do współdzielenia, podczas gdy procesor 2 przez cały czas wykonuje stronicowanie. Nie ma spo-
sobu na to, aby procesor 2 pożyczył jakieś strony od procesora 1, ponieważ alokacja pamięci
jest stała.
Po czwarte i najgorsze, jeśli system operacyjny utrzymuje bufor pamięci podręcznej zawie-
rający ostatnio używane bloki dyskowe, to każdy system operacyjny robi to niezależnie od pozo-
stałych. W związku z tym może się zdarzyć, że określony blok dyskowy będzie zabrudzony
w wielu buforach pamięci podręcznej przez cały czas, co doprowadzi do niespójnych wyników.
Jedynym sposobem uniknięcia tego problemu jest wyeliminowanie buforowych pamięci pod-
ręcznych. Wykonanie tego zadania nie jest trudne, ale znacząco obniża wydajność.
Z tych powodów ten model dziś jest już rzadko wykorzystywany w systemach produkcyjnych,
choć używano go we wczesnych latach systemów wieloprocesorowych, kiedy celem było jak
najszybsze przeniesienie istniejących systemów operacyjnych na nowe systemy wieloproceso-
rowe. W badaniach model powraca, ale testowane są różne jego odmiany. Istnieją argumenty za
tym, aby systemy operacyjne były całkowicie odrębne. Jeśli stan każdego procesora jest prze-
chowywany lokalnie dla wskazanego procesora, jest mało lub nie ma niczego do współdzielenia.
W związku z tym nie ma czynników, które mogłyby doprowadzić do problemów ze spójnością lub
z blokowaniem. Z kolei jeśli wiele procesorów ma uzyskać dostęp i modyfikować tę samą tabelę
procesów, blokowanie szybko staje się skomplikowane (i ma kluczowe znaczenie dla wydajności).
Więcej na ten temat powiemy podczas omawiania modelu symetrycznych układów wieloproce-
sorowych w dalszej części tego rozdziału.
Model ten dynamicznie równoważy procesy i pamięć, ponieważ istnieje tylko jeden zbiór
tablic systemu operacyjnego. Eliminuje również wąskie gardła procesora nadrzędnego, ponieważ
nie ma takiego procesora. Powoduje jednak nowe problemy. W szczególności jeśli dwa (lub
więcej) procesory wykonują kod systemu operacyjnego w tym samym czasie, może dojść do
awarii. Wyobraźmy sobie, że dwa procesory jednocześnie pobierają ten sam proces do urucho-
mienia lub żądają tej samej wolnej strony pamięci. Najprostszym sposobem obejścia tych pro-
blemów jest powiązanie muteksa (tzn. blokady) z systemem operacyjnym. Dzięki temu cały
system staje się jedną wielką sekcją krytyczną. Kiedy procesor chce uruchomić kod systemu
operacyjnego, musi najpierw uzyskać muteks. Jeśli muteks jest zablokowany, procesor po prostu
czeka. Dzięki temu dowolny procesor może wykonywać system operacyjny, ale mogą one to robić
tylko pojedynczo. Takie podejście jest czasami nazywane wielką blokadą jądra.
Zaprezentowany model działa, ale jest niemal tak samo wadliwy jak model master-slave.
Wyobraźmy sobie, że 10% czasu wykonywania procesor spędza wewnątrz systemu operacyjnego.
Przy 20 procesorach będą długie kolejki procesorów oczekujących na wejście do systemu ope-
racyjnego. Na szczęście istnieje łatwy sposób poprawy tej sytuacji. Wiele części systemu opera-
cyjnego działa niezależnie od siebie; np. nie ma problemu, jeśli na jednym procesorze działa
program szeregujący, drugi obsługuje wywołania systemu plików, a trzeci przetwarza błędy
braku stron.
Powyższa obserwacja doprowadziła do podzielenia systemu operacyjnego na wiele niezależ-
nych obszarów krytycznych, które nie wchodzą ze sobą w interakcje. Każdy obszar krytyczny
jest chroniony własnym muteksem, dzięki czemu jednorazowo może do niego wejść tylko jeden
procesor. Dzięki temu można osiągnąć dość wysoki stopień współbieżności. Zdarza się jednak,
że niektóre tablice, np. tablice procesów, są wykorzystywane w wielu obszarach krytycznych.
I tak tablica procesów jest potrzebna do szeregowania, ale także na użytek wywołania systemo-
wego fork oraz obsługi sygnałów. Każda tablica, która może być używana w wielu obszarach
krytycznych, potrzebuje własnego muteksa. W ten sposób każdy obszar krytyczny może być
wykonany tylko przez jeden procesor na raz, a do każdej krytycznej tablicy może uzyskać dostęp
tylko jeden procesor.
Taki układ stosuje większość nowoczesnych systemów wieloprocesorowych. Trudność
w napisaniu systemu operacyjnego na taką maszynę nie polega na tym, że kod zasadniczo różni
się od standardowego systemu operacyjnego. Tak nie jest. Trudność polega na podzieleniu kodu
systemu operacyjnego na obszary krytyczne, które mogą być wykonywane współbieżnie przez
różne procesory w taki sposób, aby nie wchodziły sobie wzajemnie w drogę, nawet w subtelny
i niebezpośredni sposób. Dodatkowo każda tablica używana przez dwa (lub więcej) obszary
krytyczne musi być osobno zabezpieczona przez muteks, a cały kod wykorzystujący tę tablicę
musi prawidłowo korzystać z muteksa.
Ponadto trzeba zachować szczególną ostrożność, aby unikać zakleszczeń. Jeśli dwa obszary
krytyczne jednocześnie potrzebują tablicy A i tablicy B, przy czym jeden z nich najpierw zażąda
tablicy A, a drugi najpierw tablicy B, to wcześniej czy później dojdzie do zakleszczenia i nikt nie
będzie potrafił wyjaśnić, dlaczego do niego doszło. Teoretycznie do każdej z tablic można przy-
pisać liczby całkowite i żądać od każdej z nich uzyskania tabel w rosnącej kolejności. Dzięki
zastosowaniu tej strategii można uniknąć zakleszczeń. Wymaga ona jednak od programisty
uważnego myślenia o tym, jakich tablic potrzebuje każdy z obszarów krytycznych, oraz formu-
łowania żądań we właściwej kolejności.
W miarę ewoluowania kodu obszary krytyczne mogą potrzebować nowych tablic, których
wcześniej nie potrzebowały. Jeśli system modyfikuje nowy programista, który nie do końca
rozumie pełną logikę systemu, może odczuwać pokusę, by pobrać muteks zarządzający dostępem
do tablicy wtedy, gdy jest on potrzebny, i zwolnić go w momencie, gdy przestaje być potrzebny.
Choć wydaje się to rozsądne, może prowadzić do zakleszczeń, które użytkownicy będą postrzegali
jako zawieszenie się systemu. Prawidłowe zaprojektowanie mechanizmu użytkowania tablic
nie jest łatwe, a utrzymanie prawidłowego układu w ciągu wielu lat — jeśli wziąć pod uwagę
zmieniających się programistów — okazuje się bardzo trudne.
Rysunek 8.10. Instrukcja TSL może się zakończyć niepowodzeniem, jeśli nie można zablokować
magistrali; te cztery kroki pokazują sekwencję zdarzeń, kiedy dochodzi do awarii
Aby zapobiec temu problemowi, instrukcja TSL musi najpierw zablokować magistralę, by nie
dopuścić do korzystania z niej przez inne procesory. Następnie musi wykonać obie operacje
dostępu do pamięci i na koniec odblokować magistralę. Zazwyczaj blokowanie magistrali jest
to może się zdarzyć, że oba jednocześnie zauważą, że jest ona wolna i oba jednocześnie
wykonają operację TSL w celu jej uzyskania. Tylko jedno takie żądanie zakończy się powodzeniem,
dlatego nie ma tu sytuacji wyścigu, bowiem rzeczywiste przydzielanie magistrali jest wykonywane
przez instrukcję TSL, a ta instrukcja jest niepodzielna. To, że procesor zaobserwuje, że blokada
jest wolna, i spróbuje natychmiast ją uzyskać za pomocą instrukcji TSL, nie gwarantuje jej uzy-
skania. Rywalizację może wygrać inny procesor, ale z punktu widzenia poprawności algorytmu nie
ma znaczenia, kto uzyskuje blokadę. Sukces czystej operacji odczytu jest jedynie wskazówką, że
może to być dobry moment na próbę zdobycia blokady. Nie jest to jednak gwarancja, że próba
pozyskania blokady się powiedzie.
Innym sposobem ograniczenia ruchu na magistrali jest wykorzystanie dobrze znanego ether-
netowego algorytmu binarnego wykładniczego cofania (ang. binary exponential backoff) opisanego
w pracy [Anderson, 1990]. Zamiast ciągłego odpytywania, tak jak pokazano na rysunku 2.15, można
wstawić pętlę opóźniającą pomiędzy zadawaniem kolejnych pytań. Początkowo opóźnienie wynosi
jedną instrukcję. Jeśli blokada jest w dalszym ciągu zajęta, opóźnienie jest podwajane do dwóch
instrukcji, następnie do czterech instrukcji i tak dalej, do pewnej wartości maksymalnej. Niska
wartość maksymalna umożliwia uzyskanie szybkiej odpowiedzi w momencie zwalniania blokady,
ale powoduje marnotrawstwo większej liczby cykli, gdy pamięć podręczna jest zatłoczona. Wysoka
wartość maksimum powoduje zmniejszenie obciążenia pamięci podręcznej kosztem późniejszego
zauważenia zwolnienia blokady. Algorytm binarnego wykładniczego cofania może być używany
razem ze standardowymi odczytami poprzedzającymi instrukcję TSL lub bez nich.
Jeszcze lepszym pomysłem jest przydzielenie każdemu procesorowi chcącemu uzyskać
muteks prywatnej zmiennej blokady do testowania, tak jak pokazano na rysunku 8.11
[Mellor-Crummey i Scott, 1991]. W celu uniknięcia konfliktów zmienna powinna rezydować
w nieużywanym w innym przypadku bloku pamięci podręcznej. Algorytm działa poprzez nakazanie
procesorowi, któremu nie powiodło się uzyskanie blokady, zaalokowania zmiennej blokady
i dołączenia się na koniec listy procesorów oczekujących na blokadę. Kiedy bieżący właściciel
blokady wyjdzie z obszaru krytycznego, zwalnia prywatną blokadę sprawdzaną przez pierwszy
procesor na liście (we własnej pamięci podręcznej). Następnie ten procesor wchodzi do obszaru
krytycznego. Kiedy wykona potrzebne działania, zwalnia blokadę używaną przez swojego następcę
itd. Chociaż ten protokół jest nieco złożony (w celu uniknięcia sytuacji, w której dwa procesory
dołączają się jednocześnie na koniec listy), okazuje się wydajny i odporny na zagłodzenia.
Szczegółowe informacje na ten temat można znaleźć w artykule.
Rysunek 8.11. Wykorzystanie wielu blokad w celu uniknięcia zatłoczenia pamięci podręcznej
Zapętlanie a przełączanie
Do tej pory zakładaliśmy, że procesor wymagający zablokowanego muteksa tylko na niego czeka.
Czekanie polega na ciągłym odpytywaniu, odpytywaniu przerywanym lub dołączaniu się do listy
oczekujących procesorów. Czasami nie ma innej alternatywy dla żądającego procesora, jak
czekanie. Załóżmy, że jakiś procesor jest bezczynny i potrzebuje dostępu do współdzielonej listy
gotowych procesów do uruchomienia. Jeśli lista gotowych procesów jest zablokowana, to pro-
cesor nie może po prostu zdecydować o tym, że zawiesi te operacje, które wykonuje, i uruchomi
inny proces, ponieważ wykonanie tej czynności wymagałoby czytania listy gotowych procesów.
Musi czekać do momentu uzyskania listy gotowych procesów.
Istnieje jednak inne wyjście. Jeśli np. jakiś wątek procesora chce uzyskać dostęp do pamięci
podręcznej bufora systemu plików, a jest w danym momencie zablokowany, to procesor zamiast
o czekaniu może zdecydować o przełączeniu do innego wątku. Problem tego, czy należy się
zapętlić, czy też wykonać przełączenie wątków, był przedmiotem wielu badań. Niektóre z nich
omówimy poniżej. Zwróćmy uwagę na to, że problem ten nie występuje w systemach jedno-
procesorowych, ponieważ zapętlanie nie ma zbytniego sensu, jeśli nie ma innych procesorów,
które mogłyby zwolnić blokadę. Jeśli wątek próbuje uzyskać blokadę i mu się to nie powiedzie,
zawsze się blokuje. W ten sposób daje szansę właścicielowi blokady na uruchomienie się i zwol-
nienie blokady.
Przy założeniu, że zarówno zapętlanie, jak i wykonywanie przełączenia wątków są wykonal-
nymi opcjami, należy zdecydować się na pewien kompromis. Zapętlanie wiąże się bezpośrednio
z marnotrawstwem cykli procesora. Wielokrotne sprawdzanie blokady nie jest wydajnym dzia-
łaniem. Jednak przełączanie także powoduje marnotrawstwo cykli procesora, ponieważ trzeba
zapisać stan bieżącego wątku, uzyskać blokadę na liście gotowych wątków, wybrać wątek, zała-
dować jego stan i go uruchomić. Co więcej, pamięć podręczna procesora będzie zawierać wszystkie
nieprawidłowe bloki. W związku z tym w momencie rozpoczęcia działania nowego wątku wystąpi
wiele kosztownych chybień bufora. Prawdopodobne są również chybione odwołania do bufora
TLB. Ostatecznie musi nastąpić przełączenie do pierwotnego wątku, po którym następuje więcej
chybionych trafień. Cykle poświęcone na te dwa przełączenia kontekstu oraz wszystkie chybione
odwołania do pamięci podręcznej zostaną utracone.
Jeśli wiadomo, że muteksy są, ogólnie rzecz biorąc, utrzymywane przez np. 50 μs, a prze-
łączenie z bieżącego wątku zajmuje 1 ms i potrzeba kolejnej milisekundy, aby przełączyć się
do niego z powrotem, bardziej wydajne jest zapętlenie się na muteksie. Z drugiej strony, jeśli
przeciętnie muteks jest utrzymywany przez 10 ms, to warto podjąć trud wykonania dwóch przełą-
czeń kontekstu. Problem polega na tym, że obszary krytyczne znacznie się różnią czasem trwania.
A zatem które podejście jest właściwsze?
Jedno z możliwych rozwiązań polega na wyborze zapętlania w każdej sytuacji. Drugie roz-
wiązanie to wykonywanie za każdym razem przełączenia. Jest jednak trzecie rozwiązanie pole-
gające na podjęciu osobnej decyzji za każdym razem, kiedy system napotka zablokowany muteks.
W momencie, kiedy musi być podjęta decyzja, nie wiadomo, czy lepiej zdecydować się na zapętlenie,
czy przełączenie, ale w każdym systemie można prześledzić wszystkie działania i później poddać
je analizie w trybie offline. Wówczas można ustalić, która decyzja była najlepsza i ile czasu zmarno-
wano w najlepszym przypadku. Wybrany algorytm może następnie stać się narzędziem, według
którego można porównywać inne algorytmy możliwe do zastosowania.
Problem ten był studiowany przez badaczy przez wiele dziesięcioleci [Ousterhout, 1982].
W większości prac stosowano następujący model: wątek, któremu nie udało się uzyskać muteksa,
zapętlał się na jakiś czas. Po przekroczeniu zadanego progu następowało przełączenie. W nie-
których przypadkach próg ma stałą wartość. Zwykle jest równy znanemu kosztowi przełączenia
do innego wątku, a następnie przełączenia z powrotem. W innych przypadkach jest to dynamiczny
algorytm zależny od zaobserwowanej historii oczekiwania na muteksy.
Najlepsze rezultaty osiąga się, kiedy system śledzi czasy kilku ostatnio zaobserwowanych
zapętleń i zakłada, że bieżące zapętlenie będzie podobne do poprzednich. I tak przy założeniu, że
czas przełączania kontekstu wynosi 1 ms, wątek zapętliłby się przez maksymalnie 2 ms, przez
cały czas obserwując czas zapętlenia. Jeśli przez ten czas uzyskanie blokady się nie powiedzie,
a wątek sprawdził, że w ostatnich trzech uruchomieniach musiał czekać średnio 200 μs, to przed
przełączeniem powinien zapętlić się na 2 ms. Jeśli jednak ustalił, że był zapętlony przez pełne
2 ms w każdej z poprzednich prób, powinien przełączyć się natychmiast i w ogóle się nie zapętlać.
W niektórych nowoczesnych procesorach, włącznie z x86, dostępne są specjalne instruk-
cje umożliwiające skuteczniejsze oczekiwanie w kontekście zużycia energii. I tak instrukcje
MONITOR/MWAIT na platformie x86 pozwalają programom na zablokowanie się do czasu, aż jakiś
inny procesor zmodyfikuje dane we wcześniej zdefiniowanym obszarze pamięci. W szczególności
instrukcja MONITOR określa zakres adresów, dla których powinien być monitorowany zapis.
Następnie instrukcja MWAIT blokuje wątek do momentu, aż inny procesor wykona operację zapisu
w obszarze. W efekcie wątek jest zapętlony, ale bez niepotrzebnego marnowania wielu cykli.
i działające wspólnie. Przykładem tej drugiej sytuacji jest system z podziałem czasu — w tym
systemie niezależni użytkownicy uruchamiają niezależne procesy. Wątki różnych procesów
nie są ze sobą związane, a każdy z nich można szeregować bez względu na inne.
Przykład tej drugiej sytuacji regularnie występuje w środowiskach twórców oprogramowania.
Duże systemy często składają się z pewnej liczby plików nagłówkowych zawierających makra,
definicje typów oraz deklaracje zmiennych używanych przez właściwe pliki kodu. Modyfikacja
pliku nagłówkowego powoduje, że wszystkie pliki źródłowe, które go włączają, muszą być ponow-
nie skompilowane. Do zarządzania wytwarzaniem oprogramowaniem często wykorzystuje się
program make. Wywołanie programu make powoduje uruchomienie kompilacji tylko tych plików
kodu, które muszą być skompilowane w wyniku zmian w plikach nagłówkowych lub w plikach
kodu. Pliki obiektowe, które są aktualne, nie zostają wygenerowane od nowa.
Pierwotna wersja programu make wykonywała swoje działania sekwencyjnie, natomiast now-
sze wersje zaprojektowane dla systemów wieloprocesorowych mogą uruchamiać wszystkie kom-
pilacje na raz. Jeśli jest potrzebnych 10 kompilacji, to nie ma sensu uszeregowanie 9 z nich tak, by
zadziałały natychmiast, i pozostawienie ostatniego na później, ponieważ użytkownik nie uzna
pracy za skończoną, dopóki nie zakończy się ostatnia kompilacja. W takim przypadku warto
rozpatrywać wątki wykonujące kompilacje jako grupę i wzięcie tego pod uwagę podczas ich
szeregowania.
Ponadto czasami warto uszeregować wątki, które bardzo często się komunikują, spełniając
rolę np. producenta i konsumenta, nie tylko w tym samym czasie, ale także blisko siebie
w przestrzeni. Mogą one skorzystać ze współdzielenia pamięci podręcznej. Na podobnej zasadzie
w architekturze NUMA może być pomocne korzystanie z pamięci znajdującej się blisko.
Podział czasu
Przyjrzyjmy się najpierw przypadkowi szeregowania niezależnych wątków, a następnie prze-
analizujmy sposób szeregowania wątków, które są ze sobą powiązane. Najprostszym algorytmem
szeregowania do obsługi niezwiązanych ze sobą wątków jest stworzenie pojedynczej struktury
danych na poziomie systemu do przechowywania wątków będących w gotowości. Może to być
lista, ale lepiej, jeśli będzie to zbiór list dla wątków o różnych priorytetach, tak jak pokazano na
rysunku 8.12(a). W pokazanej sytuacji 16 procesorów jest zajętych, a zbiór 14 wątków o podanych
priorytetach oczekuje na uruchomienie. Pierwszy procesor, który zakończy swoją pracę (lub
będzie musiał zablokować swoje wątki), to procesor 4, który następnie zablokuje kolejki do
uszeregowania i wybierze wątek o najwyższym priorytecie. W sytuacji z rysunku 8.12(b) jest to
proces A. Następnie procesor 12 przechodzi do stanu bezczynności i wybiera wątek B, tak jak
pokazano na rysunku 8.12(c). Jeśli wątki są kompletnie ze sobą niezwiązane, wykonywanie sze-
regowania w ten sposób okazuje się rozsądnym wyborem i jest bardzo łatwe do wydajnego zaim-
plementowania.
Wykorzystanie przez wszystkie procesory pojedynczej struktury danych do szeregowania
powoduje podział czasu procesorów w sposób podobny do tego, w jaki odbywa się to w systemach
jednoprocesorowych. Zapewnia również automatyczne równoważenie obciążenia, ponieważ nigdy
się nie może zdarzyć, aby jeden procesor był bezczynny, podczas gdy pozostałe są przeciążone.
Dwie wady tego podejścia to potencjalna rywalizacja o strukturę danych szeregowania w miarę
zwiększania się liczby procesorów oraz obciążenie podczas przełączania kontekstu wtedy, gdy
wątki blokują się w oczekiwaniu na realizację operacji wejścia-wyjścia.
Możliwa jest również sytuacja, w której przełączenie wątków następuje w momencie wyczer-
pania się kwantu czasu wątku. Systemy wieloprocesorowe mają pewne cechy, które nie wystę-
pują w systemach jednoprocesorowych. Przypuśćmy, że wątek utrzymuje blokadę pętlową
w momencie wyczerpania się jego kwantu czasu. Inne procesory oczekujące na blokadę pętlową
marnują swój czas, tkwiąc w zapętleniu tak długo, aż wątek ten zostanie zaplanowany ponownie
i zwolni blokadę. W systemach jednoprocesorowych blokady pętlowe rzadko są stosowane. Z tego
powodu, kiedy proces zawiesi się w czasie posiadania muteksa, zostanie natychmiast zabloko-
wany. Dlatego strata czasu nie jest tak duża.
W celu obejścia tej anomalii w niektórych systemach wykorzystuje się inteligentne szerego-
wanie. W takim układzie wątek uzyskujący blokadę pętlową ustawia dla procesu globalną flagę,
która pokazuje, że w danym momencie wątek ten posiada blokadę pętlową [Zahorjan et al.,
1991]. Kiedy zwolni blokadę, zeruje tę flagę. Program szeregujący nie zatrzymuje wtedy wątku
posiadającego blokadę pętlową, ale daje mu trochę więcej czasu na zakończenie wykonywania
działania w obszarze krytycznym i zwolnienie blokady.
Innym problemem odgrywającym pewną rolę w szeregowaniu jest to, że o ile wszystkie
procesory są sobie równe, o tyle niektóre procesory są „równiejsze”. W szczególności kiedy
wątek A działał przez długi czas na procesorze k, pamięć podręczna procesora k będzie pełna
bloków A. Jeśli proces A zostanie wybrany do działania w niedalekiej przyszłości, może działać
wydajniej, pod warunkiem że będzie działać na procesorze k, ponieważ pamięć podręczna pro-
cesora k w dalszym ciągu może zawierać pewne bloki procesu A. Ponowne załadowanie bloków
do pamięci podręcznej zwiększa współczynnik trafień w pamięci podręcznej, a tym samym
szybkość działania wątku. Ponadto bufor TLB może również zawierać właściwe strony, co redu-
kuje liczbę chybionych odwołań do bufora TLB.
Niektóre systemy wieloprocesorowe biorą ten efekt pod uwagę i wykorzystują tzw. szerego-
wanie według powinowactwa (ang. affinity scheduling) [Vaswani i Zahorjan, 1991]. Podstawowa
idea polega na podjęciu wysiłków zmierzających do tego, by wątek działał na tym samym pro-
cesorze, na którym działał ostatnio. Jednym ze sposobów stworzenia tego powinowactwa jest
skorzystanie z algorytmu szeregowania dwupoziomowego. W momencie utworzenia wątku jest on
przydzielany do procesora — np. na podstawie tego, który z wątków w danym momencie ma naj-
mniejsze obciążenie. Przypisanie wątków do procesorów to górny poziom algorytmu. W rezul-
tacie tej strategii każdy procesor uzyskuje własną kolekcję wątków.
Właściwe szeregowanie wątków tworzy dolny poziom algorytmu. Jest ono realizowane przez
każdy procesor oddzielnie z wykorzystaniem priorytetów lub innych mechanizmów. Dzięki
próbie utrzymania wątku na tym samym procesorze przez cały czas jego życia można zmak-
symalizować powinowactwo pamięci podręcznej. Jeśli jednak procesor nie ma do uruchomienia
żadnych wątków, zamiast przechodzić do bezczynności, bierze jakiś wątek od innego procesora.
Zastosowanie dwupoziomowego szeregowania ma trzy zalety. Po pierwsze zapewnia w przy-
bliżeniu równomierne rozłożenie wątków na dostępne procesory. Po drugie, jeśli to możliwe,
wykorzystywane są zalety powinowactwa pamięci podręcznej. Po trzecie dzięki przekazaniu
każdemu procesorowi listy procesów będących w gotowości rywalizacja o listę gotowych pro-
cesów ulega zostaje ograniczona do minimum, ponieważ próby użycia listy gotowych procesów
innego procesora są stosunkowo rzadkie.
Współdzielenie przestrzeni
Inne ogólne podejście do szeregowania w systemach wieloprocesorowych może być wykorzy-
stane wtedy, kiedy wątki są w pewien sposób ze sobą powiązane. Wcześniej jako przykład jed-
nego z przypadków przywołaliśmy równoległe wykonywanie programu make. Często zdarza się
również, że pojedynczy proces ma wiele wątków, które wspólnie ze sobą działają. Jeśli np. wątki
procesu często się ze sobą komunikują, warto zadbać o to, by działały równocześnie. Szeregowanie
wielu wątków w tym samym czasie pomiędzy wiele procesorów nazywa się współdzieleniem
przestrzeni.
Najprostszy algorytm współdzielenia przestrzeni działa w następujący sposób. Załóżmy, że
jednocześnie tworzona jest cała grupa powiązanych ze sobą wątków. W momencie jej utworzenia
program szeregujący sprawdza, czy istnieje tyle samo wolnych procesorów, co wątków. Jeśli
tak jest, to każdy wątek uzyskuje własny, dedykowany procesor (tzn. bez wieloprogramowości)
i wszystkie wątki się uruchamiają. Jeśli nie ma wystarczającej liczby procesorów, żaden z wątków
nie rozpoczyna działania, dopóki nie będzie dostatecznej liczby dostępnych procesorów. Każdy
wątek utrzymuje swój procesor aż do zakończenia. Wówczas procesor jest zwracany do puli
procesorów dostępnych. Jeśli wątek zablokuje się w oczekiwaniu na zakończenie operacji wej-
ścia-wyjścia, dalej utrzymuje procesor, który jest bezczynny aż do chwili, kiedy wątek się zbudzi.
Gdy nadejdzie następna partia wątków, powtórzony zostanie ten sam algorytm.
W każdym momencie zbiór procesorów jest dzielony w sposób statyczny na pewną liczbę
partycji. Każda z nich wykonuje wątki jednego procesu. Na rysunku 8.13 np. mamy partycje
o rozmiarach: 4, 6, 8 i 12 procesorów, a 2 procesory są nieprzydzielone. W miarę upływu czasu
liczba i rozmiar partycji zmienia się. Tworzą się nowe wątki, a stare kończą działanie i są niszczone.
Rysunek 8.13. Zbiór 32 procesorów podzielony na cztery partycje; dwa procesory nie zostały
przydzielone
Co jakiś czas muszą być podejmowane decyzje dotyczące szeregowania. W systemach jedno-
procesorowych dobrze znanym algorytmem szeregowania zadań wsadowych jest algorytm „naj-
pierw najkrótsze zadanie”. Analogiczny algorytm dla systemu wieloprocesorowego polega na
wyborze procesu wymagającego najmniejszej liczby cykli procesora — czyli wątku, dla którego
wartość liczba procesorów×czas wykonywania jest najmniejsza. W praktyce jednak taka infor-
macja rzadko jest dostępna, zatem algorytm okazuje się trudny do realizacji. Z badań wynika, że
w praktyce trudno zrealizować algorytm „pierwszy zgłoszony, pierwszy obsłużony” [Krueger
et al., 1994].
W tym prostym modelu partycjonowania wątek pyta jedynie o pewną liczbę procesorów
i albo wszystkie je otrzymuje, albo musi czekać, aż będą dostępne. Innym podejściem możliwym
do zastosowania przez wątki jest aktywne zarządzanie stopniem współbieżności. Jedną z metod
zarządzania współbieżnością jest utrzymywanie centralnego serwera, śledzącego to, które wątki
działają, a które chcą działać oraz jakie są ich minimalne i maksymalne wymagania w zakresie
procesorów [Tucker i Gupta, 1989]. Okresowo każda z aplikacji odpytuje centralny serwer o liczbę
procesorów, z jakich może skorzystać. Następnie dostosowuje liczbę wątków w górę lub w dół,
tak by zapotrzebowanie na procesory było zgodne z liczbą dostępnych procesorów.
Przykładowo serwer WWW może uruchomić równolegle 5, 10, 20 lub dowolną inną liczbę
wątków. Jeśli w danym momencie ma 10 wątków, a nagle występuje większe zapotrzebowanie na
procesory, serwer może mu przekazać polecenie, by zmniejszył liczbę wątków do 5. Mechanizm
ten umożliwia dynamiczne różnicowanie rozmiarów partycji. Dzięki temu można rozkładać bie-
żące obciążenie lepiej niż w przypadku systemu statycznego.
Szeregowanie zespołów
Oczywistą zaletą współdzielenia przestrzeni jest eliminacja wieloprogramowości. Likwiduje to
koszty obliczeniowe związane z przełączaniem kontekstu. Jednak istnieje również oczywista
wada: stracony czas, kiedy procesor się zablokuje i nie będzie miał nic do zrobienia, aż na nowo
stanie się dostępny. W związku z tym zaczęto poszukiwać algorytmów, które podejmowałyby
próbę szeregowania zarówno w czasie, jak i przestrzeni. Miało to szczególne znaczenie zwłaszcza
dla wątków tworzących wiele wątków, które musiały się ze sobą komunikować.
Aby zdać sobie sprawę z rodzaju problemów występujących podczas niezależnego szerego-
wania wątków procesu, rozważmy system z wątkami A0 i A1 należącymi do procesu A oraz
wątkami B0 i B1 należącymi do procesu B. Wątki A0 i B0 współdzielą czas na procesorze 0; wątki
A1 i B1 współdzielą czas na procesorze 1, natomiast wątki A0 i A1 muszą się często komunikować.
Wzorzec komunikacji jest taki, że wątek A0 wysyła wątkowi A1 komunikat. Następnie wątek A1
odsyła odpowiedź do wątku A0, po czym następuje kolejna taka sekwencja. Taka sytuacja po-
wszechnie występuje w układzie klient-serwer. Załóżmy, że szczęśliwie najpierw rozpoczęły
działanie wątki A0 i B1, tak jak pokazano na rysunku 8.14.
W przedziale czasu 0 wątek A0 przesyła wątkowi A1 żądanie, ale wątek A1 nie otrzymuje go,
dopóki działa w przedziale 1 rozpoczynającym się w chwili 100 ms. Wątek A1 wysyła odpowiedź
natychmiast, ale wątek A0 nie otrzymuje odpowiedzi, zanim nie zacznie znowu działać, co nastę-
puje w chwili 200 ms. Nie jest to zbyt wysoka wydajność.
Rozwiązaniem tego problemu jest szeregowanie zespołów (ang. gang scheduling), które wywodzi
się z szeregowania równoległego (ang. co-scheduling) [Ousterhout, 1982]. Szeregowanie zespołów
składa się z trzech części:
odebrany niemal natychmiast i niemal natychmiast będzie udzielona odpowiedź. Ponieważ w sytu-
acji z rysunku 8.15 wszystkie wątki A działają razem, w ciągu jednego kwantu mogą one wysłać
i odebrać bardzo dużo komunikatów. W ten sposób można wyeliminować problem z rysunku 8.14.
8.2. WIELOKOMPUTERY
8.2.
WIELOKOMPUTERY
1
W języku angielskim słowo „cows” oznacza „krowy” — przyp. tłum.
Dla uproszczenia założymy jednak, że każdy węzeł posiada pojedynczy procesor. Często wielo-
komputer tworzy kilkaset, a nawet kilka tysięcy węzłów połączonych ze sobą. Poniżej opo-
wiemy krótko, w jaki sposób ten sprzęt jest zorganizowany.
Rysunek 8.16. Różne topologie wewnętrznych połączeń: (a) pojedynczy przełącznik; (b)
pierścień; (c) siatka; (d) podwójny torus; (e) sześcian; (f) czterowymiarowy hipersześcian
i połączenie ze sobą odpowiadających sobie węzłów, tak by tworzyły blok czterech sześcianów,
można by utworzyć sześcian pięciowymiarowy. Aby przejść do sześciu wymiarów, można by repli-
kować blok czterech sześcianów poprzez połączenie odpowiednich węzłów itd. Stworzony
w ten sposób n-wymiarowy sześcian nazywa się hipersześcianem (ang. hypercube).
Taką topologię stosuje wiele komputerów równoległych, ponieważ wraz ze zwiększaniem
wymiarów liniowo rośnie średnica. Mówiąc inaczej, średnica jest logarytmem o podstawie
2 z liczby węzłów. Tak więc np. 10-wymiarowy hipersześcian ma 1024 węzły, ale jego średnica jest
równa tylko 10, dzięki czemu taki układ charakteryzuje się niskimi opóźnieniami. Dla odróżnienia
warto zwrócić uwagę, że siatka 32×32 ma średnicę 62 — ponad sześciokrotnie niższą od hiper-
sześcianu. Ceną, jaką trzeba zapłacić za mniejszą średnicę, jest znacznie większa obciążalność,
a tym samym liczba połączeń (i koszty).
W wielokomputerach stosuje się dwa mechanizmy przełączania. W pierwszym każdy komu-
nikat musi być najpierw rozbity (przez oprogramowanie użytkownika lub interfejs sieciowy) na
fragmenty o pewnych maksymalnych rozmiarach, zwane pakietami. Schemat przełączania, nazy-
wany przełączaniem pakietów typu przechowaj i prześlij (ang. store-and-forward packet switching),
obejmuje „wstrzyknięcie” pakietu do pierwszego przełącznika przez kartę interfejsu sieciowego
węzła źródłowego, tak jak pokazano na rysunku 8.17(a). Bity napływają pojedynczo, a kiedy do
bufora wejściowego nadejdzie cały pakiet, jest on kopiowany do kolejki prowadzącej do następ-
nego przełącznika w ścieżce, co pokazano na rysunku 8.17(b). Kiedy pakiet dotrze do przełącznika
podłączonego do węzła docelowego — widać to na rysunku 8.17(c) — jest kopiowany do inter-
fejsu sieciowego tego węzła i ostatecznie do jego pamięci RAM.
O ile technika przełączania pakietów typu przechowaj i prześlij jest elastyczna i efektywna,
o tyle charakteryzuje się problemem polegającym na zwiększonym opóźnieniu wewnętrznej sieci.
Załóżmy, że czas przesyłania pakietu o jeden przeskok na rysunku 8.17 wynosi T ns. Ponieważ
przejście od procesora 1 do procesora 2 wymaga czterech operacji kopiowania (do węzła A, do
węzła C, do węzła D i do docelowego procesora), a operacja kopiowania nie może się rozpocząć,
jeśli nie zakończy się poprzednia, to opóźnienie w wewnętrznej sieci wynosi 4T. Jednym ze
sposobów poradzenia sobie z tą sytuacją jest zaprojektowanie sieci, w której pakiety muszą
być logicznie podzielone na mniejsze jednostki. Natychmiast po tym, kiedy pierwsza jednostka
dotrze do węzła, może ona zostać przekazana — jeszcze zanim nadejdzie ogon pakietu. Jednostką
w szczególnym przypadku może być nawet 1 bit.
Interfejsy sieciowe
Wszystkie węzły w wielokomputerach są wyposażone w kartę rozszerzeń umożliwiającą pod-
łączenie węzła do sieci wewnętrznej utrzymującej wielokomputer w całości. Konstrukcja takich
kart oraz sposób ich połączenia z głównym procesorem i pamięcią RAM mają znaczące implikacje
dla systemu operacyjnego. Poniżej zwięźle przeanalizujemy niektóre z tych problemów. Czę-
ściowo materiał ten bazuje na publikacji [Bhoedjang, 2000].
Niemal we wszystkich wielokomputerach karta interfejsu zawiera znaczącą ilość pamięci RAM
przeznaczonej na przechowywanie pakietów wchodzących i wychodzących. Zazwyczaj pakiet
wyjściowy musi zostać skopiowany do pamięci RAM karty sieciowej, zanim zostanie przesłany
do pierwszego przełącznika. Powodem takiego projektu jest to, że wiele sieci wewnętrznych ma
charakter synchroniczny, zatem kiedy transmisja pakietu się rozpocznie, transmisja bitów musi
być kontynuowana w stałym tempie. Jeśli pakiet znajduje się w głównej pamięci, nie można
zagwarantować tego ciągłego przepływu do sieci, z uwagi na inny ruch na magistrali pamięci.
Użycie dedykowanej pamięci RAM na karcie interfejsu eliminuje ten problem. Omawiany pro-
jekt pokazano na rysunku 8.18.
Ten sam problem występuje w przypadku pakietów przychodzących. Bity napływają z sieci
w stałym tempie, które niekiedy jest bardzo duże. Jeśli karta interfejsu sieciowego nie jest
w stanie zapisywać ich w czasie rzeczywistym — tak jak napływają — może dojść do utraty danych.
W tym przypadku próba transmisji poprzez magistralę systemową (np. magistralę PCI) do głów-
nej pamięci RAM okazuje się zbyt ryzykowna. Ponieważ karta sieciowa jest zwykle podłączona
do magistrali PCI, jest to jedyne połączenie, jakie ma ona z główną pamięcią RAM. W związku
z tym rywalizacja o tę magistralę z dyskiem oraz innymi urządzeniami wejścia-wyjścia staje się
nieunikniona. Bezpieczniej jest zapisać wchodzące pakiety do prywatnej pamięci RAM karty
interfejsu, a później skopiować je do głównej pamięci RAM.
Karta interfejsu może mieć na płycie jeden lub kilka kanałów DMA oraz kompletny procesor
(lub nawet wiele procesorów). Poprzez żądanie transferu bloków na magistrali systemowej kanały
DMA mogą kopiować pakiety pomiędzy kartą interfejsu a główną pamięcią RAM z dużymi
szybkościami. Dzięki temu jest możliwy transfer kilku słów bez konieczności osobnego żądania
magistrali dla każdego słowa z osobna. Jest to jednak dokładnie taki rodzaj transferu blokowego,
który wiąże magistralę systemową na wiele cykli magistrali. W związku z tym konieczna staje
się instalacja pamięci RAM na karcie interfejsu.
Wiele kart interfejsu jest wyposażonych w kompletny procesor, często występujący razem
z jednym lub kilkoma kanałami DMA. Określa się je terminem procesory sieci. W ostatnich latach
ich możliwości są coraz większe [El Ferkouss et al., 2011]. Z tego projektu wynika, że główny
procesor może przydzielić pewną część pracy karcie sieciowej. Może to być np. obsługa nieza-
wodnej transmisji (jeśli wykorzystywany sprzęt pozwala na gubienie pakietów), transmisja
w trybie multicast (przesyłanie pakietów do więcej niż jednego adresata), kompresja (dekom-
presja), szyfrowanie (odszyfrowywanie), a także obsługa zabezpieczeń w systemach z wieloma
procesami.
Wykorzystywanie dwóch procesorów oznacza jednak konieczność synchronizacji w celu
uniknięcia sytuacji wyścigu prowadzącego do dodatkowych kosztów i zwiększenia pracy dla
systemu operacyjnego.
Kopiowanie danych pomiędzy warstwami jest bezpieczne, ale nie zawsze skuteczne. Przykła-
dowo przeglądarka żądająca danych ze zdalnego serwera WWW spowoduje utworzenie żądania
w przestrzeni adresowej przeglądarki. Żądanie to zostanie następnie skopiowane do jądra,
gdzie będzie mogło być obsłużone przez stos TCP/IP. Następnie dane są kopiowane do pamięci
interfejsu sieciowego. Na drugim końcu połączenia odbywa się odwrotny proces: dane są kopio-
wane z karty sieciowej do bufora jądra, a następnie z bufora jądra na serwer WWW. Operacji
kopiowania jest niestety sporo. Każda wprowadza dodatkowy narzut. Nie chodzi o samo kopiowa-
nie, ale również o presję na pamięć podręczną, bufor TLB itp. W rezultacie przy takich połącze-
niach występują duże opóźnienia.
W następnym punkcie omówimy techniki zmniejszania obciążeń związanych z kopiowaniem,
zanieczyszczaniem pamięci podręcznej i przełączaniem kontekstu.
transmisji danych przez sieć. Poza tym, odbierające jądro prawdopodobnie nie będzie wiedziało,
gdzie umieszczać wchodzące pakiety, dopóki nie uzyska szansy na ich przeanalizowanie. Pięć
kroków kopiowania, o których mowa, zilustrowano na rysunku 8.18.
Jeśli kopiowanie do pamięci RAM i z pamięci RAM jest wąskim gardłem, to dodatkowe ope-
racje kopiowania do jądra i z jądra mogą spowodować podwojenie opóźnień i spowodować obcięcie
przepustowości do połowy. W celu uniknięcia tego obniżenia wydajności wiele systemów
wielokomputerowych odwzorowuje kartę interfejsu bezpośrednio do przestrzeni użytkownika
i umożliwia procesowi użytkownika bezpośrednie umieszczanie pakietów w pamięci karty — bez
udziału jądra. Chociaż takie podejście zdecydowanie poprawia wydajność, wprowadza dwa problemy.
Po pierwsze, co się stanie, jeśli w węźle działa kilka procesów, które w celu wysłania pakietu
potrzebują dostępu do sieci? Który z nich otrzyma kartę interfejsu w swojej przestrzeni adre-
sowej? Wykorzystanie wywołania systemowego, które będzie odwzorowywało kartę z wirtual-
nej przestrzeni adresowej i do wirtualnej przestrzeni adresowej, jest kosztowne, ale jeśli jeden
proces uzyska kartę, to jak inne będą wysyłać pakiety? A co się stanie, jeśli karta zostanie odwzo-
rowana do wirtualnej przestrzeni adresowej procesu A, a pakiet, który do niej dotrze, jest
przeznaczony dla procesu B, zwłaszcza gdy procesy A i B mają różnych właścicieli, z których
żaden nie ma ochoty poświęcać się, by pomóc drugiemu?
Jednym z rozwiązań jest odwzorowanie karty interfejsu do wszystkich procesów, które jej
potrzebują. Wtedy jednak jest potrzebny mechanizm unikania sytuacji wyścigu. Jeśli np. proces A
zażąda bufora na karcie interfejsu, a następnie z powodu podziału czasu zacznie działać proces B
i zażąda tego samego bufora, dojdzie do katastrofy. Potrzebny jest jakiś mechanizm synchroni-
zacji, ale takie mechanizmy jak muteksy działają tylko wtedy, gdy procesy ze sobą współpracują.
W środowisku z podziałem czasu, gdy jest wielu użytkowników i wszyscy się śpieszą z wyko-
naniem swojej pracy, jeden użytkownik może zablokować muteks powiązany z kartą interfejsu
i nigdy go nie zwolnić. Konkluzja w tym przypadku jest taka, że o ile nie zostaną przedsięwzięte
specjalne środki ostrożności (np. do przestrzeni adresowych różnych procesów będzie odwzo-
rowywana inna część pamięci RAM karty interfejsu), odwzorowanie karty interfejsu do prze-
strzeni użytkownika zadziała dobrze tylko wtedy, kiedy na każdym węźle będzie działać tylko
jeden proces użytkownika.
Drugi problem polega na tym, że jądro samo może potrzebować dostępu do sieci wewnętrz-
nej — np. w celu skorzystania z systemu plików na zdalnym węźle. Zmuszanie jądra do tego,
by współdzieliło kartę interfejsu z innymi użytkownikami, nie jest dobrym pomysłem, nawet na
zasadach podziału czasu. Przypuśćmy, że w czasie gdy karta była odwzorowana do przestrzeni
użytkownika, dotarł pakiet jądra. Albo przypuśćmy, że proces użytkownika wysyła pakiet do
zdalnego komputera, udając jądro. Wniosek jest taki, że najprostszym rozwiązaniem okazuje się
zastosowanie dwóch kart interfejsu sieciowego — jeden odwzorowany do przestrzeni użytkow-
nika do obsługi ruchu aplikacji i drugi odwzorowany do przestrzeni jądra na użytek systemu
operacyjnego. Wiele systemów wielokomputerowych działa dokładnie w ten sposób.
Z drugiej strony nowsze interfejsy sieciowe często zawierają wiele kolejek (ang. multiqueue),
co oznacza, że są one wyposażone w więcej niż jeden bufor do wydajnej obsługi wielu użytkow-
ników. Przykładowo karty sieciowe serii Intel I350 mają 8 kolejek wysyłki i 8 kolejek odbior-
czych. Pozwalają na tworzenie wielu wirtualnych portów. Co więcej, obsługują tzw. powinowactwo
do rdzenia (ang. core affinity). W szczególności zawierają własną logikę haszowania, pozwalającą
na kierowanie każdego pakietu do odpowiedniego procesu. Ponieważ przetwarzanie wszystkich
segmentów odbywa się w tym samym strumieniu protokołu TCP i na tym samym procesorze,
karty mogą używać logiki haszowania do generowania skrótów pól przepływu TCP (zawierających
adresy IP i numery portów TCP) i dodać wszystkie segmenty z taką samą wartością skrótu do
tej samej kolejki, która jest obsługiwana przez konkretny rdzeń. Jest to również pożyteczne dla
wirtualizacji, ponieważ pozwala na przydzielenie odrębnej kolejki do każdej maszyny wirtualnej.
pośredniego dostępu do pamięci innego komputera. RDMA nie korzysta z funkcji żadnego z syste-
mów operacyjnych. Dane są bezpośrednio pobierane z pamięci aplikacji albo do niej zapisywane.
To brzmi świetnie, ale technika RDMA nie jest pozbawiona wad. Podobnie jak w przypadku
zwykłego kanału DMA, system operacyjny w węzłach komunikacji musi „przypiąć” strony biorące
udział w wymianie danych. Ponadto samo wprowadzenie danych do pamięci komputera zdalnego
nie zmniejszy znacząco opóźnienia, jeśli program po drugiej stronie nie będzie tego świadomy.
W technologii RDMA nie ma automatycznych powiadomień. Zamiast tego popularnym rozwią-
zaniem jest odpytywanie przez odbiorcę o bajt w pamięci. Po zakończeniu transferu nadawca
modyfikuje bajt w celu poinformowania odbiorcy o istnieniu nowych danych. Chociaż to rozwią-
zanie się sprawdza, nie jest idealne i wiąże się z marnotrawstwem cykli procesora.
W transakcjach o naprawdę wysokich częstotliwościach wymiany stosuje się niestandardowe
karty sieciowe zbudowane przy użyciu układów FPGA (od ang. Field-Programmable Gate Arrays).
Układy te charakteryzują się niewielkimi opóźnieniami. Od odebrania bitów na w karcie siecio-
wej do przesłania komunikatu zlecającego zakup akcji za kilka milionów dolarów mija poniżej 1 μs.
Zakup akcji wartych milion dolarów w 1 μs daje wydajność 1 teradolara/s. To bardzo użyteczna
funkcja, jeśli możemy w porę uzyskać informacje o wzrostach i spadkach, ale nie jest dobra dla
osób o słabym sercu. Systemy operacyjne nie odgrywają zbyt ważnej roli w takich ekstremalnych
scenariuszach.
Wysyłanie i odbieranie
Na minimalnym poziomie dostarczone usługi komunikacyjne można sprowadzić do dwóch
wywołań (bibliotecznych) — jednego do wysyłania komunikatów i drugiego do ich odbierania.
Wywołanie do wysyłania komunikatu może mieć następującą postać:
send(dest, &mptr);
Pierwsze wysyła komunikat wskazywany przez mptr pod adres identyfikowany przez dest
i powoduje zablokowanie procesu wywołującego do czasu wysłania komunikatu. Drugie powoduje
zablokowanie procesu wywołującego do momentu dotarcia komunikatu. Kiedy komunikat dotrze,
jest kopiowany do bufora wskazywanego przez mptr, a proces wywołujący zostaje odblokowany.
Parametr addr określa adres, którego nasłuchuje odbiorca komunikatu. Możliwych jest wiele
wariantów tych procedur oraz ich parametrów.
Jednym z problemów jest sposób adresacji. Ponieważ systemy wielokomputerowe są statyczne,
to przy stałej liczbie procesorów najłatwiejszym sposobem obsługi adresacji jest przekształcenie
addr na dwuczęściowy adres składający się z numeru procesora oraz numeru portu lub procesu
w adresowanym procesorze. Dzięki temu każdy proces może zarządzać własnymi adresami,
potencjalnie bez konfliktów.
Rysunek 8.19. (a) Blokujące wywołanie send; (b) nieblokujące wywołanie send
nadpisania komunikatu przez proces podczas transmisji są zbyt przerażające, aby się nad nimi
zastanawiać. Co gorsza, proces wysyłający nie ma pojęcia o tym, kiedy transmisja zostanie zakoń-
czona, a zatem nie wie, czy ponowne wykorzystanie bufora jest bezpieczne. W związku z tym
musi unikać jego modyfikowania przez bliżej nieokreślony czas.
Istnieją trzy wyjścia z tej sytuacji. Pierwszym rozwiązaniem jest powierzenie jądru kopio-
wania komunikatu do wewnętrznego bufora, a następnie umożliwienie kontynuowania procesu,
tak jak pokazano na rysunku 8.19(b). Z punktu widzenia nadawcy ten mechanizm działa tak samo
jak wywołanie blokujące: kiedy nadawca ponownie otrzyma sterowanie, może wykorzystać bufor
po raz kolejny. Oczywiście komunikaty do tej pory jeszcze nie zostały wysłane, ale nadawca nie
jest z tego powodu wstrzymywany. Wadą tej metody okazuje się konieczność kopiowania wszyst-
kich wychodzących komunikatów z przestrzeni użytkownika do przestrzeni jądra. Przy wielu
interfejsach sieciowych komunikat i tak będzie musiał być później kopiowany do sprzętowego
bufora transmisji, zatem pierwsza kopia w zasadzie zostanie utracona. Dodatkowa operacja kopio-
wania może znacząco zmniejszyć wydajność systemu.
Drugie rozwiązanie polega na wygenerowaniu przerwania do nadawcy (zasygnalizowaniu)
w momencie, kiedy komunikat został całkowicie przesłany, a bufor stał się znów dostępny. W tym
przypadku nie jest wymagane kopiowanie. Pozwala to zaoszczędzić czas, ale przerwania na poziomie
użytkownika powodują, że programowanie staje się skomplikowane, trudne i stwarza możliwość
wystąpienia sytuacji wyścigu, których nie można odtworzyć i prawie nie można debugować.
Trzecim rozwiązaniem jest kopiowanie bufora przy okazji zapisu — czyli oznaczenie go flagą
tylko do odczytu do chwili wysłania komunikatu. Jeśli bufor zostanie ponownie wykorzystany przed
wysłaniem komunikatu, wykonywana jest jego kopia. Problem z tym rozwiązaniem polega na
tym, że o ile bufor nie jest wyizolowany na swojej własnej stronie, o tyle zapisy do pobliskich
zmiennych również wymuszają kopiowanie. Poza tym potrzebne są dodatkowe zabiegi admini-
stracyjne, ponieważ operacja wysyłania komunikatu teraz jawnie wpływa na status odczytu/zapisu
strony. Ostatecznie wcześniej czy później strona zostanie zapisana ponownie, co zainicjuje ope-
rację kopiowania, która nie musi być konieczna.
Tak więc możliwości po stronie wysyłania są następujące:
1. Blokująca operacja send (procesor bezczynny podczas przesyłania komunikatu).
2. Nieblokująca operacja send połączona z kopiowaniem (czas procesora marnotrawiony na
dodatkowe kopiowanie).
3. Nieblokująca operacja send z przerwaniem (trudne programowanie).
4. Kopiowanie podczas zapisu (ostatecznie może być potrzebna dodatkowa operacja
kopiowania).
W normalnych warunkach pierwsza opcja wydaje się najwygodniejsza, zwłaszcza jeśli dostępnych
jest wiele wątków. W takim przypadku podczas gdy jeden wątek jest zablokowany i próbuje
wysyłać komunikat, inne wątki mogą kontynuować działanie. Metoda ta nie wymaga również
zarządzania buforami jądra. Co więcej, jak można się przekonać, porównując rysunek 8.19(a)
z rysunkiem 8.19(b), komunikat zazwyczaj zostanie przesłany szybciej, jeśli nie będzie potrzebne
kopiowanie.
Warto również zaznaczyć, że niektórzy autorzy stosują inne kryterium rozróżniania prymi-
tywu synchronicznego od asynchronicznego. W widoku alternatywnym wywołanie jest syn-
chroniczne tylko wtedy, gdy nadawca jest zablokowany do momentu odebrania komunikatu
i wysłania potwierdzenia [Andrews, 1991]. W świecie komunikacji w czasie rzeczywistym słowo
„synchroniczne” ma jeszcze inne znaczenie. Niestety, może to prowadzić do nieporozumień.
Wywołanie receive podobnie jak send może być blokujące lub nieblokujące. Wywołanie
blokujące zawiesza proces wywołujący do chwili nadejścia komunikatu. Jeśli dostępnych jest wiele
wątków, to proste podejście. Alternatywnie nieblokujące wywołanie receive informuje jądro
o tym, gdzie jest bufor, i zwraca sterowanie niemal natychmiast. Do zasygnalizowania faktu
nadejścia komunikatu można wykorzystać przerwanie. Przerwania są jednak trudne do zapro-
gramowania, a poza tym są wolne. W związku z tym wygodniejszym rozwiązaniem z punktu
widzenia odbiorcy może być odpytywanie o przychodzące komunikaty za pomocą procedury poll,
która informuje o tym, czy jakieś komunikaty oczekują w buforze. Jeśli tak jest, to proces wywo-
łujący może wywołać procedurę get_message, zwracającą pierwszy komunikat, który nadszedł.
W niektórych systemach kompilator wstawia w kodzie wywołania funkcji poll w odpowiednich
miejscach. Jednak określenie częstotliwości odpytywania jest jednak skomplikowane.
Jeszcze inną opcją jest mechanizm, w którym nadejście komunikatu powoduje spontaniczne
utworzenie nowego wątku w przestrzeni adresowej procesu odbierającego. Taki wątek jest okre-
ślany jako wątek wyskakujący (ang. pop-up thread). Wątek ten uruchamia określoną zawczasu
procedurę, której parametrem jest wskaźnik do wchodzącego komunikatu. Po przetworzeniu
komunikatu proces kończy działanie i jest automatycznie niszczony.
Odmianą tego pomysłu jest uruchomienie kodu odbiorcy bezpośrednio w procedurze obsługi
przerwania, bez zadawania sobie trudu tworzenia wyskakującego wątku. Aby ten mechanizm
działał jeszcze szybciej, sam komunikat może zawierać adres procedury obsługi. Dzięki temu,
kiedy nadejdzie komunikat, można wywołać procedurę obsługi za pomocą kilku instrukcji.
Wielką zaletą tego rozwiązania jest fakt, że w tym przypadku w ogóle nie jest potrzebne kopiowa-
nie. Procedura obsługi pobiera komunikat z karty interfejsu i przetwarza go „w locie”. Schemat
ten określa się terminem aktywnych komunikatów [von Eicken et al., 1992]. Ponieważ każdy
komunikat zawiera adres procedury obsługi, aktywne komunikaty działają tylko wtedy, gdy
procesy nadawców i odbiorców całkowicie sobie ufają.
Remote Procedure Call — zdalne wywołanie procedury). Stała się ona podstawą dla wielu pro-
gramów działających w systemach wielokomputerowych. Tradycyjnie procedurę wywołującą
określa się terminem „klienta”, natomiast procedurę wywoływaną — „serwera”. W tej książce
także będziemy posługiwać się tymi nazwami.
Idea mechanizmu RPC jest taka, aby zdalne wywołanie procedury wyglądało możliwie podob-
nie do wywołania lokalnego. W najprostszej formie, aby wywołać zdalną procedurę, program
kliencki musi być powiązany z niewielką procedurą biblioteczną zwaną procedurą pośredniczącą
klienta (ang. client stub). Reprezentuje ona procedurę serwera w przestrzeni adresowej klienta.
Na podobnej zasadzie serwer jest związany z procedurą nazywaną procedurą pośredniczącą serwera
(ang. server stub). Wspomniane procedury ukrywają fakt, że wywołanie procedury z klienta do
serwera nie jest lokalne.
Kroki wymagane do wykonania wywołania RPC pokazano na rysunku 8.20. W kroku 1. klient
wywołuje swoją procedurę pośredniczącą. Instrukcja ta jest lokalnym wywołaniem procedury
z odłożeniem parametrów na stosie, tak jak w przypadku procedury lokalnej. W kroku 2. proce-
dura pośrednicząca klienta pakuje parametry do komunikatu i realizuje wywołanie systemowe
w celu wysłania komunikatu. Pakowanie parametrów to tzw. przetaczanie (ang. marshaling).
W kroku 3. jądro przesyła komunikat z komputera-klienta na komputer-serwer. W kroku 4. jądro
przekazuje wchodzące pakiety do procedury pośredniczącej serwera (która wcześniej standardowo
wywołała procedurę receive). Na koniec, w kroku 5., procedura pośrednicząca serwera wywo-
łuje procedurę serwera. Odpowiedź jest przesyłana taką samą drogą, ale w odwrotnym kierunku.
Rysunek 8.20. Kroki wykonywania zdalnego wywołania procedury; namiastki procedur zostały
oznaczone szarym kolorem
Kluczowym elementem, na który należy zwrócić uwagę w tym przypadku, jest to, że pro-
cedura klienta, napisana przez użytkownika, wykonuje normalne (tzn. lokalne) wywołanie proce-
dury w namiastce procedury klienta. Wywoływana procedura ma taką samą nazwę jak procedura
serwera. Ponieważ procedura klienta oraz jego namiastka procedury są umieszczone w tej samej
przestrzeni adresowej, parametry są przekazywane w taki sam sposób. Na podobnej zasadzie
procedura serwera jest wywoływana przez procedurę znajdującą się w tej samej przestrzeni
adresowej z parametrami w oczekiwanej postaci. Dla procedury serwera nie ma niczego nie-
zwykłego. W ten sposób, zamiast wykonywania operacji wejścia-wyjścia za pomocą funkcji send
i receive, zdalna komunikacja odbywa się poprzez pozorowanie normalnych wywołań procedur.
Problemy implementacyjne
Pomimo pojęciowej elegancji mechanizmu RPC jest w nim „kilka węży, które czają się w zaro-
ślach”. Jednym z największych jest użycie parametrów w postaci wskaźników. W normalnych
warunkach przekazanie wskaźnika do procedury nie jest problemem. Wywoływana procedura
może używać wskaźnika w taki sam sposób jak procedura wywołująca, ponieważ wspomniane
dwie procedury rezydują w tej samej wirtualnej przestrzeni adresowej. W przypadku RPC
przekazywanie wskaźników jest niemożliwe, ponieważ klient i serwer znajdują się w różnych
przestrzeniach adresowych.
W niektórych przypadkach można zastosować pewne sztuczki umożliwiające przekazywanie
wskaźników. Załóżmy, że pierwszy parametr jest wskaźnikiem do zmiennej k typu integer. Pro-
cedura pośrednicząca klienta może przetoczyć zmienną k i przesłać ją na serwer klienta. Następ-
nie procedura pośrednicząca serwera tworzy — tak jak oczekiwano — wskaźnik do procedury
serwera. Kiedy procedura serwera zwróci sterowanie do procedury pośredniczącej serwera, ta
druga przesyła zmienną k do klienta, gdzie nowa wartość k jest kopiowana do starej, na wypadek
gdyby serwer ją zmodyfikował. W rezultacie standardowa sekwencja wywołań przez referencję
została zastąpiona przez operacje kopiowania i odtwarzania. Niestety, ta sztuczka nie zawsze
działa — gdy np. wskaźnik wskazuje na wykres lub inną złożoną strukturę danych. Z tego powodu
dla parametrów procedur wywoływanych zdalnie trzeba nałożyć pewne ograniczenia.
Drugim problemem jest to, że w językach ze słabą kontrolą typów, jak język C, można napi-
sać procedurę, która oblicza iloczyn dwóch wektorów (tablic) bez określania rozmiaru para-
metrów. Każda jest zakończona specjalną wartością znaną tylko dla procedury wywołującej
i wywoływanej. W tych okolicznościach procedura pośrednicząca klienta nie ma możliwości
przetoczenia parametrów: nie ma sposobu na określenie ich rozmiaru.
Trzeci problem polega na tym, że nie zawsze można wydedukować typy parametrów, nawet
jeśli jest dostępna formalna specyfikacja lub sam kod. Przykładem może być procedura printf,
która może mieć dowolną liczbę parametrów (co najmniej jeden). Parametry mogą tworzyć
dowolną kombinację danych typu integer, short, long, znaków, łańcuchów znakowych, liczb
zmiennoprzecinkowych o różnych rozmiarach oraz innych typów. Próba wywołania printf jako
zdalnej procedury — ze względu na to, że język C jest tak „tolerancyjny” — byłaby praktycznie
niemożliwa. Jednak reguła mówiąca o tym, że mechanizm RPC można wykorzystywać, pod
warunkiem że nie programujemy w C (lub C++), nie zyskałaby popularności.
Czwarty problem jest związany z używaniem zmiennych globalnych. Zwykle procedury wywo-
łująca i wywoływana oprócz komunikowania się za pomocą parametrów mogą wykorzystywać do
komunikacji zmienne globalne. Jeśli wywoływaną procedurę przeniesiemy na zdalny komputer,
to wywołanie kodu się nie powiedzie, ponieważ zmienne globalne nie będą współdzielone.
Przytoczone problemy nie oznaczają, że użycie mechanizmu RPC jest niemożliwe. W praktyce
mechanizm ten jest często stosowany, jednak jego wykorzystanie wymaga pewnych ograniczeń
oraz ostrożności.
jest nowy, nadal prowadzi się liczne badania nad tym zagadnieniem ([Cai i Strazdins, 2012],
[Choi i Jung, 2013], [Ohnishi i Yoshida, 2011]). DSM jest interesującą dziedziną badań, ponieważ
prezentuje wiele problemów i powikłań w systemach rozproszonych. Ponadto sama koncepcja
wywarła bardzo duży wpływ na branżę. W przypadku zastosowania mechanizmu DSM każda
strona jest zlokalizowana w jednej z pamięci z rysunku 8.1. Każdy komputer ma swoją własną
pamięć wirtualną oraz własne tablice stron. Kiedy procesor wykona instrukcję LOAD lub STORE
w odniesieniu do strony, której nie posiada, wykonywany jest rozkaz pułapki do systemu ope-
racyjnego. Następnie system operacyjny lokalizuje stronę i zadaje pytanie procesorowi, który
ją posiada, aby anulował odwzorowanie strony i przesłał ją przez wewnętrzną sieć. Kiedy strona
nadejdzie, jest ponownie odwzorowywana, a instrukcja, która spowodowała błąd, zostaje wzno-
wiona. W rezultacie system operacyjny obsługuje błędy braku strony ze zdalnej pamięci RAM
zamiast z lokalnego dysku. Z punktu widzenia użytkownika komputer wygląda tak, jakby był
wyposażony we współdzieloną pamięć.
Różnicę pomiędzy rzeczywistą współdzieloną pamięcią a mechanizmem DSM pokazano na
rysunku 8.21. Na rysunku 8.21(a) można zobaczyć rzeczywisty wieloprocesor z fizyczną pamięcią
współdzieloną zaimplementowaną sprzętowo. Na rysunku 8.21(b) widać mechanizm DSM
zaimplementowany przez system operacyjny. Na rysunku 8.21(c) pokazano jeszcze jedną postać
współdzielonej pamięci, zaimplementowaną przez jeszcze wyższe poziomy oprogramowania.
Do tej trzeciej opcji powrócimy w dalszej części tego rozdziału. Na razie skoncentrujemy się na
mechanizmie DSM.
Przyjrzyjmy się teraz szczegółom działania mechanizmu DSM. W systemie DSM przestrzeń
adresowa jest podzielona na strony, które są rozproszone pomiędzy wszystkie węzły w systemie.
Kiedy procesor odwoła się do adresu, który nie jest lokalny, wykonany zostaje rozkaz pułapki.
Oprogramowanie DSM pobiera stronę zawierającą adres i wznawia instrukcję, która spowodowała
awarię. Teraz wykonuje się ona prawidłowo. Koncepcję tę zilustrowano na rysunku 8.22(a) dla
przestrzeni adresowej zawierającej 16 stron i cztery węzły — każdy zdolny do przechowywania
sześciu stron.
Rysunek 8.22. (a) Strony przestrzeni adresowej rozproszone pomiędzy cztery maszyny;
(b) sytuacja po tym, jak procesor 0 odwoła się do strony 10 i zostanie ona tam przeniesiona;
(c) sytuacja, gdy strona 10 jest tylko do odczytu i zostanie zastosowana replikacja
W tym przykładzie, jeśli procesor 0 odwoła się do instrukcji lub danych na stronach 0, 2, 5
lub 9, odwołania są wykonywane lokalnie. Odwołania do innych stron powodują pułapki, np.
odwołanie do adresu na stronie 10 spowoduje wykonanie rozkazu pułapki do oprogramowania DSM,
które następnie przeniesie stronę 10 z węzła 1 do węzła 0, tak jak pokazano na rysunku 8.22(b).
Replikacja
Jednym z usprawnień podstawowego systemu, które pozwala na znaczną poprawę wydajności,
jest replikacja stron tylko do odczytu — np. tekstu programu, stałych tylko do odczytu lub innych
struktur danych tylko do odczytu. Jeśli np. strona 10 z rysunku 8.22 jest sekcją tekstu programu,
to wykorzystanie jej przez procesor 0 może spowodować przesłanie kopii do procesora 0 bez
Fałszywe współdzielenie
Mechanizm DSM jest podobny do systemów wieloprocesorowych pod pewnymi kluczowymi
względami. W obu systemach w przypadku odwołań do nielokalnego słowa w pamięci fragment
pamięci zawierający to słowo jest pobierany z bieżącej lokalizacji i umieszczany w pamięci
komputera, który wykonuje odwołanie (odpowiednio pamięci głównej lub podręcznej). Ważnym
problemem projektowym jest to, jak duży powinien być ten fragment. W systemach wielopro-
cesorowych rozmiar bloku pamięci podręcznej zwykle wynosi 32 lub 64 bajty. Ma to na celu
uniknięcie wiązania magistrali z transferem na zbyt długi czas. W systemach DSM jednostka musi
być wielokrotnością rozmiaru strony (ponieważ jednostka MMU operuje na stronach), ale wartość
ta może wynosić 1, 2, 4 lub więcej stron. W efekcie zastosowanie takiego zabiegu symuluje większy
rozmiar strony.
Większy rozmiar strony dla mechanizmu DSM ma swoje zalety i wady. Największą zaletą
jest to, że ponieważ czas inicjalizacji transferu sieciowego jest znaczący, to przesłanie 4096 baj-
tów nie trwa dużo dłużej niż przesłanie 1024 bajtów. Dzięki transferowi danych w większych
jednostkach, w przypadku konieczności przeniesienia dużego fragmentu przestrzeni adresowej,
można często zredukować liczbę operacji transferu danych. Właściwość ta jest szczególnie istotna
z tego względu, że wiele programów charakteryzuje się lokalnością odwołań. Oznacza to, że jeśli
program odwołał się do jednego słowa na stronie, istnieje prawdopodobieństwo, że w bliskiej
przyszłości odwoła się do innych słów na tej samej stronie.
Z drugiej strony w przypadku większych transferów sieć będzie związana na dłużej. W związku
z tym inne awarie spowodowane przez inne procesy będą zablokowane. Ponadto zbyt duży
efektywny rozmiar strony przyczynia się do powstania innego problemu znanego jako fałszywe
współdzielenie. Problem ten zilustrowano na rysunku 8.23. Przedstawiono na nim stronę zawie-
rającą dwie niezwiązane ze sobą zmienne współdzielone — A i B. Procesor 1 intensywnie wyko-
rzystuje zmienną A, odczytując ją i zapisując. Z kolei proces 2 często korzysta ze zmiennej B.
W tych okolicznościach strona zawierająca obie zmienne będzie stale przesyłana pomiędzy
tymi dwoma komputerami.
Problem w tym przypadku polega na tym, że chociaż zmienne nie są ze sobą związane, to
przypadkiem znalazły się na tej samej stronie. Kiedy zatem proces użyje jednej z nich, jednocze-
śnie uzyska drugą. Im większy efektywny rozmiar strony, tym częściej występuje fałszywe
współdzielenie. Z kolei im mniejszy efektywny rozmiar strony, tym rzadziej będzie ono zacho-
dziło. W zwykłych systemach pamięci wirtualnej nie zachodzi żadne zjawisko, które byłoby
podobne do omawianego.
Inteligentne kompilatory, które rozumieją problem i odpowiednio umieszczają zmienne
w przestrzeni adresowej, pozwalają zredukować efekt fałszywego współdzielenia i poprawić
Rysunek 8.23. Fałszywe współdzielenie strony zawierającej dwie niezwiązane ze sobą zmienne
wydajność. Jednak łatwiej to powiedzieć, niż zrobić. Co więcej, jeśli fałszywe współdzielenie
polega na tym, że węzeł 1 używa jednego elementu tablicy, a węzeł 2 używa innego elementu tej
samej tablicy, istnieje niewielka szansa na to, że nawet inteligentny kompilator wyeliminuje
problem.
porównuje bieżący stan strony z czystą kopią i tworzy komunikat zawierający listę wszystkich
słów, które uległy zmianie. Lista ta jest następnie przesyłana do procesora żądającego blokady
w celu zaktualizowania swojej kopii zamiast jej unieważniania [Keleher et al., 1994].
jakaś inna metryka. Jeśli węzeł jest przeciążony, wybiera losowo inny węzeł, pytając go, jakie
jest obciążenie (przy użyciu tej samej metryki). Jeśli sondowane obciążenie węzła spada poniżej
pewnej wartości progowej, wysyłany jest tam nowy proces [Eager et al., 1986]. Jeśli tak się nie
dzieje, do sondowania wybierana jest inna maszyna. Sondowanie nie trwa wiecznie. Jeśli podczas
N prób nie zostanie odnaleziony odpowiedni host, algorytm kończy działanie, a proces zaczyna
działać na maszynie, która stworzyła proces. Idea jest taka, aby mocno obciążone węzły spróbo-
wały pozbyć się nadmiaru pracy. Sytuację tę zilustrowano na rysunku 8.25(a), który przedstawia
mechanizm równoważenia obciążenia zainicjowany przez nadawcę.
Rysunek 8.25. (a) Przeładowany węzeł szuka mniej obciążonego węzła, aby przekazać mu procesy;
(b) pusty węzeł szukający pracy
W pracy [Eager et al., 1986] skonstruowano analityczny model kolejkowania dla tego algo-
rytmu. Wykorzystanie tego modelu pozwoliło ustalić, że algorytm zachowuje się poprawnie i jest
stabilny przy różnych parametrach, w tym różnych wartościach progowych, kosztach transferu
oraz limitach sondowania.
Niemniej jednak należy zauważyć, że w warunkach dużego obciążenia wszystkie komputery
będą stale wysyłały zapytania do innych komputerów, podejmując bezskuteczne próby znalezienia
węzła, który będzie chętny zaakceptować więcej pracy. Co prawda uda się odciążyć kilka pro-
cesów, ale próba osiągnięcia tego stanu jest związana ze znacznymi kosztami.
wtedy, kiedy system jest najmniej przygotowany na tolerowanie tej sytuacji — w warunkach
intensywnego obciążenia. W przypadku algorytmu inicjowanego przez odbiorcę, kiedy system
jest intensywnie obciążony, szanse na to, że węzeł będzie miał zbyt mało pracy, są nikłe. Jeśli
jednak to się zdarzy, łatwo będzie znaleźć pracę do przejęcia. Wtedy, kiedy jest mało pracy, algo-
rytm inicjowany przez odbiorcę generuje intensywny ruch związany z sondowaniem, ponieważ
wszystkie „bezrobotne” maszyny desperacko poszukują zajęcia. Jednak znacznie korzystniejsze
okazuje się ponoszenie kosztów wtedy, gdy system jest niedociążony, niż wtedy gdy jest on
przeciążony.
Możliwe jest również połączenie obu tych algorytmów. W takim przypadku węzły będą starały
się pozbyć pracy, jeśli będą miały jej za dużo, oraz uzyskać więcej pracy, jeśli nie będą jej miały
wystarczająco. Poza tym komputery mogą usprawnić losowe odpytywanie dzięki utrzymywaniu
historii prób. Dzięki temu można ustalić, czy jakieś komputery są stale niedociążone, czy
przeciążone. Wtedy można zastosować jeden z omówionych algorytmów, w zależności od tego,
czy inicjator chce się pozbyć pracy, czy zdobyć jej więcej.
Ethernet
Klasyczny Ethernet, opisany w standardzie IEEE 802.3, składa się z koncentrycznego kabla,
do którego jest podłączonych wiele komputerów. Nazwa kabla Ethernet pochodzi od luminiferous
ether, co oznacza świetlny eter. Kiedyś uważano, że przez niego rozchodzi się promieniowanie
elektromagnetyczne. (Kiedy w dziewiętnastym wieku brytyjski fizyk James Clerk Maxwell odkrył,
Ponieważ do tego samego kabla podłączonych jest wiele komputerów, potrzebny jest protokół,
aby zapobiec chaosowi. Aby przesłać pakiet w sieci Ethernet, komputer najpierw nasłuchuje kabla,
aby stwierdzić, czy żaden inny komputer w tym czasie nie nadaje. Jeśli nie, to zaczyna transmisję
pakietu składającego się z krótkiego nagłówka, za którym występują dane o rozmiarach od
0 do 1500 bajtów. Jeśli kabel jest używany, komputer czeka, aż bieżąca transmisja się zakończy,
a następnie rozpoczyna wysyłanie.
Jeśli dwa komputery zaczną nadawać jednocześnie, występuje kolizja, którą oba komputery
wykrywają. Oba reagują przerwaniem swojej transmisji, odczekaniem losowego czasu pomiędzy
0 a T μs, a następnie zaczynają działać od nowa. Jeśli wystąpi następna kolizja, wszystkie kom-
putery, które wchodzą w kolizję, losują czas oczekiwania w przedziale od 0 do 2T μs, a następnie
podejmują kolejną próbę. Przy każdej następnej kolizji maksymalny czas oczekiwania jest podwa-
jany, co zmniejsza szanse wystąpienia kolejnych kolizji. Algorytm ten nazywa się wykładniczym
cofaniem binarnym (ang. binary exponential backoff). Wcześniej spotkaliśmy się z nim podczas
omawiania sposobów zmniejszania kosztów odpytywania o blokady.
Sieć Ethernet ma ograniczenia w postaci maksymalnej długości kabla oraz maksymalnej
liczby komputerów, które można do niego podłączyć. Aby rozszerzyć dowolne z tych ograniczeń,
duże budynki lub kampusy mogą być okablowane wieloma sieciami Ethernet, które następnie
są połączone za pomocą specjalnych urządzeń określanych jako mosty (ang. bridges). Most
umożliwia przesyłanie ruchu z jednej sieci Ethernet do innej, przy czym źródłowa sieć znajduje
się po jednej stronie mostu, natomiast docelowa po drugiej.
W celu uniknięcia problemu kolizji w nowoczesnych sieciach Ethernet wykorzystuje się
przełączniki w sposób pokazany na rysunku 8.27(b). Każdy przełącznik ma pewną liczbę portów,
do których można podłączyć komputer, sieć Ethernet lub inny przełącznik. Kiedy pakiet pomyśl-
nie uniknie wszystkich kolizji i dotrze do przełącznika, jest tam buforowany i wysyłany do
portu, do którego zostaje podłączony komputer docelowy. Dzięki przydzieleniu każdemu kom-
puterowi własnego portu można wyeliminować wszystkie kolizje kosztem większych przełączni-
ków. Możliwe są również rozwiązania, w których do jednego portu podłącza się kilka komputerów.
Na rysunku 8.27(a) pokazano klasyczną sieć Ethernet, w której wiele komputerów połączonych
kablem za pośrednictwem rozgałęźników-wampirów jest podłączonych do jednego z portów
przełącznika.
Internet
Internet ewoluował z sieci ARPANET — eksperymentalnej sieci z przełączaniem pakietów,
finansowanej przez agencję ARPA (Advanced Research Projects Agency) Departamentu Obrony
Stanów Zjednoczonych. Działanie sieci zainicjowano w grudniu 1969 roku. Wtedy była złożona
z trzech komputerów w Kalifornii i jednego w Utah. Opracowano ją w szczytowym okresie zimnej
wojny. Miała to być sieć w maksymalnym stopniu odporna na błędy, która będzie mogła zapewnić
łączność wojskową nawet w warunkach bezpośredniego nuklearnego ataku na wiele części sieci,
dzięki automatycznemu przekierowaniu ruchu w taki sposób, by ominąć uszkodzone komputery.
Sieć ARPANET dynamicznie się rozwijała w latach siedemdziesiątych. Ostatecznie objęła
kilkaset komputerów. Wówczas dołączono do niej pakietową sieć radiową, sieć satelitarną i na
koniec tysiące sieci Ethernet. W rezultacie powstała federacja sieci, którą dziś znamy jako internet.
Internet składa się z dwóch rodzajów komputerów: hostów i routerów. Hosty to komputery PC,
notebooki, komputery podręczne, serwery, komputery mainframe oraz inne komputery nale-
żące do osób prywatnych lub firm, które chcą się podłączyć do internetu. Routery są specjalizo-
wanymi komputerami przełączającymi, które akceptują wchodzące pakiety w jednej z wielu
wchodzących linii i przesyłają je do wielu linii wychodzących. Router jest podobny do prze-
łącznika z rysunku 8.27(b), ale jednocześnie różni się od niego w sposób, który w tym miejscu
nie będzie nas interesował. Routery są połączone ze sobą w duże sieci, przy czym każdy router
jest wyposażony w kable lub światłowody do wielu innych routerów i hostów. Duże narodowe
lub światowe sieci routerów są zarządzane przez firmy telefoniczne oraz operatorów interneto-
wych ISP (od ang. Internet Service Providers).
Fragment sieci internet pokazano na rysunku 8.28. Na szczycie mamy jedną z sieci szkie-
letowych (ang. backbone) zarządzaną przez operatora sieci szkieletowej. Składa się ona z wielu
routerów połączonych szybkimi łączami światłowodowymi z sieciami szkieletowymi zarządza-
nymi przez inne firmy telefoniczne (często konkurujące ze sobą). Zazwyczaj żaden host nie ma
bezpośredniego połączenia z siecią szkieletową. Wyjątek stanowią komputery testowe i zarzą-
dzające, należące do firm telekomunikacyjnych.
Do routerów sieci szkieletowej za pomocą połączeń światłowodowych średniej szybkości
są podłączone sieci regionalne oraz routery dostawców usług internetowych. Z kolei w każdej
korporacyjnej sieci Ethernet występuje router, który jest podłączony do routerów sieci regio-
nalnej. Routery dostawców usług internetowych są podłączone do banków modemów używa-
nych przez klientów dostawców ISP. W ten sposób każdy host w internecie ma przynajmniej jedną
ścieżkę, a często wiele ścieżek do każdego innego hosta.
Cały ruch do internetu jest wysyłany w postaci pakietów. Wewnątrz pakietu jest zapisany
adres docelowy. Ten adres zostaje wykorzystany do routingu. Kiedy pakiet dociera do routera,
router wyodrębnia z niego adres docelowy i wyszukuje go w tablicy (lub jej części) w celu
określenia linii wyjściowej, a tym samym routera, do którego należy wysłać pakiet. Procedura
powtarza się do czasu, kiedy pakiet dotrze do hosta docelowego. Tablice routingu są bardzo
dynamiczne. Są one stale aktualizowane w miarę wyłączania i ponownego włączania linii oraz
zmieniających się warunków ruchu. Algorytmy routingu były przedmiotem wielu badań. Przez
lata wielokrotnie podlegały modyfikacjom.
Usługi sieciowe
Sieci komputerowe dostarczają usług hostom i procesom, które je wykorzystują. Usługa połą-
czeniowa (ang. connection-oriented service) jest zamodelowana według wzorca systemu telefo-
nicznego. Aby z kimś porozmawiać, podnosimy słuchawkę, wykręcamy numer, rozmawiamy,
a następnie odkładamy słuchawkę. Na podobnej zasadzie, w celu wykorzystania usługi sieciowej
zorientowanej na połączenie, użytkownik usługi najpierw ustanawia połączenie, korzysta z niego,
a następnie zwalnia połączenie. Zasadniczą cechą połączenia jest to, że zachowuje się ono jak
rura: nadawca wrzuca obiekty (bity) z jednej strony, a odbiorca pobiera je w tej samej kolejności
na drugim końcu.
Dla odróżnienia usługa bezpołączeniowa (ang. connectionless service) jest zamodelowana według
wzorca systemu pocztowego. Każdy komunikat (list) zawiera pełny adres docelowy i każdy jest
przesyłany przez system niezależnie od innych. Standardowo, kiedy dwa komunikaty są wysyłane
do tego samego miejsca, ten, który pierwszy zostanie wysłany, pierwszy dotrze na miejsce. Moż-
liwa jest jednak sytuacja, w której pierwszy wysłany komunikat zostanie opóźniony, w związku
z czym drugi dotrze w pierwszej kolejności. W przypadku usług połączeniowych taka sytuacja
nie jest możliwa.
Każdą usługę można scharakteryzować przez jakość usług. Niektóre usługi są niezawodne,
w tym sensie, że nigdy nie tracą danych. Niezawodna usługa zazwyczaj jest implementowana
w ten sposób, że odbiorca potwierdza odbiór każdego komunikatu poprzez wysłanie pakietu potwier-
dzającego. Dzięki temu nadawca ma pewność, że pakiet dotarł na miejsce. Proces potwierdzania
jest związany z kosztami i opóźnieniami, które są konieczne do tego, by można było wykryć utratę
pakietu, ale które bardzo spowalniają komunikację.
Typową sytuacją, w której ma uzasadnienie zastosowanie niezawodnej usługi połączeniowej,
jest transfer plików. Właściciel pliku chce mieć pewność, że wszystkie jego bity poprawnie
dotarły na miejsce w tej samej kolejności, w jakiej zostały wysłane. Bardzo niewielu użytkow-
ników usługi przesyłania plików zaakceptowałoby sytuację, w której w przesyłanym pliku od
czasu do czasu brakowałoby kilku bitów, nawet gdyby przesyłanie następowało bardzo szybko.
Niezawodne usługi połączeniowe występują w dwóch wariantach: sekwencji komunikatów
i strumieni bajtów. W pierwszym wariancie są zachowane granice komunikatów. Gdy wysyłane
są dwa 1-kilobajtowe komunikaty, docierają one jako dwa osobne 1-kilobajtowe komunikaty.
Nigdy nie tworzą jednego 2-kilobajtowego komunikatu. W tym drugim przypadku połączenie jest
po prostu strumieniem bajtów, bez granic komunikatów. Kiedy do odbiorcy dotrą 2 kB danych,
nie ma możliwości stwierdzenia, czy zostały one wysłane jako jeden 2-kilobajtowy komunikat, dwa
1-kilobajtowe komunikaty, czy 2048 1-bajtowych komunikatów. Jeśli strony książki zostaną
przesłane przez sieć do osoby zajmującej się składem w formie osobnych komunikatów, dobrze
by było, aby granice komunikatów były zachowane. Z drugiej strony w przypadku terminala
rejestrującego informacje w zdalnym systemie z podziałem czasu wystarczy strumień bajtów
od terminala do komputera. W tym scenariuszu nie istnieją granice komunikatu.
W przypadku niektórych aplikacji opóźnienie wprowadzane przez potwierdzenia jest nie-
dopuszczalnie wysokie. Jedną z takich aplikacji jest digitalizowany ruch głosowy. Użytkownicy
systemu telefonicznego wolą słyszeć jakieś szumy na linii lub zniekształcone słowo, niż wprowa-
dzać opóźnienia po to, by czekać na potwierdzenia.
Nie wszystkie aplikacje wymagają połączeń. Aby np. testować sieć, wystarczy mieć możliwość
wysłania pojedynczego pakietu, który z dużym prawdopodobieństwem (ale nie z gwarancją)
dotrze do adresata. Usługi bezpołączeniowe pozbawione elementów niezawodnościowych (tzn. bez
potwierdzeń) często są określane jako usługi datagramów. Nazwa jest analogią usługi telegra-
ficznej, w której nadawca także nie otrzymuje potwierdzenia.
W innych przypadkach wygoda braku konieczności ustanawiania połączenia w celu przesłania
krótkiego komunikatu jest pożądana, ale niezawodność ma kluczowe znaczenie. Dla takich apli-
kacji można wykorzystywać usługę datagramów z potwierdzeniami. Można ją porównać do wysy-
łania listu poleconego z żądaniem potwierdzenia odbioru. Kiedy nadejdzie potwierdzenie,
nadawca będzie miał absolutną pewność, że list został dostarczony do wskazanego adresata
i nie został zagubiony po drodze.
Jeszcze innym typem usług są usługi żądanie-odpowiedź. W tej usłudze nadawca wysyła
pojedynczy datagram zawierający żądanie. Odbiorca odsyła odpowiedź. Do tej kategorii można
zaliczyć zapytanie do lokalnej biblioteki o kraj, w którym używa się języka Uighur. Usługa żąda-
nie-odpowiedź jest zwykle implementowana jako sposób komunikacji w modelu klient-serwer:
klient wysyła żądanie, a serwer na nie odpowiada. Typy usług omówione powyżej zestawiono
na rysunku 8.29.
Protokoły sieciowe
We wszystkich sieciach obowiązują specjalizowane reguły dotyczące tego, jakie komunikaty
można przesyłać oraz jakie odpowiedzi mogą być zwracane w ramach reakcji na te komunikaty.
W pewnych okolicznościach (np. podczas transferu plików), w przypadku przesyłania komunikatu
W celu ustanowienia połączenia ze zdalnym hostem (lub nawet w celu przesłania datagramu)
konieczna jest znajomość adresu IP. Ponieważ zarządzanie listami 32-bitowych adresów IP
jest dla niektórych osób niewygodne, opracowano mechanizm tłumaczenia nazwy, znany jako
DNS (od ang. Domain Name System). Jest to baza danych, która odwzorowuje tekstowe nazwy
hostów na ich adresy IP. Dzięki temu można skorzystać z nazwy DNS star.cs.vu.nl zamiast odpo-
wiadającego jej adresu IP: 130.37.24.6. Nazwy DNS są powszechnie znane, ponieważ internetowe
adresy poczty elektronicznej mają postać nazwa-uzytkownika@NazwaDNS-hosta. Taki system
nazewnictwa umożliwia programowi pocztowemu na wysyłającym hoście wyszukanie w bazie
danych DNS adresu IP docelowego hosta, ustanowienie tam połączenia TCP z procesem demona
pocztowego, a następnie wysłanie komunikatu w postaci pliku. Wraz z komunikatem jest wysy-
łana nazwa-uzytkownika w celu zidentyfikowania skrzynki pocztowej, w której ma być umiesz-
czony komunikat.
Pierwotny paradygmat sieci WWW był dosyć prosty: na każdym komputerze może być
przechowywany jeden lub więcej dokumentów zwanych stronami WWW. Każda strona WWW
zawiera tekst, zdjęcia, ikony, dźwięki, filmy itp., a także hiperłącza (wskaźniki) do innych stron
WWW. Kiedy użytkownik zażąda strony, używając programu nazywanego przeglądarką WWW,
strona ta jest wyświetlana na ekranie. Kliknięcie łącza powoduje zastąpienie bieżącej strony tą
stroną, na którą wskazuje łącze. Chociaż w ostatnich latach do sieci WWW dołączono wiele róż-
nych ulepszeń, podstawowy paradygmat ciągle jest czytelny: sieć WWW to rozbudowany skie-
rowany graf dokumentów wskazujących na inne dokumenty, podobny do tego, który pokazano
na rysunku 8.31.
Każda strona WWW zawiera unikatowy adres, znany jako URL (Uniform Resource Locator)
w postaci protokół://nazwaDNS/nazwa-pliku. Najczęściej jest stosowany protokół HTTP (od ang.
HyperText Transfer Protocol), ale istnieje także protokół FTP i inne. Za nazwą protokołu wystę-
puje nazwa DNS hosta zawierającego plik. Na końcu jest lokalna nazwa pliku informująca
o tym, który plik jest potrzebny. Zatem adres URL w unikatowy sposób określa pojedynczy plik
w sieci obejmującej cały świat.
Mechanizm działania całego systemu jest następujący: sieć WWW, jest w zasadzie systemem
klient-serwer, w którym użytkownik jest klientem, a witryna WWW serwerem. Kiedy użytkownik
wprowadzi adres URL w przeglądarce, poprzez wpisanie go lub kliknięcie hiperłącza na bieżącej
stronie, przeglądarka podejmuje określone kroki zmierzające do pobrania żądanej strony
WWW. W ramach prostego przykładu przypuśćmy, że użytkownik wprowadził URL w postaci
http://www.minix3.org/doc/faq.html. Przeglądarka podejmuje wówczas następujące kroki w celu
pobrania strony.
1. Przeglądarka zadaje pytanie systemowi DNS o adres IP witryny www.minix3.org.
2. System DNS odpowiada, że witryna ta ma adres 66.147.238.215.
3. Przeglądarka nawiązuje połączenie TCP w porcie 80 z adresem 66.147.238.215.
4. Następnie wysyła żądanie pliku getting-started/index.html.
5. Serwer WWW www.minix3.org wysyła plik getting-started/index.html.
6. Przeglądarka wyświetla tekst z pliku getting-started/index.html.
7. Przeglądarka pobiera i wyświetla wszystkie zdjęcia na stronie.
8. Następuje zwolnienie połączenia TCP.
W największym przybliżeniu jest to podstawa sieci WWW oraz sposobu jej działania. Od
tamtej pory do podstawowej sieci WWW dodano wiele innych własności, takich jak arkusze
stylów, dynamiczne strony WWW generowane „w locie”, strony WWW zawierające niewielkie
programy lub skrypty uruchamiane na maszynie klienckiej i wiele innych. Omawianie ich wykra-
cza jednak poza zakres tej książki.
Model transferu
Pierwszym problemem jest wybór pomiędzy modelem wgrywanie-ściąganie a modelem zdalnego
dostępu. W przypadku zastosowania tego pierwszego, zilustrowanego na rysunku 8.32(a), proces
uzyskuje dostęp do pliku, najpierw kopiując go ze zdalnego serwera, gdzie ten plik rezyduje.
Jeśli ten plik ma być tylko odczytany, jest on następnie odczytywany lokalnie, w celu uzyskania
lepszej wydajności. Jeśli ma być zapisany, jest on zapisywany lokalnie. Kiedy proces wykona te
operacje, umieszcza zaktualizowany plik z powrotem na serwerze. W przypadku modelu zdalnego
dostępu plik pozostaje na serwerze, a klient wysyła polecenia do wykonania tam potrzebnych
działań, tak jak pokazano na rysunku 8.32(b).
Zaletą modelu wgrywanie/ściąganie jest jego prostota oraz to, że przesyłanie wszystkich
plików na raz jest wydajniejsze od przesyłania ich w niewielkich fragmentach. Do wad można
zaliczyć konieczność dbania o to, by na dysku lokalnym była wystarczająca ilość miejsca do zapi-
sania całego pliku. Oprócz tego przenoszenie całego pliku, gdy potrzebne są tylko jego fragmenty,
jest marnotrawstwem, a poza tym w przypadku wielu równoległych użytkowników powstają
problemy spójności.
Hierarchia katalogów
Pliki to tylko część historii. Inną częścią jest system katalogów. Wszystkie rozproszone systemy
plików obsługują katalogi zawierające wiele plików. Kolejnym problemem projektowym jest
odpowiedź na pytanie, czy wszystkie klienty mają taki sam widok hierarchii katalogów. Jako
przykład tego, co przez to rozumiemy, rozważmy rysunek 8.33. Na rysunku 8.33(a) pokazano
dwa serwery plików. W każdym z nich są trzy katalogi i kilka plików. Na rysunku 8.33(b) mamy
system, w którym wszystkie klienty (a także inne komputery) mają ten sam obraz rozproszonego
systemu plików. Jeśli ścieżka /D/E/x będzie prawidłowa na jednym komputerze, będzie prawi-
dłowa także na ich wszystkich.
Rysunek 8.33. (a) Dwa serwery plików; kwadraty oznaczają katalogi, a kółka pliki; (b) system,
w którym wszystkie klienty mają ten sam obraz systemu plików; (c) system, w którym różne
klienty mają inny obraz systemu plików
Dla odróżnienia na rysunku 8.33(c) różne komputery mają inny obraz systemu plików.
Powtórzmy poprzedni przykład — ścieżka /D/E/x może być prawidłowa dla klienta 1, ale nie
będzie prawidłowa dla klienta 2. W systemach zarządzających wieloma serwerami plików poprzez
zdalne montowanie normą jest rysunek 8.33(c). Okazuje się on elastyczny i prosty do zaimple-
mentowania, ale ma tę wadę, że cały system nie zachowuje się tak jak pojedynczy, przestarzały
system z podziałem czasu. W systemie z podziałem czasu system plików wygląda tak samo dla
każdego procesu — tak jak dla modelu z rysunku 8.33(b). Właściwość ta sprawia, że system ten
staje się łatwiejszy do zaprogramowania i zrozumienia.
Z tematem blisko związane jest pytanie o to, czy istnieje globalny katalog główny — taki,
który byłby rozpoznawany jako katalog główny przez wszystkie komputery. Jednym ze sposobów
uzyskania globalnego katalogu głównego jest stworzenie takiej konfiguracji, w której katalog
główny będzie zawierał po jednej pozycji dla każdego serwera i nie będzie zawierał nic więcej.
W takich okolicznościach ścieżki przyjmują postać /serwer/ścieżka. Ma to swoje wady, ale przynajm-
niej jest identyczne we wszystkich miejscach w systemie.
Przezroczystość nazewnictwa
Zasadniczy problem z taką formą nazewnictwa polega na tym, że nie jest ona w pełni przezro-
czysta. W tym kontekście istotne znaczenie mają dwie postacie przezroczystości, które warto
rozróżnić między sobą. Pierwsza z nich, przezroczystość lokalizacji, oznacza, że nazwa ścieżki
nie daje wskazówki, gdzie znajduje się plik. Ścieżka postaci /serwer1/katalog1/katalog2/x informuje
wszystkich, że plik x jest umieszczony na serwerze serwer1, ale nie mówi nic o tym, gdzie ten
serwer się znajduje. Serwer można przenosić w obrębie sieci w dowolne miejsce bez konieczności
zmiany nazwy ścieżki. A zatem ten system charakteryzuje się przezroczystością lokalizacji.
Przypuśćmy jednak, że plik x jest bardzo duży, a na serwerze 1 jest mało miejsca. Ponadto
załóżmy, że na serwerze serwer2 jest dużo miejsca. Dobrze by było, aby system mógł automa-
tycznie przenieść plik x na serwer serwer2. Niestety, jeśli pierwszym komponentem wszystkich
nazw ścieżek będzie serwer, system nie będzie mógł automatycznie przenieść pliku na inny
serwer, nawet jeśli na obu serwerach istnieją katalogi katalog1 i katalog2. Problem polega na
tym, że automatyczne przeniesienie pliku zmienia nazwę ścieżki z /serwer1/katalog1/katalog2/x
na /serwer2/ katalog1/katalog2/x. Programy, które miały wbudowany poprzedni skrypt, przestaną
działać, jeśli zmieni się ścieżka. O systemie, w którym można przemieszczać pliki bez zmiany
ich nazw, mówi się, że charakteryzuje się niezależnością od lokalizacji. Rozproszony system,
w którym nazwa komputera lub serwera są osadzone w ścieżkach do plików, oczywiście nie
jest niezależny od lokalizacji. System bazujący na zdalnym montowaniu również nie jest nie-
zależny od lokalizacji, ponieważ nie ma możliwości przeniesienia pliku z jednej grupy plików
(jednostki montowania) do innej z zachowaniem możliwości posługiwania się starą nazwą
ścieżki. Niezależność od lokalizacji nie jest łatwa do osiągnięcia, ale stanowi pożądaną własność
systemu rozproszonego.
W podsumowaniu tego, co powiedzieliśmy wcześniej, można stwierdzić, że istnieją trzy
popularne sposoby nadawania nazw plików i katalogów w systemie rozproszonym:
1. Nazwa komputera+nazwa ścieżki, np. /komputer/ścieżka lub komputer:ścieżka.
2. Montowanie zdalnych systemów plików w lokalnej hierarchii plików.
3. Pojedyncza przestrzeń nazw, która wygląda tak samo na wszystkich maszynach.
Pierwsze dwa są łatwe do zaimplementowania, zwłaszcza jako sposób połączenia istniejących
systemów, które nie były zaprojektowane do użytkowania w trybie rozproszonym. Implementacja
ostatniego jest trudna i wymaga uważnego projektu, ale ułatwia życie programistom i użyt-
kownikom.
łanie read zwraca wartość zapisaną przed chwilą. Taką sytuację pokazano na rysunku 8.34(a).
Na podobnej zasadzie, jeśli dwie operacje write są wykonywane jedna po drugiej w bliskim sąsiedz-
twie, wartość odczytana jest tą samą, która została zapisana w ostatniej operacji write. Taki
system wymusza kolejność wszystkich wywołań systemowych, a wszystkie procesory widzą tę
samą kolejność. Taki model będziemy określać jako spójność sekwencyjną.
Rysunek 8.34. (a) Spójność sekwencyjna; (b) w systemie rozproszonym z buforowaniem odczyt
pliku może zwracać przestarzałą wartość
W systemie rozproszonym można łatwo osiągnąć spójność sekwencyjną, o ile istnieje tylko
jeden serwer plików, a klienty nie buforują plików w pamięci podręcznej. Wszystkie operacje odczytu
i zapisu są kierowane bezpośrednio na serwer plików, który przetwarza je ściśle sekwencyjnie.
Jednak w praktyce, wydajność systemu rozproszonego, w którym wszystkie żądania plików
muszą być kierowane na pojedynczy serwer, często jest niska. Problem ten zazwyczaj daje się
rozwiązać poprzez umożliwienie klientom utrzymywania lokalnych kopii często wykorzysty-
wanych plików w ich prywatnych pamięciach podręcznych. Jeśli jednak klient 1 zmodyfikuje plik
umieszczony w lokalnej pamięci podręcznej, a niedługo potem klient 2 odczyta plik z serwera,
to drugi klient uzyska przestarzały plik. Taką sytuację zilustrowano na rysunku 8.34(b).
Jednym ze sposobów pokonania tej trudności jest natychmiastowa propagacja wszystkich
zmian w plikach umieszczonych w pamięciach podręcznych z powrotem na serwer. Chociaż
pojęciowo jest to proste rozwiązanie, okazuje się ono niewydajne. Alternatywnym rozwiązaniem
jest zliberalizowanie semantyki współdzielenia plików. Zamiast wymagania, aby operacja read
widziała efekty wszystkich wcześniejszych operacji write, można sformułować nową regułę,
która mówi: „Zmiany na otwartych plikach początkowo są widoczne tylko dla procesu, który je
wykonał. Dopiero po zamknięciu pliku wszystkie zmiany stają się widoczne dla innych procesów”.
Przyjęcie takiej reguły nie zmienia tego, co zilustrowano na rysunku 8.34(b). Zmienia jednak
ocenę działania mechanizmu (proces B uzyskuje początkową wartość pliku). Teraz to działanie
jest postrzegane jako prawidłowe. Kiedy klient 1 zamknie plik, przesyła kopię na serwer.
W związku z tym kolejne operacje odczytu zwracają nową wartość — tak jak oczekiwano. W efekcie
to model wgrywanie/ściąganie z rysunku 8.32. Ta reguła semantyczna jest powszechnie imple-
mentowana i znana jako semantyka sesji.
Wykorzystanie semantyki sesji podnosi kwestię tego, co się stanie, jeśli dwa klienty (lub
więcej) spróbują jednocześnie umieścić w pamięci podręcznej i zmodyfikować ten sam plik. Jedno
z rozwiązań mówi, że w miarę jak po kolei są zamykane pliki, ich wartości są przesyłane na
serwer. Ostateczny wynik zależy zatem od tego, kto zamknie plik jako ostatni. Mniej wygodnym,
ale nieco łatwiejszym do zaimplementowania rozwiązaniem jest stwierdzenie, że ostateczny
będzie wynik jednego z kandydatów, ale bez określania tego, który to ma być klient.
Alternatywnym podejściem do semantyki sesji jest użycie modelu wgrywanie-ściąganie razem
z automatycznym blokowaniem ściągniętego pliku. Próby ściągnięcia pliku przez innych klientów
będą wstrzymane tak długo, aż pierwszy klient zwróci plik. Jeśli jest duże zapotrzebowanie na
plik, serwer może wysyłać komunikaty do klienta przetrzymującego plik z prośbą o to, by się
pospieszył. Prośba ta może być jednak spełniona lub nie. Podsumujmy: utrzymanie właściwej
semantyki współdzielonych plików jest trudnym zadaniem — bez eleganckich i wydajnych
rozwiązań.
Poważny problem z systemem CORBA polega na tym, że każdy obiekt jest umieszczony
tylko na jednym serwerze. Oznacza to, że w przypadku obiektów intensywnie wykorzystywanych
na komputerach klienckich na całym świecie wydajność będzie bardzo niska. W praktyce system
CORBA funkcjonuje w sposób możliwy do przyjęcia tylko w systemach małej skali, nadaje się np.
do łączenia procesów na jednym komputerze, w jednej sieci LAN lub w obrębie jednej firmy.
Dla krotek dostępne są cztery operacje. Pierwsza z nich, out, umieszcza krotkę w przestrzeni
krotek; np. instrukcja:
out("abc", 2, 5);
umieszcza krotkę ("abc", 2, 5) w przestrzeni krotek. Pola operacji out są zwykle stałymi,
zmiennymi lub wyrażeniami. I tak w instrukcji:
out("matr ix−1", i, j, 3.14);
powodującej wyprowadzenie krotki z czterema polami, drugie i trzecie pole jest określone
przez bieżące wartości zmiennych i i j.
Krotki można wyodrębniać z przestrzeni krotek za pomocą prymitywu in. Są one adresowane
przez treść, a nie przez nazwę czy adres. Pola operacji in mogą być wyrażeniami lub parame-
trami formalnymi. Przeanalizujmy następujący przykład:
in("abc", 2, ?i);
Powyższa operacja „przeszukuje” przestrzeń krotek, by znaleźć krotkę składającą się z ciągu
"abc", liczby typu integer o wartości 2 oraz trzeciego pola zawierającego dowolną liczbę typu
integer (przy założeniu, że liczba i jest typu integer). Jeśli szukana wartość zostanie znaleziona,
jest usuwana z przestrzeni krotek, a do zmiennej i przypisana zostaje wartość trzeciego pola.
Dopasowywanie i usuwanie to operacje atomowe, zatem kiedy dwa procesy uruchomią jedno-
cześnie tę samą operację in, tylko dla jednego z nich zakończy się ona sukcesem, chyba że
występują dwie lub więcej krotek spełniających kryteria. Przestrzeń krotek może nawet zawierać
wiele kopii tej samej krotki.
Stan semafora S jest określony przez liczbę krotek ("semafor S") w przestrzeni krotek. Jeśli nie
istnieje żadna krotka spełniająca kryterium, każda próba uzyskania jej spowoduje zablokowanie,
aż jakiś proces dostarczy potrzebną krotkę.
Oprócz operacji out i in system Linda dysponuje także prymitywem read, który jest podobny
do in — z wyjątkiem tego, że nie usuwa krotki z przestrzeni krotek. Dostępny jest również
prymityw eval, który powoduje współbieżne obliczanie jego parametrów i umieszczenie wyni-
kowej krotki w przestrzeni krotek. Mechanizm ten można wykorzystać do wykonywania dowol-
nych obliczeń. Właśnie w ten sposób w systemie Linda są tworzone procesy współbieżne.
Publikuj-subskrybuj
Następny przykład modelu bazującego na koordynacji, zainspirowany przez system Linda, nosi
nazwę publikuj-subskrybuj [Oki et al., 1993]. Składa się on z szeregu procesów połączonych
przez sieć rozgłoszeniową. Każdy proces może być producentem informacji, konsumentem
informacji albo i jednym, i drugim.
Kiedy producent informacji dysponuje nowymi informacjami (np. nową ceną), rozgłasza te
informacje w sieci w postaci krotki. To działanie nazywa się publikowaniem. Każda krotka zawiera
hierarchiczną linię tematu, zawierającą wiele pól oddzielonych kropkami. Procesy zainteresowane
określonymi informacjami mogą dokonać subskrypcji określonych tematów. W polach tematów
mogą również wykorzystywać symbole wieloznaczne. Subskrypcja jest wykonywana poprzez
nakazanie szukania określonych tematów procesowi demona krotek działającemu na tym samym
komputerze i monitorującemu opublikowane krotki.
Niewiele jest badań poświęconych systemom operacyjnym, które byłyby równie popularne jak
procesory wielordzeniowe, systemy wieloprocesorowe czy systemy rozproszone. Oprócz bezpo-
średnich problemów mapowania funkcji systemu operacyjnego na system składający się z wielu
rdzeni przetwarzania przedmiotem badań są synchronizacja i spójność oraz sposoby, aby sys-
temy te działały szybciej i były bardziej niezawodne.
8.5. PODSUMOWANIE
8.5.
PODSUMOWANIE
Dzięki zastosowaniu wielu procesorów systemy komputerowe mogą stać się szybsze i bardziej
niezawodne. Istnieją cztery organizacje systemów zawierających wiele procesorów. Są to sys-
temy wieloprocesorowe, wielokomputerowe, maszyny wirtualne oraz systemy rozproszone.
Każdy z wymienionych typów charakteryzuje się własnymi cechami oraz jest powiązany ze zbio-
rem określonych problemów.
System wieloprocesorowy składa się z dwóch lub większej liczby procesorów, które współ-
użytkują pamięć RAM. Często te procesory składają się z wielu rdzeni. Rdzenie i procesory
mogą być połączone za pomocą magistrali, przełącznika krzyżowego lub wielostopniowej sieci
przełączników. Możliwe są różne konfiguracje systemów operacyjnych. Należą do nich przydzie-
lenie każdemu procesorowi własnego systemu operacyjnego, zastosowanie jednego nadrzęd-
nego systemu operacyjnego i przypisanie reszcie roli systemów podrzędnych lub wykorzystanie
symetrycznego systemu wieloprocesorowego, w którym istnieje jedna kopia systemu opera-
cyjnego do uruchomienia na dowolnym procesorze. W tym drugim przypadku potrzebne są
blokady do zapewnienia synchronizacji. Jeśli blokada nie jest dostępna, procesor może się zapę-
tlić lub przeprowadzić przełączenie kontekstu. Dostępnych jest kilka algorytmów szeregowania,
włącznie z podziałem czasu, podziałem miejsca oraz szeregowaniem zespołów.
Systemy wielokomputerowe także mają do dyspozycji dwa procesory lub więcej, ale każdy
z tych procesorów wykorzystuje swoją prywatną pamięć. Procesory te nie współużytkują wspól-
nego obszaru pamięci RAM, dlatego komunikacja pomiędzy nimi przebiega dzięki przekazy-
waniu komunikatów. W niektórych przypadkach karta interfejsu sieciowego jest wyposażona
we własny procesor. Wówczas w celu uniknięcia wyścigów należy uważnie zorganizować komu-
nikację pomiędzy procesorem głównym a procesorem karty interfejsu. W komunikacji poziomu
użytkownika w systemach wielokomputerowych często wykorzystuje się zdalne wywołania
procedur, można jednak także skorzystać z rozproszonej pamięci współdzielonej. Problemem
w tym przypadku jest równoważenie obciążenia procesów. Wykorzystuje się do tego algorytmy
inicjowane przez nadawcę, przez odbiorcę oraz algorytmy licytacyjne.
Maszyny wirtualne pozwalają, aby pomimo istnienia jednego procesora lub dwóch procesorów
użytkownik miał iluzję, że istnieje ich więcej, niż jest naprawdę. Dzięki temu można uruchomić
wiele systemów operacyjnych lub wiele (niezgodnych ze sobą) wersji tego samego systemu
operacyjnego jednocześnie na tym samym sprzęcie. Przy połączeniu tej technologii z projektem
wielordzeniowym każdy komputer staje się potencjalnie dużym systemem wielokomputerowym
na dużą skalę.
Systemy rozproszone są luźno związanymi ze sobą węzłami. Każdy węzeł jest kompletnym
komputerem wyposażonym w pełny zbiór urządzeń zewnętrznych oraz własny system operacyjny.
Systemy te często działają na dużym obszarze geograficznym. Nad systemem operacyjnym często
umieszczana jest warstwa middleware, która zapewnia jednolity interfejs dla aplikacji. Istnieją
różne typy warstwy middleware. Mogą one bazować na dokumentach, plikach, obiektach oraz
koordynacji. Do przykładów można zaliczyć sieć WWW oraz systemy CORBA, Linda i Jini.
PYTANIA
1. Czy system grup dyskusyjnych USENET lub projekt SETI@home można uznać za sys-
temy rozproszone? (System SETI@home wykorzystuje kilka milionów komputerów
osobistych do analizowania danych z teleskopu radiowego w celu poszukiwania inteligencji
pozaziemskich). Jeśli tak, to w jaki sposób można je powiązać z kategoriami opisanymi na
rysunku 8.1?
2. Co się stanie, jeśli trzy procesory w systemie wieloprocesorowym spróbują uzyskać
dostęp do dokładnie tego samego słowa pamięci w tym samym momencie?
3. Ile procesorów potrzeba do nasycenia magistrali działającej z częstotliwością 400 MHz,
jeśli procesor wydaje jedno żądanie dostępu do pamięci w każdej instrukcji, a komputer
działa z szybkością około 200 MIPS? Załóżmy, że odwołanie do pamięci wymaga jednego
cyklu magistrali. Rozwiąż ten problem jeszcze raz dla systemu, w którym stosowane są
pamięci podręczne, a współczynnik trafień do pamięci podręcznej wynosi 90%. Jaki
współczynnik trafień do pamięci podręcznej byłby potrzebny, aby magistralę mogły współ-
dzielić 32 procesory i aby jej nie przeciążyły?
4. Przypuśćmy, że przewód łączący przełącznik 2A i przełącznik 3B w sieci omega
z rysunku 8.5 został przerwany. Od których węzłów które węzły zostaną odseparowane?
5. W jaki sposób jest wykonywana obsługa sygnałów w modelu z rysunku 8.7?
6. W przypadku wywołania systemowego w modelu z rysunku 8.8 problem musi być roz-
wiązany natychmiast po wystąpieniu pułapki, która nie występuje w modelu z rysunku 8.7.
Jaka jest natura tego problemu i jak można go rozwiązać?
7. Przepisz kod wejścia do obszaru krytycznego z rysunku 2.15, wykorzystując czyste
wywołanie read w celu zmniejszenia obciążenia powodowanego przez instrukcję TSL.
8. Wielordzeniowe procesory zaczynają się pojawiać w konwencjonalnych komputerach
desktop i laptopach. Niedługo można się spodziewać komputerów desktop wyposażonych
w kilkadziesiąt lub kilkaset rdzeni. Jedną z możliwości wykorzystania tej mocy oblicze-
niowej jest zrównoleglenie standardowych aplikacji desktop, takich jak edytory tekstu
lub przeglądarki WWW. Innym możliwym sposobem wykorzystania mocy obliczeniowej
wielu rdzeni jest zrównoleglenie usług oferowanych przez system operacyjny — np.
przetwarzania TCP — oraz powszechnie używanych usług bibliotecznych — np. bez-
piecznych funkcji bibliotecznych http. Które podejście wydaje się bardziej obiecujące?
Dlaczego?
9. Czy do uniknięcia sytuacji wyścigu w systemie operacyjnym SMP konieczne są obszary
krytyczne w sekcjach kodu, czy też muteksy w odniesieniu do struktur danych równie
dobrze spełnią swoje zadanie?
10. W przypadku użycia instrukcji TSL do synchronizacji systemu wieloprocesorowego blok
pamięci podręcznej zawierający muteks będzie wielokrotnie przesyłany pomiędzy pro-
cesorem będącym w posiadaniu blokady a procesorem żądającym jej (jeśli oba te proce-
sory będą korzystały z bloku). W celu zredukowania ruchu na magistrali procesor żądający
blokady wykonuje jedną instrukcję TSL co 50 cykli magistrali. Natomiast procesor posia-
dający blokadę zawsze sięga do bloku pamięci podręcznej pomiędzy instrukcjami TSL.
Jaki fragment pasma magistrali jest zużywany na przesyłanie bloku pamięci podręcznej,
jeśli składa się on z 16 32-bitowych słów, z których każde wymaga do transferu jednego
cyklu magistrali, a magistrala działa z szybkością 400 MHz?
11. W tekście tego rozdziału zasugerowano, że pomiędzy wywołaniami instrukcji TSL do odpy-
tywania o blokadę jest wykorzystywany algorytm wykładniczego cofania binarnego.
Sugerowano również, aby pomiędzy kolejnymi pytaniami było jak największe opóźnienie.
Czy algorytm działałby prawidłowo, gdyby nie zastosowano maksymalnego opóźnienia?
12. Przypuśćmy, że nie mamy do dyspozycji instrukcji TSL do synchronizacji systemu wie-
loprocesorowego. Zamiast niej jest dostępna inna instrukcja — SWP — która atomowo
wymienia zawartość rejestru ze słowem w pamięci. Czy można to wykorzystać w celu
wykonania synchronizacji systemu wieloprocesorowego? Jeśli tak, jak to można zrobić?
A jeśli nie, dlaczego to nie działa?
13. Twoim zadaniem jest obliczenie, w jakim stopniu blokada pętlowa obciąża magistralę.
Wyobraź sobie, że procesor wykonuje kolejne instrukcje co 5 ns. Po wykonaniu instrukcji
23. Rozważmy problem alokacji procesora z rysunku 8.24. Przypuśćmy, że proces H został
przeniesiony z węzła 2 do węzła 3. Jaka jest teraz całkowita waga zewnętrznego ruchu?
24. Niektóre systemy wielokomputerowe umożliwiają migrację działających procesów pomię-
dzy węzłami. Czy wystarczy zatrzymać proces, zamrozić jego obraz pamięci, a następnie
przenieść proces do innego węzła? Wymień dwa nietrywialne problemy, które trzeba roz-
wiązać, aby ten mechanizm zadziałał.
25. Dlaczego istnieje ograniczenie długości kabla w sieci Ethernet?
26. Na rysunku 8.26 trzecia i czwarta warstwa zostały oznaczone „Warstwa pośrednia”
i „Aplikacja” na wszystkich czterech komputerach. Pod jakim względem są one takie same
na wszystkich platformach, a pod jakim różnią się pomiędzy sobą?
27. Na rysunku 8.29 wyszczególniono sześć różnych typów usług. Jaki typ usług jest naj-
właściwszy dla każdej z poniższych aplikacji?
(a) Wideo na żądanie przez internet.
(b) Pobieranie strony WWW.
28. Nazwy DNS mają strukturę hierarchiczną, np. cs.uni.edu lub sales.generalwidget.com. Spo-
sobem utrzymania bazy danych DNS byłoby stworzenie jednej centralnej bazy danych.
Nie robi się tego jednak, ponieważ otrzymywałaby ona zbyt wiele żądań w ciągu sekundy.
Zaproponuj sposób utrzymania bazy danych DNS w praktyce.
29. Podczas omawiania sposobów przetwarzania adresów URL w przeglądarce powiedziano,
że połączenia są wykonywane w porcie 80. Dlaczego?
30. Migracja maszyn wirtualnych może być łatwiejsza od migracji procesów, ale w dalszym
ciągu może stwarzać trudności. Jakie problemy mogą się pojawić podczas migracji maszyny
wirtualnej?
31. Kiedy przeglądarka pobiera stronę WWW, najpierw nawiązuje połączenie TCP w celu
pobrania tekstu strony (w języku HTML). Następnie zamyka połączenie i analizuje stronę.
Jeśli na stronie są rysunki lub ikony, to nawiązuje oddzielne połączenie TCP w celu ich
pobrania. Zaproponuj dwa alternatywne projekty poprawiające wydajność takiego systemu.
32. Kiedy używa się semantyki sesji, prawdą jest, że zmiany w pliku są zawsze widoczne dla
procesu wykonującego zmiany i nigdy nie są widoczne dla procesów na innych kompute-
rach. Pozostaje jednak otwartą kwestią to, czy zmiany powinny być natychmiast widoczne
dla innych procesów na tym samym komputerze. Podaj argumenty przemawiające za
każdym z rozwiązań.
33. Pod jakim względem dostęp obiektowy jest lepszy od wykorzystania współdzielonej
pamięci, w przypadku gdy wiele procesów chce uzyskać dostęp do danych?
34. Podczas wykonywania operacji in systemu Linda, w celu zlokalizowania krotki, liniowe
przeszukiwanie całej przestrzeni krotek jest bardzo niewydajne. Zaprojektuj sposób orga-
nizacji przestrzeni krotek, które przyspieszy wyszukiwanie we wszystkich operacjach in.
35. Kopiowanie buforów wymaga czasu. Napisz program w języku C, który oblicza ten czas
w systemie, do którego masz dostęp. Użyj funkcji clock lub times w celu określenia, ile
czasu zajmie skopiowanie rozbudowanej tablicy. Wykonaj testy dla różnych rozmiarów
tablic, aby oddzielić czas kopiowania od dodatkowych kosztów obliczeniowych.
36. Napisz funkcje w języku C, które będą używane jako procedury pośredniczące klienta
i serwera do wykonywania wywołań RPC standardowej funkcji printf. Napisz również
program główny do testowania tych funkcji. Klient i serwer powinny komunikować się
ze sobą za pośrednictwem struktury danych, którą można przesyłać przez sieć. Możesz
wprowadzić rozsądne ograniczenia rozmiaru ciągu formatu oraz liczby, typów i rozmiaru
zmiennych, jakie będą akceptowane przez procedurę pośredniczącą klienta.
37. Napisz program implementujący opisane w punkcie 8.2 algorytmy równoważenia obcią-
żenia inicjowane przez nadawcę i inicjowane przez odbiorcę. Algorytmy powinny
pobierać jako dane wejściowe listę nowo utworzonych zadań określonych w postaci
procesor_tworzący, czas_rozpoczęcia, wymagany_czas_procesora, gdzie procesor_tworzący
oznacza numer procesora, który utworzył zadanie, czas_rozpoczęcia to czas utworzenia
zadania, natomiast wymagany_czas_procesora to ilość czasu procesora potrzebna do wyko-
nania zadania (określona w sekundach). Załóż, że węzeł jest przeładowany, jeśli ma
jedno zadanie, a drugie zostało utworzone. Załóż też, że węzeł ma zbyt małe obciążenie,
jeśli nie ma zadań. Wyświetl liczbę komunikatów sondowania przesyłanych przez oba
algorytmy w warunkach wysokiego i niskiego obciążenia. Wyświetl także maksymalną
i minimalną liczbę sond wysłanych przez dowolny host i odebranych przez dowolny host.
W celu generowania obciążenia napisz dwa generatory obciążenia. Pierwszy powinien
symulować wysokie obciążenie — generować N zadań co ŚCZ sekund, gdzie ŚCZ
oznacza średni czas trwania zadania, a N oznacza liczbę procesorów. Zadania mogą mieć
różny czas trwania, ale średni czas trwania musi wynosić ŚCZ. Zadania powinny być losowo
tworzone (przydzielane) dla wszystkich procesorów. Drugi generator powinien symulować
niskie obciążenie — losowo generować N/3 zadań co ŚCZ sekund. Zmieniaj inne usta-
wienia parametrów dla generatorów obciążenia i sprawdź, jaki mają wpływ na liczbę
komunikatów-sond.
38. Jednym z najprostszych sposobów implementacji systemu publikuj-subskrybuj jest użycie
centralnego brokera, który odbiera opublikowane artykuły i rozprowadza je do odpo-
wiednich subskrybentów. Napisz wielowątkową aplikację, która emuluje system publi-
kuj-subskrybuj bazujący na brokerze. Wątki wydawcy i subskrybenta mogą komunikować
się z brokerem za pośrednictwem (współdzielonej) pamięci. Każdy komunikat powinien
rozpoczynać się od pola rozmiaru, za którym powinna występować wskazana liczba
znaków. Wydawcy wysyłają komunikaty do brokera. Pierwszy wiersz komunikatu zawiera
hierarchiczny wiersz tematu rozdzielony kropkami. Za nim występuje jeden (lub więcej)
wiersz tworzący publikowany artykuł. Subskrybenci wysyłają komunikat do brokera skła-
dający się z pojedynczego wiersza. Zawiera on hierarchiczny wiersz zainteresowania
rozdzielany kropkami i określa artykuły, którymi subskrybenci są zainteresowani.
Wiersz zainteresowania może zawierać symbol wieloznaczny „*”. Broker musi odpo-
wiedzieć, wysyłając wszystkie (opublikowane w przeszłości) artykuły, które odpowiadają
zainteresowaniom subskrybenta. Artykuły w komunikacie są oddzielone wierszem
„POCZĄTEK NOWEGO ARTYKUŁU”. Subskrybent powinien wyświetlać każdy odebrany
komunikat razem ze swoim identyfikatorem (tzn. wierszem zainteresowania). Sub-
skrybent powinien odbierać wszystkie nowe opublikowane artykuły, które odpowiadają
jego zainteresowaniom. Wątki wydawcy i subskrybenta mogą być tworzone dynamicznie
poprzez wpisanie „W” lub „S” (od nazw „wydawca” i „subskrybent”) oraz podanie hierar-
chicznego wiersza temat/zainteresowanie. Następnie wydawcy zostaną zapytani o arty-
kuł. Wpisanie pojedynczego wiersza zawierającego kropkę („.”) będzie oznaczało koniec
artykułu (projekt ten można również zaimplementować z wykorzystaniem procesów
komunikujących się przez TCP).
593
mogła przeczytać ani zmodyfikować cudzych plików, ale umożliwić przy tym świadome udostęp-
nienie pozostałym użytkownikom wybranych plików. Wypracowano wówczas rozbudowane
modele i mechanizmy eliminujące ryzyko uzyskiwania uprawnień dostępu przez użytkowników,
którzy nie byli do tego upoważnieni.
Niektóre z tych modeli i mechanizmów definiowały klasy użytkowników, zamiast opisywać
uprawnienia poszczególnych osób. Przykładowo na komputerze używanym w jednostce woj-
skowej dane muszą być oznaczane jako ściśle tajne, tajne, poufne lub jawne, a kaprale nie mogą
mieć dostępu do katalogów należących do generałów, niezależnie od tego, kto w danej jednostce
jest kapralem, a kto generałem. W ciągu tych kilku dekad wszystkie te zagadnienia zostały pod-
dane szczegółowej analizie, precyzyjnie opisane i wielokrotnie zaimplementowane.
Przez te dekady obowiązywało niepisane założenie, zgodnie z którym raz dokonany wybór
i raz przeprowadzona implementacja wystarczyły do prawidłowego funkcjonowania oprogramo-
wania i skutecznego egzekwowania przyjętych reguł. Modele i oprogramowanie w zdecydowanej
większości były na tyle proste, że spełnienie tego założenia nie stanowiło problemu. Oznacza
to, że jeśli Sylwia teoretycznie nie miała prawa przeglądać plików Marty, rzeczywiście nie było to
możliwe.
Wzrost popularności komputerów osobistych, tabletów, smartfonów i rozwój internetu cał-
kowicie zmieniły tę sytuację. Wiele urządzeń ma tylko jednego użytkownika, więc zagrożenie,
że jeden użytkownik uzyska dostęp do plików innego użytkownika, prawie zniknęło. Oczywiście
nie dotyczy to współdzielonych serwerów (np. działających w chmurze). W tych środowiskach
olbrzymie znaczenie ma ścisła izolacja użytkowników. Podsłuchiwanie również nadal wystę-
puje — choćby w sieciach. Jeśli Sylwia pracuje w tej samej sieci Wi-Fi co Marta, to może prze-
chwytywać całą jej transmisję sieciową. Problem przechwytywania pakietów sieciowych w sie-
ciach Wi-Fi wcale nie jest nowy. Przed tym samym problemem stanął Juliusz Cezar — ponad
2 tysiące lat temu. Cezar musiał wysyłać wiadomości do swoich legionów i sojuszników, ale
zawsze istniało ryzyko, że komunikat zostanie przechwycony przez jego wrogów. Aby upewnić
się, że jego wrogowie nie będą w stanie odczytać przesyłanych rozkazów, Cezar użył szyfro-
wania polegającego na zastąpieniu każdej litery w wiadomości literą przesuniętą w alfabecie
o trzy pozycje w lewo. Zatem litera D stawała się literą A, litera E została przekształcona na B itd.
Chociaż współczesne techniki szyfrowania są bardziej wyrafinowane, zasada jest taka sama: bez
znajomości klucza osoba postronna nie powinna być w stanie odczytać wiadomości.
Niestety, to nie zawsze działa, ponieważ sieć nie jest jedynym miejscem, gdzie Sylwia może
szpiegować Martę. Jeśli Sylwii uda się włamać do komputera Marty, to będzie mogła prze-
chwycić zarówno wszystkie wychodzące wiadomości przed ich zaszyfrowaniem, jak i wiadomości
przychodzące po zaszyfrowaniu. Włamanie się do czyjegoś komputera nie zawsze jest łatwe,
ale o wiele łatwiejsze, niż powinno być (i zazwyczaj znacznie łatwiejsze od złamania czyjegoś
2048-bitowego klucza szyfrowania). Problem jest spowodowany błędami w oprogramowaniu
działającym na komputerze Marty. Na szczęście dla Sylwii systemy operacyjne i aplikacje są
coraz bardziej rozbudowane, a to daje gwarancję, że błędów nie zabraknie. Gdy błąd dotyczy
zabezpieczeń, jest to tzw. słaby punkt (ang. vulnerability). Gdy Sylwia wykryje słaby punkt
w oprogramowaniu Marty, musi wprowadzić do niego takie bajty, które spowodują wywołanie
błędu. Dane wejściowe, które wyzwalają błąd, zwykle nazywa się eksploitem. Skuteczny eksploit
często umożliwia intruzowi przejęcie pełnej kontroli nad czyimś komputerem.
Mówiąc inaczej: podczas gdy Marta myśli, że jest jedynym użytkownikiem komputera, to
naprawdę wcale nie jest sama!
Napastnicy mogą uruchamiać eksploity ręcznie lub automatycznie, za pomocą wirusów (ang.
viruses) lub robaków (ang. worms). Różnica pomiędzy wirusem a robakiem nie zawsze jest oczy-
wista. Większość osób zgadza się, że wirus do rozmnożenia potrzebuje co najmniej jakiejś inte-
rakcji z użytkownikiem; np. aby nastąpiła infekcja, użytkownik powinien kliknąć załącznik. Z kolei
robaki rozmnażają się samoczynnie. Ich propagacja następuje bez względu na to, co robi użyt-
kownik. Istnieje również możliwość, że użytkownik z własnej woli zainstaluje kod napastnika.
Napastnik może np. utworzyć pakiet popularnego, ale drogiego oprogramowania (gry albo proce-
sora tekstu) i udostępnić je za darmo w internecie. Wielu użytkowników nie może oprzeć się
pokusie zainstalowania oprogramowania, jeśli jest „za darmo”. Jednak zainstalowanie tej darmo-
wej gry powoduje automatyczną instalację dodatkowych funkcji. To tak, jakbyśmy przekazali
swój komputer PC i wszystko, co się w nim znajduje, cyberprzestępcom. Takie oprogramowanie
to tzw. konie trojańskie, które omówimy wkrótce.
Aby kompleksowo opisać temat, ten rozdział podzielono na dwie główne części. Na początek
szczegółowo zaprezentujemy krajobraz bezpieczeństwa. Omówimy zagrożenia i napastników
(podrozdział 9.1), naturę zabezpieczeń i ataków (podrozdział 9.2), różne podejścia do zapew-
nienia kontroli dostępu (podrozdział 9.3) oraz modele zabezpieczeń (podrozdział 9.4). Oprócz tego
opiszemy zagadnienia związane z kryptografią, która stanowi rdzeń zapewnienia bezpieczeństwa
(podrozdział 9.5), oraz różne sposoby przeprowadzania uwierzytelniania (podrozdział 9.6).
Na tym zakończymy omawianie zagadnień teoretycznych — i zajmiemy się praktyką. Kolejne
cztery podrozdziały prezentują praktyczne problemy, które występują w życiu codziennym.
Opiszemy sztuczki, których używają napastnicy, aby przejąć kontrolę nad systemem kompute-
rowym, a także środki mające temu zapobiec. Zajmiemy się również zagrożeniami wewnętrznymi
oraz różnego rodzaju cyfrowymi szkodnikami. Rozdział zakończymy krótkim omówieniem
prowadzonych badań dotyczących bezpieczeństwa komputerów oraz zwięzłym podsumowaniem.
Warto zwrócić uwagę, że chociaż ta książka jest o systemach operacyjnych, to zagadnienia
bezpieczeństwa systemów operacyjnych i sieci tak się ze sobą przeplatają, że jest prawie nie-
możliwe, aby je rozdzielić. Przykładowo wirusy rozprzestrzeniają się za pośrednictwem sieci, ale
wpływają na funkcjonowanie systemu operacyjnego. Na wszelki wypadek postanowiliśmy włączyć
do tego rozdziału materiał blisko spokrewniony, ale niezwiązany bezpośrednio z funkcjonowaniem
systemów operacyjnych.
9.1.1. Zagrożenia
W wielu artykułach poświęconych zabezpieczeniom systemów informacyjnych dziedzinę bez-
pieczeństwa dekomponuje się na trzy elementy: poufność (ang. confidentiality), integralność (ang.
integrity) oraz dostępność (ang. availability). Razem te komponenty są określane skrótem CIA. Te
trzy cele zestawiono w tabeli 9.1. Stanowią one podstawowe właściwości zabezpieczeń, które
musimy chronić przed atakami i podsłuchem — np. prowadzonym przez inne CIA.
i usunąć lub zmienić niektóre rekordy, co może spowodować naruszenie ich integralności. Wreszcie
przeprowadzenie ataku zablokowania usługi może doprowadzić do utraty dostępności jednego
lub kilku systemów komputerowych.
Intruz może zaatakować system na wiele sposobów — niektóre z nich opiszemy w dalszej
części tego rozdziału. Obecnie wiele ataków jest wykonywanych przy użyciu bardzo zaawanso-
wanych narzędzi i usług. Niektóre z tych narzędzi są budowane przez tzw. hakerów w czarnych
kapeluszach (ang. black-hat hackers), natomiast inne przez hakerów w białych kapeluszach (ang.
white-hat hackers). Tak jak w starych westernach, czarne charaktery w cyfrowym świecie noszą
czarne kapelusze i jeżdżą na koniach trojańskich, natomiast dobrzy hakerzy noszą białe kapelusze
i kodują szybciej niż ich cienie.
W prasie popularnej zazwyczaj stosuje się ogólne określenie „haker” wyłącznie w odniesieniu
do „czarnych charakterów”. Okazuje się jednak, że w środowisku profesjonalistów określenie
„haker” jest zarezerwowane raczej dla świetnych programistów. Chociaż niektórzy tak rozumiani
hakerzy z pewnością mają złe zamiary, większość nie podejmuje żadnych nieetycznych działań.
Obraz rysowany w prasie popularnej jest więc błędny. Przez wzgląd na prawdziwych hakerów
będziemy posługiwać się tym terminem w oryginalnym znaczeniu, a osoby próbujące włamywać
się do systemów komputerowych będziemy określać mianem krakerów (ang. crackers) lub czar-
nych kapeluszy.
Wróćmy do narzędzi używanych do przeprowadzania ataków. Ku zaskoczeniu wiele z nich
jest dziełem białych kapeluszy. Wyjaśnieniem może być to, że o ile krakerzy mogą i często
używają takich narzędzi, o tyle zostały one stworzone przede wszystkim jako wygodne mechani-
zmy testowania zabezpieczeń systemu komputerowego lub sieci. I tak narzędzie nmap pomaga
napastnikom ustalić usługi sieci oferowane przez system komputerowy za pomocą techniki
skanowania portów. Jedną z najprostszych technik skanowania oferowanych przez program nmap
jest próba ustanowienia połączeń TCP we wszystkich możliwych numerach portów w systemie
komputerowym. Jeśli zestawienie połączenia do portu zakończy się powodzeniem, to znaczy,
że po drugiej stronie musi być serwer, który nasłuchuje na tym porcie. Ponadto, ponieważ wiele
usług korzysta z dobrze znanych numerów portów, tester zabezpieczeń (lub napastnik) może
uzyskać szczegółowe informacje o tym, jakie usługi są uruchomione na komputerze. Mówiąc
inaczej, narzędzie nmap może być przydatne zarówno dla napastników, jak i obrońców. Tę wła-
ściwość określa się jako podwójne wykorzystanie (ang. dual use). Inny zestaw narzędzi, określany
nazwą dsniff, oferuje szereg możliwości monitorowania ruchu w sieci i przekierowywania pakie-
tów sieciowych. Z kolei program LOIC (ang. Low Orbit Ion Cannon — dosł. niskoorbitowe działo
jonowe) nie jest (wyłącznie) bronią science fiction do niszczenia wrogów w odległej galaktyce,
ale także narzędziem do przeprowadzania ataków typu DoS. A dzięki frameworkowi Metasploit,
który oferuje setki wygodnych eksploitów przeciwko różnego rodzaju celom, przeprowadzanie
ataków nigdy nie było łatwiejsze. Oczywiście wszystkie te narzędzia mają podwójne zastosowania.
Podobnie jak noże i topory, nie są złe same w sobie.
Cyberprzestępcy oferują również szeroki zakres usług (często online) do szerzenia swojego
panowania: rozprzestrzeniania złośliwego oprogramowania, prania pieniędzy, przekierowywania
ruchu, świadczenia usług hostingu bez pytania oraz wielu innych działań. Większość przestęp-
czej aktywności w internecie bazuje na infrastrukturze znanej jako sieć botnet, która składa się
z tysięcy (a czasami milionów) komputerów, nad którymi została przejęta kontrola — często są
to zwykłe komputery niczemu niewinnych i zupełnie nieświadomych użytkowników. Istnieje wiele
sposobów, dzięki którym napastnicy mogą przejąć kontrolę nad czyimś komputerem. Mogą np.
zaoferować darmową, ale zainfekowaną złośliwym kodem wersję popularnego oprogramowania.
Smutną prawdą jest to, że wielu użytkownikom trudno jest się oprzeć obietnicy darmowej
(„skrakowanej”) wersji drogiego oprogramowania.
Niestety, zainstalowanie takich programów daje napastnikowi pełny dostęp do komputera.
To tak, jakbyśmy wręczyli klucz od domu komuś zupełnie obcemu. Kiedy komputer działa pod
kontrolą napastnika, określa się go jako bot lub zombie. Zazwyczaj prawowity użytkownik niczego
nie dostrzega. Współczesne sieci botnet, składające się z setek tysięcy komputerów zombie,
są siłą roboczą wielu przestępczych działań. Kilkaset tysięcy komputerów PC to bardzo dużo
maszyn do wyszukiwania danych bankowych lub wykorzystania do rozsyłania spamu. Spróbujmy
tylko pomyśleć, jaka „rzeź” może nastąpić, gdy milion zombie skieruje swoje działa LOIC
przeciwko niczego niespodziewającemu się celowi. Czasami skutki ataku wykraczają poza ramy
samych systemów komputerowych i docierają do świata fizycznego. Jednym z przykładów może
być atak na system gospodarki odpadami w obszarze Maroochy Shire, niedaleko Brisbane
w Queensland w Australii. Niezadowolony ekspracownik firmy zajmującej się instalacją sys-
temu kanalizacji nie był zachwycony, gdy rada obszaru Maroochy Shire odrzuciła jego podanie
o pracę. Postanowił, że nie będzie się gniewał, ale wyrówna rachunki. Przejął kontrolę nad sys-
temem sterowania kanalizacją i spowodował wyciek milionów litrów ścieków do parków, rzek
i wód przybrzeżnych (co spowodowało zatrucie wielu ryb).
Nie brakuje grup pałających niechęcią do pewnych państw lub grup (często etnicznych) po
prostu złych na cały świat i gotowych do dokonywania maksymalnych zniszczeń w infrastrukturze
bez względu na naturę powodowanych szkód i na to, kim będą faktyczne ofiary tych działań.
Tacy ludzie zwykle uważają, że atakowanie komputerów ich wrogów jest czymś zupełnie
właściwym, mimo że zwykle nie są w stanie skoncentrować swoich działań na właściwych
przeciwnikach.
Drugą skrajnością jest wojna cybernetyczna (ang. cyberwarfare). Cyberbroń, powszechnie
określana jako Stuxnet, fizycznie zniszczyła wirówki w zakładzie wzbogacania uranu w irańskim
Natanz. Jak się powszechnie uważa, spowodowało to znaczne spowolnienie w programie nukle-
arnym realizowanym w Iranie. Chociaż żadna organizacja nie przyznała się do tego ataku, to
należy przypuszczać, że tak wyrafinowany mechanizm prawdopodobnie został opracowany przez
tajne służby jednego lub większej liczby krajów nieprzyjaznych Iranowi.
Jednym z ważnych aspektów problemu bezpieczeństwa związanych z poufnością jest prywat-
ność (ang. privacy), czyli ochrona przed nieprawidłowym wykorzystywaniem danych osobowych.
Ten aspekt ma ścisły związek z wieloma problemami prawnymi i moralnymi. Czy rządy powinny
gromadzić dane o wszystkich obywatelach, aby na tej podstawie identyfikować oszustów podat-
kowych i osoby próbujące wyłudzać zasiłki? Czy policja powinna mieć dostęp do wszystkich
danych, aby zapobiegać przestępczości zorganizowanej? Czy instytucje rządowe powinny mieć
prawo monitorowania milionów telefonów komórkowych w nadziei wytropienia potencjalnych
terrorystów? Czy pracodawcy i firmy ubezpieczeniowe mają prawo gromadzić dane swoich pra-
cowników i klientów? Co będzie, jeśli okaże się, że wymienione prawa stoją w sprzeczności
z prawami jednostki? Wszystkie te problemy są bardzo ważne, ale ich omówienie wykraczałoby
poza zakres tematyczny tej książki.
9.1.2. Intruzi
Skoro większość ludzi przestrzega prawa, po co w ogóle mielibyśmy się martwić o bezpieczeń-
stwo? Zajmujemy się tym problemem dlatego, że istnieje garstka, która ma do prawa stosunek
zupełnie odmienny i która chce powodować problemy (zwykle dla własnych korzyści). W litera-
turze poświęconej bezpieczeństwu ludzi przebywających w miejscach, w których nie są mile
widziani, określa się mianem napastników (ang. attackers), intruzów (ang. intruders) lub oponentów
(ang. adversaries). Kilka dziesięcioleci temu włamywanie się do systemów komputerowych miało
na celu jedynie pokazanie znajomym, jacy jesteśmy mądrzy. Dziś nie jest to już jedyny ani nawet
najważniejszy powód włamywania się do systemów. Istnieje wiele różnych rodzajów napastników
o różnej motywacji: kradzież, hakerstwo polityczne, wandalizm, terroryzm, wojna cybernetyczna,
szpiegostwo, spam, wymuszenia, oszustwa, a od czasu do czasu osoba atakująca nadal chce po
prostu pokazać i obnażyć słabe zabezpieczenia w organizacji.
Napastnikami mogą być zarówno niezbyt uzdolnieni przedstawiciele „czarnych kapeluszy”,
określani także mianem skrypciarzy (ang. script-kiddies), jak i bardzo utalentowani krakerzy.
Należą do nich i profesjonaliści świadczący swoje usługi przestępcom, instytucjom rządowym
(np. policji, wojsku, tajnym służbom) lub firmom ochroniarskim, i hobbyści, którzy hakingiem
zajmują się w wolnym czasie. Nie ulega wątpliwości, że próba powstrzymania wrogiego państwa
przed kradzieżą tajemnicy wojskowej to całkiem inna sprawa niż próba powstrzymania studentów
przed wstawieniem na stronie internetowej zabawnej „wiadomości dnia”. Nakład pracy potrzebny
do zabezpieczenia i ochrony wyraźnie zależy od tego, kim jest potencjalny przeciwnik.
Istnieje wiele sposobów łamania zabezpieczeń systemu komputerowego. Często w ogóle nie
są one wyrafinowane. Wiele osób np. ustawia swoje kody PIN na wartość 0000 allbo hasła na
słowo „hasło” — jest to co prawda łatwe do zapamiętania, ale niezbyt bezpieczne. Są też osoby,
które postępują odwrotnie. Wybierają bardzo skomplikowane hasła, których nie sposób zapa-
miętać. Zatem zapisują je na karteczkach i przyklejają do klawiatury lub ekranu. W ten sposób
ktoś mający fizyczny dostęp do komputera (łącznie z personelem sprzątającym, sekretarką
i wszystkimi odwiedzającymi) także ma dostęp do wszystkiego na komputerze. Istnieje wiele
innych przykładów. Znane są przypadki gubienia dysków pendrive z poufnymi informacjami przez
wysokich rangą urzędników, wyrzucania do kosza starych dysków twardych zawierających tajem-
nice handlowe bez ich odpowiedniego zniszczenia itd.
Niemniej jednak niektóre z najważniejszych incydentów łamania zabezpieczeń następują
w wyniku przeprowadzenia wyrafinowanych cyberataków. W tej książce interesują nas przede
wszystkim ataki dotyczące systemów operacyjnych. Oznacza to, że pominiemy ataki na witryny
internetowe czy bazy danych SQL. Zamiast tego skoncentrujemy się na atakach, w których to
system operacyjny jest celem albo odgrywa ważną rolę w wyegzekwowaniu (lub częściej w nie-
udanym egzekwowaniu) zasad zabezpieczeń.
Ogólnie rozróżniamy ataki, w których napastnicy próbują biernie wykraść informacje, oraz
ataki, w których starają się oni aktywnie modyfikować działania programu komputerowego. Przy-
kładem ataku pasywnego jest podsłuchiwanie ruchu w sieci i podejmowanie prób złamania
szyfrowania (o ile jest stosowane), aby dostać się do danych. W ataku aktywnym intruz może
przejąć kontrolę nad przeglądarką WWW użytkownika i spowodować, aby wykonała złośliwy
kod — np. by dokonać kradzieży danych karty kredytowej. W podobny sposób możemy wyróżnić
kryptografię, czyli ogół mechanizmów do modyfikowania wiadomości lub pliku w taki sposób, aby
odczytanie z niego pierwotnych danych bez klucza było trudne, oraz hartowanie oprogramowania
(ang. software hardening), czyli dodawanie do programu mechanizmów, które utrudniają napast-
nikom modyfikowanie jego działania. W systemach operacyjnych kryptografia jest wyko-
rzystywana do wielu celów: do bezpiecznego przesyłania danych w sieci, do bezpiecznego
przechowywania plików na dysku, do szyfrowania haseł w pliku haseł itp. Hartowanie progra-
mów również jest wykorzystywane w wielu obszarach: aby uniemożliwić intruzom wstrzykiwanie
nowego kodu do działającego oprogramowania, aby przydzielić każdemu z procesów dokładnie
takie uprawnienia, jakie są mu potrzebne do realizacji jego zadań i żadnych innych itp.
Bezpieczeństwo jest łatwiejsze do osiągnięcia, jeśli istnieje czytelny model tego, co powinno
być chronione i kto ma prawo do wykonywania określonych działań. W tej dziedzinie opubliko-
wano wiele prac, zatem w tej książce możemy jedynie zasygnalizować temat. Skoncentrujemy
się na kilku modelach ogólnych oraz mechanizmach ich egzekwowania.
W każdym momencie każdy proces działa w jakiejś domenie ochrony. Innymi słowy, zawsze
istnieje jakiś zbiór obiektów, do których ten proces może uzyskać dostęp, a dla każdego z tych
obiektów istnieje zbiór uprawnień definiujących zakres dopuszczalnych operacji. W czasie swo-
jego wykonywania procesy mogą się przełączać pomiędzy domenami. Reguły tego przełączania
w dużej mierze zależą od modelu przyjętego w danym systemie.
Aby opisana koncepcja domen ochrony była bardziej konkretna, przeanalizujemy teraz przy-
kład systemu operacyjnego UNIX (czyli w praktyce rodziny systemów Linux, FreeBSD i pokrew-
nych). W systemie UNIX domena procesu jest definiowana przez jego identyfikatory UID oraz
GID. Kiedy użytkownik loguje się w systemie, powłoka uzyskuje identyfikatory UID i GID
z odpowiedniego wpisu w pliku haseł, po czym udostępnia te identyfikatory swoim procesom
potomnym. Na podstawie dowolnej kombinacji (UID, GID) można sporządzić kompletną listę
obiektów (m.in. plików, w tym urządzeń wejścia-wyjścia reprezentowanych przez pliki specjalne),
które mogą być przedmiotem dostępu, oraz możliwych form tego dostępu (zapisu, odczytu lub
wykonania). Dwa obiekty z tą samą kombinacją obu identyfikatorów zawsze mają dostęp do tego
samego zbioru obiektów. Procesy z różnymi wartościami identyfikatorów UID i GID mają dostęp
do różnych zbiorów plików, które jednak mogą się częściowo pokrywać.
Co więcej, każdy proces w systemie operacyjnym UNIX składa się z dwóch części — części
użytkownika i części jądra. Kiedy proces wykonuje wywołanie systemowe, w praktyce przełącza
się z części użytkownika do części jądra. Część jądra ma dostęp do innego zbioru obiektów niż
część użytkownika. Jądro może uzyskiwać dostęp np. do wszystkich stron pamięci fizycznej,
całego dysku i wszystkich innych zasobów chronionych. Warto więc zapamiętać, że wywołanie
systemowe powoduje przełączenie domeny.
Jeśli jakiś proces wykonuje operację exec na jakimś pliku z włączonym bitem SETUID lub
SETGID, w rzeczywistości uzyskuje nowy efektywny identyfikator UID lub GID. Inna kombinacja
(UID, GID) oznacza, że dany proces ma dostęp do innego zbioru plików i operacji. Znaczy to, że
także wykonywanie programu z ustawionym bitem SETUID lub SETGID powoduje przełączenie
domeny, ponieważ zmieniają się dostępne uprawnienia.
W tej sytuacji niezwykle ważne jest pytanie, jak dany system określa, które obiekty są dostępne
dla poszczególnych domen. Przynajmniej na poziomie koncepcyjnym należałoby utrzymywać
wielką macierz z wierszami reprezentującymi domeny i kolumnami reprezentującymi obiekty.
Każda komórka (element) tej macierzy powinna zawierać ewentualne uprawnienia, czyli operacje,
które dana domena może wykonywać na odpowiednim obiekcie. Przykładową macierz dla domen
z rysunku 9.2 pokazano na rysunku 9.3. Na podstawie tej macierzy i numeru bieżącej domeny
system może określić, czy żądana forma dostępu do danego obiektu z poziomu tej domeny jest
możliwa.
Także samo przełączanie domen można dość łatwo włączyć do tego modelu macierzy —
wystarczy przyjąć, że sama domena jest obiektem, na którym można wykonać operację wejścia
(enter). Na rysunku 9.4 pokazano macierz z rysunku 9.3 uzupełnioną o trzy domeny repre-
zentowane przez obiekty. Procesy w pierwszej domenie mogą być przełączane do drugiej domeny,
ale po tej operacji nie mogą wrócić do pierwszej domeny.
Rysunek 9.5. Przykład użycia list kontroli dostępu do zarządzania dostępem do plików
Z każdym plikiem jest skojarzona odrębna lista ACL. Lista ACL dla pliku F1 zawiera dwa
wpisy (oddzielone średnikiem). Z pierwszego wpisu wynika, że każdy proces należący do użyt-
kownika A może odczytywać i zapisywać zawartość tego pliku. Drugi wpis określa, że każdy proces
należący do użytkownika B może odczytywać zawartość tego pliku. Wszelkie inne formy dostępu
żądane przez tych użytkowników oraz wszystkie żądania formułowane przez pozostałych użyt-
kowników są zabronione. Warto zwrócić uwagę na sposób przypisywania uprawnień na pozio-
mie użytkowników, nie procesów. Zgodnie z prezentowanymi regułami każdy proces należący
do użytkownika A może odczytywać i zapisywać plik F1. Nie ma znaczenia, czy istnieje jeden
taki proces, czy np. sto procesów. Opisywany system ochrony bierze pod uwagę identyfikator
właściciela, nie procesu.
Lista ACL dla pliku F2 zawiera trzy elementy — użytkownicy A, B i C mogą odczytywać
zawartość tego pliku, a użytkownik B może dodatkowo zapisywać jego zawartość. Żadne inne
formy dostępu nie są możliwe. Wszystko wskazuje na to, że plik F3 jest programem wykonywal-
nym, ponieważ użytkownicy B i C mogą go zarówno odczytywać, jak i wykonywać. B może
dodatkowo zapisywać zawartość tego pliku.
Gdyby Sylwia spróbowała uzyskać dostęp do jednego z tych plików, odpowiedź systemu
operacyjnego byłaby zależna od tego, w której roli zalogowała się w tym systemie. W trakcie
logowania system może zażądać od Sylwii wyboru jednej z jej grup — może się okazać, że dla
odróżnienia poszczególnych grup, do których należy, posługuje się różnymi nazwami użyt-
kownika i (lub) hasłami. Zadaniem tego schematu jest uniemożliwienie Sylwii dostępu do pliku
password, jeśli w danej chwili występuje w roli członka klubu miłośników gołębi. Dostęp do tego
pliku będzie miała dopiero po zalogowaniu jako administrator systemowy.
W pewnych przypadkach użytkownik może mieć dostęp do niektórych plików niezależnie od
grupy wskazanej podczas logowania w systemie. Można ten przypadek obsłużyć poprzez wpro-
wadzenie pojęcia symbolu wieloznacznego (ang. wildcard) reprezentującego wszystkie możliwości.
Zapis w następującej postaci:
sylwia, *: RW
daje prawo odczytu i zapisu danego pliku wszystkim z wyjątkiem Witolda. Takie rozwiązanie jest
możliwe, ponieważ kolejne elementy tego wyrażenia są przetwarzane w porządku ich zapisania,
a jeśli zostanie znalezione dopasowanie, dalsze zapisy nie są przetwarzane. Ponieważ w tym
przypadku użytkownik jest dopasowywany do pierwszego wpisu, Witold automatycznie otrzy-
muje uprawnienia (none), czyli w praktyce brak uprawnień. Poszukiwania kończą się w tym
punkcie. System nawet nie dochodzi do części wskazującej na uprawnienia wszystkich pozosta-
łych użytkowników.
Alternatywnym sposobem uwzględniania grup jest stosowanie wpisów na liście ACL obej-
mujących albo identyfikatory UID, albo identyfikatory GID (zamiast kombinacji obu identyfi-
katorów). Przykładowo wpis dla pliku pigeon_data mógłby mieć następującą postać:
dorota: RW; filip: RW; pigfan: RW
Tym razem prawo odczytu i zapisu tego pliku będą mieli Dorota i Filip, a także wszyscy człon-
kowie grupy pigfan.
Zdarza się, że jakiś użytkownik lub jakaś grupa użytkowników dysponuje uprawnieniami
wykonywania określonych operacji na pliku, którego właściciel decyduje się wycofać swoją zgodę
na te działania. Jeśli korzystamy z listy kontroli dostępu, wycofanie raz przyznanych uprawnień
jest stosunkowo proste — wystarczy wprowadzić odpowiednią zmianę na samej liście ACL.
Jeśli jednak lista ACL jest weryfikowana tylko raz, w czasie otwierania odpowiedniego pliku,
ewentualna zmiana najprawdopodobniej wpłynie tylko na przyszłe wywołania operacji open.
Oznacza to, że dla każdego już otwartego pliku nadal będą stosowane uprawnienia z momentu,
w którym został otwarty, nawet jeśli dany użytkownik utracił uprawnienia dostępu do tego pliku.
9.3.3. Uprawnienia
Innym sposobem podziału macierzy z rysunku 9.4 jest jej „pocięcie” według wierszy. W tym
modelu z każdym procesem jest kojarzona lista obiektów, do których ten proces może uzyskać
dostęp, wraz z wykazem operacji, które dany proces może wykonać na tych obiektach — lista
skojarzona z procesem reprezentuje więc jego domenę. Wspomnianą listę określa się mianem
listy uprawnień (ang. capability list — C-list), a jej elementy są nazywane uprawnieniami [Dennis
i Van Horn, 1966], [Fabry, 1974]. Przykład trzech procesów wraz z listami uprawnień pokazano
na rysunku 9.6.
Każde uprawnienie przypisuje właścicielowi prawo wykonywania określonych operacji na
pewnym obiekcie. Na rysunku 9.6 proces należący do użytkownika A może odczytywać zawartość
Rysunek 9.6. Jeśli system stosuje model uprawnień, każdemu procesowi jest przypisywana
lista uprawnień
plików F1 i F2. Pojedyncze uprawnienie zwykle składa się z identyfikatora pliku (lub — bar-
dziej ogólnie — obiektu) oraz bitmapy różnych praw. W systemach z rodziny UNIX identyfi-
kator pliku najczęściej ma postać numeru i-węzła. Listy uprawnień także są obiektami i jako
takie mogą być wskazywane przez inne listy uprawnień oraz tworzyć struktury dzielonych
poddomen. To dość oczywiste, że listy uprawnień muszą być chronione przed próbami wprowa-
dzania modyfikacji przez użytkowników.
Istnieją trzy metody ochrony tego rodzaju struktur. Pierwszy sposób wymaga użycia tzw.
architektury oznaczonej (ang. tagged architecture), czyli projektu sprzętowego, w którym dla każ-
dego słowa pamięci jest utrzymywany dodatkowy bit (znacznik) określający, czy dane słowo
zawiera uprawnienia, czy ich nie zawiera. Bit tego znacznika nie jest wykorzystywany w opera-
cjach arytmetycznych, operacjach porównywania ani innych typowych instrukcjach. Co więcej, bit
znacznika może być modyfikowany tylko przez programy pracujące w trybie jądra (np. przez
system operacyjny). Budowanie komputerów zgodnie z zasadami architektury oznaczonej jest
nie tylko możliwe, ale też bywa realizowane z sukcesem [Feustal, 1972]. Bodaj najbardziej po-
pularny przykład to architektura IBM AS/400.
Drugim sposobem jest utrzymywanie listy uprawnień w ramach systemu operacyjnego.
W takim przypadku uprawnienia są reprezentowane przez swoją pozycję na liście uprawnień.
Proces może np. wygenerować żądanie: „odczytaj 1 kB danych z pliku wskazywanego przez
uprawnienie nr 2”. Ta forma adresowania przypomina model deskryptorów plików stosowany
w systemach operacyjnych UNIX. W ten sposób działał system operacyjny Hydra [Wulf et al., 1974].
Trzeci sposób to utrzymywanie listy uprawnień w przestrzeni użytkownika i zarządzanie
tymi uprawnieniami z wykorzystaniem technik kryptograficznych uniemożliwiających ich mody-
fikowanie przez użytkowników. Ten model sprawdza się przede wszystkim w systemach roz-
proszonych. Kiedy proces klienta wysyła do zdalnego serwera (np. serwera plików) żądanie
utworzenia nowego obiektu, serwer tworzy ten obiekt i generuje długą liczbę losową (dla pola
kontrolnego). Na potrzeby nowego obiektu system rezerwuje przestrzeń w tabeli plików serwera,
gdzie umieszcza zarówno pole kontrolne, jak i adresy odpowiednich bloków dyskowych. W sys-
temach UNIX pole kontrolne jest składowane na serwerze w tzw. i-węźle (ang. i-node) i nigdy nie
jest odsyłane do użytkownika ani przesyłane za pośrednictwem sieci. Serwer generuje i zwraca
użytkownikowi reprezentację uprawnienia w formie pokazanej na rysunku 9.7.
Uprawnienie przekazane użytkownikowi zawiera identyfikator serwera, numer obiektu (w for-
mie indeksu elementu tabeli serwera, zwykle numeru i-węzła) oraz prawa reprezentowane
w formie bitmapy. W przypadku nowo tworzonych obiektów wszystkie bity uprawnień są domyślnie
włączane, ponieważ właściciel obiektu może wykonywać na nim dowolne operacje. Ostatnie
pole jest konkatenacją obiektu, uprawnień i pola kontrolnego po przetworzeniu przez bezpieczną,
jednokierunkową funkcję kryptograficzną f. Bezpieczna jednokierunkowa funkcja kryptogra-
ficzna to funkcja y = f(x), która ma taką właściwość, że jeśli jest znane x, wtedy można z łatwo-
ścią znaleźć y, ale na podstawie y nie można za pomocą obliczeń wyznaczyć x. Takie funkcje
zostaną szczegółowo omówione w podrozdziale 9.5. Na razie wystarczy zapamiętać, że w przy-
padku zastosowania dobrej, jednokierunkowej funkcji nawet zdeterminowany napastnik nie zdoła
odgadnąć pola kontrolnego, choćby znał wszystkie pozostałe pole uprawnienia.
Kiedy użytkownik chce uzyskać dostęp do tego obiektu, wysyła reprezentację odpowiedniego
uprawnienia w ramach żądania kierowanego na serwer. Serwer wyodrębnia z tego żądania
numer obiektu, aby odnaleźć go w swoich tabelach, po czym wyznacza wartość funkcji f(Obiekt,
Prawa, Kontrola), wykorzystując w roli dwóch pierwszych parametrów elementy otrzymanej
reprezentacji uprawnienia i w roli trzeciego parametru wartość odczytaną z własnych tabel. Jeśli
otrzymany wynik odpowiada czwartemu polu otrzymanego uprawnienia, żądanie jest realizowane;
w przeciwnym razie serwer odrzuca żądanie klienta. Jeśli użytkownik spróbuje uzyskać dostęp
do obiektu należącego do kogoś innego, nie będzie w stanie prawidłowo sfałszować czwartego
elementu uprawnienia, ponieważ nie zna wartości pola kontrolnego, zatem jego żądanie zostanie
odrzucone.
Użytkownik może zażądać wygenerowania słabszego uprawnienia, np. obejmującego tylko
dostęp do odczytu. Także w tym przypadku serwer w pierwszej kolejności sprawdza poprawność
samego uprawnienia. Jeśli weryfikacja przebiega pomyślnie, wyznacza wartość funkcji f(Obiekt,
Nowe_prawa, Kontrola) i generuje nowe uprawnienie, umieszczając tę wartość w czwartym
polu. Warto zwrócić uwagę na użycie oryginalnej wartości Kontrola, od której zależą pozostałe
składowe uprawnienia.
Nowe uprawnienie jest odsyłane do procesu żądającego. Właściciel pliku może teraz prze-
kazać to uprawnienie dalej, przesyłając tylko otrzymany komunikat. Jeśli osoba, która otrzymała
dany plik od jego właściciela, spróbuje włączyć bity, które powinny być wyłączone, serwer
wykryje i odrzuci tę próbę, ponieważ wartość funkcji f będzie niezgodna z polem fałszywych
uprawnień. Skoro użytkownik pliku (znajomy właściciela) nie zna prawdziwej wartości pola
kontroli, nie może przygotować fałszywego uprawnienia z wybranymi przez siebie bitami upraw-
nień. Ten schemat zastosowano w systemie Amoeba [Tanenbaum et al., 1990].
Oprócz praw skojarzonych z konkretnym obiektem (jak prawa odczytu czy wykonania pliku),
uprawnienia (zarówno te dotyczące jądra, jak i te chronione z wykorzystaniem technik krypto-
graficznych) zwykle obejmują prawa ogólne (ang. generic rights) formułowane z myślą o wszyst-
kich obiektach. Przykłady praw ogólnych przedstawiono poniżej:
1. Kopiowanie uprawnienia — utworzenie nowego uprawnienia dla tego samego obiektu.
2. Kopiowanie obiektu — utworzenie duplikatu istniejącego obiektu z nowym uprawnieniem.
3. Usunięcie uprawnienia — usunięcie odpowiedniego zapisu z listy uprawnień bez modyfi-
kowania samego obiektu.
4. Zniszczenie obiektu — trwałe usunięcie obiektu i uprawnienia.
Macierze ochrony (podobne do tej pokazanej na rysunku 9.3) nie mają statycznego charakteru.
Macierze ochrony często są zmieniane w odpowiedzi na tworzenie nowych obiektów, niszczenie
starych obiektów i rozszerzanie lub ograniczanie (przez właścicieli) zbioru użytkowników dys-
ponujących prawem wykonywania operacji na obiektach. W tej sytuacji opracowanie odpowied-
nich systemów ochrony, w ramach których macierze ochrony ulegają dynamicznym zmianom,
z natury rzeczy wymaga niezwykłej ostrożności. W tym punkcie krótko przeanalizujemy kilka
aspektów tego zadania.
Kilka dekad temu [Harrison et al., 1976] zidentyfikowali sześć prostych operacji na macierzy
ochrony, które mogą stanowić podstawę dla modelu każdego systemu ochrony. Na wspomniany
zbiór operacji składają się następujące działania: create object (stwórz obiekt), delete object
(usuń obiekt), create domain (stwórz domenę), delete domain (usuń domenę), insert right (wstaw
uprawnienie) oraz remove right (usuń uprawnienie). Dwie ostatnie operacje polegają odpowiednio
na dodawaniu i usuwaniu uprawnień z konkretnych elementów macierzy ochrony, np. prawa
odczytu do pliku Plik6 dla określonej domeny.
Wspomniana szóstka podstawowych operacji tworzy zbiór poleceń ochrony (ang. protection
commands), czyli poleceń, które programy użytkownika mogą wykonywać w celu modyfikowania
macierzy ochrony. Polecenia ochrony nie muszą jednak wykonywać tych podstawowych operacji
bezpośrednio. System operacyjny może np. stosować polecenie tworzące nowy plik, które będzie
sprawdzało ewentualne istnienie danego pliku oraz które (w razie jego braku) będzie tworzyło
nowy obiekt i przypisywało wszystkie uprawnienia jego właścicielowi. System może też ofero-
wać polecenie, za którego pośrednictwem właściciel pliku będzie mógł nadać prawo odczytu
jego zawartości wszystkim użytkownikom, czyli w praktyce umieścić uprawnienie „odczyt” we
wpisie o danym pliku w każdej domenie.
W każdej chwili macierz ochrony określa operacje, które poszczególne procesy w dowolnej
domenie teoretycznie mogą wykonywać, ale nie opisuje szczegółowych uprawnień tych procesów.
Macierz ochrony jest więc zbiorem ogólnych reguł narzucanych nam przez system; za szcze-
gółowe uprawnienia odpowiada dodatkowa strategia ochrony. Aby lepiej zrozumieć to rozróż-
nienie, przeanalizujmy prosty system (rysunek 9.10), w którym poszczególne domeny odpowia-
dają użytkownikom. W części (a) rysunku 9.8 widać strategię ochrony, zgodnie z którą Henryk
może odczytywać i zapisywać zawartość obiektu mailbox7, Robert może odczytywać i zapi-
sywać zawartość obiektu secret, a wszyscy trzej użytkownicy mogą odczytywać i wykonywać
obiekt compiler.
Wyobraźmy sobie teraz, że Robert okazał się na tyle sprytny, że udało mu się znaleźć sposób
wykonania poleceń zmieniających oryginalną macierz ochrony na wersję z części (b) rysunku 9.8.
Bezprawnie wprowadzona zmiana dała mu dostęp do obiektu mailbox7, którego zgodnie z orygi-
nalną macierzą ochrony ten użytkownik mieć nie powinien. Jeśli Robert spróbuje odczytać zawar-
tość tego obiektu, system operacyjny zrealizuje jego żądanie, ponieważ nie będzie „wiedział”,
że stan macierzy pokazany w części (b) rysunku 9.8 jest nieautoryzowany.
W tej sytuacji nietrudno zauważyć, że zbiór wszystkich możliwych macierzy można podzielić
na dwa rozłączne zbiory: zbiór wszystkich autoryzowanych stanów i zbiór wszystkich stanów
nieautoryzowanych. Dochodzimy więc do pytania wypracowanego wskutek teoretycznych roz-
ważań na ten temat: czy jeśli ma się dany początkowy stan autoryzowany i zbiór dopuszczalnych
poleceń, można udowodnić, że system nigdy nie osiągnie stanu nieautoryzowanego?
Model Biby
Aby podsumować model Bella-La Paduli, pozostańmy przy terminologii wojskowej — porucznik
może zażądać od szeregowego przekazania całej posiadanej przez niego wiedzy, po czym skopio-
wać te informacje do pliku generała bez naruszania zasad bezpieczeństwa. Spróbujmy teraz prze-
nieść ten model na grunt zastosowań cywilnych. Wyobraźmy sobie firmę, w której dozorcy mają
przypisany pierwszy poziom bezpieczeństwa, programiści mają przypisany trzeci poziom bez-
pieczeństwa, a prezes ma przypisany najwyższy, piąty poziom bezpieczeństwa. Zgodnie z modelem
Bella-La Paduli programista może żądać od stróża informacji o planach firmy, po czym nadpi-
sywać pliki prezesa określające strategię korporacyjną. Zapewne nie wszystkie firmy będą zachwy-
cone tym modelem.
Największą wadą modelu Bella-La Paduli jest to, że zaprojektowano go z myślą o ochronie
tajemnic, nie o gwarantowaniu integralności danych. Zagwarantowanie integralności danych
wymaga zastosowania dokładnie odwrotnych reguł [Biba, 1977]:
1. Prosta reguła bezpieczeństwa. Proces pracujący na poziomie bezpieczeństwa k może odczy-
tywać tylko obiekty na tym samym poziomie i niższym (nie jest więc możliwe zapisy-
wanie w górę).
2. Reguła integralności *. Proces pracujący na poziomie bezpieczeństwa k może odczytywać
tylko obiekty na swoim poziomie i wyższych poziomach (nie jest więc możliwe odczy-
tywanie z dołu).
Wymienione reguły łącznie zapewniają programiście możliwość aktualizacji plików dozorców
z wykorzystaniem informacji uzyskanych od prezesa, ale nie odwrotnie. Oczywiście istnieją
organizacje zainteresowane stosowaniem zarówno reguł Bella-La Paduli, jak i reguł Biby, jednak
bezpośredni konflikt — sprzeczność dzieląca oba modele — znacznie utrudnia ich jednoczesne
implementowanie.
proces (klient) chce wykonać jakieś działanie z wykorzystaniem drugiego procesu (serwera).
Klient i serwer nie mają do siebie pełnego zaufania. Przyjmijmy, że zadaniem serwera jest
pomoc klientom w wypełnianiu formularzy zeznań podatkowych. Procesy klienckie obawiają
się, że serwer w tajemnicy przed nimi będzie rejestrował i wykorzystywał dane o ich finansach
(np. celem sprzedaży tych informacji podmiotowi zainteresowanemu listą zamożnych klientów).
Proces serwera obawia się, że procesy klienckie spróbują wykraść cenny program podatkowy.
Trzeci proces pełni funkcję kolaboranta (współpracownika) „spiskującego” z serwerem na
rzecz nieuprawnionego pozyskania poufnych danych klientów. Procesy kolaboranta i serwera
zwykle należą do tej samej osoby. Te trzy procesy zaprezentowano na rysunku 9.10. Celem
tego modelu jest zaprojektowanie systemu, w którym nie będzie możliwy wyciek informacji
z procesu serwera do procesu kolaboranta, uzyskanych przez serwer zgodnie z przyjętymi
regułami od procesu klienta. Lampson określił to wyzwanie mianem problemu zamknięcia
(ang. confinement problem).
Rysunek 9.10. (a) Procesy klienta, serwera i kolaboranta; (b) hermetycznie zamknięty serwer,
z którego dane mogą wyciekać za pośrednictwem ukrytych kanałów
Z perspektywy projektanta systemu drogą do osiągnięcia tego celu jest hermetyczne zam-
knięcie lub odizolowanie serwera w sposób uniemożliwiający przekazywanie informacji procesowi
kolaboranta. Za pomocą macierzy ochrony możemy łatwo zagwarantować, że proces serwera
nie będzie komunikował się z procesem kolaboranta — wystarczy określić, z których plików
proces kolaboranta będzie mógł odczytywać dane. Prawdopodobnie można by też wykluczyć
możliwość komunikacji na linii serwer – kolaborant z wykorzystaniem systemowego mechanizmu
komunikacji międzyprocesowej.
Okazuje się jednak, że procesy mogą korzystać także z innych, mniej popularnych kanałów
komunikacyjnych. Serwer może np. podjąć próbę nawiązania komunikacji za pośrednictwem
nieformalnego, binarnego strumienia bitów. Aby wysłać wartość (bit) 1, serwer przetwarza dane
przez określony przedział czasu; aby wysłać wartość 0, serwer przechodzi w stan uśpienia na ten
sam okres.
Proces kolaboranta może podjąć próbę wykrycia tego strumienia bitów poprzez uważne
monitorowanie czasu generowania odpowiedzi przez proces serwera. Ogólnie odpowiedź ser-
wera powinna być generowana szybciej, jeśli serwer wysyła wartość 0, i wolniej, jeśli próbuje
wysłać wartość 1. Kanał komunikacyjny w tej formie bywa określany mianem ukrytych kanałów
(ang. covert channel); patrz rysunek 9.10(b).
Ukryty kanał z natury rzeczy jest narażony na występowanie szumów, czyli wielu dodat-
kowych informacji niezwiązanych z przekazem kierowanym do procesu docelowego. Zaszumiony
kanał nie wyklucza jednak możliwości skutecznego przekazywania informacji — wystarczy
zastosować odpowiedni kod korygujący błędy (tzw. kod korekcyjny, np. kod Hamminga lub
bardziej zaawansowany). Użycie kodu korygującego błędy ogranicza i tak niską przepustowość
ukrytego kanału, co jednak nie zmienia ryzyka wycieku informacji tą drogą. Nie ma wątpliwości,
że żaden model ochrony zbudowany w oparciu o macierz ochrony czy domeny nie może zapo-
biec tego rodzaju wyciekom.
Modulowanie użycia procesora nie jest jedynym ukrytym kanałem. Innym sposobem prze-
kazywania informacji jest modulowanie współczynnika błędów stron (wiele takich błędów
oznacza 1, brak błędów oznacza 0). W praktyce niemal każdy sposób obniżania wydajności sys-
temu w mierzalny sposób może być kandydatem do wykorzystania w roli ukrytego kanału. Jeśli
dany system oferuje możliwość blokowania dostępu do plików, serwer może zablokować wybrany
plik, aby wysłać wartość 1, i odblokować ten plik, aby wysłać wartość 0. W niektórych syste-
mach istnieje możliwość odczytywania stanu plików przez procesy, które nie mają prawa wyko-
nywania właściwych operacji dostępu na tych plikach. Schemat funkcjonowania tego ukrytego
kanału pokazano na rysunku 9.11 — plik jest blokowany i odblokowywany na ustalone z góry,
stałe przedziały czasowe, których długość jest znana zarówno procesowi serwera, jak i procesowi
kolaboranta. W tym przypadku proces wysyła strumień bitów 11010100.
wywołanie systemowe access do sprawdzenia, czy dany plik istnieje. Wspomniane wywołanie
działa prawidłowo nawet wtedy, gdy proces kolaboranta nie ma uprawnień do korzystania z danego
pliku. Istnieje niestety wiele innych ukrytych kanałów komunikacyjnych.
Lampson wspomniał też o możliwości wycieku chronionych informacji do samego właściciela
(człowieka) procesu serwera. Proces serwera zwykle ma prawo informować swojego właści-
ciela o nakładzie pracy wykonywanej na rzecz klienta, aby na tej podstawie można było wysta-
wić odpowiedni rachunek temu klientowi. Jeśli rachunek za wykonane obliczenia wynosi np.
100 zł i jeśli klient wykazał przychód na poziomie 53 000 zł, serwer może wystawić na potrze-
by swojego właściciela rachunek na kwotę 100,53 zł.
Samo odkrywanie wszystkich ukrytych kanałów nie wystarczy — ich blokowanie jest niepo-
równanie trudniejsze. W praktyce niewiele możemy zrobić. Wprowadzenie do systemu procesu
powodującego przypadkowe błędy stron lub w inny sposób obniżającego wydajność systemu
(celem ograniczenia przepustowości ewentualnych ukrytych kanałów) z pewnością nie jest zbyt
atrakcyjną propozycją.
Steganografia
Nieco innego typu ukrytego kanału można użyć do przekazywania tajnych informacji pomiędzy
procesami w sytuacji, gdy człowiek lub zautomatyzowany mechanizm cenzurujący szczegółowo
analizuje wszystkie komunikaty przesyłane pomiędzy procesami i odrzuca wszystkie próby wzbu-
dzające jego podejrzenia. Wyobraźmy sobie firmę, w której specjalna grupa pracowników wery-
fikuje wszystkie wychodzące wiadomości poczty elektronicznej wysyłane przez pozostałych
pracowników, aby upewnić się, że tą drogą nie wyciekają poza firmę żadne sekrety (np. do konku-
rencji). Czy w takim przypadku istnieje sposób przemycenia dużej ilości poufnych informacji
niemal pod nosem czujnych cenzorów? Okazuje się, że tak — i wcale nie jest to trudne.
Przeanalizujmy teraz rysunek 9.12(a). Na rysunku przedstawiono fotografię wykonaną przez
autora podczas wycieczki do Kenii — widać na niej trzy zebry na tle drzewa akacjowego. Na
rysunku 9.12(b) pokazano te same trzy zebry na tle tego samego drzewa akacjowego, tyle że
po dodaniu pewnych informacji. Okazuje się, że ta z pozoru identyczna fotografia zawiera pełen
tekst pięciu dzieł Szekspira: Hamleta, Króla Leara, Makbeta, Kupca weneckiego i Juliusza Cezara.
Rozmiar tego tekstu przekracza 700 kB.
Rysunek 9.12. (a) Trzy zebry i drzewo; (b) trzy zebry, drzewo i pełny tekst pięciu dzieł Williama
Szekspira
Jak właściwie działa ten ukryty kanał komunikacyjny? Oryginalny obraz ma rozmiary 1024×768
pikseli. Każdy piksel składa się z trzech liczb 8-bitowych, po jednej dla intensywności trzech
barw składowych: czerwonej, zielonej i niebieskiej. Kolor piksela jest wyznaczany na podsta-
wie kombinacji tych trzech kolorów. Metoda kodowania wykorzystuje w roli ukrytego kanału
mniej znaczący (dolny) bit (ang. low-order bit) każdej z trzech wartości barw RGB. Oznacza to,
że każdy piksel stwarza miejsce dla trzech bitów tajnych informacji, jeden w ramach wartości
barwy czerwonej, jeden w ramach wartości barwy zielonej i jeden w ramach wartości barwy
niebieskiej. W obrazie o rozmiarach 1024×768 można więc zmieścić tajne informacje zajmujące
294 912 bajtów (1024×768×3).
Pełny tekst pięciu dzieł Szekspira wraz z krótką notatką zajmuje 734 891 bajtów. Po zasto-
sowaniu standardowego algorytmu kompresji udało się zmniejszyć ten rozmiar do około 274 kB.
Skompresowane dane wynikowe zostały następnie zaszyfrowane i umieszczone w mniej zna-
czących bitach wartości dla poszczególnych kolorów. Jak widać (a właściwie nie widać), istnie-
nie tych informacji jest zupełnie niewidoczne. Co więcej, zakodowane w ten sposób dane nie
byłyby widoczne także na wielkiej, w pełni kolorowej wersji tej fotografii. Oko ludzkie nie potrafi
odróżnić barw 7-bitowych od 8-bitowych. Jeśli więc tak zmieniony obraz zostanie zaakcepto-
wany przez nieświadomego cenzora, odbiorca będzie musiał tylko wyodrębnić dolne bity oraz
zastosować algorytmy deszyfrujące i dekompresujące, aby otrzymać oryginalne 734 891 bajtów.
Ukrywanie informacji w ten sposób określa się mianem steganografii (ang. steganography; po
grecku „ukryte pismo”). Steganografia jest jednym z największych wrogów współczesnych dykta-
tur, które za wszelką cenę starają się ograniczać zakres informacji docierających do ich obywateli;
jest też wyjątkowo ważnym narzędziem w środowiskach ceniących sobie wolność słowa.
Analiza dwóch czarno-białych obrazów w niskiej rozdzielczości nie ilustruje prawdziwego
potencjału tej techniki ukrywania informacji. Aby lepiej zademonstrować, jak skutecznie można
korzystać ze steganografii, autor przygotował prostą demonstrację z oryginalną wersją obrazu
z rysunku 9.12(b). Można ten obraz znaleźć w witrynie internetowej www.cs.vu.nl/~ast/ —
wystarczy kliknąć łącze covered writing pod nagłówkiem STEGANOGRAPHY DEMO. Na kolejnej
stronie można znaleźć instrukcje, jak pobrać ten obraz, i narzędzia steganograficzne niezbędne
do odtworzenia zakodowanego tekstu dzieł Szekspira. Trudno w to uwierzyć, ale warto samo-
dzielnie się przekonać: zobaczyć to znaczy uwierzyć.
Innym zastosowaniem steganografii jest umieszczanie ukrytych znaków wodnych w obra-
zach udostępnianych na stronach internetowych, aby na tej podstawie wykrywać ich kradzież
i przypadki nieuprawnionego użycia na innych stronach. Jeśli Twoja strona internetowa zawiera
obraz z ukrytym komunikatem „Copyright 2008, General Images Corporation”, podczas ewentu-
alnej rozprawy sądowej będziesz dysponować niezbitym dowodem, że właśnie Ty jesteś prawo-
witym właścicielem tego materiału. Tego rodzaju zabezpieczenia można z powodzeniem sto-
sować także dla plików muzycznych, filmów i innych dzieł chronionych prawem.
Stosowanie znaków wodnych w tej formie z natury rzeczy zachęca ludzi do poszukiwania
sposobów usuwania zakodowanych informacji. Schemat polegający na zapisywaniu informacji
w dolnych bitach poszczególnych pikseli można łatwo złamać — wystarczy obrócić obraz o jeden
stopień zgodnie z kierunkiem ruchu wskazówek zegara i skonwertować ten obraz do stratnego
formatu (np. JPEG), ponownie obrócić o jeden stopień w kierunku przeciwnym do ruchu wskazó-
wek zegara i wreszcie ponownie skonwertować obraz do oryginalnego systemu kodowania
(GIF, BMP czy TIF). Stratna konwersja do formatu JPEG spowoduje prawdziwe spustoszenie
w dolnych bitach, a obroty wymuszą niezliczone obliczenia na wartościach zmiennoprzecinko-
wych, które z kolei doprowadzą do błędów zaokrągleń powodujących dodatkowy szum w dolnych
bitach. Ludzie umieszczający w swoich materiałach znaki wodne zdają (a przynajmniej powinni
zdawać) sobie sprawę z istnienia tych technik i dlatego umieszczają nadmiarowe informacje
o prawach autorskich oraz stosują schematy wykraczające poza proste wykorzystywanie dol-
nych bitów pikseli. To z kolei skłania potencjalnych atakujących do poszukiwania jeszcze lepszych
technik usuwania znaków wodnych. Wyścig pomiędzy obiema stronami nie ma końca.
Technikę steganografii można wykorzystać do wyprowadzenia potajemnie informacji, ale
znacznie częściej używa się jej do odwrotnego procesu: ukrycia informacji przed wścibskimi oczyma
napastników, niekoniecznie zatajając fakt, że staramy się coś ukryć. Tak jak Juliusz Cezar chcemy
mieć pewność, że nawet wtedy, gdy nasze wiadomości lub pliki wpadną w niepowołane ręce,
napastnikowi nie uda się wykryć poufnych informacji. To jest domena kryptografii i temat kolej-
nego podrozdziału.
Kryptografia odgrywa ważną rolę w kontekście bezpieczeństwa. Większość ludzi zna tylko kryp-
togramy publikowane w gazetach (zwykle w formie drobnych łamigłówek, w których każda litera
została zastąpiona dokładnie jedną inną literą). Ta forma szyfrowania danych ma tyle wspólnego
ze współczesną kryptografią, ile hot dogi z prawdziwą sztuką kulinarną. W tym podrozdziale
dokonamy ogólnego przeglądu elementów kryptografii w erze komputerów. Jak wspomniano
wcześniej, w systemach operacyjnych kryptografia jest wykorzystywana w wielu miejscach.
Niektóre systemy plików umożliwiają zaszyfrowanie wszystkich danych na dysku. Protokoły takie
jak IPSec pozwalają na szyfrowanie i (lub) podpisywanie wszystkich pakietów sieciowych. W więk-
szości systemów operacyjnych hasła są szyfrowane, aby utrudnić napastnikom ich odczytanie.
Ponadto w podrozdziale 9.6 omówimy rolę szyfrowania w innym ważnym aspekcie bezpieczeń-
stwa: uwierzytelnianiu.
W tej książce omówimy podstawowe zagadnienia dotyczące mechanizmów kryptograficznych.
Z drugiej strony naprawdę poważna, wyczerpująca analiza tego tematu wykraczałaby poza zakres
tematyczny tej książki. Istnieje wiele doskonałych publikacji poświęconych wyłącznie kwestii
bezpieczeństwa. Zainteresowanych Czytelników odsyłamy do tych pozycji — np. [Kaufman et al.,
2002], [Gollman, 2011]. Poniżej omówiono zagadnienia związane z kryptografią z myślą o Czytelni-
kach, którzy nie mają żadnego doświadczenia w tej dziedzinie.
Celem kryptografii jest zaszyfrowanie komunikatu lub pliku określanego mianem tekstu
jawnego (ang. plaintext) do postaci szyfrogramu (tekstu zaszyfrowanego; ang. ciphertext) w taki
sposób, aby tylko uprawnieni odbiorcy wiedzieli, jak ponownie przekonwertować te niezro-
zumiałe dane do postaci tekstu jawnego. Dla wszystkich pozostałych szyfrogram powinien być
bezwartościowym zlepkiem bitów. Osoby, które nie mają odpowiedniego doświadczenia w tej
dziedzinie, często nie mogą uwierzyć, że algorytmy (funkcje) szyfrujące i deszyfrujące zawsze
powinny być jawne. Próby utrzymywania samych algorytmów w tajemnicy niemal nigdy nie
zdają egzaminu i dają ich właścicielom fałszywe poczucie bezpieczeństwa. Technika określana
mianem bezpieczeństwa przez ukrywanie (ang. security by obscurity) jest stosowana tylko przez
amatorów. Co ciekawe, do tej grupy można zaliczyć wiele wielkich, międzynarodowych orga-
nizacji, od których należałoby oczekiwać czegoś więcej.
Zamiast utajniać algorytmy, w tajemnicy trzyma się parametry tych algorytmów, nazywane
kluczami (ang. keys). Jeśli P jest plikiem z jawnym tekstem, KE jest kluczem szyfrowania, C
jest szyfrogramem, a E jest algorytmem (funkcją) szyfrującym, to C = E(P, KE). Przytoczony
wzór jest definicją szyfrowania. Wynika z niego, że szyfrogram uzyskujemy, korzystając ze
znanego algorytmu szyfrującego E oraz dwóch parametrów, czyli tekstu jawnego P i tajnego
klucza szyfrowania KE. Koncepcję, zgodnie z którą algorytmy powinny być jawne, a tajność
powinna dotyczyć wyłącznie kluczy, określa się mianem zasady Kerckhoffsa (sformułowanej przez
żyjącego w XIX wieku holenderskiego kryptografa Auguste’a Kerckoffsa). Do dzisiaj wspomniana
reguła jest stosowana przez wszystkich poważnych kryptografów.
Podobnie P = D(C, KD), gdzie D jest algorytmem deszyfrującym, a KD jest kluczem deszy-
frowania, oznacza, że odtworzenie tekstu jawnego (P) na podstawie szyfrogramu (C) i klucza
szyfrowania (KD) wymaga użycia algorytmu D wraz z parametrami C i KD. Relacje łączące poszcze-
gólne elementy tej układanki pokazano na rysunku 9.13.
Rysunek 9.14. (a) Wyznaczanie bloku podpisu; b) struktura dokumentu trafiającego do odbiorcy
Powyżej opisaliśmy, jak stosować techniki kryptografii z kluczem publicznym dla podpisów
cyfrowych. Warto przy tej okazji wspomnieć o istnieniu schematów, które nie wykorzystują
metod kryptografii z kluczem publicznym.
zaufania jądro, byłoby jeszcze lepiej. A gdybyśmy do tego mogli pokazać, że w obrębie tego jądra
działa uprawnione oprogramowanie w odpowiedniej wersji, to strona sprawdzająca mogłaby uznać,
że jesteśmy wiarygodni.
Najpierw przeanalizujmy, co się dzieje w naszym komputerze od momentu jego uruchomienia.
Podczas uruchamiania (zaufanego) systemu BIOS najpierw jest inicjowany moduł TPM. BIOS
wykorzystuje go w celu stworzenia skrótu kodu w pamięci po załadowaniu bootloadera. Moduł
TPM zapisuje wyniki w specjalnym rejestrze, znanym jako PCR (ang. Platform Configuration
Register). Rejestry PCR są wyjątkowe, ponieważ nie można ich bezpośrednio nadpisać — można
je tylko „rozszerzyć”. W celu rozszerzenia rejestru PCR moduł TPM oblicza skrót kombinacji
wartości wejściowej i wartości, która była poprzednio zapisana w rejestrze PCR, a uzyskany
wynik zapisuje do rejestru PCR. W związku z tym bootloader wykona pomiar (obliczy skrót)
dla załadowanego jądra i rozszerzy rejestr PCR, który wcześniej zawierał pomiar dla samego
bootloadera. Intuicyjnie możemy uznać, że wynikowy skrót kryptograficzny w rejestrze PCR jest
łańcuchem skrótów wiążącym jądro z bootloaderem. Teraz z kolei jądro wykonuje pomiar apli-
kacji i za pomocą wyniku tego pomiaru rozszerza rejestr PCR.
Zastanówmy się teraz, co się stanie, gdy strona zewnętrzna zechce sprawdzić, czy w naszym
komputerze działa uprawniony (zaufany) stos oprogramowania, a nie jakiś dowolny, inny kod.
Po pierwsze strona sprawdzająca tworzy trudną do odgadnięcia wartość — np. o rozmiarze 160
bitów. Wartość ta, znana jako nonce, jest po prostu niepowtarzalnym identyfikatorem dla tego
żądania weryfikacji. Ma uniemożliwić napastnikowi zapisanie odpowiedzi na jedno z żądań ate-
stacji zdalnej, zmianę konfiguracji strony testowanej oraz po prostu odtworzenie poprzedniej
odpowiedzi dla wszystkich kolejnych żądań atestacji. Dzięki włączeniu identyfikatora nonce do
protokołu takie powtórki nie są możliwe. Kiedy strona potwierdzająca (sprawdzana) odbierze
żądanie atestacji (łącznie z identyfikatorem nonce), wykorzystuje moduł TPM do stworzenia
podpisu (z unikatowym i trudnym do podrobienia kluczem), który jest następnie scalany z warto-
ścią identyfikatora nonce i zawartością rejestru PCR. Następnie wysyła z powrotem ten podpis,
identyfikator nonce, wartość rejestru PCR oraz skróty bootloadera, jądra i aplikacji. Strona żąda-
jąca najpierw sprawdza podpis i identyfikator nonce. Następnie stara się znaleźć trzy skróty
w swojej bazie danych zaufanych bootloaderów, jąder i aplikacji. Jeśli ich nie znajdzie, atestacja
kończy się niepowodzeniem. W przeciwnym razie strona żądająca weryfikacji tworzy na nowo
połączone wartości skrótów wszystkich trzech komponentów i porównuje je z wartością rejestru
PCR otrzymanego od strony sprawdzanej. Jeśli wartości pasują do siebie, strona żądająca weryfi-
kacji ma pewność, że strona potwierdzająca rozpoczęła obliczenia, stosując dokładnie te trzy
elementy. Podpisanie wyniku zapobiega sfałszowaniu go przez napastników. Ponieważ wiemy,
że zaufany bootloader wykonał właściwe pomiary jądra, a z kolei jądro dokonało pomiarów apli-
kacji, to mamy pewność, że żaden inny kod konfiguracji nie mógł wygenerować takiego samego
łańcucha skrótów.
Istnieje wiele innych koncepcji wykorzystania modułów TPM, których nie będziemy tutaj
omawiać z braku miejsca. Warto jednak podkreślić, że układy TPM w żaden sposób nie zabezpie-
czają komputerów przed atakami zewnętrznymi — ich zadaniem jest wykorzystywanie technik
kryptograficznych do uniemożliwiania użytkownikom podejmowania jakichkolwiek działań nieak-
ceptowanych (bezpośrednio lub pośrednio) przez podmiot sterujący tymi modułami. Jeśli szukasz
dodatkowych informacji na ten temat, zapoznaj się z artykułem serwisu Wikipedia poświęco-
nym technologii Trusted Computing.
9.6. UWIERZYTELNIANIE
9.6.
UWIERZYTELNIANIE
Każdy zabezpieczony system komputerowy musi żądać od wszystkich swoich użytkowników uwie-
rzytelniania w trakcie logowania. Gdyby system operacyjny nie mógł sprawdzić, kim naprawdę
jest jego użytkownik, nie mógłby określić, które pliki i zasoby można temu użytkownikowi udo-
stępnić. Mimo że uwierzytelnianie może sprawiać wrażenie czegoś trywialnego, w praktyce jest
zagadnieniem dużo bardziej złożonym, niż tego oczekujemy. Warto więc poświęcić chwilę na
lekturę tego podrozdziału.
Uwierzytelnianie jest jednym z obszarów, które mieliśmy na myśli w punkcie 1.5.7, kiedy
mówiliśmy o ontogenezie (rozwoju osobowym) jako rekapitulacji filogenezy (rozwoju rodowego).
Pierwsze komputery mainframe, jak ENIAC, w ogóle nie zawierały systemów operacyjnych, nie
mówiąc już o procedurach logowania. Późniejsze komputery mainframe i systemy z podziałem
czasu wykorzystywały procedury logowania do uwierzytelniania zadań i użytkowników.
Wczesne minikomputery (w tym PDP-1 i PDP-8) nie oferowały nawet procedury logowania;
dopiero rosnąca popularność systemu operacyjnego UNIX pracującego na minikomputerze PDP-11
spowodowała ponowne wprowadzenie mechanizmu logowania do świata komputerów. Procedury
logowania nie stosowano także we wczesnych komputerach osobistych (jak Apple II czy orygi-
nalny IBM PC); logowanie jest stosowane w bardziej wyszukanych, współczesnych systemach
operacyjnych komputerów osobistych (w tym w systemach Linux i Windows 8), jednak wciąż
istnieje możliwość wyłączania logowania przez nierozsądnych użytkowników. Komputery pra-
cujące w korporacyjnych sieciach LAN niemal zawsze stosują procedury logowania skonfigu-
rowane w sposób uniemożliwiający ich obchodzenie przez użytkowników. I wreszcie wielu
współczesnych użytkowników (pośrednio) loguje się na zdalnych komputerach, aby korzystać
z usług bankowości elektronicznej, handlu elektronicznego, pobierać muzykę i podejmować roz-
maite inne działania komercyjne. Wszystkie te operacje wymagają logowania, zatem uwierzytel-
nianie także dzisiaj jest niezwykle ważnym aspektem działania systemów.
Skoro wiemy już, dlaczego uwierzytelnianie jest takie ważne, naturalnym krokiem staje się
znalezienie dobrego sposobu realizacji tego zadania. Większość metod uwierzytelniania użytkow-
ników przy okazji prób logowania polega na uzyskiwaniu jednej z trzech ogólnych informacji
identyfikujących:
1. Tego, co użytkownik wie.
2. Tego, czym użytkownik dysponuje.
3. Tego, kim użytkownik jest.
W pewnych sytuacjach dodatkowe wymogi bezpieczeństwa wymagają od uwierzytelnianego użyt-
kownika podania dwóch z trzech wymienionych powyżej elementów. Każdy z nich prowadzi do nieco
innego schematu uwierzytelniania z odmienną złożonością i poziomem bezpieczeństwa. W kolej-
nych punktach omówimy każdy z tych schematów.
Najszerzej stosowaną formą uwierzytelniania jest wymuszanie na użytkownikach podawania
ich nazw i haseł. Ochrona hasłem jest dla wszystkich zrozumiała i łatwa do zaimplementowania.
Najprostsza implementacja sprowadza się do utrzymywania centralnej listy par nazwa użytkow-
nika – hasło. Wpisywana przez użytkownika nazwa jest wyszukiwana na liście, a wpisane hasło
porównywane z odpowiednim hasłem na liście. Jeśli lista zawiera daną nazwę i jeśli oba hasła
do siebie pasują, próba logowania jest akceptowana; w przeciwnym razie próba logowania zostaje
odrzucona.
Chyba nikomu nie trzeba przypominać, że podczas wpisywania hasła komputer nie powinien
wyświetlać wprowadzanych znaków, aby ochronić hasło przed ciekawskimi spojrzeniami zza
ramienia użytkownika. W systemach Windows każdy wpisywany znak jest reprezentowany przez
wyświetlaną gwiazdkę. W systemach UNIX w czasie wpisywania hasła na ekranie nie są wy-
świetlane żadne znaki. Oba schematy mają nieco inne właściwości w zakresie bezpieczeństwa.
Schemat stosowany w systemach Windows umożliwia roztargnionym użytkownikom analizę
liczby już wpisanych znaków, ale też ułatwia osobom nieuprawnionym podglądanie długości
hasła. W kontekście bezpieczeństwa brak jakiejkolwiek wiedzy o haśle (także o jego długości)
jest złotem.
Inny aspekt, w którym można popełnić błąd mający istotny wpływ na poziom bezpieczeństwa,
pokazano na listingu 9.1. W części (a) tego listingu mamy do czynienia z udaną procedurą logowa-
nia, gdzie system prezentuje swoje komunikaty pisane wielkimi literami, a użytkownik wpisuje
dane identyfikacyjne złożone z małych liter. W części (b) pokazano nieudaną próbę zalogowania
w systemie A podjętą przez krakera. W części (c) widać podjętą przez krakera nieudaną próbę
zalogowania w systemie B.
Listing 9.1. (a) Udane logowanie; (b) próba zalogowania odrzucona po wpisaniu nazwy użytkownika;
(c) próba zalogowania odrzucona po wpisaniu nazwy użytkownika i hasła
(a) (b) (c)
LOGIN: mitch LOGIN: carol LOGIN: carol
PASSWORD: FooBar!-7 INVALID LOGIN NAME PASSWORD: Idunno
SUCCESSFUL LOGIN LOGIN: INVALID LOGIN
LOGIN:
Na listingu 9.1(b) przedstawiono scenariusz, w którym system odrzuca próbę zalogowania już
po wpisaniu nieprawidłowej nazwy użytkownika. Takie rozwiązanie jest o tyle niewłaściwe, że
umożliwia krakerowi sprawdzanie różnych nazw użytkownika aż do odnalezienia prawidłowej.
Na listingu 9.1(c) pokazano schemat, w którym system każdorazowo żąda hasła i nie informuje
potencjalnego krakera, czy wpisywana nazwa użytkownika jest prawidłowa. Kraker dowiaduje
się więc tylko tego, że kombinacja nazwy użytkownika i hasła jest nieprawidłowa.
Większość komputerów przenośnych konfiguruje się w taki sposób, aby każdorazowo wyma-
gały nazwy użytkownika i hasła — takie rozwiązanie ma chronić dane na wypadek zagubienia
lub kradzieży komputera. Chociaż ten schemat ochrony jest lepszy niż żaden, w praktyce okazuje
się niewiele lepszy od braku jakiegokolwiek zabezpieczenia. Każdy, kto przejmie tak zabezpie-
czony komputer przenośny, może wejść do BIOS-u, naciskając klawisz Del lub F8 (lub dowolny
inny, zwykle wyświetlany na ekranie) przed uruchomieniem systemu operacyjnego. Z poziomu
BIOS-u można łatwo zmienić sekwencję uruchamiania, aby system był ładowany np. z pamięci
USB, nie z dysku twardego. Wystarczy wówczas użyć pamięci USB z kompletnym systemem
operacyjnym i załadować z niego system. Po uruchomieniu systemu można zamontować dysk
twardy (w Uniksie) lub uzyskać dostęp do napędu D: (w Windowsie). Aby zapobiec tej sytuacji,
większość BIOS-ów oferuje możliwość ochrony hasłem samych ustawień BIOS-a, dzięki czemu
tylko prawowity właściciel komputera przenośnego może zmieniać sekwencję odczytywania
systemów z napędów. Jeśli więc dysponujesz komputerem przenośnym, przerwij lekturę, zabez-
piecz hasłem ustawienia BIOS-a i dopiero wtedy wróć do tego tekstu.
Słabe hasła
Większość krakerów włamuje się do systemów komputerowych, łącząc się z nimi za pośrednic-
twem sieci (zwykle przez internet) i próbując wielu kombinacji nazw użytkowników i haseł aż
do znalezienia prawidłowej pary. Wielu właścicieli komputerów wykorzystuje w roli nazw użyt-
kownika swoje imiona w tej czy innej formie. Oznacza to, że w przypadku Ewy Szewczyk istnieje
duże prawdopodobieństwo użycia nazwy ewa, szewczyk, ewa_szewczyk, ewa-szewczyk, ewa.szewczyk,
eszewczyk lub esz. Kraker wyposażony w książki 4096 imion dla Twojego dziecka oraz książkę
telefoniczną pełną nazwisk może łatwo wygenerować listę potencjalnych nazw użytkowników
właściwych dla kraju, w którym planuje dokonać ataku (oczywiście kombinacja ewa_szewczyk
może występować w Polsce, ale trudno oczekiwać, by podobnej nazwy użył np. Japończyk).
Odgadnięcie samej nazwy użytkownika to oczywiście nie wszystko. Kraker musi jeszcze
odgadnąć odpowiednie hasło. Jak trudne jest odgadywanie haseł? Łatwiejsze, niż większość z nas
sądzi. Zagadnienia związane z bezpieczeństwem haseł w systemach z rodziny UNIX zostały
szczegółowo opisane w uważanej dziś za klasykę książce [Morris i Thompson, 1979]. Autorzy
sporządzili listę prawdopodobnych haseł złożoną z imion i nazwisk, nazw ulic, nazw miast, słów
z niewielkich słowników (także pisanych od tyłu), numerów rejestracyjnych pojazdów itp. Gotową
listę porównali z plikiem haseł systemu operacyjnego, aby sprawdzić, w ilu przypadkach wystę-
pują dopasowania. Okazało się, że ich lista zawierała ponad 86% wszystkich haseł.
Nie należy też oczekiwać, że użytkownicy „z wyższej półki” stosują hasła wyższej jakości.
Kiedy w 2012 roku do internetu wyciekło 6,4 miliona skrótów haseł z serwisu LinkedIn (hashed),
wiele osób świetnie się bawiło, analizując rezultaty. Najbardziej popularnym hasłem było słowo
„password”. Drugim w kolejności było „123456” (hasła „1234”, „12345” i „12345678” także
znalazły się w pierwszej dziesiątce). Jak widać, nie są to hasła zbyt trudne do odgadnięcia. Krake-
rzy mogą z łatwością skompilować listę potencjalnych nazw użytkownika i listę potencjalnych
haseł, a następnie uruchomić program, który wypróuje je na tylu komputerach, na ilu zdoła.
Działanie to jest podobne do eksperymentu, którzy naukowcy z firmy IOActive przeprowa-
dzili w marcu 2013 roku. Przeskanowali długą listę routerów i urządzeń set-top, aby sprawdzić,
czy są one wrażliwe na najprostsze formy ataków. Zamiast wypróbowania wielu nazw użyt-
kownika i haseł, tak jak zasugerowaliśmy, użyli tylko dobrze znanych domyślnych kombinacji
loginów i haseł, stosowanych przez producentów sprzętu. Zgodnie z zaleceniami producentów
sprzętu użytkownicy powinni natychmiast zmienić te wartości, ale jak się okazuje, wielu tego
nie robi. Naukowcy odkryli, że w internecie istnieją setki tysięcy takich potencjalnie zagrożonych
urządzeń. Być może jeszcze bardziej niepokojące jest to, że w ataku Stuxnet skierowanym na
irańską fabrykę nuklearną wykorzystano fakt, że w komputerach Siemens sterujących pracą
wirówek do produkcji wzbogaconego uranu używano domyślnego hasła, które krążyło w inter-
necie przez lata.
Wzrost popularności internetu uczynił ten problem jeszcze poważniejszym. Zamiast jednego
hasła wielu współczesnych użytkowników korzysta nawet z dziesiątek haseł do rozmaitych usług.
Ponieważ zapamiętanie tych haseł jest zbyt trudne, użytkownicy decydują się na proste, łatwe
do złamania hasła, które dodatkowo są wykorzystywane w wielu różnych witrynach [Florencio
i Herley, 2007], [Taiabul Haque et al., 2013].
Czy łatwość odgadywania haseł rzeczywiście ma tak duże znaczenie? Tak, z całą pewnością.
W 1998 roku magazyn San Jose Mercury News opublikował artykuł opisujący sposób, w jaki
mieszkaniec Berkeley, Peter Shipley, wykorzystał wiele nieużywanych komputerów w roli tzw.
war dialerów, które nawiązały w losowej kolejności połączenia ze wszystkimi 10 tysiącami nume-
rów telefonów obsługiwanych przez odpowiednią centralę, np. (415) 770-xxxx. W ten sposób
chciał zidentyfikować firmy, które nie były przygotowane na tego rodzaju atak. Po wykonaniu
2,6 miliona telefonów udało mu się zlokalizować 20 tysięcy komputerów na obszarze Zatoki San
Francisco, z których blisko 200 komputerów nie było w żaden sposób zabezpieczonych.
Internet jest rajem dla krakerów. Jego istnienie zdjęło z nich konieczność wykonywania
całej ciężkiej pracy. Nikt nie musi już nawiązywać połączeń telefonicznych z milionami numerów.
Współczesne odpowiedniki war dialerów działają zupełnie inaczej. Kraker może napisać skrypt
ping (wysyłający pakiet sieciowy) do komputerów o adresach ze znanego zbioru. Jeśli otrzyma
jakąś odpowiedź, skrypt próbuje skonfigurować połączenie TCP ze wszystkimi możliwymi
usługami, które mogą być uruchomione na komputerze. Jak wspomniano wcześniej, to odwzo-
rowanie informacji, jakie usługi działają na określonych komputerach, to tzw. skanowanie portów
(ang. portscanning). Zamiast pisać skrypt od podstaw, kraker może równie dobrze użyć wyspe-
cjalizowanego narzędzia, np. nmap, które oferuje szeroki zakres zaawansowanych technik ska-
nowania portów. Kiedy kraker już wie, jakie serwery są uruchomione na komputerze, może
przystąpić do przeprowadzenia ataku. Jeśli napastnik chciałby sprawdzić zabezpieczenia hasłem,
mógłby nawiązać połączenie z tymi usługami, które korzystają z tej metody uwierzytelniania —
np. serwera telnet czy nawet serwera WWW. Wcześniej pisaliśmy, że hasła domyślne oraz — ogól-
nie rzecz biorąc — hasła słabe umożliwiają napastnikom zdobycie wielu kont, czasami z pełnymi
prawami administratorów.
słownika są szyfrowane z wykorzystaniem znanego algorytmu. Czas trwania tego procesu nie
ma znaczenia, ponieważ jest realizowany przed przystąpieniem do właściwego ataku. Kraker
przystępuje do próby włamania wyposażony w listę par (hasło, zaszyfrowane hasło). Odczytuje
publicznie dostępny plik haseł i przeszukuje wszystkie składowane tam (zaszyfrowane) hasła pod
kątem zgodności z zaszyfrowanymi hasłami na swojej liście. Każde znalezione dopasowanie
oznacza, że kraker zna nazwę użytkownika i hasło (w wersji zaszyfrowanej i niezaszyfrowanej).
Za pomocą prostego skryptu powłoki można ten proces zautomatyzować, aby czas jego trwania
nie przekraczał ułamka sekundy. Co więcej, zaledwie jedno wykonanie tego skryptu wystarczy
do uzyskania dziesiątek haseł.
W odpowiedzi na możliwość przeprowadzania tego rodzaju ataków Morris i Thompson opra-
cowali technikę, która niemal całkowicie eliminuje możliwość skutecznego łamania haseł tą
metodą. Ich koncepcja polega na wiązaniu z każdym hasłem n-bitowej liczby losowej określanej
mianem soli (ang. salt). Wspomniana liczba losowa jest zmieniana przy okazji każdej zmiany
samego hasła. Właśnie ta liczba jest składowana w pliku haseł w niezaszyfrowanej postaci, zatem
każdy może ją bez trudu odczytać. Zamiast składowania zaszyfrowane hasło jest konkatenowane
ze swoją liczbą losową, by dopiero potem zaszyfrować tak wygenerowany ciąg znaków. Otrzy-
many wynik jest składowany w pliku haseł — na listingu 9.2 pokazano przykładową zawartość
tego pliku dla pięciu użytkowników: Bartosza, Tomasza, Lidii, Marka i Doroty. Każdy użytkownik
jest reprezentowany przez jeden wiersz tego pliku obejmujący trzy składowe oddzielone prze-
cinkami: nazwę użytkownika, sól oraz połączone (i zaszyfrowane) hasło i sól. Notacja e(Pies, 4238)
reprezentuje wynik konkatenacji hasła Bartosza (w tym przypadku słowa Pies) z losowo przypisaną
solą (w tym przypadku 4238) przetworzony przez funkcję szyfrującą e. Właśnie wynik wygene-
rowany przez tę funkcję jest składowany w trzecim polu wpisu reprezentującego Bartosza.
Listing 9.2. Przykład użycia liczby losowej (soli) jako zabezpieczenia przed atakami z użyciem
wygenerowanej wcześniej listy zaszyfrowanych haseł
Bartosz, 4238, e(Pies,4238)
Tomasz, 2918, e(6%%TaeFF,2918)
Lidia, 6902, e(Shakespeare,6902)
Marek, 1694, e(XaB#Bwcz,1694)
Dorota, 1092, e(LordByron,1092)
Hasła jednorazowe
Większość superużytkowników namawia zwykłych użytkowników do zmieniania swoich haseł
przynajmniej raz w miesiącu. Ich prośby najczęściej są ignorowane. Istnieje też ekstremalny
schemat wymuszający zmianę hasła przy okazji każdego logowania, czyli w praktyce wymuszający
stosowanie haseł jednorazowych (ang. one-time passwords). Użytkownicy systemów, których
administratorzy decydują się na stosowanie schematu haseł jednorazowych, otrzymują papie-
rowe listy kolejnych haseł. Podczas każdego logowania należy użyć kolejnego hasła z listy. Nawet
jeśli atakującemu uda się odkryć hasło, nie będzie mógł tego hasła wykorzystać, ponieważ podczas
następnego logowania system zażąda wpisania innego hasła. Każdy użytkownik powinien oczy-
wiście dokładać wszelkich starań, aby nie zgubić swojej książki haseł.
W rzeczywistości żadna książka haseł nie jest potrzebna — wystarczy zastosować schemat
autorstwa Lesliego Lamporta, umożliwiający bezpieczne logowanie użytkownika za pośrednic-
twem niezabezpieczonej sieci z wykorzystaniem haseł jednorazowych [Lamport, 1981]. Metoda
Lamporta umożliwia użytkownikom logowanie się w systemach firmowych z poziomu swoich
komputerów domowych (za pośrednictwem internetu) nawet w sytuacji, gdy ewentualni intruzi
mogą przechwytywać dane przesyłane w obu kierunkach. Co więcej, model zaproponowany przez
Lamporta nie wymaga składowania żadnych tajnych danych w systemie plików (ani na serwerze,
ani na komputerze osobistym użytkownika). Opisywana technika bywa określana mianem jed-
nokierunkowego łańcucha skrótu (ang. one-way hash chain).
Algorytm Lamporta wykorzystuje funkcję jednokierunkową, czyli funkcję y = f(x) mającą
tę wyjątkową cechę, że wyznaczenie wartości y na podstawie argumentu x jest proste, ale już
wyznaczenie argumentu x na podstawie znanej wartości y jest obliczeniowo niewykonalne. Dane
wejściowe i wyjściowe (wynikowe) powinny mieć tę samą długość, równą np. 256 bitów.
Użytkownik wybiera i zapamiętuje nie tylko tajne hasło, ale też liczbę naturalną n określającą,
ile haseł jednorazowych ma być wygenerowanych przez dany algorytm. Na potrzeby naszej analizy
przyjmijmy, że n jest równe 4 (w praktyce stosuje się nieporównanie większe wartości). Jeśli
tajnym hasłem jest s, pierwsze hasło zostaje wyznaczone poprzez n-krotne wykonanie funkcji
jednokierunkowej f:
P1 = f(f(f(f(s))))
Drugie hasło jest generowane poprzez wykonanie tej samej funkcji jednokierunkowej n–1 razy:
P2 = f(f(f(s)))
Trzecie hasło jest generowane poprzez dwukrotne wykonanie funkcji f; czwarte hasło jest
wynikiem jednokrotnego wykonania tej funkcji. Ogólnie Pi−1 = f (Pi ). Najważniejszą cechą tego
modelu jest możliwość łatwego wyznaczenia poprzedniego hasła sekwencji i brak możliwości
wyznaczenia następnego hasła. Jeśli np. znamy hasło P2, określenie hasła P2 jest łatwe, ale okre-
ślenie hasła P2 okazuje się niemożliwe.
Serwer jest inicjalizowany z wykorzystaniem hasła P0, czyli po prostu wynikiem funkcji f(P1)
Wartość tej funkcji jest składowana we wpisie pliku haseł skojarzonym z nazwą danego użyt-
kownika wraz z liczbą całkowitą 1 (określającą, że następnym wymaganym hasłem jest P1).
Kiedy użytkownik chce po raz pierwszy zalogować się w systemie, wpisuje swoją nazwę i otrzy-
muje w odpowiedzi liczbę całkowitą odczytaną z pliku haseł, czyli 1. Komputer użytkownika
odpowiada wówczas hasłem P1, które można lokalnie wyznaczyć na podstawie znanego użytkowni-
kowi tajnego hasła s. Serwer wyznacza następnie wartość funkcji f(P1) i porównuje ją z wartością
składowaną w pliku haseł (P0). Jeśli obie wartości do siebie pasują, próba logowania jest akcep-
towana, liczba całkowita jest zwiększana do 2, a hasło P1 nadpisuje hasło P0 w pliku haseł.
wysłana inna 512-bitowa liczba losowa. Oczywiście można (tak jest w zdecydowanej większości
przypadków) użyć nieporównanie bardziej wyszukanego algorytmu niż proste podnoszenie do
kwadratu.
Jedną z wad tego i wszystkich innych stałych protokołów kryptograficznych jest ryzyko ich
złamania w dłuższej perspektywie, co z czasem może uczynić bezużytecznymi karty inteligentne
stosujące te protokoły. Można ten problem obejść poprzez wykorzystanie pamięci ROM karty
inteligentnej do składowania interpretera Javy (zamiast stałego protokołu kryptograficznego).
Właściwy protokół kryptograficzny może być wówczas pobierany na kartę w formie programu
binarnego Javy wykonywanego następnie przez interpreter. Takie rozwiązanie oznacza, że po
złamaniu jednego protokołu można łatwo (nawet na całym świecie) zainstalować inny protokół —
niezbędne oprogramowanie jest instalowane przy okazji następnego użycia karty. Wadą tego
modelu okazuje się dodatkowe spowalnianie pracy i tak wolnych kart inteligentnych, jednak
dostępne technologie są stale doskonalone, a sama metoda jest bardzo elastyczna. Inną wadą
tego schematu stosowania kart inteligentnych jest ryzyko zaatakowania zagubionej lub skra-
dzionej karty z wykorzystaniem kanału ubocznego (ang. side-channel), np. poprzez analizę zużycia
energii. Obserwacja energii elektrycznej zużywanej podczas wielokrotnie wykonywanych ope-
racji szyfrowania może pomóc ekspertowi wyposażonemu w odpowiednie narzędzia w odkryciu
tajnego klucza. Także czas szyfrowania w zależności od stosowania kluczy może dostarczyć stronie
atakującej cennych informacji o właściwym kluczu.
Drugim etapem jest identyfikacja. Użytkownik w pierwszej kolejności wpisuje swoją nazwę.
System wykonuje następnie ponowny pomiar wybranej cechy biometrycznej. Jeśli nowe wartości
odpowiadają wartościom zgromadzonym na etapie rejestracji, próba logowania jest akceptowana;
w przeciwnym razie próba logowania zostaje odrzucona. Nazwa logowania jest w tym przypadku
konieczna, ponieważ pomiary cech biometrycznych nigdy nie są stuprocentowo zgodne (w pełni
powtarzalne), co z kolei utrudnia ich indeksowanie i efektywne przeszukiwanie. Co więcej, dwie
osoby mogą mieć zbliżone lub identyczne cechy biometryczne, zatem dopasowywanie wyników
pomiaru do cech konkretnego użytkownika jest bezpieczniejsze niż ich zestawianie z danymi
zarejestrowanymi dla wszystkich użytkowników.
Weryfikowana cecha biometryczna powinna być na tyle zmienna, aby system mógł na jej pod-
stawie bezbłędnie rozróżniać wielu użytkowników. Dobrym przykładem cechy biometrycznej,
która nie nadaje się do tego rodzaju zastosowań, jest kolor włosów, ponieważ zbyt wiele osób
cechuje identyczny kolor. Stosowana w tej roli charakterystyka nie powinna też podlegać zmia-
nom w czasie — także w tym aspekcie kolor włosów okazuje się kiepskim wyborem. Z podobną
sytuacją mamy do czynienia w przypadku głosu, który może się zmieniać wskutek zimna, oraz
twarzy, której wygląd w dużej mierze zależy od stanu zarostu (obecnego lub brakującego na
etapie rejestracji użytkownika). Ponieważ późniejsze próby nigdy nie pasują w stu procentach
do wartości zgromadzonych w trakcie rejestracji, projektanci systemu muszą zdecydować, na
ile precyzyjne dopasowania powinny być akceptowane. W szczególności powinni określić, co
jest gorsze — sporadyczne odrzucanie próby uwierzytelnienia uprawnionego użytkownika czy
sporadyczna akceptacja próby uwierzytelnienia podjętej przez oszusta. Administrator witryny
sklepu internetowego może zdecydować, że odrzucenie próby uwierzytelnienia lojalnego klienta
będzie gorsze niż akceptacja niewielkiej liczby nieuprawnionych żądań; z drugiej strony spora-
dyczne odrzucenie przez system ośrodka badań nad bronią jądrową żądania dostępu uprawnio-
nego pracownika będzie lepsze niż choćby jednorazowe dopuszczenie do tajemnic osoby nie-
uprawnionej.
Przeanalizujmy teraz wybrane techniki biometryczne, które wykorzystuje się do uwierzy-
telniania użytkowników systemów komputerowych. Zadziwiająco praktyczną i skuteczną techniką
jest analiza długości palców. Jeśli administrator systemu zdecyduje się na zastosowanie tej
metody, każdy komputer jest wyposażany w urządzenie podobne do tego z rysunku 9.16.
Użytkownik kładzie swoją dłoń na urządzeniu, które mierzy długość wszystkich jego palców
i porównuje z długościami zapisanymi w bazie danych.
Pomiary długości palców nie są jednak doskonałe. Tak zabezpieczony system można zaata-
kować z wykorzystaniem modeli dłoni z gipsu paryskiego lub innego materiału.
Inną techniką biometryczną powszechnie stosowaną w rozwiązaniach komercyjnych jest roz-
poznawanie tęczówki. Nie istnieją dwie osoby z identycznymi tęczówkami (nawet wśród z pozoru
identycznych bliźniąt), zatem rozpoznawanie tęczówek jest równie skuteczne jak analiza linii
papilarnych, ale łatwiejsze do zautomatyzowania [Daugman, 2004]. Wystarczy, że użytkownik
spojrzy w obiektyw aparatu (z odległości nie większej niż metr), który sfotografuje jego oczy
i wyodrębni z pobranego obrazu pewne charakterystyki w procesie określanym mianem trans-
formacji falkowej Gabora (ang. Gabor wavelet transformation), po czym skompresuje otrzymany
wynik do 256 bajtów. Gotowy łańcuch jest porównywany z wartością uzyskaną podczas reje-
stracji danego użytkownika — jeśli odległość Hamminga (ang. Hamming distance) dzieląca oba
łańcuchy nie przekracza przyjętego progu, próba uwierzytelniania jest akceptowana. (Odległość
Hamminga pomiędzy dwoma łańcuchami bitów jest równa minimalnej liczbie zmian potrzebnych
do przekształcenia jednego z tych łańcuchów w drugi).
tego wyrażenia (podczas każdej próby logowania wykorzystuje się inny tekst). Niektóre firmy
próbują stosować techniki identyfikacji głosu w takich zastosowaniach jak zakupy przez tele-
fon, ponieważ ta forma identyfikacji jest trudniejsza do podrobienia niż identyfikacja z użyciem
kodów PIN. W celu zwiększenia dokładności techniki rozpoznawania głosu mogą być łączone
z innych technikami biometrycznymi — np. rozpoznawaniem twarzy [Tresadern et al., 2013].
Moglibyśmy oczywiście dalej wyliczać przykłady technik biometrycznych, jednak ograni-
czymy się do analizy dwóch skrajnych przykładów, które powinny nam ułatwić zrozumienie
istoty tej koncepcji. Koty i inne zwierzęta oznaczają swoje terytorium, oddając mocz na granicy
tego terenu. Wydaje się więc, że koty potrafią identyfikować inne osobniki w ten sposób. Przy-
puśćmy, że komuś udało się opracować niewielkie urządzenie zdolne do błyskawicznej analizy
moczu człowieka i bezbłędnie identyfikujące osobniki naszego gatunku. Można by wyposażyć
w to urządzenie dosłownie każdy komputer — wystarczyłoby przekazać dyskretny komunikat:
Aby zalogować się w systemie, złóż próbkę we wskazanym miejscu. System w tej formie byłby
niemożliwy do złamania, ale zapewne natknąłby się na poważne problemy z akceptacją wśród
użytkowników.
Kiedy powyższy akapit został umieszczony w poprzednim wydaniu tej książki, miał on być —
przynajmniej częściowo — żartem. Już tak nie jest. W ramach prac nad imitacją życia naukowcy
opracowali systemy rozpoznawania zapachu, które mogą być wykorzystane w systemach bio-
metrycznych [Rodriguez-Lujan et al., 2013]. Czy technologia Smell-O-Vision również zostanie
wykorzystana jako technika biometryczna?
Z podobną sytuacją mielibyśmy do czynienia w przypadku systemu złożonego z pineski
i małego spektrografu. Użytkownik miałby nacisnąć kciukiem pineskę, aby spuścić kroplę krwi
analizowaną następnie przez spektrograf. Dotychczas nikt nie opublikował niczego na ten temat,
ale są prowadzone prace poświęcone wykorzystaniu obrazu naczyń krwionośnych w celach bio-
metrycznych [Fuksis et al., 2011].
Problem w tym, że każdy schemat uwierzytelniania musi być psychologicznie akceptowalny
dla społeczności użytkowników. System mierzący długość palców prawdopodobnie nie będzie
stwarzał żadnych fizycznych trudności użytkownikom, ale już tak nieinwazyjna metoda jak reje-
stracja w systemie linii papilarnych bywa odrzucana przez użytkowników, którzy kojarzą tę
czynność z kryminalistami. Pomimo to firma Apple wprowadziła tę technologię w urządzeniu
iPhone 5S.
Jednym z głównych sposobów na to, by włamać się do czyjegoś komputera, jest wykorzystanie
luk w oprogramowaniu działającym w jego systemie. Ma to na celu takie zmodyfikowanie pro-
gramu, by działał inaczej, niż zamierzał programista. Popularnym sposobem ataku jest technika
pobierania plików bez wiedzy użytkownika (ang. drive-by download). W tym ataku cyberprze-
stępca infekuje przeglądarkę użytkownika poprzez umieszczenie złośliwej zawartości na ser-
werze WWW. Kiedy użytkownik odwiedzi tę stronę, dochodzi do zainfekowania przeglądarki.
Czasami serwery WWW są w całości prowadzone przez napastników. W takim przypadku cyber-
przestępcy szukają sposobu, aby zachęcić użytkownika do odwiedzenia ich witryny (często ucie-
kają się do przesyłania spamu z obietnicami darmowego oprogramowania lub filmów). Możliwa
jest również sytuacja, w której napastnicy umieszczają złośliwą zawartość na legalnie działających
stronach WWW (np. portalach z ogłoszeniami lub forach dyskusyjnych). Nie tak dawno w ten
sposób zaatakowano witrynę internetową Miami Dolphins — zaledwie kilka dni przed Super Bowl,
jednym z najbardziej oczekiwanych sportowych wydarzeń roku, którego klub Miami Dolphins
był gospodarzem. W tym czasie strona internetowa klubu była niezwykle popularna, dlatego
zainfekowanych zostało wielu odwiedzających ją użytkowników. Po początkowej infekcji w ataku
drive-by-download kod napastnika działający w przeglądarce pobiera faktycznie złośliwe opro-
gramowanie (malware), uruchamia je, a następnie wprowadza zmiany w konfiguracji przeglądarki,
aby złośliwy program uruchomił się przy każdym starcie systemu.
Ponieważ jest to książka poświęcona systemom operacyjnym, skoncentrujemy się w niej
na sposobach obejścia zabezpieczeń systemów operacyjnych. Nie opisaliśmy wielu sposobów
wykorzystania błędów w oprogramowaniu do atakowania witryn WWW i baz danych. W typowym
scenariuszu ktoś odkrywa błąd w systemie operacyjnym, po czym odnajduje sposób wykorzy-
stania tego błędu do przejęcia kontroli nad komputerem, na którym pracuje nieprawidłowy
kod. Ataki drive-by-download także niezbyt dokładnie pasują do prezentowanego tematu, ale jak
się przekonamy, wiele luk i eksploitów w aplikacjach użytkowych jest stosowanych również
w odniesieniu do jądra.
W słynnej książce Lewisa Carrolla Po drugiej stronie lustra Czerwona Królowa zabiera Alicję
na szalony bieg. Biegną tak szybko, jak się da, ale niezależnie od tego, jak szybko biegną, zawsze
są w tym samym miejscu. To dziwne — pomyślała Alicja i powiedziała: — W naszym kraju, jeśli
się przez długi czas biegnie tak, jak my, to zwykle dociera się w inne miejsce. — To musi być
bardzo powolny kraj! — odpowiedziała Królowa. — Tutaj trzeba biec, żeby pozostać w tym
samym miejscu. Jeśli chcesz dotrzeć gdzieś indziej, musisz biec co najmniej dwukrotnie szybciej!
Efekt Czerwonej Królowej jest typowy dla „wyścigów zbrojeń” w procesie ewolucji. W ciągu
milionów lat ewoluowali zarówno przodkowie zebr, jak i lwów. Zebry stały się szybsze i popra-
wiły wzrok, słuch i powonienie — wykształciły więc bardzo przydatne cechy do tego, by móc
uciec lwom. Ale lwy również stały się szybsze, większe, cichsze i lepiej zamaskowane — zatem
wykształciły cechy przydatne do polowania na zebry. Zatem, mimo że zarówno lwy, jak i zebry
„poprawiły” swoje projekty, żadne z tych zwierząt nie poprawiło swoich cech na tyle, aby prze-
ścignąć drugą stronę w walce o przetrwanie. Dlatego właśnie oba gatunki nadal żyją dziko
w naturze. Lwy i zebry wciąż trwają w swoim „wyścigu zbrojeń”. Biegną po to, by pozostać
w miejscu. Efekt Czerwonej Królowej ma również zastosowanie w wykorzystywaniu luk w pro-
gramach. Ataki stają się coraz bardziej wyrafinowane, aby mogły pokonywać coraz bardziej zaawan-
sowane środki bezpieczeństwa.
Mimo że każda luka w zabezpieczeniach wynika z konkretnego błędu w kodzie konkretnego
programu, istnieje wiele ogólnych, powtarzalnych kategorii błędów i scenariuszy ataku z ich
wykorzystaniem, którym warto poświęcić trochę uwagi. W poniższych punktach przeanalizu-
jemy nie tylko szereg sposobów wykorzystywania błędów, ale także środków zaradczych po-
zwalających je unieszkodliwić. Omówimy również sposoby obchodzenia środków zaradczych,
a nawet kilka sposobów pokonywania metod obchodzenia środków zaradczych itd. W ten sposób
można uzyskać dobry obraz wyścigu zbrojeń pomiędzy napastnikami a obrońcami. Można też
dowiedzieć się, co to znaczy pobiegać z Czerwoną Królową.
Naszą dyskusję zaczniemy od omówienia czcigodnego przepełnienia bufora — jednej z naj-
ważniejszych technik wykorzystywania błędów w oprogramowaniu w historii komputerowych
zabezpieczeń. Używano jej już w pierwszym robaku internetowym napisanym przez Roberta
Morrisa juniora w 1988 roku i nadal jest ona powszechnie stosowana. Pomimo istnienia wielu
środków zaradczych naukowcy przewidują, że błędy przepełnienia bufora będą wykorzystywane
jeszcze co najmniej przez jakiś czas [van der Veen, 2012]. Błędy przepełnienia bufora idealnie
nadają się do wprowadzenia w tematykę trzech spośród najważniejszych mechanizmów zabez-
pieczeń dostępnych w najbardziej nowoczesnych systemach: tzw. kanarków (ang. stack canaries),
mechanizmów zapobiegania wykonywaniu danych (ang. data execution protection) oraz losowego
generowania układu przestrzeni adresowej (ang. address-space layout randomization). Następnie
omówimy inne techniki wykorzystywania błędów w oprogramowaniu — takie jak ataki z wyko-
rzystaniem ciągów formatujących oraz „wiszących wskaźników” (ang. dangling pointers). Zatem
przygotuj się i włóż swój czarny kapelusz!
Rysunek 9.17. (a) Wykonywanie głównego programu; (b) sytuacja po wywołaniu procedury A;
(c) przykład przepełnienia bufora (wyróżnionego szarym kolorem)
Zatem co dokładnie się stanie, jeśli użytkownik wprowadzi więcej niż 128 znaków? Taką
sytuację pokazano na rysunku 9.17(c). Jak już wspomniano, funkcja gets skopiuje wszystkie
bajty do bufora i poza bufor, potencjalnie zastępując wiele informacji na stosie, ale w szczególno-
ści adres powrotu, który został odłożony na stos wcześniej. Innymi słowy, część wpisu dziennika
wypełnia teraz miejsce pamięci, zawierające — zgodnie z tym, co zakłada system — instrukcję
skoku, która ma być wykonana po zwróceniu sterowania przez funkcję. Jeśli użytkownik wpisał
z klawiatury zwyczajny wpis dziennika, znaki komunikatu prawdopodobnie nie będą reprezento-
wały prawidłowego kodu adresu. Kiedy funkcja A zwróci sterowanie, program podejmie próbę
skoku do nieprawidłowego miejsca docelowego — taka operacja z pewnością nie wpłynie dobrze
na system. W większości przypadków program natychmiast się zawiesi.
Załóżmy teraz, że nie mamy do czynienia z łagodnym użytkownikiem, który przez pomyłkę
wpisał zbyt długi komunikat, ale z napastnikiem, który wprowadził specjalnie przygotowany
komunikat mający na celu zmodyfikowanie przepływu sterowania w programie. Przypuśćmy,
że napastnik wprowadził dane wejściowe, które zostały starannie spreparowane w taki sposób,
aby zastąpić adres powrotu adresem bufora B. Ponieważ napastnik ma kontrolę nad zawartością
bufora, może wypełnić go instrukcjami maszynowymi tak, aby uruchomić własny kod w kon-
tekście pierwotnego programu. W efekcie napastnik nadpisał pamięć własnym kodem i uru-
chomił ten kod. Teraz program działa całkowicie pod kontrolą intruza. Może polecić mu wyko-
nanie dowolnych instrukcji. Często kod intruza jest wykorzystywany do uruchomienia powłoki
(np. za pomocą wywołania systemowego exec), co daje napastnikowi wygodny dostęp do maszyny.
Z tego powodu taki kod jest powszechnie określany mianem kodu powłoki (ang. shellcode), nawet
jeśli nie powoduje uruchomienia powłoki.
Problem dotyczy nie tylko programów korzystających z funkcji gets (choć lepiej nie używać
jej w swoich programach), ale i dowolnego kodu, który kopiuje dane dostarczone przez użyt-
kownika do bufora bez kontroli naruszania granic bufora. Dane użytkownika mogą zawierać
parametry wiersza polecenia, zmienne środowiskowe, dane przesyłane za pośrednictwem połą-
czenia sieciowego lub dane odczytane z pliku użytkownika. Istnieje wiele funkcji, które kopiują
lub przenoszą takie dane, m.in. strcpy, memcpy, strcat i wiele innych. Oczywiście każda pętla
zajmująca się przenoszeniem bajtów do bufora, którą samodzielnie napiszemy, także może być
wrażliwa.
Czy napastnik może coś zrobić, jeśli nie wie dokładnie, gdzie znajduje się adres powrotu?
Często intruz potrafi odgadnąć w przybliżeniu, gdzie znajduje się kod powłoki, ale nie potrafi
tego określić dokładnie. W takim przypadku typowym zabiegiem jest poprzedzenie kodu powłoki
ciągiem rozkazów NOP: sekwencją jednobajtowych instrukcji NO OPERATION (brak operacji), które
nie wykonują żadnych działań. Jeśli tylko intruzowi uda się wykonać skok do jakiegokolwiek
miejsca w ciągu rozkazów NOP, to ostatecznie i tak dotrze do kodu powłoki. Ciągi rozkazów
NOP (tzw. sanie NOP — od ang. NOP sled) działają w odniesieniu do stosu, ale są także wyko-
rzystywane na stercie. W przypadku sterty napastnicy często próbują zwiększyć swoje szanse,
umieszczając „sanie NOP” i kod powłoki w wielu miejscach sterty; np. w przeglądarce złośliwy
kod JavaScript może spróbować przydzielić jak najwięcej pamięci i wypełnić tę pamięć długą
sekwencją „sań NOP” i niewielką ilością kodu powłoki. Następnie, jeśli intruzowi uda się zwieść
przepływ sterowania i skierować do losowego adresu sterty, istnieje duża szansa, że sterowa-
nie trafi do „sań NOP”. Proces ten nazywa się natryskiwaniem sterty (ang. heap spraying).
Kanarki
Jednym z powszechnie używanych mechanizmów obrony przed atakami opisanymi powyżej
jest wykorzystanie tzw. kanarków (ang. stack canaries). Nazwa wywodzi się z branży górniczej.
Praca w kopalni jest niebezpieczna. W korytarzach mogą gromadzić się toksyczne gazy, jak tlenek
węgla, który może zatruwać górników. Co gorsza, tlenek węgla jest bezwonny, zatem górnicy mogą
nawet nie zauważyć zagrożenia.
Z tego powodu w przeszłości przynosili oni kanarki do kopalni i wykorzystywali je do wcze-
snego ostrzegania. Każdy incydent wydzielania się tlenku węgla zabijał kanarka, zanim gaz zdążył
wyrządzić szkodę jego właścicielowi. Jeśli ptak ginął, był to sygnał, aby wracać na powierzchnię.
W nowoczesnych systemach komputerowych nadal używa się kanarków (cyfrowych) w roli
systemów wczesnego ostrzegania. Idea jest bardzo prosta. W miejscach, gdzie program wyko-
nuje wywołanie funkcji, kompilator wstawia kod, który zapisuje na stosie, bezpośrednio poni-
żej adresu powrotu, losową wartość kanarka. Za instrukcją powrotu sterowania z funkcji kom-
pilator wstawia kod sprawdzający wartość kanarka. Jeśli wartość się zmieniła, to oznacza, że coś
jest nie tak. W takim przypadku lepiej wcisnąć przycisk paniki i zakończyć działanie programu,
niż je kontynuować.
Unikanie kanarków
Opisane powyżej zabezpieczenie w postaci kanarków jest skuteczne, ale w dalszym ciągu
możliwych jest wiele ataków wykorzystujących przepełnienia bufora. Dla przykładu rozważmy
fragment kodu pokazany na listingu 9.3. Wykorzystano na nim dwie nowe funkcje. strcpy to
funkcja biblioteczna języka C, która kopiuje łańcuch znaków do bufora, natomiast funkcja strlen
określa długość łańcucha.
Listing 9.3. Obejście kanarka — dzięki wcześniejszej modyfikacji zmiennej len napastnik może
obejść kanarka i bezpośrednio zmodyfikować adres powrotu
01. void A (char *date) {
02. int len;
03. char B [128];
04. char logMsg [256];
05.
06. strcpy (logMsg, date); /* najpierw kopiujemy łańcuch znaków z datą do komunikatu dziennika */
07. len = str len (date); /* sprawdzamy, ile znaków jest w ciągu daty */08. gets (B);
/* pobranie właściwego komunikatu */
09. strcpy (logMsg+len, B); /* skopiowanie go za datą w komunikacie logMessage */
10. writeLog (logMsg); /* na koniec zapisanie wiadomości dziennika na dysku */
11. }
Składnia być może wygląda trochę tajemniczo, ale w rzeczywistości to nic innego, jak deklara-
cja zmiennej. Ponieważ funkcja A z poprzedniego przykładu pasuje do powyższej sygnatury,
możemy teraz zapisać f=A i wykorzystywać w naszym programie f zamiast A. Szczegółowe
omówienie wskaźników na funkcje wykracza poza ramy tej książki. Zapamiętajmy jednak, że
wskaźniki na funkcje są w systemach operacyjnych dość powszechnie stosowane. Przypuśćmy,
że napastnikowi udało się nadpisać wskaźnik na funkcję. Kiedy program wywoła funkcję za
pomocą wskaźnika na funkcję, to w rzeczywistości wywoła kod wstrzyknięty przez intruza. Aby
eksploit zadziałał, wskaźnik na funkcję nie musi być nawet odłożony na stosie. Wskaźniki funkcji
na stercie są tak samo przydatne. Jeśli napastnik zdoła zastąpić wartość wskaźnika na funkcję
lub adres powrotu buforem zawierającym złośliwy kod, będzie w stanie zmienić przepływ ste-
rowania w programie.
się wkrótce przekonamy, objawienia nie zawsze są w stanie zatrzymać ataki bazujące na prze-
pełnieniu bufora. Pomimo to koncepcja jest właściwa. Ataki polegające na wstrzykiwaniu kodu
(ang. code injection attacks) nie zadziałają, jeśli bajty wprowadzone przez napastnika nie będą
mogły być uruchomione tak jak prawowity kod.
Nowoczesne procesory mają funkcję, która nosi popularną nazwę bit NX (od No-eXecute —
dosł. nie uruchamiać). Funkcja ta jest bardzo przydatna do odróżniania segmentów danych (sterta,
stos i zmienne globalne) od segmentów tekstu (zawierających kod). W wielu nowoczesnych sys-
temach operacyjnych są mechanizmy, które mają zapewnić możliwość zapisywania określonych
segmentów danych, ale zabronić ich uruchamiania. Z kolei segmenty tekstu mają mieć możli-
wość uruchamiania, ale nie mogą być zapisywane. W systemie OpenBSD zasada ta jest określana
jako W^X lub W XOR X. Oznacza ona, że pamięć może być zapisywalna albo wykonywalna, ale nie
może mieć obu tych cech jednocześnie. W systemach Mac OS X, Linux i Windows istnieją podobne
systemy zabezpieczeń. Są one określane ogólną nazwą zapobieganie wykonywaniu danych (ang.
Data Execution Prevention — DEP). Niektóre platformy sprzętowe nie obsługują bitu NX.
W takim przypadku funkcja DEP nadal działa, ale jest realizowana w oprogramowaniu.
Mechanizm DEP zapobiega wszystkim omówionym do tej pory atakom. Napastnik może
wprowadzić do procesu tyle kodu powłoki, ile chce. Jeśli nie zdoła ustawić trybu wykonalności
zawartości pamięci, nie ma sposobu, by udało mu się uruchomić ten kod.
teki libc atak może być przeprowadzony pośrednio. W systemie Linux napastnik może np.
zwrócić sterowanie do tablicy PLT (ang. Procedure Linkage Table). PLT to struktura, której
zadaniem jest ułatwienie dynamicznego łączenia. Zawiera fragmenty kodu, które po uruchomie-
niu wywołują łączone dynamicznie funkcje biblioteczne. Powrót do tego kodu powoduje pośred-
nie wywoływanie funkcji bibliotecznych.
W koncepcji programowania ROP (ang. Return-Oriented Programming — dosł. programo-
wanie zorientowane na powrót) wykorzystywany jest pomysł wielokrotnego użytkowania kodu
w maksymalnym możliwym stopniu. Zamiast do (punktu wejścia) funkcji bibliotecznej napast-
nik może powrócić do dowolnych instrukcji w segmencie kodu. Może np. przekazać sterowa-
nie do środka zamiast na początek funkcji. Program będzie następnie kontynuował działanie od
tego punktu — po jednej instrukcji na raz. Przypuśćmy, że po kilku instrukcjach w kodzie wystąpi
kolejna instrukcja powrotu. Teraz możemy zadać to samo pytanie po raz kolejny: dokąd można
przekazać sterowanie? Ponieważ intruz ma kontrolę nad stosem, znowu może przekazać stero-
wanie w dowolne miejsce. Ponadto po dwukrotnym wykonaniu tej operacji może równie dobrze
wykonać ją trzy, cztery, a nawet dziesięć razy.
W związku z tym sens programowania zorientowanego na powrót polega na wyszukiwaniu
niewielkich sekwencji kodu, które (a) realizują jakąś pożyteczną operację i (b) kończą się instruk-
cją powrotu. Napastnik może połączyć ze sobą te sekwencje za pomocą adresów powrotu, które
odkłada na stosie. Poszczególne fragmenty są nazywane gadżetami. Zazwyczaj mają bardzo
ograniczoną funkcjonalność — np. dodanie zawartości dwóch rejestrów, załadowanie wartości
z pamięci do rejestru lub odłożenie wartości na stos. Innymi słowy, zbiór gadżetów może być
postrzegany jako zestaw bardzo dziwnych instrukcji, których atakujący może używać — poprzez
sprytne manipulowanie stosem — do tworzenia dowolnych funkcji. Z kolei wskaźnik stosu służy
jako nieco dziwny rodzaj licznika programu.
W części (a) rysunku 9.18 pokazano przykład tego, jak gadżety są ze sobą łączone za pomocą
adresów powrotu na stosie. Gadżety są krótkimi fragmentami kodu, które kończą się instrukcją
powrotu sterowania (return). Instrukcja return zdejmuje ze stosu adres, dokąd ma być przeka-
zane sterowanie, i kontynuuje działanie z tamtego miejsca. W tym przypadku intruz najpierw
zwraca sterowanie do gadżetu A w pewnej funkcji X, następnie do gadżetu B w funkcji Y itp.
Zadaniem napastnika jest zebranie tych gadżetów z istniejącego pliku binarnego. Ponieważ intruz
nie jest autorem gadżetów, czasami musi skorzystać z gadżetów, które być może odbiegają od
ideału, ale są wystarczająco dobre do zrealizowania zadania. W części (b) rysunku 9.18 można
zauważyć, że wewnątrz sekwencji instrukcji w gadżecie A znajduje się instrukcja testu. Napast-
nikowi ten test może nie być do niczego potrzebny, ale skoro tam jest, musi go zaakceptować.
Dla większości zastosowań wystarczająco dobre byłoby zdjęcie ze stosu do rejestru 1 dowolnej
liczby nieujemnej. Kolejny gadżet zdejmuje ze stosu dowolną wartość i ładuje ją do rejestru 2,
natomiast trzeci mnoży rejestr 1 przez 4, odkłada wynik na stos i dodaje do rejestru 2. Łącznie te
trzy gadżety dają napastnikowi kod, który można wykorzystać do obliczenia adresu elementu
w tablicy liczb całkowitych. Indeks tablicy jest dostarczony za pomocą pierwszej wartości danych
na stosie, natomiast drugą wartością danych powinien być bazowy adres tablicy. Programowa-
nie zorientowane na powroty wygląda na bardzo skomplikowane i prawdopodobnie takie jest.
Ale tak jak w przypadku innych technik, opracowano technologie automatyzacji tej techniki
w takim stopniu, jak to możliwe. Przykładem narzędzi automatyzujących proces ROP są tzw. żni-
wiarze gadżetów (ang. gadget harvesters). Istnieją nawet kompilatory ROP. Obecnie ROP jest jedną
z najważniejszych technik tworzenia eksploitów wykorzystywanych przez krakerów.
Technika ASLR
Oto kolejny pomysł na powstrzymanie opisanych powyżej ataków. Oprócz modyfikowania adresu
powrotu i wstrzykiwania jakiegoś program (ROP) napastnik powinien mieć możliwość zwrotu
sterowania do dokładnie wskazanego adresu. W przypadku programowania ROP stosowanie „sań
NOP” nie jest możliwe. To łatwe, jeśli adresy są stałe, ale co zrobić, gdy takie nie są? Technika
ASLR (ang. Address Space Layout Randomization — dosł. losowe generowanie układu prze-
strzeni adresowej) ma na celu generowanie losowych adresów funkcji i danych przy każdym
uruchomieniu programu. W rezultacie wykorzystanie systemu przez osobę atakującą staje się
dużo trudniejsze. W szczególności zastosowanie techniki ASLR powoduje losową modyfikację
początkowego położenia stosu, sterty i bibliotek.
Wiele nowoczesnych systemów operacyjnych obsługuje technikę ASLR obok kanarków i DEP,
ale często z różnymi poziomami szczegółowości. W większości systemów technika ta jest dostępna
dla aplikacji użytkownika, ale tylko w kilku jest konsekwentnie stosowana również dla jądra
systemu operacyjnego [Giuffrida et al., 2012]. Połączenie sił tych trzech mechanizmów ochrony
znacznie podniosło poprzeczkę napastnikom. Sam skok do wstrzykniętego kodu lub nawet kilku
istniejących funkcji w pamięci stał się trudny do osiągnięcia. Wspólnie wymienione mechanizmy
tworzą istotną linię obrony we współczesnych systemach operacyjnych. Szczególnie wartościową
cechą tych funkcji jest to, że oferują one ochronę kosztem bardzo rozsądnej ceny w zakresie
wydajności.
W kodzie znajduje się wywołanie funkcji read_user_input, która nie należy do biblioteki stan-
dardowej języka C. Po prostu zakładamy, że ona istnieje i zwraca liczbę całkowitą, którą użyt-
kownik wpisze w wierszu polecenia. Zakładamy również, że funkcja ta nie zawiera żadnych
błędów. Pomimo to w takim kodzie jest bardzo łatwo sprowokować wyciek informacji. Wystarczy
wprowadzić indeks, który jest większy niż 15 lub mniejszy niż 0. Ponieważ program nie spraw-
dza indeksu, szczęśliwie zwróci wartość dowolnej liczbą całkowitej zapisanej w pamięci.
Do przeprowadzenia udanego ataku często wystarczy adres jednej funkcji. Powodem jest
to, że choć pozycja, pod którą załadowano bibliotekę, jest losowa, względne przesunięcie dla
każdej indywidualnej funkcji, licząc od tej pozycji, jest zazwyczaj stałe. Mówiąc inaczej: jeśli
znamy jedną funkcję, znamy je wszystkie. Nawet jeśli tak nie jest, to często wystarczy zaledwie
jeden adres w kodzie, aby znaleźć wiele innych. Pokazano to w pracy [Snow et al., 2013].
Celem powyższego kodu miało być sprawdzenie uprawnień. Tylko użytkownicy z odpo-
wiednimi poświadczeniami mogą zobaczyć poufne dane. Funkcja check_credentials nie należy
do biblioteki standardowej języka C, ale zakładamy, że istnieje gdzieś w programie i nie zawiera
żadnych błędów. Przypuśćmy, że napastnik wpisze 129 znaków. Tak jak w poprzednim przy-
padku, dojdzie do przepełnienia bufora, ale adres powrotu nie zostanie zmodyfikowany. Zamiast
tego intruz zmodyfikował wartość zmiennej authorized, nadając jej wartość różną od 0. Nie
dojdzie do awarii programu ani nie zostanie uruchomiony żaden kod wprowadzony przez ataku-
jącego, ale nastąpi wyciek poufnych informacji do nieuprawnionych użytkowników.
Wywołanie funkcji printf w tej formie jest dopuszczalne, ponieważ funkcja printf akceptuje
zmienną liczbę argumentów, jednak pierwszy argument zawsze musi reprezentować łańcuch
formatujący. Okazuje się tymczasem, że także łańcuch pozbawiony jakichkolwiek informacji
formatujących (np. "%s") nie powoduje bezpośrednich błędów, zatem druga wersja — chociaż
z pewnością nie stanowi dobrej praktyki programistycznej — jest dopuszczalna i działa prawi-
dłowo. Co więcej, wywołanie funkcji w tej formie pozwala programiście zaoszczędzić pięć zna-
ków; mamy więc do czynienia z pokusą nie do odparcia.
Sześć miesięcy później jakiś inny programista otrzymał zadanie takiego zmodyfikowania
tego kodu, aby program żądał od użytkownika jego imienia, po czym pozdrawiał go przy użyciu
podanego łańcucha. Po pobieżnej analizie kodu w dotychczasowej formie programista decyduje
się na wprowadzenie następujących zmian:
char s[100], g[100] = "Witaj "; /* deklaruje tablice s oraz g; inicjalizuje g */
gets(s); /* odczytuje łańcuch wpisany przez użytkownika do zmiennej s */
strcat(g, s); /* konkatenuje łańcuchy s oraz g */
printf(g); /* wyświetla łańcuch g */
Zmieniony kod umieszcza wpisany przez użytkownika łańcuch w zmiennej s, po czym konka-
tenuje tę zmienną z zainicjalizowanym wcześniej łańcuchem g, aby w ten sposób skonstruować
wynikowy komunikat (reprezentowany właśnie przez g). Program nadal działa. Do tej pory nie
napotkaliśmy żadnych problemów (może z wyjątkiem funkcji gets, która może być przedmiotem
ataków z wykorzystaniem zjawiska przepełnienia bufora).
Okazuje się jednak, że użytkownik dysponujący odpowiednią wiedzą natychmiast odkryje,
że dane wejściowe pobierane przez ten program nie są traktowane jako zwykły łańcuch — pełnią
funkcję łańcucha formatującego i jako takie mogą zawierać wszystkie elementy formatujące
akceptowane przez funkcję printf. O ile większość symboli formatujących, jak "%s" (dla wyświe-
tlania łańcuchów) czy "%d" (dla wyświetlania dziesiętnych liczb całkowitych), ogranicza się do
formatowania danych wyjściowych, o tyle musimy pamiętać o istnieniu kilku symboli specjal-
nych. W szczególności symbol "%n" nie wyświetla żadnych danych, tylko wyznacza liczbę znaków
dotychczas umieszczonych w łańcuchu wyjściowym i umieszcza ją w kolejnym argumencie
funkcji printf. Przykład użycia symbolu "%n" przedstawiono poniżej:
int main(int argc, char *argv[])
{
int i=0;
printf("Witaj, %nświecie\n", &i); /* wartość wyznaczona przez %n jest umieszczana w zmiennej i */
printf("i=%d\n", i); /* zmienna i ma teraz wartość 6 */
}
Warto zwrócić uwagę na to, że zmienna i została zmodyfikowana przez wywołanie funkcji printf,
co nie dla wszystkich jest oczywiste. Mimo że opisany mechanizm w praktyce stosuje się wyjąt-
kowo rzadko, powyższy przykład pokazuje, że bezkrytyczne wyświetlanie łańcucha formatują-
cego może powodować umieszczenie słowa (lub wielu słów) w pamięci. Czy pomysł użycia
funkcji printf w tej formie był prawidłowy? Zdecydowanie nie, mimo że początkowo ta koncepcja
sprawiała wrażenie wygodnego i skutecznego rozwiązania. W ten sposób powstało mnóstwo luk
w oprogramowaniu.
Jak wiemy z powyższego przykładu, programista, który zmodyfikował pierwotną wersję
naszego kodu, przypadkowo umożliwił użytkownikom tego programu wpisywanie łańcuchów
formatujących. Ponieważ wyświetlanie łańcucha formatującego może się zakończyć nadpisa-
niem pamięci, użytkownik zyskał narzędzie do nadpisania adresu zwrotnego funkcji printf na
stosie i wymuszenia skoku w dowolne inne miejsce, np. do specjalnie skonstruowanego łańcucha
formatującego. Próby wykorzystania tej luki określa się mianem ataków z wykorzystaniem łańcucha
formatującego (ang. format string attacks).
Przeprowadzenie ataku z wykorzystaniem ciągu formatującego nie jest trywialne. Gdzie
będzie przechowywana liczba znaków, które funkcja wyświetliła? Pod adresem parametru nastę-
pującego za ciągiem formatującym, tak jak w przykładzie zamieszczonym powyżej. Ale we wraż-
liwym kodzie napastnik mógł podać tylko jeden ciąg (i nie przekazywać drugiego parametru
funkcji printf). W rzeczywistości funkcja printf przyjmie założenie, że drugi parametr istnieje.
Po prostu weźmie następną wartość ze stosu i jej użyje. Napastnik może także sprawić, aby
funkcja printf użyła następnej wartości na stosie — np. poprzez podanie jako danych wejściowych
następującego ciągu formatującego:
"%08x %n"
Ciąg "%08x" oznacza, że funkcja printf wyświetli następny parametr jako 8-cyfrową liczbę
szesnastkową. Zatem jeśli wartość wynosi 1, to wyświetli 0000001. Innymi słowy, w przypadku
zastosowania tego ciągu formatującego funkcja printf po prostu założy, że następna wartość
na stosie to 32-bitowa liczba, którą należy wyświetlić, a wartość występująca za nią to adres miej-
sca, gdzie należy przechowywać liczbę wyświetlanych znaków, w tym przypadku dziewięć:
osiem do wyświetlenia liczby w formacie szesnastkowym i jeden do wyświetlenia spacji. Załóżmy,
że napastnik wprowadził następujący ciąg formatujący:
"%08x %08x %n"
W takim przypadku funkcja printf zapisze wartość pod adresem występującym na stosie jako
trzecia wartość za ciągiem formatującym itd. To stanowi klucz do tego, aby zaprezentowany
powyżej błąd ciągu formatującego stał się dla napastnika prymitywem „zapisz, co chcesz i gdzie
chcesz”. Szczegółowe opisywanie tego mechanizmu wykracza poza ramy tej książki. Koncep-
cja jest jednak taka, że napastnik próbuje zadbać o to, aby właściwy adres docelowy znalazł się
na stosie. To łatwiejsze, niż można by sądzić. Przykładowo we wrażliwym kodzie, który zapre-
zentowaliśmy powyżej, ciąg g sam jest na stosie — pod wyższym adresem niż ramka stosu
funkcji printf (patrz rysunek 9.19). Załóżmy, że ciąg rozpoczyna się tak, jak pokazano na
rysunku 9.19 — najpierw jest sekwencja "AAAA", następnie sekwencja "0%x", a na końcu sekwen-
cja "%0n". Co się stanie? Jeśli napastnik uzyska adres ciągu "0%x", dotrze do ciągu formatującego
(przechowywanego w buforze B). Innymi słowy, funkcja printf wykorzysta pierwsze 4 bajty ciągu
formatującego w roli adresu do zapisu. Ponieważ wartość ASCII znaku A to 65 (lub 0x41 w forma-
cie szesnastkowym), program zapisze wynik w lokalizacji 0x41414141, ale napastnik może bez
trudu określić także inne adresy. Oczywiście musi zadbać o to, aby liczba wyświetlanych znaków
była dokładnie taka, jak trzeba (ponieważ właśnie te bajty będą zapisane pod adresem docelowym).
W praktyce trzeba wykonać jeszcze trochę dodatkowych działań, ale niezbyt dużo. Wystarczy
wpisać „format string attack” w dowolnej wyszukiwarce internetowej, aby znaleźć mnóstwo
informacji dotyczących problemu.
01. int *A = (int *) malloc (128); /* przydzielenie miejsca dla 128 liczb całkowitych */
02. int year of bir th = read_user_input(); /* odczytanie liczby całkowitej ze standardowego wejścia */
03. if (input < 1900) {
04. printf ("Błąd. Rok urodzenia powinien być późniejszy niż 1900 \n");
05. free(A);
06. } else {
07. ...
08. /* wykonaj interesujące operacje z tablicą A*/
09. ...
10. }
11. ... /* wiele dodatkowych instrukcji, zawierających wywołania malloc i free */
12. A[0] = year_of_birth;
Powyższy kod jest błędny. Nie tylko ze względu na dyskryminację wieku, ale także dlatego, że
w linii 12. może przypisać wartość do elementu tablicy A, który wcześniej został zwolniony
(w wierszu 5.). Wskaźnik będzie nadal wskazywał na ten sam adres, ale nie powinien być już
używany. W rzeczywistości pamięć mogła być już użyta ponownie — w innym buforze (patrz
wiersz 11.).
Pytanie brzmi: co się stanie? Instrukcja w wierszu 12. próbuje zaktualizować pamięć, która
już nie jest w dyspozycji tablicy A. W związku z tym może dojść do zmodyfikowania innej
struktury danych, która teraz jest zapisana w tym obszarze pamięci. Ogólnie rzecz biorąc, taka
modyfikacja pamięci nie jest niczym dobrym, ale będzie jeszcze gorzej, jeśli napastnik zdoła
zmanipulować program w taki sposób, że umieści w tym miejscu pamięci konkretny obiekt —
np. taki, którego pierwsza liczba całkowita zawiera poziom uprawnień użytkownika. Nie zawsze
jest to łatwe do zrobienia, ale istnieją techniki (znane pod nazwą heap feng shui), które uła-
twiają napastnikom ten proceder. Feng shui to antyczna chińska sztuka odpowiedniego planowa-
nia rozmieszczenia budynków, grobowców i pamięci na stercie. Jeśli mistrzowi cyfrowego feng
shui się powiedzie, będzie mógł ustawić poziom autoryzacji na dowolną wartość (aż do 1900).
Polecenie w tej formie generuje listę wszystkich plików w bieżącym katalogu i zapisuje ją w pliku
nazwanym file-list. Na listingu 9.4 pokazano przykładowy kod, który mógłby zostać wykorzy-
stany przez leniwego programistę do kopiowania pliku z jednoczesną zmianą nazwy.
Listing 9.4. Kod, który może prowadzić do ataków poprzez wstrzykiwanie kodu
int main(int argc, char *argv[])
{
char src[100], dst[100], cmd[205] = "cp "; /* deklaruje trzy łańcuchy */
printf("("Proszę wpisać nazwę pliku źródłowego: "); /* pyta o nazwę pliku źródłowego */
gets(src); /* pobiera dane wejściowe z klawiatury */
strcat(cmd, src); /* dołącza łańcuch src za cp */
strcat(cmd, " "); /* dopisuje spację na końcu cmd */
printf("("Proszę wpisać nazwę pliku docelowego: "); /* pyta o nazwę pliku docelowego */
gets(dst); /* pobiera dane wejściowe z klawiatury */
strcat(cmd, dst); /* kończy generowanie łańcucha polecenia */
system(cmd); /* wykonuje gotowe polecenie */
}
Działanie tego programu sprowadza się do zażądania od użytkownika wpisania nazw plików
źródłowego i docelowego, skonstruowania na tej podstawie kompletnego polecenia cp oraz
użycia funkcji system do właściwego wykonania tego polecenia. Oznacza to, że jeśli użytkownik
wpisze odpowiednio nazwy abc i xyz, zostanie wykonane następujące polecenie:
cp abc xyz
Polecenie w tej formie początkowo kopiuje wskazany plik, po czym próbuje rekurencyjnie usu-
nąć wszystkie pliki i katalogi z całego systemu plików. Jeśli tak zaatakowany program działa
z uprawnieniami superużytkownika, opisana próba może się okazać skuteczna. W tym przy-
padku problemem jest traktowanie wszystkiego, co znajduje się za średnikiem, jako polecenia
powłoki.
Innym ciekawym przykładem jest użycie w roli drugiego argumentu łańcucha xyz; mail
[email protected] </etc/passwd, czyli w praktyce wymuszenie wykonania poleceń:
cp abc xyz; mail [email protected] </etc/passwd
Drugie polecenie wysyła plik haseł na nieznany i niegodny zaufania adres poczty elektronicznej.
Tak jak wcześniej zakładamy, że program ma ustawiony bit SETUID użytkownika root,
a napastnik chce skorzystać z jego uprawnień w celu dokonania zapisu do pliku haseł. Oczywi-
ście nie ma uprawnień do zapisu do pliku haseł. Spójrzmy jednak na kod. Pierwsze, co można
zauważyć, to fakt, że program z ustawionym bitem SETUID w ogóle nie próbuje zapisywać do pliku
haseł, a jedynie chce zapisać informacje do pliku o nazwie my_document w bieżącym katalogu
roboczym. Choć użytkownik może mieć ten plik w swoim katalogu roboczym, to nie znaczy, że
ma uprawnienia do zapisu do tego pliku. Plik może być dowiązaniem symbolicznym do innego
pliku, który w ogóle nie należy do użytkownika — np. do pliku haseł.
Aby temu zapobiec, program wykonuje sprawdzenie, by się upewnić, czy użytkownik ma
dostęp do zapisu do pliku. Do tego celu wykorzystywane jest wywołanie systemowe access.
Wywołanie sprawdza plik (tzn. jeśli jest to dowiązanie symboliczne, program sprawdzi referencję),
a następnie zwraca 0, jeśli żądany dostęp jest możliwy, oraz wartość kodu błędu równą –1 w prze-
ciwnym wypadku. Ponadto sprawdzenie jest przeprowadzane z wykorzystaniem rzeczywistego
identyfikatora UID procesu wywołującego zamiast efektywnego identyfikatora UID (inaczej pro-
ces z ustawionym bitem SETUID zawsze miałby dostęp). Tylko jeśli test zakończy się pomyślnie,
program otworzy plik i zapisze do niego dane wejściowe.
Program sprawia wrażenie bezpiecznego, ale tak nie jest. Problem polega na tym, że czas
sprawdzania uprawnień oraz czas, w którym te uprawnienia są wykorzystywane, nie są tym
samym. Załóżmy, że ułamek sekundy po sprawdzeniu dostępu za pomocą funkcji access napast-
nikowi udało się stworzyć dowiązanie symboliczne o takiej samej nazwie pliku do pliku haseł.
W takim przypadku funkcja open otworzy niewłaściwy plik i dokona zapisu danych dostarczonych
przez napastnika w pliku haseł. Aby to zrobić, napastnik musi wygrać wyścig z programem, aby
stworzyć dowiązanie symboliczne dokładnie we właściwym momencie.
Atak nosi nazwę TOCTOU (ang. Time of Check to Time of Use). Jeśli inaczej spojrzymy na
ten konkretny atak, zaobserwujemy, że wywołanie systemowe access po prostu nie jest bez-
pieczne. Byłoby znacznie lepiej najpierw otworzyć plik, a następnie sprawdzić uprawnienia przy
użyciu deskryptora pliku — za pomocą funkcji fstat. Deskryptory plików są bezpieczne, ponie-
waż nie mogą być zmieniane przez osobę atakującą pomiędzy wywołaniami fstat i write. To
pokazuje, że projektowanie dobrego API systemu operacyjnego jest bardzo ważne i dość trudne.
W tym przypadku projektanci popełnili błąd.
Istnieje cała kategoria działań, które można by określić mianem „roboty od wewnątrz” (ang.
inside jobs). Tego rodzaju działania są podejmowane przez programistów i innych pracowników
przedsiębiorstwa korzystających z komputerów wymagających ochrony lub tworzących oprogra-
mowanie kluczowe dla funkcjonowania tej firmy. Ataki tego typu różnią się od ataków z zewnątrz,
ponieważ pracownicy organizacji dysponują wyspecjalizowaną wiedzą i dostępem do zasobów,
które nie są dostępne dla ludzi z zewnątrz. W poniższych punktach opisano pięć przykładów —
każdy z tych scenariuszy wielokrotnie miał miejsce w przeszłości. Każdy z opisywanych przy-
kładów jest nieco inny — mamy do czynienia z różnymi sprawcami ataku, z różnymi ofiarami
ataku i różnymi celami stawianymi sobie przez stronę atakującą.
sta może np. dodać do programu odpowiedzialnego za logowanie kod umożliwiający logowanie
każdemu, kto użyje nazwy użytkownika zzzzz (niezależnie od hasła). Normalny kod programu
logującego może wyglądać tak jak fragment z listingu 9.5(a); programista mógłby ten kod zastą-
pić wersją pokazaną na listingu 9.5(b).
Wywołanie funkcji strcmp ma na celu sprawdzenie, czy w roli nazwy użytkownika nie użyto
łańcucha "zzzzz". Jeśli tak, próba logowania jest akceptowana niezależnie od użytego hasła. Gdyby
programiście udało się wprowadzić kod tylnych drzwi w tej formie do systemu instalowanego
na komputerach przez ich producenta, programista mógłby logować się na każdym kompute-
rze tego producenta niezależnie od tego, kto byłby ich właścicielem i co zawierałby plik haseł.
Podobne rozwiązanie mógłby zastosować programista zatrudniony w firmie wytwarzającej sys-
temy operacyjne. Jego tylne drzwi omijałyby cały proces uwierzytelniania użytkowników.
Jednym ze sposobów zabezpieczania się przed ryzykiem wprowadzania do budowanego opro-
gramowania tylnych drzwi jest przeprowadzanie regularnych przeglądów kodu (ang. code reviews).
Kiedy programista kończy pisanie i testowanie swojego modułu, umieszcza gotowy kompo-
nent w bazie (repozytorium) kodu źródłowego. Co jakiś czas wszyscy członkowie zespołu zbie-
rają się, aby każdy z programistów mógł wyjaśnić współpracownikom, wiersz po wierszu, jak
działa jego kod. Takie spotkania nie tylko zwiększają szanse wykrycia tylnych drzwi już wpro-
wadzonych do systemu, ale też zniechęcają programistów do podejmowania tego rodzaju prób
w obawie przed wykryciem i fatalnymi konsekwencjami dla kariery. Jeśli programiści niechęt-
nie odnoszą się do pomysłu regularnego dokonywania przeglądów, warto rozważyć wdrożenie
praktyki wzajemnej weryfikacji kodu przez pary współpracowników.
Rysunek 9.20. (a) Prawidłowy ekran logowania; (b) fałszywy ekran logowania
nowy użytkownik siada przed takim komputerem, odruchowo wpisuje swoją nazwę, a system
pyta go o hasło. Jeśli obie informacje uwierzytelniające są prawidłowe, system uruchamia powłokę
(lub graficzny interfejs użytkownika).
Przeanalizujmy teraz następujący scenariusz. Wyobraźmy sobie, że podstępny użytkownik
o imieniu Marek napisał program wyświetlający obraz widoczny na rysunku 9.20(b). Obraz gene-
rowany przez ten program wygląda zadziwiająco podobnie do ekranu z rysunku 9.20(a), tyle że
nie jest to prawdziwy program odpowiedzialny za logowanie w systemie, tylko fałszywy ekran
logowania przygotowany przez Marka. Marek uruchamia swój program i odchodzi od komputera,
by z bezpiecznej odległości obserwować zachowania swoich ofiar. Kiedy użytkownik siada przed
tym samym komputerem i wpisuje swoją nazwę, program Marka prosi o wpisanie hasła (podob-
nie jak właściwy ekran logowania wyłącza wyświetlanie wpisywanych znaków). Wpisana nazwa
użytkownika i hasło są zapisywane w pliku, a fałszywy program logujący wysyła do systemu
sygnał wymuszający zabicie swojej powłoki. Zabicie powłoki wymusza natychmiastowe wylo-
gowanie Marka i uruchomienie prawdziwego programu logującego, który z kolei wyświetla ekran
z rysunku 9.20(a). Użytkownik zakłada, że próba logowania została odrzucona wskutek błędnego
wpisania hasła, i próbuje zalogować się ponownie. Tym razem próba logowania jest akcepto-
wana. Tymczasem Marek uzyskuje wpisaną na początku parę nazwy użytkownika i hasła. Gdyby
Marek zalogował się na wielu komputerach i uruchomił tam swój program podszywający się
pod ekran logowania, mógłby w stosunkowo krótkim czasie zgromadzić całkiem sporo haseł.
Jedynym skutecznym sposobem zapobiegania tego rodzaju działaniom jest rozpoczynanie
sekwencji logowania od kombinacji klawiszy niemożliwej do przechwycenia przez programy
użytkowników. W systemach Windows stosuje się w tej roli kombinację Ctrl+Alt+Del. Jeśli
każdy użytkownik rozpoczyna swoją pracę z komputerem od naciśnięcia kombinacji klawiszy
Ctrl+Alt+Del, bieżący użytkownik jest automatycznie wylogowywany, a system uruchamia
program logujący. Tego mechanizmu nie można w żaden sposób ominąć.
W zamierzchłych czasach (czyli przed rokiem 2000) znudzone (ale inteligentne) nastolatki wypeł-
niały swój wolny czas pisaniem złośliwego oprogramowania, które było następnie rozsyłane
w świat z myślą o spowodowaniu możliwie wielu szkód. Okazało się, że oprogramowanie tego
typu, a więc konie trojańskie, wirusy i robaki, które określa się zbiorczym mianem złośliwego
oprogramowania (ang. malware), rozprzestrzeniało się na całym świecie niezwykle szybko.
Autorzy tego oprogramowania czuli się niezwykle docenieni, czytając raporty o milionach dola-
rów strat powodowanych przez ich „dzieła” i o niezliczonych użytkownikach, którzy wskutek dzia-
łania złośliwego oprogramowania utracili cenne dane. Z ich perspektywy pisanie tego oprogra-
mowania było jak dobry żart, co najwyżej wybryk; w końcu nie czerpali z tej działalności żadnych
korzyści materialnych.
Tę sytuację mamy dawno za sobą. Złośliwe oprogramowanie jest teraz pisane na zamówienie
doskonale zorganizowanych grup przestępczych, które z natury rzeczy nie są zainteresowane
chwaleniem się swoimi osiągnięciami w prasie i które koncentrują się wyłącznie na zdobywaniu
pieniędzy. Znaczna część współczesnego złośliwego oprogramowania jest od początku projekto-
wana z myślą o możliwie szybkim rozprzestrzenianiu przez internet i zainfekowaniu jak najwięk-
szej liczby komputerów. Oprogramowanie instalowane na infekowanych komputerach wysyła
raporty o ich adresach na komputery należące do twórców złośliwego oprogramowania. Na
atakowanych komputerach instaluje się też tylne drzwi, aby umożliwić kryminalistom, którzy
stworzyli to złośliwe oprogramowanie, wykorzystywanie danego komputera do swoich celów.
Komputer, nad którym przejęto kontrolę w ten sposób, określa się mianem zombie, a zbiór
zainfekowanych komputerów nazywa się siecią botnet (od robot network).
Kryminalista kontrolujący sieć botnet może spróbować ją wykorzystać do realizacji najróż-
niejszych niecnych (ale zawsze powodowanych przez chciwość) zadań. Typowym rozwiąza-
niem jest rozsyłanie komercyjnego spamu. Kiedy policja próbuje zlokalizować źródła dużych
ataków tego typu, zwykle odkrywa, że sprawcami są tysiące komputerów rozsiane po całym
świecie. Kiedy próbują skontaktować się z właścicielami tych komputerów, spotykają dzieci,
małe firmy, gospodynie domowe, babcie i mnóstwo innych ludzi, którzy stanowczo zaprzeczają,
jakoby byli masowymi spamerami. Wykorzystywanie cudzych komputerów do wykonywania brud-
nej roboty znacznie utrudnia identyfikację właściwych sprawców tego rodzaju przestępstw.
Raz zainstalowane złośliwe oprogramowanie może zostać wykorzystane także do innych
działań przestępczych. Jednym z możliwych działań z wykorzystaniem tego rodzaju rozwiązań
jest szantaż. Wyobraźmy sobie oprogramowanie szyfrujące wszystkie pliki na dysku twardym
ofiary, po czym wyświetlające następujący komunikat:
POZDROWIENIA OD MECHANIZMU SZYFRUJĄCEGO!
ABY KUPIĆ KLUCZ DESZYFRUJĄCY DANE NA TWOIM DYSKU TWARDYM, WYŚLIJ
PROSZĘ SKROMNE 100 DOLARÓW AMERYKAŃSKICH W NISKICH, NIEOZNACZONYCH
NOMINAŁACH NA ADRES BOX 2154, PANAMA CITY, PANAMA. DZIĘKUJĘ. INTERESY
Z TOBĄ TO CZYSTA PRZYJEMNOŚĆ.
klienta i delikatnie pyta o podejrzaną transakcję. Przestępcy oczywiście doskonale wiedzą o ist-
nieniu tego oprogramowania i próbują tak dostosowywać sposoby wydawania pieniędzy okra-
dzionej osoby, aby ich działania jak najdłużej pozostawały niewykryte.
Dane zgromadzone przez monitor użycia klawiatury można zestawić z danymi zarejestrowa-
nymi przez inne oprogramowanie zainstalowane na komputerze-zombie, aby jeszcze skutecz-
niej wykraść tożsamość ofiary. Tego rodzaju przestępstwa mają na celu zgromadzenie informacji
(daty urodzenia, numeru PESEL, numerów kont bankowych, hasła itp.) niezbędnych do skutecz-
nego podszywania się pod ofiarę i uzyskania nowych, fizycznych dokumentów, jak nowe prawo
jazdy, karta debetowa, akt urodzenia itp. Uzyskane w ten sposób dokumenty można z kolei sprze-
dać innym przestępcom planującym dalsze działania.
Inną formą przestępstw z wykorzystaniem złośliwego oprogramowania jest oczekiwanie
w ukryciu do momentu, w którym użytkownik prawidłowo zaloguje się na swoje konto banko-
wości elektronicznej. Przestępcy sprawdzają wówczas sumę pieniędzy przechowywaną na tym
koncie i niezwłocznie przelewają całą tę kwotę na własne konto, z którego natychmiast przesy-
łają pieniądze na inne konto, potem jeszcze na inne i kolejne (zwykle w skorumpowanych krajach),
aby policja potrzebowała dni lub tygodni na samo uzyskanie nakazów sądowych potrzebnych
do wyśledzenia drogi przebytej przez skradzione pieniądze (zdarza się, że nawet te nakazy nie
są respektowane przez banki w niektórych krajach). Tego rodzaju przestępstwa są realizowane
przez wielkie, doskonale zorganizowane grupy; złośliwe oprogramowanie nie jest już domeną
znudzonych nastolatków.
Oprócz opisanych powyżej zastosowań w świecie zorganizowanej przestępczości złośliwe
oprogramowanie jest wykorzystywane także przez legalnie działające przedsiębiorstwa. Nie-
które firmy decydują się na wprowadzanie złośliwego oprogramowania do systemów kompute-
rowych konkurencyjnych fabryk, aby sprawdzić stosowane tam zabezpieczenia i możliwość
działania w czasie, gdy nie jest zalogowany żaden administrator systemowy. Jeśli okaże się, że
program może działać niepostrzeżenie, złośliwe oprogramowanie można wykorzystać do kompli-
kowania procesu produkcji, ograniczania jakości produktów lub powodowania innych utrudnień
u konkurenta. W pozostałych przypadkach złośliwe oprogramowanie nie podejmuje żadnych dzia-
łań, co znacznie utrudnia jego wykrycie.
Innym przykładem złośliwego oprogramowania tworzonego z myślą o konkretnych zasto-
sowaniach jest program pisany przez ambitnego wiceprezesa korporacji i umieszczany w sieci
LAN. Taki wirus może sprawdzać, czy działa na zainfekowanym komputerze prezesa i — jeśli
tak — odnajdywać arkusze kalkulacyjne oraz wymieniać dwie losowe komórki. Prędzej czy póź-
niej prezes zacznie podejmować błędne decyzje na podstawie swoich arkuszy kalkulacyjnych,
co z czasem doprowadzi do jego zwolnienia — nietrudno się domyślić, kto zajmie jego pozycję.
Niektórzy całe dnie chodzą z chipem na ramieniu (nie mylić z ludźmi z chipem systemu RFID
w ramieniu). Ludzie z tej grupy żyją w poczuciu wyimaginowanej lub uprawnionej krzywdy i chcą
za wszelką cenę wyrównać rachunki ze znienawidzonym światem. Może im w tym pomóc złośliwe
oprogramowanie. Wiele współczesnych komputerów przechowuje system BIOS w pamięci flash,
którą można nadpisać programowo (takie rozwiązanie umożliwia producentom płyt głównych
efektywną dystrybucję niezbędnych poprawek). Oznacza to, że także złośliwe oprogramowanie
może umieszczać w tej pamięci flash przypadkowe dane, aby uniemożliwić uruchamianie danego
komputera. Jeśli chip pamięci flash został umieszczony w specjalnym gnieździe, usunięcie pro-
blemu może wymagać otwarcia komputera i wymiany tego chipu. Jeśli jednak chip jest przy-
lutowany do płyty głównej, jej właściciel prawdopodobnie będzie zmuszony wyrzucić tę płytę
i kupić nową.
czynności, do których zostało zaprojektowane, czyli usunąć, zmodyfikować lub zaszyfrować pliki
na dysku twardym. Może też podjąć próbę uzyskania numerów kart kredytowych, haseł i innych
przydatnych danych, które będzie można wysłać autorowi złośliwego oprogramowania za pośred-
nictwem internetu. Oprogramowanie tego typu często nasłuchuje na wybranym porcie IP w ocze-
kiwaniu na dalsze dyrektywy — takie rozwiązanie zmienia zaatakowany komputer w zombie
gotowy do rozsyłania spamu lub wykonywania dowolnych innych zadań w imieniu swojego mistrza.
Złośliwe oprogramowanie często wywołuje też polecenia niezbędne do swojego uruchamiania
przy okazji każdego ponownego uruchomienia zaatakowanego komputera (wszystkie współczesne
systemy operacyjne oferują mechanizmy automatycznego uruchamiania wskazanych aplikacji).
Największą zaletą ataków z użyciem koni trojańskich jest brak konieczności włamywania
się do komputerów ofiar przez autorów tego rodzaju pułapek. Wszystkie niezbędne działania
wykonuje sama ofiara.
Istnieją też inne sposoby przekonywania ofiar do wykonywania programów pełniących funk-
cję koni trojańskich. Wielu użytkowników systemów operacyjnych UNIX korzysta ze zmiennej
środowiskowej $PATH identyfikującej katalogi przeszukiwane pod kątem zawierania wydawa-
nych poleceń. Zawartość tej zmiennej można uzyskać w efekcie wpisania w powłoce następują-
cego polecenia:
echo $PATH
Potencjalne ustawienia dla użytkownika ast w pewnym systemie mogą się składać z następu-
jących katalogów:
:/usr/ast/bin:/usr/local/bin:/usr/bin:/bin:/usr/bin/X11:/usr/ucb:/usr/man\
:/usr/java/bin:/usr/java/lib:/usr/local/man:/usr/openwin/man
powłoka w pierwszej kolejności sprawdza, czy dany program występuje w katalogu /usr/ast/bin/prog.
Jeśli tak, program jest wykonywany. Jeśli jednak nie uda się znaleźć tego programu, powłoka
próbuje przeszukać kolejno katalogi /usr/local/bin/prog, /usr/bin/prog, /bin/prog itd., aby poddać
się dopiero po sprawdzeniu wszystkich dziesięciu katalogów. Przypuśćmy, że tylko jeden z tych
katalogów pozostawiono bez odpowiednich zabezpieczeń i że kraker właśnie w nim umieścił
swój program. Jeśli będzie to pierwsze wystąpienie tego programu w katalogach z listy, koń tro-
jański zostanie nieświadomie uruchomiony przez samego użytkownika.
Większość popularnych programów jest składowana w katalogach /bin lub /usr/bin, zatem próba
umieszczenia konia trojańskiego z nazwą często stosowanego programu w katalogu /usr/bin/X11/ls
nie przyniesie spodziewanego efektu, ponieważ prawdziwy program zostanie znaleziony jako
pierwszy. Załóżmy jednak, że kraker skopiował plik la do katalogu /usr/bin/X11. Jeśli użytkow-
nik przypadkowo wpisze polecenie la, zamiast ls (wyświetlającego zawartość katalogów), zosta-
nie uruchomiony odpowiedni koń trojański, który wykona swoje niecne zadania, po czym wyświe-
tli prawidłowy komunikat o nieistniejącym pliku la. Umieszczenie koni trojańskich w złożonych,
rzadko odwiedzanych katalogach, do których niemal nikt nigdy nie zagląda, i nadanie im nazw
reprezentujących typowe literówki znacznie zwiększy prawdopodobieństwo ich przypadkowego
uruchomienia. Być może tym, kto je uruchomi, będzie superużytkownik (także superużytkownicy
popełniają błędy, pisząc na klawiaturze) — koń trojański zyska wówczas możliwość zastąpienia
pliku /bin/ls wersją zawierającą złośliwy kod, który od tej pory będzie wykonywany dużo częściej.
Mal, czyli nasz złośliwy, ale legalny użytkownik danego systemu (i autor konia trojańskiego),
może też zastawić swoistą pułapkę na superużytkownika, stosując następujący schemat. Wystar-
czy umieścić wersję pliku ls zawierającą konia trojańskiego we własnym katalogu, po czym
sprawić, że w systemie stanie się coś na tyle podejrzanego, aby zwrócić uwagę superużytkow-
nika (np. zostanie uruchomionych sto procesów niemal w całości obciążających procesor). Naj-
prawdopodobniej superużytkownik wykona wówczas następujące polecenia, aby sprawdzić, co
znajduje się w katalogu domowym Mala:
cd /home/mal
ls –l
Ponieważ niektóre powłoki odwołują się do katalogu lokalnego przed przystąpieniem do prze-
szukania katalogów wskazanych w zmiennej $PATH, może się okazać, że zaniepokojony superu-
żytkownik właśnie uruchomił konia trojańskiego Mala — co więcej, zrobił to z uprawnieniami
superużytkownika. Tak uruchomione złośliwe oprogramowanie może wówczas ustawić bit
SETUID superużytkownika dla pliku /home/mal/bin/sh. Wystarczy użyć dwóch wywołań sys-
temowych: chown (aby ustawić superużytkownika jako właściciela pliku /home/mal/bin/sh) oraz
chmod (aby ustawić bit SETUID tego pliku). Od tej pory Mal może w dowolnej chwili zostać
superużytkownikiem — wystarczy, że uruchomi powłokę.
Jeśli Mal zbyt często boryka się z brakiem gotówki, może wykorzystać jeden z opisanych
poniżej schematów użycia koni trojańskich, aby poprawić swoją płynność. Koń trojański może
np. sprawdzić, czy ofiara korzysta z usługi dostępu do konta bankowego za pośrednictwem inter-
netu. Jeśli tak, może wymusić na tym programie przelanie pewnej kwoty z konta ofiary na spe-
cjalnie założone konto krakera (prawdopodobnie w jakimś odległym kraju), aby w przyszłości
podjąć próbę odbioru gotówki. Na podobnej zasadzie, jeśli koń trojański działa na telefonie komór-
kowym (smartfonie lub standardowym), może wysłać bardzo drogie wiadomości SMS (często
w odległym kraju — np. w Mołdawii na terenie byłego Związku Radzieckiego).
9.9.2. Wirusy
W tym punkcie szczegółowo omówimy wirusy; w kolejnym punkcie zajmiemy się robakami.
Oczywiście także w internecie aż roi się od informacji o wirusach, zatem można przyjąć, że dżinn
opuścił już zaczarowaną lampę. Zakładamy też, że obrona przed wirusami jest tym trudniejsza,
im mniej o nich wiemy. I wreszcie istnieje mnóstwo nieporozumień związanych z funkcjonowa-
niem i rozprzestrzenianiem się wirusów, które warto skorygować.
Czym właściwie jest wirus? W największym uproszczeniu wirus jest programem zdolnym
do samodzielnej reprodukcji poprzez dołączanie swojego kodu do innych programów (analo-
gicznie jak rozprzestrzeniające się wirusy biologiczne). Działania wirusa oczywiście nie ogra-
niczają się do samej reprodukcji. Robaki są podobne do wirusów, ale same się replikują. W tym
punkcie nie będziemy omawiać różnic dzielących wirusy od robaków — na razie zastosujemy
termin „wirus” w kontekście obu zjawisk. Samymi robakami zajmiemy się w punkcie 9.9.3.
Wirusy towarzyszące
Wirus towarzyszący (ang. companion virus) nie infekuje programu, a jedynie jest uruchamiany
w momencie, w którym użytkownik oczekuje uruchomienia właściwego programu. Wirusy tego
rodzaju są bardzo stare. Sięgają czasów, gdy światem komputerów rządził system MS-DOS.
Pomimo to wciąż istnieją. Najłatwiej wyjaśnić istotę działania tego rodzaju wirusów na przykła-
dzie. Kiedy użytkownik systemu MS-DOS wpisuje następujące polecenie:
prog
system szuka pliku wykonywalnego nazwanego prog.com. Jeśli nie uda się znaleźć tego pliku,
zostaje podjęta próba odnalezienia programu nazwanego prog.exe. Podobny mechanizm jest
stosowany po tym, jak użytkownik systemu operacyjnego Windows klika menu Start i polece-
nie Uruchom. Obecnie zdecydowana większość programów ma rozszerzenie .exe; rozszerzenie
.com jest wyjątkowo rzadkie.
Przypuśćmy, że Wirgiliusz wie o uruchamianiu przez wielu użytkowników pliku wykony-
walnego prog.exe z poziomu wiersza poleceń systemu MS-DOS lub za pośrednictwem polece-
nia Uruchom (dostępnego w menu Start) systemu Windows. Może wówczas wprowadzić do
systemu wirusa umieszczonego w pliku prog.com, który będzie uruchamiany za każdym razem,
gdy ktoś spróbuje wpisać polecenie prog (chyba że użyje pełnej nazwy prog.exe). Po zakończeniu
pracy program prog.com może po prostu uruchomić program prog.exe, aby użytkownik nie odkrył
próby ataku.
Podobny przebieg mają ataki z wykorzystaniem pulpitu systemu operacyjnego Windows,
na którym często składuje się skróty (dowiązania symboliczne) do programów. Wirus może
łatwo zmienić program wskazywany przez takie łącze, zastępując oryginalną aplikację wiru-
sem. Kiedy użytkownik dwukrotnie klika ikonę na pulpicie, jest wykonywany wirus, który po
zakończeniu swoich zadań uruchamia oryginalny program docelowy, aby nie wzbudzać podejrzeń.
search(char *dir_name)
{ /* rekurencyjnie poszukuje plików wykonywalnych */
DIR *dirp; /* wskaźnik do strumienia otwartego katalogu */
struct dirent *dp; /* wskaźnik do elementu katalogu */
związanych z katalogami . oraz ... Opisany algorytm pomija też dowiązania symboliczne, ponie-
waż zakładamy, że nasz program może wejść do katalogu za pomocą wywołania systemowego
chdir i wrócić do poprzedniego katalogu, korzystając z katalogu .. (czyli dowiązań twardych).
Można by oczywiście zastosować bardziej zaawansowany program uwzględniający także dowią-
zania symboliczne.
Działanie właściwej procedury infekującej infect (której kodu nie pokazano) sprowadza się
do otwarcia pliku wskazanego za pośrednictwem jej parametru, nadpisania go kodem wirusa zapi-
sanym we wspomnianej wcześniej tablicy i zamknięcia zmienionego pliku.
Wirus w tej formie można „udoskonalić” na wiele różnych sposobów. Po pierwsze można
by w ciele procedury infect umieścić mechanizm generowania liczb losowych i na tej podstawie
zwracający sterowanie bez podejmowania żadnych działań w zdecydowanej większości przy-
padków. Przyjmijmy, że dzięki użyciu tego rozwiązania infekcja ma miejsce w jednym na 128
przypadków, dzięki czemu można znacznie ograniczyć ryzyko wczesnego wykrycia (zanim wirus
będzie miał szansę rozmnożenia). Bardzo podobnie zachowują się wirusy biologiczne — te, które
zabijają swoje ofiary zbyt szybko, nigdy nie rozprzestrzeniają się równie szybko jak te, które
prowadzą do powolnej śmierci, stwarzając swoim ofiarom niezliczone szanse przekazania wirusa
innym osobnikom. Alternatywnym rozwiązaniem jest zastosowanie wyższego współczynnika
infekcji (np. na poziomie 25%), ale też ograniczenie łącznej liczby infekowanych plików, aby unik-
nąć podejrzanego wzrostu liczby operacji dyskowych.
Po drugie procedura infect może sprawdzać, czy dany plik nie został zainfekowany wcześniej.
Dwukrotne infekowanie tego samego pliku jest stratą czasu. Po trzecie być może warto zasto-
sować mechanizm zachowywania dotychczasowej daty ostatniej modyfikacji i rozmiaru plików,
aby zatrzeć ślady infekcji. W przypadku programów, których rozmiar przekracza rozmiar samego
wirusa, zachowanie dotychczasowego rozmiaru nie stanowi żadnego problemu; mniejsze programy
zwiększą jednak swój rozmiar wskutek infekcji. Ponieważ jednak zdecydowana większość wiru-
sów jest mniejsza od większości programów, problem rozmiaru plików występuje dość rzadko.
Zaproponowany powyżej program co prawda nie jest zbyt długi (program napisany w języku C,
którego kod mieści się na jednej stronie, jest kompilowany do pliku wykonywalnego o rozmiarze
nieprzekraczającym 2 kB), jednak wersja tego samego wirusa napisana w asemblerze byłaby
jeszcze krótsza. [Ludwig, 1998] napisał w asemblerze program systemu MS-DOS infekujący
wszystkie pliki w bieżącym katalogu i zajmujący zaledwie 44 bajty.
W dalszej części tego rozdziału omówimy działanie programów antywirusowych, czyli pro-
gramów, których zadaniem jest wykrywanie i eliminowanie wirusów. Co ciekawe, logika poka-
zana na listingu 9.6, którą nasz przykładowy wirus wykorzystuje do odnajdywania wszystkich
plików wykonywalnych (potencjalnych ofiar infekcji), mogłaby równie dobrze zostać wykorzy-
stana przez program antywirusowy do odnalezienia wszystkich zainfekowanych programów,
aby usunąć skutki działania tego wirusa. Technologie infekowania i dezynfekcji oprogramowania
są bliźniaczo podobne — właśnie dlatego warunkiem skutecznego zwalczania wirusów jest dobre
rozumienie sposobu ich działania.
Z perspektywy Wirgiliusza największą wadą wirusów nadpisujących jest to, że można je
dość łatwo wykrywać. Kiedy użytkownik uruchamia zainfekowany program, być może stworzy
wirusowi szansę dalszego rozprzestrzenienia, ale sam program nie będzie działał zgodnie
z oczekiwaniami użytkownika, co natychmiast zostanie zauważone. Właśnie dlatego większość
tego rodzaju wirusów dołącza się do programów i po wykonaniu swoich zadań pozwala tym
programom normalnie funkcjonować. Wirusy tego typu określa się mianem wirusów pasożytniczych
(ang. parasitic viruses).
Wirusy pasożytnicze mogą same dołączać się do programów wykonywalnych na ich początku,
końcu lub w środku. Jeśli wirus umieszcza swój kod na początku infekowanego programu, musi
najpierw skopiować ten program do pamięci RAM, umieścić swój kod przed tym programem,
po czym skopiować program z pamięci RAM — patrz rysunek 9.21(b). Tak zmieniony program
będzie jednak wykonywany pod nowym adresem wirtualnym, zatem wirus musi albo zmienić
położenie programu, albo przenieść go pod zerowy adres wirtualny (po zakończeniu własnego
wykonywania).
Rysunek 9.21. (a) Plik wykonywalny; (b) program z kodem wirusa umieszczonym z przodu;
c) program z kodem wirusa na końcu; (d) program z kodem wirusa rozrzuconym po wolnych obszarach
w ramach programu
Aby uniknąć utrudnień związanych z umieszczaniem kodu wirusa przed kodem infekowanego
programu, większość wirusów dopisuje swój kod na końcu programów wykonywalnych i tak zmienia
zawartość pola adresu startowego, aby wskazywał właśnie początek wirusa — patrz rysunek 9.21(c).
W takim przypadku kod wirusa jest wykonywany pod różnymi adresami wirtualnymi (w zależ-
ności od zainfekowanego programu), zatem Wirgiliusz musi się upewnić, że działanie jego
wirusa jest niezależne od pozycji — że stosuje adresy względne zamiast adresów bezwzględnych.
Napisanie wirusa w tej formie nie stanowi problemu dla doświadczonego programisty, a nie-
które kompilatory oferują możliwość generowania odpowiednich rozwiązań na żądanie.
Złożone formaty programów wykonywalnych (w tym pliki .exe stosowane w systemach
Windows oraz niemal wszystkie formaty binarne współczesnych wersji systemu UNIX) umoż-
liwiają stosowanie programów obejmujących wiele segmentów tekstu i danych. Za ich łączenie
i dynamiczne przenoszenie w pamięci odpowiada program ładujący. W niektórych systemach
(w tym w systemach z rodziny Windows) wszystkie segmenty (sekcje) są wielokrotnościami
512 bajtów. Jeśli jakiś segment nie jest pełny, program łączący dopełnia go zerami. Wirus, którego
autor rozumie działanie tego mechanizmu, może podjąć próbę ukrycia swojego kodu w tych
lukach. Jeśli uda się zmieścić kompletny kod wirusa w tych lukach, jak na rysunku 9.21(d), roz-
miar zainfekowanego pliku pozostanie niezmieniony, co jest o tyle ważne, że wirus pozostający
w ukryciu to szczęśliwy wirus. Wirusy wykorzystujące tę możliwość można więc określić
mianem wirusów wnękowych (ang. cavity viruses). Jeśli program ładujący nie kopiuje zawartości
tych luk do pamięci, wirus musi znaleźć inny sposób uruchomienia swojego kodu.
operacyjnej komputera, ukrywając się albo na jej szczycie, albo gdzieś blisko samego dnia, wśród
wektorów przerwań, w ostatnich kilkuset bajtach, które zwykle nie są wykorzystywane. Naj-
lepsze wirusy mogą nawet podejmować próby zmodyfikowania bitmapy pamięci RAM systemu
operacyjnego, aby system „myślał”, że pamięć zajmowana przez wirus jest w istocie zajmowana
przez zupełnie inne segmenty i — tym samym — uniknąć przypadkowego nadpisania.
Typowy wirus rezydujący w pamięci wykorzystuje jedną z popularnych pułapek lub wekto-
rów przerwań do skopiowania swojej zawartości do niezabezpieczonej zmiennej, aby umiesz-
czony tam adres skierował sterowanie właśnie do wirusa. Z reguły najskuteczniejsze są pułapki
zastawiane na wywołania systemowe, ponieważ umożliwiają wykonywanie kodu wirusa (w trybie
jądra) przy okazji każdego wywołania. Po wykonaniu tego kodu następuje skok do właściwego
wywołania systemowego (z wykorzystaniem zapisanego wcześniej adresu).
Po co komukolwiek wirus wykonywany przy okazji każdego wywołania systemowego? Oczy-
wiście po to, by infekować programy. Taki wirus może np. oczekiwać w ukryciu na użycie wywo-
łania systemowego exec, po czym zainfekować wskazany plik, korzystając z wiedzy, że chodzi
o wykonywalny plik binarny (prawdopodobnie przydatny z perspektywy danego użytkownika).
Proces infekcji w tej formie nie wymaga tak dużej liczby operacji dyskowych jak technika poka-
zana na listingu 9.6, zatem działanie tego wirusa nie będzie budziło tak dużych podejrzeń. Prze-
chwytywanie wszystkich wywołań systemowych stwarza też niemal nieograniczone możliwości
zarówno w zakresie gromadzenia danych, jak i w kwestii utrudniania pracy oprogramowania.
Podczas uruchamiania komputera wirus kopiuje swój kod do pamięci operacyjnej RAM (na
jej szczyt lub do dolnej części, pomiędzy nieużywane wektory przerwań). Na tym etapie kom-
puter pracuje w trybie jądra, w warunkach wyłączonego układu zarządzania pamięcią (ang.
Memory Management Unit — MMU), bez uruchomionego systemu operacyjnego czy programu
antywirusowego. To wprost doskonały czas dla wirusów. Kiedy wirus osiągnie swoje cele, może
uruchomić system operacyjny — zwykle pozostaje wówczas w pamięci operacyjnej, aby z tej
pozycji doglądać swoich spraw.
Jednym z najtrudniejszych problemów autorów tego rodzaju wirusów jest znalezienie spo-
sobu ponownego przejęcia kontroli nad zainfekowanym systemem. Bodaj najbardziej popularne
rozwiązanie to wykorzystanie specjalistycznej wiedzy o sposobie zarządzania przez dany sys-
tem operacyjny wektorami przerwań. Przykładowo system operacyjny Windows nie nadpisuje
wszystkich wektorów przerwań jednocześnie — ładuje sterowniki urządzeń pojedynczo, a każdy
z nich uzyskuje dostęp do właściwego wektora przerwań. Ten proces może zająć nawet minutę.
Opisany projekt systemu operacyjnego stwarza wirusom wprost niepowtarzalną szansę.
Wirus rozpoczyna działanie od przejęcia wszystkich wektorów przerwań — patrz rysunek 9.22(a).
W czasie ładowania właściwych sterowników niektóre z tych wektorów są nadpisywane, ale (o ile
sterownik zegara nie zostanie załadowany jako pierwszy) wirus będzie dysponował jeszcze wie-
loma przerwaniami zegara. Na rysunku 9.22(b) pokazano scenariusz, w którym wirus traci prze-
rwanie drukarki. Kiedy kod wirusa odkrywa, że jeden z jego wektorów przerwań został nadpi-
sany, może nadpisać ten wektor ponownie ze „świadomością” bezpieczeństwa tego wektora
(w rzeczywistości niektóre wektory przerwań są nadpisywane wielokrotnie podczas urucha-
miania systemu, jednak Wirgiliusz ma wrażenie, że schemat tego nadpisywania jest powta-
rzalny). Efekt ponownego przejęcia wektora przerwań drukarki pokazano na rysunku 9.22(c).
Kiedy system zostanie ostatecznie załadowany, wirus przywróci wszystkie wektory przerwań
i zachowa dla siebie tylko pułapkę wywołań systemowych. Od tej pory mamy do czynienia z wiru-
sem rezydującym w pamięci operacyjnej, który ma kontrolę nad wywołaniami systemowymi.
Właśnie w ten sposób uruchamia się większość wirusów rezydujących w pamięci.
Rysunek 9.22. (a) Sytuacja po przechwyceniu przez wirus wszystkich wektorów przerwań i pułapek
wywołań systemowych; (b) sytuacja po odzyskaniu przez system operacyjny wskaźnika do wektora
przerwań drukarki; (c) sytuacja po odkryciu przez wirus utraty wektora przerwań drukarki i ponownym
przechwyceniu tego wektora
Wirusy w makrach
Wiele programów, w tym Word i Excel, umożliwiają użytkownikom pisanie makr grupujących
wiele poleceń, które można następnie wykonywać zaledwie jednym naciśnięciem kombinacji
klawiszy. Makra można też kojarzyć z elementami menu — w takim przypadku do wykonania
makra wystarczy kliknięcie odpowiedniej opcji. Makra pakietu biurowego Microsoft Office mogą
obejmować całe programy pisane w pełnoprawnym języku programowania Visual Basic. Makra
nie są kompilowane, tylko interpretowane, co jednak wpływa tylko na szybkość ich wykonywania,
nie ich funkcjonalność. Ponieważ makra można kojarzyć z konkretnymi dokumentami, pakiet
Office zapisuje je w ramach tych dokumentów.
Okazuje się, że makra mogą też powodować poważne problemy. Przyjmijmy, że Wirgiliusz
pisze dokument Worda i tworzy makro związane z funkcją otwierania pliku. Jego makro zawiera
tzw. wirus makra (ang. macro virus). Wyobraźmy sobie, że Wirgiliusz rozsyła ten dokument do
swoich ofiar za pośrednictwem poczty elektronicznej i że odbiorcy tych wiadomości otwierają
otrzymany dokument (przy założeniu, że nie zrobi tego za nich ich program obsługujący pocztę
elektroniczną). Otwarcie dokumentu powoduje wykonanie makra skojarzonego z funkcją
otwierania pliku. Ponieważ każde makro może zawierać dowolny program podejmujący dowolne
działania, polegające np. na zainfekowaniu pozostałych dokumentów Worda, usunięciu plików
itp. Warto przy tej okazji oddać sprawiedliwość firmie Microsoft — program Word każdorazowo
ostrzega użytkownika o istnieniu makr w otwieranym dokumencie, jednak większość użytkow-
ników nie rozumie znaczenia tego ostrzeżenia i otwiera dokument z włączoną obsługą makr.
Co więcej, dokumenty z natury rzeczy mogą zawierać przydatne, nieszkodliwe makra. Istnieje też
wiele programów, które nawet nie wyświetlają tego rodzaju ostrzeżeń, co dodatkowo utrudnia
wykrywanie wirusów zawartych w makrach.
Rosnąca popularność poczty elektronicznej jako medium przesyłania danych (w formie załącz-
ników) powoduje, że wysyłanie wirusów umieszczonych w makrach stało się poważnym zagro-
żeniem dla bezpieczeństwa systemów komputerowych. Pisanie tego rodzaju wirusów jest niepo-
równanie prostsze od ukrywania prawdziwego sektora startowego gdzieś na liście uszkodzonych
bloków dyskowych, ukrywania złośliwego kodu wśród wektorów przerwań czy przechwyty-
wania wektora pułapek wywołań systemowych. Oznacza to, że wirusy mogą obecnie pisać dużo
mniej doświadczeni programiści, co znacznie obniża jakość tego produktu i pozbawia prestiżu
twórców tradycyjnych, zaawansowanych wirusów.
na początku każdego pliku z kodem źródłowym języka C. Wirus w tej formie powinien wstawić
jeszcze wiersz aktywujący:
run_virus();
w celu uaktywnienia wirusa. Wybór właściwego miejsca dla tego wiersza wymaga przeanali-
zowania struktury infekowanego kodu języka C, ponieważ przytoczony wiersz musi się znaleźć
w miejscu, w którym wywołanie procedury jest dopuszczalne składniowo i które rzeczywiście
jest wykonywane (nie może to być martwy kod np. za wyrażeniem return). Także umieszczenie
wywołania w środku komentarza nie przyniesie zamierzonego skutku, a występowanie wywoła-
nia w ciele pętli mogłoby niepotrzebnie doprowadzić do zbyt częstego podejmowania prób infekcji.
Przyjmijmy jednak, że wirus Wirgiliusza potrafi umieszczać wywołanie procedury run_virus
we właściwym miejscu (np. na końcu procedury main, ale przed ewentualnym wyrażeniem return).
Po skompilowaniu tak zmienionego kodu program będzie zawierał wirus z kodem zawartym
w pliku nagłówkowym virus.h (w praktyce należałoby użyć nazwy proj.h lub innej, która nie
zwracałaby na siebie takiej uwagi jak virus.h).
Kiedy użytkownik uruchomi program, zostanie wywołany wirus. Wirus w tej formie może
podjąć dowolne działania, np. odnaleźć pozostałe programy języka C i podjąć próbę ich zainfe-
kowania. Jeśli odnajdzie plik z kodem źródłowym, będzie mógł dopisać do niego dwa przyto-
czone powyżej wiersze, jednak ten schemat infekcji zda egzamin tylko na komputerze lokalnym,
na którym zainstalowano wcześniej plik virus.h. Warunkiem skutecznej infekcji na komputerze
zdalnym jest dołączenie całego kodu źródłowego samego wirusa. Można ten kod włączyć do
infekowanego pliku źródłowego w formie zainicjalizowanego łańcucha znaków, najlepiej z listą
32-bitowych liczb całkowitych zapisanych w systemie szesnastkowym, aby utrudnić ocenę praw-
dziwego znaczenia tego kodu. Taki łańcuch będzie oczywiście dość długi, jednak w czasach
oprogramowania składającego się z wielu tysięcy wierszy nietrudno o przeoczenie nawet takiego
elementu.
Mniej doświadczonym Czytelnikom opisane techniki mogą wydawać się dość skomplikowane.
Zapewne część Czytelników zastanawia się, czy prezentowane rozwiązania w ogóle mogą spraw-
dzać się w praktyce. Otóż mogą. Proszę nam uwierzyć. Musimy pamiętać, że Wirgiliusz jest
doskonałym programistą dysponującym nieograniczonym czasem. Dowody skuteczności tych
technik można bez trudu odnaleźć w prasie codziennej.
Odbiorca takiej wiadomości poczty elektronicznej od razu widzi, że jej autor jest jego przyja-
cielem lub kolegą, zatem nie spodziewa się kłopotów. Po otwarciu załącznika wiadomości jest
już jednak za późno. W ten sposób działał m.in. wirus I LOVE YOU, który zainfekował komputery
na całym świecie w czerwcu 2000 roku i spowodował straty szacowane na miliard dolarów.
Oprócz problemu rozprzestrzeniania się wirusów mamy obecnie do czynienia z nie mniej
kłopotliwą kwestią wymiany technologii implementowania wirusów. Istnieją grupy twórców
wirusów, którzy aktywnie komunikują się ze sobą za pośrednictwem internetu i wzajemnie
pomagają w opracowywaniu nowych technologii, narzędzi i samych wirusów. Większość człon-
ków tych grup to hobbiści, nie przestępcy czerpiący wymierne korzyści ze swojej działalności,
jednak efekty ich współpracy mogą być katastrofalne. Odrębną kategorią twórców wirusów są
specjaliści zatrudniani przez wojsko i służby specjalne, które postrzegają wirusy jako broń mogącą
sparaliżować systemy komputerowe przeciwnika.
Innym ważnym aspektem rozprzestrzeniania się wirusów jest unikanie wykrycia. Więzie-
nia zwykle nie oferują zbyt wielu możliwości kontaktu z systemami komputerowymi, zatem
Wirgiliusz najprawdopodobniej wolałby uniknąć konieczności przymusowego pobytu w takim
miejscu. Gdyby rozesłał wiadomości z wirusem ze swojego komputera domowego, podjąłby spore
ryzyko. Nawet gdyby jego atak zakończył się powodzeniem, policja mogłaby go wyśledzić, anali-
zując wiadomości z najmłodszymi znacznikami czasowymi, czyli najbliższe faktycznego źródła
ataku.
Aby ograniczyć ryzyko identyfikacji, Wirgiliusz może udać się do kafejki internetowej w odle-
głym mieście i stamtąd rozesłać swoją wiadomość z wirusem. Może przynieść ze sobą wirusa na
pamięci USB, po czym albo odczytać go samodzielnie, albo (jeśli udostępniane komputery nie mają
portów USB) poprosić miłą, młodą panią za biurkiem o odczytanie pliku book.doc za pośred-
nictwem jej komputera. Kiedy plik znajdzie się na jego dysku twardym, będzie mógł zmienić jego
nazwę np. na virus.exe i uruchomić, zarażając wszystkie komputery w sieci LAN wirusem. Wirus
może się aktywować np. miesiąc po wizycie w kafejce, na wypadek gdyby policja poprosiła linie
lotnicze o wykaz wszystkich pasażerów odwiedzających dane miasto w tygodniu poprzedzają-
cym atak.
Alternatywnym rozwiązaniem jest rezygnacja z pamięci USB na rzecz pobrania wirusa ze
zdalnej witryny WWW lub FTP. Autor wirusa może też przynieść do kafejki własny komputer
przenośny i włożyć do portu Ethernetu przewód udostępniany specjalnie z myślą o turystach
podróżujących z laptopami (np. po to, by także w czasie urlopu codziennie sprawdzać pocztę
elektroniczną). Po połączeniu się z siecią lokalną Wirgiliusz może podjąć próbę zainfekowania
wszystkich komputerów w tej sieci.
O wirusach można by mówić bez końca. Szczególnie interesujące są techniki ich ukrywania
oraz sposoby odkrywania zagrożeń przez oprogramowanie antywirusowe. Wirusy mogą nawet
ukryć się wewnątrz żywych zwierząt — naprawdę! — opis takiej sytuacji można znaleźć w publi-
kacji [Rieback et al., 2006]. Wrócimy do tych zagadnień w dalszej części tego rozdziału, przy okazji
omawiania sposobów obrony przed złośliwym oprogramowaniem.
9.9.3. Robaki
Pierwsze poważne (w dużej skali) naruszenie bezpieczeństwa systemów komputerowych
z wykorzystaniem internetu miało miejsce 2 listopada 1988 roku, kiedy doktorant Uniwersytetu
Cornella Robert Tappan Morris umieścił w internecie program robaka. Zanim jego robak został
specjalnie przygotowany, 536-bajtowy łańcuch. Tak długi łańcuch powodował przepełnienie bufora
tego demona i nadpisywał jego stos (podobnie jak w scenariuszu pokazanym na rysunku 9.17(c)).
Morris wykorzystał więc błąd twórców demona finger, którzy nie zabezpieczyli się przed ryzy-
kiem przepełnienia bufora. Sterowanie zwracane przez procedurę demona finger odpowie-
dzialną za obsługę tego żądania nie trafiało do procedury main, tylko do procedury zawartej
w 536-bajtowym łańcuchu na stosie. Procedura podrzucona przez Morrisa próbowała wykonać
polecenie sh. W razie powodzenia tej próby robak dysponował powłoką działającą na zaatako-
wanym komputerze.
Trzecia metoda wykorzystywała błąd w systemie poczty elektronicznej sendmail, dzięki
któremu robak Morrisa mógł rozsyłać i uruchamiać kopie programu inicjującego.
Robak umieszczony i uruchomiony w systemie ofiary próbował złamać hasła użytkownika.
Morris nie poprzedził jednak tego przedsięwzięcia wyczerpującymi badaniami tego zagadnie-
nia. Ograniczył się do lektury uważanej za klasykę książki o bezpieczeństwie, której współauto-
rem był jego ojciec, specjalista od bezpieczeństwa w Narodowej Agencji Bezpieczeństwa, czyli
organizacji rządowej Stanów Zjednoczonych zajmującej się m.in. łamaniem szyfrów. Morris senior
napisał tę książkę wraz z Kenem Thompsonem blisko dekadę wcześniej, kiedy obaj pracowali
w ośrodku Bell Labs [Morris i Thompson, 1979]. Każde złamane hasło umożliwiało temu roba-
kowi zalogowanie na wszystkich komputerach, na których właściciel tego hasła miał konto.
Za każdym razem, gdy robak Morrisa uzyskiwał dostęp do nowego komputera, sprawdzał,
czy na tym komputerze nie była aktywna inna kopia tego robaka. Jeśli tak, nowa kopia tylko
w jednym przypadku na siedem kontynuowała atak, najprawdopodobniej na wypadek gdyby
administrator systemowy próbował stosować własną wersję robaka tylko po to, by wprowadzić
w błąd właściwego intruza. Właśnie przyjęcie błędnych proporcji (stosunku jeden do siedmiu)
spowodowało utworzenie zdecydowanie zbyt wielu robaków i doprowadziło do paraliżu zainfe-
kowanych komputerów. Gdyby Morris rezygnował z ataku w razie wykrycia innego robaka, jego
dzieło prawdopodobnie pozostałoby niewykryte.
Morris wpadł, kiedy jeden z jego przyjaciół przekonywał reportera z działu komputerów
„New York Timesa”, Johna Markoffa, że atak robaka jest wynikiem przypadku, że robak jest
nieszkodliwy oraz że jego autor żałuje i przeprasza za spowodowane szkody. Przyjaciel Morrisa
niechcący wspomniał, że użytkownik będący sprawcą całego zamieszania posługuje się nazwą
rtm. Identyfikacja autora robaka okazała się wówczas dziecinnie prosta — Markoff użył wspo-
mnianego już programu finger. Historia robaka Morrisa już następnego dnia trafiła na pierwsze
strony gazet i przyćmiła nawet artykuły poświęcone wyborom prezydenckim, które miały miejsce
trzy dni później.
Morris dość skutecznie bronił się przed sądem federalnym. Skazano go na grzywnę w wyso-
kości 10 tysięcy dolarów, 3 lata próby i 400 godzin robót publicznych. Koszty procesu, którymi
także obciążono Morrisa, najprawdopodobniej przekroczyły 150 tysięcy dolarów. Wyrok sądu
federalnego wzbudził sporo kontrowersji. Wielu członków społeczności zaawansowanych użyt-
kowników ówczesnych komputerów uważało, że jedyną winą tego zdolnego doktoranta było
przygotowanie żartu, nad którym stracił kontrolę. Żaden element jego robaka nie wskazywał
na próbę kradzieży lub uszkodzenia czegokolwiek. Inni uważali jednak, że Morris dopuścił się
poważnego przestępstwa, za które powinien na wiele lat trafić do więzienia. Jakiś czas później
Morris uzyskał tytuł doktora na Harvardzie, a obecnie jest profesorem Massachusetts Institute
of Technology.
Jednym z trwałych skutków tego incydentu było powstanie zespołu CERT (od ang. Computer
Emergency Response Team), czyli centralnego ośrodka raportowania o próbach ataków i jedno-
cześnie grupy ekspertów analizujących problemy w dziedzinie zabezpieczeń i proponującej
niezbędne poprawki. Chociaż ta decyzja z pewnością była krokiem naprzód, miała też negatywne
konsekwencje — zespół CERT gromadzi informacje o lukach w systemach, które można wyko-
rzystać do ataków, oraz propozycje ich eliminowania. Taki model powoduje z kolei, że infor-
macje o lukach przechodzą przez ręce tysięcy administratorów systemów w internecie. Poten-
cjalni krakerzy nierzadko pracują właśnie na stanowiskach administratorów systemów, zatem
wymiana informacji umożliwia im wykorzystanie luk w ciągu zaledwie kilku godzin (maksymal-
nie kilku dni), zanim uda się znaleźć sposób wyeliminowania zagrożenia.
Od czasu wydania robaka Morrisa na świecie pojawiło się mnóstwo innych robaków. Cechą
wspólną wszystkich tych robaków było wykorzystywanie rozmaitych błędów w innym opro-
gramowaniu. Robaki rozprzestrzeniają się dużo szybciej niż wirusy, ponieważ ich replikacja nie
wymaga udziału użytkowników — przenoszą się z komputera na komputer samodzielnie.
1
Stewart był sędzią Sądu Najwyższego Stanów Zjednoczonych, który pisząc opinię dotyczącą pornografii,
przyznał, że nie potrafi jej precyzyjnie zdefiniować, lecz dodał: „…ale już na pierwszy rzut oka potrafię ją
rozpoznać”.
opisana technologia jest zupełnie prawidłowa. W praktyce jednak technologia Microsoftu oka-
zuje się wyjątkowo niebezpieczna. Warto przy tej okazji wspomnieć, że ataki z użyciem kontrolek
ActiveX dotyczą tylko przeglądarki Internet Explorer (IE), nigdy Firefoksa, Chrome, Safari ani
innych przeglądarek.
Kiedy użytkownik odwiedza stronę z kontrolką ActiveX, możliwości ewentualnej infekcji
zależą od ustawień zabezpieczeń przeglądarki Internet Explorer. Jeśli ustawiono zbyt mało restryk-
cyjne zabezpieczenia, oprogramowanie szpiegujące jest pobierane i instalowane automatycznie.
Użytkownicy decydują się na rezygnację ze skuteczniejszych zabezpieczeń, ponieważ restrykcyjne
reguły powodują, że wiele witryn jest wyświetlanych w sposób nieprawidłowy (lub wcale) albo
przeglądarka stale pyta użytkowników o zgodę na to czy inne działanie (treść tych pytań zwykle
jest dla nich niezrozumiała).
Przypuśćmy teraz, że użytkownik zastosował dość wysokie ustawienia zabezpieczeń. Kiedy
odwiedza zainfekowaną stronę internetową, Internet Explorer wykrywa kontrolkę ActiveX
i wyświetla okno dialogowe z komunikatem dostarczonym przez samą stronę internetową. Taki
komunikat może mieć następującą treść:
Czy chcesz zainstalować i uruchomić program, który przyspieszy twoje łącze internetowe?
Większość użytkowników w takim przypadku nie widzi niczego złego w kliknięciu przyci-
sku Tak. Bardziej doświadczeni użytkownicy zapewne zapoznają się z pozostałymi elementami
tego okna dialogowego, gdzie znajdą dwie dodatkowe informacje. Jednym z tych elementów
będzie łącze do certyfikatu danej strony internetowej (zagadnienia związane z certyfikatami
omówiono w podrozdziale 9.5), wystawionego przez nieznane centrum certyfikacji — jedynym
przesłaniem tego certyfikatu jest istnienie odpowiedniego centrum i zdolność właściciela cer-
tyfikatu do poniesienia kosztów jego uzyskania. Drugim elementem jest hiperłącze do innej
strony internetowej wskazane przez stronę właśnie odwiedzaną. W założeniu dodatkowa strona
powinna wyjaśniać, do czego służy dana kontrolka ActiveX, jednak w praktyce może to być
dowolny materiał zachwalający oferowaną kontrolkę i przekonujący, jak bardzo może ona popra-
wić doznania użytkownika. Nawet doświadczeni użytkownicy wyposażeni w te informacje z reguły
klikają przycisk Tak.
Nawet jeśli użytkownik kliknie przycisk Nie, może się okazać, że skrypt na danej stronie
wykorzysta błąd w przeglądarce Internet Explorer i spróbuje pobrać oprogramowanie szpie-
gujące mimo wyrażonego sprzeciwu. Jeśli nie uda się znaleźć żadnej luki w zabezpieczeniach
Internet Explorera, skrypt może próbować w nieskończoność namawiać użytkownika do pobra-
nia kontrolki, nieustannie wyświetlając to samo okno dialogowe. Większość użytkowników nie
wie, co powinna zrobić w takim przypadku, i (zamiast zabić proces przeglądarki w menedżerze
zadań) ostatecznie poddaje się, klikając przycisk Tak. Znowu bingo!
Oprogramowanie szpiegujące wyświetla następnie umowę licencyjną liczącą 20 – 30 stron
napisanych językiem, który być może byłby zrozumiały dla Geoffreya Chaucera, ale z pewno-
ścią nie dla zwykłych użytkowników (może poza doświadczonymi prawnikami). Akceptacja tej
umowy może oznaczać, że użytkownik utraci szansę skutecznego pozwania twórcy oprogra-
mowania szpiegującego, ponieważ zaakceptował zawarte w tej umowie, często niejasne zapisy.
W pewnych przypadkach przepisy prawne mają jednak większą moc od tego rodzaju umów (jeśli
np. w licencji ktoś zapisze „niniejszym licencjobiorca nieodwołalnie daje licencjodawcy prawo
zamordowania matki licencjobiorcy i przejęcia w całości spadku po zmarłej”, licencjodawca
najpewniej nie przekona sądu do usankcjonowania uzyskanego tą drogą prawa, mimo korzyst-
nych zapisów umowy).
9.9.5. Rootkity
Rootkit to program lub zbiór programów i plików próbujących ukryć swoje istnienie nawet
przed użytkownikami podejmującymi konkretne działania na rzecz zlokalizowania i usunięcia
tych programów (plików) z zainfekowanego komputera. Rootkit zwykle zawiera jakieś złośliwe
oprogramowanie, które dla większej skuteczności wymaga dobrego ukrycia. Rootkity mogą być
instalowane zarówno z wykorzystaniem dowolnych spośród omówionych do tej pory metod (sto-
sowanych dla wirusów, robaków i oprogramowania szpiegującego), jak i z użyciem innych tech-
nik, które zostaną omówione w dalszej części tego podrozdziału.
Rodzaje rootkitów
Przeanalizujmy teraz pięć rodzajów rootkitów, których stosowanie jest obecnie możliwe.
Zaczniemy od rootkitów najniższego poziomu. We wszystkich przypadkach najważniejszym pro-
blemem jest znalezienie sposobu ukrycia plików (programów).
1. Rootkity oprogramowania firmware. Teoretycznie rootkit może się ukryć, zastępując
oryginalny BIOS kopią samego siebie. Taki rootkit może uzyskiwać kontrolę nad kom-
puterem przy okazji każdego uruchamiania komputera oraz za każdym razem, gdy jest
wywoływana jakaś funkcja BIOS-u. Jeśli rootkit będzie szyfrował sam siebie po każdym
użyciu (i deszyfrował przed każdym użyciem), jego wykrycie stanie się dość trudne. Tego
rodzaju rootkity nie są jednak stosowane w praktyce.
2. Rootkity maszyn wirtualnych (ang. hypervisor rootkits). Wyjątkowo podstępny rootkit może
uruchamiać cały system operacyjny i wszystkie aplikacje w ramach kontrolowanej
przez siebie maszyny wirtualnej. Koncepcję takiego rozwiązania, swoistą niebieską pigułkę
(znaną z filmu Matrix), po raz pierwszy zaproponowała polska hakerka Joanna Rutkowska
w 2006 roku. Rootkity tego typu zwykle modyfikują sekwencję startową, aby po włącze-
niu komputera mogły skorzystać z bezpośredniego dostępu do sprzętu oraz uruchomić
system operacyjny i aplikacje w maszynie wirtualnej. O potencjale tej metody (podobnie
jak w przypadku firmware’u) decyduje przede wszystkim brak konieczności ukrywania
czegokolwiek w systemie operacyjnym, bibliotekach czy programach. Oznacza to, że
narzędzia wykrywające rootkity w tych miejscach są bezużyteczne.
3. Rootkity jądra. Najczęściej spotykanym rodzajem rootkitów jest oprogramowanie infe-
kujące system operacyjny i ukrywające się wewnątrz sterowników urządzeń lub łado-
walnych modułów jądra. Rootkit w takiej formie może łatwo zastąpić rozbudowany, złożony
i często zmieniany sterownik nowym, zawierającym zarówno funkcje starego sterownika,
jak i sam rootkit.
4. Rootkity bibliotek. Innym popularnym miejscem ukrywania rootkitów są biblioteki sys-
temowe, np. biblioteka libc w systemie Linux. Takie położenie stwarza złośliwemu opro-
gramowaniu możliwość analizowania argumentów i wartości zwracanych przez wywołania
systemowe oraz ich modyfikowania z myślą o jak najdłuższym pozostawaniu w ukryciu.
5. Rootkity aplikacji. Innym popularnym miejscem ukrywania rootkitów są wielkie aplika-
cje, szczególnie te tworzące wiele nowych plików w trakcie normalnego działania (np.
profilów użytkownika, podglądów obrazów itp.). Nowe pliki stwarzają wprost wymarzoną
możliwość ukrywania złośliwego oprogramowania — ich istnienie nie budzi bowiem niczy-
ich wątpliwości.
Wykrywanie rootkitów
Wykrywanie rootkitów jest trudne, jeśli nie możemy zaufać warstwie sprzętowej, systemowi
operacyjnemu, bibliotekom ani aplikacjom. Naturalnym sposobem poszukiwania rootkitów jest
wygenerowanie listy wszystkich plików na dysku. Z drugiej strony wywołanie systemowe odczy-
tujące zawartość katalogu, procedura biblioteki korzystająca z tego wywołania oraz program
generujący ostateczną listę należą do potencjalnych ofiar ataku złośliwego oprogramowania i jako
takie mogą cenzurować dostarczane wyniki, pomijając pliki składające się na rootkit. Nasza sytu-
acja nie jest jednak beznadziejna — poniżej opisano skuteczne techniki ich wykrywania.
Wykrywanie rootkitów uruchamiających własne maszyny wirtualne, systemy operacyjne i apli-
kacje (w ramach kontrolowanych przez siebie maszyn wirtualnych) jest trudne, ale nie nie-
możliwe. Wystarczy uważnie analizować nieznaczne różnice dzielące wydajność i funkcjonalność
maszyn wirtualnych od maszyn prawdziwych (fizycznych). [Garfinkel et al., 2007] zasugero-
wali kilka takich różnic (opisanych poniżej). Zagadnienia związane z wykrywaniem tego rodzaju
rootkitów omówili też [Carpenter et al., 2007].
Istnieje cała kategoria metod wykrywania rootkitów maszyn wirtualnych z uwzględnie-
niem tego, że każdy taki rootkit sam musi wykorzystywać zasoby fizyczne, których utratę (bez
związku z funkcjonowaniem właściwego systemu i aplikacji) można wykryć. Monitor maszyn
wirtualnych musi np. korzystać z pewnych wpisów bufora TLB, współzawodnicząc z samą maszyną
wirtualną w dostępie do tych rzadkich zasobów. Program wykrywający może sam żądać dostępu
do zapisów bufora TLB, obserwować wydajność systemu i porównać ją z wcześniejszymi pomia-
rami wykonanymi na „czystym” sprzęcie.
Inna kategoria metod wykrywania rootkitów wykorzystuje charakterystyki czasowe sys-
temów, w szczególności wirtualizowanych urządzeń wejścia-wyjścia. Przypuśćmy, że odczyta-
nie rejestru jakiegoś urządzenia PCI w rzeczywistym (fizycznym) komputerze zajmuje 100 cykli
zegara i że ten wynik jest stały (niemal zawsze się powtarza). z pamięci, zatem czas jej odczytu
będzie zależał od tego, czy będzie to pamięć podręczna procesora pierwszego poziomu, pamięć
podręczna procesora drugiego poziomu, czy właściwa pamięć operacyjna (RAM). Program wykry-
wający rootkity może łatwo wymusić przechodzenie pomiędzy tymi stanami (w obu kierunkach),
aby zmierzyć zmienność czasów odczytu. Warto pamiętać, że istotna jest właśnie zmienność,
nie czas odczytu.
Innym obszarem, który może zdradzić obecność rootkitów, jest czas wykonywania uprzy-
wilejowanych rozkazów, zwłaszcza tych wymagających zaledwie kilku cykli zegara w razie
wykonywania na rzeczywistym (fizycznym) sprzęcie i setek lub tysięcy cykli, jeśli korzystają
z pośrednictwa emulatora. Jeżeli np. odczytanie zawartości jakiegoś chronionego rejestru pro-
cesora zajmuje 1 ns na rzeczywistym sprzęcie, żadne naturalne zjawiska nie mogą spowodować
wydłużenia tego czasu do 1 s. Monitor maszyn wirtualnych oczywiście może podjąć próbę
oszukania programu monitorującego — poprzez przekazanie emulowanego czasu wykonywa-
nia odpowiednich wywołań systemowych. Z drugiej strony program wykrywający może obejść
problem emulowanych pomiarów, łącząc się ze zdalnym komputerem lub witryną internetową
udostępniającą precyzyjne dane czasowe. Ponieważ program wykrywający musi mierzyć tylko
przedziały czasowe (np. to, ile czasu zajmuje wykonanie miliarda operacji odczytu chronionego
rejestru), różnice dzielące wskazania lokalnego zegara od wskazań zegara zdalnego nie mają
żadnego znaczenia.
Jeśli pomiędzy sprzętem a systemem operacyjnym nie działa żaden monitor maszyn wirtu-
alnych, rootkit może ukrywać się w systemie operacyjnym. Wykrycie tak zakamuflowanego
rootkitu poprzez ponowne uruchomienie komputera jest o tyle trudne, że nie możemy mieć
zaufania do samego systemu operacyjnego. Rootkit może np. zainstalować ogromną liczbę pli-
ków, których nazwy rozpoczynają się od przedrostka $$$_, i nigdy nie raportować o ich istnie-
niu w odpowiedzi na żądania odczytu zawartości katalogów kierowane do tego systemu przez
programy użytkownika.
Jednym ze sposobów wykrywania rootkitów ukrywających się w systemie operacyjnym
jest uruchomienie komputera z wykorzystaniem zaufanego medium zewnętrznego, np. orygi-
nalnej płyty DVD lub pamięci USB. Można wówczas przeszukać dysk za pomocą programu
antyrootkitowego bez obawy o wpływ samego rootkitu na wyniki wyszukiwania. Alternatywnym
rozwiązaniem jest wygenerowanie dla każdego pliku w badanym systemie operacyjnym skrótu
kryptograficznego i porównanie oryginalnego wykazu tych skrótów (zapisanego gdzieś poza
badanym systemem) z listą zwróconą przez system. Jeszcze innym rozwiązaniem (w razie braku
wcześniej wygenerowanych skrótów) jest wyznaczenie skrótów kryptograficznych lub listy
samych plików z poziomu systemu uruchomionego z użyciem płyty CD-ROM lub DVD.
Ukrywanie rootkitów w bibliotekach i aplikacjach jest trudniejsze — jeśli system operacyjny
zostanie załadowany z zewnętrznego, zaufanego nośnika, skróty kryptograficzne plików bibliotek
i aplikacji można łatwo porównać ze sprawdzonymi skrótami składowanymi na płycie CD-ROM.
Do tej pory koncentrowaliśmy się na pasywnych rootkitach, które nie wpływały bezpośred-
nio na działanie oprogramowania wykrywającego. Warto więc wspomnieć także o aktywnych
rootkitach wyszukujących i niszczących oprogramowanie wykrywające lub przynajmniej modyfi-
kujące ich mechanizmy w taki sposób, aby zawsze wyświetlały komunikat NIE ZNALEZIONO
ROOTKITÓW. Zwalczanie tego rodzaju rootkitów wymagałoby zastosowania bardziej wyszu-
kanych rozwiązań, jednak na szczęście do tej pory nie zanotowano skutecznego ataku z użyciem
aktywnych rootkitów.
Istnieją dwie szkoły postępowania już po odkryciu rootkitu w systemie. Jedna z nich mówi,
że administrator zainfekowanego systemu powinien postępować jak chirurg operujący pacjenta
z nowotworem — powinien ostrożnie usunąć zaatakowaną tkankę. Zwolennicy drugiej szkoły
przekonują, że próby usuwania rootkitu są zbyt niebezpieczne, ponieważ nie dają gwarancji, że
jakieś elementy nie pozostaną w ukryciu. Zgodnie z tą koncepcją jedynym skutecznym rozwią-
zaniem jest przywrócenie ostatniej „czystej” kopii zapasowej. Jeśli taka kopia nie istnieje, należy
przygotować nową instalację z użyciem oryginalnego nośnika (zwykle płyty CD-ROM lub DVD).
Radio (NPR) prezes wytwórni, Thomas Hesse, powiedział: „Sądzę, że większość ludzi nawet
nie wie, czym jest rootkit, zatem dlaczego mieliby się tym rootkitem przejmować?”. Kiedy przy-
toczona reakcja wytwórni wywołała prawdziwą burzę, firma zaczęła się wycofywać ze swojego
pomysłu i wydała łatkę usuwającą zakamuflowane pliki $sys$, ale zachowującą właściwy rootkit.
Rosnąca presja ostatecznie zmusiła firmę Sony do udostępnienia w swojej witrynie interneto-
wej pełnego programu usuwającego instalację, ale też wymagającego od użytkowników zainte-
resowanych pobraniem tego programu podania adresu poczty elektronicznej i wyrażenia zgody
na otrzymywanie materiałów promocyjnych wytwórni w przyszłości (czyli czegoś, co większość
z nas określa mianem spamu).
Wydanie programu usuwającego instalację rootkita nie zakończyło problemów firmy Sony —
okazało się, że jej nowy produkt zawierał techniczne usterki, które narażały zainfekowany
komputer na ataki za pośrednictwem internetu. Dowiedziono też, że sam rootkit zawierał kod
zaczerpnięty z projektów typu open source, co było naruszeniem umów licencyjnych (które dopusz-
czały darmowe wykorzystywanie oprogramowania, pod warunkiem każdorazowego udostęp-
niania kodu źródłowego produktów tworzonych z użyciem zastosowanych elementów).
Oprócz prawdziwej katastrofy w wymiarze wizerunkowym firma Sony stanęła przed poważ-
nymi problemami natury prawnej. Stan Teksas oskarżył wytwórnię o naruszenie przepisów,
które w założeniu miały zwalczać oprogramowanie szpiegujące, a także o nierzetelne praktyki
handlowe (ponieważ rootkit był instalowany mimo odrzucenia proponowanej licencji). Podobne
procesy odbyły się aż w 39 stanach. W grudniu 2006 roku wszystkie procesy zakończyły się
ugodą, zgodnie z którą wytwórnia Sony miała zapłacić karę 4,25 miliona dolarów, zaprzestać
praktyk umieszczania rootkitu na swoich płytach CD i zapewnić każdej z ofiar możliwość pobra-
nia za darmo trzech albumów z ograniczonego katalogu nagrań. W styczniu 2007 roku firma Sony
przyznała, że jej oprogramowanie w tajemnicy monitorowało upodobania muzyczne użytkow-
ników i wysyłało raporty do wytwórni, co także stanowiło naruszenie prawa Stanów Zjedno-
czonych. W ramach ugody z Federalną Komisją Handlu (FTC) wytwórnia zgodziła się wypłacić
użytkownikom, których systemy zostały uszkodzone wskutek działania nielegalnego oprogramo-
wania Sony, rekompensaty w wysokości 150 dolarów.
Historia rootkitu firmy Sony była cenną lekcją dla wszystkich, którzy sądzili, że rootkity to
osobliwość będąca domeną rozważań i eksperymentów akademickich, ale nie znajdująca prze-
łożenia na rzeczywiste, komercyjne zastosowania. Czytelników zainteresowanych dodatkowymi
informacjami na ten temat zachęcam do wpisania w wyszukiwarce wyrażenia Sony rootkit —
w internecie można znaleźć niezliczone materiały na ten temat.
Skoro tak wiele potencjalnych zagrożeń czai się w najróżniejszych obszarach naszych systemów,
czy można jakoś te systemy zabezpieczyć? Okazuje się, że wynaleziono wiele takich rozwiązań —
w poniższych punktach przeanalizujemy wybrane sposoby projektowania i implementowania
systemów z myślą o podnoszeniu ich bezpieczeństwa. Jedną z najważniejszych koncepcji jest
strategia określana terminem obrona wielostrefowa (ang. defense in depth). W największym uprosz-
czeniu idea obrony wielostrefowej polega na stosowaniu wielu warstw (stref) ochrony, aby w razie
pokonania jednej linii obrony strona atakująca musiała stawić czoła kolejnym. Wyobraźmy sobie
posiadłość otoczoną wysokim płotem z drutem kolczastym, zabezpieczoną wykrywaczami ruchu
na otwartym terenie, dwoma doskonałymi zamkami w drzwiach wejściowych i skomputeryzo-
9.10.1. Firewalle
Możliwość nawiązania połączenia z dowolnym komputerem, gdziekolwiek się znajduje, jest
jednocześnie błogosławieństwem i przekleństwem. Z jednej strony internet stanowi niemal
nieograniczone źródło niezwykle cennych materiałów, z drugiej strony samo połączenie z inter-
netem naraża komputer na dwa rodzaje zagrożeń: zagrożenia przychodzące i wychodzące. Do
zagrożeń przychodzących zalicza się zarówno krakerów próbujących włamać się do danego
systemu, jak i wirusy, oprogramowanie szpiegujące oraz inne typy złośliwego oprogramowania.
Zagrożenia wychodzące wiążą się z ryzykiem wycieku poufnych danych, jak numery kart kre-
dytowych, hasła, zeznania podatkowe czy rozmaite tajemnice firmowe.
Oznacza to, że mechanizmy zabezpieczeń powinny dopuszczać do przesyłania „dobrych”
bitów i jednocześnie uniemożliwiać przekazywanie „złych” bitów. Jednym z rozwiązań jest użycie
firewalla, czyli współczesnego odpowiednika przeszkody znanej z czasów średniowiecznych —
głębokiej fosy otaczającej zamek. Taka konstrukcja zmuszała wszystkich wchodzących i opusz-
czających zamek do przechodzenia przez pojedynczy most zwodzony, aby ułatwić straży odpo-
wiedzialnej za wejście-wyjście kontrolę ludzi i towarów. Okazuje się, że podobne rozwiązanie
jest możliwe także we współczesnych sieciach. Pojedyncze przedsiębiorstwo może dyspono-
wać wieloma sieciami LAN połączonymi na rozmaite sposoby, a jednocześnie cała komunikacja
pomiędzy tą firmą a światem zewnętrznym może się odbywać przez elektroniczny most zwo-
dzony — firewall.
Firewalle można podzielić na dwie główne kategorie: sprzętowe i programowe. Firmy chcące
chronić swoje sieci LAN z reguły decydują się na stosowanie firewalli sprzętowych; użytkow-
nicy prywatni zwykle chronią swoje komputery domowe firewallami programowymi. Przyj-
rzyjmy się najpierw firewallom sprzętowym. Ogólny schemat działania takiego firewalla poka-
zano na rysunku 9.24. Jak widać, firewall oddziela łącze internetowe (w formie standardowego
przewodu sieciowego lub światłowodu) od sieci lokalnej (LAN). Oznacza to, że żaden pakiet
nie może wejść ani opuścić tej sieci bez akceptacji firewalla. W praktyce firewalle często są łączone
m.in. z routerami, urządzeniami tłumaczącymi adresy sieciowe (ang. network address transla-
tion — NAT) i systemami wykrywającymi ataki, jednak w tym miejscu skoncentrujemy się
wyłącznie na funkcjonalności firewalli.
Firewalle konfiguruje się, definiując reguły opisujące, jakie pakiety mogą wchodzić do sieci,
a jakie z niej wychodzić. Właściciel firewalla może te reguły zmieniać (zwykle za pośrednic-
twem interfejsu WWW; większość firewalli oferuje wbudowane miniserwery WWW umożli-
wiające korzystanie z tego interfejsu). Najprostszy rodzaj firewalla, tzw. firewall bezstanowy (ang.
stateless firewall), bada nagłówek każdego przesyłanego pakietu i na tej podstawie decyduje
(z uwzględnieniem informacji zawartych w tym nagłówku i zdefiniowanych reguł), czy należy
zezwolić na jego przesłanie, czy dany pakiet powinien zostać odrzucony. Nagłówek pakietu
Rysunek 9.24. Uproszczony schemat działania sprzętowego firewalla chroniącego sieć LAN
z trzema komputerami
zawiera m.in. takie informacje jak źródłowy i docelowy adres IP, źródłowy i docelowy port oraz
rodzaj usługi i protokołu. Pozostałe pola nagłówków rzadko są uwzględniane w regułach stoso-
wanych przez firewalle.
W przykładzie pokazanym na rysunku 9.24 mamy do czynienia z trzema serwerami, z któ-
rych każdy ma przypisany unikatowy adres IP w formie 207.68.160.x, gdzie x ma odpowiednio
wartość 190, 191 i 192. Komunikacja z serwerami tej sieci LAN wymaga kierowania pakietów
właśnie pod te trzy adresy. Pakiety przychodzące dodatkowo zawierają 16-bitowe numery portów
identyfikujące procesy na poszczególnych komputerach, do których te pakiety są adresowane
(proces może nasłuchiwać komunikacji przychodzącej na określonym porcie). Niektóre porty
są skojarzone ze standardowymi usługami — np. port 80 jest stosowany przez serwery WWW,
port 25 jest wykorzystywany dla poczty elektronicznej, a port 21 jest używany przez usługę
FTP (protokół transferu plików), ale większość pozostałych portów jest dostępna dla usług defi-
niowanych przez użytkownika. Firewall można więc skonfigurować w następujący sposób:
IP address Port Action
207.68.160.190 80 Accept
207.68.160.191 25 Accept
207.68.160.192 21 Accept
* * Deny
nawet jeśli autor gry nie miał złych zamiarów, może się okazać, że omyłkowo pozostawił w kodzie
tej gry niebezpieczną lukę. Im więcej portów otworzymy w ustawieniach firewalla, tym większe
jest ryzyko przeprowadzenia skutecznego ataku na naszą sieć. Każda luka zwiększa szansę krakera
na pokonanie zabezpieczeń.
Oprócz firewalli bezstanowych istnieją jeszcze firewalle stanowe (ang. stateful firewalls),
które śledzą połączenia i ich bieżący stan. Firewalle stanowe skuteczniej radzą sobie z pewnymi
rodzajami ataków, szczególnie z tymi polegającymi na ustanawianiu połączeń. Jeszcze inne rodzaje
firewalli implementują system wykrywania włamań (ang. Intrusion Detection System — IDS),
który weryfikuje nie tylko nagłówki pakietów, ale też ich zawartość pod kątem występowania
podejrzanego materiału.
Firewalle programowe, nazywane też firewallami osobistymi (ang. personal firewalls), robią
to samo, co firewalle sprzętowe, tyle że nie mają postaci odrębnych urządzeń, tylko specjalnych
programów. Firewalle programowe działają jak filtry skojarzone z kodem sieciowym jądra sys-
temu operacyjnego i weryfikują pakiety w sposób analogiczny do działania firewalli sprzętowych.
Skanery antywirusowe
Niewątpliwie przeciętny użytkownik nie ma czasu ani kompetencji, aby odnajdywać niezliczone
wirusy, które robią, co w ich mocy, by ukrywać swoją obecność w zainfekowanych systemach.
Właśnie dlatego na rynku pojawiła się odrębna kategoria produktów — oprogramowanie anty-
wirusowe. Producenci programów antywirusowych dysponują laboratoriami zatrudniającymi
naukowców spędzających całe godziny nad śledzeniem i próbami zrozumienia istoty działania
nowych wirusów. Pierwszym krokiem jest uzyskanie programu zainfekowanego przez wirus,
czyli tzw. plik kozła ofiarnego (ang. goat file), aby laboratorium dysponowało danym wirusem
w jego najczystszej formie. Następny krok to wyodrębnienie kodu wirusa i wpisanie go do bazy
danych znanych wirusów. Producenci oprogramowania antywirusowego konkurują m.in. roz-
miarami swoich baz danych. W tej sytuacji nie można wykluczyć, że część producentów zdecy-
duje się na nieuczciwą budowę pozycji rynkowej poprzez wymyślanie wirusów tylko po to, by
sztucznie napompować swoje bazy danych.
Pierwszym krokiem programu antywirusowego zainstalowanego na komputerze klienta
jest analiza (tzw. skanowanie) wszystkich plików wykonywalnych na dysku twardym pod kątem
zawierania jakichkolwiek wirusów w bazie danych znanych wirusów. Większość producentów
programów antywirusowych oferuje witryny internetowe, z których użytkownicy mogą pobierać
aktualizacje swoich baz danych wprowadzające opisy nowo odkrytych wirusów. Jeśli użytkownik
ma na swoim dysku 10 tysięcy plików i dysponuje bazą danych o 10 tysiącach wirusów, sprawdze-
nie tego dysku mogłoby trwać w nieskończoność, gdyby twórcy oprogramowania antywirusowego
nie stosowali zaawansowanych technik programistycznych.
Ponieważ stale pojawiają się nowe, nieznacznie zmienione odmiany znanych od dawna wiru-
sów, programy antywirusowe muszą stosować metody przybliżonego dopasowywania wirusów,
aby minimalna (obejmująca np. 3 bajty) zmiana znanego wirusa nie przekreślała możliwości jego
wykrycia. Z drugiej strony przybliżone dopasowania nie tylko spowalniają proces przeszuki-
wania, ale też mogą prowadzić do fałszywych alarmów, czyli ostrzeżeń o wirusach wykrywa-
nych w plikach, które w rzeczywistości nie są zainfekowane (przypadkiem zawierają jakiś kod
przypominający wirus odkryty np. siedem lat temu w Pakistanie). Nietrudno się domyślić, jak użyt-
kownik zareaguje na następujący komunikat:
UWAGA! Plik xyz.exe może zawierać wirus lahore-9x. Usunąć?
Rysunek 9.25. (a) Program; (b) program zainfekowany; (c) program zainfekowany i skompresowany;
(d) zaszyfrowany wirus; e) skompresowany wirus z zaszyfrowanym kodem kompresji
odtworzeniem oryginalnego kodu wirusa. Ponieważ przed wykonaniem swoich zadań wirus
musi każdorazowo deszyfrować swój kod, warunkiem jego działania jest zapisanie w zainfeko-
wanym pliku także funkcji deszyfrującej.
Opisane schematy ukrywania wirusów przed programami antywirusowymi są o tyle niedo-
skonałe, że same procedury kompresujące, dekompresujące, szyfrujące i deszyfrujące pozostają
takie same we wszystkich kopiach, zatem program antywirusowy może wykorzystać właśnie
te procedury w roli poszukiwanych sygnatur. Z drugiej strony ukrycie procedur kompresują-
cych, dekompresujących i szyfrujących jest dość łatwe — można je szyfrować wraz z pozosta-
łym kodem wirusa (patrz rysunek 9.25(e)). Nie można jednak zaszyfrować kodu deszyfrującego.
Twórcy programów antywirusowych doskonale o tym wiedzą i dlatego polują przede wszystkim
na procedury deszyfrujące.
Przyjmijmy jednak, że Wirgiliusz lubi, kiedy ostatnie słowo należy właśnie do niego, i posta-
nawia zrealizować następujący pomysł. Załóżmy, że procedura deszyfrująca musi wykonywać
następujące obliczenia:
X = (A + B + C − 4)
Przykład prostego kodu asemblera wyznaczającego wartość według tego wzoru (dla dwuadre-
sowego komputera) przedstawiono na listingu 9.7(a). Pierwszy adres reprezentuje miejsce
składowania wartości źródłowej; pod drugim adresem składujemy wartość docelową, zatem
rozkaz MOV A,R1 przenosi zmienną A do rejestru R1. Kod na listingu 9.7(b) robi dokładnie to samo,
ale jest nieznacznie mniej efektywny wskutek użycia rozkazów NOP (braku operacji) pomiędzy
oryginalnymi rozkazami.
To nie koniec naszych możliwości. Okazuje się, że istnieje ewentualność skutecznego masko-
wania także kodu deszyfrującego. Istnieje wiele form zapisu rozkazu NOP. Przykładowo dodanie
do rejestru liczby 0, zastosowanie operatora OR dla niego samego, przesunięcie w lewo o 0 bitów
lub skok do następnego rozkazu to tylko kilka możliwych operacji równoznacznych z brakiem dzia-
łań. Oznacza to, że program z listingu 9.7(c) jest funkcjonalnie tożsamy programowi z listingu 9.7(a).
W tej sytuacji wirus może skopiować swój kod, stosując zapis z listingu 9.7(c) zamiast kodu
z listingu 9.7(a), i jednocześnie zachować swoje oryginalne działanie. Wirusy mutujące przy okazji
każdego kopiowania określa się mianem wirusów polimorficznych (ang. polymorphic viruses).
Przypuśćmy teraz, że rejestr R5 nie jest do niczego potrzebny w trakcie wykonywania tego
fragmentu kodu wirusa. Oznacza to, że także kod z listingu 9.7(d) stanowi funkcjonalny rów-
noważnik kodu z listingu 9.7(a). I wreszcie w wielu przypadkach istnieje możliwość wymiany
kolejności rozkazów bez wpływ na sposób działania programu, zatem kod z listingu 9.7(e) jest
kolejnym fragmentem logicznie zgodnym z kodem z listingu 9.7(a). Fragment kodu zdolny do
mutacji sekwencji rozkazów sprzętowych bez zmiany swojej funkcjonalności nazywa się silni-
kiem mutacji (ang. mutation engine); najbardziej wyszukane wirusy korzystają z takich silników
do każdorazowego modyfikowania swoich procedur deszyfrujących. Proces mutacji może polegać
na dodawaniu bezużytecznego, ale nieszkodliwego kodu, modyfikowaniu kolejności rozkazów,
wymianie rejestrów i zastępowaniu rozkazów ich odpowiednikami. Sam silnik mutacji może być
ukrywany poprzez szyfrowanie wraz z właściwym ciałem odpowiedniego wirusa.
Oczekiwanie od oprogramowania antywirusowego, że będzie potrafiło odkryć funkcjonalną
równoważność kodu z listingów od 9.7(a) do 9.7(e), byłoby przejawem nadmiernego optymi-
zmu, szczególnie jeśli silnik mutacji korzysta z wielu innych zabiegów. Oprogramowanie antywi-
rusowe może oczywiście analizować kod pod kątem realizowanych zadań (w skrajnych przy-
padkach może nawet podejmować próby symulowania operacji wykonywanych przez ten kod),
jednak w przypadku tysięcy wirusów w bazie danych i tysięcy plików do sprawdzenia czas trwa-
nia kompletnego testu byłby zdecydowanie zbyt długi.
Samo składowanie wartości w zmiennej Y ma na celu tylko zmylenie mechanizmu wykry-
wającego i ukrycie faktu, że kod operujący na rejestrze R5 jest martwy (nie wykonuje żadnych
istotnych operacji). Jeśli inne fragmenty kodu odczytują i zapisują wartość zmiennej Y, kod w tej
formie sprawia wrażenie zupełnie prawidłowego. Dobrze zaprojektowany silnik mutacji potrafi
na tyle skutecznie generować polimorficzny kod, że stanowi największe wyzwanie dla twór-
ców oprogramowania antywirusowego. Jedyną pozytywną wiadomością jest to, że samo napi-
sanie takiego silnika jest wyjątkowo trudne, zatem wszyscy przyjaciele Wirgiliusza korzystają
z jego rozwiązań, co — przynajmniej na razie — znacznie ogranicza liczbę schematów mutacji
do wykrycia.
Do tej pory koncentrowaliśmy się tylko na próbach rozpoznawania wirusów w już zainfe-
kowanych plikach wykonywalnych. Współczesne skanery antywirusowe muszą jeszcze spraw-
dzać m.in. główny rekord startowy (MBR), sektory startowe, listy uszkodzonych sektorów,
pamięć flash oraz pamięć CMOS. Co należy zrobić, jeśli w systemie działa już wirus rezydujący
w pamięci? Taki wirus nie zostanie wykryty. Co gorsza, przypuśćmy, że działający wirus monito-
ruje wszystkie wywołania systemowe. Może wówczas łatwo wykryć próbę odczytania przez
program antywirusowy sektora startowego (w poszukiwaniu wirusów). Aby utrudnić zadanie
temu narzędziu, wirus może wstrzymać prawdziwe wywołanie i zwrócić w odpowiedzi orygi-
nalny sektor startowy (sprzed infekcji) ukryty gdzieś na liście uszkodzonych bloków dyskowych.
Wirus może też podjąć próbę ponownego zainfekowania wszystkich plików po zakończeniu
pracy skanera.
Aby uniknąć ryzyka wprowadzenia w błąd przez wirus, program antywirusowy może wyko-
nywać operacje twardego odczytu danych z dysku z pominięciem systemu operacyjnego. Takie
rozwiązanie wymaga jednak użycia wbudowanych sterowników urządzeń dla interfejsów IDE,
SCSI i innych popularnych technologii, co z kolei ogranicza przenośność programu antywiruso-
wego i uniemożliwia jego stosowanie na komputerach z nietypowymi dyskami. Co więcej, ponie-
waż istnieje możliwość pomijania systemu operacyjnego w procesie odczytu sektora startowego,
ale nie jest możliwe pominięcie tego systemu w dostępie np. do wszystkich plików wykonywal-
nych, proponowane rozwiązanie nie eliminuje ryzyka wygenerowania przez wirus fałszywych
danych o plikach wykonywalnych.
Weryfikatory integralności
Alternatywnym sposobem wykrywania wirusów jest weryfikacja integralności (ang. integrity
checking). Program antywirusowy działający w ten sposób rozpoczyna pracę od skanowania dysku
twardego w poszukiwaniu wirusów. Kiedy nabierze przekonania o braku wirusów, wyznacza
sumę kontrolną dla każdego ze znalezionych plików wykonywalnych. Algorytm obliczania sumy
kontrolnej może być zarówno prosty (jego działanie może się sprowadzać do zsumowaniu cał-
kowitoliczbowych, 32- lub 64-bitowych reprezentacji wszystkich słów programu), jak i dość
skomplikowany (obliczający np. skróty kryptograficzne niemal całkowicie eliminujące ryzyko
wykonania operacji odwrotnych). Program antywirusowy zapisuje następnie sumy kontrolne
wszystkich plików w danym katalogu w specjalnym pliku, nazwanym np. checksum. Podczas
następnego skanowania program wyznaczy ponownie sumy kontrolne plików i sprawdzi, czy
odpowiadają wartościom zapisanym w pliku checksum. Takie rozwiązanie pozwoli natychmiast
wykryć zainfekowane pliki.
Problem w tym, że Wirgiliusz nie zamierza złożyć broni. Co gorsza, może napisać wirus
wyznaczający sumę kontrolną zainfekowanego pliku i zastępujący oryginalny wpis w pliku sum
kontrolnych. Aby temu zapobiec, program antywirusowy może podjąć próbę ukrycia pliku sum
kontrolnych, jednak skuteczność takiego rozwiązania jest o tyle niewielka, że Wirgiliusz ma
mnóstwo czasu na przestudiowanie programu antywirusowego przed napisaniem swojego wirusa.
Lepszym wyjściem jest więc cyfrowe podpisanie pliku sum kontrolnych, aby łatwo wykrywać próby
jego modyfikacji. Najlepszym rozwiązaniem byłoby łączne stosowanie podpisów cyfrowych i kart
inteligentnych z zewnętrznym kluczem (bez możliwości uzyskania przez lokalne programy).
Weryfikatory zachowań
Trzecią strategią stosowaną przez oprogramowanie antywirusowe jest weryfikacja zachowań
(ang. behavioral checking). Ta koncepcja polega na umieszczeniu w pamięci programu antywi-
rusowego przechwytującego i analizującego wszystkie wywołania systemowe. Możliwość moni-
torowania aktywności systemu powinna umożliwić programowi antywirusowemu wykrycie
wszelkich podejrzanych działań. Żaden normalny program nie powinien np. podejmować prób
nadpisania sektora startowego, zatem wszelkie tego rodzaju próby mogą sugerować próbę infekcji.
Podobnie podejrzenia tak działającego programu powinny wzbudzić próby zmiany pamięci flash.
Istnieją jednak sytuacje, w których kojarzenie działań z wirusami nie jest takie oczywiste;
np. nadpisywanie plików wykonywalnych powinno budzić nasze podejrzenia, chyba że mamy do
Unikanie wirusów
Każda dobra historia musi mieć swój morał. W tym przypadku morał brzmi:
Strzeżonego Pan Bóg strzeże.
Unikanie wirusów jest dużo prostsze niż ich odnajdywanie i eliminowanie już po zainfeko-
waniu komputera. Sugerowane techniki powinni stosować zarówno użytkownicy indywidualni, jak
i przemysł komputerowy jako całość, aby znacznie ograniczyć skalę interesującego nas problemu.
Co można zrobić, aby uniknąć infekcji swojego systemu? Po pierwsze trzeba wybrać system
operacyjny oferujący wysoki stopień bezpieczeństwa, pełną rozdzielność trybów użytkownika
i jądra oraz odrębne hasła logowania dla poszczególnych użytkowników i administratora sys-
temu. Wymienione cechy powodują, że wirus, który jakoś znajdzie drogę do tego systemu, nie
będzie mógł zainfekować binariów systemu. Należy również zadbać o niezwłoczne zainstalowa-
nie poprawek bezpieczeństwa publikowanych przez producentów oprogramowania.
Po drugie należy instalować tylko oryginalnie zapakowane oprogramowanie sprawdzonych
producentów. Nawet to nie gwarantuje nam pełnego bezpieczeństwa (choć znacznie je podno-
si), ponieważ w przeszłości zdarzało się, że niezadowoleni pracownicy potajemnie umieszczali
wirusy w komercyjnych produktach. Pobieranie oprogramowania z amatorskich stron inter-
netowych i forów dyskusyjnych oferujących programy zbyt atrakcyjne na to, by były darmowe,
jest bardzo ryzykowne.
Po trzecie użytkownik powinien się zaopatrzyć w dobry pakiet oprogramowania antywiru-
sowego i korzystać z tego pakietu zgodnie z zaleceniami producenta. Koniecznie należy dbać
o regularne aktualizowanie bazy danych o wirusach za pośrednictwem witryny internetowej
producenta.
oprogramowania, prawdopodobieństwo obecności konia trojańskiego w tej kontrolce jest dość niskie,
jednak użytkownik nie może być pewien swojego bezpieczeństwa.
Jednym z rozwiązań jest jak najszersze stosowanie dla tego rodzaju produktów koncepcji
podpisu cyfrowego (patrz punkt 9.5.4). Jeśli użytkownik uruchamia tylko programy, moduły
rozszerzeń, sterowniki, kontrolki ActiveX i inne formy oprogramowania napisane i podpisane
przez zaufanych producentów, ryzyko infekcji jest znacznie mniejsze. Negatywnym skutkiem
takiego modelu doboru oprogramowania okazuje się konieczność rezygnacji z nowych, darmo-
wych, doskonałych gier nieznanych firm, które wydają się użytkownikowi zbyt dobre, aby ktokol-
wiek mógł je oferować za darmo, i które z natury nie są podpisywane przez zaufanego producenta.
Podpisywanie kodu wymaga stosowania technik kryptografii z kluczem publicznym. Produ-
cent oprogramowania generuje parę (klucz publiczny, klucz prywatny), po czym ogłasza publicz-
nie pierwszy element tej pary i zazdrośnie strzeże drugiego. Aby podpisać swoje oprogramowanie,
producent musi najpierw wyznaczyć wartość funkcji skrótu, czyli 128-, 160- lub 256-bitową
liczbę (w zależności od stosowanego algorytmu: MD5, SHA-1 lub SHA-256). Wygenerowany kod
jest następnie podpisywany poprzez zaszyfrowanie go z użyciem klucza prywatnego (w prak-
tyce ta operacja polega na odszyfrowaniu tej wartości zgodnie ze schematem pokazanym na
rysunku 9.13). Podpis jest przekazywany wraz z oprogramowaniem.
Kiedy użytkownik kupuje czy pobiera oprogramowanie, funkcja skrótu przetwarza pliki tego
programu i zapisuje otrzymany wynik. Mechanizm weryfikujący odszyfrowuje następnie pod-
pis dołączony do tego oprogramowania (z wykorzystaniem klucza publicznego) i porównuje
wynik zwrócony przez dostarczoną funkcję skrótu z wartością wyznaczoną na komputerze
użytkownika. Jeśli obie wartości są równe, program jest akceptowany. W przeciwnym razie pro-
gram zostaje odrzucony jako sfałszowany. Metody matematyczne stosowane podczas weryfi-
kacji podpisu bardzo utrudniają próby fałszowania oprogramowania, które wymagałyby stwo-
rzenia funkcji skrótu identycznej jak ta uzyskiwana w wyniku odszyfrowania podpisu. Równie
trudne jest wygenerowanie nowego, fałszywego podpisu bez klucza prywatnego. Proces podpi-
sywania oprogramowania i jego weryfikacji pokazano na rysunku 9.26.
Strony internetowe mogą zawierać zarówno kod kontrolek ActiveX, jak i kod napisany
w rozmaitych językach skryptowych. Kontrolki i skrypty często są podpisywane — w każdym
takim przypadku przeglądarka automatycznie weryfikuje zastosowany podpis. Warunkiem
sprawdzenia podpisu przez przeglądarkę jest dysponowanie kluczem publicznym producenta
oprogramowania, co zwykle wymaga korzystania z usług jakiegoś centrum certyfikacji uwie-
rzytelniającego klucze publiczne. Jeśli przeglądarka dysponuje już kluczem publicznym, może
zweryfikować certyfikat bez nawiązywania połączenia z urzędem certyfikacji. Jeśli jednak cer-
tyfikat został podpisany przez urząd nieznany przeglądarce, zostanie wyświetlone okno dialo-
gowe z pytaniem o akceptację tego certyfikatu.
Nowy program jest uruchamiany w formie procesu, który na rysunku oznaczono etykietą
Więzień. Etykietą Strażnik oznaczono zaufany proces (system) monitorujący zachowania więź-
nia. Kiedy „uwięziony” proces żąda wykonania wywołania systemowego, zamiast wykonać to
wywołanie, sterowanie (wraz z numerem wywołania systemowego i jego parametrami) jest kie-
rowane do Strażnika (za pośrednictwem specjalnej pułapki jądra). Strażnik decyduje następnie,
czy żądane wywołanie systemowe może zostać zrealizowane. Jeśli proces Więźnia próbuje np.
otworzyć połączenie sieciowe ze zdalnym serwerem nieznanym Strażnikowi, odpowiednie
wywołanie systemowe może zostać odrzucone, a proces Więźnia zabity. Jeśli wywołanie syste-
mowe jest możliwe do zaakceptowania, Strażnik informuje o tym jądro systemu, które realizuje
to wywołanie. Oznacza to, że ryzykowne zachowania można wykrywać i eliminować, zanim spo-
wodują szkody w systemie.
Istnieją różne implementacje techniki wtrącania do więzienia. Rozwiązanie, które można
z powodzeniem stosować w niemal wszystkich systemach UNIX bez konieczności modyfikowa-
nia jądra, opisano w książce [Van’t Noordende et al., 2007]. W największym uproszczeniu zapro-
ponowany schemat wykorzystuje standardowe mechanizmy diagnostyczne systemu UNIX,
gdzie proces występujący w roli strażnika jest debugerem, a proces pełniący funkcję więźnia jest
przedmiotem diagnozy. Oznacza to, że debuger może wymusić na jądrze hermetyczne zamknięcie
diagnozowanego procesu i przekazywanie do analizy wszystkich wywołań systemowych.
wykrywania włamań wspomniano już w kontekście firewalli; tym razem poświęcimy trochę
uwagi systemom wykrywania włamań na poziomie pojedynczych komputerów. Ograniczona prze-
strzeń nie pozwala nam na szczegółową analizę wielu rodzajów takich systemów. Skoncentru-
jemy się więc na prezentacji jednego typu, który dobrze ilustruje działanie pozostałych. Intere-
sujący nas system określa się mianem wykrywania włamań z użyciem modeli statycznych (ang.
static model-based intrusion detection). Można ten mechanizm zaimplementować na różne spo-
soby, m.in. z wykorzystaniem omówionej przed chwilą techniki wtrącania do więzienia.
Na rysunku 9.28(a) pokazano kod prostego programu otwierającego plik nazwany data i odczy-
tującego kolejne znaki zawarte w tym pliku aż do natrafienia na bajt zerowy (program wyświe-
tla wówczas liczbę odczytanych bajtów bez bajta zerowego i kończy pracę). Na rysunku 9.28(b)
widać graf wywołań systemowych wykonywanych przez ten program (funkcja print korzysta
z wywołania write).
(a) (b)
fd = open("data", 0);
if (fd < 0) {
exit(1);
} else {
while (1) {
read(fd, buf, 1);
if (buf[0] == 0) {
close(fd);
printf("n = %d\n", n);
exit(0);
}
n = n + 1;
}
}
}
Rysunek 9.28. (a) Kod programu; (b) graf wywołań systemowych dla kodu z części (a)
proces strażnika może odkryć niezgodność tej sekwencji z oryginalnym wzorcem i podjąć dzia-
łania uniemożliwiające atak — np. poprzez zabicie analizowanego procesu i wysłanie ostrzeże-
nia do administratora systemu. Oznacza to, że system wykrywania włamań może właściwie zare-
agować już w trakcie ataku. Statyczna analiza wywołań systemowych to jednak tylko jeden
z wielu sposobów działania systemów IDS.
Warunkiem stosowania tego mechanizmu wykrywania włamań z użyciem modeli statycz-
nych jest dysponowanie modelem (czyli grafem wywołań systemowych) przez proces strażnika.
Najprostszym sposobem uzyskania tego rodzaju wiedzy okazuje się generowanie modeli przez
kompilator i wymuszanie na autorach programów podpisywania swoich produktów i dołączania
odpowiednich certyfikatów. Takie rozwiązanie umożliwi natychmiastowe wykrycie każdej próby
wykonania zmodyfikowanego programu, ponieważ jego działanie będzie niezgodne z oczekiwa-
nym zachowaniem.
Okazuje się jednak, że sprytny kraker może przeprowadzić tzw. atak poprzez upodobnienie
(ang. mimicry attack), podczas którego wstawiony kod wykonuje te same wywołania systemowe,
co oryginalny program przed atakiem. Oznacza to, że skuteczna analiza programu wymaga bar-
dziej wyszukanego modelu niż samego wykazu wywołań systemowych. Wciąż mówimy jednak
o obronie wielostrefowej, w której system IDS jest tylko jednym z wielu elementów.
System wykrywający włamania z użyciem modeli nie jest jedyną kategorią tego rodzaju
mechanizmów. Wiele systemów IDS wykorzystuje tzw. przynęty (ang. honeypots), czyli pułapki
celowo budzące zainteresowanie krakerów i złośliwego oprogramowania. W tej roli zwykle
wykorzystuje się odizolowany komputer z kilkoma środkami obrony i pozornie interesującą,
cenną treścią, która aż się prosi, by po nią sięgnąć. Ludzie przygotowujący takie pułapki uważ-
nie monitorują działanie tego komputera pod kątem ewentualnych ataków, aby jak najlepiej
zrozumieć jego istotę. Niektóre systemy IDS tworzą pułapki w ramach maszyn wirtualnych,
aby zapobiec uszkodzeniom właściwych systemów. Właśnie dlatego złośliwe oprogramowanie pró-
buje sprawdzać, czy ma do czynienia z maszynami wirtualnymi (o czym wspomniano powyżej).
do analizy następnej witryny. Po odwiedzeniu wszystkich witryn agent wraca na komputer swojego
właściciela i informuje go o wnioskach.
Trzecim przykładem kodu mobilnego są pliki PostScript przeznaczone do wydrukowania na
drukarkach zgodnych z tym standardem (tzw. drukarkach postscriptowych). Pliki PostScript
w rzeczywistości są programami napisanymi w języku programowania PostScript, wykonywa-
nymi wewnątrz drukarki. Kod zawarty w tych plikach z reguły nakazuje drukarce narysowanie
pewnych krzywych i wypełnienie powstałych figur, jednak równie dobrze może wymuszać inne
działania. Aplety, programy agentów i pliki PostScript to tylko trzy z wielu przykładów kodu
mobilnego (ang. mobile code).
Po lekturze wcześniejszego materiału o wirusach i robakach nie powinniśmy mieć żadnych
wątpliwości, że zezwalanie na wykonywanie obcego kodu na własnym komputerze jest więcej niż
ryzykowne. Ponieważ mimo tych zagrożeń wielu użytkowników decyduje się na uruchamianie
obcych programów w swoich systemach, warto zadać sobie następujące pytanie: czy kod mobilny
można wykonywać bezpiecznie? Najprostsza odpowiedź na to pytanie brzmi: tak, ale to niełatwe.
Problem w tym, że skoro proces importuje aplet lub inny kod mobilny i wykonuje go we własnej
przestrzeni adresowej, zaimportowany kod jest częścią prawidłowego procesu użytkownika i dys-
ponuje pełnymi uprawnieniami tego użytkownika, włącznie z możliwością odczytywania, zapi-
sywania, usuwania i szyfrowania plików dyskowych, wysyłania wiadomości poczty elektronicznej
do odległych krajów itp.
Dawno temu systemy operacyjne stosowały procesy budujące swoiste mury pomiędzy
poszczególnymi użytkownikami. Zgodnie z tamtą koncepcją każdy proces dysponował własną,
chronioną przestrzenią adresową i własnym identyfikatorem UID, dzięki czemu mógł operować
na plikach i innych zasobach swojego właściciela, ale nie na zasobach innych użytkowników.
Tak rozumiana koncepcja procesów nie uwzględnia jednak zagadnień związanych z ochroną części
procesu (np. apletu) od jego reszty. Pewnym rozwiązaniem są wątki (w ramach pojedynczego
procesu można jednocześnie wykonywać wiele wątków), jednak nic nie chroni jednego wątku
przed pozostałymi.
Teoretycznie wykonywanie każdego apletu w formie odrębnego procesu mogłoby trochę
pomóc, jednak często okazuje się po prostu niewykonalne. Przykładowo strona internetowa
może zawierać dwa aplety (lub większą ich liczbę) wzajemnie ze sobą współpracujące i operujące
na danych zawartych na tej stronie. Także przeglądarka internetowa może stanąć przed koniecz-
nością współpracy z apletami, uruchamiania i zatrzymywania apletów, dostarczania im danych
itp. Gdyby każdy aplet był wykonywany we własnym, odrębnym procesie, współpraca wszystkich
elementów byłaby niemożliwa. Co więcej, umieszczenie apletu we własnej przestrzeni adreso-
wej w żaden sposób nie utrudniłoby temu apletowi wykradania czy zniekształcania danych —
przeciwnie, szkodliwa działalność apletu okazałaby się wręcz łatwiejsza, ponieważ w tym modelu
nikt nie podejrzewałby go o złe zamiary.
Przez lata proponowano i implementowano wiele różnych metod obsługi apletów (i ogólnie
kodu mobilnego). W poniższych podpunktach przeanalizujemy dwie takie metody: izolowanie
i interpretację. Alternatywnym rozwiązaniem jest podpisywanie kodu, aby można było skutecznie
weryfikować źródła apletów. Każda z tych metod ma swoje zalety i wady.
Izolowanie
Pierwsza metoda, nazywana izolowaniem (ang. sandboxing), próbuje zamykać każdy aplet w ogra-
niczonym przedziale adresów wirtualnych, wyznaczanym w czasie wykonywania programu
[Wahbe et al., 1993]. W tym celu należy podzielić przestrzeń adresów wirtualnych na obszary
równej wielkości określane mianem piaskownic (ang. sandboxes). Każda piaskownica musi
spełniać warunek wspólnego łańcucha w górnych bitach adresów; np. 32-bitową przestrzeń adre-
sową można podzielić na 256 piaskownic po 16 MB, aby wszystkie adresy w ramach jednej pia-
skownicy miały wspólne górne 8 bitów. Równie dobrze można by wyodrębnić 512 piaskownic
po 8 MB, z których każda będzie zawierała adres ze wspólnym 9-bitowym przedrostkiem.
Rozmiar piaskownicy należy wybrać w taki sposób, aby mieściła największy aplet, ale też aby nie
tracić zbyt dużej części przestrzeni adresów wirtualnych. Jeśli w danym systemie stosuje się
mechanizm stronicowania na żądanie (ang. demand paging), co jest dzisiaj normą, dostępność
pamięci fizycznej zwykle nie stanowi problemu. Każdy aplet otrzymuje dwie piaskownice —
jedną dla kodu i jedną dla danych. Na rysunku 9.29(a) przedstawiono scenariusz, w którym
mamy do czynienia z 16 piaskownicami po 16 MB każda.
Rysunek 9.29. (a) Pamięć podzielona na 16-megabajtowe piaskownice; (b) jeden ze sposobów
sprawdzania poprawności rozkazów
Interpretacja
Drugim sposobem bezpiecznego wykonywania niesprawdzonych apletów jest korzystanie z inter-
pretera i uniemożliwianie im przejmowania faktycznej kontroli nad sprzętem. Właśnie takie
rozwiązanie stosują współczesne przeglądarki internetowe. Aplety umieszczane na stronach
internetowych często są pisane albo w Javie, czyli normalnym języku programowania, albo
w wysokopoziomowym języku skryptowym, jak Safe-TCL czy JavaScript. Aplety Javy są najpierw
kompilowane do języka maszyny wirtualnej ze stosem nazywanej wirtualną maszyną Javy (ang.
Java Virtual Machine — JVM). Właśnie wirtualna maszyna Javy odpowiada za wykonywanie
apletów umieszczonych na stronie internetowej. Po pobraniu aplet jest umieszczany w interprete-
rze wirtualnej maszyny Javy pracującym w ramach danej przeglądarki (patrz rysunek 9.30).
p = rand();
*p = 0;
}
Funkcja w tej formie generuje liczbę losową i zapisuje ją we wskaźniku p, po czym zapisuje
bajt zerowy pod adresem wskazywanym przez p, nadpisując odpowiedni bajt pamięci (zawierający
kod lub dane). W Javie podobne konstrukcje mieszające różne typy danych są zabronione i odrzu-
cane przez mechanizmy weryfikujące zgodność kodu z regułami gramatycznymi. Co więcej, Java
nie oferuje zmiennych wskaźnikowych, rzutowania typów, możliwości samodzielnego zarzą-
dzania pamięcią (nie udostępnia funkcji malloc ani free), a wszystkie odwołania do tablic są
weryfikowane w czasie wykonywania programu.
Programy Javy są kompilowane do pośredniego kodu binarnego określanego mianem kodu
bajtowego wirtualnej maszyny Javy (ang. JVM byte code). Wirtualna maszyna Javy obsługuje
około 100 rozkazów, z których większość odpowiada za umieszczanie obiektów określonych
typów na stosie, zdejmowanie obiektów ze stosu i wykonywanie działań arytmetycznych na
obiektach składowanych na stosie. Programy wirtualnej maszyny Javy (JVM) zwykle są inter-
pretowane, choć w pewnych przypadkach mogą być kompilowane do kodu języka maszynowego,
aby przyspieszyć ich wykonywanie. W modelu Javy aplety wysyłane za pośrednictwem inter-
netu (z myślą o zdalnym wykonaniu) są właśnie programami maszyny JVM.
Po pobraniu apletu jego kod bajtowy jest analizowany przez weryfikator wirtualnej maszyny
Javy pod kątem spełniania określonych reguł. Prawidłowo skompilowany aplet będzie te reguły
spełniał automatycznie, jednak nie można wykluczyć sytuacji, w której ktoś napisze aplet wirtu-
alnej maszyny Javy w jej języku maszynowym. Proces weryfikacji ma na celu sprawdzenie nastę-
pujących aspektów:
1. Czy aplet próbuje fałszować wskaźniki?
2. Czy aplet narusza ograniczenia dostępu do składowych prywatnych klas?
3. Czy aplet próbuje wykorzystywać zmienne jednego typu jako zmienne innego typu?
4. Czy aplet generuje przepełnienia lub niedopełnienia stosu?
5. Czy aplet próbuje wykonywać zabronione konwersje pomiędzy typami?
Jeśli aplet przejdzie wszystkie te testy, będzie go można bezpiecznie wykonać bez obawy przed
nieuprawnionym dostępem do pamięci spoza wyznaczonego obszaru.
Aplety mogą jednak wykonywać wywołania systemowe za pośrednictwem odpowiednich metod
(procedur) Javy. Sposób obsługi wywołań systemowych stosowanych w kodzie Javy przez lata
ewoluował. W pierwszej wersji Javy aplety pakietu JDK 1.0 (od ang. Java Development Kit)
podzielono na dwie klasy: zaufane i niegodne zaufania. Aplety ładowane z lokalnego dysku, które
traktowano jako programy zaufane, mogły wykonywać dowolne wywołania systemowe. Aplety
pobierane za pośrednictwem internetu uważano za niegodne zaufania i jako takie były urucha-
miane w piaskownicach (patrz rysunek 9.30), które nie dawały im niemal żadnych praw.
Po zebraniu pewnych doświadczeń związanych z funkcjonowaniem tego modelu firma Sun
uznała, że jest zbyt restrykcyjny. W pakiecie JDK 1.1 zastosowano mechanizm podpisywania
kodu. Kiedy pobierano aplet za pośrednictwem internetu, sprawdzano, czy został podpisany
przez osobę albo organizację, do której dany użytkownik miał zaufanie (według sporządzonej
przez tego użytkownika listy zaufanych autorów). Jeśli tak, aplet mógł podejmować dowolne
działania. Jeśli nie, był uruchamiany w piaskownicy i podlegał daleko idącym ograniczeniom.
Dalsze doświadczenia wykazały jednak, że także ten model jest wysoce niedoskonały, zatem
zdecydowano się na ponowną zmianę. W pakiecie JDK 1.2 wprowadzono możliwość konfigu-
Jeden z rodzajów działań umożliwia dostęp do plików. Działanie może wskazywać na kon-
kretny plik lub katalog, zbiór plików w danym katalogu lub zbiór wszystkich plików i katalo-
gów składowanych w danym katalogu (i przeszukiwanych rekurencyjnie). Trzy kolejne wiersze
w tabeli 9.3 reprezentują każdy z tych trzech przypadków. W pierwszym wierszu użytkow-
niczka zuzanna tak ustawiła uprawnienia dostępu do pliku 1040.xls, aby aplety pochodzące z ser-
wera firmy pomagającej jej w rozliczeniach z fiskusem (z domeny www.taxprep.com) i podpisane
przez tę firmę mogły odczytywać zawartość tego pliku. Plik 1040.xls jest dostępny do odczytu
tylko dla apletów tej firmy. Aplety z pozostałych źródeł (podpisane lub nie) mogą natomiast
odczytywać i zapisywać pliki w katalogu /usr/tmp.
Co więcej, ten sam użytkownik na tyle ufa firmie Microsoft, że zezwala apletom pochodzącym
z witryny Microsoftu i podpisane przez tę firmę na odczyt, zapis oraz usuwanie wszystkich plików
poniżej katalogu Office, aby mogły np. usuwać usterki i instalować nowe wersje pakietu biuro-
wego. Aby móc weryfikować sygnatury, użytkownik musi albo dysponować niezbędnymi klu-
czami publicznymi na swoim dysku, albo pobierać te klucze dynamiczne (np. w formie certyfika-
tów podpisanych przez zaufaną firmę).
Pliki nie są jedynymi zasobami, które można chronić w ten sposób. Przedmiotem ochrony
może być także dostęp sieciowy. W takim przypadku funkcję obiektów pełnią konkretne porty
i komputery. Każdy komputer jest reprezentowany przez adres IP lub nazwę domeny DNS;
porty na tym komputerze są reprezentowane przez przedziały liczb. Do możliwych działań
należą żądania nawiązania połączenia ze zdalnym komputerem lub akceptacji połączeń nawią-
zywanych przez zdalny komputer. Oznacza to, że aplet może dysponować połączeniem siecio-
wym, ale nie może się komunikować z komputerami spoza zdefiniowanej listy. W razie potrzeby
aplety mogą co prawda dynamicznie ładować dodatkowy kod (klasy), jednak mechanizmy łado-
wania wskazane przez użytkownika mogą precyzyjnie kontrolować pochodzenie tych klas.
Platforma Javy oferuje też wiele innych elementów podnoszących bezpieczeństwo oprogra-
mowania pisanego w tym języku.
9.12. PODSUMOWANIE
9.12.
PODSUMOWANIE
Komputery często zawierają cenne i poufne dane, w tym zeznania podatkowe, numery kart kre-
dytowych, plany biznesowe, tajemnice handlowe i wiele innych. Właściciele tych komputerów
zwykle są żywo zainteresowani zachowaniem owych danych w tajemnicy i ich ochroną przed
nieuprawnionymi modyfikacjami, co z kolei przekłada się na konkretne wymagania w zakresie
bezpieczeństwa formułowane względem systemów operacyjnych. Ogólnie rzecz biorąc, bez-
pieczeństwo systemu jest odwrotnie proporcjonalne do wielkości zaufanej bazy obliczeniowej.
Podstawowym składnikiem zabezpieczeń systemów operacyjnych jest kontrola dostępu do
zasobów. Prawa dostępu do informacji można zamodelować w formie dużej macierzy z wierszami
reprezentującymi dziedziny (użytkowników) oraz kolumnami reprezentującymi obiekty (np.
pliki). Każda komórka takiej macierzy opisuje prawa dostępu odpowiedniej domeny do odpowied-
niego obiektu Ponieważ opisywana macierz jest dość rozległa, można ją składować wierszami
(wówczas ma postać listy uprawnień poszczególnych domen) lub kolumnami (wówczas staje
się listą kontroli dostępu określającą, kto może uzyskiwać dostęp do kolejnych obiektów). Za
pomocą formalnych technik modelowania można efektywnie opisywać i ograniczać przepływ
informacji. Okazuje się jednak, że nawet wówczas istnieje ryzyko wycieków z wykorzystaniem
ukrytych kanałów (np. poprzez modulowanie użycia procesora).
Jednym ze sposobów utrzymywania informacji w sekrecie jest ich szyfrowanie i ostrożne
posługiwanie się kluczami. Systemy kryptograficzne można zakwalifikować do dwóch grup:
z kluczem tajnym lub z kluczem publicznym. Metoda z kluczem tajnym wymaga od komuniku-
jących się stron wymiany klucza tajnego z góry, za pomocą jakiegoś zewnętrznego mechanizmu.
Kryptografia z kluczem publicznym nie wymaga potajemnej wymiany klucza z góry, ale stoso-
wanie tej techniki oznacza zdecydowanie niższą wydajność. Czasami zachodzi konieczność
udowodnienia prawdziwości informacji cyfrowych. W takim przypadku można wykorzystać skróty
kryptograficzne, podpisy cyfrowe i certyfikaty podpisane przez zaufane urzędy certyfikacji.
Każdy bezpieczny system musi dysponować mechanizmami uwierzytelniania użytkowni-
ków. Do uwierzytelniania można wykorzystywać to, co użytkownicy wiedzą, to, czym użyt-
kownicy dysponują, lub to, kim użytkownicy są (korzystając z technik biometrycznych). Można
zastosować dwie metody identyfikacji jednocześnie — łączne wykorzystanie takich technik jak
identyfikacja tęczówki i hasło może znacznie podnieść bezpieczeństwo.
Istnieje wiele typów błędów programistycznych, które mogą być z powodzeniem wykorzy-
stywane do przejmowania kontroli nad programami i systemami. Należą do nich przepełnienia
buforów, ataki na łańcuchy formatujące, ataki powrotu do biblioteki libc, ataki bazujące na odwo-
łaniach do pustego wskaźnika, próby przepełnienia liczb całkowitych, ataki poprzez wstrzyki-
wanie kodu i ataki TOCTOU. Podobnie istnieje wiele środków zaradczych stosowanych w celu
przeciwdziałania eksploitom wykorzystującym luki wymienione powyżej. Przykładem są kanarki
stosu oraz techniki zapobiegania wykonywaniu danych (DEP) i losowego generowania układu
przestrzeni adresowej (ang. address-space layout randomization).
Na bezpieczeństwo systemów komputerowych fatalny wpływ mogą mieć osoby z wewnątrz,
np. pracownicy danej firmy. Ataki tego rodzaju mogą przybierać bardzo różne formy. Niektórzy
decydują się na instalowanie bomb logicznych, które mają wybuchać w przyszłości, pozostawia-
nie tzw. tylnych drzwi, które zapewnią im nieuprawniony dostęp do zasobów w przyszłości, lub
przygotowywanie fałszywych ekranów logowania.
W internecie aż roi się od złośliwego oprogramowania, w tym koni trojańskich, wirusów,
robaków, oprogramowania szpiegującego i rootkitów. Każdy z wymienionych typów złośliwego
PYTANIA
1. Trzy składniki bezpieczeństwa to poufność, integralność i dostępność. Opisz aplikację,
która wymaga integralności i dostępności, ale nie wymaga poufności, aplikację wymagają-
cą zachowania poufności i integralności, ale nie (zbyt dużej) dostępności oraz aplikację
wymagającą poufności, integralności i dostępności.
2. Jedną z technik budowy bezpiecznego systemu operacyjnego jest minimalizowanie roz-
miarów bazy zaufanej bazy obliczeniowej (TCB). Która z wymienionych poniżej funkcji
musi być implementowana wewnątrz bazy TCB, a które mogą być implementowane poza
TCB: (a) przełączenie kontekstu procesów; (b) czytanie plików z dysku; (c) dodawanie
większego obszaru wymiany; (d) słuchanie muzyki; (e) pobieranie współrzędnych GPS
smartfona.
3. Co to jest ukryty kanał (ang. covert channel)? Jakie są podstawowe wymagania dla istnie-
nia ukrytych kanałów?
4. W pełnej macierzy kontroli dostępu wiersze odpowiadają domenom, a kolumny obiektom.
Co się stanie, jeśli jakiś obiekt będzie potrzebny w dwóch domenach?
5. Przypuśćmy, że jakiś system w pewnym momencie obejmuje 5000 obiektów i 100 domen.
Zaledwie 1% tych obiektów jest dostępny dla wszystkich domen (z możliwością wyko-
nywania różnych kombinacji operacji r, w oraz x); 10% obiektów jest dostępnych dla
dwóch domen; pozostałe 89% obiektów jest dostępnych tylko dla jednej domeny. Przy-
puśćmy, że składowanie samych informacji o pojedynczym uprawnieniu dostępu (czyli
pewnej kombinacji praw r, w oraz x) wymaga jednej jednostki przestrzeni pamięciowej.
Tyle samo miejsca zajmuje zarówno identyfikator obiektu, jak i identyfikator domeny.
Ile przestrzeni potrzeba do składowania pełnej macierzy ochrony, macierzy ochrony w for-
mie listy kontroli dostępu (ACL) oraz macierzy ochrony w formie listy uprawnień?
6. Wyjaśnij, która implementacja macierzy ochrony jest bardziej odpowiednia dla następu-
jących operacji:
(a) Przyznanie dostępu do odczytu pliku wszystkim użytkownikom.
(b) Odebranie dostępu do zapisu pliku wszystkim użytkownikom.
(c) Udzielanie dostępu do zapisu pliku Janowi, Leokadii, Krystynie i Jerzemu.
(d) Odebranie prawa do wykonania pliku Janinie, Michałowi, Monice i Stefanii.
7. W tym rozdziale omówiliśmy dwie formy reprezentowania reguł na potrzeby mechanizmów
ochrony: listy uprawnień oraz listy kontroli dostępu. Dla każdego z opisanych poniżej
scenariuszy wskaż mechanizmy, których należałoby użyć.
(a) Krzysztof chce udostępnić swoje pliki do odczytu wszystkim użytkownikom poza
kolegą z pokoju.
(b) Michał i Sebastian chcą wzajemnie udostępniać sobie pewne poufne pliki.
(c) Lidia chce udostępnić publicznie część swoich plików.
8. Spróbuj sporządzić macierz ochrony reprezentującą relacje własności i uprawnienia dostępu
do plików katalogu systemu UNIX, którego zawartość pokazano na poniższym listingu.
Uwaga: użytkownik asw jest członkiem dwóch grup, users i devel; użytkownik gmw jest
członkiem grupy users). Każdy użytkownik i każda grupa powinny być reprezentowane
przez odrębną domenę, zatem gotowa macierz powinna się składać z czterech wierszy
(po jednym dla każdej domeny) i czterech kolumn (po jednej dla każdego pliku).
– rw– r– – r– – 2 gmw users 908 May 26 16:45 PPP– Notes
– rwx r– x r– x 1 asw devel 432 May 13 12:35 prog1
– rw– rw– – – – 1 asw users 50094 May 30 17:51 project.t
– rw– r– – – – – 1 asw devel 13124 May 31 14:30 splash.gif
9. Spróbuj wyrazić uprawnienia dostępu do plików składowanych w naszym przykładowym
katalogu (patrz poprzednie pytanie) w formie listy kontroli dostępu.
10. Zmodyfikuj listę kontroli dostępu z poprzedniego pytania dla jednego pliku w celu udzie-
lenia lub odebrania dostępu, który nie może być wyrażony za pomocą uniksowego sys-
temu rwx. Wyjaśnij wprowadzoną zmianę.
11. Załóżmy, że istnieją trzy poziomy zabezpieczeń: 1, 2 i 3. Obiekty A i B są na poziomie
1., C i D — na poziomie 2., a E i F — na poziomie 3. Procesy 1. i 2. są na poziomie 1., 3.
i 4. — na poziomie 2., a 5. i 6. — na poziomie 3. Określ, czy każda z poniższych operacji
jest dopuszczalna zgodnie z modelem Bella-La Paduli, modelem Biby lub obydwoma
tymi modelami.
(a) Proces 1. zapisuje obiekt D.
(b) Proces 4. odczytuje obiekt A.
(c) Proces 3. odczytuje obiekt C.
(d) Proces 3. zapisuje obiekt C.
(e) Proces 2. odczytuje obiekt D.
(f) Proces 5. odczytuje obiekt F.
(g) Proces 6. odczytuje obiekt E.
(h) Proces 4. zapisuje obiekt E.
(i) Proces 3. odczytuje obiekt F.
12. W schemacie zabezpieczeń systemu Amoeba użytkownik może zażądać od serwera
wygenerowania nieznacznie węższych uprawnień, które będzie można przekazać zaprzy-
jaźnionym użytkownikom. Co będzie, jeśli zaprzyjaźniony użytkownik zażąda od serwera
usunięcia dodatkowych uprawnień, aby samemu móc przekazać ten plik dalej (jeszcze
innemu zaprzyjaźnionemu użytkownikowi)?
13. Na rysunku 9.9 nie naniesiono strzałki prowadzącej od procesu B do obiektu 1. Czy taka
strzałka byłaby dopuszczalna? Jeśli nie, jaką regułę naruszylibyśmy, nanosząc ją na wspo-
mniany rysunek?
14. Gdyby w modelu pokazanym na rysunku 9.9 było możliwe przekazywanie komunikatów
bezpośrednio pomiędzy procesami, jakie reguły należałoby stosować dla tych procesów?
Do których procesów mógłby wysyłać swoje komunikaty np. proces B?
15. Przeanalizuj ponownie system steganograficzny z rysunku 9.12. Każdy piksel może być
reprezentowany jako punkt przestrzeni kolorów, czyli w trójwymiarowym systemie
wyznaczanym przez osie R, G i B. Spróbuj wyjaśnić, co dzieje się z tą przestrzenią barw
w momencie zastosowania techniki steganografii w obrazie.
16. Spróbuj złamać następujący szyfr monoalfabetyczny. Oryginalny, niezaszyfrowany tekst
składał się z samych liter łacińskich (bez polskich znaków) i zawierał początek znanego
wiersza Adama Mickiewicza:
uht zayglshj upl rhghuv dzahwpslt uh kgphsv
p zwvqyghslt uh wvsl kdplzjpl hytha nygtphsv
hyafslyfp ybzrplq jphnuh zpl zglylnp
wyvzav ksbnv khslrv qhrv tvygh iyglnp
p dpkgphslt pjo dvkgh wygfiplns tpljglt zrpuhs
p qhr wahr qlkuv zrygfksv dvqzrh zdlnv gdpuhs
dfsldh zpl zwvk zrygfksh zjpzupvuh wpljovah
ksbnh jghyuh rvsbtuh qhrv shdh isvah
uhzfwhuh pzryhtp ihnulavd qhr zlwf
jghyul jovyhndpl uh ztplyj wyvdhkgh ghzalwf
17. Wyobraź sobie szyfr z kluczem tajnym i macierzą 26×26 z kolumnami oznaczonymi
etykietami A, B, C, …, Z oraz wierszami oznaczonymi etykietami A, B, C, …, Z. Tekst
jawny jest szyfrowany w taki sposób, że w każdym kroku koduje się dwa znaki. Pierwszy
znak jest reprezentowany przez kolumnę, drugi przez wiersz. Komórka na przecięciu tej
kolumny i wiersza zawiera dwa zaszyfrowane znaki. Jakie warunki musiałaby spełniać
taka macierz i ile kluczy zawiera?
18. Rozważmy następujący sposób szyfrowania pliku. Algorytm szyfrowania używa dwóch
n-bajtowych tablic, A i B. Pierwsze n bajtów jest odczytywanych z pliku do tablicy A.
Wtedy element A[0] jest kopiowany do B[i], A[1] jest kopiowany do B[j], A[2] jest kopio-
wany do B[k] itd. Po skopiowaniu wszystkich n bajtów do tablicy B tablica ta jest zapi-
sywana do pliku wyjściowego, a do tablicy A odczytywanych jest kolejnych n bajtów.
Taka procedura jest powtarzana, aż cały plik zostanie zaszyfrowany. Zwróćmy uwagę,
że szyfrowanie nie odbywa się tu poprzez zastąpienie znaków innymi, ale poprzez zmianę
ich kolejności. Ile kluczy trzeba wypróbować, aby w wyczerpujący sposób przeszukać
przestrzeń kluczy? Na czym polega przewaga tego systemu nad szyfrem z podstawianiem
monoalfabetycznym?
19. Szyfrowanie z kluczem tajnym jest bardziej efektywne niż szyfrowanie z kluczem publicz-
nym, ale wymaga od nadawcy i odbiorcy wcześniejszego uzgodnienia i zachowania w tajem-
nicy tego klucza. Przypuśćmy, że nadawca i odbiorca nigdy się nie spotkali, ale mają kon-
takt z zaufaną stroną trzecią, która udostępnia jeden klucz tajny nadawcy i drugi (inny)
odbiorcy. Jak w takim przypadku nadawca i odbiorca mogą uzgodnić jeden wspólny klucz
tajny?
20. Podaj prosty przykład funkcji matematycznej, która przynajmniej na pierwszy rzut oka
sprawia wrażenie funkcji jednokierunkowej.
21. Przypuśćmy, że dwa podmioty A i B, które nie mają ze sobą bezpośredniego kontaktu
i nie znają się osobiście, chcą się ze sobą komunikować z wykorzystaniem kryptografii
z kluczem tajnym, ale nie chcą dzielić tego samego klucza. Przypuśćmy, że oba podmioty
mają zaufanie do trzeciego podmiotu C, którego klucz publiczny jest powszechnie znany.
Jak w takim przypadku dwa nieznane sobie podmioty mogą uzgodnić jeden wspólny
klucz tajny?
22. Ponieważ kafejki internetowe są coraz bardziej powszechne, ludzie chcą mieć możli-
wość pójścia do dowolnej kafejki na świecie i prowadzenia z niej swojej działalności biz-
nesowej. Opisz sposób tworzenia podpisanych dokumentów w takiej kafejce przy użyciu
karty inteligentnej (załóżmy, że wszystkie komputery są wyposażone w czytniki kart
inteligentnych). Czy ten system jest bezpieczny?
23. Tekst języka naturalnego zapisany w standardzie ASCII można skompresować o co
najmniej 50%, dzięki zastosowaniu rozmaitych algorytmów kompresji. Skoro wiesz już,
jakie są możliwości kompresji, oblicz, ile tekstu ASCII (w bajtach) można zmieścić
w mniej znaczących (dolnych) bitach obrazu o wymiarach 1600×1200. O ile zwiększy się
rozmiar obrazu wskutek zastosowania tej techniki (przy założeniu, że nie zastosowano
technik szyfrowania, które dodatkowo zwiększyłyby objętość ukrytych danych)? Jaka
jest efektywność tego mechanizmu (rozumiana jako stosunek ilości ukrywanych infor-
macji do łącznej liczby przesyłanych bajtów)?
24. Przypuśćmy, że ściśle powiązana grupa dysydentów żyjących w kraju pod rządami dykta-
tora wykorzystuje technikę steganografii do wysyłania za granicę komunikatów o warun-
kach życia i panujących nastrojach. Rząd walczy z tym zjawiskiem, wysyłając fałszywe
obrazy ze spreparowanymi komunikatami steganograficznymi. Jak dysydenci mogą
ułatwić odbiorcom tych komunikatów odróżnienie prawdziwych przekazów od fałszywych?
25. Odwiedź stronę www.cs.vu.nl/~ast i kliknij łącze covered writing. Postępuj zgodnie z instruk-
cjami, aby uzyskać tekst dzieł Szekspira zawarty w obrazie. Odpowiedz na następujące
pytania:
(a) Jakie są rozmiary plików original-zebras.bmp i zebras.bmp?
(b) Które sztuki Szekspira potajemnie zakodowano w pliku zebras.bmp?
(c) Ile bajtów potajemnie zapisano w tym pliku?
26. Brak jakichkolwiek wyświetlanych znaków jest lepszy od wyświetlanych gwiazdek, ponie-
waż gwiazdki zdradzają długość hasła, która może być cennym ułatwieniem dla osób
zaglądających użytkownikowi przez ramię. Jeśli przyjąć, że hasła składają się wyłącznie
z wielkich liter, małych liter i cyfr oraz że każde hasło musi się składać z co najmniej
pięciu i maksymalnie ośmiu znaków, o ile bezpieczniejszy będzie schemat bez wyświe-
tlania gwiazdek?
27. Wyobraź sobie, że bezpośrednio po zakończeniu nauki starasz się o pracę w roli kie-
rownika wielkiego uniwersyteckiego centrum komputerowego, które właśnie zrezygno-
wało z przestarzałego systemu obejmującego jeden komputer wielkiej mocy na rzecz
rozbudowanego serwera sieci LAN pracującego pod kontrolą systemu operacyjnego
UNIX. Zaledwie piętnaście minut po rozpoczęciu pracy Twój asystent wpada do biura,
krzycząc: „Jacyś studenci odkryli i umieścili w internecie algorytm, którego używaliśmy
do szyfrowania haseł!”. Co powinieneś zrobić?
28. Schemat ochrony opracowany przez Morrisa i Thompsona z n-bitowymi liczbami loso-
wymi (solą) ma na celu utrudnienie intruzowi odkrycia haseł poprzez wcześniejsze zaszy-
frowanie listy popularnych haseł. Czy przytoczony schemat chroni system także przed
intruzem próbującym odgadnąć hasło superużytkownika danego komputera? Zakładamy,
że plik haseł jest dostępny do odczytu.
29. Przypuśćmy, że plik haseł w danym systemie jest dostępny dla krakera. Ile czasu kra-
ker będzie potrzebował do złamania wszystkich haseł reprezentowanych w tym pliku,
jeśli atakowany system stosuje schemat ochrony Morrisa-Thompsona z n-bitową solą,
a ile czasu zajmie złamanie haseł w systemie bez tego schematu?
30. Wymień trzy cechy dobrego wskaźnika biometrycznego wykorzystywanego podczas uwie-
rzytelniania użytkowników systemu komputerowego.
31. Mechanizmy uwierzytelniania można podzielić na trzy kategorie: coś, co użytkownik
wie, coś, co użytkownik ma, i coś, czym użytkownik jest. Wyobraź sobie system uwie-
rzytelniania, który wykorzystuje kombinację mechanizmów należących do tych trzech
kategorii. Przykładowo najpierw prosi użytkownika o wprowadzenie identyfikatora logo-
wania i hasła, następnie o włożenie karty plastikowej (z paskiem magnetycznym), wpro-
wadzenie kodu PIN i na koniec zeskanowanie odcisku palca. Czy potrafisz wymienić
dwie wady tego projektu?
32. Wydział informatyki pewnej uczelni dysponuje siecią lokalną złożoną z ogromnej liczby
komputerów pracujących pod kontrolą systemu operacyjnego UNIX. Użytkownicy każ-
dego z tych komputerów mogą wykonać następujące polecenie:
rexec machine4 who
39. Usunięcie pliku zwykle powoduje ponowne umieszczenie skojarzonych z nim blokad na
liście wolnych blokad, ale nie powoduje ich usunięcia. Czy Twoim zdaniem nie byłoby
lepiej, gdyby system operacyjny usuwał blokady przed ich zwalnianiem? Przeanalizuj
wpływ tego rozwiązania na bezpieczeństwo i wydajność systemu.
40. Skąd wirus pasożytniczy (a) wie, że zostanie wykonany przed swoim programem-żywi-
cielem, oraz jak (b) przekazuje sterowanie do swojego żywiciela po wykonaniu własnych
działań?
41. Niektóre systemy operacyjne wymagają takiego podziału na partycje, aby początek każ-
dej partycji znajdował się na początku ścieżki. Na ile taki model ułatwia zadanie wirusom
sektora startowego?
42. Spróbuj tak zmienić program z listingu 9.6, aby odnajdywał wszystkie pliki z kodem źró-
dłowym języka C (zamiast plików wykonywalnych).
43. Na rysunku 9.25(d) pokazano schemat zaszyfrowanego wirusa. Jak specjalista zatrud-
niony w laboratorium producenta oprogramowania antywirusowego może stwierdzić,
która część zainfekowanego pliku zawiera klucz, aby na jego podstawie odszyfrować kod
wirusa i poddać go dalszej analizie? Co może zrobić Wirgiliusz, aby utrudnić im pracę?
44. Wirus pokazany na rysunku 9.25(c) obejmuje zarówno procedurę kompresującą, jak i kod
dekompresujący. Procedura dekompresująca jest niezbędna do uzyskania i uruchomienia
skompresowanego kodu. Do czego służy procedura kompresująca?
45. Wskaż największą wadę wirusów polimorficznych z szyfrowaniem z perspektywy twórcy
wirusów.
46. Wielu użytkowników sądzi, że w razie ataku wirusa na ich system komputerowy należy
przeprowadzić następującą procedurę:
1. Uruchomić zainfekowany system.
2. Sporządzić kopię zapasową wszystkich plików na zewnętrznym nośniku.
3. Uruchomić program fdisk (lub podobny) w celu sformatowania dysku.
4. Ponownie zainstalować system operacyjny z oryginalnej płyty CD-ROM.
5. Skopiować pliki zabezpieczone na zewnętrznym nośniku.
Wskaż dwa poważne niedociągnięcia w powyższej sekwencji kroków.
47. Czy w systemie UNIX jest możliwe stosowanie tzw. wirusów towarzyszących (czyli takich,
które nie modyfikują istniejących plików)? Jeśli tak, jak to robić? Jeśli nie, dlaczego?
48. Do dostarczania programów i aktualizacji często wykorzystuje się archiwa samorozpa-
kowujące, które zawierają nie tylko jeden skompresowany plik lub wiele takich plików,
ale też program dekompresujący. Opisz wpływ tej techniki na bezpieczeństwo syste-
mów komputerowych.
49. Dlaczego rootkity, w przeciwieństwie do wirusów i robaków, są niezmiernie trudne lub
prawie niemożliwe do wykrycia?
50. Czy maszynę zainfekowaną rootkitem można przywrócić do dobrej kondycji poprzez wyco-
fanie stanu oprogramowania do wcześniej zapisanego punktu przywracania systemu?
51. Opisz możliwości w zakresie pisania programów otrzymujących na wejściu inne pro-
gramy i określających, czy te programy zawierają wirusy.
52. W punkcie 9.10.1 opisano zbiór reguł firewalla ograniczających dostęp z zewnątrz do
zaledwie trzech usług. Spróbuj sporządzić inny zbiór reguł, które będzie można zdefi-
niować w tym samym firewallu w celu dalszego ograniczenia dostępności tych usług.
53. Na niektórych komputerach rozkaz SHR wypełnia nieużywane bity zerami, jak na
rysunku 9.29(b); na innych komputerach bit znaku jest rozszerzany w prawo. Czy dla
poprawności schematu z rysunku 9.29(b) rodzaj stosowanej operacji przesunięcia ma
znaczenie? Jeśli tak, która operacja jest lepsza?
54. Aby umożliwić użytkownikom sprawdzenie, czy aplet został podpisany przez zaufanego
producenta, jego twórca może dołączyć do swojego produktu certyfikat podpisany przez
zaufane, niezależne centrum certyfikacji z użyciem jego klucza publicznego. Z drugiej
strony odczytanie certyfikatu wymaga od użytkownika znajomości klucza publicznego
stosowanego przez to centrum. Taki klucz może zostać dostarczony przez jeszcze jed-
nego, czwartego uczestnika tego procesu, który także wymaga weryfikacji. Wydaje się
więc, że zamknięcie tego systemu weryfikacji jest niemożliwe. Z drugiej strony przeglą-
darki z powodzeniem stosują ten mechanizm. Jak to możliwe?
55. Opisz trzy elementy, które powodują, że Java sprawdza się lepiej od C w roli języka
wykorzystywanego do pisania bezpiecznych programów.
56. Przyjmijmy, że Twój system korzysta z pakietu JDK 1.2. Spróbuj opisać reguły (w sposób
zbliżony do tych z tabeli 9.3) potrzebne do prawidłowego działania na Twoim kompute-
rze apletu z witryny www.appletsRus.com. Wspomniany aplet może pobierać dodatkowe
pliki z witryny www.appletsRus.com, odczytywać i zapisywać pliki w katalogu /usr/tmp/
oraz odczytywać pliki z katalogu /usr/me/appletdir.
57. Czym różnią się aplety od aplikacji? Jak ta różnica odnosi się do bezpieczeństwa?
58. Napisz dwa programy (w języku C lub w formie skryptów powłoki) odpowiednio wysy-
łające i odbierające komunikaty przez jakiś ukryty kanał w systemie operacyjnym UNIX
(Wskazówka: bit uprawnień jest widoczny nawet wtedy, gdy dany plik jest niedostępny
w żaden inny sposób, a polecenie lub wywołanie systemowe sleep gwarantuje określone
opóźnienie (zgodne z użytym argumentem). Spróbuj zmierzyć ilość przekazywanych
w ten sposób danych w bezczynnym systemie, po czym wygeneruj duże obciążenie (np.
uruchamiając wiele procesów działających w tle), aby zmierzyć przepustowość swojego
kanału w zmienionym otoczeniu.
59. Wiele systemów operacyjnych UNIX wykorzystuje do szyfrowania haseł algorytm DES.
System z tej grupy zwykle stosuje ten algorytm 25 razy dla każdego wiersza, aby uzy-
skać zaszyfrowane hasło. Pobierz implementację algorytmu DES z internetu, napisz
program szyfrujący jakieś hasło i sprawdź, czy plik haseł zawiera tak samo zaszyfrowany
łańcuch. Wygeneruj listę dziesięciu zaszyfrowanych haseł, stosując schemat ochrony
Morrisa-Thomsona. Użyj 16-bitowej soli.
60. Przypuśćmy, że system wykorzystuje listy kontroli dostępu (ACL) w roli reprezentacji
swojej macierzy ochrony. Napisz zbiór funkcji zarządzających tymi listami w odpowiedzi
na (1) utworzenie nowego obiektu; (2) usunięcie obiektu; (3) utworzenie nowej domeny;
(4) usunięcie domeny; (5) przyznanie domenie nowych uprawnień dostępu do jakiegoś
obiektu (w formie kombinacji r, w, x); (6) wycofanie istniejących praw dostępu domeny do
jakiegoś obiektu; (7) przyznanie nowych uprawnień w dostępie do jakiegoś obiektu wszyst-
kim domenom; (8) wycofanie istniejących uprawnień wszystkich domen w dostępie do
jakiegoś obiektu.
61. Napisz program opisany w punkcie 9.7.1, aby się przekonać, co się stanie, gdy nastąpi
przepełnienie bufora. Spróbuj poeksperymentować z ciągami różnych rozmiarów.
62. Napisz program emulujący wirusy nadpisujące opisane w punkcie 9.9.2, „Wirusy w pro-
gramach wykonywalnych”. Wybierz istniejący plik wykonywalny, o którym wiesz, że może
zostać nadpisany bez żadnych szkód. Do roli binariów wirusa wybierz jakiś nieszkodliwy
binarny plik wykonywalny.
715
i struktury danych są podobne, choć istnieją pewne różnice. Aby prezentowane przykłady były
możliwie konkretne, najlepiej wybrać jeden z tych systemów i w spójny, przemyślany sposób
opisać jego funkcjonowanie. Ponieważ większość Czytelników miała większe szanse kontaktu
z systemem Linux niż z pozostałymi odmianami Uniksa, wykorzystamy właśnie ten wariant w roli
przykładu ilustrującego całą rodzinę systemów operacyjnych. Istnieje wiele książek poświęconych
zarówno kwestiom korzystania z systemu UNIX, jak i zasadom funkcjonowania jego wewnętrz-
nych mechanizmów — [Love, 2013], [McKusick i Neville-Neil, 2004], [Nemeth et al., 2013],
[Ostrowick, 2013], [Sobell, 2014], [Stevens i Rago, 2013], [Vahalia, 2007].
UNIX i Linux mają długą, ciekawą historię, zatem rozpoczniemy ten rozdział właśnie od analizy
ich pochodzenia. To, co początkowo miało być osobistym projektem jednego młodego naukowca
(Kena Thompsona), przerodziło się w miliardowe przedsięwzięcie angażujące uniwersytety, mię-
dzynarodowe korporacje, rządy i międzynarodowe ciała standaryzacyjne. W kolejnych punktach
tego podrozdziału przyjrzymy się rozwojowi tej ciekawej historii.
10.1.1. UNICS
Wróćmy do lat czterdziestych i pięćdziesiątych ubiegłego wieku, kiedy wszystkie komputery były
osobiste w tym sensie, że ich używanie wymagało uzyskania dostępu np. na godzinę i polegało na
wyłącznym korzystaniu z ich mocy obliczeniowej w tym czasie. Mimo ogromnych rozmiarów
komputerów z tamtego okresu mogła z nich korzystać w danym momencie tylko jedna osoba
(programista). Kiedy w latach sześćdziesiątych pojawiły się systemy wsadowe (ang. batch systems),
programiści zyskali możliwość dostarczania operatorowi komputera gotowych programów na
kartach perforowanych. Po zebraniu odpowiedniej liczby zadań operator umieszczał je w jednym
wsadzie, którego przetworzenie do momentu uzyskania danych wynikowych zajmowało co
najmniej godzinę. W owym czasie debugowanie oprogramowania było tak czasochłonne, że
zaledwie jeden źle postawiony przecinek mógł skutkować stratą wielu godzin pracy programisty.
Aby obejść utrudnienia, które przez niemal wszystkich były postrzegane jako źródło niskiej
produktywności, ośrodki Dartmouth College oraz MIT opracowały techniki dzielenia czasu
komputerów.
System z Dartmouth, który oferował tylko możliwość wykonywania programów napisanych
w języku BASIC, osiągnął spory, choć krótkotrwały sukces komercyjny. System MIT nazwany
CTSS był bardziej uniwersalny i osiągnął wprost niespotykany sukces w świecie badań naukowych.
Niedługo potem naukowcy z MIT połączyli siły z inżynierami z ośrodków badawczych Bell Labs
i General Electric (ówczesnego producenta komputerów), aby wspólnie zaprojektować system
drugiej generacji nazwany MULTICS (od ang. MULTiplexed Information and Computing Service),
który omówiono już w rozdziale 1.
Chociaż ośrodek Bell Labs był jednym z założycieli projektu MULTICS, później się z niego
wycofał, co spowodowało, że jeden z naukowców Bell Labs, Ken Thompson, zaczął szukać
ciekawego tematu, którym mógłby się zająć. Ostatecznie zdecydował się w pojedynkę napisać
okrojoną wersję systemu MULTICS (tym razem w asemblerze) na przestarzałym minikomputerze
PDP-7. Mimo bardzo ograniczonych możliwości komputera PDP-7 system Thompsona działał
prawidłowo i spełniał jego oczekiwania. Jakiś czas potem inny pracownik Bell Labs, Brian Kerni-
ghan, żartobliwie nadał dziełu Thompsona nazwę UNICS (od ang. UNiplexed Information and
Computing Service). Mimo szyderstw z nowego systemu sugerujących, jakoby był zaledwie wyka-
strowanym systemem MULTICS (nazywano go nawet Eunuchs), nazwa zaproponowana przez
Kernighana przyjęła się, choć z czasem jej pisownię zmieniono na UNIX.
z dziełami Chaucera czy Szekspira [Lions, 1996]. W książce opisano wersję 6, ponieważ powstała
na podstawie szóstego wydania UNIX Programmer’s Manual. Kod źródłowy systemu UNIX obej-
mował 8200 wierszy kodu C oraz 900 wierszy kodu asemblera. Wskutek ogromnej aktywności
użytkowników, niezliczonych nowych pomysłów i udoskonaleń system zaczął się błyskawicznie
rozrastać.
Po kilku latach wersja 6 została zastąpiona wersją 7, czyli pierwszą przenośną wersją sys-
temu UNIX (działającą zarówno na komputerze PDP-11, jak i na komputerze Interdata 8/32). Nowa
wersja składała się z 18800 wierszy kodu języka C i 2100 wierszy kodu asemblera. Z wersją 7
miało kontakt całe pokolenie studentów, co szybko przełożyło się na wzrost popularności systemu
UNIX w przedsiębiorstwach (dzięki doskonałej opinii wśród trafiających tam absolwentów).
W połowie lat osiemdziesiątych ubiegłego wieku system UNIX był już powszechnie stosowany
na minikomputerach i stacjach roboczych najróżniejszych producentów. Wiele firm zdecydowało
się nawet na zakup licencji umożliwiających wydawanie własnych wersji tego systemu. Jednym
z tych przedsiębiorstw była niewielka, początkująca firma Microsoft, która (na wiele lat przed zmianą
profilu działalności) sprzedawała wersję 7 systemu UNIX pod nazwą XENIX.
i fizyczne przeniesienie taśmy magnetycznej na pierwsze piętro, aby sprawdzić, czy nowa wersja
działa prawidłowo. Po kilku miesiącach noszenia taśm nieznany pracownik firmy zapytał: „Skoro
jesteśmy wielkim przedsiębiorstwem telekomunikacyjnym, czy naprawdę nie można połączyć tych
dwóch komputerów jakimś przewodem?”. Właśnie tak narodziła się obsługa sieci w systemie
UNIX. Po przeniesieniu tego systemu na platformę Interdata przystąpiono do prac nad jego prze-
niesieniem na komputer VAX i inne komputery. Po podzieleniu firmy AT&T przez rząd Stanów
Zjednoczonych w roku 1984 przedsiębiorstwo zyskało możliwość legalnego wejścia na rynek
oprogramowania komputerowego, z której szybko skorzystało. Niedługo po podziale AT&T wydało
swój pierwszy komercyjny system UNIX nazwany System III. System nie został zbyt dobrze
przyjęty, zatem już rok później zastąpiono go poprawioną wersją nazwaną System V. Los Sys-
temu IV do dzisiaj należy do jednej z największych tajemnic informatyki.
Oryginalny System V został zastąpiony wydaniem drugim, trzecim i czwartym, z których
każde było większe i bardziej skomplikowane od swojego poprzednika. Z czasem zapomniano
o oryginalnej koncepcji stojącej za systemem UNIX, czyli idei stworzenia prostego, eleganckiego
systemu. Mimo że grupa Ritchiego i Thompsona dalej pracowała nad ósmym, dziewiątym
i dziesiątym wydaniem systemu UNIX, nowe wersje nigdy nie zyskały popularności, ponieważ
firma AT&T skoncentrowała swoje wysiłki na promowaniu Systemu V. Z drugiej strony część
rozwiązań zawartych w wymienionych wydaniach ostatecznie została wykorzystana w Syste-
mie V. Z czasem kierownictwo firmy AT&T zdecydowało, że korporacja powinna się koncen-
trować na usługach telekomunikacyjnych, nie na produkcji oprogramowania, co w 1993 roku
doprowadziło do sprzedaży praw do systemu UNIX firmie Novell. Novell odsprzedała produkt
firmie Santa Cruz Operation. Od tamtego czasu prawa własności do oryginalnego Uniksa nie mają
większego znaczenia, ponieważ wszystkie ważne firmy komputerowe dysponowały odpowiednimi
licencjami.
ment 1003.1 napisano w taki sposób, aby był zrozumiały zarówno dla programistów implemen-
tujących oba systemy operacyjne, jak i twórców oprogramowania, co w świecie standardów było
zupełną nowością (autorzy współczesnych standardów próbują iść tą samą drogą).
Mimo że standard 1003.1 opisuje tylko wywołania systemowe, istnieją dokumenty pokrewne
standaryzujące wątki, programy użytkowe, zasady obsługi sieci i wiele innych aspektów systemu
operacyjnego UNIX. Z czasem także język programowania C został objęty standardami organi-
zacji ANSI oraz ISO.
10.1.6. MINIX
Do nieodłącznych cech wszystkich współczesnych systemów UNIX należą ogromne rozmiary
i duża złożoność, co w pewnym sensie przeczy oryginalnej idei przyświecającej twórcom pierw-
szych systemów z tej rodziny. Nawet gdyby udostępniano za darmo kompletny kod źródłowy
systemu operacyjnego (co nie zawsze ma miejsce), nie ma wątpliwości, że jeden programista
miałby ogromne problemy ze zrozumieniem tego kodu w całości. Właśnie dlatego autor tej
książki zdecydował się napisać nowy system UNIX, który będzie na tyle mały, aby każdy mógł
go zrozumieć, którego kod źródłowy będzie powszechnie dostępny i który będzie można wyko-
rzystywać do celów edukacyjnych. System składał się z zaledwie 11 800 wierszy kodu języka C
oraz 800 wierszy kodu asemblera. Opisywany system, który wydano w 1987 roku, był funkcjonal-
nie niemal równoważny systemowi UNIX Version 7, czyli najbardziej popularnemu systemowi
instalowanemu na komputerach PDP-11 na wydziałach informatyki szkół wyższych.
MINIX był jednym z pierwszych systemów z rodziny UNIX, które zbudowano wokół pro-
jektu mikrojądra. Koncepcja mikrojądra sprowadza się do umieszczania w jądrze systemu ope-
racyjnego minimalnego zbioru funkcji, co przekłada się na większą niezawodność i efektywność
jądra. W systemie MINIX zarządzanie pamięcią i system plików zostały przeniesione na poziom
procesów użytkownika. Jądro odpowiadało niemal wyłącznie za przekazywanie komunikatów
pomiędzy tymi procesami. Składało się z 1600 wierszy kodu języka C oraz 800 wierszy asemblera.
Z przyczyn technicznych (związanych z wymogami architektury 8088) jądro systemu MINIX
obejmowało także sterowniki urządzeń wejścia-wyjścia, czyli dodatkowe 2900 wierszy kodu
języka C. System plików (5100 wierszy języka C) oraz menedżer pamięci (2200 wierszy języka C)
działały w formie dwóch odrębnych procesów użytkownika.
Mikrojądra mają tę przewagę nad systemami monolitycznymi, że ich kod jest nieporównanie
prostszy do zrozumienia i łatwiejszy w utrzymaniu z uwagi na modułową strukturę. Przeniesienie
kodu z jądra do trybu użytkownika podnosi bezpieczeństwo systemu, ponieważ awaria procesu
działającego w trybie użytkownika nie ma tak katastrofalnych skutków jak awaria komponentu
w trybie jądra. Największą wadą tego rozwiązania jest nieznacznie niższa wydajność wynikająca
z konieczności przełączania pomiędzy trybem użytkownika a trybem jądra. Wydajność to jednak
nie wszystko — wszystkie współczesne systemy UNIX oferują system X Window pracujący
w trybie użytkownika; zaakceptowano spadek wydajności, ponieważ takie rozwiązanie podnosi
modułowość systemu operacyjnego. Zupełnie inny model zastosowano w systemie Windows,
gdzie cały graficzny interfejs użytkownika (ang. Graphical User Interface — GUI) włączono do
jądra. Innymi popularnymi projektami realizującymi w owym czasie koncepcję mikrojądra były
systemy Mach [Accetta et al., 1986] i Chorus [Rozier et al., 1988].
Już po kilku miesiącach od wydania system MINIX stał się niemal przedmiotem kultu. Powstała
nawet specjalna grupa USENET (obecnie grupa Google) nazwana comp.os.minix i licząca ponad
40 tysięcy użytkowników. Wielu użytkowników zdecydowało się włączyć w rozwój systemu
poprzez pisanie dodatkowych poleceń i programów użytkowych, zatem MINIX szybko stał się
wspólnym projektem ogromnej rzeszy użytkowników (komunikujących się za pośrednictwem
internetu). MINIX był więc swoistym prototypem innych projektów realizowanych dużo później
z udziałem szerokiej społeczności. W 1997 roku, kiedy wydano wersję 2.0 systemu MINIX, jego
podstawowe elementy (obejmujące obsługę sieci) urosły do 62 200 wierszy kodu.
Około 2004 roku kierunek rozwoju systemu MINIX uległ radykalnej zmianie — tym razem
społeczność zaangażowana w jego tworzenie postawiła sobie za cel opracowanie wyjątkowo
niezawodnego systemu zdolnego do automatycznego naprawiania samego siebie (swoistego
samoleczenia) i prawidłowego funkcjonowania nawet w razie napotykania powtarzalnych błędów
w oprogramowaniu. Wskutek tych zmian idea systemu modułowego wprowadzona w systemie
UNIX Version 1 została znacznie rozszerzona w systemie MINIX 3.0. Niemal wszystkie ste-
rowniki urządzeń dla tego systemu udało się przenieść do przestrzeni użytkownika, gdzie każdy
sterownik jest wykonywany w formie odrębnego procesu. Rozmiar całego jądra błyskawicznie
spadł poniżej 4000 wierszy kodu, czyli poziomu umożliwiającego łatwe opanowanie przez zale-
dwie jednego programistę. W nowej wersji wprowadzono też wiele zmian w mechanizmach
wewnętrznych, aby podnieść ich odporność na błędy.
Co więcej, z czasem ponad 650 popularnych programów systemu UNIX przeniesiono do
systemu MINIX 3.0, w tym X Window System (czasem określany po prostu mianem X), w tym
rozmaite kompilatory (w tym gcc), edytory tekstu, oprogramowanie sieciowe, przeglądarki inter-
netowe i wiele innych. W przeciwieństwie do wcześniejszych wersji, które były tworzone przede
wszystkim z myślą o zastosowaniach edukacyjnych, począwszy od wersji 3.0, system MINIX
zyskał na użyteczności (ze szczególnym uwzględnieniem niezawodności). Nadrzędny cel jego
twórców można by streścić słowami: nigdy więcej nie sięgajmy do przycisku resetowania.
Do księgarni trafiło też trzecie wydanie książki [Tanenbaum i Woodhull, 2006] opisują-
cej nowy system, zawierającej kompletny kod źródłowy wraz ze szczegółową dokumentacją
(w dodatku). System MINIX stale ewoluuje i skupia wokół siebie aktywną społeczność użyt-
kowników. Ponieważ został przeniesiony na procesor ARM, stał się dostępny także dla syste-
mów wbudowanych. Więcej informacji na temat systemu MINIX można znaleźć na stronie
www.minix3.org. Z tej witryny można również pobrać darmową wersję systemu.
10.1.7. Linux
W pierwszych latach rozwijania systemu MINIX, w trakcie niekończących się dyskusji prowa-
dzonych w internecie wielu użytkowników wyrażało oczekiwanie (a często po prostu żądało)
większej liczby bardziej zaawansowanych funkcji. Autor konsekwentnie odmawiał, ponieważ
chciał zachować prostotę umożliwiającą kompletne opanowanie kodu tego systemu przez stu-
dentów w trakcie zaledwie semestru. Nieprzejednana postawa autora systemu drażniła wielu
użytkowników. W owym czasie system FreeBSD nie był dostępny, zatem rezygnacja z systemu
MINIX na rzecz tego produktu nie była możliwa. Po kilku latach fiński student Linus Torvalds
zdecydował się opracować inny klon Uniksa, nazwany Linux, który w założeniu miał być peł-
nowartościowym systemem produkcyjnym obejmującym wiele funkcji brakujących w systemie
MINIX. Pierwszą wersję Linuksa (oznaczoną numerem 0.01) wydano w roku 1991. Linux, który
opracowano na komputerze z systemem MINIX, czerpał wiele koncepcji z tego systemu, od
struktury drzewa źródeł po układ systemu plików. Linux był jednak raczej rozwiązaniem mono-
litycznym, nie projektem mikrojądra, ponieważ cały system operacyjny zawarto w jego jądrze.
Jego kod obejmował łącznie 9300 wierszy języka C oraz 950 wierszy asemblera, czyli niewiele
więcej niż kod systemu MINIX (także zbiory funkcji obu systemów początkowo były zbliżone).
Linux był więc przebudowanym systemem MINIX, jedynym, którego kod źródłowy był w tam-
tym czasie dostępny dla Torvaldsa.
System Linux szybko zaczął się rozrastać i ewoluował w kierunku kompletnego, produk-
cyjnego klonu systemu UNIX z takimi elementami jak pamięć wirtualna, bardziej wyszukany
system plików i wiele innych zaawansowanych funkcji. Mimo że oryginalna wersja działała tylko
na komputerze 386 (niektóre procedury języka C zawierały nawet kod asemblera ściśle związany
z tą rodziną procesorów), szybko przeniesiono system Linux na pozostałe platformy, zatem
obecnie działa na wielu różnych komputerach (podobnie jak system UNIX). Jeden aspekt różni
jednak Linuksa od systemu UNIX — Linux wykorzystuje wiele specjalnych funkcji kompilatora
gcc, zatem jego przystosowanie do kompilacji z użyciem standardowego kompilatora ANSI C
wymagałoby mnóstwo pracy. Krótkowzroczny pomysł, że gcc jest jedynym kompilatorem, który
kiedykolwiek zobaczy świat, już staje się problemem, ponieważ LLVM — kompilator open source
powstały na Uniwersytecie Illinois — szybko zyskuje zwolenników (głównie ze względu na
elastyczność i jakość kodu). Ponieważ LLVM nie obsługuje wszystkich niestandardowych roz-
szerzeń języka C, jakie są dostępne w gcc, nie można za jego pomocą skompilować jądra
Linuksa, nie wprowadzając przy tym wielu poprawek mających na celu zastąpienie kodu nie-
zgodnego ze standardem ANSI.
Kolejnym ważnym wydaniem systemu Linux była wersja 1.0 udostępniona w 1994 roku.
Nowa wersja obejmowała blisko 165 tysięcy wierszy kodu implementującego m.in. nowy system
plików, pliki odwzorowań pamięci oraz mechanizmy sieciowe zgodne z systemem BSD (z obsługą
gniazd i protokołu TCP/IP). Wersja 1.0 zawierała też wiele nowych sterowników urządzeń.
W kolejnych latach wydano kilka mniej ważnych zmian.
W owym czasie system Linux był na tyle zgodny z systemem UNIX, że udało się przenieść
sporą część oprogramowania uniksowego, co z kolei przełożyło się na znaczny wzrost jego uży-
teczności w porównaniu z wcześniejszymi wersjami. Co więcej, system Linux osiągnął znaczną
popularność, a wielu użytkowników aktywnie włączyło się w jego rozwój i pracowało (pod ogólnym
kierownictwem Torvaldsa) nad kodem rozszerzającym jego możliwości.
Następne ważne wydanie (oznaczone numerem 2.0) miało miejsce w roku 1996. Tym razem
system Linux składał się z blisko 470 tysięcy wierszy języka C i 8000 wierszy kodu asemblera.
Nowa wersja obsługiwała architektury 64-bitowe, wieloprogramowość symetryczną, nowe pro-
tokoły sieciowe i niezliczone inne funkcje. Znaczna część kodu nowego systemu odpowiadała za
implementację rozbudowanego zbioru sterowników urządzeń. Od tamtej pory kolejne wydania
były już udostępniane dużo częściej.
Numery wersji jądra systemu Linux składają się z czterech liczb: A.B.C.D, np. 2.6.9.11.
Pierwsza wartość reprezentuje numer jądra. Druga liczba określa główną zmianę (ang. revision).
Przed wydaniem jądra 2.6 parzyste numery zmian były stosowane dla stabilnych wydań jądra,
natomiast numery nieparzyste stosowano dla zmian niestabilnych (wymagających dalszych prac).
Zrezygnowano z tego schematu oznaczania wersji wraz z wydaniem jądra 2.6. Trzecia wartość
odpowiada mniej znaczącej zmianie, np. w związku z obsługą nowych sterowników. Czwarta liczba
składowa reprezentuje drobne poprawki eliminujące błędy i podnoszące bezpieczeństwo. W lipcu
2011 roku Linus Torvalds ogłosił wydanie Linuksa 3.0 nie w odpowiedzi na znaczny postęp
techniczny, ale raczej by uczcić dwudziestą rocznicę powstania jądra. Według stanu z 2013 roku
jądro Linuksa składa się z blisko 16 milionów wierszy kodu.
Do systemu Linux przeniesiono mnóstwo standardowych produktów programowych znanych
z systemu UNIX, w tym X Window System i niezliczone programy sieciowe. Dla Linuksa napisano
też dwa różne graficzne interfejsy użytkownika — GNOME oraz KDE. Krótko mówiąc, system
W tym podrozdziale wprowadzimy ogólne zasady funkcjonowania systemu Linux i sposoby jego
używania (przede wszystkim z myślą o Czytelnikach, którzy do tej pory nie mieli okazji zapo-
znać się z tym systemem). Niemal cały ten materiał odnosi się także do wszystkich wariantów
systemu UNIX (z kilkoma nieistotnymi wyjątkami). Mimo istnienia kilku graficznych interfej-
sów użytkownika dla systemu Linux, w tym podrozdziale skoncentrujemy się na jego działaniu
z perspektywy programisty korzystającego z okna powłoki systemu X. W kolejnych podrozdzia-
łach przeanalizujemy wywołania systemowe i wewnętrzne mechanizmy ich wykonywania.
generuje listę wszystkich plików, których nazwy rozpoczynają się od wielkiej litery A, to
polecenie:
rm A*
powinno powodować usunięcie wszystkich plików, których nazwy rozpoczynają się od wielkiej
litery A, a nie np. jednego pliku, którego nazwa składa się z litery A i gwiazdki. Ta cecha systemów
komputerowych bywa nazywana zasadą najmniejszego zaskoczenia (ang. principle of least surprise).
Alternatywnym rozwiązaniem jest uruchomienie w pierwszym kroku programu grep (bez żad-
nych argumentów), aby otrzymać wygenerowany przez to narzędzie komunikat (lub podobny):
Cześć, jestem grep, szukam wzorców w plikach. Wpisz, proszę, swój wzorzec. Po otrzymaniu wzorca
grep poprosi użytkownika o podanie nazwy pliku. Zaraz potem zapyta, czy użytkownik nie chce
podać dodatkowych nazw plików. I wreszcie podsumuje planowane zadanie i spyta, czy dotych-
czasowe ustawienia spełniają oczekiwania użytkownika. O ile interfejs użytkownika w tej formie
może odpowiadać nowicjuszom, o tyle ten sam interfejs doprowadziłby doświadczonych progra-
mistów do białej gorączki. Szukają sługi, nie niani.
trybu jądra. Ponieważ nie jest możliwe pisanie tego rodzaju rozkazów w języku C, istnieje biblio-
teka oferująca po jednej procedurze dla każdego wywołania systemowego. Procedury udostęp-
niane przez tę bibliotekę są co prawda pisane w języku asemblera, ale mogą być wywoływane
z poziomu języka C. Działanie każdej z tych procedur rozpoczyna się od umieszczenia argumen-
tów we właściwym miejscu, by następnie wykonać rozkaz pułapki. Oznacza to, że aby wykonać
wywołanie systemowe read, program języka C powinien wywołać procedurę biblioteki nazwaną
read. Warto przy tej okazji wspomnieć, że standard POSIX opisuje właśnie interfejs tej biblio-
teki, nie interfejs wywołań systemowych. Innymi słowy, standard POSIX określa, które proce-
dury biblioteki muszą być oferowane przez system zgodny z tym standardem, jakie parametry
powinny obsługiwać, co mają robić i jakie wyniki powinny zwracać. Standard POSIX nawet nie
wspomina o wywołaniach systemowych jako takich.
Oprócz systemu operacyjnego i biblioteki wywołań systemowych wszystkie wersje Linuksa
oferują bogaty zbiór standardowych programów, z których część wprost wskazano w standardzie
POSIX 1003.2, a część różni się w zależności od konkretnej wersji systemu. Programy oferowane
przez wszystkie wersje systemu Linux obejmują procesor poleceń (powłokę), kompilatory, edytory,
edytory tekstu oraz narzędzia operujące na plikach. Programy tego typu użytkownik uruchamia,
wpisując odpowiednie polecenia za pomocą klawiatury. Możemy więc mówić o trzech różnych
interfejsach systemu Linux — prawdziwym interfejsie wywołań systemowych, interfejsie biblio-
teki oraz interfejsie tworzonym przez zbiór standardowych programów użytkowych.
Większość dystrybucji systemu Linux dla komputerów osobistych zastąpiło ten interfejs użyt-
kownika oparty na klawiaturze graficznym interfejsem użytkownika opartym na myszy. Co cie-
kawe, zamiana interfejsu nie wymagała żadnych modyfikacji w samym systemie operacyjnym.
Nie ma wątpliwości, że właśnie ta elastyczność decyduje o popularności systemu Linux i pozwoliła
mu przetrwać niezliczone zmiany technologii w niższych warstwach.
Graficzny interfejs użytkownika (GUI) systemu Linux przypomina pierwsze tego rodzaju inter-
fejsy tworzone dla systemów UNIX w latach siedemdziesiątych ubiegłego wieku i spopularyzo-
wane przez firmę Macintosh, a później także Microsoft już dla platform PC. Interfejs GUI two-
rzy środowisko pulpitu obejmującego okna, ikony, foldery, paski narzędzi i mechanizmy typu
przeciągnij i upuść. Kompletne środowisko pulpitu obejmuje nie tylko program menedżera okien
kontrolujący ich rozmieszczenie i wygląd, ale też zbiór różnych aplikacji udostępniających spójny
interfejs graficzny. Do najpopularniejszych środowisk tego typu stworzonych z myślą o syste-
mie Linux należą GNOME (od ang. GNU Network Object Model Environment) i KDE (od ang.
K Desktop Environment).
Graficzne interfejsy użytkownika w systemie Linux są obsługiwane przez system okien
X Window System (nazywany często X11 lub po prostu X), który definiuje protokoły komunikacji
i wyświetlania niezbędne do prezentacji okien na ekranach bitmapowych przez systemy UNIX
(i pokrewne). Głównym komponentem systemu X jest serwer kontrolujący takie urządzenia jak
klawiatura, mysz czy ekran oraz odpowiedzialny za przekierowywanie danych wejściowych lub
akceptację danych wyjściowych programów klienckich. Właściwe środowisko GUI zwykle jest
budowane ponad niskopoziomową biblioteką xlib zawierającą funkcję niezbędną do interakcji
z serwerem X. Interfejs graficzny rozszerza podstawowy zbiór funkcji systemu X11 o bogatszy
widok okien, przyciski, menu, ikony i wiele innych opcji. Serwer X można uruchomić ręcznie
z poziomu wiersza poleceń, jednak zwykle jest uruchamiany przez menedżera ekranu (wyświe-
tlającego ekran logowania z elementami graficznymi) w trakcie uruchamiania systemu opera-
cyjnego.
Użytkownicy korzystający z systemu Linux za pośrednictwem interfejsu graficznego mogą
m.in. uruchamiać aplikacje lub otwierać pliki, klikając odpowiedni przycisk myszy, a także
10.2.3. Powłoka
Mimo że systemy linuksowe oferują graficzny interfejs użytkownika, większość programistów
i bardziej doświadczonych użytkowników nadal decyduje się na korzystanie z interfejsu wiersza
poleceń, tzw. powłoki (ang. shell). Użytkownicy z tej grupy często uruchamiają jedno lub wiele
okien powłoki w graficznym interfejsie użytkownika, aby wygodnie pracować w kilku oknach
jednocześnie. Interfejs wiersza poleceń powłoki jest dużo szybszy w działaniu, rozszerzalny, ofe-
ruje większe możliwości i nie wymusza na użytkowniku nieustannego posługiwania się myszą.
Poniżej oględnie omówimy powłokę bash, którą stworzono na bazie oryginalnej powłoki systemu
UNIX nazywanej powłoką Bourne’a (ang. Bourne shell), napisanej przez Steve’a Bourne’a z Bell
Labs. Co ciekawe, nazwa bash jest akronimem słów Bourne Again SHell (dosł. znowu powłoka
Bourne’a; fonetycznie odrodzona powłoka). Istnieje też wiele innych powłok (w tym ksh, csh itp.),
ale właśnie bash jest powłoką domyślną w większości systemów Linux.
Uruchomiona powłoka po zainicjalizowaniu wyświetla na ekranie znak zachęty (ang. prompt),
czyli w większości przypadków symbol procentów lub dolara, i czeka na wiersz polecenia wpi-
sany przez użytkownika.
Kiedy użytkownik wpisuje wiersz poleceń, powłoka wyodrębnia pierwsze słowo tego wier-
sza — zakłada, że w tym miejscu wskazano nazwę programu do uruchomienia, próbuje ten
program odnaleźć i (jeśli poszukiwania zakończą się sukcesem) uruchamia odpowiedni plik wyko-
nywalny. Powłoka wstrzymuje swoje działanie do czasu zakończenia pracy przez ten program.
Dopiero wówczas jest gotowa do odebrania i wykonania następnego polecenia. Warto przy tej
okazji podkreślić, że powłoka jest zwykłym programem użytkownika. Do jej działania w zupełności
wystarczy zdolność odczytywania znaków z klawiatury, wyświetlania komunikatów na monitorze
i uruchamiania innych programów.
Polecenia mogą otrzymywać argumenty, które należy przekazywać do wywoływanych pro-
gramów w formie łańcuchów znaków. I tak polecenie w postaci:
cp src dest
uruchamia program cp z dwoma argumentami: src i dest. Wywołany program interpretuje pierw-
szy argument jako nazwę istniejącego pliku, po czym kopiuje ten plik, nadając powstałej kopii
nazwę reprezentowaną przez drugi argument.
Nie wszystkie argumenty reprezentują nazwy plików; np. w następującym poleceniu:
head –20 file
pierwszy argument (-20) nakazuje programowi head wyświetlenie pierwszych dwudziestu wier-
szy pliku file (zamiast domyślnych dziesięciu wierszy). Argumenty sterujące działaniem programu
lub reprezentujące wartości opcjonalne określa się mianem flag. Zgodnie z konwencją poprzedza
się je znakiem myślnika. Myślnik jest niezbędny, aby uniknąć niejednoznaczności, ponieważ
np. polecenie w postaci:
head 20 file
jest w pełni prawidłowe i powoduje wyświetlanie przez polecenie head początkowych dziesięciu
wierszy pliku nazwanego 20 oraz pierwszych dziesięciu wierszy pliku nazwanego file. Większość
poleceń systemu Linux akceptuje wiele flag i argumentów.
Aby ułatwić użytkownikom definiowanie nazw wielu plików, powłoka akceptuje tzw. znaki
magiczne, nazywane też symbolami wieloznacznymi (ang. wild cards). I tak symbol gwiazdki (*)
reprezentuje wszystkie możliwe łańcuchy, zatem polecenie:
ls *.c
nakazuje programowi ls wygenerowanie listy wszystkich plików, których nazwy kończą się roz-
szerzeniem .c. Oznacza to, że gdyby istniały plik x.c, y.c oraz z.c, powyższe polecenie byłoby rów-
noważne poleceniu w postaci:
ls x.c y.c z.c
Innym powszechnie stosowanym symbolem wieloznacznym jest znak zapytania, który reprezen-
tuje dowolny znak. Lista znaków umieszczonych pomiędzy nawiasami kwadratowymi powoduje,
że zostanie wybrany jeden z wymienionych znaków, zatem polecenie:
ls [ape]*
wywoła program sort, który odczyta wiersze z terminala (aż użytkownik naciśnie kombinację
klawiszy Ctrl+D oznaczającą koniec pliku), posortuje je w porządku alfabetycznym i wyświetli
wynik sortowania na ekranie.
Istnieje też możliwość przekierowania standardowego wejścia i standardowego wyjścia, co
w wielu przypadkach jest wyjątkowo przydatne. Do przekierowania standardowego wejścia
służy znak mniejszości (<), po którym należy wskazać nazwę pliku wejściowego. Podobnie do
przekierowania standardowego wyjścia służy znak większości (>). Istnieje możliwość jedno-
czesnego (w ramach jednego polecenia) przekierowania zarówno wejścia, jak i wyjścia. Pole-
cenie w postaci:
sort <in >out
spowoduje, że program sort pobierze swoje dane wejściowe z pliku in i zapisze swoje dane
wyjściowe (wynikowe) w pliku out. Ponieważ standardowy błąd nie został przekierowany, ewen-
tualne komunikaty o błędach zostaną wyświetlone na ekranie. Program odczytujący swoje dane
ze standardowego wejścia, przetwarzający je i kierujący dane wynikowe do standardowego wyj-
ścia określa się mianem filtra.
Przeanalizujmy teraz następujący wiersz składający się z trzech odrębnych poleceń:
sort <in >temp; head –30 <temp; rm temp
Pierwsze polecenie uruchamia program sort, który pobiera dane wejściowe z pliku in i zapisuje dane
wyjściowe w pliku temp. Po zakończeniu wykonywania tego programu powłoka uruchomi narzędzie
head, którego zadaniem jest wyświetlenie pierwszych trzydziestu wierszy pliku temp — wybrane
wiersze trafią na standardowe wyjście, czyli domyślnie terminal. I wreszcie ostatnie polecenie rm
usuwa plik tymczasowy temp. Plik nie jest umieszczany w koszu. Jest na trwałe usuwany.
Często zdarza się, że pierwszy program wskazany w wierszu poleceń generuje dane wyni-
kowe, które są wykorzystywane w roli danych wejściowych następnego programu. W powyższym
przykładzie wykorzystaliśmy do składowania tych danych plik temp. Okazuje się jednak, że system
Linux oferuje prostszą konstrukcję umożliwiającą realizację tego samego zadania. W następu-
jącym poleceniu:
sort <in | head –30
znak pionowej linii, nazywany symbolem potoku (ang. pipe symbol), określa, że dane wynikowe wyge-
nerowane przez program sort mają być wykorzystane w roli danych wejściowych polecenia head,
co eliminuje konieczność tworzenia, stosowania i usuwania pliku tymczasowego. Sekwencja wyra-
żeń połączonych symbolami pionowej linii, którą określa się mianem potoku (ang. pipeline), może
zawierać dowolną liczbę poleceń. Poniżej przedstawiono przykład potoku złożonego z czterech
takich komponentów:
grep ter *.t | sort | head –20 | tail –5 >foo
Potok w tej formie spowoduje skierowanie na standardowe wyjście wszystkich wierszy zawie-
rających łańcuch "ter" w plikach, których nazwy kończą się rozszerzeniem .t. Polecenie head
wybiera pierwsze 20 wierszy z posortowanych danych, po czym przekazuje te wiersze do pole-
cenia tail, które zapisuje w pliku foo ostatnich pięć spośród dwudziestu otrzymanych wierszy
(czyli wiersze od 16. do 20.). Mamy tutaj do czynienia z przykładem użycia oferowanych przez
system Linux bloków składowych (wielu połączonych filtrów), z których każdy wykonuje okre-
ślone zadanie, oraz mechanizmu ich łączenia na niemal nieograniczone sposoby.
Linux jest uniwersalnym systemem wieloprogramowym. Pojedynczy użytkownik może uru-
chamiać wiele programów jednocześnie, każdy w odrębnym procesie. Składnia powłoki dla proce-
sów uruchamianych w tle sprowadza się do zakończenia powłoki symbolem &. Oznacza to, że
polecenie w postaci:
wc –l <a >b &
uruchomi program licznika słów (wc) w trybie zliczania wierszy (flaga -l) w pliku wejściowym (a).
Dane wynikowe tego programu zostaną zapisane w pliku wyjściowym b, a cała operacja będzie
wykonywana w tle. Zaraz po wpisaniu polecenia w tej formie powłoka ponownie wyświetla
znak zachęty i jest gotowa do otrzymania i obsługi następnego polecenia. W tle można wyko-
nywać także całe potoki, oto prosty przykład:
sort <x | head &
kopiuje plik a do pliku b, pozostawiając oryginalny plik nienaruszony. Dla odmiany polecenie:
mv a b
kopiuje plik a do pliku b, ale też usuwa oryginalny plik. W efekcie polecenie mv przenosi plik,
zamiast sporządzać jego kopię (w rozumieniu potocznym). Za pomocą polecenia cat można
konkatenować wiele plików — polecenie cat odczytuje wskazane pliki wejściowe i kopiuje
kolejno ich zawartość na standardowe wyjście. Pliki można usuwać za pomocą polecenia rm.
Polecenie chmod umożliwia właścicielowi pliku zmianę bitów uprawnień, aby rozszerzać lub
ograniczać dostęp do danego pliku. Katalogi można tworzyć i usuwać odpowiednio za pomocą
poleceń mkdir i rmdir. Do generowania list plików zawartych we wskazanych katalogach służy
polecenie ls, które obsługuje wiele flag określających m.in. zakres wyświetlanych informacji
o plikach (jak rozmiar, właściciel, grupa czy data utworzenia), porządek sortowania (np. alfabe-
tyczny, według czasu ostatniej modyfikacji, porządek odwrócony itp.) oraz układ na ekranie.
W tym rozdziale mieliśmy już okazję analizować działanie kilku filtrów: grep wyodrębnia ze
standardowego wejścia bądź jednego lub wielu plików wejściowych wiersze zawierające określony
wzorzec; sort sortuje swoje dane wejściowe i zapisuje je w standardowym wyjściu; head wyod-
rębnia ze swoich danych wejściowych określoną liczbę początkowych wierszy; tail wyodrębnia
Tabela 10.1. Wybrane, najbardziej popularne programy użytkowe systemu Linux narzucane
przez standard POSIX
Program Typowe zastosowanie
cat Konkatenuje wiele plików i przekazuje wynik konkatenacji na standardowe wyjście
chmod Zmienia ustawienia ochrony pliku
cp Kopiuje jeden lub wiele plików
cut Wycina kolumny tekstu ze wskazanego pliku
grep Przeszukuje plik pod kątem zawierania określonego wzorca
head Wyodrębnia z pliku określoną liczbę początkowych wierszy
ls Generuje listę plików zawartych we wskazanym katalogu
make Kompiluje plik, aby skonstruować jego wersję binarną
mkdir Tworzy katalog
od Tworzy ósemkowy zrzut pliku
paste Wkleja do pliku kolumny tekstu
pr Formatuje plik na potrzeby drukowania
ps Wyświetla listę działających procesów
rm Usuwa jeden lub wiele plików
rmdir Usuwa katalog
sort Sortuje wiersze zawarte we wskazanym pliku zgodnie z porządkiem alfabetycznym
tail Wyodrębnia z pliku określoną liczbę końcowych wierszy
tr Tłumaczy dwa zbiory znaków
Jądro znajduje się bezpośrednio nad warstwą sprzętową i umożliwia interakcję z urządzeniami
wejścia-wyjścia oraz jednostką zarządzania pamięci, a także kontroluje dostęp do czasu procesora.
Na najniższym poziomie samego jądra (patrz rysunek 10.2) znajdują się procedury obsługi prze-
rwań, czyli podstawowe mechanizmy interakcji z urządzeniami, oraz niskopoziomowy mechanizm
przydziałów, tzw. dyspozytor (ang. dispatcher). Funkcje tego mechanizmu są wykorzystywane
przy okazji każdego wystąpienia przerwania. Niskopoziomowy kod dyspozytora zatrzymuje wów-
czas wykonywany proces, zapisuje jego stan w strukturach procesów jądra i uruchamia odpo-
wiedni sterownik. Mechanizm przydziałów jest wykorzystywany także w momencie zakończenia
wykonywania bieżącej operacji przez jądro, kiedy można wrócić do wykonywania ostatniego
procesu użytkownika. Kod dyspozytora jest pisany w asemblerze i nie powinien być mylony
z mechanizmami szeregowania zadań.
Możemy teraz podzielić trzy różne podsystemy jądra na trzy główne komponenty. Komponent
wejścia-wyjścia na rysunku 10.2 obejmuje wszystkie składniki jądra odpowiedzialne za inte-
rakcję z urządzeniami, operacje sieciowe i operacje wejścia-wyjścia związane z utrwalaniem danych.
Na najwyższym poziomie wszystkie operacje wejścia-wyjścia są zintegrowane z warstwą wirtu-
alnego systemu plików (ang. Virtual File System — VFS). Oznacza to, że na najwyższym poziomie
operacja odczytu pliku (niezależnie czy składowanego w pamięci, czy na dysku) nie różni się od
operacji odczytu znaku wejściowego z terminala. Na najniższym poziomie wszystkie operacje
wejścia-wyjścia przechodzą przez ten sam sterownik urządzenia. Wszystkie sterowniki urządzeń
klasyfikuje się albo jako sterowniki urządzeń znakowych, albo jako sterowniki urządzeń blokowych
(najważniejszą różnicą dzielącą te urządzenia jest możliwość wykonywania operacji poszukiwania
i swobodnego dostępu w przypadku urządzeń blokowych oraz brak tej możliwości w przypadku
urządzeń znakowych). Technicznie urządzenia sieciowe są w istocie urządzeniami znakowymi,
choć ich obsługa jest nieco inna, stąd decyzja o wyodrębnieniu tej kategorii (jak na rysunku).
Kod jądra znajdujący się ponad sterownikami urządzeń zależy od rodzaju obsługiwanego
urządzenia. Urządzenia znakowe można wykorzystywać na dwa sposoby. Niektóre programy, jak
wizualne edytory (np. vi oraz emacs), traktują każde naciśnięcie klawisza jako rodzaj odrębnego
żądania. Taką możliwość oferuje terminal TTY. Inne programy, w tym powłoka, operują raczej
na wierszach i umożliwiają użytkownikom edycję całego wiersza przed naciśnięciem klawisza
Enter — dopiero wówczas gotowy wiersz jest wysyłany do programu. W takim przypadku stru-
mień znaków z urządzenia terminala zostaje przekazany z wykorzystaniem specjalnego protokołu
obsługi i podlega odpowiedniemu formatowaniu.
Oprogramowanie sieciowe często ma charakter modułowy, co oznacza, że poszczególne skład-
niki odpowiadają za obsługę różnych urządzeń i protokołów. Warstwa ponad sterownikami sie-
ciowymi obsługuje rodzaj funkcji routingu, która odpowiada za kierowanie właściwych pakie-
tów do właściwych urządzeń lub procedur obsługujących protokół. Większość systemów Linux
oferuje kompletne zbiory funkcji routerów w ramach jądra, jednak ich wydajność jest niższa niż
w przypadku routerów sprzętowych. Ponad kodem routera znajduje się właściwy stos protokołu,
który zawsze obejmuje protokoły IP i TCP (ale też wiele innych protokołów). Na najwyższym
poziomie znajduje się interfejs gniazd, który umożliwia programom tworzenie gniazd dla kon-
kretnych sieci i protokołów (dla każdego utworzonego gniazda interfejs zwraca deskryptor pliku
niezbędny do dalszego korzystania z tego gniazda).
Nad sterownikami dysków znajduje się koordynator wejścia-wyjścia (ang. I/O scheduler), który
odpowiada za porządkowanie i realizację żądań operacji dyskowych w sposób maksymalnie ogra-
niczający liczbę niezbędnych ruchów głowicy lub gwarantujący zgodność ze strategią przyjętą
w danym systemie.
Na szczycie kolumny urządzenia blokowego znajdują się systemy plików. Linux może ofero-
wać (i oferuje) wiele współistniejących i stosowanych jednocześnie systemów plików. Aby ukryć
poważne różnice w architekturze, dzielące poszczególne urządzenia przed implementacją systemu
plików, jądro systemu Linux stosuje ogólną warstwę urządzeń blokowych, czyli wspólną
abstrakcję wykorzystywaną przez wszystkie systemy plików.
W prawej części rysunku 10.2 pokazano pozostałe dwa ważne komponenty jądra systemu
operacyjnego Linux. Komponenty odpowiadają za realizację zadań związanych z zarządzaniem
pamięcią i procesami. Do zadań związanych z zarządzaniem pamięcią należą utrzymywanie
odwzorowań pamięci wirtualnej w pamięć fizyczną, buforowanie ostatnio wykorzystywanych stron
w pamięci podręcznej i implementowanie przemyślanej strategii wymiany stron oraz przenosze-
nie do pamięci (na żądanie) nowych stron z niezbędnym kodem i danymi.
Najważniejszym zadaniem komponentu odpowiedzialnego za zarządzanie pamięcią jest two-
rzenie i kończenie procesów. Opisywany komponent obejmuje mechanizm koordynujący, szere-
gujący (ang. scheduler), który wybiera proces (a raczej wątek) wykonywany w pierwszej kolejności.
Jak dowiemy się z następnego podrozdziału, jądro systemu Linux traktuje zarówno procesy, jak
i wątki jako byty wykonywalne, których wykonywanie wymaga koordynacji zgodnie z globalną
strategią szeregowania. I wreszcie komponent zarządzający pamięcią obejmuje mechanizmy
obsługi sygnałów.
Mimo że te trzy komponenty są reprezentowane na powyższym rysunku jako odrębne struk-
tury, w praktyce pozostają ściśle ze sobą powiązane. Systemy plików zwykle uzyskują dostęp
do plików za pośrednictwem urządzeń blokowych. Okazuje się jednak, że aby ukryć opóźnienia
związane z dostępem do zasobów dyskowych, pliki są kopiowane do pamięci podręcznej stron
w ramach pamięci głównej. Co więcej, niektóre pliki mogą być tworzone dynamicznie i wystę-
pować wyłącznie w formie reprezentacji w pamięci głównej (tak jest np. w przypadku plików
obejmujących informacje o wykorzystaniu zasobów w czasie wykonywania programu). System
pamięci wirtualnej może też wykorzystywać specjalną partycję dyskową lub obszaru wymiany
(w odpowiednim pliku) do składowania kopii fragmentów pamięci głównej w razie konieczności
zwalniania pewnych stron — wówczas system pamięci wirtualnej musi współpracować z kompo-
nentem wejścia-wyjścia. Istnieje też wiele innych wzajemnych zależności pomiędzy opisanymi
komponentami.
Oprócz statycznych komponentów jądra system Linux obsługuje moduły ładowane dynamicznie.
Można te moduły wykorzystywać do dodawania lub zastępowania domyślnych sterowników urzą-
dzeń, systemu plików, mechanizmów sieciowych i innych obszarów kodu jądra. Moduły tego typu
nie zostały uwzględnione na rysunku 10.2.
I wreszcie na samym szczycie struktury jądra znajduje się interfejs wywołań systemowych.
Wszystkie wywołania systemowe trafiają właśnie do tego interfejsu i wywołują procedury puła-
pek, które z kolei przełączają tryb wykonywania (z trybu użytkownika do chronionego trybu jądra)
i przekazują kontrolę do jednego z opisanych powyżej komponentów jądra.
tworzy dwa procesy, sort i head, po czym tworzy potok pomiędzy nimi w taki sposób, aby stan-
dardowe wyjście procesu sort było połączone ze standardowym wejściem procesu head. Dzięki
temu wszystkie dane generowane przez proces sort trafiają bezpośrednio do procesu head (bez
konieczności zapisywania w jakimś pliku pośredniczącym). Kiedy potok się zapełnia, system
wstrzymuje wykonywanie procesu sort do czasu pobrania buforowanych danych przez proces
head.
Procesy mogą też komunikować się w inny sposób — z wykorzystaniem przerwań progra-
mowych. Proces może wysłać do innego procesu tzw. sygnał. Procesy mogą informować system,
jak powinien reagować na przychodzące sygnały. Mogą one być ignorowane, przechwytywane
lub zabijać procesy (takie jest domyślne działanie większości sygnałów). Jeśli proces decyduje
się na przechwytywanie kierowanych do siebie sygnałów, musi wskazać systemowi procedurę
ich obsługi. W takim przypadku nadejście sygnału powoduje natychmiastowe przekazanie stero-
wania do tej procedury. Kiedy procedura obsługująca kończy działanie, sterowanie jest zwracane
tam, skąd zostało zabrane (a więc podobnie jak w przypadku sprzętowych przerwań wejścia-
-wyjścia). Proces może wysyłać sygnały tylko do członków swojej grupy procesów, czyli rodzica
(i dalszych przodków), rodzeństwa i dzieci (i dalszych potomków). Istnieje nawet możliwość
wysłania sygnału do wszystkich członków grupy procesów danego procesu za pomocą pojedynczego
wywołania systemowego.
Sygnały są wykorzystywane także do innych celów. Jeśli np. jakiś proces wykonuje działania
arytmetyczne na liczbach zmiennoprzecinkowych i przypadkowo spróbuje podzielić jakąś wartość
przez zero, otrzyma sygnał SIGFPE (wyjątku operacji na liczbach zmiennoprzecinkowych).
W tabeli 10.2 wymieniono sygnały narzucone przez standard POSIX. Wiele systemów Linux
obsługuje dodatkowe sygnały, jednak programy korzystające z tych sygnałów często nie są prze-
nośne do innych wersji Linuksa ani do systemów z szerszej rodziny UNIX.
Tabela 10.3. Wybrane wywołania systemowe związane z procesami. Każde z tych wywołań zwraca
wartość –1 w razie wystąpienia błędu; argument PID we wszystkich przypadkach reprezentuje
identyfikator procesu, a zmienna residual zawiera czas pozostały do aktywacji poprzedniego alarmu.
Pozostałe parametry nie wymagają dodatkowych wyjaśnień
Wywołania systemowe Opis
pid = fork() Tworzy proces potomny identyczny z procesem-rodzicem
pid = waitpid(pid, &statloc, opts) Oczekuje na zakończenie procesu potomnego
s = execve(name, argv, envp) Zastępuje obraz pamięci procesu
exit(status) Kończy działanie procesu i zwraca jego status
s = sigaction(sig, &act, &oldact) Definiuje działanie podejmowane w reakcji na przychodzące sygnały
s = sigretur n(&context) Zwraca sterowanie z procedury obsługi sygnału
s = sigprocmask(how, &set, &old) Zwraca lub zmienia maskę sygnałów
s = sigpending(set) Zwraca zbiór blokowanych sygnałów
s = sigsuspend(sigmask) Zastępuje maskę sygnałów i wstrzymuje wykonywanie danego procesu
s = kill(pid, sig) Wysyła sygnał do procesu
residual = alarm(seconds) Ustawia alarm czasowy
s = pause() Wstrzymuje wykonywanie procesu wywołującego do momentu
otrzymania następnego sygnału
pracy przez dowolnego potomka. Drugi parametr reprezentuje adres zmiennej, której zostanie
przypisany status końca pracy procesu potomnego (wykonywanie procesu może się zakończyć
normalnie lub w sposób nietypowy). Dzięki temu proces-rodzic może się dowiedzieć o losie
procesu-dziecka. Trzeci parametr określa, czy proces wywołujący powinien być blokowany,
czy natychmiast powinien odzyskiwać sterowanie w razie braku procesów potomnych do
wstrzymania.
W przypadku powłoki proces potomny musi uruchomić polecenie wprowadzone przez użyt-
kownika. W tym celu wykorzystuje wywołanie systemowe exec, które powoduje zastąpienie
całego obrazu pamięci procesu (ang. core image) zawartością pliku wymienionego za pomocą
pierwszego parametru. Bardzo uproszczony kod powłoki, w którym pokazano użycie wywołań
systemowych fork, waitpid i execve pokazano na listingu 10.2.
if (pid != 0) {
waitpid (−1, &status, 0); /* rodzic czeka na dziecko */
} else {
execve(command, params, 0); /* dziecko wykonuje polecenie użytkownika */
}
}
W najbardziej ogólnym przypadku wywołanie exec wykorzystuje trzy parametry: nazwę pliku
do uruchomienia, wskaźnik do tablicy z argumentami oraz wskaźnik do tablicy zawierającej zmienne
środowiskowe. Opiszemy je wkrótce. Dostępnych jest kilka procedur bibliotecznych execl, execv,
execle i execve. Pozwalają one na pomijanie niektórych parametrów lub podawanie ich na różne
sposoby. Wszystkie te procedury wywołują to samo bazowe wywołanie systemowe. Właśnie
dlatego mimo istnienia wywołania exec nie istnieje tak samo nazwana procedura biblioteki
(programista musi użyć jednej z wymienionych procedur z rodziny exec).
Przeanalizujmy teraz przykład następującego polecenia wpisanego w powłoce:
cp plik1 plik2
gdzie argc oznacza liczbę elementów w wierszu polecenia włącznie z nazwą programu. I tak
w powyższym przykładzie argument argc ma wartość 3.
Drugi parametr, argv, zawiera wskaźnik do tablicy. Element numer i w tej tablicy jest wskaź-
nikiem do i-tego ciągu znaków w wierszu poleceń. Oznacza to, że w analizowanym przykładzie
element argv[0] wskazuje łańcuch "cp"; element argv[1] wskazuje pięcioznakowy łańcuch "plik1",
a element argv[2] wskazuje pięcioznakowy łańcuch "plik2".
Trzeci parametr funkcji main — envp — to wskaźnik do tablicy zmiennych środowiskowych.
Zawiera ona pary postaci nazwa = wartość. Wykorzystuje się je do przekazywania do progra-
mów takich informacji, jak typ terminala, czy nazwa katalogu macierzystego. Na listingu 10.2 nie
przekazano procesowi potomnemu środowiska, zatem w miejsce trzeciego parametru procedury
execve użyto wartości zero.
Jeśli wywołanie systemowe exec sprawia wrażenie zbyt złożonego, nie powinniśmy się znie-
chęcać — właśnie exec jest najbardziej złożonym wywołaniem. Wszystkie pozostałe są znacz-
nie prostsze. Przykładem prostego wywołania systemowego jest exit. Procesy wykorzystują je
podczas kończenia swojego działania. Wywołanie exit otrzymuje na wejściu tylko jeden parametr
reprezentujący status wyjścia (z przedziału od 0 do 255), który jest następnie zwracany do procesu
macierzystego (gdzie jest przypisywany zmiennej status wywołania systemowego waitpid).
Mniej znaczący (dolny) bajt zmiennej status reprezentuje przyczynę przerwania wykonywania
procesu, gdzie 0 oznacza normalne zakończenie pracy, a każda wartość niezerowa wskazuje na
wystąpienie błędu. Bardziej znaczący bajt zawiera status wyjścia (z przedziału od 0 do 255)
zdefiniowany w ramach wywołania exit na poziomie procesu potomnego. Jeśli np. proces macie-
rzysty wykona wyrażenie w postaci:
n = waitpid(−1, &status, 0);
działanie tego procesu zostanie wstrzymane do czasu zakończenia pracy przez któryś z procesów
potomnych. Jeśli proces potomny zakończy działanie z parametrem 4 przekazanym na wejściu
wywołania exit, proces macierzysty zostanie aktywowany — zmiennej n zostanie wówczas
przypisany identyfikator PID dziecka, a zmiennej status zostanie przypisana wartość 0x0400
(0x to przedrostek wartości szesnastkowych w języku C). Mniej znaczący bajt wartości przypisanej
zmiennej status ma związek z sygnałami; bardziej znaczący bajt reprezentuje wartość zwróconą
przez potomka w jego wywołaniu procedury exit.
Jeśli proces kończy działanie, mimo że jego proces macierzysty nie czeka na to zdarzenie,
wchodzi w stan porównywalny ze wstrzymaną animacją — staje się tzw. zombie. Proces potomny
ostatecznie kończy działanie dopiero w chwili, kiedy jego rodzic zgłosi zainteresowanie oczeki-
waniem na takie zakończenie.
Spora część wywołań systemowych ma związek z sygnałami, które można wykorzystywać na
wiele różnych sposobów. Jeśli np. użytkownik przypadkowo nakaże swojemu edytorowi tekstu
wyświetlenie całej zawartości bardzo długiego pliku i odkryje, że popełnił błąd, powinien mieć
możliwość przerwania pracy tego edytora. Do najbardziej popularnych rozwiązań należy naciskanie
specjalnych klawiszy (np. klawisza Del lub kombinacji Ctrl+C), aby wysłać do edytora odpowiedni
sygnał. Edytor powinien przechwycić ten sygnał i przerwać proces wyświetlania pliku.
Aby zgłosić swoje zainteresowanie przechwytywaniem i obsługą tego sygnału (ale też każ-
dego innego), proces może się posłużyć wywołaniem systemowym sigaction. Pierwszym para-
metrem tego wywołania powinien być przedmiotowy sygnał (patrz tabela 10.2). Drugi parametr
jest wskaźnikiem do struktury obejmującej wskaźnik do procedury obsługującej dany sygnał oraz
pozostałe bity i flagi. Trzeci parametr wskazuje na strukturę, w której system powinien umieścić
informacje o aktualnie obowiązujących ustawieniach w zakresie obsługi sygnałów (na wypadek
konieczności ich przywrócenia w przyszłości).
Procedura obsługująca sygnał może działać tak długo, jak to konieczne. W praktyce jednak
tego rodzaju mechanizmy zwykle wykonują swoje zadania bardzo szybko. Po zakończeniu pracy
procedura obsługująca sygnał zwraca sterowanie do punktu, w którym nastąpiło przerwanie.
Wywołanie systemowe sigaction można wykorzystać także do wymuszenia ignorowania
sygnałów lub przywrócenia domyślnej reakcji (czyli każdorazowego zabicia procesu).
Naciśnięcie klawisza Del nie jest jedynym sposobem wysłania sygnału do procesu. Za pomocą
wywołania systemowego kill jeden proces może wysłać sygnał do innego, spokrewnionego pro-
cesu. Wybór nazwy kill dla tego wywołania systemowego jest o tyle niefortunny, że większość
procesów wysyła sygnały do innych procesów z myślą o ich przechwyceniu i obsłudze, nie o ich
zabijaniu. Jednak sygnał, który nie zostanie przechwycony, rzeczywiście zabija odbiorcę.
W wielu aplikacjach czasu rzeczywistego proces musi zostać przerwany w określonym czasie
(umożliwiającym np. ponowne przesłanie potencjalnie utraconych pakietów przesyłanych za
pomocą zawodnego łącza komunikacyjnego). Właśnie z myślą o obsłudze tego rodzaju sytuacji
stworzono wywołanie systemowe alarm. Parametr tego wywołania określa przedział czasowy
(wyrażony w sekundach), po którym należy wysłać do procesu sygnał SIGALRM. Dla każdego
procesu może jednocześnie istnieć tylko jeden alarm. Jeśli użyjemy wywołania systemowego
alarm z parametrem wyznaczającym przedział 10-sekundowy, po czym, 3 s później, ponownie
użyjemy tego wywołania z parametrem wyznaczającym przedział 20-sekundowy, sygnał zostanie
wysłany tylko raz, po 20 s od drugiego wywołania. Drugie wywołanie systemowe alarm powo-
duje anulowanie pierwszego. Jeśli na wejściu wywołania alarm przekażemy wartość zerową,
ewentualny sygnał oczekujący zostanie anulowany Jeśli sygnał alarmu czasowego nie zostanie
przechwycony, zostanie zastosowane działanie domyślne, a proces otrzymujący ten sygnał zosta-
nie zabity. Technicznie istnieje możliwość ignorowania sygnałów alarmowych, jednak w takim
przypadku stosowanie tych sygnałów byłoby bezcelowe.
Zdarza się, że proces nie ma do wykonania żadnych zadań do czasu otrzymania sygnału.
Wyobraźmy sobie program wspierający proces dydaktyczny, którego zadaniem jest testowanie
szybkości czytania i rozumienia przez studentów określonego materiału. Taki program może
wyświetlić jakiś tekst na ekranie, po czym użyć wywołania alarm, aby otrzymać sygnał alarmowy
np. po 30 s. W czasie, w którym student czyta wyświetlony tekst, program nie realizuje żadnych
zadań. Taki program mógłby oczywiście wejść w pętlę niewykonującą konkretnych operacji, jednak
wiązałoby się to z marnotrawstwem czasu procesora potrzebnego procesom działającym w tle
lub innemu użytkownikowi. Lepszym rozwiązaniem byłoby użycie wywołania systemowego pause,
które powoduje, że system Linux wstrzymuje wykonywanie danego procesu do czasu otrzymania
najbliższego sygnału. Biada programowi, który wywoła pause w sytuacji, gdy nie ma zaległego
alarmu.
Na rysunku 10.3 pokazano opisane powyżej kroki na prostym przykładzie — użytkownik wpi-
suje w terminalu polecenie ls, powłoka tworzy nowy proces, tworząc rozwidlenie samej siebie.
Nowy proces powłoki wywołuje exec, aby wypełnić swoją pamięć zawartością pliku wykonywal-
nego ls. Po wykonaniu tych operacji program ls może się uruchomić.
Rysunek 10.3. Kroki składające się na uruchomienie polecenia ls wpisanego przez użytkownika
w powłoce
Wywołanie systemowe w tej formie tworzy nowy wątek albo w ramach bieżącego procesu, albo
w ramach nowego procesu (w zależności od ustawień reprezentowanych przez parametr
sharing_flags). Jeśli nowy wątek należy do bieżącego procesu, współdzieli przestrzeń adresową
z już istniejącymi wątkami, zatem każda kolejna operacja zapisu w dowolnym bajcie tej prze-
strzeni jest natychmiast widoczna dla pozostałych wątków danego procesu. Z drugiej strony,
jeśli przestrzeń adresowa nie jest współdzielona, nowy wątek otrzymuje wierną kopię tej prze-
strzeni, ale wykonywane następnie operacje zapisu w tej przestrzeni nie są widoczne dla istnie-
jących wcześniej wątków. Ta semantyka jest więc zgodna z semantyką wywołania systemowego
fork standardu POSIX.
Bit CLONE_VM określa, czy pamięć wirtualna (tj. przestrzeń adresowa) ma być współdzielona
z istniejącymi wątkami, czy skopiowana. Jeśli bit CLONE_VM jest ustawiony, nowy wątek zostaje
po prostu dodany do już istniejących, zatem wywołanie systemowe clone w praktyce tworzy
nowy wątek w ramach istniejącego procesu. Jeśli bit nie jest ustawiony, nowy wątek uzyska
własną, prywatną przestrzeń adresową. Dysponowanie własną przestrzenią adresową oznacza,
że skutki rozkazów STORE nie są widoczne dla istniejących wcześniej wątków. Działanie wywoła-
nia systemowego clone w tym trybie przypomina działanie wywołania fork (z pewną różnicą,
którą wyjaśnimy poniżej). Tworzenie nowej przestrzeni adresowej w praktyce wyczerpuje defi-
nicję tworzenia nowego procesu.
Bit CLONE_FS decyduje o współdzieleniu katalogu głównego, katalogów roboczych oraz flagi
uprawnień (ang. umask). Nawet jeśli wątek dysponuje własną przestrzenią adresową, ustawienie
tego bitu spowoduje, że stare i nowe wątki będą współdzieliły te same katalogi robocze. Oznacza
to, że wywołanie chdir przez jeden z tych wątków zmieni katalog roboczy wszystkich pozostałych
wątków, mimo że każdy z nich może dysponować własną przestrzenią adresową. W systemie
UNIX wywołanie chdir przez wątek zawsze zmienia katalog roboczy wszystkich wątków danego
procesu, ale nigdy wątków innych procesów. Oznacza to, że bit CLONE_FS umożliwia stosowanie
modelu, który nie był oferowany w tradycyjnych wersjach Uniksa.
Znaczenie bitu CLONE_FILES jest analogiczne jak w przypadku bitu CLONE_FS. Jeśli ustawimy
bit CLONE_FILES, nowy wątek będzie współdzielił deskryptory plików z wątkami już istniejącymi,
zatem skutki wywołania lseek użytego przez jeden z tych wątków będą widoczne dla pozosta-
łych — mamy więc ponownie do czynienia z modelem, który normalnie obowiązuje w przypadku
wątków tego samego procesu, ale nie wątków różnych procesów. Podobnie bit CLONE_SIGHAND
włącza lub wyłącza współdzielenie tablicy procedur obsługi sygnałów pomiędzy starymi wąt-
kami a nowo tworzonym wątkiem. Jeśli ta tablica jest współdzielona (nawet pomiędzy wątkami
dysponującymi odrębnymi przestrzeniami adresowymi), zmiana procedury obsługującej w jednym
wątku wpływa na procedury obsługujące w pozostałych.
I wreszcie każdy proces ma swojego rodzica. Bit CLONE_PARENT decyduje, kto jest rodzicem
nowego wątku. Może to być albo wątek macierzysty wątku wywołującego (wówczas nowy wątek
jest traktowany jako rodzeństwo wątku wywołującego), albo sam wątek wywołujący (wówczas
nowy wątek jest potomkiem wątku wywołującego). Istnieją jeszcze bity kontrolujące inne aspekty
tworzenia nowego wątku, jednak nie mają tak dużego znaczenia jak te omówione.
Tak szczegółowe określanie zasad współdzielenia zasobów jest możliwe, ponieważ system
Linux utrzymuje odrębne struktury danych dla wielu spośród elementów opisanych w punkcie
10.3.3 (parametrów szeregowania, obrazu pamięci itp.). Ponieważ struktura zadania wskazuje
na poszczególne struktury danych, utworzenie nowej struktury zadania dla klonowanego wątku,
wskazującej np. na parametry szeregowania czy obraz pamięci lub kopiującej te struktury, nie
stanowi większego problemu. Z drugiej strony sama możliwość szczegółowego określania zasad
współdzielenia zasobów nie powoduje jeszcze, że opisany model jest użyteczny, zwłaszcza że
podobnych funkcji nie oferują tradycyjne wersje systemu UNIX. Oznacza to, że program Linuksa
korzystający z tego mechanizmu traci walor przenośności do systemów UNIX.
Model wątków Linuksa powoduje też inne utrudnienie. Systemy UNIX kojarzą pojedynczy
identyfikator PID z jednym procesem, niezależnie od tego, czy jest to proces jedno- czy wielo-
wątkowy. Aby zapewnić zgodność z pozostałymi systemami uniksowymi, Linux rozróżnia
identyfikatory procesów (PID) od identyfikatorów zadań (TID). Oba pola są składowane w struk-
turze zadania. Kiedy program używa wywołania clone do utworzenia nowego procesu, który nie
współdzieli żadnych elementów z procesem tworzącym, w polu identyfikatora PID jest ustawiana
nowa wartość; w przeciwnym razie nowe zadanie otrzymuje tylko nowy identyfikator TID,
a identyfikator PID jest dziedziczony po procesie tworzącym. Oznacza to, że wszystkie wątki tego
samego procesu mają przypisany ten sam identyfikator PID co pierwszy wątek tego procesu.
Rysunek 10.4. Ilustracja struktur danych runqueue Linuksa dla (a) programu szeregującego
Linux O(1) i (b) programu szeregującego CFS (ang. Completely Fair Scheduler)
kwantu czasu zadanie trafia do tablicy zadań, które wykorzystały już swój czas. Kiedy żadna
z aktywnych tablic nie zawiera już oczekujących zadań, algorytm szeregujący ogranicza się do takiej
zamiany wskaźników, aby tablice zadań, które wcześniej wyczerpały swój czasy, stały się aktywne
(i odwrotnie). Opisana metoda gwarantuje, że zadania z niższym priorytetem nie będą oczekiwały
w nieskończoność (chyba że czas procesora będzie w całości wykorzystywany przez wątki czasu
rzeczywistego szeregowane w porządku FIFO, co jest mało prawdopodobne).
Zadania z różnymi priorytetami mają przydzielane przedziały czasowe różnych długości. System
Linux przydziela najdłuższe kwanty procesom z najwyższymi priorytetami; np. zadanie z prio-
rytetem na poziomie 100 otrzyma kwant o długości 800 ms, natomiast zadanie z priorytetem na
poziomie 139 otrzyma kwant o długości 5 ms.
Opisany schemat realizuje koncepcję polegającą na jak najszybszym wyprowadzaniu proce-
sów z jądra. Jeśli jakiś proces próbuje odczytać plik dyskowy, konieczność oczekiwania np. przez
sekundę pomiędzy kolejnymi wywołaniami read znacznie spowolniłaby pracę systemu. Dużo
lepszym rozwiązaniem byłoby natychmiastowe wznawianie wykonywania po każdym żądaniu,
aby można było błyskawicznie zrealizować kolejne żądanie. Podobnie, jeśli wykonywanie procesu
jest blokowane w oczekiwaniu na dane wpisywane za pomocą klawiatury, nie ma wątpliwości,
że proces ma charakter interaktywny i jako taki powinien otrzymać priorytet na tyle wysoki, aby
sprawnie obsługiwać żądania użytkownika. Zgodnie z tym modelem wszystkie pozostałe procesy
(tzw. procesy obliczeniowe — ang. CPU-bound) uzyskują czas procesora tylko wówczas, gdy
procesy stosujące operacje wejścia-wyjścia i procesy interaktywne są blokowane.
Ponieważ system Linux (ani żaden inny system operacyjny) nie wie z góry, czy zadanie jest
związane z wejściem-wyjściem, czy z procesorem, algorytm bazuje na stałym utrzymywaniu
heurystyk interaktywności. Właśnie dlatego Linux rozróżnia priorytety statyczne i dynamiczne.
Priorytety dynamiczne są obliczane na bieżąco, aby z jednej strony nagradzać, promować wątki
interaktywne, a z drugiej strony karać (degradować) procesy zajmujące czas procesora. Dla algo-
rytmu szeregującego O(1) maksymalna nagroda wynosi –5 punktów, ponieważ niższe wartości
reprezentują wyższe priorytety algorytmu szeregującego. Maksymalna kara wynosi +5 punktów.
Algorytm szeregujący utrzymuje dla każdego zadania zmienną zwaną sleep_avg. Za każdym razem,
gdy zadanie jest aktywowane (budzone), wartość wspomnianej zmiennej jest zwiększana o jeden.
Kiedy zadanie zostanie wywłaszczone lub wyczerpuje swój kwant czasu, wartość zmiennej
sleep_avg jest zmniejszana o odpowiednią wartość. Właśnie na podstawie tej zmiennej wyznacza
się wysokość nagrody lub kary (od –5 do +5). Algorytm szeregujący wyznacza poziom priorytetu
za każdym razem, gdy wątek jest przenoszony z listy wątków aktywnych na listę wątków, które
wyczerpały swoje kwanty czasu.
Algorytm szeregujący O(1) stał się popularny, począwszy od wczesnych wersji jądra 2.6, a po
raz pierwszy został wprowadzony w niestabilnej wersji 2.5. Algorytmy stosowane wcześniej
cechowały się niedostateczną wydajnością w środowiskach wieloprocesorowych i nie gwaran-
towały należytej skalowalności w warunkach rosnącej liczby zadań. Ponieważ z powyższego
opisu wynika, że decyzja algorytmu szeregującego wymaga dostępu do odpowiedniej listy aktyw-
nych zadań, czas takiej decyzji jest stały i wynosi O(1) niezależnie od liczby procesów w sys-
temie. Jednak pomimo pożądanej własności stałego czasu działania algorytm szeregujący O(1)
miał istotne niedociągnięcia. Przede wszystkim heurystyki używane do określenia interaktywności
zadań, a tym samym ich poziomu priorytetu, były skomplikowane i niedoskonałe, co skutkowało
niską wydajnością interaktywnych zadań.
Aby rozwiązać ten problem, Ingo Molnár, który stworzył także algorytm szeregujący O(1),
zaproponował nowy algorytm szeregujący o nazwie CFS (od ang. Completely Fair Scheduler —
dosł. całkowicie sprawiedliwy algorytm szeregujący). Algorytm CFS bazował na koncepcjach
pierwotnie opracowanych przez Cona Kolivasa dla wcześniejszego algorytmu szeregowania.
Po raz pierwszy włączono go do jądra w wersji 2.6.23. Nadal jest to domyślny algorytm szere-
gowania dla zadań, które nie wymagają trybu czasu rzeczywistego.
Główną koncepcją algorytmu CFS jest użycie drzewa czerwono-czarnego (ang. red-black tree)
jako struktury danych runqueue. Zadania są uporządkowane w drzewie na podstawie czasu, przez
jaki są realizowane przez procesor CPU. Ten czas jest określany jako vruntime. W algorytmie
CFS czas realizacji zadań jest wyliczany z nanosekundową rozdzielczością. Jak pokazano
na rysunku 10.4(b), każdy wewnętrzny węzeł drzewa odpowiada jednemu zadaniu. Węzły potomne
z lewej strony odpowiadają zadaniom, które dotychczas wymagały mniej czasu procesora i w związku
z tym zostaną zaplanowane wcześniej, natomiast węzły potomne po prawej stronie to te, które do
tej pory wymagały więcej czasu procesora. Liście w drzewie nie odgrywają żadnej roli w sze-
regowaniu zadań.
Algorytm szeregowania można streścić następująco: algorytm CFS zawsze wyznacza do uru-
chomienia to zadanie, które miało przydzieloną najmniejszą ilość czasu procesora — zazwyczaj
jest to najbardziej skrajny węzeł po lewej stronie drzewa. Okresowo algorytm CFS zwiększa
wartość vruntime odpowiadającą zadaniu na podstawie czasu, przez jaki już działało, i porównuje
tę wartość z bieżącym skrajnym węzłem po lewej stronie drzewa. Jeśli uruchomione zadanie ma
nadal mniejszą wartość zmiennej vruntime, to będzie ono kontynuowane. W przeciwnym razie
zostanie wstawione w odpowiednim miejscu drzewa czerwono-czarnego, a procesor zostanie
przydzielony do zadania odpowiadającego nowemu skrajnemu węzłowi po lewej stronie.
Aby uwzględnić różnice w priorytetach zadań oraz wartościach parametru nice, CFS modyfi-
kuje tempo, z jakim upływa wirtualny czas zadania w czasie, gdy jest ono uruchomione na proce-
sorze. Dla zadań o niższym priorytecie czas mija szybciej, a wartość powiązanej z nimi zmiennej
vruntime zwiększa się szybciej i w zależności od innych zadań istniejących w systemie szybciej
stracą one czas procesora i zostaną ponownie wstawione do drzewa — prędzej, niż gdyby miały
wyższy priorytet. W ten sposób w algorytmie CFS unika się utrzymywania oddzielnych struktur
kolejek runqueue dla różnych poziomów priorytetu.
Podsumujmy: wybór węzła do uruchomienia może odbywać się w stałym czasie, natomiast
wstawianie zadania do kolejki runqueue odbywa się w czasie O(log(N)), gdzie N oznacza liczbę
zadań w systemie. Jeśli wziąć pod uwagę poziom obciążenia współczesnych systemów, taki
schemat jest dopuszczalny, ale jeśli wydajność węzłów i liczba zadań, które można na nich uru-
chamiać, zwiększą się — zwłaszcza w przestrzeni serwera — to jest możliwe, że w przyszłości
zostaną zaproponowane nowe algorytmy szeregowania.
Oprócz podstawowego algorytmu szeregowania, w algorytmie szeregowania w systemie
Linux uwzględniono specjalne funkcje szczególnie przydatne w przypadku platform wieloproce-
sorowych lub wielordzeniowych. Po pierwsze istnieje osobna struktura runqueue dla każdego
procesora platformy wieloprocesorowej. Algorytm szeregujący próbuje stosować technikę szere-
gowania z uwzględnieniem powinowactwa (ang. affinity scheduling) przypisywać zadania proce-
sorom, na których były już wcześniej wykonywane. Po drugie istnieje specjalny zbiór wywołań
systemowych, za których pośrednictwem można definiować i modyfikować zasady powinowactwa
szeregowanych wątków. I wreszcie algorytm szeregujący wykonuje okresowe procedury rów-
noważenia obciążeń struktur runqueue skojarzonych z poszczególnymi procesorami, aby obcią-
żenie procesorów było możliwie równomierne (z zachowaniem wymagań w zakresie wydajności
i powinowactwa).
Algorytm szeregujący uwzględnia tylko zadania wykonywalne reprezentowane w odpowied-
niej strukturze runqueue. Zadania, których realizacja jest niemożliwa i które oczekują na rozmaite
operacje wejścia-wyjścia lub inne zdarzenia jądra, są reprezentowane w innej strukturze danych:
waitqueue. Struktura waitqueue jest związana z każdym zdarzeniem, na które mogą oczekiwać
zadania. Pierwszy element tej struktury zawiera wskaźnik do jednokierunkowej listy zadań oraz
tzw. blokadę pętlową (ang. spinlock). Obecność tej blokady jest w tym przypadku niezbędna do
zagwarantowania możliwości współbieżnego modyfikowania struktury waitqueue zarówno przez
główny kod jądra, jak i przez procedury obsługi przerwań oraz inne wywołania asynchroniczne.
Synchronizacja w Linuksie
W poprzednim punkcie wspomnieliśmy, że w systemie Linux są wykorzystywane blokady pętlowe
w celu zapobieżenia równoległym modyfikacjom takich struktur danych jak kolejki waitqueue.
W rzeczywistości kod jądra w wielu różnych miejscach zawiera zmienne synchronizujące. W dal-
szej części pokrótce omówimy konstrukcje synchronizacji dostępne w systemie Linux.
Wcześniejsze wersje jądra Linuksa obejmowały zaledwie jedną tzw. wielką blokadę jądra
(ang. Big Kernel Lock — BKL). Takie rozwiązanie było jednak wysoce nieefektywne, szczególnie
w przypadku platform wieloprocesorowych, ponieważ uniemożliwiało procesom przypisanym do
różnych procesorów współbieżne wykonywanie kodu jądra. W odpowiedzi na nowe potrzeby
wprowadzono wiele dodatkowych punktów synchronizacji.
W Linuksie dostępnych jest kilka typów zmiennych synchronizacji — zarówno używanych
wewnątrz jądra, jak i dostępnych dla aplikacji i bibliotek na poziomie użytkownika. Na najniż-
szym poziomie Linux dostarcza procedur-opakowań (ang. wrappers) dla obsługiwanych sprzętowo
atomowych instrukcji, takich jak atomic_set i atomic_read. Ponadto, ponieważ w nowoczesnym
sprzęcie modyfikowana jest kolejność operacji w pamięci, Linux dostarcza barier pamięci. Wyko-
rzystanie takich operacji jak rmb i wmb gwarantuje zakończenie wszystkich operacji odczytu (zapisu)
poprzedzających wywołanie bariery przed każdym kolejnym dostępem.
Częściej są używane wysokopoziomowe konstrukcje synchronizacji. Dla wątków, które nie chcą
się blokować (ze względów wydajności lub poprawności), są wykorzystywane blokady pętlowe
oraz blokady odczytu i zapisu. W nowych wersjach Linuksa zaimplementowano tzw. blokadę
pętlową bazującą na żetonach (ang. ticket-based spinlock), która gwarantuje doskonałą wydajność
zarówno na maszynach jednoprocesorowych, jak i wielordzeniowych. Wątki, które mogą lub
muszą się blokować, korzystają z takich konstrukcji jak muteksy i semafory. Linux obsługuje
wywołania nieblokujące, takie jak mutex_trylock i sem_trywait, do określania stanu zmiennej syn-
chronizacji bez blokowania. Obsługiwane są również inne rodzaje zmiennych synchronizacji, jak
futeksy (ang. futex), uzupełnienia, blokady odczyt-kopiowanie-aktualizacja (RCU) itp. Wreszcie
synchronizację pomiędzy jądrem a kodem wykonywanym za pomocą procedur obsługi przerwań
można również osiągnąć poprzez dynamiczne wyłączanie i włączanie odpowiednich przerwań.
W kolejnym kroku jądro przydziela pamięć swoim strukturom danych. Większość tych struk-
tur cechuje się stałymi rozmiarami, jednak wymiary części z nich, w tym pamięci podręcznej
stron i niektórych struktur tablicy stron, zależą od ilości dostępnej pamięci operacyjnej.
Od tego momentu rozpoczyna się automatyczne konfigurowanie systemu. Na podstawie pli-
ków konfiguracyjnych system określa, jakiego rodzaju urządzenia wejścia-wyjścia mogą się znaj-
dować w systemie, po czym przystępuje do sprawdzania, które urządzenia rzeczywiście są dostępne.
Jeśli badane urządzenie odpowiada na wysłany sygnał, jest dodawane do tablicy zainstalowanych
urządzeń. Jeśli system nie otrzymuje odpowiedzi, przyjmuje, że dane urządzenie nie jest dostępne,
i ignoruje je. Inaczej niż w tradycyjnych wersjach systemu UNIX, sterowniki urządzeń systemu
Linux nie muszą być dołączane statycznie — mogą być ładowane dynamicznie (jak we wszyst-
kich wersjach systemów MS-DOS i Windows).
Argumenty za dynamicznym ładowaniem sterowników i przeciw niemu są na tyle intere-
sujące, że warto poświęcić im trochę uwagi. Najważniejszym argumentem na rzecz dynamicz-
nego ładowania sterowników jest możliwość dostarczania klientom dysponującym różnymi konfi-
guracjami pojedynczych plików binarnych oraz ich automatyczne ładowanie w razie potrzeby (być
może za pośrednictwem sieci). Najważniejszym argumentem przeciw dynamicznemu ładowaniu
sterowników są względy bezpieczeństwa. Jeśli pracujemy na komputerze wymagającym zabez-
pieczeń, np. bankowym serwerze bazy danych lub korporacyjnym serwerze WWW, najprawdo-
podobniej nie chcielibyśmy, aby ktokolwiek mógł umieszczać w jądrze dowolny, niesprawdzony
kod. Administrator takiego systemu może składować pliki źródłowe i wynikowe na bezpiecz-
nym komputerze, aby tam przeprowadzać wszelkie niezbędne operacje kompilacji systemu i aby
jądro w gotowej wersji binarnej przesyłać na pozostałe komputery za pośrednictwem sieci lokalnej.
Jeśli dynamiczne ładowanie sterowników jest niemożliwe, opisany scenariusz wyklucza możli-
wość umieszczania w jądrze złośliwego lub niesprawdzonego kodu przez użytkowników dyspo-
nujących hasłem superużytkownika. Co więcej, w przypadku dużych sieci komputerowych konfigu-
racje sprzętowe są doskonale znane już na etapie kompilacji i łączenia systemu. Konieczność
zmian ma wówczas miejsce na tyle rzadko, że wykonanie dodatkowych zadań związanych z insta-
lacją nowego sprzętu nie stanowi problemu.
Kiedy już cały sprzęt zostanie odpowiednio skonfigurowany, jądro stanie przed koniecznością
ostrożnego przygotowania, ustawienia stosu i uruchomienia procesu zerowego. Proces zerowy
kontynuuje inicjalizację, przeprowadzając takie operacje jak zaprogramowanie zegara czasu rze-
czywistego, zamontowanie głównego systemu plików czy utworzenie procesów pierwszego (init)
i drugiego (demona stron).
Proces init sprawdza swoje flagi pod kątem konieczności obsługi jednego lub wielu użytkow-
ników. W pierwszym przypadku jego zadanie ogranicza się do rozwidlenia procesu uruchamia-
jącego powłokę i oczekiwania na zakończenie tego procesu. W drugim przypadku proces init
musi się rozwidlić, aby wykonać skrypt powłoki odpowiedzialny za inicjalizację systemu (/etc/rc),
czyli np. weryfikację spójności systemu plików, zamontowanie dodatkowych systemów plików,
uruchomienie procesów demonów itp. Należy następnie przeanalizować zawartość pliku /etc/ttys
pod kątem zawartych tam ustawień terminali. Dla każdego włączonego terminala opisywany proces
rozwidla się, tworząc swoją kopię, która po odpowiednim przygotowaniu środowiska uruchamia
program nazwany getty.
Program getty ustawia przepustowość i inne właściwości poszczególnych łączy (np. mode-
mów), po czym wyświetla na ekranie terminala następujący komunikat:
login:
W ten sposób program getty próbuje uzyskać nazwę użytkownika za pośrednictwem klawiatury.
Kiedy użytkownik usiądzie przed terminalem i wpisze swoją nazwę, program getty zakończy
działanie i przekaże sterowanie programowi logującemu /bin/login. Program login pyta użytkow-
nika o hasło, szyfruje je i porównuje z zaszyfrowanym hasłem składowanym w pliku haseł
(/etc/passwd). Jeśli hasło wpisane przez użytkownika jest prawidłowe, program login urucha-
mia w swoje miejsce powłokę użytkownika, która oczekuje na pierwsze polecenie. Jeśli jednak
hasło jest nieprawidłowe, program login żąda podania innej nazwy użytkownika. Działanie tego
mechanizmu w systemie obejmującym trzy terminale pokazano na rysunku 10.5.
Na rysunku proces getty działający na terminalu 0 nadal czeka na dane wejściowe. Na termi-
nalu 1 użytkownik wpisał swoją nazwę, zatem program getty jest zastępowany przez program
login, który żąda od użytkownika podania hasła. Na terminalu 2 miało już miejsce udane logo-
wanie, po którym proces powłoki wyświetlił na ekranie znak zachęty (%). Użytkownik korzy-
stający z tego terminala wpisał następujące polecenie:
cp f1 f2
które powoduje rozwidlenie powłoki i utworzenie procesu potomnego wykonującego program cp.
Proces powłoki jest blokowany w oczekiwaniu na zakończenie wykonywania procesu potom-
nego — powłoka wyświetli wówczas następny znak zachęty i odczyta kolejne polecenie wpisane
przez użytkownika na klawiaturze. Gdyby użytkownik terminala 2 wpisał polecenie cc zamiast
polecenia cp, uruchomiłby główny program kompilatora języka C, który z kolei zostałby rozwi-
dlony i utworzyłby kolejne procesy odpowiedzialne za realizację kolejnych żądań kompilacji.
Model pamięci systemu Linux jest dość prosty, dzięki czemu programy tworzone dla tego systemu
są przenośne, a sam system można zaimplementować dla platform ze zróżnicowanymi jednost-
kami zarządzania pamięcią, od systemów niemal pozbawionych tego rodzaju mechanizmów (jak
Rysunek 10.6. (a) Wirtualna przestrzeń adresowa procesu A; (b) pamięć fizyczna; (c) wirtualna
przestrzeń adresowa procesu B
Segment danych jest miejscem składowania wszystkich zmiennych, łańcuchów, tablic i innych
danych programu. Segment danych składa się z dwóch części — danych inicjalizowanych i danych
nieinicjalizowanych. Druga część z przyczyn historycznych jest nazywana blokiem BSS (od ang.
Block Started by Symbol). Część danych inicjalizowanych obejmuje zmienne i stałe kompilatora,
które w czasie uruchamiania programu wymagają przypisania konkretnych wartości początko-
wych. Wszystkie zmienne należące do bloku BSS są inicjalizowane wartością zero po załado-
waniu programu.
Przykładowo w języku C istnieje możliwość jednoczesnego zadeklarowania i zainicjalizowania
łańcucha znakowego. Kiedy program jest uruchamiany, zakłada, że dany łańcuch ma przypisaną
pewną wartość początkową. Implementacja tej konstrukcji wymaga od kompilatora przypisania
danemu łańcuchowi miejsca w przestrzeni adresowej i zagwarantowania, że podczas uruchamiania
programu we wspomnianym miejscu będzie składowany odpowiedni łańcuch. Z perspektywy
systemu operacyjnego dane inicjalizowane nie różnią się znacząco od tekstu samego programu —
w obu przypadkach mamy bowiem do czynienia ze wzorcami bitów wygenerowanymi przez kom-
pilator i wymagającymi załadowania do pamięci w czasie uruchamiania programu.
Istnienie danych nieinicjalizowanych jest w istocie rozwiązaniem na rzecz większej optyma-
lizacji. Jeśli zmienna globalna nie jest inicjalizowana wprost, semantyka języka programowania C
mówi, że jej wartością początkową jest 0. W praktyce większość zmiennych globalnych nie jest
inicjalizowana bezpośrednio, zatem początkowo reprezentuje wartość 0. Można tę regułę zaim-
plementować poprzez wyznaczenie sekcji wykonywalnego pliku binarnego o rozmiarach rów-
nych liczbie bajtów zajmowanych przez zmienne nieinicjalizowane (oraz inicjalizowane z war-
tością 0) i umieszczenie we wszystkich tych bajtach wartości 0.
Takie rozwiązanie nie jest jednak stosowane, ponieważ wiąże się ze sporym wzrostem roz-
miaru plików wykonywalnych. Plik wykonywalny obejmuje więc tylko wprost inicjalizowane
zmienne i właściwy tekst programu. Wszystkie nieinicjalizowane zmienne są zebrane w jed-
nym miejscu (za zmiennymi inicjalizowanymi), a kompilator ogranicza się do umieszczenia
w nagłówku słowa reprezentującego liczbę bajtów, które w czasie uruchamiania programu należy
im przydzielić.
Aby lepiej zrozumieć działanie tego mechanizmu, wróćmy do rysunku 10.6(a). Jak widać,
zarówno tekst programu, jak i inicjalizowane dane zajmują po 8 kB. W tym przypadku dane nieini-
cjalizowane (BSS) zajmują 4 kB. Plik wykonywalny zajmuje zaledwie 16 kB (tekstu programu
i inicjalizowanych danych) oraz krótki nagłówek nakazujący systemowi (przed właściwym uru-
chomieniem programu) przydzielenie kolejnych 4 kB za inicjalizowanymi danymi oraz wypeł-
nienie tej przestrzeni zerami. W ten sposób uniknięto konieczności składowania w pliku wykony-
walnym 4 kB zer.
Aby uniknąć każdorazowego przydzielania fizycznej ramki strony wypełnionej zerami, w trakcie
inicjalizacji system Linux wyznacza statyczną stronę zerową, czyli chronioną przed zapisem stronę
wypełnioną zerami. W czasie ładowania procesu obszar danych nieinicjalizowanych jest tak usta-
wiany, aby wskazywał właśnie na stronę zerową. Kiedy proces próbuje następnie zapisać wartość
w tym obszarze, do gry wkracza mechanizm kopiowania przy próbie zapisu, który przypisuje
danemu procesowi właściwą ramkę strony.
W przeciwieństwie do segmentu tekstu segment danych może być zmieniany. Programy
stale modyfikują swoje zmienne. Co więcej, wiele programów musi w czasie wykonywania dyna-
micznie przydzielać swoim zmiennym niezbędną przestrzeń pamięciową. Linux obsługuje tego
rodzaju żądania, umożliwiając rozszerzanie i zmniejszanie segmentu danych w odpowiedzi na
operacje przydziału i zwalniania pamięci. Istnieje specjalne wywołanie systemowe, nazwane brk,
za którego pośrednictwem program może ustawiać rozmiar swojego segmentu danych. Ozna-
cza to, że aby uzyskać więcej pamięci, program może zwiększyć rozmiar swojego segmentu
danych. Do przydzielania pamięci najczęściej wykorzystuje się procedurę malloc biblioteki C,
która z natury rzeczy korzysta z wywołania systemowego brk. Deskryptor przestrzeni adre-
sowej procesu zawiera informacje o obszarach pamięci dynamicznie przydzielonych danemu pro-
cesowi, tzw. stercie (ang. heap).
Trzecim segmentem jest stos. Na większości komputerów stos rozpoczyna się na szczycie
lub blisko szczytu wirtualnej przestrzeni adresowej i rośnie w dół w kierunku adresu zerowego.
Przykładowo na 32-bitowej platformie x86 stos rozpoczyna się pod adresem 0xC0000000, zatem
wirtualna przestrzeń adresowa widoczna dla procesów w trybie użytkownika obejmuje 3 GB.
Jeśli stos rozrośnie się poniżej dolnej granicy tego segmentu, wystąpi błąd sprzętowy, a system
operacyjny obniży tę granicę o jedną stronę. Same programy wprost nie zarządzają rozmiarem
segmentu stosu.
Kiedy program jest uruchamiany, jego stos nie jest pusty. Przeciwnie, zawiera wszystkie
zmienne środowiskowe (powłoki) oraz wiersz polecenia wpisany przez użytkownika w powłoce,
aby uruchomić dany program. Dzięki temu program ma dostęp do swoich argumentów. Jeśli
np. użytkownik wpisze polecenie w postaci:
cp src dest
program cp będzie dysponował na swoim stosie łańcuchem "cp src dest", z którego będzie
mógł łatwo wyodrębnić nazwy plików źródłowego i docelowego. Wspomniany łańcuch jest repre-
zentowany w formie tablicy wskaźników do symboli tego łańcucha, aby ułatwić jego analizę.
Jeśli dwóch użytkowników korzysta z tego samego programu, np. z edytora tekstu, istnieje
możliwość (choć byłoby to nieefektywne) utrzymywania w pamięci dwóch kopii tekstu tego pro-
gramu. Zamiast tego systemy Linux stosują jednak mechanizm współdzielonych segmentów tekstu
(ang. shared text segments). Na rysunkach 10.6(a) i 10.6(c) pokazano dwa procesy, A i B, które
obejmują ten sam segment tekstu. Na rysunku 10.6(b) pokazano możliwy układ pamięci fizycznej,
w którym oba procesy korzystają z tego samego fragmentu tekstu. Za przedstawione odwzoro-
wanie odpowiada sprzętowy mechanizm pamięci wirtualnej.
Segmenty danych i stosu nigdy nie są współdzielone, z wyjątkiem sytuacji, w której proces
macierzysty jest rozwidlany na proces rodzica i proces dziecka (wówczas współdzielone są tylko
te strony, które nie podlegają modyfikacjom). Jeśli któryś z tych segmentów wymaga powięk-
szenia i jeśli nie jest możliwe rozszerzenie o przylegające segmenty pamięci, nie możemy mówić
o problemie, ponieważ sąsiednie strony wirtualne nie muszą być odwzorowywane w sąsiednie
strony fizyczne.
Mechanizmy sprzętowe w niektórych komputerach obsługują odrębne przestrzenie adre-
sowe dla rozkazów i danych. Okazuje się, że system Linux potrafi ten model wykorzystać; np.
na komputerze z adresami 32-bitowymi i obsługą tego rozwiązania mamy do dyspozycji 232 bitów
przestrzeni adresowej dla rozkazów i dodatkowe 232 bitów przestrzeni adresowej dla segmentów
danych i stosu. Skok pod adres 0 powoduje przejście pod ten adres przestrzeni tekstu, natomiast
przeniesienie wartości z adresu 0 odwołuje się do położenia w przestrzeni danych. Opisany
model pozwala więc podwoić ilość dostępnej przestrzeni adresowej.
Oprócz dynamicznego przydzielania dodatkowej pamięci procesy systemu Linux mogą uzy-
skiwać dostęp do pliku danych za pośrednictwem tzw. plików odwzorowywanych w pamięci
(ang. memory-mapped files). Odpowiedni mechanizm umożliwia odwzorowywanie plików w części
przestrzeni adresowej procesu, aby jego zawartość mogła być odczytywana i zapisywana, tak
jakby była to tablica bajtów w pamięci operacyjnej. Ten sposób odwzorowania pliku znacznie
ułatwia swobodny dostęp do jego zawartości w porównaniu ze standardowymi wywołaniami
systemowymi wejścia-wyjścia, jak read czy write. Prezentowany mechanizm odwzorowywania
jest wykorzystywany m.in. do uzyskiwania dostępu do bibliotek dzielonych. Na rysunku 10.7
pokazano plik odwzorowany jednocześnie w ramach dwóch procesów, ale pod różnymi adresami
wirtualnymi.
Dodatkową zaletą tego mechanizmu jest możliwość jednoczesnego odwzorowywania tego
samego pliku przez dwa procesy lub większą ich liczbę. Operacje zapisu wykonywane na tym pliku
przez dowolny proces są natychmiast widoczne dla innych procesów. W praktyce odwzorowywanie
pliku tymczasowego (usuwanego po zakończeniu pracy przez wszystkie procesy) stanowi wygodny
i efektywny mechanizm współdzielenia pamięci przez wiele procesów. W skrajnym przypadku
dwa procesy (lub większa liczba procesów) mogą odwzorować plik zajmujący całą przestrzeń
adresową i utworzyć model stanowiący wypadkową pomiędzy odrębnymi procesami, a wątkami.
Rysunek 10.7. Dwa procesy mogą współdzielić jeden plik odwzorowany w pamięci
Przestrzeń adresowa jest wówczas współdzielona (jak w przypadku wątków), ale każdy proces
utrzymuje własne otwarte pliki i sygnały (inaczej niż w przypadku wątków). W praktyce jednak
nie stosuje się rozwiązań całkowicie pokrywających się przestrzeni adresowych.
Tabela 10.5. Wybrane wywołania systemowe związane z zarządzaniem pamięcią. W razie wystąpienia
jakiegoś błędu kod wynikowy s ma wartość –1; a oraz addr reprezentują adresy w pamięci;
len reprezentuje długość; prot określa zasady ochrony; flags reprezentuje rozmaite bity ustawień;
fd jest deskryptorem pliku, a offset określa przesunięcie w pliku
Wywołania systemowe Opis
s = brk(addr) Zmienia rozmiar segmentu danych
a = mmap(addr, len, prot, flags, fd, offset) Odwzorowuje plik w pamięci
s = unmap(addr,len) Usuwa odwzorowanie pliku
Pozostałe wolne bloki tej wielkości można odnaleźć dzięki wskaźnikom zawartym w poszcze-
gólnych deskryptorach stron.
I wreszcie, ponieważ system Linux jest przenośny do architektur NUMA (które charakte-
ryzują się zróżnicowanymi czasami dostępu do różnych adresów w pamięci), wewnętrznie sto-
suje deskryptory węzłów, które mają umożliwić skuteczne rozróżnianie pamięci fizycznej w po-
szczególnych węzłach (i przydzielanie obszarów z różnych węzłów). Każdy deskryptor węzła
zawiera informacje o wykorzystaniu pamięci i o strefach w konkretnym węźle. Na platformach
UMA system Linux opisuje całą pamięć za pomocą zaledwie jednego takiego deskryptora.
Pierwsze bity każdego deskryptora strony są wykorzystywane do identyfikacji węzła i strefy,
do której należy ramka tej strony.
Aby mechanizm stronicowania działał efektywnie w architekturach 32- i 64-bitowych, system
Linux wykorzystuje czteropoziomowy schemat stronicowania. Trzypoziomowy schemat stroni-
cowania, który po raz pierwszy zastosowano w systemie komputerów Alpha, zdecydowano się
rozszerzyć po wydaniu Linuksa 2.6.10; w wersji 2.6.11 wprowadzono nowy, czteropoziomowy
schemat stronicowania. Każdy adres wirtualny jest dzielony na pięć pól (patrz rysunek 10.9).
Pola katalogów wykorzystuje się w roli indeksu wskazującego właściwe katalogi stron (każdy
proces dysponuje własnym katalogiem). W każdym polu jest składowany wskaźnik do jednego
z katalogów następnego poziomu, które także są indeksowane przez pewne pole adresu wirtu-
alnego. Wybrany wpis w środkowym katalogu stron wskazuje na ostateczną tablicę stron, która
z kolei jest indeksowana przez pole strony adresu wirtualnego. Na tym poziomie wpis identyfikuje
już konkretną stronę. W architekturze Pentium, w której stosuje się stronicowanie dwupoziomowe,
górne i środkowe katalogi stron zawierają po jednym wpisie, zatem wpis w katalogu globalnym
pamięci, w pierwszym kroku zaokrągla żądaną liczbę stron do potęgi dwójki, np. do ośmiu. Cały
obszar pamięci zostaje następnie podzielony na pół, jak w części (b) rysunku. Ponieważ każda
z tych części okazuje się zbyt duża, pierwsza z nich jest ponownie dzielona na pół (c), po czym
następuje podział pierwszej z otrzymanych połówek (d). Dopiero teraz dysponujemy obszarem
właściwych rozmiarów — wyróżnionym kolorem szarym w części (d) — zatem można go przy-
dzielić procesowi, który skierował żądanie do dyspozytora.
Przypuśćmy teraz, że drugie żądanie dotyczy ośmiu stron. Można to żądanie zrealizować od
razu, jak w części (e). Przyjmijmy, że trzecie żądanie dotyczy czterech stron. Najmniejszy dostępny
obszar jest dzielony (f), a jego połowa jest przydzielana żądającemu procesowi (g). Następnie
jest zwalniany drugi z 8-stronicowych obszarów (h). I wreszcie następuje zwolnienie drugiego
ośmiostronicowego obszaru. Ponieważ dwa zwolnione obszary należały do tego samego frag-
mentu obejmującego łącznie szesnaście stron, dyspozytor może je ponownie scalić (i).
Linux zarządza pamięcią, stosując algorytm bliźniaków wzbogacony o dodatkową tablicę,
której pierwszy element reprezentuje początek listy bloków zajmujących po jednej jednostce,
drugi element reprezentuje początek listy bloków zajmujących po dwie jednostki, następny ele-
ment wskazuje na bloki zajmujące po cztery jednostki itd. Takie rozwiązanie umożliwia błyska-
wiczne odnajdywanie bloków o rozmiarach równych potędze liczby dwa.
Opisany algorytm prowadzi do sporej fragmentacji wewnętrznej, ponieważ w odpowiedzi na
żądanie obszaru zajmującego 65 stron otrzymujemy obszar zajmujący aż 128 stron.
Aby złagodzić skutki tego problemu twórcy systemu Linux stworzyli drugi mechanizm
przydzielania pamięci, tzw. dyspozytor płytowy, plastrowy (ang. slab allocator), który przydziela
pamięć, stosując standardowy algorytm bliźniaków, by następnie wycinać z nich plastry (mniejsze
jednostki) i zarządzać tymi drobnymi obszarami niezależnie od siebie.
Ponieważ jądro często tworzy i niszczy obiekty pewnych typów (np. typu task_struct), wyko-
rzystuje tzw. pamięci podręczne obiektów (ang. object caches). Pamięci podręczne obiektów składają
się ze wskaźników do jednego lub wielu plastrów, które mogą zawierać wiele obiektów tego
samego typu. Każdy z tych plastrów może być pełny, częściowo zapełniony lub pusty.
Jeśli np. jądro musi przydzielić pamięć dla nowego deskryptora procesu, czyli dla nowej struk-
tury typu task_struct), w pierwszej kolejności szuka częściowo zapełnionej pamięci podręcznej
obiektów dla struktur zadań, aby właśnie tam przydzielić pamięć nowemu obiektowi task_struct.
Jeśli taki plaster nie jest dostępny, jądro przeszukuje listę pustych plastrów. I wreszcie (w razie
niepowodzenia) jądro przydziela tworzonej strukturze nowy plaster, po czym wiąże ten plaster
z pamięcią podręczną struktur zadań. Usługę jądra kmalloc, która odpowiada za przydzielanie
fizycznie ciągłych obszarów pamięci w przestrzeni adresowej jądra, w rzeczywistości zbudowano
właśnie ponad opisanym tutaj interfejsem plastrów i pamięci podręcznych obiektów.
Istnieje też trzeci dyspozytor pamięci, nazwany vmalloc, który jest wykorzystywany w sytu-
acji, gdy żądana pamięć musi być ciągła tylko w przestrzeni wirtualnej (kiedy nie jest wymagana
ciągłość w pamięci fizycznej). Okazuje się, że ten warunek spełnia zdecydowana większość żądań
przydziału pamięci. Wyjątkiem są urządzenia, które pracują po drugiej stronie magistrali pamięci
i jednostki zarządzania pamięcią, zatem z natury rzeczy nie potrafią interpretować adresów wirtu-
alnych. Warto jednak pamiętać, że stosowanie wywołania vmalloc powoduje pewien spadek
wydajności, zatem jest stosowane przede wszystkim do przydzielania ogromnych, ciągłych obsza-
rów wirtualnej przestrzeni adresowej, np. na potrzeby dynamicznie ładowanych modułów jądra.
Wszystkie te mechanizmy zbudowano na bazie rozwiązań zastosowanych w Systemie V.
stronicowania stałej wielkości, określanego mianem obszaru wymiany (ang. swap area). Pliki
stronicowania można dodawać i usuwać dynamicznie, a każdy z nich ma przypisywany priorytet.
Warto pamiętać, że stronicowanie do odrębnej partycji (traktowanej jako osobne urządzenie) jest
z kilku powodów bardziej efektywne niż stronicowanie do pliku. Po pierwsze ta forma stronicowa-
nia nie wymaga odwzorowywania pomiędzy blokami plików a blokami dyskowymi, zatem elimi-
nuje konieczność dyskowych operacji wejścia-wyjścia na pośrednich blokach. Po drugie operacje
zapisu w urządzeniu fizycznym mogą obejmować dane dowolnych rozmiarów i nie są ograni-
czone do rozmiaru bloku pliku. Po trzecie strona zawsze jest zapisywana w ciągłym obszarze
na dysku; w przypadku pliku stronicowania nie mamy takiej gwarancji.
Strony nie są alokowane w urządzeniu lub partycji stronicowania do czasu wystąpienia takiej
potrzeby. Każde urządzenie i każdy plik rozpoczyna się od mapy bitowej określającej, które strony
są wolne. Jeśli strona pozbawiona zapasowej pamięci (w formie powiązanego pliku dyskowego)
musi zostać usunięta z pamięci głównej, system wybiera dla niej partycję lub plik stronicowania
z najwyższym priorytetem, który dysponuje odpowiednią przestrzenią. W normalnych warunkach
partycja stronicowania (jeśli istnieje) ma przypisany wyższy priorytet niż jakikolwiek plik stroni-
cowania. Tablica stron jest wówczas aktualizowana, aby uwzględniać brak danej strony w pamięci
głównej (np. poprzez ustawienie bitu braku strony w pamięci) i wskazywać miejsce składowania
tej strony na dysku.
Działanie algorytmu PFRA zawsze rozpoczyna się od próby odzyskania stron, których usunięcie
z pamięci jest najprostsze, by następnie podjąć próbę zmierzenia się z „trudniejszymi” stronami.
Wiele osób również zaczyna pracę od rzeczy najprostszych. Strony usuwalne i strony, które nie
są wskazywane przez żadne odwołania, można odzyskać błyskawicznie, przenosząc je na listę
wolnych stron danej strefy. Algorytm PFRA szuka następnie stron z pamięcią zapasową, które
w ostatnim czasie nie były wykorzystywane — identyfikuje je, stosując metodę zbliżoną do algo-
rytmu zegarowego. Bezpośrednio potem opisywany mechanizm podejmuje próbę usunięcia stron
współdzielonych, które sprawiają wrażenie szczególnie rzadko wykorzystywanych przez użyt-
kowników. Ze stronami współdzielonymi wiąże się jednak pewien problem — jeśli taka strona
jest odzyskiwana, tablice stron wszystkich przestrzeni adresowych, które do tej pory współ-
dzieliły tę stronę, muszą zostać zaktualizowane w sposób zsynchronizowany. Linux utrzymuje
efektywną strukturę drzewiastą, która umożliwia łatwe odnajdywanie wszystkich użytkowników
strony współdzielonej. Algorytm PFRA poszukuje następnie zwykłych stron użytkownika, które
w razie decyzji o usunięciu z pamięci głównej muszą zostać przeniesione do obszaru wymiany.
Jednym z parametrów tego algorytmu jest tzw. wymienność (ang. swappiness) systemu, czyli
stosunek stron, dla których istnieje pamięć zapasowa, do stron wymagających wymiany w trakcie
sesji algorytmu PFRA. I wreszcie jeśli strona jest nieprawidłowa, poza pamięcią, współdzielona,
w pamięci zablokowanej lub w użyciu przez operacje DMA, algorytm PFRA pomija ją.
Algorytm PFRA wykorzystuje do wyboru starych stron w poszczególnych kategoriach tech-
nikę zbliżoną do algorytmu zegarowego. Sercem tego algorytmu jest pętla, która przeszukuje listy
aktywnych i nieaktywnych stron w poszczególnych strefach, próbując odzyskiwać różne rodzaje
stron z odmienną intensywnością (zależną od ich charakteru). Wartość reprezentująca priorytet
odzyskiwania stron jest przekazywana w formie parametru odpowiedniej procedury i decyduje
o zakresie działań na rzecz zwalniania pewnych stron. Parametr ten zwykle określa, ile stron
należy przeanalizować przed rezygnacją z dalszych działań.
W czasie działania algorytmu PFRA strony są przenoszone pomiędzy listą stron aktywnych
a listą stron nieaktywnych w sposób zilustrowany na rysunku 10.11. Aby skutecznie odnajdy-
wać strony, które nie są przedmiotem żadnych odwołań i które najprawdopodobniej nie będą
potrzebne w najbliższej przyszłości, algorytm PFRA utrzymuje dla każdej strony dwie flagi:
określającą, czy dana strona jest aktywna, oraz określającą, czy jest przedmiotem odwołań.
Wspomniane flagi łącznie kodują cztery stany (patrz rysunek 10.11). Podczas pierwszego prze-
szukiwania zbioru stron algorytm PFRA zeruje bity odwołań. Jeśli w trakcie drugiego przeglądu
algorytm odkrywa, że strona jest przedmiotem odwołań, zmienia jej stan na taki, w którym usu-
nięcie z pamięci jest mniej prawdopodobne. W przeciwnym razie stan strony zostaje zmieniony
na taki, w którym usunięcie z pamięci staje się bardziej prawdopodobne.
Strony na liście stron nieaktywnych, które nie były przedmiotem odwołań od czasu ostatniej
weryfikacji, z natury rzeczy są najlepszymi kandydatami do usunięcia z pamięci głównej. Strony
z tej grupy charakteryzują się wartością zerową w bitach PG_active i PG_referenced (patrz rysu-
nek 10.11). Warto przy tej okazji wspomnieć, że w razie konieczności można odzyskać także strony
znajdujące się w pozostałych stanach. Tego rodzaju operacje zilustrowano na rysunku 10.11 strzał-
kami Ponowne wypełnienie.
Algorytm PRFA utrzymuje na liście stron nieaktywnych nawet strony, które mogą być przed-
miotem odwołań, aby uniknąć sytuacji opisanej poniżej. Wyobraźmy sobie proces uzyskujący okre-
sowo, np. co godzinę, dostęp do różnych stron. Strona, która była przedmiotem dostępu od czasu
ostatniego przejścia pętli, będzie miała ustawioną flagę odwołania. Ponieważ jednak nie będzie
potrzebna przez następną godzinę, algorytm PRFA nie traktuje jej jako kandydata do usunięcia
z pamięci głównej.
Jednym z ważnych aspektów opisywanego systemu zarządzania pamięcią, o którym do tej pory
nie wspominaliśmy, jest drugi demon, pdflush (czyli w praktyce zbiór działających w tle wąt-
ków demona). Wątki demona pdflush albo (1) aktywują się w stałych odstępach czasu (zwykle
co 500 ms), aby zapisać na dysku najstarsze, brudne strony, albo (2) są aktywowane wprost przez
jądro, kiedy tylko ilość dostępnej pamięci spada poniżej pewnego progu, aby zapisać na dysku
brudne strony z pamięci podręcznej. W tzw. trybie laptopa, w którym system dąży do przedłu-
żenia żywotności baterii, brudne strony są zapisywane na dysku za każdym razem, gdy demon
pdflush jest aktywowany. Brudne strony mogą też być zapisywane na dysku w odpowiedzi na
jawne żądania synchronizacji, np. za pośrednictwem takich wywołań systemowych jak sync,
fsync czy fdatasync. Starsze wersje systemu Linux wykorzystywały w tej roli dwa odrębne
demony: kupdate odpowiedzialny za okresowe odzyskiwanie starych stron oraz bdflush odpo-
wiedzialny za odzyskiwanie stron w warunkach brakującej pamięci. W jądrze 2.4 zintegrowano
oba mechanizmy w ramach wątków demona pdflush. Decyzja o zastosowaniu wielu wątków miała
na celu wyeliminowanie długich opóźnień związanych z operacjami dyskowymi.
W Linuksie system wejścia-wyjścia jest stosunkowo prosty i nie różni się zbytnio od swoich
odpowiedników w innych systemach z rodziny UNIX. W największym skrócie wszystkie urzą-
dzenia wejścia-wyjścia mają przypominać pliki i być dostępne za pomocą tych samych wywołań
systemowych read i write, które stosuje się dla zwykłych plików dyskowych. W niektórych przy-
padkach konieczne jest ustawienie parametrów urządzeń, co można zrobić za pomocą specjalnego
wywołania systemowego. Przeanalizujemy to i inne zagadnienia w kolejnych punktach tego pod-
rozdziału.
ma przypisaną ścieżkę, zwykle w katalogu /dev. Dysk twardy np. może być reprezentowany przez
plik /dev/hd1, drukarka może być reprezentowana przez plik /dev/lp, a interfejs sieciowy może
występować jako plik /dev/net.
Dostęp do tych plików specjalnych można uzyskiwać w taki sam sposób jak w przypadku
wszystkich innych plików. Nie są potrzebne żadne specjalne polecenia ani wywołania systemowe.
W zupełności wystarczą standardowe wywołania systemowe open, read i write. I tak polecenie
w postaci:
cp file /dev/lp
skopiuje zawartość pliku file do urządzenia drukarki, czyli w praktyce spowoduje jego wydruko-
wanie (oczywiście pod warunkiem że dany użytkownik ma uprawnienia dostępu do pliku spe-
cjalnego /dev/lp). Programy mogą otwierać, odczytywać i zapisywać pliki specjalne dokładnie
tak samo, jak robią to z tradycyjnymi plikami. W rzeczywistości uruchomiony powyżej program
cp nawet nie „wie”, że drukuje zawartość wskazanego pliku. Oznacza to, że wykonywanie tego
rodzaju operacji wejścia-wyjścia nie wymaga żadnego specjalnego mechanizmu.
Pliki specjalne dzieli się na dwie kategorie: blokowe i znakowe. Specjalne pliki blokowe to
takie, które składają się z sekwencji ponumerowanych bloków. Najważniejszą cechą specjalnego
pliku blokowego jest możliwość odrębnego zaadresowania i uzyskania dostępu do każdego bloku.
Inaczej mówiąc, program może otworzyć specjalny plik blokowy i odczytać np. blok nr 124 bez
konieczności uprzedniego odczytania bloków 0 – 123. Specjalne pliki blokowe zwykle stosuje się
w roli reprezentacji dysków.
Specjalne pliki znakowe z reguły wykorzystuje się dla urządzeń, których wejście lub wyjście
ma postać strumieni znaków. Do tej grupy zalicza się klawiatury, drukarki, interfejsy sieciowe,
myszy, plotery i większość innych urządzeń wejścia-wyjścia otrzymujących lub generujących
dane dla swoich użytkowników. Nie można np. (byłoby to zresztą dość osobliwe) szukać bloku
nr 124 w myszy.
Z każdym plikiem specjalnym jest związany sterownik urządzenia odpowiedzialny za jego
obsługę. Każdy sterownik obejmuje tzw. główny numer urządzenia (ang. major device number),
który służy do identyfikacji tego sterownika. Jeśli jeden sterownik obsługuje wiele urządzeń,
np. dwa dyski tego samego typu, każdy dysk ma przypisany pomocniczy numer urządzenia
(ang. minor device number), który identyfikuje konkretne urządzenie. Numery główny i pomoc-
niczy łącznie jednoznacznie identyfikują każde urządzenie wejścia-wyjścia. W pewnych przypad-
kach pojedynczy sterownik obsługuje dwa ściśle powiązane urządzenia; np. sterownik reprezen-
towany przez plik /dev/tty kontroluje zarówno klawiaturę, jak i ekran, które często są postrzegane
jako jedno urządzenie — tzw. terminal.
Mimo że większość znakowych plików specjalnych nie oferuje możliwości swobodnego dostępu
do danych, często wymaga mechanizmów kontroli, które nie są potrzebne w przypadku bloko-
wych plików specjalnych. Weźmy np. dane wejściowe wpisywane za pomocą klawiatury i wyświe-
tlane na ekranie. Kiedy użytkownik popełnił błąd i chce usunąć ostatni wpisany znak, naciska
odpowiedni klawisz. Niektórzy wolą korzystać z klawisza Backspace, inni wybierają przycisk Del.
Z usunięciem całego ostatniego wiersza także wiąże się kilka konwencji. Tradycyjnym rozwiąza-
niem było wykorzystywanie w tej roli znaku @, jednak rosnąca popularność poczty elektronicznej
spowodowała, że w wielu systemach zastąpiono ten znak kombinacją Ctrl+U lub innym znakiem.
Podobnie przerwanie wykonywania programu wymaga użycia odpowiedniego znaku specjalnego.
Także w tym obszarze różni użytkownicy mają różne preferencje. Typowym rozwiązaniem było
stosowanie kombinacji Ctrl+C, jednak nie jest to rozwiązanie w pełni uniwersalne.
po 512 bajtów, i jeśli odbiorca zażąda 2560 bajtów, w przypadku użycia gniazda pierwszego typu
odbiorca natychmiast otrzyma wszystkie 2560 bajtów, a w razie użycia gniazda drugiego typu
zostanie zwróconych tylko 512 bajtów (uzyskanie pozostałych danych będzie wymagało czterech
dodatkowych wywołań systemowych). Gniazdo trzeciego typu ma na celu zapewnienie użyt-
kownikowi bezpośredniego dostępu do sieci. Ten typ gniazd jest szczególnie przydatny w apli-
kacjach czasu rzeczywistego oraz w sytuacjach, w których użytkownik chce zaimplementować
własny, wyspecjalizowany schemat obsługi błędów. Warto jednak pamiętać, że pakiety mogą
zostać utracone, a ich kolejność może się zmienić podczas przesyłania za pośrednictwem sieci.
Trzeci typ gniazd nie daje nam więc takich gwarancji jak dwa pierwsze typy. Największą zaletą
tego trybu jest wyższa wydajność, która czasem okazuje się ważniejsza od niezawodności
(np. w przypadku przesyłania danych multimedialnych, kiedy wysoka przepustowość jest waż-
niejsza od niezawodności przekazu).
Podczas tworzenia gniazda jeden z parametrów określa protokół, którego należy użyć. W przy-
padku niezawodnych strumieni bajtowych najbardziej popularny jest protokół TCP (od ang. Trans-
mission Control Protocol). Do zawodnej transmisji pakietowej zwykle wykorzystuje się protokół
UDP (od ang. User Datagram Protocol). Oba protokoły znajdują się w warstwie ponad protokołem
IP (od ang. Internet Protocol). Wszystkie te protokoły opracowano na potrzeby sieci ARPANET
stworzonej na zamówienie Departamentu Obrony Stanów Zjednoczonych, a obecnie stanowią
podstawę internetu. Warto przy tej okazji wspomnieć, że nie istnieje standardowy, powszechnie
stosowany protokół dla niezawodnych strumieni pakietów.
Zanim będziemy mogli użyć gniazda do komunikacji sieciowej, musimy skojarzyć z nim kon-
kretny adres. Wskazany adres może się mieścić w jednej z wielu domen nazewniczych. Najbar-
dziej popularna jest internetowa domena nazewnicza, w której komputery są identyfikowane przez
32-bitowe liczby całkowite w systemie Version 4 oraz 128-bitowe liczby całkowite w systemie Ver-
sion 6 (Version 5 był systemem eksperymentalnym, który nigdy nie został spopularyzowany).
Po utworzeniu gniazd zarówno na komputerze źródłowym, jak i na komputerze docelowym
można ustanowić połączenie pomiędzy tymi komputerami (na potrzeby komunikacji połącze-
niowej). Jedna ze stron połączenia wykonuje wywołanie systemowe listen dla lokalnego gniazda,
aby utworzyć bufor i bloki dla przychodzących danych. Druga strona wykonuje wywołanie syste-
mowe connect, przekazując na jego wejściu deskryptor pliku (reprezentujący lokalne gniazdo)
oraz adres zdalnego gniazda. Jeśli zdalny komputer zaakceptuje to wywołanie, system ustanowi
połączenie pomiędzy tymi gniazdami.
Ustanowione połączenie działa analogicznie do wielokrotnie wspominanych potoków. Proces
może odczytywać i zapisywać dane, posługując się deskryptorem pliku swojego lokalnego gniazda.
Kiedy połączenie jest już niepotrzebne, można je zamknąć za pomocą standardowego wywołania
systemowego close.
Ostatnie dwa wywołania z tej listy odpowiadają za ustawianie i zwracanie wszystkich znaków
specjalnych, które służą do usuwania znaków i wierszy, przerywania procesów itp. Za pośred-
nictwem wywołania tcsetattr można też włączyć lub wyłączyć wyświetlanie wpisywanych znaków,
zarządzać sposobem sterowania przepływem i innymi pokrewnymi funkcjami. Istnieją też inne
funkcje wejścia-wyjścia, których jednak nie będziemy omawiać z uwagi na wyspecjalizowany
charakter. Warto przy tej okazji wspomnieć, że wciąż istnieje wywołanie systemowe ioctl.
zapisywanie danych w tym urządzeniu itp. Pomocniczy numer urządzenia jest przekazywany na
wejściu tych procedur w formie parametru. Dodanie nowego typu urządzenia do systemu Linux
wiąże się z koniecznością dodania nowego wpisu w jednej z tych tablic oraz dostarczenia odpo-
wiednich procedur obsługujących różne operacje na tym urządzeniu.
Wybrane operacje, które można skojarzyć z różnymi urządzeniami znakowymi wymieniono
w tabeli 10.7. Każdy wiersz reprezentuje pojedyncze urządzenie wejścia-wyjścia (czyli w praktyce
pojedynczy sterownik). Każda kolumna reprezentuje funkcje, którą muszą być obsługiwane przez
wszystkie sterowniki urządzeń znakowych. Oczywiście istnieje też wiele innych funkcji tego
typu. Kiedy na znakowym pliku specjalnym jest wykonywana jakaś operacja, system korzysta
z indeksu tablicy urządzeń znakowych, aby odnaleźć właściwą strukturę, po czym wywołuje odpo-
wiednią funkcję w celu realizacji danego żądania. Właśnie dlatego każda operacja na znakowym
pliku specjalnym musi zawierać wskaźnik do funkcji zawartej w odpowiednim sterowniku.
Tabela 10.7. Wybrane operacje na plikach stosowane dla typowych urządzeń znakowych
Urządzenie Otwórz Zamknij Odczytaj Zapisz ioctl Pozostałe
Null null null null null null ...
Pamięć null null mem_read mem_write null ...
Klawiatura k_open k_close k_read error k_ioctl_ ...
Terminal tty_open tty_close tty_read tty_write_ tty_ioctl ...
Drukarka lp_open lp_close error lp_write lp_ioctl ...
Każdy sterownik jest dzielony na dwie części, z których obie wchodzą w skład jądra systemu
Linux i obie działają w trybie jądra. Górna część sterownika działa w kontekście procesu wywo-
łującego i zapewnia interfejs pozostałym elementom systemu. Dolna część działa w kontekście
jądra i odpowiada za bezpośrednią interakcję z danym urządzeniem. Sterowniki mogą wywo-
ływać procedury jądra związane z przydziałem pamięci, zarządzeniem zegarem, kontrolą układu
DMA itp. Zbiór funkcji jądra, które mogą być wywoływane przez sterowniki urządzeń, zdefinio-
wano w dokumencie nazwanym interfejsem sterownik-jądro (ang. Driver-Kernel Interface). Pisanie
sterowników urządzeń dla systemu Linux szczegółowo opisano w kilku publikacjach [Cooper-
stein, 2009] i [Corbet et al., 2009].
System wejścia-wyjścia podzielono na dwa główne komponenty odpowiedzialne za obsługę
blokowych plików specjalnych i znakowych plików specjalnych. Oba komponenty omówimy kolejno
poniżej. Oba komponenty omówimy kolejno poniżej.
Głównym celem części systemu odpowiedzialnej za obsługę operacji wejścia-wyjścia na blo-
kowych plikach specjalnych (np. na dyskach) jest minimalizacja liczby niezbędnych operacji prze-
syłu. Aby osiągnąć ten cel, systemy Linux stosują pamięć podręczną pomiędzy sterownikami
dyskowymi a systemem plików (patrz rysunek 10.13). Przed wydaniem jądra w wersji 2.2 system
Linux utrzymywał zupełnie odrębne pamięci podręczne stron i buforów, zatem plik składowany
w bloku dyskowym mógł być buforowany w obu pamięciach podręcznych. Nowsze wersje Linuksa
oferują jedną, zunifikowaną pamięć podręczną. Za wspólne funkcjonowanie obu komponentów
odpowiada tzw. ogólna warstwa blokowa (ang. generic block layer), która tłumaczy sektory, bloki
i bufory dyskowe oraz strony danych, a także zapewnia możliwość wykonywania operacji na
tych strukturach.
Pamięć podręczna ma postać wewnętrznej tablicy jądra, w której są składowane tysiące ostatnio
użytych bloków. Kiedy jest potrzebny blok dyskowy w dowolnej formie (i-węzła, katalogu lub
danych), w pierwszej kolejności sprawdza się, czy nie występuje w pamięci podręcznej. Jeśli
tak, zostaje pobrany właśnie stamtąd, zatem nie jest konieczny czasochłonny dostęp do dysku,
co z kolei przekłada się na znaczny wzrost wydajności systemu.
Jeśli potrzebny blok nie jest składowany w pamięci podręcznej stron, zostaje odczytany
z dysku i umieszczony w tej pamięci, skąd następnie jest kopiowany we właściwe miejsce.
Ponieważ pamięć podręczna stron oferuje miejsce tylko dla stałej liczby bloków, niezbędne oka-
zuje się stosowanie algorytmu wymiany opisanego w poprzednim podrozdziale.
Pamięć podręczna stron jest wykorzystywana zarówno do operacji odczytu, jak i do operacji
zapisu. Kiedy program zapisuje jakiś blok, dane w pierwszej kolejności trafiają właśnie do pamięci
podręcznej, nie na dysk. Za ostateczne zapisanie tego bloku na dysku po osiągnięciu przyjętego
progu wypełnienia pamięci podręcznej odpowiada demon pdflush. Aby uniknąć zbyt długiego
składowania bloków w pamięci podręcznej przed utrwaleniem na dysku, wszystkie brudne bloki
są zapisywane na dysku do 30 s.
Aby zminimalizować opóźnienia powodowane przez wielokrotne, powtarzalne ruchy głowicy
dysku, system Linux korzysta z mechanizmu koordynatora wejścia-wyjścia (ang. I/O scheduler).
Zadaniem koordynatora jest właściwe porządkowanie lub grupowanie żądań wejścia-wyjścia
kierowanych do urządzeń blokowych. Istnieje wiele technik szeregowania tego rodzaju operacji
zoptymalizowanych pod kątem różnych obciążeń. Podstawowy mechanizm stosowany w systemie
Linux zbudowano na bazie oryginalnej tzw. windy Linusa (ang. Linus Elevator). Podstawowy
schemat działania programu szeregującego można opisać w następujący sposób: operacje dys-
kowe są sortowane w ramach listy dwukierunkowej uporządkowanej według adresów sektorów
dyskowych odpowiadających żądaniom. Nowe żądania są umieszczane na właściwych pozycjach
tej listy. Takie rozwiązanie pozwala uniknąć kosztownych ruchów głowicy dysku. Lista żądań
jest następnie scalana, aby sąsiednie operacje można było wykonać w ramach pojedynczego
żądania kierowanego do urządzenia. Algorytm windy w podstawowej formie może jednak pro-
wadzić do zjawiska określanego mianem zagłodzenia (ang. starvation). Właśnie dlatego poprawiona
wersja koordynatora operacji dyskowych systemu Linux wykorzystuje dwie dodatkowe listy
stosunkowo niewielkie i stałe zbiory urządzeń wejścia-wyjścia, wspomniany model sprawdzał się
całkiem dobrze. Wystarczyło skompilować jądro ze sterownikami dla odpowiednich urządzeń wej-
ścia-wyjścia. Jeśli rok później dana organizacja decydowała się na zakup nowego dysku, można
było po prostu raz jeszcze skompilować jądro. Dla nikogo nie był to poważny problem.
Po wprowadzeniu systemu Linux dla platformy PC nagle wszystko uległo zmianie. Liczba
urządzeń wejścia-wyjścia dostępnych dla komputerów PC o kilka rzędów wielkości przekraczała
liczbę tego rodzaju urządzeń instalowanych w minikomputerach. Co więcej, mimo że większość
użytkowników Linuksa dysponuje (lub może łatwo uzyskać) kompletny kod źródłowy tego sys-
temu, prawdopodobnie dla niemal wszystkich operacja dodania nowego sterownika, aktualizacji
wszystkich struktur danych opisujących sterowniki urządzeń, ponowna kompilacja jądra i instalacja
go w formie uruchamialnego systemu (nie wspominając o rozwiązywaniu problemów związanych
z usuwaniem usterek w razie problemów z rozruchem jądra) byłaby kłopotliwa.
W systemie Linux rozwiązano ten problem — wprowadzono tzw. ładowalne moduły (ang.
loadable modules). To fragmenty kodu, które można ładować do jądra systemu operacyjnego już
w czasie jego działania. Do najbardziej popularnych modułów tego typu należą sterowniki urzą-
dzeń znakowych i blokowych, jednak równie dobrze można instalować w ten sposób całe systemy
plików, protokoły sieciowe, narzędzia monitorujące wydajność i dowolne inne mechanizmy.
W trakcie ładowania modułu należy wykonać szereg operacji. Po pierwsze moduł musi zostać
przeniesiony do pamięci w trakcie ładowania. Po drugie system musi sprawdzić, czy zasoby
wymagane przez dany sterownik (np. poziomy żądań przerwań) są dostępne, i — jeśli tak —
oznaczyć je jako wykorzystywane. Po trzecie należy ustawić wszelkie niezbędne wektory
przerwań. Po czwarte systemu musi zaktualizować tablicę sterowników, aby obsługiwała nowy
typ urządzeń. Dopiero po wykonaniu tych kroków sterownik może przystąpić do inicjalizacji
swojego urządzenia. Sterownik zostaje wówczas w pełni zainstalowany i dysponuje takimi samymi
prawami jak sterowniki instalowane statycznie. Ładowalne moduły są obecnie obsługiwane także
przez inne współczesne systemy UNIX.
Najbardziej widocznym składnikiem każdego systemu operacyjnego, w tym Linuksa, jest system
plików. W poniższych punktach omówimy podstawowe rozwiązania zastosowane w systemie
plików Linuksa, wywołania systemowe operujące na tym systemie oraz sposób implementacji
systemu plików. Niektóre z tych rozwiązań zaczerpnięto z systemu MULTICS, inne były wzo-
rowane na odpowiednich elementach m.in. systemów MS-DOS i Windows, jeszcze inne wystę-
pują tylko w systemach uniksowych. Model zastosowany w systemie Linux jest o tyle interesujący,
że doskonale ilustruje zasadę „małe jest piękne”. Mimo minimalnych mechanizmów i bardzo
ograniczonej liczby wywołań systemowych Linux oferuje elegancki system plików ze sporym
potencjałem.
Istnieją dwa sposoby określania nazw plików w systemie Linux, zarówno na potrzeby powłoki,
jak i podczas otwierania plików z poziomu programów. Pierwszym sposobem jest posługiwanie
się tzw. ścieżkami bezwzględnymi (ang. absolute paths), które określają, jak dotrzeć do odpowied-
nich plików, począwszy od katalogu głównego. Przykładem ścieżki bezwzględnej jest /usr/ast/
books/mos3/chap-10. W ten sposób sygnalizujemy systemowi konieczność odnalezienia w katalogu
głównym katalogu nazwanego usr, po czym odnalezienia podkatalogu nazwanego ast. Katalog
ast powinien z kolei zawierać podkatalog books, w którym należy odnaleźć katalog mos4 zawie-
rający plik chap-10.
Bezwzględne ścieżki w wielu przypadkach okazują się zbyt długie i niewygodne. Właśnie dla-
tego system Linux oferuje użytkownikom i procesom wskazywanie katalogu, w którym obecnie
pracują, jako tzw. katalogu roboczego (ang. working directory). Okazuje się, że ścieżki do plików
można definiować właśnie względem katalogu roboczego. Ścieżkę wskazującą na położenie pliku
lub katalogu względem katalogu roboczego określa się mianem ścieżki względnej (ang. relative path).
Jeśli np. /usr/ast/books/mos4 jest katalogiem roboczym, polecenie powłoki w postaci:
cp chap-10 backup-10
Często się zdarza, że użytkownik musi korzystać z plików należących do innego użytkownika
lub przynajmniej składowanych w innym miejscu drzewa plików. Jeśli np. dwóch korzysta z jed-
nego pliku, który z natury rzeczy musi być składowany w katalogu należącym tylko do jednego
z nich, drugi musi albo odwoływać się do tego pliku, stosując ścieżkę bezwzględną, albo zmienić
swój katalog roboczy (co zwykle jest niepożądane). Jeśli ścieżka do tego pliku jest długa, jej
ciągłe wpisywanie może być niewygodne. Linux oferuje rozwiązanie tego i podobnych problemów
poprzez definiowanie w katalogach nowych wpisów wskazujących na istniejące pliki. Tego rodzaju
wpisy określa się mianem dowiązań (ang. links).
Przeanalizujmy teraz przykładową sytuację pokazaną na rysunku 10.14(a). Filip i Lidia pracują
nad wspólnym projektem i każde z nich musi mieć dostęp do plików swojego współpracownika.
Jeśli katalogiem roboczym Filipa jest /usr/filip, może odwoływać się do pliku x w katalogu Lidii
za pośrednictwem ścieżki /usr/lidia/x. Alternatywnym rozwiązaniem jest utworzenie przez Filipa
specjalnego wpisu w jego katalogu, (co pokazano na rysunku 10.14(b)), umożliwiającego mu uży-
wanie samej nazwy x w odwołaniach do pliku /usr/lidia/x.
Rysunek 10.14. (a) Sytuacja sprzed utworzenia dowiązania; (b) sytuacja po utworzeniu dowiązania
macierzystego, czyli do katalogu, w którym umieszczono nowy katalog. Oznacza to, że z poziomu
katalogu /usr/filip można się odwołać do pliku x Lidii — wystarczy wykorzystać ścieżkę ../lidia/x.
Oprócz zwykłych plików system Linux obsługuje też znakowe pliki specjalne i blokowe pliki
specjalne. Znakowe pliki specjalne stosuje się dla szeregowych urządzeń wejścia-wyjścia, jak
klawiatury czy drukarki. Otwieranie i odczytywanie danych z pliku /dev/tty jest równoznaczne
z odczytywaniem znaków z klawiatury. Otwieranie i zapisywanie danych w pliku /dev/lp jest
równoznaczne z wysyłaniem znaków do drukarki. Blokowe pliki specjalne, np. /dev/hd1, można
wykorzystywać do odczytywania i zapisywania danych w surowych partycjach dyskowych, z pomi-
nięciem systemu plików. Oznacza to, że przejście do bajta k i wykonanie operacji odczytu spo-
woduje odczytanie danych, począwszy od k-tego bajta odpowiedniej partycji bez najmniejszego
udziału struktur i-węzłów i plików. Surowe urządzenia blokowe wykorzystuje się nie tylko pod-
czas stronicowania i wymiany przez programy pracujące poniżej systemów plików (np. mkfs),
ale też podczas usuwania usterek z systemów plików (np. przez program fsck).
Wiele komputerów zawiera dwa dyski lub więcej dysków, np. w komputerach mainframe
wchodzących w skład systemów bankowych często instaluje się sto i więcej dysków, aby skła-
dować na nich ogromne bazy danych. Nawet współczesne komputery osobiste nierzadko zawie-
rają co najmniej po dwa napędy — zwykle jest to dysk twardy i napęd optyczny (np. DVD). Jeśli
komputer zawiera wiele napędów dyskowych, z natury rzeczy należy odpowiedzieć sobie na
pytanie, jak te napędy obsłużyć.
Jednym z możliwych rozwiązań jest stworzenie dla każdego z napędów odrębnego systemu
plików. Wyobraźmy sobie np. sytuację pokazaną na rysunku 10.15(a). Mamy tam do czynienia
z jednym dyskiem twardym (nazwanym przez nas C:) oraz napędem DVD (nazwanym D:). Każdy
z tych systemów dysponuje własnym katalogiem głównym i własnymi plikami. Oznacza to, że
użytkownik zainteresowany odwołaniem do zasobów spoza urządzenia domyślnego musi wskazać
zarówno odpowiednie urządzenie, jak i interesujący go plik. Aby np. skopiować plik x do katalogu
d (przy założeniu, że C: jest urządzeniem domyślnym), należałoby użyć polecenia w postaci:
cp D:/x /a/d/x
Rysunek 10.15. (a) Odrębne systemy plików; (b) systemy plików po zamontowaniu
dyspozycji pojedyncze drzewo plików i nie musi się już martwić o to, który plik jest składowany
na którym urządzeniu. Oznacza to, że zamiast powyższego polecenie może się posługiwać pole-
ceniem w postaci:
cp /b/x /a/d/x
Nowa wersja polecenia ma więc identyczną postać jak w sytuacji, w której wszystko jest skła-
dowane na jednym dysku twardym.
Inną ciekawą cechą systemu plików Linuksa jest mechanizm blokowania (ang. locking).
W niektórych aplikacjach dwa procesy (lub większa ich liczba) mogą się jednocześnie odwoły-
wać do tego samego pliku, co z kolei może prowadzić do tzw. wyścigu. Jednym z rozwiązań jest
zastosowanie tzw. obszarów krytycznych. Jeśli jednak procesy należą do użytkowników pracu-
jących niezależnie od siebie, którzy nie wiedzą o poczynaniach pozostałych, tego rodzaju próby
koordynacji zwykle są nieefektywne.
Wyobraźmy sobie np. bazę danych złożoną z wielu plików składowanych w jednym katalogu
lub w wielu katalogach i wykorzystywaną przez wielu niezwiązanych ze sobą użytkowników.
Z pewnością istnieje możliwość skojarzenia z każdym katalogiem lub plikiem semafora zapew-
niającego procesom wyłączny dostęp do wybranych danych (po wykonaniu operacji down wła-
ściwego semafora). Wadą tego rozwiązania jest czasowy brak dostępu do całego katalogu lub pliku,
mimo wykorzystywania przez bieżącego dysponenta semafora zaledwie jednego rekordu.
Właśnie dlatego w ramach standardu POSIX wprowadzono elastyczny, szczegółowy mecha-
nizm blokowania przez procesy dostępu do danych na poziomie zarówno pojedynczych bajtów,
jak i całych plików w ramach jednej niepodzielnej operacji. Mechanizm blokowania wymaga od pro-
cesu wywołującego wskazania pliku do zablokowania, bajta początkowego oraz liczby blokowanych
bajtów. Jeśli żądanie nałożenia blokady zostanie zaakceptowane, system umieści w wewnętrznej
tablicy odpowiedni wpis o blokowanych bajtach (np. składających się na rekord bazy danych).
Istnieją dwa rodzaje blokad — tzw. blokady współdzielone (ang. shared locks) oraz blokady wyłączne
(ang. exclusive locks). Jeśli na jakąś część pliku nałożono już blokadę współdzieloną, druga próba
nałożenia takiej blokady na tę część zostanie zaakceptowana, ale już próba nałożenia blokady
wyłącznej będzie odrzucona. Jeśli na jakąś część pliku nałożono blokadę wyłączną, wszelkie próby
zablokowania choćby fragmentu tej części będą odrzucane do czasu zwolnienia tej blokady.
Skuteczne nałożenie nowej blokady wymaga dostępności wszystkich bajtów wskazanego obszaru.
Podczas nakładania blokady proces musi określić, czy w razie braku możliwości natychmia-
stowej realizacji tego żądania wykonywanie procesu ma zostać wstrzymane. Jeśli proces zdecy-
duje się na takie rozwiązanie, w momencie zdjęcia bieżącej blokady działanie procesu zostanie
wznowione i to jego blokada zostanie nałożona. Jeśli proces nie zdecyduje się na wstrzymanie
działania do czasu zwolnienia bieżącej blokady, odpowiednie wywołanie systemowe natychmiast
zwraca sterowanie, a informacje o powodzeniu lub niepowodzeniu żądania są zawarte w kodzie
stanu. Jeśli blokada nie zostanie nałożona, od procesu wywołującego zależą dalsze kroki (np.
oczekiwanie albo podjęcie ponownej próby).
Zablokowane obszary mogą się w całości lub części pokrywać. Na rysunku 10.16(a) pokazano
proces A, który nałożył blokadę współdzieloną na bajty od 4. do 7. pewnego pliku. Nieco później
proces B nakłada blokadę współdzieloną na bajty od 6. do 9. — patrz rysunek 10.16(b). I wreszcie
proces C blokuje bajty od 2. do 11. Dopóki wszystkie te blokady są współdzielone, mogą istnieć
jednocześnie.
Zastanówmy się teraz, co się stanie, jeśli jakiś proces spróbuje nałożyć blokadę wyłączną na
bajt 9. pliku z rysunku 10.16(c). Przyjmijmy, że proces wstrzymuje działanie do czasu realizacji
Rysunek 10.16. (a) Plik z pojedynczą blokadą; (b) efekt dodania drugiej blokady; (c) efekt dodania
trzeciej blokady
tego żądania. Ponieważ wspomniany bajt jest objęty blokadami nałożonymi przez dwa inne pro-
cesy, działanie interesującego nas procesu będzie wstrzymane do czasu zwolnienia blokad przez
procesy B i C.
tworzy plik nazwany abc z bitami ochrony reprezentowanymi przez zmienną mode. Wspomniane
bity określają, którzy użytkownicy mają dostęp do nowego pliku i jaki jest zakres tego dostępu.
Zagadnieniem bitów ochrony zajmiemy się później.
Wywołanie systemowe creat nie tylko tworzy nowy plik, ale też otwiera go w trybie zapisu.
Aby umożliwić kolejnym wywołaniom systemowym dostęp do tego pliku, w razie powodzenia
wywołanie creat zwraca niewielką liczbę nieujemną określaną mianem deskryptora pliku
(w powyższym przykładzie deskryptor jest reprezentowany przez zmienną fd). Jeśli użyjemy
wywołania creat dla istniejącego pliku, jego rozmiar zostanie zmniejszony do zera, a dotychcza-
sowa zawartość zostanie usunięta. Pliki można też tworzyć za pomocą wywołania open z odpo-
wiednimi argumentami.
Przyjrzyjmy się teraz innym ważnym wywołaniom systemowym wymienionym i krótko opi-
sanym w tabeli 10.9. Warunkiem odczytywania i zapisywania zawartości istniejącego pliku jest
jego uprzednie otwarcie za pomocą wywołania open lub creat. Za pośrednictwem parametrów tego
Tabela 10.9. Wybrane wywołania systemowe związane z plikami. Zwracany kod ma wartość –1,
jeśli miał miejsce jakiś błąd; zmienna fd reprezentuje deskryptor pliku, a zmienna position określa
przesunięcie w pliku. Pozostałe parametry nie wymagają dodatkowych wyjaśnień
Wywołania systemowe Opis
fd = creat(name, mode) Jeden ze sposobów tworzenia nowych plików
fd = open(file, how, ...) Otwiera plik do odczytu, zapisu lub do odczytu i zapisu jednocześnie
s = close(fd) Zamyka otwarty plik
n = read(fd, buffer, nbytes) Odczytuje dane z pliku do bufora
n = write(fd, buffer, nbytes) Zapisuje dane z bufora do pliku
position = lseek(fd, offset, whence) Przesuwa wskaźnik pliku
s = stat(name, &buf) Odczytuje informacje dotyczące statusu pliku
s = stat(name, &buf) Odczytuje informacje dotyczące statusu pliku
s = pipe(&fd[0]) Tworzy potok
s = fcntl(fd, cmd, ...) Blokuje dostęp do pliku i wykonuje inne operacje
wywołania należy wskazać nazwę pliku do otwarcia, sposób tego otwarcia (do odczytu, do zapisu
lub do odczytu i zapisu) oraz rozmaite inne opcje. Podobnie jak wywołanie creat, wywołanie open
zwraca deskryptor pliku na potrzeby przyszłych operacji odczytu i (lub) zapisu. Po wykonaniu
niezbędnych operacji plik można zamknąć za pomocą wywołania close, które zwalnia deskryptor
pliku dla przyszłych wywołań creat lub open. Zarówno wywołanie creat, jak i wywołanie open
zawsze zwraca najniższy dostępny (aktualnie nieużywany) deskryptor pliku.
Kiedy program jest uruchamiany w standardowy sposób, deskryptory plików 0, 1 i 2 są auto-
matycznie otwierane i reprezentują odpowiednio standardowe wejście, standardowe wyjście oraz
standardowy błąd. Dzięki temu filtry, np. popularny program sort, mogą łatwo odczytywać swoje
dane wejściowe z pliku reprezentowanego przez deskryptor 0 oraz zapisywać swoje dane wyj-
ściowe w pliku reprezentowanym przez deskryptor 1 (bez konieczności otwierania plików).
Opisany mechanizm działa prawidłowo, ponieważ powłoka dba o kojarzenie tych wartości z prawi-
dłowymi (czasem przekierowanymi) plikami przed uruchomieniem programu.
Do najczęściej stosowanych wywołań systemowych bez wątpienia należą read i write. Oba
wywołania otrzymują na wejściu po trzy parametry: deskryptor pliku (określający, który otwarty
plik ma być przedmiotem operacji odczytu lub zapisu), adres bufora (określający, gdzie należy
umieścić odczytywane dane lub skąd mają pochodzić zapisywane dane) oraz licznik (określający
liczbę bajtów do odczytania lub zapisania). To wszystko. Wymienione wywołania cechują się więc
wyjątkowo prostym projektem. Typowe wywołanie ma następującą postać:
n = read(fd, buffer, nbytes);
Chociaż niemal wszystkie programy odczytują i zapisują pliki sekwencyjnie, niektóre aplikacje
muszą mieć możliwość swobodnego dostępu do dowolnych fragmentów plików. Właśnie dlatego
z każdym otwartym plikiem jest skojarzony wskaźnik określający bieżącą pozycję kursora.
Podczas sekwencyjnego odczytu (i zapisu) wspomniany wskaźnik wskazuje na następny bajt do
odczytania (zapisania). Jeśli np. wskazuje na 4096 bajt przed odczytaniem kolejnych 1024 bajtów,
po udanym wykonaniu wywołania systemowego read automatycznie zostanie przesunięty na pozy-
cję (bajt) 5120. Pozycję wskaźnika pozycji można zmienić za pomocą wywołania systemowego
lseek — wywołania read lub write następujące po tym wywołaniu mogą więc dotyczyć dowolnej
części pliku (a nawet operować za jego końcem) Nazwano to wywołanie lseek, aby uniknąć kon-
fliktów z istniejącym wywołaniem seek (uważanym obecnie za przestarzałe wywołaniem zmie-
niającym pozycję w plikach jeszcze na komputerach 16-bitowych).
Polecenie lseek ma trzy parametry: pierwszy oznacza deskryptor pliku, drugi pozycję pliku,
a trzeci informuje o tym, czy pozycja pliku jest oznaczona względem początku pliku, pozycji bieżą-
cej, czy końca pliku. Wywołanie systemowe lseek zwraca bezwzględną pozycję w pliku już po
zmianie wskaźnika. Paradoksalnie lseek jest jedynym wywołaniem systemowym operującym na
plikach, które nie powoduje poszukiwania (ang. seek) danych na dysku, ponieważ jego działanie
sprowadza się do aktualizacji bieżącej pozycji w pliku, czyli w praktyce pewnej wartości składo-
wanej w pamięci.
Dla każdego pliku system Linux utrzymuje m.in. informacje o charakterze (może to być zwy-
kły plik, katalog lub plik specjalny), rozmiarze oraz czasie ostatniej modyfikacji. Programy mogą
uzyskiwać te informacje za pośrednictwem wywołania systemowego stat. Pierwszy parametr
reprezentuje nazwę pliku. Za pośrednictwem drugiego parametru należy przekazać wskaźnik
do struktury, w której mają zostać zapisane żądane informacje. Pola tej struktury pokazano
w tabeli 10.10. Wywołanie fstat działa tak samo jak wywołanie stat, tyle że operuje na otwartym
pliku (którego nazwa może być nieznana), nie na znanej ścieżce i nazwie.
Wywołanie systemowe pipe służy do tworzenia potoków powłoki. Wywołanie pipe tworzy
rodzaj pseudopliku, w którym będą buforowane dane pomiędzy komponentami (stronami) potoku,
i zwraca deskryptory plików wykorzystywanych w roli buforów odczytu i zapisu. W potoku nastę-
pującej postaci:
sort <in | head –30
deskryptor pliku 1 (standardowe wyjście) procesu wykonującego program sort jest ustawiony
(przez powłokę) w taki sposób, aby dane były zapisywane w pewnym potoku, a deskryptor pliku 0
(standardowe wejście) procesu head jest ustawiony (przez powłokę) w taki sposób, aby dane
były odczytywane z tego potoku. Oznacza to, że proces sort odczytuje dane z deskryptora pliku 0
(pliku in) i zapisuje dane w deskryptorze pliku 1 (potoku), mimo że sam „nie wie” o ustawio-
nym przekierowaniu tych danych. Jeśli dane wejściowe nie są przekierowywane, program sort
automatycznie odczytuje dane z klawiatury i zapisuje je na ekranie (czyli korzysta z dwóch
urządzeń domyślnych). Podobnie, kiedy program head odczytuje dane z deskryptora pliku 0,
w rzeczywistości odczytuje dane umieszczone w buforze potoku przez program sort, chociaż
„nie wie”, że zastosowano potok. Jest to doskonały przykład tego, jak nieskomplikowany zabieg
(przekierowanie) z prostą implementacją (deskryptorami plików 0 i 1) może stanowić doskonałe
narzędzie łączenia programów na dowolne sposoby, bez konieczności ich modyfikowania.
Ostatnim wywołaniem systemowym opisanym w tabeli 10.9 jest fcntl. Za pomocą tego
wywołania można blokować i odblokowywać pliki, nakładać blokady współdzielone i wyłączne
oraz wykonywać kilka innych operacji na plikach.
Przeanalizujmy teraz wybrane wywołania systemowe związane w większym stopniu z kata-
logami lub systemem plików jako całością, nie z pojedynczymi plikami. Najczęściej stosowane
wywołania systemowe z tej grupy wymieniono i krótko opisano w tabeli 10.11. Do tworzenia
i usuwania katalogów służą odpowiednio wywołania mkdir i rmdir. Usuwane mogą być tylko
puste katalogi.
Tabela 10.11. Wybrane wywołania systemowe związane z plikami. W razie wystąpienia błędu
wywołania zwracają wartość −1; zmienna dir identyfikuje strumień katalogu, a zmienna dirent
reprezentuje wpis w katalogu. Pozostałe parametry nie wymagają dodatkowych wyjaśnień
Wywołania systemowe Opis
s =mkdir(name, mode) Tworzy nowy katalog
s = rmdir(path) Usuwa katalog
s = link(oldpath, newpath) Tworzy dowiązanie do istniejącego pliku
s = unlink(path) Usuwa dowiązanie do pliku
s = chdir(path) Zmienia katalog roboczy
dir = opendir(path) Otwiera katalog do odczytu
s = closedir(dir) Zamyka katalog
dirent = readdir(dir) Odczytuje jeden wpis katalogu
rewinddir(dir) Wraca na początek katalogu, aby ponownie odczytać jego zawartość
Jak widać na rysunku 10.14, utworzenie nowego dowiązania do pliku polega na dodaniu
nowego wpisu katalogu wskazującego na istniejący plik. Do tworzenia dowiązań służy wywołanie
systemowe link. Parametry tego wywołania określają odpowiednio oryginalną i nową nazwę.
Wpisy katalogów można usuwać za pomocą wywołania systemowego unlink. Wraz z usunięciem
ostatniego dowiązania do pliku automatycznie jest usuwany także sam plik. W przypadku pliku,
który nigdy nie był wskazywany przez dowiązanie, już pierwsze wywołanie unlink jest równo-
znaczne z jego usunięciem.
Katalog roboczy można zmienić za pomocą wywołania systemowego chdir. Zmiana katalogu
roboczego oznacza konieczność innego interpretowania względnych ścieżek do plików i katalogów.
Ostatnie cztery wywołania systemowe opisane w tabeli 10.11 operują na katalogach. Kata-
logi można — podobnie jak pliki — otwierać, zamykać i odczytywać. Każde wywołanie readdir
zwraca dokładnie jeden wpis katalogu w stałym formacie. Użytkownicy nie mogą zapisywać
danych w katalogu (takie rozwiązanie ma zapewnić zachowanie integralności systemu plików).
Do dodawania do katalogu służą wywołania systemowe creat lub link; pliki można usuwać za
pomocą wywołania unlink. Nie istnieje też wywołanie umożliwiające poszukiwanie konkretnego
pliku w katalogu — za pomocą wywołania rewinddir można jednak otworzyć katalog, aby ponownie,
od początku odczytać jego zawartość.
niezależnie od tego, czy są składowane na urządzeniach lokalnych, czy zdalnie (kiedy wymagają
dostępu za pośrednictwem sieci). Warstwa wirtualnego systemu plików pośredniczy także
w dostępie do urządzeń i innych plików specjalnych. W kolejnym podpunkcie omówimy imple-
mentację pierwszego popularnego systemu plików Linuksa, ext2, czyli drugiej wersji tzw. rozsze-
rzonego systemu plików (ang. extended file system). Nieco później omówimy udoskonalenia wprowa-
dzone w systemie plików ext4. W systemie Linux wykorzystuje się też wiele innych systemów
plików. Wszystkie systemy Linux oferują możliwość obsługi wielu partycji dyskowych, z których
każda może zawierać inny system plików.
Aby ułatwić niektóre operacje na katalogach i analizę ścieżek, np. w formie /usr/ast/bin,
wirtualny system plików stosuje strukturę danych dentry reprezentującą pojedynczy wpis kata-
logu. Struktury danych dentry są tworzone przez ten system plików na bieżąco. Wpisy katalogów
są składowane w strukturze pomocniczej dentry_cache, która obejmuje takie elementy jak /, /usr,
/usr/ast itp. Jeśli wiele procesów uzyskuje dostęp do tego samego pliku za pośrednictwem tego
samego dowiązania twardego (tj. tej samej ścieżki), ich obiekty plików będą wskazywały ten sam
wpis struktury dentry_cache.
I wreszcie struktura danych file jest składowaną w pamięci głównej reprezentacją otwartego
pliku, zatem jest tworzona w odpowiedzi na wywołanie systemowe open. Właśnie struktura file
jest wykorzystywana przez takie operacje jak read, write, sendfile czy lock, a także inne wywoła-
nia systemowe opisane w poprzednim podrozdziale.
Właściwe systemy plików zaimplementowane poniżej warstwy wirtualnego systemu plików
nie muszą wewnętrznie korzystać z tych samych abstrakcji czy operacji. Muszą jednak imple-
mentować operacje na systemie plików semantycznie równoważne z operacjami zdefiniowanymi
w obiektach wirtualnego systemu plików. Elementy struktur danych operacji dla każdego z czte-
rech obiektów wirtualnego systemu plików wskazują na właściwe funkcje znajdującego się poniżej
systemu plików.
Pierwszy blok zawiera tzw. superblok, czyli informacje o układzie systemu plików, w tym liczbę
i-węzłów, liczbę bloków dyskowych oraz początek listy wolnych bloków dyskowych (obejmu-
jącej zwykle kilkaset wpisów). Następnym elementem jest deskryptor grupy z informacjami
o położeniu map bitowych, liczbie wolnych bloków oraz i-węzłów w danej grupie i liczbie kata-
logów wchodzących w skład tej grupy. Wymienione informacje są o tyle ważne, że system ext2
próbuje równomiernie rozkładać katalogi na dysku.
Do śledzenia wolnych bloków i wolnych i-węzłów system plików ext2 wykorzystuje dwie mapy
bitowe — to rozwiązanie zaczerpnięto z systemu plików systemu MINIX 1 (w odróżnieniu do
większości systemów plików UNIX, gdzie stosuje się pojedynczą listę). Każda mapa zajmuje
jeden blok. Oznacza to, że w przypadku bloków 1-kilobajtowych opisywany projekt ogranicza liczbę
bloków oraz i-węzłów w grupie do 8192. O ile pierwsze ograniczenie może stanowić pewien pro-
blem, o tyle drugie w praktyce nie ma znaczenia. W przypadku bloków 4-kilobajtowych liczby są
czterokrotnie większe.
Rysunek 10.18. (a) Katalog systemu Linux z trzema plikami; (b) ten sam katalog po usunięciu
pliku voluminous
Na rysunku 10.18(b) widać ten sam katalog po usunięciu wpisu skojarzonego z plikiem volu-
minous. Okazuje się, że jedynym skutkiem tej operacji było powiększenie rozmiaru wpisu dla
pliku colossal o obszar zajmowany wcześniej przez pole pliku voluminous — wpis tego pliku
zastąpiono więc dopełnieniem wpisu dla pliku colossal. Taką samą procedurę można by oczy-
wiście zastosować także dla kolejnego wpisu.
Ponieważ katalogi są przeszukiwane liniowo, odnalezienie wpisu na końcu wielkiego katalogu
może wymagać sporo czasu. Właśnie dlatego system utrzymuje pamięć podręczną z katalogami,
do których ostatnio uzyskiwano dostęp. Pamięć podręczna jest przeszukiwana według nazw
plików, a w razie odnalezienia żądanego wpisu można uniknąć kosztownej operacji przeszuki-
wania liniowego. Dla każdego składnika użytej ścieżki w pamięci podręcznej jest zapisywany
odrębny obiekt dentry. Odpowiedni katalog jest przeszukiwany według i-węzłów pod kątem
zawierania następnego komponentu ścieżki aż do osiągnięcia i-węzła właściwego pliku.
Wyobraźmy sobie np. poszukiwanie pliku identyfikowanego przez ścieżkę bezwzględną
/usr/ast/file — cała procedura wymaga wykonania następujących kroków. Po pierwsze system
lokalizuje katalog główny, który zwykle wykorzystuje i-węzeł nr 2, szczególnie jeśli pierwszy
i-węzeł jest zarezerwowany dla obsługi błędnych bloków. System umieszcza odpowiedni wpis
w pamięci podręcznej obiektów dentry, aby przyspieszyć ewentualne przyszłe żądania przeszu-
kiwania katalogu głównego. W tym przypadku system szuka w katalogu głównym łańcucha "usr",
aby uzyskać numer i-węzła katalogu /usr, którego obiekt dentry także jest umieszczany w pamięci
podręcznej. Po odczytaniu odpowiedniego i-węzła system wyodrębnia z niego bloki dyskowe,
aby umożliwić odczyt i przeszukanie katalogu /usr pod kątem zawierania łańcucha "ast". Po
odnalezieniu tego wpisu można odczytać numer i-węzła katalogu /usr/ast. Na podstawie tego
numeru można odczytać sam i-węzeł oraz odpowiednie bloki tego katalogu. I wreszcie system
poszukuje łańcucha "file" i numeru jego i-węzła. Oznacza to, że stosowanie ścieżki względnej nie
tylko jest wygodniejsze dla użytkownika, ale też oszczędza sporo pracy samemu systemowi.
Jeśli żądany plik uda się odnaleźć, system odszuka numer i-węzła i wykorzysta go w roli
indeksu tabeli i-węzłów (składowanej na dysku), aby zlokalizować i umieścić w pamięci właściwy
i-węzeł. Odnaleziony i-węzeł jest umieszczany w tabeli i-węzłów, czyli wewnętrznej strukturze
danych jądra wykorzystywanej do przechowywania wszystkich i-węzłów aktualnie otwartych
plików i katalogów. Format wpisów o i-węzłach musi obejmować przynajmniej wszystkie pola
zwracane przez wywołanie systemowe stat, ponieważ od tego zależy prawidłowe działanie tego
wywołania (patrz tabela 10.10). W tabeli 10.13 opisano wybrane pola struktury i-węzłów stosowa-
nej w warstwie systemu plików Linuksa. W praktyce struktura obejmuje dużo więcej pól, ponieważ
system plików wykorzystuje ją także do reprezentowania katalogów, urządzeń i innych plików
specjalnych. Struktura i-węzłów zawiera też pola zaprojektowane z myślą o przyszłych zastoso-
waniach. Historia pokazuje, że niewykorzystane bity nie zachowują swojego statusu zbyt długo.
Przeanalizujmy teraz sposób, w jaki system odczytuje plik. Jak już wspomniano, typowe wywo-
łanie procedury biblioteki, która z kolei wykorzystuje wywołanie systemowe read, ma następującą
postać:
n = read(fd, buffer, nbytes);
Kiedy sterowanie trafia do jądra, w pierwszej kolejności musi odczytać te trzy parametry oraz
składowane w wewnętrznych tablicach informacje o użytkowniku. Jednym elementów tych tablic
jest tablica deskryptorów plików. Tablica jest indeksowana według deskryptorów i zawiera
po jednym elemencie dla każdego otwartego pliku (aż do ich maksymalnej liczby, która zwykle
wynosi 32).
Na tym etapie zadaniem jądra dysponującego deskryptorem pliku jest znalezienie odpowied-
niego i-węzła. Przeanalizujmy teraz jedno z możliwych rozwiązań — umieszczenie wskaźnika
do i-węzła w tablicy deskryptorów plików. Ta metoda, choć prosta, niestety nie zdaje egzaminu.
Wiąże się z nią pewien problem. Skoro z każdym deskryptorem pliku jest skojarzona pozycja
pliku określająca, od którego bajta powinna się zacząć następna operacja odczytu (lub zapisu),
gdzie należałoby składować tę pozycję?Jednym z rozwiązań jest umieszczanie pozycji w tablicy
i-węzłów, jednak ten model się nie sprawdzi, jeśli dwa niezwiązane ze sobą procesy (lub większa
ich liczba) spróbują jednocześnie otworzyć ten sam plik, ponieważ każdy powinien dysponować
własną pozycją w pliku.
Drugim rozwiązaniem jest umieszczanie informacji o bieżącej pozycji w pliku w tablicy deskryp-
torów plików. W takim przypadku każdy proces otwierający plik otrzymywałby własną pozycję.
Okazuje się jednak, że także ten schemat nie zdaje egzaminu — tym razem przyczyna jest
bardziej subtelna niż opisany powyżej problem ze współdzieleniem plików systemu Linux.
Wyobraźmy sobie skrypt powłoki s składający się z dwóch wykonywanych kolejno poleceń: p1
oraz p2. Jeśli w wierszu poleceń uruchomimy ten skrypt w następujący sposób:
s >x
Rysunek 10.19. Relacja łącząca tablicę deskryptorów plików, tablicę opisów otwartych plików
oraz tablicę i-węzłów
Jeśli jednak proces niezwiązany z interesującymi nas procesami otworzy ten sam plik, zosta-
nie utworzony odrębny wpis w tablicy opisów otwartych plików z odrębną pozycją w pliku, co
jest w pełni zgodne z naszymi oczekiwaniami. Celem tabeli opisów otwartych plików jest więc
umożliwienie procesom macierzystym i potomnym współdzielenia tej samej pozycji w pliku i jed-
nocześnie utrzymywanie odrębnych wartości dla procesów niespokrewnionych.
Skoro wiemy już, jak odnajduje się pozycje w plikach i odpowiednie i-węzły, wróćmy do pro-
blemu wykonywania operacji read. I-węzeł zawiera adresy dyskowe pierwszych 12 bloków pliku.
Jeśli pozycja w pliku mieści się w tych pierwszych 12 blokach, odpowiedni blok jest odczyty-
wany, a zawarte w nim dane są kopiowane dla użytkownika. Dla plików zajmujących więcej niż
12 bloków istnieje widoczne na rysunku 10.19 pole i-węzła z adresem dyskowym bloku jedno-
pośredniego (ang. single indirect block). Jeśli np. blok obejmuje 1 kB, a adres dyskowy zajmuje
4 bajty, blok jednopośredni może zawierać 256 adresów dyskowych. Oznacza to, że opisany
schemat wystarczy do opisywania plików zajmujących nie więcej niż 268 kB.
Po bloku jednopośrednim następuje blok dwupośredni zawierający adresy 256 bloków jedno-
pośrednich, z których każdy zawiera adresy 256 bloków danych. Ten mechanizm wystarczy do
obsługi plików złożonych z maksymalnie 10+216 bloków (czyli 67119104 bajtów). Jeśli nawet to
nie wystarczy, i-węzeł zapewnia przestrzeń dla bloku trójpośredniego. Wskaźniki zawarte w tym
bloku wskazują na wiele bloków dwupośrednich. Ten schemat adresowania pozwala obsługiwać
pliki o maksymalnym rozmiarach 224 kB (16 GB). W przypadku bloków 8-kilobajtowych ten sam
schemat umożliwia obsługę plików, których rozmiary dochodzą do 64 TB.
na samym dzienniku nie są „księgowane”, nie mogą być obsługiwane przez ten sam system pli-
ków ext4. Do wykonywania operacji odczytu i zapisu na dzienniku stosuje się więc odrębne
urządzenie JBD (od ang. Journaling Block Device).
Urządzenie JBD wykorzystuje trzy główne struktury danych: rejestr zapisów dziennika,
strukturę odpowiedzialną za obsługę atomowych operacji oraz strukturę transakcji. Rejestr zapisów
dziennika opisuje niskopoziomowe operacje na systemie plików, które zwykle oznaczają zmiany
w ramach pojedynczego bloku. Ponieważ wywołanie systemowe write wprowadza zmiany
w wielu miejscach — i-węzłach, blokach istniejących plików, blokach nowych plików, liście
wolnych bloków itp. — odpowiednie zapisy rejestru grupuje się w ramach atomowych operacji.
System plików ext4 informuje urządzenie JBD o rozpoczęciu i zakończeniu przetwarzania wywo-
łania systemowego, aby umożliwić zastosowanie wszystkich rekordów w ramach atomowej ope-
racji albo uniknąć zastosowania którejkolwiek z nich. I wreszcie (przede wszystkim z myślą o jak
największej efektywności) urządzenie JBD traktuje kolekcje atomowych operacji jako transakcje.
Zapisy dziennika związane z jedną transakcją są składowane obok siebie. JBD umożliwia usu-
wanie fragmentów pliku dziennika dopiero po bezpiecznym zatwierdzeniu na dysku wszystkich
rekordów wchodzących w skład jednej transakcji.
Ponieważ zapisywanie w dzienniku informacji o wszystkich operacjach dyskowych może być
dość kosztowne, system plików ext4 można skonfigurować w taki sposób, aby rejestrował albo
wszystkie zmiany danych dyskowych, albo tylko zmiany związane z metadanymi systemu plików
(w i-węzłach, superblokach, mapach bitowych itp.). Księgowanie samych metadanych powoduje
mniejsze opóźnienia w pracy systemu i przekłada się na wyższą wydajność, ale też nie eliminuje
ryzyka uszkodzenia plików z danymi. Istnieje wiele księgujących systemów plików, które utrzy-
mują dzienniki tylko dla operacji na metadanych (np. system XFS firmy SGI). Ponadto wiary-
godność dziennika można dodatkowo poprawić poprzez sprawdzanie sumy kontrolnej.
Najważniejszą zmianą wprowadzoną w systemie ext4 w porównaniu z poprzednikami jest
wykorzystanie zakresów (ang. extents). Zakresy reprezentują ciągłe bloki na dysku — np. o roz-
miarze128 MB — sąsiadujących ze sobą 4-kilobajtowych bloków. Pod tym względem projekt ten
różni się od stosowanego w systemie ext2, w którym wykorzystywano pojedyncze bloki dys-
kowe. W przeciwieństwie do swoich poprzedników system ext4 nie wymaga operacji na meta-
danych dla każdego bloku dyskowego. Ten schemat ogranicza również fragmentację dla dużych
plików. W rezultacie system plików ext4 zapewnia szybsze operacje w systemie plików, obsługuje
większe pliki i rozmiary systemu plików. Przykładowo w przypadku bloków 1-kilobajtowych
system ext4 zwiększa maksymalny rozmiar pliku z 16 GB do 16 TB, a maksymalny rozmiar
systemu plików do 1 EB (exabajta).
I wreszcie klient 2 zamontował wspomniany katalog projects, zatem także ma dostęp do pliku a,
tyle że z wykorzystaniem ścieżki /mnt/proj1/a. Jak widać, ten sam plik może występować pod
różnymi nazwami na różnych komputerach klienckich, ponieważ może być montowany w różnych
częściach docelowych drzew katalogów. Punkt montowania ma charakter ściśle lokalny i zależy
tylko od klienta — serwer nie wie, gdzie jego katalogi są montowane po stronie klientów.
systemów plików, aby wymusić przeprowadzanie niezbędnych procedur jeszcze przed umożli-
wieniem logowania. Alternatywnym rozwiązaniem jest oferowany przez większość wersji Linuksa
mechanizm automatycznego montowania. Za pomocą tego mechanizmu można wskazać zbiór
zdalnych katalogów, które należy skojarzyć z jakimś lokalnym katalogiem. W czasie uruchamia-
nia komputera klienta żaden z tych zdalnych katalogów nie jest montowany (nie jest nawiązywane
nawet połączenie z serwerem). Dopiero kiedy użytkownik po raz pierwszy próbuje otworzyć
zdalny plik, system operacyjny wysyła stosowne komunikaty do wszystkich serwerów. Pierwszy,
który odpowie, wygrywa — w lokalnym systemie plików jest montowany katalog udostępniany
właśnie przez ten serwer.
Automatyczne montowanie katalogów ma dwie zasadnicze zalety względem statycznego
montowania katalogów za pośrednictwem skryptu /etc/rc. Po pierwsze, jeśli jeden z serwerów
NFS wskazanych w tym skrypcie będzie niedostępny, nie będzie można uruchomić komputera
klienta (a przynajmniej bez dodatkowych działań, pewnego opóźnienia i sporej liczby komuni-
katów o błędach). Jeśli w danej chwili użytkownik w ogóle nie jest zainteresowany zasobami
udostępnianymi przez ten serwer, dodatkowe zabiegi na rzecz uruchomienia systemu opera-
cyjnego pójdą na marne. Po drugie umożliwienie klientowi podejmowania równoległych prób
kontaktu z całym zbiorem serwerów podnosi zarówno poziom tolerancji błędów (ponieważ tylko
jeden serwer musi działać), jak i wydajność (ponieważ wybór pada na serwer, który odpowiada
jako pierwszy, a więc najprawdopodobniej cechuje się najniższym obciążeniem).
Z drugiej strony działanie mechanizmu automatycznego montowania opiera się na założe-
niu, zgodnie z którym wszystkie systemy plików na alternatywnych serwerach są identyczne.
Ponieważ system NFS nie obsługuje replikacji plików ani katalogów, do samego użytkownika
należy dbanie o spójność tych systemów. Właśnie dlatego automatyczne montowanie zwykle
stosuje się dla systemów plików dostępnych tylko do odczytu i zawierających binaria systemu
operacyjnego oraz inne rzadko zmieniane pliki.
Drugi protokół NFS jest wykorzystywany podczas właściwego dostępu do plików i katalogów.
Komputer kliencki może wysyłać na serwer komunikaty zmieniające katalogi oraz odczytujące
i zapisujące zawartość plików. Klient może też uzyskiwać dostęp do takich atrybutów pliku jak
tryb, rozmiar czy czas ostatniej modyfikacji. System plików NFS obsługuje większość wywołań
systemowych Linuksa, z wyjątkiem — co ciekawe — wywołań open i close.
Brak obsługi wywołań systemowych open i close nie jest przypadkiem — to wynik świa-
domej decyzji projektantów systemu plików NFS. Otwieranie pliku przed jego odczytaniem nie
jest konieczne; nie musimy też zamykać go po zakończeniu pracy. Zamiast tego przed odczyta-
niem pliku klient wysyła na serwer komunikat lookup z pełną nazwą pliku oraz żądaniem odnale-
zienia i zwrócenia jego uchwytu, czyli struktury jednoznacznie identyfikującej ten plik (tj. zawie-
rającej m.in. identyfikator systemu plików oraz numer i-węzła). W przeciwieństwie do wywołania
open operacja lookup nie kopiuje żadnych informacji do wewnętrznych tablic systemu. Wywołanie
read zawiera uchwyt pliku do odczytania, przesunięcie względem jego początku oraz liczbę bajtów
interesujących klienta. Każdy taki komunikat jest samowystarczalny. Zaletą tego schematu jest
brak konieczności rejestrowania przez serwer informacji o otwartych połączeniach pomiędzy
kolejnymi wywołaniami. Oznacza to, że w razie awarii serwera nie istnieje ryzyko utraty infor-
macji o otwartych plikach, ponieważ takie informacje nie są potrzebne i po prostu nie istnieją.
Tego rodzaju serwery, które nie utrzymują informacji o otwartych plikach, określa się mianem
serwerów bezstanowych (ang. stateless).
Z drugiej strony rozwiązanie zastosowane w systemie NFS utrudnia osiągnięcie pełnej zgod-
ności z semantyką plików Linuksa. Plik systemu Linux można np. otwierać i blokować, aby nie
był dostępny dla innych procesów. Kiedy plik jest zamykany, odpowiednie blokady są automa-
tycznie zwalniane. Na serwerze bezstanowym (w tym na serwerze NFS) nie istnieje możliwość
kojarzenia blokad z otwartymi plikami, ponieważ serwer po prostu „nie wie”, które pliki są
otwarte. System plików NFS wymaga więc odrębnego, dodatkowego mechanizmu odpowiedzial-
nego za obsługę blokad.
System plików NFS wykorzystuje standardowy mechanizm ochrony systemu UNIX z osob-
nymi bitami rwx dla właściciela, grupy i pozostałych użytkowników (patrz rozdział 1. i szcze-
gółowy materiał zawarty w następnym podrozdziale). Początkowo każdy komunikat z żądaniem
zawierał identyfikatory użytkownika i grupy właściwe procesowi wywołującemu, na których
podstawie serwer NFS weryfikował uprawnienia dostępu do danego pliku. Takie rozwiązanie
opierało się na założeniu, że użytkownicy nie będą podejmowali prób oszukania serwera. Lata
doświadczeń jasno pokazały, że przytoczone założenie było — jakby to powiedzieć? — raczej
naiwne. Obecnie stosuje się techniki kryptograficzne z kluczem publicznym, aby weryfikować
tożsamość klienta i użytkownika przy okazji każdego żądania i odpowiedzi. Takie rozwiązanie
wyklucza możliwość podszywania się nieuprawnionego klienta pod klienta z odpowiednimi
przywilejami, ponieważ warunkiem dostępu do plików jest znajomość tajnego klucza właści-
wego klienta.
Zadaniem warstwy VFS jest utrzymywanie tablicy zawierającej po jednym wpisie dla każdego
otwartego pliku. Warstwa wirtualnego systemu plików składuje wirtualny i-węzeł, tzw. v-węzeł
dla każdego otwartego pliku. V-węzły wykorzystuje się do określania, czy dany plik ma charakter
lokalny, czy zdalny. W przypadku plików zdalnych opisywana warstwa rejestruje wszystkie
informacje niezbędne do uzyskiwania dostępu do jego zawartości. W przypadku każdego pliku
lokalnego warstwa VFS rejestruje nie tylko i-węzeł, ale też system plików, ponieważ współcze-
sne systemy Linux obsługują wiele systemów plików (np. ext2, /proc, FAT itp.). Chociaż war-
stwę wirtualnego systemu plików stworzono z myślą o systemie NFS, obecnie jest integralną
częścią większości współczesnych systemów Linux (nawet jeśli system NFS nie jest wyko-
rzystywany).
Aby lepiej zrozumieć sposób wykorzystywania v-węzłów, przeanalizujmy przykładową sekwen-
cję wywołań systemowych mount, open i read. Aby zamontować zdalny system plików, admini-
strator systemu (lub skrypt /etc/rc) uruchamia program mount, na którego wejściu wskazuje
zdalny katalog, lokalny katalog, w którym dany system ma zostać zamontowany, i inne informacje.
Program mount poddaje zdalny katalog analizie składniowej, aby wyodrębnić z niego nazwę ser-
wera NFS, na którym ten katalog jest składowany. Zaraz potem program nawiązuje kontakt z tym
komputerem, kierując do niego żądanie uchwytu pliku zdalnego katalogu. Jeśli wskazany katalog
istnieje i jest dostępny do zdalnego montowania, serwer zwraca odpowiedni uchwyt pliku. I wresz-
cie program mount wykorzystuje wywołanie systemowe mount, przekazując na jego wejściu otrzy-
many uchwyt.
Jądro konstruuje następnie v-węzeł dla tego zdalnego katalogu, po czym żąda od kodu klienta
NFS utworzenia w jego wewnętrznych tabelach tzw. r-węzła (zdalnego i-węzła), który będzie
reprezentował otrzymany uchwyt pliku. Nowy r-węzeł jest wskazywany przez v-węzeł. Każdy
v-węzeł warstwy wirtualnego systemu plików zawiera albo wskaźnik do r-węzła w ramach kodu
klienta NFS, albo wskaźnik do i-węzła jednego z lokalnych systemów plików (na rysunku 10.21
oznaczono te wskaźniki przerywanymi liniami). Oznacza to, że na podstawie v-węzła można
sprawdzić, czy dany plik lub katalog jest zasobem lokalnym, czy zdalnym. Jeśli mamy do czynienia
z plikiem lokalnym, można bez trudu zlokalizować odpowiedni system plików oraz i-węzeł. Jeśli
operujemy na pliku zdalnym, można zlokalizować zdalny komputer oraz odpowiedni uchwyt pliku.
Kiedy po stronie klienta jest otwierany zdalny plik, w pewnym momencie (w trakcie analizy
składniowej użytej ścieżki) jądro osiąga katalog, w którym zamontowano zdalny system plików.
Jądro odkrywa wówczas, że ma do czynienia ze zdalnym katalogiem, a w jego v-węźle odnajduje
wskaźnik do odpowiedniego r-węzła. Na tej podstawie jądro może zażądać od kodu klienta NFS
otwarcia danego pliku. Kod klienta analizuje wówczas pozostałą część ścieżki na zdalnym ser-
werze (skojarzonej z zamontowanym katalogiem), aby uzyskać z serwera uchwyt pliku. Klient
tworzy w swoich tabelach r-węzeł dla zdalnego pliku, po czym informuje o realizacji swoich zadań
warstwę VFS, która umieszcza w swoich tablicach v-węzeł wskazujący na ten r-węzeł. Oznacza
to, że dla każdego otwartego pliku lub katalogu istnieje v-węzeł wskazujący albo na odpowiedni
r-węzeł, albo na właściwy i-węzeł.
Proces wywołujący otrzymuje deskryptor zdalnego pliku. Deskryptor jest odwzorowywany na
v-węzeł na podstawie wewnętrznych tabel warstwy wirtualnego systemu plików. Warto pamiętać,
że żadne wpisy tabel nie są tworzone po stronie serwera. Mimo że serwer jest przygotowany na
odsyłanie uchwytów plików w odpowiedzi na otrzymywane żądania, w żaden sposób nie śledzi, które
pliki były przedmiotem tego rodzaju żądań. Kiedy serwer otrzymuje uchwyt pliku w ramach
żądania dostępu, sprawdza jego poprawność, po czym — jeśli jest prawidłowy — wykorzystuje go
do wykonania odpowiedniej operacji. Procedura weryfikacji może obejmować sprawdzenie klucza
uwierzytelniającego zawartego w nagłówkach RPC (jeśli włączono tryb zabezpieczeń).
Jeśli w kolejnym wywołaniu systemowym, np. read, użyjemy deskryptora pliku, warstwa
wirtualnego systemu plików zlokalizuje odpowiedni v-węzeł i na tej podstawie określi, czy żądamy
dostępu do pliku lokalnego, czy zdalnego, po czym zidentyfikuje opisujący go i-węzeł lub r-węzeł.
Warstwa VFS wysyła następnie na serwer komunikat obejmujący odpowiedni uchwyt, przesu-
nięcie (utrzymywane po stronie klienta, nie serwera) oraz liczbę żądanych bajtów. Dla zapew-
nienia jak najwyższej efektywności dane pomiędzy klientem a serwerem są przesyłane w dużych
pakietach (zwykle po 8192 bajtów), nawet jeśli klient żąda mniejszej liczby bajtów.
Kiedy komunikat żądania dociera na serwer, jest przekazywany do warstwy wirtualnego
systemu plików serwera, która odpowiada za identyfikację lokalnego systemu plików zawierają-
cego żądany plik. Warstwa VFS odwołuje się do tego lokalnego systemu plików, aby odczytać
i zwrócić bajty zawarte w odpowiednim pliku. Dane są następnie odsyłane klientowi. Kiedy
warstwa wirtualnego systemu plików klienta otrzymuje żądany 8-kilobajtowy pakiet, automa-
tycznie generuje żądanie następnego pakietu, aby w razie konieczności jak najszybciej dyspono-
wać potrzebnymi danymi. Ten mechanizm, znany jako odczyt z wyprzedzeniem (ang. read ahead),
znacznie podnosi wydajność opisywanego modelu.
Podczas operacji zapisu ścieżka komunikacji pomiędzy klientem a serwerem przebiega
bardzo podobnie. Także wówczas dane są przesyłane w 8-kilobajtowych pakietach. Jeśli wywoła-
nie systemowe write zapisuje mniej niż 8 kB danych, dane są lokalnie kumulowane. Dopiero po
wypełnieniu całego 8-kilobajtowego pakietu następuje jego przesłanie na serwer. Jeśli jednak
plik jest zamykany, wszystkie jego dane są niezwłocznie wysyłane na serwer.
Inną techniką wykorzystywaną do podnoszenia wydajności systemu plików NFS jest bufo-
rowanie danych w ramach pamięci podręcznej (podobnie jak w systemie UNIX). Serwery skła-
dują dane w pamięci podręcznej, aby zminimalizować operacje dostępu do dysku, jednak działanie
tego mechanizmu jest niewidoczne dla klientów. Komputery klienckie utrzymują po dwie pamięci
podręczne — jedną dla atrybutów plików (i-węzłów) i drugą dla właściwych danych plików. Kiedy
system potrzebuje i-węzła lub bloku pliku, sprawdza, czy niezbędne dane są składowane w pamięci
podręcznej — jeśli tak, można uniknąć kosztownej komunikacji sieciowej.
Pamięć podręczna stosowana po stronie klienta co prawda znacznie podnosi wydajność sie-
ciowego systemu plików, jednak prowadzi też do pewnych problemów. Przypuśćmy, że dwa
komputery klienckie składują w swoich pamięciach podręcznych ten sam blok dysku i że jeden
z tych komputerów modyfikuje zawartość tego bloku. Kiedy drugi komputer odczyta następnie
ten blok, otrzyma starą, nieaktualną wartość. Zawartość obu pamięci podręcznych jest niespójna.
Z uwagi na potencjalne niebezpieczeństwa wynikające z tej niespójności, implementacja sys-
temu plików NFS podejmuje wiele działań na rzecz ograniczania tego rodzaju zagrożeń. Po pierw-
sze z każdym blokiem pamięci podręcznej jest skojarzony pewien limit czasowy. Kiedy ten limit
wygasa, odpowiedni wpis zostaje usunięty. W przypadku bloków danych limit wynosi zwykle 3 s;
dla bloków katalogów stosuje się limit na poziomie 30 s. W ten sposób można nieznacznie ograni-
czyć ryzyko utraty spójności danych. Co więcej, przy okazji każdej operacji otwarcia pliku składo-
wanego w pamięci podręcznej klient na serwer komunikat, aby określić, kiedy otwierany plik był
po raz ostatni modyfikowany. Jeśli ostatnia modyfikacja miała miejsce już po umieszczeniu lokalnej
kopii w pamięci podręcznej, wspomniana kopia jest usuwana — należy wówczas uzyskać nową
kopię z serwera. I wreszcie raz na 30 s limit czasowy pamięci podręcznej wygasa i wszystkie
brudne (zmodyfikowane) bloki są odsyłane na serwer. Opisane zabiegi, choć niedoskonałe, zapew-
niają użyteczność systemu w większości sytuacji.
Linux, jako klon systemów MINIX i UNIX, niemal od samego początku był systemem wielu użyt-
kowników. Właśnie pochodzenie tego systemu powoduje, że mechanizmy związane z bezpieczeń-
stwem i kontrolą informacji są rozwijane od bardzo długiego czasu. W poniższych punktach przyj-
rzymy się wybranym aspektom bezpieczeństwa w systemie Linux.
Dwa pierwsze wiersze tabeli 10.14 nie wymagają dodatkowych wyjaśnień — zapewniają
odpowiednio właścicielowi i grupie właściciela pełny dostęp do pliku. Kolejna kombinacja bitów
uprawnień umożliwia grupie, do której należy właściciel pliku, odczytywanie tego pliku, ale nie
zapewnia członkom tej grupy możliwości jego modyfikowania; pozostali użytkownicy są całko-
wicie pozbawieni dostępu. Czwarta kombinacja jest typowa dla plików z danymi, których właści-
ciele chcą upublicznić swoje zasoby. Podobnie piąty wiersz jest typowy dla publicznie dostępnych
programów. Szósty wpis uniemożliwia wszystkim użytkownikom dostęp do pliku. Ten tryb
„dostępu” stosuje się czasem dla plików wykorzystywanych do wzajemnego wykluczania dostępu,
ponieważ każda próba utworzenia już istniejącego pliku tego typu kończy się niepowodzeniem.
Oznacza to, że jeśli wiele procesów jednocześnie próbuje utworzyć taki plik, tylko jeden z nich
może wykonać tę operację prawidłowo. Ostatni przykład jest o tyle dziwny, że daje pozostałym
użytkownikom systemu większe prawa niż te, którymi dysponuje właściciel pliku. Możliwość
stosowania tego rodzaju rozwiązań wynika z przyjętych reguł ochrony — okazuje się, że właściciel
pliku może zmienić tryb ochrony, nawet jeśli nie ma żadnych praw dostępu do samego pliku.
Użytkownik z identyfikatorem UID równym 0 jest traktowany specjalnie i określa się go
mianem superużytkownika (ang. superuser, root). Superużytkownik ma prawo odczytu i zapisu
wszystkich plików w systemie, niezależnie od tego, do kogo należą i jak są chronione. Także pro-
cesy z identyfikatorem UID równym 0 mają prawo korzystania z pewnego zbioru chronionych
wywołań systemowych niedostępnych dla zwykłych użytkowników. Zwykle tylko administrator
systemu zna hasło superużytkownika, jednak wielu użytkowników z mniejszymi uprawnieniami
uważa poszukiwanie luk w zabezpieczeniach umożliwiających logowanie w tej roli (mimo niezna-
jomości hasła) za doskonałą zabawę i ciekawe wyzwanie. Kierownictwo organizacji z natury rzeczy
próbuje zwalczać tego rodzaju próby.
Katalogi to także pliki z tymi samymi trybami ochrony co zwykłe pliki (z wyjątkiem bitu
wykonywania, który zastąpiono uprawnieniem przeszukiwania). Oznacza to, że katalog z trybem
rwxr–xr–x umożliwia właścicielowi odczyt, modyfikowanie i przeszukiwanie swojej zawartości,
ale pozostałym użytkownikom daje tylko możliwość odczytywania i przeszukiwania, ale już nie
dodawania ani usuwania plików.
przypisuje plikowi newgame bity ochrony rwxr–xr–x, aby każdy mógł go uruchomić (warto zwrócić
uwagę na stałą ósemkową 0755, która jest o tyle wygodna, że bity ochrony składają się z 3-bito-
wych grup). Bity ochrony mogą być zmieniane tylko przez właściciela pliku i superużytkownika.
Wywołanie systemowe access sprawdza (na podstawie rzeczywistych identyfikatorów UID
i GID), czy określona forma dostępu do pliku jest dopuszczalna. Wywołanie systemowe access jest
niezbędne do unikania naruszeń bezpieczeństwa w programach z ustawionym bitem SETUID
i należących do superużytkownika. Ponieważ taki program może niemal wszystko, warto czasem
sprawdzić, czy jego bieżący użytkownik rzeczywiście powinien mieć możliwość wykonywania
pewnych operacji. Program tego typu nie może po prostu próbować wykonywać pewnych operacji,
ponieważ praktycznie zawsze uzyskuje dostęp do żądanych zasobów. Wywołanie systemowe
access pozwala programowi określić, czy ta sama operacja byłaby możliwa w przypadku rzeczy-
wistych identyfikatorów UID i GID.
Kolejne cztery wywołania systemowe zwracają rzeczywiste i efektywne identyfikatory UID
i GID. Z ostatnich trzech wywołań może korzystać tylko superużytkownik. Za ich pomocą można
zmienić właściciela pliku oraz identyfikatory UID i GID procesu.
forma dostępu jest dopuszczalna. Jeśli tak, plik jest otwierany, a użyte wywołanie systemowe
zwraca jego deskryptor. Jeśli nie, plik nie jest otwierany, a kod wywołujący otrzymuje wartość –1.
Kolejne wywołania read lub write nie wymagają już dodatkowej weryfikacji uprawnień. Jeśli
więc tryb ochrony pliku zostanie zmieniony już po jego otwarciu, wprowadzone modyfikacje nie
wpłyną na możliwości procesów, które otwarły go wcześniej.
Model bezpieczeństwa systemu Linux i jego implementacja są takie same jak w wielu innych,
tradycyjnych systemach UNIX.
10.8. ANDROID
10.8.
ANDROID
wać się Android, aby była zapewniona zgodność z aplikacjami innych firm. W dokumencie tym
zamieszczono wymagania, jakie musi spełniać kompatybilne urządzenie z systemem Android.
Jednak bez jakiegoś sposobu egzekwowania tej zgodności wymagania te często byłyby ignorowane.
W związku z tym musi istnieć dodatkowy mechanizm, który pomoże zapewnić kompatybilność.
W systemie Android rozwiązano ten problem, pozwalając na tworzenie dodatkowych zastrze-
żonych usług na bazie platformy open source. Pozwala to na świadczenie usług (zazwyczaj
w chmurze), których nie można zaimplementować na samej platformie. Ponieważ usługi te są
zastrzeżone, istnieje możliwość ograniczenia zakresu urządzeń, na których są dozwolone. W ten
sposób można wymagać zgodności tych urządzeń z dokumentem CDD.
Firma Google zaimplementowała system Android w taki sposób, aby był w stanie wspierać
szeroką gamę zastrzeżonych usług w chmurze. Reprezentatywną grupę tych usług stanowią
usługi Google: Gmail, synchronizacja kalendarza i kontaktów, komunikacja pomiędzy chmurą
a urządzeniem (ang. Cloud To Device Messsaging — C2DM), a także wiele innych, spośród któ-
rych niektóre są widoczne dla użytkownika, a inne nie. Pod względem oferowania kompatybil-
nych aplikacji najważniejszą usługą jest Google Play.
Google Play to prowadzony przez firmę Google sklep z aplikacjami dla Androida. Ogólnie
rzecz biorąc, gdy deweloperzy stworzą aplikację na Androida, publikują ją w sklepie Google
Play. Ponieważ Google Play (lub dowolny inny sklep z aplikacjami) jest kanałem, przez który
aplikacje są dostarczane do urządzeń z systemem Android, ta zastrzeżona usługa jest odpowie-
dzialna za zapewnienie działania aplikacji na urządzeniach, na które są one dostarczane.
Usługa Google Play w celu zapewnienia zgodności wykorzystuje dwa główne mechanizmy.
Pierwszym i najważniejszym jest wymaganie, aby każde urządzenie dostarczane z usługą było kom-
patybilne z systemem Android w rozumieniu dokumentu CDD. To gwarantuje bazowe zachowanie
wszystkich urządzeń. Ponadto Google Play musi wiedzieć o wszystkich funkcjach urządzenia,
których aplikacja wymaga (np. obecność GPS do implementacji nawigacji samochodowych), aby
aplikacja nie była udostępniana na te urządzenia, na których tych funkcji brakuje.
Kiedy w lipcu 2005 roku Google przejął firmę Android, zapewniono niezbędne zasoby i wspar-
cie dla usług w chmurze w celu dalszego rozwoju Androida jako kompletnego produktu. Dość
mała grupa inżynierów, ściśle ze sobą współpracujących, zaczęła rozwijać zasadniczą infrastruk-
turę platformy, budując podstawy do rozwoju aplikacji wyższego poziomu.
Na początku 2006 roku dokonano istotnych zmian w planie: zamiast obsługi wielu języków
programowania skoncentrowano się w całości na Javie jako języku rozwoju aplikacji. Była to
trudna zmiana, ponieważ pierwotne, wielojęzyczne podejście powierzchownie zadowalało wszyst-
kich. Skoncentrowanie się na jednym języku zostało odebrane przez inżynierów, którzy prefe-
rowali inne języki, jako krok wstecz.
Jednak starając się zadowolić wszystkich, łatwo osiągnąć stan, w którym nikt nie jest zado-
wolony. Budowanie trzech różnych zestawów API dla różnych języków wymagałoby znacznie
więcej wysiłku niż skupienie się na jednym języku. Co więcej, każdy z tych interfejsów API
miałby niższą jakość. Decyzja o skupieniu się na języku Java była najważniejsza dla ostatecznej
jakości platformy oraz miała znaczny wpływ na zdolność dotrzymania ważnych terminów przez
członków zespołu deweloperów.
W miarę postępu prac platformę Android rozwijano w ścisłym związku z aplikacjami, które
ostatecznie miały być dostarczone wraz z systemem. Firma Google już wtedy oferowała szereg
usług, w tym Gmail, Maps, Calendar, YouTube i oczywiście Search, które miały być dostępne
na platformie Android. Wiedza zdobyta podczas implementowania tych aplikacji wywarła wpływ na
projekt powstającej platformy. Dzięki iteracyjnemu procesowi rozwoju platformy wraz z apli-
kacjami można było wyeliminować wiele wad projektowych na wczesnym etapie prac.
Większość wczesnych prac nad rozwojem aplikacji wykonano w warunkach dostępności dla
programistów niewielkiej części platformy systemowej. Platforma zwykle działała wewnątrz jednego
procesu — za pośrednictwem „symulatora”, na którym działał cały system wraz ze wszystkimi
jego aplikacjami. Wszystkie one działały jako pojedynczy proces na komputerze-hoście. Do tej pory
dostępne są pozostałości tej starej implementacji — np. w pakiecie SDK (od ang. Software
Development Kit), którego programiści Android używają do pisania aplikacji, jest dostępna metoda
Application.onTerminate.
W czerwcu 2006 roku wybrano dwa urządzenia jako cele rozwoju oprogramowania dla plano-
wanych produktów. Pierwsze, o nazwie kodowej Sooner (dosł. wcześniej), bazowało na smartfonie
wyposażonym w klawiaturę QWERTY i ekran bez dotykowego wejścia. Celem tego urządze-
nia było dostarczenie początkowego produktu tak szybko, jak to możliwe — z wykorzystaniem
istniejącego sprzętu. Drugie urządzenie docelowe, o nazwie kodowej Dream (dosł. sen), zapro-
jektowano specjalnie dla systemu Android — tak by działał całkowicie zgodnie z wizją produktu.
Urządzenie było wyposażone w duży (jak na owe czasy) ekran dotykowy, wysuwaną na zewnątrz
klawiaturę QWERTY, radio 3G (w celu szybszego przeglądania sieci Web), akcelerometr, GPS,
kompas (do obsługi Google Maps) itp.
Kiedy dokładniej przeanalizowano harmonogram wytwarzania oprogramowania, stało się jasne,
że rozwój dwóch harmonogramów sprzętowych nie ma większego sensu. Odkryto, że w czasie
kiedy będzie można wydać wersję Sooner, sprzęt, na który była ona przeznaczona, będzie już
przestarzały, a wysiłki wkładane w opracowanie tej wersji powodowały opóźnienia w rozwoju
ważniejszego urządzenia — Dream. Aby rozwiązać ten problem, postanowiono zrezygnować
z wersji Sooner jako urządzenia docelowego (choć prace rozwojowe na tym sprzęcie prowadzono
jeszcze przez jakiś czas — do momentu przygotowania nowszego sprzętu) i skoncentrowano się
całkowicie na wersji Dream.
Android 1.0
Pierwszą publicznie udostępnioną platformą Android był pakiet preview SDK, wydany w listo-
padzie 2007 roku. Składał się z emulatora urządzenia sprzętowego, na którym działał kompletny
obraz urządzenia z systemem Android i podstawowymi aplikacjami oraz dokumentacją API i śro-
dowiskiem programistycznym. W tym momencie były gotowe zasadniczy projekt i implementacja.
W dużej mierze były one zbliżone do współczesnej architektury systemu Android, którą będziemy
omawiać. W publikacji zawarto demonstracyjne klipy wideo platformy działającej zarówno na
sprzęcie Sooner, jak i Dream.
Wczesne prace rozwojowe nad systemem Android wykonywano w ramach kwartalnych
„kamieni milowych”, które prezentowały postępy procesu. Wydanie SDK było pierwszym bar-
dziej formalnym wydaniem platformy. Publikacja wymagała zebrania wszystkich elementów, które
zostały do tej pory opracowane, uporządkowania ich, udokumentowania oraz stworzenia spójnego
środowiska wytwarzania oprogramowania dla zewnętrznych producentów.
Odtąd prace rozwojowe były wykonywane zgodnie z dwoma ścieżkami: zbieranie opinii na
temat SDK w celu dalszego udoskonalenia i sfinalizowania API oraz wykańczanie i stabilizo-
wanie implementacji potrzebnej do opublikowania urządzenia Dream. W tym czasie wprowa-
dzono szereg publicznych aktualizacji do pakietu SDK. Ich kulminacją było wydanie 0.9 (w sierp-
niu 2008 roku), w którym zawarto interfejs API niemal w ostatecznej wersji.
Nad samą platformą prowadzono intensywne prace rozwojowe. Na wiosnę 2008 roku skon-
centrowano się na stabilizacji, aby można było opublikować urządzenie Dream. Wówczas Android
zawierał dużą ilość kodu, który nigdy nie został opublikowany jako produkt komercyjny. Na ten
nieopublikowany kod składały się część bibliotek języka C, interpreter Dalvik (służący do uru-
chamiania aplikacji), a także fragmenty systemu oraz niektóre aplikacje.
Android zawierał również sporo nowych pomysłów projektowych, których nigdy wcześniej
nie wdrażano, dlatego nie było jasne, czy sprawdzą się one w praktyce. Wszystkie te elementy
trzeba było scalić w stabilny produkt. Zespół poświęcił kilka pracowitych miesięcy na to, by
zapewnić spójne i zgodne z oczekiwaniami działanie wszystkich komponentów.
Wreszcie w sierpniu 2008 roku oprogramowanie było stabilne i gotowe do publikacji. Kom-
pilacje trafiły do produkcji. Zaczęto instalować je w urządzeniach. We wrześniu opublikowano
system Android 1.0 na platformie Dream, której nadano nazwę T-Mobile G1.
Dalszy rozwój
Po wydaniu systemu Android 1.0 nadal w szybkim tempie prowadzono prace rozwojowe. W ciągu
kolejnych pięciu lat wprowadzono około 15 istotnych aktualizacji platformy. W porównaniu z począt-
kową wersją 1.0 dodano szereg nowych funkcji oraz usprawnień.
Oryginalny dokument CDD zasadniczo określał, że zgodne urządzenia to takie, które są bardzo
podobne do urządzenia T-Mobile G1. W kolejnych latach zakres kompatybilnych urządzeń znacz-
nie się rozszerzył. Oto kluczowe wydarzenia tego procesu:
1. W 2009 roku w systemie Android w wersji od 1.5 do 2.0 wprowadzono programową kla-
wiaturę, która pozwalała usunąć wymóg istnienia klawiatury fizycznej. Wprowadzono też
obsługę różnego rodzaju ekranów (zarówno pod względem rozmiaru, jak i rozdzielczości),
m.in. tańszych urządzeń QVGA oraz nowych urządzeń — większych i o większej rozdziel-
czości — np. WVGA Motorola Droid. Wprowadzono również nowy mechanizm „funkcji
systemowych” dla urządzeń, pozwalający raportować obsługiwane funkcje sprzętu i aplikacji.
Dzięki temu można było wskazać, które funkcje sprzętowe są wymagane. Mechanizm
ten jest kluczowym komponentem używanym przez usługę Google Play do określenia
zgodności aplikacji z konkretnym urządzeniem.
2. W 2011 roku w systemie Android w wersjach od 3.0 do 4.0 wprowadzono nową podstawową
obsługę dla 10-calowych i większych tabletów. Podstawowa platforma mogła odtąd obsłu-
giwać rozmiary ekranów urządzeń, począwszy od niewielkich telefonów QVGA, poprzez
smartfony i większe tablety, po urządzenia o wyświetlaczach 7-calowych i większych (nawet
powyżej 10 cali).
3. Ponieważ platforma zapewniała wbudowaną obsługę dla bardziej zróżnicowanego sprzętu —
nie tylko wyposażonego w większe ekrany, ale również urządzenia niedotykowe z myszą
lub bez niej — pojawiło się znacznie więcej rodzajów urządzeń z systemem Android.
Obejmowało to Google TV, konsole do gier, notebooki, aparaty fotograficzne itp.
Znaczny wysiłek włożono również w coś, co było mniej widoczne: wyraźniejsze oddzielenie
zastrzeżonych usług Google od platformy open source Androida.
W pracach nad systemem Android 1.0 skoncentrowano się szczególnie na stworzeniu czytel-
nego API aplikacji zewnętrznych oraz platformy open source pozbawionej zależności od zastrze-
żonego kodu Google. Jednak implementacja zastrzeżonego kodu Google często nie była jeszcze
uporządkowana i posiadała zależności od wewnętrznej części platformy. Często platforma nie
miała nawet mechanizmów, które były wymagane przez zastrzeżony kod Google do tego, by
była możliwa właściwa integracja. Wkrótce zainicjowano szereg projektów, których celem było
rozwiązanie poniższych problemów:
1. W 2009 roku wraz z wydaniem systemu Android w wersji 2.0 wprowadzono architekturę
dla firm zewnętrznych pozwalającą na włączenie indywidualnych adapterów synchronizacji
do interfejsów API platformy, takich jak baza danych kontaktów. Kod Google potrzebny
do synchronizacji różnych danych przeniesiono do tych dobrze zdefiniowanych interfejsów
API SDK.
2. W 2010 roku w ramach prac nad systemem Android w wersji 2.2 uwzględniono zadania
opracowania wewnętrznego projektu i implementacji zastrzeżonego kodu Google. Ten
„wielki rozdział” pozwolił na czystą implementację wielu podstawowych usług Google —
od dostarczania za pośrednictwem chmury aktualizacji oprogramowania systemowego po
komunikację C2DM oraz inne usługi działające w tle. Dzięki temu można je było dostar-
czać i aktualizować niezależnie od platformy.
3. W 2012 roku do urządzeń wprowadzono nową aplikację usługi Google Play. Zawierała ona
zaktualizowane i nowe funkcje nieaplikacyjnych, zastrzeżonych usług Google. Był to efekt
prac nad rozdziałem kodu przeprowadzonych w 2010 roku. Dzięki temu firma Google mogła
dostarczać i aktualizować zastrzeżone API, takie jak komunikacja C2DM oraz mapy.
Proces init Androida nie uruchamia powłoki w tradycyjny sposób, ponieważ typowe urzą-
dzenie z systemem Android nie ma lokalnej konsoli dla dostępu na poziomie powłoki. Zamiast
tego proces demona adbd nasłuchuje połączeń zdalnych (np. przez USB), które żądają dostępu
do powłoki, i w razie potrzeby rozwidla dla nich procesy powłoki.
Ponieważ większa część systemu Android jest napisana w języku Java, demon zygote i pro-
cesy, które on inicjuje, mają kluczowe znaczenie dla systemu. Pierwszy proces, który jest zawsze
uruchamiany przez proces zygote, nosi nazwę system_server i zawiera wszystkie podstawowe
usługi systemu operacyjnego. Kluczowymi komponentami tego procesu są menedżer zasilania,
menedżer pakietów, menedżer okien i menedżer aktywności.
W miarę potrzeb proces zygote tworzy również inne procesy. Niektóre z nich są „trwałe”
i stanowią zasadniczą część systemu operacyjnego — np. stos telefonii w procesie phone, który
musi zawsze działać. Dodatkowe procesy aplikacji są tworzone i niszczone w razie potrzeby
podczas działania systemu.
Kiedy klasa PackageManager uzyska połączenie ze swoją usługą systemową, może wywoływać
jej funkcje. Większość wywołań aplikacji do klasy PackageManager jest implementowana w postaci
komunikacji międzyprocesowej przy użyciu mechanizmu Androida Binder IPC — w tym przypadku
poprzez wywołania implementacji PackageManagerService wewnątrz procesu system_server.
Implementacja usługi PackageManagerService rozstrzyga o interakcjach pomiędzy wszystkimi apli-
kacjami klienckimi i utrzymuje stan wykorzystywany przez wiele aplikacji.
Blokady WakeLock
Zarządzanie energią na urządzeniach przenośnych różni się od mechanizmów stosowanych
w tradycyjnych systemach komputerowych. Z tego względu w systemie Android wprowadzono
do Linuksa nową funkcję o nazwie blokad WakeLock (ang. wake locks, suspend blockers), zarzą-
dzającą sposobem przechodzenia urządzenia do stanu uśpienia.
W tradycyjnym systemie komputerowym system może znajdować się w jednym z dwóch
stanów zasilania: działający i gotowy do przyjęcia danych wprowadzanych przez użytkownika
lub głęboko uśpiony i bez możliwości przyjmowania zewnętrznych przerwań — np. przyciśnięcia
klawisza zasilania. W trybie działania pomocnicze elementy sprzętowe mogą być włączone lub
wyłączone według potrzeb, ale sam procesor i podstawowe części sprzętu muszą pozostać
w stanie zasilania, by mogły obsługiwać przychodzący ruch sieciowy i inne tego rodzaju zdarzenia.
Przejście do wymagającego mniejszej ilości energii stanu uśpienia jest czymś, co zdarza się
stosunkowo rzadko: albo gdy użytkownik wyraźnie wprowadzi system do stanu uśpienia, albo
gdy system sam „zdecyduje” o przejściu do uśpienia ze względu na stosunkowo długi okres
braku aktywności użytkownika. Wyjście ze stanu uśpienia wymaga wystąpienia przerwania sprzę-
towego z zewnętrznego źródła — np. wciśnięcia klawisza na klawiaturze. W odpowiedzi na takie
zdarzenie urządzenie obudzi się i włączy swój ekran.
Użytkownicy urządzeń mobilnych mają inne oczekiwania. Chociaż można wyłączyć ekran
w taki sposób, że wygląda to tak, jakby urządzenie przełączono do stanu uśpienia, to tradycyjny
stan uśpienia faktycznie nie jest pożądany. W czasie gdy ekran urządzenia jest wyłączony, urzą-
dzenie nadal musi być zdolne do pracy: musi być w stanie odbierać połączenia telefoniczne, odbie-
rać i przetwarzać dane dla przychodzących wiadomości i wykonywać wiele innych operacji.
Oczekiwania dotyczące włączania i wyłączania ekranu urządzenia mobilnego są również
znacznie bardziej większe w porównaniu z tradycyjnym komputerem. Interakcje z urządzeniami
mobilnymi to wiele krótkich cykli w ciągu całego dnia: otrzymujemy wiadomość i włączamy
urządzenie, aby ją przeczytać i być może wysłać krótką odpowiedź; spotykamy przyjaciół na
spacerze z ich nowym psem i włączamy urządzenie, aby zrobić im zdjęcie. W tego rodzaju typowych
zastosowaniach dla telefonii komórkowej każda zwłoka od wyciągnięcia urządzenia do momentu, gdy
jest ono gotowe do użytku, ma znaczący, negatywny wpływ na komfort pracy użytkownika.
Biorąc pod uwagę te wymagania, jednym z rozwiązań byłoby po prostu niedopuszczenie do
uśpienia procesora w czasie, gdy ekran urządzenia jest wyłączony. Dzięki temu urządzenie byłoby
zawsze gotowe do tego, aby ponownie się włączyć. Ostatecznie jądro przecież wie, kiedy nie ma
zaplanowanych żadnych prac dla żadnego z wątków, a Linux (podobnie jak większość systemów
operacyjnych) automatycznie przełącza procesor do stanu bezczynności i zużywa w tym stanie
mniej energii.
Bezczynny procesor to jednak nie jest dokładnie to samo co procesor całkowicie uśpiony; np.:
1. W przypadku wielu chipsetów w stanie bezczynności procesor zużywa znacznie więcej
energii niż w stanie rzecywistego uśpienia.
2. Bezczynny procesor CPU może obudzić się w każdej chwili, jeśli pojawi się jakaś praca do
wykonania — nawet wtedy, gdy operacje do wykonania nie mają istotnego znaczenia.
3. Sam stan bezczynności procesora nie oznacza, że można wyłączyć inny sprzęt, który nie
byłby potrzebny w stanie rzeczywistego uśpienia.
Blokady WakeLock w systemie Android umożliwiają systemowi przejście do głębszego uśpienia
bez przywiązywania do jawnego działania użytkownika, jak wyłączenie ekranu. Domyślny stan
systemu z blokadami WakeLock to stan uśpienia. Gdy urządzenie jest w stanie działania, musi
istnieć blokada, która zapobiega przejściu systemu z powrotem do stanu uśpienia.
Kiedy ekran jest włączony, system zawsze utrzymuje blokadę WakeLock zapobiegającą
przejściu urządzenia do stanu uśpienia, zatem — zgodnie z oczekiwaniami — urządzenie pozostaje
w stanie działania.
Jednak gdy ekran jest wyłączony, sam system zazwyczaj nie utrzymuje blokady WakeLock,
zatem pozostaje poza stanem uśpienia tylko wtedy, kiedy inny proces ją utrzymuje. Gdy nie
ma więcej aktywnych blokad WakeLock, system przechodzi do uśpienia i może wyjść z tego
stanu tylko w odpowiedzi na przerwanie sprzętowe.
Gdy system przeszedł do stanu uśpienia, przerwanie sprzętowe go obudzi — tak jak w tra-
dycyjnym systemie operacyjnym. Przykładowymi źródłami takich przerwań są alarmy czasowe,
zdarzenia z komórkowego radia (np. przychodzące połączenia), przychodzący ruch sieciowy, a także
wciśnięcia niektórych przycisków sprzętowych (np. przycisku zasilania). Procedury obsługi
przerwań dla tych zdarzeń wymagają jednej zmiany w porównaniu ze standardowym systemem
Linux: muszą zdobyć początkową blokadę WakeLock, aby utrzymać działanie systemu po obsłuże-
niu przerwania.
Blokada WakeLock uzyskana przez procedurę obsługi przerwania musi być utrzymywana
wystarczająco długo, by sterowanie zostało przekazane w górę stosu — do sterownika w jądrze,
który będzie kontynuować obsługę zdarzenia. Wówczas ten sterownik jądra jest odpowiedzialny
za zdobycie swojej własnej blokady WakeLock. Następnie blokadę WakeLock obsługi przerwania
można bezpiecznie zwolnić — bez ryzyka, że system ponownie przejdzie do stanu uśpienia.
Jeśli sterownik następnie ma zamiar przekazać to zdarzenie do przestrzeni użytkownika,
potrzebne jest podobne uzgadnianie. Sterownik musi zadbać o utrzymanie blokady WakeLock do
chwili dostarczenia zdarzenia do oczekującego procesu użytkownika i zapewnienia, że istnieje
możliwość zdobycia własnej blokady WakeLock. Ten przepływ może być również kontynuowany
w różnych podsystemach w przestrzeni użytkownika. Tak długo, jak jakiś proces utrzymuje blo-
kadę WakeLock, kontynuujemy wykonywanie żądanych operacji związanych z reakcją na zda-
rzenie. Kiedy żadne blokady WakeLock nie są już aktywne, cały system ponownie przechodzi do
stanu uśpienia, a jakiekolwiek przetwarzanie jest zatrzymywane.
Zabójca OOM
Linux zawiera mechanizm zabójcy OOM (ang. Out Of Memory killer — dosł. zabójca braku pamięci),
który jest odpowiedzialny za podjęcie działań zmierzających do odzyskania pamięci w przypadku,
gdy ilość wolnej pamięci osiąga bardzo niski poziom. Sytuacje, w których wyczerpuje się limit
pamięci, w nowoczesnych systemach operacyjnych są bardzo rzadkie. Dzięki stronicowaniu
i plikom wymiany rzadko dochodzi do wystąpienia błędów braku pamięci na poziomie aplikacji.
Jednak jądro może się znaleźć w sytuacji, w której nie jest w stanie znaleźć dostępnych stron
pamięci RAM wtedy, gdy są potrzebne, nie tylko dla nowego przydziału, ale także w przypadku
ładowania strony do określonego zakresu pamięci będącego w użyciu.
W takich sytuacjach niskiego stanu pamięci standardowy linuksowy mechanizm zabójcy OOM
daje ostatnią szansę na próbę znalezieniać pamięci RAM potrzebnej do tego, by jądro mogło konty-
nuować swoje działanie. Odbywa się to poprzez przypisanie każdemu procesowi poziomu zła
(ang. badness) i po prostu zabicie procesu, który jest uważany za najbardziej zły. Poziom zła procesu
bazuje na ilości pamięci RAM używanej przez proces, czasie, przez jaki działa, a także innych
czynnikach. Celem jest zabicie dużych procesów, które zgodnie z założeniem, nie są kluczowe.
W systemie Android na mechanizm zabójcy OOM położono szczególny nacisk. Nie ma pliku
wymiany, zatem sytuacje braku pamięci mogą być znacznie częstsze: nie ma sposobu złago-
dzenia presji na pamięć inaczej niż wyczyszczenie niedawno używanych stron pamięci RAM
10.8.6. Dalvik
Dalvik implementuje w systemie Android środowisko języka Java, które jest odpowiedzialne
za uruchamianie aplikacji, jak również większości kodu systemu. Prawie wszystko w procesie
system_service — od menedżera pakietów, poprzez menedżera okien, po menedżera aktywności —
jest zaimplementowane za pomocą kodu języka Java wykonywanego w środowisku Dalvik.
Android nie jest jednak platformą języka Java w tradycyjnym sensie. Kod Javy w aplikacji
Androida jest dostarczany w formacie kodu bajtowego mechanizmu Dalvik — na bazie rejestru
maszynowego zamiast tradycyjnego kodu bajtowego w formie stosu. Format kodu bajtowego
środowiska Dalvik pozwala na szybszą interpretację, a jednocześnie nadal wspiera kompilację JIT
(ang. Just-In-Time — dokładnie na czas). Kod bajtowy środowiska Dalvik jest również bardziej
wydajny pod względem miejsca — zarówno na dysku, jak i w pamięci RAM — dzięki zastoso-
waniu puli łańcuchów znaków i innych technik.
Podczas pisania aplikacji na Androida kod źródłowy jest pisany w Javie, a następnie kom-
pilowany do postaci standardowego kodu bajtowego Javy i przy użyciu jej tradycyjnych narzędzi.
Następnie dla Androida realizowany jest nowy etap: konwersja kodu bajtowego Javy na bardziej
zwartą reprezentację kodu bajtowego środowiska Dalvik. To wersja kodu bajtowego środowiska
Dalvik jest podstawą pakietów instalacyjnych, które tworzą ostateczną postać binariów aplikacji
i są ostatecznie instalowane w urządzeniu.
Architektura systemu Android mocno bazuje na prymitywach systemu Linux — w tym mecha-
nizmach zarządzania pamięcią, bezpieczeństwem oraz komunikacją ponad granicami zabezpieczeń.
Nie używa języka Java do zasadniczych komponentów systemu operacyjnego — nie ma zbyt wiel-
kiego nacisku na stworzenie warstwy abstrakcji dla tych istotnych aspektów bazowego systemu
operacyjnego Linux.
Na szczególną uwagę zasługuje wykorzystanie procesów w systemie Android. Jego projekt nie
bazuje na języku Java w celu odizolowania aplikacji od systemu, ale raczej przyjmuje tradycyjne
dla systemu operacyjnego podejście — izolację procesów. Oznacza to, że każda aplikacja jest
uruchamiana jako odrębny proces linuksowy z własnym środowiskiem Dalvik. W podobny sposób
działają proces system_server i inne podstawowe fragmenty platformy, które są napisane w Javie.
Użycie procesów do tej izolacji procesów pozwala wykorzystać w systemie Android wszyst-
kie linuksowe funkcje zarządzania procesami — od izolacji pamięci po zniszczenie wszelkich
zasobów związanych z procesem w momencie, gdy kończy on działanie. Procesy nie są jedy-
nym mechanizmem Linuksa stosowanym w Androidzie. Oprócz nich do zabezpieczeń są sto-
sowane wyłącznie funkcje Linuksa — nie jest wykorzystywana do tego celu architektura Secu-
rityManager Javy.
Wykorzystanie procesów Linuksa i mechanizmów bezpieczeństwa znacznie upraszcza śro-
dowisko Dalvik, ponieważ nie jest już ono odpowiedzialne za te krytyczne aspekty stabilności
i niezawodności systemu. Nie jest również przypadkiem to, że dzięki wymienionym mechanizmom
aplikacje mogą swobodnie używać natywnego kodu w swoich implementacjach. Szczególnie
w przypadku gier, które są zazwyczaj oparte na silnikach zaimplementowanych w języku C++.
Takie połączenie procesów i języka Java powoduje pewne wyzwania. Uruchomienie świeżego
środowiska języka Java może zająć kilka sekund — nawet na nowoczesnym sprzęcie mobilnym.
Przypomnijmy, że jednym z celów projektu Android było zapewnienie szybkiego uruchamiania
aplikacji — wyznaczono granicę 200 ms. Wymóg uruchomienia świeżego procesu Dalvik dla
nowej aplikacji z pewnością spowodowałby przekroczenie tego budżetu. Uruchomienie aplikacji
w czasie 200 ms jest trudne do osiągnięcia na mobilnym sprzęcie — nawet bez konieczności
inicjowania nowego środowiska Javy.
Rozwiązaniem tego problemu jest natywny demon zygote, o którym krótko wspominaliśmy
wcześniej. Demon zygote jest odpowiedzialny za uruchomienie i zainicjowanie środowiska Dalvik
do takiego momentu, kiedy będzie ono gotowe do uruchomienia napisanego w języku Java kodu
systemu lub aplikacji. Wszystkie nowe procesy bazujące na środowisku Dalvik (systemu lub apli-
kacji) są rozwidlane z demona zygote. Dzięki temu mogą zacząć działać w chwili, gdy środowisko
jest już gotowe do pracy.
Demon zygote jest odpowiedzialny nie tylko za uruchomienie środowiska Dalvik. Zajmuje
się również załadowaniem wielu powszechnie stosowanych w systemie i aplikacjach fragmentów
frameworka systemu Android. Odpowiada także za załadowanie zasobów i innych komponentów,
które często są potrzebne.
Należy zwrócić uwagę, że podczas tworzenia nowego procesu z demona zygote jest wykorzy-
stywane linuksowe wywołanie fork, ale nie jest to wywołanie exec. Nowy proces jest repliką
oryginalnego procesu zygote ze wszystkimi zainicjowanymi wcześniej stanami — gotową do
działania. Relację procesu nowej aplikacji Javy z oryginalnym procesem zygote pokazano na
rysunku 10.24. Po wykonaniu wywołania fork nowy proces ma do dyspozycji własne odrębne
środowisko Dalvik, choć współdzieli wszystkie dane załadowane i zainicjowane przez proces
zygote za pomocą techniki kopiowania stron przy zapisie. Aby nowy działający proces był gotowy
do działania, wystarczy tylko nadać mu prawidłową tożsamość (identyfikator UID itp.), zakończyć
inicjalizację środowiska Dalvik (co wymaga uruchomienia wątków) i załadować kod aplikacji lub
systemu do uruchomienia.
Oprócz szybkości uruchamiania demon zygote przynosi inną korzyść. Ponieważ do tworzenia
procesów jest wykorzystywane wyłącznie wywołanie fork, to duża liczba zabrudzonych stron RAM
potrzebnych do zainicjowania środowiska Dalvik, a także wstępnie ładowane klasy i zasoby mogą
być współużytkowane pomiędzy procesem zygote i wszystkimi jego procesami potomnymi. To
współdzielenie jest szczególnie ważne dla środowiska Android, w którym nie jest dostępny plik
wymiany — dostępne jest żądanie stronicowania czystych stron (takich jak kod wykonywalny)
z „dysku” (pamięci flash). Jednak wszelkie zabrudzone strony muszą pozostać zablokowane
w pamięci RAM; nie mogą być stronicowane na „dysk”.
(Dla potrzeb tej dyskusji uprościliśmy opis sposobu, w jaki dane transakcji są przesyłane przez
system — założyliśmy, że są one przesyłane w dwóch kopiach: jedna do jądra i druga do przestrzeni
adresowej procesu odbiorczego. W faktycznej implementacji dane są przesyłane w jednym egzem-
plarzu. Dla każdego procesu, który może odbierać transakcje, jądro tworzy obszar pamięci współ-
dzielonej z tym procesem. Podczas obsługi transakcji najpierw określa proces, który ją odbierze,
i kopiuje dane bezpośrednio do tej współużytkowanej przestrzeni adresowej).
Zwróćmy uwagę, że każdy proces pokazany na rysunku 10.26 ma „pulę wątków”. Jest to jeden
lub więcej wątków utworzonych przez przestrzeń użytkownika w celu obsługi przychodzących
transakcji. Jądro wysyła każdą transakcję przychodzącą do wątku, który aktualnie oczekuje na
pracę w tej puli wątków. Jednak wywołania do jądra z procesu wysyłającego nie muszą pocho-
dzić z puli wątków — transakcję może zainicjować dowolny wątek w procesie, np. wątek Ta
z rysunku 10.26.
Widzieliśmy wcześniej, że transakcje przekazane do jądra identyfikują docelowy obiekt, nato-
miast jądro musi określić proces odbiorczy. Aby to osiągnąć, jądro ma dostęp do listy dostępnych
obiektów w każdym procesie i mapuje je na inne procesy — tak jak pokazano na rysunku 10.27.
Obiekty, których szukamy, są po prostu lokalizacjami w przestrzeni adresowej procesu. Jądro
utrzymuje jedynie informacje o adresach tych obiektów, nie przypisuje do nich znaczenia. Mogą
one być lokalizacjami struktur danych języka C, obiektami języka C++ lub dowolnymi innymi
konstrukcjami umieszczonymi w przestrzeni adresowej procesu.
Odwołania do obiektów w procesach zdalnych są identyfikowane przez całkowitoliczbowy
uchwyt, który przypomina linuksowy deskryptor pliku. Dla przykładu rozważmy Obiekt2a
w procesie Proces 2 — jądro wie, że jest on powiązany z procesem Proces 2. Przypisało do niego
Uchwyt 2 w procesie Proces 1. Proces 1 może zatem przesłać transakcję do jądra skierowaną do
obiektu identyfikowanego przez Uchwyt 2. Na tej podstawie jądro może określić, że transakcja
została wysłana do procesu Proces 2, a konkretnie do obiektu Obiekt2a w tym procesie.
Podobnie jak w przypadku deskryptorów plików, wartość uchwytu w jednym procesie nie
oznacza tego samego, co ta wartość w innym procesie. Na rysunku 10.44 można np. zauważyć,
że w procesie Proces 1, wartość Uchwytu 2 identyfikuje Obiekt2a; jednak w procesie Proces 2 ta
sama wartość uchwytu identyfikuje obiekt Obiekt1a. Co więcej, niemożliwe jest, aby jeden
proces mógł uzyskać dostęp do obiektu w innym procesie, jeśli jądro nie przypisało do niego
uchwytu dla tego procesu. Na rysunku 10.27 możemy również zauważyć, że Obiekt2b należący
do procesu Proces 2 jest znany jądru, ale nie został mu przypisany uchwyt dla procesu Proces 1.
Nie istnieje zatem ścieżka dla procesu Proces 1, która pozwoliłaby na dostęp do tego obiektu,
nawet jeśli jądro przypisało do niego uchwyty dla innych procesów.
W jaki sposób są konfigurowane te powiązania uchwyt – obiekt? W przeciwieństwie do
deskryptorów plików systemu Linux procesy użytkownika bezpośrednio nie żądają uchwytów.
Zamiast tego jądro przypisuje uchwyty do procesów stosownie do potrzeb. Ten proces zilustro-
wano na rysunku 10.28. Można na nim zobaczyć sposób odwołania do obiektu Obiekt1b z procesu
Proces 2 w procesie Proces 1 z poprzedniego rysunku. Kluczem do tego jest kierunek przepływu
transakcji przez system — od lewej do prawej — tak jak pokazano na dole rysunku.
Oto najważniejsze kroki przedstawione na rysunku 10.28:
1. Proces 1 tworzy początkową strukturę transakcji, która zawiera lokalny adres obiektu
Obiekt1b.
2. Proces 1 przesyła transakcję do jądra.
3. Jądro „zagląda” do danych w transakcji, znajduje adres obiektu Objekt1b i tworzy dla niego
nowy wpis, ponieważ wcześniej nie znało tego adresu.
4. Jądro wykorzystuje miejsce docelowe transakcji Uchwyt 2 w celu wskazania, że transakcja
jest skierowana do obiektu Obiekt2a, który jest w procesie Proces 2.
5. Jądro przepisuje nagłówek transakcji, by był właściwy dla procesu Proces 2 — zmieniając
miejsce docelowe na adres Obiekt2a.
6. W podobny sposób jądro przepisuje dane transakcji dla procesu docelowego. W tym przy-
padku „dowiaduje się”, że Obiekt1b nie jest jeszcze znany procesowi Proces 2, dlatego
tworzy dla niego nowy Uchwyt 3.
7. Zmodyfikowana transakcja jest dostarczana do procesu Proces 2 w celu uruchomienia.
8. Po otrzymaniu transakcji proces „odkrywa”, że jest w niej nowy Uchwyt 3, i dodaje to do
swojej tabeli dostępnych uchwytów.
Jeśli obiekt w obrębie transakcji jest już znany procesowi odbierającemu, przepływ stero-
wania jest podobny, z tą różnicą, że teraz jądro musi tylko przepisać transakcję tak, aby zawierała
wcześniej przypisany uchwyt lub wskaźnik lokalnego obiektu procesu odbierającego. Oznacza
to, że obiekt wysyłany do procesu kilka razy zawsze będzie miał taką samą tożsamość. Pod
tym względem uchwyty różnią się od deskryptorów plików systemu Linux — tu w przypadku
wielokrotnego otwarcia tego samego pliku za każdym razem zostaje przydzielony inny deskryptor.
System Binder IPC utrzymuje tablicę unikatowych identyfikatorów obiektów w miarę przemiesz-
czania obiektów pomiędzy procesami.
Architektura Binder zasadniczo wprowadza do Linuksa model zabezpieczeń oparty na upraw-
nieniach (ang. capability-based). Każdy obiekt Binder to odrębne uprawnienie. Wysłanie obiektu
do innego procesu powoduje przyznanie uprawnienia do tego procesu. Proces odbierający może
następnie korzystać z wszystkich funkcji, których obiekt dostarcza. Proces może wysłać obiekt
do innego procesu, otrzymać później obiekt z dowolnego procesu i ustalić, czy ten odebrany
obiekt jest dokładnie tym samym, który został pierwotnie wysłany.
Struktura transakcji jądra, którą wcześniej omawialiśmy, jest zatem podzielona w interfejsach
API przestrzeni użytkownika: obiekt docelowy jest reprezentowany przez obiekt BinderProxy,
a jego dane są przechowywane w obiekcie Parcel. Przepływ transakcji przez jądro jest taki, jak
pokazaliśmy wcześniej. Po pojawieniu się transakcji w przestrzeni użytkownika w procesie
odbierającym jego obiekt docelowy jest używany do określenia odpowiedniego odbierającego
obiektu Binder, natomiast dane transakcji służą do stworzenia obiektu Parcel. Następnie są one
dostarczane do metody onTransact tego obiektu.
Za pomocą tych trzech klas napisanie kodu IPC jest dość proste:
1. Utworzenie podklasy klasy Binder.
2. Implementacja metody onTransact w celu zdekodowania i obsługi połączeń przychodzących.
3. Implementacja odpowiedniego kodu w celu stworzenia obiektu Parcel, który można prze-
kazać do metody transact tego obiektu.
Większość tej pracy jest wykonywana w dwóch ostatnich krokach. Jest to kod odpowiedzialny
za tzw. marshalling i unmarshalling — operacje potrzebne do tego, by przekształcić kod w postaci
wygodnej do programowania, tzn. zawierający proste wywołania metod, w kod operacji potrzeb-
nych do realizacji komunikacji IPC. Pisanie takiego kodu jest nudne i stwarza okazje do popeł-
nienia błędów, dlatego lepiej, jeśli zrobi to za nas komputer.
interface IExample {
void print(String msg);
}
Opis interfejsu podobnego do pokazanego na listingu 10.3 jest kompilowany przez narzę-
dzie AIDL. Na tej podstawie są generowane trzy klasy Javy pokazane na rysunku 10.30:
1. IExample dostarcza definicję interfejsu w języku Java.
2. IExample.Stub jest klasą bazową do implementacji tego interfejsu. Klasa ta dziedziczy po
klasie Binder, co oznacza, że może być odbiorcą wywołań IPC; dziedziczy także po inter-
fejsie IExample, ponieważ jest to implementacja tego interfejsu. Celem tej klasy jest reali-
zacja operacji unmarshalling: przekształcenia wywołań onTransact na odpowiednie wywo-
łania metod interfejsu IExample. Podklasa klasy IExample.Stub jest następnie odpowiedzialna
tylko za implementację metod IExample.
3. IExample.Proxy to klasa działająca po drugiej stronie połączenia IPC. Jest odpowiedzialna
za wykonywanie operacji marshalling wywołania. To konkretna implementacja interfejsu
IExample, która zawiera definicje wszystkich jego metod przekształcających wywołanie na
odpowiednią zawartość obiektu Parcel i wysyła ten obiekt za pośrednictwem wywołania
transact poprzez połączenie IBinder, z którym się komunikuje.
Mając do dyspozycji te klasy, nie ma powodu, by dalej przejmować się mechanizmami IPC.
Implementatorzy interfejsu IExample po prostu dziedziczą po klasie IExample.Stub i implemen-
tują metody interfejsu tak jak zwykle. Procesy wywołujące otrzymają interfejs IExample imple-
mentowany przez klasę IExample.Proxy, co pozwoli im na wykonywanie standardowych wywołań
interfejsu.
Sposób współdziałania tych fragmentów w celu wykonania kompletnej operacji IPC pokazano
na rysunku 10.31. Proste wywołanie print interfejsu IExample jest przekształcane na następującą
sekwencję:
1. Obiekt IExample.Proxy realizuje marshalling wywołania metody do postaci obiektu Parcel,
wywołując metodę transact obiektu BinderProxy.
2. Obiekt BinderProxy konstruuje transakcję jądra i dostarcza ją do jądra poprzez wywoła-
nie ioctl.
3. Jądro dokonuje transferu transakcji do zamierzonego procesu, dostarczając ją do wątku,
który czeka w swoim własnym wywołaniu ioctl.
4. Transakcja jest dekodowana z powrotem do postaci obiektu Parcel i zostaje wywołana
metoda onTransact na odpowiednim obiekcie lokalnym — w tym przypadku ExampleImpl
(będącym podklasą klasy IExample.Stub).
5. Obiekt IExample.Stub dekoduje obiekt Parcel do postaci odpowiednich metod i argumentów
do wywołania — w tym przypadku wywołuje metodę print.
6. Na koniec wykonywana jest konkretna implementacja metody print w klasie ExampleImpl.
Z wykorzystaniem tego mechanizmu jest napisana większość kodu IPC w Androidzie. Więk-
szość usług w Androidzie definiuje się za pośrednictwem AIDL i implementuje w sposób zapre-
zentowany powyżej. Przypomnijmy sobie wcześniejszy rysunek 10.23. Pokazano na nim sposób
wykorzystania komunikacji IPC w implementacji menedżera pakietów w procesie system_server.
Mechanizm IPC został tu użyty do opublikowania menedżera pakietów w menedżerze usług, by
inne procesy mogły do niego kierować wywołania. Wykorzystano tam dwa interfejsy AIDL: jeden
dla menedżera usług i drugi dla menedżera pakietów. Dla przykładu na listingu 10.4 pokazano pod-
stawowy opis AIDL dla menedżera usług. Zawiera on metodę getService, którą inne procesy
wykorzystują do pobrania interfejsu IBinder interfejsów usług systemu, takich jak menedżer
pakietów.
interface IServiceManager {
IBinder getService(String name);
void addService(String name, IBinder binder);
}
<activity android:name="com.example.email.MailMainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.categor y.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name="com.example.email.ComposeActivity">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<categor y android:name="android.intent.categor y.DEFAULT" />
<receiver android:name="com.example.email.SyncControlReceiver">
<intent-filter>
<action android:name="android.intent.action.DEVICE_STORAGE_LOW" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.DEVICE_STORAGE_OKAY" />
</intent-filter>
</receiver>
<provider android:name="com.example.email.EmailProvider"
android:author ities="com.example.email.provider.email">
</provider>
</application>
</manifest>
Aplikacje Androida nie zawierają prostego punktu wejścia main, do którego jest przekazywane
sterowanie w chwili, gdy użytkownik uruchomi aplikację. Zamiast tego wewnątrz znacznika
<application> w manifeście jest zadeklarowanych kilka różnych punktów wejścia opisujących
różne operacje, które aplikacja może wykonywać. Te punkty wejścia są wyrażane jako cztery
różne typy i definiują podstawowe rodzaje zachowań aplikacji: activity (działanie), receiver
(odbiornik), service (usługa) oraz provider (dostawca zawartości). W zaprezentowanym przykła-
dzie zamieszczono deklaracje kilku działań i po jednej deklaracji innych typów komponentów,
ale aplikacja może nie zawierać żadnej deklaracji określonego typu lub zawierać wiele deklaracji
dla każdego z typów.
Każdy z czterech typów komponentów, które może zawierać aplikacja, może mieć inną
semantykę i inne zastosowania w systemie. We wszystkich przypadkach atrybut android:name
określa nazwę klasy Javy kodu aplikacji implementującego dany komponent. System utworzy
egzemplarz tej klasy, kiedy zajdzie taka potrzeba.
Menedżer pakietów jest częścią Androida, która zarządza wszystkimi pakietami w aplikacji.
Parsuje manifesty wszystkich aplikacji, zbiera i indeksuje informacje, które są w nich zapisane.
Następnie, posiadając te informacje, zapewnia klientom mechanizmy odpytywania o aktualnie
zainstalowane aplikacje i pobierania na ich temat właściwych danych. Menedżer pakietów jest
także odpowiedzialny za instalowanie aplikacji (tworzenie miejsca dla aplikacji w pamięco trwałej
oraz zapewnienie integralności pakietu apk) oraz za wszystkie czynności potrzebne do odinsta-
lowania aplikacji (usunięcie wszystkiego, co jest związane z wcześniej zainstalowaną aplikacją).
Aplikacje statycznie deklarują swoje punkty wejścia w manifeście, dlatego w czasie instalacji
nie muszą uruchamiać kodu, który rejestruje je w systemie. Dzięki takiemu projektowi system
staje się bardziej „wytrzymały” pod wieloma względami: instalowanie aplikacji nie wymaga
uruchomienia żadnego kodu aplikacji; uprawnienia aplikacji najwyższego poziomu zawsze mogą
być ustalone na podstawie manifestu; nie ma potrzeby utrzymywania oddzielnej bazy danych
informacji o aplikacji — co wiąże się z ryzykiem utraty synchronizacji (np. w przypadku krzyżowych
aktualizacji) z faktycznymi uprawnieniami aplikacji i gwarantuje, że po odinstalowaniu aplikacji
nie pozostaną po niej żadne informacje. To zdecentralizowane podejście zastosowano w celu unik-
nięcia wielu problemów występujących dla scentralizowanego rejestru systemu Windows.
Podzielenie aplikacji na bardziej szczegółowe składniki służy również jednemu z celów pro-
jektowych — wsparciu współpracy pomiędzy różnymi aplikacjami. Aplikacje mają możliwość
publikowania swoich fragmentów realizujących konkretne funkcje. Z tych funkcji inne aplikacje
mogą korzystać bezpośrednio lub pośrednio. Możliwości te zilustrujemy przy okazji opisania
w bardziej szczegółowy sposób czterech rodzajów składników, które mogą być opublikowane.
Nad menedżerem pakietów działa inna ważna usługa systemu — menedżer aktywności
(ang. activity manager). Podczas gdy menedżer pakietów jest odpowiedzialny za utrzymywanie
statycznych informacji na temat wszystkich zainstalowanych aplikacji, menedżer aktywności
określa, kiedy, gdzie i jak te aplikacje powinny być uruchomione. Pomimo swojej nazwy mene-
dżer aktywności faktycznie jest odpowiedzialny za uruchamianie wszystkich czterech typów
składników aplikacji i implementację odpowiednich zachowań dla każdego z nich.
Aktywności
Aktywność (ang. activity) to część aplikacji, która oddziałuje bezpośrednio z użytkownikiem poprzez
interfejs użytkownika. Gdy ten uruchamia aplikację na swoim urządzeniu, w rzeczywistości jest
to aktywność wewnątrz aplikacji, która została oznaczona jako główny punkt wejścia. Aplikacja
w swojej aktywności implementuje kod, który jest odpowiedzialny za interakcje z użytkownikiem.
Przykładowy manifest aplikacji pocztowej z listingu 10.5 zawiera dwie aktywności. Pierwszą
jest główny interfejs użytkownika obsługi poczty, pozwalający użytkownikom przeglądać swoje
wiadomości; drugi to oddzielny interfejs do tworzenia nowej wiadomości. Pierwsza aktywność
obsługi poczty jest zadeklarowana jako główny punkt wejścia dla aplikacji, czyli ta aktywność,
która zostanie uruchomiona, gdy użytkownik uruchomi aplikację ze swojego ekranu startowego.
Ponieważ pierwsza aktywność jest podstawowa, wyświetli się użytkownikom jako ta apli-
kacja, którą można uruchomić za pomocą głównego programu rozruchowego aplikacji (ang.
launcher). Po uruchomieniu aplikacji system będzie w stanie pokazanym na rysunku 10.32. W tym
przykładzie menedżer aktywności, po lewej stronie, stworzył w swoim procesie egzemplarz klasy
ActivityRecord — obiekt służący do śledzenia aktywności. Jedna lub więcej takich aktywności
jest zorganizowanych w kontenery zwane zadaniami (ang. tasks), które w przybliżeniu odpo-
wiadają funkcjom, jakie aplikacja udostępnia użytkownikom. W tym momencie menedżer aktyw-
ności rozpoczął proces aplikacji pocztowej oraz stworzył egzemplarz obiektu MainMailActivity,
którego zadaniem jest wyświetlenie głównego interfejsu użytkownika — skojarzonego z odpo-
wiednim obiektem ActivityRecord. Aktywność jest w stanie o nazwie wznowiona (ang. resumed),
ponieważ w tym momencie działa na pierwszym planie interfejsu użytkownika.
Jeśli użytkownik zdecyduje się na przejście z aplikacji pocztowej (nie wyłączając jej) i uru-
chomienie aplikacji aparatu fotograficznego w celu zrobienia zdjęcia, system znajdzie się w stanie
pokazanym na rysunku 10.33. Należy zauważyć, że mamy teraz nowy proces — aparatu fotogra-
ficznego — w którym działa główna aplikacja aparatu, powiązany z nim obiekt ActivityRecord
w menedżerze aktywności, i to aktywność aparatu jest teraz w stanie wznowiona. Z poprzednią
aktywnością aplikacji pocztowej również dzieją się interesujące rzeczy: nie jest już w stanie
wznowiona; jest w stanie zatrzymana (ang. stopped), a obiekt ActivityRecord przechowuje jej
zapisany stan (ang. saved state).
Kiedy aktywność nie jest już na pierwszym planie, system żąda od niej „zapisania swojego
stanu”. Wiąże się to z utworzeniem przez aplikację minimalnej ilości informacji o stanie. Infor-
macje te obejmują dane na temat tego, co użytkownik aktualnie widzi. Aplikacja zwraca te dane do
menedżera aktywności i zapisuje w procesie system_server, wewnątrz obiektu ActivityRecord
powiązanego z aktywnością. Zapisany stan aktywności zazwyczaj zawiera niewiele danych — np.
pozycję w wiadomości, w której użytkownik przeglądał wiadomość pocztową, ale nie całą wiado-
mość, którą aplikacja będzie przechowywała w innym miejscu, tzn. trwałym magazynie danych.
Przypomnijmy, że chociaż Android żąda stronicowania (może ładować i usuwać czyste strony
RAM, które zostały zmapowane z plików na dysku — np. kod), to nie wykorzystuje obszaru
wymiany. Oznacza to, że wszystkie „zabrudzone” strony pamięci RAM w obrębie procesu aplikacji
muszą pozostać w pamięci RAM. Dzięki temu, że stan głównej aplikacji pocztowej jest bez-
piecznie zapisany w menedżerze aktywności, system uzyskuje nieco elastyczności w zarządzaniu
pamięcią, podobnej do tej, jaką daje plik wymiany.
Jeśli np. aplikacja aparatu fotograficznego zacznie potrzebować dużo pamięci RAM, system
będzie mógł po prostu pozbyć się procesu aplikacji pocztowej, tak jak pokazano na rysunku 10.34.
Obiekt ActivityRecord wraz z cennymi informacjami o zapisanym stanie pozostaje bezpiecznie
„schowany” przez menedżera aktywności w procesie system_server. Ponieważ proces system_
server jest hostem dla wszystkich podstawowych usług systemowych Androida, to musi
zawsze działać — zatem zapisany stan aplikacji będzie dostępny tak długo, jak długo będziemy go
potrzebować.
Przykładowa aplikacja poczty elektronicznej nie tylko obejmuje aktywność głównego interfejsu
użytkownika, ale także zawiera inną aktywność — ComposeActivity. Aplikacje mają możliwość
zadeklarowania dowolnej liczby aktywności. To może pomóc w zorganizowaniu implementacji
aplikacji, ale co ważniejsze, może służyć do zaimplementowania interakcji między aplikacjami.
Rysunek 10.34. Usunięcie procesu aplikacji pocztowej w celu odzyskania pamięci RAM
dla aparatu fotograficznego
Usługi
Usługa ma dwie różne tożsamości:
1. Może być samodzielną długotrwale działającą operacją drugiego planu. Typowe przykłady
wykorzystania usług w taki sposób to odtwarzanie muzyki w tle, utrzymywanie aktyw-
nego połączenia sieciowego (np. z serwerem IRC) w czasie, gdy użytkownik obsługuje
inne aplikacje, pobieranie lub przekazywanie danych w tle itp.
2. Może służyć jako punkt połączenia dla innych aplikacji lub systemu w celu realizacji
bogatej interakcji z aplikacją. Może to być wykorzystane przez aplikacje do zapewnienia
bezpiecznego API dla innych aplikacji — np. przetwarzania obrazu lub dźwięku, zamiany
tekstu na mowę itp.
Przykładowy manifest aplikacji pocztowej z listingu 10.5 zawiera usługę, która jest używana
do wykonywania synchronizacji skrzynki pocztowej użytkownika. Typowa implementacja polega
na zaplanowaniu uruchamiania usługi w regularnych odstępach czasu, np. co 15 min, urucho-
mienia usługi, gdy nadszedł czas, aby ją uruchomić, i zatrzymania po wykonaniu zadań.
Jest to typowe wykorzystanie usługi pierwszego stylu — długotrwale działającej operacji
w tle. Stan systemu w tym dość prostym przypadku pokazano na rysunku 10.37. Menedżer
aktywności utworzył obiekt aktywności ServiceRecord z informacją, że została uruchomiona i z tego
powodu utworzono egzemplarz obiektu SyncService w procesie aplikacji. W tym stanie usługa
jest w pełni aktywna (nie pozwala na przejście całego systemu do uśpienia, jeśli nie utrzymuje
blokady WakeLock) i wykonuje swoje działania. Istnieje możliwość, że w tym stanie proces
aplikacji zakończy działanie — np. w wyniku awarii — ale menedżer aktywności będzie nadal
utrzymywał związany z nią obiekt ServiceRecord i będzie mógł w tym momencie zdecydować
o ponownym uruchomieniu usługi, jeśli zajdzie taka potrzeba.
Aby zobaczyć, jak można skorzystać z usługi jako punktu połączenia do interakcji z innymi
aplikacjami, załóżmy, że chcemy rozszerzyć istniejący obiekt SyncService, by uzyskać interfejs
API umożliwiający innym aplikacjom zarządzanie interwałem synchronizacji. Dla tego interfej-
su API należy zdefiniować interfejs AIDL podobny do tego, który pokazano na listingu 10.6.
interface ISyncControl {
int getSyncInterval();
void setSyncInterval(int seconds);
}
Aby z niego skorzystać, inny proces można powiązać (ang. bind) z usługą aplikacji, co pozwala
na uzyskanie dostępu do jej interfejsu. Spowoduje to utworzenie połączenia pomiędzy dwiema
aplikacjami, jak pokazano na rysunku 10.38. Oto kolejne etapy tego procesu:
Odbiorcy
Odbiorca (ang. receiver) jest adresatem zdarzeń (zazwyczaj zewnętrznych), które zachodzą najczę-
ściej w tle — poza standardową komunikacją z użytkownikiem. Odbiorcy koncepcyjnie są tym
samym co aplikacja, która jawnie rejestruje się do wywołania zwrotnego, gdy coś interesującego
się zdarzy (nadejdzie alarm, zmieni się połączenie źródła danych itp.), ale nie wymagają urucho-
mienia aplikacji w celu otrzymania zdarzenia.
Przykład manifestu aplikacji pocztowej zaprezentowanej na listingu 10.5 zawiera odbiorcę
umożliwiającego aplikacji uzyskanie informacji o tym, że wyczerpuje się miejsce w pamięci
trwałej urządzenia. Dzięki temu aplikacja może zatrzymać synchronizowanie wiadomości e-mail
(co mogłoby zużywać więcej pamięci). Kiedy w pamięci trwałej urządzenia zaczyna się wyczerpy-
wać miejsce, system wysyła komunikat rozgłoszeniowy (ang. broadcast) z kodem zawierającym
informację o niskim stanie miejsca w pamięci trwałej. Ten kod ma dotrzeć do wszystkich odbior-
ców, którzy są zainteresowani zdarzeniem.
Na rysunku 10.39 pokazano, w jaki sposób menedżer aktywności przetwarza taki komunikat
rozgłoszeniowy w w celu dostarczenia go do zainteresowanych odbiorców. Najpierw prosi mene-
dżera pakietów o listę wszystkich odbiorców zainteresowanych zdarzeniem. Lista ta jest umiesz-
czana w obiekcie BroadcastRecord reprezentującym ten komunikat. Menedżer aktywności przy-
stępuje następnie do przetwarzania poszczególnych pozycji na liście. Każdy przypisany proces
aplikacji tworzy i uruchamia odpowiednią klasę odbiorcy.
Odbiorcy są uruchamiani wyłącznie jako jednorazowe operacje. Gdy zachodzi zdarzenie, system
wyszukuje zainteresowanych nim odbiorców i dostarcza do nich zdarzenie, które następnie jest
przez nich „konsumowane”. Nie istnieje obiekt ReceiverRecord podobny do tego, z którym
zetknęliśmy się w przypadku innych komponentów aplikacji, ponieważ określony odbiorca jest
tylko obiektem tymczasowym — istniejącym tylko na czas obsługi pojedynczego komunikatu
rozgłoszeniowego. Za każdym razem, gdy do komponentu odbiorcy zostanie wysłany komunikat
rozgłoszeniowy, tworzony jest nowy egzemplarz klasy odbiorcy.
Dostawcy zawartości
Ostatni komponent aplikacji — dostawca zawartości (ang. content provider) — jest podstawowym
mechanizmem wykorzystywanym przez aplikacje do wymiany danych pomiędzy sobą. Wszystkie
interakcje z dostawcą zawartości odbywają się za pośrednictwem identyfikatorów URI zawie-
rających oznaczenie zawartość: schemat; składnik authority identyfikatora URI jest używany do
znalezienia właściwej implementacji dostawcy zawartości, z którym mają się odbywać interakcje.
Przykładowo w aplikacji pocztowej z listingu 10.5 dostawca zawartości określa, że składnik
authority identyfikatora URI to com.example.email.provider.email. Zgodnie z tym adresy URI
operujące na tym dostawcy zawartości będą zaczynały się od:
content://com.example.email.provider.email/
Przyrostek tego identyfikatora URI jest interpretowany przez samego dostawcę w celu okre-
ślenia danych, które on dostarcza. W zaprezentowanym przykładzie przyjęto konwencję, zgodnie
z którą identyfikator URI:
content://com.example.email.provider.email/messages
Choć obsługa dostawców zawartości z punktu widzenia aplikacji nie przypomina wiązania
usług, ma wiele podobieństw do tego mechanizmu. Sposób, w jaki system obsługuje zaprezen-
towany przykład kwerendy, pokazano na rysunku 10.40:
1. Aplikacja wywołuje metodę ContentResolver.query w celu zainicjowania operacji.
2. Przedrostek authority identyfikatora URI jest przekazywany do menedżera aktywności
w celu odszukania (za pośrednictwem menedżera pakietów) odpowiedniego dostawcy
zawartości.
3. Jeśli dostawca zawartości nie jest jeszcze uruchomiony, to zostanie stworzony.
4. Po utworzeniu dostawca zawartości zwraca do menedżera aktywności jego obiekt IBinder
implementujący systemowy interfejs IContentProvider.
5. Obiekt Binder dostawcy zawartości jest zwracany do obiektu ContentResolver.
6. Obiekt ContentResolver może teraz wykonać wstępną operację query poprzez wywołanie
odpowiedniej metody interfejsu AIDL zwracającej wynik typu Cursor.
10.8.9. Zamiary
Dotychczas nie omówiliśmy jednego szczegółu występującego w manifeście aplikacji z listingu 10.5.
Chodzi o znaczniki <intent-filter> uwzględnione w deklaracjach aktywności i odbiorców.
W systemie Android są one częścią tzw. zamiarów (ang. intents). Tworzą one bazę mechanizmu
wzajemnej identyfikacji aplikacji, który zapewnia możliwość ich interakcji i współdziałania ze sobą.
Zamiar jest mechanizmem, którego Android używa do odkrywania i identyfikowania działań,
odbiorców i usług. Pod pewnymi względami przypomina ścieżkę wyszukiwania powłoki Linux,
która służy do przeszukiwania wielu możliwych katalogów w celu znalezienia pliku wykony-
walnego pasującego do przekazanej nazwy polecenia.
Można wyróżnić dwa główne typy zamiarów: jawne i niejawne. Zamiar jawny (ang. explicit
intent) to taki, który bezpośrednio identyfikuje konkretny składnik aplikacji. Zgodnie z termi-
nologią powłoki systemu Linux jest równoważny przekazaniu do polecenia bezwzględnej ścieżki
polecenia. Najważniejszą częścią takiego zamiaru jest para łańcuchów znaków tworząca nazwę
komponentu: nazwa pakietu aplikacji docelowej oraz nazwa klasy komponentu wewnątrz tej
aplikacji. Nawiązując do aktywności z rysunku 10.32 w aplikacji z listingu 10.5: wyraźnym zamia-
rem w przypadku tego składnika będzie pakiet o nazwie com.example.email i klasa o nazwie
com.example.email.MailMainActivity.
Nazwa pakietu i nazwa klasy wyraźnego zamiaru to wystarczające informacje do tego, aby
jednoznacznie zidentyfikować docelowy składnik, taki jak główna aktywność pocztowa z rysunku
10.32. Na podstawie nazwy pakietu menedżer pakietów może zwrócić wszystkie potrzebne
informacje dotyczące aplikacji — np. gdzie szukać jej kodu. Na podstawie nazwy klasy można
ustalić, jaka część tego kodu ma być uruchomiona.
Zamiar niejawny (ang. implicit intent) to taki, który opisuje właściwości pożądanego skład-
nika, ale nie bezpośrednio. W kategoriach powłoki systemu Linux jest to równoważne przeka-
zaniu do powłoki nazwy samego polecenia (bez ścieżki). Powłoka wykorzystuje je do przeszu-
kiwania ścieżki wyszukiwania po to, aby znaleźć konkretne polecenie do uruchomienia. Proces
znajdowania pasującego składnika zamiaru niejawnego jest nazywany rozpoznawaniem zamiaru
(ang. intent resolution).
Dobrym przykładem zamiaru niejawnego jest ogólny mechanizm udostępniania Androida,
który wcześniej prezentowaliśmy na rysunku 10.35 przy okazji omawiania mechanizmu udo-
stępniania za pośrednictwem aplikacji pocztowej zdjęcia, zrobionego aparatem fotograficznym
przez użytkownika. W tym przykładzie aplikacja aparatu fotograficznego tworzy zamiar opisu-
jący działanie do wykonania, a system wyszukuje wszystkie aktywności, które potencjalnie
pozwalają na wykonanie tego działania. Żądanie udostępniania jest realizowane za pomocą akcji
zamiaru android.intent.action.SEND. Jak widzimy na listingu 10.5, aktywność ComposeActivity
aplikacji pocztowej deklaruje, że może wykonać tę akcję.
Mogą być trzy wyniki rozpoznawania zamiaru: (1) nie znaleziono pasującego elementu, (2)
znaleziono pojedyncze, unikatowe dopasowanie oraz (3) istnieje wiele aktywności zdolnych do
10.8.11. Bezpieczeństwo
Zabezpieczenia aplikacji w systemie Android bazują na identyfikatorach UID. W Linuksie każdy
proces działa z konkretną tożsamością UID, a system Android wykorzystuje identyfikatory UID
do identyfikacji i ochrony barier bezpieczeństwa. Jedynym sposobem interakcji z procesami jest
wykorzystanie jakiejś formy komunikacji IPC, która zwykle obejmuje wystarczająco dużo infor-
macji do tego, aby określić identyfikator UID procesu wywołującego. Mechanizm Binder IPC
jawnie uwzględnia tę informację w każdej transakcji dostarczanej pomiędzy procesami, zatem
odbiorca komunikacji IPC może łatwo zażądać identyfikatora UID procesu wywołującego.
W systemie Android istnieje szereg standardowych, predefiniowanych numerów UID dla
bardziej niskopoziomowych części systemu, ale dla większości aplikacji identyfikatory UID są
przypisywane dynamicznie, przy pierwszym uruchomieniu komputera lub podczas instalacji.
Pochodzą one ze zbioru dostępnych „identyfikatorów UID aplikacji”. W tabeli 10.16 zestawiono
wybrane popularne odwzorowania wartości identyfikatorów UID na ich znaczenie. UID poniżej
10000 to ustalone przyporządkowania wewnątrz systemu dla dedykowanego sprzętu lub innych
spoecyficznych części implementacji — w tabeli zestawiono niektóre typowe wartości z tego
zakresu. W zakresie 10000 – 19999 są UID, które menedżer pakietów dynamicznie przypisuje
aplikacjom podczas ich instalowania. Oznacza to, że w systemie można zainstalować co najwyżej
10 000 aplikacji. Należy również zwrócić uwagę na zakres zaczynający się od 100 000, który jest
używany do zaimplementowania w systemie Android tradycyjnego modelu wielu użytkowników:
aplikacja, której przypisano UID 10002, w przypadku gdy będzie działać w imieniu drugiego użyt-
kownika, będzie identyfikowana jako 110002.
Gdy identyfikator UID zostanie przyporządkowany do aplikacji po raz pierwszy, najpierw jest
dla niej tworzony nowy katalog w pamięci trwałej. Jego właścicielem jest użytkownik o przypi-
sanym UID. Aplikacja otrzymuje swobodny dostęp do swoich prywatnych plików w tym katalogu,
ale nie może uzyskać dostępu do plików innych aplikacji. Inne aplikacje również nie mogą uzyskać
dostępu do tego katalogu. To sprawia, że dostawcy zawartości, zgodnie z tym, co napisano
w poprzednim punkcie poświęconym aplikacjom, stają się szczególnie ważni, ponieważ są jed-
nym z niewielu mechanizmów przesyłania danych pomiędzy różnymi aplikacjami.
Nawet sam system, który działa z UID o wartości 1000, nie ma prawa dostępu do plików nale-
żących do aplikacji. Do tego celu służy demon installd: działa ze specjalnymi uprawnieniami tak,
aby był w stanie uzyskać dostęp i tworzyć pliki i katalogi dla innych aplikacji. Demon installd
dostarcza menedżerowi pakietów bardzo ograniczony interfejs API, który pozwala na tworzenie
i zarządzanie katalogami danych aplikacji w miarę potrzeb.
W swoim bazowym stanie piaskownice aplikacji Androida muszą zakazać wszelkich interakcji
pomiędzy aplikacjami, które mogą naruszyć zasady bezpieczeństwa. Jednym z celów takiego
zachowania może być niezawodność (niedopuszczenie, aby jedna aplikacja doprowadziła do awarii
innej aplikacji), ale najczęściej chodzi o dostęp do informacji.
Rozważmy naszą aplikację aparatu fotograficznego. Gdy użytkownik robi zdjęcie, aplikacja
aparatu zapisuje je w swojej przestrzeni prywatnych danych. Inne aplikacje nie mogą uzyskać
dostępu do tych danych, co jest oczekiwanym zachowaniem, ponieważ zdjęcia mogą być dla
użytkownika wrażliwymi danymi.
Kiedy użytkownik zrobi zdjęcie, może zdecydować o wysłaniu go e-mailem do znajomego.
Program pocztowy jest odrębną aplikacją, działającą w swojej własnej piaskownicy, i nie ma
dostępu do zdjęć aplikacji aparatu fotograficznego. W jaki sposób aplikacja pocztowa może uzy-
skać dostęp do zdjęć w piaskownicy aplikacji aparatu?
Najbardziej znaną formą kontroli dostępu w systemie Android są uprawnienia aplikacji.
Uprawnienia są konkretnymi, dobrze zdefiniowanymi zdolnościami aplikacji, które mogą być
przydzielane aplikacjom podczas instalacji. Lista uprawnień potrzebnych aplikacji znajduje się
w jej manifeście. Przed zainstalowaniem aplikacji użytkownik otrzymuje informację o tym, co
mu będzie wolno w związku z tymi uprawnieniami.
Sposób wykorzystania uprawnień przez aplikację pocztową w celu uzyskania dostępu do
zdjęć z aparatu pokazano na rysunku 10.41. W tym przypadku aplikacji aparatu fotograficznego
przypisano uprawnienie READ_PICTURES do jej zdjęć. Oznacza ono, że każda aplikacja, która posiada
to uprawnienie, będzie mogła uzyskać dostęp do danych zdjęć. W manifeście aplikacji pocztowej
zadeklarowano, że aplikacja będzie potrzebowała tego uprawnienia. Aplikacja pocztowa może
teraz uzyskać dostęp do identyfikatora URI będącego własnością aparatu, np. content://pics/1.
Po otrzymaniu żądania tego URI dostawca zawartości aplikacji aparatu zapyta menedżera pakie-
tów, czy proces wywołujący posiada niezbędne uprawnienia. Jeśli tak, wywołanie zakończy się
pomyślnie i do aplikacji będą zwrócone odpowiednie dane.
Uprawnienia nie są związane z dostawcami zawartości. Każda komunikacja IPC skierowana
do systemu może być chroniona przez uprawnienia za pośrednictwem zapytania do menedżera
pakietów o to, czy obiekt wywołujący posiada wymagane uprawnienia. Przypomnijmy, że piaskow-
nice aplikacji bazują na procesach i identyfikatorach UID, więc bariera zabezpieczeń zawsze
występuje na graniczy procesów, a same uprawnienia są związane z identyfikatorami UID.
Biorąc to pod uwagę, sprawdzenie zabezpieczeń może być wykonane poprzez pobranie identyfi-
katora UID związanego z przychodzącą komunikacją IPC i skierowaniem zapytania do menedżera
pakietów o to, czy wskazanemu identyfikatorowi UID udzielono odpowiednich uprawnień. Przykła-
dowo uprawnienia dostępu do lokalizacji użytkownika są egzekwowane przez usługę menedżera
lokalizacji systemu w czasie, gdy aplikacje kierują do niego wywołania.
Na rysunku 10.42 zilustrowano to, co się dzieje, gdy aplikacja nie posiada uprawnień niezbęd-
nych do operacji, którą chce wykonać. W tym przykładzie aplikacja przeglądarki próbuje uzyskać
bezpośredni dostęp do zdjęć użytkownika, ale jedynym uprawnieniem, jakim dysponuje, jest
wykonywanie operacji sieciowych przez internet. W tym przypadku menedżer pakietów infor-
muje dostawcę PicturesProvider, że proces wywołujący nie posiada niezbędnych uprawnień
READ_PICTURES i w efekcie zgłasza w odpowiedzi wyjątek SecurityException.
INTERNET. Czy jednak jest sens, aby aplikacja pocztowa posiadała uprawnienie READ_PICTURES?
W aplikacji pocztowej nie istnieją funkcje, które byłyby bezpośrednio związane z czytaniem
zdjęć. Nie ma też powodu, aby aplikacja pocztowa miała dostęp do wszystkich zdjęć.
Z takim wykorzystaniem uprawnień jest też inny problem, który można zaobserwować na
rysunku 10.35. Przypomnijmy sobie, jak można uruchomić aktywność ComposeActivity aplikacji
pocztowej w celu udostępnienia zdjęcia z aparatu. Aplikacja pocztowa otrzymuje identyfikator
URI danych do udostępnienia, ale nie wie, skąd on pochodzi — w sytuacji przedstawionej na
rysunku pochodzi z aparatu, ale dowolna aplikacja mogłaby skorzystać z podobnego mechanizmu
w celu przesłania pocztą swoich danych — mogłyby to być pliki audio lub dokumenty edytora
tekstów. Aplikacja pocztowa musi tylko odczytać otrzymany identyfikator URI jako strumień
bajtów, aby dodać go jako załącznik. Jednak w przypadku korzystania z mechanizmu uprawnień
musiałaby również z góry określić uprawnienia dla wszystkich danych wszystkich aplikacji,
które mogłyby zażądać od niej wysłania pocztą swoich danych.
Mamy tu dwa problemy do rozwiązania. Po pierwsze nie chcemy dać aplikacji dostępu do
szerokiego wyboru danych, których ta aplikacja rzeczywiście nie potrzebuje. Po drugie trzeba
by udzielić dostępu wszystkim źródłom danych, nawet tych, o których z góry nie posiadamy
wiedzy.
Należy zaobserwować istotną cechę: akt wysłania zdjęcia e-mailem jest w istocie interakcją
z użytkownikiem, w której to użytkownik wyraził czytelny zamiar wykorzystania konkretnego
zdjęcia w konkretnej aplikacji. Tak długo, jak w interakcję jest zaangażowany system opera-
cyjny, może on wykorzystać te fakty w celu określenia specyficznego tunelu w piaskownicach
tych dwóch aplikacji, tak aby możliwe było przesłanie danych pomiędzy nimi.
System Android obsługuje tego rodzaju niejawny, bezpieczny ruch danych za pośrednictwem
mechanizmów zamiarów i dostawców zawartości. Sposób działania tej sytuacji dla przykładu
wysyłania zdjęcia pocztą elektroniczną przedstawiono na rysunku 10.43. Aplikacja aparatu foto-
graficznego zamieszczona w lewym, dolnym narożniku stworzyła zamiar zawierający żądanie
współdzielenia jednego ze zdjęć: content://pics/1. Oprócz uruchomienia aplikacji kompozycji
wiadomości e-mail, którą pokazaliśmy wcześniej, powoduje to również dodanie wpisu do listy
„URI z przyznanym dostępem” oraz informacją, że nowa aktywność ComposeActivity ma dostęp
do tego identyfikatora URI. Teraz, gdy aktywność ComposeActivity spróbuje otworzyć i prze-
czytać dane z przekazanego do niej adresu URI, dostawca PicturesProvider aplikacji aparatu
fotograficznego będący właścicielem danych identyfikowanych przez URI może zapytać mene-
dżera aktywności, czy wywołująca aplikacja pocztowa ma dostęp do danych. W tym przypadku
ma taki dostęp, dlatego jest zwracane zdjęcie.
Ta drobiazgowa kontrola dostępu na bazie identyfikatora URI może także zadziałać w drugą
stronę. Istnieje inna akcja zamiaru android.intent.action.GET_CONTENT, z której aplikacja może
skorzystać w celu zażądania od użytkownika pobrania danych. Z tego mechanizmu można skorzy-
stać w naszej aplikacji pocztowej np. do wykonania odwrotnego działania: użytkownik pracujący
w aplikacji pocztowej może zażądać dodania załącznika. To spowoduje uruchomienie w aplikacji
aparatu aktywności, która pozwala na wybór tego załącznika.
Ten nowy przepływ sterowania zilustrowano na rysunku 10.44. Jest to sytuacja prawie iden-
tyczna z tą, którą przedstawiono na rysunku 10.43. Jedyną różnicą jest sposób komponowania
aktywności dwóch aplikacji. To aplikacja pocztowa inicjuje odpowiednią aktywność wyboru zdjęcia
w aplikacji aparatu fotograficznego. Kiedy użytkownik wybierze zdjęcie, jego identyfikator URI
jest zwracany do aplikacji pocztowej. W tym momencie menedżer aktywności rejestruje przy-
znanie właściwego uprawnienia temu identyfikatorowi URI.
Takie podejście daje bardzo duże możliwości, ponieważ pozwala systemowi utrzymywać ścisłą
kontrolę nad danymi aplikacji, udzielać w razie potrzeby dostępu do określonych danych, a użyt-
kownik nie musi być nawet świadomy tego, co się dzieje „za kulisami”. Opisywany mechanizm
może być użyteczny w wielu innych interakcjach z użytkownikiem. Jedną z oczywistych możliwości
jest operacja „przeciągnij i upuść”, która może być używana do przyznawania identyfikatorom
URI podobnych uprawnień. Android wykorzystuje jednak także inne informacje, np. fokus bie-
żącego okna w celu określenia rodzaju interakcji dozwolonych dla aplikacji.
Uruchamianie procesów
W celu uruchomienia nowych procesów menedżer aktywności musi skomunikować się z pro-
cesem zygote. Kiedy menedżer aktywności uruchamia się po raz pierwszy, tworzy dedykowane
gniazdo z procesem zygote. Za pomocą tego gniazda wysyła polecenie, gdy potrzebuje rozpocząć
proces. Polecenie przede wszystkim opisuje piaskownicę, która ma zostać utworzona: UID, pod
jakim nowy proces powinien działać, oraz inne ograniczenia zabezpieczeń, które będą miały
zastosowanie do procesu. Z tego powodu proces zygote musi działać jako root: kiedy się rozwidla,
przeprowadza odpowiednią konfigurację dla użytkownika z identyfikatorem UID, z jakim będzie
działać. Następnie odrzuca uprawnienia użytkownika root i zmienia UID procesu na docelowe.
Przypomnijmy, że we wcześniejszym opisie dotyczącym aplikacji Androida mówiliśmy o tym,
że menedżer aktywności utrzymuje dynamiczne dane dotyczące uruchomionych aktywności
(rysunek 10.32), usług (rysunek 10.37), komunikatów rozgłoszeniowych (do odbiorców — tak
jak na rysunku 10.39) i dostawców zawartości (rysunek 10.40). Menedżer aktywności wykorzy-
stuje te informacje do stworzenia procesów aplikacji i zarządzania nimi. Kiedy np. program rozru-
chowy aplikacji generuje wywołanie systemowe z nowym zamiarem uruchomienia aktywności,
co widzieliśmy na rysunku 10.32, to menedżer aktywności jest odpowiedzialny za uruchomienie
tej nowej aplikacji.
Przepływ sterowania dla sytuacji uruchomienia aktywności w nowym procesie pokazano na
rysunku 10.45. Oto szczegóły dotyczące każdego kroku przedstawionego na ilustracji:
1. Jakiś istniejący proces (np. program rozruchowy aplikacji) wywołuje menedżera aktyw-
ności z zamiarem opisującym nową aktywność, którą chciałby uruchomić.
2. Menedżer aktywności żąda od menedżera pakietów dokonania konwersji zamiaru na jawny
komponent do uruchomienia.
3. Menedżer aktywności stwierdza, że proces aplikacji nie jest jeszcze uruchomiony i żąda
od procesu zygote nowego procesu o właściwym identyfikatorze UID.
4. Proces zygote realizuje wywołanie fork tworzące nowy proces, który jest klonem siebie
samego, porzuca uprawnienia i odpowiednio ustawia jego identyfikator UID dla piaskow-
nicy aplikacji. Następnie finalizuje inicjowanie mechanizmu Dalvik w tym procesie, aby
środowisko wykonawcze Javy było w pełni uruchomione; np. po rozwidleniu musi uru-
chomić takie wątki jak mechanizm odśmiecania (ang. garbage collector).
5. Nowy proces, który teraz jest klonem procesu zygote z w pełni skonfigurowanym i działa-
jącym środowiskiem Javy, wywołuje menedżera aktywności i pyta: „Co ja powinienem
robić?”.
6. Menedżer aktywności zwraca pełne informacje na temat uruchamianej aplikacji — np.
gdzie można znaleźć jej kod.
7. Nowy proces ładuje kod do uruchamianej aplikacji.
8. Menedżer aktywności wysyła do nowego procesu wszystkie oczekujące operacje do
uruchomienia. W tym przypadku jest to polecenie „uruchom aktywność X”.
9. Nowy proces otrzymuje polecenie do uruchomienia aktywności, tworzy egzemplarz
odpowiedniej klasy Javy i uruchamia go.
Zwróćmy uwagę, że w chwili rozpoczęcia tych działań proces aplikacji mógł już działać. W takim
przypadku menedżer aktywności po prostu przejdzie do końca procedury — wyśle nowe pole-
cenie do procesu z żądaniem utworzenia egzemplarza i uruchomienia odpowiedniego składnika.
w rekordach menedżera aktywności, zatem aplikacje nie mają możliwości zwracania na ich temat
fałszywych informacji). Przetwarzanie wykresu zależności dla procesu wymaga przeglądania wszyst-
kich dostawców zawartości i usług oraz procesów, które z nich korzystają.
W tabeli 10.18 pokazano typowy stan, w jakim mogą znajdować się procesy, uwzględniając
zależności pomiędzy nimi. W przykładzie wykorzystania dostawcy zawartości aparatu podczas
dodawania zdjęcia w formie załącznika do wiadomości pocztowej, jak pokazano na rysunku 10.44,
występują dwie zależności. Najpierw jest działająca na pierwszym planie bieżąca aplikacja pocztowa,
która korzysta z aplikacji aparatu fotograficznego do załadowania załącznika. To podnosi istot-
ność procesu aparatu do tej samej wartości, jaką ma aplikacja pocztowa. Druga sytuacja jest
podobna. Aplikacja muzyczna odtwarza muzykę w tle, korzystając z usługi. W związku z tym
posiada zależność od procesu obsługi multimediów podczas dostępu do muzyki użytkownika.
Rozważmy, co się stanie, jeśli stan z tabeli 10.18 zmieni się tak, że aplikacja pocztowa zakończy
ładowanie załącznika i przestanie używać dostawcy zawartości aparatu fotograficznego. W tabeli
10.19 pokazano, w jaki sposób zmieni się stan procesu. Zwróćmy uwagę, że aplikacja aparatu
fotograficznego nie jest już potrzebna, dlatego jej istotność zmieniła się z poziomu aplikacji
pierwszego planu do aplikacji w pamięci podręcznej. Przesunięcie aplikacji aparatu do pamięci pod-
ręcznej spowodowało również obniżenie o jeden poziom na liście LRU (od ang. Least Recently
Used — dosł. używane najdawniej) aplikacji w pamięci podręcznej starej aplikacji obsługi mapy.
Tabela 10.19. Stan procesów po zakończeniu korzystania z aparatu przez aplikację pocztową
Proces Stan Istotność
system Podstawowa część systemu operacyjnego SYSTEM
telefon Zawsze działający w celu obsługi stosu telefonii PERSISTENT
e-mail Bieżąca aplikacja pierwszego planu FOREGROUND
odtwarzacz muzyki Uruchomiona usługa w tle odtwarzacza muzyki PERCEPTIBLE
multimedia Wykorzystywana przez aplikację muzyczną w celu uzyskania dostępu PERCEPTIBLE
do plików muzycznych użytkownika
pobieranie plików Pobieranie plików dla użytkownika SERVICE
launcher Aplikacja launchera, która nie jest aktualnie w użyciu HOME
aparat fotograficzny Wcześniej używany przez aplikację pocztową CACHED
mapy Wcześniej wykorzystywana aplikacja obsługi map CACHED+1
10.9. PODSUMOWANIE
10.9.
PODSUMOWANIE
System Linux od początku jest pełnowartościowym klonem systemu UNIX typu open source
i jest obecnie wykorzystywany na rozmaitych komputerach, od notebooków po superkomputery.
Można wyróżnić trzy główne interfejsy tego systemu: powłokę, bibliotekę C oraz same wywołania
systemowe. Wielu użytkowników dodatkowo decyduje się na stosowanie graficznego interfejsu
użytkownika, który ułatwia interakcję z systemem. Za pośrednictwem powłoki użytkownicy mogą
wpisywać uruchamiane polecenia. Mogą to być proste polecenia, potoki lub bardziej skompli-
kowane struktury. Dane wejściowe i wyjściowe można łatwo przekierowywać. Biblioteka C
zawiera nie tylko wywołania systemowe, ale też wiele wywołań rozszerzonych, np. wywołanie
printf umożliwiające zapisywanie w plikach sformatowanych danych wynikowych. Sam interfejs
wywołań systemowych jest zależny od architektury — w przypadku platform x86 składa się
z blisko 250 wywołań, z których każde realizuje precyzyjnie zaplanowane zadania.
Do najważniejszych elementów systemu Linux należą procesy, model pamięci, model wej-
ścia-wyjścia oraz system plików. Procesy mogą być rozwidlane i tworzyć w ten sposób podpro-
cesy, które mogą tworzyć całe drzewa procesów. Zarządzanie procesami w systemie Linux
przebiega nieco inaczej niż w innych systemach UNIX, ponieważ Linux traktuje każdą jed-
nostkę wykonywania (proces jednowątkowy, każdy wątek procesu wielowątkowego lub jądro)
jako odrębne zadanie. Proces (lub — bardziej ogólnie — pojedyncze zadanie) jest reprezento-
wany przez dwa ważne komponenty: strukturę zadania oraz dodatkowe informacje opisujące
przestrzeń adresową użytkownika. Pierwszy komponent zawsze jest składowany w pamięci;
dodatkowe dane mogą być stronicowane i umieszczane w obszarze wymiany. Tworzenie procesu
polega na powieleniu struktury zadania i takim ustawieniu informacji o obrazie pamięci, aby
wskazywały na obraz pamięci procesu macierzystego. Właściwa procedura kopiowania stron
obrazu pamięci jest przeprowadzana dopiero w momencie, w którym okazuje się, że dalsze
współdzielenie jednej kopii jest niemożliwe (wskutek żądanych modyfikacji pamięci). Opisany
mechanizm określa się mianem kopiowania przy zapisie. Do szeregowania zadań system Linux
wykorzystuje algorytm uwzględniający priorytety i promujący procesy interaktywne.
Model pamięci składa się z trzech segmentów dla każdego procesu: tekstu, danych i stosu
Zarządzanie pamięcią jest realizowane poprzez stronicowanie. Do śledzenia stanu poszczególnych
stron służy specjalna mapa składowana w pamięci, a do utrzymywania odpowiedniej liczby wol-
nych stron demon stron wykorzystuje zmodyfikowany algorytm zegarowy.
Dostęp do urządzeń wejścia-wyjścia odbywa się za pośrednictwem plików specjalnych, z któ-
rych każdy ma przypisany główny numer urządzenia i pomocniczy numer urządzenia. Blokowe
urządzenia wejścia-wyjścia wykorzystują pamięć główną do buforowania bloków dyskowych
i ograniczania liczby niezbędnych operacji dyskowych. Znakowe urządzenia wejścia-wyjścia mogą
pracować w trybie surowym lub operować na strumieniach znakowych modyfikowanych za pośred-
nictwem specjalnego protokołu. Urządzenia sieciowe są traktowane nieco inaczej — wymagają
kojarzenia całych modułów protokołów sieciowych przetwarzających strumień pakietów sie-
ciowych przekazywanych do i z procesu użytkownika.
System plików ma charakter hierarchiczny i składa się z plików oraz katalogów. Wszystkie
dyski są montowane w ramach jednego drzewa katalogów, począwszy od unikatowego katalogu
głównego. Pojedyncze pliki mogą być przedmiotem dowiązań z poziomu innych katalogów syste-
mu plików. Użycie pliku wymaga jego uprzedniego otwarcia — w jego wyniku proces otrzymuje
deskryptor pliku niezbędny w dalszych operacjach odczytu i zapisu. System plików wewnętrznie
wykorzystuje trzy główne tablice: tablicę deskryptorów plików, tablicę opisów otwartych plików
oraz tablicę i-węzłów. Ta ostatnia tablica jest najważniejsza, ponieważ obejmuje wszystkie infor-
macje administracyjne o plikach oraz położenie ich bloków. Także katalogi i urządzenia są repre-
zentowane w formie plików (obok pozostałych plików specjalnych).
Ochrona zasobów polega na kontroli dostępu do odczytu, zapisu i wykonywania właściciela
pliku, grupy, do której należy ten właściciel, oraz pozostałych użytkowników. W przypadku kata-
logów bit wykonywania jest traktowany jako uprawnienie do przeszukiwania.
Android jest platformą, która umożliwia uruchamianie aplikacji na urządzeniach mobilnych.
Bazuje na jądrze Linuksa, ale obejmuje szereg specjalistycznych programów plus kilka nie-
wielkich zmian w jądrze Linuksa. W większości system Android jest napisany w Javie. Aplikacje
są również napisane w Javie i tłumaczone do postaci kodu bajtowego Javy, a następnie do kodu
bajtowego środowiska Dalvik. Aplikacje Androida komunikują się ze sobą za pomocą chronionego
mechanizmu przekazywania komunikatów — zwanych transakcjami. Komunikacja między proce-
sami jest realizaowana za pomocą specjalnego modułu jądra Linuksa o nazwie Binder.
Pakiety Androida są samodzielne i obejmują manifest opisujący zawartość pakietu. Pakiety
zawierają aktywności, odbiorców, dostawców zawartości i zamiary. Model zabezpieczeń Androida
różni się od modelu Linuksa. Każda aplikacja działa w wydzielonej piaskownicy, ponieważ wszyst-
kie aplikacje są traktowane jako niezaufane.
PYTANIA
1. Wyjaśnij, dlaczego napisanie systemu UNIX w języku C przyczyniło się do ułatwienia prze-
noszenia go na nowe maszyny.
2. Interfejs POSIX definiuje zbiór procedur bibliotecznych. Wyjaśnij, dlaczego POSIX standa-
ryzuje procedury biblioteczne zamiast interfejsu wywołań systemowych.
3. Możliwość przenoszenia Linuksa na nowe platformy zależy od dostępności kompilatora gcc
na tych platformach. Opisz jedną zaletę i jedną wadę tej zależności.
4. Pewien katalog zawiera następujące pliki:
aardvark ferret koala porpoise unicorn
bonefish grunion llama quacker vicuna
capybara hyena marmot rabbit weasel
dingo ibex nuthatch seahorse yak
emu jellyfish ostrich tuna zebu
Które z tych plików zostaną wyświetlone przez polecenie w postaci:
ls [abc]*e*?
5. Co zrobi następujący potok powłoki systemu Linux?
grep nd xyz | wc –l
6. Napisz potok Linuksa wyświetlający na standardowym wyjściu osiem wierszy pliku z.
7. Dlaczego system Linux rozróżnia standardowe wyjście i standardowy błąd, mimo że
domyślnie dane z obu grup trafiają na terminal?
20. W każdym wpisie reprezentującym proces w strukturze zadania składuje się identyfi-
kator PID procesu macierzystego. Dlaczego?
21. Mechanizm kopiowania przy zapisie jest używany w roli sposobu optymalizacji wywołania
systemowego fork, dzięki czemu kopia strony jest tworzona tylko wtedy, gdy jeden
z procesów (rodzic lub potomek) próbuje pisać na stronie. Przypuśćmy, że w wyniku
rozwidlenia procesu p1 tworzony jest proces p2, a niedługo potem proces p3. Wyjaśnij,
jak w tym przypadku powinno być obsłużone współdzielenie stron.
22. Jaka kombinacja bitów sharing_flags wykorzystywanych przez polecenie clone Linuksa
odpowiada tradycyjnemu wywołaniu systemowemu fork systemu UNIX? Za pomocą jakiej
kombinacji można by utworzyć konwencjonalny wątek Uniksa?
23. Dwa zadania, A i B, muszą wykonać tyle samo pracy. Jednak zadanie A ma wyższy priorytet
i należy mu przydzielić więcej czasu procesora. Wyjaśnij, jak to osiągnąć dla każdego
z programów szeregujących opisanych w tym rozdziale — O(1) i CFS.
24. Niektóre systemy UNIX określa się jako beztaktowe (ang. tickless), co oznacza, że nie
mają okresowych przerwań zegarowych. Dlaczego zastosowano takie rozwiązanie? Czy
beztaktowość ma sens w komputerze (np. w systemie wbudowanym), na którym działa
tylko jeden proces?
25. Podczas uruchamiania systemu Linux (i większości innych systemów operacyjnych) pro-
gram ładujący umieszczony w zerowym sektorze dysku zaczyna działanie od załadowania
programu uruchomieniowego, który z kolei ładuje system operacyjny. Dlaczego ten dodat-
kowy krok jest konieczny? Nie ma wątpliwości, że prostszym rozwiązaniem byłoby bezpo-
średnie ładowanie systemu operacyjnego przez program ładujący w zerowym sektorze
dysku.
26. Pewien edytor obejmuje 100 kB tekstu programu, 30 kB zainicjalizowanych danych oraz
50 kB bloku BSS. Stos początkowo zajmuje 10 kB. Przypuśćmy, że jednocześnie są urucha-
miane trzy kopie tego edytora. Ile pamięci fizycznej będzie potrzebne, jeśli (a) zastosujemy
model z tekstem współdzielonym oraz (b) tekst programu nie będzie współdzielony?
27. Dlaczego system Linux potrzebuje tablic deskryptorów otwartych plików?
28. W systemie Linux segmenty danych i stosu są stronicowane i wymieniane z podręczną
kopią składowaną na specjalnym dysku lub partycji stronicowania. Okazuje się jednak, że
segment tekstu wykorzystuje w roli obszaru wymiany odpowiedni plik binarny. Dlaczego?
29. Opisz możliwy sposób wykorzystania wywołania mmap i sygnałów do skonstruowania
mechanizmu komunikacji międzyprocesowej.
30. Pewien plik jest odwzorowywany w pamięci za pomocą wywołania systemowego mmap
w następującej formie:
mmap(65536, 32768, READ, FLAGS, fd, 0)
Strony mają rozmiar 8 kB. Który bajt tego pliku zostanie użyty, jeśli spróbujemy odczytać
bajt spod adresu pamięciowego 72 000?
31. Przyjmijmy, że po wykonaniu wywołania systemowego z poprzedniego pytania zachodzi
następujące wywołanie:
munmap(65536, 8192)
Czy wywołanie w tej formie zostanie wykonane prawidłowo? Jeśli tak, które bajty tego
pliku pozostaną odwzorowane w pamięci? Jeśli nie, dlaczego wywołanie zakończy się
błędem?
32. Czy błąd braku strony może prowadzić do zakończenia wykonywania procesu, który ten
błąd spowodował? Jeśli tak, to podaj przykład. A jeśli nie, to dlaczego?
33. Czy system zarządzania pamięcią stosujący algorytm bliźniaków może doprowadzić do
sytuacji, w której dwa przylegające bloki wolnej pamięci tej samej wielkości współistnieją,
ale nie są scalane w ramach jednego bloku? Jeśli tak, wyjaśnij, jak to możliwe. Jeśli nie,
wykaż, że to niemożliwe.
34. W tym rozdziale stwierdzono, że partycja stronicowania cechuje się wyższą wydajnością
niż plik stronicowania. Dlaczego tak się dzieje?
35. Podaj dwa przykłady zalet względnych ścieżek do plików w porównaniu ze ścieżkami
bezwzględnymi.
36. Poniższe wywołania blokujące zostały wykonane przez pewien zbiór procesów. Spróbuj
określić znaczenie każdego z tych wywołań. Jeśli proces nie może zablokować pliku, jego
wykonywanie jest wstrzymywane.
(a) Proces A chce założyć blokadę współdzieloną na bajtach 0 – 10.
(b) Proces B chce założyć blokadę wyłączną na bajtach 20 – 30.
(c) Proces C chce założyć blokadę współdzieloną na bajtach 8 – 40.
(d) Proces A chce założyć blokadę współdzieloną na bajtach 25 – 35.
(e) Proces B chce założyć blokadę wyłączną na bajcie 8.
37. Wróćmy do zablokowanego pliku z rysunku 10.16(c). Przypuśćmy, że jakiś proces próbuje
zablokować bajty 10. i 11., co powoduje wstrzymanie jego wykonywania. Następnie, ale
jeszcze przed zwolnieniem blokady nałożonej przez proces C, inny proces próbuje zabloko-
wać bajty 10. i 11., co także powoduje wstrzymanie jego wykonywania. Do jakich proble-
mów prowadzi opisana sytuacja? Zaproponuj i uzasadnij dwa rozwiązania.
38. Wyjaśnij, w jakich okolicznościach proces może zażądać blokady współdzielonej lub blo-
kady wyłącznej. Jaki problem może wystąpić, jeśli proces zażąda blokady wyłącznej?
39. Jeśli plikowi systemu Linux przypisano tryb ochrony 755 (ósemkowo), co właściciel, grupa
właściciela i pozostali użytkownicy mogą robić z tym plikiem?
40. Niektóre sterowniki urządzeń taśmowych operują na ponumerowanych blokach z moż-
liwością nadpisania określonego bloku bez konieczności modyfikowania bloków znajdują-
cych się przed nim i za nim. Czy takie urządzenie może zawierać zamontowany system
plików Linuksa?
41. W scenariuszu pokazanym na rysunku 10.14 Filip i Lidia mają dostęp do pliku x z poziomu
własnych katalogów (dzięki utworzonemu dowiązaniu Czy wspomniany dostęp jest w pełni
symetryczny w tym sensie, że po obu stronach dowiązania (w obu katalogach) można
wykonywać dokładnie te same operacje?
42. Jak wiemy, ścieżki bezwzględne są przeszukiwane, począwszy od katalogu głównego,
a ścieżki względne przeszukuje się, począwszy od katalogu roboczego. Zaproponuj efek-
tywny sposób implementacji obu procedur przeszukiwania.
43. Przy okazji otwierania pliku /usr/ast/work/f należy wykonać kilka operacji dostępu do dysku,
aby odczytać i-węzeł i bloki katalogu. Wyznacz liczbę tych operacji przy założeniu, że
i-węzeł katalogu głównego zawsze jest składowany w pamięci, a wszystkie katalogi zajmują
po jednym bloku.
44. I-węzeł systemu Linux obejmuje 12 adresów dyskowych dla bloków danych, a także adresy
bloków jedno-, dwu- i trójpośrednich. Jeśli każdy z tych bloków zawiera 256 adresów
dyskowych, jaki jest maksymalny rozmiar pliku przy założeniu, że jeden blok dyskowy
zajmuje 1 kB?
45. I-węzeł odczytany z dysku w ramach procedury otwierania pliku jest umieszczany w tablicy
i-węzłów składowanej w pamięci głównej. Wspomniana tablica zawiera pewne pola, które
nie występują w dyskowej reprezentacji i-węzłów. Jednym z nich jest licznik umożliwia-
jący śledzenie liczby operacji otwierania poszczególnych i-węzłów. Dlaczego to pole jest
potrzebne?
46. Na platformach wieloprocesorowych system Linux utrzymuje po jednej strukturze run
queue dla każdego procesora. Czy to dobre rozwiązanie? Uzasadnij swoją odpowiedź.
47. Pojęcie ładowalnych modułów jest przydatne, ponieważ pozwala na załadowanie nowych
sterowników urządzeń podczas działania systemu. Podaj dwie wady tego mechanizmu.
48. Wątki demona pdflush mogą być okresowo budzone w celu zapisania na dysku najstarszych
stron — tj. starszych niż 30-sekundowe. Dlaczego takie rozwiązanie jest konieczne?
49. Po awarii i ponownym uruchomieniu systemu zwykle uruchamia się program przywra-
cający jego pierwotny stan. Przypuśćmy, że ten program odkrywa wartość 2 licznika
dowiązań wewnątrz dyskowego i-węzła, mimo że tylko jeden wpis katalogu zawiera odwo-
łania do tego i-węzła. Czy można ten problem jakoś rozwiązać? Jeśli tak, jak to zrobić?
50. Spróbuj wskazać najszybsze wywołanie systemowe Linuksa.
51. Czy można usunąć dowiązanie do pliku, dla którego nigdy nie utworzono dowiązań? Co się
wówczas stanie?
52. Na podstawie informacji zawartych w tym rozdziale spróbuj określić maksymalną ilość
danych składowanych na dysku w pliku użytkownika, jeśli system plików ext2 Linuksa
zostanie umieszczony na dyskietce o pojemności 1,44 MB. Przyjmij, że bloki dyskowe
zajmują po 1 kB.
53. Mając na uwadze wszystkie potencjalne szkody, które mogą spowodować studenci korzy-
stający z konta superużytkownika, odpowiedz, dlaczego nie zrezygnowano z tego roz-
wiązania.
54. Wyobraźmy sobie, że profesor udostępnia pliki swoim studentom, umieszczając je
w publicznie dostępnym katalogu systemu Linux zainstalowanego na komputerze wydziału
informatyki danej uczelni. Pewnego dnia profesor odkrywa, że plik niedawno umieszczony
w tym katalogu jest publicznie dostępny do zapisu. Decyduje się więc na zmianę dotychcza-
sowych uprawnień i sprawdza, czy plik nie został zmieniony (porównując go z własną
kopią). Dzień później odkrywa, że plik z ograniczonymi uprawnieniami został zmieniony.
Jak do tego doszło i jak można temu zapobiec?
55. Linux obsługuje wywołanie systemowe fsuid. W przeciwieństwie do bitu SETUID, który
daje użytkownikowi wszystkie prawa efektywnego identyfikatora skojarzonego z urucho-
mionym przez niego programem, bit FSUID daje użytkownikowi tylko specjalne upraw-
nienia dostępu do plików. Do czego można wykorzystać ten bit?
56. W systemie Linux przejdź do katalogu /proc/####, gdzie #### oznacza zapisaną
w systemie dziesiątkowym liczbę odpowiadającą procesowi, który aktualnie działa w sys-
temie. Odpowiedz na poniższe pytania i objaśnij:
(a) Jaki jest rozmiar większości plików w tym katalogu?
(b) Jakie są ustawienia czasu i daty większości plików?
(c) Jakiego rodzaju prawa dostępu do plików są ustawione dla plików w tym katalogu?
57. Wyobraź sobie, że piszesz aktywność Androida, która ma wyświetlić stronę WWW w prze-
glądarce. Jak można zaimplementować zapisywanie stanu aktywności, aby zminimali-
zować liczbę zapisywanych informacji o stanie i nie utracić ważnych szczegółów?
58. Wyobraź sobie, że piszesz kod obsługi sieci, który wykorzystuje gniazda do pobierania
pliku i jest przeznaczony do działania w systemie Android. Jakie działania powinien wyko-
nywać ten kod, by odróżnić się od kodu pisanego na standardowy system Linux?
59. Załóżmy, że projektujesz proces przypominający proces zygote z Androida dla systemu,
w którym w każdym procesie będzie działało wiele wątków powstałych w wyniku rozwi-
dlenia projektowanego przez Ciebie procesu. Czy wolałbyś, aby te wątki zostały urucho-
mione wewnątrz procesu zygote, czy też po wykonaniu wywołania fork?
60. Wyobraź sobie, że używasz mechanizmu Androida Binder IPC w celu wysłania obiektu
do innego procesu. Później odbierasz obiekt z wywołania do Twojego procesu i odkrywasz,
że odebrany obiekt jest identyczny jak ten, który wcześniej wysłałeś. Co możesz założyć
lub czego nie wolno Ci zakładać wewnątrz Twojego procesu na temat procesów wywo-
łujących?
61. Rozważmy system Android, który natychmiast po uruchomieniu realizuje następujące
działania:
1. Uruchamia aplikację rozruchową (tzw. launcher).
2. Aplikacja pocztowa zaczyna synchronizować w tle zawartość swojej skrzynki
pocztowej.
3. Użytkownik uruchamia aplikację aparatu fotograficznego.
4. Użytkownik uruchamia aplikację przeglądarki WWW.
Strona WWW, którą użytkownik aktualnie wyświetla w przeglądarce, wymaga coraz więcej
pamięci RAM — tak długo, aż uzyska maksymalną możliwą ilość pamięci RAM do dys-
pozycji. Co się będzie działo?
62. Napisz minimalną powłokę umożliwiającą uruchamianie prostych poleceń. Powłoka
powinna dodatkowo umożliwiać użytkownikom uruchamianie procesów w tle.
63. Napisz program korzystający z języka asemblera i wywołań BIOS-u, który będzie się uru-
chamiał z dyskietki na komputerze klasy Pentium. Program powinien używać wywołań
BIOS-u do odczytywania danych z klawiatury i wyświetlania wpisywanych znaków na
ekranie (choćby po to, aby zademonstrować swoje działanie).
64. Napisz prosty program terminala łączący dwa komputery z systemem Linux za pośred-
nictwem portów szeregowych. Do skonfigurowania tych portów użyj wywołań związanych
z zarządzaniem terminalem, które zdefiniowano w standardzie POSIX.
65. Napisz aplikację klient-serwer, która — na żądanie — będzie przesyłała zawartość wiel-
kiego pliku za pośrednictwem gniazd. Zmień implementację tej aplikacji, aby korzystała
Prace projektowe firmy Microsoft nad rozwojem systemu operacyjnego Windows przeznaczonego
na komputery PC oraz serwery można podzielić na cztery ery: MS-DOS, Windows na bazie
MS-DOS-a, Windows na bazie NT oraz Modern Windows (dosł. nowoczesny Windows). Z tech-
nicznego punktu widzenia każdy z tych systemów zasadniczo różni się od pozostałych. Każdy z nich
zdominował inną dekadę w historii komputerów osobistych. W tabeli 11.1 przedstawiono daty
najważniejszych wydań systemów operacyjnych z rodziny Windows dla komputerów biurko-
wych. W kolejnych punktach krótko omówimy wszystkie trzy ery opisane w tabeli.
855
ten system na platformę IBM PC i sprzedał licencję na nowy produkt firmie IBM. Nazwano go
MS-DOS 1.0 (od ang. MicroSoft Disk Operating System) i dostarczano już z pierwszymi kompu-
terami IBM PC w 1981 roku.
MS-DOS był 16-bitowym systemem operacyjnym trybu rzeczywistego, jednego użytkownika,
sterowanym z poziomu wiersza poleceń i składającym się z 8 kB kodu składowanego w pamięci
głównej. Przez następną dekadę zarówno platforma sprzętowa PC, jak i system MS-DOS stale
ewoluowały i były rozszerzane o coraz bardziej zaawansowane funkcje i możliwości. Kiedy w roku
1986 firma IBM zbudowała komputer PC/AT na bazie procesora 286 firmy Intel, MS-DOS zaj-
mował już rozmiar 36 kB, ale wciąż był systemem wiersza poleceń obsługującym zaledwie jedną
aplikację na raz.
interfejs API systemu operacyjnego OS/2 (czyli API rozwijane wówczas wspólnymi siłami firm
IBM i Microsoft). Właśnie dlatego w oryginalnych dokumentach projektowych zespół Cutlera
nazywał tworzony system NT OS/2.
System Cutlera ostatecznie nazwano NT (od ang. New Technology, ale też dlatego, że począt-
kowo tworzono go dla nowego procesora Intel 860 o nazwie kodowej N10). System Windows
NT zaprojektowano z myślą o zapewnieniu przenośności pomiędzy różnymi procesorami ze
szczególną dbałością o bezpieczeństwo i niezawodność, a także zgodność z wersjami systemu
Windows zbudowanymi na bazie MS-DOS-a. Ogromne doświadczenie Cutlera zdobyte w firmie
DEC ujawnia się w wielu obszarach systemu NT, stąd łudzące podobieństwa rozwiązań stoso-
wanych w systemie NT, systemie VMS i innych systemach operacyjnych projektowanych przez
Cutlera (patrz tabela 11.2).
Tabela 11.2. Systemy operacyjne firmy DEC opracowane przez zespół Dave’a Cutlera
Rok System operacyjny firmy DEC Cechy
1973 RSX-11M 6-bitowy, z obsługą wielu użytkowników, system czasu
rzeczywistego, z mechanizmem wymiany
1978 VAX/VMS 32-bitowy, z pamięcią wirtualną
1987 VAXELAN System czasu rzeczywistego
1988 PRISM/Mica Projekt zarzucono na rzecz systemu MIPS/Ultrix
Programiści przyzwyczajeni tylko do systemu UNIX musieli dość długo przyzwyczajać się
do nowej architektury systemu NT. Nie była to tylko kwestia wpływu systemu wcześniejszego
VMS, ale też różnic dzielących systemy komputerowe popularne w czasie powstawania tego
projektu. System UNIX początkowo projektowano w latach siedemdziesiątych dla systemów
16-bitowych z pojedynczym procesorem, bardzo ograniczoną pamięcią i z mechanizmami wymiany,
gdzie proces był jednostką współbieżności i podziału, a operacje rozwidlania (wykonywania) pro-
cesów były realizowane niemal błyskawicznie (ponieważ systemy wymiany często kopiowały
procesy na dysk). System NT zaprojektowano na początku lat dziewięćdziesiątych, a więc w dobie
wieloprocesorowych, 32-bitowych systemów z wielomegabajtową pamięcią i mechanizmami
pamięci wirtualnej. W systemie NT wątki są jednostkami współbieżności, biblioteki dynamiczne
to jednostki podziału, a operacje rozwidlania i wykonywania (fork i exec) zaimplementowano
w formie pojedynczej operacji tworzącej i uruchamiającej nowy program bez uprzedniego spo-
rządzania kopii.
Pierwszą wersję systemu Windows na bazie projektu NT (Windows NT 3.1) wydano w roku
1993. Przypisano jej numer 3.1, aby zaznaczyć zgodność z ówczesną wersją biurkowego systemu
Windows 3.1. Współpraca z firmą IBM została zerwana, zatem mimo zachowania obsługi interfej-
sów OS/2 najważniejszymi interfejsami systemu Windows NT 3.1 były 32-bitowe rozszerzenia
Windows API nazwany Win32. Już po rozpoczęciu prac nad systemem NT wydano system Win-
dows 3.0, który osiągnął ogromny sukces komercyjny. Także ten system był przystosowany
do uruchamiania programów zgodnych z Win32, ale tylko pod warunkiem zainstalowania odpo-
wiedniej biblioteki zgodności.
Podobnie jak pierwsza wersja systemu Windows na bazie MS-DOS-a, system Windows NT
w pierwszej odsłonie nie osiągnął spodziewanego sukcesu. Platforma NT wymagała więcej
pamięci, liczba aplikacji 32-bitowych była stosunkowo niewielka, a niezgodność z wieloma ste-
rownikami urządzeń i aplikacjami skłaniał wielu klientów do dalszego korzystania z systemów na
bazie MS-DOS-a, które w dodatku były stale udoskonalane przez Microsoft — przede wszystkim
poprzez wydanie w 1995 roku systemu Windows 95. Trudno się więc dziwić, że system NT
początkowo odnosił sukcesy tylko na rynku serwerów, gdzie konkurował z systemami VMS
i NetWare.
Twórcy systemu NT osiągnęli formułowany od początku cel przenośności dzięki wydaniom
z lat 1994 i 1995, w których zaimplementowano obsługę dla architektur (typu little-endian) MIPS
i PowerPC. Pierwszym ważnym krokiem w rozwoju tej platformy było wydanie w 1996 roku
systemu Windows NT 4.0. Nowy system oferował potencjał, bezpieczeństwo i niezawodność
systemu NT, a jednocześnie cechował się tym samym interfejsem użytkownika co bardzo popu-
larny wówczas system Windows 95.
Na rysunku 11.1 pokazano relację łączącą interfejs Win32 API z systemami Windows.
Wspólny interfejs API systemów Windows na bazie MS-DOS-a i platformy NT był ważnym czyn-
nikiem decydującym o sukcesie systemów z rodziny NT.
właścicieli komputerów osobistych. Prace nad Windows Vista zakończono pod koniec 2006 roku,
czyli ponad pięć lat po wydaniu systemu Windows XP. W nowym systemie ponownie zmieniono
projekt interfejsu graficznego i zastosowano nowe wewnętrzne mechanizmy bezpieczeństwa.
Większość zmian dotyczyła jednak rozwiązań widocznych dla użytkownika. Technologie we-
wnętrzne doskonalono stopniowo, poprawiając zarówno czytelność kodu, jak i wydajność, skalo-
walność i niezawodność systemu. Wersję serwerową Visty (Windows Server 2008) wydano
blisko rok po wydaniu wersji dla komputerów biurkowych. Obie wersje korzystały z tych samych
kluczowych komponentów, jak jądro, sterowniki czy niskopoziomowe biblioteki i programy.
Historię związaną z początkowymi pracami nad systemem NT opisano w książce Show-
stopper [Zachary, 1994]. Można tam znaleźć sporo informacji o najważniejszych osobach zaanga-
żowanych w ten projekt oraz analizę problemów, z którymi musi się mierzyć zespół realizujący
tak ambitny projekt informatyczny.
z powodu trudności w rozpraszaniu ciepła wytwarzanego przez układy taktowane coraz szyb-
szymi zegarami. Prawo Moore’a nadal obowiązywało, ale w nieco zmodyfikowanej wersji —
zamiast dodatkowych tranzystorów wprowadzano nowe funkcje i większą liczbę procesorów, nie
poprawiając wydajności maszyn jednoprocesorowych. Niezliczone funkcje systemu Windows
Vista były powodem jego niższej wydajności na tych komputerach w porównaniu z systemem
Windows XP, dlatego system Windows Vista nigdy nie został powszechnie zaakceptowany.
Problemy dotyczące systemu Windows Vista rozwiązano w kolejnej wersji nazwanej Win-
dows 7. Firma Microsoft zainwestowała olbrzymie sumy w automatyzację testowania i badania
wydajności oraz nową technologię telemetrii i znacznie wzmocniła zespoły odpowiedzialne za
poprawę wydajności, niezawodności i bezpieczeństwa. Chociaż w systemie Windows 7 wpro-
wadzono stosunkowo niewiele zmian funkcjonalnych w porównaniu z systemem Windows Vista,
był to system lepiej zaprojektowany i bardziej wydajny. Windows 7 szybko zastąpił system
Windows Vista, a ostatecznie także Windows XP i stał się najbardziej popularną wersją systemu
Windows spośród wszystkich dotychczas wydanych wersji.
przez system Windows wymagała wsparcia dla popularnej architektury ARM, jak również nowych
procesorów Intel przeznaczonych dla tych urządzeń. Windows 8 stał się częścią ery Modern
Windows dzięki wprowadzeniu zasadniczych zmian w modelu programowania. Zmiany te omó-
wimy w następnym podrozdziale.
System Windows 8 nie zyskał powszechnego uznania. W szczególności brak przycisku Start
na pasku zadań (i powiązanego z nim menu) był postrzegany przez wielu użytkowników jako
ogromny błąd. Inni sprzeciwiali się stosowaniu interrfejsu typowego dla tabletów na komputerach
desktop wyposażonych w duży monitor. Microsoft odpowiedział na tę i inne krytyczne uwagi,
publikując 14 maja 2013 roku aktualizację systemu pod nazwą Windows 8.1. W tej wersji roz-
wiązano wymienione wcześniej problemy, a jednocześnie wprowadzono szereg nowych funkcji —
np. lepszą integrację z chmurą. Ponadto wprowadzono wiele nowych programów. Mimo że w tym
rozdziale będziemy posługiwali się bardziej ogólną nazwą systemu Windows 8, wszystko, co
zostało opisane w rozdziale, dotyczy sposobu, w jaki działa system Windows 8.1.
Czas rozpocząć analizę technicznych aspektów funkcjonowania systemu Windows. Zanim jednak
przejdziemy do szczegółów wewnętrznej struktury, przyjrzymy się rdzennemu interfejsowi pro-
gramowania NT API dla wywołań systemowych oraz podsystemowi programowania Win32 wpro-
wadzonemu w systemach Windows na bazie NT, a także nowoczesnemu środowisku programo-
wania WinRT wprowadzonemu w systemie Windows 8.
Warstwy systemu operacyjnego Windows pokazano na rysunku 11.2. Pod warstwami apletów
i graficznego interfejsu użytkownika (GUI) znajdują się interfejsy programowe wykorzystywane
przez aplikacje. Jak w większości systemów operacyjnych, wspomniane warstwy składają się
w dużej mierze z bibliotek kodu (DLL), które są dynamicznie dołączane do programów żądają-
cych dostępu do odpowiednich funkcji systemu. System Windows obejmuje też wiele interfejsów
programowania, które zaimplementowano w formie usług działających jako odrębne procesy.
Aplikacje komunikują się z usługami trybu użytkownika za pośrednictwem zdalnych wywołań pro-
cedur (ang. Remote Procedure Calls — RPC).
Sercem systemu operacyjnego NT jest program trybu jądra NTOS (ntoskrnl.exe) udostępnia-
jący interfejsy tradycyjnych wywołań systemowych wykorzystywanych przez pozostałe skład-
niki systemu operacyjnego. W systemie Windows tylko programiści samego Microsoftu mogą
implementować warstwę wywołań systemowych. Wszystkie publikowane interfejsy trybu użyt-
kownika należą do tzw. osobowości (ang. personalities) systemu operacyjnego zaimplementowanych
z wykorzystaniem podsystemów ponad warstwami NTOS.
Początkowo system NT obsługiwał trzy osobowości: OS/2, POSIX i Win32. Z obsługi OS/2
zrezygnowano wraz z wydaniem systemu Windows XP. Obsługę osobowości POSIX ostatecznie
usunięto w systemie Windows 8.1. Obecnie wszystkie aplikacje systemu Windows są pisane
przy użyciu interfejsów API zbudowanych na bazie podsystemu Win32, takich jak API WinFX
w modelu programowania .NET. Interfejs API WinFX zawiera wiele funkcji Win32. W rzeczy-
wistości sporo funkcji bazowej biblioteki klas w WinFX (ang. Base Class Library) to po prostu
opakowania wywołań Win32 API. Do zalet WinFX można zaliczyć bogactwo obsługiwanych typów
obiektowych, uproszczone, spójne interfejsy oraz zastosowanie środowiska CLR (od. ang. Com-
mon Language Runtime) włącznie z mechanizmem odśmiecania (GC).
Wersje Modern Windows zaczynają się od systemu Windows 8, dla którego wprowadzono
nowy zestaw interfejsów API WinRT. Wraz z wprowadzeniem systemu Windows 8 tradycyjne
aplikacje desktop Win32 stały się przestarzałe. W zamian zaczęto promować paradygmat jednej
aplikacji uruchomionej na pełnym ekranie oraz położono nacisk na interfejs dotykowy zamiast
myszki. Firma Microsoft uznała te przedsięwzięcia za konieczny krok w ramach przejścia do
jednego systemu operacyjnego, który mógłby działać na telefonach, tabletach i konsolach do gier,
jak również na tradycyjnych komputerach osobistych i serwerach. Zmiany w GUI niezbędne do
wsparcia tego nowego modelu wymagają przebudowania aplikacji do nowego modelu API —
Modern Software Development Kit, który obejmuje API WinRT. Interfejsy API WinRT są starannie
opracowywane, tak by zapewniały bardziej spójny zestaw zachowań i interfejsów. Dla tych inter-
fejsów API są dostępne wersje dla programów C++ i .NET, ale również JavaScript dla apli-
kacji działających w przypominającym przeglądarkę środowisku wwa.exe (ang. Windows Web
Application).
Oprócz interfejsów API WinRT do MSDK (ang. Microsoft Development Kit) włączono wiele
istniejących API Win32. Dostępne pierwsze wersje API WinRT nie wystarczały do napisania zbyt
wielu programów. Niektóre z dołączonych interfejsów API Win32 wybrano w celu ograniczenia
pewnych zachowań aplikacji. I tak aplikacje nie mogą tworzyć wątków bezpośrednio za pomocą
wywołań MSDK, ale muszą wykorzystywać pulę wątków Win32 w celu uruchomienia współ-
bieżnych działań w ramach procesu. To dlatego, że system Modern Windows ma spowodować
przejście od stosowania modelu wątków do modelu zadań. Ma to na celu rozdzielenie zarzą-
dzania zasobami (priorytety, powinowactwa procesora) od modelu programowania (określenie
współbieżnych działań). Do innych pominiętych interfejsów API Win32 należy większość inter-
fejsów obsługi pamięci wirtualnej. Programiści powinni wykorzystywać interfejsy API zarzą-
dzania stertą z Win32. Nie mogą zarządzać zasobami pamięci bezpośrednio. Interfejsy API, które
wcześniej zostały zaniechane w Win32, pominięto również w MSDK. Podobnie wszystkie API
obsługi kodowania ANSI. API MSDK obsługują wyłącznie kodowanie Unicode.
Wybór słowa Modern (dosł. nowoczesny) na określenie takiego produktu jak Windows jest
dość zaskakujący. Być może, jeśli za 10 lat powstanie system Windows nowej generacji, będzie
określany przymiotnikiem post-Modern.
Ale istnieje również specjalny kod, który musi być dodany w celu prawidłowego zaimplemen-
towania każdego podsystemu. Dla przykładu natywne wywołanie systemowe NtCreateProcess
implementuje dublowanie procesu w ramach obsługi wywołania systemowego POSIX fork, a jądro
implementuje dla Win32 szczególnego rodzaju tablicę łańcuchów znaków (zwanych atomami),
która pozwala na skuteczne współdzielenie pomiędzy procesami łańcuchów tylko do odczytu.
Procesy podsystemu są natywnymi programami NT korzystającymi z rodzimych wywołań
systemowych dostarczanych przez jądro NT i podstawowe usługi, takie jak smss.exe i lsass.exe
(administracja zabezpieczeniami lokalnymi). Natywne wywołania systemowe uwzględniają działa-
jące pomiędzy procesami mechanizmy zarządzania adresami wirtualnymi, wątkami, uchwytami
i wyjątkami w procesach stworzonych z myślą o uruchomieniu programów napisanych w celu
wykorzystania określonego podsystemu.
Tabela 11.5. Przykłady wywołań interfejsu Win32 API i opakowywanych przez nie rdzennych
wywołań NT API
Wywołanie Win32 API Wywołanie rdzennego NT API
CreateProcess NtCreateProcess
CreateThread NtCreateThread
SuspendThread NtSuspendThread
CreateSemaphore NtCreateSemaphore
ReadFile NtReadFile
DeleteFile NtSetInfor mationFile
CreateFileMapping NtCreateSection
VirtualAlloc NtAllocateVir tualMemory
MapViewOfFile NtMapViewOfSection
DuplicateHandle NtDuplicateObject
CloseHandle NtClose
Niektóre wywołania interfejsu Win32 otrzymują na wejściu ścieżki do plików, mimo że ich
odpowiedniki w NT API operują na uchwytach. Oznacza to, że procedury opakowujące te wywo-
łania muszą otwierać odpowiednie pliki, wywoływać interfejs NT API oraz zamykać uzyskane
uchwyty. Procedury opakowań tłumaczą też interfejsy Win32 API z formatu ANSI na format
Unicode. Te funkcje interfejsu Win32 wymienione w tabeli 11.5, które wykorzystują łańcuchy
w roli parametrów, w rzeczywistości tworzą dwa interfejsy API — tak jest np. w przypadku funkcji
CreateProcessW i CreateProcessA. Łańcuchy przekazywane na wejściu drugiego z tych API muszą
być tłumaczone na format Unicode przed wywołaniem NT API, ponieważ podsystem NT operuje
tylko na łańcuchach Unicode.
Ponieważ kolejne wydania systemu Windows wprowadzają bardzo niewiele zmian w istnie-
jących interfejsach Win32, programy binarne, które działają prawidłowo w poprzednich wydaniach,
teoretycznie powinny działać także w nowych wydaniach. W praktyce jednak nowe wydania zwy-
kle powodują liczne problemy związane z niezgodnością. System Windows jest na tyle skom-
plikowany, że nawet z pozoru nieistotne zmiany mogą prowadzić do błędów aplikacji. Twórcy
samych aplikacji także nie są bez winy, ponieważ często decydują się na sprawdzanie wprost,
z którą wersją systemu operacyjnego mają do czynienia, lub pozostawiają w swoich produktach
ukryte błędy, które ujawniają się dopiero w nowym wydaniu systemu. Tak czy inaczej, firma
Microsoft przed każdym wydaniem poświęca sporo czasu na poszukiwanie ewentualnych nie-
zgodności w rozmaitych aplikacjach, po czym albo eliminuje usterki, albo opracowuje specjalne
obejścia problemów z myślą o konkretnych aplikacjach.
System Windows obsługuje dwa specjalne środowiska wykonawcze nazywane łącznie WOW
(od ang. Windows-on-Windows). WOW32 jest wykorzystywany w 32-bitowych systemach plat-
formy x86 do wykonywania aplikacji 16-bitowego systemu Windows 3.x. Zadaniem tego środo-
wiska jest odwzorowywanie wywołań systemowych i parametrów pomiędzy światami 16 i 32
bitów. Podobnie WOW64 umożliwia uruchamianie aplikacji stworzonych dla 32-bitowego systemu
Windows w systemach 64-bitowych.
Filozofia stojąca za interfejsem Windows API jest zupełnie inna niż idee przyświecające twór-
com systemu UNIX. Funkcje systemu operacyjnego UNIX są proste, otrzymują niewiele para-
metrów i bardzo rzadko oferują więcej niż jeden sposób wykonywania tej samej operacji. Pod-
system Win32 obejmuje bardzo rozbudowane interfejsy z wieloma parametrami, często z trzema
lub czterema operacjami realizującymi te same zadania oraz obejmujące zarówno funkcje nisko-
poziomowe, jak i funkcje wysokopoziomowe (np. CreateFile i CopyFile).
Oznacza to, że podsystem Win32 oferuje bardzo bogaty zbiór interfejsów. Z drugiej strony
jest też źródłem sporej złożoności wskutek źle zaplanowanego podziału na warstwy systemu
i mieszanie funkcji wysokopoziomowych z funkcjami niskopoziomowymi w ramach jednego API.
Podczas naszej analizy systemów operacyjnych będziemy się koncentrować tylko na tych funk-
cjach niskopoziomowych interfejsu Win32 API, które opakowują rdzenny interfejs NT API.
Interfejs Win32 obejmuje wywołania odpowiedzialne za tworzenie i zarządzanie procesami
oraz wątkami. Istnieje też wiele wywołań związanych z komunikacją międzyprocesową, w tym
tworzących, niszczących i wykorzystujących muteksy, semafory, zdarzenia, porty komunikacyjne
i inne obiekty IPC.
Mimo że znaczna część systemu zarządzania pamięcią jest niewidoczna dla programistów apli-
kacji, jedna z jego funkcji jest dostępna — procesy mają możliwość odwzorowywania plików
w swoich obszarach pamięci wirtualnej. Oznacza to, że wątki wchodzące w skład tego samego
procesu mogą odczytywać i zapisywać fragmenty jednego pliku, korzystając z odpowiednich
wskaźników (bez konieczności wykonywania wprost operacji odczytu i zapisu związanych z prze-
syłem danych pomiędzy dyskiem a pamięcią). System zarządzania pamięcią sam wykonuje nie-
zbędne operacje wejścia-wyjścia (stronicowania na żądanie) związane z obsługą plików odwzoro-
wanych w pamięci.
System Windows implementuje pliki odwzorowywane w pamięci, korzystając z trzech zupeł-
nie różnych mechanizmów. Po pierwsze udostępnia interfejsy, za których pośrednictwem procesy
mogą zarządzać własnymi przestrzeniami adresów wirtualnych, włącznie z rezerwowaniem
przedziałów adresów dla przyszłych operacji. Po drugie podsystem Win32 obsługuje abstrakcję
odwzorowywania plików (ang. file mapping), która służy do reprezentowania adresowalnych
obiektów, np. plików (w warstwie NT odwzorowanie pliku określa się mianem sekcji). Odwzo-
rowania plików najczęściej są tworzone z myślą o odwołaniach z wykorzystaniem uchwytów do
plików, jednak mogą też być tworzone w celu stosowania odwołań do stron prywatnych zarezer-
wowanych w systemowym pliku stron.
Trzeci mechanizm odwzorowuje perspektywy (ang. views) odwzorowań plików w przestrzeni
adresowej procesu. Interfejs Win32 umożliwia co prawda tylko tworzenie perspektyw dla bieżą-
cego procesu, jednak wykorzystywany mechanizm NT jest bardziej elastyczny i oferuje moż-
liwość tworzenia perspektyw dla dowolnych procesów — wystarczy, że proces wywołujący
dysponuje uchwytem z odpowiednimi uprawnieniami. Oddzielenie procedury tworzenia odwzo-
rowania pliku od operacji odwzorowywania pliku w przestrzeni adresowej to zupełnie inne roz-
wiązanie niż to znane z funkcji mmap systemu UNIX.
W systemie Windows odwzorowania plików mają postać obiektów trybu jądra reprezentowa-
nych przez uchwyty. Jak większość uchwytów, te reprezentujące odwzorowania mogą być powie-
lane na potrzeby innych procesów. Każdy z tych procesów może odwzorowywać plik we własnej
przestrzeni adresowej. Odwzorowywane pliki są więc wygodnym mechanizmem współdzielenia
pamięci prywatnej przez wiele procesów bez konieczności tworzenia wspólnych plików. W war-
stwie NT odwzorowania plików (sekcje) można oznaczać jako trwałe elementy przestrzeni nazw —
można się wówczas do nich odwoływać według nazwy.
Dla wielu programów ważnym obszarem są operacje wejścia-wyjścia na plikach. Z perspek-
tywy podsystemu Win32 plik jest po prostu liniową sekwencją bajtów. Interfejs Win32 API defi-
niuje ponad 60 wywołań umożliwiających tworzenie i niszczenie plików i katalogów, otwieranie
i zamykanie plików, odczytywanie i zapisywanie ich zawartości, odczytywanie i ustawianie atry-
butów plików, blokowanie przedziałów bajtów w plikach i wiele innych podstawowych operacji zwią-
zanych zarówno z organizacją systemu plików, jak i z dostępem do pojedynczych plików.
Istnieją też bardziej zaawansowane funkcje związane z zarządzaniem danymi składowanymi
w plikach. Oprócz głównego strumienia danych pliki składowane w systemie plików NTFS mogą
zawierać dodatkowe strumienie danych. Pliki (a nawet całe woluminy) można szyfrować. Ponadto
mogą być one kompresowane i (lub) reprezentowane przez rzadki strumień bajtów, w ramach
którego puste obszary danych nie zajmują przestrzeni dyskowej. Woluminy systemu plików można
tak organizować, aby obejmowały wiele odrębnych partycji dyskowych z wykorzystaniem róż-
nych poziomów magazynu RAID. Modyfikacje poddrzew plików lub katalogów można wykrywać
albo za pomocą mechanizmu powiadomień, albo poprzez odczytywanie zapisów dziennika utrzy-
mywanego przez system plików NTFS dla każdego woluminu.
Każdy wolumin systemu plików jest automatycznie montowany w przestrzeni nazw NT z wyko-
rzystaniem nazwy nadanej temu woluminowi — oznacza to, że plik \foo\bar mógłby się nazywać np.
\urządzenie\WoluminDyskuTwardego\foo\bar. W ramach każdego woluminu NTFS utrzymuje się
punkty montowania (w systemie Windows określane mianem punktów przyłączania; ang. reparse
points) i dowiązania symboliczne, które ułatwiają zarządzanie poszczególnymi woluminami.
Niskopoziomowy model wejścia-wyjścia systemu Windows ma charakter asynchroniczny.
Po rozpoczęciu operacji wejścia-wyjścia odpowiednie wywołanie systemowe może zwrócić stero-
wanie i umożliwić wątkowi, który to wywołanie zainicjował, dalszą pracę równolegle z wykonywaną
operacją wejścia-wyjścia. System Windows oferuje możliwość anulowania wykonywanych ope-
racji wejścia-wyjścia oraz wiele różnych mechanizmów, za których pomocą wątki mogą syn-
chronizować działanie z kończącymi się operacjami wejścia-wyjścia. System Windows umożliwia
też programom wymuszanie synchronicznego wykonywania operacji wejścia-wyjścia (można ten
tryb wskazać podczas otwierania pliku). Wiele funkcji bibliotek, w tym funkcji biblioteki C oraz
wywołań Win32, korzysta z synchronicznych operacji wejścia-wyjścia dla zapewnienia zgodności
i uproszczenia modelu programowania. W takich przypadkach środowisko wykonawcze wraca do
trybu użytkownika dopiero po zakończeniu wykonywania operacji wejścia-wyjścia.
Innym obszarem, dla którego interfejs Win32 API oferuje swoje wywołania, jest bezpieczeń-
stwo. Każdy wątek jest skojarzony z obiektem trybu jądra nazwanym tokenem i dostarczającym
informacje o tożsamości i uprawnieniach przypisanych temu wątkowi. Każdy obiekt może też
dysponować listą kontroli dostępu (ang. Access Control List — ACL) precyzyjnie określającą,
którzy użytkownicy mogą wykonywać poszczególne operacje na tym obiekcie. Ten model zabez-
pieczeń umożliwia szczegółowe definiowanie zasad udostępniania obiektów z wyszczególnieniem
pojedynczych użytkowników, którzy mogą uzyskiwać dostęp do poszczególnych obiektów lub
nie mogą tego dostępu uzyskać. Co więcej, zastosowany mechanizm jest rozszerzalny i umoż-
liwia aplikacjom dodawanie nowych reguł bezpieczeństwa ograniczających np. godziny, w których
dostęp do obiektów jest możliwy.
Przestrzeń nazw interfejsu Win32 różni się od opisanej w poprzednim punkcie przestrzeni
nazw rdzennego interfejsu NT. Tylko wybrane obszary przestrzeni nazw NT są widoczne dla
interfejsów Win32 API (z poziomu tych interfejsów można jednak uzyskiwać dostęp do całej
przestrzeni nazw NT za pośrednictwem specjalnych łańcuchów, np. poprzedzonych przedrost-
kiem "\\"). W podsystemie Win32 dostęp do plików uzyskuje się z wykorzystaniem liter napędów.
Katalog NT nazwany \DosDevices zawiera zbiór dowiązań symbolicznych łączących litery napędów
z obiektami właściwych urządzeń. Przykładowo dowiązanie \DosDevices\C: może wskazywać np.
na obiekt \Device\HarddiskVolume1. Katalog \DosDevices zawiera też dowiązania do pozostałych
urządzeń warstwy Win32, jak COM1:, LPT1: czy NUL: (odpowiednio dla portów szeregowego
i drukarki oraz tzw. urządzenia zerowego). Sam katalog \DosDevices jest w istocie dowiązaniem
symbolicznym do \??. Inny katalog NT, nazwany \BaseNamedObjects, służy do składowania rozma-
itych nazwanych obiektów trybu jądra dostępnych za pośrednictwem interfejsu Win32 API. Do
tej grupy należą obiekty wykorzystywane do synchronizacji, w tym semafory, pamięć współ-
dzielona, liczniki czasowe i porty komunikacyjne.
Oprócz opisanych do tej pory niskopoziomowych interfejsów systemowych Win32 API obsłu-
guje też wiele funkcji wykonujących operacje na graficznym interfejsie użytkownika (GUI), w tym
wszystkie wywołania zarządzające interfejsem systemu. Istnieją też wywołania odpowiedzialne
za tworzenie, niszczenie, zarządzanie i używanie okien, menu, pasków narzędzi, pasków stanu,
pasków przewijania, okien dialogowych, ikon i wielu innych elementów widocznych na ekranie.
Interfejs zawiera też wywołania umożliwiające rysowanie figur geometrycznych, wypełnianie ich,
zarządzanie wykorzystywanymi przez nie paletami kolorów, obsługę czcionek i umieszczanie
ikon na ekranie. I wreszcie istnieją wywołania obsługujące klawiaturę, mysz i inne urządzenia
wykorzystywane przez użytkownika, a także urządzenia audio, drukowania i pozostałe urządzenia
wyjściowe.
Operacje na graficznym interfejsie użytkownika współpracują bezpośrednio ze sterownikiem
win32k.sys i korzystają ze specjalnych interfejsów, które zapewniają dostęp do funkcji wykony-
wanych w trybie jądra z poziomu bibliotek trybu użytkownika. Ponieważ tego rodzaju wywołania
nie wymagają angażowania podstawowych wywołań systemowych warstwy NTOS, nie poświę-
cimy im więcej czasu.
Tabela 11.6. Gałęzie rejestru systemu Windows Vista (HKLM jest skrótem
od HKEY_LOCAL_MACHINE)
Plik gałęzi Zamontowana nazwa Znaczenie
SYSTEM HKLM\SYSTEM Informacje konfiguracyjne systemu operacyjnego
wykorzystywane przez jądro
HARDWARE HKLM\HARDWARE Składowana w pamięci gałąź rejestrująca wykrywany sprzęt
BCD HKLM\BCD* Baza danych z informacjami konfiguracyjnymi procedury
uruchamiania systemu
SAM HKLM\SAM Informacje o koncie lokalnego użytkownika
SECURITY HKLM\SECURITY Konto usługi lsass i pozostałe informacje o bezpieczeństwie
DEFAULT HKEY_USERS\.DEFAULT Domyślna gałąź dla nowych użytkowników
NTUSER.DAT HKEY_USERS<identyfikator> Gałąź użytkownika składowana w jego katalogu domowym
SOFTWARE HKLM\SOFTWARE Klasy aplikacji rejestrowane przez model COM
COMPONENTS HKLM\COMPONENTS Manifesty i zależności komponentów systemowych
Tabela 11.7. Wybrane wywołania interfejsu Win32 API związane z operacjami na rejestrze
Funkcja Win32 API Opis
RegCreateKeyEx Tworzy nowy klucz rejestru
RegDeleteKey Usuwa klucz rejestru
RegOpenKeyEx Otwiera klucz, aby uzyskać jego uchwyt
RegEnumKeyEx Zwraca kolejno podklucze klucza reprezentowanego przez dany uchwyt
RegQueryValueEx Szuka danych składających się na wartość danego klucza
Kiedy system jest wyłączany, większość informacji składowanych w rejestrze zostaje zapisana
na dysku w ramach odpowiednich gałęzi. Ponieważ integralność tych danych ma zasadniczy
wpływ na funkcjonowanie systemu, system automatycznie sporządza kopie zapasowe i regu-
larnie zapisuje metadane na dysku, aby uniknąć ich uszkodzenia nawet w razie awarii. Utrata
rejestru oznaczałaby konieczność ponownej instalacji całego oprogramowania danego systemu.
inicjalizacji systemu warstwa NTOS tworzy obiekt sekcji potrzebny do odwzorowania biblioteki
ntdll oraz rejestruje adresy punktów wejścia tej biblioteki wykorzystywanych przez samo jądro.
Pod warstwami jądra i wykonawczą NTOS znajduje się oprogramowanie określane mianem
warstwy abstrakcji sprzętowej (ang. Hardware Abstraction Layer — HAL), które pozwala ukryć
niskopoziomowe szczegóły związane z działaniem sprzętu, jak dostęp do rejestrów urządzenia czy
operacje DMA. Ta sama warstwa odpowiada też za sposób reprezentowania przez firmware
BIOS-u informacji konfiguracyjnych i obsługi różnych układów pomocniczych procesora, np.
kontrolerów przerwań.
Najniższą warstwę oprogramowania stanowi hipernadzorca (ang. hypervisor), który w sys-
temie Windows jest określany nazwą Hyper-V. Hipernadzorca to własność opcjonalna (nie pokazano
jej na rysunku 11.4). Jest dostępny w wielu wersjach systemu Windows — w tym w profesjonal-
nych klientach desktop. Hipernadzorca przechwytuje wiele uprzywilejowanych operacji wyko-
nywanych przez jądro i emuluje je w sposób, który pozwala na równoczesne działanie wielu sys-
temów operacyjnych. Każdy system operacyjny jest uruchamiany w obrębie własnej maszyny
wirtualnej, która w systemie Windows nosi nazwę partycji. Hipernadzorca korzysta z funkcji
w ramach architektury sprzętowej do ochrony pamięci fizycznej i zapewnienia izolacji pomiędzy
partycjami. System operacyjny, działając pod kontrolą hipernadzorcy, uruchamia wątki i obsługuje
przerwania, korzystając z abstrakcji fizycznych procesorów nazywanych procesorami wirtualnymi.
Hipernadzorca szereguje wirtualne procesory na procesorach fizycznych.
Główny system operacyjny (root) działa w partycji głównej. Dostarcza on wielu usług usług
innym partycjom (gościom). Do najważniejszych usług należy integracja gości ze współdzielo-
nymi urządzeniami, takimi jak interfejsy sieciowe i GUI. O ile w przypadku hipernadzorcy Hyper-V
głównym systemem operacyjnym musi być Windows, o tyle na partycjach-gościach mogą działać
inne systemy operacyjne, np. Linux. Podczas pracy pod kontrolą hipernadzorcy wydajność systemu
operacyjnego-gościa, jeśli nie został on odpowiednio zmodyfikowany (tzn. parawirtualizowany),
może być bardzo niska.
Jeśli np. jądro systemu operacyjnego gościa korzysta z blokady pętlowej do synchronizacji
między dwoma procesorami wirtualnymi, a hipernadzorca zaplanuje działanie procesora wirtu-
alnego będącego w posiadaniu blokady pętlowej, to czas utrzymywania blokady może zwiększyć
się o rząd wielkości. W związku z tym inne procesory wirtualne działające w partycji pozostają
w stanie oczekiwania przez bardzo długi okres. W celu rozwiązania tego problemu system opera-
cyjny-gość jest uprawniony do blokady (ang. enlightened) tylko przez krótki czas. Następnie
musi wywołać hipernadzorcę w celu ustąpienia procesora fizycznego, tak by można było urucho-
mić inny procesor wirtualny.
Innymi ważnymi komponentami trybu użytkownika są sterowniki urządzeń. W systemie
Windows sterowniki stosuje się dla wszystkich mechanizmów trybu jądra, które nie wchodzą
w skład warstw NTOS ani HAL. Sterowniki są więc niezbędne do współpracy z systemami
plików, stosami protokołów sieciowych oraz rozszerzeniami jądra, jak programy antywirusowe
czy oprogramowanie DRM (od ang. Digital Rights Management), a także do zarządzania urządze-
niami fizycznymi, magistralami sprzętowymi itp. Komponenty wejścia-wyjścia i pamięci wirtu-
alnej współpracują podczas ładowania (i usuwania) sterowników urządzeń do pamięci jądra i wła-
ściwego łączenia tych sterowników z warstwami NTOS i HAL.
Menedżer wejścia-wyjścia udostępnia interfejsy, za których pośrednictwem można odkrywać,
organizować i wykorzystywać urządzenia (włącznie z wymuszaniem ładowania odpowiednich
sterowników). Znaczna część informacji konfiguracyjnych związanych z zarządzaniem urządze-
niami i sterownikami jest składowana w gałęzi SYSTEM rejestru systemu Windows. Podkompo-
nent plug and play menedżera wejścia-wyjścia utrzymuje informacje o wykrytym sprzęcie w gałęzi
HARDWARE, czyli ulotnym, często zmienianym obszarze rejestru składowanym w pamięci
(zamiast na dysku) i całkowicie odtwarzanym podczas każdego uruchamiania systemu.
W kolejnych podpunktach przeanalizujemy bardziej szczegółowo poszczególne komponenty
systemu operacyjnego.
w takich obszarach jak pamięć podręczna czy lokalność pamięci. Niemal wszystkie elementy
systemu NT wykorzystują proste mechanizmy synchronizujące, które działają na zasadzie porów-
naj i wymień (ang. compare&swap), zatem przeniesienie tego systemu do architektury pozba-
wionej tych mechanizmów byłoby dość trudne. I wreszcie działanie systemu operacyjnego jest
w dużej mierze uzależnione od sposobu organizowania bajtów w słowach w warstwie sprzętowej.
Wszystkie systemy komputerowe, na które do tej pory przeniesiono system NT, pracowały w try-
bie little-endian.
Oprócz tych poważnych problemów utrudniających przenoszenie systemu na różne platformy
sprzętowe istnieje też wiele drobnych różnic dzielących nawet płyty główne różnych producentów.
Różnice pomiędzy wersjami procesora wpływają na sposób implementacji prostych mechanizmów
synchronizujących, np. blokad pętlowych (ang. spin-locks). Istnieje wiele rodzin układów pomoc-
niczych, które w odmienny sposób przypisują priorytety przerwaniom sprzętowym, inaczej obsłu-
gują dostęp do rejestrów urządzeń wejścia-wyjścia, różnią się sposobem zarządzania transferami
DMA, inaczej sterują licznikami czasowymi i zegarem czasu rzeczywistego, odmiennie synchroni-
zują pracę procesorów, w inny sposób korzystają z takich funkcji BIOS-u jak ACPI (od ang.
Advanced Configuration and Power Interface) itp. Programiści firmy Microsoft włożyli mnóstwo
wysiłku w próbę ukrycia tych różnic sprzętowych w ramach wspomnianej już cienkiej warstwy
HAL. Zadaniem warstwy HAL jest udostępnianie pozostałym składnikom systemu operacyjnego
abstrakcyjnego sprzętu, który ukrywa szczegóły związane z tą czy inną wersją procesora, układu
chipset i innych elementów konfiguracji sprzętowej. Abstrakcje HAL są prezentowane w formie
usług niezależnych od warstwy sprzętowej (wywołań procedur i makr) dostępnych dla warstwy
NTOS i sterowników.
Możliwość korzystania z usług warstwy HAL bez konieczności bezpośredniego adresowania
urządzeń oznacza, że sterownik i jądro wymagają mniejszej liczby zmian w razie przenoszenia
systemu na nowe procesory — w niemal wszystkich przypadkach mogą współpracować w nie-
zmienionej formie z tymi samymi architekturami procesorów, mimo różnych wersji procesorów
i chipów pomocniczych.
Warstwa HAL nie oferuje abstrakcji ani usług dla konkretnych urządzeń wejścia-wyjścia, jak
klawiatury, myszy, dyski czy jednostki zarządzania pamięcią (MMU). Mimo to ilość kodu, którą
trzeba by zmodyfikować podczas przenoszenia systemu w razie braku warstwy HAL, byłaby nie-
porównanie większa (nawet gdyby różnice dzielące sam sprzęt były stosunkowo niewielkie).
Przenoszenie samej warstwy HAL jest o tyle proste, że kod zależny od sprzętu jest skoncen-
trowany w jednym miejscu, a cele przenoszenia są w takim przypadku wyjątkowo jasne — imple-
mentacja wszystkich usług warstwy HAL. Dla wielu wydań systemu Windows firma Microsoft
udostępniła pakiety HAL Development Kit, dzięki którym producenci systemów mogli budować
własne warstwy HAL, które z kolei umożliwiały pozostałym komponentom jądra współpracę
z nowymi systemami bez konieczności modyfikacji (oczywiście pod warunkiem że zmiany
w sprzęcie nie zaszły zbyt daleko).
Jako przykład działań podejmowanych przez warstwę abstrakcji sprzętowej przeanalizujmy
mechanizmy operacji wejścia-wyjścia odwzorowywanych w pamięci w zestawieniu z operacjami
wejścia-wyjścia na portach. Część komputerów korzysta z jednego modelu, inne stosują drugi
model. Jak w takim razie należałoby zaprogramować odpowiedni sterownik — powinien obsłu-
giwać operacje wejścia-wyjścia odwzorowywane w pamięci czy nie? Zamiast wybierać jeden
model i — tym samym — rezygnować z przenośności sterownika na komputer stosujący inny
model, możemy wykorzystać warstwę abstrakcji sprzętowej oferującą programistom sterowników
trzy procedury do odczytu rejestrów urządzeń i trzy inne procedury do ich zapisywania:
Wymienione procedury odczytują i zapisują we wskazanym porcie odpowiednio 8-, 16- i 32-bitowe
liczby całkowite bez znaku. To do warstwy abstrakcji sprzętowej należy decyzja o ewentualnej
konieczności użycia operacji wejścia-wyjścia odwzorowanych w pamięci. Oznacza to, że sterow-
nik korzystający z tych procedur można bez żadnych modyfikacji przenosić pomiędzy kompute-
rami stosującymi różne implementacje rejestrów urządzeń.
Sterowniki często muszą uzyskiwać dostęp do konkretnych urządzeń wejścia-wyjścia, aby
realizować rozmaite zadania. Na poziomie sprzętu każde urządzenie ma przypisany jeden lub
wiele adresów pewnej magistrali. Ponieważ współczesne komputery dysponują wieloma magi-
stralami (ISA, PCI, PCI-X, USB, 1394 itd.), może się okazać, że więcej niż jedno urządzenie
ma przypisany ten sam adres różnych magistral, stąd konieczność ich rozróżniania. Warstwa HAL
oferuje usługę umożliwiającą identyfikację urządzeń poprzez odwzorowywanie adresów urządzeń
w ramach odpowiednich magistral na adresy logiczne obowiązujące w całym systemie. Sterowniki
nie muszą więc kojarzyć wykorzystywanych urządzeń z poszczególnymi magistralami. Wspo-
mniany mechanizm dodatkowo chroni wyższe warstwy przed szczegółowymi cechami alterna-
tywnych struktur magistral i konwencji adresowania.
Z podobnym problemem mamy do czynienia w przypadku przerwań — także one są zależne
od magistral. Okazuje się, że także ten problem rozwiązano — zaimplementowano w warstwie
HAL usługę przypisującą przerwaniom nazwy o zasięgu systemowym. Istnieją też usługi, za
których pośrednictwem sterowniki mogą w przenośny sposób kojarzyć z przerwaniami procedury
obsługujące te przerwania (bez konieczności odwoływania się do szczegółów implementacyjnych
wektorów przerwań poszczególnych magistral). Warstwa HAL zarządza również poziomami żądań
przerwań.
Inna usługa warstwy HAL odpowiada za niezależną od urządzenia obsługę i zarządzanie prze-
syłaniem danych w trybie DMA. Wspomniana usługa może obsługiwać zarówno główny, sys-
temowy silnik DMA, jak i silniki DMA poszczególnych kart wejścia-wyjścia. W odwołaniach do
urządzeń wykorzystuje się ich adresy logiczne. Warstwa HAL implementuje programowe operacje
zapisu i odczytu z nieciągłych bloków pamięci fizycznej.
Warstwa HAL zarządza też zegarami i licznikami czasowymi w sposób gwarantujący prze-
nośność. Czas jest mierzony w jednostce 100 ns, licząc od 1 stycznia 1601 roku, czyli od pierw-
szego dnia poprzedniego czterechsetlecia, co znacznie ułatwia wyznaczanie lat przestępnych.
(Krótki quiz: czy rok 1800 był przestępny? Szybka odpowiedź: nie). Usługi czasu izolują właściwe
sterowniki od faktycznych częstotliwości taktowania zegarów.
Komponenty jądra wymagają czasem synchronizacji na bardzo niskim poziomie, zwykle po to,
by zapobiegać występowaniu zjawiska wyścigu w systemach wieloprocesorowych. Warstwa HAL
oferuje proste mechanizmy umożliwiające zarządzanie taką synchronizacją, np. blokady pętlowe,
dzięki którym jeden procesor po prostu czeka na zwolnienie zasobów blokowanych przez inny
procesor (zwykle gdy odpowiedni zasób jest wykorzystywany przez zaledwie kilka rozkazów
maszynowych).
I wreszcie po uruchomieniu systemu warstwa HAL kontaktuje się z systemem BIOS i za jego
pośrednictwem uzyskuje konfigurację systemu, w szczególności informacje o magistralach i urzą-
dzeniach wejścia-wyjścia składających się na dany system oraz o ich ustawieniach. Informacje
odczytane z BIOS-u są następnie umieszczane w rejestrze. Wybrane funkcje warstwy HAL poka-
zano na rysunku 11.5.
Rysunek 11.5. Wybrane funkcje sprzętowe, którymi zarządzają usługi warstwy HAL
Warstwa jądra
Ponad warstwą abstrakcji sprzętowej znajduje się warstwa NTOS złożona z dwóch podwarstw:
jądra i wykonawczej. Termin „jądro” występuje w systemie Windows w wielu znaczeniach. Tym
mianem można określać dowolny kod wykonywany w trybie jądra procesora. Tak samo bywa
nazywany plik ntoskrnl.exe zawierający NTOS, czyli serce systemu operacyjnego Windows. O jądrze
mówi się też w kontekście podwarstwy jądra w ramach warstwy NTOS — właśnie w tym zna-
czeniu będziemy używać terminu jądro w tym podpunkcie. Co ciekawe, nawet biblioteka trybu
użytkownika Win32 z opakowaniami dla rdzennych wywołań systemowych (kernel32.dll) jest
określana mianem jądra.
W systemie operacyjnym Windows warstwa jądra (widoczna na rysunku 11.4 ponad warstwą
wykonawczą) udostępnia zbiór abstrakcji niezbędnych do zarządzania procesorem. Centralną
abstrakcją tego zbioru są wątki, jednak jądro implementuje też obsługę wyjątków, pułapek
i rozmaitych rodzajów przerwań. Z drugiej strony tworzenie i niszczenie struktur danych przy-
stosowanych do przetwarzania wielowątkowego zaimplementowano w warstwie wykonawczej.
Warstwa jądra odpowiada natomiast za szeregowanie i synchronizację wątków. Obsługa wątków
w odrębnej warstwie umożliwia implementację warstwy wykonawczej z wykorzystaniem tego
samego modelu wielowątkowego, którego używa wykonywany współbieżnie kod trybu jądra,
choć mechanizmy synchronizujące warstwy wykonawczej z natury rzeczy są nieporównanie bar-
dziej wyspecjalizowane.
Mechanizm szeregujący wątków jądra odpowiada za określanie, który wątek jest wykony-
wany przez poszczególne procesory danego systemu. Każdy wątek jest wykonywany do momentu
wysłania sygnału przerwania zegarowego wskazującego na konieczność przełączenia do innego
wątku (po wyczerpaniu kwantu czasu), do chwili przejścia w stan oczekiwania na jakieś zdarzenia
(np. zakończenie operacji wejścia-wyjścia lub zwolnienia blokady) albo do momentu zgłoszenia
gotowości do wykonywania przez wątek z wyższym priorytetem, wymagający dostępu do pro-
cesora. Podczas przełączania pomiędzy wątkami mechanizm szeregujący sam korzysta z pro-
cesora i upewnia się, że wszystkie rejestry i inne elementy stanu sprzętu zostały bezpiecznie
zapisane. Zaraz potem opisywany mechanizm wybiera inny wątek do wykonania na procesorze
i przywraca stan tego wątku zapisany podczas poprzedniego wykonania na procesorze.
Jeśli następny wątek do wykonania mieści się w innej przestrzeni adresowej (należy do innego
procesu) niż aktualnie wykonywany wątek, mechanizm szeregujący musi dodatkowo wymienić
przestrzeń adresową. Szczegóły działania samego algorytmu szeregowania zostaną omówione
w dalszej części tego rozdziału przy okazji analizy procesów i wątków.
Obiekty dyspozytora
Innym typem obiektu synchronizującego jest tzw. obiekt dyspozytora. Do obiektów dyspozytora
zalicza się wszystkie zwykłe obiekty trybu jądra (czyli obiekty, do których można się odwoływać
za pośrednictwem uchwytów), zawierające strukturę danych dispatcher_header (patrz rysunek
11.6). Zbiór obiektów dyspozytora obejmuje semafory, muteksy, zdarzenia, liczniki czasowe
z funkcją oczekiwania i inne obiekty umożliwiające wątkom oczekiwanie w związku z synchroni-
zacją z innymi wątkami. Do obiektów dyspozytora zalicza się też obiekty reprezentujące otwarte
pliki, procesy, wątki i porty IPC. Struktura danych dispatcher_header zawiera flagę reprezentującą
sygnalizowany stan obiektu oraz kolejkę wątków oczekujących na sygnał danego obiektu.
Warstwa wykonawcza
Jak widać na rysunku 11.4, pod warstwą jądra NTOS znajduje się warstwa wykonawcza (ang. exe-
cutive). Warstwa wykonawcza, którą napisano w języku C, jest w dużej mierze niezależna od
architektury (jednym z najważniejszych wyjątków jest menedżer pamięci), zatem jej przenoszenie
na nowe platformy sprzętowe (MIPS, x86, PowerPC, Alpha, IA64, x64 i ARM) nie było zbyt
trudne. Warstwa wykonawcza składa się z wielu różnych komponentów, z których każdy korzysta
z abstrakcji udostępnianej przez warstwę jądra.
Każdy komponent jest podzielony na wewnętrzne i zewnętrzne struktury danych oraz inter-
fejsy. Wewnętrzne elementy tych komponentów są ukryte i wykorzystywane tylko w ramach
samych komponentów; elementy zewnętrzne są dostępne dla pozostałych komponentów wcho-
dzących w skład warstwy wykonawczej. Pewien podzbiór interfejsów zewnętrznych wyekspor-
towano z pliku wykonywalnego ntoskrnl.exe, dzięki czemu sterowniki urządzeń mogą łączyć się
z tymi interfejsami zupełnie tak, jakby warstwa wykonawcza miała postać biblioteki. Progra-
miści firmy Microsoft zdecydowali się nazwać wiele tych komponentów wykonawczych „mene-
dżerami”, ponieważ każdy z nich odpowiada za zarządzanie pewnym aspektem usług systemu
operacyjnego, jak operacje wejścia-wyjścia, pamięcią, procesami czy obiektami.
Jak w przypadku większości systemów operacyjnych, istotna część funkcji warstwy wyko-
nawczej systemu Windows przypomina kod biblioteki z tą różnicą, że działa w trybie jądra.
Struktury danych tej warstwy mogą być współdzielone i chronione przed dostępem z poziomu
kodu użytkownika, dzięki czemu warstwa ta może uzyskiwać dostęp do zastrzeżonych elementów
sprzętu, np. do rejestrów kontrolnych jednostki MMU. Z drugiej strony działanie warstwy wyko-
nawczej sprowadza się do wykonywania funkcji systemu operacyjnego w imieniu kodu wywołują-
cego, zatem jej mechanizmy wykonują swój kod w wątkach procesu wywołującego.
Kiedy któraś z funkcji warstwy wykonawczej jest zablokowana w oczekiwaniu na synchro-
nizację z innymi wątkami, także odpowiedni wątek trybu użytkownika jest blokowany. Takie
rozwiązanie jest logiczne, skoro funkcje warstwy wykonawczej działają w imieniu konkretnych
wątków trybu użytkownika, ale też może być nieefektywne w przypadku typowych zadań związa-
nych z utrzymaniem systemu. Aby uniknąć przechwytywania bieżącego procesu w razie odkrycia
przez warstwę wykonawczą konieczności podjęcia działań porządkowych, podczas uruchamiania
systemu tworzy się pewną liczbę wątków trybu jądra odpowiedzialnych za konkretne zadania,
np. zapisywanie na dysku zmodyfikowanych stron pamięci.
Dla przewidywalnych, rzadko wykonywanych zadań istnieje wątek aktywowany raz na sekundę
i dysponujący długą listą aspektów wymagających obsługi. Dla mniej przewidywalnych zadań
istnieje specjalna, wspominana już pula wątków roboczych o wysokim priorytecie, które można
wykorzystywać — wystarczy umieścić żądania w kolejce i zasygnalizować oczekiwane przez te
wątki zdarzenia synchronizacyjne.
Menedżer obiektów zarządza większością interesujących obiektów trybu jądra wykorzystywa-
nych w warstwie wykonawczej. Menedżer obiektów zarządza więc procesami, wątkami, plikami,
semaforami, urządzeniami wejścia-wyjścia, sterownikami, licznikami czasowymi i wieloma innymi
obiektami. Jak już wspomniano, obiekty trybu jądra są w istocie strukturami danych alokowanymi
i wykorzystywanymi przez jądro. W systemie Windows struktury danych jądra mają wiele cech
wspólnych i jako takie w większości mogą być łatwo zarządzane za pomocą zunifikowanych
mechanizmów.
Mechanizmy oferowane przez menedżera obiektów obejmują funkcje związane z przydzie-
laniem i zwalnianiem pamięci dla obiektów, zarządzaniem limitami, obsługą dostępu do obiektów
z wykorzystaniem uchwytów, utrzymywaniem liczników odwołań (zarówno dla wskaźników trybu
jądra, jak i referencji w formie uchwytów), nadawaniem obiektom nazw w przestrzeni nazw NT
oraz udostępnianiem rozszerzalnego mechanizmu zarządzania cyklem życia poszczególnych
obiektów. Struktury danych jądra, które potrzebują choć części tych rozwiązań, są zarządzane
właśnie przez menedżera obiektów.
Każdy obiekt menedżera obiektów może mieć przypisany typ, na którego podstawie można
określić, jak należy zarządzać cyklem życia tego obiektu (i innych obiektów tego samego typu).
Nie są to typy w rozumieniu programowania obiektowego (odpowiedniki klas), tylko zwykłe
kolekcje parametrów określanych w momencie tworzenia nowego typu. Aby utworzyć taki typ,
komponent wykonawczy musi skorzystać z odpowiedniego wywołania interfejsu API menedżera
obiektów. Obiekty są na tyle ważne dla funkcjonowania systemu Windows, że zagadnieniom
związanym z menedżerem obiektów poświęcimy cały punkt tego podrozdziału.
cyjne wymagania stawiane przed kodem wchodzącym w skład jądra i opóźnienia powodowane
przez przesunięcia w trybie jądra. Koszty czasowe tych działań pochłonęły część czasu zyskanego
dzięki wyeliminowaniu kosztów przełączania.
Sterowniki urządzeń
Ostatnia część rysunku 11.4 zawiera sterowniki urządzeń (ang. device drivers). W systemie Win-
dows sterowniki urządzeń mają postać bibliotek dołączanych dynamicznie, które są ładowane
przez podwarstwę wykonawczą warstwy NTOS. Mimo że sterowniki wykorzystuje się przede
wszystkim do obsługi konkretnych urządzeń sprzętowych (urządzeń fizycznych i magistral wej-
ścia-wyjścia), mechanizm sterowników jest używany także w roli uniwersalnej metody rozsze-
rzania możliwości trybu jądra. Jak już wspomniano, znaczna część podsystemu Win32 jest łado-
wana właśnie w formie sterownika.
Dla każdego urządzenia menedżer wejścia-wyjścia wyznacza ścieżkę przepływu danych (patrz
rysunek 11.7). Wspomniana ścieżka bywa określana mianem stosu urządzenia (ang. device stack)
i składa się z prywatnych kopii obiektów urządzeń jądra alokowanych na potrzeby tej ścieżki.
Każdy obiekt urządzenia wchodzący w skład tego stosu jest połączony z odpowiednim obiektem
sterownika, który z kolei zawiera tablicę procedur obsługujących pakiety żądań wejścia-wyjścia
przechodzące przez stos urządzenia. W pewnych przypadkach urządzenia na tym stosie repre-
zentują sterowniki, których jedynym zadaniem jest filtrowanie operacji wejścia-wyjścia kiero-
wanych do konkretnego urządzenia, magistrali lub sterownika sieciowego. Technikę filtrowania
stosuje się z kilku powodów. Czasem wstępne lub końcowe przetwarzanie operacji wejścia-
-wyjścia pozwala tworzyć bardziej przejrzyste architektury; w innych przypadkach jest raczej
przejawem pragmatyzmu, kiedy filtrowaniem można obejść problem braku kodu źródłowego
sterownika lub praw do jego modyfikacji. Filtry mogą też implementować zupełnie nowe funk-
cje, np. przekształcać dyski w partycje lub dyski składające się na macierz RAID w pojedyncze
woluminy.
Systemy plików są ładowane w formie sterowników. Dla każdego woluminu systemu plików
tworzy się obiekt urządzenia, który wchodzi w skład stosu urządzenia skojarzonego z tym wolu-
minem. Obiekt urządzenia jest z kolei wiązany z obiektem sterownika systemu plików (właściwego
formatowi danego woluminu). Specjalne sterowniki filtrów, nazywane sterownikami filtrów system
plików (ang. file system filter drivers), mogą umieszczać obiekty urządzeń przed obiektem urzą-
dzenia systemu plików. Takie rozwiązanie umożliwia wykonywanie na żądaniach wejścia-wyjścia
dodatkowych operacji przed ich przekazaniem do woluminu (np. analizy odczytywanych i zapisy-
wanych danych pod kątem zawierania wirusów).
Także protokoły sieciowe (np. zintegrowana z systemem Windows implementacja IPv4/IPv6
TCP/IP) są ładowane jako sterowniki z wykorzystaniem opisanego modelu wejścia-wyjścia.
Dla zapewnienia zgodności ze starszymi systemami Windows na bazie MS-DOS-a sterownik
protokołu TCP/IP implementuje specjalny protokół komunikacji z interfejsami sieciowymi ponad
modelem wejścia-wyjścia systemu Windows. Istnieją też inne sterowniki implementujące podobne
rozwiązania — w systemie Windows określa się je mianem miniportów (ang. miniports). Wspólne
mechanizmy umieszcza się w tzw. sterowniku klasy; np. wspólne funkcje dysków SCSI lub IDE
bądź urządzeń USB mogą być obsługiwane przez jeden sterownik klasy, który łączy się ze ste-
rownikami miniportów dla poszczególnych typów urządzeń.
W tym rozdziale nie będziemy omawiać sterowników żadnych konkretnych urządzeń, ale w pod-
rozdziale 11.7 wrócimy do tematu współpracy menedżera wejścia-wyjścia ze sterownikami.
Rysunek 11.7. Uproszczony schemat stosów urządzeń dla dwóch woluminów systemu plików
NTFS. Pakiet żądań wejścia-wyjścia jest przekazywany w dół stosu. Właściwe procedury powiązanych
sterowników są wywoływane na każdym poziomie tego stosu. Same stosy urządzeń składają się
z obiektów urządzeń tworzonych osobno dla każdego takiego stosu
nuje program WinResume.exe. W przeciwnym razie ładuje i wykonuje program WinLoad.exe, który
od nowa uruchamia system operacyjny. WinLoad ładuje do pamięci następujące komponenty
startowe systemu: warstwę jądra i warstwę wykonawczą (zwykle w pliku ntoskrnl.exe), warstwę
HAL (hal.dll), plik zawierający gałąź SYSTEM, sterownik Win32k.sys zawierający części pod-
systemu Win32 pracujące w trybie jądra oraz obrazy wszystkich sterowników wskazanych
w gałęzi SYSTEM jako sterowniki startowe (czyli takie, które należy załadować podczas uru-
chamiania systemu). Jeśli w systemie włączono obsługę programu Hyper-V, WinLoad ładuje
również i uruchamia program hipernadzorcy.
Po załadowaniu do pamięci komponentów startowych systemu Windows sterowanie jest
przekazywane do niskopoziomowego kodu NTOS, który odpowiada za inicjalizację warstwy HAL,
warstw jądra i wykonawczej, nawiązanie połączenia z obrazami sterowników oraz za uzyskanie
dostępu i (lub) aktualizację danych konfiguracyjnych zawartych w gałęzi SYSTEM. Po zainicjali-
zowaniu wszystkich komponentów trybu jądra kod NTOS wykorzystuje program smss.exe (swoisty
odpowiednik znanego z systemów UNIX pliku /etc/init) do utworzenia pierwszego procesu trybu
użytkownika.
Najnowsze wersje systemu Windows zapewniają wsparcie dla poprawy bezpieczeństwa sys-
temu w czasie startu. Wiele nowszych komputerów PC jest wyposażonych w moduł TPM (ang.
Trusted Platform Module) — układ na płycie głównej, który jest bezpiecznym procesorem usług
kryptograficznych chroniącym takie tajemnice jak klucze potrzebne do szyfrowania lub odszy-
frowywania. Moduł TPM systemu może służyć do ochrony kluczy, takich jak te używane przez
funkcję BitLocker do szyfrowania dysku. Chronione klucze nie są ujawniane do systemu opera-
cyjnego do czasu, aż moduł TPM zweryfikuje, że napastnik ich nie modyfikował. Może również
dostarczać innych funkcji kryptograficznych — np. sprawdzać systemy zdalne pod kątem tego, czy
system operacyjny w lokalnym systemie nie został naruszony.
Programy startowe systemu Windows obejmują logikę umożliwiającą radzenie sobie z typo-
wymi problemami napotykanymi przez użytkowników w razie niepowodzenia procesu urucha-
miania. Zdarza się, że instalacja niewłaściwego sterownika urządzenia lub nieprzemyślane użycie
jakiegoś programu (np. uszkodzenie gałęzi SYSTEM za pomocą programu regedit) uniemożliwia
normalne uruchomienie systemu. Okazuje się, że istnieje możliwość ignorowania ostatnich zmian
i uruchomienia systemu w ostatniej znanej dobrej konfiguracji. Istnieją też takie rozwiązania star-
towe jak bezpieczne uruchamianie z pominięciem wielu opcjonalnych sterowników oraz konsola
odzyskiwania uruchamiająca okno wiersza poleceń cmd.exe (przypominające trochę tryb jednego
użytkownika systemu UNIX).
Innym typowym problemem (z perspektywy użytkowników) jest występująca od czasu do
czasu niestabilność systemu operacyjnego Windows z pozornie losowymi awariami zarówno
samego systemu, jak i aplikacji. Dane gromadzone przez program On-line Crash Analysis firmy
Microsoft jasno pokazują, że znaczna część tych awarii wynika z błędów pamięci fizycznej —
właśnie dlatego system Windows Vista oferuje opcję szczegółowej analizy pamięci podczas uru-
chamiania. Być może w przyszłości komputery PC będą powszechnie obsługiwały funkcję korekcji
ECC (lub parzystości) pamięci; na razie jednak większość komputerów biurkowych i notebooków
jest narażona na choćby jednobitowe błędy wśród miliardów bitów pamięci.
interfejs zarządzania zasobami i strukturami danych systemu, w tym otwartymi plikami, proce-
sami, wątkami, sekcjami pamięci, licznikami czasowymi, urządzeniami, sterownikami i sema-
forami. Co ciekawe, menedżer obiektów zarządza nawet wyspecjalizowanymi obiektami repre-
zentującymi takie elementy jak transakcje jądra, profile, tokeny bezpieczeństwa czy pulpity
podsystemu Win32. Obiekty urządzeń są powiązane z opisami systemu wejścia-wyjścia, umoż-
liwiają zatem kojarzenie przestrzeni nazw i woluminów systemu plików. Menedżer konfiguracji
wykorzystuje w odwołaniach do gałęzi rejestru obiekt typu Key. Okazuje się, że także sam mene-
dżer obiektów wykorzystuje obiekty (w tym katalogi i dowiązania symboliczne) do zarządzania
przestrzenią nazw NT oraz implementowania obiektów z wykorzystaniem pewnego wspólnego
mechanizmu.
Ujednolicony, zunifikowany interfejs menedżera obiektów ma wiele aspektów. Wszystkie te
obiekty korzystają z tego samego mechanizmu tworzenia, niszczenia i zarządzania systemem
limitów. Procesy trybu użytkownika mogą uzyskiwać dostęp do wszystkich tych obiektów za
pośrednictwem uchwytów. Istnieje nawet zunifikowana konwencja zarządzania odwołaniami
(wskaźnikami) do obiektów z poziomu jądra. Obiektom można nadawać nazwy przestrzeni nazw
NT (zarządzanej przez menedżer obiektów). Obiekty dyspozytora (czyli obiekty rozpoczynające
się od wspólnej struktury danych umożliwiającej sygnalizowanie występowania zdarzeń) mogą
korzystać ze wspólnych interfejsów synchronizacji i powiadomień, np. funkcji WaitForMultiple
Objects. Istnieje też wspólny system zabezpieczeń z listami kontroli dostępu (ACL) stoso-
wany dla obiektów otwieranych według nazw i pozwalający weryfikować uprawnienia procesów
przy okazji każdego dostępu z użyciem uchwytu. Menedżer obiektów oferuje też mechanizmy
ułatwiające programistom kodu trybu jądra diagnozowanie problemów poprzez śledzenie użycia
obiektów.
Kluczem do zrozumienia obiektów systemu NT jest uświadomienie sobie, że obiekt jest po
prostu strukturą danych składowaną w pamięci wirtualnej i dostępną dla trybu jądra. Tego rodzaju
struktury danych często wykorzystuje się do reprezentowania bardziej abstrakcyjnych rozwią-
zań; np. obiekty plików warstwy wykonawczej są tworzone dla każdego otwieranego pliku
systemu plików. Podobnie dla każdego procesu tworzy się reprezentujący go obiekt procesu.
Ponieważ obiekty są po prostu strukturami danych jądra, podczas ponownego uruchomienia
systemu operacyjnego (lub w razie awarii) wszystkie te obiekty są tracone. W czasie uruchamiania
systemu nie istnieją żadne obiekty ani nawet żadne deskryptory typów obiektów. Wszystkie
typy obiektów oraz same obiekty muszą być dynamicznie tworzone przez pozostałe kompo-
nenty warstwy wykonawczej za pośrednictwem interfejsów udostępnianych przez menedżer
obiektów. Po utworzeniu obiektu i nadaniu mu nazwy można się do niego odwoływać za pośred-
nictwem przestrzeni nazw NT. Oznacza to, że przy okazji budowania obiektów podczas urucha-
miania systemu jest budowana także przestrzeń nazw NT.
Strukturę obiektów pokazano na rysunku 11.8. Każdy obiekt zawiera nagłówek z pewnymi
informacjami wspólnymi dla obiektów wszystkich typów. Pola tego nagłówka obejmują nazwę
obiektu, katalog obiektu w ramach przestrzeni nazw NT oraz wskaźnik do deskryptora bezpie-
czeństwa reprezentującego listę kontroli dostępu (ACL) tego obiektu.
Pamięć przydzielana obiektom pochodzi z jednej lub dwóch stert (pul) pamięci utrzymywa-
nej przez warstwę wykonawczą. Istnieją specjalne funkcje pomocnicze (podobne do wywołania
malloc) warstwy wykonawczej, za których pośrednictwem komponenty trybu jądra mogą przy-
dzielać stronicowaną pamięć jądra lub niestronicowaną pamięć jądra. Pamięć, która nie podlega
stronicowaniu, przydziela się tym strukturom danych lub obiektom trybu jądra, które mogą być
potrzebne zadaniom procesora z priorytetem na poziomie 2 lub wyższym. Ten warunek speł-
Rysunek 11.8. Struktura obiektu warstwy wykonawczej zarządzanego przez menedżer obiektów
niają procedury ISR i wywołania DPC (ale nie wywołania APC) oraz sam mechanizm szeregujący
wątki. Okazuje się, że także uchwyt błędów stron wymaga alokowania swoich struktur danych
w pamięci jądra niepodlegającej stronicowaniu, aby uniknąć rekurencji.
Większość operacji przydziału pamięci inicjowanych przez menedżera sterty jądra jest reali-
zowana z wykorzystaniem listy asocjacyjnej LIFO, która grupuje operacje przydziału tej samej
ilości pamięci. Listy LIFO optymalizuje się pod kątem eliminowania blokowania, co ma podnieść
wydajność i skalowalność systemu.
Każdy nagłówek obiektu zawiera pole kosztów limitów, czyli swoistych opłat każdorazowo
pobieranych od procesów przy okazji otwierania danego obiektu. Limity mają wyeliminować
sytuację, w której jeden użytkownik wykorzystuje zbyt wiele zasobów systemowych. Istnieją
odrębne limity dla niestronicowanej pamięci jądra (która wymaga alokacji zarówno adresów
pamięci fizycznej, jak i adresów wirtualnych jądra) oraz stronicowanej pamięci jądra (wykorzy-
stującej adresy wirtualne jądra). Kiedy skumulowane opłaty za któryś z tych typów pamięci
osiągają wyznaczony limit, dalsze żądania alokacji na wniosek danego procesu są odrzucane
wskutek niewystarczających zasobów. Także menedżer pamięci wykorzystuje limity do zarzą-
dzania rozmiarem zbioru roboczego; menedżer wątków wykorzystuje limity do ograniczania
wykorzystania procesora.
Zarówno pamięć fizyczna, jak i wirtualne adresy jądra są niezwykle cennymi zasobami sys-
temowymi. Kiedy okazuje się, że dany obiekt nie jest już potrzebny, należy go usunąć, aby odzy-
skać zajmowaną przez niego pamięć i adresy. Jeśli jednak obiekt zostanie usunięty z pamięci,
mimo że wciąż jest używany, i jeśli jego pamięć zostanie przydzielona innemu obiektowi, odpo-
wiednie struktury danych najprawdopodobniej zostaną uszkodzone. Takie zdarzenie jest dość
prawdopodobne w warstwie wykonawczej systemu Windows, która cechuje się daleko idącą
wielowątkowością i implementuje wiele operacji asynchronicznych (czyli funkcji zwracających
sterowanie przed zakończeniem przetwarzania otrzymanych struktur danych).
Aby uniknąć przedwczesnego zwalniania obiektów wskutek sytuacji wyścigu, menedżer obiek-
tów implementuje mechanizm zliczania odwołań i wprowadza pojęcie wskaźnika będącego przed-
miotem odwołań (ang. referenced pointer). Taki wskaźnik jest potrzebny do uzyskania dostępu
do obiektu za każdym razem, gdy występuje ryzyko jego usunięcia. W zależności od konwencji
przyjętej do danego typu obiektów tylko w określonych sytuacjach istnieje możliwość usunięcia
obiektu przez inny wątek. W innych przypadkach do ochrony obiektu przed przedwczesnym usu-
nięciem wystarczy występowanie blokad, istnienie zależności pomiędzy strukturami danych,
a nawet brak innych wątków dysponujących wskaźnikiem do danego obiektu.
Uchwyty
Odwołania trybu użytkownika do obiektów trybu jądra nie mogą korzystać ze wskaźników, ponie-
waż ich weryfikacja byłaby zbyt trudna. Zamiast tego obiekty trybu jądra muszą być w ten czy
inny sposób nazywane, aby kod użytkownika mógł je jednoznacznie identyfikować w swoich
odwołaniach. System Windows wykorzystuje tzw. uchwyty w odwołaniach do obiektów trybu jądra.
Uchwyty to wartości konwertowane przez menedżer obiektów na odwołania do konkretnych
struktur danych trybu jądra reprezentujących odpowiednie obiekty. Na rysunku 11.9 pokazano
strukturę danych tablicy uchwytów wykorzystywaną w procesie tłumaczenia uchwytów na
wskaźniki do obiektów. Tablicę uchwytów można rozszerzać poprzez dodawanie dalszych warstw
pośrednictwa. Każdy proces dysponuje własną tablicą tego typu (dotyczy to także procesu syste-
mowego obejmującego wszystkie wątki jądra niezwiązane z procesami trybu użytkownika).
ma być dziedziczony przez ewentualne procesy potomne). Wspomniane bity są maskowane przed
użyciem wskaźnika. Drugie słowo zawiera 32-bitową maskę uprawnień. Takie rozwiązanie jest
konieczne, ponieważ uprawnienia są sprawdzane tylko w czasie tworzenia lub otwierania obiektu.
Jeśli np. proces ma tylko prawo odczytu danego obiektu, wszystkie pozostałe bity tej maski będą
zawierały zera, a system operacyjny będzie odrzucał żądania wszystkich operacji na obiekcie inne
niż żądania odczytu.
z takich narzędzi jest program winobj, który można pobrać za darmo ze strony internetowej
www.microsoft.com/technet/sysinternals. Wspomniane narzędzie prezentuje zawartość przestrzeni
nazw obiektów, która zwykle składa się m.in. z katalogów obiektów opisanych w tabeli 11.9.
Nietypowy katalog nazwany \?? zawiera nazwy wszystkich urządzeń zgodne z konwencją obo-
wiązującą w systemie MS-DOS, czyli np. A: dla stacji dysków oraz C: dla pierwszego dysku twar-
dego. Nazwy składowane w tym katalogu w rzeczywistości są dowiązaniami symbolicznymi do
katalogu \Device, w którym przechowuje się właściwe obiekty urządzeń. Nazwę \?? wybrano
przede wszystkim ze względu na porządek alfabetyczny (poprzedza wszystkie inne nazwy), aby
przyspieszyć przeszukiwanie ścieżek rozpoczynających się od liter napędów. Zawartość pozo-
stałych katalogów obiektów nie wymaga dodatkowych wyjaśnień.
Jak już napisano, menedżer obiektów utrzymuje odrębny licznik uchwytów w każdym obiekcie.
Wartość tego licznika nigdy nie przekracza wartości licznika wskaźnika będącego przedmiotem
odwołań, ponieważ dla każdego prawidłowego uchwytu istnieje wskaźnik do obiektu (w odpo-
wiednim wpisie w tablicy uchwytów). Na utrzymywanie odrębnego licznika uchwytów zdecydowano
się dlatego, że wiele typów obiektów wymaga przywracania pierwotnego stanu w momencie
zniknięcia ostatniego odwołania trybu użytkownika, nawet jeśli odpowiednie obiekty nie są
jeszcze gotowe do ostatecznego usunięcia z pamięci.
Dobrym przykładem są obiekty plików reprezentujące egzemplarze otwartych plików.
W systemie Windows pliki można otwierać do wyłącznego dostępu. Kiedy ostatni uchwyt obiektu
pliku jest zamykany, należy jak najszybciej anulować wyłączny dostęp do tego pliku (zamiast
czekać na zwolnienie odwołań jądra, np. w ramach procesu cyklicznego usuwania danych
z pamięci głównej). W przeciwnym razie zamykanie i ponowne otwieranie plików w trybie użyt-
kownika nie będzie działało prawidłowo, ponieważ nieużywane już pliki wciąż będą oznaczane
jako zablokowane do wyłącznego dostępu.
Mimo że menedżer obiektów dysponuje rozbudowanymi mechanizmami jądra umożliwiają-
cymi zarządzanie czasem życia obiektów, ani interfejsy NT API, ani interfejsy Win32 API nie
oferują mechanizmu obsługi uchwytów wykorzystywanych przez wiele współbieżnych wątków
trybu użytkownika. Brak tego mechanizmu powoduje występowanie sytuacji wyścigu i błędów
w aplikacjach wielowątkowych (wskutek zamykania przez wątki uchwytów przed zakończeniem
pracy na tych uchwytach przez pozostałe wątki). W tego rodzaju aplikacjach zdarza się też wielo-
krotne zamykanie tego samego uchwytu lub zamykanie uchwytu wykorzystywanego przez inny
wątek i ponowne otwieranie uchwytu odwołującego się do innego obiektu.
Interfejsy API systemu Windows być może należałoby zaprojektować w taki sposób, aby
wymagały definiowania procedur zamykających na poziomie typów obiektów, zamiast uniwer-
salnej operacji NtClose. Takie rozwiązanie powinno w najgorszym razie ograniczyć częstotliwość
występowania błędów wskutek zamykania niewłaściwych uchwytów przez wątki trybu użyt-
kownika. Innym możliwym rozwiązaniem byłoby umieszczenie w każdym uchwycie dodatko-
wego pola liczby porządkowej (oprócz indeksu wpisu w tablicy uchwytów).
Aby ułatwić twórcom aplikacji wykrywanie tego rodzaju problemów w swoich aplikacjach,
system Windows oferuje tzw. weryfikator aplikacji (ang. application verifier) dostępny dla pro-
gramistów na stronie internetowej Microsoftu. Podobnie jak opisany weryfikator sterowników,
który omówimy w podrozdziale 11.7, weryfikator aplikacji analizuje oprogramowanie pod kątem
zgodności z wieloma regułami. Wykrywanie ewentualnych niezgodności w ramach zwykłych
testów byłyby niezwykle trudne. Istnieje też możliwość włączenia mechanizmu porządkowania
listy wolnych uchwytów metodą FIFO, aby uchwyty nie były wykorzystywane natychmiast po
zamknięciu (zamiast bardziej wydajnej metody LIFO domyślnie stosowanej w tablicach uchwy-
tów). Wyeliminowanie problemu natychmiastowego wykorzystywania wolnych uchwytów zastę-
puje próby użycia niewłaściwego uchwytu próbami użycia zamkniętego uchwytu, czyli żądaniami
nieporównanie łatwiejszymi do wykrycia.
Obiekt urządzenia jest jednym z najważniejszych i uniwersalnych obiektów trybu jądra wcho-
dzących w skład trybu wykonawczego. Typ obiektu jest określany przez menedżera wejścia-wyjścia,
który obok sterowników urządzeń jest głównym komponentem wykorzystującym obiekty urzą-
dzeń. Obiekty urządzeń są ściśle powiązane ze sterownikami, a każdy taki obiekt zwykle dyspo-
nuje łączem do konkretnego obiektu sterownika opisującego, jak uzyskiwać dostęp do procedur
przetwarzających żądania wejścia-wyjścia na odpowiednim urządzeniu.
Obiekty urządzeń reprezentują nie tylko sprzętowe urządzenia, interfejsy i magistrale, ale też
logiczne partycje dyskowe, woluminy dyskowe, a nawet systemy plików i rozszerzenia jądra
(np. filtry antywirusowe). Wiele sterowników urządzeń ma nadawane nazwy, dzięki czemu
można uzyskiwać dostęp do procedur sterowników bez konieczności uzyskiwania otwartych
uchwytów (podobny model obowiązuje w systemie operacyjnym UNIX). Obiekty urządzeń wyko-
rzystamy do prezentacji sposobu stosowania procedury Parse (patrz rysunek 11.11).
1. Kiedy jakiś komponent wykonawczy, np. menedżer wejścia-wyjścia implementujący rdzenne
wywołanie systemowe NtCreateFile, wywołuje procedurę ObOpenObjectByName menedżera
obiektów, przekazuje na jej wejściu ścieżkę (w formacie Unicode) do węzła przestrzeni
nazw NT, np. \??\C:\foo\bar.
2. Menedżer obiektów przeszukuje katalogi i dowiązania symboliczne, by ostatecznie odkryć,
że zapis \??\C: odwołuje się do obiektu urządzenia (typu zdefiniowanego przez menedżera
wejścia-wyjścia). Obiekt urządzenia jest w tej części przestrzeni nazw NT węzłem liścia
zarządzanym przez menedżer obiektów.
3. Menedżer obiektów wywołuje następnie procedurę Parse skojarzoną z tym typem obiektów,
czyli w tym przypadku procedurę IopParseDevice implementowaną przez menedżer wej-
ścia-wyjścia. Na wejściu tej procedury przekazuje nie tylko wskaźnik do obiektu urzą-
dzenia (C:), ale też pozostały łańcuch ścieżki, czyli \foo\bar.
Rysunek 11.11. Kroki wykonywane przez menedżer wejścia-wyjścia i menedżer obiektów podczas
tworzenia lub otwierania pliku i uzyskiwania odpowiedniego uchwytu
4. Menedżer wejścia-wyjścia tworzy pakiet żądania wejścia-wyjścia (od ang. I/O Request
Packet — IRP), alokuje obiekt pliku i wysyła to żądanie do stosu urządzeń wejścia-wyjścia
wskazanego przez obiekt urządzenia odnaleziony wcześniej przez menedżera obiektów.
5. Pakiet IRP jest tak długo przekazywany w dół stosu wejścia-wyjścia, aż osiągnie obiekt
urządzenia reprezentujący system plików C:. Na tym etapie sterowanie jest przeka-
zywane do punktu wejścia obiektu sterownika skojarzonego z obiektem urządzenia na tym
poziomie. W tym przypadku wspomniany punkt wejścia jest wykorzystywany do wyko-
nania operacji CREATE, ponieważ żądanie wejścia-wyjścia dotyczy utworzenia lub otwarcia
pliku nazwanego \foo\bar na danym woluminie.
6. Obiekty urządzeń odkrywane podczas kierowania pakietu IRP do systemu plików repre-
zentują sterowniki filtrów systemu plików, które mogą modyfikować daną operację
wejścia-wyjścia, zanim osiągnie właściwy obiekt urządzenia systemu plików. Te pośrednie
urządzenia zwykle reprezentują rozszerzenia systemu, np. filtry antywirusowe.
7. Obiekt urządzenia systemu plików dysponuje połączeniem z obiektem sterownika syste-
mu plików (przyjmijmy, że mamy do czynienia z systemem NTFS). Oznacza to, że obiekt
sterownika zawiera adres operacji CREATE systemu plików NTFS.
8. System plików NTFS wypełnia obiekt pliku, po czym zwraca go menedżerowi wejścia-
-wyjścia, który z kolei przekazuje otrzymany obiekt do stosu. Tym razem obiekt pliku
przechodzi przez wszystkie urządzenia w górę stosu, aż procedura IopParseDevice zwróci
sterowanie menedżerowi obiektów (patrz podrozdział 11.8).
9. Na tym menedżer obiektów może zakończyć zadanie przeszukiwania przestrzeni nazw.
Menedżer dysponuje już zainicjalizowanym obiektem zwróconym przez procedurę Parse
(tym razem jest to obiekt pliku, nieznaleziony początkowo obiekt urządzenia). W tej sytu-
acji menedżer obiektu może utworzyć uchwyt obiektu pliku w tablicy uchwytów bieżą-
cego procesu i zwrócić ten uchwyt wątkowi wywołującemu.
10. Ostatnim krokiem jest zwrócenie sterowania do kodu trybu użytkownika, czyli w tym
przypadku wywołania CreateFile interfejsu Win32 API, które z kolei zwróci otrzymany
uchwyt właściwej aplikacji.
Komponenty wykonawcze mogą dynamicznie tworzyć nowe typy, korzystając z wywołania
ObCreateObjectType udostępnianego przez menedżer obiektu. Nie istnieje skończona, zamknięta
lista typów obiektów, a w każdym wydaniu systemu Windows zbiór tych typów ulega zmianie.
Wybrane, najczęściej stosowane typy obiektów występujące w systemie Windows opisano
w tabeli 11.10. W dalszej części tego podpunktu krótko omówimy wymienione typy.
Tabela 11.10. Wybrane typy obiektów warstwy wykonawczej zarządzane przez menedżer obiektów
Typ Opis
Proces Proces użytkownika
Wątek Wątek w ramach procesu
Semafor Semafory wykorzystywane do synchronizacji pracy procesów
Muteks Semafor binarny wykorzystywany do wchodzenia w obszar krytyczny
Zdarzenie Obiekt synchronizujący z trwałym stanem (sygnalizowane lub nie)
Port ALPC Mechanizm przekazywania komunikatów pomiędzy procesami
Licznik czasowy Obiekt umożliwiający uśpienie wątku na stały przedział czasowy
Kolejka Obiekt wykorzystywany do powiadamiania o zakończonych asynchronicznych
operacjach wejścia-wyjścia
Otwarty plik Obiekt skojarzony z otwartym plikiem
Token dostępu Deskryptor bezpieczeństwa pewnego obiektu
Profil Struktura danych wykorzystywana podczas profilowania użycia procesora
Sekcja Obiekt wykorzystywany do reprezentowania plików odwzorowywanych w pamięci
Indeks Klucz rejestru (obiekt wykorzystywany do kojarzenia rejestru z przestrzenią nazw
menedżera obiektów)
Katalog obiektów Katalog grupujący obiekty w ramach menedżera obiektów
Dowiązanie symboliczne Odwołanie do innego obiektu menedżera obiektów według ścieżki
Urządzenie Obiekt urządzenia wejścia-wyjścia, np. fizycznego urządzenia lub magistrali bądź
sterownika lub woluminu
Sterownik urządzenia Każdy załadowany sterownik urządzenia ma swój obiekt
Procesy i wątki nie wymagają wyjaśnień. Istnieje po jednym obiekcie dla każdego procesu
i każdego wątku — obiekt zawiera trzy główne właściwości potrzebne do prawidłowego zarzą-
dzania danym procesem lub wątkiem. Trzy kolejne typy obiektów — reprezentujące semafory,
muteksy i zdarzenia — wykorzystuje się do synchronizacji międzyprocesowej. Semafory
i muteksy działają standardowo, ale też oferują pewne elementy dodatkowe (jak wartości mak-
symalne czy limity czasowe). Każde zdarzenie może się znajdować w jednym z dwóch stanów:
sygnalizowanym lub niesygnalizowanym. Jeśli wątek czeka na zdarzenie w stanie sygnalizo-
wanym, jest natychmiast zwalniany. Jeśli zdarzenie znajduje się w stanie niesygnalizowanym,
wątki oczekujące są blokowane do czasu zasygnalizowania tego zdarzenia przez inny wątek —
wówczas wszystkie zablokowane wątki (w przypadku zdarzeń powiadomień) lub pierwszy bloko-
wany wątek (w przypadku zdarzeń synchronizujących) wznawia działanie. Zdarzenie można skon-
figurować w taki sposób, aby po wystąpieniu sygnału oczekiwanego przez wątki automatycznie
wracało do stanu niesygnalizowanego (zamiast pozostawać w stanie sygnalizowanym).
Także obiekty portów, liczników czasowych i kolejek mają związek z komunikacją i synchro-
nizacją. Porty pełnią funkcję kanałów umożliwiających procesom wymianę komunikatów LPC.
Liczniki czasowe pozwalają blokować wykonywanie kodu na określony przedział czasowy. Kolejki
(wewnętrznie określane nazwą KQUEUES) służą powiadamianiu wątków o zakończeniu wykonywania
zainicjowanych wcześniej asynchronicznych operacji wejścia-wyjścia lub o komunikatach ocze-
kujących na portach. (Zaprojektowano je z myślą o zarządzaniu współbieżnym wykonywaniem
kodu na poziomie aplikacji; są wykorzystywane przez wydajne programy wieloprocesorowe,
np. bazy danych SQL).
Obiekty otwartych plików są tworzone w momencie otwierania plików. Pliki, które nie są
otwarte, nie mają swoich obiektów zarządzanych przez menedżera obiektów. Tokeny dostępu są
typowymi obiektami związanymi z bezpieczeństwem. Tokeny identyfikują użytkowników i okre-
ślają zakres ewentualnych uprawnień, którymi ci użytkownicy dysponują. Profile to struktury
wykorzystywane do składowania okresowych próbek liczników wykonywanych wątków, które
pozwalają stwierdzić, w których miejscach kodu programy spędzają najwięcej czasu.
Sekcje wykorzystuje się do reprezentowania obiektów pamięci, które na żądanie aplikacji
mogą być odwzorowywane (przez menedżer pamięci) w ich przestrzeniach adresowych. Obiekty
tego typu rejestrują sekcje pliku (lub pliku stron) reprezentujące strony obiektów pamięci skła-
dowanych na dysku. Klucze reprezentują punkty montowania w przestrzeni nazw rejestru na
poziomie przestrzeni nazw menedżera obiektów. Zwykle istnieje tylko jeden obiekt klucza
(nazwany \REGISTRY) łączący nazwy kluczy i wartości rejestru z przestrzenią nazw NT.
Katalogi obiektów i dowiązania symboliczne mają ściśle lokalny charakter i mieszczą się
w części przestrzeni nazw NT zarządzanej przez menedżer obiektów. Katalogi i dowiązania sym-
boliczne pod wieloma względami przypominają swoje odpowiedniki znane z systemu plików —
katalogi umożliwiają gromadzenie w jednym miejscu powiązanych obiektów, a dowiązania sym-
boliczne umożliwiają stosowanie w jednej części przestrzeni nazw obiektów nazwy odwołującej się
do obiektu w innej części tej przestrzeni.
Dla każdego znanego systemowi operacyjnemu urządzenia istnieje jeden obiekt urządzeń,
zawierających informacje o samych urządzeniach i wykorzystywanych przez system w odwoła-
niach do tych urządzeń, lub wiele takich obiektów. I wreszcie dla każdego załadowanego
sterownika urządzenia istnieje obiekt sterownika w przestrzeni obiektów. Obiekty sterowników
są współdzielone przez wszystkie obiekty urządzeń reprezentujące urządzenia kontrolowane
przez te sterowniki.
Pozostałe obiekty (których nie uwzględniono w tym materiale) realizują bardziej wyspecjali-
zowane cele związane np. z interakcją z transakcjami jądra lub obsługą puli wątków roboczych
podsystemu Win32.
rozwiązanie być może miało na celu uniknięcie konkurowania z systemami tworzonymi tylko
dla wybranych platform, jak w przypadku systemów VMS i Berkeley UNIX dla komputera VAX
firm DEC. Niewykluczone, że przyjęty model wynikał z tego, że nikt w firmie Microsoft nie
potrafił stwierdzić, czy OS/2 zostanie zaakceptowany jako interfejs programowania. Tak czy
inaczej, interfejs OS/2 nie zyskał uznania, a stworzony później interfejs Win32 API (zaprojek-
towany z myślą o stworzeniu jednego interfejsu dla platform Windows 95 i Windows NT) zdomi-
nował ten obszar systemów operacyjnych.
Drugim ważnym aspektem projektu trybu użytkownika systemu Windows jest biblioteka
łączona dynamicznie (ang. Dynamic Link Library — DLL), czyli kod łączony z programami
wykonywalnymi w czasie ich wykonywania (zamiast w czasie kompilacji). Biblioteki współ-
dzielone to nic nowego — są wykorzystywane przez większość współczesnych systemów opera-
cyjnych. W systemie Windows niemal wszystkie biblioteki mają postać bibliotek DLL, począwszy
od biblioteki systemowej ntdll.dll ładowanej do każdego procesu aż po biblioteki wysokopozio-
mowe z popularnymi funkcjami, które w założeniu mają ułatwić programistom aplikacji efek-
tywne używanie istniejącego kodu.
Biblioteki DLL podnoszą efektywność systemu, ponieważ zapewniają możliwość współdzie-
lenia tego samego kodu przez wiele procesów, skracają czas ładowania programów z dysku (poprzez
przechowywanie często stosowanego kodu w pamięci) oraz zwiększają możliwości doskonale-
nia systemu (poprzez aktualizowanie kodu biblioteki systemu operacyjnego bez konieczności
ponownej kompilacji i łączenia wszystkich programów aplikacji korzystających z tego kodu).
Z drugiej strony biblioteki współdzielone wprowadzają problem zarządzania wersjami i podnoszą
złożoność systemu, ponieważ zmiany wprowadzone w takiej bibliotece z myślą o poprawie dzia-
łania jednego programu teoretycznie mogą ujawnić ukryte błędy w innych aplikacjach lub wręcz
uniemożliwić ich działanie wskutek zmian w implementacji — w świecie systemów operacyjnych
Windows wspomniany problem określa się mianem piekła DLL (ang. DLL hell).
Sama koncepcja implementacji bibliotek DLL jest wyjątkowo prosta. Zamiast kompilować
kod bezpośrednio wywołujący procedury należące do tego samego obrazu wykonywalnego, wpro-
wadzono dodatkowy poziom pośrednictwa nazwany tablicą importowanych adresów (ang. Import
Address Table — IAT). Ładowany plik wykonywalny jest przeszukiwany pod kątem zawierania
listy bibliotek DLL, które należy załadować wraz z tym programem (w ten sposób powstaje
graf zależności, ponieważ wymienione na tej liście biblioteki DLL także mogą wskazywać inne
biblioteki DLL potrzebne do prawidłowego działania). Wymagane biblioteki DLL są ładowane,
a tablica IAT jest wypełniana odpowiednimi adresami.
Rzeczywistość jest jednak bardziej skomplikowana. Jednym z problemów jest możliwość
występowania cykli w grafie reprezentującym zależności pomiędzy bibliotekami DLL lub ryzyko
zachowań niedeterministycznych, przez co wygenerowana lista bibliotek DLL do załadowania
tworzy sekwencję, która po prostu nie działa. Co więcej, w systemie Windows biblioteki DLL
mogą wykonywać swój kod już na etapie ładowania do procesu lub tworzenia nowego wątku.
Przyjęto takie rozwiązanie, aby stworzyć możliwość inicjalizacji tych bibliotek oraz alokowania
pamięci na poziomie wątków, jednak wiele bibliotek DLL wykorzystuje procedury dołączania do
kosztownych i czasochłonnych obliczeń. Jeśli któraś z funkcji wywoływanych z poziomu proce-
dury dołączania analizuje listę załadowanych bibliotek DLL, może wystąpić zakleszczenie wyklu-
czające możliwość dalszego wykonywania danego procesu.
Biblioteki DLL wykorzystuje się nie tylko do współdzielenia często używanego kodu. Za
pomocą tego rodzaju bibliotek można stworzyć model goszczenia (ang. hosting) kodu w celu
rozszerzania aplikacji. Przeglądarka Internet Explorer może np. pobierać i dołączać biblioteki
DLL określane mianem kontrolek ActiveX. Także po drugiej stronie połączenia internetowego
serwery WWW mogą ładować dynamiczny kod, aby rozszerzać możliwości udostępnianych
stron WWW. Aplikacje takie jak pakiet biurowy Microsoft Office wykorzystują biblioteki DLL
przekształcające ten pakiet w swoistą platformę do budowy innych aplikacji. Model programowania
COM (od ang. Component Object Model) umożliwia programom dynamiczne odnajdywanie i łado-
wanie kodu napisanego z myślą o stworzeniu określonego interfejsu — w ten sposób powstaje
model goszczenia bibliotek DLL na poziomie procesów (wykorzystywany przez niemal wszystkie
aplikacje modelu COM).
Opisany model dynamicznego ładowania kodu skutkuje dodatkową złożonością systemu opera-
cyjnego, ponieważ zarządzanie wersjami bibliotek nie sprowadza się tylko do dopasowywania
plików wykonywalnych do właściwych wersji bibliotek DLL, ale nierzadko wymaga ładowania
wielu wersji tej samej biblioteki do jednego procesu — w systemie Windows określa się to zja-
wisko mianem wykonywania obok siebie (ang. side-by-side). Pojedynczy program może gościć dwie
różne dynamicznie łączone biblioteki kodu, z których każda może ładować tę samą bibliotekę
systemu Windows, tyle że w różnych wersjach.
Lepszym rozwiązaniem byłoby goszczenie kodu w odrębnych procesach. Z drugiej strony ten
sposób goszczenia powodowałby spadek wydajności, a w wielu przypadkach także wprowadzał
bardziej złożony model programowania. Firma Microsoft stoi więc przed koniecznością opra-
cowania dobrego rozwiązania eliminującego niedociągnięcia dotychczasowego modelu trybu
użytkownika. Wielu programistów oczekuje choćby zbliżenia tego modelu do względnej prostoty
znanej z trybu jądra.
Jednym z powodów mniejszej złożoności trybu jądra (przynajmniej w porównaniu z trybem
użytkownika) jest oferowanie stosunkowo niewielkiej, skończonej liczby punktów rozszerzalności
poza modelem sterowników urządzeń. Zbiór funkcji systemu Windows jest rozszerzany poprzez
pisanie usług trybu użytkownika. Takie rozwiązanie okazało się wystarczająco skuteczne
w przypadku podsystemów i sprawdza się w sytuacji, gdy do systemu dodaje się niewiele nowych
usług (zamiast niezliczonych usług implementujących kompletną osobowość systemu operacyj-
nego). Okazuje się, że różnic funkcjonalnych dzielących usługi implementowane w jądrze od
usług implementowanych w procesach trybu użytkownika jest stosunkowo niewiele. Zarówno
jądro, jak i proces dysponują prywatnymi przestrzeniami adresowymi, w których można
skutecznie chronić struktury danych i przetwarzać otrzymywane żądania.
Istnieje jednak ryzyko istotnych różnic w wydajności usług wchodzących w skład jądra
w porównaniu z wydajnością usług wykonywanych w ramach procesów trybu użytkownika. Na
współczesnym sprzęcie przechodzenie pomiędzy trybem użytkownika a trybem jądra jest dość
czasochłonne; jeszcze dłużej trwa dwukrotna zmiana trybu wskutek przełączania procesów
w jedną i drugą stronę. Innym ważnym problemem jest też niska przepustowość komunikacji
międzyprocesowej.
Kod trybu jądra może (oczywiście z zachowaniem daleko idącej ostrożności) uzyskiwać dostęp
do danych pod adresami trybu użytkownika przekazywanymi w formie parametrów wywołań
systemowych. Na poziomie usług trybu użytkownika odpowiednie dane należy albo skopiować do
procesu usługi, albo znaleźć rozwiązanie umożliwiające odwzorowywanie pamięci w obu kierun-
kach (w systemie Windows odpowiednie zadania automatycznie realizuje mechanizm wywołań
ALPC).
Niewykluczone, że w przyszłości koszty sprzętowe związane z przechodzeniem pomiędzy
przestrzeniami adresowymi i trybami ochrony będą mniejsze; być może nawet będą nieistotne
dla wydajności systemu. W projekcie nazwanym Singularity i realizowanym przez ośrodek
Microsoft Research [Fandrich et al., 2006] wykorzystano techniki środowiska wykonawczego
(podobne do tych znanych z platform C# i Java) do całkowitego przeniesienia zadań związanych
z zarządzaniem trybami ochrony na poziom oprogramowania. W takim przypadku nie jest konieczne
sprzętowe przełączanie adresów pamięci ani trybów ochrony.
System Windows niemal na każdym kroku wykorzystuje procesy usług trybu użytkownika
do rozszerzania zakresu oferowanych funkcji. Niektóre z tych usług są ściśle związane z dzia-
łaniem komponentów trybu jądra — np. program lsass.exe jest lokalną usługą uwierzytelniania,
która zarządza zarówno obiektami tokenów reprezentującymi tożsamości użytkowników, jak
i kluczami szyfrującymi wykorzystywanymi przez system plików. Menedżer plug and play trybu
użytkownika odpowiada za identyfikację, instalację i wymuszanie na jądrze ładowania właściwych
sterowników dla wykrywanych urządzeń sprzętowych. Także wiele aplikacji tworzonych przez
niezależnych producentów, w tym programy antywirusowe i programy do cyfrowego zarządzania
prawami (DRM), implementuje się w formie kombinacji sterowników trybu jądra i usług trybu
użytkownika.
Menedżer zadań (taskmgr.exe) systemu Windows oferuje zakładkę identyfikującą usługi
aktualnie działające w systemie. Jak łatwo zauważyć, wiele usług sprawia wrażenie wykonywa-
nych w ramach tego samego procesu (svchost.exe). Okazuje się, że system Windows wykorzy-
stuje ten proces do wykonywania wielu swoich usług startowych, aby w ten sposób skrócić czas
uruchamiania systemu. Usługi można łączyć w ramach jednego procesu, dopóki ich wspólne ope-
rowanie na podstawie tych samych uprawnień jest bezpieczne.
W ramach każdego procesu usług współdzielonych poszczególne usługi są ładowane w formie
bibliotek DLL. Poszczególne usługi zwykle korzystają z jednej puli wątków (taką możliwość stwa-
rza mechanizm pul wątków podsystemu Win32), zatem wykonywanie wszystkich tych usług
wymaga minimalnej liczby wątków.
Usługi często są źródłem luk w zabezpieczeniach systemu operacyjnego, ponieważ w wielu
przypadkach oferują możliwość zdalnego dostępu (w zależności od ustawień firewalla TCP/IP
i bezpieczeństwa IP), a nie wszyscy programiści piszący usługi zachowują należytą ostrożność.
Do najczęstszych błędów należy brak mechanizmów weryfikujących parametry wejściowe
i bufory przekazywane za pośrednictwem wywołań RPC.
Liczba usług stale wykonywanych w systemie Windows rośnie w zastraszającym tempie.
Co ciekawe, bardzo niewielka część tych usług otrzymuje jakiekolwiek żądania, a kiedy już żądanie
ma miejsce, często okazuje się, że ktoś próbuje odnaleźć i wykorzystać lukę w zabezpieczeniach,
zamiast skorzystać z oferowanych funkcji. Właśnie dlatego coraz więcej usług systemu Win-
dows jest domyślnie wyłączanych (szczególnie w nowych wersjach Windows Server).
systemowymi trybu użytkownika, tzw. blokiem PEB (od ang. Process Environment Block). Blok
PEB obejmuje listę ładowanych modułów (plików wykonywalnych EXE i bibliotek DLL), pamięć
z łańcuchami parametrów środowiskowych, bieżący katalog roboczy, dane niezbędne do zarzą-
dzania stertami procesu oraz mnóstwo wyspecjalizowanych elementów dodawanych przez lata
do podsystemu Win32.
Wątki są abstrakcją jądra systemu Windows umożliwiającą bardziej skuteczne szeregowanie
zadań procesora. Priorytety przypisywane wątkom zależą od priorytetów odpowiednich procesów.
Wątki można też ściśle wiązać (ang. affinitize) z określonymi procesorami. Takie rozwiązanie
ułatwia programom współbieżnym działającym na wielu procesorach efektywne dzielenie zadań
pomiędzy te procesory. Dla każdego wątku istnieją dwa odrębne stosy wywołań — jeden dla
pracy w trybie użytkownika i jeden dla pracy w trybie jądra. Każdy wątek dysponuje też wła-
snym blokiem TEB (od ang. Thread Environment Block), w którym przechowuje się właściwe
temu wątkowi dane trybu użytkownika, w tym zawartość pamięci lokalnej wątku (ang. Thread
Local Storage — TLS) oraz pola podsystemu Win32, opis języka i ustawień regionalnych i inne
wyspecjalizowane pola dodawane przez najróżniejsze mechanizmy.
Oprócz wspomnianych już bloków PEB i TEB istnieje jeszcze inna struktura danych, którą tryb
jądra współdzieli z każdym procesem — tzw. dane współdzielone użytkownika (ang. user shared
data). Dane współdzielone użytkownika to strona pamięci zapisywalna przez jądro, ale dostępna
tylko do odczytu dla każdego procesu trybu użytkownika. Opisywana struktura obejmuje wiele
różnych wartości utrzymywanych przez jądro, jak czas reprezentowany w różnych formach,
informacje o wersjach, dane o ilości pamięci fizycznej oraz wiele wspólnych flag wykorzysty-
wanych przez różne komponenty trybu użytkownika (komponenty modelu COM, usługi termi-
nala oraz debugery). Stosowanie dostępnej tylko do odczytu strony współdzielonej ma na celu
wyłącznie optymalizację działania procesów, które równie dobrze mogłyby uzyskiwać te wartości
za pośrednictwem wywołań systemowych trybu jądra. Wywołania systemowe są jednak dużo
bardziej kosztowne od pojedynczych odwołań do pamięci, zatem użycie struktury z polami utrzy-
mywanymi przez system (reprezentującymi np. godzinę) jest w pełni uzasadnione. Pozostałe
pola, w tym bieżące strefy czasowe, zmieniają się na tyle rzadko, że kod korzystający z tego
rodzaju danych powinien sprawdzać interesujące go wartości tylko po to, by wykrywać ewen-
tualne zmiany. Podobnie jak w przypadku wielu sztuczek w kodzie dotyczących wydajności, kod
jest trochę brzydki, ale działa.
Procesy
Procesy są tworzone z użyciem obiektów sekcji, z których każdy opisuje pojedynczy obiekt
pamięci składowany w pliku na dysku. Podczas tworzenia procesu odpowiedni proces tworzący
otrzymuje uchwyt nowego procesu. Za pośrednictwem tego uchwytu proces tworzący może mody-
fikować nowy proces poprzez odwzorowanie sekcji, przydział pamięci wirtualnej, zapisanie para-
metrów i danych środowiskowych, powielenie deskryptorów plików w tablicy uchwytów oraz
utworzenie wątków. Mamy więc do czynienia z zupełnie innym modelem tworzenia procesów
od tego, który obowiązuje w systemie UNIX — odmienne modele dobrze odzwierciedlają róż-
nice projektowe dzielące systemy UNIX i Windows.
Jak już wspomniano w podrozdziale 11.1, system UNIX zaprojektowano z myślą o 16-bito-
wych komputerach jednoprocesorowych z mechanizmem wymiany wykorzystywanym do współ-
dzielenia pamięci pomiędzy procesami. W takich systemach wykorzystywanie procesów w roli
jednostek przetwarzania współbieżnego i stosowanie takich operacji jak fork do tworzenia nowych
procesów było wprost doskonałym rozwiązaniem. Wykonywanie nowych procesów w systemach
Zadania i włókna
System Windows może grupować procesy w ramach tzw. zadań (ang. jobs). Zaprojektowano ją
z myślą o grupowaniu procesów, aby stosować wspólne ograniczenia dla wątków składających
się na te procesy (aby np. ograniczać ilość dostępnych zasobów w formie wspólnego limitu lub
zastrzeżonego tokenu uniemożliwiającego wątkom uzyskiwanie dostępu do zbyt wielu obiektów
systemowych). Jedną z najważniejszych cech zadań (w kontekście zarządzania zasobami) jest
włączanie do zadania wszystkich wątków tworzonych przez proces od momentu jego dodania
do zadania. Od tej reguły nie ma wyjątków. Jak nietrudno się domyślić, zadania zaprojektowano
z myślą o sytuacjach zbliżonych raczej do przetwarzania wsadowego niż pracy interakcyjnej.
W systemie Modern Windows zadania są używane do grupowania procesów, za których
pośrednictwem działają nowoczesne aplikacje. System operacyjny musi mieć możliwość zidenty-
fikowania procesów składających się na działającą aplikację, by można było zarządzać aplikacją
w imieniu użytkownika.
Na rysunku 11.12 pokazano relacje łączące zadania, procesy, wątki i włókna (ang. fibers).
Zadania zawierają procesy, procesy zawierają wątki, ale już wątki nie zawierają włókien. Relacje
łączące wątki z włóknami zwykle cechują się licznością wiele do wielu.
Włókno jest tworzone poprzez alokowanie stosu i odpowiedniej struktury danych w trybie
użytkownika (na potrzeby rejestrów i danych skojarzonych z danym włóknem). Wątki są konwer-
towane na włókna, ale też istnieje możliwość tworzenia włókien niezależnie od wątków. Tego
rodzaju włókno nie jest wykonywane do momentu, w którym działający wątek wprost wywoła
Rysunek 11.12. Relacje łączące zadania, procesy, wątki i włókna. Zadania i włókna są
opcjonalne; nie wszystkie procesy należą do zadań i nie wszystkie zawierają włókna
procedurę SwitchToFiber dla tego włókna. Wątki teoretycznie mogą podejmować przełączania do
już wykonywanych włókien, zatem programista musi stworzyć mechanizm synchronizujący, aby
zapobiec takim próbom.
Największą zaletą włókien są nieporównanie mniejsze koszty przełączania pomiędzy włók-
nami w porównaniu z operacjami przełączania pomiędzy wątkami. Przełączanie wątków wymaga
wejścia i opuszczenia trybu jądra. Koszty przełączania włókien sprowadzają się do zapisania
i odtworzenia kilku rejestrów bez konieczności zmiany trybu działania.
Chociaż włókna są szeregowane wspólnie, w razie istnienia wielu wątków szeregujących
włókna należy zachować daleko idącą ostrożność, aby uniknąć wzajemnego wpływu tych włó-
kien na swoje działanie. Aby uprościć interakcję pomiędzy wątkami a włóknami, często tworzy
się tylko tyle wątków, ile procesorów zainstalowano w danym systemie, i kojarzy się te wątki
z odrębnymi podzbiorami dostępnych procesorów lub wręcz pojedynczymi procesorami.
Każdy wątek może wówczas korzystać z określonego podzbioru włókien — relacje jeden do
wielu pomiędzy wątkami a włóknami znacznie upraszcza ich synchronizację. Warto jednak pamię-
tać, że nawet wówczas włókna stwarzają pewne trudności. Większość bibliotek podsystemu Win32
nie jest w żaden sposób przystosowana do operowania na włóknach, a aplikacje próbujące korzy-
stać z włókien, tak jakby były pełnoprawnymi wątkami, powodują rozmaite błędy. Także jądro
nie dysponuje żadną wiedzą o włóknach, zatem próba wejścia włókna w tryb jądra może powo-
dować wstrzymanie wykonywania odpowiedniego wątku — jądro może wówczas przydzielić
czas procesora dowolnemu wątkowi, uniemożliwiając mu wykonywanie pozostałych włókien.
Właśnie dlatego włókna stosuje się dość rzadko, z wyjątkiem kodu przenoszonego z innych
systemów, które narzucają stosowanie rozwiązań typowych dla włókien.
Windows sformalizowano to podejście w postaci puli wątków Win32 — zestawu interfejsów API
do automatycznego zarządzania pulami wątków i wysyłania do nich zadań.
Pule wątków nie są rozwiązaniem idealnym, ponieważ gdy w trakcie realizacji zadania wątek
z jakiegoś powodu się zablokuje, nie może przejść do kolejnego zadania. W związku z tym pule
wątków nieuchronnie spowodują stworzenie większej liczby wątków, niż jest dostępnych proce-
sorów, tak by były dostępne wątki do zaplanowania nawet wtedy, gdy inne zostały zablokowane.
Pule wątków są zintegrowane z wieloma powszechnymi mechanizmami synchronizacji, jak ocze-
kiwanie na zakończenie operacji wejścia-wyjścia lub zablokowanie aż do sygnalizacji zdarzenia
jądra. Synchronizację można wykorzystać jako wyzwalacz do umieszczenia zadania w kolejce,
by zadanie nie było przydzielane do wątku, zanim nie uzyska gotowości do uruchomienia.
W implementacji puli wątków wykorzystywane są te same mechanizmy kolejek, które służą
do synchronizacji zakończenia operacji wejścia-wyjścia, wraz z fabryką wątków trybu jądra wpro-
wadzającą do procesu więcej wątków, aby dostępne procesory były zajęte. Niewielkie zadania
istnieją w wielu aplikacjach, ale w szczególności w tych, które dostarczają usług w modelu
przetwarzania klient-serwer, gdy od klientów do serwerów przesyłany jest strumień żądań.
Wykorzystanie puli wątków dla tych scenariuszy poprawia wydajność systemu, ponieważ zmniej-
sza obciążenie związane z tworzeniem wątków i przenosi decyzję o sposobie zarządzania wąt-
kami w puli z aplikacji do systemu operacyjnego.
To, co programiści widzą jako pojedynczy wątek systemu Windows, to w istocie są dwa
wątki: jeden działający w trybie jądra i drugi działający w trybie użytkownika. Identyczny model
jest wykorzystywany w systemie UNIX. Każdemu z tych wątków są przydzielane własny stos
i własna pamięć, co pozwala zapisać zawartość rejestrów, gdy wątek nie działa. Dwa wątki
sprawiają wrażenie jednego, ponieważ nie działają w tym samym czasie. Wątek użytkownika
działa jako rozszerzenie wątku jądra — jest uruchamiany tylko wtedy, gdy wątek jądra się do
niego przełączy, powracając z trybu jądra do trybu użytkownika. Gdy wątek użytkownika, chcąc
wykonać wywołanie systemowe, napotka błąd strony lub zostanie wywłaszczony, system przej-
dzie do trybu jądra i przełączy się z powrotem do odpowiedniego wątku jądra. Zwykle nie jest
możliwe przełączenie się pomiędzy wątkami użytkownika bez uprzedniego przełączenia do odpo-
wiedniego wątku jądra, przełączenia do nowego wątku jądra, a następnie przełączenie do zwią-
zanego z nim wątku użytkownika.
W większości przypadków różnica między wątkami użytkownika i jądra jest przezroczysta
dla programisty. Jednak w systemie Windows 7 firma Microsoft dodała mechanizm o nazwie UMS
(od ang. User-Mode Scheduling), który ujawnia różnicę. Mechanizm UMS przypomina mecha-
nizmy stosowane w innych systemach operacyjnych, takie jak aktywacje programu szeregującego
(ang. scheduler activations). Można go wykorzystać do przełączania między wątkami użytkow-
nika bez uprzedniej konieczności wejścia do jądra. Dzięki temu można uzyskać korzyści typowe
dla włókien, ale z dużo lepszą integracją z interfejsem API Win32 ze względu na wykorzystanie
rzeczywistych wątków Win32.
Implementacja mechanizmu UMS składa się z trzech kluczowych elementów:
1. Przełączanie trybu użytkownika: można tak napisać program szeregujący trybu użytkownika,
aby można było przełączać się pomiędzy wątkami użytkownika bez wchodzenia do jądra.
Jeśli wątek użytkownika wejdzie do trybu jądra, mechanizm UMS znajdzie odpowiedni
wątek jądra i natychmiast się do niego przełączy.
2. Ponowne wejście do programu szeregującego trybu użytkownika: gdy wykonywanie wątku
jądra zablokuje się w celu oczekiwania na dostępność zasobu, mechanizm UMS realizuje
przełączenie do specjalnego wątku użytkownika i uruchamia program szeregujący trybu
użytkownika, aby można było zaplanować działanie na bieżącym procesorze innego wątku
użytkownika. Dzięki temu, jeśli któryś z wątków bieżącego procesu się zablokuje, bieżący
proces może kontynuować korzystanie z bieżącego procesora przez cały przydzielony
czas i nie musi czekać w kolejce za innymi procesami.
3. Realizacja wywołań systemowych: kiedy zablokowany wątek jądra ostatecznie zakończy
działanie, w kolejce programu szeregującego trybu użytkownika jest umieszczane powia-
domienie. Dzięki niemu możliwe jest przełączenie do odpowiedniego wątku użytkownika
przy następnej decyzji szeregowania.
Mechanizm UMS nie zawiera programu szeregującego trybu użytkownika jako części systemu
Windows. UMS spełnia rolę niskopoziomowego mechanizmu do wykorzystania przez biblioteki
wykonawcze używane przez języki programowania i serwer aplikacji do implementacji lekkiego
modelu wątków, który nie koliduje z szeregowaniem wątków na poziomie jądra. Te biblioteki
wykonawcze zwykle implementują program szeregujący trybu użytkownika najlepiej przystoso-
wany do jego środowiska. Krótkie podsumowanie tych abstrakcji zawarto w tabeli 11.11.
Wątki
Każdy proces początkowo dysponuje tylko jednym wątkiem, ale istnieje możliwość dynamicz-
nego tworzenia nowych wątków. Wątki stanowią podstawę mechanizmu szeregowania zadań
procesora, ponieważ system operacyjny zawsze wybiera do wykonania wątek, nie proces. Właśnie
dlatego każdy wątek ma przypisany stan szeregowania (gotowy, wykonywany, zablokowany itp.),
mimo że same procesy nie mają przypisywanych tego rodzaju stanów. Wątki mogą być tworzone
dynamicznie za pomocą wywołania podsystemu Win32 określającego adres (w ramach przestrzeni
adresowej odpowiedniego procesu), od którego należy rozpocząć wykonywanie tego wątku.
Każdy wątek ma przypisany identyfikator wątku wybrany z tej samej przestrzeni co iden-
tyfikatory procesów, zatem jeden identyfikator nigdy nie może być jednocześnie wykorzysty-
wany zarówno przez proces, jak i wątek. Identyfikatory procesów i wątków są wielokrotnościami
liczby cztery, ponieważ za ich przydzielanie odpowiada warstwa wykonawcza wykorzystująca
specjalną tablicę uchwytów tworzoną na potrzeby alokowanych identyfikatorów. System wyko-
rzystuje skalowalny mechanizm zarządzania uchwytami pokazany na rysunkach 11.9 i 11.10.
Wspomniana tablica uchwytów nie dysponuje odwołaniami do obiektów, ale zawiera pola wskaźni-
ków do procesów i wątków, które znacznie skracają czas przeszukiwania procesów i wątków
Użytkownicy systemu UNIX tradycyjnie dysponują szerszą wiedzą i z reguły doskonale rozu-
mieją znaczenie takich elementów jak zmienne PATH. Z drugiej strony system Windows odzie-
dziczył wiele koncepcji projektowych jeszcze po systemie MS-DOS.
Powyższe zestawienie jest o tyle niemiarodajne, że podsystem Win32 jest w istocie opako-
waniem (stworzonym na potrzeby trybu użytkownika) wokół rdzennego środowiska wykonawczego
procesów NT — jest więc w pewnym sensie odpowiednikiem biblioteki system, której funkcje
opakowują wywołania fork i exec systemu UNIX. Właściwe wywołania systemowe NT odpo-
wiedzialne za tworzenie procesów i wątków, czyli odpowiednio NtCreateProcess i NtCreateThread,
są dużo prostsze od swoich odpowiedników w interfejsie Win32 API. Do najważniejszych para-
metrów wywołania NT tworzącego proces należą uchwyt sekcji reprezentującej plik wykonywalny
uruchamianego programu, flaga określająca, czy nowy proces powinien dziedziczyć uchwyty po
procesie tworzącym, oraz parametry modelu bezpieczeństwa. Za wszystkie pozostałe szczegóły
związane z ustawianiem parametrów środowiskowych i tworzeniem początkowego wątku odpo-
wiada kod trybu użytkownika, który może wykorzystać otrzymany uchwyt nowego procesu do
bezpośredniego operowania na jego wirtualnej przestrzeni adresowej.
Aby zapewnić zgodność z podsystemem POSIX, rdzenne wywołanie tworzące proces oferuje
możliwość konstruowania nowego procesu poprzez kopiowanie wirtualnej przestrzeni adresowej
innego procesu (zamiast odwzorowywania obiektu sekcji dla nowego programu). Wykorzystano
ten mechanizmy wyłącznie do zaimplementowania wywołania fork podsystemu POSIX; pod-
system Win32 nie korzysta z tej możliwości. Ponieważ podsystem POSIX nie jest już dostar-
czany z systemem Windows, powielanie procesów ma niewielkie znaczenie — choć czasami
programiści korzystają z tego mechanizmu do specjalnych celów — na zasadzie podobnej do wyko-
rzystywania wywołania fork bez wywołania exec w systemie UNIX.
Wywołanie tworzące wątek przekazuje kontekst procesora dla nowego wątku (w tym wskaź-
nik do stosu i wskaźnik do pierwszego rozkazu), szablon bloku TEB oraz flagę określającą, czy
nowy wątek ma być wykonywany od razu, czy należy go wprowadzić w stan wstrzymania
(w oczekiwaniu na wywołanie funkcji NtResumeThread dla jego uchwytu). Za tworzenie stosu trybu
użytkownika oraz umieszczenie na tym stosie parametrów argv i argc odpowiada kod trybu użyt-
kownika wywołujący (dla uchwytu danego procesu) rdzenne interfejsy zarządzania pamięcią pod-
systemu NT.
W wydaniu Windows Vista wprowadzono nowy, rdzenny interfejs API dla procesów, prze-
niesiono w ten sposób wiele kroków wykonywanych wcześniej w trybie użytkownika do war-
stwy wykonawczej trybu jądra i połączono operację tworzenia procesu z operacją tworzenia
pierwszego (początkowego) wątku tego procesu. Zdecydowano się na taką zmianę, aby umoż-
liwić stosowanie granic procesów w roli granic bezpieczeństwa. W normalnych okolicznościach
wszystkie procesy tworzone przez użytkownika darzy się takim samym zaufaniem. Poziom tego
zaufania zależy od użytkownika (reprezentowanego przez token). Wywołanie NtCreateUserProcess
pozwala także procesom na tworzenie granic zaufania, ale to oznacza, że proces tworzący nie ma
wystarczających praw w odniesieniu do uchwytu nowego procesu, pozwalających na zaimple-
mentowanie w trybie użytkownika szczegółów tworzenia procesów, które należą do różnych
środowisk zaufania. Podstawowym zastosowaniem procesów w różnych granicach zaufania
(nazywanych procesami chronionymi) jest wsparcie dla formy zarządzania prawami cyfrowymi —
mechanizmu ochrony materiałów chronionych prawami autorskimi przed nieuprawnionym użyt-
kowaniem. Oczywiście procesy chronione zabezpieczają jedynie przed atakami przeciwko chro-
nionej zawartości w trybie użytkownika i nie mogą zapobiec atakom w trybie jądra.
Komunikacja międzyprocesowa
Wątki mogą się komunikować na wiele różnych sposobów, w tym za pośrednictwem potoków,
potoków nazwanych, skrytek pocztowych, zdalnych wywołań procedur i plików współdzielonych.
Potoki działają w jednym z dwóch trybów (trybie bajtowym i trybie komunikatów) wybieranym
w czasie tworzenia. Potoki trybu bajtowego działają tak samo jak potoki znane z systemu UNIX.
Potoki komunikatów są podobne, ale zachowują podział na komunikaty, zatem dane umieszczone
w potoku przez cztery operacje zapisu po 128 bajtów zostaną odczytane w formie 128-bajtowych
komunikatów, nie jednego komunikatu obejmującego 512 bajtów (co może mieć miejsce w przy-
padku potoków trybu bajtowego). System Windows obsługuje też potoki nazwane, które działają
w takich samych trybach jak zwykłe potoki. W przeciwieństwie do zwykłych potoków, potoki
nazwane można wykorzystywać do komunikacji za pośrednictwem sieci.
Skrytki pocztowe (ang. mailslots) są rozwiązaniem zaczerpniętym z systemu operacyjnego
OS/2 i zaimplementowanym w systemie Windows tylko dla zapewnienia zgodności. Skrytki
pocztowe pod wieloma względami przypominają potoki, ale też występują pewne różnice. Skrytki
pocztowe są jednokierunkowe, podczas gdy potoki oferują możliwość komunikacji dwukierun-
kowej. Skrytki można co prawda wykorzystywać do komunikacji sieciowej, ale nie gwarantują
dostarczania wysyłanych danych. I wreszcie skrytki umożliwiają procesowi nadawcy rozsyłanie
komunikatu do wielu odbiorców (nie tylko do jednego odbiorcy). Zarówno skrytki pocztowe, jak
i potoki nazwane zaimplementowano w systemie Windows w formie systemów plików (zamiast
funkcji warstwy wykonawczej). Takie rozwiązanie umożliwia dostęp do tych mechanizmów za
pośrednictwem sieci z wykorzystaniem istniejących protokołów zdalnych systemów plików.
Gniazda (ang. sockets) przypominają potoki, tyle że stosuje się je raczej do łączenia procesów
na różnych komputerach. Jeśli np. jeden proces zapisuje dane w gnieździe, inny proces działający
na zdalnym komputerze może te dane odczytać z tego gniazda. Gniazda można wykorzystywać
także do łączenia procesów na tym samym komputerze, jednak z uwagi na wyższe koszty niż
w przypadku potoków, stosuje się je raczej w komunikacji sieciowej. Gniazda początkowo zapro-
jektowano z myślą o systemie Berkeley UNIX, jednak z czasem zaproponowana implementacja
stała się powszechnie stosowanym mechanizmem komunikacji międzyprocesowej. Część kodu
i struktur danych stworzonych na potrzeby systemu Berkeley UNIX występuje w niezmienionej
formie we współczesnych wersjach systemu Windows (co firma Microsoft oficjalnie potwierdza
w materiałach poświęconych tym wydaniom).
Wywołania RPC (od ang. Remote Procedure Calls) umożliwiają procesowi A wywoływanie
procedury procesu B w przestrzeni adresowej procesu B, ale w imieniu procesu A i w celu
zwrócenia wyniku temu procesowi. Standard RPC nakłada wiele ograniczeń na parametry stoso-
wane w zdalnych wywołaniach procedur. Nie jest możliwe (i nie miałoby sensu) np. przekazy-
wanie wskaźnika do innego procesu, ponieważ struktury danych muszą być pakowane i prze-
syłane niezależnie od procesów. Wywołania RPC zwykle implementuje się w formie abstrakcji
ponad warstwą transportową. W systemie Windows na warstwę transportową mogą się składać
gniazda TCP/IP, potoki nazwane lub wywołania ALPC. Wywołania ALPC (od ang. Advanced
Local Procedure Call) to mechanizm przekazywania komunikatów w warstwie wykonawczej trybu
jądra. Mechanizm ALPC zoptymalizowano pod kątem komunikacji procesów na komputerze
lokalnym i nie oferuje on możliwości komunikacji za pośrednictwem sieci. Podstawowy pro-
jekt tego rodzaju wywołań przewiduje wysyłanie komunikatów generujących odpowiedzi, czyli
w praktyce implementuje lekką wersję zdalnych wywołań procedur — ponad tą implementacją
można konstruować pakiety RPC oferujące bogatszy zbiór funkcji niż ten dostępny dla wywołań
Synchronizacja
Procesy mogą też korzystać z rozmaitych typów obiektów synchronizujących. System Win-
dows oferuje nie tylko liczne mechanizmy komunikacji międzyprocesowej, ale też wiele różnych
mechanizmów synchronizacji procesów, jak semafory, muteksy, obszary krytyczne czy zdarzenia.
Wszystkie te mechanizmy działają na poziomie wątków, nie procesów, zatem w razie zabloko-
wania wykonywania wątku w oczekiwaniu na zmianę stanu semafora pozostałe wątki tego samego
procesu (jeśli istnieją) nie są blokowane i mogą kontynuować pracę.
Do tworzenia semaforów służy funkcja CreateSemaphore interfejsu Win32 API, która może nie
tylko zainicjalizować semafor z wykorzystaniem otrzymanej wartości, ale też zdefiniować war-
tość maksymalną nowego semafora.
Semafory są obiektami trybu jądra i jako takie mają przypisane deskryptory bezpieczeństwa
i uchwyty. Uchwyt semafora można powielić (za pomocą funkcji DuplicateHandle) i przekazać
innemu procesowi, aby stworzyć możliwość synchronizacji wielu procesów z wykorzystaniem
tego samego semafora. Semaforowi można też nadać nazwę przestrzeni nazw Win32; istnieje
także możliwość ustawienia dla semafora listy kontroli dostępu (ACL), aby chronić go przed nie-
uprawnionym użyciem. W pewnych sytuacjach współdzielenie semafora reprezentowanego przez
nazwę jest prostsze niż powielanie jego uchwytu.
Dla semaforów istnieją standardowe wywołania up i down, tyle że reprezentowane przez trudne
do zapamiętania nazwy ReleaseSemaphore (up) oraz WaitForSingleObject (down). Istnieje też możli-
wość określenia przekazania na wejściu procedury WaitForSingleObject limitu czasowego, aby
wątek wywołujący ostatecznie był zwalniany, nawet jeśli odpowiedni semafor będzie miał war-
tość 0 (warto jednak pamiętać, że limity czasowe ponownie wprowadzają ryzyko występowania
wyścigu). Procedury WaitForSingleObject i WaitForMultipleObjects to dość popularne interfejsy
wykorzystywane do oczekiwania na obiekty dyspozytora (omówione w podrozdziale 11.3). Chociaż
teoretycznie można by opakować jednoobiektową wersję tych API, z zastosowaniem prostszych,
bardziej zrozumiałych nazw, musimy mieć na uwadze, że wiele wątków korzysta z wersji wie-
loobiektowej umożliwiającej oczekiwanie na różne rodzaje obiektów synchronizujących oraz na
takie zdarzenia jak przerwanie procesu lub wątku, zakończenie wykonywania operacji wejścia-
-wyjścia czy występowanie komunikatów oczekujących w gniazdach lub portach.
Inne obiekty trybu jądra wykorzystywane do synchronizacji to muteksy, które z uwagi na
brak liczników są prostsze od semaforów. Muteksy stanowią w istocie blokady z interfejsem API
obejmującym funkcje WaitForSingleObject (blokującą) oraz ReleaseMutex (odblokowującą). Podobnie
jak uchwyty semaforów, uchwyty muteksów można powielać i przekazywać pomiędzy procesami,
aby zapewnić wątkom należącym do różnych procesów uzyskiwanie dostępu do tego samego
muteksa.
Trzeci mechanizm synchronizacji, który określa się mianem sekcji krytycznych (ang. critical
sections), implementuje ideę obszarów krytycznych. Sekcje krytyczne pod wieloma względami
przypominają muteksy systemu Windows z tą różnicą, że mają charakter lokalny względem
przestrzeni adresowej wątku tworzącego. Ponieważ sekcje krytyczne nie są obiektami trybu
jądra, nie mają przypisywanych wprost uchwytów ani deskryptorów bezpieczeństwa i jako takie
nie mogą być przekazywane pomiędzy procesami. Do blokowania i odblokowywania sekcji kry-
tycznych służą odpowiednio wywołania EnterCriticalSection i LeaveCriticalSection. Ponieważ
wymienione funkcje API są inicjowane w przestrzeni użytkownika i ograniczają się do korzy-
stania z wywołań jądra tylko w razie konieczności zablokowania wątków, działają dużo szybciej
niż odpowiednie procedury muteksów. Sekcje krytyczne optymalizuje się pod kątem blokad
pętlowych (w systemach wieloprocesorowych) z możliwie rzadkim wykorzystaniem obiektów
synchronizacji jądra (tylko w razie konieczności). W wielu aplikacjach zdarzenia współzawod-
nictwa o dostęp do sekcji krytycznych zdarza się na tyle rzadko lub przebywanie w sekcjach
krytycznych trwa na tyle krótko, że alokowanie obiektu synchronizacji jądra nigdy nie jest
konieczne. To z kolei przekłada się na znaczne oszczędności w wymiarze wykorzystywanej
pamięci jądra.
Ostatnim mechanizmem synchronizacji, któremu warto poświęcić trochę uwagi i który korzy-
sta z obiektów trybu jądra, są zdarzenia (ang. events). Jak już wspomniano, istnieją dwa rodzaje
zdarzeń: zdarzenia powiadomień i zdarzenia synchronizacji. Każde zdarzenie może się znajdo-
wać w jednym z dwóch stanów: sygnalizowanym lub niesygnalizowanym. Wątek może rozpocząć
oczekiwanie na przejście zdarzenia w stan sygnalizowany, wywołując procedurę WaitForSingle
Object. Jeśli inny wątek sygnalizuje zdarzenie za pomocą wywołania SetEvent, dalsze działanie
zależy od typu tego zdarzenia. W przypadku zdarzenia powiadomień wszystkie wątki oczekujące
są zwalniane, a zdarzenie pozostaje ustawione (sygnalizowane) do momentu ręcznego wywo-
łania procedury ResetEvent. W przypadku zdarzenia synchronizacji zwalniany jest tylko jeden
(pierwszy) oczekujący wątek, po czym samo zdarzenie automatycznie jest zerowane. Alterna-
tywną operacją jest wywołanie PulseEvent, które tym różni się od wywołania SetEvent, że w razie
braku oczekujących wątków sygnał jest ignorowany, a zdarzenie pozostaje w stanie niesygna-
lizowanym. Dla odmiany wywołanie SetEvent użyte dla zdarzenia pozbawionego oczekujących
wątków jest zachowywane, tj. pozostawia zdarzenie w stanie sygnalizowanym, aby ewentualne
wątki wywołujące w przyszłości procedurę WaitForSingleObject dla tego zdarzenia nie musiały
ani chwili czekać na przejście w stan sygnalizowany.
Win32 API oferuje blisko sto wywołań związanych z procesami, wątkami i włóknami. Duża
część tych wywołań odpowiada za operacje na mechanizmie IPC (lokalnych wywołań procedur)
w tej czy innej formie.
Ostatnio w systemie Windows wprowadzono dwa nowe prymitywy synchronizacji — wywo-
łania WaitOnAddress i InitOnceExecuteOnce. Funkcja WaitOnAddress jest wywoływana w celu ocze-
kiwania na modyfikację wartości pod określonym adresem. Aby obudzić pierwszy (lub wszystkie)
wątek, który wywołał WaitOnAddress dla określonej lokalizacji, po zmodyfikowaniu tej lokalizacji
aplikacja musi wywołać funkcję WakeByAddressSingle (lub WakeByAddressAll). Przewaga korzy-
stania z tego API zamiast zdarzeń polega na tym, że nie jest konieczne przydzielanie jawnego
zdarzenia synchronizacji. Zamiast tego system tworzy tablicę asocjacyjną indeksowaną adresem
lokalizacji w celu odnalezienia listy wszystkich oczekujących na zmianę wskazanego adresu.
Funkcja WaitOnAddress przypomina mechanizm sleep (wakeup) dostępny w jądrze systemu UNIX.
Funkcję InitOnceExecuteOnce można wykorzystać po to, by zyskać pewność, że procedura inicjali-
zacji zostanie wykonana w programie tylko raz. Poprawna inicjalizacja struktur danych w pro-
gramach wielowątkowych jest zaskakująco trudna. Zestawienie prymitywów związanych z syn-
chronizacją omówionych powyżej oraz kilku szczególnie ważnych wywołań, o których do tej pory
nie wspominaliśmy, zawarto w tabeli 11.12.
Tabela 11.12. Wybrane wywołania interfejsu Win32 API związane z zarządzaniem procesami,
wątkami i włóknami
Funkcja Win32 API Opis
CreateProcess Tworzy nowy proces
CreateThread Tworzy nowy wątek w ramach istniejącego procesu
CreateFiber Tworzy nowe włókno
ExitProcess Kończy wykonywanie bieżącego procesu i wszystkich jego wątków
ExitThread Kończy wykonywanie danego wątku
ExitFiber Kończy wykonywanie danego włókna
SwitchToFiber Przechodzi do innego włókna bieżącego wątku
SetPriorityClass Ustawia klasę priorytetu dla danego procesu
SetThreadPriority Ustawia priorytet danego wątku
CreateSemaphore Tworzy nowy semafor
CreateMutex Tworzy nowy muteks
OpenSemaphore Otwiera istniejący semafor
OpenMutex Otwiera istniejący muteks
WaitForSingleObject Blokuje wykonywanie w oczekiwaniu na pojedynczy semafor, muteks itp.
WaitForMultipleObjects Blokuje wykonywanie w oczekiwaniu na zbiór obiektów reprezentowanych przez
dane uchwyty
PulseEvent Wymusza przejście zdarzenia w stan sygnalizowany i automatyczny powrót do stanu
niesygnalizowanego
ReleaseMutex Zwalnia muteks, aby umożliwić jego uzyskanie przez inny wątek
ReleaseSemaphore Zwiększa licznik semafora o jeden
EnterCriticalSection Uzyskuje blokadę sekcji krytycznej
LeaveCriticalSection Zwalnia blokadę sekcji krytycznej
WaitOnAddress Blokuje się do czasu modyfikacji pamięci pod wskazanym adresem
WakeByAddressSingle Budzi pierwszy wątek, który oczekuje na modyfikację tego adresu
WakeByAddressAll Budzi wszystkie wątki, które oczekują na modyfikację tego adresu
InitOnceExecuteOnce Gwarantuje jednorazowe uruchomienie procedury inicjalizacji
Warto przy tej okazji podkreślić, że nie wszystkie te funkcje są wywołaniami systemowymi.
Część z nich to opakowania, inne zawierają całkiem sporo kodu biblioteki odwzorowującego
semantykę podsystemu Win32 w rdzenne interfejsy API podsystemu NT. Jeszcze inne, np. inter-
fejsy API włókien, mają postać typowych funkcji trybu użytkownika (jak już wspomniano, tryb
jądra systemu Windows w ogóle nie operuje na włóknach, zatem cały ten model zaimplemento-
wano w ramach bibliotek trybu użytkownika).
ścieżkach kodu wykonywanych podczas tworzenia procesów oraz na kilku szczegółach uzupeł-
niających naszą dotychczasową wiedzę.
Proces jest tworzony w odpowiedzi na użycie przez inny proces wywołania CreateProcess
interfejsu Win32 API. Procedura CreateProcess wywołuje procedurę trybu użytkownika zaimple-
mentowaną w bibliotece kernel32.dll i wykonującą wiele kroków składających się na operację
tworzenia procesu (z wykorzystaniem wielu wywołań systemowych i innych działań):
1. Konwertuje nazwę pliku wykonywalnego otrzymaną za pośrednictwem parametru (w formie
ścieżki podsystemu Win32) na ścieżkę podsystemu NT. Jeśli ścieżka obejmuje tylko nazwę
pliku, bez ścieżki do katalogu, przeszukuje się katalogi z listy katalogów domyślnych (w tym,
choć nie tylko, katalogi wymienione w zmiennej środowiskowej PATH).
2. Gromadzi niezbędne parametry tworzenia procesu i przekazuje je (wraz z pełną ścieżką
do programu wykonywalnego) do procedury NtCreateUserProcess rdzennego API.
3. Wykonywana w trybie jądra procedura NtCreateUserProcess przetwarza otrzymane para-
metry, po czym otwiera obraz programu i tworzy obiekt sekcji, który można wykorzystać
do odwzorowania tego programu w wirtualnej przestrzeni adresowej nowego procesu.
4. Menedżer procesów alokuje i inicjalizuje obiekt procesu (czyli strukturę danych jądra
reprezentującą nowy proces na potrzeby zarówno warstwy jądra, jak i warstwy wyko-
nawczej).
5. Menedżer pamięci tworzy przestrzeń adresową dla nowego procesu, alokując i inicjalizując
katalogi stron i deskryptory adresów wirtualnych opisujące część procesu związaną
z trybem jądra (w tym obszary skojarzone z danym procesem, np. wpisy w katalogu stron
samoodwzorowania — ang. self-map page directory — które w trybie jądra zapewniają
temu procesowi dostęp do stron fizycznych tablicy stron z wykorzystaniem adresów wir-
tualnych jądra. Mechanizm samoodwzorowania omówimy bardziej szczegółowo w pod-
rozdziale 11.5).
6. Dla nowego procesu tworzy się tablicę uchwytów, w której umieszcza się kopie tych
wszystkich uchwytów procesu wywołującego, które mogą być dziedziczone.
7. Współdzielona strona użytkownika jest odwzorowywana, a menedżer pamięci inicjalizuje
robocze struktury danych umożliwiające wybór właściwych stron do odebrania proce-
sowi w razie przekroczenia progu niewielkiej ilości pamięci fizycznej. Fragmenty obrazu
programu wykonywalnego reprezentowane przez obiekt sekcji są odwzorowywane w prze-
strzeni adresowej trybu użytkownika nowego procesu.
8. Warstwa wykonawcza tworzy i inicjalizuje blok środowiska procesu (ang. Process Environ-
ment Block — PEB) wykorzystywany zarówno przez tryb jądra, jak i przez jądro do utrzy-
mywania informacji o stanie procesu, w tym wskaźników do sterty trybu użytkownika
oraz listy załadowanych bibliotek DLL.
9. W ramach nowego procesu alokuje się pamięć wirtualną wykorzystywaną do przekazy-
wania parametrów (w tym łańcuchów parametrów środowiskowych i wiersza poleceń).
10. Procesowi jest przydzielany identyfikator wybrany ze specjalnej tablicy uchwytów (tablicy
identyfikatorów) utrzymywanej przez jądro w celu efektywnego przypisywania procesom
i wątkom lokalnie unikatowych identyfikatorów.
11. Obiekt wątku jest alokowany i inicjalizowany. Na tym etapie alokuje się zarówno stos
trybu użytkownika, jak i blok środowiska wątku (ang. Thread Environment Block — TEB).
Inicjalizowany jest także rekord struktury CONTEXT zawierający wartości początkowe reje-
strów procesora właściwe temu wątkowi (w tym wskaźniki do rozkazu i stosu).
12. Do globalnej listy procesów dodaje się obiekt nowego procesu. W tabeli uchwytów procesu
wywołującego są umieszczane uchwyty obiektów nowo utworzonych procesu i wątku.
Z tablicy identyfikatorów wybiera się identyfikator dla początkowego wątku tego procesu.
13. Procedura NtCreateUserProcess wraca do trybu użytkownika z nowym procesem obej-
mującym pojedynczy wątek, który na tym etapie jest gotowy do wykonywania, ale zo-
stał wstrzymany.
14. Jeśli opisane wywołanie interfejsu NT API zakończy się niepowodzeniem, kod podsystemu
Win32 sprawdzi, czy powodem błędu była przynależność procesu wywołującego do innego
podsystemu, np. WOW64. Może się też okazać, że dany program oznaczono jako prze-
znaczony do wykonywania pod kontrolą debugera. Te i inne specjalne przypadki są obsłu-
giwane przez kod procedury CreateProcess wykonywany w trybie użytkownika.
15. Jeśli wykonywanie procedury NtCreateUserProcess zakończy się pomyślnie, pozostaje
jeszcze kilka kroków do wykonania. Procesy podsystemu Win32 wymagają rejestracji
w procesie tego podsystemu: csrss.exe. Biblioteka Kernel32.dll wysyła do procesu tego
podsystemu komunikat opisujący nowy proces oraz obejmujący uchwyty tego procesu
i wątku, aby umożliwić ich powielenie. Proces podsystemu wyświetla wówczas kursor
ze standardowym wskaźnikiem i klepsydrą, aby zasygnalizować użytkownikowi realizację
swoich zadań, i pozostawia mu możliwość korzystania z kursora. Proces podsystemu
wyświetla wówczas kursor ze standardowym wskaźnikiem i klepsydrą, aby zasygnali-
zować użytkownikowi realizację swoich zadań, i pozostawia mu możliwość korzystania
z kursora. Kiedy nowy proces wykonuje swoje pierwsze wywołanie związane z graficz-
nym interfejsem użytkownika (GUI), czyli w większości przypadków wywołanie tworzące
nowe okno, wspomniany kursor jest usuwany (w razie braku dalszych wywołań jego limit
czasowy wyczerpuje się po 2 s).
16. Jeśli dany proces ma ograniczone prawa (tak jest np. w przypadku przeglądarki Internet
Explorer), jego token jest modyfikowany w taki sposób, aby nie mógł uzyskiwać dostępu
do wszystkich obiektów.
17. Jeśli dany program aplikacji oznaczono jako wymagający dostosowania do środowiska bie-
żącej wersji systemu Windows, stosuje się wskazane mechanizmy dostosowawcze (ang.
shims). Tego rodzaju mechanizmy zwykle opakowują wywołania bibliotek, nieznacznie
modyfikując ich zachowania, np. poprzez zwracanie zmienionych numerów wersji lub
opóźnianie zwalniania pamięci.
18. I wreszcie następuje wywołanie procedury NtResumeThread, aby odblokować nowy wątek —
wywołanie zwraca procesowi wywołującemu strukturę danych obejmującą identyfikatory
i uchwyty nowo utworzonych procesu i wątku.
We wcześniejszych wersjach systemu Windows większa część algorytmu tworzenia procesu była
zaimplementowana w procedurze trybu użytkownika, która tworzyła nowy proces przy użyciu
wielu wywołań systemowych i wykonywała inne działania z wykorzystaniem rdzennych inter-
fejsów API NT obsługujących implementację podsystemów. Te operacje zostały przeniesione do
jądra, aby zmniejszyć możliwość manipulowania procesem potomnym przez proces macierzysty
w przypadkach, gdy w procesie potomnym działał program chroniony — np. w celu implementacji
mechanizmów DRM do zabezpieczenia filmów przed piractwem.
Szeregowanie
Jądro systemu Windows nie korzysta z żadnego centralnego wątku szeregującego. Zamiast tego
wątek, który nie może być dłużej wykonywany, przechodzi do trybu jądra i sam wywołuje mecha-
nizm szeregujący, aby dowiedzieć się, do którego wątku należy się przełączyć. Poniżej wymie-
niono sytuacje, w których aktualnie wykonywany wątek odwołuje się do kodu mechanizmu szere-
gującego:
1. Aktualnie wykonywany wątek blokuje się w oczekiwaniu na zmianę stanu semafora,
muteksa lub zdarzenie albo na wykonanie operacji wejścia-wyjścia.
2. Dany wątek wysyła sygnał do jakiegoś obiektu (np. wykonuje operację up na semaforze).
3. Wyczerpuje się kwant czasu wykonywanego wątku.
W pierwszym przypadku dany wątek działa już w trybie jądra, co jest warunkiem operacji na
obiekcie dyspozytora lub obiekcie wejścia-wyjścia. Ponieważ w takim przypadku często nie może
kontynuować działania, wywołuje kod mechanizmu szeregującego, aby wybrać swojego następcę,
załadować jego rekord w strukturze CONTEXT i wznowić jego wykonywanie.
Także w drugim przypadku wykonywany wątek działa w trybie jądra. Z drugiej strony po
wysłaniu sygnału do jakiegoś obiektu taki wątek może kontynuować działanie, ponieważ wysy-
łanie sygnałów do obiektów nigdy nie blokuje wykonywania strony wysyłającej. Taki wątek
powinien jednak wywołać mechanizm szeregujący, aby sprawdzić, czy w wyniku tej operacji nie
został zwolniony (nie jest gotowy do działania) wątek z wyższym priorytetem szeregowania.
Jeśli tak, następuje przełączenie wątków, ponieważ Windows jest typowym systemem wywłasz-
czającym (oznacza to, że przełączanie wątków może mieć miejsce w dowolnym momencie, nie
tylko po wyczerpaniu kwantu czasu bieżącego wątku). W systemach wieloprocesorowych wątek,
który przeszedł w stan gotowości, może mieć przydzielony inny procesor — wówczas orygi-
nalny wątek może kontynuować działanie na bieżącym procesorze, nawet jeśli jego priorytet
szeregowania jest niższy.
W trzecim przypadku ma miejsce przerwanie przenoszące sterowanie do trybu jądra, w którym
bieżący wątek odwołuje się do kodu mechanizmu szeregującego, aby dowiedzieć się, który wątek
powinien być wykonywany jako następny. W zależności od zawartości listy wątków oczekujących
może się okazać, że zostanie wybrany ten sam wątek — otrzyma wówczas nowy kwant czasu i będzie
kontynuował wykonywanie. W przeciwnym razie konieczne będzie przełączenie wątków.
Mechanizm szeregujący jest wywoływany także w dwóch następujących przypadkach:
1. Po zakończeniu wykonywania operacji wejścia-wyjścia.
2. Po wyczerpaniu czasu oczekiwania.
W pierwszym przypadku wątek może oczekiwać na wykonanie danej operacji wejścia-wyjścia
i wznowić działanie po jej zakończeniu. Należy wówczas sprawdzić, czy aktualnie wykonywany
wątek nie powinien zostać wywłaszczony, ponieważ w systemie Windows nie istnieje pojęcie
gwarancji minimalnego czasu wykonywania wątków. Mechanizm szeregujący nie jest wywoływany
przez kod obsługujący przerwania (takie rozwiązanie mogłoby uniemożliwić obsługę przerwań
na zbyt długo). Zamiast tego odpowiednie wywołanie DPC jest kolejkowane z myślą o wykonaniu
Najwyższy 26 15 12 10 8 6
Powyżej 25 14 11 9 7 5
normalnego
Normalny 24 13 10 8 6 4
Poniżej 23 12 9 7 5 3
normalnego
Najniższy 22 11 8 6 4 2
Bezczynny 16 1 1 1 1 1
nym (rotacyjnym; ang. round robin). Jeśli żaden wątek nie jest gotowy do wykonywania, proces
przechodzi w stan oczekiwania (czyli stan niskiego poboru energii w oczekiwaniu na wystąpienie
przerwania).
Warto przy tej okazji podkreślić, że mechanizm szeregujący wybiera wątki niezależnie od
procesów, do których te wątki należą. Oznacza to, że opisywany mechanizm nie wybiera najpierw
procesu, by następnie wybrać któryś z jego wątków — operuje wyłącznie na wątkach. Mechanizm
szeregujący w ogóle nie uwzględnia przynależności wątków do procesów (oczywiście z wyjąt-
kiem sytuacji, w której wymiana wątków wymaga także wymiany przestrzeni adresowych).
Aby podnieść skalowalność algorytmów szeregujących w środowiskach wieloprocesorowych
z dużą liczbą procesorów, mechanizm szeregujący próbuje za wszelką cenę unikać blokowania
dostępu do globalnej tablicy list wątków oczekujących. Zamiast tego opisywany mechanizm
sprawdza, czy może od razu przydzielić właściwy procesor jakiemuś wątkowi gotowemu do
wykonywania.
Dla każdego wątku mechanizm szeregujący wyznacza tzw. idealny procesor i próbuje tak
szeregować wątki, aby każdy z nich otrzymywał dostęp właśnie do tego procesora (jeśli tylko
jest to możliwe). Takie rozwiązanie podnosi wydajność systemu, ponieważ zwiększa prawdopo-
dobieństwo występowania danych wykorzystywanych przez wątek w pamięci podręcznej ide-
alnego procesora. Mechanizm szeregujący korzysta z wiedzy o działaniu systemów wieloproce-
sorowych, w których każdy procesor dysponuje własną pamięcią umożliwiającą wykonywanie
programów bez odwoływania się do jakiejkolwiek pamięci zewnętrznej (odwołania do pamięci
innej niż lokalna wiążą się z pewnymi kosztami). Systemy tego typu określa się mianem kompu-
terów NUMA (od ang. NonUniform Memory Access). Mechanizm szeregujący próbuje optymali-
zować rozmieszczenie wątków na tego rodzaju komputerach. W razie błędu braku strony mene-
dżer pamięci próbuje przydzielać wątkom strony fizyczne należące do idealnego procesora.
Strukturę tablicy nagłówków kolejek pokazano na rysunku 11.13. Na rysunku widać cztery
kategorie priorytetów: czasu rzeczywistego, użytkownika, zerowe i bezczynności (czyli efek-
tywnie –1). Warto te priorytety wyjaśnić nieco bliżej. Priorytety z przedziału 16 – 31 zalicza
się do grupy priorytetów czasu rzeczywistego — stosowanie tych priorytetów pozwala budować
systemy spełniające warunki przetwarzania w czasie rzeczywistym, np. nieprzekraczalne terminy
realizacji zadań. Wątki z priorytetami czasu rzeczywistego mają pierwszeństwo przed wszystkimi
wątkami z priorytetami dynamicznymi, ale nie przed wywołaniami DPC i procedurami ISR.
Aplikacja czasu rzeczywistego działająca w tego rodzaju systemie może wymagać stosowania
sterowników urządzeń, które możliwie rzadko i krótko korzystają z wywołań DPC i procedur
ISR, ponieważ tego rodzaju konstrukcje mogą uniemożliwiać wątkom czasu rzeczywistego reali-
zację ich zadań w terminie.
Zwykli użytkownicy nie mogą uruchamiać wątków czasu rzeczywistego. Gdyby wątek użyt-
kownika dysponował wyższym priorytetem niż np. wątek obsługujący klawiaturę lub mysz
i gdyby wszedł w nieskończoną pętlę, wątek klawiatury lub myszy nigdy nie otrzymałby szansy
realizacji swoich zadań, co w praktyce doprowadziłoby do zawieszenia systemu. Ustawianie prio-
rytetu czasu rzeczywistego wymaga specjalnych uprawnień reprezentowanych przez token pro-
cesu. Zwykli użytkownicy nie mają tego rodzaju uprawnień.
Wątki aplikacji zwykle działają z priorytetami z przedziału 1 – 15. Ustawiając priorytety
procesu i wątków, aplikacja może łatwo decydować o pierwszeństwie swoich wątków. Wątki
systemowe stron zerowych (ang. zero page) działają z priorytetem 0 i konwertują wolne strony
na strony złożone z samych zer. Dla każdego fizycznego procesora istnieje odrębny wątek stron
zerowych.
Każdy wątek ma priorytet bazowy zależny od klasy priorytetu przypisanej odpowiedniemu
procesowi oraz priorytet względny samego wątku. Do wyboru jednej z 32 kolejek gotowych
wątków wykorzystuje się jednak tzw. priorytet bieżący, który zwykle (choć nie zawsze) jest taki
sam jak priorytet bazowy. W pewnych sytuacjach priorytet bieżący wątku (poza wątkami czasu
rzeczywistego) może być podniesiony przez jądro, ale nigdy nie przekracza priorytetu 15. Ponie-
waż tablica widoczna na rysunku 11.13 jest konstruowana właśnie na podstawie priorytetów bie-
żących, ich zmiana wpływa na sposób szeregowania wątków. Tego rodzaju zmian nigdy nie doko-
nuje się na wątkach z priorytetem czasu rzeczywistego.
Sprawdźmy teraz, kiedy priorytet wątku jest podnoszony. Po pierwsze mechanizm szeregu-
jący podnosi priorytet wątku oczekującego na zakończenie operacji wejścia-wyjścia w momencie
jej wykonania — wyższy priorytet ma umożliwić szybkie wznowienie działania i zainicjowanie
ewentualnych dalszych operacji wejścia-wyjścia. Takie rozwiązanie ma na celu utrzymywanie
możliwie dużego obciążenia urządzeń wejścia-wyjścia. Skala podnoszenia priorytetu zależy od
rodzaju danego urządzenia wejścia-wyjścia i zwykle wynosi 1 dla dysku, 2 dla połączenia szere-
gowego, 6 dla klawiatury i 8 dla karty dźwiękowej.
Po drugie, jeśli wątek czekał na semafor, muteks lub inne zdarzenie, zwolnienie tego obiektu
powoduje tymczasowe podniesienie priorytetu tego wątku o dwa poziomy (w przypadku pro-
cesu pierwszoplanowego, czyli kontrolującego okno, do którego są kierowane dane wejściowe
z klawiatury) lub o jeden poziom (w przypadku pozostałych procesów). Takie rozwiązanie ma
na celu podnoszenie priorytetów interaktywnych procesów ponad popularny (zwykle przyznany
licznym procesom) poziom 8. I wreszcie system podnosi priorytet wątku graficznego interfejsu
użytkownika (GUI) w reakcji na pojawienie się oczekiwanych danych wejściowych okna.
Podnoszenie priorytetów nie ma jednak trwałego charakteru. Efekt tego rodzaju działań jest
natychmiastowy i może powodować ponowne szeregowanie zadań procesora. Po wykorzystaniu
całego następnego kwantu czasu priorytet wątku spada o jeden poziom, a sam wątek jest przeno-
szony do niższej kolejki w tablicy priorytetów. Jeśli wątek w całości wykorzysta następny kwant
czasu, jego priorytet spadnie o kolejny poziom — procedura powtarza się aż do osiągnięcia poziomu
priorytetu bazowego (wówczas istnieje możliwość ponownego podniesienia priorytetu wątku).
Istnieje jeszcze jeden przypadek, w którym system zmienia priorytety wątków. Wyobraźmy
sobie dwa wątki wspólnie rozwiązujące popularny problem producenta-konsumenta. Zadanie
producenta jest bardziej wymagające, zatem otrzymuje wyższy priorytet, np. 12, podczas gdy
mniej obciążony konsument dysponuje priorytetem 4. W pewnym momencie producent wypełnia
wspólny bufor i wstrzymuje działanie w oczekiwaniu na zmianę stanu semafora — tę sytuację
pokazano na rysunku 11.14(a).
RDP (ang. Remote Desktop Protocol). Kiedy działa wiele sesji użytkownika, łatwo może dojść do
sytuacji, gdy sesja jednego użytkownika koliduje z sesją innego z powodu zużywania zbyt wielu
zasobów procesora. W systemie Windows zaimplementowano algorytm sprawiedliwego podziału
DFSS (od ang. Dynamic Fair-Share Scheduling), który nie dopuszcza do zbytniego zużywania
zasobów przez jedną z sesji. W mechanizmie DFSS wykorzystano grupy szeregowania (ang. sche-
duling groups) w celu zorganizowania wątków w każdej sesji. W ramach każdej grupy wątki są
szeregowane zgodnie ze standardowymi zasadami szeregowania, ale każda grupa uzyskuje
mniejszy lub większy dostęp do procesorów, w zależności od tego, przez jaki czas określona
grupa łącznie działała. Względne priorytety grup są dostosowywane powoli, tak by były ignoro-
wane krótkie aktywności oraz by czas, przez jaki grupa może działać, był skracany tylko wtedy,
gdy grupa nadmiernie wykorzystuje procesor przez długi okres.
Rysunek 11.15. Układ wirtualnej przestrzeni adresowej dla trzech procesów użytkownika
na platformie x86. Białe obszary reprezentują prywatną przestrzeń procesów. Szare obszary
są wspólne dla wszystkich procesów
Podobny mechanizm jest dostępny również w systemie UNIX. Ponieważ strony trybu użytkow-
nika tego procesu nadal są dostępne, kod trybu jądra może odczytywać parametry i bufory dostępu
bez konieczności wracania do trybu użytkownika i wielokrotnego przechodzenia pomiędzy prze-
strzeniami adresowymi ani tymczasowego podwójnego odwzorowywania stron w obu przestrze-
niach. Wadą tego rozwiązania jest mniejsza prywatna przestrzeń adresowa procesów; niewąt-
pliwą zaletą tego modelu jest szybsze wykonywanie wywołań systemowych.
System Windows umożliwia wątkom wykonywanym w trybie jądra dołączanie się do innych
przestrzeni adresowych. Dołączanie do przestrzeni adresowej zapewnia wątkowi dostęp nie tylko
do całej przestrzeni adresowej trybu użytkownika, ale też do części przestrzeni adresowej jądra
przypisanych temu procesowi, np. samoodwzorowania tablic stron. Wątki muszą ponownie przełą-
czać się do oryginalnej przestrzeni adresowej przed każdym powrotem do trybu użytkownika.
Strona wirtualna może się też znajdować w stanie zastrzeżonym (ang. reserved). Zastrzeżona
strona wirtualna jest nieprawidłowa, ale ma też tę cechę, że odpowiednie adresy wirtualne nigdy
nie są alokowane przez menedżer pamięci dla innych celów. Przykładowo podczas tworzenia
nowego wątku wiele stron przestrzeni stosu trybu użytkownika jest oznaczanych jako zastrze-
żone strony wirtualnej przestrzeni adresowej tego procesu, a tylko jedna strona zostaje zatwier-
dzona. Wraz ze wzrostem rozmiaru tego stosu menedżer pamięci wirtualnej automatycznie
zatwierdza dodatkowe strony aż do niemal całkowitego wyczerpania zbioru stron zastrzeżonych.
Strony zastrzeżone mają na celu zapobieganie zbyt szybkiemu rozszerzaniu tego stosu i nadpi-
sywaniu danych innych procesów. Zastrzeżenie wszystkich stron wirtualnych oznacza, że dany
stos może rozrosnąć się do maksymalnych rozmiarów bez ryzyka wykorzystania do innych celów
sąsiednich stron wirtualnej przestrzeni adresowej. Oprócz wymienionych stanów strony mają też
inne atrybuty decydujące m.in. o możliwości odczytu, zapisu oraz wykonywania.
Pliki stron
Z ciekawym problemem mamy do czynienia w obszarze wyznaczania pamięci zapasowej dla tych
zatwierdzonych stron, które nie są odwzorowywane w konkretnych plikach. Strony tego typu
wykorzystują tzw. plik stron (ang. pagefile). Największym problemem jest znalezienie odpowiedzi
na pytania, jak i kiedy odwzorowywać stronę wirtualną w określonych obszarach pliku stron.
Najprostszą strategią byłoby przypisanie każdej stronie wirtualnej strony w jednym z plików
stron na dysku już w czasie jej zatwierdzania. Taki model gwarantowałby nam, że zawsze istnieje
znane miejsce, w którym (w razie konieczności zwolnienia pamięci) należy zapisywać poszcze-
gólne zatwierdzone strony.
System Windows stosuje strategię stronicowania określaną mianem dokładnie na czas
(ang. just-in-time). Zatwierdzone strony, dla których wyznaczono plik stron, nie otrzymują prze-
strzeni w tym pliku do chwili wystąpienia konieczności ich przeniesienia. Oznacza to, że dla stron,
które nigdy nie są usuwane z pamięci, nie jest alokowana przestrzeń dyskowa. Jeśli łączny
rozmiar pamięci wirtualnej okazuje się mniejszy od ilości dostępnej pamięci fizycznej, plik stron
w ogóle nie jest potrzebny. Możliwość rezygnacji z tego pliku okazuje się szczególnie wygodna
w przypadku systemów wbudowanych opracowanych na bazie Windowsa. Wspomnianą możliwość
wykorzystuje się także podczas uruchamiania systemu, ponieważ pliki stron nie są inicjalizowane
do momentu uruchomienia pierwszego procesu trybu użytkownika (smss.exe).
Strategię wstępnego alokowania całej pamięci wirtualnej systemu stosuje się dla danych
prywatnych (stosów, stert i stron kodu kopiowanych przy zapisie) tylko w granicach wyznacza-
nych przez rozmiar plików stron. Model alokacji dokładnie na czas powoduje, że pamięć wirtualna
może być niemal równie duża jak suma rozmiarów plików stron i pamięci fizycznej. W obecnej
sytuacji, w której dyski twarde oferują ogromne przestrzenie i są relatywnie tanie (w porów-
naniu z pamięcią fizyczną), oszczędność miejsca nie jest tak ważna jak możliwy do osiągnięcia
wzrost wydajności.
W modelu stronicowania na żądanie operacje odczytu stron z dysku muszą być wykonywane
niezwłocznie, ponieważ wątek, który potrzebuje brakującej strony, nie może kontynuować działa-
nia do momentu zakończenia operacji ponownego przenoszenia tej strony do pamięci fizycznej
(ang. page-in). Jednym ze sposobów optymalizacji procedury przenoszenia brakujących stron do
pamięci jest podejmowanie prób przenoszenia z wyprzedzeniem dodatkowych stron w ramach
tej samej operacji wejścia-wyjścia. Z drugiej strony operacje zapisujące zmodyfikowane strony
na dysku zwykle nie są synchronizowane z wykonywanymi wątkami. Strategia alokowania prze-
strzeni pliku stron dokładnie na czas wykorzystuje ten model do poprawy wydajności operacji
Jeśli w przyszłości wspomniana kopia będzie wymagać stronicowania, zostanie zapisana w pliku
stron, nie w swoim oryginalnym pliku.
Oprócz odwzorowywania kodu i danych programu pobieranych z plików EXE i DLL system
Windows oferuje możliwość odwzorowywania w pamięci zwykłych plików, aby programy mogły
odwoływać się do danych zawartych w tych plikach bez wykonywania wprost operacji odczytu
i zapisu. Odpowiednie operacje wejścia-wyjścia oczywiście nadal są potrzebne, jednak za ich
wykonywanie odpowiada menedżer pamięci korzystający z obiektu sekcji, który z kolei reprezen-
tuje odwzorowanie pomiędzy stronami w pamięci a blokami we właściwych plikach dyskowych.
Obiekty sekcji nie muszą się odwoływać do jakichkolwiek plików na dysku. Równie dobrze
mogą reprezentować anonimowe obszary pamięci. Odwzorowywanie anonimowych obiektów
sekcji na potrzeby wielu procesów umożliwia współdzielenie pamięci bez konieczności korzy-
stania z pliku dyskowego. Ponieważ sekcje mają nadawane nazwy w przestrzeni nazw NT, pro-
cesy mogą się kontaktować, zarówno otwierając obiekty sekcji według nazw, jak i stosując stan-
dardowy mechanizm powielania uchwytów obiektów sekcji.
Tabela 11.14. Najważniejsze funkcje interfejsu Win32 API związane z zarządzaniem pamięcią
wirtualną systemu Windows
Funkcja Win32 API Opis
VirtualAlloc Rezerwuje lub zatwierdza obszar pamięci
VirtualFree Zwalnia lub usuwa zatwierdzenie obszaru pamięci
VirtualProtect Zmienia uprawnienia odczytu, zapisu i (lub) wykonywania obszaru pamięci
VirtualQuery Bada status obszaru pamięci
VirtualLock Przekształca obszar pamięci w obszar rezydentny (czyli taki, dla którego nie stosuje
się mechanizmów stronicowania)
VirtualUnlock Przekształca obszar pamięci w obszar podlegający standardowym procedurom
stronicowania
CreateFileMapping Tworzy obiekt odwzorowania pliku i (opcjonalnie) przypisuje mu nazwę
MapViewOfFile Odwzorowuje plik (lub jego część) w przestrzeni adresowej
UnmapViewOfFile Usuwa odwzorowany plik z przestrzeni adresowej
OpenFileMapping Otwiera utworzony wcześniej obiekt odwzorowania pliku
Pierwsze cztery funkcje tego API służą do alokowania, zwalniania, ochrony i uzyskiwania
informacji o obszarach wirtualnej przestrzeni adresowej. Rozmiar alokowanych obszarów nigdy
nie jest mniejszy niż 64 kB, co w założeniu ma zminimalizować ryzyko występowania proble-
mów z przenoszeniem oprogramowania do przyszłych architektur, które najprawdopodobniej będą
operowały na większych stronach. Rzeczywisty rozmiar alokowanego obszaru przestrzeni adre-
sowej może być mniejszy niż 64 kB, ale musi być wielokrotnością rozmiaru strony. Dwie kolejne
funkcje umożliwiają procesowi odpowiednio ścisłe wiązanie stron z pamięcią (aby uniknąć ich
stronicowania) oraz rezygnację z tej właściwości. Z tego rozwiązania mogą korzystać np. pro-
gramy czasu rzeczywistego, które chcą uniknąć odwołań do pliku stron (w razie błędów braku
stron) podczas realizacji krytycznych operacji. System operacyjny wprowadza jednak pewne
ograniczenia, aby zapobiec zbyt częstej ochronie stron przed stronicowaniem. Zablokowane
strony w rzeczywistości mogą zostać usunięte z pamięci, ale tylko wraz z wymianą (przenie-
sieniem do pliku wymiany) całego procesu. W czasie ponownego przenoszenia procesu i jego
stron do pamięci wszystkie zablokowane strony są ponownie ładowane, zanim jakikolwiek wątek
będzie mógł wznowić działanie. System Windows oferuje też funkcje rdzennego API — za ich
pośrednictwem proces może uzyskiwać dostęp do pamięci wirtualnej innego procesu, nad którym
ma kontrolę (dysponuje jego uchwytem — funkcje z tej grupy pominięto w tabeli 11.14, ale
wymieniono w tabeli 11.4).
Ostatnie cztery funkcje wymienione w powyższej tabeli odpowiadają za zarządzanie plikami
odwzorowanymi w pamięci. Aby odwzorować plik, należy najpierw utworzyć obiekt odwzorowania
(patrz tabela 11.5) za pomocą funkcji CreateFileMapping. Funkcja CreateFileMapping zwraca
uchwyt obiektu odwzorowania pliku (odpowiedniego obiektu sekcji) i opcjonalnie dodaje jego
nazwę do przestrzeni nazw podsystemu Win32, aby z nowego obiektu mogły korzystać także inne
procesy. Dwie kolejne funkcje odwzorowują i usuwają odwzorowanie z wirtualnej przestrzeni ad-
resowej procesu. Ostatnią funkcję można wykorzystać do współdzielenia odwzorowania utwo-
rzonego przez inny proces za pomocą wywołania CreateFileMapping (zwykle stosuje się ten
mechanizm do odwzorowywania pamięci anonimowej). W ten sposób dwa procesy lub większa
ich liczba może współużytkować obszary swoich przestrzeni adresowych. Opisana technika stwa-
rza możliwość zapisywania ograniczonych danych w ograniczonych obszarach pamięci wirtualnej
innych procesów.
na odwołanie do pierwszej strony tego obszaru tworzy się katalog tablic stron, a odpowiedni adres
fizyczny jest umieszczany w obiekcie procesu. Przestrzeń adresowa jest definiowana tylko przez
listę swoich deskryptorów VAD. Same deskryptory VAD tworzą strukturę zrównoważonego
drzewa, która umożliwia efektywne odnajdywanie deskryptorów dla konkretnych adresów. Opisany
schemat obsługuje rozproszone przestrzenie adresowe. Nieużywane przestrzenie dzielące odwzo-
rowane obszary nie wykorzystują żadnych zasobów (w pamięci ani na dysku), zatem są w pełni
darmowe.
Nieodwzorowane strony tym różnią się od zwykłych stron, że nie są inicjalizowane poprzez
odczytywanie z pliku. Zamiast tego w odpowiedzi na pierwszą próbę dostępu do nieodwzorowanej
strony menedżer pamięci udostępnia nową stronę fizyczną wypełnioną samymi zerami (dla zapew-
nienia bezpieczeństwa). Kolejne błędy braku stron mogą wymuszać albo odnalezienie nieodwzo-
rowanej strony w pamięci, albo ponowny odczyt z pliku stron.
Do obsługi stronicowania na żądanie menedżer pamięci wykorzystuje mechanizmy błędów
braku stron. Każdy taki błąd powoduje pułapkę jądra. Jądro konstruuje następnie niezależny od
danego komputera deskryptor opisujący dotychczasowe zdarzenia, po czym przekazuje ten deskry-
ptor menedżerowi pamięci wchodzącemu w skład warstwy wykonawczej. Menedżer pamięci
sprawdza wówczas prawidłowość żądanej formy dostępu. Jeśli brakująca strona należy do zatwier-
dzonego obszaru, menedżer szuka odpowiedniego adresu na liście deskryptorów VAD i znaj-
duje (lub tworzy) właściwy wpis w tablicy stron danego procesu. W przypadku strony współdzie-
lonej menedżer pamięci wykorzystuje wpis w prototypowej tablicy stron skojarzony z obiektem
sekcji i na jego podstawie wypełnia nowy wpis we właściwej tablicy stron procesu.
Format wpisów w tablicy stron różni się w zależności od architektury procesora. Struktury
wpisów dla odwzorowanych stron w architekturach x86 i x64 pokazano na rysunku 11.17. Jeśli
wpis jest oznaczony jako prawidłowy, jego zawartość jest interpretowana przez sprzęt, aby dany
adres wirtualny można było przetłumaczyć na właściwą stronę fizyczną. Także dla stron nieodwzo-
rowanych istnieją wpisy, tyle że oznaczone jako nieprawidłowe i ignorowane przez sprzęt.
Format wpisów na poziomie programowym nieco odbiega od formatu obowiązującego na pozio-
mie sprzętu i zależy od menedżera pamięci. Przykładowo wpis w tablicy stron dla strony nie-
odwzorowanej zawiera informacje o konieczności jej alokacji i wyzerowania przed użyciem.
Rysunek 11.17. Wpis w tablicy stron dla odwzorowanej strony w architekturach (a) Intel x86
oraz (b) AMD x64
Dwa ważne bity wpisu w tabeli stron są aktualizowane bezpośrednio przez sprzęt. Ta forma
aktualizacji jest stosowana dla bitów wykorzystania strony (ang. accessed) i brudnej strony
(ang. dirty) oznaczonych na rysunku 11.16 odpowiednio literami W i B. Takie rozwiązanie pozwala
śledzić, kiedy wykorzystywano poszczególne odwzorowania do uzyskiwania dostępu do stron oraz
czy wykonywane operacje mogły modyfikować ich zawartość. W praktyce tego rodzaju wiedza
pozwala podnieść wydajność systemu, ponieważ menedżer pamięci może wykorzystywać bit
wykorzystania strony do zaimplementowania mechanizmu stronicowania LRU (od ang. Least-
-Recently Used). Zgodnie z zasadą LRU w przypadku stron, które nie były przedmiotem odwołań
najdłużej, prawdopodobieństwo ponownego wykorzystania jest najmniejsze. Bit wykorzystania
umożliwia więc menedżerowi pamięci identyfikację właściwej strony do stronicowania. Bit brudnej
strony informuje menedżer pamięci o możliwości modyfikacji zawartości danej strony w prze-
szłości — w rzeczywistości najważniejszą informacją reprezentowaną przez ten bit jest wskazówka,
czy dana strona na pewno nie była modyfikowana. Jeśli strona nie była modyfikowana od ostat-
niego odczytu z dysku, menedżer pamięci nie musi zapisywać jej zawartości na dysku przed
ponownym użyciem.
Zarówno w architekturze x86, jak i x64 wykorzystuje się 64-bitowe wpisy w tablicy stron
(patrz rysunek 11.17).
Każdy błąd braku strony można przydzielić do jednej z następujących pięciu kategorii:
1. Strona będąca przedmiotem odwołania nie jest zatwierdzona.
2. Żądany dostęp do strony narusza obowiązujące uprawnienia.
3. Współdzielona strona trybu kopiowania przy zapisie miała być modyfikowana.
4. Konieczne jest rozszerzenie stosu.
5. Strona będąca przedmiotem odwołania jest zatwierdzona, ale obecnie nie jest odwzo-
rowana w pamięci.
Pierwszy i drugi przypadek wynika wprost z błędów popełnianych przez programistów. Jeśli
program próbuje użyć adresu, dla którego najprawdopodobniej nie istnieje prawidłowe odwzo-
rowanie, lub próbuje wykonać jakąś nieprawidłową operację (np. zapisać dane w stronie dostępnej
tylko do odczytu), mamy do czynienia z naruszeniem dostępu (ang. access violation), które zwykle
powoduje przerwanie wykonywania procesu. Naruszenia dostępu najczęściej są powodowane
przez błędne wskaźniki, w tym próby uzyskiwania dostępu do pamięci, która została już zwol-
niona i której odwzorowanie przestało istnieć.
Trzeci przypadek cechuje się identycznymi symptomami jak drugi (próby zapisu danych
w stronie dostępnej tylko do odczytu), ale jest traktowany zupełnie inaczej. Ponieważ stronę
oznaczono jako kopiowaną przy zapisie, menedżer pamięci nie zgłasza naruszenia dostępu,
tylko sporządza prywatną kopię danej strony na potrzeby bieżącego procesu, po czym zwraca
sterowanie wątkowi, który podjął próbę zapisu. Wspomniany wątek podejmuje wówczas kolejną
próbę zapisu, która tym razem jest wykonywana i nie powoduje kolejnego błędu.
Czwarty przypadek ma miejsce wtedy, gdy wątek umieszcza jakąś wartość na swoim stosie
i odwołuje się do strony, która do tej pory nie została zaalokowana. Menedżer pamięci ma za
zadanie rozpoznawać tego rodzaju próby i traktować jako przypadki specjalne. Dopóki istnieje
przestrzeń w ramach stron wirtualnych zarezerwowanych dla stosu, menedżer pamięci wyznacza
nowe strony fizyczne, zeruje ich zawartość i odwzorowuje dla danego procesu. Jeśli odpowiedni
wątek wznowi działanie, podejmie próbę ponownego uzyskania dostępu do tej strony i tym razem
jego żądanie zostanie zrealizowane pomyślnie.
I wreszcie piąty przypadek jest normalnym błędem braku strony. Istnieje jednak wiele warian-
tów zaliczanych do tej kategorii. Jeśli strona jest odwzorowywana przez plik, menedżer pamięci
musi przeszukać jej struktury danych (w tym prototypową tablicę stron skojarzoną z odpowiednim
obiektem sekcji), aby mieć pewność, że w pamięci nie występuje kopia tej strony. Jeśli taka
kopia istnieje (np. w pamięci innego procesu lub na liście stron oczekujących albo zmodyfiko-
wanych), menedżer pamięci ogranicza się do udostępnienia właśnie tej kopii — np. poprzez jej
oznaczenie jako kopiowanej przy zapisie (jeśli ewentualne zmiany nie powinny być widoczne dla
innych procesów). W razie braku kopii w pamięci menedżer pamięci alokuje wolną stronę fizyczną
i wymusza skopiowanie do tej strony zawartości odpowiedniej strony pliku dyskowego.
Jeśli menedżer pamięci może obsłużyć błąd braku strony, odnajdując żądaną stronę w pamięci
(zamiast odczytywać tę stronę z pliku dyskowego), taki błąd jest klasyfikowany jako miękki błąd
braku strony (ang. soft page fault). Jeśli obsługa błędu wymaga odczytania strony z pliku dysko-
wego, mówimy o twardym błędzie braku strony (ang. hard page fault). Obsługa miękkich błędów
braku stron jest dużo prostsza i ma niewielki wpływ na wydajność aplikacji (w porównaniu
z błędami twardymi). Miękkie błędy braku stron mogą wynikać z odwzorowania stron współdzie-
lonych w przestrzeniach innych procesów, mogą być powodowane przez żądania nowych stron
zerowych lub występować w sytuacji, gdy niezbędna strona została usunięta ze zbioru robo-
czego, ale jest żądana ponownie, zanim będzie możliwe jej powtórne użycie. Miękkie błędy
braku strony mogą również wystąpić ze względu na to, że strony zostały skompresowane, co
w efekcie przyczyniło się do zwiększenia rozmiaru pamięci fizycznej. W przypadku większości
konfiguracji procesora, pamięci i układów wejścia-wyjścia we współczesnych systemach bardziej
efektywne jest korzystanie z kompresji niż ponoszenie kosztów wejścia-wyjścia (w zakresie
wydajności i energii) wymaganych do odczytania strony z dysku.
Kiedy strona fizyczna nie jest odwzorowywana przez tablicę stron żadnego z procesów, trafia
na jedną z trzech list: listy stron wolnych, listy stron zmodyfikowanych i listy stron oczekują-
cych. Strony, które już nigdy nie będą potrzebne (np. strony stosu procesu, który zakończył dzia-
łanie), są zwalniane automatycznie. Te, które mogą ponownie powodować błąd braku strony,
trafiają albo na listę stron zmodyfikowanych, albo na listę stron oczekujących, w zależności od
tego, czy od czasu ostatniego odczytu z dysku ustawiono bit modyfikacji w którymś z wpisów
w tablicy stron odwzorowującej daną stronę. Strony na liście stron zmodyfikowanych ostatecz-
nie są zapisywane na dysku, po czym przenoszone na listę stron oczekujących.
Menedżer pamięci może alokować potrzebne strony, korzystając albo ze stron na liście wol-
nych stron, albo ze stron na liście stron oczekujących. Przed zaalokowaniem i skopiowaniem
strony z dysku menedżer pamięci zawsze sprawdza listy stron oczekujących i zmodyfikowanych
pod kątem zawierania żądanej strony — może się okazać, że potrzebna strona już teraz znaj-
duje się w pamięci. Zastosowany w systemie Windows schemat stronicowania z wyprzedze-
niem ma na celu zastąpienie potencjalnych twardych błędów braku stron miękkimi błędami braku
stron poprzez odczytywanie z dysku stron, które prawdopodobnie będą potrzebne w niedale-
kiej przyszłości, i umieszczanie ich na liście stron oczekujących. Także sam menedżer pamięci
korzysta z ograniczonego mechanizmu stronicowania z wyprzedzeniem, odczytując z dysku
grupy sąsiadujących stron zamiast pojedynczych stron. Dodatkowe strony są niezwłocznie
umieszczane na liście stron oczekujących. Koszty takiego rozwiązania okazują się niewielkie,
ponieważ największym obciążeniem dla menedżera pamięci są poszczególne operacje wejścia-
-wyjścia. W tej sytuacji dodatkowy koszt odczytu pakietu stron zamiast pojedynczej strony jest
niezauważalny.
Na rysunku 11.17 pokazano wpisy w tablicy stron odwołujące się do numerów stron fizycznych
(zamiast do numerów stron wirtualnych). Aby zaktualizować wpisy w tablicy stron (i w katalogu
stron), jądro musi się posłużyć adresami wirtualnymi. System Windows odwzorowuje tablice
stron i katalogi stron dla bieżącego procesu w wirtualnej przestrzeni adresowej jądra, korzy-
stając z tzw. wpisu samoodwzorowania (ang. self-map) w katalogu stron (patrz rysunek 11.18).
Odwzorowanie wpisu w katalogu stron, aby wskazywał sam katalog stron (stąd mowa o samo-
odwzorowaniu), wymaga stosowania adresów wirtualnych wskazujących zarówno wpisy w kata-
logu stron (a), jak i wpisy w tablicy stron (b). Samoodwzorowanie dla każdego wpisu zajmuje te
same 8 MB wirtualnej przestrzeni adresowej jądra (w architekturze x86). Dla uproszczenia na
rysunku pokazano samoodwzorowanie dla 32-bitowych wpisów PTE (od ang. Page Table Entries).
W systemie Windows w istocie są stosowane 64-bitowe wpisy PTE, dzięki czemu ma on do
dyspozycji więcej niż 4 GB pamięci fizycznej. W przypadku 32-bitowych wpisów PTE samood-
wzorowanie wykorzystuje tylko jeden wpis PDE (od ang. Page Directory Entry) w katalogu stron,
a zatem zajmuje tylko 4 MB adresów zamiast 8 MB.
Jeśli jednak w systemie wystąpi presja pamięci, menedżer pamięci przystąpi do „wtłaczania”
procesów do ich zbiorów roboczych (począwszy od procesów, które w największym stopniu
przekroczyły rozmiar maksymalny). Istnieją trzy poziomy aktywności menedżera zbiorów robo-
czych — operacje na wszystkich tych poziomach mają charakter cykliczny i są podejmowane
według wskazań licznika czasowego. Każdy kolejny poziom wprowadza nowe działania:
1. Duża ilość dostępnej pamięci. Menedżer przeszukuje strony, zerując ich bity wykorzysta-
nia i przypisując im wiek na podstawie reprezentowanej zawartości. Menedżer stale śledzi
liczbę nieużywanych stron we wszystkich zbiorach roboczych.
2. Coraz mniejsza ilość dostępnej pamięci. Dla każdego procesu dysponującego dużą liczbą
nieużywanych stron wstrzymuje się dodawanie kolejnych stron do zbioru roboczego
i rozpoczyna się zastępowanie najstarszych stron w odpowiedzi na kolejne żądania przy-
działu nowych stron. Zastępowane strony trafiają na listy stron oczekujących lub zmody-
fikowanych.
3. Mała ilość dostępnej pamięci. Menedżer zmniejsza zbiory robocze poniżej ich rozmiarów
maksymalnych, usuwając z pamięci najstarsze strony.
Menedżer zbiorów roboczych podejmuje działania co sekundę i jest wywoływany przez
wątek menedżera równoważenia zbiorów (ang. balance set manager). Menedżer zbiorów roboczych
unika nadmiernej aktywności, aby swoimi działaniami nie przeciążać systemu. Menedżer stale
monitoruje operacje zapisywania na dysku stron z listy stron zmodyfikowanych, aby mieć pew-
ność, że wspomniana lista nie jest zbyt długa (w razie osiągnięcia zbyt dużego rozmiaru mene-
dżer zbiorów roboczych budzi wątek ModifiedPageWriter).
Rysunek 11.19. Najważniejsze pola bazy danych ramek stron dla prawidłowych stron pamięci
Wpisy w bazie danych ramek stron zawierają też łącza do kolejnych stron na liście (jeśli takie
strony istnieją) oraz rozmaite inne pola i flagi (w tym flagi trwającego odczytu i trwającego zapisu).
Aby tego rodzaju wpisy nie zajmowały zbyt wiele miejsca, elementy list są połączone za pomocą
pól wskazujących kolejne elementy w formie indeksów w tabeli, nie w formie wskaźników. Wpisy
reprezentujące strony fizyczne dodatkowo wykorzystuje się do generowania podsumowań bitów
brudnych stron odnajdywanych w różnych wpisach w tablicy stron wskazujących daną stronę
fizyczną (większa liczba wpisów występuje w przypadku stron współdzielonych). Informacje
przechowywane w tej bazie danych są też wykorzystywane do reprezentowania różnic w stro-
nach pamięci wielkich systemów serwerowych złożonych z procesorów dysponujących szybszą
pamięcią i procesorów z wolniejszą pamięcią (czyli komputerów NUMA).
Strony są przenoszone pomiędzy zbiorami roboczymi a poszczególnymi listami przez mene-
dżer zbiorów roboczych i inne wątki systemowe. Przeanalizujmy teraz przebieg takiej operacji.
Strona usuwana ze zbioru roboczego przez menedżer zbiorów trafia na dół listy stron oczeku-
jących lub listy stron zmodyfikowanych (w zależności od stanu, tj. od ewentualnych modyfikacji).
Tę operację pokazano w części (1) rysunku 11.20.
Rysunek 11.20. Schemat list reprezentujących różne rodzaje stron i przenoszenia stron pomiędzy
tymi listami
Strony na obu listach nadal są traktowane jako prawidłowe, zatem w razie wystąpienia błędu
braku strony i potrzeby użycia jednej z tych stron następuje jej usunięcie z listy i przywrócenie
do zbioru roboczego bez konieczności wykonywania jakiejkolwiek dyskowej operacji wejścia-
-wyjścia (2). Kiedy proces kończy działanie, jego prywatne (niewspółdzielone) strony z natury
rzeczy nie mogą zostać przywrócone do zbioru roboczego, zatem prawidłowe strony reprezen-
towane w tablicy stron oraz wszystkie jego strony na listach stron zmodyfikowanych i oczeku-
jących trafiają na listę wolnych stron (3). Zwalniana jest także ewentualna przestrzeń pliku stron
zajmowana przez ten proces.
Pozostałe operacje przenoszenia są powodowane przez inne wątki systemowe. Co 4 s mene-
dżer równoważenia zbiorów przeprowadza procedurę poszukiwania procesów, których wszystkie
wątki są bezczynne od pewnej liczby sekund. Jeśli odnajdzie jakieś procesy spełniające ten waru-
nek, ich stosy trybu jądra zostaną odłączone od pamięci fizycznej, a ich strony zostaną przenie-
sione na listy stron oczekujących lub zmodyfikowanych (1).
Dwa pozostałe wątki systemowe, wątek zapisujący strony odwzorowane (ang. mapped page writer)
oraz wątek zapisujący strony zmodyfikowane (ang. modified page writer), cyklicznie aktywują się,
aby sprawdzić, czy istnieje wystarczająco duża liczba czystych stron (niezmodyfikowanych). Jeśli
takich stron jest zbyt mało, wspomniane wątki zapisują na dysku pierwsze strony z listy stron
zmodyfikowanych, po czym przenoszą je na listę stron oczekujących (4). Pierwszy z tych wąt-
ków odpowiada za zapisywanie stron w plikach odwzorowanych, drugi obsługuje operacje zapisu
w plikach stron. W wyniku ich działania zmodyfikowane (brudne) strony są przekształcane
w strony oczekujące (czyste).
Zdecydowano się zastosować dwa odrębne wątki, ponieważ plik odwzorowany może wymagać
powiększenia wskutek operacji zapisu, a powiększanie plików wymaga dostępu do dyskowych
struktur danych (niezbędnych do alokacji wolnego bloku dyskowego). Brak miejsca w pamięci
w sytuacji, gdy odpowiedni wątek musi zapisać strony na dysku, mógłby skutkować zakleszcze-
niem. Problem rozwiązano dzięki zastosowaniu drugiego wątku, który zapisuje strony w pliku stron.
Poniżej opisano pozostałe operacje przedstawione na rysunku 11.20. Jeśli proces usuwa odwzo-
rowanie jakiejś strony, nie jest ona już skojarzona z tym procesem i może trafić na listę wolnych
stron (5), chyba że jest stroną współdzieloną. Kiedy błąd braku strony wymaga odczytania odpo-
wiedniej ramki stron, ramka jest (jeśli to możliwe) pobierana z listy wolnych stron (6). W takim
przypadku nie ma znaczenia to, że dana strona może nadal zawierać poufne informacje, ponie-
waż bezpośrednio po tej operacji zostanie w całości nadpisana.
Z zupełnie inną sytuacją mamy do czynienia podczas rozszerzania stosu. Wówczas konieczna
jest pusta ramka strony, a zasady bezpieczeństwa nakazują wypełnienie tej strony samymi zerami.
Do realizacji tego rodzaju żądań wykorzystuje się inny wątek systemowy jądra — wątek stron
zerowych (ang. ZeroPage thread), który ma przypisany najniższy priorytet (patrz rysunek 11.13)
i który odpowiada za usuwanie (zerowanie) zawartości stron z listy wolnych stron oraz przeno-
szenie ich na listę stron wyzerowanych (7). Strony można zerować także wtedy, gdy procesor
jest bezczynny i gdy istnieją wolne strony, ponieważ wyzerowane strony są potencjalnie bardziej
przydatne od pełnych stron, a w czasie bezczynności procesora sama procedura ich zerowania
nie wiąże się z żadnymi kosztami.
Istnienie wszystkich tych list wymaga podejmowania pewnych decyzji. Przypuśćmy, że system
musi przenieść stronę z dysku i że lista wolnych stron jest pusta. W takim przypadku system
musi zdecydować, czy należy wykorzystać czystą stronę z listy stron oczekujących (która może
być przedmiotem żądania w przyszłości, powodując ponowny błąd braku strony), czy pustą stronę
z listy stron wyzerowanych (wykorzystując efekt kosztownej operacji zerowania). Które rozwią-
zanie jest lepsze?
Menedżer pamięci musi zdecydować, na ile agresywnie wątki systemowe powinny przenosić
strony z listy stron zmodyfikowanych na listę stron oczekujących. Dysponowanie czystymi stro-
nami jest co prawda lepsze niż dysponowanie brudnymi stronami (z uwagi na możliwość wystą-
pienia konieczności ich natychmiastowego wykorzystania), jednak aktywna strategia „czysz-
czenia” stron oznacza więcej dyskowych operacji wejścia-wyjścia. Co więcej, nie można wykluczyć,
że wskutek błędów braku stron „wyczyszczone” przed momentem strony trzeba będzie ponow-
nie włączyć do zbioru roboczego, co z kolei znów spowoduje, że będą brudne. Ogólnie system
Windows rozwiązuje ten problem tak: stosuje algorytmy, heurystyki, zgaduje, kieruje się staty-
stykami historycznymi, działa na podstawie przyjętych reguł i korzysta z parametrów ustawionych
przez administratora.
W systemie Modern Windows wprowadzono dodatkową warstwę abstrakcji działającą „poniżej”
menedżera pamięci, zwaną menedżerem magazynu (ang. store manager). Warstwa ta jest odpowie-
dzialna za podejmowanie decyzji dotyczących sposobu optymalizacji operacji wejścia-wyjścia
z wykorzystaniem dostępnych magazynów pamięci trwałej. Systemy pamięci trwałej obok trady-
cyjnych dysków wirujących obejmują dodatkowe pamięci flash i dyski SSD.
Menedżer magazynu optymalizuje miejsce i sposób składowania stron pamięci fizycznej
w pamięci trwałej. Implementuje również techniki optymalizacji, takie jak kopiowanie przy zapisie,
współdzielenie identycznych stron fizycznych oraz kompresję stron na liście stron oczekują-
cych — mechanizmy te powodują skuteczne zwiększenie dostępnej pamięci RAM.
Kolejną zmianą w systemie zarządzania pamięcią w systemie Modern Windows jest wpro-
wadzenie pliku wymiany (ang. swap file). Zarządzanie pamięcią w systemie Windows, zgodnie z tym,
co opisano wyżej, historycznie bazuje na zestawach roboczych. Wraz ze wzrostem presji na pamięć
menedżer pamięci ścieśnia zbiory robocze w celu zmniejszenia śladu, jaki każdy z procesów zaj-
muje w pamięci. Nowoczesny model aplikacji wprowadza nowe możliwości poprawy wydajności.
Ponieważ procesowi zawierającemu pierwszoplanową część nowoczesnej aplikacji po przełącze-
niu się z niej do innej nie są już przydzielane zasoby procesora, nie ma powodu, aby strony tej
aplikacji nadal rezydowały w pamięci. W miarę zwiększania presji na pamięć w systemie, strony
procesu mogą być usuwane w ramach zwykłego zarządzania zbiorami roboczymi. Jednak mene-
dżer czasu życia procesów wie, ile czasu upłynęło od chwili, gdy użytkownik przełączył się do
pierwszoplanowego procesu aplikacji. Jeśli potrzebne jest więcej pamięci, to wybiera proces,
który nie działał od określonego czasu i wywołuje menedżera pamięci w celu wymiany wszyst-
kich stron za pomocą niewielkiej liczby operacji wejścia-wyjścia. Strony będą zapisane do pliku
wymiany poprzez zgrupowanie ich w jeden lub kilka dużych fragmentów. To oznacza, że cały
proces można także przywrócić w pamięci za pomocą mniejszej liczby operacji wejścia-wyjścia.
W gruncie rzeczy zarządzanie pamięcią jest bardzo złożonym komponentem wykonawczym,
obejmującym wiele struktur danych, algorytmów i heurystyki. Twórcy systemu operacyjnego
starali się, aby mechanizm ten w większości przypadków sam się konfigurował, ale istnieje rów-
nież wiele opcji, które administratorzy mogą dostosować, aby wpłynąć na wydajność systemu.
Niektóre z tych nastaw oraz związanych z nimi liczników można przeglądać za pomocą narzędzi
należących do różnych zestawów narzędzi wymienionych wcześniej. Należy przede wszystkim
zapamiętać, że zarządzanie pamięcią w rzeczywistych systemach to o wiele więcej niż jeden
prosty algorytm stronicowania, taki jak algorytm zegarowy lub algorytm starzenia się.
Pamięć podręczna systemu Windows podnosi wydajność systemów plików poprzez przechowy-
wanie ostatnio i często używanych obszarów plików w pamięci głównej. Zamiast przechowywać
w pamięci podręcznej fizycznie adresowane bloki plików, menedżer tej pamięci operuje na blo-
kach adresowanych wirtualnie, czyli na wspomnianych już obszarach plików. Takie rozwiązanie
lepiej pasuje do struktury rdzennego systemu plików NT (NTFS), o czym przekonamy się w pod-
rozdziale 11.8. System NTFS przechowuje wszystkie swoje dane, w tym metadane samego sys-
temu plików, w formie plików.
Obszary plików przechowywane w pamięci podręcznej określa się mianem perspektyw (ang.
views), ponieważ obszary adresów wirtualnych jądra są odwzorowane na pliki systemu plików.
Oznacza to, że za zarządzanie pamięcią fizyczną zajmowaną przez pamięć podręczną odpowiada
menedżer pamięci. W tej sytuacji zadania menedżera pamięci podręcznej ograniczają się do zarzą-
dzania wykorzystaniem adresów wirtualnych jądra na potrzeby perspektyw, wymuszania na mene-
dżerze pamięci ścisłego wiązania stron z pamięcią fizyczną (bez możliwości stronicowania) oraz
udostępniania niezbędnych interfejsów systemom plików.
Mechanizmy menedżera pamięci podręcznej systemu Windows są współużytkowane przez
wszystkie systemy plików. Ponieważ pamięć podręczna jest adresowana wirtualnie dla poszcze-
gólnych plików, menedżer tej pamięci może łatwo wykonywać operacje odczytu z wyprzedze-
niem na poziomie plików. Żądania dostępu do danych w pamięci podręcznej są inicjowane przez
wszystkie systemy plików. Wirtualne adresowanie pamięci plików jest więc o tyle wygodne, że
eliminuje konieczność tłumaczenia przesunięć w plikach (na numery fizycznych bloków) przez
poszczególne systemy plików przed każdym żądaniem strony pliku składowanego w pamięci
podręcznej. Tłumaczenie adresów odbywa się nieco później, kiedy menedżer pamięci wywołuje
system plików, aby uzyskać dostęp do strony na dysku.
Oprócz zarządzania adresami wirtualnymi jądra i fizycznymi zasobami pamięciowymi wyko-
rzystywanymi przez pamięć podręczną, menedżer tej pamięci musi jeszcze koordynować swoje
działania z systemami plików, aby zachowywać spójność perspektyw, okresowo zapisywać zmiany
na dysku i właściwie zarządzać znakami końca pliku (szczególnie w razie rozszerzania plików).
Jednym z najtrudniejszych aspektów współpracy systemu plików, menedżera pamięci podręcznej
i menedżera pamięci jest zarządzanie przesunięciami ostatnich bajtów plików, czyli wartością
ValidDataLength. Jeśli program zapisuje dane za oryginalnym końcem pliku, pominięte bloki należy
wypełnić zerami. Z uwagi na bezpieczeństwo kluczowe znaczenie ma wspomniana wartość Valid
DataLength rejestrowana w metadanych pliku i uniemożliwiająca dostęp do niezainicjalizowa-
nych bloków. Oznacza to, że bloki zerowe należy zapisać na dysku przed aktualizacją metada-
nych pliku (w związku z wydłużeniem pliku). Choć oczekuje się, że w razie awarii systemu
część bloków pliku przechowywanych w pamięci może nie zostać zapisana na dysku, umieszcze-
nie w pliku danych należących wcześniej do innych plików byłoby nie do zaakceptowania.
Przyjrzyjmy się teraz sposobowi funkcjonowania samego menedżera pamięci podręcznej.
W odpowiedzi na odwołanie do pliku menedżer pamięci podręcznej odwzorowuje dany
plik w 256-kilobajtowym fragmencie wirtualnej przestrzeni adresowej jądra. Jeśli rozmiar tego
pliku przekracza 256 kB, początkowo tylko jego część zostaje odwzorowana. Kiedy menedżer
pamięci podręcznej wyczerpie wszystkie 256-kilobajtowe pakiety wirtualnej przestrzeni adreso-
wej, będzie musiał usunąć odwzorowanie starego pliku, zanim będzie mógł odwzorować nowy
plik. Po odwzorowaniu pliku menedżer pamięci może realizować żądania dostępu do bloków tego
pliku, kopiując odpowiednie dane z wirtualnej przestrzeni adresowej jądra do bufora użytkownika.
Jeśli żądany blok nie jest przechowywany w pamięci fizycznej, wystąpi błąd braku strony, który
zostanie obsłużony przez menedżer pamięci w zwykły sposób. Menedżer pamięci podręcznej
nawet nie wie, czy dany blok występuje w pamięci głównej. Żądanie kopiowania zawsze jest reali-
zowane pomyślnie.
Menedżer pamięci podręcznej sprawdza się także w przypadku stron odwzorowanych w pamięci
wirtualnej i będących przedmiotem odwołań za pośrednictwem wskaźników (zamiast poprzez
kopiowanie pomiędzy buforami trybów jądra i użytkownika). Kiedy jakiś wątek uzyskuje dostęp
do adresu wirtualnego odwzorowanego do pliku, powodując błąd braku strony, menedżer pamięci
w wielu przypadkach może zapewnić żądany dostęp w ramach procedury obsługi miękkiego błędu
braku strony. Realizacja tego zadania nie wymaga dostępu do dysku, ponieważ menedżer pamięci
odnajduje daną stronę w pamięci fizycznej (wskutek jej uprzedniego odwzorowania przez me-
nedżer pamięci podręcznej).
Sterowniki urządzeń
Aby zagwarantować prawidłową współpracę sterowników urządzeń z pozostałymi elementami
systemu Windows, firma Microsoft zdefiniowała model WDM (od ang. Windows Driver Model),
z którym powinny być zgodne sterowniki urządzeń dla tego systemu. Pakiet programistyczny
WDK (tzw. Windows Driver Kit) zawiera dokumentację i przykłady, które mają ułatwić progra-
mistom tworzenie sterowników zgodnych z WDM. Prace nad większością sterowników sys-
temu Windows rozpoczynają się od skopiowania przykładowego sterownika i jego stopniowego
modyfikowania.
Firma Microsoft opracowała też weryfikator sterowników, który sprawdza wiele różnych dzia-
łań podejmowanych przez sterowniki pod kątem zgodności ich struktur danych, protokołów żądań
wejścia-wyjścia, zarządzania pamięcią itp. z wymogami modelu WDM. Weryfikator jest insta-
lowany wraz z systemem — administratorzy mogą z niego korzystać po uruchomieniu pro-
gramu verifier.exe, który umożliwia wskazanie sterowników do sprawdzenia i zakresu (a więc
i kosztów) weryfikacji.
Mimo wszystkich tych zabiegów na rzecz wsparcia twórców sterowników i weryfikacji goto-
wych rozwiązań pisanie nawet prostych sterowników dla systemu Windows wciąż jest dość trudne.
Firma Microsoft zdecydowała się więc opracować system opakowań nazwany WDF (od ang.
Windows Driver Foundation), który działa ponad modelem WDM i upraszcza wiele typowych
wymagań tego modelu (związanych przede wszystkim ze współpracą z mechanizmami zarządzania
zasilaniem i operacjami plug and play).
Aby dodatkowo uprościć pisanie sterowników (i jednocześnie podnieść niezawodność samego
systemu operacyjnego), WDF dodatkowo oferuje framework UMDF (od ang. User-Mode Driver
Framework) dla sterowników implementowanych w formie usług wykonywanych w procesach.
Istnieje też odrębny framework KMDF (od ang. Kernel-Mode Driver Framework) dla sterowników
w formie usług wykonywanych w jądrze (ale wiele mało znanych szczegółów modelu WDM jest
realizowanych „automagicznie”). Ponieważ wymienione frameworki działają ponad modelem
sterowników WDM, właśnie na nim skoncentrujemy się w tym podpunkcie.
W systemie Windows urządzenia są reprezentowane przez obiekty urządzeń. Obiekty urzą-
dzeń wykorzystuje się też do reprezentowania takich składników architektury sprzętowej jak
magistrale, a także abstrakcje programowe, czyli systemy plików, moduły protokołów sieciowych
czy rozszerzenia jądra (np. sterowniki filtrów antywirusowych). Wszystkie te elementy orga-
nizuje się w ramach struktury określanej mianem stosu urządzeń (pokazanej na rysunku 11.7
we wcześniejszej części tego rozdziału).
Operacje wejścia-wyjścia są inicjowane przez menedżer wejścia-wyjścia korzystający z API
IoCallDriver warstwy wykonawczej, które z kolei wskazuje na szczyt obiektu urządzenia i pakiet
IRP reprezentujący dane żądanie wejścia-wyjścia. Procedura IoCallDriver odnajduje obiekt ste-
rownika skojarzony z danym obiektem urządzenia. Typy operacji wskazywane w pakietach żądań
IRP zwykle odpowiadają opisanym powyżej wywołaniom systemowym menedżera wejścia-wyjścia
(np. CREATE, READ i CLOSE).
Na rysunku 11.21 pokazano relacje występujące na pojedynczym poziomie stosu urządzeń.
Dla każdej z obsługiwanych operacji sterownik musi wskazywać punkt wejścia. Procedura
IoCallDriver otrzymuje na wejściu typ żądanej operacji z pakietu IRP, po czym wykorzystuje
obiekt urządzenia na bieżącym poziomie stosu urządzeń do odnalezienia odpowiedniego obiektu
sterownika i odnajduje w tablicy asocjacyjnej sterownika indeks właściwy danemu typowi operacji
(aby zidentyfikować odpowiedni punkt wejścia do wspomnianego sterownika). Bezpośrednio potem
następuje wywołanie sterownika — na wejściu tego wywołania przekazuje się obiekt urządzenia
i pakiet IRP.
Kiedy sterownik zakończy przetwarzanie żądania reprezentowanego przez pakiet IRP, moż-
liwe są trzy rozwiązania. Sterownik może ponownie wywołać procedurę IoCallDriver i przekazać
na jej wejściu dany pakiet IRP oraz następny obiekt urządzenia ze stosu urządzeń. Może zade-
klarować dane żądanie wejścia-wyjścia jako zrealizowane i zwrócić sterowanie do kodu wywołu-
jącego. Może też umieścić pakiet IRP w wewnętrznej kolejce i zwrócić sterowanie do kodu
wywołującego (deklarując, że wspomniane żądanie wejścia-wyjścia wciąż czeka na wykonanie).
Ostatni przypadek skutkuje powstaniem asynchronicznej operacji wejścia-wyjścia, pod warun-
kiem że wszystkie sterowniki powyżej (w ramach stosu) zgodzą się na to rozwiązanie i także
zwrócą sterowanie swoim wątkom wywołującym.
Pakiet żądań IRP obejmuje flagi, kod operacji wykorzystywany w roli indeksu tablicy asocja-
cyjnej sterownika, wskaźniki do buforów (mogą wskazywać zarówno bufor jądra, jak i bufor użyt-
kownika) oraz listę struktur MDL (od ang. Memory Descriptor Lists) opisujących strony fizyczne
reprezentowane przez te bufory (na potrzeby operacji DMA). Istnieją też pola wykorzystywane
do anulowania operacji oraz sygnalizowania ich zakończenia. Część pól, wykorzystywanych do
kolejkowania żądań IRP na poziomie poszczególnych urządzeń, jest ponownie używana po
ostatecznym zakończeniu operacji wejścia-wyjścia (w roli pamięci dla obiektu kontrolnego APC
wywołującego procedurę końca operacji menedżera wejścia-wyjścia wykonywaną w kontekście
oryginalnego wątku). Istnieje też pole łącza wiążące wszystkie oczekujące pakiety IRP z wąt-
kiem, który je zainicjował.
Stosy urządzeń
W systemie Windows sterownik może samodzielnie wykonywać wszystkie swoje zadania —
tak działa np. sterownik drukarki (patrz rysunek 11.23). Z drugiej strony sterowniki można też
łączyć w stosy, aby żądania przechodziły przez sekwencję sterowników, z których każdy wyko-
nuje swoją część wspólnego zadania. Dwa przykłady stosów sterowników pokazano na wspo-
mnianym już rysunku 11.23.
Typowym zastosowaniem sterowników łączonych w stosy jest oddzielanie zarządzania magi-
stralą od zadań związanych ze sterowaniem samym urządzeniem. Zarządzanie np. magistralą PCI
jest dość skomplikowane w związku z licznymi trybami i transakcjami. Oddzielenie tych zadań od
operacji właściwych samym urządzeniom zwalnia twórców sterowników z obowiązku opanowy-
wania zasad zarządzania odpowiednią magistralą — wystarczy, że użyją w swoim stosie stan-
dardowego sterownika magistrali. Podobnie sterowniki magistral USB i SCSI obejmują część
odpowiedzialną za obsługę urządzenia oraz część uniwersalną operującą na magistrali (sterowniki
wchodzące w skład tej części stosu i zarządzające najbardziej popularnymi magistralami są dostar-
czane wraz z systemem Windows).
Innym zastosowaniem stosów sterowników jest włączanie do procesu przetwarzania żądań
mechanizmów zaimplementowanych w sterownikach filtrów. Wspominaliśmy już o możliwych
formach wykorzystywania tego rodzaju sterowników pracujących ponad systemem plików. Ste-
rowniki filtrów wykorzystuje się także do zarządzania fizycznym sprzętem. Sterownik filtra może
wykonywać dodatkowe działania na operacjach zarówno w czasie ich przekazywania w dół stosu
urządzeń, jak i po zakończeniu operacji, kiedy sterowanie jest przekazywane w górę stosu do
procedur kończących wskazanych przez kolejne sterowniki. Sterownik filtra może np. kompreso-
wać dane kierowane na dysk lub szyfrować dane wysyłane za pośrednictwem sieci. Umiesz-
czenie filtra na stosie oznacza, że ani program aplikacji, ani właściwy sterownik urządzenia nie
muszą dysponować informacjami o istnieniu dodatkowego mechanizmu filtrującego stosowanego
automatycznie dla wszystkich danych kierowanych do danego urządzenia (lub otrzymywanych
z tego urządzenia).
Sterowniki urządzeń trybu jądra stanowią poważny problem dla niezawodności i stabilności
systemu Windows. Większość awarii tego systemu wynika właśnie z błędów w sterownikach
urządzeń. Ponieważ sterowniki urządzeń trybu jądra korzystają z tej samej przestrzeni adre-
sowej co warstwa jądra i warstwa wykonawcza, błędy w tych sterownikach mogą prowadzić
(w najlepszym razie) do uszkodzeń w systemowych strukturach danych. Część tych błędów wynika
z ogromnej, trudnej do ogarnięcia liczby sterowników dla systemu Windows oraz z prób imple-
mentowania sterowników przez mniej doświadczonych programistów systemowych. Znaczna
część błędów jest pochodną ogromnej liczby szczegółów związanych z pisaniem prawidłowych
sterowników dla systemu Windows.
Model wejścia-wyjścia jest rozbudowany i elastyczny, jednak wszystkie operacje wejścia-
-wyjścia mają charakter asynchroniczny, co może prowadzić do sytuacji wyścigu. Windows 2000
był pierwszym systemem z rodziny NT, do którego dodano technologię plug and play i mecha-
nizmy zarządzania zasilaniem zaczerpnięte z systemów Win9x. Takie rozwiązanie doprowadziło
do tego, że pojawiły się liczne dodatkowe wymagania stawiane sterownikom, które począwszy od
System operacyjny Windows obsługuje wiele systemów plików, z których najważniejsze są sys-
temy FAT-16, FAT-32 oraz NTFS (od ang. NT File System). FAT-16 to stary system plików znany
jeszcze z MS-DOS-a. FAT-16 wykorzystuje 16-bitowe adresy dyskowe, co ogranicza maksymalny
rozmiar partycji do 2 GB. System plików FAT-16 stosuje się przede wszystkim na coraz rzadziej
wykorzystywanych dyskietkach. FAT-32 wykorzystuje 32-bitowe adresy dyskowe i umożliwia
obsługę partycji zajmujących nie więcej niż 2 TB. System plików FAT-32 nie oferuje żadnych
mechanizmów zapewniających bezpieczeństwo danych, zatem jest obecnie stosowany głównie
na nośnikach wymiennych, np. napędach flash. NTFS jest systemem plików opracowanym spe-
cjalnie dla wersji NT systemu Windows. Począwszy od systemu Windows XP, właśnie NTFS był
domyślnym systemem plików instalowanym przez większość producentów komputerów. NTFS
znacznie poprawił bezpieczeństwo i funkcjonalność systemu Windows. System NTFS wyko-
rzystuje 64-bitowe adresy dyskowe i może (przynajmniej teoretycznie) obsługiwać partycje dys-
kowe obejmujące maksymalnie 264 bajtów (inne aspekty ograniczają jednak ten rozmiar do niż-
szych poziomów).
W tym podrozdziale skoncentrujemy się na systemie NTFS, czyli współczesnym systemie
plików wprowadzającym wiele interesujących rozwiązań i innowacji projektowych. NTFS jest
wyjątkowo rozbudowanym i złożonym systemem plików — ograniczona przestrzeń co prawda
uniemożliwia nam omówienie wszystkich jego elementów, jednak poniższy materiał powinien
wystarczyć do zrozumienia ogólnej koncepcji przyjętej przez twórców tego systemu.
Rozróżnianie wielkości liter jest czymś zupełnie naturalnym dla użytkowników systemów UNIX,
ale dla pozostałych użytkowników byłoby dalece niewygodne (np. internet w obecnej formie jest
niemal całkowicie pozbawiony elementów rozróżniania wielkości liter).
Plik systemu NTFS nie jest zwykłą, liniową sekwencją bajtów (jak w systemie plików
FAT-32 czy systemach operacyjnych UNIX). Plik NTFS składa się raczej z wielu atrybutów,
z których każdy jest reprezentowany przez strumień bajtów. W przypadku większości plików
tego rodzaju strumienie są dość krótkie i reprezentują nazwy plików, 64-bitowe identyfikatory
ich obiektów oraz długi (nienazwany) strumień z właściwymi danymi. Okazuje się jednak, że
plik może obejmować dwa lub większą liczbę dodatkowych (długich) strumieni danych. Każdy
taki strumień ma przypisana nazwę złożoną z nazwy samego pliku, dwukropka oraz nazwy
strumienia, np. foo:stream1. Każdy strumień ma określony rozmiar i może być blokowany nie-
zależnie od pozostałych strumieni. Idea wielu strumieni składających się na jeden plik nie jest
niczym nowym i nie została zaimplementowana wraz z systemem NTFS. Przykładowo system
plików w komputerach Apple Macintosh wykorzystuje po dwa strumienie na plik — gałąź danych
i gałąź zasobów. Decyzja o zastosowaniu wielu strumieni w systemie NTFS miała przede wszyst-
kim na celu umożliwienie współpracy serwerów plików NT z klientami Macintosh. Dodatkowe
strumienie danych wykorzystuje się także do reprezentowania metadanych opisujących pliki,
np. miniatury obrazów JPEG prezentowane przez graficzny interfejs użytkownika systemu
Windows. Z drugiej strony większa liczba strumieni bitów powoduje ryzyko utraty części danych
przesyłanych do innych systemów plików, przesyłanych za pośrednictwem sieci czy nawet zapi-
sywanych w pamięci zapasowej i odtwarzanych — problemem jest ignorowanie dodatkowych
strumieni przez wiele narzędzi.
NTFS jest hierarchicznym systemem plików dość podobnym do systemu stosowanego
w Uniksie. W systemie plików NTFS zachowano lewy ukośnik (\) w roli separatora składowych
w ścieżkach (odziedziczony kiedyś przez system MS-DOS po systemie CP/M), zamiast stoso-
wanego w systemie UNIX prawego ukośnika (/). Inaczej niż w systemie UNIX, w systemach
Windows koncepcje bieżącego katalogu roboczego, czyli twardych dowiązań do katalogu bieżą-
cego (.) i jego katalogu macierzystego (..), zaimplementowano raczej jako konwencję, a nie
integralny element projektu systemu plików. Dowiązania symboliczne są co prawda obsługiwane
w systemie plików NTFS, ale tylko na potrzeby podsystemu POSIX; z podobną sytuacją mamy
do czynienia w przypadku uprawnień do przeszukiwania katalogów (reprezentowanych w syste-
mie UNIX przez literę x).
W systemie plików NTFS dowiązania symboliczne nie były obsługiwane do czasu wprowa-
dzenia systemu operacyjnego Windows Vista. Możliwość tworzenia dowiązań symbolicznych zwykle
jest zastrzeżona dla administratorów, aby uniknąć zagrożeń związanych np. z podszywaniem się
użytkowników (tego rodzaju zdarzenia stanowiły niemały kłopot, kiedy po raz pierwszy wprowa-
dzono dowiązania symboliczne w systemie 4.2BSD). System Windows wykorzystuje do implemen-
towania dowiązań symbolicznych mechanizm systemu plików NTFS nazwany punktami przyłączania
(ang. reparse points), które omówimy w dalszej części tego podrozdziału. NTFS dodatkowo obsłu-
guje kompresję, szyfrowanie, odporność na uszkodzenia, księgowanie i działania na rozproszo-
nych plikach. Wymienione mechanizmy i ich implementacje omówimy za chwilę.
pliku tablicy MFT w bloku startowym (adres tego pierwszego bloku jest określany w czasie for-
matowania danego woluminu z systemem plików NTFS).
Pierwszy rekord jest powieleniem początkowego fragmentu pliku MFT. Informacje zawarte
w tym rekordzie są na tyle cenne, że dodatkowa kopia może mieć zasadnicze znaczenie w razie
wystąpienia błędów w pierwszych blokach tablicy MFT. Drugi rekord zawiera plik dziennika.
Każda zmiana strukturalna w systemie plików (polegająca np. na dodaniu nowego lub usunięciu
istniejącego katalogu) jest rejestrowana w tym dzienniku jeszcze przed właściwym wykonaniem,
aby zwiększyć szanse prawidłowego odtworzenia systemu w razie ewentualnej awarii (np. nagłego
wyłączenia systemu) w trakcie wykonywania tej operacji. W tym samym dzienniku rejestruje się
także zmiany atrybutów plików. W praktyce jedynymi nierejestrowanymi zmianami są modyfi-
kacje samych danych użytkownika. Trzeci rekord zawiera takie informacje o woluminie jak jego
rozmiar, etykieta czy wersja.
Jak już wspomniano, każdy rekord tablicy MFT zawiera sekwencję par nagłówek atrybutu-
wartość. Same atrybuty są definiowane w pliku $AttrDef. Informacje o tym pliku przechowuje
się w czwartym rekordzie tablicy MFT. Bezpośrednio po nim (w piątym rekordzie tablicy MFT)
następuje katalog główny, który sam jest plikiem i może rosnąć do dowolnego rozmiaru.
Do śledzenia wolnej przestrzeni na woluminie wykorzystuje się mapę bitową. Okazuje się,
że także ta mapa ma postać pliku, a jej atrybuty i adresy dyskowe są przechowywane w szóstym
rekordzie tablicy MFT. Kolejny rekord tej tablicy wskazuje na plik programu ładującego. Ósmy
rekord wykorzystuje się w roli miejsca gromadzącego wszystkie błędne bloki, aby nigdy nie były
przydzielane właściwym plikom. Dziewiąty rekord zawiera informacje zabezpieczające. Dzie-
siąty rekord służy odwzorowywaniu wielkości liter. Dla liter alfabetu łacińskiego (A – Z) takie
odwzorowanie jest dość oczywiste (przynajmniej dla osób posługujących się tym alfabetem).
Z drugiej strony dla osób posługujących się alfabetem łacińskim rozróżnianie wielkości liter języ-
ków greckiego, ormiańskiego czy gruzińskiego jest sporym problemem, stąd decyzja o zastoso-
waniu pliku z odpowiednimi odwzorowaniami. I wreszcie jedenasty rekord to katalog obejmu-
jący najróżniejsze pliki dla takich zadań jak zarządzanie limitami dyskowymi, identyfikatorami
obiektów, punktami przyłączania itp. Ostatnie cztery rekordy tablicy MFT zarezerwowano dla
przyszłych zastosowań.
Każdy rekord tablicy MFT składa się z nagłówka oraz następujących po nim par nagłówek
atrybutu-wartość. Nagłówek rekordu zawiera magiczną liczbę wykorzystywaną w procesie
weryfikacji jego poprawności, liczbę porządkową aktualizowaną przy okazji każdego użycia tego
rekordu dla nowego pliku, licznik odwołań do danego pliku, rzeczywistą liczbę bajtów zajmowa-
nych przez ten rekord, identyfikator (indeks i liczbę porządkową) rekordu bazowego (stosowanego
dla rekordów rozszerzeń) oraz kilka pól dodatkowych.
System plików NTFS definiuje trzynaście atrybutów, które mogą występować w rekordach
tablicy MFT. Wszystkie trzynaście atrybutów wymieniono i krótko opisano w tabeli 11.16. Każdy
nagłówek atrybutu identyfikuje sam atrybut, określa długość i położenie pola wartości oraz defi-
niuje ustawienia rozmaitych flag i inne informacje. Wartości atrybutów zwykle następują bezpo-
średnio po nagłówku; jeśli jednak wartość jest na tyle długa, że nie mieści się w danym rekordzie
MFT, może trafić do odrębnych bloków dyskowych. Tego rodzaju atrybuty określa się mianem
atrybutów nierezydentnych (ang. nonresident attributes). Naturalnym kandydatem na taki atrybut
jest atrybut danych. Niektóre atrybuty, np. nazwa pliku, mogą się powtarzać, jednak ich porzą-
dek w ramach rekordu tablicy MFT musi być stały. Nagłówki atrybutów rezydentnych zajmują
po 24 bajty; nagłówki atrybutów nierezydentnych są dłuższe, ponieważ muszą zawierać infor-
macje o miejscu składowania swoich wartości na dysku.
wszystko, aby drugi blok logiczny tego strumienia znalazł się w 21. bloku dyskowym, trzeci w 22.
bloku dyskowym itd. Jednym ze sposobów realizacji tego celu jest jednoczesne alokowanie wielu
bloków przestrzeni dyskowej (w miarę możliwości).
Bloki strumienia są opisywane przez sekwencję rekordów, z których każdy opisuje sekwencję
logicznie przylegających bloków. Dla strumienia pozbawionego luk w zajmowanej przestrzeni dys-
kowej istnieje tylko jeden taki rekord. Strumienie z tej kategorii są zapisywane zgodnie z natu-
ralnym porządkiem — od początku do końca. Dla strumienia z jedną luką w przestrzeni dyskowej
(np. zajmującego bloki z przedziałów 0 – 49 oraz 60 – 79) będą istniały dwa rekordy. Wygene-
rowanie takiego strumienia wymaga zapisania pierwszych 50 bloków, odnalezienia 60. bloku
logicznego i zapisania kolejnych 20 bloków. Podczas późniejszego odczytywania luki dzielącej
rekordy brakujące bajty zawierają same zera. Pliki z takimi lukami określa się mianem plików
rozproszonych (ang. sparse files).
Każdy rekord rozpoczyna się od nagłówka określającego przesunięcie pierwszego bloku
w ramach danego strumienia. Następnym polem rekordu jest przesunięcie pierwszego bloku,
który nie jest opisywany przez ten rekord. W przytoczonym powyżej przykładzie rekord miałby
nagłówek (0, 50) i wskazywałby adresy dyskowe tych 50 bloków. Drugi rekord miałby nagłówek
(60, 80) i także wskazywałby adresy dyskowe odpowiednich 20 bloków.
Po nagłówku każdego rekordu następuje jedna lub wiele par opisujących adres dyskowy
i długość sekwencji rozpoczynającej się od tego adresu. Adres dyskowy ma postać przesunięcia
bloku dyskowego względem początku odpowiedniej partycji; długość sekwencji jest po prostu
liczbą bloków zajmowanych przez daną sekwencję. W rekordzie sekwencji może się znaleźć
tyle par, ile potrzeba. Przykład użycia tego schematu dla trzech sekwencji zajmujących łącznie
dziewięć bloków pokazano na rysunku 11.25.
Rysunek 11.25. Rekord tablicy MFT dla strumienia złożonego z trzech sekwencji i zajmującego
dziewięć bloków
Na rysunku 11.25 pokazano rekord tablicy MFT dla krótkiego strumienia dziewięciu bloków
(nagłówki 0 – 8). Strumień składa się z trzech sekwencji obejmujących przylegające do siebie
bloki dyskowe. Pierwsza sekwencja zajmuje bloki 20 – 23, druga sekwencja zajmuje bloki 64 – 65,
a trzecią sekwencję umieszczono w blokach dyskowych 80 – 82. Każda z tych sekwencji jest
opisywana przez rekord tablicy MFT w formie pary (adres dyskowy, liczba bloków). Liczba takich
sekwencji zależy od skuteczności mechanizmu alokującego bloki dyskowe, który powinien odnaj-
dywać odpowiednią liczbę następujących po sobie bloków już w czasie tworzenia strumienia.
W przypadku strumienia n-blokowego liczba sekwencji może wynosić od 1 do n.
Problem ma miejsce dopiero wtedy, gdy liczba rekordów tablicy MFT osiąga poziom uniemoż-
liwiający reprezentowanie wszystkich ich indeksów w podstawowej tablicy MFT. Także dla tego
problemu istnieje skuteczne rozwiązanie — lista rekordów rozszerzeń tablicy MFT nie musi mieć
charakteru rezydentnego (może być przechowywana w innych blokach dyskowych, zamiast w pod-
stawowym rekordzie MFT). Lista rekordów może wówczas rosnąć w nieskończoność.
Na rysunku 11.27 pokazano przykładowy wpis w tablicy MFT dla niewielkiego katalogu.
Przedstawiony rekord zawiera wiele wpisów, z których każdy opisuje jeden plik lub katalog.
Każdy wpis obejmuje strukturę stałej długości oraz nazwę pliku (lub katalogu) zmiennej długości.
Stała część reprezentuje indeks rekordu tablicy MFT właściwego danemu plikowi, długość nazwy
pliku oraz rozmaite inne pola i flagi. Procedura poszukiwania wpisu w katalogu polega na analizie
wszystkich kolejnych nazw plików.
wywołuje procedurę IoCompleteRequest, aby ponownie przekazać pakiet IRP do stosu wejścia-
wyjścia oraz menedżerów wejścia-wyjścia i obiektów. Uchwyt obiektu tego pliku ostatecznie trafia
do tablicy uchwytów utrzymywanej dla bieżącego procesu, a sterowanie jest zwracane do trybu
użytkownika. W kolejnych wywołaniach procedury ReadFile aplikacja może posługiwać się otrzy-
manym uchwytem, aby zasygnalizować, że obiekt pliku C:\foo\bar powinien być dołączany do żądań
odczytu przekazywanych w dół stosu urządzeń woluminu C: (aż do systemu plików NTFS).
Oprócz standardowych plików i katalogów system NTFS obsługuje tzw. dowiązania twarde
(znane z systemu operacyjnego UNIX) oraz dowiązania symboliczne z wykorzystaniem mecha-
nizmu nazwanego punktami przyłączania. System plików NTFS oferuje możliwość oznaczania
plików lub katalogów jako punkty przyłączania i kojarzenia z nimi bloków danych. Kiedy taki plik
lub katalog jest odnajdywany w czasie przetwarzania nazwy pliku, operacja poszukiwania pliku
kończy się niepowodzeniem, a menedżer obiektów otrzymuje blok danych skojarzony z tym
punktem. Menedżer obiektów analizuje te dane pod kątem zawierania alternatywnej ścieżki do
pliku, po czym aktualizuje oryginalny łańcuch i ponownie podejmuje próbę jego analizy i wyko-
nania żądanej operacji wejścia-wyjścia. Opisany mechanizm wykorzystuje się do obsługi zarówno
dowiązań symbolicznych, jak i montowanych systemów plików — operacje przeszukiwania są
wówczas kierowane do innych fragmentów hierarchii katalogów lub nawet do innej partycji.
Punkty przyłączania są również wykorzystywane do oznaczania pojedynczych plików na
potrzeby sterowników filtrów systemu plików. Na rysunku 11.11 pokazano, jak można zainsta-
lować filtry systemu plików pomiędzy menedżerem wejścia-wyjścia a samym systemem plików.
Realizację żądań operacji wejścia-wyjścia kończy się wywołaniami procedury IoCompleteRequest,
która przekazuje sterowanie do odpowiednich procedur kończących (reprezentowanych przez
poszczególne sterowniki na stosie urządzeń i umieszczanych w pakiecie IRP przy okazji ini-
cjowania żądania). Sterownik zainteresowany takim rozwiązaniem kojarzy z plikiem tag przyłą-
czenia, po czym monitoruje operacje otwierania plików pod kątem niepowodzeń wynikających
z napotykania punktów przyłączania. Na podstawie zwróconego bloku danych (wraz z pakie-
tem IRP) sterownik może określić, czy jest to blok danych skojarzony przez ten sterownik
z danym plikiem. Jeśli tak, sterownik zatrzymuje przetwarzanie zdarzenia końca operacji i kon-
tynuuje przetwarzanie oryginalnego żądania wejścia-wyjścia. Opisany mechanizm wymaga zmiany
sposobu realizacji żądania otwarcia pliku, jednak istnieje flaga wymuszająca na systemie NTFS
ignorowanie punktu przyłączenia i otwieranie danego pliku mimo istnienia tego punktu.
Kompresja plików
System plików NTFS obsługuje kompresję plików w sposób niewidoczny dla pozostałych skład-
ników systemu operacyjnego. Plik można utworzyć w trybie kompresji — system NTFS próbuje
wówczas automatycznie kompresować bloki zapisywane na dysku i automatycznie dekompre-
sować pliki odczytywane z dysku. Procesy odczytujące lub zapisujące tak kompresowane pliki
w ogóle nie mają świadomości stosowania wspomnianych technik kompresji i dekompresji.
Działanie mechanizmu kompresji jest następujące. Kiedy system NTFS zapisuje na dysku
plik oznaczony jako przeznaczony do kompresji, analizuje pierwsze 16 bloków logicznych tego
pliku (niezależnie od zajmowanych przez nie sekwencji bloków fizycznych), po czym stosuje dla
nich algorytm kompresujący. Jeśli dane wynikowe mieszczą się w 15 blokach lub mniejszej ich
liczbie, skompresowane dane są zapisywane na dysku — jeżeli to możliwe, w ramach jednej
sekwencji. Jeśli skompresowane dane nadal zawierają 16 bloków, system NTFS zapisuje je na
dysku w oryginalnej, nieskompresowanej formie. Po przetworzeniu pierwszych 16 bloków system
NTFS analizuje bloki 16 – 31 pod kątem możliwości kompresji do 15 lub mniejszej liczby bloków
i ponawia tę procedurę do wyczerpania bloków kompresowanego pliku.
Na rysunku 11.28(a) pokazano plik, dla którego udało się skutecznie skompresować pierwsze
16 bloków do 8 bloków. Próba kompresji drugiego pakietu 16 bloków zakończyła się niepowo-
dzeniem, skompresowano za to kolejny, trzeci pakiet 16 bloków, gdzie także udało się uzyskać
50-procentowy współczynnik kompresji. Wymienione trzy fragmenty zapisano w formie trzech
sekwencji składowanych w przedstawionym rekordzie MFT. „Brakujące” bloki są składowane
we wpisie w tablicy MFT pod zerowym adresem dyskowym, co pokazano na rysunku 11.28(b).
W przedstawionym scenariuszu po nagłówku (0, 48) następuje pięć par: dwie dla pierwszej (skom-
presowanej) sekwencji, jedna dla nieskompresowanej sekwencji i dwie dla ostatniej (skompre-
sowanej) sekwencji.
Rysunek 11.28. (a) Przykład 48-blokowego pliku skompresowanego do 32 bloków; (b) rekord
tablicy MFT dla tego pliku po kompresji
Kiedy tak skompresowany plik jest ponownie odczytywany, system NTFS musi dysponować
informacjami o tym, które sekwencje są skompresowane, a które nie były poddane kompresji.
Można to stwierdzić na podstawie adresów dyskowych. Adres dyskowy równy 0 oznacza końcową
część 16 skompresowanych bloków. Aby uniknąć problemów z interpretacją, zerowy blok dys-
kowy nie może być wykorzystywany do przechowywania danych. Ponieważ blok zerowy na tym
woluminie zawiera sektor startowy, użycie go do przechowywania i tak byłoby niemożliwe.
Swobodny dostęp do zawartości skompresowanych plików jest możliwy, ale wymaga pewnych
dodatkowych zabiegów. Przypuśćmy, że jakiś proces szuka bloku 35 z rysunku 11.28. Jak system
NTFS może zlokalizować blok 35 w skompresowanym pliku? W pierwszej kolejności musi odczy-
tać i zdekompresować całą pierwszą sekwencję. Na tej podstawie może zlokalizować blok 35
i przekazać go procesowi żądającemu jego odczytu. Wybór pakietów 16-blokowych do roli jed-
nostki kompresji jest wynikiem pewnego kompromisu. Krótsze pakiety spowodowałyby niższą
efektywność kompresji. Dłuższe pakiety podniosłyby koszty operacji swobodnego dostępu.
Księgowanie
System plików NTFS udostępnia programom dwa mechanizmy wykrywania zmian w plikach
i katalogach na wskazanym woluminie. Pierwszym z tych mechanizmów jest operacja wejścia-
-wyjścia nazwana NtNotifyChangeDirectoryFile, która przekazuje systemowi pewien bufor i zwraca
sterowanie w odpowiedzi na zmianę wykrytą w danym katalogu lub jego podkatalogach. Wynik
tej operacji wejścia-wyjścia jest umieszczany we wspomnianym buforze w formie listy rekordów
zmian. Jeśli bufor jest odpowiednio duży, reprezentuje wszystkie zmiany; w przeciwnym razie
rekordy zmian są bezpowrotnie tracone.
Drugim mechanizmem wykrywania zmian jest tzw. dziennik zmian systemu NTFS. System
plików NTFS zapisuje w specjalnym pliku listę wszystkich rekordów dla plików i katalogów na
danym woluminie. Plik dziennika jest dostępny do odczytu za pośrednictwem specjalnych operacji
kontrolnych systemu plików, a konkretnie opcji FSCTL_QUERY_USN_JOURNAL wywołania NtFsCon
trolFile. Plik dziennika zwykle jest bardzo duży, zatem prawdopodobieństwo ponownego
użycia wpisów przed ich sprawdzeniem okazuje się stosunkowo niewielkie.
Szyfrowanie plików
Współczesne komputery wykorzystuje się do przechowywania rozmaitych rodzajów poufnych
danych, jak plany przejęć konkurencyjnych przedsiębiorstw, rozliczenia podatkowe czy listy
miłosne. Dane tego typu z natury rzeczy nie powinny być udostępniane każdemu. Poufne dane
mogą trafić w niepowołane ręce w razie zagubienia lub kradzieży notebooka, ponownego urucho-
mienia komputera biurkowego z wykorzystaniem dyskietki startowej systemu MS-DOS, omi-
jającej zabezpieczenia systemu Windows, albo fizycznego usunięcia dysku twardego z jednego
komputera i jego ponownego zainstalowania w komputerze z niebezpiecznym systemem ope-
racyjnym.
W systemie Windows rozwiązano ten problem — zaoferowano możliwość szyfrowania plików,
aby nawet w razie kradzieży komputera lub uruchomienia pod kontrolą systemu MS-DOS prze-
chowywane na nim pliki były nieczytelne. Standardowym sposobem korzystania z mechanizmu
szyfrowania systemu Windows jest oznaczanie wybranych katalogów jako szyfrowane — w ten
sposób można wymusić szyfrowanie wszystkich plików już przechowywanych w tych katalogach
oraz plików tam przenoszonych i tworzonych. Za samo szyfrowanie i deszyfrowanie plików nie
odpowiada jednak system NTFS, tyko sterownik systemu plików EFS (od ang. Encryption File
System) rejestrujący wywołania zwrotne systemu NTFS.
System plików EFS oferuje możliwość szyfrowania zarówno całych katalogów, jak i poje-
dynczych plików. Istnieje też inny mechanizm szyfrowania danych w systemie Windows — to
tzw. BitLocker szyfrujący niemal wszystkie dane na woluminie i — tym samym — pomagający
chronić wszystkie dane, niezależnie od ich charakteru (oczywiście pod warunkiem że zostanie
wykorzystany z odpowiednio mocnymi kluczami). Przy obecnej liczbie gubionych i kradzionych
komputerów oraz charakterze poufnych danych przechowywanych na ich dyskach twardych wła-
ściwa ochrona tych informacji jest bardzo ważna. Codziennie właściciele tracą wprost niewia-
rygodną liczbę notebooków. Można przyjąć, że tylko największe korporacje z Wall Street tracą
jeden notebook tygodniowo pozostawiony w nowojorskich taksówkach.
Za zarządzanie zużyciem energii przez cały system jest odpowiedzialny menedżer zasilania.
W starszych systemach na zarządzanie zużyciem energii składały się operacje wyłączenia moni-
tora oraz zatrzymanie wirowania dysków. Jednak problem zarządzania energią znacznie się skom-
plikował. Przyczyną są wymagania dotyczące czasu, przez jaki notebooki powinny działać na baterii,
problemy dotyczące oszczędzania energii przez komputery desktop pozostawione przez cały
czas w stanie włączenia czy też wysokie koszty zasilania ogromnych farm serwerów.
Nowsze mechanizmy zarządzania energią zmniejszają zużycie energii przez komponenty
w przypadkach, kiedy system nie jest używany. W tym celu poszczególne urządzenia są prze-
łączane do stanu czuwania lub nawet całkowicie wyłączane za pomocą wyłączników programo-
wych. W systemach złożonych z wielu procesorów wyłaczane są pojedyncze procesory, jeśli
nie są potrzebne. Czasami nawet, w celu obniżenia zużycia energii, zmniejszana jest częstotliwość
taktowania procesorów. Gdy procesor jest bezczynny, także zużywa mniej energii, ponieważ
nie musi robić niczego poza czekaniem na wystąpienie przerwania.
System Windows obsługuje specjalny tryb zamykania — nazywany hibernacją — w którym
cała zawartość pamięci fizycznej jest kopiowana na dysk. W tym stanie poziom zużycia energii
jest minimalny (notebooki w stanie hibernacji mogą działać całe tygodnie), zatem bateria wyczer-
puje się bardzo powoli. Ponieważ stan pamięci jest w całości zapisany na dysku, w stanie hiber-
nacji można nawet wymienić baterię notebooka. Po wybudzeniu ze stanu hibernacji zapisany
stan pamięci zostanie przywrócony (a urządzenia wejścia-wyjścia będą ponownie zainicjowane).
Dzięki temu komputer zostanie przywrócony z powrotem do takiego samego stanu, w jakim
znajdował się przed hibernacją. Użytkownik nie będzie zmuszony do ponownego logowania się
i uruchamiania wszystkich aplikacji i usług, które wcześniej były uruchomione. Windows optyma-
lizuje ten proces w ten sposób, że niezmodyfikowane strony, które już wcześniej były zapisane
na dysk, są ignorowane, natomiast pozostałe strony pamięci, aby zmniejszyć przepustowość
wejścia-wyjścia, są poddawane kompresji. Algorytm hibernacji automatycznie się dostraja w celu
uzyskania równowagi pomiędzy wydajnością wejścia-wyjścia a przepustowością procesora. Jeśli
jest dostępnych więcej zasobów procesora, w celu zmniejszenia wymaganej przepustowości wej-
ścia-wyjścia wykorzystywana jest kosztowna, ale bardziej wydajna kompresja. Gdy przepustowość
wejścia-wyjścia jest wystarczająca, kompresja w procesie hibernacji jest całkowicie pomijana.
Przy współczesnej generacji systemów wieloprocesorowych zarówno hibernacja, jak i wznawianie
pracy mogą być wykonywane w ciągu kilku sekund — nawet w systemach wyposażonych
w wiele gigabajtów pamięci RAM.
Alternatywą dla hibernacji jest tryb gotowości (ang. standby mode), w którym menedżer zasi-
lania zmniejsza zużycie energii całego systemu do możliwie jak najniższego poziomu — tak by
zużywał tylko tyle energii, ile wystarczy do odświeżania dynamicznej pamięci RAM. Ponieważ
pamięć nie musi być kopiowana na dysk, stan ten w przypadku niektórych systemów jest nieco
szybszy od hibernacji.
Pomimo dostępności stanów hibernacji i gotowości wielu użytkowników nadal praktykuje
zwyczaj wyłączania komputera po zakończeniu pracy. System Windows używa hibernacji do reali-
zacji pseudozamykania i ponownego uruchamiania. Operacja ta, nazywana HiberBoot, jest znacznie
szybsza od standardowego zamykania systemu i jego ponownego uruchamiania. Gdy użytkownik
wyda systemowi polecenie zamknięcia, mechanizm HiberBoot wylogowuje użytkownika, a następ-
nie hibernuje system w punkcie, w którym użytkownik powinien normalnie ponownie się zalo-
gować. Później, kiedy użytkownik włączy system ponownie, mechanizm HiberBoot wznawia
system w punkcie logowania. Z punktu widzenia użytkownika wygląda to tak, jakby zamykanie
było bardzo szybkie. Wynika to z pominięcia większości operacji związanych z inicjowaniem
systemu. Oczywiście czasami system musi przeprowadzić rzeczywiste zamknięcie systemu —
w celu rozwiązania problemów lub zainstalowania aktualizacji jądra. W przypadku wydania sys-
temowi polecenia ponownego uruchomienia zamiast zamknięcia system realizuje standardowe
zamknięcie, a następnie normalną procedurę startową.
Chociaż Pomarańczowa księga nie określa, co powinno się stać w razie kradzieży komputera
przenośnego, w wielkich organizacjach nawet jedno takie zdarzenie tygodniowo nie jest niczym
niezwykłym. Właśnie dlatego twórcy systemu Windows stworzyli narzędzia umożliwiające naj-
bardziej sumiennym użytkownikom minimalizację strat w razie kradzieży lub utraty notebooka
(w tym bezpieczne logowanie i szyfrowanie plików). Z drugiej strony najbardziej sumienni użyt-
kownicy z reguły nie gubią swoich notebooków — problemem są więc ci pozostali.
W następnym punkcie skoncentrujemy się na podstawowych pojęciach dotyczących bezpie-
czeństwa w systemie Windows. Zaraz potem przeanalizujemy wywołania systemowe związane
z bezpieczeństwem. I wreszcie w ostatnim punkcie tego podrozdziału omówimy sposób imple-
mentacji mechanizmów bezpieczeństwa.
I wreszcie lista uprawnień (jeśli istnieje) nadaje danemu procesowi specjalne możliwości,
którymi nie dysponują zwykli użytkownicy, w tym prawo do wyłączenia komputera lub uzyska-
nia dostępu do plików, które w przeciwnym razie nie byłyby dostępne. W praktyce uprawnienia
dzielą szerokie możliwości, jakimi dysponuje superużytkownik, na wiele odrębnych kategorii (praw),
które można przypisywać poszczególnym procesom. Oznacza to, że zwykły użytkownik może
otrzymać wybrane prawa superużytkownika bez konieczności nadawania mu pełnych uprawnień.
Token dostępu jest więc źródłem informacji o właścicielu danego procesu oraz o skojarzonych
z nim ustawieniach domyślnych i specjalnych uprawnieniach.
Kiedy użytkownik loguje się w systemie, program winlogon nadaje początkowemu procesowi
token dostępu. Kolejne procesy zwykle dziedziczą ten token po tym procesie. Z początku reguły
reprezentowane przez token dostępu stosuje się także dla wszystkich wątków tego procesu.
Wątek może jednak uzyskać inny token dostępu już w trakcie wykonywania — token dostępu
wątku nadpisuje wówczas token dostępu odziedziczony po procesie. W szczególności wątek
klienta może przekazać swoje prawa dostępu wątkowi serwera, aby umożliwić serwerowi uzyski-
wanie dostępu do chronionych plików i innych obiektów w imieniu klienta. Mechanizm przeka-
zywania uprawnień określa się mianem naśladowania (ang. impersonation). Zaimplementowano
go z wykorzystaniem warstw transportowych (wywołań ALPC, potoków nazwanych i protokołu
TCP/IP). Mechanizm naśladowania jest stosowany przez RPC w komunikacji pomiędzy klientami
a serwerami. W warstwach transportowych wykorzystuje się wewnętrzne interfejsy komponentu
monitora kontroli bezpieczeństwa odwołań, aby wyodrębnić kontekst bezpieczeństwa z tokenu
dostępu bieżącego wątku i przenieść go na stronę serwera (gdzie można na jego podstawie
skonstruować token dostępu na potrzeby wątku naśladującego klienta).
Innym ważnym elementem jest tzw. deskryptor bezpieczeństwa. Każdy obiekt ma przypisany
deskryptor bezpieczeństwa określający, kto może wykonywać poszczególne operacje na tym
obiekcie. Deskryptory bezpieczeństwa definiuje się podczas tworzenia obiektów. System plików
NTFS i rejestr systemu Windows oferują mechanizmy utrwalania deskryptorów kojarzonych
z obiektami plików i obiektami kluczy rejestru (obiekty menedżera obiektów reprezentują tylko
otwarte kopie plików i kluczy).
Deskryptor bezpieczeństwa składa się z nagłówka oraz listy kontroli dostępu DACL z jednym
lub wieloma wpisami ACE (od ang. Access Control Entries). Do najważniejszych rodzajów wpi-
sów ACE należą Zezwalaj oraz Odmawiaj. Element zezwalający określa identyfikator SID oraz
mapę bitową wskazującą operacje, które mogą być wykonywane na danym obiekcie przez procesy
z danym identyfikatorem. Element odmawiający ma taką samą strukturę, tyle że jego mapa
bitowa wskazuje operacje, które nie mogą być wykonywane przez procesy z danym identyfi-
katorem. Wyobraźmy sobie, że np. Iza dysponuje plikiem, którego deskryptor bezpieczeństwa
zezwala wszystkim na dostęp do odczytu, z wyjątkiem Edwarda, który nie ma żadnego dostępu
do tego pliku. Kinga ma dostęp do odczytu i zapisu, a sama Iza ma pełny dostęp do swojego pliku.
Ten prosty przykład zilustrowano na rysunku 11.30. Identyfikator SID Wszyscy odwołuje się do
zbioru wszystkich użytkowników, ale zapisy w tej formie można przykrywać następującymi po
nich wpisami ACE z konkretnymi identyfikatorami.
Oprócz wspominanej już listy DACL deskryptor bezpieczeństwa obejmuje jeszcze systemową
listę kontroli dostępu (ang. System Access Control List — SACL), która tym różni się od listy DACL,
że nie określa, kto może korzystać z danego obiektu, tylko które operacje wykonywane na tym
obiekcie mają być rejestrowane w systemowym dzienniku bezpieczeństwa. Z zapisów deskryptora
bezpieczeństwa z rysunku 11.30 wynika, że rejestrowane mają być operacje wykonywane na
naszym przykładowym pliku przez Marię. Lista SACL opisuje też tzw. poziom integralności
(ang. integrity level), który omówimy za chwilę.
tworzącego obiekt nie przekażemy żadnego deskryptora bezpieczeństwa, zostaną użyte domyślne
ustawienia bezpieczeństwa reprezentowane przez token dostępu procesu wywołującego (patrz
rysunek 11.29).
Duża część wywołań interfejsu Win32 API związanych z bezpieczeństwem umożliwia zarzą-
dzanie deskryptorami bezpieczeństwa, zatem właśnie na tych wywołaniach skoncentrujemy się
w tym punkcie. Najważniejsze wywołania z tej grupy wymieniono i krótko opisano w tabeli 11.17.
Utworzenie deskryptora bezpieczeństwa musi być poprzedzone jego zaalokowaniem i inicjali-
zacją z wykorzystaniem wywołania InitializeSecurityDescriptor. Wywołanie InitializeSecuri
tyDescriptor wypełnia nagłówek nowego deskryptora. Jeśli identyfikator SID właściciela
danego obiektu jest nieznany, można go odnaleźć według nazwy za pomocą wywołania Lookup
AccountSid. Ustalony identyfikator można następnie umieścić w tworzonym deskryptorze bez-
pieczeństwa. Tę samą procedurę możemy zastosować dla ewentualnego identyfikatora SID grupy.
W większości przypadków będzie to identyfikator SID procesu wywołującego oraz identyfikator
jednej z jego grup, jednak administrator systemu może swobodnie dobierać identyfikatory zapi-
sywane w deskryptorze.
Na tym etapie możliwe jest użycie wywołania InitializeAcl do inicjalizacji listy DACL (lub
SACL) danego deskryptora bezpieczeństwa. Wpisy listy kontroli dostępu można dodawać za pomocą
wywołań AddAccessAllowedAce i AddAccessDeniedAce. W razie konieczności można te wywołania
stosować wiele razy, aby dodać wszystkie niezbędne wpisy ACE. Do usuwania tych wpisów
służy wywołanie DeleteAce (stosowane już na etapie modyfikowania istniejącej listy ACL, nie
w trakcie jej tworzenia). Po sporządzeniu listy kontroli dostępu można użyć wywołania SetSecu
rityDescriptorDacl do skojarzenia tej listy z danym deskryptorem bezpieczeństwa. I wreszcie
podczas tworzenia właściwego obiektu nowy deskryptor bezpieczeństwa można przekazać
w formie parametru odpowiedniego wywołania, aby skojarzyć ten deskryptor z konstruowa-
nym obiektem.
systemów przed wykorzystaniem słabych punktów. Wersje systemu Windows używane przez
klientów korporacyjnych zawierają funkcje, które ułatwiają administratorom zapewnienie wła-
ściwej aktualizacji systemów podłączonych do sieci oraz poprawnej konfiguracji oprogramowania
antywirusowego.
11.11. PODSUMOWANIE
11.11.
PODSUMOWANIE
W systemie Windows tryb jądra obejmuje warstwę abstrakcji sprzętowej (HAL), warstwę jądra
i warstwę wykonawczą NTOS oraz wiele sterowników urządzeń implementujących dosłownie
wszystko — od usług urządzeń po systemy plików, obsługę sieci i zarządzanie grafiką. Warstwa
HAL ukrywa pewne różnice dzielące sprzęt od pozostałych komponentów systemu. Warstwa
jądra tak zarządza procesorami, aby obsługiwać przetwarzanie wielowątkowe i właściwą synchro-
nizację wątków. Warstwa wykonawcza implementuje większość usług trybu jądra.
Warstwa wykonawcza operuje na obiektach trybu jądra reprezentujących najważniejsze struk-
tury danych tej warstwy, m.in. procesy, wątki, sekcje pamięci, sterowniki, urządzenia i obiekty
synchronizujące. Procesy użytkownika tworzą obiekty, wywołując usługi systemowe i otrzymując
w odpowiedzi referencje uchwytów, których można użyć w kolejnych wywołaniach systemowych
kierowanych do komponentów warstwy wykonawczej. Także sam system operacyjny wewnętrznie
tworzy obiekty. Menedżer obiektów utrzymuje przestrzeń nazw umożliwiającą reprezentowanie
identyfikatorów obiektów na potrzeby przyszłych operacji przeszukiwania.
Najważniejszymi obiektami systemu operacyjnego Windows są procesy, wątki i sekcje. Procesy
dysponują wirtualnymi przestrzeniami adresowymi i pełnią funkcję kontenerów dla zasobów.
Wątki są jednostkami wykonywania i jako takie podlegają szeregowaniu w warstwie jądra —
algorytm szeregujący uwzględnia priorytety wątków, zatem gotowy wątek z najwyższym prio-
rytetem jest wykonywany natychmiast (ewentualne wątki z niższymi priorytetami są wówczas
wywłaszczane). Sekcje reprezentują obiekty w pamięci, np. pliki, które można odwzorowywać
w przestrzeni adresowej procesów. Sekcje wykorzystuje się np. do reprezentowania obrazów
programów EXE i DLL oraz pamięci współdzielonej.
System Windows obsługuje pamięć wirtualną ze stronicowaniem na żądanie. Algorytm stroni-
cowania wykorzystuje pojęcie zbioru roboczego. System utrzymuje wiele rodzajów list stron,
aby na ich podstawie optymalizować wykorzystanie pamięci. Niektóre listy są wypełniane wskutek
przycinania zbiorów roboczych według złożonych algorytmów, które mają na celu ponowne udo-
stępnianie tych stron fizycznych, które nie były przedmiotem odwołań od dłuższego czasu.
Menedżer pamięci podręcznej zarządza adresami wirtualnymi jądra wykorzystywanymi do odwzo-
rowywania plików w pamięci i — tym samym — znacznego podnoszenia efektywności operacji
wejścia-wyjścia (poprzez eliminowanie konieczności uzyskiwania dostępu do dysku).
Operacje wejścia-wyjścia są realizowane przez sterowniki urządzeń, które muszą być zgodne
z modelem Windows Driver Model (WDM). Każdy sterownik rozpoczyna działanie od inicjalizacji
obiektu sterownika zawierającego adresy procedur wywoływanych przez system podczas korzy-
stania z odpowiednich urządzeń. Same urządzenia są reprezentowane przez obiekty urządzeń
tworzone na podstawie konfiguracji systemu lub przez menedżer plug and play (odkrywający
urządzenia podczas cyklicznego przeszukiwania magistral systemowych). Urządzenia umieszcza
się na stosie, a pakiety żądań wejścia-wyjścia są przekazywane w dół tego stosu i obsługiwane
przez sterowniki kolejnych urządzeń na stosie. Operacje wejścia-wyjścia mają charakter asyn-
PYTANIA
1. Podaj jedną zaletę i jedną wadę stosowania rejestru zamiast indywidualnych plików .ini.
2. Mysz może mieć jeden, dwa lub trzy przyciski. W użyciu są urządzenia wszystkich trzech
typów. Czy warstwa HAL ukrywa tę różnicę przed pozostałą częścią systemu operacyj-
nego? Dlaczego tak lub dlaczego nie?
3. Warstwa abstrakcji sprzętowej śledzi czas, począwszy od 1601 roku. Podaj przykład apli-
kacji, która może wykorzystać ten aspekt warstwy HAL.
4. W punkcie 11.3.3 omówiliśmy problemy powodowane przez aplikacje wielowątkowe, które
zamykają uchwyty w jednym wątku i nadal próbują z tych uchwytów korzystać w innych
wątkach. Jednym z rozwiązań jest wprowadzenie dodatkowego pola liczby porządkowej.
Jak takie pole mogłoby pomóc? Jakie zmiany w systemie byłyby konieczne w związku
z dodaniem tego pola?
5. Wiele komponentów warstwy wykonawczej (rysunek 11.4) wywołuje inne komponenty tej
warstwy. Podaj trzy przykłady, gdy jeden komponent wywołuje inny, ale w sumie wyko-
rzystuje sześć różnych komponentów.
6. Podsystem Win32 nie korzysta z sygnałów. Gdyby zdecydowano się na ich wprowadze-
nie, mogłyby być obsługiwane na poziomie procesów, wątków, procesów i wątków lub
na żadnym z tych poziomów. Zaproponuj poziom, który wydaje Ci się najwłaściwszy,
i wyjaśnij, dlaczego zdecydowałeś się właśnie na niego?
7. Alternatywą dla stosowania bibliotek DLL jest statyczne łączenie programów tylko z tymi
procedurami bibliotek, które rzeczywiście są wywoływane przez te programy (i żadnymi
innymi). Gdyby wprowadzono ten schemat do systemu Windows, czy miałoby to większy
sens na komputerach klientów, czy na komputerach serwerów?
30. W celu zapewnienia obsługi POSIX wywołanie rdzennego API NtCreateProcess obsługuje
dublowanie procesu w celu wsparcia operacji fork. W systemie UNIX w większości przy-
padków za wywołaniem fork występuje wywołanie exec. Jednym z przykładów wykorzy-
stania tej własności był program dump(8S) w systemie Berkeley, który wykonywał kopię
zapasową dysków na taśmie magnetycznej. Polecenie fork było wykorzystywane jako
sposób stworzenia punktu kontrolnego dla programu dump, tak by można było go zrestar-
tować w przypadku wystąpienia błędu z napędem taśmowym.
Podaj przykład, jak można by zrealizować podobny mechanizm w systemie Windows
przy użyciu wywołania NtCreateProcess. (Wskazówka: należy wziąć pod uwagę procesy
będące hostami bibliotek DLL w celu implementacji funkcji dostarczanych przez produkty
firm trzecich).
31. Dla pliku istnieje następujące odwzorowanie. Opracuj na tej podstawie wpisy tablicy MFT
reprezentujące sekwencje bloków:
Przesunięcie 0 1 2 3 4 5 6 7 8 9 10
Adresy dyskowe 50 51 52 22 24 25 26 53 54 - 60
32. Przeanalizujmy raz jeszcze rekord tablicy MFT z rysunku 11.25. Przypuśćmy, że roz-
miar pliku zwiększył się na tyle, że koniec tego pliku znajduje się dopiero w dziesiątym
bloku (oznaczonym numerem 66). Jak wyglądałby rekord tablicy MFT po takiej zmia-
nie?
33. Na rysunku 11.28(b) dwie pierwsze sekwencje zajmują po 8 bloków. Czy równa liczba
bloków jest przypadkowa, czy wynika z przyjętego sposobu szyfrowania? Wyjaśnij swoją
odpowiedź.
34. Przypuśćmy, że chcemy zbudować system Windows Lite. Które spośród pól pokazanych
na rysunku 11.29 można by usunąć z tego systemu bez osłabiania jego bezpieczeństwa?
35. Strategia stosowania czynników ograniczających skuteczność zagrożeń w celu poprawy
bezpieczeństwa jest bardzo udana, mimo że wciąż istnieje sporo słabych punktów. Obecnie
ataki są bardzo wyrafinowane. Zbudowanie skutecznego eksploita często wymaga obecności
wielu luk w zabezpieczeniach. Jedną z luk, które zazwyczaj są wymagane, jest wyciek
informacji. Wyjaśnij, w jaki sposób można wykorzystać wyciek informacji w celu poko-
nania mechanizmu losowego rozmieszczenia przestrzeni adresowej, by przeprowadzić
atak z wykorzystaniem techniki ROP.
36. Model rozszerzeń stosowany przez wiele programów (przeglądarki internetowe, pakiet
Office, serwery COM) wymaga stosowania mechanizmu tzw. goszczenia bibliotek DLL, aby
dołączać i rozszerzać ich funkcje. Czy stosowanie tego modelu miałoby sens w przy-
padku usług RPC, gdzie (z zachowaniem należytej ostrożności) oprogramowanie strony
serwera występowałoby w imieniu klientów jeszcze przed ładowaniem bibliotek DLL?
Jeśli nie, dlaczego?
37. Na komputerze NUMA za każdym razem, gdy menedżer pamięci systemu Windows musi
przydzielić stronę fizyczną do obsługi błędu braku strony, próbuje użyć strony z węzła
NUMA idealnego procesora bieżącego wątku. Dlaczego tak się dzieje? Co będzie, jeśli
okaże się, że wspomniany wątek jest aktualnie wykonywany na innym procesorze?
38. Podaj kilka przykładów, w których po awarii systemu można łatwo odtworzyć dane
aplikacji na podstawie kopii woluminu sporządzonej w tle (zamiast stanu dysku).
39. W podrozdziale 11.10 wspomniano o przydzielaniu nowej pamięci stercie procesu jako
o jednym z tych scenariuszy, które z uwagi na bezpieczeństwo wymagają korzysta-
nia z wyzerowanych stron. Podaj jeden lub kilka przykładów innych operacji na pamięci
wirtualnej wymagających wyzerowanych stron.
40. System Windows zawiera hipernadzorcę — mechanizm pozwalający na równoczesne
działanie wielu systemów operacyjnych. Mechanizm ten jest dostępny na komputerach
klienckich, ale jest o wiele ważniejszy w przypadku przetwarzania w chmurze. Wpro-
wadzenie aktualizacji zabezpieczeń do systemu operacyjnego gościa nie różni się zbytnio
od aktualizacji serwera. Jednak gdy aktualizacja zabezpieczeń jest instalowana w głów-
nym systemie operacyjnym, może to być dużym problemem dla użytkowników przetwa-
rzania w chmurze. Jaka jest natura tego problemu? Jak można mu zaradzić?
41. We wszystkich współczesnych wersjach systemu Windows polecenie regedit można
wykorzystać do wyeksportowania fragmentu lub całego rejestru do pliku tekstowego.
Spróbuj zapisać rejestr wiele razy podczas jednej sesji systemu Windows i sprawdź, co
w tym czasie zmieniło się w rejestrze. Jeśli masz dostęp do komputera z systemem
Windows, na którym możesz swobodnie instalować oprogramowanie i sprzęt, przeana-
lizuj zmiany zachodzące w rejestrze po dodaniu lub usunięciu wybranego programu lub
urządzenia.
42. Napisz program systemu UNIX symulujący zapisywanie pliku NTFS reprezentowanego
przez wiele strumieni. Program powinien otrzymywać na wejściu listę jednego lub wielu
plików, po czym przekazywać na wyjście plik zawierający jeden strumień z atrybutami
reprezentującymi wszystkie argumenty wejściowe oraz dodatkowe strumienie z zawarto-
ścią poszczególnych argumentów. Napisz teraz drugi program przetwarzający te atrybuty
i strumienie, zdolny do odtworzenia na ich podstawie oryginalnych komponentów.
979
Projekt systemu operacyjnego w większym stopniu przypomina projekty inżynieryjne niż przed-
sięwzięcia czysto naukowe. Precyzyjne wyznaczenie i realizacja celów takiego projektu oka-
zują się nieporównanie trudniejsze. Skoncentrujmy się najpierw właśnie na tym zagadnieniu.
12.1.1. Cele
Warunkiem projektowania dobrego systemu operacyjnego jest sformułowanie przez jego twór-
ców jasnej idei tego, co chcą osiągnąć. Brak takiego celu znacznie utrudniłby podejmowanie
właściwych decyzji podczas projektowania i implementowania nowego systemu. Aby zrozumieć
istotę tego zagadnienia, warto przyjrzeć się historii dwóch języków programowania: PL/I oraz C.
Język PL/I został zaprojektowany przez firmę IBM w latach sześćdziesiątych ubiegłego wieku.
Nowy język był odpowiedzią na niedogodności związane z obsługą języków FORTRAN i COBOL
oraz nieustannych narzekań środowisk akademickich przekonanych o wyższości Algola nad
wymienionymi językami. Zorganizowano więc grupę, której zlecono opracowanie języka speł-
niającego oczekiwania wszystkich odbiorców — tak powstał PL/I. Łączył on w sobie wybrane
cechy języka FORTRAN, pewne elementy języka COBOL oraz odrobinę rozwiązań znanych
z Algola. Projekt zakończył się niepowodzeniem z powodu braku jasnej, przemyślanej wizji.
Nowy język był co najwyżej zlepkiem elementów innych języków i stwarzał ogromne problemy,
choćby wskutek braku możliwości efektywnego kompilowania kodu.
Przeanalizujmy teraz historię języka C. Zaprojektowała go jedna osoba (Dennis Ritchie)
w jednym celu (programowanie systemu operacyjnego). System okazał się ogromnym sukcesem,
ponieważ Ritchie od początku wiedział, co chce osiągnąć. Właśnie dlatego język C wciąż (a więc
ponad trzy dekady od swojego powstania) jest powszechnie stosowany. Jasna wizja okazuje się
zatem kluczem do sukcesu w świecie tego rodzaju projektów.
Czego chcą projektanci systemów operacyjnych? To oczywiście zależy od charakteru two-
rzonego systemu — np. systemy wbudowane realizują zupełnie inne cele niż systemy serwe-
rowe. Z drugiej strony można się pokusić o sformułowanie przynajmniej czterech takich celów
dla uniwersalnych systemów operacyjnych:
1. Zdefiniowanie abstrakcji.
2. Udostępnienie podstawowych operacji.
3. Zapewnienie izolacji.
4. Zarządzanie sprzętem.
Poniżej szczegółowo omówimy każdy z tych celów.
Najważniejszym, ale też bodaj najtrudniejszym zadaniem systemu operacyjnego jest definio-
wanie odpowiednich abstrakcji. Niektóre z nich, jak procesy, przestrzenie adresowe czy pliki,
stosuje się od tak dawna, że ich istnienie wydaje się oczywiste. Inne, jak wątki, są dużo młodsze
i — tym samym — sprawiają wrażenie mniej dojrzałych. Jeśli np. rozwidlimy proces wielowąt-
kowy obejmujący wątek, którego wykonywanie zostało zablokowane w oczekiwaniu na dane wej-
ściowe z klawiatury, czy nowy proces także powinien zawierać wątek czekający na te dane?
Istnieją też abstrakcje związane z synchronizacją, sygnałami, modelem pamięci, operacjami
wejścia-wyjścia i wieloma innymi obszarami.
Każda z tych abstrakcji może mieć postać konkretnych struktur danych. Użytkownicy mogą
przecież tworzyć procesy, pliki, semafory itp. Do przetwarzania tych struktur służą proste
operacje. Użytkownicy mogą np. odczytywać i zapisywać pliki. Te proste operacje implementuje
się w formie tzw. wywołań systemowych. Z perspektywy użytkownika na serce systemu ope-
racyjnego składają się właśnie abstrakcje oraz operacje, które można na tych abstrakcjach wyko-
nywać za pośrednictwem wywołań systemowych.
Ponieważ wielu użytkowników może być jednocześnie zalogowanych na tym samym kom-
puterze, system operacyjny musi oferować mechanizmy oddzielające tych użytkowników. Praca
jednego użytkownika nie może wpływać na działania innych użytkowników. Powszechnie sto-
suje się koncepcję procesu jako środka do grupowania zasobów w celu ich właściwej ochrony.
Także pliki i inne struktury danych podlegają ochronie. Innym obszarem, w którym separacja ma
kluczowe znaczenie, jest wirtualizacja: hipernadzorca musi zadbać o to, aby maszyny wirtualne
wzajemnie „nie wyrywały sobie włosów”. Jednym z najważniejszych celów projektu systemu jest
zagwarantowanie, że każdy użytkownik będzie mógł wykonywać tylko operacje, do których ma
prawo, i tylko na danych, do których ma dostęp. Ponieważ jednak użytkownicy chcą też mieć
możliwość dzielenia się swoimi danymi i zasobami, ich izolacja musi być selektywna i podlegać
kontroli użytkownika. To wymaganie zasadniczo utrudnia projektantom zadanie. Przykładowo
klient poczty elektronicznej nie powinien mieć możliwości przejmowania kontroli nad przeglą-
darką internetową — nawet jeśli z systemu korzysta tylko jeden użytkownik, poszczególne
procesy powinny być od siebie izolowane. W przypadku niektórych systemów — takich jak
Android — każdy proces, który należy do tego samego użytkownika, jest uruchamiany z innym
identyfikatorem użytkownika. Ten zabieg ma na celu wzajemną ochronę procesów przed sobą.
Innym, ale ściśle powiązanym zagadnieniem jest kwestia izolowania błędów. Nawet jeśli
jakaś część systemu ulegnie awarii (zwykle dotyczy to procesu użytkownika), takie zdarzenie nie
powinno powodować błędów w pozostałych składnikach systemu. Projekt systemu powinien
możliwie skutecznie izolować od siebie poszczególne składniki systemu. Idealnym rozwiąza-
niem byłoby takie rozdzielenie elementów systemu operacyjnego, aby ewentualne błędy jednego
elementu nie miały żadnego wpływu na funkcjonowanie pozostałych elementów. Pójdźmy
dalej: być może system operacyjny powinien być odporny na awarie i powinien umieć sam sie-
bie naprawiać.
I wreszcie system operacyjny musi zarządzać sprzętem. W szczególności musi prawidłowo
współpracować ze wszystkimi niskopoziomowymi układami, jak kontrolery przerwań czy kon-
trolery magistral. System operacyjny musi też udostępniać framework umożliwiający sterow-
nikom zarządzanie większymi urządzeniami wejścia-wyjścia, jak dyski, drukarki czy monitory.
pięć problemów, które czynią projektowanie systemów operacyjnych dużo trudniejszym niż pro-
jektowanie zwykłych aplikacji.
Po pierwsze systemy operacyjne są obecnie wyjątkowo rozbudowanymi programami. Nie ma
na świecie człowieka, który potrafiłby usiąść przed komputerem i w ciągu kilku miesięcy opraco-
wać poważny system operacyjny. W pojedynkę nawet kilka lat byłoby za mało. Kod wszystkich
współczesnych wersji Uniksa zawiera wiele milionów wierszy kodu. Dla przykładu kod systemu
Linux obejmuje 15 milionów wierszy. Objętość kodu Windowsa 8 prawdopodobnie mieści się
zakresie 50 – 100 milionów wierszy kodu w zależności od tego, co uwzględnimy w liczeniu
(kod systemu Windows Vista miał 70 milionów wierszy, ale wprowadzono zmiany polegające
zarówno na dodaniu nowego kodu, jak i usunięciu starego). Nikt nie jest w stanie opanować
miliona wierszy kodu, nie mówiąc już o 50 czy 100 milionach. Skoro mowa o produkcie, który
w całości nie jest zrozumiały dla żadnego z projektantów, trudno się dziwić, że osiągane rezultaty
często pozostają dalekie od optymalnych.
Co ciekawe, systemy operacyjne wcale nie są najbardziej złożonymi systemami oprogramo-
wania. Chociaż np. lotniskowce są nieporównanie bardziej skomplikowane, ich projektanci radzą
sobie dużo lepiej z izolowaniem podsystemów. Ludzie projektujący toalety na lotniskowcu nie
muszą się obawiać o pracę systemu radarowego. Oba systemy nie mają większego wpływu na
wzajemne działanie. Z drugiej strony w systemie operacyjnym system plików często wpływa
na działanie systemu pamięci w nieoczekiwany i nieprzewidywalny sposób.
Po drugie systemy operacyjne muszą sobie radzić z problemem współbieżności. System
musi obsługiwać wielu użytkowników i wiele aktywnych urządzeń wejścia-wyjścia. Zarządzanie
współbieżnością jest z natury rzeczy trudniejsze od zarządzania pojedynczą sekwencją operacji.
Sytuacje wyścigów i zakleszczenia to tylko dwa z wielu problemów, z którymi muszą się zmie-
rzyć projektanci systemu.
Po trzecie systemy operacyjne muszą radzić sobie z potencjalnie wrogimi użytkownikami —
użytkownikami, którzy chcą w nieuprawniony sposób wpływać na działanie systemu lub wykony-
wać zabronione operacje (np. wykradać pliki innych użytkowników). System operacyjny musi
skutecznie zapobiegać nieuprawnionym działaniom tych użytkowników. Podobne problemy nie
występują w przypadku zwykłych edytorów tekstu czy programów graficznych.
Po czwarte, mimo że nie wszyscy użytkownicy ufają wszystkim pozostałym użytkownikom,
wielu z nich decyduje się na udostępnianie swoich informacji i zasobów innym (wybranym) użyt-
kownikom. System operacyjny musi umożliwiać takie udostępnianie, ale też zabezpieczać te
dane przed nieuprawnionym dostępem innych użytkowników. Także w tym obszarze projektanci
zwykłych aplikacji mają ułatwione zadanie (tam problem udostępniania danych z reguły po prostu
nie występuje).
Po piąte życie systemów operacyjnych zwykle trwa bardzo długo. System UNIX jest użytko-
wany od około 40 lat. Windows ma już przeszło 30 lat i nie zanosi się na to, aby wkrótce miał
przestać istnieć. Oznacza to, że projektanci muszą mieć na uwadze, jak sprzęt i aplikacje mogą
się zmieniać, nawet w dość dalekiej przyszłości, oraz jak należy się przygotować na nadchodzące
zmiany. Systemy, które zbyt ściśle wiąże się z konkretnymi wizjami świata, zwykle dość szybko
giną w starciu z bardziej uniwersalnymi konkurentami.
Po szóste projektanci systemów operacyjnych nie mogą przewidzieć wszystkich scenariu-
szy wykorzystania ich dzieła, zatem muszą zapewnić daleko idącą uniwersalność. Ani systemu
UNIX, ani systemu Windows nie projektowano z myślą o klientach poczty elektronicznej czy
przeglądarkach internetowych, a mimo to wiele współczesnych komputerów pracujących pod
kontrolą tych systemów wykorzystuje się niemal wyłącznie do uruchamiania tego rodzaju apli-
kacji. Nikt nie oczekuje od projektanta statków umiejętności konstruowania nowych jednostek,
jeśli ten nie wie, czy ma to być kuter rybacki, statek wycieczkowy, czy pancernik. Co więcej,
nikt nie oczekuje od jego dzieła zdolności do całkowitej zmiany charakteru wykonywanych zadań
już po dostarczeniu produktu.
Po siódme współczesne systemy operacyjne zwykle projektuje się z myślą o zapewnieniu
przenośności, tj. zdolności do uruchamiania na wielu różnych platformach sprzętowych. Współ-
czesne systemy muszą też obsługiwać tysiące urządzeń wejścia-wyjścia, z których każde jest
projektowane przez niezależny zespół bez dbałości o zgodność z produktami konkurencji. Dobrym
przykładem scenariusza, w którym zróżnicowane platformy sprzętowe rodzą poważne problemy,
byłaby próba stworzenia systemu operacyjnego działającego zarówno na komputerach little-endian,
jak i na komputerach big-endian. Innym ciekawym przykładem (prawdziwą zmorą systemu
MS-DOS) były próby instalacji karty dźwiękowej i modemu korzystających z tych samych portów
wejścia-wyjścia lub przerwań. Niewiele programów (poza interesującymi nas systemami ope-
racyjnymi) musi sobie radzić z podobnymi problemami wynikającymi z konfliktów pomiędzy
urządzeniami.
Po ósme (na tym kończymy naszą listę) projektanci systemów operacyjnych często muszą
dbać o zgodność wstecz, czyli zgodność z wcześniejszymi wersjami systemu operacyjnego.
Systemy gwarantujące taką zgodność muszą operować na słowach ograniczonej długości, pli-
kach z nazwami ograniczonej długości i innych elementach, które z perspektywy współcze-
snych projektantów wydają się przeżytkiem. To tak, jakby ktoś próbował przebudować fabrykę
z myślą o produkcji przyszłorocznych samochodów zamiast samochodów tegorocznych, ale
z zachowaniem pełnych możliwości produkcyjnych linii budującej bieżący model.
Po tym wstępie powinno być jasne, że pisanie współczesnych systemów operacyjnych nie jest
łatwe. Od czego więc należy zacząć? Bodaj najlepszym obszarem, którym można się zająć w pierw-
szej kolejności, są interfejsy udostępniane przez tworzony system. System operacyjny udostępnia
zbiór abstrakcji, które najczęściej są implementowane przez typy danych (np. pliki) oraz ope-
racje na tych typach (np. read). Wspomniane abstrakcje razem tworzą interfejs systemu opera-
cyjnego na potrzeby jego użytkowników. Warto przy tej okazji wspomnieć, że w tym kontekście
przez użytkowników systemu operacyjnego rozumiemy programistów, którzy piszą kod korzy-
stający z wywołań systemowych (a nie użytkowników końcowych uruchamiających aplikacje).
Oprócz podstawowego interfejsu wywołań systemowych większość systemów operacyjnych
udostępnia też interfejsy dodatkowe. Niektórzy programiści muszą np. pisać sterowniki urzą-
dzeń umieszczane następnie w systemie operacyjnym. Sterowniki muszą z kolei mieć dostęp do
określonych funkcji i pewnych wywołań procedur. Także te funkcje i wywołania definiują interfejs,
który jednak nie przypomina interfejsu wykorzystywanego przez programistów aplikacji. Warun-
kiem osiągnięcia sukcesu przez system operacyjny jest uważne zaprojektowanie wszystkich tych
interfejsów.
Zasada nr 1: Prostota
Prosty interfejs jest nieporównanie łatwiejszy do opanowania i zaimplementowania bez ryzyka
popełnienia błędów. Wszyscy projektanci systemów powinni zapamiętać i nieustannie powta-
rzać sobie to słynne zdanie francuskiego lotnika i pisarza Antoine’a de Saint-Exupéry’ego:
Perfekcji nie osiąga się wtedy, gdy nie można już niczego dodać, tylko wtedy, gdy nie można
już niczego odjąć.
Ściśle rzecz biorąc, Saint-Exupéry nie wypowiedział tego zdania. Dokładnie brzmiało to tak:
Il semble que la perfection soit atteinte non quand il n’y a plus rien à ajouter, mais quand
il n’y a plus rien à retrancher.
Chodzi mi jednak o zaprezentowanie idei. Proponuję zapamiętać jedną lub drugą wersję.
Zgodnie z tą zasadą lepiej jest dysponować czymś mniejszym niż czymś większym (przynajm-
niej w świecie systemów operacyjnych). Innym sposobem wyrażenia tej idei jest zasada KISS
(od ang. Keep It Simple, Stupid; dosł. nie komplikuj, głupcze).
Zasada nr 2: Kompletność
Interfejs musi oczywiście umożliwiać użytkownikom wykonywanie wszelkich niezbędnych dzia-
łań — oznacza to, że musi być kompletny. Dochodzimy więc do innego znanego cytatu, tym razem
za Albertem Einsteinem:
Wszystko powinno być możliwie proste, ale nie prostsze.
Innymi słowy, system operacyjny powinien realizować dokładnie te zadania, do których został
stworzony, ale żadnych innych. Jeśli użytkownicy muszą przechowywać dane, system powinien
oferować mechanizm przechowywania danych. Jeśli użytkownicy muszą się ze sobą komuniko-
wać, system operacyjny powinien zapewnić odpowiedni mechanizm komunikacji itp. W swoim
odczycie po otrzymaniu Nagrody Turinga w 1990 roku Fernando J. Corbató, jeden z projektantów
systemów CTSS i MULTICS, odniósł się do kwestii prostoty i kompletności w następujący
sposób:
Warto najpierw podkreślić znaczenie prostoty i elegancji. Z drugiej strony złożoność jest
źródłem utrudnień i — jak wszyscy wiemy — prowadzi do powstawania błędów. Sam defi-
niuję elegancję jako zdolność do implementowania niezbędnych funkcji, z zastosowaniem
minimalnego mechanizmu i jak największej czytelności tworzonych rozwiązań.
Najważniejszym wyrażeniem użytym w przytoczonej wypowiedzi jest minimalny mechanizm.
Inaczej mówiąc, każdy element, funkcja i wywołanie systemowe powinny mieć przypisaną okre-
śloną wagę. Powinny realizować dokładnie jedno zadanie i robić to dobrze. Jeśli jakiś członek
zespołu projektowego proponuje rozszerzenie wywołania systemowego lub dodanie nowej funkcji,
pozostali członkowie tego zespołu powinni go zapytać, czy w razie rezygnacji z tego kroku stanie
się coś niedobrego. Jeśli na wątpliwości współpracowników autor propozycji odpowie: „Nie, ale
ktoś może kiedyś potrzebować tej funkcji”, lepiej umieścić ją w bibliotece poziomu użytkownika
(nie w systemie operacyjnym), nawet jeśli takie rozwiązanie będzie nieznacznie mniej efektywne.
Nie każda funkcja musi działać błyskawicznie — celem projektantów systemów powinno być
raczej trzymanie się sformułowanej przez Corbató zasady minimalnego mechanizmu.
Przeanalizujmy teraz dwa przykłady systemów operacyjnych, które miałem okazję projekto-
wać: MINIX [Tanenbaum i Woodhull, 2006] oraz Amoeba [Tanenbaum et al., 1990]. System
MINIX realizował wszystkie niezbędne cele za pomocą zaledwie trzech wywołań systemowych:
send, receive i sendrec. System zaprojektowano jako kolekcje procesów — menedżer pamięci,
system plików i poszczególne sterowniki urządzeń mają w tych kolekcjach postać odrębnych
procesów podlegających procedurom szeregowania. Jądro tego systemu odpowiada więc tylko
za szeregowanie procesów i obsługę przekazywania komunikatów pomiędzy tymi procesami.
Oznacza to, że do prawidłowego działania systemu wystarczą dwa wywołania systemowe: send
(wysyłające komunikat) oraz receive (odbierające komunikat). Trzecie zaimplementowane wywo-
łanie, sendrec, ma tylko podnosić efektywność mechanizmu przekazywania komunikatów poprzez
wysyłanie i odbieranie komunikatów w ramach zaledwie jednej pułapki jądra. Wszystkie inne
zadania są wykonywane przez pozostałe procesy (np. proces systemu plików lub sterownik
dysku). W najnowszej wersji systemu MINIX wprowadzono dwa dodatkowe wywołania — oba
do obsługi komunikacji asynchronicznej. Wywołanie senda wysyła asynchroniczny komunikat.
Jądro próbuje dostarczyć komunikat, ale aplikacja nie czeka, aż operacja się zakończy — po prostu
dalej działa. Do wysyłania krótkich powiadomień system korzysta z wywołania notify. Przy-
kładowo jądro może powiadomić sterownik urządzenia działający w przestrzeni użytkownika,
że coś się wydarzyło. Działanie tego mechanizmu przypomina przerwania. Z powiadomieniem
nie jest powiązany żaden komunikat. Gdy jądro dostarczy powiadomienie do procesu, to poprze-
staje na odwróceniu bitu w mapie bitowej procesu — w ten sposób informuje, że coś się wyda-
rzyło. Ponieważ jest to takie proste, może być wykonane szybko, dzięki czemu jądro nie musi
„martwić się” tym, jaki komunikat należy dostarczyć w sytuacji, gdy proces dwukrotnie otrzyma
to samo powiadomienie. Warto zauważyć, że choć liczba wywołań nadal jest bardzo mała, to ciągle
rośnie. Poszerzanie się liczby wywołań jest nieuniknione. Opór jest daremny.
Oczywiście są to tylko wywołania jądra. Uruchomienie systemu zgodnego z POSIX na ich
bazie wymaga implementacji wielu systemowych wywołań POSIX. Ale piękno tego mechanizmu
polega na tym, że wszystkie one są odwzorowywane na bardzo niewielki zbiór wywołań jądra.
Ponieważ system jest (pomimo wszystko) tak prosty, istnieją szanse, że może być zaimplemen-
towany poprawnie.
System operacyjny Amoeba jest jeszcze prostszy. Ma tylko jedno wywołanie systemowe:
wykonaj zdalną procedurę wywołania. Działanie tego wywołania polega na wysłaniu wiadomości
i oczekiwaniu na odpowiedź. Ogólnie rzecz biorąc, wywołanie odpowiada wywołaniu sendrec
z systemu MINIX. Cała reszta bazuje na tym jednym wywołaniu. To, czy komunikacja syn-
chroniczna jest właściwym sposobem działania, to już inna sprawa — powrócimy do niej w pod-
rozdziale 12.3.
Zasada nr 3: Wydajność
Trzecim celem jest wydajność tworzonej implementacji. Jeśli wydajne zaimplementowanie jakiejś
funkcji lub wywołania systemowego nie jest możliwe, być może należy po prostu zrezygnować
z tego elementu. Każdy programista powinien też rozumieć, jaki jest koszt poszczególnych
wywołań systemowych. Programiści aplikacji dla systemu UNIX oczekują, że np. wywołanie sys-
temowe lseek będzie tańsze od wywołania read, ponieważ jego działanie sprowadza się do zmiany
wskaźnika w pamięci (read wymaga wykonania operacji wejścia-wyjścia na dysku). Gdyby tego
rodzaju intuicyjne oceny okazały się chybione, programiści pisaliby niewydajne programy.
12.2.2. Paradygmaty
Po precyzyjnym zdefiniowaniu celów można przystąpić do właściwego projektowania. Dobrym
punktem wyjścia jest przemyślenie sposobu postrzegania nowego systemu przez jego docelo-
wych użytkowników. Jednym z najważniejszych problemów jest znalezienie sposobu harmo-
nijnego działania wszystkich funkcji systemu i zapewnienie czegoś, bo bywa nazywane spójnością
architekturalną (ang. architectural coherence). Warto w tym kontekście odróżnić dwa rodzaje
użytkowników systemu operacyjnego. Pierwszym rodzajem są zwykli użytkownicy, którzy korzy-
stają z aplikacji; drugi typ to programiści, którzy piszą te aplikacje. Pierwsza grupa korzysta przede
wszystkim z graficznego interfejsu użytkownika; druga grupa operuje w większym stopniu na
interfejsie wywołań systemowych. Jeśli projektanci systemu operacyjnego stawiają sobie za
cel opracowanie pojedynczego interfejsu GUI obejmującego wszystkie elementy systemu (tak
postąpili twórcy Macintosha), projektowanie tego systemu należy rozpocząć właśnie od tego
obszaru. Jeśli jednak system ma obsługiwać wiele różnych interfejsów GUI (jak w systemie
UNIX), w pierwszej kolejności należy opracować interfejs wywołań systemowych. Rozpoczy-
nanie prac projektowych od graficznego interfejsu użytkownika jest przykładem tzw. projekto-
wania z góry na dół. W takim przypadku największym problemem staje się określenie, jakie
funkcje należy zaimplementować w ramach interfejsu GUI, jak użytkownicy będą korzystali
z tych funkcji oraz jak należy zaprojektować system, aby właściwie te funkcje obsługiwał. Jeśli
np. większość programów wyświetla na ekranie ikony i czeka, aż użytkownik kliknie którąś
z tych ikon, najprawdopodobniej interfejs GUI (a prawdopodobnie także system operacyjny)
należałoby zbudować z użyciem modelu zdarzeniowego. Z drugiej strony, jeśli ekran przez
większość czasu jest wypełniony oknami tekstowymi, prawdopodobnie lepszym rozwiązaniem
byłoby zastosowanie modelu, w którym procesy odczytują dane z klawiatury.
Konstruowanie interfejsu wywołań systemowych przed graficznym interfejsem użytkownika
to przykład projektowania z dołu do góry. Wówczas problemem jest określenie rodzaju funkcji,
które będą potrzebne programistom aplikacji dla danego systemu. Okazuje się, że sama obsługa
interfejsu GUI nie wymaga zbyt wielu wyspecjalizowanych funkcji. Przykładowo system okien
Uniksa, nazwany X, jest w istocie wielkim programem języka C korzystającym z wywołań read
i write dla klawiatury, myszy i ekranu. Interfejs X opracowano na długo po stworzeniu samego
systemu UNIX, a przystosowanie tego systemu operacyjnego do nowego interfejsu wymagało
zadziwiająco niewielu zmian. Przykład systemu X potwierdza więc, że system operacyjny UNIX
był wystarczająco kompletny.
Okazuje się jednak, że interfejs użytkownika nie jest jedynym możliwym rozwiązaniem.
W tabletach, smartfonach i niektórych laptopach wykorzystywane są ekrany dotykowe pozwalające
użytkownikom na bardziej bezpośrednią i intuicyjną interakcję z urządzeniem. Niektóre palmtopy
oferują interfejs stylizowany na tradycyjne, ręczne pisanie tekstu. Dedykowane urządzenia multi-
medialne udostępniają interfejs wzorowany na tradycyjnym interfejsie odtwarzaczy wideo. Jeszcze
inny paradygmat obowiązuje — co oczywiste — w odniesieniu do interfejsu dyktafonu. Ważny
jest nie tyle wybór właściwego paradygmatu, ile to, że musi istnieć jeden nadrzędny paradygmat
unifikujący cały interfejs użytkownika.
Niezależnie od wybranego paradygmatu kluczem do sukcesu jest jego konsekwentne stoso-
wanie przez wszystkie aplikacje tworzone dla danego systemu. Projektanci tego systemu muszą
więc opracować biblioteki i zestawy narzędzi zapewniające programistom aplikacji dostęp do pro-
cedur, dzięki którym będą mogli tworzyć oprogramowanie z ujednoliconym wyglądem i sposobem
obsługi. Bez użycia odpowiednich narzędzi każdy z deweloperów aplikacji robiłby coś innego.
Projekt interfejsu użytkownika jest oczywiście bardzo ważny, nie stanowi on jednak tematu tej
książki — wróćmy więc do zagadnień związanych z interfejsem systemu operacyjnego.
Paradygmaty wykonywania
Spójność architekturalna jest ważna nie tylko na poziomie użytkownika, ale też na poziomie
interfejsu wywołań systemowych. Warto w tym kontekście odróżnić paradygmat wykonywania
od paradygmatu danych — oba paradygmaty omówimy (począwszy od pierwszego) w tym i kolej-
nym podpunkcie.
Powszechnie stosuje się dwa paradygmaty wykonywania: algorytmiczny (ang. algorithmic)
i sterowany zdarzeniami (ang. event driven). Paradygmat algorytmiczny reprezentuje koncepcję,
zgodnie z którą programy uruchamia się z myślą o realizacji konkretnych funkcji znanych
z wyprzedzeniem lub określanych przez parametry wejściowe. Tą funkcją może być kompilacja
jakiegoś programu, wygenerowanie listy płac lub lot samolotu do San Francisco. Podstawowa
logika jest trwale zapisana w kodzie, a sam program od czasu do czasu wykonuje wywołania
systemowe, aby uzyskać dane wejściowe użytkownika, skorzystać z usług systemu operacyj-
nego itp. Takie podejście zaprezentowano na listingu 12.1(a).
od systemu operacyjnego o pierwszym zdarzeniu. Tym zdarzeniem może być naciśnięcie kla-
wisza albo ruch kursora myszy. Proponowany model pracy jest przydatny w przypadku wysoce
interaktywnych programów.
Dla każdego z tych paradygmatów istnieje odrębny styl programowania. W paradygmacie
algorytmicznym centralnym elementem z natury rzeczy jest sam algorytm — system operacyjny
pełni raczej funkcję dostarczyciela usług. Także w paradygmacie zdarzeniowym (sterowanym
zdarzeniami) system operacyjny dostarcza usługi, jednak ta jego rola jest mniej ważna od dwóch
pozostałych — funkcji koordynatora aktywności użytkownika oraz generatora zdarzeń odbiera-
nych i przetwarzanych przez procesy.
Paradygmaty danych
Paradygmat wykonywania nie jest jedyną cechą systemu operacyjnego — równie ważny oka-
zuje się paradygmat danych. W tym kontekście najważniejszym problemem jest odpowiedź na
pytanie, jak należy prezentować programiście struktury systemu i urządzenia. We wczesnych
systemach wsadowych języka FORTRAN wszystko modelowano w formie sekwencyjnej taśmy
magnetycznej. Pliki kart drukowanych były traktowane jako taśmy wejściowe, pliki kart do
wydrukowania były traktowane jako taśmy wyjściowe. Także pliki dyskowe traktowano jako
taśmy. Swobodny dostęp do plików był więc możliwy tylko poprzez przewijanie odpowiedniej
taśmy i ponowny odczyt jej zawartości.
Do opisanego odwzorowywania wykorzystywano tzw. karty kontroli zadań:
MOUNT(TAPE08, REEL781)
RUN(INPUT, MYDATA, OUTPUT, PUNCH, TAPE08)
Pierwsza karta instruowała operatora, aby pobrał z szafy szpulę taśmy nr 781 i zamontował ją
w napędzie taśmowym nr 8. Druga karta instruowała system operacyjny, aby wykonał właśnie
skompilowany program języka FORTRAN, odwzorowując dane wejściowe (INPUT, czyli czytnik
kart) na taśmę logiczną nr 1, plik dyskowy MYDATA na taśmę logiczną nr 2, drukarkę (nazwaną
OUTPUT) na taśmę logiczną nr 3, kartę perforowaną (nazwaną PUNCH) na taśmę logiczną nr 4
oraz fizyczny napęd taśmowy nr 8 na taśmę logiczną nr 5.
Składnię języka FORTRAN zaprojektowano z myślą o odczytywaniu i zapisywaniu taśm
logicznych. W tym przypadku odczyt z taśmy logicznej nr 1 oznacza odczyt z karty perforowanej.
Zapis na taśmie logicznej nr 3 oznacza przekazanie danych do drukarki. Odczyt z taśmy logicznej
nr 5 oznacza odczytanie zawartości szpuli nr 781 itp. Jak nietrudno zauważyć, pojęcie taśmy było
po prostu paradygmatem integrującym czytnik kart, drukarkę, mechanizm perforujący, pliki dys-
kowe oraz właściwe taśmy. W tym przypadku tylko taśma logiczna nr 5 została skojarzona
z taśmą fizyczną; pozostałe taśmy fizyczne reprezentują pliki dyskowe. Paradygmat w tej for-
mie był wyjątkowo prosty, ale stanowił krok we właściwym kierunku.
Nieco później powstał system operacyjny UNIX, którego projektanci poszli znacznie dalej,
wprowadzając model, w którym „wszystko jest plikiem”. Wspomniany paradygmat sprawił, że
wszystkie urządzenia wejścia-wyjścia są traktowane jak pliki, zatem mogą być otwierane i podle-
gać wszystkim innym operacjom stosowanym dla plików. Oznacza to, że następujące wyraże-
nia języka C:
fd1 = open("file1", O_RDWR);
fd2 = open("/dev/tty", O_RDWR)’
otwierają odpowiednio właściwy plik dyskowy oraz terminal użytkownika (klawiaturę i ekran).
W kolejnych wyrażeniach można dowolnie wykorzystywać deskryptory plików fd1 i fd2 do odczy-
tywania i zapisywania danych w odpowiednich plikach. Od tej pory dostęp do pliku nie różni się
od dostępu do terminala, z wyjątkiem braku możliwości wykonywania na terminalu operacji
poszukiwania.
System UNIX nie tylko pliki i urządzenia wejścia-wyjścia traktuje tak samo — umożliwia też
uzyskiwanie dostępu do innych procesów za pośrednictwem potoków (tak jak do plików). Co wię-
cej, jeśli system operacyjny oferuje mechanizm odwzorowywania plików, proces może utworzyć
własną pamięć wirtualną, tak jakby ta pamięć miała postać pliku. I wreszcie w tych wersjach
systemu UNIX, które obsługują system plików /proc, następujące wyrażenie języka C:
fd3 = open("/proc/501", O_RDWR);
umożliwia procesowi uzyskanie (lub przynajmniej podjęcie próby uzyskania) dostępu do pamięci
procesu nr 501, której zawartość będzie mógł czytać i zapisywać, posługując się deskryptorem
pliku fd3. Takie rozwiązanie może być przydatne np. dla debugera.
Oczywiście tylko dlatego, że ktoś powiedział, że wszystko jest plikiem, nie oznacza, że jest
to prawda we wszystkich przypadkach. I tak gniazda sieciowe w systemie UNIX mogą w pew-
nym stopniu przypominać pliki, ale wykorzystują one własny, dość specyficzny interfejs API
obsługi gniazd. W innym systemie operacyjnym, Plan 9 z Bell Labs, nie wprowadzono wyspecja-
lizowanego interfejsu obsługi gniazda sieciowych. W rezultacie projekt systemu Plan 9 można
uznać za czystszy.
Twórcy systemu Windows postanowili, że wszystko powinno wyglądać jak obiekt. Po uzyska-
niu prawidłowego uchwytu do pliku, procesu, semafora, skrzynki pocztowej lub innego obiektu
jądra proces może na tym zasobie wykonywać dalsze operacje. Ten paradygmat jest bardziej
uniwersalny od modelu zastosowanego w systemie UNIX i jeszcze bardziej uniwersalny od
modelu znanego z języka FORTRAN.
W świecie komputerów paradygmaty unifikujące stosuje się także w innych kontekstach.
Jednym ze szczególnie interesujących przykładów są strony WWW. Zgodnie z paradygmatem
obowiązującym w świecie WWW istnieje pewna cyberprzestrzeń wypełniona dokumentami,
z których każdy ma przypisany adres URL. Po wpisaniu adresu URL lub kliknięciu łącza repre-
zentującego ten adres otrzymujemy odpowiedni dokument. W praktyce wiele tych „dokumentów”
wcale nie przypomina dokumentów — są to raczej fragmenty kodu języka HTML generowane
przez programy lub skrypty powłoki w odpowiedzi na otrzymane żądania. Jeśli np. użytkownik
żąda od witryny sklepu internetowego listy nagrań określonego artysty, odpowiedni dokument
jest generowany „w locie” przez pewien program (z pewnością nie istniał przed sformułowa-
niem tego żądania).
Do tej pory zapoznaliśmy się z czterema przypadkami — wszystko było taśmą magnetyczną,
plikiem, obiektem lub dokumentem. W każdym z tych przypadków celem była unifikacja danych,
urządzeń i innych zasobów, aby ułatwić efektywną pracę na tych zasobach. Podobny paradygmat
unifikujący dane powinien być oferowany przez każdy współczesny system operacyjny.
read. W przeciwnym razie moglibyśmy stanąć przed koniecznością stosowania wielu odrębnych
wywołań, jak read_file, read_proc czy read_tty.
W pewnych przypadkach wywołania systemowe mogą wymagać implementacji wielu wariantów,
jednak zwykle lepszym rozwiązaniem jest opracowanie jednego uniwersalnego wywołania korzy-
stającego z różnych procedur bibliotek, aby ukryć te warianty przed programistami aplikacji. Dobrym
przykładem jest dostępne w systemie UNIX wywołanie przykrywające wirtualną przestrzeń adre-
sową procesu, czyli exec. Najbardziej ogólna wersja tego wywołania ma następującą postać:
exec(name, argp, envp);
Wywołanie w tej formie ładuje plik wykonywalny name i przekazuje na jego wejściu argumenty
wskazywane przez argp i zmienne środowiskowe wskazywane przez envp. W pewnych przy-
padkach wygodniejszym rozwiązaniem jest bezpośrednie przekazanie argumentów, stąd decyzja
projektantów o opracowaniu następujących procedur biblioteki:
execl(name, arg0, arg1, ..., argn, 0);
execle(name, arg0, arg1, ..., argn, envp);
Działanie tych procedur sprowadza się do umieszczenia otrzymanych argumentów w tablicy oraz
użycia wywołania systemowego exec do realizacji właściwego zadania. Mamy więc do czynienia
z najlepszym możliwym połączeniem: pojedynczego, prostego wywołania systemowego pozwa-
lającego zachować prostotę systemu operacyjnego oraz wygody programisty dysponującego wieloma
wersjami wywołania exec.
Próby stworzenia jednego wywołania systemowego obsługującego wszystkie możliwe przy-
padki oczywiście dość szybko doprowadziłyby do poważnych problemów. W systemie UNIX
utworzenie procesu wymaga użycia kolejno dwóch wywołań: fork i exec. Pierwsze wywołanie
nie otrzymuje na wejściu żadnych parametrów; drugie otrzymuje trzy parametry. Zupełnie inne
rozwiązanie zastosowano w interfejsie Win32 API, gdzie wywołanie tworzące proces, czyli
CreateProcess, otrzymuje na wejściu aż 10 parametrów, z których jeden jest wskaźnikiem do
struktury z dodatkowymi 18 parametrami.
Już wiele lat temu ktoś powinien zapytać, czy rezygnacja z któregoś z tych parametrów dopro-
wadziłaby do negatywnych konsekwencji. Członek zespołu proponujący tak złożone wywołanie
systemowe powinien wówczas odpowiedzieć, że w pewnych przypadkach programista będzie
musiał się bardziej napracować nad realizacją określonych zadań, jednak faktycznym skutkiem
rezygnacji z części parametrów będzie prostszy, mniejszy i bardziej niezawodny system opera-
cyjny. Z drugiej strony osoba proponująca 10+18 parametrów mogłaby dodać: „Przecież użytkow-
nicy chętnie korzystają ze wszystkich tych funkcji”. Można by mu odpowiedzieć, że użytkownicy
lubią przede wszystkim niezawodne systemy zajmujące niewiele pamięci. Rozstrzygnięcie sporu
o ilość wykorzystywanej pamięci i liczbę funkcji ma zasadniczy wpływ na przyszłe działanie i cenę
systemu (ponieważ cena pamięci jest doskonale znana). Z drugiej strony niezwykle trudno oszaco-
wać liczbę dodatkowych błędów (występujących np. w ciągu roku) wynikających z dodania pewnej
funkcji lub pozostawienia użytkownikom wolnego wyboru (po uprzednim zapoznaniu ich z kosz-
tami). Warto więc odwołać się do pierwszego prawa oprogramowania sformułowanego przez
Tanenbauma:
Więcej kodu oznacza więcej błędów.
Dodawanie kolejnych funkcji oznacza dodawanie więcej kodu i — tym samym — wprowadzanie
do systemu kolejnych błędów. Programiści, którzy wierzą, że dodawanie nowych funkcji nie
powoduje dodawania nowych błędów, to albo nowicjusze w świecie komputerów, albo wyznawcy
teorii dobrej wróżki strzegącej ich przed błędami.
Dążenie do prostoty nie dotyczy tylko projektowania wywołań systemowych. Warto mieć na
uwadze znane powiedzenie Butlera W. Lampsona (1984):
Nie ukrywaj siły.
Jeśli dany sprzęt potrafi realizować pewne zadania wyjątkowo efektywnie, należy dołożyć wszel-
kich starań, aby umożliwić programistom proste korzystanie z tego potencjału i nie narzucać im
niezliczonej liczby zbędnych abstrakcji. Celem abstrakcji jest ukrywanie niepożądanych cech
systemu komputerowego, nigdy ukrywanie jego pozytywów. Przypuśćmy, że sprzęt oferuje
specjalny mechanizm efektywnego przenoszenia wielkich bitmap na ekranie (np. z wykorzy-
staniem pamięci wideo RAM). W takim przypadku naturalnym rozwiązaniem byłoby stworzenie
nowego wywołania systemowego uruchamiającego ten mechanizm zamiast poszukiwania sposo-
bów przenoszenia zawartości pamięci wideo RAM do pamięci głównej. Działanie nowego wywo-
łania powinno się ograniczać wyłącznie do przenoszenia bitów. Jeśli wywołanie systemowe jest
odpowiednio szybkie, użytkownicy zawsze mogą zbudować ponad tym wywołaniem wygodne
interfejsy. Jeśli jest wolne, nikt nie będzie go używał.
Innym dylematem, przed którym stoją projektanci, jest wybór wywołań połączeniowych lub
bezpołączeniowych. W systemach UNIX i Windows standardowe wywołania systemowe odczytu-
jące zawartość plików mają charakter połączeniowy (jak w telefonii). W pierwszym kroku należy
otworzyć plik, by następnie odczytywać jego zawartość, po czym go zamknąć. Model połączeniowy
stosuje się także w niektórych protokołach zdalnego dostępu do plików. Przykładowo korzy-
stanie z protokołu FTP wymaga zalogowania na zdalnym komputerze, odczytu plików i — po
zakończeniu pracy — wylogowania.
Z drugiej strony niektóre protokoły zdalnego dostępu do plików mają charakter bezpołącze-
niowy. Przykładem takiego protokołu jest HTTP. Odczytanie strony internetowej wymaga tylko
przekazania na serwer stosownego żądania — nie jest wymagane wcześniejsze ustanawianie
połączenia (połączenie TCP co prawda jest wymagane, jednak TCP to protokół niższego poziomu;
sam protokół HTTP wykorzystywany w procesie dostępu do stron WWW jest bezpołączeniowy).
Najważniejszą różnicą dzielącą model połączeniowy od modelu bezpołączeniowego jest koszt
związany z dodatkowymi operacjami ustanawiania połączenia (np. otwierania pliku) oraz ewentu-
alny zysk związany z brakiem konieczności każdorazowego łączenia się (zwykle w wielu wywoła-
niach systemowych) z żądanym zasobem. W przypadku plikowych operacji wejścia-wyjścia na
jednym komputerze, gdzie koszt ustanawiania połączenia jest niski, standardowy sposób (otwiera-
nia i używania) okazuje się najlepszym rozwiązaniem. W przypadku zdalnych systemów plików
wybór właściwego rozwiązania nie jest taki oczywisty.
Innym ważnym aspektem interfejsu wywołań systemowych jest widoczność samych wywołań.
Odnalezienie listy wywołań systemowych standardu POSIX nie stanowi najmniejszego problemu.
Wszystkie systemy UNIX obsługują zarówno te, jak i pewną (zwykle niewielką) liczbę dodat-
kowych wywołań systemowych, jednak kompletna lista jest zawsze publiczna. Zupełnie inaczej
postępuje firma Microsoft, która nigdy nie upubliczniła listy wywołań systemowych systemu
Windows. Ograniczono się do udostępnienia listy wywołań interfejsu WinAPI i innych interfej-
sów API, które jednak obejmują ogromną liczbę (ponad 10 tysięcy) wywołań procedur bibliotek
i stosunkowo niewiele prawdziwych wywołań systemowych. Za ujawnianiem pełnych list wywo-
łań systemowych przemawia to, że programiści aplikacji powinni wiedzieć, co jest tanie (funkcje
wykonywane w przestrzeni użytkownika), a co drogie (wywołania jądra). Z drugiej strony utaj-
nienie listy wywołań systemowych zapewnia większą swobodę twórcom systemu operacyjnego,
ponieważ mogą modyfikować wywołania systemowe bez naruszania funkcjonowania już istnieją-
cych programów użytkownika. Jak pisaliśmy w punkcie 9.7.7, projektanci popełnili błąd z wywoła-
niem systemowym access, a pomimo to jesteśmy skazani na posługiwanie się nim.
12.3. IMPLEMENTACJA
12.3.
IMPLEMENTACJA
Systemy wielowarstwowe
Jednym z najlepszych rozwiązań wypracowanych i rozwijanych całymi latami jest system wie-
lowarstwowy. Pierwszym wielowarstwowym systemem operacyjnym był system THE autorstwa
Dijkstry (patrz tabela 1.3). Strukturę wielowarstwową zastosowano także w systemach UNIX
i Windows 8, jednak w ich przypadku podział na warstwy jest raczej próbą znalezienia sposobu opisu
systemów niż skutkiem realizacji prawdziwej reguły projektowej podczas budowy systemów.
Projektanci nowego systemu, którzy decydują się na to rozwiązanie, powinni najpierw bar-
dzo uważnie dobrać warstwy projektowanej struktury i zdefiniować funkcje realizowane przez
każdą z tych warstw. Najniższa warstwa zawsze powinna próbować ukrywać najbardziej niezro-
zumiałe aspekty funkcjonowania sprzętu (dobrym przykładem jest pokazana na rysunku 11.2
warstwa HAL). Kolejna warstwa zwykle powinna obsługiwać przerwania, przełączanie kontekstu
i działanie jednostki MMU, aby kod w wyższych warstwach był (na tyle, na ile to możliwe) nieza-
leżny od sprzętu. Dobór struktury ponad tymi warstwami zależy w dużej mierze od preferencji
projektantów. Jednym z rozwiązań jest umieszczenie w trzeciej warstwie mechanizmów zarządza-
nia wątkami, w tym mechanizmów szeregowania i synchronizacji wątków (patrz rysunek 12.1). Taki
model oznacza, że począwszy od czwartej warstwy, dysponujemy prawidłowymi wątkami, które
można szeregować i synchronizować za pomocą standardowego mechanizmu (np. muteksów).
Czwarta warstwa składa się ze sterowników urządzeń, z których każdy ma postać odrębnego
wątku z własnym stanem, licznikiem programu, rejestrami itp. Sterowniki mogą (ale nie muszą)
działać w przestrzeni adresowej jądra. Taki projekt znacznie upraszcza strukturę wejścia-wyjścia,
ponieważ w razie wystąpienia przerwania istnieje możliwość jego konwersji na operację unlock
na muteksie i wywołania mechanizmu szeregującego (mechanizm szeregujący może wówczas
przydzielić procesor wątkowi, który do tej pory czekał na odblokowanie muteksa). Takie rozwią-
zanie zastosowano w systemie MINIX 3; w systemach UNIX, Linux i Windows 8 mechanizmy
obsługi przerwań działają na swoistej ziemi niczyjej — nie mają postaci wątków, które można
by szeregować, wstrzymywać itp. Ponieważ znaczna część złożoności każdego systemu opera-
cyjnego ma związek z operacjami wejścia-wyjścia, każda technika ułatwiająca zarządzanie tymi
operacjami i izolowanie ich jest warta rozważenia.
Ponad czwartą warstwą zwykle oczekuje się warstwy pamięci wirtualnej, jednego lub wielu
systemów plików oraz mechanizmów odpowiedzialnych za obsługę wywołań systemowych. Te
warstwy są odpowiedzialne za dostarczanie usług aplikacjom. Jeśli pamięć wirtualna znajduje się
w warstwie niższej niż systemy plików, istnieje możliwość stronicowania zawartości pamięci
podręcznej bloków, a menedżer pamięci wirtualnej może dynamicznie określać sposób dzielenia
pamięci rzeczywistej pomiędzy strony użytkownika a strony jądra (w tym pamięć podręczną).
Takie rozwiązanie zastosowano w systemie Windows 8.
Egzojądra
Chociaż podział na warstwy ma wielu zwolenników wśród projektantów systemów, istnieje też
obóz przeciwników tego modelu, którzy preferują zupełnie inne rozwiązanie [Engler et al., 1995].
Ta grupa projektantów uważa, że lepszym wyjściem jest stosowanie tezy koniec-koniec (ang.
end-to-end argument) [Saltzer et al., 1984]. Zgodnie z tą koncepcją, jeśli coś może być realizo-
wane przez sam program użytkownika, wykonanie tego zadania w niższej warstwie byłoby mar-
notrawstwem.
Wyobraźmy sobie aplikację, której głównym zadaniem jest uzyskiwanie dostępu do zdalnych
plików. Jeśli system obawia się uszkodzeń danych w transporcie, powinien dla każdego zapi-
sywanego pliku wyznaczać sumę kontrolną i przechowywać tę wartość wraz z danym plikiem.
W czasie przesyłania pliku za pośrednictwem sieci (z dysku źródłowego do procesu docelowego)
należy przesłać także sumę kontrolną, która powinna następnie zostać porównana z sumą kon-
trolną wyznaczoną po stronie odbiorcy. Jeśli obie sumy okażą się różne, plik trzeba będzie prze-
słać raz jeszcze.
Taka weryfikacja jest bezpieczniejsza niż stosowanie niezawodnego protokołu sieciowego,
ponieważ — oprócz typowych błędów transmisji — pozwala dodatkowo wychwytywać błędy na
dysku, błędy w pamięci, błędy w oprogramowaniu routerów i inne usterki. Teza koniec-koniec
mówi, że stosowanie niezawodnego protokołu sieciowego nie jest wówczas konieczne, ponie-
waż punkt końcowy (proces odbiorcy) dysponuje wszystkimi informacjami niezbędnymi do
weryfikacji poprawności pliku. W tej sytuacji jedynym powodem stosowania niezawodnego proto-
kołu sieciowego może być dążenie do podniesienia efektywności (poprzez szybsze wykrywanie
i korygowanie błędów transmisji).
Tezę koniec-koniec można zastosować dla niemal wszystkich aspektów funkcjonowania sys-
temu operacyjnego. Zgodnie z tą tezą system operacyjny nie powinien realizować żadnych zadań,
które mogą być wykonywane przez program użytkownika. Po co mielibyśmy np. udostępniać
system plików? Wystarczy przecież, by użytkownik sam odczytywał i zapisywał fragmenty suro-
wego dysku z zachowaniem odpowiednich zabezpieczeń. Większość plików oczywiście lubi
dysponować plikami — w tej sytuacji (zgodnie z tezą koniec-koniec) można udostępnić użyt-
kownikowi system plików zaimplementowany w formie procedur biblioteki dołączanej do każ-
dego programu potrzebującego plików. Takie rozwiązanie umożliwia różnym programom sto-
sowanie różnych systemów plików. W prezentowanym modelu zadania systemu operacyjnego
ograniczają się do bezpiecznego przydzielania zasobów (np. procesora i dysków) konkurującym
użytkownikom. Przykładem systemu operacyjnego opracowanego zgodnie z zaleceniami tezy
koniec-koniec jest Exokernel [Engler et al., 1995].
Sterowniki urządzeń wchodzące w skład jądra mają bezpośredni dostęp do rejestrów urzą-
dzeń sprzętowych. Sterowniki spoza jądra wymagają dodatkowego mechanizmu zapewniającego
dostęp do tych rejestrów. Jeśli pozwalają na to rozwiązania sprzętowe, każdy proces sterownika
może uzyskiwać dostęp tylko do tych urządzeń wejścia-wyjścia, których potrzebuje. Każdy proces
sterownika realizującego np. operacje wejścia-wyjścia odwzorowywane w pamięci może dyspo-
nować stroną pamięci dla odpowiedniego urządzenia, ale nie stronami pozostałych urządzeń. Jeśli
przestrzeń portów wejścia-wyjścia może być częściowo chroniona, istnieje możliwość udostęp-
niania poszczególnym sterownikom właściwych fragmentów tej przestrzeni.
Opisany model można zrealizować nawet wtedy, gdy nie można liczyć na wsparcie warstwy
sprzętowej. W takim przypadku konieczne jest zdefiniowanie nowego wywołania systemowego
dostępnego tylko dla procesów sterowników urządzeń i otrzymującego na wejściu listę par port-
-wartość. Jądro w pierwszej kolejności sprawdza, czy dany proces dysponuje wszystkimi portami
z tej listy. Jeśli tak, jądro kopiuje otrzymane wartości na właściwe porty, inicjując — tym samym —
odpowiednie operacje wejścia-wyjścia.
Podobnego wywołania można by użyć do odczytywania danych z portów wejścia-wyjścia w chro-
niony sposób. Można by stworzyć analogiczny zbiór wywołań umożliwiający procesom sterowników
odczytywanie i zapisywanie tablic jądra, ale tylko w kontrolowany sposób i za aprobatą jądra.
Największym problemem tego modelu (i ogólnie koncepcji mikrojądra) jest obniżona wydaj-
ność wskutek dodatkowych operacji przełączania kontekstu. Z drugiej strony niemal wszystkie
projekty rozwijające tę koncepcję realizowano wiele lat temu, kiedy procesory były nieporów-
Systemy rozszerzalne
Opisany powyżej model systemów typu klient-serwer miał na celu przeniesienie możliwie wielu
funkcji poza jądro. Rozwiązaniem przeciwnym jest umieszczenie jak największej części modułów
w jądrze, tyle że w odpowiednio chroniony sposób. W tym przypadku kluczem do sukcesu jest
oczywiście słowo chroniony. Pewne mechanizmy takiej ochrony omawialiśmy już w punkcie
9.8.6 — przedstawione tam techniki początkowo projektowano z myślą o importowaniu apletów
za pośrednictwem internetu, jednak znajdują zastosowanie także podczas umieszczania obcego
kodu w jądrze. Do najważniejszych technik tego typu należy izolowanie (ang. sandboxing) oraz
podpisywanie kodu (inna technika — interpretacji — akurat w przypadku kodu jądra byłaby
raczej niepraktyczna).
Sama koncepcja systemu rozszerzalnego oczywiście nie definiuje struktury systemu opera-
cyjnego. Z drugiej strony można rozpocząć proces projektowania od opracowania minimalnego
systemu złożonego niemal wyłącznie z mechanizmu ochrony, aby następnie przystąpić do stop-
niowego dodawania do jądra chronionych modułów aż do osiągnięcia oczekiwanego zbioru funkcji.
Minimalny system operacyjny można nawet budować z myślą o konkretnej aplikacji. Nowy sys-
tem operacyjny można wówczas dostosowywać do kolejnych aplikacji — wystarczy włączyć do
jądra niezbędne moduły. Przykładem takiego systemu jest Paramecium [van Doorn, 2001].
Wątki jądra
Innym ważnym problemem, który występuje niezależnie od wybranej struktury, jest kwestia
wątków systemowych. W pewnych sytuacjach warto wprowadzić do modelu systemu wątki jądra
jako jednostki niezależne i odrębne względem procesów użytkownika. Wątki jądra mogą działać
w tle i odpowiadać za takie zadania jak zapisywanie na dysku brudnych stron, wymiana procesów
pomiędzy pamięcią główną a dyskiem itp. W praktyce struktura jądra może się składać z samych
tego rodzaju wątków — wywołanie systemowe nie powoduje wówczas przejścia wątku użyt-
kownika do trybu jądra, tylko zablokowanie tego wątku i przekazanie kontroli wątkowi jądra
odpowiedzialnemu za realizację właściwego zadania.
Oprócz wątków jądra wykonywanych w tle większość systemów operacyjnych korzysta z wielu
procesów demonów (także działających w tle). Mimo że procesy demonów nie są częścią sys-
temu operacyjnego, często realizują typowe zadania „systemowe”. Procesy z tej grupy nierzadko
odpowiadają za odbieranie i wysyłanie wiadomości poczty elektronicznej oraz realizację rozma-
itych żądań nadsyłanych przez zdalnych użytkowników, jak żądania protokołu FTP czy żądania
stron WWW.
obniżane po wykorzystaniu kwantu czasu. Istnieje jeszcze wiele innych strategii, jednak z naszego
punktu widzenia najważniejsze jest oddzielenie strategii (zasad szeregowania) od mechanizmu
(sposobu obsługi listy gotowych wątków).
Innym ciekawym przykładem jest stronicowanie. Odpowiedni mechanizm wymaga zarządzania
jednostką MMU, utrzymywania list zajętych i wolnych stron oraz kodu przenoszącego strony
pomiędzy dyskiem a pamięcią główną. Strategia określa, co należy robić w razie wystąpienia
błędu braku strony. Strategia może mieć charakter lokalny lub globalny, może stosować technikę
LRU lub FIFO, jednak wybrany algorytm może (i powinien) być skutecznie odizolowany od
mechanizmu bezpośrednio odpowiedzialnego za zarządzanie stronami.
Trzecim ciekawym przykładem jest model ładowania modułów do jądra. Odpowiedni mecha-
nizm odpowiada za sposób dodawania ich kodu do kodu jądra, sposób ich łączenia, zakres wywołań
dostępnych dla dołączanego kodu oraz zakres wywołań, które można wykonywać na tym kodzie.
Strategia określa, kto może ładować moduły do jądra i które moduły mogą być ładowane w ten
sposób. Być może prawo ładowania modułów będzie miał tylko superużytkownik, a może takie
prawo zyska każdy użytkownik dysponujący cyfrowym podpisem potwierdzonym przez odpo-
wiedni ośrodek.
12.3.3. Ortogonalność
Dobry projekt systemu obejmuje wiele odrębnych elementów, które można łączyć niezależnie
od siebie. Przykładowo w języku programowania C istnieją takie proste typy danych jak liczby
całkowite, znaki czy liczby zmiennoprzecinkowe. Istnieje też mechanizm łączenia prostych typów
danych w ramach tablic, struktur i unii. Programista może łączyć te typy niezależnie od siebie —
istnieje możliwość definiowania tablic liczb całkowitych, tablic znaków, struktury i unii ze składo-
wymi reprezentującymi liczby zmiennoprzecinkowe itp. W praktyce raz zdefiniowany typ danych,
np. tablica liczb całkowitych, może być wykorzystywana tak jak proste typy danych, np. w roli
składowej struktury lub unii. Możliwość niezależnego łączenia odrębnych bytów określa się
mianem ortogonalności (ang. orthogonality). Ortogonalność jest bezpośrednim następstwem zasad
prostoty i kompletności.
Koncepcja ortogonalności występuje (pod wieloma postaciami) także w świecie systemów
operacyjnych. Dobrym przykładem jest wywołanie systemowe clone Linuksa, które tworzy nowy
wątek. Na wejściu tego wywołania przekazuje się mapę bitową, która umożliwia współdzielenie
i indywidualne kopiowanie przestrzeni adresowej, katalogu roboczego, deskryptorów plików
i sygnałów. Jeśli programista zdecyduje się na skopiowanie wszystkich tych elementów, powsta-
nie nowy proces (wówczas działanie wywołania clone jest identyczne jak w przypadku wywo-
łania fork). Jeśli nic nie jest kopiowane, wywołanie clone tworzy nowy wątek w ramach bieżą-
cego procesu. Okazuje się jednak, że istnieje też możliwość utworzenia pośrednich form
współdzielenia (niedostępnych w tradycyjnych systemach UNIX). Oddzielenie poszczególnych
elementów i zapewnienie ich ortogonalności zapewnia nam lepszą kontrolę nad procedurami two-
rzenia procesów i wątków.
Innym zastosowaniem ortogonalności jest oddzielanie pojęcia procesu od pojęcia wątku
w systemie operacyjnym Windows 8. We wspomnianym systemie proces jest kontenerem dla
zasobów, niczym więcej i niczym mniej. Wątek to jednostka szeregowania. Kiedy jeden proces
otrzymuje uchwyt innego procesu, nie ma znaczenia liczba wątków składających się na ten
proces. Podczas szeregowania wątku nie ma znaczenia, do którego procesu ten wątek należy.
Pojęcia procesów i wątków są więc ortogonalne.
12.3.4. Nazewnictwo
Większość wykorzystywanych przez system operacyjny struktur danych, które cechują się długim
czasem życia, ma przypisywane nazwy lub identyfikatory umożliwiające wygodne odwołania do
tych struktur. Do najbardziej oczywistych przykładów takich identyfikatorów należą nazwy użyt-
kowników, nazwy plików, nazwy urządzeń, identyfikatory procesów itp. Wybór sposobu przypi-
sywania tego rodzaju nazw i zarządzania nimi jest jedną z najważniejszych decyzji, które muszą
zostać podjęte podczas projektowania i implementowania systemu operacyjnego.
Nazwy projektowane z myślą o wygodzie użytkowników mają postać łańcuchów znakowych
w formacie ASCII lub Unicode i zwykle tworzą struktury hierarchiczne. Przykładem takiej struktury
hierarchicznej są ścieżki do katalogów, np. /usr/ast/books/mos2/chap-12, reprezentujące sekwen-
cje katalogów, począwszy od katalogu głównego. Innym przykładem struktur hierarchicznych
są adresy URL. I tak adres www.cs.vu.nl/~ast/ wskazuje na konkretny komputer (www) okre-
ślonego wydziału (cs) na określonym uniwersytecie (vu) w określonym kraju (nl). Część adresu
za prawym ukośnikiem identyfikuje konkretny plik na wskazanym komputerze (w tym przy-
padku — zgodnie z konwencją — plik www/index.html w katalogu domowym użytkownika ast).
Warto przy tej okazji wspomnieć, że adresy URL (i ogólnie adresy DNS, w tym adresy poczty
elektronicznej) interpretuje się odwrotnie, tj. od dołu drzewa. Zupełnie inaczej jest w przypadku
nazw plików, które analizuje się od szczytu drzewa. Odmienny sposób interpretacji ścieżek
i adresów może też zależeć od tego, czy drzewo jest zapisywane od lewej do prawej strony,
czy od prawej do lewej strony.
Nazwy często przypisuje się na dwóch różnych poziomach: zewnętrznym i wewnętrznym.
Przykładowo pliki zawsze mają przypisywane nazwy w czytelnej dla użytkowników formie łań-
cuchów znakowych. Dodatkowo niemal wszystkie systemy wykorzystują nazwy wewnętrzne.
W systemie UNIX rzeczywistą nazwą pliku jest numer odpowiedniego i-węzła (nazwa ASCII
w ogóle nie jest wykorzystywana wewnętrznie). W praktyce nazwy zewnętrzne nawet nie muszą
być unikatowe, ponieważ na pojedynczy plik może wskazywać wiele dowiązań. Analogiczne
rozwiązanie zastosowano w systemie Windows 8, gdzie pliki są wewnętrznie reprezentowane
przez indeksy w tablicy MFT. Zadaniem katalogu jest zapewnianie odwzorowania pomiędzy
nazwą zewnętrzną a nazwą wewnętrzną (patrz rysunek 12.3).
W wielu przypadkach (m.in. w pokazanym powyżej przykładzie nazw plików) nazwy wewnętrzne
mają postać liczb całkowitych bez znaku wykorzystywanych w roli indeksów tablic jądra. Innymi
ciekawymi przykładami nazw wykorzystywanych w roli indeksów są deskryptory plików sto-
sowane w systemie UNIX oraz uchwyty obiektów stosowane w systemie Windows 8. Warto
podkreślić, że dla żadnej z tych struktur nie istnieje reprezentacja zewnętrzna. Zaprojektowano
Języki programowania często obsługują wiele trybów wiązania zmiennych z adresami wir-
tualnymi. Zmienne globalne są kojarzone z określonymi adresami wirtualnymi już przez kom-
pilator (w ich przypadku możemy więc mówić o wczesnym wiązaniu). Zmienne lokalne w ramach
procedur mają przypisywane adresy wirtualne (na stosie) dopiero w czasie wywoływania swo-
ich procedur (tu mamy do czynienia z wiązaniem pośrednim). Zmienne składowane na stercie
(alokowane za pomocą procedury malloc języka C lub metody new języka Java) mają przydzielane
adresy wirtualne dopiero wtedy, gdy są rzeczywiście potrzebne (to przykład późnego wiązania).
Systemy operacyjne zwykle stosują technikę wczesnego wiązania nazw dla większości struk-
tur danych i tylko w niektórych przypadkach korzystają z mechanizmu późnego wiązania (dla
większej elastyczności). Dobrym przykładem jest alokowanie pamięci. Wczesne systemy wielo-
programowe na komputerach pozbawionych sprzętowych mechanizmów relokacji adresów musiały
ładować programy pod pewien adres w pamięci i relokować je w celu właściwego wykonania
pod tym adresem. W razie usunięcia z pamięci w procesie wymiany system musiał przywrócić
program pod ten sam adres. Z drugiej strony pamięć wirtualna ze stronicowaniem jest przy-
kładem późnego kojarzenia. Adres fizyczny skojarzony z danym adresem wirtualnym nie jest
znany do momentu przeniesienia odpowiedniej strony do pamięci.
Innym ciekawym przykładem późnego kojarzenia jest rozmieszczenie okien w ramach gra-
ficznego interfejsu użytkownika (GUI). Inaczej niż we wczesnych systemach graficznych, w któ-
rych to programista musiał określać bezwzględne współrzędne wszystkich obrazów na ekranie,
we współczesnych interfejsach GUI oprogramowanie wykorzystuje współrzędne względem punktu
początkowego okna (z natury rzeczy nieznanego do momentu umieszczenia tego okna na ekranie
i zmienianego wraz ze zmianą pozycji okna).
Listing 12.2. Kod poszukujący określonego identyfikatora PID w statycznej tablicy procesów
found = 0;
for (p = &proc_table[0]; p < &proc_table[PROC_TABLE_SIZE]; p++) {
if (p->proc_pid == pid) {
found = 1;
break;
}
}
Tablice statyczne sprawdzają się najlepiej wtedy, gdy ilość niezbędnej pamięci oraz poziom
wykorzystania tych struktur można dość precyzyjnie przewidzieć z dużym wyprzedzeniem.
W przypadku np. systemu jednego użytkownika jest mało prawdopodobne, by ten użytkownik
uruchomił jednocześnie więcej niż 64 procesy; co więcej, nawet nieudana próba uruchomienia
65. procesu nie będzie katastrofą.
Jeszcze innym rozwiązaniem jest stosowanie tablicy stałej długości z możliwością alokacji
nowej tablicy stałej długości (dwukrotnie większej) w razie wypełnienia oryginalnej, pierwszej
tablicy. Wpisy z pierwszej tablicy można wówczas skopiować do nowej tablicy, a przestrzeń zaj-
mowaną przez oryginalną tablicę można zwrócić do puli wolnej pamięci. Takie rozwiązanie pozwala
zachować strukturę jednej ciągłej tablicy, zamiast korzystać z listy jednokierunkowej. Wadą
tego modelu jest konieczność stosowania dodatkowego mechanizmu zarządzania pamięcią oraz
zmieniający się adres tej struktury.
Podobny dylemat mają projektanci stosów jądra. Kiedy jakiś wątek użytkownika przechodzi
w tryb jądra lub kiedy jest uruchamiany wątek trybu jądra, należy mu przydzielić stos przestrzeni
jądra. W przypadku wątków użytkownika stos można zainicjalizować w taki sposób, aby rósł
w dół, począwszy od szczytu wirtualnej przestrzeni adresowej (takie rozwiązanie eliminuje
konieczność określania jego rozmiaru z wyprzedzeniem). W przypadku wątków jądra rozmiar
należy zdefiniować z góry, ponieważ stos zajmuje część wirtualnej przestrzeni adresowej jądra,
która może zawierać więcej stosów. Pytanie brzmi: ile przestrzeni należy przydzielić poszcze-
gólnym stosom w tej przestrzeni? Mamy więc do czynienia z dylematem podobnym do tego
opisanego w kontekście tablicy procesów. Mamy więc do czynienia z dylematem podobnym do
tego opisanego w kontekście tablicy procesów.
Innym obszarem sporu o wyższość rozwiązań statycznych nad dynamicznymi jest szerego-
wanie procesów. W niektórych systemach, szczególnie w systemach czasu rzeczywistego, ist-
nieje możliwość statycznego szeregowania zadań z wyprzedzeniem; np. linie lotnicze znają
rozkład lotów na kilka tygodni przed startami samolotów. Podobnie systemy multimedialne
„wiedzą” z wyprzedzeniem, jak szeregować procesy odpowiedzialne za odtwarzanie dźwięku
i obrazu. W pozostałych przypadkach podobna wiedza nie jest dostępna, zatem szeregowanie
musi mieć charakter dynamiczny.
Jeszcze innym elementem, w którym należy wybrać rozwiązanie statyczne lub dynamiczne,
jest struktura jądra. Najprostszym rozwiązaniem wydaje się umieszczenie jądra w pojedynczym
programie binarnym ładowanym w całości do pamięci. Taki model oznaczałby jednak koniecz-
ność ponownego łączenia jądra z nowym sterownikiem przy okazji dodawania każdego nowego
urządzenia wejścia-wyjścia. W ten sposób działały wczesne wersje systemu UNIX — model
ten sprawdzał się w środowisku minikomputerów, kiedy nowe urządzenia wejścia-wyjścia doda-
wano wyjątkowo rzadko. Obecnie większość systemów operacyjnych oferuje możliwość dyna-
micznego dodawania kodu do jądra, godząc się na związany z tym wzrost złożoności.
na nadejście odpowiedzi — czy może być coś prostszego? Sprawa staje się nieco bardziej skom-
plikowana, gdy jest wiele klientów, z których każdy wymaga uwagi serwera. Każde żądanie
może zablokować się na długi czas w oczekiwaniu na zakończenie obsługi innych żądań. Pro-
blem można rozwiązać poprzez wprowadzenie obsługi wielowątkowości na poziomie serwera,
tak aby każdy wątek mógł obsługiwać jednego klienta. Model został wypróbowany i przetesto-
wany w wielu rzeczywistych implementacjach, systemach operacyjnych, jak również aplikacjach
użytkownika.
Sprawy jeszcze bardziej się komplikują, jeśli wątki często czytają i zapisują współdzielone
struktury danych. W takim przypadku blokowanie jest nieuniknione. Niestety, właściwe stoso-
wanie blokad nie jest łatwe. Najprostszym rozwiązaniem jest zastosowanie jednej wielkiej blo-
kady na wszystkie struktury danych (podobnie do wielkiej blokady jądra). Gdy wątek chce
uzyskać dostęp do współdzielonych struktur danych, musi najpierw uzyskać blokadę. Ze wzglę-
dów wydajnościowych jedna wielka blokada jest złym pomysłem, ponieważ wątki przez cały
czas wzajemnie na siebie czekają — nawet jeśli ze sobą nie kolidują. Druga skrajność: wiele
mikroblokad dotyczących (fragmentów) pojedynczych struktur danych; to rozwiązanie znacznie
prostsze, ale sprzeczne z wiodącą zasadą numer jeden — zapewnieniem prostoty.
W innych systemach operacyjnych komunikacja międzyprocesowa jest budowana z wykorzy-
staniem prymitywów asynchronicznych. Pod pewnymi względami komunikacja asynchroniczna
jest jeszcze prostsza od swojej synchronicznej kuzynki. Proces klienta wysyła wiadomość na
serwer, ale zamiast czekać na dostarczenie wiadomości lub przesłanie odpowiedzi, po prostu
kontynuuje działanie. Oczywiście oznacza to, że odpowiedź również przychodzi asynchronicznie,
zatem proces klienta powinien pamiętać, które żądanie dotyczy której odpowiedzi. Serwer zazwy-
czaj przetwarza żądania (zdarzenia) jako pojedyncze wątki w pętli obsługi zdarzeń.
Za każdym razem, gdy żądanie wymaga od serwera kontaktu z innymi serwerami w celu
dalszego przetwarzania, wysyła własny asynchroniczny komunikat i zamiast się zablokować,
kontynuuje przetwarzanie następnego żądania. Nie ma potrzeby istnienia wielu wątków. Ponieważ
istnieje tylko jeden wątek przetwarzający zdarzenia, problem wielu wątków próbujących uzy-
skać dostęp do współdzielonych struktur danych nie może występować. Z drugiej strony długo
działająca procedura obsługi zdarzeń sprawia, że mechanizm odpowiedzi serwera obsługiwanych
w jednym wątku może być niewydajny.
Dylemat, czy lepszym modelem programowania są wątki, czy zdarzenia, to od lat dyskuto-
wana, kontrowersyjna kwestia, która niepokoiła umysły fanatyków po obu stronach od czasu
opublikowania klasycznego artykułu Johna Ousterhouta: Why threads are a bad idea (for most
purposes) (dosł. Dlaczego wątki to zły pomysł (dla większości zastosowań)) (1996). Ousterhout
twierdzi, że wątki niepotrzebnie komplikują wiele rzeczy, m.in. blokowanie, debugowanie, wywo-
łania zwrotne, wydajność. Oczywiście nie byłoby kontrowersji, gdyby wszyscy zgodzili się z takim
poglądem. Kilka lat po opublikowaniu artykułu Ousterhouta [von Behren et al., 2003] opubli-
kowali artykuł zatytułowany Why events are a bad idea (for highconcurrency servers) (dosł. „Dla-
czego zdarzenia to zły pomysł (dla serwerów o dużym stopniu współbieżności)). Tak więc decyzja
co do stosowanego modelu programowania jest dla projektantów systemów trudna, ale ważna.
W tym przypadku nie istnieje jednoznaczny zwycięzca. W serwerach WWW, takich jak apache,
konsekwentnie stosowane są komunikacja synchroniczna i wątki, ale inne, np. lighttpd, bazują
na paradygmacie zdarzeniowym (ang. event-drivent paradigm). Oba podejścia są bardzo popularne.
W naszej opinii zdarzenia są często łatwiejsze do zrozumienia i debugowania niż wątki. Jeśli nie
ma potrzeby stosowania współbieżności na poziomie rdzeni, jest to prawdopodobnie dobry wybór.
Ukrywanie sprzętu
Kod obsługi sprzętu w większości jest brzydki. Należy więc możliwie wcześnie podjąć starania na
rzecz jego ukrycia (chyba że chcemy w pełni prezentować jego potencjał, czego jednak zwykle
się nie robi). Niektóre szczegóły najniższego poziomu można skutecznie ukrywać za pomocą
odpowiednika warstwy HAL w tej czy innej formie (patrz rysunek 12.1 — warstwa 1.). Oka-
zuje się jednak, że wielu szczegółów związanych z funkcjonowaniem sprzętu nie da się ukryć
w ten sposób.
Jednym z aspektów wymagających rozwiązania na wczesnym etapie prac jest obsługa prze-
rwań. Przerwania utrudniają programowanie, ale ich obsługa przez system operacyjny okazuje
się absolutnie niezbędna. Jedno z możliwych rozwiązań to natychmiastowa konwersja przerwań
na inne byty. Każde przerwanie można np. przekształcić w błyskawicznie pojawiający się wątek.
Od tej pory operujemy na wątkach, nie na kłopotliwych przerwaniach.
Drugim rozwiązaniem jest konwersja każdego przerwania na operację unlock wykonywaną
na muteksie, na którego odblokowanie czeka odpowiedni sterownik. W takim przypadku jedynym
faktycznym skutkiem wystąpienia przerwania jest przejście pewnego wątku w stan gotowości.
Trzecie rozwiązanie polega na konwersji przerwania na komunikat wysyłany do pewnego
wątku. Niskopoziomowy kod odpowiada więc tylko za skonstruowanie komunikatu określającego,
skąd wzięło się dane żądanie, umieszczenie tego komunikatu w kolejce i wywołanie mechanizmu
szeregującego, aby (być może) umożliwić wykonanie procedury obsługującej (która najpraw-
dopodobniej czeka na dany komunikat). Wszystkie przytoczone techniki mają na celu konwer-
sję przerwań na operacje synchronizujące wątki. Obsługa przerwań przez odpowiednie wątki
w odpowiednim kontekście jest prostsza niż wywoływanie procedur obsługi w przypadkowym
kontekście (obowiązującym akurat w czasie wystąpienia wątku). Wszystkie te działania oczywiście
muszą gwarantować należytą efektywność — to wymaganie stawia się wszystkim mechanizmom
działającym głęboko we wnętrzu systemu operacyjnego.
Większość systemów operacyjnych projektuje się z myślą o działaniu na wielu platformach
sprzętowych. Obsługiwane platformy mogą się od siebie różnić procesorami, jednostkami MMU,
długością słowa, ilością pamięci RAM i innymi cechami, które trudno ukryć, jeśli stosuje się
warstwę HAL lub jej odpowiednik. Tak czy inaczej, opracowanie jednego zbioru plików źró-
dłowych wykorzystywanych do generowania wszystkich wersji jest wysoce pożądane; w prze-
ciwnym razie ewentualne błędy trzeba byłoby eliminować wielokrotnie w wielu plikach z kodem
źródłowym, co z kolei wiązałoby się z ryzykiem utraty spójności tych plików.
Z niektórymi różnicami sprzętowymi, np. rozmiarem pamięci operacyjnej, można sobie pora-
dzić — wystarczy określić odpowiednie wartości w czasie uruchamiania systemu operacyjnego
i przechowywać je w zmiennych. Mechanizmy alokujące pamięci mogą wykorzystywać np.
zmienną rozmiaru pamięci RAM do określania, jak duże powinny być rozmiary pamięci pod-
ręcznej bloków, tablice stron itp. Nawet rozmiary tablic statycznych (np. tablicy procesów) mogą
być uzależnione od łącznej ilości dostępnej pamięci.
Z drugiej strony istnieją różnice, jak odmienne procesory, których nie można zamaskować
poprzez zastosowanie pojedynczego kodu binarnego określającego w czasie wykonywania rodzaj
procesora, na którym działa. Można ten problem ominąć — utworzyć jeden kod źródłowy i skom-
pilować go dla wielu platform docelowych za pomocą konstrukcji kompilacji warunkowej. W pli-
kach źródłowych należy zdefiniować flagi dla różnych konfiguracji sprzętowych, aby w czasie
kompilacji wykorzystano kod właściwy danemu procesorowi, długości słowa, jednostce MMU
itp. Wyobraźmy sobie np. system operacyjny implementowany z myślą o procesorach Pentium
i UltraSPARC oraz wymagający odmiennego kodu inicjalizującego. W takim przypadku procedurę
init można by zaimplementować tak jak na listingu 12.3(a). W zależności od wartości makra
CPU (zdefiniowanego w pliku nagłówkowym config.h) kompilator wybiera jedną z wersji kodu
inicjalizującego. Ponieważ generowany w ten sposób kod binarny zawiera tylko wersję wła-
ściwą docelowej platformie sprzętowej, takie rozwiązanie nie powoduje spadku efektywności.
Listing 12.3. (a) Kompilacja warunkowa zależna od typu procesora; (b) kompilacja warunkowa
zależna od długości słowa
(a) (b)
#include "config.h" #include "config.h"
init( ) #if (WORD_LENGTH == 32)
{ typedef int Register;
#if (CPU == IA32) #endif
/* Inicjalizacja dla IA32 */
#endif #if (WORD_LENGTH == 64)
typedef long Register;
#if (CPU == ULTRASPARC) #endif
/* Inicjalizacja dla UltraSPARC */
#endif Register R0, R1, R2, R3;
}
Przeanalizujmy teraz drugi przykład. Przypuśćmy, że nasz system potrzebuje typu danych
Register, który na komputerach z procesorem Pentium powinien reprezentować wartość 32-bitową,
a na komputerach z procesorem UltraSPARC wartość 64-bitową. Można tę różnicę obsłużyć
dzięki zastosowaniu kodu warunkowego z listingu 12.3(b) (przy założeniu, że kompilator gene-
ruje 32-bitowe wartości typu int i 64-bitowe wartości typu long). Po umieszczeniu tej definicji
w kodzie źródłowym (prawdopodobnie w jakimś pliku nagłówkowym) zadanie programisty ogra-
nicza się już tylko do zadeklarowania zmiennych typu Register, które będą reprezentowały war-
tości właściwej długości.
Prawidłowe działanie tego schematu wymaga oczywiście prawidłowego zdefiniowania pliku
config.h. Dla procesora IA32 zawartość tego pliku mogłaby mieć następującą postać:
#define CPU IA32
#define WORD LENGTH 32
Kompilacja systemu dla procesora UltraSPARC wymagałaby użycia innego pliku config.h z war-
tościami właściwymi tej platformie sprzętowej, np. w następującej formie:
#define CPU ULTRASPARC
#define WORD LENGTH 64
64-bitowych dla procesora UltraSPARC). Okazuje się jednak, że nie byłoby to właściwe rozwią-
zanie. Wyobraźmy sobie przyszłą próbę przeniesienia tak zaimplementowanego systemu na
32-bitową platformę ARM. W takim przypadku należałoby dodać do listingu 12.3(b) trzeci waru-
nek dla procesora ARM. Programiści powinni jeszcze umieścić następujący wiersz:
#define WORD_LENGTH 32
Pośrednictwo
Mówi się czasem, że w świecie komputerów nie ma problemów, których nie można rozwiązać
poprzez wprowadzenie kolejnego poziomu pośrednictwa. Chociaż przytoczone twierdzenie
wydaje się przesadne, jest w nim ziarnko prawdy. Przyjrzyjmy się kilku przykładom. Na kom-
puterach x86 naciśnięcie klawisza powoduje wygenerowanie przez warstwę sprzętową prze-
rwania i umieszczenie w rejestrze urządzenia numeru naciśniętego klawisza (zamiast kodu
znaku ASCII).
Co więcej, także zwolnienie tego klawisza powoduje wygenerowanie kolejnego przerwania
z numerem klawisza w rejestrze urządzenia. Odpowiednia warstwa pośrednicząca umożliwia
systemowi operacyjnemu użycie tego numeru w roli indeksu tablicy znaków ASCII, co znacznie
ułatwia obsługę wielu różnych klawiatur stosowanych w różnych krajach. Informacje o naci-
skanych i zwalnianych klawiszach umożliwiają właściwą obsługę takich klawiszy jak Shift, ponie-
waż pozwalają systemowi operacyjnemu precyzyjnie określać sekwencję naciskania i przytrzy-
mywania klawiszy.
Pośrednictwo stosuje się także dla danych wyjściowych. Programy mogą zapisywać na ekranie
znaki ASCII, które są interpretowane jako indeksy elementów tablicy znaków dla bieżącej czcionki.
Każdy element tej tablicy reprezentuje bitmapę odpowiedniego znaku. Takie pośrednictwo umoż-
liwia skuteczne oddzielanie znaków od czcionek.
Jeszcze innym przykładem pośrednictwa jest stosowanie głównych numerów urządzeń w sys-
temie UNIX. Jądro utrzymuje dwie wewnętrzne tablice indeksowane według głównych numerów
urządzeń blokowych i głównych numerów urządzeń znakowych. Kiedy proces otwiera jakiś
plik specjalny, np. /dev/hd0, system określa typ odpowiedniego urządzenia (może to być urzą-
dzenie blokowe lub znakowe) oraz główny i pomocniczy numer tego urządzenia, po czym odnaj-
duje właściwy sterownik w tablicy sterowników. Poziom pośrednictwa ułatwia modyfikowanie
konfiguracji systemu, ponieważ programy operują na symbolicznych nazwach urządzeń, nie na
nazwach sterowników.
Z kolejnym przykładem pośrednictwa mamy do czynienia w systemach przekazywania komu-
nikatów, które w roli adresatów wykorzystują skrzynki pocztowe zamiast konkretnych proce-
sów docelowych. Pośrednictwo skrzynek pocztowych (w przeciwieństwie do identyfikatorów
procesów) zapewnia nieporównanie większą elastyczność (podobną do możliwości przeglądania
wiadomości przez sekretarkę zamiast bezpośrednio przez jej przełożonego).
W pewnym sensie także stosowanie makr w postaci:
#define PROC_TABLE SIZE_256
jest specyficzną formą pośrednictwa, ponieważ programista może pisać swój kod bez koniecz-
ności uwzględniania wielkości struktur danych (w tym przypadku tablicy procesów). Ogólnie
dobrą praktyką jest nadawanie nazw symbolicznych wszystkim stałym (może z wyjątkiem –1,
0 i 1) oraz umieszczanie odpowiednich definicji w nagłówkach z komentarzami wyjaśniającymi
ich przeznaczenie.
Wielobieżność
Wielobieżność (ang. reentrancy) oznacza zdolność kodu do równoczesnego wykonywania w dwóch
kopiach lub większej ich liczbie. W systemach wieloprocesorowych zawsze istnieje ryzyko, że
w trakcie wykonywania pewnej procedury przez jeden procesor inny procesor rozpocznie wyko-
nywanie tej samej procedury, zanim zakończy się jej wykonywanie na pierwszym procesorze.
Oznacza to, że dwa (lub więcej) wątki na różnych procesorach mogą jednocześnie wykonywać
ten sam kod. W takim przypadku obszary krytyczne wymagają właściwej ochrony z wykorzy-
staniem muteksów lub innych mechanizmów synchronizacji.
Okazuje się jednak, że ten sam problem występuje w środowisku jednoprocesorowym.
W szczególności większość systemów operacyjnych obsługuje przerwania. System musi obsłu-
giwać przerwania niezwłocznie, ponieważ utrata przerwań powodowałaby niestabilność systemu.
Oznacza to, że w czasie wykonywania pewnej procedury P może wystąpić przerwanie, a procedura
obsługująca to żądanie może wywołać tę samą procedurę P. Jeśli w momencie wystąpienia żądania
struktury danych tej procedury znajdują się w stanie niespójności i nie są odpowiednio chronione,
może się okazać, że procedura obsługująca operuje na nieprawidłowych danych.
Bodaj najbardziej oczywistym przykładem sytuacji, w które opisane zdarzenie może mieć
miejsce, jest procedura P pełniąca funkcję mechanizmu szeregującego. Przypuśćmy, że pewien
proces wykorzystał swój kwant czasu i że system operacyjny przenosi ten proces na koniec
swojej wewnętrznej kolejki. W trakcie modyfikowania list procesów występuje przerwanie,
które powoduje uaktywnienie (przejście w stan gotowości) pewnego procesu i zwrócenie ste-
rowania mechanizmowi szeregującemu. Ponieważ w momencie wystąpienia przerwania kolejki
tego mechanizmu były niespójne, system najprawdopodobniej ulegnie awarii. Właśnie dlatego
nawet w środowiskach jednoprocesorowych najlepszym rozwiązaniem jest zapewnianie wielo-
bieżności większości składników systemu operacyjnego, ochrona najważniejszych struktur danych
za pomocą muteksów oraz wyłączanie obsługi przerwań w momentach szczególnie narażonych
na negatywne skutki ich obsługi.
Rozwiązania siłowe
Stosowanie przez lata rozwiązań siłowych dla problemów informatycznych zyskało złą sławę,
jednak często jest jedynym sposobem zachowania prostoty. Każdy system operacyjny imple-
mentuje wiele procedur, które są wywoływane wyjątkowo rzadko lub które operują na tak nie-
wielkiej ilości danych, że ich optymalizacja nie jest warta wymaganego czasu i nakładów. Sys-
temy muszą np. często przeszukiwać rozmaite tabele i tablice wewnętrzne. Algorytm siłowy
sprowadza się do kolejnego, liniowego przeszukania wpisów w tych tabelach i tablicach pod
kątem zawierania właściwego elementu. Jeśli liczba tych wpisów jest niewielka (np. nie prze-
kracza tysiąca), zysk wynikający z ich sortowania lub porządkowania według kodów byłby nie-
wielki, a niezbędny kod okazałby się nieporównanie bardziej złożony i bardziej narażony na błędy.
Z drugiej strony funkcje wchodzące w skład ścieżki krytycznej, np. odpowiedzialne za przełą-
czanie kontekstu, powinny być optymalizowane w taki sposób, aby realizowały wszystkie swoje
zadania możliwie efektywnie.
W ich przypadku warto nawet sięgać po (niech Bóg mi wybaczy) język asemblera. Warto
jednak pamiętać, że znaczne obszary systemu operacyjnego nie mieszczą się w ścieżce kry-
tycznej. Istnieje zwykle wiele wywołań systemowych, które są wykorzystywane przez aplikacje
i sam system wyjątkowo rzadko. Jeśli co sekundę jest używane wywołanie fork, którego wyko-
nanie zajmuje jedną milisekundę, nawet skrócenie tego czasu do zera oznaczałoby wzrost wydaj-
ności systemu o zaledwie 0,1%. Jeśli zoptymalizowany kod ma być większy i zawierać więcej
błędów, być może powinniśmy w ogóle zrezygnować z optymalizacji.
wystąpić wiele razy. Oznacza to, że ostatecznie większość lub wszystkie wpisy tablicy procesów
będą niedostępne, co z kolei spowoduje awarię systemu w sposób trudny do przewidzenia i jeszcze
trudniejszy do zdiagnozowania.
W wielu systemach podobne problemy przejawiają się w formie tzw. wycieków pamięci.
Programy często wywołują funkcję malloc, aby alokować niezbędną przestrzeń, ale „zapominają”
później zwalniać tę przestrzeń za pomocą funkcji free. Z czasem działanie tych programów pro-
wadzi do stopniowego wyczerpania całej pamięci — błąd można wyeliminować dopiero poprzez
ponowne uruchomienie systemu.
[Engler et al., 2000] zaproponowali interesujący sposób sprawdzania występowania niektó-
rych spośród tych błędów na etapie kompilacji. Zaobserwowali, że programista wie o wielu nie-
zmiennych aspektach, które nie są znane kompilatorowi — np. o tym, że od momentu zablo-
kowania muteksa wszystkie ścieżki wykonywania muszą zawierać wywołanie odblokowujące i nie
mogą zawierać wywołań blokujących ten sam muteks. Autorzy tej koncepcji opracowali nawet
sposób informowania kompilatora o tym fakcie i — tym samym — wymuszania na kompilatorze
weryfikacji kompilowanego kodu pod kątem naruszeń tej i innych zasad we wszystkich ścież-
kach. Programista może w ten sposób definiować wiele warunków poprawności kodu, w tym
konieczność zwalniania alokowanej pamięci we wszystkich ścieżkach.
12.4. WYDAJNOŚĆ
12.4.
WYDAJNOŚĆ
Jeśli wszystkie inne aspekty pozostają stałe, szybki system operacyjny jest lepszy od wolniej-
szego systemu operacyjnego. Z drugiej strony szybki, ale zawodny system operacyjny nigdy nie
będzie tak dobry jak system wolny, ale niezawodny. Ponieważ złożona optymalizacja często
prowadzi do błędów, należy stosować tego rodzaju techniki sporadycznie. Warto przy tej okazji
wspomnieć o istnieniu obszarów, w których wydajność jest na tyle istotna, że każda próba opty-
malizacji jest warta ponoszonych kosztów. W poniższych punktach skoncentrujemy się na kilku
ogólnych technikach podnoszących wydajność w miejscach, które tego najbardziej wymagają.
Listing 12.4. (a) Procedura zliczająca bity w bajcie; (b) makro zliczające bity w bajcie; (c) makro
zliczające bity poprzez przeszukiwanie tablicy
(a)
#define BYTE SIZE 8 /* Jeden bajt zawiera 8 bitów. */
int bit count(int byte)
{ /* Zlicza bity w bajcie. */
int i, count = 0;
for (i = 0; i < BYTE SIZE; i++) /* Przeszukuje w pętli bity bajta. */
if ((byte >> i) & 1) count++; /* Jeśli dany bit ma wartość 1, zwiększa licznik. */
return(count); /* Zwraca sumę. */
}
(b)
/* Makro sumujące bity w bajcie i zwracające otrzymany wynik. */
#define bit count(b) ((b&1) + ((b>>1)&1) + ((b>>2)&1) + ((b>>3)&1) + \
((b>>4)&1) + ((b>>5)&1) + ((b>>6)&1) + ((b>>7)&1))
(c)
/* Makro odnajdujące licznik bitów w tablicy. */
char bits[256] = {0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4, 1, 2, 2, 3, 2, 3, 3,
...};
#define bit count(b) (int) bits[b]
W przypadku procedury z listingu 12.4(a) mamy do czynienia z dwoma źródłami niskiej efek-
tywności. Po pierwsze procedura w tej formie musi zostać wywołana, co wiąże się z koniecz-
nością zaalokowania stosu na potrzeby jej kodu, oraz musi zwrócić sterowanie. Jak wiemy,
każde wywołanie procedury pociąga za sobą pewne koszty. Po drugie procedura zawiera pętlę,
a z wykonywaniem pętli zawsze wiążą się pewne opóźnienia.
Zupełnie innym rozwiązaniem jest zastosowanie makra z listingu 12.4(b). To wbudowane
wyrażenie wyznacza sumę bitów, poprzez stopniowe przesuwanie argumentu — operacje
przesunięcia pozwalają ukryć całą wartość z wyjątkiem najmniej znaczącego bitu, który jest
dodawany do wartości końcowej. Opracowanie tego rodzaju makra wymaga sporo inwencji, ale
jego zaletą jest jednokrotne występowanie w kodzie źródłowym. Jeśli np. użyjemy tego makra,
stosując wyrażenie w tej postaci:
sum = bit count(table[i]);
nasze wywołanie będzie wyglądało identycznie jak wywołanie odpowiedniej procedury. Oznacza
to, że oprócz dość nieczytelnej definicji nasz kod nie będzie wyglądał gorzej od pierwotnej wersji
z tradycyjną procedurą. Z drugiej strony wersja z makrem jest bardziej efektywna, ponieważ
eliminuje zarówno koszty związane z wywołaniem procedury, jak i opóźnienia wynikające z wyko-
nywania pętli.
Okazuje się, że można iść jeszcze krok dalej. Po co w ogóle mielibyśmy zliczać bity? Może
powinniśmy po prostu odnaleźć właściwą wartość w jakiejś tablicy. Istnieje przecież tylko 256
różnych bajtów, z których każdy może mieć przypisaną unikatową wartość z przedziału od 0 do 8
(reprezentującą liczbę ustawionych bitów). Możemy więc zadeklarować 256-elementową tablicę
bits, której wpisy (z licznikami ustawionych bitów) będą inicjalizowane już na etapie kompilacji.
Takie rozwiązanie eliminuje konieczność wykonywania jakichkolwiek obliczeń w czasie wyko-
nywania — wystarczy operacja indeksowania. Makro realizujące tę koncepcję pokazano na lis-
tingu 12.4(c).
Mamy w tym przypadku do czynienia z klasycznym przykładem przyspieszenia czasu wyko-
nywania operacji kosztem większego wykorzystania pamięci. Co ciekawe, można by iść jeszcze
dalej. Gdybyśmy zliczali bity dla całych 32-bitowych słów, makro bit_count musiałoby wykonywać
cztery operacje wyszukiwania na każde słowo. Gdybyśmy jednak rozszerzyli naszą tablicę do
65 536 wpisów, zmniejszylibyśmy liczbę operacji wyszukiwania do zaledwie dwóch na słowo
(kosztem przechowywania w pamięci dużo większej tablicy).
Technikę poszukiwania odpowiedzi w tablicach można stosować także na inne sposoby. W zna-
nej technice kompresji obrazów GIF wykorzystywana jest tabela odnośników do zakodowania
24-bitowych pikseli RGB. Jednak format obrazów GIF obsługuje tylko 256 lub mniej kolorów.
Dla każdego kompresowanego obrazu konstruuje się paletę 256 wpisów, z których każdy repre-
zentuje jedną 24-bitową wartość RGB. Dzięki temu skompresowany obraz składa się z 8-bitowego
indeksu dla każdego piksela (zamiast z 24-bitowej wartości koloru), co daje nam trzykrotny
współczynnik kompresji. Działanie tego mechanizmu (na przykładzie fragmentu 4×4 piksele)
zilustrowano na rysunku 12.4. Na rysunku 12.4(a) pokazano oryginalny obraz. Każda wartość
jest reprezentowana przez 24 bity, po 8 bitów reprezentujących odpowiednio intensywność
czerwieni, zieleni i koloru niebieskiego. Skompresowany obraz GIF pokazano na rysunku 12.4(b).
Tym razem każda wartość reprezentuje 8-bitowy indeks wpisu w palecie barw. Sama paleta kolo-
rów jest przechowywana w ramach pliku obrazu — pokazano ją na rysunku 12.4(c). W rzeczy-
wistości mechanizm kompresji obrazów w tym formacie okazuje się nieco bardziej skompliko-
wany, jednak podstawowym rozwiązaniem jest właśnie przeszukiwanie tablicy.
Rysunek 12.4. (a) Fragment nieskompresowanego obrazu z 24-bitowymi wartościami dla każdego
piksela; (b) ten sam fragment skompresowany metodą znaną z formatu GIF z 8 bitami na piksel;
(c) paleta kolorów
Istnieje jeszcze inny sposób ograniczania wielkości obrazów, który ilustruje nieco inny dyle-
mat projektantów. PostScript jest językiem programowania, którego można używać do opisywania
obrazów. (W praktyce wiele języków programowania może opisywać obrazy, jednak tylko język
PostScript zaprojektowano specjalnie z myślą o tego rodzaju zastosowaniach). Sporo drukarek
dysponuje wbudowanymi interpreterami PostScriptu, dzięki czemu mogą wykonywać otrzymy-
wane programy tego języka.
Jeśli np. obraz zawiera prostokątny blok pikseli tego samego koloru, odpowiedni program
języka PostScript zawierałby wyrażenia wyznaczające prostokąt w określonym miejscu i wypeł-
niające go właściwym kolorem. Reprezentowanie tego polecenia wymaga zaledwie kilku bitów.
Po otrzymaniu tak reprezentowanego obrazu drukarka korzysta z interpretera, który musi wyko-
nać program konstruujący dany obraz. Oznacza to, że język PostScript wykorzystywany w tym
trybie pozwala na znaczną kompresję danych kosztem dodatkowych obliczeń. Nie jest to problem
tożsamy z tym rozwiązywanym poprzez przeszukiwanie tablicy, jednak możliwość zastosowania
tego mechanizmu okazuje się wyjątkowo cenna w warunkach ograniczonej pamięci lub prze-
pustowości.
Z podobnymi dylematami mamy do czynienia także w przypadku struktur danych. Listy dwu-
kierunkowe zajmują więcej przestrzeni pamięciowej od list jednokierunkowych, ale często gwa-
rantują szybszy dostęp do elementów. Tablice asocjacyjne (ang. hash tables) zajmują jeszcze
więcej przestrzeni, ale też pozwalają na jeszcze szybszy dostęp. Krótko mówiąc, jednym z najważ-
niejszych aspektów, który należy mieć na uwadze podczas optymalizowania kodu, jest taki dobór
struktur danych, który najlepiej równoważy dążenia do ograniczania zużycia pamięci i skracania
czasu przetwarzania.
12.4.4. Buforowanie
Jedną z najbardziej popularnych technik podnoszenia wydajności jest buforowanie (ang. caching)
danych wynikowych. Można tę technikę stosować wszędzie tam, gdzie ten sam wynik jest potrzebny
wiele razy. Ogólna zasada polega na pełnej realizacji zadania za pierwszym razem oraz zapisa-
niu uzyskanego wyniku w pamięci podręcznej. W odpowiedzi na kolejne żądania sprawdza się
zawartość tej pamięci. Jeśli wynik żądanej operacji występuje w pamięci podręcznej, jest wyko-
rzystywany zamiast wykonywania tej operacji. W przeciwnym razie należy ponownie wykonać
daną operację.
Z techniką buforowania zapoznaliśmy się już przy okazji omawiania systemów plików, które
przechowują pewną liczbę ostatnio używanych bloków dyskowych, aby uniknąć konieczności
każdorazowego wykonywania dyskowej operacji odczytu. Technikę buforowania można jednak
stosować także w innych obszarach. Przykładowo analiza ścieżek do plików i katalogów okazuje
się wyjątkowo kosztowna. Wróćmy na chwilę do przykładu zaczerpniętego z systemu UNIX (patrz
rysunek 4.29). Odnalezienie pliku /usr/ast/mbox wymaga następujących operacji dostępu do dysku:
1. Odczytanie i-węzła dla katalogu głównego (i-węzła nr 1).
2. Odczytanie katalogu głównego (bloku nr 1).
3. Odczytanie i-węzła dla katalogu /usr (i-węzła nr 6).
4. Odczytanie katalogu /usr (bloku nr 132).
5. Odczytanie i-węzła dla katalogu /usr/ast (i-węzła nr 26).
6. Odczytanie katalogu /usr/ast (bloku nr 406).
Jak widać, samo odkrycie numeru i-węzła pliku /usr/ast/mbox wymaga aż sześciu operacji dostępu
do dysku. Na tym nie koniec — musimy jeszcze odczytać sam i-węzeł tego pliku, aby uzyskać
odpowiednie numery bloków dyskowych. Jeśli dany plik jest mniejszy od rozmiaru bloku
(tj. 1024 bajtów), odczytanie danych będzie wymagało łącznie ośmiu operacji dyskowych.
Niektóre systemy optymalizują proces analizy ścieżek, przechowując w pamięci podręcznej
użyte wcześniej kombinacje (ścieżka, i-węzeł). W scenariuszu pokazanym na rysunku 4.29 po
przeanalizowaniu ścieżki /usr/ast/mbox pamięć podręczna powinna zawierać pierwsze trzy wpisy
z tabeli 12.1. Trzy ostatnie wpisy widoczne w tej tabeli sporządzono przy okazji analizy innych
ścieżek.
Tabela 12.1. Fragment pamięci podręcznej i-węzłów dla scenariusza pokazanego na rysunku 4.29
Ścieżka Numer i-węzła
/usr 6
/usr/ast 26
/usr/ast/mbox 60
/usr/ast/books 92
/usr/bal 45
/usr/bal/paper.ps 85
Po otrzymaniu żądania analizy nowej ścieżki odpowiedni mechanizm sprawdza najpierw, czy
pamięć podręczna i-węzłów nie zawiera możliwie długiego podłańcucha tej ścieżki. Jeśli np.
użyjemy ścieżki /usr/ast/grants/stw, na podstawie zawartości pamięci podręcznej będzie można
błyskawicznie stwierdzić, że ścieżka /usr/ast jest reprezentowana przez i-węzeł nr 26 — jeśli
rozpoczniemy przeszukiwanie od tego i-węzła, oszczędzimy cztery operacje dostępu do dysku.
Jednym z problemów związanych z buforowaniem ścieżek do plików i katalogów jest to, że
odwzorowania łączące nazwy plików i numery i-węzłów nie mają stałego charakteru. Przypu-
śćmy np., że plik /usr/ast/mbox został usunięty z systemu, a jego i-węzeł został ponownie
użyty dla innego pliku należącego do innego użytkownika. Przyjmijmy, że niedługo potem ponow-
nie jest tworzony plik /usr/ast/ mbox, który tym razem otrzymuje i-węzeł numer 106. Jeśli nie
zastosujemy żadnych środków zaradczych, odpowiedni wpis w pamięci podręcznej będzie teraz
nieprawidłowy, zatem w odpowiedzi na kolejne żądania będzie zwracany niewłaściwy numer
i-węzła. Właśnie dlatego usunięcie pliku lub katalogu powinno skutkować usunięciem odpowied-
niej pary z pamięci podręcznej i-węzłów oraz (w przypadku katalogu) wszystkich wpisów znaj-
dujących się poniżej w strukturze katalogów.
Bloki dyskowe i ścieżki do plików to nie jedyne elementy wymagające buforowania. Bufo-
rować można także same i-węzły. Jeśli do obsługi przerwań wykorzystuje się specjalne wątki,
każdy z tych wątków wymaga stosu i pewnych dodatkowych mechanizmów. Okazuje się, że
raz wykorzystane wątki można buforować, ponieważ odnowienie istniejącego wątku jest prostsze
od utworzenia nowego wątku od podstaw (nie wymaga alokowania pamięci). Niemal wszystko,
co wymaga kosztownego tworzenia, może i powinno być buforowane.
12.4.5. Wskazówki
Wpisy w pamięci podręcznej zawsze muszą być prawidłowe. Próba odnalezienia wpisu w tej
pamięci co prawda może się zakończyć niepowodzeniem, ale jeśli już zostanie odnaleziony żądany
wpis, musimy mieć pewność co do jego poprawności i możliwości bezpiecznego użycia. W nie-
których systemach wygodnym rozwiązaniem jest stosowanie tablicy tzw. wskazówek (ang. hints).
Wpisy w tej tablicy pełnią funkcje sugerowanych rozwiązań, które nie dają gwarancji prawi-
dłowości. To strona wywołująca musi zweryfikować poprawność otrzymanego wyniku.
Popularnym przykładem wskazówek są adresy URL udostępniane na stronach internetowych.
Kliknięcie takiego łącza nie gwarantuje trafienia na właściwą stronę WWW. W rzeczywistości
strona wskazywana przez to łącze może nie istnieć np. od 10 lat. Oznacza to, że informacja o wska-
zywanej stronie jest właśnie wskazówką (w myśl powyższej definicji).
Wskazówki wykorzystuje się także w mechanizmach łączenia się ze zdalnymi plikami. Infor-
macja zawarta we wskazówce mówi nam coś o zdalnym pliku, np. określa jego położenie. Nie
oznacza to jednak, że danego pliku nie przeniesiono ani nie usunięto od czasu ostatniego zareje-
strowania wskazówki, zatem każde użycie tej wskazówki wymaga weryfikacji jej poprawności.
oznaczyć jako zbiór roboczy tego procesu. W takim przypadku system operacyjny powinien dbać
o to, by w momencie przejścia tego procesu w stan gotowości cały jego zbiór roboczy znajdował
się w pamięci — w ten sposób można znacznie ograniczyć liczbę błędów braku stron.
Zasada lokalności obowiązuje także w świecie plików. Kiedy proces wskazuje konkretny
katalog roboczy, można przyjąć, że znaczna część jego przyszłych odwołań do plików będzie
dotyczyła zawartości tego katalogu. W tej sytuacji rozmieszczenie na dysku wszystkich i-węzłów
i plików tego katalogu możliwie blisko siebie powinno podnieść wydajność. Właśnie tą zasadą
kierowali się projektanci systemu plików Berkeley Fast File System [McKusick et al., 1984].
Innym obszarem, w którym lokalność odgrywa istotną rolę, jest szeregowanie wątków
w środowisku wieloprocesorowym. Jak wiemy z rozdziału 8., jednym ze sposobów szeregowania
wątków w takim środowisku jest podejmowanie prób wykonywania wątków na tych samych pro-
cesorach, z których ostatnio korzystały. Takie rozwiązanie zwiększa prawdopodobieństwo wystę-
powania przynajmniej części bloków pamięci tych wątków w pamięci podręcznej procesorów.
W latach siedemdziesiątych ubiegłego wieku Harlan Mills połączył teorię o przewadze jednych
programistów nad innymi z koncepcją spójności architekturalnej i zaproponował paradygmat
zespołu z głównym programistą (ang. chief programmer team) [Baker, 1972]. Koncepcja Millsa
polega na maksymalnym upodobnieniu struktury zespołów programistycznych do zespołów
operacyjnych (zamiast do zespołów rzeźnickich). Aby wyeliminować chaos panujący w typowych
zespołach programistycznych, Harlan Mills zaproponował, by tylko jedna osoba dysponowała
odpowiednikiem skalpela. Zadaniem pozostałych członków zespołu jest wspieranie głównego
programisty. Na potrzeby projektów realizowanych przez dziesięć osób Mills zaproponował
strukturę opisaną w tabeli 12.2.
Minęły trzy dekady, zanim opisane powyżej propozycje doczekały się wykorzystania w praw-
dziwym środowisku produkcyjnym. Pewne aspekty uległy co prawda zmianie (zatrudnianie spe-
cjalistów ds. języków programowania nie jest już takie ważne, ponieważ język C jest nieporówna-
nie prostszy od języka PL/I), jednak ogólna koncepcja pojedynczej osoby kontrolującej cały
projekt pozostaje w mocy. Ta jedna osoba powinna mieć możliwość koncentrowania się wyłącznie
na projektowaniu i programowaniu — stąd konieczność współpracy zespołu wspierającego (choć
oczywiście współczesne narzędzia pozwalają znacznie ograniczyć rozmiar tego zespołu). Ogólna
idea Millsa jest jednak wciąż aktualna.
Organizacja każdego dużego zespołu projektowego musi mieć charakter hierarchiczny. Na
najniższym poziomie mamy do czynienia z małymi zespołami kierowanymi przez głównych pro-
gramistów. Następny poziom tworzą grupy zespołów koordynowane przez menedżera. Doświad-
czenie pokazuje, że każda jednostka zarządzana przez menedżera zajmuje 10% jego czasu, zatem
w pełni obciążony menedżer powinien koordynować prace dziesięciu zespołów. Menedżerowie
także mają swoich kierowników itp.
Brooks odkrył, że złe wieści nie są zbyt sprawnie przekazywane w górę drzewa. Jerry Saltzer
z MIT określił to zjawisko mianem lampki złych wiadomości (ang. bad-news diode). Żaden główny
programista ani menedżer nie jest skłonny informować swojego przełożonego np. o 4-miesięcznym
opóźnieniu projektu i o braku możliwości realizacji zadania w założonym terminie. Wszystkiemu
winna jest 2000-letnia tradycja ścinania posłańców złych wieści. Właśnie dlatego kierownictwo
organizacji zwykle nie ma pojęcia o prawdziwym stanie projektu. A kiedy już nawet dociera do
nich informacja o opóźnieniach, reagują zwiększeniem liczebności zespołu projektowego, co
z kolei prowadzi do skutków wskazanych w prawie Brooksa.
W praktyce największe firmy, które mają już dość duże doświadczenie w wytwarzaniu opro-
gramowania i doskonale znają skutki nieprzemyślanych strategii realizacji projektów, przynajm-
niej próbują wypracowywać coraz doskonalsze modele tworzenia produktów. Dla odmiany mniej-
sze, mniej doświadczone przedsiębiorstwa, które nie mogą się doczekać debiutu rynkowego,
często ignorują zasady prawidłowej realizacji projektów. Pośpiech zwykle powoduje, że osiągane
wyniki są dalekie od optymalnych.
Ani Brooks, ani Mills nie przewidział wzrostu znaczenia i popularności produktów typu open
source. Pomimo wielu wątpliwości (wyrażanych zwłaszcza przez przedstawicieli dużych firm
produkujących oprogramowanie o zamkniętym dostępie do kodu źródłowego) oprogramowanie
open source odniosło ogromny sukces. Oprogramowanie open source jest wszędzie — od dużych
serwerów po urządzenia wbudowane i od przemysłowych systemów sterowania po podręczne
smartfony. Obecnie duże firmy, takie jak Google i IBM, wykorzystują możliwości systemu Linux
i aktywnie współuczestniczą w tworzeniu jego kodu. Warto przy tej okazji wspomnieć, że więk-
szość udanych projektów open source była realizowana pod kierownictwem głównego progra-
misty, zgodnie z modelem jednej osoby kontrolującej projekt architektury (tak było w przypadku
jądra systemu Linux tworzonego pod kierownictwem Linusa Torvaldsa oraz kompilatora GNU C
tworzonego pod kierownictwem Richarda Stallmana).
Rysunek 12.5. (a) Tradycyjne etapy projektowania oprogramowania; (b) alternatywny model pracy
polegający na tworzeniu od pierwszego dnia działającego (choć bezwartościowego) systemu
operacyjnego
W efekcie druga wersja systemu okazuje się przepełniona funkcjami i działa nieporównanie
wolniej od pierwszej. Za trzecim razem zespół jest już otrzeźwiony wskutek niepowodzenia
drugiej wersji i ponownie wykazuje więcej ostrożności.
Dobrym przykładem ilustrującym to zjawisko jest para CTSS-MULTICS. CTSS był pierw-
szym uniwersalnym systemem z podziałem czasu i okazał się ogromnym sukcesem, mimo mini-
malnej funkcjonalności. Jego następca, system MULTICS, okazał się projektem zbyt ambitnym,
co skończyło się dla niego fatalnie. Chociaż pomysły projektantów tego systemu były dość inte-
resujące, liczba nowych rozwiązań okazała się po prostu zbyt długa, co znacznie obniżyło wydaj-
ność systemu i przekreśliło jego szanse na sukces rynkowy. Trzeci system, nazwany UNIX, był
już tworzony dużo ostrożniej i zyskał nieporównanie więcej uznania wśród użytkowników.
W 1899 roku szef Biura Patentowego Stanów Zjednoczonych Charles H. Duell zasugerował pre-
zydentowi McKinleyowi zamknięcie tego biura (pozbawienie go pracy!), uzasadniając ten wniosek
twierdzeniem: „Wszystko, co można było wynaleźć, zostało już wynalezione” [Cerf and Navasky,
1984]. Zaledwie kilka lat później Thomas Edison wykazał, jak dalece błędne były przewidywania
Duella — udoskonalił żarówkę, wynalazł fonograf i projektor filmowy. Chodzi o to, że świat ciągle
się zmienia, a systemy operacyjne muszą przez cały czas dostosowywać się do nowej rzeczy-
wistości. W tym podrozdziale wymienimy kilka trendów, które są istotne z punktu widzenia współ-
czesnych projektantów systemów operacyjnych.
Aby uniknąć nieporozumień — platformy sprzętowe wymienione poniżej już są dostępne.
Nie ma jedynie oprogramowania systemu operacyjnego, który pozwoliłby skutecznie je wyko-
rzystywać.
Ogólnie rzecz biorąc, gdy pojawi się nowy sprzęt, zazwyczaj jest na nim uruchamiane stare
oprogramowanie (Windows, Linux itp.). W dłuższej perspektywie to zły pomysł. Do obsługi
nowoczesnego sprzętu potrzebujemy nowoczesnego oprogramowania. Jeśli jesteś studentem
informatyki lub inżynierii oprogramowania albo profesjonalistą w tych dziedzinach, Twoją pracą
domową powinno być opracowanie takiego oprogramowania.
Oczywiste pytanie brzmi: co będą robić wszystkie te rdzenie? W przypadku serwera obsłu-
gującego wiele tysięcy żądań klientów na sekundę odpowiedź może być stosunkowo prosta.
Możemy np. zdecydować o przydzieleniu dedykowanego rdzenia do każdego żądania. Przy założe-
niu, że nie wystąpią zbyt wielkie problemy z blokowaniem, takie rozwiązanie może się spraw-
dzić. Ale co mamy zrobić z wszystkimi tymi rdzeniami na tabletach?
Można również sformułować inne pytanie: jakiego rodzaju rdzeni potrzebujemy? Rdzenie
superskalarne o głębokich potokach z wyrafinowanymi mechanizmami wykonywania nie po kolei
(ang. out-of-order execution) oraz wykonywania spekulatywnego (ang. speculative execution)
przy wysokich częstotliwościach zegara mogą się sprawdzać dla kodu sekwencyjnego, ale nie
będą już tak łaskawe dla naszych rachunków za energię. Takie układy nie przydadzą się nam
również zbytnio, jeśli zadanie w dużym stopniu wymaga przetwarzania równoległego. Wiele
aplikacji działa lepiej, jeśli rdzenie są mniejsze i prostsze, ale jest ich więcej. Niektórzy eksperci są
zwolennikami heterogenicznych układów wielordzeniowych, ale pytania pozostają takie same:
jakie rdzenie, ile i z jaką szybkością mają działać? A nie wspomnieliśmy nawet o problemach doty-
czących działania systemu operacyjnego i wszystkich jego aplikacji. Czy system operacyjny będzie
działał na wszystkich rdzeniach, czy tylko na niektórych? Czy będzie wykorzystywany jeden
stos sieciowy, czy więcej? W jakim stopniu będzie potrzebne współdzielenie? Czy określone
rdzenie powinny być przeznaczone do specyficznych funkcji systemu operacyjnego (np. stosu
sieci lub pamięci trwałej)? Jeśli tak, to czy takie funkcje powinny być replikowane w celu zapew-
nienia lepszej skalowalności?
Poznając różne kierunki rozwoju, badacze zajmujący się systemami operacyjnymi starają
się obecnie sformułować odpowiedzi na te pytania. Podczas gdy naukowcy mogą się nie zgadzać
co do niektórych odpowiedzi, większość z nich potwierdza jedną rzecz: obecne czasy są ekscy-
tujące dla badań nad systemami!
tów, a nawet smartfonów. W związku z kontynuacją tego trendu ich systemy operacyjne siłą
rzeczy muszą różnić się od bieżących systemów, aby spełnić rosnące oczekiwania użytkowników.
Ponadto muszą zarządzać budżetem mocy po to, by zachować „zimną krew”. Odprowadzanie
ciepła i zarządzanie zużyciem energii to tylko niektóre z najważniejszych wyzwań, dotyczących
nawet komputerów wysokiej klasy.
Z drugiej strony jeszcze szybciej rosnącą częścią rynku komputerów osobistych jest seg-
ment komputerów zasilanych bateriami, w tym notebooków, tabletów, laptopów w cenie do
100 dolarów oraz smartfonów. Większość z tych urządzeń dysponuje połączeniami bezprzewo-
dowymi ze światem zewnętrznym. Wymienione urządzenia potrzebują systemów operacyjnych,
które są mniejsze, szybsze, bardziej elastyczne i niezawodne od systemów operacyjnych kom-
puterów tradycyjnych. Wiele z tych urządzeń bazuje dziś na tradycyjnych systemach operacyj-
nych, takich jak Linux, Windows i OS X, ale ze znaczącymi modyfikacjami. Ponadto często
używają rozwiązań bazujących na mikrojądrze (hipernadzorcy) w celu zarządzania stosem połą-
czeń radiowych.
Te systemy operacyjne muszą lepiej niż obecne systemy obsługiwać operacje w pełnym
połączeniu (czyli przewodowe), słabym połączeniu (czyli bezprzewodowe) i bez połączenia. Obej-
muje to m.in. takie zadania jak gromadzenie danych przed przejściem do trybu offline oraz roz-
wiązywanie problemów spójności po ponownym wejściu do trybu online. Nowe systemy muszą
też dużo lepiej od współczesnych rozwiązań radzić sobie z problemami mobilności, jak odnaj-
dywanie drukarki laserowej, logowanie się czy bezprzewodowe wysyłanie plików do wydru-
kowania. Kluczem do sukcesu tych systemów będzie też prawidłowe zarządzanie zasilaniem,
w tym efektywne mechanizmy przekazywania pomiędzy systemem operacyjnym a aplikacjami
informacji o pozostałym czasie pracy na bateriach i optymalnym sposobie korzystania z dostępnej
energii. Duże znaczenie będzie miała także zdolność dostosowywania się aplikacji do takich
ograniczeń jak miniaturowe ekrany. I wreszcie nowe tryby wejścia i wyjścia, w tym rozpozna-
wanie pisma i mowy, może wymagać od systemu operacyjnego lepszej obsługi nowych technik.
Istnieje prawdopodobieństwo, że system operacyjny dla zasilanego bateriami, podręcznego, bez-
przewodowego i sterowanego głosem komputera będzie znacząco różny od systemu projektowa-
nego z myślą o 64-bitowym, 16-rdzeniowym komputerze biurkowym dysponującym gigabito-
wym, światłowodowym połączeniem sieciowym. Nietrudno sobie również wyobrazić istnienie
rozmaitych rozwiązań hybrydowych, które także będą miały swoje unikatowe wymagania.
aplikacji mogą to być systemy lekkie lub ciężkie — jedynym wymaganiem jest zachowanie nale-
żytej spójności. Ponieważ systemy wbudowane będą produkowane w setkach milionów, mówimy
o naprawdę ogromnym rynku dla nowych systemów operacyjnych.
12.7. PODSUMOWANIE
12.7.
PODSUMOWANIE
Projektowanie systemu operacyjnego rozpoczyna się od określenia jego zadań. Interfejs tworzo-
nego systemu powinien być prosty, kompletny i efektywny. Projektant powinien postępować
zgodnie z zaleceniami paradygmatu interfejsu, paradygmatu wykonywania i paradygmatu danych.
Struktura systemu powinna być przemyślana — warto wybrać którąś z doskonale znanych
i udokumentowanych technik, jak struktura wielowarstwowa lub architektura klient-serwer.
Komponenty wewnętrzne systemu powinny być ortogonalne i ściśle oddzielać strategię od
mechanizmu. Projektant jest zobowiązany dokładnie przemyśleć takie aspekty jak zakres sto-
sowania statycznych i dynamicznych struktur danych, nazewnictwo, czas kojarzenia czy kolej-
ność implementowania modułów.
Wydajność jest oczywiście bardzo ważna, jednak decyzje o optymalizacji należy podejmować
ostrożnie, aby przypadkowo nie uszkodzić struktury systemu. Warto poświęcić trochę czasu na
rozstrzygnięcie dylematu przestrzeń-czas oraz rozważyć zastosowanie takich technik jak bufo-
rowanie, wskazówki, wykorzystanie efektu lokalności oraz optymalizacja typowego przypadku.
Pisanie systemu w kilkuosobowym zespole różni się od pracy nad wielkim systemem ope-
racyjnym w gronie 300 osób. W drugim przypadku kluczem do sukcesu projektu jest właściwa
struktura zespołu oraz sprawne zarządzanie pracami.
I wreszcie w nadchodzących latach systemy operacyjne muszą ewoluować w odpowiedzi na
pojawiające się nowe trendy i wyzwania. Źródłem tych wyzwań są środowiska obejmujące sprzę-
towe monitory maszyn wirtualnych, systemy wielordzeniowe, 64-bitowe przestrzenie adresowe,
podręczne komputery bezprzewodowe i systemy wbudowane. Najbliższe lata będą więc wyjąt-
kowo ciekawym czasem dla projektantów systemów operacyjnych.
PYTANIA
1. Prawo Moore’a mówi o fenomenie wykładniczego wzrostu podobnego do tego, który można
zaobserwować w populacji niektórych gatunków zwierząt przeniesionych z dużą ilością
pożywienia do nowego środowiska, w którym nie występują naturalni wrogowie tych
gatunków. W środowisku naturalnym ten wykładniczy wzrost ostatecznie przyjmuje postać
krzywej sigmoidalnej z prostą asymptotyczną wyznaczającą limit pożywienia lub opanowa-
nie przez lokalnych drapieżników sztuki polowania na nowe ofiary. Opisz czynniki, które
ostatecznie ograniczają tempo doskonalenia sprzętu komputerowego.
2. Na listingu 12.1 pokazano dwa paradygmaty: algorytmiczny i zdarzeniowy. Dla każdego
z wymienionych poniżej rodzajów programów spróbuj określić, które paradygmaty będą
właściwsze (łatwiejsze do zastosowania):
(a) kompilator,
(b) program do edycji fotografii,
(c) program płacowy.
3. Hierarchiczne nazwy plików zawsze zaczynają się od górnej części drzewa. Dla przy-
kładu plik nosi nazwę /usr/ast/books/mos2/chap-12, a nie chap-12/mos2/books/ast/usr. Dla
odróżnienia nazwy DNS zaczynają się od dołu drzewa i są budowane w górę. Czy istnieje
jakiś zasadniczy powód tej różnicy?
4. Corbató zasugerował, że system powinien oferować możliwie minimalny mechanizm.
Poniżej zamieszczono listę wywołań systemowych standardu POSIX, które były dostępne
także w systemie UNIX Version 7. Które z tych wywołań są nadmiarowe, czyli takie, które
można usunąć bez ryzyka utraty niezbędnych funkcji, tj. mogą być zastąpione przez kom-
binacje innych wywołań systemowych realizujących te same zadania z podobną efektyw-
nością? Oto wspomniane wywołania: access, alarm, chdir, chmod, chown, chroot, close,
creat, dup, exec, exit, fcntl, fork, fstat, ioctl, kill, link, lseek, mkdir, mknod, open,
pause, pipe, read, stat, time, times, umask, unlink, utime, wait i write.
5. Załóżmy, że warstwy 3. i 4. z rysunku 12.1 zamieniono miejscami. Jakie konsekwencje
miałoby to dla projektu systemu?
6. W systemie klient-serwer z mikrojądrem zadaniem tego mikrojądra jest tylko przeka-
zywanie komunikatów. Czy mimo to procesy użytkownika mogą tworzyć semafory i ich
używać? Jeśli tak, jak to możliwe? Jeśli nie, dlaczego?
7. Ostrożna optymalizacja może podnieść wydajność wywołań systemowych. Wyobraź sobie
sytuację, w której pewne wywołanie systemowe jest stosowane co 10 ms. Średni czas
realizacji tego wywołania zajmuje 2 ms. Gdyby udało się dwukrotnie skrócić czas wykony-
wania tego wywołania, ile czasu zajmowałoby wykonywanie procesu, który do tej pory
realizował swoje zadania w 10 s?
8. Systemy operacyjne często stosują dwa poziomy nazewnictwa: wewnętrzny i zewnętrzny.
Jakie są różnice dzielące oba poziomy nazw w następujących aspektach:
(a) długości,
(b) unikatowości,
(c) hierarchii?
9. Jednym ze sposobów obsługi tablic, których rozmiar nie jest znany z góry, jest stoso-
wanie struktur stałych rozmiarów i — w razie wypełnienia — zastępowanie ich więk-
szymi strukturami (po skopiowaniu wszystkich wpisów oryginalna, mniejsza struktura
zostaje zwolniona). Jakie są zalety i wady konstruowania dwukrotnie większej tablicy
w porównaniu z tablicą większą o zaledwie 50% od poprzedniej wypełnionej struktury?
10. Na listingu 12.2 użyto flagi found do określenia, czy udało się zlokalizować poszukiwany
identyfikator PID. Czy można by zrezygnować z flagi found i ograniczyć się do spraw-
dzania wartości zmiennej p na końcu pętli for, aby określić, czy osiągnięto koniec prze-
szukiwanej struktury?
11. W kodzie z listingu 12.3 udało się ukryć różnice dzielące procesory Pentium i UltraSPARC
dzięki zastosowaniu kompilacji warunkowej. Czy to samo rozwiązanie można by wyko-
rzystać do ukrywania różnic pomiędzy komputerami x86 wyposażonymi wyłącznie w dyski
IDE a komputerami x86 wyposażonymi wyłącznie w dyski SCSI? Czy takie rozwiązanie
byłoby właściwe?
12. Pośrednictwo jest jednym ze sposobów podnoszenia elastyczności algorytmów. Czy
pośrednictwo ma jakieś wady? Jeśli tak, jakie?
13. Czy procedury wielobieżne mogą dysponować prywatnymi, statycznymi zmiennymi glo-
balnymi? Uzasadnij swoją odpowiedź.
14. Makro pokazane na listingu 12.4(b) jest bez wątpienia bardziej efektywne od procedury
z listingu 12.4(a). Jego wadą jest jednak spora nieczytelność. Czy zaproponowane makro
ma też inne wady? Jeśli tak, jakie?
15. Przypuśćmy, że musimy znaleźć sposób określenia, czy liczba bitów w 32-bitowym słowie
jest parzysta, czy nieparzysta. Spróbuj opracować algorytm możliwie szybko wykonujący
niezbędne obliczenia.
W razie potrzeby możesz użyć maksymalnie 256 kB pamięci RAM dla wykorzysty-
wanych tablic. Spróbuj napisać makro implementujące Twój algorytm. Zadanie dodat-
kowe: napisz procedurę wyznaczającą wynik poprzez przeszukiwanie w pętli kolejnych
32 bitów. Zmierz, ile razy szybsze jest Twoje makro od tej procedury.
16. Na rysunku 12.4 pokazano, jak w plikach w formacie GIF wykorzystuje się wartości
8-bitowe do indeksowania palety kolorów. To samo rozwiązanie można by zastosować
dla 16-bitowej palety kolorów. W jakich okolicznościach — jeśli potrafisz takie wskazać
— miałoby sens stosowanie 24-bitowej palety kolorów?
17. Jedną z wad formatu GIF jest konieczność dołączania do plików graficznych palety kolo-
rów, która z natury rzeczy zwiększa ich rozmiar. Jaki jest minimalny rozmiar obrazu, dla
którego stosowanie takiej 8-bitowej palety kolorów staje się opłacalne? Odpowiedz na to
samo pytanie także w kontekście 16-bitowej palety kolorów.
18. W rozdziale pokazano, jak przechowywanie ścieżek do plików w pamięci podręcznej może
istotnie przyspieszyć interpretację tych ścieżek. Inną ciekawą techniką jest korzystanie
z programu demona otwierającego wszystkie pliki w katalogu głównym i stale utrzymują-
cego ten stan tylko po to, by wymusić ciągłe utrzymywanie odpowiednich i-węzłów
w pamięci. Czy takie rozwiązanie może być bardziej efektywne od buforowania ścieżek?
19. Nawet jeśli zdalny plik nie został usunięty od czasu ostatniego zarejestrowania wska-
zówki, niewykluczone, że od ostatniego odwołania został zmieniony. Jakie inne informacje
warto więc rejestrować we wskazówkach?
20. Wyobraźmy sobie system gromadzący odwołania do plików zdalnych w formie wskazó-
wek, np. w postaci trójek (nazwa, zdalny komputer, zdalna nazwa). Przyjmijmy, że ist-
nieje możliwość usunięcia i zastąpienia zdalnego pliku bez naszej wiedzy. Oznacza to,
że wskazówka może prowadzić do niewłaściwego pliku. Jak sprawić, by prawdopodo-
bieństwo takich zdarzeń było mniejsze?
21. W tekście tego rozdziału stwierdzono, że w wielu przypadkach wydajność można pod-
nieść, jeśli korzysta się z efektu lokalności. Wyobraźmy sobie jednak sytuację, w której
jakiś program odczytuje dane wejściowe jakiegoś źródła i stale generuje dane wynikowe
zapisywane w dwóch plikach lub większej ich liczbie. Czy w takim przypadku próba
wykorzystania efektu lokalności na poziomie systemu plików może doprowadzić do
spadku efektywności? Czy można ten problem jakość obejść?
22. Fred Brooks stwierdził, że programista może napisać 1000 wierszy debugowanego kodu
rocznie. Okazuje się jednak, że pierwsza wersja systemu MINIX (licząca 13 000 wierszy
kodu) została stworzona przez jednego programistę w mniej niż trzy lata. Jak wyjaśnisz
tę rozbieżność?
23. Przyjmij, że twierdzenie Brooksa o tysiącu wierszy pisanych przez jednego programistę
rocznie sprawdza się w przypadku firmy Microsoft. Spróbuj oszacować koszt wyprodu-
kowania systemu Windows 8. Załóż, że koszt rocznego utrzymania programisty wynosi
100 000 dolarów (z uwzględnieniem takich obciążeń dodatkowych jak komputery, prze-
strzeń biurowa, wsparcie sekretariatu i wynagrodzenie jego kierownictwa). Czy Twoim
zdaniem otrzymana wartość jest wiarygodna? Jeśli nie, co może być przyczyną błędnych
wyliczeń?
24. Stały spadek cen pamięci operacyjnej powoduje, że coraz łatwiej można sobie wyobrazić
komputer z wielką, zasilaną bateryjnie pamięcią RAM zamiast tradycyjnego dysku
twardego. Ile kosztowałby dzisiaj tani komputer PC wyposażony wyłącznie w taką pamięć?
Przyjmijmy, że RAMdysk o pojemności 100 GB jest wystarczający dla maszyny „z dolnej
półki”. Czy taki komputer stanowiłby poważną konkurencję dla współczesnych maszyn?
25. Wskaż funkcje konwencjonalnego systemu operacyjnego, które nie są potrzebne w syste-
mach wbudowanych stosowanych w typowych urządzeniach.
26. Napisz w języku C procedurę wykonującą operacje dodawania dwóch parametrów wej-
ściowych z podwójną precyzją. Użyj wyrażeń kompilacji warunkowej w taki sposób, aby
Twoja procedura działała zarówno na komputerach 16-bitowych, jak i na komputerach
32-bitowych.
27. Napisz program zapisujący losowo generowane, krótkie łańcuchy w tablicy, po czym
przeszukujący tę tablicę pod kątem zawierania określonego łańcucha, z zastosowaniem
(a) prostego przeszukiwania liniowego (rozwiązanie siłowe) oraz (b) dowolnej, wybranej
przez Ciebie, bardziej zaawansowanej metody. Skompiluj swój program dla tablic różnych
rozmiarów — od bardzo małych do największych obsługiwanych przez Twój system —
i porównaj wyniki osiągnięte przez oba algorytmy. Potrafisz wskazać punkt, od którego
zastosowana optymalizacja ma sens?
28. Napisz program symulujący działanie systemu plików w pamięci głównej.
1031
1
Polskie wydanie: Programowanie w środowisku systemu UNIX, Wydawnictwa Naukowo-Techniczne,
2002 — przyp. tłum.
Zhuravlev et al., Survey of Scheduling Techniques for Addressing Shared Resources in Multicore
Processors
Systemy wielordzeniowe zaczęły dominować w ogólnej branży komputerów. Jednym z naj-
ważniejszych wyzwań jest rywalizacja o współdzielone zasoby. Autorzy artykułu prezentują różne
techniki szeregowania niezbędne do obsługi tego rodzaju rywalizacji.
Silberschatz et al., Operating System Concepts with Java, 7th ed.
W rozdziałach 3. – 6. omówiono procesy i komunikację międzyprocesową, w tym takie zagad-
nienia jak szeregowanie, sekcje krytyczne, semafory, monitory oraz klasyczne problemy komuni-
kacji międzyprocesowej.
Stratton et al., Algorithm and Data Optimization Techniques for Scaling to Massively Threaded
Systems
Programowanie systemu obejmującego pół tuzina wątków jest dość trudne. Aż strach pomy-
śleć, jak trudne może być programowanie wielu tysięcy wątków! Delikatnie mówiąc, to bardzo
trudne. W tym artykule omówiono sposoby postępowania z takimi systemami.
2
Polskie wydanie: Sztuka programowania, t. 1, Wydawnictwa Naukowo-Techniczne, 2002 — przyp. tłum.
13.1.5. Wejście-wyjście
Geist i Daniel, A Continuum of Disk Scheduling Algorithms
W artykule opisano ogólne działanie algorytmu szeregującego ruchy głowicy dysku twardego.
Można tam znaleźć także rozbudowaną symulację i wyniki przeprowadzonych eksperymentów.
Scheible, A Survey of Storage Options
Istnieje obecnie wiele sposobów przechowywania bitów, jak choćby w pamięciach DRAM,
SRAM, SDRAM, flash, a także na dysku twardym, dyskietkach, płytach CD-ROM, płytach DVD
czy taśmach magnetycznych. W artykule dokonano przeglądu najróżniejszych technologii ze szcze-
gólnym uwzględnieniem ich mocnych i słabych punktów.
Stan i Skadron, Power-Aware Computing
Dopóki ktoś nie znajdzie sposobu zastosowania prawa Moore’a dla baterii, zużycie energii
będzie stanowiło poważny problem dla urządzeń mobilnych. W niedalekiej przyszłości być może
będziemy mieli okazję obserwować systemy operacyjne reagujące na zmiany temperatury. W tym
artykule przeanalizowano wybrane problemy — tekst jest wprowadzeniem do pięciu innych,
bardziej specjalistycznych artykułów poświęconych konkretnym problemom w związku z zarzą-
dzaniem energią.
Swanson i Caulfield, Refactor, Reduce, Recycle: Restructuring the I/O Stack for the Future of Storage
Dyski istnieją z dwóch powodów: po wyłączeniu zasilania pamięć RAM traci swoją zawar-
tość; oprócz tego dyski mają dużą pojemność. Ale przypuśćmy, że po wyłączeniu zasilania pamięć
RAM nie traci swojej zawartości. W jaki sposób wpłynęłoby to na zmiany w stosie wejścia-wyjścia?
W tym artykule omówiono, w jaki sposób zastosowanie nieulotnej pamięci RAM wpłynie na pro-
jektowanie systemów.
Ion, From Touch Displays to the Surface: A Brief History of Touchscreen Technology
Ekrany dotykowe w ciągu krótkiego czasu stały się wszechobecne. W tym artykule zapre-
zentowano historię ekranów dotykowych zilustrowaną czytelnymi objaśnieniami oraz intere-
sującymi archiwalnymi zdjęciami i filmami. Fascynujący materiał!
Walker i Cragon, Interrupt Processing in Concurrent Processors
Implementacja precyzyjnych przerwań na komputerach superskalarnych jest sporym wyzwa-
niem. Największy problem to możliwie szybka konwersja przerwań na odpowiednie stany.
W artykule omówiono szereg problemów projektowych i dylematów związanych z tym aspektem
działania systemów komputerowych.
13.1.6. Zakleszczenia
Ahmad, Gigantic Clusters: Where Are They and What Are They Doing?
Ten artykuł jest doskonałym źródłem wiedzy o sposobie funkcjonowania i strukturze wiel-
kich środowisk wielokomputerowych. Autor dokonał przeglądu koncepcji stojących za kilkoma
aktualnie działającymi wielkimi systemami. Zgodnie z prawem Moore’a można bezpiecznie zało-
żyć, że mniej więcej co dwa lata rozmiary tych systemów będą podwajane.
Dubois et al., Synchronization, Coherence, and Event Ordering in Multiprocessors
Ten artykuł wprowadza Czytelnika w świat synchronizacji działań w systemach wielopro-
cesorowych ze wspólną pamięcią. Okazuje się jednak, że część opisanych tam rozwiązań znaj-
duje zastosowanie także w środowiskach jednoprocesorowych oraz systemach z pamięcią
rozproszoną.
Geer, For Programmers, Multicore Chips Mean Multiple Challenges
Rosnąca popularność układów wielordzeniowych jest faktem niezależnie od tego, czy progra-
miści są na to gotowi, czy nie. Praktyka pokazuje, że wspomniana gotowość programistów jest
raczej wątpliwa. Co gorsza, programowanie tego rodzaju układów rodzi szereg wyzwań —
wymaga opracowania właściwych narzędzi, podzielenia pracy na niewielkie podzadania oraz odpo-
wiedniego testowania wyników.
Kant i Mohapatra, Internet Data Centers
Internetowe centra danych to w istocie przerośnięte środowiska wielokomputerowe. Zwykle
obejmują dziesiątki lub setki tysięcy komputerów, na których działa zaledwie jedna aplikacja.
Dla tego rodzaju środowisk zdecydowanie najważniejsza jest skalowalność, możliwość łatwej
konserwacji oraz zużycie energii. Artykuł prezentuje te zagadnienia i stanowi swoiste wpro-
wadzenie do kolejnych czterech artykułów na ten temat.
Kumar et al., Heterogeneous Chip Multiprocessors
Układy wielordzeniowe instalowane w komputerach biurkowych mają charakter syme-
tryczny — wszystkie rdzenie są identyczne. Okazuje się jednak, że w pewnych zastosowaniach
dużo bardziej popularne są heterogeniczne architektury CMP z wyspecjalizowanymi rdzeniami
dla przetwarzania danych, dekodowania obrazu wideo, dekodowania dźwięku itp. W artykule
opisano zagadnienia związane z funkcjonowaniem takich architektur.
Kwok i Ahmad, Static Scheduling Algorithms for Allocating Directed Task Graphs to Multiprocessors
Optymalne szeregowanie zadań w środowiskach wielokomputerowych i wieloprocesoro-
wych jest możliwe tylko wtedy, gdy podstawowe cechy tych zadań są znane z wyprzedzeniem.
Problem w tym, że optymalne szeregowanie zadań jest na tyle kosztowne obliczeniowo, że nie
może być realizowane w czasie rzeczywistym. W artykule autorzy omówili i porównali 27 zna-
nych algorytmów szeregowania zadań na najróżniejsze sposoby.
Zhuravlev et al., Survey of Scheduling Techniques for Addressing Shared Resources in Multicore
Processors
Jak wspomniano wcześniej, jednym z najważniejszych wyzwań w systemach wieloproceso-
rowych jest rywalizacja o współdzielone zasoby. W tym artykule zaprezentowano różne techniki
szeregowania w celu obsługi takich rywalizacji.
13.1.9. Bezpieczeństwo
Anderson, Security Engineering, 2nd ed.
Wspaniała książka jednego z najbardziej znanych badaczy w dziedzinie bezpieczeństwa kom-
puterów. Bardzo czytelnie wyjaśnia metody budowania niezawodnych i bezpiecznych syste-
mów. Jej zaletą jest nie tylko to, że prezentuje fascynujące spojrzenie na wiele aspektów bez-
pieczeństwa (w tym techniki, aplikacje i zagadnienia związane z organizacją), ale również to, że
jest ona dostępna za darmo online. Nie ma usprawiedliwienia dla tych, którzy jej nie czytali.
van der Veen et al., Memory Errors: The Past, the Present, and the Future
Historyczne spojrzenie na błędy pamięci (w tym przepełnienia bufora, ataki z wykorzysta-
niem ciągów formatujących, „wiszące wskaźniki” i wiele innych). Opisuje zarówno sposoby ata-
ków, jak i mechanizmy obronne, ataki, które omijają te mechanizmy obronne, oraz nowe mechani-
zmy obronne powstrzymujące ataki, które omijały wcześniejsze mechanizmy obronne, itd. Autorzy
pokazują, że pomimo upływu lat i powstania nowych rodzajów ataku błędy pamięci pozostają
niezwykle ważnym źródłem zagrożeń. Ponadto twierdzą, że nie zanosi się na to, aby ta sytuacja
zmieniła się w najbliższym czasie.
Bratus, What Hackers Learn That the Rest of Us Don’t
Co odróżnia hakerów od innych programistów? Czy jest coś, na co hakerzy zwracają uwagę,
a co jest przeoczane przez zwykłych programistów? Czy hakerzy inaczej korzystają z tych samych
interfejsów API? Czy skrajne przypadki rzeczywiście są ważne? Chcesz wiedzieć? Przeczytaj
ten artykuł.
Bratus et al., From Buffer Overflows to Weird Machines and Theory of Computation
Związki skromnego błędu przepełnienia bufora z Alanem Turingiem. Autorzy pokazują, że
hakerzy programują wrażliwe programy jak dziwne maszyny z dziwnie wyglądającymi zestawami
instrukcji. W tej dyskusji nawiązują do przełomowych badań Turinga na temat tego, „co da się
obliczyć”.
Denning, Information Warfare and Security
Informacja zyskała status broni wykorzystywanej zarówno w wojnach prowadzonych przez
wrogie armie, jak i w wojnach między korporacjami. Strony konfliktu próbują nie tylko atako-
wać systemy informatyczne przeciwnika, ale też zabezpieczać przed podobnymi atakami własne
systemy. W tej fascynującej książce autor szczegółowo analizuje wszystkie zagadnienia związane
ze strategią ofensywną i defensywną, w tym techniki manipulowania danymi i przechwytywania
pakietów. Książka Denninga jest pozycją obowiązkową dla każdego zainteresowanego bezpie-
czeństwem systemów komputerowych.
Ford i Allen, How Not to Be Seen
Twórcy wirusów, oprogramowania szpiegującego, rootkitów czy systemów DRM są bardzo
zainteresowani ukrywaniem pewnych danych lub nawet swojego istnienia. W tym artykule można
znaleźć krótkie wprowadzenie do technik niewykrywalności w najróżniejszych formach.
Hafner i Markoff, Cyberpunk
W książce opisano trzy historie młodych hakerów włamujących się do komputerów na całym
świecie. Współautorem książki jest reporter działu komputerowego „New York Timesa”, Markoff,
który rozwiązał zagadkę robaka internetowego.
3
Polskie wydanie: Linux. Niezbędnik programisty, Helion, 2009 — przyp. tłum.
mechanizmy tego systemu, ta książka będzie zdecydowanie najlepsza. Omówiono w niej wiele
wewnętrznych algorytmów i struktur danych oraz opisano najróżniejsze techniczne aspekty ich
stosowania. Żadna inna książka nie przybliża tych zagadnień równie skutecznie.
ANDREWS G.R. i SCHNEIDER F.B.: Concepts and Notations for Concurrent Programming,
„Computing Surveys”, Vol. 15, March 1983, s. 3 – 43.
ANDREWS G.R.: Concurrent Programming — Principles and Practice, Redwood City, CA: Benjamin/
Cummings, 1991.
APPUSWAMY R., VAN MOOLENBROEK D.C. i TANENBAUM A.S.: Flexible, Modular File
Volume Virtualization in Loris, Proc. 27th Symp. on Mass Storage Systems and Tech., IEEE, 2011,
s. 1 – 14.
ARNAB A. i HUTCHISON A.: Piracy and Content Protection in the Broadband Age, Proc. S. African
Telecomm. Netw. and Appl. Conf, 2006.
ARON M. i DRUSCHEL P.: Soft Timers: Efficient Microsecond Software Timer Support for Network
Processing, Proc. 17th Symp. on Operating Systems Principles, ACM, 1999, s. 223 – 246.
ARPACI-DUSSEAU R. i ARPACI-DUSSEAU A.: Operating Systems: Three Easy Pieces, Madison,
WI: Arpacci-Dusseau, 2013.
BAKER F.T.: Chief Programmer Team Management of Production Programming, „IBM Systems
Journal”, Vol. 1, 1972.
BAKER M., SHAH M., ROSENTHAL D.S.H., ROUSSOPOULOS M., MANIATIS P., GIULI
T.J. i BUNGALE P.: A Fresh Look at the Reliability of Long-Term Digital Storage, Proc. First Euro-
pean Conf. on Computer Systems (EuroSys), ACM, 2006, s. 221 – 234.
BALA K., KAASHOEK M.F. i WEIHL W.: Software Prefetching and Caching for Translation Looka-
side Buffers, Proc. First Symp. on Operating System Design and Implementation, USENIX, 1994,
s. 243 – 254.
BARHAM P., DRAGOVIC B., FRASER K., HAND S., HARRIS T., HO A., NEUGEBAUER
R., PRATT I. i WARFIELD A.: Xen and the Art of Virtualization, Proc. 19th Symp. on Operating
Systems Principles, ACM, 2003, s. 164 – 177.
BARNI M.: Processing Encrypted Signals: A New Frontier for Multimedia Security, Proc. Eighth Works-
hop on Multimedia and Security, ACM, 2006, s. 1 – 10.
BARNI M.: Processing Encrypted Signals: A New Frontier for Multimedia Security, Proc. Eighth
Workshop on Multimedia and Security, ACM, 2006, s. 1 – 10.
BARR K., BUNGALE P., DEASY S., GYURIS V., HUNG P., NEWELL C., TUCH H.
i ZOPPIS B.: The VMware Mobile Virtualization Platform: Is That a Hypervisor in Your Pocket?,
„ACM SIGOPS Operating Systems Rev.”, Vol. 44, December 2010, s. 124 – 135.
BARWINSKI M., IRVINE C. i LEVIN T.: Empirical Study of Drive-By-Download Spyware, Proc.
Int’l Conf. on I-Warfare and Security, Academic Confs. Int’l, 2006.
BASILLI V.R. i PERRICONE B.T.: Software Errors and Complexity: An Empirical Study, „Commun.
of the ACM”, Vol. 27, January 1984, s. 42 – 52.
BAUMANN A., BARHAM P., DAGAND P., HARRIS T., ISAACS R., PETER S., ROSCOE T.,
SCHUPBACH A. i SINGHANIA A.: The Multikernel: A New OS Architecture for Scalable
Multicore Systems, Proc. 22nd Symp. on Operating Systems Principles, ACM, 2009, s. 29 – 44.
BAYS C.: A Comparison of Next-Fit, First-Fit, and Best-Fit, „Commun. of the ACM”, Vol. 191 – 192,
March 1977.
BEHAM M., VLAD M. i REISER H.: Intrusion Detection and Honeypots in Nested Virtualization
Environments, Proc. 43rd Conf. on Dependable Systems and Networks, IEEE, 2013, s. 1 – 6.
BELAY A., BITTAU A., MASHTIZADEH A., TEREI D., MAZIERES D. i KOZYRAKIS C.:
Dune: Safe User-level Access to Privileged CPU Features, Proc. Ninth Symp. on Operating Systems
Design and Implementation, USENIX, 2010, s. 335 – 348.
BELL D. i LA PADULA L.: Secure Computer Systems: Mathematical Foundations and Model,
Technical Report MTR 2547 v2, Mitre Corp., November 1973.
BEN-ARI M.: Principles of Concurrent and Distributed Programming, Upper Saddle River, NJ: Prentice
Hall, 2006.
BEN-YEHUDA M., DAY M.D., DUBITZKY Z., FACTOR M., HAR’EL N., GORDON A.,
LIGUORI A., WASSERMAN O. i YASSOUR B.: The Turtles Project: Design and Implementation
of Nested Virtualization, Proc. Ninth Symp. on Operating Systems Design and Implementation,
USENIX, Art. 1 – 6, 2010.
BHEDA R.A., BEU J.G., RAILING B.P. i CONTE T.M.: Extrapolation Pitfalls When Evaluating
Limited Endurance Memory, Proc. 20th Int’l Symp. on Modeling, Analysis, & Simulation of Computer
and Telecomm. Systems, IEEE, 2012, s. 261 – 268.
BHEDA R.A., POOVEY J.A., BEU J.G. i CONTE T.M.: Energy Efficient Phase Change Memory
Based Main Memory for Future High Performance Systems, Proc. Int’l Green Computing Conf.,
IEEE, 2011, s. 1 – 8.
BHOEDJANG R.A.F., RUHL T. i BAL H.E.: User-Level Network Interface Protocols, „Computer”,
Vol. 31, November 1998, s. 53 – 60.
BIBA K.: Integrity Considerations for Secure Computer Systems, Technical Report 76 – 371, U.S. Air
Force Electronic Systems Division, 1977.
BIRRELL A.D. i NELSON B.J.: Implementing Remote Procedure Calls, „ACM Trans. on Computer
Systems”, Vol. 2, February 1984, s. 39 – 59.
BISHOP M. i FRINCKE D.A.: Who Owns Your Computer? „IEEE Security and Privacy”, Vol. 4, 2006,
s. 61 – 63.
BLACKHAM B., SHI Y. i HEISER G.: Improving Interrupt Response Time in a Verifiable Protected
Microkernel, Proc. Seventh European Conf. on Computer Systems (EuroSys), April 2012.
BOEHM B.: Software Engineering Economics, Upper Saddle River, NJ: Prentice Hall, 1981.
BOGDANOV A. i LEE C.H.: Limits of Provable Security for Homomorphic Encryption, Proc. 33rd
Int’l Cryptology Conf., Springer, 2013.
BORN G.: Inside the Windows 98 Registry, Redmond, WA: Microsoft Press, 19984.
BOTELHO F.C., SHILANE P., GARG N. i HSU W.: Memory Efficient Sanitization of a Deduplicated
Storage System, Proc. 11th USENIX Conf. on File and Storage Tech., USENIX, 2013, s. 81 – 94.
BOTERO J.F. i HESSELBACH X.: Greener Networking in a Network Virtualization Environment,
„Computer Networks”, Vol. 57, June 2013, s. 2021 – 2039.
4
Polskie wydanie: Microsoft Windows 98. Rejestr, Read Me, 1999 — przyp. tłum.
CARR R.W. i HENNESSY J.L.: WSClock — A Simple and Effective Algorithm for Virtual Memory
Management, Proc. Eighth Symp. on Operating Systems Principles, ACM, 1981, s. 87 – 95.
CARRIERO N. i GELERNTER D.: Linda in Context, „Commun. of the ACM”, Vol. 32, April 1989,
s. 444 – 458.
CARRIERO N. i GELERNTER D.: The S/Net’s Linda Kernel, „ACM Trans. on Computer Systems”,
Vol. 4, May 1986, s. 110 – 129.
CERF C. i NAVASKY V.: The Experts Speak, New York: Random House, 1984.
CHEN M.-S., YANG B.-Y. i CHENG C.-M.: RAIDq: A Software-Friendly, Multiple-Parity RAID,
Proc. Fifth Workshop on Hot Topics in File and Storage Systems, USENIX, 2013.
CHEN S. i THAPAR M.: A Novel Video Layout Strategy for Near-Video-on-Demand Servers, Prof.
Int’l Conf. on Multimedia Computing and Systems, IEEE, 1997, s. 37 – 45.
CHEN Z., XIAO N. i LIU F.: SAC: Rethinking the Cache Replacement Policy for SSD-Based Storage
Systems, Proc. Fifth Int’l Systems and Storage Conf., ACM, Art. 13, 2012.
CHERVENAK A., VELLANKI V. i KURMAS Z.: Protecting File Systems: A Survey of Backup Tech-
niques, Proc. 15th IEEE Symp. on Mass Storage Systems, IEEE, 1998.
CHIDAMBARAM V., PILLAI T.S., ARPACI-DUSSEAU A.C. i ARPACI-DUSSEAU R.H.:
Optimistic Crash Consistency, Proc. 24th Symp. on Operating System Principles, ACM, 2013,
s. 228 – 243.
CHILDS S. i INGRAM D.: The Linux-SRT Integrated Multimedia Operating System: Bringing QoS
to the Desktop, Proc. Seventh IEEE Real-Time Tech. and Appl. Symp., IEEE, 2001, s. 135 – 141.
CHOI S. i JUNG S.: A Locality-Aware Home Migration for Software Distributed Shared Memory,
Proc. 2013 Conf. on Research in Adaptive and Convergent Systems, ACM, 2013, s. 79 – 81.
CHOW T.C.K. i ABRAHAM J.A.: Load Balancing in Distributed Systems, „IEEE Tr ans. on Software
Engineering”, Vol. SE-8, July 1982, s. 401 – 412.
CLEMENTS A.T, KAASHOEK M.F., ZELDOVICH N., MORRIS R.T. i KOHLER E.: The
Scalable Commutativity Rule: Designing Scalable Software for Multicore Processors, Proc. 24th Symp.
on Operating Systems Principles, ACM, 2013, s. 1 – 17.
COFFMAN E.G., ELPHICK M.J. i SHOSHANI A.: System Deadlocks, „Computing Surveys”,
Vol. 3, June 1971, s. 67 – 78.
COLP P., NANAVATI M., ZHU J., AIELLO W., COKER G., DEEGAN T., LOSCOCCO P.
i WARFIELD A.: Breaking Up Is Hard to Do: Security and Functionality in a Commodity Hype-
rvisor, Proc. 23rd Symp. of Operating Systems Principles, ACM, 2011, s. 189 – 202.
COOKE D., URBAN J. i HAMILTON S.: UNIX and Beyond: An Interview with Ken Thompson,
„Computer”, Vol. 32, May 1999, s. 58 – 64.
COOPERSTEIN J.: Writing Linux Device Drivers: A Guide with Exercises, Seattle: CreateSpace, 2009.
CORBATÓ F.J. i VYSSOTSKY V.A.: Introduction and Overview of the MULTICS System, Proc.
AFIPS Fall Joint Computer Conf., AFIPS, 1965, s. 185 – 196.
CORBATÓ F.J., MERWIN-DAGGETT M. i DALEY R.C.: An Experimental Time-Sharing System,
Proc. AFIPS Fall Joint Computer Conf., AFIPS, 1962, s. 335 – 344.
CORBATÓ F.J.: On Building Systems That Will Fail, „Commun. of the ACM”, Vol. 34, June
1991, s. 72 – 81.
CORBET J., RUBINI A. i KROAH-HARTMAN G.: Linux Device Drivers, Sebastopol, CA:
O’Reilly & Associates, 2009.
CORNWELL M.: Anatomy of a Solid-State Drive, „ACM Queue”, Vol. 10, No. 10, 2012, s. 30 – 37.
CORREIA M., GÓMEZ FERRO D., JUNQUEIRA F.P. i SERAFINI M.: Practical Hardening
of Crash-Tolerant Systems, Proc. USENIX Ann. Tech. Conf., USENIX, 2012.
COURTOIS P.J., HEYMANS F. i PARNAS D.L.: Concurrent Control with Readers and Writers,
„Commun. of the ACM”, Vol. 10, October 1971, s. 667 – 668.
CROWLEY C.: Operating Systems: A Design-Oriented Approach, Chicago: Irwin, 1997.
CUCINOTTA T., ABENI L., PALOPOLI L. i LIPARI G.: A Robust Mechanism for Adaptive
Scheduling of Multimedia Applications, Trans. on Embedded Computing Systems, Vol. 10, Art. 46,
November 2011.
CUCINOTTA T., CHECCONI F., ABENI L. i PALOPOLI L.: Adaptive Real-time Scheduling for
Legacy Multimedia Applications, Trans. on Embedding Computing Systems, Vol. 11, Art. 86,
December 2012.
CUSUMANO M.A. i SELBY R.W.: How Microsoft Builds Software, „Commun. of the ACM”, Vol. 40,
June 1997, s. 53 – 61.
DABEK F., KAASHOEK M.F., KARGET D., MORRIS R. i STOICA I.: Wide-Area Cooperative
Storage with CFS, Proc. 23rd Symp. of Operating Systems Principles, ACM, 2001, s. 202 – 215.
DAI Y., QI Y., REN J., SHI Y., WANG X. i YU X.: A Lightweight VMM on Many Core for High
Performance Computing, Proc. Ninth Int’l Conf. on Virtual Execution Environments, ACM, 2013,
s. 111 – 120.
DALEY R.C. i DENNIS J.B.: Virtual Memory, Process i Sharing in MULTICS, „Commun. of the ACM”,
Vol. 11, May 1968, s. 306 – 312.
DASHTI M., FEDOROVA A., FUNSTON J., GAUD F., LACHAIZE R., LEPERS B., QUEMA
V. i ROTH M.: Traffic Management: A Holistic Approach to Memory Placement on NUMA Sys-
tems, Proc. 18th Int’l Conf. on Arc h. Support for Prog. Lang. and Operating Systems, ACM, 2013,
s. 381 – 394.
DAUGMAN J.: How Iris Recognition Works, „IEEE Trans. on Circuits and Systems for Video Tech.”,
Vol. 14, January 2004, s. 21 – 30.
DAWSON-HAGGERTY S., KRIOUKOV A., TANEJA J., KARANDIKAR S., FIERRO G.
i CULLER D.: BOSS: Building Operating System Services, Proc. 10th Symp. on Networked Systems
Design and Implementation, USENIX, 2013, s. 443 – 457.
DAYAN N., SVENDSEN M.K., BJORING M., BONNET P. i BOUGANIM L.: Eagle-Tree:
Exploring the Design Space of SSD-based Algorithms, Proc. VLDB Endowment, Vol. 6, August 2013,
s. 1290 – 1293.
DE BRUIJN W. i BOS H.: Beltway Buffers: Avoiding the OS Traffic Jam, Proc. 27th Int’l Conf. on
Computer Commun., April 2008.
DE BRUIJN W., BOS H. i BAL H.: Application-Tailored I/O with Streamline, „ACM Trans. on
Computer Syst.”, Vol. 29, No. 2, May 2011, s. 1 – 33.
DENNING D.: Information Warfare and Security, Boston: Addison-Wesley, 1999.
DENNING P.J.: The Working Set Model for Program Behavior, „Commun. of the ACM”, Vol. 11,
1968a, s. 323 – 333.
DENNING P.J.: Thrashing: Its Causes and Prevention, Proc. AFIPS National Computer Conf., AFIPS,
1968b, s. 915 – 922.
DENNING P.J.: Virtual Memory, „Computing Surveys”, Vol. 2, September 1970, s. 153 – 189.
DENNING P.J.: Working Sets Past and Present, „IEEE Trans. on Software Engineering”, Vol. SE-6,
January 1980, s. 64 – 84.
DENNIS J.B. i VAN HORN E.C.: Programming Semantics for Multiprogrammed Computations,
„Commun. of the ACM”, Vol. 9, March 1966, s. 143 – 155.
DIAB K., ELGAMAL T., CALAGARI K. i HEFEEDA M.: Storage Optimization for 3D Streaming
Systems, Proc. Fifth Multimedia Systems Conf., ACM, 2014.
DIFFIE W. i HELLMAN M.E.: New Directions in Cryptography, „IEEE Trans. on Information
Theory”, Vol. IT-22, November 1976, s. 644 – 654.
DIJKSTRA E.W.: Co-operating Sequential Processes, Programming Languages, Genuys F. (Ed.), Lon-
don: Academic Press, 1965.
DIJKSTRA E.W.: The Structure of THE Multiprogramming System, „Commun. of the ACM”, Vol. 11,
May 1968, s. 341 – 346.
DUBOIS M., SCHEURICH C. i BRIGGS F.A.: Synchronization, Coherence i Event Ordering in
Multiprocessors, „Computer”, Vol. 21, February 1988, s. 9 – 21.
DUNN A., LEE M.Z., JANA S., KIM S., SILBERSTEIN M., XU Y., SHMATIKOV V. i WIT-
CHEL E.: Eternal Sunshine of the Spotless Machine: Protecting Privacy with Ephemeral Channels,
Proc. 10th Symp. on Operating Systems Design and Implementation, USENIX, 2012, s. 61 – 75.
DUTTA K., SINGH V.K. i VANDERMEER D.: Estimating Operating System Process Energy
Consumption in Real Time, Proc. Eighth Int’l Conf. on Design Science at the Intersection of Physical
and Virtual Design, Springer-Verlag, 2013, s. 400 – 404.
EAGER D.L., LAZOWSKA E.D. i ZAHORJAN J.: Adaptive Load Sharing in Homogeneous Distri-
buted Systems, „IEEE Trans. on Software Engineering”, Vol. SE-12, May 1986, s. 662 – 675.
EDLER J., LIPKIS J. i SCHONBERG E.: Process Management for Highly Parallel UNIX Systems,
Proc. USENIX Workshop on UNIX and Supercomputers, USENIX, September 1988, s. 1 – 17.
EL FERKOUSS O., SNAIKI I., MOUNAOUAR O., DAHMOUNI H., BEN ALI R., LEMIEUX
Y. i OMAR C.: A 100Gig Network Processor Platform for Openflow, Proc. Seventh Int’l Conf. on
Network Services and Management, IFIP, 2011, s. 286 – 289.
EL GAMAL A.: A Public Key Cryptosystem and Signature Scheme Based on Discrete Logarithms, „IEEE
Trans. on Information Theory”, Vol. IT-31, July 1985, s. 469 – 472.
ELNABLY A. i WANG H.: Efficient QoS for Multi-Tiered Storage Systems, Proc. Fourth USENIX
Workshop on Hot Topics in Storage and File Systems, USENIX, 2012.
ELPHINSTONE K., KLEIN G., DERRIN P., ROSCOE T. i HEISER G.: Towards a Practical,
Verified, Kernel, Proc. 11th Workshop on Hot Topics in Operating Systems, USENIX, 2007, s. 117 – 122.
ENGLER D.R., CHELF B., CHOU A. i HALLEM S.: Checking System Rules Using System-Specific
Programmer-Written Compiler Extensions, Proc. Fourth Symp. on Operating Systems Design and
Implementation, USENIX, 2000, s. 1 – 16.
ENGLER D.R., KAASHOEK M.F. i O’TOOLE J. Jr.: Exokernel: An Operating System Architecture
for Application-Level Resource Management, Proc. 15th Symp. on Operating Systems Principles, ACM,
1995, s. 251 – 266.
ERL T., PUTTINI R. i MAHMOOD Z.: Cloud Computing: Concepts, Technology & Architecture, Upper
Saddle River, NJ: Prentice Hall, 2013.
EVEN S.: Graph Algorithms, Potomac, MD: Computer Science Press, 1979.
FABRY R.S.: Capability-Based Addressing, „Commun. of the ACM”, Vol. 17, July 1974, s. 403 – 412.
FANDRICH M., AIKEN M., HAWBLITZEL C., HODSON O., HUNT G., LARUS J.R.
i LEVI S.: Language Support for Fast and Reliable Message-Based Communication in Singularity
OS, Proc. First European Conf. on Computer Systems (EuroSys), ACM, 2006, s. 177 – 190.
FEELEY M.J., MORGAN W.E., PIGHIN F.H., KARLIN A.R., LEVY H.M. i THEKKATH C.A.:
Implementing Global Memory Management in a Workstation Cluster, Proc. 15th Symp. on Operating
Systems Principles, ACM, 1995, s. 201 – 212.
FELTEN E.W. i HALDERMAN J.A.: Digital Rights Management, Spyware i Security, „IEEE Secu-
rity and Privacy”, Vol. 4, January – February 2006, s. 18 – 23.
FETZER C. i KNAUTH T.: Energy-Aware Scheduling for Infrastructure Clouds, Proc. Fourth Int’l
Conf. on Cloud Computing Tech. and Science, IEEE, 2012, s. 58 – 65.
FEUSTAL E.A.: The Rice Research Computer — A Tagged Architecture, Proc. AFIPS Conf., AFIPS, 1972.
FLINN J. i SATYANARAYANAN M.: Managing Battery Lifetime with Energy-Aware Adaptation,
„ACM Trans. on Computer Systems”, Vol. 22, May 2004, s. 137 – 179.
FLORENCIO D. i HERLEY C.: A Large-Scale Study of Web Password Habits, Proc. 16th Int’l Conf.
on the World Wide Web, ACM, 2007, s. 657 – 666.
FLUCKIGER F.: Understanding Networked Multimedia, Upper Saddle River, NJ: Prentice Hall, 1995.
FORD R. i ALLEN W.H.: How Not To Be Seen, „IEEE Security and Privacy”, Vol. 5, January – February
2007, s. 67 – 69.
FOTHERINGHAM J.: Dynamic Storage Allocation in the Atlas Including an Automatic Use of a Backing
Store, „Commun. of the ACM”, Vol. 4, October 1961, s. 435 – 436.
FRYER D., SUN K., MAHMOOD R., CHENG T., BENJAMIN S., GOEL A. i DEMKE
BROWN A.: ReCon: Verifying File System Consistency at Runtime, Proc. 10th USENIX Conf. on
File and Storage Tech., USENIX, 2012, s. 73 – 86.
FUKSIS R., GREITANS M. i PUDZS M.: Processing of Palm Print and Blood Vessel Images for
Multimodal Biometrics, Proc. COST1011 European Conf. on Biometrics and ID Mgt., Springer-Verlag,
2011, s. 238 – 249.
FURBER S.B., LESTER D.R., PLANA L.A., GARSIDE J.D., PAINKRAS E., TEMPLE S.
i BROWN A.D.: Overview of the SpiNNaker System Architecture, „Trans. on Computers”, Vol. 62,
December 2013, s. 2454 – 2467.
FUSCO J.: The Linux Programmer’s Toolbox, Upper Saddle River, NJ: Prentice Hall, 2007.
GARFINKEL T., PFAFF B., CHOW J., ROSENBLUM M. i BONEH D.: Terra: A Virtual Machine-
-Based Platform for Trusted Computing, Proc. 19th Symp. on Operating Systems Principles, ACM,
2003, s. 193 – 206.
GAROFALAKIS J. i STERGIOU E.: An Analytical Model for the Performance Evaluation of Mul-
tistage Interconnection Networks with Two Class Priorities, Future Generation Computer Systems,
Vol. 29, January 2013, s. 114 – 129.
GEER D.: For Programmers, Multicore Chips Mean Multiple Challenges, „Computer”, Vol. 40, Sep-
tember 2007, s. 17 – 19.
GEIST R. i DANIEL S.: A Continuum of Disk Scheduling Algorithms, „ACM Trans. on Computer
Systems”, Vol. 5, February 1987, s. 77 – 92.
GELERNTER D.: Generative Communication in Linda, „ACM Trans. on Programming Languages
and Systems”, Vol. 7, January 1985, s. 80 – 112.
GHOSHAL D. i PLALE B.: Provenance from Log Files: a BigData Problem, Proc. Joint EDBT/ICDT
Workshops, ACM, 2013, s. 290 – 297.
GIFFIN D., LEVY A., STEFAN D., TEREI D., MAZIERES D.: Hails: Protecting Data Privacy
in Untrusted Web Applications, Proc. 10th Symp. on Operating Systems Design and Implementation,
USENIX, 2012.
GIUFFRIDA C., KUIJSTEN A. i TANENBAUM A.S.: Enhanced Operating System Security through
Efficient and Fine-Grained Address Space Randomization, Proc. 21st USENIX Security Symp.,
USENIX, 2012.
GIUFFRIDA C., KUIJSTEN A. i TANENBAUM A.S.: Safe and Automatic Live Update for Ope-
rating Systems, Proc. 18th Int’l Conf. on Arc h. Support for Prog. Lang. and Operating Systems, ACM,
2013, s. 279 – 292.
GOLDBERG R.P.: Architectural Principles for Virtual Computer Systems, Harvard University,
Cambridge, MA, 1972 [praca doktorska].
GOLLMAN D.: Computer Security, West Sussex, UK: John Wiley & Sons, 2011.
GONG L.: Inside Java 2 Platform Security, Boston: Addison-Wesley, 1999.
GONZALEZ-FEREZ P., PIERNAS J. i CORTES T.: DADS: Dynamic and Automatic Disk Sche-
duling, Proc. 27th Symp. on Appl. Computing, ACM, 2012, s. 1759 – 1764.
GORDON M.S., JAMSHIDI D.A., MAHLKE S. i MAO Z.M.: COMET: Code Offload by Migra-
ting Execution Transparently, Proc. 10th Symp. on Operating Systems Design and Implementation,
USENIX, 2012.
GRAHAM R.: Use of High-Level Languages for System Programming, Project MAC Report TM-13,
MIT, September 1970.
GROPP W., LUSK E. i SKJELLUM A.: Using MPI: Portable Parallel Programming with the
Message Passing Interface, Cambridge, MA: MIT Press, 1994.
HEWAGE K. i VOIGT T.: Towards TCP Communication with the Low Power Wireless Bus, Proc.
11th Conf. on Embedded Networked Sensor Systems, ACM, Art. 53, 2013.
HILBRICH T., DE SUPINSKI R., NAGEL W., PROTZE J., BAIER C. i MULLER M.: Distri-
buted Wait State Tracking for Runtime MPI Deadlock Detection, Proc. 2013 Int’l Conf. for High
Performance Computing, Networking, Storage and Analysis, ACM, New York, NY, USA, 2013.
HILDEBRAND D.: An Architectural Overview of QNX, Proc. Workshop on Microkernels and Other
Kernel Arch., ACM, 1992, s. 113 – 136.
HIPSON P.: Mastering Windows XP Registry, New York: Sybex, 2002.
HOARE C.A.R.: Monitors, An Operating System Structuring Concept, „Commun. of the ACM”, Vol. 17,
October 1974, s. 549 – 557; Erratum in „Commun. of the ACM”, Vol. 18, February 1975, s. 95.
HOCKING M: Feature: Thin Client Security in the Cloud, „J. Network Security”, Vol. 2011, June
2011, s. 17 – 19.
HOHMUTH M., PETER M., HAERTIG H. i SHAPIRO J.: Reducing TCB Size by Using Untrusted
Components: Small Kernels Versus Virtual-Machine Monitors, Proc. 11th ACM SIGOPS European
Workshop, ACM, Art. 22, 2004.
HOLMBACKA S., AGREN D., LAFOND S. i LILIUS J.: QoS Manager for Energy Efficient Many-
-Core Operating Systems, Proc. 21st Euromicro Int’l Conf. on Parallel, Distributed i Network-based
Processing, IEEE, 2013, s. 318 – 322.
HOLT R.C.: Some Deadlock Properties of Computer Systems, „Computing Surveys”, Vol. 4, September
1972, s. 179 – 196.
HOQUE M.A., SIEKKINEN M. i NURMINEN J.K.: TCP Receive Buffer Aware Wireless Multi-
media Streaming: An Energy Efficient Approach, Proc. 23rd Workshop on Network and Operating
System Support for Audio and Video, ACM, 2013, s. 13 – 18.
HOSSEINI M., PETERS J. i SHIRMOHAMMADI S.: Energy-Budget-Compliant Adaptive 3D
Texture Streaming in Mobile Games, Proc. Fourth Multimedia Systems Conf., ACM, 2013.
HOWARD M. i LEBLANK D.: Writing Secure Code, Redmond, WA: Microsoft Press, 2009.
HRUBY T., BOS H. i TANENBAUM A.S.: When Slower Is Faster: On Heterogeneous Multicores
for Reliable Systems, Proc. USENIX Ann. Tech. Conf., USENIX, 2013.
HRUBY T., VOGT D., BOS H. i TANENBAUM A.S.: Keep Net Working — On a Dependable and
Fast Networking Stack, Proc. 42nd Conf. on Dependable Systems and Networks, IEEE, 2012, s. 1 – 12.
HUA J., LI M., SAKURAI K. i REN Y.: Efficient Intrusion Detection Based on Static Analysis and
Stack Walks, Proc. Fourth Int’l Workshop on Security, Springer-Verlag, 2009, s. 158 – 173.
HUND R., WILLEMS C. i HOLZ T.: Practical Timing Side Channel Attacks against Kernel Space
ASLR, Proc. IEEE Symp. on Security and Privacy, IEEE, 2013, s. 191 – 205.
HUTCHINSON N.C., MANLEY S., FEDERWISCH M., HARRIS G., HITZ D., KLEIMAN S.
i O’MALLEY S.: Logical vs. Physical File System Backup, Proc. Third Symp. on Operating Systems
Design and Implementation, USENIX, 1999, s. 239 – 249.
IEEE: Information Technology — Portable Operating System Interface (POSIX), Part 1: System Applica-
tion Program Interface (API) [C Language], New York: Institute of Electrical and Electronics
Engineers, 1990.
INTEL: PCI-SIG SR-IOV Primer: An Introduction to SR-IOV Technology, Intel White Paper, 2011.
ION F.: From Touch Displays to the Surface: A Brief History of Touchscreen Technology, ArsTechnica,
History of Tech, April 2013
ISLOOR S.S. i MARSLAND T.A.: The Deadlock Problem: An Overview, „Computer”, Vol. 13, Sep-
tember 1980, s. 58 – 78.
IVENS K.: Optimizing the Windows Registry, Hoboken, NJ: John Wiley & Sons, 1998.
JANTZ M.R., STRICKLAND C., KUMAR K., DIMITROV M. i DOSHI K.A.: A Framework
for Application Guidance in Virtual Memory Systems, Proc. Ninth Int’l Conf. on Virtual Execution
Environments, ACM, 2013, s. 155 – 166.
JEONG J., KIM H., HWANG J., LEE J. i MAENG S.: Rigorous Rental Memory Management for
Embedded Systems, „ACM Trans. on Embedded Computing Systems”, Vol. 12, Art. 43, March 2013,
s. 1 – 21.
JIANG X. i XU D.: Profiling Self-Propagating Worms via Behavioral Footprinting, Proc. Fourth ACM
Workshop in Recurring Malcode, ACM, 2006, s. 17 – 24.
JIN H., LING X., IBRAHIM S., CAO W., WU S. i ANTONIU G.: Flubber: Two-Level Disk
Scheduling in Virtualized Environment, Future Generation Computer Systems, Vol. 29, October
2013, s. 2222 – 2238.
JOHNSON E.A: Touch Display — A Novel Input/Output Device for Computers, „Electronics Letters”,
Vol. 1, No. 8, 1965, s. 219 – 220.
JOHNSON N.F. i JAJODIA S.: Exploring Steganography: Seeing the Unseen, „Computer”, Vol. 31,
February 1998, s. 26 – 34.
JOO Y.: F2FS: A New File System Designed for Flash Storage in Mobile Devices, Embedded Linux
Europe, Barcelona, Spain, November 2012.
JULA H., TOZUN P. i CANDEA G.: Communix: A Framework for Collaborative Deadlock Immunity,
Proc. IEEE/IFIP 41st Int. Conf. on Dependable Systems and Networks, IEEE, 2011, s. 181 – 188.
JUNG D., KIM J., KIM J.-S. i LEE J.: ScaleFFS: A Scalable Log-Structured Flash File System for
Mobile Multimedia Systems, „Trans. on Multimedia Computing, Commun. and Appl.”, Vol. 5,
Art. 9, October 2008.
KABRI K. i SERET D.: An Evaluation of the Cost and Energy Consumption of Security Protocols in
WSNs, Proc. Third Int’l Conf. on Sensor Tech. and Applications, IEEE, 2009, s. 49 – 54.
KAMAN S., SWETHA K., AKRAM S. i VARAPRASAS G.: Remote User Authentication Using
a Voice Authentication System, Inf. Security J., Vol. 22, No. 3, 2013, s. 117 – 125.
KAMINSKY D.: Explorations in Namespace: White-Hat Hacking across the Domain Name System,
„Commun. of the ACM”, Vol. 49, June 2006, s. 62 – 69.
KAMINSKY M., SAVVIDES G., MAZIERES D. i KAASHOEK M.F.: Decentralized User Authen-
tication in a Global File System, Proc. 19th Symp. on Operating Systems Principles, ACM, 2003,
s. 60 – 73.
KANETKAR Y.P.: Writing Windows Device Drivers Course Notes, New Delhi: BPB Publications, 2008.
KANG J., JEONG H. i CHUNG K.: Scalable Depth Map Coding for 3D Video Using Contour Infor-
mation, Proc. 27th Ann. Symp. on Applied Computing, ACM, 2012, s. 1028 – 1029.
KANT K. i MOHAPATRA P.: Internet Data Centers, „IEEE Computer”, Vol. 37, November 2004,
s. 35 – 37.
KAPRITSOS M., WANG Y., QUEMA V., CLEMENT A., ALVISI L. i DAHLIN M.: All about Eve:
Execute-Verify Replication for Multi-Core Servers, Proc. 10th Symp. on Operating Systems Design
and Implementation, USENIX, 2012, s. 237 – 250.
KASIKCI B., ZAMFIR C. i CANDEA G.: Data Races vs. Data Race Bugs: Telling the Difference with
Portend, Proc. 17th Int’l Conf. on Arc h. Support for Prog. Lang. and Operating Systems, ACM,
2012, s. 185 – 198.
KATO S., ISHIKAWA Y. i RAJKUMAR R.: Memory Management for Interactive Real-Time Appli-
cations, „Real-Time Systems”, Vol. 47, May 2011, s. 498 – 517.
KAUFMAN C., PERLMAN R. i SPECINER M.: Network Security, 2nd ed., Upper Saddle River,
NJ: Prentice Hall, 2002.
KELEHER P., COX A., DWARKADAS S. i ZWAENEPOEL W.: TreadMarks: Distributed Shared
Memory on Standard Workstations and Operating Systems, Proc. USENIX Winter Conf., USENIX,
1994, s. 115 – 132.
KERNIGHAN B.W. i PIKE R.: The Linux Programmer’s Toolbox, Upper Saddle River, NJ: Prentice
Hall, 1984.
KIM J., LEE J., CHOI J., LEE D. i NOH S.H.: Improving SSD Reliability with RAID via Elastic
Striping and Anywhere Parity, Proc. 43rd Int’l Conf. on Dependable Systems and Networks, IEEE,
2013, s. 1 – 12.
KIRSCH C.M., SANVIDO M.A.A. i HENZINGER T.A.: A Programmable Microkernel for Real-Time
Systems, Proc. First Int’l Conf. on Virtual Execution Environments, ACM, 2005, s. 35 – 45.
KLEIMAN S.R.: Vnodes: An Architecture for Multiple File System Types in Sun UNIX, Proc. USENIX
Summer Conf., USENIX, 1986, s. 238 – 247.
KLEIN G., ELPHINSTONE K., HEISER G., ANDRONICK J., COCK D., DERRIN P., ELKA-
DUWE D., ENGELHARDT K., KOLANSKI R., NORRISH M., SEWELL T., TUCH H.
i WINWOOD S.: seL4: Formal Verification of an OS Kernel, Proc. 22nd Symp. on Operating Systems
Primciples, ACM, 2009, s. 207 – 220.
KNUTH D.E.: The Art of Computer Programming, Vol. 2, Boston: Addison-Wesley, 1997.
KOLLER R., MARMOL L., RANGASWAMI R., SUNDARARAMAN S., TALAGALA N.
i ZHAO M.: Write Policies for Host-side Flash Caches, Proc. 11th USENIX Conf. on File and Sto-
rage Tech., USENIX, 2013, s. 45 – 58.
KOUFATY D., REDDY D. i HAHN S.: Bias Scheduling in Heterogeneous Multi-Core Architectures,
Proc. Fifth European Conf. on Computer Systems (EuroSys), ACM, 2010, s. 125 – 138.
KRATZER C., DITTMANN J., LANG A. i KUHNE T.: WLAN Steganography: A First Practical
Review, Proc. Eighth Workshop on Multimedia and Security, ACM, 2006, s. 17 – 22.
KRAVETS R. i KRISHNAN P.: Power Management Techniques for Mobile Communication, Proc.
Fourth ACM/IEEE Int’l Conf. on Mobile Computing and Networking, ACM/IEEE, 1998, s. 157 – 168.
KRISH K.R., WANG G., BHATTACHARJEE P., BUTT A.R. i SNIADY C.: On Reducing Energy
Management Delays in Disks, „J. Parallel and Distributed Computing”, Vol. 73, June 2013, s. 823 – 835.
LI D., JIN H., LIAO X., ZHANG Y. i ZHOU B.: Improving Disk I/O Performance in a Virtualized
System, „J. Computer and Syst. Sci.”, Vol. 79, March 2013a, s. 187 – 200.
LI D., LIAO X., JIN H., ZHOU B. i ZHANG Q.: A New Disk I/O Model of Virtualized Cloud
Environment, „IEEE Trans. on Parallel and Distributed Systems”, Vol. 24, June 2013b, s. 1129 –
– 1138.
LI K. i HUDAK P.: Memory Coherence in Shared Virtual Memory Systems, „ACM Trans. on Com-
puter Systems”, Vol. 7, November 1989, s. 321 – 359.
LI K., KUMPF R., HORTON P. i ANDERSON T.: A Quantitative Analysis of Disk Drive Power
Management in Portable Computers, Proc. USENIX Winter Conf., USENIX, 1994, s. 279 – 291.
LI K.: Shared Virtual Memory on Loosely Coupled Multiprocessors, Yale University, 1986 [praca
doktorska].
LI Y., SHOTRE S., OHARA Y., KROEGER T.M., MILLER E.L. i LONG D.D.E.: Horus: Fine-
-Grained Encryption-Based Security for Large-Scale Storage, Proc. 11th USENIX Conf. on File
and Storage Tech., USENIX, 2013c, s. 147 – 160.
LIEDTKE J.: Improving IPC by Kernel Design, Proc. 14th Symp. on Operating Systems Principles,
ACM, 1993, s. 175 – 188.
LIEDTKE J.: On Micro-Kernel Construction, Proc. 15th Symp. on Operating Systems Principles, ACM,
1995, s. 237 – 250.
LIEDTKE J.: Toward Real Microkernels, „Commun. of the ACM”, Vol. 39, September 1996, s. 70 – 77.
LING X., JIN H., IBRAHIM S., CAO W. i WU S.: Efficient Disk I/O Scheduling with QoS Guarantee
for Xen-based Hosting Platforms, Proc. 12th Int’l Symp. on Cluster, Cloud i Grid Computing,
IEEE/ACM, 2012, s. 81 – 89.
LIONS J.: Lions’ Commentary on Unix 6th Edition, with Source Code, San Jose, CA: Peerto-Peer
Communications, 1996.
LIU C.L. i LAYLAND J.W.: Scheduling Algorithms for Multiprogramming in a Hard Real-Time
Environment, „J. of the ACM”, Vol. 20, January 1973, s. 46 – 61.
LIU T., CURTSINGER C. i BERGER E.D.: Dthreads: Efficient Deterministic Multithreading, Proc.
23rd Symp. of Operating Systems Principles, ACM, 2011, s. 327 – 336.
LIU Y., MUPPALA J.K., VEERARAGHAVAN M., LIN D. i HAMDI M.: Data Center Networks:
Topologies, Architectures and Fault-Tolerance Characteristics, Springer, 2013.
LO V.M.: Heuristic Algorithms for Task Assignment in Distributed Systems, Proc. Fourth Int’l Conf.
on Distributed Computing Systems, IEEE, 1984, s. 30 – 39.
LORCH J.R. i SMITH A.J.: Apple Macintosh’s Energy Consumption, „IEEE Micro”, Vol. 18, Novem-
ber – December 1998, s. 54 – 63.
LORCH J.R., PARNO B., MICKENS J., RAYKOVA M. i SCHIFFMAN J.: Shroud: Ensuring
Private Access to Large-Scale Data in the Data Center, Proc. 11th USENIX Conf. on File and Sto-
rage Tech., USENIX, 2013, s. 199 – 213.
LOVE R.: Linux System Programming: Talking Directly to the Kernel and C Library, Sebastopol,
CA: O’Reilly & Associates, 2013.
LÓPEZ-ORTIZ A., SALINGER A.: Paging for Multi-Core Shared Caches, Proc. Innovations in
Theoretical Computer Science, ACM, 2012, s. 113 – 127.
LU L., ARPACI-DUSSEAU A.C. i ARPACI-DUSSEAU R.H.: Fault Isolation and Quick Recovery
in Isolation File Systems, Proc. Fifth USENIX Workshop on Hot Topics in Storage and File Systems,
USENIX, 2013.
LUDWIG M.A.: The Little Black Book of Email Viruses, Show Low, AZ: American Eagle Publica-
tions, 2002.
LUO T., MA S., LEE R., ZHANG X., LIU D. i ZHOU L.: S-CAVE: Effective SSD Caching to Improve
Virtual Machine Storage Performance, Proc. 22nd Int’l Conf. on Parallel Arch. and Compilation Tech.,
IEEE, 2013, s. 103 – 112.
MA A., DRAGGA C., ARPACI-DUSSEAU A.C. i ARPACI-DUSSEAU R.H.: ffsck: The Fast File
System Checker, Proc. 11th USENIX Conf. on File and Storage Tech., USENIX, 2013.
MAO W.: The Role and Effectiveness of Cryptography in Network Virtualization: A Position Paper, Proc.
Eighth ACM Asian SIGACT Symp. on Information, Computer i Commun. Security, ACM, 2013,
s. 179 – 182.
MARINO D., HAMMER C., DOLBY J., VAZIRI M., TIP F. i VITEK J.: Detecting Deadlock in
Programs with Data-Centric Synchronization, Proc. Int’l Conf. on Software Engineering, IEEE, 2013,
s. 322 – 331.
MARSH B.D., SCOTT M.L., LEBLANC T.J. i MARKATOS E.P.: First-Class User-Level Threads,
Proc. 13th Symp. on Operating Systems Principles, ACM, 1991, s. 110 – 121.
MASHTIZADEH A.J., BITTAY A., HUANG Y.F. i MAZIERES D.: Replication, History i Grafting
in the Ori File System, Proc. 24th Symp. on Operating System Principles, ACM, 2013, s. 151 – 166.
MATTHUR A. i MUNDUR P.: Dynamic Load Balancing Across Mirrored Multimedia Servers, Proc.
2003 Int’l Conf. on Multimedia, IEEE, 2003, s. 53 – 56.
MAXWELL S.: Linux Core Kernel Commentary, Scottsdale, AZ: Coriolis Group Books, 2001.
MAZUREK M.L., THERESKA E., GUNAW ARDENA D., HARPER R. i SCOTT J.: ZZFS:
A Hybrid Device and Cloud File System for Spontaneous Users, Proc. 10th USENIX Conf. on File
and Storage Tech., USENIX, 2012, s. 195 – 208.
MCKUSICK M.K. i NEVILLE-NEIL G.V.: The Design and Implementation of the FreeBSD Oper-
ating System, Boston: Addison-Wesley, 2004.
MCKUSICK M.K., BOSTIC K., KARELS M.J. i QUARTERMAN J.S.: The Design and Imple-
mentation of the 4.4BSD Operating System, Boston: Addison-Wesley, 1996.
MCKUSICK M.K.: Disks from the Perspective of a File System, „Commun. of the ACM”, Vol. 55,
November 2012, s. 53 – 55.
MEAD N.R.: Who Is Liable for Insecure Systems? „Computer”, Vol. 37, July 2004, s. 27 – 34.
MELLOR-CRUMMEY J.M. i SCOTT M.L.: Algorithms for Scalable Synchronization on Shared-
– Memory Multiprocessors, „ACM Trans. on Computer Systems”, Vol. 9, February 1991, s. 21 – 65.
MIKHAYLOV K. i TERVONEN J.: Energy Consumption of the Mobile Wireless Sensor Network’s
Node with Controlled Mobility, Proc. 27th Int’l Conf. on Advanced Networking and Applications
Workshops, IEEE, 2013, s. 1582 – 1587.
MILOJICIC D.: Security and Privacy, „IEEE Concurrency”, Vol. 8, April – June 2000, s. 70 – 79.
MOODY G.: Rebel Code, Cambridge. MA: Perseus Publishing, 2001.
MOON S. i REDDY A.L.N.: Don’t Let RAID Raid the Lifetime of Your SSD Array, Proc. Fifth USENIX
Workshop on Hot Topics in Storage and File Systems, USENIX, 2013.
MORRIS R. i THOMPSON K.: Password Security: A Case History, „Commun. of the ACM”, Vol. 22,
November 1979, s. 594 – 597.
MORUZ G. i NEGOESCU A.: Outperforming LRU Via Competitive Analysis on Parametrized Inputs
for Paging, Proc. 23rd ACM-SIAM Symp. on Discrete Algorithms, SIAM, s. 1669 – 1680.
MOSHCHUK A., BRAGIN T., GRIBBLE S.D. i LEVY H.M.: A Crawler-Based Study of Spyware
on the Web, Proc. Network and Distributed System Security Symp., Internet Society, 2006, s. 1 – 17.
MULLENDER S.J. i TANENBAUM A.S.: Immediate Files, „Software Practice and Experience”,
Vol. 14, 1984, s. 365 – 368.
NACHENBERG C.: Computer Virus-Antivirus Coevolution, „Commun. of the ACM”, Vol. 40, January
1997, s. 46 – 51.
NARAYANAN D.N. THERESKA E., DONNELLY A., ELNIKETY S. i ROWSTRON A.: Migra-
ting Server Storage to SSDs: Analysis of Tradeoffs, Proc. Fourth European Conf. on Computer Systems
(EuroSys), ACM, 2009.
NELSON M., LIM B.-H. i HUTCHINS G.: Fast Transparent Migration for Virtual Machines,
Proc. USENIX Ann. Tech. Conf., USENIX, 2005, s. 391 – 394.
NEMETH E., SNYDER G., HEIN T.R. i WHALEY B.: UNIX and Linux System Administration
Handbook, 4th ed., Upper Saddle River, NJ: Prentice Hall, 2013.
NEWTON G.: Deadlock Prevention, Detection i Resolution: An Annotated Bibliography, „ACM SIGOPS
Operating Systems Rev.”, Vol. 13, April 1979, s. 33 – 44.
NIEH J. i LAM M.S.: A SMART Scheduler for Multimedia Applications, „ACM Trans. on Computer
Systems”, Vol. 21, May 2003, s. 117 – 163.
NIGHTINGALE E.B., ELSON J., FAN J., HOGMANN O., HOWELL J. i SUZUE Y.: Flat
Datacenter Storage, Proc. 10th Symp. on Operating Systems Design and Implementation, USENIX,
2012, s. 1 – 15.
NIJIM M., QIN X., QIU M. i LI K.: An Adaptive Energy-conserving Strategy for Parallel Disk Systems,
„Future Generation Computer Systems”, Vol. 29, January 2013, s. 196 – 207.
NIST (National Institute of Standards and Technology): FIPS Pub. 180-1, 1995.
NIST (National Institute of Standards and Technology): The NIST Definition of Cloud Com-
puting, Special Publication 800-145, Recommendations of the National Institute of Standards and
Technology, 2011.
NO J.: NAND Flash Memory-Based Hybrid File System for High I/O Performance, „J. Parallel and
Distributed Computing”, Vol. 72, December 2012, s. 1680 – 1695.
OH Y., CHOI J., LEE D. i NOH S.H.: Caching Less for Better Performance: Balancing Cache Size
and Update Cost of Flash Memory Cache in Hybrid Storage Systems, Proc. 10th USENIX Conf. on
File and Storage Tech., USENIX, 2012, s. 313 – 326.
OHNISHI Y. i YOSHIDA T.: Design and Evaluation of a Distributed Shared Memory Network for
Application-Specific PC Cluster Systems, Proc. Workshops of Int’l Conf. on Advanced Information
Networking and Applications, IEEE, 2011, s. 63 – 70.
OKI B., PFLUEGL M., SIEGEL A. i SKEEN D.: The Information Bus — An Architecture for
Extensible Distributed Systems, Proc. 14th Symp. on Operating Systems Principles, ACM, 1993,
s. 58 – 68.
ONGARO D., RUMBLE S.M., STUTSMAN R., OUSTERHOUT J. i ROSENBLUM M.: Fast
Crash Recovery in RAMCloud, Proc. 23rd Symp. of Operating Systems Principles, ACM, 2011,
s. 29 – 41.
ORGANICK E.I.: The Multics System, Cambridge, MA: MIT Press, 1972.
ORTOLANI S. i CRISPO B.: NoisyKey: Tolerating Keyloggers via Keystrokes Hiding, Proc. Seventh
USENIX Workshop on Hot Topics in Security, USENIX, 2012.
ORWICK P. i SMITH G.: Developing Drivers with the Windows Driver Foundation, Redmond, WA:
Microsoft Press, 2007.
OSTRAND T.J. i WEYUKER E.J.: The Distribution of Faults in a Large Industrial Software System,
Proc. 2002 ACM SIGSOFT Int’l Symp. on Software Testing and Analysis, ACM, 2002, s. 55 – 64.
OSTROWICK J.: Locking Down Linux — An Introduction to Linux Security, Raleigh, NC: Lulu
Press, 2013.
OUSTERHOUT J.K.: Scheduling Techniques for Concurrent Systems, Proc. Third Int’l Conf. on Distrib.
Computing Systems, IEEE, 1982, s. 22 – 30.
OUSTERHOUT J.L.: Why Threads are a Bad Idea (for Most Purposes), Presentation at Proc. USENIX
Winter Conf., USENIX, 1996.
PARK S. i OHM S.-Y.: Real-Time FAT File System for Mobile Multimedia Devices, Proc. Int’l Conf.
on Consumer Electronics, IEEE, 2006, s. 245 – 346.
PARK S. i SHEN K.: FIOS: A Fair, Eff icient Flash I/O Scheduler, Proc. 10th USENIX Conf. on File
and Storage Tech., USENIX, 2012, s. 155 – 170.
PARK S.O. i KIM S.J.: ENFFiS: An Enhanced NAND Flash Memory File System for Mobile
Embedded Multimedia Systems, „Trans. on Embedded Computing Systems”, Vol. 12, Art. 23,
February 2013.
PATE S.D.: UNIX Filesystems: Evolution, Design i Implementation, Hoboken, NJ: John Wiley
& Sons, 2003.
PATHAK A., HU Y.C. i ZHANG M.: Where Is the Energy Spent inside My App? Fine Grained Energy
Accounting on Smartphones with Eprof, Proc. Seventh European Conf. on Computer Systems (EuroSys),
ACM, 2012.
PATTERSON D. i HENNESSY J.: Computer Organization and Design, 5th ed., Burlington, MA:
Morgan Kaufman, 2013.
PATTERSON D.A., GIBSON G. i KATZ R.: A Case for Redundant Arrays of Inexpensive Disks
(RAID), Proc. ACM SIGMOD Int’l. Conf. on Management of Data, ACM, 1988, s. 109 – 166.
PEARCE M., ZEADALLY S. i HUNT R.: Virtualization: Issues, Security Threats i Solutions,
„Computing Surveys”, ACM, Vol. 45, Art. 17, February 2013.
RENZELMANN M.J., KADAV A. i SWIFT M.M.: SymDrive: Testing Drivers without Devices,
Proc. 10th Symp. on Operating Systems Design and Implementation, USENIX, 2012, s. 279 – 292.
RIEBACK M.R., CRISPO B. i TANENBAUM A.S.: Is Your Cat Infected with a Computer Virus?,
Proc. Fourth IEEE Int’l Conf. on Pervasive Computing and Commun., IEEE, 2006, s. 169 – 179.
RITCHIE D.M. i THOMPSON K.: The UNIX Timesharing System, „Commun. of the ACM”, Vol. 17,
July 1974, s. 365 – 375.
RIVEST R.L., SHAMIR A. i ADLEMAN L.: On a Method for Obtaining Digital Signatures and Public
Key Cryptosystems, „Commun. of the ACM”, Vol. 21, February 1978, s. 120 – 126.
RIZZO L.: Netmap: A Novel Framework for Fast Packet I/O, Proc. USENIX Ann. Tech. Conf.,
USENIX, 2012.
ROBBINS A.: UNIX in a Nutshell, Sebastopol, CA: O’Reilly & Associates, 2005.
RODRIGUES E.R., NAV AUX P.O., PANETTA J. i MENDES C.L.: A New Technique for Data
Privatization in User-Level Threads and Its Use in Parallel Applications, Proc. 2010 Symp. on Applied
Computing, ACM, 2010, s. 2149 – 2154.
RODRIGUEZ-LUJAN I., BAILADOR G., SANCHEZ-AVILA C., HERRERO A. i VIDAL-DE-
-MIGUEL G.: Analysis of Pattern Recognition and Dimensionality Reduction Techniques for Odor
Biometrics, Vol. 52, November 2013, s. 279 – 289.
ROSCOE T., ELPHINSTONE K. i HEISER G.: Hype and Virtue, Proc. 11th Workshop on Hot
Topics in Operating Systems, USENIX, 2007, s. 19 – 24.
ROSENBLUM M. i GARFINKEL T.: Virtual Machine Monitors: Current Technology and Future
Trends, „Computer”, Vol. 38, May 2005, s. 39 – 47.
ROSENBLUM M. i OUSTERHOUT J.K.: The Design and Implementation of a Log-Structured
File System, Proc. 13th Symp. on Operating Systems Principles, ACM, 1991, s. 1 – 15.
ROSENBLUM M., BUGNION E., DEVINE S. i HERROD S.A.: Using the SIMOS Machine
Simulator to Study Complex Computer Systems, „ACM Trans. Model. Comput. Simul.”, Vol. 7, 1997,
s. 78 – 103.
ROSSBACH C.J., CURREY J., SILBERSTEIN M., RAY B. i WITCHEL E.: PTask: Operating
System Abstractions to Manage GPUs as Compute Devices, Proc. 23rd Symp. of Operating Systems
Principles, ACM, 2011, s. 233 – 248.
ROSSOW C., ANDRIESSE D., WERNER T., STONE-GROSS B., PLOHMANN D., DIE-
TRICH C.J. i BOS H.: SoK: P2PWNED — Modeling and Evaluating the Resilience of Peer-to-Peer
Botnets, Proc. IEEE Symp. on Security and Privacy, IEEE, 2013, s. 97 – 111.
ROZIER M., ABROSSIMOV V., ARMAND F., BOULE I., GIEN M., GUILLEMONT M.,
HERRMANN F., KAISER C., LEONARD P., LANGLOIS S. i NEUHAUSER W.: Chorus
Distributed Operating Systems, „Computing Systems”, Vol. 1, October 1988, s. 305 – 379.
RUSSINOVICH M. i SOLOMON D.: Windows Internals, Part 1, Redmond, WA: Microsoft Press,
2012.
RYZHYK L., CHUBB P., KUZ I., LE SUEUR E. i HEISER G.: Automatic Device Driver Synthesis
with Termite, Proc. 22nd Symp. on Operating Systems Principles, ACM, 2009.
RYZHYK L., KEYS J., MIRLA B., RAGNUNATH A., VIJ M. i HEISER G.: Improved Device
Driver Reliability through Hardware Verification Reuse, Proc. 16th Int’l Conf. on Arch. Support for
Prog. Lang. and Operating Systems, ACM, 2011, s. 133 – 134.
SACKMAN H., ERIKSON W.J. i GRANT E.E.: Exploratory Experimental Studies Comparing Online
and Offline Programming Performance, „Commun. of the ACM”, Vol. 11, January 1968, s. 3 – 11.
SAITO Y., KARAMANOLIS C., KARLSSON M. i MAHALINGAM M.: Taming Aggressive Repli-
cation in the Pangea Wide-Area File System, Proc. Fifth Symp. on Operating Systems Design and
Implementation, USENIX, 2002, s. 15 – 30.
SALOMIE T.-I., SUBASU I.E., GICEVA J. i ALONSO G.: Database Engines on Multicores: Why
Parallelize When You can Distribute?, Proc. Sixth European Conf. on Computer Systems (EuroSys),
ACM, 2011, s. 17 – 30.
SALTZER J.H. i KAASHOEK M.F.: Principles of Computer System Design: An Introduction,
Burlington, MA: Morgan Kaufmann, 2009.
SALTZER J.H. i SCHROEDER M.D.: The Protection of Information in Computer Systems, Proc.
IEEE, Vol. 63, September 1975, s. 1278 – 1308.
SALTZER J.H., REED D.P. i CLARK D.D.: End-to-End Arguments in System Design, „ACM Trans.
on Computer Systems”, Vol. 2, November 1984, s. 277 – 288.
SALTZER J.H.: Protection and Control of Information Sharing in MULTICS, „Commun. of the
ACM”, Vol. 17, July 1974, s. 388 – 402.
SALUS P.H.: UNIX At 25, „Byte”, Vol. 19, October 1994, s. 75 – 82.
SARHAN N.J. i DAS C.R.: Caching and Scheduling in NAD-Based Multimedia Servers, „IEEE
Trans. on Parallel and Distributed Systems”, Vol. 15, October 2004, s. 921 – 933.
SASSE M.A.: Red-Eye Blink, Bendy Shuffle i the Yuck Factor: A User Experience of Biometric Airport
Systems, „IEEE Security and Privacy”, Vol. 5, May – June 2007, s. 78 – 81.
SCHABER P., KOPF S., WESCH C. i EFFELSBERG W.: CamMark — A Camcorder Copy
Simulation as Watermarking Benchmark for Digital Video, Proc. Fifth Multimedia Systems Conf.,
ACM, 2014.
SCHEIBLE J.P.: A Survey of Storage Options, „Computer”, Vol. 35, December 2002, s. 42 – 46.
SCHINDLER J., SHETE S. i SMITH K.A.: Improving Throughput for Small Disk Requests with
Proximal I/O, Proc. Ninth USENIX Conf. on File and Storage Tech., USENIX, 2011, s. 133 – 148.
SCHWARTZ C., PRIES R. i TRAN-GIA P.: A Queuing Analysis of an Energy-Saving Mechanism
in Data Centers, Proc. 2012 Int’l Conf. on Inf. Networking, IEEE, 2012, s. 70 – 75.
SCOTT M., LEBLANC T. i MARSH B.: Multi-Model Parallel Programming in Psyche, Proc. Second
ACM Symp. on Principles and Practice of Parallel Programming, ACM, 1990, s. 70 – 78.
SEAWRIGHT L.H. i MACKINNON R.A.: VM/370 — A Study of Multiplicity and Usefulness,
„IBM Systems J.”, Vol. 18, 1979, s. 4 – 17.
SEREBRYANY K., BRUENING D., POTAPENKO A. i VYUKOV D.: AddressSanitizer: A Fast
Address Sanity Checker, Proc. USENIX Ann. Tech. Conf., USENIX, 2013, s. 28.
SEVERINI M., SQUARTINI S. i PIAZZA F.: An Energy Aware Approach for Task Scheduling in
Energy-Harvesting Sensor Nodes, Proc. Ninth Int’l Conf. on Advances in Neural Networks, Springer-
-Verlag, 2012, s. 601 – 610.
SHARMA N., KRISHAPPA D.K., IRWIN D., ZINK M. i SHENOY P.: GreenCache: Augmenting
Off-the-Grid Cellular Towers with Multimedia Caches, Proc. Fourth Multimedia Systems Conf.,
ACM, 2013, s. 271 – 280.
SHEN K., SHRIRAMAN A., DWARKADAS S., ZHANG X. i CHEN Z.: Power Containers: An
OS Facility for Fine-Grained Power and Energy Management on Multicore Servers, Proc. 18th Int’l
Conf. on Arc h. Support for Prog. Lang. and Operating Systems, ACM, 2013, s. 65 – 76.
SHENOY P.J. i VIN H.M.: Efficient Striping Techniques for Variable Bit Rate Continuous Media
File Servers, „Perf. Eval. J.”, Vol. 38, 1999, s. 175 – 199.
SILBERSCHATZ A., GALVIN P.B. i GAGNE G.: Operating System Concepts, 9th ed., Hoboken,
NJ: John Wiley & Sons, 2012.
SIMON R.J.: Windows NT Win32 API SuperBible, Corte Madera, CA: Sams Publishing, 1997.
SITARAM D. i DAN A.: Multimedia Servers, Burlington, MA: Morgan Kaufman, 2000.
SLOWINSKA A., STANESCU T. i BOS H.: Body Armor for Binaries: Preventing Buffer Overflows
Without Recompilation, Proc. USENIX Ann. Tech. Conf., USENIX, 2012.
SMALDONE S., WALLACE G. i HSU W.: Efficiently Storing Virtual Machine Backups, Proc. Fifth
USENIX Conf. on Hot Topics in Storage and File Systems, USENIX, 2013.
SMITH D.K. i ALEXANDER R.C.: Fumbling the Future: How Xerox Invented, Then Ignored, the First
Personal Computer, New York: William Morrow, 1988.
SMOLIC A.: Next Generation 3D Video Representation, Processing and Coding, Proc. Workshop on
Surreal Media and Virtual Cloning, ACM, 2010, s. 1 – 2.
SNIR M., OTTO S.W., HUSS-LEDERMAN S., WALKER D.W. i DONGARRA J.: MPI: The
Complete Reference Manual, Cambridge, MA: MIT Press, 1996.
SNOW K., MONROSE F., DAVI L., DMITRIENKO A., LIEBCHEN C. i SADEGHI A.-R.:
Just-In-Time Code Reuse: On the Effectiveness of Fine-Grained Address Space Layout Randomization,
Proc. IEEE Symp. on Security and Privacy, IEEE, 2013, s. 574 – 588.
SOBELL M.: A Practical Guide to Fedora and Red Hat Enterprise Linux, 7th ed., Upper Saddle River,
NJ: Prentice-Hall, 2014.
SOORTY B.: Evaluating IPv6 in Peer-to-peer Gigabit Ethernet for UDP Using Modern Operating
Systems, Proc. 2012 Symp. on Computers and Commun., IEEE, 2012, s. 534 – 536.
SPAFFORD E., HEAPHY K. i FERBRACHE D.: Computer Viruses, Arlington, VA: ADAPSO, 1989.
STALLINGS W.: Operating Systems, 7th ed., Upper Saddle River, NJ: Prentice Hall, 2011.
STAN M.R. i SKADRON K.: Power-Aware Computing, „Computer”, Vol. 36, December 2003,
s. 35 – 38.
STEINMETZ R. i NAHRSTEDT K.: Multimedia: Computing, Communications and Applications,
Upper Saddle River, NJ: Prentice Hall, 1995.
TANENBAUM A.S., HERDER J.N. i BOS H.: File Size Distribution on UNIX Systems: Then
and Now, „ACM SIGOPS Operating Systems Rev.”, Vol. 40, January 2006, s. 100 – 104.
TANENBAUM A.S., VAN RENESSE R., VAN STAVEREN H., SHARP G.J., MULLEN-
DER S.J., JANSEN J. i VAN ROSSUM G.: Experiences with the Amoeba Distributed Operating
System, „Commun. of the ACM”, Vol. 33, December 1990, s. 46 – 63.
TANG H., HUANG J. i WANG W.: A Novel Passive Worm Defense Model for Multimedia Sharing,
Proc. Research in Adaptive and Convergent Systems, ACM, 2013, s. 293 – 299.
TARASOV V., HILDEBRAND D., KUENNING G. i ZADOK E.: Virtual Machine Workloads: The
Case for New NAS Benchmarks, Proc. 11th Conf. on File and Storage Technologies, USENIX, 2013.
TEORY T.J.: Properties of Disk Scheduling Policies in Multiprogrammed Computer Systems, Proc.
AFIPS Fall Joint Computer Conf., AFIPS, 1972, s. 1 – 11.
THEODOROU D., MAK R.H., KEIJSER J.J. i SUERINK R.: NRS: A System for Automated
Network Virtualization in IAAS Cloud Infrastructures, Proc. Seventh Int’l Workshop on Virtuali-
zation Tech. in Distributed Computing, ACM, 2013, s. 25 – 32.
THIBADEAU R.: Trusted Computing for Disk Drives and Other Peripherals, IEEE Security and Privacy,
Vol. 4, September – October 2006, s. 26 – 33.
THOMPSON K.: Reflections on Trusting Trust, „Commun. of the ACM”, Vol. 27, August 1984,
s. 761 – 763.
TIMCENKO V. i DJORDJEVIC B.: The Comprehensive Performance Analysis of Striped Disk Array
Organizations — RAID-0, Proc. 2013 Int’l Conf. on Inf. Systems and Design of Commun., ACM,
2013, s. 113 – 116.
TRESADERN P., COOTES T., POH N., METEJKA P., HADID A., LEVY C., MCCOOL C.
i MARCEL S.: Mobile Biometrics: Combined Face and Voice Verification for a Mobile Platform,
IEEE Pervasive Computing, Vol. 12, January 2013, s. 79 – 87.
TSAFRIR D., ETSION Y., FEITELSON D.G. i KIRKPATRICK S.: System Noise, OS Clock Ticks
i Fine-Grained Parallel Applications, Proc. 19th Ann. Int’l Conf. on Supercomputing, ACM, 2005,
s. 303 – 312.
TUAN-ANH B., HUNG P.P. i HUH E.-N.: A Solution of Thin-Thick Client Collaboration for Data
Distribution and Resource Allocation in Cloud Computing, Proc. 2013 Int’l Conf. on Inf. Networking,
IEEE, 2013, s. 238 – 243.
TUCKER A. i GUPTA A.: Process Control and Scheduling Issues for Multiprogrammed Shared-Memory
Multiprocessors, Proc. 12th Symp. on Operating Systems Principles, ACM, 1989, s. 159 – 166.
UHLIG R., NAGLE D., STANLEY T., MUDGE T., SECREST S. i BROWN R.: Design Tra-
deoffs for Software-Managed TLBs, „ACM Trans. on Computer Systems”, Vol. 12, August 1994,
s. 175 – 205.
UHLIG R., NEIGER G., RODGERS D., SANTONI A.L., MARTINS F.C.M., ANDERSON
A.V., BENNET S.M., KAGI A., LEUNG F.H. i SMITH L.: Intel Virtualization Technology,
„Computer”, Vol. 38, 2005, s. 48 – 56.
UR B., KELLEY P.G., KOMANDURI S., LEE J., MAASS M., MAZUREK M.L., PASSARO T.,
SHAY R., VIDAS T., BAUER L., CHRISTIN N. i CRANOR L.F.: How Does Your Password
Measure Up? The Effect of Strength Meters on Password Creation, Proc. 21st USENIX Security
Symp., USENIX, 2012.
VAGHANI S.B.: Virtual Machine File System, „ACM SIGOPS Operating Systems Rev.”, Vol. 44,
2010, s. 57 – 70.
VAHALIA U.: UNIX Internals — The New Frontiers, Upper Saddle River, NJ: Prentice Hall, 2007.
VAN ’T NOORDENDE G., BALOGH A., HOFMAN R., BRAZIER F.M.T. i TANENBAUM
A.S.: A Secure Jailing System for Confining Untrusted Applications, Proc. Second Int’l Conf. on
Security and Cryptography, INSTICC, 2007, s. 414 – 423.
VAN DER VEEN V., DUTT-SHARMA N., CAVALLARO L. i BOS H.: Memory Errors: The
Past, the Present i the Future, Proc. 15th Int’l Conf. on Research in Attacks, Intrusions i Defenses,
Berlin: Springer-Verlag, 2012, s. 86 – 106.
VAN DOORN L.: The Design and Application of an Extensible Operating System, Capelle a/d Ijssel:
Labyrint Publications, 2001.
VAN MOOLENBROEK D.C., APPUSWAMY R. i TANENBAUM A.S.: Integrated System and
Process Crash Recovery in the Loris Storage Stack, Proc. Seventh Int’l Conf. on Networking, Arc
hitecture i Storage, IEEE, 2012, s. 1 – 10.
VASWANI R. i ZAHORJAN J.: The Implications of Cache Affinity on Processor Scheduling for
Multiprogrammed Shared-Memory Multiprocessors, Proc. 13th Symp. on Operating Systems Principles,
ACM, 1991, s. 26 – 40.
VENKATA CHALAM V. i FRANZ M.: Power Reduction Techniques for Microprocessor Systems,
„Computing Surveys”, Vol. 37, September 2005, s. 195 – 237.
VIENNOT N., NAIR S. i NIEH J.: Transparent Mutable Replay for Multicore Debugging and Patch Vali-
dation, Proc. 18th Int’l Conf. on Arc h. Support for Prog. Lang. and Operating Systems, ACM, 2013.
VINOSKI S.: CORBA: Integrating Diverse Applications within Distributed Heterogeneous Environments,
„IEEE Communications Magazine”, Vol. 35, February 1997, s. 46 – 56.
VISCAROLA P.G., MASON T., CARIDDI M., RYAN B. i NOONE S.: Introduction to the Windows
Driver Foundation Kernel-Mode Framework, Amherst, NH: OSR Press, 2007.
VMWARE, Inc.: Achieving a Million I/O Operations per Second from a Single VMware vSphere 5.0
Host, http://www.vmware.com/files/pdf/1M-iops-perf-vsphere5.pdf, 2011.
VOGELS W.: File System Usage in Windows NT 4.0, Proc. 17th Symp. on Operating Systems Principles,
ACM, 1999, s. 93 – 109.
VON BEHREN R., CONDIT J. i BREWER E.: Why Events Are A Bad Idea (for High-Concurrency
Servers), Proc. Ninth Workshop on Hot Topics in Operating Systems, USENIX, 2003, s. 19 – 24.
VON EICKEN T., CULLER D., GOLDSTEIN S.C. i SCHAUSER K.E.: Active Messages:
A Mechanism for Integrated Communication and Computation, Proc. 19th Int’l Symp. on Computer
Arch., ACM, 1992, s. 256 – 266.
VOSTOKOV D.: Windows Device Drivers: Practical Foundations, Opentask, 2009.
VRABLE M., SAVAGE S. i VOELKER G.M.: BlueSky: A Cloud-Backed File System for the Enterprise,
Proc. 10th USENIX Conf. on File and Storage Tech., USENIX, 2012, s. 124 – 250.
WAHBE R., LUCCO S., ANDERSON T. i GRAHAM S.: Efficient Software-Based Fault Isolation,
Proc. 14th Symp. on Operating Systems Principles, ACM, 1993, s. 203 – 216.
WALDSPURGER C.A. i ROSENBLUM M.: I/O Virtualization, „Commun. of the ACM”, Vol. 55,
2012, s. 66 – 73.
WALDSPURGER C.A. i WEIHL W.E.: Lottery Scheduling: Flexible Proportional-Share Resource
Management, Proc. First Symp. on Operating Systems Design and Implementation, USENIX, 1994,
s. 1 – 12.
WALDSPURGER C.A.: Memory Resource Management in VMware ESX Server, ACM SIGOPS
Operating System Rev., Vol. 36, January 2002, s. 181 – 194.
WALKER W. i CRAGON H.G.: Interrupt Processing in Concurrent Processors, „Computer”, Vol. 28,
June 1995, s. 36 – 46.
WALLACE G., DOUGLIS F., QIAN H., SHILANE P., SMALDONE S., CHAMNESS M.
i HSU W.: Characteristics of Backup Workloads in Production Systems, Proc. 10th USENIX Conf.
on File and Storage Tech., USENIX, 2012, s. 33 – 48.
WANG L., KHAN S.U., CHEN D., KOLODZIEJ J., RANJAN R., XU C.-Z. i ZOMAYA A.:
Energy-Aware Parallel Task Scheduling in a Cluster, Future Generation Computer Systems, Vol. 29,
September 2013b, s. 1661 – 1670.
WANG X., TIPPER D. i KRISHNAMURTHY P.: Wireless Network Virtualization, Proc. 2013
Int’l Conf. on Computing, Networking i Commun., IEEE, 2013a, s. 818 – 822.
WANG Y. i LU P.: DDS: A Deadlock Detection-Based Scheduling Algorithm for Workflow Computations
in HPC Systems with Storage Constraints, „Parallel Comput.”, Vol. 39, August 2013, s. 291 – 305.
WATSON R., ANDERSON J., LAURIE B. i KENNAW AY K.: A Taste of Capsicum: Practical
Capabilities for UNIX, „Commun. of the ACM”, Vol. 55, March 2013, s. 97 – 104.
WEI M., GRUPP L., SPADA F.E. i SWANSON S.: Reliably Erasing Data from Flash-Based Solid
State Drives, Proc. Ninth USENIX Conf. on File and Storage Tech., USENIX, 2011, s. 105 – 118.
WEI Y.-H., YANG C.-Y., KUO T.-W., HUNG S.-H. i CHU Y.-H.: Energy-Efficient Real-Time
Scheduling of Multimedia Tasks on Multi-core Processors, Proc. 2010 Symp. on Applied Computing,
ACM, 2010, s. 258 – 262.
WEISER M., WELCH B., DEMERS A. i SHENKER S.: Scheduling for Reduced CPU Energy,
Proc. First Symp. on Operating Systems Design and Implementation, USENIX, 1994, s. 13 – 23.
WEISSEL A.: Operating System Services for Task-Specific Power Management: Novel Approaches to
Energy-Aware Embedded Linux, AV Akademikerverlag, 2012.
WENTZLAFF D., GRUENWALD III C., BECKMANN N., MODZELEWSKI K., BELAY A.,
YOUSEFF L., MILLER J. i AGARWAL A.: An Operating System for Multicore and Clouds:
Mechanisms and Implementation, Proc. Cloud Computing, ACM, June 2010.
WENTZLAFF D., JACKSON C.J., GRIFFIN P. i AGARWAL A.: Configurable Finegrain Pro-
tection for Multicore Processor Virtualization, Proc. 39th Int’l Symp. on Computer Arch., ACM,
2012, s. 464 – 475.
WHITAKER A., COX R.S., SHAW M. i GRIBBLE S.D.: Rethinking the Design of Virtual Machine
Monitors, „Computer”, Vol. 38, May 2005, s. 57 – 62.
WHITAKER A., SHAW M. i GRIBBLE S.D.: Scale and Performance in the Denali Isolation Kernel,
„ACM SIGOPS Operating Systems Rev.”, Vol. 36, January 2002, s. 195 – 209.
WILLIAMS D., JAMJOOM H. i WEATHERSPOON H.: The Xen-Blanket: Virtualize Once, Run
Everywhere, Proc. Seventh European Conf. on Computer Systems (EuroSys), ACM, 2012.
WIRTH N.: A Plea for Lean Software, „Computer”, Vol. 28, February 1995, s. 64 – 68.
WONG C.K.: Algorithmic Studies in Mass Storage Systems, New York: Computer Science Press, 1983.
WU N., ZHOU M. i HU U.: One-Step Look-Ahead Maximally Permissive Deadlock Control of AMS by
Using Petri Nets, „ACM Trans. Embed. Comput. Syst.”, Vol. 12, Art. 10, January 2013, s. 10:1 – 10:23.
WULF W.A., COHEN E.S., CORWIN W.M., JONES A.K., LEVIN R., PIERSON C. i POL-
LACK F.J.: HYDRA: The Kernel of a Multiprocessor Operating System, „Commun. of the ACM”,
Vol. 17, June 1974, s. 337 – 345.
YANG J., TWOHEY P., ENGLER D. i MUSUVATHI M.: Using Model Checking to Find Serious
File System Errors, „ACM Trans. on Computer Systems”, Vol. 24, 2006, s. 393 – 423.
YEH T. i CHENG W.: Improving Fault Tolerance through Crash Recovery, Proc. 2012 Int’l Symp. on
Biometrics and Security Tech., IEEE, 2012, s. 15 – 22.
YOUNG M., TEVANIAN A. Jr., RASHID R., GOLUB D., EPPINGER J., CHEW J., BO-
LOSKY W., BLACK D. i BARON R.: The Duality of Memory and Communication in the Imple-
mentation of a Multiprocessor Operating System, Proc. 11th Symp. on Operating Systems Principles,
ACM, 1987, s. 63 – 76.
YUAN D., LEWANDOWSKI C. i CROSS B.: Building a Green Unified Computing IT Laboratory
through Virtualization, J. Computing Sciences in Colleges, Vol. 28, June 2013, s. 76 – 83.
YUAN J., JIANG X., ZHONG L. i YU H.: Energy Aware Resource Scheduling Algorithm for Data
Center Using Reinforcement Learning, Proc. Fifth Int’l Conf. on Intelligent Computation Tech. and
Automation, IEEE, 2012, s. 435 – 438.
YUAN W. i NAHRSTEDT K.: Energy-Efficient CPU Scheduling for Multimedia Systems, „ACM Trans.
on Computer Systems”, ACM, Vol. 24, August 2006, s. 292 – 331.
ZACHARY G.P.: Showstopper, New York: Maxwell Macmillan, 1994.
ZAHORJAN J., LAZOWSKA E.D. i EAGER D.L.: The Effect of Scheduling Discipline on Spin
Overhead in Shared Memory Parallel Systems, „IEEE Trans. on Parallel and Distr. Systems”,
Vol. 2, April 1991, s. 180 – 198.
ZAIA A., BRUNEO D. i PULIAFITO A.: A Scalable Grid-Based Multimedia Server, Proc. 13th IEEE
Int’l Workshop on Enabling Technologies: Infrastructure for Collaborative Enterprises, IEEE, 2004, s.
337 – 342.
ZEKAUSKAS M.J., SAWDON W.A. i BERSHAD B.N.: Software Write Detection for a Distributed
Shared Memory, Proc. First Symp. on Operating Systems Design and Implementation, USENIX,
1994, s. 87 – 100.
ZHANG C., WEI T., CHEN Z., DUAN L., SZEKERES L., MCCAMANT S., SONG D. i ZOU W.:
Practical Control Flow Integrity and Randomization for Binary Executables, Proc. IEEE Symp. on
Security and Privacy, IEEE, 2013b, s. 559 – 573.
ZHANG F., CHEN J., CHEN H. i ZANG B.: CloudVisor: Retrofitting Protection of Virtual Machines
in Multi-Tenant Cloud with Nested Virtualization, Proc. 23rd Symp. on Operating Systems Principles,
ACM, 2011.
ZHANG M. i SEKAR R.: Control Flow Integrity for COTS Binaries, Proc. 22nd USENIX Security
Symp., USENIX, 2013, s. 337 – 352.
ZHANG X., DAVIS K. i JIANG S.: iTransformer: Using SSD to Improve Disk Scheduling for High-
-Performance I/O, Proc. 26th Int’l Parallel and Distributed Processing Symp., IEEE, 2012b, s. 715 – 726.
ZHANG Y., LIU J. i KANDEMIR M.: Software-Directed Data Access Scheduling for Reducing Disk
Energy Consumption, Proc. 32nd Int’l Conf. on Distributed Computer Systems, IEEE, 2012a,
s. 596 – 605.
ZHANG Y., SOUNDARARAJAN G., STORER M.W., BAIRAVASUNDARAM L., SUBBIAH S.,
ARPACI-DUSSEAU A.C. i ARPACI-DUSSEAU R.H.: Warming Up Storage-Level Caches with
Bonfire, Proc. 11th Conf. on File and Storage Technologies, USENIX, 2013a.
ZHENG H., ZHANG X., WANG E., WU N. i DONG X.: Achieving High Reliability on Linux for
K2 System, Proc. 11th Int’l Conf. on Computer and Information Science, IEEE, 2012, s. 107 – 112.
ZHOU B., KULKARNI M. i BAGCHI S.: ABHRANTA: Locating Bugs that Manifest at Large System
Scales, Proc. Eighth USENIX Workshop on Hot Topics in System Dependability, USENIX, 2012.
ZHURAVLEV S., SAEZ J.C., BLAGODUROV S., FEDOROVA A. i PRIETO M.: Survey of
scheduling techniques for addressing shared resources in multicore processors, „Computing Surveys”,
ACM, Vol. 45, No. 1, Art. 4, 2012.
ZOBEL D.: The Deadlock Problem: A Classifying Bibliography, ACM SIGOPS Operating Systems Rev.,
Vol. 17, October 1983, s. 6 – 16.
ZUBERI K.M., PILLAI P. i SHIN K.G.: EMERALDS: A Small-Memory Real-Time Microkernel,
Proc. 17th Symp. on Operating Systems Principles, ACM, 1999, s. 277 – 299.
ZWICKY E.D.: Torture-Testing Backup and Archive Programs: Things You Ought to Know But
Probably Would Rather Not, Proc. Fifth Conf. on Large Installation Systems Admin., USENIX,
1991, s. 181 – 190.
Filmy cyfrowe, klipy wideo i muzyka są coraz częstszym sposobem prezentowania informacji
w komputerach. Pliki audio i wideo można zapisywać na dysku i odtwarzać na żądanie. Jednak
ich charakterystyki znacznie się różnią od tradycyjnych plików tekstowych, pod kątem których
zaprojektowano współczesne systemy operacyjne. W konsekwencji potrzebne są nowe rodzaje
systemów plików do ich obsługi. Co więcej, przechowywanie i odtwarzanie plików audio i wideo
nakłada nowe żądania na program szeregujący, a także na inne części systemu operacyjnego.
W tym rozdziale przeanalizujemy wiele z problemów wymienionych powyżej oraz opowiemy, jakie
mają implikacje dla systemów operacyjnych zaprojektowanych w celu obsługi multimediów.
Pod nazwą multimedia, która dokładnie oznacza „więcej niż jedno medium”, z reguły rozumie
się filmy cyfrowe. Według tej definicji niniejsza książka jest pracą multimedialną. W końcu zawiera
dwa media: tekst i rysunki. Większość osób używa jednak terminu „multimedia” w znaczeniu
dokumentu zawierającego dwa lub więcej ciągłych mediów, czyli takich, które muszą być odtwa-
rzane w pewnym przedziale czasu. W niniejszej książce będziemy używać terminu „multimedia”
w tym sensie.
Innym terminem, który wydaje się nieco dwuznaczny, jest „wideo”. W sensie technicznym
to po prostu obraz filmu (w odróżnieniu od dźwięku). Kamery wideo i odbiorniki telewizyjne
zwykle są wyposażone w dwa złącza: jedno oznaczone etykietą „wideo” i drugie oznaczone jako
„audio”. Jest tak dlatego, że te dwa sygnały są oddzielne. Jednak termin „cyfrowy film wideo”
zwykle odnosi się do kompletnego produktu — zawierającego i dźwięk, i obraz. Poniżej będziemy
używać terminu „film” w odniesieniu do kompletnego produktu. Warto zwrócić uwagę, że w tym
sensie pod pojęciem „film” nie musi być rozumiany dwugodzinny film wyprodukowany w hol-
lywoodzkim studio i kosztujący więcej niż Boeing 747. Według naszej definicji, 30-sekundowy
klip przesłany przez internet ze strony serwisu CNN, także jest filmem. Bardzo krótkie filmy
są również określane terminem klipy wideo.
1069
usługi jest olbrzymia. Aby możliwe było skorzystanie z techniki wideo na żądanie, potrzebna jest
specjalna infrastruktura. Dwie propozycje infrastruktury technologii „wideo na żądanie” poka-
zano na rysunku A.1. Każda składa się z trzech zasadniczych komponentów: jednego lub kilku
serwerów wideo, sieci dystrybucji oraz przystawki STB (od ang. Set Top Box), podłączanej do
odbiornika telewizyjnego w każdym domu w celu umożliwienia dekodowania sygnału. Serwer
wideo jest mocnym komputerem, pozwalającym na przechowywanie w swoim systemie plików
wielu filmów i odtwarzanie ich na żądanie. Czasami w roli serwerów wideo wykorzystywane są
komputery mainframe, ponieważ podłączenie np. 1000 dużych dysków do komputera mainframe
jest proste, podczas gdy podłączenie 1000 dysków do komputera osobistego dowolnego typu sta-
nowi poważny problem. Większość materiału w kolejnych punktach niniejszego rozdziału dotyczy
serwerów wideo i ich systemów operacyjnych.
Rysunek A.1. W technologii wideo na żądanie stosowanych jest wiele różnych technologii
dystrybucji; (a) ADSL; (b) telewizja kablowa
Sieć dystrybucji pomiędzy użytkownikiem a serwerem wideo musi być zdolna do szybkiego
przesyłania danych w czasie rzeczywistym. Projekt takiej sieci jest interesujący i złożony, ale
wykracza poza zakres niniejszej książki. Nie powiemy o nich nic więcej oprócz tego, że w tej
sieci zawsze stosuje się światłowody od serwera wideo do punktów dystrybucji zlokalizowanych
w pobliżu miejsc zamieszkania klientów. W systemach ADSL, obsługiwanych przez firmy telefo-
niczne, istniejące kable telefoniczne zapewniają transmisję mniej więcej na ostatnim kilometrze.
W systemach ADSL, obsługiwanych przez firmy telefoniczne, istniejące kable telefoniczne
zapewniają transmisję mniej więcej na ostatnim kilometrze. W systemach telewizji kablowej,
dostarczanych przez operatorów sieci kablowych, do lokalnej dystrybucji wykorzystywane jest ist-
niejące okablowanie telewizyjne. Technika ADSL ma tę przewagę, że oferuje każdemu użytkow-
nikowi dedykowany kanał, a tym samym gwarantowane pasmo. Przepustowość jest jednak niska
(co najwyżej 100 Mb/s) z powodu ograniczeń istniejących kabli telefonicznych. W sieci telewizji
kablowych stosuje się kabel koncentryczny o wysokiej przepustowości (rzędu gigabitów na
sekundę). Jednak w tych systemach wielu użytkowników jest zmuszonych do współdzielenia
tego samego kabla, przez co powstaje konieczność rywalizacji i żaden z indywidualnych użyt-
kowników nie ma gwarantowanego pasma. Jednak w ramach rywalizacji z firmami kablowymi
firmy telefoniczne zaczynają doprowadzać światłowody do indywidualnych domów. W takim przy-
padku ADSL przez światłowód może zagwarantować znacznie większą przepustowość niż przez
kabel.
Ostatni element systemu stanowi przystawka STB. Do niej podłącza się kabel ADSL lub kabel
telewizyjny. Urządzenie to jest w istocie zwykłym komputerem wyposażonym w kilka specjal-
nych układów przeznaczonych do dekodowania i kompresji wideo. W minimalnej konfiguracji
zawiera ono procesor, pamięć RAM i ROM, interfejs do łącza ADSL lub kablowego oraz złącze do
odbiornika telewizyjnego.
Alternatywą dla stosowania przystawki STB jest wykorzystanie istniejącego komputera PC
klienta i wyświetlanie filmu na monitorze. Interesujący jest powód, dla którego bierze się pod
uwagę przystawki STB, choć przecież większość klientów ma komputery. Otóż operatorzy
usługi wideo na żądanie spodziewają się, że użytkownicy chcą oglądać filmy w salonach, gdzie
zazwyczaj jest telewizor, ale rzadko jest komputer. Z technicznego punktu widzenia wykorzy-
stanie komputera osobistego zamiast przystawki STB jest znacznie bardziej sensowne, ponieważ
komputer ma większą moc obliczeniową, duży dysk oraz monitor o znacznie większej rozdziel-
czości. Tak czy owak, często będziemy oddzielać serwer wideo od procesu klienta w lokalizacji
użytkownika, który dekoduje i wyświetla film. Jednak z perspektywy projektu systemu nie ma
znaczenia, czy proces klienta działa na przystawce STB, czy na komputerze PC. W przypadku
systemów edycji wideo w komputerach desktop wszystkie procesy działają na tej samej maszynie.
Pomimo to będziemy w dalszym ciągu używać terminologii serwer i klient, aby w czytelny spo-
sób podkreślić, który komponent za co jest odpowiedzialny.
Wróćmy do samych multimediów — mają one dwie cechy, które trzeba dobrze zrozumieć,
aby można było się nimi prawidłowo posługiwać:
1. W multimediach stosuje się bardzo duże szybkości przesyłania danych.
2. Multimedia wymagają odtwarzania w czasie rzeczywistym.
Wysokie prędkości danych wywodzą się z natury informacji wizualnych i akustycznych. Oko i ucho
jest w stanie przetwarzać niezwykłe ilości informacji na sekundę. Trzeba je dostarczać z taką
szybkością, aby było możliwe uzyskanie akceptowalnego komfortu oglądania. Szybkości prze-
syłania danych dla kilku cyfrowych źródeł multimedialnych oraz popularnych urządzeń sprzęto-
wych zestawiono w tabeli A.1. Niektóre z tych formatów kodowania omówimy w dalszej części
niniejszego dodatku. Na szczególną uwagę zasługują wysokie szybkości przesyłania danych wyma-
gane przez multimedia, konieczność kompresji oraz wymagana ilość miejsca. Przykładowo nie-
skompresowany 2-godzinny film HDTV (nawet w rozdzielczości niższej od maksymalnej) wypełnia
plik o rozmiarach 570 GB. Serwer wideo, który przechowuje 1000 takich filmów, potrzebuje
570 TB miejsca na dysku. Warto również zwrócić uwagę, że bez kompresji bieżący sprzęt nie
jest w stanie dotrzymać szybkości przesyłania danych. Tematyką kompresji wideo zajmiemy się
w dalszej części niniejszego rozdziału.
Tabela A.1. Szybkości przesyłania danych wybranych urządzeń wejścia-wyjścia; format wideo
720p ma rozdzielczość 1280×720, format wideo 1080p ma rozdzielczość 1920×1080, a format
wideo 4K to rozdzielczość 3840×2160; standard NTSC charakteryzuje się szybkością 29,97 ramek
na sekundę; tutaj przyjęliśmy przelicznik 24 bitów na piksel; szybkości dla surowych danych
odpowiadają nieskompresowanemu strumieniowi wideo; zwróćmy uwagę, że 1 Mb/s to 106 bitów/s,
natomiast 1 GB to 230 bajtów
Źródło Mb/s GB/h Urządzenie Mb/s
Telefon (PCM) 0,064 0,03 Sieć Fast Ethernet 100
Muzyka MP3 0,14 0,06 Dysk EIDE 133
Płyta CD audio 1,4 0,62 Sieć ATM OC-3 156
Film MPEG-2 (640×480) 4 1,76 IEEE 1394b (FireWire) 800
Surowy strumień wideo NTSC 720p 664 291 Sieć Gigabit Ethernet 1000
Surowy strumień wideo NTSC 1080p 1491 655 Dysk SCSI Ultra-640 5120
Surowy strumień wideo 4K przesyłany 11 944 5249 Dysk SATA 3.0 6000
z szybkością 60 ramek na sekundę
w 99% na poziomie 105 – 110 ms oraz współczynnikiem strat bitów 10−10. Takie parametry są
wystarczające do wyświetlania filmów MPEG-2. Operator mógłby również zaoferować tańszą
usługę, nieco niższej klasy o przeciętnej przepustowości 1 Mb/s (np. ADSL). W takiej sytuacji
jakość byłaby nieco obniżona — m.in. ze względu na niższą rozdzielczość, niższą szybkość
przesyłania ramek lub rezygnację z kolorów i wyświetlanie filmów czarno-białych.
Najpopularniejszym sposobem zapewnienia gwarancji jakości usług jest zarezerwowanie
zasobów z góry dla każdego nowego klienta. Do zarezerwowanych zasobów należy kwant mocy
procesora, buforów pamięci, możliwości transferu dyskowego oraz przepustowości sieci. Jeśli
nowy klient zgłasza się do usługodawcy i prosi o opcje potrzebne do oglądania filmów, a serwer
wideo lub sieć oblicza, że nie ma wystarczających możliwości do obsłużenia nowego klienta,
usługodawca jest zmuszony odmówić nowemu klientowi. Dzięki temu unika degradacji usług
dostarczanych do bieżących klientów. W konsekwencji serwery multimedialne potrzebują mecha-
nizmów rezerwacji zasobów oraz algorytmu sterowania przyjmowaniem zgłoszeń. Dzięki nim mogą
stwierdzić, czy są w stanie wykonać więcej pracy.
W większości systemów zwykły plik tekstowy składa się z liniowej sekwencji bajtów bez żadnej
struktury, o której wiedziałby system operacyjny. W przypadku multimediów sytuacja jest bar-
dziej skomplikowana. Przede wszystkim strumienie wideo i audio są całkowicie różne. Są prze-
chwytywane przez różne urządzenia (wideo: układy CCD; audio: mikrofon), mają inną strukturę
wewnętrzną (wideo jest przesyłane z szybkością 25 – 30 ramek/s; audio z szybkością 44 100 pró-
bek/s) oraz są odtwarzane przez różne urządzenia (wideo: monitor; audio: głośniki).
Co więcej, większość filmów produkowanych w Hollywood tworzy się z myślą o odbiorcach
na całym świecie. Większość tych osób nie mówi po angielsku. Drugi problem jest rozwiązywany
na jeden z dwóch sposobów. W przypadku niektórych krajów tworzona jest dodatkowa ścieżka
dźwiękowa. Podkłada się dubbing w lokalnym języku (ale efekty dźwiękowe pozostają bez
zmian). W Japonii wszystkie odbiorniki telewizyjne są wyposażone w dwa kanały dźwiękowe.
Dzięki temu widzowie mogą oglądać zagraniczne filmy w oryginalnym języku albo po japońsku.
Do wyboru języka wykorzystywany jest przycisk na pilocie. Jeszcze w innych krajach wykorzy-
stywana jest oryginalna ścieżka dźwiękowa oraz napisy w lokalnym języku.
Poza tym w wielu filmach wideo dodatkowo są dostępne podpisy w języku oryginalnym.
Pozwala to oglądać film osobom z wadami słuchu posługującym się oryginalnym językiem filmu.
W efekcie film cyfrowy może składać się z wielu plików: jednego pliku wideo, wielu plików audio
oraz wielu plików tekstowych z napisami w różnych językach. Płyty DVD pozwalają na zapisa-
nie do 32 wersji językowych i plików z napisami. Prosty zbiór plików multimedialnych pokazano
na rysunku A.2. Znaczenie funkcji szybkiego przewijania w przód i szybkiego przewijania wstecz
omówimy w dalszej części niniejszego dodatku.
W konsekwencji system plików musi utrzymywać wiele plików pomocniczych dla jednego pliku
głównego. Jednym z możliwych mechanizmów jest zarządzanie każdym plikiem pomocniczym, tak
jak tradycyjnym plikiem (np. z wykorzystaniem i-węzła do śledzenia bloków), oraz utrzymywanie
nowej struktury danych, która wyświetla wszystkie pliki pomocnicze dla pliku multimedialnego.
Drugi sposób polega na stworzeniu czegoś w rodzaju dwuwymiarowego i-węzła, w którym w każ-
dej kolumnie są wyszczególnione bloki każdego z plików pomocniczych. Ogólnie rzecz biorąc,
organizacja danych powinna być taka, aby widz mógł dynamicznie wybierać używaną ścieżkę
dźwiękową i wersję napisów w czasie wyświetlania filmu.
W każdym przypadku potrzebny jest również pewien sposób synchronizacji plików pomocni-
czych, tak aby odtwarzana wybrana ścieżka dźwiękowa była zsynchronizowana z obrazem wideo.
Jeśli ścieżka audio i obraz wideo rozsynchronizują się, widz będzie słyszał słowa aktora przed lub
po tym, kiedy aktor poruszy ustami. Taka sytuacja jest łatwa do wykrycia i dość denerwująca.
Aby lepiej zrozumieć sposób organizacji plików multimedialnych, należy nieco dokładniej
przeanalizować zagadnienia związane z cyfrowymi strumieniami audio i wideo. Poniżej zamie-
ścimy wprowadzenie w tę tematykę.
Rysunek A.3. Wzorzec skanowania używany dla wideo i telewizji w standardzie NTSC
29,97) ramek/s. Europejskie systemy PAL i SECAM mają 625 linii skanowania, ten sam współ-
czynnik kształtu wynoszący 4:3 oraz 25 ramek/s. W obydwu systemach kilka górnych i kilka dol-
nych linii nie jest wyświetlanych (w celu przybliżenia prostokątnego obrazu na pierwszych, okrą-
głych monitorach CRT). Wyświetla się tylko 483 spośród 525 linii skanowania NTSC (oraz 576
spośród 625 PAL/SECAM linii skanowania).
O ile 25 ramek/s wystarcza do płynnego przechwytywania ruchu, o tyle przy tej szybkości
wyświetlania ramek wiele osób, zwłaszcza starszych, będzie dostrzegało migotanie obrazu (ponie-
waż stary obraz zniknął z siatkówki, zanim pojawił się nowy). Ponieważ zwiększanie szybkości
przesyłania ramek wymagałoby użycia większej ilości pasma (które jest zasobem deficytowym),
stosowane jest inne podejście. Zamiast wyświetlać linie skanowania po kolei, od góry do dołu,
najpierw wyświetla się wszystkie nieparzyste linie skanowania, a następnie linie parzyste. Każda
taka półramka nosi nazwę pola. Doświadczenia pokazały, że choć ludzie widzą migotanie przy
szybkości 25 ramek/s, nie zauważają go przy szybkości 50 pól/s. Technikę tę nazywa się prze-
plataniem (ang. interlacing). Odbiorniki telewizyjne lub wideo bez przeplotu określa się jako
progresywne.
Dla kolorowych filmów wideo stosuje się ten sam wzorzec skanowania, co dla monochroma-
tycznych (czarno-białych), z tą różnicą, że zamiast wyświetlać obraz za pomocą jednego rucho-
mego strumienia, stosuje się trzy strumienie. Dla każdego spośród trzech addytywnych kolorów
podstawowych: czerwonego, zielonego i niebieskiego (Red, Green, Blue — RGB), wykorzysty-
wany jest jeden strumień. Technika ta działa dlatego, że każdy kolor można skonstruować za
pomocą liniowej superpozycji kolorów czerwonego, zielonego i niebieskiego o odpowiedniej
intensywności. Jednak w przypadku transmisji w jednym kanale trzy sygnały kolorów trzeba
połączyć w pojedynczy sygnał zespolony (ang. composite).
Aby było możliwe przeglądanie sygnałów telewizji kolorowej na odbiornikach czarno-białych,
we wszystkich trzech systemach sygnały RGB są liniowo łączone ze sobą i tworzą sygnał lumi-
nancji (jasności) oraz dwa sygnały chrominancji (koloru). Każdy z systemów wykorzystuje jednak
różne współczynniki tworzenia tych sygnałów na podstawie sygnałów RGB. Co dziwne, oko jest
znacznie bardziej czułe na sygnały luminancji niż na sygnały chrominancji, dlatego te drugie nie
muszą być przesyłane tak dokładnie. W konsekwencji sygnały luminancji mogą być transmi-
towane z tą samą częstotliwością co stare sygnały czarno-białe. Dzięki temu można je odbierać na
czarno-białych odbiornikach. Dwa sygnały chrominancji są przesyłane w wąskim paśmie, z wyż-
szymi częstotliwościami. Niektóre odbiorniki telewizyjne są wyposażone w gałki lub suwaki
opisane jako jasność (ang. brightness), odcień (ang. hue lub tint) i nasycenie lub kolor (ang. satu-
ration lub color). Umożliwiają one osobne sterowanie tymi trzema sygnałami. Zrozumienie pojęć
luminancji i chrominancji jest konieczne do tego, aby pojąć, jak działa kompresja wideo.
Do tej pory koncentrowaliśmy się na analogowych sygnałach wideo. Przyjrzyjmy się teraz
sygnałom cyfrowym. Najprostszą reprezentacją cyfrowego wideo jest sekwencja ramek. Każda
z nich składa się z prostokątnej siatki elementów obrazu, zwanych pikselami. W przypadku kolo-
rowego sygnału wideo wykorzystuje się 8 bitów na piksel dla każdego z kolorów RGB, co daje
224 ≈ 16 milionów kolorów. Taka liczba jest wystarczająca, ludzkie oko i tak nie jest w stanie
rozróżnić tak wielu kolorów. Większą ich liczbę trudno sobie nawet wyobrazić.
Aby można było uzyskać płynny ruch, cyfrowy sygnał wideo musi być wyświetlany z szyb-
kością co najmniej 25 ramek/s. Ponieważ jednak monitory komputerowe dobrej jakości często
skanują ekrany z obrazów zapisanych w pamięci RAM wideo, z szybkością 75 razy na sekundę
lub większą, przeplot nie jest potrzebny. W konsekwencji we wszystkich monitorach kompute-
rowych stosowane jest skanowanie progresywne. Do wyeliminowania migotania wystarczy wykre-
ślenie tej samej ramki trzy razy z rzędu.
Inaczej mówiąc, płynność obrazu jest określona przez liczbę różnych obrazów na sekundę,
natomiast migotanie jest określone przez to, ile razy ekran jest wykreślany w ciągu sekundy.
Te dwa parametry różnią się między sobą. Ciągły obraz wykreślany z szybkością 20 ramek/s
będzie płynny, ale będzie migotał, ponieważ jedna ramka znika z siatkówki, zanim druga się na niej
pojawi. Film, który wyświetla 20 różnych ramek na godzinę, z których każda jest wykreślana
cztery razy z rzędu z częstotliwością 80 Hz, nie będzie migotał, ale ruch nie będzie płynny.
Znaczenie tych dwóch parametrów staje się jasne, jeśli weźmiemy pod uwagę przepustowość
wymaganą do transmisji cyfrowego sygnału wideo w sieci. Wiele monitorów komputerowych sto-
suje współczynnik kształtu 4:3. Dzięki temu mogą one wykorzystywać produkowane masowo
monitory produkowane na rynek telewizji konsumenckiej. Popularne konfiguracje to 640×480
(VGA), 800×600 (SVGA), 1024×768 (XGA), 1600×1200 (UXGA) oraz 4096×3072 (HXGA).
Obecnie wiele monitorów korzysta również z innych współczynników proporcji — np. 16:9.
Przykłady to: 1280×720 (HD), 1920×1080 (Full HD), 2160×3840 (Ultra HD lub 4K) oraz
4096×2160 (DCI — od ang. Digital Cinema Initiatives). Zwróćmy uwagę, że rozdzielczości 4K
oraz DCI™ są prawie takie same, ale przekonanie producenta z branży komputerowej (w pół-
nocnej Kalifornii) i przedstawiciela przemysłu filmowego (w południowej Kalifornii) do porozu-
mienia w sprawie jednolitego standardu to o jeden most za daleko. W konsekwencji w przypadku
wyświetlania filmów w formacie DCI na monitorach 4K będą obcięte krawędzie.
Monitor HXGA o gęstości 24 bitów na piksel i szybkości wyświetlania 25 ramek/s trzeba
zasilać z szybkością 7 Gb/s, ale nawet monitor VGA wymaga szybkości 184 Mb/s. Podwojenie
tych wartości w celu uniknięcia migotania nie brzmi atrakcyjnie. Lepszym rozwiązaniem jest
transmisja sygnału z szybkością 25 ramek/s oraz zlecenie komputerowi dwukrotnego zapisy-
wania i rysowania każdej ramki. Stacje telewizyjne nie stosują tej strategii, ponieważ odbiorniki
telewizyjne nie mają pamięci i w wielu przypadkach sygnał analogowy nie może być zapisany
w pamięci RAM, jeśli wcześniej nie zostanie przekonwertowany na postać cyfrową, a to wymaga
dodatkowego sprzętu. W konsekwencji przeplot jest potrzebny do transmisji programów te-
lewizyjnych, ale nie jest potrzebny do przesyłania cyfrowego wideo.
Rysunek A.4. (a) Fala sinusoidalna; (b) próbkowanie fali sinusoidalnej; (c) kwantyzacja próbek
do 4 bitów
Próbki cyfrowe nigdy nie są dokładne. Próbki z rysunku A.4(c) umożliwiają przedstawienie
tylko dziewięciu wartości — od −1,00 do +1,00 w krokach co 0,25. W rezultacie do reprezen-
tacji ich wszystkich potrzeba 4 bitów. 8-bitowa próbka pozwala na przedstawienie 256 różnych
wartości. 16-bitowa próbka pozwoliłaby na przedstawienie 65 536 różnych wartości. Błąd wpro-
wadzony przez skończoną liczbę bitów na próbkę określa się jako szum kwantyzacji. Jeśli jest
zbyt duży, ucho to wykryje.
Dwa dobrze znane przykłady próbkowanego dźwięku to odgłosy telefonu i płyty CD audio.
W systemach telefonicznych stosowana jest modulacja PCM (od ang. Pulse Code Modulation).
W tej technologii wykorzystuje się próbki 7-bitowe (Ameryka Północna i Japonia) lub 8-bitowe
(Europa) z szybkością próbkowania 8000 razy na sekundę. Taki system gwarantuje szybkość
przesyłania danych na poziomie 56 000 b/s lub 64 000 b/s. Przy zaledwie 8000 próbek/s często-
tliwości powyżej 4 kHz są tracone.
Płyty audio CD są cyfrowe i charakteryzują się częstością próbkowania 44 100 próbek/s.
Jest to wartość wystarczająca do przechwytywania częstotliwości do 22 050 Hz. To wystarczy
dla ludzi, ale nie wystarczy dla psów. Próbki mają po 16 bitów każda i są liniowe w całym zakresie
amplitud. Warto zwrócić uwagę, że 16-bitowe próbki pozwalają na przedstawienie zaledwie 65 536
różnych wartości, mimo że zakres dynamiczny ucha wynosi około 1 miliona w przypadku pomiaru
w krokach odpowiadających najmniejszej częstotliwości słyszalnego dźwięku. W związku z tym
użycie zaledwie 16 bitów na próbkę wprowadza pewien szum kwantyzacji (choć pełny zakres
dynamiczny nie jest pokryty — słuchanie płyt CD nie powinno boleć). Gdy następuje 44 100
próbek/s po 16 bitów każda, płyta CD audio potrzebuje pasma około 705,6 kb/s dla dźwięku mono-
fonicznego i około 1,411 Mb/s dla dźwięku stereo (patrz tabela A.1). Możliwa jest kompresja audio,
która bazuje na psychoakustycznym modelu ludzkiego narządu słuchu. System MPEG warstwy
3 (MP3) pozwala na dziesięciokrotną kompresję. W ostatnich latach odtwarzacze muzyczne wyko-
rzystujące ten format stały się bardzo popularne.
Digitalizowany dźwięk można z łatwością przetwarzać programowo za pomocą komputerów.
Istnieją dziesiątki programów na komputery osobiste, które pozwalają użytkownikom rejestro-
wać, wyświetlać, modyfikować, miksować i zapisywać fale dźwiękowe z wielu źródeł. Obecnie
niemal wszystkie profesjonalne operacje rejestrowania i edycji dźwięku są cyfrowe. Dźwięku
analogowego prawie się nie stosuje.
W tym momencie dla wszystkich powinno być jasne, że przetwarzanie materiału multimedialnego
w postaci nieskompresowanej jest całkowicie nie do przyjęcia — informacji jest po prostu zbyt
dużo. Jedyną nadzieją okazuje się masowa kompresja. Na szczęście liczne badania prowadzone
w kilku ostatnich dekadach doprowadziły do powstania wielu technik kompresji oraz algorytmów
umożliwiających przeprowadzanie transmisji multimedialnych. W poniższych punktach przestu-
diujemy kilka metod kompresji danych multimedialnych, zwłaszcza obrazów. Więcej informacji na
ten temat można znaleźć w publikacjach [Fluckiger, 1995] oraz [Steinmetz i Nahrstedt, 1995].
Wszystkie systemy kompresji wymagają dwóch algorytmów: jednego do kompresji danych
w systemie źródłowym i drugiego do dekompresji ich w systemie docelowym. W literaturze algo-
rytmy te określa się odpowiednio jako kodowanie i dekodowanie. W tej książce także posłużymy
się taką terminologią.
Używane algorytmy charakteryzują się pewnymi asymetriami, które należy dokładnie zro-
zumieć. Po pierwsze dla wielu aplikacji dokument multimedialny, np. film, jest kodowany tylko
raz (w momencie zapisywania na serwerze multimedialnym), ale jest dekodowany tysiące razy
(kiedy jest przeglądany przez klientów). Z tej asymetrii wynika, że algorytm kodowania może być
wolny i może wymagać drogiego sprzętu, pod warunkiem że algorytm dekodowania będzie szybki
i nie będzie wymagał drogiego sprzętu. Z drugiej strony dla systemów multimedialnych dzia-
łających w czasie rzeczywistym, np. wideokonferencji, wolne kodowanie jest niedopuszczalne.
Kodowanie musi być wykonywane „w locie” — w czasie rzeczywistym.
Druga asymetria polega na tym, że proces kodowania (dekodowania) nie musi być w 100%
odwracalny. Oznacza to, że w przypadku gdy plik jest poddawany kompresji, następnie przesy-
łany i dekompresowany, użytkownik spodziewa się uzyskania oryginału — z dokładnością do
ostatniego bitu. W przypadku multimediów takie wymaganie nie istnieje. Zazwyczaj dopuszcza
się, aby sygnał wideo po zakodowaniu, a następnie zdekodowaniu nieco różnił się od oryginału.
Jeśli zdekodowane wyjście nie jest dokładnie identyczne z oryginalnym wejściem, mówi się, że
system używa kompresji ze stratą. Wszystkie systemy kompresji wykorzystywane na potrzeby
multimediów wprowadzają straty, ponieważ takie systemy gwarantują znacznie lepszy współ-
czynnik kompresji.
Kiedy przekształcenie DCT jest kompletne, algorytm JPEG przechodzi do kroku 3. — etapu
kwantyzacji, w którym następuje wyeliminowanie mniej istotnych współczynników DCT. To prze-
kształcenie (ze stratami) jest wykonywane poprzez podzielenie każdego ze współczynników
w macierzy DCT 8×8 przez wagę pobraną z tablicy. Jeśli wszystkie wagi mają wartość 1, prze-
kształcenie nie wykonuje żadnych działań. Jeśli jednak wagi zwiększają się gwałtownie od początku
układu, wyższe częstotliwości przestrzenne są szybko porzucane.
Przykład tego kroku pokazano na rysunku A.7. Widzimy tutaj początkową macierz DCT,
tablicę kwantyzacji i wynik uzyskany przez podzielenie każdego elementu DCT przez odpowia-
dający mu element tablicy kwantyzacji. Wartości w tablicy kwantyzacji nie są częścią standardu
JPEG. Każda aplikacja musi dostarczać własnej tablicy kwantyzacji. Dzięki temu może kontro-
lować kompromis pomiędzy stratami a kompresją.
W kroku 4. jest redukowana wartość (0, 0) każdego bloku (znajdująca się w górnym lewym
rogu) poprzez zastąpienie jej przez wartość, o jaką różni się ona od odpowiadającego jej elementu
w poprzednim bloku. Ponieważ elementy te są średnimi poszczególnych bloków, powinny one
zmieniać się powoli. W związku z tym obliczenie różniczki dla tych wartości powinno zreduko-
wać większość elementów do małych wartości. Z innych wartości różniczki nie są obliczane.
Wartości (0, 0) to tzw. komponenty DC. Pozostałe wartości to komponenty AC.
W kroku 5. jest przeprowadzana linearyzacja 64 elementów i zastosowane dla elementów listy
kodowania RLE (od ang. Run-Length Encoding). Skanowanie bloku od lewej do prawej, a następ-
nie od góry do dołu nie doprowadzi do koncentracji zer. Dlatego wykorzystywany jest wzorzec
zygzakowy, tak jak pokazano na rysunku A.8. W tym przykładzie macierz zygzakowa ostatecznie
zawiera 38 kolejnych zer na końcu macierzy. Ten ciąg można zastąpić pojedynczym licznikiem,
który informuje, że jest 38 zer.
1,2 Mb/s. MPEG-2 (ISO 13818) opracowano w celu kompresji wideo o jakości nadawanej przez
stacje telewizyjne (ang. broadcast quality) z szybkością od 4 do 6 Mb/s, tak aby można je było
przesyłać w kanale nadawczym systemów NTSC lub PAL. Z kolei MPEG-4 bazuje na standar-
dach MPEG-1 i MPEG-2, ale oferuje nowe funkcje, takie jak rendering 3D, nowe techniki DRM
i interaktywność. W rzeczywistości jest to zestaw standardów. Producenci sprzętu wideo powinni
dokładnie wskazywać te części standardu, które obsługują ich urządzenia. Niestety, czasami
o tym „zapominają”. O ile zgodność ze standardem MPEG-4 często jest trochę myląca, o tyle
MPEG-4 (część 10.) jest standardem używanym na płytach Blu-ray.
Standardy MPEG wykorzystują dwa rodzaje redundancji występujące w filmach: przestrzenną
i chwilową. Redundancję przestrzenną można wykorzystać poprzez zakodowanie każdej ramki
oddzielnie za pomocą algorytmu JPEG. Dodatkową kompresję można uzyskać poprzez wykorzy-
stanie faktu, że kolejne ramki są często prawie identyczne (redundancja chwilowa). System DV
(od ang. Digital Video) wykorzystywany w kamerach cyfrowych stosuje tylko mechanizm zbli-
żony do JPEG. W tym przypadku kodowanie musi być przeprowadzone w czasie rzeczywistym,
a oddzielne kodowanie każdej ramki po prostu przebiega szybciej.
W przypadku scen, w których kamera i tło są statyczne, a jeden czy dwóch aktorów wolno
się porusza, niemal wszystkie piksele pomiędzy kolejnymi ramkami będą identyczne. W takim
przypadku wystarczy odjąć każdą ramkę od poprzedniej i uruchomić algorytm JPEG w odnie-
sieniu do różnicy. Jednak w przypadku scen, dla których kamera przeprowadza omiatanie obrazu
(ang. panning) lub powiększenia (ang. zooming), technika ta się nie sprawdza. Potrzebny jest
sposób kompensacji tego ruchu. Właśnie do tego służy kompresja MPEG. W rzeczywistości na
tym polega najważniejsza różnica pomiędzy MPEG i JPEG.
Wyjście MPEG-2 składa się z trzech różnych rodzajów ramek, które muszą być przetwarzane
przez program przeglądający:
1. Ramki I (od ang. Intracoded): samodzielne statyczne obrazy kodowane algorytmem JPEG.
2. Ramki P (od ang. Predictive): różnica w stosunku do ostatniej ramki na poziomie bloków.
3. Ramki B (od ang. Bidirectional): różnice w odniesieniu do ostatniej i następnej ramki.
Ramki I są po prostu statycznymi obrazami zakodowanymi z użyciem algorytmu JPEG.
Wykorzystuje się w nich luminancję pełnej rozdzielczości oraz chrominancję o połowie rozdziel-
czości wzdłuż każdej osi. Ramki I powinny okresowo występować w strumieniu wyjściowym
z trzech powodów. Po pierwsze standard MPEG może być wykorzystywany do transmisji tele-
wizyjnych, wówczas bowiem widzowie zmieniają kanały, kiedy mają ochotę. Gdyby wszystkie
ramki zależały od wszystkich swoich poprzedników — aż do pierwszej ramki — nikt, kto opuścił
pierwszą ramkę, nie zdołałby nigdy zdekodować żadnej z kolejnych ramek. W związku z tym po
rozpoczęciu filmu widzowie nie mogliby zmieniać kanału. Po drugie, gdyby jakakolwiek ramka
została odebrana z błędem, dalsze kodowanie nie byłoby możliwe. Po trzecie bez ramek I przy
szybkim przewijaniu w przód lub szybkim cofaniu dekoder musiałby wykonywać obliczenia dla
każdej przewijanej ramki, tak by znał pełną wartość ramki, na której się zatrzymał. Dzięki wyko-
rzystaniu ramek I możliwe jest omijanie ramek w przód albo w tył, aż do znalezienia ramki I.
Stamtąd można rozpocząć przeglądanie. Z tych powodów ramki I są wstawiane do wyjścia raz lub
dwa razy na sekundę.
Dla odróżnienia ramki P służą do kodowania różnic pomiędzy ramkami. Bazują na idei makro-
bloków pokrywających obszary 16×16 pikseli w przestrzeni luminancji oraz 8×8 pikseli w prze-
strzeni chrominancji. Makrobloki koduje się poprzez poszukiwanie określonego makrobloku lub
bloku niewiele się od niego różniącego w poprzedniej ramce.
Przykład sytuacji, w której ramki P byłyby przydatne, pokazano na rysunku A.9. Mamy tam
trzy kolejne ramki, które mają takie same tło, ale różnią się pozycją jednej osoby. Takie sceny
często występują w przypadku, gdy kamera jest ustawiona na trójnogu, a aktorzy poruszają się
przed nią. Makrobloki zawierające scenę przedstawiającą tło będą pasowały dokładnie, natomiast
makrobloki z osobą będą przesunięte o pewną nieznaną wartość, którą trzeba będzie znaleźć.
Standard MPEG nie określa, w jaki sposób należy szukać, jak daleko trzeba szukać lub jak
dokładne musi być dopasowanie, aby się liczyło. To zależy od konkretnej implementacji. Pewna
implementacja może wyszukiwać makroblok na bieżącej pozycji w poprzedniej ramce, a wszystkie
inne pozycje przesuwać o ±Δx w kierunku x oraz ±Δy w kierunku y. Dla każdej pozycji można ob-
liczyć liczbę dopasowań w macierzy luminancji. Pozycja o najwyższej wartości zostanie ogłoszona
zwycięzcą, pod warunkiem że miała wartość powyżej pewnego predefiniowanego progu. W innym
przypadku makroblok zostanie uznany za brakujący. Oczywiście są również możliwe znacznie
bardziej zaawansowane algorytmy.
Jeśli makroblok zostanie znaleziony, jest kodowany poprzez obliczenie różnicy z wartością
w poprzedniej ramce (dla luminancji i obu sygnałów chrominancji). Macierze różnic są następnie
poddawane algorytmowi JPEG. Wartość makrobloku w strumieniu wyjściowym jest wektorem
ruchu (jak daleko makroblok przesunął się w stosunku do poprzedniej pozycji w każdym kierunku)
uzupełnionym o różnice w stosunku do jednej z poprzednich ramek kodowane za pomocą algo-
rytmu JPEG. Jeśli makroblok nie zostanie znaleziony w poprzedniej ramce, bieżąca wartość jest
kodowana za pomocą algorytmu JPEG, tak jak w przypadku ramki I.
Ramki B są podobne do ramek, tyle że pozwalają na odwołania do makrobloków w poprzed-
niej ramce lub w kolejnej ramce — może to być ramka I lub ramka P. Ta dodatkowa swoboda
pozwala na ulepszoną kompensację ruchu. Jest również przydatna, kiedy obiekty przemieszczają
się przed lub za innymi obiektami. W przypadku gry w baseball, kiedy zawodnik w trzeciej bazie
rzuci piłkę do pierwszej bazy, w obrazie może występować ramka, w której piłka zasłania głowę
gracza z drugiej bazy poruszającego się w tle. W następnej ramce głowa może być częściowo
widoczna z lewej strony piłki, a następne przybliżenie głowy jest obliczane na podstawie kolej-
nej ramki, kiedy piłka już minie głowę. Ramki B pozwalają obliczać ramki na podstawie przy-
szłych ramek.
W celu zakodowania ramki B program kodujący musi jednocześnie przechowywać w pamięci
trzy zdekodowane ramki: poprzednią, bieżącą i następną. W celu uproszczenia kodowania ramki
muszą występować w strumieniu MPEG w kolejności wzajemnych zależności, a nie w kolejności
wyświetlania. W związku z tym, w czasie gdy wideo jest odtwarzane w sieci, nawet przy dosko-
nałych parametrach czasowych jest potrzebne buforowanie. Dzięki niemu można właściwie zmie-
nić kolejność ramek przed ich wyświetleniem. Ze względu na różnice pomiędzy kolejnością
zależności a kolejnością wyświetlania próba odtworzenia filmu nie zadziała bez odpowiedniego
buforowania i złożonych algorytmów.
Filmy z dynamiczną akcją i gwałtownymi cięciami (np. filmy wojenne) wymagają wielu ramek I.
Natomiast filmy, w których reżyser ustawia kamerę, a następnie idzie na kawę, podczas gdy
aktorzy recytują swoje kwestie (np. w filmach o miłości), mogą wykorzystywać długie przebiegi
ramek P i ramek B. Zajmują one bowiem znacznie mniej miejsca w pamięci od ramek I. Z punktu
widzenia ekonomiczności wykorzystania miejsca na dysku firma świadcząca usługi multime-
dialne powinna zabiegać o to, by jak największy odsetek jej klientów stanowiły kobiety.
Jak przed chwilą się dowiedzieliśmy, dźwięk w jakości CD wymaga przepustowości transmisji na
poziomie 1,411 Mb/s. Jest oczywiste, że aby była możliwa transmisja przez internet, wymagana
jest znacząca kompresja. Z tego względu opracowano różne algorytmy kompresji dźwięku. Praw-
dopodobnie najbardziej popularny jest algorytm MPEG dla dźwięku. Ma on trzy warstwy (warianty),
z których najpopularniejszą i gwarantującą najlepszą kompresję jest MP3 (MPEG audio war-
stwa 3). W internecie jest dostępnych wiele plików muzycznych w formacie MP3. Nie wszystkie
one są tam legalnie, co doprowadziło do znacznej liczby procesów wytaczanych przez artystów
i właścicieli praw autorskich. MP3 należy do części audio standardu kompresji wideo MPEG.
Kompresję audio można przeprowadzić na jeden z dwóch sposobów. W przypadku wykorzy-
stania kodowania kształtu przebiegu sygnał jest przekształcany matematycznie za pomocą trans-
formaty Fouriera do postaci komponentów częstotliwości. Na rysunku A.10 pokazano przykład
funkcji czasu wraz z jej pierwszymi 15 amplitudami Fouriera. Amplituda każdego komponentu
jest następnie kodowana w minimalny sposób. Celem tego kodowania jest jak najdokładniejsze
odtworzenie kształtu fali na drugim końcu z wykorzystaniem jak najmniejszej liczby bitów.
Inny sposób, kodowanie percepcyjne, wykorzystuje pewne wady ludzkiego słuchu. Dźwięk jest
kodowany w taki sposób, aby człowiek słyszał go tak samo, nawet jeśli na oscyloskopie wygląda
inaczej. Kodowanie percepcyjne bazuje na zasadach psychoakustyki — sposobu odbierania dźwięku
przez ludzi. Standard MP3 bazuje na kodowaniu percepcyjnym.
Zasadnicza cecha kodowania percepcyjnego polega na tym, że niektóre dźwięki mogą masko-
wać inne dźwięki. Wyobraźmy sobie, że transmitujemy na żywo koncert gry na flecie w gorący,
letni dzień. Nagle, ni stąd, ni zowąd, grupa robotników w pobliżu uruchamia młoty pneumatyczne
i zaczyna zrywać asfalt. Nikt już nie jest w stanie słyszeć fletu. Jego dźwięki zostały zamaskowane
przez młoty pneumatyczne. Dla potrzeb transmisji danych wystarczy zakodować tylko pasmo
częstotliwości używane przez młoty pneumatyczne, ponieważ słuchacze i tak już nie są w stanie
słyszeć fletu. Takie zjawisko nazywa się maskowaniem częstotliwości — jest to zdolność gło-
śnego dźwięku w jednym paśmie częstotliwości do ukrywania cichszego dźwięku w innym paśmie
częstotliwości — takiego, który byłby słyszalny w przypadku braku głośnego dźwięku. W rze-
czywistości flet przez chwilę nie będzie słyszalny nawet wtedy, gdy umilkną młoty pneu-
matyczne. Wynika to stąd, że ucho obniżyło swoją czułość w momencie, gdy młoty rozpoczęły
pracę, i teraz potrzebuje pewnego czasu, by ją ponownie zwiększyć. Taki efekt nazywa się masko-
waniem czasowym.
Aby opisywane efekty można było łatwiej zmierzyć ilościowo, wyobraźmy sobie następujący
eksperyment. Osoba przebywająca w cichym pokoju włącza słuchawki podłączone do karty dźwię-
kowej komputera. Przy niskiej, ale stopniowo podnoszonej mocy, komputer generuje czystą falę
sinusoidalną o częstotliwości 100 Hz. Użytkownik otrzymuje polecenie wciśnięcia klawisza
w momencie, gdy usłyszy dźwięk. Komputer rejestruje bieżący poziom mocy i powtarza ekspery-
ment przy 200 Hz, 300 Hz oraz pozostałych częstotliwościach, aż do granicy słyszalności ludzkiego
Rysunek A.10. (a) Sygnał binarny i jego średniokwadratowe amplitudy Fouriera; (b) – (e)
kolejne przybliżenia pierwotnego sygnału
ucha. Przy próbie wykonanej na grupie osób wykres poziomu mocy, jaka jest potrzebna, aby dźwięk
był słyszalny, wygląda w sposób pokazany na rysunku A.11(a). Bezpośrednią konsekwencją tej
krzywej jest brak konieczności kodowania częstotliwości, których moc spada poniżej progu
słyszalności. Gdyby np. w sytuacji z rysunku A.11(a) moc przy 100 Hz wynosiła 20 dB, sygnał
można by pominąć bez słyszalnej straty jakości, ponieważ 20 dB przy 100 Hz spada poniżej progu
słyszalności.
Rysunek A.11. (a) Próg słyszalności w funkcji częstotliwości; (b) efekt maskowania
Rozważmy teraz drugą sytuację. Komputer uruchomił eksperyment ponownie, ale tym razem
z falą sinusoidalną o stałej amplitudzie, powiedzmy 150 Hz, nałożoną na częstotliwość testową.
Odkryliśmy, że próg słyszalności dla częstotliwości w pobliżu 150 Hz podniósł się, tak jak poka-
zano na rysunku A.11(b).
Konsekwencją tej nowej obserwacji jest to, że dzięki śledzeniu sygnałów maskowanych przez
sygnały większej mocy w bliskich pasmach częstotliwości można pominąć coraz więcej często-
tliwości w kodowanym sygnale. W sytuacji z rysunku A.11 możliwe okazuje się całkowite pomi-
nięcie z wyjścia sygnału o częstotliwości 125 Hz i nikt nie będzie w stanie usłyszeć różnicy.
Nawet jeśli mocny sygnał zatrzyma się w pewnym paśmie częstotliwości, wiedza na temat jego
tymczasowych właściwości maskowania pozwala na kontynuowanie pomijania zamaskowanych
częstotliwości przez pewien czas — tak długo, aż ucho powróci do stanu wyjściowego. Sednem
kodowania MP3 jest obliczenie transformaty Fouriera dźwięku w celu uzyskania mocy dla każdej
częstotliwości. Następnie przesyłane są tylko niezamaskowane częstotliwości — kodowane za
pomocą jak najmniejszej liczby bitów.
Wykorzystując te informacje jako tło, możemy zobaczyć, w jaki sposób jest realizowane
kodowanie. Kompresja audio jest realizowana poprzez próbkowanie przebiegu dla częstotliwości
32 kHz, 44,1 kHz lub 48 kHz. Pierwsza i ostatnia częstotliwość to łatwe do przetwarzania liczby
całkowite. Wartość 44,1 kHz jest wykorzystywana dla płyt CD audio. Wybrano ją, ponieważ jest
wystarczająca do przechwytywania wszystkich informacji dźwiękowych, jakie jest w stanie zare-
jestrować ludzkie ucho. Próbkowanie można zrealizować w jednym kanale lub dwóch kanałach
w dowolnej z czterech konfiguracji:
1. Monofoniczna (pojedynczy strumień wejściowy).
2. Podwójna monofoniczna (np. ścieżka dźwiękowa w językach angielskim i japońskim).
3. Rozłączna stereofoniczna (każdy kanał kompresowany osobno).
4. Łączna stereofoniczna (w pełni wykorzystywana redundancja międzykanałowa).
Najpierw wybierana jest wyjściowa szybkość przesyłania bitów. Standard MP3 pozwala na
kompresję stereofonicznej rock’n’rollowej płyty CD z szybkością 96 kb/s przy niewielkiej sły-
szalnej utracie jakości. Nawet fani rock’n’rolla nie są w stanie usłyszeć strat. W przypadku kon-
certu fortepianowego potrzeba co najmniej 128 kb/s. Różnice te wynikają z tego, że współczynnik
sygnału do szumów dla rock’n’rolla jest znacznie wyższy niż dla koncertu fortepianowego. Można
również wybrać niższe współczynniki wyjściowe i zaakceptować pewne obniżenie jakości.
Następnie próbki są przetwarzane w grupach po 1152 (przez czas około 26 ms). Każda grupa
jest najpierw przesyłana przez 32-cyfrowe filtry, w celu uzyskania 32 pasm częstotliwości. W tym
Systemy operacyjne obsługujące multimedia różnią się od tradycyjnych trzema głównymi cechami:
szeregowaniem procesów, systemem plików oraz szeregowaniem dysków. W niniejszym rozdziale
rozpoczniemy od szeregowania procesów, natomiast w kolejnych punktach zajmiemy się pozo-
stałymi tematami.
a różne filmy mogą mieć różną rozdzielczość. W konsekwencji różne procesy mogą być zmuszone
działać z różnymi częstotliwościami, wykonywać różną ilość pracy i mieć różne terminy ukoń-
czenia pracy.
Przytoczone uwarunkowania prowadzą do innego modelu: wiele procesów rywalizujących
o procesor — każdy wykonuje własną pracę i ma swoje terminy. W poniższych modelach zało-
żymy, że system zna częstotliwość, z którą musi działać każdy z procesów, wie, ile pracy musi
wykonać oraz jaki jest następny termin (szeregowanie dysku również stanowi pewien problem,
ale to zagadnienie omówimy później). Szeregowanie wielu rywalizujących ze sobą procesów,
w przypadku gdy niektóre lub wszystkie mają terminy, których należy dotrzymać, nazywa się
szeregowaniem w czasie rzeczywistym.
W ramach przykładu środowiska, w którym pracuje multimedialny program szeregujący,
rozważmy trzy procesy A, B i C, pokazane na rysunku A.12. Proces A działa co 30 ms (w przybli-
żeniu z taką częstotliwością działa NTSC). Każda ramka wymaga 10 ms czasu procesora. W przy-
padku braku rywalizacji proces działałby w wiązkach A1, A2, A3 itd. Każda kolejna wiązka zaczy-
nałaby się 30 ms po poprzedniej. Każda wiązka procesora obsługuje jedną ramkę i ma termin:
musi się zakończyć, zanim następna się rozpocznie.
Rysunek A.12. Trzy okresowe procesy, z których każdy wyświetla film; szybkości przesyłania
ramek oraz wymagania dotyczące przetwarzania są różne dla każdego filmu
Na rysunku A.12 pokazano również dwa inne procesy: B i C. Proces B działa z szybkością
25 razy/s (może to być sygnał PAL), a proces C działa z szybkością 20 razy/s (np. spowolniony
strumień NTSC lub strumień PAL przeznaczony dla użytkownika dysponującego wolnym połą-
czeniem z serwerem wideo). Czasy przetwarzania ramek dla procesów B i C pokazano odpo-
wiednio jako 15 ms i 5 ms. W ten sposób problem szeregowania staje się bardziej ogólny niż wtedy,
gdyby wszystkie czasy były takie same.
Problem szeregowania sprowadza się do udzielenia odpowiedzi na pytanie, w jaki sposób
uszeregować procesy A, B i C, by mieć pewność, że uda się dotrzymać właściwych terminów.
Zanim zaczniemy szukać algorytmu szeregowania, powinniśmy sprawdzić, czy określony zbiór
procesów jest możliwy do uszeregowania. Jak pamiętamy z punktu 2.4.4, jeśli proces i miał
okres Pi ms i potrzebował Ci ms czasu procesora na ramkę, to system daje się uszeregować
wtedy i tylko wtedy, gdy:
m C
i 1
i 1 Pi
gdzie m oznacza liczbę procesów — w tym przypadku 3. Zwróćmy uwagę na to, że Ci /Pi to
kwant czasu procesora zużywany przez proces i. W przykładzie z rysunku A.12 proces A zużywa
10
/30 procesora, B zużywa 15/40 procesora, a C — 5/50 procesora. Po zsumowaniu otrzymujemy
wartość 0,808 czasu procesora, a zatem procesy można uszeregować.
Do tej pory zakładaliśmy, że występuje jeden proces na strumień. W rzeczywistości może
ich być dwa (lub więcej) na strumień — np. jeden dla strumienia audio, a inny dla wideo. Mogą
one działać z różnymi szybkościami przesyłania danych i zużywać różne ilości czasu procesora na
wiązkę. Dodanie procesów audio do tego zestawu nie zmienia jednak ogólnego modelu, ponieważ
zakładamy jedynie, że istnieje m procesów, z których każdy działa ze stałą częstotliwością oraz
wymaga wykonania tylu samo obliczeń w każdej wiązce procesora.
W niektórych systemach czasu rzeczywistego procesy można wywłaszczać, a w innych nie.
W systemach multimedialnych procesy, ogólnie rzecz biorąc, dają się wywłaszczać. Oznacza to,
że proces będący w niebezpieczeństwie niedotrzymania terminu może przerwać działający proces,
zanim ten ostatni skończy obsługę swojej ramki. Po tej operacji można wznowić poprzedni proces.
Opisane działanie jest standardową cechą systemów wieloprogramowych, o czym przekonaliśmy
się wcześniej. W niniejszej książce będziemy analizować algorytmy szeregowania z możliwo-
ścią wywłaszczania. Nic nie stoi na przeszkodzie, aby się nimi posługiwać w systemach multi-
medialnych, algorytmy te gwarantują bowiem lepszą wydajność od tych, które nie pozwalają na
wywłaszczanie. Należy jedynie zadbać o to, aby bufor, który jest zapełniany w niewielkich wiąz-
kach, był pełen na czas (kiedy zostanie osiągnięty termin). W przeciwnym wypadku może dojść
do efektu fluktuacji fazy (jitter).
Algorytmy czasu rzeczywistego mogą być statyczne lub dynamiczne. Algorytmy statyczne
z góry przypisują każdemu procesowi stały priorytet, a następnie wykorzystując te priorytety,
realizują szeregowanie z wywłaszczaniem. W dynamicznych algorytmach szeregowania nie obowią-
zują ustalone priorytety. Poniżej przeanalizujemy przykład poszczególnych typów algorytmów.
Na rysunku A.13 początkowo wszystkie trzy procesy są gotowe do działania. Najpierw wybie-
rany jest proces o najwyższym priorytecie — A. Program szeregujący pozwala mu na działanie do
zakończenia, co następuje po 15 ms, tak jak pokazano na osi RMS. Po zakończeniu zaczynają
działać procesy B i C — dokładnie w tym porządku. Wspólnie działanie tych procesów zajmuje
30 ms, zatem kiedy proces C się zakończy, nadchodzi czas, by proces A został uruchomiony
ponownie. Taka rotacja trwa do chwili, aż system osiąga bezczynność, co następuje w chwili t = 70.
W chwili t = 80 proces B uzyskuje gotowość i zaczyna działać. Jednak w momencie t = 90
gotowość uzyskuje proces o wyższym priorytecie — A. W związku z tym wywłaszcza proces B
i działa aż do zakończenia w chwili t = 100. W tym momencie system może wybrać pomiędzy
dokończeniem procesu B a uruchomieniem procesu C. Wybiera proces o najwyższym priory-
tecie — B.
Przykład algorytmu EDF pokazano na rysunku A.13. Początkowo są gotowe wszystkie trzy
procesy. Procesy te działają w kolejności swoich terminów zakończenia. Proces A musi się zakoń-
czyć do momentu t = 30. Proces B musi się zakończyć do chwili t = 40, a proces C musi się
zakończyć do chwili t = 50. Jak widać, najwcześniejszy termin zakończenia ma proces A, dlatego
to on działa jako pierwszy. Aż do chwili t = 90 wybór jest taki sam jak dla RMS. W chwili t = 90
proces A ponownie zyskuje gotowość. Jego termin zakończenia wynosi t = 120 — tyle samo
co termin zakończenia procesu B. Program szeregujący mógłby wybrać do uruchomienia dowolny
z nich, ale ponieważ z wywłaszczeniem procesu B jest związany pewien niezerowy koszt obli-
czeniowy, lepiej pozwolić procesowi B na kontynuowanie działania, zamiast ponosić koszty
przełączenia.
Aby rozwiać wątpliwości, czy algorytmy RMS i EDF zawsze dają te same wyniki, przyjrzyjmy
się innemu przykładowi, pokazanemu na rysunku A.14. W tej sytuacji okresy działania procesów
A, B i C są takie same jak poprzednio, ale teraz A potrzebuje 15 ms czasu procesora na wiązkę,
a nie 10 ms — tak jak poprzednio. Test możliwości szeregowania oblicza wykorzystanie pro-
cesora jako 0,500+0,375+0,100 = 0,975. Pozostaje tylko 2,5% czasu procesora, ale teoretycznie
procesor ma możliwość spełnienia wymagań, dlatego znalezienie właściwego uszeregowania
powinno być możliwe.
Rysunek A.14. Kolejny przykład algorytmów szeregowania w czasie rzeczywistym RMS i EDF
W przypadku algorytmu RMS priorytety trzech procesów w dalszym ciągu mają wartości 33,
25 i 20. Znaczenie ma bowiem tylko okres działania, a nie czas. Tym razem B1 nie zakończy się
aż do chwili t = 30, kiedy to proces A zyskuje gotowość do ponownego uruchomienia. Kiedy
proces A się zakończy, co następuje w chwili t = 45, proces B ponownie zyskuje gotowość.
W związku z tym, ponieważ ma wyższy priorytet niż proces C, zaczyna działać, a proces C nie
dotrzymuje terminu zakończenia. Algorytm RMS kończy się niepowodzeniem.
Przyjrzyjmy się teraz temu, w jaki sposób algorytm EDF obsługuje ten przypadek. W chwili
t = 30 występuje rywalizacja pomiędzy A2 i C1. Ponieważ termin zakończenia wiązki C1 to 50,
a A2 to 60, program szeregujący wybiera proces C. To jest pierwsza różnica w porównaniu z algo-
rytmem RMS, gdzie wygrywał proces A, ponieważ miał wyższy priorytet.
W chwili t = 90 proces A uzyskuje gotowość po raz czwarty. Termin zakończenia procesu A
jest taki sam jak bieżącego procesu (120). W związku z tym program szeregujący ma wybór —
może go wywłaszczyć lub nie. Tak jak wcześniej, jeśli nie ma potrzeby wywłaszczania, lepiej
tego nie robić. Wiązka B3 uzyskuje więc zgodę na zakończenie.
W przykładzie z rysunku A.14, procesor jest zajęty w 100%, aż do chwili t = 150. Jednak
w końcu nastąpi luka, ponieważ procesor jest wykorzystany tylko w 97,5%. Ponieważ wszystkie
czasy rozpoczęcia i zakończenia są wielokrotnościami 5 ms, luka wynosi 5 ms. W celu osiągnięcia
wymaganego czasu bezczynności na poziomie 2,5% luka wynosząca 5 ms będzie musiała nastąpić
co 200 ms. Dlatego właśnie nie występuje na rysunku A.14.
Interesujące jest pytanie o powód, dla którego algorytm RMS się nie powiódł. Ogólnie rzecz
biorąc, wykorzystanie statycznych priorytetów działa tylko wtedy, gdy procent wykorzystania
procesora nie jest zbyt wysoki. Liu i Layland w pracy [Liu i Layland, 1973] udowodnili, że dla
dowolnego systemu z okresowymi priorytetami, jeśli zachodzi:
C
m
Pi m 21 / m 1
i 1 i
to istnieje gwarancja powodzenia algorytmu RMS. Dla 3, 4, 5, 10, 20 i 100 procesów maksy-
malne dozwolone wykorzystanie procesora wynosi odpowiednio 0,780, 0,757, 0,743, 0,718, 0,705
i 0,696. W miarę jak m , maksymalne wykorzystanie procesora jest asymptotą do ln 2. Inaczej
mówiąc, Liu i Layland udowodnili, że dla trzech procesów algorytm RMS zawsze będzie działał,
jeśli procent wykorzystania procesora jest na poziomie 0,780 lub poniżej. W naszym pierwszym
przykładzie miało wartość 0,808 i algorytm RMS zadziałał, ale to było, po prostu szczęście.
W przypadku różnych okresów i czasów działania przy wykorzystaniu procesora na poziomie
0,808 algorytm może zakończyć się niepowodzeniem. W drugim przykładzie procent wykorzy-
stania procesora był tak duży (0,975), że nie było nadziei, aby algorytm RMS zadziałał.
Dla odróżnienia algorytm EDF zawsze działa dla dowolnego zbioru procesów możliwych do
uszeregowania. Przy zastosowaniu algorytmu EDF można osiągnąć do 100% wykorzystania pro-
cesora. Ceną, jaką trzeba za to zapłacić, jest bardziej złożony algorytm. Tak więc w rzeczywistym
serwerze wideo, jeśli procent wykorzystania procesora ma wartość poniżej limitu algorytmu RMS,
można zastosować algorytm RMS. W przeciwnym wypadku należy wybrać algorytm EDF.
Drugi problem polega na tym, że serwer wideo musi mieć możliwość dostarczania bloków danych
bez opóźnień. Jest to trudne do osiągnięcia, jeśli żądania przychodzą w sposób niezaplanowany,
a zasoby nie zostały zarezerwowane z góry.
W celu rozwiązania tych problemów multimedialne serwery plików stosują całkowicie inny
wzorzec: działają tak, jak magnetowidy VCR (od ang. Video Cassette Recorders). Aby odczytać plik
multimedialny, proces użytkownika wykonuje wywołanie systemowe start, określając plik do
odczytania oraz kilka innych parametrów — np. ścieżkę audio i plik z napisami. Następnie serwer
wideo zaczyna wysyłać ramki z wymaganą szybkością. Zadaniem użytkownika jest obsługa ich
w takim tempie, w jakim nadchodzą. Jeśli użytkownika znudzi film, zatrzymuje strumień za pomocą
wywołania systemowego stop. Serwery plików o takim modelu obsługi strumieni często są okre-
ślane serwerami wypychania (ang. push server) — ponieważ wypychają dane do użytkownika.
Różnią się one od tradycyjnych serwerów ściągania (ang. pull servers), z których użytkownik jest
zmuszony ściągać dane blok po bloku. W tym celu kilkakrotnie korzysta z wywołań systemo-
wych read, by po kolei ściągać kolejne bloki. Różnicę pomiędzy tymi modelami zilustrowano
na rysunku A.15.
szego? Jednak funkcje szybkiego przewijania i cofania z podglądem są znacznie trudniejsze. Gdyby
nie kompresja, to jednym ze sposobów przewijania w przód z szybkością 10× byłoby wyświe-
tlanie co dziesiątej ramki. Przewijanie w przód z szybkością 20× wymagałoby wyświetlania co
dwudziestej ramki. W istocie, w przypadku braku kompresji, przewijanie w przód lub wstecz
z dowolną szybkością jest łatwe. W celu odtwarzania k razy szybciej wystarczy wyświetlać co
k-tą ramkę. Aby cofać film z k razy wolniejszą szybkością, należy zrobić to samo, ale w przeciw-
nym kierunku. Takie podejście działa równie dobrze dla serwerów ściągania, jak i wypychania.
W przypadku algorytmu MPEG taki algorytm nie zadziała nawet teoretycznie, ze względu na
wykorzystanie ramek I, P oraz B. Pominięcie k ramek (zakładając, że w ogóle można to zrobić)
może spowodować dotarcie do ramki P bazującej na ramce I, którą właśnie pominięto. Baz ramki
bazowej przyrostowe zmiany od tego miejsca (a ramki P zawierają właśnie zmiany przyrostowe)
nie mają sensu. Algorytm MPEG wymaga sekwencyjnego odtwarzania pliku.
Inne podejście do rozwiązania problemu mogłoby polegać na próbie sekwencyjnego odtwa-
rzania pliku z szybkością 10×. Realizacja tej operacji wymaga jednak ściągania danych z dysku
z szybkością 10×. W tym momencie serwer mógłby spróbować dekompresji ramek (czegoś,
czego normalnie nie robi), dowiedzieć się, jaka ramka jest potrzebna, i ponownie poddać kom-
presji co dziesiątą ramkę jako ramkę I. Wykonanie takiej operacji wprowadza jednak olbrzymie
obciążenie serwera. Wymaga także od serwera rozumienia formatu kompresji — czegoś, czego
serwer nie musi znać.
Alternatywa polegająca na dostarczeniu danych przez sieć do użytkownika i umożliwienie
wybrania właściwych ramek w tamtej lokalizacji wymaga działania sieci z szybkością 10×. Poten-
cjalnie jest to wykonalne, ale oczywiście nie jest łatwe, jeśli wziąć pod uwagę dużą szybkość,
z jaką musi działać serwer.
Tak czy owak, nie istnieje proste rozwiązanie problemu. Jedyna sensowna strategia wymaga
wcześniejszego planowania. Można stworzyć specjalny plik zawierający np. co dziesiątą ramkę
i skompresować ten plik za pomocą algorytmu MPEG. Taki plik oznaczono na rysunku A.2 nazwą
„szybkie przewijanie”. W celu przełączenia się do trybu szybkiego przewijania serwer musi
stwierdzić, w jakim miejscu pliku szybkiego przewijania użytkownik aktualnie się znajduje. Jeśli
np. bieżąca ramka ma numer 48 210, a plik szybkiego przewijania ma być wyświetlany z szyb-
kością 10×, to serwer musi zlokalizować ramkę 4821 w pliku szybkiego przewijania w przód
i stamtąd rozpocząć odtwarzanie z normalną szybkością. Oczywiście może to być ramka P lub B,
ale proces dekodujący w obrębie klienta może pomijać ramki tak długo, aż zobaczy ramkę I. Prze-
wijanie wstecz wykonuje się w analogiczny sposób, z wykorzystaniem innego, specjalnie przy-
gotowanego pliku.
Kiedy użytkownik przełączy się do normalnej szybkości, trzeba wykonać odwrotną sztuczkę.
Jeśli bieżąca ramka w pliku szybkiego przewijania z podglądem ma numer 5734, to serwer prze-
łącza się do zwykłego pliku i kontynuuje od ramki 57 340. Tak jak wcześniej, jeśli to nie jest
ramka I, proces dekodujący po stronie klienta musi ignorować przychodzące ramki tak długo,
aż zobaczy ramkę I.
Choć wykorzystanie tych dwóch dodatkowych plików pozwala na wykonanie pracy, podejście
to ma kilka wad. Po pierwsze potrzebna jest pewna dodatkowa ilość miejsca na dysku do zapi-
sywania dodatkowych plików. Po drugie szybkie przewijanie w przód i cofanie można wykonać
tylko z szybkościami odpowiadającymi plikom specjalnym. Po trzecie potrzebne są dodatkowe
operacje mające na celu przełączanie w obie strony pomiędzy zwykłym plikiem a plikami prze-
wijania z podglądem w przód i w tył.
Rysunek A.16. W usłudze wideo prawie na żądanie nowy strumień rozpoczyna się w regularnych
odstępach czasu. W tym przykładzie co 5 min (9000 ramek)
strategia polega na udostępnieniu opcji bez oczekiwania. W takim przypadku nadawanie stru-
mienia rozpoczyna się natychmiast, ale klient musi zapłacić więcej za natychmiastowy start.
Wideo na żądanie w pewnym sensie przypomina korzystanie z taksówki: dzwonimy po tak-
sówkę i ona przyjeżdża. Wideo niemal na żądanie przypomina korzystanie z autobusu: ma on
ustalony rozkład jazdy i trzeba czekać na następny. Jednak środki masowej komunikacji mają sens
tylko wtedy, gdy komunikacja jest masowa. Autobus, którego trasa przebiega przez przedmie-
ścia, może być pusty prawie przez cały czas. Na podobnej zasadzie nadawanie najnowszej pro-
dukcji Stevena Spielberga może przyciągnąć tak wielu klientów, że opłaca się rozpoczynać nowy
strumień co 5 min. Z kolei film Przeminęło z wiatrem lepiej zaoferować ściśle na żądanie.
W przypadku usługi wideo prawie na żądanie użytkownicy nie mają do dyspozycji funkcji
magnetowidu. Użytkownik nie może zatrzymać filmu po to, by odbyć wycieczkę do kuchni.
Najlepsze, co można zrobić po powrocie z kuchni, to powrót do strumienia, który rozpoczął się
później. W ten sposób czasami trzeba powtórzyć kilka minut materiału.
W rzeczywistości możliwy jest również inny model usługi wideo prawie na żądanie. Zamiast
dostosowywać się do tego, że jakiś film będzie się rozpoczynał co 5 min, widzowie mogą zamawiać
filmy wtedy, kiedy chcą. Co 5 min system widzi, jakie filmy zostały zamówione, i je rozpoczyna.
Przy takim podejściu filmy mogą się rozpoczynać o 20:00, 20:10, 20:15 i 20:25. Nie mogą jednak
rozpoczynać się pomiędzy tymi punktami czasu. W rezultacie strumienie, które nie mają widzów,
nie są przesyłane. Umożliwia to oszczędność pasma, pamięci i możliwości sieci. Z drugiej strony
atakowanie lodówki jest teraz trochę hazardowym zagraniem, ponieważ nie ma gwarancji, że
5 min za strumieniem, który oglądaliśmy, jest przesyłany kolejny strumień. Oczywiście operator
może udostępnić użytkownikowi opcję wyświetlania listy wszystkich współbieżnych strumieni.
Większość osób uważa jednak, że piloty telewizorów mają dość przycisków, i nie jest zbyt przy-
chylna przywitaniu kilku następnych.
Gdy przepustowość była bardziej cennym zasobem niż dziś, model wideo prawie na żądanie
był popularny — zarówno wśród użytkowników, jak i badaczy. Obecnie jednak większość ludzi
jest przyzwyczajona do modelu oglądania filmów na żywo — np. za pośrednictwem serwisów
YouTube czy Netflix. Zawartość wideo może być serwowana z olbrzymich centrów danych
wyposażonych w liczne serwery, które łatwo nie „przestraszą się” dodatkowych żądań. Ponadto
poprzez agresywne buforowanie w internecie wiele żądań wideo nie jest nawet serwowanych przez
serwery źródłowe. Model wideo prawie na życzenie wydaje się zanikać, choć nadal jest stosowany
od czasu do czasu (np. w niektórych systemach rozrywkowych używanych w samolotach).
Pliki multimedialne są bardzo duże. Często są zapisywane tylko raz, ale odczytywane wiele razy.
Zwykle są odczytywane sekwencyjnie. Odtwarzanie musi dokładnie spełniać kryteria jakości usług.
Wszystkie te wymagania sugerują inne rozmieszczenie plików niż to, które jest stosowane
w tradycyjnych systemach operacyjnych. Niektóre z problemów związanych z rozmieszczaniem
plików opiszemy poniżej — najpierw dla pojedynczego dysku, a następnie dla wielu dysków.
wewnątrz plików na serwerach wideo jest posługiwanie się ciągłymi plikami. Zazwyczaj spełnienie
wymagania ciągłości plików jest trudne. Jednak na serwerach wideo wstępnie załadowanych filmami,
które później się nie zmieniają, można to osiągnąć.
Jedną z komplikacji jest obecność wideo, audio i tekstu, tak jak pokazano na rysunku A.2.
Nawet jeśli każdy z plików wideo, audio i tekst są zapisane w oddzielnych ciągłych plikach,
potrzebna jest operacja seek w celu przejścia z pliku wideo do pliku audio, a stamtąd do pliku
tekstowego, jeśli zachodzi taka potrzeba. Z tych uwarunkowań wynika druga możliwa aranżacja
pamięci trwałej — przeplatanie wideo, audio i tekstu z zachowaniem ciągłości pliku. Taki układ
zaprezentowano na rysunku A.17. W pokazanej sytuacji bezpośrednio za informacjami wideo
pierwszej ramki występują różne ścieżki dźwiękowe dla tej ramki. W zależności od tego, ile
ścieżek dźwiękowych i tekstu zapisano, najprostszą metodą może być przeczytanie wszystkich
fragmentów dla każdej ramki za pomocą pojedynczej operacji read i przesłanie do użytkownika
tylko potrzebnych informacji.
Rysunek A.17. Przeplatanie wideo, audio i tekstu w pojedynczym ciągłym pliku z filmem
ciągłego zbioru bloków dyskowych. W ten sposób czytanie ramki k polega na znalezieniu w skoro-
widzu ramek k-tej pozycji, a następnie przeczytaniu całej ramki w jednej operacji dyskowej.
Ponieważ różne ramki mają różny rozmiar, w skorowidzu ramek trzeba zapisać rozmiar ramek
(w blokach). Jednak nawet przy blokach dyskowych o rozmiarze 1 kB 8-bitowe pole pozwala
obsłużyć ramki do 255 kB. To wystarcza do zapisania nieskompresowanej ramki NTSC nawet
z wieloma ścieżkami dźwiękowymi.
Rysunek A.18. Przechowywanie filmów w nieciągłych blokach; (a) małe bloki dyskowe;
(b) duże bloki dyskowe
Inny sposób zapisania filmu polega na wykorzystaniu dużych bloków dyskowych (np. 256 kB)
i umieszczeniu wielu ramek w każdym bloku. Taką organizację pokazano na rysunku A.18(b).
W dalszym ciągu potrzebny jest skorowidz, ale tym razem jest to skorowidz bloków, a nie ramek.
W istocie skorowidz ten jest identyczny jak i-węzeł z rysunku 4.10, ewentualnie z dodatkiem
informacji o tym, która ramka znajduje się na początku każdego bloku. W ten sposób można szybko
zlokalizować każdą ramkę. Ogólnie rzecz biorąc, blok nie zawiera integralnej liczby ramek. Trzeba
zatem coś zrobić, aby obsłużyć tę sytuację. Istnieją dwie opcje.
W pierwszej opcji, którą zilustrowano na rysunku A.18(b), za każdym razem, kiedy następna
ramka nie mieści się w bieżącym bloku, pozostała część bloku jest pozostawiana jako pusta. To
zmarnowane miejsce wynika z wewnętrznej fragmentacji — na tej samej zasadzie jak w syste-
mach pamięci wirtualnej z ramkami o stałym rozmiarze. Z drugiej strony nigdy nie jest konieczne
wykonywanie operacji seek w środku ramki.
Inna opcja polega na wypełnieniu każdego bloku do końca z podziałem ramek na kilka bloków.
Zastosowanie tej opcji wprowadza konieczność wykonywania operacji seek w środku ramek, co
może mieć wpływ na wydajność. Pozwala jednak na oszczędność miejsca na dysku ze względu
na eliminację wewnętrznej fragmentacji.
Dla porównania — wykorzystanie małych bloków tak, jak pokazano na rysunku A.18(a), rów-
nież wiąże się z marnotrawstwem pewnej ilości miejsca na dysku, ponieważ fragment ostatniego
bloku w każdej ramce jest nieużywany. W przypadku 1-kilobajtowego bloku dyskowego i dwu-
godzinnego filmu NTSC składającego się z 216 000 ramek zmarnowane miejsce na dysku wy-
nosi tylko około 108 kB z 3,6 GB. Ilość straconego miejsca dla sytuacji z rysunku A.18(b) jest
trudniejsza do obliczenia, ale jest go znacznie więcej, ponieważ od czasu do czasu na końcu bloku
pozostaje 100 kB, a następną ramką jest ramka I o rozmiarze przekraczającym 100 kB.
Z drugiej strony skorowidz bloków ma znacznie mniejszą objętość niż skorowidz ramek.
W przypadku 256-kilobajtowego bloku dyskowego i ramki o przeciętnym rozmiarze 16 kB w jed-
nym bloku mieści się około 16 ramek. W związku z tym film składający się z 216 000 ramek
wymaga tylko 13 500 pozycji w skorowidzu bloków, ileż mniej w porównaniu z 216 000 pozycjami
w skorowidzu ramek. Z powodów wydajnościowych w obu przypadkach skorowidz powinien
zawierać pozycje dla wszystkich ramek czy bloków (to oznacza, że nie może być bloków po-
średnich, jak w systemie UNIX). Dlatego związanie 13 500 8-bajtowych wpisów w pamięci (4 bajty
na każdy adres dyskowy, 1 bajt na rozmiar ramki i 3 bajty na numer ramki startowej) w porówna-
niu z 216 000 5-bajtowych wpisów (tylko adres dyskowy i rozmiar) pozwala na zaoszczędzenie
prawie 1 MB pamięci RAM podczas odtwarzania filmu.
Przytoczone uwarunkowania prowadzą do konieczności przyjęcia następujących kompromisów:
1. Skorowidz ramek: większe wykorzystanie pamięci RAM podczas odtwarzania filmu, mniej
straconego miejsca na dysku.
2. Skorowidz bloków (nie ma podziału ramek pomiędzy blokami): mniejsze zużycie pamięci
RAM, dużo straconego miejsca na dysku.
3. Skorowidz bloków (podział ramek pomiędzy blokami jest dozwolony): mniejsze zużycie
pamięci RAM, brak straconego miejsca na dysku, dodatkowe operacje seek.
Koszty obejmują zużycie pamięci RAM podczas odtwarzania, zmarnowane miejsce na dysku przez
cały czas oraz obniżenie wydajności podczas odtwarzania spowodowane dodatkowymi operacjami
seek. Problemy te można jednak rozwiązać na kilka sposobów. Problem zużycia pamięci RAM
można zredukować poprzez stronicowanie tablicy ramek w odpowiednim czasie. Koszty ope-
racji seek podczas przesyłania ramek można zamaskować poprzez odpowiednie buforowanie,
ale to wprowadza potrzebę dodatkowej pamięci i prawdopodobnie dodatkowego kopiowania.
Dobry projekt musi szczegółowo analizować wszystkie te czynniki i podejmować właściwe decy-
zje dla tworzonej aplikacji.
Kolejnym problemem w tym przypadku jest większa złożoność zarządzania pamięcią masową
w sytuacji z rysunku A.18(a), ponieważ zapisanie ramki wymaga znalezienia ciągłego zbioru
bloków o odpowiednim rozmiarze. W idealnej sytuacji ten zbiór bloków nie powinien przekraczać
granic ścieżki, ale przy odpowiednim przekrzywieniu głowic strata nie jest poważna. Należy
jednak unikać przekraczania granicy cylindra. Wymagania te oznaczają, że wolne miejsce na dysku
musi być zorganizowane w postaci listy luk o zmiennych rozmiarach, a nie w postaci prostej listy
bloków lub mapy bitowej — oba te rozwiązania mogą być stosowane w sytuacji przedstawionej
na rysunku A.18(b).
We wszystkich przypadkach wiele przemawia za tym, aby tam, gdzie to możliwe, wszystkie
bloki lub ramki filmu były umieszczane w wąskim zakresie, np. kilku cylindrów. Takie rozmiesz-
czenie powoduje, że operacje seek wykonują się szybciej. Dzięki temu pozostaje więcej czasu
na inne operacje (takie, które nie wykonują się w czasie rzeczywistym) lub na obsługę dodat-
kowych strumieni wideo. Ograniczone rozmieszczenie tego typu można osiągnąć poprzez podzie-
lenie dysku na grupy cylindrów i utrzymywanie dla każdej grupy oddzielnych list lub map bito-
wych wolnych bloków. I tak w przypadku używania luk jedna lista może dotyczyć luk o rozmiarze
1 kB, inna tych o rozmiarze 2 kB, jeszcze inna tych o rozmiarze od 3 do 4 kB, jeszcze inna tych
o rozmiarze od 5 – 8 kB itd. W ten sposób można łatwo znaleźć lukę o odpowiednim rozmiarze
w określonej grupie cylindrów.
Inną różnicę pomiędzy tymi dwoma rozwiązaniami stanowi buforowanie. Przy podejściu
z małymi blokami każda operacja read powoduje pobranie dokładnie jednej ramki. W konsekwencji
dobrze działa prosta strategia podwójnego buforowania: jeden bufor do odtwarzania bieżącej ramki
i jeden do pobierania następnej. W przypadku użycia stałych buforów każdy bufor musi być na
tyle duży, aby mógł zmieścić największą możliwą ramkę I. Z drugiej strony, jeśli dla każdej ramki
jest przydzielany z puli inny bufor, a rozmiar ramki jest znany przed jej wczytaniem, dla ramki P
lub ramki I można wybrać niewielki bufor.
W przypadku dużych bloków wymagana jest bardziej skomplikowana strategia, ponieważ każdy
blok zawiera wiele ramek, które na końcu mogą zawierać fragmenty ramek (w zależności od
tego, jaką opcję wybrano wcześniej). Jeśli wyświetlanie lub przesyłanie ramek wymaga od nich
ciągłości, trzeba je skopiować. Kopiowanie jest jednak kosztowną operacją, dlatego należy jej
unikać tam, gdzie to możliwe. Jeśli ciągłość nie jest konieczna, to ramki przekraczające granice
bloków można przesyłać przez sieć lub do urządzenia wyświetlającego w dwóch kawałkach.
Podwójne buforowanie można również zastosować dla dużych bloków, ale wykorzystanie
dwóch dużych bloków jest marnotrawstwem pamięci. Jednym ze sposobów obejścia problemu
strat pamięci jest wykorzystanie dla strumienia cyklicznego bufora transmisji o rozmiarze nieco
większym od bloku dyskowego, który zasila sieć lub urządzenie wyświetlające. Kiedy zapełnienie
bufora spadnie poniżej pewnego progu, z dysku odczytywany jest nowy duży blok. Jego zawar-
tość jest kopiowana do bufora transmisji, a bufor dużego bloku jest zwracany do wspólnej puli.
Rozmiar cyklicznego bufora należy wybrać w taki sposób, aby po osiągnięciu progu było w nim
miejsce na kolejny pełny blok dyskowy. Operacja odczytu na dysk nie może być kierowana bez-
pośrednio do bufora transmisji, ponieważ może wystąpić potrzeba jego zawinięcia. W tym przy-
padku należy zdecydować się na wybór pomiędzy sprawniejszą operacją kopiowania a większym
zużyciem pamięci lub odwrotnie.
Jeszcze innym czynnikiem przy porównywaniu tych dwóch sposobów jest wydajność dysku.
Używanie dużych bloków powoduje działanie dysku z pełną szybkością. Często jest to jeden
z bardziej istotnych problemów. Czytanie niewielkich ramek P i ramek B w postaci oddziel-
nych jednostek nie jest wydajne. Dodatkowo możliwe jest paskowanie (ang. striping) dużych blo-
ków na wiele dysków (technikę tę omówiono poniżej), podczas gdy paskowanie indywidualnych
ramek na wiele napędów nie jest możliwe.
Organizacja bazująca na małych blokach z rysunku A.18(a) czasami jest nazywana techniką
stałego odcinka czasu (ang. constant time length), ponieważ każdy wskaźnik w skorowidzu jest
indeksem reprezentującym tę samą liczbę milisekund czasu odtwarzania. Dla odróżnienia orga-
nizacja pokazana na rysunku A.18(b) czasami jest nazywana techniką stałego bloku danych (ang.
constant data length), ponieważ bloki danych mają taki sam rozmiar.
Inna różnica pomiędzy pokazanymi dwiema organizacjami plików polega na tym, że jeśli
w skorowidzu z rysunku A.18(a) są zapisane informacje o typach ramek, to można zrealizować
szybkie przewijanie z podglądem poprzez wyświetlanie samych ramek I. Jednak w zależności
od tego, jak często ramki I występują w strumieniu, tempo przewijania może być zbyt szybkie
lub zbyt wolne. Z kolei w przypadku organizacji z rysunku A.18(b) realizacja funkcji szybkiego
przewijania w taki sposób nie jest możliwa. Sekwencyjne czytanie pliku w celu wybrania pożą-
danych ramek wymaga wielu dyskowych zasobów wejścia-wyjścia.
Inne podejście polega na użyciu specjalnego pliku, który w przypadku odtwarzania z nor-
malną szybkością daje iluzję szybkiego przewijania z szybkością 10×. Struktura takiego pliku
może być taka sama jak innych plików — z wykorzystaniem skorowidza ramek lub skorowidza
bloków. Podczas otwierania pliku system musi mieć możliwość znalezienia pliku szybkiego
przewijania. Jeśli użytkownik użyje przycisku szybkiego przewijania, system musi natychmiast
Rysunek A.19. Krzywa przedstawia prawo Zipfa dla N = 20; kwadraty pokazują populację
20 największych miast w USA posortowaną od największej do najmniejszej (Nowy Jork jest
numerem 1, Los Angeles numerem 2, Chicago numerem 3 itd.)
W odniesieniu do filmów na serwerze wideo prawo Zipfa mówi, że najbardziej popularny film
jest wybierany dwa razy częściej niż film drugi co do popularności, trzy razy częściej niż trzeci
film w rankingu itd. Mimo że rozkład gwałtownie opada na początku, ma bardzo długi „ogon”.
Dla przykładu film nr 50 ma popularność równą C/50, natomiast film nr 51 ma popularność
równą C/51. Tak więc popularność filmu 51 wynosi 50/51 popularności filmu 50. Różnica wynosi
więc około 2%. Dla dalszych pozycji w rankingu procentowa różnica popularności pomiędzy
kolejnymi filmami staje się coraz mniejsza. Płynie stąd wniosek, że na serwerze powinno być
zapisanych wiele filmów, ponieważ istnieje duże zapotrzebowanie na filmy spoza pierwszej
dziesiątki.
Znajomość względnej popularności różnych filmów pozwala zamodelować wydajność serwera
wideo i wykorzystać te informacje do rozmieszczenia plików. Z badań wynika, że najlepsza stra-
tegia jest zaskakująco prosta i niezależna od rozkładu. Nazywa się ją algorytmem organowym
(ang. organ-pipe algorithm) [Grossman i Silverman, 1973] oraz [Wong, 1983]. Polega on na umiesz-
czeniu najbardziej popularnego filmu w środku dysku, a film drugi i trzeci w rankingu popu-
larności są umieszczone po jego obu stronach. Na zewnątrz tych plików umieszcza się pliki
czwarty i piąty, tak jak pokazano na rysunku A.20. Takie rozmieszczenie najlepiej się spraw-
dza, jeśli każdy z filmów jest ciągłym plikiem typu pokazanego na rysunku A.17. W pewnym
stopniu może być również wykorzystywane, gdy każdy film jest ograniczony do wąskiego zakresu
cylindrów. Nazwa algorytmu pochodzi od tego, że histogram prawdopodobieństw nieco przy-
pomina niesymetryczne organy.
Celem tego algorytmu jest próba utrzymania głowicy dysku w pobliżu środka dysku. Przy
1000 filmach i rozkładzie zgodnym z prawem Zipfa pierwsze pięć filmów reprezentuje całkowite
prawdopodobieństwo 0,307. Oznacza to, że głowica dysku będzie przebywała na cylindrach przy-
dzielonych do pięciu najpopularniejszych filmów przez około 30% czasu. To zaskakująco duża
wartość, w przypadku gdy dostępnych jest 1000 filmów.
może nie być właściwie zrównoważone. Jeśli na pewnych dyskach znajdują się filmy, które są
bardziej pożądane, natomiast na innych są filmy mniej popularne, system nie będzie wykorzy-
stany w pełni. Oczywiście, jeśli są znane częstotliwości korzystania z filmów, można przemie-
ścić niektóre z nich ręcznie po to, aby zrównoważyć obciążenie.
Drugą możliwą organizacją jest paskowanie każdego filmu pomiędzy wiele dysków — w przy-
kładzie z rysunku A.21(b) jest ich 4. Załóżmy na chwilę, że wszystkie ramki mają ten sam rozmiar
(tzn. są nieskompresowane). Stała liczba bajtów z filmu A jest zapisana na dysku 1. Następnie
ta sama liczba bajtów jest zapisana na dysku 2 itd., aż do ostatniego dysku (w tym przypadku
z jednostką A3). Następnie paskowanie jest kontynuowane, począwszy od pierwszego dysku
z jednostką A4 itd., aż do zapisania całego pliku. W tym momencie filmy B, C i D są podzielone
zgodnie z takim samym wzorcem.
Wadą pokazanego mechanizmu paskowania jest to, że ponieważ wszystkie filmy zaczynają się
na pierwszym dysku, obciążenie pomiędzy dyskami może nie być zrównoważone. Jednym ze
sposobów lepszego rozmieszczenia obciążenia okazuje się naprzemienne ułożenie dysków star-
towych w sposób pokazany na rysunku A.21(c). Jeszcze inną metodą zrównoważenia obcią-
żenia jest wykorzystanie losowego wzorca paskowania dla każdego pliku, tak jak pokazano na
rysunku A.21(d).
Do tej pory zakładaliśmy, że wszystkie ramki są takiego samego rozmiaru. W przypadku filmów
MPEG-2 to założenie jest fałszywe: ramki I mają znacznie większy rozmiar od ramek P. Są
dwa sposoby postępowania z tymi komplikacjami: paskowanie według ramki lub paskowanie
według bloku. W przypadku paskowania według ramki pierwsza ramka filmu A trafia na dysk 1
w postaci ciągłej jednostki, niezależnie od tego, jak duża jest. Następna trafia na dysk 2 itd.
Film B jest paskowany w podobny sposób — rozpoczyna się od tego samego dysku, następnego
dysku (w przypadku przekładania) lub losowego dysku. Ponieważ ramki są czytane po jednej,
ten rodzaj paskowania nie przyspiesza czytania żadnego filmu. Pomimo to rozkłada obciążenie na
dyski znacznie lepiej niż w przypadku algorytmu pokazanego na rysunku A.21(a). Algorytm ten
może działać niewłaściwie, jeśli pewnego wieczoru wiele osób zdecyduje się na oglądanie
filmu A, a nikt nie będzie chciał oglądać filmu C. Ogólnie rzecz biorąc, rozłożenie obciążenia na
wszystkie dyski umożliwia lepsze wykorzystanie całkowitego pasma dysku, a tym samym zwiększa
liczbę klientów, których można obsłużyć.
Paskowanie można również realizować według bloku. Dla każdego filmu na każdym z dys-
ków po kolei (lub losowo) są zapisywane jednostki o stałym rozmiarze. Każdy blok zawiera jedną
ramkę lub więcej ramek albo ich fragmentów. System może teraz wydawać żądania o wiele
bloków na raz dla tego samego filmu. Każde żądanie jest zapytaniem o odczytanie danych do
innego bufora pamięci. Musi się to odbyć w taki sposób, aby w momencie wykonania wszystkich
żądań w pamięci został złożony ciągły fragment filmu (zawierający wiele ramek). Żądania te
mogą być przetwarzane równolegle. Po spełnieniu ostatniego żądania można przesłać sygnał
do procesu żądającego z informacją, że praca została wykonana. Proces ten może teraz rozpo-
cząć transmisję danych do użytkownika. Kilka ramek później, kiedy w buforze pozostanie kilka
ostatnich ramek, wydawane są kolejne żądania w celu przeładowania innego bufora. W tym podej-
ściu, aby dyski były przez cały czas zajęte, wykorzystuje się duże ilości pamięci do buforowania.
W systemie, w którym jest 1000 aktywnych użytkowników i 1-megabajtowe bufory (np. z wyko-
rzystaniem 256-kilobajtowych bloków na każdym z czterech dysków), na potrzeby buforów
potrzeba 1 GB RAM. Taka ilość pamięci nie jest niczym wielkim w przypadku serwera, z któ-
rego korzysta 1000 użytkowników, i nie powinna stanowić problemu.
Ostatni problem dotyczący paskowania to wybór liczby dysków do paskowania. Jednym z eks-
tremalnych rozwiązań jest paskowanie każdego z filmów na wszystkich dyskach. Jeśli np. filmy
mają rozmiar 2 gigabajtów, a w systemie jest 1000 dysków, na każdym dysku można zapisać blok
o rozmiarze 2 MB, tak aby żaden film nie wykorzystał tego samego dysku dwukrotnie. Innym
ekstremalnym rozwiązaniem jest podzielenie dysków na małe grupy (tak jak pokazano na ry-
sunku A.21). Każdy film zostaje ograniczony do pojedynczej partycji. Pierwsze rozwiązanie, okre-
ślane jako szerokie paskowanie, nadaje się do równoważenia obciążenia na wiele dysków. Główny
problem polega na tym, że jeśli każdy z filmów będzie używał każdego dysku, a jeden z dysków
ulegnie awarii, nie będzie można wyświetlić żadnego filmu. Drugie rozwiązanie, nazywane wąskim
paskowaniem, może stwarzać problemy dla popularnych partycji, ale utrata jednego dysku spo-
woduje zniszczenie filmów tylko na tej partycji. Paskowanie ramek o zmiennym rozmiarze prze-
analizowano szczegółowo za pomocą modelu matematycznego w pracy [Shenoy i Vin, 1999].
A.8. BUFOROWANIE
A.8.
BUFOROWANIE
Tradycyjne buforowanie plików według algorytmu LRU nie działa dobrze z plikami multime-
dialnymi, ponieważ wzorce dostępu do filmów różnią się od tych, które obowiązują dla plików
tekstowych. Idea stosowania tradycyjnych buforowych pamięci podręcznych polega na tym, że
po wykorzystaniu bloku należy utrzymywać je w pamięci podręcznej, na wypadek gdyby były
szybko potrzebne jeszcze raz. W przypadku edycji pliku zbiór bloków, w których został zapisany
plik, jest używany wielokrotnie, aż do zakończenia sesji edycji. Inaczej mówiąc, jeśli istnieje sto-
sunkowo wysokie prawdopodobieństwo, że blok będzie użyty ponownie w krótkim okresie, warto
mieć go pod ręką, aby wyeliminować konieczność dostępu do dysku w przyszłości.
W przypadku multimediów standardowy wzorzec dostępu polega na tym, że film jest oglądany
sekwencyjnie od początku do końca. Istnieje małe prawdopodobieństwo użycia bloku po raz drugi,
o ile użytkownik nie przewinie filmu po to, by zobaczyć określoną scenę jeszcze raz. W rezul-
tacie standardowe techniki buforowania nie działają. Techniki buforowania w dalszym ciągu mogą
jednak okazać się pomocne. Trzeba je tylko inaczej wykorzystać. W poniższych punktach omó-
wimy buforowanie dla plików multimedialnych.
w buforze. Wszystkie jego bloki można przechowywać w pamięci podręcznej tak długo, aż drugi
(i ewentualnie trzeci) widz je wykorzysta. Dla pozostałych filmów w ogóle nie wykonuje się
buforowania.
Ideę tę można nieco rozwinąć. W niektórych przypadkach może istnieć możliwość scalenia
dwóch strumieni. Przypuśćmy, że dwóch użytkowników ogląda ten sam film, ale z 10-sekun-
dowym odstępem jeden od drugiego. Utrzymywanie bloków w pamięci podręcznej przez 10 s jest
możliwe, ale prowadzi do marnotrawstwa pamięci. Podejście alternatywne, które wymaga pew-
nego podstępu, polega na zsynchronizowaniu obu filmów. Można to zrobić poprzez zmianę szyb-
kości wyświetlania ramek dla obu filmów. Ideę tę zilustrowano na rysunku A.22.
Rysunek A.22. (a) Dwóch użytkowników oglądających ten sam film przesunięty o 10 s;
(b) scalenie dwóch plików.
Na rysunku A.22(a) obydwa filmy działają z szybkością NTSC równą 1800 ramek/min. Ponie-
waż użytkownik 2 rozpoczął oglądanie 10 s później, będzie o 10 s z tyłu przez cały film. Jednak
w sytuacji z rysunku A.22(b) strumień pierwszego użytkownika jest spowalniany w momencie,
gdy pojawia się użytkownik 2. Zamiast wyświetlać się z szybkością 1800 ramek/min, przez następne
3 min film wyświetla się z szybkością 1750 ramek/min. Po upływie 3 min znajduje się na pozy-
cji 5550. Z kolei strumień użytkownika 2 jest odtwarzany z szybkością 1850 ramek/min przez
pierwsze 3 min, przez co także dociera do ramki 5550. Od tego momentu oba odtwarzają się
z normalną szybkością.
W okresie synchronizacji strumień użytkownika 1 działa 2,8% szybciej, natomiast strumień
użytkownika 2 działa 2,8% szybciej. Istnieje bardzo małe prawdopodobieństwo, że użytkownik
cokolwiek zauważy. Jeśli jednak to problem, okres synchronizacji można przeprowadzić w dłuż-
szym czasie niż 3 min.
cyjnych systemach operacyjnych żądania o bloki dyskowe są wykonywane w sposób dość nie-
przewidywalny. W najlepszym przypadku podsystem dyskowy może odczytać zawczasu po jed-
nym bloku dla każdego otwartego pliku. Poza tym musi oczekiwać na żądania i przetwarzać je na
bieżąco. W przypadku systemów multimedialnych jest inaczej. Każdy aktywny strumień nakłada
ściśle zdefiniowane obciążenie na system, które można dość dokładnie przewidzieć. W przypadku
odtwarzania NTSC każdy klient co 33,3 ms oczekuje następnej ramki w swoim pliku. System ma
33,3 ms na dostarczenie wszystkich ramek (musi zatem buforować co najmniej jedną ramkę na
strumień, tak aby pobieranie ramki k+1 mogło odbywać się równolegle z odtwarzaniem ramki k).
Tę przewidywalność obciążenia można wykorzystać do szeregowania operacji dyskowych
z wykorzystaniem algorytmów dopasowanych do operacji multimedialnych. Poniżej rozważymy
tylko jeden dysk. Ideę tę można jednak zastosować także do innych dysków. Dla potrzeb tego
przykładu założymy, że jest 10 użytkowników, z których każdy ogląda inny film. Dodatkowo
założymy, że wszystkie filmy mają taką samą rozdzielczość, szybkość wyświetlania ramek oraz
inne właściwości.
W zależności od tego, jaka jest pozostała część systemu, komputer może dysponować 10 pro-
cesami na jeden strumień wideo, jednym procesem z 10 wątkami lub nawet jednym procesem
z jednym wątkiem obsługującym 10 strumieni w sposób cykliczny. Szczegóły nie są istotne.
Ważne jest to, że czas jest podzielony na rundy, przy czym runda określa czas ramki (33,3 ms dla
systemu NTSC, 40 ms dla PAL). Na początku każdej rundy generowane jest jedno żądanie dyskowe
w imieniu każdego z użytkowników, tak jak pokazano na rysunku A.23.
Kiedy napłyną wszystkie żądania na początku rundy, dysk będzie wiedział, co ma do zrobienia
podczas tej rundy. Będzie również wiedział, że nie napłyną żadne inne żądania, aż te nie zostaną
obsłużone i nie rozpocznie się następna runda. Dzięki temu można posortować żądania w opty-
malny sposób — najczęściej według cylindrów (choć w niektórych przypadkach być może także
według sektorów) — i przetwarzać je w optymalnym porządku. Na rysunku A.23 żądania zostały
posortowane według cylindrów.
Na pierwszy rzut oka można by sądzić, że optymalizacja dysku w taki sposób nie ma sensu,
ponieważ jeśli tylko dysk zdoła obsłużyć żądanie na czas, nie ma znaczenia, czy zrobi to na 1 ms
przed czasem, czy na 10 ms przed czasem. Taka konkluzja jest jednak fałszywa. Dzięki zopty-
malizowaniu operacji seek w taki sposób spada przeciętny czas przetwarzania poszczególnych
żądań, a to oznacza, że dysk może przeciętnie obsłużyć więcej strumieni w ciągu jednej rundy.
Inaczej mówiąc, optymalizacja żądań dyskowych w taki sposób zwiększa liczbę filmów, które
serwer może transmitować jednocześnie. Pozostały czas na końcu rundy może być również wyko-
rzystany do obsługi żądań wykraczających poza zadania przetwarzania w czasie rzeczywistym.
Jeśli serwer ma do obsłużenia zbyt wiele strumieni, to raz na jakiś czas, kiedy zostanie popro-
szony o pobranie ramek z odległych części dysku, nie zdoła wykonać zadania w terminie. O ile
jednak niedotrzymane terminy są wystarczająco rzadkie, można je tolerować w zamian za obsługę
większej liczby strumieni na raz. Zwróćmy uwagę, że istotne znaczenie ma liczba pobieranych
strumieni. Występowanie dwóch lub większej liczby klientów na strumień nie ma wpływu na
wydajność operacji dyskowych ani na szeregowanie.
Aby zapewnić płynny przepływ danych do klientów, na serwerze potrzeba podwójnego bufo-
rowania. Podczas rundy 1. wykorzystywany jest jeden zbiór buforów — po jednym buforze na
strumień. Kiedy runda zostanie zakończona, proces lub procesy wyjściowe odblokowują się
i otrzymują polecenie przesłania ramki nr 1. W tym samym czasie napływają nowe żądania
o ramkę nr 2 dla poszczególnych filmów (dla każdego filmu może działać wątek obsługi dysku
i wątek wyjściowy). Żądania te muszą być obsłużone za pomocą innego zbioru buforów, ponie-
waż pierwszy jest zajęty. Kiedy rozpocznie się runda 3, pierwszy zbiór buforów będzie wolny
i będzie mógł być wykorzystany ponownie do pobrania ramki nr 3.
Założyliśmy, że istnieje jedna runda na ramkę. Takie ograniczenie nie jest konieczne. Jedna
ramka może być obsługiwana przez dwie rundy, np. w celu zmniejszenia ilości wymaganego miej-
sca w buforze, kosztem dwukrotnej liczby operacji dyskowych. Na podobnej zasadzie w jednej
rundzie mogą być pobierane z dysku dwie ramki (przy założeniu, że pary ramek są zapisane na
dysku w sposób ciągły). Taki projekt obcina liczbę operacji dyskowych o połowę, kosztem podwo-
jenia ilości wymaganego miejsca w buforze. W zależności od względnej dostępności, wydajności
i kosztów pamięci w porównaniu z dyskowymi operacjami wejścia-wyjścia, można obliczyć i wyko-
rzystać optymalną strategię.
się obsługa żądania dyskowego, sterownik ma do obsługi pewien zbiór zaległych żądań, z których
musi wybrać jedno. Pytanie brzmi: jakiego algorytmu system używa do wybierania następnego
żądania do obsługi?
Podczas wybierania następnego żądania dyskowego odgrywają rolę dwa czynniki: terminy
i cylindry. Z punktu widzenia wydajności posortowanie żądań według cylindrów i wykorzystanie
algorytmu windy pozwala na minimalizację całkowitego czasu wyszukiwania. Taka strategia może
jednak spowodować niedotrzymanie terminu dla żądań z zewnętrznych cylindrów. Z punktu
widzenia przetwarzania żądań w czasie rzeczywistym posortowanie żądań według terminów
wykonania i przetwarzanie ich w takim porządku — najpierw żądanie o wcześniejszym termi-
nie wykonania — minimalizuje szansę niedotrzymania terminów, ale zwiększa całkowity czas
wyszukiwania.
Czynniki te można ze sobą połączyć za pomocą algorytmu scan-EDF [Reddy i Wyllie, 1994].
Podstawową ideą tego algorytmu jest zbieranie żądań, których terminy wykonania są stosun-
kowo blisko siebie, w paczki i przetwarzanie ich w kolejności cylindrów. Dla przykładu rozważmy
sytuację z rysunku A.24 w chwili t = 700. Sterownik dysku wie, że ma 11 zaległych żądań dla
różnych terminów wykonania i różnych cylindrów. W tym przypadku może on zdecydować, że
np. pięć żądań o najwcześniejszych terminach realizacji tworzy paczkę, i posortować je według
numerów cylindrów. Następnie może wykorzystać algorytm windy w celu obsługi ich w kolej-
ności numerów cylindrów. W tej sytuacji będą kolejno obsługiwane cylindry 110, 330, 440, 676
i 680. O ile każde żądanie zostanie zakończone przed upływem terminu wykonania, żądania można
bezpiecznie przegrupować, tak aby zminimalizować całkowity wymagany czas wyszukiwania.
Jeśli różne strumienie charakteryzują się różnymi szybkościami przesyłania danych, powstaje
poważny problem w sytuacji, gdy pojawia się nowy klient: czy należy go obsłużyć? Jeśli obsłu-
żenie klienta spowoduje konieczność częstego niedotrzymywania terminów dla innych strumieni,
odpowiedź prawdopodobnie brzmi „nie”. Istnieją dwa sposoby podejmowania decyzji o tym, czy
należy obsłużyć nowego klienta, czy nie. Jeden ze sposobów polega na założeniu, że każdy klient
wymaga średnio pewnej liczby zasobów — np. dysku, pasma, buforów pamięci, czasu proce-
sora itp. Jeśli istnieje wystarczająca ilość tych zasobów dla przeciętnego klienta, nowy klient jest
obsługiwany.
Drugi algorytm jest bardziej szczegółowy. Bierze on pod uwagę film, którego żąda nowy klient,
i sprawdza (obliczoną wcześniej) szybkość przesyłania danych dla tego filmu. Wartość ta będzie
inna dla filmów czarno-białych, a inna dla kolorowych. Inna dla filmów animowanych, a inna dla
filmów z aktorami. Różnice występują nawet pomiędzy filmami o miłości a filmami wojennymi.
W tych pierwszych są długie sceny, niewiele ruchu i wolne przejścia. W związku z tym takie filmy
dobrze się kompresują. Z kolei w filmach wojennych występują ostre cięcia i szybka akcja — stąd
wiele ramek I i duże ramki P. Jeśli ilość miejsca na serwerze umożliwia zmieszczenie filmu,
którego żąda klient, system podejmuje decyzję o jego obsłużeniu. W przeciwnym wypadku system
odmawia dostępu.
Multimedia to dziś gorący temat, dlatego są przedmiotem wielu badań. Większość tych badań
dotyczy zawartości, narzędzi projektowania i aplikacji. Wszystkie te zagadnienia wykraczają poza
zakres tej książki. Innym popularnym tematem jest obsługa multimediów w sieci — to zagad-
nienie także wykracza poza ramy tej publikacji. Tymczasem praca na serwerach, zwłaszcza roz-
proszonych, jest powiązana z systemami operacyjnymi [Sarhan i Das, 2004] i [Zaia et al., 2004].
Obsługa systemów plików dla multimediów także jest przedmiotem badań w społeczności twór-
ców systemów operacyjnych — [Diab et al., 2014], [Harter et al., 2012], [Jung et al., 2008],
[Park i Kim, 2013], [Park i Ohm, 2006].
Dobre kodowanie audio i wideo (zwłaszcza dla aplikacji 3D) jest ważne dlatego, że pozwala
zachować wysoką wydajność. W związku z tym zagadnienia te są przedmiotem badań — [Kang
et al., 2012], [Smolic, 2010].
W systemach multimedialnych istotne znaczenie ma jakość usług. Z tego powodu naukowcy
poświęcają uwagę temu tematowi — [Childs i Ingram, 2001], [Tamai et al., 2004]. Z jakością
usług powiązane jest szeregowanie, zarówno w odniesieniu do procesora ([Cucinotta et al., 2011],
[Cucinotta et al., 2012]), jak i dysku ([Reddy et al., 2005]).
Jeśli nadawanie programów multimedialnych jest usługą płatną, to istotne znaczenie ma bez-
pieczeństwo. Z tego względu ten temat przyciąga uwagę badaczy — [Barni, 2006], [Schaber et al.,
2014], [Tang et al., 2013]. Przedmiotem badań jest również zużycie energii przez serwery wideo
i klientów mobilnych — [Kuang et al., 2010], [Hosseini et al., 2013], [Sharma et al., 2013].
A.11. PODSUMOWANIE
A.11.
PODSUMOWANIE
Multimedia nadal szybko się rozwijają. Ze względu na duży rozmiar plików multimedialnych
oraz ich ścisłe wymagania w zakresie działania w czasie rzeczywistym systemy operacyjne zapro-
jektowane dla tekstu nie są optymalne dla multimediów. Pliki multimedialne składają się z wielu
równoległych ścieżek — zazwyczaj jednej ścieżki wideo i jednej audio, a czasami także napi-
sów. Wszystkie te ścieżki podczas odtwarzania muszą być ze sobą zsynchronizowane.
Dźwięk jest rejestrowany poprzez okresowe próbkowanie sygnału — zwykle 44 100 razy/s
(w przypadku dźwięku w jakości CD). Sygnał dźwiękowy może być poddawany kompresji o jedno-
litym współczynniku kompresji wynoszącym około 10×. W kompresji wideo wykorzystuje się
kompresję zarówno między ramkami (JPEG), jak i wewnątrz ramek (MPEG). Ta druga repre-
zentuje ramki P jako różnicę w odniesieniu do poprzedniej ramki. Ramki B pozwalają obliczać
ramki na podstawie poprzedniej lub następnej ramki.
Multimedia wymagają szeregowania w czasie rzeczywistym po to, by było możliwe dotrzy-
manie terminów realizacji. Powszechnie stosowane są dwa algorytmy. Pierwszy to szeregowanie
monotoniczne RMS — statyczny algorytm z wywłaszczaniem, który przypisuje stałe priorytety
do procesów na podstawie ich okresów działania. Drugi z algorytmów — najpierw wcześniejszy
termin — to dynamiczny algorytm, który zawsze wybiera proces o najbliższym terminie reali-
zacji. Algorytm EDF jest bardziej skomplikowany, ale pozwala na osiągnięcie stuprocentowego
wykorzystania procesora. W przypadku zastosowania algorytmu RMS nie jest to możliwe do
osiągnięcia.
W multimedialnych systemach plików zwykle stosuje się model „wypychania” zamiast
„ściągania”.
Po uruchomieniu strumienia bity są wysyłane na dysk bez dalszych żądań użytkownika. Takie
podejście w zasadniczy sposób różni się od konwencjonalnych systemów operacyjnych, ale jest
potrzebne do spełnienia wymagań czasu rzeczywistego.
Pliki mogą być przechowywane w sposób ciągły lub nieciągły. W tym drugim przypadku
jednostka może być zmiennego rozmiaru (jeden blok to jedna ramka) lub stałego rozmiaru (jeden
blok to wiele ramek). Wymienione podejścia są związane z przyjęciem różnych kompromisów.
Rozmieszczenie plików na dysku ma wpływ na wydajność. W przypadku konieczności roz-
mieszczenia wielu plików czasami wykorzystuje się algorytm organowy. Powszechnie za to wyko-
rzystuje się paskowanie plików na wiele dysków — może ono być szerokie lub wąskie. W celu
poprawy wydajności często stosuje się także strategie buforowania bloków i plików.
PYTANIA
1. Jaka jest szybkość transmisji bitów dla nieskompresowanego strumienia full-color XGA
przesyłanego z szybkością 25 klatek na sekundę? Czy źródłem strumienia przesyłanego
z taką szybkością może być dysk UltraWide SCSI?
2. Czy nieskompresowany czarno-biały sygnał telewizji NTSC może być przesyłany przez
szybki Ethernet? Jeśli tak, to ile kanałów na raz można przesłać?
3. Telewizja HDTV charakteryzuje się dwukrotnie wyższą rozdzielczością w poziomie
w porównaniu ze standardową telewizją (1280 zamiast 640 pikseli). Wykorzystując infor-
macje zamieszczone w tekście, odpowiedz na pytanie, o ile więcej pasma wymaga ta tele-
wizja w porównaniu ze standardową.
4. Na rysunku A.2 pokazano dwa oddzielne pliki dla szybkiego przewijania i szybkiego cofania.
Jeśli serwer wideo ma również obsługiwać ruch w zwolnionym tempie, to czy jest wyma-
gany dodatkowy plik dla przewijania w przód ze spowolnioną szybkością? A jak jest
w przypadku kierunku wstecz?
5. Płyta audio CD mieści 74 min muzyki lub 650 MB danych. Oszacuj współczynnik kompresji
stosowany do muzyki.
6. Sygnał dźwiękowy jest kodowany z wykorzystaniem 16-bitowej liczby ze znakiem (1 bit
znaku i 15 bitów wielkości). Ile wynosi w procentach maksymalny szum kwantyzacji? Czy
jest to większy problem podczas słuchania koncertów na flecie, czy podczas słuchania rock’-
n’rolla? A może problem w obu przypadkach jest taki sam? Uzasadnij swoją odpowiedź.
7. Studio nagrań może nagrać wzorcowy materiał cyfrowy z wykorzystaniem próbkowania
20-bitowego. Ostatecznie do słuchaczy dotrze 16 bitów. Zasugeruj sposób redukcji efektu
szumu kwantyzacyjnego oraz wyjaśnij zalety i wady takiego mechanizmu.
8. W systemach NTSC i PAL używany jest taki sam 6-megahercowy kanał, a jednak w syste-
mie NTSC strumień wideo jest przesyłany z szybkością 30 klatek na sekundę, podczas
gdy w systemie PAL tylko 25 klatek na sekundę. Jak to jest możliwe? Czy to znaczy, że
gdyby w obu systemach stosowano ten sam system kodowania kolorów, system NTSC
miałby z natury lepszą jakość niż PAL? Uzasadnij odpowiedź.
21. Rozważmy mechanizm alokacji pamięci trwałej z rysunku A.18(a) dla standardów NTSC
i PAL. Czy dla określonego rozmiaru bloku dysku i czasu trwania filmu jeden ze standardów
charakteryzuje się większą wewnętrzną fragmentacją niż inny? Jeśli tak, to który jest
lepszy i dlaczego?
22. Rozważmy dwie alternatywy pokazane na rysunku A.18. Czy przejście na technologię
HDTV faworyzuje jeden z systemów? Uzasadnij swoją odpowiedź.
23. Rozważmy system o 2-kilobajtowym rozmiarze bloku dyskowego, który przechowuje dwu-
godzinny film PAL przeciętnie 16 kB na ramkę. Jaka jest przeciętna ilość zmarnotrawionego
miejsca w przypadku metody przechowywania danych bazującej na małych blokach
dyskowych?
24. Jaki jest największy rozmiar filmu możliwy do przechowywania, jeśli wpis dla każdej ramki
z powyższego przykładu wymaga 8 bajtów, z czego 1 bajt jest używany do wskazania
liczby bloków dyskowych przypadających na ramkę?
25. Ile bloków skorowidza jest potrzebnych do przechowywania filmu Przeminęło z wiatrem
w formacie PAL dla sytuacji z powyższego przykładu? (Wskazówka: odpowiedź może
uwzględniać różne warianty).
26. Usługa wideo na życzenie Chena i Thapara [Chen i Thapar, 1997] działa najlepiej, jeśli
każdy zbiór ramek ma taki sam rozmiar. Przypuśćmy, że film jest pokazywany w 24 rów-
noległych strumieniach, a co 10 ramka jest ramką I. Załóżmy także, że ramki I mają 10
razy większy rozmiar od ramek P. Ramki B mają taki sam rozmiar jak ramki P. Jakie
jest prawdopodobieństwo tego, że bufor o rozmiarze 4 ramek I oraz 20 ramek P będzie
za mały? Czy sądzisz, że taki rozmiar bufora jest do przyjęcia? Dla ułatwienia załóżmy,
że ramki różnych typów są rozprowadzane pomiędzy strumienie losowo i niezależnie.
27. Jaki powinien być rozmiar bufora dla metody Chena i Thapara przy założeniu, że 5 ście-
żek wymaga 8 ramek I, 35 ścieżek wymaga 5 ramek I, a 45 ścieżek wymaga 3 ramek I,
natomiast każde 15 ramek wymaga od 1 do 2 ramek I, jeśli chcemy zapewnić, aby
w buforze zmieściło się 95 ramek?
28. Załóżmy, że w metodzie Chena i Thapara strumień trzygodzinnego filmu zakodowanego
w formacie PAL powinien być przesyłany co 15 min. Ile potrzeba równoległych strumieni?
29. Układ z rysunku A.17 wymaga czytania wszystkich ścieżek językowych wraz z każdą
ramką. Przypuśćmy, że projektanci serwera wideo muszą obsłużyć wiele języków, ale
nie chcą poświęcać tyle pamięci RAM na bufory, aby można było pomieścić wszystkie
ramki. Jakie alternatywy są dostępne oraz jakie są wady i zalety każdej z nich?
30. Mały serwer wideo zawiera osiem filmów. Jakie prawdopodobieństwa żądań filmów prze-
widuje prawo Zipfa dla najpopularniejszego filmu, drugiego co do popularności itd., aż
do filmu najmniej popularnego?
31. Dysk o pojemności 14 GB zawierający 1000 cylindrów jest wykorzystywany do przecho-
wywania 30-sekundowych klipów wideo MPEG-2 wyświetlanych z szybkością 4 Mb/s.
Są one składowane zgodnie z algorytmem organowym. Ile czasu spędzi głowica dysku na
środkowych 10 cylindrach przy założeniu, że zachodzi prawo Zipfa?
32. Jakie jest oczekiwane wykorzystanie czterech dysków z rysunku A.21 dla czterech poka-
zanych metod paskowania, przy założeniu, że względne zapotrzebowanie na filmy A, B,
C i D jest opisane przez prawo Zipfa?
33. Dwóch klientów usługi wideo na życzenie rozpoczęło oglądanie tego samego filmu PAL
w odstępie 6 s. Jaki procent zwiększenia/zmniejszenia szybkości jest potrzebny do scalenia
dwóch strumieni w ciągu 3 min, jeśli w celu tego scalenia system przyspiesza jeden stru-
mień i opóźnia drugi?
34. Serwer wideo MPEG-2 wykorzystuje schemat rund z rysunku A.23 do transmisji wideo
w formacie NTSC. Wszystkie filmy wideo są przechowywane na pojedynczym dysku
UltraWide SCSI o szybkości obrotowej 10 800 obrotów na minutę ze średnim czasem
wyszukiwania wynoszącym 3 ms. Ile strumieni można obsłużyć?
35. Rozwiąż poprzedni problem jeszcze raz, ale tym razem przyjmij, że algorytm scan-EDF
zmniejsza średni czas wyszukiwania o 20%. Ile strumieni można teraz obsłużyć?
36. Dany jest zbiór żądań do dysku pokazany poniżej. Każde żądanie jest reprezentowane przez
krotkę (termin w ms, cylinder). Kiedy cztery przychodzące terminy realizacji zostaną
połączone w klaster i obsłużone, stosowany jest algorytm scan-EDF. Czy dojdzie do
pominięcia jakiegoś terminu realizacji, jeśli przeciętny czas obsługi każdego żądania
wynosi 6 ms?
(32, 300); (36, 500); (40, 210); (34, 310)
Załóżmy, że bieżący czas to 15 ms.
37. Rozwiąż poprzedni problem jeszcze raz, ale tym razem przyjmij, że każda ramka jest pasko-
wana na 4 dyski, a algorytm scan-EDF zmniejsza średni czas wyszukiwania o 20% na
każdym dysku. Ile strumieni można teraz obsłużyć?
38. W tekście niniejszego dodatku opisano wykorzystanie zestawu pięciu żądań danych w celu
uszeregowania żądań dla sytuacji z rysunku A.24. Jaki jest maksymalny czas na żądanie
dopuszczalny w tym przykładzie, jeśli obsługa każdego żądania zajmuje tyle samo czasu?
39. Wiele map bitowych dostarczanych w celu generowania komputerowych tapet wyko-
rzystuje niewiele kolorów i łatwo poddaje się kompresji. Prosty schemat kompresji działa
w następujący sposób: wybierz wartość danych, która nie występuje w pliku wejścio-
wym, i wykorzystaj ją jako flagę. Przeczytaj plik bajt po bajcie w poszukiwaniu powta-
rzających się wartości. Skopiuj pojedyncze wartości i bajty powtarzające się do trzech
razy bezpośrednio do pliku wyjściowego. W przypadku znalezienia powtarzającego się
ciągu złożonego z 4 lub więcej bajtów zapisz do pliku wyjściowego ciąg złożony z trzech
bajtów: bajta flagi, bajta określającego licznik od 4 do 255 oraz rzeczywistej wartości
znalezionej w pliku wejściowym. Używając tego algorytmu, napisz program kompresujący
oraz program dekompresujący pozwalający na odtworzenie pliku wyjściowego. Zadanie
dodatkowe: jak można obsłużyć pliki zawierające bajt flagi wewnątrz danych?
40. Efekt animacji komputerowej jest uzyskiwany dzięki wyświetlaniu sekwencji niewiele
różniących się od siebie obrazów. Napisz program, który oblicza różnicę bajt po bajcie
pomiędzy dwoma nieskompresowanymi obrazami map bitowych o tych samych wymia-
rach. Plik wynikowy, co oczywiste, będzie miał taki sam rozmiar jak pliki wejściowe.
Wykorzystaj ten plik różnic w roli wejścia do programu kompresującego z poprzedniego
pytania i porównaj skuteczność tego sposobu z kompresją pojedynczych obrazów.
41. Zaimplementuj proste algorytmy RMS i EDF w sposób opisany w tekście. Głównym wej-
ściem programu jest plik składający się z kilku wierszy. Każdy wiersz oznacza żądanie
procesora i ma następujące parametry: okres (w sekundach), czas obliczeń (w sekun-
dach), czas rozpoczęcia (w sekundach) oraz czas zakończenia (w sekundach). Porównaj
te dwa algorytmy pod względem: (a) średniej liczby żądań procesora zablokowanych ze
względu na brak możliwości ich uszeregowania, (b) średniego wykorzystania procesora,
(c) średniego czasu oczekiwania na każde żądanie procesora, (d) średniej liczby chybio-
nych terminów realizacji.
42. Zaimplementuj techniki stałego odcinka czasu i stałego bloku danych używane do składo-
wania plików multimedialnych. Głównym wejściem programu jest zbiór plików, w którym
każdy plik zawiera metadane na temat każdej ramki pliku multimedialnego skompreso-
wanego algorytmem MPEG-2 (np. filmu). Metadane obejmują typ ramki (I/P/B), rozmiar
ramki, powiązane z nią ramki dźwiękowe itp. Porównaj te dwie techniki dla różnych roz-
miarów bloków pod względem całkowitego wymaganego miejsca na dysku, marnotrawio-
nego miejsca na dysku oraz przeciętnego rozmiaru potrzebnej pamięci RAM.
43. Do powyższego systemu dodaj „czytnik” — program, który losowo wybiera pliki z powyż-
szej listy wejściowej w celu odtworzenia ich w trybie wideo na żądanie oraz wideo nie-
mal na żądanie z funkcjami magnetowidu. Zaimplementuj algorytm scan-EDF kolejko-
wania żądań odczytu dysku. Porównaj techniki stałego odcinka czasu i stałego bloku
danych pod względem średniej liczby operacji wyszukiwania na dysku na plik.
1119
F H
fałszywe współdzielenie, 563 HAL, Hardware Abstraction Layer, 876
HAL Development Kit, 878
FAT, File Allocation Table, 300
hasła, 628
FIFO, First-In, First-Out, 229
jednorazowe, 631
filmy, 1067 heterogeniczne procesory wielordzeniowe, 533
firewalle, 685 hierarchia
firma VMware, 503 dziedziczenia, 822
flaga przerwań, 488 katalogów, 579
flagi mapy bitowej, 747 pamięci, 52, 201
formatowanie dysków, 384 procesów Androida, 115, 810
FPGA, Field-Programmable Gate Arrays, 555 hierarchiczne systemy katalogów, 292
framework hipernadzorca, hypervisor, 96, 485, 876
KMDF, 945 typu 1, 483
UMDF, 945 typu 2, 483
funkcja Xen, 501
gets, 641 hipersześcian czterowymiarowy, 549
printf, 649 historia systemu
Rectangle, 419 Android, 805
skrótu Linux, 716
SHA-1, 622 UNIX, 716
strcmp, 657 Windows, 855
XOpenDisplay, 411
funkcje I
atomowe, 156
jednokierunkowe, 622 IAAS, Infrastructure as a Service, 500
kryptograficzne, 609 IAT, Import Address Table, 902
skrótu, 622 IDE, Integrated Drive Electronics, 379
.wmf, 419
P
atrybuty, 286
PAAS, Platform as a Service, 500 binarne, 284
pakiet deskryptory, 290
Pthreads, 129, 156 implementacja, 297
SDK, 806 multimedialne, 1072, 1095
WDK, 945 nagłówkowe, 99
pakiety żądań wejścia-wyjścia, 946 nazwy, 281
pamięci o dużej pojemności, 74, 223 odwzorowane w pamięci, 248, 758
pamięć, 51, 430 program do kopiowania, 289
EEPROM, 54 rozszerzenia, 282
fizyczna, 935 stron, 926
flash, 54 struktura, 283
masowa, 393 typu peer-to-peer, 677
podręczna, 328, 939 typy, 284
podręczna L2, 53 współdzielone, 304
RAM, 53, 201, 396 wykonywalne, 285
ROM, 53 z pojedynczą blokadą, 782
wirtualna, 76, 213, 262, 928 PLT, Procedure Linkage Table, 645
paradygmaty plug and play, 60, 859
danych, 988 płyta macierzysta, motherboard, 61
interfejsu użytkownika, 986 pobieranie plików bez wiedzy, 677
multimedialnych systemów wykonywania, 987 podmiot, 605
plików, 1091 podpisy cyfrowe, 622
parametry dyskowe, 380 podpisywanie kodu, 693
parawirtualizacja, 488, 489 podsystemy, 901
partycjonowanie pamięci, 534 podsystemy NT, 865
paski, strips, 382 podział czasu, 39, 543
paskowanie, striping, 382 pola, 1074
patchguard, 971 pakietu żądań wejścia-wyjścia, 947
PBA, parallel bus architecture, 59 struktury i-węzła, 790
PCR, Platform Configuration Register, 625 polecenie lseek, 784
PDA, Personal Digital Assistant, 46, 63 port ALPC, 900
PDE, Page Directory Entry, 933 pośrednictwo, 1006
PDP-11 UNIX, 717 potok trójfazowy, 49
PEB, Process Environment Block, 905, 917 potoki, pipes, 45, 737
perspektywy, views, 939 poufność, 596
PFF, Page Fault Frequency, 240 powłoka, 71, 728
PFN database, 935 uproszczona, 81
piaskownice aplikacji, 835 powstawanie
PID, Process Identifier, 736, 742 przerwań, 359
pierścień, 549 zakleszczeń, 447
PKI, Public Key Infrastructure, 623 poziomy RAID, 383
platforma priorytety
jako usługa, 500 systemu Windows, 921
x86, 505 wątków, 922
plik problem
AndroidManifest.xml, 824 czytelników i pisarzy, 190
lib.dll, 930 pięciu filozofów, 187
ntoskrnl.exe, 891 producent-konsument, 150, 152, 165
pliki, 68, 281 relokacji, 204
.dll, 89
przetwarzanie w chmurze, 477, 1022, 1035 rozmieszczenie plików multimedialnych, 1100, 1095
przydzielanie rozpowszechnianie
adresów wirtualnych, 925 oprogramowania szpiegującego, 677
dedykowanych urządzeń, 376 wirusów, 672
pamięci, 763 rozpoznawanie tęczówki, 636
przynęty, honeypots, 697 rozproszona pamięć współdzielona, 249, 560
przyspieszenie stronicowania, 219 rozszerzenia
PTE, Page Table Entries, 933 Joliet, 342
publikacje, 1031 plików, 282
wprowadzające i ogólne, 1032
Rock Ridge, 341
publikuj-subskrybuj, 585
rozszerzona maszyna, 32
pule wątków, 907
rozwiązania siłowe, 1008
pułapka, breakpoint, 927
punkty rozwiązanie Petersona, 146
kontrolne, 502 równoważenie obciążenia, 565
przyłączania, 950 RPC, Remote Procedure Calls, 560, 888, 913,
559, 862
ruch ramienia dysku, 330
R
RAID, 381 S
RAM, Random Access Memory, 53, 201
ramka, 1073 SAAS, Software as a Service, 500
wideo, 1082 SACL, System Access Control List, 965
raportowanie błędów, 376 SAM, Security Access Manager, 873
RDMA, Remote Direct Memory Access, 554 sandboxing, 477
RDP, Remote Desktop Protocol, 924 SATA, Serial ATA, 32, 56, 379
rdzeń, 51 SBA, shared bus architecture, 59
regiony krytyczne, 143 schemat
reguły ochrony, 703 list, 936
rejestr stosów urządzeń, 890
bazowy, 57
SCSI, Small Computer System Interface, 60
systemu Windows, 872
SDK, Software Development Kit, 806
bazy i limitu, 206
segmentacja, 256, 259
rekord tablicy MFT, 955, 957
klasyczna, 259
relokacja, 204
ze stronicowaniem, 260, 263
remapowanie adresów pamięci, 55
sekcja, 900
replikacja, 562
sektor dysku, 385
reprezentacja uprawnienia, 609
sekwencja ucieczki, 408
RGB, Red, Green, Blue, 1074
selektor x86, 264
RMS, Rate Monotonic Scheduling, 1088
semafor, 151, 446, 900
robaki, 673
semantyka współdzielenia plików, 580
rodzaje
serwer, 125, 409
komunikatów, 411
siatka, 549
rootkitów, 680
sieciowy system plików, NFS, 794
systemów, 569
sieć
ROM, Read Only Memory, 53, 202
botnet, 597
rootkit, 680, 971
Ethernet, 570
firmy Sony, 683
Internet, 572
ROP, Return-Oriented Programming, 645, 970
LAN, 570
rozgałęźnik-wampir, 571
przełącznikowa omega, 527
rozmiar
WAN, 570
bloku, 313, 377
WWW, 577
maksymalny partycji, 335
silniki mutacji, 690
strony, 242
offline, 36
T
piaskownicy, 477
tabela FAT, 300 użytkownika, 29
tabele stron, 217, 223, 493 tryby ochrony pliku, 801
odwrócone, 225 tworzenie
wielopoziomowe, 223 dowiązania, 779
tablica procesów, 112, 736, 816
GPT, 388 tylne drzwi, back door, 656
plików, 952 typ master-slave, 535
PLT, 645 typy
stron, 218, 931 obiektów, 900
uchwytów, 894, 895 plików, 284
takt zegara, 397 sieci, 570
TCP, Transmission Control Protocol, 575, 772 usług sieciowych, 575
TEB, Thread Environment Block, 905, 917 wieloprocesorowych systemów operacyjnych, 534
technika
ASLR, 646
plug and play, 60
U
techniki UAC, User Account Control, 968
antyantywirusowe, 687 uchwyty, 894
antywirusowe, 687 UDF, Universal Disk Format, 298
biometryczne, 635 udostępnianie zdjęcia, 829, 840
skutecznej wirtualizacji, 484 UEFI, Unified Extensible Firmware Interface, 890
technologia UID, User ID, 67, 800
RAID, 381 układ
wewnętrznych połączeń, 549 czterordzeniowy, 51
teoria grafów, 566 systemu plików, 296
test POST, 753 układy
THE, 90 scalone, 37
TLB, Translation Lookaside Buffer, 929 ultrawielordzeniowe, 532
TLS, Thread Local Storage, 905 wielordzeniowe, 50, 530, 1022
tłumacz binarny, 485 wielowątkowe, 50
TOCTOU, 655 ukryty kanał komunikacyjny, 614, 616
token, 964 ukrywanie sprzętu, 1004
dostępu, 900, 965 UMS, User-Mode Scheduling, 908
topologie wewnętrznych połączeń, 549 UNICS, 716
torus podwójny, 549 unikanie
TPM, Trusted Platform Module, 891 blokad, 168
trafienie pamięci, 52 kanarków, 642
trajektorie zasobów, 457 wirusów, 692
transfer UNIX, 45, 716
obiektów, 820 uprawnienia, 607
poprzez DMA, 356 superużytkownika, 601
transformacja falkowa Gabora, 636 uruchamianie
transmisja równoległa, 59 aktywności, 826
tranzystory, 35 aplikacji, 827
tryb komputera, 61
beztaktowy, 749 procesów, 842
jądra, 29 systemu Linux, 753
nadzorcy, 29 systemu Windows, 890
niekanoniczny, 403 usługi, 830
wydajność systemu
X
operacyjnego, 1009
plików, 327 X Window System, 45
wyjście VM, 492
wykonywanie danych, 643
wykorzystanie luk w oprogramowaniu, 638
Z
wykrywanie zabezpieczenia, 71
rootkitów, 681 aplikacji, 836
włamań, 695 sprzętowe, 74
zakleszczeń, 451, 453 zabieranie cykli, cycle stealing, 357
wyłączanie przerwań, 144 zabijanie procesów, 456
wymagania dotyczące wirtualizacji, 480 zabójca OOM, 813
wymiana pamięci, 207 zadania, jobs, 906
wysyłanie komunikatu rozgłoszeniowego, 832 zagłodzenia, 469
wyszukiwanie pliku, 338 zagrożenia, 596
wyścig, 141 zakleszczenie, deadlock, 443, 1035
wyświetlacz, 427 komunikacyjne, 465
wywłaszczanie, 444, 455 modelowanie, 448
wywołania przeciwdziałanie, 462
API, 911, 965 unikanie, 449, 457
biblioteczne, 86 usuwanie, 455
blokujące, 556 warunki powstawania, 447
funkcji Win32, 869 wykrywanie, 451, 453
interfejsu NT API, 867, 943 wywłaszczanie, 455
interfejsu Win32 API, 874, 916 zalecenia projektowe
nieblokujące, 556 kompletność, 984
pakietu Pthreads, 157, 158 paradygmaty, 986
procedur, 558 prostota, 984
rdzennego NT API, 869 wydajność, 985
RPC, 913 zależności pomiędzy procesami, 844
systemowe, 76 zamiary, intents, 834
do zarządzania katalogami, 83 zapętlanie, 541
do zarządzania plikami, 82 zapobieganie wykonywaniu danych, 643
do zarządzania procesami, 79 zarządca, 135
Linuksa, 738, 759 zarządzanie
różne, 85 bateriami, 431
wejścia-wyjścia, 772 buforem TLB, 221
systemu plików, 782 energią, 424, 960
wywołanie katalogami, 80, 83
chmod, 85 miejscem na dysku, 313
read, 124 obciążeniem, 241
sleep, 149 pamięcią, 118, 210, 211, 267, 1033
stat, 784 pamięcią, 201, 924
time, 85 pamięcią fizyczną, 760, 935
wakeup, 149 pamięcią W Linuksie, 755
Win32 API, 869 pamięcią wirtualną, 928
wyzwanie-odpowiedź, challenge-response, 632 plikami, 80, 82, 118
wzajemne wykluczanie, 144, 462 procesami, 79, 80, 118, 911, 916
wzorzec skanowania, 1074 procesorem, 909
wątkami, 911, 916
zarządzanie
włóknami, 911, 916
zadaniami, 911
zarządzanie projektem, 1017
systemem plików, 80
systemem plików, 313
zarządzanie temperaturą, 431
zarządzanie wolną pamięcią, 210
zarządzanie zasobami, 909
zasada
Kerckhoffsa, 620
pola, 603
zasady projektowe, 1040
zasoby, 444
zastępowanie stron, 226, 227
zaufana baza obliczeniowa, 601
zbiór roboczy, working set, 233, 934
zdalne wywołanie procedury, RPC, 558
zdarzenie, 900
zdobywanie zasobu, 445
zegary, 396
programowe, 400
złośliwe oprogramowanie, 639, 658
zmienne globalne, 139
znak
CR, 405
EOF, 405
ERASE, 405
INTR, 405
KILL, 405
LNEXT, 405
NL, 405
QUIT, 405
START, 405
STOP, 405
zwalnianie dedykowanych urządzeń, 376
Ż
żądanie uprawnień, 838
żniwiarze gadżetów, gadget harvesters, 645