check out the forum: your browser does not support <object> tag /_\

Trucs et astuces pour la programmation réseau



1. Communication entre processus-esclave et processus-maître

Le principal défaut du modèle "multiprocessus" pour les serveurs multi-connexion est qu'il est difficile d'échanger des informations entre les différents processus. Il existe bien sûr une panoplie de techniques de communication inter-processus (IPC) fournies par Unix, mais elles nécessitent souvent une mise en place assez compliquée (notamment de garantir la synchronisation à une mémoire partagée, etc.)

Une alternative intéressante est de se servir à nouveau des sockets pour communiquer entre les différents processus.


#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>

int main() {

  int peer[2];
  int pid;
 
  if (socketpair(PF_UNIX,SOCK_STREAM,0,peer))
    error("cannot create sockets")
;

  if (pid=fork()) {
   
    char buffer[256];
    sprintf(buffer,"processus parent %i a créé le fils %i", getpid(),pid);
    fprintf(stderr,"%s\n",buffer);
    close(peer[1]);
    if (send(peer[0],buffer,strlen(buffer)+1,0)==-1) perror("dad.send");
    if (recv(peer[0],buffer,256,0)==-1) error("dad.recv");
    fprintf(stderr,"réponse du fils: [%s]\n",buffer);
    exit(0);

  } else {
    char buffer[256];
    sprintf(buffer,"processus fils %i créé par %i",getpid(), getppid());
    fprintf(stderr,"%s\n",buffer);
    close(peer[0]);
    if (send(peer[1],buffer,strlen(buffer)+1,0)==-1) error("son.send");
    if (recv(peer[1],buffer,256,0)==-1) error("son.recv");
    fprintf(stderr,"message du père:[%s]\n",buffer);
    exit(0);
  }
}



Principe

Avant  de lancer un nouveau processus, le processus-maître construit 2 socket interconnectés à l'aide de socketpair(...,peer). Une fois cette commande effectuée, toutes les données envoyées sur peer[0] seront disponibles sur peer[1] et réciproquement. L'astuce, c'est que ces liaisons restent valables à travers les frontières des processus une fois le fork() effectué. Puisqu'une seule des connexions créées (voir figures ci-dessous) est nécessaire, chaque processus va fermer l'un des deux.


Première Technique: le Socket de Commande

