Filozofët e ngopur ose programimi konkurrent në .NET

Filozofët e ngopur ose programimi konkurrent në .NET

Le të shohim se si funksionon programimi konkurent dhe paralel në .Net, përmes shembullit të problemeve të filozofëve që hanë. Plani është ky: nga sinkronizimi i rrjedhave/proceseve, deri te modeli i aktorëve (në pjesët e ardhshme). Ky artikull mund të jetë i dobishëm për një njohje të parë apo për përrefreshimin e njohurive tuaja.

Pse është e nevojshme të dimë këtë? Transistorët po arrijnë madhësinë e tyre më minimale, ligji i Murit po përballlet me kufizimin e shpejtësisë së dritës, prandaj rritja po vërehet në numrin e transistorëve, mund të bëjmë më shumë. Ndërkohë, sasia e të dhënave po rritet dhe përdoruesit presin një reagim të menjëhershëm nga sistemet. Në një situatë të tillë, programimi 'i zakonshëm', ku kemi një rrjedhë ekzekutimi, nuk është më efikas. Duhet ndonjë mënyrë për të zgjidhur problemin e ekzekutimit të përbashkët ose konkurent. Kjo është një çështje që ekziston në nivele të ndryshme: në nivelin e rrjedhave, në nivelin e proceseve, në nivelin e makinave në rrjet (sisteme të shpërndara). Në .NET ka teknologji të cilësisë, të provuara me kalimin e kohës, për zgjidhjen e shpejtë dhe efektive të këtyre problemeve.

Detyra

Edsgar Dijkstra e ngriti këtë problem për studentët e tij në vitin 1965. Formulimi i njohur është si më poshtë. Ka një numër të caktuar (zakonisht pesë) filozofësh dhe ka po aq pirunë. Ata ulën në një tavolinë të rrumbullakët, me pirunë midis tyre. Filozofët mund të hanë nga pjatat e tyre me ushqim të pafund, të mendojnë ose të presin. Për të ngrënë, filozofi duhet të marrë dy pirunë (të fundit ndan një pirun me të parin). Të marrësh dhe të vendosësh një pirun është dy veprime të ndara. Të gjithë filozofët janë të heshtur. Problemi është të gjejmë një algoritëm që të gjithë ata të mendojnë dhe të jenë të ngopur, madje edhe pas 54 vjetësh.

Fillimisht do të përpiqemi ta zgjidhim këtë problem duke përdorur hapësirë të përbashkët. Pirunët janë në tryezën e përbashkët dhe filozofët thjesht i marrin kur kanë nevojë dhe i kthejnë përsëri. Këtu lindin probleme me sinkronizimin, kur saktësisht duhet të marrim pirunët? Çfarë duhet të bëjmë nëse nuk ka pirunj? etj. Por fillimisht le të aktivizojmë filozofët.

Për të nisur rrjedhat do të përdorim një grup rrjedhash nëpërmjet Task.Run metodës:

var cancelTokenSource = new CancellationTokenSource();
Action<int> create = (i) => RunPhilosopher(i, cancelTokenSource.Token);
for (int i = 0; i < philosophersAmount; i++) 
{
    int icopy = i;
    // Vendosni detyrën në radhën e grupit të thjeshtësisë. Metoda RunDeadlock nuk ekzekutohet 
    // menjëherë, por pret për threadin e saj. Ekzekutimi asinkron.
    philosophers[i] = Task.Run(() => create(icopy), cancelTokenSource.Token);
}

Grupi i thjeshtësisë është krijuar për të optimizuar krijimin dhe fshirjen e thjeshtësive. Ky grup ka një radhë me detyra dhe CLR krijon ose fshin thjeshtësi në varësi të numrit të këtyre detyrave. Një grup për të gjitha AppDomain’t. Ky grup duhet të përdoret pothuajse gjithmonë, pasi nuk ka nevojë të shqetësoheni për krijimin, fshirjen e thjeshtësive, radhët e tyre etj. Mund të kaloni pa grup, por atëherë do të duhet ta përdorni drejtpërdrejt Thread, është e arsyeshme për raste kur duhet të ndryshoni prioritetin e thjeshtësisë, kur kemi një operacion të gjatë, për thjeshtësinë e Parë dhe të tjerë.

