Söönud filosoofid või konkurentsipõhine programmeerimine .NET-is

Söönud filosoofid või konkurentsipõhine programmeerimine .NET-is

Vaatame, kuidas on korraldatud konkurentsi ja paralleelse programmeerimise protsess .NET-is, kasutades söönud filosoofide probleemi näidet. Plaan on selline: alates lõngade/protsesside sünkroniseerimisest kuni näitlejate mudelini (järgnevates osades). Artikkel võib olla kasulik esimese tutvumise või oma teadlikkuse värskendamiseks.

Miks üldse selliseid oskusi omada? Transistorid saavutavad oma minimaalset suurust, Moore'i seadus põrkub valguse kiirusest tulenevate piirangutega ning seetõttu on kasv täheldatav transistorite arvu suurenemises. Samal ajal kasvab andmete hulk ja kasutajad ootavad süsteemide kohest reageerimist. Sellises olukorras pole 'tavaline' programmeerimine, kus meil on üks tegevusvoog, enam efektiivne. Probleem on kuidagi lahendatud, et saavutada samal ajal või konkurentsis töötamine. See probleem eksisteerib erinevatel tasanditel: lõngade tasandil, protsesside tasandil, masinate tasandil võrku (jaotatud süsteemid). .NET-is on kvaliteetsed, aja jooksul tõestatud tehnoloogiad selliste ülesannete kiireks ja tõhusaks lahendamiseks.

Ülesanne

Edsger Dijkstra esitas selle probleemi oma õpilastele juba 1965. aastal. Ulesehitatud määratlus on järgmine. On teatud (tavaliselt viis) fänni ja sama palju kahvleid. Nad istuvad ringlaudade ümber, kahvlid nende vahel. Filosoofid võivad süüa oma taldrikutest, kus on lõputult toitu, mõelda või oodata. Filosoofil on söömise jaoks vaja võtta kaks kahvlit (viimane jagab kahvlit esimesega). Kahvli võtmiseks ja panemiseks on kaks eraldi toimingut. Kõik filosoofid on vaikselt. Ülesanne on leida selline algoritm, et kõik nad mõtleksid ja oleksid pärast isegi 54 aastat söönud.

Alustame selle ülesande lahendamist jagatud ruumi kasutamise kaudu. Kahvlid on ühisel laual ja filosoofid lihtsalt võtavad neid, kui nad söövad, ja panevad tagasi. Siinkohal tekivad sünkroniseerimise probleemid, millal kahvleid võtta? mida teha, kui kahvlit pole? jne. Aga kõigepealt lasta filosoofidel töötada.

Protsesside käivitamiseks kasutame niidipooli kaudu Task.Run meetodi:

var cancelTokenSource = new CancellationTokenSource();
Action<int> create = (i) => RunPhilosopher(i, cancelTokenSource.Token);
for (int i = 0; i < philosophersAmount; i++) 
{
    int icopy = i;
    // Paigalda ülesanne lõime tõukerattas. Meetod RunDeadlock ei käivitu 
    // kohe, vaid ootab oma lõime. Asünkrooniline käivitamine.
    philosophers[i] = Task.Run(() => create(icopy), cancelTokenSource.Token);
}

Tõukerattaste grupp on loodud tõukerattaste loomise ja eemaldamise optimeerimiseks. Sellel grupil on ülesande järjekord ja CLR loob või eemaldab tõukerattaid ülesannete arvu põhjal. Üks grupp kõikide AppDomainide jaoks. Seda gruppi tasub peaaegu alati kasutada, kuna ei pea muretsema tõukerataste loomise, eemaldamise, nende järjekordade jne pärast. Saab ka ilma grupita, kuid siis tuleb otse kasutada. Tõukeratas, see on mõistlik olukordades, kus tuleb tõukerataste prioriteeti muuta, kui meil on pikk operatsioon, Foreground lõime jaoks jne.

Teisisõnu, System.Threading.Tasks.Task klass — see on sama Tõukeratas, kuid igasuguste mugavustega: võimalus käivitada ülesanne pärast teiste ülesannete plokki, tagastada need funktsioonidest, mugavalt katkestada ja palju muud. Need on vajalikud async/await konstruktsioonide toetamiseks (ülesande-põhine asünkroonne musternäidis, süntaktiline suhkruroo IO toimingu ootamiseks). Räägime sellest veel.

