AntonioGanci

Il blog di Antonio Ganci
posts - 201, comments - 420, trackbacks - 31

Usare il Test Driven Development per progettare applicazioni multithreading - Un esempio concreto

Il multithreading aggiunge complessità al design di un'applicazione. Occorre prestare attenzione alle differenti problematiche legate alla sincronizzazione, race condition, ecc.

In questi giorni sto realizzando un'applicazione in cui devo salvare dei messaggi di log su un database e per non rallentare il chiamante la scrittura sul database avviene in modo asincrono.

Voglio realizzare questa funzionalità in TDD e riporto qui i passi che ho effettuato e la soluzione trovata. Per ogni test scriverò prima il codice del test, introducendo classi e metodi necessari a descrivere lo scenario che sto testando, successivamente scriverò l'implementazione minima per far compilare il test ed infine modificherò il codice per farlo passare.

Partiamo dal LogWriter:

 public interface ILogWriter

 {

     void Write(Log log);

 }

L'implementazione concreta di questa interfaccia salva sul database i dati contenuti nella classe Log. La classe che realizzerò in TDD deve chiamare in modo asincrono il metodo Write.

Non utilizzerò librerie di mocking e scriverò delle classi stub che simuleranno i vari scenari da testare; questo renderà più semplice la comprensione del codice. Il primo stub che useremo conta quante volte viene chiamato il metodo Write e salva l'ultimo parametro con cui è stato chiamato:

class LogWriterForTest : ILogWriter

{

    public int WriteCallCount = 0;

    public Log Log = null;

    public void Write(Log log)

    {

        WriteCallCount++;

        Log = log;

    }

}

 

Il test controlla che quando viene accodato un log viene chiamato il metodo Write:

 

 

[Test]

public void When_Enqueue_A_Log_The_LogWriter_Is_Invoked()

{

    Log log = new Log();

    LogWriterForTest writer = new LogWriterForTest();

    LogThread thread = new LogThread(writer);

    thread.Enqueue(log);

    Assert.AreEqual(1, writer.WriteCallCount);

    Assert.AreEqual(log, writer.Log);

}

 

Per far passare il test non è necessario che il metodo Enqueue chiami il metodo Write in modo asincrono, questo sarà compito del prossimo test. Questo concetto è importante nel TDD e lo ribadisco: scrivere solo il codice indispensabile a far passare il test anche se non contiene ancora le funzionalità che effettivamente ci servono. Questo ci consente di ridurre al minimo la complessità del codice e dell'architettura e di arrivare alla soluzione in modo incrementale.

Il primo passo da fare ora è quello di scrivere il codice minimo per compilare, ma che non basta per far passare il test:

 

public class LogThread

{

    public LogThread(ILogWriter writer)

    {

    }

 

    public void Enqueue(Log log)

    {

    }

}

 

Se eseguo ora il test ottengo il messaggio: Expected: 1 But was: 0, in quanto il metodo Write non è stato chiamato; aggiungiamo quindi la chiamata:

 

public class LogThread

{

    private readonly ILogWriter _writer;

 

    public LogThread(ILogWriter writer)

    {

        _writer = writer;

    }

 

    public void Enqueue(Log log)

    {

        _writer.Write(log);

    }

}

 

Rilanciamo il test che passa con successo.

Passiamo al prossimo scenario: quando viene chiamata la Write il metodo Enqueue la esegue in modo asincrono. Ci serve un meccanismo di sincronizzazione che ci permetta di bloccare il metodo write e sbloccarlo successivamente con un comando; creiamo a questo scopo un nuovo stub:

 

class ManualResetLogWriter : ILogWriter

{

    public int WritingCount = 0;

    public Log Log = null;

    public readonly ManualResetEvent ResetEvent = new ManualResetEvent(false);

    public void Write(Log log)

    {

        WritingCount++;

        Log = log;

        ResetEvent.WaitOne();

        WritingCount--; 

    }

}

 

