Mätta filosofer eller konkurrensprogrammering på .NET

Mätta filosofer eller konkurrensprogrammering på .NET

Låt oss titta på hur konkurrerande och parallell programmering fungerar i .Net, med hjälp av problemet med de ätande filosoferna som exempel. Planen är att gå från synkronisering av trådar/projekt till aktörsmodellen (i kommande delar). Artikeln kan vara användbar för en första bekantskap eller för att fräscha upp dina kunskaper.

Varför ska man kunna detta? Transistorer når sin miniminivå, Moore's lag möter ljusets hastighetsbegränsning och därför observeras tillväxten i antalet transistorer, det går att tillverka fler. Samtidigt växer mängden data, och användarna förväntar sig omedelbara svar från systemen. I en sådan situation är "vanlig" programmering, där vi har en enda exekverande tråd, inte längre effektiv. Vi måste på något sätt lösa problemet med samtidig eller konkurrerande exekvering. Problemet existerar på olika nivåer: på trådnivå, på processnivå, på maskinnivå i nätverket (distribuerade system). I .NET finns kvalitativa, beprövade teknologier för att snabbt och effektivt lösa sådana uppgifter.

Uppgift

Edsger Dijkstra ställde detta problem till sina studenter redan 1965. Den etablerade formuleringen är följande. Det finns ett visst antal (vanligtvis fem) filosofer och lika många gafflar. De sitter runt ett runt bord, med gafflarna mellan sig. Filosoferna kan äta från sina tallrikar med oändlig mat, tänka eller vänta. För att en filosof ska kunna äta behöver han ta två gafflar (den sista delar en gaffel med den första). Att ta och lägga tillbaka en gaffel är två separata handlingar. Alla filosofer är tysta. Utmaningen är att hitta en algoritm så att alla kan tänka och vara mätta även efter 54 år.

Först ska vi försöka lösa detta problem genom att använda ett delat utrymme. Gafflarna ligger på det gemensamma bordet och filosoferna tar helt enkelt dem när de finns tillgängliga och lägger tillbaka dem. Här uppstår problem med synkronisering, när ska man ta gafflarna? vad ska man göra om gafflarna inte finns? och så vidare. Men först, låt oss starta filosoferna.

För att starta trådar använder vi en trådpool via Task.Run metoden:

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

Trådpoolen har skapats för att optimera skapandet och borttagandet av trådar. Denna pool har en kö med uppgifter och CLR skapar eller tar bort trådar beroende på antalet uppgifter. En pool för alla AppDomainer. Denna pool bör nästan alltid användas, eftersom det inte är nödvändigt att oroa sig för skapande, borttagning av trådar, deras köer och så vidare. Det går att göra utan pool, men då måste man använda Tråd, vilket är rimligt i situationer där man behöver ändra prioritet på en tråd, när vi har en lång operation, för Foreground-trådar och andra.

Med andra ord, System.Threading.Tasks.Task klassen är likadan Tråd, men med olika bekvämligheter: möjligheten att starta en uppgift efter en annan uppgiftsblock, returnera dem från funktioner, enkelt avbryta dem och mycket mer. De behövs för att stödja async/await-konstruktioner (Task-based Asynchronous Pattern, syntaktisk socker för att vänta på IO-operationer). Det här kommer vi att diskutera mer.

CancelationTokenSource behövs här så att tråden kan avslutas själv på signal från den anropande tråden.

Problem med synkronisering

Blockerade filosofer

Bra, vi kan skapa trådar, låt oss försöka äta lunch:

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

Här försöker vi först ta gaffeln till vänster, sedan gaffeln till höger och om vi lyckas, äter vi och lägger tillbaka dem. Att ta en gaffel är atomärt, det vill säga två trådar kan inte ta en samtidigt (inte korrekt: den första läser att gaffeln är ledig, den andra också, den första tar, den andra tar). För detta Interlocked.CompareExchange, som måste implementeras med hjälp av processorinstruktionen (TSL, XCHG), som låser ett minnesområde för atomär sekventiell läsning och skrivning. Och SpinWait är ekvivalent med konstruktionen while(true) endast med liten "magik" — tråden tar processor (Thread.SpinWait), men ibland överlämnar den kontrollen till en annan tråd (Thread.Yeild) eller somnar (Thread.Sleep).

Men denna lösning fungerar inte, eftersom trådarna snart (för mig inom en sekund) blockeras: alla filosofer tar sin vänstra gaffel, men ingen tar den högra. Arrayen forks har då värdena: 1 2 3 4 5.

Mätta filosofer eller konkurrensprogrammering på .NET

I bilden, blockeringen av trådarna (deadlock). Grönt för utförande, rött för synkronisering, grått för en tråd som sover. Rhomber markerar tiden för start av uppgifter.

