Filozofë të ngopur ose programim konkurrues në .NET

Filozofë të ngopur ose programim konkurrues në .NET

Le të shohim se si funksionon programimi konkurent dhe paralel në .Net, duke marrë si shembull problemin e filozofëve që hanë. Plani është nga sinkronizimi i thread-eve/procesëve deri te modeli i aktorëve (në pjesët në vijim). Artikulli mund të jetë i dobishëm për njohjen e parë ose për të rifreskuar njohuritë tuaja.

Pse duhet ta dimë këtë? Transistorët po arrijnë madhësinë e tyre minimale, ligji i Murës përballet me kufizimin e shpejtësisë së dritës dhe prandaj rritja po vërehet në numrin e transistorëve që mund të prodhohen. 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", kur kemi vetëm një thread ekzekutues, nuk është më efikas. Duhet të zgjidhemi si të trajtojmë problemin e ekzekutimit të njëkohshëm ose konkurent. Ky problem ekziston në gjithçka: në nivelet e thread-eve, në nivelet e proceseve, në nivelet e makinave në rrjet (sisteme të shpërndara). Në .NET ka teknologji cilësore, të provuara në kohë, për të zgjidhur këto çështje shpejt dhe eficient.

Detyra

Edsger Dijkstra e paraqiti këtë problem për nxënësit e tij që në vitin 1965. Formulimi i pranuar është ky: Ekziston një numër (zakonisht pesë) filozofësh dhe po aq pirunash. Ata ulen rreth një tryeze të rrumbullakët, me pirunat 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ë, një filozof duhet të marrë dy piruna (filozofi i fundit ndan një pirun me të parin). Të marrë dhe të vendosë një pirun - janë dy veprime të ndara. Të gjithë filozofët janë në heshtje. Detyra është të gjejmë një algoritëm të tillë që të gjithë ata të mendojnë dhe të jenë të ngopur, edhe pas 54 vitesh.

Fillimisht do të përpiqemi ta zgjidhim këtë problem nëpërmjet përdorimit të hapësirës së ndarë. Pirunat ndodhen në tryezën e përbashkët dhe filozofët i marrin kur janë aty, dhe i kthejnë prapa. Këtu dalin probleme me sinkronizimin, kur është momenti i duhur për të marrë pirunat? Çfarë duhet të bëjmë nëse nuk ka piruna? etj. Por së pari, le të nisim filozofët.

Për të nisur thread-at, përdorim një pool thread-ash përmes Task.Run metodës:

var cancelTokenSource = new CancellationTokenSource();
Action create = (i) => RunPhilosopher(i, cancelTokenSource.Token);
for (int i = 0; i  create(icopy), cancelTokenSource.Token);
}

Grupi i_threads është krijuar për optimizimin e krijimit dhe fshirjes së rrjedhave. Ky grup ka një radhë me detyra dhe CLR krijon ose fshin rrjedha në vartësi të numrit të këtyre detyrave. Një grup për të gjitha AppDomain-et. Ky grup duhet përdorur pothuajse gjithmonë, pasi nuk është nevoja për të u angazhuar me krijimin, fshirjen e rrjedhave, radhët e tyre etj. Mund të funksiononi edhe pa grup, por atëherë do të duhet të përdorni drejtpërdrejt. Rrjedha, është e arsyeshme për raste kur është e nevojshme të ndryshohet prioriteti i një rrjedhe, kur kemi një operacion të gjatë, për rrjedha në Foreground etj.

Në fjalë të tjera, System.Threading.Tasks.Task klasa është e njëjtë me Rrjedha, por me disa komoditete: mundësia për të nisur detyrën pas një grupi të tjera detyrash, t'i kthesh ato nga funksionet, t'i ndërpresësh lehtësisht dhe shumë të tjera. Ato janë të nevojshme për mbështetje të konstruksioneve async/await (Task-based Asynchronous Pattern, sintaksë e lehtë për të pritur operacione IO). Këtë do ta diskutojmë më tej.

CancelationTokenSource këtu është e nevojshme, në mënyrë që rrjedha të mund të përfundojë vetë me sinjalin e rrjedhës që e thërret.

Problemet me sinkronizimin

Filozofët e bllokuar

Mirë, ne dimë të krijojmë rrjedha, le të provojmë të kemi një drekë:

// Кто какие вилки взял. К примеру: 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 së pari përpiqemi të marrim pirunin e majtë, dhe më pas të djathtin, dhe nëse ia dalim, hamë dhe i kthejmë ato prapa. Marrja e një piruni është atomike, dmth. dy rrjedha nuk mund të marrin një të vetëm në të njëjtën kohë (nuk është e vërtetë: e para lexon se piruni është i lirë, e dyta po ashtu, e para merr, e dyta merr). Për këtë, Interlocked.CompareExchange, i cili duhet të realizohet me ndihmën e instrukciones së procesorit (TSL, XCHG), e cila bllokon një zonë kujtese për lexim dhe shkrim atomik. Dhe SpinWait është ekuivalente me konstruksionin while(true) në mënyrë, me një "magji" të vogël — rrjedha merr procesorin (Thread.SpinWait), por ndonjëherë ia kalon kontrollin një rrjedhe tjetër (Thread.Yeild) ose fle (Thread.Sleep).

Por kjo zgjidhje nuk funksionon, pasi rrjedhat shpejt (tek unë brenda një sekonde) bllokohen: të gjithë filozofët marrin pirunin e majtë, por të djathtin jo. Arrë forks atëherë ka vlera: 1 2 3 4 5.

Filozofë të ngopur ose programim konkurrues në .NET

Në diagram, bllokimi i rrjedhave (deadlock). Në ngjyrë jeshile — ekzekutimi, në të kuqe — sinkronizimi, në gri — rrjedha fle. Rombojnë shënojnë kohën e nisjes së Task-ëve.

Uria e filozofëve

Megjithëse për të menduar nuk është e nevojshme shumë ushqim, uria mund të detyrojë çdo njeri të heqë dorë nga filozofia. Le të përpiqemi të modellojmë situatën e urisë së rrjedhave në detyrën tonë. Uria është kur një rrjedhë punon, por pa punë të rëndësishme; në fjalë të tjera, është si një bllokim, por tani rrjedha nuk fle, ajo aktivisht kërkon se si të ha, por nuk ka ushqim. Për të shmangur bllokimin e shpeshtë, do të vendosim pirunat prapa, nëse nuk arrijmë 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, e rëndësishme është se dy nga katër filozofët harrojnë të vendosin pirunat e tyre të majtë. Dhe kështu, ata hanë më shumë, ndërsa të tjerët fillojnë të uritën, megjithëse rrjedhat kanë prioritet të njëjtë. Këtu ata nuk po uriten plotësisht, pasi filozofët e këqij ndonjëherë i vendosin pirunat prapa. Më rezulton se të mirët hanë rreth 5 herë më pak se të këqijtë. Pra, një gabim i vogël në kod shkakton një rënie të performancës. Gjithashtu, vlen të përmendet se ekziston një situatë e rrallë, kur të gjithë filozofët marrin pirunin e majtë, por nuk kanë të djathtin; ata vendosin të majtin, presin, pastaj marrin përsëri të majtin, etj. Kjo situatë është gjithashtu uri, më shumë si një bllokim mutuar. Nuk arrita ta riprodhoj. Më poshtë është një figurë për situatën kur dy filozofë të këqij morën të dy pirunat, ndërsa dy të mirët po uriten.

Filozofë të ngopur ose programim konkurrues në .NET

Këtu shihet se rrjedhat zgjedhin ndonjëherë dhe provojnë të marrin burime. Dy bërthama nga katër nuk bëjnë asgjë (grafiku i gjelbër në krye).

Vdekja e filozofit

Në mënyrë të ngjashme, një tjetër problem që mund të ndërpresë drekën e lavdishme të filozofëve është nëse një nga ata papritmas vdes me pirunat në duar (dhe ashtu do të varroset). Atëherë fqinjët do të mbesin pa drekë. Shembujt e kodit për këtë rast mund t'i shpikni vetë, për shembull, hidhet NullReferenceException pasi filozofi merr pirunat. Dhe, për të thënë të drejtën, përjashtimi do të mbetet i pa trajtuar dhe kodi që e thirri atë nuk do ta kapë atë (për këtë AppDomain.CurrentDomain.UnhandledException etj.). Prandaj, trajtuesit e gabimeve janë të nevojshëm brenda vetë rrjedhave dhe me përfundimin e duhur.

Garsoni

Mirë, si mund ta zgjidhim këtë problem me bllokime të ndërsjella, urie dhe vdekje? Do të lejojmë vetëm një filozof të aksesojë pirunat, do të shtojmë përjashtim të ndërsjellë për këtë vend. Si ta realizojmë këtë? Supozoni se pranë filozofëve ndodhet një kamarier që jep leje për të lejuar një filozof të marrë pirunat. Si ta krijojmë këtë kamarier dhe si do të kërkojnë filozofët prej tij, pyetjet janë interesante.