Quando chiamiamo il metodo Write del ManualResetLogWriter l'esecuzione si blocca sull'istruzione ResetEvent.WaitOne() fin quando da un altro thread non viene chiamato il metodo Set della classe del framework .NET ManualResetEvent.

La classe ManualResetLogWriter è utile nel prossimo test, in cui voglio verificare che le chiamate al metodo Write avvengono in modo asincrono. Sarà necessario modificare il metodo Enqueue affinché invochi il metodo write in un theread dedicato, evitando così che l'esecuzione dell'Enqueue si blocchi sull'istruzione WaitOne. Verifichiamolo:

 

[Test]

public void The_Calls_To_The_Writer_Are_Asynchronous()

{

    Log log = new Log();

    ManualResetLogWriter writer = new ManualResetLogWriter();

    LogThread thread = new LogThread(writer);

    thread.Enqueue(log);

    while(writer.WriteCallCount == 0)

    {

       Thread.Sleep(0); 

    }

    Assert.AreEqual(1, writer.WriteCallCount);

    writer.ResetEvent.Set();

    Assert.AreEqual(log, writer.Log);

}

 

Se eseguiamo il test con l'implementazione attuale della Enqueue vediamo che il test si blocca sull'istruzione: thread.Enqueue(log). Modifichiamo il codice in modo da far passare questo secondo test:

 

public class LogThread

{

    private readonly ILogWriter _writer;

 

    public LogThread(ILogWriter writer)

    {

        _writer = writer;

    }

 

   public void Enqueue(Log log)

    {

        Thread thread = new Thread(

        delegate()

        {

            _writer.Write(log);

        });

        thread.Start();

    }

}

 

Per maggiore compattezza del codice la chiamata _writer.Write(log) è stata fatta all'interno di un anonymous method.

 Rieseguiamo il test che ora passa.

Chiaramente non abbiamo ancora finito, perchè due thread in contemporanea potrebbero eseguire la Write creando una Race Condition (o corsa critica in italiano).

Il prossimo test si occuperà proprio di testare che non ci sia una race condition, cioè dobbiamo creare uno scenario in cui il test fallisce nel caso in cui più thread eseguono contemporaneamente il metodo Write. Scriviamo il test e poi commentiamolo un pezzo per volta:

 

[Test]

public void Two_Logs_Enqueued_Are_Served_One_At_Time()

{

    ManualResetLogWriter writer = new ManualResetLogWriter();

    LogThread thread = new LogThread(writer);

 

    Log log1 = new Log();

    thread.Enqueue(log1);

    while (writer.WritingCount < 1)

    {

        Thread.Sleep(0);

    }

 

    Log log2 = new Log();

    thread.Enqueue(log2);

    while(thread.RunningThreadCount < 2)

    {

        Thread.Sleep(0);

    }

 

    Assert.AreEqual(1, writer.WritingCount);

 

    writer.ResetEvent.Set();

    while (writer.WritingCount > 0)

    {

        Thread.Sleep(0);

    }

}

 

Nel codice del test precedente ho usato più volte la chiamata Thread.Sleep(0), il metodo Sleep interrompe il thread corrente per x millisecondi, se invece viene passato zero si permette al thread corrente di essere sospeso; questo per evitare di bloccare il PC con CPU al 100% nel caso in cui il test si blocchi in uno dei cicli while.

 

Il primo ciclo while attende che il primo thread sia fermo all'istruzione ResetEvent.WaitOne del ManualResetLogWriter.

 

Viene successivamente accodato il secondo log (variabile log2); ci serve quindi un meccanismo che mi permetta di verificare che i thread in esecuzione siano effettivamente due. Per questo scopo introduciamo, per ora solo nel codice del test, la property RunningThreadCount ed aspettiamo che raggiunga il valore 2.

 

Segue la Assert la quale assume che ci sia un solo thread in scrittura mentra l'altro è in attesa (ricordo che lo scopo é quello di far si che il metodo Write venga eseguito in mutua esclusione).

 

