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.

giovedì 20 febbraio 2025

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

Drugo: E se poi quello se la prende?
Bunny: A lui non importa niente di niente, è un nichilista.
Drugo: Ah, dev’essere faticoso da morire.

Per la serie "battere il ferro finché è caldo"  ho pensato che, dopo il Socket Server multithread dell'ultimo articolo, era il caso di presentare un Socket Server multiplex, un oggetto di cui ho accennato alcune volte ma che non ho mai mostrato (quindi un oggetto avviato a diventare mitico, ah ah ah). E, visto che un Server di quel tipo si basa sulla famosa select(2) che ho già trattato in un articolo in due parti (qui e qui), ho pensato di fare una terza parte di quella serie. E, quindi, cercherò di non fare come il mitico Drugo (lui si che è veramente mitico) del capolavoro The Big Lebowski, e invece di riposare sugli allori vi mostrerò, in tutto il suo splendore, un'altro gran uso della select(2)!

...che fatica la select(2). Quasi quasi mi faccio un riposino...

E, in effetti, il mio primo accenno al Socket Server multiplex l'avevo fatto proprio nel primo articolo della serie, con questa affermazione:

"...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 ?..."

salvo poi "ammorbidire" l'affermazione poche linee dopo:

"...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)..."

Ecco, le due affermazioni (divergenti) qui sopra dimostrano che la scelta multithreading vs multiplexing è una scelta quasi filosofica: hanno entrambe pro e contro e, alla fin fine, si può decidere per l'una o per l'altra in base alle esigenze del momento. O, chissà, è solo una questione di "de gustibus"...

E allora bando alla ciance: vai col codice!

// sockserver-mp.c - un semplice socket server multiplex
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/select.h>

#define BACKLOG 10 // per listen(2)
#define MYBUFSIZE 1024

// funzione main()
int main(int argc, char *argv[])
{
// test argomenti
if (argc != 2) {
// errore args
printf("%s: numero argomenti errato\n", argv[0]);
printf("uso: %s port [i.e.: %s 9999]\n", argv[0], argv[0]);
return EXIT_FAILURE;
}

// creo il socket in modo Network e Stream
int sock;
if ((sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)) == -1) {
// errore di creazione
printf("%s: non posso creare il socket (%s)\n", argv[0], strerror(errno));
return EXIT_FAILURE;
}

// prepara la struttura sockaddr_in per questo server
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET; // set address family
server.sin_addr.s_addr = INADDR_ANY; // set server address per qualunque interfaccia
server.sin_port = htons(atoi(argv[1])); // set port number del server

// associa l'indirizzo del server al socket
if (bind(sock, (struct sockaddr *)&server, sizeof(server)) == -1) {
// errore bind
printf("%s: errore bind (%s)\n", argv[0], strerror(errno));
return EXIT_FAILURE;
}

// avvio ascolto con una coda di max BACKLOG connessioni
if (listen(sock, BACKLOG) == -1) {
// errore listen
printf("%s: errore listen (%s)\n", argv[0], strerror(errno));
close(sock);
return EXIT_FAILURE;
}

// inizializzo il fd_set attivo
fd_set active_fd_set;
FD_ZERO(&active_fd_set);
FD_SET(sock, &active_fd_set);
int max_sock = sock; // max_sock: numero massimo di socket pronti

