Kapitel 5

Action! Aktivitäten, Prozesse und all das

IN DIESEM KAPITEL

  • lernen Sie, wie neue Prozesse geschaffen werden und wie man sie wieder loswird,
  • behandeln wir die wichtigsten UNIX-Systemrufe, die für Prozesse relevant sind,
  • erzeugen Sie parallele Abläufe innerhalb von Prozessen mit Hilfe von Threads.

Betriebssysteme sollen unter anderem (Nutzer-)Programme abarbeiten. Dazu bieten sie bestimmte Abstraktionen an, die Sie in diesem Kapitel kennenlernen. Nun wird es auch endlich praktisch, denn Sie werden selbst nicht nur Prozesse und Threads erzeugen, sondern diese auch überlagern, synchronisieren und wieder aus dem System entfernen.

Prozesse und Threads

Zur Repräsentation eines ausgeführten Programms bieten Betriebssysteme zwei wesentliche Abstraktionen: Prozesse und Threads. Da sie historisch älter sind, lernen Sie zunächst Prozesse kennen. Die Threads folgen etwas später.

Prozess

Der Begriff »Prozess« hat viele Bedeutungen, so im juristischen Sinne oder in der Arbeitsorganisation. Im Kontext von Betriebssystemen erlernen Sie nun (wahrscheinlich) eine weitere.

imagesEin Prozess ist ein Programm in Abarbeitung. Er besteht aus verschiedenen Ressourcen und wird durch einen Adressraum von anderen Prozessen und dem Betriebssystem separiert.

Der Prozess muss durch einen anderen Prozess erzeugt werden, existiert für einen gewissen Zeitraum im System und endet definiert.

Damit der Prozess voranschreiten kann, muss er auf einem Prozessor abgearbeitet werden. Leider gibt es in typischen Systemen viel weniger Prozessoren als Prozesse. Somit müssen sich die Prozesse die vorhandenen Prozessoren teilen. Jeder bekommt einen Prozessor nur für eine gewisse Dauer, dann ist jeweils ein anderer dran. Dass es dabei geordnet zugeht, überwacht der sogenannte Scheduler, dem wir uns im nächsten Kapitel genauer zuwenden.

Außer dem Prozessor benötigt ein Prozess für seine Arbeit Ressourcen wie Hauptspeicherblöcke, Dateien, bestimmte Geräte und anderes mehr. Wie Sie bereits im vorangegangenen Kapitel erfahren haben, stehen ihm diese Ressourcen nicht einfach ad hoc zur Verfügung, sondern er erhält diese bei Bedarf vom Betriebssystem zugeteilt. Wenn ein neuer Prozess erzeugt wird, dann erhält er automatisch die initial notwendigen Ressourcen.

Prozesszustände

Bedingt durch die potenzielle Nichtverfügbarkeit von Ressourcen und Prozessoren wechselt ein Prozess im Laufe seines »Lebens« zwischen drei verschiedenen Zuständen hin und her. Abbildung 5.1 veranschaulicht dies.

Abbildung 5.1: Zustandsdiagramm eines Prozesses

Aktiv ist gewissermaßen der Idealzustand eines Prozesses, denn er besitzt alle angeforderten Ressourcen und wird gerade abgearbeitet. Nach geraumer Zeit entscheidet der Scheduler, den Prozessor einem anderen Prozess zuzuteilen, damit wechselt der Prozess von aktiv nach bereit (gleichzeitig wechselt ein anderer Prozess von bereit nach aktiv, denn der Prozessor darf nicht leerlaufen). Hingegen erfolgt eine Transition von aktiv nach wartend, wenn der Prozess eine Ressource anfordert, die ihm durch das System verweigert wird. Er kann momentan nicht weiterarbeiten, weil ihm etwas Wichtiges fehlt. Das Betriebssystem merkt sich die fehlgeschlagene Anforderung und wird die verweigerte Ressource zu einem späteren Zeitpunkt »nachliefern«. Dies geschieht, wenn der jetzt die Ressource besitzende Prozess diese an das System zurückgibt. Entsprechend wechselt genau dann unser betrachteter Prozess wieder in den Zustand bereit und balgt sich jetzt wieder mit den anderen bereiten Prozessen um den Prozessor. Beachten Sie, dass es keine Transition von wartend nach aktiv gibt!

Bei der Erzeugung eines neuen Prozesses erhält dieser eine gewisse »Ressourcengrundausstattung«, mit der er zunächst arbeitet. Daher landet ein neu erzeugter Prozess im Zustand bereit.

Umschalten zwischen Prozessen

In einem typischen Rechensystem gibt es eine verhältnismäßig große Anzahl an Prozessen, jedoch nur relativ wenige Prozessoren beziehungsweise Kerne. Sie wissen bereits, dass der Scheduler festlegt, welcher Prozess wann, wo (auf welchem Prozessor/Kern) und für welche Zeitspanne abgearbeitet wird.

Es gibt sehr viele verschiedene Schedulingverfahren, die für völlig unterschiedliche Zwecke konzipiert sind. Ein Betriebssystem, das den Computer eines Flugzeugs steuert, benötigt ein anderes Verfahren als das eines Universalbetriebssystems wie Windows oder Linux.

