Processus et virtualisation

Guillaume Chanel

Brain-storming

Pour vous qu’est-ce qu’un processus?

Quelles informations contient-il?

Où réside ces informations en mémoire?

Que permet les systèmes multi-processus ?

Le processus en bref

Un processus représente l’exécution courante d’un programme. Il contient donc toutes les informations nécessaire à l’exécution du programme.

De l'executable au processus

Mémoire virtuelle

Espace d’adressage du processus (mémoire virtuelle)

Espace d'adressage

Exercice

Créer un programme en C qui:

  • déclare des variables globales
  • déclare des variables locales (e.g. dans une fonction)
  • utilise la fonction malloc pour allouer de la mémoire
  • appelle une fonction autre que la fonction main
  • affiche TOUTES les adresses des objects déclarés ci-dessus (y compris les fonctions et les adresses des pointeurs)
  • attend une entrée utilisateur ou se met en pause

Correction

Click here to download

Exercice

En utilisant la commande pmap:

  • observer les différents segments du processus
  • comparer les adresses des segments avec les adresses des variables de votre programme
  • confirmer la bonne répartition des données dans les segments

Objectif de la mémoire virtuelle

Grâce la mémoire virtuelle on va pouvoir:

  • définir un espace d’adressage indépendant pour chaque processus;
  • adresser plus de mémoire que la mémoire physique disponible;
  • partager facilement des zones de mémoire entre processus;
  • adresser le contenu de fichiers comme s’il étaient en mémoire.

Virtualisation de la mémoire

L’espace d’adressage est divisé en pages (en général de 4Ko). Une page virtuelle peut être associée à une page de mémoire vive (page valide) ou morte (page invalide).

Virtualisation

Conversion adr. virtuelle -> adr. physique

Elle est réalisée par le matériel (Memory Management Unit - MMU):

Convertion

Table des pages

Une table des pages existe pour chaque processus.

Chaque table est maintenue par le système (i.e. Linux, MacOSX, Windows, etc…) et utilisée par le MMU.

Quelques informations généralement contenues dans une entrée de la table:

  • numéro de page physique;
  • taille d’une page;
  • permissions d’accès;
  • bit «page valide» ou «page présente en RAM»;
  • bit «page sale» (i.e. modifiée depuis sa dernière présence sur disque);

Défaut de page

Un défaut de page arrive lorsque le MMU ne peut pas satisfaire une demande de page car elle n’est pas référencée dans la table du processus (bit «page valide» = false).

Il y a alors 3 cas possibles:

  • l’accès mémoire est illégal -> le noyaux termine le processus en «segmentation fault» (SIGSEG);
  • La page est présente en mémoire physique , c’est un défaut de page mineur -> il suffit de mettre à jour la table du processus pour la faire pointée sur la page en mémoire physique;
  • La page n’est pas présente en mémoire physique, c’est un défaut de page majeur .

Défaut de page majeur

Pour un défaut de page majeur il faut charger la page manquante:

  • on sauvegarde l’état du processus et on le mets «en attente»;
  • si il n’y a pas de place en mémoire physique on libère une page peu utilisée;
  • on charge la page manquante en mémoire depuis le disque;
  • on mets à jour la table des pages du processus;
  • on charge l’état du processus et on repart de l’instruction ayant provoquée la faute de page (cette fois satisfaite).

A noter que lorsqu’une page est libérée en mémoire physique soit:

  • elle existe déjà sur le disque car elle n’a pas été modifié (i.e. bit «page sale» = 0), dans ce cas il suffit de remplacer cette page physique par la nouvelle
  • elle à été modifiée et est mise en swap pour conserver les modifications.

Exercice

En utilisant la commande pmap -X sur le processus précédent, observer et expliquer les champs Size, RSS, PSS et Swap


Comment ces champs evoluent-t-ils lorsque plusieurs processus identiques sont lancés ?


Que faudrait-il faire pour que la champ Swap commence à augmenter ?


Pourquoi dans certain cas Size est différent de RSS mais Swap vaut 0 ?

Processus

Structure d'un processus

Un processus est identifié grâce à son PID (Process ID). Il est unique pour chaque processus mais un PID libéré peut être réutilisé.

Chaque processus est décrit par son contexte:

  • l’état du processeur qui l’exécute:
    • les registres accessibles au programme;
    • l’instruction courante (compteur ordinal);
    • les informations de pagination (tables des pages...);
  • son espace mémoire virtuel -> les données et le programme;
  • les ressources dont il dispose;
  • des informations administratives:
    • PID, utilisateur(s), Session ID, Groupe ID;
    • priorités (statique et dynamique);
    • consommation de ressources.

Structure d'un processus

