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.

mercoledì 19 febbraio 2020

Totò, Peppino e il Watchdog
come scrivere un Watchdog in C, C++ e Go - pt.1

Peppino: Che.
Totò: Che! Scusate se sono poche.
Peppino: Che…
Totò: Che, scusate se sono poche, ma settecentomila lire, punto e virgola, noi, noi ci fanno specie che quest’anno, una parola, quest’anno c’è stato una grande moria delle vacche, come voi ben sapete! Punto! Due punti!! Ma si, fai vedere che abbondiamo. Abbondandis in abbondandum. Questa moneta servono, questa moneta servono, questa moneta servono che voi vi consolate. Scrivi presto!
Peppino: Con insalata.
Totò: Che voi vi consolate!
Peppino: Ah! Avevo capito con l’insalata.
L'argomento della mitica e famosissima lettera di Totò e Peppino era, per loro, molto importante, era una questione familiare di un certo rilievo. Ed anche noi, con questo articolo, tratteremo un argomento di un certo peso, anche se non familiare: sarà un argomento industriale. Ebbene si, oggi parleremo di un oggetto per molti misterioso, il Watchdog.
...ma si, fai vedere che abbondiamo. Watchdog in abbondandum...
Una applicazione industriale che si rispetti (specialmente se embedded) include sempre un Watchdog a più livelli. vediamoli:
  • primo livello: Watchdog Hardware. Questo non può mancare: in presenza di malfunzionamenti che provocano il blocco o lo stallo della applicazione si può forzare il reset e riavvio del sistema.
  • secondo livello: Watchdog Software dei processi. Si monitorizzano i vari processi che compongono l'applicazione (su Linux, ad esempio, si può fare con Monit) e si effettuano eventuali attività di recupero in caso di necessità (restart di quelli "morti", ad esempio).
  • terzo livello: Watchdog Software dei thread. Se l'applicazione da monitorare è multithread si controlla che tutti i thread funzionino correttamente, ossia si controlla che nessuno di essi rimanga bloccato in attesa di qualche evento che non arriva mai, oppure che nessun thread lavori a velocità molto più bassa di quella prevista e/o ammessa, ecc. e, in caso di problemi, si agisce opportunamente (come minimo alzando degli allarmi per segnalare la situazione).
Perché ho affermato che il Watchdog è un oggetto per molti misterioso? Perché, anche se quasi tutte le applicazioni industriali usano quello di primo livello, alcune omettono quello di secondo e moltissime (ahimè) non usano quello di terzo livello (e questo, secondo me, è molto grave). Ma niente paura! vedremo in questo post ben tre semplici implementazioni di un Watchdog di terzo livello: in C, C++ e Go, così dopo avrete solo l'imbarazzo della scelta su quale usare.

Come funziona un Watchdog di terzo livello? Il modus operandi è abbastanza semplice, e include il rispetto di poche direttive di base:
  • i thread da monitorare devono avere una struttura "classica", e cioè quella di una funzione "che fa cose" in loop infinito con un opportuno intervallo di sleep tra un ciclo e l'altro.
  • i thread da monitorare devono registrarsi al Watchdog prima di avviare il loop infinito.
  • nessuna delle cose che il thread fa nel loop deve essere bloccante: ad esempio se si legge da un socket questo deve essere stato aperto in modo nonblocking.
  • ad ogni giro del loop (appena prima della sleep) si deve aggiornare una variabile di monitoring che verrà letta dal Watchdog vero e proprio.
Viste le direttive di base si può già dedurre che cosa è il Watchdog: è una funzione che esegue un loop infinito in cui testa le variabili di monitoring dei thread che si sono registrati. Anche questo loop avrà una sleep che, al contrario di quella dei thread che, tipicamente, è piccola (dell'ordine dei millisecondi) sarà grande (dell'ordine dei secondi), perché normalmente è inutile sorvegliare i thread con frequenze altissime (ma ci sono, ovviamente, delle eccezioni). Se il Watchdog si accorge che un thread non risponde (ossia: non rinfresca la variabile di stato), prenderà gli opportuni provvedimenti che dipendono dalla natura dell'applicazione (alzare allarmi, effettuare una procedura di recovery, ecc.).

