Filozofii sătui sau programarea competitivă pe .NET

Filozofii sătui sau programarea competitivă pe .NET

Să vedem cum funcționează programarea concurentă și paralelă în .Net, folosind exemplul problemei filosofilor care iau prânzul. Planul este să discutăm despre sincronizarea firelor/proceselor, până la modelul actorilor (în părțile următoare). Articolul poate fi util pentru o primă familiarizare sau pentru a-ți reîmprospăta cunoștințele.

De ce ar trebui să știm asta? Transistorii ating dimensiunea lor minimă, legea lui Moore se lovește de limita vitezei luminii și, prin urmare, creșterea se observă în numărul de tranzistori, care pot fi realizați în cantitate mai mare. În acest timp, cantitatea de date crește, iar utilizatorii așteaptă reacții imediate din partea sistemelor. Într-o astfel de situație, programarea „obișnuită”, în care avem un singur fir de execuție, nu mai este eficientă. Trebuie să găsim o modalitate de a rezolva problema execuției simultane sau concurente. Această problemă există la diferite niveluri: la nivelul firelor, al proceselor, al mașinilor din rețea (sisteme distribuite). În .NET există tehnologii de calitate, testate de timp, pentru a rezolva rapid și eficient astfel de sarcini.

Sarcină

Edsger Dijkstra a pus această problemă elevilor săi încă din 1965. Formularea consacrată este următoarea. Există un anumit număr (de obicei cinci) de filosofi și tot atâtea furculițe. Ei stau la o masă rotundă, cu furculițe între ei. Filosofii pot mânca din farfuriile lor cu hrană infinită, pot gândi sau pot aștepta. Pentru a mânca, un filosof trebuie să ia două furculițe (ultimul împarte o furculiță cu primul). A lua și a pune furculița sunt două acțiuni separate. Toți filosofii sunt tăcuți. Sarcina este să găsim un algoritm astfel încât toți să gândească și să fie sătui, chiar și după 54 de ani.

Mai întâi, să încercăm să rezolvăm această problemă prin utilizarea unui loc comun. Furculițele stau pe masa comună și filosofii le iau pur și simplu când sunt disponibile și le pun înapoi. Aici apar probleme de sincronizare, când anume să ia furculițele? ce să facă dacă furculița nu este disponibilă? și altele. Dar mai întâi, hai să lansăm filosofii.

Pentru a lansa firele, folosim un pool de fire prin Task.Run metodă:

var cancelTokenSource = new CancellationTokenSource();
Action<int> create = (i) => RunPhilosopher(i, cancelTokenSource.Token);
for (int i = 0; i < philosophersAmount; i++) 
{
    int icopy = i;
    // Puneți sarcina în coada pool-ului de fire. Metoda RunDeadlock nu se lansează
    // imediat, ci așteaptă firul său. Lansare asincronă.
    philosophers[i] = Task.Run(() => create(icopy), cancelTokenSource.Token);
}

Poolul de_threads a fost creat pentru a optimiza crearea și ștergerea thread-urilor. Acest pool are o coadă de sarcini, iar CLR creează sau șterge thread-uri în funcție de numărul acestor sarcini. Este un singur pool pentru toate AppDomain-urile. Ar trebui să folosiți acest pool aproape întotdeauna, deoarece nu trebuie să vă faceți griji cu privire la crearea, ștergerea thread-urilor, coșurile lor etc. Se poate și fără pool, dar va trebui să folosiți direct Thread, este pertinent pentru cazurile în care trebuie să schimbăm prioritatea unui thread, când avem o operațiune lungă, pentru un thread Foreground și altele.

Cu alte cuvinte, System.Threading.Tasks.Task clasa este același lucru Thread, dar cu tot felul de conforturi: posibilitatea de a lansa o sarcină după un bloc de alte sarcini, de a le returna din funcții, de a le întrerupe ușor și multe altele. Ele sunt necesare pentru a susține construcțiile async/await (Task-based Asynchronous Pattern, sintaxă pentru a aștepta o operațiune IO). Despre asta vom discuta mai târziu.

