Architetture Software Concorrenti

Introduzione

Questo articolo nasce da una ricerca personale sulle architetture software concorrenti. Durante la fase di studio di un piccolo progetto di integrazione tramite socket file unix, ero alla ricerca di linee guida sul come non fermare l’esecuzione dell’applicativo mentre attendevo la lettura dei dati dal file (non blocking I/O) e mi sono accorto che quando parliamo di concetti relativi alla concorrenza, spesso navighiamo in una nebbia di fonti travisate che lasciano molto alla libera interpretazione.

Dopo aver raccolto diverso materiale e spunti, ho quindi preferito raggrupparli e creare questo articolo dedicato alla concorrenza e come essa impatta la scalabilità e l’architettura delle nostre applicazioni, soprattutto se doveste trovarvi di punto in bianco a gestire milioni di richieste o processi contemporanei.

N.B. le immagini, il codice e gran parte del contenuto a seguire, nascono dall'eccellente articolo di Sherif Ramadan.

Questo articolo subirà cambiamenti nei prossimi mesi al fine di spiegare la concorrenza attraverso altri approcci.

la scelta del PHP

Dovendo scegliere un linguaggio per spiegare i concetti che affronteremo, ho scelto PHP, anzitutto perché ne sono appassionato e poi perché ha caratteristiche forti quali: curva di apprendimento, community, reperibilità di materiale, qualità di documentazione e “prova del tempo”.

I concetti che seguono sono facilmente trasferibili e saranno frequenti i riferimenti ad altri linguaggi di programmazione ma è importante comprendere quanto ad oggi dal punto di vista architetturale il linguaggio non è più un limite.

Infatti possiamo dire che qualsiasi linguaggio Turing-Completo può approssimare qualsiasi funzione di runtime di un altro linguaggio.

PHP multithreading

L’HTML non nasce con i costrutti necessari alla programmazione come i loop e il branching if-then (che permettono a sezioni di codice di essere saltate sotto determinate circostanze). Esso è infatti Turing-incompleto ed inizialmente era il Web Server che si faceva carico delle logiche necessarie a mostrare il contenuto in base a quanto richiesto dall'utente. Il PHP sboccia proprio dall'esigenza di aggiungere costrutti funzionali e logici più avanzati, senza dover produrre ogni volta librerie interne al Web Server stesso.

Per questo motivo non nasce con una libreria per il threading o una libreria per il multi-tasking asincrono, dato che normalmente veniva utilizzato per affrontare la natura state-less di HTTP, una richiesta alla volta.

L’avvento dei WebSockets portò una rivoluzione che rese possibile la creazione di servizi state-full e Node.js divenne omonimo di questa architettura. Dopo poco si misero in discussione le varie architetture e relative applicazioni back-end, dimostrando come linguaggi tipo Go e Erlang, creati per la programmazione concorrente, potevano veramente prosperare in questo ambito.

Ma gli sviluppatori avevano già le proprie librerie scritte in altri linguaggi come PHP e Ruby e pochi erano effettivamente disposti ad abbandonare tutto ciò su cui avevano lavorato e passare a qualcosa di nuovo solo per moda. Le discussioni scaturite da queste novità hanno invece preso sotto analisi i compromessi strutturali ai quali questi ci espongono, così da comprenderne l’impatto sull'architettura software.


Architettura Software

In primo luogo, dovremmo analizzare cosa si intende per architettura del software e perché svolge un ruolo così importante.

L’obbiettivo principale di una architettura è Fare di tutto per prevenire il fallimento.

Dove “fare di tutto” sta a significare che è necessario scendere a compromessi, sacrificando una cosa per ottenere un’altra. Ad esempio, potrebbe essere preferibile l’utilizzo di una piccola quantità di memoria in più per ottenere un calcolo leggermente più veloce.

