<![CDATA[InformaticaBrutta]]>https://informaticabrutta.it/https://informaticabrutta.it/favicon.pngInformaticaBruttahttps://informaticabrutta.it/Ghost 5.8Sun, 01 Jan 2023 15:51:00 GMT60<![CDATA[Creare interfacce vocali è difficile]]>https://informaticabrutta.it/interfacce-vocali-difficile/618785ab351e500001ea96e1Mon, 08 Nov 2021 12:00:07 GMT

Cos'è un'interfaccia vocale? O Vocal UI per dirla in modo più figo?

È un tipo di interfaccia che l'utente non vede, ma ascolta e ci interagisce con la voce. Degli esempi possono essere Cortana, Alexa, Siri, Google Coso, o qualunque cosa che si attivi con la voce, e ti risponde con tanto di voce.

Per quanto possa essere interessante l'idea di avere un assistente virtuale che parla e ascolta, costruire un'interfaccia di questo tipo non è per niente facile. O meglio, è (relativamente) facile se il tuo obiettivo è facile, ma si complica vertiginosamente all'aumentare della complessità dei task che vuoi realizzare.

Questo articolo contiene riflessioni e opinioni personali che ho sviluppato durante la creazione del progetto finale per il corso di Human Computer Interaction (HCI) alla magistrale che sto frequentando.

Insieme a quattro colleghi, abbiamo deciso di creare un'interfaccia vocale per manovrare dei CRM; nello specifico, abbiamo preso molta ispirazione da Monica, un CRM orientato alla persona invece che all'azienda. Abbiamo sviluppato la nostra interfaccia come skill per Mycroft, un assistente vocale open-source.

Molto semplicemente, un CRM è un programma per gestire relazioni con persone, ad esempio annotando l'ultima volta con abbiamo parlato con loro, come, quando, perché, etc. Immagina una rubrica, ma con i muscoli.

Già da questa descrizione è facile farsi una prima idea delle possibili difficoltà nel creare una cosa del genere. I punti che scrivo nell'articolo infatti, provengono da esperienze e esperimenti che abbiamo fatto durante lo sviluppo del progetto.

Partiamo.

Riconoscimento vocale

La prima ovvia cosa che un'interfaccia deve fare per capire cosa hai detto, è prendere la registrazione audio della tua bella vocetta, e darla in pasto ad una scatola nera che ne sputa fuori la trascrizione (speech to text). Tutto questo è chiamato speech recognition.

Quali sono i problemi nel fare una cosa del genere? Cosa può andare storto?

Qualità dell'input audio

Ecco qualche scenario:

  • Il volume della registrazione è troppo basso, e il motore di riconoscimento non riesce a fare correttamente il suo lavoro; potrebbe ad esempio saltare intere parole.
  • Cancellazione dell'eco: a seconda del dispositivo su cui fai girare il software di assistente vocale, potrebbe esserci o non esserci cancellazione dell'eco. In ogni caso, una scarsa o assente cancellazione dell'eco può compromettere la qualità, ad esempio se l'acustica della stanza è cattiva. Occhio, perché anche un'eccessiva cancellazione dell'eco può dare problemi.
  • Cancellazione del rumore: come per il punto precedente, se l'ambiente è molto rumoroso e la soglia del gate è troppo bassa, alcuni rumori ambientali potrebbero essere valutati come parole. Al contrario, una soglia troppo alta può avere l'effetto di scambiare parole correttamente pronunciate per rumore.
  • Timbri di voce: in base all'hardware del dispositivo e al modello di speech to text, alcuni timbri di voce possono essere più o meno riconoscibili. Ad esempio, durante i test del nostro progetto di esame ci siamo accorti che il motore STT di Mycroft su cui ci stavamo basando aveva problemi a riconoscere voci fine/alte.

Questi punti sono molto dipendenti dall'hardware che stai usando (dispositivo, microfono) e al software che ci gira sopra (SO, driver audio, filtri, assistente vocale).

Una cosa divertente che ho fatto per vedere se con qualche filtro riuscivo a risolvere i problemi di riconoscimento delle voci "fine", è stato mettere un octaver prima di Mycroft e... sorpresa, le voci fine venivano riconosciute.

Dialetti, lingue diverse, parole sconosciute

Nel mondo ideale tutti parlano correttamente un'unica lingua, in condizioni ambientali favorevoli, hanno una dizione impeccabile, non dicono cose come "uhmmmm, cioèèè tipooo, eeeeeee", non esistono i dialetti, non esistono neologismi e nessuno inventa nuovi termini.

Nel mondo reale, tutti parliamo a modo nostro, anche più di una lingua, usiamo parole di lingue diverse nella stessa frase, parliamo mentre il cane abbaia, facciamo dei suoni mentre pensiamo, ci inventiamo le parole e a volte sostituiamo nomi di oggetti con "coso".

Noi umani siamo bravi a disambiguare tutte queste cose; un algoritmo non proprio.

Intent parsing

Il testo estratto dalla utterance dell'utente (ciò che ha detto), deve essere in qualche modo compreso: il sistema deve ricavarne un significato.

Il concetto di significato può essere ridotto al concetto di intent, ovvero ciò che il sistema crede sia l'intenzione dell'utente.

Per fare un esempio, immaginiamo che la nostra interfaccia vocale gestisca un piccolo sistema di smart home, in cui i possibili intent sono:

  • Accendi la luce numero X.
  • Spegni la luce numero X.
  • Aumenta la luminosità della luce numero X.
  • Diminuisci la luminosità della luce numero X.

Quando l'utente parla, il sistema deve capire cosa fare tra le cose in questa lista; per farlo ha bisogno di qualche regola. Questo processo è chiamato intent parsing, e consiste in breve nel trovare una corrispondenza tra la utterance dell'utente e un intent definito da chi ha creato la skill.

Gli intent parser possono funzionare in modo diverso: per esempio Padatious è basato su reti neurali, mentre Adapt è basato sul riconoscimento di parole chiavi.

A seconda della complessità del task e delle caratteristiche dell'intent parser, le cose possono complicarsi, specialmente se il sistema deve anche estrarre delle entità. Per esempio, la nostra intent definition per Padatious del nostro stupido sistemino di smart home, potrebbe essere questa.

(accendi|accendere) (|la) luce (|numero) {NomeLuce}
Task di accensione di una luce
(aumenta|alza) (|la) luminosità (|della) luce (|numero) {NomeLuce}
(aumenta|alza) (|la) luce (|numero) {NomeLuce}
rendi {NomeLuce} più luminosa
Task di aumento della luminosità

Non mi va di scrivere Per brevità ometto i task complementari di spegnimento e diminuzione.

La sintassi è facile:

  • (a|b|c) denota opzionalità delle stringhe {a, b, c}.
  • (|a|b|c) aggiunge la stringa vuota all'opzionalità.
  • {Entità} è un placeholder per un'entità chiamata Entità, che dopo essere estratta possiamo farci qualcosa.

Sebbene questi siano task molto semplici, sono comunque adatti per ragionare su cosa può andare storto. Cosa succede se l'utente dice qualcosa come:

Ehmm, allora, puoi alzarmi per favore la luminosità della luceeeehin cucina? Grazie.

Basta fare qualche test di usabilità per accorgersi che gli utenti tendono a sparare utterance di questo tipo. Ovviamente meno l'utente ha esperienza con assistenti vocali, più tenderà a costruire utterance "gentili", destrutturate, complesse, più lunghe del necessario.

Se l'intent parser è bravo a filtrare tutte le possibili stopword e altre parole non gradite (grazie, per favore, ehm, allora, etc), l'interazione sarà abbastanza piacevole, e noi non dovremmo faticare molto per scrivere delle buone intent definition e gestirle. Se prendiamo in considerazione Padatious, scopriremo che non è per niente bravo a farlo. In particolare, durante il nostro progetto ci siamo resi conto che:

  • Intent definition con e senza entità lavorano male insieme.
  • Per qualche ragione, il parser lavora male quando ci sono molte opzionalità.
  • In alcune occasioni, il contenuto delle entità estratte è sbagliato.[1][2]

Trovati questi problemi, potremmo ben pensare di passare ad un altro intent parser, come Adapt. Migrare è semplice: basta riscrivere tutte le intent definition come RegEx, definire dei file contenenti parole chiave, e annotare in modo dichiarativo il modo con cui ci aspettiamo che l'utente utilizzi la combinazione di parole chiave e intent definition.

Faccio un esempio, così si capisce meglio:

.* (?P<LightName>.*)
Task di accensione luce
.* (alza|aumenta) (?P<LightName>.*)
Task di aumento della luminosità

Per gestire il primo task potremmo avere:

@intent_handler(IntentBuilder("LightOn")
	.require("LightName")
	.require("PowerOnKeyword")
)
def handle_light_on(self, message):
	# ...code...

Dove LightName è obbligatorio, e corrisponde al nome dell'entità da estrarre, e PowerOnKeyword è altrettanto obbligatorio ed è il nome di un file contenente un elenco di parole chiave che ci aspettiamo che l'utente dica per accendere una luce, come ad esempio {accendi, accendere}.

Questo intent parser si presenta molto bene e sembra molto flessibile, infatti lo è. Peccato che apra ad un mondo completamente nuovo di problemi da gestire:

  • Scrivere intent definition troppo generiche può provocare conflitti (che vedremo tra poco).
  • .* è molto comodo, ma se abusato può provocare conflitti.
  • Più RegEx possono matchare la stessa utterance; è un problema quando ci aspettiamo di trovare un'entità ma viene matchata la RegEx senza entità.
  • In alcuni casi, specialmente se le RegEx sono complesse o molto diversificate, Adapt fallisce il matching.

Nel nostro progetto l'ultimo punto ci ha dato molto da lavorare; in particolare per far atterrare l'utente sul task corretto anche in assenza di un match completo, abbiamo dovuto togliere la valutazione delle RegEx da Adapt:

  • Con Adapt controllavamo solo la presenza di determinate parole chiave.
  • La valutazione delle RegEx era gestita da noi, per estrarre le giuste entità.

In pratica ci siamo riscritti il codice per fare matching sulle RegEx, perché quello usato da Adapt non era affidabile e falliva.

Intent in conflitto tra loro

La probabilità di avere conflitti aumenta all'aumentare del numero di task che gestisci e al numero di skill installate dall'utente.

Un conflitto si ha quando una utterance è riducibile a più di un intent. Più le intent definition sono generiche, più è facile avere conflitti.

Se un tuo task va in conflitto con quello di un'altra skill, auguri.

Correzione degli errori

Quando usiamo interfacce grafiche è (relativamente) facile gestire gli errori. È una pratica abbastanza comune usare un codice di colori, icone e testi per evidenziare gli errori (pensa ad esempio a un form da compilare).

Gestire gli errori soltanto con la voce, senza un riferimento visivo, può essere frustrante, fastidioso, lento, confusionario. Il caso peggiore si ha quando l'utente non capisce dove sta l'errore; per questo dobbiamo cercare di offrire sempre un aiuto contestuale.

Cosa succede se chiediamo all'utente di dare un nome ad un nuovo contatto in rubrica, e l'utente risponde "il cognome è Di Paolo"? Chiaramente dobbiamo essere bravi a filtrare via tutta quella zozzeria che gli utenti maldestri tendono a pronunciare.

Ma cosa succede se l'utente dice qualcosa di veramente scomodo, che supera i nostri filtri?

Cosa succede se l'utente dice "il cognome di questa persona è Di Paolo"

Supponiamo di avere un filtro semplice che:

  • Elimina {nome} in quanto parola che consideriamo indesiderata.
  • Elimina {il, di, è} in quanto stopword.

Rimaniamo con "questa persona Paolo".

Brutta cosa: abbiamo perso un pezzo del cognome. Come dobbiamo trattare questa frase? La prendiamo per buona? Ci serve un filtro migliore?

Durante il progetto abbiamo dovuto gestire queste situazioni, che nel nostro caso erano abbastanza frequenti perché a differenza del sistemino per accendere e spegnere le luci, nel nostro caso l'utente doveva generare dei contenuti.

Abbiamo usato due approcci a seconda dei momenti in cui queste situazioni accadevano:

  • In certi casi, ad esempio quando l'utente doveva fornire il nome e il cognome di un nuovo contatto, se dopo la rimozione di parole indesiderate ovvie rimanevano delle stopwords, l'interfaccia chiedeva una conferma.
  • In casi meno critici, semplicemente l'interfaccia accettava l'utterance e la ripeteva all'utente, in modo che quest'ultimo potesse valutare autonomamente se ripetere oppure no.
  • In casi in cui la completa integrità della utterance non era importante (es. su una ricerca), l'interfaccia usava l'output completamente filtrato.

Nessuno di questi approcci è corretto in assoluto: dipende dal contesto.

Controllo di flusso

Questo punto è strettamente legato al precedente. Se il tuo task è complesso (composto da più passaggi, un dialogo), è opportuno dare all'utente la possibilità di gestire il flusso del task, ad esempio ripetendo qualcosa, tornando indietro, saltando dei passaggi non importanti, o di annullare l'esecuzione.

Fornire controllo di flusso è semplice, ma apre ad un mondo di complicazioni: supponiamo di fissare delle parole chiave per il controllo di flusso: {avanti, dietro, salta, annulla}, più qualche variante, sinonimo, o parole con significato simile (ad esempio nel nostro progetto in certi casi valutavamo espressioni come "non me lo ricordo" come un modo per saltare una domanda).

A questo punto gestire queste parole si complica un po': cosa succede se il cognome di un contatto include "salta"?

Nel nostro progetto l'interfaccia chiedeva una conferma quando la situazione era ambigua; quando non la chiedeva, l'utente poteva eventualmente correggere il suo errore perché in ogni caso riceveva un feedback sull'input "compreso" dall'interfaccia, e poteva correggere l'errore ripetendo il pezzo di task.

Giusto, sbagliato, elegante, scomodo? Forse importa poco: i risultati dei test di usabilità ci raccontano che gli utenti riuscivano a concludere i task con meno errori e con più semplicità.

Gestione del contesto

Quando parliamo con qualcuno si viene a formare un contesto, ovvero un insieme di informazioni collegate alla conversazione, che ne descrivono lo stato. Il contesto si arricchisce di continuo, man mano che la conversazione prosegue.

In un interfaccia vocale con task complessi si verifica lo stesso fenomeno, o meglio, si deve verificare; siamo noi sviluppatori a doverlo riprodurre.

In particolare, dobbiamo fare in modo che tutti i dati che l'utente ci fornisce man mano, vengano aggiunti a questo contesto, così da fornire una specie di memoria a breve termine alla nostra interfaccia, o anche a lungo termine se siamo particolarmente ambiziosi.

Gestire il contesto non è affatto banale, ma ad esempio Mycroft ci aiuta tramite un pattern chiamato Conversational Context, in cui ad ogni passo del task corrisponde una funzione con un'annotazione specifica.

Conclusioni

Creare interfacce vocali è difficile. Ci sono molti fattori che influiscono sulla qualità e sull'usabilità del prodotto finale:

  • Tutto ciò che riguarda l'hardware del dispositivo fisico su cui gira il sistema, incluso il microfono.
  • Il sistema operativo e i driver audio installati.
  • Eventuali filtri a monte.
  • Il motore di speech to text.
  • L'intent parser e la qualità delle intent definition.
  • Non ne abbiamo parlato perché non rientra nello scopo dell'articolo, ma anche il motore di text to speech influisce sulla qualità: pensiamo a voci robotiche o che per qualche ragione non sono gradevoli.

Insomma, ci sono molte variabili in gioco, e le scelte tecnologiche sono fondamentali.


Perfetto, se l'articolo ti è piaciuto fammelo sapere in qualche modo. Se poi conosci qualcuno a cui può essere utile, considera di mandarglielo :)

]]>
<![CDATA[Paxos: un protocollo di consenso distribuito]]>https://informaticabrutta.it/paxos-consenso-sistemi-distribuiti/6075a449b5e43b0001fcc0afTue, 13 Apr 2021 14:20:43 GMTPaxos: un protocollo di consenso distribuito

Nello scorso articolo abbiamo visto il tema del consenso e abbiamo capito perché è importante complesso; abbiamo visto i concetti di safety e liveness, fatto qualche esempio, e poi ci siamo lasciati dicendo che nel prossimo articolo (questo) avremmo visto Paxos.

Paxos è un protocollo per raggiungere il consenso in sistemi distribuiti; è safe ma non live, e non tollera nodi bizantini. È stato inventato da quel pazzo genio di Leslie Lamport e regalato al mondo in un paper intitolato The Part-Time Parliament, sotto forma di una falsa ricerca archeologica su una inventata isola greca chiamata Paxos (che poi esiste davvero).

All'inizio di quel paper, Lamport scrive:

Scoperte archeologiche recenti sull'isola di Paxos rivelano che il parlamento funzionava nonostante la "passeggiosa" propensità dei suoi legislatori part-time. I legislatori mantenevano copie coerenti dei registri parlamentari, nonostante le loro frequenti fughe dalla camera e al fatto che i loro messaggi fossero facilmente dimenticati. Il protocollo del parlamento di Paxos ci fornisce un nuovo modo di implementare l'approccio state-machine per progettare sistemi distribuiti.

In pratica Leslie si inventò una finta ricerca archeologica su una civiltà inesistente che aveva un particolare sistema in cui i politici lavoravano part-time. Nessuno ci capì niente di quel paper, tant'è un po' di tempo dopo Lamport pubblicò Paxos Made Simple, e nessuno ci capì niente ugualmente.

Questo - in parte - è un motivo per cui Paxos è così temuto e incompreso. In questo articolo cerchiamo capirci qualcosa.

Funzionamento di Paxos

Paxos è un protocollo che serve a raggiungere il consenso su un valore, e a mantenerlo finché il protocollo è in esecuzione. I nodi che partecipano a Paxos possono avere ruoli diversi, e l'esecuzione del protocollo è suddivisa in turni. Vediamo meglio.

Ruoli dei processi

Abbiamo tre tipi di nodi:

  • Proposer: sceglie un valore da votare e lo propone a tutti.
  • Acceptor: riceve la proposta dal proposer, la vota oppure no.
  • Learner: memorizza il valore votato dalla maggioranza.

Ok, questa è una descrizione molto a grandi linee; ora vedremo meglio come interagiscono tra loro.

Esecuzione del protocollo

Orientativamente, Paxos funziona così:

  1. Un proposer avvia un turno di votazione, inviando a tutti un messaggio che chiamiamo $prepare$.
  2. Gli acceptor che lo ricevono, possono rispondere con una $promise$, impegnandosi quindi a votare per quel turno.
  3. Il proposer sceglie un valore e lo manda a tutti con un $accept$.
  4. Gli acceptor dicono ai learner di imparare quel valore con un $learn$.

In realtà è leggermente più complicato di così, infatti:

  • I messaggi contengono dei dati.
  • Il proposer sceglie il valore in base a una regola.
  • Affinché il valore sia scelto ci deve essere un quorum di acceptor.

Ok, vediamo un esempio.

Esempio 1

Paxos: un protocollo di consenso distribuito

Capiamo prima cosa c'è nell'immagine:

  • P, A, L sono rispettivamente Proposer, Acceptor, Learner.
  • Abbiamo 3 proposer e 5 acceptor, 5 learner.
  • Il grafico mostra le ciò che avviene nel tempo tra proposer, acceptor, learner.
  • Le frecce rappresentano i messaggi scambiati tra nodi.
  • I puntini rappresentano un insieme di messaggi (non disegnati per non fare un delirio grafico)
  • Ogni proposer ha un turno associato:
    • Proposer 1 -> turno 1
    • Proposer 2 -> turno 2
    • Proposer 3 -> turno 3