Scrivere il codice di un Watchdog è relativamente semplice ma, ovviamente, si può complicare a piacere. La versione che vi proporrò, ad esempio, controlla tutti i thread con una cadenza fissa, impostata da un valore in secondi che si passa al Watchdog. Quindi un primo livello di complicazione potrebbe essere quello di avere intervalli di sorveglianza indipendenti e impostati dal thread nella fase di registrazione: in questa maniera un unico Watchdog potrebbe sorvegliare velocemente alcuni thread e lentamente altri. E così via, si possono aggiungere e/o perfezionare prestazioni, ma il modello base che vedremo è, sicuramente, una buona base di lavoro per usi reali.

Cominceremo con la versione C (qualcuno lo dubitava?) e, prima di mostrare il Watchdog, vedremo un esempio d'uso con test incorporato: ho scritto un main() che avvia due thread passandogli un pointer al Watchdog. I thread non fanno nulla ma, ogni tanto, smettono di dare segni di vita, permettendoci di osservare realmente cosa fa il nostro Watchdog in questi casi. Vai col codice!
#include "watchdog.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>

// prototipi locali
void* myThreadA(void *arg);
void* myThreadB(void *arg);

// funzione main()
int main(int argc, char* argv[])
{
    int error;

    // init del watchdog
    Watchdog watchdog;
    if (setWatchdog(&watchdog) != 0) {
        printf("%s: non posso usare il watchdog\n", argv[0]);
        return EXIT_FAILURE;
    }

    // avvio thread A
    pthread_t tid_A;
    if ((error = pthread_create(&tid_A, NULL, &myThreadA, (void *)&watchdog)) != 0) {
        printf("%s: non posso creare il thread A (%s)\n", argv[0], strerror(error));
        return EXIT_FAILURE;
    }

    // avvio thread B
    pthread_t tid_B;
    if ((error = pthread_create(&tid_B, NULL, &myThreadB, (void *)&watchdog)) != 0) {
        printf("%s: non posso creare il thread A (%s)\n", argv[0], strerror(error));
        return EXIT_FAILURE;
    }

    // avvio check watchdog (contiene un loop infinito)
    chkWatchdog(&watchdog, 1);  // sleep interna di 1 sec

    // attesa terminazione thread A
    if ((error = pthread_join(tid_A, NULL)) != 0) {
        printf("%s: non posso unire il thread A (%s)\n", argv[0], strerror(error));
        return EXIT_FAILURE;
    }

    // attesa terminazione thread B
    if ((error = pthread_join(tid_B, NULL)) != 0) {
        printf("%s: non posso unire il thread B (%s)\n", argv[0], strerror(error));
        return EXIT_FAILURE;
    }

    // esce con Ok
    printf("%s: thread terminati\n", argv[0]);
    return EXIT_SUCCESS;
}

// thread routine A
void* myThreadA(void *arg)
{
    // ottengo i dati del thread con un cast (tdata*) di (void*) arg
    Watchdog *watchdog = (Watchdog *)arg;

    // aggiunge un watch per questo thread
    int watch_id;
    if ((watch_id = addWatch(watchdog, "myThreadA")) < 0) {
        // errore: non posso usare il watch
        printf("%s: non posso usare il watch: fermo il thread myThreadA", __func__);
        return NULL;
    }

    // loop del thread
    printf("thread A partito\n");
    int i = 0;
    for (;;) {
        // il thread fa cose...

        // ...

        // TEST: ogni 5 secondi simulo un blocco del thread
        if (i++ == 500) {
            printf("thread A: sleep di 5 sec\n");
            i = 0;
            sleep(5);
        }

        // rinfresco il watch del thread
        setWatch(watchdog, watch_id);

        // sleep del thread (10 ms)
        usleep(10000);
    }

    // il thread esce
    printf("thread A finito\n");
    return NULL;
}

