Verzadigde filosofen of competitief programmeren op .NET

Verzadigde filosofen of competitief programmeren op .NET

Laten we eens kijken naar hoe concurrerend en parallel programmeren werkt in .Net, aan de hand van het probleem van de etende filosofen. Het plan is om van thread- en proces-synchronisatie naar Actor-modellen te gaan (in de volgende delen). Dit artikel kan nuttig zijn voor een eerste kennismaking of om uw kennis op te frissen.

Waarom zou je dit überhaupt moeten kunnen? Transistors bereiken hun minimale grootte, de wet van Moore stuit op de lichtsnelheid en daarom groeit het aantal transistors, je kunt er meer maken. Tegelijkertijd groeit de hoeveelheid data en verwachten gebruikers onmiddellijke reacties van systemen. In zo'n situatie is 'traditioneel' programmeren met één uitvoerende thread al niet efficiënt meer. We moeten een manier vinden om het probleem van gelijktijdige of concurrerende uitvoering op te lossen. Dit probleem bestaat op verschillende niveaus: op het niveau van threads, op het niveau van processen, en op het niveau van machines in een netwerk (gedistribueerde systemen). In .NET zijn er kwalitatieve, door de tijd beproefde technologieën voor snelle en effectieve oplossingen voor deze uitdagingen.

Task

Edsger Dijkstra stelde dit probleem al in 1965 aan zijn leerlingen. De gangbare formulering is als volgt. Er is een bepaald (meestal vijf) aantal filosofen en evenveel vorken. Ze zitten aan een ronde tafel, met vorken tussen hen in. Filosofen kunnen uit hun borden met voedsel zonder einde eten, nadenken of wachten. Om te kunnen eten, moet een filosoof twee vorken pakken (de laatste deelt een vork met de eerste). Het pakken en neerleggen van een vork zijn twee afzonderlijke handelingen. Alle filosofen zijn stil. De uitdaging is om een algoritme te vinden zodat ze allemaal kunnen nadenken en verzadigd zijn, zelfs na 54 jaar.

Laten we proberen dit probleem op te lossen door gebruik te maken van een gedeelde ruimte. De vorken liggen op een gezamenlijke tafel en de filosofen nemen ze gewoon wanneer ze er zijn, en leggen ze weer terug. Hier ontstaan synchronisatieproblemen: wanneer moet je vorken pakken? Wat moet je doen als er geen vorken zijn? enz. Maar laten we eerst de filosofen in actie brengen.

Om de threads te starten, gebruiken we een threadpool via Task.Run methode:

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

De threadpool is gemaakt om het aanmaken en verwijderen van threads te optimaliseren. Deze pool heeft een wachtrij met taken en de CLR maakt of verwijdert threads afhankelijk van het aantal deze taken. Er is één pool voor alle AppDomain's. Deze pool moet bijna altijd worden gebruikt, omdat het niet nodig is om zich bezig te houden met het creëren, verwijderen van threads, hun wachtrijen, enzovoort. Zonder pool kan het ook, maar dan moet men direct gebruiken Thread, dit is zinvol voor situaties waarin men de prioriteit van een thread moet wijzigen, wanneer we een lange operatie hebben, voor de Foreground thread, enzovoort.

Met andere woorden, System.Threading.Tasks.Task is dezelfde klasse, maar met allerlei gemakken: de mogelijkheid om een taak te starten na een blok van andere taken, ze uit functies terug te geven, gemakkelijk te onderbreken, enzovoort. Ze zijn nodig voor het ondersteunen van async/await constructies (Task-based Asynchronous Pattern, syntactische suiker voor het wachten op IO-operaties). Daarover zullen we later nog praten. ThreadCancelationTokenSource

is hier nodig, zodat de thread zichzelf kan beëindigen op signaal van de oproepende thread. Problemen met synchronisatie

Geblokkeerde filosofen

Goed, we weten hoe we threads moeten aanmaken, laten we proberen te lunchen:

Hier proberen we eerst de linker en dan de rechter vork te pakken, en als dat lukt, eten we en leggen ze terug. Het pakken van één vork is atomair, dat wil zeggen, twee threads kunnen niet tegelijkertijd dezelfde pakken (onjuist: de eerste leest dat de vork vrij is, de tweede ook, de eerste pakt hem, de tweede pakt hem). Hiervoor

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

Interlocked.CompareExchange , dat moet worden geïmplementeerd met behulp van processorinstructies (TSLXCHG, ), die een geheugenblok vergrendelen voor atomair en sequentieel lezen en schrijven. De SpinWait is equivalent aan de constructiewhile(true) maar met een klein 'magisch' element — de thread neemt de CPU in beslag ( Thread.SpinWait), maar soms geeft het de controle over aan een andere thread (Thread.Yield) of slaapt (Thread.SleepMaar deze oplossing werkt niet, aangezien de threads al snel (bij mij binnen een seconde) worden geblokkeerd: alle filosofen pakken hun linker vork, maar hebben de rechter niet. De array forks heeft dan waarden: 1 2 3 4 5.).

Op de afbeelding, de blokkering van threads (deadlock). In groen — uitvoering, in rood — synchronisatie, in grijs — thread slaapt. De ruitvormige figuren geven de tijd aan waarop taken zijn gestart.

Verzadigde filosofen of competitief programmeren op .NET

Hongerige filosofen

De honger van de filosofen

Hoewel je voor diep nadenken niet veel voedsel nodig hebt, kan honger iedereen ertoe brengen de filosofie op te geven. Laten we de situatie van threads die lijden aan honger in onze taak simuleren. Honger is wanneer een thread actief is, maar zonder betekenisvolle taken, met andere woorden, het is hetzelfde als deadlock, alleen is de thread nu niet inactief, maar zoekt actief naar voedsel terwijl er geen is. Om frequente blokkades te voorkomen, zullen we de vork terugleggen als we er geen andere kunnen pakken.

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

In deze code is het belangrijk dat twee van de vier filosofen vergeten hun linker vork neer te leggen. Hierdoor eten ze meer voedsel, terwijl anderen honger lijden, ondanks dat de threads dezelfde prioriteit hebben. Hier lijden ze niet echt honger, omdat de slechte filosofen af en toe hun vorken terugleggen. Het lijkt erop dat de goede filosofen ongeveer vijf keer minder eten dan de slechte. Zo leidt een kleine fout in de code tot een daling in de prestaties. Het is ook vermeldenswaard dat er een zeldzame situatie mogelijk is waarin alle filosofen hun linker vork pakken, maar geen rechter hebben. Ze leggen de linker neer, wachten, pakken opnieuw de linker, enzovoort. Deze situatie is ook honger, meer vergelijkbaar met een deadlock. Ik ben er niet in geslaagd deze te repliceren. Hieronder is een afbeelding voor de situatie waarin twee slechte filosofen beide vorken hebben gepakt en twee goede honger lijden.

Verzadigde filosofen of competitief programmeren op .NET

Hier is te zien dat de threads soms ontwaken en proberen een bron te verkrijgen. Twee cores van de vier doen niets (de groene grafiek bovenaan).

De dood van de filosoof

En nog een probleem dat de vrolijke lunch van de filosofen kan onderbreken — is als een van hen plotseling sterft met vorken in de handen (en zo wordt hij begraven). Dan blijven de buren zonder lunch. Je kunt zelf een voorbeeldcode voor dit geval bedenken, bijvoorbeeld wordt er een NullReferenceException gegenereerd nadat de filosoof de vorken pakt. En trouwens, de uitzondering zal ongehandeld blijven en de aanroepende code zal deze gewoon niet opvangen (daarvoor AppDomain.CurrentDomain.UnhandledException enzovoort). Daarom zijn foutafhandelingsmechanismen noodzakelijk in de threads zelf en voor een correcte beëindiging.

Ober

Hoe lossen we het probleem van wederzijdse blokkades, honger en sterfgevallen op? We laten slechts één filosoof toegang hebben tot de vorken en voegen wederzijdse uitsluiting (mutual exclusion) van threads op deze plek toe. Hoe doen we dat? Stel je voor dat er een ober naast de filosoof staat die toestemming geeft aan één filosoof om de vorken te nemen. Hoe maken we deze ober en hoe zullen de filosofen hem verzoeken? Interessante vragen.