// loop per accettare connessioni e messaggi da client entranti
printf("%s: attesa connessioni entranti...\n", argv[0]);
for (;;) {
// uso la select(2) sui socket in lettura aperti
fd_set read_fd_set = active_fd_set;
if (select(max_sock + 1, &read_fd_set, NULL, NULL, NULL) < 0) {
printf("%s: errore select (%s)\n", argv[0], strerror(errno));
exit(EXIT_FAILURE);
}

// loop sui socket aperti
for (int my_sock = 0; my_sock <= max_sock; ++my_sock) {
// cerco attivitá nel set di socket
if (FD_ISSET(my_sock, &read_fd_set)) {
// c'è attivitá su un socket del set di socket in lettura
if (my_sock == sock) {
// è il socket principale: accetta una connessione da un client entrante
socklen_t socksize = sizeof(struct sockaddr_in);
struct sockaddr_in client; // (remote) client socket info
int client_sock;
if ((client_sock =
accept(sock, (struct sockaddr *)&client, &socksize)) == -1) {

// errore accept()
printf("%s: errore accept (%s)\n", argv[0], strerror(errno));
return EXIT_FAILURE;
}

// connessione accettata: aggiorno il active_fd_set
printf("%s: connessione accettata dal sock %d\n", argv[0], client_sock);
FD_SET(client_sock, &active_fd_set);
if (client_sock > max_sock)
max_sock = client_sock;
}
else {
// è un socket secondario: ricezione di messaggi dal client
int read_size;
char client_msg[MYBUFSIZE];
memset(client_msg, 0, MYBUFSIZE);
if ((read_size = recv(my_sock, client_msg, MYBUFSIZE, 0)) > 0 ) {
// send messaggio di ritorno al client
printf("%s: ricevuto messaggio dal sock %d: %s\n",
argv[0], my_sock, client_msg);
char server_msg[MYBUFSIZE];
snprintf(server_msg, sizeof(server_msg), "mi hai scritto: %s", client_msg);
send(my_sock, server_msg, strlen(server_msg), 0);
}
else {
// lettura non riuscita: test del motivo
if (read_size == -1) {
// errore recv()
printf("%s: errore recv\n", argv[0]);
return EXIT_FAILURE;
}
else {
// read_size == 0: il client si è disconnesso
printf("%s: client disconnesso\n", argv[0]);
FD_CLR(my_sock, &active_fd_set);
}
}
}
}
}
}

// esco con Ok
return EXIT_SUCCESS;
}

Sicuramente avrete notato che il codice (ben commentato, come sempre) del Socket Server multiplex è una via dimezzo tra quello monothread (visto qui) e quello multithread (visto qui): la parte iniziale è la solita, almeno fino alla fase di listen(2), per poi divergere nella fase di accept(2) che, nelle due versioni "multi", viene ripetuta più volte, ogni volta che un Client chiede di connettersi: nel multithread viene istanziato un thread per ogni client, mentre nel multiplex si mantiene tutto nel main thread e si aggiunge un nuovo socket descriptor alla lista della select(2) per ogni nuovo Client collegato. L'idea è abbastanza semplice, anche se, bisogna ammetterlo, il codice del multiplex, alla fine, è un po' più complicato (ma non troppo).

La parte di codice fondamentale è questa:

// inizializzo il fd_set attivo
...

// loop per accettare connessioni e messaggi da client entranti
for (;;) {
// uso la select(2) sui socket in lettura aperti
...

// loop sui socket aperti
for (int my_sock = 0; my_sock <= max_sock; ++my_sock) {
// cerco attivitá nel set di socket
if (FD_ISSET(my_sock, &read_fd_set)) {
// c'è attivitá su un socket del set di socket in lettura
if (my_sock == sock) {
// è il socket principale: accetta una connessione da un client entrante
...

// connessione accettata: aggiorno il active_fd_set
...
}
else {
// è un socket secondario: ricezione di messaggi dal client
...
}
}
}
}
...

E cioè:

  1. Si inizializza il fd_set che userà successivamente la select(2).
  2. Si avvia un loop infinito al cui interno si usa la select(2) per sorvegliare (sul set di lettura readfds) le scritture che arrivano al Server.
  3. Si fa un loop sui socket aperti al momento (al primo giro ci sarà solo quello usato dalla listen(2)) e si valuta il da farsi:
    - se è il socket "principale" (quello usato dalla listen(2)) significa che è un nuovo Client che vuole collegarsi, e quindi si esegue accept(2).
    - se è uno dei nuovi socket aperti dalla accept(2) significa che è uno dei Client  già "accettati" che ci sta scrivendo, e quindi gli si risponde.
  4. Si torna al punto 3.

Che vi sembra? Al prezzo di un codice un po' più complicato si riesce a fare, con un solo thread, lo stesso che si fa in multithread... mica male, no?

Per testare il nostro Socket Server è necessario compilare anche un Socket Client  (ovviamente quello descritto in un altro mio vecchio post, Il Client oscuro - Il ritorno), ed eseguire, ad esempio, una istanza del Server e due istanze del Client (in tre terminali diversi della stessa macchina, oppure su tre macchine diverse). Eseguendo sulla mia macchina (Linux, ovviamente) su tre terminali il risultato è il seguente:

Nel terminale 1:

aldo@Linux $ ./sockserver-mp 9999
./sockserver-mp: attesa connessioni entranti...
./sockserver-mp: connessione accettata dal sock 4
./sockserver-mp: connessione accettata dal sock 5
./sockserver-mp: ricevuto messaggio dal sock 4: pippo
./sockserver-mp: ricevuto messaggio dal sock 5: pluto
./sockserver-mp: client disconnesso
./sockserver-mp: client disconnesso

Nel terminale 2:

aldo@Linux $ ./sockclient 127.0.0.1 9999
Scrivi un messaggio per il Server remoto: pippo
./sockclient: Server reply: mi hai scritto: pippo
Scrivi un messaggio per il Server remoto: ^C

Nel terminale 3:

aldo@Linux $ ./sockclient 127.0.0.1 9999
Scrivi un messaggio per il Server remoto: pluto
./sockclient: Server reply: mi hai scritto: pluto
Scrivi un messaggio per il Server remoto: ^C

notare che quando uno dei Client esce (con un CTRL-C, ad esempio) il Server  se ne accorge e visualizza, come previsto, client disconnesso... perfetto!

E, a questo punto, bisognerebbe fare delle considerazioni più profonde, tipo che per un Server ad alte prestazioni bisognerebbe usare la poll(2)  invece della select(2) che può maneggiare solo  1024 file descriptors. Oppure che bisogna tenere presente il C10K problem (non a caso nella citazione iniziale ho usato il numero 10000...), ecc. Ma se andate a rileggervi il secondo articolo della serie (e magari anche il primo) è già tutto spiegato li. Mi raccomando, fatelo!

Ok, per oggi può bastare. Nel prossimo articolo giuro che non parlerò di Server  e Client... magari scriverò qualcosa di più legato alla programmazione C di base. Vedremo!

Ciao e al prossimo post! 

lunedì 20 gennaio 2025

Thread Runner
come usare i thread in C - pt.3

Eldon Tyrell: La luce che arde col doppio di splendore brucia per metà tempo. E tu hai sempre bruciato la tua candela da due parti, Roy. Guardati: tu sei il figliol prodigo. Sei motivo d'orgoglio per me.
Roy Batty: Ho fatto delle cose discutibili...
Eldon Tyrell: Anche delle cose straordinarie, Roy. Godi più che puoi.
Roy Batty: Cose per cui il Dio della biomeccanica non ti farebbe entrare in paradiso.

(...una premessa: questo post è un remake di un mio vecchio post (parte 3 di 3). Ma, anche se tratta lo stesso argomento, amplia e perfeziona un po' il discorso è mi è sembrato il caso di riproporlo. Leggete e mi direte...)

Con questo post chiudiamo (in bellezza, spero) il mini-ciclo sui thread, ispirato al mitico Blade Runner del Maestro Ridley Scott.

...è qui che si scrivono applicazioni multithread?...

Dopo gli esempi base delle prime due parti del ciclo (che avete appena riletto, vero? qui e qui), è il caso di fare un esempio pratico di una delle tante applicazioni che possono usare i thread. E tra le tante ne ho scelto una che mi sembra interessante, ovvero un Socket Server multithread, dove ogni connessione con un Client remoto viene gestita con un thread separato. Una raccomandazione: prima di andare avanti dovreste rileggere un mio vecchio post, e cioè: Il Server oscuro - Il ritorno, che è una ideale introduzione all'argomento in corso, visto che descrive (e bene, spero) funzionalità e codice di un Socket Server monothread. Tra l'altro (come noterete tra poco) il nuovo codice che vi mostrerò è parente strettissimo di quello mostrato nel vecchio post.

E ora bando alle ciance, vai col codice!

// sockserver-mt.c - un semplice socket server multithread
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <pthread.h>
#include <arpa/inet.h>

#define BACKLOG 10 // per listen()
#define MYBUFSIZE 1024

// prototipi locali
void *connHandler(void *conn_sock);

// funzione main()
int main(int argc, char *argv[])
{
// test argomenti
if (argc != 2) {
// errore args
printf("%s: numero argomenti errato\n", argv[0]);
printf("uso: %s port [i.e.: %s 9999]\n", argv[0], argv[0]);
return EXIT_FAILURE;
}

// creo il socket in modo Network e Stream
int sock;
if ((sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)) == -1) {
// errore di creazione
printf("%s: non posso creare il socket (%s)\n", argv[0], strerror(errno));
return EXIT_FAILURE;
}

// prepara la struttura sockaddr_in per questo server
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET; // set address family
server.sin_addr.s_addr = INADDR_ANY; // set server address per qualunque interfaccia
server.sin_port = htons(atoi(argv[1])); // set port number del server

// associa l'indirizzo del server al socket
if (bind(sock, (struct sockaddr *)&server, sizeof(server)) == -1) {
// errore bind
printf("%s: errore bind (%s)\n", argv[0], strerror(errno));
return EXIT_FAILURE;
}

// start ascolto con una coda di max BACKLOG connessioni
if (listen(sock, BACKLOG) == -1) {
// errore listen
printf("%s: errore listen (%s)\n", argv[0], strerror(errno));
close(sock);
return EXIT_FAILURE;
}

// accetta connessioni da un client entrante
printf("%s: attesa connessioni entranti...\n", argv[0]);
pthread_t thread_id;
socklen_t socksize = sizeof(struct sockaddr_in);
struct sockaddr_in client; // (remote) client socket info
int client_sock;
while ((client_sock = accept(sock, (struct sockaddr *)&client, &socksize)) != -1) {
printf("%s: connessione accettata dal sock %d\n", argv[0], client_sock);
if (pthread_create(&thread_id, NULL, &connHandler, (void*)&client_sock) == -1) {
// errore pthread_create()
printf("%s: errore pthread_create (%s)\n", argv[0], strerror(errno));
close(sock);
return EXIT_FAILURE;
}
}

// errore accept()
printf("%s: errore accept (%s)\n", argv[0], strerror(errno));
close(sock);
return EXIT_FAILURE;
}

