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 novembre 2022

Atomichunter
come usare le funzioni atomic built-in di GCC in C

Graham: Non è curioso di sapere se Lei è più furbo di quello che cerchiamo?
Lecktor: Stai scherzando? Tu sei più furbo, visto che mi hai preso...
Graham: No, non sono più furbo di Lei.
Lecktor: E allora come hai fatto a prendermi?
Graham: Lei era svantaggiato.
Lecktor: In che senso svantaggiato?
Graham: Lei è pazzo.

Per parlare delle funzioni atomic built-in di GCC ho deciso di fare un temerario abbinamento con il capolavoro Manhunter del Maestro Michael Mann, un film assolutamente imperdibile. Il fatto è che per risolvere un problema pratico (ebbene si, oggi parleremo del mondo reale) a volte un programmatore deve trasformarsi in un cacciatore (fortunatamente non di uomini), e quindi deve investigare, provare, cercare informazioni qui e là per risolvere un caso reale. Si, bisogna fare come il detective Graham del film: beh, magari senza arrivare ai suoi estremi, e chi ha visto il film sa di cosa parlo...

...sai, ti ho preso grazie alle atomic built-in di GCC...

Non so se ricordate un mio articolo di un annetto fa, Atomic: Endgame, in cui decantavo la bontà del nuovo type qualifier _Atomic introdotto con C11 (e, ovviamente, se non lo ricordate siete tenuti a rileggerlo...). Nell'articolo era (spero) ben descritta la nuova funzionalità e si evidenziava che, grazie a _Atomic, la scrittura di applicazioni multi-thread  diventava molto più agevole (era ora!). E perché oggi torniamo sull'argomento? E qui sopraggiunge il problema pratico citato sopra: supponiamo (true story) di dover aggiungere una nuova prestazione a una vecchia applicazione su Linux Embedded usando la piattaforma (e quindi il compilatore) originale, che, per la legge di Murphy, non supporta C11 (anzi supporta a stento C99): che facciamo? Dovremo mica riscrivere il codice (già scritto e testato su piattaforme più recenti) in modo di usare i classici meccanismi di sincronizzazione delle variabili (mutex, ecc.)? No, ci deve essere una soluzione più semplice!

E quindi mi sono Graham-izzato (vedi sopra) e ho scoperto che il nostro amato GCC, grande amico dei programmatori C (e non solo), ha da molto tempo delle funzioni built-in che hanno anticipato le funzionalità di _Atomic ben prima del supporto a C11. Anzi, abbiamo ben due famiglie di built-in denominate __sync_* e __atomic_* , aggiunte con a la seguente sequenza temporale:

  • GCC 4.1: __sync_* built-in (nel 02/2006)
  • GCC 4.4: __sync_* built-in (nel 04/2009,  con supporto ARM)
  • GCC 4.7: __atomic_* built-in (nel 03/2012)
  • GCC 4.9: _Atomic (nel 04/2014, supporto completo)

Considerando la data in cui _Atomic è entrato in GCC il supporto a queste operazioni è stato fornito con un buon anticipo, no? Nella lista qui sopra ho anche evidenziato quando è stata aggiunta la compatibilità con ARM, perché l'argomento di questo articolo è decisamente indirizzato alla programmazione con Linux Embedded, e ARM è una piattaforma molto usata in questo ambito. Tra l'altro si può evidenziare che anche le versioni recenti di GCC continuano a supportare questi built-in, e quindi un eventuale Software "datato" che le usa si può ancora compilare senza problemi con un GCC recente, e senza modificare nulla (questa si che è vera retro-compatibilità! Grazie gcc.gnu.org!).

Nella mia ricerca ho anche notato che la quantità di informazioni a livello base (tipicamente quella fornita da gcc.gnu.org) è notevole in quantità e qualità, però latitano un po' i veri esempi d'uso (il codice!), e quindi ho deciso di contribuire un po' con questo articolo, che interesserà a pochi (credo) ma per quei pochi potrebbe essere una manna dal cielo (vabbé, non esageriamo...).

Che ho fatto allora? Ho scritto una mini-libreria per semplificare l'uso dell funzioni atomic built-in, e ho strutturato la libreria in maniera che si comporti in modo "intelligente" riconoscendo la versione del GCC in uso, per passare automaticamente da _Atomic a __atomic_* oppure a __sinc_*. Ho scritto un header, atoint.h che definisce un nuovo tipo atoint (nome originalissimo che sta per atomic int). Vediamolo: vai col codice!

