Lezione Precedente Elenco Lezioni

Laboratorio di Sistemi Informativi

Concorrenza nelle applicazioni web

Vogliamo adesso realizzare quella che è la parte "centrale" del nostro software, ovvero la procedura di prenotazione di un oggetto. Partendo dalla versione di Mancolista alla fine della scorsa lezione, l'idea è quella di modificare la pagina elenco in maniera tale che, per ogni oggetto:

Il link per prenotare richiama una pagina di prenotazione passando l'id dell'oggeto da prenotare. Non è necessario passare l'id dell'utente che ha effettuato la prenotazione in quanto questa si trova nella variabile di sessione $_SESSION['user_id'].. Lo script di prenotazione esegue una semplice query UPDATE aggiornando mettendo nel campo utente_id corrispondente all'oggetto selezionato, l'user_id dell'utente loggato. Otteniamo i file elenco5.php e prenota.php.

Intermezzo: messaggi flash

Notare che abbiamo deciso di non realizzare una pagina di conferma della prenotazione, ma di saltare alla pagina elenco dopo aver effettuaro la prenotazione. Tuttavia, per confermare comunque all'utente che la prenotazione è avvenuta, ci siamo inventati una nuova soluzione. Abbiamo inserito in elenco5.php un nuovo pezzo di codice che riportiamo qua sotto:

if (isset($_SESSION['flash'])) {
  echo "<p><strong>$_SESSION[flash]</strong></p>";
  unset($_SESSION['flash']);
}

Quello che fanno queste tre righe è semplicemente mandare in output la variabile $_SESSION['flash'] se è settata, e poi eliminarla. L'idea è che se uno script vuole tornare all'elenco degli oggetti ma nel contempo vuole visualizzare un messaggio all'utente, basta che setti la variabile $_SESSION['flash'] con il messaggio voluto, e poi faccia una redirect_browser (ovviamente avremmo potuto usare una variabile di sessione qualsiasi, non per forza $_SESSION['flash']).

La procedura di prenotazione così scritta non va bene perché non tiene conto del fatto che quando l'utente clicca su un oggetto, non è detto che l'oggetto sia ancora prenotabile. Lo era quando la pagina con l'elenco è stata visualizzata, ma nel frattempo qualcun altro può avere cliccato sullo stesso oggetto, prenotandolo. Se, come facciamo attualmente, non controlliamo nella pagina di prenotazione se l'oggetto è già prenotato, rischiamo di "rubare" la prenotazione a qualcun altro. Inoltre, il solito hacker potrebbe chiamare manualmente la pagina prenota.php con l'id di un oggetto già prenotato o con un id inesistente. Raffiniamo allora le due pagina ottenendo elenco5.1.php e prenota.1.php. L'unica differenza di elenco5.1 è che il link di prenotazione punta a prenota.1.php, mentre le modifiche a prenota.1.php sono più sostanziose ma ovvie: adesso prima di prenotare veramente un oggetto controlliamo che non sia già stato prenotato.

Il problema della concorrenza

Adesso sembrerebbe che le cose siano a posto.. ma purtroppo così non è. Può infatti succedere che due persone tentino di prenotare lo stesso oggetto contemporaneamente. Quello che succede è che partono due copie dello script prenota.1.php (in termine tecnico, due processi) che vengono eseguite più o meno in parallelo. Il funzionamento del programma dipende dall'ordine con cui vengono eseguite le interrogazioni sul server SQL (possiamo pensare per semplicità che il server possa eseguire una sola interrogazione alla volta). Ovviamente la select dell'utente 1 è sempre eseguita prima dell'update dell'utente 1, e analogamente avviene per l'utente 2. Ma questo lascia comunque aperte varie possibilità. Ad esempio:

Caso 1
Caso 2
Caso 3
select1
select1
select1
update1
select2
select2
select2
update1
update2
update2
update2
update1

Se siamo nella situazione non c'è nessun problema: il primo script trova l'oggetto libero e prenota, il secondo script controlla l'oggetto, lo trova occupato, e quindi informa l'utente due che l'oggetto è già stato prenotato (quindi, in realtà, il passo update2 non avviene). Ma cosa avviene negli altri casi? Le due select vengono eseguite prima degli update e produrranno esattamente lo steso risultato: l'oggetto è libero. Successivamente i due update modificheranno entrambi la tabella oggetti, e la seconda ad essere eseguità sarà quella vincente, perché sovrascriverà il dato precedente. Entrambi gli utenti saranno avvisati che la prenotazione ha avuto successo ma uno dei due non avrà veramente prenotato l'oggetto.

Ovviamente questo è un caso limite che è difficile far accadere, ma è possibile aiutare il fato aggiungendo artificialmente una pausa tra la select e l'update, utilizzando la funzione:

In questo modo è facile, usando contemporaneamente il sito con due browser, causare la successione degli eventi sfavorevole, semplicemente prenotando uno dopo l'altro lo stesso oggetto dalle due finestre. Notare che la situazione dannosa, prima o poi, si verificherebbe anche senza la pausa artificiale.. ma non abbiamo ore di tempo per tentare ripetutamente.

