Lo strato applicativo di Internet
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.
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.
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:
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.
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
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.
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.
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().
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()); } } |
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.
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.
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.
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; |
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.
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.
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:
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.
La disposizione little endian al contrario, dispone i byte in memoria dal meno significativo al più significativo, ed è adottata dai processori Intel.
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 network e ntoh=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.
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.
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.
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.
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.
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.
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 } |
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.
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.
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
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:
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.
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.
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.
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.
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.
[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
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!