Betrübssysteme?
|
|
| | | | | Blog | Sitemap | Suchen | Webmaster | |
Der Server ist dabei kein Teil des Betriebssystems selbst, sondern ein normaler Prozeß, der möglicherweise einige zusätzliche Privilegien gegenüber gewöhnlichen Anwendungen hat. Dazu gehört etwa die Zugriffsmöglichkeit auf einige Hardwareregister. Das eigentliche Betriebssystem ist dann auf die allerwesentlichsten Funktionen reduziert: Es verwaltet lediglich die Umschaltung zwischen den verschiedenen Prozessen, den lokalen Speicher des Systems und natürlich den Versand der messages. Alle anderen Dienste werden durch leicht austauschbare Serverprozesse realisiert.
Dieses Modell des Dienstaufrufes hat nicht die Nachteile des Unterprogrammaufrufes: Die Dienstleistungen des Systems können asynchron in Anpruch genommen werden, indem die Anwendung beispielsweise eine Nachricht mit einem Lesekommando an den Fileserver sendet, dann aber weiterarbeitet. Später, wenn die Daten benötigt werden, sieht die Anwendung nach, ob die Antwort des Servers schon vorliegt. Wenn ja, kann ohne Pause weitergearbeitet werden, ansonsten muß die Anwendung (wie beim Dienstaufruf durch Sprung in ein Unterprogramm auch) warten.
Für die Anwendung ist es egal, ob der angeforderte Dienst lokal auf der eigenen CPU vorhanden ist oder ob ein Server über ein Netz auf einem anderen Rechner läuft. Die Netzadresse mag sich ändern, aber der Aufrufmechanismus bleibt derselbe. Unterschiedliche Datenformate, Maschinensprachen oder Aufrufkonventionen werden durch den Message-Mechanismus verborgen.
Auch kann das Verhalten den Servers, der ja von seinem Client vollständig getrennt ist, genau durch seine Reaktion auf die verschiedenen Kommandonachrichten beschrieben werden. Für den Client ist der Server auf jeden Fall eine Black Box. Unsaubere Gemeinheiten wie das heimliche Auslesen von undokumentierten Variablen oder der Sprung in interne Funktionen, die eine Anwendung eigentlich nicht kennen geschweige denn aufrufen sollte, sind nicht mehr machbar, denn der Server läuft in einem vom Client vollständig getrennten Adreßraum oder sogar auf einer ganz anderen Maschine in einem Netz.
Aus dieser Beobachtung heraus ist das Konzept des Multitasking geboren worden. In regelmäßigen Zeitabständen wird das laufende Programm unterbrochen, sein augenblicklicher Zustand wird eingefroren. Der Scheduler des Betriebssystems wählt dann einen anderen Prozeß aus, dessen Zustand aufgetaut wird und der dann für die Dauer der nächsten Zeitscheibe zum Ablauf kommt. Ein Scheduler, der mit Zeitscheiben arbeitet, heißt "preemptive".
Viele Betriebssysteme, die nachträglich auf Multitasking nachgerüstet worden sind, haben keinen solchen Scheduler, sondern sind darauf angewiesen daß eine Task den Prozessor freiwillig hergibt. Das kann ausdrücklich durch den Aufruf einer schedule()-Funktion in regelmäßigen Abständen geschehen oder versteckt, indem man im Betriebssystem am Ende jeder Systemfunktion einen Aufruf des Schedulers vor dem Verlassen des Betriebssystems einsetzt. Eine solche Form von Multitasking nennt man "kooperativ" oder "non-preemptive". Bei einem System mit kooperativem Multitasking kann die Umschaltung für den Benutzer aber leicht schwerfällig wirken, wenn rechenintensive Anwendungen laufen, die selten Systemaufrufe tätigen.
Einer der schwersten Fehler, den ein Programmierer auf einem Rechner mit Multitasking-Betriebssystem machen kann, ist es, in einer aktiven Warteschleife auf das Eintreffen eines Ereignisses zu warten ("busy waiting"). Die Effektivität von Multitasking beruht ja gerade darauf, daß Prozesse, die gerade keine echte Arbeit zu tun haben, stillgelegt werden und vom Betriebssystem erst dann wieder Rechenzeit zugeteilt bekommen, wenn sie etwas damit anfangen können.
Deswegen ist es fast unmöglich, ein Betriebssystem wie beispielsweise MS-DOS nachträglich multitaskingfähig zu machen oder in einer Multitaskingumgebung (z.B. eine DOS-Box in UNIX oder OS/2) zu betreiben. Nahezu jedes Programm, angefangen vom Kommandointerpreter der Shell, "verbrät" die ihm zugeteilte Rechenzeit vollständig in Zeicheneinleseroutinen und anderen engen Warteschleifen, anstatt diese anderen Programmen zur Verfügung zu stellen. MS-DOS Prozesse sind gewissermaßen immer im Zustand "ready". Wenn man einem solchen System nachträglich eine Multitaskingerweiterung überstülpt, fehlt diesem System notgedrungen der Zustand "sleeping", und jeder laufende Prozeß erhält genau 1/ntel der Gesamtleistung der CPU - selbst dann, wenn er eigentlich gar nichts damit anfangen könnte.
Nach welcher Strategie die schedule()-Funktion letztendlich bestimmt, welcher Prozeß aus der "ready"-Queue in den Zustand "running" gehen darf und damit auf die CPU gelangen kann, hängt stark von den gewünschten Eigenschaften des Systems ab. Der einfachste Zuteilungsalgorithmus ist das "round-robin"-Verfahren. Dabei wird die zur Verfügung stehende CPU-Zeit gleichmäßig zwischen allen Prozessen aufgeteilt, die sich darum bewerben. Alle diese Prozesse kommen reihum auf die CPU - daher der Name.
Oft wird dieses Verfahren noch so erweitert, daß man Prozesse unterschiedlicher Priorität unterscheidet. Der Scheduler von AmigaOS, aber auch der von VAX/VMS, ist ein Round-Robin Scheduler mit Prioritäten. Das bedeutet: Der Ringtausch der Prozesse auf der CPU funktioniert innerhalb der höchsten vorhandenen Prioritätsebene. Erst wenn alle Prozesse einer höheren Prioritätsebene aus der Ready-Queue entfernt sind, etwa weil sie terminiert sind oder weil sie auf ein Ereignis warten und deswegen stillgelegt sind, kommen Prozesse mit niederen Prioritäten auf die CPU.
Andererseits kann ein CPU-intensiver Prozeß mit einer hohen Priorität den Rest des Systems recht schwerfällig machen oder gar blockieren. Gelegentlich (bei Echtzeitanwendungen zum Beispiel) ist dieser Effekt erwünscht: Man geht davon aus, daß ein Prozeß mit einer hohen Priorität die Rechenzeit, die er beansprucht, auch unbedingt und gerade zu diesem Zeitpunkt benütigt. Im allgemeinen Fall allerdings möchte man eine Lösung haben, die auch dann noch eine gewisse Fairness gewährleistet, wenn ein Programm böswillig Rechenzeit schluckt. In UNIX zum Beispiel sind die Prioritäten von Prozessen nicht fest, sondern hängen davon ab, wie lange der Prozeß schon in der Warteschlange steht und wie CPU-intensiv er ist.
Prozesse, die frühzeitig die CPU verlassen (etwa weil sie viel I/O machen und deswegen häufig im Zustand "sleeping" sind) bekommen ihre Restzeit als Bonus für die nächste Zeitscheibe gutgeschrieben, der in die Berechnung der Priorität eingeht. Dadurch bekommen solche Prozesse automatisch eine bessere Priorität als solche, die die Zeitscheiben immer voll ausnutzen.
Prozesse, die lange in der Ready-Queue gestanden haben (weil sie eine schlechte Priorität haben), werden temporär aufgewertet, damit sie auch eine Chance haben, einmal zum Ablauf zu kommen (priority aging).
Auf Rechnern mit nur einem Prozessor läßt sich dieses Problem recht leicht dadurch lösen, daß man in diesen beiden Funktionen kurze Zeit sämtliche Unterbrechungen verbietet und die gewünschte Operation durchführt. Auf Mehrprozessorsystemen nützt dies natürlich nichts mehr, denn während der eine Prozessor den Semaphor bearbeitet, kann ein anderes Programm auf einem anderen Prozessor dasselbe tun. Hier ist das Betriebssystem auf die Unterstützung der Hardware angewiesen, die nicht unterbrechbare Semaphor-Operationen schon als Prozessorbefehle anbieten muß. Prozessoren wie der 680x0 und der 80x86 haben solche Operationen bereits eingebaut: Der TAS-Befehl des 680x0 führt zum Beispiel eine solche Semaphor-Operation in einem nicht unterbrechbaren Read-Modify-Write-Zyklus aus, und der 80x86 kennt das LOCK-Prefix für eine ganze Reihe von Befehlen, die dann ebenfalls nicht unterbrechbar werden.
Auf diese Weise läßt sich ein Prozeß in seinem eigenen Speicher einzäunen. Zwar erlaubt man ihm, auf den eigenen vom System angeforderten Speicher zuzugreifen, doch sobald versucht wird, auf andere, nicht erlaubte Speicherbereiche zuzugreifen, wird der Prozeß unterbrochen und das Betriebssystem übernimmt die Kontrolle ("memory protection", "Speicherschutz"). Es kann den problematischen Prozeß stillegen und ein Image seines Zustandes zur späteren Fehlersuche auf Platte ablegen. Auf diese Weise verhindert man zwar nicht, daß ein fehlerhaftes Programm abstürzt, aber immerhin wird sichergestellt, daß keine anderen Programme in Mitleidenschaft gezogen werden.
Die MMU-Hardware kann man auch dazu benutzen, dem Programm mehr RAM vorzuspiegeln, als tatsächlich in der verwendeten Maschine vorhanden ist ("virtual memory"). Die meisten Programme brauchen nicht alle ihre Daten und ihren gesamten Code zur gleichen Zeit, sondern sind oft nur in einem kleinen, eng begrenzten Speicherbereich (dem "working set") aktiv. Je nach Benutzung des Programmes verschiebt sich dieser Bereich von Zeit zu Zeit, aber im Prinzip würde ein kleiner Speicherbereich ausreichen, um das ganze Programm ablaufen zu lassen.
Betriebssysteme wie UNIX unterteilen deswegen den gesamten Speicher der Maschine in Seiten fester Größe - bei einer 80386 CPU zum Beispiel werden durch die Hardware Pages von 4 KB Größe vorgegeben. Nicht alle Speicherseiten eines Prozesses sind gleichzeitig im RAM, sondern Teile davon befinden sich in der "paging area", einem abgeteilten Bereich einer Festplatte. Wenn ein Prozeß versucht, eine Speicherseite anzusprechen, die gerade nicht präsent ist, generiert die MMU wieder eine Unterbrechung ("page fault") und läßt das Betriebssystem zum Zuge kommen. Dieses sucht jetzt eine andere Speicherseite, die möglichst lange nicht benutzt worden ist, speichert diese in der Paging Area zwischen, adressiert sie um und stellt sie dann dem unterbrochenen Prozeß an der richtigen Adresse und mit dem richtigen Inhalt, der inzwischen von der Platte geladen wurde, zur Verfügung. Dieser wird nach der kurzen Unterbrechung dann fortgesetzt.
Wenn genug Speicher vorhanden ist, um die aktiven Teile der verschiedenen lauffähigen Prozesse gleichzeitig im Speicher zu halten, fallen die kurzen Unterbrechungen beim Paging nicht weiter ins Gewicht. Wenn der Rechner jedoch überlastet wird und für die Anzahl der gleichzeitig laufenden Prozesse zu wenig Speicher hat, fängt er an, auch Teile von Prozessen auf Platte auszulagern, die gleich wieder geladen werden müssen. Die Rechenleistung der Maschine fällt plötzlich um einige Zehnerpotenzen, und die Antwortzeiten auf ein simples Return an der Console liegen auf einmal im Minutenbereich. Man bezeichnet diesen Vorgang als "trashing", und die Abhilfe bei diesem Problem ist simpel: Man lege einige Megabytes RAM nach.
Die Autoren von Mach und Chorus haben diese Idee konsequent weiter gedacht: Diese beiden Betriebssysteme können das auch mit Daten machen, die mit read() und write() aus Dateien gelesen oder geschrieben werden. Dateien, die aus Datensätzen fester Länge bestehen, sind schließlich nur der Sonderfall eines Arrays: In PASCAL kann man das sogar noch in der Definition sehen.
Mit Hilfe der MMU blendet man einen Speicherbereich von der Größe der Datei, die verarbeitet werden soll, in den Adreßraum des Prozesses ein ("memory mapped file"). Für den Prozeß ist die Basis dieses Speicherbereiches die Startadresse eines ganz normalen Arrays. Versucht der Prozeß, auf dieses Array zuzugreifen, kommt es zu einem Page Fault. Das Betriebssystem hat diesen Speicherbereich mit einer Datei assoziiert und lädt jetzt die entsprechenden Dateiinhalte in die angesprochenen Speicherseiten, mit denen der Prozeß dann wie bei einem normalen Arrayzugriff arbeitet. Beim Schließen der Datei wird der Speicherbereich wieder aus dem Adreßraum des Prozesses ausgeblendet, und alle Modifikationen werden spätestens zu diesem Zeitpunkt in die Datei zurückgeschrieben.
Auf diese Weise werden spezielle Dateioperationen in einer Programmiersprache überflüssig; Dateien werden wieder zu dem, was sie eigentlich sind: Arrays dynamischer Länge, deren Inhalt über die Laufzeit eines Prozesses Bestand hat. Der Programmierer einer Anwendung braucht sich nicht mehr um das Lesen und Schreiben von Daten in Puffer zu kümmern, er greift einfach darauf zu. Das tatsächliche Bereitstellen der Daten und das Zurückschreiben auf Platte geschieht durch das Betriebssystem, ohne daß er etwas damit zu tun hätte.
Die Erzeugung eines Threads ist - verglichen mit der eines eigenständigen Prozesses - relativ wenig aufwendig. Ein Thread ist kaum mehr als einige Prozessorregister und ein eigener Stackbereich, während an einem Prozeß noch ungezählte Tabellen für MMU-Konfiguration, offene Dateien, etc. hängen. Beim kommerziellen Chorus-System hat man diese Trennung zwischen Speicherverwaltung und Kontrollflußverwaltung auch begrifflich deutlich machen wollen: Ein Kontrollfluß ist bei Chorus ein Thread, der zugehörige Kontext heißt Actor und ein Actor mit mindestens einem Thread bildet einen Prozeß. Die Kommunikation und der Datentausch zwischen Threads sind fast ohne Aufwand möglich: Da Threads im selben Adreßraum liegen, können sie auf dieselben gemeinsamen Variablen zugreifen, ohne auf langsame und aufwendige Mechanismen zur Prozeßkommunikation zurückgreifen zu müssen.
Viele Betriebssysteme stellen deswegen höher entwickelte, aber auch langsamere Kommunikationsmöglichkeiten zur Verfügung. Am häufigsten findet sich ein Message-System, bei dem eine Art FIFO-Struktur realisiert wird. Jeder Prozeß hat, wenn er Nachrichten entgegennehmen möchte, einen sogenannten Port, an den andere Prozesse Nachrichten senden können. Treffen die Nachrichten schneller ein, als der Prozeß sie abarbeiten kann, bildet sich am Port eine Queue mit FIFO-Struktur. Der empfangende Prozeß arbeitet die Nachrichten jetzt in der Reihenfolge ab und sendet die Antworten an die Absender zurück.
Hier kann das Betriebssystem schon einen guten Teil der Zugriffsregelung, der Speicherverwaltung und der Adreßauflösung übernehmen. Wenn der Message-Mechanismus nämlich allgemein genug implementiert wird, muß der Message-Port des Empfängers nicht unbedingt auf der eigenen Maschine sein, sondern kann sich irgendwo im Netz befinden. Ist der Empfänger lokal erreichbar, kann die Nachricht ohne Kopieren einfach in seinen Adreßbereich eingeblendet werden. Andernfalls muß sie "by value" über das Netz kopiert werden. Der Benutzer des Message-Systems merkt davon nichts, für ihn sind diese lästigen Details verborgen. Das geht bei Chorus dann sogar soweit, daß Ports von einem Prozeß zu einem anderen migrieren können, ohne daß sendende Prozesse von diesem Wechsel des Empfängers etwas merken. Derartige Features erleichtern dem Programmierer von "fault tolerant systems" die Arbeit natürlich ungemein.
Noch viel wichtiger als ein Mechanismus zur Kommunikation zwischen Prozessen ist jedoch, daß ein einheitliches, erweiterbares Format zum Datentausch besteht. Es genügt nicht, daß das Betriebssystem Dienste zum Verschieben von Bytes zwischen einem oder mehreren Prozessen zur Verfügung stellt. Der Empfänger muß auch in der Lage sein, diese Bytes als eine bestimmte Datenstruktur, etwa einen Bildausschnitt oder einen Text mit Steuerinformationen, zu deuten. Wenn das Betriebssystem ein durch Subklassen erweiterbares Format für die verschiedenen am häufigsten auftretenden Datentypen bereitstellt, leistet es einen wesentlichen Teil zur Vereinheitlichung der Datenformate der Applikationen, die auf diesem Betriebssystem ablaufen.
Wichtig für die Funktionsfähigkeit eines solchen Datenformates ist, und das soll noch einmal besonders herausgestellt werden, daß es von seinem Benutzer unter Wahrung der Abwärtskompatibilität erweiterbar ist. Ein Benutzer eines Textverarbeitungsformates, beispielsweise ein DTP-Programm, muß in der Lage sein, seine programmspezifischen Zusatzinformationen in einem Text abzulegen. Andere Anwendungen, die dieses Textformat lesen können, müssen trotzdem in der Lage sein, die Standardattribute eines Textes aus so einer Datei herauszulesen und die Nichtstandardattribute unverändert weiter zu kopieren. Eine Anwendung, die ein zusammengesetztes Format schreibt, z.B. eine Animation oder ein Text mit Bildern, muß dies in einer Art und Weise tun können, die es anderen Anwendungen erlaubt, die Komponenten dieser Daten (etwa Einzelbilder) gezielt zu lesen, zu verarbeiten und wieder in die Gesamtdatei zu integrieren.
Funktionsbibliotheken, die die Arbeit mit solchen Dateiformaten unterstützen, gehören zwar nicht zu den unmittelbaren (Kern-) Dienstleistungen eines Betriebssystems, aber sie sollten auf jeden Fall zu seinem Standardfunktionsumfang gehören, um eben eine Einheitlichkeit in den Datenformaten bei den Anwendungen zu begünstigen.
Betriebssysteme wie UNIX und seine Abkömmlinge tun dies beispielsweise, in dem sie den meisten Systemdiensten das Dateiparadigma überstülpen: Pipelines, zeichen- und blockorientierte Geräte, Querverweise auf andere Dateien, Netzwerkdienste - alles dies ist als Eintrag im hierarchischen Namensraum des Dateisystems zu finden.
In Mach, aber auch in Plan9 und in den neuesten Versionen von UNIX, ist dieses Konzept noch verallgemeinert worden, indem von der Möglichkeit Gebrauch gemacht wurde, die unterschiedlichsten Typen von Dateisystemen in den Namensraum zu integrieren. So gibt es bei System V Rel. 4 ein /proc-Dateisystem, in dem die zur Zeit im Rechner laufenden Prozesse als Dateieinträge sichtbar gemacht werden.
Amoeba, ein experimentelles Betriebssystem von Tanenbaum, treibt es jedoch auf die Spitze. In Amoeba sind alle Dienste - also das Bereitstellen der Informationen in einer Datei, Pipelines, Prozeßkommunikation, etc. - unter einer Kennung zu erreichen, die das jeweils angesprochene Objekt (die Datei, den Prozeß, ...) eindeutig identifiziert. Diese Kennung beinhaltet zugleich die Kodierung der Zugriffsrechte, die Adresse des Objektes im System und noch einige Informationen mehr. Die vom Betriebssystem verwalteten Objekte werden durch einen Directory-Service miteinander zu einer Struktur verknüpft, die nicht mehr auf die baumartige Hierarchie eines UNIX-Dateisystems beschränkt ist, sondern jeden beliebigen, gerichteten Graphen modellieren kann. Da der Directory-Service selbst wieder ein Objekt im Amoeba-System ist, ergibt sich, ähnlich wie beim UNIX-Dateisystem, eine interessante, rekursiv definierte Struktur.
Eine andere Möglichkeit der Virtualisierung ist die Abstraktion von einem konkreten Gerät zu seinen Möglichkeiten. In UNIX gibt es zum Beispiel die curses-Bibliothek, die es ermöglicht, ein Terminal unabhängig von den Steuercodes, die es verwendet, zu programmieren. Windows, OS/2, AmigaDOS und andere Betriebssysteme leisten ähnliches bei der Ansteuerung von Druckern. Statt daß die Anwendung einem bestimmten Drucker an der parallelen Schnittstelle einen bestimmten Steuercode zum Einschalten von Fettschrift zu sendet, bittet sie den Druckertreiber, den vom Anwender gewünschten Drucker in Fettschrift umzustellen, falls der Drucker das kann. Eine austauschbare Komponente des Betriebssystems, ein Gerätetreiber, stellt jetzt fest, ob der Drucker diese Funktion hat, und veranlaßt die für den konkreten angeschlossenen Drucker passenden Maßnahmen an einem weiteren untergeordneten Gerätetreiber für die Druckerschnittstelle. Dies kann der Treiber für die parallele oder die serielle Schnittstelle sein, aber auch der SCSI- oder der Ethernet-Treiber für einen Drucker im Netz. Die "passenden Maßnahmen für den konkreten Drucker" können die Generierung von fünf ASCII-Steuerzeichen zur Umschaltung auf Fettdruck sein oder das Berechnen eines neuen Fonts und das Laden dieses Zeichensatzes auf den Drucker. In jedem Fall bekommt die Anwendung die von ihr gewünschte Dienstleistung, ohne überhaupt etwas von den Aktivitäten hinter den Kulissen zu merken und ohne daß der Hersteller der Anwendung bei jedem Update mehrere Disketten mit Gerätetreibern mitliefern muß.
Der Preis für die Funktionalität: Mehr Megabytes, mehr Megaherz, mehr Megapixel.
| Top | Geändert:15-Feb-2004 15:10:05 Url: http://kris.koehntopp.de/artikel/betriebssysteme/index.html |