// thread routine B
void* myThreadB(void *arg)
{
    // ottengo i dati del thread con un cast (tdata*) di (void*) arg
    Watchdog *watchdog = (Watchdog *)arg;

    // aggiunge un watch per questo thread
    int watch_id;
    if ((watch_id = addWatch(watchdog, "myThreadB")) < 0) {
        // errore: non posso usare il watch
        printf("%s: non posso usare il watch: fermo il thread myThreadA", __func__);
        return NULL;
    }

    // loop del thread
    printf("thread B partito\n");
    int i = 0;
    for (;;) {
        // il thread fa cose...

        // ...

        // TEST: ogni 15 secondi simulo un blocco del thread
        if (i++ == 1500) {
            printf("thread B: sleep di 5 sec\n");
            i = 0;
            sleep(5);
        }

        // rinfresco il watch del thread
        setWatch(watchdog, watch_id);

        // sleep del thread (10 ms)
        usleep(10000);
    }

    // il thread esce
    printf("thread B finito\n");
    return NULL;
}
È abbastanza semplice, no? Grazie ai commenti credo che sia sufficientemente auto-esplicativo, e non credo che ci sia molto da aggiungere: il main() inizializza il Watchdog, avvia i due thread e avvia la funzione chkWatchdog() che è il cuore del nostro sistema. I due thread si registrano ed entrano in un loop infinito che non fa nulla, a parte (come detto sopra) simulare un blocco ogni tanto per testare il corretto funzionamento del Watchdog.
E adesso vediamo l'header, watchdog.h:
#ifndef WATCHDOG_H
#define WATCHDOG_H

#include <pthread.h>
#include <stdbool.h>

#define MAX_WATCH   32  // numero massimo di watch in uso

// typedef del tipo Watch
typedef struct {
    int  id;            // identificatore del watch (numero)
    char name[16];      // identificatore del watch (stringa)
    bool active;        // flag di attività (true=attivo)
} Watch;

// typedef del tipo Watchdog
typedef struct {
    Watch           *watch_list[MAX_WATCH]; // lista di watch
    pthread_mutex_t watch_mutex;            // mutex per operazioni add/set/check
} Watchdog;

// prototipi globali
int  setWatchdog(Watchdog *watchdog);
void delWatchdog(Watchdog *watchdog);
void chkWatchdog(Watchdog *watchdog, unsigned int wait_sec);
int  addWatch(Watchdog *watchdog, char *name);
void delWatch(Watchdog *watchdog, int id);
void setWatch(Watchdog *watchdog, int id);

#endif /* WATCHDOG_H */
Come si nota ho definito due tipi, uno che descrive un punto di sorveglianza elementare (il tipo Watch) e uno che descrive il Watchdog vero e proprio (il tipo Watchdog) che è, alla fin fine, solo una lista di watch protetta da un mutex (ebbene si, stiamo parlando di multithreading, quindi un mutex ci voleva proprio).

Ed ora siamo, finalmente, pronti ad esaminare il codice contenuto in watchdog.c:
#include "watchdog.h"
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

// setWatchdog - set iniziale del Watchdog
int setWatchdog(
    Watchdog *watchdog)     // watchdog pointer
{
    // init mutex
    int error;
    if ((error = pthread_mutex_init(&watchdog->watch_mutex, NULL)) != 0) {
        // errore fatale: fermo la inizializzazione
        printf("%s: non posso creare il mutex (%s)\n", __func__,  strerror(error));
        return -1;
    }

    // reset pointers watchdog
    for (int i = 0; i < MAX_WATCH; i++) {
        // set pointer to NULL
        watchdog->watch_list[i] = NULL;
    }

    // set watchdog Ok
    return 0;
}

// delWatchdog - elimina tutti i watch
void delWatchdog(
    Watchdog *watchdog)     // watchdog pointer
{
    // rilascia le risorse allocate
    for (int i = 0; i < MAX_WATCH; i++) {
        // check se il watch è disponibile
        if (watchdog->watch_list[i] != NULL) {
            // cancella un watch
            delWatch(watchdog, i);
        }
    }
}

// chkWatchdog - check di tutti i watch nella lista watch
void chkWatchdog(
    Watchdog     *watchdog, // watchdog pointer
    unsigned int wait_sec)  // sleep del loop interno in secondi
{
    // loop infinito di check watch
    for (;;) {
        // lock della funzione per uso thread-safe
        pthread_mutex_lock(&watchdog->watch_mutex);

        // check di tutti i watch nella lista watch
        for (int i = 0; i < MAX_WATCH; i++) {
            // check solo dei watch in uso
            if (watchdog->watch_list[i] != NULL) {
                // check del watch
                if (watchdog->watch_list[i]->active) {
                    // watch attivo: reset flag active
                    watchdog->watch_list[i]->active = false;
                }
                else {
                    // watch inattivo: mostro l'errore
                    printf("%s: watch %d: %s thread inattivo\n",
                           __func__, watchdog->watch_list[i]->id, watchdog->watch_list[i]->name);
                }
            }
        }

        // unlock della funzione
        pthread_mutex_unlock(&watchdog->watch_mutex);

        // sleep del loop
        sleep(wait_sec);
    }
}

