Dobre živení filozofi alebo konkurencieschopné programovanie .NET

Dobre živení filozofi alebo konkurencieschopné programovanie .NET

Pozrime sa, ako funguje súbežné a paralelné programovanie v .Net, na príklade problému obedujúcich filozofov. Plán je nasledujúci, od synchronizácie vlákna/procesu po model aktéra (v nasledujúcich častiach). Článok môže byť užitočný na prvé zoznámenie alebo na osvieženie vedomostí.

Prečo vôbec vedieť, ako to urobiť? Tranzistory dosahujú svoju minimálnu veľkosť, Mooreov zákon naráža na hranicu rýchlosti svetla, a preto je pozorovaný rast v číslach, je možné vyrobiť viac tranzistorov. Zároveň rastie množstvo dát a používatelia očakávajú okamžitú odozvu systémov. V takejto situácii už nie je „normálne“ programovanie, keď máme jedno spustené vlákno, efektívne. Musíme nejako vyriešiť problém simultánneho alebo súbežného vykonávania. Navyše tento problém existuje na rôznych úrovniach: na úrovni vlákna, na úrovni procesu, na úrovni strojov v sieti (distribuované systémy). .NET disponuje kvalitnými, rokmi overenými technológiami na rýchle a efektívne riešenie takýchto problémov.

Úloha

Edsger Dijkstra položil tento problém svojim študentom už v roku 1965. Stanovená formulácia je nasledovná. Existuje určitý počet (zvyčajne päť) filozofov a rovnaký počet vidličiek. Sedia za okrúhlym stolom, medzi sebou vidličky. Filozofi môžu jesť zo svojich tanierov nekonečné množstvo jedla, premýšľať alebo čakať. Aby sa filozof najedol, musí si vziať dve vidličky (druhý zdieľa vidličku s prvým). Zdvihnutie a položenie vidlice sú dve samostatné činnosti. Všetci filozofi mlčia. Úlohou je nájsť taký algoritmus, aby aj po 54 rokoch všetci premýšľali a boli dobre živení.

Najprv sa pokúsme vyriešiť tento problém pomocou zdieľaného priestoru. Vidličky ležia na spoločnom stole a filozofi ich jednoducho vezmú, keď sú tam, a dajú ich späť. Tu vznikajú problémy so synchronizáciou, kedy presne brať vidly? čo robiť, ak nie je zástrčka? atď. Najprv však začnime s filozofmi.

Na spustenie vlákien používame fond vlákien cez Task.Run metóda:

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);
}

Fond vlákien je navrhnutý tak, aby optimalizoval vytváranie a odstraňovanie vlákien. Táto oblasť má rad úloh a CLR vytvára alebo odstraňuje vlákna v závislosti od počtu týchto úloh. Jeden fond pre všetky domény AppDomains. Tento bazén by sa mal používať takmer vždy, pretože... nemusíte sa obťažovať vytváraním a odstraňovaním vlákien, ich frontov atď. Môžete to urobiť bez fondu, ale potom ho budete musieť použiť priamo Thread, je to užitočné v prípadoch, keď potrebujeme zmeniť prioritu vlákna, keď máme dlhú operáciu, pre vlákno v popredí atď.

Inými slovami, System.Threading.Tasks.Task trieda je rovnaká Thread, no s najrôznejšími vymoženosťami: možnosť spustiť úlohu po bloku iných úloh, vrátiť ich z funkcií, pohodlne prerušiť a mnoho ďalšieho. atď. Sú potrebné na podporu konštrukcií async/wait (Asynchrónny vzor založený na úlohách, syntaktický cukor pre čakanie na operácie IO). O tom si povieme neskôr.

CancelationTokenSource tu je potrebné, aby sa vlákno mohlo ukončiť samo na základe signálu z volajúceho vlákna.

Problémy so synchronizáciou

Zablokovaní filozofi

Dobre, vieme, ako vytvárať vlákna, skúsme obed:

// Кто какие вилки взял. К примеру: 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();
    }
}