CancelationTokenSource este necesar aici, astfel încât thread-ul să poată termina de la sine la semnalul thread-ului apelant.

Probleme cu sincronizarea

Filozofii blocați

Bine, știm să creăm thread-uri, să încercăm să luăm masa:

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

Aici încercăm mai întâi să luăm furculița din stânga, apoi pe cea din dreapta și, dacă reușim, mâncăm și le punem înapoi. Luarea unei furculițe este atomică, adică două thread-uri nu pot lua aceeași furculiță simultan (fals: primul citește că furculița este liberă, al doilea - de asemenea, primul ia, al doilea ia). Pentru aceasta, Interlocked.CompareExchange, care trebuie implementat folosind instrucțiunea procesorului (TSL, XCHG), care blochează o regiune de memorie pentru citire și scriere atomică secvențială. Iar SpinWait este echivalent cu construcția while(true) doar cu o mică „magie” - thread-ul ocupă procesorul (Thread.SpinWait), dar uneori transmite controlul unui alt thread (Thread.Yeild) sau adoarme (Thread.Sleep).

Dar această soluție nu funcționează, deoarece thread-urile se blochează rapid (la mine în decurs de o secundă): toți filozofii își iau furculița din stânga, dar nu și pe cea din dreapta. Array-ul forks atunci are valori: 1 2 3 4 5.

Filozofii sătui sau programarea competitivă pe .NET

În imagine, blocarea thread-urilor (deadlock). Cu verde se arată execuția, cu roșu - sincronizarea, cu gri - thread-ul doarme. Diamantele indică timpul de запуск на Task'uri.

Foamea filozofilor

Deși nu este nevoie de multă mâncare pentru a gândi, foamea poate determina pe oricine să renunțe la filozofie. Să încercăm să modelăm situația în care firele suferă de foame în problema noastră. Foamea este atunci când un fir de execuție funcționează, dar fără a realiza o muncă semnificativă; în alte cuvinte, este ca un deadlock, doar că acum firul nu doarme, ci caută activ să mănânce, dar nu găsește mâncare. Pentru a evita blocajele frecvente, vom pune furca înapoi dacă nu am reușit să luăm alta.

// То же что и в 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 acest cod, este important că doi din cei patru filosofi uită să își pună furca stângă la loc. Astfel, ei mănâncă mai mult, iar ceilalți încep să sufere de foame, deși toate firele au aceeași prioritate. Aici, ei nu suferă chiar de foame, deoarece filosofii mai puțin buni își pun furcile înapoi din când în când. Mie îmi pare că cei buni mănâncă de cinci ori mai puțin decât cei răi. Astfel, o mică eroare în cod duce la o scădere a performanței. De asemenea, merită observat că există o situație rară în care toți filosofii iau furca stângă, dar nu reușesc să ia furca dreaptă; îi pun pe amândouă înapoi și contină să facă asta. Această situație este, de asemenea, foame, mai asemănătoare cu un deadlock. Nu am reușit să o reproduc. Mai jos este imaginea pentru situația în care doi filosofi răi au luat ambele furci, iar doi buni suferă de foame.

Filozofii sătui sau programarea competitivă pe .NET

Aici se poate vedea că firele se trezesc uneori și încearcă să obțină resurse. Două nuclee din patru nu fac nimic (graficul verde de sus).

Moartea filozofului

Și o altă problemă care poate întrerupe masa glorioasă a filosofilor este că unul dintre ei moare subit cu furcile în mână (iar el va fi îngropat așa). Atunci vecinii vor rămâne fără masa. Un exemplu de cod pentru acest caz îl puteți inventa singuri, de exemplu aruncând NullReferenceException după ce filosoful ia furcile. Și, între noi fie vorba, excepția nu va fi gestionată, iar codul invocant nu o va prinde pur și simplu (pentru asta AppDomain.CurrentDomain.UnhandledException etc.). Prin urmare, handler-ele de erori sunt necesare în firele însele și cu un final corect.

O waiter

