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ì 14 settembre 2020

Sleep? No, grazie!
considerazioni sul perché non usare la sleep in C (e C++) - pt.1

soldato Hudson: Vengono fuori dalle pareti! Vengono fuori dalle fottute pareti!

Se ricordate, nell'articolo Sleeper - considerazioni su quale sleep usare in C avevo intrapreso la "missione" di demistificazione della sleep (e se non lo ricordate, la soluzione è semplice: andate a rileggervelo, male non vi farà). In quell'articolo avevo isolato le tre classiche domande che si fanno sull'uso delle funzioni di sleep: quale usare, come usare, quando usare.

E, per cominciare avevo cercato di rispondere alla prima, la più semplice. Avevo, poi, promesso di scrivere un articolo sulle altre due domande, che avrebbe dovuto intitolarsi, quindi, "considerazioni su come e quando usare la sleep"... ma ora, scrivendolo, mi sono reso conto che è meglio esporre qualcosa di un po' più radicale, qualcosa tipo "considerazioni sul perché NON USARE la sleep", e questo perché, come vedremo, gli usi sono (anzi, dovrebbero essere) molto limitati, anche se nella pratica (ahimè) non lo sono: ebbene si, come diceva il soldato Hudson nel bellissimo film Aliens, gli alieni (e, nel nostro caso, le sleep) "escono dalle fottute pareti!". Si usano e abusano decisamente troppo, ma non preoccupatevi, una volta letto quanto segue tutto sarà (spero) più chiaro.

...le sleep vengono fuori dalle fottute pareti!...
Ho diviso l'articolo in due parti, e anche questa volta cominceremo con la parte più semplice, descrivendo i pochi casi in cui ha senso utilizzare le istruzioni di sleep. Ovviamente, da qui in avanti, parleremo solo di programmazione multithread, perché è qui che l'argomento si fa critico, mentre nel singlethread una sleep serve solo a introdurre dei ritardi, e non credo che si possa aggiungere molto altro.

Cominciamo. Il caso più classico è quello di una applicazione modulare in cui abbiamo un flusso main() che avvia dei thread ("i moduli") che "fanno cose" in loop infinito, ossia eseguono delle operazioni e, a fine ciclo, le rieseguono. Insomma, una roba tipo quella che vi avevo mostrato nell'articolo sul watchdog (di nuovo: ricordate?), che, tra l'altro, si basava proprio sull'idea di sorvegliare dei thread di questo tipo per segnalare eventuali interruzioni impreviste, starvation, ecc. E già che ci sono vi ripropongo una parte della descrizione che scrissi:

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.

Ecco, per oggi abbiamo finito, questo è l'unico caso in cui ha senso usare una istruzione di sleep...

E vabbè, dai, si può aggiungere ancora qualcosa. Cominciamo con un piccolo esempio che può essere usato come programma di test: è una versione semplificata di quello che avevo proposto per il watchdog, e ci permetterà di fare alcune (spero) interessanti considerazioni, anche per ampliare un po' il discorso su quale sleep usare. Vai col codice!

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdbool.h>
#include <pthread.h>

// creo un nuovo tipo per passare dei dati ai thread
typedef struct _tdata {
    int  index;     // thread index
    bool *stop;     // flag per stop thread
} tdata;

// prototipi locali
void *faiCose(void *arg);
void Sleep(unsigned int milliseconds);

