A szinkronizáció buktatói

Egy előző részben egy szervizt próbáltunk összerakni, ami minél több műveletet képes elvégezni egységnyi idő alatt. Ehhez szükség volt egy megfelelő adatszerkezetre, ami helyt tud állni többszálú környezetben. Mivel egy beépített adatszerkezetet használtunk (ConcurrentQueue), nem feltétlenül tudatosult, hogy nem triviális feladatról van szó.

Régi, nyugodt idők

Több szálnak ellenálló adatszerkezetre/algoritmusra régóta szükség van, és sokan ismerik, vagy ismerni vélik a problémát a megoldással együtt. Vannak a programban műveletek, amelyeket vagy csak együtt, vagy sehogy nem szabad végrehajtani, különben a program inkonzisztens állapotot vesz fel. Nézzünk egy nagyon egyszerű példát, egy verem jellegű adatszerkezetet:

A fenti verem megvalósításában egy items nevű tömböt használ. A tömb rekeszei hordozzák a verembe tett értékeket. A tömb nagyobb méretű, mint ahány elemet a verembe tettek, így a tömb néhány rekesze még szabad, és további elemek fogadására kész. A következő szabad rekesz indexét a top nevű változó mondja meg.

A verem Push() művelete, amellyel új elemet teszünk a veremre, ezek alapján könnyen implementálható:

Az ábra felső részén látszik, hogy a két lépés között az adatszerkezet inkonzisztens állapotban van. Ha ekkor próbálnánk egy másik művelettel elérni a vermet, helytelen eredményt kapnánk. Hogyan tudnánk két művelet között elérni az adatszerkezetet? Például egy másik végrehajtási szálból. Az alábbi ábrán két párhuzamosan futó szál próbál elemet tenni a veremre. A piros számok a műveletek végrehajtásának sorrendjét mutatják.

Az első probléma a második lépésnél következik be, amikor egy második szál felülírja az első szál által a veremre helyezett értéket. A gond az, hogy mind a két szál ugyanazt a rekeszt használja, mivel az első szál a top értéket még nem növelte meg. Ezután mind a két szál növel egyet a top értékén, ami összesen két növelés, viszont csak egy rekesz lett (hibásan) felhasználva. Ezért egy érték elveszett a veremről, illetve egy meghatározhatatlan érték került rá.

Critical Section

A fenti probléma kezelésére természetesen vannak eszközök. A Windows operációs rendszer például szolgáltat egy Critical Section nevű objektumot, ami használható szinkronizációra. Ennek a működése a következő: miután a programunk létrehozott egy Critical Section objektumot, ezt egy szál az operációs rendszer segítségével ideiglenesen kisajátíthatja. Ha egy szál a Critical Section objektumot kisajátította, akkor a többi szál ezt már nem tudja megtenni. Ekkor a többi szálnak több választása van. Az egyik, és leggyakrabban alkalmazott, hogy elkezd várakozni az operációs rendszer segítségével, amíg a Critical Section objektum újra szabad nem lesz. Ha ez megtörtént, az operációs rendszer választ egyet a Critical Section-re várakozó szálak közül, hozzárendeli a Critical Section-t, majd elkezdi futtatni.

A fenti ábra a Critical Section-ök működését érzékelteti. A piros rész a védett programkód. Amikor a védett kód nincs végrehajtás alatt, akkor a Critical Section objektum szabad, ezt jelzi a zöld sáv az 1-es pontnál. Ahogy egy végrehajtási szál eléri a védett programkódot, az operációs rendszertől kéri, hogy az rendelje hozzá a Critical Section objektumot. Ennek a segítségével el tudja kezdeni a védett kódrész végrehajtását (2). Ahogy a szál elhagyja a kritikus kódrészt, visszaadja a Critical Section objektumot, ami így újra szabad (3). Ekkor az operációs rendszer ezt egy másik várakozó szálnak ki tudja osztani (4).

A Critical Section-t a Windows API-t alkalmazva nagyon egyszerű használni. A fenti vermes programot körülbelül így kellene megírni:

EnterCriticalSection(handle);
items[top] = newItem;
top++;
LeaveCriticalSection(handle);

Amikor a program futása eléri az EnterCriticalSection() sort, az operációs rendszer megvizsgálja, hogy a “handle” által hivatkozott Critical Section objektum szabad-e. Ha igen, a szálhoz rendeli az objektumot, majd a program futása folytatódik, egészen a LeaveCriticalSection() hívásig, amikor a Critical Section objektum felszabadul. Ha egy második szál eközben eléri az EnterCriticalSection() hívást, akkor az ő futása megakad, amíg a másik szál el nem éri a LeaveCriticalSection() hívást. Így nem fordulhat elő, hogy egy szál a másik által félbehagyott programállapotot lát.

A Critical Section-ökhöz teljesen hasonló konstrukció a .NET-en keresztül is elérhető, a Monitor osztályt használva. A .NET keretrendszer elfedi a Critical Section objektumot, emiatt azzal közvetlenül nem kell dolgoznunk. A Monitor osztály bármely referenciatípusú példányt enged szinkronizációs objektumként használni. A háttérben azonban az történik, hogy amikor először használunk egy példányt szinkronizációs objektumként, akkor létrejön hozzá egy Critical Section objektum. Erről érintőlegesen volt szó a GetHashCode-ról szóló cikkben, mivel az összerendelés pont azon a területen történik, mint a hash kód tárolása. Amikor tehát egy Monitor.Enter(alma) hívást használunk, akkor a háttérben egy az “alma” példányhoz rendelt Critical Section (szerű) objektum EnterCriticalSection()-nek megfelelő metódusa hívódik meg.

A C# compiler azonban még egy kis pluszt ad a kezünkbe. Ahelyett, hogy a Monitor osztály használnánk, lehetőségünk van a sokkal kézenfekvőbb lock kulcsszó használatára. Ezzel a programunk így néz ki:

object sync = new object;
 
...
 
void Push(T newItem)
{
 
  lock (sync) 
  {
 
    items[top] = newItem; 
    top++; 
  } 
}

Amiből a C# fordító a következő kódot készíti:

void Push(T newItem) 
{ 
  bool lockTaken = false;
  try
  {
    Monitor.Enter(sync, ref lockTaken); 
 
    items[top] = newItem; 
    top++; 
  }
  finally
  {
    if (lockTaken)
    {
      Monitor.Exit(sync);
    }
  } 
}

Amihez pedig a CLR a sync-hez rendelt Critical Section Enter/Leave metódusait hívja.

Mindent tudunk?

