Dobro hranjeni filozofi ali konkurenčno programiranje .NET

Dobro hranjeni filozofi ali konkurenčno programiranje .NET

Poglejmo, kako deluje sočasno in vzporedno programiranje v .Netu, na primeru Philosophers Dining Problem. Načrt je tole, od sinhronizacije niti/procesov, do akterskega modela (v naslednjih delih). Članek je lahko koristen za prvo spoznavanje ali za osvežitev znanja.

Zakaj sploh? Tranzistorji dosežejo svojo najmanjšo velikost, Moorov zakon temelji na omejitvi svetlobne hitrosti in zato opazimo povečanje števila, lahko naredimo več tranzistorjev. Hkrati raste količina podatkov, uporabniki pa pričakujejo takojšen odziv sistemov. V takšni situaciji "normalno" programiranje, ko imamo eno izvajajočo nit, ni več učinkovito. Nekako morate rešiti problem hkratnega ali sočasnega izvajanja. Poleg tega ta problem obstaja na različnih ravneh: na ravni niti, na ravni procesov, na ravni strojev v omrežju (razdeljeni sistemi). .NET ima visoko kakovostne, časovno preizkušene tehnologije za hitro in učinkovito reševanje tovrstnih težav.

Naloga

Edsger Dijkstra je ta problem postavil svojim študentom že leta 1965. Uveljavljena formulacija je naslednja. Obstaja določeno (običajno pet) število filozofov in enako število vilic. Sedijo za okroglo mizo, med njimi so vilice. Filozofi lahko jedo s svojih krožnikov neskončno hrano, razmišljajo ali čakajo. Če želite jesti filozofa, morate vzeti dve vilici (zadnja si deli vilice s prvo). Dviganje in odlaganje vilic sta dve ločeni dejanji. Vsi filozofi so tiho. Naloga je najti takšen algoritem, da bi vsi mislili in bili siti tudi po 54 letih.

Najprej poskusimo rešiti ta problem z uporabo skupnega prostora. Vilice ležijo na skupni mizi in filozofi jih preprosto vzamejo, ko so in jih postavijo nazaj. Tukaj so težave s sinhronizacijo, kdaj točno sprejeti vloge? kaj če ni vilic? itd. Toda najprej začnimo s filozofi.

Za zagon niti uporabljamo skupino niti skozi Task.Run metoda:

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

Bazen niti je zasnovan tako, da optimizira ustvarjanje in brisanje niti. To področje ima čakalno vrsto z opravili in CLR ustvari ali odstrani niti glede na število teh opravil. En bazen za vse domene aplikacij. Ta bazen je treba uporabljati skoraj vedno, ker. ni se vam treba obremenjevati z ustvarjanjem, brisanjem niti, njihovih čakalnih vrst itd. Možno je brez bazena, vendar ga morate potem uporabiti neposredno Thread, to je uporabno v primerih, ko morate spremeniti prioriteto niti, ko imamo dolgo operacijo, za nit v ospredju itd.

Z drugimi besedami, System.Threading.Tasks.Task razred je isti Thread, vendar z vsemi vrstami ugodnosti: zmožnostjo zagona opravila po bloku drugih opravil, vrnitve iz funkcij, priročne prekinitve in več. itd. Potrebni so za podporo konstrukcij async/await (asinhroni vzorec, ki temelji na opravilu, sintaktični sladkor za čakanje na operacije IO). O tem bomo govorili kasneje.

CancelationTokenSource tukaj je potrebno, da se nit lahko zaključi na signal klicne niti.

Težave s sinhronizacijo

Blokirani filozofi

V redu, znamo ustvarjati niti, poskusimo na kosilo:

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

Tukaj najprej poskusimo vzeti leve vilice, nato pa desne, in če uspe, jih pojemo in vrnemo nazaj. Vzetje ene vilice je atomsko, tj. dve niti ne moreta vzeti ene hkrati (napačno: prva se glasi, da je fork prosta, druga - tudi, prva vzame, druga vzame). Za to Interlocked.CompareExchange, ki mora biti implementiran s procesorskim ukazom (TSL, XCHG), ki zaklene del pomnilnika za atomsko zaporedno branje in pisanje. In SpinWait je enakovreden konstruktu while(true) le z malo "čarovnije" - nit vzame procesor (Thread.SpinWait), včasih pa prenese nadzor na drugo nit (Thread.Yeild) ali zaspi (Thread.Sleep).

Toda ta rešitev ne deluje, ker tokovi se kmalu (pri meni v sekundi) zamašijo: vsi filozofi zavijejo levo, ne pa desno. Matrika vilic ima potem vrednosti: 1 2 3 4 5.

Dobro hranjeni filozofi ali konkurenčno programiranje .NET

