Processus
Unix est un système qui associe à chaque exécution d'un
programme un processus. Dans [CDM96] Card, Dumas et Mével
résument ainsi la différence entre programme et processus : << un
programme en lui-même n'est pas un processus : un programme est une
entité passive (un fichier exécutable résidant sur un disque),
alors qu'un processus est une entité active avec un compteur ordinal
spécifiant l'instruction suivante à exécuter et un ensemble de
ressources associées. >>
Unix est un système dit multi-tâches : plusieurs processus
peuvent être exécutés simultanément. Il est préemptif,
l'exécution des processus est confiée à un processus particulier
chargé de leur ordonnancement. Un processus n'est donc pas
entièrement maître de ses ressources. Au premier chef, un processus
n'est pas maître du moment de son exécution et ce n'est pas parce
qu'il est créé qu'un processus est exécuté sur le champ.
Chaque processus dispose de son propre espace mémoire. Les processus
peuvent communiquer à travers des fichiers ou des tubes de
communication. Nous sommes en présence du modèle de parallélisme à
mémoire distribuée simulé sur une seule machine.
Le système attribue aux processus un identificateur unique : un
entier appelé PID (Process IDentifier).
De plus, sous Unix, à l'exception notable du processus initial, tout
processus est engendré par un autre processus que l'on appelle son
père.
On peut connaître l'ensemble des processus actifs par la
commande Unix ps3 :
$ ps -f
PID PPID CMD
1767 1763 csh
2797 1767 ps -f
L'emploi de l'option -f fait apparaître, pour chaque
processus actif, son identificateur (PID), celui de son père
(PPID) et le programme invoqué (CMD). Ici, nous avons
deux processus, l'interprète de commandes csh et la commande ps
elle-même. On note que ps étant invoquée depuis
l'interprète de commandes csh, le père de son processus est
le processus associé à l'exécution de csh.
Exécution d'un programme
Environnement d'exécution
Trois valeurs sont associées à un programme exécuté depuis un
interprète de commandes du système d'exploitation :
-
la ligne de commande ayant servi à son exécution qui est
contenue dans la valeur Sys.argv,
- les variables d'environnement de l'interprète de commandes que
l'on peut récupérer grâce à la fonction Sys.getenv,
- un statut d'exécution lorsque le programme termine.
Ligne de commande
La ligne de commande permet de récupérer les arguments ou options
d'appel d'un programme. Celui-ci peut alors déterminer son
comportement en fonction de ces valeurs. En voici un petit exemple.
On écrit le petit programme suivant dans le fichier argv_ex.ml :
if
Array.length
Sys.argv
=
1
then
Printf.printf
"Hello world\n"
else
if
Array.length
Sys.argv
=
2
then
Printf.printf
"Hello %s\n"
Sys.argv.
(1
)
else
Printf.printf
"%s : trop d'arguments\n"
Sys.argv.
(0
)
On le compile :
$ ocamlc -o argv_ex argv_ex.ml
Et on exécute successivement :
$ argv_ex
Hello world
$ argv_ex lecteur
Hello lecteur
$ argv_ex cher lecteur
./argv_ex : trop d'arguments
Variables d'environnement
Les variables d'environnement contiennent différentes valeurs
nécessaires à la bonne marche du système ou de certaines
applications. Le nombre et le nom de ces variables dépendent à la
fois du système d'exploitation et de configurations propres aux
utilisateurs. Le contenu de ces variables est accessible par la
fonction getenv qui prend en argument le nom d'une
variable d'environnement sous forme d'une chaîne de caractères :
#
Sys.getenv
"HOSTNAME"
;;
-
:
string
=
"zinc.pps.jussieu.fr"
Statut d'exécution
La valeur de retour d'un programme est un entier fixé, en
général, automatiquement par le système suivant que le programme
se termine en erreur ou non. Le développeur peut toujours mettre fin
explicitement à son programme en précisant la valeur du statut
d'exécution par appel à la fonction :
# Pervasives.exit
;;
- : int -> 'a = <fun>
Lancement de processus
Un programme est lancé à partir d'un processus que l'on appelle le
processus courant. L'exécution du programme devient un nouveau processus.
On obtient trois cas de figure :
-
les deux processus sont indépendants et peuvent s'exécuter concurremment;
- le processus père est en attente sur la fin d'exécution
du processus fils;
- le processus lancé remplace le processus père qui disparaît.
On peut aussi dupliquer le processus courant et obtenir ainsi deux instances
du même processus qui ne diffèrent que par leur PID. C'est le fameux
fork que nous décrivons dans la suite.
Processus indépendants
Le module Unix offre une fonction portable de lancement d'un processus
correspondant à l'exécution d'un programme.
# Unix.create_process
;;
- : string ->
string array ->
Unix.file_descr -> Unix.file_descr -> Unix.file_descr -> int
= <fun>
Le premier argument est le nom du programme (qui peut être un
chemin), le deuxième, le tableau d'arguments du programme, les trois
derniers sont les descripteurs devant servir à l'entrée standard, à
la sortie standard et à la sortie en erreur du processus. La valeur de
retour est le numéro du processus créé.
Il existe une variante de cette fonction permettant de
préciser la valeur de variables d'environnement :
# Unix.create_process_env
;;
- : string ->
string array ->
string array ->
Unix.file_descr -> Unix.file_descr -> Unix.file_descr -> int
= <fun>
Ces deux fonctions sont utilisables sous Unix ou Windows.
Empilement de processus
Il n'est pas toujours utile que le processus lancé le soit de manière
concurrente. En effet le processus père peut avoir besoin d'attendre la fin
du processus qu'il vient de lancer pour poursuivre sa tâche. Les deux
fonctions suivantes prennent comme argument le nom d'une commande et
l'exécutent.
# Sys.command;;
- : string -> int = <fun>
# Unix.system;;
- : string -> Unix.process_status = <fun>
Elles diffèrent par le type du code retour. Le type process_status
est détaillé à la page ??.
Pendant l'exécution de la commande le processus père est bloqué.
Remplacement de processus courant
Le remplacement du processus courant par la commande qu'il vient de
lancer permet de limiter le nombre de processus en cours d'exécution.
Les quatre fonctions suivantes effectuent ce travail :
# Unix.execv
;;
- : string -> string array -> unit = <fun>
# Unix.execve
;;
- : string -> string array -> string array -> unit = <fun>
# Unix.execvp
;;
- : string -> string array -> unit = <fun>
# Unix.execvpe
;;
- : string -> string array -> string array -> unit = <fun>
Leur premier argument est le nom du programme. En utilisant
execvp ou execvpe, ce nom peut indiquer un chemin
dans l'arborescence des fichiers. Le second argument contient les
arguments du programme qu'il est possible de passer sur la ligne de
commande. Le dernier argument des fonctions execve et
execvpe permet en plus d'indiquer la valeur des variables
système utiles au programme.
Création d'un processus par duplication
L'appel système originel de création de processus sous Unix est :
# Unix.fork
;;
- : unit -> int = <fun>
La fonction fork engendre un nouveau processus et non un
nouveau programme. Son effet exact est de dupliquer le processus
appelant. Le code du nouveau processus est le même que celui de son
père. Sous Unix un même code peut servir à plusieurs processus,
chacun possédant son propre contexte d'exécution. On parle alors de
code réentrant.
Voyons cela sur le petit programme suivant (on utilise la fonction
getpid qui retourne le PID du processus associé à l'exécution
du programme) :
Printf.printf
"avant fork : %d\n"
(Unix.getpid
())
;;
flush
stdout
;;
Unix.fork
()
;;
Printf.printf
"après fork : %d\n"
(Unix.getpid
())
;;
flush
stdout
;;
On obtient l'affichage suivant :
avant fork : 1447
après fork : 1447
après fork : 1448
Après l'exécution du fork, deux processus exécutent la
suite du code. C'est ce qui provoque deux fois l'affichage du PID
<< après >>. On remarque qu'un des processus a gardé le PID de
départ (le père) alors que l'autre en a un nouveau (le fils) qui
correspond à la valeur de retour de l'appel à fork.
Pour le processus père la valeur de retour
de fork est le PID du fils alors que pour le fils, elle vaut
0.
C'est cette différence de valeur de retour de fork qui
permet, dans un même programme source, de différencier le
code exécuté par le fils du code exécuté par le père :
Printf.printf
"avant fork : %d\n"
(Unix.getpid
())
;;
flush
stdout
;;
let
pid
=
Unix.fork
()
;;
if
pid=
0
then
(* -- Code du fils *)
Printf.printf
"je suis le fils : %d\n"
(Unix.getpid
())
else
(* -- Code du pere *)
Printf.printf
"je suis le père : %d du fils : %d\n"
(Unix.getpid
())
pid
;;
flush
stdout
;;
Voici la trace de l'exécution de ce programme :
avant fork : 1456
je suis le père : 1456 du fils : 1457
je suis le fils : 1457
On peut aussi utiliser la valeur de retour dans un filtrage :
match
Unix.fork
()
with
0
->
Printf.printf
"je suis le fils : %d\n"
(Unix.getpid
())
|
pid
->
Printf.printf
"je suis le père : %d du fils : %d\n"
(Unix.getpid
())
pid
;;
La fécondité d'un processus peut être très grande. Elle est
cependant limitée à un nombre fini de descendants par la
configuration du système d'exploitation. L'exemple suivant crée
deux générations de processus avec grand
père, pères, fils, oncles et cousins.
let
pid0
=
Unix.getpid
();;
let
print_generation1
pid
ppid
=
Printf.printf
"Je suis %d, fils de %d\n"
pid
ppid;
flush
stdout
;;
let
print_generation2
pid
ppid
pppid
=
Printf.printf
"Je suis %d, fils de %d, petit fils de %d\n"
pid
ppid
pppid;
flush
stdout
;;
match
Unix.fork()
with
0
->
let
pid01
=
Unix.getpid
()
in
(
match
Unix.fork()
with
0
->
print_generation2
(Unix.getpid
())
pid01
pid0
|
_
->
print_generation1
pid01
pid0)
|
_
->
match
Unix.fork
()
with
0
->
(
let
pid02
=
Unix.getpid
()
in
match
Unix.fork()
with
0
->
print_generation2
(Unix.getpid
())
pid02
pid0
|
_
->
print_generation1
pid02
pid0
)
|
_
->
Printf.printf
"Je suis %d, père et grand père\n"
pid0
;;
On obtient :
Je suis 1548, père et grand père
Je suis 1549, fils de 1548
Je suis 1550, fils de 1548
Je suis 1552, fils de 1549, petit fils de 1548
Je suis 1553, fils de 1550, petit fils de 1548
Ordre et moment d'exécution
En enchaînant sans précaution la création de processus, on peut
obtenir des effets poétiques à la M. Jourdain :
match
Unix.fork
()
with
0
->
Printf.printf
"Marquise "
;
flush
stdout
|
_
->
match
Unix.fork
()
with
0
->
Printf.printf
"vos beaux yeux me font "
;
flush
stdout
|
_
->
Printf.printf"mourir d'amour\n"
;
flush
stdout
;;
Ce qui peut donner le résultat suivant :
mourir d'amour
Marquise vos beaux yeux me font
Pour obtenir un M. Jourdain prosateur, il faut que notre programme
soit capable de s'assurer lui-même de l'ordre d'exécution des
processus le composant. De façon plus générale, lorsqu'une application
met en oeuvre plusieurs processus elle doit, quand besoin est,
s'assurer de leur synchronisation. Selon le modèle de parallélisme
utilisé, cette synchronisation est réalisée par la communication entre
processus ou par des attentes sur condition. Cette problématique est
plus amplement présentée dans les deux prochains chapitres. En
attendant, on peut rendre M. Jourdain prosateur de deux façons :
-
laisser du temps au fils pour écrire son bout de phrase avant
d'écrire le sien propre.
- attendre la mort du fils qui aura écrit son bout de phrase
avant d'écrire son propre bout de phrase.
Délai d'attente
Un processus peut suspendre son activité en appelant la fonction :
# Unix.sleep
;;
- : int -> unit = <fun>
L'argument fournit le nombre de secondes de suspension du processus
appelant avant la reprise d'activité.
En utilisant cette fonction,
on écrira :
match
Unix.fork
()
with
0
->
Printf.printf
"Marquise "
;
flush
stdout
|
_
->
Unix.sleep
1
;
match
Unix.fork
()
with
0
->
Printf.printf"vos beaux yeux me font "
;
flush
stdout
|
_
->
Unix.sleep
1
;
Printf.printf"mourir d'amour\n"
;
flush
stdout
;;
Et on pourra obtenir :
Marquise vos beaux yeux me font mourir d'amour
Néanmoins, cette méthode n'est pas sûre. A priori, rien
n'empêche le système d'allouer suffisamment de temps à l'un des
processus pour qu'il puisse à la fois réaliser son temps de
sommeil et son affichage. Nous préférerons donc dans notre cas la
méthode ci-dessous qui séquentialise les processus.
Attente de terminaison du fils
Un processus père a la possibilité d'attendre la mort de son
fils par appel à la fonction :
# Unix.wait
;;
- : unit -> int * Unix.process_status = <fun>
L'exécution du père est suspendue jusqu'à terminaison de l'un de ses
fils. Si un wait est exécuté par un processus n'ayant plus de
fils, alors l'exception Unix_error est déclenchée.
Nous reviendrons
ultérieurement sur la valeur de retour de wait. Ignorons la
pour l'instant et faisons dire de la prose à M. Jourdain :
match
Unix.fork
()
with
0
->
Printf.printf
"Marquise "
;
flush
stdout
|
_
->
ignore
(Unix.wait
())
;
match
Unix.fork
()
with
0
->
Printf.printf
"vos beaux yeux me font "
;
flush
stdout
|
_
->
ignore
(Unix.wait
())
;
Printf.printf
"mourir d'amour\n"
;
flush
stdout
Et, de fait, il dit :
Marquise vos beaux yeux me font mourir d'amour
Warning
fork est propre au système Unix
Filiation, mort et funérailles d'un processus
La fonction wait n'a pas pour seule utilité l'attente
de terminaison du fils utilisée ci-dessus. Elle est également chargée de
consacrer la mort du processus fils.
Lorsqu'un processus est créé, le système ajoute une entrée dans la
table qui lui sert à gérer l'ensemble des processus. Lorsqu'il meurt,
un processus ne disparaît pas automatiquement de cette table. C'est au
père, par appel à wait, d'assurer la suppression de ses fils
de la table des processus. S'il ne le fait pas, le processus fils
subsiste dans la table des processus. On parle alors de processus
zombie.
Au démarrage du système, est lancé un premier processus appelé
init. Après initialisation d'un certain nombre de paramètres
du système, un rôle essentiel de ce << grand ancêtre >> est de
prendre en charge les processus orphelins et d'exécuter le
wait qui les rayera de la table des processus à leur
terminaison.
Attente de fin d'un processus donné
Il existe une variante de la fonction wait, nommée waitpid
et portée sous Windows :
# Unix.waitpid
;;
- : Unix.wait_flag list -> int -> int * Unix.process_status = <fun>
Le premier argument spécifie les modalités d'attente et le
second, quel processus ou quel groupe de processus il faut
traiter.
À la mort d'un processus, deux informations sont accessibles au père
comme résultat de l'appel à wait ou waitpid : le
numéro du processus terminé et son statut de terminaison. Ce
dernier est représenté par une valeur de type
Unix.process_status.
Ce type a trois constructeurs dont
chacun a un argument entier.
-
WEXITED n : le processus s'est terminé normalement
avec le code de retour n.
- WSIGNALED n : le processus a été tué par le
signal n.
- WSTOPPED n : le processus a été stoppé par le
signal n.
La dernière valeur n'a de sens que pour la fonction waitpid
qui peut, par son premier argument, se mettre à l'écoute de tels
signaux. Nous détaillons les signaux et leur traitement page
??.
Gestion de l'attente par un ancêtre
Pour éviter de gérer soi-même la terminaison d'un processus fils,
il est possible de faire traiter cette attente par un processus ancêtre.
C'est l'astuce du << double fork >> qui permet à un processus de
n'avoir pas à se soucier des funérailles de ses descendants en les
confiant au processus init. En voici le principe : un processus
P0 crée un processus P1 qui à son tour crée un troisième
processus P2 puis termine. Ainsi, P2 se retrouve
orphelin et est adopté par init qui se chargera d'attendre sa
terminaison. Le processus initial P0 peut alors exécuter un
wait sur P1 qui sera de courte durée. L'idée est de
confier au petit fils le travail que l'on aurait confié au fils.
Le schéma de mise en oeuvre est le suivant :
# match
Unix.fork()
with
(* P0 crée P1 *)
0
->
if
Unix.fork()
=
0
then
exit
0
;
(* P1 crée P2 et termine *)
Printf.printf
"P2 fait son travail\n"
;
exit
0
|
pid
->
ignore
(Unix.waitpid
[]
pid)
;
(* P0 attend la mort de P1 *)
Printf.printf
"P0 peut faire autre chose sans attendre\n"
;;
P2 fait son travail
P0 peut faire autre chose sans attendre
- : unit = ()
Nous verrons une utilisation de ce principe
pour traiter les requêtes envoyées à un serveur au chapitre 20.