CancelationTokenSource on siin vajalik, et lõime saaks ise lõpetada kutsuva lõime signaali kaudu.

Sünkroniseerimisprobleemid

Blokeeritud filosoofid

Hea, me oskame luua lõime, proovime nüüd lõunatada:

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

Siin proovime kõigepealt võtta vasaku ja seejärel parema kahvli, ja kui see õnnestub, siis sööme ja paneme need tagasi. Ühe kahvli võtmine on atomaarne, s.t. kaks lõime ei saa sama kahvlit korraga võtta (vale: esimene loeb, et kahvel on vaba, teine — samuti, esimene võtab, teine võtab). Selleks Interlocked.CompareExchange, mis peab olema rakendatud protsessori juhise abil (TSL, XCHG), mis lukustab mäluosa atomaarseks järkjärguliseks lugemiseks ja kirjutamiseks. Ja SpinWait on ekvivalentne konstruktsiooniga while(true) ainult väikese «võluga» — lõim haarab protsessori (Thread.SpinWait), kuid mõnikord edastab juhtimise teisele lõimele (Thread.Yield) või magab (Thread.Sleep).

Aga see lahendus ei tööta, kuna lõngad blokeeruvad kiiresti (mul on see sekundi jooksul): kõik filosoofid võtavad oma vasaku kahvli, aga paremat ei saa. Massiiv forks siis tähendab: 1 2 3 4 5.

Söönud filosoofid või konkurentsipõhine programmeerimine .NET-is

Joonisel, lõngade blokeerimine (deadlock). Roheline - täitmine, punane - sünkroniseerimine, hall - lõng magab. Rombid näitavad Task’ide käivitamise aega.

Filosoofide nälg

Kuigi kuidagi mõtlema ei ole eriti palju toitu vaja, võib nälg kedagi sundida filosoofiat loobuma. Proovime modelleerida lõngade nälgimise olukorda meie ülesandes. Nälgimine - see on siis, kui lõng töötab, kuid mitte oluliselt, teisisõnu, see on sama deadlock, kuid nüüd ei maga lõng, vaid otsib aktiivselt, kuidas süüa, aga toitu ei ole. Et vältida sagedast blokeerimist, paneme kahvli tagasi, kui ei suutnud teist võtta.

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

Selles koodis on oluline, et kaks neljast filosoofist unustavad oma vasaku kahvli lauale panna. Tulemuseks on see, et nad söövad rohkem toitu, samas kui teised hakkavad nälgima, kuigi voogudel on sama prioriteet. Nad ei ole päris näljased, kuna halvad filosoofid panevad mõnikord oma kahvlid tagasi. Tundub, et head söövad kuskil viis korda vähem kui halvad. Nii et väike viga koodis põhjustab jõudluse languse. Samuti on oluline märkida, et on võimalik haruldane olukord, kus kõik filosoofid võtavad oma vasaku kahvli, paremat pole, nad panevad vasaku tagasi, ootavad, võtavad jälle vasaku jne. See olukord on samuti nälgimine, rohkem sarnane vastastikusele lukustusele. Ma ei suutnud seda korrata. Allpool on pilt olukorrast, kus kaks halba filosoofi võtavad mõlemad kahvlid ja kaks head nälgivad.

Söönud filosoofid või konkurentsipõhine programmeerimine .NET-is

Siit on näha, et vood mõnikord ärkavad ja proovivad ressursse hankida. Kaks tuuma neljast ei tee midagi (ülemine roheline graafik).

Filosoofi surm

Ja veel üks probleem, mis võib filosoofide toreda eine katki teha, on see, kui üks neist äkki sureb kahvlitega käes (ja teda ka nii maetakse). Siis jäävad naabrid ilma eineta. Näiteks võite ise sellele olukorrale koodi välja mõelda, näiteks visatakse välja NullReferenceException pärast seda, kui filosoof on kahvlid võtnud. Ja muide, see erand jääb käideldamatuks ja kutsuv kood ei püüa seda lihtsalt kinni (selleks, et AppDomain.CurrentDomain.UnhandledException jne.). Seetõttu on vigade käsitlejad vajalikud juba ainuüksi nihete koopias ja korrektseks lõpetamiseks.

Kelner