Tu najprv skúšame zobrať ľavú a potom pravú vidličku a ak to pôjde, zjeme a položíme ich späť. Odobratie jednej vidlice je atómové, t.j. dve vlákna nemôžu zaberať jedno súčasne (nesprávne: prvé píše, že vidlica je voľná, druhé robí to isté, prvé zaberá, druhé zaberá). Pre to Interlocked.CompareExchange, ktorá musí byť implementovaná pomocou inštrukcie procesora (TSL, XCHG), ktorý uzamkne časť pamäte pre atómové sekvenčné čítanie a zápis. A SpinWait je ekvivalentom konštrukcie while(true) len s trochou „mágie“ - vlákno zaberá procesor (Thread.SpinWait), ale niekedy odovzdá riadenie inému vláknu (Thread.Yeild) alebo zaspí (Thread.Sleep).

Toto riešenie však nefunguje, pretože... vlákna sa čoskoro (u mňa do sekundy) zablokujú: všetci filozofi berú svoju ľavú vidličku, ale neexistuje žiadna pravá. Pole vidlíc má potom hodnoty: 1 2 3 4 5.

Dobre živení filozofi alebo konkurencieschopné programovanie .NET

Na obrázku blokovanie vlákien (zablokovanie). Zelená označuje spustenie, červená označuje synchronizáciu a sivá označuje, že vlákno spí. Diamanty označujú čas spustenia úloh.

Hlad filozofov

Hoci na premýšľanie nepotrebujete veľa jedla, hlad môže každého prinútiť vzdať sa filozofie. Skúsme simulovať situáciu hladovania nití v našom probléme. Hladovanie je, keď vlákno funguje, ale bez významnej práce, inými slovami, je to rovnaká patová situácia, len vlákno teraz nespí, ale aktívne hľadá niečo na jedenie, ale nie je tam žiadne jedlo. Aby sme sa vyhli častému zablokovaniu, vidličku vrátime späť, ak by sme nemohli vziať inú.

// То же что и в 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)
    );
}

Na tomto kóde je dôležité, že dvaja zo štyroch filozofov zabudnú odložiť ľavú vidličku. A ukázalo sa, že jedia viac jedla a iní začnú hladovať, hoci vlákna majú rovnakú prioritu. Tu nie sú úplne hladní, pretože... zlí filozofi niekedy kladú vidličky späť. Ukazuje sa, že tie dobré jedia asi 5-krát menej ako tie zlé. Takže malá chyba v kóde vedie k poklesu výkonu. Tu je tiež potrebné poznamenať, že je možná zriedkavá situácia, keď všetci filozofi berú ľavú vidličku, neexistuje žiadna pravá, ľavú odložia, počkajú, znova zoberú ľavú atď. Aj táto situácia je hladovka, skôr vzájomná blokáda. Nemohol som to zopakovať. Nižšie je obrázok pre situáciu, keď dvaja zlí filozofi vzali obe vidličky a dvaja dobrí hladujú.

Dobre živení filozofi alebo konkurencieschopné programovanie .NET

Tu môžete vidieť, že vlákna sa niekedy prebudia a pokúsia sa získať zdroj. Dve zo štyroch jadier nerobia nič (zelený graf vyššie).

Smrť filozofa

No a ešte jeden problém, ktorý by mohol prerušiť slávnu večeru filozofov, je, ak jeden z nich náhle zomrie s vidlami v rukách (a bude tak pochovaný). Potom susedia zostanú bez obeda. Príklad kódu pre tento prípad si môžete vymyslieť sami, napríklad sa vyhodí NullReferenceException po tom, čo filozof vezme vidly. A mimochodom, výnimka nebude spracovaná a volací kód ju jednoducho nezachytí (na tento účel AppDomain.CurrentDomain.UnhandledException atď.). Preto sú potrebné obsluhy chýb v samotných vláknach a s elegantným ukončením.

čašník

Dobre, ako vyriešime tento problém slepej uličky, hladovania a úmrtí? Na vidličky si pustíme len jedného filozofa a pre toto miesto pridáme vzájomné vylúčenie vlákien. Ako to spraviť? Predpokladajme, že vedľa filozofov je čašník, ktorý dáva povolenie jednému filozofovi vziať vidličky. Ako by sme mali robiť tohto čašníka a ako sa ho budú pýtať filozofi, to sú zaujímavé otázky.

