Linea guida: Simultaneità
Questa guida aiuta gli sviluppatori nella scelta del metodo migliore per soddisfare le esigenze di simultaneità in un sistema software.
Relazioni
Descrizione principale

Introduzione

Una buona progettazione si ottiene scegliendo il modo "migliore" per soddisfare una serie di requisiti. Una buona progettazione di un sistema simultaneo si ottiene scegliendo il modo più semplice per soddisfare le esigenze di simultaneità. Una delle prime regole per i progettisti è di evitare di reinventare nuovi schemi. Sono stati sviluppati degli ottimi pattern ed idiomi di progettazione per la risoluzione della maggior parte dei problemi. Data la complessità dei sistemi simultanei, è ragionevole utilizzare delle soluzioni comprovate e mirare alla semplicità della progettazione.

Approcci alla simultaneità

Le attività simultanee che hanno luogo interamente all'interno di un computer vengono denominate thread di esecuzione. Come tutte le attività simultanee, i thread di esecuzione sono un concetto astratto poiché si verificano nel tempo. Il modo migliore per catturare fisicamente un thread di esecuzione è di rappresentarne lo stato in un particolare istante nel tempo.

Il modo più diretto per rappresentare delle attività simultanee che utilizzano dei computer è di dedicare un computer separato per ogni attività. Tuttavia in genere si tratta di un metodo troppo dispendioso e non sempre porta alla soluzione dei conflitti. E' cosa usuale quindi supportare più attività sullo stesso processore fisico mediante qualche forma di multi-tasking. In questo caso, il processore e le relative risorse associate, ad esempio la memoria ed i bus, vengono condivisi. (Sfortunatamente questa condivisione di risorse può portare anche a dei nuovi conflitti, che non erano presenti nel problema originale).

La forma più comune di multi-tasking è di fornire ad ogni attività un processore "virtuale". Il processore virtuale in genere viene denominato un processo o un attività. Di solito, ogni processo dispone di un proprio spazio di indirizzo che viene distinto logicamente dallo spazio di indirizzo di altri processori virtuali. Ciò protegge i processi dai conflitti reciproci rispetto a sovrascritture accidentali nella memoria degli altri. Sfortunatamente le spese richieste per passare il processore fisico da un processo ad un altro sono spesso proibitive. Implica un significativo swap di insiemi di registro all'interno della CPU (passaggio di contesto) che persino con i processori moderni ad alta velocità potrebbe richiedere centinaia di microsecondi.

Per ridurre queste spese, molti sistemi operativi forniscono la possibilità di includere più thread leggeri in un singolo processo. I thread contenuti in un processo condividono lo spazio di indirizzo di quel processo. Ciò riduce la spesa implicata nel passaggio di contesto ma aumenta le probabilità di conflitti di memoria.

Per alcune applicazioni ad alta produttività, persino la spesa del passaggio di thread leggeri può risultare troppo alta. In situazioni di questo genere è usuale disporre di una forma di multi-tasking ancora più leggera, ottenuta sfruttando alcune funzioni speciali dell'applicazione.

i requisiti di simultaneità del sistema possono avere un impatto drammatico sull'architettura del sistema. La decisione di spostare la funzionalità da un'architettura a processi unici ad un'architettura multi-processo introduce delle significative modifiche alla struttura del sistema, in molte dimensioni. Potrebbe essere necessario introdurre degli ulteriori meccanismi (ad esempio delle chiamate di procedura remote) che cambiano in maniera sostanziale l'architettura del sistema.

Devono essere tenuti in considerazione i requisiti di disponibilità del sistema, oltre alla ulteriore spesa relativa alla gestione di processi e thread aggiuntivi.

Come per la maggior parte delle decisioni strutturali, la modifica dell'architettura dei processi scambia in modo efficace un insieme di problemi con un altro:

Approccio

Vantaggi

Svantaggi

Processo unico, nessun thread
  • Semplicità
  • Rapida messaggistica fra i processi
  • Carico di lavoro difficile da bilanciare
  • Nessuna scalabilità a più processori
Processo unico, multi-thread
  • Rapida messaggistica fra i processi
  • Multi-tasking senza comunicazione fra i processi
  • Migliore multi-tasking senza spese di processi 'pesanti'
  • L'applicazione deve essere 'sicura per i thread'
  • Il sistema operativo deve disporre di una efficace gestione dei thread
  • Devono essere tenute in considerazione le problematiche relative alla memoria condivisa
