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ì 16 dicembre 2024

Thread Runner
come usare i thread in C - pt.2

Eldon Tyrell: Quale sarebbe il tuo problema?
Roy Batty: La morte.
Eldon Tyrell: La morte... beh questo temo sia un po' fuori della mia giurisdizione.
Roy Batty: Io voglio più vita, padre!

(...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...)

Dove eravamo rimasti? Ah, si: nella prima parte di Blade Runner (oops... Thread Runner) avevamo introdotto l'argomento thread  partendo dalla base, e cioè dai POSIX Threads. Ora, come promesso, tenteremo di scrivere lo stesso esempio dello scorso post usando una interfaccia alternativa, e cioè i C11 Threads.

...ti spiego: io sono un POSIX thread e tu un C11 thread...

E qui ci vuole una premessa che parte dal lato oscuro della forza (e vabbé, il C++...): il committee ISO del C++ decise di introdurre, nella versione C++11, i thread all'interno del linguaggio. Quindi niente più uso diretto dei POSIX Threads attraverso le funzioni della libreria libpthread, ma uso diretto di costrutti del linguaggio stesso. La realizzazione finale è stata (secondo me) brillante, e i C++11 Threads sono una delle poche cose del C++11 che uso frequentemente. Certo, anche i C++11 Threads non sono esenti da difetti, e alcuni anche abbastanza gravi, eh! Come la supercazzola dei future e promise per controllare gli errori (ne ho parlato qui)... ma usandoli senza farsi troppe seghe mentali sono abbastanza programmer-friendly. Comunque già sapete cosa ne penso della brutta deriva del C++ pilotata dal committee ISO, ne ho parlato varie volte in passato...

Ma torniamo al nostro amato C: il committee ISO del C non poteva rimanere indietro, e quindi hanno pensato di fare la stessa cosa con il C11, ovvero far si che i thread siano direttamente una parte del C... e ci sono riusciti? Prima di rispondere vi anticipo una considerazione: ho una stima del committee ISO del C maggiore di quella che ho di quello del del C++ (e non ci voleva molto...), ma in questo caso devo proprio dire che non ci siamo: a seguire vedremo il perché.

Come sono stati pensati i nuovi C11 Threads? Allora, hanno preso tutte le funzioni e variabili che compongono i POSIX Threads e gli hanno cambiato il nome (e devo ammettere che quelli nuovi sono più semplici e immediati); inoltre, in alcuni casi (pochi, per fortuna), hanno cambiato i tipi dei codici di ritorno e degli argomenti delle funzioni. Punto. Geniale? Non proprio direi, e niente a che vedere con la soluzione brillante usata nel C++11. Motivi per usare questa nuova versione? Zero, direi, e non vi ho ancora esposto l problemi principali...

Comunque, per farla breve, ho riscritto l'esempio dello scorso post usando i C11 Threads. Vai col codice!

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdbool.h>
#include <threads.h>

#define NUMTHREADS 2 // numero di thread da trattare, in questo caso 2

// struttura per i dati condivisi dai thread
typedef struct {
mtx_t mutex; // mutex di sincronizzazione comune ai thread
bool stop; // flag per stop thread
unsigned long cnt; // counter condiviso dai thread
} Thdata;

// prototipi locali
int threadFunc(void *arg);

