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.

domenica 20 dicembre 2020

Processi o Thread?
considerazioni sulla scelta tra Processi e Thread - pt.2

Claudia Wilson Gator: Adesso che ci siamo incontrati hai nulla in contrario se non ci vediamo più?

Nel capolavoro Magnolia c’è una scena in cui Claudia (una bravissima Melora Walters) dice a Jim (un altrettanto bravo John C.Reilly) la frase qui sopra. Ecco, la reazione di Claudia è la stessa che hanno molti programmatori dopo i primi esperimenti col multithreading, quando si trovano ad affrontare per la prima volta cosucce come race-condition o starvation. In quel caso la tentazione, forte, è di non usare più i threadcome se usare i processi fosse una passeggiata! Si, forse i processi sono un po’ più facili da controllare e sincronizzare ma non bisogna sceglierli solo per questo motivo. Bisogna, invece, fare sempre una unica considerazione: nel mio progetto cosa è (tecnicamente) meglio usare? E una volta trovata la risposta (magari aiutandosi con lo specchietto dello scorso articolo) agire di conseguenza, senza mai spaventarsi e/o preoccuparsi: risolvere problemi è il nostro lavoro (ebbene si: è un lavoro da masochisti).

...ti avevo detto di usare i processi, ma tu sei un testone...
Allora: dove eravamo rimasti? Si, ora ricordo, si parlava di processi e thread, e l’articolo (che sicuramente conoscete a memoria) si era concluso mostrando un semplicissimo programma multithread ed una promessa, questa:

…è un esempio veramente semplice che mi permetterà di mostrarvi, nella seconda parte dell’articolo, una applicazione multiprocess che fa esattamente la stessa cosa, oltretutto con un codice veramente molto simile e con risultati di esecuzione abbastanza interessanti…

Bene, le promesse bisogna mantenerle, quindi è il turno della applicazione-replica in multiprocess. Vediamo come è strutturata:

  1. Un programma padre che crea i due processi figli (lo chiameremo, ad esempio, processes.c). Equivale al main() del programma multithread dello scorso articolo.
  2. Un programma figlio che verrà lanciato due volte dal programma padre (lo chiameremo, ad esempio, process.c). Equivale al threadfunc() del programma multithread dello scorso articolo.
  3. Una mini-libreria di comunicazione IPC che usa un memory-mapped file (la chiameremo, ad esempio, mmap.c). Contiene la struttura dati da condividere, che è equivalente alla shdata del programma multithread dello scorso articolo.

Ed ora bando alle ciance, cominciamo con processes.c. Vai col codice!

// processes.c - main processo padre
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/wait.h>
#include "mmap.h"

