Jól táplált filozófusok vagy versenyképes programozás a .NET-ben

Jól táplált filozófusok vagy versenyképes programozás a .NET-ben

Nézzük meg, hogyan működik a párhuzamos és párhuzamos programozás a .Neten, az ebédlő filozófusok probléma példáján. A terv a következő, a szál/folyamat szinkronizálástól a szereplőmodellig (a következő részekben). A cikk hasznos lehet az első ismerkedéshez vagy az ismereteinek felfrissítéséhez.

Miért kell egyáltalán tudni, hogyan kell ezt csinálni? A tranzisztorok elérik minimális méretüket, a Moore-törvény eléri a fénysebesség határát, ezért számbeli növekedés figyelhető meg, több tranzisztor készülhet. Ezzel párhuzamosan nő az adatok mennyisége, és a felhasználók azonnali választ várnak a rendszerektől. Ilyen helyzetben a „normál” programozás, ha egy végrehajtó szálunk van, már nem hatékony. Valahogy meg kell oldanunk az egyidejű vagy párhuzamos végrehajtás problémáját. Sőt, ez a probléma különböző szinteken létezik: szál szinten, folyamat szinten, a hálózaton lévő gépek szintjén (elosztott rendszerek). A .NET kiváló minőségű, jól bevált technológiával rendelkezik az ilyen problémák gyors és hatékony megoldására.

Feladat

Edsger Dijkstra már 1965-ben feltette ezt a problémát tanítványainak. A kialakult megfogalmazás a következő. Van egy bizonyos (általában öt) filozófus és ugyanennyi villa. Egy kerek asztalnál ülnek, villák vannak közöttük. A filozófusok ehetnek a végtelen ételt tartalmazó tányérjaikról, gondolkodhatnak vagy várhatnak. Egy filozófusnak két villát kell vennie az evéshez (az utóbbi osztozik egy villán az előbbivel). A villa felemelése és letétele két külön művelet. Minden filozófus hallgat. A feladat egy olyan algoritmus megtalálása, hogy 54 év után is mindannyian gondolkodjanak és jóllakjanak.

Először próbáljuk meg megoldani ezt a problémát megosztott tér használatával. A villák a közös asztalon hevernek, a filozófusok pedig egyszerűen elveszik és visszateszik őket, amikor ott vannak. Itt merülnek fel a szinkronizálási problémák, pontosan mikor kell villát venni? mi a teendő, ha nincs csatlakozó? stb. De először kezdjük a filozófusokkal.

A szálak indításához a via szálkészletet használjuk Task.Run módszer:

var cancelTokenSource = new CancellationTokenSource();
Action<int> create = (i) => RunPhilosopher(i, cancelTokenSource.Token);
for (int i = 0; i < philosophersAmount; i++) 
{
    int icopy = i;
    // Поместить задачу в очередь пула потоков. Метод RunDeadlock не запускаеться 
    // сразу, а ждет своего потока. Асинхронный запуск.
    philosophers[i] = Task.Run(() => create(icopy), cancelTokenSource.Token);
}

A szálkészletet úgy tervezték, hogy optimalizálja a szálak létrehozását és eltávolítását. Ebben a készletben feladatok sora található, és a CLR a feladatok számától függően szálakat hoz létre vagy töröl. Egy pool az összes AppDomain számára. Ezt a medencét szinte mindig érdemes használni, mert... nem kell foglalkoznia a szálak létrehozásával és törlésével, azok soraival stb. Megteheti készlet nélkül is, de akkor közvetlenül kell használnia Thread, ez olyan esetekben hasznos, amikor egy szál prioritását kell módosítanunk, ha hosszú műveletünk van, egy Foreground szálhoz stb.

Más szavakkal, System.Threading.Tasks.Task osztály ugyanaz Thread, de mindenféle kényelemmel: lehetőség a feladat elindítására más feladatok blokkja után, visszaadni a függvényekből, kényelmesen megszakítani őket és még sok más. stb. Az aszinkron/várakozó konstrukciók támogatásához szükségesek (Task-based Asynchronous Pattern, szintaktikai cukor az IO-műveletek várakozásához). Erről később beszélünk.

CancelationTokenSource itt szükséges, hogy a szál a hívó száltól érkező jelre befejezze magát.

Szinkronizálási problémák

Blokkolt filozófusok

