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.

venerdì 26 aprile 2024

The Fastpipe
come velocizzare le POSIX IPC in C

Enigmista: Può esser crudele, poetica o cieca... ma quando è negata, violenza a volte reca.
Batman: Giustizia. La risposta è giustizia.

Per questo articolo ho scelto di rifarmi al bel The Batman, del bravo Matt Reeves. The Batman è una sorta di reboot  del mitico Batman di Nolan, da cui eredita le atmosfere cupe e la profondità degli intrecci. E anche questo articolo è un po' un reboot  (e occhio: non è un remake, non è la stessa cosa). Ho (ri)preso la parte tecnica (il codice) di un vecchio articolo e l'ho riscritta per verificare se era possibile aumentare le prestazioni (spoiler: è possibile, ma con il metodo seguito era, come vedrete, quasi scontato o, se preferite, "lapalissiano"). l'argomento è, quindi: è possibile velocizzare i meccanismi di POSIX IPC (che già sono veloci di per se, come visto qui, qui e qui)? E magari si può fare anche in modo semplice? Lo vedremo tra poco!

...la mia Fastpipe è più veloce della tua...

Ok, veniamo al dunque: ci sono varie maniere di velocizzare un sistema di scambio dati, ma il primo che viene in mente, il più scontato, deriva da questa semplice espressione:

meno dati = meno tempo

Dopo questa perla matematica penso avrete capito perché ho usato più sopra il termine lapalissiano  (che è una maniera più elegante di dire "e grazie al c...o!", ma questo è un blog serio, non posso scrivere parolacce, ah ah ah). Quindi tutti sanno che, spesso, quando si inviano dati si cerca di comprimerli, sempre sperando che l'esecuzione del codice di compressione/decompressione non annulli il vantaggio derivante dall'invio/ricezione di "meno dati". Ma c'è una maniera più semplice di rispettare l'equazione qui sopra? Si, ed è quella di inviare pacchetti di dati con dimensione variabile, corrispondente alla dimensione reale dei dati, senza sprecare neanche un byte. Quindi, ad esempio, se abbiamo un protocollo di trasmissione di messaggi ASCII (tipo una chat o un file transfer di testi) sarebbe un ottima idea evitare di inviare messaggi a lunghezza fissa (e, quindi, di non usare un buffer enorme per trasmettere un semplice "Ciao") no?

Mi sembra evidente che quanto sopra è abbastanza scontato e quasi inutile da verificare... ma, comunque, un bel benchmark non fa mai male, tanto per confermare la teoria con la pratica, per cui ho preso il codice del test della POSIX pipe visto nel vecchio ciclo di articoli, e l'ho modificato per ottenere due scopi:

  1. Dimostrare che la "velocizzazione" è fattibile in maniera abbastanza semplice.
  2. Dimostrare che è effettivamente più veloce

Ok, il codice originale ve lo risparmio perché per il benchmark non ho modificato praticamente nulla (solo qualche printf), quindi potete consultarlo qui. Per cui vi mostro, direttamente la nuova versione, vai col codice!

// processes.c - main processo padre
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/stat.h>
#include "data.h"

// funzione main()
int main(int argc, char* argv[])
{
// creo il file fifo (named pipe)
if (mkfifo(FIFO_PATH, DEFFILEMODE) == -1) {
// errore di creazione
printf("%s: non posso creare il file fifo (%s)\n", argv[0], strerror(errno));
exit(EXIT_FAILURE);
}

// crea i processi figli
pid_t pid1, pid2;
(pid1 = fork()) && (pid2 = fork());

// test pid processi
if (pid1 == 0) {
// sono il figlio 1
printf("sono il figlio 1 (%d): eseguo il nuovo processo\n", getpid());
char *pathname = "reader";
char *newargv[] = { pathname, NULL };
execv(pathname, newargv);
exit(EXIT_FAILURE); // exec non ritorna mai
}
else if (pid2 == 0) {
// sono il figlio 2
printf("sono il figlio 2 (%d): eseguo il nuovo processo\n", getpid());
char *pathname = "writer";
char *newargv[] = { pathname, NULL };
execv(pathname, newargv);
exit(EXIT_FAILURE); // exec non ritorna mai
}
else if (pid1 > 0 && pid2 > 0) {
// sono il padre
printf("sono il padre (%d): attendo la terminazione dei figli\n", getpid());
int status;
pid_t wpid;
while ((wpid = wait(&status)) > 0)
printf("sono il padre (%d): figlio %d terminato (%d)\n", getpid(),
(int)wpid, status);

// rimuovo il file fifo ed esco
printf("%s: processi terminati\n", argv[0]);
remove(FIFO_PATH);
exit(EXIT_SUCCESS);
}
else {
// errore nella fork(): rimuovo il file fifo ed esco
printf("%s: fork error (%s)\n", argv[0], strerror(errno));
remove(FIFO_PATH);
exit(EXIT_FAILURE);
}
}
// writer.c - main processo figlio
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <fcntl.h>
#include "data.h"
#include "message.h"

