Torna alla pagina di Sistemi per l'elaborazione delle informazioni
:: Socket Library ::
Data la noiosità della materia trattata, l'esposizione dei contenuti sarà intervallata ad immagini di donne in pose discinte (uomini solo a richiesta).
Introduzione
In soldoni, perché due processi residenti su macchine diverse possano comunicare tra loro, è necessario:
- verificare che le macchine siano in rete
- creare un canale di comunicazione
I socket sono i due capi di tale canale, che forniscono l'interfaccia tra i programmi applicativi e il livello di trasporto (TCP, UDP, ...).
Torna su
Come avviene la comunicazione
Cosa siano client e server lo sappiamo tutti (no? Leggi qua), come comunichino (in generale) tramite socket è presto detto:
- ho un programma X che gira su un server, ed è associato a una certa porta tramite il socket "s1"
- il server continua ad ascoltare su "s1", aspettando che qualche client richieda la connessione
- quando questa arriva, se è tutto ok il server accetta la connessione e crea un nuovo socket "s2" su una nuova porta
- il servizio chiamato dal client viene gestito dal server sul socket "s2" (che verrà poi chiuso una volta soddisfatta la richiesta)
- mentre viene eseguito il punto 4, il server è tornato ad ascoltare su "s1" eventuali chiamate da altri client.
Torna su
Creare un socket
La funzione necessaria per creare un socket è, manco a dirlo, socket.
sockid = socket (pf, type, protocol)
, dove:
- pf specifica la famiglia di protocolli da usare con il socket, che si rifletterà sul modo in cui verranno interpretati gli indirizzi forniti. Ogni famiglia è rappresentata da una costante intera, alla quale è associato un nome simbolico per facilitare la memorizzazione e aumentare la chiarezza. Per convenzione questi nomi iniziano per PF_ (Protocol Family) o AF_ (Address Family), la cui scelta nella pratica è del tutto equivalente. Ad esempio: PF_INET (per il TCP/IP) o PF_UNIX (per il file system UNIX).
- type consente la scelta del tipo di comunicazione, che dipende dal protocollo utilizzato tra quelli disponibili nella famiglia scelta sopra. Ogni tipo ha un valore intero, anche in questo caso rappresentato da costanti. Ad esempio:
- SOCK_STREAM: fornisce un canale di trasmissione bidirezionale, sequenziale e affidabile. Il nome stream deriva dal fatto che i dati vengono trattati come un flusso continuo di byte. Il SOCK_STREAM della PF_INET è TCP;
- SOCK_DGRAM: usato per trasmettere pacchetti di dati (datagram) senza connessione e con trasmissione non affidabile. Il SOCK_DGRAM della PF_INET è UDP;
- SOCK_RAW: dà accesso di basso livello ai protocolli di rete e alle varie interfacce. Riservato all'uso di sistema. Il SOCK_RAW della PF_INET è IPv4.
Nota: da notare che non tutte le combinazioni "famiglia di protocolli"-"tipo di socket" sono valide (ad esempio la PF_UNIX non ha SOCK_RAW).
- protocol, il protocollo. Essendo in genere indicato implicitamente nel parametro type, ha quasi sempre valore 0.
La funzione socket ritorna un intero positivo in caso di successo (che diventerà il descrittore del socket) e -1 in caso di errore.
Esempio: sd = socket (AF_INET, SOCK_STREAM, 0);
Torna su
Codifica degli indirizzi
Avrete notato che la funzione socket si disinteressa completamente degli indirizzi che identificano i due punti finali della comunicazione, occupandosi solo di specificare il tipo di famiglia dei protocolli da utilizzare. E' solo attraverso le altre funzioni di gestione del socket (bind, listen, ...) che vengono manipolati gli indirizzi, sotto forma di strutture (passate sempre per riferimento, tramite puntatori).
La struttura generica è la seguente:
struct sockaddr {
u_short sa_family; // Address Family
char sa_data[14]; // Indirizzo (la cui forma dipende dall'AF scelta)
};
Va da sé che ogni famiglia di protocolli implementerà in modo diverso questa struttura generica, dal momento che ognuna di esse ha forme di indirizzamento specifiche e distinte. I nomi di tutte queste strutture iniziano per sockaddr_ più un suffisso finale che indica la famiglia di appartenenza.
Vediamo ad esempio la sockaddr_in, ovvero la sockaddr specifica per IP.
struct sockaddr_in {
short sin_family; // Valore: AF_INET
u_short sin_port; // Numero di porta. Valore: 0 - 65535
struct in_addr sin_addr; // sin_addr è l'indirizzo IP
char sin_zero[8]; // inutilizzato
};
Importante sottolineare come i bit degli indirizzi e dei numeri di porta possano essere ordinati in modo diverso a seconda dell'hardware degli host. Si parla di ordinamento big-endian quando si parte dai bit più significativi (adottato ad esempio da IBM, Sun, Motorola), little-endian quando parte da quelli meno significativi (es. Intel). Questo comporta la necessità di eseguire delle apposite routine di verifica dell'ordinamento ed eventuale riconversione per garantire la portabilità del codice.
Un'altra cosa da ricordare è che se specifico sin_port = 0, il sistema mi troverà la prima porta disponibile in automatico.
Torna su
Funzione bind
Abbiamo visto che nel momento della loro creazione i socket non sono associati a nessun indirizzo (IP o di porta). Se lato client non è sempre necessario conoscere l'indirizzo locale, lato server è spesso importante specificare la porta in ascolto di connessione. Ecco dunque lo scopo della funzione bind: assegnare un indirizzo locale ad un socket.
status = bind (sockid, &localaddr, addrlen)
, dove:
- sockid è il descrittore intero del socket da collegare all'indirizzo, quello ottenuto come valore di ritorno dalla funzione socket;
- localaddr è la struttura che contiene l'indirizzo locale del socket (indirizzo IP più porta). Per specificare un indirizzo generico si usa il valore INADDR_ANY, che ha valore 0;
- addrlen è un intero che indica la dimensione dell'indirizzo in byte.
La funzione bind restituisce 0 in caso di successo e -1 in caso di errore.
Torna su
Creazione della connessione
Introduciamo le prossime funzioni di gestione del socket descrivendo le varie fasi di connessione che caratterizzano il protocollo TCP. Le due figure che entrano in gioco sono il partecipante passivo (un server), in fiduciosa attesa che qualcuno gli richieda la connessione, e un partecipante attivo (il client) che effettua tale richiesta.
Ecco in pratica cosa succede:
- (lato passivo) Il server esegue l'apertura passiva del socket, attraverso le funzioni socket, bind e listen;
- (lato attivo) Il client richiede l'inizio della connessione (apertura attiva) usando la funzione connect;
- (lato passivo) Il server accetta la connessione tramite la funzione accept e la sposta su un nuovo socket;
- (lato passivo e attivo) Invio e ricezione dei dati.
Da notare che tutto ciò accade solo su quei socket che supportano la connessione, come ad esempio SOCK_STREAM. Per il SOCK_DGRAM (tipico delle UDP), ad esempio, non accade nulla di tutto questo.
Torna su
Funzione connect
Se la bind permetteva a un server di assegnare una porta ad un socket, la connect è la funzione che consente ad un client di collegarsi a quella porta specifica, ponendo un socket nello stato connesso.
status = connect (sockid, &destaddr, addrlen)
, dove:
- sockid è il descrittore intero del socket da connettere;
- destaddr è la struttura che contiene l'indirizzo di destinazione cui il socket si dovrà collegare (indirizzo IP più porta);
- addrlen è un intero che indica la dimensione dell'indirizzo in byte.
La funzione connect restituisce 0 in caso di successo e -1 in caso di errore.
La connect è inoltre bloccante, ovvero ritorna solo quando la connessione è stabilita o quando si è verificato un errore. Se ciò non avviene, il codice non va avanti.
Torna su
Funzione listen
Una volta che il socket è stato creato e assegnato a una porta locale, al server per completare la procedura di apertura passiva non rimane altro che eseguire la funzione listen, che mette il socket in modalità passiva e specifica una lunghezza della coda per le richieste di connessione.
status = listen (sockid, queuelen)
, dove:
- sockid è il descrittore intero del socket pronto per essere usato dal server;
- queuelen è il numero massimo di partecipanti attivi che possono attendere una connessione. Quando il limite viene raggiunto, le richieste di connessione successive vengono ignorate e scartate.
La funzione listen restituisce 0 in caso di successo e -1 in caso di errore.
La listen è non-bloccante, ovvero ritorna subito, e può essere usata solo per socket che supportano le connessioni.
Torna su
Funzione accept
Completata l'apertura passiva, grazie alla funzione accept il server blocca tutto e si mette in attesa di richieste di connessione.
s = accept (sockid, &addr, &addrlen)
, dove:
- sockid è il descrittore intero del socket su cui il server ascolta le richieste di connessione;
- addr è l'indirizzo del client che ha richiesto la connessione (tale campo viene dunque riempito solo a richiesta avvenuta);
- addrlen è un intero che indica la dimensione di addr in byte.
La funzione accept restituisce un intero positivo in caso di successo (il descrittore del nuovo socket) in caso di successo e -1 in caso di errore.
Vediamo passo passo cosa succede:
- la accept estrae da sockid la prima richiesta di connessione che attende in coda. Se non sono presenti, si blocca finché non ne arriva una;
- la chiamata della funzione viene completata riempendo l'argomento addr con l'indirizzo del client richiedente e l'argomento addrlen calcolato di conseguenza;
- viene creato un nuovo socket con le stesse caratteristiche di sockid, la cui destinazione è connessa al client chiamante. Il descrittore del nuovo socket è quello restituito dalla accept;
- viene ritornato il descrittore del nuovo socket al client richiedente.
Mentre vengono eseguiti i punti 3 e 4, il socket sockid originale non viene toccato e resta nello stato di listen.
La funzione accept, lo abbiamo visto, è decisamente bloccante.
Torna su
Gestione delle richieste
Una volta accettata una connessione, il server ha due approcci diversi per gestire le richieste:
- interattivamente. Il server gestisce direttamente la richiesta, chiude il nuovo socket e richiama quindi la accept per passare alla richiesta di connessione successiva;
- simultaneamente. Il server crea un processo secondario che eredita una copia del nuovo socket, e gli delega la gestione della richiesta. Una volta evasa, il modulo secondario chiude il socket e termina. Nel frattempo il processo server principale chiude la sua copia del socket dopo aver attivato quello secondario, e chiama accept per passare alla richiesta di connessione successiva.
La chiamata di sistema fork() consente di creare processi secondari, anche detti processi figli per distinguerli dai processi padri di cui sono una copia. L'unica differenza tra i due è il valore di ritorno della stessa fork, che vale 0 nel processo figlio ed un numero positivo nel processo padre (che sarebbe poi l'identificativo del figlio) se l'operazione è andata a buon fine.
Torna su
Chiusura del socket
Abbiamo parlato più di una volta di chiusura del socket senza spiegare come si fa. E' presto detto:
status = close (sockid)
, dove:
- sockid è il descrittore intero del socket da chiudere.
La funzione close restituisce 0 in caso di successo e -1 in caso di errore.
Tre precisazioni:
- se più di un processo ha lo stesso socket aperto, questo non verrà chiuso finché non sarà invocata una close su ognuno di essi;
- se un processo termina per una qualsiasi ragione, tutti i socket che utilizzava saranno chiusi automaticamente dal sistema;
- una volta chiuso il socket, la porta che utilizzava diventa di nuovo disponibile.
Torna su
Funzione select
Se per programmi semplici il fatto di avere funzioni bloccanti (come la connect o la accept) può essere utili, per quelli più complessi aumentano i problemi. Ad esempio, come saranno gestite le connessioni multiple? E cosa succederà nel caso di connessioni simultanee? Si finirà irrimediabilmente per saturare la coda dei client in attesa, continuando a scartare nuove richieste.
Un sistema per risolvere il problema l'abbiamo già visto con la fork() e quindi con la gestione simultanea delle richieste. Un altro è fare in modo che uno stesso processo possa attendere delle connessioni su più di un socket. Questo sistema avviene utilizzando la funzione select.
status = select (nfds, &readfds, &writefds, &exceptfds, &timeout)
, dove:
- nfds è il numero dei descrittori da esaminare (che vanno da 2 a nfds-1);
- readfds è l'elenco dei descrittori da controllare perchè pronti alla lettura;
- writefds è l'elenco dei descrittori da controllare perchè pronti alla scrittura;
- exceptfds è l'elenco dei descrittori da controllare perchè si è verificata un’eccezione;
- timeout specifica un tempo massimo di attesa prima che la funzione ritorni. Se è impostato a NULL (tempo infinito), la funzione attende indefinitamente; se è 0, significa che la chiamata blocca l'esecuzione finché un descrittore non è pronto.
La funzione select restituisce i descrittori dell'insieme dei descrittori dei socket che sono pronti, o -1 in caso di errore.
Generalmente viene usata quando il numero dei socket da gestire è basso.
Torna su
Funzione send
Ora che sappiamo come creare e gestire le connessioni, vogliamo anche farci qualcosa. Per inviare dati posso utilizzare la funzione send.
count = send (sockid, &buf, len, flags)
, dove:
- sockid è il descrittore del socket da utilizzare;
- buf, sta per buffer, è la stringa dei caratteri che devono essere inviati;
- len è un intero che indica la dimensione di buf in byte;
- flags è un intero che consente al mittente di specificare alcune opzioni speciali di invio. Viene generalmente lasciato a 0.
La funzione send restituisce il numero dei byte trasmessi, o -1 in caso di errore.
La send è bloccante, ovvero non ritorna finché non vengono immessi dei dati nel buffer.
Torna su
Funzione recv
A che serve mandare dati con la send se poi non abbiamo modo di riceverli? Ecco a cosa serve la recv.
count = recv (sockid, &buf, len, flags)
, dove:
- sockid è il descrittore del socket da utilizzare;
- buf specifica l'indirizzo di memoria in cui va memorizzato il messaggio;
- len è un intero che indica la dimensione di buf in byte;
- flags è un intero che consente al chiamante di controllare la ricezione. Anche in questo caso viene generalmente lasciato a 0.
La funzione recv restituisce il numero dei byte ricevuti, o -1 in caso di errore.
La recv è bloccante, ovvero non ritorna finché non vengono ricevuti dei dati.
Da notare infine che sia la send che la recv sono pensate per socket che supportano le connessioni. I SOCK_DGRAM, ad esempio, utilizzeranno altre funzioni come ad esempio la sendto e la recvto in cui verranno specificati anche gli indirizzi di destinazione/partenza.
Torna su
Torna alla pagina di Sistemi per l'elaborazione delle informazioni