// funzione main()
int main(int argc, char* argv[])
{
char errmsg_buf[256]; // buffer per strerror_r(3)

// init dei dati condivisi dai thread
Thdata thdata;
thdata.stop = false;
thdata.cnt = 0;

// init del mutex di Thdata
int error;
if ((error = mtx_init(&thdata.mutex, mtx_plain)) != thrd_success) {
// errore!
printf("%s: non posso creare il mutex (%d)\n", argv[0], error);
return 1;
}

// loop di avvio dei thread
thrd_t tid[2];
for (int i = 0; i < NUMTHREADS; i++) {
// creo un thread
if ((error = thrd_create(&tid[i], threadFunc, &thdata)) != thrd_success) {
// errore!
printf("%s: non posso creare il thread %d (%d)\n", argv[0], i, error);
return 1;
}
}

// dopo 10 secondi fermo tutti i thread
sleep(10);
thdata.stop = true;

// loop di join dei thread
for (int i = 0; i < NUMTHREADS; i++) {
if ((error = thrd_join(tid[i], NULL)) != thrd_success) {
// errore!
printf("%s: non posso attendere il thread %d (%d)\n", argv[0], i, error);
return 1;
}
}

// cancello il mutex di sincronizzazione
mtx_destroy(&thdata.mutex);

// esco
printf("%s: thread terminati: thdata.cnt=%lu\n", argv[0], thdata.cnt);
return 0;
}

// threadFunc() - funzione per i thread
int threadFunc(void *arg)
{
// ottengo i dati del thread con un cast (Thdata *) di (void *) arg
Thdata *thdata = (Thdata *)arg;

// loop del thread
printf("thread partito\n");
unsigned long loc_cnt = 0;
for (;;) {
// lock del mutex
mtx_lock(&thdata->mutex);

// incremento il counter locale e quello condiviso
loc_cnt++;
thdata->cnt++;

// unlock del mutex
mtx_unlock(&thdata->mutex);

// test dello stop flag
if (thdata->stop) {
// il thread esce
printf("thread terminato dal main: loc_cnt=%lu\n", loc_cnt);
thrd_exit(0);
}

// sleep del thread (uso usleep solo per comodità invece della nanosleep(2))
usleep(1000);
}

// il thread esce per altro motivo diverso dallo stop flag
printf("thread terminato localmente: loc_cnt=%lu\n", loc_cnt);
thrd_exit(0);
}

Come vedete il codice nuovo è praticamente identico al vecchio, mi sono limitato a usare le nuove funzioni al posto di quelle vecchie (per esempio thrd_create() invece di pthread_create(3)) , ho usato i nuovi tipi (per esempio mtx_t invece di pthread_mutex_t) e ho leggermente modificato il test dei valori di ritorno: poche differenze, devo dire, e, in alcuni casi, in peggio: ad esempio è sparito il parametro attr di pthread_create(3), che (per semplicità) nello scorso esempio avevo lasciato a NULL, ma che a volte può risultare utile (leggere il manuale della funzione per rendersene conto). Comunque si potrebbe dire (senza fare troppo gli schizzinosi) che la nuova interfaccia non ci offre nessun vantaggio sostanziale, ma neanche un peggioramento decisivo, quindi si potrebbe anche usare (de gustibus).

Ma c'è un problema: pare che i C11 Threads non siano stati considerati una priorità per chi scrive i compilatori e le varie libc, quindi per molti anni dall'introduzione è stato difficile compilare/eseguire un programma come quello che ho mostrato. Perfino il nostro amato GCC (che di solito è il primo a fornire supporto per le ultime novità) ci ha messo una vita a supportare i nuovi thread (in realtà a causa della mancata integrazione nella glibc). Questo è un brutto segno, per niente incoraggiante: se il mondo che gira intorno ai linguaggi e ai Sistemi Operativi non approva rapidamente vuol dire che ci sono delle perplessità.

Comunque dopo tanto tempo qualcosa è arrivato, e adesso è possibile compilare i C11 Threads su Linux (con versioni recenti di GCC e con una glibc dalla v.2.28 in su), sui vari sistemi BSD, su Windows, ma non ancora su macOS (se non mi sbaglio). Aggiungo un dettaglio illuminante sulla coppia GCC/glibc: in attesa di un supporto veramente nativo, la compilazione era basata su una implementazione che era, praticamente, un wrapper che simulava i C11 Threads usando i POSIX Threads (infatti bisognava linkare la libpthread): un po' assurdo, no?