Me fjalë të tjera, System.Threading.Tasks.Task klasa është e njëjta Thread, por me gjitha komoditetet: mundësia për të ekzekutuar një detyrë pas grupit të detyrave të tjera, t’i rikthejmë ato nga funksionet, t’i ndërpresim lehtësisht dhe shumë të tjera. Ato janë të nevojshme për mbështetje të konstruktave async/await (Task-based Asynchronous Pattern, sintaksë e thjeshtë për të pritur operacionet IO). Këtë do ta diskutojmë më vonë.

CancelationTokenSource këtu është e nevojshme, për t'i dhënë mundësi thread-it të përfundojë vetë me një sinjal nga thread-i që e thërret.

Problemet me sinkronizimin

Filozofët e ngjitur

Mirë, ne dimë të krijojmë thread-e, le të përpiqemi të hamë:

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

Këtu ne fillimisht provojmë të marrim pirunat e majtë dhe pastaj ato të djathtët, dhe nëse ia arrijmë, hamë dhe i kthejmë përsëri. Marrja e një piruni është atomike, domethënë, dy thread-e nuk mund të marrin të njëjtin pirun në të njëjtën kohë (nuk është e saktë: e para lexon që piruni është i lirë, e dyta - gjithashtu, e para merr, e dyta merr). Për këtë Interlocked.CompareExchange, i cili duhet të realizohet përmes instruksionit të procesorit (TSL, XCHG), i cili bllokon një seksion të memories për lexim dhe shkrim atomik dhe të rendit. SpinWait është ekuivalente me strukturën while(true) vetëm me pak ‘magji’ - thread-i merr procesorin (Thread.SpinWait), por herë pas here i jep mundësi thread-it tjetër (Thread.Yield) ose bie në gjumë (Thread.Sleep).

Por kjo zgjidhje nuk funksionon, sepse rrjedhjet shumë shpejt (në sekondën time) bllokohen: të gjithë filozofët marrin pirunat e tyre të majtë, por jo atë të djathtin. Ndërsa vargu i forks ka vlera: 1 2 3 4 5.

Filozofët e ngopur ose programimi konkurrent në .NET

Në figurë, bllokimi i rrjedhave (deadlock). Me ngjyrë të gjelbër është ekzekutimi, me të kuqe - sinkronizimi, me gri - rrjedha është në gjumë. Në rombët është treguar koha e fillimit të Task'ave.

Holësia e filozofëve

Megjithëse për të menduar nuk nevojitet shumë ushqim, holësia mund të bëjë që këdo të heqë dorë nga filozofia. Të përpiqemi të simullojmë situatën e holësisë së rrjedhave në detyrën tonë. Holësia është kur një rrjedhë punon, por pa punë të rëndësishme, me fjalë të tjera është e njëjta gjë me deadlock, por tani rrjedha nuk është në gjumë, por aktivisht po kërkon si të hajë, por ushqim nuk ka. Për të shmangur bllokimin e shpeshtë, do ta vendosim pirunin mbrapsht nëse nuk arritëm të marrim një tjetër.

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

Në këtë kod është e rëndësishme se dy nga katër filozofët harrojnë të vendosin what fork e tyre të majtë. Kështu, ata hanë më shumë ushqim, ndërsa të tjerët fillojnë të uriten, megjithëse gjithnjë kanë të njëjtin prioritet. Këtu ata nuk janë krejtësisht në urie, pasi filozofët e këqij vendosin ndonjëherë pirunët e tyre përsëri. Më duket se të mirët hanë diku rreth 5 herë më pak se të këqinjtë. Pra, një gabim i vogël në kod çon në rënie në performancë. Gjithashtu, duhet të theksoj se ekziston një situatë e rrallë kur të gjithë filozofët marrin pirunin e majtë, nuk ka të djathtin, ata vendosin të majtin, prisni, përsëri marrin të majtin etj. Kjo situatë është gjithashtu urie, më shumë e ngjashme me bllokimin e ndërsjellë. Nuk kam arritur ta përsëris atë. Më poshtë është një imazh për situatën kur dy filozofë të këqij morën të dy pirunët, ndërsa dy të mirë po uriten.