// main() - funzione main
int main(int argc, char* argv[])
{
    // init thread
    pthread_t tid[2];
    tdata     data[2];
    bool      stop = false;
    for (int i = 0; i < 2; i++) {
        // set data del thread e crea il thread
        data[i].index = i;
        data[i].stop  = &stop;
        int error;
        if ((error = pthread_create(&tid[i], NULL, &faiCose, (void *)&data[i])) != 0)
            printf("%s: non posso creare il thread %d (%s)\n", argv[0], i, strerror(error));
    }

    // dopo 10 secondi fermo i thread
    time_t start_time = time(NULL);
    for (;;) {
        // test timeout
        if (time(NULL) - start_time >= 10) {
            stop = true;
            break;
        }

        Sleep(100);
    }

    // join threads
    pthread_join(tid[1], NULL);
    pthread_join(tid[0], NULL);

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

// faiCose() - thread routine
void *faiCose(void *arg)
{
    // ottengo i dati del thread con un cast (tdata *) di (void *) arg
    tdata *data = (tdata *)arg;

    // thread loop
    printf("thread %d partito\n", data->index);
    unsigned long i = 0;
    for (;;) {
        // incrementa counter
        i++;

        // test stop flag
        if (*data->stop) {
            // il thread esce
            printf("thread %d terminato dal main (i = %ld))\n", data->index, i);
            pthread_exit(NULL);
        }

        // thread sleep
        Sleep(1);
    }

    // il thread esce per altro motivo che lo stop flag
    printf("thread %d terminato localmente (i = %ld)\n", data->index, i);
    pthread_exit(NULL);
}

// Sleep() - una sleep in ms
void Sleep(unsigned int milliseconds)
{
    // testa il tempo di sleep per intercettare il valore 0
    if (milliseconds > 0) {
        // usa nanosleep() o usleep()
        #if (_POSIX_C_SOURCE >= 199309L)
        struct timespec ts;
        ts.tv_sec  = milliseconds / 1000;
        ts.tv_nsec = ( milliseconds % 1000) * 1000000;
        nanosleep(&ts, NULL);
        #else
        usleep(milliseconds * 1000);
        #endif
    }
    else {
        // usa sched_yield() come alternativa Linux alla Sleep(0) di Windows
        sched_yield();
    }
}

Allora, come avrete notato stiamo usando la famosa Sleep() multi-piattaforma che avevo proposto nell'articolo "Sleeper", e grazie a questo uso possiamo anche facilmente provare la famigerata Sleep(0) e l'effetto reale che si ottinene cambiando il valore del tempo passato come argomento: vediamo una semplice tabellina con i casi che ho analizzato su una macchina Linux con un i7 (4 core e 8 thread). Per i casi inferiori al millisecondo ho modificato il codice per usare la obsoleta (ma in questo caso comoda) usleep(), e ho registrato i valori (approssimati) di uso della CPU e del counter "i" che ci mostra quanti cicli ha effettuato ogni thread:

senza sleep         i = 5314563827  CPU = 100%
Sleep(0)            i = 18863299    CPU = 100% (esegue sched_yield())
Sleep(10)   t=10ms  i = 900         CPU = 1/2%
Sleep(1)    t=1ms   i = 9000        CPU = 1/2%
usleep(100) t=100us i = 62970       CPU = 1/2%
usleep(10)  t=10us  i = 145000      CPU = 1/2%
usleep(1)   t=1us   i = 170000      CPU = 1/2%
usleep(0)   t=0us   i = 170000      CPU = 1/2% (test con usleep(0))

Il primo caso ci mostra perchè qui conviene usare la sleep: i nostri due thread funzionano bene (i due contatori avanzano simultaneamente), e la attività è gestita dal thread-scheduler (facile in questo caso: un thread dell'applicazione su un thread della CPU), ma comunque, se non lavorano volontariamente in maniera cooperativa (o gentile, se preferite) e non rilasciano ogni tanto la CPU, finiscono col mangiarsela tutta (due degli otto CPU-thread, nel mio caso), e in un sistema multiprocess non è una buona cosa, no?.

Esaminiamo, ora, il secondo caso che ci dimostra che la famosa Sleep(0)/sched_yield() di cui abbiamo parlato nell'altro articolo non da proprio dei gran risultati: i thread lavorano meno (guardare il counter) ma la CPU va lo stesso al 100%.

Il terzo e il quarto caso ci mostrano che, usando valori dell'ordine di grandezza del time-slice del sistema, la CPU lavora poco e il comportamento è lineare (fino a 1 ms).

I casi successivi mantengono il rispetto della CPU ma il comportamento diventa irregolare (i counter non aumentano proporzionalmente alla riduzione della sleep). Conclusione: in questa tipo di architettura Software (che è un buon riferimento, essendo abbastanza comune) è conveniente usare dei tempi paragonabili al time-slice del sistema, ossia tra 1 e 10 ms.

L'ultimo caso l'ho aggiunto solo per curiosità, per confermare quanto detto nell'altro articolo: usleep(0) non è equivalente a Sleep(0) (...magari qualcuno non ci credeva...), e applica il tempo di sleep minimo possibile, che è di 1 us.

E con questo abbiamo finito veramente, perché la sleep serve veramente a poco: sincronizzare i thread di una applicazione mandandone a dormire qualcuno non è proprio la maniera di operare più ottimizzata. I thread dovrebbero lavorare/fermarsi/riattivarsi in base a eventi, mentre affidarsi a intervalli di tempo (spesso scelti in maniera arbitraria) non è proprio una ideona... specialmente considerando che, una sleep con un tempo x garantisce che il thread stia fermo come minimo per quel tempo x, ma non garantisce il tempo massimo, che potrebbe essere anche molto più grande, in base a quanto è affollata la coda dei runnable-thread in attesa di partire.

E, come dicono oltreoceano, quando trovate una applicazione multithread piena di sleep ("che escono dalle fottute pareti!") siamo in presenza di un caso di "poor design", che necessita assolutamente di essere migliorato. Ma tutto questo lo vedremo nella seconda parte dell'articolo, per oggi può bastare. Mettetevi in sleep che al momento opportuno vi risveglierò io...

Ciao, e al prossimo post!