ABONAMENTE VIDEO REDACȚIA
RO
EN
NOU
Numărul 150
Numărul 149 Numărul 148 Numărul 147 Numărul 146 Numărul 145 Numărul 144 Numărul 143 Numărul 142 Numărul 141 Numărul 140 Numărul 139 Numărul 138 Numărul 137 Numărul 136 Numărul 135 Numărul 134 Numărul 133 Numărul 132 Numărul 131 Numărul 130 Numărul 129 Numărul 128 Numărul 127 Numărul 126 Numărul 125 Numărul 124 Numărul 123 Numărul 122 Numărul 121 Numărul 120 Numărul 119 Numărul 118 Numărul 117 Numărul 116 Numărul 115 Numărul 114 Numărul 113 Numărul 112 Numărul 111 Numărul 110 Numărul 109 Numărul 108 Numărul 107 Numărul 106 Numărul 105 Numărul 104 Numărul 103 Numărul 102 Numărul 101 Numărul 100 Numărul 99 Numărul 98 Numărul 97 Numărul 96 Numărul 95 Numărul 94 Numărul 93 Numărul 92 Numărul 91 Numărul 90 Numărul 89 Numărul 88 Numărul 87 Numărul 86 Numărul 85 Numărul 84 Numărul 83 Numărul 82 Numărul 81 Numărul 80 Numărul 79 Numărul 78 Numărul 77 Numărul 76 Numărul 75 Numărul 74 Numărul 73 Numărul 72 Numărul 71 Numărul 70 Numărul 69 Numărul 68 Numărul 67 Numărul 66 Numărul 65 Numărul 64 Numărul 63 Numărul 62 Numărul 61 Numărul 60 Numărul 59 Numărul 58 Numărul 57 Numărul 56 Numărul 55 Numărul 54 Numărul 53 Numărul 52 Numărul 51 Numărul 50 Numărul 49 Numărul 48 Numărul 47 Numărul 46 Numărul 45 Numărul 44 Numărul 43 Numărul 42 Numărul 41 Numărul 40 Numărul 39 Numărul 38 Numărul 37 Numărul 36 Numărul 35 Numărul 34 Numărul 33 Numărul 32 Numărul 31 Numărul 30 Numărul 29 Numărul 28 Numărul 27 Numărul 26 Numărul 25 Numărul 24 Numărul 23 Numărul 22 Numărul 21 Numărul 20 Numărul 19 Numărul 18 Numărul 17 Numărul 16 Numărul 15 Numărul 14 Numărul 13 Numărul 12 Numărul 11 Numărul 10 Numărul 9 Numărul 8 Numărul 7 Numărul 6 Numărul 5 Numărul 4 Numărul 3 Numărul 2 Numărul 1
×
▼ LISTĂ EDIȚII ▼
Numărul 62
Abonament PDF

Introducere în programarea .Net Multithreading (II)

Dan Sabadis
Team Lead @ SDL



PROGRAMARE