Dans le noyaux Linux (3.7.10) un processus est définit par la structure task_struct (/usr/src/linux/include/linux/sched.h).


							struct task_struct {
								/* ... */
								/* PID du processus */
								pid_t pid;

								/* Description de la mémoire virtuelle + table de page */
								struct mm_struct *mm;

								/* Etat du CPU / registres (specifique à la platforme) */
								struct thread_struct thread;

								/* Information sur l'ordonnancement du processus */
								struct sched_info sched_info;

								/* Contient notament la table des descripteur de fichier ainsi
								qu'une liste des descripteurs "close on-exec" */
								struct files_struct *files;

								/* ... (+ de 350 lignes au total) */
							};

							/* Dans /usr/src/linux/include/linux/mm_types.h */
							struct mm_struct {
								/* ... */
								unsigned long start_code, end_code, start_data, end_data; /* segments de code / données */
								unsigned long start_brk, brk, start_stack; /* segment du tas et de la pile */
								/* ... */
							}
						

Création de processus

Lors du démarrage du système, le processus systemd (ou init) est créé par le noyau. Il est donc le premier processus et porte le PID 1.

Tous les autres processus sont créés par un appel à la fonction fork. Chaque processus a donc un parent (excepté systemd, c.f. commande pstree).


						#include <unistd.h>
						#include <sys/types.h>
						pid_t fork(void); // Crée un nouveau processus enfant
						pid_t getpid(void); // retourne le PID du processus
						pid_t getppid(void); // retourne le PID du parent
						

Cette fonction crée un nouveau processus qui est une réplique du processus parent (e.g. copie de la table des pages, état du processeur, descripteurs de fichier, etc...), et va continuer son exécution à partir du fork.

La fonction fork retourne 0 pour le processus enfant, le PID de l’enfant dans le processus parent, -1 en cas d’erreur.

Création de processus

L’implémentation d’un fork peut donc ce faire de la manière suivante:


						#include <unistd.h>
						pid_t pid = fork()
						if(pid > 0) {
							// Code du parent
						}
						else if(pid == 0){
							// Code de l’enfant
						}
						else // Error
						

Le processus enfant n’est pas une réplique exacte du parent (see man fork), notamment:

  • l’enfant a son propre PID et son PPID est égale au PID du parent;
  • pas d’héritage des verrous mémoire et fichiers (mlock, flock).

Fork et descripteurs de fichiers

La table des descripteurs de fichier est copiée

En conséquence, que ce passe-t-il si le processus parent écrit sur le même descripteur que le processus enfant ?

Fork et mémoire virtuelle

Le nouveau processus va donc partager des pages avec son processus parent.

Création de processus et mémoire virtuelle

Copy on write

Ces pages seront copiées uniquement lors de modifications de la mémoire. C’est ce que l’on appelle le «copy on write».

Mécanisme de copy on write

Terminaison de processus

La fonction exit permet de terminer un processus à tout moment:

exit(int status);

Il existe deux constantes souvent utilisées EXIT_SUCCESS et EXIT_FAILURE.

Avant de terminer le processus la fonction exit:

  • ferme les descripteurs de fichiers ouverts (inclus STDIN, STDOUT, STDERR);
  • envoi le signal SIGCHLD au parent pour l’informer de la mort de l’enfant;
  • tous les enfants du processus deviennent enfant du processus 1 (systemd /init), on dit qu’ils sont orphelins;
  • appelle les fonctions enregistrées par atexit (c.f. man).

Il existe d’autres fonction pour terminer un programme:


						void _exit(int status); // appel système direct,  sans appel aux fonction enregistrées avec atexit
						void abort(void); // génération d’un core dump
						

Processus zombies

Lorsqu’un processus se termine, le noyau garde certaines informations de la task_struct (pid, statut de terminaison, etc...). On dit alors que le processus est un zombie.

Ces information sont conservées en mémoire tant que le parent du processus n’y a pas accédé.

Dans le cas ou le parent du processus est mort c’est le processus 1 (systemd ou init) qui va se charger de détruire la task_struct.

Eviter les zombies

Lorsqu’un processus effectue un fork il doit donc prendre soins d’éviter les zombies en appelant une des fonctions suivantes:


						#include <sys/types.h>
						#include <sys/wait.h>
						pid_t wait(int *status);
						pid_t waitpid(pid_t pid, int *status, int options);
						

Ces fonctions permettent d’attendre la terminaison d’un enfant pour récupérer son statut. Si un enfant est déjà terminé (i.e. est un zombie), ces fonctions retournent immédiatement.

Plusieurs macros permettent de tester le statut de retour (c.f. man wait) dont:

  • WIFEXITED(status) : indique si l’enfant c’est terminé normalement;
  • WCOREDUMP(status) : indique si un core dump de l’enfant a été créé.