E anche se ora si può usare direttamente GCC con una glibc recente (la v.2.28 è del 2018), io ho deciso di seguire con la stessa soluzione che è stata disponibile quasi da subito (già nel 2014) e ho già usato in passato: ho usato la musl libc che è una alternativa alla glibc, ed è dotata di un wrapper per GCC (musl-gcc). musl fornisce (su Linux) il supporto completo al C11, ed è stata (credo) la prima in assoluto a fornire il supporto nativo ai C11 Thread. E come funziona tutto questo? Una volta compilato il programma si comporta correttamente, come potete vedere qui sotto:

aldo@Linux $ musl-gcc c11threads.c -o c11threads
aldo@Linux $ ./c11threads
thread partito
thread partito
thread terminato dal main: loc_cnt=8597
thread terminato dal main: loc_cnt=8597
./c11threads: thread terminati: thdata.cnt=17194

Ma il gioco vale la candela? No, per quel che mi riguarda continuerò ad usare i POSIX Threads, che uso da molti anni e rimangono il riferimento d'eccellenza. I C11 Threads sono uno strano ibrido che non rispetta la classica sintassi della glibc (ad esempio in caso di errore non ritorna -1 ma un codice di errore e, oltretutto, non aggiorna errno), e non rispetta pienamente neanche lo standard POSIX (i codici di errore non sono inseriti in una lista analizzabile con strerror(3)). E poi, come già detto sopra, le nuove funzioni sono equivalenti alle vecchie ma non abbastanza, alcune funzionalità non sono più disponibili e alcune cose semplici (come stampare il thread Id interno della libreria o le stringhe degli errori) sono molto più complicate... e, dulcis in fundo, nelle classiche pagine dei manuali Linux e POSIX non c'è ancora traccia delle nuove funzioni. Son problemi, no?

Ok, per oggi chiudo qui. Nel prossimo e ultimo episodio di Thread Runner vedremo un esempio pratico. Rimanete sintonizzati, eh!

Ciao e al prossimo post!

venerdì 22 novembre 2024

Thread Runner
come usare i thread in C - pt.1

Roy Batty: Io ne ho viste cose che voi umani non potreste immaginarvi. Navi da combattimento in fiamme al largo dei bastioni di Orione... e ho visto i raggi B balenare nel buio vicino alle porte di Tannhäuser. E tutti quei momenti andranno perduti nel tempo come lacrime nella pioggia. È tempo di morire.

(...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...)

I thread sono un po' come i replicanti del mitico capolavoro Blade Runner del Maestro Ridley Scott: sono copie dei loro creatori, sembrano tutti uguali ma poi divergono e hanno una vita propria... e poi terminano, come tristemente descritto nel mitico ed emozionante monologo del bravissimo Rutger Hauer. E quindi oggi parleremo di thread, che è un argomento che ho già più volte affrontato, e in varie sfumature: i problemi con la sleep(3) in Sleep? No, grazie!, il controllo in Totò, Peppino e il Watchdog, il mal uso in Thread Cancel? No, grazie!, l'uso inflazionato in L'invasione dei Multithread, le alternative in Processi o Thread? e poi in molti altri articoli in cui li ho usati negli esempi senza citarli direttamente. Ma ho sempre affrontato l'argomento come se fosse qualcosa di consolidato, qualcosa su cui era inutile entrare in dettagli elementari. Beh, è giunto il momento di di fare un bell'articolo sulle basi, un articolo introduttivo su come usare i thread in C, un articolo che arriva (forse) un po' in ritardo, ma meglio tardi che mai!

...come lacrime nella pioggia...

In questo post (che è il primo di tre) vedremo un esempio semplice semplice di come usare i thread in C: ovviamente l'argomento è molto vasto e complicabile a piacere, ma il nostro esempio contiene già le basi per capire come funziona il tutto, ovvero: la creazione, la sincronizzazione e la terminazione dei thread. Ovviamente in questa prima parte cominceremo usando la versione base (quasi) universale, ovvero useremo i POSIX Threads. E ora bando alle ciance, vai col codice!

#define _GNU_SOURCE // per usare strerror_r(3) GNU-specific version
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdbool.h>
#include <pthread.h>

