LEZIONE del 3 Dicembre

L'esempio discusso nell'ultima lezione ha evidenziato, se possibile, la pratica comune di spezzare i grossi programmi in parti più maneggevoli, di piccole dimensioni, contenute in singoli file e per i quali potrebbe essere richiesta una compilazione differenziata. Il linguaggio C favorisce questa pratica con l'impiego degli header file, caratterizzati dall'estensione .h, dei file di codice sorgente, la cui estensione è .c oppure .C.

Analogamente, l'impiego delle librerie permette di separare le diverse parti di codice eseguibile, raggruppando le funzioni in moduli riusabili da applicazione diverse. Alcune librerie sono fornite dal sistema altre possono essere implementate durante la fase realizzativa dell'applicazione. In entrambi i casi, sia i file sorgente del software che le librerie potrebbero richiedere modifiche più o meno sostanziali, andando ad incidere sulla vita del software. Per poter aggiornare consistentemente l'applicazione risulta utile disporre di strumenti in grado di determinare quali parti richiedono una ricompilazione e quali nuove librerie devono essere impiegate. Il comando make, come si è già visto, risolve egregiamente situazioni di questo tipo.

Esempio di Sincronizzazione

Facendo riferimento alle funzioni e procedure definite in precedenza, viene proposto un esempio di programma nel quale sono attivati due thread che comunicano tramite buffer.

/*
 *  file sample.c
 */

#include <stdio.h>
#include <unistd.h>
#include "mailbox.h"


void *proc1();
void *proc2();

Channel Mbx;


main() {
  int pid;
  process T0, T1, T2;

  pid = Gettid(once, &T0 );
  printf("Main thread = %d\n", pid);

  newChannel( &Mbx );  	/* inizializzazione del canale */


      /* crea due processi "leggeri" */

  NewThread( proc1, 0, &T1 );
  if (pError(T1)) exit(1);

  NewThread( proc2, 0, &T2 );
  if (pError(T2)) exit(1);

      /* ricongiungi i due flussi */

  if (JoinThread(T1, T2))
    printf("Joining thread error\n"), exit(2);
  pid = Gettid(next, &T1);
  printf("Thread %d terminated.\n", pid);
  pid = Gettid(next, &T2);
  printf("Thread %d terminated.\n", T2);

      /* termina */

  exit(0);
}


void *proc1()
  {
  }

void *proc2()
  {
  }
Per la compilazione in ambiente Linux si utilizzi il comando

gcc -DLinux -lpthread -o sample kernel.c buffer.c mailbox.c -sample.c

e lo si lanci eseguendo il comando sample. Si noti l'utilizzo della libreria di sistema pthread per la creazione e la gestione dei thread.

Socket

La relazione client/server è senz'altro uno degli schemi di interazione fra processi più noti. Tuttavia, il problema della comunicazione fra processi assume connotazioni più generali e che si presentano in modo assai diverso a seconda che tali processi siano in esecuzione sullo stesso elaboratore o su elaboratori diversi.

Nel primo caso la comunicazione è resa possibile dalla condivisione di un'area di memoria (buffer) con un adeguato meccanismo di sincronizzazione (semafori), analogamente a quanto già visto nel caso dei thread.

Nel secondo caso l'interazione è resa possibile attraverso il meccanismo più generale dello scambio messaggio il quale richiede un adeguato ambiente di comunicazione basato su canali entro cui far scorrere le informazioni. In essi le primitive di comunicazione send e receive condividono la stessa struttura del messaggio e la stessa identificazione del canale. La figura seguente

Figura 1

evidenzia la relazione fra la struttura astratta della comunicazione, realizzata in termini di scambio messaggi, e i necessari protocolli attraverso i quali tale comunicazione può effettivamente ottenersi. In particolare, viene fatto riferimento sia allo schema generale ISO/OSI sia alla specifica implementazione della rete internet.

Qualunque siano i livelli di rete e di trasporto, effettivamente utilizzati nella realizzazione della rete di elaboratori, il metodo generale con cui i processi possono comunicare fra loro è quello noto col termine IPC, o Inter Process Communication, attraverso il quale qualunque processo voglia comunicare con un altro deve dotarsi di un punto terminale di comunicazione, genericamente noto col termine socket.