In typischen interaktiven Systemen hat der Nutzer das Gefühl, dass alle Prozesse gleichzeitig abgearbeitet werden. In Wahrheit bearbeitet jeder Prozessor einen Prozess jeweils nur für eine sehr kurze Zeitspanne, die ungefähr zehn bis 100 Millisekunden beträgt. Nachdem der Prozessor einen Prozess für diese Dauer abgearbeitet hat, ist der nächste Prozess für diese Dauer an der Reihe, dann der nächste und so weiter. Wurden alle Prozesse einmal kurz bearbeitet, beginnt der Zyklus von Neuem. Die Illusion der Gleichzeitigkeit kommt durch die ungeheure Geschwindigkeit zustande, mit der der Prozessor die Abarbeitung und die »Umschaltung« zwischen den Prozessen vornimmt.

Dieses Umschalten wird Kontextwechsel oder Context Switch genannt. Was geschieht dabei? Stellen Sie sich vor, dass der Scheduler im betrachteten System der Meinung ist, der aktuell aktive Prozess A hat fürs Erste genug Rechenzeit bekommen, jetzt sei einmal ein anderer Prozess B an der Reihe (A war somit aktiv, B bereit):

  1. Alle Werte des unterbrochenen Prozesses A (man spricht vom sogenannten Prozesskontext) müssen gesichert werden, damit er bei seiner nächsten Aktivierung an der Stelle, an der er unterbrochen wurde, nahtlos fortgesetzt werden kann. Im Wesentlichen schreibt das Betriebssystem die Inhalte aller Register des Prozessors, auf dem A abgearbeitet wurde, an eine bestimmte Stelle in den Hauptspeicher.
  2. Der Scheduler wird aktiv. Er sucht aus der Menge der bereiten Prozesse nach bestimmten Kriterien den nächsten abzuarbeitenden Prozess (hier: B) aus.
  3. Der Prozesskontext von Prozess B muss restauriert werden, damit er fortgesetzt werden kann. Beim letzten Kontextwechsel, bei dem B unterbrochen wurde, wurde dieser Zustand im Schritt 1 ja gesichert. Die im Hauptspeicher gesicherten Registerinhalte von B bei dessen letzter Unterbrechung werden also wieder in die Register des Prozessors geschrieben.
  4. Mit dem Rücksprung aus dem Betriebssystem wird nun Prozess B abgearbeitet, da im restaurierten Befehlszeiger die Adresse der Instruktion steht, die bei der letzten Unterbrechung als Nächstes abgearbeitet worden wäre.

Es ist klar, dass der Umschaltvorgang selbst Zeit kostet. Man muss sich daher genau überlegen, wie oft man umschaltet. Auch diesen Aspekt werden wir im Kapitel 6 »Planenvon Aktivitäten (Scheduling)« genauer analysieren.

Die Erzeugung eines Prozesses

Eine wichtige Aufgabe von Betriebssystemen besteht darin, Programme auszuführen. Der Nutzer klickt auf ein Icon oder gibt im Terminal einen Befehl ein. Im Regelfall wird dadurch ein Programm gestartet. Andere Programme starten automatisch, wie das Backup oder eine Konsistenzprüfung des Dateisystems.

Für den Start eines Programms sind minimal die folgenden Ressourcen notwendig:

  • eine Handlungsvorschrift in Form von Programmcode, typischerweise abgelegt als Binärabbild im Dateisystem auf einem Massenspeicher,
  • ein gewisses Quantum Hauptspeicher, in den der Programmcode geladen wird,
  • ein eindeutiger Identifikator (Process Identificator, PID).

Eine Komponente des Betriebssystems, der Lader, prüft, ob die benötigten Ressourcen zur Verfügung stehen, erzeugt einen sogenannten Adressraum (um den kümmern wir uns im Kapitel 9 »Hauptspeicher (RAM)«), lädt den Programmcode in den Hauptspeicher, generiert einen neuen Eintrag in der Prozesstabelle und sorgt dafür, dass das resultierende Gebilde in den Zyklus der Abarbeitung aufgenommen wird. Der Lader kombiniert also drei verschiedene Ressourcen (Programmcode, Hauptspeicher und PID) und erzeugt aus ihnen ein Programm in Abarbeitung, einen neuen Prozess.

imagesDer Vorgang ist weitaus komplizierter als hier dargestellt. Verschiedene Betriebssysteme arbeiten mit verschiedenen Binärformaten (Linux und FreeBSD: Executable and Linking Format [ELF], Windows: Portable Executable [PE], macOS: Mach-O). Die Binärabbilder, die den Programmcode enthalten, bestehen aus verschiedenen Sektionen, die an verschiedene Stellen im Speicher geladen werden müssen, sowie einer Einsprungadresse, an der die Abarbeitung startet.

Stehen nicht genügend initiale Ressourcen zur Verfügung, dann schlägt die Erzeugung des neuen Prozesses fehl.

Der Systemruf fork()

Wir wollen uns die Erzeugung eines Prozesses anhand der UNIX-Betriebssystemfamilie genauer ansehen. Zur Erzeugung eines neuen Prozesses nutzt man hier traditionell den Systemruf fork(). Dieser Ruf hat eine etwas eigenwillige Semantik:

Nach Aufruf von fork() wird zunächst der rufende Prozess (im Nachfolgenden in Anlehnung an die Biologie Vater genannt) angehalten. Nun wird ein neuer Adressraum angelegt, der als »Behälter« für den neuen Prozess fungieren soll. Des Weiteren erscheint in der Prozesstabelle ein neuer Eintrag mit einer frischen PID. Nun kopiert das Betriebssystem den Programmcode und die Daten des Vaterprozesses in den neuen Adressraum. Schließlich werden beide Prozesse ins Scheduling aufgenommen, indem sie in den Bereit-Zustand versetzt werden. Abbildung 5.2 verdeutlicht den Ablauf. Der Prozess – in Erwartung des Folgenden bereits »Vater« genannt – ruft fork(). Dies führt zur Erstellung einer bitgetreuen Kopie (»Sohn«) des rufenden Vaterprozesses. Beide Prozesse verlassen den Systemruf und setzen ihre Abarbeitung unabhängig voneinander an der auf fork() folgenden Instruktion fort. In diesem Falle ist dies die Fehlerbehandlung des gerade ausgeführten Systemrufs.

Abbildung 5.2: Prozesserzeugung mittels fork()

Im Vergleich zu allen anderen Systemrufen gibt es somit eine Besonderheit: Ein Prozess springt in den Systemruf hinein, aber zwei Prozesse kehren aus diesem Systemruf zurück, nämlich der erzeugende Vaterprozess und der neu erzeugte Kindprozess! Der Kindprozess ist eine getreue Kopie des Vaterprozesses, beide führen (nach fork()) den gleichen Programmcode aus.

Wenn Sie programmtechnisch erreichen wollen, dass Vater und Kind unterschiedliche Dinge tun, müssen Sie beide unterscheiden können. Dies geschieht mittels des Resultats von fork(), das folgendermaßen definiert ist:

  • Wenn fork() fehlschlägt, erhält der Vater den Wert -1.
  • Ansonsten erhält der Vater die PID des erzeugten Kindprozesses.
  • Der Sohn erhält 0 als Rückgabewert.

Somit können Sie im Programmcode unterscheiden, wer Vater und wer Sohn ist, wie das Programmbeispiel in Listing 5.1 illustrieren soll.

images

Listing 5.1: Beispiel für den Systemruf fork()

Sobald fork() komplettiert wurde, sind beide Prozesse völlig unabhängig voneinander. Es ist auch nicht vorherzusehen, welcher von beiden zuerst fortgesetzt wird. Die Entscheidung darüber trifft der bereits erwähnte Scheduler. Es kann sogar passieren, dass beide gleichzeitig die Arbeit fortsetzen, sofern man über mindestens zwei Prozessoren verfügt.

Beide Prozesse sind allerdings auch nicht ganz unabhängig: Sie teilen sich nämlich ein und dasselbe Terminal. Machen beide Prozesse Ausgaben, dann kann es passieren, dass diese »verwürfelt« erfolgen: Ihre Reihenfolge ist nicht vorhersehbar. Die Prozesse sollten entweder ihre Ausgaben geeignet voneinander unterscheiden, beispielsweise durch die Ausgabe ihrer Prozess-ID, oder sich synchronisieren, also ihre Arbeit untereinander koordinieren. Aber das ist ein Thema für ein späteres Kapitel (7, »Synchronisation«).

Der exec-Mechanismus

Moment mal! Damit haben Sie ja gar nichts gewonnen, sondern nur den Vaterprozess identisch geklont? Mit diesem Mechanismus können Sie ja nur Horden identischer Programme laufen lassen!

Nun, Sie sind auch noch nicht bis zum Ende der Prozesserzeugung vorgedrungen. Unter UNIX hat man sich nämlich entschieden, das Erzeugen eines neuen Prozesses logisch zu trennen vom Laden des Programmcodes in den Hauptspeicher. Den ersten Teil erledigt das eben erklärte fork(). Für den zweiten Teil benötigen Sie einen weiteren Systemruf, das exec, das in Wahrheit eine ganze Familie von Systemrufen bildet.

Semantisch tun alle exec-Varianten das Gleiche. Sie erhalten als Parameter eine Pfadangabe, die auf eine Datei mit Programmcode (ein sogenanntes Binärabbild) verweist. Der Programmcode und die Daten werden in den gegenwärtigen Adressraum geladen und überschreiben rigoros den bis dahin ausgeführten Code (und dessen Daten). Danach wird er, beginnend bei main(), ausgeführt. Eine Rückkehr in das zuvor ausgeführte Programm ist unmöglich, weil dessen Binärabbild überschrieben wurde. Der Systemruf selbst kann jedoch zurückkehren, nämlich genau dann (und nur dann!), wenn er fehlschlägt. Dies ist beispielsweise der Fall, wenn ein falscher Pfad übergeben wurde, oder der Prozess nicht berechtigt ist, die referenzierte Datei auszuführen.

Schauen wir uns einmal exemplarisch den Systemruf execlp() an; die Signatur sieht ein bisschen kompliziert aus:

images

Der Parameter file enthält den Pfad zum neu zu ladenden Programm. Einem Programm können Sie beim Start wiederum Parameter übergeben, und da deren Anzahl variabel ist, enthält execlp() eine variable Parameterliste, eine sogenannte Ellipse. Dabei müssen Sie beachten, dass das allererste zu übergebende Kommandozeilenargument stets ein (frei wählbarer) Name für den neu zu schaffenden Prozess sein muss. Darauf folgen die eigentlichen Parameter, und das Ende der Parameterliste wird durch einen NULL-Zeiger gekennzeichnet.

Erweitern Sie obiges Programm, indem der Sohn das Kommando ps -au ausführen soll; es zeigt auf der Konsole alle Prozesse des Nutzers an:

images

Listing 5.2: Kombination von fork() und execlp()