Na sliki blokiranje niti (zastoj). Zelena - izvedba, rdeča - sinhronizacija, siva - nit spi. Rombovi označujejo začetni čas nalog.

Lakota filozofov

Čeprav ni treba posebej razmišljati o hrani, lakota prisili vsakogar, da opusti filozofijo. Poskusimo simulirati situacijo stradanja niti v našem problemu. Stradanje je, ko nit teče, vendar brez pomembnega dela, z drugimi besedami, to je isti zastoj, le da nit zdaj ne spi, ampak aktivno išče nekaj za jesti, vendar ni hrane. Da bi se izognili pogostemu blokiranju, bomo vilice vrnili nazaj, če ne moremo vzeti druge.

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

Pomembna stvar pri tem kodeksu je, da dva od štirih filozofov pozabita odložiti leve vilice. In izkaže se, da pojedo več hrane, drugi pa začnejo stradati, čeprav imajo niti enako prednost. Tukaj ne stradajo povsem, saj. slabi filozofi včasih postavijo vilice nazaj. Izkazalo se je, da dobri ljudje pojedo približno 5-krat manj kot slabi. Majhna napaka v kodi torej povzroči padec zmogljivosti. Tukaj velja tudi omeniti, da je možna redka situacija, ko vsi filozofi uberejo levo vilico, desnega ni, postavijo levo, počakajo, spet vzamejo levo itd. Ta situacija je tudi stradanje, bolj podobna slepi ulici. Ni mi uspelo ponoviti. Spodaj je slika situacije, ko sta dva slaba filozofa prijela za obe vilici, dva dobra pa stradata.

Dobro hranjeni filozofi ali konkurenčno programiranje .NET

Tukaj lahko vidite, da se niti včasih prebudijo in poskušajo pridobiti vir. Dve od štirih jeder ne delata ničesar (zelen graf zgoraj).

Smrt filozofa

No, še en problem, ki lahko zmoti veličastno večerjo filozofov, je, če eden od njih nenadoma umre z vilicami v rokah (in ga bodo tako pokopali). Takrat bodo sosedje ostali brez večerje. Sami si lahko izmislite primer kode za ta primer, na primer, vrže se ven NullReferenceException potem ko filozof prime za vilice. In, mimogrede, izjema ne bo obravnavana in klicna koda je ne bo kar ujela (za to AppDomain.CurrentDomain.UnhandledException in itd.). Zato so obdelovalci napak potrebni v samih nitih in z elegantnim zaključkom.

Končnik

V redu, kako naj rešimo ta problem brezizhodnosti, stradanja in smrti? Samo enemu filozofu bomo dovolili, da doseže vilice, za to mesto dodamo medsebojno izključitev niti. Kako narediti? Recimo, da je poleg filozofov natakar, ki kateremu koli filozofu dovoli, da vzame vilice. Kako narediti tega natakarja in kako ga bodo vprašali filozofi, so zanimiva vprašanja.

Najenostavneje je, da bodo filozofi preprosto nenehno prosili natakarja za dostop do vilic. Tisti. zdaj filozofi ne bodo čakali na vilice v bližini, ampak čakali ali vprašali natakarja. Sprva za to uporabljamo samo uporabniški prostor, v njem ne uporabljamo prekinitev za klicanje kakršnih koli procedur iz jedra (o njih spodaj).

Rešitve v uporabniškem prostoru

Tukaj bomo naredili isto kot nekoč z eno vilico in dvema filozofoma, vrteli se bomo v ciklu in čakali. Zdaj pa bodo vsi filozofi in tako rekoč samo ena vilica, tj. lahko rečemo, da bo jedel le tisti filozof, ki je od natakarja vzel to »zlato vilico«. Za to uporabljamo 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 to je blokator, pri čemer je, grobo rečeno, enako while(true) { if (!lock) break; }, a s še več "čarovnije" kot v SpinWait (ki se tam uporablja). Zdaj zna prešteti čakajoče, jih malo uspavati in še kaj. itd. Na splošno naredi vse za optimizacijo. Ne smemo pa pozabiti, da je to še vedno isti aktivni cikel, ki požre procesorske vire in ohranja pretok, kar lahko povzroči stradanje, če eden od filozofov postane bolj prednosten kot drugi, vendar nima zlate vilice (problem z inverzijo prioritet) . Zato ga uporabljamo samo za zelo zelo kratke spremembe v skupnem pomnilniku, brez kakršnih koli klicev tretjih oseb, ugnezdenih ključavnic in drugih presenečenj.

Dobro hranjeni filozofi ali konkurenčno programiranje .NET

Risba za SpinLock. Potoki se ves čas »borijo« za zlato vilico. Obstajajo napake - na sliki izbrano območje. Jedra niso v celoti izkoriščena: te štiri niti le približno 2/3.

