Nel mio precedente post ho affrontato il problema di come sviluppare usando il TDD un'applicazione multithreading.
In questo post, cercherò di approfondire su come testare la sincronizzazione per l'accesso ad una risorsa condivisa.
L'obiettivo che voglio raggiungere è scrivere un test che fallisca se non applico un meccanismo di sincronizzazione (in questo caso la lock) per l'accesso alla risorsa e abbia successo quando viene introdotta la sincronizzazione. Il fatto che il test abbia successo deve essere sistematico e non deve esserci nessun elemento di casualità come l'ordine di schedulazione la velocità del processore.
Vediamo di descrivere lo scenario. La risorsa condivisa è il Broker la cui responsabilità è quella di immettere sul mercato azionario un ordine di acquisto o vendita di azioni. La classe Broker implementa la seguente interfaccia:
interface IBroker
{
void PlaceOrder(Order order);
}
La classe Order che non riporterò contiene i dati necessari per immettere l'ordine come il prezzo, la quantità di azioni, ecc.
L'ultimo elemento di questo scenario è la classe OrderThread, la sua responsabilità è quella di eseguire il metodo PlaceOrder in un thread creato da lei.
class OrderThread
{
private readonly IBroker _broker;
private readonly Order _order;
public OrderThread(IBroker broker, Order order)
{
_broker = broker;
_order = order;
}
public void Start()
{
Thread thread = new Thread(PlaceOrder);
thread.Start();
}
private void PlaceOrder()
{
_broker.PlaceOrder(_order);
}
}
Come si può notare alla chiamata PlaceOrder non viene usato nessun meccanismo di sincronizzazione. Quindi due istanze di OrderThread che utilizzino lo stesso broker potrebbero chiamare contemporaneamente il metodo PlaceOrder dando luogo ad una race condition. Vogliamo scrivere un test che abbia successo se la race condition non possa verificarsi e fallisca altrimenti.
Per ottenere il risultato scriviamo un'implementazione dell'interfaccia IBroker pilotabile dal nostro test che ci permetta di simulare lo scenario in cui due OrderThread cerchino di chiamare contemporaneamente il metodo PlaceOrder.
class SleepingBroker : IBroker
{
readonly ManualResetEvent _resetEvent = new ManualResetEvent(false);
public int CallingPlaceOrderCount { get; set;}
public void PlaceOrder(Order order)
{
CallingPlaceOrderCount++;
_resetEvent.WaitOne();
CallingPlaceOrderCount--;
}
public void WakeUp()
{
_resetEvent.Set();
}
}
Il metodo SleepingBroker.PlaceOrder ci permette di far durare la chiamata a PlaceOrder il tempo necessario per creare una race condition. Se il valore della property CallingPlaceOrderCount è maggiore di uno significa che più di un trade sta usando il Broker e quindi il test deve fallire.
Proviamo a questo punto a scrivere il test:
[Test]
public void Access_To_The_Broker_Should_Be_Synchronized()
{
Order order1 = new Order();
Order order2 = new Order();
SleepingBroker broker = new SleepingBroker();
OrderThread thread1 = new OrderThread(broker, order1);
OrderThread thread2 = new OrderThread(broker, order2);
thread1.Start();
thread2.Start();
Assert.AreEqual(1, broker.CallingPlaceOrderCount);
broker.WakeUp();
}
In questo test abbiamo creato due OrderThread che utilizzano lo stesso Broker (in questo caso SleepingBroker). I due thread vengono fatti partire e controlliamo che effettivamente solo uno sia all'interno del metodo PlaceOrder.
Il test apparentemente corretto in realtà contiene un errore concettuale. L'errore è che potrebbe fallire o avere successo in maniera del tutto casuale perchè dipende dalla velocità di creazione ed esecuzione dei thread al momento in cui si fa la Assert.
Il valore di CallingPlaceOrderCount potrebbe quindi essere 0, 1 oppure due. Zero nel caso in cui entrambi i thread siano stati creati ma al momento della Assert la chiamata a PlaceOrder non sia ancora avvenuta. Uno nel caso che uno dei due sia entrato nella PlaceOrder. Due nel caso in cui entrambi siano nella PlaceOrder.
Per come abbiamo scritto il test questo non è determinabile. Quindi modifichiamolo sincronizzandoci nel modo corretto. Per far questo ci viene in aiuto la property ThreadState, nel caso in cui il valore è impostato su WaitSleepJoin come descritto nella MSDN:
The thread is blocked. This could be the result of calling Thread..::.Sleep or Thread..::.Join, of requesting a lock — for example, by calling Monitor..::.Enter or Monitor..::.Wait — or of waiting on a thread synchronization object such as ManualResetEvent.
Quindi se entrambi i thread sono in WaitSleepJoin siamo sicuri di poter eseguire la Assert su CallingPlaceOrderCount nel momento corretto.
Per controllare lo stato del thread Aggiungiamo la Property IsWaiting alla classe OrderThread.
class OrderThread
{
private readonly IBroker _broker;
private readonly Order _order;
private Thread _thread;
public OrderThread(IBroker broker, Order order)
{
_broker = broker;
_order = order;
}
public bool IsWaiting
{
get
{
if (_thread == null)
{
return false;
}
return _thread.ThreadState == ThreadState.WaitSleepJoin;
}
}
public void Start()
{
_thread = new Thread(PlaceOrder);
_thread.Start();
}
private void PlaceOrder()
{
_broker.PlaceOrder(_order);
}
}
Quindi ora aggiungiamo il codice nel test che attenda che per entrambi i Thread IsWaiting sia a true.
[Test]
public void Access_To_The_Broker_Should_Be_Synchronized()
{
Order order1 = new Order();
Order order2 = new Order();
SleepingBroker broker = new SleepingBroker();
OrderThread thread1 = new OrderThread(broker, order1);
OrderThread thread2 = new OrderThread(broker, order2);
thread1.Start();
thread2.Start();
while(!thread1.IsWaiting || !thread2.IsWaiting)
{
Thread.Sleep(0);
}
Assert.AreEqual(1, broker.CallingPlaceOrderCount);
broker.WakeUp();
}
In questo modo abbiamo tolto l'elemento di casualità spiegato prima. Se non modifichiamo il codice della classe OrderThread il test fallirà in modo sistematico:
TestCase 'OrderThreadTests.Access_To_The_Broker_Should_Be_Synchronized' failed: Equal assertion failed: [[1]]!=[[2]]
Aggiungiamo a questo punto la lock prima della chiamata di PlaceOrder nella classe OrderThread:
private void PlaceOrder()
{
lock(_broker)
{
_broker.PlaceOrder(_order);
}
}
Proviamo ora a lanciare nuovamente il test ed ecco finalmente la conferma che i due thread sono sincronizzati:
Found 1 tests
[success] OrderThreadTests.Access_To_The_Broker_Should_Be_Synchronized
Il motivo per cui il test passa è che in questo caso sono entrambi in waiting ma uno è all'interno del metodo PlaceOrder mentre l'altro è fermo sulla lock.
L'esempio che ho fatto si può adattare facilmente anche ad altri scenari, in quanto cambieranno gli attori, ma i concetti sono gli stessi.
Feedback?