Dr. Carlo Pescio
Architettura di Sistemi Reattivi, Parte 1

Pubblicato su Computer Programming No. 68


Iniziamo una serie di puntate dedicate al design architetturale, in cui svilupperemo un realistico framework ad oggetti per la gestione di impianti industriali. Le problematiche che affronteremo si ritrovano peraltro in una ampia categoria di sistemi, che va dalle centrali telefoniche alla realtà virtuale.

Chi ha seguito la mia vecchia serie di articoli sulla tecnologia ad oggetti (Computer Programming No. 34 - 45) sa bene che uno dei passi fondamentali della progettazione è il cosiddetto design architetturale. L'architettura del sistema determina le sue possibilità di crescita e di evoluzione, e fornisce una collocazione ideale per ogni elemento strutturale. Proprio come in una struttura abitativa non avviene (normalmente) di trovare una finestra nel pavimento, così un prodotto software fondato su una architettura solida tenderà a crescere in modo naturale ed omogeneo, anziché degenerare nel caos delle soluzioni improvvisate.
Naturalmente, la progettazione architetturale non è semplice. Oltre alla necessaria competenza, è necessario acquisire un senso estetico che guidi verso una soluzione elegante, permeata da quella qualità senza nome che tutti sappiamo riconoscere ma che è così difficile introdurre in un progetto.
Pensando ad alcuni articoli sul design architetturale, avrei voluto discutere un sistema reale, scelto fra i progetti che ho contribuito a portare a termine. Purtroppo, i soliti vincoli di riservatezza non mi consentono di diffondere i dettagli di tali progetti. Ho quindi deciso di affrontare il tema in senso più generale, discutendo le scelte di progettazione comuni ad una grande classe di sistemi, detti normalmente sistemi reattivi. Naturalmente, ho integrato nell'articolo l'esperienza che deriva dall'aver progettato sistemi simili in molti settori diversi. Il risultato sarà il design di un sistema non banale, che seppure non reale è comunque fortemente realistico.

Sistemi reattivi
Un sistema è detto reattivo quando il suo comportamento è influenzato da eventi che hanno luogo nel mondo reale, al di fuori dei computer che governano il sistema stesso. In risposta a tali eventi, il sistema aggiorna il suo stato e risponde con un feedback che nuovamente influenza il mondo esterno. Esempi tipici di sistemi reattivi sono gli impianti di controllo industriale, ma problematiche del tutto analoghe si riscontrano, ad esempio, nel software di controllo della rete bancomat o delle centrali telefoniche, in alcune apparecchiature biomediche, nei sistemi di realtà virtuale, ed in molte altre situazioni. Una categoria molto ampia di sistemi reattivi è rappresentata dai sistemi embedded, che si stanno sempre più innestando nella vita di ogni giorno: le automobili, gli elettrodomestici, i telefoni (tanto per citare qualche esempio) sono sempre più ricchi di "elettronica intelligente".
In genere un sistema reattivo ha una componente real-time, in quanto gli stimoli esterni devono essere gestiti entro tempistiche stabilite. Per la sua natura tipicamente mission-critical, un sistema reattivo richiede inoltre una particolare attenzione al design for testability: ad esempio, la possibilità di operare in regime di simulazione deve spesso far parte delle capacità intrinseche del sistema. Altre caratteristiche tipiche di un buon sistema reattivo sono la high availability (in teoria, dovrebbe essere sempre operativo, anche a fronte di problemi interni od esterni) e la semplicità di estensione ed adattamento a situazioni diverse (soprattutto per i sistemi di controllo industriale, che si trovano ad operare in ambienti estremamente eterogenei e soggetti a frequenti modifiche dei requisiti).

