Processus d'apprentissage sous Linux


Dans cet article, je voudrais parler du chemin de vie des processus dans la famille Linux OS. En théorie et en exemples, je vais regarder comment les processus naissent et meurent, un petit discours sur la mécanique des appels et des signaux du système.

Cet article est plus destiné aux débutants en programmation système et à ceux qui veulent simplement en savoir un peu plus sur le fonctionnement des processus sous Linux.

Tout ce qui est écrit ci-dessous s'applique à Debian Linux avec le noyau 4.15.0.

Table des matières


  1. Présentation
  2. Attributs de processus
  3. Cycle de vie du processus
    1. Naissance de processus
    2. État prêt
    3. Le statut est «en cours d'exécution»
    4. Réincarnation dans un autre programme
    5. État en attente
    6. Statut d'arrêt
    7. Achèvement du processus
    8. L'état des "zombies"
    9. Oubli
  4. Remerciements

Présentation


Le logiciel système interagit avec le cœur du système via des fonctions spéciales - les appels système. Dans de rares cas, il existe une autre API, par exemple, procfs ou sysfs, réalisée sous la forme de systèmes de fichiers virtuels.

Attributs de processus


Le processus dans le noyau est présenté simplement comme une structure avec de nombreux champs (la définition de la structure peut être lue ici ).
Mais puisque l'article est consacré à la programmation système, et non au développement du noyau, nous sommes quelque peu abstraits et nous concentrons simplement sur les domaines importants du processus pour nous:

  • Identifiant du processus (pid)
  • Descripteurs de fichiers ouverts (fd)
  • Gestionnaires de signaux
  • Répertoire de travail actuel (cwd)
  • Variables d'environnement (environ)
  • Code retour

Cycle de vie du processus




Naissance de processus


Un seul processus dans le système est né d'une manière spéciale - init - il est généré directement par le noyau. Tous les autres processus apparaissent en dupliquant le processus en cours à l'aide de l'appel système fork(2) . Après l'exécution de fork(2) , nous obtenons deux processus presque identiques, à l'exception des points suivants:

  1. fork(2) renvoie le PID de l'enfant au parent, 0 est retourné à l'enfant;
  2. L'enfant modifie le PPID (Parent Process Id) en PID du parent.

Une fois fork(2) exécuté, toutes les ressources du processus enfant sont une copie des ressources du parent. La copie d'un processus avec toutes les pages de mémoire allouées coûte cher, donc le noyau Linux utilise la technologie Copy-On-Write.
Toutes les pages dans la mémoire du parent sont marquées en lecture seule et deviennent accessibles à la fois au parent et à l'enfant. Dès que l'un des processus modifie les données sur une page particulière, cette page ne change pas, mais la copie est déjà copiée et modifiée. Dans ce cas, l'original est «délié» de ce processus. Dès que l'original en lecture seule reste «lié» à un seul processus, la page est réaffectée au statut de lecture-écriture.

Un exemple d'un simple programme inutile avec fork (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 


État prêt


Immédiatement après l'exécution, fork(2) passe à l'état prêt.
En fait, le processus met en file d'attente et attend que le planificateur dans le noyau laisse le processus s'exécuter sur le processeur.

Le statut est «en cours d'exécution»


Dès que le planificateur a mis le processus à exécution, l'état «en cours» a commencé. Le processus peut exécuter toute la période proposée (quantique) de temps, ou il peut céder la place à d'autres processus à l'aide de l'exportation du système sched_yield .

Réincarnation dans un autre programme


Certains programmes implémentent une logique dans laquelle le processus parent crée un enfant pour résoudre un problème. Dans ce cas, l'enfant résout un problème spécifique et le parent délègue uniquement des tâches à ses enfants. Par exemple, un serveur Web avec une connexion entrante crée un enfant et lui passe le traitement de connexion.
Cependant, si vous devez exécuter un autre programme, vous devez recourir à l'appel système execve(2) :

 int execve(const char *filename, char *const argv[], char *const envp[]); 

ou la bibliothèque appelle execl(3), execlp(3), execle(3), execv(3), execvp(3), execvpe(3) :

 int execl(const char *path, const char *arg, ... /* (char *) NULL */); int execlp(const char *file, const char *arg, ... /* (char *) NULL */); int execle(const char *path, const char *arg, ... /*, (char *) NULL, char * const envp[] */); 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[]); 

Tous les appels ci-dessus exécutent le programme, dont le chemin d'accès est indiqué dans le premier argument. En cas de succès, le contrôle est transféré au programme chargé et n'est pas retourné à celui d'origine. Dans ce cas, le programme chargé possède tous les champs de la structure du processus, à l'exception des descripteurs de fichiers marqués O_CLOEXEC , ils seront fermés.