Mënyra më e thjeshtë është që filozofët thjesht të kërkojnë vazhdimisht nga kamarieri që t'u japë qasje në pirun. Domethënë, tani filozofët nuk do të presin që të kenë pirun pranë, përkundrazi, do të presin ose kërkojnë nga kamarieri. Fillimisht, do ta përdorim këtë vetëm në Hapësirën e Përdoruesit, aty nuk përdorim ndërprerje për të thirrur ndonjë procedurë nga bërthama (për to më poshtë).

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

Këtu do të bëjmë gjithçka ashtu siç bëmë më herët me një pirun dhe dy filozofë, do të rrotullohemi në cikël dhe do të presim. Por tani do të jenë të gjithë filozofët dhe siç duket do të ketë vetëm një pirun, domethënë do të thoshim se do të hahet vetëm nga filozofi që merr këtë "pirun të arit" nga kamarieri. Për këtë do të përdorim SpinLock.

private static SpinLock spinLock = new SpinLock();  // Kamarieri ynë
private void RunSpinLock(int i, CancellationToken token)
{
    while (true)
    {
        // Bllokim i ndërsjellë përmes pritjes. Thërrasim deri te provim, që
        // të hedhin një përjashtim në rast se ndodhi ndonjë gabim në SpinLock.
        bool hasLock = false;
        spinLock.Enter(ref hasLock);
        try
        {
            // Këtu mund të ketë vetëm një rrjedhë (përjashtim i ndërsjellë).
            forks[Left(i)] = i + 1;  // Marrim pirunin menjëherë, pa pritur.
            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();  // Parandalojmë problemin me vdekjen e filozofit.
        }

        Think(i);

        if (token.IsCancellationRequested)
            break;
    }
}

SpinLock është një bllokues, me fjalë të tjera, me të njëjtin while(true) { if (!lock) break; }, por me "magji" edhe më të madhe se në SpinWait (i cili përdoret aty). Tani ai di të numërojë ata që presin, t’i dremitë pak dhe shumë më tepër. Në përgjithësi, bën gjithçka të mundur për optimizimin. Por duhet të kujtojmë se ky është gjithashtu një cikël aktiv, i cili konsumon burimet e procesorit dhe mbush një rrjedh që mund të çojë në urinë, nëse një nga filozofët bëhet prioritar ndaj të tjerëve, por nuk ka pirun të artë (problemi i Inversionit të Prioritetit). Prandaj, e përdorim vetëm për ndryshime shumë, shumë të shkurtra në memorien e përgjithshme, pa thirrje nga jashtë, bllokime të thelluara dhe surpriza të tjera.

Filozofë të ngopur ose programim konkurrues në .NET

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

Një tjetër zgjidhje këtu do të ishte të përdorim vetëm Interlocked.CompareExchange me pritje aktive, siç e tregon kodi më sipër (në filozofët që kanë uri), por, siç u tha më parë, teorikisht mund të çojë në bllokim.

Për Interlocked duhet thënë se atje nuk ka vetëm CompareExchange, por edhe metode të tjera për lexim dhe shkrim atomik. Dhe përmes përsëritjes së ndryshimeve në rast se rrjedha tjetër arrin të bëjë ndryshimet e saj (leximi 1, leximi 2, shkrimi 2, shkrimi 1 është i keq), mund të përdoret për ndryshime të komplikuara të një vlerë (Interlocked Anything model).

Zgjidhjet në mënyrën e bërthamës

Për të shmangur humbjen e burimeve në cikël, le të shohim se si mund të bllokojmë rrjedhën. Me fjalë të tjera, duke vazhduar me shembullin tonë, le të shohim se si kamarjeri do të dremitë filozofin dhe do ta zgjojë atë vetëm kur është e nevojshme. Së pari, le të shqyrtojmë se si ta bëjmë këtë përmes mënyrës së 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ë deri në 53 herë më e ngadalshme SpinLock [Richter]. Por me to mund të sinkronizohen proceset në të gjithë sistemin, pavarësisht nëse janë të menaxhuara apo jo.

