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.

lunedì 28 aprile 2025

Il buono, il brutto, il VLA
come usare i Variable Length Arrays in C - pt.2

Biondo (il buono): [contando gli uomini di Sentenza] Uno, due, tre, quattro, cinque, sei... Sei, il numero perfetto!
Sentenza (il cattivo): Non è tre il numero perfetto?
Biondo (il buono): Sì, ma io ho sei colpi qui dentro...

(...una premessa: questo post è un remake di un mio vecchio post (parte 2 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...)

Dunque, dove eravamo rimasti? Ah si, nell'ultimo articolo (che avete appena riletto, vero?) avevamo approvato (con riserva) i Variable Length Array (VLA per gli amici), che sono facili da usare, utili e con ottime prestazioni, ma allora... perché ho detto che sono adatti al ruolo del cattivo nel mitico Il buono, il brutto, il cattivo del grande Sergio Leone?

...non fidatevi del VLA, parola del buono!...

Presto detto: oltre ai (notevoli) pro ci sono anche alcuni (pesanti) contro. Prima di seguire ricordiamoci sempre che un VLA si alloca dinamicamente nello stack come una variabile automatica con scope limitato al blocco di codice dove avviene l'allocazione: dopodiché i (principali) possibili problemi sono:

  1. la gestione degli errori è problematica, perché non c'è maniera di sapere se il VLA è stato allocato bene o ha provocato un stack overflow  (in questo caso provoca effetti simili a quelli di un errore per ricorsione infinita).
  2. il size del VLA si decide a run-time, quindi il compilatore deve fare dei giochi un po' strani: in base all'implementazione è possibile che una parte (anche importante) dello stack di una funzione venga riservato per un VLA, limitando molto la memoria locale disponibile. Quindi lo stack overflow è sempre dietro l'angolo.
  3. la portabilità del codice va un po' a farsi benedire: il codice diventa molto compiler-dependent  e, soprattutto, visto che una buona fetta di programmatori C scrivono anche codice per sistemi embedded (dove lo stack è, spesso, limitato) risulta complicato il porting di funzioni da applicazioni normali  ad applicazioni embedded. Funzioni che, magari, smetterebbero di funzionare per motivi misteriosi (beh, neanche tanto misteriosi...).
  4. E, dulcis in fundo: forse per i motivi appena elencati (o per altri ancora) da C11 in avanti i VLA sono opzionali e subordinati al flag __STDC_NO_VLA__ del compilatore, e questo è un brutto segno.

Che fare allora? Meglio non usarli o usarli con le precauzioni del caso, anche perché le alternative non mancano. E, con questo, abbiamo trovato il Cattivo!

E adesso ci tocca cercare qualcuno che sia adatto ai ruoli del buono e del brutto. Ecco, per il buono non c'è problema, il candidato ideale è la cara, buona, vecchia malloc(3) che è sempre una garanzia ed è uscita molto bene dal test. Sulla malloc(3) è inutile dilungarci, è un punto fermo del C e ne abbiamo già parlato abbondantemente qui.

E il brutto? Beh, per cercare uno adatto dovremo, ahimè, addentrarci nel lato oscuro della forza, e cioè in territorio C++...

(...apro una parentesi: non parlo mai di argomenti che non conosco, perché penso che sia stupido farlo. Per fare un esempio: io non capisco niente di moto e, vi assicuro, nessuno ha mai avuto l'onore di sentirmi disquisire sul mondiale di MotoGP. Seguo una filosofia che, sfortunatamente, non è seguita da molta gente, è cioè: "mai parlare solo per dare aria alla bocca". Proprio in virtù di questa coerenza penso di avere i titoli per parlare del C++: lo uso in parallelo al mio amato C da circa trenta (!) anni, e, modestia a parte, penso di saperlo usare bene. Quindi ne posso disquisire, nel bene e nel male. Chiudo la parentesi...).

Allora, ho ripreso pari pari l'esempio C del post precedente e (facendo il minimo sindacale di modifiche) l'ho trasformato in codice C++, per poter, così, aggiungere un test nuovo che usa std::vector (questo è un oggetto particolarmente caro ai C++ lovers, che lo usano anche per condire l'insalata). Per non ripetere tutto il codice dello scorso post vi riporto solo il main() e la nuova funzione di test aggiunta (il resto è, praticamente, invariato). Vai col codice!

#include <stdio.h>
#include <time.h>
#include <stdlib.h>
#include <vector>

#define MYSIZE 1000000

// variabile dummy per evitare lo svuotamento totale delle funzioni usando g++ -O2
int avoid_optimization;

// prototipi locali
void runTest(int iterations, void (*funcptr)(int), int size, const char *name);
void testVLA(int size);
void testMallocVLA(int size);
void testStackFLA(int dum);
void testHeapFLA(int dum);
void testVectorVLA(int size);

// funzione main()
int main(int argc, char* argv[])
{
// test argomenti
if (argc != 2) {
// errore: conteggio argomenti errato
printf("%s: wrong arguments counts\n", argv[0]);
printf("usage: %s vla iterations [e.g.: %s 10000]\n", argv[0], argv[0]);
return EXIT_FAILURE;
}

// estrae iterazioni
int iterations = atoi(argv[1]);

// esegue test
runTest(iterations, &testVLA, MYSIZE, "testVLA");
runTest(iterations, &testMallocVLA, MYSIZE, "testMallocVLA");
runTest(iterations, &testStackFLA, 0, "testStackFLA");
runTest(iterations, &testHeapFLA, 0, "testHeapFLA");
runTest(iterations, &testVectorVLA, MYSIZE, "testVectorVLA");

// esce
return EXIT_SUCCESS;
}

// testVectorVLA() - funzione per eseguire il test del vector VLA
void testVectorVLA(
int size) // size per std::vector
{
std::vector<int> vectorvla(size);

// loop di test
for (int i = 0; i < size; i++)
vectorvla[i] = i;

// istruzione per evitare lo svuotamento totale della funzione usando g++ -O2
avoid_optimization = vectorvla[size / 2];
}

Compilando (con/senza ottimizzazioni) ed eseguendo questo codice i risultati sono i seguenti:

aldo@Linux $ g++ -O0 vlacpp.cpp -o vlacpp
aldo@Linux $ ./vlacpp 2000
testVLA - Tempo trascorso: 3.912879 secondi
testMallocVLA - Tempo trascorso: 2.727576 secondi
testStackFLA - Tempo trascorso: 3.662505 secondi
testHeapFLA - Tempo trascorso: 3.625115 secondi
testVectorVLA - Tempo trascorso: 8.964794 secondi

aldo@Linux $ g++ -O2 vlacpp.cpp -o vlacpp
aldo@Linux $ ./vlacpp 2000
testVLA - Tempo trascorso: 0.630394 secondi
testMallocVLA - Tempo trascorso: 0.615095 secondi
testStackFLA - Tempo trascorso: 0.218578 secondi
testHeapFLA - Tempo trascorso: 0.208932 secondi
testVectorVLA - Tempo trascorso: 0.773074 secondi

E, allora, come si è comportato std::vector direi che i numeri parlano da soli, quindi abbiamo un buon candidato per il ruolo del brutto... ma vabbè, mettiamola giù in maniera diplomatica: diciamo che abbiamo due notizie, una buona e una cattiva:

  • la buona notizia è che il C++ è efficiente come il C (e su questo non avevo dubbi), infatti il nostro programma C trasformato in C++ ottiene (nei primi quattro test) le stesse prestazioni (andate a controllare là, se non ci credete, eh!).
  • la brutta notizia è che il C++ è efficiente come il Cma solo se lo usate come il C, quindi niente STL e ammennicoli vari. Ma, comunque, con l'ottimizzazione attivata, anche std::vector  funzionicchia...

(...apro un altra parentesi: ovviamente la brutta notizia qui sopra non deriva solo dal semplice test proposto in questo post: deriva da anni ed anni di osservazioni ed uso intensivo di entrambi i linguaggi, ci mancherebbe solo. Chiudo la parentesi...).

E, visto quanto sopra, credo che sia il caso di esporre (brevemente) una mia opinione: il C++ è un grande linguaggio potente, efficiente ed espressivo (è parente stretto del C!), con cui si può scrivere del Software di alta qualità. Ma i risultati migliori (perlomeno in termini di prestazioni e fluidità del codice) si ottengono usandolo per quello che era stato concepito originalmente, e cioè come un semplice C a oggetti. La piega che ha preso in seguito (da quando è caduto nelle mani dei malefici committee ISO) non mi piace e non mi convince... ma, fortunatamente (e questo è importante), continua a poter essere usato nella sua essenza, quella che permette di scrivere a oggetti usando un linguaggio (quasi) identico al C (e questo si aggancia alla buona notizia qui sopra... e do per scontato che prestazioni e fluidità del codice siano estremamente importanti, eh!).

Ah, una ultima precisazione per chi si è sorpreso del codice C++ (qui sopra) che include un VLA: è una gentile offerta dal nostro amato GCC (nella sua incarnazione g++). Quindi è un estensione del linguaggio fornita dal compilatore, visto che i VLA non fanno parte del C++ standard (almeno fino a C++14).

Nel prossimo post, per chiudere il cerchio, parleremo di un parente stretto dei VLA, e cioè della funzione alloca(3)Sarà un altro buono, un altro brutto o un altro cattivo?

Ciao e al prossimo post!

lunedì 24 marzo 2025

Il buono, il brutto, il VLA
come usare i Variable Length Arrays in C - pt.1

cacciatore di taglie: Ehi, lo sai che la tua faccia somiglia a quella di uno che vale duemila dollari?
Biondo (il buono): [comparendo alle loro spalle] Già... ma tu non somigli a quello che li incassa...

(...una premessa: questo post è un remake di un mio vecchio post (parte 1 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...)

Il riferimento cinematografico di questo mese calza proprio a pennello: un Variable Length Array (VLA per gli amici) sarebbe perfetto per fare la parte del cattivo nel capolavoro Il buono, il brutto, il cattivo del grande Sergio Leone. E alla fine del (prossimo) articolo sarà chiaro il perché.

...ciao sono un VLA: inizia a preoccuparti...

I  VLA sono una cosa relativamente (si, molto relativamente) nuova del C: sono stati introdotti nel C99, e sono, apparentemente, il sogno fatto realtà del mondo C: "Finalmente gli array con dimensione variabile! Ah, se li avessi avuti prima del '99!". Allora: l'idea è semplice, con un VLA potete scrivere cosucce tipo queste:

void myVla(
int size1, // un size desiderato del VLA
int size2) // un size desiderato del VLA
{
// il mio VLA di int
int ivla[size1];

// fai qualcosa con il VLA di int
...

// il mio VLA bidimensionale di float
float fvla[size1][size2]:

// fai qualcosa con il VLA bidimensionale di float
...
}

Fantastico, no? Troppo bello per essere vero... ma ci saranno delle controindicazioni? Sicuramente non nelle prestazioni: ho scritto giustappunto un po' di codice per testare le prestazioni dei VLA rispetto alle alternative più immediate: array dinamici (con malloc(3)) e array fissi (in heap e stack). Vediamolo, no? Vai col codice!

#include <stdio.h>
#include <time.h>
#include <stdlib.h>

#define MYSIZE 1000000

// variabile dummy per evitare lo svuotamento totale delle funzioni usando GCC -O2
int avoid_optimization;

// prototipi locali
void runTest(int iterations, void (*funcptr)(int), int size, const char *name);
void testVLA(int size);
void testMallocVLA(int size);
void testStackFLA(int dum);
void testHeapFLA(int dum);

// funzione main()
int main(int argc, char* argv[])
{
// test argomenti
if (argc != 2) {
// errore: conteggio argomenti errato
printf("%s: wrong arguments counts\n", argv[0]);
printf("usage: %s vla iterations [e.g.: %s 10000]\n", argv[0], argv[0]);
return EXIT_FAILURE;
}

// estrae iterazioni
int iterations = atoi(argv[1]);

// esegue test
runTest(iterations, &testVLA, MYSIZE, "testVLA");
runTest(iterations, &testMallocVLA, MYSIZE, "testMallocVLA");
runTest(iterations, &testStackFLA, 0, "testStackFLA");
runTest(iterations, &testHeapFLA, 0, "testHeapFLA");

// esce
return EXIT_SUCCESS;
}

// runTest() - funzione per eseguire i test
void runTest(
int iterations, // iterazioni del test
void (*funcptr)(int), // funzione di test
int size, // size dell'array
const char *name) // nome funzione di test
{
// prende start time
clock_t t_start = clock();

// esegue iterazioni test
for (int i = 0; i < iterations; i++)
(*funcptr)(size);

// prende end time e mostra il risultato
clock_t t_end = clock();
double t_passed = ((double)(t_end - t_start)) / CLOCKS_PER_SEC;
printf("%-13s - Tempo trascorso: %f secondi\n", name, t_passed);
}

// testVLA() - funzione per eseguire il test del VLA
void testVLA(
int size) // size per VLA
{
int vla[size];

// loop di test
for (int i = 0; i < size; i++)
vla[i] = i;

// istruzione per evitare lo svuotamento totale della funzione usando GCC -O2
avoid_optimization = vla[size / 2];
}

// testMallocVLA() - funzione per eseguire il test del malloc VLA
void testMallocVLA(
int size) // size per malloc()
{
int *mallocvla = malloc(size * sizeof(int));

// loop di test
for (int i = 0; i < size; i++)
mallocvla[i] = i;

// istruzione per evitare lo svuotamento totale della funzione usando GCC -O2
avoid_optimization = mallocvla[size / 2];

free(mallocvla);
}

// testStackFLA() - funzione per eseguire il test dello stack FLA
void testStackFLA(
int dum) // parametro dummy
{
int stackfla[MYSIZE];

// loop di test
for (int i = 0; i < MYSIZE; i++)
stackfla[i] = i;

// istruzione per evitare lo svuotamento totale della funzione usando GCC -O2
avoid_optimization = stackfla[MYSIZE / 2];
}

// testHeapFLA() - funzione per eseguire il test dello heap FLA
int heapfla[MYSIZE];
void testHeapFLA(
int dum) // parametro dummy
{
// loop di test
for (int i = 0; i < MYSIZE; i++)
heapfla[i] = i;
}

Ok, come vedete è ampiamente commentato e quindi è auto-esplicativo, per cui non mi dilungherò sulle singole istruzioni e/o gruppi di istruzioni (leggete i commenti! sono li per quello!), ma aggiungerò, solo, qualche dettaglio strutturale.

Allora: visto che si tratta di un test comparativo ho scritto una funzione runTest() che chiama n-iterazioni della funzione da testare e conta il tempo impiegato. Il main() si limita a chiamare quattro volte runTest(), una per ogni funzione. Le quattro funzioni di test che ho scritto testano (come richiamato dai nomi, ovviamente): un C99-VLA (la variabile vla), un tradizionale malloc-VLA (la variabile mallocvla), un FLA (Fixed Lengh Array) allocato nello stack (la variabile stackfla) e un FLA allocato nello heap (la variabile heapfla). Per ogni test viene usato un (gran) array-size di 1000000 e il numero di iterazioni si decide al lancio dell'applicazione (questo è molto utile come vedremo tra poco). Ovviamente il malloc-VLA l'ho chiamato così non perché sia un vero e proprio VLA, ma perché rappresenta il modo tradizionale di creare a run-time un array con size "dinamico".

Notare che runTest() usa un function pointer per lanciare il test (avevamo visto qualcosa del genere parlando qui delle callback): ho usato la versione estesa della dichiarazione (void (*funcptr)(int) + passaggio della funzione con l'operatore &) ma vi ricordo che, ad esempio, GCC digerisce facilmente anche la dichiarazione semplificata (void funcptr(int) + passaggio senza l'operatore &). La versione estesa è, ovviamente, più portabile. E visto che siamo in tema di compilatori: anche se i VLA sono ammessi solo da C99 in avanti non c'è bisogno (se usate GCC) di specificare il flag -std=c99 in compilazione: siamo nel 2025 (come passa il tempo!) e le versioni recenti di GCC includono di default (come minimo) anche il C99 (oltre alle estensioni del GNU C).

E, già che ci siamo, facciamo un accenno sul discorso "compatibilità & retrocompatbilità": se proprio volete essere sicuri che quello che avete scritto rispetta uno standard in particolare dovete usare correttamente i flag di compilazione: ad esempio, se volete scrivere usando solo il C89, dovete aggiungere sulla linea di compilazione: -std=c89 -pedantic. Se poi state usando un GCC veramente datato allora la compilazione dell'esempio con i VLA vi darà Warning e/o errori, e dovrete ricompilare forzando (se possibile) la compatibilità col C99.

Notare anche che ho aggiunto, in ogni funzione di test, una semplice istruzione per usare l'array creato (é questa: avoid_optimization = ...), per evitare che, compilando con -O2, l'ottimizzatore del GCC azzeri il contenuto della funzione stessa: infatti, se l'array non lo usa nessuno, il nostro amico GCC si prende la libertà di eliminare (praticamente) la funzione, con il risultato che il test passa in 0 secondi!

E vediamo i risultati!

aldo@Linux $ gcc -O0 vla.c -o vla
aldo@Linux $ ./vla 2000
testVLA - Tempo trascorso: 3.918936 secondi
testMallocVLA - Tempo trascorso: 2.729077 secondi
testStackFLA - Tempo trascorso: 3.648311 secondi
testHeapFLA - Tempo trascorso: 3.623842 secondi

aldo@Linux $ gcc -O2 vla.c -o vla
aldo@Linux $ ./vla 2000
testVLA - Tempo trascorso: 0.664499 secondi
testMallocVLA - Tempo trascorso: 0.616732 secondi
testStackFLA - Tempo trascorso: 0.211779 secondi
testHeapFLA - Tempo trascorso: 0.258773 secondi

Come vedete ho eseguito test senza ottimizzazione (con il flag di compilazione -O0 che si può anche omettere visto che è il default) e con ottimizzazione (con il flag di compilazione -O2 ) e, ovviamente, mi è tornato utile il parametro n-iterazioni dell'applicazione, perché mi ha permesso di trovare un valore adatto a ottenere risultati significativi pur evitando tempi di esecuzione biblici per la versione senza ottimizzazioni. Come possiamo commentare? Beh, il VLA se la cava egregiamente, con e senza ottimizzazioni! Ottiene, praticamente, gli stessi risultati del suo diretto concorrente, il malloc-VLA, ed è più semplice da usare!

E allora, tornando sul pezzo: si può dire che il VLA è approvato!

MA PERÒ...

Beh, il però del VLA "cattivo" anticipato sopra ve lo spiegherò meglio nel prossimo articolo, e sappiate che non è tutto oro quello che luccica... e tanto per farvi un piccolo spoiler sulle prossime considerazioni finali: io non uso mai i VLA nel codice che scrivo!

Ciao e al prossimo post!  

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!