// funzione main()
int main(int argc, char *argv[])
{
// apro il file fifo (named pipe) in modo scrittura
printf("processo %d partito (writer)\n", getpid());
int fd;
if ((fd = open(FIFO_PATH, O_WRONLY)) == -1) {
// errore di apertura
printf("%s: non posso aprire il file fifo (%s)\n", argv[0], strerror(errno));
exit(EXIT_FAILURE);
}

// loop di scrittura messaggi per il reader
Message message;
Data *my_data = &message.data;
my_data->index = 0;
do {
// test index per forzare l'uscita
if (my_data->index == N_MESSAGES) {
// il processo chiude il file fifo ed esce per indice raggiunto
printf("processo %d terminato (text=%s messaggi=%ld)\n",
getpid(), my_data->text, my_data->index);
close(fd);
exit(EXIT_SUCCESS);
}

// compongo il messaggio e lo invio
my_data->index++;
snprintf(my_data->text, sizeof(my_data->text), "un-messaggio-di-test:%ld",
my_data->index);
} while (fastWrite(fd, &message) != -1);

// il processo chiude il file fifo ed esce per altro motivo (errore)
printf("processo %d terminato con errore (%s)\n", getpid(), strerror(errno));
close(fd);
exit(EXIT_FAILURE);
}
// reader.c - main processo figlio
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <time.h>
#include <sys/time.h>
#include <fcntl.h>
#include "data.h"
#include "message.h"

// funzione main()
int main(int argc, char *argv[])
{
// apro il file fifo (named pipe) in modo lettura
printf("processo %d partito (reader)\n", getpid());
int fd;
if ((fd = open(FIFO_PATH, O_RDONLY)) == -1) {
// errore di apertura
printf("%s: non posso aprire il fifo (%s)\n", argv[0], strerror(errno));
exit(EXIT_FAILURE);
}

// set clock e time per calcolare il tempo di CPU e il tempo di sistema
clock_t t_start = clock();
struct timeval tv_start;
gettimeofday(&tv_start, NULL);

// loop di lettura messaggi dal writer
Message message;
Data *my_data = &message.data;
while (fastRead(fd, &message) != -1) {
// test index per forzare l'uscita
if (my_data->index == N_MESSAGES) {
// get clock e time per calcolare il tempo di CPU e il tempo di sistema
clock_t t_end = clock();
double t_passed = ((double)(t_end - t_start)) / CLOCKS_PER_SEC;
struct timeval tv_end, tv_elapsed;
gettimeofday(&tv_end, NULL);
timersub(&tv_end, &tv_start, &tv_elapsed);

// il processo chiude il file fifo ed esce per indice raggiunto
printf("reader: ultimo messaggio ricevuto: %s\n", my_data->text);
printf("processo %d terminato (messaggi=%ld tempo CPU: %.3f - "
"tempo totale:%ld.%ld)\n",
getpid(), my_data->index, t_passed, tv_elapsed.tv_sec,
tv_elapsed.tv_usec / 1000);
close(fd);
exit(EXIT_SUCCESS);
}
}

// il processo chiude il file fifo ed esce per altro motivo (errore)
printf("processo %d terminato con errore (%s)\n", getpid(), strerror(errno));
close(fd);
exit(EXIT_FAILURE);
}
// data.h - header per dati per mini-libreria IPC con pipes
#ifndef DATA_H
#define DATA_H

// path del file fifo (named pipe)
#define FIFO_PATH "myfifo"

// numero di messaggi da scambiare per il benchmark
#define N_MESSAGES 2000000

// struttura Data per i messaggi
typedef struct {
unsigned long index; // indice dei dati
char text[16384]; // testo dei dati
} Data;

#endif // DATA_H
// message.c - implementazione per read/write per mini-libreria IPC con pipes
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include "data.h"
#include "message.h"

// fastwrite - scrittura con size
ssize_t fastWrite(
int fd,
Message *buf)
{
// set size reale (somma di tutte le dimensioni dei membri del
// tipo Message (eccetto il membro size))
buf->size = sizeof(unsigned long) + strlen(buf->data.text);

// invio il messaggio completo: size + real-size (size + somma di
// tutte le dimensioni dei membri del tipo Message (eccetto il membro size))
return write(fd, buf, SIZEOFP(buf));
}