La struttura di massima che prenderò in esame sarà quella di figura 1, orientata a problematiche di tipo CIM (Computer Integrated Manufacturing). Possiamo vedere come diversi dispositivi di I/O verso il mondo fisico (indicati con "controllo dispositivi") si interfaccino con il sistema reattivo, informandolo degli eventi e ricevendo comandi. I dispositivi che si incontrano più frequentemente sul campo sono i PLC (Programmable Logic Controller), tipicamente connessi tramite porte seriali, oppure dei PC con schede di I/O programmabili. Non è comunque raro trovarsi di fronte a box Unix/VME, o a dispositivi hardware custom con uscite seriali: un buon sistema reattivo deve facilmente adattarsi a ricevere input da sorgenti diverse.
Un ulteriore sistema, spesso rappresentato da un mini-computer o da un mainframe, fornisce le informazioni relative alla produzione e riceve gli aggiornamenti via via che il processo avanza. Spesso la stessa macchina mantiene il database degli ordini, dei clienti, ed altri aspetti contabili della produzione. La connessione varia dalla rete ethernet ad una emulazione di terminale, ed anche in questo caso il sistema reattivo deve essere sufficientemente flessibile nell'interfacciarsi con il controllo di produzione. Infine, il sistema reattivo fornisce una serie di informazioni di stato ad una macchina di supervisione, molto spesso un personal con un package di tipo SCADA (Supervisory Control And Data Acquisition). In tempi più recenti, ho provato con un discreto successo ad abbandonare gli SCADA in favore di una composizione on-the-fly di pagine html accoppiata ad un browser sul lato supervisore.
Il sistema reattivo, rappresentato dalla nuvoletta centrale, non deve necessariamente risiedere su un'unica macchina: anzi, non di rado dovremo prevedere la possibilità di scalare il sistema su più computer. Riassumiamo quindi le caratteristiche che desideriamo per il nostro sistema reattivo:

Come vedremo, la tecnologia ad oggetti è uno strumento ideale per raggiungere questi obiettivi, attraverso la creazione di un framework misto black-box e white box [Pes98a].

Lo strato esterno
Iniziamo ora ad identificare i principali elementi del sistema reattivo. Il primo punto di variabilità del sistema è dovuto al grande numero di dispositivi esterni. Si tratta in gran parte di elementi fuori dal nostro controllo, già insediati negli impianti esistenti e pertanto praticamente impossibili da standardizzare. Un obiettivo importante è quindi l'isolamento di tale variabilità, che deve essere opportunamente nascosta da una interfaccia resiliente.
Nel paradigma ad oggetti, questo è facilmente ottenibile attraverso una classe interfaccia [Pes97] che definisce solo le funzionalità di più alto livello (configurazione, lettura, scrittura, verifica di eventuali errori, reset della periferica), demandando l'intera implementazione alle classi derivate (figura 2). Le classi derivate permettono di dialogare con un dispositivo collegato tramite porta seriale, o su rete con comunicazione tramite socket, o con netbios, o con named pipe, e così via. Se confrontiamo le varie classi derivate con i livelli ISO/OSI, esse risultano di norma eterogenee: non sempre la pratica riflette i modelli proposti dalla teoria, ed il nostro sistema è modellato per risolvere i problemi reali di interfacciamento a periferiche diverse, dalla schedina a microprocessore al mainframe.