Bine, cum putem rezolva această problemă cu blocările reciproce, înfometarea și morțile? Vom permite doar unui singur filozof să folosească furculițele, adăugând excluderea reciprocă (mutual exclusion) a firelor pentru acest loc. Cum putem realiza acest ospătar și cum vor cere filozofii ajutorul său, întrebările sunt interesante.

Cel mai simplu mod este ca filozofii să ceară constant ospătarului accesul la furculițe. Adică, acum filozofii nu vor mai aștepta furculița lângă ei, ci vor aștepta sau vor cere ospătarului. La început, vom folosi doar User Space pentru aceasta, unde nu folosim întreruperi pentru a apela unele proceduri din kernel (despre care vorbim mai târziu).

Soluții în spațiul utilizatorului

Aici vom face același lucru ca înainte cu o furculiță și doi filozofi, ne vom învârti într-un ciclu și vom aștepta. Dar acum vor fi toți filozofii și cum ar fi doar o singură furculiță, adică putem spune că va mânca doar filozoful care a luat această «furculiță de aur» de la ospătar. Pentru aceasta, vom folosi SpinLock.

private static SpinLock spinLock = new SpinLock();  // Ospătarul nostru
private void RunSpinLock(int i, CancellationToken token)
{
    while (true)
    {
        // Blocarea reciprocă prin așteptare activă. Apelăm înainte de try, pentru a
        // arunca o excepție în cazul unei erori în SpinLock.
        bool hasLock = false;
        spinLock.Enter(ref hasLock);
        try
        {
            // Aici poate exista doar un singur fir (excludere reciprocă).
            forks[Left(i)] = i + 1;  // Luăm furculița imediat, fără a aștepta.
            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();  // Evităm problema morții filozofului.
        }

        Think(i);

        if (token.IsCancellationRequested)
            break;
    }
}

SpinLock este un blocator, care, pe scurt, are cam aceeași funcționalitate while(true) { if (!lock) break; }, dar cu încă mai multă „magie” decât în SpinWait (care este folosit acolo). Acum poate număra așteptările, să le amorțească puțin și multe altele. În general, face tot ce este posibil pentru optimizare. Dar trebuie să ne amintim că este același ciclu activ, care consumă resursele procesorului și menține un flux, care poate duce la flămânzire, dacă unul dintre filozofi devine prioritar față de ceilalți, dar nu are furculiță de aur (problema inversării priorității). Așadar, îl folosim doar pentru modificări foarte, foarte scurte în memoria comună, fără apeluri externe, blocări înnodată etc. surprize.

Filozofii sătui sau programarea competitivă pe .NET

Ilustrație pentru SpinLock. Firele „se luptă” constant pentru furculița de aur. Apar eșecuri — în figura evidențiată. CPU-urile nu sunt utilizate complet: doar aproximativ 2/3 cu aceste patru fire.

O altă soluție aici ar fi să folosim doar Interlocked.CompareExchange cu aceeași așteptare activă, așa cum este arătat în codul de mai sus (în filozofii flămânzi), dar, după cum s-a spus deja, acest lucru poate duce teoretic la blocare.

Despre Interlocked trebuie menționat că acolo nu este doar CompareExchange, ci și alte metode pentru citirea și scrierea atomică. Și prin repetarea modificării în cazul în care un alt fir reușește să facă modificările sale (citirea 1, citirea 2, scrierea 2, scrierea 1 este proastă), poate fi folosit pentru schimbări complexe ale unei valori (modelul Interlocked Anything).

Soluții în modul kernel

Pentru a evita pierderea resurselor în ciclu, să vedem cum putem bloca firul. Cu alte cuvinte, continuând exemplul nostru, să vedem cum chelnerul va adormi filozoful și îl va trezi doar atunci când este necesar. Mai întâi să vedem cum se poate face acest lucru prin modul kernel al sistemului de operare. Toate structurile de acolo se dovedesc adesea mai lente decât cele din spațiul utilizatorului. Mai lente de câteva ori, de exemplu AutoResetEvent poate fi de 53 de ori mai lent SpinLock [Richter]. Dar cu ajutorul lor se pot sincroniza procesele din întreaga sistemă, gestionate sau nu.