Ogni proposer in realtà ha più di un turno associato, ad esempio:

  • Proposer 1 -> turno 1,4,7,...
  • Proposer 2 -> turni 2,5,8,...
  • Proposer 3 -> turni 3,6,9,...

Detto questo, vediamo cosa succede nell'esempio:

  1. Il proposer 1 inizia il protocollo e manda a tutti un $prepare(1)$ specificando il turno.
  2. Gli acceptor rispondono con una $promise(1,-,-)$, che contiene:
    • Turno.
    • Ultimo turno a cui ha partecipato (nessuno per ora).
    • Ultimo valore votato (nessuno per ora).
  3. Il proposer riceve più della metà delle $promise$, e può quindi inviare a tutti un $accept(1,5)$, specificando il valore (5) da votare per il turno (1).
  4. Gli acceptor votano inviando ai learner la loro intenzione con $learn(1,5)$.

Carino.

Facciamo una piccola pausa vedendo un po' meglio il formato e il significato dei messaggi.

Messaggi in Paxos

Messaggio Chi A chi Significato
$prepare(r)$ Proposer Acceptor Hey belli, ho avviato una votazione! Il turno (round) attuale è $r$.
$promise(r,lr,lv)$ Acceptor Proposer Egregio proposer, le prometto solennemente di partecipare al suo turno. La informo anche che l'ultima volta che ho votato è stato per il turno $lr$, ed ho votato $lv$. Mi impegno anche a non partecipare a eventuali round $r'$ più vecchi del suo ($r'<r$).
$accept(r,v)$ Proposer Acceptor Ok, il valore che dovete votare per il round $r$ è $v$.
$learn(r,v)$ Acceptor Learner Voto il valore $v$ nel round $r$. Sia scritto nei sacri registri.

Prima di mandare un $accept(r,v)$, il proposer aspetta di ricevere almeno $\frac{n}{2}+1$ $promise(r,lr,lv)$. dove $n$ è il numero di nodi. Questa quantità è chiamata quorum ($q$).

Lo stesso controllo viene fatto dai learner prima di memorizzare il valore mandatogli dagli acceptor.

Esempio 2

Supponiamo di continuare da dove eravamo rimasti nell'esempio precedente. La votazione è andata a buon fine, e il secondo proposer inizia un nuovo turno.

Paxos: un protocollo di consenso distribuito

Ragioniamo:

  1. Il proposer 2 avvia un nuovo turno con $r=2$.
  2. Gli acceptor ricevono il $prepare(2)$ e decidono di prendere parte a quel turno, quindi rispondono con una $promise(2,1,5)$, cioè indicano di prendere parte al turno $2$, e informano che l'ultima volta che hanno votato era per il turno $1$ ed hanno votato $5$.
  3. Il proposer riceve almeno la metà delle risposte propone un valore, che deve per forza essere $5$ (sorpresa, tra poco spiego).
  4. Gli acceptor ricevono la proposta e mandano il learn ai learner.

Anche in questo esempio tutto va per il meglio:

  • Nessun nodo fallisce.
  • Nessun nodo ha fallito nel turno precedente.

In questo esempio abbiamo anche visto una cosa strana:

Perché il proposer deve per forza scegliere 5? Non può scegliere un altro valore? È un proposer, deve proporre un valore, quindi perché non può inventarselo?

Per rispondere a questa domanda ricordiamoci a cosa serve Paxos:

  • Raggiungere il consenso su un valore.
  • Mantenere il valore raggiunto fino al termine del protocollo.

E ci rendiamo conto che il valore di consenso è stato scelto nel turno precedente, ed è proprio $5$, quindi il proposer mantiene quel valore. Infatti soltanto al primo turno il proposer propne veramente un valore.

Un po' più formalmente, dopo che il proposer riceve almeno la metà delle $promise$, applica questa regola:

  • Se in tutte le $promise$ si ha $lr=-$, significa che questo è il primo turno, quindi sceglie arbitrariamente $v$.
  • Altrimenti, sceglie $value[max(lr)]$, cioè il valore $lv$ associato all'ultimo turno.

Riguardo l'ultimo caso c'è da dire che per come funziona il protocollo, c'è la garanzia che questo valore sarà sempre unico, ovvero non è possibile che nello stesso turno ci sia più di un valore votato.

Ora vediamo un esempio in cui si spacca qualcosa.

Esempio 3

Paxos: un protocollo di consenso distribuito

Qui un acceptor muore e rimane morto per tutto il turno. La votazione va comunque a buon fine perché c'è il quorum. La cosa interessante avviene nel turno successivo, quando questo acceptor morto si risveglia; vediamo che dice.

Paxos: un protocollo di consenso distribuito

Nel turno 3 era morto, nel 4 si risveglia e partecipa alla votazione. A questo punto sarà l'unico nodo che nella $promise$ dirà di aver votato l'ultima volta in un altro turno, ma non cambia niente:

  • Il proposer sceglie il valore associato all'ultimo round, ovvero in questo caso $max(lr)=3 \rightarrow lv=5$.
  • Il nodo morto (in questo caso) già ha il valore di consenso (5), perché era vivo quando è stato raggiunto, e non potrà cambiare.

Esempio 4

Vediamo ora cosa succede quando al primo turno ci sono dei morti.

Paxos: un protocollo di consenso distribuito

Due acceptor falliscono e rimangono giù per tutto il turno. Gli altri votano tranquillamente il valore $5$. Il quorum c'è, e tutto va bene.

Paxos: un protocollo di consenso distribuito

Al turno successivo si svegliano e partecipano alla votazione, e non ci stupiamo di vedere che tutto va comunque bene:

  • Questi acceptor sono gli unici che mandano una $promise(2,-,-)$, dicendo cioè di non aver ancora votato.
  • Il valore scelto è comunque $5$, perché $max(lr)=1 \rightarrow lv=5$

Divertente, vero?

Esempio 5

Ora che siamo diventati bravi, vediamo un caso un po' più rotto.

Paxos: un protocollo di consenso distribuito

Due acceptor muoiono prima di ricevere $prepare$, e un altro muore prima di ricevere l'$accept$. Non c'è più il quorum, quindi il valore $5$ non viene scritto.

Paxos: un protocollo di consenso distribuito

Al turno successivo rimane soltanto un acceptor fallito, tutto va bene, e il valore scelto viene finalmente registrato.

Perfetto.

Safety

All'inizio abbiamo detto che Paxos è safe ma non live. Vediamo perché è safe, cioè che qualunque scelta fatta dal protocollo è corretta; in altre parole, non è possibile che in un turno $r'>r$ si scopra che il valore di consenso scelto nel turno $r$ sia sbagliato.

Come prerequisito, dobbiamo prima essere convinti che una volta scelto e votato con successo un valore, in ogni turno successivo il valore non sarà mai diverso. È abbastanza facile convincersene, grazie alla regola che usa il proposer per scegliere il valore: una volta ricevute le $promise$ necessarie a formare un quorum, il proposer sceglie il valore associato al turno più recente. Per questo, una volta scelto un valore $v$ nel turno $i$, in ogni turno $r'>r$ il proposer sceglierà sempre lo stesso valore $v$, ergo non è possibile che in un turno $r'>r$ si abbia un valore $v' \ne v$.

A questo punto se fissiamo $j=max(lr)$ abbiamo due casi su cui ragionare:

Nessun voto

Se $j=-$ significa che nessuno ha votato; più formalmente, significa che $\forall j \le i-1$ dove $i$ è il turno attuale, non ci sono stati voti.

In questo caso è safe fare un $accept(i,v)$.

Qualcuno ha votato

Se $j\ge1$ significa che qualcuno ha votato. Se $i$ è il turno attuale (e $j\le i$), i nodi che hanno votato in questo turno hanno inviato una $promise(i,lr,lv)$ ed hanno quindi promesso anche di ignorare eventuali messaggi vacanti riferiti a turni $i'<i$.

Questo significa che $\not\exists \space v'\ne v$ votato nella finestra temporale $[j;i-1]$, per cui è safe fare un $accept(i,v)$.


Detto in parole semplici:

  • Dato che il proposer sceglie i valori in base a quella regola, si ha la garanzia che una volta scelto un valore, da quel momento in poi il consenso su quel valore viene mantenuto.
  • Dato che le $promise$ hanno anche il significato di ignorare eventuali voti ancora aperti per turni precedenti, si ha la garanzia che una volta scelto un valore in un turno, questo non cambi durante un altro turno.

Mancanza di liveness

È facile accorgersi che Paxos non è live, cioè che esistono dei casi per cui il protocollo non fa mai una scelta. La chiave è proprio nel significato della $promise(r,lr,lv)$, in cui l'acceptor promette al proposer di votare per il turno $r$, ma anche di ignorare tutti i messaggi relativi a turni $r'<r$.

Infatti può accadere questo:

Paxos: un protocollo di consenso distribuito

Cosa succede se un proposer manda un $prepare(r)$ durante la votazione per il turno precedente?

Il protocollo non dice che i proposer devono aspettare il termine di un turno precedente, quindi possono comportarsi come vogliono.

In questo caso un proposer cattivo invia $prepare(2)$ prima che il proposer 1 possa mandare un $accept(1,5)$, e prima quindi che gli acceptor possano votare il valore. Gli acceptor quindi inizieranno a lavorare per il turno 2 e ignoreranno i messaggi del turno 1.

Questa dinamica può andare avanti all'infinito (anche in altre fasi del protocollo), determinando quindi l'assenza di liveness.

Paxos con coordinator

Una possibile variante di Paxos consiste nell'aggiungere un coordinator che gestisce tutti i turni: in pratica fa da proxy tra i proposer e gli acceptor, diventando quindi lo special proposer per ogni turno.

In questo modo si evitano quegli scenari in cui i proposer si fanno i dispetti a vicenda, che determinano la mancanza di liveness.

Ovviamente eleggere un coordinator (leader election) è a sua volta una forma di consenso, quindi ci mordiamo la coda. Quello che in genere si fa è usare un protocollo debole per scegliere il leader, cioè un protocollo che non deve essere safe, ma che ci va bene lo stesso in relazione al nostro obiettivo, ovvero sicuramente eleva un proposer a coordinator.

Flessibilità dei ruoli

Due parole finali su Paxos riguardano i ruoli. Prima abbiamo visto che esistono i ruoli di:

  • Proposer.
  • Acceptor.
  • Learner.

Se ammettiamo la variante vista poco fa, abbiamo anche un Coordinator.

Bene, ma dobbiamo dire che in Paxos i ruoli sono flessibili, cioè in diversi momenti del turno un nodo può assumere anche altri ruoli. Ad esempio lo special proposer di un certo turno, potrà anche essere uno degli acceptor, e sicuramente sarà anche un learner.

Conclusione

Bello, abbiamo visto Paxos e l'abbiamo inquadrato all'interno del contesto più ampio del consenso nei sistemi distribuiti. Abbiamo visto come funziona, quali sono i ruoli dei nodi, e quali messaggi si scambiano e perché; abbiamo fatto alcuni esempi, e visto perché è safe ma non live.

Ci sono alcune estensioni di Paxos, come quella con il coordinatore, ma anche robetta interessante come Multi-Paxos, ovvero un ambiente in cui sono eseguite più istanze di Paxos, ad esempio per gestire il consenso su una sequenza di valori.

Su Multi-Paxos si basa Fast Paxos: un protocollo che introduce un'ottimizzazione nel primo round, a costo di un po' di tolleranza ai fallimenti e un po' di confusione.

Domande? Lascia un commento! Se ti interessa questa roba e vuoi rimanere aggiornato c'è la newsletter 👍

]]>
<![CDATA[Consenso nei Sistemi Distribuiti, Atomic Commit e FLP Theorem]]>https://informaticabrutta.it/sistemi-distribuiti-consenso/600feeb3b5e43b0001fcc045Wed, 27 Jan 2021 19:05:40 GMTConsenso nei Sistemi Distribuiti, Atomic Commit e FLP Theorem

Nell'ambito dei sistemi distribuiti, il consenso è un problema fondamentale. Se abbiamo n processi che eseguono lo stesso protocollo, consenso significa che tutti devono essere d'accordo sulla stessa decisione.

Ma nell'articolo precedente abbiamo visto che i processi possono fallire, in particolare possono andare in crash o essere bizantini. Per questo garantire il consenso non è per niente semplice: come facciamo a mettere tutti d'accordo, se qualche processo muore sempre? Possiamo proseguire anche se una percentuale di processi muore? Come avvisiamo i processi morti resuscitati delle decisioni precedenti? Serve farlo? Cosa succede se troppi nodi falliscono?

Insomma, ci sono una serie di domande a cui ora come ora non sappiamo rispondere, ma procedendo con questo articolo e quelli successivi, avremo un'idea più chiara e potremo darci delle risposte.

Il consenso sta da tutte le parti: quando due client modificano insieme un documento Google, sotto c'è qualche protocollo di consenso; quando usiamo servizi per replicare basi di dati c'è qualche protocollo di consenso; quando navighiamo in giro per Internet e i contenuti ci vengono serviti da una CDN, questa esegue qualche protocollo di consenso.

Ok, belle chiacchiere, ma cosa significa nella pratica consenso?

Ecco, un esempio di problema di consenso è il cosiddetto atomic commit, ovvero una decisione che vogliamo considerare atomica, ma in realtà è composta da varie operazioni. Ad esempio, se siamo più persone e vogliamo scegliere una sola pizza da ordinare potremmo tutti proporre un tipo, ma alla fine ciò che è importante è arrivare a una sola decisione da comunicare al cameriere.

Il nostro ordine quindi diventa atomico, nel senso che le varie proposte individuali non devono più importare perché c'è un'unica decisione finale corretta a cui tutti hanno detto di sì.

Questo problema ha una sua formalizzazione: vediamola.

Atomic commit

  • Tutti i nodi devono essere d'accordo sulla stessa decisione.

  • Non è possibile se il sistema è asincrono e i processi possono fallire (vedremo dopo perché).

  • C'è un processo coordinatore.

  • Ci sono vari processi partecipanti.

  • L'obiettivo è decidere qualcosa su una transazione: commit o abort.

Inoltre abbiamo alcune proprietà:

  • AC1: tutti i processi che raggiungono una decisione, raggiungono la stessa.
  • AC2: un processo non può cambiare idea.
  • AC3: il commit può essere raggiuto solo se tutti i processi hanno votato .
  • AC4: se non ci sono fallimenti e tutti i processi hanno votato , allora la decisione deve essere commit.
  • AC5: dopo una risoluzione dei fallimenti, la transazione deve essere completata (recovery).

Benissimo. Esiste un protocollo per risolvere l'atomic commit? Sì, ed è il commit a due fasi.

2-phase-commit (2PC)

Consenso nei Sistemi Distribuiti, Atomic Commit e FLP Theorem

Il protocollo funziona così:

  • Il coordinator manda una richiesta di voto a tutti i participant.
  • Ogni participant risponde al coordinator con o no.
  • Se tutte le risposte sono , allora manda a tutti un commit; altrimenti manda abort.
  • Ogni participant aggiorna il proprio stato con quello ricevuto dal coordinator.

Possiamo notare che se il participant vota no, può già mettersi in stato di abort, perché sa già che la decisione finale sarà quella.

Bene, carino.

Quali sono i possibili problemi di questo protocollo?

  • Se il coordinator fallisce il protocollo non va avanti.
  • Se i nodi falliscono, potremmo non raggiungere mai il commit.

Per far fronte al problema della debolezza del coordinator come punto centrale di fallimento, possiamo usare il cooperative termination protocol, ovvero uno stratagemma per far terminare il protocollo in una certa situazione.

Cooperative termination protocol

Immaginiamo una situazione in cui il coordinator fallisce prima di inviare la decisione finale.

Consenso nei Sistemi Distribuiti, Atomic Commit e FLP Theorem

Il protocollo non terminerà perché tutti i nodi non riceveranno mai la decisione finale, perché il coordinator si pianta prima di mandarla. Qui c'è poco da fare. Esaminiamo un caso in cui si può fare qualcosa.

Consenso nei Sistemi Distribuiti, Atomic Commit e FLP Theorem

In questa situazione il coordinator fallisce subito dopo aver mandato la decisione finale ad almeno un nodo; ci rendiamo quindi conto che qualcosa si può fare: basta che questo nodo fortunato propaghi la decisione a tutti i suoi amichetti.

Per la precisione, questa propagazione avviene su richiesta: se un nodo è nello stato di incertezza e non riceve nulla dal coordinator, dopo un certo tempo potrà bussare agli altri participant e chiedere se qualcuno di loro ha già la risposta finale.

Con il cooperative termination protocol certamente non risolviamo definitivamente i problemi, ma almeno diamo al protocollo qualche chance in più di terminare con successo.

Recovery

Abbiamo parlato spesso di fallimenti di tipo crash. Ok, cosa fa un nodo quando si risveglia? Sa chi è? Deve chiedere qualcosa agli altri processi? Deve votare di nuovo?

Tutte queste domande possono essere auto-risposte dal nodo, se questo mantiene un suo log locale di tutto ciò che impara. Questo registro è chiamato DTlog, e serve ai nodi per ricostruire gli stati della votazione quando si risvegliano dopo un crash.

Ci sono delle regolette minime di compilazione, ovvero ogni nodo deve scrivere almeno queste informazioni:

  • Il coordinator scrive "start-2pc" all'inizio del protocollo.
  • Quando un participant vota , deve scriverlo prima di mandare il messaggio.
  • Quando un participant vota no, deve scriverlo.
  • Quando un participant riceve una decisione, la scrive.
  • Se il coordinator sceglie commit, deve scriverlo prima di inviare i messaggi.
  • Se il coordinator sceglie abort, deve scriverlo.

Semplice, no?


Concludiamo questo articolo spiegando finalmente perché qui e nel precedente articolo dico che il consenso non si può raggiungere in certe condizioni.

Teorema FLP

Esiste un bel teorema che prende il nome dai suoi inventori: Michael Fischer, Nancy Lynch e Michael Patterson. Questo teorema è anche chiamato FLP impossibility result, ed essenzialmente dice che:

In un sistema distribuito asincrono in cui sono possibili fallimenti di tipo crash, non è possibile avere un protocollo deterministico per raggiungere il consenso.

Questo sembra un po' strano, dato che prima abbiamo visto un semplice protocollo deterministico per realizzare l'atomic commit, che è una forma di consenso; abbiamo anche fatto vedere che in certe situazioni il protocollo può terminare anche se c'è qualche crash. Perché quindi diciamo che non si può raggiungere il consenso?

Beh, perché bisogna capire che queste tre persone hanno definito il concetto di consenso con due proprietà: safety e liveness. In particolare, quando diciamo "raggiungere il consenso" stiamo implicitamente dicendo che lo facciamo in modo safe e live.

In parole semplici, queste due proprietà significano:

  • Safety: la scelta fatta dal protocollo è corretta, nel senso che non è possibile dimostrare (in un momento successivo) che la decisione corretta era un'altra.
  • Liveness: il protocollo consente sempre di raggiungere una decisione (corretta o sbagliata che sia).

Per esempio, il 2PC è banalmente non live, perché se il coordinator fallisce potremmo non riuscire a decidere niente; è invece safe perché una volta collezionati tutti i , la decisione corretta deve necessariamente essere commit.

Per questo spesso leggiamo una versione più esplicita del Teorema FLP, cioè:

In un sistema distribuito asincrono in cui sono possibili fallimenti di tipo crash, non è possibile avere un protocollo di consenso deterministico che sia safe e live.

Possiamo convincerci di questo con una piccola dimostrazione: supponiamo di avere un sistema in cui due nodi devono sempre essere d'accordo su un valore: quindi se un nodo cambia da 0 a 1, anche l'altro deve farlo.

Se ammettiamo la possibilità di avere anche un solo crash, possono accadere due cose:

Consenso nei Sistemi Distribuiti, Atomic Commit e FLP Theorem

  • Nel primo caso un processo cambia il suo valore, ma fallisce prima che l'altro possa accorgersene; a questo punto il processo vivo rimane bloccato perché non sa quale è la scelta corretta. Sacrifichiamo quindi liveness per rimanere safe.
  • Nel secondo caso il processo fallisce prima che l'altro possa accorgersene, ma questo se ne frega e mantiene il proprio valore in nome del progresso; peccato che dopo un po' il morto resuscita e scoprono di essere in disaccordo. Abbiamo sacrificato safety per rimanere live.

Carino eh?

Conclusione

Con questa seconda parte abbiamo affrontato il tema del consenso e capito perché è fondamentale; ci siamo posti delle domande e ci siamo risposti, e abbiamo capito che se i nodi muoiono e siamo in un sistema asincrono, è difficile raggiungere il consenso in modo sia safe che live.

Per esempio il meccanismo di Proof-of-Work che usa Bitcoin per determinare il consenso su un blocco è non-safe e non-live, ma riesce ad essere entrambi con alta probabilità (è infatti non deterministico, ma probabilistico).

Nel prossimo articolo vedremo Paxos, che invece è safe ma non live.


Se hai domande o in generale vuoi dire qualcosa, scrivi un commento :pencil:. Ah, se conosci qualcuno a cui questa roba può essere utile, mandagliela eh :thumbsup:

]]>
<![CDATA[Sistemi distribuiti: concetti base]]>https://informaticabrutta.it/sistemi-distribuiti-introduzione/600c28bfb5e43b0001fcbfa3Sat, 23 Jan 2021 20:26:48 GMTSistemi distribuiti: concetti base

Non c'è bisogno di tanta immaginazione: possiamo dire che un sistema distribuito è composto da una serie di processi che utilizzano lo stesso protocollo per comunicare tra loro, al fine di risolvere un problema comune.

Questi processi possono trovarsi su reti diverse (come in questa immagine) o nella stessa rete. Ciascun processo potrebbe avere ruoli diversi oppure no; questo dipende dal protocollo.

Sistemi distribuiti: concetti base

In un sistema distribuito non abbiamo un punto centrale di fallimento, certo, ma si apre la porta a tutta un'altra serie di questioni complicate che vanno gestite, ad esempio:

  • Potremmo perdere messaggi.
  • La rete potrebbe essere lenta.
  • Qualche nodo potrebbe morire.
  • Qualche nodo potrebbe comportarsi in modo strano.

Questo ci porta ad una semplicissima classificazione di processi e sistemi.

Tassonomia sistemi e processi

Un sistema può essere:

  • Sincrono: i processi hanno un "orologio" globale e affidabile; esiste un time bound sui tempi dei messaggi.
  • Asincrono: non abbiamo un divino orologio globale; non sappiamo quanto tempo impiegano i messaggi ad arrivare.

Quanto ai processi, possiamo classificarli in base ai modi con cui possono fallire:

  • Crash-faulty: possono andare in crash.
  • Bizantini: buggati o malevoli; inaffidabili.

Obiettivo

In generale l'obiettivo di un sistema distribuito è di procedere sempre verso la corretta decisione, che si traduce in due proprietà che determinano l'efficienza dei protocolli utilizzati:

  • Safety: la decisione scelta è sempre corretta.
  • Liveness: il protocollo consente sempre di raggiungere una decisione.

Come vedremo, nel prossimo articolo c'è un teorema che ci dice che in un sistema asincrono con processi proni a crash non è possibile avere entrambe le proprietà. In base alle necessità, c'è bisogno di un compromesso tra le due.


Diagramma spazio-tempo

Ok, passiamo a qualcosa di un po' più serio: come rappresentiamo graficamente un calcolo distribuito? Si può usare un diagramma che qualcuno chiama diagramma spazio-tempo, che ha questa forma:

Sistemi distribuiti: concetti base

A una prima occhiata cosa capiamo?

  • Ci sono 3 processi chiamati $p_k$.
  • In ogni evento ci sono degli eventi chiamati $e_k^i$, dove $k$ è il numero di processo e $i$ è il numero di evento all'interno di esso.
  • Tutti gli eventi sono etichettati in questo modo.
  • Alcune coppie di eventi hanno una freccia: sono scambi di messaggio; altri eventi non interagiscono con altri processi.
  • Il tempo scorre.

Possiamo da subito fissare queste due regolette:

  • Se $i < j$, allora $e_k^i \rightarrow e_k^j$
  • Se $e$ è un evento di invio del messaggio $m$ e $e'$ è l'evento di ricezione di $m$, allora $e \rightarrow e'$

Tagli del sistema

Abbiamo un sistema distribuito che fa cose, e in un certo momento noi vogliamo in qualche modo conoscerne lo stato: abbiamo bisogno di qualche protocollo per fare uno snapshot, ma prima di parlarne nel dettaglio dobbiamo capire alcuni concetti propedeutici, ovvero:

  • Cos'è un stato globale.
  • Cos'è un stato locale.
  • Cos'è un taglio.
  • Coerenzaa dei tagli.

Ciascun processo ha una storia personale, ad esempio nell'immagine precedente $p_1$ può essere rappresentato da ${e_1^1,e_1^2,e_1^3}$: questo è il suo stato locale. Formalmente, il local state del processo $k$ è definito come $\sigma_k^n = e_k^1, ...,e_k^n$.

Se uniamo tutti gli stati locali dei processi del sistema, sorpresa, otteniamo lo stato globale, definito come $\Sigma = (\sigma_1,...,\sigma_i) \space \forall p_{1,...,n}$.

Un taglio non è altro che un particolare stato globale ad un particolare istante. Quando facciamo uno snapshot, otteniamo un taglio.

Sistemi distribuiti: concetti base

Un taglio è la rappresentazione grafica dello stato globale: tutto ciò che sta a sinistra è nello stato; ciò che sta a destra non è ancora accaduto.

Nell'immagine c'è una situazione molto fortunata, ovvero in cui il taglio ottenuto è preciso. Questo si può ottenere solo se il sistema è sincrono, ovvero quando tutti i processi hanno un "orologio sincronizzato" sulla quale possono basarsi. Ma noi vogliamo avere a che fare con sistemi asincroni, in cui un tipico taglio potrebbe avere questo aspetto:

Sistemi distribuiti: concetti base

Questo taglio è problematico, perché è incoerente: abbiamo catturato un evento di ricezione $e_3^2$ ma non il relativo invio $e_1^3$. Un taglio incoerente è un po' come una foto di un evento accaduto, ma senza la causa.

Sistemi distribuiti: concetti base

Un taglio incoerente (o stato globale incoerente, inconsistent cut) è un po' come vedere questa foto, ma con la palla ferma sul dischetto: non può succedere. L'intero sistema è nello stato in cui la palla è stata calciata e il portiere si è buttato, ma la foto catturata mostra ancora la palla ferma (lagga).

Formalmente, un taglio $C$ è coerente se $e \rightarrow e' \wedge e' \in C \Rightarrow e \in C$.
Tra amici, se nel taglio hai l'evento di ricezione, devi avere anche quello di invio.


Snapshot coerenti grazie a Chandy e Lamport

Ok, come possiamo fare degli snapshot sempre coerenti? Esiste il bel protocollo di Chandy-Lamport che ce lo garantisce, assumendo che:

  • I canali siano FIFO: $send_i(m) \rightarrow send_i(m') \Rightarrow delivery_j(m) \rightarrow delivery_j(m')$
  • I canali soddisfino la causal delivery: $send_i(m) \rightarrow send_j(m') \Rightarrow deliver_k(m) \rightarrow deliver_k(m')$.

Dove $send$ e $delivery$ sono rispettivamente gli eventi di invio e ricezione.

La causal delivery non è altro che un FIFO tra coppie di processi, e fa quindi in modo che i messaggi vengano valutati nel giusto ordine anche al lato di ricezione.

Sistemi distribuiti: concetti base

Protocollo di snapshot di Chandy-Lamport

Questo protocollo costruisce sempre degli snapshot coerenti, e per semplificare funziona così:

  • C'è un processo monitor che chiamiamo $p_0$.
  • Tutti i processi si conoscono a vicenda.
  • Il monitor fa partire il protocollo mandando in broadcast un messaggio marker, che significa "fai lo snapshot".
  • Ciascun processo, alla ricezione del marker:
    • Se è il primo che riceve, fa lo snapshot e inoltra il marker a tutti gli altri processi.
    • Altrimenti, smette di ascoltare chi glie l'ha mandato, e aggiunge allo snapshot eventuali eventi la cui ricezione è avvenuta tra il marker precedente e quello attuale.

Sistemi distribuiti: concetti base

Il motivo per cui questo protocollo costruisce sempre stati globali coerenti è proprio nelle due assunzioni:

  • Canali FIFO.
  • Causal delivery.

Infatti non è possibile avere uno snapshot del genere:

Sistemi distribuiti: concetti base

Proprio perché $e'$ verrebbe escluso dalla foto, perché valutato dopo l'istante in cui $p_1$ esegue lo snapshot.

Vector clock

Ok, tutto bello. Abbiamo parlato di alcuni concetti in modo un po' astratto: ad esempio abbiamo detto che la causal delivery è una regola per cui gli eventi vengono valutati nel giusto ordine anche a lato di ricezione; bellissimo, ma come avviene nella pratica? Come fa un processo a sapere l'ordine degli eventi degli altri processi?

Ecco, un modo figo per farlo consiste nell'utilizzare i vector clock, cioè un sistema efficiente per etichettare gli eventi in modo tale che l'ordine sia univoco e comprensibile tra diversi processi. Possiamo vedere i vector clock come un meccanismo di timestamp.

Sistemi distribuiti: concetti base

È più facile dedurre le regole guardando l'immagine invece che scrivendole formalmente, ma comunque ciascun processo:

  • Mantiene un vettore in cui ogni posizione corrisponde a un processo del sistema.
  • Inizializza tutte le posizioni a $0$.
  • Incrementa la propria posizione ad ogni evento locale.
  • Allega il vettore ad ogni evento di invio.
  • Confronta il vettore ricevuto con quello attuale, e si prende i valori maggiori.

In un certo senso, i vector clock ci dicono cosa ciascun processo conosce di tutti gli altri.

I vector clock ci danno anche sette belle proprietà, le cui più interessanti sono:

Strong clock condition

Sia $VC(e)$ il valore del vettore all'istante dell'evento $e$:

$e \rightarrow e' \Leftrightarrow VC(e) < VC(e')$.

Simple strong clock condition

$e_i \rightarrow e_j \Leftrightarrow VC(e_i)[i] \le VC(e_j)[i]$

Weak gap detection

$VC(e_i)[k] < VC(e_j)[k] \wedge k \ne j \Rightarrow \exists e_k \space | \space e_k \not\rightarrow e_i \wedge e_k \rightarrow e_j$

Informalmente, osservando due vettori in $e_i$ ed $e_j$ sui rispettivi processi distinti $p_i$ e $p_j$, si può capire se c'è stato un evento su un altro processo $p_k$ che $p_j$ conosce ma non $p_i$.


Conclusione

Perfetto, questi sono i concetti di base per poter ragionare sui sistemi distribuiti. Abbiamo capito che lavorare su sistemi asincroni non è banale, e che un esempio di questa difficoltà è l'interrogazione di tutti i processi per capire lo stato globale del sistema.

Questo ci ha portato al concetto di taglio e di coerenza.

Abbiamo visto un protocollo per ottenere snapshot sempre coerenti, che è stato tirato fuori da due personcine chiamate Chandy e Lamport.

Alla fine abbiamo visto un vero meccanismo per etichettare gli eventi tramite vector clock, e alcune proprietà interessanti.

I prossimi articoli riguarderanno il tema del consenso distribuito.


Se hai domande o semplicemente vuoi dire qualcosa, lascia un commento qui sotto. Se vuoi anche rimanere aggiornato sul nuovo materiale trovi un form per iscriverti alle newsletter.

]]>
<![CDATA[Matomo: installazione con Docker (docker-compose)]]>https://informaticabrutta.it/matomo-docker/5e63c8722b274800013a50f1Sat, 07 Mar 2020 17:47:57 GMT

Matomo è una piattaforma di analisi web, che consente di ottenere statistiche sui visitatori e sui contenuti dei nostri siti web.

Ciò che contraddistingue Matomo (ma anche altre piattaforme analoghe) dal grosso e facile Google Analytics, è il fatto che con il primo abbiamo il totale controllo e proprietà dei dati.

Matomo: installazione con Docker (docker-compose)

In questo articolo vediamo come fare self-hosting di Matomo, con un setup composto da:

Inoltre assumiamo di aver già un container con un progetto web attivo (es. Apache, Ghost, WordPress), configurato sotto reverse proxy; in particolare per questa guida consideriamo:

Cosa vogliamo

Supponiamo di avere un progetto web su miosito.boh. Noi vogliamo ficcare Matomo in un sottodominio stats.miosito.boh.

Per farlo abbiamo bisogno banalmente di:

  • Configurare un record DNS di tipo CNAME che renda il sottodominio un alias del dominio principale.
  • Installare Matomo ed esporlo su https://stats.miosito.boh.

Dopo aver creato quel record, passiamo al secondo punto.

Preparazione

Creiamo una cartella ~/matomo con dentro:

docker-compose.yml

version: "2"

services:
  matomo:
    container_name: matomo
    image: matomo
    ports:
      - 8080:80
    environment:
      - MATOMO_DATABASE_HOST=matomo_db
      - VIRTUAL_HOST=stats.miosito.boh
      - LETSENCRYPT_HOST=stats.miosito.boh
      - LETSENCRYPT_EMAIL=email@qualcosa.boh
    env_file:
      - ./db.env
    networks:
      - proxy
      - net
    depends_on:
      - matomo_db
    restart: unless-stopped

  matomo_db:
    container_name: matomo_db
    image: mariadb
    command: --max-allowed-packet=64MB
    environment:
      - MYSQL_ROOT_PASSWORD=inventa
    env_file:
      - ./db.env
    networks:
      - net
    restart: unless-stopped

networks:
  proxy:
    external:
      name: nginx-proxy
  net:
    driver: bridge

Cose notevoli:

  • Abbiamo attaccato matomo alla rete già esistente nginx-proxy, su cui gira anche il nostro progetto web.
  • Abbiamo definito una nuova rete net, in cui sono presenti sia matomo che matomo_db, ma non serve che quest'ultimo faccia parte di nginx-proxy.
  • Le variabili d'ambiente LETSENCRYPT_* servono per letsencrypt-nginx-proxy-companion.
  • Abbiamo definito una relazione di dipendenza (e quindi di ordine di partenza) tra i due container.

db.env

MYSQL_PASSWORD=inventa2
MYSQL_DATABASE=matomo
MYSQL_USER=matomo
MATOMO_DATABASE_ADAPTER=mysql
MATOMO_DATABASE_TABLES_PREFIX=matomo_
MATOMO_DATABASE_USERNAME=matomo
MATOMO_DATABASE_PASSWORD=
MATOMO_DATABASE_DBNAME=matomo

Appena siamo pronti docker-compose up -d

Verifichiamo che i nostri bei container siano in esecuzione con docker ps. Se tutto va bene andiamo avanti.

Notiamo che a questo punto dovremmo già avere un certificato SSL attivo e funzionante per il nostro sottodominio.

Installazione di Matomo

Dopo aver fatto partire il nostro stack, se andiamo su stats.miosito.boh saremo accolti dalla schermata di installazione di Matomo.