// fastread - lettura con size
ssize_t fastRead(
int fd,
Message *buf)
{
// legge il size da usare nella successiva read
ssize_t size_rcvd;
if ((size_rcvd = read(fd, &buf->size, sizeof(size_t))) > 0) {
// return la read successiva
return read(fd, &buf->data, buf->size);
}

// ritorna nessun byte letto o errore
return size_rcvd;
}
// message.h - header per read/write per mini-libreria IPC con pipes
#ifndef MESSAGE_H
#define MESSAGE_H

#include "data.h"

// struttura Data per i messaggi
typedef struct {
size_t size; // size reale (somma di tutte le dimensioni dei membri del
// tipo Message (eccetto il membro size))
Data data; // campo dati del messaggio
} Message;

// size reale del messaggio
#define SIZEOFP(X) (sizeof(size_t) + sizeof(unsigned long) + strlen(X->data.text))

// prototipi globali
ssize_t fastRead(int fd, Message *buf);
ssize_t fastWrite(int fd, Message *buf);

#endif // MESSAGE_H

Ok, il nuovo codice è ampiamente commentato (come sempre), ma comunque è il caso di aggiungere qualche dettaglio. È formato da sei file, quindi due in più di quello di riferimento, per cui la struttura è questa:

  1. Il main di un processo padre: processes.c. Crea ed esegue due processi figli con fork + exec. I due processi figli si chiameranno writer e reader.
  2. Il main del processo writer: writer.c.
  3. Il main del processo reader: reader.c.
  4. Un header file per reader e writer: data.h.
  5. Un nuovo sorgente che serve a gestire la trasmissione di messaggi con lunghezza variabile: message.c.
  6. Un nuovo header file per il file message.c: message.h.

Come sicuramente avrete notato (e vabbé, spero che lo abbiate notato), processes.c e data.h sono identici a quelli vecchi, mentre reader.c e writer.c hanno minime differenze rispetto agli originali: inviano e ricevono un dato (definito in data.h) incapsulato in un nuovo tipo Message (definito in message.h), usando le funzioni di read/write implementate in message.c. Le nuove funzioni di read/write sono, come si può notare, abbastanza semplici, e usano internamente le funzioni di read/write della libc. E qual'è il trucco che fa funzionare tutto questo? Come si vede nel codice, funziona così:

  1. Il dato "Data" viene incapsulato in un "Message" insieme alla lunghezza del dato stesso (che sarebbe il campo "size").
  2. La funzione fastWrite() spedisce il messaggio specificando la lunghezza reale, usando la macro SIZEOFP(): spedisce esattamente i byte necessari, neanche uno in più.
  3. La funzione fastRead() riceve il messaggio in due passaggi: legge i primi byte (4 o 8 in base al tipo di architettura in uso) del messaggio per conoscere la lunghezza reale dei dati successivi; dopodiché esegue una nuova lettura usando la la lunghezza reale: riceve esattamente i byte necessari, neanche uno in più.

Visto? Il trucco è semplice e anche la sua implementazione lo è. E i risultati del test quali sono? Erano abbastanza scontati, ma è, comunque, il caso di mostrarli:

aldo@Linux $ cd pipes/
aldo@Linux $ ./processes
sono il padre (18389): attendo la terminazione dei figli
sono il figlio 1 (18390): eseguo il nuovo processo
sono il figlio 2 (18391): eseguo il nuovo processo
processo 18390 partito (reader)
processo 18391 partito (writer)
processo 18391 terminato (text=un-messaggio-di-test:2000000 messaggi=2000000)
reader: ultimo messaggio ricevuto: un-messaggio-di-test:2000000
processo 18390 terminato (messaggi=2000000 tempo CPU: 7.278 - tempo totale:7.283)
sono il padre (18389): figlio 18390 terminato (0)
sono il padre (18389): figlio 18391 terminato (0)
./processes: processi terminati

aldo@Linux $ cd ../fastpipes/
aldo@Linux $ ./processes
sono il padre (18401): attendo la terminazione dei figli
sono il figlio 1 (18402): eseguo il nuovo processo
sono il figlio 2 (18403): eseguo il nuovo processo
processo 18402 partito (reader)
processo 18403 partito (writer)
processo 18403 terminato (text=un-messaggio-di-test:2000000 messaggi=2000000)
sono il padre (18401): figlio 18403 terminato (0)
reader: ultimo messaggio ricevuto: un-messaggio-di-test:2000000
processo 18402 terminato (messaggi=2000000 tempo CPU: 3.397 - tempo totale:3.398)
sono il padre (18401): figlio 18402 terminato (0)
./processes: processi terminati

Il miglioramento nell'invio di 2000000 messaggi è buono, 3.397 secondi invece di 7.278 secondi, ed era, come detto sopra, scontato, visto che invece di trattare pacchetti (a lunghezza fissa) di 16 KB trattiamo pacchetti di qualche decina di byte; magari ci si poteva aspettare qualcosa di più, ma è evidente che in un benchmark di questo tipo anche solo l'avvio di una operazione di read o write ha il suo peso, e contribuisce al tempo totale a prescindere dalla quantità di dati trattati.