Nos, a régi szép időkben ennyi többnyire elég is volt, hogy több szálnak ellenálló algoritmusokat/adatszerkezeteket készítsünk. Mi a gond? A Critical Section-ök a Windows 95/Windows NT-s időkhöz lettek tervezve, és az elmúlt jó 17-18 évben jelentős architektúrális változásokon mentek át a processzorok. A változásokkal kapott lehetőségeket a Critical Section-ök pedig nem tudják igazán kiaknázni. Bár a .NET saját Critical Section implementációt használ, nem az operációs rendszerét, az ütközések feloldására az is az operációs rendszer konstrukcióit alkalmazza, konkrétan egy Event szinkronizációs objektumot használ. Hogy ennek mi a hátránya, jól mutatja az alábbi két teszt program. Ezek közül az első a System.Collection.Stack<T> osztályra épül. Ennek a Push metódusa szinte pont ugyanúgy működik, ahogy azt fentebb mi implementáltuk. Amivel többet tesz, az egy ellenőrzés, hogy elég-e a belső tömb mérete. Mivel a metódus nem szinkronizált, ezt használat közben nekünk kell megtennünk:

static void Main(string[] args)
{
    // Annyi szálat fogunk indítani, ahány magos rendszeren dolgozunk
    var coreCount = Environment.ProcessorCount;
    var threadsLeft = coreCount;
    Console.WriteLine("running on {0} cores", coreCount);
    // Kicsit trükközni kell, hogy a szálak egyszerre kezdjék a munkát.
    // Az utolsó szálat leszámítva erre az eventre vár majd a többi.
    // Az event-et az utolsó szál beállítja, így egyszerre kezdhetik
    // az elemek mozgatását a queue-ba.
    var manualResetEvent = new ManualResetEventSlim();
    var stopwatch = new Stopwatch();
    // Közönséges, szinkronizáció nélküli verem, ebbe dolgoznak majd
    // a szálak
    var stack = new Stack<int>();
    // var stack = new ConcurrentStack<int>();
    // A magszámnak megfelelő szál létrehozása.
    for (var i = 0; i < coreCount; i++)
    {
        new Thread(
                () =>
                {
                    // Ez az utolsó létrehozandó szál?
                    if (Interlocked.Decrement(ref threadsLeft) == 0)
                    {
                        // Igen, többi, már elkészült szál továbbengedése.
                        manualResetEvent.Set();
                        stopwatch.Start();
                    }
                    else
                    {
                        // Még vannak létrehozandó szálak, várakozás
                        manualResetEvent.Wait();
                    } // else
                    // Sok sok verem művelet...
                    for (var j = 0; j < 50000; j++)
                    {
                        for (var k = 0; k < 1000; k++)
                        {
                            lock (stack)  // ConcurrentStack-nál nem kell
                            {
                                stack.Push(k);
                            } // lock
                        } // for k
                        for (var k = 0; k < 1000; k++)
                        {
                            lock (stack)  // ConcurrentStack-nál nem kell
                            {
                                stack.Pop();
                            } // lock
                        } // for k
                    } // for j
                    // Ez az utolsó szál, ami végzett?
                    if (Interlocked.Increment(ref threadsLeft) == coreCount)
                    {
                        // Igen, eredmény riportolása.
                        Console.WriteLine("Finished in {0} ms", stopwatch.ElapsedMilliseconds);
                    }
                }
            ).Start();
    } // for i
    Console.ReadKey();
} // Main()

A futásidő egy átlagos két magos számítógépen 11 másodperc.

Ezzel szemben a következő, szinte ugyanolyan felépítésű program a modern követelményeknek megfelelő szinkronizációval ellátott verem adatszerkezetet használja:

static void Main(string[] args)
{
    // Annyi szálat fogunk indítani, ahány magos rendszeren dolgozunk
    var coreCount = Environment.ProcessorCount;
    var threadsLeft = coreCount;
    Console.WriteLine("running on {0} cores", coreCount);
    // Kicsit trükközni kell, hogy a szálak egyszerre kezdjék a munkát.
    // Az utolsó szálat leszámítva erre az eventre vár majd a többi.
    // Az event-et az utolsó szál beállítja, így egyszerre kezdhetik
    // az elemek mozgatását a queue-ba.
    var manualResetEvent = new ManualResetEventSlim();
    var stopwatch = new Stopwatch();
    // A szálak szinkronizált adatszerkezeten dolgoznak
    var stack = new ConcurrentStack<int>();
    // A magszámnak megfelelő szál létrehozása.
    for (var i = 0; i < coreCount; i++)
    {
        new Thread(
                () =>
                {
                    // Ez az utolsó létrehozandó szál?
                    if (Interlocked.Decrement(ref threadsLeft) == 0)
                    {
                        // Igen, többi, már elkészült szál továbbengedése.
                        manualResetEvent.Set();
                        stopwatch.Start();
                    }
                    else
                    {
                        // Még vannak létrehozandó szálak, várakozás
                        manualResetEvent.Wait();
                    } // else
                    // Sok sok verem művelet...
                    for (var j = 0; j < 50000; j++)
                    {
                        for (var k = 0; k < 1000; k++)
                        {
                            stack.Push(k);
                        } // for k
                        for (var k = 0; k < 1000; k++)
                        {
                            int result;
                            stack.TryPop(out result);
                        } // for k
                    } // for j
                    // Ez az utolsó szál, ami végzett?
                    if (Interlocked.Increment(ref threadsLeft) == coreCount)
                    {
                        // Igen, eredmény riportolása.
                        Console.WriteLine("Finished in {0} ms", stopwatch.ElapsedMilliseconds);
                    }
                }
            ).Start();
    } // for i
    Console.ReadKey();
} // Main()

A futásidő 6 másodperc, azaz majdnem kétszer olyan gyors, mint az előző. De nem csak a sebességkülönbség az, ami miatt nem elegendő a lock kulcsszót ismerni egy mai többszálú program írásához. Hogy miért, meglátjuk a következőkben.

Egyéb szinkronizációs problémák

Trükkök a háttérben

Kezdetben a processzorok viszonylag egyszerűen és kiszámíthatóan működtek. Később a processzorgyártók egyre több trükköt kezdtek alkalmazni a sebesség érdekében. Amire figyeltek, hogy az egy szálon végrehajtott programkód viselkedése ne változzon meg a trükkök alkalmazásával sem. Több szál esetében viszont a trükkök alkalmazása más más programviselkedést eredményezhet. Mivel a programozók zömmel egyszálú programokat írnak, ezekkel a trükkökkel nem is kell szembesülni, így meg sem tanulnak alkalmazkodni hozzá. A helyzet azonban változik a többmagos processzorok terjedésével.

Relativitás elmélet és a processzorok

Milyen trükkökről van szó? Az egyik jellemző és könnyen megérthető trükk a cache-ek használata. A cache azért került bevezetésre, mert a processzorok jóval gyorsabban képesek dolgozni, mint ahogy a memória szolgáltatni tudja az adatot. Emiatt a processzor és a memória közé kisebb, drágább, de gyors memóriát helyeztek, ma már több lépcsőben. Mivel a programokra igaz az, hogy egy-egy programrész ugyanarról a területről éri el adatokat többször egymás után (lokalitási elv), a cache-ben lehet tárolni az éppen szükséges adatok szűk körét, így a processzor gyorsan tudja őket írni/olvasni. Az írt adatok később aztán átszivárognak a fő memóriába.