Der Aufruf von execlp() befindet sich in Zeile 15. Hier dürfen Sie ausnahmsweise auf das Resultat verzichten, denn der Ruf kehrt nur im Fehlerfalle zurück, und es wird stets der Wert -1 zurückgeliefert. Das erste Argument von execlp() ist der Name des auszuführenden Programms, also ps. Es folgen der Name des resultierenden Prozesses, hier also pssst, und das (einzige) Argument -au für ps.

imagesEs gibt insgesamt sechs verschiedene exec-Varianten, die im Grunde genommen das Gleiche tun. Sie unterscheiden sich nur in der Art und Weise, wie das zu startende Programm und dessen Argumente übergeben werden, und darin, ob bei der Suche nach dem Image die Pfadvariable genutzt werden soll oder nicht.

Prozessbeendigung

Um »Überbevölkerung« eines Rechensystems mit Prozessen zu vermeiden, ist es notwendig, Prozesse nach getaner Arbeit aus dem System zu entfernen, also zu beenden. Dies kann zum einen durch den Prozess selbst erfolgen, zum anderen sind auch andere Prozesse dazu in der Lage, wenn diese über die entsprechenden Rechte verfügen (es kann also nicht jeder Prozess jeden anderen nach Gutdünken beenden).

Der Systemruf exit()

Für die Selbstbeendigung eines Prozesses unter UNIX existiert der Systemruf exit(). Das Beenden eines Prozesses wirft eine kleines Problem auf: Einerseits sollen alle Ressourcen dieses Prozesses möglichst unverzüglich an das System zurückgegeben werden. Andererseits wäre es sinnvoll, eine »Rückmeldung« an das System über den Verlauf der Abarbeitung des Prozesses zu geben. Wenn Sie aber den gesamten Prozess sofort aus dem Speicher tilgen, wo soll bitte schön diese Rückmeldung abgelegt werden?

UNIX löst diesen Widerspruch durch die Einführung eines transienten Zustandes, der auf den schönen Namen Zombie hört. Nach dem Ruf von exit() holt sich das Betriebssystem die Ressourcen des Prozesses zurück, löscht ihn somit weitestgehend, bewahrt aber den so genannten Exit-Status des Prozesses, also das Argument von exit(), noch auf, und auch der zugehörige Eintrag in der Prozesstabelle wird noch nicht gelöscht.

imagesDer Begriff »Zombie« ist nicht besonders korrekt. Bei Zombies im Film handelt es sich ja um wiedererweckte Tote, die den (noch) Lebenden nach dem Leben trachten. Ein Zombie-Prozess wird jedoch nie wiederauferstehen, denn seine Ressourcen sind bereits alle an das System zurückgegeben worden.

Der Exit-Status umfasst genau ein Byte und wird normalerweise für eine Information genutzt, ob der Prozess erfolgreich abgearbeitet wurde oder ein Fehlschlag zu verzeichnen war. Dabei besteht im UNIX die Konvention, dass ein Wert von 0 einen Erfolg und jeder andere Wert einen Misserfolg (im Sinne eines Fehlercodes) anzeigt.

Synchronisation zwischen Vater und Sohn mittels wait()

Wer könnte nun an diesem Exit-Status das größte Interesse haben? Dies dürfte mit einiger Wahrscheinlichkeit der entsprechende Vaterprozess sein, schließlich hat dieser den (Kind-)Prozess irgendwann einmal mit einem konkreten Ziel in die Welt gesetzt. Genau dafür gibt es den Systemruf wait().

images

Ruft ein Prozess wait(), dann wird er in den Wartezustand geschickt, sofern er mindestens einen Kindprozess besitzt (anderenfalls kehrt der Systemruf einfach zurück). Aus diesem kehrt er zurück (und landet wiederum im Zustand »bereit«), sobald irgendeiner der Kindprozesse beendet wurde.

Hat sich bereits ein Kindprozess beendet, bevor sein Vater wait() aufrief, dann befindet sich der Kindprozess im Zombie-Zustand, wie Sie im vorangegangenen Abschnitt gerade gelernt haben. Das nächste wait() des Vaters übermittelt diesem den Exit-Status des Kindes, löscht das Kind nun vollständig aus dem Speicher und kehrt im Vaterprozess zurück.

Da es unter Umständen mehrere Kindprozesse geben kann, muss der Vater zwei verschiedene Informationen erhalten:

  • Wer hat sich beendet?
  • Wie ist der Exit-Status (der Rückgabewert) des beendeten Prozesses?

Ersteres erfährt er über den Resultatwert von wait, dessen Pseudodatentyp pid_t auf seinen Zweck hinweist: er enthält die PID des beendeten Kindes (oder -1, wenn etwas schiefging). Über die Zeigervariable wstatus erhält der Vater den Exit-Status des beendeten Prozesses, wie der folgende Code demonstriert:

images

Listing 5.3: Der Vater wartet auf das Ende des Kindprozesses

imagesBeim aufmerksamen Lesen fällt Ihnen möglicherweise das Makro WEXITSTATUS auf, das auf die Variable wstatus angewandt wird. Dies ist nötig, weil über wstatus ein Integerwert geliefert wird, der Exit-Status aber nur ein Byte umfasst. Folgerichtig haben die UNIX-Entwickler in wstatus noch mehr Informationen untergebracht, und das Makro WEXITSTATUS extrahiert daraus den passenden Teil.

Bitte beachten Sie, dass mittels wait() nur der Vater auf den Kindprozess warten kann. Andersherum (der Sohn wartet auf das Ende des Vaters) funktioniert es nicht. allerdings ist dieser Fall auch kaum nützlich.