Ok, il risultato è stato raggiunto! Ma ci sono delle dolenti note? Ebbene si, quelle non mancano mai, e sono collegate al fatto che ho scelto, non casualmente, le POSIX pipe per effettuare questa semplice dimostrazione. Il fatto è che per la natura stessa di file FIFO su cui funzionano le POSIX pipe permette che un esempio come quello mostrato vada al primo colpo. Ma, e se volessimo usare lo stesso metodo con dei POSIX IPC socket? O con dei Network socket? O con delle POSIX Message Queue? Ecco, qui il discorso si complica un po'... Vediamo perché:

  1. POSIX IPC socket: per la natura stessa del protocollo in uso (Kernel socket) non viene garantito l'invio "non spezzettato" dei pacchetti dati, quindi implementare il meccanismo qui sopra non è impossibile ma non è semplicissimo.
  2. Network socket: per la natura stessa del protocollo in uso (TCP/IP) è presente lo stesso problema del punto 1, sia usado TCP che UDP.
  3. POSIX Message Queue: per la natura stessa di queste code il buffer di trasferimento è a lunghezza fissa, quindi non è possibile realizzare il trucco descritto.

Riepiloghiamo: a parte il caso 3 (che è impraticabile), se decidete di provare a implementare i punti 1 e 2 non vi stupite se non funzionano al primo colpo usando un semplice approccio come quello che ho usato per le POSIX pipes, il codice funzionante (che sicuramente riuscirete a scrivere) sarà sicuramente più complesso, ma i risultati finali saranno decisamente migliori delle versioni che usano messaggi a lunghezza fissa. Provare per credere! Comunque non mi faccio responsabile degli, eventuali, mal di testa che vi verranno per implementare le versioni difficili... e ricordate: "quando il gioco si fa duro i duri cominciano a giocare" (e "non ci sono più le mezze stagioni", e "si stava meglio quando si stava peggio", e... non facciamoci mai mancare i luoghi comuni, ah ah ah).

Ciao, e al prossimo post!

mercoledì 20 marzo 2024

busy-waiting? Forse!
come scrivere un busy-wait loop in C

Alain: Aal mondo ci sono 8 miliardi di persone, la possibilità di nascere è una su 400 bilioni, eppure tu ed io siamo qui, vuole dire che abbiamo vinto la lotteria cosmica.

Visto che l'ultimo articolo era della serie "Forse!" ho deciso di battere il ferro finché è caldo e ve ne propongo un altro (non dimenticate, però, di leggere anche gli articoli della serie "No, grazie!" che sono, spero, interessanti: li potete trovare  quiqui, quiqui, qui e qui). Oggi parleremo di busy-waiting, o meglio di busy-wait loop, un argomento che, per quel che ho visto in rete, è fonte di molti dubbi, e noi siamo qui per questo, per fugarli! L'argomento dell'articolo, si intona con il cinquantesimo film del Maestro Woody Allen, il bel Coup de Chance, un film sulla importanza, nella vita, del caso e della fortuna: affidarsi a un busy-wait loop è un po' affidarsi al caso, e quindi bisogna usarli con le dovute precauzioni... Forse!

...secondo me quel codice funzionava solo per caso...

Cosa sono i busy wait-loop? Senza girarci troppo intorno è meglio vedere un brevissimo esempio con una versione elementare. Vai col codice!

// faccio un busy-wait loop "elementare"
while (condizione) {
// non faccio nulla: sto aspettando che la condizione diventi false
}

// busy-wait loop terminato: proseguo (e, magari, mi scuso con la povera CPU...)
...

Facile, no? Il codice è semplicissimo e ben commentato, e c'è poco da aggiungere se non che il commento finale "...mi scuso con la povera CPU..." anticipa un problema abbastanza grave... In realtà questo difetto è facilmente evitabile con un trucchetto (di cui ho già parlato varie volte, ad esempio qui), ma ne parlerò solo alla fine dell'articolo, spiegandovi anche il perché non ne parlo ora (pazientare, prego...).

Andiamo avanti: un busy-wait loop elementare come quello mostrato sopra ha vari difetti e, per sintetizzare, possiamo isolare i due più gravi (di cui il primo è quello appena anticipato):

  1. È un cpu-killer, nel senso che durante l'attesa si mangia tutte le risorse del sistema, visto che continua a testare la condizione alla massima velocità possibile.
  2. Non ha una condizione d'uscita: il loop potrebbe trasformarsi in infinito.

