Lo strato applicativo di Internet

Network programming

Vengono analizzate le chiamate di sistema necessarie alla creazione ed al mantenimento di una canale di comunicazione che consenta a due computer posti ai bordi di Internet, di comunicare tramite essa. Sono discussi esempi di semplici applicazioni client e server, e per queste ultime, è discussa e sperimentata la modalità di servizio a thread singolo basata sull'I/O multiplato.

Applicazioni di rete

A differenza delle applicazioni desktop, che per funzionare non hanno bisogno di una connessione in rete, le applicazioni di rete sono quei programmi che di mestiere interagiscono con altri programmi in esecuzione su computer remoti. I paradigmi più diffusi per le applicazioni di rete sono:

Il modello a tre livelli, di cui non ci occupiamo ora, prevede di disaccoppiare le tre funzioni di interfaccia utente, logica di calcolo, ed accesso ai dati, che sono svolti da processi indipendenti; un esempio di questo tipo di applicazioni sono le Applicazioni Web basate su CGI. Il modello paritetico (peer to peer) ha acquisito importanza e notorietà grazie alla diffusione dei servizi di condivisione di file multimediali, e pure non verrà per ora affrontato.

Il modello publish/subscribe è tipicamente usato dalle applicazioni distribuite in cui ogni partecipante può contribuire svolgendo compiti diversi a seguito della notifica asincrona di eventi di diversa natura. Il modello su cui ora ci focalizzeremo, è quello cliente/servente, sia nel caso di protocolli di trasporto orientati alla connessione (TCP) che non (UDP).

In una applicazione client-server i computer in comunicazione non hanno ruoli identici, ma sono definite, per così dire, due personalità, in cui uno (il client) svolge la funzione di richiedente, mentre l'altro (il server) risponde. Nel caso in cui anche il computer server si trovi nella necessità di effettuare delle richieste, allora esisterà al suo interno un diverso processo, avente funzione di client.

Tipologie di serventi

Dal punto di vista dello strato di trasporto, una applicazione server risponde presso un numero di porta ben noto, tipicamente elencato nel file /etc/services, e possiede i privilegi necessari ad aprirla. Il client quindi inserisce questo numero di porta ben noto nel campo di destinazione dell'intestazione di trasporto; viceversa, il numero di porta di origine usato dal client è detto effimero, perché deciso in modo estemporaneo al momento della richiesta, come evidenziato nelle figure che seguono, tratte da BAF. A seconda del modo in cui sono gestite le richieste, possiamo classificare i server come

In realtà ci sono almeno altri due modi per realizzare un server parallelo, di cui ci occuperemo più avanti:

Comunicazione tra processi tramite Internet

L'interfaccia software o API (Application Program(ming) Interface) di comunicazione in assoluto più utilizzata fra processi in rete, sono i socket di Berkeley, di Unix BSD, definita nel 1982 in C per Unix BSD 4.1c, e rimasta sostanzialmente invariata da allora. Socket è la parola inglese che indica una presa (elettrica), significando in questo caso l'inserimento metaforico di uno spinotto, da parte di un programma, nella presa che lo collega ad un altro, per il tramite della rete. Ma dato che l'I/O di un programma, non è un segnale elettrico bensì numerico, è più appropriato pensare al socket come ad un identificatore di FILE, che ci permette di leggere/scrivere verso/da un programma, anziché su/da disco. Dal punto di vista della stratificazione funzionale offerta dal modello ISO-OSI, nella sua implementazione semplificata offerta dal modello TCP/IP, un socket rappresenta il SAP (Service Access Point) dello strato di trasporto, nei confronti del quale il processo applicativo si comporta come il client che utilizza i servizi di trasporto e di rete.

Creazione di un socket

Nel web esistono valide risorse (BJN, GaPiL, SOG, IIC) relative al network programming. Inoltre, moltissime chiamate di sistema Unix sono documentate con gran dettaglio nelle pagine MAN richiamabili da linea di comando. In particolare, la chiamata socket(2)

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

int socket(int domain, int type, int protocol)

è quella che ci permette di collegarci allo strato di trasporto. Il parametro domain è uno dei define presenti in bits/socket.h, incluso in sys/socket.h, e definisce la Protocol Family da usare, che per Internet è PF_INET. Il campo type specifica il tipo di trasporto da usare nell'ambito della famiglia, e può essere impostato come SOCK_STREAM per il TCP, SOCK_DGRAM (datagram) per UDP, o SOCK_RAW per bypassare lo strato di trasporto ed inviare in rete pacchetti forgiati a mano. Infine il parametro protocol, per PF_INET, può essere posto a zero.

In caso di successo, la chiamata socket() restituisce un intero, o handle (maniglia), che indicizza in un array di descrittori, quello relativo al socket creato, costituito da una struct C contenente i campi illustrati nella figura che segue, in cui famiglia, tipo e protocollo sono quelli specificati nella chiamata, mentre le due sotto-strutture indirizzo, di tipo sockaddr_in, identificano i due estremi della comunicazione.

Le strutture C che rappresentano entrambi gli indirizzi locale e remoto sono costituite dai campi riportati nella figura ancora appresso, in cui il primo e l'ultimo campo non sono in genere usati, sin_family è di nuovo posto a PF_INET, e sin_port, sin_addr identificano l'indirizzo di trasporto (un numero di porta TCP o UDP) e di rete (i 4 byte di indirizzo IP) del socket; essendo presenti sia gli indirizzi locali che quelli remoti, comprendiamo come un socket è biunivocamente legato ad entrambi: le connessioni da parte di due diversi client verso uno stesso server, danno luogo a due diversi socket. Come si vede dalla definizione, l'indirizzo IP sin_addr è in realtà descritto mediante la struttura in_addr, che come riportiamo sotto a destra, è composta da un solo membro, s_addr, che consiste di un intero senza segno, e che appunto rappresenta i 32 bit associati all'indirizzo IP

Socket di dominio Unix

Queste altre strutture dati non permettono la comunicazione tra processi attraverso la rete, ma sono all'interno di uno stesso computer. Quindi, anche se non strettamente pertinenti a questo capitolo, li citiamo ugualmente qui, per la loro similitudine con i socket di dominio Internet. I socket Unix usano il filesystem come spazio dei nomi, e due processi possono comunicare se referenziano lo stesso numero di inode, mentre lo scambio di dati vero e proprio avviene tramite buffer in memoria. L'uso dei socket Unix da parte dei processi può essere visualizzato mediante il comando netstat.

Utilizzo dei socket

La richiesta di creazione di un socket, è solo la prima di una serie di richieste che lo strato applicativo effettua verso la API offerta dall'interfaccia socket, che offe un insieme di diverse possibili chiamate. La figura che segue esemplifica il caso di un client che accede ad un server remoto operante in modalità parallela, e ci serve come linea guida per collocare le diverse chiamate nel rispettivo ruolo funzionale e sequenza temporale.


host socket stack

Nella figura, le trasmissioni che si svolgono internamente allo stack TCP/IP e non fuoriescono dall'interfaccia socket, sono mostrate con una linea tratteggiata, e rappresentano l'applicazione dei protocolli tra pari strettamente sottostanti lo strato applicativo, come ad esempio, il three way handshake, o la trasmissione degli ACK.

Dopo la creazione del socket, la chiamata a bind()

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

int bind(int sockfd, struct sockaddr *my_addr, int addrlen);

