1.3 Prozesse, Tasks und Threads
Nachdem wir nun die Grundlagen geklärt haben, wollen wir auf die interessanten Einzelheiten zu sprechen kommen.
Wir werden die Begriffe Prozess, Thread und Task sowie deren »Lebenszyklen« im System analysieren und dabei auch einen kurzen Blick auf das Scheduling werfen. Ebenfalls interessant wird ein Abschnitt über die Implementierung dieser Strukturen im Kernel werden.
Was in diesem Kapitel zum Kernel über Prozesse fehlt – die Administration und die Userspace-Sicht – können Sie in einem eigenen Kapitel nachlesen. Im aktuellen Kernel-Kontext interessieren uns eher das Wesen und die Funktion als die konkrete Bedienung, die eigentlich auch nichts mehr mit dem Kernel zu tun hat – sie findet ja im Userspace <Eigentlich sollte intuitiv klar sein, was Userspace bedeutet: Wörtlich übersetzt heißt das Wort ja »Raum des Benutzers«, damit also der eingeschränkte Ring 3 gemeint, in dem alle Programme ausgeführt werden.> statt. <Wenn Sie an dieser Stelle den Einspruch wagen, dass die zur Administration der Prozesse benutzten Programme nun auch wieder über Syscalls auf den Kernel zugreifen und so ihre Aktionen erst ausführen können, sind wir sehr stolz auf Sie: Sie haben verstanden, worum es geht.>
1.3.1 Definitionen
Beginnen wir also mit den Definitionen. Anfangs haben wir bereits die aus der Erwartung der Benutzer resultierende Notwendigkeit für den Multitasking-Betrieb erläutert. Der Benutzer möchte schließlich mehrere Programme gleichzeitig ausführen können, die Zeiten von MS-DOS und des Singletaskings sind schließlich vorbei. Die Programme selbst liegen dabei als Dateien irgendwo auf der Festplatte.
Prozess
Programm samt Laufzeitdaten
Wird so ein Programm nun ausgeführt, spricht man von einem Prozess. Dabei hält das Betriebssystem natürlich noch einen ganzen Kontext weiterer Daten vor: die ID des ausführenden Benutzers, bereits verbrauchte Rechenzeit, alle geöffneten Dateien ... – alles, was für die Ausführung wichtig ist.
Ein Prozess ist ein Programm in Ausführung.
Besonders hervorzuheben ist natürlich, dass jeder Prozess seinen eigenen virtuellen Adressraum besitzt.
Wie dieser Speicherbereich genau organisiert ist, werden wir später noch im Detail klären. Im Moment ist jedoch wichtig zu wissen, dass im virtuellen Speicher nicht nur die Variablen des Programms, sondern auch der Programmcode selbst sowie der Stack enthalten sind.
Der Stack
Funktionsaufrufe abbilden
Der Stack ist dabei eine besondere Datenstruktur, über die man Funktionsaufrufe besonders gut abbilden kann.
Diese Datenstruktur bietet nämlich folgende Operationen:
- push
- Mit dieser Operation kann man ein neues Element auf den Stack legen. Damit erweitert man die Datenstruktur um ein Element, das gleichzeitig zum aktuellen wird.
- top
- Mit dieser Operation kann man auf das oberste beziehungsweise aktuelle Element zugreifen und es auslesen. Wendet man diese Operation an, so bekommt man also das letzte per push auf den Stack geschobene Element geliefert.
- pop
- Mit dieser Operation kann man schließlich das oberste Element vom Stack löschen. Beim nächsten Aufruf von top würde man also das Element unter dem von pop gelöschten Element geliefert bekommen.
Die einzelnen Funktionen und ihre Auswirkungen auf den Stack sind auch auf der folgenden Abbildung veranschaulicht – push und pop bewirken jeweils eine Veränderung der Datenstruktur, während top auf der aktuellen Datenstruktur operiert und das oberste Element zurückgibt:
Abbildung 1.3 Das Prinzip eines Stacks
Um zu veranschaulichen, wieso diese Datenstruktur so gut das Verhalten von Funktionsaufrufen abbilden kann, betrachten wir nun dieses kleine C-Beispiel:
#include <stdio.h> void funktion1() { printf("Hello World! \n"); return; } int main() { funktion1(); return 0; }
Listing 1.7 Ein modifiziertes »Hello World!«-Programm
Verschachtelte Aufrufe
In diesem Beispiel wird die Funktion funktion1() aus dem Hauptprogramm heraus ausgerufen. Diese Funktion wiederum ruft die Funktion printf() mit einem Text als Argument auf. Der Stack für die Funktionsaufrufe verändert sich während der Ausführung wie folgt:
- Start des Programms
- Beim Start des Programms ist der Stack zwar vom Betriebssystem initialisiert, aber im Prinzip noch leer. <Das ist zwar nicht ganz richtig, aber für uns erst einmal uninteressant. >
- Aufruf von funktion1()
- An dieser Stelle wird der Stack benutzt, um sich zu merken, wo es nach der Ausführung von funktion1() weitergehen soll. Im Wesentlichen wird also das Befehlsregister auf den Stack geschrieben – und zwar mit der Adresse des nächsten Befehls nach dem Aufruf der Funktion: von return. Es werden in der Realität auch noch andere Werte auf den Stack geschrieben, aber die sind hier für uns uninteressant. Schließlich wird der Befehlszeiger auf den Anfang von funktion1() gesetzt, damit mit dieser Funktion weitergemacht werden kann.
- In der Funktion funktion1()
- Hier wird sofort eine weitere Funktion aufgerufen: printf() aus der Standard-C-Bibliothek mit dem Argument »Hello World!«. Auch hier wird wieder die Adresse des nächsten Befehls auf den Stack geschrieben. Außerdem wird auch das Argument für die Funktion, also eben unser Text, auf dem Stack abgelegt. <Es gibt auch Rechnerarchitekturen, bei denen man die Parameterübergabe anders regelt. Dass auf einem MIPS-System die Parameter über die Prozessorregister übergeben werden, haben Sie in unserem Syscall-Beispiel direkt in Assembler schon sehen können.>
- Die Funktion printf()
- Die Funktion kann jetzt auf den Stack sehen und das Argument lesen und somit unseren Text schreiben. Nach der Funktion wird der vor dem Aufruf von printf() auf den Stack gelegte Befehlszeiger ausgelesen und das Befehlsregister mit diesem Wert gefüllt. So kann schließlich die Funktion funktion1() ganz normal mit dem nächsten Befehl nach dem Aufruf von printf() weitermachen. Das ist nun schon ein Rücksprungbefehl, der das Ende der Funktion anzeigt.
- Das Ende
- Wir kehren also ins Hauptprogramm zurück und verfahren dabei analog wie beim Rücksprung von printf() zu funktion1(). Wie man sieht, eignet sich der Stack also prächtig für Funktionsaufrufe: Schließlich wollen wir nach dem Ende einer Funktion in die aufrufende Funktion zurückkehren – alle anderen interessieren uns nicht.
- Mit dem Rücksprung nach main() ist unser Programm nun auch schon am Ende und wird vom Betriebssystem beendet werden.
Wie gesagt liegen noch mehr Daten auf dem Stack als nur der Befehlszeiger oder die Funktionsargumente – aber damit werden wir uns später noch auseinandersetzen.
Thread
Kommen wir also zur nächsten Definition. Im letzten Abschnitt haben wir einen Prozess als ein Programm in Ausführung definiert. Jedoch ist klar, dass die eigentliche Ausführung nur von den folgenden Daten abhängt:
- dem aktuellen Befehlsregister
- dem Stack
- dem Inhalt der Register des Prozessors
Ausführungsfaden
Ein Prozess besteht nun vereinfacht gesagt aus dem eigenen Speicherbereich, dem Kontext (wie zum Beispiel den geöffneten Dateien) und genau einem solchen Ausführungsfaden, einem sogenannten Thread.
Ein Thread ist ein Ausführungsfaden, der aus einem aktuellen Befehlszeiger, einem eigenen Stack und dem Inhalt der Prozessorregister besteht.
Ein auszuführendes Programm kann nun theoretisch auch mehrere solcher Threads besitzen. Das bedeutet, dass diese Ausführungsfäden quasiparallel im selben Adressraum laufen. Diese Eigenschaften ermöglichen ein schnelles Umschalten zwischen verschiedenen Threads einer Applikation. Außerdem wird durch die Möglichkeit zur parallelen Programmierung einer Applikation die Arbeit des Programmierers teilweise deutlich vereinfacht.
User- oder Kernelspace?
Threads müssen dabei nicht notwendigerweise im Kernel implementiert sein: Es gibt nämlich auch sogenannte Userlevel-Threads. Für das Betriebssystem verhält sich die Applikation wie ein normaler Prozess mit einem Ausführungsfaden. Im Programm selbst sorgt jetzt jedoch eine besondere Bibliothek dafür, dass eigens angelegte Stacks richtig verwaltet und auch die Threads regelmäßig umgeschaltet werden, damit die parallele Ausführung gewährleistet ist.
Außerdem gibt es neben den dem Kernel bekannten KLTs (Kernellevel-Threads) und den eben vorgestellten PULTs (Puren Userlevel-Threads) auch noch Kernelmode-Threads. Diese Threads sind nun Threads des Kernels und laufen komplett im namensgebenden Kernelmode. Normalerweise wird der Kernel eigentlich nur bei zu bearbeitenden Syscalls und Interrupts aktiv. Es gibt nun aber auch Arbeiten, die unabhängig von diesen Zeitpunkten vielleicht sogar periodisch ausgeführt werden müssen. Solche typischen Aufgaben sind zum Beispiel das regelmäßige Aufräumen des Hauptspeichers oder das Auslagern von lange nicht mehr benutzten Speicherseiten auf die Festplatte, wenn der Hauptspeicher mal ganz besonders stark ausgelastet ist.
Task
Prozess + x Threads
Ein Task ist eigentlich nichts anderes als ein Prozess mit mehreren Threads. Zur besseren Klarheit wird teilweise auch die folgende Unterscheidung getroffen: Ein Prozess hat einen Ausführungsfaden, ein Task hat mehrere.
Im Besonderen ist also ein Prozess ein Spezialfall eines Tasks. Aus diesem Grund gibt es auch keine Unterschiede bei der Realisierung beider Begriffe im System.
Unter Unix spricht man trotzdem meistens von Prozessen, da hier das Konzept der Threads im Vergleich zur langen Geschichte des Betriebssystems noch relativ neu ist. So hat zum Beispiel Linux erst seit Mitte der 2.4er-Reihe eine akzeptable Thread-Unterstützung. Vorher war die Erstellung eines neuen Threads fast langsamer als die eines neuen Prozesses, was dem ganzen Konzept natürlich wiederspricht. Aber mittlerweile ist die Thread-Unterstützung richtig gut, und somit ist auch eines der letzten Mankos von Linux beseitigt.
Identifikationsnummern
Damit das Betriebssystem die einzelnen Prozesse, Threads und Tasks unterscheiden kann, wird allen Prozessen beziehungsweise Tasks eine Prozess-ID (PID) zugewiesen. Diese PIDs sind auf jeden Fall eindeutig im System.
Threads haben entsprechend natürlich eine Thread-ID (TID). Ob TIDs nun aber im System oder nur innerhalb eines Prozesses eindeutig sind, ist eine Frage der Thread-Bibliothek. Ist diese im Kernel implementiert, ist es sehr wahrscheinlich, dass die IDs der Threads mit den IDs der Prozesse im System eindeutig sind. Schließlich gilt es für das Betriebssystem herauszufinden, welcher Prozess oder Thread als Nächstes laufen soll. Dazu ist natürlich ein eindeutiges Unterscheidungsmerkmal wichtig. Allerdings könnte auch das Tupel (Prozess-ID, Thread-ID) für eine solche eindeutige Identifizierung herangezogen werden, falls die TID nur für jeden Prozess eindeutig ist.
Ist der Thread-Support nur im Userspace über eine Bibliothek implementiert, so ist eine eindeutige Identifizierung für den Kernel unnötig – er hat mit der Umschaltung der Threads nichts zu tun. So werden die TIDs nur innerhalb des betreffenden Tasks eindeutig sein.
Mehr zu PIDs erfahren Sie in Kapitel 24.
1.3.2 Lebenszyklen eines Prozesses
Der nächste wichtige Punkt – die Lebenszyklen eines Prozesses – betrifft also auch Tasks; Threads spielen in diesem Kontext keine Rolle.
Unterschiedliche Zustände
Ein Prozess hat also verschiedene Lebensstadien. Das wird schon deutlich, wenn man sich veranschaulicht, dass ein Prozess erstellt, initialisiert, verarbeitet und beendet werden muss. Außerdem gibt es ja noch den Fall, dass ein Prozess blockiert ist – wenn er zum Beispiel auf eine (Tastatur-)Eingabe des Benutzers wartet, dieser sich aber Zeit lässt.
Prozesserstellung
Zuerst wollen wir jedoch die Geburt eines neuen Prozesses betrachten. Dabei interessieren zuerst die Zeitpunkte, an denen theoretisch neue Prozesse erstellt werden könnten:
- Systemstart
- Request eines bereits laufenden Prozesses zur Erstellung eines neuen Prozesses
- Request eines Users zur Erstellung eines neuen Prozesses
- Starten eines Hintergrundprozesses (Batch-Job)
Der »Urprozess« init
Sieht man sich diese Liste näher an, so fällt auf, dass die letzten drei Punkte eigentlich zusammengefasst werden können: Wenn man einen neuen Prozess als Kopie eines bereits bestehenden Prozesses erstellt, braucht man sich nur noch beim Systemstart um einen Urprozess zu kümmern. Von diesem werden schließlich alle anderen Prozesse kopiert, indem ein Prozess selbst sagt, dass er kopiert werden möchte. Da die Benutzer das System ausschließlich über »Programme« bedienen, können die entsprechenden Prozesse natürlich auch selbst die Erstellung eines neuen Prozesses veranlassen – Punkt 3 wäre damit also auch abgedeckt. Bleiben noch die ominösen Hintergrundjobs: Aber wenn diese durch einen entsprechenden Dienst auf dem System gestartet werden, reicht auch für diese Arbeit das einfache Kopieren eines Prozesses aus.
fork() und exec*()
Eine solche Idee impliziert aber auch die strikte Trennung vom Starten eines Prozesses und dem Starten eines Programms. So gibt es denn auch zwei Syscalls: fork() kopiert einen Prozess, sodass das alte Programm dann in zwei Prozessen ausgeführt wird, und exec*() ersetzt in einem laufenden Prozess das alte Programm durch ein neues. Offensichtlich kann man die häufige Aufgabe des Startens eines neuen Programms in einem eigenen Prozess dadurch erreichen, dass erst ein Prozess kopiert und dann in der Kopie das neue Programm gestartet wird.
Beim fork()-Syscall muss man also nach dem Aufruf unterscheiden können, ob man sich im alten oder im neuen Prozess befindet. Eine einfache Möglichkeit dazu ist der Rückgabewert des Syscalls: Dem Kind wird 0 als Ergebnis des Aufrufs zurückgegeben, dem Vater die ID des Kindes:
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
int main()
{
if( fork() == 0 )
{
// Hier ist der Kindprozess
printf("Ich bin der Kindprozess! \n");
}
else
{
// Hier ist der Elternprozess
printf("Ich bin der Vaterprozess! \n");
}
return 0;
}
Listing 1.8 Ein fork-Beispiel
Im obigen Beispiel wurde dabei die Prozess-ID (PID), die der fork()- Aufruf dem erzeugenden Prozess übergibt, ignoriert. Wir haben diese Rückgabe nur auf 0 überprüft, die den Kindprozess anzeigt. Würde den erzeugenden Prozess anders als in diesem Beispiel doch die PID des Kindes interessieren, würde man den Rückgabewert von fork() einer Variablen zuweisen, um schließlich diese Variable entsprechend auszuwerten.
#include <sys/types.h> #include <unistd.h> #include <stdio.h> int main() { if( fork() == 0 ) { execl( "/bin/ls", "ls", NULL ); } return 0; }
Listing 1.9 Das Starten eines neuen Programms in einem eigenen Prozess
Ein neues Programm starten
In diesem Beispiel haben wir wie beschrieben ein neues Programm gestartet: Zuerst wird ein neuer Prozess erzeugt, in dem dann ein neues Programm gestartet wird. Beim Starten eines neuen Programms wird natürlich fast der gesamte Kontext des alten Prozesses ausgetauscht – am wichtigsten ist dabei der virtuelle Speicher.
Das neue Programm bekommt nämlich einen neuen Adressraum ohne die Daten des erzeugenden Prozesses. Das erfordert auf den ersten Blick eine unnötige doppelte Arbeit: Beim fork() wird der Adressraum erst kopiert und eventuell sofort wieder verworfen und durch einen neuen, leeren Speicherbereich ersetzt.
Dieses Dilemma umgeht man durch ein einfaches wie geniales Prinzip: Copy on Write. Beim fork() wird der Adressraum ja nur kopiert, die Adressräume von Vater- und Kindprozess sind also direkt nach der Erstellung noch identisch. Daher werden die Adressräume auch nicht real kopiert, sondern die Speicherbereiche werden als »nur-lesbar« markiert. Versucht nun einer der beiden Prozesse, auf eine Adresse zu schreiben, bemerkt das Betriebssystem den Fehler und kann den betreffenden Speicherbereich kopieren – diesmal natürlich für beide schreibbar.
Die Prozesshierarchie
Familien- beziehungen
Wenn ein Prozess immer von einem anderen erzeugt wird, ergibt sich eine Prozesshierarchie. Jeder Prozess – außer der beim Systemstart erzeugte init-Prozess – hat also einen Vaterprozess. So kann man aus allen Prozessen zur Laufzeit eine Art Baum konstruieren, mit init als gemeinsamer Wurzel.
Eine solche Hierarchie hat natürlich gewisse Vorteile bei der Prozessverwaltung: Die Programme können so nämlich über bestimmte Syscalls die Terminierung samt eventueller Fehler überwachen. Außerdem war in den Anfangsjahren von Unix das Konzept der Threads noch nicht bekannt, und so musste Nebenläufigkeit innerhalb eines Programms über verschiedene Prozesse realisiert werden. Dies ist der Grund, warum es eine so strenge Trennung von Prozess und Programm gibt und warum sich Prozesse selbst kopieren können, was schließlich auch zur Hierarchie der Prozesse führt.
Das Scheduling
Den nächsten Prozess auswählen
Nach der Prozesserstellung soll ein Prozess natürlich entsprechend dem Multitasking-Prinzip abgearbeitet werden.
Dazu müssen nun alle im System vorhandenen Threads und Prozesse nacheinander jeweils eine gewisse Zeit den Prozessor nutzen können. Die Entscheidung, wer wann wie viel Rechenleistung bekommt, obliegt einem besonderen Teil des Betriebssystems, dem Scheduler.
Als Grundlage für das Scheduling dienen dabei bestimmte Prozesszustände, diverse Eigenschaften und eine ganze Menge Statistik. Betrachten wir jedoch zuerst die für das Scheduling besonders interessanten Zustände:
- RUNNING
- Den Zustand Running kann auf einem Einprozessorsystem nur ein Prozess zu einer bestimmten Zeit haben: Dieser Zustand zeigt nämlich an, dass dieser Prozess jetzt gerade die CPU benutzt.
- READY
- Andere lauffähige Prozesse haben den Zustand Ready. Diese Prozesse stehen also dem Scheduler für dessen Entscheidung, welcher Prozess als nächster laufen soll, zur Verfügung.
- BLOCKED
- Im Gegensatz zum Ready-Zustand kann ein Prozess natürlich auch aus den verschiedensten Gründen blockiert sein: wenn ein Prozess zum Beispiel auf Eingaben von der Tastatur wartet, ohne bestimmte Datenpakete aus dem Internet nicht weiterrechnen oder aus sonstigen Gründen gerade nicht arbeiten kann.
Prozesse wieder befreien
Wenn die Daten für einen solchen wartenden Prozess ankommen, wird das Betriebssystem üblicherweise über einen Interrupt informiert. Das Betriebssystem kann dann die Daten bereitstellen und durch Setzen des Prozessstatus auf »READY« diesen Prozess wieder freigeben.
Natürlich gibt es noch weitere Zustände, von denen wir einige in späteren Kapiteln näher behandeln werden.Mit diesen hat nämlich weniger der Scheduler als vielmehr der Benutzer Kontakt, daher sind sie hier nicht weiter aufgeführt.
Prozesse bevorzugen
Weitere wichtige Daten für den Scheduler sind natürlich eventuelle Prioritäten für spezielle Prozesse, die besonders viel Rechenzeit bekommen sollen. Auch muss natürlich festgehalten werden, wie viel Rechenzeit ein Prozess im Verhältnis zu anderen Prozessen schon bekommen hat.
Wie sieht nun aber der Scheduler in Linux genau aus? Die oben genannten Prinzipien werden recht einfach realisiert:
Der Kernel verwaltet zwei Listen mit allen lauffähigen Prozessen: eine Liste mit den Prozessen, die schon gelaufen sind, und die andere mit allen Prozessen, die noch nicht gelaufen sind. Hat ein Prozess nun seine Zeitscheibe beendet, wird er in die Liste mit den abgelaufenen Prozessen eingehängt und aus der anderen Liste entfernt.
Ist die Liste mit den noch nicht abgelaufenen Prozessen beendet, so werden die beiden Listen einfach getauscht.
Eine Zeitscheibe (»timeslice«), in der ein Prozess rechnet, dauert unter Linux übrigens maximal 1/1000 Sekunde. Eine Zeitscheibe kann natürlich auch vorher abgebrochen werden, wenn das Programm zum Beispiel auf Daten wartet und dafür einen blockierenden Systemaufruf benutzt hat.
Bei der Auswahl des nächsten abzuarbeitenden Prozesses werden dabei interaktive vor rechenintensiven Prozessen bevorzugt. Interaktive Prozesse interagieren mit dem Benutzer und brauchen so meist eine geringe Rechenzeit.
Dafür möchte man aber als Benutzer bei diesen Programmen häufig eine schnelle Reaktion haben, da eine Verzögerung zum Beispiel bei der grafischen Oberfläche X11 sehr negativ auffallen würde. Der Scheduler im Linux-Kernel besitzt nun ausgefeilte Algorithmen, um festzustellen, von welcher Art nun ein bestimmer Prozess ist und wo Grenzfälle am besten eingeordnet werden.
Details zur Priorisierung von Prozessen und den Zusammenhang mit dem Scheduling erfahren Sie in Kapitel 24.
Prozessbeendigung
Irgendwann wird jedoch jeder Prozess einmal beendet. Dafür kann es natürlich verschiedenste Gründe geben, je nachdem, ob sich ein Prozess freiwillig beendet oder beendet wird:
- normales Ende (freiwillig)
- Ende aufgrund eines Fehlers (freiwillig)
- Ende aufgrund eines Fehlers (unfreiwillig)
- Ende aufgrund eines Signals eines anderen Prozesses (unfreiwillig)
Die meisten Prozesse beenden sich, weil sie ihre Arbeit getan haben. Ein Aufruf des find-Programms durchsucht zum Beispiel die gesamte Festplatte nach bestimmten Dateien. Ist die Festplatte durchsucht, beendet sich das Programm. Viele Programme einer grafischen Oberfläche geben dem Benutzer die Möglichkeit, durch einen Klick auf »das Kreuz rechts oben« das Fenster zu schließen und die Applikation zu beenden – also auch eine Art des freiwilligen Beendens. Diesem ging eben nur ein entsprechender Wunsch des Benutzers voraus.
Fehler!
Im Gegensatz dazu steht das freiwillige Beenden eines Programms aufgrund eines Fehlers. Möchte man zum Beispiel mit dem gcc eine Quelldatei übersetzen, hat sich aber dabei im Namen vertippt, wird Folgendes passieren:
\# gcc -o test tset.c gcc: tset.c: Datei oder Verzeichnis nicht gefunden gcc: keine Eingabedateien #
Listing 1.10 Freiwilliges Beenden des gcc
exit()
Damit sich Programme so freiwillig beenden können, brauchen sie natürlich einen Syscall. Unter Unix heißt dieser Aufruf exit(), und man kann ihm auch noch eine Zahl als Rückgabewert übergeben. Über diesen Rückgabewert können zum Beispiel Fehler und teilweise sogar die Fehlerursache angegeben werden. Ein Rückgabewert von 0 signalisiert dabei ein »Alles okay, keine Fehler«. In der Shell kann man über die Variable »$?« auf den Rückgabewert des letzten Prozesses zugreifen. Nach dem obigen Beispiel eines freiwilligen Endes aufgrund eines Fehlers bekommt man folgendes Ergebnis:
# echo $? 1 #
Listing 1.11 Rückgabewert ungleich null: Fehler
Aber wie sieht nun ein vom Betriebssystem erzwungenes Ende aus? Dieses tritt vor allem auf, wenn ein vom Programm nicht abgefangener und nicht zu reparierender Fehler passiert. Dies kann zum Beispiel eine versteckte Division durch null sein, wie sie bei folgendem kleinen C-Beispiel auftritt:
#include <stdio.h> int main() { int a = 2; int c; // Die fehlerhafte Berechnung c = 2 / (a – 2); printf("Nach der Berechnung. \n"); return 0; }
Listing 1.12 Ein Beispielcode mit Division durch null
Will man dieses Beispiel nun übersetzen, ausführen und sich schließlich den Rückgabewert ansehen, muss man wie folgt vorgehen:
# gcc -o test test.c # ./test Gleitkomma-Ausnahme # echo $? 136
Listing 1.13 Den Fehler betrachten
Böse Fehler!
Der Punkt, an dem der Text »Nach der Berechnung.« ausgegeben werden sollte, wird also nicht mehr erreicht. Das Programm wird vorher vom Betriebssystem abgebrochen, nachdem es von einer Unterbrechung aufgrund dieses Fehlers aufgerufen wurde. Es stellt fest, dass das Programm einen Fehler gemacht und dafür keine Routine zur einer entsprechenden Behandlung vorgesehen hat – also wird der Prozess beendet. Einen solchen Fehler könnte ein Programm noch abfangen, aber für bestimmte Fehler geht auch dies nicht mehr. Sie werden ein solches Beispiel im Abschnitt über Speichermanagement kennenlernen, wenn auf einen vorher nicht mit malloc() angeforderten virtuellen Speicherbereich zugegriffen und damit ein »Speicherzugriffsfehler« provoziert wird.
Kommunikation zwischen Prozessen
Jetzt müssen wir nur noch die letzte Art eines unfreiwilligen Prozessendes erklären: die Signale. Signale sind ein Mittel der Interprozesskommunikation (IPC), über die es in diesem Buch ein ganzes eigenes Kapitel gibt.
Die dort beschriebenen Mechanismen regeln die Interaktion und Kommunikation der Prozesse miteinander und sind so für die Funktionalität des Systems sehr bedeutend. Ein Mittel dieser IPC ist nun das Senden der Signale, von denen zwei in diesem Zusammenhang für das Betriebssystem besonders wichtig sind:
- SIGTERM
- Dieses Signal fordert einen Prozess freundlich auf, sich zu beenden. Der Prozess hat dabei die Möglichkeit, dieses Signal abzufangen und noch offene temporäre Dateien zu schließen oder sonst alles zu unternehmen, was nötig ist, um ein sicheres und korrektes Ende zu gewährleisten. Jedoch ist für den Prozess klar, dass dieses Signal eine deutliche Aufforderung zum Beenden ist.
- SIGKILL
- Reagiert ein Prozess nicht auf ein SIGTERM, so kann man ihm auch ein SIGKILL schicken. Dies ist nun keine freundliche Aufforderung mehr, denn der Prozess bemerkt so ein Signal nicht einmal mehr. Er wird nämlich vom Betriebssystem sofort beendet, ohne noch einmal gefragt zu werden.
Der »Shutdown«
Ein unfreiwilliges Ende wäre also der Empfang <Dieses Signal kann wie gesagt nicht abgefangen werden, aber der Prozess ist doch in gewissem Sinne der Empfänger dieses Signals.> eines SIGKILL-Signals.
Beim Herunterfahren des Systems wird entsprechend der Semantik beider Signale auch meist so verfahren: Zuerst wird allen Prozessen ein SIGTERM gesendet, dann wird ein paar Sekunden gewartet, und schließlich wird allen ein SIGKILL geschickt.
1.3.3 Die Implementierung
Im Folgenden möchten wir einen kurzen Überblick über die Implementierung von Tasks und Threads im Linux-Kernel geben.
Wir haben schon vereinzelt viele Details erwähnt, wenn diese gerade in den Kontext passten. In diesem Abschnitt möchten wir nun einige weitere Einzelheiten vorstellen, auch wenn wir natürlich nicht alle behandeln können.
Konzentrieren wir uns dabei zuerst auf die Repräsentation eines Prozesses im System. Wir haben festgestellt, dass ein Prozess viele zu verwaltende Daten besitzt. Diese Daten werden nun direkt oder indirekt alle im Prozesskontrollblock gespeichert. Diese Struktur bildet also ein Programm für die Ausführung durch das Betriebssystem in einen Prozess ab. Alle diese Prozesse werden nun in einer großen Liste, der Prozesstabelle (engl.: »process table«), eingetragen. Jedes Element dieser Tabelle ist also ein solcher Kontrollblock.
So ist's im Code
Sucht man diesen Kontrollblock nun im Kernel-Source, so wird man in der Datei include/linux/sched.h fündig.
Dort wird nämlich der Verbund task_struct definiert, der auch alle von uns erwarteten Eigenschaften besitzt:
struct task_struct {
volatile long state; /* –1 unrunnable,
0 runnable, >0 stopped */
struct thread_info *thread_info;
…
Listing 1.14 Beginn der task_struct im Kernel
Status und Thread
In diesem ersten Ausschnitt kann man bereits erkennen, dass jeder Task (Prozess) einen Status sowie einen »initialen« Thread besitzt. Dieser erste Ausführungsfaden wird aus Konsistenzgründen benötigt, um beim Scheduling keine weitgreifenden Unterscheidungen zwischen Threads und Prozessen treffen zu müssen.
Auch zum Scheduling gibt es in dieser Struktur Informationen:
… int prio, static_prio; struct list_head run_list; prio_array_t *array; unsigned long sleep_avg; long interactive_credit; unsigned long long timestamp, last_ran; int activated; unsigned long policy; cpumask_t cpus_allowed; unsigned int time_slice, first_time_slice; …
Listing 1.15 Informationen zum Scheduling
Der eigene Speicher
Natürlich hat auch jeder Task seinen eigenen Speicherbereich. In der Struktur mm_struct merkt sich der Kernel, welche virtuellen Adressen belegt sind und auf welche physikalischen, also real im Hauptspeicher vorhandenen Adressen diese abgebildet werden. Jedem Task ist nun eine solche Struktur zugeordnet:
struct mm_struct *mm, *active_mm;
Listing 1.16 Informationen zum Memory Management
Eine solche Struktur definiert nun einen eigenen Adressraum. Nur innerhalb eines Tasks kann auf die im Hauptspeicher abgelegten Werte zugegriffen werden, da innerhalb eines anderen Tasks keine Übersetzung von einer beliebigen virtuellen Adresse auf die entsprechend fremden realen Speicherstellen existiert.
Prozesshierarchie
Später werden in der Datenstruktur auch essenzielle Eigenschaften wie die Prozess-ID oder Informationen über die Prozesshierarchie gespeichert:
… pid_t pid; … struct task_struct *parent; struct list_head children; struct list_head sibling; …
Listing 1.17 Prozesshierarchie
Die Prozesshierarchie wird also so implementiert, dass ein Prozess einen direkten Zeiger auf seinen Vaterprozess besitzt sowie eine Liste seiner Kinder sowie eine Liste der Kinder seines Vaters. Diese Listen sind vom Typ list_head, der einen Zeiger prev und next zur Verfügung stellt. So kann bei entsprechender Initialisierung schrittweise über alle Kinder beziehungsweise Geschwister iteriert werden.
… uid_t uid,euid,suid,fsuid; gid_t gid,egid,sgid,fsgid; …
Listing 1.18 Informationen über den Benutzer
Die Rechte
Aber natürlich sind auch alle Benutzer- und Gruppen-IDs für die Rechteverwaltung im Taskkontrollblock gespeichert. Anhand dieser Werte kann bei einem Zugriff auf eine Datei festgestellt werden, ob der Zugriff berechtigt ist.
Mehr über die Rechteverwaltung erfahren Sie in Kapitel 2 und 8.
Natürlich finden sich auch alle bereits angesprochenen Statusinformationen des Tasks in der Datenstruktur. Dazu gehören unter anderem die geöffneten Dateien des Tasks:
… /* file system info */ int link_count, total_link_count; /* ipc stuff */ struct sysv_sem sysvsem; /* CPU-specific state of this task */ struct thread_struct thread; /* filesystem information */ struct fs_struct *fs; /* open file information */ struct files_struct *files; /* namespace */ struct namespace *namespace; /* signal handlers */ struct signal_struct *signal; struct sighand_struct *sighand; … };
Listing 1.19 Offene Dateien & Co.
... und der ganze Rest
In diesem Ausschnitt konnte man auch einige Datenstrukturen für die Interprozesskommunikation ausmachen, wie beispielsweise eine Struktur für Signalhandler, also die Adressen der Funktionen, die die abfangbaren Signale des Prozesses behandeln sollen.
Mehr zur Interprozesskommunikation finden Sie in Kapitel 24.
Ebenso haben natürlich auch Threads einen entsprechenden Kontrollblock, der jedoch sinnvollerweise viel kleiner als der eines kompletten Tasks ist.
union thread_union { struct thread_info thread_info; unsigned long stack[THREAD_SIZE/sizeof(long)]; }; struct thread_info { struct task_struct *task; /* main task structure */ … unsigned long status; /* thread flags */ __u32 cpu; /* current CPU */ … mm_segment_t addr_limit; /* thread address space: 0-0xBFFFFFFF for user-thread 0-0xFFFFFFFF for kernel-thread */ … unsigned long previous_esp; /* ESP of the previous stack in case of nested (IRQ) stacks */ … };
Listing 1.20 Thread-Strukturen
An diesen Strukturen kann man nun sehr schön die reduzierten Eigenschaften eines Threads sehen: Stack, Status, die aktuelle CPU und ein Adresslimit, das den Unterschied zwischen Threads des Kernels und des Userspace vornimmt. Außerdem ist ein Thread natürlich einem bestimmten Task zugeordnet.
Datenstrukturen vs. Code
Auch wenn Sie jetzt nur einen kurzen Blick auf die den Prozessen und Threads zugrunde liegenden Datenstrukturen werfen konnten, sollen diese Ausführungen hier genügen. Natürlich gibt es im Kernel noch sehr viel Code, der diese Datenstrukturen mit Werten und damit mit Leben füllt, die Basis haben Sie jedoch kurz kennengelernt.
Mehr dazu gibt es natürlich in den Kernel-Quellen.
Kommen wir nun jedoch zum Speichermanagement. Wir haben dieses Thema schon kurz mit der Speicherhierarchie und der Aufgabe des Stacks eingeführt und wollen uns nun näher mit den Aufgaben sowie den konkreten Lösungen in diesem Problemkreis beschäftigen.