(...apro una parentesi per fare il precisino: in alcuni ambienti di programmazione, tipicamente quelli del firmware di basso livello senza sistema operativo (il bare-metal, per gli amici) i busy-wait loop "elementari" sono usati e ammessi. Ma questa è un'altra storia...)

E come si procede (correttamente) se abbiamo bisogno di eseguire qualcosa "tipo un busy-wait loop"? Ci sono varie maniere, e dipendono dal tipo di condizione da testare. Vi propongo un piccolo riassuntino di tre casi: sicuramente non è esauriente ma che credo renda bene l'idea; gli esempi forniti sono molto (ma moooolto) semplificati e servono solo a rendere un po' l'idea:

  1. La condizione è l'attesa di un segnale del sistema: invece di non fare nulla nel loop si usa una funzione bloccante come pause(2) o la più moderna sigsuspend(2), e questo comporta un carico nullo per la CPU. Vediamo un piccolo esempio:
    // faccio un busy-wait loop con pause(2) (o sigsuspend(2))
    while (true) {
    // aspetto che la pause(2) ritorni perché è arrivato un segnale
    pause(); // questa è bloccante e non consuma CPU
    break; // pause(2) è uscita: interrompo il loop
    }

    // busy-wait loop terminato: proseguo (e, non devo scusarmi con la CPU...)
    ...
  2. La condizione è l'attesa di un evento su una variabile: useremo allora una condition variable attraverso la funzione pthread_cond_wait(3) che è bloccante (come la pause(2) dell'esempio precedente). Vediamo l'esempio:
    // lock mutex
    pthread_mutex_lock(&my_mutex);

    // faccio un busy-wait loop con una condition variable
    while (condizione) {
    // aspetto che la pthread_cond_wait(2) ritorni quando si segnala la condizione
    pthread_cond_wait(&my_cond, &my_mutex); // questa è bloccante e non consuma CPU
    // il break non è necessario: la condizione non è più valida
    }

    // unlock mutex
    pthread_mutex_unlock(&my_mutex);

    // busy-wait loop terminato: proseguo (e, non devo scusarmi con la CPU...)
    ...
  3. La condizione è l'attesa di una evento di I/O: useremo allora una funzione bloccante (come, ad esempio, la read(2) o la recv(2)). Vediamo, di nuovo, un piccolo esempio:
    // faccio un busy-wait loop con read(2) (o recv(2))
    while (true) {
    // aspetto che la read(2) ritorni perché è arrivato un buffer di I/O
    read(my_fd, my_buf, my_count); // questa è bloccante e non consuma CPU
    break; // read(2) è uscita: interrompo il loop
    }

    // busy-wait loop terminato: proseguo (e, non devo scusarmi con la CPU...)
    ...

Notare che l'esempio n.3 si poteva anche scrivere usando la nostra cara select(2), che è bloccante e si sveglia quando arriva l'I/O sul canale sorvegliato... ma in questo caso non sarebbe un busy-wait loop! Notare anche che, per semplificare e unificare gli esempi, ho usato dei loop che sembrano innecessari, ma potrebbero essere utili per risolvere il difetto n.2 della lista-difetti mostrata più sopra, come vedremo più avanti.

E, come promesso all'inizio dell'articolo, vediamo quale è il trucchetto (che molti avranno già intuito) per migliorare il busy-wait loop "elementare".  Vediamolo!

// faccio un busy-wait loop "elementare" migliorato
while (condizione) {
// aspetto che la condizione diventi false rilasciando la CPU ad ogni ciclo
sleep(1); // o nanosleep o usleep, ecc.
}

// busy-wait loop terminato: proseguo (e, non devo scusarmi con la CPU...)
...

Con questo semplice trucco il busy-wait loop non è più un cpu-killer, perché rilascia la CPU (durante il tempo di sleep) permettendo ad altri thread e/o processi di lavorare liberamente. Ma allora perché ho lasciato questo interessante esempio per ultimo? Semplicissimo: perché un loop fatto così non è più un busy-wait loop! Infatti i cicli vengono eseguiti molte volte (magari infinite) ma, visto che ogni volta viene rilasciata la CPU, usare il termine busy è poco appropriato.

(...e apro un'altra parentesi da precisino: i tre esempi "buoni", 1, 2 e 3 qui sopra, si possono considerare dei veri esempi di busy-waiting solo pensando che il loop è bloccato ma è anche attivo; ma c'è chi, con argomenti validi, non li considera degli esempi calzanti... va a finire che l'unico vero busy-wait loop è quello "elementare" mostrato all'inizio!...)

Comunque questo ultimo caso ci può aiutare a risolvere il secondo dei difetti descritti sopra: "Non ha una condizione d'uscita: il loop potrebbe trasformarsi in infinito.". Visto che (andando in sleep) il loop si sospende per un certo tempo e poi riparte, è abbastanza semplice modificarlo per contare il numero di ripartenze e alzare un allarme o scrivere un messaggio di errore (e, magari, forzare un break) nel caso che si superi un certo tempo. Vediamolo!

