
In diesem Artikel möchte ich über den Lebensweg von Prozessen in der Linux-Betriebssystemfamilie sprechen. In Theorie und Beispielen werde ich untersuchen, wie Prozesse geboren werden und sterben, und ein wenig über die Mechanik von Systemaufrufen und Signalen sprechen.
Dieser Artikel richtet sich eher an Anfänger in der Systemprogrammierung und an diejenigen, die nur ein wenig mehr über die Funktionsweise von Prozessen unter Linux erfahren möchten.
Alles, was unten geschrieben steht, gilt für Debian Linux mit dem Kernel 4.15.0.
Inhalt
- Einführung
- Prozessattribute
- Prozesslebenszyklus
- Prozessgeburt
- Bereitschaftszustand
- Der Status ist "läuft"
- Reinkarnation in einem anderen Programm
- Ausstehender Zustand
- Status stoppen
- Prozessabschluss
- Der Zustand der "Zombies"
- Vergessenheit
- Danksagung
Einführung
Systemsoftware interagiert mit dem Systemkern über spezielle Funktionen - Systemaufrufe. In seltenen Fällen gibt es eine alternative API, z. B. procfs oder sysfs, die in Form von virtuellen Dateisystemen erstellt wird.
Prozessattribute
Der Prozess im Kernel wird einfach als Struktur mit vielen Feldern dargestellt (die Definition der Struktur kann hier gelesen
werden ).
Da sich der Artikel jedoch der Systemprogrammierung und nicht der Kernelentwicklung widmet, sind wir etwas abstrahiert und konzentrieren uns einfach auf die für uns wichtigen Prozessfelder:
- Prozess-ID (pid)
- Öffnen Sie die Dateideskriptoren (fd)
- Signalhandler
- Aktuelles Arbeitsverzeichnis (cwd)
- Umgebungsvariablen (Umgebung)
- Rückkehrcode
Prozesslebenszyklus

