RPC – Appel de procédures à distance
Pierre Gançarski*
Octobre 2003
Département d'informatique
UFR de Mathématique et Informatique
Université Louis Pasteur – Strasbourg
3.4.3
Un exemple complet (niveau intermédiaire)
4.1 Incapacité à
communiquer le serveur
* Pierre.Gancarski@dpt-info.u-strasbg.fr
Les différentes étapes lors
d'un appel de procédure locale :
L'idée de RPC est de faire
réaliser une opération sur une machine distante.
Pour cela, la façon dont une
procédure est réalisée est modifiée :
1)
empilement de
l'adresse de retour et des paramètres de l'appel sur la pile locale
2)
positionnement
des paramètres par
a)
transfert de ces
paramètres à la machine distante ainsi que le nom de la fonction distante
b)
positionnement
des paramètres dans des registres de la machine distante
3)
appel au noyau ou
saut dans le code du processus distant devant réaliser cette opération
4)
exécution par
a)
exécution du code
distant
b)
renvoi du
résultat
5)
dépilement des variables
locales à la procédure et des paramètres d'appel
6)
écriture du
résultat à l'adresse donnée par l'adresse de retour
7)
dépilement de
cette adresse
De plus, cela doit être fait
de façon, transparente, à
l'utilisateur : celui-ci doit simplement savoir comment demander cette
opération sans s'inquiéter du passage des paramètres via le réseau (2.a et
2.b), du lancement réel de l'opération (3 à 4.b) et de la récupération du
résultat (4.b).
On utilise des subrogés (dit aussi talon ou stubs)
-
à la compilation,
un appel à une procédure distante est remplacé par un appel à un subrogé client
-
à l'appel lors de
l'exécution, les paramètres effectifs sont rassemblés dans un message, puis par
un appel au noyau, et via des sockets, ce message est transmis au noyau de la
machine distante
-
Le noyau de la
machine distante transmet ce message au subrogé serveur du processus chargé du
service qui
-
désassemble les
paramètres
-
appelle la
fonction de façon locale et classique.
-
le résultat fait
le cheminement inverse.
Ainsi, par exemple
- sur SUN on utilise le code
ASCII alors que sur IBM on utilise le code EBCDIC
- sur SPARC les entiers sont
codés de gauche à droite, c'est l'inverse sur INTEL
- la représentation des
chaînes de caractères ou des tableaux peut être différentes suivant les
compilateurs.
Deux solutions
1.
le client (resp.
le serveur) connaît la représentation utilisée par le serveur (resp. le client)
: il utilise un algorithme de recodage dans sa propre, représentation MAIS cela
nécessite de connaître TOUS les modes de représentations possibles
2.
il existe un
format "universel" (un standard) de représentation que toutes les
machines comprennent : une machine est capable de traduire SES propres
représentations en ce format universel et bien, évidement, elle est capable de
la réciproque. Ce qui va être échangé entre les machines sera toujours donné
dans ce format. Un tel format existe : XDR
pour eXternal Data Representation.
·
Pointeurs.
Que faire lorsqu'un des
paramètres est un pointeur : l'adresse pointée sur l'autre machine sera une
erreur!!
Trois solutions
1.
interdire les
pointeurs ;
2.
transmettre toute
la zone pointée MAIS cela ne peut être automatique car on peut se trouver dans
le cas d'une arborescence de pointeurs (tableaux de pointeurs, pointeurs de
pointeurs ... ) => il faut écrire une fonction qui transforme cette
arborescence en une zone compacte : utilisation des fonctions XDR.
3.
transférer uniquement
l'adresse et le type de l'objet pointé : à chaque accès, le serveur demande la
valeur pointée. S'il modifie la valeur pointée, il le signale au client pour
que celui-ci puisse mettre à jour sa mémoire.
4.
Négation C/S à suivant le cas, on transmet la zone pointée ou non
Pour qu'une machine puisse
rendre un service quel qu'il soit (RPC ou autre), il faut d'une part, que ce
service soit "nommable" (c-à-d que le client connaissent le nom de la
machine et le "nom local" sur cette machine, du service demandé) et
d'autre part, qu'il existe un processus qui réalise le service
·
Pour connaître
1.
le nom d'une
machine serveur -> utilisation de serveurs de noms.
2.
le nom local du
service. Deux cas :
·
Pour qu'un
machine connaisse le processus
-
le service n'est
pas un RPC, mais est basé sur des sockets -> utilisation d'un processus
particulier inetd chargé de lancer le processus en fonction des
indications données par le fichier /etc/inetd.conf.
telnet strear tcp nowai
root /usr/sbin/in.telnetd telnetd
-
le service est un
RPC à utilisation d'un
processus particulier rpcbind (Solaris) ou portmap (Linux) chargé
de demander la réalisation du service au processus qui s'est engagé à le
réaliser en enregistrant ce service grâce au triplet (N° de programme, N° de version, N° de
fonction) et à la fonction registerrpc.
On trouvera dans le fichier
/etc/rpc un ensemble de services "prédéfinis". Ainsi, NFS est un
ensemble de services RPC du NumProg 1000003 dont, par exemple,
la lecture a pour NumFonction 6 et l'écriture a pour NumFonction 8.
Sous UNIX, il existe trois
niveaux de RPC (haut, intermédiaire et bas) qui offrent plus ou moins de
transparence à l'utilisateur è demande une connaissance plus ou moins approfondie des mécanismes de
plus bas niveau du type sockets.
Les trois niveaux
haut : Un ensemble de fonctions est disponible via la
bibliothèque librpcsvc.a. L'utilisateur peut simplement appeler une
fonction en spécifiant la machine cible et les paramètres. Il n'est pas
possible de développer, à ce niveau, de nouveaux services.
Exemple
de service existant : rnusers(<machine
cible>)
intermédiaire : Il est possible de développer de nouvelles
applications (presque toutes) en n'ayant qu'une connaissance limitée (minimale)
de XDR et des RPC sans avoir à manipuler des sockets (qui sont sur UDP) -
écritures des fonctions sur le serveurs ;
-
enregistrement via registerrpc et mise en attente du serveur
-
appel par le client de ces fonctions via callrpc
bas : Son utilisation est beaucoup plus complexe et
suppose une bonne connaissance des sockets. Nécessaire pour les applications où
UDP est inadapté.
Les commandes
§
rpcinfo
-p [<machine cible>] : donne la
liste des services RPC enregistrés sur la machine cible (locale par défaut)
§
rpcinfo
-[ut] <machine cible> <Nprog> [<Nversion>] : appel de la procédure 0 du service UDP (pour -u) ou
TCP (pour -t) du programme Nprog sur la machine cible.
§
rpcinf
o -d <machine cible> <Nprog> [<Nversion>] : efface le service.
A ce niveau un certain nombre
de fonctions sont disponibles en standard dans la librairie Iibpcsrv.
Deux exemples de telles
fonctions
- getrpcport :donne le numéro de port correspondant au service RPC
si ce service est connu, 0 sinon
- rnusers <machine cible> : renvoie le nombre d'utilisateurs connectés sur une
machine distante.
Exemple d'utilisation
/**
Un programme qui affiche le numéro de port RPC d'un service dont le numéro est
donné **/ /* Rappel sur la structure rpcent donné dans rpcent.h
struct
rpcent {
char * r_ name ; //
nom du programme RPC
char ** aliases;
int r number; //
numéro du programme
}*/
main()
{
int num serv = 1000003, iport ;
struct rpcent * p ;
p = getrpcbynumber(num_serv) ; // retourne le rpcent du
service
printf(" Nom du service %s
\n",p->rname) ;
iport = getrpcport("ada", num_serv, 1,
iPPPRO_UDP);
printf(" Port affecté au service %d
\n",iport) ;
}
Trois
étapes :
1. écriture des fonctions
2. enregistrement du service
3. mise en attente
Écriture des fonctions (étapes 4, 7, 8)
·
Les fonctions
doivent nécessairement n'avoir qu'UN paramètre (et si possible) du type char*. Pourquoi? Tout simplement parce que lors de l'appel,
ce paramètre sera passé à XDR pour être codé. Or cet appel à XDR est
automatique : le type des paramètres est donc fixe. Idem pour le résultat.
·
XDR permet de
coder et décoder des données. Pour cela il faut définir EXPLICITEMENT les
fonctions de codage sachant qu'il existe un ensemble de fonctions de codage
prédéfinies.
Par exemple, pour coder une
structure : typedef
struct { int I ;
float F } couple
; on définit la fonction :
bool_t xdr_couple(XDR xdrp,
couple *C) { return(xdr_int(xdrp,C) && xdr_float(xdrp,C));}
Attention
-
pour une fonction
de codage pouvant être appelée automatiquement par un RPC,
·
le premier
paramètre est TOUJOURS un objet du type XDR, appelé flot XDR.
·
le second est
l'objet à coder ou décoder (dans ce dernier cas, il faut que la place mémoire
existe pour cet objet)
- certaines fonctions XDR
demandent plus de deux paramètres (par exemple : xdr_ string(XDR xdrp, char *p,
int L)). Elles ne peuvent être utilisées directement dans un
appel RPC.
Compilation Nécessite les options : -lnsl, librairie des sockets et -lrpcsvc, librairie des RPC.
Enregistrement du service (étapes 1, 2, 3) Cela consiste à signaler l'existence du service au
démon portmap ou rpcbind qui lui associe un port.
Chaque fonction doit être enregistrée individuellement par :
int registerrpc(unsigned
long <prog>,unsigned long <version>, unsigned long <proc>,
char*(*f)(), bool_t(*xdr_arg)(),bool_t(*xdr_res)() )
Par exemple la fonction
suivante
char * ADD(couple *pc) {
static
float res ;
res =
pc->I + pc->F ;
return
(char *)&res ;
}
sera enregistrée par registerrpc(32001,1,1,ADD,xdr_couple,
xdr_float) ;
Effacement d'un service Deux
possibilités :
- par programme : pmap_unset(unsigned long
<prog>, unsigned long <version>)
- par commande système : rpcinfo -d <prog>
<version>
Mise en attente du serveur
(étapes 5, 6) La fonction svc_run() met le processus serveur en attente. Cette fonction
ne finit "jamais" sauf en cas d'erreur.
Pour appeler un service RPC,
le client utilise la fonction :
callrpc(char* <machine>,
unsigned long <prog>,unsigned long <version>,
unsigned
long <proc>, bool_t(*xdr_arg)(), char * arg, bool_t(*xdr_res)(), char *
res)
Cet appel est bloquant.
Exemple
couple p = { 4 ,
9.6 } ; double res ;
callrpc ("ada ",
32001, 1, 1,xdr_ couple, (char *)p, xdr_float, (char *)r); }
/*****
Le serveur ****/ #include
<rpc/types.h> #include
<rpc/xdr.h> void
xdr_String(XDR * xdrp, char * p) { xdr_string(xdrp,
&p, 1000) ; } |
/*****
Le client ****/ #include <rpc/types.h> #include <rpc/xdr.h> void xdr_String(XDR * xdrp, char * p) { xdr-string(xdrp,
&p, 1000) ; } |
char * Reponse() { char *
uneReponse = "OK"; return
uneReponse; } |
void main(void) { char res[1000] ; callrpc("ada", 32012, 1, 1, xdr_void, NULL, xdr_String, (char *)res) != 0) //
Le résultat est dans res ===> res = "OK" } |
void main(void) { registerrpc(32012, 1, 1, Reponse, xdr_void,
xdr_String) ; svc-run()
; } |
|
Plusieurs causes possibles
d'échecs
1.
incapacité à "communiquer" avec le serveur
2.
perte de la demande
3.
perte de la réponse
4.
panne du serveur
5.
panne du client
Exemple, en cas d'évolution
du serveur, les paramètres peuvent avoir changés.
Comment le serveur va-t-il
signaler qu'il a bien reçu la requête mais qu'elle n'est pas valide :
- par la variable
"errno" : or celle-ci est locale au processus client
- par un retour égal par
exemple à -1 : mais cette valeur peut très bien être une valeur acceptable
comme résultat
èNécessité de mettre en place un mécanisme de signal ou d'exception
Utilisation de temporisateur
et ré-émission à problème du time-out. En effet, si celui-ci est trop petit, le client
va abandonner rapidement et considérer à tort dans certain cas, que le serveur
n'est pas accessible. Par contre, s'il est trop grand, il peut y avoir beaucoup
de perte de temps si le nombre de ré-émissions nécessaire pour un succès est
élevé.
Au bout d'un certain temps,
le client décide que la réponse aurait dû lui parvenir. Trois possibilités :
- il
y a eu perte réelle de la réponse
-
le serveur n'a pas fini (la réponse peut encore venir)
-
la demande s'est perdue
Deux cas à voir :
- l'opération est idempotente : elle peut être
effectuée autant de fois que nécessaire sans problème (ex : lecture de 1024
premiers caractères d'un fichier, ré-initialisation d'une variable, ...)
- l'opération n'est pas idempotente : l'exécution
modifie l'état du serveur par exemple (ex : incrément d'un variable,
è utilisation d'un marqueur de ré-émission des demandes
Deux cas :
-
le serveur a
effectué l'opération mais n'a pas eu le temps d'envoyer la réponse
§
s'il a eu le
temps de sauvegarder alors après redémarrage : il peut l'envoyer ou attendre
une ré-émission de la demande . (A noter : (1) la réponse est-elle toujours
valable? combien de temps faut-il la mémoriser? (2) cela sert aussi dans le cas
général où la réponse se perd)
§
s'il n'a pas eu
le temps (ou l'envie) de sauvegarder alors après redémarrage : il doit attendre
une ré-émission de la demande => on retrouve le problème d'avant :
l'opération est-elle idempotente?
-
le serveur
n'avait pas fini : il doit attendre une ré-émission de la demande => on
retrouve le problème d'avant l'opération est-elle idempotente ?
D'où dans le cas d'une
non-réponse pour panne, plusieurs possibilités de reprise par le client
-
"une fois au
moins" : attendre que le serveur redémarre (ou en trouver un autre) pour
renouveler la demande jusqu'à obtention d'une réponse
-
"une fois au
plus" : abandon immédiat en cas de non-réponse avec message d'erreur
-
"sans
garantie" : quand un serveur tombe en panne ; le RPC a pu être exécuté de
0 à plusieurs fois
Solution théorique : "exactement une fois" è impossible car la procédure de reprise dépend
entièrement de ce qui s'est passé avant la panne mais le client est incapable
de le déterminer.
Idéalement, le serveur
devrait être capable de réaliser une opération de façon "atomique" :
soit l'opération est totalement réalisée soit RIEN n'est fait è notion de transaction.
Envoi d'une demande puis
panne du client
-
que doit faire le
serveur de la réponse (s'il y a mémorisation cf 4.4)
-
que faire des
(petits-)fils que le client a lancé
Extermination : Le subrogé client tient un journal à jour sur disque
où il note chaque demande RPC. Après redémarrage, il "tue"
explicitement ces orphelins MAIS :
-
méthode coûteuse car nécessite des écritures sur disque
-
peut échouer en cas de petits orphelins
-
le réseau peut être cloisonné et interdit les "kill"
è méthode à proscrire
Réincarnation : On divise le temps en périodes numérotées. Quand un
client redémarre, il diffuse à toutes les machines un message qui marque le
début d'une nouvelle période. A réception toutes les exécutions
distantes sont détruites. Les réponses d'orphelins non détruits (pour une
raison X ou Y) ne seront de toute façon pas prises ne compte car datées d'une
période précédente.
Réincarnation douce : Idem, mais à réception de la nouvelle période, le serveur ne
tue les exécutions locales que pour celle où il n'arrive pas à localiser le
client
Expiration : Chaque RPC dispose d'un quantum T pour s'exécuter. Au
redémarrage du client, celui-ci attend un temps T avant de (re-)faire des
demandes. Il sait qu'après ce temps, plus aucun orphelin ne peut encore
exister.
En conclusion, il faut bien
voir que le client n'a aucune information sur la cause de non réponse!!! Il ne
peut donc choisir la meilleure solution!!