Multi-processo
  • Buona scalabilità quando si aggiungono dei processori
  • Relativamente facile da distribuire nei nodi
  • Sensibile al boundary del processo: il troppo utilizzo di comunicazione fra i processi inficia sulle prestazioni
  • Swap e passaggi di contesto dispendiosi
  • Più difficile da progettare

Un tipico percorso evolutivo è di iniziare con un'architettura a processo unico, aggiungendo dei processi per i gruppi di comportamenti che devono verificarsi simultaneamente. All'interno di questi raggruppamenti più vasti, considerare delle ulteriori esigenze di simultaneità, aggiungendo i thread nei processi per aumentare la simultaneità.

Il punto iniziale di avvio è di assegnare molti oggetti attivi ad una singola attività o thread di sistema operativo, utilizzando uno scheduler di oggetti attivi costruito a questo scopo - in questo modo in genere è possibile ottenere una simulazione molto leggera di simultaneità, anche se con una singola attività o thread di sistema operativo non è possibile sfruttare le macchine con più CPU.  La decisione chiave è di isolare in thread separati il comportamento bloccante, in modo che non diventi un collo di bottiglia. Questo risulterà in una separazione degli oggetti attivi con comportamento bloccante in propri thread del sistema operativo.

Nei sistemi in tempo reale questo ragionamento riguarda ugualmente le capsule - ogni capsula dispone di un thread logico di controllo, che può condividere o non condividere un thread di sistema operativo, un'attività o un processo con altre capsule.

Problematiche

Sfortunatamente, come molte decisioni strutturali, non esistono risposte semplici; la giusta soluzione implica un approccio attentamente bilanciato. E' possibile utilizzare dei piccoli prototipi strutturali per esplorare le implicazioni di un particolare insieme di scelte. Quando si crea un prototipo di architettura del processo, focalizzare l'attenzione sulla scalabilità del numero di processi fino al massimo teorico per il sistema. Considerare le seguenti problematiche:

  • Il numero di processi può essere scalato fino al massimo? Quanto può essere spinto il sistema oltre il massimo? Esiste la possibilità per una potenziale crescita?
  • Qual è l'impatto della modifica di alcuni dei processi in thread leggeri che operano in uno spazio di indirizzo condiviso del processo?
  • Cosa accade ai tempi di risposta quando vengono aggiunti dei processi? Quando viene aumentata la quantità di comunicazione fra processi (IPC, Inter-Process Communication)? Si nota un degrado?
  • La quantità di IPC potrebbe essere ridotta combinando o riorganizzando i processi? Questa modifica porterebbe a grossi processi monolitici difficili da bilanciare?
  • E' possibile utilizzare la memoria condivisa per ridurre l'IPC?
  • Quando vengono assegnate delle risorse di tempo, tutti i processi ottengono "tempi uguali"? E' possibile eseguire l'assegnazione di tempo? Esistono delle potenziali controindicazioni alla modifica delle priorità di pianificazione?

Comunicazione fra gli oggetti

Gli oggetti attivi possono comunicare fra loro in modo sincrono o asincrono. La comunicazione sincrona è utile perché può semplificare le collaborazioni complesse mediante sequenze controllate rigidamente. Vale a dire, quando un oggetto attivo esegue un passo di esecuzione al completamento (run-to-completion) che implica dei richiami sincroni di altri oggetti attivi, le eventuali interazioni simultanee avviate da altri oggetti possono essere ignorate finché non viene portata a termine la sequenza completa.

Anche se in alcuni casi è utile, può anche essere problematico poiché potrebbe accadere che un evento ad altra priorità più importante debba attendere (inversione di priorità). Questo viene esacerbato dalla possibilità che l'oggetto richiamato in modo sincrono possa lui stesso essere bloccato in attesa di una risposta ad un richiamo sincrono proprio. Questo può portare ad un'illimitata inversione di priorità. Nel caso più estremo, se esiste circolarità nella catena dei richiami sincroni può portare ad una condizione di stallo (deadlock).

Le chiamate asincrone evitano questo problema abilitando i tempi di risposta collegati. Tuttavia, in base all'architettura software, la comunicazione asincrona porta spesso ad un codice più complesso, poiché un oggetto attivo potrebbe dover rispondere in qualunque momento a diversi eventi asincroni (ognuno dei quali potrebbe implicare una complessa sequenza di interazioni asincrone con altri oggetti attivi). Questo può essere molto difficile da implementare e propenso agli errori.  

