Principi e modelli di progettazione per applicazioni altamente concorrenti

1. Panoramica

In questo tutorial, discuteremo alcuni dei principi e dei modelli di progettazione che sono stati stabiliti nel tempo per creare applicazioni altamente concorrenti.

Tuttavia, vale la pena notare che la progettazione di un'applicazione simultanea è un argomento ampio e complesso, e quindi nessun tutorial può pretendere di essere esaustivo nel suo trattamento. Ciò che tratteremo qui sono alcuni dei trucchi popolari spesso utilizzati!

2. Nozioni di base sulla concorrenza

Prima di procedere oltre, dedichiamo un po 'di tempo alla comprensione delle basi. Per cominciare, dobbiamo chiarire la nostra comprensione di ciò che chiamiamo un programma concorrente. Ci riferiamo a un programma che è simultaneo se vengono eseguiti più calcoli allo stesso tempo .

Ora, nota che abbiamo menzionato i calcoli che avvengono nello stesso momento, ovvero sono in corso nello stesso momento. Tuttavia, possono essere eseguiti o meno contemporaneamente. È importante comprendere la differenza poiché l'esecuzione simultanea dei calcoli viene definita parallela .

2.1. Come creare moduli simultanei?

È importante capire come possiamo creare moduli simultanei. Ci sono numerose opzioni, ma qui ci concentreremo su due scelte popolari:

  • Processo : un processo è un'istanza di un programma in esecuzione isolato da altri processi nella stessa macchina. Ogni processo su una macchina ha il proprio tempo e spazio isolato. Quindi, normalmente non è possibile condividere la memoria tra i processi e devono comunicare passando messaggi.
  • Thread : un thread, d'altra parte, è solo un segmento di un processo . Possono esserci più thread all'interno di un programma che condividono lo stesso spazio di memoria. Tuttavia, ogni thread ha uno stack e una priorità univoci. Un thread può essere nativo (pianificato in modo nativo dal sistema operativo) o verde (pianificato da una libreria runtime).

2.2. Come interagiscono i moduli simultanei?

È l'ideale se i moduli simultanei non devono comunicare, ma spesso non è così. Ciò dà origine a due modelli di programmazione concorrente:

  • Memoria condivisa : in questo modello, i moduli simultanei interagiscono leggendo e scrivendo oggetti condivisi nella memoria . Questo spesso porta all'interleaving di calcoli simultanei, causando condizioni di competizione. Quindi, può portare in modo non deterministico a stati errati.
  • Passaggio dei messaggi : in questo modello, i moduli simultanei interagiscono scambiandosi messaggi tramite un canale di comunicazione . Qui, ogni modulo elabora i messaggi in arrivo in sequenza. Poiché non esiste uno stato condiviso, è relativamente più facile da programmare, ma non è ancora esente da condizioni di gara!

2.3. Come vengono eseguiti i moduli simultanei?

È passato un po 'di tempo da quando la legge di Moore ha colpito un muro rispetto alla velocità di clock del processore. Invece, poiché dobbiamo crescere, abbiamo iniziato a impacchettare più processori sullo stesso chip, spesso chiamati processori multicore. Tuttavia, non è comune sentire parlare di processori con più di 32 core.

Ora sappiamo che un singolo core può eseguire solo un thread, o un insieme di istruzioni, alla volta. Tuttavia, il numero di processi e thread può essere rispettivamente di centinaia e migliaia. Allora, come funziona davvero? È qui che il sistema operativo simula la concorrenza per noi . Il sistema operativo ottiene ciò mediante il time-slicing , il che significa effettivamente che il processore passa da un thread all'altro frequentemente, in modo imprevedibile e non deterministico.

3. Problemi nella programmazione simultanea

Mentre discutiamo di principi e modelli per progettare un'applicazione concorrente, sarebbe saggio prima capire quali sono i problemi tipici.

