ReaderWriterLockSlim - Ottimizzare le performance delle applicazioni multi-thread.
Carissimi lettori ben trovati. Quest’oggi parleremo delle performance delle nostre applicazioni multithread, soffermandoci sull’utilizzo di una nuova classe: ReaderWriterLockSlim introdotta con la .NET Framework 3.5. Tale classe nasce con il presupposto di ottimizzare l’utilizzo dei lock in applicazioni multi-thread.
Capita spesso infatti che più thread cerchino d’accedere contemporaneamente ad una risorsa condivisa, la quale per motivi di design oppure per ragioni legate alla natura stessa della risorsa, deve essere progettata garantendo l’accesso esclusivo. Questo implica che la classe che gestisce la risorsa debba essere Thread Safety. La classe ReaderWriterLockSlim nasce proprio per migliorare l’utilizzo delle risorse condivise.
Vediamo nel seguito un esempio di un’applicazione multithread:
class Example_Lock
{
public const int NUM_READER = 1000;
public const int NUM_WRITER = 50;
public const int NUMTHREADS = NUM_READER + NUM_WRITER;
public const int NUM_INTERATIONS = 10;
}
/*******************************************************
* This class takes care of all bank operations
* without use locks
*******************************************************/
class BankAccount
{
protected long cash = 0;
protected long receivables = Example_Lock.NUM_WRITER * Example_Lock.NUM_INTERATIONS;
public virtual void ReceivePayment(int amount)
{
Thread.Sleep(1);
cash += amount;
receivables -= amount;
}
public virtual long Situation
{
get { return cash + receivables; }
}
}
La classe di cui sopra consente di mappare le operazioni tipiche di un conto corrente: visione del saldo e ricezione di un accredito. Come avete modo di vedere l’esempio di cui sopra non tiene conto dell’accesso multiplo di più thread, quindi non è per nulla affidabile se più thread cercano di effettuare l’accesso contemporaneamente. Vediamo infatti così si verifica in una situazione multithread:
static void StartThreadsWork()
{
int numberError = 0;
Thread[] threads = new Thread[NUMTHREADS];
BankAccount bA = new BankAccount();
//BankAccount bA = new Lock_BankAccount();
//BankAccount bA = new Smart_BankAccount();
long networth = bA.Situation;
Stopwatch sw = new Stopwatch();
sw.Start();
for (int i = NUM_READER; i < NUMTHREADS; i++)
{
threads[i] = new Thread(() =>
{
for (int j = 0; j < NUM_INTERATIONS; j++)
bA.ReceivePayment(1);
});
threads[i].Start();
}
for (int i = 0; i < NUM_READER; i++)
{
threads[i] = new Thread(() =>
{
for (int j = 0; j < NUM_INTERATIONS; j++)
if (bA.Situation != networth)
numberError++;
});
threads[i].Start();
}
for (int i = 0; i < NUMTHREADS; i++)
threads[i].Join();
Console.WriteLine("Number error {0} - Situation {1} in {2}", numberError, bA.Situation, sw.Elapsed);
}
Nell’esempio di cui sopra, esistono 1000 thread (NUM_READER) ognuno dei quali cerca di effettuare 10 operazioni di lettura (NUMITERATIONS) su un medesimo conto corrente. Inoltre esistono 50 thread scrittori (NUM_WRITER) che cercano di accreditare un totale di 500 euro (NUM_WRITER * NUMITERATIONS) sullo stesso conto corrente contemporaneamente.
Poiché la classe non è Thread Safety potrà capitare che otterremo risultati differenti ad esecuzioni differenti. Vi ricordo che il risultato atteso dovrebbe essere 500 euro. Lanciando il codice di cui sopra più volte, uno dei risultati ottenuti è il seguente:
L’esecuzione è sicuramente veloce, sono stati impiega solamente 4.5 secondi, ma il risultato aimè è semplicemente errato!
Prima dell’avvento della .NET Framework 3.5, un programmatore attento avrebbe utilizzato i lock, al fine di ottenere risultati corretti. Vediamo una possibile implementazione:
/*******************************************************
* This class extends the class BankAccount accomplishing
* the same operations using locks
*******************************************************/
class Lock_BankAccount : BankAccount
{
object guard = new object();
public override void ReceivePayment(int amount)
{
lock (guard)
{
base.ReceivePayment(amount);
}
}
public override long Situation
{
get
{
lock (guard)
{
return base.Situation;
}
}
}
}
In questo caso il risultato finale è sicuramente corretto, infatti rieseguendo il metodo StartThreadsWork, utilizzando l’instanza Lock_BankAccount , otterremo un output simile al seguente:
Tale valore è indubbiamente quello atteso, ma se guardiamo le performance siamo vicini ad un disastro: 13 secondi.
La limitazione dei lock consiste nel fatto che non esiste alcuna distinzione tra le scritture e le letture. Infatti è assolutamente corretto che un solo thread effettui la scrittura in un dato istante, ma perché non consentire la lettura contemporanea a più thread, garantendo che nessuno modifichi il valore letto?
La classe ReaderWriterLockSlim, ci viene incontro in tal senso:
/*******************************************************
* This class extends the class BankAccount accomplishing
* the same operations using ReaderWriterLockSlim.
* It is the fast and correct class to use.
*******************************************************/
class Smart_BankAccount : BankAccount
{
ReaderWriterLockSlim guard = new ReaderWriterLockSlim();
public override void ReceivePayment(int amount)
{
guard.EnterWriteLock();
try
{
base.ReceivePayment(amount);
}
finally
{
guard.ExitWriteLock();
}
}
public override long Situation
{
get
{
guard.EnterReadLock();
try
{
return base.Situation;
}
finally
{
guard.ExitReadLock();
}
}
}
}
Distinguo gli accessi in lettura e scrittura. Questa volta l’output sarà simile al seguente:
Impiegando quasi 4 secondi in meno rispetto al tempo precedente.
Questo ovviamente è un esempio banale, dato che le operazioni di accredito sono veloci, ma in un’applicazione reale l’aumento delle performance non è sicuramente trascurabile.
Alla prossima,
Carmelo.