Nii, kuidas me selle probleemiga, mis puudutab üksteise blokeerimist, nälgimist ja surma, lahendame? Lubame kahvlitele ligipääsu vaid ühele filosoofile, lisame sellele kohale üksteise välistamise (mutual exclusion) nihete jaoks. Kuidas seda teha? Oletame, et filosoofide kõrval on kelner, kes annab luba mõnele ühele filosoofile kahvleid võtta. Kuidas me teeme selle kelneri ja kuidas filosoofid teda paluvad, need on huvitavad küsimused.

Lihtsaim viis on see, kui filosoofid pidevalt paluvad kelnerilt ligipääsu kahvlitele. See tähendab, et filosoofid ei oota enam kahvlit läheduses, vaid ootavad või küsivad kelnerit. Alustame ainult kasutajaruumis, kus me ei kasuta katkemisi mingite tuumaprotseduuride väljakutsumiseks (need on allpool).

Lahendused kasutajaruumis

Siin teeme ka sama, mida varem ühe kahvli ja kahe filosoofiga, keerleme silmust ja ootame. Kuid nüüd on need kõik filosoofid ja justkui ainult üks kahvel, see tähendab, et ühesõnaga on ainult see filosoof, kes on võtnud selle „kuldse kahvli“ kelnerilt. Selleks kasutame SpinLocki.

private static SpinLock spinLock = new SpinLock();  // Meie "kelner"
private void RunSpinLock(int i, CancellationToken token)
{
    while (true)
    {
        // Vastastikune lukustus busy waitinguga. Kutsume välja kuni proovime, et
        // visata erand juhul, kui SpinLockis on viga.
        bool hasLock = false;
        spinLock.Enter(ref hasLock);
        try
        {
            // Siin võib olla ainult üks thread (vastastikune välistamine).
            forks[Left(i)] = i + 1;  // Võtame kahvli kohe, ooteolekuta.
            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();  // Vältime filosoofi surma probleemi.
        }

        Think(i);

        if (token.IsCancellationRequested)
            break;
    }
}

SpinLock see on lukustus, mis on, sisuliselt, sama while(true) { if (!lock) break; }, aga veel suurema "maagiaga" kui SpinWait (mida seal kasutatakse). Nüüd oskab ta oodata arvestatavaid, veidi uinutada neid ja palju muud. Ühesõnaga, teeb kõik võimaliku optimeerimise nimel. Kuid tuleb meeles pidada, et see on ikka sama aktiivne tsükkel, mis sööb protsessori ressursse ja hoiab voogu, mis võib viia nälgimiseni, kui üks filosoof muutub teistega võrreldes prioriteetsemaks, kuid tal ei ole kuldset kahvlit (Priority Inversion problem). Seetõttu kasutame seda ainult väga lühikesteks muudatusteks üldises mälus, ilma igasuguste väliste kutsungiteta, pesitsemisblokeeringuteta ja muude üllatustega.

Söönud filosoofid või konkurentsipõhine programmeerimine .NET-is

Joonis SpinLock. Voogud võitlevad pidevalt kuldse kahvli nimel. Juhtub ebaõnnestumisi — joonisel on esile tõstetud ala. Südamikud ei ole täielikult kasutuses: ainult umbes 2/3 nendest neljast voogust.

Teine lahendus oleks kasutada ainult Interlocked.CompareExchange aktiivsete ootamistega, nagu on näidatud eelnevas koodis (nälgivad filosoofid), kuid see, nagu juba öeldud, võib teoreetiliselt viia blokeerimiseni.

Umbes Interlocked tuleb öelda, et seal ei ole mitte ainult CompareExchange, aga ka muud meetodid aatomite lugemiseks ja kirjutamiseks. Kui samal ajal muudab teine thread oma andmeid (lugemine 1, lugemine 2, kirjutamine 2, kirjutamine 1 halb), saab seda kasutada ühe väärtuse keerukate muudatuste tegemiseks (Interlocked Anything muster).

Tuuma režiimi lahendused

Ressursside kadu tsüklis vältimiseks vaatame, kuidas vooge blokeerida. Teisisõnu, jätkates meie näidet, vaatame, kuidas kelner uinutab filosoofi ja äratab ta üles ainult siis, kui see on vajalik. Esiteks vaatame, kuidas seda teha operatsioonisüsteemi tuumarežiimi kaudu. Sealne struktuur on sageli aeglasem kui kasutajakeskkonna struktuur. Aeglasem mitmeid kordi, näiteks AutoResetEvent võib olla 53 korda aeglasem SpinLock [Rihter]. Kuid nende abil saab sünkroonida protsesse kogu süsteemis, kas juhitavad või mitte.