De eenvoudigste manier is dat de filosofen gewoon voortdurend de ober vragen om toegang tot de vorken. Dat wil zeggen, de filosofen zullen nu niet wachten op een vork naast hen, maar wachten of de ober vragen. We gebruiken hiervoor eerst alleen User Space, waarbij we geen onderbrekingen gebruiken om oproepen naar de kernel (hierover later meer) te doen.

Oplossingen in de gebruikersruimte

Hier doen we hetzelfde als eerder met één vork en twee filosofen, we draaien in een lus en wachten. Maar nu zijn het alle filosofen en lijkt het alsof er maar één vork is, dat wil zeggen, alleen die filosoof die deze 'gouden vork' van de ober heeft genomen, kan eten. Hiervoor gebruiken we SpinLock.

private static SpinLock spinLock = new SpinLock();  // Onze "ober"
private void RunSpinLock(int i, CancellationToken token)
{
    while (true)
    {
        // Wederzijdse blokkade door busy waiting. We roepen tot try aan om
        // een uitzondering te gooien in geval van een fout in de SpinLock.
        bool hasLock = false;
        spinLock.Enter(ref hasLock);
        try
        {
            // Hier kan slechts één thread zijn (wederzijdse uitsluiting).
            forks[Left(i)] = i + 1;  // Neem de vork meteen, zonder te wachten.
            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();  // Voorkom het probleem met de dood van de filosoof.
        }

        Think(i);

        if (token.IsCancellationRequested)
            break;
    }
}

SpinLock is een vergrendeling die, grof gezegd, hetzelfde doet while(true) { if (!lock) break; }, maar met nog meer 'magie' dan in SpinWait (die daar wordt gebruikt). Nu kan het wachten tellen, ze een beetje in slaap brengen en meer. Over het algemeen doet het zijn best om te optimaliseren. Maar men moet onthouden dat dit nog steeds dezelfde actieve cyclus is, die middelen van de processor verbruikt en een stroom vasthoudt die kan leiden tot honger, als een van de filosofen belangrijker wordt dan de anderen, maar geen gouden vork heeft (Priority Inversion-probleem). Daarom gebruiken we het alleen voor zeer korte wijzigingen in het gedeelde geheugen, zonder externe oproepen, geneste vergrendelingen en andere verrassingen.

Verzadigde filosofen of competitief programmeren op .NET

Afbeelding voor SpinLock. Stromen 'vechten' constant om de gouden vork. Er ontstaan ​​fouten - het gemarkeerde gebied op de afbeelding. De cores worden niet volledig benut: slechts ongeveer 2/3 door deze vier stromen.