Wenn Prozesse aufeinander warten, dann spricht man von Synchronisation. Dieses Aufeinanderwarten spielt eine große Rolle, wenn Prozesse Daten austauschen. Wir werden uns im Kapitel 7 »Synchronisation: Warten auf Godot« genauer mit Mechanismen und Algorithmen genau für diesen Zweck auseinandersetzen.

Andere Formen der Beendigung

Außer der hier vorgestellten Selbstbeendigung eines Prozesses mittels exit() gibt es noch einige andere Möglichkeiten zur Beendigung:

  • Irgendwo innerhalb von main() ruft der Prozess return n (n repräsentiert den gewünschten Exit-Status). Dies ist interessanterweise nicht äquivalent zur Nutzung von exit(), aber die Diskussion würde den Rahmen sprengen.
  • Der Prozess erreicht die }-Klammer von main(). Das ist nicht besonders sauber, führte bis zum Standard C99 zu einem undefinierten Exit-Status, seitdem ist diese Variante aber mit return 0 identisch. Sie vergessen sie am besten gleich wieder.
  • Der Prozess erhält ein Signal zugestellt, das den Prozess abbricht. Diesen Mechanismus behandeln wir in Kapitel 8 genauer. Hier sei nur angemerkt, dass dies durch den Prozess selbst, einen anderen Prozess oder durch das Betriebssystem erfolgen kann.

Sie sehen, es gibt verschiedene Wege, Prozesse aus dem System zu entfernen.

Wir basteln uns eine Shell

Mit diesen recht grundlegenden Systemrufen sind Sie in der Lage, ziemlich komplexe Probleme zu lösen. Ein Klassiker dabei ist die sogenannte Shell, im Deutschen manchmal recht holprig als »Kommandozeileninterpreter« bezeichnet. Der Terminus geht auf die Tatsache zurück, dass die Shell, ähnlich wie die Schale einer Muschel, gewissermaßen die »Innereien« des Betriebssystems umschließt und so die Grenze zwischen diesen und der Umwelt bildet.

Die Shell bildet die einfachste Möglichkeit, mit dem Betriebssystem zu kommunizieren, und ist daher, historisch gesehen, weitaus älter als grafische Benutzeroberflächen.

imagesDas ist natürlich wieder nur die halbe Wahrheit. Moderne Versionen von Shells wie die bash, die tcsh oder die zsh bringen viele Mechanismen, Hunderte von Kommandos und eine vollständige Programmiersprache zur Automatisierung von Bedienhandlungen mit.

Die Shell ist auch nicht auf Betriebssysteme aus der UNIX-Familie beschränkt. Microsoft Windows 10 bietet die PowerShell, während ältere Windows-Versionen sowie MS-DOS zumindest eine rudimentäre Shell in Form der »Eingabeaufforderung« enthalten.

Im Grunde genommen führt eine (UNIX-)Shell zyklisch die folgenden Operationen aus:

  1. ein Kommando samt Parametern vom Nutzer entgegennehmen,
  2. einen Kindprozess mittels fork() starten,
  3. den Code des Kindprozesses mittels exec() mit dem Programmcode des Kommandos überlagern,
  4. auf das Ende des Kindprozesses warten,
  5. GOTO 1.

Listing 5.4 zeigt eine rudimentäre Umsetzung:

images

images

Listing 5.4: Eine kleine Shell

Für jedes einzelne Kommando muss extra Programmcode geladen werden. Für häufig genutzte Befehle wie cd oder ls ist das nicht besonders effizient. Eine »richtige« Shell besitzt für diesen Fall eingebaute Befehle, das heißt, ihre Funktionalität ist direkt im Programmcode der Shell verankert.

Threads

Prozesse sind ein verhältnismäßig altes Konzept zur Realisierung von Parallelität. Insbesondere für die Realisierung von parallelen Abläufen innerhalb von Applikationsprogrammen entwickelte man später die Abstraktion der sogenannten Threads, die Sie im Folgenden kennenlernen werden.

Nachteile des Prozesskonzeptes

Im bislang betrachteten Prozessmodell enthält ein Adressraum genau ein ablaufendes Programm, also einen konkreten Handlungsablauf. Im Abschnitt »Umschalten zwischen Prozessen« haben Sie bereits gesehen, dass dieser Vorgang des Umschaltens teuer ist, also Rechenzeit benötigt. Darüber hinaus muss beim Umschalten zwischen Prozessen der Adressraum gewechselt werden. Dieser Vorgang ist wiederum zeitintensiv. Warum dies so ist und wie ein Adressraumwechsel vor sich geht, wird im Kapitel 9 »Hauptspeicher (RAM)« genauer erläutert.

Des Weiteren birgt das Prozesskonzept noch einen weiteren Nachteil. Die strikte Separierung der Prozesse voreinander ist zwar aus Sicht ihrer Integrität wünschenswert: Kein Prozess kann einen anderen aus dem Takt bringen, etwas in den Speicher schreiben (oder aus diesem lesen!) oder anderweitig ins Handwerk pfuschen. Wenn mehrere Prozesse jedoch gemeinsam an einer Aufgabe arbeiten und beispielsweise Daten austauschen wollen, dann ist diese Abschottung voreinander hinderlich. Sie müssen gewissermaßen Löcher in die Grenzsicherungsanlagen der Prozesse pieken, um Daten von einem zum anderen Prozess zu transferieren. Dies ist aufwendig und erfordert Hilfe des Betriebssystems in Form der sogenannten Mechanismen zur Interprozesskommunikation (oder Inter Process Communication, IPC).