sul lato server, serve a specificare l'indirizzo locale su cui porsi in ascolto. Nei parametri che vengono passati, sockfd è pari all'handle del socket precedentemente creato, my_addr dovrà essere opportunamente inizializzato con indirizzo IP e porta locale (vedi esempio), mentre addrlen prende il valore di sizeof(struct sockaddr). Se tutto va bene, bind() restituisce 0, altrimenti -1, ad es. quando la porta è già occupata, ovvero un'altra applicazione ha già aperto un socket, su quella stessa porta.

La chiamata seguente, listen()

int listen(int sockfd, int backlog);

è quella che effettivamente abilita il socket sockfd ad accettare le richieste in arrivo, che se giungono in questa fase, vengono parcheggiate in una coda (e non è inviata nessuna risposta) di dimensione backlog; al riempimento della coda, il server risponde con dei messaggi di rifiuto. L'istruzione successiva, accept(),

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

è di tipo bloccante, ovvero se la coda delle richieste è vuota, il server rimane in attesa. Se invece c'é già una richiesta, o quando ne arriva una, accept() restituisce il controllo alla applicazione server, scrivendo nella struttura puntata da addr gli estremi del socket remoto relativo al lato client. Prima della chiamata, *addrlen è preimpostato a sizeof(struct sockaddr_in), allo scopo di impedire ad accept() di sovrascrivere altre parti di memoria, ed a sua volta, accept() modifica *addrlen assegnandogli il numero di byte scritti. L'intero di ritorno costituisce in realtà un nuovo handle di socket in cui oltre agli indirizzi locali sono presenti anche quelli (di rete e di trasporto) del client remoto, e che dovrà essere usato nelle successive operazioni di lettura/scrittura; nel caso di errori, invece, accept() restituirà -1. Il server si troverà ora a disporre di due socket, come evidenziato nella figura più in basso: uno, è quello che resta in attesa di nuove chiamate, e l'altro è quello connesso con la chiamata accettata, e che verrà chiuso al termine del servizio.

Dal lato client, notiamo che dopo la chiamata a socket(), manca la chiamata a bind(). Infatti, la scelta del numero di porta locale da utilizzare viene demandato al kernel, ovvero, viene usata una porta effimera scelta al momento della connect; l'indirizzo IP locale viene invece scelto (nel caso il computer sia multihomed) sempre dal kernel, come quello associato alla interfaccia in grado di raggiungere l'indirizzo di destinazione. Si, ma la destinazione qual'è ? ... E' specificata nella chiamata a connect()

int connect(int sockfd, struct sockaddr *serv_addr, int addrlen);

anch'essa di tipo bloccante, ovvero non ritorna finchè il server non ha risposto, in modo positivo o negativo. Prima della chiamata, *serv_addr è preimpostato con IP e porta della destinazione, e addrlen è posto a sizeof(struct sockaddr). Al ritorno, connect() restituirà -1 in caso di fallimento.

Ora che finalmente le due parti sono collegate, avendo richiesto un socket() di tipo SOCK_STREAM, queste possono inviarsi dati usando le chiamate send() e recv(), indipendentemente da chi sia il server od il client:

int send(int sockfd, const void *msg, int len, int flags);

in cui sockfd è l'handle del socket locale sui cui inviare i dati (restituito dalla chiamata a socket() per il client, ovvero quello restituito dalla accept() per il server); msg è un puntatore ai dati da inviare, e len è la lunghezza dei dati, in bytes; flags può essere 0, ovvero essere posto ad uno o più dei valori descritti nella manpage di send(). Il valore di ritorno indica il numero di bytes effettivamente inviati, e nel caso sia inferiore a len, è compito dell'applicazione reinviare successivamene i dati mancanti. Dall'altro lato della connessione, occorre invocare recv()

int recv(int sockfd, void *buf, int len, unsigned int flags);

dove sockfd è l'handle da cui leggere, buf punta a dove le informazioni ricevute devono essere scritte, len rappresenta la dimensione dell'area di memoria puntata da buf, e flags può nuovamente essere posto a zero, od a quanto indicato nella manpage di recv.

Una volta che lo scambio di dati è terminato, si può chiudere la connessione, invocando

close(sockfd);

oppure

int shutdown(int sockfd, int how);

La differenza è che mentre close() ha un effetto di chiusura totale, con shutdown() la connessione può essere chiusa anche in sola direzione; per liberare del tutto l'handle del socket, occorrerà comunque chiamare lo stesso close().

Processi e Fork

Ancora con riferimento alla figura precedente, osserviamo che quando la accept() del server ritorna, questo esegue una fork().

pid_t fork();

Ciò significa che il processo che sta eseguendo il codice del server viene clonato, ovvero vengono duplicate le sue aree di memoria programma e memoria dati, che vengono associate ad un nuovo PID (Process IDentifier). Ad esempio, per conoscere il pid di tutti i programmi in esecuzione sul proprio computer Linux, è sufficiente impartire il comando

ps ax

in cui ps sta per process status, e ax sono due opzioni che permettono di vederli tutti. Il programma clonato è detto figlio (child), ed inizia la sua esecuzione esattamente dallo stesso punto in cui si trovava il padre al momento del fork(): in effetti, è stato clonato anche il program counter, completo del suo valore! E.. come si distinguono padre e figlio, tra loro? semplice ! la fork() restituisce zero al figlio, ed il pid del figlio, al padre, come esemplificato qui sotto, in cui getpid() restituisce il pid di chi lo invoca, e getppid() del suo genitore (parent):

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main (void) {
  pid_t pid;

  pid = fork();
  if (pid != 0) {
    printf ("Padre: il processid del padre è %ld e quello del figlio è %ld\n", (long)getpid(), (long)pid);
  } else {
    printf ("Figlio: il processid del figlio è %ld, e quello del padre %ld\n", (long)getpid(), (long)getppid());
  }
}
Esercizio: compilare (con cc) il codice proposto, ed eseguirlo (./a.out).
Osservazione: può succedere che il processo padre termini prima del figlio, e quest'ultimo dichiari di discendere dal processo init (pid 1), che infatti adotta i processi orfani.
Analisi: Modificare il codice in modo che sia il padre che il figlio non terminino subito, ma (ad es.) attendano un input da tastiera (ad es, usando una scanf()). Verificare con ps axf la gerarchia dei processi ed i rispettivi pid. Uccidere il figlio con kill, e verificare (con ps) il suo stato di "zombie".

Come si vede, lo stesso programma contiene sia il codice del padre che quello del figlio, cosicché a seguito dell'esecuzione della fork(), tutti i descrittori di file aperti, così come i descrittori dei socket, vengono duplicati, come mostrato nella figura che segue. Come già discusso, dopo l'esecuzione della accept() il processo possiede due socket, uno ancora in ascolto e l'altro connesso al client, ed a seguito della fork(), si duplicano entrambi. Quindi, il processo padre provvederà a chiudere la sua copia del socket connesso, ed il processo figlio chiuderà la sua copia del socket in ascolto.

Un esempio di server parallelo

Siamo finalmente pronti a mostrare un diagramma di flusso che descrive in modo chiaro le operazioni fin qui discusse.