ATTENZIONE

Nel compiere questi esperimenti sulla concorrenza è necessario richiedere in contemporanea l'esecuzione di più di una istanza della stesso script PHP. Anche se è possibile farlo semplicemente aprendo due finestre dallo stesso browser, è preferibile utilizzare un browser diverso per ogni istanza della pagina. Questo perchè alcuni browser, Firefox compreso, in varie situazioni si rifiutano di richiedere due copie della stessa pagina, e aspettano che una copia sia arrivata interamente prima di richiederne un'altra. È ovvio che un comportamento simile, che è ragionevole quando si tratta di un utilizzo normale del browser, rende difficile effettuare gli esperimenti che ci interessano. Utilizzando browser diversi, invece, il problema non sussiste.

Soluzione 1: riscrivere la query UPDATE

Come fare per risolvere questo problema? Abbiamo in realtà varie possibilità. Il metodo più standard è quello di usare le transazioni, che sono una caratteristica avanzata di SQL. Purtroppo le transazioni sono una cosa abbastanza complessa, che non abbiamo tempo di trattare nel corso di laboratorio.

Altra soluzione può essere quella di modificare la query UPDATE in prenota.1.php in modo che sia la query stessa ad accorgersi che qualcosa non va. Ad esempio, si può sostituire

$stmt=$conn->prepare("UPDATE oggetti SET utente_id=? WHERE id=?");

con

$stmt=$conn->prepare("UPDATE oggetti SET utente_id=? WHERE id=? AND utente_id is null");

In questo modo se una UPDATE in precedenza ha già modificato il campo utente_id, la seconda UPDATE non provocherà alcuna modifica. Il tutto funziona perché la UPDATE è una operazione atomica, nessun'altra operazione può avvenire, sulla stessa riga della tabella, mentre la UPDATE è in corso. Stessa proprietà di atomicità vale per INSERT e DELETE.

Ovviamente il programma deve potersi accorgere del fatto che l'UPDATE non ha in realtà aggiornato nulla, e generare un messaggio di errore di conseguenza. Per far ciò si può usare il metodo rowCount dell'oggetto PDOStatemente:

Se il numero righe coinvolte dall'operazione UPDATE modificata è zero, vuol dire che qualcuno ha già prenotato l'oggetto. Questo consente tra l'altro di semplificare la procedura eliminando la query SELECT e lasciando solo la UPDATE. Apportando le opportune modifiche, otteniamo elenco5.2.php e prenota.2.php.

ATTENZIONE: operazioni atomiche

I comandi UPDATE, INSERT, DELETE e simili non sono vere operazioni atomiche, ma lo sono riga per riga. Ovvero, in una update multipla, l'aggiornamento di ogni singola riga è una operazione atomica, ma tra l'aggiornamento di due righe è possibile che siano eseguite delle operazioni diverse provenienti da un'altra connessione.

Soluzione 2: Blocchi di tabella

La soluzione vista sopra è abbastanza efficiente ma un po' troppo ad-hoc. Bisogna adattare le query di volta in volta al caso specifico, e non sempre questo è possibile. Una soluzione più generica, ma disponibile solo su MySQL, è quella che ora vedremo dei blocchi di tabella.

Come avviene per i sistemi operativi, il MySQL ha delle primitive che consentono di bloccare le risorse (in questo caso le tabelle) ed assegnarne l'uso esclusivo ad una specifica connessione. Il comando SQL che fa al caso nostro è LOCK TABLES.

La sintassi del comando è la seguente:

LOCK TABLES <nome_tabella> [READ | WRITE]  [, <nome_tabella> [READ | WRITE]]....

Ad esempio,

LOCK TABLES oggetti WRITE, utenti READ

blocca la tabella oggetti per la scrittura e la tabella utenti per la lettura. Ma cosa vuol dire che queste tabelle sono bloccate?  Una tabella bloccata con la clausola READ può essere letta da altre connessioni ma non può essere modificata. Una tabella bloccata con la clausola WRITE non può essere nè letta nè scritta da altre connessioni. Qualora una connessione tenta di effettuare una operazione non consentita su una tabella bloccata, essa si sospende, in attesa che il blocco venga rilasciato e che quindi la query in sospeso possa essere ripresa.

In questo modo, finchè il LOCK è attivo, possiamo essere sicuri che nulla di strano può accadere alle nostre tabelle. Per rilasciare il lock quando abbiamo finito con le manipolazioni, si può usare il comando SQL:

UNLOCK TABLES

che rilascia tutti i lock acquisiti.


NOTA. Le tabelle vengono comunque sbloccate automaticamente quando una connessione al database termina. Questo implica che, al termine dello script PHP, visto che le connessioni vengono automaticamente chiuse, vi è un UNLOCK automatico.


Notare alcune caratteristiche dell'uso della LOCK:
A questo punto, è possibile risolvere il problema dell'inserimento di nuove prenotazioni bloccando la tabella oggetti prima delle operazioni di prenotazione. In particolare, prima di effettuare la SELECT che controlla esistenza e stato di un oggetto, si deve bloccare la tabella con

LOCK TABLES oggetti WRITE