Konstruksioni kryesor këtu është semafori, i propozuar nga Dijkstra më shumë se një gjysmë shekulli më parë. Semafori është, në terma të thjeshtë, një numër i plotë pozitiv, i menaxhuar nga sistemi, dhe dy operacione mbi të — rritje dhe ulje. Nëse ulja nuk është e mundur, në zero, atëherë rrjedha e thirrjes bllokohet. Kur numri rritet nga ndonjë rrjedhë/proces tjetër aktiv, atëherë rrjedhat kalohen, dhe semafori përsëri ulet në numrin e kaluar. Mund ta imagjinoni si trena në një vend të ngushtë me një semafor. .NET ofron disa konstruksione me funksione të ngjashme: AutoResetEvent, ManualResetEvent, Mutex dhe vetë Semaphore. Ne do të përdorim AutoResetEvent, kjo është konstruksioni më i thjeshtë nga këto: vetëm dy vlera 0 dhe 1 (false, true). Metoda e saj WaitOne() bllokon rrjedhën e thirrjes, nëse vlera është 0, dhe nëse është 1, atëherë e ul në 0 dhe e kalon atë. Metoda Set() e rrit në 1 dhe kalon një që pret, i cili përsëri e ul në 0. Vepron si një turniket në metro.

Të komplikohet zgjidhja dhe të përdorim bllokim për çdo filozof, e jo për të gjithë së bashku. Pra, tani mund të kenë disa filozofë njëkohësisht, e jo një. Por përsëri bllokojmë qasjen në tavolinë, për të marrë pirunat në mënyrë të saktë, 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 arriti të marrë pirunat, atëherë veprimet e tij do të jenë të tilla. Ai pret qasjen në tavolinë. Pasi ta marrë atë ai provon të marrë pirunat. Nuk ia doli. Ai e kthen qasjen në tavolinë (përshtatshmëria e ndërsjellë). Dhe kalon nëpër "turniketi" i tij (AutoResetEvent) (në fillim ato janë të hapura). Kthehet përsëri në cikël, sepse nuk ka pirunat. Provon t'i marrë dhe ndalon përpara "turniketit" të tij. Disa fqinjë më me fat nga e djathta ose e majta, duke përfunduar me ngrënë, çlirojnë filozofin tonë, "duke hapur turniketin" e tij. Filozofi ynë kalon përmes tij (dhe ai mbyllet pas tij) për herë të dytë. Provon herën e tretë të marrë pirunat. Me sukses. Dhe kalon "turniketin" e tij, për të ngrënë drekë.

Kur në këtë kod do të ketë gabime të rastësishme (ato janë gjithmonë prezente), për shembull, fqinja do të jetë caktuar gabim ose bëhet të njëjtin objekt AutoResetEvent për të gjithë (Enumerable.Repeat), atëherë filozofët do të presin zhvilluesit, sepse gjetja e gabimeve në të tillë kod është një punë mjaft e komplikuar. Një problem tjetër me këtë zgjidhje është se ajo nuk garanton që ndonjë filozof nuk do të fillojë të urisë.

Zgjidhje hibride

Ne shqyrtuam dy qasje për sinkronizimin, kur qëndrojmë në modalitetin e përdoruesit dhe rrotullohemi në cikël dhe atëherë kur bllokojmë rrjedhën përmes bërthamës. Metoda e parë është e mirë për bllokime të shkurtra, e dyta për të gjata. Shpesh është e nevojshme fillimisht të prisni shkurt për ndryshimin e variables në cikël dhe pastaj të bllokoni rrjedhën kur pritja është e gjatë. Kjo qasje është realizuar në ashtuquajturat struktura hibride. Këtu ka të njëjtat struktura si për modalitetin e bërthamës, por tani me një cikël në modalitetin e përdoruesit: SemaforSlim, ManualResetEventSlim e të tjerë. Struktura më popullore këtu është Monitor, pasi në C# ekziston një lock sintaksë. Monitor Ky është gjithashtu semafori me vlerën maksimale 1 (mutex), por me mbështetje për paralajmërim në cikël, rekurzion, patrónin Condition Variable (për të cilin do të flasim më poshtë) dhe të tjerë. Le të shikojmë 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 ne përsëri bllokojmë të gjithë tabelën për qasje në pirunat, por tani ne çbllokojmë të gjithë rrjedhat menjëherë, dhe jo vetëm fqinjët, kur dikush përfundon ngrënien. Kjo do të thotë se fillimisht, dikush ha dhe bllokon fqinjët, dhe kur ky dikush përfundon, por dëshiron menjëherë të hajë përsëri, ai shkon në bllokim dhe zgjon fqinjët e tij, pasi koha e tij e pritjes është më e vogël.

Kështu ne shmangim ngërçet dhe urinë e ndonjë filozofi. Përdorim një cikël për të pritur shkurtimisht dhe bllokojmë rrjedhën për një periudhë më të gjatë. Çbllokimi i të gjithëve menjëherë punon më ngadalë se sa do të ishte në rastin e çbllokimit vetëm të fqinjit, si në zgjidhjen me AutoResetEvent, por diferenca nuk duhet të jetë e madhe, pasi rrjedhat duhet të qëndrojnë fillimisht në modalitetin e përdoruesit.