Osserviamo che nel disegno precedente, vengono utilizzate le istruzioni write() e read() anziché send() e recv(): le prime sono esattamente le stesse che è possibile usare con dei files su disco; le seconde, permettono di specificare dei parametri in più, come ad esempio dei flags, così chiamati perché associati ognuno, ai diversi bit presenti in una unica parola. L'uso dei flag permette di variare il comportamento del socket in situazioni particolari. Ad esempio la write(), nel caso in cui la dimensione dei dati da spedire sia eccessiva, rimane bloccata finché i buffer di alimentazione del TCP non siano stati liberati; specificando invece tra i flag di send(), la macro MSG_DONTWAIT, è possibile ottenere un comportamento non bloccante, e permettere alla chiamata di tornare subito, restituendo l'errore EAGAIN. Allo stesso modo, la recv() resta di per sé bloccata se non c'é nulla da leggere, mentre se invocata con il flag MSG_DONTWAIT, assume un comportamento non bloccante, e nella stessa circostanza, ritorna con un errore. Ma per concretizzare le idee, non resta che mostrare il codice di una implementazionereale che, una volta accettata una connessione, si limita ad inviare al client la stringa "Hello, world!" e termina (vedi linea 68):

1  /*
2  ** server.c -- a stream socket server demo
3  */

4  #include <stdio.h>
5  #include <stdlib.h>
6  #include <unistd.h>
7  #include <errno.h>
8  #include <string.h>
9  #include <sys/types.h>
10 #include <sys/socket.h>
11 #include <netinet/in.h>
12 #include <arpa/inet.h>
13 #include <sys/wait.h>
14 #include <signal.h>

15 #define MYPORT 3490                       // the port users will be connecting to
16 #define BACKLOG 10                        // how many pending connections queue will hold

17 void sigchld_handler(int s) {             // executed on children exit
18   while(waitpid(-1, NULL, WNOHANG) > 0);  // read exit state for all the children
19 }

20 int main(void) {

21   int sockfd, new_fd;                     // listen on sock_fd, new connection on new_fd
22   struct sockaddr_in my_addr;             // my address information
23   struct sockaddr_in their_addr;          // connector's address information
24   socklen_t sin_size;                     // will hold sockaddr size
25   struct sigaction sa;                    // signal handler data
26   int yes=1;

27   if ((sockfd = socket(PF_INET, SOCK_STREAM, 0)) == -1) {
28     perror("socket");
29     exit(1);
30   }

31   if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(int)) == -1) {
32     perror("setsockopt");                 // REUSEADDR option allows others to use
33     exit(1);                              // the same port
34   }
35
36   my_addr.sin_family = AF_INET;           // host byte order
37   my_addr.sin_port = htons(MYPORT);       // short, network byte order
38   my_addr.sin_addr.s_addr = INADDR_ANY;   // automatically fill with my IP
39   memset(&(my_addr.sin_zero), '\0', 8);   // zero the rest of the struct

40   if (bind(sockfd, (struct sockaddr *)&my_addr, sizeof(struct sockaddr)) == -1) {
41     perror("bind");
42     exit(1);
43   }

44   if (listen(sockfd, BACKLOG) == -1) {
45     perror("listen");
46     exit(1);
47   }

48   sa.sa_handler = sigchld_handler;         // reap all dead processes
49   sigemptyset(&sa.sa_mask);
50   sa.sa_flags = SA_RESTART;
51   if (sigaction(SIGCHLD, &sa, NULL) == -1) {
52     perror("sigaction");
53     exit(1);
54   }

55   while (1) {                              // main accept() loop
56     sin_size = sizeof(struct sockaddr_in);
57     if ((new_fd = accept(sockfd, (struct sockaddr *)&their_addr, &sin_size)) == -1) {
58       perror("accept");
59       continue;
60     }
61     struct hostent *he;
62     he = gethostbyaddr ((struct in_addr *)&their_addr.sin_addr,
63                          sizeof (struct in_addr), AF_INET);
64     printf("server: got connection from %s, remote name %s\n",
65             inet_ntoa(their_addr.sin_addr), he->h_name);

66     if (!fork()) {                         // this is the child process
67       close(sockfd);                       // child doesn't need the listener
68       if (send(new_fd, "Hello, world!\n", 14, 0) == -1)
69         perror("send");
70       close(new_fd);
71       exit(0);                             // child ends here
72     }
73     close(new_fd);                         // parent doesn't need this
74   }
75   return 0;

76 }

Una implementazione alternativa che fa uso di thread anziché di processi figli, può essere trovata presso D. Marshall. Qui sotto, spieghiamo alcuni degli elementi che ancora ci mancano per comprendere le operazioni compiute dal listato del server.

Segnali

I processi possono comunicare tra loro in modo molto primitivo, inviandosi dei segnali, che non portano altra informazione oltre al loro tipo (il numero di segnale), scelto tra pochi (da 1 a 31). Sono usati dal kernel per notificare situazioni eccezionali ai processi, e per notificare eventi tra processi, come ad es. la terminazione di un processo figlio, o la richiesta di interruzione espressa con una combinazione di tastiera (es Control-C). Si possono inviare segnali ai processi, mediante il comando

kill [ -signal ] pid

in cui pid indica il processo a cui inviare il segnale, ed i numeri più usati per signal sono riportati appresso:

Name  Num  Action Description

ALRM   14    exit Timer signal from alarm(2)
HUP     1    exit il processo si re-inizializza
INT     2    exit Interrupt from keyboard
KILL    9    exit terminazione forzata - non può essere bloccato
PIPE   13    exit Broken pipe: write to pipe with no readers
TERM   15    exit Termination signal
CHLD   17  ignore
URG    23  ignore
STOP   19    stop non può essere bloccato
CONT   18 restart continua se stoppato, altrimenti ignora
ABRT    6    core genera anche un core dump a fini diagnostici
FPE     8    core Floating point exception
ILL     4    core Istruzione illegale
QUIT    3    core
SEGV   11    core Invalid memory reference
TRAP    5    core

Ad esempio, Control-C equivale ad inviare il 15 (TERM), con la ricezione del 9 (KILL) il processo termina di sicuro, mentre invocando kill pid senza specificare il segnale, si invia un TERM.

Tornando al nostro listato di server, quando un processo figlio termina, invia al padre il segnale CHLD, ed anche se le risorse da esso impegnate (memoria, buffer) sono liberate, il descrittore del suo processo rimane in memoria (producendo un cosiddetto processo zombie) finchè il processo padre non esegue una istruzione waitpid() per leggere il suo stato di uscita, memorizzato appunto nel suo descrittore. Nella tabella precedente, la colonna Action indica cosa succede al processo che riceve il segnale, ed osserviamo che nel caso di SIGCHLD, non accade nulla. Allora, la funzione sigaction() (linea 51) per così dire arma il segnale, nel senso che specifica quale subroutine eseguire alla ricezione di un dato segnale. Questa viene assegnata dalla istruzione sa.sa_handler = sigchld_handler; in cui sigchld_handler è appunto il puntatore alla routine di gestione del segnale, e sa è una struct sigaction, i cui campi sa_mask e sa_flag vengono pure inizializzati congruentemente. Alla ricezione del segnale SIGCHLD quindi, il processo padre interrompe il normale flusso del codice, e si porta ad eseguire il codice contenuto nell'handler, come fosse una subroutine invocata dal programma stesso. Per quanto riguarda la procedura sigaction(), questa si limita a loopare finché ci sono processi zombie da mietere (traduzione letterale di to reap), e poi termina. Quindi, il programma server riprende la propria esecuzione dal punto in cui si trovava quando è stato interrotto dal segnale.

Errori

Osserviamo che spesso viene verificato l'intero restituito dalle system call, che se negativo indica una condizione di errore. In tal caso, viene effettuata una chiamata (vedi linee 28, 32, 41, 45, 52, 58, 69) a