Tipico caso di questo tipo di scelta è rappresentato dagli array: in PHP sono hashmap ordinati, infatti grazie alla loro proprietà di hashing, consentono l’accesso ai propri valori con maggiore velocità, anche se sono richiesti più dati da memorizzare. Dal momento che la maggior parte delle richieste PHP vive solo per la durata di alcuni millisecondi, questa scelta è vista come accettabile: Si considera preferibile utilizzare più memoria per ottenere una esecuzione più veloce delle richieste, facilitando l’utilizzo di un array PHP sia come Dizionario che come Lista.

Un altro esempio di un compromesso che fa PHP è evidente nel suo modello di concorrenza.

PHP è in grado di servire centinaia di richieste simultanee, perché è incorporabile ed estensibile per design.

Questo non è comunemente indicato come attributo di qualità e spesso viene definito requisito non funzionale, ma dal momento che PHP può essere incorporato direttamente in un altro programma, come il server Apache httpd o il PHP Fork Process Manager (PHP-FPM), non è necessario preoccuparsi della concorrenza all'interno del codice applicativo. Il runtime PHP stesso può essere duplicato in un contesto multi-thread (solitamente utilizzato solo su Windows in cui non è possibile il forking) o un contesto multi-processo (la configurazione tipica di linux). Ciò è possibile grazie alle API del server (SAPI), che consentono a di allocare e disallocare la memoria di ogni richiesta in maniera indipendente.

image originale http://phpden.info/images/articles/article8/mpm-forking.png

La realizzazione di quanto descritto all'interno di Apache httpd web server avviene piuttosto facilmente grazie all'utilizzo delle SAPI nel modulo mod_php che utilizza un pre-fork Multi Process Manager (o MPM). Ogni processo figlio (httpd worker) ha una copia incorporata del runtime PHP e così il web server può gestire una richiesta per processo simultaneamente (nel caso sopra 3).

Quello che probabilmente sorprende è che PHP si è focalizzato su questo stile architettonico per la maggior parte degli ultimi 20 anni. Quindi non possiamo che chiederci, perché? Voglio dire, il fatto che funziona ancora in modo efficace, deve dirci qualcosa sulle scelte che PHP ha compiuto fin dall'inizio.

“Tutto Fallisce, sempre”
Werner Vogels, CTO e Vice Presidente di Amazon.com

Poiché PHP non condivide nulla tra le richieste, non importa molto se fallisce nel fare qualcosa all'interno di una di esse. Quindi, facendo un esempio, diciamo che c’è un errore strano in PHP che ne provoca il crash una volta ogni 100 mila richieste. Beh, su un server molto carico che ha centinaia di richieste al secondo, significa un errore critico ogni 15 minuti o giù di lì. Dato che una richiesta PHP vive in un unico processo nell'architettura di cui sopra, l’errore è abbastanza accettabile. Questo perché se un processo si blocca solo una sola richiesta soffre.

Notiamo come questa architettura è la stessa in uso nella maggior parte delle applicazioni PHP distribuite e utilizzate sul web ogni giorno. Se utilizziamo mod_php o PHP-FPM, ci stiamo basando su questa stessa “shared-nothing Architecture”.

Se si considera l’alternativa, dove PHP vive in un unico processo e utilizza thread per eseguire le richieste, un crash che si verifica in un thread singolo fa sì che tutte le altre richieste servite da quello stesso processo cadano. Quindi si finisce per perdere centinaia di richieste ogni 15 minuti invece di una sola. Questo sarebbe un dettaglio di scarsa progettazione architettonica per PHP e in effetti proprio in virtù di questo non si vedono molti server PHP in Windows (il quale, non possedendo l’abilità di effettuare fork, crea thread). Non ha senso.

Concorrenza

Se vogliamo parlare di concorrenza, dobbiamo cercare di capire esattamente cosa significa e perché gioca un ruolo così importante dal punto di vista architetturale. La concorrenza ha due forme: C’è la concorrenza hardware, dove due cose si verificano contemporaneamente a livello fisico e poi c’è la concorrenza software, avremo cioè la sensazione che due cose stiano accadendo contemporaneamente nel nostro software ma (per quanto riguarda l’hardware) stanno accadendo in sequenza (uno dopo l’altro).