// faccio un busy-wait loop "elementare" migliorato e con condizione d'uscita
int seconds = 0;
while (condizione) {
// aspetto che la condizione diventi false rilasciando la CPU ad ogni ciclo
sleep(1); // o nanosleep o usleep, ecc.
if (++seconds > 5) {
printf("impossibile rispettare la condizione in %d sec\n", seconds);
break;
}
}

// busy-wait loop terminato: proseguo (e, non devo scusarmi con la CPU...)
...

Semplice ed efficace, no? Ma allora quest'ultimo esempio (ribadisco: super semplificato) è quello da usare sempre? Io direi di no: in alcuni casi è l'unico possibile e raccomandabile, ma (quando possibile) è consigliabile usare uno degli esempi (1, 2 o 3) descritti sopra, visto che non usano la sleep(3) (che è una fonte notevole di problemi, come scrissi qui e qui). Certo, con il codice di quegli esempi diventa un po' più complicato (ma comunque possibilissimo) gestire le condizioni d'uscita temporizzate, ma con un po' di inventiva si può fare! Anzi, ve lo lascio come divertente attività per le vacanze di Pasqua, sempre che le facciate, ah ah ah. E per oggi ho detto tutto!

Ciao, e al prossimo post!

martedì 27 febbraio 2024

Librerie header-only? Forse!
come scrivere una libreria header-only in C

Holland March: Alla fine nessuno si è fatto male.
Jackson Healy: A me sembra di sì...
Holland March: Nel senso che sono morti in fretta, non è che si sono fatti male.

Ebbene si, oggi parleremo di un argomento abbastanza inusuale e poco conosciuto del C (almeno per quello che mi riguarda): le librerie header-only. Come vedremo più avanti questo è un argomento un po' controverso e (forse) di dubbia utilità, ma per evitare gli strali dei fan (ce ne sono) di questo tipo di librerie ho deciso che questo sarà un articolo del tipo "Forse!" invece che un "No, grazie!" (a proposito, di "No, grazie!" ne ho scritti un po', li potete trovare quiqui, quiqui, qui e qui, mentre un esempio di "Forse!", o meglio, era uno "Scusate", lo trovate qui). L'argomento dell'articolo, tra il serio e il faceto, si intona con il bel film The Nice Guys del bravo Shane Black, un film brillante di quelli che Hollywood anticamente sfornava a ripetizione, ma che ora non sanno quasi più fare...

...secondo me è una libreria header-only...

E cosa sono le queste librerie header-only? Ok, partiamo dalle basi (Linux, eh! Ma anche in altri S.O. funziona più o meno allo stesso modo): qui stiamo per parlare di librerie di sviluppo (su Linux, famiglia Deb, sono i pacchetti *-dev), che sono quelle che servono per realizzare una applicazione. Normalmente (anzi canonicamente) una libreria di sviluppo si distribuisce (semplificando un po') attraverso due file:

  • Un header-file (un *.h) che contiene i prototipi delle funzioni disponibili più la definizione di eventuali strutture dati tipiche della libreria: questo permette di scrivere e compilare una applicazione che usa quello che mette a disposizione la libreria.
  • Un lib-file (un *.a o un *.so), che è una versione compilata dei file di implementazione (i *.c) che compongono la libreria. Questo lib-file può avere formato a link statico (*.a) o dinamico (*.so): grazie a questo si può linkare ed eseguire la applicazione che usa la libreria. Quindi quando si distribuisce la applicazione in formato eseguibile bisogna anche distribuire la libreria associata.
  • La libreria da distribuire avrà varie versioni in base a tipo/versione del S.O. della macchina su cui si vuole installare e usare.

Come si può intuire (e se no che lo scrivo a fare questo articolo?) c'è anche una maniera "non canonica" di distribuzione, ed è proprio quella delle librerie header-only:

  • Si distribuisce un solo file, un header-file un po' strano che contiene prototipi, strutture dati e anche implementazioni! Questo header si deve includere in tutti i file del progetto che usano funzioni e/o strutture dati della libreria.
  • Si compila e via! Si può eseguire l'applicazione linkando solo le (eventuali) altre librerie necessarie all'applicazione. Quindi quando si distribuisce la applicazione in formato eseguibile non c'è bisogno di distribuire anche la libreria associata.
  • La portabilità è buona: una libreria header-only è, a tutti gli effetti, un file sorgente, quindi se si riesce a compilare sarà perfettamente compatibile con la macchina che stiamo usando (eventuali problemi verranno dopo, per distribuire la sola applicazione precompilata ed eseguibile).
  • Notare che alcune delle librerie header-only disponibili in rete usano alcuni trucchi (basati sulla definizione di alcune macro ad-hoc) per far si che uno solo dei file *.c (del progetto che userà la libreria) includa la parte di implementazione del header-file, mentre gli altri *.c includeranno solo la lista dei prototipi contenuta nell'header-file. In questo caso si dovrebbe parlare di librerie pseudo-header-only, che non sono l'argomento di questo articolo: qui parleremo solo di quelle "vere".

Apparentemente questo metodo di distribuzione è interessante, ma non è oro tutto quello che luccica: in letteratura tecnica sono riportati alcuni difetti:

  • In un grande progetto con molti sorgenti *.c che includono la libreria header-only una modifica a quest'ultima provocherà un notevole allargamento dei tempi di compilazione (l'header  verrà ricompilato n-volte). Con la potenza delle macchine attuali questo potrebbe non essere un gran problema (anche se non bisogna sottovalutarlo: alcuni progetti sono veramente grandi e comprendono migliaia di sorgenti).
  • Per evitare gli ovvi errori di ridefinizione in compilazione si fa grande uso (come vedremo più avanti) di storage classes di tipo static e/o static inline: questo provoca una crescita notevole del codice macchina generato, un problema difficilmente ottimizzabile dal compilatore.