Fassen wir kurz zusammen. Das Prozesskonzept birgt zwei inhärente Nachteile:

  • zusätzlichen Zeitbedarf für das Umschalten des Adressraums beim Kontextwechsel,
  • Komplexität beim Datenaustausch zwischen Prozessen.

Begriff des Threads

Die gerade beschriebenen Nachteile führten zur Überlegung, ob Parallelität der Ausführung innerhalb eines Adressraums sinnvoll wäre, und fand Niederschlag in der Entwicklung des Thread-Konzeptes.

Zum besseren Verständnis ist es günstig, noch einmal das Konzept des Prozesses zu rekapitulieren. Eingangs dieses Kapitels haben Sie gelernt, dass ein Prozess ein Programm in Abarbeitung repräsentiert, das fest einem Adressraum zugeordnet ist. Diese feste Zuordnung weicht das Thread-Konzept auf. Wir unterscheiden nun Programme in Abarbeitung, die wir künftig unabhängige Handlungsabläufe nennen, und Adressräume, die eine beliebige Anzahl an Handlungsabläufen enthalten können. Da der Begriff »unabhängiger Handlungsablauf« extrem unhandlich ist, hat sich dessen englisches Pendant »Thread« auch im Deutschen eingebürgert.

Abbildung 5.3 verdeutlicht beide Prinzipien. Im linken Teil des Bildes existieren drei herkömmliche Prozesse. Jeder Prozess besteht aus Adressraum und Handlungsablauf. Im rechten Teil der Abbildung gibt es einen Adressraum, der einen Thread enthält, sowie einen weiteren Adressraum mit drei Threads, die sich alle Daten des Adressraums teilen.

Abbildung 5.3: Drei Prozesse (links) vs. ein Adressraum mit drei Threads und ein Adressraum mit einem Thread (rechts)

Es ist ersichtlich, dass Threads keine Einschränkung, sondern nur eine Erweiterung des Prozessbegriffs darstellen. Ein herkömmlicher Prozess ist einfach ein Adressraum mit nur einem einzigen Thread.

imagesEin Thread ist ein selbstständiger identifizierbarer Handlungsablauf innerhalb eines Adressraumes. Mehrere Threads können in ein und demselben Adressraum koexistieren.

In der deutschsprachigen Literatur hat sich hier und da der Begriff »Leichtgewichtsprozess« etabliert, der aber zum einen etwas in die Irre führt – immerhin handelt es sich gerade um keinen Prozess –, zum anderen auch ganz schön unhandlich ist. Aus diesem Grunde finden Sie in diesem Buch nur Threads.

Damit gibt es nun schon zwei verschiedene Möglichkeiten, Parallelität zu implementieren. Steht die Integritätssicherung der beteiligten Komponenten im Vordergrund, dann nutzt man Adressräume mit jeweils nur einem Thread (also Prozesse), sodass alle Threads voreinander geschützt sind. Wird hingegen auf Effizienz der Kommunikation Wert gelegt und somit der Kooperationsaspekt betont, dann wird man zu einer Lösung mit mehreren Threads innerhalb eines Adressraumes greifen.

Kernel-Level- und User-Level-Threads

Man unterscheidet zwei verschiedene Ausprägungen von Threads, die davon abhängen, wer die Threads verwaltet.

Zum einen ist das Betriebssystem natürlich ideal geeignet, um Threads zu verwirklichen. Insbesondere teilt es die Prozessoren den einzelnen Threads zu (das bedeutet, dass der Scheduler über die existierenden Threads informiert ist). Die Threads durchlaufen die gleichen Zustände wie zuvor im Abschnitt für Prozesse diskutiert. Das Erzeugen neuer Threads, das Beenden von Threads, das Warten aufeinander und andere Funktionen sind somit Dienste des Betriebssystems, wir benötigen weitere Systemrufe. Jegliche Funktionalität im Zusammenhang mit Threads wird auf der Ebene des Betriebssystems erbracht, daher nennt man diese Form der Realisierung Kernel-Level-Threads. Im vorherigen Kapitel »Grundlegende Begriffe und Abstraktionen« haben Sie gelernt, dass für jeden Systemruf aus dem User Mode in den Kernel Mode und wieder zurück geschaltet werden muss, was eine gewisse Zeit benötigt. Dieser Zeitbedarf ist einer der Nachteile dieser Thread-Variante. Da das Betriebssystem die Threads verwaltet, werden diese gewöhnlich präemptiv geplant, können also zu jedem beliebigen Zeitpunkt unterbrochen werden.

Man kann Threads aber noch auf eine ganz andere Art verwirklichen: nämlich ausschließlich im User Mode des Prozessors, typischerweise in Form einer Bibliothek einer Programmiersprache. Alle Funktionen zum Verwalten (Erzeugen, Beenden, Synchronisieren) der Threads sind innerhalb dieser Bibliothek realisiert. Das Betriebssystem hat bei diesem Modell keine Kenntnis von den Threads. Da das Betriebssystem nicht involviert ist, werden User-Mode-Threads meist mit kooperativem Multitasking realisiert. Das bedeutet, dass die Threads selbst den Prozessor abgeben müssen. Die einzelnen Thread-Funktionen und insbesondere das Umschalten zwischen Threads sind sehr effizient, weil keine Systemein- und -austritte nötig sind. Sobald ein Thread jedoch durch einen Systemruf blockiert wird, blockiert er alle anderen Threads des Ensembles. Diesen Nachteil besitzen Kernel-Level-Threads wiederum nicht.