Oké, tudjuk, hogyan kell szálakat létrehozni, próbáljuk ki az ebédet:

// Кто какие вилки взял. К примеру: 1 1 3 3 - 1й и 3й взяли первые две пары.
private int[] forks = Enumerable.Repeat(0, philosophersAmount).ToArray();

// То же, что RunPhilosopher()
private void RunDeadlock(int i, CancellationToken token) 
{
    // Ждать вилку, взять её. Эквивалентно: 
    // while(true) 
    //     if forks[fork] == 0 
    //          forks[fork] = i+1
    //          break
    //     Thread.Sleep() или Yield() или SpinWait()
    void TakeFork(int fork) =>
        SpinWait.SpinUntil(() => 
            Interlocked.CompareExchange(ref forks[fork], i+1, 0) == 0);

    // Для простоты, но можно с Interlocked.Exchange:
    void PutFork(int fork) => forks[fork] = 0;

    while (true)
    {
        TakeFork(Left(i));
        TakeFork(Right(i));
        eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
        PutFork(Left(i));
        PutFork(Right(i));
        Think(i);

        // Завершить работу по-хорошему.
        token.ThrowIfCancellationRequested();
    }
}

Itt először megpróbáljuk elvenni a bal, majd a jobb villát, és ha sikerül, megesszük és visszatesszük. Egy villa elvétele atomi, i.e. két szál egyszerre nem vehet fel egyet (rossz: az első azt írja, hogy a villa szabad, a második ugyanezt teszi, az első veszi, a második veszi). Ezért Interlocked.CompareExchange, amelyet egy processzor utasítás segítségével kell megvalósítani (TSL, XCHG), amely egy memóriadarabot zárol az atomi szekvenciális olvasáshoz és íráshoz. A SpinWait pedig egyenértékű a felépítéssel while(true) csak egy kis „varázslattal” - a szál felveszi a processzort (Thread.SpinWait), de néha átadja az irányítást egy másik szálnak (Thread.Yeild) vagy elalszik (Thread.Sleep).

De ez a megoldás nem működik, mert... a szálak hamarosan (nálam egy másodpercen belül) elakadnak: minden filozófus veszi a bal villát, de nincs jobb. Ekkor a forks tömb értékei: 1 2 3 4 5.

Jól táplált filozófusok vagy versenyképes programozás a .NET-ben

A képen blokkoló szálak (patthelyzet). A zöld a végrehajtást, a piros a szinkronizálást, a szürke pedig azt, hogy a szál alszik. A gyémántok jelzik a Tasks indításának idejét.

A filozófusok éhsége

Bár nem kell sok étel a gondolkodáshoz, az éhség bárkit a filozófia feladására kényszeríthet. Próbáljuk szimulálni a szál éhezés helyzetét a problémánkban. Az éhezés az, amikor egy cérna működik, de jelentősebb munka nélkül, vagyis ugyanaz a holtpont, csak most a szál nem alszik, hanem aktívan keres ennivalót, de nincs ennivaló. A gyakori elakadások elkerülése érdekében a villát visszatesszük, ha nem tudtunk másikat venni.

// То же что и в RunDeadlock, но теперь кладем вилку назад и добавляем плохих философов.
private void RunStarvation(int i, CancellationToken token)
{
    while (true)
    {
        bool hasTwoForks = false;
        var waitTime = TimeSpan.FromMilliseconds(50);
        // Плохой философов может уже иметь вилку:
        bool hasLeft = forks[Left(i)] == i + 1;
        if (hasLeft || TakeFork(Left(i), i + 1, waitTime))
        {
            if (TakeFork(Right(i), i + 1, TimeSpan.Zero))
                hasTwoForks = true;
            else
                PutFork(Left(i)); // Иногда плохой философ отдает вилку назад.
        } 
        if (!hasTwoForks)
        {
            if (token.IsCancellationRequested) break;
            continue;
        }
        eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
        bool goodPhilosopher = i % 2 == 0;
        // А плохой философ забывает положить свою вилку обратно:
        if (goodPhilosopher)
            PutFork(Left(i));
        // А если и правую не положит, то хорошие будут вообще без еды.
        PutFork(Right(i));

        Think(i);

        if (token.IsCancellationRequested)
            break;
    }
}

