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ì 17 settembre 2021

Atomic: Endgame
come, quando e perché usare il type qualifier _Atomic in C

Scott Lang/Ant-Man: È una pazzia!
Natasha Romanoff/Vedova Nera: Scott, io ricevo e-mail da un procione, perciò nulla sembra una pazzia ormai.

E siamo arrivati al terzo capitolo della mini-saga degli Endgame sui type qualifier del C. Questa volta tocca a _Atomic, che, rispetto ai qualificatori trattati nei due precedenti articoli (su volatile e restrict), ha un ruolo più importante nel linguaggio. E, visto che nel nuovo mondo post Infinity War la nostra Natasha non si sorprende di parlare con un procione, noi non siamo affatto sorpresi di avere (finalmente!) la possibilità di usare variabili atomiche nel C.

...con le variabili atomiche la mia vita è cambiata da così a così...

Anche stavolta (per la solita pigrizia) seguirò la stessa impostazione dei due articoli precedenti ("Squadra che vince non si cambia") quindi questo è il momento delle notizie, e cominceremo con la buona:

  • La buona notizia (che ho già spoilerato sopra) è che _Atomic è un qualificatore veramente molto utile, che ci può rendere la vita più facile nella scrittura del codice (solo nella programmazione multithread, eh!). _Atomic è arrivato con lo Standard C11 insieme ad altre poche, mirate e utili novità (che vi invito a esaminare e ad usare). _Atomic ha un nome, ahimè, un po' strano che è stato scelto per curare la retrocompatibilità, visto che è possibile che ci siano delle implementazioni "private" e pre-C11 delle variabili atomiche (l'attesa è stata lunga!); un po' come è successo, per esempio, con le variabili booleane _Bool definite nell'header stdbool.h: questa norma del nome che inizia con undescore+maiuscola è seguita per tutte le nuove keyword del C, che possono, poi, essere usate con l'header corrispondente per "raddrizzare" il nome (la stabilità e la retrocompatibilità sopra tutto!).

(...e visto che siamo in argomento: come ben sapete le periodiche revisioni dello Standard del C si limitano sempre a poche e essenziali aggiunte, già che il C è un linguaggio, per sua natura ed origine, stabile e completo. Proprio il contrario del C++ che, ad ogni revisione, sembra dire: "Fino ad oggi abbiamo scherzato, ma da adesso il C++ è un linguaggio nuovo.". Ma non è che avere un linguaggio nuovo ogni tre (3!) anni è un po' eccessivo? E non è che aggiungere n-mila novità ogni volta (molte di uso dubbioso e che, probabilmente, quasi nessuno userà mai, come già fatto notare dal Maestro Rob Pike) è un po' ridicolo e offensivo per chi già lo usa bene (tra cui, modestamente, il sottoscritto) da anni? Ma questa è un altra storia...)

E ora è il turno della cattiva notizia, che è meno cattiva di quel che sembra:

  • _Atomic ha una doppia personalità: è un type qualifier ma è anche un type specifier, e questo origina, come vedremo tra poco, alcune considerazioni e dubbi.

E veniamo al dunque: nel titolo di questo articolo preannunciavo la descrizione del come, quando e perché usare _Atomic in C, per cui ora ci tocca cominciare con:

IL COME:

Come abbiamo appena visto _Atomic ha due personalità, e quindi possiamo scrivere cose come queste:

int dummy; // un int normalissimo che si chiama dummy
_Atomic int ato_dummy1; // uso come type qualifier: ato_dummy1 è una
// variabile che è una versione atomica di un int
_Atomic(int) ato_dummy2; // uso come type specifier: ato_dummy2 è una
// variabile di un nuovo tipo "int atomico"

