Sincronizzazione di comportamenti e attività

Comportamenti e attività possono richiedere l'esecuzione concorrente. Una attività può contenere un fork di due flussi di esecuzione. Un messaggio ricevuto può generarne due, e il secondo andrebbe inviato subito dopo il primo senza attendere la conclusione di tutti i comportamenti che vengono attivati dal primo.

Queste esecuzioni concorrenti usano gli stessi dati, quelli delle classi dati Autobus, Autista, ecc. È quindi necessario garantire la coerenza dei dati. L'implementazione iniziale ignora il problema.

Flussi paralleli nelle attività

L'attività di manutenzione contiene tre flussi di esecuzione separati dal fork. Vengono realizzati in Java con i thread. I dati che leggono e scrivono sono gli stessi: le classi Autista, Autobus, ecc. incluse le loro associazioni.

Flussi paralleli nei comportmenti

Il messaggio di entrata ricevuto dall'autobus genera due altri messaggi di entrata verso l'autista e verso l'autorimessa. Nell'implemnentazione semplificata del comportamento questi vengono inviati in modo sequenziale:

	this.precedente.entrata(this);		// messaggio ad Autista
	this.autorimessa.entrata(this);		// messaggio ad Autorimessa

Il secondo messaggio non viene inviato fino a che non termina l'esecuzione del primo metodo. Ma questo a sua volta può richiedere l'invio di altri messaggi oppure delle attese di eventi esterni. Fino a che tutto questo non è finito, il secondo messaggio non viene inviato.

La soluzione è gestire questi messaggi in thread separati.

Sincronizzazione

Sia per le attività che per i comportamenti, i thread accedono agli stessi dati: Autobus, Autorimessa, ecc e le loro associazioni. In questi casi, è necessario tenere conto che un thread può venire sospeso in qualsiasi momento per passare a un altro.

Questo crea un problema di coerenza dei dati. Per esempio, un thread aggiorna l'associazione fra autobus e autorimessa:

autobus.setrimessa(rimessa);
rimessa.aggiungiautobus(autobus);

Il thread può venire sospeso a metà fra le due istruzioni, lasciando l'associazione incoerente: l'autobus risulta nell'autorimessa, ma non è fra gli autobus che si trovano nell'autorimessa.

L'altro thread potrebbe leggere lo stato dell'associazione, ma potrebbe anche modificarla in modo opposto:

autobus.setRimessa(null);
rimessa.eliminaAutobus(autobus);

Quando questo termina viene ripreso il primo, che esegue rimessa.aggiungiAutobus(autobus);. Alla fine, l'autorimessa dell'autobus è null, ma l'autorimessa contiene ancora l'autobus. La sequenza

                                    |
autobus.setrimessa(rimessa);        |
                                    v
                                     --->  
                                         |   autobus.setRimessa(null);
                                         |   rimessa.eliminaAutobus(autobus);
                                         v
                                     <---
                                    |
rimessa.aggiungiautobus(autobus);   |
                                    v

Lo stesso problema si può anche verificare con associazioni non doppie e con attributi semplici, nel caso in cui i cambiamenti da fare sono più di uno.

Per risolvere il problema ogni messaggio deve essere eseguito in modo non interrompibile dagli altri, e lo stesso per le attività che leggono e scrivono le classi dati.

Ridondanza dei dati

Il problema con le associazioni a responsabilità doppia è un caso specifico del problema generale di mantenere dati ridondanti. Il collegamento da autobus a rimessa e quello da rimessa ad autobus indicano infatti la stessa cosa: che l'autobus si trova in una rimessa. È la stessa informazione ripetuta due volte. Questa ridondanza è necessaria per poter risalire facilmente sia dall'autobus alla rimessa in cui si trova che dalla rimessa agli autobus che contiene.

Essendo la stessa informazione, i due collegamenti devono coincidere: quello da autobus a rimessa e quello da rimessa ad autobus. In generale, le versioni multiple della stessa informazione all'interno della applicazione devono essere essere uguali. Vanno cambiate insieme. Fra la modifica di una e quella dell'altra il gestore dei thread può passare il controllo a un altro thread che le modifica in modo diverso. Nell'esempio che segue due variabili a e b dovrebbero contenere sempre lo stesso intero.