// addWatch - aggiunge un watch nella watch list
int addWatch(
    Watchdog *watchdog,     // watchdog pointer
    char     *name)         // watch name
{
    // lock della funzione per uso thread-safe
    pthread_mutex_lock(&watchdog->watch_mutex);

    // loop sulla watch list per trovare il primo watch disponibile
    for (int i = 0; i < MAX_WATCH; i++) {
        // check se il watch è disponibile
        if (watchdog->watch_list[i] == NULL) {
            // aggiunge un watch in watch list
            watchdog->watch_list[i] = malloc(sizeof(Watch));

            // set valori
            watchdog->watch_list[i]->id = i;
            snprintf(watchdog->watch_list[i]->name,
                     sizeof(watchdog->watch_list[i]->name), "%s", name);
            watchdog->watch_list[i]->active = false;
            printf("%s: watch aggiunto: id=%d name=%s\n", __func__, i, name);

            // unlock della funzione
            pthread_mutex_unlock(&watchdog->watch_mutex);

            // return id
            return i;
        }
    }

    // watch disponibili finiti: mostro errore
    printf("%s: non ci sono più watch disponibili\n", __func__);

    // unlock della funzione
    pthread_mutex_unlock(&watchdog->watch_mutex);

    // return errore
    return -1;
}

// delWatch - cancella un watch nella watch list
void delWatch(
    Watchdog *watchdog,     // watchdog pointer
    int      id)            // watch id
{
    // lock della funzione per uso thread-safe
    pthread_mutex_lock(&watchdog->watch_mutex);

    // cancella un watch
    printf("%s: cancella un watch: id=%d name=%s\n",
           __func__, watchdog->watch_list[id]->id, watchdog->watch_list[id]->name);
    free(watchdog->watch_list[id]);
    watchdog->watch_list[id] = NULL;

    // unlock della funzione
    pthread_mutex_unlock(&watchdog->watch_mutex);
}