La struttura di figura 2 è totalmente estendibile, anche se dobbiamo ancora risolvere qualche problema di realizzabilità (che vedremo in dettaglio nelle prossime puntate). Idealmente, ogni classe concreta sarà contenuta in una libreria a caricamento dinamico (DLL), che verrà specificata in un file di configurazione. In questo modo, possiamo cambiare il tipo di comunicazione con un dispositivo semplicemente aggiornando un file esterno: ad esempio, se passiamo da un PLC collegato via seriale ad uno collegato su rete ethernet, in teoria dovremmo essere in grado di adattare il sistema attraverso qualche modifica alla configurazione.
Naturalmente, la realtà non è così semplice. Occorre stabilire con precisione cosa significano "lettura" e "scrittura" a livello della classe I/O. Fondamentalmente abbiamo due scelte: la classe I/O conosce la struttura dei messaggi da/verso il dispositivo, oppure la classe I/O conosce solo i dettagli del protocollo di dialogo con il dispositivo (es. RS 232) ma non i contenuti.
Evidentemente, se vogliamo ottenere la massima riusabilità delle classi concrete di I/O e la massima configurabilità del sistema, ogni classe I/O deve occuparsi solo del trasferimento dei dati in formato raw, senza alcuna conoscenza della struttura dei messaggi. In caso contrario, non potremmo usare la stessa classe SerialIO per comunicare con due dispositivi connessi a porte seriali, ma con formato dei messaggi diverso.
Questo significa però rinunciare ad una parte di conoscenza sul sistema: ad esempio, se sappiamo che un dispositivo manderà sempre sequenze di 512 byte in modalità burst, ed arrivano invece 200 byte seguiti da un silenzio di (es.) tre secondi, dovremmo considerare il messaggio come "perso". La gestione dei ritardi sarebbe molto semplice all'interno della classe I/O, ma se disaccoppiamo l'I/O raw dalla gestione dei messaggi (tipicamente tramite una coda implementata come buffer circolare) rischiamo seriamente di perdere questo tipo di controllo.
Le soluzioni sono apparentemente due: o riusciamo ad utilizzare la struttura dei messaggi per "ricostruirli" a partire dal contenuto della coda, senza fare riferimento a ritardi et similia, o dobbiamo arricchire l'interfaccia di I/O per consentire un maggiore controllo. In genere, io prediligo il primo approccio (con una variante che vedremo tra breve). Infatti, se vogliamo realmente essere indipendenti dal tipo di connessione, non possiamo legare il riconoscimento dei messaggi alle tempistiche: una rete o una porta seriale si comportano in modo molto diverso. Quando questo non è possibile (ad es. alcuni vecchi protocolli usano necessariamente i ritardi intercarattere) la soluzione ideale è comunque un'altra: aggiungere una ulteriore specializzazione della classe di I/O concreta. Ad esempio, se il nostro protocollo prevede una comunicazione seriale, e richiede l'analisi dei tempi per separare i messaggi, anziché complicare l'interfaccia di I/O, ed anziché legare le parti interne del sistema reattivo a dettagli soggetti a variazione, creiamo una classe derivata da SerialI/O che conosce (in parte) il formato dei messaggi. Questa SmartSerialI/O (si veda sempre la figura 2) verrà vista dal sistema reattivo come una I/O qualsiasi, ma non metterà messaggi parziali nella coda: userà invece la sua conoscenza per riconoscere i messaggi errati, ed inserirà nella coda solo messaggi completi. Se cambiamo il tipo di connessione, mantenendo il formato dei messaggi ma cambiando i dettagli di tempistica, dobbiamo comunque cambiare solo un componente esterno. Chi ha seguito il mio corso di Systematic Object Oriented Design potrà riconoscere l'applicazione di una delle tecniche di trasformazioni elementari (PushDown Completo) per evitare di esporre dettagli interni di I/O. Se avessimo scelto la strada "ovvia", ovvero l'arricchimento dell'interfaccia della classe I/O e la conseguente esposizione di più dettagli interni, nelle stesse condizioni avremmo dovuto cambiare un modulo interno (almeno nella struttura attuale), ovvero il riconoscitore dei messaggi.
Lo strato esterno di I/O ha anche una ulteriore, importante funzione: la separazione tra hard e soft real-time. Una delle domande più frequenti che mi viene rivolta da chi inizia a strutturare un sistema reattivo secondo il modello ad oggetti è: "Come faccio ad associare una member function ad un interrupt?". La risposta giusta è quasi sempre: "non farlo".
Normalmente, una classe concreta di I/O si appoggia su un driver, che gestisce l'hardware di collegamento vero e proprio. All'interno del driver gestiamo gli interrupt e risolviamo tutte le problematiche di hard real-time. L'interfaccia tra driver e classe concreta di I/O è tipicamente un buffer, eventualmente con un semaforo per la notifica. Fuori dalla classe concreta di I/O, i tempi di reazione sono normalmente più lunghi: per rispondere ad un interrupt vogliamo sprecare pochi nanosecondi, ma per rispondere ad un intero messaggio da un dispositivo abbiamo in genere diversi millisecondi.
Questo tipo di sistemi è basato su un pattern noto come "Half-Synch/Half-Async" [SC96]. Lo "strato esterno" e quindi a sua volta composto da due strati, uno di basso livello ed implementato tipicamente come driver di sistema, ed uno di alto livello implementato come classe (tipicamente in un linguaggio ad elevate prestazioni come il C++).
Notiamo che lo strato esterno, che viene spesso detto (impropriamente, ma espressivamente) "livello fisico", isola anche il resto del sistema reattivo dalla comunicazione con i vari elementi del mondo reale. L'isolamento è per ora solo apparente, in quanto le restanti parti del sistema reattivo devono comunque conoscere il formato dei messaggi, la logica di gestione di ogni messaggio, eccetera. Si tratta nuovamente di elementi di variabilità che dovremo gestire opportunamente.
Infine, notiamo che attraverso la virtualizzazione dei dettagli "fisici" del mondo esterno abbiamo anche ottenuto una migliore testabilità del sistema. Possiamo usare una classe concreta di I/O "file" per pilotare in modo batch il sistema reattivo e verificarne il comportamento. Possiamo anche usare un "simulatore software" più complesso ed interattivo, connesso tramite una classe concreta di I/O "pipe", per testare più a fondo il sistema. Questo tipo di strumenti si rivela utile anche in fase di installazione, come ausilio per risolvere eventuali malfunzionamenti: diventa più facile capire se si tratta di un problema di comunicazione a basso livello (quindi delle classi di I/O concrete) o localizzata nel resto del sistema reattivo.
Un'ultima, importante nota prima di proseguire nella progettazione: il diagramma di figura 2 rappresenta (come tutti i diagrammi delle classi) una visione statica del sistema. Può trarre in inganno chi non è abituato a leggere i diagrammi UML, e suggerire che vi sia un unico oggetto I/O nell'intero sistema reattivo. Naturalmente non è così: vi saranno molti oggetti di classe derivata da I/O, tipicamente uno per ogni dispositivo esterno. Questo andrà sempre tenuto presente via via che proseguiremo con la progettazione.