Filozofët e ngopur ose programimi konkurrent në .NET

Këtu duket se rrjedhat ngrihen ndonjëherë dhe përpiqen të marrin burimin. Dy bërthama nga katër nuk bëjnë asgjë (grafiku i gjelbër lart).

Vdekja e filozofit

Dhe një problem tjetër që mund të ndërpresë drekën e filozofëve është nëse një prej tyre papritur vdes me pirun në duar (dhe ashtu do të varroset). Pastaj, fqinjët do të mbeten pa drekë. Një shembull kodi për këtë rast mund ta shpikni vetë, për shembull, hidhet NullReferenceException pas kësaj, kur filozofi merr pirunët. Dhe, ndër të tjera, përjashtimi nuk do të përpunohet dhe kodi që e thërret nuk do ta kapë thjesht ashtu (për këtë AppDomain.CurrentDomain.UnhandledException etj.). Prandaj, trajtuesit e gabimeve janë të nevojshëm në vetë fletët dhe me përfundim të saktë.

Garsoni

Mirë, si ta zgjidhim këtë problem me bllokimet e ndërsjella, urinë dhe vdekjet? Do të lejojmë vetëm një filozof të marrë pirunët, do të shtojmë përjashtimin e ndërsjellë (mutual exclusion) të fletëve për këtë vend. Si ta bëjmë këtë garson dhe si do të kërkojnë filozofët prej tij, këto janë pyetje interesante.

Një mënyrë e thjeshtë është kur filozofët vazhdimisht kërkojnë nga kamarieri të japë akses në pirun. Që do të thotë, tani filozofët nuk do të presin që piruni të jetë afër, por do të presin ose do të kërkojnë kamarierin. Fillimisht, do ta përdorim vetëm Hapësirën e Përdoruesit, ku nuk do të përdorim ndërprerje për të thirrur ndonjë procedurë nga bërthama (për të cilat do të flasim më poshtë).

Zgjidhjet në hapësirën e përdoruesit

Këtu do të bëjmë po atë që bëmë më parë me një pirun dhe dy filozofë, duke u rrotulluar në një cikël dhe duke pritur. Por tani do të jenë të gjithë filozofët dhe siç është, vetëm një pirun, pra, mund të thuhet se do të hajë vetëm ai filozof që merr këtë 'pirun të artë' nga kamarieri. Për këtë do të përdorim SpinLock.

private static SpinLock spinLock = new SpinLock();  // “Kelneri” ynë
private void RunSpinLock(int i, CancellationToken token)
{
    while (true)
    {
        // Ndërprerja e ndërsjellë përmes pritjes aktive. Thërrasim deri në prova, që
        // të hedhim përjashtim në rast gabimi në vetë SpinLock.
        bool hasLock = false;
        spinLock.Enter(ref hasLock);
        try
        {
            // Këtu mund të jetë vetëm një rrjedhë (përjashtimi mutu).
            forks[Left(i)] = i + 1;  // Marrim pirunin menjëherë, pa pritje.
            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();  // Shmangim problemin e vdekjes së filozofit.
        }

        Think(i);

        if (token.IsCancellationRequested)
            break;
    }
}

SpinLock është një bllokues, me, thënë të drejtën, të njëjtin while(true) { if (!lock) break; }, por me një “magji” edhe më të madhe se në SpinWait (i cili përdoret atje). Tani ai di të numërojë pritësit, të përkushtojë pak nga ata dhe shumë të tjera. Në përgjithësi, bën gjithçka të mundshme për optimizim. Por duhet kujtuar se ky është ende një cikël aktiv që përdor burime të CPU-së dhe mban një rrjedhë, që mund të çojë në urinë nëse një nga filozofët bëhet prioritar ndaj të tjerëve, por s’ka pirun ari (problemi i inverzionit të prioritetit). Prandaj, e përdorim vetëm për ndryshime shumë, shumë të shkurtra në memorien e përbashkët, pa ndonjë thirrje të jashtme, bllokime të përfshira dhe surpriza të tjera.