Ci basta andare avanti fino alla configurazione del DB, che sarà già compilata (grazie alle variabili d'ambiente MATOMO_*), tranne per la password del DB, che dovrà essere il valore di MYSQL_PASSWORD.

Dopo aver confermato, la connessione al DB dovrebbe funzionare e Matomo si installerà senza problemi.

Configurazione iniziale di Matomo

Prima di incollare il codice di tracciamento nel nostro sito, ci conviene configurare l'archiviazione dei report; se non facciamo questa piccola cosa sperimenteremo l'ebbrezza di un sito lentissimo e un downtime alto alto alto.

Cos'è l'archiviazione: è semplicemente Matomo che processa i dati raccolti e ce li rende visibili.

Di default l'archiviazione viene eseguita ogni tanto ed attivata dalle visite dei nostri utenti. Ciò significa che tra le richieste a stats.miosito.boh/matomo.php ce ne sarà qualcuna che dice "Hey Matomo, avvia l'archiviazione".

Ciò che vogliamo è disattivare questo comportamento e mettere il controllo nelle nostre mani, configurando un processo automatico che ogni tot esegue l'archiviazione.

Archiviazione dei report

Semplice:

  1. Accediamo a stats.miosito.boh con il SuperUser definito durante l'installazione;
  2. Andiamo nella sezione Sistema->Impostazioni generali;
  3. Impostiamo l'archiviazione attivata dal browser a No.

Ora dobbiamo configurare un qualcosa di automatico. Abbiamo un paio di strade:

  • Definire un container sidecar chiamato matomo_cron, con la stessa immagine di matomo ed eventualmente gli stessi volumi, con uno script entrypoint che essenzialmente fa due cose:
    • Dorme per tot secondi;
    • Esegue lo script di archiviazione.
  • Definire banalmente un cron job sul sistema host.

In questo articolo seguiamo la seconda strada, anche se un po' meno elegante.

Cron Job

Sul sistema host e con l'utente con cui normalmente gestiamo Docker ed eseguiamo mkdir -p $HOME/logs , poi crontab -e e incolliamo 'sta roba:

5 * * * * if [ $(docker inspect -f '{{.State.Running}}' matomo) ]; then docker exec -t matomo su -s "/bin/bash" -c "/usr/local/bin/php /var/www/html/console core:archive --url=https://stats.miosito.boh" www-data; fi >> $HOME/logs/matomo-archive.log

Dato che lo abbiamo definito con crontab -e, verrà eseguito dall'utente attuale, e i campi hanno (in ordine) questo significato: minuti, ore, giorni, mesi, giorni della settimana, comando.

Se spezzettiamo il nostro codice:

  • Viene eseguito ogni ora al minuto 05;
  • Se matomo è in esecuzione:
    • Esegue l'archiviazione per il sito, con l'utente (nel container) www-data;
  • Sputa l'output in ~/logs/matomo-archive.log.

La linea vuota finale è nelle specifiche di cron e la sua assenza può determinare il fallimento dello script.

Comunque consiglio di testare lo script subito e verificare che tutto sia ok.

Conclusioni

A questo punto possiamo incollare il codice di tracciamento nel sito, e goderci statistiche:

  • Fatte in casa;
  • Facilmente gestibili a livello di privacy;
  • Auto-aggiornanti ogni ora.

Se vogliamo fare i fighi, ci scarichiamo anche l'app mobile di Matomo e guardiamo le statistiche anche da lì.

]]>
<![CDATA[HCI #2: Need finding, task e storyboard]]>https://informaticabrutta.it/hci-need-finding/5e18f3330cc1dc0001e008d7Wed, 08 May 2019 09:40:04 GMTHCI #2: Need finding, task e storyboard

Come dice il nome, il need finding è l'attività della Human-Computer Interaction (HCI) finalizzata alla conoscenza dei bisogni dell'utente.

Il need finding è fondamentale, perché ci permette di individuare esattamente:

  • Problemi dell'utente;
  • Le sue frustrazioni;
  • Il modo di fare;
  • Necessità;
  • Obiettivi.

Il need finding, quindi, ci consente di pensare e progettare un prodotto orientato ai bisogni di chi lo utilizzerà.

Esistono diversi metodi (che qualcuno chiama query techniques) per acquisire le informazioni sui bisogni degli utenti, tra cui interviste e questionari. Prima di vederli, è importante fissare a mente che durante il need finding:

  • Dobbiamo essere aperti a scoperte impreviste;
  • Dobbiamo considerare il contesto: l'ambiente in cui conduciamo il need finding, il tipo di utente, le sue abilità, etc;
  • Dobbiamo notare differenze e similarità tra le persone;
  • Dobbiamo cercare di non influenzare i test;
  • Dobbiamo fare attenzione alla percezione dell'utente.

Prima di andare avanti, facciamo una pausetta con questa frase:

Le soluzioni anticipate limitano le possibilità di capire il problema e le sue possibili soluzioni.

Carina, vero?

Query Techniques: come fare il need finding

Abbiamo cinque modi di raccogliere informazioni:

Interviste

Prendiamo una persona in target, e conduciamo una breve intervista finalizzata a capirne i need.
Alcuni preferiscono prepararsi una scaletta di domande, altri invece una scaletta di argomenti, da modulare in base al carattere di chi abbiamo davanti e a come procede l'intervista.

A ogni modo è importante essere preparati per evitare di far perdere tempo alla gente.

HCI #2: Need finding, task e storyboard

Ci sono delle domande — o meglio, delle formulazioni di domande — che andrebbero evitate, perché possono darci delle informazioni sbagliate, imprecise, esagerate, o comunque poco utili. Queste riguardano:

  • Scenari ipotetici: le persone tendono ad affrontare queste domande in modo aspirativo, cioè rispondendo nel modo che loro credono ci si aspetti da loro. Invece dovremmo prima capire qual è il modo di fare dell'utente, le sue abitudini, e come ha affrontato alcuni problemi simili.
  • Domande che contengono una risposta: "quindi non hai avuto problemi con x?". È facile fare questo errore in certi momenti del laddering (che vedremo tra poco). È meglio evitare di dare alle persone dei mezzi suggerimenti a cui sicuramente si aggrapperanno.
  • Domande troppo generiche: se non approfondite, si rivelano perdite di tempo.
  • Domande con risposta scontata: tollerabili solo per capire se l'intervistato è distratto, pazzo, o ci sta prendendo in giro.
  • Richiesta di consigli o suggerimenti di design: cosa facciamo a fare un corso di HCI se poi facciamo disegnare l'app a un tizio a caso in mezzo alla strada?
  • Frequenze di eventi: "quante volte mangi la pizza in una settimana?". Il problema di queste domande è che richiedono alla gente di fare una stima abbastanza complicata, a mente e senza preparazione. A una domanda del genere io non saprei rispondere, a meno che non mangiassi la pizza esattamente n volte a settimana. In caso contrario, dovrei stimare una frequenza basandomi su ciò che ricordo. Invece conviene chiedere "ricordi l'ultima volta che hai mangiato la pizza?", e la risposta già ci da una migliore inquadratura.

Utenti notevoli

Abbiamo tre categorie di utenti che possono darci informazioni utili durante il need finding:

  • Lead user - gli early adopters dei prodotti:
    • Autonomi;
    • Abbastanza competenti;
    • Beneficiano delle innovazioni;
    • Tirano fuori dei nuovi need.
  • Extreme users - usano servizi con assiduità (es. per lavoro):
    • Hanno i need amplificati;
    • Anche loro sono competenti e autonomi.
  • Esperti del dominio:
    • Hanno conoscenze astratte;
    • Hanno dati aggregati utili;
    • Hanno qualche studio utile.

Prima di andare avanti, tira fuori un paio di esempi per le prime due categorie.
Domanda: se uso IFTTT ed ho almeno una applet che mi collega l'email a qualcosa, posso dire di essere un extreme user dell'email?

Modalità di conduzione delle interviste

Abbiamo cinque tecniche cumulabili e personalizzabili: con una spesa minima di €99

  • Laddering: approfondire in profondità, ad esempio chiedendo di continuo "perché?";
  • Cultural context: domande finalizzate a capire il contesto;
  • Intercepts: una sola domanda secca;
  • Process mapping: farsi descrivere un processo;
  • History: capire il comportamento facendosi descrivere sequenze di eventi.

Riassumendo:

  • Ci sono alcune domande da evitare;
  • Ci sono 3 utenti notevoli;
  • Ci sono 5 tecniche per condurre le interviste;
  • Extra: ovviamente dobbiamo prendere appunti;
  • Extra: se siamo in due è più comodo (uno parla, l'altro scrive);
  • Extra: possiamo registrare l'audio (chiedendo il permesso);
  • Bonus: se questa persona è molto interessata al progetto, magari ci facciamo dare un indirizzo email per ricontattarla in futuro.

Questionari

A differenza delle interviste, i questionari sono orientati all'analisi e ci danno più dati in meno tempo. Anche per i questionari valgono le considerazioni riguardo formulazioni da evitare.

I questionari possono sia essere cartacei che elettronici, ed esistono diversi servizi per crearli (es. Google Forms).

In giro ci sono tantissimi questionari fatti male, ed è facilissimo fare di meglio. In un buon questionario dovremmo:

Evitare errori grammaticali: banale, lo so.

Usare un linguaggio opportuno: se il questionario è rivolto a medici, è meglio dare del lei; se è rivolto a studenti universitari, conviene rilassare i toni e non annoiare.

Forzare l'utente a sbilanciarsi: ad esempio impostando un numero pari risposte alle domande a scelta multipla, in modo che l'utente non possa selezionare la risposta neutrale.

Impedire all'utente di fare scelte incoerenti: quante volte abbiamo visto domande fatte così?

Quali app di messaggistica usi?

  • [ ] WhatsApp;
  • [ ] Telegram;
  • [ ] Slack;
  • [ ] Nessuna di queste.

Cosa succede se io seleziono anche l'ultima opzione?

Questo punto infatti si riduce all'utilizzo di controlli/widget adatti alla domanda (radio buttons, checkboxes, risposta libera, scala, etc).

Utilizzare la logica condizionale: supponiamo di voler mostrare un set di domande diverso, a seconda dell'app di messaggistica che l'utente usa. I servizi di creazione di questionari generalmente permettono di creare roba come (ma anche più complessa di):

  • Se l'utente risponde --> mandalo alla sezione X;
  • Se risponde No --> fai finire il questionario.

Questo ti consente anche di rafforzare il punto precedente.

Come capiamo se il questionario é funzionante e scritto bene? Test pilota: osserviamo una persona mentre risponde al questionario, e prendiamo nota di incomprensioni, problemi, o in generale di cose migliorabili; dopo ogni test sistemiamo il questionario.

In genere bastano 3-5 persone per trovare l'80% dei problemi.

Riassumendo:

  • I questionari ci danno dati quantitativi;
  • Fare un questionario migliore dei 90% che circolano è facile;
  • Richiedono un test pilota.

Diari

Questa query technique prevede la raccolta di informazioni per tempi prolungati.

Camera studies

Qui registriamo a video gli utenti nel contesto, e cerchiamo di capire i loro need dai comportamenti.

Pager studies

Domande precise ad un momento preciso (es. voto alla qualità della videochiamata).


Task e Storyboard

Dal need finding produciamo un elenco di need.
Di questi need tiriamone fuori alcuni più interessanti, da cui deriveremo dei task.

Un task è un obiettivo dell'utente per soddisfare un suo need.

Per esempio in un'app di messaggistica, alcuni task possono essere:

  • Creazione di una nuova chat;
  • Creazione di un nuovo gruppo;
  • Invio di un allegato.

Per ogni task ci interessa uno scenario, cioè:

  • Problema alla base e azione da compiere;
  • Contesto;
  • Utenti e ruoli coinvolti;
  • Ruolo dell'interfaccia;
  • Contesto dopo l'esecuzione del task.

Possiamo rappresentare graficamente queste informazioni con uno storyboard, cioè un disegnino stupido che descrive l'idea generale del task.

HCI #2: Need finding, task e storyboard

Gli storyboard devono essere:

  • Disegnati su carta;
  • A mano libera;
  • Veloci;
  • Facili da leggere;
  • Poveri di testo;
  • Privi di UI.

Perché? Perché non vogliamo perderci tempo.

In conclusione

In questo articolo abbiamo visto che esiste il need finding, e sappiamo perché è importante. Abbiamo visto alcune tecniche per farlo, ed approfondito interviste e questionari. Poi abbiamo visto che dall'analisi dei need possiamo trovare dei task, che confluiscono negli storyboard.

Adesso possiamo andare avanti col prototyping, in cui realizziamo e testiamo pezzi di interfaccia.

Se vuoi essere avvisato quando uscirà il prossimo articolo, lascia la tua email qui sotto ;)

]]>
<![CDATA[Interazione Uomo-Macchina (HCI): Introduzione]]>https://informaticabrutta.it/interazione-uomo-macchina-hci/5e18f3330cc1dc0001e008d6Tue, 07 May 2019 07:05:52 GMT

Interazione Uomo-Macchina, anche chiamata Interazione Uomo-Computer, o in inglese Human-Computer Interaction (HCI).

Questo è ciò di cui parleremo in questa serie di articoli che comincia proprio qui.

Iniziamo con una inquadratura di questo campo di studi. Probabilmente qualcuno è atterrato su questo articolo aspettandosi di leggere qualcosa riguardo l'UX design. E quel qualcuno non ha tutti i torti.

C'è infatti una relazione tra HCI e UX design, che si traduce proprio la loro differenza principale:

  • La HCI nasce prima dell'UX design;
  • Entrambe studiano grosso modo le stesse cose, ma:
  • La HCI è più generale ed accademica, basata su psicologia cognitiva;
  • L'UX design tende ad essere più specializzato ed orientato al business.

In questa serie di articoli vedremo dei concetti fondamentali dell'HCI, con particolare attenzione allo sviluppo di app mobili con metodologia agile-UCD.

Questo è l'indice di tutti gli articoli del corso:

  1. Intro;
  2. Need finding;
  3. Prototyping;
  4. Test di usabilità;
  5. Modelli di sviluppo;
  6. Luogo dell'attenzione, modi, contesto.

Buon divertimento :)

]]>
<![CDATA[7 Cose da sapere prima di passare da WordPress a Ghost]]>https://informaticabrutta.it/wordpress-ghost/5e18f3330cc1dc0001e008d5Thu, 09 Aug 2018 13:23:50 GMT7 Cose da sapere prima di passare da WordPress a Ghost

WordPress è un CMS molto versatile, che consente di costruire quasi ogni tipo di sito. I suoi punti di forza sono:

  • Facilità di personalizzazione;
  • Facilità di estensione;
  • Relativamente semplice da usare.

WordPress è un CMS immediato: una persona con poche o scarse competenze tecniche può facilmente mettere in piedi il proprio sitarello, personalizzarlo, ed usarlo più o meno come vuole.

Allo stesso modo, esistono figure professionali specializzate nello sviluppo di siti basati su WordPress. E quando dico sviluppo, non sto parlando di quella pratica virtuosa che consiste nell'installare WordPress e 30+ plugin, ma parlo di lavori fatti ad arte.

Possiamo dire che WordPress ha sia aumentato le possibilità di esprimersi sul web, sia dato vita ad un gran bel mercato.

Vediamo qualche numero sulla diffusione:

  • Il 30% dei primi 10.000.000 siti nella classifica di Alexa, usano WordPress; [1]
  • WordPress ha un market share del 60% tra i CMS; [1:1]
  • Ogni giorno nella classifica Alexa entrano in media 800 nuovi siti WordPress; [1:2]
  • Esistono almeno 11.000 temi per WordPress;[2]
  • Esistono almeno 50.000 plugin per WordPress; [3]

Nonostante l'enorme diffusione e versatilità, ci sono svariati motivi per voler usare un CMS diverso.

In questo articolo prenderemo in esame un concorrente di WordPress: Ghost.
Ci sono delle cose importanti da sapere prima di iniziare ad usarlo, e tra poco le vedremo.

Un modo diverso per interpretare questo articolo è "perché non passare da WordPress a Ghost".

Disclaimer 1: non è un articolo che parla male di WordPress o Ghost.
Disclaimer 2: in questo articolo uso come riferimento la versione self-hosted di Ghost, perché è quella con cui ho diretta esperienza.

Perché allontanarsi da WordPress

Ci sono vari motivi per cui una persona può decidere di cambiare CMS e migrarci i propri contenuti.

Io, ad esempio, sono passato a Ghost perché mi andava: volevo fare un po' di esperienza su qualcosa di diverso, ed il cambio di dominio ed hosting che avevo già programmato mi sembravano il momento perfetto per farlo.

A parte motivi del genere, le persone serie di solito fanno analisi più intelligenti e contestualizzate alle loro necessità; ad esempio:

  • WordPress è poco sicuro;
  • WordPress è troppo pesante;
  • Non mi fido della qualità dei plugin;
  • Non mi fido dei temi;
  • Ha più cose di quelle che mi servono.

Certo, queste sono critiche applicabili ad ogni CMS mainstream, ma recentemente le sentiamo spesso in discorsi in cui si parla di WordPress.

Oltre ai motivi di gusto personale, abbiamo dei dati leggermente preoccupanti sulla sicurezza.

Proprio grazie alla sua fenomenale diffusione, WordPress è anche ampiamente preso di mira dai cattivoni. Vediamo qualche numero:

  • In uno studio del 2016[4], Sucuri ha rilevato che WordPress è il CMS più bucato;
  • Nello studio di Sucuri, il 70% dei siti compromessi esaminati girava su WordPress;
  • WPScan riporta che il 52% delle vulnerabilità sono da attribuire ai plugin; [5]
  • Il 30% proviene invece dai file core di WordPress; [5:1]
  • Mentre l'11% delle vulnerabilità sono causate dai temi. [5:2]

Questo, ad esempio, è uno dei motivi per cui alcuni vogliono allontanarsi da WordPress.

Perché passare da WordPress a Ghost

