Retrouvez cet article dans : Linux Magazine 86
L’article précédent a planté le décor : le principe consiste à présenter les principaux appels système Unix et à les mettre en œuvre en Ruby. Le but que nous poursuivons est donc double : apprendre ces appels système et découvrir la puissance et l’élégance de Ruby, qui ne se contente pas, loin de là, d’être le langage support de Ruby On Rails.
Le mois dernier, nous avons donc présenté les concepts préliminaires, la gestion de la ligne de commande, des fichiers, des répertoires, des verrous et des signaux. Nous allons maintenant nous pencher sur les processus Unix et sur les threads POSIX.
1. Les processus
1.1 Présentation
Un processus est une exécution d’un programme à un moment donné.
Au niveau du système, un processus est caractérisé par un PCB (Process Control Block). Sous Unix, le PCB s’appelle CORE (image).
Le PCB contient :
- le contexte mémoire ;
- les segments mémoire :
- le segment de code : contient le code du programme exécuté par le processus. Ce segment peut être partagé par plusieurs processus.
- le segment de données : c’est la zone des variables globales et statiques, ainsi que du tas, géré dynamiquement. Ce segment est privé au processus.
- le segment de pile : c’est la zone de transmission des paramètres, des retours de fonctions et des variables locales. Ce segment est privé au processus.
Il n’y a donc pas de partage des données entre les processus (sauf zones spéciales, comme la mémoire partagée).
- l’environnement : ensemble des variables et de leurs valeurs (
TERM=vt100, par exemple). Cet environnement est accessible à un programme C via la variable environ (voirenviron(7)). Les fonctions de la bibliothèque standard,getenv(3)etputenv(3)permettent, respectivement, d’obtenir la valeur d’une variable d’environnement et de la positionner. En Ruby, l’environnement d’un processus est stocké dans la " constante " globaleENV(qui se comporte comme un hachage dont les clés seraient les noms des variables) :
ENV.each { |cle, val| puts("#{cle} : #{val}")}
puts(ENV[‘PATH’])
ENV[‘PATH’].split(":").each { |rep| puts(rep) }
ENV[‘MA_VAR’] = ‘Ok’
- La priorité du processus pour l’attribution de l’UC.
- Les descripteurs des fichiers utilisés (le nombre maximum dépend du système). Il y en a toujours au moins 3, qui sont ouverts automatiquement à la création du processus :
- 0 :
STDIN(par défaut : le clavier) ; - 1 :
STDOUT(par défaut : l’écran) ; - 2 :
STDERR(par défaut : l’écran).
- 0 :
En Ruby, ces trois descripteurs sont décrits par les constantes de même nom.
1.2 PID, PPID, groupes de processus et numéros de session
Au niveau du système, tout processus est désigné par un entier positif, le PID (le premier processus, init a donc 1 pour PID). L’API système fournit les appels getpid(2) et getppid(2) pour obtenir le PID d’un processus et celui de son père.
En Ruby, le module Process fournit les méthodes pid et ppid qui ont, respectivement, le même effet. Voir également les numéros de session et les groupes de processus, la sortie de la commande ps -jax, les appels getpgid(2), getpgrp(2), setpgid(2), setpgrp(2), getsid(2), setsid(2) et les méthodes de même nom dans le module Process de Ruby.
1.3 Utilisateurs et groupes réels et effectifs
L’utilisateur et le groupe réels d’un processus sont l’utilisateur et le groupe qui l’ont appelé. Cette identification permet de gérer la comptabilité du système (pour savoir qui utilise quelles ressources et, éventuellement, facturer).
L’utilisateur et le groupe effectifs d’un processus sont ceux sous lesquels s’exécute un processus (ce sont eux deux qui fixent les droits du processus). L’utilisateur réel jaco, par exemple, peut créer un processus pour l’exécution de la commande passwd, mais l’utilisateur effectif sera root :
% ps a -ouser,ruser,command|grep [p]asswd root jaco passwd
Cette distinction permet à l’utilisateur lambda d’effectuer des commandes qui nécessitent d’avoir l’identité d’un autre utilisateur (root, par exemple).
Au moment de l’exécution de la commande, l’UID de l’utilisateur est remplacé par celui du propriétaire du fichier (EUID) afin d’en avoir ses droits (idem pour le groupe). Pour qu’un fichier permette ce type de substitution, son propriétaire doit avoir positionné le bit setuid et/ou setgid. On conçoit que les programmes " setuid root " doivent être particulièrement fiables...
Les appels système permettant de manipuler ces identificateurs sont getuid(2), geteuid(2), getgid(2), getegid(2), setuid(2), seteuid(2), setreuid(2), setgid(2), setregid(2), setegid(2). En Ruby, voir les méthodes de même nom dans le module Process::Sys.
1.4 Création et suppression de processus
La création des processus est dynamique.
1.4.1 Création
Un processus est créé par un appel à fork(2).
Le processus fils est créé à l’image de son père :
- Chacun a son propre segment de données et son propre segment de pile, qui contiennent la même chose au départ.
- Chacun a son propre environnement, qui contient la même chose au départ.
- Chacun a ses propres descripteurs de fichiers : ce sont les mêmes au départ (le processus fils doit donc fermer les descripteurs qui ne le concernent pas).
- Le père et le fils ont le même code dans leurs segments de code : ils exécutent donc le même programme (et le fils exécute donc également l’appel
fork(2)...)
L’appel fork(2) renvoie :
- -1 en cas d’erreur (consulter alors
errno) ; - 0 chez le fils ;
- > 0 chez le père (en fait, le PID du processus fils créé).
On notera que l’ordre de retour de l’appel à fork(2) n’est pas défini : il peut s’agir du fils ou du père, selon la gestion et la charge du système.
En C, l’utilisation classique de fork(2) est donc :
switch ( pid = fork() ) {
case -1 : /* traiter l’erreur */
...
break;
case 0 : /* traitement à exécuter par le fils */
...
break;
default : /* traitement à exécuter par le père */
...
}
En Ruby, le module Kernel fournit la méthode fork (ri Process.fork) qui, comme File.open, peut être appelée avec ou sans bloc associé. Kernel étant inclus dans la classe Object, ses méthodes sont disponibles dans toutes les classes et peuvent être appelées sans objet récepteur (c’est-à-dire comme des fonctions classiques) :
- Sans bloc associé,
forkcrée un processus fils et renvoie son PID au processus père ounilau processus fils (commefork(2)). En cas d’échec, cette méthode lèvera une exception. - Avec un bloc, l’appel renverra le pid du processus fils, qui exécutera les instructions de ce bloc, pendant que le père exécutera celles qui suivent le bloc. Le fils se terminera automatiquement à la fin du bloc en renvoyant 0 (succès).
Exemple (imparfait) en C :
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main(void) {
int pid;
switch(pid = fork()) {
case -1: /* Pb... */
perror(“Erreur du fork”);
exit(1);
case 0: /* On est chez le fils */
printf(“Je suis le fils, mon PID est %d,”
“ le PID de mon père est %d\n",
getpid(), getppid());
break;
default: /* On est chez le père */
printf("Je suis le père, mon PID est %d\n",
getpid());
}
exit(0);
}
La même chose en Ruby, sans bloc :
if (fork == nil)
puts(“Je suis le fils : #{Process.pid}, mon pere est #{Process.ppid}”)
else
puts(“Je suis le pere : #{Process.pid}”)
end
Idem, avec un bloc :
fork do # Code exécuté par le fils
puts("Je suis le fils: #{Process.pid}, mon pere est #{Process.ppid}")
end
# Code exécuté par le père
puts("Je suis le pere : #{Process.pid}")
Petit jeu amusant : combien de lignes s’afficheront lorsque l’on exécute le programme suivant ?
1.upto(4) do |i|
if fork != nil
puts "pid = #{Process.pid}, i = #{i}"
end
end
Suite du jeu : donner la relation entre le nombre de boucles et le nombres de lignes affichées.
1.4.2 Terminaison d’un processus
Un processus se termine par l’appel à exit(raison). raison est un petit entier (0 pour indiquer une terminaison correcte, différent de 0 sinon).
Normalement, un processus père doit attendre la terminaison de ses fils avant de se terminer lui-même ; sinon, on a des processus orphelins, qui sont automatiquement adoptés par le processus init. C’est pour cela que l’exemple précédent était imparfait : quand le processus père se terminait avant le fils, le PPID de ce dernier était 1...
Inversement, si un processus fils se termine et que son père ne consulte pas son statut (par un appel à wait(2) ou assimilé), le processus fils est bien détruit de la liste des processus en cours d’exécution (puisqu’il s’est terminé), mais son PID apparaît encore dans la table des processus du système.
Ce processus est dit " zombie " : il ne peut pas être tué (car il n’existe plus), mais il continue d’occuper une entrée dans la table et son PID ne peut pas être réutilisé par un autre processus.
Note :
Moralité. Un processus doit toujours récupérer le statut de ses fils (ou, si vous aimez les images, un père doit toujours aller se recueillir sur le lit de mort de son fils) !
Un processus père peut attendre la terminaison de son fils par un appel wait(int *status) (wait(2)) qui renverra le PID du fils qui s’est terminé, ou -1 en cas d’erreur. Le paramètre status contient le code de retour de ce processus fils (celui qu’il a renvoyé par exit). Le processus appelant wait est bloqué jusqu’à ce qu’un fils se termine.
Note :
La fonction POSIX waitpid(2) est plus générale et permet de passer des options.
Quand un processus fils se termine ou est stoppé, il envoie le signal SIGCHLD à son père, qui peut donc détourner ce signal pour récupérer son statut et ainsi éviter qu’il soit zombie... (sur la majeure partie des systèmes, le comportement par défaut consiste à ignorer ce signal).
Exemple en C :
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
/* Test de création d’un processus :
on crée un fils. Le père attend la fin de
celui-ci en testant la condition de sa fin. On
peut simuler une mauvaise fin en effectuant
un ‘kill’ du processus fils.
*/
int main(void) {
pid_t pid = fork();
int raison;
switch (pid) {
case -1 : perror(“Erreur de création du processus");
exit(1);
case 0 : /* on est chez le fils */
printf("Pid du fils = %d\n", getpid());
sleep(20);
break;
default : /* on est chez le père */
printf("Pid du père = %d\n", getpid());
printf("Attente de la terminaison du fils...\n");
pid = wait(&raison);
if (raison == 0)
printf("\a%d s’est terminé correctement...\n", pid);
else printf("\a\a%d s’est mal terminé : %d\n",
pid, raison);
} /* switch */
exit(0); /* exécuté par le fils et le père
}
En Ruby, on dispose également des méthodes Process.wait et Process.waitpid (voir ri Process.wait) :
do
puts("PID du fils : #{Process.pid}")
sleep(20)
end
puts("PID du pere : #{Process.pid}")
puts(“Attente de la terminaison du fils...”)
pid = Process.wait
if $?.exitstatus == 0
puts(“#{pid} s’est correctement termine”)
else
puts(“#{pid} s’est mal termine”)
end
exit(0) # uniquement par le père
Pour empêcher les processus zombies, on peut également détourner SIGCHLD dans le processus père pour qu’il aille consulter l’état d’un fils lorsque celui-ci meurt. Voici un script qui crée volontairement un processus zombie :
fork do
puts("PID du fils : #{Process.pid}")
end
puts(“PID du pere : #{Process.pid}”)
puts(“Mon fils est un zombie pendant 20 secondes (vérifier avec ps)...")
sleep(20)
pid = Process.wait
if $?.exitstatus == 0
puts(“#{pid} s’est correctement terminé")
else
puts("#{pid} s’est mal termine")
end
exit(0) # uniquement par le père
Voici la solution pour éviter les zombies (on notera le remplacement du sleep par un gets pour faire attendre le processus père... en effet, le sleep serait interrompu par le signal SIGCHLD qui, ici, n’est plus ignoré)...
pid = fork do
puts("PID du fils : #{Process.pid}")
end
trap(:SIGCHLD) do
Process.waitpid
end
puts("PID du pere : #{Process.pid}")
puts(“Mon fils n’est plus un zombie (vérifier avec ps)...")
gets # Pour pouvoir vérifier qu’il n’y a pas de zombie
begin
Process.waitpid # 2
rescue Errno::ECHILD
puts("fini")
ensure
exit(0) # uniquement par le père
end
Remarque :
Ce n’est pas parce qu’on essaie d’éviter les processus zombies qu’il faut créer des processus orphelins ! Si, pour une raison donnée, le processus arrive sur le waitpid marqué 2, il attendra la fin de son fils avant de se terminer lui-même. Il n’y aura donc ni zombie ni orphelin. Si, par contre, le fils se termine avant le père (ce qui n’est pas le cas ici, mais c’est un " hasard ") le trap aura fait le waitpid pour éviter le zombie et le waitpid marqué 2 échouera avec ECHILD puisqu’il n’y a plus de processus fils à attendre... C’est la raison pour laquelle cet appel est dans un bloc de traitement d’exception.
Cela dit, avec Ruby, il y a un moyen bien plus simple : utiliser la méthode Process.detach(pid). Cette méthode crée un thread (pour l’instant, considérons que c’est un " sous-processus ") chargé d’aller consulter l’état du processus fils pid lorsque celui-ci se termine. Cette consultation a lieu toutes les secondes...
pid = fork do
puts("PID du fils : #{Process.pid}")
puts("Mon PPID : #{Process.ppid}")
end
puts(“PID du pere : #{Process.pid}”)
Process.detach(pid)
puts(“Mon fils n’est plus un zombie pendant 20 sec (vérifier avec ps)...")
sleep(20) # pour pouvoir vérifier qu’il n’y a pas de zombie
begin
Process.waitpid
rescue Errno::ECHILD
puts("fini")
ensure
exit(0) # uniquement par le père
end
Ici, on évite à la fois les zombies et les orphelins. Pour les mêmes raisons que précédemment, le waitpid du père peut échouer s’il n’y a plus de processus fils.
1.5 Commutation d’image
Comme on l’a vu, après un appel à fork(), le segment de code du processus fils contient la même chose que le segment de code de son père : ils exécutent donc le même programme.Les appels de la famille exec() permettent de charger un segment de code avec le code d’un autre programme : il y a donc écrasement du code antérieur et il n’est plus possible de revenir à l’ancien code après cet appel. Si les instructions qui le suivent sont exécutées, c’est que l’appel a échoué.
La plupart des informations décrivant le processus gardent leurs valeurs : PID, PPID, descripteurs de fichiers (si le père a redirigé une de ses entrées/sorties, cela reste valable dans le fils, notamment).
Note :
La commande shell exec(1) est l’une des rares commandes ne produisant pas de fork. Si on tape exec ls dans un shell, le code de la commande ls écrase donc celui du shell : ls s’exécute et le shell est terminé...
Les fonctions exec existent en plusieurs versions variant selon les paramètres qu’elles acceptent. Voir exec(3) : le ‘l’ signifie liste, le ‘v’ variable, le ‘e’ signifie environnement et le ‘p’ path. En réalité, toutes ces fonctions sont des enveloppes de la fonction execve(2) dont les paramètres sont les suivants :
pathest le nom du programme dont on veut charger le code.argvest un tableau contenant les paramètres passés à ce programme (argv[0]doit être le nom du programme).envpest un tableau contenant les chaînes d’environnement du programme. La variable globale environ contient un pointeur vers ce tableau.
Dans le cas des fonctions execl*, la liste des arg doit se terminer par NULL. Dans le cas des fonctions exect() et execv*, le dernier élément du tableau argv doit être le pointeur NULL (idem pour le tableau envp).
Une utilisation typique en C est :
switch (fork()) {
case -1 : /* traiter l’erreur */
case 0 : /* on est chez le fils, on exécute un autre code */
...
exec...(....);
* traiter l’erreur de l’exec */
default: /* on est chez le père */
...
}
En Ruby, on dispose de la méthode Kernel.exec qui peut être appelée avec un ou plusieurs paramètres.
Le premier paramètre est le nom du programme dont le code remplacera celui du processus courant, les autres paramètres sont les paramètres qui sont passés à ce programme (sans interprétation par le shell). Si l’appel échoue, l’exception SystemCallError est levée (Errno::ENOENT, le plus souvent).
À titre d’exemple, nous allons écrire la commande run nomfic qui compile un fichier source en C et exécute le programme résultant s’il n’y a pas eu d’erreur.
Si la compilation échoue, run appelle la commande more pour visualiser les messages d’erreur, puis charge le fichier source dans l’éditeur vi pour qu’on puisse le corriger :
begin
raise "Usage: #{$0} nomfic" if not ARGV[0] or ARGV.length > 1
fork do # processus fils
fic = File.open("errtmp", "w")
STDERR.reopen(fic) # redirection de STDERR
fic.close
exec(“gcc”, ARGV[0]) # exécution de gcc
end
# Code du père
Process.wait # attend la fin de gcc
if $?.exitstatus != 0 # erreurs de compilation
fork do # création d’un fils
exec("more", "errtmp") # visualisation des erreurs
end
# code du père
Process.wait
print("Appuyez sur Entree pour lancer l’éditeur :")
STDIN.gets
exec("vi", ARGV[0]) # puis chargement de vi
else # la compilation s’est bien passée
exec("./a.out") # on exécute le programme
end
rescue RuntimeError => e
STDERR.puts(e)
exit(1)
rescue Exception => e
STDERR.puts(e)
exit(2)
end
On pourrait encore trouver bien d’autres exercices amusants : par exemple une compilation des différentes sources d’un programme en parallèle (avec gcc -c) puis édition des liens si tout s’est bien passé, ou chargement du ou des fichiers sources dans un éditeur de texte en cas d’erreur. Cet exercice est laissé à la sagacité du lecteur.
2. Les tubes de communication (pipes)
La plupart des commandes Unix sont des filtres. Un filtre est un programme qui prend des données en entrée (sur STDIN), les transforme et affiche le résultat sur sa sortie (STDOUT). Ainsi, la ligne de commande ls -l | wc -l utilise un tube pour relier la sortie de ls -l à l’entrée de wc -l.
On peut donc voir un tube comme un moyen de communication entre deux processus : il permet de connecter la sortie d’un processus à l’entrée d’un autre.
En fait, un tube fonctionne selon un schéma producteur-consommateur : la production consiste à écrire dans le tube, la consommation à y lire.
Pour cela, les mécanismes suivants sont assurés :
- synchronisation automatique pour l’accès au tube :
- lecture bloquante si le nombre d’informations demandé n’est pas disponible ;
- écriture bloquante si le tube ne peut contenir les informations à écrire.
- la gestion des requêtes n’est pas nécessairement FIFO (d’où des problèmes de famine) ;
- la lecture se fait dans l’ordre des écritures (FIFO) ; il n’y a qu’une seule consommation (destructrice).
2.1 Tubes vers d’autres processus
La fonction popen(3) de la bibliothèque standard du C permet de gérer les tubes de façon transparente. Elle renvoie un FILE * (un descripteur de fichier C) et prend un nom de commande et un mode d’ouverture en paramètre.
En coulisses, cette fonction appelle l’appel système pipe(2) pour créer un tube (cf. plus bas), puis appelle fork(2) pour démarrer un nouveau processus qui sera attaché à l’une des extrémités de ce tube (selon la valeur de son paramètre mode) et qui exécutera la commande indiquée.
Pour mettre fin à ce tube, on appelle la fonction pclose(3). Le paramètre mode vaut "r" pour ouvrir un tube en lecture (on lit la sortie standard de la commande), ou "w" pour écrire dans le tube (on écrit des données sur l’entrée standard de la commande).
En Ruby, la classe IO fournit la méthode popen qui prend exactement les mêmes paramètres que son homologue C et qui peut, comme File.open, être ou non associée à un bloc. Le mode par défaut est "r".
Si un bloc est associé à popen, le tube sera automatiquement fermé à la fin de ce bloc.
Exemple de tube en lecture : on affiche la sortie de la commande ps –l :
begin
# Ouverture du tube pour *lire* la sortie de "ps -l"
IO.popen("ps -l") do |f|
f.readlines.each { |ligne| puts ligne}
end
rescue Exception => e
STDERR.puts(e)
exit(1)
end
Un code plus court (mais s’éloignant du codage " traditionnel ") :
puts IO.popen("ps -l").read
Exemple de tube en écriture :
require ‘etc’
# Création de la commande
destinataire = Etc.getpwuid.name
commande = "mail -s ‘message du processus #{Process.pid}’ #{destinataire}"
# Ouverture d’un tube pour *écrire* dans cette commande
begin
IO.popen(commande, "w") do |f|
f.puts(“C’est la commande #{$0} qui vous parle”)
f.puts(“J’agis pour le compte de #{Etc.getpwuid.gecos}”)
end
puts(“Message envoyé à #{destinataire}")
rescue Exception => e
STDERR.puts(e)
exit(1)
end
REMARQUE :
La construction %x(....) de Ruby, qui est équivalente au $(...) ou `...` du shell, permet de lire très simplement le résultat d’une commande (il est également possible d’utiliser `...`, mais je trouve cela moins lisible). Le premier exemple peut donc aussi être remplacé par cette simple ligne :
puts %x(ps -l) # ou : puts `ps -l`
Le module open3 fournit une méthode popen3 fonctionnant comme popen, mais avec possibilité de gérer STDERR. Son utilisation typique est la suivante :
require ‘open3’
Open3.popen3(une_commande) do |stdin, stdout, stderr|
err = stderr.gets
if err
puts "#{err}"
else
stdout.readlines.each { |x| puts x}
end
end
2.2 Tubes système
Un tube est créé dynamiquement par un processus à l’aide de la fonction pipe(int Tube[2]), qui renvoie 0 si OK, -1 sinon (voir pipe(2)). Cette fonction alloue deux descripteurs de fichiers et place leurs numéros dans le tableau Tube.
Le premier descripteur, Tube[0], permet de lire dans le tube. Le deuxième, Tube[1] permet d’y écrire. On notera qu’un processus peut utiliser un tube pour ses seuls besoins personnels : il dispose alors d’une file FIFO...
2.2.1 Accès à un tube
Les accès à un tube sont identiques à ceux des fichiers et utilisent donc les appels read(Tube[0], Tampon, Nb_Octets) et write(Tube[1], Tampon, Nb_Octets) qui renvoient, tous deux, le nombre d’octets effectivement lus ou écrits.
ATTENTION :
A la différence des fichiers, il y a synchronisation entre l’écriture et la lecture. L’appel read() est donc bloquant s’il n’y a pas Nb_Octets dans le tube...
Tous les processus fils créés après la création du tube connaissent ce tube (à cause de la duplication des descripteurs) : on a donc une communication limitée à une branche de processus.
Pour que deux processus puissent communiquer, il suffit donc qu’ils aient un ancêtre commun qui ait créé un tube.
2.2.2 Fin de communication
Dans le cas d’un consommateur (lecteur), elle a lieu lorsqu’il n’y a plus aucun processus ayant le tube ouvert en écriture. Le consommateur récupère alors ce qui reste dans le tube, à concurrence de ce qu’il avait demandé :
- Nb_Restants >= Nb_Octets => Nb_Octets
- Nb_Restants < Nb_Octets => Nb_Restants
Dans le cas d’un producteur (écrivain), elle a lieu lorsque le tube n’est plus ouvert qu’en écriture.
Note :
Conséquence, les fermetures de tubes sont essentielles ! (Sinon, les processus bouclent car ils ne sauront jamais que la communication est finie).
2.2.3 Tubes en Ruby
La classe IO fournit la méthode pipe qui renvoie un tableau (une instance de Array) de deux éléments, [lecture, écriture], tous deux instances de IO. Ces deux éléments se ferment grâce à leur méthode close.
2.2.4 Exemple
On veut écrire un script qui effectue le pipeline grep Unix fic1 fic2 | sort -uf | wc -l. On notera que chaque processus prend soin de fermer l’extrémité du tube qu’il n’utilise pas :
begin
rd, wr = IO.pipe # Creation du pipe sort-wc
fork do # Le fils fait le sort
rd.close # il n’y lira jamais
STDOUT.reopen(wr) # redirection de la sortie standard dans le tube
wr.close
rd, wr = IO.pipe # creation du pipe grep-sort
fork do # le fils fait le grep
rd.close # idem sort
STDOUT.reopen(wr)
wr.close
exec("grep", ARGV[0], ARGV[1], ARGV[2])
end
# le père fait le sort
wr.close
STDIN.reopen(rd)
rd.close
exec("sort", "-uf")
end
# le père de fait le wc
wr.close
STDIN.reopen(rd)
rd.close
exec("wc", "-l")
rescue Exception => e
STDERR.puts(e)
exit(1)
end
2.3 Les tubes nommés (FIFO)
Les tubes système n’ont pas de nom, ils ne peuvent donc être partagés qu’entre des processus ayant un ancêtre commun. Autrement dit, deux processus distincts ne peuvent pas utiliser un tube commun pour communiquer.
Une FIFO est un tube auquel on a associé un nom, il est donc accessible par tous les processus, qui pourront l’utiliser pour communiquer.
2.3.1 Création, ouverture et fermeture d’une FIFO
On crée un tube nommé à l’aide de la fonction mkfifo(2), qui attend deux paramètres (le nom et les droits d’accès) et renvoie un descripteur de fichier (un entier). La FIFO peut ensuite être ouverte en lecture ou en écriture par un appel à open(2). Par rapport aux fichiers, les seuls modes possibles sont O_RDONLY (lecture) et O_WRONLY (écriture) car une FIFO est half-duplex (on ne peut pas y lire et y écrire en même temps).
Comme les fichiers, une FIFO est fermée par un appel à close(2) sur le descripteur renvoyé par open(2). Ruby ne dispose pas d’une méthode équivalente, mais on peut utiliser la commande shell mkfifo(1) pour créer la FIFO (en faisant system("mkfifo #{nom_fifo}"), par exemple). Ensuite, on ouvre la FIFO avec File.open en mode "r" ou "w".
2.3.2 Écriture et lecture d’une FIFO
Les écritures et les lectures d’une FIFO utilisent les appels write(2) et read(2) déjà vus pour les fichiers. On rappelle qu’une FIFO fonctionne comme un tube : la lecture peut donc être bloquante si la FIFO est vide.
Les FIFO ne peuvent pas être partagées par NFS, elles ne sont accessibles que sur le système de fichiers local, ce qui signifie qu’elles ne peuvent pas servir aux communications entre machines (on utilisera pour cela des sockets). En Ruby, on utilisera les méthodes de lecture et d’écriture des objets IO (read, gets, readlines, print, puts, etc.).
2.3.3 Exemple : communication entre deux processus distincts
On a deux processus, client et serveur, désirant communiquer : on utilise deux FIFO, FIFO.1 pour les communications de client vers serveur et FIFO.2 pour celles de serveur vers client. Le client envoie un nom de fichier au serveur qui lui renvoie le contenu de ce fichier, ou un message d’erreur :

Code de serveur.rb :
def serveur(lect, ecr)
begin
nom_fic = lect.gets.chomp
File.foreach(nom_fic) do |ligne|
ecr.puts(ligne)
end
rescue Exception => e
ecr.puts(“Pb d’ouverture du fichier”)
end
end
FIFO_1 = "/tmp/FIFO.1"
FIFO_2 = "/tmp/FIFO.2"
# Création des 2 FIFO
system(“mkfifo -m 0666 #{FIFO_1}”) if not File.exists?(FIFO_1)
system(“mkfifo -m 0666 #{FIFO_2}”) if not File.exists?(FIFO_2)
begin
# Ouverture des FIFO
readfd = File.open(FIFO_1, "r")
writefd = File.open(FIFO_2, "w")
serveur(readfd, writefd)
rescue Exception => e
STDERR.puts(e)
exit!(1)
end
Code du client client.rb :
Note :
Ce n’est pas au serveur de supprimer les FIFO, c’est au client, puisque c’est lui qui fait les dernières opérations sur les FIFO.
def client(lect, ecr)
print("Entrez un nom de fichier :")
nom_fic = gets.chomp
ecr.puts(nom_fic)
ecr.flush
while ligne = lect.gets
puts ligne
end
end
FIFO_1 = "/tmp/FIFO.1"
FIFO_2 = "/tmp/FIFO.2"
begin
# Ouverture des FIFO : attention à
# l’ordre, sinon deadlock !!!
writefd = File.open(FIFO_1, "w")
readfd = File.open(FIFO_2, "r")
client(readfd, writefd)
rescue Exception => e
STDERR.puts(e)
exit!(1)
ensure
writefd.close
readfd.close
File.unlink(FIFO_1, FIFO_2)
end
2.3.4 Exemple : un serveur, plusieurs clients
Principe :
- Le serveur crée une FIFO portant un nom qui devra être connu de tous les clients. Cette FIFO est ouverte en lecture, elle servira de boîte de réception au serveur.
- Chaque client crée sa propre FIFO d’écriture, dont le nom est formé d’après son PID.
- Une requête arrivant au serveur devra contenir le PID du client en plus de la requête proprement dite : le serveur sait alors dans quelle FIFO il doit écrire.
On a donc le schéma suivant (le codage du serveur et du client est laissé en exercice) :

On a ici un serveur itératif : les clients sont servis les uns après les autres. Un serveur concurrent permet de servir plusieurs clients en même temps : le serveur forke un processus pour chaque requête cliente (on a donc un fils par client).
Pour économiser le coût des forks, on peut utiliser plusieurs techniques :
- Lancer un ensemble de fils au démarrage du serveur et les bloquer. On débloque un fils pour chaque nouvelle requête.
- Ne pas créer de processus fils, mais utiliser des threads (on crée un thread par client).
- Créer un ensemble de threads au démarrage et les bloquer. On débloque un thread pour chaque nouvelle requête.
Attention :
Les FIFO sont des objets stockés sur disque, leur accès est donc lent (il est conseillé de les créer dans un disque virtuel).
3. Accès concurrents à des objets partagés
3.1 Problème de l’exclusion mutuelle
3.1.1 Énoncé du problème
Soit un système de réservation de billets d’avion. Le code permettant de réserver une place pourrait être :
Trouver une place libre; Nb_Places_Reservées := Nb_Places_Reservées + 1;
Ce code peut être exécuté par plusieurs processus en même temps (agences de réservation) et Nb_Places_Reservées doit être partagé par tous ces processus.
Deux processus peuvent donc lire quasiment en même temps la valeur de Nb_Places_Reservées et le résultat de deux réservations ne réservera donc qu’une place...
Pour éviter ce problème, deux processus ne doivent jamais pouvoir manipuler en même temps une variable commune : il faut garantir l’accès en exclusion mutuelle aux variables communes.
On appellera section critique un segment de code contenant des variables globales. L’accès à cette section devra respecter l’exclusion mutuelle.
On devra donc ajouter du code :
- un prologue pour attendre de pouvoir entrer en section critique ;
- un épilogue pour signaler qu’on en est sorti.
Les processus pourront ainsi se bloquer mutuellement.
Toute section critique sera encadrée par un prologue et un épilogue :
Entrée -------------> blocage du processus tant que la SC est occupée Section Critique Sortie -------------> on signale qu’on a libéré la SC
Il ne doit pas être possible d’entrer en section critique (ou en sortir) sans passer par Entrée (ou Sortie).
3.1.2 Spécification d’une solution valide
- À tout instant, un processus au plus est engagé en SC.
- Aucune hypothèse ne sera faite sur la vitesse des processus.
- Tout processus est susceptible d’être interrompu à tout moment.
- Pas de famine.
- Si la SC est libre, tout processus demandant à y entrer doit pouvoir le faire.
3.2 Les sémaphores de Dijkstra (1965)
3.2.1 Présentation
Un sémaphore est un objet abstrait sur lequel on peut réaliser deux opérations atomiques : P(Sem) et V(Sem). P et V viennent du hollandais prolagen (essayer de diminuer) et verhogen (augmenter).
P est l’opération susceptible de bloquer, V n’est jamais bloquante, mais débloque un processus bloqué par P.
Un sémaphore étant un objet commun à plusieurs processus, toutes les opérations P et V agissant sur un même processus doivent se faire en exclusion mutuelle.
Edsger Wybe Dijkstra était un mathématicien/informaticien néerlandais. Il s’est éteint, il y a tout juste 4 ans non sans avoir laissé sa trace dans le monde de l’informatique avec, entre autres, l’algorithme qui porte son nom. Il est également l’auteur du célèbre " On the Go To Statement Considered Harmful " de 1968, marquant le début de la fin de la programmation " spaghetti ".
À un sémaphore S est associé, de façon interne :
- un compteur
c, qui est un entier représentant la valeur du sémaphore ; - une file d’attente
f, initialement vide.
Attention :c est obligatoirement initialisé avec une valeur positive ou nulle.
3.2.2 Fonctionnement
L’algorithme de P(S) est le suivant :
S.c := S.c - 1; SI S.c < 0 ALORS Insérer le processus exécutant P dans S.f ; Bloquer ce processus ; FIN SI;
L’algorithme de V(S) est le suivant :
S.c := S.c + 1; SI S.c <= 0 ALORS /* il y a des processus bloqués */ Extraire le processus en tête de S.f; Débloquer ce processus ; FIN SI;
On remarque que le nombre de processus bloqués par un sémaphore S est égal à |S.c|.Un processus bloqué par P(S) devra donc attendre qu’un autre processus exécute V(S). Pour initialiser le processus, on utilise généralement un sous-programme Sem_Init(S, val).
3.2.3 Utilisation des sémaphores
Réalisation de l’exclusion mutuelle : on utilise un sémaphore, traditionnellement appelé MUTEX (MUTual EXclusion) :
MUTEX : Semaphore;
Sem_Init(MUTEX, 1); /* 1 seul process doit pouvoir entrer */
P(MUTEX); /* donc MUTEX.c vaut maintenant 0 */
/* tout process faisant P(MUTEX) sera bloqué */
SC /* Section Critique */
V(MUTEX); /* MUTEX.c repasse à 1 */
Note :
MUTEX ne doit servir qu’à cette exclusion mutuelle !
Problème du producteur-consommateur : un processus P écrit dans un tampon et un processus C lit ce tampon. On suppose que P produit indéfiniment et que C consomme indéfiniment.
Le tampon a une capacité limitée de N cases. Si le producteur va plus vite que le consommateur, le tampon peut être plein et le producteur est alors bloqué en attendant que le consommateur libère une case du tampon.
Inversement, si le consommateur va plus vite que le producteur, le tampon peut être vide et le consommateur est alors bloqué en attendant que le producteur écrive dans le tampon.
On utilisera donc trois sémaphores : VIDE, initialisé à N (le nombre de cases vides), PLEIN, initialisé à 0 (le nombre de cases pleines) et MUTEX pour garantir l’exclusion mutuelle sur le tampon. On supposera l’existence d’un module Semaphore (voir plus loin pour son implémentation) :
require ‘semaphore’
N = 100
vide = semaphore.new(N)
plein = semaphore.new(0)
mutex = semaphore.new(1)
def producteur
loop do
objet = ... # Création d’un objet
vide.p # Une case vide de moins
mutex.p # Entrée en SC
mettre(objet) # Met l’objet dans le tampon
mutex.v # Sortie de la SC
plein.v # Une case pleine de plus
end
end
def consommateur
loop do
plein.p # Une case pleine de moins
mutex.p # Entrée en SC
prendre(objet) # retirer l’objet du tampon
mutex.v # Sortie de SC
vide.v # Une case vide de plus
consommer(objet) # Utiliser l’objet
end
end
3.2.4 Un grand classique : le problème des philosophes (Dijkstra)
Des philosophes sont assis autour d’une table ronde, devant une assiette. Chaque philosophe a exactement une fourchette à sa gauche et une fourchette à sa droite. Les philosophes pensent et mangent tour à tour. Problème : comment s’assurer qu’ils pourront tous manger et dans les meilleurs délais ?
Ce problème modélise l’accès concurrent à des ressources en nombre limité (périphériques d’E/S, par exemple).
Première solution :
N = 5 # Nombre de philosophes
def philosophe(i)
loop do
penser
prendre_fourchette(i) # fourchette gauche
prendre_fourchette(i + 1) # fourchette droite
manger
poser_fourchette(i)
poser_fourchette(i + 1)
end
end
Cette solution ne marche pas... si chaque philosophe prend en même temps sa fourchette gauche, il y aura interblocage.
Solution possible : un sémaphore par philosophe pour contrôler l’attente des deux fourchettes libres simultanément (problème de synchronisation) :
- Un philosophe a 0 ou 2 fourchettes (jamais une seule).
- Un philosophe prévient ses voisins quand il libère ses fourchettes.
- Un philosophe a trois états : PENSE, FAIM, MANGE.
- Une fourchette est libre si l’autre philosophe n’est pas dans l’état MANGE.
- Pas d’interblocage car un philosophe prend ses deux fourchettes d’un coup et bloque donc ses deux voisins.
require ‘semaphore’
N = 5 # Nombre de philosophes
def gauche(i)
return (i - 1) % N
end
def droite(i)
return (i + 1) % N
end
etats = []
sems = []
mutex = semaphore.new(1)
# Initialisation du tableau des 5 semap
1.upto(N) { sems << semaphore.new(0) }
hores
def test(i)
if etats[i] == :FAIM and etats[gauche(i)] != :MANGE and
etats[droite(i)] != :MANGE
etats[i] = :MANGE
sems[i].v
end
def prendre_fourchettes(i)
mutex.p
etats[i] = :FAIM
test(i) # Essaie d’avoir deux fourchettes
mutex.v
sems[i].p # Blocage si pas deux fourchettes
end
def poser_fourchettes(i)
mutex.p
etats[i] = :PENSE
test(gauche(i)) # Autorise gauche à manger
test(droite(i)) # Autorise droite à manger
mutex.v
end
def philosophe(i)
loop do
penser
prendre_fourchettes(i)
manger
prendre_fourchettes(i)
end
end
3.2.5 Les sémaphores en Ruby
Il n’existe pas de classe Semaphore dans la bibliothèque standard de Ruby, mais elle n’est pas difficile à implémenter :
Pour assurer le blocage, on peut utiliser un tube... Faire un P consiste à tenter de lire dans le tube : s’il est vide, ce P sera bloquant. Faire V revient à ajouter un élément dans le tube. L’initialisation du sémaphore ajoutera le nombre d’éléments voulus dans le tube.
Cette implémentation n’est pas parfaite puisqu’elle ne garantit pas que l’ordre FIFO sera respecté (normalement, les processus débloqués doivent l’être dans l’ordre où ils ont été bloqués). Nous verrons comment résoudre ce problème avec les threads.
Le programme de test associé est une boucle tentant d’afficher alternativement " Ping " et " Pong ", sachant qu’il faut commencer par " Ping " et qu’on ne peut afficher " Pong " qu’après un " Ping ".
=begin
jsemaphore.rb : implémentation d’une classe Semaphore
en utilisant des pipes pour la
synchronisation
=end
class Semaphore
def initialize(cpteur = 1)
raise ArgumentError, "cpteur must be >= 0" if cpteur < 0
@rd, @wr = IO.pipe
cpteur.downto(1) { @wr.putc(:jeton) }
end
def p { @rd.getc }
def v { @wr.putc(:jeton) }
def synchronize
self.p
begin
yield
ensure
self.v
end
end
# Mettre en ObjectSpace.define_finalizer ?
def delete
@rd.close
@wr.close
end
end
#---------------------------------------
if __FILE__ == $0
begin
sem_ping = Semaphore.new(1)
sem_pong = Semaphore.new(0) # Bloque tout de suite les Pong...
1.upto(10) do
fork do
sem_pong.p
puts("Pong")
sem_ping.v
end
end
1.upto(10) do
sem_ping.p
print("Ping...")
STDOUT.flush
sem_pong.v
end
ensure
sem_ping.delete
sem_pong.delete
end
3.3 Les processus légers (threads)
3.3.1 Présentation
Les interfaces graphiques, les systèmes multiprocesseurs ainsi que les systèmes répartis ont donné lieu à une révision de la conception usuelle des processus. Cette révision se fonde sur la notion de fils d’exécution (thread of control).
Pour comprendre la différence entre les processus classiques d’Unix (processus lourds) et les threads (processus légers), il suffit de comparer la gestion des processus Unix avec celle des tâches Mach.
Comme on l’a vu, un processus Unix contient un programme en cours d’exécution et un ensemble de ressources, comme sa table des descripteurs de fichiers et un espace adressable.
Une tâche Mach, par contre, ne contient qu’un ensemble de ressources, ce sont les threads qui gèrent toutes les activités d’exécution. À une tâche Mach peut être associé un nombre quelconque de threads et tout thread doit être associé à une tâche. Tous les threads associés à une tâche donnée partagent les ressources de celle-ci : un thread est donc essentiellement un compteur de programme, une pile et un ensemble de registres, toutes les autres structures de données appartiennent à la tâche. En fait, on peut modéliser un processus Unix par une tâche mono-thread.
L’idée est donc d’associer plusieurs fils d’exécution à un espace d’adressage et à un processus. Les fils d’exécution sont donc des processus dégradés (sans espace d’adressage propre) à l’intérieur d’un processus ou d’une application.
3.3.2 Intérêt des processus légers
Certaines applications se dupliquent entièrement au cours du traitement. C’est notamment le cas pour des systèmes client-serveur, où le serveur exécute un fork() pour traiter chacun de ses clients.
Cette duplication est souvent très coûteuse. Avec des fils d’exécution, on peut arriver au même résultat sans gaspillage d’espace, en ne créant qu’un fil de contrôle pour un nouveau client et en conservant le même espace d’adressage, de code et de données.
Les fils d’exécution sont, par ailleurs, très bien adaptés au parallélisme. Ils peuvent s’exécuter simultanément sur des machines multiprocesseurs. L’avantage essentiel des fils d’exécution est de permettre le parallélisme de traitement en même temps que les appels système bloquants.
Ceci impose une coordination particulière des fils d’exécution d’un processus. Dans une utilisation optimale, chaque fil de commande dispose d’un processeur.
3.3.3 Création et terminaison d’un thread
L’utilisation de ces primitives passe par l’inclusion de pthread.h, toutes ces fonctions renvoient 0 si tout s’est bien passé, une valeur correspondant à errno sinon (EBUSY, EFAULT, etc. voir /usr/include/errno.h).
Un thread est créé par un appel à pthread_create(3). Cet appel précise la fonction qui sera exécutée par le thread, les paramètres passés à la fonction, ainsi que les attributs (priorité, politique d’ordonnancement) : le plus souvent, on utilise la constante pthread_attr_default. Si l’appel réussit, le premier paramètre contient l’identificateur du thread créé.
On notera que la fonction associée au thread doit renvoyer un void *. On termine un thread en appelant pthread_exit(3) (par défaut, pthread_exit(NULL) est automatiquement exécuté à la fin du thread.
Note :
Ne surtout pas utiliser exit(3) pour terminer un thread, car cet appel met fin au processus entier, donc notamment à tous ses threads !
En Ruby, un thread est une instance de la classe Thread. Le constructeur doit être associé à un bloc qui définit les instructions qu’exécutera le thread. Les éventuels paramètres passés à new seront transmis au bloc.
Un thread peut être explicitement terminé par un appel à la méthode de classe Thread.exit (fin du thread courant ou du programme s’il s’agit du thread principal) ou la méthode d’instance un_thread.exit. Par défaut, le thread se termine lorsqu’il atteint la fin du bloc qui lui est associé lors de sa création.
3.3.4 Un exemple...
On crée ici une application utilisant vingt threads : 10 affichant " ping ", 10 affichant " Pong ".
Note :
Ne pas mettre les join. On y reviendra plus tard.
=begin
ping_pong.rb : présentation des threads et de leur
synchro par un mutex. Comme on ne peut
pas préciser le nombre de tickets à la
création d’un objet Mutex, on le bloque
dès le départ.
=end
require ‘thread’
WITH_SEMAPHORES = (ARGV[0] != ‘--without-sem’)
if WITH_SEMAPHORES
sem_ping = Mutex.new
sem_pong = Mutex.new
# blocage d’entrée du thread pong
sem_pong.lock
end
threads_pong = []
1.upto(10) do # Création de 10 threads pong
threads_pong << Thread.new do
sem_pong.lock if WITH_SEMAPHORES
puts(“pong...”)
sem_ping.unlock if WITH_SEMAPHORES
end
end
threads_ping = []
1.upto(10) do
threads_ping << Thread.new do
sem_ping.lock if WITH_SEMAPHORES
print(“Ping...”)
sem_pong.unlock if WITH_SEMAPHORES
end
end
# Voir plus loin...
threads_ping.each { |t| t.join}
threads_pong.each { |t| t.join}
puts(“”) # Pour afficher les Ping si pas de synchro
Pour information, la même chose en C :
#include <stdio.h>
#include <semaphore.h>
#include <pthread.h>
#define WITH_SEMAPHORES 1
sem_t sem_ping, sem_pong;
void *func_ping(void *bidon) {
if (WITH_SEMAPHORES) sem_wait(&sem_ping);
printf(„Ping... „); fflush(stdout);
if (WITH_SEMAPHORES) sem_post(&sem_pong);
return NULL;
}
void *func_pong(void *bidon) {
if (WITH_SEMAPHORES) sem_wait(&sem_pong);
printf(„Pong\n“); fflush(stdout);
if (WITH_SEMAPHORES) sem_post(&sem_ping);
return NULL;
}
int main(void) {
int i;
pthread_t ping, pong;
if (WITH_SEMAPHORES) sem_init(&ping, 0, 1);
if (WITH_SEMAPHORES) sem_init(&pong, 0, 0);
for (i = 0; i < 10; i++)
pthread_create(&pong, NULL, func_pong, NULL);
for (i = 0; i < 10; i++)
pthread_create(&ping, NULL, func_ping, NULL);
/* Voir plus loin */
for (i = 0; i < 10; i++) {
pthread_join (hello, NULL);
pthread_join (world, NULL);
}
putchar(‘\n’);
if (WITH_SEMAPHORES) sem_destroy(&ping);
if (WITH_SEMAPHORES) sem_destroy(&pong);
exit(0);
}
3.3.5 Le thread initial (principal)
Lorsqu’une application démarre, il y a lancement automatique d’un thread pour exécuter main(). En fait, ce thread exécute une procédure de démarrage contenant le code suivant :
{ int result = main(argc, argv); exit(result); }
Par conséquent, quand main() se termine, le processus Unix se termine aussi (par l’appel à exit), même s’il y a encore des threads en cours d’exécution... Dans notre exemple, cela pourrait se traduire par aucun affichage car le processus se terminerait juste après avoir lancé les threads... Pour éviter cela, on peut :
- Bloquer le thread initial sur l’attente de la terminaison d’un ou plusieurs autres threads (avec
pthread_join). - Terminer explicitement le thread initial avec
pthread_exit, ce qui court-circuitera l’appel àexit.
Avec ces précisions, on comprend les appels à pthread_join et à la méthode join dans les exemples précédents.
3.3.6 Synchronisation par verrous
Les threads partageant des données communes, on doit assurer la cohérence des accès à celles-ci en utilisant les mécanismes de synchronisation.
La norme POSIX prévoit deux types de synchronisation : les verrous d’exclusion (sémaphores d’exclusion mutuelle ou mutex) et les variables conditions (tirées des moniteurs de Hoare).
3.3.6.1 Création dynamique ou statique d’un verrou d’exclusion
Un mutex peut être créé dynamiquement par un appel à pthread_mutex_init(3) ou statiquement, en faisant l’instruction suivante :
pthread_mutex mutex = PTHREAD_MUTEX_INITIALIZER;
Dans le cas de la création dynamique, on utilisera le plus souvent la constante pthread_mutexattr_default comme valeur du deuxième paramètre.
Comme on l’a vu dans l’exemple du ping-pong, Ruby propose la classe Mutex (qui fait partie du paquetage thread) pour créer des verrous d’exclusion mutuelle (avec une valeur fixée à 1). Les mutex ruby disposent des méthodes lock, unlock et synchronize (qui applique les deux précédentes au bloc qui lui est passé).
Cette classe, comme tous les objets de synchronisation définis dans la bibliothèque standard de Ruby, est conçue pour fonctionner avec les threads, pas avec les processus créés par fork.
3.3.6.2 Suppression d’un verrou
Pour supprimer un verrou, on utilise l’appel pthread_mutex_destroy(3).
3.3.6.3 Verrouillage et déverrouillage
On verrouille par un appel à pthread_mutex_lock(3) ou avec pthread_mutex_trylock(3). Le premier est bloquant si le compteur du mutex est nul, la deuxième essaie simplement de verrouiller et positionne errno si cela n’a pas été possible (EBUSY si le verrou est déjà verrouillé).
Un Mutex Ruby dispose des méthodes lock et try_lock qui ont exactement la même sémantique que leurs homologues C, ainsi qu’une méthode locked? permettant de tester si un verrou est déjà verrouillé.
On déverrouille un mutex par un appel à pthread_mutex_unlock(3). Seul le thread ayant posé le mutex a le droit de le déverrouiller (comportement indéfini sinon).
Si un ou plusieurs threads sont bloqués en attente du verrou, l’un d’entre eux obtient le verrou et est débloqué. Le choix de ce thread dépend des priorités et des politiques d’ordonnancement. Si ces attributs ne sont pas utilisés, on considérera que l’ordre de déblocage est celui du blocage (FIFO).
En Ruby, la méthode équivalente s’appelle unlock. On rappelle que m.synchronise { bloc } revient à placer les instructions de bloc entre m.lock et m.unlock.
3.3.7 Synchronisation par variables conditions
3.3.7.1 Création dynamique ou statique d’une variable condition
Comme les verrous d’exclusion mutuelle, les variables conditions peuvent être créées de façon dynamique, avec un appel à pthread_cond_init(3), ou statiquement avec l’instruction pthread_cond_t cond = PTHREAD_COND_INITIALIZER. Dans le cas de la création dynamique, on utilisera le plus souvent la constante pthread_condattr_default comme valeur du deuxième paramètre.
En Ruby, une variable condition est une instance de la classe ConditionVariable (qui fait partie du module thread).
3.3.7.2 Destruction
Une variable condition est détruite par un appel à pthread_cond_destroy(3).
3.3.7.3 Blocage
Pour bloquer un thread sur l’attente d’une condition, on utilise l’appel à pthread_cond_wait(3). Le thread appelant doit posséder le verrou mutex. Il sera alors bloqué sur la variable condition après avoir libéré le verrou et le restera jusqu’à ce que cette variable soit signalée et que le thread ait réussi à réacquérir le verrou. En Ruby, la méthode vc.wait bloque le thread appelant en attente de la variable condition vc : le thread relâche alors le verrou et se bloque. Lorsque vc sera signalée, il devra reprendre le verrou pour continuer.
3.3.7.4 Réveil simple ou général
L’appel pthread_cond_signal signale la variable condition passée en paramètre : un thread bloqué sur celle-ci est réveillé et tente alors de reprendre le verrou correspondant à son appel à pthread_cond_wait. Il ne sera vraiment débloqué que lorsqu’il aura réussi à reprendre ce verrou. Il n’y a aucun ordre garanti pour le choix du thread réveillé au cas où plusieurs étaient en attente. Cette opération n’a aucun effet s’il n’y a aucun thread bloqué sur la variable condition.
L’appel pthread_cond_broadcast(3) réveille tous les threads en attente de la condition. Ils tentent alors d’obtenir le verrou correspondant à leurs appels à pthread_cond_wait.
En Ruby, les appels équivalents sont vc.signal (qui réveille le premier thread en attente de vc) et s (qui les réveille tous).
3.3.8 Exemples
Implantation d’un mécanisme producteur/consommateur dans un tampon d’une case en utilisant les threads :
#include <pthread.h>
#include <stdio.h>
#include <sys/types.h>
char tampon;
int tampon_plein = 0;
pthread_mutex_t mutex;
void lecture(void);
void ecriture(void);
int main(void) {
pthread_t lecteur;
pthread_mutex_init( &mutex, pthread_mutexattr_default );
pthread_create( &lecteur, pthread_attr_default,
(void *)&lecture, NULL );
ecriture(); /* C’est le thread principal qui écrit */
return 0;
}
void ecriture(void) {
for( ;; ) {
pthread_mutex_lock( &mutex );
if ( !tampon_plein ) {
printf( “Écriture dans tampon\n" );
tampon_plein = 1;
}
pthread_mutex_unlock( &mutex );
}
}
void lecture(void) {
for( ;; ) {
pthread_mutex_lock( &mutex );
if ( tampon_plein ) {
printf( “lecture dans tampon\n” );
tampon_plein = 0;
}
pthread_mutex_unlock( &mutex );
}
}
En Ruby :
require ‘thread’
tampon_plein = false
tampon = nil
mutex = Mutex.new
id = Thread.new do # Thread de lecture
loop do
mutex.lock
if tampon_plein
puts(“Lecture dans tampon”)
tampon_plein = false
end
mutex.unlock
end
end
# Le thread principal écrit
loop do
mutex.lock
if not tampon_plein
puts(“Ecriture dans tampon”)
tampon_plein = true
end
mutex.unlock
end
id.join
Implantation d’un mécanisme producteur/consommateur dans un tampon à N cases en utilisant les threads :
#include <pthread.h>
#include <stdio.h>
#define N 10
pthread_mutex_t mutex;
pthread_cond_t est_libre, est_plein;
char Tampon[N];
int Nb_In; /* Nbre de caractères dans Tampon */
void *ecrire(void) {
pthread_mutex_lock(&mutex);
while (Nb_In == N) /* Tampon plein : on attend */
pthread_cond_wait(&est_libre, &mutex);
Tampon[Nb_In++] = ‘c’; /* on écrit */
printf("Un caractère ecrit...\n");
pthread_cond_signal(&est_plein); /* il y a qquechose à lire */
pthread_mutex_unlock(&mutex);
}
void *lire(void) {
char car;
pthread_mutex_lock(&mutex);
while (Nb_In == 0) /* Tampon vide : on attend */
pthread_cond_wait(&est_plein, &mutex);
car = Tampon[Nb_In--] ; /* on lit */
printf(“Un caractère lu...\n");
pthread_cond_signal(&est_libre); /* il y a une place libre */
pthread_mutex_unlock(&mutex);
}
int main(void) {
int i;
pthread_t producteur, lecteur;
pthread_mutex_init(&mutex, pthread_mutexattr_default);
pthread_cond_init(&est_libre, pthread_condattr_default);
pthread_cond_init(&est_plein, pthread_condattr_default);
Nb_In = 0;
for (i = 0; i < 1000; i++)
pthread_create(&lecteur, NULL, (void *)&lire, NULL);
for (i = 0; i < 1000; i++)
pthread_create(&producteur, NULL, (void *)&ecrire, NULL);
for (i = 0; i < 1000; i++) {
pthread_join(producteur, NULL);
pthread_join(lecteur, NULL);
}
return 0;
}
La même chose en Ruby :
require ‘thread’
N = 10
mutex = Mutex.new
est_libre = ConditionVariable.new
est_plein = ConditionVariable.new
nb_in = 0
tampon = []
th_prods = []
th_lects = []
1.upto(1000) do
th_lects << Thread.new do # Thread des lecteurs
mutex.lock
est_plein.wait(mutex) while nb_in == 0 # rien à lire
car = tampon[nb_in]
nb_in -= 1
puts(“un caractère lu")
est_libre.signal # il y a une case de libre
mutex.unlock
end
end
1.upto(1000) do
th_prods << Thread.new do # Thread des producteurs
mutex.lock
est_vide.wait(mutex) while nb_in == N # Tampon plein
tampon[nb_in] = ‘c’
nb_in += 1
puts(“un caractère écrit")
est_plein.signal # il y a une case à lire
mutex.unlock
end
end
th_lects.each {|id| id.join}
th_prods.each {|id| id.join}
4. Ressources
Si Ruby n’est pas déjà installé sur votre système, vous pouvez utiliser votre gestionnaire de paquetage ou télécharger les sources et les compiler vous-même (ce qui n’est pas bien difficile puisque cela consiste essentiellement à exécuter le trio infernal configure/make/make install).
Le fichier archive des sources de la dernière version publique, la 1.8.4, est disponible à l’URL suivante : ftp://ftp.ruby-lang.org/pub/ruby/ruby-1.8.4.tar.gz. Après l’installation de Ruby, vous devriez disposer de tous les outils nécessaires utilisés dans cet article : ri, irb et ruby.
Le site www.ruby-lang.org contient une foule de pointeurs liés au langage. Le portail http://www.ruby-doc.org/ vous permettra d’accéder à quasiment toutes les ressources en ligne concernant Ruby. Vous y trouverez notamment la documentation de l’API, ainsi que des liens vers des ouvrages et des articles librement téléchargeables ou disponibles en librairie. Le site de Why, the lucky stiff, http://qa.poignantguide.net/, comporte des liens intéressants et, surtout, son ouvrage, A poignant guide to Ruby (il en existe une traduction française, en cours d’élaboration : http://fr-draft.poignantguide.net/).
Le même Why, sur le site http://tryruby.hobix.com/, vous permet d’apprendre Ruby interactivement, grâce à un programme écrit en... Ruby. Le forum Usenet consacré à Ruby est comp.lang.ruby (c’est une réplication de la liste de diffusion ruby-talk à laquelle vous pouvez vous abonner à partir du site ruby-lang (Attention ! Cette liste est très prolixe...). Les éditions O’Reilly ont également une page consacrée à Ruby : http://www.oreillynet.com/ruby/
L’ouvrage qui me sert de référence pour la programmation des processus et des threads et sur les problèmes liés à la synchronisation est le volume 2 de Unix Network Programming, seconde édition, de W.R. Stevens (Prentice Hall).
Retrouvez cet article dans : Linux Magazine 86