// Теперь можно ждать определенное время.
bool TakeFork(int fork, int philosopher, TimeSpan? waitTime = null)
{
    return SpinWait.SpinUntil(
        () => Interlocked.CompareExchange(ref forks[fork], philosopher, 0) == 0,
              waitTime ?? TimeSpan.FromMilliseconds(-1)
    );
}

Ebben a kódban az a fontos, hogy négy filozófusból kettő elfelejti letenni a bal villát. És kiderül, hogy több ételt esznek, mások pedig éhezni kezdenek, bár a szálaknak ugyanaz a prioritása. Itt nem éheznek teljesen, mert... a rossz filozófusok néha visszateszik a villát. Kiderült, hogy a jók körülbelül ötször kevesebbet esznek, mint a rosszak. Tehát egy kis hiba a kódban a teljesítmény csökkenéséhez vezet. Itt érdemes megjegyezni azt is, hogy előfordulhat ritka helyzet, amikor minden filozófus a bal villát veszi, nincs jobb, leteszi a balost, várjon, megint balra, stb. Ez a helyzet is éhezés, inkább kölcsönös elzáródás. Nem tudtam megismételni. Az alábbiakban egy kép egy olyan helyzetről, amikor két rossz filozófus mindkét villát elvette, két jó pedig éhezik.

Jól táplált filozófusok vagy versenyképes programozás a .NET-ben

Itt látható, hogy a szálak néha felébrednek, és megpróbálnak forrást szerezni. Négy magból kettő nem csinál semmit (a fenti zöld grafikon).

Egy filozófus halála

Nos, még egy probléma, ami megszakíthatja a filozófusok dicsőséges vacsoráját, ha valamelyikük villával a kezében hirtelen meghal (és úgy temetik el). Akkor a szomszédok ebéd nélkül maradnak. Erre az esetre magad is kitalálhatsz egy példakódot, például kidobják NullReferenceException miután a filozófus veszi a villákat. És mellesleg a kivételt nem kezelik, és a hívókód nem fogja egyszerűen elkapni (ehhez AppDomain.CurrentDomain.UnhandledException satöbbi.). Ezért szükség van hibakezelőkre magukban a szálakban és kecses befejezéssel.

pincér

Oké, hogyan oldjuk meg a holtpontok, az éhezés és a halálozás problémáját? Csak egy filozófust engedünk az elágazáshoz, és ehhez a helyhez a szálak kölcsönös kizárását adjuk. Hogyan kell csinálni? Tegyük fel, hogy a filozófusok mellett van egy pincér, aki engedélyt ad egy filozófusnak a villák átvételére. Érdekes kérdések, hogyan készítsük el ezt a pincért, és hogyan teszik fel neki a filozófusok.

A legegyszerűbb módja az, hogy a filozófusok egyszerűen folyamatosan kérik a pincértől, hogy hozzáférjenek a villákhoz. Azok. Most a filozófusok nem várnak egy villára a közelben, hanem megvárják vagy megkérdezik a pincért. Eleinte csak a User Space-t használjuk erre, ebben nem használunk megszakításokat egyetlen eljárás meghívására sem a kernelből (erről lentebb olvashat).

Felhasználói tér megoldások

Itt ugyanazt fogjuk csinálni, mint korábban egy villával és két filozófussal, körben forogunk és várunk. De most már mind filozófusok lesznek, és mintha csak egy villa, i.e. azt mondhatjuk, hogy csak az a filozófus eszik, aki elvette a pincértől ezt az „aranyvillát”. Ehhez a SpinLockot használjuk.

private static SpinLock spinLock = new SpinLock();  // Наш "официант"
private void RunSpinLock(int i, CancellationToken token)
{
    while (true)
    {
        // Взаимная блокировка через busy waiting. Вызываем до try, чтобы
        // выбрасить исключение в случае ошибки в самом SpinLock.
        bool hasLock = false;
        spinLock.Enter(ref hasLock);
        try
        {
            // Здесь может быть только один поток (mutual exclusion).
            forks[Left(i)] = i + 1;  // Берем вилку сразу, без ожидания.
            forks[Right(i)] = i + 1;
            eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
            forks[Left(i)] = 0;
            forks[Right(i)] = 0;
        }
        finally
        {
            if(hasLock) spinLock.Exit();  // Избегаем проблемы со смертью философа.
        }

        Think(i);

        if (token.IsCancellationRequested)
            break;
    }
}