// setWatch - set di un watch
void setWatch(
    Watchdog *watchdog,     // watchdog pointer
    int      id)            // watch id
{
    // lock della funzione per uso thread-safe
    pthread_mutex_lock(&watchdog->watch_mutex);

    // set a true del flag active
    if (watchdog->watch_list[id] != NULL)
        watchdog->watch_list[id]->active = true;

    // unlock della funzione
    pthread_mutex_unlock(&watchdog->watch_mutex);
}
Anche questo è molto commentato ed è auto-esplicativo. Si può solo aggiungere che la variabile di monitoring è una booleana che il thread associato mette a true (dicendo "sono vivo"): la funzione chkWatchdog() la legge e la rimette a false (dicendo "al prossimo giro devi dimostrami che sei vivo"). Tutte le operazioni che coinvolgono la variabile di stato sono protette dal mutex (ovviamente) e così anche quelle di creazione/rimozione dei watch. Nel caso che un thread risulti inattivo dopo l'intervallo di check, il problema viene segnalato (solo con una printf() in questo semplice esempio).
Che ne dite? direi che su questa base si può costruire un vero Watchdog "industriale", ad esempio abbinandolo a un gestore di allarmi (segnatevelo: in futuro parleremo anche di questo). E credo che per oggi può bastare: nella seconda parte esamineremo le altre versioni che ho scritto (in C++ e Go), e vi anticipo che, difficilmente, riuscirò a evitare qualche piccola polemica parlando della versione C++ (ma non si sa mai, dipenderà dall'umore del momento...).

Ciao, e al prossimo post!

venerdì 24 gennaio 2020

The Queue
come scrivere una thread-safe queue in C, C++ e Go

Earl: Sto portando noci pecan a mia nipote. Fa la peggior torta di noci del mondo! mi dispiace per il marito ma mi dispiace anche per le noci!
Il gioco di parole del titolo di questo post richiama, evidentemente, il gran film The Mule dell'altrettanto grande Clint Eastwood. Lui nel film interpreta un corriere un po' particolare (un "Drug Mule"), un corriere che deve rispettare norme di sicurezza di trasporto molto strette... Ebbene, anche noi oggi parleremo di trasporto sicuro, trasporteremo dati (roba più tranquilla di quella che trasportava Clint) usando code sicure (thread-safe queues per gli amici).
..la sicurezza prima di tutto...
Le code sicure sono un argomento un po' convenzionale, quindi come facciamo a renderlo un po' più frizzante? Semplicissimo: facendo un comparazione di stili e risultati! Quindi presenterò una classica thread-safe queue scritta nel nostro amato C, poi la stessa coda scritta nel nostro (un po' meno amato) C++, e, dulcis in fundo, la stessa soluzione scritta in Go, che è un linguaggio che uso da un po' e che trovo fantastico. Quest'ultima comparazione (un po' OT, devo ammetterlo) l'ho inserita per motivi che spiegherò in fondo all'articolo.

Allora, ovviamente cominciamo con il C, con una implementazione di una coda decisamente classica a cui ho aggiunto un mutex per renderla sicura: vai col codice!
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <pthread.h>

// struct node - struttura di un nodo della coda thread-safe
typedef struct node {
    int value;
    struct node *next;
} node;

// struct Queue_r - struttura della coda thread-safe (usa una linked list)
typedef struct {
    node *front;
    node *rear;
    pthread_mutex_t mutex;
} Queue_r;

// qcreate() - crea una coda vuota
Queue_r* qcreate()
{
    // crea la coda
    Queue_r *queue = malloc(sizeof(Queue_r));

    // inizializza la coda
    queue->front = NULL;
    queue->rear  = NULL;
    pthread_mutex_init(&queue->mutex, NULL);
    return queue;
}

// enqueue() - aggiunge un elemento alla coda
void enqueue(Queue_r* queue, int value)
{
    // crea un nuovo nodo
    node *temp = malloc(sizeof(struct node));
    temp->value = value;
    temp->next  = NULL;

    // blocco l'accesso
    pthread_mutex_lock(&queue->mutex);

    // test se la coda è vuota
    if (queue->front == NULL) {
        // con la coda vuota front e rear coincidono
        queue->front = temp;
        queue->rear  = temp;
    }
    else {
        // aggiungo un elemento
        node *old_rear = queue->rear;
        old_rear->next = temp;
        queue->rear    = temp;
    }

    // sblocco l'accesso ed esco
    pthread_mutex_unlock(&queue->mutex);
}

// dequeue() - toglie un elemento dalla coda
bool dequeue(Queue_r* queue, int *value)
{
    // blocco l'accesso
    pthread_mutex_lock(&queue->mutex);

    // test se la coda è vuota
    node *front = queue->front;
    if (front == NULL) {
        // sblocco l'accesso ed esco
        pthread_mutex_unlock(&queue->mutex);
        return false;
    }

    // leggo il valore ed elimino l'elemento dalla coda
    *value = front->value;
    queue->front = front->next;
    free(front);

    // sblocco l'accesso ed esco
    pthread_mutex_unlock(&queue->mutex);
    return true;
}

// main() - funzione main
int main()
{
    // creo la coda
    Queue_r *my_queue = qcreate();

    // aggiungo un po' di elementi
    enqueue(my_queue, 10);
    enqueue(my_queue, 20);
    enqueue(my_queue, 30);
    enqueue(my_queue, 40);

    // mostro i risultati
    printf("la coda my_queue contiene: ");
    int qval;
    while (dequeue(my_queue, &qval))
        printf("%d ", qval);

    printf("\n");

    return 0;
}
Come avrete notato è un esempio veramente semplice di coda linked list, un oggetto di comprovata funzionalità e efficienza, un vero classico del linguaggio C. Evidentemente il codice presentato è un po' semplificato, ma neanche tanto: per usarlo in produzione basta aggiungere un po' di test di controllo (sull'esito delle malloc() e sulla creazione del mutex) con il relativo trattamento degli errori, e poi bisognerebbe scrivere una funzione di cancellazione per liberare tutte le risorse. Poca roba in più, insomma.

Volendo proprio sofisticare il disegno si potrebbe aggiungere una condition variable per gestire in modo più efficiente l'uso in multithreading, ma già così com'è si può usare egregiamente. Il main() è semplicissimo e serve solo a mostrare che le operazioni di queue/dequeue vengono realizzate correttamente.

E passiamo alla versione C++: se l'avessi scritta a C-style sarebbe stata praticamente identica alla versione C (con classi invece di strutture e metodi invece di funzioni), quindi l'operazione sarebbe stata fine a se stessa. Ma perché non si dica che sono prevenuto verso il C++ (ma si, un po' lo sono), ho deciso di usare la STL, e quindi ho scelto il container più simile all'oggetto di questo articolo, il container std::queue, e ho cercato di renderlo sicuro. Vai col codice!
#include <queue>
#include <mutex>
#include <iostream>
using namespace std;

// classe Queue_r- una classe queue thread-safe (usa std::queue)
class Queue_r {
public:
    // metodi per aggiungere/togliere elementi dalla coda
    void enqueue(int value);
    bool dequeue(int& value);

private:
    // oggetto std::queue interno
    queue<int> queue_r;

    // mutex per controllo accessi
    mutex q_mutex;
};

// enqueue() - aggiunge un elemento alla coda
void Queue_r::enqueue(int value)
{
    // blocco l'accesso
    lock_guard<mutex> lock(q_mutex);

    // aggiungo un elemento
    queue_r.push(value);
}

// dequeue() - toglie un elemento dalla coda
bool Queue_r::dequeue(int& value)
{
    // blocco l'accesso
    lock_guard<mutex> lock(q_mutex);

    // test se la coda è vuota
    if (queue_r.empty()) {
        // esco
        return false;
    }

    // assegno il valore e tolgo un elemento
    value = queue_r.front();
    queue_r.pop();

    return true;
}

// main() - funzione main
int main()
{
    // creo la coda
    Queue_r my_queue;

    // aggiungo un po' di elementi
    my_queue.enqueue(10);
    my_queue.enqueue(20);
    my_queue.enqueue(30);
    my_queue.enqueue(40);

    // mostro i risultati
    cout << "la coda my_queue contiene: ";
    int value;
    while (my_queue.dequeue(value))
        std::cout << value << ' ';

    std::cout << '\n';

    return 0;
}
Codice classico anche in questo caso, e decisamente più compatto della versione C, ma c'era da aspettarselo, visto che molto codice è nascosto dentro std::queue. Notare che ho usato C++11, quindi ho potuto usufruire della nuova interfaccia RAII per usare i mutex, e cioè std::lock_guard (e sul fatto che considero brillante la gestione dei thread del C++11 ne avevo già parlato qui... visto che imparzialità?). Anche in questo caso il codice presentato è quasi definitivo, manca qualche controllo e una (eventuale) condition variable. Il main() di questa versione è praticamente identico a quello della versione C, e quindi anche il risultato dell'esecuzione viene presentato in modo identico.

E i risultati? Ho scritto un piccolo benchmark, e ho testato i risultati facendo un numero esagerato di operazioni di enqueue/dequeue (100000000 di operazioni!), e i risultati sono interessanti, eccoli:
TEST con coda di dimensione 100000000
C queue - TEST con compilazione gcc senza ottimizzazioni
Test: Tempo trascorso: 7.671992 secondi
C++ queue - TEST con compilazione g++ senza ottimizzazioni
Test: Tempo trascorso: 12.378136 secondi
C queue - TEST con compilazione gcc con ottimizzazione -O2
Test: Tempo trascorso: 6.349089 secondi
C++ queue - TEST con compilazione g++ con ottimizzazione -O2
Test: Tempo trascorso: 1.405633 secondi
Come si nota la versione C è, di base, più veloce di quella C++, ma con le ottimizzazioni il risultato si ribalta. Quindi pare che std::queue sia un ottimo container, di uso molto raccomandabile (specialmente se si possono abilitare le ottimizzazioni). Si può concludere che in C si deve, per forza, seguire una via come quella indicata, mentre in C++ si può optare per lavorare con l'aiuto della STL o seguire un approccio C-style (scelta filosofica).

E ora, visto che quando il gioco si fa duro i duri cominciano a giocare, veniamo alla parte più interessante: ho scritto una versione Go della thread-safe queue, per verificare compattezza di codice e risultati. Come mi aspettavo il codice Go risultante è veramente tirato all'osso, perché Go è un vero linguaggio di alto livello (nella concezione moderna della definizione, in quella antica anche C e C++ lo erano). Vediamo il codice!
package main

import "fmt"

// enqueue() - aggiunge un elemento alla coda
func enqueue(queue chan int, value int) {
    // uso select per non rendere bloccanti le operazioni
    select {
    case queue <- value:

    default:
    }
}

// dequeue() - toglie un elemento dalla coda
func dequeue(queue chan int, value* int) bool {
    // uso select per non rendere bloccanti le operazioni
    select {
    case *value = <- queue:
        return true

    default:
        return false
    }
}

// main() - funzione main
func main() {
    // creo la coda
    my_queue := make(chan int, 100000000)

    // aggiungo un po' di elementi
    enqueue(my_queue, 10)
    enqueue(my_queue, 20)
    enqueue(my_queue, 30)
    enqueue(my_queue, 40)

    // mostro i risultati
    fmt.Printf("la coda my_queue contiene: ")
    qval := 0
    for dequeue(my_queue, &qval) {
        fmt.Printf("%d ", qval);
    }

    fmt.Printf("\n")
}
Visto che il Go appartiene alla grande famiglia del C, il codice (almeno in questo semplice esempio) non dovrebbe risultare molto ostico anche per i lettori che non conoscono il Go. Tanto per cominciare il main() è praticamente identico a quello delle versioni C e C++, mentre le funzioni di enqueue e dequeue sono... sono fantastiche, visto che sono praticamente vuote! Infatti il concetto di queue thread-safe è praticamente già implicito nel linguaggio, attraverso i canali chan, che sono (anche) uno strumento di gestione della concorrenza, quindi sono thread-safe per definizione.

Ovviamente le code thread-safe si possono realizzare in Go anche in altre maniere, magari più "classiche" (usando liste e mutex, per esempio, e probabilmente è la soluzione migliore come prestazioni), ma la implementazione che ho proposto è così immediata che non si può evitare di prenderla in considerazione.

L'unico limite è che questo tipo di coda ha una dimensione finita, infatti nel main() viene inizializzata a 100000000 di elementi (ho esagerato, ma solo per dimostrare che la dimensione è "finita" ma può essere quasi "infinita" senza problemi). In realtà una coda thread-safe di solito si usa come mezzo di comunicazione tra thread, usando la logica del produttore/consumatore, quindi la dimensione conta fino a un certo punto: si suppone che se la coda ha una dimensione ragionevole non dovrebbe riempirsi mai, e se si riempie vuol dire che i consumatori si sono fermati e/o che i produttori producono troppo, quindi il programma in esecuzione ha ben altri problemi che la dimensione della coda...

A questo punto il dubbio è: Go è un vero linguaggio di alto livello e ha un suo runtime che lo isola dal sistema operativo, quindi le prestazioni saranno penalizzate, no? Guardate e stupite:
TEST con coda di dimensione 100000000
Go queue - TEST con compilazione Go di default
Test: Tempo trascorso: 8.772547848 secondi
è perfino più veloce della versione C++ non ottimizzata! Comunque, se può interessare, ho visto e realizzato vari benchmark e posso confermare la conoscenza comune: Go è un linguaggio veloce. È sicuramente più lento di C, C++ e Rust (che è velocissimo), ma si difende bene, quindi ha un range di utilizzazione veramente notevole. Usatelo, non ve ne pentirete.

E ora, per finire, qualche considerazione filosofica (e se qualcuno odia le mie disquisizioni filosofiche può anche saltare la lettura, me ne farò una ragione). Comincerò con una nota un po' OT: il C ha vinto (e non è la prima volta) il titolo di "Programming Language of the Year" di TIOBE per il 2019, un risultato di un certo prestigio. Considerando che è un linguaggio che ha una certa età (é del 1972) è la dimostrazione che la qualità paga, anche a lungo termine. Tra l'altro è il primo o secondo linguaggio più usato degli ultimi 20 anni (C e Java si alternano ogni anno al primo posto). Il fatto è che il C è un linguaggio insostituibile in alcuni tipi di applicazioni: Sistemi Operativi (chiedere un parere a Linus...), Software di Sistema, Firmware di basso livello (senza OS o con OS minimale, tipo FreeRTOS). E il C++? Beh, se si limitasse al suo ruolo nativo, quello di C a oggetti, sarebbe (ed è) una buonissima alternativa al C, almeno quando si vuole scrivere in OOP (anche se, secondo il grande Alan Kay, non è un vero linguaggio OOP). Il problema è che vuol darsi le arie da "linguaggio ad alto livello"... e poi usi il Go e scopri che cosa è un vero linguaggio ad alto livello. Ma niente paura, nonostante gli interventi dei Comitati ISO, il core del C++ rimane sempre lo stesso (inclusa la quasi-compatibilità con il C), quindi si può ancora usare solo per quello che è: un buonissimo C a oggetti.

E visto che con queste ultime note mi sono guadagnato le critiche di un notevole gruppo di colleghi, per oggi mi posso considerare soddisfatto...

Ciao, e al prossimo post!