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.
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.
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.
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
Definito un dominio, vi si possono introdurre un certo numero di socket ed accedervi mediante le seguenti operazioni primitive:
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.
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.
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.