Lezione Precedente
Lezione Successiva

Laboratorio di Sistemi Informativi

Concorrenza per le applicazioni web

Il problema della concorrenza

Supponiamo di voler realizzare una procedura per l'immissione di una nuova prenotazione. Il sistema deve accettare dall'utente il  numero del volo e il nome della prenotazione, e deve automaticamente assegnare al nome indicato il primo posto libero nell'aereo. Per stabilire quali siano i posti liberi su un volo, si può usare la seguente query:
select posti.idposto from voli natural join posti left join prenotazioni
                     on posti.idposto=prenotazioni.idposto and prenotazioni.idvolo=voli.id
                     where voli.id='<numvolo>' and nome is null
Notare l'uso di natural join, che non avevamo mai usato fin'ora, per legare la tabella voli e la tabella posti in base ai loro attributi comuni, ovvero idaereo. Senza l'uso del natural join, la query diventerebbe:
select posti.idposto from voli, posti left join prenotazioni
                     on posti.idposto=prenotazioni.idposto and prenotazioni.idvolo=voli.id
                     where voli.id='<numvolo>' and nome is null and voli.idaereo=posti.idaereo
Possiamo quindi prendere il primo valore del risultato e fornirlo in input ad una query di inserimento, del tipo

insert into prenotazioni values(<numvolo>, <numposto>, <nome>)

Lo script prenotazioni.php svolge proprio questo compito.  Notare che, per semplicità espositiva, non si utilizza la struttura a tre fasi di cui abbiamo parlato diffusamente le lezioni scorse, ma la fase di inserimento  e quella di comunicazione del risultato sono inglobate in un sola.

Il punto che ci interessa evidenziare, in questo caso, è che l'inserimento di una prenotazione richiede due query  distinte, una di lettura per determinare il numero del posto, e una di scrittura per aggiornare la tabella prenotazioni. Ma cosa succede se due persone stanno tentando contemporaneamente di inserire una nuova prenotazione? In generale, non è possibile stabilire in che ordine vengano eseguite le varie query. Ovviamente la select dell'utente 1 è sempre eseguita prima dell'insert 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
insert1
select2
select2
select2
insert1
insert2
insert2
insert2
insert1

Mentre per il caso 1 non c'è nessun problema, in quanto le due prenotazioni vengono eseguite sequenzialmente uno dopo l'altro, i casi 2 e 3 presentano dei problemi. Le select vengono eseguite prima delle insert, e produrranno esattamente lo steso risultato. Successivamente le due insert tenteranno di inserire la stessa riga due volte nella tabella prenotazioni, causando una violazione di vincolo della chiave primaria (che per la tabella prenotazioni è formata dalla coppia idvolo e idposto).

È possibile osservare questo fenomeno con lo script di prima, aggiungendo artificialmente una pausa tra la select e la insert. Si usa la funzione
In questo modo si facilita il verificarsi delle situazioni dannose (la situazione dannosa, prima o poi, si verificherebbe anche senza pausa.. ma non abbiamo ore di tempo per tentare ripetutamente). Basta a questo punto richiedere due prenotazioni in sequenza, in modo che la seconda prenotazione sia richiesta prima che il sistema porti a termini la prima, e la frittata è fatta!

Inoltre, mentre in questo caso il secondo inserimento non avviene per nulla, ci sono delle situazioni in cui entrambe le operazioni hanno apparentemente successo, magari compromettendo l'integrità della base di dati.


ATTENZIONE. Nel compiere questi esperimenti sulla concorrenza è necessario, spesso, 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, per sicurezza è 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.

Mandatory Locking

Come fare per risolvere questo problema? Abbiamo in realtà varie possibilità: oggi esamineremo l'uso 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 un thread (processo interno di MySQL). 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 prenotazioni WRITE, voli READ

blocca la tabella prenotazioni per la scrittura e la tabella voli per la lettura. Ma cosa vuol dire che queste tabelle sono bloccate?  Una tabella bloccata con la clausola READ può essere letta da altri thread ma non può essere modificata. Una tabella bloccata con la clausola WRITE non può essere nè letta nè scritta da altri thread. Qualora un thread tenti di effettuare una operazione non consentita su una tabella bloccata, il thread 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 (in realtà ci sarebbe il problema delle connessioni persistenti, che però non abbiamo mai introdotto e che quindi preferisco ignorare).


Notare alcune caratteristiche dell'uso della LOCK:
A questo punto, è possibile risolvere il problema dell'inserimento di nuove prenotazioni bloccando le tabelle incriminate prima di aggiornarle. In particolare, prima di effettuare la SELECT che restituisce il posto da occupare, si bloccano tutte le tabelle con

lock tables posti read, voli read, prenotazioni write

e alla fine delle operazioni si rilasciano i blocchi con

unlock tables;

Lo script corrispondente è prenotazioni-lock.php.


SUGGERIMENTO. Per acquisire una maggiore familiarità con i problemi legati alla concorrenza (non solo nel mondo dei database), vi consiglio di rivedere il corso di "Sistemi di Elaborazione"

Esercizio 1

Sia data la tabella contatore, all'interno del database test, 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. Modificare lo script in modo che non si verifichino problemi nel caso di più esecuzioni concorrenti.

Advisory Locking

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).  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 dell'inserimento di prenotazioni, può essere usato come nello script prenotazioni-lock-adv.php.

Prima di iniziare le interrogazioni, si acquisisce il lock di nome 'prenotazione' come segue:
   
$result=mysql_query("select GET_LOCK('prenotazione',5)") or mysql_showerror();
$row=mysql_fetch_array($result);
if ($row[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 $row[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. 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 (posti disponibili) che di insuccesso (posti tutti occupati), si deve rilasciare il lock con

mysql_query("select RELEASE_LOCK('prenotazione')");

Esercizio 2

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

Lezione Precedente
Lezione Successiva

I file utili per questa lezione: airdb3.sql, config.php, libreria.php e .htaccess