// funzione main()
int main(int argc, char* argv[])
{
    // crea il memory mapped file
    const char *mmname = "mydata";
    shdata *data;
    if ((data = memMapOpen(mmname, true)) == NULL) {
        printf("sono il padre (%d): memMapOpen error\n", getpid());
        exit(EXIT_FAILURE);
    }

    // crea i processi figli
    pid_t pid1, pid2;
    (pid1 = fork()) && (pid2 = fork());

    // test pid processi
    if (pid1 == 0) {
        // sono il figlio 1
        printf("sono il figlio 1 (%d): eseguo il nuovo processo\n", getpid());
        char *pathname = "process";
        char *newargv[] = { pathname, NULL };
        execv(pathname, newargv);
        exit(EXIT_FAILURE);   // exec non ritorna mai
    }
    else if (pid2 == 0) {
        // sono il figlio 2
        printf("sono il figlio 2 (%d): eseguo il nuovo processo\n", getpid());
        char *pathname = "process";
        char *newargv[] = { pathname, NULL };
        execv(pathname, newargv);
        exit(EXIT_FAILURE);   // exec non ritorna mai
    }
    else if (pid1 > 0 && pid2 > 0) {
        // sono il padre
        printf("sono il padre (%d): attendo 10 sec per fermare i figli\n", getpid());
        sleep(10);
        data->stop = true;

        // attende la terminazione dei processi figli
        int status;
        pid_t wpid;
        while ((wpid = wait(&status)) > 0)
            printf("sono il padre (%d): figlio %d terminato (%d)\n", getpid(),
                   (int)wpid, status);

        // chiude il memory mapped file ed esce
        printf("%s: processi terminati: counter=%ld\n", argv[0], data->counter);
        memMapClose(mmname, data);
        exit(EXIT_SUCCESS);
    }
    else {
        // errore nella fork()
        printf("error: %s\n", strerror(errno));
        memMapClose(mmname, data);
        exit(EXIT_FAILURE);
    }
}
Direi che il codice è sufficientemente chiaro, visto che è strutturalmente semplice e ben commentato. Qualche dubbio potrebbe sorgere a chi è poco pratico della fork(), per cui consiglio, al solito, la lettura del manuale UNIX/Linux nel link, dove viene bene illustrata. Per essere brevi (visto che questo non è un articolo sui segreti della fork()) posso solo riassumere che questa system call “sdoppia” il processo padre creandone uno identico che è il figlio. I due processi proseguono autonomamente dopo la chiamata di sistema e, normalmente, il codice prevede due flussi diversi in base al risultato della fork(). Spesso, ma non sempre, il processo figlio esegue un altro programma attraverso la funzione exec(): e questo è il nostro caso. Tra l’altro questo è il caso (si fa per dire) “peggiore”: eseguendo un altro programma la separazione tra i processi è totale, quindi farli parlare tra di loro è un po’ più complicato.

Il programma multithread dello scorso articolo aveva due thread e, visto che questo nuovo esempio doveva essere identico, processes.c crea, come detto sopra al punto 1, due figli: notare il “trucchetto” che ho usato per realizzare questo (segnatevelo, non è del tutto usuale): le due fork() vengono chiamate in una espressione AND. Perché? Forse non è evidente a prima vista, ma la fork() “sdoppiando” il processo chiamante, deve essere chiamata “in cascata” con una certa cautela, visto che il numero di processi aumenterà con progressione geometrica di ragione 2, quindi è facile ottenere 2, 4, 8… processi, ma per averne tre, come nel nostro caso (un padre e due figli) bisogna operare come mostrato nell’esempio. E aggiungo una curiosità: cosa succede se metto una fork() in un loop? Succede che il sistema collassa, perché avete creato una fork bomb!

E ora passiamo al codice dei processi figli, process.c:

// process.c - main processo figlio
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include "mmap.h"