La gente molto spesso fa confusione con i termini asincrono, concorrenza, parallelismo o threading. Questi non significano esattamente la stessa cosa, anche se possiamo correlarli tra loro. Per risolvere qualsiasi confusione, solitamente aiuta a pensare a queste cose in termini di elaborazione e esecuzione.

image originale http://phpden.info/images/articles/article8/chart.png
  • Il Processo è l’oggetto o contenitore che definisce la memoria, le istruzioni ed il contesto di un programma software.
  • Il Thread è l’unità di esecuzione che si verifica in relazione a un processo.

A partire da queste definizioni potremmo erroneamente dedurre che la maggior parte dei processi siano single-thread, dal momento che ad ognuno di essi corrisponde un thread di esecuzione. Tuttavia, un processo può definire più thread di esecuzione e quindi diventare multi-threaded.

Siamo dunque sulla via del parallelismo, poiché il threading crea un contesto aggiuntivo all'interno di un processo e può essere aiutato dalla concorrenza hardware. Tutto questo spiega come un processo possa creare concorrenza basandosi su quanti thread di esecuzione ha al suo interno.

Sincrono/Asincrono

Quando pensiamo a sincrono e asincrono, tuttavia, ci riferiamo al modo in cui vediamo l’esecuzione da un “contesto di un processo”. Il contesto di un processo è il thread di esecuzione stesso.

Sia per un processo single-thread che per uno multi-thread possiamo visualizzare il contesto di esecuzione come sincrono o asincrono.

Possiamo anche pensare che l’esecuzione interna a ciascun thread possa essere sincrona o asincrona.

immagine originale http://phpden.info/images/articles/article8/synchronous-processing.png
Con il termine esecuzione sincrona o “bloccante” (blocking) si intende una routine o insieme di istruzioni che vengono eseguite in un determinato thread e che possono bloccare altre routine.

Dato che un thread viene generalmente eseguito sulla CPU da un singolo core fisico o logico, esso è soggetto all'esecuzione sequenziale a livello hardware. Tuttavia, ciò non significa che non possiamo scrivere codice in maniera da creare thread aggiuntivi all'interno di quello principale, consentendo di passare attraverso i vari thread di esecuzione all'interno dello stesso thread di processo. Affrontiamo quindi il tema di thread bloccante dal punto di vista di ciclo CPU (mentre più avanti analizzeremo il discorso dell’I/O).

Diamo un’occhiata a come è l’esecuzione sincrona a livello di codice. Partiamo dalla concorrenza con una funzione che verifica se un numero è primo:

Possiamo dire con estrema semplicità che questo loop richiede l’esecuzione sincrona di ogni step. Quello che fa in pratica è prendere i due numeri in $numbers e cercare di capire se ciascuno di questi è un numero primo.

Si evince facilmente che questa test (indicato dalla funzione isPrime()) non ha una soluzione efficiente. Infatti ogni volta che chiamiamo la funzione isPrime()all'interno del nostro ciclo, dobbiamo anche eseguire un numero n di istruzioni sincrone per verificare che sia primo, controllando di volta in volta che il numero in esame non sia divisibile con l’elemento n. Come possiamo notare se la nostra funzione isPrime()richiede molti step, bloccherà qualsiasi altra chiamata a isPrime()nel nostro loop precedente.

Dato che il numero 7 è veramente un numero primo, sarà necessario un totale di 5 passaggi prima di ritornare il risultato. Quindi è facile capire come ogni passaggio nel ciclo foreach precedente possa essere considerato un compito sincrono che coinvolge ogni passo nel ciclo for nella nostra funzione isPrime().

Quindi, come potremmo scrivere questo codice in modo asincrono? Utilizziamo le coroutine asincrone di PHP attraverso i generatori, i quali possono essere riassunti come funzioni che possono essere interrotte mantenendo il proprio stato e riprendere, opzionalmente con un diverso valore di ritorno.

QUI nei commenti. il dettaglio esplicativo di come funziona lo script e dei costrutti utilizzati

Attenzione, è necessario ancora lo stesso numero di passi per completare la verifica ma visto che vengono eseguiti in maniera asincrona, l’unica reale differenza è che possiamo ottenere il risultato per il numero 4 (non primo).

