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.
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!