Due problemi da niente, no?

E vabbé, è ora di dare un esempio reale (ultra-semplificato) di un progetto che usa una libreria header-only; il progetto include:

  • mylib.h: la libreria header-only (è una "vera", senza i trucchi citati sopra).
  • test1.c: un sorgente C che definisce una funzione fun1() che usa internamente una funzione della libreria mylib.h.
  • test2.c: un sorgente C che definisce una funzione fun2() che usa internamente una funzione della libreria mylib.h.
  • test.c: un sorgente con un main() che chiama le funzioni definite in test1.c e test2.c.

Vai col codice!

// mylib.h - una libreria header-only
#include <stdio.h>

#ifdef STATICINLINE
#define MY_API static inline
#define MY_API_STR "static inline api"
#elif defined STATIC
#define MY_API static
#define MY_API_STR "static api"
#elif defined INLINE
#define MY_API inline
#define MY_API_STR "inline api"
#elif defined EXTERN
#define MY_API extern
#define MY_API_STR "extern api"
#else
#define MY_API
#define MY_API_STR "api"
#endif

// libfun1 - una funzione generica della libreria
MY_API void libfun1(void)
{
printf("%s: sono %s\n", MY_API_STR, __func__);
}

// libfun2 - una funzione generica della libreria
MY_API void libfun2(void)
{
printf("%s: sono %s\n", MY_API_STR, __func__);
}
/ test1.c - modulo per il test della libreria header-only mylib
#include "mylib.h"

void fun1(void)
{
// chiamo una funzione della libreria header-only mylib
libfun1();
}
/ test2.c - modulo per il test della libreria header-only mylib
#include "mylib.h"

void fun2(void)
{
// chiamo una funzione della libreria header-only mylib
libfun2();
}
/ test.c - main di test della libreria header-only mylib

// test - funzione main
int main(void)
{
extern void fun1(void);
extern void fun2(void);

// chiamo le funzioni dei moduli test1 e test2
fun1();
fun2();

return 0;
}

Come sempre il codice è ben commentato, però in questo caso qualche spiegazione è doverosa: a parte le espressioni condizionali iniziali (che vedremo più avanti) il meccanismo di avere prototipi e definizioni in mylib.h è semplice e abbastanza chiaro, no ? Quindi visto che test1.c e test2.c includono mylib.h possono chiamare, rispettivamente, libfun1() e libfun2() internamente alle funzioni globali fun1() e fun2(). Dopodiché test.c, che non ha bisogno di includere mylib.h, chiama le funzioni globali fun1() e fun2(): tutto abbastanza lineare.

Ma l'inclusione in più sorgenti di uno strano header-file con prototipi e implementazioni non è triviale, e quindi bisogna scegliere accuratamente le storage classes delle funzioni, perché gli errori di ridefinizione sono dietro l'angolo. Il meccanismo che permette il funzionamento è, come anticipato sopra, quello che si vede nelle prime linee di mylib.h, dove ho messo alcune espressioni condizionali che permettono di testare e mostrare le varie maniere operative. Poi, una volta capito il meccanismo, mylib.h si può semplificare togliendo tutti i condizionali e lasciando solo il tipo di storage classes scelto.

Vediamo, allora, cosa succede compilando (e, se possibile, eseguendo) la nostra applicazione:

aldo@Linux $ gcc test.c test1.c test2.c -o test -DSTATICINLINE
aldo@Linux $ ./test
static inline api: sono libfun1
static inline api: sono libfun2