#include <stdio.h>

void perror(const char *s);

che ha l'effetto di stampare su standard error il messaggio associato all'ultima condizione di errore che si è verificata, preceduto dalla stringa posta in argomento (che tipicamente indica il programma e/o la procedura che ha causato errore), più due punti ed uno spazio, in modo che si capisca in che frangente si è verificato l'errore. Quando una system call fallisce, imposta una variabile interna ad un valore (errno) che identifica appunto il tipo di errore; questo valore viene quindi usato dalla funzione perror() come indice nell'array di stringhe sys_errlist[], che contiene le descrizioni testuali dei motivi di errore

#include <errno.h>

const char *sys_errlist[];
int sys_nerr;
int errno;

Opzioni del socket

Alla linea 31 troviamo l'invocazione di setsockopt(), che permette di agire sui parametri che modificano il comportamento del socket, e che possono essere specificati ai diversi livelli della pila protocollare: il secondo argomento posto pari a SOL_SOCKET, specifica che si deve intervenire a livello di socket, ed il terzo posto pari a SO_REUSEADDR, permette a bind() di avere successo, anche se un altro processo occupa già la stessa porta, come potrebbe accadere, ad esempio, se un processo figlio (di una precedente istanza del server) è rimasto in esecuzione, ed in collegamento con un client remoto.

Indirizzi speciali

Alla linea 38 osserviamo che l'indirizzo my_addr.sin_addr.s_addr viene impostato a INADDR_ANY. Questa costante è un intero a trentadue bit, tutti pari a zero, definita all'interno dell'header netinet/in.h, ed è il modo per indicare questo host su questa rete: in pratica ciò vuol dire che, anche se il computer possiede più di un indirizzo, qualunque questo sia, per il solo fatto che lo strato IP abbia accettato un pacchetto destinato ad uno di questi indirizzi, lo stesso pacchetto deve essere accettato anche dal socket. Viceversa, assegnando a my_addr.sin_addr.s_addr un indirizzo specifico tra quelli dell'host, imponiamo che il server venga raggiunto solo dalle richieste destinate esattamente a quell'indirizzo. In netinet/in.h sono anche definiti altri indirizzi speciali, come INADDR_BROADCAST posto a tutti uno, che quando usato come destinazione, specifica tutti gli host su questa sottorete; INADDR_LOOPBACK che vale 127.0.0.1, e specifica lo stesso host da cui parte il pacchetto (qualunque esso sia), ed impone allo strato IP di non inoltrare il pacchetto verso lo strato di collegamento.

Rappresentazione degli interi e conversione degli indirizzi

Alla linea 37, la variabile my_addr.sin_port viene impostata al valore htons(MYPORT). Mentre MYPORT rappresenta l'indirizzo di trasporto presso il quale ascolta il server, la chiamata a htons() serve a convertire tra la rappresentazione degli interi adottata dalla architettura (CPU) utilizzata (ovvero come questi vengono organizzati in memoria) detto anche host order, ed il modo in cui gli stessi interi sono invece disposti nelle intestazioni dei pacchetti, il network order. Le due diverse disposizioni in memoria sono le cosiddette Big endian e Little endian:

Big endian

La disposizione big endian dispone in memoria i bytes iniziando dal più significativo (Most Significant, MS) al meno (less) significativo (LS), ed è quella adottata, oltre che per il network order, dai mainframe IBM e dai processori Motorola, PowerPC, SPARC.

Little endian

La disposizione little endian al contrario, dispone i byte in memoria dal meno significativo al più significativo, ed è adottata dai processori Intel.

Da Host a Network Order e ritorno

Per rendere indipendente lo strato di rete, che opera in modalità big endian, da quello dello strato applicativo, che rappresenta gli interi in accordo alla propria dotazione hardware, ora chiamata Host, sono definite due funzioni per convertire dall'Host order verso il Network order e viceversa, chiamate hton=host to networkntoh=network to host. In effetti, però, le funzioni necessarie sono quattro, perché vanno specializzate ai casi di intero corto (16 bit, short) od intero lungo (32 bit, long), e si ottengono aggiungendo a hton e ntoh i suffissi, rispettivamente, s e l, come mostrato in figura. Nel passaggio di indirizzi tra host e rete, sono usati short per i numeri di porta, e long per gli indirizzi IP.

Definiamo ora due altre funzioni molto utili.

Da Network a Ascii e ritorno

La chiamata a inet_aton() (ascii to network) è il modo per convertire un indirizzo IP dalla forma "192.168.151.122" (ossia una stringa di caratteri ascii), nella sua rappresentazione di intero a 32 bit, espresso in network byte order. Alla linea 65, la chiamata a inet_ntoa() svolge la conversione inversa, passando da network order ad ascii.

Da nome a indirizzo IP e ritorno

struct hostent *gethostbyname ( const char *hostname );

La chiamata a gethostbyname() , che sarà usata nel prossimo listato, permette al processo applicativo di risalire all'indirizzo IP, a partire dal campo fqdn che compare nell'indirizzo applicativo, ossia ad esempio www.domino.org. L'operazione coinvolge quasi sempre una interrogazione al DNS, operata dal componente resolver del sistema operativo. Il risultato della chiamata è un puntatore alla struttura hostent, che presenta i campi illustrati di seguito: h_name punta al nome ufficiale dell'host; h_aliases ad un array di stringhe contenenti i nomi alternativi; h_addrtype vale AF_INET o AF_INET6; h_length è la lunghezza in byte dell'indirizzo; e h_addr_list punta ad un array contenente tutti gli indirizzi IP del computer.

La funzione inversa viene invece svolta dalla chiamata a

struct hostent *gethostbyaddr( const void *addr, socklen_t len, int type );

che restituisce un puntatore ad una struttura hostent, a partire da un puntatore a struttura in_addr, di lunghezza len, e di tipo AF_INET. Un esempio di utilizzo viene mostrato alla linea 62 del listato - server.

Un esempio di client

Di nuovo dal sito di Brian "Beej" Hall [BJN], riportiamo appresso il codice di un ClientTCP che contatta il server stream illustrato prima. Innanzitutto osserviamo che al momento della chiamata il programma client prevede la presenza di un parametro, contenente l'indirizzo di destinazione. Quindi

C'é da notare che recv() esce subito dal suo stato di ricezione, anziché attendere l'arrivo di MAXDATASIZE bytes, in quanto il lato server chiude la connessione subito dopo l'invio del messaggio.

 1  /*
 2  ** client.c -- a stream socket client demo
 3  */

 4  #include <stdio.h>
 5  #include <stdlib.h>
 6  #include <unistd.h>
 7  #include <errno.h>
 8  #include <string.h>
 9  #include <netdb.h>
10  #include <sys/types.h>
11  #include <netinet/in.h>
12  #include <sys/socket.h>

13  #define PORT 3490                            // the port client will be connecting to
14  #define MAXDATASIZE 100                      // max number of bytes we can get at once

15  int main(int argc, char *argv[])
16  {
17    int sockfd, numbytes; 
18    char buf[MAXDATASIZE];
19    struct hostent *he;
20    struct sockaddr_in their_addr;             // connector's address information

21    if (argc != 2) {
22        fprintf(stderr,"usage: client hostname\n");
23        exit(1);
24    }

25    if ((he=gethostbyname(argv[1])) == NULL) { // get the host info
26        perror("gethostbyname");
27        exit(1);
28    }

29    if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
30        perror("socket");
31        exit(1);
32    }