Per gran parte, la nostra esperienza con la programmazione concorrente implica l' utilizzo di thread nativi con memoria condivisa . Quindi, ci concentreremo su alcuni dei problemi comuni che ne derivano:

  • Esclusione reciproca (primitive di sincronizzazione) : i thread di interleaving devono avere accesso esclusivo allo stato o alla memoria condivisi per garantire la correttezza dei programmi . La sincronizzazione delle risorse condivise è un metodo popolare per ottenere l'esclusione reciproca. Sono disponibili diverse primitive di sincronizzazione da utilizzare, ad esempio un blocco, un monitor, un semaforo o un mutex. Tuttavia, la programmazione per l'esclusione reciproca è soggetta a errori e spesso può portare a colli di bottiglia delle prestazioni. Ci sono diversi problemi ben discussi relativi a questo come deadlock e livelock.
  • Cambio di contesto (thread pesanti) : ogni sistema operativo ha un supporto nativo, sebbene vario, per moduli simultanei come processo e thread. Come discusso, uno dei servizi fondamentali forniti da un sistema operativo è la pianificazione dei thread da eseguire su un numero limitato di processori tramite il time-slicing. Ora, questo significa effettivamente che i thread vengono spesso commutati tra stati diversi . Nel processo, il loro stato attuale deve essere salvato e ripristinato. Questa è un'attività dispendiosa in termini di tempo che influisce direttamente sulla produttività complessiva.

4. Modelli di progettazione per alta concorrenza

Ora che comprendiamo le basi della programmazione concorrente ei problemi comuni in essa contenuti, è tempo di comprendere alcuni dei modelli comuni per evitare questi problemi. Dobbiamo ribadire che la programmazione concorrente è un compito difficile che richiede molta esperienza. Quindi, seguire alcuni degli schemi stabiliti può rendere il compito più facile.

4.1. Concorrenza basata sugli attori

Il primo progetto che discuteremo rispetto alla programmazione concorrente è chiamato Actor Model. Questo è un modello matematico di calcolo simultaneo che tratta fondamentalmente ogni cosa come un attore . Gli attori possono scambiarsi messaggi e, in risposta a un messaggio, possono prendere decisioni locali. Questo è stato proposto per la prima volta da Carl Hewitt e ha ispirato una serie di linguaggi di programmazione.

Il costrutto principale di Scala per la programmazione concorrente sono gli attori. Gli attori sono normali oggetti in Scala che possiamo creare istanziando la classe Actor . Inoltre, la libreria Scala Actors fornisce molte utili operazioni sugli attori:

class myActor extends Actor { def act() { while(true) { receive { // Perform some action } } } }

Nell'esempio sopra, una chiamata al metodo di ricezione all'interno di un ciclo infinito sospende l'attore fino all'arrivo di un messaggio. All'arrivo, il messaggio viene rimosso dalla casella di posta dell'attore e vengono intraprese le azioni necessarie.

Il modello dell'attore elimina uno dei problemi fondamentali della programmazione concorrente: la memoria condivisa . Gli attori comunicano attraverso i messaggi e ogni attore elabora i messaggi dalle proprie cassette postali esclusive in sequenza. Tuttavia, eseguiamo attori su un pool di thread. E abbiamo visto che i thread nativi possono essere pesanti e, quindi, in numero limitato.

Ci sono, ovviamente, altri schemi che possono aiutarci qui - li tratteremo più avanti!

4.2. Concorrenza basata su eventi

I progetti basati su eventi affrontano esplicitamente il problema che i thread nativi sono costosi da generare e da utilizzare. Uno dei progetti basati su eventi è il ciclo di eventi. Il ciclo di eventi funziona con un provider di eventi e una serie di gestori di eventi. In questa configurazione, il ciclo di eventi si blocca sul provider di eventi e invia un evento a un gestore di eventi all'arrivo .

Fondamentalmente, il ciclo di eventi non è altro che un dispatcher di eventi! Il ciclo di eventi stesso può essere eseguito su un singolo thread nativo. Allora, cosa accade veramente in un ciclo di eventi? Diamo un'occhiata allo pseudo-codice di un ciclo di eventi molto semplice per un esempio:

while(true) { events = getEvents(); for(e in events) processEvent(e); }

Fondamentalmente, tutto ciò che il nostro ciclo di eventi sta facendo è cercare continuamente eventi e, quando vengono trovati, elaborarli. L'approccio è davvero semplice, ma raccoglie il vantaggio di un design basato sugli eventi.

La creazione di applicazioni simultanee utilizzando questo design offre un maggiore controllo all'applicazione. Inoltre, elimina alcuni dei problemi tipici delle applicazioni multi-thread, ad esempio deadlock.

JavaScript implementa il ciclo di eventi per offrire una programmazione asincrona . Mantiene uno stack di chiamate per tenere traccia di tutte le funzioni da eseguire. Mantiene anche una coda di eventi per l'invio di nuove funzioni per l'elaborazione. Il ciclo degli eventi controlla costantemente lo stack di chiamate e aggiunge nuove funzioni dalla coda degli eventi. Tutte le chiamate asincrone vengono inviate alle API Web, in genere fornite dal browser.

Il ciclo di eventi stesso può essere eseguito da un singolo thread, ma le API Web forniscono thread separati.

4.3. Algoritmi non bloccanti

Negli algoritmi non bloccanti, la sospensione di un thread non porta alla sospensione di altri thread. Abbiamo visto che possiamo avere solo un numero limitato di thread nativi nella nostra applicazione. Ora, un algoritmo che si blocca su un thread ovviamente riduce significativamente il throughput e ci impedisce di creare applicazioni altamente concorrenti.

Gli algoritmi non bloccanti fanno invariabilmente uso della primitiva atomica di confronto e scambio fornita dall'hardware sottostante . Ciò significa che l'hardware confronterà il contenuto di una posizione di memoria con un dato valore e solo se sono uguali aggiornerà il valore a un nuovo valore dato. Può sembrare semplice, ma ci fornisce effettivamente un'operazione atomica che altrimenti richiederebbe la sincronizzazione.

Ciò significa che dobbiamo scrivere nuove strutture dati e librerie che facciano uso di questa operazione atomica. Questo ci ha fornito una vasta gamma di implementazioni senza attesa e senza blocchi in diverse lingue. Java ha diverse strutture di dati non bloccanti come AtomicBoolean , AtomicInteger , AtomicLong e AtomicReference .

Considera un'applicazione in cui più thread stanno tentando di accedere allo stesso codice:

boolean open = false; if(!open) { // Do Something open=false; }

Chiaramente, il codice precedente non è thread-safe e il suo comportamento in un ambiente multi-thread può essere imprevedibile. Le nostre opzioni qui sono di sincronizzare questo pezzo di codice con un lucchetto o utilizzare un'operazione atomica:

AtomicBoolean open = new AtomicBoolean(false); if(open.compareAndSet(false, true) { // Do Something }

Come possiamo vedere, l'utilizzo di una struttura dati non bloccante come AtomicBoolean ci aiuta a scrivere codice thread-safe senza indulgere negli svantaggi dei lock!

5. Supporto nei linguaggi di programmazione

Abbiamo visto che ci sono diversi modi in cui possiamo costruire un modulo simultaneo. Sebbene il linguaggio di programmazione faccia la differenza, è principalmente il modo in cui il sistema operativo sottostante supporta il concetto. Tuttavia, poiché la concorrenza basata su thread supportata da thread nativi sta raggiungendo nuovi muri rispetto alla scalabilità, abbiamo sempre bisogno di nuove opzioni.

L'implementazione di alcune delle pratiche di progettazione che abbiamo discusso nell'ultima sezione si è dimostrata efficace. Tuttavia, dobbiamo tenere presente che complica la programmazione in quanto tale. Ciò di cui abbiamo veramente bisogno è qualcosa che fornisca la potenza della concorrenza basata su thread senza gli effetti indesiderati che porta.

Una delle soluzioni a nostra disposizione sono i fili verdi. I thread verdi sono thread pianificati dalla libreria runtime invece di essere pianificati in modo nativo dal sistema operativo sottostante. Anche se questo non elimina tutti i problemi della concorrenza basata su thread, in alcuni casi può sicuramente darci prestazioni migliori.

Ora, non è banale usare thread verdi a meno che il linguaggio di programmazione che scegliamo di usare non lo supporti. Non tutti i linguaggi di programmazione hanno questo supporto integrato. Inoltre, ciò che chiamiamo vagamente thread verdi può essere implementato in modi davvero unici da diversi linguaggi di programmazione. Vediamo alcune di queste opzioni a nostra disposizione.

5.1. Goroutines in Go

Le Goroutine nel linguaggio di programmazione Go sono thread leggeri. Offrono funzioni o metodi che possono essere eseguiti contemporaneamente ad altre funzioni o metodi. Le Goroutine sono estremamente economiche in quanto occupano solo pochi kilobyte nella dimensione dello stack, tanto per cominciare .

Ancora più importante, le goroutine sono multiplexate con un numero inferiore di thread nativi. Inoltre, le goroutine comunicano tra loro utilizzando i canali, evitando così l'accesso alla memoria condivisa. Otteniamo praticamente tutto ciò di cui abbiamo bisogno e indovina un po ', senza fare nulla!

5.2. Processi a Erlang

In Erlang, ogni thread di esecuzione è chiamato processo. Ma non è proprio come il processo di cui abbiamo discusso finora! I processi Erlang sono leggeri con un ingombro di memoria ridotto e sono veloci da creare e smaltire con un basso sovraccarico di pianificazione.

Sotto il cofano, i processi di Erlang non sono altro che funzioni per le quali il runtime gestisce la pianificazione. Inoltre, i processi di Erlang non condividono alcun dato e comunicano tra loro tramite il passaggio di messaggi. Questo è il motivo per cui chiamiamo questi "processi" in primo luogo!

5.3. Fibre in Java (Proposta)

La storia della concorrenza con Java è stata una continua evoluzione. Java aveva il supporto per i thread verdi, almeno per i sistemi operativi Solaris, per cominciare. Tuttavia, questo è stato interrotto a causa di ostacoli che esulano dallo scopo di questo tutorial.

Da allora, la concorrenza in Java è tutta una questione di thread nativi e su come lavorarci in modo intelligente! Ma per ovvie ragioni, potremmo presto avere una nuova astrazione della concorrenza in Java, chiamata fibra. Project Loom propone di introdurre continuazioni insieme alle fibre, che potrebbero cambiare il modo in cui scriviamo applicazioni simultanee in Java!

Questa è solo un'anteprima di ciò che è disponibile in diversi linguaggi di programmazione. Ci sono modi molto più interessanti in cui altri linguaggi di programmazione hanno provato a gestire la concorrenza.

Inoltre, vale la pena notare che una combinazione di modelli di progettazione discussi nell'ultima sezione, insieme al supporto del linguaggio di programmazione per un'astrazione simile a un thread verde, può essere estremamente potente quando si progettano applicazioni altamente concorrenti.

6. Applicazioni ad alta concorrenza

Un'applicazione del mondo reale ha spesso più componenti che interagiscono tra loro in rete. In genere vi accediamo su Internet e si compone di più servizi come servizio proxy, gateway, servizio Web, database, servizio directory e file system.

Come possiamo garantire un'elevata concorrenza in tali situazioni? Esploriamo alcuni di questi livelli e le opzioni che abbiamo per creare un'applicazione altamente concorrente.

Come abbiamo visto nella sezione precedente, la chiave per creare applicazioni ad alta concorrenza è utilizzare alcuni dei concetti di progettazione discussi qui. Dobbiamo scegliere il software giusto per il lavoro, quelli che incorporano già alcune di queste pratiche.

6.1. Livello Web

Il Web è in genere il primo livello in cui arrivano le richieste degli utenti e il provisioning per un'elevata concorrenza è inevitabile qui. Vediamo quali sono alcune delle opzioni:

  • Node (chiamato anche NodeJS o Node.js) è un runtime JavaScript multipiattaforma open source basato sul motore JavaScript V8 di Chrome. Il nodo funziona abbastanza bene nella gestione delle operazioni di I / O asincrone. Il motivo per cui Node lo fa così bene è perché implementa un ciclo di eventi su un singolo thread. Il ciclo di eventi con l'aiuto dei callback gestisce tutte le operazioni di blocco come l'I / O in modo asincrono.
  • nginx è un server web open source che usiamo comunemente come proxy inverso tra i suoi altri usi. Il motivo per cui nginx offre un'elevata concorrenza è che utilizza un approccio asincrono basato sugli eventi. nginx opera con un processo master in un singolo thread. Il processo principale mantiene i processi di lavoro che eseguono l'elaborazione effettiva. Quindi, i processi di lavoro elaborano ogni richiesta contemporaneamente.

6.2. Livello applicazione

Durante la progettazione di un'applicazione, sono disponibili diversi strumenti per aiutarci a creare per un'elevata concorrenza. Esaminiamo alcune di queste librerie e framework a nostra disposizione:

  • Akka è un toolkit scritto in Scala per la creazione di applicazioni altamente concorrenti e distribuite sulla JVM. L'approccio di Akka alla gestione della concorrenza si basa sul modello di attore di cui abbiamo discusso in precedenza. Akka crea uno strato tra gli attori e i sistemi sottostanti. Il framework gestisce le complessità della creazione e della pianificazione di thread, della ricezione e dell'invio di messaggi.
  • Project Reactor è una libreria reattiva per la creazione di applicazioni non bloccanti sulla JVM. Si basa sulla specifica Reactive Streams e si concentra sul passaggio efficiente dei messaggi e sulla gestione della domanda (contropressione). Gli operatori e gli scheduler del reattore possono sostenere velocità di throughput elevate per i messaggi. Diversi framework popolari forniscono implementazioni di reattori, inclusi Spring WebFlux e RSocket.
  • Netty è un framework per applicazioni di rete asincrono, basato su eventi. Possiamo usare Netty per sviluppare server e client di protocollo altamente concorrenti. Netty sfrutta NIO, che è una raccolta di API Java che offre il trasferimento di dati asincrono tramite buffer e canali. Ci offre diversi vantaggi come una migliore velocità effettiva, una minore latenza, un minor consumo di risorse e ridurre al minimo le copie di memoria non necessarie.

6.3. Livello dati

Infine, nessuna applicazione è completa senza i suoi dati e i dati provengono da una memoria persistente. Quando parliamo di alta concorrenza rispetto ai database, la maggior parte dell'attenzione rimane sulla famiglia NoSQL. Ciò è dovuto principalmente alla scalabilità lineare che i database NoSQL possono offrire ma è difficile da ottenere nelle varianti relazionali. Diamo un'occhiata a due strumenti popolari per il livello dati:

  • Cassandra è un database distribuito NoSQL gratuito e open source che fornisce alta disponibilità, alta scalabilità e tolleranza agli errori su hardware di base. Tuttavia, Cassandra non fornisce transazioni ACID su più tabelle. Quindi, se la nostra applicazione non richiede una forte coerenza e transazioni, possiamo trarre vantaggio dalle operazioni a bassa latenza di Cassandra.
  • Kafka è una piattaforma di streaming distribuita . Kafka memorizza un flusso di record in categorie chiamate argomenti. Può fornire scalabilità orizzontale lineare sia per i produttori che per i consumatori dei record, fornendo allo stesso tempo un'elevata affidabilità e durata. Partizioni, repliche e broker sono alcuni dei concetti fondamentali su cui fornisce una concorrenza distribuita in modo massiccio.

6.4. Livello cache

Ebbene, nessuna applicazione web nel mondo moderno che mira a una concorrenza elevata può permettersi di raggiungere il database ogni volta. Questo ci lascia a scegliere una cache, preferibilmente una cache in memoria che può supportare le nostre applicazioni altamente concorrenti:

  • Hazelcast è un motore di elaborazione e archivio di oggetti in memoria distribuito, cloud-friendly che supporta un'ampia varietà di strutture di dati come Map , Set , List , MultiMap , RingBuffer e HyperLogLog . Dispone di replica integrata e offre alta disponibilità e partizionamento automatico.
  • Redis è un archivio di strutture dati in memoria che utilizziamo principalmente come cache . Fornisce un database chiave-valore in memoria con durabilità opzionale. Le strutture dati supportate includono stringhe, hash, elenchi e set. Redis ha la replica integrata e offre alta disponibilità e partizionamento automatico. Nel caso in cui non abbiamo bisogno di persistenza, Redis può offrirci una cache in memoria ricca di funzionalità, in rete e con prestazioni eccezionali.

Naturalmente, abbiamo appena scalfito la superficie di ciò che è a nostra disposizione nel nostro tentativo di creare un'applicazione altamente concorrente. È importante notare che, più del software disponibile, le nostre esigenze dovrebbero guidarci a creare un design appropriato. Alcune di queste opzioni potrebbero essere adatte, mentre altre potrebbero non essere appropriate.

E non dimentichiamo che ci sono molte altre opzioni disponibili che potrebbero essere più adatte alle nostre esigenze.

7. Conclusione

In questo articolo, abbiamo discusso le basi della programmazione concorrente. Abbiamo compreso alcuni degli aspetti fondamentali della concorrenza e dei problemi a cui può portare. Inoltre, abbiamo esaminato alcuni dei modelli di progettazione che possono aiutarci a evitare i problemi tipici della programmazione concorrente.

Infine, abbiamo esaminato alcuni dei framework, delle librerie e del software a nostra disposizione per creare un'applicazione end-to-end altamente concorrente.