Nella versione sincrona dobbiamo attendere tutti e 5 i passaggi per il completamento del test del numero 7, prima ancora di iniziare a verificare il numero 4.

In realtà è più veloce perché chi aspetta il risultato di isPrime(4) è indipendente da chi aspetta il risultato di isPrime(7), altrimenti, non c’è davvero alcun vantaggio! Poiché in termini di tempo effettivamente trascorso in entrambi i task, prendono la stessa quantità di tempo, non importa se stai visualizzando i passaggi di esecuzione come sincroni o asincroni.

Deduciamo quindi che:

Oltre all'algoritmo asincrono è importante anche la gestione asincrona del risultato!

Concorrenza Hardware

Immaginiamo che questi due task siano due richieste indipendenti che vengono fatte da due persone diverse al server PHP. Se PHP avesse un singolo thread e un singolo processo ed entrambe queste richieste fossero state accolte contemporaneamente, nella nostra prima implementazione sincrona, la persona che aspetta il risultato per isPrime(4) potrebbe dover aspettare molto più a lungo della persona che ha inviato la richiesta di isPrime(7). In realtà il modello di concorrenza PHP è basato sugli attori. Ogni richiesta è un attore che passa un messaggio a PHP attraverso le SAPI.

immagine originale http://phpden.info/images/articles/article8/concurrent-processing.png

In quest’ottica, ogni istanza dell’interprete PHP in esecuzione è considerata un processo o un programma separato che può avere un proprio thread di esecuzione legato a un core della CPU.

In un sistema multi-core questo fa si che la richiesta A possa ottenere una risposta entro due cicli di clock hardware mentre la richiesta B entro cinque cicli. Questa è la vera concorrenza.

Nel nostro precedente modello asincrono, all'interno di un singolo thread dovevamo aspettare quattro cicli di clock hardware completi per terminare la prima attività. Mentre qui abbiamo la concorrenza in tempo reale e dobbiamo aspettarne solo due. Naturalmente questo non significa che ogni singolo processo in esecuzione sul sistema necessariamente ottenga concorrenza hardware vera.

Ci sono sistemi in cui la concorrenza hardware non è sempre possibile, ad esempio in un sistema single-core o embedded o ovunque ci siano più processi in esecuzione contemporaneamente rispetto ai core disponibili. Esiste anche il concetto di core logici come nella tecnologia Intel x86 hyper-threading, in cui un singolo core fisico può produrre lo stesso tipo di esecuzione asincrona che abbiamo dimostrato nell’esecuzione PHP di cui sopra, con più thread di esecuzione (definito micro-architettura).

Gestione degli errori nella concorrenza

Il threading è normalmente aiutato dalla concorrenza hardware per accelerare l’unità di esecuzione composta dal thread del processo. Tutto questo è possibile grazie al coordinamento tra il sistema operativo e la micro-architettura attraverso qualcosa chiamato task scheduler. È ciò che rende possibile il multi-tasking sia fisicamente a livello hardware che virtualmente a livello di software quando vengono saturate le capacità hardware. Poiché le CPU sono molto veloci, non si nota mai la differenza tra concorrenza hardware e software, ma la si nota molto bene quando fallisce.

immagine originale http://phpden.info/images/articles/article8/parallel-processing.png

Torniamo alla nostra implementazione sincrona Vs asincrona del calcolo dei numeri primi. Cosa succede nella versione sincrona di tale implementazione se viene generata un’eccezione dall'interno del ciclo nella funzione isPrime()? Nel migliore dei casi, se intercettiamo l’eccezione dall'interno del loop foreach e riusciamo a gestirla, dovremo ricominciare da capo. Non è un grosso problema quando stiamo testando il numero 7 e ci capita di fallire al punto 3 o 4 ma è un problema significativo se durante isPrime(789343)ci capita di fallire al numero 789340. Dovremmo ripercorrere decine di migliaia di passaggi di nuovo solo per gestire quel caso di fallimento. Una follia!