Filosofernas hunger

Även om man inte behöver speciellt mycket mat för att tänka, kan hunger få vem som helst att överge filosofin. Låt oss försöka modellera en situation av hunger bland trådar i vårt scenario. Hunger är när en tråd arbetar, men utan att utföra något meningsfullt arbete, med andra ord, det är samma som dödläge, fast nu sover inte tråden, utan aktivt letar efter mat, men det finns ingen. För att undvika frekventa blockeringar ska vi lägga tillbaka gaffeln om vi inte kan ta en annan.

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

I den här koden är det viktigt att två av fyra filosofer glömmer att lägga tillbaka sin vänstra gaffel. Det resulterar i att de äter mer mat, medan andra börjar hungrar, trots att trådarna har samma prioritet. Här hungerar de inte helt, eftersom de dåliga filosoferna ibland lägger tillbaka sina gafflar. Jag ser att de goda äter ungefär fem gånger mindre än de dåliga. Så ett litet fel i koden leder till att prestandan faller. Här bör man också notera en sällsynt situation, när alla filosofer tar sin vänstra gaffel, men inte den högra, de lägger tillbaka den vänstra, väntar, tar den vänstra igen, osv. Denna situation är också hunger, mer lik en ömsesidig blockering. Jag har inte kunnat återskapa den. Nedan finns en bild som visar situationen när två dåliga filosofer har tagit båda gafflarna, medan två goda hungerar.

Mätta filosofer eller konkurrensprogrammering på .NET

Här kan man se att trådarna ibland vaknar och försöker få resurser. Två kärnor av fyra gör ingenting (den gröna grafen högst upp).

Filosofens död

Och ytterligare ett problem som kan avbryta de filosofiska luncherna är om en av dem plötsligt dör med gafflar i händerna (och han begravs så). Då kommer grannarna att bli utan lunch. Du kan skapa ett exempel på kod för detta fall själv, till exempel kastar man NullReferenceException efter att filosoferna har tagit gafflarna. Och förresten, undantaget kommer att vara okontrollerat och den källkod som kallar det kommer helt enkelt inte att fånga det (för detta krävs AppDomain.CurrentDomain.UnhandledException och andra). Därför är felhanterare nödvändiga i trådarna själva för korrekt avslutning.

Servitören

Hur ska vi lösa problemet med ömsesidiga blockeringar, hungersnöd och dödsfall? Vi kommer att tillåta endast en filosof att använda gafflarna, och vi lägger till ömsesidig uteslutning (mutual exclusion) av trådar för detta område. Hur kan vi åstadkomma detta? Anta att det står en servitör nära filosoferna, som ger tillstånd till en viss filosof att ta gafflarna. Hur ska vi implementera denna servitör och hur kommer filosoferna att be om honom? Det är intressanta frågor.

Ett enkelt sätt är att filosoferna bara ständigt ber servitören om tillgång till gafflarna. Det vill säga att filosoferna nu inte kommer att vänta på en gaffel bredvid sig, utan vänta på eller be servitören. Först kommer vi att använda enbart User Space, där vi inte använder avbrott för att kalla några procedurer från kärnan (om dem nedan).

Lösningar i användarutrymmet

Här kommer vi att göra samma sak som vi gjorde tidigare med en gaffel och två filosofer, vi kommer att snurra i en loop och vänta. Men nu handlar det om alla filosofer, och vi kan säga att det bara finns en gaffel, det vill säga att endast den filosof som tar denna "gyllene gaffel" från servitören får äta. För detta kommer vi att använda SpinLock.

private static SpinLock spinLock = new SpinLock();  // Vår "servitör"
private void RunSpinLock(int i, CancellationToken token)
{
    while (true)
    {
        // Ömsesidig blockering genom busy waiting. Vi anropar till try för att
        // kasta ett undantag om det uppstår ett fel i själva SpinLock.
        bool hasLock = false;
        spinLock.Enter(ref hasLock);
        try
        {
            // Här kan det endast finnas en tråd (ömsesidig uteslutning).
            forks[Left(i)] = i + 1;  // Tar gaffeln direkt, utan att vänta.
            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();  // Undviker problemet med filosofens död.
        }

        Think(i);

        if (token.IsCancellationRequested)
            break;
    }
}

SpinLock det är en lås, med, grovt taget, samma while(true) { if (!lock) break; }, men med ännu mer "magik" än i SpinWait (som används där). Nu kan den räkna väntande, lugna dem lite och mycket mer. Sammanfattningsvis gör den allt som är möjligt för att optimera. Men man måste komma ihåg att detta fortfarande är samma aktiva cykel, som använder CPU-resurser och håller en ström, vilket kan leda till svält om en av filosoferna får högre prioritet än de andra men inte har en gyllene gaffel (Priority Inversion-problemet). Så vi använder den bara för mycket, mycket korta ändringar i det delade minnet, utan några externa anrop, inbäddade lås och andra överraskningar.