Verso l'interno
Scendiamo ora ulteriormente all'interno della nuvoletta, cercando di fornire uno strato estendibile per la gestione dei messaggi. Ci preoccuperemo del versante opposto (la scrittura da parte del sistema reattivo verso il mondo esterno) nelle prossime puntate.
Se le classi concrete di I/O ci forniscono un trasferimento raw dei dati inviati dai dispositivi esterni, il primo compito da svolgere al livello successivo è di trasformare questa sequenza di byte in messaggi completi, pronti per essere gestiti dal sistema. A questo punto incontriamo diversi elementi di variabilità. Sicuramente, passando da un sistema ad un altro, capiterà di dover riconoscere diversi tipi di messaggio: i dispositivi esterni cambiano molto frequentemente. Può anche capitare che un messaggio logicamente equivalente (ad es. "pezzo scartato" proveniente da una macchina a controllo numerico) sia rappresentato da sequenze di byte diverse tra un sistema e l'altro. Infine, può facilmente capitare che lo stesso messaggio logico debba essere gestito in modo diverso tra un impianto e l'altro. Non sorprende quindi la tendenza piuttosto diffusa a considerare ogni impianto CIM come "unico nel suo genere", ed a costruirlo quasi da zero, oppure "cannibalizzando" progetti precedenti alla ricerca di un riuso Copy&Paste. È proprio qui che interviene l'importanza di una buona architettura.
Iniziamo con l'introdurre una classe astratta Parser, da cui vengono derivate le classi concrete che riconoscono i messaggi per ogni dispositivo esterno (figura 3). L'importanza di Parser verrà chiarita tra poco: per ora, pensiamo che serva al sistema come astrazione di un elemento di variabilità. I vari DeviceDependentParser saranno probabilmente implementati come macchine a stati. Nei casi più semplici (ad esempio, messaggi a lunghezza fissa), la macchina ha solo tre stati: messaggio completo e riconosciuto, messaggio incompleto, messaggio errato. Nei casi più complessi, esistono molti stati e il riconoscimento dei messaggi dipende dallo stato in cui ci si trova. Vediamo un semplice esempio: se i messaggi hanno un header standard e lunghezza variabile, riconosciuto l'header si entra in uno stato diverso, in cui anche i caratteri che di norma compongono l'header vengono riconosciuti come parte del corpo, sino a quando non si raggiunge la fine del messaggio e si torna allo stato iniziale.