La differenza tra qualificatore e specificatore è sottile: ad esempio nel semplice caso descritto sopra ato_dummy1  e ato_dummy2  sono, in effetti, la stessa cosa. Diciamo che la versione "qualifier"  ha un uso più familiare (specialmente dopo aver letto gli ultimi articoli... li avete letti, vero?) e può essere anche combinata con gli altri type qualifier (volatile, restrict e const). Invece la versione "specifier" non può essere combinata con i qualificatori  (incluso lo stesso _Atomic). Bisogna anche aggiungere che (come specificato nello standard) un "tipo atomico" non necessariamente ha la stessa struttura di basso livello (size, allineamento, ecc.) del tipo da cui deriva (però potrebbe averla: dipende dall'implementazione).

E adesso un piccolo esempio di casi reali:

_Atomic const int *ptr1; // pointer a un atomic const int
const _Atomic(int) *ptr2; // anche questo è un pointer a un
// atomic const int

_Atomic const volatile int *ptr3; // pointer a un atomic const volatile int
const _Atomic(volatile int) *ptr4; // errore: non si può combinare se si usa
// come type specifier

E per concludere in bellezza questo paragrafo, vi dirò un segreto di pulcinella: anche se esistono le due possibilità di uso viste sopra, in pratica non se ne usa nessuna delle due, visto che, molto convenientemente, l'uso delle variabili atomiche passa attraverso l'inclusione dell'header  stdatomic.h  che contiene un gran gruppo di tipi predefiniti (sono ben 37), che si dovrebbero usare in maniera preferente. Quindi, ad esempio, un codice normale è questo:

#include <stdatomic.h> // include standard del type qualifier _Atomic

// funzione main
int main(void)
{
// dichiaro due variabili atomiche usando i tipi predefiniti in stdatomic.h
atomic_bool my_ato_bool;
atomic_int my_ato_int;

// uso le variabili atomiche my_ato_bool e my_ato_int
...
}

E direi che a questo punto è evidente che perdersi nel dilemma "uso il qualificatore o lo specificatore?"  invece di usare i tipi predefiniti e un po' una sega mentale, tranne in particolari casi in cui si vogliono usare versioni atomiche di tipi complessi (strutture) invece che di tipi semplici (e anche questo si puo fare!).

E ora siamo pronti a passare al prossimo punto:

IL QUANDO:

Come anticipato sopra, _Atomic è decisamente riservato all'uso nella programmazione multithread: infatti, in questo tipo di programmazione l'accesso ai dati condivisi (shared, per gli amici) deve essere sempre "sicuro", perché se due (o più) thread accedono simultaneamente allo stesso dato si può produrre il famigerato "data race" con effetti indesiderati e imprevedibili. Al proposito lo standard del C dice:

"The execution of a program contains a data race if it contains two conflicting actions in different threads, at least one of which is not atomic, and neither happens before the other. Any such data race results in undefined behavior." [The C Standard, section 5.1.2.4, paragraph 25 [ISO/IEC 9899:2011]]

Nel mondo pre-C11 queste situazioni si risolvevano, al solito, usando i classici strumenti di sincronizzazione dei thread (ad esempio i mutex, oppure usando, molto erroneamente, volatile), ma ora, grazie a _Atomic è molto più semplice scrivere codice senza "data races", usando variabili atomiche per i dati condivisi. Gli strumenti di sincronizzazione tipo mutex e spinlock si usano ancora, ma per usi più specifici di sincronizzazione e non di accesso a dati shared.

Quale è, ora, il prossimo punto? Ah si, è:

IL PERCHÉ:

Il perché direi che si può descrivere facilmente: come visto nel punto precedente, nella programmazione multithread è obbligatorio evitare i "data races". Questo si è sempre ottenuto usando gli strumenti classici a disposizione (mutex, spinlock, ecc.). Ora, con l'introduzione in C11 di _Atomic ci viene data la possibilità di risolvere lo stesso problema in modo più semplice (ottenendo codice più compatto e pulito) ed efficiente (le operazioni atomiche non eseguono lock dei thread, e quindi sono generalmente più veloci delle operazioni fatte con i mutex). Quindi direi che usare _Atomic è una scelta azzeccatissima e quasi obbligata.

E adesso, come sempre, è venuto il momento del codice, per cui vi propongo un esempio semplicissimo, che è uno dei tanti possibili. Anche stavolta l'ho scopiazzato da cppreference.com, perché non avevo voglia di scriverne uno ad-hoc, ma anche perché è veramente un bell'esempio, molto calzante. E vi dirò di più: questo stesso esempio (identico o con piccoli cambi) l'ho trovato anche in altre pagine della rete, quindi non posso dire esattamente quale sia la fonte originale (che potrebbe non essere neppure cppreference.com, ah ah ah). Vai col codice!

#include <stdio.h>
#include <threads.h>
#include <stdatomic.h>

atomic_int ato_cnt; // un int atomico usato come contatore
int cnt; // un int normale usato come contatore

// updateCnt() - funzione di update dei counter eseguita da ogni thread
int updateCnt(void* thr_data)
{
// incremento i counter per 1000 volte
for (int n = 0; n < 1000; ++n) {
++ato_cnt;
++cnt;
// per questo esempio, il relaxed memory order è sufficiente,
// quindi si dovrebbe usare:
// atomic_fetch_add_explicit(&ato_cnt, 1, memory_order_relaxed),
// ma va bene anche il semplice incremento (++) usato
}

return 0;
}

// funzione main
int main(void)
{
// avvio 10 thread
thrd_t thr[10];
for (int n = 0; n < 10; ++n)
thrd_create(&thr[n], updateCnt, NULL);

// attendo la fine dei 10 thread
for(int n = 0; n < 10; ++n)
thrd_join(thr[n], NULL);

// i risultati!
printf("Il contatore atomico vale: %u\n", ato_cnt);
printf("Il contatore normale vale: %u\n", cnt);
}

E se compilate ed eseguite l'esempio i risultati saranno (si fa per dire) sorprendenti: ato_cnt  varrà 10000 mentre cnt  varrà molto meno (ma chissà perché?).

Notare, nella nota scritta dopo il ++cnt , che si cita la funzione atomic_fetch_add_explicit(): ecco, le operazioni atomiche possono essere semplici (ad esempio il "++" eseguito su una variabile atomica è garantito come "atomico"), ma in alcune situazioni bisogna usare alcune operazioni speciali (tipicamente di fetch, load e store) e, magari, bisogna anche curare il modo di riempimento della memoria (il memory_order). Ma questi sono dettagli che vi consiglio di approfondire autonomamente leggendo bene i manuali e scrivendo qualche programma di test.

E per oggi penso che possa bastare. Teoricamente dovrei dedicare un altro Endgame al type qualifier const... ma const non è proprio una novità, ed è, oramai, di uso molto comune (specialmente per chi usa anche il C++), quindi quasi quasi lo salto, perché non ci sono molti misteri da svelare sull'argomento. Vedremo...

Ciao, e al prossimo post!

domenica 15 agosto 2021

Restrict: Endgame
come, quando e perché usare il type qualifier restrict in C

Scott Lang/Ant-Man: Voi avete mai studiato la fisica quantistica?
Natasha Romanoff/Vedova Nera: Solo per fare conversazione.

Beh, vediamo un po': siamo ad Agosto, fa caldo, sono in ferie... macchimmelofaffare di scrivere un post? E vabbè, lo faccio, ma sarà un post leggero e frivolo, in cui riutilizzerò la struttura, il tema e perfino il titolo dell'ultimo articolo che ho scritto. E quindi mi ispirerò ancora al formato tutto trame e sottotrame del bel Avengers: Endgame per fare una dotta (?) disquisizione nonché "chiusura finale"  su un altro misterioso e (fortunatamente) poco usato type qualifier del C: restrict.

...ti spiego: restrict non serve a niente...

Per seguire lo stesso schema dell'ultimo articolo bisogna ora dare le notizie, e direi di cominciare con quella buona:

  • La buona notizia è che, similmente a volatile che si usava solo col contagocce, restrict si usa ancora meno, visto che è veramente poco utile (e nella citazione di chiusura dell'articolo capirete il perché) ma, comunque, non è vietato usarlo.

E anche qui c'è una cattiva notizia collegata a quella buona:

  • Visto che usare restrict è possibile, se lo si usa bisogna usarlo bene, altrimenti si possono avere comportamenti indefiniti (undefined behavior) durante l'esecuzione del programma: macchittelofaffare?

E veniamo al dunque: nel titolo di questo articolo preannunciavo la descrizione del come, quando e perché usare restrict in C, per cui ora ci tocca cominciare con:

IL COME:

Ricordiamo che restrict è un type qualifier, e quindi si usa (solo) per aggiungere una proprietà a un pointer (ripeto, solo a un pointer) a un tipo qualsiasi già esistente, quindi il come è semplicissimo e, ad esempio, possiamo scrivere:

int *dummy; // un int pointer normalissimo che si chiama dummy
int *restrict res_dummy; // un int pointer di tipo restrict che si chiama res_dummy

E vi ricordo nuovamente che i type qualifier sono solo quattro: volatile, restrict, const e _Atomic, quindi ce ne mancano ancora due da analizzare... (uff, siamo solo a metà!). E ora siamo pronti a passare al prossimo punto:

IL QUANDO:

Per capire il quando bisogna descrivere brevemente cosa fa restrict applicato a un pointer: dice al compilatore che l'oggetto puntato da quel pointer sarà puntato esclusivamente attraverso quel pointer e mai con un altro: quindi restrict si dovrebbe usare quando abbiamo il controllo totale del codice scritto e siamo sicuri che questa condizione di uso esclusivo sarà sempre rispettata (e se no: undefined behaviour!). Uhm, "controllo totale del codice scritto" questo è possibile per programmatori molto esperti (ne ho già parlato) però diventa molto difficile lavorando in team (anche piccoli).

E ora siamo pronti per il prossimo punto, che è:

IL PERCHÉ:

Il perché direi che si può riassumere in: perché voglio ottenere un codice super-ultra-stra-mega efficiente e ottimizzato e, visto che non mi fido delle ottimizzazioni realizzate autonomamente dal compilatore, voglio aiutarlo un po'... beh, questo appare un po' presuntuoso e pretenzioso e, tra l'altro, ne ho già parlato qui.

Riassumendo, a questo punto possiamo trarre la inevitabile conclusione, che è: restrict non è del tutto inutile, ma è da usare con tale parsimonia e attenzione che si può benissimo farne a meno, e non a caso in alcuni ambienti di programmazione molto restrittivi (ad esempio quelli in cui si usano le norme MISRA) l'uso è scoraggiato.

È però una buona abitudine usare restrict quando si scrivono funzioni molto ottimizzate (magari di libreria) con uso di argomenti "limitati"  (ma con la condizione di documentare adeguatamente agli utilizzatori le restrizioni d'uso). Ad esempio, se ho bisogno di scrivere una mia versione in C della memcpy(), perché non voglio/posso usare quella standard, dovrei dichiararla come:

void *myMemcpy(void *restrict dest, const void *restrict src, size_t n);

Così la nuova funzione avrebbe esattamente le stesse limitazioni e lo stesso prototipo della memcpy() standard, dove gli argomenti src e dest non possono "overlappare".

E adesso, dopo tante parole, ci vuole un po' di codice, per cui vi propongo un esempio semplicissimo, che è uno dei tanti possibili (l'ho scopiazzato da cppreference.com perché non avevo voglia di scriverne uno ad-hoc):

// copy() - funzione di copia senza overlap
void copy(int n, int *restrict dest, int *restrict src)
{
while (n-- > 0)
*dest++ = *src++; // nessuno degli oggetti modificati tramite *dest
// è uguale a nessuno degli oggetti letti tramite
// *src. Il compilatore è libero di ottimizzare,
// vettorializzare, mappare le pagine, ecc.
}

// funzione main
int main()
{
extern int d[100];
copy(50, d + 50, d); // OK
copy(50, d + 1, d); // Undefined behavior: d[1] è utilizzato attraverso
// entrambi dest e src in copy()
}

E per concludere, vi aggiungo la citazione (che ho già usato qui e qui) di uno molto più bravo di me, come avevo promesso all'inizio dell'articolo. Una citazione che riassume quello che penso di restrict, e che può aiutare a tornare coi piedi per terra quando ci si perde in dettagli ininfluenti e si scrive del pessimo codice pensando di scrivere un capolavoro:

"The real problem is that programmers have spent far too much time worrying about efficiency in the wrong places and at the wrong times; premature optimization is the root of all evil (or at least most of it) in programming" [Donald Ervin Knuth, "Computer Programming as an Art", 1974]

E per oggi penso che possa bastare. Rilassatevi, che siamo in Agosto!

Ciao, e al prossimo post!

lunedì 19 luglio 2021

Volatile: Endgame
come, quando e perché usare il type qualifier volatile in C

Scott Lang/Ant-Man: No, se rispettiamo le regole del viaggio nel tempo: non parlare con noi che troveremo in quel passato, non scommettere su eventi sportivi...
Tony Stark/Iron Man: Ti fermo prima che tu vada avanti, Scott. Mi stai seriamente dicendo che il tuo piano per salvare l'universo è basato su Ritorno al Futuro?

Il bel Avengers: Endgame termina in maniera magistrale il primo ciclo degli Avengers Marvel, ed è un bel film che chiude varie trame e sotto-trame iniziate tempo addietro, ed ha molteplici chiavi di lettura che gli permettono di piacere sia ai Cinefili (come me) sia a chi pensa che un Cinema è "un posto come un altro per ripararsi dalla pioggia" [S.Kubrick, 1987].

E cosa centra tutto questo con la famigerata keyword volatile? Beh, forse la presenza di trame e sotto-trame di una lunga storia che ha creato non pochi equivoci sull'argomento. Con questo articolo vorrei creare un Endgame sull'uso e disuso di volatile, ma ho obiettivi limitati e realistici: perlomeno mi accontenterei di dare qualche utile informazione a chi neppure sa cosa sia e, allo stesso tempo, di non confondere le idee a chi la usa già in maniera efficace. Speriamo bene...

...ti spiego: un volatile non necessariamente ha le ali...

E direi di cominciare con le buone notizie:

  • La prima buona notizia è che volatile si usa veramente col contagocce, perché realmente serve solo nella programmazione di bassissimo livello, quella a stretto contato con l'Hardware: quindi se non siete dei programmatori hard-embedded probabilmente non la userete mai.
  • La seconda buona notizia è che l'uso improprio di volatile è probabile che non faccia molti danni: nel senso che se la usate (per sbaglio) quando in realtà non vi serve è molto probabile che il codice funzioni ugualmente bene: in quel caso potrete essere fieri di avere un codice ben funzionante nonostante l'uso di volatile (sono soddisfazioni...).

Ma c'è anche una cattiva notizia molto somigliante alla seconda descritta sopra:

  • L'uso improprio di volatile potrebbe anche fare molti danni: nel senso che se la usate per scopi che non sono quelli previsti dallo standard, è molto probabile che il codice abbia dei malfunzionamenti veramente strani e difficili da capire, roba da sindrome di "mal di testa del programmatore".

E veniamo al dunque: nel titolo di questo articolo preannunciavo la descrizione del come, quando e perché usare volatile in C, per cui ora ci tocca cominciare con:

IL COME:

Ricordiamo che volatile è un type qualifier, e quindi si usa (solo) per aggiungere una proprietà a un tipo qualsiasi già esistente, quindi il come è semplicissimo e, ad esempio, possiamo scrivere:

int dummy; // un int normalissimo che si chiama dummy
volatile int vol_dummy; // un int di tipo volatile che si chiama vol_dummy

E approfitto l'occasione per ricordare che i type qualifier sono solo quattro, e che gli altri sono const, restrict e _Atomic : magari ne parleremo in un prossimo articolo (magari uno per ogni qualificatore). E ora siamo pronti a passare al prossimo punto:

IL QUANDO:

In questo caso è sufficiente ricordare i motivi "storici" alla base dell'uso di volatile, quattro motivi raggruppati sotto la definizione: "in tutte situazioni in cui il valore della variabile può cambiare senza azione da parte del codice visibile". E vediamoli, questi quattro quando (spoiler: il quarto è sbagliatissimo):

  1. Quando ci si interfaccia con un Hardware che cambia il valore stesso della variabile.
  2. Quando c'è un gestore di segnali che potrebbe cambiare il valore della variabile.
  3. Quando una variabile cambia tra un setjmp e il longjmp collegato.
  4. Quando c'è un altro thread in esecuzione che usa anche lui la stessa variabile (ERRORE! Anzi, ORRORE!)

Ecco, il punto 4 è veramente sbagliato, ma non ve lo spiegherò direttamente, lo vedremo nel prossimo punto, che è:

IL PERCHÉ:

Ma perché scrivendo del codice bisogna informare qualche entità suprema che la variabile è volatile? Ma che gliene frega all'entità suprema (che è, in questo caso, il compilatore) che qualcuno esternamente potrebbe modificare il valore della variabile? Sembrerebbe più un problema di run-time che di compile-time, eppure... beh, la risposta è semplice: perché funzioni il tutto  il compilatore deve sviluppare del codice-macchina che sia a conoscenza che quella variabile è speciale, che quella variabile non cambia per le operazioni dirette scritte nel codice ma cambia per alcune ingerenze esterne.

E tutto questo è strettamente collegato alle ottimizzazioni: dovete sapere (beh, immagino che lo sappiate già) che i compilatori hanno la pessima (o ottima, dipende dai punti di vista) abitudine di ottimizzare il codice (...vedi al proposito questo ottimo articolo...). Quindi, il compilatore ha bisogno di sapere se la variabile è volatile, per evitare di eseguire ottimizzazioni dannose su un codice apparentemente "strano" come può apparire un codice che ha veramente bisogno di variabili volatili (l'ottimizzatore del compilatore non può capire tutti i dettagli se non glieli esplicitiamo).

E adesso vi propongo un esempio semplicissimo, che è uno dei tanti possibili (è veramente, ma veramente, semplificato, ma è solo per rendere l'idea):

int main()
{
int dummy = 0; // per semplificare, ma dovrebbe essere esterna

while (dummy == 0) {
// eseguo qualcosa...
;
}

return 0;
}

ecco, questo semplice codice verrà trasformato dal compilatore (anche con ottimizzazioni minime, ad esempio usando -O1  nel caso del GCC ) in:

int main()
{
int dummy = 0; // per semplificare, ma dovrebbe essere esterna

while (1) {
// eseguo qualcosa...
;
}

return 0;
}

Questo perché il test della variabile nel while è inutile, già che la condizione è sempre vera.

Ma cosa succede se la mia variabile dummy è una abilitazione proveniente da una variabile mappata direttamente nell'Hardware (un input digitale, per esempio)? Succede che il test che sembrava inutile in realtà deve essere sempre fatto, e quindi mio codice non funziona più! E che si fa allora? Si fa questo:

int main()
{
volatile int vol_dummy = 0; // per semplificare, ma dovrebbe essere esterna

while (vol_dummy == 0) {
// eseguo qualcosa...
;
}

return 0;
}

In questo caso il compilatore è informato che non deve assolutamente ottimizzare il loop, perché è basato su una variabile volatile, e se provate a ottenere l'assembler dalla compilazione ve ne renderete facilmente conto (certo, spiegare dettagli della programmazione C usando l'assembler è un po' fuorviante, ma in questo caso ci sta bene). E questo spiega anche la seconda buona notizia descritta sopra: se usiamo volatile dove non ce n'è bisogno, alla fin fine solo stiamo disabilitando le ottimizzazioni per una parte limitata del codice, e questo non provoca malfunzionamenti, ma, al massimo, una riduzione delle prestazioni.

E la allora, perché il punto 4 del quando era sbagliato? Questo ci porta alla cattiva notizia descritta in testa all'articolo: se usiamo volatile dove non bisogna usarla, potremmo avere dei problemi, e questo nasce da un vecchio malinteso: il motivo per cui la variabile si intende come "modificabile esternamente"  è valido solo per i primi tre casi elencati sopra, ma non include le modifiche effettuate da un altro thread. Una operazione thread-safe su una variabile deve essere atomica (usando i costrutti forniti dal linguaggio) oppure deve essere protetta dai soliti metodi di sincronizzazione disponibili: mutex, spinlock, ecc. Le operazioni su una volatile non sono atomiche, e quindi non sono adatte per eseguire sincronizzazione (e questo è specificato anche nei vari standard (C, POSIX e altri).

E per oggi penso che possa bastare, e spero di non avere aggiunto confusione a un argomento già di per sé abbastanza confuso...

Ciao, e al prossimo post!