Kapitel 5
IN DIESEM KAPITEL
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.
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.
Der Begriff »Prozess« hat viele Bedeutungen, so im juristischen Sinne oder in der Arbeitsorganisation. Im Kontext von Betriebssystemen erlernen Sie nun (wahrscheinlich) eine weitere.
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.
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.
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):
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.
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 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.
Stehen nicht genügend initiale Ressourcen zur Verfügung, dann schlägt die Erzeugung des neuen Prozesses fehl.
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:
fork()
fehlschlägt, erhält der Vater den Wert -1.Somit können Sie im Programmcode unterscheiden, wer Vater und wer Sohn ist, wie das Programmbeispiel in Listing 5.1 illustrieren soll.
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«).
exec
-MechanismusMoment 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:
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:
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
.
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).
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.
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.
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()
.
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:
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:
Listing 5.3: Der Vater wartet auf das Ende des Kindprozesses
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.
Außer der hier vorgestellten Selbstbeendigung eines Prozesses mittels exit()
gibt es noch einige andere Möglichkeiten zur Beendigung:
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.}
-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.Sie sehen, es gibt verschiedene Wege, Prozesse aus dem System zu entfernen.
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.
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:
fork()
starten,exec()
mit dem Programmcode des Kommandos überlagern,Listing 5.4 zeigt eine rudimentäre Umsetzung:
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.
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.
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:
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.
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.
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.
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.
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.
Die wichtigste Funktion ist zweifellos die Erzeugung eines neuen Threads. Dies geschieht mit der Funktion pthread_create()
:
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:
Des Weiteren muss das Headerfile pthread.h
mittels #include
-Direktive in den Quelltext eingeschlossen werden.
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:
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.
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.
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 ausgehend von einem per Parameter übergebenen Startwert
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.
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.
ls
-Kommando. Sie benötigen dafür die Systemrufe opendir()
, readdir()
und closedir()
.fork()
gerufen wird. Wie viele Threads enthält der Sohnprozess?