SpinLock ez egy blokkoló, nagyjából ugyanazzal while(true) { if (!lock) break; }, de még több „varázslattal”, mint benn SpinWait (amit ott használnak). Most már tudja, hogyan kell megszámolni a várakozókat, elaltatni őket egy kicsit, és még sok minden más. stb. Általában mindent megtesz az optimalizálás érdekében. De emlékeznünk kell arra, hogy ez még mindig ugyanaz az aktív hurok, amely felemészti a processzor erőforrásait és egy szálat tart, ami éhezéshez vezethet, ha az egyik filozófus prioritást élvez a többinél, de nincs arany villája (Priority Inversion probléma ). Ezért csak a megosztott memória nagyon rövid módosításaira használjuk, harmadik féltől származó hívások, beágyazott zárolások vagy egyéb meglepetések nélkül.

Jól táplált filozófusok vagy versenyképes programozás a .NET-ben

Rajz a számára SpinLock. A patakok folyamatosan „harcolnak” az aranyvilláért. Hibák fordulnak elő - a kiemelt terület az ábrán. A magok nincsenek teljesen kihasználva: ennek a négy szálnak csak körülbelül a 2/3-a.

Egy másik megoldás itt az lenne, ha csak használja Interlocked.CompareExchange ugyanazzal az aktív várakozással, mint a fenti kódban (éhező filozófusoknál), de ez, mint már említettük, elméletileg blokkoláshoz vezethet.

Про Interlocked érdemes elmondani, hogy nem csak CompareExchange, hanem más atomi olvasási ÉS írási módszerek is. És a változtatás megismétlésével, ha egy másik szálnak sikerül végrehajtania a változtatásait (1-es olvasás, 2-es írás, 2-es írás, 1-es írás rossz), akkor egy érték komplex módosítására használható (Interlocked Anything minta).

Kernel módú megoldások

Az erőforrások hurokban való pazarlásának elkerülése érdekében nézzük meg, hogyan blokkolhatunk egy szálat. Vagyis folytatva példánkat, nézzük meg, hogyan altatja el a pincér a filozófust, és csak szükség esetén ébreszti fel. Először nézzük meg, hogyan kell ezt megtenni az operációs rendszer kernel módján keresztül. Az ott található összes struktúra gyakran lassabb, mint a felhasználói térben lévők. Például többször lassabban AutoResetEvent talán 53-szor lassabb SpinLock [Richter]. Segítségükkel azonban szinkronizálhatja a folyamatokat a teljes rendszerben, függetlenül attól, hogy felügyelt-e vagy sem.

Az alapterv itt egy szemafor, amelyet Dijkstra javasolt több mint fél évszázaddal ezelőtt. A szemafor leegyszerűsítve egy pozitív egész szám, amelyet a rendszer szabályoz, és két műveletet hajt végre rajta: növelés és csökkentés. Ha nem lehetséges a nulla csökkentése, akkor a hívó szál blokkolva van. Ha a számot egy másik aktív szál/folyamat növeli, akkor a szálak átadásra kerülnek, és a szemafor ismét csökken az átadott számmal. Elképzelheti a vonatokat egy szűk keresztmetszetben egy szemaforral. A .NET számos hasonló funkciójú konstrukciót kínál: AutoResetEvent, ManualResetEvent, Mutex és magam Semaphore. Használni fogjuk AutoResetEvent, ez a legegyszerűbb ezek közül a konstrukciók közül: csak két érték 0 és 1 (hamis, igaz). A módszere WaitOne() blokkolja a hívó szálat, ha az érték 0 volt, és ha 1, akkor 0-ra csökkenti és kihagyja. Egy metódus Set() 1-re nő, és egy személyt enged át, aki ismét 0-ra csökken. Úgy működik, mint egy forgókapu a metróban.

Bonyolítsuk le a megoldást, és használjunk blokkolást minden filozófusnál, és ne egyszerre. Azok. Most több filozófus is ehet egyszerre, és nem csak egy. De ismét blokkoljuk az asztalhoz való hozzáférést, hogy helyesen vegyük fel a villát, elkerülve a versenykörülményeket.

// Для блокирования отдельного философа.
// Инициализируется: new AutoResetEvent(true) для каждого.
private AutoResetEvent[] philosopherEvents;