Ha veszünk egy mai többmagos processzort, könnyű belátni, hogy a másik magon futó szál az adatokat a cache miatt késleltetve fogja látni. Ha a másik szál is ugyanazokat az értékeket állítgatja, például mind a két szál egy számlálót növelget, akkor helytelen eredményt kapunk. Az egyik szál egy változó értékét 10-ről 11-re állította, a másik szál viszont még mindig a 10-et látja, és ő is ezt növeli szintén 11-re, a 12 helyett. Ezt demonstrálja a következő program:

using System;
using System.Threading.Tasks;
class Program
{
    static int counter = 0;       // A növelendő számláló
    static int loopSize = 100000; // A ciklus mérete, ami majd növelget

    static void Main(string[] args)
    {
        var tasks =                              // Minden magra lesz
           new Task[Environment.ProcessorCount]; // egy taszk, ami növelget

        for (var i = 0; i < tasks.Length; i++)   // A taszkok létrehozása magonként
        {
            tasks[i] = 
                Task.Factory.StartNew(
                    () => 
                    {                            // A taszk feladata a számláló növelése
                        for (var n = 0; n < loopSize; n++)
                        {
                            counter++;
                        }
                    });
        } // for

        Task.WaitAll(tasks);                     // Fusson le az összes taszk

        Console.WriteLine(
          "Expected: {0}, got {1}",              // Várt/aktuális érték
           tasks.Length * loopSize, counter);
    } // Main()
} // class Program

Egy kétmagos gépen azt várjuk, hogy a counter értéke 200000 lesz a program végén. A gyakorlatban azonban mindenféle más érték előfordul:

Van ennél azonban durvább dolog is. A mai processzorok irdatlan sebességét a hagyományos cache-ek sem képesek megfelelően kielégíteni, emiatt újabb trükkök kerültek alkalmazásra. A cache részekre (bank-okra) van osztva, amelyek párhuzamosan tudnak dolgozni egymás mellett. Ennek azonban nagyon furcsa következményei lehetnek.

Hogy megértsük a problémát, előbb meg kell érteni miért jók a cache bankok: a cache úgynevezett cache line-okból áll, ami cache line a memória szomszédos rekeszeit tartalmazza, mondjuk 256 byte-ot. Egy cache line például a 0x1000-0x10ff memóriaterület. Ezt a területet egy egységként kezeli a cache. Ha egy program kér két adatot a 0x1000-es és a 0x1010-es címről, és az nincs a cache-ben, akkor a cache elkezdi beolvasni ezeket a tartalmakat, a processzor pedig várakozik (nos, ez sem igaz, a hyperthread-es processzorok ilyenkor például elkezdenek futtatni egy erre az esetre készenlétbe helyezett másik szálat), adott idő után megjönnek az adatok, és a processzor tud dolgozni az adatokkal. Mivel a két szükséges adat egy cache line-ban helyezkedik el, egy lépésben megszerzésre került mind a kettő (és ez általában bejön, mivel a lokalitási elv miatt a program gyakran módosít egymáshoz közel levő adatokat). Ha azonban egy 256 byte-os cache line-nal rendelkező processzoron futó program a 0x1000 és 0x1110-es címről kér adatot, amelyek nincsenek a cache-ben, ez két cache line olvasási igény lesz dupla idővel, mivel két cache line-t kell felolvasni. És itt jönnek képbe a bank-ok. Ezek majdnem független egységek, így párhuzamosan tudnak működni, például adott időben párhuzamosan behúzni adatokat. Így ha jól állnak a csillagok, az 0x1000 és 0x1110 címen levő adat mégis egyszerre meg tud érkezni, mivel a két cache line-t két cache bank dolgozza fel, a processzornak nem kell kétszer sokat várni.

A cache bankok által okozott, számunkra érdekes furcsaságot most az írás példáján fogjuk megnézni. Tegyük fel, hogy van egy következő (gépikód jellegű) programunk:

// valamilyen számítás történt,
// az eredmény előállt EAX-ben:
MOV [0x1000], AEX   // az eredmény tárolása a memóriába, az 0x1000 címre
MOV [0x1110], 1     // egy flag, ami mutatja, hogy 0x1000-en jó érték van

A fenti program például egy hasonló programból fordulhatott:

bool isCalculated;  // ez megy 0x1110-ra
int value;          // ez pedig 0x1000-ra
...
value = xxx;        // az xxx valamilyen számítás
isCalculated= true; // az érték kiszámítva

Ha a processzor írni akar az 0x1000-es címre (value), azután pedig a 0x1110-re (flag), ezt esetleg két külön cache bank fogja teljesíteni, mivel két különböző cache line-ról van szó. Ennek az a csúnya következménye lehet, hogy ha a 0x1110-es címmel foglalkozó cache bank szabad (mert éppen nem foglalkozik egy korábbi kérés végrehajtásával), de az 0x1000 nem az (mert egy előző kérést teljesít) akkor a 0x1110 címmel foglalkozó bank a műveletet hamarabb fogja elvégezni, azaz a két adat írása időben helyet cserél, ha a memória oldaláról nézzük. Ekkor ha egy másik magon futó szál ezeket az adatokat jó ütemben olvassa, előfordulhat, hogy az átállított flag-et hamarabb észleli, mint a kiszámolt értéket – ami ekkor számára még az eredeti értéken látszik.

A fenti példához hasonló okok miatt, a modern processzorok hajlamosak átrendezi az írások/olvasások sorrendjét, hogy mindig a rendelkezésre álló adatokkal tudjanak dolgozni. Nyilván ezt nem tehetik meg tetszés szerint, például ha egy program kiír 0x1000-re 10-et, aztán pedig 100-at, akkor a két írás átrendezése más programfutást eredményez. Emiatt a processzor csak olyan átalakításokat végez, ami adott szálon tekintve ugyanazt a viselkedést eredményezi (de más szálból tekintve már nem feltétlenül). Ha például a programkód beolvas adatot 0x1000-ről, aztán 0x1100-ről majd 0x2000-ről, nem biztos, hogy a processzor az olvasásokat ilyen sorrendben hajtja végre. A szál szempontjából úgyis csak az a lényeg, hogy a végén meglegyen mind a három adat, mondjuk három regiszterben. Ezeknek az olvasásoknak a felcserélése nem módosítja a szál által futtatott program viselkedését, ha csak az adott szálat tekintjük. A value/flag értékek írásának a felcserélése sem módosítja a program viselkedését, ha csak egy szálat tekintünk. Miután lefutott a két értékadás, a memóriában az adott két érték található, akármilyen sorrendben futnak az értékadások. Mivel a kódban a két értékadás között nincs művelet az értékekkel, nem számít a sorrend. A gondot az okozza, ha egy másik szálat is figyelembe kell venni. Ha az írásaink/olvasásaink sorrendje több szál szemszögéből nézve fontos, akkor értesíteni kell a processzort, hogy szándékunkban áll egy másik szállal kommunikálni, emiatt az utasítások/írások/olvasások felcserélése problémát okoz. Erről szól a szinkronizáció egyék része.