thread 1            thread 2
--------            --------
          |
a = 10;   v
           --->
                |   a = 20;
                v   b = 20;
           <--- 
b = 10;   |
          v

Il risultato è che a contiene 20 e b contiene 10, due valori diversi.

Come nel caso dei collegamenti doppi, la duplicazione può essere necessaria. Le modifiche ai dati ridondanti devono essere sincronizzati per evitare che vengano interrotte da un altro thread che le modifica in modo diverso.

Istruzioni di guardia

Un altro caso che richiede le sincronizzazione sono i condizionali di guardia: una operazione va eseguita solo se è vera una condizione. La verifica e l'esecuzione sono due istruzioni in sequenza, e un thread può venire interrotto fra la prima e la seconda.

thread 1                  thread 2
--------                  --------
                 |
a = 3;           |
b = 12;          |
…                |
if (a == 0)      |
    return -1;   v
                  --->
	              |   a = 0;
                      v
	          <---
return b / a;    |
                 v

L'intenzione del primo thread è ritornare la divisione fra due naturali se è possibile, ossia il divisore è diverso da zero, In caso contrario ritorna -1 invece di un numero naturale per segnalare che l'operazione non è possibile. Dato che a vale 3, la verifica riesce e si procede. Ma a viene azzerato dall'altro thread prima della divisione, che produce errore invece di -1.

Un caso concreto

Su un sito di aste online un compratore può comprare un articolo a un prezzo deciso dal venditore oppure fare un'offerta a un prezzo minore, che il venditore può accettare in qualsiasi momento.

Cosa può succedere:

I metodi compraSubito() e accetta() verificano prima se l'oggetto è venduto, se non lo è lo assegnano e confermano.

compraSubito()                   accetta()
--------------                   ---------
                        |
verifica                |
-> non venduto          |
                         --->
                             |   verifica
                             |   -> non venduto
                             |   vende a compratore 2
                             |   scala dal credito di 2
                         <---
vende a compratore 1    |
scala dal credito di 1  |
                        v

Entrambe le verifiche riescono: entrambi i compratori pagano per lo stesso articolo.

I metodi devono essere sincronizzati: non possono venire interrotti fra la verifica e la conferma. Dato che i due acquisti sono contemporanei, è accettabile scegliere arbitrariamente uno dei due. Non lo è vendere lo stesso oggetto a due compratori diversi.

Coda di eventi

La sincronizzazione risolve il problema, ma ne crea uno nuovo: l'esecuzione fuori ordine.

Secondo le regole, l'oggetto andrebbe venduto al primo compratore che vuole acquistarlo subito, quindi A. La sincronizzazione dei thread può invece farlo vendere a B.

Il gestore dei thread può passare il controllo in qualsiasi momento a qualsiasi thread in stato eseguibile. Se le due offerte non sono ancora state finire di gestire quando arriva l'acquisto immediato di B, viene gestito da un thread che è eseguibile. Può essere il prossimo a cui viene passato il controllo, anche se la gestione l'acquisto di A era già eseguibile.

A fa un'offerta          |
-> offerta memorizzata   |
                         v
                          --->
                              |   B fa un'offerta
                              |   -> offerta memorizzata
                              |   …
                              |   B compra subito
                              |   -> vende a compratore 2
                              v
                          <---
A compra subito          |
-> no                    |
                         v

Nei casi in cui è importante l'ordine di tempo degli eventi, occorre gestirli secondo una coda: il primo che arriva va gestito per primo.

Per esempio, fare offerte e aquistare subito diventano oggetti Offerta e CompraSubito. Non vengono gestiti immediatamente ma memorizzati alla fine di una lista. Un singolo thread li legge in ordine dall'inizio della lista li gestisce uno per volta. In questo modo, il primo inserito viene gestito per primo.