// Для доступа к вилкам / доступ к столу.
private AutoResetEvent tableEvent = new AutoResetEvent(true);

// Рождение философа.
public void Run(int i, CancellationToken token)
{
    while (true)
    {
        TakeForks(i); // Ждет вилки.
        // Обед. Может быть и дольше.
        eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
        PutForks(i); // Отдать вилки и разблокировать соседей.
        Think(i);
        if (token.IsCancellationRequested) break;
    }
}

// Ожидать вилки в блокировке.
void TakeForks(int i)
{
    bool hasForks = false;
    while (!hasForks) // Попробовать еще раз (блокировка не здесь).
    {
        // Исключающий доступ к столу, без гонок за вилками.
        tableEvent.WaitOne();
        if (forks[Left(i)] == 0 && forks[Right(i)] == 0)
            forks[Left(i)] = forks[Right(i)] = i + 1;
        hasForks = forks[Left(i)] == i + 1 && forks[Right(i)] == i + 1;
        if (hasForks)
            // Теперь философ поест, выйдет из цикла. Если Set 
            // вызван дважды, то значение true.
            philosopherEvents[i].Set();
        // Разблокировать одного ожидающего. После него значение tableEvent в false.
        tableEvent.Set(); 
        // Если имеет true, не блокируется, а если false, то будет ждать Set от соседа.
        philosopherEvents[i].WaitOne();
    }
}

// Отдать вилки и разблокировать соседей.
void PutForks(int i)
{
    tableEvent.WaitOne(); // Без гонок за вилками.
    forks[Left(i)] = 0;
    // Пробудить левого, а потом и правого соседа, либо AutoResetEvent в true.
    philosopherEvents[LeftPhilosopher(i)].Set();
    forks[Right(i)] = 0;
    philosopherEvents[RightPhilosopher(i)].Set();
    tableEvent.Set();
}

Hogy megértsük, mi történik itt, vegyük figyelembe azt az esetet, amikor a filozófusnak nem sikerült elvennie a villát, akkor cselekedetei a következők lesznek. Arra vár, hogy hozzáférjen az asztalhoz. Miután megkapta, megpróbálja elvenni a villákat. Nem sikerült. Hozzáférést biztosít az asztalhoz (kölcsönös kizárás). És átadja a „forgókapuját” (AutoResetEvent) (eleinte nyitva vannak). Megint beleesik a körforgásba, mert nincsenek villái. Megpróbálja elvinni őket, és megáll a „forgókapujánál”. Valamelyik szerencsésebb szomszéd jobbra vagy balra, miután befejezte az evést, úgy oldja meg filozófusunkat, hogy „kinyitja a forgókapuját”. Filozófusunk másodszor is átmegy rajta (és bezárul mögötte). Harmadszor próbálja elvenni a villákat. Sikeres. És átmegy a forgókapuján ebédelni.

Ha véletlenszerű hibák vannak az ilyen kódban (mindig léteznek), például egy szomszéd helytelenül lesz megadva, vagy ugyanaz az objektum jön létre AutoResetEvent mindenkinek (Enumerable.Repeat), akkor a filozófusok megvárják a fejlesztőket, mert A hibák keresése az ilyen kódokban meglehetősen nehéz feladat. Egy másik probléma ezzel a megoldással, hogy nem garantálja, hogy egyes filozófusok nem halnak éhen.

Hibrid megoldások

A szinkronizálás két megközelítését vizsgáltuk, amikor felhasználói módban maradunk és ciklusban pörögünk, és amikor blokkoljuk a szálat a kernelen keresztül. Az első módszer a rövid, a második a hosszú blokkokhoz jó. Gyakran először rövid ideig kell várnia, amíg egy változó megváltozik a ciklusban, majd blokkolja a szálat, ha a várakozás hosszú. Ezt a megközelítést az ún. hibrid kivitelek. Ugyanazok a konstrukciók, mint a kernel módban, de most egy felhasználói módú ciklussal: SemaphorSlim, ManualResetEventSlim stb. A legnépszerűbb design itt az Monitor, mert a C#-ban van egy jól ismert lock szintaxis. Monitor ez ugyanaz a szemafor, amelynek maximális értéke 1 (mutex), de támogatja a ciklusban való várakozást, a rekurziót, a feltételváltozó mintát (erről bővebben lentebb), stb. Nézzük meg a megoldást.

