Programmation Système
Programmation Système
Programmation Système
En résumé, maîtriser ces concepts donne un contrôle total sur l’environnement d’exécution,
permettant de créer des logiciels plus performants, sécurisés et adaptés aux besoins systèmes.
5
TP Final : Surveillance de l'utilisation du CPU et de
la mémoire
Instructions :
▰ - Utiliser fork() pour créer un processus fils chargé de la surveillance.
▰ - Le processus fils lit les fichiers /proc/meminfo et /proc/stat pour collecter les données.
▰ - Le processus père attend la fin du fils et affiche un message lorsque celui-ci termine. 6
1
Rappels
7
Système
d’exploitation
8
❖ Qu'est-ce qu’un système d’exploitation ?
9
Qu’est-ce qu’un système d’exploitation ?
Un système d'exploitation, ou logiciel système, ou Operating System (OS), est un logiciel qui, dans un
appareil électronique, pilote les dispositifs matériels et reçoit des instructions de l'utilisateur ou
d'autres logiciels (ou applications).
En informatique, un système d'exploitation est un groupe de programmes qui facilitent l'utilisation
d'un ordinateur. Il s'agit d'un logiciel qui reçoit des sollicitations pour employer les ressources de la
machine comme le disque dur pour stocker de la mémoire, ou des périphériques pour établir une
communication visuelle ou auditive.
▰ Offre une interface unifiée du matériel aux applications
▰ Protège les ressources (et les applis entre elles)
▰ Virtualise les ressources
10
Qu’est-ce qu’un système d’exploitation ?
11
Qu’est-ce qu’un système d’exploitation ?
12
Rôle d’un système d’exploitation
Deux interfaces :
▰ Interface de programmation (Application Programming Interface)
▻ Utilisable à partir des programmes s’exécutant sous le système
▻ Composée d’un ensemble d’appels systèmes (procédures spéciales)
▰ Interface de commande ou interface utilisateur
▻ Utilisable par un utilisateur humain, sous forme textuelle ou graphique
▻ Composée d’un ensemble de commandes
❖ Textuelles (ex : rm *.o)
❖ Graphique (ex : déplacer un fichier vers la corbeille) 14
Exemples d’usage des interfaces d’UNIX
▰ Interface de programmation
▻ Programme copiant un
fichier fich1 dans un autre
fich2 grâce à l’utilisation des
appels système read et write
▰ Interface de commande
▻ $ cp fich1 fich2
15
Découpage en couches du système
▰ Niveau utilisateur
▻ Applications utilisateur
▻ Logiciels de base
▻ Bibliothèque système
▻ Appels de fonction
▰ Niveau noyau
▻ Gestion des processus
▻ Système de fichier
▻ Gestion de la mémoire
▰ Niveau matériel
16
▻ Firmware, contrôleur
Protection des ressources
17
Exemples de ressources protégées
18
Limites de la protection
Les systèmes réels ont des failles. Ils ne protègent pas de tout.
▰ While true ; do mkdir toto ; cd toto ; done (en shell)
▰ While(1) { fork(); } (en C)
▰ While(1) { char *a=malloc(512); *a=‘1’; } (en C)
Réponse classique de l’OS : gel (voire pire)
On suppose que les utilisateurs ne sont pas si mal intentionnés
Unix was not designed to stop people from doing stupid things, because that would also
19
stop them from doing clever things. – Doug Gwyn
Limites de la protection
21
Pourquoi le langage C ?
Différents langages permettent au programmeur de construire des programmes qui seront exécutés par le
processeur. En réalité, le processeur ne comprend qu’un langage : le langage machine. Ce langage est un langage
binaire dans lequel toutes les commandes et toutes les données sont représentés sous la forme de séquences
de bits.
Le langage machine est peu adapté aux humains et il est extrêmement rare qu’un informaticien doive manipuler
des programmes directement en langage machine. Par contre, pour certaines tâches bien spécifiques, comme
par exemple le développement de routines spéciales qui doivent être les plus rapides possibles ou qui doivent
interagir directement avec le matériel, il est important de pouvoir efficacement générer du langage machine. Cela
peut se faire en utilisant un langage d’assemblage. Chaque famille de processeurs a un langage d’assemblage qui
lui est propre. Le langage d’assemblage permet d’exprimer de façon symbolique les différentes instructions qu’un
processeur doit exécuter. Le langage d’assemblage est converti en langage machine grâce à un assembleur.
22
Pourquoi le langage C ?
Le langage d’assemblage est le plus proche du processeur. Il permet d’écrire des programmes compacts et
efficaces. C’est aussi souvent la seule façon d’utiliser des instructions spéciales du processeur qui permettent
d’interagir directement avec le matériel pour par exemple commander les dispositifs d’entrée/sortie. C’est
essentiellement dans les systèmes embarqués qui disposent de peu de mémoire et pour quelques fonctions
spécifiques des systèmes d’exploitation que le langage d’assemblage est utilisé de nos jours. La plupart des
programmes applicatifs et la grande majorité des systèmes d’exploitation sont écrits dans des langages de plus
haut niveau.
Le langage C, développé dans les années 70 pour écrire les premières versions du système d’exploitation Unix,
est aujourd’hui l’un des langages de programmation les plus utilisés pour développer des programmes qui
doivent être rapides ou doivent interagir avec le matériel. La plupart des systèmes d’exploitation sont écrits en
langage C.
Le langage C a été conçu à l’origine comme un langage proche du processeur qui peut être facilement compilé,
c’est-à-dire traduit en langage machine, tout en conservant de bonnes performances.
23
Structure générale d’un code en C
[directives au préprocesseur]
[déclarations de variables externes]
[fonctions secondaires]
main(arguments formels)
{
déclarations de variables internes
instructions
instruction de retour
}
Tout programme C doit contenir une fonction nommée main dont la signature est :
En C, la fonction main retourne un entier. Cette valeur de retour est passée par le système
d’exploitation au programme (typiquement un shell ou interpréteur de commandes) qui a demandé
l’exécution du programme. Grâce à cette valeur de retour il est possible à un programme d’indiquer
s’il s’est exécuté correctement ou non.
Par convention, un programme qui s’exécute sous Unix doit retourner EXIT_SUCCESS lorsqu’il se
termine correctement et EXIT_FAILURE en cas d’échec. La plupart des programmes fournis avec un
Unix standard respectent cette convention.
Dans certains cas, d’autres valeurs de retour non nulles sont utilisées pour fournir plus
d’informations sur la raison de l’échec. En pratique, l’échec d’un programme peut être dû aux
arguments incorrects fournis par l’utilisateur ou à des fichiers qui sont inaccessibles.
26
Type de retour de la fonction « main »
#define EXIT_FAILURE 1
#define EXIT_SUCCESS 0
27
Exemples de codes en C
28
Compilation d’un code C sous Unix
Pour être exécuté, un programme doit être compilé. Il existe de nombreux compilateurs permettant
de transformer le langage C en langage machine. Dans le cadre de ce cours, nous utiliserons gcc.
gcc supporte de très nombreuses options dont l’option -Wall qui le force à afficher tous les
messages de type warning et l’option -o suivie du nom de fichier qui indique le nom du fichier dans
lequel le programme exécutable doit être sauvegardé par le compilateur.
Exemple : gcc -Wall -o hello hello.c
Le programme exécutable peut alors être exécuté comme un script en précédant son nom des
caractères ./ depuis l’interpréteur de commandes. Cela peut être suivi par les arguments à envoyer à
la fonction main.
Exemple : ./hello
29
./hello Arg1 Arg2 Arg3
Organisation de
la mémoire
30
Organisation d’un programme Linux en mémoire
Le segment text
34
Organisation d’un programme Linux en mémoire
Une liste exhaustive de toutes les variables d’environnement est impossible, mais
en voici quelques unes qui sont utiles en pratique :
HOSTNAME : le nom de la machine sur laquelle le programme s’exécute. Ce nom
est fixé par l’administrateur système via la commande hostname
SHELL : l’interpréteur de commande utilisé par défaut pour l’utilisateur courant.
Cet interpréteur est lancé par le système au démarrage d’une session de
l’utilisateur. Il est stocké dans le fichier des mots de passe et peut être modifié par
l’utilisateur via la commande passwd
USER : le nom de l’utilisateur courant. Sous Unix, chaque utilisateur est identifié
par un numéro d’utilisateur et un nom uniques. Ces identifiants sont fixés par
l’administrateur système via la commande passwd
HOME: le répertoire d’accueil de l’utilisateur courant. Ce répertoire d’accueil
appartient à l’utilisateur. C’est dans ce répertoire qu’il peut stocker tous ses 37
fichiers.
Organisation d’un programme Linux en mémoire
La pile (stack)
Le langage C utilise le passage par valeur, les valeurs des arguments d’une
fonction sont copiés sur la pile avant de démarrer l’exécution de cette
fonction. Lorsque la fonction prend comme argument un entier, cette copie
prend un temps très faible. Par contre, lorsque la fonction prend comme
argument une ou plusieurs structures de grand taille, celles-ci doivent être
entièrement copiées sur la pile. 39
2
Processus
40
❖ Qu'est-ce qu’un processus ?
41
Qu’est-ce qu’un processus ?
Il est très important de différencier la notion de programme et la notion de processus. Un programme, c'est une
suite d'instructions (notion statique), tandis qu'un processus, c'est l'image du contenu des registres et de la
mémoire centrale (notion dynamique).
On peut imaginer un processus comme un programme en cours d'exécution auquel est associé un
environnement processeur et un environnement mémoire. En effet, au cours de son exécution, les instructions
d'un programme modifient les valeurs des registres (le compteur ordinal, le registre d'état...) ainsi que le contenu
de la pile. Cette représentation est très imparfaite car une application peut non seulement utiliser plusieurs
processus concurrents, mais un unique processus peut également lancer l'exécution d'un nouveau programme,
en remplaçant entièrement le code et les données du programme précédent.
Sous Unix, toute tâche qui est en cours d'exécution est représentée par un processus. Un processus est une
entité comportant à la fois des données et du code. On peut considérer un processus comme une unité
élémentaire en ce qui concerne l'activité sur le système.
42
Qu’est-ce qu’un processus ?
Question : Si un même programme est lancé deux fois, obtiendra-t-on un même processus pour les deux
exécutions ?
Réponse : Non, les processus seront différents. Le principe est le même que celle de la différence entre deux
objets d’une même classe.
43
Lister les processus sous Linux
On peut examiner la liste des processus présents sur le système à l'aide de la commande
ps, et plus particulièrement avec ses options, qui nous permettent de voir les processus
endormis, et ceux qui appartiennent aux autres utilisateurs.
Exemple : ps aux
La commande ps affiche plusieurs colonnes dont la signification nous donne différents
types d’informations sur les processus listés.
44
Utilité des processus : Simplicité
▰ Mieux gérer les communications qui bloquent les processus. Les communications
dans ce cas sont à prendre au sens large : réseau, disque, utilisateur, autres
programmes…
L’image ci-dessous illustre le cas de recouvrement des temps de calculs et communications :
46
Parallélisme et pseudo-parallélisme
▰ Compétition
Plusieurs processus peuvent vouloir accéder en même temps à une ressource exclusive (ne pouvant être
utilisée que par un seul à la fois) comme le processeur, l’imprimante ou la carte son.
Une solution parmi tant d’autre est le FCFS ; les autres attendent leur tour.
▰ Coopération
Plusieurs processus peuvent collaborer pour une tâche commune et souvent, ils doivent se synchroniser.
Par exemple P1 produit un fichier et P2 imprime le fichier ou P1 met à jour un fichier et P2 consulte le
fichier. La synchronisation se ramène au fait que P2 doit attendre que P1 ait franchi un certain point de
son exécution.
48
Faire attendre un processus
▰ Attente active
Cette attente active constitue un gaspillage de ressources surtout si les processus sont
exécutés en pseudo-parallélisme.
49
Faire attendre un processus
▰ Blocage du processus
Définition d’un nouvel état de processus : bloqué. L’exécution est suspendue et le réveil est
explicitement lancé par un autre processus ou par le système.
50
Gestion des
processus
51
État d’un processus
Les processus sont organisés en hiérarchie. Chaque processus doit être lancé par un autre.
La racine de cette hiérarchie est le programme initial.
Le processus inactif du système (System idle process : le processus que le noyau exécute
tant qu'il n'y a pas d'autres processus en cours d'exécution) a le PID 0. C'est celui-ci qui
lance le premier processus que le noyau exécute, le programme initial. Généralement, sous
les systèmes basés sous Unix, le programme initial se nomme init, et il a le PID 1.
Après son chargement, le programme initial gère le reste du démarrage : initialisation du
système, lancement d'un programme de connexion... Il va également se charger de lancer
les démons. Un démon (du terme anglais daemon) est un processus qui est constamment
en activité et fournit des services au système.
53
Identification des processus
Un processus peut être identifié par 3 valeurs, à savoir le PID, le PPID, le UID et le GID.
▰ PID (Process IDentifier) : Chaque processus peut être identifié par son numéro de processus, ou
PID. Un numéro de PID est unique dans le système : il est impossible que deux processus aient
un même PID au même moment.
▰ PPID (Parent PID) : PID du procéssus créateur.
▰ UID (User IDentifier) : Les systèmes basés sur Unix sont particulièrement axés sur le côté multi-
utilisateur. Chaque utilisateur possède un identifiant, sous forme numérique, nommé UID. En
conséquence, nous pouvons également distinguer les processus entre eux par l'UID de
l'utilisateur qui les a lancés.
▰ GID (Group IDentifier) : Chaque utilisateur du système appartient à un ou plusieurs groupes. Un
processus fait donc également partie des groupes de l'utilisateur qui l'a lancé. 54
Vie et mort des processus
58
Appel système pid_t fork()
59
Appel système pid_t fork()
60
Appel système pid_t fork()
61
Appel système pid_t fork()
62
Appel système pid_t fork()
63
Appel système pid_t fork()
64
Appel système pid_t fork()
Bon nombre de fonctions utilisées avec la programmation système (notamment les appels-système comme fork)
nécessiterons l'inclusion de la bibliothèque <unistd.h>.
La fonction fork retourne une valeur de type pid_t. Il s'agit généralement d'un int ; il et déclaré dans <sys/types.h>.
Dans le cas où la fonction a renvoyé -1 et donc qu'il y a eu une erreur, le code de l'erreur est contenue dans la
variable globale errno, déclarée dans le fichier errno.h. Ce code peut correspondre à deux constantes :
▰ ENOMEM : le noyau n'a plus assez de mémoire disponible pour créer un nouveau processus ;
▰ EAGAIN : ce code d'erreur peut être dû à deux raisons : soit il n'y a pas suffisamment de ressources systèmes
pour créer le processus, soit l'utilisateur a déjà trop de processus en cours d'exécution. Ainsi, que ce soit pour
l'une ou pour l'autre raison, vous pouvez rééditer votre demande tant que fork renvoie EAGAIN.
Voici le prototype de la fonction fork :
65
Autres fonctions
66
Exemple de code
Complétez, compilez et exécutez le code suivant puis indiquer les différents résultats :
67
Exemple de code (test de clonage)
68
Exercices
69
Exercices
70
Exercices
71
Terminaison
d'un programme
72
Terminaison normale d'un processus
Un programme peut se terminer de deux façons différentes. La plus simple consiste à laisser le processus finir le
main avec l'instruction return suivie du code de retour du programme. Une autre est de terminer le programme
grâce à la fonction :
73
Terminaison anormale d'un processus
Pour quitter, de manière propre, un programme, lorsqu'un bug a été détecté, on utilise la fonction :
Un prototype simple pour une fonction qui possède un défaut majeur : il est difficile de savoir à quel endroit du
programme le bug a eu lieu.
Pour y remédier, il est préférable d'utiliser la macro assert, déclarée dans <assert.h> qui fonctionne comme suit :
▰ Elle prend en argument une condition.
▰ Si cette condition est vraie, assert ne fait rien.
▰ Si elle est fausse, la macro écrit un message contenant la condition concernée, puis quitte le programme.
Cela permet d'obtenir une bonne gestion des erreurs.
74
Terminaison anormale d'un processus
N'utilisez assert que dans les cas critiques, dans lesquels votre programme ne peut pas continuer si la condition
est fausse.
75
Exécution de
routines de
terminaison
76
Routines de terminaison
77
on_exit
Le paramètre est un pointeur de fonction vers la fonction à exécuter lors de la terminaison. Elle renvoie 0 en cas
de réussite ou -1 sinon.
Vous pouvez également enregistrer plusieurs fonctions à la terminaison. Si c'est le cas, lors de la fin du
programme, les fonctions mémorisées sont invoquées dans l'ordre inverse de l'enregistrement.
79
Atexit : Exemple 01
80
Atexit : Exemple 02
81
Communication
par signaux
82
Signaux
84
Remarques sur les signaux
86
Quelques exemples de signaux
▰ Les signaux SIGKILL et SIGSTOP ne peuvent pas être interceptés, bloqués ou ignorés.
Leur gestionnaires est non modifiable.
▰ Consulter le résultat de man 7 signal pour plus d’informations sur les signaux
87
État d’un signal
▰ Interfaces
▻ Commande
▻ Programmation
▰ À qui envoyer le signal ?
▻ Si victime > 0, kill() envoie le signal sig au processus dont le PID est égal à victime.
▻ Si victime = 0, kill() envoie le signal sig à tous les processus dont le GID est égal à celui de l'expéditeur, à l'exception de
ceux auxquels l'expéditeur n'a pas l'autorité appropriée pour envoyer un signal.
▻ Si victime = -1 :
❖ Si super utilisateur, kill() envoie le signal sig à tous les processus sauf système et émetteur
❖ Si non, kill() envoie le signal sig à tous les processus dont l’utilisateur est propriétaire
❖ Si victime < -1, kill() envoie le signal sig à tous les processus dont le GID est égal à la valeur absolue de victime, à 89
l'exception de ceux auxquels l'expéditeur n'a pas l'autorité appropriée pour envoyer un signal.
Synchronisation
entre processus
père et fils
90
Processus Zombie
91
Processus Zombie
Au moment de la terminaison d'un processus, le système désalloue les ressources que possède encore celui-ci
mais ne détruit pas son bloc de contrôle. Le système passe ensuite l'état du processus à la valeur TASK_ZOMBIE
(représenté généralement par un Z dans la colonne " statut " lors du listage des processus par la commande ps).
Le signal SIGCHLD est alors envoyé au processus père du processus qui s'est terminé, afin de l'informer de ce
changement. Dès que le processus père a obtenu le code de fin du processus achevé au moyen de l'appel
système wait, le processus terminé est définitivement supprimé de la table des processus.
Étant donné que les processus zombies ne peuvent pas être supprimés par les méthodes classiques (y compris
pour les utilisateurs privilégiés), le système se retrouve alors encombré de processus achevés (" morts ") mais
encore visibles. Ceux-ci ne consomment, à proprement parler, pas plus de ressources systèmes que les quelques
octets de mémoire occupés par le bloc de contrôle dans la table des processus ; toutefois, le nombre de
processus étant limité par le nombre possible de PID, un trop grand nombre de zombies peut empêcher le
système de créer de nouveaux processus. Cette métaphore de horde de processus défunts, impossibles à tuer
car déjà morts, est à l'origine du terme de " zombie ".
92
Processus Zombie
La seule manière d'éliminer ces processus zombies est de causer la mort du processus
père, par exemple au moyen du signal SIGKILL.
Les processus fils sont alors automatiquement rattachés au processus n°1, généralement
init, qui se charge à la place du père original d'appeler wait sur ces derniers. Si ce n'est pas
le cas, cela signifie que init est défaillant (ou que le processus n°1 n'est pas init, mais un
autre programme n'ayant pas été prévu pour ça).
Le seul moyen de se débarrasser des zombies, dans ce cas, est le redémarrage du
système.
93
Éviter les processus zombie
Le processus doit attendre de lire les codes de retours de tous ses processus fils avant sa terminaison. Cela est
possible grâce à l’utilisation de la fonction wait disponible dans le bibliothèque <sys/wait.h> déclarée comme :
Lorsque l'on appelle cette fonction, cette dernière bloque le processus à partir duquel elle a été appelée jusqu'à ce
qu'un de ses fils se termine. Elle renvoie alors le PID de ce dernier.
En cas d'erreur, la fonction renvoie la valeur -1.
Attention : il faut mettre autant de wait qu'il y a de fils.
94
Éviter les processus zombie
Si status n'est pas un pointeur nul, le status du processus fils (valeur retournée par exit()) est mémorisé à
l'emplacement pointé par status. De manière plus précise :
▰ l'octet de poids faible est un code indiquant pourquoi le processus fils s'est arrêté ;
▰ si le processus fils a effectué un appel à exit(), l'octet précédent contient le code de retour.
Ces informations peuvent être accédées facilement à l'aide des macros suivantes définies dans sys/wait.h :
▰ WIFEXITED (status) : renvoie vrai si le statut provient d'un processus fils qui s'est terminé normalement ;
▰ WEXITSTATUS (status) :(si WIFEXITED (status) renvoie vrai) renvoie le code de retour du processus fils passé
à exit() ou la valeur retournée par la fonction main() ;
▰ WIFSIGNALED (status) :renvoie vrai si le statut provient d'un processus fils qui s'est terminé à cause de la
réception d'un signal ;
▰ WTERMSIG (status) : (si WIFSIGNALED (status) renvoie vrai) renvoie la valeur du signal qui a provoqué la
terminaison du processus fils. 95
Éviter les processus zombie
96
Attendre la fin de n'importe quel processus
Il existe également une fonction qui permet de suspendre l'exécution d'un processus père jusqu'à ce
qu'un de ses fils, dont on doit passer le PID en paramètre, se termine. Il s'agit de la fonction waitpid :
En réalité, il existe six fonctions appartenant à cette famille : execl, execle, execlp, execv,
execve et execvp toutes disponibles dans la bibliothèque <unistd.h>.
Parmi eux, seule la fonction execve est un appel système, les autres sont implémentées à
partir de celui-ci.
Ces fonctions permettent de remplacer un programme en cours par un autre programme
sans en changer le PID. Autrement dit, on peut remplacer le code source d'un programme
par celui d'un autre programme en faisant appel à une fonction exec.
101
La famille exec
103
La famille exec
Le premier argument correspond au chemin complet d'un fichier objet exécutable (si path) ou le nom
de ce fichier (si file).
Le second argument correspond aux paramètres envoyés au fichier à exécuter : soit sous forme de
liste de pointeurs sur des chaînes de caractères, soit sous forme de tableau. Le premier élément de
la liste ou du tableau est le nom du fichier à exécuter, le dernier est un pointeur NULL.
Le troisième argument éventuel est une liste ou un tableau de pointeurs d'environnement.
De plus, toutes ces fonctions renvoient -1. errno peut correspondre à plusieurs constantes, dont
EACCESS (vous n'avez pas les permissions nécessaires pour exécuter le programme), E2BIG (la liste
d'argument est trop grande), ENOENT (le programme n'existe pas), ETXTBSY (le programme a été
ouvert en écriture par d'autres processus), ENOMEM (pas assez de mémoire), ENOEXEC (le fichier
exécutable n'a pas le bon format) ou encore ENOTDIR (le chemin d'accès contient un nom de
répertoire incorrect). 104
La famille exec
105
La fonction
system
106
La fonction system
La fonction system est semblable aux exec, mais elle est beaucoup plus simple d'utilisation.
En revanche, on ne peut pas y passer d'arguments.
Son prototype est :
107
4
Threads
108
Rappel sur les
pointeurs de
fonction en C
109
Pointeurs de fonction
Un pointeur de fonctions en C est une variable qui permet de désigner une fonction C.
Comme n’importe quelle variable, on peut mettre un pointeur de fonctions soit en variable
dans une fonction, soit en paramètre dans une fonction.
On déclare un pointeur de fonction comme un prototype de fonction, mais on ajoute une
étoile (∗) devant le nom de la fonction.
Dans l’exemple suivant, on déclare dans le main un pointeur sur des fonctions qui prennent
en paramètre un int, et un pointeur sur des fonctions qui retournent un int.
110
Pointeurs de fonction
111
Rappel sur les
pointeurs
génériques en C
112
Pointeurs génériques
La généricité en C, comme dans d'autres langages, permet d'effectuer des actions, sur un ensemble
de données de manière générique, c'est-à-dire quelque soit son type. On peut donc, à l'instar des
patrons de fonctions du C++, réaliser des traitements qui pourront s'appliquer sur des types entiers
comme des types réels, pourvu que le type qui sera utilisé lors de l'appel de fonction supporte les
opérations utilisées par la fonction générique.
Le langage C dispose d'un pointeur particulier appelé pointeur générique qui est un pointeur
compatible avec tous les autres pointeurs, c'est-à-dire que ce pointeur est à même de pointer vers
n'importe quel type d'objet. Ce pointeur est le pointeur void *.
Il faut faire attention car ce n'est pas un pointeur vers un objet de type void mais bien un type à part
entière. D'ailleurs au passage, un pointeur vers un type void n'a pas de sens puisque le type void n'est
pas un type qui doit contenir quelque chose, il est là pour indiquer une absence de type.
113
Pointeurs génériques
114
Threads sous
Linux
115
Qu'est ce qu'un thread ?
Un thread (ou fil d’exécution en français) est une parie du code d’un programme (une
fonction), qui se déroule parallèlement à d’autre parties du programme.
Un premier intérêt peut être d’effectuer un calcul qui dure un peu de temps (plusieurs
secondes, minutes, ou heures) sans que l’interface soit bloquée (le programme continue à
répondre aux signaux). L’utilisateur peut alors intervenir et interrompre le calcul sans taper
un ctrl-C brutal.
Un autre intérêt est d’effectuer un calcul parallèle sur les machines multiprocesseur.
Les fonctions liées aux thread sont dans la bibliothèque <pthread.h>, et il faut compiler
avec la librairie libpthread.a :
$ gcc -lpthread monprog.c -o monprog ou $ gcc -pthread monprog.c -o monprog 116
Des processus légers ?
Le mot « thread » est un terme anglais qui peut se traduire par « fil d'exécution ».
L'appellation de « processus léger » est également utilisée.
Dans la plupart des systèmes d'exploitation, chaque processus possède un espace
d'adressage et un thread de contrôle unique, le thread principal. Du point de vue
programmation, ce dernier exécute le main.
En général, le système réserve un processus à chaque application, sauf quelques
exceptions. Beaucoup de programmes exécutent plusieurs activités en parallèle, du moins
en pseudo-parallélisme. Comme à l'échelle des processus, certaines de ces activités
peuvent se bloquer, et ainsi réserver ce blocage à un seul thread séquentiel, permettant par
conséquent de ne pas stopper toute l'application.
117
Des processus légers ?
Il faut savoir que le principal avantage des threads par rapport aux processus, c'est la
facilité et la rapidité de leur création. En effet, tous les threads d'un même processus
partagent le même espace d'adressage, et donc toutes les variables. Cela évite donc
l'allocation de tous ces espaces lors de la création, et il est à noter que, sur de nombreux
systèmes, la création d'un thread est environ cent fois plus rapide que celle d'un processus.
118
Des processus légers ?
119
Des processus légers ?
120
Usage des threads
122
Quand (pourquoi) ne pas
utiliser les threads
Pour créer un thread, il faut créer une fonction qui va s’exécuter dans le thread, qui a pour
prototype :
void *start_routine(void *arg);
Dans cette fonction, on met le code qui doit être exécuté dans le thread. On crée ensuite le
thread par un appel à la fonction pthread_create, et on lui passe en argument la fonction
start_routine dans un pointeur de fonction (et son argument arg). La fonction
pthread_create a pour prototype :
124
Création d'un thread
La fonction renvoie une valeur de type int : 0 si la création a été réussie ou une autre valeur
si il y a eu une erreur.
Le premier argument est un pointeur vers l'identifiant du thread (valeur de type pthread_t).
Le second argument désigne les attributs du thread. Vous pouvez choisir de mettre le
thread en état joignable (par défaut) ou détaché, et choisir sa politique d'ordonnancement
(usuelle, temps-réel...). Dans nos exemple, on mettra généralement NULL.
Le troisième argument est un pointeur vers la fonction à exécuter dans le thread. Cette
dernière devra être de la forme void *fonction(void* arg) et contiendra le code à exécuter
par le thread.
Enfin, le quatrième et dernier argument est l'argument à passer au thread. 125
Attente de terminaison d'un thread
Le processus qui exécute le main (l’équivalent du processus père) est aussi un thread et
s’appelle le thread principal. Le thread principal peut attendre la fin de l’exécution d’un
autre thread par la fonction pthread_join (similaire à la fonction wait dans le fork. Cette
fonction permet aussi de récupérer la valeur retournée par la fonction exécutée dans le
thread.
Le prototype de la fonction pthread_join est le suivant :
int pthread_join(pthread_t thread, void **retour);
Le premier paramètre est l’dentifiant du thread (que l’on obtient dans pthread_create), et le
second paramètre est un passage par adresse d'un pointeur qui permet de récupérer la
valeur retournée par la routine exécutée par le thread. 126
Terminer un thread
Cette fonction prend en argument la valeur qui doit être retournée par le thread, et doit être
placée en dernière position dans la fonction concernée.
127
Autres actions sur un thread
128
Exemple 01
129
Exemple 02
130
Données
partagées et
exclusion mutuelle
131
Données partagées
Lorsqu’un nouveau processus est créé par un fork, toutes les données
(variables globales, variables locales, mémoire allouée dynamiquement),
sont dupliquées et copiées, et le processus père et le processus fils
travaillent ensuite sur des variables différentes.
Dans le cas de threads, la mémoire est partagée, c’est à dire que les
variables globales sont partagées entre les différents threads qui
s’exécutent en parallèle. Cela pose des problèmes lorsque deux threads
différents essaient d’écrire et de lire une même donnée. 132
Données partagées
Pour accéder à des données globales, il faut donc avoir recours à un mécanisme d’exclusion
mutuelle, qui fait que les threads ne peuvent pas accéder en même temps à une donnée. Pour cela,
on introduit des données appelés mutex, de type pthread_mutex_t.
Un thread peut verrouiller un mutex, avec la fonction pthread_mutex_lock(), pour pouvoir accéder à
une donnée globale ou à un flot (par exemple pour écrire sur la sortie stdout). Une fois l’accès
terminé, le thread déverrouille le mutex, avec la fonction pthread_mutex_unlock().
Si un thread A essaie de verrouiller le mutex alors qu’il est déjà verrouillé par un autre thread B, le
thread A reste bloqué sur l’appel de pthread_mutex_lock() jusqu’à ce que le thread B déverrouille le
mutex. Une fois le mutex déverrouillé par B, le thread A verrouille immédiatement le mutex et son
exécution se poursuit. Cela permet au thread B d’accéder tranquillement à des variables globales
pendant que le thread A attend pour accéder aux mêmes variables.
134
Mutex
Pour déclarer et initialiser un mutex, on le déclare en variable globale (pour qu’il soit accessible à tous
les threads) :
pthread_mutex_t mon_mutex = PTHREAD_MUTEX_INITIALIZER;
La fonction pthread_mutex_lock(), qui permet de verrouiller un mutex, a pour prototype :
int pthread_mutex_lock(pthread_mutex_t *mutex);
La fonction pthread_mutex_unlock(), qui permet de déverrouiller un mutex, a pour prototype :
int pthread_mutex_unlock(pthread_mutex_t *mutex);
Il faut éviter de verrouiller deux fois un même mutex dans le même thread sans le déverrouiller entre
temps. Il y a un risque de blocage définitif du thread. Certaines versions du système gèrent ce
problème mais leur comportement n’est pas portable. 135
Mutex (Exemple)
136
Sémaphores
137
Section critique
En général, une section critique est une partie du code où un processus ou un thread ne
peut rentrer qu’à une certaine condition. Lorsque le processus (ou un thread) entre dans la
section critique, il modifie la condition pour les autres processus/threads.
Par exemple, si une section du code ne doit pas être exécutée simultanément par plus de n
threads. Avant de rentrer dans la section critique, un thread doit vérifier qu’au plus n-1
threads y sont déjà. Lorsqu’un thread entre dans la section critique, il modifie la condition
sur le nombre de threads qui se trouvent dans la section critique. Ainsi, un autre thread
peut se trouver empêché d’entrer dans la section critique.
138
Section critique
La difficulté est qu’on ne peut pas utiliser une simple variable comme compteur. En effet, si
le test sur le nombre de thread et la modification du nombre de threads lors de l’entrée
dans la section critique se font séquentiellement par deux instructions, si l’on joue de
malchance un autre thread pourrait tester le condition sur le nombre de threads justement
entre l’exécution de ces deux instructions, et deux threads passeraient en même temps
dans la section critiques.
Il y a donc nécessité de tester et modifier la condition de manière atomique, c’est-à-dire
qu’aucun autre processus/thread ne peut rien exécuter entre le test et la modification.
C’est une opération atomique appelée Test and Set Lock.
139
Sémaphore
142
Initialisation et destruction d’un sémaphore
144
Exercices sur les
Sémaphores
145
Le problème du rendez-vous
146
Le problème de l’émetteur et du récepteur
Des processus producteurs produisent des objets et les insère un par un dans un tampon
de n places. Bien entendu des processus consommateurs retirent, de temps en temps les
objets (un par un). Résolvez le problème pour qu’aucun objet ne soit ni perdu ni consommé
plusieurs fois.
Écrire une programme avec N threads producteurs et M threads consommateurs, les
nombres N et M étant saisis au clavier. Les producteurs et les consommateurs attendent
un temps aléatoire entre 1 et 3 secondes entre deux produits. Les produits sont des octets
que l’on stocke dans un tableau de 10 octets avec gestion LIFO. S’il n’y a plus de place, les
producteurs restent bloqués en attendant que des places se libèrent.
148
THANKS!
Any questions?
You can find me at
90 50 94 17
avenakaj@gmail.com 149