L'uso di una tecnologia di messaggistica asincrona con la consegna assicurata del messaggio può semplificare l'attività di programmazione applicativa. L'applicazione può continuare l'operazione anche se la connessione alla rete o l'applicazione remota non sono disponibili. La messaggistica asincrona non ne preclude l'utilizzo in modalità sincrona. La tecnologia sincrona richiede che sia disponibile una connessione ogni volta che è disponibile l'applicazione. Poiché è noto che esiste una connessione, la gestione dell'elaborazione del commit può risultare più facile.

Nell'approccio consigliato in Rational Unified Process per i sistemi real-time, le  capsule comunicano in modo sincrono mediante l'utilizzo di segnali, secondo dei  protocolli particolari. E' possibile, tuttavia, ottenere la comunicazione sincrona mediante l'utilizzo di coppie di segnali, uno in ciascuna direzione.

Pragmatica

Nonostante la spesa per il passaggio di contesto degli oggetti attivi sia molto bassa, è possibile che qualche applicazione ritenga il costo non accettabile. Questo in genere accade in situazioni in cui grossi quantitativi di dati devono essere elaborati ad un'alta velocità. In quei casi potrebbe essere necessario ripiegare sull'utilizzo di oggetti passivi e su tecniche di gestione della simultaneità più tradizionali (ma a più alto rischio), ad esempio i semafori.

Queste considerazioni, tuttavia, non implicano necessariamente il completo abbandono dell'approccio oggetti attivi. Anche in applicazioni con dati intensivi, spesso accade che la parte sensibile delle prestazioni sia una parte relativamente piccola del sistema globale. Ciò implica che il resto del sistema può ancora sfruttare il paradigma degli oggetti attivi.

In generale, le prestazioni sono solo uno dei criteri di progettazione, quando si tratta di progettazione di sistemi. Se il sistema è complesso, anche altri criteri, come la manutenibilità, la semplicità di modifica, la comprensibilità, ecc., sono ugualmente importanti, se non addirittura più importanti. L'approccio oggetti attivi ha un chiaro vantaggio perché nasconde gran parte della complessità della gestione della simultaneità, pur consentendo l'espressione della progettazione in termini specifici per l'applicazione, di contro ai meccanismi di basso livello specifici per tecnologia.

Euristica

Interazioni fra componenti simultanei

I componenti simultanei senza interazioni sono un problema quasi irrilevante. Quasi tutte le sfide di progettazione hanno a che fare con le interazioni fra le attività simultanee, è quindi necessario prima concentrare le energie sulla comprensione delle interazioni. Alcune delle domande da porre sono:

  • L'interazione è unidirezionale, bidirezionale o multidirezionale?
  • E' presente una relazione client-server o master-slave?
  • E' richiesta qualche forma di sincronizzazione?

Una volta compresa l'interazione, è possibile dedicarsi ai metodi per implementarla. L'implementazione deve essere scelta per produrre la più semplice progettazione coerente con gli obiettivi di prestazione del sistema. I requisiti di prestazione generalmente includono una produttività globale ed una latenza accettabile nella risposta ad eventi generati esternamente.

Queste problematiche sono anche più critiche per i sistemi real-time, che spesso sono meno tolleranti alle variazioni di prestazione, ad esempio 'jitter' nei tempi di risposta, o scadenze mancate.

Isolamento ed incapsulamento delle interfacce esterne

Non è una buona tattica includere in un'applicazione delle presupposizioni specifiche relative a interfacce esterne, e non è efficiente disporre di diversi thread di controllo bloccati in attesa di un evento. Assegnare, invece, ad un singolo oggetto un'attività dedicata di rilevazione dell'evento. Quando si verifica l'evento, l'oggetto informa chiunque debba esserne messo al corrente. Questa progettazione si basa su un pattern di progettazione ben conosciuto e comprovato, il pattern "Observer" [GAM94]. Per una flessibilità anche maggiore può essere facilmente esteso al "pattern publisher-subscriber," dove un oggetto publisher agisce da intermediario fra i rilevatori di eventi e gli oggetti interessati all'evento (i "subscriber") [BUS96].

Isolamento ed incapsulamento del comportamento di blocco e di polling