Põhiülesanne siin on semafor, mille ettepanek esitas Dijkstra üle poole sajandi tagasi. Semafor on lihtsustatult öeldes positiivne täisarv, millega haldab süsteem, ning sellel on kaks operatsiooni: suurenda ja vähenda. Kui vähendamine pole võimalik, siis kui see jõuab nulli, blokeeritakse helistav lõng. Kui mingi muu aktiivne lõng/ protsess suurendab arvu, siis lastakse lõngudel edasi, ja semafor väheneb taas möödunud arvu võrra. Seda võib ette kujutada nagu rongide liiklemist kitsastes kohtades semaforiga. .NET pakub mitmeid sarnaste funktsioonidega konstruktsioone: AutoResetEvent, ManualResetEvent, Mutex ja ise Semaphore. Me kasutame AutoResetEvent, see on neist konstruktsioonidest kõige lihtsam: ainult kaks väärtust 0 ja 1 (vale, tõene). Selle meetod WaitOne() blokeerib helistava lõnga, kui väärtus on 0, ja kui see on 1, siis alandab selle 0-le ja laseb edasi. Meetod Set() suurendab seda 1-ks ja laseb ühe oodanud edasi, kes alandab selle taas 0-le. Töötab nagu metroo värav.

Teeme lahenduse keerulisemaks ja kasutame blokeerimist iga filosoofi jaoks, mitte kõigi jaoks korraga. See tähendab, et nüüd saavad mitmed filosoofid olla samal ajal kohal, mitte ainult üks. Kuid me blokeerime endiselt juurdepääsu laua juurde, et tõhusalt, vältides võistlusolukordi, võtta kahvlid.

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

Et mõista, mis siin toimub, vaatleme olukorda, kus filosoof ei suuda kahvleid võtta. Tema tegevused on järgmised: ta ootab juurdepääsu lauale. Kui ta selle saab, proovib ta kahvleid võtta. Ei õnnestu. Ta annab juurdepääsu lauale tagasi (vastastikune väljaheitmine). Ja läbib oma "turniiri" (AutoResetEvent) (alguses on need avatud). Ta satub taas tsüklisse, kuna tal pole kahvleid. Proovib neid võtta ja peatub oma "turniiril". Mõni õnnelikum naaber paremalt või vasakult, kes on söömise lõpetanud, vabastab meie filosoofi, "avatades tema turniiri". Meie filosoof läbib selle (ja see sulgub tema taga) teist korda. Proovib kolmandat korda kahvleid võtta. Õnnestub. Ja läbib oma turniiri, et lõunatada.

Kui sellises koodis esinevad juhuslikud vead (need on alati olemas), näiteks on vale naaber määratud või loodud on sama objekt AutoResetEvent kõigi jaoks (Enumerable.Repeat), siis filosoofid ootavad juba arendajaid, kuna vigade otsimine sellises koodis on üsna keeruline tegevus. Veel üks probleem selle lahendusega on see, et see ei taga, et mõni filosoof ei hakka nälgima.

Hübriidlahendused

Oleme vaadanud kahte lähenemist sünkroniseerimisele, kus jääme kasutaja režiimi ja pöörleme silmas, ning kui blokeerime voolu läbi tuuma. Esimene meetod sobib lühikesteks blokeeringuteks, teine pikadeks. Sageli on vajalik kõigepealt lühidalt oodata muutust muutuvas silmas, ja seejärel blokeerida voog, kui ooteaeg on pikk. Seda lähenemist rakendatakse nn hübriidkonstruktsioonides. Siin on samad konstruktsioonid, mis olid tuumarežiimis, kuid nüüd kasutaja režiimis silmas: SemaphorSlim, ManualResetEventSlim jne. Kõige populaarsem konstruktsioon siin on Monitor, kuna C#-is on tuntud kõikide seas lock süntaks. Monitor see on sama semafor maksimaalse väärtusega 1 (müutex), kuid toetab ooteid silmas, rekursiooni, Condition Variable mustrit (sellest allpool) jne. Vaadake lahendust koos sellega.

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

Siin me blokeerime jälle kogu laua juurdepääsu kahvlitele, kuid nüüd vabastame kõik vood korraga, mitte naabreid, kui keegi lõpetab söömist. Ehk siis, esmalt keegi sööb ja blokeerib naabreid, ja kui see keegi lõpetab, kuid tahab kohe uuesti süüa, läheb ta blokeeringusse ja äratab oma naabreid, kuna tema ooteaeg on lühem.

