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.
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!
https://italiancoders.it/atomic-endgame-come-quando-e-perche-usare-il-type-qualifier-_atomic-in-c/