L'implementazione delle macchine a stati non verrà presa in considerazione in questa serie di articoli. Esistono molte alternative interessanti nel paradigma ad oggetti, dal semplice pattern State [GoF95] alle macchine a stati a tre livelli [Mar95], sino a macchine a stati componibili [SC95]. Molto spesso, esigenze di performance finiscono per far pendere la bilancia in favore degli approcci più semplici ed hard-coded: nella nostra struttura, non si tratta di un grande problema, in quanto il parser dei messaggi è comunque una classe isolata, possibilmente implementata in una libreria a caricamento dinamico. Questo riporta immediatamente all'utilità della classe astratta Parser: se i parser device-dependent sono in librerie a caricamento dinamico, il resto del sistema non può neppure conoscere i nomi delle classi in esse contenute. È allora indispensabile (e lo vedremo ancora meglio fra breve) che al di fuori delle DLL tali classi vengano viste attraverso una interfaccia stabile, rappresentata dalla classe astratta Parser.
Il parser device-dependent genera un messaggio device-independent. È importante capire bene il ruolo di questo passaggio: molte volte eventi del tutto simili (completamento di un pezzo, scarti, passaggio ad una fase successiva) vengono comunicati dai diversi dispositivi in modo diverso. Il ruolo delle tre nuove classi che abbiamo introdotto in figura 3 è di disaccoppiare il messaggio logico dai dettagli dipendenti dalla periferica. Possiamo dire che, mentre la classe I/O serve a fornire un'astrazione dell'interfaccia verso il device, le classi Parser e DeviceIndependentMessage servono a fornire un'astrazione del device stesso.
Naturalmente, non siamo costretti ad implementare tale strato di astrazione per ogni device: se siamo sicuri che un certo tipo di dispositivo sia fisso per tutti i nostri impianti, possiamo tranquillamente lavorare in termini di messaggi device-dependent. In genere, questo tipo di semplificazione torna indietro a perseguitarci nei progetti successivi, ma qui si introducono una serie di valutazioni sui rischi del progetto che è meglio rimandare ad altra sede.
Abbiamo per ora raggiunto due degli obiettivi di cui sopra: possiamo facilmente cambiare il riconoscitore dei messaggi per adattarci a nuovi dispositivi, e possiamo continuare a vedere messaggi di più alto livello all'interno del sistema. Dobbiamo ancora pensare alla gestione di tali messaggi, compreso il feedback verso la sorgente, che come abbiamo visto costituisce un ulteriore punto di variabilità. Di questo ci occuperemo nella prossima puntata, in quanto dobbiamo ancora soffermarci su un elemento centrale del progetto.