Le azioni in un sistema possono essere attivate col verificarsi di eventi generati esternamente. Un evento molto importante generato esternamente può essere semplicemente il trascorrere del tempo, come viene rappresentato dal ticchettio dell'orologio. Altri eventi esterni provengono da unità di input collegate a dell'hardware esterno, incluse le unità di interfaccia utente, i sensori di processi ed i collegamenti di comunicazione ad altri sistemi. Ciò è vero in maniera schiacciante per i sistemi real-time, che tipicamente hanno un'elevata connettività con il mondo esterno.

Affinché il software possa rilevare un evento, deve essere bloccato in attesa di un'interruzione o deve periodicamente verificare l'hardware per controllare se si è verificato l'evento. In quest'ultimo caso, il ciclo periodico deve essere breve, per evitare di perdere un evento breve o più ricorrenze o semplicemente per minimizzare la latenza fra il verificarsi dell'evento e la sua rilevazione.

La cosa interessante è che a prescindere da quanto un evento possa raro, del software deve essere bloccato in sua attesa o deve frequentemente verificarne la presenza. Molti (se non la maggior parte) degli eventi che un sistema deve gestire sono rari; per la maggior parte del tempo, in qualsiasi sistema, non accade nulla di significativo.

Il sistema di ascensori ne fornisce un buon numero di esempi. Gli eventi importanti nel ciclo di vita di un ascensore sono la chiamata di servizio, la selezione del piano da parte del passeggero, una mano del passeggero che blocca la porta e passare da un piano a quello successivo. Alcuni di questi eventi richiedono una risposta con tempi critici ma sono tutti estremamente rari in confronto alla scala di tempi relativa ai tempi di risposta desiderati.

Un singolo evento può attivare molte azioni e le azioni possono dipendere dagli stati di vari oggetti. Inoltre, differenti configurazioni di un sistema possono utilizzare lo stesso evento in modo diverso. Ad esempio, quando un ascensore passa un piano, il display nella cabina dell'ascensore deve essere aggiornato e l'ascensore stesso deve sapere dove si trova, in modo da sapere come rispondere alle nuove chiamate e alle selezioni di piani dei da parte dei passeggeri. Potrebbero essere o non essere presenti su ogni piano dei display relativi alla posizione dell'ascensore.

Preferenza per il comportamento reattivo piuttosto che comportamento polling

Il polling è dispendioso; richiede che una parte del sistema interrompa le attività periodicamente per controllare se si è verificato un evento. Se all'evento deve essere fornita rapidamente una risposta, il sistema deve controllare piuttosto di frequente l'arrivo di eventi, limitando ulteriormente la quantità di lavoro che può essere portata a termine.

E' molto più efficiente assegnare un'interruzione all'evento, con l'attivazione del codice dipendente dall'evento effettuata dall'interruzione. Sebbene le interruzioni vengano a volte evitate perché considerate "dispendiose", un loro giudizioso utilizzo può essere di gran lunga più efficiente di un polling ripetuto.

I casi in cui le interruzioni sono preferite come meccanismo di notifica eventi sono quelle in cui l'arrivo dell'evento è casuale ed occasionale, a tal punto che la maggior parte degli interventi di polling attestano che l'evento non si è verificato. I casi in cui è preferibile il polling sono quelli in cui gli eventi arrivano in maniera regolare e prevedibile e la maggior parte degli interventi di polling rilevano che l'evento si è verificato. In mezzo a queste due casistiche vi è un punto in cui è indifferente utilizzare il comportamento reattivo o di polling (vanno bene entrambi e la scelta ha poca importanza). Nella maggior parte dei casi, tuttavia, data la casualità degli eventi nel mondo reale, si preferisce il comportamento reattivo.

Preferenza per la notifica dell'evento piuttosto che la diffusione dei dati

