Copyright © 2007 - Jérôme Quelin
POE est un puissant framework permettant de réaliser plusieurs tâches en même temps, tout en restant simple à utiliser. Les composants de haut niveau disponibles permettent de réaliser facilement des applications complexes. POE s'intègre très bien avec les toolkits graphiques existants et ses capacités de passage de message permettent de distribuer une application sur un réseau de machines... Bref, POE est un framework intéressant que je vous propose de découvrir.
POE [0] est un environnement multitâche coopératif. C'est-à-dire que chaque tâche doit explicitement permettre à une autre de s'exécuter.
Cela rappellera de mauvais souvenirs à certains : Microsoft Windows 3.x (ainsi que les vieux Mac OS) utilisait cette forme de multitâche. Ce qui pose des problèmes à l'échelle d'un OS :
Si un des processus ne redonne pas la main à un autre processus (par exemple si le programme est bloqué), le système entier peut s'arrêter.
Le partage des ressources (CPU, mémoire, I/O, etc.) peut être inefficace et injuste.
Cependant, une application est généralement écrite par une personne ou une équipe réduite : il est donc (relativement) facile de s'assurer que chaque développeur respecte l'environnement - au contraire d'un OS où l'utilisateur peut faire tourner n'importe quel logiciel au fonctionnement plus ou moins stable.
Ce couplage fort réduit les inconvénients du multitâche coopératif, pour révéler la puissance d'un environnement multitâche.
fork()
, ni threads.Bien que POE soit multitâche, POE n'utilise ni fork()
ni threads.
Les applications basées sur POE sont donc facilement portables, même sur
les environnements ne supportant pas l'appel système fork()
ou les threads - ce qui
inclut la majorité des distributions Perl, très souvent compilées sans le
support pour les threads.
De plus, les applications POE sont plus faciles à écrire :
Contrairement à une application multi-processus, forcée de mettre en œuvre des mécanismes IPC complexes pour pouvoir communiquer, une application POE bénéficie du fait qu'il n'y a qu'un seul processus. Ainsi, les données sont partagées et accessibles facilement.
Contrairement à une application multi-threadée, forcée de mettre en œuvre des mécanismes de verrous complexes pour s'assurer de l'intégrité de ses données, POE bénéficie du fait qu'il n'y a qu'un seul thread. Ainsi, à tout moment, une seule tâche s'exécute, et donc les opérations sur les données sont forcément atomiques du point de vue de la tâche.
Une application POE est une suite de traitements d'événements que les tâches s'envoient entre elles.
Un événement est simplement un message indiquant qu'il s'est passé quelque chose d'intéressant : un fichier est prêt à être traité, une alarme est arrivée à expiration, une nouvelle connexion vient d'arriver... POE va surveiller ces événements ainsi que d'autres, et notifiera les applications lors de leur occurrence.
Les gestionnaires d'événements sont des fonctions assignées par les applications, destinées à être appelées lorsque des événements arrivent. POE s'assure que ces callbacks sont appelés : lorsqu'il y a du trafic entrant, POE appelle le gestionnaire idoine, lorsqu'une alarme se produit, POE réveille l'application en appelant la fonction qui attendait, etc.
Du fait de sa nature multitâche et événementielle, POE est un framework de choix pour toute application en réseau - d'autant plus que POE fournit un ensemble conséquents de composants de haut niveau. Ces composants permettent de créer très rapidement une application complexe avec un minimum de lignes.
D'autre part, POE s'interface très bien avec les toolkits graphiques classiques tels que Tk, Gtk, et autres Curses. La nature événementielle des GUIs fait tout naturellement de POE un environnement de choix pour ces applications.
Comme tous les frameworks avancés, POE implique une perte d'efficacité comparé à un modèle strictement procédural. Cependant, POE reste performant et suffira pour la majorité des besoins - surtout que les avantages que POE apporte sont nombreux. Seules les applications les plus exigeantes en terme de performance devront étudier si POE leur convient réellement.
Pour parler chiffres, POE convient pour tout besoin de l'ordre du millier de messages par seconde. Donc, au delà de 10000 messages par seconde, POE n'est plus le framework idéal, et il faut se tourner vers autre chose.
Le noyau de toute application POE est implémenté dans POE::Kernel
. [1]
Le kernel s'occupe d'acheminer les événements entre les tâches et d'appeler le code censé s'en occuper. Il vérifie certaines conditions et lance des événements si besoin (alarmes, fichiers prêts à être traités, etc.), déclenchant alors le code les gérant.
La méthode run()
de POE::Kernel
constitue la boucle principale d'un
programme. Cette méthode ne return
pas tant que des tâches sont
actives : tout code placé après run()
devra attendre pour être
exécuté que la dernière tâche se soit arrêtée.
Un programme POE suivra donc généralement le découpage suivant :
#!/usr/bin/env perl use strict; use warnings; use POE; # initialisation et création des tâches POE... POE::Kernel->run(); exit; # déclaration des fonctions gérant les événements.
Notez que la ligne use POE
va implicitement charger d'autres
modules indispensables au fonctionnement de POE, tels que POE::Kernel
.
Il est donc inutile d'écrire use POE::Kernel
, et la ligne appelant
run()
est donc tout à fait valide.
En POE, les tâches sont appelées des sessions. Ce sont des objets
POE::Session
(ce module étant lui aussi automatiquement chargé lors
d'un use POE
).
Les sessions ont leurs propres ressources. Chaque session a ses propres descripteurs de fichier, son propre espace de stockage (appelé la heap), accepte ses propres événements, et peut même disposer de ses propres sessions filles.
Par bien des aspects, on peut assimiler les sessions aux processus d'un OS. POE s'assure que les données d'une session sont séparées des autres sessions.
Une session reste en vie tant qu'elle a quelque chose à faire. Beaucoup de choses qui appartiennent à une session requièrent qu'elle fasse quelque chose. Les sessions s'arrêtent dès qu'elles n'ont plus rien à faire. L'une des meilleures façons d'arrêter une session est de s'assurer qu'elle ne possède plus rien.
Par exemple, une socket aura besoin d'être gérée. En conséquence, POE va garder la session en vie tant qu'elle possède la socket. Dès que la socket sera fermée et libérée, POE pourra arrêter la session - en supposant bien sûr qu'elle ne possédait rien d'autre.
Une session est référencée par un nombre (son ID), alloué par le kernel lors de sa création. Il est possible d'envoyer un message à une session en la référençant par son ID. Toutefois cela ne reste pas pratique pour les développeurs.
POE permet donc l'affectation d'alias, qui sont des noms symboliques pour les sessions. Cela permet aux sessions de se poster des messages en utilisant des noms. Une session peut avoir plusieurs alias.
POE ne peut pas savoir quelles sessions vont envoyer des événements à un alias, il va donc garder les sessions aliasées en vie pour se prémunir d'arrêter une session par erreur. Ce qui explique que l'attribution d'un alias "garde les sessions en vie".
Cependant, si toutes les sessions ne restent en vie que parce qu'elles
ont un alias, attendant passivement que les autres sessions leur
envoient des messages, on arrive en position de deadlock. Le kernel va
détecter ces situations et enverra un signal IDLE
à toutes les sessions
pour essayer de les réveiller. Si les sessions ne se réveillent pas
après le signal IDLE
, le kernel va leur envoyer un signal ZOMBIE
qui
n'est pas gérable par les sessions : celles-ci vont donc mourir.
POE a été conçu (entre autres) pour implémenter de manière extrêmement complète les usages courants qu'on peut trouver dans une application réseau. Cela permet de réduire le temps et l'effort demandés pour écrire une application.
Mais tout le monde n'a pas besoin du même degré d'abstraction : c'est pourquoi POE fournit trois niveaux d'abstraction au développeur. Chaque niveau fournit une balance différente entre effort du développeur et contrôle du développement. Le résultat est une flexibilité accrue pour chaque tâche, et la possibilité pour une application de mélanger les composants de différents niveaux pour un résultat optimum.
Les composants de haut niveau (résidant dans l'espace de nom
POE::Component
) permettent au développeur de créer et déployer
rapidement de puissantes applications sans trop d'effort.
On peut trouver dans cette catégorie un serveur ou un client générique TCP, différents serveurs plus spécialisés (serveur web simple, syslog, etc.), des interfaces à des applications ou des bibliothèques (pcap, oggenc, etc.) ou des gestion du temps évoluées (système de cron)...
POE fournit un toolkit de niveau intermédiaire (les modules POE::Wheel
) pour
les fois où les composants de haut niveau ne sont pas appropriés. Les
développeurs peuvent utiliser ces roues (wheels en anglais) sans
les réinventer pour créer des solutions adaptées ou développer leurs
propres composants réutilisables de haut niveau.
On trouve dans cette catégorie des roues permettant l'accès aux lignes
ajoutées à la fin d'un fichier (l'équivalent d'un tail -f
), la
gestion d'I/O bufferisées non bloquantes, le lancement et contrôle de
processus externes... sans oublier bien sûr la création et gestion
avancée de sockets non bloquantes !
Certaines applications sont si particulières que même les roues de POE sont déjà trop avancées et ne correspondent pas au besoin. Mais POE peut aussi servir dans ces cas-là. Ses fonctions bas niveau peuvent être utilisées pour écrire du code vraiment non standard, les développeurs ayant dans ce cas un contrôle complet sur leur code.
Les fonctions fournies par le kernel comportent la gestion des alarmes,
des interfaces à l'appel système select
permettant de surveiller des
fichiers, et la gestion des signaux.
C'est au développeur de choisir entre toutes ces possibilités, pour obtenir la recette qui lui permettra d'être le plus efficace pour résoudre le problème dont il a la charge.
Voyons maintenant comment tout cela s'articule. Lorsqu'une session est créée, elle récupère une liste d'événements auxquels elle doit répondre, avec le callback associé. Chaque callback est une référence de fonction :
POE::Session->create( inline_states => { _start => \&on_start, file_ready => \&on_file_ready, } );
Quand un événement file_ready
survient, la fonction correspondante
est appelée pour le gérer.
Certains événements sont prédéfinis par POE, tels que _start
et
_stop
appelés lorsque la session est respectivement créée et stoppée.
Les autres événements font partie de l'API de la session, et sont
choisis librement par le développeur.
Commençons par le traditionnel programme d'entraînement. Le voici dans toute sa splendeur, version POE :
#!/usr/bin/env perl use warnings; use strict; use POE; POE::Session->create( inline_states => { _start => sub { print "Hello, world!\n"; } }, ); POE::Kernel->run(); exit;
La partie intéressante est bien sûr la création de session. Dans cet
exemple, on ne réagit qu'à l'événement _start
, et le traitement
associé est tellement simple qu'on utilise une fonction anonyme.
Il est possible de passer des arguments à l'événement _start
en
fournissant un paramètre args
à la fonction create()
. Le callback
les retrouvera dans ses arguments. Reprenons notre "Hello, world!"
qui
va cette fois saluer en fonction des arguments passés en ligne de
commande :
#!/usr/bin/env perl use warnings; use strict; use POE; POE::Session->create( inline_states => { _start => \&on_start, }, args => \@ARGV, ); POE::Kernel->run(); exit; sub on_start { my @args = @_[ARG0 .. $#_]; print "Hello, @args!\n"; }
Notez que la fonction on_start()
récupère bien ses arguments dans
@_
... Mais comme un gestionnaire d'événements POE a besoin d'un
contexte pour fonctionner, POE va passer d'autres arguments en plus,
tels que le kernel, la heap ainsi que beaucoup d'autres choses (objet,
session active, etc.).
Pour récupérer les vrais arguments (au sens arguments passés par le
développeur), il faut utiliser des constantes définies (et exportées)
par POE. Pour les arguments, il s'agit de ARG0
à ARG9
. Et s'il y a
besoin de plus, comme les vrais arguments sont placés en fin de liste,
il est toujours possible d'utiliser ARG9+1
pour le onzième argument,
voire $#_
pour l'indice du dernier élément de @_
. C'est ce qui est
utilisé dans l'exemple ci-dessus.
Le but de POE est de faire cohabiter plusieurs sessions, chacune gérant son périmètre. POE leur permet de s'échanger des messages.
Prenons donc maintenant le cas d'une application un peu plus évoluée. On va créer deux sessions :
La première, nommée logger
, va être chargée d'écrire sur STDERR
tout message debug
reçu.
La deuxième va juste être chargée de se réveiller périodiquement et
d'envoyer un message à logger
. Au bout de 10 réveils, elle
s'arrêtera.
Cette application n'est pas très utile, d'autant plus qu'on n'a pas forcément besoin de deux sessions pour effectuer cela... Mais elle permettra d'étudier le passage de messages avec POE.
Commençons par regarder la création de logger
. Celle-ci doit réagir
aux événements debug
:
POE::Session->create( inline_states => { _start => \&on_logger_start, debug => \&on_logger_debug, }, );
Le callback de départ va mettre en place un alias logger
avec la
méthode alias_set()
du kernel. Celui-ci peut être récupéré via
$_[KERNEL]
, avec KERNEL
une autre constante déclarée et exportée
par POE :
sub on_logger_start { my $kernel = $_[KERNEL]; $kernel->alias_set( 'logger' ); }
Le callback de log proprement dit n'a rien d'exceptionnel, on appelle
juste warn()
avec tous les arguments reçus :
sub on_logger_debug { my @args = @_[ARG0 .. $#_]; warn @args; }
Passons maintenant à la création de l'autre session :
POE::Session->create( inline_states => { _start => \&on_alarm_start, tick => \&on_alarm_tick, }, );
Là encore, rien de nouveau. Mais la fonction d'initialisation est plus
intéressante. Comme on ne souhaite se réveiller que dix fois avant
de s'arrêter, cela veut dire qu'on doit garder un compteur
quelque part. Il est bien sûr hors de question d'utiliser une variable
globale. On va donc garder cette information avec les données de la
session, c'est-à-dire dans la heap (autrement dit le tas, par analogie
avec la partie de mémoire d'un processus où la mémoire sera allouée à
coups de malloc()
). Chaque session dispose d'une référence à un hash
anonyme (par défaut), accessible via $_[HEAP]
(HEAP
étant là
encore une constante définie et exportée par POE). La fonction
d'initialisation va aussi armer une alarme, qui devra lancer le
message tick
au bout d'une seconde :
sub on_alarm_start { $_[HEAP]->{nb} = 0; $_[KERNEL]->delay_set( 'tick', 1 ); }
Le kernel sait quelle session est active à tout moment, il n'a donc pas
besoin de cette information pour la méthode delay_set()
: celle-ci va
poster l'événement tick
à la session courante.
Lors de la réception d'un tick, ne reste plus qu'à poster un événement
debug
à destination de logger
grâce à la méthode post()
du
kernel, incrémenter notre compteur, et si celui-ci le permet, alors
relancer une alarme :
sub on_alarm_tick { my ($k,$h) = @_[KERNEL, HEAP]; $k->post( 'logger', 'debug', 'this is a test: msg ', $h->{nb} ); return if ++$h->{nb} > 9; $k->delay_set( 'tick', 1 ); }
La méthode post()
prend comme arguments la session destinatrice,
l'événement à déclencher, et des paramètres éventuels pour le
callback du dit événement.
Et voici donc le programme complet :
#!/usr/bin/env perl use strict; use warnings; use POE; POE::Session->create( inline_states => { _start => \&on_logger_start, debug => \&on_logger_debug, }, ); POE::Session->create( inline_states => { _start => \&on_alarm_start, tick => \&on_alarm_tick, }, ); POE::Kernel->run(); exit; # -- logger sub on_logger_start { my $kernel = $_[KERNEL]; $kernel->alias_set( 'logger' ); } sub on_logger_debug { my @args = @_[ARG0 .. $#_]; warn @args; } # -- alarm sub on_alarm_start { $_[HEAP]->{nb} = 0; $_[KERNEL]->delay_set( 'tick', 1 ); } sub on_alarm_tick { my ($k,$h) = @_[KERNEL, HEAP]; $k->post( 'logger', 'debug', 'this is a test: msg ', $h->{nb} ); return if ++$h->{nb} > 9; $k->delay_set( 'tick', 1 ); }
On peut remarquer que bien qu'un alias ait été défini pour logger
,
nul besoin de l'enlever : le kernel détecte que plus aucune session
n'est active et arrête donc bien toutes les sessions.
Il est généralement plus propre de gérer chaque session dans un module : de cette façon on obtient des composants bien encapsulés, et plus facilement réutilisables.
Dans ce cas-là, le module va fournir une méthode spawn()
(c'est une
convention de nommage que chacun est libre de respecter... ou non) qui
lancera une nouvelle session. Toutes les fonctions de gestion
d'événements seront alors dans le module, pour une encapsulation forte.
Une autre méthode de modularisation consiste à utiliser plusieurs objets dans la même session, chacun gérant un ensemble de messages. Il est même possible de mélanger, au sein d'une même session, gestion par objets et gestion par fonctions.
Ainsi, en reprenant l'exemple précédent, et en supposant qu'on a une
classe Logger
qui dispose des méthodes debug()
et error()
:
my $logger = Logger->new( ... ); POE::Session->create( inline_states => { _start => \&on_start, tick => \&on_tick, }, object_states => [ $logger => [ 'debug', 'error' ], ], );
Ici, le paramètre object_states
est identique à inline_states
,
sauf qu'il attend une référence de liste. En effet, les clefs d'un hash
sont automatiquement transformés en chaîne par Perl, et la référence de
l'objet serait perdue. Donc on fournit à object_states
une liste
composée de paires : chaque objet est suivi par une référence vers la
liste des messages qu'il va traiter.
Cette liste des messages peut être :
soit une liste de chaînes, auquel cas ces chaînes seront des événements qui seront traités par les méthodes de même nom de l'objet,
soit un hash dont les clefs sont les noms d'événement et les valeurs le nom de la méthode à appeler. Ceci est utile dans le cas où l'API de l'objet ne suit pas l'API de la session, ce qui peut arriver dans le cas où on réutilise des modules du CPAN.
Ainsi, voici l'exemple ci-dessus qui utilise deux objets : le premier pour logger les messages bénins, et un deuxième qui va gérer les erreurs fatales :
my $logger = Logger->new( ... ); my $error = Error->new( ... ); POE::Session->create( inline_states => { _start => \&on_start, tick => \&on_tick, }, object_states => [ $logger => [ 'debug', 'warning' ], $error => { critical => 'graceful_exit', emergency => 'emergency_exit', }, ], );
La session ainsi créée va donc répondre aux événements :
_start
et tick
avec les fonctions on_start()
et on_tick()
respectivement,
debug
et warning
en appelant respectivement $logger->debug()
et
$logger->warning()
(donc des méthodes différentes du même objet),
et enfin critical
et emergency
en appelant respectivement
$error->graceful_exit()
et $error->emergency_exit()
(donc des
méthodes dont le nom diffère du message reçu).
Tout ceci laisse donc énormément de place au développeur pour choisir le type de modularisation souhaité.
Vous avez très certainement remarqué que les objets POE::Session
ne
sont quasiment jamais stockés. C'est parce que POE s'occupe de les gérer
lui-même, pour plusieurs raisons.
Créer des sessions est relativement banal, et les sauvegarder puis les détruire à la main serait lourd à gérer.
De plus, POE utilise énormément de références circulaires : ce fichier appartient à telle session, telle session gère ce fichier. POE aurait des fuites de mémoire énormes s'il ne gérait pas lui-même tous ces liens et ne les détruisait donc pas plus tard.
Enfin, puisque POE est obligé de gérer ces références de toute manière, cela lui permet de savoir quand une session n'est plus nécessaire, il s'occupera donc de la passer au ramasse-miettes au bon moment.
Il est fréquent que les sessions s'envoient des messages à elles-mêmes,
pour différentes raisons. Il est possible de faire cela en récupérant
$_[SESSION]
qui est la session courante, et de lui post()
-er un
message :
$_[KERNEL]->post( $_[SESSION], $event, @args );
Mais cela est plutôt maladroit pour une opération finalement assez
banale. POE propose donc une méthode yield()
qui fait exactement cela
en beaucoup plus simple :
$_[KERNEL]->yield( $event, @args );
Puisqu'on parle des messages, ceux-ci sont traités dans un ordre FIFO, c'est-à-dire que les premiers messages lancés seront les premiers livrés.
POE permet de remplacer la heap par n'importe quel scalaire au moment de
la création. Ceci peut être intéressant si on souhaite utiliser un
objet afin de vraiment encapsuler l'accès aux données de la session avec
des accesseurs comme get_nb()
et set_nb()
(pour notre exemple
ci-dessus). Il faut dans ce cas utiliser le paramètre heap
lors de
l'appel à create()
:
POE::Session->create( # ... heap => My::Heap->new( ... ), # ... );
_start()
L'événement _start
notifie à une session qu'elle a démarrée. POE envoie
ce message automatiquement car il est très fréquent d'initialiser une
session.
Le gestionnaire de l'événement _start()
sera déclenché immédiatement.
Son but est d'initialiser la session et de créer des choses qui
appartiendront à la session. En effet, la session va très certainement
s'arrêter immédiatement si elle ne gère rien après le retour du callback
de _start()
, car POE la passera au ramasse-miettes.
Les sessions définissent lors de leur création les événements auxquels elles vont réagir. Lorsqu'une session reçoit par la suite un événement non géré, celui-ci sera discrètement ignoré.
Ce n'est en effet pas une erreur d'envoyer un message à une session qui ne sait pas le gérer. POE va donc le supprimer sans avertissement, ce qui permet à des programmes modulaires de fonctionner quand des sessions optionnelles ne sont pas présentes. Par exemple, on peut imaginer un programme contenant une session de debug qui loggue des informations dans un fichier pendant le développement. Mais quand la session est omise (dans le programme de production), les événements contenant les messages de debug sont tout simplement éliminés, et le programme fonctionne correctement.
Cependant, les messages non traités peuvent être une source d'erreur
énervante. Il est en effet banal de mal taper le nom d'un événement, ou
d'oublier d'ajouter son handler dans le constructeur de la session. Dans
ces cas, les programmes cessent de fonctionner normalement, sans qu'on
sache pourquoi. On peut changer POE pour qu'il traite certaines choses
optionnelles en tant qu'erreur, ce qui inclut les situations où des
événements sont éliminés silencieusement. Il suffit pour cela de
définir une nouvelle fonction constante dans le module POE::Kernel
,
mais avant que celui-ci soit chargé :
#!/usr/bin/env perl use strict; use warnings; sub POE::Kernel::ASSERT_DEFAULT () { 1 } use POE; POE::Session->create( inline_states => { _start => sub { $_[KERNEL]->yield( 'unknown' ); }, } ); POE::Kernel->run(); exit;
La fonction ASSERT_DEFAULT
déclenche les assertions pour tous les cas
marginaux de POE. Il déclenche la validation des paramètres et des
valeurs de retour, ainsi que la détection avancée de fuites de mémoire
dans POE. Bien sûr, cela va ralentir le programme, mais cela peut toujours
être intéressant lors des phases de développement et déboguage.
En lançant l'exemple ci-dessus, le résultat ne se fait pas attendre :
$ ./unknown.pl a 'unknown' event was sent from unknown.pl at 8 to session 2 (POE::Session=ARRAY(0x808b8e8)) but session 2 (POE::Session=ARRAY(0x808b8e8)) has neither a handler for it nor one for _default
Comme le message d'erreur ci-dessus le précise, on peut aussi fournir à
la session un gestionnaire pour l'événement _default
, qui capturera
tous les messages non nommés durant la création de la session. Dans le
gestionnaire d'événement associé, $_[ARG0]
correspond au nom du
message envoyé initialement, et $_[ARG1]
est une référence de liste
contenant les arguments initiaux du message envoyé. Cela permet de créer
un gestionnaire fourre-tout, qui peut être utile pour faire son propre
dispatch.
Les signaux POSIX sont aussi gérés via POE. Il suffit pour cela
d'enregistrer avec la méthode sig()
du kernel les signaux qu'on
souhaite traiter en association avec le message qui sera généré lors de
cet événement, et le kernel enverra ce signal à toutes les sessions. Le
paramètre $_[ARG0]
contiendra le nom du signal.
$_[KERNEL]->sig( USR1 => 'usr1' );
Les signaux dits terminaux (HUP
, INT
, KILL
, QUIT
et TERM
)
arrêtent le programme s'ils ne sont pas gérés. Pour cela, ils doivent
utiliser la méthode sig_handled()
du kernel pour signifier que le
signal a été traité.
Du fait de la nature de POE, les programmes ne vont fonctionner correctement que si leurs composants coopèrent entre eux.
Une session qui va appeler sleep()
ou toute autre construction qui ne
revient pas immédiatement va mettre l'ensemble du programme en pause. Ainsi, les
appels à sleep()
doivent être remplacés avec des appels à
delay_set()
, qui appelleront un événement déterminé, avec des
arguments éventuels :
$_[KERNEL]->delay_set( 'tick', 10, @args ); # wait 10 secs
Il est aussi possible de positionner une alarme à une date donnée :
$_[KERNEL]->alarm_set( 'alarm', $epoch, @args );
De même, une longue boucle peut empêcher les autres sessions de recevoir leur quota de temps. Pour cela, il faut donc les transformer de façon à ce que la boucle soit coupée en petits morceaux :
sub long_loop_start { print "start...\n"; $_[HEAP]->{i} = 0; $_[KERNEL]->yield('long_loop'); } sub long_loop { my $h = $_[HEAP]; if ( $h->{i} < 1_000_000 ) { # ... $h->{i}++; $_[KERNEL]->yield('long_loop'); } else { print "done.\n"; } }
Bien sûr, appeler un nouvel événement pour chaque itération va énormément ralentir la boucle. Dans ce cas, une solution intermédiaire consiste à faire plusieurs itérations dans le même événement. Par exemple, avec 100 itérations par événement :
sub long_loop { my $h = $_[HEAP]; my $i = 0; while ( $i++ < 100 && $h->{i} < 1_000_000 ) { # ... $h->{i}++; } if ( $h->{i} < 1_000_000 ) { $_[KERNEL]->yield('long_loop'); } else { print "done.\n"; } }
Ainsi, la boucle devrait prendre beaucoup moins de temps, tout en permettant aux autres sessions (ou aux autres tâches de la session) d'avoir une chance de s'exécuter.
C'est la même chose pour les appels à die()
ou exit()
: la tâche
faisant cet appel doit bien être consciente qu'elle va arrêter toutes
les sessions et les tâches en cours, et non pas elle-même uniquement. Il
y a des chances que cela ne soit pas l'effet désiré !
POE fournit sa propre boucle d'événements, basée sur des appels à
select()
et écrite entièrement en Perl.
Cependant, POE peut très bien s'adapter à d'autres boucles d'événements.
En particulier, les toolkits graphiques utilisent en interne une boucle
d'événements qui leur est propre. C'est ainsi que POE peut s'interfacer
avec Gtk, Glib (pour les interfaces Gtk2), Tk, ou Wx quand l'un de ces
modules est chargé avant POE::Kernel
:
use Gtk; use POE;
POE::Kernel
détecte que Gtk a été chargé, et il chargera
automatiquement la bonne interface pour l'utiliser. L'interface publique
de POE demeure bien sûr la même, quelle que soit la boucle d'événements
chargée et utilisée.
Note : ceci est aussi valable pour d'autres boucles d'événements tels
que Event.pm
pour les programmes basés sur Event ou bien IO::Poll
,
qui est potentiellement plus efficace que la boucle par défaut de POE
basée sur select()
, en particulier pour les clients et serveurs
fortement sollicités.
Il est aussi possible de charger une autre boucle d'événements avec la construction suivante :
use POE qw[ Loop::Gtk ];
ou alors :
use POE::Kernel { loop => "Gtk" }; use POE::Session;
Dans le cas de Tk, qui refuse de s'exécuter si aucun widget n'est créé, POE
va créer une fenêtrer principale et l'exporter via $poe_main_window
.
POE offre aussi un mécanisme simple pour avertir de la destruction d'un
widget. Il suffit d'enregistrer via la méthode signal_ui_destroy()
le
widget à surveiller. POE lancera alors un signal UIDESTROY
lorsque le
widget sera détruit :
$heap->{gtk_toplevel_window} = Gtk::Window->new('toplevel'); $kernel->signal_ui_destroy( $heap->{gtk_toplevel_window} );
Voyons maintenant comment cela marche. Imaginons un simple compteur incrémenté toutes les secondes, dont on affiche la valeur.
On commence donc tout classiquement en chargeant les modules souhaités :
#!/usr/bin/env perl use strict; use warnings; use Tk; use POE;
On charge le module Tk avec POE, ce qui va donc changer la boucle d'événements interne de POE, ainsi qu'on l'a vu précédemment.
On continue en créant une session qui va répondre (en plus de
l'initialisation) à l'événement tick
qui sera lancé toutes les
secondes. Puis on lance la boucle interne de POE, qui ne reviendra
que pour arrêter le programme :
POE::Session->create( inline_states => { _start => \&on_start, tick => \&on_tick, } ); POE::Kernel->run(); exit;
La partie initialisation va créer l'interface graphique, en utilisant
$poe_main_window
. Dans notre cas simpliste, on crée deux labels :
le premier servira d'en-tête, tandis que le second affichera le contenu
du compteur stocké dans la heap :
sub on_start { my ($k, $h) = @_[KERNEL, HEAP]; $poe_main_window->Label( -text => 'counter' )->pack; $poe_main_window->Label( -textvariable => \$h->{counter} )->pack; $k->delay_set('tick', 1); }
Bien sûr, la dernière action de la fonction d'initialisation est de
démarrer une alarme qui sera délivrée une seconde plus tard. Cette
alarme sera traitée dans la fonction on_tick()
qui incrémentera le
compteur et réinitialisera l'alarme. Évidemment, comme le second label
affiche le contenu de la variable incrémentée, on n'a pas besoin
de demander le rafraîchissement du label.
sub on_tick { $_[HEAP]->{counter}++; $_[KERNEL]->delay_set('tick', 1); }
Ce qui donne le programme suivant :
#!/usr/bin/env perl use strict; use warnings; use Tk; use POE; POE::Session->create( inline_states => { _start => \&on_start, tick => \&on_tick, } ); POE::Kernel->run(); exit; sub on_start { my ($k, $h) = @_[KERNEL, HEAP]; $poe_main_window->Label( -text => 'counter' )->pack; $poe_main_window->Label( -textvariable => \$h->{counter} )->pack; $k->delay_set('tick', 1); } sub on_tick { $_[HEAP]->{counter}++; $_[KERNEL]->delay_set('tick', 1); }
Bien sûr, ce programme est très simple et ne propose aucune interactivité. Voyons comment faire pour ajouter des widgets qui permettront à l'utilisateur d'influer sur le fonctionnement du programme.
Reprenons notre exemple ci-dessus, et ajoutons un bouton qui permet de réinitialiser le compteur. La majorité du programme reste la même, bien sûr, mais il faut rajouter quelques lignes.
Cela commence lors de la création de la session, qui va maintenant
réagir à un nouvel événement zero
envoyé lorsque l'utilisateur
souhaite réinitialiser le compteur :
POE::Session->create( inline_states => { _start => \&on_start, tick => \&on_tick, zero => \&on_zero, } );
La fonction d'initialisation, responsable de la construction de
l'interface, va donc créer un bouton. Et c'est là qu'on va voir
une différence avec un programme Tk : on ne va pas spécifier un
callback directement, callback qui ne serait pas intégré à POE et qui ne
respecterait pas l'ordonnancement de POE. À la place, on va
demander à la session de se poster un événement qui sera acheminé par
POE et donc géré comme un événement normal. Ceci est fait grâce à la
méthode postback()
de la session courante. On obtient donc le code
suivant :
sub on_start { my ($k, $s, $h) = @_[KERNEL, SESSION, HEAP]; # ... création des labels $poe_main_window->Button( -text => 'zero', -command => $s->postback('zero') )->pack; # ... fin de l'initialisation }
On rajoute donc tout simplement un bouton 'zero' qui, lorsqu'il sera pressé, lancera un événement POE nommé 'zero'. Il sera traité dans sa fonction associée, et son seul rôle sera de remettre le compteur stocké sur la heap à zéro :
sub on_zero { $_[HEAP]->{counter} = 0; }
Voilà donc notre programme interactif :
#!/usr/bin/env perl use strict; use warnings; use Tk; use POE; POE::Session->create( inline_states => { _start => \&on_start, tick => \&on_tick, zero => \&on_zero, } ); POE::Kernel->run(); exit; sub on_start { my ($k, $h, $s) = @_[KERNEL, HEAP, SESSION]; $poe_main_window->Label( -text => 'counter' )->pack; $poe_main_window->Label( -textvariable => \$h->{counter} )->pack; $poe_main_window->Button( -text => 'zero', -command => $s->postback('zero') )->pack; $k->delay_set('tick', 1); } sub on_tick { $_[HEAP]->{counter}++; $_[KERNEL]->delay_set('tick', 1); } sub on_zero { $_[HEAP]->{counter} = 0; }
On se retrouve, grâce à POE, avec un code facile à lire et modulaire. Il est ainsi aisé de créer des fenêtres différentes qui seront gérées par des sessions dédiées, tout en permettant à des sessions non-graphiques de continuer à gérer en arrière-plan des connexions réseau ou toute autre fonction qui n'a rien à voir avec l'interface graphique.
Rien qu'avec ce qu'on a vu ci-dessus, POE est un framework de choix pour bien des applications. Mais POE ne s'arrête pas là et permet beaucoup plus, en fournissant un moyen de distribuer des applications sur plusieurs machines - ou sur la même machine mais dans des processus différents.
POE permet en fait la communication inter-kernel (ou IKC, Inter
Kernel Communication). Ceci est implémenté dans POE::Component::IKC
qui fournit tout ce qu'il faut.
Pour comprendre un peu la puissance d'IKC, reprenons notre session
logger
vue précédemment. Mais cette fois-ci, on va
l'implémenter dans son propre processus, accessible de n'importe quelle
session utilisant IKC.
Outre les en-têtes habituels, on va charger la partie serveur
d'IKC via POE::Component::IKC::Server
:
#!/usr/bin/env perl use strict; use warnings; use POE; use POE::Component::IKC::Server;
Cela va permettre de créer un serveur IKC, qui sera en fait un
composant POE utilisant sa propre session. On fournit l'IP (ou le
nom), le port sur lequel écouter, ainsi que le nom du serveur qui
servira à retrouver ce kernel via IKC. Dans cet exemple, on prend le
nom my-server
:
POE::Component::IKC::Server->spawn( ip => 'localhost', port => 17000, name => 'my-server', );
On crée ensuite notre session logger qui va s'occuper de logger les messages. Elle ne diffère pas de celle qu'on a créé dans l'exemple ci-dessus. On peut ensuite lancer le kernel de la manière standard :
POE::Session->create( inline_states => { _start => \&on_start, debug => \&on_debug, } ); POE::Kernel->run(); exit;
Pour garder cet exemple aussi simple que possible, la fonction
on_debug()
ne change pas et fera juste un warn()
avec les
paramètres reçus. La fonction on_start()
, elle non plus, ne change
pas tellement. La seule chose différente consiste à publier via IKC
notre alias de session ainsi que les messages acceptés dans un contexte
de communication inter-kernel :
sub on_start { my $k = $_[KERNEL]; $k->alias_set('logger'); $k->call( IKC => publish => 'logger', ['debug'] ) } sub on_debug { warn @_[ARG0 .. $#_]; }
On a donc un serveur en bonne et due forme :
#!/usr/bin/env perl use strict; use warnings; use POE; use POE::Component::IKC::Server; POE::Component::IKC::Server->spawn( ip => 'localhost', port => 17000, name => 'my-server', ); POE::Session->create( inline_states => { _start => \&on_start, debug => \&on_debug, } ); POE::Kernel->run(); exit; sub on_start { my $k = $_[KERNEL]; $k->alias_set('logger'); $k->call( IKC => publish => 'logger', ['debug'] ) } sub on_debug { warn @_[ARG0 .. $#_]; }
Le client n'est pas tellement plus dur à écrire. Cette fois-ci on va charger la partie client d'IKC :
#!/usr/bin/env perl use strict; use warnings; use POE; use POE::Component::IKC::Client;
On va ensuite créer un client IKC qui va se connecter sur le serveur, en lui fournissant un callback lorsque la connexion sera effective. On ne crée pas d'autre session pour l'instant, car cela ne sert à rien tant que le client n'est pas connecté.
POE::Component::IKC::Client->spawn( host => 'localhost', port => 17000, name => "client-$$", on_connect => \&on_connect, ); POE::Kernel->run(); exit;
Par contre, lorsque le client s'est connecté, on peut maintenant créer notre session, qui va envoyer un message de debug toutes les secondes. La création de session est tout à fait classique :
sub on_connect { POE::Session->create( inline_states => { _start => \&on_start, tick => \&on_tick, } ); }
De même, la fonction on_start()
ne change pas beaucoup, mis à part la
souscription au service IKC publié par le serveur. Les souscriptions à
des services POE distants sont du type
poe://<kernel>/<session>/<événement>
. Bien
sûr, kernel
doit correspondre à un nom de kernel tel que publié via
le paramètre name
, session
doit correspondre à un nom de session
et de même pour événement
. On peut utiliser des jokers à la place,
tel que poe://<kernel>/logger/*
.
sub on_start { my $k = $_[KERNEL]; $_[HEAP]->{nb} = 0; $k->delay_set('tick', 1); $k->post('IKC', 'subscribe', [ 'poe://my-server/logger/debug' ]); }
Une fois souscrit à cet événement, on peut poster des événements de manière transparente de la façon suivante :
sub on_tick { my ($k, $h) = @_[KERNEL, HEAP]; $k->post('poe://my-server/logger', 'debug', $h->{nb}++ ); $k->delay_set('tick', 1); }
Notre client complet est donc :
#!/usr/bin/env perl use strict; use warnings; use POE; use POE::Component::IKC::Client; POE::Component::IKC::Client->spawn( host => 'localhost', port => 17000, name => "client-$$", on_connect => \&on_connect, ); POE::Kernel->run(); exit; sub on_connect { POE::Session->create( inline_states => { _start => \&on_start, tick => \&on_tick, } ); } sub on_start { my $k = $_[KERNEL]; $_[HEAP]->{nb} = 0; $k->delay_set('tick', 1); $k->post('IKC', 'subscribe', [ 'poe://my-server/logger/debug' ]); } sub on_tick { my ($k, $h) = @_[KERNEL, HEAP]; $k->post('poe://my-server/logger', 'debug', $h->{nb}++ ); $k->delay_set('tick', 1); }
À noter que ce qui passe sur le réseau est automatiquement sérialisé et
dé-sérialisé, ce qui fait qu'on peut passer de manière transparente
toute structure de donnée évoluée. Cependant, la sérialisation se
faisant avec Data::Dumper
, l'eval()
correspondant peut poser des
problèmes de sécurité.
IKC permet beaucoup d'autres choses, dont voici une liste non exhaustive :
Le passage d'événements retours, avec ou sans RSVP (i.e., événement retour différé dans le temps). Intéressant pour avoir un mécanisme de procédures distantes type RPC.
Une communication peer-to-peer pour que le kernel serveur soit à l'origine des communications. Intéressant pour avoir un scheduler qui distribue des tâches à différents clients connectés...
Une surveillance intégrée des kernels distants pour être averti lorsque des kernels se connectent, ou déconnectent, etc.
Mais tout ceci va bien au-delà de cette introduction à POE, et je vous invite à lire la documentation associée à IKC [2] pour découvrir toutes les possibilités offertes.
Bien qu'on n'ait fait que survoler les mécanismes associés à POE, on peut d'ores et déjà s'apercevoir que le framework est puissant et bien pensé. Rocco Caputo (l'auteur de POE) nous livre donc un monument d'ingénierie extrêmement intéressant et plaisant à utiliser.
Je ne peux que vous recommander de l'essayer, et de l'adopter pour simplifier vos applications.
Copyright © Les Mongueurs de Perl, 2001-2011
pour le site.
Les auteurs conservent le copyright de leurs articles.