Modelli di concorrenza
Il diagramma di figura 3 è un po' anomalo perché non compare alcun oggetto attivo. Non compare neanche la tipica classe Thread, pressoché indispensabile in un sistema reattivo implementato con linguaggi ad oggetti "classici" dal punto di vista della concorrenza [Pes98b].
La mancanza è voluta, perché dobbiamo ora compiere una importante scelta architetturale. Possiamo rendere ogni parser un oggetto attivo (un thread), che rimane in attesa di input dalla classe di I/O a lui associata, crea un messaggio device-independent, e lo gestisce nel suo contesto di esecuzione. In questo modo non possiamo leggere/creare nuovi messaggi sino a quando non abbiamo completato la gestione del messaggio corrente.
Alternativamente, possiamo avere un unico thread di lettura per tutti gli oggetti di I/O, fermo in attesa di dati da uno qualunque di essi. Quando arrivano i dati, esegue il codice del parser associato nel proprio contesto di esecuzione, ma fa partire un nuovo thread per gestire i messaggi completi. In questo modo la sola fase bloccante è il parsing di un messaggio, ma non la sua gestione (che si suppone più lunga); questa soluzione è fondamentalmente basata sul pattern Reactor [Sch95]. Notiamo che in questo caso l'importanza della classe astratta Parser diviene ancora più chiara, in quanto il thread di lettura si interfaccia con i vari parser concreti (che non conosce) proprio attraverso questa interfaccia.
Infine possiamo scegliere la soluzione a maggior parallelismo interno: ogni parser viene associato ad un thread, e la gestione di ogni messaggio riconosciuto viene a sua volta associata ad un nuovo thread, eventualmente con un pooling. Nuovamente, la classe Parser si rivela importante: è attraverso di essa che i parser concreti ereditano lo status di oggetto attivo.
La scelta fra le tre alternative non è semplice, né può essere facilmente fattorizzata al di fuori del framework applicativo che stiamo costruendo. In genere, la prima soluzione funziona bene solo quando i dispositivi collegati si basano su un protocollo mono-direzionale, nel qual caso è decisamente la più efficiente. La seconda è particolarmente adatta ai casi in cui la frequenza con cui ogni dispositivo dialoga con il sistema è bassa: a quel punto, evitiamo di avere molti thread sospesi usandone uno solo per tutti i dispositivi. In tutti gli altri casi, la terza soluzione è la migliore, anche se richiede una attenzione maggiore alla sincronizzazione tra i vari thread. Peraltro, se la associamo ad una tecnica di pooling dei thread, possiamo comunque effettuare un tuning adeguato per l'hardware su cui deve girare il sistema.

Conclusioni
Dovendo prendere una decisione generale, indipendente dalle caratteristiche peculiari dei vari sistemi, baserò il framework sulla terza soluzione (a parallelismo massimo con pooling). Nella prossima puntata, completeremo il diagramma delle classi specificando gli oggetti attivi, il pool dei thread, e vedremo come gestire in modo estendibile ed allo stesso tempo con il massimo di riusabilità i messaggi device-independent. Riprenderemo anche le problematiche di design for testability, che abbiamo solo accennato in questa puntata. Più avanti completeremo il ciclo, fornendo il necessario feedback al mondo esterno, e scenderemo in alcuni importanti particolari di design (come le responsabilità di istanziazione), fondamentali per creare un sistema che sia non solo elegante ma anche effettivamente realizzabile.

Reader's Map
Molti visitatori che hanno letto
questo articolo hanno letto anche:

Biografia
Carlo Pescio svolge attività di consulenza e formazione in ambito internazionale nel campo delle tecnologie Object Oriented. Ha svolto la funzione di Software Architect in grandi progetti per importanti aziende europee e statunitensi. È l’ideatore del metodo di design ad oggetti SysOOD (Systematic Object Oriented Design), ed autore di numerosi articoli su temi di ingegneria del software, programmazione C++ e tecnologia ad oggetti, apparsi sui principali periodici statunitensi. Laureato in Scienze dell’Informazione, è membro dell’ACM e dell’IEEE.

Bibliografia
[GoF95] Gamma ed altri, "Design Patterns", Addison-Wesley, 1995.
[Mar95] Robert Martin, "Three Level FSM", in Pattern Languages of Program Design Vol. 1, Addison-Wesley, 1995.
[Pes97] Carlo Pescio, "Oggetti ed Interfacce", Computer Programming No. 63.
[Pes98a] Carlo Pescio, "Oggetti e Componenti COM", Computer Programming No. 67.
[Pes98b] Carlo Pescio, "Oggetti e Thread", Computer Programming No. 65.
[SC95] Aamod Sane, Roy Campbell, "Object-Oriented State Machines: Subclassing, Composition, Delegation, and Genericity", Proceedings of ACM OOPSLA '95.
[SC96] Douglas C. Schmidt, Charles D. Cranor: "Half-Sync/Half-Async: An Architectural Pattern for Efficient and Well-Structured Concurrent I/O", in Pattern Languages of Program Design Vol. 2, Addison-Wesley, 1996.
[Sch95] Douglas C. Schmidt, "Reactor: An Object Behavioral Pattern for Concurrent Event Demultiplexing and Event Handler Dispatching", in Pattern Languages of Program Design Vol. 1, Addison-Wesley, 1995.