Een andere oplossing hier zou zijn om alleen , dat moet worden geïmplementeerd met behulp van processorinstructies ( te gebruiken met dezelfde actieve wachting, zoals hierboven in de code getoond (in hongerige filosofen), maar dit kan, zoals eerder gezegd, theoretisch leiden tot een blokkade.

Over Interlocked moet worden gezegd dat daar niet alleen CompareExchange, maar ook andere methoden voor atomair lezen en schrijven. En met herhaalde wijzigingen in het geval dat een andere stroom zijn wijzigingen weet door te voeren (lezen 1, lezen 2, schrijven 2, schrijven 1 is slecht), kan het worden gebruikt voor complexe wijzigingen van één waarde (Interlocked Anything-patroon).

Oplossingen in de kernelmodus

Om middelenverlies in de cyclus te vermijden, laten we eens kijken hoe we een stroom kunnen blokkeren. Met andere woorden, door ons voorbeeld voort te zetten, bekijken we hoe de ober de filosoof in slaap kan brengen en hem alleen wakker kan maken wanneer dat nodig is. Laten we eerst bekijken hoe we dit kunnen doen via de kernelmodus van het besturingssysteem. Alle structuren daar blijken vaak trager te zijn dan die in de gebruikersruimte. Veelal trager, bijvoorbeeld AutoResetEvent kan tot 53 keer trager zijn SpinLock [Richter]. Maar met hun hulp kunnen processen door het hele systeem gesynchroniseerd worden, beheerd of niet.

De basisconstructie hier is een semafoor, voorgesteld door Dijkstra meer dan een halve eeuw geleden. Een semafoor is, vereenvoudigd gezegd, een positief geheel getal dat door het systeem wordt beheerd, met twee bewerkingen erop: verhogen en verlagen. Als verlagen niet mogelijk is, in nul, dan wordt de oproepende thread geblokkeerd. Wanneer het getal door een andere actieve thread/proces wordt verhoogd, worden threads doorgelaten terwijl de semafoor opnieuw wordt verlaagd met het aantal gepasseerden. Men kan dit voorstellen als treinen in een nauwe doorgang met een semafoor. .NET biedt verschillende constructies met soortgelijke functies: AutoResetEvent, ManualResetEvent, Mutex en zelf Semaphore. We zullen gebruik maken van AutoResetEvent, dit is de eenvoudigste van deze constructies: slechts twee waarden 0 en 1 (false, true). De methode WaitOne() blokkeert de oproepende thread als de waarde 0 was, en als het 1 is, verlaagt het naar 0 en laat het doorgaan. De methode Set() verhoogt naar 1 en laat een wachtende doorgaan, die deze vervolgens weer verlaagt naar 0. Werkt als een draaideur in de metro.

Laten we de oplossing ingewikkelder maken en een blokkering gebruiken voor elke filosoof, in plaats van voor allemaal tegelijk. Dat betekent, nu kunnen er meerdere filosofen tegelijk zijn, niet maar één. Maar we blokkeren opnieuw de toegang tot de tafel, zodat we correct, en om race conditions te vermijden, de vorken kunnen pakken.

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

Om te begrijpen wat hier gebeurt, bekijken we het geval waarin de filosoof er niet in slaagt om de vorken te pakken; zijn acties zullen als volgt zijn. Hij wacht op toegang tot de tafel. Zodra hij deze krijgt, probeert hij de vorken te pakken. Het lukt niet. Hij geeft de toegang tot de tafel terug (onderlinge uitsluiting). En gaat zijn "draaideur" (AutoResetEvent) in (in het begin zijn ze open). Hij komt weer in de lus terecht, omdat hij geen vorken heeft. Hij probeert ze te pakken en stopt bij zijn "draaideur". Een meer gelukkige buur aan de rechter- of linkerzijde, die klaar is met eten, ontgrendelt onze filosoof door zijn draaideur te "openen". Onze filosoof gaat erdoorheen (en deze sluit zich achter hem) voor de tweede keer. Hij probeert voor de derde keer de vorken te pakken. Met succes. En gaat zijn draaideur door om te gaan lunchen.

Wanneer er in dergelijke code willekeurige fouten optreden (die zijn er altijd), bijvoorbeeld als de buur onjuist is opgegeven of hetzelfde object is gemaakt AutoResetEvent voor allemaal (Enumerable.Repeat), dan zullen de filosofen al de ontwikkelaars aan het wachten zijn, aangezien het zoeken naar fouten in dergelijke code een behoorlijk moeilijke bezigheid is. Nog een probleem met deze oplossing is dat het niet garandeert dat een of andere filosoof niet hongerig zal worden.

Hybride oplossingen

We looked at two approaches to synchronization: one where we stay in user mode and loop, and another where we block the thread through the kernel. The first method is good for short locks, while the second is suitable for long ones. Often, you may first need to briefly wait for a variable change in a loop, and then block the thread when the wait is prolonged. This approach is realized in so-called hybrid constructs. Here, the same constructs that were used for the kernel mode are applied, but now with a loop in user mode: SemaphoreSlim, ManualResetEventSlim etc. The most popular construct here is Monitor, since there is a well-known lock syntax in C#. Monitor This is the same semaphore with a maximum value of 1 (mutex), but with support for waiting in loops, recursion, the Condition Variable pattern (discussed below), etc. Let's look at a solution using it.

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

Here, we again block the entire table to access the forks, but now we wake all threads at once instead of just the neighbors when someone finishes eating. That is, initially, someone eats and blocks the neighbors, but when this person finishes and wants to eat again immediately, they go into a lock and wake their neighbors because their wait time is shorter.

Thus, we avoid deadlocks and starvation for any philosopher. We use a loop for brief waits and block the thread for long waits. Unlocking all at once is slower than if only the neighbor was unlocked, as in the solution with AutoResetEvent, but the difference shouldn't be significant, as threads should initially remain in user mode.

Er lock This syntax has unpleasant surprises. It's recommended to use Monitor directly [Richter] [Eric Lippert]. One of them is that lock always exits from Monitor, even if there was an exception, and then another thread can change the state of shared memory. In such cases, it's often better to end up in a deadlock or safely terminate the program. Another surprise is that Monitor uses synchronization blocks (SyncBlock), which are present in all objects. Therefore, if you choose an unsuitable object, you can easily end up with a deadlock (for example, if you lock an interned string). Always use a hidden object for this.

Het Condition Variable patroon maakt het mogelijk om op een kortere manier te wachten op een complexe voorwaarde. In .NET is het niet volledig, naar mijn mening, omdat er in principe meerdere wachtrijen op meerdere variabelen zouden moeten zijn (zoals in Posix Threads), in plaats van op één lok. Dan zou je ze voor alle filosofen kunnen maken. Maar ook in deze vorm verkort het de code.

Veel filosofen of async / await

Goed, nu kunnen we threads efficiënt blokkeren. Maar wat als we veel filosofen hebben? 100? 10000? Stel dat we 100000 aanvragen op de webserver ontvangen. Een nieuwe thread maken voor elke aanvraag zou inefficiënt zijn, aangezien zoveel threads niet parallel kunnen worden uitgevoerd. Slechts zoveel als er logische cores zijn (ik heb er 4). En al het andere zal gewoon bronnen verspillen. Een van de oplossingen voor dit probleem is het async / await patroon. Het idee is dat de functie de thread niet vasthoudt als er iets moet worden afgewacht voor de voortzetting. Wanneer dat iets dan gebeurt, hervat het de uitvoering (maar niet noodzakelijk in dezelfde thread!). In ons geval zullen we wachten op de vorken.

SemaphoreSlim heeft hiervoor WaitAsync() methode. Hier is de implementatie met behulp van dit patroon.

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

De methode wordt async / await vertaald naar een slimme eindige automaat, die zijn interne Taak. Hierdoor kun je wachten op de voltooiing van de methode, deze annuleren en alles wat je met een Task kunt doen. Binnen de methode controleert de eindige automaat de uitvoering. Het punt is dat als er geen vertraging is, de uitvoering synchronisch is, en als er wel is, de thread wordt vrijgegeven. Voor een beter begrip hiervan is het beter om deze eindige automaat te bekijken. Je kunt ketens van deze async / await methoden maken.

Laten we testen. Werking van 100 filosofen op een machine met 4 logische cores, 8 seconden. De vorige oplossing met Monitor voerde alleen de eerste 4 threads uit, terwijl de anderen helemaal niet werden uitgevoerd. Elke van deze 4 threads stond ongeveer 2 ms stil. De oplossing met async / await voerde allemaal 100 uit, waarbij elk gemiddeld 6.8 seconden wachtte. Natuurlijk is stilstand van 6 seconden in echte systemen onaanvaardbaar en is het beter om niet zoveel aanvragen zo te behandelen. De oplossing met Monitor bleek totaal niet schaalbaar.

Conclusie

Zoals te zien is in deze kleine voorbeelden, ondersteunt .NET veel synchronisatieconstructies. Het is echter niet altijd duidelijk hoe ze te gebruiken. Ik hoop dat dit artikel nuttig was. We sluiten hiermee af, maar er is nog veel interessants over, zoals thread-safe verzamelingen, TPL Dataflow, Reactive programmering, het Software Transaction model, en meer.

Bronnen

Bron: habr.com

Koop betrouwbare webhosting met bescherming tegen DDoS, VPS VDS servers 🔥 Koop betrouwbare webhosting met bescherming tegen DDoS, VPS VDS servers | ProHoster