Druga rešitev bi bila uporaba samo Interlocked.CompareExchange z enakim aktivnim čakanjem, kot je prikazano v zgornji kodi (pri stradajočih filozofih), vendar bi to, kot že rečeno, teoretično lahko vodilo do blokade.

na Interlocked Treba je opozoriti, da ni samo CompareExchange, ampak tudi druge metode za atomsko branje IN pisanje. In s ponavljanjem spremembe, v primeru, da ima druga nit čas za svoje spremembe (branje 1, branje 2, pisanje 2, pisanje 1 je slabo), se lahko uporabi za kompleksne spremembe ene same vrednosti (vzorec Interlocked Anything) .

Rešitve načina jedra

Da bi se izognili zapravljanju virov v zanki, poglejmo, kako lahko blokiramo nit. Z drugimi besedami, če nadaljujemo naš primer, poglejmo, kako natakar uspava filozofa in ga zbudi le, ko je treba. Najprej si poglejmo, kako to storiti v načinu jedra operacijskega sistema. Vse tamkajšnje strukture so pogosto počasnejše od tistih v uporabniškem prostoru. Nekajkrat počasneje npr AutoResetEvent morda 53-krat počasneje SpinLock [Richter]. Toda z njihovo pomočjo lahko sinhronizirate procese v celotnem sistemu, upravljane ali ne.

Osnovni konstrukt tukaj je semafor, ki ga je predlagal Dijkstra pred več kot pol stoletja. Semafor je, preprosto povedano, pozitivno celo število, ki ga upravlja sistem, in dve operaciji na njem, inkrement in dekrement. Če se ne zmanjša, nič, potem je klicna nit blokirana. Ko število poveča neka druga aktivna nit/proces, se niti preskočijo in semafor se ponovno zmanjša za posredovano število. Lahko si predstavljamo vlake v ozkem grlu s semaforjem. .NET ponuja več konstrukcij s podobno funkcionalnostjo: AutoResetEvent, ManualResetEvent, Mutex in jaz Semaphore. Bomo uporabili AutoResetEvent, to je najpreprostejša od teh konstrukcij: samo dve vrednosti 0 in 1 (false, true). Njena metoda WaitOne() blokira klicno nit, če je bila vrednost 0, in če je 1, jo zniža na 0 in preskoči. Metoda Set() poviša na 1 in spusti enega natakarja skozi, ta pa spet zniža na 0. Deluje kot vrtljivi križ podzemne železnice.

Zakomplicirajmo rešitev in uporabimo ključavnico za vsakega filozofa in ne za vse hkrati. Tisti. zdaj je lahko več filozofov hkrati in ne eden. Toda spet blokiramo dostop do mize, da bi pravilno, izogibanje dirkam (pogoji dirke), sprejeli vloge.

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

Da bi razumeli, kaj se tukaj dogaja, razmislite o primeru, ko filozof ni uspel vzeti vilic, potem bodo njegova dejanja naslednja. Čaka na dostop do mize. Ko ga je prejel, poskuša vzeti vilice. Ni uspelo. Omogoča dostop do tabele (medsebojna izključitev). In gre mimo njegovega "turnita" (AutoResetEvent) (sprva so odprti). Spet pride v ciklus, saj nima vilic. Poskuša jih vzeti in se ustavi pri svojem "turnitu". Nek srečnejši sosed na desni ali levi, ko je končal z jedjo, odklene našega filozofa in "odpre njegov vrtljivi križ". Naš filozof ga preteče (in se za njim zapre) že drugič. Že tretjič poskuša prijeti za vilice. Vso srečo. In gre mimo vrtljivega križa, da bi večerjal.

Ko so v takšni kodi naključne napake (vedno obstajajo), je na primer sosed nepravilno določen ali pa je ustvarjen isti objekt AutoResetEvent za vse (Enumerable.Repeat), potem bodo filozofi čakali na razvijalce, ker Iskanje napak v takšni kodi je precej težka naloga. Druga težava te rešitve je, da ne zagotavlja, da kakšen filozof ne bo ostal lačen.

Hibridne rešitve

Ogledali smo si dva pristopa k določanju časa, ko ostanemo v uporabniškem načinu in zanki ter ko blokiramo nit skozi jedro. Prva metoda je dobra za kratke pramene, druga za dolge. Pogosto je treba najprej na kratko počakati, da se spremenljivka spremeni v zanki, in nato blokirati nit, ko je čakanje dolgo. Ta pristop se izvaja v t.i. hibridne strukture. Tukaj so enaki konstrukti kot za način jedra, vendar zdaj z zanko uporabniškega načina: SemaphorSlim, ManualResetEventSlim itd. Najbolj priljubljen dizajn tukaj je Monitor, Ker v C# je dobro znan lock sintaksa. Monitor to je isti semafor z največjo vrednostjo 1 (mutex), vendar s podporo za čakanje v zanki, rekurzijo, vzorec spremenljivke pogoja (več o tem spodaj) itd. Poglejmo rešitev z njim.

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