Filozofët e ngopur ose programimi konkurrent në .NET

Ilustrimi për SpinLock. Rrymat vazhdimisht “luftojnë” për pirunin e artë. Ka raste dështimi - në ilustrim është e shënuar zona. Bërthamat nuk përdoren plotësisht: vetëm rreth 2/3 nga këto katër rrjedha.

Një zgjidhje tjetër këtu do të ishte të përdorim vetëm Interlocked.CompareExchange me të njëjtin pritje aktive, siç është treguar në kodin më sipër (në filozofët e uritur), por kjo, siç është theksuar më parë, teoretikisht mund të çojë në bllokim.

Për Interlocked duhet thënë se aty nuk është vetëm CompareExchange, por dhe metoda të tjera për lexim dhe shkruar atomar. Nëpërmjet ndryshimeve të ripërsëritura në rast se një rrjedh tjetër arrin të bëjë ndryshime të veta (lexim 1, lexim 2, shkruar 2, shkruar 1 e keqe), mund të përdoret për ndryshime të komplikuara të një vlera (Interlocked Anything pattern).

Zgjidhjet në modalitetin e bërthamës

Për të shmangur humbjen e burimeve në cikël, le të shikojmë se si mund të bllokojmë rrjedhën. Me fjalë të tjera, duke vazhduar shembullin tonë, le të shikojmë se si garçoni do të flijojë filozofin dhe ta zgjojë atë vetëm kur është e nevojshme. Fillimisht të shikojmë se si ta bëjmë këtë nëpërmjet modalitetit të bërthamës së sistemit operativ. Të gjitha strukturat atje shpesh rezultojnë të jenë më të ngadalta se ato në hapësirën e përdoruesit. Më të ngadalta disa herë, për shembull AutoResetEvent mund të jetë 53 herë më i ngadaltë SpinLock [Rihter]. Por me ndihmën e tyre mund të sinkronizoni procese në të gjithë sistemin, të menaxhuara ose jo.

Struktura kryesore këtu është semafori, i propozuar nga Dijkstra më shumë se pesëdhjetë vjet më parë. Semafori është, thjesht, një numër i plotë pozitiv, i menaxhuar nga sistemi, dhe dy operacione mbi të, - rritja dhe reduktimi. Nëse reduktimi nuk është i mundur, është zero, atëherë rrjedha e thirrjes bllokohet. Kur numri rritet nga ndonjë rrjedhë/proces tjetër aktiv, atëherë rrjedhat lejohet të kalojnë, dhe semafori përsëri ulet me numrin e kalimeve. Mund të imagjinohet si trenat në një vend të ngushtë me një semafor. .NET ofron disa struktura me funksione të ngjashme: AutoResetEvent, ManualResetEvent, Mutex dhe vetë Semaphore. Ne do ta përdorim AutoResetEvent, kjo është struktura më e thjeshtë mes këtyre: vetëm dy vlera 0 dhe 1 (false, true). Metoda e saj WaitOne() bllokon rrjedhën e thirrjes nëse vlera ka qenë 0, dhe nëse është 1, atëherë e ul në 0 dhe e lejon atë të kalojë. Dhe metoda Set() e rrit në 1 dhe lejon një të pritur, i cili përsëri e ul në 0. Funksionon si një bileta në metronë.

Do ta e bëjmë më të komplikuar zgjidhjen dhe do të përdorim bllokimin për çdo filozof, dhe jo për të gjithë njëkohësisht. Pra, tani disa filozofë mund të jenë në vend në të njëjtën kohë, jo vetëm një. Por përsëri, ne bllokojmë qasjen në tavolinë, për të marrë pirunat në mënyrë korrekte, duke shmangur garat (race conditions).

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

