« Tcl: Manipolazione degli array • Appunti di Tcl/Tk • Tcl: »
I socket costituiscono un meccanismo di comunicazione tra processi molto potente. Come caso particolare possono essere usati per la comunicazione anche da processi che girano su una stessa macchina, ma la loro caratteristica più importante è di consentire il dialogo tra applicazioni residenti su macchine diverse, sia della stessa rete locale sia tramite reti geografiche molto estese come la rete Internet.
L'interfaccia dei socket che viene fornita da un sistema operativo consente al programmatore di scrivere programmi che utilizzano la rete senza richiedere la conoscenza approfondita del funzionamento della rete e dei protocolli che stanno alla base del suo funzionamento (ad esempio la pila di protocolli TCP/IP per la rete Internet) né tantomeno di dover supportare i diversi canali e mezzi fisici che vengono usati per la trasmissione.
L'interfaccia dei socket e l'hardware della rete si occupano di gestire tutti questi dettagli; il programmatore che la usa si limita sostanzialmente a richiedere la trasmissione e la ricezione dei dati, tramite primitive molto semplici, simili a quelle utilizzate per leggere o scrivere nei file locali e a definire protocolli (o formati) dei dati solo al più alto livello, allo scopo di consentire alle applicazioni di interpretare correttamente i dati che si scambiano. Tutti gli altri problemi della trasmissione (quali ad esempio il rilevamento e la correzione degli errori, il routing e la comunicazione tra diverse reti locali, la conversione dei segnali per passare da un mezzo trasmissivo all'altro, eccetera) sono già stati risolti da livelli software e hardware inferiori, senza che il programmatore debba preoccuparsene.
Questo articolo si propone di dimostrare tramite alcuni esempi come il linguaggio Tcl consenta di scrivere applicazioni di rete portabili con molto meno codice rispetto a linguaggi di sistema come il C e persino con maggior semplicità rispetto a linguaggi da alcuni ritenuti i più comodi per questi task, come ad esempio Java, grazie al fatto che si tratta di un linguaggio a comandi provvisto di primitive ad alto livello e una sintassi molto semplice e di facile lettura e basato sugli eventi.
I socket rappresentano il punto finale di un canale di comunicazione bidirezionale tra processi diversi (o al limite anche lo stesso processo). Per processo di intende un programma in esecuzione. Con bidirezionale si intende che è possibile sia inviare sia ricevere byte sullo stesso canale.
Il sistema operativo UNIX semplifica l'utilizzo dei socket facendoli apparire dal punto di vista dei programmi come un normale descrittore di file. Si tratta comunque sempre di un file speciale. Limitiamo la nostra discussione ai socket di Internet, che sono comunque i più importanti e i più usati. Il nome di un socket di questo tipo, che corrisponde come concetto al nome di un file ordinario in un filesystem, è costituito da due elementi:
Un kernel UNIX mette a disposizione una system call detta socket(2) invocabile dal linguaggio C, per la creazione dei socket:
int socket(int domain, int type, int protocol);
Volendo creare uno stream socket per comunicare in Internet la funzione va invocata come segue:
sock=socket(PF_INET, SOCK_STREAM, 0);
Il parametro domain specifica la famiglia di protocolli da usare; la costante PF_INET indica la famiglia dei protocolli di Internet versione 4. type indica il tipo di socket; i socket di tipo SOCK_STREAM permettono di comunicare tramite un flusso di byte di lunghezza arbitraria, in modo bidirezionale; garantiscono la ricezione dei dati nello stesso ordine in cui vengono inviati e l'affidabilità della connessione (vengono sempre rilevati errori a cui non si può rimediare).
Il parametro protocol indica il numero di protocollo da usare (vedere a proposito protocol(5)), ma poiché esiste un solo protocollo per connessioni di tipo STREAM in Internet (è il TCP) l'ho posto a zero, in modo che venga selezionato il TCP.
Questa funzione crea un socket e ne inizializza il nome utilizzando l'indirizzo di rete principale dell'host e un numero di porta allocato dinamicamente e non utilizzato da nessun altro socket sullo stesso sistema. Ritorna -1 se si verifica un errore, altrimenti ritorna un descrittore che si riferisce al socket. I tipi di errori che si possono verificare sono descritti in dettaglio nella manpage socket(2); in breve si possono avere errori a causa della mancanza di descrittori o di spazio di buffer per creare il socket, oppure se gli argomenti della chiamata sono errati o non si hanno sufficienti permessi per creare un socket di un certo tipo.
I socket di tipo SOCK_STREAM sono full-duplex che significa che si possono leggere e scrivere flussi di byte contemporaneamente e indipendentemente nel canale (in modo simile a come si fa con le pipe). I dati non vengono mai perduti o ricevuti in modo duplicato. Se una porzione di dati bufferizzata non può essere trasmessa entro un certo limite di tempo in cui vengono effettuati vari tentativi, le system call indicheranno certamente l'errore restituendo -1 e settando la variabile globale errno con un codice di errore.
socket crea solo il socket, ossia un socket appena creato rimane non connesso; prima di poter mandare o ricevere dei dati tramite questo socket occorre connetterlo ad un altro socket utilizzando la system call connect(2):
int connect(int s, const struct sockaddr *name, socklen_t namelen);
Una volta connessi i due socket è possibile comunicare come in una pipe bidirezionale. Il parametro s di connect deve essere il descrittore ritornato dalla call socket. Per un socket di tipo SOCK_STREAM, il parametro name specifica il nome dell'altro socket a cui connettersi. Nel caso di un socket di internet nella struttura di tipo sockaddr verrà specificato l'indirizzo IP e il numero di porta. namelen indica la lunghezza in byte della struttura sockaddr.
connect restituisce 0 se tutto va bene, -1 in caso di errore. Per il dettaglio degli errori consultare la manpage.
Una volta connessi il trasferimento dei dati può avvenire tramite le primitive generiche di I/O di Unix read(2) e write(2) oppure le varianti send(2) e recv(2) specializzate per mandare o ricevere messaggi sui socket o alcune loro varianti. La syscall close(2) può essere usata per chiudere una sessione di comunicazione.
Uno dei calcolatori all'estremità di una comunicazione di dati basata su socket viene detto server e l'altro viene detto client.
Di solito è il client che inizia la connessione con il server. Il client deve conoscere l'intero nome del socket sul server. Su Internet vale a dire che deve conoscere non solo l'indirizzo IP del server da contattare ma anche il numero di porta su cui il server risiede.
Di solito il server invece non stabilisce alcuna connessione nel momento in cui viene avviato, ma semplicemente si mette in attesa che un client si connetta e richieda dei servizi. Un server non conosce in anticipo quando i client si connetteranno. In alcuni momenti il server avrà da gestire molte richieste in quanto molti client tentano di connettersi nell'arco di un breve periodo di tempo, mentre in altri momenti (ad es. di notte quando di solito la rete è poco usata) potrebbe non avere alcuna connessione attiva.
Naturalmente se il server ogni volta che viene riavviato utilizzasse un numero di porta dinamico, sarebbe molto difficile per il client indovinare questo numero di porta. Perciò occorre una ulteriore syscall che permetta di cambiare il nome di un socket, la bind(2):
int bind(int s, const struct sockaddr *addr, socklen_t addrlen);
bind introduce la restrizione che il numero di porta scelto non deve essere già in uso da un altro socket nel sistema locale. Questo è molto importante per garantire che tutti i nomi di socket nel mondo siano unici e che ciascun nome corrisponda ad un solo processo (infatti anche gli indirizzi di ciascun host su Internet sono unici).
Ci sono 65535 porte IP, quindi un massimo di 65535 servizi differenti che può offrire uno stesso host. Di solito un processo server processa delle richieste provenienti da una sola porta, ma si trova anche il caso in cui uno stesso processo apre più di un socket per ascoltare su porte diverse, ad esempio uno stesso processo di Apache col modulo mod_ssl ascolta sia sulla porta 80 che 443.
In Unix per ragioni di sicurezza solamente l'utente root può creare socket il cui nome comprende un numero di porta inferiore a 1024; le porte nell'intervallo [0, 1023] infatti sono assegnate a tutti i principali servizi disponibili in Internet e le assegnazioni sono ben note ai sistemi operativi, ai loro programmi e agli utenti.
Solitamente quindi si usa la bind per dare al socket del server un nome fisso, vale a dire che il server crea un socket e lo associa all'indirizzo host corrente con un numero fisso di porta. In altri termini con bind scegliamo la porta su cui vogliamo fornire un certo servizio. L'uso di bind non è strettamente necessario per comunicare ma come abbiamo osservato se non cambiamo il nome del socket sarà piuttosto difficile per i client indovinare il nostro numero di porta. Ad esempio il servizio www (World Wide Web) che è il servizio "di navigazione" più diffuso di Internet viene di solito offerto sulla porta standard numero 80 e questo numero di porta viene utilizzato nella connect da un browser per default a meno che l'utente non specifichi diversamente. In Unix il file /etc/services elenca tutti i numeri di porta per i servizi standard. Vedere inoltre la RFC 1060.
Solitamente solo il processo server usa la bind. Per i client non ha molta importanza il numero di porta che si usa, perciò un client di solito lascia invariato il nome del socket scelto dalla syscall socket e procede a connettere il socket tramite la connect.
socket connect read, write, send, recv, ... closeL'ordine delle system call usate da un tipico client in C
socket bind listen accept read, write, send, recv, ... closeL'ordine delle system call usate da un tipico server in C
La syscall listen(2) serve per comunicare al kernel che il server è pronto ad accettare connessioni da parte dei client:
int listen(int s, int backlog);
listen comunica inoltre al kernel la massima lunghezza della coda delle connessioni pendenti, cioè non ancora accettate tramite accept(2) perché il server è occupato a processare altre richieste. Se giunge una richiesta di connessione quando la coda è piena il client relativo, limitatamente al caso del protocollo TCP, ritenterà automaticamente una nuova connessione, fino a quando viene raggiunto un timeout ragionevole.
Il socket di tipo server creato con socket, bind e listen diventa leggibile quando un cliente vuole connettersi. Leggibile in questo caso non vuol dire che si possono leggere dei dati da questo socket, ma solo delle richieste di connessione.
Il server accetta una connessione tramite la syscall accept(2), che estrae la prima richiesta di connessione dalla coda delle connessioni pendenti:
int accept(int s, struct sockaddr *addr, socklen_t *addrlen);
Se non ci sono connessioni pendenti in coda, la accept risulta bloccante per il processo che la invoca se il socket s è in modalità bloccante; se invece il socket s è in modalità non bloccante la accept non blocca in questo caso e restituisce un errore.
Se tutto va bene la accept ritorna il descrittore di un nuovo socket sul server. Questo nuovo socket ha lo stesso numero di porta del socket che rimane in ascolto e per indirizzo quello da cui proviene la connessione del client. Inoltre è già connesso al socket del client. Il nuovo socket ritornato dalla accept e il socket del client formano quindi la pipe per comunicare.
Questo nuovo socket va usato solo per comunicare con il client di cui si è accettata la connessione e non deve essere usato in una successiva chiamata di listen o accept; è invece il socket s, che rimane aperto dopo una accept e continua ad ascoltare per ulteriori richieste tramite la coda che gli è associata, che va utilizzato per accettare altre connessioni e chiuso esplicitamente quando non si vogliono più accettare altre connessioni.
Nel codice che implementa accept, ci sono delle istruzioni che memorizzano il nome del socket del client in un buffer tenuto dal kernel e associato al descrittore del nuovo socket. Questo nome viene poi copiato anche nella zona di memoria puntata da addr (che è quindi un parametro di uscita). Come per tutti i socket di Internet, il nome del socket del client è composto da un indirizzo IP più un numero di porta. Il server tipicamente utilizzerà questa informazione per decidere se accettare o meno la connessione oppure a solo scopo di logging. addrlen è un parametro di ingresso e uscita che inizialmente deve indicare quanti byte si possono scrivere a partire dall'indirizzo addr e al ritorno conterrà il numero di byte effettivamente scritti.
Il tipo di server più semplice è quello sequenziale, costituito da un unico processo o processo leggero (thread), che sarà in grado di servire un solo client per volta. Questo è accettabile solo se un servizio è raramento usato o se la durata delle conversazioni tra ciascun client e il server è molto breve. In questo caso il server entra in un ciclo infinito (che in pratica sarà interrotto killando il processo o se fallisce una delle sue system call o se la macchina viene riavviata) caratterizzato dalla chiamata delle seguenti syscall nell'ordine in cui sono scritte:
accept read, write, send, recv, ... close
Qui il close è relativo al socket ritornato dalla accept. Un esempio di server sequenziale è il servente daytimed.
Tuttavia protocolli di alto livello più complessi di daytime, ad esempio FTP, richiedono di poter effettuare una serie di richieste che sono attivate manualmente a distanza di tempo, ad esempio nel caso di FTP una serie di comandi PUT o GET di file. Sarebbe un overhead inaccettabile dover instaurare una connessione separata per ciascun comando. Il servente FTP non sa quindi in anticipo quando durerà la sessione di ciascun client e inoltre è necessario che le richieste di più utenti connessi al server FTP possano essere intervallate per garantire tempi di risposta accettabili per tutti. La soluzione a questi problemi consiste nell'iniziare un nuovo processo o un nuovo thread (se il server è basato su thread invece che processi) per ciascun client e lasciare che sia questo processo/thread a continuare a servire il client, mentre il processo daemone/thread principale continuerà ad ascoltare per accogliere nuove connessioni.
Questo tipo di server che sfrutta la multiprogrammazione viene detto server concorrente ed è in grado di servire più di un client alla volta, senza costringere un client ad aspettare che il server finisca di servirne un altro come accadeva col server sequenziale. Il nuovo processo o thread va creato proprio dopo che il processo/thread principale ha chiamato la accept. Questo comporta inoltre alcune complicazioni relative al rilascio delle risorse (descrittori e stati di terminazione dei processi) in C che esulano dagli scopi di questo articolo dedicato invece alla scrittura di server in Tcl.
Una soluzione intermedia tra quella del server sequenziale e del server concorrente è quella del multiplexing sincrono dell'I/O per cui vi rimando alla manpage della syscall select(2).
Per aprire la connessione dal lato client il comando socket(n) va usato con la seguente sintassi:
socket ?options? host port
Il comando socket ritorna un identificatore di canale che può essere usato sia per leggere che per scrivere nel canale, come si fa in un file. Equivale ad usare le due funzioni del C socket(2) e connect(2), ma è molto più facile da usare.
In Tcl viene definito come canale ogni risorsa che permette di effettuare operazioni di I/O, indipendentemente dal dispositivo che può essere un file, un nastro, un terminale, un socket eccetera.
L'argomento host è il nome di dominio o l'indirizzo IP numerico del calcolatore a cui ci si vuole connettere; localhost oppure l'indirizzo 127.0.0.1 detto anche indirizzo di loopback si riferisce allo stesso host sul quale viene invocato il comando.
port indica la porta a cui connettersi; può essere un numero intero oppure il nome di un servizio se il sistema operativo su cui gira l'interprete Tcl supporta la conversione del nome nel numero di porta corrispondente.
La conversione funziona in Unix e in Windows. Unix mantiene il database dei nomi di servizio nel file di testo /etc/services e fornisce nella libreria standard del C delle funzioni per interrogarlo. Vedere a proposito la manpage getservent(3). In Windows il database si trova in WINDOWS\SERVICES.
Le opzioni che si possono specificare prima dell'host sono le seguenti. Tcl utilizza i parametri con nome per aumentare la leggibilità e non costringere il programmatore a ricordarsi l'ordine in cui devono essere specificati.
A meno che non vengano settati in modo non-bloccante tramite fconfigure(n), i socket vengono aperti in modalità bloccante, come avviene per default per tutti gli altri canali. Nella sezione Esempi ci sono esempi di entrambe le modalità, che dovrebbero chiarire quando conviene usare la modalità bloccante e quando no.
La connessione può fallire per vari motivi: l'indirizzo IP o il nome host a cui ci si vuole connettere non esiste, oppure non è raggiungibile (ad es. è il server è spento) oppure il server è sovraffollato e la sua coda di connessioni pendenti è piena ed è stato raggiunto il timeout. In tal caso il comando socket solleva un errore.
In Tcl se catch(n) non viene usato e si verifica un errore vengono abortite tutte le esecuzioni dei comandi innestati e poi l'interprete stampa il messaggio di errore. Se si vuole evitare la terminazione occorre catturare e gestire l'errore tramite il comando catch(n):
if [catch {socket ...} sock] { # sock contiene il messaggio di errore ... } else { # sock e' il canale ... }
Per aprire una connessione dal lato server il comando socket(n) va usato con la seguente sintassi:
socket -server command ?options? port
Equivale ad usare le tre funzioni del C socket(2), bind(2), listen(2), ma è molto più facile da usare.
Il socket creato sarà un server per la porta specificata dal parametro port (che può essere un intero oppure, se il sistema operativo host lo supporta, un nome di servizio). Questo sarà il numero di porta che i client dovranno usare quando fanno una richiesta di connessione.
In Tcl non esiste un comando che equivalga esplicitamente alla funzione accept del C. Invece Tcl predispone le cose in modo da accettare automaticamente le connessioni alla porta specificata. Dopo aver creato il server socket col comando socket, Tcl controllerà lo stato di leggibilità di questo socket nel suo ciclo degli eventi.
Ogni volta che Tcl nel suo ciclo degli eventi scopre che un socket server è diventato leggibile, ossia che un client vuole connettersi, vale a dire per ogni nuova connessione, esegue la accept in modo da creare un nuovo canale che può essere utilizzato per comunicare con il client e poi invoca il comando command specificato come argomento del comando socket che ha creato il socket server relativo. A questo comando verranno aggiunti tre argomenti, che sono i parametri di uscita della connect. Nell'ordine:
In questo modo il comando command verrà invocato una sola volta per ogni client che si connette al server. Il socket server continua ad ascoltare per accettare nuove connessioni sul numero di porta stabilito.
Poiché in Tcl un processo server è completamente controllato dal ciclo degli eventi come descritto, è necessario che il server entri nel ciclo degli eventi. Se l'applicazione server non entra mai nel ciclo degli eventi, allora non sarà accettata alcuna connessione e il server rimarrà inutilizzabile.
Le applicazioni grafiche in Tcl/Tk, che vengono eseguite dalla shell grafica wish(1) sono normalmente guidate dagli eventi (event-driven) e quindi si entra implicitamente nel loop degli eventi; se invece usate la shell tclsh, per entrare nel loop degli eventi occorre invocare i comandi vwait(n) e update(n). Quando Tcl è incorporato in C si usa la funzione Tcl_DoOneEvent(3).
In Tcl per scrivere server in grado di servire più di un client alla volta occorre impostare nel comando command il socket che permette di comunicare con il client (che gli viene passato come primo argomento) in modalità non bloccante e poi utilizzare il comando fileevent(n) per creare un gestore di evento su file.
Un gestore evento su file permette di stabilire un legame tra un canale di I/O e uno script, in modo tale che lo script venga eseguito ogni volta che sul canale ci sono dei dati da leggere o da scrivere.
L'utilizzo dei gestori di evento su file è utile anche nelle applicazioni client interattive in quanto permette di ricevere dei dati da un altro processo con un approccio basato sugli eventi, in modo che il processo ricevente può continuare a interagire con l'utente mentre aspetta che i dati arrivino.
Normalmente infatti se un'applicazione invoca gets(n) o read(n) su un canale impostato in modalità bloccante di I/O su cui non è disponibile dell'input, il processo si bloccherà finché arrivano dei dati di input e rimanendo così congelato in attesa di quell'evento di input non potrà servire altri eventi. Usando filevent invece il processo può sapere quando i dati che aspettava arrivano e invocare la gets o la read per leggerli solo quando è sicuro che questi non bloccheranno.
Come abbiamo osservato per i socket server in C, anche i Tcl i canali di tipo server non possono essere utilizzati per l'input o l'output; il loro unico scopo è di accettare nuove connessioni. Sono i canali che vengono creati per ciascuna connessione in entrata del client che vengono invece aperti per l'input e per l'output. Chiudere il canale del server fa sì che non vengano accettate nuove connessioni, ma non ha alcun effetto sulle connessioni esistenti.
L'unica opzione che può essere specificata prima del numero di porta è:
Se port vale zero, il sistema operativo allocherà una porta non usata per il socket server. Il numero di porta allocato può essere ottenuto usando il comando fconfigure:
set sock [socket -server cmd 0] lindex [fconfigure $sock -sockname] 2
Il listato 1 presenta il codice Tcl di un client di un servizio di Internet molto semplice, il servizio daytime, che permette di conoscere la data e l'ora segnata dall'orologio interno di un server. Si tratta di un esempio di utilizzo di un socket in modalità sincrona e bloccante e solo in lettura, quindi di una comunicazione unidirezionale.
Il servizio daytime può utilizzare sia il protocollo TCP che UDP, nel nostro caso supponiamo sia usato TCP perché attualmente Tcl supporta solo questo protocollo (in successive versioni verranno supportati altri protocolli). Ovviamente sia client che il server devono utilizzare lo stesso protocollo affinché possa aver luogo la comunicazione.
#!/usr/local/bin/tclsh8.4 # daytime set DEFAULTSERVER localhost puts -nonewline [read [socket [expr {$argc>0 ? [lindex $argv 0] : $DEFAULTSERVER}] daytime]]Listato 1. Un semplice client daytime.
Si noti che a parte il prefisso per avviare l'interpretazione e la definizione della costante DEFAULTSERVER, tutto può essere fatto in una sola linea di codice. Il listato 2 mostra una versione equivalente più prolissa che non innesta mai due comandi se non strettamente necessario:
#!/usr/local/bin/tclsh8.4 # daytime set DEFAULTSERVER localhost if {$argc>0} { set server [lindex $argv 0] } else { set server $DEFAULTSERVER } set sock [socket $server daytime] set date [read $sock] puts -nonewline $dateListato 2. Un semplice client daytime (in Tcl prolisso).
Il comando daytime che abbiamo creato accetta come argomento opzionale sia il nome di dominio che l'indirizzo IP del servente daytime a cui connettersi.
% daytime Fri Nov 21 16:04:26 2003 % daytime 127.0.0.1 Fri Nov 21 16:08:24 2003 % daytime time.nist.gov 52974 03-12-01 15:47:28 00 0 0 80.4 UTC(NIST) *
La versione del listato 3 visualizza il risultato in una finestra grafica. La figura 1 mostra un esempio di come appare questa finestra in Windows:
#!/usr/local/bin/wish8.4 # daytime set DEFAULTSERVER localhost pack [label .d -text [read [socket [expr {$argc>0 ? [lindex $argv 0] : $DEFAULTSERVER}] daytime]]]Listato 3. daytime in veste grafica
Figura 1. daytime in Windows
Un paio di osservazioni su questo programma: non è necessario alla fine chiudere il canale esplicitamente tramite un comando close(n) in quanto a ciò provvede l'interprete quando il programma termina; il comando read(n) legge tutti i dati dal socket finché questo non viene chiuso (il servente daytime lo chiude subito dopo avervi stampato la data). Se il programma invece non terminasse subito dopo aver letto tutti i dati dal socket sarebbe bene eseguire la close non appena possibile.
Si tratta di un semplice server sequenziale. Le connessioni utilizzano un semplice socket in modalità sincrona e bloccante e solo in scrittura, quindi la comunicazione è unidirezionale come nel caso del client.
#!/usr/local/bin/tclsh8.4 # daytimed proc printTime {sock addr port} { puts $sock [clock format [clock seconds]] close $sock } socket -server printTime daytime vwait foreverListato 4. Il servente daytimed.
% daytimed & 2367 % daytimed couldn't open socket: address already in use while executing "socket -server printTime daytime" (file "./daytimed" line 10) % telnet localhost 13 Trying 127.0.0.1... Connected to localhost. Escape character is '^]'. Fri Nov 21 21:35:25 CET 2003 Connection closed by foreign host. % daytime Fri Nov 21 21:36:13 CET 2003
Come abbiamo detto prima il comando vwait(n) non è necessario se lo script viene eseguito tramite wish(n). Il comando vwait ha come parametro un nome di variabile che deve essere definita come una variabile globale e ha l'effetto di far entrare l'applicazione nel ciclo degli eventi. Se ci sono eventi pendenti verranno processati, altrimenti la vwait è bloccante. Si continuano a processare gli eventi finché qualche procedura di gestione degli eventi scrive nella variabile specificata dopo vwait. Dopo che la variabile è stata settata, il comando vwait ritorna, precisamente ritorna non appena termina l'esecuzione del gestore dell'evento che ha modificato la variabile vwait.
Col termine evento si intende qualsiasi azione eseguita in background. Può trattarsi di un evento associato ad un widget Tk, un handler registrato tramite fileevent(n) o socket(n) (quest'ultimo quando viene usato per creare connessioni server).
Notate come nel nostro caso la variabile specificata non viene mai settata, quindi il server rimarrà sempre nel ciclo degli eventi. Per enfatizzare questo fatto ho chiamato la variabile forever, ma avrei potuto scegliere un nome qualunque di variabile inesistente.
Questo programma fa una richiesta ad un server web, per semplicità usando la versione 1.0 di HTTP e visualizza la risposta su stdout:
#!/usr/local/bin/tclsh8.4 # simple HTTP client set sock [socket www.google.it http] puts $sock {GET / HTTP/1.0 } flush $sock puts [read $sock]Listato 5. Un semplice client HTTP.
Da notare che è necessario usare il comando flush a causa del fatto che Tcl per default usa l'I/O pienamente bufferizzato per socket e pipe. Questo significa che Tcl non invia i dati finché non glielo si dice esplicitamente tramite flush o finché il buffer non è pieno.
Se omettete la flush nell'esempio precedente, il programma non funziona più correttamente: la richiesta non sarà mandata al server, la read bloccherà in quanto il server non invierà alcuna risposta. In pratica la read non bloccherà definitivamente in quanto il server dopo un certo timeout chiuderà la connessione col client. In Apache il timeout tra richieste e risposte è controllato dalla direttiva Timeout del file di configurazione httpd.conf e per default è impostato a 300 secondi (5 minuti).
Quando Tcl scrive sui socket, per default su tutte le piattaforme, traduce il carattere di nuova linea in una sequenza crlf (carriage-return-linefeed), che normalmente è quella utilizzata per rappresentare il finelinea nelle connessioni di rete.
Negli script Tcl il finelinea si rappresenta sempre usando il carattere di newline singolo \n. Ad esempio sarebbe stato ugualmente giusto scrivere la puts come segue:
puts $sock "GET / HTTP/1.0\n"
Il newline finale è richiesto dal protocollo HTTP, i cui dettagli esulano dallo scopo di questo articolo.
Per fare scripting HTTP avanzato consiglio di studiare il pacchetto http(n) che è molto potente. Riscriviamo il nostro semplice client usando le funzionalità di questo pacchetto:
#!/usr/local/bin/tclsh8.4 # simple HTTP client package require http puts [http::data [http::geturl http://www.google.it]] ;# stampa solo il bodyListato 6. Ancora più semplice col package http!
Si tratta di un client di un altro servizio molto semplice, il servizio echo che viene di solito offerto sulla porta standard numero 7. In attesa di scrivere il nostro server echo, potete usare il server echo incorporato dentro inetd(8) per provare questo client.
Il client apre la connessione al server e gli manda tutto quello che legge da stdin. Il server risponde facendo l'eco di ciò che gli arriva, senza applicare alcuna trasformazione (ma non sarebbe difficile aggiungerla) e il client riceve la risposta del server e la manda su stdout. Alla fine dell'input l'echo client chiude la connessione al server. Sia il client che il server leggono e scrivono dai socket usati per comunicare, quindi la comunicazione è bidirezionale.
Ancora una volta abbiamo scelto di connetterci in modo sincrono, ossia il comando socket non ritorna finché il server non risponde alla richiesta di connessione. La comunicazione avviene linea per linea, nel senso che solo delle linee complete vengono mandate e ricevute dall'host remoto.
Come nel caso del client e del server daytime, l'I/O su socket viene fatto in modalità bloccante.
#!/usr/local/bin/tclsh8.4 # echoclient set EX_USAGE 64 if {$argc!=1 && $argc!=2} { puts stderr {usage: echoclient host [port]} exit $EX_USAGE } set sock [socket [lindex $argv 0] [expr {$argc>1 ? [lindex $argv 1] : {echo}}]] while {[gets stdin line]!=-1} { puts $sock $line flush $sock puts [gets $sock] }Listato 7. Un echo client orientato alla linea.
Come abbiamo visto è necessario l'uso della flush esattamente nel punto in cui l'ho inserita se l'output è pienamente bufferizzato. Questo avviene per default in Tcl, ma volendo può essere cambiato e questo nelle situazioni come la nostra in cui si usa un protocollo orientato alla linea per la sincronizzazione, evita di dover utilizzare molte istruzioni flush nel codice:
... fconfigure $sock -buffering line while {[gets stdin line]!=-1} { puts $sock $line puts [gets $sock] }
In questo caso anche il valore none va bene per l'opzione -buffering. Consultate la manpage fconfigure(n) per il dettagli.
La nostra prima versione è sequenziale, ossia può gestire una sola connessione per volta:
#!/usr/local/bin/tclsh8.4 # echod (sequential) proc echo {sock addr port} { fconfigure $sock -buffering line while {[gets $sock line]!=-1} { puts $sock $line } close $sock } socket -server echo echo vwait foreverListato 8. Prima versione (sequenziale) dell'echoserver.
Provate a lanciare il server e a connettervi due volte con l'echoclient (anche dallo stesso host usando due terminali virtuali differenti). Noterete che nella seconda connessione l'eco non avviene e poi avviene tutto in un blocco quando la prima connessione viene chiusa. Questo succede perché il kernel mantiene dei buffer per lo stdin; comunque nonostante potete continuare a digitare, il programma echoclient rimane bloccato sulla gets sul socket.
La versione concorrente richiede due procedure di callback separate: echoAccept come prima imposta la modalità di buffering a linee per il nuovo socket, in modo da forzare la trasmissione dei dati che sono scritti nel buffer non appena viene inserito un finelinea e poi tramite il comando fileevent(n) fa sì che venga chiamata la procedura di callback echo ogni volta che il socket usato per comunicare con il client diventa leggibile (readable). Questo significa che ci sono dei dati non ancora letti. Se questi dati consistono di una linea intera, come tipicamente avverrà, siamo sicuri che la gets dentro la procedura echo del server echod non bloccherà.
Inoltre il socket verrà considerato leggibile e la funzione echo verrà invocata anche quando viene raggiunta la fine del file e questa è la ragione per cui la procedura echo non consiste solo delle istruzioni gets e puts in sequenza, ma è necessario controllare il valore di ritorno di gets: se questo è -1 vuol dire che è stata raggiunta la fine dell'input dal socket, ovvero che l'echoclient ha chiuso la connessione. Occorre allora non tentare una puts che generebbe un errore di "pipe broken", ma piuttosto chiudere il socket usato per la comunicazione con il client tramite una close(n), per liberare il relativo descrittore. Quando il socket viene chiuso viene anche automaticamente cancellata la registrazione dell'handler echo sul socket stesso che avevamo fatto con fileevent(n).
#!/usr/local/bin/tclsh8.4 # echod (concurrent) proc echo {sock} { if {[gets $sock line]!=-1} { puts $sock $line } else { close $sock } } proc echoAccept {sock addr port} { fconfigure $sock -buffering line fileevent $sock readable "echo $sock" } socket -server echoAccept echo vwait foreverListato 9. Versione concorrente (ma non pienamente) dell'echoserver.
Abbiamo utilizzato per realizzare la versione concorrente l'I/O sempre in modalità bloccante, ma gestito ad eventi (event-driven I/O).
In realtà il nostro server non è pienamente concorrente, in quanto potrebbero verificarsi delle situazioni sfortunate in cui rimane bloccato per un certo tempo sulla gets o sulla puts all'interno dell'handler echo; quando un comando blocca non saranno processati gli eventi e quindi non potranno essere accettate immediatamente nuove connessioni in questo intervallo di tempo. La puts potrebbe bloccare nel tentativo di trasmettere una lunga stringa se la connessione è lenta o ci sono congestioni. Similmente la get potrebbe bloccare se, a causa della lentezza della rete, non è arrivata un'intera linea ma solo una porzione, quindi ci bloccheremo in attesa della porzione successiva fino al finelinea.
Una versione più sofisticata e pienamente concorrente prevede sempre l'uso dell'I/O gestito tramite eventi, ma stavolta in modalità non bloccante (nonblocking mode). Probabilmente non vale la pena di affrontare una tale complicazione nel caso specifico del semplice protocollo di test echo, ma naturalmente questa modalità avanzata dovrà essere utilizzata in serventi più complicati, quindi è bene conoscerla.
Nel modo non bloccante la puts e la gets non bloccheranno mai. Se non è arrivata un'intera linea di input, la gets non blocca, ritorna -1 e nella variabile line inserisce la stringa vuota, senza consumare alcun input. L'output in modalità non bloccante presenta invece maggiori difficoltà implementative. La puts invece di bloccare deve inserire i dati in un buffer; nel ciclo degli eventi poi quando il socket è pronto a ricevere altri dati, questo buffer verrà svuotato alla massima velocità possibile. Ovviamente questo richiede che l'applicazione entri nel ciclo degli eventi, il che non è un problema nel nostro caso in quanto il nostro server è permanentemente nel ciclo degli eventi.
Bisogna comunque stare attenti con l'output in modalità non bloccante, in quanto è possibile che venga bufferizzata una grande quantità di dati se l'applicazione continua a scrivere ma la connessione o il dispositivo ricevente sono molto lenti. Questo richiederà grosse quantità di memoria per il buffer. Per evitare di usare molta memoria è meglio usare l'I/O non bloccante solo in combinazione con l'I/O gestito ad eventi; in pratica questo significa che è raccomandabile usare la puts in modo non bloccante solo nell'handler che viene chiamato quando un canale si rende scrivibile (writable) (anch'esso definito tramite fileevent), che ci assicura che il canale è pronto a ricevere almeno un byte di dati senza bloccarsi.
Nel listato 10 presentiamo una versione migliorata dell'echod che risolve il problema del bloccaggio sulla gets, ma non ancora di quello della puts, quindi non è ancora 100% concorrente. Notate che stavolta la gets restituisce -1 sia se sopraggiunge la fine dell'input (e in questo caso chiamiamo la close sul socket), sia se la linea non è arrivata interamente (in questo caso non dobbiamo fare niente). Per distinguere i due casi occorre utilizzare il comando eof(n). Ora la puts è in modalità non bloccante, ma potrebbe causare il consumo di troppo spazio di buffer.
#!/usr/local/bin/tclsh8.4 # echod (fully concurrent) proc echo {sock} { if {[gets $sock line]!=-1} { puts $sock $line } elseif {[eof $sock]} { close $sock } } proc echoAccept {sock addr port} { fconfigure $sock -buffering line -blocking false fileevent $sock readable "echo $sock" } socket -server echoAccept echo vwait foreverListato 10. Versione pienamente concorrente dell'echoserver.
Notate che se viene eseguita una gets che non trova una linea completa nel buffer di input, il canale non sarà considerato più leggibile finché non arrivano nuovi dati. Questo consente di leggere l'input una linea alla volta in modalità non bloccante usando gli eventi, evitando ripetute chiamate dell'handler che monopolizzerebbero la CPU (fenomeno di attesa attiva o busy waiting).
Nel listato 11 l'I/O in modalità nonbloccante è completamente gestito tramite eventi. Questo rappresenta un'altra tecnica per realizzare server pienamente concorrenti, alternativa all'utilizzo dei thread o dei processi, e non meno efficace.
Notate l'uso dell'istruzione list per costruire un comando ben formato per la callback (provate ad usare "writeLine $sock $line" invece di [list writeLine $sock $line] e vedrete si verificherà un errore quando line è la stringa vuota). Notate inoltre come l'handler chiamato quando il socket si rende scrivibile venga impostato nella procedura readLine e cancellato nella procedura writeLine per evitare un loop infinito.
#!/usr/local/bin/tclsh8.4 # echod (fully concurrent) proc readLine {sock} { if {[gets $sock line]!=-1} { fileevent $sock writable [list writeLine $sock $line] } elseif {[eof $sock]} { close $sock } } proc writeLine {sock line} { fileevent $sock writable {} puts $sock $line } proc echoAccept {sock addr port} { fconfigure $sock -buffering line -blocking false fileevent $sock readable "readLine $sock" } socket -server echoAccept echo vwait foreverListato 11. Versione pienamente concorrente migliorata dell'echoserver.
Il listato 11 implementa un servizio di rete non molto utile, tuttavia rappresenta uno schema comune a molti altri protocolli più utili, basati su comandi costituiti da una sola linea. La procedura readLine legge il comando o rileva la fine della connessione. La procedura writeLine costruisce la risposta che nel caso di un echoserver è semplicemente l'echo del comando, ma potete facilmente aggiungere il codice e le elaborazioni che volete.
Ci sono vantaggi e svantaggi di una soluzione basata su processi o thread e un'altra basata su singolo processo e sugli eventi. Un vantaggio della prima è che se il processo server viene killato, le connessioni correnti possono rimanere ancora attive, in quanto gestite da altri processi, mentre nella seconda soluzione tutte le connessioni correnti vengono terminate alla chiusura forzata del server. La soluzione basata sugli eventi potrebbe essere più efficiente di quella basata su processi/thread in quando non vi è l'overhead della creazione/distruzione di nuovi processi o thread, ma in pratica l'efficienza dipende dipende anche dal linguaggio usato, dalla sua implementazione e dal sistema operativo. La soluzione che usa gli eventi è forse più semplice da programmare rispetto a quella che usa i processi/thread, ma anche qui molto dipende dal linguaggio usato e persino dai gusti personali del programmatore.
Nella mia lan casalinga ho fatto girare l'echo server su una macchina FreeBSD il cui IP è 192.168.1.1 e mi sono connesso da un'altra macchina FreeBSD con IP 192.168.1.3 tramite l'echo client. Con la connessione aperta ho dato un comando che elenca i socket di Internet versione 4 aperti, una volta sul server e una volta sul client. Il risultato è istruttivo e lo riporto di seguito. Sulla macchina server ottengo:
$ sockstat -4 USER COMMAND PID FD PROTO LOCAL ADDRESS FOREIGN ADDRESS root tclsh8.4 170 3 tcp4 *:7 *:* root tclsh8.4 170 4 tcp4 192.168.1.1:7 192.168.1.3:1024 ...
La prima riga rappresenta il socket ascoltatore. La seconda rappresenta il socket connesso (se ci sono più connessioni ci saranno più linee di questo tipo). Sul client invece ho (posso limitarmi ai socket connessi tramite l'opzione c):
$ sockstat -4c USER COMMAND PID FD PROTO LOCAL ADDRESS FOREIGN ADDRESS ant tclsh8.4 138 3 tcp4 192.168.1.3:1024 192.168.1.1:7 ...
In Linux al posto del comando sockstat di FreeBSD occorre utilizzare netstat. Sul server ho:
$ netstat --ip -a Active Internet connections (servers and established) Proto Recv-Q Send-Q Local Address Foreign Address State tcp 0 0 *:echo *:* LISTEN tcp 0 0 192.168.1.1:echo 192.168.1.3:32772 ESTABLISHED ...mentre sul client:
$ netstat --ip Active Internet connections (w/o servers) Proto Recv-Q Send-Q Local Address Foreign Address State tcp 0 0 192.168.1.3:32772 192.168.1.1:echo ESTABLISHED ...
Windows fornisce un comando netstat simile a quello di Unix da digitare in una finestra DOS. Per il server utilizzare il comando:
C:\WINDOWS>netstat -na Connessioni attive Proto Indirizzo locale Indirizzo remoto Stato TCP 0.0.0.0:7 0.0.0.0:0 LISTENING TCP 127.0.0.1:7 127.0.0.1:1027 ESTABLISHED ...mentre per il client:
C:\WINDOWS>netstat -n Connessioni attive Proto Indirizzo locale Indirizzo remoto Stato TCP 127.0.0.1:1027 127.0.0.1:7 ESTABLISHED ...
« Tcl: Manipolazione degli array • Appunti di Tcl/Tk • Tcl: »