Une fois ces opérations achevées, on dispose d'une connexion bidirectionnelle entre le maître et l'esclave sur laquelle il est possible de développer un petit protocole maison (par exemple "Bye" pour signaler la fin du processus esclave, "Ok index.html" pour signaler qu'une requête a été correctement traitée, etc.)

Toutefois, si on utilise réellement la connexion bidirectionnelle, le code du maître devient presqu'aussi compliqué que s'il s'agissait d'un serveur mono-processus (avec la difficulté d'ajouter/retirer dynamiquement des socket à l'ensemble monitoré par select(), etc.). On a donc tout intérêt à n'exploiter que le sens maître->esclave pour transmettre aux processus esclaves les informations nécessaires à leur travail.

Une fois cette communication en place, on peut alors utiliser d'autres moyens tels que la mémoire de statut partagée ou les signaux pour indiquer au maître le succès/l'échec d'une transmission.


Alternative: le Socket de LOG

Il y a aussi moyen de simplifier un peu les choses dans le cas où les esclaves n'ont pas besoin de recevoir de messages du maître: ouvrir un seul socket pair  pour tous les esclaves ...


dans cette approche, il n'y a plus qu'un seul appel à socket pair, et les données envoyées par chaque esclave aboutissent sur le même socket.


#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>


int main() {
  char buffer[256];
  int peer[2];
  int pid;
 
  if (socketpair(PF_UNIX,SOCK_STREAM,0,peer))
    error("cannot create sockets")
;

  for (i=0;i<3;i++) {

    if (pid=fork()) {
  } else {
    int j;
    char buffer[256];
    sprintf(buffer,"processus fils %i créé par %i",getpid(), getppid());
    fprintf(stderr,"%s\n",buffer);
    close(peer[0]);
    for(j=0;j<5;j++) {
       if (send(peer[1],buffer,strlen(buffer)+1,0)==-1)
          perror("son.send");
       sleep(1);
    }
    send(peer[1],"bye",4,0);
    exit(0);
  }
  while(i) {
    if (recv(peer[0],buffer,256,0)==-1) {
      perror("dad.recv");
      exit(-1);
    }

    if (strcmp(buffer,"bye")==0) i--;

    fprintf(stderr,"réponse du fils: [%s]\n",buffer);
 }
}


tout message envoyé par l'un des esclaves sur peer[1] sera reçu sur peer[0]. En revanche, si un message est envoyé sur peer[0] par le maître, seul un des esclaves (en fonction du scheduler) le recevra ... pire, il est possible (si le buffer de réception de l'esclave est trop petit) que le message en question soit reçu en partie par un esclave et que la fin du message soit reçu par un autre...

On a donc tout intérêt à n'envoyer sur ce socket que des messages simples et court qui seront lu en une seule fois par le processus père et qui mentionnent l'identité du processus-esclave.


enum statusType {
  SLAVE_DONE,
  SLAVE_FAIL,
  SLAVE_TERMINATED,
};

struct
statusReport {
  statusType type;
  unsigned slaveID;
  int extra_data;
};

void report(type, id, extra)
{
  struct statusReport sr={type, id, extra};
  send(tomaster,&sr,sizeof(sr),0);
}



Parler aux esclaves ...

Finalement, que faire si on désire avoir une communication bidirectionnelle entre maître et esclaves, mais sans devoir écouter tous les esclaves en permanence ?

La solution la plus simple est probablement de combiner les 2 approches précédemment évoquées, et d'avoir
  1. un canal de communication N->1 qui permet à n'importe quel esclave d'envoyer un message au maître
  2. N canaux de communication 1<->1 qui permet une communication bidirectionnele entre le maître et n'importe lequel des esclaves.
On peut alors permettre au maître de n'écouter que le canal "N->1", qui ne servira plus à transmettre de véritables messages des esclaves, mais uniquement à renseigner au maître lequel des esclaves veut lui parler, ou plus précisément, le numéro du socket sur lequel un message d'un esclave est disponible.



2. sockets en mode "texte"

Il est particulièrement pénible de devoir décoder un flux 'ligne par ligne' en se servant uniquement des primitives send() et recv() de l'API socket ... Entre autre, comment être sûr qu'une ligne commençant dans un bloc fourni par recv() ne se poursuit pas sur le bloc suivant ?
La seule manière d'être à couvert, c'est de rajouter une couche par dessus le socket "cru" destiné à stocker temporairement les données reçues par send/recv et qui ne renverrait que des lignes entières ...

Difficile, me direz-vous ? même pas ... c'est tout simplement dans la bibliothèque standard (stdio, pour être plus précis).


#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>


void processing(int socket_fd)
{
  int len;
  char buffer[256];
  FILE* sp=fdopen(socket_fd,"r");
  while(!feof(sp)) {
     int len=strlen(fgets(buffer,256,sp));
     // n'importe quoi
  }
}



L'appel à fdopen() permet de construire un pointeur de fichier (manipulé par les fonction de la bibliothèques standard comme fread, fwrite, fgets, ...) à partir du descripteur de fichier (utilisé par les appels-système unix tels que read, write, close, recv ...). Le deuxième argument spécifie le sens désiré pour le FILE*: lecture ("r") ou écriture ("w"). Evitez soigneusement d'ouvrir votre FILE* avec le mode "rw".

opération
descripteur de fichier fd
FILE* sp
plus de données ?
recv(fd,...)==0
feof(sp)
lire N bytes de données brutes (au plus)
recv(fd,buffer,N,0)
fread(buffer,N,1,sp)
lire N bytes de données (ou rien)
recv(fd,buffer,N,MSG_WAITALL)
fread(buffer,1,N,sp)
envoyer une chaîne formatée
snprintf(buffer,N,"format",...);
send(fd,buffer,strlen(buffer),0)

fprintf(sp,"format",...)
lire ligne par ligne
-- pas de moyen immédiat --
fgets(buffer,N,sp);
décodage avancé
-- pas de moyen immédiat --
fscanf(sp,"format",...)

remarque
la commande "fprintf" n'enverra pas le caractère de fin de chaîne ('\0') sur le socket, alors que "snprintf" l'écrira dans le buffer. Dans la plupart des protocoles (SMTP, POP, FTP, HTTP, ...) il n'y a normalement aucun caractère de fin de chaîne dans les commandes

Attention!

fflush
Les fonctions de la bibliothèque standard travaillent à l'aide d'un tampon intermédiaire qu'elles ne vident "que quand elles le veulent bien" (en fait, quand le buffer est plein) ... Avant de s'inquiéter de savoir où ont disparu les bytes que vous avez envoyé sur un FILE*, assurez-vous que vous avez correctement saupoudré votre code de fflush(sp), visant à forcer la bibliothèque à envoyer tous les bytes restant dans ses buffers.
Et au passage, fflush() n'a aucun effet sur un fichier en lecture ;)
"r" vs "w"
Il est normalement nécessaire de forcer une synchronisation (à l'aide d'un fflush) entre tout accès en lecture et en écriture sur un fichier géré par stdio.h. Toutefois, il est fortement conseillé, dans le cas des sockets, d'ouvrir deux FILE* séparés si le socket doit être accédé à la fois en lecture et en écriture.
stdio & send/recv
une fois que le FILE* a été créé, il est fortement déconseillé de continuer à utiliser les commandes send() et recv() sur le descripteur de fichier. Ceci, principalement pour éviter que des données que l'on désirerait lire à l'aide de recv() n'ait déjà été lues par la bibliothèque (on risquerait donc de louper des bytes)



3. Lecture intelligente sur un socket

Que fait fscanf() ?

SYNOPSIS
          #include <stdio.h>
       int scanf(const char *format, ...);
       int fscanf(FILE *stream, const char *format, ...);
       int sscanf(const char *str, const char *format, ...);

       #include <stdarg.h>
       int vscanf(const char *format, va_list ap);
       int vsscanf(const char *str, const char *format, va_list ap);
       int vfscanf(FILE *stream, const char *format, va_list ap);

DESCRIPTION
The  scanf  family  of  functions scans input according to a format as described below. This format may contain conversion specifiers; the results from  such  conversions,  if any, are stored through the pointer arguments.  The scanf function reads input from the standard input stream stdin, fscanf reads input from the  stream  pointer  stream,  and sscanf reads its input from the character string pointed to by str.


tout comme printf  reçoit un format lui définissant comment les données doivent être produites en sortie, fscanf reçoit un format qui va lui décrire comment décoder une entrée pour en stocker le contenu dans des variables (principalement des chaines, des entiers ou des nombres flottants).
Par exemple, la saisie au clavier d'un numéro de version (majeur.mineur) pourrait être réalisée par


int ver_minor, ver_major;
scanf("%d.%d",&ver_minor, &ver_major);




Attention, scanf nécessite les adresses des variables dans lesquelles il devra écrire, et pas le contenu de ces variables. un opérateur "&" est donc nécessaire.

Comment l'utiliser ?


En pratique, le formattage %s montre rapidement ses limites: il s'arrête automatiquement après un séparateur (espace, fin de ligne, tabulation, etc.) et, plus grave, suppose que la longueur du tableau qui reçoit le mot est suffisante. Ce qui évidemment en opposition avec le 5° commandement du programmeur C:
"Thou shalt check the array bounds of all strings (indeed, all arrays), for surely where thou typest `foo' someone someday shall type `supercalifragilisticexpialidocious'."
En d'autres termes, on se dirige tout droit vers un segfault dû à un buffer overflow.
En revanche, il existe un "modifieur" qui permet de forcer la bibliothèque à allouer elle-même un tableau de taille suffisante pour allouer le mot.


char* word;
scanf("hello %as",&word);



Prenons la ligne de requête du protocole HTTP (GET <url> HTTP/1.1). Puisque l'URL ne peut pas comporter de caractère d'espacement ni aucuns des séparateurs par défaut, il nous suffira d'écrire:


httprequest(FILE* sock)
{
   char* url;
   int minor;

   if (fscanf(sock,"GET %as HTTP/%d.%d")==3) {
      // on a une requête GET correcte vers URL
   }
}




Et si je ne veux pas "un mot" ?

Il peut arriver que l'on doive lire plusieurs mots, ou un nombre de caractères variables qui ne va pas jusqu'à la fin du mot. un exemple typique apparaît dans le code html "<a href="alink.html">a link</a>". La chaîne de formattage "<a href=\"%s\">%s</a>" ne donnerait rien, parce qu'il n'y a pas d'espace entre " et >. On peut évidemment tenter de tricher et considérer comme "conforme" uniquement les liens qui ont la forme "<a href= " ... " > ... </a>".

La véritable solution consiste en fait à utiliser des ensembles de caractères pour définir nos formats (une version édulcorée des expressions régulières couramment utilisées dans les langages et outils de manipulation de texte tels que perl, grep et les autres). On peut en effet découper la balise '<a>' comme suit:
"<a href="" - n'importe quel nombre de caractères autres que " > et < - "">" - n'import quel nombre de caractères autre que < - "</a>".

ce qui se traduit en l'expression régulière "<a href=\"[^\"><]*\">[^<]*</a>". la structure [...] exprime une classe de caractères et [^...] la classe opposée (tous les caractères ne faisant pas partie de [...]). Il reste à passer de l'expression régulière au format pour fscanf, ce qui ne pourra malheureusement être obtenu qu'au prix d'une petite restriction: alors que [...]* accepte la chaîne vide (en d'autres termes, <a href=""> aurait été valide), fscanf ne l'accepte pas. Si cela constitue une réelle contrainte pour votre programme, il faudra passer à une véritable bibliothèque de gestion des expressions régulières, mais cela sort du propos.


html_link(FILE* sock)
{
   char* href;
   char* text;

   if (fscanf(sock,"<a href=\"%a[^\"><]\">%a[^<]</a>",&href,&text)==3) {
      // on a un lien correct dans href et text :)
   }
}




Petit piège à éviter ...


L'utilisation de fscanf peut s'avérer particulièrement efficace pour obtenir un code compact capable de traiter rapidement des lignes contenant plusieurs paramètres, comme le montre le programme en annexe remote-gethostbyname.c.  Il faut cependant prendre garde au fait que si le contenu du socket correspond partiellement au format donné, les bytes lus sont retirés du tampon interne du FILE*. On ne peut donc pas construire un choix par


http_request(FILE* sock)
{
   char* url=NULL;

   if (fscanf(sock,"GET %as HTTP/1.1",&url)==3) {
      http_process_get_request(url);

   } else if(fscanf(sock,"PUT %as HTTP/1.1",&url) {
      http_process_put_request(url);

   }
else if(fscanf(sock,"POST %as HTTP/1.1",&url) {
      http_process_post_request(url);

   }
else if(fscanf(sock,"DELETE %as HTTP/1.1",&url) {
      http_process_delete_request(url);

   }

   if(url) free(url);

}



On peut cependant s'en tirer par un 'scanf' en cascade: dans un premier temps, nous allons lire toute la lignefscanf, puis effectuer des sscanf sur cette chaîne. L'avantage par rapport à un fgets ou un recv est qu'il n'y a pas besoin d'estimer la taille maximale de la ligne a priori.

Une autre possibilité ici serait de lire le type de requête comme un %as également.


4. Mémoire de Statut Partagée

Dans l'absolu, l'utilisation de la mémoire partagée nécessite pas mal de tact et de sémaphores pour fonctionner correctement. Il existe malgré tout un cas de figure dans lequel les choses deviennent beaucoup plus simple: le statut partagé.

On peut reconnaître ce cas de figure aux propriétés suivantes:
Imaginons par exemple que chacun des processus-esclaves doive indiquer le nombre de tâches qu'il a traitées et le nombre d'erreurs qu'il a rencontrées.
On est donc bien dans le cadre d'une mémoire de statut. En revanche, si les esclaves devaient annoncer un nom de fichier sur lequel ils travaillent, ça ne fonctionne plus aussi bien: lors de la mise à jour du nom, il est possible qu'un processus lise une chaîne qui contient le début du nouveau nom et la fin de l'ancien nom ... Le même genre de problème peut se présenter si l'on dépasse la taille des mots de la machine pour des entiers (un long long sur une machine 32 bits, par exemple)

Comment procéder ?

On a deux types d'activités: le master_processing() et les slave_processing(). Chaque esclave se voit attribuer un identificateur compris entre 0 (inclus) et NB_SLAVES (exclus). Le type et la taille des données à partager est connu à l'avance et fixé à SHARED_TYPE.

On va donc utiliser les appels IPC pour créer la mémoire partagée dans le processus-maître avant le démarrage du premier des esclaves. Lors du fork, chaque processus-fils hérite des segments de mémoire de son processus-père.


SHARED_TYPE *shared=NULL;

{
    /** créer le segment */
    int id=shmget(IPC_PRIVATE,
         NB_SLAVES*sizeof(SHARED_TYPE),
         IPC_CREAT|IPC_EXCL|0600        
         );
   
    if (id<0) FATAL("cannot get shared memory");

    /** attacher à ce processus */
    shared=shmat(id,NULL,0);

    /** autoriser la destruction après le dernier detach */
    shmctl(id, IPC_RMID, NULL);
}



Kékidi ??

Une fois créé, le segment n'est encore nulle part, il est nécessaire de l'attacher quelque-part dans la mémoire du processus. La valeur NULL comme deuxième argument laisse l'OS trouver un emplacement approprié et retourne un pointeur vers cet emplacement que nous conservons dans shared.

Finalement il faut savoir qu'un segment partagé n'est libéré automatiquement par le système que lorsque les deux conditions suivantes sont remplies:
Afin de garantir que le segment sera libéré à la fin du programme (les segments de mémoire partagée étant une ressource rare sur un système Unix), on peut donc soit tenter de détecter la mort du dernier processus l'utilisant pour commander la destruction, soit autoriser d'office la destruction dès sa création.

Et après ?

Chaque esclave utilisera shared[id] pour accéder à 'sa part' de la mémoire partagée. l'identificateur est passé lors de la construction des esclaves:


pid_t all_pids[NB_SLAVES];

void slave_processing(int id);
void master_processing(void);

{
  int i;
  for (i=0;i<NB_SLAVES;i++) {
    pid_t pid=fork();
    if (pid==0) {
      segv_setup();
      slave_processing(i);
      exit(0);
    } else
      all_pids[i]=pid;
  }
  master_processing();
}