Mätta filosofer eller konkurrensprogrammering på .NET

Bild för SpinLock. Strömmar "krigar" ständigt om den gyllene gaffeln. Det sker misslyckanden - det markerade området i bilden. Kärnorna används inte helt: bara ungefär 2/3 av dessa fyra strömmar.

Ett annat lösning här skulle vara att använda endast Interlocked.CompareExchange med samma aktiva väntan, som visas i koden ovan (i de svältande filosoferna), men detta, som redan nämnts, kan teoretiskt leda till låsning.

Om Interlocked är värt att nämna att det inte bara finns CompareExchange, utan även andra metoder för atomär läsning och skrivning. Och genom att repetera ändringen om en annan ström hinner göra sina ändringar (läsning 1, läsning 2, skrivning 2, skrivning 1 dålig), kan det användas för komplexa ändringar av ett värde (Interlocked Anything-mönstret).

Lösningar i kärnläge

För att undvika resursförlust i cykeln, låt oss se hur vi kan blockera en ström. Med andra ord, fortsätt med vårt exempel och se hur servitören kan lugna filosofen och väcka honom endast när det behövs. Först ska vi se hur detta görs genom operativsystemets kärnläge. Alla strukturer där tenderar att vara långsammare än de som är i användarutrymmet. Långsammare flera gånger, till exempel AutoResetEvent kan vara 53 gånger långsammare SpinLock [Richter]. Men med deras hjälp kan processer synkroniseras över hela systemet, oavsett om de är kontrollerade eller inte.

Den grundläggande konstruktionen här är en semafor, föreslagen av Dijkstra för mer än ett halvsekel sedan. En semafor är, förenklat, ett positivt heltal som styrs av systemet, och två operationer på den — öka och minska. Om det inte går att minska till noll, blockeras den anropande tråden. När talet ökas av någon annan aktiv tråd/process, släpps trådarna igenom, och semaforen minskas på nytt med antalet som passerat. Man kan tänka sig tåg vid en smal passage med en semafor. .NET erbjuder flera konstruktioner med liknande funktioner: AutoResetEvent, ManualResetEvent, Mutex och sig själv Semaphore. Vi kommer att använda AutoResetEvent, detta är den enklaste av dessa konstruktioner: endast två värden 0 och 1 (false, true). Dess metod WaitOne() blockerar den anropande tråden om värdet var 0, och om 1, sänker det till 0 och släpper igenom den. Metoden Set() ökar till 1 och släpper en väntande, som sedan sänker det till 0. Fungerar som en turbinett i tunnelbanan.

Vi komplicerar lösningen och använder en låsning för varje filosof, istället för för alla på en gång. Det vill säga, nu kan flera filosofer vara aktiva samtidigt, istället för en. Men vi blockerar åter tillgången till bordet, för att korrekt, och undvika race conditions, ta gafflarna.

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

För att förstå vad som händer här, låt oss titta på ett fall där en filosof misslyckas med att ta gafflarna, då kommer hans handlingar att se ut så här. Han väntar på tillgång till bordet. Får han det försöker han ta gafflarna. Det lyckas inte. Han ger tillbaka tillgången till bordet (ömsesidig uteslutning). Och går igenom sin "turbinett" (AutoResetEvent) (de är öppna från början). Han återgår till cykeln, eftersom han saknar gafflar. Han försöker ta dem och stannar vid sin "turbinett". Någon mer lyckosam granne till höger eller vänster, som har ätit klart, låser upp vår filosof, "öppnar hans turbinett". Vår filosof går igenom den (och den stängs bakom honom) för andra gången. Han försöker för tredje gången att ta gafflarna. Lyckas. Och går igenom sin turbinett för att äta lunch.

När det finns slumpmässiga fel i sådan kod (de finns alltid), till exempel om en granne specificeras felaktigt eller om samma objekt skapats AutoResetEvent för alla (Enumerable.Repeat), då kommer filosoferna att vänta på utvecklarna, eftersom det är en ganska svår uppgift att felsöka sådan kod. Ytterligare ett problem med denna lösning är att det inte garanterar att någon filosof inte kommer att börja svälta.

Hybridlösningar

Vi har övervägt två metoder för synkronisering, när vi förblir i användarläge och kör i en loop och när vi blockerar tråden genom kärnan. Den första metoden är bra för korta blockeringar, den andra för längre. Ofta behöver man först vänta kort på att en variabel ska ändras i loopen och sedan blockera tråden när väntan blir lång. Denna metod implementeras i så kallade hybridkonstruktioner. Här finns samma konstruktioner som användes för kärnläget, men nu med en loop i användarläge: SemaforSlim, ManualResetEventSlim m.fl. Den mest populära konstruktionen här är Monitor, eftersom det finns en välkänd syntax i C# som alla känner till lock syntax. Monitor Detta är samma semafor med ett maximalt värde av 1 (mutex), men med stöd för att vänta i loopen, rekursion och Condition Variable-mönstret (mer om det nedan) m.fl. Låt oss titta på lösningen med den.

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