Najjednoduchší spôsob je, že filozofi jednoducho neustále žiadajú čašníka o prístup k vidličkám. Tie. Teraz filozofi nebudú čakať na vidličku v blízkosti, ale čakať alebo sa opýtať čašníka. Najprv na to používame iba Používateľský priestor, v ktorom nepoužívame prerušenia na volanie procedúr z jadra (viac o nich nižšie).

Riešenie užívateľského priestoru

Tu urobíme to isté, čo predtým s jednou vidličkou a dvoma filozofmi, budeme sa točiť v slučke a čakať. Ale teraz to budú všetko filozofi a akoby len jedna vidlička, t.j. môžeme povedať, že jesť bude len filozof, ktorý si túto „zlatú vidličku“ vzal od čašníka. Na tento účel používame funkciu SpinLock.

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 toto je blokátor, ktorý je, zhruba povedané, rovnaký while(true) { if (!lock) break; }, ale s ešte väčším „čarom“ ako v SpinWait (ktorý sa tam používa). Teraz vie, ako spočítať čakajúcich, trochu ich uspať a oveľa viac. atď. Vo všeobecnosti robí všetko pre optimalizáciu. Musíme si však uvedomiť, že je to stále tá istá aktívna slučka, ktorá zaberá zdroje procesora a drží vlákno, čo môže viesť k hladovaniu, ak sa jeden z filozofov stane prioritnejším ako ostatní, ale nemá zlatú vidlicu (problém s inverziou priority ). Preto ho používame len na veľmi krátke zmeny v zdieľanej pamäti, bez akýchkoľvek volaní tretích strán, vnorených zámkov alebo iných prekvapení.

Dobre živení filozofi alebo konkurencieschopné programovanie .NET

Kreslenie pre SpinLock. Prúdy neustále „bojujú“ o zlatú vidličku. Vyskytujú sa poruchy - zvýraznená oblasť na obrázku. Jadrá nie sú plne využité: len asi 2/3 týmito štyrmi vláknami.

Ďalším riešením by tu bolo iba použitie Interlocked.CompareExchange s rovnakým aktívnym čakaním, ako je uvedené v kóde vyššie (u hladujúcich filozofov), ale to, ako už bolo povedané, môže teoreticky viesť k zablokovaniu.

o Interlocked stojí za to povedať, že nie je len CompareExchange, ale aj iné metódy pre atómové čítanie A zápis. A opakovaním zmeny, ak sa inému vláknu podarí vykonať svoje zmeny (čítanie 1, čítanie 2, zápis 2, zápis 1 je zlý), môže byť použitý na komplexné zmeny jednej hodnoty (vzor Interlocked Anything).

Riešenia režimu jadra

Aby sme sa vyhli plytvaniu zdrojmi v slučke, pozrime sa, ako zablokovať vlákno. Inými slovami, pokračujúc v našom príklade, pozrime sa, ako čašník uspí filozofa a zobudí ho len v prípade potreby. Najprv sa pozrime na to, ako to urobiť prostredníctvom režimu jadra operačného systému. Všetky štruktúry tam často skončia pomalšie ako tie v užívateľskom priestore. Niekoľkokrát pomalšie napr AutoResetEvent možno 53-krát pomalšie SpinLock [Richter]. Ale s ich pomocou môžete synchronizovať procesy v celom systéme, riadené alebo nie.