/* atoint.h - header della libreria atoint
versioni delle funzioni atomiche:
_Atomic: da GCC 4.9 in avanti (40900L)
__atomic: da GCC 4.7 in avanti (40700L)
__sync con ARM: da GCC 4.4 in avanti (40400L)
__sync: da GCC 4.1 in avanti (40100L) */

// GCC_VERSION contiene la versione di GCC come numero (e.g.: GCC4.9 = 40900)
#define GCC_VERSION (__GNUC__ * 10000 + __GNUC_MINOR__ * 100 + __GNUC_PATCHLEVEL__)

#if GCC_VERSION >= 40900L
// da GCC 4.9 in avanti è disponibile _Atomic di C11
#include <stdatomic.h>
typedef atomic_int atoint; // uso atomic_int per atoint
#else
// _Atomic di C11 non è disponibile
typedef int atoint; // uso int per atoint
#endif

#if GCC_VERSION >= 40900L
// uso le funzioni di _Atomic
#define ATOMIC_STORE(ptr, val) atomic_store((ptr), val)
#define ATOMIC_LOAD(ptr) atomic_load(ptr)
#define ATOMIC_INC(ptr) atomic_fetch_add((ptr), 1)
#define ATOMIC_DEC(ptr) atomic_fetch_sub((ptr), 1)
#elif GCC_VERSION >= 40700L
// uso le funzioni di GCC __atomic prefixed built-in
#define ATOMIC_STORE(ptr, val) __atomic_store_n((ptr), val, __ATOMIC_SEQ_CST)
#define ATOMIC_LOAD(ptr) __atomic_load_n((ptr), __ATOMIC_SEQ_CST)
#define ATOMIC_INC(ptr) __atomic_fetch_add((ptr), 1, __ATOMIC_SEQ_CST)
#define ATOMIC_DEC(ptr) __atomic_fetch_sub((ptr), 1, __ATOMIC_SEQ_CST)
#elif GCC_VERSION >= 40100L
// TODO: scrivere la versione con __sync prefixed built-in
// ...
#else
#error Spiacente, il tuo compilatore è troppo vecchio - aggiornalo, per favore.
#endif

// prototipi globali
atoint atoStore(atoint *val, atoint newval); // store
atoint atoLoad(atoint *val); // load
atoint atoPreInc(atoint *value); // prefix increment (++val)
atoint atoPostInc(atoint *value); // postfix increment (val++) (usa il prefix increment)
atoint atoPreDec(atoint *value); // prefix decrement (--val)
atoint atoPostDec(atoint *value); // postfix decrement (val--) (usa il prefix decrement)