#define NUMTHREADS 2 // numero di thread da trattare, in questo caso 2

// struttura per i dati condivisi dai thread
typedef struct {
pthread_mutex_t mutex; // mutex di sincronizzazione comune ai thread
bool stop; // flag per stop thread
unsigned long cnt; // counter condiviso dai thread
} Thdata;

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

// funzione main()
int main(int argc, char* argv[])
{
char errmsg_buf[256]; // buffer per strerror_r(3)

// init dei dati condivisi dai thread
Thdata thdata;
thdata.stop = false;
thdata.cnt = 0;

// init del mutex di Thdata
int error;
if ((error = pthread_mutex_init(&thdata.mutex, NULL)) != 0) {
// errore!
printf("%s: non posso creare il mutex (%s)\n",
argv[0], strerror_r(error, errmsg_buf, sizeof(errmsg_buf)));
return 1;
}

// loop di avvio dei thread
pthread_t tid[2];
for (int i = 0; i < NUMTHREADS; i++) {
// creo un thread
if ((error = pthread_create(&tid[i], NULL, &threadFunc, (void *)&thdata)) != 0) {
// errore!
printf("%s: non posso creare il thread %d (%s)\n",
argv[0], i, strerror_r(error, errmsg_buf, sizeof(errmsg_buf)));
return 1;
}
}

// dopo 10 secondi fermo tutti i thread
sleep(10);
thdata.stop = true;

// loop di join dei thread
for (int i = 0; i < NUMTHREADS; i++)
if ((error = pthread_join(tid[i], NULL)) != 0) {
// errore!
printf("%s: non posso attendere il thread %d (%s)\n",
argv[0], i, strerror_r(error, errmsg_buf, sizeof(errmsg_buf)));
return 1;
}

// cancello il mutex di sincronizzazione
if ((error = pthread_mutex_destroy(&thdata.mutex)) != 0) {
// errore!
printf("%s: non posso cancellare il mutex (%s)\n",
argv[0], strerror_r(error, errmsg_buf, sizeof(errmsg_buf)));
return 1;
}

// esco
printf("%s: thread terminati: thdata.cnt=%lu\n", argv[0], thdata.cnt);
return 0;
}

// threadFunc() - funzione per i thread
void *threadFunc(void *arg)
{
// ottengo i dati del thread con un cast (Thdata *) di (void *) arg
Thdata *thdata = (Thdata *)arg;

// loop del thread
printf("thread %ld partito\n", pthread_self());
unsigned long loc_cnt = 0;
for (;;) {
// lock del mutex
pthread_mutex_lock(&thdata->mutex);

// incremento il counter locale e quello condiviso
loc_cnt++;
thdata->cnt++;

// unlock del mutex
pthread_mutex_unlock(&thdata->mutex);

// test dello stop flag
if (thdata->stop) {
// il thread esce
printf("thread %ld terminato dal main: loc_cnt=%lu\n", pthread_self(), loc_cnt);
pthread_exit(NULL);
}

// sleep del thread (uso usleep solo per comodità invece della nanosleep(2))
usleep(1000);
}

// il thread esce per altro motivo diverso dallo stop flag
printf("thread %ld terminato localmente: loc_cnt=%lu\n", pthread_self(), loc_cnt);
pthread_exit(NULL);
}

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. 

Supponendo che già sappiate cosa sono e a cosa servono i thread (se no leggetevi prima qualche guida introduttiva, in rete ce ne sono di ottime) il flusso del codice è evidente: si inizializzano i dati da passare ai thread, compreso il mutex di sincronizzazione che deve essere creato con pthread_mutex_init(3); dopodiché con pthread_create(3) si creano/avviano i due thread che useremo, usando un loop (ma si potevano anche creare ripetendo due volte i passi, ovviamente). A questo punto si blocca il main() con una sleep(3) di 10 secondi e alla scadenza si attiva il flag di stop dei thread e ci si mette in attesa (usando pthread_join(3)) della terminazione dei thread; quando sono terminati entrambi il main() distrugge il mutex (con pthread_mutex_destroy(3)) ed esce.