Për të kuptuar se çfarë po ndodh këtu, le të shqyrtojmë rastin kur filozofi nuk e ka arritur të marrë pirunat, atëherë veprimet e tij do të jenë këto. Ai pret për qasje në tavolinë. Pas marrjes së saj, ai provon të marrë pirunat. Nuk ia del. Ai i kthen qasjen tavolinës (ndërprerje e ndërsjellë). Dhe kalon në "turniket" e tij (AutoResetEvent) (në fillim ato janë të hapura). Kthehet sërish në cikël, sepse nuk ka pirunat. Provon t'i marrë ata dhe ndalet në "turniket" e tij. Ndonjë fqinj më i suksesshëm nga e djathta ose e majta, pasi ka përfunduar të hashë, çliron filozofin tonë, "duke hapur turniketin" e tij. Filozofi ynë kalon atë (dhe ai mbyllet pas tij) për herë të dytë. Provon për herë të tretë të marrë pirunat. Ja ku është edhe fat. Dhe kalon turniketin e tij, për të ngrënë drekë.

Kur në një kod të tillë do të ketë gabime rastësore (ato gjithmonë ndodhin), për shembull, fqinj i caktuar gabimisht ose të krijojë një objekt të njëjtë AutoResetEvent për të gjithë (Enumerable.Repeat), atëherë filozofët do të presin për zhvilluesit, sepse gjetja e gabimeve në një kod të tillë është një aktivitet mjaft i vështirë. Një problem tjetër me këtë zgjidhje është se ajo nuk garanton që ndonjë filozof nuk do të fillojë të vdesë nga uria.

Zgjidhje hibrid

Ne kemi shqyrtuar dy qasje për sinkronizimin, kur ne qëndrojmë në modalitetin e përdoruesit dhe rrotullohemi në cikël dhe, kur ne bllokojmë rrjedhën përmes bërthamës. Metoda e parë është e mira për bllokime të shkurtra, e dyta për ato të gjata. Shpesh kërkohet që së pari të presim shkurtazi për ndryshimin e një variabli në cikël, dhe pastaj të bllokojmë rrjedhën kur pritja është e gjatë. Kjo qasje është e realizuar në ashtuquajturat konstruksione hibride. Këtu ka të njëjtat konstruksione që ishin për modalitetin e bërthamor, por tani me një cikël në modalitetin e përdoruesit: SemaphorSlim, ManualResetEventSlim etj. Konstruksioni më i njohur këtu është Monitor, sepse në C# ekziston një lock sintaksë. Monitor ky është ai semafor me vlerë maksimale 1 (mjuteks), por me mbështetje për pritje në cikël, rekursivitet, dhe modelin e Condition Variable (për atë më poshtë) etj. Le të shohim një zgjidhje me të.

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

Këtu përsëri bllokojmë të gjithë tryezën për akses në pirun, por tani ne çlirojnë të gjitha menjëherë rrjedhat, dhe jo fqinjët, kur dikush përfundon së ngrëni. Domethënë, së pari, dikush hante dhe bllokonte fqinjët, dhe kur ai dikush e përfundon, por dëshiron të hajë përsëri menjëherë, ai shkon në bllokim dhe zgjon fqinjët e tij, pasi koha e tij e pritjes është më e shkurtër.

Kështu shmangim deadlock-et dhe urinë e ndonjë filozofi. Përdorim një cikël për pritje të shkurtër dhe bllokojmë rrjedhën për të gjatë. Çlirimi i menjëhershëm i të gjithëve punon më ngadalë sesa nëse do të ishte çlirimi vetëm i fqinjit, si në zgjidhjen me AutoResetEvent, por diferenca nuk duhet të jetë e madhe, pasi rrjedhat duhet të qëndrojnë në modin e përdoruesit fillimisht.

Tek lock sintaksë ka surpriza të pakëndshme. Rekomandohet të përdorim Monitor direkt [Richter] [Eric Lippert]. Një nga to është se lock përherë del nga Monitor, madje edhe nëse kishte një përjashtim, dhe atëherë rrjedha tjetër mund të ndryshojë gjendjen e memories së përbashkët. Në këto raste shpesh është më mirë të shkonim në deadlock ose në një mënyrë të sigurt për të përfunduar programin. Një surprizë tjetër është se Monitor përdor bllokime sinkronizimi (SyncBlock), të cilat janë në të gjitha objektet. Prandaj, nëse zgjidhet një objekt i papërshtatshëm, mund të shkaktohet një bllokim (p.sh., nëse bëhet një bllokim në një string të interneruar). Përdorim gjithmonë një objekt të fshehtë për këtë.