Här blockerar vi återigen hela bordet för tillgång till gafflarna, men nu låser vi upp alla trådar samtidigt, och inte bara grannarna, när någon slutar äta. Det vill säga, först äter någon och blockerar grannarna, men när den personen är klar, men vill äta igen direkt, går den i blockering och väcker sina grannar, eftersom dess väntetid är kortare.

Så vi undviker dödlås och att vissa filosofer blir utan mat. Vi använder loopen för kort väntan och blockerar tråden för lång väntan. Att låsa upp alla på en gång fungerar långsammare än om bara grannen låstes upp, som i lösningen med AutoResetEvent, men skillnaden bör inte vara stor, eftersom trådarna först måste förbli i användarläge.

Hos lock Det finns obehagliga överraskningar med syntaxen. Det rekommenderas att alltid använda Monitor direkt [Richter] [Eric Lippert]. En av dem är att lock alltid går ur Monitor, även om ett undantag inträffade, och då kan en annan tråd ändra tillståndet i det gemensamma minnet. I sådana fall är det ofta bättre att gå in i ett dödlås eller på ett säkert sätt avsluta programmet. En annan överraskning är att Monitor använder synkroniseringsblock (SyncBlock), som finns i alla objekt. Därför, om ett olämpligt objekt väljs kan man lätt få ett dödlås (till exempel om man gör en lock på en intern sträng). Använd alltid ett dolt objekt för detta.

Condition Variable-mönstret gör det möjligt att på ett mer kortfattat sätt implementera väntan på ett komplext villkor. I .NET är det ofullständigt, enligt min åsikt, eftersom det borde finnas flera köer för flera variabler (som i Posix-trådar), inte bara på en lås. Då skulle de kunna göras för alla filosofer. Men även i denna form möjliggör det att korta ner koden.

Många filosofer eller async / await

Bra, nu kan vi effektivt blockera trådar. Men vad händer om vi får många filosofer? 100? 10000? Till exempel, om vi får 100000 förfrågningar till webbservern. Att skapa en tråd för varje förfrågan skulle vara kostsamt, eftersom så många trådar inte kommer att köras parallellt. Endast så många som det finns logiska kärnor (jag har 4). Och alla de andra skulle bara ta resurser. En lösning på detta problem är async/await-mönstret. Idén är att funktionen inte behåller tråden om den behöver vänta på något för att fortsätta. När det som den väntar på inträffar, återupptar den sin körning (men inte nödvändigtvis i samma tråd!). I vårt fall kommer vi att vänta på gafflar.

SemaphoreSlim har för detta WaitAsync() metod. Här är en implementation med användning av detta mönster.

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

Metoden med async / await översätts till en smart slutautomat som omedelbart returnerar sin interna Task. Genom den kan man vänta på att metoden ska slutföras, avbryta den och allt annat som man kan göra med Task. Inuti metoden kontrollerar slutautomaten körningen. Poängen är att om det inte finns någon fördröjning, så är körningen synkron, och om det finns en fördröjning, så frigörs tråden. För bättre förståelse av detta är det bättre att titta på denna slutautomat. Man kan skapa kedjor av dessa async / await metoder.

Låt oss testa. Arbetet med 100 filosofer på en maskin med 4 logiska kärnor, 8 sekunder. Den tidigare lösningen med Monitor utförde endast de första 4 trådarna, medan de andra överhuvudtaget inte kördes. Varje av dessa 4 trådar stod stilla i cirka 2 ms. Lösningen med async/await utförde alla 100, där varje i genomsnitt väntade 6.8 sekunder. Självklart är det oacceptabelt att stå stilla i 6 sekunder i verkliga system och det är bättre att inte hantera så många förfrågningar så. Lösningen med Monitor visade sig vara helt ohållbar.

Slutsats

Som det framgår av dessa små exempel stöder .NET många synkroniseringskonstruktioner. Det är dock inte alltid uppenbart hur de ska användas. Jag hoppas att denna artikel var till nytta. Vi avslutar här, men det finns fortfarande mycket intressant kvar, som trådsäkra samlingar, TPL Dataflow, reaktiv programmering, Software Transaction-modellen m.m.

Källor

Källa: habr.com

Köp pålitlig webbhotell med DDoS-skydd, VPS VDS-servrar 🔥 Köp pålitlig webbhotell med DDoS-skydd, VPS VDS-servrar | ProHoster