În articolul precedent am explicat ce este execuția multithreading și am descris elementele esențiale ale programării asincrone în .Net, anume interfața IAsyncResult. Am prezentat și o scurtă istorie a evoluției abstractizării programelor multithreading în mediul .Net, începând cu clasa Thread și sfârșind cu designul async-await (async și await ajungând chiar cuvinte rezervate în limbajul C#).

În acest articol vom oferi exemple de sincronizare a firelor de execuție (numite de acum în engleza originală "thread") din mediul .Net axate pe cele două moduri în care procesorul funcționează pentru orice sistem de operare: modul Utilizator și modul Kernel. Ca de obicei, începem cu definițiile.

Modul Kernel

În modul Kernel, codul sursă care se execută are acces complet și nerestricționat la hardware. Poate executa orice instrucțiune a procesorului și poate referenția orice adresă de memorie. Modul Kernel este rezervat funcțiilor de bază, de nivel jos ale sistemului de operare. Erorile critice în modul Kernel sunt catastrofale. Acestea vor opri complet calculatorul.

Modul Utilizator

În modul Utilizator, programul care se execută nu are acces direct la hardware sau la referințe de memorie. Rularea codului în modul Utilizator trebuie să delege funcțiilor API (Application Programming Interface) de sistem accesul la hardware și memorie. Datorită protecției rezultate din acest tip de izolare, erorile pot fi reparate. Majoritatea codului care rulează pe PC se va executa în modul Utilizator.

Ca o primă concluzie, atunci când e posibil, un programator ar trebui să folosească constructori de sincronizare în modul Utilizator deoarece nu numai că prezintă o siguranță mai mare, dar sunt semnificativ mai rapizi decât constructorii în modul Kernel, care folosesc instrucțiuni speciale de procesor pentru a coordona threadurile. Coordonarea se întâmplă la nivel de hardware, fapt ce o face rapidă. De aici rezultă și că sistemul de operare Windows nu detectează niciodată că un thread este blocat la nivel de constructor de sincronizare în modul Utilizator! Deoarece un thread blocat la nivel de constructor în modul Utilizator nu este niciodată considerat blocat, nu se va crea un nou thread care să înlocuiască threadul blocat temporar. Mai mult, aceste instrucțiuni de procesor blochează threadul pentru o perioadă extrem de scurtă de timp.

Precum în viață, există un echilibru și niște alegeri de făcut pentru a păstra acest echilibru. Există niște dezavantaje legate de threaduri executate în modul Utilizator: un thread care dorește să obțină niște resurse, dar pe care nu le poate obține, va rula continuu în modul Utilizator. Acest lucru consumă enorm de mult timp procesorului, timp care ar putea fi folosit mai bine pentru alte activități sau pentru conservarea puterii procesorului în mod leneș (idle).

Construcțiile din modul Kernel sunt puse la dispoziție de sistemul de operare însuși. Acestea impun ca threadurile aplicației să apeleze funcțiile implementate în sistemul de operare Kernel. Tranziția threadurilor de la modul Utilizator la Kernel și invers are efecte notabile asupra performanței, motiv pentru care construcțiile în modul Kernel ar trebui evitate. Totuși, acestea au și o parte pozitivă — când un thread folosește constructori în modul Kernel pentru a prelua o resursă a unui alt thread, sistemul de operare blochează threadul, de obicei înghețându-l, pentru a nu-și utiliza timpul de procesor.

Un thread care așteaptă o construcție s-ar putea bloca la nesfârșit dacă threadul care deține construcția nu o face disponibilă niciodată. Dacă construcția este de tip modul Utilizator, thread-ul rulează pe procesor la nesfârșit, ceea ce noi numim livelock. Dacă constructorul este de tip Kernel, threadul este blocat la nesfârșit, ceea ce noi numim deadlock. Ambele sunt indezirabile, dar din cele două, un deadlock este de preferat unui livelock, deoarece un livelock epuizează atât timpul cât și memoria procesorului, în timp ce deadlockul epuizează doar memorie.

Într-o lume ideală, am dori construcții care să ia ce este mai bun din ambele moduri. Ne-am dori o construcție de sincronizare care este rapidă și care nu blochează (precum construcțiile în modul Utilizator) când nu există competiție. Când există competiție pentru o astfel de construcție, am dori să fie blocat de sistemul de operare Kernel. Astfel de construcții hibride există în frameworkul .Net și le vom analiza în exemplele următoare.

Există două tipuri de construcții de sincronizare în modul Utilizator:

Toate construcțiile Volatile și Interlocked necesită transmiterea unei referințe (adresă de memorie) către o variabilă ce conține un tip simplu de date.

În continuare, vom analiza niște exemple din cod:

internal sealed class ThreadsSharingData {
  private Int32 m_flag = 0;
  private Int32 m_value = 0;

  // This method is executed by one thread
  public void Thread1() {
    // Note: These could execute in reverse order
    m_value = 5;
    m_flag = 1;
  }

  // This method is executed by another thread
  public void Thread2() {
    // Note: m_value could be read before m_flag
    if (m_flag == 1)
      Console.WriteLine(m_value);
  }
}

Problema cu acest cod este că procesorul/compilatorul ar putea rearanja codul astfel încât să inverseze cele două linii de cod din metoda Thread1! Inversarea celor două linii nu modifică scopul metodei. Metoda trebuie să primească valoarea 5 în m_value și 1 în m_flag. Astfel de probleme apar în timpul fazei de optimizare a compilatorului, deci problema nici nu va putea fi reprodusă în modul DEBUG, ci doar în modul RELEASE al unui program de calculator! Cu alte cuvinte, rezultatul acestui program nu este 100% predictibil, va fi 5 în majoritatea cazurilor, dar ar putea fi și 0 din când în când în modul de producție.

Pentru a corecta problema din codul de mai sus, vom folosi clasa statică .Net Volatile (sau cuvâtul cheie volatile).

public static class Volatile {
  public static void Write(ref Int32 location, 
    Int32 value);

    public static Int32 Read(ref Int32 location);
}

Aceste metode sunt speciale. Acestea dezactivează niște optimizări realizate de obicei de compilatorul C#, de compilatorul JIT sau chiar de procesor. Iată cum funcționează metodele:

Acum putem repara clasa ThreadsSharingData utilizând următoarele metode:

internal sealed class ThreadsSharingData {
  private Int32 m_flag = 0;
private Int32 m_value = 0;

// This method is executed by one thread
public void Thread1() {
// Note: 5 must be written to m_value before 1 
// is written to m_flag
m_value = 5;
Volatile.Write(ref m_flag, 1);
}

// This method is executed by another thread
public void Thread2() {
  // Note: m_value must be read after m_flag is read
  if (Volatile.Read(ref m_flag) == 1)
    Console.WriteLine(m_value);
}
}

Dacă codul de mai sus pare confuz, poate fi sumarizat astfel - când threadurile comunică unul cu celălalt prin memoria partajată, scrie ultima valoare apelând Volatile.Write și citește prima valoare apelând Volatile.Read.

Utilizând cuvântul-cheie volatile, putem rescrie și simplifica clasa ThreadsSharingData astfel:

internal sealed class ThreadsSharingData {
private volatile Int32 m_flag = 0;
private Int32 m_value = 0;

// This method is executed by one thread
public void Thread1() {
// Note: 5 must be written to m_value before 1 is // written to m_flag
m_value = 5;
m_flag = 1;
}

// This method is executed by another thread
public void Thread2() {
// Note: m_value must be read after m_flag is read
if (m_flag == 1)
    Console.WriteLine(m_value);
}
}

Metoda Volatile.Read realizează o operație atomică de citire, în timp ce metoda Write realizează o operație atomică de scriere. Astfel, fiecare metodă realizează fie o operație atomică de citire, fie una de scriere. Să analizăm acum metodele clasei statice System.Threading.Interlocked.

Fiecare metodă a clasei Interlocked realizează o operație atomică de citire și una de scriere. Mai mult, toate metodele Interlocked sunt "bariere complete de memorie". Orice editare de variabilă înainte de apelul metodei Interlocked se execută înainte de apel, iar orice citire de variabilă se execută după apel. Deci, compilatorul nu mai are libertatea deplină să ordoneze operațiile de citire/scriere asupra variabilelor trimise drept parametri metodelor Interlocked.

Deoarece metodele statice care operează asupra variabilelor Int32 sunt, de departe, cele mai utilizate metode, prezentăm câteva dintre ele mai jos. Există și elemente suplimentare asociate metodelor precedente care operează asupra valorilor Int64. Mai mult, clasa Interlocked expune metodele Exchange și CompareExchange care iau tipurile de valori Object, IntPtr, Single, și Double. Există, de asemenea, o versiune generică în cadrul căreia tipul generic este constrâns la nivel de clasă (orice tip de referință):

public static class Interlocked {
// return (++location)
public static Int32 Increment(ref Int32 location);

// return (--location)
public static Int32 Decrement(ref Int32 location);

// return (location += value)
// Note: value can be a negative number allowing 
// subtraction
public static Int32 Add(ref Int32 location, 
                        Int32 value);

// Int32 old = location; location = value; 
// return old;
public static Int32 Exchange(ref Int32 location, Int32 value);

// Int32 old = location;
// if (location == comparand) location = value;
// return old;
public static Int32 CompareExchange(
 ref Int32 location, Int32 value, Int32 comparand);
...
}

Deși extrem de utile, metodele Interlocked operează cel mai mult asupra valorilor Int32. Ce se întâmplă când trebuie manipulate mai multe câmpuri dintr-un obiect al unei clase la nivel atomic? În acest caz, avem nevoie de o metodă prin care să controlăm toate threadurile, în afară de unul, astfel încât doar unul să poată accesa porțiunea de cod ce manipulează câmpurile. Utilizând metodele Interlocked, putem crea un "lacăt" (numit de acum înainte "lock") pentru sincronizarea de threaduri:

internal struct SimpleSpinLock {
  private Int32 m_ResourceInUse; 
  // 0=false (default), 1=true

  public void Enter() {
  while (true) {
    // Always set resource to in-use
    // When this thread changes it from not in-use,     
    // return
    if (Interlocked.Exchange(
      ref m_ResourceInUse, 1) == 0) return;

    // Black magic goes here...
    }
  }

  public void Leave() {
    // Set resource to not in-use
    Volatile.Write(ref m_ResourceInUse, 0);
  }
}

Clasa de mai jos demonstrează modul de utilizare a SimpleSpinLock.

public sealed class SomeResource {
  private SimpleSpinLock m_sl = new SimpleSpinLock();

  public void AccessResource() {
m_sl.Enter();
// Only one thread at a time can get in here to access the resource...
m_sl.Leave();
  }
}

Implementarea SimpleSpinLock este foarte simplă. Dacă două threaduri apelează Enter în același timp, Interlocked.Exchange se asigură că un thread modifică m_resourceInUse de la 0 la 1 și vede că m_resourceInUse a fost 0. Apoi, acest thread iese din Enter pentru a putea continua execuția codului în/din metoda AccessResource. Celălalt thread va modifica m_resourceInUse de la 1 la 1. Acest thread va vedea că nu a schimbat m_resourceInUse din 0, iar acest thread va începe rularea, apelând Exchange până la primele apeluri de thread Leave!

Cea mai mare problemă cu acest lock este că face ca threadurile să ruleze continuu când există o competiție pentru lock. Astfel se consumă timp prețios de procesor care nu poate să facă muncă mai utilă. Prin urmare, spin lockurile ar trebui folosite doar pentru a păzi regiunile de cod care se execută foarte repede.

Construcțiile din modul Kernel sunt mai lente decât cele din modul Utilizator, deoarece necesită coordonare de la sistemul de operare. De asemenea, fiecare apel de metodă a unui obiect Kernel face ca threadul care apelează să treacă de la cod gestionat la cod nativ de Utilizator iar apoi la cod nativ Kernel, ca, mai apoi, să reînceapă procesul. Aceste tranziții necesită mult timp de procesor, iar dacă se realizează frecvent, pot avea efect negativ asupra performanței globale a aplicației.

Totuși, construcțiile din modul Kernel oferă beneficii față de construcțiile primitive în modul Utilizator, precum:

System.Threading oferă o clasă abstractă numită WaitHandle. Clasa WaitHandle este o clasă simplă al cărei singur scop este să cuprindă obiecte din kernelul Windows. Mediul .Net oferă câteva clase derivate din WaitHandle. Toate clasele sunt definite în System.Threading. Ierarhia claselor arată astfel:

WaitHandle
  EventWaitHandle
    AutoResetEvent
    ManualResetEvent
  Semaphore
Mutex

Câteva aspecte trebuie menționate raportat la metodele Wait ale clasei WaitHandle:

Metoda WaitOne ale clasei WaitHandle se apelează pentru a face ca timpul de așteptare din apelarea threadului, pentru obiectul Kernel de fond, să fie marcat. Intern, această metodă apelează funcția Win32 WaitForSingleObjectEx. Valoarea booleană returnată este adevărată dacă obiectul devine marcat sau falsă dacă apare un timeout.

Metoda statică WaitAll a WaitHandle se apelează pentru a face ca timpul de așteptare din apelarea threadului, pentru toate obiectele Kernel specificate în WaitHandle[] să devină marcate. Valoarea Boolean returnată este adevărată dacă toate obiectele devin marcate sau falsă dacă apare un timeout.

Metoda statică WaitAny a WaitHandle se apelează pentru a face ca timpul de așteptare din apelarea threadului pentru orice obiect Kernel specificat în WaitHandle[] să devină marcat. Tabloul (array) pe care îl transmitem metodelor WaitAny și WaitAll trebuie să conțină nu mai mult de 64 de elemente. În caz contrar, metodele returnează System.NotSupportedException.

Evenimentele sunt variabile Boolean simple, menținute de Kernel. Un thread care așteaptă un eveniment blochează evenimentul dacă are valoarea fals și îl deblochează când evenimentul are valoarea adevărat. Există două tipuri de evenimente. Când un eveniment auto-reset are valoarea adevărat, face ca doar un thread blocat să devină activ, deoarece Kernelul resetează automat evenimentul la valoarea fals după deblocarea primului thread. Când un eveniment manual-reset are valoarea adevărat, deblochează toate threadurile în așteptare, deoarece Kernelul nu resetează automat evenimentul la valoarea fals; codul vostru trebuie să reseteze manual evenimentul la valoarea fals. Clasele asociate evenimentelor arată astfel:

public class EventWaitHandle : WaitHandle {
  public Boolean Set(); 
  // Sets Boolean to true; always returns true

  public Boolean Reset(); 
  // Sets Boolean to false; always returns true
}

public sealed class AutoResetEvent : EventWaitHandle {
  public AutoResetEvent(Boolean initialState);
}

public sealed class ManualResetEvent : 
  EventWaitHandle {
    public ManualResetEvent(Boolean initialState);
}

Metaforic vorbind, diferența dintre aceste este ca diferența dintre o ușă rabatabilă și o ușă normală. ManualResetEvent este ușa ce trebuie închisă (resetată) manual. AutoResetEvent este o ușă rabatabilă (ca la un saloon din Vestul sălbatic) ce permite doar unei singure persoane să treacă și care se închide automat înainte să treacă următoarea persoană. Imaginați-vă că AutoResetEvent execută WaitOne() și Reset() ca o singură operație atomică.

Utilizând un eveniment auto-reset, putem crea ușor un lock pentru sincronizarea thread-urilor, cu comportament similar clasei SimpleSpinLock prezentate mai devreme:

internal sealed class SimpleWaitLock : IDisposable {
  private readonly AutoResetEvent m_available;
  public SimpleWaitLock() {
    m_available = new AutoResetEvent(true); 
    // Initially free
}

public void Enter() {
  // Block in kernel until resource available
  m_available.WaitOne();
}

public void Leave() {
  // Let another thread access the resource
  m_available.Set();
}

public void Dispose() { m_available.Dispose(); }
}

Am folosit acest SimpleWaitLock similar cu SimpleSpinLock. Comportamentul extern este același. Totuși performanța celor două lockuri este radical diferită. Când nu există o competiție pe lock, SimpleWaitLock este mai lent decât SimpleSpinLock, deoarece fiecare apel la metodele Enter și Leave ale SimpleWaitLock forțează threadul apelant să treacă de la cod gestionat la Kernel și înapoi — ceea ce este rău. Când exista competiție, threadul perdant este blocat de Kernel, nu se mai rotește și nu mai consumă cicluri de procesor — ceea ce este bine. Construirea obiectului AutoResetEvent și apelarea Dispose la nivelul obiectului provoacă de asemenea tranziții spre Kernel, afectând performanța. Aceste apeluri se întâmplă rar de obicei, deci nu sunt aspecte de care trebuie să fim prea preocupați.

Pentru a înțelege mai bine diferențele de performanță, să analizăm codul următor:

public static void Main() {
Int32 x = 0;
const Int32 iterations = 10000000; // 10 million
// How long does it take to increment x 10 million 
// times?

Stopwatch sw = Stopwatch.StartNew();
for (Int32 i = 0; i < iterations; i++) {
  x++;
}
Console.WriteLine("Incrementing x: {0:N0}", sw.ElapsedMilliseconds);
// How long does it take to increment x 10 million 
// times
// adding the overhead of calling a method that 
//  does nothing?

sw.Restart();
for (Int32 i = 0; i < iterations; i++) {
  M(); x++; M();
}
Console.WriteLine("Incrementing x in M: {0:N0}",   
  sw.ElapsedMilliseconds);
// How long does it take to increment x 10 million // times
// adding the overhead of calling an uncontended //
// SimpleSpinLock?

SpinLock sl = new SpinLock(false);
sw.Restart();
for (Int32 i = 0; i < iterations; i++) {
  Boolean taken = false; sl.Enter(ref taken); x++;   
  sl.Exit();
}
Console.WriteLine("Incrementing x in SpinLock:     
  {0:N0}", sw.ElapsedMilliseconds);

// How long does it take to increment x 10 million 
// times
// adding the overhead of calling an uncontended 
// SimpleWaitLock?

using (SimpleWaitLock swl = new SimpleWaitLock()) {
  sw.Restart();
  for (Int32 i = 0; i < iterations; i++) {
    swl.Enter(); x++; swl.Leave();
}
Console.WriteLine("Incrementing x in 
  SimpleWaitLock: {0:N0}", sw.ElapsedMilliseconds);
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void M() { 
/* This method does nothing but return */ }

Dacă rulăm codul anterior, obținem următorul rezultat:

Incrementing x: 8 Fastest
Incrementing x in M: 69 ~9x slower
Incrementing x in SpinLock: 164 ~21x slower
Incrementing x in SimpleWaitLock: 8,854 ~1,107x slower

După cum se poate observa, doar incrementarea lui x a durat 8 milisecunde. Apelarea metodelor vide înainte și după incrementarea lui x a făcut ca operația să dureze de 9 ori mai mult! Apoi, executarea codului într-o metodă care folosește o construcție în modul Utilizator a determinat codul să ruleze de 21 (164 / 8) de ori mai lent. Dar, iată cât de lent a rulat programul utilizând construcții în modul Kernel: de 1,107 (8,854 / 8) mai încet.

Deci, dacă putem evita sincronizarea de threaduri, ar trebui să o facem! Dacă aveți nevoie de sincronizare, utilizați construcțiile în modul Utilizator. Încercați să evitați construcțiile în modul Kernel.

Următorul exemplu va prezenta abordarea lockului hibrid ce reunește avantajele ambelor moduri de mai sus: pentru început vom încerca sincronizarea în modul Utilizator, iar dacă aceasta eșuează (alt thread utilizează deja lockul), vom reveni la metoda mai sigură în modul Kernel:

internal sealed class SimpleHybridLock : IDisposable {
// The Int32 is used by the primitive user-mode 
// constructs (Interlocked methods)
private Int32 m_waiters = 0;

// The AutoResetEvent is the primitive kernel-mode 
// construct
private readonly AutoResetEvent m_waiterLock = new AutoResetEvent(false);

public void Enter() {
  // Indicate that this thread wants the lock
  if (Interlocked.Increment(ref m_waiters) == 1)
    return; 
    // Lock was free, no contention, just return

    // Another thread has the lock (contention), 
    // make this thread wait
    m_waiterLock.WaitOne(); 
    // Bad performance hit here
    // When WaitOne returns, this thread now has 
    // the lock
}

public void Leave() {
  // This thread is releasing the lock
  if (Interlocked.Decrement(ref m_waiters) == 0)
    return; 
    // No other threads are waiting, just return

    // Other threads are waiting, wake 1 of them
        m_waiterLock.Set(); 
    // Bad performance hit here
}
public void Dispose() { m_waiterLock.Dispose(); }
}

SimpleHybridLock conține două câmpuri: un Int32, care va fi manipulat prin intermediul construcțiilor primitive în modul Utilizator, și AutoResetEvent, care este o construcție primitivă în modul Kernel.

Pentru o performanță bună, lock-ul încearcă să folosească Int32 și să evite AutoResetEvent pe cât posibil. Simpla construire a unui obiect SimpleHybridLock determină crearea lui AutoResetEvent, fapt ce reprezintă un salt de performanță comparativ cu elementele suplimentare asociate câmpului Int32. Vom vedea mai încolo o construcție hibridă (AutoResetEventSlim) care evită saltul de performanță generat de AutoResetEvent până când prima competiție este detectată. anume threaduri multiple care accesează lockul simultan. Metoda Dispose închide AutoResetEvent, ceea ce duce la alt salt de performanță.

Deși ar fi bine să îmbunătățim performanța construirii și a dealocării unui obiect

SimpleHybridLock, ar fi și mai bine să ne concentrăm asupra perfecționării metodelor sale Enter și Leave, deoarece acestea tind să fie apelate de multe ori pe parcursul vieții unui obiect. Să discutăm despre cele două metode!

Primul thread care apelează metoda Enter determină Interlocked.Increment să adauge 1 la câmpul m_waiters, făcând ca valoarea lui să fie 1. Acest thread vede că au existat zero threaduri care așteaptă acest lacăt, deci threadul se întoarce de la apel la Enter. Ceea ce trebuie remarcat aici este că threadul a achiziționat lockul foarte repede. Acum, dacă un alt thread intervine și apelează Enter, acest al doilea thread incrementează m_waiters la 2 și vede că un alt thread are lacătul, deci acest thread se blochează apelând WaitOne și utilizând AutoResetEvent. Apelarea lui WaitOne face ca threadul să se transfere spre Kernelul sistemului de operare, ceea ce duce la un salt de performanță. Totuși, threadul trebuie să stea pe loc, deci nu este o idee rea să avem un timp de oprire completă. Vestea bună este că threadul este acum blocat, nu consumă timp de procesor, fiind exact ceea ce făcea și metoda Enter a SimpleSpinLock, metodă introdusă anterior.

Să ne uităm și la metoda Leave! Când un thread apelează Leave, Interlocked.Decrement scade 1 din câmpul m_waiters. Deoarece m_waiters este acum 0, niciun alt thread nu este blocat în interiorul unui apel către Enter, iar threadul care apelează Leave poate reveni. Gândiți-vă cât de rapid este totul: abandonarea unui lock presupune că un thread scade 1 din Int32, face un test 'if' rapid, apoi revine! Pe de altă parte, dacă threadul care apelează Leave vede că m_waiters nu a fost 1, threadul știe că există rivalitate/competiție și că există cel puțin încă un thread blocat în Kernel. Acest thread trebuie să activeze un (și doar unul singur) thread blocat. Face acest lucru prin apelarea Set on AutoResetEvent. Acest aspect reprezintă un salt de performanță, deoarece thread se transferă spre Kernel și înapoi, dar tranziția se întâmplă doar în cazuri de concurență/rivalitate. Evident, AutoResetEvent permite ca doar un thread blocat să devină activ; orice alte threaduri blocate de AutoResetEvent vor rămâne în această stare până când noul thread deblocat apelează Leave.

În concluzie, performanța globală a unei aplicații poate fi îmbunătățită făcând ca un thread să cicleze în modul Utilizator pentru o vreme permițând threadului să ajungă în modul Kernel. Dacă lockul după care așteaptă threadul devine disponibil în timpul rotirii, tranziția spre modul Kernel este evitată. Dacă includem SimpleHybridLock în comparația de performanță de mai sus, se vor obține aceleași rezultate temporale ca în cazul SpinLock.

Frameworkul .Net oferă o serie de construcții de sincronizare hibride (clase), așa-zisele clase "Slim", precum : "ManualResetEventSlim", "SemaphoreSlim" și "ReaderWriterLockSlim".

Dorim să prezentăm cel mai folosit thread hibrid de sincronizare, adică clasa Monitor. Această clasă oferă un lock care suportă ciclare, guvernanță de threaduri și recursivitate. Este cea mai utilizată construcție de sincronizare deoarece există de cel mai mult timp, C# are chiar și un cuvânt cheie intrinsec pentru acesta (lock), compilatorul just-in-time (JIT) are informații intrinseci despre acesta, iar common language runtime (CLR) îl utilizează în aplicație. Totuși, aceasta construcție are multe dezavantaje, ceea ce determină apariția de cod cu buguri, cum vom putea vedea.

Orice obiect din memoria dinamică (numită de acum înainte "heap") are o structură a datelor, numită sync block, asociată obiectului. Un sync block are câmpuri pentru un obiect Kernel, ID-ul threadului principal, o contorizare recursivă, și o contorizare a câmpurilor în așteptare. Clasa Monitor este o clasă statică ale cărei metode acceptă o referință către orice obiect din heap, iar aceste metode manipulează câmpurile din sync blockul obiectului specificat. Așa arată cele mai folosite metode ale clasei Monitor.

public static class Monitor {
public static void Enter(Object obj);
public static void Exit(Object obj);

// We can also specify a timeout when entered the 
// lock (not commonly used):
public static Boolean TryEnter(Object obj, 
  Int32 millisecondsTimeout);

// I'll discuss the lockTaken argument later
public static void Enter(Object obj, 
  ref Boolean lockTaken);

public static void TryEnter(Object obj, 
 Int32 millisecondsTimeout, ref Boolean lockTaken);
}

Asocierea unei structuri de date de tip "sync block" fiecărui obiect din heap poate fi o activitate consumatoare de timp, în special din cauza faptului că sync blockurile obiectelor nu sunt folosite niciodată. Pentru a reduce consumul de memorie, echipa CLR oferă funcționalitatea descrisă mai sus într-un mod mai eficient: când CLR se inițializează, oferă un tablou (array) de sync blockuri în heapul nativ. Așa cum am mai discutat, atunci când un obiect este creat în heap, primește două câmpuri suplimentare asociate cu acesta. Primul câmp suplimentar, pointerul pentru tip de obiecte, conține adresa de memorie a tipului obiectului. Al doilea câmp suplimentar, index-ul sync block, conține un index cu valoare integer în tabloul (array) sync blockului.

Când se construiește un obiect, indexul sync block al obiectului este inițializat la valoarea -1, ceea ce presupune că nu se referă la niciun sync block. Apoi, când se apelează Monitor.Enter, CLR-ul găsește un sync block liber în tablou (array) și setează indexul sync block să se refere la sync blockul găsit. Cu alte cuvinte, sync blockurile sunt asociate cu un obiect ad-hoc. Când se apelează Exit, această metodă verifică dacă mai sunt threaduri care așteaptă să folosească sync blockul obiectului. Dacă nu există threaduri care așteaptă după el, sync blockul este liber, Exit setează indexul sync block al obiectului la valoarea -1, iar blockul free sync poate fi asociat cu alt obiect în viitor.

În fine, să presupunem că scriem următoarea metodă:

private void SomeMethod() {
  lock (this) {
    // This code has exclusive access to the data...
  }
}
Codul de mai sus este echivalent, iar compilatorul generează codul ca mai jos:

private void SomeMethod() {
  Boolean lockTaken = false;
  try {
  // An exception (such as ThreadAbortException) 
  // could occur here...

  Monitor.Enter(this, ref lockTaken);
  // This code has exclusive access to the data...
  }
  finally {
    if (lockTaken) Monitor.Exit(this);
  }
}

Ieșirea condițională din blocul 'finally' permite ca resursa să fie disponibilă doar dacă lockul a fost preluat anterior (astfel încât nicio eroare/excepție nu este ridicată înainte de apelul Enter).

Linkuri utile:


NUMĂRUL 149 - Development with AI

Sponsori

  • Accenture
  • BT Code Crafters
  • Accesa
  • Bosch
  • Betfair
  • MHP
  • BoatyardX
  • .msg systems
  • P3 group
  • Ing Hubs
  • Cognizant Softvision
  • Colors in projects