// funzione main()
int main(int argc, char* argv[])
{
    // apre il memory mapped file
    const char *mmname = "mydata";
    shdata *data;
    if ((data = memMapOpen(mmname, false)) == NULL) {
        printf("processo %d: memMapOpen error\n", getpid());
        exit(EXIT_FAILURE);
    }

    // process loop
    printf("processo %d partito\n", getpid());
    unsigned long i = 1;
    for (;;) {
        // lock mutex
        pthread_mutex_lock(&data->mutex);

        // incrementa i counter
        data->counter++;
        i++;

        // unlock mutex
        pthread_mutex_unlock(&data->mutex);

        // test stop flag
        if (data->stop) {
            // il processo esce
            printf("processo %d terminato dal padre (i=%ld counter = %ld)\n",
                   getpid(), i, data->counter);
            exit(EXIT_SUCCESS);
        }

        // sleep processo (uso usleep solo per comodità)
        usleep(1000);
    }

    // il processo esce per altro motivo che lo stop flag
    printf("processo %d terminato localmente (i=%ld counter = %ld)\n",
           getpid(), i, data->counter);
    exit(EXIT_SUCCESS);
}
Non so se avete notato: è, come promesso, praticamente identico alla funzione threadfunc() del programma multithread dello scorso articolo, con l’unica differenza (quasi trascurabile) del modo di accesso ai dati condivisi, che qui usa l’IPC fornito dalla mini-libreria mmap. Visto che questo codice l’ho, praticamente, già` descritto in altri articoli, possiamo passare subito all’ultima parte, la mmap: vai col codice!
// mmap.h - header mini-libreria IPC con memory mapped file
#include <pthread.h>
#include <stdbool.h>

// struttura per i dati condivisi
typedef struct {
    pthread_mutex_t mutex;      // mutex comune ai processi
    bool            stop;       // flag per stop processi
    unsigned long   counter;    // dato comune ai processi
} shdata;

// prototipi globali
shdata *memMapOpen(const char *mmname, bool create);
int    memMapClose(const char *mmname, shdata *ptr);
questo era l’header mmap.h. La struttura dei dati condivisi shdata è, come promesso, esattamente identica a quella usata nel programma multithread dello scorso articolo. E ora ci manca solo l’implementazione mmap.c:
// mmap.c - implementazione mini-libreria IPC con memory mapped file
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <errno.h>
#include <unistd.h>
#include <sys/mman.h>
#include "mmap.h"

// memMapOpen() - apre un memory mapped file
shdata *memMapOpen(
    const char *mmname,     // nome del memory mapped file
    bool       create)      // flag di creazione/apertura
{
    shdata *ptr;

    // test se modo create o modo open di un file già creato
    if (create) {
        // crea la shared memory (il file "mmname" è creato in /dev/shm)
        int fd;
        if ((fd = shm_open(mmname, O_CREAT | O_RDWR, 0666)) < 0) {
            fprintf(stderr, "shm_open error: %s\n", strerror(errno));
            return NULL;
        }

        // tronca la shared memory
        if (ftruncate(fd, sizeof(shdata)) < 0) {
            fprintf(stderr, "ftruncate error: %s\n", strerror(errno));
            close(fd);
            return NULL;
        }

        // mappa il memory mapped file
        if ((ptr = mmap(NULL, sizeof(shdata),
                        PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)) < 0) {
            fprintf(stderr, "mmap error: %s\n", strerror(errno));
            close(fd);
            return NULL;
        }

        // chiude la shared memory: questo non compromette il map eseguito
        close(fd);

        // init mutex in modo "shared memory"
        pthread_mutexattr_t attr;
        pthread_mutexattr_init(&attr);
        pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);
        pthread_mutex_init(&ptr->mutex, &attr);
        pthread_mutexattr_destroy(&attr);

        // init altri dati comuni ai processi
        ptr->stop    = false;
        ptr->counter = 0;
    }
    else {
        // apre la shared memory (il file "mmname" è creato in /dev/shm)
        int fd;
        if ((fd = shm_open(mmname, O_RDWR, 0666)) < 0) {
            fprintf(stderr, "shm_open error: %s\n", strerror(errno));
            return NULL;
        }

        // mappa il memory mapped file
        if ((ptr = mmap(NULL, sizeof(shdata),
                        PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)) < 0) {
            fprintf(stderr, "mmap error: %s\n", strerror(errno));
            close(fd);
            return NULL;
        }

        // chiude la shared memory: questo non compromette il map eseguito
        close(fd);
    }

    // ritorna il pointer
    return ptr;
}

// memMapClose() - chiude un memory mapped file
int memMapClose(
    const char *mmname,     // nome del memory mapped file
    shdata     *ptr)        // pointer alla memoria condivisa
{
    // elimina il mutex
    if (pthread_mutex_destroy(&ptr->mutex) < 0) {
        fprintf(stderr, "pthread_mutex_destroy error: %s\n", strerror(errno));
        return -1;
    }

    // un-mappa il memory mapped file
    if (munmap(ptr, sizeof(shdata)) < 0) {
        fprintf(stderr, "munmap error: %s\n", strerror(errno));
        return -1;
    }

    // rimuove la shared memory
    if (shm_unlink(mmname) < 0) {
        fprintf(stderr, "shm_unlink error: %s\n", strerror(errno));
        return -1;
    }

    // success
    return 0;
}
Come avrete notato ho scritto una mini-libreria di una semplicità disarmante. Contiene solo due chiamate, una, la memMapOpen(), per creare/aprire il memory-mapped file (e inizializzare i dati) e un’altra, la memMapClose(), per chiuderlo. Non ci sono funzioni di lettura, scrittura, ecc.: una volta aperta la memoria condivisa ci si accede esattamente come se fosse una variabile (pointer) del programma. Una cosa veramente semplicissima.

E ora cosa ci manca per compiere la promessa? Ah, si, avevo detto …con risultati di esecuzione abbastanza interessanti… E allora vediamo cosa scrivono sul terminale i nostri due programmi (threads.c e processes.c)  durante l’esecuzione (su UNIX o Linux, ovviamente, una versione per Windows non userebbe fork(), ma questa è un altra storia…):

programma multithread "threads"
-------------------------------
thread 139969076664064 partito
thread 139969068271360 partito
thread 139969076664064 terminato dal main (i=9067 counter=18134))
thread 139969068271360 terminato dal main (i=9068 counter=18135))
./threads: thread terminati: counter=18135

programma multiprocess "processes"
----------------------------------
sono il padre (5780): attendo 10 sec per fermare i figli
sono il figlio 1 (5781): eseguo il nuovo processo
sono il figlio 2 (5782): eseguo il nuovo processo
processo 5781 partito
processo 5782 partito
processo 5782 terminato dal padre (i=9051 counter = 18099)
processo 5781 terminato dal padre (i=9051 counter = 18100)
sono il padre (5780): figlio 5781 terminato (0)
sono il padre (5780): figlio 5782 terminato (0)
./processes: processi terminati: counter=18100
Allora: entrambi i programmi operano per 10 secondi (come da codice, e se uno non si fida può eseguirli usando il comando time), e risulta che eseguono lo stesso numero di cicli (notare i valori dei contatori). Ossia: la versione multiprocess ha, esattamente le stesse prestazioni della versione multithread! Ecco perché parlavo di risultati interessanti… Tra l’altro anche usando una sleep di 1 us invece di una sleep di 1 ms (o, addirittura, senza nessuna sleep), si ottengono risultati comparabili, questo nel caso che qualcuno pensasse che il test è falsato da tempi di riposo troppo lunghi.

Ovviamente non sto dicendo che, magicamente, gli heavy weight process sono esattamente intercambiabili con i light weight process, ma, come evidenziato nel precedente articolo, le differenze prestazionali sono meno evidenti di quello che si crede (il context-switch dei thread è più leggero ma non così tanto). Concludendo: se vi fidate di me, quando avete dei dubbi seguite lo specchietto (quello in 3 punti) del precedente articolo, non ve ne pentirete!

Ciao, e al prossimo post!

giovedì 19 novembre 2020

Processi o Thread?
considerazioni sulla scelta tra Processi e Thread - pt.1

Gwenovier: Frank, cosa stai facendo?
Frank T.J.Mackey: Ti sto giudicando in silenzio.

Nel capolavoro Magnolia, Frank T.J.Mackey (un grande Tom Cruise) è un tipo con delle precise, radicate e radicali convinzioni, lui sa sempre come procedere in qualsiasi situazione. Lui non avrebbe mai dubbi su quale architettura scegliere scrivendo una applicazione: Processi o Thread? O un mix dei due? Ecco, visto che la maggior parte dei programmatori non ha le stesse sicurezze di Frank, cercheremo con questo articolo di fare un po' di chiarezza: speriamo bene...

Processi vs Thread
...processi o thread? Essere o non essere?...

La scelta di suddividere un programma in single/multi process o single/multi thread non è triviale. Non ci sono regole fisse e, spesso, si seguono metodi empirici o, più semplicemente, metodi intuitivi basati (si spera) sul buon senso.

Sfortunatamente l’avvento del multithreading è stato mal recepito da una grande parte dei programmatori che tendono a usarlo sempre (ma proprio sempre!) pur conoscendo in maniera approssimativa le (grandi) problematiche implicite nello scrivere un buon programma multithread. Per prendere un esempio dal mondo reale posso dirvi che l’idea di scrivere questo articolo mi è venuta mentre mio cuggino (ebbene si, sempre lui) mi raccontava di una sua riunione di lavoro, dove si dava per scontato che una nuova funzionalità da aggiungere a una applicazione già esistente (che era già multithread, e come no?) dovesse essere, sicuramente, un nuovo thread da aggiungere a quelli già esistenti...

Ma, sfortunatamente, la scelta non è così semplice.

Cerchiamo di chiarire cominciando col riassumere, brevemente, le caratteristiche e differenze tra Processi e Thread, ma senza entrare troppo nel dettaglio (mica che qualche lettore si addormenta) e dando anche per scontato che tutto questo sia già ben noto:

 

Processi

Thread

Definizione            

Un processo è l'istanza di un programma in esecuzione. A volte viene definito come "heavy weight process".

Un thread è un flusso di esecuzione all’interno di un processo. A volte viene definito come "light weight process".

Context-Switch         

Il cambio di contesto tra processi è relativamente lungo.                                               

Il cambio di contesto tra thread è più veloce di quello tra processi.

Memory Sharing         

I processi sono isolati tra loro. Questo è un vantaggio perché il "crash" di un processo non impedisce agli altri di continuare l'attività normale.

I thread non sono isolati tra loro e condividono la memoria: questo non è vantaggio: il malfunzionamento di un thread può, infatti, bloccare tutto il meccanismo di funzionamento del processo che lo contiene.

Comunicazione          

I processi anche se isolati tra loro possono condividere dati attraverso i meccanismi di IPC              

I thread grazie alla condivisione di memoria possono comunicare più facilmente e velocemente tra loro rispetto ai processi.

Creazione e Terminazione

la creazione e terminazione di un processo sono attività relativamente lente.                               

la creazione e terminazione di un thread sono attività più veloci delle equivalenti attività dei processi.

 

Così a prima vista sembrerebbe che, almeno in teoria, la scelta penda sempre dalla parte dei thread, ma in pratica... in pratica bisogna soffermarsi su due fattori:

  1. La differenza di prestazioni (velocità di context-switch e comunicazione) non è così abissale da far pendere clamorosamente la bilancia dal lato thread: nella decisione possono entrare anche altri fattori dipendenti dall'Hardware: ad esempio è noto che, in un sistema single-core e single-processor (è il caso di molti sistemi embedded) il multithreading spinto (di tipo preemptive, il tipo cooperative è tutta un'altra storia) non da poi delle grandi prestazioni...
  2. La frase "...il malfunzionamento di un thread può, infatti, bloccare tutto il meccanismo di funzionamento del processo..." mostrata qui sopra, insegna che un cattivo disegno di una applicazione multithread "si mangia" tutti i vantaggi descritti nella tabella precedente. 

Entrando un po' nel dettaglio del punto 1 appena descritto possiamo soffermarci sulla implementazione Linux dei thread (e il discorso è abbastanza valido anche per la implementazione Windows): in Linux un thread è una COE (context of execution) esattamente come un processo (e ce lo spiega lo stesso Linus qui: [Re: proc fs and shared pids]), quindi, visto che per il sistema operativo thread e processi sono (praticamente) la stessa cosa possiamo dedurre che, se il meccanismo di context-switch è ben fatto, la differenza di prestazioni non sarà enorme. Sarà magari più complicato scrivere l'applicazione, visto che far comunicare due processi con IPC non è semplice come far comunicare due thread che condividono la memoria, ma, e lo ripeterò per l'ennesima volta: scrivere una buona applicazione multithread è complicato, perché la sincronizzazione non può essere presa sotto gamba: una cattiva sincronizzazione produce problemi enormi (race-condition, starvation, ecc.), blah, blah, blah...

Quindi, senza dilungarci troppo, possiamo riassumere le (poche) regole necessarie a definire la scelta:

  1. Partendo da zero, ha senso realizzare una applicazione multithread quando la attività si può dividere in varie piccole sotto-attività omogenee, che necessitano di una notevole condivisione di dati e context-switch frequente (e, quindi, necessariamente rapido). Occhio all'Hardware però: se il target è una applicazione embedded su una CPU single-core avere molti thread può essere un problema.
  2. Partendo da una applicazione multithread già esistente, magari buona e ben testata, non ha molto senso aggiungere un nuovo thread che esegue, rispetto all'attività originale dell'applicazione,  una grande attività non omogenea, con una limitata condivisione di dati e context-switch scarso. E perché no ? Perché potrebbe creare problemi di concorrenza con la applicazione base e quindi, sicuramente, aggiungerebbe problemi di implementazione della sincronizzazione.
  3. Quindi nel caso 2 (specialmente se il target è una CPU single-core) potrebbe essere una buona scelta scrivere la parte nuova come processo indipendente (magari a sua volta di tipo multithread) e, probabilmente, anche l'implementazione risulterebbe più semplice e veloce, soprattutto a livello di debug, messa a punto e manutenzioni future (tutti argomenti fondamentali nel Software Engineering).

Per concludere: io sono un grande stimatore/utilizzatore del multithreading (e continuerò a esserlo), ma so che quando si fanno delle scelte tecniche bisogna essere il più possibile obbiettivi e non badare ai propri gusti personali... e per chi pensasse che tutto quanto sopra è solo un mio delirio informatico, posso proporre un interessante esempio reale fornito dagli amici del team di sviluppo Chrome di casa Google (che non sono esattamente gli ultimi arrivati): indovinate un po' che cosa fa Google Chrome quando aprite un nuovo tab di navigazione? Crea un nuovo thread? No: crea un nuovo processo!

Non posso finire un articolo senza mostrare un po' di codice, quindi ho pensato di mostrare un esempio di applicazione multithread (simile, ma ancora più semplificata di quella vista qui), che crea due thread che condividono un contatore (...si, lo so, usa le sleep al contrario di quello raccomandato nell'ultimo articolo... ma è proprio un esempio semplificato, non fate i pignoli...). Questo esempio si può compilare ed eseguire per prendere nota dei risultati.

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

// struttura per i dati condivisi
typedef struct {
    pthread_mutex_t mutex;      // mutex comune ai threads
    bool            stop;       // flag per stop thread
    unsigned long   counter;    // dato comune ai threads
} shdata;

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

// funzione main()
int main(int argc, char* argv[])
{
    pthread_t tid[2];
    shdata data;

    // init mutex
    int error;
    if ((error = pthread_mutex_init(&data.mutex, NULL)) != 0) {
        printf("%s: non posso creare il mutex (%s)\n", argv[0],  strerror(error));
        return 1;
    }

    // init altri dati comuni ai threads
    data.stop    = false;
    data.counter = 0;

    for (int i = 0; i < 2; i++) {
        // crea un thread
        if ((error = pthread_create(&tid[i], NULL, &threadfunc, (void *)&data)) != 0)
            printf("%s: non posso creare il thread %d (%s)\n", argv[0], i, strerror(error));
    }

    // dopo 10 secondi ferma i thread
    sleep(10);
    data.stop = true;

    // join threads e cancella mutex
    pthread_join(tid[0], NULL);
    pthread_join(tid[1], NULL);
    pthread_mutex_destroy(&data.mutex);

    // exit
    printf("%s: thread terminati: counter=%ld\n", argv[0], data.counter);
    return 0;
}

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

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

        // incrementa i counter
        data->counter++;
        i++;

        // unlock mutex
        pthread_mutex_unlock(&data->mutex);

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

        // sleep thread (uso usleep solo per comodità)
        usleep(1000);
    }

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

Chiaro, no? È un esempio veramente semplice che mi permetterà di mostrarvi, nella seconda parte dell'articolo, una applicazione multiprocess che fa esattamente la stessa cosa, oltretutto con un codice veramente molto simile e con risultati di esecuzione abbastanza interessanti... Non trattenete il respiro nell'attesa, mi raccomando!

Ciao, e al prossimo post!