e alla fine delle operazioni si rilasciano i blocchi con

UNLOCK TABLES

Gli script corrispondenti sono elenco5-lock.php e prenota-lock.php. Anche a prenota-lock.php aggiungiamo la pausa di 10 secondi tra SELECT e UPDATE come fatto per prenota.1.php, non riusciamo comunque a causare una doppia prenotazione.

ATTENZIONE: problemi con LOCK TABLES

Il comando LOCK TABLES potrebbe generare un errore quando inviato da uno script PHP: dipende dalla versione di PHP che avete nella vostra macchina. Per evitare queso problema, bisogna configurare un parametro della libreria PDO con il comando:

$conn -> setAttribute(PDO::ATTR_EMULATE_PREPARES,true);   

Se vi ricordate, setAttribute lo usiamo già per indicare alla libreria PDO di lanciare le opportune eccezioni quando si verifica un errore. Per far ciò, modificavamo l'attributo PDO::ATTR_ERRMODE, adesso invece modifichiamo anche PDO::ATTR_EMULATE_PREPARES.

Advisory Locking (materiale aggiuntivo)

I tipi di blocchi introdotti con i comandi LOCK/UNLOCK vengono di solito chiamati col nome di mandatory lockings (blocchi obbligatori). Questo perché, una volta che un blocco è stato acquisito, tutti gli altri thread del sistema devono obbligatoriamente rispettarlo. Se la tabella prenotazioni ha un blocco in lettura, nessun altro processo sarà in grado di scrivere su quella tabella,  indipendentemente dal fatto che tenti a sua volta di acquisire un blocco oppure no.

Quando l'acceso al database avviene tramite una unica applicazione, tuttavia, si può utilizzare un metodo di lock diverso, che ha il nome di advisory locking. Con un blocco di questo tipo, i thread devono volontariamente chiedere di sottostare alle sue restrizioni. Su MySQL gli advisory lock si realizzano con le funzioni predefinite GET_LOCK e RELEASE_LOCK.

Per acquisire un lock, si esegue una query del tipo SELECT GET_LOCK('nome_lock',timeout) dove nome_lock è il nome del blocco da acquisire e timeout è il numero di secondi che si è disposti ad attendere. Il risultato sarà 1 se il blocco è stato acquisito o 0 se è trascorso il tempo di timeout senza essere riusciti ad acquisire il blocco (anche NULL è un possibile risultato qualora si  verifichi un errore di qualche tipo come l'esaurimento della memoria).  Una volta che un blocco è acquisito, qualunque altro GET_LOCK con lo stesso nome di blocco deve attendere il rilascio del blocco (o la scadenza del timeout) prima di poter continuare.

Un blocco viene rilasciato con SELECT RELEASE_LOCK('nome_lock') dove il nome_lock deve essere uguale a quello usato durante l'acquisizione.

Notare che se un processo non esegue un GET_LOCK, non ha nessun tipo di restrizione su nessuna tabella. GET_LOCK e RELEASE_LOCK vanno quindi utilizzati per proteggere le regioni critiche di una applicazione di database. Nel caso delle prenotazioni, otteniamo gli script elenco5-lock-adv.php e prenota-lock-adv.php.
Prima di iniziare le interrogazioni, si acquisisce il lock di nome 'prenotazione' come segue:
   
$result=$conn->query("SELECT GET_LOCK('prenotazione',5)");
$riga=$result->fetch();
if ($riga[0]==1) {
      .... prenotazioni...
} else
     echo "errore: sistema congestionato<p>";

La chiamata select GET_LOCK('prenotazione',5) restituisce una unica riga con un unico campo. Se il valore di questo campo (ottenuto con $rifa[0]) è 1, allora il lock è stato acquisito e si può proseguire con la prenotazione. Altrimenti, vuol dire che sono trascorsi i 5 secondi di timeout senza essere riusciti ad ottenere il lock. In generale, non dovrebbe mai accadere. Questo è probabilmente dovuto al fatto che il sistema è sottoposto a un numero notevole di interrogazioni al momento, e quindi non riesce a rispondere abbastanza velocemente alle richieste. Per questo visualizziamo un messaggio di errore indicando che il sistema è congestionato.

Una volta eseguita la prenotazione, sia nel caso di successo che di insuccesso, si deve rilasciare il lock con

$conn->query("SELECT RELEASE_LOCK('prenotazione')");

Esercizi e Soluzioni

Esercizio 1

Sia data la tabella contatore, creata e inzializzata con i seguenti comandi:
create table contatore (
  val int
);

insert into contatore values (0);
e lo script conteggia.php che aggiunge un numero specificato dall'utente al valore corrente del contatore. Verificare che lo script può generare dei risultati errati quando ne vengono eseguite più copie concorrenti. Modificare lo script in modo che non si verifichino problemi nel caso di più esecuzioni concorrenti, sia modificando le query opportunamente, sia ricorrendo ai blocchi di tabella.

Esercizio 2

Risolvere l'esercizio 1 usando gli advisory lock invece dei mandatory lock.

Lezione Precedente Elenco Lezioni

Valid HTML 4.01 Transitional Valid CSS!