Comment ne pas se perdre dans tous ces défis et choisir le bon? Il suffit de comprendre la logique de nommage:

  • Tous les appels commencent par exec
  • La cinquième lettre définit le type d'argument passant:
    • l signifie liste , tous les paramètres sont passés comme arg1, arg2, ..., NULL
    • v signifie vecteur , tous les paramètres sont passés dans un tableau à terminaison nulle;
  • La lettre p , qui signifie chemin , peut suivre. Si l'argument de file commence par un caractère autre que "/", le file spécifié est recherché dans les répertoires répertoriés dans la variable d'environnement PATH
  • Le dernier peut être la lettre e , indiquant environ . Dans de tels appels, le dernier argument est un tableau terminé par null de chaînes terminées par null de la forme key=value - variables d'environnement qui seront transmises au nouveau programme.

Exemple d'appel à / 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. * * 


La famille d'appels exec* vous permet d'exécuter des scripts avec des droits d'exécution et en commençant par une séquence de shebangs (#!).

Un exemple d'exécution d'un script avec un PATH usurpé à l'aide d'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 #!/bin/bash echo $0 echo $PATH $ gcc test.c && ./a.out /tmp/test.sh /habr:/rulez 


Il existe une convention qui implique que argv [0] correspond aux arguments nuls des fonctions de la famille exec *. Cependant, cela peut être violé.

Exemple lorsque le chat devient chien en utilisant execlp

 #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]... * * 


Un lecteur curieux peut remarquer qu'il y a un nombre dans la signature de la fonction int main(int argc, char* argv[]) - le nombre d'arguments, mais rien de ce genre n'est transmis à la famille de fonctions exec* . Pourquoi? Parce que lorsque le programme démarre, le contrôle n'est pas immédiatement transféré au principal. Avant cela, certaines actions définies par la glibc sont effectuées, y compris le comptage de l'argc.

État en attente


Certains appels système peuvent prendre du temps, comme les E / S. Dans de tels cas, le processus passe dans un état «en attente». Dès que l'appel système est terminé, le noyau mettra le processus à l'état «prêt».
Sous Linux, il existe également un état «d'attente» dans lequel le processus ne répond pas aux signaux d'interruption. Dans cet état, le processus devient "indestructible" et tous les signaux entrants restent en ligne jusqu'à ce que le processus quitte cet état.
Le noyau lui-même choisit dans quel état transférer le processus. Le plus souvent, les processus qui demandent des E / S sont à l'état "en attente (sans interruption)". Cela est particulièrement visible lorsque vous utilisez un disque distant (NFS) avec un Internet peu rapide.

Statut d'arrêt


Vous pouvez suspendre un processus à tout moment en lui envoyant un signal SIGSTOP. Le processus entrera dans un état «arrêté» et y restera jusqu'à ce qu'il reçoive un signal pour continuer à travailler (SIGCONT) ou mourir (SIGKILL). Les signaux restants seront mis en file d'attente.

Achèvement du processus


Aucun programme ne peut s'arrêter. Ils ne peuvent demander cela au système qu'avec l' _exit système _exit ou être interrompus par le système en raison d'une erreur. Même lorsque vous retournez un nombre à partir de main() , _exit est toujours implicitement appelé.
Bien que l'argument de l'appel système soit int, seul l'octet de poids faible du numéro est pris comme code retour.

L'état des "zombies"


Immédiatement après la fin du processus (qu'il soit correct ou non), le noyau écrit des informations sur la fin du processus et le met dans l'état «zombie». En d'autres termes, un zombie est un processus terminé, mais sa mémoire est toujours stockée dans le noyau.
De plus, c'est le deuxième état dans lequel le processus peut ignorer en toute sécurité le signal SIGKILL, car il ne peut pas mourir à nouveau.

Oubli


Le code retour et la raison de l'achèvement du processus sont toujours stockés dans le noyau et doivent être extraits de là. Pour ce faire, vous pouvez utiliser les appels système appropriés:

 pid_t wait(int *wstatus); /*  waitpid(-1, wstatus, 0) */ pid_t waitpid(pid_t pid, int *wstatus, int options); 

Toutes les informations sur la fin du processus s'inscrivent dans le type de données int. Les macros décrites dans la page de waitpid(2) sont utilisées pour obtenir le code retour et la raison de l'arrêt du programme.

Exemple de bonne exécution et réception d'un code retour

 #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 


Exemple d'achèvement incorrect

Passer argv [0] comme NULL entraîne un plantage.

 #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 


Il y a des moments où un parent se termine plus tôt que l'enfant. Dans de tels cas, init deviendra le parent de l'enfant et il utilisera l'appel wait(2) le moment venu.

Une fois que le parent a pris des informations sur la mort de l’enfant, le noyau efface toutes les informations sur l’enfant afin qu’un autre processus prenne bientôt sa place.

Remerciements


Merci à Sasha «Al» pour l'édition et l'aide dans la conception;

Merci à Sasha "Reisse" pour les réponses claires aux questions difficiles.

Ils ont enduré l'inspiration qui m'a attaqué et le déluge de mes questions qui m'a attaqué.

Source: https://habr.com/ru/post/fr423049/


All Articles