Précédent Index Suivant

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 250 in
let n = input oc_in r 0 250
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 : À 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 :
  1. On modifie le comportement associé au signal sigalrm de façon à déclencher l'exception Timeout.
  2. On a pris soin, au passage, de mémoriser le comportement original associé à sigalrm pour définir une fonction capable de le restaurer.
  3. On déclenche l'horloge.
  4. On lance le calcul :
    1. Si tout s'est bien passé, on remet sigalrm dans son état d'origine et on renvoie la valeur du calcul.
    2. 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 10 1 (-1));
Printf.printf "2ème exécution : %d\n" (timeout itere 100000000 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 :
  1. 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.
  2. 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







Précédent Index Suivant