Come si nota pthread_create(3) ha quattro parametri, che sono (nell'ordine): un pointer a un thread descriptor che identifica univocamente il thread creato, un pointer a un contenitore di attributi del thread da creare, un function pointer alla funzione che esegue il thread e, infine, un pointer all'unico argomento che si può passare alla funzione suddetta. In particolare, nel nostro esempio (semplice semplice), ho usato gli attributi di default (usando NULL per il secondo parametro), e ho creato (con typedef) un nuovo tipo ad-hoc per passare più parametri alla funzione che esegue il thread, sfruttando il fatto che l'argomento di default è un void* che si può facilmente trasformare (con una operazione di cast) in qualsiasi tipo complesso (nel nostro caso nel nuovo tipo Thdata).

In questo esempio i due thread creati eseguono la stessa funzione, che ho chiamato (con molta originalità, ah aha ah)  threadFunc() (ma avrebbero anche potuto eseguire due funzioni completamente differenti: in questo caso, ovviamente, avrei dovuto scrivere una threadFunc1() e una threadFunc2()). Il flusso della funzione è molto semplice: prima esegue un cast sull'argomento arg per poter usare i dati del tipo Thdata, poi entra in un classico thread-loop infinito con uscita controllata dallo stato dello stop flag inserito nella struttura Thdata. Notare che il thread-loop usa una sleep di 1 ms: provate a dimenticarvi di mettere la sleep in un thread-loop veramente infinito e vedrete i salti di gioia che farà la CPU del vostro PC!

Notare anche che ho usato, per comodità, usleeep(3) ma, come già fatto notare in un altro articolo, bisognerebbe usare sempre nanosleep(2) (usleep(3), come dice il manuale, è deprecata! ). E, comunque, anche la sleep del thread andrebbe usata con parsimonia come ho già scritto in un altro articolo, ci sono maniere più efficienti per controllare un loop infinito...

Ma cosa esegue il thread-loop? In questo semplice esempio si limita a incrementare un contatore locale e il contatore condiviso inizializzato nel main(), e lo fa in maniera sincronizzata usando pthread_mutex_lock(3) e pthread_mutex_unlock(3) sul mutex condiviso: questo serve per sincronizzare gli accessi ai dati condivisi, evitando che i due thread tentino di modificare contemporaneamente il contatore.

Compilando con GCC su macchina Linux (ovviamente) ed eseguendo, il risultato è:

aldo@Linux $ gcc -Wall threads.c -o threads -pthread
aldo@Linux $ ./threads
thread 137560485529280 partito
thread 137560475043520 partito
thread 137560475043520 terminato dal main: loc_cnt=8671
thread 137560485529280 terminato dal main: loc_cnt=8670
./threads: thread terminati: thdata.cnt=17341

Notare il numerone corrispondente al thread_id: stiamo stampando il risultato di pthread_self(3) che è di tipo pthread_t, ossia un tipo opaco (in questo caso anche machine-dependent): su Linux è, normalmente, un long unsigned int, quindi il risultato è corretto usando una printf(3) con format=%lu, ma in altri casi bisognerebbe trattarlo differentemente; comunque per questo semplice esempio va bene così, tanto l'ho stampato solo a titolo informativo.

E i risultati? Sono Ok: il valore dei due contatori è, esattamente quello sperato: ogni thread ha incrementato quando era il suo turno e i due valori locali sono (praticamente) identici e anche il contatore condiviso corrisponde alla somma dei due... perfetto! Thread sincronizzati alla grande!

E per oggi può bastare, non voglio far addormentate nessuno... Nel prossimo post parleremo di una interfaccia alternativa ai POSIX Threads. E, come sempre, vi raccomando di non trattenere il respiro nell'attesa, può nuocere gravemente alla salute!

Ciao e al prossimo post!