aldo@Linux $ gcc test.c test1.c test2.c -o test -DSTATIC
aldo@Linux $ ./test
static api: sono libfun1
static api: sono libfun2

aldo@Linux $ gcc test.c test1.c test2.c -o test -DINLINE
/usr/bin/ld: /tmp/ccdLwOwW.o: in function `fun1':
test1.c:(.text+0x9): undefined reference to `libfun1'
/usr/bin/ld: /tmp/ccK47RcM.o: in function `fun2':
test2.c:(.text+0x9): undefined reference to `libfun2'
collect2: error: ld returned 1 exit status

aldo@Linux $ gcc test.c test1.c test2.c -o test -DEXTERN
/usr/bin/ld: /tmp/ccNPBHJP.o: in function `libfun1':
test2.c:(.text+0x0): multiple definition of `libfun1'; /tmp/cczGXRP8.o:test1.c:(.text+0x0): first defined here
/usr/bin/ld: /tmp/ccNPBHJP.o: in function `libfun2':
test2.c:(.text+0x33): multiple definition of `libfun2'; /tmp/cczGXRP8.o:test1.c:(.text+0x33): first defined here
collect2: error: ld returned 1 exit status

aldo@Linux $ gcc test.c test1.c test2.c -o test
/usr/bin/ld: /tmp/cc5t0uT9.o: in function `libfun1':
test2.c:(.text+0x0): multiple definition of `libfun1'; /tmp/ccLox6Tj.o:test1.c:(.text+0x0): first defined here
/usr/bin/ld: /tmp/cc5t0uT9.o: in function `libfun2':
test2.c:(.text+0x33): multiple definition of `libfun2'; /tmp/ccLox6Tj.o:test1.c:(.text+0x33): first defined here
collect2: error: ld returned 1 exit status

Come si nota il programma si compila ed esegue regolarmente solo nei modi STATICINLINE e STATIC: la parola chiave è, in entrambi i casi static, perché definendo così le funzioni la definizione resta limitata al singolo file che include mylib.h, quindi non ci sarà nessun errore di ridefinizione (ma si cade nel problema descritto sopra: una crescita del codice macchina risultante). Aggiungendo a static anche il function specifier  inline, il risultato non cambia: funziona bene e, inoltre, il compilatore cerca, se possibile, di "inlineare" la funzione (e anche in questo caso si cade nello stesso problema descritto sopra, però l'eseguibile potrebbe essere più efficiente). Notare che le funzioni mostrano anche il tipo di interfaccia MY_API in uso, ottenuta dalla macro MY_API_STR di mylib.h.

E cosa succede compilando coi modi INLINE, EXTERN e "senza modo"? Succede che ci sono errori di compilazione, quindi non possiamo neanche eseguire. Nel modo INLINE (senza static) notiamo che la funzione non viene resa disponibile al linker, e quindi abbiamo degli errori del tipo "undefined reference to libfun1": il linker ha bisogno, per questo tipo di librerie della parola magica static. Nei modi EXTERN e "senza modo" abbiamo, invece, degli errori del tipo "multiple definition of libfun1", che indicano che, chiedendo un linkaggio di tipo extern (o senza tipo) e fornendo poi la funzione ogni volta che si include mylib.h si verifica il problema di avere multiple definizioni. Come previsto.

È tutto chiaro? Spero di si.

E qui ci sta bene aggiungere qualche considerazione personale, perché non ho ancora detto se considero buone o cattive le librerie header-only (magari l'ho fatto intuire, però): devo ammettere che non mi piacciono, perché le considero una forzatura del linguaggio: il fatto che, con le dovute accortezze, si riesca a farle digerire al compilatore e al linker, non significa che sia una buona idea usarle.

E poi inserire la definizione di una funzione dentro un header-file è più roba da C++ (il lato oscuro della forza): li è abbastanza usuale che in un header-file ci sia, all'interno della definizione di una classe, anche il codice dei metodi (questo è Ok ma non mi piace: io preferisco scriverlo nel file di implementazione della classe); per non parlare poi dei (famigerati) template, dove è addirittura obbligatorio (o perlomeno molto raccomandabile) avere "tutto" (class template  e codice dei metodi) in un solo header-file. Anche per questo i compilatori C e C++ trattano in maniera un po' differente le storage classes  descritte sopra. Conclusione: se proprio vi piacciono le librerie header-only passate al C++, ah ah ah.

Per oggi può bastare, sono contento di avere scritto un nuovo articolo del tipo "Forse!", perché su alcuni argomenti non è necessario essere troppo radicali... ma su altri si, quindi aspettatevi qualche altro "No, grazie!" in futuro! E non trattenete il respiro nell'attesa, mi raccomando!

Ciao, e al prossimo post!