Základným dizajnom je tu semafor, ktorý navrhol Dijkstra pred viac ako polstoročím. Semafor je, zjednodušene povedané, kladné celé číslo riadené systémom a na ňom dve operácie – zvýšenie a zníženie. Ak nie je možné znížiť nulu, volajúce vlákno je zablokované. Keď sa počet zvýši o nejaké iné aktívne vlákno/proces, vlákna prejdú a semafor sa opäť zníži o počet odovzdaných. Môžete si predstaviť vlaky v úzkom hrdle so semaforom. .NET ponúka niekoľko konštrukcií s podobnou funkcionalitou: AutoResetEvent, ManualResetEvent, Mutex a ja Semaphore. Budeme používať AutoResetEventToto je najjednoduchší z týchto konštruktov: iba dve hodnoty 0 a 1 (false, true). Jej metóda WaitOne() zablokuje volajúce vlákno, ak bola hodnota 0, a ak 1, zníži ho na 0 a preskočí ho. Metóda Set() zvýši na 1 a prepustí jednu osobu, ktorá sa opäť zníži na 0. Funguje ako turniket v metre.

Skomplikujme riešenie a použime blokovanie pre každého filozofa a nie pre všetkých naraz. Tie. Teraz môže jesť niekoľko filozofov naraz, a nielen jeden. Ale opäť blokujeme prístup k stolu, aby sme správne brali vidličky a vyhli sa podmienkam pretekov.

// Для блокирования отдельного философа.
// Инициализируется: 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();
}

Aby ste pochopili, čo sa tu deje, zvážte prípad, keď sa filozofovi nepodarilo vziať vidličky, potom budú jeho činy nasledovné. Čaká na prístup k stolu. Keď to dostane, pokúsi sa vziať vidličky. Nevyšlo to. Umožňuje prístup k stolu (vzájomné vylúčenie). A prejde okolo svojho „turniketu“ (AutoResetEvent) (najskôr sú otvorené). Opäť sa dostáva do kolobehu, pretože nemá vidličky. Pokúsi sa ich vziať a zastaví sa pri svojom „turniketu“. Nejaký šťastnejší sused napravo alebo naľavo, keď dojedol, odblokuje nášho filozofa „otvorením turniketu“. Náš filozof to prejde (a zavrie sa za ním) druhýkrát. Tretíkrát sa pokúša vziať vidličky. Úspešný. A prejde cez svoj turniket na obed.

Ak sa v takomto kóde vyskytnú náhodné chyby (vždy existujú), napríklad sused bude zadaný nesprávne alebo sa vytvorí rovnaký objekt AutoResetEvent pre všetkých (Enumerable.Repeat), potom filozofi počkajú na vývojárov, pretože Hľadanie chýb v takomto kóde je pomerne náročná úloha. Ďalším problémom tohto riešenia je, že nezaručuje, že nejaký filozof nezomrie od hladu.

Hybridné riešenia

Pozreli sme sa na dva prístupy k synchronizácii, keď zostávame v užívateľskom režime a točíme sa v slučke a keď blokujeme vlákno cez jadro. Prvý spôsob je dobrý pre krátke bloky, druhý pre dlhé. Často musíte najprv krátko počkať, kým sa premenná zmení v slučke, a potom vlákno zablokovať, keď je čakanie dlhé. Tento prístup je implementovaný v tzv. hybridné dizajny. Má rovnaké konštrukcie ako pre režim jadra, ale teraz so slučkou používateľského režimu: SemaphorSlim, ManualResetEventSlim atď. Tu je najobľúbenejší dizajn Monitor, pretože v C# je dobre známy lock syntax. Monitor toto je ten istý semafor s maximálnou hodnotou 1 (mutex), ale s podporou čakania v slučke, rekurzie, vzoru Condition Variable (viac o tom nižšie) atď. Pozrime sa na riešenie s ním.

// Спрячем объект для Монитора от всех, чтобы без дедлоков.
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);
    }
}

Tu opäť zablokujeme prístup celého stola k vidličkám, ale teraz odblokujeme všetky vlákna naraz, a nie susedom, keď niekto doje. Tie. najprv niekto zje a zablokuje susedov, a ked tento niekto doje, ale chce hned zase jest, ide do bloku a zobudi susedov, lebo jeho čakacia doba je kratšia.

Vyhneme sa tak patovej situácii a vyhladovaniu nejakého filozofa. Pomocou slučky krátko počkáme a na dlhší čas zablokujeme vlákno. Odblokovanie všetkých naraz je pomalšie, ako keby sa odblokoval len sused, ako pri riešení s AutoResetEvent, ale rozdiel by nemal byť veľký, pretože vlákna musia najskôr zostať v používateľskom režime.