Come si può ben vedere il file contiene la definizione del nuovo tipo e i prototipi delle funzioni della libreria. E, soprattutto, contiene degli alias (delle #define) delle funzioni che usano le appropriate atomic built-in in base alla versione del compilatore in uso, versione che è contenuta nella macro GCC_VERSION (è un numero identificativo abbastanza semplice da calcolare, ad esempio per GCC 4.9 è 40900). Con l'uso di alcune semplici direttive #if  per il preprocessore del C possiamo scegliere quali alias attivare automaticamente. In questa versione ho usato come requisito minimo per la famiglia __sync_* il GCC 4.1  (ver.40100), ma chi ha bisogno del supporto ARM dovrebbe usare come requisito minimo il GCC 4.4 (ver.40400). Usando un compilatore ancora più anziano del requisito minimo, la compilazione esce (miseramente) con questo errore: "Spiacente, il tuo compilatore è troppo vecchio - aggiornalo, per favore.".

Visto che non volevo mettere troppa carne al fuoco ho lasciato come TODO  la versione con __sync_*  che è un po' più complicata e avrebbe appesantito un po' il codice. Tra l'altro si dovrebbero/potrebbero aggiungere anche altre funzioni oltre a quelle presentate qui (e si può fare: io, mio malgrado, ho dovuto scrivere una versione full-optional perché mi serviva): magari ci torneremo in un prossimo articolo.

Grazie agli alias delle funzioni la implementazione della libreria è molto semplice e lineare. Vediamola:

// atoint.c - implementazione della libreria atoint
#include "atoint.h"

// store (assegna il valore)
atoint atoStore(atoint *val, atoint newval)
{
ATOMIC_STORE(val, newval);
return *val; // return del valore nuovo
}

// load (legge il valore)
atoint atoLoad(atoint *val)
{
return ATOMIC_LOAD(val);
}

// prefix increment (++val)
atoint atoPreInc(atoint *val)
{
ATOMIC_INC(val); // incrementa atomicamente
return *val; // return del valore nuovo
}

// postfix increment (val++) (usa il prefix increment)
atoint atoPostInc(atoint *val)
{
atoint old = *val; // salva il valore vecchio
atoPreInc(val); // incrementa atomicamente
return old; // return del valore vecchio
}

// prefix decrement (--val)
atoint atoPreDec(atoint *val)
{
ATOMIC_DEC(val); // decrementa atomicamente
return *val; // return del valore nuovo
}

// postfix decrement (val--) (usa il prefix decrement)
atoint atoPostDec(atoint *val)
{
atoint old = *val; // salva il valore vecchio
atoPreDec(val); // decrementa atomicamente
return old; // return del valore vecchio
}

È veramente molto semplice, no? E grazie ai molti commenti, credo che non sono necessarie ulteriori spiegazioni.

E, dulcis in fundo, bisogna provare se la libreria funziona, no? Si, ci vuole un bel programma di test, vai col codice!

#include <stdio.h>
#include <pthread.h>
#include "atoint.h"

// prototipi locali
void* updateCnt(void* arg);

// variabili globali (per un semplice test si possono usare!)
atoint ato_cnt; // un int atomico usato come contatore
int cnt; // un int normale usato come contatore

// funzione main
int main(void)
{
// init contatori
atoStore(&ato_cnt, 0);
cnt = 0;

// avvio 10 thread
pthread_t tid[10];
for (int n = 0; n < 10; ++n)
pthread_create(&tid[n], NULL, &updateCnt, NULL);

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

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

return 0;
}

// updateCnt() - funzione di update dei counter eseguita da ogni thread
void* updateCnt(void* arg)
{
// incremento i counter per 1000 volte
for (int n = 0; n < 1000; ++n) {
// uso prefix increment
atoPreInc(&ato_cnt);
++cnt;
}

return NULL;
}

I più attenti avranno notato che è esattamente lo stesso programma di test mostrato nell'articolo Atomic: Endgame, solo che in questo caso si usa il nuovo tipo atoint invece del tipo standard atomic_int: se il test funzionasse potremmo affermare che la libreria atoint è un buon sostituto della versione standard. Ci riusciremo? Proviamo: prima di tutto se stiamo usando un GCC recente (cosa probabile) dobbiamo forzare l'uso della versione senza _Atomic (ad esempio "truccando"  la define di GCC_VERSION), poi possiamo compilare ed eseguire. Il risultato è questo:

aldo@Linux $ ./test
Il contatore atomico vale: 10000
Il contatore normale vale: 4902

Ma funziona! Il contatore atomico atoint ha contato esattamente tutti gli incrementi e vale 10000 mentre il contatore normale ha perso molti degli incrementi a causa della concorrenza, e, quindi, vale molto meno (e ripetendo l'esecuzione si ottengono sempre valori inferiori a 10000). Se poi vogliamo fare anche "la prova del 9"  possiamo togliere il trucco che forza la versione antica e ricompilare con il nostro GCC recente: con _Atomic,  il risultato dovrebbe essere lo stesso (e se non lo è sarebbe il caso di dire "Houston, abbiamo un problema.").

E, se avete voglia di sperimentare, potete modificare leggermente il codice per usare anche le tre funzioni inutilizzate (atoPostInc(), atoPreDec() e atoPostDec()), vi assicuro che funzionano bene.

Ok, personalmente a questo punto sono soddisfatto e per oggi possiamo concludere qui. La libreria atoint funziona e spero vivamente che possa risultare utile a molti lettori. Nel prossimo articolo cambieremo argomento, ma, come promesso sopra, prima o poi torneremo su questa storia per completare la trattazione... e non trattenete il respiro nell'attesa, mi raccomando!

Ciao, e al prossimo post!