33    their_addr.sin_family = AF_INET;           // host byte order
34    their_addr.sin_port = htons(PORT);         // short, network byte order
35    their_addr.sin_addr = *((struct in_addr *)he->h_addr);
36    memset(&(their_addr.sin_zero), '\0', 8);   // zero the rest of the struct

37    if (connect(sockfd, (struct sockaddr *)&their_addr, sizeof(struct sockaddr)) == -1) {
38        perror("connect");
39        exit(1);
40    }

41    if ((numbytes=recv(sockfd, buf, MAXDATASIZE-1, 0)) == -1) {
42        perror("recv");
43        exit(1);
44    }

45    buf[numbytes] = '\0';
46    printf("Received: %s",buf);
47    close(sockfd);

48    return 0;
49  }

Connessioni di tipo DGRAM

Sempre sul sito di Brian "Beej" Hall, possiamo trovare un esempiodi server (listener) e di client (talker) che fanno uso di una connessione di tipo SOCK_DGRAM, ossia senza connessione.

 1  /*
 2  ** listener.c -- a datagram sockets "server" demo
 3  */

 4  #include <stdio.h>
 5  #include <stdlib.h>
 6  #include <unistd.h>
 7  #include <errno.h>
 8  #include <string.h>
 9  #include <sys/types.h>
10  #include <sys/socket.h>
11  #include <netinet/in.h>
12  #include <arpa/inet.h>

13  #define MYPORT 4950                        // the port users will be connecting to

14  #define MAXBUFLEN 100

15  int main(void)
16  {
17    int sockfd;
18    struct sockaddr_in my_addr;              // my address information
19    struct sockaddr_in their_addr;           // connector's address information
20    socklen_t addr_len;
21    int numbytes;
22    char buf[MAXBUFLEN];

23    if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) == -1) {
24      perror("socket");
25      exit(1);
26    }

27    my_addr.sin_family = AF_INET;            // host byte order
28    my_addr.sin_port = htons(MYPORT);        // short, network byte order
29    my_addr.sin_addr.s_addr = INADDR_ANY;    // automatically fill with my IP
30    memset(&(my_addr.sin_zero), '\0', 8);    // zero the rest of the struct

31    if (bind(sockfd, (struct sockaddr *)&my_addr,
32      sizeof(struct sockaddr)) == -1) {
33      perror("bind");
34      exit(1);
35    }

36    addr_len = sizeof(struct sockaddr);
37    if ((numbytes = recvfrom(sockfd, buf, MAXBUFLEN-1 , 0,
38                            (struct sockaddr *)&their_addr, &addr_len)) == -1) {
39      perror("recvfrom");
40      exit(1);
41    }

42    printf("got packet from %s\n",inet_ntoa(their_addr.sin_addr));
43    printf("packet is %d bytes long\n",numbytes);
44    buf[numbytes] = '\0';
45    printf("packet contains \"%s\"\n",buf);
46    close(sockfd);

47    return 0;
48  }

/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */

 1  /*
 2  ** talker.c -- a datagram "client" demo
 3  */

 4  #include <stdio.h>
 5  #include <stdlib.h>
 6  #include <unistd.h>
 7  #include <errno.h>
 8  #include <string.h>
 9  #include <sys/types.h>
10  #include <sys/socket.h>
11  #include <netinet/in.h>
12  #include <arpa/inet.h>
13  #include <netdb.h>

14  #define SERVERPORT 4950                      // the port users will be connecting to

15  int main(int argc, char *argv[])
16  {
17    int sockfd;
18    struct sockaddr_in their_addr;             // connector's address information
19    struct hostent *he;
20    int numbytes;

21    if (argc != 3) {
22      fprintf(stderr,"usage: talker hostname message\n");
23      exit(1);
24    }

25    if ((he=gethostbyname(argv[1])) == NULL) { // get the host info
26      perror("gethostbyname");
27      exit(1);
28    }

29    if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) == -1) {
30      perror("socket");
31      exit(1);
32    }

33    their_addr.sin_family = AF_INET;           // host byte order
34    their_addr.sin_port = htons(SERVERPORT);   // short, network byte order
35    their_addr.sin_addr = *((struct in_addr *)he->h_addr);
36    memset(&(their_addr.sin_zero), '\0', 8);   // zero the rest of the struct

37    if ((numbytes = sendto(sockfd, argv[2], strlen(argv[2]), 0,
38         (struct sockaddr *)&their_addr, sizeof(struct sockaddr))) == -1) {
39      perror("sendto");
40      exit(1);
41    }

42    printf("sent %d bytes to %s\n", numbytes, inet_ntoa(their_addr.sin_addr));
43    close(sockfd);

44    return 0;
45  }

Stavolta è il client che invia qualcosa al server, che termina subito dopo la ricezione, mentre il client termina subito dopo la trasmissione, che sia andata a buon fine o meno. Dal lato listener, osserviamo che dopo aver creato il socket ed aver effettuato il bind sulla propria porta, si esegue direttamente il recvfrom(), che resta bloccato in attesa. Una volta ricevuto un messaggio, questo viene scritto su stdout, e listener esce. Dal lato talker, osserviamo che oltre al nome del computer remoto, è presente un altro parametro di input al programma, e cioé il messaggio da inviare al server. Di nuovo, costruiamo in their_addr l'indirizzo da contattare, dopo averlo ottenuto in network byte order tramite la gethostbyname(), ed aver convertito il numero di porta con htons(). Quindi, si invia il messaggio mediante sendto(), e si chiude il socket, in modo che la recvfrom() del server possa uscire.

I/O multiplato

Sebbene il modello di programmazione di rete che fa uso di processi figli o di thread sia tutto sommato semplice ed elegante, e permetta di scrivere del codice ben leggibile, in cui l'interazione con la specifica entità connessa all'altro estremo della rete è ben delimitata, spesso si preferisce ricorrere ad una soluzione diversa. Nel caso dei processi figli infatti, ognuno di essi determina una nuova occupazione di memoria, ed anche se nel caso dei thread questo avviene in forma molto ridotta, la fase di inizializzazione delle nuove istanze del flusso di controllo può portare ad un aumento del tempo di risposta, e dell'impegno di risorse di calcolo.

Inoltre come abbiamo visto, le chiamate alla accept(), così come alla recv(), sono di tipo bloccante, ovvero non restituiscono il controllo finché non sopraggiunge una connessione, o sono pronti dei dati da leggere, cosicchè si possono verificare problemi di sincronizzazione tra i diversi sotto processi, e lo stato di blocco di alcuni di essi potrebbe causare un blocco generalizzato. Quantomeno, se il programma è bloccato in attesa di dati provenienti da un socket, non può al tempo stesso ascoltare altri stream, come ad es. stdin collegato all'input da tastiera.

I/O multiplato Al contrario, nel caso dell'I/O multiplato [SMI, GaPiL] un unico processo si pone contemporaneamente in ascolto di tutti i socket attivi, ad ognuno dei quali è connesso un diverso client, e provvede a gestire il colloquio con ciascuno di essi, non appena sono disponibili nuovi dati. In questo modo, sono virtualmente risolti tutti i problemi di sincronizzazione, ed il server può conseguire una elevata scalabilità (ossia servire un numero molto elevato di connessioni contemporanee). Il lato negativo, è che il codice risulta meno leggibile, dovendo gestire diversi casi nello stesso flusso di controllo.