imagesDas Konzept der Threads wurde entwickelt, als die UNIX-Betriebssystemfamilie schon einige Jahre existierte. Deshalb mussten die Kernel-Level-Threads in das bestehende UNIX hineinoperiert werden, was sich als ziemlich schwierig erwies und bei der Programmierung einige Fallstricke mit sich brachte. Man einigte sich schließlich auf eine Standardisierung der zugehörigen API in Form der heute sehr populären POSIX-Threads (pthreads).

User-Level-Threads sind unter UNIX nicht allzu gebräuchlich. Die Bibliothek GNU Portable Threads (GNU Pth) ist ein Beispiel, sie wird allerdings seit geraumer Zeit nicht mehr weiterentwickelt.

Da Windows deutlich später als UNIX entwickelt wurde, sind beide Formen von Threads von Anfang an integriert. Die User-Level-Threads werden Fibers, also »Fasern«, genannt, da ein Faden gewissermaßen aus mehreren Fasern bestehen kann.

POSIX-Threads

In diesem Abschnitt sollen Sie einige Grundlagen der Programmierung mittels POSIX-Threads erlernen. Vom Standpunkt des Programmierers ist es häufig einfacher, ein Problem mittels Threads statt durch mehrere Prozesse zu parallelisieren. Die gesamte API umfasst mehrere Dutzend Systemrufe; folglich beschränken wir uns hier auf die allerwichtigsten: das Erzeugen und Beenden von Threads sowie ein bisschen Synchronisation. Alle Rufe, die mit Pthreads zu tun haben, werden durch das Präfix pthread_ gekennzeichnet.

Erzeugung und Beendigung eines Threads

Die wichtigste Funktion ist zweifellos die Erzeugung eines neuen Threads. Dies geschieht mit der Funktion pthread_create():

images

Die vier Argumente haben die folgende Funktion: Analog zu Prozessen müssen Threads im System identifiziert werden. Dies erfolgt mit einer sogenannten Thread-ID, und eine Variable dieses Typs übergeben Sie per Referenz als ersten Parameter. Nach erfolgreicher Ausführung des Rufs befindet sich in der Variable die durch das System zugewiesene ID. Mittels des zweiten Arguments kann man bestimmte Eigenschaften (man spricht von »Attributen«) des Threads von Beginn an modifizieren. Es ist gebräuchlich, einfach an dieser Stelle einen NULL-Zeiger zu übergeben: Damit werden die Attribute des Threads auf sinnvolle Voreinstellungen initialisiert. Das dritte Argument ist für C-Anfänger syntaktisch ein bisschen schwierig. Es handelt sich um die Adresse einer Funktion, die ihrerseits einen void*-Zeiger als Argument erhält und ebenso einen void*-Zeiger als Funktionswert zurückliefert.

Ein Thread beginnt seine Arbeit mit dieser Funktion und bewegt sich zeit seines Lebens in ihr. Er kann natürlich andere Funktionen rufen, die er auch wieder verlassen kann, nur seine »Ursprungsfunktion« darf er nicht beenden. In diesem Falle endet der Thread. Der Parameter der Thread-Funktion ist ein allgemeiner Zeiger, über den ihm initial ein Datum übergeben werden kann. Dieses ist gleichzeitig der vierte Parameter arg von pthread_create().

Das Resultat von pthread_create() ist 0, wenn alles in Ordnung war, oder ein Wert ungleich 0, wenn irgendetwas schiefgegangen ist.

Probieren Sie Threads am besten gleich selbst aus! Listing 5.5 zeigt das beliebte Hello, world!-Beispiel implementiert mit POSIX-Threads. Wenn Sie es übersetzen, vergessen Sie bitte nicht, gegen die pthreads-Bibliothek zu linken. Dies geschieht mittels des zusätzlichen Switches -lpthread beim Aufruf des gcc, der somit so aussieht:

images

Des Weiteren muss das Headerfile pthread.h mittels #include-Direktive in den Quelltext eingeschlossen werden.

images

Listing 5.5: „Hello, world“ mit Pthreads

Der »Ursprungsthread« erzeugt zunächst einen neuen Thread. Dieser soll die Funktion thread_f() abarbeiten, und er erhält als Parameter die Zeichenkette »world!« übergeben.

Wenn die Erzeugung erfolgreich war, wird dies vermeldet, und die Thread-ID (eigentlich ein unsigned long) wird auf der Konsole ausgegeben. Danach gibt der Ursprungsthread »Hello,« auf der Konsole aus, schließt die Zeile jedoch nicht ab. Der folgende Aufruf von pthread_join() wartet auf das Ende des Threads, der als TID im ersten Parameter übergeben wird. Kommt Ihnen das bekannt vor? Die Semantik ist die gleiche wie bei wait() (genauer gesagt, waitpid()), nur geht es hier anstelle von Prozessen um Threads.

