Processus et exécution
Description des processus
Les processus permettent à toutes les applications de progresser simultanément, les processeurs physiques les exécutent en alternance et chaque application a l'illusion d'être seule sur la machine. Les processus ont pour objectif de partager les ressources disponibles entre de multiples applications pour utiliser les ressources efficacement.
Définition : Un processus est un programme en exécution, une instance d'un programme s'exécutant sur un ordinateur, une entité pouvant être assignée et exécutée sur un processeur ou une unité d'activité caractérisée par l'exécution d'une suite d'instructions, un état courant et un ensemble de ressources système.
Les processus sont caractérisés par leur initialisation (code du programme, ensemble de données en entrée), leur état durant l'exécution (bloc de contrôle) et un identifiant (PID).
Le bloc de contrôle d'un processus est composé de :
- un identifiant
- des adresses mémoire
- le compteur ordinal (program counter) et les registres sauvegardés
- état d'ordonnancement
- état des entrées / sorties
- privilèges
- statistiques
Chaque processus a une généalogie, c'est-à-dire un processus père et potentiellement des processus fils. Le père de chaque processus est notifié de la mort de ses fils, peut l'attendre et savoir s'il a réussi. Un processus peut devenir orphelin (il est alors adopté par le processus init, de PID 1).
Exécution des processus
Le processeur exécute les instructions au rythme de son horloge (cycles de durée fixe, liée à la fréquence, avec un pipeline). Une horloge matérielle (timer) interrompt régulièrement le processeur pour redonner la main au système (entre 100 et 1000 fois par seconde sous Linux selon la configuration). Chaque processus possède un mode d'exécution avec différents privilèges.
Modes d'exécution
Il existe plusieurs niveaux de privilèges qui sont mis en place matériellement par le processeur (accès aux registres de contrôle, instructions d'entrées / sorties de bas niveau, gestion mémoire). Les différents niveaux de privilèges définissent les instructions autorisées. Le noyau peut tout faire, sauf en cas de virtualisation matérielle, où seul l'hyperviseur peut tout faire.
Le mode utilisateur est quant à lui très limité (protection ring 3 sur x86) et n'a pas accès à la mémoire du noyau.
Exceptions et interruptions
Ce sont des événements inattendus qui interrompent temporairement l'exécution en cours. Le processeur saute automatiquement et immédiatement à un traitant (fonction dont l'adresse a été définie par le système d'exploitation au démarrage). Le traitant dépend du type d'exception ou d'interruption.
Une exception est déclenchée par le processeur lui-même lorsqu'une instruction provoque une erreur et qu'il ne sait pas comment continuer (défaut de page, division par zéro, etc.) : il demande alors à l'OS de l'aider. Si le traitant a pu corriger la situation (par exemple en chargeant la page manquante), l'exécution reprend au même endroit et l'application ne se rend compte de rien ; sinon, l'OS signale l'erreur au processus (par un signal, qui le termine souvent).
Les exceptions sont synchrones : elles ne se produisent que lorsque le processeur exécute une instruction.
Les interruptions sont des messages d'un périphérique (signal électrique sur une broche dédiée du processeur ou écriture à une adresse mémoire spéciale). C'est un événement asynchrone, le processeur pourrait continuer à tourner en l'ignorant pendant un certain temps.
Les interruptions peuvent survenir n'importe quand, même si le processeur ne fait rien ; elles n'ont pas d'influence directe sur la tâche qui s'exécutait (l'application ne se rend compte de rien).
Les appels système sont des exceptions provoquées volontairement par une application (instruction spéciale) pour effectuer une opération privilégiée. Ils se produisent uniquement depuis l'espace utilisateur, à la demande des applications.
Appels système
Le traitement de l'appel système est effectué dans le traitant de l'instruction spéciale, dont l'adresse est définie par le noyau au démarrage ; le processeur y saute immédiatement lors de l'appel système. S'ensuit une exécution temporaire en mode privilégié (le processus n'est pas interrompu : son exécution est déroutée).
L'appel système se fait en passant les paramètres au traitant. Convention de passage des paramètres sous Linux x86 32 bits (int $0x80) : numéro dans %eax et arguments dans %ebx,
%ecx, %edx, %esi et %edi.
Une fois le traitement terminé, les registres utilisateur sont restaurés et le retour
en espace utilisateur se fait par une instruction spéciale (iret, sysexit).
Les appels système sont coûteux, et certains sont parfois inutiles (le résultat de gettimeofday() peut être calculé sans privilège particulier) : les appels système virtuels (vsyscall, puis vDSO sous Linux) permettent d'exécuter ces appels directement en espace utilisateur.
Processus et threads
Vie et mort des processus
Sous Unix, le lancement d'un programme se fait par la création d'un nouveau
processus puis l'exécution d'une commande (fork() + exec()). Le fork()
permet de dupliquer l'espace d'adressage et de créer un nouveau fil d'exécution
dans cet espace. Le fils créé est identique au père, sauf pour son PID (et la valeur renvoyée par fork()).
La création d'un thread revient à ajouter un fil d'exécution dans
l'espace d'adressage courant, via pthread_create() ou l'appel système clone().
Pour lancer une tâche, le noyau crée un contexte utilisateur. Ce contexte pointe
vers une fonction à exécuter et possède une nouvelle pile. Lors de la terminaison
d'une tâche, il faut passer en mode noyau, par un appel système (exit) ou une exception. La file d'exécution est alors détruite et
relâche les ressources qu'elle utilisait. Lorsqu'une tâche se termine elle
renvoie un code de retour destiné au père. Le père peut attendre la terminaison
d'un fils précis ou de n'importe quel fils (waitpid() et wait()). La tâche terminée reste zombie
tant que le père n'a pas récupéré son code de retour.
Les processus prêts à s'exécuter sont dans une file d'attente spéciale appelée runqueue. Cette liste est utilisée uniquement pour les processus prêts ; les processus non prêts sont dans des files spéciales avec un moyen de les retrouver. Il ne sert à rien de parcourir une liste de 1000 processus pour trouver le seul prêt à s'exécuter.
Les attentes actives sont des boucles infinies qui attendent un événement : elles
monopolisent le processeur jusqu'à l'événement. Elles offrent une très bonne
réactivité mais gaspillent des cycles processeur. L'attente semi-active est
une attente active en rendant la main. L'opération yield() à chaque itération
d'une boucle while est effectuée et la réactivité est imprévisible (pas de
garantie de reprise de main rapide). Cela peut gaspiller autant de temps CPU
(dépend de l'activité des autres tâches). L'attente semi active n'est à utiliser
que quand une attente passive est totalement impossible.
L'attente passive d'un événement est un mécanisme de réveil lors de l'événement. La tâche est placée sur une file d'attente et elle n'est plus dans la runqueue (ignorée par l'ordonnanceur). Lors de l'événement, celui qui produit l'événement déplace la tâche de la file d'attente vers la runqueue. En revanche l'endormissement et le réveil d'un processus coûtent assez cher, puisqu'il est nécessaire d'effectuer des changements de contexte. Il n'est donc rentable de mettre un processus en sommeil que si le délai d'attente n'est pas très court.
Ordonnancement
Les états des processus sont représentés par un automate de transitions entre les états suivants :
- Running
- Ready (prêt mais pas en train de s'exécuter)
- Blocked (non-prêt, en attente d'un événement)
- New (en cours de création)
- Exit (en cours de destruction, zombie)
- Suspend (suspendu par l'utilisateur)
L'ordonnanceur fait partie du noyau, mais il n'y a pas d'« homme en noir » qui ordonnance les tâches en arrière-plan :
l'ordonnancement nécessite que le code de l'ordonnanceur soit effectivement exécuté. L'ordonnancement peut être activé explicitement par les applications (processus
qui rend la main avec yield() ou processus qui attend sleep()) ou
implicitement par des appels système bloquants (read(), poll()). Cependant,
nous n'avons aucune garantie d'un ordonnancement régulier, cela dépend du bon
vouloir des applications. Il y a aussi un problème d'équité et de réactivité, un
processus peut conserver le processeur indéfiniment. Les tâches doivent donc
coopérer.
Il faut qu'une tâche s'exécutant en mode noyau, sur le bon processeur, appelle le code de l'ordonnanceur. Il faut donc trouver un moyen d'appeler l'ordonnanceur même si l'application ne fait pas d'ordonnancement explicite ou implicite. Il faut donc utiliser le mécanisme de préemption.
La préemption est le mécanisme qui permet de désordonnancer une tâche de force si une tâche s'exécute depuis trop longtemps ou si une autre tâche est prioritaire. La préemption engendre un léger surcoût puisque l'on doit vérifier de temps en temps s'il faut désordonnancer de force mais c'est un gain énorme en équité et réactivité. Il n'y a plus besoin de coopération des processus.
Sous Linux, l'ordonnanceur vérifie s'il faut passer la main à une autre tâche lors du retour vers le code utilisateur (après un appel système ou une interruption, notamment celle du timer). Il n'y a pas de préemption directe en espace utilisateur (il faut être dans le noyau pour exécuter le code de l'ordonnanceur) : les processus sont préemptés depuis le noyau (par exemple juste avant leur retour en espace utilisateur). Le code du noyau pouvant être long et nuire à la réactivité du système, un noyau préemptible peut aussi être préempté en dehors des sections critiques. Les interruptions peuvent survenir n'importe quand.
Threads
L'intérêt des threads est le partage de ressources entre les tâches, permettant une meilleure exploitation des processeurs : un seul programme peut tourner sur plusieurs processeurs (critique depuis l'avènement des multicœurs).
Un thread est un fil d'exécution interne à un processus. Le processus devient un conteneur (au moins un thread et des ressources partagées). Certaines données ne peuvent pas être partagées (pile, registres), alors que d'autres le peuvent (espace d'adressage mémoire, fichiers ouverts, gestionnaires de signaux, identifiant du processus, etc.).
Modèle 1:1
Un thread noyau est associé à chaque thread utilisateur : la création, la destruction et le changement de contexte sont presque aussi coûteux que pour des processus, mais les threads d'un même processus peuvent s'exécuter en parallèle sur plusieurs cœurs, et un thread bloqué ne bloque pas les autres (modèle de Linux avec NPTL).
Threads en espace utilisateur
Un seul thread noyau propulse plusieurs threads utilisateur (modèle N:1) : le coût d'ordonnancement est très réduit (on reste en espace utilisateur), mais le multithreading est ignoré par le système : pas de parallélisme réel, et un appel système bloquant bloque tous les threads.
Modèle M:N
Dans ce modèle, plusieurs threads utilisateur s'exécutent sur plusieurs threads noyau. Si un thread se bloque, les autres peuvent continuer à travailler ; l'ajout de threads noyau se fait à la volée.
Algorithmes d'ordonnancement
Nous devons résoudre le problème suivant : nous avons besoin d'équité et d'interactivité dans la progression de toutes les tâches, sans que cela ne dépende de la bonne volonté des tâches (préemption). Il est donc nécessaire de déterminer la durée d'exécution allouée à chaque tâche (choix de la durée des timeslices). Il nous faut un temps ni trop court, sinon le coût d'ordonnancement devient prohibitif, ni trop long, sinon les processus n'avancent pas assez régulièrement.
En revanche il faut noter qu'il est difficile de savoir précisément si un processus est gourmand en temps processeur, l'ordonnancement est essentiellement basé sur les ticks d'horloge (peu précis).