Il funzionamento dell'I/O multiplato si basa sull'uso della chiamata select(), il cui prototipo POSIX è

#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

che mostra come il suo funzionamento dipenda da tre insiemi di descrittori di file (fd_set), che individuano tutti i canali di cui occorre monitorare lo stato, in attesa di un suo cambiamento. Ogni insieme include i descrittori da cui vogliamo leggere (readfds), in cui vogliamo scrivere (writefds), e di cui vogliamo monitorare gli errori (exceptfds). Questi insiemi (set) vengono modificati dalle chiamate

void FD_SET   (int fd, fd_set *set);   /* aggiunge il descrittore fd all'insieme set */
void FD_CLR   (int fd, fd_set *set);   /* rimuove il descrittore fd dall'insieme set */
void FD_ZERO  (fd_set *set);           /* rimuove tutti i descritori dall'insieme set */
int  FD_ISSET (int fd, fd_set *set);   /* verifica se il descrittore fd appartiene */
                                       /* all'insieme set */

In pratica, occorre prima popolare gli insiemi con FD_SET(), e poi chiamare select(), che non torna finché uno (o più) dei descrittori inseriti negli insiemi non cambia stato, ovvero finché uno dei descrittori appartenenti a readfds non dispone di dati da leggere, oppure uno di quelli di writefds non è pronto ad accettare dati, oppure ancora, per uno presente in exceptfds non si verifica una condizione di errore.

Quando select() ritorna modifica gli insiemi fd_set, lasciandovi dentro solo i fd il cui stato è cambiato. A questo punto, invocando FD_ISSET() per ognuno dei descrittori e degli insiemi, si individua quale(i) di questi è cambiato, in modo che il programma possa svolgere le azioni previste per quel caso particolare. Per ciò che riguarda gli altri due parametri della chiamata a select(), nfds è pari al numero (più uno) del descrittore con il numero più elevato (ad es, se i descrittori settati sono 1, 4 e 7, nfds deve valere 8 = 7+1), mentre timeout codifica per quanto tempo al massimo select() resterà in attesa, ed è espresso mediante la struct timeval:

struct timeval {
  int tv_sec;     /* secondi      */
  int tv_usec;    /* microsecondi */
};

Ad esempio, dopo aver posto un socket in ascolto con listen(), ed averlo inserito in readfds, quando questo riceve una richiesta di connessione da parte del client, select() ritorna, e possiamo invocare subito la accept(), senza restare bloccati. Se invece la connessione è già instaurata, dopo aver inserito l'accepting socket in readfds, select() ritornerà non appena il kernel riceve un nuovo pacchetto, e potremo subito invocare recv(), di nuovo senza rimanere bloccati.

Negli insiemi, è ovviamente (!) possibile inserire anche i descrittori associati a standard input, standard output e standard error, anzi questo è fortemente raccomandato, se il programma deve anche poter gestire l'I/O da tastiera.

Nel caso in cui si voglia monitorare un solo insieme, select() può ricevere NULL al posto di un fd_set *; ponendo tv_sec e tv_usec a zero, select() tornerà immediatamente, mentre passando NULL al posto di struct timeval *, select() userà un timeout infinito.

Per riassumere i concetti esposti, e fornire un esempio pratico, mostriamo di seguito un listato ancora tratto da BJN, che realizza un semplice server di chat multiutente. Una volta posto in esecuzione, se da altre finestre-terminale, o da altri computer, si esegue telnet hostname 9034, (telnet apre una connessione TCP collegando lo standard output ed input di un computer ad un sever remoto), tutto ciò che viene scritto da un client, viene mostrato a tutti gli altri.

  1  /*
  2  ** selectserver.c -- a cheezy multiperson chat server
  3  */

  4  #include <stdio.h>
  5  #include <stdlib.h>
  6  #include <string.h>
  7  #include <unistd.h>
  8  #include <sys/types.h>
  9  #include <sys/socket.h>
 10  #include <netinet/in.h>
 11  #include <arpa/inet.h>

 12  #define PORT 9034          // port we're listening on

 13  int main(void)
 14    {
 15    fd_set master;                       // master file descriptor list
 16    fd_set read_fds;                     // temp file descriptor list for select()
 17    struct sockaddr_in myaddr;           // server address
 18    struct sockaddr_in remoteaddr;       // client address
 19    int fdmax;                           // maximum file descriptor number
 20    int listener;                        // listening socket descriptor
 21    int newfd;                           // newly accept()ed socket descriptor
 22    char buf[256];                       // buffer for client data
 23    int nbytes;
 24    int yes=1;                           // for setsockopt() SO_REUSEADDR, below
 25    socklen_t addrlen;
 26    int i, j;

 27    FD_ZERO(&master);                    // clear the master and temp sets
 28    FD_ZERO(&read_fds);

 29    // get the listener
 30    if ((listener = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
 31      perror("socket");
 32      exit(1);
 33    }

 34    // lose the pesky "address already in use" error message
 35    if (setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(int)) == -1) {
 36      perror("setsockopt");
 37      exit(1);
 38    }

 39    // bind
 40    myaddr.sin_family = AF_INET;
 41    myaddr.sin_addr.s_addr = INADDR_ANY;
 42    myaddr.sin_port = htons(PORT);
 43    memset(&(myaddr.sin_zero), '\0', 8);
 44    if (bind(listener, (struct sockaddr *)&myaddr, sizeof(myaddr)) == -1) {
 45      perror("bind");
 46      exit(1);
 47    }

 48    // listen
 49    if (listen(listener, 10) == -1) {
 50      perror("listen");
 51      exit(1);
 52    }
 53    // add the listener to the master set
 54    FD_SET(listener, &master);

 55    // keep track of the biggest file descriptor
 56    fdmax = listener;                    // so far, it's this one
 57    // main loop
 58    for (;;) {
 59      read_fds = master;                 // copy it
 60      if (select(fdmax+1, &read_fds, NULL, NULL, NULL) == -1) {
 61        perror("select");
 62        exit(1);
 63      }
 64      // run through the existing connections looking for data to read
 65      for (i = 0; i <= fdmax; i++) {
 66        if (FD_ISSET(i, &read_fds)) {    // we got one!!
 67          if (i == listener) {           // handle new connections
 68            addrlen = sizeof(remoteaddr);
 69            if ((newfd = accept(listener, (struct sockaddr *)&remoteaddr, &addrlen)) == -1) {
 70              perror("accept");
 71            } else {
 72              FD_SET(newfd, &master);    // add to master set
 73              if (newfd > fdmax) {       // keep track of the maximum
 74                fdmax = newfd;
 75              }
 76              printf("selectserver: new connection from %s on "
 77                     "socket %d\n", inet_ntoa(remoteaddr.sin_addr), newfd);
 78            }
 79          } else {                       // handle data from a client
 80            if ((nbytes = recv(i, buf, sizeof(buf), 0)) <= 0) {
 81                                         // got error or connection closed by client
 82              if (nbytes == 0) {         // connection closed
 83                printf("selectserver: socket %d hung up\n", i);
 84              } else {
 85                perror("recv");
 86              }
 87              close(i);                  // bye!
 88              FD_CLR(i, &master);        // remove from master set
 89            } else {                     // we got some data from a client
 90              for(j = 0; j <= fdmax; j++) {
 91                                         // send to everyone!
 92                if (FD_ISSET(j, &master)) {  // is it connected to me?
 93                                         // except the listener socket and the sender
 94                  if (j != listener && j != i) {
 95                    if (send(j, buf, nbytes, 0) == -1) {
 96                      perror("send");
 97                    }
 98                  }
 99                }
100              }
101            }
102          }                              // it's SO UGLY!
103        }
104      }
105    }
106    
107    return 0;
108  }