Prozessgeburt
Nur ein Prozess im System wird auf besondere Weise geboren -
init
- er wird direkt vom Kernel generiert. Alle anderen Prozesse werden angezeigt, indem der aktuelle Prozess mithilfe des Systemaufrufs
fork(2)
dupliziert wird. Nachdem
fork(2)
ausgeführt wurde, erhalten wir zwei nahezu identische Prozesse mit Ausnahme der folgenden Punkte:
fork(2)
gibt die PID des Kindes an das Elternteil zurück, 0 wird an das Kind zurückgegeben;- Das Kind ändert die PPID (Parent Process ID) in die PID des Elternteils.
Nachdem
fork(2)
ausgeführt wurde, sind alle Ressourcen des untergeordneten Prozesses eine Kopie der Ressourcen des übergeordneten Prozesses. Das Kopieren eines Prozesses mit allen zugewiesenen Speicherseiten ist teuer, daher verwendet der Linux-Kernel die Copy-On-Write-Technologie.
Alle Seiten im Speicher des Elternteils sind als schreibgeschützt markiert und stehen sowohl dem Elternteil als auch dem Kind zur Verfügung. Sobald einer der Prozesse die Daten auf einer bestimmten Seite ändert, ändert sich diese Seite nicht, aber die Kopie wurde bereits kopiert und geändert. In diesem Fall wird das Original von diesem Vorgang „gelöst“. Sobald das schreibgeschützte Original an einen einzelnen Prozess „gebunden“ bleibt, wird die Seite dem Lese- / Schreibstatus zugewiesen.
Ein Beispiel für ein einfaches nutzloses Programm mit Gabel (2) #include <stdio.h> #include <unistd.h> #include <errno.h> #include <sys/wait.h> #include <sys/types.h> int main() { int pid = fork(); switch(pid) { case -1: perror("fork"); return -1; case 0: // Child printf("my pid = %i, returned pid = %i\n", getpid(), pid); break; default: // Parent printf("my pid = %i, returned pid = %i\n", getpid(), pid); break; } return 0; }
$ gcc test.c && ./a.out my pid = 15594, returned pid = 15595 my pid = 15595, returned pid = 0
Bereitschaftszustand
Unmittelbar nach der Ausführung wechselt die
fork(2)
in den Bereitschaftszustand.
Tatsächlich stellt der Prozess in die Warteschlange und wartet darauf, dass der Scheduler im Kernel den Prozess auf dem Prozessor ausführen lässt.
Der Status ist "läuft"
Sobald der Scheduler den Prozess ausgeführt hat, begann der Status "Ausführen". Der Prozess kann den gesamten vorgeschlagenen Zeitraum (Quantum) der Zeit
sched_yield
oder anderen Prozessen mithilfe des
sched_yield
.
Reinkarnation in einem anderen Programm
Einige Programme implementieren eine Logik, bei der der übergeordnete Prozess ein untergeordnetes Element erstellt, um ein Problem zu lösen. In diesem Fall löst das Kind ein bestimmtes Problem, und der Elternteil delegiert nur Aufgaben an seine Kinder. Beispielsweise erstellt ein Webserver mit einer eingehenden Verbindung ein untergeordnetes Element und übergibt die Verbindungsverarbeitung an dieses.
Wenn Sie jedoch ein anderes Programm ausführen müssen, müssen Sie auf den Systemaufruf
execve(2)
zurückgreifen:
int execve(const char *filename, char *const argv[], char *const envp[]);
oder Bibliotheksaufrufe
execl(3), execlp(3), execle(3), execv(3), execvp(3), execvpe(3)
:
int execl(const char *path, const char *arg, ... ); int execlp(const char *file, const char *arg, ... ); int execle(const char *path, const char *arg, ... ); int execv(const char *path, char *const argv[]); int execvp(const char *file, char *const argv[]); int execvpe(const char *file, char *const argv[], char *const envp[]);
Alle oben genannten Aufrufe führen das Programm aus, dessen Pfad im ersten Argument angegeben ist. Bei Erfolg wird die Steuerung an das geladene Programm übertragen und nicht an das ursprüngliche zurückgegeben. In diesem Fall verfügt das geladene Programm über alle Felder der Prozessstruktur, mit Ausnahme der als
O_CLOEXEC
gekennzeichneten
O_CLOEXEC
, die geschlossen werden.
Wie kann man bei all diesen Herausforderungen nicht verwirrt werden und die richtige auswählen? Es reicht aus, die Namenslogik zu verstehen:
- Alle Aufrufe beginnen mit
exec
- Der fünfte Buchstabe definiert die Art der Argumentübergabe:
- l steht für Liste , alle Parameter werden als
arg1, arg2, ..., NULL
- v steht für Vektor , alle Parameter werden in einem nullterminierten Array übergeben;
- Der Buchstabe p , der für Pfad steht, kann folgen. Wenn das
file
mit einem anderen Zeichen als "/" beginnt, wird die angegebene file
in den Verzeichnissen nachgeschlagen, die in der Umgebungsvariablen PATH aufgeführt sind - Der letzte kann der Buchstabe e sein , der die Umgebung angibt. In solchen Aufrufen ist das letzte Argument ein nullterminiertes Array von nullterminierten Zeichenfolgen der Form
key=value
- Umgebungsvariablen, die an das neue Programm übergeben werden.
Beispielaufruf an / bin / cat --help via execve #define _GNU_SOURCE #include <unistd.h> int main() { char* args[] = { "/bin/cat", "--help", NULL }; execve("/bin/cat", args, environ); // Unreachable return 1; }
$ gcc test.c && ./a.out Usage: /bin/cat [OPTION]... [FILE]... Concatenate FILE(s) to standard output. * *
Mit der Familie
exec*
call können Sie Skripte mit Ausführungsrechten ausführen und mit einer Folge von Shebangs (#!) Beginnen.
Ein Beispiel für das Ausführen eines Skripts mit einem gefälschten PATH mithilfe von execle #define _GNU_SOURCE #include <unistd.h> int main() { char* e[] = {"PATH=/habr:/rulez", NULL}; execle("/tmp/test.sh", "test.sh", NULL, e); // Unreachable return 1; }
$ cat test.sh
Es gibt eine Konvention, die impliziert, dass argv [0] mit den Nullargumenten für Funktionen in der exec * -Familie übereinstimmt. Dies kann jedoch verletzt werden.
Beispiel, wenn Katze mit execlp zum Hund wird #define _GNU_SOURCE #include <unistd.h> int main() { execlp("cat", "dog", "--help", NULL); // Unreachable return 1; }
$ gcc test.c && ./a.out Usage: dog [OPTION]... [FILE]... * *
Ein neugieriger Leser kann feststellen, dass die Signatur der
int main(int argc, char* argv[])
eine Zahl enthält
int main(int argc, char* argv[])
- die Anzahl der Argumente, aber nichts dergleichen wird an die
exec*
-Funktionsfamilie übergeben. Warum? Denn wenn das Programm startet, wird die Steuerung nicht sofort auf main übertragen. Zuvor werden einige von glibc definierte Aktionen ausgeführt, einschließlich des Zählens von argc.
Ausstehender Zustand
Einige Systemaufrufe können lange dauern, z. B. E / A. In solchen Fällen geht der Prozess in einen Wartezustand über. Sobald der Systemaufruf abgeschlossen ist, versetzt der Kernel den Prozess in den Status "Bereit".
Unter Linux gibt es auch einen Wartezustand, in dem der Prozess nicht auf Interrupt-Signale reagiert. In diesem Zustand wird der Prozess "unzerstörbar" und alle eingehenden Signale stehen in einer Linie, bis der Prozess diesen Zustand verlässt.
Der Kernel selbst wählt aus, in welchen Zustand der Prozess übertragen werden soll. In den meisten Fällen befinden sich Prozesse, die E / A anfordern, im Status "Warten (ohne Unterbrechungen)". Dies macht sich insbesondere bei Verwendung einer Remote-Festplatte (NFS) mit einem nicht sehr schnellen Internet bemerkbar.
Status stoppen
Sie können einen Prozess jederzeit anhalten, indem Sie ihm ein SIGSTOP-Signal senden. Der Prozess wird in einen "gestoppten" Zustand versetzt und bleibt dort, bis er ein Signal erhält, weiter zu arbeiten (SIGCONT) oder zu sterben (SIGKILL). Die verbleibenden Signale werden in die Warteschlange gestellt.
Prozessabschluss
Kein Programm kann sich selbst herunterfahren. Sie können das System nur mit dem
_exit
fragen oder vom System aufgrund eines Fehlers beendet werden. Selbst wenn Sie eine Zahl von
main()
, wird
_exit
implizit aufgerufen.
Obwohl das Argument für den Systemaufruf int ist, wird nur das niedrige Byte der Nummer als Rückkehrcode verwendet.
Der Zustand der "Zombies"
Unmittelbar nach Abschluss des Prozesses (unabhängig davon, ob er korrekt ist oder nicht) schreibt der Kernel Informationen darüber, wie der Prozess beendet wurde, und versetzt ihn in den Status "Zombie". Mit anderen Worten, ein Zombie ist ein abgeschlossener Prozess, aber der Speicher davon ist immer noch im Kernel gespeichert.
Darüber hinaus ist dies der zweite Zustand, in dem der Prozess das SIGKILL-Signal sicher ignorieren kann, da es nicht wieder tot sterben kann.
Vergessenheit
Der Rückkehrcode und der Grund für den Abschluss des Prozesses sind noch im Kernel gespeichert und müssen von dort übernommen werden. Dazu können Sie die entsprechenden Systemaufrufe verwenden:
pid_t wait(int *wstatus); pid_t waitpid(pid_t pid, int *wstatus, int options);
Alle Informationen zur Beendigung des Prozesses passen in den Datentyp int. Die in der
waitpid(2)
beschriebenen Makros werden verwendet, um den Rückkehrcode und den Grund für die Programmbeendigung abzurufen.
Beispiel für die korrekte Vervollständigung und den Erhalt eines Rückkehrcodes #include <stdio.h> #include <unistd.h> #include <errno.h> #include <sys/wait.h> #include <sys/types.h> int main() { int pid = fork(); switch(pid) { case -1: perror("fork"); return -1; case 0: // Child return 13; default: { // Parent int status; waitpid(pid, &status, 0); printf("exit normally? %s\n", (WIFEXITED(status) ? "true" : "false")); printf("child exitcode = %i\n", WEXITSTATUS(status)); break; } } return 0; }
$ gcc test.c && ./a.out exit normally? true child exitcode = 13
Falsches AbschlussbeispielDas Übergeben von argv [0] als NULL führt zu einem Absturz.
#include <stdio.h> #include <unistd.h> #include <errno.h> #include <sys/wait.h> #include <sys/types.h> int main() { int pid = fork(); switch(pid) { case -1: perror("fork"); return -1; case 0: // Child execl("/bin/cat", NULL); return 13; default: { // Parent int status; waitpid(pid, &status, 0); if(WIFEXITED(status)) { printf("Exit normally with code %i\n", WEXITSTATUS(status)); } if(WIFSIGNALED(status)) { printf("killed with signal %i\n", WTERMSIG(status)); } break; } } return 0; }
$ gcc test.c && ./a.out killed with signal 6
Es gibt Zeiten, in denen ein Elternteil früher als das Kind endet. In solchen Fällen wird
init
zum Elternteil des Kindes und verwendet zu
wait(2)
Zeit den Anruf
wait(2)
.
Nachdem der Elternteil Informationen über den Tod des Kindes aufgenommen hat, löscht der Kernel alle Informationen über das Kind, sodass bald ein anderer Prozess an seine Stelle tritt.
Danksagung
Vielen Dank an Sasha „Al“ für die Bearbeitung und Unterstützung beim Design.
Vielen Dank an Sasha „Reisse“ für die klaren Antworten auf schwierige Fragen.
Sie ertrugen die Inspiration, die mich angriff, und die Flut meiner Fragen, die mich angriffen.