Attraverso l’implementazione asincrona, è effettivamente possibile riprendere da un errore in un determinato passaggio senza dover riavviare l’intera attività da zero o bloccare l’esecuzione delle altre attività all'interno del nostro foreach loop. Dato che i generatori sono condotti a due vie in PHP, possiamo inviare le informazioni al generatore in un determinato step per riprendere dal punto in errore (supponendo che il recupero sia possibile) e questo risultato è possibile grazie alla nostra scelta architetturale. Questa analisi mette in luce come un codice asincrono non sia il massimo per il debug e la sua gestione è nettamente più complicata di un tipico codice sincrono, è quindi preferibile utilizzare costrutti simili solo dove effettivamente necessario.

Blocking I/O

Facendo un sunto di quanto illustrato sin’ora e prendendo Node.js come esempio per la definizione di “non blocking I/O”, la differenza principale tra questo e PHP è che in Node il codice viene eseguito in un contesto di processo persistente e che esiste finché il server Node.js è in esecuzione. Quindi, ad esempio, se si scrive un valore in una variabile globale in una richiesta e lo si legge in un’altra richiesta, si otterrà lo stesso valore.

Al contrario, in PHP, viene creato un nuovo contesto per ciascuna richiesta e così le variabili globali scritte in una richiesta vengono perse quando lo script che gestisce la richiesta termina. Node è quindi basato su un modello di concorrenza single process, single-threaded e software-based, che espone l’intero codice a tutti gli errori di questo stesso modello. È vero che Node ha un pool di thread per non bloccare l’I/O, ma è sufficiente un errore in un singolo thread per mandare giù l’intero server ed ogni richiesta concorrente che stava gestendo con esso.

Node ci consente di scrivere routine che recuperano dati da fonti esterne senza preoccuparci della latenza di ricezione delle stesse (tempi di rete o lettura/scrittura sul nostro driver) perché eseguirà il codice quando il dato sarà disponibile. In PHP esistono delle implementazioni dell’event loop, la più famosa è React ma possiamo nominarne decine come icilie, amp, kraken, etc… esistono inoltre delle proposte per l’integrazione di alcune di queste metodologie in future versioni del PHP ma, ma, ma… L’utilizzo di librerie esterne (almeno per ora) implica che il tuo codice standard non funziona e dovrai implementare i ragionamenti specifici delle libreria che hai selezionato.

NOTA SULLA SICUREZZA: Condividere la memoria utilizzando una architettura multi-thread piuttosto che multi-processo, significa dare accesso a chiunque nel thread alle altre variabili del processo. Quindi se gestissimo più richieste nello stesso processo, condivideremmo lo spazio variabili e questo potenzialmente significa esporre la nostra applicazione alla possibilità che qualcuno, riesca a leggere i dati di altri richieste e questo potrebbe essere molto grave in base alla quantità di dati sensibili che gestiamo (nome utente e password, dati di sessione, chiavi di sicurezza, fino ad arrivare ai dati delle carte di credito per i siti di e-commerce).

PHP in realtà non si pone questi problemi con il suo modello di concorrenza computazionale basata sugli attori. Nel PHP di fatto ogni processo può effettuare chiamate bloccanti ma impatterà solo ed unicamente la richiesta che è stata demandata a quel processo. In questa maniera possiamo espandere questo stesso modello estendendolo con un motore di code facendo si che non venga bloccato nessun’altro processo nella nostra architettura.

Potremo dunque eseguire più processi in background che possono scambiarsi messaggi, facilitandoci con un modello multi-process e multi-actor di concorrenza, sfruttando sia la concorrenza a livello hardware che la concorrenza asincrona a livello di applicazione. Nell'esempio relativo al recupero dati da una risorsa esterna, potremmo implementare un modello secondo il quale diversi processi PHP recuperano le risorse e, una volta ricevuta, invia un messaggio sulla coda. I processi in ascolto su di essa potranno quindi consumare i dati eseguendo un singolo task. Al crescere della nostra applicazione sarà semplice la scalabilità orizzontale e il debugging non sarà un inferno.