Miért érdekeljen a processzor?

A .NET az ECMA CLI specifikációjára épül, ami sok minden mellett leír egy gépikód szerű nyelvet és egy végrehajtó rendszert (VES – Virtual Execution System, máshol Execution Engine). Ebben az a jó, hogy elvileg nem kell foglalkozni azzal, hogy a Virtual Execution System milyen valós processzoron fut, mivel a CLI egységes felületet ad meg.

A valóság azonban az, hogy a CLI által adott egységes felület nagyon laza, lényegében csak az írja elő, hogy egy szál kontextusában a program viselkedése nem változhat meg. A korábban a cache bankoknál bemutatott probléma tehát a CLI-n keresztül is fennáll. Ez nehezíti ugyan a programozó dolgát, ugyanakkor nem fogja vissza a processzort. Ha a CLI el akarná fedni a processzor furcsa tulajdonságait, folyamatosan szinkronizációra kellene, hogy kényszerítse, ami teljesítményvesztéssel járna.

Hogy mégis meg lehessen oldani a szálak közötti kommunikációt, a CLI bevezeti a volatile írást és olvasást. A volatile-nek több hatása is van, amit ebben a pillanatban nem veszünk sorra. De az egyik például az, hogy a volatile műveleteket (írás, olvasás) nem szabad cache-elni. Ez azt jelenti, hogy a CLI biztosítja, hogy egy szál által végzett volatile írás az összes másik szál számára láthatóvá válik, mielőtt az írást végző szál a következő utasítás feldolgozásába kezd. Szintén követelmény a volatile műveletekkel szemben, hogy a sorrendjük az összes másik szál számára olyan módon látszik, ahogy azt a végrehajtó szál elvégezte. Volatile írásoknál tehát nem jön elő a cache bankoknál bemutatott probléma.

A compiler, a tettestárs

Az eddigiekben megtudtuk, hogy a processzor a sebesség érdekében átrendezheti a programunk utasításait, és ha ugyan ez egy szálat tekintve nem okozhat másféle viselkedést, több szál esetén már igen. Nem a processzor azonban az egyedüli “bűnös”. A compiler szintén megteszi a magáét, sőt, sokkal agresszívebben, mint a processzor. A C# esetében ráadásul 2 szinten megy a fordítás, van egy C# fordító, ami C# – IL fordítást végez, majd utána a jitter, ami előállítja mondjuk az x86-os kódot. Nézzük meg, mi történik a két fordítás alatt például az alábbi programmal:

class Program
{
    static int endValue = 1000;
    static void Main(string[] args)
    {
         var a = endValue;
         for (var i = 0; i < endValue; i++)
         {
         } // for i
         var b = endValue;
         Console.WriteLine("Kész");            
    }
}

Nézzük az eredményt:

// A verem állapotának az elmentése, hogy azt
// ugyanolyan állapotban adjuk vissza a hívónak
// ahogy kaptuk. Az első árulkodó jel az
// optimalizációról már itt látszik, ugyanis a
// jitter nem foglalt helyet a lokális változóknak,
// pedig abból van három is (a, i, b változók).
// Ha lenne helyfoglalás, akkor az esp-ből itt látnánk
// egy kivonást, ami helyet csinál a vermen a változóknak,
// például SUB ESP, 12
00000000  push        ebp 
00000001  mov         ebp,esp 
// ez csak az a hívás, ami behúzta nekem a debuggert,
// nem kell vele foglalkozni. Az igazi optimalizációhoz
// nem szabad debuggerből indítani még a release verziót
// sem, mert akkor a jitter nem lesz olyan agresszív, 
// hogy könnyebb legyen a gépikódot a forráskóddal
// párosítani. Mi most agresszív optimalizálást akarunk,
// ezért debuggeren kívül indult a kód, az alábbi hívás
// pedig, miután a jitter keményen optimalizált, berántja
// a debuggert.
00000003  call        67837D20 
// itt következik a for ciklus. Ez azért érdekes, mert
// az "a = endValue" értékadás nincs sehol, azaz azt a
// compiler/jitter kitörölte, mivel "a" változó értékét
// nem használtuk sehol, valóban hasztalan is volt.
// Az alábbi sor az i = 0 értékadás a for ciklusból. Látszik, hogy az
// edx regiszter játsza az i szerepét, tehát az i nem jelenik
// meg hagyományos módon lokális változóként (nincs ábrázolva
// a memóriában).
00000008  xor         edx,edx      // i = 0
// ez a kód az "i < endValue" azon része, ami a ciklusba
// lépésnél fut le. Első lépésként az EAX regiszterbe
// olvassa az endVaule értékét. Ha most végigtekintünk
// a maradék kódon, akkor látszik, hogy több helyen ez
// a változó nem is lesz beolvasva, pedig eredeti értelmezésben
// a változót sokszor olvassuk. Ezzel a jitter elspórolt egy
// csomó olvasást, az endValue értékét a továbbiakban konstansnak
// veszi.
// A megvalósítás kicsit kifacsart, itt a jitter tudta,
// hogy a ciklusváltozó kezdeti értéke nulla, emiatt ha
// az endValue <= 0, akkor az i < endValue
// nem teljesül. Így nem is a két értéket hasonlítja, hanem
// csak az endValue értékét vizsgálja önmagában, a
// TEST gépikódú utasítás segítségével. 
0000000a  mov         eax,dword ptr ds:[00183358h] // EAX = endValue
0000000f  test        eax,eax                      // EAX < 0?
00000011  jle         00000018                     // Ha igen a ciklus átugrása
// A for ciklus i++ része. Korábban láttuk, hogy a
// ciklusváltozó egy lokális változó helyett a 
// regiszterben lesz.
00000013  inc         edx 
// az "i < endValue" ellenőrzés. Látható, hogy a jitter
// megspórolja az endValue folyamatos olvasását a memóriából.
// Egy szálban gondolkodva ez nem jelent változást a program
// viselkedésében, hiszen a memóriaterületet itt semmi nem írja.
00000014  cmp         edx,eax           // i és endValue összehasonlítása
00000016  jl          00000013          // ha kisebb, vissza a ciklusba
// Ez lenne a Console.WriteLine kódja. Mi az érdekes? Kicsit
// rutinosabb szem kell hozzá, de ki lehet szúrni, hogy ez
// nem is lehet a Console.WriteLine. Egyrészt két hívás van,
// az egyik egy paraméter nélküli statikus függvény, ami visszaad
// egy 32 bitben elférő valamit. Ezt a valamit azután a 25-ik
// sortól pontosan úgy használja, mint amikor egy virtuális
// függvényt hívunk. A Console.WriteLine() pedig egy egy paraméteres
// statikus függvény. Mi történik hát? Az, hogy a jitter túl kicsinek
// találta a Console.WriteLine() metódust, emiatt beinline-olta.
// Az alábbi kicsi kód tehát a WriteLine belső implementációja: 
00000018  call        67227110                // Console osztály Out property getter
0000001d  mov         ecx,eax                 // Egy TextWriter példány referenciája
0000001f  mov         edx,dword ptr ds:[036A2030h] // "Kész" string referenciája
00000025  mov         eax,dword ptr [ecx]     // TextWriter metódustáblájának címe
00000027  mov         eax,dword ptr [eax+3Ch] // TextWriter.WriteLine(string) virtual slot
0000002a  call        dword ptr [eax+10h]     // virtuális függvény hívása
0000002d  pop         ebp                     // verem visszaállítása, ahogy kaptuk
0000002e  ret                                 // visszatérés