Ora vediamo perché comparato con WordPress, Ghost è una scelta così carina:

  • È scritto in NodeJS (va un po' di moda);
  • È minimalista (molto);
  • È veloce e si gestisce da solo la cache;
  • Molto semplice (più di WordPress);
  • Strumenti di base social e SEO già integrati;
  • Orientato al contenuto (sì lo so, dipende dal tema, ma mi sono fatto prendere la mano);
  • Ha una API pubblica;
  • Può funzionare sia con MySQL che con SQLite;
  • I post si scrivono in Markdown;
  • Usa un comodo linguaggio di templating.

Capisco che un paio di punti possono non essere chiari a tutti, ma non importa. Il concetto è che Ghost è un CMS molto diverso da WordPress, ed il target di utenti è altrettanto diverso; di conseguenza, possono cambiare anche i punti su cui discutere.

Entriamo ora nel vivo dell'articolo, altrimenti ci annoiamo troppo.

Da WordPress a Ghost: cosa sapere

Un cambio del genere si traduce in qualche beneficio e qualche maleficio problema. Ho appena accennato ad alcune belle cose ma non ho approfondito, perché questo articolo si parla dei contro e non dei pro.

Vediamo quindi questi punti negativi che ci faranno rimpiangere WordPress. Tutto ciò che trovi scritto da qui in poi serve per farti rendere conto che gestire Ghost è in genere più ostico.

1. Addio hosting condiviso

Eh già. Ghost è scritto con NodeJS, e non esistono hosting condivisi per applicazioni di questo tipo.

Per usare Node abbiamo bisogno di una soluzione dedicata o quasi: dobbiamo almeno avere un VPS.

Questo significa prenderci carico di svariati problemi che nel caso di un hosting troviamo già risolti, ad esempio:

  • Gestione webserver e database;
  • Gestione mailserver;
  • Sicurezza;
  • FTP;
  • Backup e manutenzione;
  • Aggiornamenti;
  • Capacità di intervenire quando e dove serve.

Per usare Ghost in un nostro spazio, dobbiamo ricoprire anche il ruolo di sysadmin, e non è uno scherzo.

Se il tuo battito cardiaco è aumentato ti tranquillizzo dicendoti che in fin dei conti è divertente e si imparano tante cose.

2. Addio plugin per ogni scopo

In WordPress c'è la maestosa comodità dei plugin. C'è un plugin quasi per tutto.

  • Vuoi gestire la cache? Ci sono 20+ plugin;
  • Vuoi un sito più sicuro? Ci sono almeno 2 plugin abbastanza validi e famosi;
  • Vuoi una author box, un form contatti, un page builder? Hai tanti plugin trendy tra cui scegliere;
  • Vuoi creare una squeeze page con l'integrazione di un servizio di email marketing, per vendere il tuo infoprodotto rigorosamente a €97? Ci sono plugin anche per questo.

Con i plugin possiamo aggiungere funzionalità al sito senza scrivere una riga di codice. Questo è uno dei motivi per cui WordPress è così diffuso tra persone non tecniche.

Ecco, scordiamoci dei plugin per Ghost, perché non esistono (ancora).

O meglio, esistono e si chiamano app, ma attualmente ce ne sono solo 4 di ufficiali (preinstallate), e la piattaforma per la loro distribuzione ancora non esiste. Su GitHub, però, possiamo trovare qualche app indie.

7 Cose da sapere prima di passare da WordPress a Ghost

In generale, ad oggi l'unico modo per aggiungere funzionalità a Ghost consiste nello sporcarsi le mani:

  • Modificando il tema;
  • Creando una app per Ghost.

3. Addio temi all-purpose già pronti

Esistono una miriade di temi versatili e spettacolari per WordPress. Molti temi forniscono anche delle funzioni specifiche, usabili con gli shortcode.

In Ghost esistono validi temi, sia gratuiti che a pagamento, ma scordiamoci della versatilità tipica di alcuni temi per WordPress.

7 Cose da sapere prima di passare da WordPress a Ghost

I temi ufficiali sono molto pochi, e gratuiti ancora meno. I posti in cui cercare sono questi:

  • Envato;
  • Collezioni in giro;
  • In giro su GitHub.

4. Non c'è ancora una comunità solida

Ghost fornisce assistenza a quelli che usano Ghost(Pro).
Per tutti gli altri (uh, qui ci siamo noi!) c'è un forum gestito dalla community.
C'è una buona attività ma non possiamo aspettarci un vero e proprio supporto.

In genere in caso di problemi riusciamo a trovare risposte con qualche ricerca su Google (in Inglese), spesso provenientei dagli issues del progetto su GitHub.

5. La migrazione dei contenuti può dare problemi

Ghost ha un plugin ufficiale per esportare i contenuti da WordPress, ma ha alcune falle:

  • Non esporta le immagini;
  • Nel mio caso ha reso il sito WordPress inaccessibile (500 Internal Server Error) finché il plugin era attivo;
  • In alcuni casi può introdurre degli errori di formattazione.

Per le immagini possiamo procedere in questo modo:

  1. Esportiamo tranquillamente i contenuti;
  2. Prendiamo tutte le vecchie immagini dal sito WordPres e le mettiamo nel percorso /content/images/* di Ghost, mantenendo la struttura delle cartelle.
  3. Modifichiamo il file .json dei contenuti esportati, aggiornando tutti i percorsi delle cartelle. Ci basta sostituire /wp-content/uploads con /content/images.

Per gli errori di formattazione dobbiamo necessariamente rivedere gli articoli e correggerli a mano.

Dal lato di Ghost invece, possono esserci problemi se usiamo SQLite come database. In questo caso prima di caricare il file dei contenuti, dobbiamo semplicemente aggiungere la direttiva useNullAsDefault nella configurazione di Ghost (file config.production.json):

"database": {
  "client": "sqlite3",
  "connection": {
    "filename": "percorso/db/ghost.db"
  },
  "useNullAsDefault": true
},

6. Non possiamo gestire altri file statici

Con WordPress possiamo tranquillamente caricare qualsiasi file, che va a finire in /wp-content/uploads/.

Con Ghost non abbiamo questa possibilità. Dobbiamo necessariamente gestire questo aspetto dalla configurazione del webserver.

L'articolo che ho linkato fa vedere un modo possibile, ma non è l'unico corretto.

7. Markdown non piace a tutti

Personalmente reputo Markdown utilissimo e pratico, non solo in contesti da sviluppatori. Anche un fashion blogger può divertirsi a scrivere in Markdown.

Non tutti, però, hanno la stessa opinione. Alcuni preferiscono il classico editor TinyMCE di WordPress. Se non ti piace Markdown, tieni presente che con Ghost sei obbligato ad usarlo.

8. Slug da inserire e mantenere a mano

Questo è un punto che dispiace anche a me. Con WordPress abbiamo la comodità del selettore di pagine dell'editor. È quel dialog che spunta quando vogliamo aggiungere un link, e ci permette di selezionare una pagina del nostro sito invece che inserire l'URL a mano.

L'editor di Ghost non ha questo lusso, quindi dobbiamo:

  • Inserire gli URL a mano;
  • Gestire i redirect se cambiamo slug.

Conclusioni

Abbiamo visto i 7 motivi per non passare da WordPress a Ghost.
Sì, in realtà sono 8 ma nel titolo mi piaceva di più il 7.

Se ti senti pronto per gestire questa montagna di responsabilità, installa al volo Ghost in locale e fai qualche prova.

Se invece questo articolo ti ha fatto capire che forse Ghost non fa al tuo caso, corri ad abbracciare WordPress finché sei in tempo.

In ogni caso, se sei arrivato a leggere fino alla fine significa che l'articolo ti è servito. Puoi darmi un bel feedback condividendolo con qualcuno a cui può essere utile, o anche lasciando un commento.

Grazie.


  1. https://w3techs.com/technologies/overview/content_management/all ↩︎ ↩︎ ↩︎

  2. https://themeforest.com ↩︎

  3. https://wordpress.org/plugins/ ↩︎

  4. https://blog.sucuri.net/2017/01/hacked-website-report-2016q3.html ↩︎

  5. https://ithemes.com/2016/01/14/understanding-wordpress-security-vulnerabilities/ ↩︎ ↩︎ ↩︎

]]>
<![CDATA[Assembly MIPS: Le Basi]]>https://informaticabrutta.it/assembly-mips-basi/5e18f3330cc1dc0001e008d4Wed, 01 Aug 2018 17:08:46 GMTAssembly MIPS: Le Basi

Ecco una roadmap di quello che vedremo oggi:

  • Registri specializzati e non;
  • Syscall;
  • Direttive all'assemblatore;
  • Formati delle istruzioni;
  • Salti condizionati e non;
  • Cicli;
  • Vettori e matrici.

Programmare in assembly è un'esperienza totalmente diversa rispetto a programmare con un qualunque linguaggio ad alto livello. Abbiamo diretto accesso ai registri, e possiamo ottimizzare (o peggiorare) il codice con un alto livello di precisione.

Nella serie di articoli parallela sulla teoria vedremo meglio questo aspetto di ottimizzazione, ma ti anticipo al volo che riguarda principalmente l'ordine delle istruzioni.

In questa serie di articoli utilizzeremo MARS: una IDE MIPS che integra anche un simulatore.

Senza perderci ancora in chiacchiere, cominciamo.

Registri

Come anticipato prima, in assembly abbiamo accesso diretto ai registri. In particolare abbiamo 31 registri (più quelli con la robaccia in virgola mobile), che convenzionalmente servono a fare cose distinte. In realtà possiamo usarli tutti a nostro piacimento, ma renderemmo il nostro codice non in linea con le convenzioni, e rischieremo di confonderci o complicarci la vita.

Ecco i registri standard MIPS:

# Nome Descrizione o uso convenzionale
$0 $zero Contiene solo il valore 0
$1 $at Riservato per le pseudo-istruzioni
$2-$3 $v0-$v1 Valori di ritorno delle funzioni
$4-$7 $a0-$a3 Argomenti delle funzioni, non preservati tra funzioni
$8-$15 $t0-$t7 Dati temporanei, non preservati tra funzioni
$16-$23 $s0-$s7 Dati salvati, preservati tra funzioni
$24-$25 $t8-$t9 Dati temporanei, non preservati tra funzioni
$26-$27 $k0-$k1 Riservato per il kernel
$28 $gp Global Area Pointer (base del segmento dati)
$29 $sp Stack Pointer
$30 $fp Frame Pointer
$31 $ra Indirizzo di ritorno
$f0-$f3 - Valori di ritorno in virgola mobile
$f4-$f10 - Registri temporanei in virgola mobile, non preservati tra funzioni
$f12-$f14 - Primi due argomenti in virgola mobile, non preservati tra funzioni
$f16-$f18 - Dati temporanei in virgola mobile, non preservati tra funzioni
$f20-$f31 - Dati salvati in virgola mobile, preservati tra funzioni

Un paio di note:

  • Nel codice possiamo usare sia il numero di registro (es. $0) che il nome (es. $zero);
  • La differenza tra dati preservati e non preservati tra funzioni è solo convenzionale, e non riguarda un comportamento che avviene in automatico. Per fare un esempio, significa che per convenzione non dovresti usare i registri temporanei per salvare o passare dati tra funzioni;
  • Il registro con la costante $zero esiste perché è molto utile.

Esempi

Prima di andare avanti con i formati, vediamo al volo qualche esempio di istruzioni in ASM MIPS che coinvolgono registri:

# $t0 = $t0 + $t1
add $t0, $t0, $t1

# $t0 = $t0 + 1
addi $t0, $t0, 1

# $s0 = $t2
move $s0, $t2

# $t1 = read_mem($sp + 4)
lw $t1, 4($sp)

Due parole su questi esempi:

  • Abbiamo scoperto che i commenti iniziano con #;
  • Abbiamo scoperto che la sintassi è abbastanza semplice: può o meno sempre nello stile cod_istruz op1, op2 [, op3];
  • Per usare valori costanti c'è una i alla fine delle istruzioni (vedremo perché);
  • L'ultima istruzione (load word) legge dalla memoria all'indirizzo dato da $sp + 4 byte (offset 4), e lo salva in $t1; possiamo anche vedere la situazione come un accesso ad un array che inizia all'indirizzo contenuto in $sp, e il nostro vettore è di word da 32 bit (4 byte), accedere al byte 4 significa accedere logicamente all'indice 1.

Carino eh?

Syscall

Le syscall sono letteralmente chiamate al sistema operativo, che servono principalmente per operazioni di input e output. Un esempio può essere la lettura di caratteri da tastiera, e la scrittura di qualcosa sullo schermo.

Esistono diversi tipi di syscall, identificate da un numero, e funzionano in questo modo:

  1. Carichiamo in un apposito registro il codice della syscall;
  2. Carichiamo gli eventuali valori in appositi registri per gli argomenti;
  3. Chiamiamo la syscall;
  4. Recuperiamo gli eventuali valori di ritorno dagli appositi registri di risultato.

Un esempio tranquillissimo per stampare un numero:

addi $a0, $0, 42    # alternativa a move $a0, 42
li $v0, 1           # carica il servizio "print integer"
syscall             # chiama la syscall 1
# risultato: stampa 42

Allora, capisco che in questo istante la situazione può essere un po' confusa, ma è voluto. Ora rimetteremo insieme i pezzi con un bell'elenco di syscall che MARS mette a disposizione, insieme alle rispettive descrizioni e registri coinvolti.

Servizio Codice Argomenti Risultato
print integer 1 $a0 = intero da stampare
print float 2 $f12 = float da stampare
print double 3 $f12 = double da stampare
print string 4 $a0 = indirizzo della stringa (terminata da NULL) da stampare
read integer 5 $v0 ← intero letto
read float 6 $f0 ← float letto
read double 7 $f0 ← double letto
read string 8 $a0 = indirizzo input buffer
$a1 = num max caratteri da leggere
exit 10
print character 11 $a0 = carattere da stampare
read character 12

Questo elenco è incompleto, perché comprende solo le syscall che useremo con più frequenza in questa serie di articoli. Puoi leggere la lista completa nella documentazione di MARS (premi F1), oppure nella documentazione online.

Esempi di altre syscall sono quelle per generare numeri random, gestire file, chiedere la data, far comparire finestre e fare incantesimi.

Esempio

Prima di provare questo esempio, disabilita le finestre popup per l'input da tastiera; trovi l'opzione sotto Settings.

Facciamo finta di voler prendere due numeri da tastiera, sommarli, e sputare fuori il risultato. Nel farlo vogliamo anche agevolare l'utente con dei messaggi di guida, tipo "Inserisci numero:" e "Il risultato è:".

Potremmo fare una cosa del genere (disponibile anche su GitHub):

.globl main

.data
  prompt1: .asciiz "Intero 1: "
  prompt2: .asciiz "Intero 2: "
  resultDescr: .asciiz "Il risultato è "

.text

main:
  # chiedi numero 1
  la $a0, prompt1
  li $v0, 4
  syscall

  # leggi numero 1
  li $v0, 5
  syscall
	
  # spostalo in $t0
  move $t0, $v0
	
  # chiedi numero 2
  la $a0, prompt2
  li $v0, 4
  syscall
	
  # leggi numero 2
  li $v0, 5
  syscall

  # somma i due numeri
  addu $a1, $v0, $t0
	
  # salva testo del risultato
  la $a0, resultDescr
  li $v0, 4
  syscall
	
  # carica il risultato dove serve
  move $a0, $a1
	
  # stampa intero
  li $v0, 1
  syscall
	
  # termina il programma
  li $v0, 10
  syscall

In questo esempio abbiamo anche introdotto delle cosette nuove. Vediamo.

  • Direttive:
    • .globl main indica all'assemblatore che il simbolo main può essere acceduto anche da un altro file;
    • .data delimita l'inizio del segmento data, ovvero il contenitore dei dati statici nel file oggetto:
      • .asciiz indica che la stringa tra virgolette è ASCII e terminata da NULL (byte 0).
    • .text delimita l'inizio del segmento text, ovvero il contenitore delle istruzioni nel file oggetto.
  • Simboli:
    • main è in questo caso il nostro punto di ingresso.

Quando eseguito, il programma fa questo:

  1. Con la load address (la) carichiamo in $a0 l'indirizzo della stringa del primo prompt;
  2. Carichiamo il servizio print string (4) con la load immediate (li);
  3. Eseguiamo la syscall per far comparire la stringa;
  4. Carichiamo il servizio read integer (4) e facciamo partire la syscall;
  5. Ora il numero letto è disponibile in $v0, ma per comodità lo copiamo in $t0;
  6. Ripetiamo i passi 1-4 per il secondo prompt e numero;
  7. Sommiamo i numeri con una addu (add unsigned) per non dover gestire un eventuale overflow. In caso lo volessimo, possiamo usare una semplice add;

Il resto è abbastanza capibile avendo analizzato quello che succede prima. Terminiamo l'esecuzione con la syscall 10.

Direttive all'assemblatore

Ne abbiamo viste un paio poco fa, ed ora le spieghiamo in generale. Con le direttive diciamo all'assemblatore:

  • Come gestire certe cose (es. allineamento dei valori al byte);
  • Come preparare il file oggetto (es. inizio dei segmenti);
  • Cosa metterci (es. dati statici);
  • Se usare dei nomi più umani per i registri;
  • Se raggruppare una sequenza di istruzioni in macro.

Le direttive iniziano con il punto, e quelle che useremo sono:

Direttiva Descrizione
.text Inizio del bocco istruzioni
.globl x Indica che la label x è accessibile da un altro file
.data Inizio del blocco dei dati statici
.eqv $nome, $reg Permette di usare $nome per riferirci a $reg
.macro e .end_macro Definisce una macro

All'interno del blocco .data possiamo definire dati statici in questi modi:

.data
label: .tipo val1, val2, ..., valn  # n valori separati da virgola
label: .tipo val:n                  # n valori ripetuti 

Dove .direttiva può essere una di queste:

Direttiva Utilizzo Esempio
.align k Allinea i prossimi dati alla potenza k^2 .align 2
.space Riserva n byte s: .space 255
.word Alloca spazio per delle word n: .word 1,2,3
.half Alloca spazio per della half-word h: .half 0:10
.asciiz Alloca un testo ASCII terminato da 0 txt: .asciiz "Salve"
.ascii Alloca un testo ASCII txt: ascii "Salve"

Dobbiamo spendere due parole sulla direttiva .align.
Se la usiamo, l'assemblatore farà in modo che tutti i dati siano allineati di un certo numero di byte, dato da 2^k. In altre parole, le posizioni di inizio dei dati saranno allineate con una precisione pari a 2^k.

Di solito si utilizza .align 2 per allinare dati a multipli di 4. Il motivo è semplice: dato che l'hardware è ottimizzato per trasferire word (4 byte), facciamo in modo che il loro recupero sia più facile (la posizione di inizio dei dati è facilmente calcolabile, perché ha precisione di una word).

Il lato negativo di questa direttiva è che crea inevitabilmente dei buchi in memoria.

Le macro ci permettono di definire blocchi di codice come se fossero delle funzioni. Durante l'assemblamento, vengono rimpiazzate dal codice che contengono.

Un esempio di macro può essere:

.macro takeInt (%regDst)
	li $v0, 5
	syscall
	move %regDst, $v0
.end_macro

Questa macro generalizza il comportamento della presa in input di un intero, come abbiamo visto prima. Le macro si chiamano nel codice come se fossero funzioni ad alto livello, quindi:

# ...
takeInt ($s0)
# ...

Nice.

Fermiamoci un attimo

Prima di passare ai salti (che sono carinissimi), dobbiamo trattare una cosa noiosa e brutta, che però ci serve per capire perché in MIPS non possiamo fare cose virtuose come lw $t0($t1), ed anche per capire cosa significa quella i in istruzioni come addi, subi, muli, etc.

Per questo direi di fare una pausa, in cui ti fai un po' di esercizi per consolidare queste poche conoscienze che abbiamo visto. Alcune idee:

  • Crea una variante a piacere dell'esempio visto prima;
  • Prendi in input due interi ed un carattere separatore, e stampa queste cose separate dal carattere dato:
    • Numero 1;
    • Numero 2;
    • Il loro prodotto;
    • La loro somma + 42.

Formati delle Istruzioni

Rieccoci qui.

Questa parte va a braccetto con lo schema della CPU MIPS ad un colpo di clock, quindi se non l'hai ancora visto ti consiglio di farlo più o meno ora.

Dobbiamo sapere che le istruzioni che utilizziamo in ASM MIPS sono un'astrazione che ci maschera la scrittura di sequenze di bit; a basso livello, infatti, un'istruzione è una sequenza di bit che viene data in pasto alla CPU.

In opportune fasi di esecuzione, altrettanto opportune unità funzionali della CPU prendono i pezzi del'istruzione che servono (in base ai segnali di controllo attivi).

Qui ho parlato in un modo abbastanza offuscato, perché sono cose che vediamo meglio nell'articolo di teoria che ho linkato qualche riga fa.

Possiamo anche osservare che le istruzioni MIPS possono essere divise in 4 gruppi, in base al loro funzionamento. Infatti abbiamo istruzioni che:

  • Operano solo su registri;
  • Operano su registri, e costanti;
  • Fanno dei salti:
    • In base a certe condizioni;
    • In ogni caso.

Da qui deriva l'esistenza di 3 codifiche di istruzioni MIPS.
I vero: i numeri non quadrano; il motivo è che possiamo riassumere quei 4 comportamenti in 3 sole codifiche.

Codifica R (Register)

Queste istruzioni operano solo su registri (es. add, move), ed il loro formato è questo:

oc rs rt rd shamt
bit 6 5 5 5 5

Alcune istruzioni di tipo R sono add, sub, and, xor, sra, jal.

Codifica I (Immediate)

Ecco finalmente il significato di quella i. In MIPS le istruzioni che hanno uno spazio per una costante numerica si chiamano immediate.

oc rs rt imm
bit 6 5 5 16

In questa categoria ricadono anche le istruzioni che eseguono salti condizionati, ovvero quelle della famiglia branch (le vedremo tra poco).

Alcuni esempi di istruzioni di tipo I sono addi, andi, slt, bne, lui, lw, sw, lb.

Codifica J (Jump)

Qui abbiamo i salti incondizionati, cioè quelli che vengono eseguiti sempre, senza necessità di soddisfare una condizione.

oc dest
bit 6 26

Abbiamo solo due istruzioni di questo tipo: j e jal.

Bene, ci siamo tolti questa parte.

Salti

Li abbiamo appena introdotti, ed ora ne parliamo meglio.

Come accennato poco fa, abbiamo due tipi di salti:

  • Condizionati: branch;
  • Non condizionati: jump.

Salti non condizionati

Questo tipo di salto è semplicissimo. In pratica ogni volta che si incontra un'istruzione di questo tipo, saltiamo alla destinazione indicata.

La sintassi è:

tipo_jump label

Dove label è l'etichetta che denota l'istruzione a cui saltare. Vediamo:

Tipo jump A voce Uso
j Jump j label
jal Jump and Link jal label

La jal salva il contenuto del program counter (PC) in $ra, e serve per chiamare funzioni salvando l'indirizzo da cui riprendere l'esecuzione una volta terminate. Vedremo meglio nel prossimo articolo.

Branch

Branch (o diramazioni) sono quelli che in un linguaggio ad alto livello corrispondono agli if.

In MIPS non abbiamo la stessa flessibilità di quel tipo di costrutti, ma è molto più rudimentale. La sintassi qui è:

tipo_branch $reg1, $reg2, label

In cui i due registri vengono confrontati in base al tipo di operazione di branch, ed in caso positivo l'esecuzione salta all'istruzione denotata da label.

Esistono branch in cui si compara con 0, ed hanno una sintassi più compatta.

Tipo branch A voce Uso Salta se op1 ? op2
beq Branch if equal beq $t0, $t1, label ==
bne Branch if not equal bne $t0, $t1, label !=
blt Branch if less than blt $t0, $t1, label <
ble Branch if less or equal ble $t0, $t1, label <=
bgt Branch if greater than bgt $t0, $t1, label >
bge Branch if greater or equal bge $t0, $t1, label >=
beqz Branch if equal to zero beqz $t0, label
bnez Branch if not equal to zero bne $t0, label

Ti viene in mente un modo per usare un branch per saltare sempre?
Il primo che risponde bene nei commenti vince un panino.

PRO TIP: Salvati la MIPS Reference Card.

Cicli

In MIPS non abbiamo istruzioni che ci permettono puramente di creare dei loop, ma possiamo farli combinando banch e jump.

A seconda del tipo di ciclo che vogliamo, abbiamo dei pattern.

Alcune note:

  • Uso branch come placeholder per beq, bne, etc.;
  • Uso etichette dummy (come do, while, for) per far vedere le analogie, ma puoi (leggi "devi") usare etichette univoche e adatte al contesto.

Do-While

Se ad alto livello vogliamo:

do {
  // roba
} while (cond)

In MIPS abbiamo:

do:
  # roba
  branch $a, $b, do

While

Alto livello:

while (cond) {
  // roba
}

MIPS:

while:
  branch (not_cond), endWhile
  # roba
  j while
endWhile:
# resto del programma

Da notare che dobbiamo negare la condizione.

For

Alto livello:

for (i; i<n, i++) {
  // roba
}

MIPS:

for:
  branch not_cond endFor
  # roba
  addi $i, $i, 1
  j for
endFor:
# resto del programma

Anche qui dal punto di vista concettuale dobbiamo negare la condizione iniziale.

Facciamo (ancora) una pausa

Prima di ucciderci con i vettori, conviene fermarsi a fare qualche esercizio.
Anche qui, ecco qualche idea:

  • Calcolatrice: dati in input due interi ed un'operazione codificata come intero, stampa il risultato del calcolo. Poi confronta con questo esempio;
  • Somma cumulativa:
    • Prendi in input un numero n;
    • Prendi in input n interi, e man mano sommali al registro $s0;
    • Stampa il risultato.
  • strlen: stampa la lunghezza di una stringa presa in input;
  • str2int:
    • Prendi una stringa e iteraci sopra;
    • Per ogni carattere, stampa il suo valore intero (hint: tabella ASCII);
    • Se il carattere è maiuscolo (stesso hint), aggiungi il suo valore intero ad una somma comulativa;
    • Al termine stampa la somma comulativa.

Quando risolvi gli esercizi (anche a piacere), mettili su GitHub e linkali nei commenti ;)

Vettori e Matrici

Un vettore è una struttura dati in cui sono presenti dati contigui della stessa lunghezza. Sono facilmente immaginabili in memoria come un segmento spezzettato.

Quello che bisogna sapere è che l'accesso ad un vettore è diretto: significa che dato un indirizzo di partenza (base) ed uno scostamento (offset), accediamo direttamente alla posizione che ci interessa, senza passare per quelle precedenti (cosa che avviene, ad esempio, nelle liste).

Ad esempio, ragionando in simil-C:

type vettore[2] <=> vettore + 2*sizeof(type)

In assembly MIPS, quindi, ci basta replicare il ragionamento appena visto: per accedere ad un elemento dobbiamo:

  1. Prendere l'indice attuale i;
  2. Moltiplicarlo per la dimensione del tipo;
  3. Sommarlo all'indirizzo di partenza del vettore.

Per fare un esempio, supponiamo di voler accedere alla posizione 2 di un vettore di interi V:

.eqv $i, $t0		# indice
.eqv $offs, $t1		# offset
.eqv $data, $t2		# dato letto

addi $i, $0, 2		# i=2
sll $offs, $i, 2	# offset=i*4
lw $data, V($offs)	# data=read_mem(V+offset)

Da notare la riga sll: facciamo lo shift left logical di 2 posizioni perché equivale a moltiplicare l'indice per 4, cioè la dimensione in byte di un intero (word).

Se invece operiamo su un vettore di byte (una stringa, ad esempio), non serve moltiplicare.

La situazione è un po' più complicata per le matrici, perché dobbiamo scordarci del comodissimo accesso con righe e colonne, perché non esistono. Dobbiamo immaginare di avere una riga lunghissima che contiene tutte le "righe" della matrice.

Ci accorgiamo che per accedere ad un elemento dobbiamo:

  1. Prendere la base;
  2. Calcolare lo scostamento in verticale (offset riga): riga*dimRiga;
  3. Calcolare lo scostamento in orizzontale (offset colonna): colonna*sizeof(tipo);
  4. Sommare i due offset alla base.

Quindi abbiamo che:

int M[X][Y];

M[x][y] <=> M + y*Y + x*sizeof(int)

Hai a disposizione qualche esempio sia per vettori che matrici.

Conclusioni

Carino MIPS eh?

Con questo articolo abbiamo imparato le basi della programmazione in assembly MIPS, ed abbiamo tutto quello che ci serve per passare allo stadio successivo, in cui vedremo roba un po' più virtuosa: giocheremo con lo stack e la ricorsione.

Prima di passare al prossimo articolo (quando uscirà), ti consiglio di:

  • Prendere familiarità con MARS, specialmente con il debugger;
  • Aver ben chiari i pattern per creare cicli;
  • Aver ben chiaro come operare su vettori.

Un extra ben accetto è sperimentare con le macro.

Se vuoi fare il figo, cimentati nella riduzione del numero di branch: ristruttura il codice in modo da evitare i branch non necessari.

Come sempre, se hai domande usa i commenti. Se hai risolto o vuoi proporre qualche esercizio/esempio, mettili in una repo su GitHub e segnalalo nei commenti.

]]>
<![CDATA[CPU MIPS ad Un Colpo di Clock]]>https://informaticabrutta.it/cpu-mips-un-colpo-clock/5e18f3330cc1dc0001e008d3Wed, 01 Aug 2018 14:53:20 GMTCPU MIPS ad Un Colpo di Clock

In questo articolo vedremo e commenteremo lo schema della CPU MIPS ad un colpo di clock, per capire a cosa servono certe unità funzionali.

Ecco lo schema semplificato di una CPU MIPS ad un colpo di clock.

CPU MIPS ad Un Colpo di Clock

Capisco che a prima vista può essere una brutta esperienza, ma per tranquillizzarti ti dico che non è terribile da capire, e dopo un po' di ragionamento diventa anche un po' simpatico.

Le fasi di un colpo di clock

Come inizio è utile tenere a mente le fasi di esecuzione di un'istruzione:

  • Fetch: l'istruzione viene presa dalla memoria istruzioni;
  • Decode: l'istruzione viene spacchettata, e i singoli pezzi assumono il loro significato;
  • Execute: l'ALU esegue un calcolo;
  • Memory: eventualmente il risultato dell'ALU o la lettura da un registro viene scrita in memoria, oppure la memoria viene letta;
  • Write-Back: eventualmente il risultato dell'ALU o la lettura da memoria viene scritta in un registro.

Ora è abbastanza veloce vedere delle similitudini con lo schema:

  • Fetch: comprende il PC (Program Counter) e l'adder vicino;
  • Decode: comprende lo spacchettamento dell'istruzione, l'unità di controllo e la lettura dai registri;
  • Execute: riguarda la parte in cui l'ALU opera;
  • Memory: accesso alla memoria dati;
  • Write-Back: l'uscita del mux in basso a destra.

È opprtuno però far presente che nella CPU ad un colpo di clock questa separazione delle fasi non è proprio netta; capiremo meglio il motivo quando vedremo la CPU con pipeline.

Formato delle Istruzioni

In architettura MIPS abbiamo 3 tipi di istruzioni: R, I, J. Nell'articolo sull'assembly MIPS approfondisco questo aspetto, ma ai fini di questa spiegazione ci serve solo in generale il formato delle istruzioni.

Tipo Formato (bit)
R oc (6) rs (5) rt (5) rd (5) shamt (5) funct (6)
I oc (6) rs (5) rt (5) imm (16)
J oc (6) dest (26)

Alcuni argomenti sono comuni tra R ed I, mentre altri variano a seconda del formato. Facciamo chiarezza:

  • oc: è l'opcode dell'istruzione, ovvero un codice che la identifica (e quindi ne determina il formato);
  • rs: registro sorgente;
  • rt: registro target;
  • rd: registro destinazione;
  • shamt: shift amount; se l'operazione è uno shift, rappresenta il numero di posizioni da shiftare;
  • func: insieme al segnale di controllo AluOp determina la funzione da passare all'ALU;
  • imm: parte immediata, ovvero una costante;
  • dest: costante che indica l'indirizzo a cui saltare.

Segnali di Controllo

Dopo aver letto l'opcode, l'unità di controllo attiva opportuni segnali di controllo, affinché la CPU si comporti bene. Sullo schema i segnali di controllo sono evidenziati in blu, e sono:

  • RegDst: sceglie il registro di destinazione tra rd ed rt. È utile perché permette di supportare le due codifiche R ed I senza aggiungere troppa circuiteria. Semplicemente, a seconda del formato viene scelto uno dei due argomenti come destinazione;
  • Jump: attivo se l'istruzione è della famiglia jump;
  • Branch: attivo se l'istruzione è della famiglia branch;
  • MemRead: attivo se l'istruzione legge dalla memoria;
  • MemToReg: attivo se il dato letto dalla memoria deve essere schiaffato in un registro;
  • AluOp: identifica l'operazione da far eseguire all'ALU (riporto le istruzioni):
    • 00: addi, lw, sw;
    • 01: beq, bne;
    • 1x: dipende dal campo func:
      • 32: add;
      • 34: sub;
      • 36: and;
      • 37: or;
      • 42: slt.
  • MemWrite: attivo se l'istruzione scrive in memoria;
  • AluSrc: attivo se il secondo operando dell'ALU deve essere il campo imm;
  • RegWrite: attivo se l'istruzione scrive in un registro.

Molto carino. Ora tutti i segnali di controllo hanno un senso e non ci spaventano più; quindi possiamo passare ad altro.

Analisi dello Schema

Spezziamo lo schema in blocchi, e commentiamolo.

Fetch

In questa fase dobbiamo prendere la prossima istruzione dalla memoria istruzioni. Sappiamo che tutte le istruzioni sono lunghe una word (4 byte), quindi abbiamo un bell'adder che fa solo il calcolo indirizzo_attuale + 4;

La gestione di salti o branch avviene nella fase successiva, cioè quando abbiamo i segnali di controllo ben impostati.

Decode

Qui dobbiamo spacchettare l'istruzione ed in base all'oc attivare i segnali di controllo opportuni. Vediamo meglio come la CPU si comporta.

Branch e Jump

Qui fa comodo analizzare insieme questi due segnali di controllo, per vedere come si altera il PC:

  • Se Branch è attivo, significa che dobbiamo saltare se la condizione è vera. Il controllo della condizione viene fatto dall'ALU con una differenza tra i valori dei due registri da controllare. Se il risultato è 0 significa che la condizione è verificata, e quindi dobbiamo saltare. Per questo abbiamo Branch AND 0 che attiva il mux che sceglie tra il risultato dell'adder visto prima, e l'indirizzo relativo del salto;
  • Se Jump è attivo, dobbiamo saltare all'indirizzo contenuto nel campo dest, quindi il PC viene aggiornato con l'indirizzo del salto.

Ti lascio una domanda che ti serve per ragionare meglio su questa fase: che succede se Jump e Branch sono entrambi attivi?

Ora in base ai valori dei campi dell'istruzione, vengono letti i dati dai registri.

Execute

Abbiamo gli argomenti dell'ALU e l'operazione da eseguire. L'ALU calcola, ed il risultato può rappresentare:

  • L'indirizzo da cui leggere in RAM;
  • L'abilitazione al salto di un branch;
  • Un valore da scrivere in un registro.

Memory

In base a quanto detto poco fa ed ai segnali di controllo, si può leggere o scrivere.

Write-Back

Semplicemente:

  • Se MemToReg è attivo, il dato letto viene buttato direttamente nel registro di destinazione;
  • Altrimenti viene scritto il risultato dell'ALU.

Abbiamo finito.

Conclusione

Per riordinare le idee, oggi abbiamo scoperto:

  • Come è fatta una CPU MIPS ad un colpo di clock;
  • Quali sono le fasi di esecuzione;
  • Quali segnali di controllo esistono, e come vengono attivati;
  • Come i segnali di controllo influiscono nel flusso di esecuzione;
  • Che i segnali di controllo sono importanti;

Per fissare meglio questi concetti, ti consiglio di fare questo tipo di esercizio:

  1. Prendi un'istruzione;
  2. Ragiona sui segnali di contollo che devono essere attivi;
  3. Segui il flusso di quell'istruzione.

Ad esempio, prendiamo lw $reg, ADDR.
I segnali di controllo saranno:

  • RegDst: 0;
  • Jump: 0;
  • Branch: 0;
  • MemRead: 1;
  • MemToReg: 1;
  • AluOp: sum;
  • MemWrite: 0;
  • AluSrc: 1;
  • RegWrite: 1.

Continua tu con altre istruzioni. Buona fortuna!

Per qualsiasi cosa, lascia un commento.

]]>
<![CDATA[Markdown: Guida Completa 2019]]>https://informaticabrutta.it/markdown-guida/5e18f3330cc1dc0001e008cfSat, 06 Jan 2018 11:48:27 GMTMarkdown: Guida Completa 2019

Markdown è un linguaggio di markup leggero, creato nel 2004 (e lasciato lì) da un tizio chiamato John Gruber.

Data l'età e l'apparente abbandono, sembra inspiegabile come molti siti supportino Markdown; esempi rilevanti sono:

  • GitHub;
  • BitBucket;
  • Reddit;
  • Stack Exchange;
  • Facebook (parzialmente).

Più alcuni software importanti come Slack e WhatsApp, che usano qualche piccola cosa che gli assomiglia vagamente.

In realtà non è così inspiegabile, dato che questi siti/servizi utilizzano varianti di Markdown, principalmente a causa di:

  • Mancanza di uno standard chiaro di Markup;
  • Falle in giro da correggere;
  • Funzioni significative mancanti.

Per questo è corretto dire che in questo articolo non parleremo di Markdown puro, ma di GitHub Flavored Markdown (per gli amici GFM).


Prima di entrare nel vivo, voglio dare al volo una definizione che ci serve per capire quali sono i vantaggi di un linguaggio del genere.

Cos'è un Linguaggio di Markup Leggero?

Il nome suggerisce e non suggerisce. Mi spiego meglio.

Un linguaggio di markup è, in sostanza, un set di regole per aggiungere struttura ad un documento, in un modo che sia chiaramente distinguibile dal testo vero e proprio.

Possiamo dire che un linguaggio di markup leggero è più umano rispetto a semplice markup, nel senso che:

  • È facilmente leggibile in formato grezzo;
  • La sintassi è semplice e non fastidiosa;
  • Leggibile e scrivibile con qualsiasi editor di testo;
  • Convertibile in HTML senza tanta fatica.

Se guardiamo un qualsiasi pezzo di codice Markdown, ci accorgiamo di quanto queste caratteristiche siano azzeccate.

Perfetto. Direi che possiamo andare avanti.

Guida Markdown

Ti dico subito come è strutturata questa guida. Questo argomento è abbastanza semplice e tecnico, non si presta a divagazioni varie e robe teoriche, quindi può essere tranquillamente espresso in modo rapido e asciutto.

Quello che troverai ora sarà una serie di sezioni in cui vedremo come fare cosa. In alcuni punti lascio aperte delle questioni per incoraggiare prove da parte tua.

Ritorni a capo

In Markdown un ritorno a capo è dato lasciando una riga vuota.

Questo è il primo paragrafo. Questa linea non è un ritorno a capo. Questo, invece, è un altro paragrafo; quindi il ritorno c'è

Intestazioni

Con intestazioni mi riferisco a quelli che in HTML sarebbero i vari tag H, quindi H1-H7.

In Markdown creiamo le intestazioni aggiungendo il carattere #.

# H1
## H2
### H3
#### H4
##### H5
###### H6

In alternativa, per gli H1 e H2 possiamo usare quest'altro formato:

H1
===
H2
---

Enfasi

Possiamo formattare il testo usando i soli caratteri {*, _, ~}.

*Corsivo* e sempre _corsivo_.
**Grassetto** e sempre __grassetto__.
**Grassetto e pure _corsivo_**.
Infine ~sbarrato~

Liste

Scrivere liste con Markdown è tanto intuitivo quanto potente.

Liste non ordinate (ul)

- Elemento 1
- Elemento 2
* Notato la seperazione?
* Altro elemento
+ Notato l'altra separazione?

Liste ordinate (ol)

1. Uno
2. Due
3. Tre
4. Quattro
8. <-- la numerazione è cambiata?

Possiamo anche avere liste miste. Prova a ficcare una lista non ordinata in una ordinata (o viceversa) e osserva che succede.

Il modo di creare link in Markdown ricorda un po' quello usato da MediaWiki.

[Link](https://www.google.it/)
[Link con titolo](https://www.google.it/ "Google")
[Riferimento][chiave del riferimento]
Ora metto un [altro riferimento] [chiave del riferimento]: http://qualcosa
[altro riferimento]: http://altro-qualcosa

Qui possiamo aggiungere qualche parola:

  • Possiamo usare numeri come chiave di riferimento;
  • URL da soli, o circondati da <> vengono automaticamente tradotti in link.

Immagini

Simile ai link, con l'aggiunta del punto esclamativo.

![alt-text](url/immagine.png "Titolo")

Possiamo usare i riferimenti anche per le immagini.

Tabelle

Molto semplice, basta anche qui usare qualche carattere.

Prodotto|Q.tà
--------|----
Legno|10
Staffa|20
Vite|10
Stop|10

Citazioni

Basta usare il carattere >.

>Questa è una citazione

Linea orizzontale (hr)

A volte può essere utile dare una separazione netta al testo. Con Markdown possiamo usare:

Separatore 1:
---
Separatore 2:
***
Separatore 3:
___

Basta avere almeno tre caratteri.

Checklist

Guarda che carino:

- [x] Fatto
- [ ] Non fatto

Codice e Syntax Highlighting

Con Markdown possiamo distinguere codice inline e a blocchi. Otteniamo il syntax highlighting scrivendo il nome del linguaggio all'inizio del blocco.

Delimitiamo codice inline con la sequenza ```, e blocchi con `````.

Questo è un `codice inline`.
\`\`\`java
void doThings(SyntaxHighlighting sh) {
    sh.getInstance()
        .setEnabled(true)
        .setLanguage("python");
}

Emoji

C'è chi le ama e chi le odia.

:smile:, :laughing:, :wink:, ...

Qui trovi un elenco di emoji e chi le supporta.

Ignorare la formattazione Markdown

Il backslash è il nostro carattere di escape:
Non voglio \*formattare\* il testo.

Conclusioni

Concludiamo vedendo alcuni editor che offrono funzioni specifiche per questi file con estensione *.md o *.markdown:

  • StackEdit: cloud editor con interfaccia web, con supporto per import/export e conversione;
  • Dillinger: altro cloud editor con simili funzioni del precedente;
  • Typora: editor minimalista WISIWIG;
  • Boostnote: definito come app di note open source per sviluppatori.

Tutti questi editor supportano anche funzioni di Markdown Extra, come espressioni matematiche stile LaTeX, diagrammi di flusso e UML, tipografia smart, note a fondo, sommario.

In alternativa, esistono svariati plugin per editor generali come Atom, Visual Studio Code, Sublime Text, etc.

Perfetto, grazie per la lettura. Se ti va, sentiti libero di condividere l'articolo :).

]]>
<![CDATA[Memoria Virtuale: cos'è e come funziona?]]>https://informaticabrutta.it/memoria-virtuale/5e18f3330cc1dc0001e008ceThu, 28 Dec 2017 17:50:59 GMTMemoria Virtuale: cos'è e come funziona?

Nello scorso articolo sulla gestione della memoria, ci siamo lasciati accennando alla segmentazione con paginazione, e dicendo che per parlarne avremmo prima dovuto affrontare la memoria virtuale.

Il che è corretto, perché per capire come funziona la segmentazione paginata, dobbiamo conoscere almeno a grandi linee la memoria virtuale.

In questo articolo non andremo molto sul pesante, ma faremo un percorso un po’ in generale, per capire cos’è, come funziona, e i vantaggi della memoria virtuale. Tutto questo per rispondere all’interrogativo dell’articolo precedente.

Cos’è la Memoria Virtuale?

La memoria virtuale è un meccanismo del sistema operativo che permette principalmente di far girare programmi più grandi della RAM. Dà l’illusione ai processi di avere spazio potenzialmente illimitato. Le conseguenze immediate sono queste:

  • Possiamo avere processi più grandi dello spazio libero in RAM;
  • Possiamo elaborare dati più grandi dello spazio libero in RAM.

Sembra una stregoneria, perché sappiamo che tutto ciò che deve essere elaborato deve risiedere in memoria primaria. Infatti la cosa che rende geniale la memoria virtuale, è che usiamo il disco come se fosse RAM.

Memoria Virtuale: cos'è e come funziona?

In realtà non è proprio così, o meglio non è del tutto corretto.

Dobbiamo dire che usiamo la memoria secondaria per appoggiare temporaneamente pezzi di processi/dati che non ci servono in questo momento, e che probabilmente non ci serviranno per un po’ (ricordi il principio di località?).

Immagina questo scenario:

  1. Devi eseguire un processo;
  2. Carichi solamente le prime pagine in RAM;
  3. Le restanti le allochi sul disco;
  4. Man mano che il processo richiede altre pagine, le prendi dal disco e le swappi in RAM;

Oppure:

  1. Hai alcuni processi in RAM;
  2. Devi caricare un altro processo ma non hai spazio libero;
  3. Prendi un pezzo di un altro processo, le cui pagine non sono utilizzate da un bel po’ di tempo;
  4. Nello spazio appena liberato vai a caricare le prime pagine del nuovo processo.

Una bella salvata, no?

Abbiamo appena introdotto un termine: swap, che indica un trasferimento tra memoria primaria e secondaria; più precisamente:

  • Swap in: disco –> RAM;
  • Swap out: RAM –> disco.

Memoria Virtuale: cos'è e come funziona?

Indirizzamento nella Memoria Virtuale (Segmentazione su Paginazione)

Ora che sappiamo cos’è la memoria virtuale, vediamo di capire come funziona l’indirizzamento.

Dato che usiamo RAM e disco un po’ allo stesso modo, ci conviene avere un indirizzamento che tenga conto di questa proprietà. Infatti gli indirizzi virtuali sono usati sia per indirizzare pagine in RAM che su disco. Magnifico eh 🙂

Un indirizzo virtuale è una tripla formata da <numero segmento; numero pagina; offset>.

Al di sotto di questo indirizzo abbiamo i due livelli già familiari di segmentazione e paginazione.

Riga tabella dei segmenti: <bit di controllo; lunghezza; base del segmento>;
Riga tabella delle pagine: <P; M; bit di controllo; numero frame>.

Ci siamo resi conto che l’indirizzamento delle pagine ora è leggermente diverso. In particolare abbiamo aggiunto due bit P ed M.

  • P (present): indica che quella pagina è attualmente in RAM, e non su disco;
  • M (modified): indica che quella pagina è stata modificata.

Memoria Virtuale: cos'è e come funziona?

Prima di proseguire con la traduzione degli indirizzi, è furbo ricordare che:

  • Ogni processo ha la sua tabella dei segmenti, che indirizza punti di partenza e lunghezza dei suoi vari segmenti;
  • Ogni processo ha varie tabelle delle pagine, che indirizzano varie pagine;
  • Ogni segmento indirizza una specifica tabella delle pagine.

Bene, vediamo lo schema generale dell’indirizzamento.
Se trovi qualcosa che non quadra, ridai una letta alle tre cose che abbiamo ricordato un attimo fa.

Memoria Virtuale: cos'è e come funziona?

Può essere utile avere una telecronaca dell’immagine appena vista:

  1. Abbiamo un indirizzo virtuale, quindi: un numero di segmento, un numero di pagina ed un offset;
  2. Abbiamo anche il puntatore alla tabella dei segmenti per il processo;
  3. Prendiamo la tabella dei segmenti, e andiamo alla riga denotata dal numero di segmento;
  4. In quella riga troviamo l’indirizzo della page table per quel segmento;
  5. Prendiamo quella tabella delle pagine, e andiamo alla riga denotata dal numero di pagina;
  6. In quella riga troviamo il numero di frame;
  7. Perfetto: prendiamo la RAM ed andiamo all’indirizzo denotato da quel numero di frame, e spostiamoci in base all’offset.

Nice.

Perfetto. Abbiamo finito la prima parte di questo articolo. Passiamo alla seconda ed ultima.


Politiche per lo Swap di Pagine

Ok, sappiamo cos’è lo swap.

Immagina ora di avere una situazione in cui il sistema è in costante stato di swap in e out.
Questo fenomeno si chiama thrashing.

Dato che lo swap è costoso, con il thrashing andiamo a devastare pesantemente le prestazioni del sistema.
Vogliamo evitare tutto ciò, e ci accorgiamo che lì all’angolino c’è sempre il solito principio di località che ci fa l’occhiolino.

Perfetto, usiamo il principio di località per caricare le pagine in modo saggio.

Fetch Policy (politiche di caricamento)

Questo tipo di politica decide come caricare le pagine in RAM.

Quando c’è un riferimento ad una pagina non presente in memoria, abbiamo un page fault, che innesca il meccanismo di fetch, oppure di *replacement *(che vedremo tra poco).

Abbiamo due tipi di fetch policy:

  • On-Demand Paging: carica pagina appena viene referenziata; inizialmente provoca molti page fault;
  • Prepaging: carica diverse pagine contigue (principio di località, again).

Replacement Policy (politiche di rimpiazzo)

Decide quale pagina rimpiazzare, se necessario.
Queste politiche sono abbastanza importanti, infatti abbiamo diversi algoritmi per realizzarle.
La bontà di un algoritmo è determinata dal numero di page fault che innesca.

Le pagine sostituite vengono poi inserite in un page buffer, che tipicamente divide tra pagine modificate e non.
Dopo un page fault, prima di caricare la pagina dal disco, il gestore della memoria ne controlla la presenza nel page buffer.

Vediamo questi cinque algoritmi di rimpiazzo delle pagine.

OPT (Sostituzione ottimale)

È un’utopistica sostituzione ottimale delle pagine.
Non è realizzabile, perché prevede la sostituzione della pagina che verrà usata più tardi, che non possiamo sapere.

LRU (Least Recently Used)

Sostituisce la pagina meno usata di recente.
Ha bisogno di un’etichetta che rappresenti il tempo di utilizzo.

FIFO (First In – First Out)

Sostituisce la pagina presente in memoria da più tempo. È come un buffer circolare.
Semplice e carino, peccato che non sfrutta il principio di località. Quindi ci fa schifo.

Clock

Su questo algoritmo c’è un po’ di più da dire. Lo chiamiamo clock perché possiamo rappresentarlo graficamente come un orologio in cui:

  • Al posto dei numeri abbiamo pagine;
  • Ad ogni pagina è assegnato un use bit (o reference bit) con valori {0, 1}
  • La lancetta funge da puntatore.

Da un punto di vista generale, possiamo dire che l’algoritmo di clock funziona così:

  • Il puntatore punta alla pagina più vecchia;
  • Inizialmente l’use bit è 0;
  • Quando si referenzia una pagina, il suo bit incrementa ad 1; il bit delle pagine incontrate dal puntatore (lancetta) fino alla pagina referenziata viene settato a 0;
  • La pagina da rimpiazzare è la prima che la lancetta incontra con use bit a 0.

Questo algoritmo è anche chiamato second chance, perché alle pagine con use bit 1 viene data una seconda possibilità di rimanere in memoria.

Qui abbiamo un’ottima illustrazione sia dell’algoritmo clock che g-clock:

G-Clock

L’algoritmo Generalized Clock è – appunto – una versione generalizzata del clock, in cui:

  • L’use bit ha valori nel range [0, infinito);
  • La pagina referenziata incrementa il contatore, e tutti quelli incontrati dalla lancetta vengono decrementati;
  • La pagina da rimpiazzare è la prima con use bit 0.

Come scritto poco fa, puoi trovare l’illustrazione di questo algoritmo nel video precedente.

Ricapitoliamo gli algoritmi di sostituzione delle pagine

Perfetto, prima di toglierceli di torno, vediamo al volo un esempio pratico.
Supponiamo di avere spazio per tre pagine, e vediamo il comportamento di questi algoritmi a confronto.

Memoria Virtuale: cos'è e come funziona?

Come hai osservato, all’inizio abbiamo esattamente gli stessi page fault (indicati in rosso); è normale, perché vengono referenziate pagine che ancora non abbiamo in memoria.
Troviamo differenze dalla quinta richiesta in poi.
In questo particolare esempio, vince l’algoritmo LRU (ce ne freghiamo dell’OPT), ma non è sempre così.

Osservazione sui benefici di avere pagine piccole

Ora che conosciamo bene:

  • Paginazione;
  • Segmentazione;
  • Memoria Virtuale;
  • Segmentazione su Paginazione;
  • Significato di page fault;
  • Principio di località;
  • Importanza delle replacement policy.

Possiamo fare una piccola osservazione sui benefici di avere pagine piccole.

Avere pagine piccole comporta:

  • Minor frammentazione interna;
  • Process Page Table più grandi:
    • PPT grandi su disco, e il disco è ottimizzato per trasferimenti in blocco (quindi di grandi quantità); quindi siamo felici.
  • Più pagine caricabili in memoria:
    • Pochi page fault.

Che possiamo riassumere in modo più gradevole con quest’immagine.

Memoria Virtuale: cos'è e come funziona?

Insomma è proprio carino avere pagine piccoline.

Conclusione

Splendido: con questo articolo e il precedente abbiamo concluso il discorso sulla gestione della memoria. È molto probabile che io continui a scrivere roba di questo tipo sui sistemi operativi. Se vuoi essere avvisato quando li pubblicherò, iscriviti al volo alle newsletter (sono poche, concise e non invasive).

In ogni caso, grazie per aver letto fino alla fine :).
Se hai apprezzato l’articolo, che ne dici di condividerlo? Saresti tipo un eroe.

Per qualsiasi cosa, sentiti libero di lasciare un commento.

Licenze per le icone usate nelle immagini: RAM e Hard Disk by Double-J Design, con licenza CC BY 4.0.

]]>
<![CDATA[Gestione della Memoria in un Sistema Operativo]]>https://informaticabrutta.it/gestione-memoria-ram-sistema-operativo/5e18f3330cc1dc0001e008cdSat, 09 Dec 2017 16:45:20 GMTGestione della Memoria in un Sistema Operativo

La gestione della memoria principale (RAM, primaria, o come vogliamo chiamarla) è uno degli aspetti più importanti dei sistemi operativi. Come tutte le risorse di un computer, anche la RAM deve essere gestita secondo i soliti principi di equità e prestazioni.

In particolare, la gestione della memoria deve:

  • Essere economica (basso overhead);
  • Massimizzarne l'uso.

Detto un po' meglio, non si deve sprecare tempo CPU per mettere e togliere roba dalla memoria primaria, e si deve usare tutto lo spazio a disposizione in modo efficiente.

Prima di iniziare a parlare più concretamente, ci serve qualche piccolo formalismo. Andiamo.

Qualche concetto di base

Per parlare della gestione della memoria useremo spesso dei termini specifici. Non è detto che tutti li conoscano, per questo metto qui un piccolo glossario che può far comodo.

  • Allocare: memorizzare qualcosa in memoria;
  • Deallocare: rimuovere qualcosa dalla memoria;
  • Indirizzo [di memoria]: indirizzo esadecimale che corrisponde ad un preciso punto nella memoria.

Perfetto, entriamo un po' più nel dettaglio.

Requisiti per la Gestione della Memoria

Per garantire efficienza e facilità di gestione, protezione di dati, e un minimo di portabilità del codice, abbiamo bisogno di questi bei requisiti.

Protezione e Condivisione

Generalmente, non vogliamo che un processo possa accedere ad aree di memoria di altri processi.
Immagina di avere un password manager in esecuzione: in RAM hai sicuramente le tue credenziali in chiaro; sono sicuro che non gradiresti che un qualsiasi processo possa accedere a quel pezzo di memoria, e leggere le tue credenziali.

Gestione della Memoria in un Sistema Operativo

Allo stesso tempo però, vogliamo anche far sì che alcune zone di memoria siano condivise tra processi.
Immagina di avere una libreria grande 500 kB, che viene usata da 20 processi: invece di avere la libreria copiata in ognuno dei 20 processi (per cui 500*20 kB = 10 MB), la lasciamo condivisa e risparmiamo almeno 10 MB.

Ho unito questi due requisiti in un unico punto perché sono strettamente correlati. Appena parliamo di condivisione, introduciamo anche il concetto di protezione, e viceversa.

Rilocazione

Questo è un bel concetto; vediamo che significa.

Sappiamo che:

  • La dimensione della RAM non è la stessa per ogni dispositivo;
  • Il sistema operativo decide dove allocare in memoria;
  • Un processo può essere tolto dalla memoria, e rimesso in seguito (swapping);

Di conseguenza abbiamo che la zona che un processo occuperà in memoria non è mai certa. Non è per niente prevedibile.

Ma un processo deve essere eseguito normalmente, a prescindere dalla sua posizione in memoria.
Un processo deve poter eseguire le sue istruzioni senza pensare a dove realmente esse siano in RAM.

Gestione della Memoria in un Sistema Operativo

Questa è l'essenza della rilocazione: rendere il processo indipendente dalla sua posizione in memoria.

In particolare, la visione di un processo è confinata alla sua area di memoria, quindi a quella delimitata dall'indirizzo della prima istruzione e quello dell'ultima. Queste due informazioni vengono memorizzate in due registri specializzati, chiamati rispettivamente base register e bounds register.

In pratica, si mette in atto la rilocazione introducendo degli indirizzi relativi, chiamati anche logici.
Il processo usa questi indirizzi relativi, che poi vengono tradotti dal sistema operativo in indirizzi fisici reali.

Osserviamo che parlando di rilocazione, abbiamo anche – implicitamente – parlato di protezione della memoria.

Per finire questo concetto, parliamo dei due tipi di rilocazione.

Rilocazione Statica

Primitiva e brutta. In fase di caricamento del processo, tutti gli indirizzi logici vengono tradotti in fisici, calcolandoli a partire da quello iniziale.

  • Pro: nessuno;
  • Contro: il processo non è swappabile –> la memoria non può essere riorganizzata.

Rilocazione Dinamica

Carina e moderna. Traduciamo gli indirizzi a runtime.

  • Pro: possiamo rilocare il processo in giro per la RAM –> possiamo riorganizzare la memoria;
  • Contro: nessuno.

Organizzazione della Memoria

Dobbiamo distinguere tra quella fisica e logica.

  • Fisicamente, in memoria attuiamo l'overlaying, ovvero possiamo allocare diversi moduli/processi nella stessa zona di memoria, ma in tempi diversi. Inoltre la gestione viene fatta in maniera trasparente all'utente/programmatore.
  • Logicamente, vediamo la memoria in modo lineare, con permessi per ciascun modulo/processo.

Ottimo, ci siamo levati la parte più pallosa e prolissa.
Un applauso a te che stai ancora leggendo. Questo è per te:

Gestione della Memoria in un Sistema Operativo

Ora le cose iniziano a farsi un po' serie.

Gestione della Memoria (ora ci siamo)

La memoria primaria è uno spazio lineare in cui il sistema operativo alloca e dealloca robe. Queste due operazioni sembrano molto banali a parole, ma in realtà sono un bel problema.

Negli anni sono state introdotte diverse idee per rendere possibili queste operazioni. I sistemi operativi moderni usano delle tecniche chiamate segmentazione e paginazione (anche usate insieme), che consentono di usare la memoria principale con efficienza e senza sprechi.

Ma non è stato sempre così. Inizialmente siamo partiti con una tecnica abbastanza primitiva, ma che all'epoca poteva anche andare bene: il partizionamento.

Ora inizieremo da questo concetto, ed arriveremo pian piano alle tecniche di oggi.

Partizionamento della Memoria

L'idea di fondo di questa tecnica cavernicola, era di dividere la RAM in blocchi di una certa lunghezza, chiamati partizioni.
Le caratteristiche principali erano:

  • Un programma in ogni partizione;
  • No programmi a cavallo di partizioni.

Avevamo tre tipi di partizionamento:

  • Fisso;
  • Variabile (fisso a dimensione variabile);
  • Dinamico.

Ti anticipo che i primi due sono molto simili, ed hanno più o meno gli stessi problemi e limitazioni, mentre il terzo è leggermente più carino.

Partizionamento Fisso

L'idea era di avere partizioni di lunghezza fissa.

Questa tecnica richiedeva un supporto hardware non da poco: infatti le memorie RAM dovevano uscire dalla fabbrica già partizionate.

Gestione della Memoria in un Sistema Operativo

È anche abbastanza intuitivo capire le limitazioni:

  • Numero limitato di processi caricati simultaneamente in memoria;
  • Dimensione massima dei processi data dalla grandezza delle partizioni.

Inoltre, qui si introduce un problema chiamato frammentazione interna: come puoi vedere dall'immagine, abbiamo diversi processi che sono più piccoli delle partizioni, quindi in ogni partizione abbiamo dello spazio non utilizzabile.

La gestione delle memoria in questo caso non è neanche lontanamente efficiente. Sembra di buttare all'aria metà di questo articolo. Ma procediamo con ordine.

Partizionamento Variabile

Qualche persona intelligente si era resa conto che c'era un modo abbastanza intuitivo per ridurre la frammentazione interna.

Il ragionamento di questo/a signore/a era all'incirca così:

Se abbiamo frammentazione interna, significa che abbiamo programmi più piccoli delle partizioni.
Allora, se avessimo partizioni con lunghezze diverse, potremmo allocare i programmi nelle partizioni di dimensione più simile.

Magnifico, meraviglioso. Ecco l'idea rappresentata graficamente.

Gestione della Memoria in un Sistema Operativo

Avevamo sempre un numero fisso di partizioni, ma le loro dimensioni erano incrementali.

Questa roba poteva essere gestita con una coda globale, oppure una coda per ogni partizione.

In ogni caso, anche questo metodo ha dei problemi seri:

  • Riduce la frammentazione interna, ma non la risolve;
  • Richiedeva ancora un un partizionamento a livello hardware;
  • Numero limitato di processi;
  • Dimensione massima del processo fissata;

Il caso peggiore di questa tecnica consiste nell'avere molti processi piccoli, o molti grandi.
In questo caso, tutti questi processi faranno a gara per accaparrarsi la partizione per loro migliore (che è la stessa).

Terrificante.

Partizionamento Dinamico

Un'altra persona un intelligente, a questo punto ha detto:

Eh no ragazzi, non possiamo continuare così. Questi metodi non sono scalabili e sono pieni di problemi. Perché non ci svincoliamo da queste robe fisse ed antiche?

Ed ecco che qualcuno si è inventato il partizionamento dinamico. Eccolo qui nel suo splendore.

Gestione della Memoria in un Sistema Operativo

Che bello, guarda che bellezza:

  • Non servono più memorie già partizionate;
  • **Partizioni create ad-hoc **a runtime;
  • Addio frammentazione interna;
  • Addio limiti di processi;
  • Ora la grandezza massima del processo coincide con la dimensione della RAM;

Oh no, abbiamo frammentazione esterna :(.

Immagina qualcosa come:

  1. Sei processi Pi con i=1…6 vengono allocati dove il SO decide;
  2. Ad un certo punto P2 termina, e viene deallocato –> buco lasciato da P2;
  3. Arriva P6 e deve essere allocato:
    1. Non riesce ad entrare nel buco, perché è troppo grande;
  4. Viene allocato dopo P5, perché è l'unico posto in cui riesce ad entrare.
  5. P5 passa una certa fase, e libera un po' di memoria –> buco tra P5 e P6;
  6. Arriva un nuovo processo P7, che riesce ad entrare nel buco tra P2 e P3:
    1. Viene allocato lì dentro;
  7. Il buco si restringe, ma la sua nuova dimensione è troppo piccola per accogliere altri processi.

Questa situazione è un esempio per esporre il problema della frammentazione esterna.
Immagina una libreria in cui metti e togli libri, oppure li sposti. Prima o poi arriverai ad un bel casino in cui hai:

  • Buchi;
  • Disordine.

Per questo ogni tanto dobbiamo fare un'operazione chiamata compattazione, che riordina tutto in modo da non avere buchi.

Il problema è che la compattazione ha un overhead alto; in pratica, è un'operazione costosa, e non possiamo permetterci di farla molto spesso.

Infatti esistono diversi algoritmi per decidere dove allocare nuovi pezzi di processo. Sono tantissimi: quattro.

Gestione della Memoria in un Sistema Operativo

Best-Fit
  • Processo allocato nello spazio più piccolo che lo può contenere (il migliore, per definizione);
  • Alto overhead, perché trovare questo spazio richiede tempo;
  • A lungo termine ci porta più frammentazione esterna;
First-Fit
  • Processo allocato nel primo spazio libero a partire dall'inizio della memoria;
  • Basso overhead;
  • A lungo termine tende ad affollare la parte iniziale della RAM;
Next-Fit
  • Come il first-fit, ma a partire dall'ultima posizione allocata (idea derivata dal principio di località);
  • Basso overhead;
  • A lungo termine tende ad affollare la parte finale della RAM.
Buddy System

Questa tecnica è un po' più complessa delle precedenti, e l'idea di base consiste nel dividere la memoria in blocchi chiamati buddies, ed allocarci il processo se rispetta una certa condizione. Vediamolo meglio:

  • Tratta la memoria come un blocco lungo 2u;
  • Se un processo richiede di allocare uno spazio s, tale che 2u-1 < s < 2u, alloca l'intero spazio;
  • Altrimenti, spezza la memoria libera in due buddies lunghi 2u/2, e procedi ricorsivamente.

Detto in parole umane: se il processo è più grande della metà dello spazio libero, allocalo lì; altrimenti spezza in due lo spazio libero ed applica ricorsivamente la regola.

Paginazione della Memoria

Finalmente siamo arrivati alle cose serie.

Se ci piacciono le comparazioni, possiamo dire che questa tecnica usa alcuni principi del partizionamento fisso, ma in chiave moderna.
Sì, forse questa descrizione è più adatta a recensire un album musicale, ma non è sbagliata. Vediamo perché.

Con la paginazione, sia la memoria che i processi vengono suddivisi in **piccoli blocchi di dimensione fissa **(tipicamente 512 kB);

Introduciamo al volo la terminologia:

  • Pezzi di memoria: pagine;
  • Pezzi di processo: frame.

Gestione della Memoria in un Sistema Operativo

Per ogni processo il sistema operativo mantiene una tabella delle pagine, che mappa frame a pagine.

Possiamo vedere un indirizzo di memoria come una coppia formata da <numero di pagina; offset>.

Dato che questo sistema è l'erede del partizionamento fisso, ci portiamo dietro il problema della frammentazione interna, ma talmente ridotto (data la dimensione delle pagine) che possiamo trascurarlo e non pensarci.

Segmentazione della Memoria

Quest'altro sistema è invece erede del partizionamento dinamico.

Infatti ogni processo viene suddiviso in segmenti di lunghezza variabile.

Un indirizzo di memoria è formato da <numero segmento; lunghezza segmento>.
Per ogni processo abbiamo una tabella dei segmenti, che mappa la coppia al corrispondente indirizzo fisico.

La segmentazione permette di vedere la memoria come uno spazio di indirizzi. C'è un problema però: i segmenti possono non essere contigui; in questo caso lo spazio degli indirizzi non è contiguo, ma frammentato.

Questo rende le cose un po' scomode e poco eleganti. Noi vorremmo un modo per astrarre questo meccanismo, e far sembrare al programmatore di avere uno spazio contiguo tutto per lui.

Riusciamo a compiere questo passo introducendo la memoria virtuale.

Intanto, per concludere il discorso, ti anticipo come risolviamo il problema.

Segmentazione su Paginazione (o Segmentazione Paginata)

Questo metodo combina segmentazione e paginazione. In pratica, usiamo la segmentazione per indirizzare le pagine.
In questo modo riusciamo ad astrarre la discontinuità dei segmenti, facendo credere al programma di avere uno spazio di indirizzi contiguo.
Segmenti e pagine rimangono discontinui in memoria, ma usiamo un sistema per – appunto – astrarre lo spazio degli indirizzi.

Per continuare, passa all'articolo sulla memoria virtuale.

]]>
<![CDATA[Splash Screen in Android: come crearle (bene)]]>https://informaticabrutta.it/launch-splash-screen-android/5e18f3330cc1dc0001e008cbSat, 30 Sep 2017 14:33:07 GMTSplash Screen in Android: come crearle (bene)

Alzino la mano le persone a cui piace aspettare.

Quante mani alzate? Nessuna, o comunque poche.
A nessuno piace aspettare.

In particolare, nel mondo della tecnologia ci piace avere tutto a disposizione nel minor tempo possibile.
Guai a far aspettare all’utente del tempo inutile. Lo fai incavolare.

In una guida sulla Technical SEO mostro una statistica sugli abbandoni dei siti da parte degli utenti: molti abbandoni sono correlati ad una scarsa velocità del sito.

Ora stiamo parlando di App, ma il discorso non cambia troppo: a nessuno piace aspettare.

Ma ci sono dei casi in cui l’attesa è oltre che giustificata, necessaria.
In tal caso bisogna intrattenere l’utente in qualche modo.

Per questo qualcuno ha inventato le splash screen: delle schermate per far vedere qualcosa all’utente, mentre l’App si carica.

Splash Screen: sì o no?

Chris Stewart (l’autore del post su cui ho basato questo articolo), dice che la sola idea di splash screen lo mette in imbarazzo e lo fa arrabbiare, ed aggiunge:

Le splash screen ti fanno solo perdere tempo, giusto? Da sviluppatore Android, quando vedo una splash screen, so che un povero programmatore ha dovuto aggiungere al codice un delay di tre secondi.

Quindi a questo punto ti puoi chiedere: questo articolo è a favore o contro le splash screen?

La risposta è nel mezzo.

, le splash screen possono far perdere tempo, ma sono necessarie quando l’App ha tempi di caricamento abbastanza sostenuti.

In Android non è raro rimanere a guardare una schermata bianca in attesa che si carichi il layout dell’activity principale.

Beh, sfruttiamo questo leggero delay naturale per far vedere qualcosa di carino.
In fin dei conti è un ritardo che non possiamo evitare. Sfruttiamolo!

Su questo si basa l’articolo di Chris (e questo che stai leggendo): splash screen educate, adatte a lunghi e corti tempi di caricamento.

Sì, ma Google che dice sulle Splash Screen?

Qui c’è dell’interessante.

Nelle linee guida Material Design, leggiamo che Google incoraggia l’uso di splash screen.

Dato che Google le chiama launch screen, per coerenza faremo così anche noi da questo punto in poi.

Vediamo anche che esistono due tipi di launch screen:

  • Placeholder UI: mostriamo un layout vuoto in attesa del caricamento effettivo;
  • Brandizzate: facciamo vedere un logo, o qualsiasi cosa per migliorare il brand recognition.

Questo è un esempio di lancio dell’app Uber.

Splash Screen in Android: come crearle (bene)

Vediamone uno un po’ più sobrio, che corrisponde al tipo di layout che andremo a creare.

Splash Screen in Android: come crearle (bene)

Questa è la launch screen che ho creato per un mio progetto: BookCherry.

Nonostante Google – a differenza di qualche anno fa – incoraggi l’uso di launch screen, bisogna farlo nel modo giusto, ovvero evitando di:

  • Far aspettare l’utente per caricare la launch screen;
  • Farlo aspettare inutilmente durante la launch screen.

In breve: non facciamo perdere tempo all’utente, ma neanche gli facciamo vedere una schermata bianca e non configurata dell’app.

La frase che hai appena letto è il riassunto del pensiero di Chris.

Come è fatta una buona Launch Screen?

Secondo quanto detto, la launch screen che andremo a creare è:

  • Semplice;
  • Visibile solamente il tempo necessario al caricamento;
  • Non costosa da caricare.

Quindi un po’ come nelle recenti app di Google.

Splash Screen in Android: come crearle (bene)

Implementare una Launch Screen in un’app Android

La launch screen deve essere pronta immediatamente, anche prima di poter fare un inflate sul layout.

Infatti non useremo un file di layout, ma definiremo un tema per l’activity di lancio.

Per farlo, creiamo un XML in res/drawable, che possiamo chiamare splash_theme.xml.

Il suo contenuto sarà:

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@color/gray"/>
    <item>
        <bitmap android:gravity="center" android:src="@mipmap/ic_launcher"/>
    </item>
</layer-list>

In pratica, abbiamo definito una bitmap centrata, che mostra l’icona dell’app sopra un background grigio.
Cambia colore di sfondo ed immagine come preferisci.

Ora registriamolo come stile, quindi aggiungiamo questo codice nel file styles.xml:

<style name="SplashTheme" parent="Theme.AppCompat.NoActionBar">
    <item name="android:windowBackground">@drawable/splash_theme</item>
</style>

Perfetto.

Ora dobbiamo associare questo tema a quello della splash activity.

Creiamo quindi la nostra activity dedicata al lancio; possiamo chiamarla SplashActivity.

Configuriamola in AndroidManifest.xml come activity principale:

<activity android:name=".SplashActivity" android:theme="@style/SplashTheme">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

Ed ora basta sovrascrivere il metodo SplashActivity#onCreate per fargli semplicemente caricare l’activity giusta, ad esempio MainActivity.

public class SplashActivity extends AppCompatActivity {
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        startActivity(new Intent(this, MainActivity.class));
        finish();
    }
}

Fine.

Osserviamo la nostra launch screen

Ora ci ritroviamo una launch screen educata, cioè che:

  • Non è costosa da caricare;
  • Intrattiene l’utente;
  • Dura il tempo necessario al caricamento dell’app, quindi dal tap sulla nostra icona al caricamento della MainActivity.

Come si può ulteriormente sfruttare questo tempo?
Ecco alcune idee:

  • Carica un sottoinsieme importante dei dati dell’app;
  • Controlla se dirottare l’utente su un’altra activity;
  • Controlla la presenza/assenza di qualche impostazione;

Basta fare tutto nell’onCreate della SplashActivity. Ovviamente evitando di caricare più del necessario.

Thanks

Bene, siamo arrivati alla fine della guida. Per qualsiasi dubbio, suggerimento, insulto, sentiti libero di lasciare un commento qui sotto :).

]]>
<![CDATA[Introduzione allo Stack TCP/IP]]>https://informaticabrutta.it/stack-tcp-ip/5e18f3330cc1dc0001e008caSun, 16 Jul 2017 15:05:40 GMTIntroduzione allo Stack TCP/IP

Se ti interessa l’argomento reti, sicuramente hai incontrato almeno una volta il termine TCP/IP, insieme ad almeno uni di questi altri:

  • Stack protocollare;
  • Suite di protocolli;
  • TCP, UDP, IP;
  • Modello TCP/IP;
  • Modello ISO/OSI.

Volendo possiamo aggiungerne altri.

Ciò che li accomuna è questo: tutti hanno a che fare con l’argomento di questo articolo, ovvero la suite di protocolli Internet TCP/IP.

Prima di entrare nello specifico, cominciamo con lo spiegare cosa caspita sia una suite di protocolli Internet.

Innanzitutto, stack (in italiano pila) di protocolli e suite di protocolli sono, di fatto, sinonimi.

Una suite di protocolli non è nient’altro che un insieme di protocolli di rete. Fine.
Se proprio vogliamo essere più precisi (e corretti), possiamo dire che questi protocolli devono essere specificati con un certo ordine.

Il motivo risulterà ovvio tra qualche paragrafo.

Sappiamo tutti cos’è un protocollo? Cioè un insieme di regole che degli interlocutori devono rispettare per riuscire a comunicare?

Bene, ora entriamo nel vivo.

Layering di Protocolli

Oh che parolona. Dall’inglese layer, tiriamo fuori l’italiano strato, o livello se vogliamo.
Infatti per comunicazioni complesse, si devono usare più protocolli.

Da qui deriva l’introduzione di una suite di protocolli, in un certo ordine.

Tra poco vedremo quali protocolli, e in quale ordine.

Ti anticipo che la stratificazione dei protocolli serve a garantire:

  • Modularizzazione;
  • Suddivisione di compiti complessi in sotto-compiti più semplici;
  • Astrazione del problema.

Insomma i soliti principi fondamentali-ricorrenti che piacciono molto a noi informatici.

Inoltre tra due interlocutori, ogni livello dello stack TCP/IP forma un collegamento logico tra le due parti.

Lo Stack TCP/IP

Ora che abbiamo ben chiare le proprietà della stratificazione dei protocolli di rete, parliamo di qualcosa di concreto.

La suite TCP/IP infatti, realizza proprio quel layering di cui abbiamo parlato poco fa.
Il nome deriva da due protocolli importanti che la possono caratterizzare, ovvero:

  • TCP a livello di trasporto;
  • IP a livello di rete.

Ora daremo un senso a ciò che hai appena letto.

Un altro modo per definire uno stack protocollare, è come famiglia di protocolli.

Ed ora puoi dare il benvenuto alla famiglia di protocolli TCP/IP.

Livello Nome Esempi Denominazione pacchetto Implementazione Indirizzamento
5 Applicazione HTTP, FTP, DNS, TLS Messaggio SW Nomi
4 Trasporto TCP, UDP, SCTP Segmento SW Porte
3 Rete IP, {routing} Datagramma SW Indirizzi IP
2 Collegamento Ethernet Frame HW Indirizzi MAC
1 Fisico Bit HW

Due parole su questo schema:

La voce routing è tra parentesi graffe perché non è banalmente un esempio di protocollo, ma un insieme di protocolli che si occupano dell’instradamento dei pacchetti.
Vedremo tutto meglio a tempo debito.

La pila TCP/IP completa è implementata in ogni end-system, ovvero gli host interlocutori.
Più in là vedremo che gli intermediate-system, generalmente, non implementano tutti e cinque i livelli:

  • I router implementano i primi tre livelli;
  • Gli switch (ed altri) si fermano al secondo livello.

In questa immagine vediamo lo stack protocollare TCP/IP implementato tra due host.

Introduzione allo Stack TCP/IP

Questa immagine ci serve per due motivi:

  • Vediamo con un bel disegnino quanto detto fino ad ora;
  • Ci porta ad un discorso abbastanza importante, di cui parliamo ora.

Incapsulamento e Decapsulamento

Ho mentito.

Non sull’importanza di questo argomento, ma riguardo la parola discorso.
Infatti c’è veramente poco da dire. Vediamo.

Quando un dispositivo vuole comunicare con un altro dispositivo, deve:

  1. Prendere il messaggio da inviare;
  2. Ficcarlo in una scatola;
  3. Inviarlo al livello inferiore;
  4. Il quale lo ficca in un’altra scatola.

Questo giretto termina a livello 1.

La Matrioska appena creata viene inviata lungo il canale (cablato o wireless che sia), fino a raggiungere la destinazione, dove viene:

  1. Aperta;
  2. Il contenuto passato al livello superiore;
  3. Riaperta;
  4. Etc…

Fino ad arrivare al livello corretto (ricordi il canale logico?), dove si ritrova il messaggio originale.

I passi che abbiamo descritto sono rispettivamente: incapsulamento e decapsulamento.
Il mittente incapsula; il ricevente decapsula.

Introduzione allo Stack TCP/IP

Un po' più formalmente, durante l’incapsulamento:

  1. Ogni livello riceve un pacchetto dal livello superiore, che comprende un header e dei dati (payload);
  2. Il livello crea un nuovo pacchetto con un proprio header;
  3. Nel nuovo pacchetto inserisce come payload quello ricevuto prima.
  4. Invia questa scatola al livello inferiore.

In fase di decapsulamento, avviene la procedura inversa.

Multiplexing e Demultiplexing

Dato che esistono molti protocolli per livello, ti potrai chiedere se è possibile utilizzarli contemporaneamente; e se sì, come.

La risposta alla prima domanda è YES.
Per la seconda domanda, guarda qui:

Introduzione allo Stack TCP/IP

Come vedi questo principio è strettamente legato all’incapsulamento.

  • Multiplexing (arancione): un protocollo incapsula e recapita pacchetti ottenuti da più protocolli dal livello superiore;
  • Demultiplexing (verde): un protocollo decapsula e recapita pacchetti verso più protocolli superiori.

La cosa non scontatissima è che per realizzare tutto questo c’è bisogno di alcune informazioni nell’intestazione (header) dei pacchetti.
Ad esempio, se un pacchetto deve essere ricevuto da UDP, nell’header IP deve essere specificata questa informazione.

Queste cose le vedremo meglio nei prossimi articoli.

Ora andiamo avanti con questa introduzione.

Indirizzamento nello Stack TCP/IP

Fermi tutti. Di che si parla quando diciamo indirizzamento? A quali indirizzi ci riferiamo?

Chi ha un po' di familiarità con le reti, conosce sicuramente questi oggettini: URL ed indirizzi IP.

Anche il tuo fornaio preferito ha una vaga idea di come sia fatto un URL (forse).
Ed il fornaio avanzato ha anche visto un indirizzo IP.

Questi infatti, sono solo due sistemi per indirizzare qualcosa, ad un certo livello dello stack.

Nella tabella dello stack TCP/IP ho elencato anche i tipi di indirizzamento.
In questo momento non è importante entrare nel dettaglio, ma per farti un esempio:

Livello Nome Per indirizzare Per identificare Esempi
5 Applicazione Nomi Protocolli HTTP: "http://yahoo.it/roba"
4 Trasporto Porte Processi 0, ..., 25, ..., 80, ..., 65535
3 Rete Indirizzi IP Reti 192.168.1.1, 216.58.198.46, 127.0.0.1
2 Collegamento Indirizzi MAC Dispositivi fisici 2E:F0:5F:EE:70:8C
1 Fisico

In pratica, ad ogni livello della pila TCP/IP, si usano degli indirizzi di forma diversa, per identificare particolari oggetti di quel livello.

L’indirizzamento serve a garantire che un pacchetto vada nella giusta direzione, fino all’host specifico di destinazione.

Bello eh?

Ora possiamo concludere questa prima parte introduttiva con un mattoncino teorico di scarsa utilità pratica, ma importante perché:

  • È una curiosità, ed è scientificamente provato che a volte aiuta a fissare cose più importanti;
  • Fa capire come **non **si è evoluto TCP/IP.

Vediamo.

Confronto con il modello ISO/OSI

Questo ISO/OSI (Open Systems Interconnection) è un altro modello di stack protocollare.

Attenzione: perché è solo un modello, e non una vera e propria famiglia di protocolli?

Perché non esiste. Non è mai esistito, e non esisterà mai.

O meglio, esiste solo sulla carta: in teoria.

Infatti viene definito come modello teorico di stack di protocolli di rete, e qualcuno dice addirittura che TCP/IP sia stato costruito in riferimento al modello ISO/OSI.

Cosa non vera. Infatti TCP/IP nacque prima di ISO/OSI.

TCP/IP non ha mai dato problemi, ed è sempre stato efficiente.
Un eventuale cambio con OSI sarebbe stato costoso e rischioso.
Inoltre OSI è anche privo di alcuni requisiti per i livelli 5 e 6, che contribuiva a renderlo una scelta non sicura.

Quindi l'ente ISO decise di imporlo solamente come modello teorico, su cui eventuali futuri stack protocollari dovevano far riferimento.

Introduzione allo Stack TCP/IP

Sinceramente, non vale la pena di mettersi a spiegare quale livello doveva fare cosa in ISO/OSI.
Se ti interessa, ti lascio l’articolo che ne parla su Wikipedia.

Bene, abbiamo finito la parte introduttiva sullo stack TCP/IP.

Se sei rimasto a leggere fino alla fine, può significare che:

  • Ti interessa l’argomento;
  • Probabilmente lo devi studiare;
  • Sei una bella persona.

Ti avviso che scriverò altri articoli per la serie reti, quindi questo mini-corso andrà avanti.
Puoi iscriverti alle newsletter per essere avvisato sui nuovi articoli, così non lasciamo cose in sospeso.

Non escludere la possibilità di condividere l’articolo con qualcuno a cui può essere utile :)
Per qualsiasi cosa, non esitare a lasciarmi un commento qui sotto.

]]>