Tukaj spet blokiramo celotno mizo za dostop do vilic, zdaj pa odblokiramo vse niti hkrati in ne sosedov, ko nekdo neha jesti. Tisti. najprej nekdo poje in zablokira sosede in ko ta nekdo konča, pa hoče takoj spet jesti, gre v blokado in zbudi sosede, ker. njegova čakalna doba je krajša.

Tako se izognemo zastojem in stradanju kakšnega filozofa. Pri kratkem čakanju uporabimo zanko, pri dolgem pa blokiramo nit. Odblokiranje vseh naenkrat je počasnejše, kot če bi bil odblokiran samo sosed, kot pri rešitvi z AutoResetEvent, a razlika ne sme biti velika, saj niti morajo najprej ostati v uporabniškem načinu.

У lock sintaksa prinaša grda presenečenja. Priporočljivo za uporabo Monitor neposredno [Richter] [Eric Lippert]. Eden izmed njih je ta lock vedno izven Monitor, tudi če bi prišlo do izjeme, bi lahko druga nit spremenila stanje skupnega pomnilnika. V takšnih primerih je pogosto bolje iti v slepi zastoj ali nekako varno prekiniti program. Drugo presenečenje je, da Monitor uporablja sinhronizacijske bloke (SyncBlock), ki so prisotni v vseh objektih. Če torej izberete neustrezen predmet, lahko zlahka pride do zastoja (na primer, če zaklenete interni niz). Za to uporabljamo vedno skriti predmet.

Vzorec spremenljivke pogoja vam omogoča bolj jedrnato implementacijo pričakovanja nekega kompleksnega pogoja. V .NET je po mojem mnenju nepopoln, ker v teoriji bi moralo biti več čakalnih vrst na več spremenljivkah (kot v Posix Threads) in ne na enem mestu. Potem bi jih lahko naredili za vse filozofe. Toda tudi v tej obliki vam omogoča zmanjšanje kode.

številni filozofi oz async / await

V redu, zdaj lahko učinkovito blokiramo niti. Kaj pa, če imamo veliko filozofov? 100? 10000? Na spletni strežnik smo na primer prejeli 100000 zahtev. Ustvariti nit za vsako zahtevo bo veliko, ker toliko niti ne bo potekalo vzporedno. Zagnal se bo le toliko, kolikor je logičnih jeder (imam 4). In vsi drugi bodo le jemali sredstva. Ena od rešitev te težave je vzorec async/await. Njegova ideja je, da funkcija ne zadrži niti, če mora počakati, da se nekaj nadaljuje. In ko nekaj naredi, nadaljuje z izvajanjem (vendar ne nujno v isti niti!). V našem primeru bomo počakali na fork.

SemaphoreSlim ima za to WaitAsync() metoda. Tukaj je izvedba, ki uporablja ta vzorec.

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

Metoda z async / await se prevede v zapleten državni stroj, ki takoj vrne svoje notranje Task. Preko njega lahko počakate na zaključek metode, jo prekličete in vse ostalo, kar lahko naredite z Task. Znotraj metode stanje stroj nadzoruje izvajanje. Bistvo je, da če ni zamude, je izvedba sinhrona, in če je, se nit sprosti. Za boljše razumevanje tega je bolje pogledati ta državni stroj. Iz njih lahko ustvarite verige async / await metode.

Testirajmo. Delo 100 filozofov na stroju s 4 logičnimi jedri, 8 sekund. Prejšnja rešitev z Monitorjem je zagnala samo prve 4 niti, ostale pa sploh niso. Vsaka od teh 4 niti je bila nedejavna približno 2 ms. In rešitev async/await je zagnala vseh 100, s povprečnim čakanjem 6.8 sekunde na vsako. Seveda je v realnih sistemih nedejavnost 6 sekund nesprejemljiva in bolje je, da ne obdelamo toliko zahtev kot je ta. Izkazalo se je, da rešitev z Monitorjem sploh ni razširljiva.

Zaključek

Kot lahko vidite iz teh majhnih primerov, .NET podpira številne sinhronizacijske konstrukcije. Vendar pa ni vedno jasno, kako jih uporabiti. Upam, da je bil ta članek v pomoč. Zaenkrat je to konec, vendar je ostalo še veliko zanimivih stvari, na primer nizovno varne zbirke, TPL Dataflow, reaktivno programiranje, model programske transakcije itd.

viri

Vir: www.habr.com

Dodaj komentar