7.2 Prozesskomponente
Wie schon erwähnt, steht dem Prozess eine ganze Verwaltungseinheit des Prozessors zur Verfügung. Zu solch einer Umgebung gehören Dinge wie die geöffneten Dateien, Registerinhalte der CPU, Speicherbelegung, das Arbeitsverzeichnis, die Umgebungsvariablen und noch einiges mehr.
Ein Prozess besteht aus einem Adressraum und den Kenndaten eines Prozesses, der intern eine Datenstruktur im Kernel darstellt, worin verschiedene Informationen über den Prozess aufgezeichnet werden.
Der Adressraum eines Prozesses wird dabei in Benutzerdaten und Systemdaten aufgeteilt (auch User- und Kernelspace genannt) Zu den Systemdaten gehören prozessspezifische Daten und der Systemstack. Der Adressraum von Benutzerdaten wird in die folgenden Segmente aufgeteilt (nicht nur Linux):
|
Textsegment (.text) – Darin befindet sich der ausführbare Programmcode, der auch von mehreren Programmen gleichzeitig verwendet werden kann (Shared Libraries). Außerdem ist das Textsegment schreibgeschützt; denn könnte man den Code dynamisch ändern, würde dies auch andere Prozesse, die sich das Textsegment teilen, betreffen. |
|
Datensegment – Darin befinden sich die Benutzerdaten eines Prozesses. Das Datensegment wird außerdem nochmals in einen initialisierten (wiederum in schreibgeschützt (.rodata) bzw. nicht schreibgeschützt (.data)) und einen nicht initialisierten Bereich (.bss) aufgeteilt. Das Datensegment beinhaltet außerdem den Heap (siehe auch »C von A bis Z« auf der Buch-CD). |
|
Stacksegment (/) – In diesem Bereich befinden sich alle lokalen Variablen einer Funktion, wenn diese aufgerufen wird. |
|
Shared-Memory-Segment(e) – Dieser Bereich kann bei Bedarf hinzukommen und als von mehreren Prozessen geteilter Speicherbereich verwendet werden, die gemeinsam darauf zugreifen können. |
Zu den Kenndaten eines Prozesses gehören Dinge wie:
|
Prozessnummer vom Prozess (PID) und Elternprozess (PPID) |
|
Gruppen- und Benutzernummer (GID, UID) vom »Prozesseigentümer« |
|
effektiver Benutzer und Gruppe (EGID, EUID) |
|
Filesystem effective user and group (kernel-intern) (FSUID, FSGID), z. B. bei NFS, wo UID-Mappings entstehen können |
|
Prozesspriorität (PRI) |
|
gewünschte Priorität (NICE) |
|
physikalische Adresse im Speicher oder Blockadresse im Auslagerungsbereich |
|
Zustand des Prozesses |
|
Ort, von wo aus der Prozess gestartet wurde (Kontrollterminal ...) ((C)TTY) |
|
verbrauchte Rechenzeit des Prozesses (TIME) |
|
Kommando, womit der Prozess gestartet wurde (CMD) |
7.2.1 Prozessnummer (PID)
Jeder Prozess besitzt eine systemweit eindeutige fortlaufende Prozessnummer (Process Identification (Number) == PID). Das Betriebssystem stellt dabei sicher, dass systemweit keine Nummer zweimal vorkommt, es sei denn, dies wird explizit von einem Programm gefordert (siehe clone(2) und !CLONE_PID, aber das scheint seit der Linux-Kernel-Version 2.5.16 wieder entfernt worden zu sein).
Stehen bei der Vergabe von fortlaufenden Prozessnummern keine Nummern mehr zur Verfügung, wird wieder bei 0 angefangen. Vergebene Prozessnummern werden übersprungen. Standardmäßig hat der Kernel Platz für 32768 verschiedene Prozesse, aber auf großen Systemen kann man auch größere Tabellen bis zu 262144 finden. Natürlich belegt das mehr RAM, der nicht geswappt werden kann, weshalb eine Erhöhung auf Heimsystemen keinen Sinn macht.
7.2.2 Prozessnummer des Vaterprozesses (PPID)
Die Prozessnummer des übergeordneten Prozesses (Parent Process Identification (Number) == PPID) gibt an, von welchem Prozess der aktuelle Prozess (PID) gestartet wurde. Man spricht dabei auch vom Elternprozess. Die Prozesse mit der Prozessnummer 0 und 1 haben unter Linux eine besondere Bedeutung und werden noch extra behandelt.
7.2.3 Benutzer- und Gruppennummer eines Prozesses (UID, EUID, GID, EGID)
Bei einem Prozess gibt es drei Arten von Benutzer- und Gruppennummern:
|
die effektive Benutzer- und Gruppennummer (EUID, EGID) |
|
die reale Benutzer- und Gruppennummer (UID, GID) |
|
die Filesysteme UID/GID, die bei NFS-Mapping auftreten. Sie sind meist kernel-intern (FSUID, FSGID, siehe setfsuid()). |
Die effektive Benutzer- bzw. Gruppennummer wird bei der Überprüfung von Zugriffsrechten auf Dateien verwendet. Die reale Benutzer- bzw. Gruppennummer hingegen ist die Nummer, die der aufrufende Benutzer eines Prozesses besitzt.
Ist bei einem aufrufenden Programm das Ausführungsrecht auf x (rwx) eingestellt, so wird beim Start des Programms (Prozesses) die effektive und reale Benutzer- und Gruppennummer auf die jeweilige Nummer des Aufrufers gesetzt. Steht aber an der Stelle des Ausführungsrechtes anstatt x ein s (für set user ID = SUID), dann wird die Benutzernummer des Dateieigentümers als effektive Benutzernummer verwendet, während die reale Benutzernummer die des Aufrufers bleibt. Damit können bestimmte Funktionen auf Dateien ausgeführt werden, die sonst nur dem Programmbesitzer gestattet sind, während der aufrufende Benutzer eigentlich keine Rechte darauf hat. Damit wird veranlasst, vom Programm kontrollierte Veränderungen von geschützten Dateien vorzunehmen. Setzt man mit dem s-Attribut des Benutzers auch noch das s-Attribut für die Gruppe, so wird die Gruppennummer der Programmdatei als effektive Gruppennummer bei der Programmausführung gesetzt. Dies gilt natürlich auch, wenn das Benutzerattribut »s« (SUID) nicht gesetzt ist und nur das Gruppenattribut s (SGID).
Man benötigt also die realen und effektiven Nummern, um die Identität und Berechtigung eines Prozesses zu unterscheiden. Ich gebe zu, dass das Thema sehr trocken ist (zumindest in der Theorie). Daher sei für weitere Informationen das HowTo zu »Secure Programming for Linux and UNIX« empfohlen (siehe Buch-CD).
7.2.4 Prozessstatus
Ein Prozess kann sich in den folgenden Zuständen befinden:
Tabelle 7.1
Mögliche Zustände eines Prozesses
Status
|
Bedeutung
|
läuft gerade (running)
|
Der Prozess wird gerade ausgeführt.
|
lauffähig (runnable)
|
Der Prozess kann ausgeführt werden.
|
schlafend (sleeping)
|
Der Prozess wartet darauf, dass er ein Stück Rechenzeit bekommt.
|
angehalten (stopped)
bzw. traced
|
Der Prozess wurde unterbrochen/angehalten.
|
Zombie
|
Ein Prozess, der beendet, aber noch nicht aus der Tabelle entfernt wurde.
|
Ist ein Prozess lauffähig, heißt dies noch lange nicht, dass er automatisch CPU-Zeit bekommt. Er wird erst dann ausgeführt, wenn ihm CPU-Zeit gewährt wird.
Bekommt ein Prozess Rechenzeit zur Verfügung gestellt, blockiert jedoch ein Systemcall (z. B. Pipe voll durch write()), wird der Prozess erst mal »schlafen gelegt«. Genauer gesagt, er wird in der Prioritätenliste etwas herabgelassen, und es wird später noch einmal geschaut, ob die Pipe immer noch voll ist. Nur das Eintreffen eines Signals kann dieses Warten unterbrechen (sofern der Systemcall dies erlaubt). Im genannten Beispiel würde write() umgehend -1 zurückgegeben und errno auf EINTR gesetzt.
Sie können einen Prozess aber auch anhalten, was dem Schlafen eines Prozesses ähnlich ist. Anhalten können Sie einen Prozess, indem Sie diesem das Signal STOP schicken. Ein ähnlich lautendes Signal, TSTP, wird den »Vordergrund«prozessen geschickt – was meist die Shell und das aktuelle Kommando sind –, wenn die Tastenkombination zum Anhalten gedrückt wird, i. d. R. (STRG)+(Z). In diesem Falle sendet die Shell dann STOP an den aktuellen Prozess. Mit dem Signal CONT können Sie einen Prozess wieder fortlaufen lassen. Der Unterschied zum angehaltenen Prozess im Gegensatz zum Schlafenden ist der, dass der angehaltene Prozess den Status so lange beibehält, bis ein anderer Prozess diesen Status aufhebt.
Ein Zombie ist ein Prozess, der sich längst beendet hat bzw. beendet wurde, aber noch nicht aus der Prozesstabelle ausgetragen worden ist (das muss nämlich der Elternprozess machen). Zombie-Prozesse verbrauchen keine Rechenleistung oder sonstigen Speicher, sondern sind nur noch als Prozesseintrag enthalten. Darauf wird noch näher eingegangen. Vereinfacht: Sie brauchen eine PID. Wenn Sie dabei vergessen, wait() oder waitpid() (später erklärt) aufzurufen, könnten Sie sich wundern, warum irgendwann (bei einem langlebigeren Programm) keine PIDs mehr frei sind.
7.2.5 Prozesspriorität
Damit, wie es sich für ein Multitasking-Betriebssystem gehört, jeder Prozess zu seiner Arbeit kommt und Zeit von der CPU zur Verfügung stehen hat, ist eine Steuerung nötig. Für die Steuerung aller Prozesse ist der Scheduling-Algorithmus zuständig. Dabei wird unter Linux ein prioritätsgesteuerter Algorithmus verwendet. Dabei bekommt der Prozess die CPU als Nächstes zugesprochen, der eine höhere Priorität besitzt. Ruft z. B. ein Prozess eine Systemfunktion auf, so hat dieser eine höhere Priorität. Prozesse wechseln somit immer vom Userspace in den Kernelspace (und dann wieder zurück), niemals umgekehrt. Der Kernel ist praktisch als Rasthof zu sehen: Man kommt rein, bleibt aber nicht für immer (man verbringt mehr Zeit im Userspace als im Kernelspace ... meistens). Der Beweis, dass diese These (vom User- in Kernelspace) richtig ist: Wären Interrupts deaktiviert, würde man nie in den Kernelspace wechseln können. Dagegen braucht man nichts weiter, um vom Kernelspace in den Userspace zu wechseln.
Da also »normale Prozesse« immer zwischen User- und Kernelspace wechseln, gibt es keine »normalen« Prozesse. Ausnahme: Kernel-Threads, Kthreads (kswapd etc.). Linux kennt zwei Prioritätsklassen, die Systemprozesse, worauf Sie keinerlei Einfluss haben, und die Timesharing-Prozesse. Mittlerweile gibt es eine dritte Prioritätsklasse, nämlich die Echtzeitprozesse (Real-Time). Diese werden bei besonders zeitkritischen Prozessen eingesetzt, womit diese dann eine höhere Priorität als die Timesharing-Prozesse besitzen.
7.2.6 Timesharing-Prozesse
Alle gewöhnlichen Prozesse sind somit Timesharing-Prozesse. Beim Timesharing wird versucht, die CPU-Zeit möglichst gleichmäßig auf alle anderen Prozesse unter Beachtung der Priorität zu verteilen. Damit es keine Ungerechtigkeiten gibt, wird die Priorität der Prozesse nach einer gewissen Zeit neu berechnet. Beeinflussen können Sie die Scheduling-Priorität mit dem Kommando nice oder renice. Wenn Sie z. B. die Prozesse mit ps -l auflisten lassen, finden Sie darin einen Wert NI (nice). Diesen Wert können Sie mit einer Priorität belegen. -20 bedeutet die höchste und +19 die niedrigste Priorität. Wollen Sie z. B. dem Prozess ein_prozess, der die PID 1234 besitzt, eine tiefere Priorität vergeben, können Sie wie folgt vorgehen:
$ renice +10 1234
1234: Alte Priorität: 0, neue Priorität: 10
Um dem Prozess eine höhere Priorität zuzuweisen (egal ob man ihn vorher runtergesetzt hat), braucht man Superuser-Rechte. Man kann sich selbst degradieren, aber nicht wieder aufsteigen. Dazu fehlt dem Linux-Kernel ein Feld in der Prozesstabelle, das es erlauben würde, wieder bis zur Originalpriorität aufzusteigen. Inwiefern ein solches Feld Sinn macht, ist fraglich, denn ein Prozess, der seine Priorität wieder erhöhen will, zählt sowieso schon als besonders.
Zwar sind die beiden Kommandos nice und renice noch Relikte aus Zeiten, wo es noch keine Monster-CPU im GHz-Bereich gab. Aber dennoch haben diese Kommandos durchaus ihre Daseinsberechtigung, wenn z. B. der Superuser nachträglich manuell Prozessen Rechenzeit zuteilen will, wenn ein ausgesuchter Prozess etwas schneller fertig sein soll als ein anderer (natürlich zulasten der anderen Anwendungen).
Oder ein weiteres Beispiel: Es würde sonst »etwas« (knapp doppelt so lange) dauern, bis man etwas fertig kompiliert hat, wenn auf derselben Priorität noch ein Prozess mit gleicher CPU-Beanspruchung läuft. Als Beispiel nehme man Seti@Home (http://seti.alien.de/) – und da erscheint es logisch, dass die Scheduling-Priorität auf Nice 20 statt Nice 0 laufen sollte.
Scheduling-Priorität in einer Anwendung setzen bzw. abfragen –
setpriority() und getpriority()
Wollen Sie die Scheduling-Priorität eines Prozesses, einer Prozessgruppe abfragen bzw. setzen oder einen Benutzer mit Ihrer Anwendung (in welcher Häufigkeit der Prozess über den Prozessor verfügen darf) abfragen, stehen Ihnen folgende zwei Systemcalls zur Verfügung:
#include <sys/time.h>
#include <sys/resource.h>
int getpriority(int which, int who)
int setpriority(int which, int who, int prio)
Mit setpriority() können Sie ähnlich wie mit dem Systemkommando nice die Priorität eines Prozesses verändern. Natürlich sollte klar sein, dass das Heraufsetzen der Priorität weiterhin dem Superuser überlassen bleibt. Aber Achtung: Das Systemkommando nice, also /usr/bin/nice, setzt den Nice-Wert auf den angegebenen Wert, während der Syscall nice(2) den Nice-Wert um dessen Argument erhöht. Als normaler User können Sie lediglich die Priorität heruntersetzen oder mittels getpriority() erfragen. Wobei who angibt, wie which interpretiert werden soll. Dabei stehen Ihnen folgende Makros zur Verfügung:
|
PRIO_PROCESS – who ist eine PID. |
|
PRIO_PGRP – who ist eine Prozessgruppe. |
|
PRIO_USER – who ist eine UID. |
Zum Setzen der Priorität mithilfe des dritten Argumentes von setpriority() wird ein Wert zwischen -20 und 20 erwartet, der dann auf den aktuellen Nice-Wert hinzuaddiert wird. Auch hier gilt, dass Sie mit einem negativen Wert (bspw. -10) die Priorität erhöhen und mit einem positiven diese verringern (wie schon bei nice). Beide Funktionen geben bei Erfolg 0 zurück, ansonsten wird -1 bei einem Fehler zurückgegeben (wobei Sie dabei errno überprüfen sollten), der im Falle, dass man nicht berechtigt ist, den Nice-Wert zu verändern, EACCES sein sollte (häufigster Fehler).
Hierzu folgt ein einfaches Beispiel, das zunächst die Priorität mittels getpriority() überprüft und diese dann mit setpriority() um 10 verringert. Anschließend wird die Priorität nochmals überprüft und versucht, diese um den Wert 10 zu erhöhen, was gewöhnlich nur dem Superuser gestattet ist.
/* prio.c */
#include <unistd. h.>
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <sys/resource.h>
int main (void) {
printf("Priorität:%d\n",
getpriority(PRIO_PROCESS, getpid()));
printf("Priorität um 10 reduzieren\n");
if (setpriority (PRIO_PROCESS, getpid (), 10) == -1) {
perror ("Fehler bei setpriority()");
exit (EXIT_FAILURE);
}
printf("Priorität:%d\n",
getpriority(PRIO_PROCESS, getpid()));
printf("Priorität um 10 erhöhen (root-only)\n");
if (setpriority (PRIO_PROCESS, getpid (), -10) == -1) {
perror ("Fehler bei setpriority()");
exit (EXIT_FAILURE);
}
printf("Priorität:%d\n",
getpriority(PRIO_PROCESS, getpid()));
return EXIT_SUCCESS;
}
Das Programm bei der Ausführung:
$ gcc -o prio prio.c
$ ./prio
Priorität:0
Priorität um 10 reduzieren
Priorität:10
Priorität um 10 erhöhen (root-only)
Fehler bei setpriority(): Permission denied
$ su
Password: ********
# ./prio
Priorität:0
Priorität um 10 reduzieren
Priorität:10
Priorität um 10 erhöhen (root-only)
Priorität:–10
Prozess-Warteschleifen – sched_yield()
Sollten Sie in Ihrer Anwendung Warteschleifen benötigen, empfiehlt es sich, diese mithilfe von Signalen zu realisieren (siehe nächstes Kapitel). Meistens werden solche Warteschleifen verwendet, um zu warten (J), bis ein bestimmtes Ereignis eintrifft. Ein primitives Beispiel hierzu – ohne jede Funktionalität:
/* eventloop1.c */
#include <stdio.h>
#include <stdlib.h>
static int event_loop( void ) {
int ereignis = 0;
/* Eine Ereignisschleife */
if( ereignis )
return 1;
else
return 0;
}
int main(void) {
int loop = 0;
while( !loop )
loop = event_loop();
return EXIT_SUCCESS;
}
Wenn Sie das Beispiel ausführen, wird »gepollt« ohne Ende – sprich sinnlos Rechenzeit verbraten. Wenn nichts los ist, könnten in dieser Zeit andere Prozesse vielleicht wichtigere Aufgaben erledigen.
In solch einem Beispiel können Sie, wie eben schon erwähnt, die Signale oder die Funktion sched_yield()verwenden.
#include <sched. h.>
int sched_yield(void);
Eingebaut in eine Warteschleife, sorgt diese Funktion dafür, dass die Ausführung der Anwendung unterbrochen und dieser Prozess an das Ende der Liste der auszuführenden Prozesse verschoben wird. Der Rückgabewert ist bei Erfolg 0, ansonsten bei Fehler -1. Ist der aktuelle Prozess der einzige Prozess in der höchsten Prioritätenliste während der Laufzeit, wird dieser gleich unmittelbar nach dem Aufruf von sched_yield() weiter ausgeführt. Außerdem hängt das »Verschieben an das Ende der Liste der auszuführenden Prozesse« auch davon ab, mit welcher Priorität die anderen Prozesse in der Prozessliste unterwegs sind. Ob das Scheduling mit sched_yield() überhaupt zur Verfügung steht, können Sie dadurch überprüfen, ob die Konstante _POSIX_PRIORITY_SCHEDULING in der Headerdatei unistd. h. definiert ist.
/* eventloop2.c */
#include <stdio.h>
#include <stdlib.h>
#include <unistd. h.>
#include <sched. h.>
#ifndef _POSIX_PRIORITY_SCHEDULING
#error "sched_yield() ist hier nicht vorhanden"
#endif
// aus dem Beispiel eventloop1.c zu entnehmen
static int event_loop( void );
int main() {
int loop = 0;
while( !loop ) {
loop = event_loop();
sched_yield();
}
return EXIT_SUCCESS;
}
Ab der Kernel-Version von 2.6.x hat sich das Verhalten von sched_yield() erheblich verändert. Benutzt ein Prozess diesen Systemaufruf, kann dieser jetzt erwarten, für sehr lange Zeit inaktiv zu sein.
Hinweis Wollen Sie noch tiefer ins Detail des Prozess-Schedulings einsteigen, sollten Sie sich Literatur zum Linux-Kernel beschaffen.
|
7.2.7 Prozessauslagerung
Reicht der echte Hauptspeicher (RAM) nicht mehr im System für einen neuen Prozess aus, muss ein anderer zuerst in einem so genannten Swap-Bereich ausgelagert werden. Zum Swapping werden externe Medien, wie Festplatten u. Ä., als ein langsames RAM zum Hin- und Herschaufeln der Daten verwendet. Für das Auslagern der Prozesse ist unter Linux der Dämon kswapd verantwortlich. Natürlich sorgt auch hierbei wieder ein vernünftiger Algorithmus dafür, dass dieses Swapping nicht wild und planlos ausgeführt wird, auch wenn die Festplatte des Anwenders mal wieder endlos »rödelt«.
Außerdem wird nicht das ganze Programm in den Hauptspeicher gelegt, sondern nur ein Teil. Dazu wird das Programm in kleine Stücke, so genannte Pages, zerlegt. Es befinden sich in der Regel auch nur die Pages im Hauptspeicher, mit denen auch wirklich gearbeitet wird. Muss wegen Speicherknappheit ausgelagert werden, so werden nur einzelne Pages verlegt. Beim Einlagern der Pages werden auch nicht wieder alle ausgelagerten Seiten eingelesen, sondern nur die, die im Augenblick benötigt werden. Man spricht dabei vom Demand Paging (Seite auf Anfrage). Der Bereich, in den diese Pages ausgelagert werden, wird als Paging-Bereich (Paging Area) bezeichnet. Damit ist die maximale Programmgröße vom virtuellen Adressraum des Rechners und des Kernels abhängig. Der virtuelle Adressraum erstreckt sich beim x86 bekanntlich auf 4 GB, bei 64-Bit-CPUs bis auf 16 EB. Auf neue Techniken wie PAE, die helfen sollen, die 4 GB zu erweitern, soll hier nicht weiter eingegangen werden.
In Kernel 2.4 gab es eine Option, wie viel vom 4-GB-Adressraum dem User- und Kernelspace zustand, meist war es ein 3-GB/1-GB-Split, der die maximale Prozessgröße (Text-, Datensegment etc.) bestimmte. Es gab auch 2/2 und 3.5/0.5, in 2.6 ist diese Option jedoch nicht mehr wählbar. Stattdessen scheint 3/1 (für x86) fest eincodiert worden zu sein.
7.2.8 Steuerterminal
Den meisten Prozessen ist außerdem ein Steuerterminal zugeordnet. Prozesse, denen kein Steuerterminal (CTTY) zugeordnet ist, sind in der Regel Dämonprozesse oder »verloren gegangene« Prozesse. Wer z. B. ein Perl-Skript startet und das xterm dazu beendet, kann u. U. (muss nicht immer sein) erreichen, dass Perl ohne CTTY weiterläuft. Wird z. B. ein Prozess aus einer Shell gestartet, so wird der Terminal der Shell in der Regel auch der Steuerterminal.
|