Questions

Un processus orphelin peut-il rester un zombie longtemps

Dans quels cas un processus peut rester un zombie longtemps ?

Execution de processus

Exec*

L’execution d’un nouveau programme ce fait par les fonctions exec*, dont:


						#include <unistd.h>
						int execve(const char *filename, char *const argv[], char *const envp[]);
						

Cette fonction ne retourne pas de valeur en cas de succès mais elle:

  • retourne -1 en cas d'erreur (+ errno mis à jour)
  • remplace les segments du processus courant par les segments de l’éxécutable filename (c.f. Fichiers ELF);
  • les paramètres argv et envp sont disponibles dans le main du programme appelé.

Si filename est un script, le shell correspondant est chargé et le fichier executé par le shell.

C'est donc cette fonction qui se charge de construire l'espace de mémoire virtuel d'un processus à partir du fichier executable.

Execve: exemple

Les types de fichiers compilés

Système Nom Commentaires
MSDOS / Windows COM Exécutable très limité, n’est quasi plus utilisé
PE (Portable Executable) Fichiers exécutables: .EXE
Librairies partagées : .DLL
ActiveX: .OCX
OS X Mach-O Apps., frameworks, bib., etc.
Unix/Linux a.out Format original des objets et exécutable Unix, non adapté au librairies partagées
COFF (Common Object File Format) Ancien format des objets et exécutable Unix, non adapté au librairies partagées
ELF (Executable and Linkable Format) Fichiers Exécutables: .o
Librairies partagées: .so
Fichiers core (coredump)
Utilisable sur plusieurs plateformes

Organization d'un fichier ELF

  • des segments qui:
    • permettent à l'OS de préparer le programme pour son exécution (c.f. exec*);
    • contiennent une ou plusieurs sections;

  • des sections qui:
    • contiennent TOUTES les informations du programme (pas forcément nécessaire à l’exécution – e.g. débogage);
    • sont nécessaires lors de la phase de liage;

  • Des entêtes et tables qui:
    • indiquent la position de chaque section;
    • indiquent la position de chaque segment;
    • indiquent la position de la table des sections et de la table des segments.

Fichier ELF

On peut observer le contenu d’un fichier ELF avec les commandes objdump et readelf.

Entête Table des segments Section .text Section .rodata ... Section .data Section .bss Section .debug ... Table des sections

									#include <elf.h>
									typedef struct {
									//les variables ci-dessous ne sont pas listée dans l’ordre de l’entête
									...
									uint16_t e_type; /* Executable, bibliothèque, objet, ... */
									uint_16 e_machine; /* Intel, HP,...*/
									ElfN_Addr e_entry; /* Première instruction à exécuter par le processus */

									ElfN_Off e_phoff; /* Offset de départ de la table des segments */
									uint16_t e_phentsize; /* Taille d’une entrée dans la table des segments*/
									uint16_t e_phnum; /* nombre d’entrées dans la table des segments */

									ElfN_Off e_shoff; /* Offset de départ de la table des sections */
									uint16_t e_shentsize; /* Taille d’une entrée dans la table des sections*/
									uint16_t e_shnum; /* nombre d’entrées dans la table des sections*/
									...
									} ElfN_Ehdr;
								

Fichier ELF

La table des sections permet de définir les sections dans le fichier. Une section peut contenir des informations de liage, du code, des données.


						typedef struct {
							...
							uint32_t sh_name; /*Index spécifiant le nom de la section (.text, .data, etc.)*/
							ElfN_Addr sh_addr; /* Adresse de la section en mémoire virtuelle */
							ElfN_Off sh_offset; /* Offset de la section dans le fichier ELF*/
							uintN_t	sh_size; /* Taille de la section */
							...
						} ElfN_Shdr;
					

Exercice: ajouter les flêches

Fichier ELF

La table des segments (program header) permet de regrouper les sections en plusieurs segments. Ces segments peuvent être chargés en mémoire virtuelle lors de l'exécution.

Espace virt. proc.

						typedef struct {
							uint32_t p_type; /* if == PT_LOAD -> le segment doit être placé en mémoire */
							ElfN_Off p_offset; /* Offset du segment dans le fichier */
							uintN_t p_filesz; /* Taille du segment dans le fichier*/

							ElfN_Addr p_vaddr; /* Adresse où charger le segment en mémoire virtuelle */
							uint32_t p_memsz; /* Taille du segment en mémoire, si >= p_filesz, complété par des 0 */
							uintN_t	p_flags; /* Exec, write, read */
							...
							} ElfN_Phdr;
					

Exercice (ensemble): représenter comment ces informations permettent de définir l'espace de mémoire virtuelle du processus