I
Systemsoftware - Grundlagen moderner Betriebssysteme
Jürgen Nehmer hat an der TH Karlsruhe Nachrichtentechnik studiert. Nach einer Industrietätigkeit bei Siemens war er als wiss. Mitarbeiter am Kernforschungszentrum Karlsruhe tätig. 1971 hielt er sich ein Jahr als Gastwissenschaftler am T. J. Watson Research Center der IBM in Yorktown Heights, USA auf. Er promovierte 1973 an der Fakultät für Informatik der Universität Karlsruhe. Seit 1979 ist er Professor für Informatik an der Universität Kaiserslautern; seine Interessen liegen auf den Gebieten Betriebssysteme, Verteilte Systeme, Echtzeitsysteme und Software Engineering.
Peter Sturm hat an der Universität Kaiserslautern Informatik mit Nebenfach Mathematik studiert. Anschließend war er dort am Lehrstuhl für Systemsoftware (Prof. Nehmer) zunächst als wiss. Mitarbeiter und nach der Promotion als Hochschulassistent tätig. Seit 1997 ist er Professor für praktische Informatik an der Universität Trier; seine Arbeitsschwerpunkte liegen im Bereich Systemsoftware und Verteilte Systeme.
dpunkt.lehrbuch Bücher und Teachware für die moderne Informatikausbildung Berater für die dpunkt.lehrbücher sind: Prof. Dr. Gerti Kappel, E-Mail:
[email protected] Prof. Dr. Ralf Steinmetz, E-Mail:
[email protected] Prof. Dr. Martina Zitterbart, E-Mail:
[email protected] Jürgen Nehmer • Peter Sturm
Systemsoftware Grundlagen moderner Betriebssysteme
2., aktualisierte Auflage
dpunkt.verlag
Prof. Dr, Jürgen Nehmer Universität Kaiserslautern Fachbereich Informatik AG Systemsoftware Postfach 3049 67653 Kaiserslautern E-Mail:
[email protected] Prof. Dr. Peter Sturm Universität Trier Fachbereich IV Informatik Systemsoftware und Verteilte Systeme 54286 Trier E-Mail:
[email protected] Lektorat: Christa Preisendanz Copy-Editing: Ursula Zimpfer, Herrenberg Satz: FrameMaker-Dateien von den Autoren Herstellung: Josef Hegele Umschlaggestaltung: Helmut Kraus, Düsseldorf Druck: Koninklijke Wöhrmann B.V., Zutphen, Niederlande
Die Deutsche Bibliothek- CIP-Einheitsaufnahme Nehmerjürgen: Systemsoftware Grundlagen moderner Betriebssysteme / Jürgen Nehmer; Peter Sturm. 2., aktualisierte Aufl. - Heidelberg : dpunkt-Verl., 2001 (dpunkt-Lehrbuch) ISBN 3-89864-115-5
Copyright © 2001 dpunkt .verlag GmbH Ringstraße 19 b 69115 Heidelberg
Die vorliegende Publikation ist urheberrechtlich geschützt. Alle Rechte vorbehalten. Die Verwendung der Texte und Abbildungen, auch auszugsweise, ist ohne die schriftliche Zustimmung des Verlags urheberrechtswidrig und daher strafbar. Dies gilt insbesondere für die Vervielfältigung, Übersetzung oder die Verwendung in elektronischen Systemen. Alle Informationen in diesem Buch wurden mit größter Sorgfalt kontrolliert. Weder Autoren noch Verlag können jedoch für Schäden haftbar gemacht werden, die in Zusammenhang mit der Verwendung dieses Buches stehen. In diesem Buch werden eingetragene Warenzeichen, Handelsnamen und Gebrauchsnamen verwendet. Auch wenn diese nicht als solche gekennzeichnet sind, gelten die entsprechenden Schutzbestimmungen.
Vorwort zur zweiten Auflage
In den zurückliegenden drei Jahren hat sich im Bereich Systemsoftware viel bewegt. Der Linux-Kern ist seit kurzem in der Version 2.4 verfügbar, und Microsoft bietet seit geraumer Zeit eine auf Windows NT aufbauende Version Windows 2000 an. Die neuen Versionen dieser Betriebssysteme unterstützen eine Vielzahl an neuen Geräten, sind besser an die besonderen Bedürfnisse großer Server-Systeme anpaßbar und lassen sich leichter in ein vorhandenes Rechnernetz integrieren. Obwohl sich in beiden Systemen deshalb viele Dinge geändert haben, blieben die grundlegende Systemarchitektur und die angebotene Programmierschnittstelle davon in wesentlichen Teilen unberührt. In der vorliegenden zweiten Auflage fand die notwendige Aktualisierung dieses Lehrbuchs statt. Natürlich wurden auch alle bis jetzt bekannt gewordenen Fehler korrigiert und der Text an die momentan gültigen Kenngrößen und Preise im Hardwarebereich angepaßt. Allen interessierten und kritischen Lesern, die uns auf Fehler aufmerksam gemacht haben, sei an dieser Stelle herzlich gedankt.
Kaiserslautern und Trier, im Februar 2001 Jürgen Nehmer, Peter Sturm
Vorwort
Die Betriebsorganisation einzelner Rechner sowie vernetzter Rechnersysteme ist eine Kerndisziplin der Informatik mit einer nahezu 50jährigen Entwicklungsgeschichte. Theorie und Praxis der Betriebssysteme werden in zahlreichen Lehrbüchern behandelt. Die QuasiStandards UNIX (inkl. der Derivate) und Microsoft Windows haben sich durchgesetzt. Vor diesem Hintergrund stellt sich die Frage, welche neuen Entwicklungen heutzutage ein weiteres Lehrbuch auf diesem Gebiet rechtfertigen. Eine Antwort auf diese Frage liegt in den ungebremsten Fortschritten der Rechnertechnologie begründet. Als Bindeglied zwischen Hardware und Anwendungssoftware haben Betriebssysteme schon immer eine Mittlerrolle zwischen den Anwendungsanforderungen und den technologiebedingten Einschränkungen der Rechnerhardware übernommen. Abstraktion war das mächtige Konzept, mit dem dieser Brückenschlag gelang. Dahinter verbirgt sich das Konzept, Anwendungen eine Sicht auf die Rechnerhardware zur Verfügung zu stellen, die von unwesentlichen Details und technologiebedingten Beschränkungen befreit ist. Was adäquate Abstraktionen sind, wird jedoch weitgehend durch ein gegebenes Leistungsspektrum der Hardwaretechnologie bestimmt. So macht es beispielsweise wenig Sinn, die Abstraktion nahezu unbegrenzter Adreßräume für Anwendungen einzuführen, ohne eine effektive Hardwareunterstützung in Form des Konzepts der virtuellen Adressierung zur Verfügung zu haben. Aus diesem Grund haben die Abstraktionen der frühen Betriebssystemgenerationen schrittweise Verallgemeinerungen erfahren, von denen heute jede Anwendung profitiert. Das gewachsene Verständnis über einen strukturierten Aufbau von Betriebssystemen und die wechselseitigen Abhängigkeiten der Mechanismen fand seinen Niederschlag in einer modularen Architektur, die zwischen Kernfunktionen und darauf aufbauenden höheren Funktionen unterscheidet, kernbasierte Systemarchitekturen sind der Schlüssel für die Offenheit und Anpaßbarkeit von Betriebssystemen an unterschiedlichste Anwendungsanforderungen. Bei kernbasierten Betriebssystemarchitekturen wird die Grenze zwischen Anwendungen und klassischen Betriebssystemdiensten fließend. Aus diesem Grund
Vorwort
haben wir uns für den Buchtitel Systemsoftware entschieden: Für den Anwender ist die Bereitstellung einer bestimmten Funktionalität entscheidend, unabhängig davon, ob sie im Kern, in einem Laufzeitpaket des Anwendungsadreßraumes oder durch einen unabhängigen Server erbracht wird. Die damit vollzogene Ablösung der früheren monolithischen Betriebssysteme ist wohl die größte Leistung der Betriebssystemforschung der letzten Jahre und wird künftige Betriebssystemgenerationen nachhaltig beeinflussen. Das vorliegende Lehrbuch stellt die kernbasierte Systemarchitektur in den Mittelpunkt der Betrachtungen. Aufbauend auf der Einführung verbreiteter Laufzeitmodelle werden die heute gängigen Betriebssystemmechanismen vorgestellt und ihre Einbettung in die Gesamtarchitektur demonstriert. Jedes Kapitel behandelt ein Thema aus konzeptueller, allgemeingültiger Sicht und schließt mit einer Demonstration anhand des UNIX-Standards POSIX bzw. der Win32Programmierschnittstelle der Microsoft-Betriebssysteme Windows 95, und Windows NT. Echtzeitaspekte werden immer angesprochen, wenn sie für eine Thematik relevant sind. Wir möchten mit diesem Buch Studenten der Fachrichtungen Informatik und Informationstechnik an Universitäten und Fachhochschulen sowie Softwareentwickler in Wirtschaft und Industrie ansprechen, die sich detaillierte Grundkenntnisse über die Funktionsweise und den Aufbau moderner Systemsoftware erwerben wollen. Ohne die Unterstützung durch einen umfangreichen Personenkreis wäre das Buch sicher nicht zustandegekommen. Namentlich erwähnen möchten wir Frau Hofbauer, die wesentliche Teile der Schreibarbeiten erledigt hat, sowie die Gutachter Frau Prof. M. Zitterbart, TU Braunschweig, und Herrn Prof. T. Braun, Universität Bern, die mit ihren konstruktiven Kommentaren wesentlich zur Verbesserung des Textes beigetragen haben. Der dpunkt.verlag und insbesondere Frau C. Preisendanz haben unendliche Geduld mit uns aufgebracht. Ihnen allen gilt unser herzlicher Dank.
Kaiserslautern und Trier, im Mai 1998 Jürgen Nehmer, Peter Sturm
Inhaltsverzeichnis
1
Einleitung
1
2 2.1 2.2 2.3 2.4 2.5
Hardware-Grundlagen Der Prozessor Der Speicher Ein- und Ausgabegeräte Nebenläufigkeit Eine abstrakte Rechnerarchitektur
5 6 10 14 19 22
3 3.1 3.2 3.3 3.4
Laufzeitunterstützung aus Anwendersicht Unverzichtbare Dienste Elementare Laufzeitmodelle Erweiterung der elementaren Laufzeitmodelle Grobarchitektur von Laufzeitsystemen
27 30 34 37 39
4 4.1 4.2 4.3 4.4 4.5 4.6 4.7
Adreßräume Organisation von Adreßräumen aus Anwendungssicht . . . Physischer Adreßraum Segmentbasierter virtueller Adreßraum Seitenbasierter virtueller Adreßraum Dynamische Seitenersetzung Swapping ganzer Adreßräume Implementierungsaspekte
41 43 53 59 65 76 88 88
5 5.1 5.2 5.3 5.4 5.5 5.6 5.7
Threads Anforderungen Zustandsmodelle Monoprozessor-Scheduling Echtzeit-Scheduling Multiprozessor-Scheduling Thread-Unterstützung durch APIs Implementierungsaspekte
97 100 108 112 122 131 136 145
Inhaltsverzeichnis
6 6.1
6.5
Speicherbasierte Prozeßinteraktion Mechanismen auf der Basis atomarer Speicheroperationen Hardwaregestützte Mechanismen Betriebssystemgestützter Mechanismus: Semaphore . . . . 6.3.1 Das Konzept 6.3.2 Beispiele mit Semaphoren 6.3.3 Implementierungsaspekte 6.3.4 Erweiterungen für die Echtzeitverarbeitung Sprachgestützter Mechanismus: Monitore 6.4.1 Das Konzept 6.4.2 Beispiele mit Monitoren 6.4.3 Implementierungsaspekte 6.4.4 Erweiterungen für Echtzeitverarbeitung Realisierungsbeispiele
7 7.1 7.2 7.3 7.4 7.5 7.6 7.7
Nachrichtenbasierte Prozeßinteraktion 199 Elementare Nachrichtenkommunikationsmodelle 201 Erweiterungen elementarer Kommunikationsmodelle . . 213 Remote Procedure Call (RPC) 218 Signale 222 Echtzeitaspekte 224 Implementierungsaspekte 224 Nachrichtenkommunikation im POSIX-Standard 226
8 8.1 8.2 8.3 8.4
Synchronisationsfehler Beispiele zeitabhängiger Fehler Formale Modelle Erkennungs- und Vermeidungsalgorithmen Realisierungsbeispiele
235 236 240 246 251
9 9.1 9.2 9.3 9.4 9.5
Dateisysteme Anforderungen Dateien Verzeichnisse Schichtenmodell Realisierungsaspekte
253 255 259 267 274 278
10 10.1 10.2 10.3
Ein- und Ausgabe Konzepte Einbettung der E/A in das Dateisystem Dedizierte Geräte-APIs
283 284 289 290
6.2 6.3
6.4
153 158 161 163 163 165 170 172 173 174 178 182 184 185
Inhaltsverzeichnis
11 11.1 11.2
Schutz Die Schutzmatrix Schutz in UNIX
293 293 298
12 12.1 12.2 12.3 12.4
Zugang zur Systemsoftware Start neuer Prozesse Prozeßverwaltung Zugang zum Dateisystem Batch- und Skript-Dateien
301 304 309 312 313
13 13.1 13.2 13.3 13.4 13.5
Implementierungsaspekte für Systemsoftware Speichereinbettung der Kerne Serielle versus nebenläufige Kerne Kerne ohne E/A-Unterstützung Nichtblockierende Kerne Minimalkerne
315 320 321 324 325 327
Glossar
329
Abkürzungen
343
Literaturhinweise
345
Index
353
1
Einleitung
Mit dem vorliegenden Lehrbuch wird eine klassische Disziplin der Informatik - die Betriebssysteme - auf eine neuartige Weise vermittelt, die der Entwicklung dieses Gebietes in den zurückliegenden 10 Jahren Rechnung trägt. Wir haben deshalb im Titel bewußt den Begriff »Systemsoftware« gewählt, um eine entscheidende Veränderung in der Sichtweise auf Betriebssysteme zum Ausdruck zu bringen: die Wandlung vom monolithischen System hin zu einem stark gegliederten System, in dem die Grenzen zur Anwendung fließend sind. Genau genommen läßt sich in einem nach modernen Architekturprinzipien strukturierten Softwaresystem gar nicht mehr klar definieren, welche Komponenten zum Betriebssystem oder zur Anwendung gehören. Mittels Systemsoftware wird Anwendungen der komfortable Zugang zu den Hardwareressourcen eines Rechnersystems eröffnet. Sie erfüllt damit eine wichtige Brückenfunktion zwischen Anwendungen und der Rechnerhardware und sorgt bei einem Mehrbenutzer-/Mehrprogrammbetrieb durch entsprechende Koordinierung dafür, daß sich unabhängige Anwendungsprogramme beim Zugriff auf die Ressourcen des Rechnersystems nicht in die Quere kommen. Was im einzelnen unter einem komfortablen Zugang zur Hardware verstanden wird, ist allerdings sehr interpretationsbedürftig. Eine Anwendung, die beispielsweise Daten persistent auf einem externen Medium speichern möchte, kann dies auf unterschiedliche Weise tun:
• • • •
durch direktes Abspeichern von Blöcken auf einer Platte, durch Benutzung eines Dateisystems, das seinerseits eine Platte benutzt, durch Verwendung eines Datenbanksystems, das auf einer Dateisystem-Schnittstelle aufsetzt, durch Verwendung persistenter Objekte in einer objektorientierten Programmierumgebung, die eine Datenbank als Unterstützung voraussetzt.
Bei jeder dieser Möglichkeiten wird letztlich ein Gerät benutzt. Welche Abstraktionsebene als geeignete Basis für Anwendungen anzusehen ist, läßt sich jedoch nur anwendungsabhängig beantworten. In der
Aufgaben der Systemsoftware
1
Laufzeitplattform
Systemsoftware
Einleitung
Zeit der sogenannten monolithischen Betriebssysteme mit ihrem abgeschlossenen Funktionsvorrat gab es eine klare Aufteilung: hier die Anwendungssoftware, dort das Betriebssystem. Diese Situation hat sich drastisch geändert. Anwendungen wird in ihrem Adreßraum ein Laufzeitpaket zur Verfügung gestellt. Die Menge der darin enthaltenen Funktionen wird oft auch als Laufzeitplattform oder »Application Programming Interface« (API) bezeichnet und eröffnet u.a. den Zugang zu klassischen Betriebssystemdiensten. Es ist jedoch nicht sinnvoll, daß ein Laufzeitpaket alle ansprechbaren Funktionen selbst realisiert. Vielmehr wird die Erbringung der hinter einer Laufzeitroutine stehenden Funktionalität oft an einen Systemkern oder spezielle Server delegiert. Im letzteren Fall spielt der Kern lediglich eine Vermittlerrolle: Er überträgt die Aufträge und Ergebnisse zwischen Auftraggebern (Clients) und den Diensteerbringern (Servern) und nimmt damit eine wichtige Infrastrukturleistung wahr. Die Betriebssysteme alter Prägung sind ersetzt worden durch stark gegliederte Systeme, bei denen die Grenzen zur Anwendung verschwimmen. So können bestimmte Laufzeitroutinen oder Server entweder noch sehr beriebssystemnahe Dienste im klassischen Sinne erbringen oder aber bereits sehr stark auf eine bestimmte Anwendungsdomäne zugeschnitten sein. Der Begriff »Betriebssystem« verliert damit allmählich seinen Sinn. Mit dem neutralen Begriff »Systemsoftware« wollen wir zum Ausdruck bringen, daß die Offenheit und Fähigkeit zur anwendungsangepaßten Konfigurierbarkeit hardwarenaher Programme zentrales Anliegen aller Anwendungen ist. Die Softwarearchitektur, die diese Eigenschaften unterstützt, hat deshalb eine größere Bedeutung als der fest umrissene Funktionsvorrat von Betriebssystemen alter Prägung. Es ist das Anliegen des Buches, diese Botschaft zu vermitteln. Die Vorstellung bekannter Betriebssystemmechanismen erfolgt deshalb im Kontext einer offenen Systemarchitektur, die den übergeordneten Rahmen darstellt. Behandelte Systeme Die vorgestellten Konzepte und Techniken werden am Beispiel realer Systemsoftware konkretisiert. Der Zugang zu der Funktionalität wird in diesem Buch primär durch die jeweiligen Programmierschnittstellen erschlossen. Diese Schnittstellen werden am Beispiel der aktuellen Windows-Systeme von Microsoft sowie im Kontext des POSIX-Standards, der Schnittstellendefinitionen für eine Vielzahl an UNIX-basierten Betriebssystemen festlegt, exemplarisch vorgestellt. Dabei ist es kein Anliegen dieses Buches, als Programmierhilfe für die Entwicklung von Anwendungen auf diesen Systemen zu dienen. Aus der Vielfalt an
1
Einleitung
technischer Literatur sei dazu für das Windows-API auf [Richter 1999] und für POSIX- und UNIX-Programmierung auf [Gallmeister 1995] und [Stevens 1992] verwiesen. Da sich moderne Systeme in ihrem Funktionsangebot und in ihrem grundsätzlichen Aufbau zunehmend ähnlicher werden, wurde zur Vermeidung von Wiederholungen auf in sich abgeschlossene Einführungen in die jeweilige Systemsoftware verzichtet. Vielmehr wird das jeweilige Funktionsangebot der verschiedenen Systeme am Ende des entsprechenden Kapitels vorgestellt. Zusatzinformationen Freundlicherweise hat sich der dpunkt-Verlag bereit erklärt, eine WWW-Seite zu diesem Buch aufzubauen. Über die URL http://www.dpunkt.de/syssoft
können Interessenten auf Zusatzinformationen, interessante Links und Korrekturen zu diesem Buch zugreifen. Zu einem späteren Zeitpunkt sollen auch Übungsaufgaben zu den einzelnen Kapiteln das Angebot ergänzen.
2
Hardware-Grundlagen
Eine wesentliche Aufgabe der Systemsoftware besteht in der Bereitstellung höherer Abstraktionen für den bequemen Zugriff von Anwendungen und Anwendern auf die Hardware eines Rechners. Dazu sind Grundkentnisse über den Aufbau und die wesentlichen Eigenschaften heutiger Rechnersysteme unerläßlich. Eine erschöpfende Behandlung dieses Stoffgebiets ist an dieser Stelle nicht möglich. Der interessierte Leser sei zum Beispiel auf [Hennessy und Patterson 1990], [Stallings 1993] oder [Tanenbaum 1990] verwiesen. Abb. 2-1 Von-Neumann-Rechner
Bis auf wenige, im Forschungsbereich angesiedelte Ausnahmen basieren alle heutigen Computersysteme auf einem 1944 von Eckert, Mauchly und von Neumann entwickelten Architekturprinzip. Danach besteht ein Computersystem in erster Näherung aus einem Prozessor, einem Speieber und ein oder mehreren Ein- und Ausgabegeräten (siehe Abbildung 2-1). Die einzelnen Komponenten des Systems sind über insgesamt drei Busse miteinander verknüpft. Der Adreßbus adressiert einzelne Datenzellen eines Speicherbausteins oder eines E/A-Gerätes. Er besitzt eine Breite von 16, 32 oder 64 Bit (= Anzahl Signalleitungen). Die Breite dieses Busses legt die maximale Anzahl an adressierbaren Zellen fest, z.B. können über einen 32 Bit breiten Adreßbus maximal 232 Zellen angesprochen werden (bei einer Zellengröße von einem Byte wären das 4 GByte). Über den Steuerbus werden die einzelnen Lese- und Schreibzyklen zwischen dem Prozessor und den anderen
Adreßbus
Steuerbus
2
Datenbus
Hardware-Grundlagen
Komponenten koordiniert. Er umfaßt Signalleitungen zur Unterscheidung von Lese- und Schreibaufträgen, zur Festlegung der Zyklusart, zur Synchronisation paralleler Abläufe zwischen den einzelnen Komponenten und zur Signalisierung externer Ereignisse an den Prozessor. Der Datenbus dient der eigentlichen Informationsübertragung. Die Breite dieses Busses bestimmt, wie viele Einzelzyklen für die Übermittlung eines Datums bestimmter Länge notwendig sind. Gängige Datenbusbreiten sind 8, 16 und 32 Bit.
2.1
Registerzugriffszeiten liegen bei modernen Computern im Bereich von 1-3 ns
Der Prozessor
Die wesentliche Aufgabe des Prozessors ist die sequentielle Ausführung einer Instruktions- oder Befehlsfolge (Programm). Alle Instruktionen sind zusammen mit den zu verarbeitenden Daten im Speicher des Rechners abgelegt (von-Neumann-Prinzip). Im Zuge jeder einzelnen Befehlsausführung werden Daten im Prozessor verarbeitet oder zwischen Prozessor, Speicher und Gerät bewegt. In der Summe haben alle diese Aktionen ein von außen beobachtbares und im Idealfall erwünschtes Systemverhalten entsprechend dem Einsatzgebiet des Computers zur Folge. Der Prozessor bildet damit das Herz jedes Rechnersystems. Er umfaßt mindestens einen Registersatz, eine arithmetisch-logische Einheit (ALU) und ein Steuerwerk. Der Registersatz besteht aus einer verhältnismäßig kleinen Menge an sehr schnellen prozessorinternen Speicherzellen. Typischerweise sind zwei dieser Register besonders ausgezeichnet: Der Programmzähler (PC = Program Counter) enthält die Adresse der nächsten auszuführenden Instruktion, das Kellerregister (SP = Stack Pointer) wird zur Umsetzung von Unterprogrammaufrufen eingesetzt. Die ALU erlaubt die Manipulation von Daten. Neben den Grundoperationen für ganze und reelle Zahlen stellt sie verschiedene Logik- und Testfunktionen zur Verfügung. Das Steuerwerk koordiniert alle Einzelaktivitäten der Prozessorhardware bei der Ausführung der einzelnen Instruktionen. Instruktionssatz Die Menge aller vom Steuerwerk verstandenen Befehle definiert den Instruktionssatz eines Prozessors. Instruktionen lassen sich drei Hauptgruppen zuordnen:
• • •
Lade- und Speicheroperationen Arithmetik-, Logik- und Schiebeoperationen Operationen zur Beeinflussung der Ausführungsreihenfolge
2.1
Der Prozessor
Lade- und Speicheroperationen dienen dem Austausch von Daten zwischen Prozessor, Speicher und E/A-Geräten. Dabei sind verschiedene Formen der Kommunikation möglich. Diese sogenannten Adressierungsarten sind prozessorabhängig und können bei ihrer Ausführung zum Teil in eine Vielzahl von Einzelschritten zerfallen. Gängige Adressierungsarten sind:
• •
• • •
Adressierungsarten bestimmen, wie Operanden ermittelt werden
Registeradressierung: Ziel oder Quelle eines Kommunikationszyklus ist ein Prozessorregister. Absolute Adressierung: Direkte Angabe einer Adresse; Ziel oder Quelle ist eine Speicher- oder Gerätezelle. Relative Adressierung: Ziel oder Quelle werden relativ zu einer in einem Register gehaltenen Basisadresse durch ein Offset angesprochen; die tatsächliche Adresse ergibt sich aus der Addition des Registerinhalts und des angegebenen Offsets. PC-relative Adressierung und Kelleradressierung: Spezialfälle der relativen Adressierung mit den Registern PC oder SP als Bezugsbasis. Indirekte Adressierung: Ziel- oder Quelladresse ist der Inhalt eines Registers oder einer Speicherzelle. Indizierte Adressierung: Durch ein sogenanntes Indexregister wird ein Datum aus einer Menge gleich großer Elemente adressiert.
Durch die Ausführung von Befehlen aus der Gruppe der Arithmetik-, Logik- und Schiebeoperationen werden Register- und Speicherinhalte manipuliert. Dabei werden bestimmte Eigenschaften des Ergebnisses der zuletzt ausgeführten Operation, z.B. Ergebnis ist 0, durch das Setzen oder Zurücksetzen einzelner Bits (Flags) in einem ausgezeichneten Prozessorzustandsregister (PSW = Processor Status Word) signalisiert. Alle gängigen Prozessoren verfolgen ein streng sequentielles Verarbeitungsmodell. Dabei wird nach Beendigung der aktuellen Operation implizit die entsprechend der Adresse nachfolgende Instruktion geladen und ausgeführt. Diese sequentielle Befehlsverarbeitung kann durch eine Reihe von Sprungbefehlen beeinflußt werden. Man unterscheidet zwischen einem unbedingten Sprung und einem bedingten Sprung. Unbedingte Sprünge heben die sequentielle Verarbeitungsreihenfolge immer auf und führen die Programmausführung ab einer angegebenen Adresse fort. Bedingte Sprünge machen die Verarbeitungsreihenfolge von dem Wert einzelner Flags im Prozessorzustandswort abhängig, z.B. wird nur bei einem negativen Additionsergebnis an eine andere Stelle im Programm gesprungen; ist die Sprungbedingung nicht erfüllt, wird gemäß der sequentiellen Verarbeitungsreihenfolge automatisch der nächste Befehl ausgeführt. Darüber hinaus unterscheidet man zwischen absoluten und relativen Sprüngen. In Anleh-
Sprungbefehle ändern die sequentielle Programmausführung
2
Unterprogramme
Hardware-Grundlagen
nung an die entsprechenden Adressierungsarten wird das Ziel bei einem absoluten Sprung direkt angegeben, während bei relativen Sprüngen ein angegebenes Offset zum aktuellen Wert des Programmzählers addiert wird. Programme, die ausschließlich Relativsprünge verwenden, haben den Vorteil, daß sie an jeder beliebigen Speicheradresse ausgeführt werden können. Eine Variante des Sprungbefehls wird zur Realisierung von Unterprogrammen eingesetzt. Bei der Ausführung eines UnterprogrammSprungs (z.B. J S R = Jump Subroutine) wird zusätzlich zuerst die nachfolgende Programmadresse (Rücksprungadresse) auf dem Keller gerettet. Danach beginnt die Ausführung der angegebenen ersten Instruktion des Unterprogramms. Durch die Sicherung der Rücksprungadressen in einem Keller sind geschachtelte und rekursive Prozeduraufrufe möglich. Dabei wird der Keller meist auch zur Übergabe der Parameter und zur Mehrfachinstanziierung von prozedurlokalen Variablen eingesetzt. Ein Unterprogramm wird mit der Ausführung eines Return-Befehls beendet. Dieser Befehl interpretiert das oberste Kellerelement als die beim Unterprogrammaufruf gespeicherte Rücksprungadresse und verzweigt die Programmkontrolle an diese Stelle. Synchrone und asynchrone Unterbrechungen
Synchrone Unterbrechungen
Die normale Programmausführung eines Prozessors kann auch durch mehrere Arten von Unterbrechungen verändert werden (siehe Abbildung 2-2). Man unterscheidet zwischen synchronen und asynchronen Unterbrechungen. Synchrone Unterbrechungen sind eine unmittelbare Folge der aktuellen Befehlsausführung, d.h., sie werden synchron mit dem aktuellen Befehl ausgelöst. Sie können explizit durch den Aufruf eines entsprechenden Befehls (z.B. eines Trap-Befehls bei 680xO-Prozessoren) oder implizit im Fehlerfall zum Beispiel bei einer Division durch 0 oder einem Zugriff auf eine nicht existente Speicherzelle eintreten. Bei der letztgenannten Gruppe spricht man auch von sogenannten Ausnahmen (Exceptions).
Abb. 2-2 Unterbrechungsformen
Interrupts sind externe und damit asynchrone Unterbrechungen
Asynchrone Unterbrechungen (Interrupts) sind Ereignisse im Computersystem, die über besondere Steuerbusleitungen an den Prozessor weitergegeben werden. Asynchron bedeutet in diesem Kontext, daß
2.1
Der Prozessor
der eintreffende Interrupt in keiner kausalen Beziehung zum aktuell ausgeführten Befehl steht. Typischerweise informieren Ein- und Ausgabegeräte den Prozessor durch eine Unterbrechung über relevante Ereignisse, z.B. Drücken einer Taste, Ankunft eines Nachrichtenpakets etc. Die meisten Prozessoren stellen mehrere getrennte und in ihrer Wichtigkeit gestaffelte Interruptleitungen zur Verfügung. Durch entsprechende Befehle können Interrupts maskiert werden, d.h., eine Reaktion des Prozessors auf einen eingetroffenen Interrupt kann für einen bestimmten Zeitraum unterbunden werden. Abb. 2-3 Reaktion auf eine synchrone oder asynchrone Unterbrechung
Synchrone und asynchrone Unterbrechungen haben hardwaremäßig die Speicherung des aktuellen Prozessorzustandes zur Folge und lösen im Anschluß daran einen indirekten Sprung über eine im Speicher befindliche Sprungtabelle aus (siehe Abbildung 2-3). Dabei ordnet der Prozessor jeder synchronen und asynchronen Unterbrechung einen festen Index in dieser Sprungtabelle zu. An dieser Stelle steht die Anfangsadresse einer Unterbrechungsroutine, die entsprechende Folgemaßnahmen, z.B. das Anstoßen eines weiteren Leseauftrags an die Platte, einleitet. Die unterbrochene Programmausführung kann durch die Wiederherstellung des gespeicherten Prozessorzustands zu einem beliebigen, späteren Zeitpunkt fortgeführt werden.
Unterbrechungsroutinen werden über eine Sprungtabelle ausgewählt
Ausführungsmodi
Alle modernen Prozessoren unterstützen mehrere Modi der Programmausführung mit abgestuften Privilegien. Die häufigste Form ist die Unterscheidung zwischen einem privilegierten Modus und einem Normalmodus. Intel-Prozessoren ab 80386, Pentium-Prozessoren und z.B. AMD-Prozessoren bieten dagegen z.B. 4 abgestufte Schutzringe
Prozessoren unterscheiden einen privilegierten und normalen Ausführungsmodus
2 Hardware-Grundlagen
Unterschiede zwischen den Ausführungsmodi
0 bis 3 an, dabei entspricht Ring 0 dem privilegierten Modus und Ring 3 dem Normalmodus. Mindestens zwei Modi sind für die Umsetzung von Schutzkonzepten unabdingbar. Der Modus der aktuellen Programmausführung hat u. a. Auswirkungen auf die Ausführbarkeit einzelner Instruktionen. So sind spezielle E/A-Befehle (z.B. die In- und Out-Befehle der 80x86-Familie) nur in einem hinreichend privilegierten Modus oder Ring ausführbar; ihre Ausführung im Normalmodus führt zu einer synchronen Unterbrechung »Privilegsverletzung«. Auch das Maskieren von Interrupts ist nur in einem privilegierten Modus erlaubt. Darüber hinaus sind bestimmte Register des Prozessors ausschließlich im privilegierten Modus zugreifbar und/oder veränderbar; andere Register wie zum Beispiel das Kellerregister und das Prozessorstatuswort sind häufig für jeden Modus getrennt vorhanden. Das Auftreten einer synchronen oder asynchronen Unterbrechung ist immer mit der zwangsweisen Umschaltung in einen privilegierten Modus gekoppelt. Ein Wechsel vom privilegierten Modus in den Normalmodus kann durch besondere Befehle oder durch das Setzen bestimmter Flags im Prozessorstatuswort erreicht werden. Der gezielte Wechsel vom Normalmodus in einen privilegierten Modus kann ausschließlich durch die Ausführung eines synchronen Unterbrechungsbefehls (Trap) erzwungen werden.
2.2
4 GByte Speicher bei 32-Bit-Adressen kosteten Anfang 2001 je nach Qualität zwischen 4000 und 10000 DM
Der Speicher
Die von dem Prozessor ausgeführten Programme und die zugehörigen Daten befinden sich im Speicher des Computers. Aus Sicht des Prozessors besteht der Speicher aus einer hardwareabhängigen Anzahl an Speicherzellen, die durch entsprechende Belegungen des Adreßbusses einzeln angesprochen werden können. Der physische Adreßraum eines Computers wird durch die Menge an gültigen, d.h. ansprechbaren Adressen des Speichers definiert. Er ist in seiner maximalen Größe durch die Breite des Adreßbusses begrenzt; der tatsächliche Speicherausbau eines Rechners liegt jedoch meist darunter. Aufgrund von Beschränkungen bei der Konfigurier- und Erweiterbarkeit der Rechnerhardware ist der Adreßbus außerdem nur in Ausnahmefällen zusammenhängend, meist werden Intervalle vorhandenen Speichers von unterschiedlich langen Lücken unterbrochen. Zugriffe auf solche Lücken im physischen Adreßraum werden von der Hardware erkannt und lösen eine synchrone Ausnahmebehandlung aus. Der Speicher besteht zum größten Teil aus Bausteinen, auf die sowohl lesend als auch schreibend zugegriffen werden kann (RAM-Bausteine = Random-Access-Memory). Diese Bausteine verlieren ihren Speicherinhalt beim Ausschalten des Rechners. Ein kleiner Teil des
2.2
Der Speicher
physischen Adreßraums besteht aus Nurlesespeichern (ROM = ReadOnly-Memory), die ihren Speicherinhalt auch bei fehlender Stromversorgung beibehalten. In diesem Speicher befindet sich ein spezielles Lade- oder Boot-Programm (z.B. das sogenannte BIOS eines PC). Durch eine geeignete Plazierung der ROM-Bausteine im physischen Adreßraum des Prozessors wird dieses Programm, dessen Hauptaufgabe das Starten des eigentlichen Systemsoftware ist, automatisch beim Einschalten oder nach einem Reset ausgeführt. Die Geschwindigkeit beim lesenden und schreibenden Zugriff auf den Speicher ist für die Gesamtleistung des Computers von zentraler Bedeutung. Im PC- und Workstation-Bereich sind gegenwärtig Zugriff szeiten von 14-20 ns Stand der Technik. Bei teureren Hochleistungssystemen mit entsprechend leistungsstarken Prozessoren kann diese Zugriffszeit durch sehr aufwendige Techniken wie z.B. durch eine mehrfache Speicherverschränkung weiter reduziert werden. In allen Fällen hinkt der Wert jedoch um ca. eine Größenordnung hinter der potentiellen Zugriffsleistung des Prozessors hinterher, z.B. kann ein mit 1 GHz getakteter Prozessor im optimalen Fall jeden 2. Zyklus auf eine Speicherzelle zugreifen. Ohne zusätzliche Wartezyklen erfordert das einen Speicher mit einer Zugriffszeit von maximal 2 ns. Obwohl synchrone DRAM-Bausteine (SDRAM) und Speicher mit doppelter Datenrate (DDR-RAM) schrittweise bessere Zugriffszeiten erreichen, bleibt die Kluft in der Bandbreite zwischen Prozessor und Speicher mit der schnell ansteigenden Taktfrequenz (z.B. Prozessoren mit 1,5 GHz und mehr) weiter bestehen.
Diskrepanz zwischen Prozessor- und Speichergeschwindigkeit
Abb. 2-4 Funktionsweise eines Cache-Speichers
Caches
Damit die Gesamtleistung des Systems nicht durch die beschränkte Zugriffsgeschwindigkeit zum Hauptspeicher vermindert wird, besitzen praktisch alle modernen Rechner ein oder mehrere Zwischenspeicher (sogenannte Cache-Speicher). Ein Cache kann den Inhalt einzel-
Caches verbessern die Speicherzugriffszeit
2
Cache Hit
Cache Miss
Deferred Write Cache
Write Through Cache
Caches erreichen aufgrund der Referenzlokalität Trefferraten über 90 %
Hardware-Grundlagen
ner Zellen des Hauptspeichers zusammen mit deren Adresse Zwischenspeichern. Bei jedem Zugriff überprüft der Cache halb- oder vollassoziativ, ob die gewünschte Adresse aktuell zwischengespeichert ist. Im Fall eines Treffers (Cache Hit) kann der Inhalt ohne Verzögerung an den Prozessor weitergeleitet werden. So wird zum Beispiel in Abbildung 2-4 beim Anlegen der Adresse 2033 der entsprechende Eintrag 82 im Cache gefunden und zurückgegeben. Wurde die Adresse nicht gefunden (Cache Miss), so muß das Datum vom langsameren Speicher nachgeladen und an den Prozessor übergeben werden. Dabei wird nach einer bestimmten Regel eine Zelle des Caches mit dem gerade geladenen Wert überschrieben, z.B. wird die älteste gespeicherte Assoziation ausgewählt und dabei gelöscht. Verändert der Prozessor den Inhalt einer Speicherzelle, kann zur Leistungssteigerung nur der Cache-Inhalt aktualisiert werden. In diesem Fall wird der Hauptspeicher zu einem späteren Zeitpunkt angeglichen (Deferred Write). Dabei entsteht eine temporäre Inkonsistenz zwischen dem im Cache befindlichen Wert und dem Inhalt der entsprechenden Zelle im Hauptspeicher. Durch das gleichzeitige Zurückschreiben des Werts in den Hauptspeicher (Write Through Cache) kann dieses Risiko vermieden werden. Nachteilig an diesem Verfahren ist, daß schreibende Zugriffe immer die volle Hauptspeicherzugriffszeit benötigen. Der Aufwand, einen zusätzlichen Zwischenspeicher zu integrieren, lohnt sich natürlich nur, wenn ein hinreichend kleiner aber schneller Cache die mittlere Speicherzugriffszeit des Gesamtsystems deutlich verringert. Der entscheidende Faktor dafür ist die Trefferwahrscheinlichkeit des Caches. Für eine gegebene Trefferwahrscheinlichkeit p ergibt sich eine mittlere Zugriffszeit von:
Dabei wird angenommen, daß im Fall eines Cache Miss die Aktualisierung des Zwischenspeichers parallel zur Weiterleitung der Daten an den Prozessor geschieht. In heutigen Computersystemen kann bereits mit verhältnismäßig kleinen Cache-Speichern eine Trefferwahrscheinlichkeit von ca. 90 % erzielt werden. Dieser überraschend hohe Wert resultiert aus einer stark ausgeprägten Referenzlokalität der meisten Programme, u.a. eine Folge des sequentiellen Verarbeitungsmodells. Referenzlokalität bedeutet, daß für eine aktuell zugegriffene Speicherzelle eine hohe Wahrscheinlichkeit existiert, in naher Zukunft erneut referenziert zu werden. Anschaulich ist diese Situation bei Schleifen im Programmverlauf gegeben, die vollständig im Cache zwischengespeichert werden können. In jedem Schleifendurchlauf wird jede Instruktion erneut ausgeführt. Aber auch im Bereich der Daten ist in begrenzten Zeitintervallen eine starke Beschränkung auf wenige, häufig referenzierte Variablen die Regel. Ein weiterer Grund für diese hohe
2.2
Der Speicher
Trefferrate ist, daß die meisten Caches beim Zugriff auf den Hauptspeicher immer mehrere bzgl. der Adressierung aufeinanderfolgende Zellen (Cache Line) in einem sogenannten Burst schnell hintereinander laden. Neben der eigentlich referenzierten Zelle werden dadurch weitere Speicherinhalte vorab geladen (Prefetch), die aufgrund des sequentiellen Verarbeitungsmodells mit hoher Wahrscheinlichkeit in naher Zukunft referenziert werden. Die Trefferwahrscheinlichkeit hängt auch von dem jeweiligen Anpassungsgrad des Caches an die aktuelle Programmausführung ab. Wird zum Beispiel ein neues Programm gestartet, so entsprechen die Cache-Inhalte zu Beginn nicht den Speicherzellen, die durch dieses Programm häufig referenziert werden. Man spricht in diesem Fall von einem kalten Cache, eine geringe Trefferrate ist die Folge. Im Verlauf der weiteren Programmausführung paßt sich der Cache zunehmend an die Referenzlokalität an, der Cache wird warm. Ein optimal eingestellter Cache ermöglicht eine hohe Trefferrate und wird auch als heißer Cache bezeichnet.
Cache Line
Höhe der Trefferrate = Cache-Temperatur
Abb. 2-5 Speicherhierarchie
Speicherhierarchie Häufig werden mehrere Caches kaskadiert, um zwischen Prozessor und Speicher eine maximale Transferleistung zu erreichen. Aus Gründen der Wirtschaftlichkeit wird dabei versucht, die mit der Kaskadierung zunehmend schnelleren und entsprechend teureren Caches bezüglich der Größe und der angestrebten Leistungssteigerung optimal zu dimensionieren. Auf modernen Prozessoren ist daher bereits auf dem Chip meist ein allererster Cache (Level-1-Cache) mit 64 bis 512 KByte Größe integriert, der häufig mit der vollen Prozessortaktgeschwindigkeit angesprochen werden kann. Um das unterschiedliche Lokalitätsverhalten geeignet zu unterstützen, wird dieser Zwischenspeicher häufig in einen Instruktions- und einen Daten-Cache unterteilt. Wegen der kurzen Signalwege im Chip kann die Leistung dieses Caches durch externe Zwischenspeicher nicht erreicht werden. Caches der zweiten
2
Typische Größe eines Level-2-Cache
Hardware-Grundlagen
Stufe (Level-2-Cache) befinden sich zwischen Prozessor und Hauptspeicher. Ein L2-Cache mit 512 KByte für einen Hauptspeicher bis zu einer Größe von 64 MByte ist z.B. im PC-Bereich üblich. Die Schichtung der unterschiedlichen Speichertypen in einem Computer ist in Abbildung 2-5 zusammen mit den typischen Größen und Zugriffszeiten dargestellt. Diese auch als Speieberpyramide bezeichnete Architektur ist in praktisch jedem modernen Computersystem anzutreffen. Auf die Arbeitsweise der L1- und L2-Caches hat die Systemsoftware eines Rechners in der Regel keinen unmittelbaren Einfluß, abgesehen von der Möglichkeit, den jeweiligen Cache vollständig abzuschalten oder ggf. zu löschen. Indirekt haben diese Caches jedoch entscheidenden Einfluß auf das Betriebssystem, das aus Leistungsgesichtspunkten versucht, kalte Caches zu vermeiden.
2.3
Bediengeräte
Externe Speicher
Netzadapter
Ein- und Ausgabegeräte
Ein- und Ausgabegeräte erweitern die Fähigkeiten eines Computers und erlauben die Kommunikation mit seiner Umgebung. Entsprechend den unterschiedlichen Einsatzgebieten heutiger Computer ist auch das Angebot an E/A-Geräten sehr vielfältig. Eine wichtige Gruppe stellen die Bediengeräte dar, die eine Interaktion zwischen Benutzer und Computer ermöglichen. Zu den typischen Eingabegeräten dieser Gruppe zählen Tastatur, Maus, Joystick, Graphiktablett und Mikrophon. Ausgabegeräte sind im wesentlichen Bildschirme, Drukker, Plotter und angeschlossene Lautsprecher. Eine zweite unverzichtbare Gerätegruppe bilden externe Speicher. Bekannte Vertreter sind Diskettenlaufwerke, Platten, CD- bzw. DVDLaufwerke und CD-Recorder sowie unterschiedlichste Bandlaufwerke wie z.B. DAT- und QlC-Streamer. Je nach Einsatzgebiet dienen sie der langfristigen Datenhaltung, der Sicherung und Archivierung oder sie ermöglichen den lesenden Zugriff auf zusätzliche Daten und Programme (CD-ROM). Bei all diesen Geräten werden Daten durch magnetische und/oder optische Verfahren dauerhaft gespeichert. Netzadapter erlauben den Anschluß von Rechnern an ein Kommunikationsnetzwerk. Gängige Adapter sind Ethernetanschlüsse, die eine preisgünstige Vernetzung mit einer Bandbreite von 10 oder 100 MBit/sec ermöglichen, Modems zur Vernetzung von jeweils zwei Rechnern über analoge Telefonverbindungen und zunehmend auch ISDN-Anschlüsse mit Übertragungsraten bis zu 64 KBit/sec. Im Hochgeschwindigkeitsbereich werden kreuzverschaltete Ethernetsysteme (sogenannte Ethernet-Switches), FDDI-Ringe und ATM-Netzwerke über entsprechende Adapter ebenfalls zugänglich. Zu den Netzadaptern zählt man auch Systeme, die eine drahtlose Kommunikation über digitale Funksysteme (z.B. GSM) ermöglichen.
2.3
Ein- und Ausgabegeräte
Diese Aufzählung ist zwangsläufig nicht vollständig, es gibt eine ganze Reihe weiterer Spezialgeräte. So werden z.B. im Bereich der eingebetteten Systeme, die unterschiedlichste technische Prozesse kontrollieren, parallele Ein- und Ausgabekanäle sowie D/A- und A/DWandler zur Ansteuerung von Sensoren und Aktoren eingesetzt. E/A-Controller Geräte werden aufgrund ihrer unterschiedlichen technischen Ansteuerung in der Regel nicht direkt mit dem Prozessorbus des Computers verbunden. Vielmehr übernimmt ein E/A-Controller die Vermittlerrolle zwischen dem Computer und dem Gerät (siehe Abbildung 2-6). So wandelt z.B. die Graphikkarte eines PCs den digital gespeicherten Bildschirminhalt in eine sichtbare Darstellung um. Über die Geräteschnittstelle werden in diesem Fall Signale zur Ansteuerung und Synchronisation der Farbkanonen des Monitors übertragen. Die Geräteschnittstellen sind in den meisten Fällen auf eine bestimmte Geräteklasse zugeschnitten. In manchen Fällen sind sie aber zur Ansteuerung von z.T. sehr unterschiedlichen Geräten geeignet. So können z.B. über serielle RS232-Schnittstellen Tastaturen und Mäuse, aber auch Modems angeschlossen werden. Zur Steuerung komplizierter Geräte verfügen E/A-Controller häufig selbst über einen eigenen Prozessor und eigenen Speicher, d.h., sie bilden in diesem Fall eigenständige kleinere Computersysteme.
E/A-Controller vermitteln zwischen Prozessor und Gerät
Abb. 2-6 Anschluß von Gerät/Netz über E/A-Controller
Die Interaktion zwischen Prozessor und E/A-Controller geschieht über den Prozessorbus (siehe auch Abbildung 2-6). Jeder E/A-Controller stellt einen E/A-Adreßbereich mit einer bestimmten Menge an E/A-Registern zur Verfügung. Diese Register werden unterteilt in:
• •
Kommandoregister zur Übermittlung von Befehlen an den E/A-Controller, z.B. zur Festlegung der Bildwiederholrate bei einer Graphikkarte. Statusregister zur Abfrage des Controller- und Gerätezustands, z.B. ob eine Taste der Tastatur gedrückt wurde.
E/A-Register
2
Hardware-Grundlagen
•
Datenregister (bei einem größeren zusammenhängenden Speicherbereich spricht man auch von einem E/A-Puffer) für den eigentlichen Informationsaustausch, z.B. der Bildschirmspeicher einer Graphikkarte, der jedem Bildpunkt eine bestimmte Menge an Bits zuordnet.
E/A-Architekturvarianten Speicherbasierte E/A
Der E/A-Bereich eines Controllers kann vom Prozessor auf zwei Arten angesprochen werden. Bei der speicherbasierten Ein- und Ausgabe (siehe Abbildung 2-7) sind die Register für den Prozessor nicht von normalen Speicherzellen zu unterscheiden; bis auf eine veränderte Semantik entsprechen die E/A-Controller in ihrer Ansteuerung herkömmlichen Speicherbausteinen. Durch normale Lese- und Schreiboperationen und ohne Einschränkungen bei den verwendeten Adressierungsarten kann in diesem Fall auf die E/A-Register zugegriffen werden.
Abb. 2-7 Speicherbasierte Controller-Ansteuerung
Abb. 2-8 Dedizierter E/A-Bus
E/A-Bus
Die zweite Realisierungsvariante verwendet einen dedizierten E/A-Bus zur Ansteuerung der Controller (siehe Abbildung 2-8). Prozessoren, die diese Variante unterstützen, bieten eigene Lese- und Schreiboperationen für die Interaktion mit den Controllern an. Ein Beispiel dafür sind die In- und Out-Operationen der Intel-Prozessorfamilie 80x86. Für die Controller- und Registerauswahl wird bei diesen Prozessoren ebenfalls der Adreßbus eingesetzt. Zusätzliche Signalleitungen des Steuerbusses unterscheiden dabei zwischen einem Speicher- und einem E/A-Zugriff. Es ist offensichtlich, daß bei E/A-Bus-basierten Prozessoren auch speicherbasierte E/A-Strukturen realisiert werden können.
2.3
Ein- und Ausgabegeräte
Prozessoren, die über diese zusätzlichen Signalleitungen nicht verfügen, können E/A-Geräte nur speicherbasiert ansprechen; ein E/A-Bus kann in diesem Fall nicht realisiert werden. Erwähnenswert ist auch, daß meistens nicht alle vom Prozessor unterstützten Adressierungsarten bei E/A-Operationen eingesetzt werden können. Außerdem können sie in der Regel nur im privilegierten Modus ausgeführt werden, ein Nachteil, der auf bestimmte Betriebssystemarchitekturen gravierende Auswirkungen hat. Der Eintritt relevanter Ereignisse kann dem Prozessor über asynchrone Interrupts mitgeteilt werden. Je nach Wichtigkeit der einzelnen Geräte werden dazu die vorhandenen Interruptleitungen entsprechend ihrer Priorität auf die Controller verteilt. Diese Priorisierung ist besonders zur Unterstützung von zeitkritischen und/oder sehr schnellen Geräten wie z.B. Platten notwendig, da die Ausnahmebehandlung eines Gerätes mit höherer Priorität sowohl die normale Programmausführung als auch die Behandlung eines Gerätes mit niedriger Priorität unterbrechen kann. Bei Eingabegeräten ist die Bedeutung eines ausgelösten Interrupts offensichtlich, aber auch bei Ausgabeanweisungen nutzen Controller Interrupts z.B. zur Signalisierung der Auftragsbeendigung.
Nachteile eines E/A-Busses
Interruptprioritäten erlauben eine adäquate Systemreaktion je nach Gerätetyp
Abb. 2-9 E/A-Bus-Controller
E/A-Bus-Controller Eine besondere Form von Controllern stellen sogenannte E/A-BusController dar, die ausgangsseitig einen standardisierten Bus für den Anschluß der eigentlichen Geräte-Controller zur Verfügung stellen (siehe Abbildung 2-9). Diese Unterteilung in Prozessor- und Gerätebus hat eine Reihe von Vorteilen:
•
Es sind nur wenige Komponenten an den schnellen und in der Ausdehnung stark beschränkten Prozessorbus angeschlossen (keine Beeinträchtigung in der Systemleistung durch langsame E/A-Geräte).
2
Hardware-Grundlagen
•
•
Busmaster-Fähigkeit
Der Bus-Controller kann eine Reihe von Grundfunktionen, z.B. die Bearbeitung von Interrupts und DMA-Aufträgen (siehe unten), für alle angeschlossenen Geräte-Controller zentral übernehmen. Bei entsprechend standardisierten Gerätebussen können einzelne E/A-Controller in unterschiedlichste Computersysteme integriert werden.
Die bekanntesten Gerätebus-Systeme sind ISA, EISA und PCI für den PC-Bereich und SCSI, das ursprünglich aus dem UNIX-Bereich kam, aber zunehmend auch als breitbandige Alternative im PC-Bereich eingesetzt wird. Diese Busse unterscheiden sich hinsichtlich der maximalen Übertragungsrate und der Höchstzahl anschließbarer Controller. Ein weiteres wichtiges Kriterium ist, ob einzelne E/A-Controller am Gerätebus zeitweise die Initiative beim Datentransfer übernehmen können (Busmaster-Fähigkeit); in diesem Fall kann z.B. ein Kopiervorgang von einer Platte zu einem CD-Recorder ohne jede Prozessormitwirkung autonom durchgeführt werden. Zeichen- und blockorientierte Geräte
Zeichenorientierte Geräte
Blockorientierte Geräte
DMA
In Abhängigkeit von der kleinsten Übertragungseinheit zwischen Prozessor und Controller wird zwischen zeichen- und blockorientierter Ein- und Ausgabe unterschieden. Zeichenorientierte Geräte sind z.B. Tastatur, Maus und Drucker. Die Ankunft eines Datums bei einem zeichenorientierten Eingabegerät wird durch das Setzen der zugeordneten Interruptleitung signalisiert. Zur Reduzierung der Interruptrate wird zunehmend auch ein Puffer eingesetzt, der mehrere eintreffende Zeichen Zwischenspeichern kann. Als Reaktion auf den Interrupt kopiert der Prozessor das empfangene Zeichen in den Hauptspeicher oder in ein Register. Umgekehrt wird das Datum bei der Ausgabe zuerst in ein E/A-Datenregister des Controllers kopiert. Anschließend wird durch entsprechende Belegungen der E/A-Kommandoregister die Weiterverarbeitung durch den Controller initiiert. Die Gruppe der blockorientierten Geräte umfaßt Platten, Disketten- und CD-Laufwerke sowie die meisten Netzadapter. Hier wird mit jedem Lese- und Schreibauftrag immer ein ganzer Block bestimmter Länge zwischen Speicher und Gerät übertragen. Bei Geräten dieser Gruppe besteht zusätzlich die Möglichkeit, den Prozessor mit der Hilfe von DMA-Techniken (DMA = Direct Memory Access) zu entlasten. Diese Technik wird entweder vom Controller selbst oder von einem eigenständigen DMA-Controller eingesetzt und beschränkt sich auf die Übertragung von Datenblöcken zwischen Hauptspeicher und E/A-Puffer. Zu Beginn eines DMA-Transfers teilt der Prozessor lediglich Quell- und
2.4
Nebenläufigkeit
Zieladresse sowie Länge des zu übertragenden Blocks mit. Der eigentliche Kopiervorgang wird anschließend ohne weitere Prozessorintervention selbständig abgewickelt. Dabei macht man sich zunutze, daß der Prozessor nicht bei jeder Instruktion einen Speicherzyklus durchführen muß (z.B. reine Registeroperationen) und daß aufgrund der Referenzlokalität adressierte Speicherzellen häufig bereits im Cache gefunden werden. Die resultierende Entlastung des Hauptspeichers reicht häufig aus, den Speichertransfer praktisch ohne Verzögerung des Hauptprozessors durchführen zu können.
2.4
Nebenläufigkeit
Nebenläufigkeit, d.h. die Gleichzeitigkeit mehrerer Aktivitäten, findet bereits in unterschiedlichen Ausprägungen auf der Hardwareebene statt. Der maximale Grad an Nebenläufigkeit wird durch die jeweilige Hardwarekonfiguration festgelegt, er ist ein direktes Maß für die potentielle Leistungsfähigkeit eines Systems. Im wesentlichen wird der Grad an Nebenläufigkeit durch das mehrfache Vorhandensein autonomer Ausführungseinheiten bestimmt: • • • •
Mehrere Mehrere Mehrere Mehrere
Einheiten innerhalb eines Prozessors E/A- und DMA-Controller am Prozessorbus E/A-Controller in autonomen E/A-Subsystemen allgemein einsetzbare Prozessoren
Nebenläufigkeit innerhalb eines Prozessors Alle modernen Prozessoren sind mittlerweile in der Lage, mehrere Befehle gleichzeitig auszuführen. Durch eine sogenannte InstruktionsPipeline befinden sich Instruktionen in unterschiedlichen Ausführungszuständen. Man kann mindestens vier Zustände unterscheiden: Instruktion laden, Instruktion auswerten, Operanden laden sowie ein oder mehrere Stufen der Instruktionsausführung. Darüber hinaus können bei entsprechender Prozessorauslegung mehrere arithmetischlogische Instruktionen gleichzeitig ausgeführt werden, da entweder mehrere Recheneinheiten vorhanden sind oder die Recheneinheit selbst über eine eigene Pipeline verfügt. Prozessoren setzen zusätzlich meist ein Instruktionsfenster fester Größe ein, innerhalb dessen sie Befehle im Hinblick auf eine Durchsatzerhöhung in der Ausführungsreihenfolge umordnen können, solange die Semantik der sequentiellen Befehlsabarbeitung nicht verletzt wird. Nebenläufigkeit innerhalb eines Prozessors wird vollständig durch das Steuerwerk koordiniert und bleibt mit der Ausnahme einer erhöhten Komplexität bei der Fehlerund Interruptbehandlung für die Systemsoftware transparent.
Nebenläufigkeit durch Instruktions-Pipelining
Nebenläufigkeit durch mehrere Funktionseinheiten
2
Hardware-Grundlagen
Nebenläufigkeit im E/A-Bereich
Nebenläufigkeit durch autonome Busse
Eine erste sichtbare Stufe der Nebenläufigkeit ist durch das Vorhandensein mehrerer autonomer E/A-Controller am Prozessorbus oder innerhalb von E/A-Bussystemen (die durch einen Bus-Controller vom Hauptbus entkoppelt sind) gegeben. Dadurch können mehrere Geräte selbständig durch die jeweils zugeordneten Controller bedient werden. Prozessor und Bus-Controller senden dazu lediglich kurze Aufträge an die Controller; der Prozessor- oder E/A-Bus selbst kann anschließend für andere Aufgaben benutzt werden. Zusätzlich können durch den Einsatz von DMA-Techniken mehrere Datentransfers zeitlich verschränkt stattfinden. Beispielsweise kann ein Prozessor eine Platte mit dem Laden eines bestimmten Blocks beauftragen. Je nach Plattengeschwindigkeit verstreichen mehrere Millisekunden bis der Block tatsächlich gelesen, anschließend über DMA an den entsprechenden Platz im Hauptspeicher kopiert und über einen Interrupt die Auftragsbeendigung an den Prozessor signalisiert wurde. Der Prozessor selbst und/oder der Bus-Controller können während dieser Zeit andere anstehende Tätigkeiten durchführen. In Master-fähigen E/ABussen (z.B. SCSI) können sogar E/A-Controller untereinander direkt in Kontakt treten, d.h., ein entsprechend leistungsfähiger Controller übernimmt zeitweilig die Buskontrolle und initiiert unabhängig vom Hauptprozessor ein oder mehrere Unteraufträge an andere Geräte. MuItiprozessorsysteme
Nebenläufigkeit durch mehrere Arbeitsprozessoren
Zunehmend an Bedeutung gewinnt auch Nebenläufigkeit in Form von Multiprozessorsystemen, in denen mehrere Prozessoren gleichzeitg Instruktionen ausführen können (siehe Abbildung 2-10). Mehrprozessorsysteme sind im Workstationbereich bereits üblich und im PC-Bereich stark im Kommen; erste Hauptplatinen für den breiten Markt mit Steckplätzen für bis zu 4 Prozessoren sind ebenfalls bereits erhältlich. Durch den Einsatz mehrerer Prozessoren, die alle über ihren Cache an einen zentralen Hauptspeicher angeschlossen sind, kann tatsächlich eine erhebliche Leistungssteigerung gegenüber einem Monoprozessorsystem erzielt werden. Die Begründung dafür ergibt sich aus der bereits angesprochenen Tatsache, daß ein Prozessor die maximale Übertragungskapazität des Speichers nicht voll ausschöpft: eine große Zahl von Speicherzugriffen wird bereits im Cache aufgefunden, ein voller Speicherzyklus wird damit unnötig. Da jeder Prozessor über eigene Caches an den Hauptspeicher angeschlossen ist, können potentiell mehrere Caches den Inhalt einer bestimmten Speicherzelle Zwischenspeichern. Dadurch kann es zu Inkonsistenzen kommen, wenn ein Prozessor den zwischengespeicherten
2.4
Nebenläufigkeit
Wert verändert. Die Änderung wird zwar unmittelbar (Write Through) oder zeitversetzt (Deferred Write) im Hauptspeicher nachgezogen, ohne zusätzliche Hardwarevorkehrungen werden andere Caches jedoch nicht aktualisiert. Dieses als Cache-Kohärenz bezeichnete Problem, die Sicht auf den Hauptspeicher trotz der zwischengeschalteten Caches konsistent zu halten, wird meist durch sogenannte Snoopy Caches gelöst. Diese Spezialform eines Write Through Caches hört den Speicherbus permanent ab und führt jeden erkannten Schreibzugriff auf einem ebenfalls zwischengespeicherten Datum nach. Mit Hilfe von Snoopy Caches kann starke Kohärenz auf Kosten einer maximalen Parallelität erzielt werden, d.h., alle Prozessoren haben tatsächlich die gleiche Sicht auf den Speicher. Es existieren auch Systeme, die einen höheren Grad an Nebenläufigkeit durch eine Abschwächung dieser strengen Kohärenz erreichen. Die Programmierung dieser recht exotischen Systeme ist jedoch gewöhnungsbedürftig, da sich der Speicher nicht so verhält, wie man das von der sequentiellen Programmierung gewohnt ist.
Problematik kohärenter lokaler Prozessor-Caches
Snoopy Caches und Cache-Kohärenz
Abb. 2-10 Multiprozessorsystem
Man unterscheidet bei Multiprozessorsystemen zwei gängige Architekturvarianten. Asymmetrische Systeme sind aus einfachen Monoprozessorsystemen entstanden. Ausgangspunkt ist ein vollständig ausgerüsteter Rechner, an den zusätzliche Arbeitsprozessoren über einen geringfügig modifizierten Bus an den Speicher angeschlossen werden. Die Asymmetrie ergibt sich aus der resultierenden ungleichen Arbeitsteilung, da praktisch alle E/A-Aufträge vom sogenannten Master-Prozessor durchgeführt werden müssen. Nur dieser Prozessor kann Interrupts empfangen und verarbeiten, im Fall eines dedizierten E/A-Busses ist er sogar für die Abwicklung des gesamten E/A-Datenverkehr zuständig. Die zusätzlichen Prozessoren werden als sogenannte Slaves eingesetzt, die jeden E/A-Auftrag an den Master-Prozessor übergeben müssen. Dem Vorteil der einfachen Realisierbarkeit dieser Architekturvariante stehen zwei gewichtige Nachteile entgegen: Der MasterProzessor bildet den Flaschenhals hinsichtlich der Leistung des Gesamtsystems und ein Ausfall dieses Prozessors hat den Ausfall des Gesamtsystems zur Folge. Im Gegensatz dazu verfügen symmetrische MultiprozessorSysteme über eine stark erweiterte E/A-Struktur, so daß
Asymmetrischer Multiprozessor
Symmetrischer Multiprozessor
2
Obere Schranke für die Prozessoranzahl
Hardware-Grundlagen
jeder Prozessor gleichberechtigt Zugriff auf alle E/A-Controller, E/ABusse und Interrupts hat. Der maximalen Prozessoranzahl in einem Multiprozessorsystem sind harte Grenzen gesetzt. Jeder zusätzliche Prozessor erhöht die Zugriffslast auf den Speicher trotz zwischengeschalteter Caches. Durch notwendige Hardwaremechanismen für das Einhalten bestimmter Kohärenzkriterien wird die maximale Transferleistung des Speichers zusätzlich reduziert. Gängige Systemgrößen liegen im Bereich 8 bis 16 Prozessoren, in Ausnahmefällen können aber auch bis zu 64 Prozessoren verschaltet werden. Aufgrund der ungleichen Lastverteilung ist die obere Schranke bei asymmetrischen Systemen niedriger anzusetzen. Über den Maximalwert an Prozessoren hinaus ist praktisch keine Leistungssteigerung zu erzielen, vielmehr wirkt sich die resultierende Überlast auf dem Bus verzögernd auf die Ausführungsgeschwindigkeit aller Anwendungsprogramme aus. Nebenläufigkeit und Systemsoftware In allen Fällen der Nebenläufigkeit durch autonome E/A-Controller und zusätzliche Arbeitsprozessoren muß es das Ziel jeder Systemsoftware sein, die vorhandenen aktiven Einheiten sinnvoll auszulasten, um den Durchsatz des Gesamtsystems zu erhöhen und damit letztendlich Anwendungsprogramme möglichst schnell auszuführen. Bei einem symmetrischen Multiprozessorsystem gehört dazu auch die nicht leicht zu lösende Frage, an welchen Prozessor eintreffende asynchrone Unterbrechungen weitergeleitet werden sollen. Durch die gleichzeitige Ausführung mehrerer paralleler Handlungsstränge können außerdem Fehler passieren, die zu einem sichtbaren Fehlverhalten einzelner Anwendungsprogramme führen. Der Systemsoftware obliegt dabei die wichtige Aufgabe, die Abwicklung dieser parallelen Aktivitäten zu koordinieren.
2.5
Abstrakte Maschine M0
Eine abstrakte Rechnerarchitektur
Die Ausführungen der vorhergehenden Abschnitte haben gezeigt, daß es in heutigen Rechnern zum Teil erhebliche Architekturunterschiede gibt: Instruktionssatz, Registerstruktur, Caches, E/A-Konzept usw. variieren sehr stark von Rechner zu Rechner. Ein Anliegen dieses Buchs ist es aber, Systemsoftware-Technologie in einer weitgehend abstrakten, von einer konkreten Rechnerarchitektur unabhängigen Form zu vermitteln. Es ist deshalb angebracht, eine geeignete Abstraktion realer Rechnersysteme vorzugeben, die in der Definition eines Funktionssatzes einer abstrakten Maschine MO mündet. Auf diesem Funktions-
2.5
Eine abstrakte Rechnerarchitektur
satz werden alle nachfolgend eingeführten hardwarenahen Betriebssystemmechanismen aufsetzen. Es würde zu weit führen, einen fiktiven Prozessorbefehlssatz für eine abstrakte Maschine MO festzulegen. Statt dessen wird hier und in den nachfolgenden Kapiteln eine C-ähnliche Notation eingesetzt, von der man ausgehen kann, daß sie durch geeignete Compiler jeweils in die gewünschte Maschinensprache übersetzt werden kann. Wichtige Bestandteile einer abstrakten Rechnerarchitektur sind dagegen die folgenden vier Aspekte:
• • • •
Sichern und Restaurieren bestimmter Registerinhalte oder des gesamten Prozessorzustands An- und Abmelden einzelner Unterbrechungsroutinen Interruptmaskierung Ein- und Ausgabefunktionen
Prozessorzustand sichern Durch das Sichern des Prozessorzustands können höhere Betriebssystemschichten den aktuellen Zustand der Programmausführung jederzeit einfrieren und in einem anderen Kontext fortführen. Dies kann aus einer Reihe von Gründen geschehen, u.a. wenn beispielsweise eine längere E/A-Operation angestoßen wurde und der Prozessor zwischenzeitlich einer anderen Aufgabe zugeordnet werden kann. Der Prozessorzustand ist im wesentlichen durch die sichtbaren Register definiert, die durch den Aufruf der Funktion MO.ContextSave(Address)
an die angegebene Adresse im Hauptspeicher kopiert werden. Analog dazu kann ein gesicherter Prozessorzustand mittels MO.ContextRestore(Address)
wieder als aktueller Zustand des realen Prozessors restauriert werden. Da das direkte Überladen bestimmter Register unmittelbare Konsequenzen auf die aktuelle Programmausführung hat (z.B. führt das Überladen des Programmzählers zu einer impliziten Verzweigung an die angegebene Programmadresse), muß in manchen Fällen eine feste Reihenfolge beim Sichern und Wiederherstellen der Registerinhalte eingehalten werden. Dies gilt im wesentlichen für Programmzähler, Kellerregister und Prozessorstatuswort, für die es daher eigene M0Funktionen gibt: MO.RegisterSave(PC|SP|PSW,Address) MO.RegisterRestore(PC|SP|PSW,Address)
Sichern und Wiederherstellen des Prozessorzustands
2 Hardware-Grundlagen
Durch die Kapselung des Registersatzes wird die tatsächliche Prozessorstruktur für höhere Komponenten der Systemsoftware verborgen. Unterbrechungsverwaltung Unterbrechungen
Da die Anzahl an synchronen und asynchronen Unterbrechungen sowie die Position und Struktur der zugehörigen Sprungtabelle ebenfalls sehr stark vom jeweils eingesetzten Prozessor abhängt, ist auch hier eine Definition der relevanten Funktionen als Teil der abstrakten Maschine MO notwendig: Address MO.RegisterlSR(Index,Address) Address MO.UnregisterlSR(Index) MO.DefaultlSR(Address)
Mit der Funktion RegisterISR kann die Adresse einer Unterbrechungsroutine (ISR = Interrupt Service Routine) für eine synchrone oder asynchrone Unterbrechung eingetragen werden. Dabei wird angenommen, daß alle Unterbrechungen entsprechend ihrem Index in der Sprungtabelle durchnumeriert sind. Der Index in der Tabelle kann damit als eindeutiger Bezeichner für eine Unterbrechung eingesetzt werden. Die Funktion liefert die Adresse einer eventuell vorher eingetragenen Unterbrechungsroutine als Ergebnis zurück. Durch den Aufruf der Funktion UnregisterISR wird eine registrierte Unterbrechungsroutine, ausgetragen; im Gegensatz dazu wird beim Eintreffen eines entsprechenden Ereignisses die mit DefaultISR angegebene Standardunterbrechungsroutine aufgerufen. MO.InterruptDisable(Interrupt) MO.InterruptEnable(Interrupt)
Durch die Funktion i n t e r r u p t D i s a b l e kann eine bestimmte Unterbrechung zeitweise maskiert werden. Mit InterruptEnable wird eine Maskierung wieder aufgehoben, d.h., eintreffende Interrupts lösen den Aufruf der Unterbrechungsroutine aus. Diese Funktionen können nur sinnvoll auf asynchrone Unterbrechungen (Interrupts) ausgeführt werden, sie haben bei synchronen Unterbrechungen keine Bedeutung. MO.Raise(Interrupt) Mit der letzten Funktion Raise aus dieser Gruppe kann der Aufruf einer Unterbrechungsroutine erzwungen werden. Diese Funktion dient im wesentlichem dem kontrollierten Wechsel in den SupervisorModus. Sie wird auf die jeweils vom Prozessor angebotenen synchronen Ausnahmebefehle (z.B. Trap) abgebildet.
2.5
Eine abstrakte Rechnerarchitektur
Ein- und Ausgabe Die letzte Funktionsgruppe der MO umfaßt die Ansteuerung von Einund Ausgabegeräten. Wie in Abschnitt 2.3 diskutiert, muß zwischen zeichen- und blockorientierten Geräten unterschieden werden: Char MO.In(EA_Address) MO.Out(EA_Address,Char) Block MO.InBlock(EA_Address) MO.OutBlock(EA_Address,Block)
Die bei diesen Funktionen jeweils einzusetzende E/A-Adresse hängt von der Plazierung des entsprechenden E/A-Controllers im Prozessoroder im dedizierten E/A-Bus ab. In Abhängigkeit von der konkreten E/A-Struktur werden die zeichenweisen Ein- und Ausgabebefehle auf einfache Speichertransferbefehle oder auf spezielle E/A-Instruktionen abgebildet. Die blockorientierten Funktionen werden entweder als Speicherkopierfunktion realisiert oder bei vorhandener DMA-Funktionalität in einen entsprechenden DMA-Auftrag umgewandelt.
E/A-Operationen
3
Laufzeitunterstützung aus Anwendersicht
Der direkte Umgang der Anwendungsprogramme mit der Rechnerhardware, wie sie in Kapitel 2 vorgestellt wurde, ist aus verschiedenen Gründen problematisch:
• •
• •
Die Schnittstellen zu den Geräte-Controllern sind heute zwar weitgehend standardisiert, aber dennoch unhandlich. Die Koordination von Prozessor und Geräten mit den hardwareseitig dafür zur Verfügung stehenden Hilfsmitteln wie Interruptmechanismus oder zyklische Abfrage (Polling) führt auf schwer durchschaubare Softwarestrukturen, die man auf Anwendungsebene besser vermeiden sollte. In einem Mehrbenutzerbetrieb resultieren aus dem direkten Zugriff zur Hardware schwerwiegende Schutzprobleme durch die unvermeidbare Benutzung privilegierter Instruktionen. Die Abwicklung unabhängiger Benutzeraktivitäten in einem Mehrbenutzerbetrieb erfordert einen Koordinationsaufwand, der ohne ein unterstützendes Programm undenkbar ist.
Aus Anwendungssicht ist es deshalb wünschenswert, einen indirekten Zugang zur Rechnerhardware über eine Dienstschicht zu organisieren. Ziel dieser Schicht ist die Realisierung einer virtuellen Maschine, die einen komfortablen und sicheren Umgang mit der realen Hardware eines Rechners ermöglicht und auch die notwendige Koordination zwischen mehreren Benutzern durchführt. Die in Diensten bereitgestellten Abstraktionen stellen einen Rahmen für die Organisation von Anwendungen zur Laufzeit dar, d.h., sie definieren ein Laufzeitmodell. Anschaulich repräsentiert ein Laufzeitmodell eine fest umrissene Funktionsmenge, die Anwendungsprogrammierern und Benutzern zur Verfügung steht. Die folgenden Dienste sind sehr häufig Bestandteil eines Laufzeitmodells: -
Systembedienung Prozeßmanagement Prozeßinteraktion Datenhaltung Gerätemanagement
Dienstschicht realisiert eine virtuelle Maschine
Laufzeitmodell = Abstraktionen der Dienstschicht
3
Systembedienung
Prozeßmanagement
Prozeßinteraktion
Datenhaltung
Gerätemanagement
Laufzeitunterstützung aus Anwendersicht
Der Dienst Systembedienung ist für die interaktive Führung des Benutzers verantwortlich. Über eine Kommandoschnittstelle wird dem Benutzer der Zugang zum System eröffnet. Dazu gehören wenigstens die oben aufgezählten Standarddienste. Moderne Bediensysteme erleichtern über ausgefeilte graphische Techniken wie Fenster, Menüs und Symbole den Umgang mit dem System. Mit dem Prozeßmanagement bezeichnet man den Dienst zur Verwaltung von Rechenaufträgen. Rechenaufträge werden von dem Augenblick ihrer Erzeugung bis zu ihrer Beendigung als sogenannte Prozesse organisiert. Prozesse definieren damit die Arbeitseinheit für einen Prozessor. Leistungsfähige Dienste für das Prozeßmanagement erlauben es einem Anwender, mehrere Prozesse zu einem Zeitpunkt in der Bearbeitung zu haben und damit freie Kapazitäten des physischen Prozessors möglichst gut auszunutzen. Obligatorischer Bestandteil aller Prozeßmanagement-Dienste ist ein Mechanismus zur Ausnahmebehandlung. Er gestattet es, den Kontrollfluß eines Prozesses bei Auftreten eines Hardwarefehlers oder eines arithmetischen Überlaufs instruktionsgenau zu stoppen und auf eine vordefinierte Ausnahmebehandlungsprozedur umzuschalten. Nach erfolgreicher Behandlung der Ausnahmesituation kann üblicherweise der unterbrochene Prozeß fortgesetzt werden. In Echtzeitsystemen sind Zeitdienste ein weiterer wichtiger Bestandteil des Prozeßmanagements. Mit ihrer Hilfe können Prozesse bis zum Verstreichen einer vorgegebenen Relativzeit oder dem Erreichen einer Absolutzeit in einen Ruhezustand versetzt werden. Unter Zuhilfenahme des Dienstes Prozeßinteraktion können gezielte Formen des Informationsaustausches zwischen Prozessen und notwendige Synchronisationen der Prozesse - z.B. beim Zugriff auf gemeinsam genutzte Ressourcen - wirksam unterstützt werden. Der Dienst Datenhaltung sorgt für die langlebige Aufbewahrung von Daten und Programmen. Dateien können erzeugt oder gelöscht, für eine vorgesehene Bearbeitungsform geöffnet bzw. geschlossen und gelesen, erweitert und modifiziert werden. Datenhaltung ist einer der aufwendigsten und wichtigsten Dienste eines Laufzeitmodells und häufig Voraussetzung für darauf aufbauende Dienste. Der komfortable Zugriff auf Geräte, die unter Kontrolle einer Anwendung stehen, wird durch den Dienst Gerätemanagement realisiert. Für Anwender ist eine Sichtweise auf Geräte sehr attraktiv, die sie aussehen läßt wie Dateien. Da Geräte üblicherweise nicht erzeugt und gelöscht werden können (es sei denn, es handelt sich um virtuelle Geräte), reichen häufig die Funktionen OPEN, CLOSE, READ und WRITE aus, um einen hardwareunabhängigen Zugang zur Rechnerperipherie zu eröffnen. Schwieriger gestaltet sich jedoch die Modellierung interaktiver Geräte wie z.B. graphischer Terminals, die - ausgelöst durch
3
Laufzeitunterstützung aus Anwendersicht
Anschlagen der Tastatur oder Mausbewegungen - spontane Reaktionen auf der Seite des weiterverarbeitenden Programms wünschenswert machen. Ein Beispiel für einen höherwertigen Gerätedienst stellen Druckdienste dar, die das Ausdrucken von textuellen und graphischen Dokumenten wählbarer Qualität unterstützen. Gewöhnlich benötigen sie den Systemdienst Datenhaltung, in dem die zu druckenden Dokumente gespeichert sind. Druckdienste sind ein typisches Beispiel für einen Diensttyp, der den Zugriff zu einem Gerät über eine höherwertige Schnittstelle eröffnet. Insbesondere sind Druckdienste darauf präpariert, Dokumente aus beliebigen Formaten in das für den Drucker notwendige Format - z.B. Postscript - zu wandeln. Fragestellungen Die Diskussion der oben aufgezählten Dienste wirft eine Reihe grundsätzlicher Fragen auf:
•
Welche Dienste gehören zu einem Laufzeitmodell?
Mit dieser Frage ist vor allem die Offenheit eines Laufzeitmodells angesprochen, d.h. die Erweiterbarkeit um weitere Dienste. Wünschenswert wäre natürlich unbegrenzte Offenheit, d.h., der Diensteumfang kann von der leeren Dienstmenge bis hin zu einer einsatzabhängigen Dienstmenge beliebig konfiguriert werden. Totale Offenheit in diesem Sinne setzt Ortbogonalität, d.h. freie Kombinierbarkeit aller Dienste, voraus. Obwohl Orthogonalität eine wünschenswerte Systemeigenschaft darstellt, ist sie nicht immer vollständig erreichbar.
•
Gibt es einen inneren Zusammenhang unter den Diensten?
Mit dieser Fragestellung ist die Dienstestrukturierung unter dem Aspekt der Wirtschaftlichkeit angesprochen. Gibt es elementare Dienste, auf die andere Dienste aufsetzen können? Diese Frage kann bei den heute vorliegenden Erfahrungen eindeutig positiv beantwortet werden.
•
Welche Dienstmenge ist unverzichtbar?
Diese Frage beschäftigt noch heute weltweit die Forschung auf dem Betriebssystemgebiet. Die Festlegung einer unverzichtbaren, minimalen Dienstmenge, auf der alle weiteren Dienste aufbauen, maximiert offenbar die Offenheit eines Systems.
• Wo werden Dienste realisiert?
Druckdienst
3
Laufzeitunterstützung aus Anwendersicht
Diese Frage zielt auf die grundlegende Architektur eines Unterstützungssystems für Anwendungen, das aus einer unverzichtbaren und daraus abgeleiteten Dienstmenge besteht. In den nachfolgenden Unterabschnitten wird auf diese wichtigen Fragen näher eingegangen. Die Antworten tragen dazu bei, ein grundlegendes Verständnis über elementare Laufzeitmodelle mit den darin definierten Abstraktionen zu vermitteln sowie die Grobarchitektur von Laufzeitsystemen als Basis der nachfolgenden Kapitel festzulegen. Unter einem Laufzeitsystem wird hier die Menge von SystemsoftwareKomponenten verstanden, die ein spezifisches, auf eine bestimmte Anwendungsdomäne spezialisiertes Laufzeitmodell realisieren.
3.1
Unverzichtbare Dienste
Alle Rechneranwendungen sind letztendlich auf die Zuweisung von Prozessor und Speicher angewiesen. Der Zugriff auf diese Ressourcen bildet deshalb eine unverzichtbare Basis, auf der höherwertige Dienste aufgesetzt werden können. Ein Laufzeitsystem, das diese Dienste bereitstellt, muß jedoch zwei wesentliche Randbedingungen erfüllen:
• Keine Monopolisierung von Ressourcen
Isolation der Anwendungen untereinander
•
Es muß verhindern, daß eine Anwendung die physischen Ressourcen Prozessor und Speicher auf Dauer vollständig für sich in Anspruch nehmen kann. Diese Monopolisierung der Ressourcen würde nämlich zur Folge haben, daß auf einem Rechner gleichzeitig abgewickelte Anwendungen überhaupt keine Chance mehr hätten, jemals in den Genuß der Zuweisung von Prozessor und Speicher zu kommen. Es muß gleichzeitige Anwendungen so gegeneinander abschotten, daß eine unerwünschte wechselseitige Beeinflussung ausgeschlossen wird. Gewollte Interaktionen müssen jedoch in einer kontrollierten Weise möglich sein.
Die Dienste Prozeßmanagement und Prozeßinteraktion aus dem oben angegebenen Dienstekatalog stellen bei richtiger Auslegung genau die Funktionalität für Anwendungen bereit, die für den nichtmonopolisierenden, geschützten Zugriff zu den Ressourcen Prozessor und Speicher benötigt werden. Sie sind deshalb die geeigneten Kandidaten für die Realisierung elementarer Laufzeitmodelle. Prozesse [Dijkstra 1968] sind dynamische Objekte, die sequentielle Aktivitäten in einem System repräsentieren. Jeder Prozeß ist definiert durch einen Adreßraum, eine darin gespeicherte Handlungsvorschrift in Form eines sequentiellen Programms und einen Aktivitätsträger, der mit der Handlungsvorschrift verknüpft ist und sie ausführt. Adreßräume sind Abstraktionen des physischen Speichers
3.1
Unverzichtbare Dienste
und Aktivitätsträger Abstraktionen des physischen Prozessors. Aktivitätsträger werden in der Fachliteratur auch als Threads bezeichnet. Der Begriff deutet an, daß ein Thread für einen sequentiellen Kontrollfluß verantwortlich ist. Ein Prozeß kann damit als eine Art virtueller Rechner aufgefaßt werden, der auf die Ausführung eines ganz bestimmten sequentiellen Programms spezialisiert ist. Aus Anwendungssicht sollten im Idealfall so viele Adreßräume und Threads definiert werden können, wie zur Lösung eines Problems erforderlich sind. Die zeitweise Zuordnung der physischen Ressourcen Prozessor und Speicher zu Adreßräumen und Threads durch geeignete Multiplexmechanismen ist dann Aufgabe des Laufzeitsystems. Der Dienst Prozeßinteraktion ist eine natürliche Ergänzung des Dienstes Prozeßmanagement. Er unterstützt die gezielte wechselseitige Beeinflussungsmöglichkeit zwischen ansonsten unabhängigen Prozessen und kann als eine Art virtueller Ein- und Ausgabemechanismus aufgefaßt werden. Nachfolgend werden die elementaren Abstraktionen Adreßraum, Thread und Prozeßinteraktion etwas genauer beschrieben. Adreßräume Unter einem Adreßraum versteht man einen von der Speichertechnologie und den beschränkten Ausbaumöglichkeiten physischer Speicher unabhängigen virtuellen Speicher [Denning 1970]. Ein einzelner Adreßraum wird durch Speicherzellen (gewöhnlich Bytes) definiert, die von der Adresse 0 an aufwärts durchnumeriert sind. Adreßräume können nach Bedarf erzeugt und gelöscht werden. Sie stellen die Behälter für die Aufnahme abgegrenzter Programme mit zugeordneten Daten dar. Adreßräume sind gegeneinander abgeschottet, d.h., Threads, die in einem Adreßraum A operieren, können in der Regel nicht auf Speicherzellen anderer Adreßräume zugreifen, es sei denn, der gemeinsame Zugriff wird explizit vereinbart. Damit erfüllen Adreßräume eine grundlegende Schutzfunktion. Die räumliche und zeitliche Zuordnung aller Adreßräume eines Systems zu den vorhandenen Speichern (Cache, Arbeitsspeicher, Platten) geschieht durch einen Speicher-Multiplexer.
Adreßraum = Abstraktion des physischen Speichers
Threads Ein Thread stellt die Abstraktion eines physischen Prozessors dar. In dieser Rolle ist er Träger einer sequentiellen Aktivität, die durch die Ausführung eines dem Thread zugeordneten Programms - seiner Handlungsvorschrift - bestimmt ist. Ist A ein Adreßraum, H eine Handlungsvorschrift in Form eines sequentiellen Programms und T
Thread = Abstraktion des physischen Prozessors
3
Laufzeitunterstützung aus Anwendersicht
ein Thread, dann repräsentiert das Tripel ( A , H , T ) einen sequentiellen Prozeß. Da ausschließlich sequentielle Prozesse betrachtet werden, sprechen wir vereinfacht von Prozessen. Ebenso wie Adreßräume können Threads nach Bedarf erzeugt und terminiert werden. Die Selbstterminierung eines Threads erfolgt immer dann, wenn das zugeordnete Programm bis zu Ende abgewickelt, d.h. der Rechenauftrag vollständig ausgeführt wurde. Die zeitweise Zuordnung der physischen Prozessoren eines Rechners zu den Threads ist Aufgabe des Prozessormultiplexers [Nehmer 1975]. Prozeßinteraktion
Konkurrenz
Kooperation
Disjunkte Prozesse, d.h. Prozesse, die völlig isoliert voneinander ablaufen, stellen eher die Ausnahme dar. Sehr häufig kommt es gewollt oder ungewollt zu Wechselwirkungen zwischen Prozessen: Die Prozesse interagieren. Die Unterstützung der Prozeßinteraktion stellt einen unverzichtbaren Dienst dar, ohne den ein koordiniertes Nebeneinander von Prozessen in einem System undenkbar wäre. Betrachtet man diese Prozeßwechselwirkungen genauer, dann lassen sich zwei grundlegende Interaktionsmuster unterscheiden, die mit Konkurrenz und Kooperation bezeichnet werden [Ben-Ari 1990]. Eine Konkurrenzsituation unter Prozessen liegt dann vor, wenn sie sich gleichzeitig um ein nur exklusiv benutzbares Betriebsmittel wie z.B. einen Drucker bewerben. Durch entsprechende Koordinierung der beteiligten Prozesse beim Zugriffsversuch muß sichergestellt werden, daß das Betriebsmittel zu einem Zeitpunkt immer höchstens einem Prozeß zugeordnet wird. Das wird durch zeitliche Verzögerung der beteiligten Prozesse erreicht, durch die insgesamt eine serielle Benutzung des Betriebsmittels erzwungen wird. Die zeitliche Abstimmung unter nebenläufigen Prozessen - z. B. durch Verzögerung einiger Prozesse - wird als Prozeßsyncbronisation bezeichnet. Synchronisationsmechanismen dienen demnach vornehmlich der systematischen Behandlung von Konkurrenzsituationen. Man spricht von Prozeßkooperation, wenn die beteiligten Prozesse gezielt Information untereinander austauschen. Im Gegensatz zu konkurrierenden Prozessen müssen sich kooperierende Prozesse kennen. Eine häufige Form der Kooperationsbeziehung ist eine Auftragsbeziehung, d.h., eine Gruppe von Prozessen - die Clients - erteilt Aufträge an andere Prozesse - die Server. Das typische Muster einer Auftragsabwicklung zwischen einem Client und einem Server ist untenstehend veranschaulicht:
3.1
Unverzichtbare Dienste
Kooperationsbeziehungen zwischen Prozessen werden durch Mechanismen der Prozeßkommunikation unterstützt. Sehr häufig ist einem Prozeß nicht bewußt, daß er in Interaktionsbeziehungen mit anderen Prozessen verstrickt ist. In Abbildung 3-1 ist z.B. ein Prozeß dargestellt, der Funktionen einer tieferliegenden Schicht aufruft, die zur Durchführung ihrer Aufgabe mit anderen Prozessen interagiert. Die Interaktion ist jedoch in der Schicht gekapselt und äußert sich lediglich darin, daß der Prozeß in der Funktion ggf. für eine bestimmte Zeit blockiert wird. Er erhält die Kontrolle erst nach vollständiger Durchführung der Funktion wieder zurück. Man spricht dann auch von blockierenden Aufrufen. Prozeßinteraktionen, die über blockierende Aufrufe zustande kommen, nennen wir implizit und grenzen sie von der expliziten Prozeßinteraktion ab, bei der Prozesse bewußt interagieren.
Explizite und implizite Interaktion
Abb. 3-1 Implizite Prozeßinteraktion durch blockierenden Aufruf
Abbildung 3-2 faßt die Ergebnisse der obigen Diskussion in einem groben Klassifikationsschema für das Gebiet der Prozeßinteraktion zusammen (siehe dazu auch [Andrews und Schneider 1983] und [Herrtwich und Hommel 1994]).
12
Zugang zur Systemsoftware
Betriebssysteme verdeutlicht. Im nachfolgenden Abschnitt 12.1 werden die für den Start neuer Anwendungen notwendigen Shell-Kommandos vorgestellt. Abschnitt 12.2 beschäftigt sich mit den Funktionen zur Verwaltung aller aktuell ausgeführten Prozesse eines Benutzers oder des Gesamtsystems. Ein weiterer wichtiger Bestandteil einer Betriebssystem-Shell ist der Zugang zum Dateisystem. Er ist Gegenstand von Abschnitt 12.3. Abschließend werden in Abschnitt 12.4 Möglichkeiten vorgestellt, komplexe Kommandofolgen in einer textuellen Shell mit der Hilfe von Batch- und Skript-Dateien zu automatisieren.
12.1 Start neuer Prozesse
Dienstprogamme
Kommandozeilenoptionen und Argumente
Eine Hauptaufgabe jeder Systemsoftware-Shell ist es, dem Benutzer die Erzeugung und den Start neuer Anwendungsprozesse zu ermöglichen. Insbesondere wird ein wesentlicher Teil der Funktionalität an der Bedienungsoberfläche selbst von eigenständigen Programmen (Dienstprogramme) erbracht, die von der Shell bei Eingabe der entsprechenden Befehlszeile aufgerufen werden. Bei jedem Start muß vom Benutzer der Name der Datei angegeben werden, die den auszuführenden Programmcode enthält. Je nach Betriebssystem wird dann von der Shell z.B. mit Hilfe der Funktion CreateProcess() im Fall der Microsoft-Betriebssysteme Windows 9x und Windows NT/2000 oder durch den Aufruf der Funktionen fork() und exec() in UNIX-Systemen ein neuer Adreßraum angelegt. Anschließend beginnt der initial erzeugte Thread und damit die vom Benutzer gestartete Anwendung mit der Programmausführung ab einer systemspezifischen Adresse. Der Benutzer kann neben dem obligatorischen Programmnamen auch sogenannte Kommandozeilenoptionen und weitere für die Ausführung notwendige Argumente angeben. Die Shell wandelt alle Optionen und Argumente in ein für das jeweilige Betriebssystem spezifisches Format um und übergibt sie an das neu erzeugte Anwendungsprogramm. Dies soll am Beispiel der UNIX-Funktionen verdeutlicht werden. In diesem Beispiel startet der Benutzer einen C-Compiler, der eine C-Quelldatei c_prog.c übersetzen und binden soll: cc -g -o c_prog c__prog.c
Dabei weist z.B. die Option -g den Compiler an, alle für die Fehlersuche (Debugging) notwendigen Informationen in die fertige Programmdatei zu integrieren. Die zweite Option -o legt den Namen der erzeugten Programmdatei auf das unmittelbar der Option folgende Argument (hier c_prog) fest. Ohne die explizite Angabe des resultierenden Programmnamens würde der Compiler als sogenanntes De-
3.2
Elementare Laufzeitmodelle
Abb. 3-3 Speichergekoppelte Prozesse (Team)
B: Nachrichtengekoppelte Prozesse(Abbildung 3-4) Dieses Laufzeitmodell unterstützt die Strukturierung einer Anwendung in n Prozesse, die jeweils aus einem Adreßraum mit zugeordnetem Thread bestehen. Da die Adreßräume als disjunkt vorausgesetzt werden, kommen als Mittel zur Prozeßinteraktion ausschließlich nachrichtenbasierte Interaktionstechniken in Frage. Prozesse sind die natürlichen Einheiten der Rechnerallokation, d.h., ein oder mehrere Prozesse werden auf einem Rechner plaziert. Für Prozesse, die auf einem Rechner liegen, wurden besonders effiziente Realisierungen für die nachrichtenbasierte Prozeßinteraktion entwickelt. Die Attraktivität dieses Laufzeitmodells liegt in seiner Offenheit: Abgeleitete Dienste lassen sich mühelos in Form eigenständiger Prozesse realisieren; die Abbildung des Modells auf Rechnernetze bietet sich förmlich an.
Abb. 3-4 Nachrichtengekoppelte Prozesse
C: Nachrichtengekoppelte Teams(Abbildung 3-5) Dieses Laufzeitmodell kombiniert die Modelle A und B und damit auch deren Vorteile. Innerhalb eines Teams interagieren Prozesse über den gemeinsamen Speicher (enge Kopplung), während Prozesse aus verschiedenen Teams über Nachrichten interagieren (lose Kopplung).
3
Laufzeitunterstützung aus Anwendersicht
Abb. 3-5 Nachrichtengekoppelte Teams
D: Überlappende Adreßräume(Abbildung 3-6) Wesentliches Charakteristikum dieses Laufzeitmodells ist die Möglichkeit der Vereinbarung gezielter Überlappungen unabhängiger Adreßräume. Diese Fähigkeit eines Laufzeitmodells ist potentiell interessant für Anwendungen, bei denen die gemeinsame Bearbeitung großer Datenmengen zwischen ansonsten weitgehend unabhängigen Teilsystemen gefragt ist. Im Gegensatz zu den Modellen A-C werden allerdings zwei gravierende Probleme aufgeworfen:
•
•
Abb. 3-6 Überlappende Adreßräume
Eine natürliche Strategie der Lokalisierung von Adreßräumen/Threads in einem Rechnernetz existiert nicht mehr. Der Zugriff von Threads aus verschiedenen Adreßräumen auf gemeinsame Segmente macht eine Adreßraum-globale Synchronisation der beteiligten Threads notwendig.
3.3
3.3
Erweiterung der elementaren Laufzeitmodelle
Erweiterung der elementaren Laufzeitmodelle
Die in Abschnitt 3.2 eingeführten elementaren Laufzeitmodelle, die auf den Abstraktionen Adreßraum, Thread und Prozeßinteraktion beruhen, können in vielfältiger Weise ergänzt bzw. erweitert werden. Zusätzliche Leistungen können entweder durch abgeleitete Dienste oder Erweiterung/Ergänzung der vorhandenen Basisabstraktionen eingebracht werden. Dies soll anschließend anhand einiger Beispiele erläutert werden. Gerätemanagement Der kontrollierte Zugriff auf Geräte muß durch jede Laufzeitumgebung unterstützt werden. Bei einer speicherbasierten Ein/AusgabeArchitektur kann dies z.B. durch Einblenden der ein Gerät repräsentierenden Register in den Adreßraum des verantwortlichen Prozesses/Teams geschehen. Der mit einem Gerät assoziierte Dienst wird dann durch den Prozeß bzw. das Team erbracht, in dem die Zugriffsrechte liegen. Die Inanspruchnahme der Dienste für das Gerätemanagement durch andere Prozesse erfolgt mit den Hilfsmitteln der Prozeßinteraktion. Gerätemanagement kann deshalb als ein abgeleiteter Dienst aufgefaßt werden, der die Basisabstraktionen Adreßraum, Threads und Prozeßinteraktion verwendet. In ähnlicher Weise können die Dienste Systembedienung, Druckdienst, Zeitdienst usw. realisiert werden.
Zugriff auf Geräte
Datenhaltung Laufzeitunterstützung für die langfristige Haltung von Daten ist für nahezu alle Anwendungen obligatorisch. Dateisysteme stellen den traditionellen Ansatz für die Bereithaltung dieses Dienstes dar und können z.B. als abgeleiteter Dienst realisiert werden, der spezielle Zugriffsrechte zu einem nichtflüchtigen Speichermedium - in der Regel einer Platte - besitzt. Anwendungen benutzen diesen Dienst über die existierenden Mechanismen der Prozeßinteraktion und stellen damit eine Verbindung zwischen dem flüchtigen Speicher der Adreßräume und dem nichtflüchtigen Speicher eines Dateisystems her. Attraktiv sind jedoch auch Erweiterungen der Basis-Laufzeitmodelle um das Konzept eines persistenten Speichers. Sollen Speicherbereiche eines Adreßraums dessen Lebensdauer überstehen, dann müssen sie traditionell explizit mittels WRITE-Operationen in Dateien abgelegt werden. In neu erzeugten Adreßräumen, in denen auf Dateien zugegriffen werden soll, müssen diese Dateien dagegen mittels READOperationen zunächst in den flüchtigen Speicher geladen werden.
Dateisystem
Persistenter Speicher
3
Single-Level-Store
Laufzeitunterstützung aus Anwendersicht
Diese Trennung zwischen flüchtigem und nichtflüchtigem Speicher ist etwas unnatürlich und kann überwunden werden, wenn man die Abstraktion Adreßraum um Funktionen zur Vereinbarung persistenter Segmente erweitert. Man spricht dann auch vom Konzept des SingleLevel-Store [Chase et al. 1994]. Der explizite Informationsaustausch zwischen flüchtigem und nichtflüchtigem Speicher wird dann überflüssig, da das Laufzeitsystem diese Transporte selbst organisieren kann: Beim Einrichten eines neuen Adreßraumes werden automatisch alle als persistent gekennzeichneten Segmente geladen; spätestens mit dem Löschen eines Adreßraumes werden die persistenten Segmente auf einen nichtflüchtigen Speicher zurückgeschrieben. Die konsequente Fortentwicklung dieser Idee mündet letztlich in persistenten Speichern mit einer transaktionsorientierten Zugriffssemantik, die den Adreßraum auch bei fatalen Fehlern - z.B. dem Ausfall des Rechners - in einem konsistenten Zustand hinterläßt. Echtzeitanwendungen
Einhaltung vorgegebener Zeitschranken
Der indirekte Zugang zur Rechnerhardware über eine Dienstschicht entzieht den Anwendungen notwendigerweise die vollständige Kontrolle über sie. Das ist aus Schutzgesichtspunkten unerläßlich und bildet ferner die Basis für faire Ressourcen-Zuteilungsverfahren. Für harte Echtzeitanwendungen, die auf die Einhaltung vorgegebener Zeitschranken, angewiesen sind, ist dieser Entzug von Kontrolle jedoch nur bis zu einem gewissen Grade tolerierbar. Deshalb müssen durch Erweiterungen der elementaren Laufzeitmodelle Einflußmöglichkeiten auf die Zuteilungsstrategien für Prozessor und Speicher geschaffen werden. Das Thread-Konzept muß zu diesem Zweck um Mechanismen erweitert werden, die die anwendungsspezifische Zuteilung von Prozessoren zu Threads unterstützen. Bei Echtzeitprogrammen kann in der Regel die dynamische Verdrängung von Programmen aus dem Speicher bei Engpaßsituationen nicht toleriert werden, da sie ein indeterministisches Zeitverhalten bewirkt. Hier müssen Einflußmöglichkeiten für Anwendungen geschaffen werden, um Teile eines Adreßraumes im Arbeitsspeicher zu verankern.
3.4
3.4
Grobarchitektur von Laufzeitsystemen
Grobarchitektur von Laufzeitsystemen
Die Bereitstellung der Dienste eines Laufzeitsystems kann grundsätzlich auf drei verschiedene Weisen erfolgen:
• • •
im Adreßraum der Anwendung, in einem abgeschotteten Kern und in Servern, die in separaten Adreßräumen liegen.
Daraus ergibt sich eine Grobarchitektur für Laufzeitsysteme gemäß Abbildung 3-7. Der Zugriff zu einem Dienst beginnt immer durch Aufruf einer Funktion im Adreßraum der Anwendung. Diese Funktion kann den Dienst entweder selbständig erbringen oder die Unterstützung des Kerns bzw. anderer Server in Anspruch nehmen. Obwohl die Diskussion darüber, wo welche Dienste am geeignetsten realisiert werden, noch nicht abgeschlossen ist, lassen sich doch einige generelle Anmerkungen machen.
Abb. 3-7 Grobarchitektur eines Laufzeitsystems bestehend aus Adreßraum-Iokalen Laufzeitroutinen, Servern und einem Kern
Kerne müssen mindestens einen geschützten, nicht monopolisierenden Zugriff zu den Ressourcen Prozessor und Speicher unterstützen. Damit muß der Kern elementare Abstraktionen für Threads, Adreßräume und die Prozeßinteraktion bereitstellen. Um seine Schutzfunktion sicher erfüllen zu können, muß der Kern gegenüber Anwendungen vollständig abgeschottet werden: Der direkte Zugriff auf den Speicher des Kerns ist zu unterbinden, und der Aufruf von Kernfunktionen sollte nur über den Trap-Mechanismus möglich sein, der eine Umschaltung vom Normalmodus, in dem zwangsläufig alle
Elementarabstraktionen
3
Mikrokern
Clien t/Server-Architektur
Laufzeitunterstützung aus Anwendersicht
Prozesse oberhalb des Kerns ablaufen, in den privilegierten Modus bewirkt. Es ist jedoch ratsam, nur ein Minimum an Funktionalität zur Verfügung zu stellen und alle Spezialisierungen auf höhere Ebenen zu verlagern, um die universelle Einsatzmöglichkeit des Kerns für möglichst viele Anwendungsgebiete offenzuhalten. Die Suche nach einem minimalen Kern, der in dem Begriff Mikrokern (micro kernel) zum Ausdruck kommt, beschäftigt noch immer die Forschung auf dem Betriebssystemgebiet [Liedtke 1995]. Die Realisierung von Diensten in separaten Servern ist dann angebracht, wenn sie entweder von sehr allgemeinem Charakter sind (und damit von vielen Anwendungen potentiell genutzt werden) und/oder mit diesen Diensten der geschützte Zugriff auf Geräte verbunden ist, auf die Anwendungen nicht direkt zugreifen dürfen. Ein Beispiel sind Dateidienste. Adreßraum-lokale Realisierung von Diensten ist unter diesen Randbedingungen immer dann vorzuziehen, wenn sie anwendungsbezogene Spezialisierungen eines allgemeineren, durch den Kern oder externe Server bereitgestellten Dienstes darstellen und keine fundamentalen Schutzprinzipien verletzt werden. Der Vorteil Adreßraumlokaler Dienstebereitstellung liegt in der Effizienz begründet: Der Aufruf eines Dienstes erfolgt durch einen einfachen Prozeduraufruf. Die weitaus aufwendigere Umschaltung in den Kern mittels des TrapMechanismus entfällt.
4
Adreßräume
Der Adreßraum ist eine der zentralen Abstraktionen, die von der Systemsoftware eines Rechners zur Verfügung gestellt werden muß. Über einen Adreßraum sind alle für die Ausführung eines Anwendungsprogramms notwendigen Instruktionen und Datenstrukturen zugreifbar. Allgemein wird ein Adreßraum durch eine zusammenhängende Menge von Adressen und deren Inhalte definiert. Die maximale Größe eines Adreßraums kann aus dem Adreßbusaufbau der verwendeten Prozessorarchitektur abgeleitet werden. Sehr weit verbreitet sind gegenwärtig 4 GByte große Adreßräume; in diesem Fall wird jede Adresse durch einen 32-stelligen Binärwert dargestellt (hexadezimal umfaßt ein solcher Adreßraum die Werte von 0x00000000 bis 0xffffffff). Eine Tendenz zu größeren Adreßräumen ist erkennbar, so werden 64-Bit-Adressen bereits heute von Prozessoren wie z.B. SPARC (Sun) oder ALPHA (DEC) unterstützt. Der Inhalt einer vorgegebenen Adresse in einem Adreßraum ist entweder Undefiniert (= Undefinierte Adresse) oder er entspricht einem n-stelligen Binärwert (meistens 1 Byte). Aufgrund unbelegter Adressen entstehen nichtreferenzierbare Lücken im Adreßraum. Der Zugriff auf eine Position innerhalb der Adreßlücke hat einen Laufzeitfehler (Ausnahme) zur Folge, der in der Regel von der Systemsoftware behandelt wird und im Fall eines fehlerhaften oder unzulässigen Zugriffs zum Abbruch des betroffenen Anwendungsprogramms führt. Der konkrete Typ eines Adreßinhalts wird beim Zugriff auf eine definierte Adresse durch den jeweiligen Zugriffskontext festgelegt. So stellen alle in der Instruktionsladephase referenzierten Adreßinhalte binärkodierte Maschinenbefehle dar. Analog dazu werden in der Operandenladephase alle Inhalte als Daten interpretiert, deren Typ sich implizit aus der verwendeten Instruktionsart ergibt. Einer Anwendung wird immer mindestens ein Adreßraum zugeordnet. Grundlage für jede Zuordnung ist der physische Adreßraums eines Rechners. Dieser Adreßraum entsteht durch eine direkte Abbildung der 16, 32 oder 64 Bit breiten Adressen des Prozessors auf die vorhandenen Speicherbausteine und E/A-Controller. Diese meist statische Abbildung wird von Hardwareadreßdekodern durchgeführt
Definition Adreßraum
Undefinierte Adresse
Physischer Adreßraum
4
Einblenden
Adreßräume
(siehe Abbildung 4-1), die in Abhängigkeit von der jeweils anliegenden Adresse über eine einzelne Freigabeleitung (Chip Select) den angesprochenen Baustein freischalten. Man kann in diesem Kontext auch vom Einblenden der RAM- und ROM-Bausteine sowie der E/A-Controller in den physischen Adreßraum des Rechners sprechen. Die Größe eines eingeblendeten Bereichs entspricht der Anzahl an adressierbaren Zellen der jeweiligen Komponente; so kann ein 4 MByte großes RAMModul z.B. den physischen Adreßbereich 0x1a000000 bis 0x1a3fffff belegen.
Abb. 4-1 Abbildung des physischen Adreßraums auf die Hardwarekomponenten
Virtueller oder logischer Adreßraum
MMU
Die unmittelbare Nutzung des physischen Adreßraums bei der Anwendungsentwicklung ist aufgrund der notwendigen detaillierten Kenntnisse über seine Struktur und Zusammensetzung nur in bestimmten Fällen empfehlenswert. Im wesentlichen gilt dies für industrielle Steuerungen überschaubarer Komplexität, bei denen ein minimaler Hard- und Softwareaufwand aus wirtschaftlichen Gründen gefordert wird. Als Sonderfall gelten ältere aber weit verbreitete Betriebssysteme wie z. B. MS-DOS der Firma Microsoft, die für Anwendungen ausschließlich den physischen Adreßraum des Rechners bereitstellen. Allgemein ist es sinnvoller, einer Anwendung einen »bereinigten« Adreßraum zur Verfügung zu stellen, der alle technischen Details verbirgt und vorhandene Beschränkungen aufhebt. Erreicht wird dies durch einen zweiten Abbildungsschritt, in dem ein logischer oder virtueller Adreßraum aufgebaut und auf den physischen Adreßraum des Rechners abgebildet wird. Obwohl dieser zweite Abbildungsschritt zusätzlichen Hardware- und Softwareaufwand kostet, wird der Overhead wegen der resultierenden Vereinfachung bei der Nutzung und Verwaltung von Adreßräumen in den meisten Fällen akzeptiert. Die Programmausführung findet in diesem Fall in einem virtuellen Adreßraum statt; der Prozessor referenziert virtuelle Adressen, die typischerweise durch eine nachgeschaltete sogenannte MMU (= Memory Management Unit) auf der Grundlage einer veränderbaren
^
4.1
Organisation von Adreßräumen aus Anwendungssicht
Abbildungsvorschrift in physische Adressen transformiert werden (siehe Abbildung 4-2). Der Vorteil virtueller Adreßräume gegenüber der direkten Nutzung des physischen Adreßraums liegt primär in dieser programmgesteuerten Veränderbarkeit der Abbildungsvorschrift begründet, die der Systemsoftware eine umfassende Kontrolle und Koordinierung von ein oder mehreren Anwendungsadreßräumen ermöglicht. Abb. 4-2 Abbildung eines virtuellen Adreßraums auf den physischen Adreßraum
Im nachfolgenden Abschnitt 4.1 werden die typischen Eigenschaften von Adreßräumen aus Anwendungssicht diskutiert und Anforderungen an deren Realisierung definiert. Danach werden die Möglichkeiten und Konsequenzen der direkten Nutzung des physischen Adreßraums vorgestellt. In den Abschnitten 4.3 und 4.4 werden anschließend segment- und seitenbasierte virtuelle Adreßräume erläutert. Die Einbeziehung externer Hintergrundspeicher zur Vergrößerung virtueller Adreßräume über die Grenzen des physischen Speichers und die Behandlung von Speicherengpässen durch die Auslagerung ganzer virtueller Adreßräume sind Gegenstand der Abschnitte 4.5 und 4.6. Anwendungsadreßräume gängiger Betriebssysteme und Implementierungsaspekte werden in Abschnitt 4.7 besprochen.
4.1
Organisation von Adreßräumen aus Anwendungssicht
Im Adreßraum einer Anwendung müssen alle für die Programmausführung notwendigen Daten zur Verfügung gestellt werden. Man kann diese unmittelbar benötigten Informationen in 3 Bereiche untergliedern: • • •
Programmcode (Text) Datenbereich Laufzeitkeller (Stack)
4 Adreßräume
Code- oder Textbereich
Statischer Datenbereich Dynamischer Datenbereich (Heap)
Laufzeitkeller (Stack)
Der Code- oder Textbereich umfaßt die zur Programmausführung notwendigen Maschineninstruktionen. Der Datenbereich enthält alle notwendigen Variablen und speichert damit einen wesentlichen Anteil des Programmzustands. Außerdem benötigt jeder Thread oder Kontrollfluß, der innerhalb eines Adreßraums ausgeführt wird, einen Laufzeitkeller, in dem u.a. die jeweilige Unterprogrammverschachtelung gespeichert ist. Der Datenbereich selbst wird feiner unterteilt in einen statischen Datenbereich, der alle bereits zum Startzeitpunkt bekannten und u.U. vorinitialisierten Datenstrukturen enthält, und einen dynamischen Datenbereich (Heap), der alle während der Programmausführung dynamisch erzeugten Datenstrukturen aufnimmt. Letztere entstehen z.B. durch den Aufruf der Funktion malloc() bei C-Programmen oder des new-Operators in C++-Programmen. Eine dritte Gruppe von Datenbereichen ist meist in die Laufzeitkeller der Kontrollflüsse integriert und enthält alle beim Aufruf einer Prozedur benötigten lokalen Variablen (sogenannte automatische Variablen). Die Speicherung dieser Variablen auf dem Laufzeitkeller ist insbesondere bei der Realisierung rekursiver Prozeduraufrufe von Vorteil, da jede nicht beendete Prozeduraktivierung einen eigenen lokalen Variablensatz erfordert. Wachstum einzelner Adreßbereiche
Wachstum von Heap und Stack
Abb. 4-3 Wichtige Bereiche innerhalb eines Adreßraums
Für jeden Bereich muß von der Adreßraumverwaltung die Plazierung und Größe im Adreßraum festgelegt werden. Dabei muß bei dieser Plazierung ein eventuelles Größenwachstum einzelner Bereiche besonders berücksichtigt werden. Die Bereiche Programmcode und statische Daten sind in dieser Beziehung unproblematisch, da sich ihre Größe typischerweise nicht während der Programmausführung verändert. Im Unterschied dazu können der dynamische Datenbereich und der oder die Laufzeitkeller an Umfang erheblich zunehmen. Beim Heap geschieht dies u.U. mit jeder neuen Speicheranforderung seitens der Anwendung, wenn diese zwischenzeitlich keinen weiteren Speicher freigibt. Bei Laufzeitkellern hängen Größenschwankungen und Ausdehnung von der maximalen Tiefe der Prozedurschachtelung und dem Bedarf an prozedurlokalen Variablen ab.
4.1
Organisation von Adreßräumen aus Anwendungssicht
Die resultierende Adreßraumstruktur aus der Sicht des ausgeführten Programms ist in Abbildung 4-3 dargestellt. In der Regel werden Programm und statische Daten an einem Ende des Adreßraums plaziert. Die beiden dynamisch anwachsenden Bereiche werden mit einem maximalen Abstand voneinander im verbleibenden Adreßraum angeordnet, um ein möglichst unbeschränktes Wachstum dieser Bereiche zu gewährleisten. Die in der Abbildung gewählte Position der einzelnen Bereiche und die Wachstumsrichtung bei veränderlichen Bereichen sind für viele Systeme typisch und dienen o.B.d.A. als Grundlage für die nachfolgenden Abschnitte. Die konkrete Plazierung der Adreßbereiche wird durch das jeweils verwendete Betriebssystem und die zugrundegelegte Prozessorarchitektur bestimmt, z.B. legen viele Prozessoren bei Kelleroperationen eine bestimmte Wachstumsrichtung des Kellers automatisch fest und schränken damit die Plazierungsmöglichkeiten der Laufzeitkeller ein. Eine Anpassung an die Gegebenheiten einer konkreten Rechnerarchitektur ist meist einfach durchführbar. Mehrere Threads in einem Adreßraum Ein maximaler Abstand zwischen den dynamischen Daten und einem Laufzeitkeller erlaubt ein maximales Wachstum dieser beiden Bereiche innerhalb eines vorgegebenen Adreßraums. Die Situation wird etwas komplizierter, wenn mehrere Kontrollflüsse in einem Adreßraum unterstützt werden müssen. In diesem Fall wird der Kellerbereich typischerweise mehrfach unterteilt und jedem Kontrollfluß ein eigener Laufzeitkeller zugewiesen (siehe Abbildung 4—4). Der Abstand zwischen den einzelnen Kellern wird entweder standardmäßig vom System vorgegeben oder er wird vom Programmierer festgelegt. Eine exakte Bestimmung der maximalen Ausdehnung eines Laufzeitkellers ist in der Praxis meist unmöglich, d.h., der Abstand sollte deshalb möglichst groß gewählt werden. Dem steht die Forderung nach einer möglichst ökonomischen Nutzung des Adreßraums gegenüber, um insbesondere den Bereich für dynamische Daten im Wachstum nur minimal einzuschränken.
Ein Stack pro Thread
Abb. 4-4 Adreßraumorganisation bei mehreren Threads
4
Adreßräume
Überschneidung von Heap und Keller
Laufzeitfehler bei Überschneidungen von Heap und Stack
Unabhängig von der Anzahl der Laufzeitkeller muß eine Überschneidung zwischen mehreren Kellern oder zwischen dem »untersten« Laufzeitkeller und dem Bereich der dynamischen Daten grundsätzlich vermieden werden. Überschneiden sich diese Bereiche irgendwann während der Programmausführung entweder durch den Aufruf einer Prozedur oder durch das Anlegen einer weiteren, dynamischen Datenstruktur, so ist der zugeordnete Adreßraum zu klein, das Programm kann nicht ausgeführt werden. In der Realität ist der Abstand zwischen Heap und einem Laufzeitkeller in den meisten Fällen jedoch hinreichend groß, d.h., es existiert eine viele MByte große nicht referenzierte Lücke im Adreßraum. Geht man z.B. davon aus, daß alle vier genutzten Bereiche eines Adreßraums zusammen 20 MByte umfassen (die meisten Programme erreichen diese Größe bei weitem nicht), so ergibt sich bei einem 4 GByte großen Adreßraum je nach Betriebssystem eine Lücke von 2028 bis 4076 MByte. Im Fall mehrerer Laufzeitkeller wird dagegen die Wahrscheinlichkeit für einen zu kleinen Adreßraum durch den Zwang, den Abstand zwischen den einzelnen Kellern a priori festlegen zu müssen, überproportional erhöht. Unerkannte Überschneidungen haben in der Regel schwer zu lokalisierende indirekte Laufzeitfehler zur Folge. Wünschenswert sind also Maßnahmen, die den Eintritt einer solchen Überschneidung automatisch erkennen und die Programmausführung abbrechen. Bei heutigen Systemen werden Überschneidungen zwischen dynamischem Datenbereich und »unterstem« Laufzeitkeller in der Regel erkannt, ein Schutz der Keller untereinander ist jedoch in vielen Systemen noch nicht gewährleistet. Theoretisch kann ein absoluter Schutz für sogenannte Kellerüber- und -unterlaufe nur durch den Einsatz bestimmter Adressierungstechniken, die nicht jeder Prozessor anbietet, garantiert werden. In der Praxis kann aber auch in den verbleibenden Fällen die Wahrscheinlichkeit für den Eintritt eines solchen Fehlers hinreichend klein gehalten werden. Größe eines Adreßraums Heutige Prozessoren besitzen meist einen 32 oder 64 Bit breiten Adreßbus. Da der Prozessor bei der Programmausführung auf alle Instruktionen und Daten letztendlich über diesen Prozessorbus zugreift, steht jeder Anwendung potentiell ein 4 GByte oder bei 64 Bit breiten Adressen sogar ein 18x10 18 Byte großer Adreßraum zur Verfügung. Um das Wachstum des Programms nur minimal zu beschränken, beginnt der Programmbereich im Idealfall an der Adresse 0x00...00, die Bereiche der statischen und dynamischen Daten schließen sich un-
4.1
Organisation von Adreßräumen aus Anwendungssicht
mittelbar an, und der Keller wird am anderen Ende des Adreßraums plaziert - mit einem mehrere 100 MByte großen Abstand zum Heap. Bei sehr großen Programmen wird im Extremfall diese Lücke zwischen Heap und Stack vollständig benutzt, d.h., durch eine tiefe Prozedurverschachtelung und/oder umfangreiche statische und dynamische Datenbereiche werden von einem solchen Programm tatsächlich alle 4096 MByte im Adreßraum belegt. In der Realität ist eine Ausdehnung des Anwendungsadreßraums ohne zusätzliche Hilfsmittel jedoch nur innerhalb der Schranken des physischen Speicherausbaus möglich, durch den meist eine enge Grenze für das Adreßraumwachstum vorgegeben wird. Die Ausführung derart großer Programme setzt in diesem Fall sogar einen vollständig bestückten 32-Bit-Adreßraum voraus. Auch in Zeiten fallender Hardwarepreise ist ein so großer Speicherausbau teuer (ganz zu schweigen von einem vollständig bestückten 64-Bit-Hauptspeicher) und bis auf ganz wenige Einzelfälle auch völlig unnötig, da selbst ein sehr schneller Prozessor nicht auf jede Speicherzelle innerhalb des 32-Bit-Adreßraums mit hoher Frequenz zugreifen kann. Demgegenüber bieten moderne externe Speichersysteme wie z.B. Festplatten zwischen 8 und 80 GByte Speicherkapazität zu einem vergleichsweise geringen Preis von gegenwärtig ca. 12 DM pro GByte. Es liegt also nahe, Teile des Adreßraums größerer Anwendungen temporär auf externem Speicher auszulagern und den frei werdenden Speicherbereich zur Vergrößerung des Adreßraums oder zur Befriedigung von Speicheranforderungen anderer Anwendungen einzusetzen. Vorbedingung für den erfolgreichen Einsatz dieser Technik ist natürlich, daß der ausgelagerte Teil des Adreßraums längere Zeit nicht referenziert wird, um die erheblich höheren Zugriffszeiten auf externe Speichermedien zu kompensieren. Konzeptionell kann die Speicherauslagerung durch eine spezielle Abbildungstabelle realisiert werden, die für jede Speicherzelle angibt, ob sich der entsprechende Wert im Hauptspeicher befindet - also direkt zugreifbar ist - oder nicht. Zusätzlich muß im Fall der Auslagerung vermerkt werden, an welcher Stelle und auf welchem externen Medium sich der ausgelagerte Speicherinhalt befindet. Während der Programmausführung muß durch eine Spezialhardware bei jedem Speicherzugriff überprüft werden, wo sich die jeweils referenzierte Zelle gegenwärtig befindet. Im Fall der Auslagerung muß die aktuelle Instruktionsausführung vom Prozessor unterbrochen und die Systemsoftware beauftragt werden, den ausgelagerten Zelleninhalt wieder in den Hauptspeicher zu übertragen. Nach Abschluß dieses Einlagerungsvorgangs kann dann die unterbrochene Instruktionsbearbeitung durch den Prozessor wieder aufgenommen und der Adreßinhalt aus dem Hauptspeicher gelesen werden.
Lücke zwischen Heap und Stack
In Ausnahmefällen benötigen Anwendungen den gesamten virtuellen Adreßraum
Adreßraum um externen Speicher erweitern
4
Adreßräume
Adreßraumfragmentierung
Externe Fragmentierung
Unter dem Begriff Fragmentierung versteht man verschiedene Formen der Zerstückelung des noch freien und nutzbaren Teils des Adreßraums in kleinere Bereiche. Fragmentierung kann jedesmal dann entstehen, wenn eine neue Speicherplatzanforderung aus einer Menge noch freier Speicherbereiche mit einem einzigen zusammenhängenden Bereich befriedigt werden muß. Über viele einzelne Anforderungen hinweg kann eine Situation entstehen, in der eine weitere Anforderung bestimmter Größe nicht erfüllt werden kann, obwohl in der Summe ausreichend viel freier Speicher zur Verfügung steht. Man unterscheidet zwischen externer und interner Fragmentierung (siehe auch Abbildung 4-5). Bei der externen Fragmentierung wechseln sich benutzte und unbenutzte Speicherbereiche innerhalb eines Adreßraums ab. Im Fall eines Anwendungsadreßraums beschränkt sie sich auf den dynamischen Datenbereich und entsteht durch die unterschiedlichen Speicheranforderungs- und Freigabemuster. Zu einem bestimmten Zeitpunkt ist der Adreßraum soweit zerstückelt, daß größere Bereichsanforderungen nicht mehr erfüllt werden können. In bestimmten Fällen könnte diese Form der Fragmentierung durch ein meist zeitaufwendiges Zusammenschieben der benutzten Speicherbereiche vermieden werden. Diese Kompaktierung verbietet sich jedoch, wenn innerhalb der benutzten Speicherbereiche Verweise auf Absolutadressen von Instruktionen oder Daten enthalten sind und diese nicht erkannt werden können. In diesem Fall ist eine externe Fragmentierung nur durch den Einsatz einer zusätzlichen Abbildungstabelle möglich, die eine Umordnung der Speicheradressen gestattet. Dabei wird für jede Adresse im Anwendungsadreßraum vermerkt, an welcher Stelle sie sich tatsächlich im physischen Adreßraum befindet. Ein im Anwendungsadreßraum zusammenhängender Bereich kann damit auf mehrere freie, aber nicht zusammenhängende Lücken abgebildet werden.
Abb. 4-5 Externe und interne Fragmentierung
Interne Fragmentierung
Eine zweite, häufige Form der Fragmentierung ergibt sich, wenn der Speicher in Bereiche fester Größe untergliedert ist und Speicheranforderungen nur in Vielfachen dieser festen Grundgröße befriedigt wer-
4.1
Organisation von Adreßräumen aus Anwendungssicht
den können. Beispielsweise kann auf Festplatten Speicher nur blockweise belegt werden; je nach Plattentyp schwanken Blockgrößen zwischen 512 Byte und 4 bis 8 KByte. Selbst für die Speicherung eines einzigen Bytes muß in solchen Fällen ein ganzer Block belegt werden. Auch bei dieser internen Fragmentierung ergibt sich damit ein Verschnitt, der weder von der anfordernden Anwendung noch von der Speicherverwaltung genutzt werden kann. Zugriffscharakteristik Die einzelnen Adreßraumbereiche einer Anwendung unterscheiden sich auch sehr stark in ihrer Zugriffscharakteristik. Bis auf wenige, klar definierte Ausnahmen wird auf den Programmbereich einer Anwendung praktisch nur lesend zugegriffen. Programmierfehler, die eine Veränderung des Programmbereichs zur Folge haben, sind meist sehr schwer und verhältnismäßig spät zu erkennen. Ein entsprechender Schutz vor schreibendem Zugriff minimiert das Fehlerrisiko und ist daher in diesem Bereich des Adreßraums anstrebenswert. Umgekehrt befinden sich in den statischen und dynamischen Datenbereichen sowie im Keller Daten, auf die sowohl lesend als auch schreibend zugegriffen wird. Bei einer sehr restriktiven Auffassung über die Adreßraumnutzung sollte das Ausführen von Instruktionen in diesen Bereichen z.B. zum Schutz gegen bestimmte Formen von Computerviren unterbunden werden. Technisch setzt die Überwachung des Speicherzugriffs voraus, daß zusätzliche Informationsbits für jede Speicherzelle die erlaubten Zugriffsmodi festlegen (keine Zugriffserlaubnis, nur lesender Zugriff, lesender und schreibender Zugriff, ausführbare Instruktion). Die Inhalte dieser Schutzinformationen müssen von der Systemsoftware des Rechners verändert werden können, um den Speicher an die wechselnden Schutzanforderungen anpassen zu können. Gleichzeitig muß durch entsprechende Maßnahmen sichergestellt werden, daß für normale Anwendungsprogramme diese Schutzinformationen nur in kontrollierter Form geändert werden können. Während der Programmausführung muß außerdem Spezialhardware den einzelnen Zugriff des Prozessors auf seine Gültigkeit überprüfen. Bei einer Schutzverletzung muß die Systemsoftware des Rechners z.B. über eine Unterbrechung informiert werden, damit diese geeignete Folgemaßnahmen einleiten und ggf. den verantwortlichen Prozeß terminieren kann.
Nur lesender Zugriff auf Codebereich
Keine Instruktionen in den Datenbereichen
Überwachung jedes Speicherzugriffs
4
Adreßräume
Anwendungsforderungen Zusammenfassend können für einen Adreßraum aus Sicht der Anwendungsprogrammierung eine Reihe wichtiger Forderungen an dessen Realisierung gestellt werden:
• • • •
Homogener und zusammenhängender Adreßraum
Erkennen und Isolieren von Laufzeitfehlern
Homogene und zusammenhängende Adreßbereiche Größe des genutzten Adreßraums unabhängig von der Kapazität des physischen Adreßraums Erkennen fehlerhafter Zugriffe Erkennen von Überschneidungen zwischen Heap und Keller sowie zwischen mehreren Laufzeitkellern
Die ersten beiden Forderungen zielen auf eine Verbesserung der Adreßraumstruktur, die konfigurationsbedingte, technische Details vor dem Anwendungsentwickler verbergen soll. Eine homogene und zusammenhängende Adreßraumstruktur ermöglicht eine Programmentwicklung ohne das ansonsten notwendige Wissen über Position, Typ und Größe der referenzierbaren Speichermodule und E/A-Controller. Durch die zweite Forderung kann eine Anwendung einen Adreßraum nutzen, dessen Größe nicht durch die Kapazität des vorhandenen physischen Speichers beschränkt ist. Das frühzeitige Erkennen und Isolieren von Zugriffs- und Überschneidungsfehlern ist eine weitere wichtige Gruppe von Forderungen an die Adreßraumrealisierung. Durch eine entsprechend restriktive und geschützte Auslegung des Adreßraums soll versucht werden, für ansonsten schwer aufzudeckende Programmfehler den zeitlichen Abstand zwischen Fehlerursache und -Wirkung zu verkürzen. Aus Sicht der Systemsoftware steht primär der Schutz gegenüber fehlerhaften Anwendungen im Vordergrund. Es müssen Vorkehrungen getroffen werden, die einen Ausfall des gesamten Computersystems als Folge eines Anwendungsfehlers ausschließen. Kein ausreichender Schutz an dieser Stelle bedingt zumindest einen meist langwierigen Neustart des Computers. Die Konsequenzen können aber auch erheblich weitreichender sein, wenn durch den Fehler die Inhalte der dauerhaften Speicher verändert werden. Zum Beispiel können auf einer Platte wichtige Informationen zerstört oder sensible Daten über die Hardwarekonfiguration in batteriegepufferten Speicherbausteinen verändert werden, wenn eine fehlerhafte Anwendung ungeschützt auf kritische Bereiche außerhalb ihres Adreßraums zugreifen kann. Manche Systemsoftware verzichtet sogar auf diese elementare Form des Schutzes und setzt den Ausfall der Anwendung mit dem Ausfall des Gesamtsystems gleich. Zustätzliche Forderungen entstehen, wenn die Systemsoftware eines Rechners mehrere Adreßräume gleichzeitig unterstützt:
4.1
• • • •
Organisation von Adreßräumen aus Anwendungssicht
Schutz funktionstüchtiger Anwendungen gegenüber fehlerhaften Anwendungen Kontrollierbares und kontrolliertes Aufteilen der Speicherressourcen auf alle Anwendungen Speicherökonomie Minimale Fragmentierung
Essentiell ist bei der gleichzeitigen Unterstützung mehrerer Adreßräume deren gegenseitige Abschottung. Analog zum Schutz der Systemsoftware vor fehlerhaften Anwendungen, sollen auch andere funktionstüchtige Anwendungsprogramme geschützt werden. Ist dieser Schutz nicht gewährleistet, können fehlerhafte Programme den Adreßraum einer weiteren Anwendung verändern und damit Folgefehler in dieser auslösen. Indeterministische Fehler dieser Form sind schwer zu reproduzieren und ihre Lokalisierung ist meist extrem schwierig und langwierig. Bei mehreren gleichzeitig zu unterstützenden Adreßräumen müssen außerdem die vorhandenen Speicherressourcen auf diese aufgeteilt werden. Dabei muß berücksichtigt werden, daß der vorhandene physische Speicher meist nicht alle Anforderungen gleichzeitig befriedigen kann und daß Anwendungen zum Teil sehr unterschiedlichen Speicherbedarf haben. Während die meisten Anwendungen moderate Speicheranforderungen im Bereich weniger MByte stellen, gibt es vereinzelt Programme (z.B. im Bereich der KI oder der Bildverarbeitung), die den virtuellen 32-Bit-Adreßraum praktisch ausschöpfen. Eine den Anforderungen der einzelnen Anwendung entsprechende Zuteilung der Speicherressourcen und deren Kontrolle ist damit eine zentrale Aufgabe der Systemsoftware, um den reibungslosen Ablauf zu gewährleisten. Im Fall der speicherbasierten Ein- und Ausgabe obliegt der Systemsoftware in diesem Zusammenhang auch die exklusive Zuteilung bestimmter E/A-Geräte an einzelne Anwendungen. Zusätzlich sollte die Systemsoftware ökonomisch mit dem vorhandenen Speicher umgehen und alle gängigen Techniken einsetzen, um die durch dynamische Anforderungen bedingte Speicherfragmentierung und den tatsächlichen Speicherbedarf jeder Anwendung zu minimieren. Zum Beispiel können zusätzliche Einsparungen vorgenommen werden, wenn mehrere Anwendungen denselben Programmcode oder teilweise dieselben Funktionsbibliotheken verwenden. Durch geeignete Maßnahmen kann dafür gesorgt werden, daß dieser Code mehrfach in verschiedenen virtuellen Adreßräumen vorhanden ist, aber nur in einer Kopie Ressourcen des physischen Adreßraums belegt.
Gegenseitige Isolation der Adreßräume
Aufteilung der Speicherressourcen
Speicherökonomie
4
Adreßräume
Die Speicherabbildungstabelle Wie bereits an mehreren Stellen angeklungen ist, liegt der Schlüssel für eine erfolgreiche Umsetzung der meisten Anforderungen in einer zusätzlichen Abbildungstabelle, die bei jedem Speicherzugriff des Prozessors von einer Spezialhardware berücksichtigt und ausgewertet wird. Konzeptionell definiert diese Abbildungstabelle für jede Zelle im virtuellen Adreßraum der Anwendung die erlaubten Zugriffsmodi auf eine Adresse und den Ort des Adreßinhalts zum gegenwärtigen Zeitpunkt (siehe Abbildung 4-6). Die Ortsinformation selbst kann drei verschiedene Werte annehmen:
• •
•
Sie bezieht sich auf eine Position im physischen Speicher des Rechners (der Zelleninhalt ist in diesem Fall über eine normale Speicher Operation zugreifbar). Sie definiert die Position auf einem externen Speicher, auf den der Zelleninhalt ausgelagert wurde (der Zelleninhalt ist nur zugreifbar, nachdem er vom externen Speicher wieder geladen wurde). Die angegebene Adresse wird gegenwärtig nicht von der Anwendung genutzt (Undefinierte Adresse; der Zugriff führt zu einem Laufzeitfehler).
Abb. 4-6 Speicherabbildungstabelle
Es ist offensichtlich, daß nicht für jedes Byte im Anwendungsadreßraum ein Eintrag in dieser Abbildungstabelle vorhanden sein kann; die Tabelle wäre in diesem Fall mindestens um den Faktor k größer als der realisierte virtuelle Adreßraum (dabei sei k die Anzahl der benötigten Bytes pro Tabelleneintrag). Besondere Berücksichtigung muß auch der Aspekt finden, daß nicht bei jedem Speicherzugriff zusätzlich mehrere Zugriffe auf die Abbildungstabelle stattfinden können; die Ausführungsgeschwindigkeit von Programmen würde sonst ebenfalls minde-
4.2
Physischer Adreßraum
stens um den Faktor k reduziert werden. Viele der im nachfolgenden beschriebenen Adreßraumrealisierungen verfolgen das Ziel, die Größe der Tabelle und die Zugriffszeit substantiell zu verringern, ohne die Vorteile der zusätzlichen Adreßabbildung zu verlieren.
4.2
Physischer Adreßraum
Ein Großteil der Anwendungsforderungen, die im vorigen Abschnitt aufgestellt wurden, können durch den physischen Adreßraum allein nicht erfüllt werden. Eine homogene Adreßraumstruktur ergibt sich zum Beispiel nur dann, wenn der physische Adreßraum bereits homogen und zusammenhängend angelegt ist. Ohne zusätzliche Hardwareunterstützung können auch fehlerhafte Zugriffe auf die verschiedenen Adreßraumbereiche einer Anwendung praktisch nicht erkannt werden. Die theoretische Möglichkeit, vor der Ausführung jeder Instruktion zu prüfen, ob es zu einem Zugriffskonflikt kommt, scheidet aus Aufwandsgründen aus. Überschneidungen zwischen größenveränderlichen Bereichen des Adreßraums (Heap und Keller) können im Prinzip aufgedeckt werden. In diesem Fall muß bei der Vergrößerung eines solchen Bereichs immer überprüft werden, ob sich aus der Zuteilung eine Überschneidung ergibt. Wie im nachfolgenden dargestellt wird, sind jedoch einige Anforderungen zumindest in eingeschränktem Umfang erfüllbar. So können mit Hilfe entsprechender Unterstützungstechniken und unter Verwendung externer Speichermedien Anwendungen ausgeführt werden, die größer als der vorhandene physische Adreßraum sind. Auch der Schutz des zumindest von der Systemsoftware verwendeten Adreßraums vor dem fehlerhaften Zugriff durch Anwendungsprogramme kann mit Rückgriff auf vorhandene Möglichkeiten des Prozessors verhältnismäßig einfach erzwungen werden. Insgesamt bleiben die Möglichkeiten der direkten Nutzung des physischen Adreßraums aber weit hinter den Idealvorstellungen aus Anwendungssicht zurück, so daß ihr Einsatz auf bestimmte Einsatzgebiete beschränkt werden sollte. Ein solcher Einsatzbereich sind einfache industrielle Steuerungen, bei denen sich der zusätzliche Hardund Softwareaufwand zur Realisierung virtueller Adreßräume aus wirtschaftlichen Gründen meist nicht lohnt. Insbesondere wird für virtuelle Adreßraumtechniken zusätzlicher RAM-Speicher in Form der angesprochenen Abbildungstabelle benötigt, der sich aus Kostengründen bei Industriesteuerungen oft verbietet. Viele der aufgezählten Anwendungsforderungen sind im Bereich eingebetteter Systeme glücklicherweise auch nicht essentiell, so wird meist nur eine Anwendung ausgeführt; ein Schutz einzelner Anwendungen untereinander
Erkennen und Isolieren von Fehlern ist praktisch unmöglich
Anforderungen an Speicherverwaltung sind ungenügend umsetzbar
Einsatz bei einfachen Industriesteuerungen
4
MS-DOS
Adreßräume
entfällt damit. Außerdem können die benötigen Speicheranforderungen für diese Programme meist hinreichend genau bestimmt und statisch fixiert werden, so daß keine besonderen Anforderungen an die Laufzeitflexibilität der Adreßraumrealisierung gestellt werden müssen. Ein zweiter Einsatzbereich wird durch ältere aber weit verbreitete Betriebssysteme definiert, die Anwendungsprogrammen ausschließlich den physischen Adreßraum zur Verfügung stellen. Der bekannteste Vertreter dieser Gruppe von Betriebssystemen dürfte MS-DOS der Firma Microsoft sein. Hier steht ein maximal 640 KByte großer RAM-Bereich im physischen Adreßraum des Rechners jeweils nur einer Anwendung zur Verfügung. Schutz des Adreßraums der Systemsoftware
Schutz der Systemsoftware
Variante
Es ist mit verhältnismäßig einfachen Maßnahmen möglich, den von der Systemsoftware verwendeten Teil des physischen Adreßraums vor dem fehlerhaften Zugriff durch ein Anwendungsprogramm zu schützen. Das einfachste dieser Verfahren ist natürlich, alle unveränderlichen Teile der Systemsoftware im ROM-Bereich des physischen Adreßraums anzusiedeln. Eine Veränderung des SystemsoftwareCodes wird damit verhindert. Nachteilig an diesem Verfahren ist unter anderem, daß eine Aktualisierung oder ein Wechsel der Systemsoftware mit hohem Aufwand verbunden ist. Außerdem profitieren Zustandsinformationen der Systemsoftware, die im beschreibbaren Speicher liegen müssen, nicht von dieser Schutzmaßnahme. Eine zweite Möglichkeit besteht darin, Systemsoftware in einem Bereich des Adreßraums abzulegen, der nur im privilegierten Ausführungsmodus zugreifbar ist. Man macht sich in diesem Fall die Tatsache zunutze, daß die meisten Prozessoren die Ausführung von Instruktionen im privilegierten Modus über spezielle Statusleitungen nach außen signalisieren. Kombiniert man diese Statusinformation in geeigneter Weise mit der Adreßdekodierlogik des Rechners, können bestimmte Teile des physischen Adreßraums exklusiv der Systemsoftware zugeordnet werden. Umgekehrt kann die Systemsoftware selbst weiterhin auf den gesamten physischen Adreßraum zugreifen. Der Schutzmechanismus kann so ausgelegt werden, daß bei einem Zugriff auf diesen Speicherbereich im Normalmodus ein Interrupt ausgelöst wird. Da der Prozessor die Ausführung der Unterbrechungsroutine im privilegierten Modus beginnt, kann die Systemsoftware auf diese Schutzverletzung reagieren und das auslösende Anwendungsprogramm z.B. beenden.
4.2
Physischer Adreßraum
Anzahl unterstützter Anwendungsadreßräume Generell besteht die Möglichkeit, mehrere Anwendungen gleichzeitig im physischen Adreßraum zu unterstützen. Soll nur eine Anwendung unterstützt werden, steht dieser im Prinzip der gesamte physische Adreßraum zur Verfügung, abzüglich der von der Systemsoftware benötigten Teile. Bei mehreren Anwendungen muß der verbleibende physische Adreßraum unter allen Anwendungen aufgeteilt werden. Der nutzbare Adreßraum wird damit für jede Anwendung kleiner, und es wächst das Risiko einer Überschneidung bei den größenveränderlichen Speicherbereichen Heap und Stack. Aufgrund des fehlenden Schutzes der Anwendungen untereinander erhöht sich außerdem die Wahrscheinlichkeit, daß ein fehlerhaftes Programm auf Adreßbereiche einer anderen Anwendung zugreift und dort indeterministische Folgefehler herbeiführt. Bei der Unterstützung mehrerer Adreßräume entsteht beim Starten neuer Anwendungen Zusatzaufwand aufgrund notwendiger Relokationen, da Position und Größe des neuen Adreßraums erst unmittelbar vor dem Programmstart ermittelt werden können. Dieser Aufwand kann bei der Verwendung positionsunabhängigen Codes vermieden werden.
Gleichzeitig mehrere Adreßräume unterstützen
Relokation
Swapping Wenn keine besonderen Vorkehrungen getroffen werden, müssen alle zu einem bestimmten Zeitpunkt geladenen Anwendungsadreßräume vollständig in den physischen Speicher des Rechners passen. Wie bereits angesprochen, kann ohne spezielle Hard- und Softwareunterstützung keine für die Anwendung transparente Vergrößerung des Adreßraums über die Kapazität des physischen Speichers hinaus erzielt werden. Überschreiten die Speicherplatzanforderungen in der Summe trotzdem die vorhandenen Kapazitäten, müssen ein oder mehrere Anwendungsadreßräume vollständig auf einen Hintergrundspeicher (z.B. eine Festplatte) verdrängt und zu einem späteren Zeitpunkt wieder in den Hauptspeicher transferiert werden. Mit Hilfe dieses als Swapping bezeichneten Vorgangs wird ein Zeitmultiplexen des vorhandenen physischen Speichers unter den existierenden Anwendungsadreßräumen, die einzeln jeweils vollständig in den physischen Speicher passen müssen, erreicht. Optimierungskriterium für ein solches Multiplexverfahren ist eine gute Prozessorauslastung trotz der mit der Auslagerung verbundenen hohen Zugriffszeiten auf den Hintergrundspeicher. Man macht sich dabei zwei Eigenschaften besonders zunutze:
Temporäre Auslagerung ganzer Adreßräume
4
Adreßräume
1. Bei vielen Programmen wechseln sich längere Rechen- und Blockadephasen ab. 2. Hintergrundspeicher können in der Regel mittels DMA asynchron zum Hauptprozessor bedient werden.
Wechsel zwischen Prozessor- und E/A-Burst
Die erste Eigenschaft beschreibt ein empirisch ermitteltes, typisches Verhalten vieler Anwendungsprogramme. Während der auch als Prozessorbursts bezeichneten aktiven Rechenphasen führt der Prozessor Instruktionen der Anwendung aus. Die Rechenphasen werden immer wieder von Blockadephasen unterbrochen, in denen die Anwendung auf die Beendigung einer Ein- oder Ausgabeoperation wartet. Während dieser E/A-Bursts kann der Prozessor keine weiteren Instruktionen der auf das E/A-Ende wartenden Anwendung ausführen. Zusammen mit der zweiten Eigenschaft ergibt sich damit für SwappingVerfahren die Möglichkeit, die Auslagerung einer Anwendung in deren E/A-Burst zu verlagern. In der Regel erzielt man dabei mit nur einem unterstützten Anwendungsadreßraum keine ausreichende Prozessorauslastung, da das Potential an möglicher Parallelarbeit durch die asynchrone Bedienung des Hintergrundspeichers in diesem Fall gar nicht ausgeschöpft wird. Erst wenn zu einem Zeitpunkt mehrere Adreßräume unterstützt werden und diese unabhängig voneinander bei Bedarf ausgelagert werden können, sind akzeptable Werte für die Prozessorauslastung erreichbar. In diesem Fall kann der Prozessor während der Ein- oder Auslagerung eines Adreßraums einer anderen Anwendung zugeordnet werden. Techniken der Speicherüberlagerung In der Vergangenheit wurden vielfältige Methoden entwickelt, um Programme auszuführen, deren Speicherbedarf bereits alleine die vorhandene Kapazität des Hauptspeichers übertraf. Die Gründe dafür waren so unterschiedlich wie die eingesetzten Methoden. Beispielsweise waren die 16 Bit breiten Adreßbusse der frühen Mikroprozessoren für einige komplexe Programme der damaligen Zeit (z.B. Compiler und Datenbanksysteme) zu klein. Trotz der verhältnismäßig hohen Speicherkosten wurden daher hardwarebasierte Techniken entwikkelt, um mehr als 64 KByte bei einem auf 16 Bit begrenzten Adreßbus anzusprechen. Dieser Trend wurde lange Zeit durch den höheren Preisverfall bei Speicherbausteinen im Vergleich zum Preis der ersten 32-Bit-Prozessoren verstärkt.
4.2
Physischer Adreßraum
Abb. 4-7 Bank-Switching
Eine gängige Technik ist das sogenannte Bank-Switching. Bei diesem Verfahren wird ein festgelegter Teilbereich des physischen Adreßraums mehrfach überlagert (siehe Abbildung 4-7). Durch einen zusätzlichen parallelen Ausgang (Bankselektor) kann dabei programmgesteuert festgelegt werden, welcher Überlagerungsbereich (Bank) zum aktuellen Zeitpunkt im entsprechenden Fenster des physischen Adreßraums zugreifbar ist. Bank-Switching ist damit ein Verfahren, bei dem das ansonsten statische Einblenden der Speicherbausteine in den physischen Adreßraum des Rechners in Teilen dynamisch veränderbar bleibt. Eine softwaremäßige Realisierung des Bank-Switching-Verfahrens stellt die Overlay-Technik dar. Sie zielte vor allem darauf ab, die Überlagerungsbereiche durch den weitaus billigeren Hintergrundspeicher zu ersetzen. Overlay-Techniken sind eher dem Bereich Codeerzeugung bei der Übersetzung von Anwendungsprogrammen zuzuordnen; eine besondere Unterstützung seitens der Systemsoftware ist nicht notwendig. Der Entwickler teilt die Funktionen einer Anwendung in diesem Fall auf mehrere Overlays auf, von denen sich bei der Programmausführung jeweils nur eins im Hauptspeicher befindet. Der Compiler kann bei der Programmübersetzung mit einfachen Mitteln feststellen, ob bei einem Funktionsaufruf in ein anderes Overlay gewechselt wird. Ist dies der Fall, wird entsprechender Code zum Laden des notwendigen Overlays eingefügt, gefolgt vom eigentlichen Funktionsaufruf. Aufgrund der hohen Nachladezeiten ist es sinnvoll, die Unterteilung in mehrere Overlays so zu wählen, daß ein Wechsel möglichst selten vorkommt, d.h., daß viele Funktionsaufrufe im gleichen Overlay bleiben. Bei vielen Programmen können solche wechselwirkungsarmen Ausführungsphasen identifiziert werden. So bestehen z.B. Compiler selbst meist aus zwei Übersetzungsphasen, die häufig mittels der OverlayTechnik nacheinander in den Hauptspeicher geladen wurden. Da sich
Overlay-Technik
4
Adreßräume
die Überlagerung immer nur auf den reinen Programmcode bezieht der gesamte Programmzustand also für die gesamte Ausführungsdauer im Hauptspeicher verbleibt - muß das aktuell geladene Overlay im Gegensatz zu Swapping-Techniken beim Nachladen eines anderen Overlays nicht auf den Hintergrundspeicher zurückgeschrieben werden. Beispiel MS-DOS
MS-DOS
640-KByte-Grenze
Die Problematik der direkten Nutzung des physischen Speichers soll abschließend am Beispiel von MS-DOS kurz verdeutlicht werden (für eine detaillierte Diskussion siehe z.B. [Messmer 1995]). Die Entwicklung der ersten Version von MS-DOS im Jahre 1981 steht in unmittelbarem Zusammenhang mit der Einführung des ersten PCs durch IBM. Herz dieses Ur-PCs war der damalige 8088-Mikroprozessor der Firma Intel. Dieser Prozessor besitzt einen 20 Bit breiten Adreßbus und ist damit in der Lage, maximal 1 MByte zu adressieren. In der PC-Architektur mußte nun dieser 1 MByte große Adreßraum auf das Betriebssystem, speicherbasierte E/A-Geräte und den Speicherbereich für Anwendungsprogramme aufgeteilt werden. Sowohl Betriebssystem als auch alle Anwendungsprogramme werden nach dieser mehr oder weniger willkürlichen Aufteilung in den ersten 640 KByte des physischen Adreßraums untergebracht. Die verbleibenden 384 KByte bis zur 1-MByte-Grenze wurden für ROM-Speicher und verschiedene speicherbasierte E/A-Geräte wie z.B. dem Bildschirmspeicher reserviert. Solange 640 KByte für die Programmausführung ausreichen, entstehen mit dieser Aufteilung keine Probleme. Nachfolgende PC-Generationen besaßen schnell erheblich leistungsfähigere Prozessoren der Intel-Serien 80x86, die weit mehr als nur 1 MByte adressieren konnten (die heutigen Pentium-Prozessoren verfügen z.B. über einen 32 Bit breiten Adreßbus). Gleichzeitig stieg der Speicherbedarf vieler Anwendungen nicht zuletzt wegen der zunehmenden Verbreitung graphischer Bedienungsoberflächen drastisch an. Die 640-KByte-Grenze wurde damit sehr schnell erreicht. Obwohl im UNIX-Bereich virtuelle Speichertechniken bereits seit längerem gängig waren, wurde bei PCs wohl primär aus Kostengründen weiterhin darauf verzichtet. Statt dessen wurde eine Vielzahl an mehr oder weniger exotischen Techniken entwickelt, um zusätzlichen Speicher für Anwendungen zugreifbar zu machen. Ein weiteres Hindernis bei der Durchsetzung einer klaren Adreßraumarchitektur war auch der starke Druck der Abwärtskompatibilität, der erzwang, daß auch frühere PC-Programme auf neueren Rechnern direkt ausführbar bleiben. Entwickelt wurden u.a. Techniken, um unbenutzte Bereiche zwischen der 640-KByte- und der 1-MByte-Grenze nutzbar zu machen und eher
4.3
Segmentbasierter virtueller Adreßraum
als Adressierungstricks einzustufende Versuche, einen kleinen 64KByte-Bereich jenseits der 1-MByte-Schwelle (HIMEM) anzusprechen. Ein Speicherausbau bis zu 32 MByte wurde lange Zeit durch eine weitere Bank-Switching-Variante erreicht, bevor mit neueren Versionen, wie z.B. Windows 95, Anwendungen ein virtueller 32-BitAdreßraum zur Verfügung stand. Bei diesem als Expanded Memory (EMS) bezeichneten Verfahren werden innerhalb des unteren 1MByte-Bereichs des PCs vier Adreßfenster zu 16 KByte plaziert. Der erweiterte Speicher selbst wird ebenfalls in eine vom Ausbau abhängigen Anzahl von Seiten zu je 16 KByte unterteilt. In jedes der vier Adreßfenster kann durch ein spezielles Mapping Register (vergleichbar einem Bankselektor) jede beliebige Seite des erweiterten Speichers eingeblendet werden.
4.3
HIMEM
Expanded Memory
Segmentbasierter virtueller Adreßraum
Eine insbesondere im PC-Bereich gängige und einfache Variante virtueller Adreßraumtechniken basiert auf besonderen Prozessorregistern. Diese sogenannten Segmentregister (bei manchen Prozessoren auch als Basisregister bezeichnet) ermöglichen die Adressierung von Speicherzellen relativ zu einer Basisadresse. Zu diesem Zweck wird allgemein vor jeder Adreßreferenz der aktuelle Wert des Segmentregisters addiert; das Additionsergebnis definiert dann die effektive Speicheradresse. Im Fall von Intel wird damit ab dem 8088-Prozessor z.B. auch die Erweiterung des physischen Adreßraums von 64 KByte auf 1 MByte erreicht. Absolutadressen werden bei diesen Prozessoren und bei neueren Intel-Prozessoren in einem besonderen Kompatibilitätsmodus weiterhin als 16-Bit-Werte angegeben. Die Segmentregister sind ebenfalls 16 Bit breit. Um zu der 20 Bit breiten effektiven Adresse zu gelangen, wird der Inhalt des Segmentregisters konzeptionell mit dem Faktor 16 multipliziert (siehe Abbildung 4-8). Enthält ein Segmentregister zum Beispiel den Wert 0x13a0 und wird innerhalb des Segments die Adresse 0x023f angesprochen, so ergibt sich eine effektive Adresse 0x13a00 + 0x0023f = 0x13c3f. Praktisch wird diese Multiplikation ohne Zeitverzögerung durchgeführt, indem die einzelnen Bits des Segmentregisters um 4 Stellen verschoben addiert werden. Das Betriebssystem ist damit in der Lage, ältere Programme ohne jede Änderung in einem vergrößerten Adreßraum auszuführen, indem es vor dem eigentlichen Programmstart durch Setzen der Segmentregister die Anwendung im 1-MByte-Adreßraum geeignet plaziert.
Segmentregister
Segmentregister wirken implizit auf jeden Speicherzugriff
4
Adreßräume
Abb. 4-8 Bestimmung der Effektivadresse bei Verwendung von Segmentregistern
Zugriffstyp bestimmt das konkrete Segmentregister
Nachteile
Vorteile
Intel-Prozessoren stellen gleich mehrere Segmentregister zur Verfügung, die in Abhängigkeit vom Typ des jeweiligen Speicherzugriffs implizit bei der Ausführung der Maschineninstruktionen eingesetzt werden: Das Code-Segmentregister (CS-Register) wird beim Zugriff auf Instruktionen und PC-relativen Daten, das Data-Segmentregister (DSRegister) bei normalen Datenzugriffen und das Stack-Segmentregister (SS-Register) bei Kelleroperationen ausgewertet. Darüber hinaus steht Anwendungen ein weiteres ES-Register, bei neueren Prozessoren sogar zusätzlich die Register FS und GS, zur Verfügung. Segmente sind nach dem angegebenen Verfahren auf eine Größe von 64 KByte beschränkt. Beim Zugriff auf zusammenhängende Instruktionssequenzen und Datenstrukturen, die diese maximale Segmentgröße überschreiten, müssen die entsprechenden Segmentregister immer wieder neu geladen werden; ein zusätzlicher Aufwand, der die Gesamtleistung meßbar reduziert und der aufgrund von Beschränkungen oder Fehlern bei Sprachcompilern die Programmentwicklung häufig erschwert. Neben der Plazierung von Programmen in einem größeren Adreßraum erfüllen Segmentregister weitere Aufgaben. Die Unterscheidung zwischen verschiedenen Segmentregistern je nach Zugriffstyp erlaubt eine einfache Form des Schutzes - zumindest des Programmcodes. Die beschränkte Größe eines Segments schützt (abgesehen vielleicht von 64 KByte großen Segmenten, die definitiv zu klein sind) darüber hinaus bis zu einem gewissen Grad vor Überläufen, wenn die Basisadressen der einzelnen Segmente ausreichend weit auseinander liegen. Segmentregister sind damit eine primitive Variante virtueller Adressierungstechniken, die einen verhältnismäßig geringen zusätzlichen Hardwareaufwand erfordern. Basis- und Grenzregister Eine echte Überprüfung auf Gültigkeit beim Zugriff auf eine Speicheradresse findet bei der Verwendung von Segmentregistern nicht statt, da jede 16-Bit-Adresse innerhalb eines Segments als gültige Adresse aufgefaßt wird. Außerdem muß - zumindest bei Intel-Prozessoren -
4.3
Segmentbasierter virtueller Adreßraum
eine Anwendung mit der fest vorgegebenen 16-Bit-Größe der Segmente auskommen. Durch eine Erweiterung auf zwei Register können Segmente verschiedener Länge geeignet realisiert werden, das zusätzliche Register speichert in diesem Fall die aktuelle Segmentlänge. Abb. 4-9 Basis- und Grenzregister
Diese als Basis- und Grenzregister (siehe Abbildung 4-9) bekannte Technik faßt die Adressen innerhalb des Segments als Offsets relativ zur Anfangsadresse 0 auf. Bei jedem Speicherzugriff wird überprüft, ob ein Zugriff außerhalb des Segments stattfindet. Zu diesem Zweck speichert das Grenzregister die aktuelle Segmentlänge. Ist die angegebene Adresse kleiner als der Inhalt des Grenzregisters, so findet ein Zugriff innerhalb des Segments statt und der Wert des Basisregisters wird zur virtuellen Adresse addiert. Bei einem Zugriff außerhalb der Segmentgrenzen wird eine Ausnahmebehandlung ausgelöst und damit die Systemsoftware mit der weiteren Behandlung dieses Adreßfehlers beauftragt. Bei einem Basis- und Grenzregisterpaar kann der gesamte Adreßraum der jeweils aktiven Anwendung während der Ausführung geschützt werden. Mit jedem Kontextwechsel durch den Prozessor werden neue Werte für Basis- und Grenzregister geladen, um auf den jeweils aktuellen Adreßraum zugreifen zu können. Fehler wirken sich nur innerhalb des Adreßraums aus. Ein differenzierter Schutz innerhalb eines Adreßraums ist mit diesem Verfahren jedoch nicht möglich. Nachteilig ist außerdem, daß jedes Segment auf einen zusammenhängenden Speicherbereich im physischen Adreßraum abgebildet werden muß. Relokationen der Adreßräume im Speicher - z.B. zur Bereinigung externer Fragmentierung - sind jedoch prinzipiell möglich, da innerhalb des Segments relativ zur Basisadresse adressiert wird.
Einfacher Schutzmechanismus Nachteile
4
Adreßräume
Abb. 4-10 Segmenttabelle
Segmenttabelle
Segmentdeskriptor
Faßt man mehrere Basis- und Grenzregisterpaare zur Beschreibung von Segmenten zusammen, erhält man eine Segmenttabelle (siehe Abbildung 4-10). Auf die zu einem Zeitpunkt gültige Tabelle verweist ein spezielles Register im Prozessor. Das implizit bei jedem Speicherzugriff verwendete Segmentregister enthält in diesem Fall nicht die Basisadresse, sondern einen Index s, der auf den für die Adreßberechnung gültigen sogenannten Segmentdeskriptor in der Tabelle verweist. Alle Adressen werden weiterhin als Offset innerhalb des angesprochenen Segments verwendet. Mit jedem Speicherzugriff wird im Prinzip der entsprechende Segmentdeskriptor von der MMU aus dem Speicher geladen und ausgewertet. Bei gültigem Zugriff wird die im Deskriptor enthaltene Basisadresse zum Offset addiert und auf die angesprochene Speicherzelle zugegriffen. In allen anderen Fällen wird eine Ausnahmebehandlung im Hauptprozessor ausgelöst und der Speicherzugriff abgebrochen. In der Praxis wird der Deskriptor immer nur beim ersten Zugriff auf ein Segment tatsächlich geladen. Bei erlaubtem Segmentzugriff werden in der Regel Basisadresse und Segmentlänge in prozessorlokale Cache-Register kopiert, um nachfolgende Zugriffe zu beschleunigen. Segmente beim 80386-Prozessor Neben der Basisadresse und der Segmentlänge können in einem Segmentdeskriptor weitere Informationen gespeichert werden. Am Beispiel der Segmentdeskriptoren bei den Intel-Prozessoren 80386 und höher soll dies verdeutlicht werden. Jeder dieser Deskriptoren ist 8 Byte groß und speichert für jedes Segment im wesentlichen folgende Daten:
Bestandteile eines Segmentdeskriptors
• •
Basisadresse (32 Bit) Segmentlänge oder Limit (20 Bit)
4.3
• • • • •
Segmentbasierter virtueller Adreßraum
G-Bit (Granularitätsbit) P-Bit (Present-Bit) A-Bit (Accessed-Bit) Segmenttyp (5 Bit) Privilegierungsstufe (2 Bit) = DPL (Descriptor Privilege Level)
Mit der 32-Bit-Basisadresse kann das Segment an jeder Position im maximal 4 GByte großen physischen Adreßraum des Rechners plaziert werden. Die tatsächliche Segmentgröße wird durch das 20-BitLimit und das G-Bit bestimmt. Ist das G-Bit 0, wird die Segmentlänge in Einheiten zu einem Byte interpretiert. Die maximale Segmentgröße ist in diesem Fall 220 Byte = 1 MByte. Bei gesetzten G-Bit ist die Segmentgröße ein Vielfaches einer 4096 Byte großen Seite, mit einer resultierenden Maximalgröße von 232 Byte = 4 GByte pro Segment. Das PBit gibt an, ob sich ein Segment im Hauptspeicher des Rechners befindet oder auf einen Hintergrundspeicher ausgelagert wurde. Das A-Bit wird von der Hardware des Prozessors bei jedem Zugriff auf ein Segment automatisch gesetzt. Durch periodisches Prüfen und Zurücksetzen kann die Systemsoftware dieses Bit nutzen, um geeignete Auslagerungskandidaten zu bestimmen. Indirekt über den Segmenttyp werden die Zugriffsrechte auf ein Segment unterschieden, d.h., ob Leseund/oder Schreibzugriff zugelassen ist sowie ob ausführbare Instruktionen enthalten sind. Außerdem kann über den Segmenttyp die Wachstumsrichtung angegeben werden. Die Privilegierungsstufe DPL legt fest, welche Programme auf ein bestimmtes Segment zugreifen dürfen und welche nicht. Wie bereits in Kapitel 2 erläutert, unterscheiden Prozessoren der 80x86-Familie vier Privilegierungsstufen 0 bis 3. Die Privilegierungsstufe des aktuell ausgeführten Programms wird durch einen zusätzlichen Eintrag im Segmentregister CS vermerkt (CPL = Current Privilege Level) und bei jedem Zugriff auf ein anderes Segment überprüft. Ein direkter Zugriff wird nur gestattet, wenn die Privilegierungsstufe des ausgeführten Programms höher (wertmäßig kleiner) ist als die geforderte Stufe des zugegriffenen Segments, d.h wenn CPL < DPL. In allen anderen Fällen muß ein Zugriff auf höher privilegierte Segmente über besondere CallGates durchgeführt werden, die im wesentlichen einem auf mehrere Stufen erweiterten Trap-Mechanismus entsprechen (siehe dazu z.B. [Messmer 1995]). Der Prozessor selbst besitzt zwei sichtbare Register GDTR und LDTR, in denen letztendlich Verweise auf zwei aktuell gültige Segmenttabellen enthalten sind. Die globale Tabelle GDT (Global Descriptor Table) enthält Deskriptoren für Segmente von allgemeinem Interesse, z.B. Betriebssystemdienste und Funktionsbibliotheken. Diese Segmenttabelle ist für jedes Anwendungsprogramm zugreifbar; die
Zugriffsrechte
Globale Segmenttabelle (GDT)
4
Lokale Segmenttabelle (LDT)
Adreßräume
tatsächliche Nutzung der über sie zugreifbaren Segmente hängt von den jeweiligen Privilegierungsstufen ab. Die lokale Segmenttabelle LDT (Local Descriptor Table) beschreibt typischerweise die Segmente des Anwendungsadreßraums. In ihr können ein oder mehrere Codesegmente, die das Anwendungsprogramm beschreiben, mehrere Datensegmente sowie dynamisch wachsende Segmente für Heap und Laufzeitkeller vermerkt werden. Im Gegensatz zum GDTR wird das LDTR vom Betriebssystem bei jedem Adreßraumwechsel neu geladen. Beide Tabellen sind in ihrer Größe auf jeweils maximal 8192 Segmentdeskriptoren beschränkt. Zusammenfassung
Vorteile
Nachteile
Auf der Grundlage segmentbasierter Adressierungstechniken können die meisten der in Abschnitt 4.1 aufgestellten Forderungen realisiert werden. Die Segmentgröße ist nur durch die Adressierungsmöglichkeiten des Prozessors beschränkt. Jedes Segment kann einen zusammenhängenden Adreßbereich des physischen Adreßraums aufnehmen. Durch das Ein- und Auslagern ganzer Segmente können im Prinzip die Beschränkungen des physischen Speichers überwunden werden. Der wechselseitige Schutz der Anwendungsadreßräume untereinander einschließlich eines feingranularen Schutzes der einzelnen Bereiche innerhalb eines Adreßraums ist ebenso gewährleistet. Ordnet man dem dynamischen Datenbereich und jedem Laufzeitkeller ein eigenes Segment zu, können auch Überschneidungen zwischen diesen Bereichen ausgeschlossen werden, vorausgesetzt die den Segmenten zugeordneten Bereiche im physischen Adreßraum sind überlappungsfrei. Nachteilig an einem rein segmentbasierten Verfahren ist die resultierende externe Fragmentierung, da jedes Segment einen zusammenhängenden Speicherbereich im physischen Adreßraum benötigt. Aufgrund der Relokierbarkeit der Programme können jedoch alle Segmente an einem Ende des physischen Adreßraums zusammengeschoben werden. Dadurch entsteht wieder ein zusammenhängender Freispeicher maximaler Größe. Nach dem Zusammenschieben der Speicherbereiche müssen die Basisadressen in den jeweiligen Segmentdeskriptoren nur mit den neuen Positionen geladen werden. In der Praxis verbietet sich dieses Technik jedoch meist aufgrund ihres enormen Zeitaufwands. Problematisch ist auch der verhältnismäßig hohe Zeitaufwand beim Segmentwechsel. Bei Intel-Prozessoren kann z.B. das Laden eines einfachen Datums aus einem anderen Segment die Ausführungszeit für eine Instruktion um den Faktor 3 erhöhen. Je stärker also Segmente zur Realisierung eines feingranularen Schutzkonzepts ein-
4.4
Seitenbasierter virtueller Adreßraum
gesetzt werden, desto häufiger müssen Deskriptoren nachgeladen werden und desto größer ist der resultierende Overhead.
4.4
Seitenbasierter virtueller Adreßraum
Seitenbasierte Verfahren unterteilen einen Adreßraum in aufeinanderfolgende Seiten gleicher Größe. Für jede einzelne Seite können unter anderem Zugriffsrechte und die aktuelle Position des Seiteninhalts vermerkt werden. Analog zu Segmentdeskriptoren wird diese Information innerhalb einer Seitentabelle in Form sogenannter Seitendeskriptoren gespeichert. Im Gegensatz zu segmentbasierten Verfahren kommt diese Technik ohne besondere Register aus, d.h, Adressierungsarten und sichtbare Registerstruktur des Prozessors bleiben davon unberührt. Eine Adresse wird von der nachgeschalteten MMU als ein 2-Tupel aufgefaßt, das sowohl den Seitenanteil als auch den Offset innerhalb einer Seite für eine Speicherzelle festlegt (siehe Abbildung 4-11). Die Größe einer Seite ist immer eine Zweierpotenz. Zur Adressierung jeder Speicherzelle innerhalb einer Seite benötigt man deshalb k Bits, wenn 2 der Seitengröße entspricht. In der Regel wird dafür gesorgt, daß die MMU die niederwertigen k Bits der Adresse als Offset interpretiert. Die verbleibenden Bits des Adreßbusses legen dann die jeweilige Seite fest. Beispielsweise werden bei 1 KByte großen Seiten 10 Bit für das Offset benötigt. Besitzt der Prozessor einen 32 Bit breiten Adreßbus, können in diesem Fall maximal 22 Adreßleitungen zur Angabe der Seite verwendet werden.
Seitendeskriptor
Abb. 4-11 Aufbau einer seitenbasierten virtuellen Adresse
Der Seitenanteil einer virtuellen Adresse wird als Index innerhalb der Seitentabelle benutzt. Dabei wird der Anfang der aktuell gültigen Seitentabelle durch ein besonderes Register in der MMU (vergleichbar den Registern GDTR und LDTR als Zeiger auf Segmenttabellen bei Intel-Prozessoren) definiert. Da die Seitentabelle in einem zusammenhängenden Speicherbereich liegt und jeder Seitendeskriptor eine feste Größe hat, kann durch eine einfache Berechnung der Form
die Anfangsadresse des aktuellen Seitendeskriptors zu einer Adreßreferenz von der MMU ermittelt werden. Jeder Seitendeskriptor enthält die folgenden Informationen:
Seitentabelle
4
Bestandteile eines Seitendeskriptors
Kachel
Schutzbits
Referenced-Bit
Cache-Disable-Bit
Adreßräume
• • • • • • •
Seitenadresse Present-Bit (P-Bit) Schutzbits Referenced-Bit (R-Bit) Dirty-Bit (D-Bit) Cache-Disable-Bit (C-Bit) frei benutzbare Bits
Die Seitenadresse zusammen mit dem P-Bit gibt an, wo sich der Seiteninhalt gegenwärtig befindet. Ist das P-Bit gesetzt, definiert die Seitenadresse die Kachel mit dem Inhalt. Dabei werden als Kacheln typischerweise die Seiten des physischen Adreßraums bezeichnet. Bei nicht gesetztem P-Bit wurde der Seiteninhalt ausgelagert. In diesem Fall enthält die Seitenadresse in kodierter Form den jeweiligen Auslagerungsort z.B. auf Festplatte. Die Schutzbits geben analog zu den entsprechenden Informationen bei Segmentdeskriptoren an, in welcher Form auf die Speicherzellen einer Seite zugegriffen werden darf. Gewöhnlich wird dabei zwischen keinem, lesendem, schreibendem oder ausführendem Zugriff unterschieden. Die Schutzinformationen in einem Deskriptor beziehen sich immer auf alle Speicherzellen der Seite. Das Referenced-Bit wird von der MMU beim Zugriff auf eine Speicherzelle innerhalb der Seite gesetzt. Vom Betriebssystem können die R-Bits einer Seitentabelle durch zyklisches Überprüfen und Löschen dazu verwendet werden, innerhalb eines Zeitintervalls genutzte und ungenutzte Seiten zu identifizieren. Das Dirty-Bit gibt an, ob seit dem letzten Zurücksetzen durch die Systemsoftware schreibend auf eine Speicherzelle der Seite zugegriffen wurde. Das Cache-Disable-Bit wird benötigt, um die Zwischenspeicherung von Werten aus bestimmten Speicherzellen in den LI- und L2Caches des Rechners zu unterbinden. Die Information gilt für alle Speicherzellen innerhalb der Seite. Dieses Flag ist im wesentlichen beim Zugriff auf speicherbasierte E/A-Geräte notwendig, da hier eine transparente Zwischenspeicherung und ein zeitversetztes Zurückschreiben (Write Back) der Inhalte von E/A-Registern zu Inkonsistenzen und einem Fehlverhalten des Geräts führt. Beispielsweise wird bei einem über eine serielle RS232-Schnittstelle angeschlossenen Gerät das zuletzt empfangene Zeichen aus einem schnittstellenabhängigen E/A-Register entnommen. Durch aufeinanderfolgende Leseoperationen von demselben E/A-Register wird gewöhnlich eine vom Gerät gesendete Zeichenfolge empfangen. Würden die Zugriffe auf das E/ARegister über die Caches des Rechners laufen, könnten die zweite und alle weiteren Leseoperationen aus dem Cache befriedigt werden; ein Zugriff auf das eigentliche Gerät fände nicht mehr statt und nachfolgend eintreffende Zeichen gingen in diesem Fall verloren.
4.4
Seitenbasierter virtueller Adreßraum
Seitendeskriptoren enthalten außerdem meist eine bestimmte Anzahl von der MMU nicht benutzter Bits, die vom Betriebssystem verwendet werden können. Sie erlauben die Speicherung seitenspezifischer Zustandsinformation, insbesondere im Zusammenhang mit der Auslagerung von Seiten auf Hintergrundspeicher.
Freie Bits
Abb. 4-12 Einstufige Seitentabelle
Einstufige Seitentabelle Im einfachsten Fall existiert für jeden virtuellen Adreßraum eine Seitentabelle. Bei jedem Zugriff auf eine Adresse im virtuellen Adreßraum durch den Prozessor kann die angesprochene Seite p und das Offset d innerhalb der Seite identifiziert werden. Unter Verwendung der Anfangsadresse der aktuellen Seitentabelle und dem Seitenindex p wird der zugeordnete Seitendeskriptor geladen (siehe Abbildung 4-12). Ein besonders gekennzeichneter Seitendeskriptor (Nulldeskriptor) entspricht einem Adreßbereich, der gegenwärtig nicht von der Anwendung verwendet wird. Ein Zugriff auf eine Adresse in dieser Seite wird als Adreßfehler zurückgewiesen; der Prozessor beginnt mit der Ausführung einer entsprechenden Ausnahmebehandlungsroutine. Bei einem gültigen Seitendeskriptor werden die in den Schutzbits festgelegten Zugriffsrechte mit dem aktuellen Zugriffsmodus des Prozessors verglichen. Eine Zugriffsverletzung erzwingt ebenfalls eine entsprechende Ausnahmebehandlung im Prozessor, die MMU löst in diesem Fall die referenzierte Adresse nicht weiter auf. Ist der Zugriffsmodus des Prozessors in den Schutzbits enthalten, wird anhand des P-Bits überprüft, ob sich die Seite im physischen Speicher des Rechners befindet. Wenn ja - und in diesem Abschnitt gehen wir von P=l aus -, ergibt die Verknüpfung aus der im Deskriptor gespeicherten Seitenadresse und dem Offset d die effektive Adresse der Speicherzelle. Je nach C-Bit wird dann die Speicherzelle über die Caches oder unter Umgehung der Caches referenziert. Außerdem wird das R-Bit im zugehörigen Seitendeskriptor immer und das D-Bit im Fall eines Schreibzugriffs gesetzt.
Einstufige Adreßabbildung
Nulldeskriptor
4
Einstufige Seitentabellen sind zu groß
Adreßräume
Im Vergleich zu der in Abschnitt 4.1 eingeführten Abbildungstabelle kann durch dieses einstufige seitenbasierte Abbildungsverfahren viel Platz gespart werden. Die Einschränkung, Schutz- und Ortsinformationen auf der Basis ganzer Seiten festzulegen, ist in den meisten Fällen sogar eher von Vorteil, da der Verwaltungsaufwand erheblich reduziert und die Blockorientierung der Hintergrundspeicher im Auslagerungsfall besser berücksichtigt wird. Problematisch bei einem einstufigen Verfahren bleibt jedoch immer noch die Größe der Seitentabelle. Da durch die Plazierung von Heap und Laufzeitkeller immer Anfang und Ende eines Adreßraums verwendet werden, muß zwangsläufig der Speicherplatz für eine vollständig benutzte Seitentabelle angelegt werden. Bei einer typischen Größe von 8 Byte pro Seitendeskriptor, einer Seitengröße von z.B. 4 KByte (12 Bit Offset) und einem 32 Bit breiten virtuellen Adreßraum ergibt sich damit eine Größe von 8 MByte (8*2 32-12 =8*2 20 ). Dieser Wert ist für viele Systeme mit einem Speicherausbau im Bereich 16 bis 64 MByte immer noch viel zu hoch. Außerdem werden alle Seitendeskriptoren, die der Abbildung von virtuellen Adressen innerhalb der Lücke zwischen Heap und Laufzeitkeller dienen, nicht benötigt; d.h., bei einer Größe von 20 MByte für den von einer Anwendung verwendeten Teil des Adreßraums werden 99,5% des Platzes in der Seitentabelle verschwendet. Mehrstufige Seitentabelle
Größenreduktion durch mehrere Stufen
Seitentabellendeskriptor
Durch die Unterteilung in mehrere Abbildungsschritte kann die Größe der Seitentabelle weiter reduziert werden. Gängig sind Verfahren, die über zwei, maximal drei Abbildungsstufen gehen. Für jeden einzelnen Abbildungsschritt können eigene Seitentabellen eingesetzt werden, wobei die Größe der beschriebenen Seiten mit jedem Schritt kleiner wird. Die erste Seitentabelle unterteilt den gesamten virtuellen Adreßraum in verhältnismäßig wenige, aber sehr große Seiten. An jeder Stelle der Seitentabelle kann - analog zum einstufigen Verfahren - ein Seitendeskriptor stehen, wenn tatsächlich ein entsprechend großer Bereich im physischen Adreßraum dafür reserviert wurde. Wird dagegen nur ein verhältnismäßig kleiner Teil des Adreßbereichs einer so großen Seite von der Anwendung tatsächlich genutzt, kann auch ein sogenannter Seitentabellendeskriptor eingetragen werden. Ein Seitentabellendeskriptor verweist in diesem Fall auf eine weitere Seitentabelle, die den Adreßbereich der übergeordneten »Seite« ihrerseits in kleinere Seiten unterteilt. Auch in dieser Tabelle können Seitendeskriptoren und Seitentabellendeskriptoren gemischt vorkommen. Es entsteht eine baumartige Anordnung von Seitentabellen, mit großen Seiten an der Wurzel und der kleinsten unterstützten Seiten-
4.4
Seitenbasierter virtueller Adreßraum
große an den Blättern. Die Blätter dieser Baumstruktur sind immer konventionelle Seitendeskriptoren, die auf den Seiteninhalt im physischen Speicher oder auf Hintergrundspeicher verweisen. Jeder Seitentabellendeskriptor enthält im einzelnen folgende Informationen:
• • • • •
Typfeld Adresse der Seitentabelle (Größe der Seitentabelle - Limit) Present-Bit (P-Bit) frei benutzbare Bits
Da bei einem mehrstufigen Verfahren jeder Tabelleneintrag sowohl ein Seitendeskriptor als auch ein Seitentabellendeskriptor sein kann, müssen die beiden durch ein zusätzliches Typ-Bit für die MMU unterscheidbar sein (entsprechend enthält auch der Seitendeskriptor zusätzlich ein Typfeld zur Unterscheidung). Primärer Inhalt des Deskriptors ist die Adresse der entsprechenden Seitentabelle. In manchen Systemen kann über ein zusätzliches Limit-Feld die Seitentabelle analog zu Segmenten in der Länge begrenzt werden. Außerdem verfügen Seitentabellendeskriptoren ebenfalls über ein Present-Bit (P-Bit), das angibt, ob die Seitentabelle ausgelagert ist. In diesem Fall muß vor einer weiteren Adreßabbildung die benötigte Tabelle vom Hintergrundspeicher geladen werden. Zusätzliche freie Bits können vom Betriebssystem zur Speicherung von Seitentabellen-bezogenen Zustandsinformationen herangezogen werden. Technisch wird bei einem mehrstufigen Verfahren der Seitenanteil der virtuellen Adresse in Abhängigkeit von der Stufenanzahl weiter unterteilt. Stehen für den Seitenanteil insgesamt p Bits zur Verfügung, so wird bei L Stufen eine Unterteilung pl p2, ..., pL gesucht mit p=p 1 +p 2 +...+p L . In der Regel wird mit der Adreßabbildung ab dem höchstwertigen Bit begonnen. In diesem Fall definieren die obersten p1-Bits der Adresse den Seitenanteil der ersten Stufe. Die nächsten P 2 Bits definieren den Seitenanteil zweiter Stufe. Analog wird mit den verbleibenden Abbildungsstufen verfahren. Bei jedem Schritt definieren die restlichen Bits, d.h die tieferen Seitenanteile einschließlich des Offsets, den Versatz relativ zu dieser Seite.
Bestandteile eines Seitentabellendeskriptors
Aufbau einer mehrstufigen virtuellen Adresse
Abb. 4-13 Aufbau einer virtuellen Adresse mit drei
Ein Beispiel für den Adreßaufbau bei einem dreistufigen Abbildungsverfahren ist in Abbildung 4-13 dargestellt. Der 32-Bit-Adreßraum wird bei der ersten Stufe in 2 =128 Seiten zu je 32 MByte unterteilt. Ein Seitendeskriptor auf dieser Stufe verweist auf eine 32 MByte
Abbildungsstufen
4
Adreßräume
(232-7) große Seite, die zusammenhängend im physischen Speicher des Rechners abgelegt sein muß. Ein Seitentabellendeskriptor auf dieser ersten Stufe verweist dagegen auf eine weitere Seitentabelle, die den 32 MByte großen Adreßbereich wiederum in 27 =128 Seiten zu je 256 KByte unterteilt. Ein Seitendeskriptor auf der zweiten Stufe beschreibt damit eine 256 KByte große Seite im physischen Adreßraum. Im Fall eines zweiten Seitentabellendeskriptors wird in einer Seitentabelle der dritten Stufe ein 256 KByte großer Bereich nochmals in 128 Seiten zu je 2 KByte unterteilt. Auf der dritten Ebene können sich nur noch Seitendeskriptoren für 2 KByte große Seiten befinden; für die Adressierung einer einzelnen Speicherzelle innerhalb einer solchen Seite wird ein 11 Bit breites Offset benötigt. Abb. 4-14 Mehrstufige Seitentabelle
Mehrstufige Adreßabbildung
Geringe Größe
Der Vorgang der Adreßabbildung selbst verläuft sehr ähnlich zu einem einstufigen Verfahren, mit dem Unterschied weiterer Iterationen im Fall von Seitentabellendeskriptoren (siehe Abbildung 4-14). Bei Anlegen der Adresse bestimmt die MMU den entsprechenden Deskriptoreintrag über den Seitenanteil p1 in der aktuell gültigen Wurzeltabelle. Auch in diesem Fall verweist ein besonderes Register in der MMU auf den Anfang dieser Tabelle. Bei einem Nulldeskriptor wird der Abbildungsvorgang gestoppt, und der Prozessor beginnt mit einer entsprechenden Ausnahmebehandlung. Bei einem Seitendeskriptor (PD) wird in bekannter Weise verfahren: Befindet sich die Seite im Hauptspeicher (P-Bit) und ist der aktuelle Zugriff erlaubt, werden Basisadresse der Seite und Offset zur Ermittlung der effektiven Adresse konkatentiert. Im Fall eines Seitentabellendeskriptors (PTD) wird aus der angegebenen Tabelle der benötigte Deskriptor mittels des nächsten Seitenanteils p2 bestimmt. Je nach MMU können maximal bis zu vier Abbildungsstufen über Seitentabellendeskriptoren durchgeführt werden, bevor das Verfahren mit einem Null- oder Seitendeskriptor endet. Der wesentliche Vorteil mehrstufiger Verfahren liegt in der enormen Speicherersparnis bei der Darstellung typischer Adreßräume. Wenn größere Bereiche innerhalb des Adreßraums nicht genutzt werden, können Nulldeskriptoren nahe der Wurzel des Seitentabellen-
4.4
Seitenbasierter virtueller Adreßraum
baums große Teilbäume frühzeitig abschneiden; der entsprechende Speicherplatz wird gespart. Gerade die bei vielen Anwendungen charakteristische Nutzung von Anfang und Ende des Adreßraums mit der entsprechend großen Lücke zwischen Heap und Laufzeitkeller kann platzgünstig abgebildet werden. Wenn in einem Adreßraum z.B. nur das erste und das letzte Byte genutzt werden, benötigt eine wie oben beschriebene dreistufige Adreßabbildung über alle drei Stufen 5 KByte zur Speicherung der gesamten Seitentabelle. Dabei werden Seitentabellen- und Seitendeskriptoren mit einer Größe von 8 Byte veranschlagt. Bei 128 Einträgen pro Tabelle ergibt das eine Größe von 1 KByte für jede Seitentabelle; es werden eine Tabelle auf der ersten, zwei Tabellen auf der zweiten und weitere zwei Tabellen auf der dritten Stufe benötigt. Bei einer Seitengröße von 2 KByte wäre eine einstufige Seitentabelle 16 MByte groß. Translation Lookaside Buffer (TLB) Es ist offensichtlich, daß gerade mehrstufige Verfahren bei jedem Abbildungsvorgang eine größere Menge an Zusatzinformationen in Form von Deskriptoren aus dem Hauptspeicher laden müssen. Dadurch kann ein Zugriff auf eine Speicherzelle bei einer Deskriptorlänge von 8 Byte und L Abbildungsstufen um maximal 8L-Ladeoperationen verzögert werden, vorausgesetzt die benötigten Seitentabellen und die referenzierte Seite befinden sich im Hauptspeicher. Um den Vorgang der Adreßabbildung wesentlich zu beschleunigen, verwenden daher alle seitenbasierten MMUs einen besonderen Cache, den sogenannten Translation Lookaside Buffer TLB (siehe Abbildung 4-15). Der TLB speichert für eine bestimmte Menge an virtuellen Seiten die zugehörige Kachel im physischen Adreßraum. Bei jedem Zugriff auf eine virtuelle Adresse wird überprüft, ob sich die zugehörige virtuelle Seite und damit die Basisadresse der Seite im TLB befindet. Diese Suche geschieht assoziativ, d.h, alle Einträge werden gleichzeitig auf Übereinstimmung mit dem Seitenanteil der referenzierten Adresse verglichen. Wird der Eintrag gefunden, spricht man von einem TLB-Hit. In diesem Fall kann die Seitenadresse unter Umgehung der gesamten Adreßabbildung aus dem TLB gelesen und mit dem Offset verknüpft werden. Ist kein entsprechender Eintrag im TLB enthalten (TLBMiss), muß die Adreßabbildung einschließlich dem Laden aller Deskriptoren durchgeführt werden, um zur effektiven Speicheradresse zu gelangen. Aufgrund der Referenzlokalität vieler Programme ist es sinnvoll, diese Adreßabbildung daraufhin in den TLB zu übernehmen und dafür z.B. den am längsten nicht verwendeten Eintrag zu löschen.
Zeitverzögerungen durch die virtuelle Adreßabbildung
TLB speichert das Ergebnis virtueller Adreßabbildungen
TLB-Hit
TLB-Miss
4
Adreßräume
Abb. 4-15 TLB
Abschätzung der Trefferrate
Adreßraumwechsel
TLB-Temperatur
Die Trefferrate des TLB wächst mit der Anzahl der Einträge. In Abhängigkeit von der erzielten Trefferrate kann die mittlere Verzögerung ermittelt werden, die ein Speicherzugriff durch die Adreßabbildung erfährt. Für eine einstufige Seitentabelle gilt:
Dabei ist H die Trefferrate des TLB, tTLB der Zeitaufwand für die assoziative Suche im TLB und tPD der Zugriff auf den Seitendeskriptor. Bei modernen Prozessoren liegt die Trefferrate H aufgrund der Referenzlokalität im Bereich von 90% bis 98%. Nimmt man für tTLB einen Wert von 20 ns an und für tPD 100 ns (für das Laden von 2-32 Bit) ergibt sich für H=90% eine Verzögerung von 28 ns und für H=98% eine Verzögerung von 21.6 ns. Adreßraumwechsel sind für den TLB wie für jeden anderen Cache innerhalb des Rechners besonders kritisch. Ausgelöst wird ein solcher Wechsel durch eine neue Wurzelseitentabelle, deren Adresse in das entsprechende Register der MMU geladen wird. Da jeder TLB-Eintrag nur für den aktuellen virtuellen Adreßraum gültig ist, werden diese durch den Adreßraumwechsel invalidiert. Manche MMUs erkennen den Wechsel selbständig und löschen die TLB-Einträge automatisch. Bei anderen MMUs muß die Systemsoftware durch besondere Befehle die TLB-Einträge manuell zurücksetzen. Die Programmausführung beginnt nach dem Wechsel immer mit einem kalten TLB, dessen Einträge zuerst wieder vom Hauptspeicher nachgeladen werden müssen. Einzelne MMUs erlauben sogar das Sichern des TLB im Hauptspeicher und das Restaurieren des TLBInhalts beim Adreßraumwechsel, um möglichst schnell wieder zu einem heißen TLB zu gelangen. Eine zweite Möglichkeit besteht in der längerfristigen Reservierung von einzelnen Einträgen für bestimmte
4.4
Seitenbasierter virtueller Adreßraum
Adreßräume. Zu diesem Zweck werden virtuelle Adreßräume und TLB-Einträge mit einer Identifikation versehen. Bei jedem AdreßraumWechsel wird die Kennung des ab jetzt gültigen Adreßraums in einem Register der MMU gespeichert. Die assoziative Suche findet in diesem Fall auf der Basis des virtuellen Seitenanteils und der zwischengespeicherten Kennung statt, d.h, nur Einträge für den aktuell gültigen Adreßraum werden ausgewertet. Dieses zweite Verfahren ist besonders interessant, wenn ein TLB aufgrund einer besseren Ausnutzung der Chip-Fläche vom Hersteller vergrößert werden kann. Seitenbasierte Adressierung beim SPARC-Prozessor Die mehrstufige virtuelle Adreßabbildung soll am Beispiel der SPARCMMU noch einmal kurz verdeutlicht werden (siehe auch [Spare Int. 1992]). Das dreistufige Verfahren wandelt eine 32 Bit große virtuelle Adresse in eine 36 Bit große physische Adresse um (siehe Abbildung 4-16). Die kleinste unterstützte Seitengröße beträgt 4 KByte (12 Bit Offset). Abb. 4-16 Dreistufige Abbildung der SPARC-MMU
Seitentabellen- und Seitendeskriptoren sind jeweils 4 Byte groß. Ein Deskriptor kann damit in einem Speicherzugriff geladen werden. Die Größenreduktion wurde im wesentlichen durch Einschränkungen bei den Plazierungsmöglichkeiten von Seitentabellen und Seiten erreicht. Da der physische Adreßraum in Seiten zu 4 KByte unterteilt wird, reichen der MMU 24 Bit für die Angabe der Kachel mit dem Seiteninhalt. Die verbleibenden 12 Bit dienen u.a. der Speicherung der Zugriffsrechte und der verschiedenen Statusbits. Seitentabellen sind bei der SPARC-MMU in der ersten Stufe 1024 Bytes, ansonsten 256 Bytes groß; sie müssen an einer 256-Byte-Grenze beginnen. Der Zeiger auf die Seitentabelle ist in diesem Fall 30 Bit lang. Zwei Bits dienen bei beiden Deskriptoren zur Speicherung des Typs (ET = Entry Type). Ein Wert ET=01 identifiziert einen Seitendeskriptor, ET=10 einen Seitentabellendeskriptor. ET=00 ist für den Nulldeskriptor, d.h für nicht angelegten virtuellen Adreßraum, reserviert.
Seitengröße 4 KByte
4
Kontextinformation
Adreßräume
Der Aufbau der SPARC-MMU ist in Abbildung 4-17 schematisch dargestellt. Die 32 Bit große virtuelle Adresse VA wird von der MMU im Virtual Address Latcb zwischengespeichert. Im TLB kann - abhängig von der MMU-Version - eine bestimmte Anzahl an Seitendeskriptoren PD; zusammen mit der virtuellen Seiten- und Kontextinformation gespeichert werden. Die Kontextinformation entspricht dabei der erwähnten Kennzeichnung von Adreßräumen. Die assoziative Suche wird mit dem Seitenanteil vp der virtuellen Adresse und dem Inhalt des Context-Registers, das den aktuell gültigen Adreßraum identifiziert, durchgeführt. Bei einem Treffer wird aus der im Seitendeskriptor gespeicherten Kacheladresse und dem Offset innerhalb der Seite die effektive Adresse RA bestimmt. Bei einem TLB-Miss müssen die notwendigen Deskriptoren nachgeladen werden. Dabei zeigt das Context Table Pointer Register (CTPR) immer auf die aktuell gültige Wurzeltabelle.
Abb. 4-17 Schematischer Aufbau der SPARC-MMU
Fehlerregister der SPARC-MMU
Der Prozessor hat im privilegierten Modus lesenden und schreibenden Zugriff auf alle Steuerregister und alle TLB-Einträge. Neben dem CR und dem CTPR dienen das Fault Status- und das Fault AddressRegister zur genaueren Beschreibung von Fehlersituationen bei der Adreßabbildung. Das Fault Address-Register gibt immer die virtuelle Adresse an, bei der im Verlauf der Adreßabbildung ein Fehler entstanden ist. Im Statusregister wird der Fehlertyp vermerkt, d.h., ob z.B. eine Zugriffsverletzung aufgetreten ist, ein Nulldeskriptor eingetragen oder die benötigte Seite bzw. Seitentabelle ausgelagert war. Über das Control-Register kann der Prozessor Typ und Version der MMU erfragen, verschiedene Speicher- und Fehlerbehandlungsformen wählen und die gesamte virtuelle Adreßabbildung ein- oder ausschalten. Wie bei allen MMUs ist die virtuelle Adreßabbildung
4.4
Seitenbasierter virtueller Adreßraum
nach dem Einschalten des Rechners nicht aktiv. Die Systemsoftware muß während einer Initialisierungsphase virtuelle Adreßräume durch Anlegen der entsprechenden Seitentabellen erst einmal aufbauen. Danach kann durch einen privilegierten Befehl das entsprechende Bit im Control-Register gesetzt werden, um den virtuellen Adressierungsmechanismus zu aktivieren. Die TLB-Einträge müssen bei der SPARC-MMU explizit gelöscht werden. Dafür stellt die MMU einen sogenannten Flush-Befehl zur Verfügung. Über einen zusätzlichen Parameter kann die Reichweite dieses Befehls gesteuert werden. Neben dem Zurücksetzen aller TLBEinträge können alle Einträge eines bestimmten Kontextes oder selektiv einzelne Einträge auf einer bestimmten Abbildungsstufe gelöscht werden. Im letzten Fall muß dann zusätzlich die virtuelle Seitenadresse des zu löschenden Eintrags angegeben werden. Außerdem können TLB-Einträge über sogenannte Probe-Befehle prozessorgesteuert geladen werden.
Flush- und Probe-Befehle
Kombination Segment- und Seitenadressierung: 80386-Prozessor Segment- und seitenbasierte virtuelle Adressierungstechniken können auch kombiniert werden. Es gibt im wesentlichen zwei Kombinationsmöglichkeiten: •
•
Jedes Segment definiert einen eigenen seitenbasierten Adreßraum. In diesem Fall enthält jeder gültige Segmentdeskriptor einen Zeiger auf die zugehörige Seitentabelle erster Stufe. Alle Segmente werden in einem virtuellen Adreßraum verankert. Aus der im Segmentdeskriptor enthaltenen Basisadresse wird zusammen mit dem Offset innerhalb des Segments eine lineare virtuelle Adresse gebildet, die anschließend über ein ein- oder mehrstufiges seitenbasiertes Abbildungsverfahren in eine effektive Adresse umgewandelt wird.
Der 80386-Prozessor realisiert z.B. die zweite Variante. Alle Segmente der GDT und der LDT werden in einem 32 Bit großen virtuellen Adreßraum plaziert. Die in einem Deskriptor gespeicherte Basisadresse gilt innerhalb dieses 32-Bit-Adreßraums. Beim Zugriff auf eine Speicherzelle wird - wie bereits angesprochen - implizit über den im zugehörigen Segmentregister gespeicherten Selektor der benötigte Segmentdeskriptor aus einer der beiden Tabellen bestimmt. Die im Selektor gespeicherte Basisadresse und das Offset innerhalb des Segments werden anschließend addiert. Bei der rein segmentbasierten Adressierung endet hier der Abbildungsvorgang; das Additionsergebnis entspricht der effektiven Adresse im physischen Adreßraum.
80x86-Prozessoren bieten seitenbasierte virtuelle Adressierung innerhalb der Segmente
4
Adreßräume
Bei der Kombination der segment- und seitenbasierten Adressierung bildet das Additionsergebnis die virtuelle Ausgangsadresse für die seitenbasierte Abbildungsphase. Die MMU des 80386 unterstützt ein zweistufiges Verfahren. Der Seitenanteil jeder Stufe beträgt 10 Bit, d.h, jede Seitentabelle besitzt 1024 Einträge. Die kleinste unterstützte Seitengröße sind 4 KByte mit einem Offset von 12 Bit. Zusammenfassung
Vorteile
Nachteil
Auf der Grundlage seitenbasierter virtueller Adressierungstechniken können alle in Abschnitt 4.1 aufgestellten Forderungen erfüllt werden. Durch entsprechend belegte Seitentabellen kann ein zusammenhängender virtueller Adreßraum unabhängig von der Struktur und Zerstückelung des physischen Speichers angelegt werden. Im Gegensatz zu rein segmentbasierten Verfahren gibt es keine externe Fragmentierung. Statt dessen müssen Speicheranforderungen von Anwendungsadreßräumen immer durch ganze Seiten/Kacheln befriedigt werden. Für die Systemsoftware, die den physischen Adreßraum verwaltet, entsteht dadurch ein vernachlässigbares internes Fragmentierungsproblem. Der Schutz innerhalb eines Adreßraums auf der Grundlage von Zugriffsrechten für jede einzelne Seite ist ebenfalls in den meisten Situationen ausreichend. Lediglich beim Schutz einzelner Bereiche eines Adreßraums vor Überläufen sind segmentbasierte Verfahren leistungsfähiger, da diese bei einer geeigneten Segmentplazierung vollständigen Schutz gewährleisten. In einem rein seitenbasierten Verfahren können die einzelnen Bereiche ohne zusätzliche Informationen von der Speicherverwaltung nicht innerhalb eines virtuellen Adreßraums unterschieden werden. Das Erkennen von Überläufen muß in diesem Fall durch übergeordnete Mechanismen realisiert werden.
4.5
Dynamische Seitenersetzung
Die große Stärke seitenbasierter Verfahren kommt mit der dynamischen Aus- und Einlagerung von Seiten auf dem Hintergrundspeicher voll zum Tragen. Die Kernidee besteht darin, den Hauptspeicher als zusätzlichen (Level 3)-Cache für einen auf Hintergrundspeichern abgelegten virtuellen Speicher zu nutzen. Diese Auffassung entspricht einer Erweiterung der in Kapitel 2 eingeführten Speicherhierarchie. Konzeptionell werden Anwendungen auf dem großen aber verhältnismäßig langsamen virtuellen Speicher ausgeführt. Teile der Anwendungsadreßräume werden während der Ausführung lediglich im physischen Adreßraum zwischengespeichert. Technisch läßt sich die Cache-Verwaltung sehr einfach auf das in allen Deskriptoren vorhandene P-Bit zurückführen. Bei einem Treffer
4.5
Dynamische Seitenersetzung
befinden sich alle für die Adreßabbildung notwendigen Deskriptoren einschließlich des Seiteninhalts im Hauptspeicher. Der Prozessor kann in diesem Fall direkt auf den Inhalt einzelner Speicherzellen zugreifen. Stößt man im Verlauf der Adreßabbildung auf ein nicht gesetztes PBit, so handelt es sich um einen Cache Miss (siehe Abbildung 4-18). Der fehlende Teil muß in diesem Fall vom Hintergrundspeicher nachgeladen werden; unter Umständen müssen zu diesem Zweck die Inhalte anderer Kacheln wieder in den virtuellen Speicher zurückkopiert werden. Dem Prozessor wird die Notwendigkeit des Nachladens durch einen Seitenfehler-Interrupt signalisiert. Alle Informationen über den Fehlerursprung, ob z.B. eine Seitentabelle oder der Seiteninhalt nachgeladen werden muß, kann der Prozessor entweder aus entsprechenden Statusregistern der MMU erfahren (vgl. SPARC-MMU) oder - bei einer sehr engen Verzahnung von Prozessor und MMU - einem sogenannten Exception Frame auf dem privilegierten Laufzeitkeller entnehmen.
Seitenfehler-Interrupt
Abb. 4-18 Zugriff auf eine ausgelagerte Seite des Adreßraums (Seitenfehler)
Die Leistungsfähigkeit eines virtuellen Speichers hängt primär von der Wahrscheinlichkeit eines Seitenfehlers ab. Aufgrund der hohen Diskrepanz in der Zugriffzeit ist ein hohes Potential an Leistungssteigerung möglich, wenn durch eine entsprechend hohe Trefferrate die mittlere Zugriffszeit des virtuellen Speichers in der Nähe der Hauptspeicherzugriffszeit gehalten werden kann. Umgekehrt ist natürlich zu erwarten, daß gerade wegen der hohen Zugriffszeit auf den Hintergrundspeicher eine sehr hohe Trefferrate zur Kompensation notwendig ist. Die mittlere effektive Zugriffszeit tys auf den virtuellen Speicher kann wie folgt berechnet werden (dabei nehmen wir zur Vereinfachung nur ausgelagerte Seiten an, d.h, die Seitentabellen befinden sich alle im Hauptspeicher):
Dabei ist p die Seitenfehlerwahrscheinlichkeit, tHS die mittlere Zugriffszeit auf den Hauptspeicher im Fall eines Treffers und tSF die mitt-
Wahrscheinlichkeit eines Seitenfehlers
4
Adreßräume
lere Verzögerung für die Behandlung eines Seitenfehlers. Zur Bestimmung der mittleren Verzögerung im Fall eines Seitenfehlers ist es hilfreich, die einzelnen Phasen zu bestimmen (es wird davon ausgegangen, daß keine freie Seite mehr vorhanden ist): 1. Ausführung der Unterbrechnungsroutine (tUbr) 2. Auswahl und Auslagerung einer zu verdrängenden Seite (tAus) 3. Einlagern der referenzierten Seite vom Hintergrundspeicher (tEin 4. Aktualisierung der betroffenen Seitendeskriptoren (tAkt) 5. Wiederholung der unterbrochenen Instruktion (tWdh) Insgesamt ergibt sich damit für tSF:
Die dominanten Anteile sind das Aus- und Einlagern einer Seite, d.h
Da die Zeit für die Kommunikation mit dem Hintergrundspeicher, die im Bereich mehrerer Millisekunden liegt, auch gegenüber der Zugriffszeit auf den Hauptspeicher dominiert, ergibt sich für die effektive Zugriffszeit auf den virtuellen Speicher in Abhängigkeit von der Seitenfehlerwahrscheinlichkeit:
Bei einer mittleren Zugriffszeit auf den Hauptspeicher tHS=70 ns und einer mittleren Ein- und Auslagerungszeit von t A u s / E i n = 10 ms für eine Seite zwischen Haupt- und Hintergrundspeicher ergibt sich für die Seitenfehlerwahrscheinlichkeit:
Seitenfehler höchstens alle 2-3 Millionen Speicherzugriffe
Dabei definiert der Schwellwert k, welche prozentuale Verzögerung bei der effektiven Zugriffszeit auf den virtuellen Speicher im Vergleich zum mittleren Hauptspeicherzugriff noch tolerierbar sein soll. Ein Wert k=l.l bedeutet z.B. eine maximal um 10% über tHS liegende effektive Zugriffszeit auf den virtuellen Speicher. Die Einhaltung dieser Maximalverzögerung erfordert eine Seitenfehlerwahrscheinlichkeit p < 3.5e-7, d.h, höchstens alle 2,8 Millionen Speicherzugriffe darf ein Seitenfehler eintreten. Der extrem geringe Grenzwert für eine akzeptable Seitenfehlerrate ist eine unmittelbare Folge der im Millisekundenbereich liegenden Transferzeiten beim Zugriff auf den Hintergrundspeicher. Aus diesem Grund kann die Gesamtleistung des virtuellen Speichers durch eine Minimierung dieser Zeiten substantiell
4.5
Dynamische Seitenersetzung
verbessert werden. Ein wichtiger Aspekt ist dabei, Seitenauslagerungen soweit wie möglich einzusparen. Existiert von einer Seite, die als Auslagerungskandidat ausgewählt wurde, bereits eine Kopie auf dem Hintergrundspeicher und wurde die zugehörige Kachel im Hauptspeicher nicht verändert (Dirty-Bit des Seitendeskriptors nicht gesetzt), so kann auf die Auslagerung ganz verzichtet werden. Außerdem ist es sinnvoll, bei unterschiedlich schnellen Hintergrundspeichern die schnellste Platte zur Realisierung des virtuellen Speichers einzusetzen. Bereits kleine Verbesserungen bei der Latenz- und Übertragungszeit der verwendeten Platte können meßbare Verbesserungen der Gesamtleistung zur Folge haben.
Zeit für die Behandlung von Seitenfehlern verkürzen
Working-Set-Modell
In der Praxis kann die Seitenfehlerrate trotz des geringen Grenzwertes meist in einem akzeptablen Bereich gehalten werden. Ein entscheidender Grund dafür ist die bereits angesprochene Referenzlokalität vieler Programme. Besonders ausgeprägt ist sie bei Code, der aus prozedural strukturierten Anwendungsprogrammen generiert wurde. Für die prozeduralen Programmiersprachen Pascal und C wurde z.B. empirisch ermittelt, daß Zuweisungen' und Bedingungen mehr als 80% der in Programmen verwendeten Hochsprachenkonstrukte ausmachen ([Huck 1983], [Patterson und Sequin 1982]). Entsprechende Programme weisen ausgeprägte Zugriffs- und sequentielle Ausführungsmuster auf, aus denen sich eine hohe Referenzlokalität ableiten läßt. Dieses Verhalten wird durch die mehrfache Ausführung von Instruktionen innerhalb von Schleifen weiter verstärkt. Die Auswirkungen der Referenzlokalität werden auf der Ebene von ganzen Seiten noch deutlicher. Man betrachtet zu diesem Zweck sogenannte Referenzstrings, die das Zugriffsverhalten eines Programmes auf den Adreßraum wiedergeben. Dazu wird für jeden Speicherzugriff die virtuelle Seite vermerkt, in der er stattfand. Ein Beispiel für einen solchen Referenzstring ist: RS = 0 0 1 1 2 2 2 1 2 3 4 3 2 2 3 3 3 3 3 3 3 4 3 3 4 5 6 5 4 3 3 3 .... Von links nach rechts gelesen gibt der Referenzstring PS an der Position i (RS[i]) an, innerhalb welcher virtuellen Seite der i-te Speicherzugriff während der Programmausführung stattfand. Auf der Grundlage eines solchen Referenzstrings kann man nun für jeden Zeitpunkt t (dabei kann man die einzelnen Speicherzugriffe vereinfachend als einen diskreten Zeittakt auffassen) die letzten A Speicherzugriffe zurückverfolgen:
Referenzstring beschreibt Speicherzugriffsverhalten
4
Adreßräume
Diese Seitenmenge wird von Denning als Working-Set bezeichnet [Denning 1970]. Sie enthält bei einer geeigneten Wahl von A (ALS) praktisch über die gesamte Ausführungszeit des Programms eine im Vergleich zur Gesamtgröße des virtuellen Adreßraums geringe Seitenanzahl:
Lokalitätsmenge
Dieses durch ALS bestimmte Working-Set wird auch als Lokalitätsmenge bezeichnet. Anschaulich bedeutet dies, daß ein Programm über längere Zeiträume hinweg auf einer verhältnismäßig geringen Zahl von virtuellen Seiten arbeitet. Es ergibt sich eine minimale Seitenfehlerrate, wenn die Speicherverwaltung in der Lage ist, im physischen Adreßraum genügend Platz zur Speicherung der Lokalitätsmenge zu schaffen. Die Größe der Lokalitätsmenge ist dabei nicht konstant, d.h, aufgrund des Programmverhaltens werden zu bestimmten Zeitpunkten Seiten hinzugefügt oder entfernt. Für die Bestimmung eines Working-Sets ist die Wahl von A kritisch. Ist der Wert zu klein, d.h kleiner als die oben angesprochene Schranke A L S , wird die Lokalitätsmenge nicht voll erfaßt. Bei einem zu großen Wert besteht WS aus der Vereinigung mehrerer aufeinander abfolgender Lokalitätsmengen. Seitenverdrängungsverfahren
Welche Seiten sollen bei einem Seitenfehler verdrängt werden?
Seitenverdrängung und Lokalitätsmenge
Bei einem aufgetretenen Seitenfehler muß die neu referenzierte Seite vor der weiteren Befehlsausführung vom Hintergrundspeicher in eine Kachel des physischen Adreßraums kopiert werden. Wenn keine freie Kachel unmittelbar zur Verfügung steht, muß durch ein Seitenverdrängungsverfahren der benötigte Platz im physischen Adreßraum vorher geschaffen werden. In diesem Fall wird eine bereits belegte Kachel ausgewählt und die enthaltene Seite eines virtuellen Adreßraums auf den Hintergrundspeicher ausgelagert. Im Idealfall baut ein Verdrängungsverfahren auf der Referenzlokalität vieler Programme und der sich ergebenden Lokalitätsmenge auf. Solange sich die Lokalitätsmenge jedes Anwendungsprogramms im physischen Adreßraum des Rechners befindet, ist die Seitenfehlerrate sehr gering. Sie kann mit zusätzlich im Hautspeicher befindlichen Seiten nur geringfügig reduziert werden, da auf Seiten außerhalb der Lokalitätsmenge nur mit einer sehr geringen Wahrscheinlichkeit zugegriffen wird. Dagegen erhöht sich die Seitenfehlerrate überproportional, wenn die Lokalitätsmenge nicht vollständig im Hauptspeicher liegt. Auf der Grundlage der Working Set-Theorie ergibt sich damit für ein Seitenverdrängungsverfahren die Aufgabe, bevorzugt solche Seiten auszulagern, die nicht in der Lokalitätsmenge enthalten sind. Veränderungen an der Lokalitätsmenge sind in der Praxis jedoch schwer zu er-
4.5
Dynamische Seitenersetzung
mitteln, da jeder schreibende und lesende Zugriff auf eine virtuelle Adresse die Lokalitätsmenge beeinflussen kann und dementsprechend berücksichtigt werden muß. Aus diesem Grund stehen in der Praxis Verdrängungsverfahren im Vordergrund, die ohne Kenntnis der jeweiligen Lokalitätsmenge Auslagerungskandidaten im Hinblick auf eine minimale Seitenfehlerrate auswählen. Die Leistungsfähigkeit von Verdrängungsverfahren kann auf der Grundlage der bekannten Referenzstrings bestimmt werden. Bewertungsgrundlage ist die Gesamtanzahl an Seitenfehlern, die eine Verdrängungsstrategie bei der Abbildung des Referenzstrings auf eine konstante Kachelanzahl verursacht. Für alle Verfahren bildet das optimale Verdrängungsverfahren nach Belady [Belady 1966] eine Vergleichsbasis. Bei diesem Verdrängungsverfahren wird bei einem Seitenfehler die Seite verdrängt, die in der Zukunft am längsten nicht referenziert wird. Aus einsichtigen Gründen kann mit diesem Verfahren die Anzahl an Seitenfehlern minimiert werden. Es ist jedoch auch offensichtlich, daß dieses Verfahren in der Praxis nicht realisierbar ist; das notwendige Wissen über das zukünftige Programmverhalten liegt normalerweise nicht vor.
Optimale Verdrängung nach Belady
Abb. 4-19 Optimale Verdrängung
Das Verhalten dieses Verfahrens ist in Abbildung 4-19 graphisch dargestellt. Im oberen Teil der Abbildung befindet sich der Referenzstring in leicht abgewandelter Form. Dabei wurden Teilfolgen gleicher Seitenreferenzen jeweils zu einem Wert zusammengefaßt, da nur das erste Auftauchen einer neuen referenzierten Seite einen Seitenfehler auslösen kann. Unmittelbar nachfolgende Referenzen auf dieselbe Seite führen zu keinen weiteren Seitenfehlern, da sich die Seite im Hauptspeicher befindet. Unter dem Referenzstring ist jeweils von links nach rechts protokolliert, welche virtuellen Seiten sich bei einer konstanten Anzahl von 3 zur Verfügung stehenden Kacheln jeweils im Hauptspeicher befinden. Angegeben ist die Kachelbelegung nur bei Änderungen aufgrund von Seitenfehlern. Geht man davon aus, daß sich zum Zeitpunkt des Programmstarts keine einzige virtuelle Seite im Hauptspeicher befindet, geben die dargestellten Kachelbelegungen die Anzahl aufgetretener Seitenfehler wieder. Im Fall der optimalen Verdrängung nach Belady (siehe Abbildung 4-19) werden zuerst die virtuellen Seiten 7, 0 und 1 eingelagert. Beim
4
FIFO-Verdrängung
LRU-Verdrängung
Adreßräume
vierten Seitenfehler als Folge eines Zugriffs auf die virtuelle Seite 2 muß erstmals eine Seite verdrängt werden. Anschaulich wählt das Verfahren diejenige der eingelagerten Seiten aus, die im Referenzstring am längsten nicht mehr referenziert wird (in diesem Fall Seite 7). Analog wird mit jedem weiteren Seitenfehler verfahren. Ein einfaches Verdrängungsverfahren basiert auf dem FIFO-Prinzip (First-In-First-Out), d.h., es wird die Seite verdrängt, die sich am längsten im Hauptspeicher befindet. Dieses Verfahren ist nicht besonders leistungsfähig, da es die Lokalitätsmenge eines Programms nicht berücksichtigt. So werden über längere Zeiten hinweg häufig referenzierte Seiten, sogenannte Hot Spots, von diesem Verfahren verdrängt. Umgekehrt bleiben selten benutzte Seiten im Mittel genauso lange im Hauptspeicher wie häufig referenzierte Seiten. Zusätzlich weist das FIFO-Verfahren ein anomales Verhalten auf (Beladys Anomalie [Belady et al. 1969]), das bei wachsender Anzahl an verfügbaren Kacheln eine steigende Seitenfehlerrate zur Folge haben kann. Das Least-Recently-Used-Verfakren (LRU) kommt der optimalen Verdrängung nach Belady am nächsten. Dabei wird die Seite aus dem Hauptspeicher verdrängt, die in der Vergangenheit am längsten nicht benutzt wurde (siehe Abbildung 4-20). Das Verfahren garantiert bei Referenzlokalität eine nahe dem Optimum liegende Seitenfehlerrate, da die Wahrscheinlichkeit für eine Änderung der gegenwärtigen Lokalitätsmenge sehr klein ist.
Abb. 4-20 LRU-Verfahren
LRU-Verfahren praktisch nicht implementierbar
Das LRU-Verfahren ist leider aufwendig zu implementieren. Man muß sich im Prinzip für jede Seite den Zeitpunkt des letzten Speicherzugriffs in Form eines Zeitstempels merken. Im Verdrängungsfall wird die Seite mit dem ältesten Zeitstempel ausgelagert. Durch eine doppelt verkettete Liste, die alle Seiten nach aufsteigendem Zeitstempel sortiert, kann die Wahl des Verdrängungskandidaten in konstanter Zeit durchgeführt werden. Problematisch ist dabei, daß bei jedem schreibenden und lesenden Speicherzugriff - insbesondere auch bei der Mehrheit der Zugriffe ohne Seitenfehler - der Zeitstempel der referenzierten Seite und damit auch die Seitenverkettung aktualisiert werden müssen. Aufgrund dieses Aufwands hat das LRU-Verfahren keine große praktische Relevanz.
4.5
Dynamische Seitenersetzung
In den heute verbreiteten Betriebssystemen kommen statt dessen Näherungsverfahren zum Einsatz. Im wesentlichen handelt es sich um Verfahren, die auf eine Hardwareunterstützung z.B. in Form des RBits in einem Seitendeskriptor angewiesen sind. Im einfachsten Fall wird jeder Seite ein Zähler zugeordnet. In periodischen Abständen inkrementiert die Speicherverwaltung den Zähler jeder im letzten Intervall nicht referenzierten Seite (R-Bit nicht gesetzt). Für jede referenzierte Seite werden dagegen Zähler und R-Bit gelöscht. Verdrängt wird immer die Seite mit dem höchsten Zählerstand, da diese in der Vergangenheit am längsten nicht referenziert wurde. Ein anderes Verfahren nutzt das Referenced-Bit zur näherungsweisen Bestimmung des zurückliegenden Referenzstrings (siehe Abbildung 4-21) [Tanenbaum 1992]. Das Verfahren benötigt neben der Seitentabelle eine Zusatztabelle. In dieser Tabelle wird der zurückliegende Referenzstring in einem Ausschnitt ermittelt, dabei beschreibt jeder Eintrag den jeweiligen Referenzstring für eine bestimmte virtuelle Seite. Das Verfahren aktualisert den Referenzstring periodisch (z.B. alle 100 ms). Für jede Seite wird dazu der entsprechende Eintrag in der Zusatztabelle um eine Bitposition nach rechts verschoben und der aktuelle Wert des R-Bits im zugehörigen Seitendeskriptor links angehängt. Das R-Bit selbst wird daraufhin zurückgesetzt. Damit speichert jeder Eintrag, in welchen zurückliegenden k Intervallen eine Seite referenziert wurde. Faßt man nun im Verdrängungsfall jeden Eintrag als vorzeichenlose ganze Zahl auf, so wurde der Eintrag mit dem kleinsten Wert am längsten in der Vergangenheit nicht referenziert. Die zugehörige Seite wird in diesem Fall verdrängt.
LRU-Approximation
Variante
Abb. 4-21 Näherungsverfahren für LRU
Das ebenfalls verbreitete 2nd-Chance-Verfahren orientiert sich grundsätzlich am FIFO-Verfahren, verbessert dies aber durch Berücksichtigung von Seitenreferenzen durch eine Inspektion des R-Bits. Muß eine Seite verdrängt werden, so wird die Liste der im Hauptspeicher anwe-
2nd-Chance-Verfahren
4 Adreßräume
Clock-Algorithmus
senden Seiten in Richtung abnehmender Einlagerungsdauer so lange durchsucht, bis eine Seite mit R-Bit=0 gefunden wurde. Diese Seite wird dann verdrängt. Bei zwischenzeitlich referenzierten Seiten (RBit=l) wird lediglich das R-Bit zurückgesetzt, d.h., sie erhalten eine zweite Chance. Werden alle Seiten innerhalb eines FIFO-Durchlaufs erneut referenziert, so wird automatisch die Seite verdrängt, die zuletzt ein zweites Mal in die Schlange eingereiht wurde. Das Verfahren degeneriert in diesem Fall zum ursprünglichen FIFO-Verfahren. Die im UNIX-Bereich verbreiteten Clock-Algorithmen sind Implementierungsvarianten des 2nd-Chance-Verfahrens. Grund für diese Verfeinerungen ist die verhältnismäßig aufwendige Aktualisierung der FIFO-Schlange. Bei den Clock-Algorithmen wird die Schlange durch eine zirkuläre Liste ersetzt. Ein »Uhrzeiger« zeigt immer auf die gemäß FIFO-Ordnung älteste Seite. Im Verdrängungsfall wird der Uhrzeiger so lange um ein Element in der zirkulären Liste weitergeschaltet, bis eine Seite mit zurückgesetztem R-Bit gefunden ist. Seiten mit gesetztem R-Bit bekommen eine nächste Chance, d.h., ihr R-Bit wird wieder zurückgesetzt und der Uhrzeiger auf das nächste Element umgeschaltet. In der Praxis hat sich jedoch gezeigt, daß die Suche nach dem Verdrängungskandidaten bei einer großen Anzahl von Hauptspeicherkacheln zu langwierig ist.
Abb. 4-22 Clock-Algorithmus mit zwei Zeigern
Variante des Clock-Algorithmus
In einer weiteren Variante des Clock-Algorithmus werden aus diesem Grund zwei Zeiger verwendet, die einen konstanten Abstand innerhalb der zirkulären Liste besitzen (siehe Abbildung 4-22). Die Vergabe der zweiten Chance und die Auswahl des Verdrängungskandidaten wird auf die beiden Zeiger verteilt. Der »vordere« Zeiger setzt das R-Bit im Seitendeskriptor zurück. Der hintere Zeiger prüft das R-Bit. Bei zurückgesetztem Bit kann die Seite verdrängt werden. Bei gesetztem Bit werden beide Zeiger gleichzeitig um ein Element weitergeschaltet. Der Abstand zwischen den beiden Zeigern bestimmt, wie viele Schritte maximal durchgeführt werden müssen, um eine verdrängbare Seite zu finden. Bei geringem Abstand bleiben nur die am häufigsten referenzierten Seiten im Hauptspeicher; in dieser Form
4.5
Dynamische Seitenersetzung
wird der Algorithmus z.B. in BSD Unix [McKusik et al. 1996] eingesetzt. Bei maximalen Zeigerabstand entspricht diese Variante dem ursprünglichen Clock-Algorithmus. Seitennachschubverfahren Seitennach schubverfahren kopieren ein oder mehrere auf dem Hintergrundspeicher befindliche virtuelle Seiten in den Hauptspeicher des Rechners. Man kann grundsätzlich zwei verschiedene Nachschubverfahren unterscheiden: Demand-Paging und Pre-Paging. Beim Demand-Paging wird eine Seite nur aufgrund eines vorangehenden Seitenfehlers nachgeladen. Bei reinem Demand-Paging beginnt die Anwendungsausführung mit einem angelegten aber leeren Adreßraum. Ein Anwendungsprogramm baut sich in diesem Fall seine Lokalitätsmenge mit Beginn der Ausführung schrittweise auf. Charakteristisch für diese Vorgehensweise ist eine verhältnismäßig hohe Seitenfehlerrate während der Anlaufphase, die nach vollendeter Einlagerung des Working-Set auf ein Minimum zurückfällt. Beim PrePaging werden dagegen virtuelle Seiten bereits in den Hauptspeicher kopiert, ohne daß ein entsprechender Seitenfehler vorangegangen sein muß. Die Programmausführung beginnt in diesem Fall mit einem bereits teilweise gefüllten Adreßraum. Im Extremfall kann sogar der gesamte von der Anwendung genutzte Adreßraum eingelagert werden; in der Regel empfiehlt sich diese Vorgehensweise jedoch nicht, da Einlagerungen über die Lokalitätsmenge hinaus den Programmstart unnötig verzögern. In der Praxis wird häufig eine Kombination aus beiden Verfahren angewendet. Dabei wird mittels Pre-Paging der Anfang des Programmcodes und der Bereich der statischen Daten vom Hintergrundspeicher kopiert. Zusätzlich werden ein oder mehrere Seiten jeweils für Heap und Laufzeitkeller im Hauptspeicher reserviert. Dadurch wird eine erhöhte Seitenfehlerrate zu Beginn der Programmausführung vermieden. Weitere Einlagerungen finden dann gemäß DemandPaging als Reaktion auf Seitenfehler statt. Da Hintergrundspeicher beim sequentiellen Lesen mehrerer aufeinanderfolgender Blöcke besonders effizient arbeiten, werden vom Betriebssystem durch PrePaging häufig neben der gewünschten Seite auch weitere virtuelle Seiten vorab geladen. In welchem Umfang dies geschieht, hängt letztendlich von der Wahrscheinlichkeit ab, ob diese in naher Zukunft referenziert werden oder nicht. Da zusätzlich viele technische Details wie z.B. günstige Übertragungsmengen zwischen Platte und Hauptspeicher ebenfalls eine Rolle spielen, wird ein geeigneter Pre-Paging-Anteil in existierenden Systemen empirisch festgelegt.
Demand-Paging
Pre-Paging
Kombinierte Verfahren
4
Adreßräume
Kachelzuteilung
Unterschiedliche Anforderungen
Lokale Kachelzuteilung
Eine wichtige Aufgabe der Speicherverwaltung ist die Zuordnung von freien Kacheln im Hauptspeicher zu Anwendungsadreßräumen. Wird zu einem Zeitpunkt nur ein Adreßraum unterstützt, gestaltet sich diese Aufgabe recht einfach: Nach Abzug der von der Systemsoftware benötigten Speicherbereiche stehen alle verbleibenden Kacheln für den Anwendungsadreßraum zur Verfügung. Bei mehreren gleichzeitig unterstützten Adreßräumen müssen die vorhandenen Ressourcen dagegen geeignet auf alle verteilt werden. Die Speicherverwaltung muß also entscheiden, wie viele Kacheln sie für jeden einzelnen Anwendungsadreßraum reserviert. Dabei muß sie berücksichtigen, daß Anwendungen sehr unterschiedliche Speicheranforderungen stellen. Der Bedarf reicht von wenigen KByte für einfache Programme bis hin zu mehreren 100 MByte für Anwendungen aus den Bereichen Bildverarbeitung oder KI. Außerdem können jederzeit dynamisch neue Anwendungen und damit auch neue Adreßräume erzeugt werden. Damit diese neuen Adreßräume möglichst schnell aufgebaut werden können, sollte die Speicherverwaltung über einen ausreichenden Vorrat an freien Kacheln verfügen. Grundsätzlich kann zwischen einer lokalen und globalen Kachelzuteilung unterschieden werden. Eine lokale Kachelzuteilung basiert auf dem Working-Set-Modell. In diesem Fall werden für einen Adreßraum mindestens so viele Kacheln reserviert, daß die Lokalitätsmenge der zugehörigen Anwendung vollständig im Hauptspeicher gehalten werden kann. Die resultierende Seitenfehlerrate jeder Anwendung ist dann minimal. Tritt während der Programmausführung ein Seitenfehler auf, wird ebenfalls eine lokale Verdrängungsstrategie verfolgt und bevorzugt eine Seite desselben virtuellen Adreßraums verdrängt.
Abb. 4-23 Seitenfehlerrate in Abhängigkeit von der Kachelanzahl
Globale Kachelzuteilung
In der Praxis scheitert die lokale Kachelzuteilung an der hohen Frequenz, mit der die pro Adreßraum erzeugte Seitenfehlerrate bestimmt werden muß. Statt dessen wird bei einer globalen Kachelzuteilung die Seitenfehlerrate des Gesamtsystems gemessen (siehe Abbildung 4-23). Das Überschreiten einer oberen Schranke für die Seitenfehlerrate deu-
4.5
Dynamische Seitenersetzung
tet darauf hin, daß die Summe der Lokalitätsmengen aller Anwendungen nicht mehr in den Hauptspeicher paßt. Als Folge davon stehlen sich Anwendungen ständig wechselseitig Kacheln. Dieser Effekt ist so drastisch, daß er weitreichende Auswirkungen auf die Leistung des Gesamtsystems haben kann. Bei diesem als Seitenflattern oder Thrashing bezeichneten Phänomen werden ständig Seiten im virtuellen Adreßraum der Anwendung zwischen Hintergrund- und Hauptspeicher bewegt. Dabei kann es zum Stillstand des Gesamtsystems kommen, da der Hintergrundspeicher durch das permanente Ein- und Auslagern von Seiten zum Flaschenhals wird und damit letztendlich jede Anwendung blockiert. Das System reagiert in diesem Fall mit dem Auslagern ganzer Adreßräume, um den Speicherengpaß zu beheben. Bei einer Zuteilung, die auf Seitenfehlerraten basiert, können die bezüglich der Kachelanzahl unterbesetzten Adreßräume nicht direkt ermittelt werden, da die Lokalitätsmengen der einzelnen Anwendungen unbekannt sind. Sie wird daher in Verbindung mit einer globalen Verdrängungsstrategie angewendet, bei der alle Kacheln als Verdrängungskandidaten zur Verfügung stehen. Wird bei diesem Verfahren außerdem eine untere Schranke für die Seitenfehlerrate unterschritten, kann zusätzlicher freier physischer Adreßraum geschaffen werden, um neue Aufträge zu beginnen oder aufgrund von Thrashing unterbrochene Aufträge fortzusetzen. Es ist offensichtlich, daß auch im Fall einer lokalen Zuteilungsstrategie auf ein globales Verdrängungsverfahren zurückgegriffen werden muß, wenn im Fall eines Seitenfehlers mit dem Auslagern einer Seite die Lokalitätsmenge unterschritten wird.
Seitenflattern oder Thrashing ist ein systemweiter Effekt
Globale Verdrängungsstrategie
Dämon-Paging Eine gängige Technik, immer über einen ausreichenden Vorrat an freien Kacheln für die schnelle Reaktion auf weitere Speicher- und Adreßraumanforderungen zu verfügen, besteht in der Trennung von Seitenverdrängung und Seiteneinlagerung. Bei diesem als Dämon-Paging [McKusik et al. 1996] bezeichneten Verfahren versucht die Speicherverwaltung immer, eine vorab festgelegte Anzahl an Kacheln freizuhalten. Aus diesem Vorrat können Seitenfehler ohne eine vorherige Seitenverdrängung bedient und neue Adreßräume unmittelbar angelegt werden. Gleichzeitig wird ein spezieller Prozeß periodisch aktiviert. Dieser Dämon-Pager prüft, ob die vorgegebene Anzahl an freien Kacheln unterschritten wurde. In diesem Fall werden so lange Seiten auf der Grundlage der verwendeten Seitenverdrängungsstrategie auf den Hintergrundspeicher ausgelagert, bis die Anzahl an freien Kacheln den Schwellwert wieder überschreitet. Dämon-Paging wird z.B. in BSD UNIX eingesetzt. In diesem Betriebssystem wird der Dämon-Pager alle 250 ms aktiviert. Der Prozeß
Trennung von Seitenverdrängung und Seitenersetzung
4
Adreßräume
führt eine globale Verdrängung auf der Grundlage der 2-Zeiger-Variante des Clock-Algorithmus durch, bis der Schwellwert l o t s f ree an freien Kacheln überschritten wird. Dieser Schwellwert kann bei der Konfiguration des Betriebssystems auf einen bestimmten Prozentsatz des physischen Speicherausbaus festgesetzt werden; in der Praxis liegt der Wert bei ca. 2 5 % .
4.6
Swapping bei Thrashing-Gefahr
Auch beim Einsatz virtueller Adreßraumtechniken wird in bestimmten kritischen Situationen Swapping eingesetzt, d.h., ganze Adreßräume werden zeitweilig auf Hintergrundspeicher verdrängt. Aufgrund des hohen Aufwands wird Swapping im wesentlichen zur Vermeidung von akuten Speicherengpässen und der damit einhergehenden Gefahr des Seitenflatterns benutzt. Swapping kann aber auch eintreten, wenn eine bestimmte Anwendung plötzlich große Mengen an zusätzlichem Speicherbedarf benötigt. Bei der Auslagerung eines Adreßraums werden die im Hauptspeicher befindlichen virtuellen Seiten und in der Regel auch die zugehörigen Seitentabellen ausgelagert. Bei Systemen mit einem Dirty-Bit in den Seitendeskriptoren brauchen nur die veränderten Seiten zurückgeschrieben werden. Eine faire Strategie, welcher Adreßraum bei akutem Speicherplatzmangel ausgelagert wird, kann primär nur aufgrund der Informationen in der Prozeß Verwaltung getroffen werden. Wie bereits in Abschnitt 4.2 erläutert wurde, sollte die Auslagerung bevorzugt bei einer im I/O-Burst befindlichen Anwendung geschehen (siehe hierzu auch Kapitel 5). Aus Sicht der Speicherverwaltung sollte diese Entscheidung primär von der Adreßraumgröße bestimmt werden. Am sinnvollsten erscheint die Auslagerung der bzgl. der reservierten Kachelanzahl größten Adreßräume, um dadurch maximal viel Freispeicher zu schaffen.
4.7 Starke Verbreitung seitenbasierter Verfahren
Swapping ganzer Adreßräume
Implementierungsaspekte
Von allen virtuellen Adreßraumtechniken sind in der Praxis seitenbasierte Verfahren am weitesten verbreitet. Selbst wenn der Prozessor segmentbasierte Mechanismen zur Verfügung stellt, werden diese von vielen Betriebssystemen gegenwärtig nur selten genutzt. So setzen neuere Microsoft Windows-Betriebssysteme bei Intel-Prozessoren zwar die Kombination aus segment- und seitenbasierten Verfahren ein, aber in Anwendungsadreßräumen werden die Segmente in der Regel mit einer Basisadresse 0 und einer Segmentlänge von 4 GByte initialisiert (siehe auch [Oney 1996]). Der resultierende flache 32-BitAdreßraum wird in diesem Fall wie bei rein seitenbasierten Verfahren verwaltet.
4.7
Implementierungsaspekte
Abb. 4-24 Anwendungsadreßräume bei gängigen Betriebssystemen
Die 32 Bit großen virtuellen Adreßräume, die moderne Betriebssysteme Anwendungen zur Verfügung stellen, sind in vielen Bereichen ähnlich aufgebaut. Beispiele für Anwendungsadreßräume von einigen Betriebssystemen sind in Abbildung 4-24 schematisch dargestellt. Der weiße Bereich gibt jeweils den Teil des Adreßraums wieder, der von der Anwendung direkt genutzt werden kann. Dieser Bereich schwankt je nach Betriebssystem zwischen ca. 2 GByte und knapp 4 GByte. Bei allen Systemen wird ein unterschiedlich großer Adreßbereich am Anfang (Adressen 0 aufwärts) für jeglichen Zugriff gesperrt: Bei Windows 95 sind es 4096 Byte, d.h. genau 1 Seite, bei Windows NT 64 KByte und bei BSD Unix je nach eingesetztem Rechnertyp 4 bis 8 KByte. Durch diese Sperrung wird jeder Zugriff auf diesen Bereich und damit insbesondere das Dereferenzieren eines Nullzeigers, ein häufiger Programmierfehler, vom System abgefangen und die Programmausführung mit einer Fehlermeldung abgebrochen. In Windows 95 wird zusätzlich der Adreßbereich von 4 KByte bis 4 MByte von der Nutzung durch 32-Bit-Anwendungen ausgeklammert. In diesem Adreßbereich werden MS-DOS- und 16-Bit-Windows-Anwendungen ausgeführt, die von einem gemeinsamen physischen Adreßraum ausgehen. Im oberen Bereich eines virtuellen Adreßraums wird meist der Betriebssystemcode eingeblendet. Dadurch kann das Betriebssystem jederzeit ohne Adreßraumwechsel auf seinen Code zugreifen. Insbesondere wird damit der Wechsel in den privilegierten Modus beim Aufruf einer Betriebssystemfunktion erleichtert. Bei BSD Unix und bei Windows NT/2000 ist dieser Codebereich vor jeglichem Zugriff durch die Anwendung geschützt. In der Regel wird sogar explizit lesender Zugriff unterbunden, damit Anwendungen keine impliziten Annahmen
Dereferenzieren eines Nullzeigers
Systemcode im Anwendungsadreßraum
4
Keine vollständige Adreßraumisolation in Windows 9x
Adreßräume
über die interne Struktur des Betriebssystems machen können. Der überdimensionierte 2 GByte große Betriebssystembereich bei Windows NT ist primär auf eine Hardwarebeschränkung bei der Realisierung auf MlPS-Prozessoren zurückzuführen (siehe [Richter 1996]). Im obersten 1-GByte-Bereich von Windows 9x befinden sich alle wesentlichen Teile des Betriebssystems einschließlich aller Gerätetreiber. In dem darunterliegenden 1-GByte-Bereich werden außerdem alle allgemein genutzten Funktionsbibliotheken eingeblendet. Beide Bereiche können von jeder Anwendung aus Gründen der Abwärtskompatibilität, z.B. weil ältere Funktionsbibliotheken in diesem Adreßbereich auch Daten ablegen, sowohl gelesen als auch geschrieben werden. Dadurch ist der Schutz des Betriebssystems nicht vollständig gewährleistet; fehlerhafte Windows 9x-Applikationen können damit immer noch das gesamte System beeinflussen und zum Absturz bringen. Adreßraumerzeugung und -initialisierung Erzeugung und Initialisierung neuer Adreßräume sind wichtige Aufgaben der Speicherverwaltung. Bei der Erzeugung wird Speicherplatz für die Seitentabellen und für eine Teilmenge der virtuellen Seiten reserviert. Danach werden die Seitentabellen initialisiert und die verschiedenen Speicherbereiche des virtuellen Adreßraums initialisiert. Diese Initialisierung hängt in der Regel von der Angabe einer ausführbaren Datei ab, die das auszuführende Anwendungsprogramm in maschinenlesbarer Form enthält (siehe Abbildung 4-25).
Abb. 4-25 Adreßrauminitialisierung
4.7
Implementierungsaspekte
Die Datei selbst ist in mehrere Bereiche aufgeteilt, deren Größe und Position in einem Header am Anfang der Datei festgelegt sind. Das Maschinenprogramm liegt in der Regel in einer Form vor, die das direkte Einblenden einzelner Seiten innerhalb der Datei in den virtuellen Adreßraum der Anwendung ermöglicht. Dadurch wird das Dateisystem als erweiterter Teil des virtuellen Speichers aufgefaßt. Bei einem Seitenfehler wird der entsprechende Teil der Datei geladen. Eine Verdrängung erübrigt sich, da Programmcode in der Regel nicht verändert wird. Der Bereich der statisch allokierten Daten in der Datei wird in der Regel in den entsprechenden Bereich des virtuellen Adreßraums kopiert und damit initialisiert. Darüber hinaus kann eine ausführbare Datei Relokations- und Symbolinformationen enthalten. Relokationsinformationen erlauben das Verschieben von Daten und Instruktionen im virtuellen Adreßraum. Sie geben an, wo Daten und Instruktionen absolut adressiert werden und wie diese Adressen im Fall einer Verschiebung verändert werden müssen. Die Symboltabelle gibt Auskunft über die definierten und referenzierten Symbole in einem Programm. In einem ausführbaren Programm wird sie u.a. von Debuggern genutzt. Bei den meisten Betriebssystemen wird jeder Bereich des virtuellen Adreßraums nur in beschränktem Umfang angelegt. In der Regel wird z.B. nur ein Teil des Programmcodes unmittelbar in den Hauptspeicher geladen. Statische Daten werden aufgrund unterschiedlicher Initialwerte der Variablen zu Beginn meist vollständig angelegt. Für Heap und Laufzeitkeller werden in der Regel jeweils wenige Kacheln initial reserviert. Viele Betriebssysteme wie z.B. Windows 9x und Windows NT/ 2000 fassen die Erzeugung und Initialisierung eines neuen Adreßraums zu einer Funktion zusammen. Dabei wird im allgemeinen durch den Aufruf von Create ( Dateiname, ... )
ein neuer Adreßraum angelegt und mit den entsprechenden Informationen aus der angegebenen Programmdatei initialisiert. Im UNIX-Bereich werden Erzeugung und Initialisierung getrennt durchgeführt. Ein neuer Adreßraum wird durch den Aufruf der Funktion fork() erzeugt. Der erzeugte Adreßraum ist eine identische Kopie des ursprünglichen Adreßraums. Dabei wendet das System ein als Copy-on-Write bekanntes Verfahren [Fitzgerald und Rashid 1986] an, das eine extrem schnelle Erzeugung von Adreßraumkopien gestattet. Zu diesem Zweck werden effektiv nur die Seitentabellen des erzeugenden Adreßraums kopiert. Jede Seite des Adreßraums wird außerdem im zugehörigen Seitendeskriptor vor schreibendem Zugriff geschützt, der besondere Schutzzustand wird in einem der freien Bits des
Aufbau einer ausführbaren Programmdatei
Relokationsinformation und Symboltabelle
Adreßraumerzeugung durch den Aufruf von Create()
Adreßraumerzeugung durch den Aufruf von fork() Copy-on-Write
4
Adreßräume
Deskriptors vermerkt. Insgesamt entsteht ein zweiter virtueller Adreßraum, in dem jeder Seitendeskriptor auf dieselbe Kachel im Hauptspeicher oder im Auslagerungsfall auf dieselbe Position im Hintergrundspeicher zeigt. Solange keiner der beiden Adreßräume schreibend zugreift, verweisen beide Deskriptoren auf denselben Seiteninhalt. Erst mit der Ausführung von Schreiboperationen weichen die beiden Adreßräume voneinander ab. Da die Seiten zu Beginn schreibgeschützt sind, wird von der MMU ein Interrupt ausgelöst. Die Speicherverwaltung stellt in diesem Fall eine Kopie des zugehörigen Seiteninhalts her. Außerdem wird im betreffenden Seitendeskriptor die Seitenposition aktualisiert und anschließend in beiden Deskriptoren die ursprünglichen Zugriffsrechte wiederhergestellt. Durch den Aufruf einer zweiten Funktion exec () kann ein beliebiger Adreßraum mit dem neuen Inhalt eines ausführbaren Programms überladen werden (siehe dazu auch Kapitel 5). Überschneidungen zwischen Heap und Stack
Guards in WindowsBetriebssystemen
Viele Betriebssysteme nutzen mittlerweile seitenbasierte Schutztechniken zur Vermeidung von Überschneidungen zwischen den dynamischen Speicherbereichen eines Adreßraums. Dabei wird zwischen zwei veränderlichen Bereichen ein Teil des Adreßraums für jeden Zugriff gesperrt. Bei Windows NT wird zu diesem Zweck ein 4-KByte-Bereich gesperrt, bei Windows 9x sind diese sogenannten Guards jeweils 64 KByte groß. Im Fall von Windows 9x wird dabei jeder Laufzeitkeller am Anfang und Ende durch jeweils einen eigenen Guard geschützt. Verändert ein Adreßbereich seine Größe und über- oder unterschreitet dabei die vordefinierten Grenzen, so löst der Zugriff auf eine Adresse innerhalb eines Guards einen Interrupt durch die MMU aus. Nach einer Analyse der Fehlersituation wird das Betriebssystem in diesem Fall die Anwendung mit einer entsprechenden Fehlermeldung beenden. Leider sind mit diesem Verfahren nicht alle Überschneidungen erkennbar. Ist z.B. der bei einem Funktionsaufruf neu angelegte Stackbereich zur Speicherung von Parametern und funktionslokalen Variablen größer als der Guard, kann der nächste Adreßbereich hinter dem Guard u.U. ohne Fehlermeldung verändert werden. Durch einen großen Guard-Bereich wird lediglich die Wahrscheinlichkeit für einen solchen Fehlerfall reduziert.
4.7
Implementierungsaspekte
Überlappende Adreßräume Überlappende Adreßräume, wie sie z.B. zur Realisierung des in Kapitel 3 eingeführten Laufzeitmodells D notwendig sind, können mit virtuellen Adressierungstechniken leicht erzeugt werden. Dabei verweisen Seitendeskriptoren in verschiedenen Adreßräumen auf denselben Seiteninhalt (siehe Abbildung 4-26). Abb. 4-26 Gemeinsame Adreßbereiche
Die Schutzbits in den jeweiligen Seitendeskriptoren können zur Modellierung unterschiedlicher Zugriffsrechte eingesetzt werden, so kann z.B. in einem Adreßraum nur lesender Zugriff gestattet sein. In der Regel versucht man, den gemeinsamen Speicherbereich in allen Adreßräumen an derselben virtuellen Adresse zu plazieren. Dadurch können auch absolute Referenzen innerhalb des gemeinsamen Speicherbereichs von allen beteiligten Anwendungen korrekt interpretiert werden. Gemeinsam genutzte Funktionsbibliotheken Funktionsbibliotheken, die in mehreren Anwendungen benutzt werden, können als Spezialfall gemeinsam genutzter Adreßbereiche aufgefaßt werden. Bibliotheken dieser Form werden im Windows-Umfeld als dynamisch ladbare Bibliothek (DLL = Dynamic Link Library) und im UNIX-Bereich häufig als gemeinsame Bibliothek (Shared Library) bezeichnet. Die Bibliothek belegt in diesem Fall nur einmal physische Ressourcen des Systems. Der Programmbereich wird in jeden Anwendungsadreßraum, in dem eine bestimmte Bibliothek verwendet werden soll, lediglich an geeigneter Stelle eingeblendet. Dies funktioniert in einfacher Weise natürlich nur bei positionsunabhängigem Code. In diesem Fall kann dieselbe Bibliothek in verschiedenen Adreßräumen sogar an unterschiedlichen virtuellen Positionen plaziert werden. Enthält die Bibliothek absolute Referenzen, muß sie gezwungenermaßen in allen Adreßräumen an derselben virtuellen Adresse eingeblendet werden.
DLLs und Shared Libraries
4
Struktur dynamischer Bibliotheken
Adreßräume
Sinnvollerweise sollte der Code einer mehrfach eingeblendeten Bibliothek in jedem Adreßraum vor schreibendem Zugriff geschützt werden. Alternativ kann durch das Copy-on-Write-Verfahren zumindest sichergestellt werden, daß andere Adreßbereiche von schreibenden Änderungen unbeeinflußt bleiben. Aus Kompatibilitäts- und Platzgründen wird dieser Schutz z.B. bei Windows 9x jedoch nicht gewährleistet. Damit kann indirekt über dynamische Bibliotheken eine fehlerhafte Windows-Anwendung den Adreßraum anderer Anwendungen beeinflussen und dadurch schwer zu lokaliserende Laufzeitfehler auslösen. Dynamische Bibliotheken müssen besonders übersetzt und gebunden werden. Insbesondere müssen die Bibliotheken so ausgelegt werden, daß notwendige Datenstrukturen in jedem Adreßraum getrennt angelegt werden können. Umgekehrt können Referenzen auf Funktionen einer dynamischen Bibliothek nicht vor dem Startzeitpunkt aufgelöst werden. Das hat zur Folge, daß der Programmcode in einer ausführbaren Datei offene Verweise auf Bibliotheksfunktionen haben kann. Bei der Adreßrauminitialisierung werden in diesem Fall die notwendigen Bibliotheken in den Anwendungsadreßraum eingeblendet und die offenen Referenzen mit Hilfe der enthaltenen Symboltabelle nachträglich aufgelöst. Die meisten Betriebssysteme nutzen dieselbe Technik auch zum Einsparen von Speicherressourcen bei Anwendungsprogrammen. Werden mehrere Adreßräume mit derselben ausführbaren Programmdatei initialisert, reicht auch in diesem Fall die einmalige Reservierung von Speicherressourcen aus. Verankerung virtueller Adreßbereiche im Speicher
I/O-Locking
Gelegentlich muß die Auslagerung eines virtuellen Adreßbereichs unterbunden werden. Entsprechende Seiten oder Segmente werden durch das Setzen eines freien Bits im Seitendeskriptor markiert. Die zugeordneten Kacheln werden dann von den eingesetzten Verdrängungsverfahren ausgeklammert. Zum Beispiel werden virtuelle Speicherbereiche vom Betriebssystem nach dem Anstoßen von E/A-Operationen gesperrt. Diese auch als I/O-Locking bezeichnete Technik wird implizit vom Betriebssystem eingesetzt und ist für die Anwendung selbst transparent. Da die meisten Ein- und Ausgabeoperationen asynchron angestoßen und anschließend selbständig vom E/A-Controller mittels DMA durchgeführt werden, müssen die betroffenen Speicherbereiche bis zur Beendigung der E/A-Operation im Hauptspeicher verankert werden. Ein unbemerkter Austausch des Kachelinhalts als Folge einer Seitenverdrängung hätte ohne I/O-Locking fatale Folgen.
4.7
Implementierungsaspekte
Echtzeitaspekte Die Verankerung virtueller Adreßbereiche ist auch für die Einhaltung von Echtzeitanforderungen von Bedeutung. Echtzeitanwendungen, deren garantierte Antwortzeit in einem kritischen Bereich liegt, können ihre Zeitvorgaben nicht einhalten, wenn im Verlauf der Programmausführung unkontrolliert Teile des Adreßraums aus dem Hintergrundspeicher kopiert werden müssen. Bei dieser expliziten Form der Verankerung teilt die Anwendung dem System mit, welche virtuellen Adreßbereiche im Hauptspeicher von einer Seitenersetzung verschont werden sollen. Da die Verankerung virtueller Seiten die vorhandenen Speicherressourcen merklich reduzieren kann, setzen viele Betriebssysteme eine entsprechende Privilegstufe des Programms voraus. Ist diese nicht gegeben, ignoriert die Speicherverwaltung die Sperrung des Speichers. In POSIX.4 existieren mehrere Funktionen, die einer Anwendung das Sperren des gesamten Adreßraums oder bestimmter Adreßbereiche ermöglichen. Durch die Funktion mlockall(flags)
können alle momentan im Speicher befindlichen Seiten gesperrt werden (flags=MCL_CURRENT). Wird der Parameter flags dieser Funktion mit dem Wert MCL_CURRENT | MCL_FUTURE initialisiert, bleiben darüber hinaus alle zukünftig eingelagerten Seiten im Hauptspeicher verankert. Mit Hilfe der Funktionen mlock(Anfangsadresse,Länge)
und munlock{Anfangsadresse,Länge)
können Teile des virtuellen Adreßraums im Hauptspeicher verankert und wieder freigegeben werden. Die Parameter Anfangsadresse und Länge legen dabei den zu verankernden Adreßbereich fest. Da von der Speicherverwaltung nur ganze Seiten gesperrt werden können, wird ggf. auf Seitengrenzen gerundet. Explizites Sperren durch die Anwendung erfordert detaillierte Kenntnisse über den Aufbau des virtuellen Adreßraums und die Plazierung der einzelnen Speicherbereiche. So müssen in der Regel bestimmte Codebereiche zusammen mit Teilen des Anwendungszustands gesperrt werden. Außerdem müssen u.U. aufgerufene Bibliotheksfunktionen ebenfalls vor einer Verdrängung auf Hintergrundspeicher abgesichert werden, wenn diese aus einer kritischen Funktion heraus potentiell aufgerufen werden können.
Unerwartete Seitenfehler sind im Echtzeitfall oft kritisch
5
Threads
Threads (oder Kontrollflüsse) beschreiben die Aktivitäten in einem System. Sie können als virtuelle Prozessoren aufgefaßt werden, die jeweils für die Ausführung eines zugeordneten sequentiellen Programms in einem Adreßraum verantwortlich sind. Die Realisierung von Threads basiert auf dem Multiplexen der physischen Prozessoren. Innerhalb eines Adreßraums können je nach Laufzeitmodell ein oder mehrere Threads existieren (siehe Kapitel 3). Mit der Einrichtung eines neuen Adreßraums wird von der Systemsoftware immer ein erster Thread erzeugt, der seine Programmausführung an einer vordefinierten Startadresse aufnimmt. Bei einfachen Anwendungen reicht in vielen Fällen ein Thread pro Adreßraum aus. Dem Programmcode liegt in diesem Fall ein streng sequentielles Verarbeitungsmodell zugrunde. Bei komplexeren Anwendungen können auch mehrere Threads gleichzeitig in einem Adreßraum eingesetzt werden. Aus Sicht der Anwendungsentwicklung gibt es dafür im wesentlichen drei Gründe:
Sequentielles Verarbeitungsmodell
• Während einer E/A-Blockade eines Threads können weitere
Aufgaben der Anwendung von anderen Threads bearbeitet werden. • Bei Multiprozessorsystemen können Anwendungsaufgaben parallel auf mehreren Prozessoren bearbeitet werden. • Die Reaktionszeit der Anwendung auf Benutzereingaben kann verbessert werden. In vielen Fällen kann im Vergleich zum sequentiellen Verarbeitungsmodell die Gesamtausführungszeit der Anwendung und die Reaktionszeit z.B. auf Benutzereingaben reduziert werden. Dieses nebenläufige Verarbeitungsmodell gewinnt aufgrund dieser Vorteile und der wachsenden Verbreitung von geeigneten Thread-Realisierungen in Betriebssystemen und Laufzeitpaketen stark an Bedeutung. Darüber hinaus ist Nebenläufigkeit die natürliche Form der Programmierung, wenn innerhalb der Anwendung mehrere weitgehend entkoppelte Handlungsabläufe existieren. Klassische Beispiele für die nebenläufige Programmierung sind z.B. Anwendungen mit graphischer Bedienungsoberfläche. Insbeson-
Nebenläufiges Verarbeitungsmodell
5
Threads
dere wenn die Anwendung parallel zur graphischen Darstellung kontinuierliche Berechnungen durchführen muß, kann ein zweiter Thread die Interaktion mit dem Benutzer über die Bedienungsoberfläche in sinnvoller Weise koordinieren. Jeder Kontrollfluß für sich besitzt dadurch weiterhin einen klaren und eng an das sequentielle Verarbeitungsmodell angelehnten Aufbau. Während der erste Kontrollfluß praktisch ungehindert die notwendigen Berechnungen ausführt:
Schnelle Reaktion auf
wartet der zweite Kontrollfluß in einer Schleife auf eingehende Ereignisse des Graphiksystems - beispielsweise Benutzereingaben oder Änderungen bei der Fensteranordnung und -Sichtbarkeit - und führt die mit dem jeweiligen Ereignis verbundenen Anwendungsfunktionen
eingehende Ereignisse
aus: Thread 2: while (1) { e = ReceiveEvent(); ProcessEvent(e);
} Aufgrund der Nebenläufigkeit muß »lediglich« bei der Anwendungsprogrammierung darauf geachtet werden, daß gleichzeitige Zugriffe in den Funktionen Compute und ProcessEvent auf dieselben Datenstrukturen synchronisiert werden (siehe Kapitel 6). Unterbleibt diese Synchronisation, können selbst auf einem Monoprozessorsystem Dateninkonsistenzen und Programmfehler als Folge einer zeitlich verschränkten Ausführung beider Funktionen auftreten. Im Unterschied zur nebenläufigen Programmierung muß eine Anwendung mit nur einem Thread die kontinuierliche Berechnung periodisch unterbrechen, um eventuell eingetroffene Graphikereignisse entgegenzunehmen und zu beantworten. Dabei darf der Thread bei fehlenden Ereignissen des Graphiksystems in keinem Fall blockiert werden, da sonst auch die kontinuierliche Berechnung unterbrochen wäre: while (1) { /* Ein Berechnungsschritt */ ComputeStep(); if (QueryEvent()) { /* Ereignis angekommen? */ ProcessEvent(e);
} }
5 Threads
Mit wachsender Komplexität der Berechnung und der Oberflächenfunktionalität entsteht durch diese explizite Verzahnung schnell eine zerklüftete und schwer lesbare Programmstruktur. Nachteilig ist auch, daß z.B. die Reaktionszeit auf Benutzereingaben durch die Programmstruktur vorgegeben wird. Bei vergleichsweise kurzen Berechnungsschritten ist es beispielsweise nicht sinnvoll, in jedem Durchlauf ankommende Ereignisse entgegenzunehmen. Graphikereignisse treffen nicht in so hoher Frequenz ein, d.h., ein Großteil der QueryEventAufrufe ist bei einer Abfrage pro Durchlauf unnötig und verschwendet Prozessorleistung. Umgekehrt darf der zeitliche Abstand zwischen zwei Abfragen nicht zu groß werden, wenn die Reaktion auf Eingaben nicht zu lange verzögert werden soll. Ein Vorteil ergibt sich jedoch unmittelbar aus der sequentiellen Verarbeitung: Da sich aufgrund der Programmstruktur die Funktionen ComputeStep und ProcessEvent in der Ausführung wechselseitig ausschließen, erübrigt sich zumindest die im nebenläufigen Fall notwendige Synchronisation. Aus den oben angesprochenen Gründen ist es sinnvoll, bei der Anwendungsprogrammierung von einer unbeschränkten Anzahl an verfügbaren Threads auszugehen, d.h. von der tatsächlichen Anzahl an Prozessoren in einem Rechner zu abstrahieren. Bei der Anwendungsprogrammierung soll dabei im Idealfall jede potentiell vorhandene Nebenläufigkeit in Form eigenständiger Kontrollflüsse beibehalten und nicht durch nur einen Thread zwangsserialisiert werden. Die Vorteile rechtfertigen in vielen Fällen auch einen erhöhten Aufwand aufgrund notwendiger Synchronisationsmaßnahmen. Bereits auf einem Monoprozessorsystem kann die Anwendung durch das Ausnutzen von Blockadezeiten einzelner Threads profitieren. Auf einem Multiprozessorsystem ist häufig auch eine tatsächliche Leistungssteigerung erzielbar. Eine Tendenz zur Entwicklung nebenläufiger Programme ist in vielen Bereichen erkennbar, z.B. sind viele WWW-Server und -Browser mittlerweile multi-threaded, d.h., sie bestehen aus mehreren parallelen Kontrollflüssen. Auch im Windows-Bereich werden mit der Verfügbarkeit von Threads in der 32-Bit-Programmierschnittstelle (Win32-API) Anwendungen zunehmend durch mehrere Kontrollflüsse realisiert. Allgemein kann ein Thread als sogenannter virtueller Prozessor aufgefaßt werden. Teams mit mehreren Threads innerhalb eines Adreßraums entsprechen einer Gruppe virtueller Prozessoren, die über gemeinsamen Speicher miteinander kommunizieren (= virtueller Multiprozessor). Das gleiche gilt für Threads in verschiedenen Adreßräumen, aber mit einem überlappenden Speicherbereich (Laufzeitmodell D). Für eine nebenläufig programmierte Anwendung ist es dabei unerheblich, wie viele Prozessoren real vorhanden sind, d.h., ob es sich um ein Mono- oder Multiprozessorsystem handelt. Ist die Anzahl
Zerklüftete und schwer lesbare Programmstruktur
Anwendungsprogrammierer soll von unbeschränkter Prozessoranzahl ausgehen
Virtueller Prozessor
Virtueller Multiprozessor
5 Threads
Kontextwechsel
Scheduler
der in einer Anwendung benötigten virtuellen Prozessoren kleiner oder gleich der Anzahl vorhandener Prozessoren, kann im Prinzip eine l:l-Zuordnung durchgeführt werden. In diesem Fall wird jeder Thread von genau einem physischen Prozessor bearbeitet. Werden dagegen in der Anwendung mehr Threads erzeugt als physische Prozessoren vorhanden sind, muß von der Systemsoftware durch ein Zeitmultiplexverfahren implizit eine zeitlich versetzte Ausführung der Threads auf ein oder mehreren Prozessoren erzwungen werden. Zu diesem Zweck wird zu bestimmten Zeiten ein sogenannter Kontextwechsel durchgeführt, d.h., der Zustand des aktuell ausgeführten Threads wird gesichert und der vorher gespeicherte Zustand eines anderen Kontrollflusses wird vom Prozessor erneut übernommen. Dadurch wird mit jedem Kontextwechsel die Zuordnung virtueller Prozessor zu physischem Prozessor dynamisch verändert. Aufgabe des sogenannten Schedulers ist dabei, einen geeigneten Kandidaten aus einer Menge von potentiell ausführbaren Threads zu ermitteln. Insbesondere bei mehreren ausgeführten Anwendungen mit ein oder mehreren Threads kann der Scheduler in der Regel aus einem großen Vorrat den nächsten Kontrollfluß auswählen. Diese Wahl wird sehr stark von dem zugrundegelegten Optimierungsziel der Prozeßverwaltung beeinflußt, d.h., ob das System bei der Prozessorzuteilung eine hohe Auslastung der vorhandenen Hardwareressourcen oder im Fall von Echtzeitanwendungen die Einhaltung von Zeitvorgaben anstrebt. Im nachfolgenden Abschnitt 5.1 werden die zentralen Anforderungen an ein Thread-Konzept diskutiert. Zustandsmodelle für Threads sind Gegenstand von Abschnitt 5.2. Sie beschreiben relevante Thread-Zustände und bilden die Grundlage für verschiedene Schedulingstrategien: Monoprozessorstrategien (5.3), Scheduling mit Echtzeitgarantien (5.4) und Verfahren für Multiprozessoren (5.5). Abschließend werden in Abschnitt 5.6 gängige Programmierschnittstellen im Bereich Verwaltung und Scheduling von Threads einschließlich der entsprechenden POSIX-Schnittstelle und in Abschnitt 5.7 Implementierungsaspekte diskutiert.
5.1
Anforderungen
Die Forderung nach einer minimalen Einschränkung der Nebenläufigkeit bei der Anwendungsprogrammierung setzt ein einfach zu benutzendes, aber leistungsfähiges Thread-Konzept voraus. Wie bereits erwähnt, wird mit jedem neuen Adreßraum ein initialer Thread erzeugt. Diese Minimalunterstützung wird von jedem existierenden Betriebssystem zur Verfügung gestellt. Weitere von der Anwendung benötigte Threads können bei den meisten modernen Betriebssystemen bei Be-
5.1
Anforderungen
darf explizit erzeugt und über eine Startadresse mit einem sequentiellen Programm verknüpft werden. Natürlich ergibt sich aus Sicht der Anwendung für jeden neu erzeugten Thread der Wunsch, diesen möglichst dauerhaft an einen physischen Prozessor zu binden. Wenn genügend Prozessoren vorhanden sind, kann diese Prozessorzuordnung über die gesamte Lebensdauer des Threads aufrechterhalten werden. Threads werden in diesem Fall nur durch E/A-Operationen oder durch den Zwang zur Synchronisation mit anderen Kontrollflüssen blockiert. Eine ausreichende Auslastung der vorhandenen Hardwareressourcen, insbesondere der Prozessoren, ist in diesem Fall nur bei Anwendungen mit einem hohen Grad an Nebenläufigkeit gewährleistet. In der Praxis nutzen viele Anwendungen das Rechenpotential mehrerer Prozessoren noch nicht aus, d.h., die feste Zuordnung von ein oder mehreren Prozessoren zu einer Anwendung führt zu einer vergleichsweise geringen Auslastung der Hardwarressourcen. Außerdem übersteigt normalerweise die Gesamtanzahl der im System vorhandenen Threads die Prozessoranzahl um ein Vielfaches. Das Betriebssystem muß also Kompromisse bei der Prozessorzuordnung eingehen. Dadurch konkurrieren primär die Threads aller ausgeführten Anwendungen um das Betriebsmittel Prozessor. Bei einem hohen Parallelitätsgrad kann dies bereits innerhalb einer einzelnen Anwendung geschehen, d.h., mehrere Threads innerhalb eines Adreßraums stehen in einer direkten Konkurrenzsituation. In diesem Fall entsteht im Vergleich zur sequentiellen 1-Thread-Lösung ein verhältnismäßig geringer zusätzlicher Overhead aufgrund der durchgeführten Kontextwechsel.
Verhältnis Thread-Anzahl zu Prozessoranzahl
CPU- und I/O-Bursts Mit wachsender Thread- und fallender Prozessoranzahl verschärft sich der Wettbewerb um Prozessoren. Häufig existiert eine große Anzahl an Threads, die alle von der einzigen CPU eines Monoprozessorsystems ausgeführt werden müssen. Im PC- und Workstationbereich ist das der Normalfall. Durch die Zwangsserialisierung wird die absolute Ausführungsdauer der später ausgeführten Threads erheblich verlängert. Geht man z.B. von n Threads mit gleich langer Bearbeitungsdauer k aus, die unterbrechungsfrei hintereinander ausgeführt werden, so wird der erste Thread um 0, der zweite Thread um k, der i.-te Thread um (i-l)*k und der zuletzt ausgeführte Thread um die Zeitdauer (n-l)*k verzögert. Im Mittel ergibt sich für alle Threads eine Verzögerung, die proportional mit der Thread-Anzahl wächst:
Zwangsserialisierung bei einer CPU
5 Threads
CPU- und I/O-Bursts
CPU-Bursts sind meist kurz
In der Praxis wirkt der für viele Anwendungen typische Wechsel zwischen CPU- und I/O-Bursts subjektiv einer proportional mit der Thread-Anzahl wachsenden Verzögerung entgegen. Die obige Formel ergibt sich nämlich nur bei einer permanenten Belegung des Prozessors durch den Thread. Sinnvollerweise ordnet die Prozeßverwaltung aber nur dann einem Thread einen physischen Prozessor zu, wenn sich dieser im CPU-Burst befindet. Sobald der Thread eine E/A-Operation anstößt, können bis zur Beendigung dieser Operation keine weiteren Instruktionen sinnvoll ausgeführt werden. Die Prozessorzuordnung wird in diesem Fall mindestens bis zur Beendigung der E/A-Operation gelöst. Technisch findet ein Kontextwechsel statt, der die Ausführung des im I/O-Burst befindlichen Threads unterbricht und statt dessen den Prozessor mit der Ausführung eines rechenwilligen, d.h. im CPUBurst befindlichen zweiten Threads beauftragt. Rechenwillig kann z.B. ein Prozeß werden, dessen E/A-Operation in der Zwischenzeit beendet worden ist. CPU-Bursts sind verhältnismäßig kurz. In Abbildung 5-1 ist eine gemessene Verteilung der Dauer von CPU-Bursts dargestellt. Eine Häufung bei 2 ms Burst-Dauer ist klar erkennbar. Insgesamt sind über 90% der Bursts kürzer als 8 Millisekunden.
Abb. 5-1 Gemessene Verteilung bei der Dauer von CPU-Bursts
I/O-Bursts sind im Gegensatz zu CPU-Bursts meist erheblich länger. So werden z.B. bei einem Plattenzugriff bereits 8-10 ms zur Positionierung des Lese- und Schreibkopfs benötigt, und beim Lesen von Benutzereingaben können viele 100 ms zwischen zwei Eingaben (Drücken einer Taste) verstreichen. Setzt man voraus, daß genügend viele E/AOperationen von der Hardware asynchron ausgeführt werden können, ergibt sich für die mittlere Verzögerung eines Threads:
5.1
Anforderungen
mit t Burst gleich der mittleren Dauer eines CPU-Burst. Aufgrund der großen Zeitdiskrepanz zwischen CPU- und I/O-Burst ist die Verzögerung durch E/A-Operationen dominant. Der proportionale Verzögerungsfaktor bleibt in diesem Fall vielen Anwendungen und damit auch vielen Benutzern bis zu einer bestimmten Thread-Anzahl verborgen. Für die Prozeßverwaltung ergibt sich aus dem typischen Wechsel zwischen CPU- und I/O-Bursts ein leistungsfähiges Mittel zur erfolgreichen Umsetzung eines Thread-Konzepts. Der Aufruf jeder E/AOperation wird damit zu einem der zentralen Ansatzpunkte für Schedulingentscheidungen. In Abhängigkeit der maximalen Anzahl an autonom arbeitenden E/A-Controllern ist damit bereits auf einem Monoprozessorsystem ein beachtliches Maß an Nebenläufigkeit realisierbar. Vermeidung einer CPU-Monopolisierung Mit der Ausführung eines Threads durch einen physischen Prozessor übernimmt die zugehörige Anwendung bis zu einem gewissen Grad die Kontrolle über den Prozessor. Die Systemsoftware kann die Kontrolle auf drei verschiedene Arten zurückgewinnen:
• • •
bei Aufruf einer blockierenden E/A-Operation, durch die freiwillige Abgabe des Prozessors (Yielding) oder nach Eintreffen eines asynchronen Hardware-Interrupts.
Tritt keiner der drei Fälle ein, kann eine Anwendung im Extremfall auf einem Prozessor das Ausführungsmonopol erlangen. Die ersten beiden Möglichkeiten können z.B. durch eine fehlerhafte Anwendung, die sich in einer Endlosschleife befindet, leicht umgangen werden. Betriebssysteme nutzen in aller Regel die dritte Möglichkeit, um eine Monopolisierung des Prozessors durch eine fehlerhafte oder »böswillige« Anwendung zu verhindern. Da der Zugriff auf die Interruptmaske eine privilegierte Prozessorinstruktion ist, können Anwendungsprogramme nur indirekt über den Aufruf einer Betriebssystemfunktion daran Veränderungen vornehmen. Das Betriebssystem kann in diesem Fall sicherstellen, daß eine Monopolisierung jederzeit ausgeschlossen wird. Lediglich besondere Anwendungsprogramme, die im privilegierten Modus ausgeführt werden, können damit »erfolgreich« die dauerhafte Kontrolle über einen Prozessor erlangen. Privilegien dieser Art sollten aus diesem Grund die seltene Ausnahme bleiben. Technisch erlangt die Systemsoftware die Kontrolle über den Prozessor meist mit Hilfe sogenannter Timer-Bausteine. Diese werden so programmiert, daß sie periodisch oder nach einer vorgegebenen Zeit einen Interrupt auslösen. Mit jedem Timer-Interrupt wird damit die Prozeßverwaltung in die Lage versetzt, potentiell die Zuordnung
Timer-Interrupts sind ein sicheres Mittel gegen CPU-Monopolisierung
5 Threads
Preemptives Scheduling
Nichtpreemptives Scheduling
Thread : Prozessor durch einen Kontextwechsel zu verändern. Nutzt eine Prozeßverwaltung einen Timer-Interrupt in dieser Form, um einen mitten in der Ausführung befindlichen Thread vorzeitig zu unterbrechen, spricht man von preemptivem Scheduling. Neben Time-Interrupts kann eine Preemption auch durch andere asynchrone Unterbrechungen ausgelöst werden, wenn z.B. ein Interrupt die Fertigstellung einer E/A-Operation signalisiert und der damit wieder rechenbereit gewordene Thread den eben ausgeführten Kontrollfluß aufgrund einer Schedulingentscheidung ablöst. Im Gegensatz dazu kann bei einem nichtpreemptiven Scheduling nur durch die ersten beiden Möglichkeiten, d.h. beim Aufruf einer blockierenden Betriebssystemfunktion oder durch die explizite Abgabe des Prozessors, ein Kontextwechsel und damit die Ausführung eines anderen Threads herbeigeführt werden. Schedulingziel
Dialogbetrieb
Stapel- oder Batchbetrieb
Bei jedem Kontextwechsel muß die Prozeßverwaltung typischerweise aus einer Menge ausführbarer Threads einen geeigneten Kandidaten auswählen. Dabei zählt man zur Menge der ausführbaren Kontrollflüsse alle Threads, die nicht aufgrund einer E/A- oder Synchronisationsoperation blockiert sind. Insbesondere gehören auch alle Threads dazu, denen der Prozessor preemptiv entzogen worden ist. Die Auswahl des geeigneten Kandidaten wird vom Scheduler, einer zentralen Komponente der Prozeßverwaltung, vorgenommen. Das jeweils eingesetzte Schedulingverfahren hängt dabei letztendlich von dem übergeordneten Schedulingziel ab, das von der Prozeßverwaltung verfolgt wird. Schedulingziele selbst werden durch die geplante Betriebsform und die vorhandene Hardwarestruktur bestimmt. Ein weit verbreiteter Einsatzbereich ist der Dialogbetrieb. Das Schedulingziel ist in diesem Fall die besondere Unterstützung von interaktiven Anwendungen. Threads, die nach dem Aufruf einer blokkierenden E/A-Operation durch eine Benutzereingabe wieder ausführbar sind, werden vom Schedulingverfahren bevorzugt ausgewählt. Für den Anwender ist dies in der Regel durch eine schnelle Reaktion auf Benutzereingaben erkennbar. Im Unterschied dazu steht beim sogenannten Stapel- oder Batchbetrieb eine hohe Auslastung der vorhandenen Hardwareressourcen oder ein hoher Durchsatz an fertiggestellten Aufträgen im Vordergrund. Bei dieser früher weit verbreiteten Form der Offline-Verarbeitung existiert in der Regel keine direkte Verbindung mit den Benutzern über Terminals, eine besondere Reaktion auf Benutzereingaben ist dadurch unnötig. Eine spezielle Form der Prozeßverwaltung ist notwendig, wenn Anwendungen mit Echtzeitanforderungen ausgeführt werden sollen.
5.1
Anforderungen
In diesem Echtzeitbetrieb ist die Einhaltung von Zeitgarantien das vorrangige Schedulingziel, dem sich eine Optimierung der Hardwareauslastung oder der Reaktionszeit auf Benutzereingaben unterordnen muß. Charakteristisch für diese Betriebsform ist, daß Echtzeitanwendungen der Prozeßverwaltung zusätzliche Informationen über die maximale Laufzeit und die einzuhaltenden Fristen mitteilen müssen. Auf der Grundlage dieser Zusatzinformationen von allen Threads kann durch entsprechende Schedulingverfahren eine Verarbeitungsreihenfolge ermittelt werden, in der die Fristen aller Threads eingehalten werden (genügend Hardwareressourcen vorausgesetzt). Beim Hintergrundbetrieb werden Anwendungen ausgeführt, deren Fertigstellungszeitpunkt völlig unkritisch ist. Primäres Schedulingziel ist in diesem Fall, daß die Bearbeitung dieser Anwendungen den eigentlichen Rechenbetrieb nur minimal oder gar nicht beeinflußt. Meist wird diese Betriebsform in Kombination mit einem Dialog- oder Echtzeitbetrieb benutzt. Bei der Thread-Auswahl werden dabei zuerst dialogorientierte oder echtzeitkritische Threads ausgewählt, bevor verbleibende freie Zeitintervalle auf die Hintergrundaufträge aufgeteilt werden. Neben den spezifischen Eigenschaften des Einsatzgebietes hat die Anzahl vorhandener Prozessoren entscheidenden Einfluß auf die Schedulingstrategie. Bei nur einem verfügbaren Prozessor findet die bereits angesprochene Sequentialisierung aller Threads statt; bei jedem Kontextwechsel wird der entsprechend dem verfolgten Schedulingziel am besten geeignete Kandidat ausgewählt. Stehen mehrere Prozessoren zur Verfügung, können auf jeden Fall mehrere voneinander unabhängige Kontrollflüsse auf unterschiedlichen Prozessoren ausgeführt werden. Außerdem ergeben sich mehr Freiheitsgrade bei der Umsetzung des Schedulingziels, die jedoch nicht notwendigerweise eine Leistungssteigerung für eine nebenläufig programmierte Anwendung zur Folge haben. Nur wenn das Schedulingverfahren sicherstellen kann, daß mehrere Threads derselben Anwendung gleichzeitig auf mehreren Prozessoren ausgeführt werden, kann aufgrund der schnellen Interaktion innerhalb eines Adreßraums eine Beschleunigung erzielt werden.
Echtzeitbetrieb
Hintergrundbetrieb
Auswirkungen der Prozessoranzahl
Abb. 5-2 Kemel-Level-Threads (KL-Threads)
5
Threads
Thread-Typen KL-Thread
Je nach Realisierungsform unterscheidet man zwischen Kernel-LevelThreads (KL-Threads) und User-Level-Threads (UL-Threads). KLThreads werden im Kern durch Multiplexen der physischen Prozessoren realisiert (siehe Abbildung 5-2). Thread-Wechsel, d.h. die Umschaltung eines physischen Prozessors von einem KL-Thread auf einen anderen, erfordern daher immer die Übergabe der Kontrolle über den physischen Prozessor an den Betriebssystemkern. Ein vollständiger Thread-Wechsel durchläuft im allgemeinen die in Abbildung 5-3 dargestellten Schritte. Da die Threads KL-Thread 1 und KL-Thread 2 im allgemeinen in verschiedenen Adreßräumen operieren, muß als Teil des Kontextwechsels auch der Adreßraum gewechselt werden. Obwohl die unmittelbaren Zusatzkosten bei einem solchen Adreßraumwechsel lediglich aus ein oder zwei Instruktionen zum Neuladen der Segmenttabellen- oder dem obersten Seitentabellenregisters der MMU bestehen, sind die indirekten Folgekosten aufgrund der TLBTnvalidierung und der geringen anfänglichen Trefferrate kalter Caches z.T. erheblich.
Abb. 5-3 Thread-Wechsel
KL-Threads sind schwergewichtig
Aufgrund der relativ hohen Kosten, die bei KL-Threads durch ThreadWechsel im Kern entstehen, spricht man häufig auch von schwergewichtigen Threads. Die Kosten für den Thread-Wechsel können jedoch reduziert werden, wenn mehrere KL-Threads pro Adreßraum zugelassen sind und bei einem Thread-Wechsel vom Betriebssystemkern zunächst versucht wird, einen ablaufbereiten Thread im selben Adreßraum zu finden (siehe Abbildung 5-4). Damit kann die Invalidierung von TLB und Cache bis zu einem gewissen Grad vermieden werden. Eine gewisse Abkühlung von TLB und Caches läßt sich jedoch nicht vermeiden, da auch der Wechsel zu einem anderen Thread innerhalb desselben Adreßraums meist mit einem Wechsel der Lokalitätsmenge verbunden ist.
.,
5.1
Anforderungen
Abb. 5-4 Mehrere KL-Threads innerhalb eines Adreßraums
Bei der Unterstützung mehrerer KL-Threads innerhalb eines Adreßraums wird jedoch weiter kritisiert, daß ein Thread-Wechsel trotz des eingesparten Adreßraumwechsels immer den Aufruf einer Betriebssystemfunktion zur Folge hat. Da diese Funktionen aus Schutzgründen fast immer durch einen Trap-Aufruf realisiert werden, sind sie im Vergleich zu einem Prozeduraufruf innerhalb eines Adreßraums deutlich teurer. Der Mehraufwand ist im wesentlichen auf folgende Aspekte zurückzuführen: •
Trap-Instruktionen gehören zu den aufwendigen Instruktionen im Befehlssatz eines Prozessors. • Durch einen eventuell notwendigen Wechsel in den Adreßraum des Betriebssystems entstehen indirekte Folgekosten. • Die Ausführung allgemeiner Verwaltungsfunktionen des Betriebssystems erzeugt zusätzlichen Overhead. • Beim Aufruf der Betriebssystemfunktion kann potentiell ein Wechsel zu einem KL-Thread in einem anderen Adreßraum stattfinden. Abb. 5-5 Leichtgewichtige Thread-Realisierung (User-Level-Threads)
Zur Reduzierung der Kontextwechselkosten wurde aus diesem Grund eine leichtgewichtige Variante entwickelt, bei der Threads vollständig im Adreßraum der Anwendung realisiert werden. Diese als UserLevel-Thread (UL-Thread) bezeichnete Thread-Variante ist der Prozeßverwaltung des Kerns völlig unbekannt, benötigt aber einen Träger in Form eines KL-Threads (siehe Abbildung 5-5). Die Ausführungsin-
UL-Thread
5
UL-Threads sind leichtgewichtig
Coroutinen
Threads
tervalle des Träger-Threads werden in diesem Fall von einer anwendungsspezifischen Thread-Verwaltung auf die vorhandenen ULThreads aufgeteilt. Diese Verwaltungsfunktionen werden in der Summe als Thread-Package bezeichnet und befinden sich in Form einer Laufzeitbibliothek innerhalb des Anwendungsadreßraums. Kontextwechsel zwischen verschiedenen UL-Threads einer Anwendung sind damit im Aufwand mit einfachen Prozeduraufrufen vergleichbar; daraus ergibt sich auch der häufig verwendete Name Light-WeightThread. Technisch aufwendig ist die Vermeidung einer Blockade des Träger-Threads beim Aufruf einer potentiell blockierenden Betriebssystemfunktion durch den UL-Thread. Da in diesem Fall die Kontrolle durch das Betriebssystem u.U. in einen anderen Adreßraum verlagert wird, findet kein Wechsel der Kontrolle auf einen anderen UL-Thread innerhalb derselben Anwendung statt. Damit gehen wesentliche Vorteile der leichtgewichtigen Thread-Realisierung verloren. Einen Spezialfall von UL-Threads stellen sogenannte Coroutinen [Conway 1963] dar, wie sie von Programmiersprachen wie z.B. Simula zur Verfügung gestellt werden. In diesem Verfahren finden ebenfalls Kontextwechsel zwischen mehreren logischen Kontrollflüssen, die an einzelne Prozeduren der Anwendung gebunden werden, statt. Der Wechsel in den Kontext einer anderen Coroutine wird meist explizit durch den Aufruf einer entsprechenden Funktion eingeleitet. Die Schedulingreihenfolge ist dabei fest vorgegeben. Die verschiedenen Thread-Varianten haben einen großen Einfluß auf die Programmierung nebenläufiger Anwendungen. Sobald in der schwergewichtigen Variante mehreren Threads eigene Adreßräume zugeordnet werden, kann eine Kommunikation zwischen diesen Threads nur über Nachrichten oder über explizit angelegte, gemeinsame Speicherbereiche in den ansonsten disj unkten Adreßräumen stattfinden. Aufgrund der hohen Kosten der damit verbundenen Prozeßwechsel empfiehlt sich die Aufteilung in verhältnismäßig wenige Threads, die sich nach Möglichkeit bei der Bewerbung um einen Prozessor in größeren Zeitabständen abwechseln.
5.2
Zustandsmodelle
Die Unterscheidung zwischen verschiedenen Thread-Zuständen bildet die Entscheidungsgrundlage für die Auswahl eines geeigneten Kandidaten, der nach einem Kontextwechsel in den Besitz eines Prozessors kommt.
5.2
Zustandsmodelle
Abb. 5-6 Einfaches ThreadZustandsmodell
Einfaches Zustandsmodell In einem einfachen Zustandsmodell wird zwischen drei Thread-Zuständen unterschieden (siehe Abbildung 5-6):
•
•
•
Rechnend: Threads in diesem Zustand sind im Besitz eines physischen Prozessors. Bei Monoprozessorsystemen kann sich jeweils nur ein Thread in diesem Zustand befinden; bei Multiprozessorsystemen mit k Prozessoren sind höchstens k Threads rechnend. Blockiert: Blockierte Threads warten auf die Beendigung einer E/A-Operation oder den Eintritt einer bestimmten Synchronisationsbedingung. Zu jedem Zeitpunkt können beliebig viele Threads im Zustand Blockiert sein. Bereit: Threads in diesem Zustand sind potentiell ausführbar, aber nicht im Besitz eines physischen Prozessors. Auch in diesem Zustand können sich beliebig viele Threads befinden.
Thread-Zustände
Die im Zustandsmodell erlaubten Übergänge sind in Abbildung 5-6 durch Pfeile dargestellt:
• • •
•
add: Ein neu erzeugter Thread wird - u.U. nach dem Anlegen und Initialisieren des zugehörigen Adreßraums - dynamisch in die Menge der bereiten Threads aufgenommen. assign: Als Folge eines Kontextwechsels wird einem bereiten Thread der Prozessor zugeordnet. Der ausgewählte Thread wird damit ausgeführt. block: Beim Aufruf einer blockierenden E/A- oder Synchronisationsoperation sowie bei einem Seitenfehler wird einem rechnenden Thread der Prozessor entzogen. Der blockierte Thread wartet in diesem Fall auf die Beendigung der Operation oder die Einlagerung der fehlenden Seite. ready: Ein blockierter Thread wechselt nach Beendigung der angestoßenen Operation in den Zustand »Bereit«. Er bewirbt sich damit erneut um einen physischen Prozessor.
Zustandsübergänge
5
Threads
• •
resign: Einem rechnenden Thread wird der Prozessor z. B. aufgrund eines Timer-Interrupts vorzeitig entzogen. Der Thread bewirbt sich anschließend erneut um eine Prozessorzuteilung. retire: Ein aktuell rechnender Thread terminiert und gibt damit freiwillig den Prozessor ab; belegte Hard- und Softwareressourcen werden freigegeben.
Erweitertes Zustandsmodell Auswirkungen des Swapping auf das Zustandsmodell
Das einfache Zustandsmodell muß erweitert werden, wenn durch Swapping ganze Adreßräume und mit ihnen die darin enthaltenen Threads aufgrund eines Speichermangels ausgelagert werden können. Bis zur erneuten Einlagerung kann keiner der betroffenen Threads ausgeführt werden. Im einfachsten Fall besteht die Erweiterung aus einem zusätzlichen Zustand, der alle zu einem Zeitpunkt ausgelagerten Threads beinhaltet (siehe Abbildung 5-7; die aus Abbildung 5-6 bekannten Übergänge wurden aus Gründen der Übersichtlichkeit nicht dargestellt). In diesem ausgelagerten Zustand kann im Prinzip ein Übergang swap out aus jedem der drei Grundzustände existieren. Insbesondere wenn mehrere Threads innerhalb eines Adreßraums vorhanden sind, können durch die Auslagerung Threads aller drei Zustände davon betroffen sein. Umgekehrt existieren swap «'«-Übergänge vom Auslagerungszustand in die Zustände »Bereit« oder »Blockiert«. In welchen Zustand ein Thread jeweils konkret wechselt, hängt davon ab, ob er sich nach der Einlagerung erneut um einen Prozessor bewerben kann oder weiterhin auf die Beendigung einer E/A- oder Synchronisationsoperation warten muß.
Abb. 5-7 Erweitertes ThreadZustandsmodell
Umsetzung von Thread-Zustand und Zustandsmodell Prozeßkontrollblock (PCB)
Der Zustand eines Threads wird in den meisten Betriebssystemen durch einen sogenannten Prozeßkontrollblock (PCB) beschrieben (siehe Abbildung 5-8). Der PCB enthält alle für die Prozeßverwaltung notwendigen Informationen. Dazu gehören u.a.:
5.2
Zustandsmodelle
• Eine eindeutige Identifikation (PID = Process Identification) • • •
Bestandteile des PCB
Speicherplatz zur Sicherung des Prozessorzustands bei einem Kontextwechsel Informationen über den sogenannten Wartegrund im Fall eines blockierten Threads Adreßrauminformation, z. B. Verweis auf die oberste Seitentabelle Zustandsinformationen und Statistiken für das Scheduling
•
Im Hinblick auf die verschiedenen Thread-Realisierungen wird in existierenden Systemen zunehmend die Adreßrauminformation vom eigentlichen Thread-Zustand isoliert und in separaten Datenstrukturen gespeichert. In diesem Fall ergibt sich ein reduzierter Thread-Kontrollblock, der lediglich einen Verweis auf die zugehörige Adreßraumbeschreibung enthält. Aus Gründen der Übersichtlichkeit wird in den nachfolgenden Abschnitten einheitlich von einem PCB gesprochen und implizit unterstellt, daß bei der Unterstützung mehrerer KLThreads in einem Adreßraum und bei UL-Threads der entsprechende Teil des PCBs nicht vorhanden ist.
ThreadKontrollblock
Abb. 5-8 PCB und Zustandslisten
Das Zustandsmodell selbst kann durch drei bzw. vier Zeiger realisiert werden, die jeweils auf den Anfang der Rechnend-, Bereit- und Blokkiert-Liste sowie auf die Liste der ausgelagerten Threads zeigen. Innerhalb einer Zustandsliste werden die PCBs der zugehörigen Threads direkt untereinander verkettet, da ein Thread sich zu jedem Zeitpunkt in genau einem Zustand befindet.
Listenbasierte Realisierung des Zustandsmodells
5 Threads
Dispatcher und Scheduler Dispatcher führt Zustandsübergänge aus
Scheduler wählt nächsten auszuführenden Prozeß
Die Durchführung der Zustandsübergänge selbst ist Aufgabe des Dispatchers. Er stellt für alle Übergänge entsprechende Funktionen zur Verfügung, die von anderen Teilen der Prozeßverwaltung aufgerufen werden. Dabei muß sichergestellt sein, daß mit jedem Wechsel eines rechnenden Threads in die Listen der bereiten oder blockierten Threads durch den Aufruf der Funktion Assign ein geeigneter Kandidat zur weiteren Ausführung ausgewählt wird. Die Auswahl eines Threads aus einer Thread-Menge während des Zustandsübergangs wird von einem Scheduler vorgenommen. Aus Leistungsgesichtspunkten werden in einem konkreten System in der Regel zwei verschiedene Scheduler eingesetzt: •
•
Der Short-Term-Scheduler tritt beim assign-Übergang in Aktion und ist ausschließlich für die Prozessorzuteilung zwischen bereiten Threads zuständig. Der Long-Term-Scheduler trifft komplexere Schedulingentscheidungen und wählt z.B. bei einem akuten Speicherengpaß einen geeigneten Adreßraum für die Auslagerung aus.
Hauptgrund für die Trennung sind die Unterschiede in der Aufrufhäufigkeit und in den Effizienzanforderungen. Da der Short-Term-Scheduler bei jedem Kontextwechsel aufgerufen wird, steht nur ein extrem schmales Zeitfenster für die Auswahl zur Verfügung. Es ist daher sinnvoll, alle längerfristigen oder unkritischen Schedulingentscheidungen in den Long-Term-Scheduler zu verlagern.
5.3 Monoprozessor-Scheduling
Schedulingkriterien
Die Wahl der konkreten Schedulingstrategie wird sehr stark durch die vom Betriebssystem unterstützte Betriebsform beeinflußt. Da es eine Reihe sehr unterschiedlicher Schedulingverfahren gibt, müssen zusätzliche Kriterien zur Bewertung von Schedulingstrategien hinsichtlich ihrer Qualität und ihrer Eignung für eine bestimmte Betriebsform herangezogen werden. Im wesentlichen sind dies: • • •
CPU-Auslastung: Maß für die Auslastung eines Prozessors durch die Ausführung von Anwendungsinstruktionen Durchsatz: Anzahl der pro Zeiteinheit fertiggestellten Aufträge Turnaround: Zeit zwischen zwei Thread-Aktivierungen (Zeitintervall zwischen zwei aufeinanderfolgenden assign-Übergängen eines Threads)
5.3
• •
•
Monoprozessor-Scheduling
Wartezeit: Verweildauer in der Bereit-Liste, d. h. die Zeitdauer, in der einem rechenwilligen Thread kein physischer Prozessor zugeordnet wird Antwortzeit: Für interaktive Anwendung wichtige Zeitspanne zwischen der Ankunft z.B. einer Benutzereingabe und einer potentiellen Reaktion durch einen assign-Übergang des zugehörigen Threads Realzeit: Einhaltung der von Anwendungen vorgegebenen Realzeitgarantien
Abgesehen vom Realzeitkritierium, das erst in Abschnitt 5.4 diskutiert wird, können alle aufgezählten Kriterien zur Bewertung der nachfolgend vorgestellten Schedulingverfahren für Monoprozessorsysteme eingesetzt werden. Es ist offensichtlich, daß kein einzelnes Verfahren CPU-Auslastung und Durchsatz maximieren und gleichzeitig Turnaround, Warte- sowie Antwortzeit minimieren kann. Aus diesem Grand muß ein Schedulingverfahren in Hinblick auf die Betriebsform Kompromisse schließen. First-Come, First-Served (FCFS) Dieses nichtpreemptive Schedulingverfahren teilt einen Prozessor in der Reihenfolge des Auftragseingangs zu. Für FCFS gibt es eine einfache schlangen-basierte Realisierung, bei der mit jedem assignÜbergang der Thread am Kopf der als Schlange implementierten Bereit-Liste den Prozessor zugeordnet bekommt. Ein Kontextwechsel findet bei diesem Verfahren nur statt, wenn der aktuell rechnende Thread eine blockierende Betriebssystemfunktion aufruft oder den Prozessor freiwillig abgibt. Im ersten Fall wird der PCB des Threads nach Beendigung der blockierenden Operation wieder an das Ende der Bereit-Schlange angehängt. Bei einer freiwilligen Abgabe des Prozessors kommt der PCB sofort wieder an das Ende der Schlange; der Thread bewirbt sich also erneut um den Prozessor.
FCFS
Abb. 5-9 FCFS: Varianz bei der mittleren Wartezeit
Mit FCFS kann eine hohe CPU-Auslastung erzielt werden. Alle anderen Schedulingkriterien werden mit diesem Verfahren jedoch nicht optimiert. So hängen z.B. Turnaround, Warte- und Antwortzeit sehr stark von der konkreten Lastsituation ab und variieren entsprechend stark. In Abbildung 5-9 wird dies am Beispiel der mittleren Wartezeit verdeutlicht. Dargestellt sind die CPU-Bursts dreier Threads mit einer
Vor- und Nachteile
5
Threads
Länge von 24 oder 3 Zeiteinheiten. Ordnet FCFS den Prozessor zuerst dem Thread mit CPU-Burst 24 zu, ergibt sich eine mittlere Wartezeit von 17 ms. Umgekehrt ergibt sich eine mittlere Wartezeit von nur 3 ms, wenn zuerst die »kurzen« Threads ausgeführt werden. Abb. 5-10 Konvoi-Effekt
Nachteilig an FCFS ist außerdem, daß aufgrund eines sogenannten Konvoi-Effekts zwar die CPU-Auslastung hoch, die Auslastung des Gesamtsystems jedoch vergleichsweise gering sein kann. Dieser negative Effekt entsteht durch Threads mit langen CPU-Bursts in Kombination mit E/A-intensiven Threads (kurzer CPU-Burst). Die Auswirkungen des Effekts sind in Abbildung 5-10 am Beispiel eines rechenintensiven Threads PCPU u n d dreier E/A-intensiver Threads PJ/Q dargestellt. Man erkennt, daß während der Prozesssorbelegung durch PcPU keiner der E/A-intensiven Threads zum Zuge kommt, obwohl diese nur kurz den eigentlichen Prozessor belegen und meist sehr schnell eine blockierende E/A-Operation anstoßen. Dadurch wird das Potential nebenläufig ausführbarer E/A-Operationen nicht hinreichend gut ausgenutzt. Beispiel: Windows 3.x Kooperatives Scheduling
Eine Variante des FCFS-Verfahrens ist das bei Windows 3.x eingesetzte kooperative Scheduling, das in ähnlicher Form z.B. auch bei System 7 von Apple angewendet wird. Bei diesem Verfahren wird die Reihenfolge der Prozessorzuordnung »kooperativ« zwischen den aktuell ausgeführten Threads in der Form einer FCFS-basierten Ereignisschlange ermittelt. Die grundsätzliche Funktionsweise dieses Schedulingverfahrens wird in Abbildung 5-11 verdeutlicht. Im Zentrum steht eine für alle Anwendungen globale Ereignisschlange. Die primäre Quelle für die Erzeugung von Ereignissen stellt das Graphiksystem dar. Jede Benutzereingabe über die Maus oder die Tastatur sowie Änderungen am Status einzelner Fenster werden in Form von Ereignissen an das Ende der Schlange gestellt. Außerdem kann jede Anwendung zusätzlich
5.3
Monoprozessor-Scheduling
Ereignisse über die zentrale Schlange an andere Anwendungen versenden. Dabei ist jedes Ereignis in der Schlange an eine bestimmte Anwendung adressiert. Der Scheduler wählt die Anwendung für eine Prozessorzuteilung aus, die als Empfänger beim Ereignis am Kopf der Schlange eingetragen ist. Analog zu FCFS wird durch den Aufruf einer blockierenden Operation oder durch die freiwillige Prozessorabgabe der nächste Schedulingschritt initiiert. Abb. 5-11 Kooperatives Scheduling bei Windows 3.x
Die Nachteile dieses Verfahrens sind vielen Benutzern entsprechender Betriebssysteme bekannt. Eine vergleichsweise geringe Antwortzeit kann nur erreicht werden, wenn sich alle Anwendungen tatsächlich kooperativ verhalten und entsprechend häufig die Kontrolle an andere Anwendungen abgeben.
Nachteile
Shortest-Job-First (SJF) Dieses Verfahren führt die Prozessorzuteilung in der Reihenfolge wachsender CPU-Bursts durch, d.h., der Thread mit dem kleinsten nächsten CPU-Burst erhält den Prozessor. Bei mehreren Threads mit gleich langem nächsten CPU-Burst wird FCFS angewendet. Das SJFVerfahren versucht damit explizit den Konvoi-Effekt von FCFS zu umgehen und ermöglicht dadurch eine hohe Auslastung des Gesamtsystems. Darüber hinaus ist es beweisbar optimal bzgl. der Minimierung der Wartezeit. Das Verfahren ist nur bedingt realisierbar, da die Länge des nächsten CPU-Bursts a priori nicht bekannt ist. In der Praxis wird daher eine Approximation eingesetzt, die auf der Basis der gemessenen
SJF
Kein Konvoi-Effekt
SJF nur bedingt realisierbar
5
Threads
Länge des zurückliegenden Bursts und dessen Schätzwert einen exponentiellen Mittelwert für den nächsten Burst ermittelt:
Preemptive und nichtpreemptive Varianten
Dabei wird durch den Faktor a zwischen 0 und 1 bestimmt, welchen Einfluß zurückliegende Burstlängen auf die Schätzung haben. Werte nahe 1 ordnen der Vergangenheit nur einen geringen Stellenwert zu und sind besonders geeignet, wenn ein Thread eine hohe Varianz bei der Burstdauer aufweist. Für das SJF-Verfahren existieren preemptive und nichtpreemptive Varianten. Die beiden Varianten unterscheiden sich in ihrer Reaktion auf einen Thread Tj, der z.B. nach der Beendigung einer E/A-Operation während der Ausführung von T2 rechenbereit wird. Ist der geschätzte nächste CPU-Burst von Tj kürzer als die verbleibende Restzeit von T2, wird der Prozessor bei der preemptiven Variante dem Thread T2 unmittelbar entzogen und Tj zugeordnet. Im nichtpreemptiven Fall findet ein Kontextwechsel erst nach dem Aufruf einer blockierenden Operation oder der freiwilligen Abgabe des Prozessors durch T 2 statt. Round-Robin (RR)
RR
Zeitscheibe
Starke Verbreitung von RR
Round-Robin ist eine weit verbreitete preemptive Schedulingvariante, die eine gleichmäßige Aufteilung der verfügbaren Rechenzeit auf alle rechenwilligen Threads zum Ziel hat. Das Verfahren ordnet jedem rechenwilligen Thread ein definiertes Zeitquantum (auch Zeitscheibe genannt) zu. Nach einem Kontextwechsel ist ein Thread bis zum Ablauf dieses Zeitquantums oder bis zum Aufruf einer blockierenden Systemfunktion im Besitz eines Prozessors. Alle Prozesse im System werden in einer FIFO-Schlange verwaltet. Bei einem Kontextwechsel wird immer der am Kopf der Schlange befindliche Thread als nächstes ausgeführt. Ein aufgrund einer abgelaufenen Zeitscheibe unterbrochener Thread reiht sich an das Ende der Schlange ein und bewirbt sich damit erneut um einen Prozessor. Round-Robin kann damit als eine besondere preemptive Variante von FCFS-Scheduling angesehen werden. Round-Robin bildet die Grundlage für die meisten Multi-Taskingfähigen Betriebssysteme, bei denen die verfügbare Rechenleistung auf mehrere voneinander unabhängige Anwendungen möglichst gleichmäßig verteilt werden soll. Dieses Ziel wird aus einsichtigen Gründen auch erreicht, wenn alle Threads ihr Zeitquantum immer ausschöpfen (rechenintensive Threads). I/O-intensive Threads, die häufig vor Ablauf der Zeitscheibe eine blockierende Systemfunktion aufrufen und
5.3
Monoprozessor-Scheduling
damit vorzeitig den Prozessor entzogen bekommen, werden durch dieses Schedulingverfahren benachteiligt. Die konkrete Wahl des Zeitquantums ist für die Systemleistung kritisch. Eine zu kleine Zeitscheibe erhöht den »unproduktiven« Zeitaufwand (Anzahl Kontextwechsel). Im Extremfall wird nie die Nominalleistung des Systems erreicht, da aufgrund einer abgelaufenen Zeitscheibe ein erneuter Kontextwechsel stattfindet, bevor die Caches ihre Warmlaufphase überwunden haben. Bei steigender Zeitscheibe wird Round-Robin zunehmend zu einem reinen FCFS-Scheduling, da die Wahrscheinlichkeit für den Aufruf einer blockierenden Systemfunktion mit wachsendem Zeitquantum ansteigt. Analog erhöht sich die mittlere Wartezeit für einen Kontrollfluß und damit insbesondere die Reaktionszeit bei interaktiven Anwendungen. Typische Werte für die Zeitscheibe bei gängigen Systemen liegen im Bereich von 10 bis 20 Millisekunden.
Wahl des Zeitquantums ist kritisch
Prioritätsbasiertes Scheduling Bei dieser Form des Schedulings wird jedem Thread eine Priorität zugeordnet, die jede Schedulingentscheidung maßgeblich beeinflußt. Prioritäten werden innerhalb eines vom Betriebssystem vorgegebenen Intervalls zugewiesen, häufig wird dabei dem zahlenmäßig kleinsten Wert die höchste Priorität zugeordnet. Man unterscheidet zwischen statischen und dynamischen Verfahren. Bei einem statischen prioritätsbasierten Scheduling wird die Priorität jedes Threads zum Erzeugungszeitpunkt festgelegt. Dieser Wert kann im weiteren Verlauf nicht mehr verändert werden. Aufgrund der damit erzwungenen deterministischen Ordnung zwischen Threads werden statische Prioritäten häufig als Grundlage für Echtzeit-Scheduling eingesetzt. Bei einem dynamischen prioritätsbasierten Verfahren kann die zugewiesene Priorität vom Betriebssystem und in kontrollierter Weise auch vom Benutzer verändert werden. Zum Beispiel kann das SJF-Verfahren auch als Spezialfall eines dynamischen prioritätsbasierten Schedulings angesehen werden; die jeweilige Priorität eines Threads ist in diesem Fall umgekehrt proportional zur geschätzten Länge des nächsten CPU-Bursts. Bei beiden Schedulingvarianten wird bei einem Kontextwechsel ein Prozessor immer dem rechenbereiten Thread mit der höchsten Priorität zugeordnet. Dieser Thread bleibt bei der nichtpreemptiven Variante dieses Verfahrens so lange im Besitz des Prozessors, bis er eine blockierende Systemfunktion aufruft oder freiwillig den Prozessor abgibt. Im Fall des preemptiven Schedulings wird die aktuelle Prozessorzuordnung unmittelbar dann unterbrochen, wenn ein neuer Thread mit höherer Priorität erzeugt oder ein deblockierter Thread mit höherer Priorität erneut rechenbereit wird.
Statische Prioritäten
Dynamische Prioritäten
Preemptive und nichtpreemptive Varianten
5
Aushungerung (Starvation)
Aging
Threads
Bei einem reinen prioritätsbasierten Schedulingverfahren besteht die Gefahr der Aushungerung (Starvation). Zur Aushungerung von Threads mit niedriger Priorität kommt es, wenn die gesamte verfügbare Rechenleistung von Threads höherer Priorität ausgeschöpft wird. Im Extremfall kann es zu einer Monopolisierung eines Prozessors durch einen Thread kommen. Bei statischen Verfahren ist eine Aushungerung oder Monopolisierung nur durch eine Veränderung der Prioritätszuordnung und anschließendem Neustart zu beheben. Im Fall dynamischer Verfahren können auch sogenannte Aging-Techniken angewendet werden, bei denen in kontrollierter Weise Prioritäten von der Systemsoftware reduziert werden, um einer Aushungerung von Prozessen entgegenzuwirken. Multilevel-Scheduling
Kombination Echtzeitbetrieb und zeitunkritische Aufträge
Realisierung
In der Praxis werden häufig mehrere Schedulingverfahren miteinander kombiniert. Durch die Verknüpfung können Schedulingentscheidungen besser auf die jeweilige Betriebsform abgestimmt und mehrere Betriebsformen nebeneinander betrieben werden. Gängige Kombinationen sind eine gleichzeitige Unterstützung von Dialog- und Hintergrundbetrieb oder eine gleichzeitige Unterstützung von Echtzeitaufträgen und zeitunkritischen Aufträgen. In beiden Fällen werden primär alle rechenbereiten dialogorientierten oder zeitkritischen Aufträge ausgeführt. Nur wenn alle diese Aufträge abgearbeitet wurden, wird die eventuell verbleibende Rechenzeit auf unkritische Aufträge aufgeteilt. Auch beim prioritätsbasierten Scheduling müssen in der Praxis Multilevel-Techniken zum Einsatz kommen, um z.B. die Schedulingreihenfolge bei mehreren rechenbereiten Threads mit gleicher Priorität festzulegen. Die einfachste Form der Kombination besteht in einer Unterteilung der Bereit-Liste. Bei diesem Verfahren wird in jeder Teilliste eine andere Schedulingstrategie eingesetzt. Rechenaufträge werden - je nach der gewünschten Betriebsform - der entsprechenden Teilliste zugeordnet. Durch ein zusätzliches Auswahlverfahren wird bei einem Kontextwechsel aus mehreren nicht leeren Teillisten ein geeigneter Thread ausgewählt. Diese Auswahl kann z.B. durch eine Priorisierung der einzelnen Teillisten oder durch ein Zeitmultiplexen entsprechend den FCFS- oder RR-Verfahren gesteuert werden. Durch die Verwendung eines Multilevel-Schedulingverfahrens ist z.B. eine Unterscheidung zwischen zeitkritischen und -unkritischen Threads leicht umsetzbar: Die Bereit-Liste wird zu diesem Zweck in eine nichtpreemptive FCFS- und eine preemptive RR-Teilliste untergliedert. Innerhalb beider Teillisten können Threads entsprechend ihrer Priorität eingeordnet werden. Die im FCFS-Bereich nutzbaren Prioritäten liegen über
5.3
Monoprozessor-Scheduling
den RR-Prioritäten. Durch diese Zuordnung wird automatisch eine Wahl von zeitkritischen Threads aus der FCFS-Schlange priorisiert. Diese Form des Multilevel-Schedulings wird vom Prinzip her z.B. in POSIX.4-konformen Systemen eingesetzt. Hier können sich im Einzelfall die Prioritätsbereiche zwischen FCFS- und RR-Liste überschneiden. Feedback-Scheduling Diese Klasse von Schedulingverfahren berücksichtigt die Historie der Prozesse bei der Auswahl eines rechenwilligen Prozesses. Die Algorithmen beruhen darauf, die Schedulingkriterien periodisch oder bei bestimmten Ereignissen (z.B. Thread wird erneut rechenbereit) in Abhängigkeit vom aktuellen Systemzustand anzupassen. Ein gängiges Beispiel für einen Feedback-Mechanismus ist z.B. das bereits angesprochene Aging beim prioritätsbasierten Scheduling, um einer Aushungerung von Threads vorzubeugen. Auch SJF-Scheduling kann in diesem Zusammenhang als ein prioritätsbasiertes Feedback-Scheduling aufgefaßt werden.
Berücksichtigung der Vergangenheit
Muitilevel-Feedback-Scheduling In vielen Fällen werden im Zusammenhang mit Multilevel-Scheduling Techniken eingesetzt, die eine dynamische Anpassung der Schedulingkritierien im laufenden Betrieb vorsehen. Das Betriebssystem versucht damit, selbst feine Änderungen im Thread- oder Systemverhalten beim Scheduling zu berücksichtigen. Da die Einstufung eines Threads in diesem Fall von seinem früheren Verhalten und der aktuellen Situation des Gesamtsystems abhängig ist, werden diese Verfahren auch als Muitilevel-Feedback-Scheduling bezeichnet. Abb. 5-12 Multilevel-FeedbackScheduling
5 Threads
Eine gängige Variante dieses Schedulingverfahrens basiert auf dem RR-Verfahren und teilt die Bereit-Liste in mehrere Teillisten mit unterschiedlich langer Zeitscheibe sowie einer FCFS-Schlange (siehe Abbildung 5-12). Durch diese Verfeinerung kann die Benachteiligung I/Ointensiver Threads, wie sie bei einem einfachen RR-Scheduling auftritt, vermieden werden. Threads, die ihre Zeitscheibe aufbrauchen, d.h. denen der Prozessor mit Ablauf des Zeitquantums entzogen wird, kommen in eine Teilliste mit längerer Zeitscheibe aber geringerer Priorität (Feedback). Ruft ein Thread vor Ablauf der Zeitscheibe eine blockierende Systemfunktion auf oder gibt er den Prozessor freiwillig ab, verbleibt er in der jeweiligen Bereit-Liste. Zusätzliche Feedback-Mechanismen müssen eingesetzt werden, um geeignet auf eine erneute Veränderung im Burst-Verhalten zu reagieren, hierzu zählt die Verlagerung in Listen mit höherer Priorität bei kürzer werdenden CPU-Bursts. Diese Form des Feedback-Schedulings wird z.B. standardmäßig vom Linux-Betriebssystem zur Verfügung gestellt; Prozeßprioritäten bestimmen dabei für einen Thread direkt die jeweilige Teilliste. Beispiel: Scheduling in UNIX
Gute Auslastung der E/A-Geräte Kernprioritäten
Bevorzugung interaktiver Anwendungen
Eine andere Variante des Multilevel-Feedback-Schedulings wird in einigen auf BSD Unix basierenden Systemen eingesetzt (siehe Abbildung 5-13). Diese Betriebssysteme verfolgen mit dieser Schedulingstrategie zwei Ziele: erstens eine Bevorzugung dialogorientierter Anwendungen und zweitens eine möglichst hohe Auslastung schneller E/A-Geräte. Die Anzahl der Teillisten ist systemabhängig; BSD 4.4 bietet z.B. 128 unterschiedliche Prioritätsstufen an, die auf 32 einzelne Bereit-Listen aufgeteilt werden. Die Listen mit höherer Priorität (negative Werte in Abbildung 5-13) verwalten Threads, die durch den Aufruf einer Systemfunktion im Kern blockiert werden. Durch die hohe Priorität dieser Threads erreicht das Betriebssystem, daß Threads möglichst schnell nach Aufhebung der Blockadesituation den Kern verlassen. Dabei werden die höchsten Prioritäten für die schnellen E/A-Geräte eingesetzt, um diese möglichst gut auszulasten. Mit einer abgestuften Priorität werden langsamere Geräte wie z.B. Maus und Tastatur belegt. Kernprioritäten werden nur vom Betriebssystem vergeben, sie stehen Anwendungsprogrammen nicht unmittelbar zur Verfügung. Aus diesem Grund werden auch dialogorientierte Threads, die an Eingabegeräten warten, gegenüber normalen Threads bevorzugt behandelt. Für den Benutzer ist dies an einer schnellen Reaktion auf Benutzereingaben erkennbar. Werte oberhalb der Kernprioritäten (in Abbildung 5-13 Werte größer oder gleich 0) können für die Festlegung einer Rangordnung mit niedrigen Prioritäten zwischen rechenwilligen Anwendungsthreads eingesetzt werden.
5.3
Monoprozessor-Scheduling
Abb. 5-13 Multilevel-FeedbackScheduling bei UNIX
Der UNIX-Scheduler sucht von hoher Priorität zu niedriger Priorität die erste Liste, die mindestens einen rechenwilligen Thread enthält. Der erste Thread in dieser Liste bekommt den Prozessor zugeteilt. Die Bereit-Listen selbst werden über Round-Robin-Scheduling verwaltet, d.h., bei einem Kontextwechsel kommt der zuletzt ausgeführte Thread an das Ende der entsprechenden Liste. Um die Benachteiligung von dialogorientierten Anwendungen durch RR-Scheduling zu verhindern, verwendet der Scheduler zusätzlich ein Aging-Verfahren. Dabei wird in periodischen Abständen (bei BSD 4.4 alle 40 Millisekunden) die vergangene CPU-Auslastung jedes einzelnen Threads bestimmt und der Prioritätswert proportional zur ermittelten Auslastung erhöht (niedrige Priorität). Dadurch werden CPU-intensive Threads gegenüber I/O-intensiven Threads benachteiligt. Damit das System auf Veränderungen im Burst-Verhalten einzelner Threads reagieren kann, wird bei der Ermittlung der CPU-Auslastung durch einen Thread ein exponentieller Mittelwert berechnet. Vereinfacht fließt dabei »ältere« Information über die mittlere CPU-Auslastung nur zu einem bestimmten Prozentsatz in die Berechnung der aktuellen Auslastung ein [McKusik et al. 1996]:
Dabei gibt oc an, wie träge die Berechnung der Auslastung auf Änderungen reagiert. Für Werte nahe bei 0 werden Änderungen im BurstVerhalten nur sehr langsam vom Schedulingverfahren aufgegriffen, d.h., die Auslastung der letzten 40 Millisekunden (U -40ms ) wird im Gegensatz zur gemittelten Last der letzten Sekunde (U-1s) praktisch
Basis ist RR
Aging
5 Threads
nicht berücksichtigt. Ein Wert für a nahe bei 1 reagiert zwar schnell auf Änderungen, hebt aber die Benachteiligung I/O-intensiver Threads nicht auf. In der Praxis haben sich Werte im Bereich 0.9 bewährt. Beispiel: Windows 9x und Windows NT
Prioritätsklassen
Auch die Microsoft-Betriebssysteme Windows 9x und Windows NT verwenden ein prioritätsbasiertes Multilevel-Feedback-Scheduling. Beide Betriebssysteme unterscheiden insgesamt 31 verschiedene Prioritätswerte, die auf vier verschiedene Prioritätsklassen IDLE, NORMAL, HIGH und REALTIME aufgeteilt werden. Innerhalb jeder Prioritätsklasse kann ausgehend von einer Stufe NORMAL zusätzlich eine Feinabstufung um zwei Stufen nach oben (HIGHEST, ABOVEJSTORMAL) oder nach unten (BELOW_NORMAL, LOWEST) stattfinden. Threads der Klasse REALTIME besitzen die höchsten Prioritäten. Entsprechend vorsichtig sollte die Zuordnung von Werten aus dieser Klasse vorgenommen werden, da REALTiME-Threads selbst gegenüber der notwendigen Ausführung kritischer Betriebssystemfunktionen bevorzugt werden. Belegt ein solcher Thread für längere Zeit den Prozessor, werden eventuell geänderte Seiten im Platten-Cache nicht zurückgeschrieben oder die Aktualisierung der Mausposition findet nur sehr schleppend statt. Der Scheduler legt die Prozessorzuordnung auf der Grundlage von Prioritätsklasse und -stufe fest. Innerhalb einer Prioritätsstufe wird gemäß Round-Robin verfahren. Um die meist dialogzentrierten Anwendungen im Windows-Umfeld besonders zu unterstützen, erhöht der Scheduler automatisch die Priorität aller Threads einer Anwendung, wenn für diese Benutzereingaben anliegen. Da die Graphikoberfläche aus historischen Gründen fester Bestandteil des Betriebssystems ist, kann die Priorität der beteiligten Threads im Vergleich zu UNIXSystemen gezielter erhöht werden. Die Erhöhung wird, wenn keine weiteren Benutzereingaben anliegen, mit jeder Zeitscheibe reduziert, bis der Ausgangswert wieder erreicht wird.
5.4
Strikte Echtzeitsysteme
Echtzeit-Scheduling
Beim Echtzeit-Scheduling steht die Einhaltung von Zeitvorgaben im Vordergrund. Diesem Schedulingziel werden alle anderen Aspekte wie z.B. Maximierung der Betriebsmittelauslastung oder Unterstützung interaktiver Anwendungen untergeordnet. Die Zeitvorgaben werden immer von der jeweiligen Anwendung definiert. In vereinfachter Form gibt man dabei vor, wie lange die Anwendung im Maximalfall für die Reaktion auf einzelne Ereignisse benötigen darf. Man unterscheidet in der Praxis zwischen strikten und schwachen Echtzeitsystemen. Bei strikten Echtzeitsystemen bedeutet die Verlet-
5.4
Echtzeit-Scheduling
zung einer Zeitvorgabe meist eine Katastrophe, die unter allen Umständen zu vermeiden ist. Zum Beispiel ist es für eine Industriesteuerung katastrophal, wenn das Steuerungssystem beim Eintreffen eines Überdrucksignals von einem entsprechenden Sensor nicht rechtzeitig mit dem Öffnen eines Ventils reagiert, um einer Explosion vorzubeugen. Bei schwachen Echtzeitsystemen ist nach Möglichkeit die Überschreitung einer Zeitvorgabe ebenfalls durch geeignete Maßnahmen zu vermeiden, eine konkrete Verletzung hat jedoch keine katastrophalen Auswirkungen. Verspätete Reaktionen haben bei diesen Systemen eher einen störenden Charakter. Ein Beispiel dafür ist die Behandlung multimedialer Daten, bei denen Verzögerungen und Verschiebungen zwischen Ton- und Bildinformation bis zu einem gewissen Grad toleriert werden.
Schwache Echtzeitsysteme
Abb. 5-14 Sporadische und periodische Echtzeitaktivitäten
Formalisierung von Zeitvorgaben Die korrekte Behandlung von Echtzeitanwendungen setzt eine formale Beschreibung aller einzuhaltenden Zeitvorgaben voraus. Die Zeitanforderungen fließen in ihrer Gesamtheit in sehr unterschiedlicher Form in die jeweiligen Schedulingstrategien ein. Allgemein umfaßt eine Echtzeitanwendung eine Menge von Einzelaktivitäten. Jede Aktivität wird durch drei Kenngrößen charakterisiert (siehe Abbildung 5-14a): •
Bereitzeit (r, Ready Time): Frühestmöglicher Ausführungsbeginn einer Aktivität • Frist (d, Deadline): Spätester Zeitpunkt für die Beendigung einer Aktivität • Ausführungszeit (Ae, Execution Time): Worst-Case-Abschätzung für das zur vollständigen Ausführung der Aktivität notwendige Zeitintervall Die Bestimmung dieser drei Werte für jede Einzelaktivität ist essentiell, um mit Hilfe des Echtzeit-Schedulings alle Zeitvorgaben einer Anwendung einhalten zu können. Durch die Bereitzeit wird ausgedrückt, daß
Kenngrößen für Echtzeitaktivitäten
5 Threads
Problematik Worst-CaseAbschätzung der Ausführungszeit
Kenngrößen periodischer Aktivitäten
die Ausführung einer Aktivität vor diesem Zeitpunkt nicht begonnen werden darf, da z.B. notwendige Informationen vorher nicht vorliegen oder bestimmte Ressourcen noch nicht zur Verfügung stehen. Die Frist definiert die einzuhaltende Zeitvorgabe und die Ausführungszeit beschreibt den maximal notwendigen CPU-Bedarf. Aus einsichtigen Gründen gilt dabei Ae < d-r. In der Praxis ist eine Bestimmung der notwendigen Ausführungszeit aufgrund ihres Worst-Case-Charakters in vielen Fällen sehr aufwendig. Außerdem führt diese Abschätzung insgesamt zu einer sehr schlechten Auslastung des Gesamtsystems. Dies gilt insbesondere bei strikten Echtzeitsystemen, da hier genügend Rechenleistung vorhanden sein muß, um alle anfallenden Aktivitäten auch bei einem Worst-Case-Bedarf an Rechenzeit rechtzeitig fertigstellen zu können. In vielen Fällen sind Aktivitäten periodisch, z.B. werden bestimmte Zustände des technischen Systems häufig in festen Abständen abgefragt. Für periodische Aktivitäten hat sich in der Praxis eine Formalisierung über folgende Kenngrößen bewährt (siehe Abbildung 5-14b)i
• • • Dabei legt die Phase anschaulich die Bereitzeit innerhalb der Periode fest. Aus Periode und Phase können durch einfache Berechnungen Bereitzeit und Frist der k-ten Ausführung einer periodischen Aktivität ermittelt werden:
Die Ausführung jeder Aktivität k einer Echtzeitanwendung wird vom Scheduler zu einem bestimmten Zeitpunkt sk (Startzeit) eingeplant. In Abhängigkeit von dieser Startzeit ergibt sich damit zusammen mit der notwendigen Ausführungszeit eine Abschlußzeit ck. Im Fall eines nichtpreemptiven Schedulingverfahrens gilt für ck (im Extremfall):
Bei preemptiven Schedulingverfahren ergibt sich für die Abschlußzeit lediglich die Abschätzung:
5.4
Echtzeit-Scheduling
da u.U. die Ausführung anderer Aktivitäten zwischengeschoben wurden. In beiden Fällen werden Zeiten für notwendige Kontextwechsel und andere Betriebssystemfunktionen nicht berücksichtigt. Einige im nachfolgenden beschriebenen Schedulingverfahren gehen ausschließlich von periodischen Aktivitäten aus. In diesem Fall modelliert man häufig auch sporadische Einzelaktivitäten indem man eine Periodizität dieser Ereignisse unterstellt und statistisch abschätzt. Dabei muß die gewählte Periode gleich dem Zeitintervall sein, das minimal zwischen zwei Ausführungen dieser sporadischen Aktivität vergehen kann. In der Regel ergibt sich daraus eine weitere Verschlechterung der Systemauslastung. Die vorgestellte Formalisierung sporadischer und periodischer Echtzeitaktivitäten ist trotz ihrer Komplexität eine starke Vereinfachung realer Verhältnisse. Insbesondere bleiben komplexe Abhängigkeitsbeziehungen zwischen verschiedenen Aktivitäten z.B. in Form von Datenabhängigkeiten oder Synchronisationsbedingungen bestenfalls in den Werten für Bereitzeit und Ausführungszeit verborgen. Eine entsprechend detaillierte Modellierung der Zeitanforderungen ist zum Teil noch Gegenstand der Forschung; existierende Ansätze sind aufgrund ihrer Zeitkomplexität in der Praxis meist nicht anwendbar.
Sporadische Aktivitäten
Zeit- und ereignisgesteuerte Systeme Es gibt prinzipiell zwei verschiedene Systemmodelle, die bei der Ausführung von Echtzeitanwendungen eingesetzt werden können. Bei der zeitgesteuerten Ausführung werden alle Schedulingentscheidungen durch das Voranschreiten der Zeit ausgelöst. In diesem Fall reagiert der Scheduler ausschließlich auf eintreffende Timer-Interrupts. Die kleinste vorgegebene Periode einer Echtzeitaktivität ist ein ganzzahliges Vielfaches der Frequenz des Timer-Interrupts. Mit jedem »Tick« dieses Interrupts wird die Zeit fortgeschrieben und ggf. ein Kontextwechsel erzwungen. Der Zugriff auf E/A-Geräte findet direkt statt. Wie bereits angesprochen, geht man dabei prinzipiell von einem konfliktfreien Zugriff aus, da alle impliziten und expliziten Abhängigkeiten in der Formalisierung der Einzelaktivitäten bereits aufgelöst worden sind. Insbesondere werden Eingabegeräte wie z.B. Sensoren im Rahmen der Ausführung des Anwendungsprogramms explizit abgefragt (Polling). Der höhere Aufwand einer Interrupt-gesteuerten Geräteverwaltung ist unnötig und kann eingespart werden. Bei der ereignisgesteuerten Ausführung werden Schedulingentscheidungen durch externe und interne Ereignisse ausgelöst. Die Realisierung externer Ereignisse findet in der Regel auf der Grundlage von Interrupts statt und dient der Einbettung aller E/A-Geräte einschließlich der Sensoren und Aktoren zum technischen System. Interne Ereignisse werden durch andere Prozesse im System ausgelöst und werden
Zeitgesteuerte Echtzeitsysteme
Ereignisgesteuerte Echtzeitsysteme
5 Threads
Strikte Echtzeitsysteme sind meist zeitgesteuert
Schwache Echtzeitsysteme sind meist ereignisgesteuert
typischerweise über den Signalmechanismus, dem softwaretechnischen Gegenstück von Interrupts (siehe auch Kapitel 7), weitergegeben. In einer vereinfachenden Sichtweise kann die Eignung der beiden Systemmodelle in Einklang mit der konkreten Struktur der Echtzeitanwendung gebracht werden. Bei strikten Echtzeitsystemen sind alle Anforderungen a priori bekannt. Entsprechend einfach können sie daher auf ein zeitgesteuertes Systemmodell übertragen werden. Zeitgesteuerte Systeme können jedoch nur in sehr eingeschränktem Umfang dynamisch an sich ändernde Anforderungen angepaßt werden. In vielen Fällen unterscheidet man lediglich verschiedene Arbeitsmodi, zwischen denen zu bestimmten Zeitpunkten gewechselt wird. Die Reaktion auf dynamische Änderungen der Umgebung ist dagegen mit einem ereignisgesteuerten Systemmodell leicht zu bewerkstelligen. Da jedoch die Einhaltung aller Zeitvorgaben im Fall dynamischer Systemänderungen erheblich schwieriger nachzuweisen ist, wird dieses Systemmodell vorzugsweise bei Anwendungen mit schwachen Echtzeitanforderungen eingesetzt. Besonders kritisch an diesem Systemmodell ist außerdem, daß das Systemverhalten im Hochlastfall (viele gleichzeitig auftretende Ereignisse) schwer vorhersagbar ist. Statisches Offline-Scheduling
Komplexität
Offline-Scheduling ist meist beschränkt auf strikte Echtzeitsysteme
In besonders kritischen Fällen müssen Anwendungen mit strikten Echtzeitanforderungen einschließlich bestimmter Abhängigkeitsbeziehungen zwischen einzelnen Aktivitäten modelliert werden, damit das System in der vorgegebenen Zeit auf jede noch abzufangende katastrophale Situation reagiert. Dies gilt insbesondere, wenn von der Funktionstüchtigkeit des zu steuernden Systems Menschenleben abhängen, z.B. Echtzeitanwendungen im Bereich der Auto- oder Flugzeugsteuerung. Im allgemeinen Fall sind die dafür notwendigen Schedulingalgorithmen, die eine Einhaltung aller Zeitvorgaben garantieren, NP-vollständig, d.h., zum gegenwärtigen Zeitpunkt sind nur Algorithmen mit exponentiellem Zeitaufwand bekannt. Ein Einsatz dieser Algorithmen im laufenden Betrieb verbietet sich daher von selbst. Bei einem als Offline-Scheduling bezeichneten Verfahren wird das Scheduling in diesen kritischen Fällen zeitlich ganz vor die eigentliche Programmausführung gelegt. Eine hohe Laufzeit der Schedulingalgorithmen zur Ermittlung eines Plans, der alle Zeitvorgaben erfüllt, hat damit keinen negativen Einfluß auf das eigentliche Echtzeitsystem während der Ausführung. Ergebnis der Vorberechnung ist ein vollständiger Plan, der während der Programmausführung in Form einer Tabelle vorliegt. Das Schedulingverfahren selbst wird damit auf einen einfachen Tabellenzugriff reduziert, um in einer bestimmten Phase den
5.4
Echtzeit-Scheduling
nächsten auszuführenden Thread zu bestimmen. Meist wird OfflineScheduling in Verbindung mit einem zeitgesteuerten Systemmodell eingesetzt, d.h., mit jedem Tick wird über den Tabellenzugriff entschieden, ob ein Kontextwechsel durchgeführt werden muß und welcher Thread in diesem Fall einem Prozessor zugeordnet wird. OfflineScheduling setzt außerdem meist periodische Aktivitäten voraus; sporadische Aktivitäten müssen über ihre kleinste statistische Periode abgeschätzt werden. Earliest Deadline First (EDF) Bei diesem Schedulingverfahren wird ein Prozessor immer dem Thread (Aktivität) mit der am nächsten in der Zukunft liegenden Frist zugeordnet. Faßt man Fristen als Prioritäten auf, dann entspricht dieses Verfahren im Prinzip einem prioritätsbasierten Scheduling (siehe auch Seite 117). Beim EDF-Scheduling sind preemptive und nichtpreemptive Varianten möglich. Im nichtpreemptiven Fall bleibt eine Thread-Prozessor-Zuordnung bis zum Aufruf einer blockierenden Funktion oder bis zur freiwilligen Abgabe bestehen. Die Threads werden in der Reihenfolge der Fristen abgearbeitet. Ein Prozessor bleibt untätig (idle), solange die Bereitzeit des nächsten auszuführenden Threads noch nicht erreicht wurde. Die preemptive Variante führt einen Kontextwechsel durch, sobald ein Thread mit einer näher in der Zukunft liegenden Frist rechenbereit wird, z.B. weil nach dem Eintreffen eines Interrupts eine blockierende E/A-Operation beendet oder die Bereitzeit eines zweiten Threads mit kürzerer Deadline überschritten worden ist.
EDF
Preemptive und nichtpreemptive Varianten
Abb. 5-15 Beispiel für drei Echtzeitaktivitäten
Die nichtpreemptive Variante von EDF ist nicht optimal, d.h., das Verfahren findet nicht immer eine Abarbeitungsreihenfolge für alle Threads unter Einhaltung aller Zeitvorgaben, obwohl es eine solche Thread-Anordnung gibt. Ein Beispiel dafür ist in Abbildung 5-15 dargestellt. Die Prozesse P1 bis P3 werden gemäß EDF in der Reihenfolge ihrer Frist bearbeitet: P1, P2 und anschließend P3. P1 kann zum Zeitpunkt 0 begonnen und zum Zeitpunkt 2 beendet werden. P2 wird aufgrund der späten Bereitzeit erst zum Zeitpunkt 4 angefangen und zum Zeitpunkt 6 beendet. Im Zeitintervall 2 bis 4 ist der Prozessor daher
Nichtpreemptives EDF ist nicht optimal
5
Threads
untätig. Der frühestmögliche Beginn von P3 zum Zeitpunkt 6 verletzt dessen Frist, da er im schlimmsten Fall erst zum Zeitpunkt 10 beendet ist (siehe Abbildung 5-16a). Eine nichtpreemptive Abarbeitungsreihenfolge P1 (Zeitintervall 0-2), P3 (Zeitintervall 2-6), P2 (Zeitintervall 6-8) erfüllt dagegen alle Bereitzeiten und Fristen (siehe Abbildung 5-16b). Abb. 5-16 Nichtpreemptive Schedulingreihenfolgen mit und ohne Fristverletzung
Preemptives EDF
Abb. 5-17 Preemptive Schedulingreihenfolge nach EDF ohne Fristverletzung
Die preemptive EDF-Variante hat mehr Freiheitsgrade, da durch die Unterbrechbarkeit begonnener Aktivitäten eine Schedulingentscheidung nur auf die Menge der rechenbereiten Threads, d.h. Threads, deren Bereitzeit bereits erreicht oder überschritten wurde, beschränkt werden kann. Angewendet auf das Beispiel aus Abbildung 5-15 wird ebenfalls mit der Abarbeitung von P1 begonnen. Diese ist zum Zeitpunkt 2 beendet. Die preemptive Variante beginnt in diesem Fall jedoch mit der Ausführung von P3, dem (einzigen) rechenbereiten Thread mit kürzester Frist. Zum Zeitpunkt 4 muß diese Prozessorzuordnung unterbrochen werden, da Thread P2 mit einer noch kürzeren Frist rechenbereit wird. Nach der fristgerechten Beendigung von P2 zum Zeitpunkt 6 kann die Verarbeitung von P3 weitergeführt werden. Diese endet ebenfalls fristgerecht zum Zeitpunkt 8 (siehe Abbildung 5-17). Allgemein kann man beweisen, daß die preemptive Variante immer eine Abarbeitungsreihenfolge mit Einhaltung aller Zeitvorgaben findet, solange mindestens eine solche Reihenfolge tatsächlich existiert.
5.4
Echtzeit-Scheduling
Rate-Monotonic-Scheduling (RMS) Rate-Monotonic-Scheduling [Liu und Layland 1973] ist ein verbreitetes preemptives Verfahren im Echtzeitbereich, das ebenfalls auf statische Prioritäten abgebildet werden kann. Ähnlich wie beim EDF-Scheduling wird durch eine geeignete Interpretation des Prioritätsbegriffs eine implizite Abarbeitungsreihenfolge für alle Threads festgelegt, bei der alle Zeitvorgaben erfüllt werden können. RMS eignet sich nur zur Beschreibung periodischer Systeme, es kann jedoch ebenfalls durch eine statistische Abschätzung der kleinsten Periode auch für sporadische Ereignisse eingesetzt werden. RMS ordnet Prioritäten in Abhängigkeit von der Periode der einzelnen Aktivitäten zu; Aktivitäten mit der höchsten Frequenz (= kleinste Periode) wird dabei die höchste Priorität zugewiesen, Aktivitäten mit der geringsten Frequenz (= größte Periode) erhalten die tiefste Priorität. Diese Zuordnung steht im Widerspruch zu einer intuitiven Prioritätsfestlegung nach »Wichtigkeit« einer Aktivität, wie sie z.B. auch beim EDF-Scheduling durch die Frist der einzelnen Aktivitäten nahegelegt wird. Durch diese Zuordnung werden hochfrequente Aktivitäten minimal verzögert, so daß die Wahrscheinlichkeit einer Fristverletzung für diese Aktivitäten minimal ist. Das Verfahren führt jedoch zu einer stärkeren Zerstückelung niederfrequenter Aktivitäten aufgrund einer höheren Anzahl an Kontextwechseln zu Threads mit höherer Priorität. Neben der unproblematischen Abbildung der Zeitvorgaben einer Menge von Aktivitäten auf Prioritäten besitzt das Verfahren außerdem die günstige Eigenschaft, daß durch eine einfache Berechnung im Rahmen der Vorgaben sichergestellt werden kann, ob eine Echtzeitanwendung auf einer bestimmten Rechnerhardware ohne Fristverletzung ausgeführt werden kann oder nicht. Im einfachen Fall reicht zur Beantwortung dieser Frage die Überprüfung der Gesamtbelastung durch die Echtzeitanwendung aus. Dabei errechnet sich die Auslastung aus der Periode und der Ausführungszeit aller Aktivitäten A1 bis An:
Gilt für diesen Wert die Abschätzung (siehe dazu [Liu und Layland 1973]):
so liefert RMS beweisbar immer eine Ausführungsreihenfolge, bei der alle Zeitvorgaben eingehalten werden können. Umgekehrt bedeutet diese Abschätzung, daß bei einer geeigneten Auswahl der Hardware
RMS
Hohe Frequenz = Hohe Priorität
Einfache Handhabbarkeit
5 Threads
Hardwareauslastung mindestens 70% Kritische Instanz
maximal 30% der vorhandenen Ressourcen ungenutzt bleiben. Ist der konkrete Auslastungswert höher als die Schranke UM n , muß die Ausführbarkeit auf der Basis der sogenannten kritischen Instanz festgestellt werden. Dabei wird für einen Periodendurchlauf ab dem Zeitpunkt 0 ermittelt, ob alle Aktivitäten unter der Annahme einer Phase Ahk=0 für alle k fristgerecht durchgeführt werden können. Ist dies der Fall, dann gilt die fristgerechte Ausführung aller Aktivitäten auch für alle gültigen Phasenverschiebungen Ahk ungleich 0. Best Effort
Best-Effort
Ausreichend schnelle Hardware
Keine Garantien
In der Praxis sind Earliest-Deadline-First- oder Rate-Monotonic-Scheduling primär im Bereich strikter Echtzeitsysteme angesiedelt. Die schwierige Worst-Case-Abschätzung der Ausführungszeiten aller sporadischen und periodischen Aktivitäten unter Berücksichtigung relevanter Abhängigkeitsbeziehungen verhindert eine breite Verwendung. Außerdem reagieren diese Ansätze meist sensibel auf Programmänderungen, da die Erhöhung einzelner Ausführungszeiten meist Anpassungen der gesamten Programmstruktur nach sich zieht. Aus diesem Grund werden zumindest im Bereich schwacher Echtzeitsysteme häufig Betriebssysteme und Schedulingstrategien ohne besondere Echtzeitfähigkeiten eingesetzt. Man geht in diesem Fall von einer hinreichend leistungsfähigen Rechnerhardware aus, die eine Einhaltung von Zeitvorgaben auch in einer ungünstigen Lastsituation mit hoher Wahrscheinlichkeit gewährleistet kann (Best Effort). Die Tauglichkeit von Hardware, Betriebssystem und Anwendung wird dabei stichpunktartig unter künstlichen oder realen Lastbedingungen getestet. Es ist offensichtlich, daß diese Form der Echtzeitprogrammierung keine verbindlichen Garantien abgeben kann. Durch viele Unbestimmtheiten im Detailverhalten, durch Swapping oder durch Programmfehler ist die fristgerechte Ausführung kritischer Aktivitäten der Echtzeitanwendung letztendlich nicht sichergestellt. Beispiel: POSIX.4
Scheduling in POSIX.4
Bei POSIX.4-konformen Betriebssystemen wie z.B. Solaris von SUN, QNX oder Linux werden FCFS- und RR-Verfahren in Kombination mit Prioritäten in Form eines Multilevel-Schedulings eingesetzt. Dadurch ergibt sich insbesondere im Fall von FCFS eine preemptive Variante, die einem über FCFS eingeplanten Thread den Prozessor entzieht, sobald ein Prozeß mit höherer Priorität z.B. mit der Beendigung einer E/A-Operation rechenbereit wird. In der Summe unterscheiden sich damit beide Verfahren letztendlich nur in der maximalen Dauer der Prozessorzuordnung: Während bei der FCFS-Variante diese Zu-
5.5
Multiprozessor-Scheduling
Ordnung potentiell unbeschränkt lange andauern kann, wird sie bei der RR-Variante nach oben durch die Länge der Zeitscheibe begrenzt. Die in diesem Standard definierte Mischung von fest vorgegebenen Schedulern auf der Grundlage von FCFS und RR und einer optionalen, systemspezifischen Schedulingstrategie entspricht dem aktuellen Stand der Technik. Preemptives prioritätsbasiertes FCFS-Scheduling wird insbesondere im Echtzeitbereich eingesetzt, da EDF-Scheduling und RMS verhältnismäßig einfach darauf abgebildet werden können. Prioritätsbasiertes RR-Scheduling hat sich dagegen im Dialog- und Hintergrundbetrieb bewährt. In der Regel können damit auch Anwendungen mit schwachen Echtzeitanforderungen sinnvoll ausgeführt werden, solange die Zeitvorgaben entsprechend moderat sind (Best Effort). Besondere Verfahren stehen in Form des dritten Schedulers SCHED_OTHER zur Verfügung. Hier können je nach System z.B. explizite EDF-Verfahren mit entsprechend umfangreichen Informationen bereitgestellt werden. In Linux wird in diesem Fall z.B. das Standard-Schedulingverfahren mit dynamischen Prioritäten eingesetzt. Es sind jedoch auch gerade im Hinblick auf die Unterstützung von Echtzeitsystemen weitergehende Verfahren vorstellbar. Die Zusammenführung der drei Schedulingverfahren, die bei mehreren Threads in einem System koexistieren können, wird ebenfalls über den Prioritätsmechanismus erreicht. Konzeptionell gibt es eine Menge von Prioritätswerten, in denen die FCFS- und RR-Prioritäten eingebettet werden. Für beide Schedulingstrategien steht ein Intervall an nutzbaren Prioritäten zur Verfügung, das durch den Aufruf der Funktionen: int sched_get_priority_min ( int policy ) int sched_get_priority_max ( int policy )
ermittelt werden kann. Dabei definiert das Argument policy, für welches Prioritätsintervall der minimale oder maximale Wert abgefragt werden soll (z.B. SCHED_FIFO für das FCFS-Prioritätsintervall). In der Regel enthält das FCFS-Intervall Werte mit höherer Priorität als das RR-Intervall, damit die Ausführung von Echtzeitanwendungen sichergestellt werden kann.
5.5
Multiprozessor-Scheduling
Multiprozessorsysteme ermöglichen im Gegensatz zu Monoprozessorsystemen die echt parallele Ausführung mehrerer Threads. Auf den ersten Blick scheint die Verfügbarkeit mehrerer Prozessoren die Schedulingproblematik zu vereinfachen, da mehrere rechenwillige Threads in den Besitz eines Prozessors gelangen können und damit die Konkurrenz um nur einen Prozessor entschärft wird. Dies gilt insbesondere, wenn im System genügend »Rechenlast« in Form unabhängiger
Kombination Echtzeitund Dialog/Batchbetrieb
FCFS- und RR-Prioritäten
5 Threads
Abhängigkeiten schränken erreichbaren Leistungsgewinn ein
Anwendbarkeit von Monoprozessorstrategien
Threads vorhanden ist. Durch Abhängigkeiten zwischen den einzelnen Threads ist die höhere nominale Leistung eines Multiprozessorsystems jedoch häufig nicht voll ausschöpfbar. Abhängigkeiten können durch eine notwendige explizite Synchronisation zwischen mehreren Threads einer Anwendung (siehe auch Kapitel 6) oder implizit durch den Zugriff auf gemeinsame Ressourcen innerhalb der Systemsoftware (z.B. Zugriff auf dasselbe E/A-Gerät) entstehen. Ein Großteil der Kontrollflüsse ist dadurch zu einem bestimmten Zeitpunkt blockiert und die Anzahl an rechenwilligen Prozessen unterschreitet in vielen Fällen die vorhandene Prozessoranzahl. Eingeschränkt auf das Scheduling unabhängiger Threads ergeben sich bei einem Multiprozessorsystem keine wesentlichen Änderungen. Jede Monoprozessorstrategie kann auf einfache Weise auf mehrere Prozessoren übertragen werden. Die Verfügbarkeit mehrerer Prozessoren gleicht sogar häufig etwaige Nachteile einzelner Schedulingverfahren aus. So wird z.B. die Wahrscheinlichkeit für einen KonvoiEffekt beim FCFS-Scheduling reduziert, da Threads mit langem CPUBurst zwar weiterhin jeweils einen Prozessor dauerhaft belegen, aber I/O-intensiven Threads weitere Prozessoren als Ausweichkandidaten zur Verfügung stehen. Außerdem kann z.B. bei prioritätsbasierten Verfahren die Problematik mehrerer rechenwilliger Threads mit gleicher Priorität entschärft werden. Scheduling eng kooperierender Threads
Gleichzeitige Ausführung kooperierender Threads ist essentiell
Erheblich schwieriger wird das Scheduling abhängiger Threads mit dem Ziel, den maximalen Parallelitätsgrad zu erreichen und dadurch nebenläufig programmierten Anwendungen eine Leistungssteigerung zu ermöglichen. Von zentraler Bedeutung für die Realisierung dieses Schedulingziels ist die explizite Kennzeichnung eng kooperierender Threads durch die Anwendung und darauf aufbauend die gleichzeitige Zuordnung mehrerer Prozessoren an diese Thread-Gruppe. Nur in diesem Fall können die Threads unmittelbar und mit maximaler Geschwindigkeit über den gemeinsamen Speicher kommunizieren. Auch eventuell notwendige Blockadezeiten lassen sich dadurch auf ein absolutes Minimum reduzieren. Werden dagegen zwei oder mehrere dieser kooperierenden Threads zeitlich versetzt ausgeführt, ist die Ausführungsgeschwindigkeit einer nebenläufigen Anwendung im Extremfall vergleichbar mit der auf einem Monoprozessorsystem. Viele nebenläufige Anwendungen reagieren dabei bereits auf kleinste Verschiebungen bei den relativen Ausführungszeiten der interagierenden Threads mit deutlichen Leistungseinbußen; dies ist mit einer der Gründe, warum viele nebenläufige Anwendungen das Potential an Parallelarbeit auf den meisten multiprozessorfähigen Betriebssystemen nur selten ohne besondere Vorkehrungen ausschöpfen können.
5.5
Multiprozessor-Scheduling
Thread-Prozessor-Zuordnung Man unterscheidet bei Schedulingverfahren für Multiprozessoren außerdem zwischen einer statischen und dynamischen Thread-Prozessor-Zuordnung. Bei einer statischen Bindung bleibt eine einmal getroffene Zuordnung zwischen einem Thread und einem Prozessor bestehen, d.h., ein Thread wird in aufeinanderfolgenden Aktivierungen immer auf demselben Prozessor ausgeführt. In diesem Fall kann eine eventuell vorhandene Unterstützung zur Speicherung von Kontextinformationen bei MMU-TLB und Cache von der Systemsoftware genutzt werden. Mit zunehmender Größe dieser Zwischenspeicher können Cache-Einträge für einen gerade ausgeführten Thread auch nach einem Kontextwechsel bis zu einem gewissen Zeitpunkt im Cache verbleiben. Ist diese Kontextinformation bei der nächsten Ausführung des Threads noch im Zwischenspeicher enthalten, kann man die ansonsten notwendige Warmlaufphase der Caches verkürzen oder sogar ganz vermeiden. Bei einer dynamischen Zuordnung kann ein Thread bei jedem Kontextwechsel auf einem beliebigen anderen Prozessor des Systems weiter ausgeführt werden. Man spricht dann auch von einem symmetrischen Mehrprozessorbetrieb [Nehmer 1975]. Eine eventuelle Nutzung von Kontextinformation ist in diesem Fall meist unrealistisch, da die Wahrscheinlichkeit für eine identische Prozessorzuordnung in aufeinanderfolgenden Aktivierungen mit der Anzahl an Prozessoren immer kleiner wird. In besonders ungünstigen Fällen kann durch dieses Verfahren die Systemleistung aus Sicht einer Anwendung sogar unter das Niveau eines Monoprozessorsystems fallen, da nach jedem Kontextwechsel immer mit vollständig erkalteten Caches begonnen werden muß.
Statische Prozessorzuordnung
Dynamische Prozessorzuordn ung
Load Sharing Die einfachste Form des Multiprozessor-Schedulings verwendet eine zentrale Bereit-Liste für alle Prozessoren. Bei diesem auch als Load Sharing bezeichneten Verfahren wird bei jedem Kontextwechsel auf einem der Prozessoren der nächste auszuführende Thread aus der zentralen Liste ausgewählt. Da nur eine Liste vorhanden ist, kann im Prinzip jedes bekannte Schedulingverfahren für Monoprozessoren eingesetzt werden. Zur Vermeidung von Inkonsistenzen muß jedoch eine geeignete Koordinierung beim Zugriff und bei der Manipulation der Zustandslisten erfolgen. Im einfachsten Fall verwaltet dazu ein ausgezeichneter Prozessor alle Zustandslisten und führt entsprechende Operationen im Auftrag anderer Prozessoren aus. Viele aktuelle Betriebssysteme arbeiten nach diesem Verfahren, da es die schnelle
Entsteht in einfacher Form aus einem MonoprozessorScheduling
5 Threads
Hohe Thread-Last ist wichtig
Nachteile
Bereitstellung einer multiprozessorfähigen Version des Betriebssystems auf der Grundlage einer schon existierenden Version für Monoprozessorsysteme erlaubt. Die zweite Form der Konsistenzwahrung besteht in einer geeigneten Synchronisation der Listenzugriffe, die in diesem Fall von jedem Prozessor des Systems selbständig ausgeführt werden. Bei genügend vielen unabhängigen Threads kann mit diesem Verfahren eine gleichmäßige Verteilung der Rechenlast auf alle Prozessoren erreicht werden. Die gleichzeitige Ausführung kooperierender Threads auf unterschiedlichen Prozessoren wird bei diesem Verfahren aus einsichtigen Gründen in keinster Weise garantiert. Entsprechende Leistungssteigerungen für nebenläufig programmierte Anwendungen sind daher in der Regel auch nicht zu erwarten. Nachteilig an diesem Verfahren ist auch, daß es meist in Kombination mit einer dynamischen Thread-Prozessor-Zuordnung angewendet wird und eventuell vorhandene Unterstützung für Kontextinformation zur Vermeidung kalter Zwischenspeicher nicht eingesetzt werden kann. Außerdem wird durch dieses Verfahren die maximale Prozessoranzahl nach oben sehr stark beschränkt, da alle Prozessoren direkt oder indirekt über einen ausgezeichneten Prozessor auf die zentralen Listen zugreifen und damit die zentrale Thread-Verwaltung sehr schnell zu einem Flaschenhals wird [Nehmer 1977]. Gruppen-Scheduling
Explizite Gruppierung von Threads
Nachteile
Gruppen-Scheduling erlaubt die Kennzeichnung kooperierender Threads durch die Anwendung. Es gibt verschiedene Varianten dieses Verfahrens, die auch als Gang-Scheduling [Feitelson und Rudolph 1990], Group-Scheduling [Jones und Schwarz 1980] oder Co-Scheduling [Gehringer et al. 1987] bezeichnet werden. Allen Varianten gemeinsam ist die Behandlung einer Gruppe von kooperierenden Threads als eine Einheit. Zur Ausführung kommt eine solche ThreadGruppe nur bei genügend vielen »freien« Prozessoren, so daß in einem Schedulingschritt allen Threads innerhalb der Gruppe ein eigener Prozessor zugeordnet werden kann. Die gleichzeitige Ausführung kooperierender Threads ist damit gewährleistet. Nachteilig an diesem Verfahren ist ein in vielen Fällen ungünstiges Verhältnis zwischen vorhandener Prozessoranzahl und Größe der verschiedenen Thread-Gruppen. So können z.B. mehrere Prozessoren während einzelner Schedulingintervalle keinem Thread zugeordnet werden, da keine weitere Thread-Gruppe entsprechender Größe rechenwillig ist. In diesem Fall ergibt sich eine verminderte CPU-Auslastung für das Gesamtsystem. Besonders gering fällt die CPU-Auslastung aus, wenn z.B. nur größere Thread-Gruppen existieren, von
5.5
Multiprozessor-Scheduling
denen aufgrund der Gruppengröße nie zwei Gruppen gleichzeitig im Besitz von Prozessoren sind. Die resultierende Auslastung kann dann bis auf 50% fallen. Außerdem besteht die Schwierigkeit, mit wachsender Gruppengröße ausreichend viele freie Prozessoren zum Schedulingzeitpunkt bereitzustellen. Dadurch ergibt sich zwangsläufig eine Benachteiligung großer Thread-Gruppen, der durch zusätzliche Mechanismen entgegengewirkt werden muß. Dedizierte Prozessorzuordnung Bei dieser Form des Multiprozessor-Schedulings wird die Thread-Prozessor-Zuordnung für die gesamte Ausführungszeit der Anwendung getroffen. Auch bei diesem Verfahren wird durch die Gruppierung kooperierender Threads eine Vorgabe bezüglich der benötigten Prozessoranzahl getroffen. Entscheidet sich der Scheduler für eine bestimmte Thread-Gruppe, so stehen der zugehörigen Anwendung die zugeordneten Prozessoren bis zur Programmterminierung zur Verfügung. Solange die Gesamtanzahl an Threads innerhalb der Anwendung die Prozessoranzahl nicht übersteigt, kann jedem Thread ein dedizierter Prozessor zugewiesen werden. Durch die langfristige Thread-Prozessor-Bindung kann dieses Verfahren den in der Implementierung enthaltenen Parallelitätsgrad am besten ausschöpfen. Dem Verfahren liegt dabei die Annahme zugrunde, daß genügend viele Prozessoren zur Verfügung stehen und daß die Maximierung der CPU-Auslastung kein primäres Optimierungsziel mehr darstellt. Bei heutigen Multiprozessorsystemen ist diese Annahme nur in Einzelfällen gerechtfertigt, da die maximale Prozessoranzahl aufgrund vieler technischer Schranken auf 32 bis 64 Knoten begrenzt ist. Bei Parallelrechnersystemen mit vielen tausend Prozessoren, aber ohne gemeinsamen Speicher, ist dieses Schedulingverfahren jedoch gegenwärtig sehr weit verbreitet.
Feste Prozessorzuteilung für die gesamte Ausführungszeit
Maximaler Leistungsgewinn ist möglich
Viele Prozessoren sind wichtig
Activity Working Set Eine interessante Verallgemeinerung der Scheduling-Problematik für Multiprozessoren beschreibt die Activity Working Sef-Theorie [Gehringer et al. 1987]. Sie überträgt Aspekte der Working Set-Theorie aus dem Bereich der seitenbasierten virtuellen Speicherverwaltung auf Threads und Prozessoren. Prozessoren werden in dieser Analogie mit Kacheln im physischen Adreßraum eines Rechners gleichgesetzt, Threads mit Seiten virtueller Adreßräume. Dabei kann man kooperierende Threads mit verschiedenen Seiten desselben virtuellen Adreßraums identifizieren, während unabhängige Threads in dieser Analogie mit Seiten aus verschiedenen Adreßräumen gleichgesetzt werden
Analogie Working Set-Theorie
5
Gedankenmodell
Threads
können. Ein in Ausführung befindlicher Thread entspricht damit einer in einer Kachel eingelagerten virtuellen Seite. Die Problematik unbenutzter Prozessoren aufgrund ungünstiger Größen bei den Thread-Gruppen ist mit einer Speicherfragmentierung zu vergleichen. Auch hier unterscheidet man zwischen einer internen Fragmentierung, d.h., mehrere der zugeordneten Prozessoren sind blockiert oder zum aktuellen Zeitpunkt nicht aktiv, und einer externen Fragmentierung, bei der Prozessoren aufgrund ungünstiger Gruppengrößen ungenutzt bleiben. Von zentraler Bedeutung ist die Frage nach der Existenz einer Lokalitätsmenge bzgl. der Threads analog zu der Seitenlokalitätsmenge aufgrund der ausgeprägten Referenzlokalität vieler Awendungen. Die Größe dieser Menge bestimmt die benötigte Anzahl an Prozessoren, um eine möglichst gute Leistungssteigerung zu erzielen. Wenn die Analogie trägt, würden zusätzliche Prozessoren nur noch eine unwesentliche Steigerung der Leistung ermöglichen. Dagegen reduzieren zuwenig Prozessoren die Systemleistung drastisch und führen - wenn die Anwendung immer wieder neue Prozessoren anzufordern versucht - unter Umständen zu globalen Effekten, die mit dem Seitenflattern verglichen werden können. Zum gegenwärtigen Zeitpunkt besitzt die Activity Working SetTheorie den Stellenwert eines Gedankenmodells, mit dessen Hilfe über Analogieschlüsse interessante Lösungsansätze für die Scheduling-Problematik in Multiprozessorsystemen abgeleitet werden können. Eine praktische Anwendung der Theorie wird durch eine Reihe von Problemen behindert. Unter anderem setzt eine realistische Bewertung der Theorie Anwendungen mit einem hohen Parallelitätsgrad voraus, damit die Existenz einer Aktivitätsmenge und deren Lokalitätseigenschaften mit statistischen Methoden untermauert werden kann. Nur wenige Anwendungsfelder erfüllen zur Zeit diese Bedingung; viele dieser Anwendungen z.B. aus dem Bereich der Mehrgittersimulation besitzen zudem sehr reguläre Aktivitätsmuster, die nicht direkt verallgemeinert werden können.
5.6
Thread-Unterstützung durch APIs
Durch die verstärkte Unterstützung der nebenläufigen Programmierung in Form von Kernel- oder User-Level-Threads und durch die wachsende Anzahl an Anwendungen mit schwachen Echtzeitanforderungen haben sich die Programmierschnittstellen im Bereich Scheduling und Thread-Verwaltung stark gewandelt. Die angebotenen Funktionen lassen sich folgenden Gruppen zuordnen:
• • •
Erzeugung neuer Adreßräume mit initialem Thread Erzeugung zusätzlicher Threads innerhalb eines Adreßraums Wahl und Parametrisierung des Schedulers
5.6 Thread-Unterstützung durch APIs
• • •
Terminierung einzelner Threads Terminierung aller Threads Weitergabe des Terminierungsgrunds an andere Threads
Im nachfolgenden werden die einzelnen Funktionsgruppen am Beispiel der 32-Bit-Programmierschnittstelle Win32 von Windows 9x und Windows NT/2000 (siehe z.B. auch [Cohen und Woodring 1998], [Solomon und Russinovich 2000]) sowie für UNIX-ähnliche Systeme am Beispiel des entsprechenden POSIX-Standards vorgestellt ([Vahalia 1996], [Nichols et al. 1996]). Erzeugung neuer Adreßräume mit initialem Thread Beim Aufbau neuer Adreßräume mit einem initialem Thread dominieren die Speicheraspekte. Wie bereits in Kapitel 4 skizziert, können die notwendigen Adreßräume auf zwei Arten erzeugt werden: 1. Ein neuer, leer initialisierter Adreßraum wird mit dem auszuführenden Programmcode direkt überlagert (Create). 2. Ein neuer Adreßraum entsteht als identische Kopie auf der Basis von Copy-on-Write aus einem bereits vorhandenen Adreßraum (Fork). Die Win32-Programmierschnittstelle enthält Create-basierte Funktionen zum Aufbau neuer Adreßräume. Im Zentrum steht dabei die Funktion:
Win32
Boolean CreateProcess ( Pfadname, Argumente, ...)
Diese Funktion erzeugt einen initialen Adreßraum und überlagert ihn mit dem in einer Datei enthaltenen Programmcode. Der Name der Programmdatei wird durch den Parameter Pfadname festgelegt. Zusätzliche Argumente für die Anwendung werden durch den zweiten Parameter übermittelt. Weitere Parameter legen u.a. die Zugriffsrechte für den erzeugten Adreßraum und den initialen Thread, einen Normal- oder Debug-Modus für die Thread-Ausführung und die initialen Schedulingparameter fest. Der initiale Thread wird unmittelbar im Anschluß an die Adreßraumerzeugung ab einer festen Adresse, die sich aus dem jeweiligen Format der angegebenen Programmdatei ergibt, ausgeführt. An dieser Adresse befindet sich in der Regel eine Routine, die im wesentlichen System- und sprachspezifische Initialisierungen vornimmt. In einem letzten Schritt ruft diese Funktion je nach eingesetzter Programmiersprache eine bestimmte Anwendungsfunktion auf (z.B. main() im Fall von C- oder C++-Programmen). Im UNIX-Umfeld werden neue Adreßräume einschließlich des initialen Threads durch die Abspaltung vom Ausgangsadreßraum erzeugt. Die Abspaltung wird durch den im POSIX.l-Standard festgelegten System-Call fork() eingeleitet:
POSIX
5 Threads
Zusammen mit der Abspaltung des Adreßraums des Kindprozesses findet gleichzeitig eine Gabelung des Kontrollflusses statt, d.h., sowohl der aufrufende Thread als auch der neu erzeugte Thread führen die Programmausführung unmittelbar mit der Beendigung des forkAufrufs fort. Durch den Rückgabewert (im obigen Beispiel in der Variable cpid gespeichert) können die beiden Threads unterschieden werden. Der aufrufende Thread erhält als Rückgabewert die Prozeßidentifikation des neu erzeugten Threads (cpid = child pid), der neu erzeugte Thread erhält dagegen den Wert 0 als Ergebnis des f ork-Aufrufs. Die Übermittlung der eigenen Prozeßidentifikation an den Kindprozeß ist nicht nötig, da diese von jedem Thread durch den Aufruf der Funktion getpid() bei Bedarf ermittelt werden kann. Durch eine Verzweigung in Abhängigkeit von cpid kann sich anschließend die Programmausführung in Vater- und Kindprozeß unterscheiden. Zusätzlich zu fork() steht in UNIX-Systemen die Funktion execve ( Pfadname, Argumente, Environment )
mit verschiedenen darauf aufbauenden Varianten zur Verfügung, die eine Überlagerung des vorhandenen Adreßraums mit dem in Pfadname angegebenen Programmcode ermöglicht. Diese Funktion entspricht im wesentlichen dem CreateProcess() der Win32, mit dem Unterschied, daß der Adreßraum des aufrufenden Threads statt eines initial leeren Adreßraums überlagert wird. Auch hier wird die Programmausführung ab einer vorgegebenen Adresse mit einer systemund sprachspezifischen Initialisierung und der anschließenden Anwendungsausführung begonnen. Erzeugung zusätzlicher Threads innerhalb eines Adreßraums Win32
Durch weitere Funktionen können zusätzliche Threads innerhalb eines bereits vorhandenen Adreßraums erzeugt werden. Im Win32-Kontext geschieht dies durch den Aufruf der Funktion: HANDLE CreateThread ( Schutzattribute, Stackgröße, Startadresse, Parameter, ... )
5.6 Thread-Unterstützung durch APIs
Die Funktion gibt im Erfolgsfall einen Deskriptor (HANDLE) für den neu erzeugten KL-Thread zurück. Threads, die im Besitz eines anderen Thread-Deskriptors sind, können bestimmte Kernoperationen mit Bezug auf diesen Thread ausführen. Die erlaubten Rechte werden dabei durch die Schutzattribute festgelegt. Das zweite Argument gibt die von dem neu erzeugten Thread benötigte Größe des Laufzeitkellers an. Der neu erzeugte Thread beginnt ab der angegebenen Startadresse, meist die Anfangsadresse einer im Adreßraum enthaltenen Anwendungsfunktion, mit der Programmausführung. Zusätzlich kann ein einzelner 32-Bit-Parameter an den neu erzeugten Thread übergeben werden. Um auf diesen Wert zugreifen zu können, muß an der angegebenen Startadresse eine Funktion mit der entsprechenden Signatur definiert sein. Viele UNIX-Systeme bieten ebenfalls eigene KL-Thread-Realisierungen und entsprechende Zugriffsfunktionen an. Aufgrund der verschiedenen Dialekte wird auf eine Vorstellung der systemspezifischen Thread-Realisierungen verzichtet. Statt dessen wird die ThreadErweiterung Pthreads des POSIX-Standards (POSIX4.a) vorgestellt. Diese Erweiterung erlaubt die Modellierung von UL- und KLThreads. Viele UNIX-Systeme bieten Pthreads bereits, heute neben der proprietären Thread-Realisierung an oder werden in der näheren Zukunft diesen Standard unterstützen. Ein neuer Thread wird bei dieser Erweiterung mit dem Aufruf der folgenden Funktion erzeugt: in pthread_create ( pthread_t *tid, pthread_attr_t *attr, void * (*start)(void * ) , void *arg )
Im Zentrum steht die Angabe der vom neu erzeugten Thread auszuführenden Startfunktion start. Dem Thread kann ein 32 Bit großes Argument beliebigen Typs (arg) beim Start übergeben werden. Außerdem legt die Signatur der Startfunktion die Rückgabe eines 32 Bit großen Wertes bei der späteren Terminierung des Threads nahe. Der Rückgabewert der Funktion pthread_create() selbst gibt Aufschluß über den Erfolg der Thread-Erzeugung (0 = erfolgreich). Die Identifikation des neu erzeugten Threads wird über den Referenzparameter tid an den aufrufenden Thread zurückgegeben. Über die Attributstruktur attr können die Umsetzung in UL- oder KL-Threads gesteuert, die Größe und Position des Laufzeitkellers festgelegt und zusätzliche Schedulinginformationen angegeben werden. Wahl und Parametrisierung des Schedulers Die meisten Betriebssysteme verfolgen eine feste Schedulingstrategie, die aus Sicht der Anwendung nur über die vorhandenen Schedulingparameter wie z.B. Priorität beeinflußt werden kann. In Fällen wie
POSIX
5 Threads
Win32
dem kooperativen Scheduling von Windows 3.x sind sogar keine Schedulingparameter zu belegen. Multilevel-Schedulingverfahren lassen sich dagegen in eingeschränktem Umfang über die Schedulingparameter an unterschiedliche Anwendungsprofile (z.B. die Unterscheidung zwischen Dialog- und Hintergrundbetrieb) anpassen. Abgesehen von der Parameterbelegung und einer eventuellen Änderung dieser Werte im laufenden Betrieb sind jedoch keine weiteren Funktionen im Bereich Scheduling-API notwendig. Typische Vertreter einer solchen API mit festem Scheduler sind Win32-basierte Systeme und frühere UNIX-Versionen. Bei beiden Systemen werden Prioritäten für jeden Thread festgelegt, die vom Scheduler berücksichtigt werden. Im Fall von Win32 kann die Prioritätsstufe eines Threads bei dessen Erzeugung mit angegeben werden. Zusätzlich kann die Priorität jederzeit über einen entsprechenden Thread-Deskriptor (HANDLE) durch GetThreadPriority(HANDLE)
abgefragt und mit SetThreadPriority(HANDLE,Priority)
innerhalb der Prioritätsklasse verändert werden. Die Prioritätsklasse eines Threads selbst wird mit Hilfe der Funktion SetPriorityCLASS() verändert; die aktuelle Prioritätsklasse liefert die Funktion GetPriorityClass() . UNIX
POSIX
Bei UNIX-Systemen mit Multilevel-Feedback-Scheduling kann die Thread-Priorität bei der Erzeugung nicht angegeben werden. Der neu erzeugte Thread erbt die Priorität des erzeugenden, d.h. des fork()oder execve() -aufrufenden Threads. Dynamisch ist die Priorität ähnlich den Win32-Funktionen über getpriority() abruf- und änderbar. Die Multi-User-Fähigkeit von UNIX erlaubt dabei für normale Benutzerprozesse lediglich eine Reduzierung der Priorität (renice) und damit die Betonung einer Hintergrundcharakteristik. Die Erhöhung der Priorität ist nur bei sogenannten Superuser- oder RootThreads möglich. Der POSIX.4-Standard geht im Schedulingbereich erheblich weiter. Der Standard schreibt die beiden Schedulingstrategien FCFS und RR fest vor. Eine dritte proprietäre Schedulervariante kann von der Systemsoftware zusätzlich zur Verfügung gestellt werden. Die Schedulerzuordnung kann durch den Aufruf der Funktion int sched_setscheduler ( pid_t pid, int policy, struct sched_param *param )
5.6
Thread-Unterstützung durch APIs
gesetzt und dynamisch verändert werden. Das Argument pid bezeichnet den Thread, dessen Schedulingeigenschaften verändert werden sollen. Änderungsrechte werden auf UNIX-spezifische Gruppenstrukturen für Threads abgebildet; sie sind nicht Bestandteil des Standards. Über den Parameter p o l i c y wird FCFS-Scheduling ( S C H E D _ F I F O ) , RR-Scheduling (SCHED_RR) oder der proprietäre Scheduler (SCHED_OTHER) festgelegt. Über das dritte Argument param können die notwendigen Parameter an den Scheduler übergeben werden. Die FCFS- und RR-Scheduler benutzen nur das Feld sched_priority innerhalb dieses Records. Eine Erweiterung dieser Struktur um zusätzliche Parameter für SCHED_OTHER ist möglich; z.B. können auf diesem Weg zukünftige EDF-Realisierungen mit den notwendigen Werten für Bereitzeit, Frist und Ausführungszeit versorgt werden. Zusätzliche Funktion erlauben die Abfrage und die Änderung einzelner Teile der Schedulinginformation. Von besonderer Bedeutung im Hinblick auf das Scheduling ist auch die Funktion sched_yield(). Durch den Aufruf dieser Funktion gibt ein Thread explizit den Prozessor ab. Terminierung einzelner Threads Die Terminierung eines einzelnen Threads t kann auf zwei Arten geschehen: Sie kann erstens durch den Thread t selbst herbeigeführt oder zweitens über einen anderen Thread t' erzwungen werden. In beiden Fällen wird in diesem Abschnitt davon ausgegangen, daß der terminierte Thread nicht der letzte Thread innerhalb des Adreßraums ist. Die selbständige Terminierung eines Threads ist der einfachere Fall, wenn man davon ausgeht, daß dieser Thread vor der eigentlichen Terminierung alle belegten Systemressourcen einschließlich eventueller, von ihm angeforderter Sperren freigibt. Die eigenverantwortliche Terminierung kann implizit geschehen, indem das Ende der main()-Funktion im Fall von C oder der bei der Thread-Erzeugung angegebenen Startfunktion erreicht wird. Eine explizite Terminierung durch den Thread selbst ist auch über den Aufruf spezieller Funktionen möglich, z.B. über ExitThread() im Bereich Win32 oder pthreadexit() im Fall der Pthread-Erweiterung. Beiden Funktionen kann ein 32-Bit-Argument übergeben werden, das den genauen Grund der Terminierung wiedergibt. In den meisten Betriebssystemen gibt es darüber hinaus Funktionen, die es einem Thread erlauben, die Terminierung eines zweiten Threads zu erzwingen. Im Fall der Win32-Schnittstelle kann dies durch den Aufruf der Funktion TerminateThread ( HANDLE, ExitCode )
Eigenverantwortliche Terminierung
Terminierung anderer Threads Win32
5
POSIX
Threads
erfolgen. Dabei identifiziert HANDLE den zu terminierenden Thread und Exitcode entspricht dem Terminierungsgrund. Die erzwungene Terminierung eines Threads durch einen anderen Thread birgt potentiell die Gefahr der dauerhaften Belegung von Systemressourcen oder nicht auflösbarer Blockadesituationen aufgrund belegter Sperren. Dies ist z.B. bei der Win32-Funktion TerminateThread() der Fall, d.h., alle belegten Ressourcen und Sperren bleiben belegt. Insbesondere können im Bereich globaler dynamischer Bibliotheken (DLLs) Inkonsistenzen auftreten, wenn sich der terminierte Thread zu diesem Zeitpunkt in einer entsprechenden Bibliotheksfunktion befand. Die Pthread-Erweiterung bietet ebenfalls eine Funktion zur Terminierung anderer Threads an: int pthread_cancel ( pthread_t t )
Auch hier besteht die Möglichkeit der dauerhaften Belegung von Ressourcen und Sperren, wenn die Terminierung des angegebenen Threads t zu einem ungünstigen Zeitpunkt stattfindet. Der PthreadStandard geht an dieser Stelle jedoch weiter und unterscheidet drei Thread-Typen hinsichtlich ihrer Terminierbarkeit:
• • •
Der Thread ist nicht von außen terminierbar; Aufrufe der Funktion pthread_cancel () sind für diesen Thread wirkungslos. PTHRE'AD_CANCEL_DEFERRED: Die Terminierung eines Threads wird verzögert, bis dieser einen Terminierungspunkt (Cancellation Point) passiert. PTHREAD_CANCEIW\SYNCHRONOUS: Die Terminierung des Threads kann jederzeit erzwungen werden. PTHREAD_CANCEL_DISABLE:
Die Terminierbarkeit ist ein Attribut des jeweiligen Threads. Dadurch kann die versehentliche Terminierung kritischer Threads durch die Anwendung verhindert werden. Von besonderer Bedeutung ist die verzögerte Terminierung, bei der entsprechende Terminierungspunkte explizit durch den Aufruf von pthread_testcancel() definiert werden. Nur an diesen Punkten können entsprechende Threads von außen terminiert werden. Die Terminierung wird genau dann erzwungen, wenn zum Zeitpunkt des Aufrufs eine über pthread_cancel() initiierte Aufforderung vorliegt. Es wird implizit davon ausgegangen, daß eine Terminierung an einem solchen Punkt keine dauerhafte Belegung von Ressourcen zur Folge hat. Die Einhaltung dieser Bedingung obliegt jedoch der Anwendung, sie wird vom System nicht forciert.
5.6 Thread-Unterstützung durch APIs
Terminierung aller Threads Ein Adreßraum wird wieder freigegeben, wenn der letzte enthaltene Thread eigenverantwortlich terminiert. Dieser Thread muß nicht notwendigerweise mit dem initialen Thread bei der Adreßraumerzeugung übereinstimmen. Der angegebene Terminierungsgrund kann in diesem Fall je nach Betriebssystem an einen anderen Thread außerhalb des Adreßraums weitergegeben werden. Ein Thread hat außerdem die Möglichkeit, die Terminierung aller Threads eines Adreßraums und die anschließende Freigabe des Adreßraums selbst zu erzwingen. Die Win32-API stellt zu diesem Zweck die Funktion ExitProcess(ExitCode) zur Verfügung. Eine entsprechende Funktion wird in der Pthread-Erweiterung nicht angeboten, dasselbe Ergebnis wird im Fall von UNIX aber z.B. durch den Aufruf der Funktion exit() erreicht. In allen Fällen werden mit der Terminierung des letzten Threads innerhalb eines Adreßraums alle eventuell noch belegten Ressourcen vom Betriebssystem wieder freigegeben.
Win32
UNIX
Weitergabe des Terminierungsgrunds an andere Threads Die verschiedenen Varianten der Thread-Terminierung erlauben die Übergabe eines meist 32 Bit großen Parameters, der den genauen Grund der jeweiligen Terminierung wiedergibt. In vielen Fällen sind bestimmte Threads daran interessiert, über die Terminierung anderer Threads informiert zu werden und den konkreten Terminierungsgrund übermittelt zu bekommen. Je nach Betriebssystem gibt es verschiedene Realisierungsformen bei der Weitergabe des Terminierungsgrunds. Im UNIX-Umfeld geschieht dies innerhalb der Prozeßhierarchie, die über die Prozeßabspaltung auf der Grundlage des System-Calls fork() aufgebaut wird. Ein Vaterprozeß, oder - falls dieser bereits selbst terminiert ist - der bzgl. der Hierarchie übergeordnete Prozeß (Großvater) kann den Terminierungsgrund eines mit fork() erzeugten Kindprozesses über den Aufruf einer der Funktionen wait ( int *status ) waitpid ( int pid, int *status, int options )
erfahren. In beiden Fällen wird der aufrufende Prozeß blockiert, bis ein Kindprozeß oder im Fall von waitpid() bestimmte Kindprozesse (pid legt dabei fest, welche Prozesse in Frage kommen) terminiert sind. Der jeweilige Terminierungsgrund wird über den Zeiger satus zurückgegeben. In einer weiteren Variante wait3() kann der Zustand eines eventuell terminierten Prozesses auch nicht blockierend erfragt werden. Darüber hinaus existiert auch die Möglichkeit der asynchro-
UNIX
5 Threads
Zombie-Prozeß
POSIX
nen Benachrichtigung über eine Prozeßterminierung auf der Grundlage sogenannter Software-Interrupts (siehe auch Kapitel 7.4). Ein terminierter Prozeß nimmt innerhalb des Betriebssystems den Zustand eines sogenannten Zombie-Prozesses an, wenn der Terminierungsgrund vom Vaterprozeß noch nicht entgegengenommen wurde. Bis auf den PCB, der den Terminierungsgrund enthält, werden dabei alle belegten Systemressourcen (Adreßraum, geöffnete Dateien etc.) zum Zeitpunkt der Terminierung freigegeben. Der PCB selbst kann vom System erst mit der Abfrage des Terminierungsgrunds wiederverwendet werden. Aus historischen Gründen funktioniert der Mechanismus der Weitergabe von Terminierungsinformation in UNIX nur bei Prozessen, d.h. bei Adreßräumen mit jeweils genau einem Kontrollfluß. Bei UNIX-Systemen, die mehrere KL-Threads in einem Adreßraum unterstützen, oder bei UL-Threads kann daher nur der Terminierungsgrund bei der Auflösung des Adreßraums einschließlich aller enthaltener Threads an den Vaterprozeß übermittelt werden. Innerhalb eines Adreßraums definiert die Pthread-Erweiterung des POSIX-Standards eine Weitergabe von Terminierungsgründen an andere Threads desselben Adreßraums. Zu diesem Zweck muß ein Thread, der an der Terminierung eines anderen Threads und der Weitergabe des Terminierungsgrundes interessiert ist, die Funktion pthreadjoin ( pthread_t tid, void **status )
aufrufen. Der aufrufende Thread wird bis zur Terminierung des angegebenen Threads tid blockiert. Der Terminierungsgrund wird über den Zeiger status zurückgegeben. Analog zu UNIX wird der Terminierungsgrund eines Threads vom System zwischengespeichert, bis ein weiterer Thread innerhalb des Adreßraums diesen mittels pthread_join() entgegennimmt. Dieser Mehraufwand kann durch den Aufruf der Funktion pthread_detach ( pthread_t which )
Win32
eingespart werden, wenn eine Abfrage des Terminierungsgrundes in der Anwendung nicht vorgesehen ist. Auf die Terminierung entsprechend markierter Threads kann nicht mehr über phtread_join() gewartet werden. Ein explizites Verfahren wird auch bei Windows 95 und Windows NT angewendet. Jeder Kernel-Thread wird innerhalb des Betriebssystems über einen Thread-Deskriptor (HANDLE) identifiziert. Dieser Deskriptor wird vom Betriebssystem bei der Terminierung des Threads automatisch in einen sogenannten signalisierten Zustand versetzt. Andere Threads, die im Besitz des Deskriptors sind, können durch den Aufruf der Funktionen WaitForSingleObject ( HANDLE tid, Timeout ) WaitForMultipleObjects (...)
5.7
Implementierungsaspekte
blockierend auf die Terminierung eines Threads tid oder im Fall von WaitForMultipleObjects() auf die Terminierung eines Threads aus einer angegebenen Menge warten. Für die Wartezeit kann optional eine obere Schranke Timeout angegeben werden. Mit einem Wert Timeout=0 wird die Funktion nicht blockierend aufgerufen; bei einem Wert INFINITE ist die Wartzeit unbeschränkt. Die Signalisierung des Deskriptors als Folge der Thread-Terminierung befreit alle am Deskriptor blockierten Threads. Der Terminierungsgrund kann anschließend über die Funktion GetExitCodeThread ( HANDLE tid, long *status )
abgefragt werden. Da das Betriebssystem zusätzlich Buch darüber führt, welche Threads noch in Besitz eines bestimmten Deskriptors sind, kann der Terminierungsgrund auch zu einem späteren Zeitpunkt ermittelt werden. Außerdem funktioniert das Verfahren sogar über Adreßraumgrenzen hinweg, da alle Deskriptoren systemweit definiert sind. Im konkreten Fall wird der Kreis der Threads, die potentiell den Terminierungsgrund abfragen können, darauf reduziert, welcher Thread einen bestimmten Deskriptor übermittelt bekommen hat und welche Zugriffsrechte darauf definiert sind. Analog zu einzelnen Threads ermöglicht die Win32-Schnittstelle auch die Ermittlung von Terminierungsgründen, wenn Adreßräume mit allen darin enthaltenen Threads terminiert werden. Die Signalisierung und Synchronisation interessierter Threads findet in diesem Fall durch den Aufruf einer der Funktionen WaitForSingleObject() oder WaitForMultipleObject() unter Angabe des Prozeßdeskriptors statt. Den Terminierungsgrund selbst liefert die Funktion GetExitCodeProcess().
5.7
Implementierungsaspekte
Kontextwechsel-Problematik Jeder Kontextwechsel kostet Rechenzeit, die der Ausführung von Anwendungsprogrammen entzogen wird. Aus diesem Grund ist es ein wichtiges übergeordnetes Ziel, sowohl die Kosten eines einzelnen Wechsels als auch die Gesamtanzahl an durchgeführten Kontextwechseln zu minimieren. Eine Reduktion der Einzelkosten kann im wesentlichen durch eine handoptimierte Implementierung des eigentlichen Kontextwechsels erreicht werden. Der hohe Aufwand der manuellen Programmierung auf Maschinensprachebene amortisiert sich aufgrund der häufig durchgeführten Kontextwechsel bereits bei einer Einsparung von wenigen Instruktionen. Eine zweite wichtige Möglichkeit der Kosten-
Reduktion der Kosten eines Kontextwechsels
5 Threads
Reduktion der Anzahl an Kontextwechseln
reduktion besteht in der oben angesprochenen Bereitstellung unterschiedlicher Gewichtsklassen bei Threads. Die entscheidende Ansatzstelle für eine Reduktion der Gesamtkosten ist die Minimierung der Kontextwechselfrequenz. Im Prinzip geht es dabei um die Auswahl einer Schedulingstrategie, die bei vorgegebener Betriebsart und einem typischen Lastprofil die minimale Anzahl an durchgeführten Kontextwechseln aufweist. Die Bestimmung eines Schwellwerts für eine gerade noch akzeptable Anzahl an Kontextwechseln pro Zeitintervall wird daher von vielen Faktoren beeinflußt:
• • • Einfluß Registersatz
Einfluß TLB und Caches
Kontextinformation
Gesamtgröße der Registerstruktur eines Prozessors Struktur und Umfang des TLB Struktur und Größe der LI- und L2-Caches
Der Aufwand, einen Thread-Zustand zu sichern und zu aktualisieren, wächst proportional mit der Größe des Registersatzes eines Prozessors. Besonders kritisch ist dies bei sogenannten RISC-Prozessoren wie z.B. dem SPARC-Prozessor, die häufig über 32, 64 oder noch mehr Register verfügen. Geht man z.B. von 64 Registern zu je 32 Bit aus, so müssen bei einem Kontextwechsel jeweils 64-4 Byte = 256 Byte gelesen und geschrieben werden. Beim Sichern des aktuellen Registersatzes wird aus Gründen der Speicherkonsistenz meist auf den Hauptspeicher durchgeschrieben (Write Through). Umgekehrt ist die Wahrscheinlichkeit, daß sich der zu ladende Registersatz in einem der Caches befindet, äußerst gering. Insgesamt ergeben sich dadurch z.B. bei einer Speicherzugriffszeit von 60 ns pro 32 Bit minimale Kosten von 8 us für 2-64 Hauptspeicherzugriffe. Dabei wurde noch keine einzige Schedulingentscheidung getroffen. Im Vergleich dazu kann der Registerzustand bei Intel-Prozessoren ab dem 80386 mit jeweils 26 Speicherzugriffen zu je 32 Bit vollständig gesichert und geladen werden. Das ergibt eine minimale Zeit von 3.6 u.s für einen Kontextwechsel, die z.B. von dem Echtzeitbetriebssystem QNX einschließlich der Schedulingentscheidung knapp erreicht wird [QNX 1993]. Struktur und Größe von TLB und Caches haben ebenfalls einen indirekten Einfluß auf die Kontextwechselzeit und damit auf eine sinnvolle Kontextwechselfrequenz. So muß bei einem Adreßraumwechsel der Inhalt des TLB häufig vollständig invalidiert werden. Die sich ergebenden Zusatzkosten bestehen aus der Invalidierung des TLB selbst und aus der reduzierten Ausführungsgeschwindigkeit eines Threads nach einem Kontextwechsel aufgrund eines kalten TLB. In bestimmten Fällen kann auch selektiv nur ein Teil des TLB invalidiert werden, um die TLB-Einträge für den Betriebssystembereich zu erhalten. Besonders günstig wirkt sich ein TLB aus, der zu jedem Eintrag auch die Kontextidentifikation speichern kann (z.B. die SPARCMMU). In diesem Fall kommen keine weiteren Invalidierungskosten
5.7
Implementierungsaspekte
hinzu. Darüber hinaus besteht die Möglichkeit, daß bei einem hinreichend großen TLB noch frühere Einträge des nächsten Threads enthalten sind und die Ausführung mit einem warmen oder gar heißen TLB weitergeführt werden kann. Interessant sind auch MMU-Architekturen, die eine Speicherung des TLB-Inhalts als Teil der Zustandssicherung bei einem Kontextwechsel erlauben. In welchen Fällen sich diese explizite Speicherung gegenüber einem »Kaltstart« des TLB rentiert, hängt im wesentlichen von der Größe des TLB ab. Auch die Plazierung des L2-Caches in der Rechnerstruktur kann Auswirkungen auf die Kontextwechselzeit haben. Wie in Kapitel 4 diskutiert, kann ein L2-Cache vor oder nach der MMU positioniert werden. Im ersten Fall speichert der Cache virtuelle Adressen. Da sich diese Adreßabbildung bei einem Kontextwechsel ändert, müssen in diesem Fall alle L2-Einträge invalidiert werden. Handelt es sich zusätzlich um einen Write Back Cache, d.h., auch Schreiboperationen werden nicht unmittelbar auf den Hauptspeicher durchgeschrieben, muß spätestens mit dem Kontextwechsel der Abgleich der Speicherinhalte zwischen Cache und Hauptspeicher erzwungen werden. Ein Cache, der sich hinter der MMU befindet, speichert dagegen physische Adressen. Eine Invalidierung bestimmter Cache-Einträge ist in diesem Fall nur bei der Änderung der Kachelzuordnung oder beim Nachladen einer virtuellen Seite notwendig. Die Kontextwechselzeiten werden bei dieser Cache-Anordnung nicht erhöht.
Plazierung L2-Cache
Beispiel: Dispatcher für Monoprozessorsystem Threads werden im wesentlichen über den Dispatcher eines Betriebssystems realisiert. Der Thread-Zustand ist mit der Ausnahme rechnender Threads im jeweiligen PCB gespeichert. Bei den rechnenden Threads ist der dynamische Zustand im Prozessor und anderen Systemkomponenten gespeichert. Im nachfolgenden soll eine exemplarische Realisierung der Dispatcher-Funktionen skizziert werden. Dabei wird vereinfachend angenommen, daß es sich um ein Monoprozessorsystem handelt; entsprechender Synchronisationsbedarf beim gleichzeitigen Zugriff auf die jeweiligen Zustandslisten entfällt damit. Alle Dispatcher-Funktionen operieren auf den Prozeßzustandslisten, die aus einzelnen PCBs bestehen. Die Struktur eines PCBs wird dabei vereinfachend durch ein Record beschrieben: Realisierung PCB
Der PCB enthält alle notwendigen Felder zur Speicherung des Zustands. Dazu gehört z.B. ein Bereich r zur Sicherung des Register-
5 Threads
zustands. Die Größe dieses Feldes ist prozessorspezifisch und hängt von der Registeranzahl N_REG ab. Die tatsächliche Realisierung der Listen ist für das Verständnis der Dispatcher-Funktionen unnötig. Es wird daher von einem abstrakten Datentyp List ausgegangen, der im wesentlichen die Funktionen List.Put ( Element ) Element List.Get () Element List.Get ( Key )
Realisierung der Zustandslisten
zur Verfügung stellt. Mit Put kann ein Element zur angegebenen Liste hinzugefügt werden. Die Funktion Get entfernt entweder ein beliebiges Element aus der Liste (ohne Argument) oder ein Element mit der angegebenen Identifikation Key. In beiden Fällen ist das entfernte Element auch der Rückgabewert der Funktion. In der Praxis gibt es sehr unterschiedliche Realisierungsformen für diese Zustandslisten. Neben einfach- und doppelt-verketteten Listen werden häufig auch komplexere Strukturen wie z.B. Hash-Tabellen eingesetzt, um auch aufwendigere Schedulingverfahren effizient zu implementieren. Bei Multilevel-Verfahren werden außerdem zusätzliche Hilfsinformationen verwaltet, damit die Suche nach einer nichtleeren Teilliste in konstanter Zeit ausgeführt werden kann. Die Zustandslisten selbst bilden drei Instanzen des abstrakten Datentyps List mit PCB als dem Listenelement (alle Listen sind initial leer): List(PCB) rechnend = 0; List(PCB) bereit = 0; List(PCB) blockiert = 0;
Dispatcher-Funktionen
Die Dispatcher-Funktion Add fügt den PCB eines neuen Threads in die Bereit-Liste ein: Dispatcher.Add (NewPCB) { bereit.Put(NewPCB); }
Das Betriebssystem wird vor dem Aufruf von Add Speicherplatz für die Seitentabelle anlegen und diese entsprechend initialisieren. Darüberhinaus wird ein initialer Registerzustand festgelegt, bei dem PC und Stackpointer bereits auf die entsprechenden Anfangsadressen im virtuellen Adreßraum zeigen. Bei der Terminierung eines Threads wird der zugehörige PCB aus der Bereit-Liste entfernt: PCB Dispatcher.Retire (pid) { return bereit.Get(pid);
} Die Funktionen Assign, Resign, Ready und Block führen im wesentlichen entsprechende Umverteilungen eines PCBs innerhalb der drei
5.7
Implementierungsaspekte
Zustandslisten durch. Damit die Funktionen Resign und Block auch tatsächlich den vollständigen Prozessorzustand konsistent sichern können, müssen entsprechende Vorkehrungen beim Eintritt in den Kern stattfinden. Der Einfachheit halber wird im nachfolgenden angenommen, daß in beiden Fällen der korrekte Prozessorzustand unmittelbar nach dem Eintritt durch den Aufruf der Funktion MO.ContextSave(reg_save) in einem besonderen Speicherbereich reg_save gesichert wird:
Ein Kerneinthtt findet bei einer asynchronen Unterbrechung oder beim Aufruf eines System-Calls statt
Dispatcher.Assign () { PCB next; next = bereit.Get(); rechnend.Put(next); MO.ContextRestore(next->r);
} Dispatcher.Resign () { PCB current; current = rechnend.Get(); current->r = reg_save; bereit. Put (current) ,-
} Dispatcher.Ready (pid) { PCB p; p = blockiert.Get(pid); bereit.Put(p);
} Dispatcher.Block () { PCB seif; seif = rechnend.Get(); self->r = reg_save; blockiert.Put(seif);
Bei der angegebenen Realisierung wird vorausgesetzt, daß nach dem Verlassen einer Betriebssystemfunktion exakt ein Thread im Zustand »Rechnend« ist. Zur Erfüllung dieser Bedingung ist es notwendig, daß zu jedem beliebigen Zeitpunkt wenigstens ein Prozeß im Zustand »Bereit« ist. Diese Bedingung wird am einfachsten durch einen sogenannten Nullprozeß hergestellt, der nie in einen Wartezustand tritt und daher ständig einem Prozessor zugeordnet werden kann. Durch geeignete Schedulingmaßnahmen muß natürlich sichergestellt werden, daß dieser Nullprozeß nur dann rechnet, wenn kein anderer Thread rechenwillig ist. Die Forderung, nach Verlassen des Nukleus exakt einen Thread im Zustand »Rechnend« vorzufinden, schreibt zwingend vor, nach dem Entzug eines Prozessors durch die Funktionen Resign, Block und Retire immer die Funktion Assign auszuführen. Der Initialzustand des Systems wird durch eine besondere Startroutine hergestellt.
Nullprozeß
Nukleus
5 Threads
Da zu diesem Zeitpunkt in keiner Zustandsliste ein Thread vorgefunden wird, muß diese in der Regel einen ersten Urprozeß anlegen und dessen Ausführung durch Dispatcher.Add(...); Dispatcher.Assign();
initiieren. Im Verlauf des weiteren Bootvorgangs des Betriebssystems werden von diesem Urprozeß alle weiteren Systemprozesse und -Threads erzeugt. Auf der Grundlage des Dispatchers werden alle weiteren Funktionen des Betriebssystems, die potentiell einen Kontextwechsel zur Folge haben können, aufgebaut. Die Aktivierung einer Betriebssystemfunktion kann durch den Aufruf eines System-Calls in einem existierenden Thread oder durch einen Interrupt ausgelöst werden. Letztere signalisieren z. B. die Beendigung einer E/A-Operation-und führen damit zum Aufruf der Dispatcher-Funktion Ready. Timer-Interrupts können darüber hinaus bei einem preemptiven Schedulingverfahren die aktuelle Thread-Prozessor-Zuordnung durch den Aufruf von Resign aufheben. Realisierung von UL-Thread-Packages
setjmpO und longjmp()
yield()
Bei der Realisierung eines UL-Thread-Packages (ULTP) werden die notwendigen Verwaltungsinformationen (Zustandsinformationen und -listen) in den Anwendungsadreßraum verlagert. Die Zugriffsfunktionen einschließlich Scheduler und Dispatcher stehen der Anwendung in Form einer Laufzeitbibliothek zur Verfügung. Die Sicherung und Wiederherstellung des Registerzustands ist systemspezifisch. Im UNIXUmfeld können dafür die Funktionen setjmp() und longjmp() eingesetzt werden, die u.a. die Inhalte aller Register einschließlich PC und Stackpointer in besonderen Datenstrukturen ablegen. Stehen entsprechende Funktionen nicht zur Verfügung, muß der Registerzustand über gesonderte Assemblerroutinen gesichert und restauriert werden. Problematisch kann dabei im Einzelfall der Zugriff auf privilegierte Register wie z.B. Statuswort sein, der je nach Betriebssystem aus Schutzgründen durch einen Wechsel in den privilegierten Zustand realisiert werden muß. Die Microsoft-Betriebssysteme Windows 9x und Windows NT/2000 bieten z. B. besondere Funktionen, die lesenden und bis zu einem gewissen Grad auch schreibenden Zugriff auf privilegierte Register erlauben. Ein expliziter Thread-Wechsel kann in einem ULTP sehr einfach durch eine Funktion yield() realisiert werden. Der Zustand des aufrufenden Threads wird zuerst im zugehörigen PCB im Anwendungsadreßraum gespeichert. Anschließend wird analog zur Kernel-Reali-
5.7 Implementierungsaspekte
sierung ein neuer Thread ausgewählt und durch die Wiederherstellung der Registerinhalte ausgeführt. Bei einer nichtpreemptiven ThreadRealisierung müssen darüber hinaus blockierende System- und Bibliotheksaufrufe durch das ULTP abgefangen werden, damit die resultierende Blockade des Träger-Threads nicht alle Threads im Adreßraum betrifft. Statt des blockierenden Systemaufrufs muß auf eine asynchrone Variante zurückgegriffen werden, die den aufrufenden KLThread selbst nichtblockiert und die eine Beendigung der Operation über einen zusätzlichen Mechanismus (z.B. dem Signal-Mechanismus in UNIX) der Anwendung mitteilt. Allgemein wird also jeder blockierende System-Call und jede blockierende Bibliotheksfunktion X: x () { }
durch eine erweiterte Funktion xULT ersetzt: () { PCB current; Xasync(); current = rechnend.Get () ; blockiert.Put(current); Dispatcher.Assign() ;
} Über den angesprochenen zusätzlichen Kommunikationsmechanismus wird die Anwendung über die Beendigung der angestoßenen asynchronen Operation Xasync() informiert. In diesem Fall muß der blockierte Thread ermittelt und über den Aufruf einer Dispatcher.Ready()-ähnlichen Funktion wieder in die Bereit-Liste eingegliedert werden. Vereinfachend wurde in dem obigen Code-Beispiel angenommen, daß es zu einer blockierenden Funktion eine entsprechende nichtblockierende Funktion gibt. Inder Praxis ist diese Situation nur sehr selten gegeben. Für viele blockierende Funktionen gibt es in modernen Betriebssystemen zwar asynchrone Realisierungen, sie unterscheiden sich jedoch in ihrer Komplexität und in ihrem CodeUmfang sehr stark vom einfachen Funktionsaufruf. In anderen Fällen ist eine Blockade einfach nicht zu umgehen, d.h., blockierende Operationen haben den Stillstand aller UL-Threads in einem Adreßraum zur Folge. Aus diesem Grund werden in vielen ULTPs nur die wesentlichen blockierenden Operationen maskiert. Bei einer preemptiven UL-Thread-Realisierung muß zusätzlich ein zeitgesteuerter Signalisierungsmechanismus ähnlich dem Timer-Interrupt eingesetzt werden, um einen Thread-Wechsel innerhalb des Adreßraums herbeizuführen.
Behandlung blockierender Aufrufe
5 Threads
Umsetzung der POSIX.4-Threads Abbildung auf KLund/oder UL-Jhreads
Die Pthread-Erweiterung schreibt keine feste Realisierung als Kerneloder User-Level-Threads vor. Die konkrete Umsetzung hängt vielmehr von dem jeweiligen Wirtssystem ab, insbesondere, ob dieses mehrere Threads innerhalb eines Adreßraums zuläßt oder nicht. Das Spektrum an möglichen Umsetzungen wird von zwei Realisierungsformen begrenzt:
•
•
Bestimmte Funktionen dürfen nur betroffenen Thread blockieren
Standardisierungsprozeß ist noch nicht abgeschlossen
Pthread = KL-Thread Pthread = UL-Thread
Im ersten Fall wird natürlich vorausgesetzt, daß das Wirtssystem mehrere Threads innerhalb eines Adreßraums unterstützt. Jeder Pthread wird dann 1:1 auf einen KL-Thread abgebildet. Wettbewerb um Ressourcen findet bei dieser Realisierung auf der Ebene des Gesamtsystems statt (PTHREAD_SCOPE_SYSTEM). Bei der einfachen UL-Realisierung werden alle Pthreads auf genau einen KL-Thread abgebildet. Diese Variante kann praktisch auf jedem UNIX-System realisiert werden. Die Threads bewerben sich in diesem Fall innerhalb einer Anwendung um Ressourcen und Rechenzeit (PTHREAD_SCOPE_PROCESS). Bei der User-Level-Realisierung taucht das bereits angesprochene Problem auf, daß beim Aufruf einer blockierenden Betriebssystemfunktion u.U. nicht nur der aufrufende Thread, sondern auch der Träger-Thread blockiert wird. Dadurch findet indirekt ebenfalls ein Wettbewerb um Ressourcen auf Systemebene statt, und ein wesentlicher Vorteil der User-Level-Realisierung geht verloren. Der POSIX-Standard schreibt aus diesem Grund vor, daß bestimmte und häufig benutzte blockierende Funktionen wie z.B. pause(), wait(), write(), open(), read() oder sleep() auch im Fall von PTHREAD_SCOPE_PROCESS nur den betroffenen Thread blockieren. Der Scheduler der User-Level-Implementierung erhält damit die Möglichkeit, einem anderen Thread desselben Adreßraums den Träger-Thread zuzuordnen. POSIX.4-konforme Systeme mit einer Kernel-Level-Implementierung können der Anwendung für jeden Thread die Wahl der Schedulingebene (SCOPE_PROCESS oder SCOPE_SYSTEM) überlassen. Dadurch entstehen Mischformen, bei denen in einer Anwendung gleichzeitig beide Thread-Formen entsprechend ihren jeweiligen Vorteilen eingesetzt werden. Teil des POSIX.4-Standards ist auch die Nutzung von Multiprozessorsystemen in pthread-basierten Anwendungen. Eine bestimmte Menge an Prozessoren (Allocation Domain) wird dabei als Einheit bezüglich des Schedulings aufgefaßt. Aufgrund der Komplexität des Multiprozessor-Schedulings bei kooperierenden Threads ist der Standard an dieser Stelle noch sehr unspezifisch.
6
Speicherbasierte Prozeßinteraktion
Bei dieser Form der Prozeßinteraktion interagieren Prozesse über gemeinsam zugreifbare Speicherzellen. Die Voraussetzungen dafür sind grundsätzlich bei den im Kapitel 3 vorgestellten Laufzeit-Basismodellen A, C und D erfüllt. Eine elementare Aufgabe stellt das exklusive Sperren einer Speicherzelle durch konkurrierende Prozesse dar. Wir setzen zu diesem Zweck die beiden Operationen
Exklusives Sperren einer Speicherzelle
Sperren (SpeicherAdr) Freigabe (SpeicherAdr)
voraus. Prozesse, die sich um den Besitz derselben- Speicherzelle bewerben, benutzen diese Operationen gewöhnlich in einem kritischen Abschnitt wie untenstehend gezeigt:
Die zur Synchronisation benutzte Speicherzelle wird hier als Sperrflag bezeichnet. Es sei angenommen, daß das Sperrflag im Initialzustand frei ist. In ihrem Zusammenspiel müssen die Operationen Sperren() und Freigabe() garantieren, daß die folgende Invariante zu jedem Zeitpunkt erfüllt ist: Es existiert höchstens ein Besitzer eines Sperrflags, d.h., im kritischen Abschnitt darf sich höchstens ein Prozeß aufhalten.
Kritischer Abschnitt
Sperrflag
Sperren() und Freigabe ()
6 Speicherbasierte Prozeßinteraktion
Ein belegtes Sperrflag blockiert nachfolgende Prozesse
Das bedeutet, daß bei zeitlich überlappter Ausführung der Sperren-Operation auf dasselbe freie Sperrflag durch n Prozesse genau ein Prozeß die Operation erfolgreich beenden und damit in seinen kritischen Abschnitt eintreten kann; alle anderen n-1 Prozesse werden auf unbestimmte Zeit in der Operation verzögert. Das Sperrflag ist dann belegt. Durch die Ausführung der Freigabe-Operation wird ein in der Sperren-Operation verzögerter Prozeß fortgesetzt. Er kann damit in den kritischen Abschnitt eintreten und gilt bis zur Ausführung der Freigabe-Operation als neuer Besitzer des Sperrflags. Eine Freigabe-Operation setzt das Sperrflag wieder in den Zustand f r e i , wenn kein weiterer Prozeß den Eintritt in den kritischen Abschnitt wünscht. Um eine faire Behandlung aller Prozesse zu garantieren, sollten alle verzögerten Prozesse in der zeitlichen Reihenfolge bedient werden, in der sie die Sperren-Operation aufgerufen haben. In Abbildung 6-1 ist die synchronisierende Wirkung der Sperren-Operation am Beispiel zweier Prozesse dargestellt, die zeitgleich Zutritt zum kritischen Abschnitt suchen.
Abb. 6-1 Prozeßsynchronisation mittels kritischer
Beispiel: Zugriff auf exklusiv nutzbare Ressourcen
Eine naheliegende Anwendung der Operationen Sperren und Freigabe im Rahmen eines kritischen Abschnitts ist der exklusive Zugriff auf seriell benutzbare Ressourcen wie z.B. einen Drucker, wobei jede Ressource durch ein Sperrflag repräsentiert wird [Dijkstra 1968].
6
Speicherbasierte Prozeßinteraktion
Sperren(SpeicherAdr); /* SpeicherAdr repräsentiert eine Ressource *Benutzung der Ressource; Freigabe(SpeicherAdr);
Mit der Synchronisation von Zugriffen auf Ressourcen sind die Anwendungsmöglichkeiten kritischer Abschnitte jedoch keineswegs erschöpft. So stellen sie auch das geeignete Mittel dar, um den Zugriff von Prozessen auf gemeinsame Daten im Speicher zu synchronisieren. Das folgende Beispiel zeigt, warum hier eine Synchronisation der beteiligten Prozesse notwendig ist. Gegeben sei ein Platzbuchungssystem für Flüge. Alle Sitzplätze eines Flugzeugs sind in einem Feld Sitz
Platz[Anzahl];
organisiert, wobei Sitz eine Datenstruktur ist, die für jeden Sitzplatz den Status (frei oder belegt) und den Namen eines Passagiers im Falle der Sitzbelegung enthält: typedef struct{ enum Status{frei, belegt}; char Kunde[25] } Sitz;
In einer weiteren Variablen int Freiplätze = Anzahl;
wird die Anzahl der freien Plätze in einem Flugzeug gespeichert. Sie wird am Anfang mit einem positiven Wert initialisiert. Es sei nun angenommen, daß k Prozesse, die jeweils ein Terminal bedienen, auf die oben definierten Variablen gemeinsam zugreifen, um unabhängig voneinander Platzbuchungen vorzunehmen. Jeder Prozeß durchläuft dabei die folgenden Anweisungen: PROCESS { int I; LOOP { [1] Warte auf Signal von Terminal; [2] if(Freiplätze>0){ [3] I = SuchePlatz () ; [4] Platz [I] .Status = belegt; [5] Platz [I] .Kunde = ReadName() ; [6] Freiplätze --; [7] Print (I) ; [8] } else Print ("Flugzeug belegt");
} } Wir betrachten nun zwei Buchungsprozesse A und B, die zeitlich überlappt die Anweisungen 1-8 durchlaufen. Prozeß A möchte den Kunden K. Müller und Prozeß B den Kunden M. Zink einbuchen. Die Abbildung 6-2a zeigt die angenommene Ausgangslage. Die Abbildung
Beispiel: Platzbuchungssystem für Flüge
6
Zeitlich verzahnte Anweisungsfolgen von A und B
Inkonsistenter Datenbestand
Speicherbasierte Prozeßinteraktion
6-2b bis Abbildung 6-2d zeigen das Ergebnis bei verschiedenen zeitlichen Verzahnungen der Anweisungsfolgen von A und B, und zwar
b) A1...A8,B1...B8 c) B1...B8,A1...A8 d) A1, B1, A2, B2, A3, B3, A4, B4, A5, B5, A6, B6, A7, B7, A8, B8 Während die Ergebnisse von b) und c) korrekt sind, fand in d) offenbar eine Doppelbelegung des Sitzplatzes 7 statt, wobei die Buchung des Kunden K. Müller durch M. Zink überschrieben wurde. Der Zähler Freiplätze täuscht dagegen zwei zusätzliche Buchungen vor und stimmt mit der tatsächlichen Zahl freier Sitzplätze nicht mehr überein. Die Anweisungsfolge d) hat demnach einen inkonsistenten DatemSestand hinterlassen. Der Grund ist in dem nahezu gleichzeitigen Zugriff der Prozesse auf die Zustandsinformation des Platzes 7 zu suchen. Er führt in beiden Prozessen zu der (falschen) Schlußfolgerung, daß der Platz 7 noch frei ist.
Abb. 6-2 Sitzplatz-Belegungen eines Flugzeugs bei verschiedenen zeitlichen Überlappungen zweier Buchungsprozesse
Konsistenzprobleme der geschilderten Art sind immer dann zu erwarten, wenn Zugriffe auf gemeinsam benutzte Daten durch die beteiligten Prozesse unkoordiniert erfolgen. Für die korrekten Ergebnisse der Anweisungsfolgen b) und c) gibt es eine simple Erklärung: Unterstellt man, daß die Anweisungsfolge 18 einen korrekten Algorithmus darstellt (den man dann als Berechnung bezeichnet), dann hinterläßt jede Ausführung dieses Algorithmus einen konsistenten Folgezustand, vorausgesetzt der Initialzustand war konsistent. Jede Folge von Berechnungen muß dann ebenfalls in
6 Speicherbasierte Prozeßinteraktion
einem konsistenten Endzustand münden. Daraus folgt, daß durch die Serialisierung von unabhängigen Berechnungen, die auf dieselben Daten zugreifen, automatisch ein korrektes Ergebnis erzielt wird. Hoare [Hoare 1972] hat diese Überlegungen formalisiert und ein Axiom angegeben, das die Bedingungen für das Entstehen korrekter Ergebnisse bei parallelen Berechnungen definiert:
Das heißt, wenn I0 ein konsistenter Ausgangszustand und Il, I2 konsistente Folgezustände nach serieller Ausführung der Berechnungen a und b darstellen, dann dürfen bei beliebiger zeitlicher Überlappung von a und b nur die Endzustände Il oder I2 entstehen. Eine Möglichkeit zur Erreichung dieses Zieles ist die Zwangsserialisierung konkurrierender Berechnungen durch kritische Abschnitte. Ein kritischer Abschnitt hat dann den folgenden Aufbau:
Serialisierung
Zwangsserialisierung durch kritische Abschnitte
Sperren(SpeicherAdr); Berechnung; /* Zugriff auf die gemeinsamen Daten */ Freigabe(SpeicherAdr);
Das Sperrflag ist in diesem Fall ein Repräsentant der gemeinsamen Daten, auf die in konkurrierenden Berechnungen zugegriffen wird. Mit kritischen Abschnitten können jedoch nicht nur Konkurrenzsituationen zwischen Prozessen aufgelöst werden, sie bilden auch eine geeignete Basis zur Prozeßkooperation. Angenommen, es existieren zwei Berechnungen read() und write(), die auf einen Puffer, der den gemeinsam benutzten Datenbereich bildet, zugreifen. Zwei Prozesse A und B können dann über den Puffer mittels kritischer Abschnitte wie folgt kommunizieren:
Das Problem eines vollen Puffers in der write-Operation und leeren Puffers in der read-Operation wurde in der Darstellung bewußt ignoriert. In den nachfolgenden Abschnitten werden verschiedene Realisierungsformen der Operationen Sperren und Freigabe vorgestellt und
Kritische Abschnitte und Prozeßkooperation
6
Synchronisationsmechanismen
Speicherbasierte Prozeßinteraktion
Anwendungen diskutiert. Sie werden in der Literatur pauschal als Synchronisationsmechanismen bezeichnet, da sie primär zur Behandlung von Konkurrenzsituationen entwickelt wurden. In Abschnitt 6.1 werden Mechanismen behandelt, die mit Ausnahme atomarer Speicheroperationen keine weitere Unterstützung benötigen. Hardwaregestützte Synchronisation wird in Abschnitt 6.2 diskutiert. Der in Abschnitt 6.3 vorgestellte Semaphor-Mechanismus benötigt zusätzlich Betriebssystemunterstützung. Monitore (Abschnitt 6.4) stellen einen sprachgestützten Ansatz zur Synchronisation dar. In Abschnitt 6.5 wird abschließend eine Übersicht über die in verschiedenen Betriebssystemen angebotenen Möglichkeiten der speicherbasierten Prozeßinteraktion und der dabei einsetzbaren Synchronisationshilfsmittel gegeben.
6.1
Aktive Warteschleife (Busy Waiting)
Mechanismen auf der Basis atomarer Speicheroperationen
//
Die hier vorgestellten Lösungen setzen außer atomaren Speicheroperationen für das Lesen und Schreiben von Speicherzellen keine weitere Unterstützung voraus. Sie basieren auf der Idee, einen Prozeß so lange in einer aktiven Warteschleife in der Sperren-Operation zu verzögern, bis das Sperrflag freigegeben wird. Die unten aufgeführte inkorrekte Lösung zeigt, daß die Problematik schwieriger ist als zunächst vermutet. enum { frei, belegt } Sperrflag = frei; void Sperren () { while (Sperrflag == belegt) NOP; Sperrflag = belegt;
} void Freigabe () { Sperrflag = frei;
} Mehrere direkt aufeinanderfolgende Lesezugriffe auf das Sperrflag durch konkurrierende Prozesse haben zur Folge, daß sie alle das Sperrflag im Zustand frei antreffen. Sie belegen anschließend das Sperrflag und treten damit gleichzeitig in den kritischen Abschnitt ein. Die nachfolgend vorgestellten Algorithmen werden anhand zweier konkurrierender Prozesse A, B diskutiert. Eine Verallgemeinerung auf n Prozesse ist prinzipiell möglich ([Dijkstra 1968], [Peterson und Silberschatz 1985]).
6.1
Mechanismen auf der Basis atomarer Speicheroperationen
Rotierende Berechtigung Die Grundidee bei diesem Algorithmus basiert auf der vollständigen Vermeidung von Wettbewerbssituationen zwischen konkurrierenden Prozessen. In dem Sperrflag Turn wird zu diesem Zweck festgehalten, welcher Prozeß aktuell das Recht hat, in einen kritischen Abschnitt einzutreten. Die Eintrittsberechtigung wird jeweils am Ende eines kritischen Abschnitts an den nächsten Prozeß weitergereicht. Prozesse bilden damit einen logischen Ring, in dem das Eintrittsrecht unter den konkurrierenden Prozessen ständig zirkuliert. Für zwei Prozesse A, B können dann die Algorithmen für Sperren und Freigabe wie folgt beschrieben werden:
T u r n = Wer darf den kritischen Abschnitt betreten?
Processld Turn = A; void Sperren ( Processld Who ) { while (Turn != Who) NOP;
} void Freigabe ( Processld Who ) { if (Who == A) Turn = B eise Turn = A;
} Obwohl diese Implementierung die korrekte Synchronisation der beteiligten Prozesse gewährleistet, weist sie doch einige schwerwiegende Nachteile auf: 1. Die beteiligten Prozesse müssen bekannt und fest sein. 2. Durch den Algorithmus wird eine deterministische Reihenfolge festgelegt, in der die Prozesse in den kritischen Abschnitt eintreten dürfen. Dies widerspricht aber den Erfordernissen einer typischen Konkurrenzsituation. Nimmt ein Prozeß das an ihn weitergereichte Eintrittsrecht nicht wahr, dann können nachfolgende Prozesse mit einem vorliegenden Eintrittswunsch für lange Zeit verzögert werden.
Problematik: Prozeß nimmt Eintrittsrecht nicht wahr
Dekker-Algorithmus Dieser mündlich überlieferte Algorithmus [Dijkstra 1968] behält zwar das Prinzip der rotierenden Berechtigung bei, wendet es aber lediglich bei Vorliegen einer aktuellen Konkurrenzsituation unter den konkurrierenden Prozessen an. Ansonsten können Prozesse sofort in den kritischen Abschnitt eintreten. Zusätzlich zum Sperrflag Turn wird ein Feld benötigt, in dem jeder Prozeß sein Interesse an einen Eintritt in den kritischen Abschnitt bekundet.
Rotierende Berechtigung dient der Auflösung einer Konkurrenzsituation
6 Speicherbasierte Prozeßinteraktion
ProcessId Turn = A; enum { yes, no } Interest[2]; void Sperren ( Processld who ) { Processld Other; if (Who == A) Other = B else Other = A; while (true) { Interest[Who] = yes; repeat if (!Interest[Other]) goto fertig; unt if (Turn != Who); Interest[Who] = no; while (Turn == Other) NOP;
} fertig: ;
} void Freigabe ( Processld Who ) { if (Who == A) Turn = B else Turn = A; Interest[Who] = no;
} Zur Wirkung der Algorithmen hier noch einige Erläuterungen: Der Eintritt in den kritischen Abschnitt für einen Prozeß P ist nur möglich, wenn a) er sein Interesse bekundet hat und b) kein Interesse bei allen anderen Prozessen festgestellt wurde (Verlassen der äußeren Schleife über die goto-Anweisung).
T u r n gibt an, wer in einer Konkurrenzsituation den Vorzug erhält
Diese Eintrittsbedingung garantiert, daß alle potentiell konkurrierenden Prozesse von dem Interesse von P erfahren haben. Die Austrittsbedingung der inneren Schleife sorgt dafür, daß alle Prozesse ihr Interesse aufgeben, die in Turn die Identifikation eines anderen Prozesses vorfinden. Der Prozeß, der in Turn seine eigene Identifikation vorfindet, hält jedoch sein Interesse aufrecht: Er wird dadurch bevorzugt in den kritischen Abschnitt hineingelassen, sobald der im kritischen Abschnitt operierende Prozeß diesen freigegeben hat. In der abschließenden while-Schleife werden alle Prozesse verzögert, die in Turn nicht ihre eigene ProcessId finden. Durch Weiterreichen des Eintrittsrechts in der Freigabe-Operation wird damit immer nur ein Prozeß aus der while-Schleife entlassen, der damit als nächster den kritischen Abschnitt betritt. Peterson-Algorithmus In [Peterson und Silberschatz 1985] wird eine vereinfachte Version des Dekker-Algorithmus angegeben, die auf das Prinzip der rotierenden Berechtigung ganz verzichtet:
6.2 Hardwaregestützte Mechanismen
ProcessId Turn = A; enum { yes, no } Interest[2]; void Sperren ( Processld Who ) { Processld Other; if (Who == A) Other = B else Other = A; Interest[Who] = yes; Turn = Other; while (Interest[Other] and (Turn != Who)) NOP;
} void Freigabe ( Processld Who ) { Interest[Who] = no;
} Bei einer aktuellen Konkurrenzsituation der Prozesse soll derjenige als Sieger hervorgehen, der zuerst sein Interesse bekundet und die Variable Turn gesetzt hat. Bei zwei Prozessen A, B muß deshalb derjenige Prozeß in der while-Schleife verzögert werden, der Turn=Who bei bekundetem Interesse des anderen Prozesses feststellt (er hat dann offenbar Turn als letzter gesetzt).
Voraussetzung: Atomarität und Zwangssehalisierung einzelner Lese- und Schreiboperationen
Zusammenfassung Neben den bereits mehrfach aufgezählten Schwächen haben alle bisher diskutierten Algorithmen - wie bereits einleitend erwähnt - den Nachteil, daß sie nichtblockierende (d.h. aktive) Wartestellungen für ausgesperrte Prozesse realisieren und damit Prozessorzyklen nutzlos vergeuden.
6.2
Aktives Warten verschwendet nutzbare CPU-Zyklen
Hardwaregestützte Mechanismen
Eine denkbar einfache Realisierung der Operationen Sperren und Freigabe erhält man mit Unterstützung des Unterbrechungsmechanismus. Mittels zweier Prozessorinstruktionen Disable und Enable können Instruktionssequenzen zu einer unteilbaren (atomaren) Operation nach untenstehendem Muster zusammengefaßt werden:
Sperren von Interrupts
6
Atomarität durch Interruptsperren
Speicherbasierte Prozeßinteraktion
Die Atomarität der zwischen Disable . . . Enable eingeschlossenen Instruktionen wird durch eine Unterbrechungssperre garantiert, die lediglich durch Hardwarefehler außer Kraft gesetzt werden kann. Das Abprüfen und Modifizieren des Sperrflags kann nun unterbrechungsgeschützt durchgeführt werden: enum {frei, belegt} Sperrflag; void Sperren() { Disable; while (Sperrflag==belegt) { Enable; NOP's; Disable;
} Sperrflag=belegt; Enable;
} void Freigabet) { Sperrflag=frei;
} Problemkreise: Aktives Warten und Monopolisierung
Atomarer Leseund Schreibzyklus
Die vorgestellte Lösung ist jedoch nicht alleine wegen des aktiven Wartens für Anwendungsprozesse ungeeignet, sie bietet außerdem keinen Schutz gegen die Monopolisierung des physischen Prozessors. Bei Mehrprozessorsystemen versagt die vorgestellte Lösung ebenfalls, da die Unterbrechungssperre bei einem Prozessor nicht ausreicht, um den exklusiven Zugriff auf das Sperrflag durch verschiedene Prozessoren sicherzustellen. Eine spezielle Hardware-Instruktion Lock schafft hier Abhilfe. Sie erlaubt das atomare Abprüfen und Setzen des Sperrflags in zwei Speicherzyklen: typedef enum {frei, belegt} LockByte; LockByte Sperrflag; LockByte Lock() { LockByte Temp = Sperrflag; Sperrflag = belegt; return Temp;
} Damit können die Operationen Sperren und Freigabe wie folgt implementiert werden: void Sperren() { while (Lock() == belegt) NOP;
} void Freigabe() { Sperrflag = frei;
}
6.3
Betriebssystemgestützter Mechanismus: Semaphore
Diese Konstruktion wird auch als Spinlock bezeichnet. Spinlocks sollten grundsätzlich nur dann eingesetzt werden, wenn die kritischen Abschnitte aus wenigen ausgeführten Instruktionen bestehen und die Wartezeiten für Prozessoren damit kurz bleiben. In bestimmten Bereichen der Systemsoftware wie z.B. der Prozessorzuteilung bei Mehrprozessorsystemen ist die Verwendung von Spinlocks zwingend erforderlich. Auf Anwendungsebene sollten Spinlocks abgesehen von der Synchronisation eng gekoppelter Thread-Gruppen auf einem Multiprozessorsystem vermieden werden. Anwendungstaugliche Synchronisationsmechanismen blockieren die Verlierer einer Konkurrenzsituation und schalten den Prozessor auf einen ablaufbereiten Thread um. Auf diesen Grundgedanken basieren die im folgenden Abschnitt vorgestellten Mechanismen.
6.3
Spinlock
Schnelle Synchronisation bei Multiprozessoren
Betriebssystemgestützter Mechanismus: Semaphore
Betriebssystemunterstützung für Prozeßsynchronisation ist unumgänglich, wenn Wartesituationen durch Thread-Blockaden und nicht durch aktive Warteschleifen in den Prozessen realisiert werden sollen. Die Operationen Sperren und Freigabe müssen dann direkt auf die im Zustandsmodell definierten Thread-Zustände einwirken.
Blockierende Synchronisationsverfahren
Stellvertretend für verschiedene, in der Literatur vorgestellte Varianten blockierender Synchronisationsmechanismen, wird hier der Semaphor-Mechanismus detailliert behandelt. 6.3.1
Das Konzept
Der Semaphor-Mechanismus stammt von Dijkstra [Dijkstra 1968]. Sperrflags heißen dort in Anlehnung an Signalmasten - z.B. in der Schiffahrt oder in Eisenbahnnetzen - Semaphore. Die Operationen Sperren und Freigabe werden mit P (Passieren) bzw. V (Verlassen) bezeichnet und suggerieren den Eintritt in bzw. Austritt aus einem kritischen Abschnitt. Ein Semaphor besteht aus einem nicht negativ initialisierten Zähler und einer eventuell leeren Schlange mit Verweisen auf Threads, die Eintritt in den kritischen Abschnitt wünschen, aber aufgrund einer aktuellen Konkurrenzsituation vorübergehend blockiert wurden (siehe Abbildung 6-3). Die Operationen P und V lassen sich grob durch die untenstehenden Algorithmen beschreiben, für die atomare Realisierungen existieren müssen.
P- und V-Operation
6
Gemeint ist der dem Aufruf von P() folgende Zustand des Threads Ta .
Speicherbasierte Prozeßinteraktion
void P (Semaphor S) { Zähler(S)--; if (Zähler(S)BLOCKIERT*/ Schlange(S) = Schlange(S) bereiten Thread Tb wählen; /*BEREIT-> RECHNEND*/ Zustand von Tb laden;
} } void V(Semaphor S) { if (Zähler(S)BEREIT*/
} Zähler(S)++;
} Abb. 6-3 Struktur eines Semaphors
Funktionsweise von P und V
Jede p-Operation löst demnach bei einem Zählerstand des Semaphors kleiner oder gleich 0 eine Blockade des aktuellen Threads Ta und die zwangsläufige Umschaltung auf einen anderen ablaufbereiten Thread T b aus. Die V-Operation befreit bei negativem Zähler einen an dem Semaphor S blockierten Thread aus seiner Blockade und schickt ihn wieder in den Wettbewerb um Zuteilung eines Prozessors. Üblicherweise wird die mit einem Semaphor assoziierte Schlange durch eine FIFOStrategie verwaltet, d.h., blockierte Threads werden in der zeitlichen Reihenfolge bedient, in der sie Zugang zum kritischen Abschnitt gesucht haben. Anforderungen an den Echtzeitbetrieb können es jedoch erforderlich machen, von dieser Regel abzuweichen (siehe hierzu Abschnitt 6.3.4). Die Zustandswechsel »Rechnend«—>»Blockiert« und »Bereit«—> »Rechnend« für einen Thread werden begleitet durch ein Einfrieren der momentanen Registerinhalte bzw. deren Restaurierung. Der ifZweig der P-Operation beschreibt demnach einen vollständigen Pro-
6.3
Betriebssystemgestützter Mechanismus: Semaphore
zessorwechsel, während im if-Zweig der V-Operation lediglich die Deblockade eines blockierten Threads erfolgt. Aktuelle Implementierungen beider Funktionen müssen auch ihre logische Unteilbarkeit garantieren, sonst resultieren ähnliche Konsistenzprobleme wie in Abschnitt 6.1 diskutiert. Dazu werden in Abschnitt 6.3.3 verschiedene Möglichkeiten aufgezeigt. 6.3.2
Beispiele mit Semaphoren
Der Gebrauch des Semaphor-Mechanismus wird nachfolgend anhand einiger klassischer Synchronisations- bzw. Kommunikationsaufgaben demonstriert. 1. Beispiel: Einfacher kritischer Abschnitt Zwei Prozesse A und B konkurrieren um den Eintritt in einen kritischen Abschnitt. Es wird ein Semaphor benötigt, das zu 1 initialisiert wird.
Einfacher kritischer Abschnitt
2. Beispiel: Erzeuger-Verbraucher-System Zwei Prozesse kommunizieren über einen gemeinsamen Puffer. Der Erzeuger füllt den Puffer, während der Verbraucher den Pufferinhalt konsumiert. Die beiden Semaphore leer und voll werden benötigt, um zwischen den Prozessen den Eintritt der Ereignisse »Puffer ist leer« und »Puffer ist voll« zu melden. Anders als bei der Konstruktion kritischer Abschnitte führt in einer Erzeuger-Verbraucher-Konstellation der Erzeuger die V-Operation und der Verbraucher die P-Operation auf dasselbe Semaphor aus. Damit werden Synchronisationsereignisse produziert und konsumiert. Der untenstehende Algorithmus garantiert außerdem, daß sich Erzeuger und Verbraucher in der Benutzung des Puffers abwechseln.
Erzeuger-VerbraucherSystem
6
Speicherbasierte Prozeßinteraktion
3. Beispiel: Verwaltung gleichartiger Betriebsmittel
Betriebsmittelverwaltung
Zwei Prozeduren GetDisc und PutDisc regeln den Zugriff zu einem Pool gleichartiger Betriebsmittel, in unserem Fall Diskettenlaufwerke. Prozesse, die mittels GetDisc ein Diskettenlaufwerk anfordern, sollen nur dann in der Operation blockiert werden, wenn alle Laufwerke belegt sind. Zur Lösung des Problems wird ein Semaphor DiscSem benötigt, dessen Zähler mit der Anzahl n verfügbarer Diskettenlaufwerke initialisiert wird. Ein weiteres Semaphor Mutex dient dazu, die Auswahl eines beliebigen Laufwerks unter wechselseitigem Ausschluß von maximal n konkurrierenden Prozessen durchzuführen. Semaphore DiscSem=N, Mutex=l; enum {frei,belegt} Disc[N] = frei; int GetDisc { int i ; P(DiscSem); P(Mutex); i = l; while (Disc[i]==belegt) i + +; Disc[i]=belegt; V(Mutex); return i;
} void PutDisc(int i) { P(Mutex); Disc [i]=frei; V(Mutex); V(DiscSem) ;
} Ein Prozeß benutzt die Prozeduren GetDisc und PutDisc wie in einem kritischen Abschnitt:
6.3
Betriebssystemgestützter Mechanismus: Semaphore
4. Beispiel: Reader-Writer-Problem (1) Zwei Klassen von Prozessen bearbeiten einen gemeinsamen Datenbereich. Prozesse aus der einen Klasse - genannt die Writer - modifizieren den Datenbereich und beanspruchen deshalb exklusiven Zugriff. Prozesse aus der zweiten Klasse - genannt die Reader - lesen lediglich den Inhalt des Datenbereichs. Sie können diese Funktion parallel zu allen anderen Reader durchführen. Für dieses Problem existieren zwei Lösungen [Courtois et al. 1971].
Reader-Writer: Bevorzugung der Reader
int Readcount=0; Semaphor W, Mutex=l; PROCESS Reader { P(Mutex); Readcount++; if (Readcount==1) P(W); V(Mutex); Lese Daten; P(Mutex); Readcount--; if (Readcount==0) V(W); V(Mutex); } PROCESS Writer { P(W) ; Modifiziere Daten V(W) ; }
5. Beispiel: Reader-Writer-Problem (2) In der zweiten Lösung zu diesem Problem wird dem Schreiben Priorität über das Lesen gegeben: Sobald ein Writer Zutritt zum kritischen Abschnitt erlangt, wird er ihm zum frühestmöglichen Zeitpunkt gestattet. Nachfolgenden Reader wird deshalb der Eintritt in den kritischen Abschnitt erst dann wieder ermöglicht, wenn ein aktueller
Reader- Writer: Bevorzugung der Writer
6
Speicherbasierte Prozeßinteraktion
Lösung mit
Writer den kritischen Abschnitt verlassen hat und sich keine weiteren Writer um Zugriffe zum kritischen Abschnitt bewerben. Zur Lösung des Problems werden fünf Semaphore und zwei Zäh-
fünf Semaphoren
ler Readcount und Writecount zum Zählen der Reader und Writer
Semaphor R erzwingt die Writer-Bevorzugung
Mutex3 stellt sicher, daß maximal ein Reader einem Writer zuvorkommt
benötigt. Das Semaphor W hat dieselbe Bedeutung wie in der vorangegangenen Lösung: Es dient der Realisierung des kritischen Abschnitts, in dem die eigentlichen Datenzugriffe erfolgen. Durch die Semaphoren Mutex1 und Mutex2 wird dafür gesorgt, daß die Zähler Readcount und Writecount durch nur einen Prozeß zu einer Zeit modifiziert werden. Durch das Semaphor R wird die Priorität der Writer über die Reader realisiert: der durch P(R)...V(R) definierte kritische Abschnitt, um den sich Reader und Writer gleichermaßen bewerben, wird von einem Writer erst dann wieder freigegeben, wenn kein weiterer Writer Eintritt in den kritischen Abschnitt verlangt. Im anderen Falle wird das Exklusivrecht von Writer zu Writer weitergereicht. Durch das Semaphor Mutex3 schließlich wird erreicht, daß sich alle Reader nacheinander um den Eintritt in den kritischen Abschnitt bewerben. Bei einer aktuellen Wettbewerbssituation zwischen mehreren Reader und einem Writer wird durch Mutex3 sichergestellt, daß der Writer im ungünstigen Fall einem Reader den Vortritt lassen muß. Mit diesen Erläuterungen können wir die Lösung für das 2. Reader- Writer-Problem wie folgt angeben: int Readcount, Writecount=0; Sempaphor Mütexl, Mutex2, Mutex3, W,R=1;
PROCESS Reader { P(Mutex3); P(R) ; P(Mutexl); Readcount++; if (Readcount==l) P(W); V(Mutexl); V(R) ; V(Mutex3); Lese Daten; P(Mutexl); Readcount--; if (Readcount==0) V(W); V(Mutexl); } PROCESS Writer { P(Mutex2); Writecount++; if (Writecount==l) P(R); V(Mutex2), P(W) ;
6.3
Betriebssystemgestützter Mechanismus: Semaphore
Modifiziere Daten; V(W) ; P(Mutex2); Writecount--; if (Writecount==0) V(R); V(Mutex2);
}
6. Beispiel: Bedingte kritische Abschnitte Häufig taucht bei kritischen Abschnitten das Problem auf, daß der Eintritt von einer Bedingung abhängig gemacht wird, die als Prädikat über den durch den kritischen Abschnitt geschützten Daten definiert ist. Hoare [Hoare 1972] hat diese Situation durch eine Sprachkonstruktion erfaßt: with D when B do S;
wobei D die im kritischen Abschnitt bearbeiten Daten, B eine auf D definierte Eintrittsbedingung und S die Anweisungsfolge darstellen. Eine naive Lösung mittels eines Semaphors Mutex sähe wie folgt aus:
Bedingter kritischer Abschnitt
Semaphore Mutex=l; P(Mutex); while (!B) { V(Mutex); Nop; P(Mutex); } Datenzugriff; V(Mutex);
Dadurch, daß die Auswertung der Bedingung selbst im kritischen Abschnitt erfolgen muß, entsteht zwangsläufig eine aktive Warteschlange (Busy Waiting) - eine Situation, die eigentlich durch die blockierende P-Operation vermieden werden sollte. Im schlimmsten Fall können solche Situationen zum Aushungern (Starvation) aller anderen Prozesse führen, nämlich dann, wenn durch unfaires Thread-Scheduling ein Prozessorwechsel verhindert wird. Eine bessere Strategie ist es, einen Prozeß bei Nichterfüllung der Bedingung B so lange an einem zweiten Semaphor zu blockieren, bis die Daten durch einen anderen Prozeß modifiziert wurden. Erst dann liegt die Notwendigkeit für eine Neuauswertung der Bedingung vor. Die Lösung erfordert zwei Semaphore und einen Zähler. Durch das Semaphor Mutex wird der bedingte kritische Abschnitt betreten bzw. verlassen. Das Semaphor Condsem dient der Realisierung der Wartestellung von Prozessen, die ihre Bedingung nicht erfüllt vorfinden. Der
Aktives Warten
Nur ein anderer Prozeß kann zwischen zwei Überprüfungen die Bedingung herstellen
6
Speicherbasierte Prozeßinteraktion
Zähler waitcount enthält die Anzahl der Prozesse, die auf den Eintritt einer Bedingung warten. Semaphore Mutex=l, Condsem=0; int Waitcount=0; P(Mutex); while (!Bedingung) { Waitcount++; V(Mutex); P(Condsem); P(Mutex); } Datenzugriff; while (Waitcount>0) { Waitcount--; V(Condsem); } V(Mutex); Semaphorlösungen werden schnell komplex und sind daher fehleranfällig
Die Praxis im Umgang mit Semaphoren hat gezeigt, daß Lösungen sehr schnell komplex und damit fehleranfällig werden. In der Forschung wurde deshalb nach überzeugenden Alternativen zum Semaphor-Mechanismus gesucht, die leichter verständliche Lösungen für Synchronisationsprobleme hervorbringen. Eine Alternative stellt das Monitorkonzept dar, das in Abschnitt 6.4 beschrieben wird. 6.3.3
Bei KL-Threads sind die Pund V-Operationen Traps
Unteilbarkeit von P und V garantieren
Implementierungsaspekte
Ein Semaphor-Mechanismus entfaltet seine synchronisierende Wirkung durch adäquate Beeinflussung der Thread-Zustände. Es ist deshalb sinnvoll, ihn auf derselben Abstraktionsebene anzusiedeln wie die Thread-Realisierung, um die enge Verflechtung beider Mechanismen möglichst effizient zu bewerkstelligen. Das bedeutet, daß bei Threads, die durch Multiplexen physischer Prozessoren in einem nur über den Trap-Mechanismus zugänglichen Kern realisiert wurden (KL-Threads, vgl. Kapitel 5), auch der Semaphor-Mechanismus dort zur Verfügung gestellt werden sollte. Die in Abschnitt 6.3.1 skizzierten Operationen P und V sind dann Trap-Routinen. Die Traps werden durch Laufzeitroutinen aktiviert, die im Adreßraum der Prozesse liegen und mit den dort befindlichen Programmen zusammengebunden werden. Die Unteilbarkeit der Operationen p und v wird bei Monoprozessorsystemen durch die verdrängungsfreie Ausführung aller Prozesse realisiert, die gerade eine Trap-Routine im Kern abwickeln. In Mehrprozessorsystemen ist zusätzlich die Klammerung aller Code-
6.3 Betriebssystemgestützter Mechanismus: Semaphore
Sequenzen durch Spinlocks erforderlich, die im Kern auf gemeinsame Daten zugreifen. Die in den Algorithmen dargestellten Zustandswechsel werden im Kern durch einen Dispatcher realisiert, der das Multiplexen der physischen Prozessoren steuert und mit den Umschaltvorgängen auch das Einfrieren/Restaurieren aller Prozessorregister übernimmt (vgl. hierzu Kapitel 5). Handelt es sich dagegen um UL-Threads, dann wird der SemaphorMechanismus in einem Thread-Paket zusammen mit den Funktionen zur Erzeugung und Terminierung der Threads im Adreßraum der Prozesse bzw. Teams zur Verfügung gestellt (siehe Abbildung 6-4). Die Pund V-Operationen sind dann einfache Prozeduren, die die Thread-Synchronisation ohne Inanspruchnahme des Kerns durchführen.
Bei UL-Threads sind die Pund V-Operationen Teil des Thread-Packets
Abb. 6-4 Unterstützung von ULThreads durch ein ThreadPaket als Teil Adreßraumlokaler Laufzeitsysteme
Die Realisierung der Unteilbarkeit der P- und V-Operationen stellt in diesem Fall ein gewisses Problem dar und muß insbesondere mit der Situation fertig werden, die durch Thread-Preemption aufgrund von Interrupts wie z.B. Timer-Interrupts, entstehen kann (vgl. Kapitel 5). Es soll hier eine Lösung skizziert werden, die von genau einem kernbasierten Thread pro Adreßraum ausgeht. Sie basiert darauf, daß zu jedem Zeitpunkt feststellbar ist, ob ein Thread gerade eine Funktion des Adreßraum-lokalen Laufzeitsystems durchläuft. Durch Setzen eines Flags am Beginn jeder Laufzeitroutine und Rücksetzen vor dem Ende läßt sich diese Forderung leicht erfüllen. Wird nun ein Thread durch einen Interrupt - z.B. einen Timer-Interrupt - in den Exception-Modus versetzt, dann muß in der Signalbehandlungsroutine zunächst festgestellt werden, ob die Unterbrechung innerhalb des Laufzeitsystems geschah. Ist dies nicht der Fall, dann kann ohne Gefahr einer Konsistenzverletzung der aktive UL-Thread verdrängt »Rechnend«—>»Bereit« und auf einen neuen ablaufbereiten UL-
Unteilbarkeit von P und V im Fall von UL-Threads ist kritisch
Lösungsansatz bei nur einem KL-Träger-Thread pro Adreßraum
6
Speicherbasierte Prozeßinteraktion
Thread umgeschaltet werden »Bereit«—>»Rechnend«. Wurde der Thread jedoch im Laufzeitsystem unterbrochen, dann kann eine Konsistenzverletzung nicht ausgeschlossen werden. In der Signalbehandlungsroutine darf deshalb nur gespeichert werden, daß dieses Ereignis stattgefunden hat. Vor der Beendigung jeder Laufzeitroutine muß dann geprüft werden, ob Signale eingetroffen sind, die Preemption des UL-Threads bewirken sollen. Sofern dies der Fall ist, wird nun eine Umschaltung des UL-Threads eingeleitet. Damit dieser Vorgang in einer Signalbehandlung ohne Unterstützung des Kerns durchgeführt werden kann, muß der Zugriff auf die zum Interrupt-Zeitpunkt eingefrorenen Prozessorregister möglich sein. Dies läßt sich am einfachsten durch ihre Ablage auf dem Laufzeitkeller des Threads erreichen. 6.3.4
Erweiterungen für die Echtzeitverarbeitung
Bei Echtzeitanwendungen ist es wichtig, daß ablaufbereite Prozesse zeitgerecht abgewickelt werden. Sehr häufig dienen Prozeßprioritäten als Grundlage des Prozessorschedulings. Prozesse hoher Priorität haben dabei Vorrang vor Prozessen niedrigerer Priorität. Dieses Prinzip kann jedoch leicht verletzt werden, wenn die Prozesse um kritische Abschnitte konkurrieren. In Abbildung 6-5 ist eine Situation dargestellt, in der drei Prozesse P1, P 2 , P3 zeitlich versetzt über P(S) in einen kritischen Abschnitt eintreten wollen. Senkrechte Pfeile symbolisieren die Umschaltung des physischen Prozessors. Prozeß P1 hat die höchste, Prozeß P3 die niedrigste Priorität. Da P3 die p-Operation zuerst ausführt, gewinnt er das Rennen um den Eintritt in den kritischen Abschnitt. Aufgrund der zeitlichen Reihenfolge der P-Operationen wird als nächster Prozeß P2 den kritischen Abschnitt betreten, obwohl bereits bekannt ist, daß ein Prozeß mit höherer Priorität (P1) auf den Eintritt wartet. Abb. 6-5 Verletzung der Prozeßprioritäten durch kritische Abschnitte
6.4
Sprachgestützter Mechanismus: Monitore
Für den Echtzeitbetrieb ist es deshalb naheliegend, die Einreihung konkurrierender Prozesse in eine Semaphor-Schlange nicht an der zeitlichen Reihenfolge der Eintrittswünsche, sondern an der Prozeßpriorität zu orientieren. Damit geht zwar die Eigenschaft der Fairneß verloren, das Zeitverhalten des Systems wird jedoch planbarer. Damit sind jedoch noch nicht alle Probleme gelöst. Abbildung 6-6 zeigt eine Situation, in der ein Prozeß P2 einen Prozeß niedriger Priorität (P3) verdrängt, der in einem kritischen Abschnitt arbeitet. Das ist in der gezeigten Konstellation aber ungünstig, da ein Prozeß hoher Priorität (P1) auf den Eintritt in den kritischen Abschnitt wartet, nun aber durch P2 noch länger verzögert wird. Zur Vermeidung dieser Situation wird bei echtzeitfähigen Semaphor-Mechanismen oft das Prinzip der Prioritätsvererbung angewendet [Lampson und Redell 1980]. Es besagt, daß der im Besitz des kritischen Abschnitts befindliche Prozeß dynamisch die höhere Priorität des Prozesses erbt, der zu einem späteren Zeitpunkt den Eintritt in den kritischen Abschnitt sucht. Bei Verlassen des kritischen Abschnitts fällt der Prozeß wieder auf seine alte Priorität zurück. Damit ergibt sich ein gegenüber Abbildung 6-6 geänderter Verlauf der Prozeßabwicklung der zu einer Bevorzugung des Prozesses P1 führt (siehe Abbildung 6-7).
Einreihung in die Warteliste gemäß Priorität
Invertierung der Prioritäten
Prioritätsvererbung
Abb. 6-6 Zeitliche Verzögerung eines Prozesses P1 mit höherer Priorität in einem kritischen Abschnitt durch einen Prozeß P2
Abb. 6-7 Gegenüber Abb. 6-6 geänderter Ablauf der Prozesse durch Prioritätsvererbung von P1 an P3
6.4
Sprachgestützter Mechanismus: Monitore
Ausgehend von den Erfahrungen im Umgang mit dem SemaphorMechanismus haben sich Wissenschaftler in den 70er Jahren damit beschäftigt, für das Konzept des kritischen Abschnitts abstraktere Darstellungsformen zu entwickeln, die sich in Systemsprachen inte-
6
Sprachkonzept Monitor
Speicherbasierte Prozeßinteraktion
grieren lassen und eine übersichtlichere Strukturierung von Synchronisationsproblemen unterstützen. Breite Beachtung fand das MonitorKonzept, das in verschiedene Hochsprachen integriert wurde. Besonders hervorzuheben sind Erweiterungen der Sprachen Pascal [Brinch Hansen 1975], PL/I [Nehmer 1979] sowie die für Telekommunikationsanwendungen standardisierte Sprache CHILL [Sammer und Schwärtzel 1982]. Stellvertretend für alle sprachgestützten Synchronisationskonzepte wird deshalb das Monitor-Konzept nachfolgend vertieft behandelt. 6.4.1
Zusammenfassung der gemeinsamen genutzten Daten und der darauf
Das Konzept
Das Monitorkonzept wurde etwa gleichzeitig von Brinch Hansen [Brinch Hansen 1973] und Hoare [Hoare 1974] eingeführt. Es basiert auf der Idee, die in einem kritischen Abschnitt bearbeiteten Daten zusammen mit den darauf definierten Zugriffsalgorithmen in einer sprachlichen Einheit - dem Monitor - zusammenzufassen: MONITOR Monitorname (Parameter) Datendeklarationen; /* Gemeinsame Daten */
definierten Zugriffsfunktionen
ENTRY Funktionsnamel (Parameter) { Prozedurkörper } ENTRY Funktionsname2 (Parameter) { Prozedurkörper }
ENTRY FünktionsnameN (Parameter) { Prozedurkörper } INIT {Initialisierung} END
Die Initialisierung der Monitordaten erfolgt durch einmaligen Aufruf von Monitorname(aktuelle Parameter);
mit dem implizit der Initialisierungsteil durchlaufen wird. Prozesse benutzen den Monitor durch Aufrufe von Monitorprozeduren in der Form Monitorname.Funktionsname (aktuelle Parameter); Ähnlichkeiten zum Moduloder Klassenbegriff
Diese Konstruktion ist praktisch identisch mit dem Modulkonzept in Hochsprachen wie z.B. in MODULA2 oder dem Klassenbegriff objektorientierter Sprachen, erweitert es aber um eine Synchronisationssemantik. Sie besagt, daß sich Monitorprozeduren bei konkurrierendem Zugriff durch mehrere Prozesse wechselseitig ausschließen, d.h., Monitorprozeduren stellen kritische Abschnitte dar. Der erfolgreiche Aufruf einer Monitorprozedur ist gleichbedeutend mit der Sperre des Monitors, die bis zum Verlassen der Monitorprozedur bestehen bleibt.
6.4
Sprachgestützter Mechanismus: Monitore
Durch die konsequente Anwendung des Monitorkonzeptes resultieren gegenüber der Verwendung des Semaphor-Mechanismus zwei entscheidende Vorteile:
•
Gemeinsam durch mehrere Prozesse bearbeitete Daten werden explizit in der Programmstruktur sichtbar gemacht, da sie in Monitoren organisiert werden müssen. Die an der Schnittstelle bereitgestellten Monitorprozeduren definieren außerdem, welche Zugriffsalgorithmen zu den Monitordaten zulässig sind. Ein Umgehen der Prozedurschnittstelle des Monitors ist nicht erlaubt.
•
Monitore kapseln ebenso wie Module die gemeinsamen Daten ein. Solange die Prozedurschnittstelle eines Monitors nicht geändert wird, bleiben Änderungen der Monitor-internen Datenstrukturen und Algorithmen für Prozesse unsichtbar. Dieses bewährte Prinzip des information hiding begrenzt die Auswirkungen von lokalen Programmänderungen auf das gesamte Programm und erhöht damit die Änderungsfreundlichkeit der Programmstruktur.
Zur eleganten Formulierung bedingter kritischer Abschnitte können innerhalb eines Monitors sogenannte Condition-Variable deklariert werden:
Klare Programmstruktur
Kapselung der Datenstrukturen
Condition-Variable
Condition a;
Jede Condition-Variable steht für eine Bedingung (d.h. ein logisches Prädikat über den Monitordaten), die für die Fortsetzung eines Prozesses in einer Monitorprozedur erfüllt sein muß. Aus diesem Grunde verbirgt sich hinter einer Condition-Variablen eine einfache FIFOSchlange mit Zeigern auf Threads, die auf den Eintritt der Bedingung warten. Eine Wartestellung wird mittels der Operation Conditionvariable.WAIT
eingeleitet. Die Operation hat die folgende Wirkung: a) Der ausführende Thread wird blockiert. b) Ein Zeiger auf den Thread wird in die Schlange der ConditionVariablen aufgenommen. c) Unter den Zutritt suchenden Prozessen wird einer ausgewählt und sein Thread deblockiert. Existiert kein solcher Prozeß, dann wird der Monitor freigegeben.
Condition-Variablen stehen für anwendungsspezifische Bedingungen
Aufruf der W A I T ( ) -
Funktion, wenn die Bedingung für einen Prozeß nicht erfüllt ist
6
Monitor wird implizit verlassen
Aufruf der S I G N A L O -
Funktion, wenn ein Prozeß
Speicherbasierte Prozeßinteraktion
Die Monitorfreigabe erscheint zunächst unverständlich, ist aber notwendig, um anderen Prozessen die Möglichkeit zu geben, den Monitor zu betreten. Nur dadurch besteht die Chance, die Bedingungen irgendwann zu erfüllen, auf die Prozesse an Condition-Variablen warten. Vor Ausführung einer WAIT-Operation muß in einer Monitorprozedur ferner sichergestellt werden, daß sich die Monitordaten in einem konsistenten Zustand befinden. Wird durch die Ausführung einer Monitorprozedur eine Bedingung erfüllt, so wird dies mittels der Operation Conditionvariable.SIGNAL;
die Bedingung herstellt
signalisiert. Durch die SIGNAL-Operation wird bei nichtleerer Schlange der Condition-Variablen wenigstens ein Thread daraus entfernt und die Blockade dieses/dieser Threads aufgehoben. Da jedoch nur ein Prozeß zu einem Zeitpunkt in einem Monitor rechnen darf, stellt sich sofort die Frage, wer dies ist. Da der signalisierende Prozeß ja noch im Besitz des Monitors ist, gibt es offenbar verschiedene semantische Varianten für S I G N A L , die alle die Anforderungen erfüllen, daß a) wenigstens ein Thread die Blockade an der Condition-Variablen überwindet und b) nach Beendigung der SIGNAL-Operation höchstens ein Prozeß im Monitor rechnet. Signal-Varianten
Drei Varianten sollen anschließend näher behandelt werden. Sie weisen besondere Vorzüge hinsichtlich der Unterstützung bestimmter Anwendungsanforderungen auf. Signal-Variante I
Maximal 1 befreiter Prozeß, kein Besitzwechsel
Diese Variante beläßt den signalisierenden Prozeß im Monitorbesitz und veranlaßt, daß bei nichtleerer Schlange der Condition-Variablen ein Thread daraus entfernt wird, wobei der Prozeß allerdings gezwungen wird, sich erneut um den Zutritt zum Monitor zu bewerben. Signal-Variante II
Alle wartenden Prozesse befreien, kein Besitzwechsel
Diese Variante unterscheidet sich von Variante I nur dadurch, daß alle an der Condition-Variablen blockierten Threads aus ihrer Blockade befreit werden. Sie wird besonders vorteilhaft in Situationen eingesetzt, bei denen für mehrere Prozesse potentiell die Bedingungen für eine Fortführung nach einer Signalisierung eingetreten sind.
6.4 Sprachgestützter Mechanismus: Monitore
Signal-Variante III Diese Variante wurde von Hoare vorgeschlagen und basiert darauf, einen Besitzwechsel über den Monitor vom signalisierenden auf genau einen signalisierten Prozeß vorzunehmen, der seine Berechnung sofort im Monitor fortsetzen kann. Als Konsequenz dieser Strategie muß der signalisierende Prozeß den Monitor verlassen und sich um erneuten Zutritt bewerben. Der Vorteil dieser semantischen Variante liegt in der Gewißheit des signalisierten Prozesses, daß er nach seiner Befreiung aus der Blokkade die Bedingung für seine Fortführung vorfindet, da ja kein anderer Prozeß eine Möglichkeit hatte, zwischenzeitlich den Monitor zu betreten (vorausgesetzt, die Bedingung war bei Signalisierung erfüllt). Bei der Hoare'schen SIGNAL-Variante kann ein Prozeß deshalb mittels einer if-Anweisung den Eintritt in die Wartestellung einleiten:
Maximal 1 befreiter Prozeß, Besitzwechsel
Vorteile der Variante III
if (Bedingung=False) condvar.WAIT;
Da signalisierte Prozesse bei den SIGNAL-Varianten I und II diese Gewißheit nicht haben können (insbesondere kann ja auch der signalisierende Prozeß im Anschluß an die SIGNAL-Operation die Bedingung wieder invalidieren), muß dort die Einleitung einer Wartestellung mit einer while-Schleife beginnen: while (Bedingung=False) condvar.WAIT;
Das ist aber nicht weiter tragisch und hat zudem den Vorteil, daß fehlerhafte Signalisierungen nicht zu einer unbeabsichtigten Fortführung wartender Prozesse führen. Ein Nachteil der Variante III ist die hohe Anzahl von ThreadUmschaltungen im Zuge einer Signalisierung, die eine beträchtliche Minderung der Performanz bewirkt. So sind bis zur Fortsetzung des signalisierenden Prozesses zwei komplette Thread-Wechsel erforderlich. Da die SIGNAL-Anweisungen nach aller Erfahrung die letzten Anweisungen innerhalb einer Monitorprozedur bilden, erscheint dieser Aufwand besonders ungerechtfertigt, da der Monitor danach ohnehin freigegeben wird. Hoare [Hoare 1974] hat zur Lösung dieses Problems einige Realisierungsoptimierungen vorgeschlagen, auf die hier aber nicht näher eingegangen wird. Alle drei vorgestellten Varianten der SIGNAL-Operation teilen die Eigenschaft, daß sie bei leerer Warteschlange ohne Wirkung sind. Gelegentlich ist es nützlich, in einer Monitorprozedur den Zustand einer Condition-Variablen zu kennen. Dazu steht die Funktion int condvar.STATUS;
zur Verfügung, die im Rückgabeparameter die Länge der Schlange der betreffenden Gondition-Variablen bereitstellt.
Nachteil der Variante III
Signal auf einer leeren Condition-Variablen
6
Speicherbasierte Prozeßinteraktion
6.4.2
Beispiele mit Monitoren
Werden Monitore als Synchronisationshilfsmittel für die Zugriffssteuerung zu exklusiv benutzbaren Ressourcen verwendet, dann bietet es sich an, jede Ressource durch einen Monitor zu repräsentieren, dessen Prozeduren die erlaubten Zugriffsfunktionen definieren. Ein Monitor Disc zur Synchronisation von Plattenzugriffen könnte dann z.B. den unten gezeigten Aufbau haben: Beispiel: Betriebsmittelverwaltung
MONITOR Disc ENTRY Read (PlattenAdr,SpeicherAdr) {Lesevorgang durchführen} ENTRY Write (SpeicherAdr,PlattenAdr) {Schreibvorgang durchführen} INIT {Gerät in Initialzustand bringen} END
Die Lösung ist denkbar einfach, da sie ohne zusätzliche Monitorvariablen auskommt. Das Konzept der Condition-Variablen wird überhaupt nicht benötigt. Die Benutzung des Monitors durch Prozesse erfolgt einfach durch Aufrufe der Form Disc.Read(Von,Nach);
bzw. Disc.Write(Von,Nach);
Jeder Monitoraufruf realisiert dabei einen kritischen Abschnitt. Der wechselseitige Ausschluß der Monitorprozeduren garantiert dabei den exklusiven Zugriff zur Platte. Häufig ist die Benutzung einer Ressource jedoch nicht durch ein wohldefiniertes Zugriffsmuster wie Read oder Write beschreibbar. Die oben angegebene Lösung versagt bereits, wenn zwischen der Belegung und der Freigabe der Platte eine anwendungsabhängige Zahl von Plattenzugriffen liegt. In diesem Fall ist es nicht mehr sinnvoll, die Plattenzugriffe über entsprechende Monitorprozeduren in den Monitor zu verlegen. Vielmehr muß man sich dann darauf beschränken, im Monitor lediglich Funktionen zum Sperren und Freigeben der Ressource bereitzustellen, die eigentliche Benutzung der Ressource jedoch durch außerhalb des Monitors definierte Funktionen zu bewerkstelligen. Damit geht der eigentliche Vorteil des Monitorkonzeptes jedoch weitgehend verloren. Die nachfolgende Monitorrealisierung trägt diesem Gedankengang Rechnung. Wie bei allen weiteren Beispielen wurde die Semantik der Signal-Variante I unterstellt.
6.4 Sprachgestützter Mechanismus: Monitore
1. Beispiel: Verwaltung einer einzelnen Ressource MONITOR S i n g l e R e s o u r c e int b u s y ; /* b u s y = l : R e s s o u r c e frei b u s y = 0 : R e s s o u r c e b e l e g t */ Condition nonbusy; ENTRY A c q u i r e { w h i l e (busy0 ist. Die Wiederholung einiger Beispiele aus dem Abschnitt 6.3 demonstriert den Umgang mit diesem nützlichen Konzept und beweist eindrucksvoll die verbesserte Verständlichkeit der resultierenden Algorithmen gegenüber den korrespondierenden Semaphorlösungen.
Semaphore können durch einen Monitor realisiert werden
6
Speicherbasierte Prozeßinteraktion
2. Beispiel: Verwaltung gleichartiger Betriebsmittel Verwaltung mehrerer
MONITOR DiscPool (int Anzahl) enum DiscStatus[N] {frei,belegt}; int busy; /* Zahl belegter Laufwerke */ Condition nonbusy;
Betriebsmittel
ENTRY int GetdiscO { int i ; i = l; while (busy==N) nonbusy.WAIT; while (DiscStatus[i]==belegt) i++; DiscStatus[i]=belegt; busy++; return (i);
} ENTRY PutDisc(int ActDisc) { DiscStatus[AtcDisc]=frei; busy--; nonbusy.SIGNAL;
} INIT { N=Anzahl; DiscStatus=frei; busy=0 END
3. Beispiel: Reader-Writer-Problem (1) Die untenstehende Lösung von Hoare setzt die Existenz von vier Monitorprozeduren StartRead, EndRead, StartWrite, EndWrite voraus. In der Variablen Readcount wird die Zahl der gleichzeitigen Readers und in der Variablen Writeflag ein potentieller Writer vermerkt. Die Condition-Variablen OkToRead und OkToWrite dienen der Realisierung von Wartestellungen für Prozesse, die auf die Erlaubnis zum Lesen bzw. Schreiben warten müssen. Reader- Writer-Problem: Reader-Bevorzugung
MONITOR ReadWrite int Readcount; //Zahl der Prozesse, die lesen oder wollen int Writeflag; Condition OkToRead, OkToWrite; ENTRY StartRead() { Readcount++; while (Writeflag==l) OkToRead. WAIT () ; OkToRead.SIGNAL();
} ENTRY EndRead() { Readcount--; if (Readcount==0) OkToWrite.SIGNAL();
} ENTRY StartWrite() { while((Readcount>0) | | (Writeflag==l)) { OkToWrite.WAIT{); Writeflag=l;
}
6.4
Sprachgestützter Mechanismus: Monitore
ENTRY EndWrite() { Writeflag=0; if(OkToWRead.Status>0) OktoRead.SIGNAL(); eise OktoWrite.SIGNAL();
} INIT { Readcount=0; WritefIag=0; } END
Die nachfolgende Lösung für das 2. Reader/Writer-Problem folgt dem Lösungsansatz für das 1. Reader/Writer-Problem und ist ebenso leicht verständlich. Die Komplexität der korrespondierenden Semaphorlösung wird nicht annähernd erreicht. 4. Beispiel: Reader-Writer-Problem (2) MONITOR ReadWrite { int Readcount, Writecount, Writeflag; Condition OkToRead, OkToWrite; ENTRY StartReadO { while (Writecount>0) OkToRead.WAIT(); Readcount++; OkToRead.SIGNAL();
} ENTRY EndRead() { Readcount--; if (Readcount==0) OkToWrite.SIGNAL 0 ;
} ENTRY StartWrite() { Writecount++; while ((Readcount>0)||(Writeflag==l)) OkToWrite.WAIT(); Writeflag=l;
} ENTRY EndWrite() { Writecount--; Writeflag=0; if (Writecount>0) OkToWrite.SIGNAL 0 ; else OktoRead.SIGNAL() ;
} INIT { Readcount=0; Writecount=0; WritefIag=0; } END
Prozesse benutzen den Monitor ReadWrite in der Form PROCESS R e a d e r {
PROCESS W r i t e r { ReadWrite.StartWrite(); Schreibzugriffe; ReadWrite.EndWrite();
ReadWrite.StartRead; Lesezugriffe; ReadWrite.EndRead();
}
}
Reader-Writer-Problem: Writer-Bevorzugung
6 Speicherbasierte Prozeßinteraktion
Aus der Sicht der Reader und Writer ist deshalb der Zugriff zu den gemeinsamen Daten genauso einfach wie mittels eines gewöhnlichen kritischen Abschnitts. 6.4.3
Semaphor-basierte Realisierung eines Monitors
M_UrgentCount speichert die Anzahl der am Semaphor M_urgent
Implementierungsaspekte
Da es sich beim Monitorkonzept um eine Sprachkonstruktion handelt, basieren alle Implementierungen auf der Übersetzung von Monitoren in eine primitivere Darstellungsform, in der die Synchronisation explizit enthalten ist. Die nachfolgend angegebenen Lösungen folgen einem Vorschlag von Hoare, Monitore mittels Semaphoren zu realisieren. Monitore werden dazu in Module übersetzt, in denen die Synchronisationen bei Monitoreintritt, Monitoraustritt sowie alle Operationen auf Condition-Variable durch Semaphor-Operationen ersetzt werden. Monitorein- und -austritt werden dabei über zwei Semaphore gesteuert, die bei der Compilierung für jeden Monitor getrennt erzeugt werden. In der untenstehend gezeigten übersetzten Version des Monitors M wurden die Semaphore mit M_Mutex und M_urgent bezeichnet. Über das Semaphor M_Mutex betreten alle Prozesse erstmalig den Monitor M. Prozesse, die bereits einmal im Monitorbesitz waren und ihn aufgrund einer Wait- oder Signal-Operation vorübergehend verlassen mußten, betreten dagegen den Monitor erneut über das Semaphor M_urgent. Prozesse, die am Semaphor M_Urgent warten, werden bevorzugt bedient. Mit diesen Erläuterungen nimmt der durch Compilierung von M resultierende Modul die folgende Gestalt an: MODULE M Datendeklarationen; Semaphor M_Mutex, M_Urgent; int M_UrgentCount ;
blockierten Prozesse ENTRY Funktionsnamel (Parameter) { P(M_Mutex); Anweisungsteil; if (M_UrgentCount>0) V(M_Urgent) else V(M_Mutex);
} Weitere Monitorprozeduren; INIT { M_Mutex=l; M_Urgent=0; M_UrgentCount=0; END
Jede Condition-Variable cond wird bei der Übersetzung durch ein Semaphor cond und einen zugehörigen Zähler condcount ersetzt: Semaphor-basierte Realisierung einer Condition-Variable
Condition cond: Semaphor cond = 0; int condcount = 0;
6.4
Sprachgestützter Mechanismus: Monitore
Die Operation cond.STATUS wird ersetzt durch einen Zugriff auf den zugehörigen Zähler condcount. Realisierungen der Operationen S I G N A L und W A I T sind abhängig von der jeweiligen semantischen Variante der Signal-Operation. Signal-Variante I: cond.SIGNAL: if (condcount>0) {v(cond); condcount--; M_UrgentCount++};
Umsetzung Signal-Variante I
cond.WAIT: condcount++; if (M_UrgentCount>0) V(M__Urgent); else V(M_Mutex); P (cond); P(M_Urgent); M_UrgentCount--;
Signal-Variante II: Die Realisierung der Wait-Operation ist identisch mit der Wait-Operation der Signal-Variante I, lediglich die Signal-Operation muß modifiziert werden: cond.SIGNAL: while(condcount>0) {V(cond); condcount- -; M_UrgentCount++;};
Umsetzung Signal-Variante II
Signal-Variante III: Die Realisierung der Signal-Variante III folgt dem Vorschlag von Hoare [Hoare 1974]. cond.WAIT: condcount++; if (M_UrgentCount > 0) V(M_Urgent); else V(M_Mutex); /* Prozeß verläßt Monitor */ P(cond); /* Prozeß betritt Monitor erneut */ condcount--;
Umsetzung Signal-Variante III
cond.SIGNAL: M_UrgentCount++; if (condcount > 0) { V(cond); P(M_Urgent); } M_UrgentCount--;
Der geführte Nachweis, daß sich Monitorkonzept und Semaphormechanismus ineinander überführen lassen verdeutlicht auch, daß beide Synchronisationshilfsmittel grundsätzlich gleich mächtig sind.
Semaphor und Monitor lassen sich ineinander überführen
6 Speicherbasierte Prozeßinteraktion
Effizientere Implementierungen sind möglich, wenn man auf die Benutzung des Semaphormechanismus verzichtet. Der interessierte Leser sei hier auf die entsprechende Spezialliteratur verwiesen ([Nehmer 1979], [Schmidt 1976]). 6.4.4
Berücksichtigung der Prozeßprioritäten
Erweiterung für Condition-Variable
Erweiterungen für Echtzeitverarbeitung
Grundsätzlich lassen sich die in Abschnitt 6.3 für Semaphore diskutierten Erweiterungen für die Echtzeitverarbeitung auch auf Monitore übertragen. Dies würde primär bedeuten, Prozesse bei Monitoreintritt prioritätsgerecht zu bedienen; die Kombination mit dem Prinzip der Prioritätsvererbung ist ebenfalls möglich. Für eine anwendungsgesteuerte Bedienung der an Condition-Variablen blockierten Prozesse hat Hoare vorgeschlagen, die Operationen Wait und Signal um einen Parameter zu erweitern: cond.WAIT(Priorität) cond.SIGNAL(Priorität)
In der Wait-Operation bestimmt der Parameter Priorität die Stellung des Prozesses in der Schlange der Condition-Variablen cond. Damit lassen sich anwendungsabhängige Prioritätsschemata realisieren. Der Parameter in der Signal-Operation ist optional und hat die Bedeutung eines Selektors: Er wählt den Prozeß aus der Schlange der Condition-Variablen aus, dessen Prioritätswert mit dem angegebenen Parameter übereinstimmt. Hoare hat die Nützlichkeit dieses Konzeptes anhand eines Monitors zur Realisierung eines einfachen Zeitdienstes demonstriert, wobei lediglich die Wait-Operation den Prioritätsparameter benötigt. Abbildung 6-8 zeigt die gesamte Anordnung. Ein Prozeß Timer, der durch einen periodischen Zeitgeber getriggert wird, ruft periodisch die Monitorprozedur Tick des Monitors Clock auf und aktualisiert damit eine Monitor-interne Zeitbasis. Prozesse, Clients genannt, benutzen den Weckdienst des Monitors Clock, indem sie die Monitorprozedur WakeMe aufrufen. Als Parameter wird der Prozedur die relative Weckzeit als Anzahl von Zeiteinheiten eines vorgegebenen Basisintervalls übergeben. Prozesse werden so lange im Monitor blokkiert, bis ihre Weckzeit abgelaufen ist. Der Monitor hat den folgenden Aufbau: MONITOR Clock int now; /*Absolutzeit*/ Condition wakeup; ENTRY WakeMe(int N) { int next; next=now+N;
6.5
Realisierungsbeispiele
while (now P 2 . P3) R={r1, r 2 , r 3 , r4} E={(p1,r1), (P 2 ,r 3 ), (r 1 ,p 2 ), (r 2 ,P 2 ), (r 2 ,P l ). (r3,P3)}
8.2 Formale Modelle
Abb. 8-5 Resource-Allocation-Graph ohne Zyklen
Der zugehörige Resource-Allocation-Graph ist in Abbildung 8-5 dargestellt. Er stellt eine Momentaufnahme dar, in der
• das Betriebsmittel r1 im Besitz von p2 ist und von p1 angefor-
• • •
dert wird, je ein Exemplar des Betriebsmittels vom Typ r2 im Besitz von pl und p 2 ist, das Betriebsmittel r3 im Besitz von p3 ist und von p2 angefordert wird, alle Betriebsmittel des Typs r4 frei sind.
Eine notwendige Bedingung für die Entstehung einer Verklemmung ist die Ausbildung eines Zyklus (geschlossener, gerichteter Kantenzug). Existiert für alle Betriebsmittel im Zyklus lediglich ein Exemplar, so kann mit Sicherheit auf eine Verklemmung geschlossen werden.
Zyklus ist notwendige Bedingung für Verklemmung
Abb. 8-6 Resource-Allocation-Graph mit Zyklen
Führt man z.B. in dem in Abbildung 8-5 dargestellten Graphen eine zusätzliche Kante (p 3 ,r 2 ) ein, so erhält man einen Graphen mit zwei Zyklen (siehe Abbildung 8-6):
8
Zyklus ist keine hinreichende Bedingung für Verklemmung
Synchronisationsfehler
Obwohl die notwendigen und hinreichenden Bedingungen für die Ausbildung einer Verklemmung nicht erfüllt sind (Betriebsmitteltyp r2 enthält mehr als ein Exemplar), liegt in beiden Fällen eine Verklemmung vor. Der offensichtliche Grund ist, daß die Prozesse p1 und p 2 , die jeweils ein Exemplar von r2 besitzen, beide Teil eines Zyklus sind und damit nicht aus eigenem Antrieb Betriebsmittel freigegeben können. In dem Graphen in Abbildung 8-7 ist eine Situation dargestellt, in der zwar ein Zyklus existiert, die Betriebsmittel aber auch von Prozessen außerhalb des Zyklus belegt sind (p 2 und p 4 ). In diesem Fall existiert keine Verklemmung, da der Zyklus durch Freigabe eines Exemplars des Typs r1 durch p2 oder des Typs r2 durch p4 aufgelöst wird.
Abb. 8-7 Beispiel eines ResourceAllocation-Graphs mit Zyklen
Grundsätzlich gilt, daß eine genauere Analyse der Situation erforderlich ist, wenn bei einem vorhandenen Zyklus Betriebsmitteltypen mit mehr als einem Exemplar vorkommen. Eine Verklemmung liegt nur dann vor, wenn die am betrachteten Zyklus unbeteiligten Prozesse (die Exemplare von im Zyklus liegenden Betriebsmitteltypen besitzen) selbst in einem anderen Zyklus verklemmt sind.
8.3
Erkennungs- und Vermeidungsalgorithmen
Die zirkuläre Wartebedingung als allgemeines Kriterium für die Ausbildung von Verklemmungen wurde von Coffmann [Coffmann et al. 1971] für das Gebiet der Betriebsmittelzuteilung präzisiert. Danach müssen die folgenden Bedingungen erfüllt sein, damit sich eine Verklemmung einstellt: Vier gleichzeitig geltende Bedingungen für eine Verklemmung
1. Die belegten beziehungsweise angeforderten Betriebsmittel können nur exklusiv von jeweils einem Prozeß benutzt werden. 2. Prozesse sind bereits im Besitz von Betriebsmitteln, während sie weitere anfordern.
8.3
Erkennungs- und Vermeidungsalgorithmen
3. Belegte Betriebsmittel können nicht zwangsweise entzogen werden. 4. Es existiert eine zirkuläre Kette von Prozessen, in der jeder Prozeß ein oder mehrere Betriebsmittel besitzt, die vom nächsten Prozeß im Zyklus angefordert werden. Sobald auch nur eine dieser vier Existenzbedingungen außer Kraft gesetzt wird, können Verklemmungen zuverlässig vermieden werden. Aufbauend auf dieser Erkenntnis sind die folgenden Betriebsmittelzuteilungsstrategien vorgeschlagen worden: •
Zuteilung der Betriebsmittel in einer vorher festgelegten linearen Reihenfolge. • Zuteilung aller benötigten Betriebsmittel zu einem Anforderungszeitpunkt. • Zwangsweiser Entzug (bzw. freiwillige Rückgabe) aller bereits belegten Betriebsmittel, sofern eine Anforderung nicht erfüllt werden kann. • Zuteilung von Betriebsmitteln nur dann, wenn der resultierende Zustand sicher ist.
Möglichkeiten, eine Verklemmungsbedingung aufzuheben
Die letzte Methode setzt voraus, daß eine Verklemmung eindeutig entdeckt werden kann. Dazu wurde ebenfalls von Coffmann ein Algorithmus angegeben, der nachfolgend beschrieben wird. Algorithmus zum Erkennen einer Verklemmung B={bm1, bm 2 , ..., bmk} sei die Menge der verschiedenen Betriebsmittel. Jeder Wert bmi gibt die Anzahl der insgesamt verfügbaren Exemplare des Betriebsmitteltyps ri an. Es existieren insgesamt n Prozesse. Zu einem beliebigen Zeitpunkt t bezeichne
• •
pi,j die Anzahl von Betriebsmitteln des Typs rj, die dem Prozeß Ti zugeteilt sind und qi,j die Anzahl von Betriebsmitteln des Typs rj, die vom Prozeß Ti zusätzlich angefordert werden.
Die Werte pi,j können zu einer Zuteilungsmatrix P=(pi,j) zusammengefaßt werden, in der jeder Zeilenvektor Pi alle Betriebsmittel enthält, die dem Prozeß Ti gegenwärtig zugeteilt sind. Analog dazu bilden die Werte qi,j eine Anforderungsmatrix Q=(qi,j), in der jeder Zeilenvektor Qi alle Betriebsmittel enthält, die der Prozeß Ti zusätzlich anfordert. Durch einen Vektor V=(vl, v 2 ,..., vk) werden außerdem die für jeden Betriebsmitteltyp momentan noch verfügbaren Exemplare definiert, wobei vj kleiner oder gleich bmj sein muß.
Zuteilungsmatrix
Anforderungsmatrix
8 Synchronisationsfehler
Offenbar gilt:
Der skizzierte Algorithmus weist eine Verklemmung nach, indem er versucht, eine Anordnung für die n Prozesse zu finden, in der jeder Prozeß mit den restlichen, noch freien Betriebsmitteln und den freigegebenen Betriebsmitteln seines Vorgängers seinen persönlichen Bedarf befriedigen und deshalb zu Ende geführt werden kann: Boolean Erkennen (P,Q,V) { W := V; Stop := False; for i:=l to n { if (P[i] = (0,0, ...,0)) Markiere P [i] ; } do { if (Unmarkierte Zeile u mit Q[u]