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.

mercoledì 26 aprile 2023

The Big Select
come usare la select(2) in C - pt.2

Big Lebowski: Cos'è... cos'è che fa di un uomo un uomo, signor Lebowski?
Drugo: Non... non lo so, signore.
Big Lebowski: Essere pronti a fare ciò che è più giusto. A qualunque costo. Non è questo che fa di un uomo un uomo?
Drugo: Sì, quello e un paio di testicoli.

Nel capolavoro The Big Lebowski, il Lebowski "grande" diceva al Drugo che un vero uomo fa sempre la cosa giusta, a qualunque costo (e vabbè, poi il Drugo con il suo solito acume aggiungeva un piccolo dettaglio...). Riportando questo al nostro caso, e cioè all'uso della ottima e indispensabile system-call select(2), potrebbe sembrare che usarla non sia proprio la cosa più giusta, visto che, come anticipato nello scorso articolo, ci sono delle controindicazioni. Ok, è venuto il momento di verificare se è il caso di usarla o no!

...cioè, spiegami bene 'sta storia delle controindicazioni...

Allora, il manuale della select(2) inizia la descrizione con un Warning, il che dovrebbe preoccuparci:

WARNING: select() can monitor only file descriptors numbers that
are less than FD_SETSIZE (1024) — an unreasonably low limit for
many modern applications — and this limitation will not change.
All modern applications should instead use poll(2) or epoll(7),
which do not suffer this limitation.

e certo, se lo dice il Linux Programmer's Manual c'è poco da dubitare. E poi, chi siamo noi per dubitare del manuale? Eppure io, in questo caso particolare, dubito, e non perché voglia mettere in dubbio quanto sopra (e ci mancherebbe! Il manuale è la bibbia del programmatore Linux!), ma perché mi sembra che il Warning sia strettamente riferito a un dettaglio implementativo di Linux (e, infatti, il manuale POSIX della pselect(3p) non ne parla) e, soprattutto, la frase "an unreasonably low limit for many modern applications" è un po' eccessiva: secondo me le applicazioni che devono maneggiare più di 1024 file descriptors alla volta sono l'eccezione e non la regola. L'importante è ricordarsene al momento opportuno, e se necessario usare, come consigliato, poll(2) o epoll(7).

E a proposito della system-call poll(2) si può aggiungere che ha, esattamente come la select(2), una "versione p" che si chiama ppoll(2), che differisce dalla versione "normale" più o meno come la select(2) differisce dalla pselect(2):

  • Usa una struct timespec per il timeout mentre la poll(2) usa un int  (in  millisecondi).
  • Il timeout ha un comportamento differente: il valore del timeout viene mantenuto costante durante l'attività della ppoll(2), mentre potrebbe venire aggiornato (decrementato) durante l'azione della poll(2).
  • Ha un ulteriore argomento sigmask che, come indica il nome, permette di personalizzare i segnali POSIX durante l'esecuzione della ppoll(2): rimpiazza la sigmask del processo con la nuova sigmask e poi reinstalla quella originale al termine dell'attività della chiamata.

Se avete scritto del Software che usa la select(2) e volete convertirlo rapidamente alla poll(2) potete seguire questo semplice esempio (adattandolo alle esigenze del caso):

////////////////////////////////////////////////////////////////////////////////
VERSIONE CON LA SELECT
////////////////////////////////////////////////////////////////////////////////

// sorvegliamo stdin (fd 0) per verificare se viene scritto qualcosa
fd_set rfds;
FD_ZERO(&rfds); // azzero il set
FD_SET(0, &rfds); // aggiungo stdin (il fd 0) al set

// set del timeout a 5 secondi
struct timeval tv;
tv.tv_sec = 5; // set di 5 sec
tv.tv_usec = 0; // set di 0 usec (utile per aggiungere frazioni di secondo)

// chiamo select(2)
int retval = select(1, &rfds, NULL, NULL, &tv);

// uso retval
// ...

////////////////////////////////////////////////////////////////////////////////
VERSIONE CON LA POLL
////////////////////////////////////////////////////////////////////////////////

// sorvegliamo stdin (fd 0) per verificare se viene scritto qualcosa
struct pollfd rfds;
rfds.fd = 0; // aggiungo stdin (il fd 0) al set
rfds.events = POLLIN; // per verificare se ci sono dati da leggere

// set del timeout a 5 secondi
int timeout = 5000; // set di 5000 ms

// chiamo poll(2)
int retval = poll(&rfds, 1, timeout);

// uso retval
// ...

facile, no?

La epoll(7), invece, è tutta un'altra storia: è una vera e propria API presente solo su Linux che permette, usando le varie funzioni a disposizione (epoll_create(2), epoll_create1(2), epoll_ctl(2), epoll_wait(2)) un uso veramente molto sofisticato (ma, ahimè, abbastanza complicato) della gestione degli eventi di I/O: da usare proprio per esigenze particolari, direi.