Der erzeugte Thread gibt seinerseits auf der Konsole »world!« aus und schließt die Zeile ab. Dann beendet er seine Arbeit mittels pthread_exit() (ganz recht, das ist das Pendant zu exit()). Dessen Parameter &x ist der Resultatwert des Threads, der wiederum im pthread_join()-Aufruf des Ursprungsthreads mit Hilfe des Parameters retval abgefragt wird. Dieser Mechanismus ist leider ebenfalls ein bisschen komplizierter als der der Prozesswelt mittels exit() und wait(), denn es wird ein allgemeiner Zeiger und kein numerischer Rückgabewert geliefert. Die vollständige Diskussion dieses Mechanismus müssen wir der Fachliteratur überlassen. Es sei aber zumindest angemerkt, dass im endenden Thread kein Zeiger auf eine lokale Variable übergeben werden darf, weil diese sich auf dem Stack des endenden Threads befindet und der Stack durch das Ende des Threads vernichtet wird. Im Beispielcode wird stattdessen eine Referenz in das Datensegment in Form der Adresse von x benutzt.

Das Beispiel weist noch ein Problem auf: Genauso wie Prozesse sind Threads ab ihrer Erzeugung sofort in der Bereit-Menge und können potenziell loslegen. Es ist also auch denkbar, dass nach pthread_create() zunächst der erzeugte Thread an der Reihe ist, und »world!« ausgibt, bevor der Ursprungsthread die TID auf die Konsole schreibt und dazu »Hello, «. Wenn Sie das Binary häufig genug ausführen, dann werden Sie dieses Verhalten irgendwann sehen; probieren Sie es aus! Die Ausgabe sieht dann ungefähr so aus:

images

Wir haben es mit einem Synchronisationsproblem zu tun. Die Mittel, um solche und ähnliche Probleme universell zu lösen, erlernen Sie in Kapitel 7.

Es ist ersichtlich, dass unabhängig davon, ob Prozesse oder Threads zur parallelen Programmierung genutzt werden, in beiden Fällen ähnliche Funktionalität zur Verfügung gestellt wird.

Mehrere Threads in einem Adressraum

So weit, so gut. Bis hierhin könnten Sie sich möglicherweise fragen, warum man unbedingt Threads benötigt, wenn es doch Prozesse gibt. Der große Vorteil besteht darin, dass mehrere Threads sich ein und denselben Adressraum teilen können und somit sehr leicht miteinander kommunizieren können.

Zur Verdeutlichung gehen Sie bitte noch einmal zurück in das Kapitel zur C-Programmierung. Um das for-Statement zu demonstrieren, sollte die Summe der natürlichen Zahlen von 1 bis 100 addiert werden. Das Programm in Listing 3.7 leistete das Gewünschte. Besitzt man eine Mehrprozessor- oder Multicore-Maschine, dann könnte man den Vorgang beschleunigen, indem man zwei Threads anlegt und einen die geraden und den anderen die ungeraden Zahlen addieren lässt. Zum Schluss müssen beide Teilergebnisse natürlich noch zur Summe addiert werden.

images

Listing 5.6: Zwei Threads addieren natürliche Zahlen

Listing 5.6 zeigt das zugehörige Programm. Der Ursprungsthread erzeugt diesmal zwei Threads, die beide die gleiche Funktion thread_f ausführen. Beide summieren alle Zahlen n comma n plus 2 comma n plus 4 comma ellipsis ausgehend von einem per Parameter übergebenen Startwert n bis zum Symbol LIMIT. Da einer der beiden die geraden und der andere die ungeraden Zahlen summieren soll, erhalten sie per Argument zwei verschiedene Startwerte 0 und 1.

Am Ende legen beide ihre in der jeweiligen Instanz der Variablen partsum aufbewahrten Teilsummen in jeweils einer globalen Variable sum[] ab, die über den Startwert indexiert wird.

imagesEs wäre auch möglich, dass die Threads das Aufaddieren auf eine einzige Variable selbst vornehmen, aber dann muss man aufpassen, dass die beiden sich nicht ins Gehege kommen, das bedeutet das Aufaddieren gleichzeitig vornehmen. Dem zugrunde liegenden Phänomen des kritischen Abschnitts und dessen korrektem Management ist das Kapitel 7 gewidmet.

Der Ursprungsthread wartet auf das Ende der beiden, summiert danach in aller Ruhe die beiden Teilergebnisse und gibt das Ergebnis aus.

Bitte versuchen Sie nicht, den »Geschwindigkeitsgewinn« durch diese Parallelisierung zu ermitteln. Der zusätzliche Aufwand für das Management beider Threads ist viel größer als das, was Sie durch die Parallelausführung gewinnen. Das Beispiel soll nur demonstrieren, wie Threads prinzipiell arbeiten.

Lesevorschläge

  • Wenn Sie tiefer in die Programmierung mit Pthreads einsteigen wollen, ist [19] trotz seines Alters eine ausgezeichnete Einführung.

Übungsaufgaben

  1. Entwickeln Sie ein C-Programm, das einen Sohn erzeugt. Beide Prozesse sollen dann in einer Schleife jeweils ihre PID ausgeben und danach eine Sekunde schlafen. Überzeugen Sie sich, dass in der Regel bei jeder Aktivierung eine andere Reihenfolge der Ausgaben resultiert.
  2. Erweitern Sie die Mini-Shell aus Listing 5.4 um ein internes ls-Kommando. Sie benötigen dafür die Systemrufe opendir(), readdir() und closedir().
  3. Versuchen Sie, das Beispiel der zwei addierenden Threads auf Prozesse zu adaptieren. Welches prinzipielle Problem haben Sie dabei?
  4. Modifizieren Sie das Beispiel der zwei addierenden Threads für eine variable Anzahl an Threads.
  5. Versuchen Sie, herauszufinden, was geschieht, wenn aus einem Prozess, der zwei Threads enthält, fork() gerufen wird. Wie viele Threads enthält der Sohnprozess?