Il test termina sbloccando la Write tramite l'istruzione writer.ResetEvent.Set() ed infine attendiamo che entrambi i thread siano terminati.

 

Aggiungiamo la property RunningThreadCount alla classe LogThread:

 

public class LogThread

{

    private readonly ILogWriter _writer;

    private int _runningThreadCount = 0;

 

    public LogThread(ILogWriter writer)

    {

        _writer = writer;

    }

 

    public int RunningThreadCount { get { return _runningThreadCount; } }

 

    public void Enqueue(Log log)

    {

        Thread thread = new Thread(

        delegate()

        {

            _runningThreadCount++;

            _writer.Write(log);

            _runningThreadCount--;

        });

        thread.Start();

    }

}

 

Eseguendo il test si nota che la assert fallisce in quanto tutte e due i thread eseguono il metodo Write. Dobbiamo ora inserire un meccanismo di sincronizzazione, useremo l'istruzione lock:

 

    public void Enqueue(Log log)

    {

        Thread thread = new Thread(

        delegate()

        {

            _runningThreadCount++;

            lock(_writer)

            {

                _writer.Write(log);

            }

            _runningThreadCount--;

        });

        thread.Start();

    }

 

Ora rilanciamo il test il quale passerà con successo.

 

A qualcuno potrà sembrare strano che sia stata aggiunta la property RunningThreadCount, in quanto viene usata solo dal test. Questa pratica però è piuttosto comune nel TDD e capita spesso che queste funzionalità, aggiunte solo per poter testare la classe, tornino utili in seguito anche nel codice di produzione.

 

Se il metodo Write sollevasse un'eccezione la property RunningThreadCount non verrebbe decrementata. Creiamo un nuovo stub il quale sollevi un eccezione:

 

class ExceptionRaiseLogWriter : ILogWriter

{

    public bool IsWriting = false;

    public void Write(Log log)

    {

        IsWriting = true;

        throw new ArgumentException();

    }

    }

}

 

Ed il test che verifichi che effettivamente venga descrementata la property:

 

public void When_The_Writer_Throws_An_Exception_The_QueueLength_Is_Decreased()

{

    ExceptionRaiseLogWriter writer = new ExceptionRaiseLogWriter();

    LogThread thread = new LogThread(writer);

    thread.Enqueue(new Log());

    while(!writer.IsWriting)

    {

        Thread.Sleep(0);

    }

    while(thread.RunningThreadCount > 0)

    {

        Thread.Sleep(0);

    }

}

 

Proviamo ad eseguirlo: il test si blocca all'interno dell'ultimo while.

Per far passare il test aggiungiamo un try / finally al metodo Enqueue della classe LogThread:

 

        public void Enqueue(Log log)

        {

            Thread thread = new Thread(

            delegate()

            {

                _runningThreadCount++;

                try

                {

                    lock (_writer)

                    {

                        _writer.Write(log);

                    }

                }

                finally

                {

                    _runningThreadCount--;

                }

            });

            thread.Start();

        }

 

A questo punto anche questo test è a posto.

Mancano ancora altre cose, come la gestione delle eccezioni, ecc. ma credo che il modo di procedere ora sia chiaro.

Un altro test interessante da sviluppare è quello relativo alla gestione delle richieste secondo una politica FIFO mediante una coda, vale a dire che la prima richiesta accodata sia effettivamente la prima ad essere eseguita, mentre nell'implentazione derivata dai test finora scritti l'ordine di scrittura dipende da quale thread viene sbloccato prima e quindi non è deterministico.

Se avete suggerimenti, consigli o segnalazioni di errori scrivetemeli nei commenti.

 

Print | posted on venerdì 6 giugno 2008 16:29 | Filed Under [ Extreme Programming ]

Feedback

Gravatar

# re: Usare il Test Driven Development per progettare applicazioni multithreading - Un esempio concreto