Nel seguito viene presentato in generale tale schema senza un riferimento preciso ad un qualche sistema operativo ma, semplicemente, fornendo per le socket una definizione in termini di struttura astratta di dati. Successivamente si passa ad un esempio specifico di server che utilizza le primitive di manipolazione delle socket in ambiente Unix.

Implementazione dello Scambio Messaggi mediante Socket

L'implementazione dello scambio messaggi secondo il paradigma delle socket si basa su un metodo generale attraverso il quale qualunque processo che vuole comunicare con un altro si dota di un punti terminali di comunicazione, al quale resta associato univocamente un nome.

Le socket sono definite da un tipo, il cui scopo è quello di rappresentare il dominio di comunicazione, da intendersi come un'astrazione che viene introdotta per raggruppare le proprietà comuni a tutti i processi che comunicano con quel tipo specificato di socket. Una di queste proprietà è lo schema utilizzato per denominare le socket.

Le socket sono tipizzate secondo le proprietà di comunicazione visibili all'utente. Si presume che i processi comunichino solamente fra socket dello stesso tipo benchè non vi sia alcuna preclusione alla comunicazione fra socket di tipo diverso purchè il sottostante protocollo di comunicazione lo permetta. Normalmente sono disponibili tre tipi di socket che prendono il nome di

A parte la bidirezionalità del flusso di dati, una coppia di socket stream interconnessa fornisce un'interfaccia molto simile a quella delle pipe. Caratteristica importante delle socket datagram è la non alterazione della delimitazione dei record costituenti il messaggio. Questa socket modella il comportamento di molte reti a commutazione di pacchetto come Ethernet.

Definito un dominio, vi si possono introdurre un certo numero di socket ed accedervi mediante le seguenti operazioni primitive:

A questo punto si possono realizzare le operazioni standard di scambio messaggi. In particolare, per quanto riguarda l'operazione send, tenendo presente che il cliente necessita della connessione solamente per il tempo necessario a spedire il messaggio, si ha
  void Send( Mailbox *Mbx, Message Msg ) {
   Socket S;

   Attach( DOMAIN, S );
   Connect( &S, Mbx );
   Put( &S, Msg );
   Close( &S );
  }
dove il nome della socket Mbx è, in parte, determinato dalla scelta di DOMAIN, il dominio prescelto all'interno del quale viene stabilita la comunicazione. Come si intuisce facilmente, ogni qualvolta il cliente necessita di inviare un messaggio alla mailbox Mbx, viene creata temporaneamente una socket di trasmissione entro la quale viene introdotto il messaggio; successivamente la socket viene distrutta.

Per quanto riguarda, invece, l'operazione receive, questa può essere implementata nel modo seguente

  void Receive( Socket *S, Message *Msg ) {
   Socket P;

    Listen( &S, N );		 /* S = socket di comunicazione */
    Accept( &S, &P ); 		 /* P = socket di trasmissione  */
    if  (fork() == 0) {
       Close( &S );
       Get( &P, &Msg );
       Close( &P );
      }
  }

nella quale si fa riferimento alla Mailbox con la corrispondente socket piuttosto che col suo nome globale. Questo è possibile purchè si supponga che esiste un unico server che ascolta sulla socket, cioè, i nomi globali devono considerarsi identificativi di porte. Si deve aggiungere, pertanto, la procedura StartReceive che appare nel seguito

  procedure StartReceive( Mailbox Mbx, Socket *S ) {
   Attach( DOMAIN, &S );
   Bind( &S, Mbx );		  /* S = socket di trasmissione */
  }

il cui compito è quello di attivare il punto di connessione sul quale il server esegue le accettazioni di messaggi, essendo N il loro numero massimo in attesa di ricezione.

Esempio di Server

Il programma C che segue è un esempio di server iterativo costituito da una routine principale main.c che rappresenta l'ossatura del server e la subroutine guess_action, da realizzarsi a parte nel file action.c, che ne implementa la funzionalità. Per compilarlo usare il comando

gcc -o server -lnsl server.c action.c

Per l'utilizzo del server procedere nel seguente modo.