E veniamo al famoso 10000, il numero scelto, non a caso, nell'articolo precedente per anticipare i problemi della select(2): visto il limite di 1024 file descriptors, la select(2) non è la system-call più adatta a evitare il famigerato C10K problem, che descrive esattamente il caso di un mega-server che deve trattare moltissime connessioni contemporanee: l'autore del primo articolo che trattò l'argomento scelse a titolo esemplificativo il numero 10000 (che, ai tempi, nel 1999, era un numero enorme per l'Hardware disponibile, mentre ora questo numero sembra persino piccolissimo per i mega-server attuali). Ecco spiegato il mistero del 10000 (e leggetevi l'articolo citato sopra, è molto interessante).

Ed ora, come promesso nella prima parte dell'articolo, vi propongo un uso un po' originale della select(2): non so se ricordate il ciclo di articoli sulle POSIX IPC, dove nel capitolo riservato alle Message Queue avevo mostrato due programmi reader e writer (in pratica un server e un client) che usavano le funzioni mq_send(3) e mq_receive(3). Queste due funzioni hanno delle versioni "con timeout", che si chiamano, rispettivamente, mq_timedend(3) e mq_timedrecv(3). Queste versioni sono, in alcuni casi, molto utili, e sono assenti nelle analoghe chiamate send(2) e recv(2) della classica interfaccia BSD Socket. Per colmare questa mancanza ho scritto due nuove funzioni, timedSend() e timedRecv() che usano, ovviamente, la select(2) per la gestione del timeout. Le due nuove funzioni hanno gli stessi parametri delle versioni "normali" con l'aggiunta di un ulteriore parametro "timeout_ms" che serve a impostare, per l'appunto, il timeout. E allora vediamole, 'ste funzioni: vai col codice!

include <stdio.h>
#include <errno.h>
#include <sys/select.h>
#include <sys/types.h>
#include <sys/socket.h>
#include "timedSendRecv.h" // contiene solo i prototipi

// timedRecv - una recv(2) con timeout
ssize_t timedRecv(int sockfd, void *buf, size_t len, int flags, unsigned int timeout_ms)
{
// test se timeout_ms è maggiore di 0
if (timeout_ms) {
// timeout maggiore di 0: set del timeout della select(2)
struct timeval tv;
tv.tv_sec = timeout_ms / 1000;
tv.tv_usec = (timeout_ms % 1000) * 1000;

// eseguo select(2) e (eventualmente) la recv(2)
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);
int rc = select(sockfd + 1, &readfds, NULL, NULL, &tv);
if (rc == -1) {
// select(2) fallita: ritorno errore senza cambiare errno
return -1;
} else if (rc) {
// dati disponibili: eseguo recv(2)
return recv(sockfd, buf, len, flags);
} else {
// timeout scaduto: ritorno errore con errno=ETIMEDOUT (utile per il chiamante)
errno = ETIMEDOUT;
return -1;
}
}
else {
// timeout_ms è 0: eseguo direttamente la recv(2)
return recv(sockfd, buf, len, flags);
}
}

// timedSend - una send(2) con timeout
ssize_t timedSend(int sockfd, const void *buf, size_t len, int flags, unsigned int timeout_ms)
{
// test se timeout_ms è maggiore di 0
if (timeout_ms) {
// timeout maggiore di 0: set del timeout della select(2)
struct timeval tv;
tv.tv_sec = timeout_ms / 1000;
tv.tv_usec = (timeout_ms % 1000) * 1000;

// eseguo select(2) e (eventualmente) la send(2)
fd_set writefds;
FD_ZERO(&writefds);
FD_SET(sockfd, &writefds);
int rc = select(sockfd + 1, NULL, &writefds, NULL, &tv);
if (rc == -1) {
// select(2) fallita: ritorno errore senza cambiare errno
return -1;
} else if (rc) {
// dati disponibili: eseguo send(2)
return send(sockfd, buf, len, flags);
} else {
// timeout scaduto: ritorno errore con errno=ETIMEDOUT (utile per il chiamante)
errno = ETIMEDOUT;
return -1;
}
}
else {
// timeout_ms è 0: eseguo direttamente la send(2)
return send(sockfd, buf, len, flags);
}
}

Che ne dite? Sono relativamente semplici, stra-commentate (non credo che ci sia nulla da aggiungere) e funzionano anche bene! Io le uso da molto tempo anche in progetti reali, e permettono alcuni "giri di codice" interessanti, per esempio quando è necessario fare un loop di lettura non bloccante. Provare per credere!

Ok, per oggi può bastare, e anzi può bastare anche per l'argomento select/poll (su cui si potrebbe scrivere un libro, ma per il momento ci fermiamo qui). Per il prossimo articolo non vi prometto nessun argomento in particolare (che poi non mantengo le promesse e mi devo pure scusare). L'unica cosa che vi prometto è che sarà sicuramente molto interessante! (ehm, che modestia...).

Ciao, e al prossimo post!