Nii me vältime deadlock'e ja mõne filosoofi nälgimist. Kasutame lühikese ootamise jaoks tsüklit ja blokeerime voogude jaoks pikka. Kõigi kohene vabastamine töötab aeglasemalt, kui kui vabastada ainult naaber, nagu lahenduses AutoResetEvent, kuid erinevus ei tohiks olla suur, kuna vood peavad esmalt jääma kasutaja režiimi.

U lock süntaksil on ebameeldivaid üllatusi. Soovitavad kasutada Monitor otse [Rihter] [Erik Lippert]. Üks neist on see, et lock alati väljub Monitor, isegi kui oli erand, ja siis võib teine voog muuta jagatud mäluseisundit. Sellistes olukordades on sageli parem minna deadlock'i või kuidagi turvaliselt programmi lõpetada. Teine üllatus on see, et Monitor kasutab sünkroonimist (SyncBlock), mis on kõigis objektides. Seetõttu, kui valitakse vale objekt, võib kergesti tekkida ummik (näiteks kui lukustada interniseeritud string). Kasutame alati selle jaoks varjatud objekti.

Condition Variable korralduse mudel võimaldab lühemalt realiseerida ooteolukorda mingi keerulise tingimuse jaoks. .NETis on see minu arvates puudulik, sest seal peaks olema mitu järjekorda mitmete muutujate jaoks (nagu Posix Threads'is), mitte ühe luku kohta. Siis saaks neid teha kõigi filosoofide jaoks. Kuid isegi sellisena võimaldab see koodi lühendada.

Palju filosoofe või async / await

Nüüd oskame tõhusalt vooge blokeerida. Aga mis juhtub, kui meil on palju filosoofe? 100? 10 000? Oletame, et me saime 100 000 päringut veebiserverisse. Iga päringu jaoks teise joone loomine on kulukas, kuna nii suuri jooni ei saa samaaegselt käitada. Käitatakse ainult nii palju, kui on loogilisi tuuma (minu arvutis on neid 4). Kõik ülejäänud võtavad lihtsalt ressursse. Üks lahendus sellele probleemile on async/await mustrid. Selle idee on see, et funktsioon ei hoia joont, kui midagi tuleb oodata. Ja kui see midagi juhtub, jätkab see oma täitmist (aga mitte tingimata samas joones!). Meie puhul ootame haru.

SemaphoreSlim selleks on WaitAsync() meetod. Siin on rakendus selle mustriga.

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

Meetod async / await tõlgitakse nutikaks lõppautomaadiks, mis tagastab kohe oma sisemise Ülesanne. Selle kaudu saab oodata meetodi lõpetamist, tühistada selle ja teha kõike muud, mida saab Task'iga teha. Meetodi sees kontrollib lõppautomaat täitmist. Asi on selles, et kui viivitust pole, siis toimub täitmine sünkroonselt, ja kui viivitus on, siis vabastatakse niit. Selle parema mõistmise jaoks on parem vaadata seda lõppautomaat. Neist saab luua ahelaid. async / await meetoditest.

Testime. 100 filosoofi töö 4 loogilisel tuumal, 8 sekundi jooksul. Eelmine lahendus Monitoriga täitis vaid 4 esimest niiti, teised ei täitunud üldse. Igaüks neist 4 niidist seisis umbes 2 ms. Lahendus async/await täitis aga kõik 100, samal ajal kui keskmiselt igaüks ootas 6.8 sekundit. Muidugi, reaalsetes süsteemides on 6 sekundi seismine vastuvõetamatu ja selliste päringute töötlemine pole parem. Lahendus Monitoriga osutus üldse mitte skaleeritavaks.

Kokkuvõte

Nagu nendest väikestest näidetest näha, toetab .NET palju sünkroneerimise konstruktsioone. Kuid mõnikord ei ole selge, kuidas neid kasutada. Loodan, et see artikkel on olnud kasulik. Kuid lõpetame sellega, aga veel on palju huvitavat, nagu thread-safe kogud, TPL Dataflow, reaktiivne programmeerimine, tarkvara tehingute mudel jne.

Allikad

Allikas: habr.com

Osta usaldusväärne veebihosting DDoS kaitsega, VPS VDS serverid 🔥 Osta usaldusväärne veebihosting DDoS kaitsega, VPS VDS serverid | ProHoster