У lock syntax má niekoľko nepríjemných prekvapení. Odporúča sa používať Monitor priamo [Richter] [Eric Lippert]. Jedným z nich je to lock vždy vyjde Monitor, aj keď došlo k výnimke, a potom môže iné vlákno zmeniť stav zdieľanej pamäte. V takýchto prípadoch je často lepšie dostať sa do slepej uličky alebo nejakým spôsobom bezpečne ukončiť program. Ďalším prekvapením je, že Monitor používa bloky hodín (SyncBlock), ktoré sú prítomné vo všetkých objektoch. Ak sa teda vyberie nevhodný objekt, ľahko sa dostanete do mŕtveho bodu (napríklad ak zamknete na internovanom reťazci). Vždy na to používame skrytý predmet.

Vzor Condition Variable umožňuje presnejšie implementovať očakávanie nejakej zložitej podmienky. V .NET je to podľa mňa neúplné, pretože... Teoreticky by malo existovať niekoľko frontov na niekoľkých premenných (ako v Posix Threads), a nie na jednom zámku. Potom by ich bolo možné vyrobiť pre všetkých filozofov. Ale aj v tejto podobe umožňuje skrátiť kód.

Mnohí filozofi resp async / await

Dobre, teraz môžeme efektívne blokovať vlákna. Ale čo ak máme veľa filozofov? 100? 10000 100000? Napríklad sme dostali 4 XNUMX požiadaviek na webový server. Vytvorenie vlákna pre každú požiadavku bude drahé, pretože toľko vlákien sa nebude vykonávať paralelne. Spustí sa len toľko logických jadier (ja mám XNUMX). A všetci ostatní jednoducho odoberú zdroje. Jedným z riešení tohto problému je vzor async / wait. Jeho myšlienkou je, že funkcia nedrží vlákno, ak potrebuje čakať, kým niečo bude pokračovať. A keď sa niečo stane, obnoví sa vykonávanie (ale nie nevyhnutne v rovnakom vlákne!). V našom prípade si počkáme na vidličku.

SemaphoreSlim má na to WaitAsync() metóda. Tu je implementácia pomocou tohto vzoru.

// Запуск такой же, как раньше. Где-нибудь в программе:
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();
}

Metóda s async / await sa preloží do prefíkaného konečného automatu, ktorý okamžite vráti svoje vnútorné Task. Prostredníctvom nej môžete čakať na dokončenie metódy, zrušiť ju a všetko ostatné, čo môžete s Task robiť. Vo vnútri metódy stavový automat riadi vykonávanie. Pointa je, že ak nedôjde k oneskoreniu, potom je vykonávanie synchrónne, a ak áno, vlákno sa uvoľní. Pre lepšie pochopenie je lepšie pozrieť sa na tento stavový automat. Z nich môžete vytvoriť reťazce async / await metódy.

Poďme to otestovať. Práca 100 filozofov na stroji so 4 logickými jadrami, 8 sekúnd. Predchádzajúce riešenie s Monitorom vykonalo iba prvé 4 vlákna a zvyšok nevykonalo vôbec. Každé z týchto 4 vlákien bolo nečinné asi 2 ms. A riešenie async / wait vykonalo všetkých 100, s priemerom 6.8 sekundy na každé čakanie. Samozrejme, v reálnych systémoch je nečinnosť 6 sekúnd neakceptovateľná a je lepšie takto nespracovať toľko požiadaviek. Ukázalo sa, že riešenie s Monitorom nie je vôbec škálovateľné.

Záver

Ako môžete vidieť z týchto malých príkladov, .NET podporuje mnoho synchronizačných konštrukcií. Nie je však vždy jasné, ako ich používať. Dúfam, že vám tento článok pomohol. Zatiaľ to uzatvárame, ale stále zostáva veľa zaujímavých vecí, napríklad kolekcie bezpečné pre vlákna, tok údajov TPL, reaktívne programovanie, model softvérových transakcií atď.

zdroje

Zdroj: hab.com

Pridať komentár