X Luka: si il TDD quasi ti impone di distribuire correttamente le responsabilità perchè altrimenti inizi a far fatica a costruire gli scenari che vuoi testare.
Me ne accorgo in fretta perchè se non sono ben divise le responsabilità il codice dei test inizia a diventare lungo e poco leggibile.
Ad esempio in questo caso la creazione del thread è dentro il metodo Enqueue e non ho usato il meccanismo di thread pooling che mette a disposizione .NET. Se avessi voluto usarlo avrei dovuto togliere la responsabilità della creazione dei thread dalla classe LogThread.
06/06/2008 18:08 | Antonio Ganci
Gravatar

# re: Usare il Test Driven Development per progettare applicazioni multithreading - Un esempio concreto

Mi piace molto la tua esposizione del TDD e del disegno emergente, molto bella e molto "condensata", che e' una versione di TDD che mi piace anche di piu'.

Sul problema che affronti e risolvi avrei delle osservazioni ed obiezioni: in particolare su come non formuli il problema, o forse la soluzione, non sono neppure riuscito a capire quale... e poi su come la complessita', che deriva dal disegno, puo' essere semplificata con un altro disegno...

-LV
07/06/2008 03:57 | LudovicoVan
Gravatar

# re: Usare il Test Driven Development per progettare applicazioni multithreading - Un esempio concreto

> Si la seconda versione è più chiara, in effetti sono stato poco preciso sui requisiti.

Mi fa piacere che ti suoni bene perche' non prevede necessita' di fare multithreading...

-LV
08/06/2008 21:59 | LudovicoVan
Gravatar

# re: Usare il Test Driven Development per progettare applicazioni multithreading - Un esempio concreto

XLV: il pescare da un altra coda è un operazione che viene fatta in un altro thread per non dover aspettare in modo sincrono la scrittura del log sul database.
08/06/2008 22:32 | Antonio Ganci
Gravatar

# re: Usare il Test Driven Development per progettare applicazioni multithreading - Un esempio concreto

Il punto e' che chi "scrive", non ha bisogno di fare chiamate asincrone, perche' e' il concetto stesso di coda che disaccoppia mittente e ricevente.

Quanto al versante database, li' puoi schedulare lo "scarico" dalla coda come ti pare, cioe' di nuovo non ci sono problematiche di sincronizzazione particolari, e comunque te le gestisci "in loco", sono disaccoppiate da quelle del client.

Da entrambi i lati, l'atomicita' delle operazioni e' incapsulata nella coda stessa.

Non dimentiamo inoltre che on top del multithreading abbiamo gia' il multitasking. Cioe' mettendo insieme tutti i pezzi, la complessita' che ci rimane da gestire esplicitamente al livello di codice si riduce alla quota minimale strettamente legata ai requisiti di business (o requisiti tout-court che dir si voglia).

-LV

P.S. Sono consapevole che il tuo e' primariamente un articolo sul TDD, ma e' proprio per questo che ti ho chiesto il "permesso" prima di procedere. :)
08/06/2008 22:46 | LudovicoVan
Gravatar

# re: Usare il Test Driven Development per progettare applicazioni multithreading - Un esempio concreto

x Matteo:
Hai ragione c'è un errore in quel metodo di test. Oltre alla tua correzione va aggiunto anche un ciclo while di attesa:
thread.Enqueue(log);
while(writer.WriteCallCount == 0)
{
Thread.Sleep(0);
}
Assert.AreEqual(1, writer.WriteCallCount);
Assert.AreEqual(log, writer.Log);
writer.ResetEvent.Set();

Perchè potrei chiamare la Assert prima che il thread abbia incrementato il valore. Ora correggo anche il post.
Grazie per la segnalazione.
23/08/2008 17:08 | Antonio Ganci
Gravatar

# Usare RhinoMocks per testare applicazioni multithreading

Interessato dal post di Antonio sull&#39;uso del TDD per progettare applicazi multithreading ho deciso
Comments have been closed on this topic.

Powered by:
Powered By Subtext Powered By ASP.NET