Articolo pubblicato sul n. 88 di MCmicrocomputer (Edizioni Technimedia Srl - Roma) nel settembre 1989
Amighevole: A partire da questo numero inizieremo a "giocare duro" coi nostri Amiga. E vi assicuro che ne vedremo delle belle. Questo mese vi presento il mio ultimo giocattolino ideato espressamente per sfruttare al massimo, col minimo sforzo e con la massima "pulizia", il multitasking di Amiga. Grazie all'ADPmttb sara' infatti possibile scrivere applicazioni multitask: formate non da un solo programma, ma da una collezione di processi intercomunicanti in grado cioe' di cooperare. Nel corso di questo articolo vedremo anche un paio di semplici esempi di utilizzo dell'ADPmttb, ma dietro le quinte (tanto per non fare anticipazioni) e' gia' funzionante la release 2.0 di ADPnetwork, la rete software per Amiga scritta interamente in ADPmttb. Ma non voglio anticiparvi nulla di piu', altrimenti vi farei perdere il gusto della sorpresa. Prima di iniziare vorrei ringraziare pubblicamente (e in ordine alfabetico) Marco Ciuchini, Andrea Gozzi, Oscar Sillani, Andrea Suatoni che mi hanno aiutato a debuggare le precedenti versioni dell'mttb e coi loro consigli hanno contribuito a rendere tale progetto il piu' user friendly e completo possibile. Se qualche amighevole lettore, ancora fresco di 64, ricorda l'ADPbasic e il significato di tale sigla (Advanced Disk Printers Basic), aggiungero' che l'ADP di ADPmttb sta per Advanced Distribuited Programming. Bene, ora sono proprio nei guai. Cosa inventero' entro il prossimo mese per giustificare anche ADPnetwork ?
Un po' di teoria (scusate...)
Mentre continuo a sognare il mio prossimo personal computer basato su una dozzina di 68000, vi svelero' il motivo per cui sono tanto entusiasta di Amiga. Entusiasmo nato prima ancora di vederlo funzionare, prima di assaporare modi grafici impensabili (nell'86...) o animazioni degne di Workstation ben piu' importanti. Cio' che mi appassiona di piu' di questa macchina tanto odiata da molti, e' il multitasking finalmente non piu' simulato sopra una macchina monotask (non per vantarmente, ma facevo esperimenti del genere anche sul mio fido 64, ben prima dei vai multilink per PC) ma vivo, vegeto e funzionante, al livello stesso del sistema operativo. Accendete un Amiga e prima di clickare su qualsiasi icona domandatevi quanti programmi sono gia' in esecuzione sulla vostra macchina. Sono una decina almeno e riguardano i processi di sistema pronti ad esaudire ogni vostra richiesta. A questo punto clickate (memoria permettendo) su tutte le icone che vi pare. Vi accorgerete (anzi ve ne siete gia' accorti da un pezzo) che il multitask vi permette di sfruttare maggiormente ogni risorsa disponibile sulla vostra macchina, a cominciare dal processore stesso. Attualmente, sul mio Amiga super espanso, al momento del boot lancio un Word Processor (C1-Text), un programma di comunicazione (JRCOMM), un line editor (CED), lo schermo PCcolor della Janus, Gomf, TableCloth, DisplayMemory, PCdisk, Dmouse e non ricordo cos'altro. E non e' affatto raro che mentre compilo un programma, da MClink via jrcomm scarico un file, e col fido C1-Text stampo l'articolo da consegnare... ieri. Il tutto in un tempo totale minore della somma dei tre tempi impiegati per eseguire le operazioni sequenzialmente. Miracolo ? No, tecnologia e nemmeno tanto moderna. Sono infatti decenni che i computer (senza personal davanti, per favore!) lavorano in multitask non per perdere tempo ma per guadagnarlo. E non mi spiego perche' il giorno in cui hanno deciso di fare i personal abbiano scelto di riferirsi a schemi di sistemi di elaborazione vecchi alcuni decenni. Che Amiga (o chi per lui) dovesse prima o poi arrivare non c'e' mai stato dubbio. Chissa' quanto altro tempo dovremo aspettare per vedere il primo personal multiprocessor, con schema di funzionamento vecchio solo una decina d'anni... Ma torniamo a noi. Dicevamo che col multitasking e' possibile eseguire in parallelo un certo numero di funzioni in un tempo minore della somma dei singoli tempi impiegati per eseguire la stessa serie di operazioni in modo sequenziale. Il perche' e' molto semplice e per spiegarlo basta pensare ad un word processor che stampa un file. Qualsiasi computer utilizziate, la stampa di un file avviene inviando caratteri da stampare alla stampante. Nella stragrande maggioranza dei casi, la stampante stessa rappresentera' un vero e proprio collo di bottiglia del sistema. Infatti e' lecito pensare che il computer sia in grado di "passare" i caratteri ben piu' velocemente della capacita' della stampante di stamparli. Buffer a parte, che, comunque, prima o poi si riempira' (il caso di buffer infinito non lo prendo in considerazione per ovvi motivi) a regime un sistema computer-periferica di questo tipo avra' un funzionamento del tipo: eccoti un carattere... hai finito ? ... eccoti un altro carattere... e cosi' via. Durante la fase "hai finito ?" il processore aspetta risposta dalla periferica e fino a quando quest'ultima non gli dara' l'ok, il processore non potra' spedire alcun carattere. In un personal computer tradizionale la CPU non potra' fare altro che aspettare pazientemente l'ok, ma in un computer multitask si sfruttano tali tempi "morti" per elaborare altri processi pronti. In altre parole spariscono le fasi di "attesa attiva" sul verificarsi di eventi esterni e al loro posto troviamo delle commutazioni di contesto che permettono, appunto, di fare avanzare altri processi. Arrivato l'ok che aspettavamo, naturalmente, occorre tornare al processo di stampa per dare un altro carattere da stampare, e il ciclo si ripete fintantoche' c'e' qualcosa da elaborare. Esattamente come dire che in un computer multitask la CPU non sta mai in ozio, ma lavora sempre al 100% (che poi e' quello che interessa maggiormente).
Detto questo...
Amiga va ancora oltre. La sua architettura infatti non e' solo multitask, ma a modo suo addirittura multiprocessor. A parte la scheda Janus che contiene oltre ad un processore aggiuntivo anche la ram e l'elettronica di controllo per fregiarsi del titolo di "computer su scheda" e alla quale possiamo demandare compiti elaborativi anche non indifferenti (tale feature, promessa da anni, non e' ancora stata sfruttata da nessuno), troviamo dentro Amiga anche un processore sonoro e un processore grafico che lavorano in parallelismo reale col 68000. Ma questo esula dal nostro discorso e quindi preferirei tornare all'elaborazione "pura". Come detto all'inizio, il multitask di Amiga non e' simulato sopra una macchina monotask, ma il sistema operativo, nel suo livello piu' basso, Exec, mette a disposizione tutti gli strumenti per la programmazione concorrente. E' infatti possibile utilizzare nei nostri programmi C alcune primitive di comunicazione interprocess per permettere a piu' programmi di "parlarsi". E' infatti questa la caratteristica piu' importante dei sistemi multitask: non tanto "piu' processi in esecuzione parallela" quanto "piu' processi in esecuzione parallela in grado di comunicare". Solo in questo modo e' possibile scindere applicazioni monotask in piu' processi concorrenti (e cooperanti). Facciamo un bell'esempio ? Torniamo al nostro amato WordProcessor, questa volta multiprogrammato. Mettiamo un processo per gestire l'input da tastiera, uno per il controllo ortografico, uno per la formattazione WYGIWYS, uno per la stampa in background e uno denominato "saver" che ogni 100 modifiche del testo provvede a salvarne una copia su HD. Tutti moduli semplicissimi da scrivere che ci permetteranno una volta lanciati di avere testi sempre ben formattati e corretti, salvati per sicurezza ogni cento modifiche e quando decideremo di stampare il file, una volta dato l'ordine potremo immediatamente cominciare a lavorare su un altro testo o addirittura uscire dal WP per cariacare qualcos'altro (se la memoria scarseggia). Tutte feature, s'intende, disponibili anche su programmi monotask, ma provate ad immaginare cosa vuol dire programmare la stampa in background o il controllo ortografico in real time o, peggio, l'autosave senza che l'operatore venga rallentato minimamente. Se siete bravi programmatori provate a pensarci su un po' e poi fatemi sapere...
Non dilunghiamoci troppo
Avete letto finora circa novemila caratteri di questo
articolo e non vi ho ancora detto, in pratica, cosa diavolo
sia questo mttb. A dire il vero non ho ancora finito con
l'infarinatura teorica (neccessaria!) ma per premiarvi del
fatto di avermi seguito fin qui (che coraggio...) vi
anticipero' qualcosa. Dunque, l'mttb non e' altro che una
collezione di subroutine C (vi consiglio vivamente di
raggrupparle, compilate, in una libreria linked) con le
quali e' possibile comunare tra processi in una maniera piu'
pulita e intuitiva dei meccanismi offerti da Exec. Oltre a
questo con l'mttb e' possibile creare processi "figli"
completamente indipendenti dal processo "padre" (colui che
"crea"), ed effettuare numerose interrogazioni sullo stato
delle porte e dei processi creati tramite mttb. La tabella 1
mostra la lista delle funzioni implementate con una breve
descrizione accanto.
Ambiente globale e ambiente locale
Essenzialmente esistono due modi per far comunicare i
processi. Amiga fa, come al solito, eccezione, implementando
una tecnica sua propria a meta' strada tra l'ambiente locale
e quello globale. Altra anticipazione: l'mttb rimette le
cose a posto spostando il tutto sulla comunicazione ad
ambiente locale. Che vuol dire ? Se invece l'ambiente e' globale, i dati sono grosso modo alla merce' di tutti. Sono fisicamente dislocati in una zona di memoria raggiungibile da tutti i processi che devono utilizzarli e tramite meccanismi di mutua esclusione (tipicamente monitor, semafori o piu' brutali sospensioni del multitask) sono in uso ad uno o ad un altro processo.
Amiga, the best (...ia)
Cosa fa, di contro, lo sporco Amiga ? Utilizza il meccanismo dello scambio messaggi, tipico della comunicazione ad ambiente locale, per implementare una sorta di comunicazione ad ambiente globale. L'unica cosa in grado di spedire e' un puntatore alla zona di memoria (locale o allocata dal processo mittente) dando cosi' implicita autorizzazione al processo destinatario ad utilizzare i dati (veri e propri, non una copia) del processo mittente. Per convenzione e' poi stabilito che il processo mittente non utilizza gli stessi dati fino a quando il destinatario non restituisce l'autorizzazione, avendo finito di utilizzarli, spedendo un apposito messaggio "vuoto" di risposta. Ecco perche' ad ogni PutMsg di Exec fa sempre seguito una WaitPort(replayport) dal lato del mittente mentre dal lato destinatario, dopo aver utilizzato i dati il cui puntatore e' arrivato a seguito della GetMsg si effettua una ReplayMsg(port). Pura sincronizzazione, nulla di piu'. E per di piu' a carico dell'utente. Cosa succede infatti se chi programma dimentica di effettuare la ReplayMsg oppure la WaitPort(replayport) ? Guru Metitation a piu' non posso, ma soprattutto... a ragione!
L'ADPmttb
A questo punto entro in gioco io e tiro fuori l'mttb col quale fare casino sara' veramente tanto difficile. L'ambiente, abbiamo detto, diventa per incanto locale e spedire dal processo A al processo B diecimila byte di informazione significa "fisicamente" approntarne una copia per il destinatario che potra' farne quello che vuole: e' sua! Il tutto, ovviamente, trasparente per l'utente che dovra' solo dire cosa spedire a chi o cosa ricevere da chi il gioco (perche' di gioco si tratta, a questo punto) e' fatto. Sara' perche' sono un teorico irriducibile, ma a me questo mttb di "attizza" piu' della stesso software di rete per Amiga che subito dopo ho realizzato sfruttanto l'mttb. Finalmente programmare in multitasking su Amiga diventa possibile senza importare contemporaneamente tutta una collezione di problemi cronici che Exec si porta dietro forse perche' rivolto a programmatori esperti (soprattutto in cose sporche!). Volete un primo, agghiacciante, esempio ? In figura 1 e' mostrato lo schema di funzionamento di uno spool di stampa manuale. Il processo Spool gira in backgrount e il processo Print e' invece invocato da cli seguito dal nome del file da stampare. Da cli possiamo dare ordine di stampa di quanti file vogliamo semplicemente perche' Print torna subito il prompt del cli indipendentemente dal file effettivamente in fase di stampa. Se ad esempio dobbiamo stampare i file "tizio.doc", "caio.doc" e "sempronio.doc" sara' sufficiente digitare in rapida sequenza i tre comandi:
Print tizio.doc Print caio.doc Print sempronio.doc
e
sentire la stampante che piano piano (beh, questo dipende
dalla vostra stampante) stampa i tre file uno dopo l'altro.
Forme di Comunicazione
Nella comunicazione ad ambiente locale, esistono alcune forme tipiche che ho creduto opportuno implementare nell'mttb. Infatti dire che il processo A manda qualcosa al processo B e' abbastanza riduttivo. Bisogna vedere cosa succede se B non e' pronto a ricevere il messaggio oppure, viceversa, se B e' pronto ma A non l'ha ancora inviato. Ma prima di affrontare questo problema (con risoluzione a scelta dell'utente) e' necessario stabilire in che modo avviene la comunicazione vera e propria tra due processi. Per questo mi sono completamente appoggiato allo schema di Exec che prevede l'utilizzo di porte di proprieta' esclusiva dei processi destinatari. Tali porte sono invece accessibili in scrittura da parte di tutti i processi mittenti. Dunque un processo che deve ricevere qualcosa su una porta, la prima cosa che deve fare e' creare la porta stessa dandogli un nome. La funzione mttb da utilizzare e':
NewPort("NomeDellaPorta");
e da quel momento in poi e' possibile accedervi sia per spedire (tramine Send) che per ricevere (traminte Receive) messaggi. Quando una porta non serve piu' e' necessario deallocarla con la funzione:
EndPort("NomeDellaPorta");
che a differenza della corrispondente DeletePort di Exec prima di deallocarla la ripulisce di tutti i messaggi accodati ma non letti. Tornando al discorso delle forme di comunicazione esistono essenzialmente comunicazioni sincrone, asincrone, bloccanti, non bloccanti e a rendez-vous esteso. Per comunicazione sincrona si intende che il processo mittente che effettua una Send non va avanti nell'elaborazione fino a quando il corrispondente processo destinatario non esegue la Receive che preleva il messaggio dalla porta. In altre parole, dal punto di vista logico, l'istante in cui il processo mittente spedisce e il processo destinatario riceve coincidono temporalmente. Coincidono al punto che se... non coincidono di per se', coincidono "a forza" dal momento che chi arriva prima aspetta l'altro. Nella comunicazione asincrona cio' non succede. Il mittente che esegue la Send non aspetta la corrispondente Receive ma va avanti nella sua elaborazione. Il messaggio si accoda sulla porta (e piu' messaggi possono accodarsi) e restano disponibili al processo destinatario per quando vorra' prelevarli. Su questa forma di comunicazione si basa lo Spool prima commentato. Print non fa altro che accodare i nomi dei file da stampare sulla porta "FilePort", infischiandosene dello stato di avanzamento del processo destiatario. Questo, man mano che stampa, preleva nomi di file dalla porta; tutto qui. Per quanto riguarda le Receive, bisogna stabilire cosa fare nel caso in cui il messaggio non sia ancora presente sulla porta. Possiamo infatti rimanere in attesa del messaggio (quindi in destinatario aspetta) oppure non aspettare e ottenere un messaggio nullo di ritorno. Nel primo caso si dice che la Receive e' bloccante, nel secondo caso non bloccante. E veniamo ora alle due funzioni di scambio messaggio mostrate nel listato 1: SendBlock e ReceiveBlock. Esse permettono di trasferire da un processo ad un altro un qualsiasi insieme contiguo di byte. Il primo parametro da passare alla SendBlock e' la forma di comunicazione adottata, sincrona o asincrona, il secondo la porta interessata (ovvero il nome della porta sulla quale spedire il messaggio), il terzo parametro e' il puntatore alla zona di memoria da trasferire e il quarto la lunghezza, in byte, del blocco di memoria in questione. Quindi, se dobbiamo spedire 200 byte di memoria a partire locazione 1000 sulla porta "Pippo" in modo sincrono scriveremo:
SendBlock(MODE_SYNC,"Pippo",1000,200);
in modo asincrono:
SendBlock(MODE_ASYNC,"Pippo",1000,200);
Ovviamente il caso di trasferimenti di porzioni di memoria nude e crude ci interessa poco, ma fortunatamente in C si gioca tutto coi puntatori che altro non sono che indirizzi di memoria. Ad esempio se buffer e' un array di char lungo 200, se dobbiamo spedirlo sulla porta Pippo nel caso sincrono scriveremo:
SendBlock(MODE_SYNC,"Pippo",buffer,200);
discorso analogo anche per strutture piu' complesse: se "record" e' una struct possiamo scrivere:
SendBlock(MODE_SYNC,"Pippo",&record,sizeof(record));
dove, come noto, &record denota appunto l'indirizzo di memoria dove e' memorizzato record. Per finire, il valore restituito dalla SendBlock e' una delle tre costanti predefinite OP_OK, OP_FAIL, NO_PORT che indicano rispettivamente che l'operazione e' andata a buon fine, che e' fallita, che la porta sulla quale intendiamo spedire non esiste (piu'). Per quanto riguarda la ReceiveBlock, i parametri da passare sono rispettivamente il modo di ricezione (bloccante o non bloccante), la porta dalla quale prelevare il messaggio e un indirizzo di memoria (un puntatore) dove riporre il messaggio ricevuto. La ReceiveBlock restituisce la lunghezza del messaggio letto (praticamente il quarto parametro passato alla SendBlock corrispondente). Se ad esempio aspettiamo sulla porta Pippo (che abbiamo preventivamente creato noi) un buffer di caratteri e siamo interessati al modo bloccante, scriveremo (dopo aver naturalmente dichiarato buffer come array di char sufficientemente grande):
len = ReceiveBlock(MODE_WAIT,"Pippo",buffer)
Per il modo non bloccante:
len = ReceiveBlock(MODE_NOWAIT,"Pippo",buffer)
Resterebbe da descrivere il modo di comunicazione a rendez-vous esteso, ma visto che ho gia' superato i ventimila caratteri di questo articolo e devo necessariamente dirvi dell'altro prima di chiudere, rimando tale descrizione ai prossimi... riquadri, tanto piu' che tale modo di comunicazione non e' implementato per la SendBlock e ReceiveBlock, ma solo per la Send e Receive che trasferiscono stringhe null terminated da un processo ad un altro.
Note tecniche
Tralascio di commentare anche le due funzioni NewPort e EndPort (listato 1) che sono abbastanza autoesplicative data la loro estrema semplicita'. Dedichiamoci alla SendBlock e alla ReceiveBlock. Innanzitutto il messaggio effettivamente trasferito (ma questo l'utente e' autorizzato, anzi, invitato a ignorare) e' di tipo struct adp_message, dichiarato in testa al listato 1. Esso e' composto da un effettivo campo struct Message utilizzato da Exec (e dal sottoscritto) per la trasmissione vera e propria, e da tre campi mode, len e testo che contengono rispettivamente il modo ti comunicazione (MODE_SYNC oppure MODE_ASYNC) la lunghezza del messaggio da trasmettere e il testo del messaggio (i byte da trasmettere). Notare come "testo" sia dichiarato come un array di un solo carattere ma non per questo non e' possibile trasmettere cose ben piu' corpose. Vediamo come agisce la SendBlock (seguitemi per favore sul listato 1). Per prima cosa dichiara due puntatori ad una struttura di tipo struct MsgPort. Segue la dichiarazione di un puntatore ad una struttura di tipo struct adp_message. Dopo il controllo sul corretto modo di trasmissione (MODE_SYNC o MODE_ASYNC) si alloca una quantita' di memoria atta a contenere il messaggio da trasmettere. Il puntatore restituito dalla AllocMem e' associato ad adpmsg: se ci pensate un attimo, questo equivale a dire che il nostro array "testo" dichiarato nella struttura lungo un solo byte in realta' puo' essere lungo quanto ci pare, semplicemente allocando una quantita' di memoria maggiore di quella strettamente necessaria (pari, per la cronaca, a sizeof(struct adp_message)). Ovviamente ci facciamo anche complici del fatto che il C non esegue alcun controllo sugli indici degli array. Andiamo avanti. Nel campo adpmsg->mode mettiamo il modo di trasmissione cosi' come ci e' passato dal chiamante. Se tale modo e' quello sincrono, occorre creare una replayport e inserirla nel campo "message.mn_ReplayPort", altrimenti poniamo tale campo a NULL. Seguono altri "settaggi" comuni a tutt'e due i modi e infine si copia l'oggetto da spedire nel campo adpmsg->testo tramite una bella CopyMem di Exec. A questo punto, racchiusa in una coppia di Forbid - Permit effettuiamo la spedizione vera e propria, naturalmente dopo aver rintracciato il puntatore vero e proprio alla porta (noi, infatti, passiamo il nome di questa e non il puntatore alla struct MsgPort). Infine, se tutto e' andato bene e il modo era sincrono il processo esegue una WaitPort(replyport) per attendere il completamento della corrispondente ReceiveBlock. Segue, ovviamente (e solo nel caso MODE_SYNC), la rimozione della replyport e la deallocazione della memoria utilizzata per adpmsg. Se qualcuno si sta chiedendo chi deallochera' la memoria nel caso di modo asincrono la risposta e' molto semplice: la ReceiveBlock corrispondente che ora andro' a commentare. La linee iniziali della ReceiveBlock sono molto simili a quelle della SendBlock. Qui non troviamo allocazione di memoria in quanto gia' fatta dalla SendBlock. Dopo aver cercato l'effettivo puntatore alla porta, occorre distinguere il caso in cui la ReceiveBlock sia bloccante o non bloccante. Nel primo caso infatti effettueremo una WaitPort(port) nel secondo semplicemente una lettura al volo tramite GetMsg di Exec che restituisce NULL se la porta e' vuota. Se avevamo richiesto una ReceiveBlock non bloccante e il messaggio non e' ancora arrivato restituiamo un bel EMPTY_PORT. Se il messaggio c'e' oppure non c'era ma noi abbiamo aspettato il suo arrivo con la WaitPort, copiamo il testo nell'area di memoria passataci come terzo parametro (vtg) e se il modo di trasmissione effettuato dalla SendBlock era sincrono effettuiamo una ReplayMsg(adpmsg) altrimenti il modo era asincrono e non dobbiamo fare altro che deallocare la memoria allocata dalla SendBlock.
Utilizzazione pratica
Per usare l'ADPmttb e' sufficiente includere ai vostri processi mttb il listato 1 prima di compilarli normalmente. In questo caso, pero', saranno presenti nel vostro codice oggetto anche routine da voi non invocate con conseguente spreco di memoria. Una maniera piu' corretta di utilizzo e', come gia' detto prima, quella di preparare una libreria di funzioni mttb da linkare ai vostri processi dopo la normale compilazione. In questo caso, la parte da includere in ogni processo sara' la sola parte iniziale del listato 1 fino alla prima funzione (NewPort) esclusa . Col Lattice C 4.0 che uso normalmente, e' sufficiente separare le funzioni in tanti file (avendo l'accortezza di ripetere in ognuno le definizioni e gli include iniziali) e compilarli col comando:
LC -Rmttb.lib [lista di file]
Quando poi compilerete i vostri processi, darete il comando: LC -L+mttb.lib [NomeFile.c]
Per chi invece acquistera' il dischetto in redazione, la procedura di compilazione sara' completamente automatizzata utilizzando una apposita utility che troverete sul dischetto.
Conclusioni
Per quei pochi coraggiosi che sono giunti fin qui, il listato 2 mostra un esempio di utilizzo di ADPmttb completo: non la versione ridotta mostrata questo mese. Si tratta di uno spool di stampa multitask che provvede anche alla giustificazione e alla paginazione del testo da stampare. Il primo processo, Out, digitato da cli e seguito dal nome del file da stampare e dalla larghezza di stampa desiderata, non fa altro che aprire il file e carattere dopo carattere lo spedisce al processo MakeWord (creato, come gli altri, dallo stesso Out). Questo riceve caratteri e forma parole che provvede a spedire a MakeLine il quale si occupa di formare appunto le linee lunghe meno della larghezza di stampa desiderata. A questo punto FormatLine, che riceve da MakeLine la linea da formattare, esegue la giustificazione e spedisce la linea formattata al processo Printer che stampa. E' chiaro che si tratta solo di un esempio di programmazione multitasking e la stessa funzione si sarebbe potuta ottenere con minore dispendio di energie con un solo programma monotask. Ma come programma monotask, cosa l'avrei messo a fare in questo articolo ? Beh, l'unica cosa certa e' che la stanchezza non perdona. La mia e la vostra. Buona notte... Articolo pubblicato su www.digiTANTO.it - per ulteriori informazioni clicca qui |