Problema della sincronizzazione
Si è visto come lo stato complessivo
di una rete NetGame sia mantenuto in modo distribuito su tutti
i server che ne fanno parte e che si connettono tra loro con
modalità peer-to-peer. In particolare vi sono due livelli
diversi a cui operano i server:
- i
MainServer
sono tutti connessi tra loro e le informazioni che trattano
riguardano sostanzialmente la presenza degli utenti. Essi
gestiscono infatti il login ed il logout di un utente all'interno
della rete. Ogni MainServer
ha di fatto nozione di tutti i clienti connessi alla struttura
in quell'istante, ma può comunicare in modo diretto e ha
informazioni dettagliate solo riguardo a quelli che si sono
direttamente connessi a lui.
- i
RoomServer
hanno invece il compito di gestire, oltre agli utenti presenti
nella stanza, i messaggi che essi si scambiano e soprattutto
le partite che essi creano e giocano. Ogni RoomServer
è connesso solo con i RoomServer
remoti analoghi (cioè coloro che ospitano lo stesso gioco
all'interno della stessa rete), e con essi condivide lo
stato della stanza, formato dagli utenti presenti, dalle
partite in definizione con i rispettivi giocatori iscritti,
ecc. Anche in questo caso le informazioni sulle partite
sono condivise su tutti i server.
Un obiettivo fondamentale è dunque quello
di mantenere consistenti le visioni che i vari server (sia
a livello MainServer
che RoomServer )
hanno dei rispettivi stati. Si vuole cioè che una operazione
di modifica dello stato, generalmente avviata da un utente
(per esempio la creazione di una partita) sia vista da tutti
i server allo stesso modo o eventualmente, se non è possibile
su uno di essi, non venga eseguita su nessuno.
Il Two Phase Commit Protocol
Operazioni di questo tipo possono essere
inquadrate come transazioni. Le transizioni sono operazioni
che devono agire nel distribuito su più risorse, mantenendone
la consistenza globale. Tipicamente le proprietà che deve
rispettare una transazione si indicano con l'acronimo ACID,
dove :
- Atomicity. Tutte le operazioni che compongono una
transazione sono eseguite con successo, oppure nessuna deve
essere eseguita.
- Consistency. L’insieme delle operazioni eseguite
deve lasciare lo stato del sistema in una situazione consistente.
- Isolation. L’effetto delle operazioni che compongono
una transazione non deve essere visibile a oggetti esterni
alla transazione fino a che questa non sia completata.
- Durability. Gli effetti della transazione devono
essere durevoli e rimanere anche dopo il termine della transazione
Già a livello locale la realizzazione
di queste proprietà presenta problemi di sincronizzazione,
risolvibili attraverso acquisizione e rilascio di lock di
risorse, avendo cura di evitare (in modo ottimista o pessimista)
eventuali situazioni di deadlock.
In ambito distribuito, cioè ove la transazione veda coinvolti
più processi anche su host differenti, le problematiche si
moltiplicano. In particolare bisogna considerare il caso di
errori di comunicazione e di possibile caduta di un partecipante.
Comunque, ipotizzando un comportamento non "bizantino"
dell'ambiente distribuito in cui si opera, si è riconosciuto
un protocollo che consente di ottenere le proprietà di cui
sopra. Questo protocollo è il Two Phase Commit Protocol (2PC),
ed il suo obiettivo è indicare come coordinare i partecipanti
ad una transazione di modo che la stessa venga eseguita atomicamente.
In termini generali, il 2PC prevede
la presenza di un Transaction Manager (TM), che dovrà essere
eletto dal partecipante (client) che intende effettuare la
transazione. Ogni volta che vorrà fare una transazione il
client dovrà effettuare le seguenti operazioni :
- Comunicare al TM tutti i partecipanti alla transazione
- Eseguire tutte le operazioni che compongono la transazione
sui partecipanti interessati
- Chiedere al TM di effettuare l'operazione di COMMIT
A questo punto il TM dovrà :
- Mandare il comando PREPARE a tutti i partecipanti
- Attendere le risposte da parte dei partecipanti. Si possono
verificare due casi :
- Le risposte sono tutte affermative (cioè tutti i partecipanti
sono in grado di eseguire l'operazione).
In questo caso il TM manda a tutti i partecipanti i comando
COMMIT
- Almeno una risposta è negativa.
In questo caso il TM manda a tutti i partecipanti il comando
di ABORT provocando l’aborto della transazione.
Dal loro lato, i partecipanti dovranno
comportarsi nel seguente modo
- Ricevere ed eseguire le operazione da parte del client
- Una volta ricevuto il comando PREPARE da parte del TM,
stabilire se le operazioni di cui sopra sono state eseguite
con successo (in questa fase non è possibile accettare ulteriori
operazioni da parte del client). Se questo è vero si risponde
al TM positivamente, altrimenti negativamente
- Si attende la comunicazione definitiva da parte del TM
(evidentemente se un partecipante ha risposto negativamente
saprà già che essa sarà un ABORT). Si possono verificare
due casi :
- il TM comunica un ABORT. In questo caso le operazioni
corrispondenti a questa transazione dovranno essere "disfatte",
riportando lo stato alla situazione precedente.
- il TM comunica un COMMIT. In questo caso le modifiche
apportate sono rese definitive e stabili.
Evidentemente la possibilità di fare
abort implica che dalla parte dei partecipanti si abbia un
supporto di rollback, che può essere ottenuto in vari modi,
per esempio facendo uso di un file di log, o effettuando le
modifiche non sui dati principali ma su una copia, rendendole
poi effettive solamente al COMMIT.
Graficamente possiamo rappresentare
graficamente le transizioni di stato possibili per i partecipanti
nel seguente modo :
Operazioni in conflitto
Prima di vedere come il protocollo 2PC
è stato adattato al nostro caso, vediamo quali sono le operazioni
"pericolose", cioè che possono entrare in conflitto
e che quindi necessitano di essere configurate come transazioni.
Le operazioni possono avere inizio su iniziativa di un client
(che si connette, crea una partita, ecc) o di un server (un
nuovo server entra nel sistema). Comunque sia, le transazioni
vengono trattate a livello di Server. In particolare MainServer
e RoomServer
riconoscono tali operazioni e applicano loro il protocollo.
Analizzeremo successivamente il caso di ingresso di un nuovo
server. Vediamo ora i possibili conflitti che può incontrare
un'operazione attivata da un utente
MainServer
Op1
|
Op2
|
Motivi del conflitto
|
login utente
|
login utente
|
Utenti con lo stesso nome
|
La colonna Op1 indica l'operazione che
il cliente vuole compiere.
La colonna Op2 indica un'altra operazione che si sta eseguendo
in quel momento all'interno del sistema.
Per valutare i motivi del conflitto si considera che l'operazione
in corso vada a buon fine, cioè venga completamente eseguita
prima di Op1.
RoomServer
Op1
|
Op2
|
Motivi del conflitto
|
create match
|
crate match
|
Partita con lo stesso nome
|
enter match
|
enter match
|
Raggiunto il massimo numero di giocatori ammissibili
per il gioco
|
enter match
|
exit match
|
La partita non esiste più
|
enter match
|
start match
|
La partita è iniziata : non è più possibile entrare
|
exit match
|
start match
|
La partita è iniziata : non è più possibile uscire
|
start match
|
enter match
|
Finché ci sono ingressi in corso non è possibile iniziare
la partita
|
start match
|
exit match
|
Finché ci sono uscite in corso non è possibile iniziare
la partita
|
Come si può vedere in RoomServer
non c'è il problema del login, perché eventuali conflitti
sono già stati risolti a livello di MainServer .
In sostanza, se un utente chiede di entrare in una stanza
significa che è già connesso al sistema e quindi ha un nome
unico all'interno di esso. Non possono quindi verificarsi
due richieste di accesso ad un RoomServer
con lo steso nick.
Inoltre vi sono operazioni, come il logout di un utente o
l'invio di un messaggio, che non presentano alcun problema
a questo livello, e non hanno quindi bisogno di essere trattati
con questo protocollo.
Naturalmente in queste tabelle si sono
trattati solo i problemi sorti con la concorrenza con cui
possono essere eseguite operazioni su server diversi. Per
esempio non si è considerato il caso enter match -
create match perché risolvibile in modo 'statico',
nel senso che se si è giunti a questo punto con enter significa
che la partita esiste già e quindi una create con lo stesso
nome di partita fallirebbe subito. Né si sono considerati
tutte le condizioni che comunque devono essere valide per
ogni operazione (esempio - vincolo sul numero di giocatori
per enter match e start match). Si intende che tutte queste
condizioni siano verificate, altrimenti l'operazione fallirebbe
ben prima di effettuare il protocollo 2PC.
Per avere un'idea di cosa potrebbe accadere
si consideri la seguente figura, in cui è rappresentato un
caso di possibile conflitto enter match - exit match.
Si pensi al caso in cui un utente entra in una partita mentre
un altro ne esce ed essendo l'ultimo giocatore iscritto, la
cancella. In questo caso, senza gli accorgimenti di cui sopra,
e in dipendenza dalla tempistica di ricezione dei messaggi
da parte dei vari server, alcuni (quelli in cui la richiesta
di ingresso è giunta prima) vedrebbero la partita ancora in
essere con l'utente iscritto, mentre gli altri non vedrebbero
più tale partita.
Adottando invece il protocollo 2PC ed
osservando la tabella precedente si vede come al passo tre
avremmo un conflitto per l'operazione eseguita dal Server1,
che quindi viene abortita, cancellando il giocatore 2 dalla
partita P1 su Server1 e Server2.
Ingresso di un server
L'ingresso di un server in una rete
(sia a livello MainServer
che RoomServer )
è un evento che necessita particolare attenzione. E' necessaria
in questo caso una forte sincronizzazione con le altre operazioni,
perché tutte loro accedono in continuazione sulla tabella
dei server remoti (avendo necessità di comunicare ad essi
ogni operazione) ed una modifica di tale tabella durante un
ciclo potrebbe portare ad un comportamento errato o ad una
inconsistenza dello stato sui diversi servers. Inoltre potrebbe
accadere che se due server tentano di connettersi lo stesso
istante, facendo la richiesta a server diversi, a connessione
avvenuta non siano visibili l’uno all’altro. Si consideri
per esempio il seguente caso:
Si supponga che all'inizio la rete sia
formata da due soli server (1 e 2). Si ricorda che la struttra
remoteServers
su ogni nodo contiene tutti i server remoti connessi a quel
nodo. Ad un certo punto, contemporaneamente il server 3 tenta
di connettersi al server 1 ed il server 4 al server 2. All'inizio
quindi 3 e 4 fanno richiesta al rispettivo server a cui si
stanno connettendo dello stato della rete (getServerState ),
e in base alla risposta ricevuta modificheranno la loro tabella
remoteServers .
Dopo questo provvederanno ad aggiungere loro stessi a tutti
i server della rete (addServer ).
Ma in questo modo, come si può notare dalla quarta
figura, c'è il pericolo che i server 3 e 4 non siano
visibili tra di loro, cioè ci si trova in uno stato
inconsistente della rete con informazioni non sincronizzate.
Con l'adozione del protocollo 2PC invece
il problema può essere risolto, permettendo ad un solo
server alla volta di connettersi alla rete.
Adattamento del protocollo 2PC
a NetGame
Si sono viste in precedenza le proprietà
ACID di una transazione. In realtà in questo progetto l'accento
è posto sulla serializzabilità e sulla atomicità, piuttosto
che sulla persistenza. In sostanza si vuole che se due o più
transazioni sono eseguite in modo concorrente, il risultato
finale sia lo stesso di quello ottenuto eseguendo le transazioni
in un qualche ordine sequenziale (serializzabilità) e che
una transazione possa essere considerata indivisibile ed istantanea,
senza stati intermedi visibili (atomicità).
Oltre a questi problemi di sincronizzazione, introdotti dall'architettura
distribuita, ve ne sono altri dovuti all'uso della tecnologia
RMI. L'implementazione delle RMI prevede infatti la generazione
sull'oggetto server di un thread per ogni invocazione remota
(in realtà questo non è specificato dalla Sun, ma comunque
si è dovuto considerare il caso peggiore). Questo ha necessariamente
portato all'introduzione di opportune tecniche di sincronizzazione
locale (uso di semafori ed eventualmente monitor). Particolare
attenzione si è quindi dovuto porre al deadlock avoidance.
Si è realizzata per ora una soluzione pessimistica (2 Phase
lock), assegnando un ordine alle risorse ed imponendo che
i lock su di esse vengano acquisiti secondo quell'ordine ed
impedendo che un lock possa essere richiesto dopo averne abbandonato
un altro (naturalmente solo per una stessa sezione critica).
Innanzitutto per permettere ai partecipanti
ad una transazione di operare un rollback in seguito ad un
comando di ABORT, si sono introdotte delle strutture accessorie
su cui avvengono effettivamente le elaborazioni dettate dal
coordinatore. Solo in seguito alla ricezione del comando COMMIT
le modifiche saranno rese effettive, spostandole da tali strutture
a quelle ‘permanenti’.
In particolare, tenendo conto delle tabelle di conflitto sopra
esposte e del problema dell'ingresso di un nuovo server, si
possono individuare facilmente tali strutture accessorie.
Esse sono:
Per il MainServer |
connectingServer |
server remoto che sta entrando nel sistema |
loggingUsers |
lista degli utenti che si stanno connettendo al sistema |
Per il RoomServer |
connectingServer |
server remoto che sta entrando nel sistema |
creatingMatches |
lista delle partite in creazione (nome della partita
- nome del creatore) |
enteringMatch |
lista degli utenti entranti in una partita (nome della
partita - lista dei giocatori) |
exitingMatch |
lista degli utenti uscenti da una partita (nome della
partita - lista dei giocatori) |
startingMatches |
lista delle partite in partenza |
Il ruolo che nel protocollo 2PC è del
Transacrion Manager, viene qui vestito direttamente dal server
che riceve la richiesta originaria (da parte di un client
o di un secondo server).
Per implementare tutte le fasi del protocollo si specializzano
i metodi dell'interfaccia di comunicazione tra i server. In
particolare, per ogni operazione tra quelle precedentemente
presentate, si definiscono tre metodi :
prepare<operazione>()
commit<operazione>()
abort<operazione>()
Analizziamo ora in generale le operazioni
che deve compiere un server e le strutture di questi tre metodi.
Supponiamo che doAction()
sia il metodo del server invocato da un client e che deve
essere trattato come transazione. Esso avrà la seguente struttura
:
doAction() {
<acquisisce i lock sulle strutture da controllare>
<verifica in base ad esse la fattibilità dell'azione. Se non è possibile fallisce>
<acquisisce i lock sulle strutture ausiliarie coinvolte>
<verifica in base ad esse la fattibilità dell'azione
(cfr tabelle). Se non è possibile fallisce>
<effettua le modifiche necessarie sulle strutture ausiliarie>
<rilascia i lock sulle strutture ausiliarie coinvolte>
<rilascia i lock sulle strutture da controllare>
<invoca prepareAction() su tutti i Server remoti ed attende le loro risposte>
<se tutti i prepareAction() hanno avuto esito positivo>
<acquisisce i lock sulle strutture coinvolte>
<effettua le modifiche su di esse>
<rilascia i lock acquisiti>
<invoca commitAction() su tutti i server remoti>
<altrimenti>
<acquisice i lock sulle strutture ausiliarie precedentemente modificate>
<rimuove le modifiche precedentemente effettuate>
<rilascia i lock>
<invoca abortAction() su tutti i server remoti>
}
Vediamo ora la struttura delle funzioni
di prepare, commit e abort :
boolean prepareAction() {
<acquisisce i lock sulle strutture ausiliarie coinvolte>
<verifica se l'azione può essere effettuata. Se sì>
<effettua le modifiche sulle strutture ausiliarie e ritorna TRUE>
<altrimenti>
<ritorna FALSE>
<rilascia i lock>
}
commitAction() {
<acquisisce i lock delle strutture coinvolte>
<rimuove le modifiche dalle strutture accessorie e le applica alle altre>
<rilascia i lock>
}
abortAction() {
<acquisice i lock sulle strutture ausiliarie modificate in prepare>
<rimuove le modifiche effettuate in prepare>
<rilascia i lock>
}
Vediamo ora un esempio più concreto, per esempio
quello della creazione di un match :
createMatch(nome del match, nome del giocatore) {
<acquisisce i lock di matches e startedMatches>
<Verifica se esiste già in tali liste una partita con quel nome. Se è così fallisce>
<acquisisce il lock di creatingMatches>
<Verifica se in tale lista esiste già una partita con quel nome. Se è così fallisce>
<inserisce in creatingMatches il nome della partita e del giocatore>
<rilascia il lock di creatingMatches>
<rilascia i lock di matches e startedMatches>
<invoca prepareCreateMatch() su tutti i server remoti ed attende le loro risposte>
<se tutti i prepareCreateMatch() hanno avuto esito positivo>
<acquisisce i lock di matches e creatingMatches>
<toglie la coppia partita-giocatore da creatingMatches e la mette in matches>
<rilascia i lock di matches e creatingMatches>
<invoca commitCreateMatch() su tutti i server remoti>
<altrimenti>
<acquisice il lock su creatingMatches>
<rimuove la coppia partita-giocatore da creatingMatches>
<rilascia il lock su creatingMatches>
<invoca abortCreateMatch() su tutti i server remoti>
}
Vediamo ora la struttura delle funzioni di prepare, commit e
abort :
boolean prepareCreateMatch(nome del match, nome del giocatore) {
<acquisisce il lock su creatingMatches>
<verifica se creatingMatches contiene già la il nome del match. Se sì>
<inserisce partita e giocatore in creatingMatches e ritorna TRUE>
<altrimenti>
<ritorna FALSE>
<rilascia il lock su creatingMatches>
}
commitCreateMatch(nome del match, nome del giocatore) {
<acquisisce i lock di matches e creatingMatches>
<toglie la coppia partita-giocatore da creatingMatches e la mette in matches>
<rilascia i lock di matches e creatingMatches>
}
abortCreateMatch(nome del match, nome del giocatore) {
<acquisice il lock su creatingMatches>
<rimuove la coppia partita-giocatore da creatingMatches>
<rilascia il lock su creatingMatches>
}
In questo esempio è necessario comunicare
anche il nome del giocatore, anche se potrebbe sembrare superfluo,
perché l'abort viene invocata su tutti i server, indipendentemente
dal fatto che abbiano risposto positivamente o meno. Potrebbe
quindi accadere che un server abbia nella tabella creatingMatches
una entry con lo stesso nome della partita in questione, ma
richiesta da un diverso giocatore. Evidentemente tale entry
non dovrà essere toccata.
Naturalmente negli schemi sopra si sono
messe solo le macroistruzioni che riguardano il protocollo
2PC. Non si sono indicate le comunicazioni ai clients, che
comunque avvengono all'interno di doAction()
per i clienti locali al server TM, e in commitAction()
per gli altri.
Finora non si è trattato il caso spinoso
dell’ingresso di un nuovo server. Si sono già presentate le
situazioni pericolose che si possono presentare se non ci
si cautela in qualche modo.
Per ottenere un’esecuzione corretta si è deciso di escludere
mutuamente la fase di commit dell’ingresso di un nuovo server
rispetto a qualsiasi altra operazione. Il metodo addServer()
presente sia nel MainServer
che nel RoomServer
e invocato dal nuovo server che vuole entrare nella rete è
così strutturato :
stato addServer(nome del Server) {
<acquisisce il lock di connectingServer>
<Verifica se c’è già un server che sta tentando di connettersi. Se è così fallise>
<pone in connectingServer il nome del Server>
<rilascia il lock di connectingServer>
<invoca prepareConnectServer() su tutti i server remoti ed attende le loro risposte>
<se tutti i prepareConnectServer() hanno avuto esito positivo>
<acquisisce TUTTI i lock delle risorse coinvolte
(sostanzialmente le strutture dati che devono poi essere trasferite nello stato)>
<toglie da connectingServer il nome del server>
<prepara lo stato attuale della stanza>
<pone il server nella lista dei server remoti>
<invoca commitConnectServer() su tutti i server remoti>
<ritorna lo stato della stanza precedentemente preparato>
<rilascia TUTTI i lock>
<altrimenti>
<acquisice il lock su connectingServer>
<rimuove da connectingServer il nome del server >
<rilascia il lock su connectingServer >
<invoca abortConnectServer() su tutti i server remoti>
}
Lo stato restituito sarà naturalmente
diverso nel caso di MainServer
o di RoomServer .
Per un MainServer
bisognerà restituire informazioni quali
- la lista degli utenti connessi
- tutte le informazioni mantenute nelle strutture dati provvisorie
precedentemente presentate
Per un RoomServer
invece bisognerà restituire :
- la lista degli utenti nella stanza
- la lista delle partite in definizione
- la lista delle partite iniziate, con relativo host su
cui sono ospitate
- tutte le informazioni mantenute nelle strutture dati provvisorie
precedentemente presentate
Si noti come la restituzione del risultato
avvenga in una porzione di codice fortemente sincronizzata,
in quanto il thread, per effettuare tale operazione, deve
posseder tutti i lock delle risorse coinvolte. Così si garantisce
che nessun altra operazione possa modificare in qualche modo
lo stato del server, ed il nuovo arrivato venga a conoscenza
senza alcun dubbio dello stato attuale e non corra il rischio
di perdere qualche avvenimento.
Per evitare il problema di 2 server che si connettono contemporaneamente
e rischiano di non vedersi, viene consentito l’ingresso di
un nuovo server alla volta; sostanzialmente connectingServer
può contenere al più una entry.
Oltre alla realizzazione del metodo
addServer()
e dei tre metodi prepareConnectServer() ,
commitConnectServer()
e abortConnectServer()
– che non presentano del resto variazioni significative rispetto
alle strutture generali già viste – occorre modificare tutte
le funzioni precedentemente presentate come doAction()
effettuando come primo controllo la verifica che un server
non si stia connettendo. Occorre che esse acquisiscano come
prima cosa il lock su connectingServer ,
quindi verifichino se esso contiene o no il nome di un server.
Se lo contiene l’operazione fallisce, viceversa si può continuare
normalmente, eseguendo le operazioni successive.
|