Nei titoli e nei testi troverete qualche rimando cinematografico (ebbene si, sono un cinefilo). Se non vi interessano fate finta di non vederli, già che non sono fondamentali per la comprensione dei post...

Di questo blog ho mandato avanti, fino a Settembre 2018, anche una versione in Spagnolo. Potete trovarla su El arte de la programación en C. Buona lettura.

domenica 19 marzo 2023

The Big Select
come usare la select(2) in 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 (si, si, lo so, e i più attenti se ne saranno già accorti: il film e il dialogo li ho già usati per un altro articolo... ma l'ho rivisto da poco (il film) e non ho resistito alla tentazione di ri-utilizzarlo, chiedo venia...).

...sorseggiavo il mio White Russian e pensavo: "Ma... e la select(2)?"...

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:

  1. Non è immediatamente evidente dove e quando sia utile usarla.
  2. 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:

  1. Si definiscono, usando il tipo fd_set, i set di descrittori di file da sorvegliare.
  2. Si inizializzano i set usando la macro FD_ZERO (per azzerare il set) seguita da FD_SET (per aggiungere un file al set).
  3. Si prepara il timeout riempiendo una struct timeval (ovvero si scrivono i secondi e i microsecondi che compongono il nostro timeout)
  4. Si lancia la select(2) con gli argomenti preparati nei punti precedenti.
  5. 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!