Structura principală aici este semaforul, propus de Dijkstra cu mai bine de o jumătate de secol în urmă. Semaforul este, simplificat, un număr întreg pozitiv, gestionat de sistem, și două operații asupra lui: a crește și a micșora. Dacă nu se poate micșora (când este zero), firul apelant se blochează. Când numărul este crescut de către un alt fir/proces activ, atunci firele sunt lăsate să treacă, iar semaforul este din nou micșorat cu numărul celor trecuți. Poate fi imaginat ca trenuri într-un loc îngust cu un semafor. .NET oferă mai multe construcții cu funcții similare: AutoResetEvent, ManualResetEvent, Mutex și el însuși Semaphore. Vom folosi AutoResetEvent, aceasta este cea mai simplă dintre aceste construcții: doar două valori 0 și 1 (false, true). Metoda sa WaitOne() blochează firul apelant, dacă valoarea a fost 0, iar dacă este 1, o reduce la 0 și îl lasă să treacă. Iar metoda Set() crește la 1 și lasă să treacă unul care aștepta, care din nou o reduce la 0. Funcționează ca un turnichet în metrou.

Să complicăm soluția și să folosim blocarea pentru fiecare philosopher, nu pentru toți deodată. Adică acum pot fi mai mulți filosofi simultan, și nu doar unul. Dar din nou blocăm accesul la masă, pentru a lua furculițele corect, evitând condițiile de cursă.

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

Pentru a înțelege ce se întâmplă aici, să luăm în considerare cazul în care filosoful nu a reușit să ia furculițele, atunci acțiunile sale vor fi următoarele. Așteaptă să aibă acces la masă. Odată ce îl are, încearcă să ia furculițele. Nu a reușit. Dă accesul la masă (excludere reciprocă). Și trece prin 'turnichetul' său (AutoResetEvent) (la început sunt deschise). Intră din nou în ciclu, deoarece nu are furculițe. Încearcă să le ia și se oprește la 'turnichetul' său. Un vecin mai norocos, din dreapta sau stânga, terminând de mâncat, deblochează filosoful nostru, 'deschizându-i turnichetul'. Filosoful nostru trece prin acesta (iar el se închide după el) a doua oară. Încearcă a treia oară să ia furculițele. Cu succes. Și trece prin turnichetul său pentru a lua prânzul.

Când în acest cod vor apărea erori aleatorii (mereu există), de exemplu, un vecin poate fi specificat greșit sau un singur obiect este creat AutoResetEvent pentru toți (Enumerable.Repeat), atunci filosofiile vor aștepta deja dezvoltatorii, deoarece găsirea erorilor în astfel de cod este o muncă destul de complicată. O altă problemă cu această soluție este că nu garantează că un filosof nu va începe să înfometeze.

Soluții hibride

Am analizat două abordări pentru sincronizare: atunci când rămânem în modul utilizator și ne învârtim într-un ciclu și când blocăm firul prin nucleu. Prima metodă este bună pentru blocări scurte, iar a doua pentru cele mai lungi. Este adesea necesar să așteptăm mai întâi scurt o modificare a variabilei în ciclu și apoi să blocăm firul când așteptarea este lungă. Această abordare este implementată în așa-numitele construcții hibride. Aici sunt aceleași construcții ca pentru modul nucleu, dar acum cu ciclu în modul utilizator: SemaphoreSlim, ManualResetEventSlim și altele. Cea mai populară construcție aici este Monitor, deoarece în C# există cunoscutul lock sintaxă. Monitor Este același semafor cu valoare maximă 1 (mutex), dar cu suport pentru așteptarea în ciclu, recursivitate, modelul Condition Variable (despre care vorbim mai jos) și altele. Să vedem soluția cu el.

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

Aici blocăm din nou întreaga masă pentru accesul la furculițe, dar acum deblocăm toate firele simultan, nu doar vecinii, atunci când cineva termină de mâncat. Adică, mai întâi, cineva mănâncă și blochează vecinii, iar când acel cineva termină, dar vrea imediat să mănânce din nou, intră în blocaj și îi trezește pe vecini, deoarece timpul său de așteptare este mai mic.