IL PROGRAMMA PRINCIPALE

Segue la struttura del server che utilizza le primitive per la gestione delle socket. La signal serve per evitare che i figli terminino senza che il padre faccia una wait(0) per 'consumare' il return code.

/*
* server.c
*      main program implementing the skeleton of the server
*
*/
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/socket.h>
#include <netinet/in.h>

#include <syslog.h>
#include <errno.h>


void guess_action(int in, int out);

main (int argc, char *argv[])

{
     int      port, sock, fd, addrlen;
     int      value;
     struct   sockaddr_in server;
     struct   sockaddr_in client;

        /* preleva gli argomenti passati al main */

     if (argc != 2) {
       fprintf(stderr, "Sintassi: %s <porta>", argv[0]);
       exit(4);
     }

     port = atoi(argv[1]);

       /* per emettere immediatamente l'output in stdout */

     setbuf(stdout,NULL);


        /* get an internet domain socket */

     sock = socket(AF_INET, SOCK_STREAM, 0);

        /* connessione IP, con trasporto TCP : rete internet */

     if (sock < 0) {
       perror("creating stream socket");
       exit(1);
     }


        /* complete the socket structure */

     server.sin_family = AF_INET;                  /* per internet         */
     server.sin_addr.s_addr = htonl(INADDR_ANY);   /* indirizzo nullo:     */
                                                   /*   sceglie il sistema */
     server.sin_port = htons(port);

        /*  riutilizza piu' volte lo stesso indirizzo */
        /*  nel caso che vengano lanciati piu' server successivamente */

     value = 1;
     if (setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (char *)&value,
            sizeof(value)) < 0) {
       perror("during setsockopt");
       exit(5);
     }


        /* bind the socket to the port number */

     if (bind(sock, (struct sockaddr *) &server, sizeof(server)) < 0) {
       perror("binding socket");
       exit(2);
     }

        /* il server attende richieste */
        /* il valore 2 indica la lunghezza massima della coda di attesa */

     listen(sock,2);

     signal(SIGCHLD,SIG_IGN);

        /* il server e' implementato in modo concorrente, */
        /* cioe' viene clonato ad ogni nuova richiesta    */

     while (1) {

        /* attendi che il cliente passi la richiesta */

       addrlen = sizeof(client);
       if ((fd = accept(sock, (struct sockaddr *) &client, &addrlen)) < 0) {
         perror("accepting connection");
         exit(3);
       }

       if (fork() == 0) {

            /* figlio */

         guess_action(fd, fd);

         exit(0);

            /* alla fine il figlio termina (e chiude la connessione)*/
        } else

      close(fd);

            /* il padre chiude la connessione */
     }
}
Come si può notare facilmente da quest'ultimo esempio, la funzionalità del server è garantita da un configurazione adeguata della rete.

Come esempio di possibile azione del server si consideri la seguente routine il cui effetto è quello di leggere una riga di caratteri dal client remoto e restituirla come echo.

/*
* rCopy routine
*      read a remote line and display it locally
*/

#include     <stdio.h>
#include     <stdlib.h>
#include     <string.h>

#define      MAXLEN    80
#define      EOT       4

char readChar(int Channel) {
  char CH[1];
  read(Channel, CH, 1);
  return CH[0];
}

void writeChar(int Channel, char ch) {
  char CH[1];
  CH[0] = ch;
  write(Channel, CH, 1);
}

void guess_action(int in, int out) {

  int  ch;
  char msg[MAXLEN];

  sprintf(msg, "benvenuto in questo server\n\n");
  write(out, msg, strlen(msg));

  while ( (ch = readChar(in)) != EOT ) writeChar(out, ch);
}

In questo modo viene realizzato un server che fornisce il servizio di echo fino a quando il client non inserisce il carattere ^D, il cui effetto è quello di chiudere la connessione. Per la compilazione si utilizzi il comando riportato di seguito

gcc -o rcopy -lnsl server.c rCopy.c

e si lanci il server rcopy Port, con un numero di porta arbitrariamente scelto fra quelli non utilizzati come servizi standard. Per dialogare con il server è sufficiente disporre di un client TELNET passandogli come parametri localhost e Port.