Me lock Sintaksa ka surpriza të pakëndshme. Rekomandohet të përdoret Monitor direkt [Rihter] [Erik Lippert]. Njëra prej tyre është se lock përherë del nga Monitor, madje edhe nëse kishte një përjashtim, dhe atëherë një rrjedhë tjetër mund të ndryshojë gjendjen e memories së përbashkët. Në këto raste shpesh është më mirë të shkojmë në ngërç ose ndonjëherë të mbyllim programin në mënyrë të sigurt. Një surprizë tjetër është se Monitor përdor bllokime sinkronizimi (SyncBlock), të cilat janë në të gjitha objektet. Prandaj, nëse zgjedh një objekt të papërshtatshëm, mund të merrni lehtësisht një ngërç (p.sh., nëse bëni lock mbi një varg të individualizuar). Përdorim gjithmonë objektin e fshehtë për këtë.

Patterni Variable Condition lejon më të shkurtra për të implementuar pritjen e një kushti të komplikuar. Në .NET, mendoj se është i paplotë, sepse në parim duhet të ketë disa radhë për disa variabla (si në Posix Threads), e jo një bllokim. Ndryshe, mund të krijoheshin për të gjithë filozofët. Megjithatë, edhe në këtë formë, lejon të reduktohet kodi.

Shumë filozofë apo async / await

Mirë, tani dimë si të bllokojmë efektivisht fijet. Por, çfarë ndodh nëse kemi shumë filozofë? 100? 10000? Për shembull, kemi pranuar 100000 kërkesa në serverin web. Të krijosh një fije për çdo kërkesë do të ishte e shtrenjtë, pasi kaq shumë fije nuk do të ekzekutoheshin paralelisht. Do të ekzekutoheshin vetëm aq sa janë bërthamat logjike (unë kam 4). Dhe të gjitha të tjerat do të ishin thjesht duke marrë burime. Një nga zgjidhjet e kësaj problemi është modeli async / await. Ideja është se funksioni nuk mban fijet, nëse për vazhdimin e tij nevojitet të presim diçka. Dhe kur ndodh kjo diçka, ai rifillon ekzekutimin e tij (por jo domosdoshmërisht në të njëjtën fije!). Në rastin tonë, do të presim pirunët.

SemaphoreSlim ka për këtë WaitAsync() metodën. Këtu është realizimi 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 translocohet në një automatik të fshehtë përfundimtar, i cili menjëherë kthen brendësinë e tij Detyrë. Përmes tij mund të presim përfundimin e metodës, ta anulojmë dhe çdo gjë tjetër që mund të bëjmë me Task. Brenda metodës, automatiku kontrollon ekzekutimin. Natyra është se nëse nuk ka ndalesë, atëherë ekzekutimi është sinhron, dhe nëse ka, fija lirohet. Për një kuptim më të mirë të kësaj, është më mirë të shikoni këtë automatik përfundimtar. Mund të krijoni zinxhirë të këtyre async / await metodave.

Të testojmë. Funksionimi i 100 filozofëve në një makinë me 4 bërthama logjike, 8 sekonda. Zgjidhja e mëparshme me Monitor ekzekutoi vetëm 4 fijet e para, ndërsa të tjerat nuk u ekzekutuan fare. Çdo një nga këto 4 fije kishte një rikthim afërsisht 2ms. Ndërsa zgjidhja me async / await ekzekutoi të gjitha 100, me një mesatare që secili priste 6.8 sekonda. Sigurisht, në sistemet reale rikthimi për 6 sekonda nuk është i pranueshëm dhe është më mirë të mos përpunosh aq shumë kërkesa. Zgjidhja me Monitor rezultoi të ishte krejtësisht jo e shkallëzueshme.

Përfundim

Siç duket nga këto disa shembuj të vogël, .NET mbështet shumë struktura sinkronizimi. Megjithatë, nuk është gjithmonë e qartë se si të përdoren ato. Shpresoj se ky artikull ishte i dobishëm. Për momentin, do të mbyllim këtu, por mbetet ende shumë interesante, si koleksionet e sigurta për rrjedha, TPL Dataflow, programimi reaktiv, modeli i Transaksionit të Softuerit dhe të tjerë.

Burimet

Burimi: habr.com

Blini hosting të besueshëm për faqe interneti me mbrojtje nga DDoS, serverë VPS VDS 🔥 Blini hosting të besueshëm për faqe interneti me mbrojtje nga DDoS, serverë VPS VDS | ProHoster