Mire a c# kódból gépikód lett, addigra eltűntek a lokális változók, az endValue a sok beolvasás helyett csak egyszer lett a memóriából kiolvasva, és eltűnt egy függvényhívás is, helyette be lett másolva annak a törzse. Maga a program tehát jelentősen átalakult a sebesség érdekében, bár elvileg a program viselkedése nem változott. Mi ekkor a baj? Nézzünk egy hasonló esetet:

class Program
{
    static bool done = false;
    static void Main(string[] args)
    {
         while (!done)
         {
             // ...
         }
         Console.WriteLine("Kész");            
    }
}

A fenti program egy ciklusban csinál valamit, amíg a done változó igazra nem áll. Ezt a változtatást (hogy done = true) például egy másik szál teheti meg. Ha a while ciklus előtt a done változó értékét a compiler egy regiszterbe húzza, mint ahogy az előző példában az endValue-val történt (a jelenlegi compiler egyébként ezt nem teszi meg, azért is nincs itt a gépi kód), akkor a másik szál módosítása soha nem lesz észlelhető. Vannak tehát helyzetek, amikor a regiszter optimalizálás nem szerencsés. De vannak a compiler optimalizációnak más hátrányai is. Induljunk ki a következő kódból:

int a = f1();
int b = f2();
int c = a;

Milyen kód lesz emögött egy x86-oson? A teljes kód lényegtelen, ami a lényeg, hogy egy függvényhívás eredménye többnyire az EAX regiszterben adódik vissza. Emiatt az első sor lefutása után EAX-ben lesz az az érték, amit az “a” lokális változót hordozó memóriacímre ki kell írni. Ezután a második sorban új függvényhívás történik, ami felülírja EAX értékét az új visszatérési érték miatt. A harmadik sorban azt szeretnénk, ha az “a” lokális változó értéke odamásolódna “c”-be. Az x86 nem tud memóriából memóriába mozgatni, emiatt itt először egy regiszterbe kell felolvasni “a” értékét, majd ezt a regisztert kiírni “c”-re. Csakhogy “a” értéke egyszer már járt egy regiszterben, de azt az f() hívás tönkretette. Viszont láthatjuk, hogy “c” változó lokális, és a fenti kódban az értéke semmilyen módon nem tudja befolyásolni például f2() függvény futásának a módját. Emiatt lényegében mindegy, hogy a “c = a” értékadás f2() hívása előtt vagy után történik meg, viszont ha a jitter ezt előre rakja, akkor fel tudja használni a még friss EAX regiszter értékét, és meg lehet spórolni egy gépi utasítást illetve egy memóriamozgatást. Ezzel nem azt mondom, hogy a jelenleg compilerek/jitterek csinálnak ilyet, de a lehetőségük meg lenne rá.

Kicsit nehezebb olyan példát adni, amikor egy instance (vagy statikus) változóval történik ugyanez. A következő példa így erőltetett lesz, de mutatja az utat a problémák felé:

class C
 { 
  Something a; 
  ...
   
  void f() 
  {
     var temp = new Something(); 
     temp.A = "alma"; // property setter, inlineolható 
     this.a = temp;
  } 
}

A nem lokális változókkal az a nehézség az optimalizálás szempontjából, hogy a jitter nem tudhatja, hogy a hívási lánc mélyén nem-e használja valami metódus a nem lokális változót, és így nincs lehetősége megvizsgálni, hogy a rá vonatkozó értékadások átmozgatása biztonságos-e vagy nem. Az én példám emiatt azon alapszik, hogy a property settereket a jitter beinline-olja (ha elég rövidek), és az inline-olt kódot kiértékelve el tudja dönteni, hogy az instance változó érintett-e vagy nem. Ha nem érintett, akkor a jitter az alábbi kódnak megfelelő gépikódot generálhatja a korábbi példa megfontolásai alapján (ismét, ez csak spekuláció, a gyakorlatban nem ilyet csinál, de akár csinálhatna is):

var temp = new Something(); // eredmény EAX-ba kerül
this.a = temp;              // még mindig EAX-ban, gyorsan írjuk ide is
temp.A = "alma"; 

A “this.a = temp” értékadás feljebb került, így a még friss EAX regisztert lehet használni. Ha a temp másra nincs használva, akkor a fenti kódban nincs szerepe, esetleg azt ki is törölheti a jitter:

a = new Something();
a.A = "alma";

Egy szálban gondolkodva ez a kód ugyanazt az eredményt (hatást) adja, mint az eredeti (ezért is engedheti meg magának a compiler/jitter az optimalizálást). Több szál esetén viszont érhetnek meglepetések. Az alábbi ábra az eredeti program működését ábrázolja, miközben egy másik szál is használja a manipulált instance változót.

A fenti ábrán a második szál azután kezdi el használni az “a” membert, miután az új Something példány már létrejött. Az új példány azonban csak egy ideiglenes változón keresztül látszik, nem az “a”-n keresztül. Emiatt a Thread 2 nem fog félig inicializált példányt használni. Nem ez a helyzet az optimalizált verzióval:

Ebben az esetben a két szál egymáshoz képes ugyanabban az ütemben fut, mint előzőleg, azaz a második szál az új Something példány létrejötte után közvetlenül hivatkozik az “a” változó értékére. Most azonban már a félig inicializált Something példányt látja, ami nyilván nem helyes.

Hol tartunk most?

Láttuk, hogy a nagyobb teljesítmény érdekében több szinten is módosulhatnak a programjaink. Egyrészt a processzor (cache) átrendezheti az írásokat/olvasásokat, másrészt a compiler/jitter megteheti ugyanezt. Az is előfordulhat, hogy a compiler a többszörös olvasásokat/írásokat elnyeli. Ezek a program viselkedését, amennyiben egy szál szemszögéből nézzük, nem befolyásolják, több szál esetén azonban problémák forrása lehet. Emiatt, ha egy változón keresztül több szál valamilyen módon kommunikál, akkor ezeket a pontokat jelölni kell. Ekkor a fordítóprogram/processzor viselkedése megváltozik, de ez általában azon a ponton a teljesítmény rovására megy (cache ürítés, a processzor futásának esetleges pillanatnyi felfüggesztése, kevésbé optimalizált kód).

Kawasaki mankókeréken

Sokan egy triciklin kezdtük az utakat szelni. Ez a jármű a tervezésénél fogva nem igényel egyensúlyozást. A gyerekeknek így ki sem alakul ez a készségük. A kétkerekű bicikliknél ez a probléma már jelentkezik. Hogy ebből ne legyen gond, emiatt két támasztókereket szoktak felszerelni. Előbb-utóbb azonban ezt a szülők leszerelik, arra kényszerítve a gyereket, hogy tanulja meg az eszközt rendeltetésszerűen használni.