Astfel evităm deadlock-urile și foamea vreunui filozof. Folosim ciclu pentru așteptare scurtă și blocăm firul pentru așteptare lungă. Deblocarea tuturor simultan funcționează mai lent decât deblocarea doar a vecinului, ca în soluția cu AutoResetEvent, dar diferența nu ar trebui să fie mare, deoarece firele ar trebui să rămână în modul utilizator mai întâi.

În lock Sintaxa are surprize neplăcute. Se recomandă utilizarea Monitor direct [Richter] [Eric Lippert]. Una dintre ele este că lock ies întotdeauna din Monitor, chiar dacă a fost o excepție, și atunci un alt fir poate schimba starea memoriei comune. În astfel de cazuri, este adesea mai bine să ne retragem în deadlock sau să încheiem programul într-un mod sigur. O altă surpriză este că Monitor folosește blocuri de sincronizare (SyncBlock), care există în toate obiectele. Prin urmare, dacă se alege un obiect nepotrivit, este ușor să obținem deadlock (de exemplu, dacă facem lock pe un șir internat). Folosim întotdeauna un obiect ascuns pentru aceasta.

Patternul Condition Variable permite o implementare mai concisă a așteptării unei condiții complexe. În .NET, acesta este incomplet, în opinia mea, deoarece ar trebui să existe mai multe cozi pentru mai multe variabile (ca în Posix Threads), nu doar pe un singur lock. Atunci ar putea fi create pentru toți filozofii. Dar, chiar și în această formă, permite reducerea codului.

Mulți filozofi sau async / await

Bine, acum știm cum să blocăm eficient firele. Dar, ce se întâmplă dacă avem mulți filozofi? 100? 10000? De exemplu, am primit 100000 de solicitări pe serverul web. A crea un fir pentru fiecare solicitare va fi costisitor, deoarece atât de multe fire nu vor putea fi executate simultan. Vor fi executate doar atât câte nuclee logice (eu am 4). Și toate celelalte vor consuma doar resurse. O soluție la această problemă este patternul async / await. Ideea este că funcția nu menține firul dacă pentru continuarea sa trebuie să aștepte ceva. Și când acel ceva se întâmplă, își reia execuția (dar nu neapărat în același fir!). În cazul nostru, vom aștepta furculițele.

SemaphoreSlim are pentru aceasta WaitAsync() metodă. Iată implementarea folosind acest pattern.

// Запуск такой же, как раньше. Где-нибудь в программе:
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 cu async / await se transformă într-un automat finit inteligent, care returnează imediat Task. Prin acesta, putem aștepta finalizarea metodei, o putem anula și tot ce se poate face cu Task. În interiorul metodei, automatul finit controlează execuția. Esența este că, dacă nu există întârziere, execuția este sincronă, iar dacă există, firul este eliberat. Pentru o mai bună înțelegere a acestuia, este mai bine să vedem acest automat finit. Se pot crea lanțuri din aceste async / await metode.

Să testăm. Funcționarea a 100 de filozofi pe o mașină cu 4 nuclee logice, 8 secunde. Soluția anterioară cu Monitor a executat doar primele 4 fire, iar celelalte nu au fost executate deloc. Fiecare dintre cele 4 fire a fost inactiv aproximativ 2 ms. Iar soluția cu async / await a executat toate cele 100, fiecare așteptând în medie 6.8 secunde. Bineînțeles, în sistemele reale, o inactivitate de 6 secunde este inacceptabilă și este mai bine să nu procesăm atât de multe solicitări astfel. Soluția cu Monitor s-a dovedit a fi complet ne-scalabilă.

Concluzie

După cum se poate observa din aceste exemple mici, .NET suportă multe construcții de sincronizare. Totuși, nu este întotdeauna evident cum să le folosești. Sper că acest articol a fost util. Deocamdată ne oprim aici, dar mai sunt multe lucruri interesante, cum ar fi colecțiile thread-safe, TPL Dataflow, programarea reactivă, modelul Software Transaction și altele.

Surse

Sursa: habr.com

Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS 🔥 Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS | ProHoster