// Спрячем объект для Монитора от всех, чтобы без дедлоков.
private readonly object _lock = new object();
// Время ожидания потока.
private DateTime?[] _waitTimes = new DateTime?[philosophersAmount];

public void Run(int i, CancellationToken token)
{
    while (true)
    {
        TakeForks(i);
        eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
        PutForks(i);
        Think(i);
        if (token.IsCancellationRequested) break;
    }
}

// Наше сложное условие для Condition Variable паттерна.
bool CanIEat(int i)
{
    // Если есть вилки:
    if (forks[Left(i)] != 0 && forks[Right(i)] != 0)
        return false;
    var now = DateTime.Now;
    // Может, если соседи не более голодные, чем текущий.
    foreach(var p in new int[] {LeftPhilosopher(i), RightPhilosopher(i)})
        if (_waitTimes[p] != null && now - _waitTimes[p] > now - _waitTimes[i])
            return false;
    return true;
}

void TakeForks(int i)
{
    // Зайти в Монитор. То же самое: lock(_lock) {..}.
    // Вызываем вне try, чтобы возможное исключение выбрасывалось выше.
    bool lockTaken = false;
    Monitor.Enter(_lock, ref lockTaken);
    try
    {
        _waitTimes[i] = DateTime.Now;
        // Condition Variable паттерн. Освобождаем лок, если не выполненно 
        // сложное условие. И ждем пока кто-нибудь сделает Pulse / PulseAll.
        while (!CanIEat(i))
            Monitor.Wait(_lock); 
        forks[Left(i)] = i + 1;
        forks[Right(i)] = i + 1;
        _waitTimes[i] = null;
    }
    finally
    {
        if (lockTaken) Monitor.Exit(_lock);
    }
}

void PutForks(int i)
{
    // То же самое: lock (_lock) {..}.
    bool lockTaken = false;
    Monitor.Enter(_lock, ref lockTaken);
    try
    {
        forks[Left(i)] = 0;
        forks[Right(i)] = 0;
        // Освободить все потоки в очереди ПОСЛЕ вызова Monitor.Exit.
        Monitor.PulseAll(_lock); 
    }
    finally
    {
        if (lockTaken) Monitor.Exit(_lock);
    }
}

Itt ismét blokkoljuk az egész asztalt, hogy hozzáférjen a villákhoz, de most az összes szálat egyszerre oldjuk fel, nem pedig a szomszédokat, amikor valaki befejezi az evést. Azok. először valaki megeszi és blokkolja a szomszédokat, és amikor ez a valaki befejezi, de azonnal újra enni akar, bemegy a háztömbbe és felébreszti a szomszédokat, mert kevesebb a várakozási ideje.

Így elkerüljük a holtpontokat és néhány filozófus éhezését. Egy hurkot használunk, hogy rövid ideig várjunk, és hosszú ideig blokkoljuk a szálat. Mindenki egyszerre feloldása lassabb, mintha csak a szomszédot oldanák fel, mint a megoldásban AutoResetEvent, de a különbség ne legyen nagy, mert a szálaknak először felhasználói módban kell maradniuk.

У lock szintaxist tartogat néhány kellemetlen meglepetés. Használata javasolt Monitor közvetlenül [Richter] [Eric Lippert]. Az egyik az lock mindig kijön Monitor, még akkor is, ha volt kivétel, és akkor egy másik szál megváltoztathatja a megosztott memória állapotát. Ilyen esetekben gyakran jobb, ha holtpontra jutunk, vagy valahogy biztonságosan leállítjuk a programot. Egy másik meglepetés, hogy a Monitor órablokkokat használ (SyncBlock), amelyek minden objektumban jelen vannak. Ezért, ha nem megfelelő objektumot választ ki, könnyen patthelyzetbe kerülhet (például ha egy beépített karakterláncot zárol). Ehhez mindig egy rejtett tárgyat használunk.

A Condition Variable minta lehetővé teszi, hogy tömörebben megvalósítsa valamilyen összetett feltétel elvárását. .NET-ben szerintem hiányos, mert... Elméletileg több sornak kell lennie több változón (mint a Posix Threads esetében), és nem egy záron. Akkor minden filozófus számára elkészíthető lenne. De még ebben a formában is lehetővé teszi a kód lerövidítését.