A programozóknál kicsit hasonló helyzet játszódott le, csak éppen néha túlaggódó szülőkkel.

Miért nem estünk eddig nagyot?

Hogy páran már próbálkoztak többszálú programokkal, és nem találták nyomát a fenti jelenségekből adódó hibáknak? Mi zömmel x86-os processzorokon dolgozunk, mivel ez a legelterjedtebb. Az elterjedtségnek köszönhetően rengeteg program készült ezekre a processzorokra. Vajon mi történt volna, ha az Intel csak úgy ráereszt a világra a fent említett újításokat, mint például az utasítások cserélgetése? Jobb nem elképzelni.

A helyzet, hogy az x86-os processzorok nagyon óvatosan bánnak az utasítások átrendezésével. A processzor kézikönyvéből kiderül, hogy a processzor többnyire úgy viselkedik többszálú és többmagos környezetben is, ahogy azt a programozó józan ésszel egyébként is elképzeli. Az olvasások például nem cserélődnek fel egymással, ahogy az írások sem. A Intel architektúrája még azt is biztosítja, hogy egy másik processzor szemszögéből az írások ugyanolyan sorrendben mennek végre, ahogy azt a megfigyelt processzor elvégezte. Ennek a managelése nyilván teljesítményveszteséggel jár a kevésbé szívbajos processzorokhoz képest, ugyanakkor az Intel a sajátos piaci helyzete miatt kénytelen a triciklin nevelkedett programozók termékeit életben tartani.

Meglepetést azért az x86 is tud okozni, például a fent említett kézikönyv 8.2.3.4 pontjából kiderül, hogy az írás és az olvasás műveletek egymással felcserélhetőek, ha azok más területre vonatkoznak. Erre még később visszatérünk.

Hol a lazaság?

Említettük korábban, hogy minket, .NET fejlesztőket nem kell, hogy érdekeljen a fizikai processzor, hiszen mi egy virtuális környezettel szemben programozunk. Azt is megtudtuk, hogy ez a virtuális környezet az ECMA CLI specifikációja szerint nagyon laza, lényegében átjönnek rajta keresztül a processzor optimalizációi.

Bár a CLI maga nagyon megengedő, az első implementációk a .NET 1.0-1.1 képében a konzisztens x86-os processzorra készültek. A .NET 2.0 azonban már elérhető volt Itanium processzorokon – egy processzoron, amit nem kötöttek kompatibilitási problémák, mint az x86-ot. Emiatt ez a processzor és a köré épített cache nem fogja vissza magát a konzisztencia érdekében. Csakhogy a meglévő .NET programok x86-ra lettek tesztelve, s bár az ECMA CLI megköveteli, hogy ahol több szál dolgozik együtt, azt jelezni kell, az x86 mankókerekei miatt egy .NET 1.0-ban megírt kód akkor is jól működhetett például egy többmagos vagy több x86 processzoros környezetben futtatva, ha a fejlesztő nem tett meg mindent ennek az érdekében.

A fentiek miatt a .NET 2.0 alá a ECMA CLI-nél szigorúbb model-t tettek, hogy az addig elkészült, esetleg hibás programok hibátlanul fussanak Itanium processzorokon is. Ez a memóriamodel nem olyan szigorú, mint amit az x86 biztosít, de azért néhány elfelejtett volatile írás hatását ki tudja javítani. Nem engedi például a memória írásokat felcserélni (a ECMA CLI igen, az x86 nem). Emiatt a korábban bemutatott cache bankos példánál fennálló probléma a .NET 2.0-nál nem fog előjönni – cserében a .NET 2.0 és felette lévő kódok közel sem használják ki például az Itanium lehetőségeit.

Felturbózott lock

A cikk elején láttunk egy példát egy verem megvalósításra, amely működik többszálú környezetben. Ott csak arra figyeltünk, hogy két szál ne fusson egyszerre rá a metódusra, hogy ne írják felül egymás műveleteit. Mostanra azonban már sejtjük, hogy ez egyáltalán nem elég. A lock ugyan megfogja a második szálat, azonban amikor az első végez, és szabaddá válik az út a második szál számára, a fent leírtak alapján előfordulhat, hogy a módosított értékeket a második szál még nem látja, vagy felét igen, és azt is összevissza.

Szerencsére a lock implementációja több mindent biztosít számunkra, mint amennyit eredetileg gondolnánk. Egyrészt megakadályozza a compilert, hogy bizonyos optimalizációkat elvégezzen. Hiába tárazta be a kód egy változó értékét egy regiszterbe, a lock-ot átlépve annak értéke újra lesz olvasva, ha azt a lockon belül használja a kód. A biztonsághoz persze az is kell, hogy a cache is érvénytelenítve legyen a lock-ot átlépve, és ez meg is történik. Hasonló folyamatok mennek végbe a lock-olt részt elhagyva, ekkor az írások mindenképpen megtörténnek a memóriába (nem csak regiszterben lesznek tartva) és a cache szintén érvénytelen lesz. A compiler továbbá nem fogja úgy cserélgetni a műveleteket, hogy azok a lock keretét átlépjék. Ez azonban nem elég, hiszen a processzor is cserélgetheti az utasításokat, a lock miatt azonban olyan gépikódú utasítások generálódnak, amely jelzi a processzornak, hogy ezt nem teheti meg. Összességében tehát nagyon sok dolog történik:

  • compiler optimalizációk visszafogása
  • processzor optimalizációk visszafogása
  • processzor cache kezelés
  • és az eredeti funkció, a szálak belépésének szinkronizációja

Kell nekünk a szinkronizáció?

A .NET programjaink futtatásánál tehát nagyon sok minden véd minket. Általában véd az x86, véd a .NET erős memóriamodellje, véd minket a lock olyan dolgokkal, amire nem is számítottunk. Ezért működhetnek a programjaink felületes tudással is. És pontosan ezért működnek egyre lassabban a lehetségesnél, ahogy a jövőben a magok száma emelkedik. Ez az egyik ok, ami miatt érdemes megtanulni a szinkronizációs fogásokat.

Ráadásul a .NET szigorúbb modelje sem küszöböl ki mindent. Korábban már említettük, hogy azért az x86 sem minden tekintetben konzisztens, például az írás és az olvasás műveletek felcserélődhetnek, ha nem ugyanarra a memóriaterületre szólnak. Ez a hatás a .NET memória modeljén is átmegy. Emiatt egy x86-on is előfordulhat a következő, nagyon furcsa helyzet.

Nézzünk két szálat, amelyek saját maguk akarják megoldani, hogy egy erőforráshoz egyszerre csak egyikük nyúlhasson. Lényegében egy Critical Sectiont valósítanak meg egyedi módon. Ehhez a következő stratégiát használják:

Az erőforrás elérése előtt át kell haladni egy “kapun”. Aki a kapun hamarabb áthaladt, azé lesz az erőforrás. A kapun áthaladás implementálása azonban nem olyan egyszerű. Magas szintű nyelvek esetén egyszerűnek tűnhet, csakhogy azok a műveletek nem atomiak. Az tehát nem megoldás, hogy egy szál megnézzen egy flag-en, hogy beállította-e a másik szál, és ha nem, akkor majd beállítja, mert a két lépés között a másik szál simán állíthat rajta. Viszont ha két szál agresszívan “rárepül” a flag-re, akkor amelyik másodjára éri el a flag-et, annak az értékét tartja meg. Nézzük konkrétabban, úgy érthetőbb:

A fenti ábrán a Thread 1 hamarabb fut rá a gate-re, ezért a Thread 2 azt felülírja. Innen látszik, hogy a Thread 2 volt a lassúbb. A gond az, hogy egymagában a gate változó nem tudja megmondani, hogy megnyertük-e az elérés jogát, vagy nem. Miért? Mert két eset fordulhat elő, és mind a kettő más eredményt ad:

  • Ha a két szál nagyon közel halad egymáshoz a végrehajtásban, akkor a második (vesztes) szál azonosítóját tartalmazza a gate változó. Ebben az esetben egy adott szál onnan tudja, hogy ő az első, ha pont nem a saját azonosítóját látja a gate-ben.
  • Ha a két szál nincs versenyben, akkor annak a szálnak az azonosítóját lehet kiolvasni a gate változóból, amelyik éppen fut. Ekkor tehát pont az ellenkező eredmény a jó, mint korábban.

Arra az információra is szükség van tehát, hogy adott szál versenyzik-e. Ezt könnyen megvalósíthatjuk két új flag bevezetésével, mind a kettő azt mondja meg, hogy az adott szál egyáltalán igényt tart-e az erőforrásra. Nézzük az alábbi ábrát:

Az adott szál egy while ciklus segítségével döntheti el, hogy jogosult-e elérni a védett erőforrást. Itt több lehetőség van (lásd az ábra számozását):

  1. A második szál el sem érte a szinkronizációs részt (lehet, hogy el sem fogja). Ekkor az IsThreadLocking értéke false, tehát a Thread 1 várakozó ciklusa kilép, és futhat tovább a védett rész felé.
  2. A második szál egy picivel késik, itt már jelezni tudja, hogy lockolni fog. Ekkor a Thread1 látja, hogy a Thread 2 játszik, viszont a Thread 2 még nem írta át a gate értékét, emiatt a Thread 1 a ciklusban várakozik egy kicsit, mert azt hiszi, vesztett a Thread 2-vel szemben. Nem sokkal ezután a Thread 2 módisítja a gate-et, ami miatt a Thread 1 kiugrik a ciklusból (már látja, hogy nyert), és eléri a védett részt.
  3. A Thread 1 azonnal látja, hogy a Thread 2 versenyzik, de a gate értéke már Thread 2, tehát a Thread 2 ért oda később. Emiatt a while ciklus azonnal kiugrik, és a vezérlés eléri a védett részt.

Összességében, vagy versenyhelyzet van, és akkor az veszt, aki később írta a gate-et, vagy nincs versenyhelyzet (és akkor mindegy mi van a gate-n). Az algoritmus alapja, hogy versenyhelyzetben az IsThreadNLocking változók íródnak hamarabb, és az x86-os az IsThreadNLocking és a gate írását biztosan nem fogja felcserélni (ellentétben más processzorokkal). Erre tehát nem is kell vigyázni. Viszont az x86 az írások/olvasások sorrendjét egymással felcserélheti, ha nem ugyanarra a memóriaterületre szólnak. Miért jelent ez gondot?

Tegyük fel, hogy a két szál lefutása olyan, mint ami az alábbi ábrán látható:

Tudjuk, hogy az x86 az írásokat és az olvasásokat felcserélheti, ha azok nem ugyanarra a memóriaterületre szólnak. A mi esetünkben a piros nyilak mutatják, hogy a processzor melyik ponton olvassa az értéket, amelyre csak később lesz szükség (nem feltétlen történik átrendezés, de előfordulhat. Tehát a példa nem minden esetben lesz rossz, valójában szinte soha. De lehet, hogy N évben egyszer igen). A Thread 1 elvileg írja az IsThread1Locking területet, majd kicsit később a while ciklus gépikódú megvalósításánál olvas az IsThread2Locking területéről. Mivel más területről van szó, a processzor az olvasást elé viheti az IsThread1Locking írásának. Ugyanez a helyzet a Thread 2-vel is, és így az átmozgatásnak az lesz a következménye, hogy mind a két szálon a while ciklusának első lefutása IsThread1Locking/IsThread2Locking false-szal értékelődik ki (mivel az olvasás pillanatában még nem futottak le az IsThreadNLocking írások), így mind a két szál azt hiszi, hogy ráfuthat a védett részre. Van tehát példa arra, amikor x86-oson, .NET alatt egy kód az átrendezések miatt megborulhat, és ahol explicit jelezni kell, hogy mi ezt el akarjuk kerülni.

Mit kellene tudnunk?

A .NET alatt több szinkronizációs lehetőség áll rendelkezésre. A lock-ot már ismerjük, és megtudtuk, hogy ez többet nyújt annál, mint hogy egyetlen szálat enged rá egyszerre egy programrészre. Felfüggeszt bizonyos compiler optimalizációkat, hatással van a cache-re, a processzorokra. Ami sok zavart okozhat, hogy ezenkívül akadnak más szinkronizációs módszerek, melyek közül mindegyik több mindent csinál. Az egyik kedvencem, az Interlocked osztály függvényei például szintén hatással vannak a cache-re és hatással vannak a compiler-re is. A volatile kulcsszó szintén és a Thread.MemoryBarrier() szintén. Hogy el tudjuk dönteni, mikor melyiket kell használni, ismerni kell a háttérben levő problémákat, azt, hogy ebből adott szinkronizációs eszköz mit old meg, és nem mellékes, hogy milyen módszerrel, milyen teljesítmény vesztést okozva.

Nézzük például a már sokat emlegetett lock-ot. Ez egy Critical Section megvalósításra épül, amely az operációs rendszer Event szinkronizációs objektumait használja, ha a szálak ütköznek. Tegyük fel, hogy van egy egymagos rendszer, két szálat futtatva. Ezt időosztásos módon tudja megtenni, azaz kicsit fut az egyik szál, kicsit a másik. Mondjuk hogy mind a két szál ugyanazt a szinkronizált metódust hívta, kicsi időkülönbséggel. Az első szál ért oda előbb, azonban a lock-kal védett rész közepén az operációs rendszer elvette a száltól a processzoridőt, hogy a másik szál futhasson. Ez a másik szál most ráfut a lock-ra, ami nem szabad, hiszen a korábbi szál kisajátította. A .NET Critical Section implementációja ekkor egy Windows Event szinkronizációs objektumra (tehát nem a .NET Event class-ára, hanem az operációs rendszer objektumára) kezd el várakozni, amit a lockot elhagyó szál triggerel majd. Amikor az operációs rendszer észreveszi, hogy a szál az event objektumra kezdett várakozni, azonnal elveszi a processzor időt tőle. Ez nagyon logikus lépés, hiszen ez a szál már úgysem tud mit csinálni, így csak pazarolná a processzoridőt várakozással. Ha a maradék időt az lock-olt részen félbeszakított szál kapja meg, az hamarabb elhagyja a védett részt, és mindenki jól jár.