// connHandler() - funzione di connessione eseguita dai thread
void *connHandler(void *conn_sock)
{
// ottengo i dati del thread con un cast (int *) di (void *) conn_sock
int client_sock = *(int*)conn_sock;

// loop di ricezione messaggi dal client
int read_size;
char client_msg[MYBUFSIZE];
while ((read_size = recv(client_sock, client_msg, MYBUFSIZE, 0)) > 0) {
// send messaggio di ritorno al client
printf("%s: ricevuto messaggio dal sock %d: %s\n", __func__, client_sock, client_msg);
char server_msg[MYBUFSIZE];
snprintf(server_msg, sizeof(server_msg), "mi hai scritto: %s", client_msg);
send(client_sock, server_msg, strlen(server_msg), 0);

// clear del buffer
memset(client_msg, 0, MYBUFSIZE);
}

// loop terminato: test motivo
if (read_size == -1) {
// errore recv()
printf("%s: errore recv (sock %d)\n", __func__, client_sock);
}
else {
// read_size == 0: il client si è disconnesso
printf("%s: client disconnesso (sock %d)\n", __func__, client_sock);
}

// il chiude il socket ed esce
close(client_sock);
pthread_exit(NULL);
}

Ok, non stiamo a raccontare di nuovo come funziona un Socket Server (già fatto nel vecchio post, rileggere attentamente, please), ma concentriamoci sulle differenze tra il codice monothread e quello multithread: sicuramente avrete notato che sono praticamente identici fino alla fase di listen(2), e anche dopo le differenze sono minime: la fase di accept(2) adesso è in un loop, e per ogni connessione accettata (di un Client  remoto) viene creato un nuovo thread. E cosa esegue il thread? Esegue la funzione locale connHandler() che contiene, guarda caso, il loop di recv(2) che nel vecchio codice era eseguito subito dopo la fase di accept(2). Anche il successivo test del motivo di uscita (prematura) dal loop è contenuto in connHandler(), e mostra il corretto segnale di errore (errore recv o client disconnesso, in base al codice ritornato dalla recv(2)).

Cosa aggiungere? È semplice e super-funzionale: un Socket Server multithread con quattro righe di codice! Ovviamente la sintassi di creazione del thread e l'esecuzione della thread function dello stesso sono identiche a quelle descritte qui. Per testare il nostro Socket Server è necessario compilare anche un Socket Client  (ovviamente quello descritto in un altro mio vecchio post, Il Client oscuro - Il ritorno), ed eseguire, ad esempio, una istanza del Socket Server e due istanze del Socket Client (in tre terminali diversi della stessa macchina, oppure su tre macchine diverse). Eseguendo sulla mia macchina (Linux, ovviamente) su tre terminali il risultato è il seguente:

Nel terminale 1:

ldo@Linux $ ./sockserver-mt 9999
./sockserver-mt: attesa connessioni entranti...
./sockserver-mt: connessione accettata dal sock 4
./sockserver-mt: connessione accettata dal sock 5
connHandler: ricevuto messaggio dal sock 4: pippo
connHandler: ricevuto messaggio dal sock 5: pluto
connHandler: client disconnesso (sock 4)
connHandler: client disconnesso (sock 5)

Nel terminale 2:

aldo@Linux $ ./sockclient 127.0.0.1 9999
Scrivi un messaggio per il Server remoto: pippo
./sockclient: Server reply: mi hai scritto: pippo
Scrivi un messaggio per il Server remoto: ^C

Nel terminale 3:

aldo@Linux $ ./sockclient 127.0.0.1 9999
Scrivi un messaggio per il Server remoto: pluto
./sockclient: Server reply: mi hai scritto: pluto
Scrivi un messaggio per il Server remoto: ^C

notare che quando uno dei Client esce (con un CTRL-C, ad esempio) il Server se ne accorge e visualizza, come previsto, client disconnesso... perfetto!

A questo punto, però, è doveroso aggiungere due note a margine:

1. Multitreading vs Multiplexing

Come già scrissi in altri articoli (qui, qui e qui), non necessariamente il multithreading è la scelta migliore (anzi, spesso non lo è). Ad esempio proprio sull'argomento Server TCP multithread (come quello descritto sopra), nel mio articolo sulla select(2) parlai della diatriba multithreading vs multiplexing e scrissi questo:

...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 ?...

Quindi occhio: in un programma la stessa cosa si può fare in modi molto differenti, per cui non fatevi prendere dalla fretta o dalle mode del momento e cercate sempre di dedicare del tempo alla scelta della soluzione ottimale.

2. La strerror(3)

Avrete notato che nell'esempio qui sopra ho usato la strerror(3) nelle varie segnalazioni di errore: considerando che ho già scritto in passato che nei programmi multithread  bisogna sempre usare la strerror_r(3) qualcuno potrebbe pensare che sono improvvisamente rincoglionito (potrebbe anche essere, eh!). In realtà e` successa una cosa che avevo previsto (sono un preveggente, ah ah ah), infatti avevo scritto, proprio in quell'articolo:

...non è vietato scrivere una strerror() che sia thread-safe, e in alcuni sistemi lo è: ma visto che secondo lo standard non lo è, non possiamo essere sicuri che sul sistema che stiamo usando (o sul sistema su cui, un giorno, girerà la applicazione che stiamo scrivendo) non ci sia una implementazione come quella appena descritta...

Ecco, alla fine è successo: la strerror(3) standard ora è thread-safe, infatti nelle ultime versioni dei manuali Linux della funzione c'è questa descrizione:

ATTRIBUTES
For an explanation of the terms used in this section, see attributes(7).
┌────────────────────┬───────────────┬──────────────────────────┐
│ Interface          │ Attribute     │ Value                    │
├────────────────────┼───────────────┼──────────────────────────┤
│ strerror()         │ Thread safety │ MT-Safe                  │
├────────────────────┼───────────────┼──────────────────────────┤
│ strerrorname_np(), │ Thread safety │ MT-Safe                  │
│ strerrordesc_np()  │               │                          │
├────────────────────┼───────────────┼──────────────────────────┤
│ strerror_r(),      │ Thread safety │ MT-Safe                  │
│ strerror_l()       │               │                          │
└────────────────────┴───────────────┴──────────────────────────┘
Before glibc 2.32, strerror() is not MT-Safe.

E quindi cosa bisogna fare? Io direi che valgono ancora le considerazioni che feci al tempo: adesso si può usare con discreta tranquillità la strerror(3) in un nuovo programma multithread, ma con la cautela di valutare se quel programma non finirà per essere compilato e usato su qualche sistema non molto aggiornato, eh! E soprattutto non vi passi per la testa di andare a fare refactoring di vecchi sorgenti sostituendo le strerror_r(3) con strerror(3): sarebbe un lavoro inutile e che non tiene presente le accortezze appena descritte. Meditate gente, meditate...

Ok, con i thread direi che abbiamo finito. Adesso cercherò di pensare a qualche nuovo interessante argomento per il prossimo post. Come sempre vi invito a non trattenere il respiro nell'attesa...

Ciao e al prossimo post!