Come possiamo osservare, la select() in linea 60 usa solo l'insieme di lettura read_fds, che alla linea 59 viene posto pari a master. Questo a sua volta, definito alla linea 15, è inizializzato a zero alla linea 27, e quindi popolato alla linea 54 con il descrittore del socket di ascolto, ottenuto alla linea 30 dopo l'invocazione di socket(). La select() è posta all'interno di un loop infinito che ha inizio alla linea 58, e la prima volta che viene eseguita, riceve come primo parametro (l'intero superiore del numero di socket più grande negli insiemi) il numero del socket in ascolto + 1, come risulta alla linea 56.

Quando select() restituisce il controllo al programma, il for di linea 65 scansiona tutti i descrittori fino al più grande, e quando la condizione di linea 66 ne trova uno contenuto nell'insieme read_fds restituito da select(), ci son due possibilità: o si tratta di una nuova connessione, oppure di un nuovo pacchetto arrivato su di una connessione già attiva. Nel primo caso (che è il primo a verificarsi!) viene eseguita la accept(), il socket così ottenuto è aggiunto all'insieme master (che sarà di nuovo copiato in read_fds alla linea 59), e fdmax viene aggiornato, nel caso in cui il nuovo socket abbia un numero maggiore di quelli già in uso. Nel secondo caso, si tentano di leggere i dati in arrivo, ed in caso di successo (linee 90-100) questi vengono re-inviati a tutti gli altri socket presenti nell'insieme master, ed esclusione di quello relativo al listening socket, ed a quello del client mittente. Se invece la recv() fallisce, viene stampato un diverso messaggio di errore a seconda se sia verificato un errore (linea 85) oppure non venga letto nulla (linea 83), segno che il socket è stato chiuso dall'altro lato. In entrambi i casi, il socket viene chiuso, e rimosso dall'insieme master (linee 87 e 88). Notiamo che non viene neanche tentato di decrementare fdmax, tanto un suo valore eccessivo, non arreca nessun danno.

Socket non bloccante

Finora il nostro stile di programmazione è stato completamente vincolato dal fatto che alcune primitive (accept(), recv(), select(), ma anche send(), nel caso in cui il TCP abbia i buffer pieni) bloccano, ovvero non restituiscono il controllo al programma che le ha chiamate finchè non si verifica l'evento di cui sono in attesa. Questo ha il vantaggio che la CPU del computer che esegue l'applicazione non viene impegnata per nulla, ed il processo resta in uno stato di sospensione per la maggior parte del tempo.

Una alternativa è quella di modificare il comportamento del socket, andando ad intervenire sui flag associati al suo descrittore, utilizzando la chiamata a fcntl():

#include <unistd.h>
#include <fcntl.h>
.
.
sockfd = socket(AF_INET, SOCK_STREAM, 0);
fcntl(sockfd, F_SETFL, O_NONBLOCK);
.
.

Questo è possibile perchè in Unix ogni cosa è un file, ed il comportamento di un socket, che è referenziato da un file descriptor alle stregua di un qualunque altro file, dipende dai flags dello stesso. Il secondo argomento di fcntl(), settato a F_SETFL, indica appunto l'intenzione di settare un flag, in particolare il flag O_NONBLOCK, che appunto fa si che le chiamate bloccanti, anziché sospendere il processo, tornino immediatamente, restituendo però -1, e settando errno al valore EWOULDBLOCK. In tal modo la chiamata assume il ruolo di una interrogazione, che il programma può eseguire di continuo, finché il codice di errore non cambia, e l'operazione può avere buon fine. Ma un programma scritto in questo modo, abusa delle risorse del computer, la cui CPU viene impegnata di continuo nell'interrogazione del socket, e adottare questa soluzione, è da sprovveduti.

Client broadcast

L'esempio di chat server precedentemente svolto, mostra un server centralizzato a cui si connettono tutti client, e che provvede a re-inviare a tutti ciò che ognuno scrive. Evidentemente questo approccio risulta tanto più pesante per il server, quanti più client si collegano. Un approccio alla comunicazione di gruppo del tutto diverso, consiste nel non disporre di nessun server, utilizzare uno strato di trasporto senza connessione (udp), e fare invece affidamento ai meccanismi offerti dallo strato di rete:

Ma se utilizziamo il programma talker che appunto invia un messaggio via UDP (vedi sopra), specificando un indirizzo di destinazione broadcast, riceviamo un errore del tipo sendto: Permission denied. Prima, infatti, dobbiamo modificare il comportamento del socket, agendo questa volta su di una sua opzione, mediante il comando setsockopt():

#include <sys/types.h>
#include <sys/socket.h>
.
.
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &broadcast, sizeof(broadcast);
.
.

Il secondo parametro di setsockopt() identifica il livello (o meglio, lo strato) a cui si intende operare, e indicando SOL_SOCKET, si specifica di operare a livello di socket. L'opzione che abilitiamo è SO_BROADCAST, che appunto permette al socket sia di ricevere che di trasmettere pacchetti da/verso indirizzi broadcast. Un esempio di client che fa uso di questa opzione è broadcaster.c, ancora prelevata dalle guide di Brian"Beej" Hall. Lo possiamo mandare in esecuzione, con il comando ./bradcaster 255.255.255.255 "messaggio di saluto".

Se lanciamo listener.csu diversi computer di una stessa LAN, possiamo osservare che tutto ciò che è scritto da broadcaster, compare sugli schermi di tutti i listener. Bello ! :-) Va però osservato che, nel rispetto degli altri, fare uso di comunicazioni broadcast con troppa leggerezza non è una buona pratica, in quanto tutti i computer della stessa LAN si trovano obbligati a leggere tutti i pacchetti broadcast, eventualmente solo per scoprire che non esiste nessun processo in ascolto su quella porta.

Riepilogando

Questa è la tabella riassuntiva dei parametri dei programmi proposti

programma trasporto Porta modalità trasmissione
client TCP - fork unicast
server TCP 3490 fork unicast
talker UDP - - unicast
listener UDP 4950 - -
selectserver TCP 9034 select unicast
broadcaster UDP - - broadcast

Il codice sorgente dei programmi, è reperibile presso una apposita directory (archivio), dove è presente anche lo script di compilazione, ed il Makefile. Una versione modificata dei programmi, come suggerito nel corso delle esercitazioni, è fornita nella directory di test (archivio), in cui

Appendice: Programmazione a eventi

Il modo in cui è scritto selectserver è un caso particolare di un paradigma di programmazione in cui il flusso di esecuzione non è strettamente descritto dal programma in sé, ma piuttosto è definito dalla natura dei dati che lo stesso riceve. In questi casi il codice è scritto in forma di un event loop infinito, che attinge ad una coda di eventi verificatisi esternamente al programma, e che determinano in quest'ultimo l'esecuzione di operazioni dipendenti dal tipo di evento. Pertanto, la sequenza delle operazioni non è preimpostata a priori, ma viene a dipendere da ciò che accade all'esterno del programma.

Da un punto di vista storico, gli eventi da gestire in modo asincrono rispetto all'esecuzione di un codice derivano dalla notifica di Interrupt hardware direttamente a livello della circuitera afferente alla CPU, a cui si sono aggiunte le procedure di gestione dei segnali POSIX descritti precedentemente. Altri esempi classici di questa modalità riguardano la gestione dei movimenti del mouse nel contesto di una interfaccia grafica, la gestione dell'input da tastiera, o quella di messaggi provenienti dalla rete.

Spesso il loop che si occupa di ricevere gli eventi non contiene il codice vero e proprio da eseguire, ma piuttosto si limita a smistare gli eventi stessi verso un insieme di procedure appositamente preposte alla loro gestione. Tale approccio consente di disaccoppiare la componente di comunicazione da quella operativa, dato che le procedure di gestione eventi possono essere sviluppate indipendentemente, dovendo solo comunicare allo smistatore il loro interesse a gestire determinate categorie di eventi. Qualche link:

Publish-subscribe

Un modo di descrivere questo paradigma è di pensare che ognuno dei diversi eventi possa essere gestito da una particolare parte di codice, denominato per questo handler dell'evento. Gli eventi vengono prodotti da entità dette Publisher che non conoscono con esattezza come questi saranno gestiti; il dispatcher quindi li classifica in base a categorie (topic) per le quali gli handler hanno sottoscritto (subscribe) il loro interesse. Quindi, tutti i subscriber interessati alle categorie con cui una nuova pubblicazione è stata classificata, vengono notificati. Una variante di questo approccio è indicata come Observer pattern in cui un unico soggetto registra le intenzioni degli osservatori ad essere notificati di eventi che soddisfano particolari criteri, e provvede a richiamarli quando questi sono verificati.

Callback


Così come, nella programmazione ad oggetti, immediatamente dopo la creazione di un oggetto appartenente ad una classe viene invocato il metodo costruttore dell'oggetto stesso, allo stesso modo un handler di eventi deve innanzitutto sottoscrivere presso il dispatcher i propri interessi. Successivamente l'handler verrà quindi richiamato (callback) dal dispatcher, in corrispondenza dell'evento corretto: per questo motivo gli handler sono anche detti, appunto, callback.

Un caso classico è quello in cui il dispatcher fa parte di una libreria di basso livello, mentre gli handler sono definiti nell'ambito di un programma utente, come ad esempio nella gestione degli eventi associati all'interazione uomo-macchina nel contesto delle interfacce grafiche. In questo caso, il codice utente deve innanzitutto comunicare alla libreria i puntatori a funzione corrispondenti agli entrypoints degli handlers, che saranno successivamente invocati dal dispatcher della libreria, a cui il main program cede il controllo dopo avergli comunicato tutti i propri callback.

Esecuzione condizionale

Una applicazione che svolge le sue funzioni per ogni evento ricevuto in modo del tutto indifferente rispetto agli eventi già ricevuti in precedenza, è detta stateless in quanto, appunto, il proprio stato non si modifica a seguito della operazioni svolte. Al contrario, molto spesso la gestione di un evento dipende da quanto è avvenuto in precedenza, determinando un comportamento stateful. Mentre però alcune modifiche dello stato possono rimanere del tutto interne al singolo gestore di eventi, come avviene nel caso delle variabili private nella programmazione a oggetti, in altre circostanze la gestione di un evento deve tener conto della situazione complessiva determinatasi a seguito degli eventi elaborati da altri handler. Per questo, è opportuno che gli handler possano accedere ad un ambiente condiviso che permetta di stabilire nell'ambito di ciascuno di essi delle diramazioni condizionali in base alla situazione corrente.

Macchina a stati

Un modo tradizionale di rappresentare la storia passata è quello di definire un diagramma di stato capace di descrivere l'evoluzione delle sequenze di eventi osservate, almeno per ciò che concerne i vincoli che queste determinano sui comportamenti da tenere a seguito degli eventi successivi. Un esempio molto banale di macchina è stati è la seguente, che descrive in termini esasperatamente semplificati l'evoluzione familiare maschile, almeno in una società che non prevede la poligamia:

I possibili stati sono tre: fin dalla nascita si è Single, e si può Morire senza sposarsi mai. Viceversa, si può tornare dallo stato di Sposato a quello di Single sia a seguito di un divorzio, sia a causa del decesso della moglie.

Una macchina a stati può essere descritta da una struttura dati costituita da un valore numerico che rappresenta lo stato corrente, ed una tabella per ogni possibile stato che, per ogni possibile evento in ingresso, indica lo stato di destinazione, e le eventuali azioni da produrre in corrispondenza. Un gestore di eventi basato su di una macchina a stati, dunque, non dovrebbe far altro che

Dal punto di vista delle singole routine di gestione eventi, possiamo ora pensare queste ultime come quelle parti di codice che si occupano di svolgere la azioni conseguenti alla evoluzione della macchina a stati, e che possono eventualmente essere invocate a partire da diversi stati; viceversa, il loop di smistamento eventi ora basa le sue operazioni sulla consultazione delle strutture dati che rappresentano la macchina a stati, eliminando l'esigenza di gestire i casi di esecuzione condizionale direttamente nel codice, e permettendo di modificare il comportamento del programma modificando le strutture dati ma non il codice.

Diagrammi temporali

La descrizione di più processi indipendenti, e che operano in base allo scambio reciproco di messaggi o eventi, è spesso descritto in base alla rappresentazione di come questi scambi evolvano nel tempo, mettendo in luce i soggetti mittente e destinario, così come eventuali terze parti coinvolte nel processo comunicativo. Esempi di questi diagrammi sono forniti nel testo, come ad esempio lo scambio di informazioni legate alla apertura e chiusura di una connessione TCP, il processo di risoluzione recursiva DNS, o i diagrammi del VoIP.


Riferimenti

[AL] - Appunti di informatica libera di Daniele Giacomini
[BJN] - Beej's Guide to Network Programming Using Internet Sockets - Brian "Beej Jorgensen" Hall
[GaPiL] - Guida alla Programmazione in Linux - Programmazione di rete di Simone Piccardi
[SOG] - Internet: Indirizzi e Protocolli di Vittoria Giannuzzi
[IIC] - Socket da Imparare il C di Marco Latini e Paolo Lulli
Ripasso Unix - Lezione 2 - Processi di Umberto Salsi
[SMI] - Servers Multiprocesso e I/O Multiplexing di Vittorio Ghini, da LABORATORIO di PROGRAMMAZIONE di RETE
[UPF] - Unix Programming Frequently Asked Questions di Andrew
[UPF2] - Programming UNIX Sockets in C - Frequently Asked Questions
[MMC] - Multimedia Communications di Fred Halsall, ed. Addison-Wesley 2001, ISBN 0-201-39818-4
[BAF] - I protocolli TCP/IP di Behrouz A. Forouzan - Ed McGraw-Hill 2001, ISBN 88-386-6005-0
[TST] - Trasmissione dei Segnali e Sistemi di Telecomunicazione di Alessandro Falaschi - Ed Aracne 2003, ISBN 88-7999-477-8
[MCX] -
man.cx - Tutte le Man page
Computer Networks - di
Theo Schouten

Capitolo: Risoluzione degli indirizzi applicativi





Realizzato con Document made with Kompozer da Alessandro Falaschi -
x
Logo

Lo Strato Applicativo
di Internet

Dal TCP al VoIP, dal DNS all'Email alla crittografia, tutto ciò che accade dietro le quinte di Internet, completo di cattura del traffico.
Scopri come effettuare il download, ricevere gli aggiornamenti, e contribuire!