Sok filozófus ill async / await

Oké, most hatékonyan blokkolhatjuk a szálakat. De mi van akkor, ha sok filozófusunk van? 100? 10000? Például 100000 4 kérést kaptunk a webszerverhez. Egy szál létrehozása minden kéréshez drága lesz, mert ennyi szál nem fog párhuzamosan végrehajtani. Csak annyi logikai mag kerül végrehajtásra (nekem XNUMX van). A többiek pedig egyszerűen elveszik az erőforrásokat. A probléma egyik megoldása az aszinkron / várakozás minta. Az ötlet az, hogy egy függvény nem tart fenn szálat, ha várnia kell, amíg valami folytatódik. És ha valami történik, akkor folytatja a végrehajtását (de nem feltétlenül ugyanabban a szálban!). A mi esetünkben egy villára várunk.

SemaphoreSlim rendelkezik ehhez WaitAsync() módszer. Íme egy megvalósítás, amely ezt a mintát használja.

// Запуск такой же, как раньше. Где-нибудь в программе:
Task.Run(() => Run(i, cancelTokenSource.Token));

// Запуск философа.
// Ключевое слово async -- компилятор транслирует этот метот в асинхронный.
public async Task Run(int i, CancellationToken token)
{
    while (true)
    {
        // await -- будем ожидать какого-то события.
        await TakeForks(i);
        // После await, продолжение возможно в другом потоке.
        eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
        // Может быть несколько событий для ожидания.
        await PutForks(i);

        Think(i);

        if (token.IsCancellationRequested) break;
    }
}

async Task TakeForks(int i)
{
    bool hasForks = false;
    while (!hasForks)
    {
        // Взаимоисключающий доступ к столу:
        await _tableSemaphore.WaitAsync();
        if (forks[Left(i)] == 0 && forks[Right(i)] == 0)
        {
            forks[Left(i)] = i+1;
            forks[Right(i)] = i+1;
            hasForks = true;
        }
        _tableSemaphore.Release();
        // Будем ожидать, чтобы сосед положил вилки:
        if (!hasForks)
            await _philosopherSemaphores[i].WaitAsync();
    }
}

// Ждем доступа к столу и кладем вилки.
async Task PutForks(int i)
{
    await _tableSemaphore.WaitAsync();
    forks[Left(i)] = 0;
    // "Пробудить" соседей, если они "спали".
    _philosopherSemaphores[LeftPhilosopher(i)].Release();
    forks[Right(i)] = 0;
    _philosopherSemaphores[RightPhilosopher(i)].Release();
    _tableSemaphore.Release();
}

Módszer a async / await egy ravasz véges állapotú géppé alakul, amely azonnal visszaadja belsőjét Task. Ezen keresztül megvárhatja a metódus befejezését, törölheti azt, és minden mást, amit a Feladattal megtehet. A metóduson belül egy állapotgép vezérli a végrehajtást. A lényeg az, hogy ha nincs késleltetés, akkor a végrehajtás szinkron, ha van, akkor a szál felszabadul. Ennek jobb megértéséhez jobb, ha megnézzük ezt az állapotgépet. Ezekből láncokat hozhat létre async / await mód.

Teszteljük. 100 filozófus munkája egy gépen 4 logikai maggal, 8 másodperc. A korábbi, Monitor-os megoldás csak az első 4 szálat hajtotta végre, a többit pedig egyáltalán nem. Mind a 4 szál tétlen volt körülbelül 2 ms-ig. Az aszinkron/várakozás megoldás pedig mind a 100-at, átlagosan 6.8 másodpercet várt. Természetesen valós rendszerekben elfogadhatatlan a 6 másodperces tétlenség, és jobb, ha nem dolgozunk fel ennyi kérést így. A Monitorral készült megoldásról kiderült, hogy egyáltalán nem skálázható.

Következtetés

Amint ezekből a kis példákból látható, a .NET számos szinkronizálási konstrukciót támogat. Azonban nem mindig egyértelmű, hogyan kell használni őket. Remélem, hogy ez a cikk hasznos volt. Egyelőre lezárjuk ezt, de még sok érdekesség maradt hátra, például a szálbiztos gyűjtemények, a TPL adatfolyam, a reaktív programozás, a szoftvertranzakciós modell stb.

forrás

Forrás: will.com

Hozzászólás