Communication entre processus
L'utilisation de processus dans le développement d'une application
permet la délégation des tâches. Néanmoins, comme nous l'avons déjà
évoqué, ces tâches peuvent ne pas être indépendantes et il est dès
lors nécessaire que les processus sachent communiquer entre eux.
Nous abordons dans ce paragraphe deux modes de communication entre
processus : les tubes de communication (pipes)
et les signaux. Ce chapitre ne fait pas un tour complet
des possibilités de communication entre processus. Il n'est qu'une
première approche des applications développées aux chapitres
19 et 20.
Tubes de communication
À la manière des fichiers, il est possible de communiquer
directement entre processus à travers des tubes de communication.
Les tubes sont en quelque sorte des fichiers virtuels dans lesquels
on peut lire et écrire au moyen des fonctions d'entrées-sorties
read et write. Cependant ils sont de taille
limitée (cette limite dépendant des systèmes) et leur
discipline de remplissage et de vidage est celle des files d'attente :
le premier entré est le premier sorti. Et quand nous disons
<< sorti >>, il faut prendre l'expression au pied de la lettre : la
lecture de données dans un tube les supprime de celui-ci.
Cette discipline de file d'attente est réalisée en associant deux
descripteurs à un tube : l'un correspond à l'extrémité du tube
dans laquelle on écrit ; l'autre, l'extrémité dans laquelle on lit.
Un tube est créé par la fonction :
# Unix.pipe
;;
- : unit -> Unix.file_descr * Unix.file_descr = <fun>
La première composante du couple obtenu en résultat est la sortie du
tube utilisée en lecture et la seconde, l'entrée du tube utilisée en
écriture. Tout processus en ayant connaissance peut fermer ces
descripteurs.
La lecture dans un tube est bloquante sauf si tous les processus
connaissant son descripteur d'entrée (et donc, susceptible d'y
écrire) l'ont fermé. Dans ce dernier cas, la fonction read
renvoie 0. Si un processus tente d'écrire dans un tube plein, il est
suspendu jusqu'à ce qu'un autre processus ait effectué une
lecture. Si un processus tente d'écrire dans un tube alors que plus
aucun autre processus n'est susceptible d'y lire (tous ont fermé le
descripteur de sortie) alors le processus tentant d'écrire reçoit
le signal sigpipe qui, sauf mention contraire, provoque sa
terminaison.
L'exemple suivant montre l'utilisation des tubes dans lesquels des
petits fils communiquent leur numéro à leur grand père.
let
sortie,
entree
=
Unix.pipe();;
let
write_pid
entree
=
try
let
m
=
"("
^
(string_of_int
(Unix.getpid
()))
^
")"
in
ignore
(Unix.write
entree
m
0
(String.length
m))
;
Unix.close
entree
with
Unix.
Unix_error(n,
f,
arg)
->
Printf.printf
"%s(%s) : %s\n"
f
arg
(Unix.error_message
n)
;;
match
Unix.fork
()
with
0
->
for
i=
0
to
5
do
match
Unix.fork()
with
0
->
write_pid
entree
;
exit
0
|
_
->
()
done
;
Unix.close
entree
|
_
->
Unix.close
entree;
let
s
=
ref
""
and
buff
=
String.create
5
in
while
true
do
match
Unix.read
sortie
buff
0
5
with
0
->
Printf.printf
"Mes petits fils sont %s\n"
!
s
;
exit
0
|
n
->
s
:=
!
s
^
(String.sub
buff
0
n)
^
"."
done
;;
On obtient la trace :
Mes petits fils sont (1575.).(1576.).(1577.).(1578.).(1579.).(1580.).
Nous avons introduit des points entre chaque partie de chaîne
lue. On peut ainsi lire sur la trace la succession des contenus du
tube. On remarquera que la lecture peut ainsi être
désynchronisée : dès qu'une entrée, fût-elle partielle, est
produite, elle est consommée.
Les tubes nommés
Certains Unix acceptent de nommer les tubes comme s'il s'agissait de
fichiers normaux. Il est alors possible de communiquer entre deux
processus sans lien de parenté en utilisant le nom du tube. La
fonction suivante permet de créer le tube.
# Unix.mkfifo
;;
- : string -> Unix.file_perm -> unit = <fun>
Les descripteurs de fichier pour l'utiliser sont obtenus par
openfile comme s'il s'agissait de fichiers, mais leur
comportement est celui des pipes. En particulier, puisqu'il
s'agit de files d'attente, on ne peut appliquer la fonction
lseek à un tube.
Warning
mkfifo n'est pas implantée pour Windows.
Canaux de communication
Le module Unix fournit une fonction de haut niveau
permettant le lancement d'un programme en lui associant des canaux
d'entrée ou de sortie avec le programme appelant :
# Unix.open_process
;;
- : string -> in_channel * out_channel = <fun>
L'argument est le nom du programme ou, plus précisément, la ligne
d'appel du programme telle qu'on la taperait pour l'interprète de
commande. Elle pourra donc éventuellement contenir les arguments du
programme à lancer. Les deux valeurs de sortie sont les descripteurs
de fichier associés aux entrées-sorties standard du programme
ainsi lancé qui est exécuté en parallèle du programme
appelant.
Warning
Le programme lancé par open_process est exécuté par
appel à l'interprète de commandes Unix /bin/sh.
L'utilisation de cette fonction n'est possible que sur les systèmes
connaissant cet interprète de commandes.
On peut mettre fin à l'exécution d'un programme lancé par
open_process en utilisant :
# Unix.close_process
;;
- : in_channel * out_channel -> Unix.process_status = <fun>
L'argument est le couple des canaux associés à un processus
que l'on veut fermer. La valeur de retour est le statut d'exécution
du processus dont on a attendu la terminaison.
Il existe des variantes de ces deux fonctions n'ouvrant et ne fermant
qu'un canal d'entrée ou un canal de sortie :
# Unix.open_process_in
;;
- : string -> in_channel = <fun>
# Unix.close_process_in
;;
- : in_channel -> Unix.process_status = <fun>
# Unix.open_process_out
;;
- : string -> out_channel = <fun>
# Unix.close_process_out
;;
- : out_channel -> Unix.process_status = <fun>
Voici un petit exemple amusant d'utilisation de
open_process : on lance ocaml dans ocaml !
# let
n_print_string
s
=
print_string
s
;
print_string
"(* <-- *)"
;;
val n_print_string : string -> unit = <fun>
# let
p
()
=
let
oc_in,
oc_out
=
Unix.open_process
"/usr/local/bin/ocaml"
in
n_print_string
(input_line
oc_in)
;
print_newline()
;
n_print_string
(input_line
oc_in)
;
print_newline()
;
print_char
(input_char
oc_in)
;
print_char
(input_char
oc_in)
;
flush
stdout
;
let
s
=
input_line
stdin
in
output_string
oc_out
s
;
output_string
oc_out
"#quit\059\059\n"
;
flush
oc_out
;
let
r
=
String.create
2
5
0
in
let
n
=
input
oc_in
r
0
2
5
0
in
n_print_string
(String.sub
r
0
n)
;
print_string
"Merci de votre visite\n"
;
flush
stdout
;
Unix.close_process
(oc_in,
oc_out)
;;
val p : unit -> Unix.process_status = <fun>
L'appel de la fonction p lance un toplevel
d'Objective CAML. On remarquera que c'est la version 2.03 qui se trouve
dans le catalogue /usr/local/bin. Les quatre premières
opérations de lecture permettent de récupérer l'en-tête qu'affiche le
toplevel. La ligne let x = 1.2 +. 5.6;; est lue au
clavier, puis envoyée sur oc_out (le canal de sortie lié à
l'entrée standard du nouveau processus). Celui-ci type et évalue la
phrase Objective CAML passée, et écrit le résultat dans sa sortie standard
qui est liée au canal d'entrée oc_in. Ce résultat est alors lu par
la fonction input et affiché, ainsi que la chaîne
"Merci de votre visite"
. On envoie d'autre part la
directive #quit;; pour sortir du nouveau processus.
# p();;
Objective Caml version 2.03
# let x = 1.2 +. 5.6;;
val x : float = 6.8
Merci de votre visite
- : Unix.process_status = Unix.WSIGNALED 13
#
Signaux sous Unix
Une des possibilités pour communiquer avec un processus est de lui
envoyer un signal. Un signal peut être reçu à n'importe quel
moment de l'exécution du programme. La réception d'un signal provoque
une interruption logicielle. L'exécution d'un programme est interrompue
pour traiter le signal reçu, puis reprend à l'endroit de son interruption.
Les signaux sont en nombre fini et relativement restreint (32 avec
Linux). L'information véhiculée par un signal est rudimentaire :
elle se borne à l'identité (le numéro) du signal. Les processus ont
tous une réaction prédéfinie aux signaux. Néanmoins, celle-ci peut
être redéfinie par le programmeur pour la plupart des signaux.
Les données et fonctions de traitement des signaux sont réparties
entre les modules Sys et Unix. Le module
Sys contient la déclaration d'un certain nombre de signaux
répondant à la norme POSIX (décrits dans [Rif90]) ainsi
que des fonctions de traitement des signaux. Le module Unix
définit la fonction kill d'émission d'un
signal. L'utilisation des signaux sous Windows est restreinte au
seul sigint.
Un signal peut avoir de multiples sources : le clavier ; une tentative
erronée d'accès mémoire, etc. Un processus peut émettre
un signal à destination d'un autre processus en ayant recours à la
fonction :
# Unix.kill
;;
- : int -> int -> unit = <fun>
Son premier paramètre est le PID du processus destinataire et le
second est le signal qu'on veut lui envoyer.
Traitement des signaux
La réaction associée à un signal peut être de trois
ordres. À chacun d'eux correspond un constructeur du type
signal_behavior :
-
Signal_default : le comportement par défaut défini
par le système. Dans la plupart des cas c'est la terminaison du
processus avec création ou non d'un fichier d'état du processus
(fichier core).
- Signal_ignore : le signal est ignoré.
- Signal_handle : le comportement est redéfini par une
fonction Objective CAML de type int -> unit que l'on passe en
argument au constructeur. Lors du traitement du signal ainsi
modifié, le numéro de signal est passé à la fonction de
traitement.
À la réception d'un signal, l'exécution du processus récepteur
est déroutée vers la fonction de traitement du signal. La
fonction permettant de redéfinir le comportement associé à un
signal est fournie par le module Sys :
# Sys.set_signal;;
- : int -> Sys.signal_behavior -> unit = <fun>
Le premier argument est le signal à redéfinir et le second, le
comportement assigné.
Le module Sys fournit une autre fonction de modification du
traitement des signaux :
# Sys.signal
;;
- : int -> Sys.signal_behavior -> Sys.signal_behavior = <fun>
Elle agit comme set_signal, sauf qu'en plus elle renvoie la
valeur associée au signal avant la modification. On peut ainsi
écrire une fonction renvoyant (sans la modifier apparemment) la valeur
comportementale associée à un signal :
# let
signal_behavior
s
=
let
b
=
Sys.signal
s
Sys.
Signal_default
in
Sys.set_signal
s
b
;
b
;;
val signal_behavior : int -> Sys.signal_behavior = <fun>
# signal_behavior
Sys.sigint;;
- : Sys.signal_behavior = Sys.Signal_handle <fun>
Certains signaux ne peuvent pas voir leur comportement modifié. Notre
fonction n'est donc pas utilisable pour n'importe quel signal :
# signal_behavior
Sys.sigkill
;;
Uncaught exception: Sys_error("Invalid argument")
Quelques signaux
Nous illustrons ci-dessous l'utilisation de quelques signaux
essentiels.
sigint
Ce signal est, en général, associé à la combinaison de touches
CTRL-C. Dans le petit exemple ci-dessous, nous modifions la
réaction à ce signal de façon à ce que le processus récepteur
ne s'interrompe qu'à la troisième occurrence du signal.
Créons le fichier ctrlc.ml
suivant :
let
sigint_handle
=
let
n
=
ref
0
in
function
_
->
incr
n
;
match
!
n
with
1
->
print_string
"Vous venez d'appuyer sur CTRL-C\n"
|
2
->
print_string
"Vous avez encore appuyé sur CTRL-C\n"
|
3
->
print_string
"Si vous insistez ...\n"
;
exit
1
|
_
->
()
;;
Sys.set_signal
Sys.sigint
(Sys.
Signal_handle
sigint_handle)
;;
match
Unix.fork
()
with
0
->
while
true
do
()
done
|
pid
->
Unix.sleep
1
;
Unix.kill
pid
Sys.sigint
;
Unix.sleep
1
;
Unix.kill
pid
Sys.sigint
;
Unix.sleep
1
;
Unix.kill
pid
Sys.sigint
;;
Ce programme simule l'appui de la combinaison de touches CTRL-C
par
l'envoi du signal sigint, on obtient la trace d'exécution
suivante :
$ ocamlc -i -o ctrlc ctrlc.ml
val sigint_handle : int -> unit
$ ctrlc
Vous venez d'appuyer sur CTRL-C
Vous avez encore appuyé sur CTRL-C
Si vous insistez ...
sigalrm
Un autre signal couramment utilisé est sigalrm qui est
associé à l'horloge de la machine. Il peut être émit par la
fonction :
# Unix.alarm
;;
- : int -> int = <fun>
L'argument spécifie le nombre de secondes d'attente avant
l'émission du signal sigalrm. La valeur de retour est le
nombre de secondes restant à courir avant l'émission d'un prochain signal, ou
si aucune alarme n'est en cours.
Nous allons utiliser cette fonction et le signal associé pour
définir la fonction timeout qui lance l'exécution d'une
autre fonction et l'interrompt, si besoin est, au bout d'un temps
donné. Plus précisément, la fonction timeout prendra en
argument une fonction f, l'argument arg attendu par
f, la durée (time) du << timeout >> et la
valeur (default_value) à rendre si cette dernière est
dépassée.
Dans timeout, les choses se passent ainsi :
-
On modifie le comportement associé au signal sigalrm
de façon à déclencher l'exception Timeout.
- On a pris soin, au passage, de mémoriser le comportement
original associé à sigalrm pour définir une
fonction capable de le restaurer.
- On déclenche l'horloge.
- On lance le calcul :
-
Si tout s'est bien passé, on remet sigalrm dans son
état d'origine et on renvoie la valeur du calcul.
- Sinon, on restaure sigalrm et si la durée est dépassée, on renvoie la valeur par défaut.
Voici les définitions correspondantes ainsi qu'un petit essai :
# exception
Timeout
;;
exception Timeout
# let
sigalrm_handler
=
Sys.
Signal_handle
(fun
_
->
raise
Timeout)
;;
val sigalrm_handler : Sys.signal_behavior = Sys.Signal_handle <fun>
# let
timeout
f
arg
time
default_value
=
let
old_behavior
=
Sys.signal
Sys.sigalrm
sigalrm_handler
in
let
reset_sigalrm
()
=
Sys.set_signal
Sys.sigalrm
old_behavior
in
ignore
(Unix.alarm
time)
;
try
let
res
=
f
arg
in
reset_sigalrm
()
;
res
with
exc
->
reset_sigalrm
()
;
if
exc=
Timeout
then
default_value
else
raise
exc
;;
val timeout : ('a -> 'b) -> 'a -> int -> 'b -> 'b = <fun>
# let
itere
n
=
for
i
=
1
to
n
do
()
done
;
n
;;
val itere : int -> int = <fun>
Printf.printf
"1ère exécution : %d\n"
(timeout
itere
1
0
1
(-
1
));
Printf.printf
"2ème exécution : %d\n"
(timeout
itere
1
0
0
0
0
0
0
0
0
1
(-
1
))
;;
1ère exécution : 10
2ème exécution : -1
- : unit = ()
sigusr1 et sigusr2
Ces deux signaux sont à la disposition du programmeur pour les
besoins de ses applications. Ils ne sont pas utilisés par le
système d'exploitation.
Dans cet exemple, la réception, par le fils, du signal
sigusr1 provoque l'affichage du contenu de la variable
i.
let
i
=
ref
0
;;
let
affiche_i
s
=
Printf.printf
"signal recu (%d) -- i=%d\n"
s
!
i
;
flush
stdout
;;
Sys.set_signal
Sys.sigusr1
(Sys.
Signal_handle
affiche_i)
;;
match
Unix.fork
()
with
0
->
while
true
do
incr
i
done
|
pid
->
Unix.sleep
0
;
Unix.kill
pid
Sys.sigusr1
;
Unix.sleep
3
;
Unix.kill
pid
Sys.sigusr1
;
Unix.sleep
1
;
Unix.kill
pid
Sys.sigkill
Voici la trace d'une exécution de ce programme :
signal recu (10) -- i=0
signal recu (10) -- i=47580467
En examinant cette trace, on voit qu'après avoir exécuté
une première fois le code associé au signal sigusr1, le
processus fils continue à exécuter la boucle et à incrémenter
i.
sigchld
Ce signal est émis vers son père à la terminaison d'un processus. Nous
allons l'utiliser pour rendre un père plus attentif au devenir de ses
enfants. Voici comment :
-
On définit une fonction de traitement du signal
sigchld qui traite tous les enfants morts à la réception
de ce
signal4 et
termine le processus père lorsque celui-ci n'a plus d'enfant
(exception Unix_error). Pour ne pas bloquer le père, si
tous ses enfants ne sont pas morts, on utilise waitpid
plutôt que wait.
- Le programme principal, après avoir redéfini la réaction
associée à sigchld, boucle pour créer cinq fils. Ceci
fait, le père fait autre chose (boucle while true)
jusqu'à la mort de ses fils.
let
rec
sigchld_handle
s
=
try
let
pid,
_
=
Unix.waitpid
[
Unix.
WNOHANG]
0
in
if
pid
<>
0
then
(
Printf.printf
"%d est mort et enterré au signal %d\n"
pid
s
;
flush
stdout
;
sigchld_handle
s
)
with
Unix.
Unix_error(_,
"waitpid"
,
_
)
->
exit
0
;;
let
i
=
ref
0
in
Sys.set_signal
Sys.sigchld
(Sys.
Signal_handle
sigchld_handle)
;
while
true
do
match
Unix.fork()
with
0
->
let
pid
=
Unix.getpid
()
in
Printf.printf
"Création de %d\n"
pid
;
flush
stdout
;
Unix.sleep
(Random.int
(5
+
!
i))
;
Printf.printf
"Terminaison de %d\n"
pid
;
flush
stdout
;
exit
0
|
_
->
incr
i
;
if
!
i
=
5
then
while
true
do
()
done
done
;;
On obtient la trace :
Création de 1561
Création de 1562
Création de 1563
Création de 1564
Création de 1565
Terminaison de 1565
1565 est mort et enterré au signal 17
Terminaison de 1561
1561 est mort et enterré au signal 17
Terminaison de 1562
1562 est mort et enterré au signal 17
Terminaison de 1563
1563 est mort et enterré au signal 17
Terminaison de 1564
1564 est mort et enterré au signal 17