Motivi i Varietetit të Kushteve lejon të implementohet më shkurt pritja për ndonjë kush të komplikuar. Në .NET ai është i paplotë, sipas mendimit tim, sepse në parim aty duhet të ketë disa radhë mbi disa variabla (si në Posix Threads), e jo mbi një bllokim. Atëherë mund të krijoheshin për të gjithë filozofët. Por edhe në këtë formë ai lejon të shkurtosh kodin.

Shumë filozofë ose async / await

Mirë, tani dimë si të bllokojmë efektivisht flukset. Por, çfarë ndodh nëse kemi shumë filozofë? 100? 10000? Për shembull, nëse marrim 100000 kërkesa në serverin web. Krijimi i një thread për çdo kërkesë do të ishte i kushtueshëm, pasi aq shumë thread-e nuk do të përpunoheshin paralelisht. Do të ekzekutoheshin vetëm aq sa ka bërthama logjike (unë kam 4). Të tjerët thjesht do të konsumonin burime. Një nga zgjidhjet për këtë problem është modeli async / await. Ideja është që funksioni nuk mbajti thread-in, nëse për vazhdimin e tij nevojitet të presim diçka. Kur kjo ndodhi, ai rifilloi ekzekutimin e tij (por jo domosdoshmërisht në të njëjtin thread!). Në rastin tonë, ne do të presim për vilat.

SemaphoreSlim ka për këtë WaitAsync() metodën. Ja implementimi me përdorimin e këtij modeli.

// Запуск такой же, как раньше. Где-нибудь в программе:
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 me async / await translatohet në një automatik të sofistikuar, i cili kthehet menjëherë në brendësinë e tij Detyrë. Me për të pritur përfundimin e metodës, për ta anuluar atë dhe gjithçka tjetër që mund të bëhet me Task. Brenda metodës, automatizmi i fundit kontrollon ekzekutimin. Thelbi është se, nëse nuk ka vonesë, atëherë ekzekutimi është sinkron, ndërsa nëse ka, ato lirohen. Për ta kuptuar më mirë këtë është më mirë të shihni këtë automatizëm përfundimtar. Mund të krijoni zinxhirë nga këto async / await metoda.

Le ta testojmë. Puna e 100 filozofëve në një makinë me 4 bërthama logjike, 8 sekonda. Zgjidhja e mëparshme me Monitor executonte vetëm 4 rrjedha të para, ndërsa të tjerat nuk u ekzekutuan fare. Çdo një nga këto 4 rrjedha kishte një ndalesë prej rreth 2ms. Ndërsa zgjidhja me async / await ekzekutoi të gjitha 100, ku çdo njëra priti mesatarisht 6.8 sekonda. Sigurisht, në sisteme reale, ndalesa prej 6 sekondash është e papranueshme dhe është më mirë të mos përpunoni aq shumë kërkesa kështu. Zgjidhja me Monitor doli se nuk ishte e shkallëzueshme fare.

Përfundimi

Siç duket nga këto shembuj të vogla, .NET mbështet shumë konstrukcione për sinkronizim. Megjithatë, nuk është gjithmonë e qartë si t'i përdorim ato. Shpresoj se ky artikull ka qenë i dobishëm. Për momentin, e përfundojmë këtu, por ka ende shumë gjëra interesante, si koleksionet e sigurta për gjurmët, TPL Dataflow, programimi reaktiv, modeli i Transaksioneve Softuerike dhe të tjera.

Burimet

Burimi: habr.com

Bleni hostim të besueshëm për faqe me mbrojtje nga DDoS, serverë VPS VDS 🔥 Bleni hostim të besueshëm për faqe me mbrojtje nga DDoS, serverë VPS VDS | ProHoster