La diffusione dei dati (di solito tramite segnali) è costosa ed in genere dispersiva (solo pochi oggetti potrebbero essere interessati ai ai dati ma tutti (o molti) devono interrompersi per esaminarli. Un approccio migliore, che utilizza meno risorse, è l'utilizzo della notifica per informare solo gli oggetti interessati che si è verificato un evento. Limitare la diffusione di dati ad eventi che richiedono l'attenzione di molti oggetti (in genere eventi di sincronizzazione).

Utilizzo cospicuo di meccanismi leggeri e scarso utilizzo di meccanismi pesanti

Nello specifico:

  • Utilizzare oggetti passivi e chiamate di metodo sincrono quando la simultaneità non è un problema ma lo è una risposta istantanea.
  • Utilizzare gli oggetti attivi ed i messaggi asincroni per la maggioranza dei concetti di simultaneità a livello applicativo.
  • Utilizzare thread OS per isolare gli elementi bloccanti. Un oggetto attivo può essere associato ad un thread OS.
  • Utilizzare i processi OS per il massimo isolamento. Sono necessari dei processi separati se i programmi devono essere avviati e chiusi in maniera indipendente, e per i sottosistemi che devono essere distribuiti.
  • Utilizzare delle CPU separate per la distribuzione fisica o per la potenza non elaborata.

Forse la linea guida più importante per lo sviluppo di applicazioni simultanee efficienti è di utilizzare il più possibile meccanismi leggeri di simultaneità. Sia l'hardware che il software del sistema operativo giocano un ruolo importante nel supportare la simultaneità, ma entrambi forniscono dei meccanismi relativamente pesanti, lasciando al progettista dell'applicazione un pesante carico di lavoro. Resta da costruire un ponte fra il divario esistente fra i tool disponibili e le esigenze delle applicazioni simultanee.

Gli oggetti attivi sono utili per colmare questo divario in virtù di due funzioni chiave:

  • Unificano le astrazioni della progettazione incapsulando l'unità base della simultaneità (un thread di controllo), che può essere implementata utilizzando uno qualunque dei meccanismi sottostanti forniti dal sistema operativo o dalla CPU.
  • Quando gli oggetti attivi condividono un unico thread OS, divengono un meccanismo di simultaneità leggero e molto efficiente, che altrimenti dovrebbe essere implementato direttamente nell'applicazione.

Gli oggetti attivi costituiscono inoltre un ambiente ideale per gli oggetti passivi forniti dai linguaggi di programmazione. Progettare un sistema interamente da una base di oggetti simultanei senza artefatti procedurali, come i programmi ed i processi, porta a delle progettazioni più modulari, coesive e comprensibili.

Evitare il fanatismo per le prestazioni

Nella maggior parte dei sistemi meno del 10% di codice utilizza più del 90% dei cicli di CPU.

Molti progettisti di sistema agiscono come se ogni riga di codice dovesse essere ottimizzata. E' necessario invece cercare di ottimizzare quel 10% di codice che viene eseguito più spesso o che richiede molto tempo. Progettare il restante 90% con enfasi sulla comprensibilità, la manutenibilità, la modularità e la facilità di implementazione.

Scelta dei meccanismi

I requisiti non funzionali e l'architettura del sistema influenzeranno la scelta del meccanismo utilizzato per implementare le chiamate di procedure remote.  Di seguito viene presentata una panoramica dei tipi di compromessi fra le alternative. 

Meccanismo Usi Commenti
Messaggistica Accesso asincrono ai server dell'impresa Il middleware di messaggistica può semplificare l'attività di programmazione applicazioni gestendo le code, i timeout e le condizioni di recupero/riavvio. È possibile utilizzare il middleware di messaggistica anche in modalità pseudo-sincrona. In genere, la tecnologia di messaggistica può supportare messaggi di grosse dimensioni. Alcuni approcci RPC possono essere limitati nella dimensioni dei messaggi, richiedendo ulteriore programmazione per gestire i messaggi grossi.
JDBC/ODBC Chiamate al database Si tratta di interfacce indipendenti dal database per programmi applicativi o servlet Java, per effettuare chiamate ai database che risiedono sullo stesso o su un altro server.
Interfacce native Chiamate al database Molti fornitori di database hanno implementato delle interfacce native del programma applicativo nei loro database, che offrono un vantaggio nelle prestazioni su ODBC, a spese della portabilità dell'applicazione.
Chiamata di procedura remota Per chiamare i programmi su server remoti Potrebbe non essere necessario programmare a livello RPC, se si dispone di un generatore di applicazioni che si occupa di questo.
Conversazionale Poco usato nelle applicazioni e-business In genere comunicazioni di basso livello da programma a programma che utilizzano protocolli come APPC o Socket.

Riepilogo

Molti sistemi richiedono un comportamento simultaneo e dei componenti distribuiti. La maggior parte dei linguaggi di programmazione fornisce scarso apporto in relazione a queste problematiche. Si è visto che sono necessarie delle buone astrazioni per comprendere sia l'esigenza di simultaneità nelle applicazioni che individuare le opzioni per implementarla nel software. Si è visto anche che, paradossalmente, mentre il software simultaneo è intrinsecamente più complesso di un software non simultaneo, è anche in grado di semplificare enormemente la progettazione dei sistemi che devono utilizzare la simultaneità nel mondo reale.