Ez így szép is volt sok-sok évig, amíg el nem kezdtek terjedni a többmagos gépek. Most a két szál párhuzamosan tud futni, emiatt csak azért nem kellene a lock-on fennakadt szálnak lemondani az idejéről, hogy a másik szál hamarabb végezzen – azzal tud foglalkozni a másik mag. Mit kell itt megfontolni? Ha az egyik mag által futtatott szál megakad egy szinkronizációs ponton, akkor ezzel erőforrásokat pazarlunk, hiszen ekkor a mag nem számol semmit. Emiatt úgy tűnik, megéri egy másik szálra kapcsolni. A szálak váltása viszont egy magnak (illetve az operációs rendszernek) rengeteg idejébe kerül. Így a helyzetet két részre lehet bontani.

Ha a lockolt rész csak néhány (néhány száz) utasításból áll, esetleg megéri nem váltani a második, éppen várakozó szálnak. Mire egy új szálat beindítana, addigra lehet, hogy az első mag már áthajtotta a lockolt részen az első szálat. Ekkor gazdaságosabb kidobni várakozásra azt a kevés időt.

Ha viszont a lockolt rész nagyon sokáig tart, ekkor már megéri a szálak váltogatása. Honnan tudja az operációs rendszer, hogy mikor érdemes szálat váltani, és mikor várakoztatni a meglévőt? Sehonnan nem tudja. Ezt csak a programozó tudja, aki a szinkronizált részt megírta. A ConcurrentStack programozói például tudták, hogy az adatszerkezetükön olyan rövid műveletek vannak, amit ütközés esetén érdemes a másik szálnak kivárni. Emiatt ők egy erre a célra tervezett lockolási mechanizmust használnak. Ez magyarázza a kétszeres sebességet a hagyományos lockhoz képest.

Konklúzió

A szinkronizációnak sok indoka lehet. Ezekkel eddig nem nagyon kellett foglalkoznunk, mert burokban tartott minket az Intel és a Microsoft. A hardverek azonban változnak, és a burokban élésnek egyre nagyobb ára van. Ma egy .NET-ben fejlesztett program nem sokkal lassabb a natív párjánál, sőt bizonyos esetekben akár gyorsabb is lehet. A többmagos rendszerek terjedésével, illetve más, a konzisztenciát nem alapként adó processzorok megjelenésével (ez nem spekuláció, Windows 8 + ARM) a .NET programjaink idővel egyre nagyobb hátrányban lesznek, ha csak nem tanuljuk meg kiaknázni az új architektúrák lehetőségeit.

A következő néhány cikkben a fentiek miatt végignézzük a fontosabb szinkronizációs lehetőségeket.

  1. #1 by Attila Zoltan Kovacs on 2011. December 10. - 01:33

    Csak egy apróság: a winapi szintjén a critical section nem handle, hanem egy adatszerkezet (ebben eltér az összes többi szinkronizációs objektumtól)
    http://msdn.microsoft.com/en-us/library/windows/desktop/ms682530%28v=vs.85%29.aspx.

    • #2 by Tóth Viktor on 2011. December 10. - 08:24

      Tényleg, igazad van! Ahogy most utána néztem, a WinAPI ugyanúgy valósítja meg a critical section-t, ahogy a .NET: az általad említett struktúrában van egy flag, amit a szál, ha a flag nullán van, megpróbál átbillenteni (megszerezni a lockot), ha pedig nem sikerül, akkor elkezd várakozni a struktúrában levő Event objektumra, amit majd a lockot birtokló szál triggerel. Ezt a struktúrát pedig a programozónak kell allokálni, nem pedig a WinAPI adja.
      Köszi!

  2. #3 by Attila Zoltan Kovacs on 2011. December 10. - 11:49

    Még egy: a “TryPop(out result);” nem az igazi, mivel nem garantálja, hogy elvégzi a Pop műveletet. Helyette pl. “while (!concurrentStack.TryPop(out result)) ;” kéne.

    • #4 by Attila Zoltan Kovacs on 2011. December 10. - 11:56

      Mondjuk ez itt nem okoz gondot, mivel a végrehajtási sorrend miatt mindig lesz benne elég elem.

  3. #5 by Attila Zoltan Kovacs on 2011. December 10. - 18:41

    Illetve még egy. A lock(obj)-ből készülő kód nem igazán helyes. Ha ilyen lenne, akkor akkor is meghívhatná a Monitor.Exit-et, ha nem is sikerült a Monitor.Enter. A helyes kód:

    void Push(T newItem)
    {
    bool lockTaken = false;
    try
    {
    Monitor.Enter(sync, ref lockTaken);

    items[top] = newItem;
    top++;
    }
    finally
    {
    if (lockTaken)
    {
    Monitor.Exit(sync);
    }
    }
    }

    Nézd meg a generált il kódot, ennek majdnem ugyanaz, mint a lock(sync)-nek.

    • #6 by Tóth Viktor on 2011. December 10. - 20:25

      igazad van, valóban bevezet a compiler egy lockTaken változót, és az általad megadott overload-ot használja:

      IL_000c: ldloca.s ‘s__LockTaken0’
      IL_000e: call void [mscorlib]System.Threading.Monitor::Enter(object, bool&)

  4. #7 by Tóth Viktor on 2011. December 11. - 08:40

    Kicsit aggódva ébredtem, de úgy látom akkor ennyi🙂 Ha mégis akad, bármi észrevételt/javaslatot/javítást szívesen veszek, az eddigieket pedig köszönöm.

  5. #8 by version on 2011. December 13. - 21:36

    sajnos csak elmeletben kozeliti meg a c+-t a managed code, a valosagban nagyon elmarad, elmeletben is csak 8 mag folott kozeliteni meg, mivel csak ennyi magszamnal kerul az Amdahl’s Law es a JIT es garbage col. egyensulyba
    csak ott a problema a 8 mag nem lesz altalanos mostanaban , lasd intel fejlesztesi , es a microsoft sem kepes az elmeletet rendesen atultetni a gyakorlatba

    nem hiaba hirdette meg a microsoft a c++ reneszanszat 2020-ig,

    a szinkronizacio iszonyat sokba kerul tobb threaden, 2-4 magnal meg se eri szinte az egesz , plane ha akoltsegeket isfigyelembevesszuk,ezalolkiveteltermeszetesena gpu altal gyorsitott kod, ott 256 -1000 magokkal jatszadozhatunk

  1. A jitter és az inlining - pro C# tology - devPortal

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: