The Big Select
come usare la select(2) in C e C++ – pt.1
Poliziotto: E nella valigetta? Drugo: Oh, beh, documenti, solo documenti. Già, solo i miei documenti. Documenti di lavoro. Poliziotto: Che lavoro fa? Drugo: Sono disoccupato.
Stavo cercando di ultimare la seconda parte dell’oramai mitico articolo sulla programmazione real-time e mi sono reso conto che in anni e anni di onorata carriera divulgativa (ehm…) non ho mai parlato della select(2). Non sia mai! La select(2) è una funzione così importante che non si può rimandare ulteriormente. L’altro articolo dovrà aspettare ancora un po’ (anzi, smetto di parlarne, quando arriverà sarà una sorpresa): oggi si parla di select(2) e farò come il drugo del capolavoro dei fratelli Coen: lui si che è un tipo concreto (vedi il dialogo qui sopra) uno con degli obiettivi precisi e diretti.
E allora veniamo al dunque: la select(2) è una system-call importantissima che permette (come dice il manuale) di eseguire il “synchronous I/O multiplexing”, e cioè permette di sorvegliare più canali di I/O alla volta (che tipo di canali? Pensate ai socket, ai file aperti, ecc.) per verificare quando sono pronti per una nuova operazione di read/write. Cioè, in pratica, permette di eseguire in un singolo thread di esecuzione quello che spesso si esegue (in maniera ingiustificata) in multithreading (e scusate se è poco!). Un piccolo esempio: un buon Server TCP che serve 10000 Client: secondo voi è più efficiente e funzionale aprire 10000 thread che aspettano i dati dai Client o usare il multiplexing ? Se qualcuno pensa che è meglio aprire 10000 thread il mio consiglio è:
- mettere le scarpe da running e correre per 10 Km a buon ritmo (un metro per ogni thread…).
- dopodiché fare una bella doccia rilassante e ripensare all’argomento con la mente (ora) decisamente più aperta.
- a questo punto, se si preferiscono ancora i 10000 thread, c’è da considerare l’idea di cambiare mestiere.
Ma, ovviamente, scherzo: ci sono in giro Server TCP e Web con multithread “spinto”, scritti da gente brava e competente, in grado di servire ben più di 10000 connessioni alla volta (usando, però, mostruose risorse Hardware di CPU e RAM). In ogni caso io continuo a pensare che il mutithreading viene usato spesso a sproposito per semplice pigrizia progettuale, e quando posso lo evito (ah, dimenticavo: il numero 10000 qui sopra non l’ho scelto a caso, ci ritorneremo nella seconda parte dell’articolo).
Eppure, nonostante gli evidenti meriti, la select(2) è abbastanza misconosciuta, e penso che i motivi siano due:
- Non è immediatamente evidente dove e quando sia utile usarla.
- Non è semplicissima da usare, visto che lavora in simbiosi con ben quattro macro, che preparano l’ambiente di esecuzione e testano i risultati.
E allora cerchiamo di fare chiarezza, siamo qui per questo! Per quanto riguarda il punto 1 lo abbiamo già descritto sopra, e la parola magica è “multiplexing” (anche se, in realtà, ci sono anche altri usi interessanti che vedremo prossimamente). Una volta chiarito dove e quando usarla si può passare al come, e credo che può tornare utile questa piccola lista che ho scritto, con le descrizioni degli argomenti della select(2) e delle quattro macro abbinate:
// select() - gestisce il synchronous I/O multiplexing su un set di descrittori di file int select( int nfds, // fd con il numero più alto (+1) nei 3 set sorvegliati fd_set *readfds, // set di fd da sorvegliare per "ready for reading" fd_set *writefds, // set di fd da sorvegliare per "ready for writing" fd_set *exceptfds, // set di fd da sorvegliare per eventi eccezionali struct timeval *timeout); // tempo di bloccaggio del set durante la sorveglianza // FD_CLR() - rimuove il file descriptor fd dal set di descrittori FD_CLR( int fd, // file descriptor da rimuovere dal set fd_set *set); // set di file descriptor // FD_SET() - cerca il file descriptor fd nel set di descrittori FD_ISSET( int fd, // file descriptor da cercare nel fd_set *set); // set di file descriptor // FD_SET() - aggiunge il file descriptor fd al set di descrittori FD_SET( int fd, // file descriptor da aggiungere al set fd_set *set); // set di file descriptor // FD_SET() - rimuove tutti i file descriptor dal set di descrittori FD_ZERO( fd_set *set); // set di file descriptor da svuotare
E tutto questo lo trovate anche nel manuale, eh! E, sicuramente, con più dettagli e con migliori descrizioni, ma lo specchietto qui sopra è una specie di quick-reference guide per chi non ha voglia di leggersi le mille spiegazioni del manuale (che, in questo caso, sono un po’ complesse e magari contribuiscono a far passare la voglia di usare la select(2)…). Nella stessa pagina del manuale si descrive anche una system-call “gemella”, la pselect(2), che è sostanzialmente identica a parte queste caratteristiche:
- Ha un sesto argomento sigmask che, come indica il nome, permette di personalizzare i segnali POSIX surante l’esecuzione della pselect(2): rimpiazza la sigmask del processo con la nuova sigmask e poi reinstalla quella originale al termine dell’attività della pselect(2). Questa è, evidentemente, una funzionalità molto utile e interessante.
- Usa una struct timeval per il timeout (invece di una struct timespec): questo cambio tipo è abbastanza irrilevante, ma è associato a un comportamento differente: il valore del timeout viene mantenuto costante durante l’attività della pselect(2), mentre potrebbe venire aggiornato (decrementato) durante l’azione della select(2): anche questo fatto è da tenere in conto scrivendo il codice.
Un ultimo appunto lo merita il descrittore exceptfds: per “eventi eccezionali” non si intendono gli errori ma, tipicamente, messaggi speciali (“Urgent Messages”) generati da alcuni protocolli: un buon esempio è il messaggio “out-of-band” che si può ricevere su un socket TCP (ma questo è un argomento molto particolare che necessiterebbe un articolo a parte: diciamo che il set exceptfds si usa poco e in casi molto specifici).
E quindi come si usa la select? Diciamo che per un uso “classico” sono sufficienti questi cinque passi:
- Si definiscono, usando il tipo fd_set, i set di descrittori di file da sorvegliare.
- Si inizializzano i set usando la macro FD_ZERO (per azzerare il set) seguita da FD_SET (per aggiungere un file al set).
- Si prepara il timeout riempiendo una struct timeval (ovvero si scrivono i secondi e i microsecondi che compongono il nostro timeout)
- Si lancia la select(2) con gli argomenti preparati nei punti precedenti.
- Si testa il risultato della select(2) per eseguire le varie ed eventuali operazioni che necessitiamo.
E qui casca a fagiolo un bell’esempio pratico ed elementare, lo stesso presente nel manuale, tradotto e con qualche commento in più (perché inventarne uno nuovo? Questo è veramente ben fatto). In quest’esempio si sorveglia il descrittore 0 che non è nient’altro che il famoso standard input “stdin” e, in base all’attività sullo stdin (ossia se scriviamo o no qualcosa sulla tastiera), visualizzeremo il risultato corrispondente: nell’esempio il timeout è di 5 secondi e quindi, se non scriviamo nulla, apparirà dopo 5 secondi la scritta “nessun dato disponibile” , ma se scriviamo qualcosa prima che scada il timeout, apparirà la scritta “ci sono dati disponibili”. Semplicissimo, no? Vai col codice!
#include <stdio.h> #include <stdlib.h> #include <sys/select.h> // funzione main() int main(void) { fd_set rfds; struct timeval tv; int retval; // sorvegliamo stdin (fd 0) per verificare se viene scritto qualcosa FD_ZERO(&rfds); // azzero il set FD_SET(0, &rfds); // aggiungo stdin (il fd 0) al set // set del timeout a 5 secondi tv.tv_sec = 5; // set di 5 sec tv.tv_usec = 0; // set di 0 usec (utile per aggiungere frazioni di secondo) retval = select(1, &rfds, NULL, NULL, &tv); /* N.B. da qui in avanti il valore di tv cambia dinamicamente: bisogna tenerlo in conto nel caso di usarlo! */ if (retval == -1) { // retval == -1 indica che la select() ha fallito perror("select()"); } else if (retval) { /* retval > 0 indica che qualcuno ha scritto qua si poteva anche usare questo test: FD_ISSET(0, &rfds) > 0 */ printf("ci sono dati disponibili!\n"); } else { // retval == 0 indica che è scaduto il timeout printf("nessun dato disponibile\n"); } exit(EXIT_SUCCESS); }
Ok, per oggi può bastare. Spero che questa introduzione abbia fatto comprendere la potenza e l’utilità della sistem-call select(2) e abbia fatto venire la voglia a qualcuno di usarla in qualche progetto reale. Nella seconda parte dell’articolo parleremo delle criticità della select(2) (spoiler: ahimè, ce ne sono! Ad esempio quel 10000 usato qua sopra…), parleremo delle possibili alternative e, dulcis in fundo, proporrò un esempio di uso “non proprio canonico” della select(2) che potrebbe interessare a molti. Non state in pena, ci risentiremo presto!
Ciao, e al prossimo post!