Filosofi ben nutriti o programmazione competitiva in .NET

Filosofi ben nutriti o programmazione competitiva in .NET

Diamo un'occhiata a come funziona la programmazione simultanea e parallela in .Net, utilizzando l'esempio del problema dei filosofi a pranzo. Il piano è il seguente, dalla sincronizzazione thread/processo al modello ad attori (nelle parti seguenti). L'articolo può essere utile per una prima conoscenza o per rinfrescare le proprie conoscenze.

Perché sapere come farlo? I transistor stanno raggiungendo la dimensione minima, la legge di Moore raggiunge il limite della velocità della luce e quindi si osserva una crescita numerica; è possibile produrre più transistor. Allo stesso tempo, la quantità di dati cresce e gli utenti si aspettano una risposta immediata dai sistemi. In una situazione del genere, la programmazione “normale”, quando abbiamo un thread in esecuzione, non è più efficace. Dobbiamo in qualche modo risolvere il problema dell'esecuzione simultanea o simultanea. Inoltre, questo problema esiste a diversi livelli: a livello di thread, a livello di processo, a livello di macchine in rete (sistemi distribuiti). .NET dispone di tecnologie di alta qualità e collaudate nel tempo per risolvere tali problemi in modo rapido ed efficiente.

Compito

Edsger Dijkstra pose questo problema ai suoi studenti nel 1965. La formulazione stabilita è la seguente. Esistono un certo numero (di solito cinque) di filosofi e lo stesso numero di forchette. Si siedono a un tavolo rotondo, con la forchetta in mezzo. I filosofi possono mangiare dai loro piatti di cibo infinito, pensare o aspettare. Per mangiare, un filosofo deve prendere due forchette (la seconda condivide la forchetta con la prima). Prendere e posare una forchetta sono due azioni separate. Tutti i filosofi tacciono. Il compito è trovare un algoritmo tale che tutti pensino e siano ben nutriti anche dopo 54 anni.

Innanzitutto, proviamo a risolvere questo problema utilizzando lo spazio condiviso. Le forchette giacciono sulla tavola comune e i filosofi semplicemente le prendono quando ci sono e le rimettono a posto. È qui che sorgono problemi con la sincronizzazione, quando prendere esattamente le forche? cosa fare se non c'è la spina? ecc. Ma prima cominciamo con i filosofi.

Per avviare i thread utilizziamo un pool di thread tramite Task.Run metodo:

var cancelTokenSource = new CancellationTokenSource();
Action<int> create = (i) => RunPhilosopher(i, cancelTokenSource.Token);
for (int i = 0; i < philosophersAmount; i++) 
{
    int icopy = i;
    // Поместить задачу в очередь пула потоков. Метод RunDeadlock не запускаеться 
    // сразу, а ждет своего потока. Асинхронный запуск.
    philosophers[i] = Task.Run(() => create(icopy), cancelTokenSource.Token);
}

Il pool di thread è progettato per ottimizzare la creazione e la rimozione dei thread. Questo pool ha una coda di attività e CLR crea o elimina thread in base al numero di queste attività. Un pool per tutti gli AppDomain. Questa piscina dovrebbe essere utilizzata quasi sempre, perché... non c'è bisogno di preoccuparsi di creare ed eliminare thread, relative code, ecc. Puoi farlo senza pool, ma poi dovrai usarlo direttamente Thread, questo è utile nei casi in cui dobbiamo modificare la priorità di un thread, quando abbiamo un'operazione lunga, per un thread in primo piano, ecc.

In altre parole, System.Threading.Tasks.Task la classe è la stessa Thread, ma con ogni sorta di comodità: la possibilità di avviare un'attività dopo un blocco di altre attività, restituirle dalle funzioni, interromperle comodamente e molto altro ancora. ecc. Sono necessari per supportare le costruzioni asincrone/in attesa (modello asincrono basato su attività, zucchero sintattico per l'attesa di operazioni IO). Ne parleremo più tardi.

CancelationTokenSource qui è necessario che il thread possa terminare su un segnale dal thread chiamante.

Problemi di sincronizzazione

Filosofi bloccati

Ok, sappiamo come creare thread, proviamo il pranzo:

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

Qui proviamo a prendere prima il bivio a sinistra e poi quello a destra, e se funziona, mangiamo e li rimettiamo a posto. Prendere una forchetta è atomico, cioè due thread non possono prenderne uno contemporaneamente (sbagliato: il primo legge che il fork è libero, il secondo fa lo stesso, il primo prende, il secondo prende). Per questo Interlocked.CompareExchange, che deve essere implementato utilizzando un'istruzione del processore (TSL, XCHG), che blocca un pezzo di memoria per la lettura e la scrittura sequenziale atomica. E SpinWait è equivalente alla costruzione while(true) solo con un po' di "magia": il thread occupa il processore (Thread.SpinWait), ma a volte passa il controllo a un altro thread (Thread.Yeild) o si addormenta (Thread.Sleep).

Ma questa soluzione non funziona, perché... i fili presto (nel giro di un secondo per me) si bloccano: tutti i filosofi prendono il bivio a sinistra, ma non esiste quello a destra. L'array forks avrà quindi i valori: 1 2 3 4 5.

Filosofi ben nutriti o programmazione competitiva in .NET

Nella foto, thread di blocco (deadlock). Il verde indica l'esecuzione, il rosso indica la sincronizzazione e il grigio indica che il thread è inattivo. I diamanti indicano l'ora di avvio delle attività.

La fame dei filosofi

Sebbene non sia necessario molto cibo per pensare, la fame può costringere chiunque a rinunciare alla filosofia. Proviamo a simulare la situazione di carenza di thread nel nostro problema. La fame è quando il thread funziona, ma senza un lavoro significativo, in altre parole, è la stessa situazione di stallo, solo che ora il thread non dorme, ma cerca attivamente qualcosa da mangiare, ma non c'è cibo. Per evitare frequenti intasamenti, rimetteremo la forchetta se non potessimo prenderne un'altra.

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

La cosa importante di questo codice è che due filosofi su quattro dimenticano di posare la forchetta sinistra. E si scopre che mangiano più cibo e altri iniziano a morire di fame, sebbene i fili abbiano la stessa priorità. Qui non stanno morendo del tutto di fame, perché... i cattivi filosofi a volte rimettono a posto la forchetta. Si scopre che quelli buoni mangiano circa 5 volte meno di quelli cattivi. Quindi un piccolo errore nel codice porta ad un calo delle prestazioni. Qui vale anche la pena notare che è possibile una rara situazione in cui tutti i filosofi prendono la biforcazione a sinistra, non c'è quella a destra, mettono giù quella a sinistra, aspettano, prendono di nuovo quella a sinistra, ecc. Anche questa situazione è fame, più simile a un blocco reciproco. Non potrei ripeterlo. Di seguito è riportata un'immagine di una situazione in cui due cattivi filosofi hanno preso entrambe le forchette e due buoni stanno morendo di fame.

Filosofi ben nutriti o programmazione competitiva in .NET

Qui puoi vedere che i thread a volte si attivano e provano a ottenere una risorsa. Due core su quattro non fanno nulla (grafico verde sopra).

Morte di un filosofo

Ebbene, un altro problema che potrebbe interrompere la gloriosa cena dei filosofi è se uno di loro muore improvvisamente con la forchetta in mano (e verrà sepolto in quel modo). Quindi i vicini rimarranno senza pranzo. Puoi inventare tu stesso un codice di esempio per questo caso, ad esempio viene buttato via NullReferenceException dopo che il filosofo prende le forchette. E, comunque, l'eccezione non verrà gestita e il codice chiamante non la catturerà semplicemente (per questo AppDomain.CurrentDomain.UnhandledException e così via.). Pertanto, sono necessari gestori di errori nei thread stessi e con terminazione corretta.

cameriere

Ok, come risolviamo questo problema di impasse, fame e morti? Permetteremo un solo filosofo ai bivi e aggiungeremo la mutua esclusione dei thread per questo posto. Come farlo? Supponiamo che accanto ai filosofi ci sia un cameriere che dà il permesso a un filosofo di prendere le forchette. Come dovremmo fare questo cameriere e come glielo porranno i filosofi sono domande interessanti.

Il modo più semplice per i filosofi è semplicemente chiedere costantemente al cameriere l'accesso alle forchette. Quelli. Ora i filosofi non aspetteranno una forchetta nelle vicinanze, ma aspetteranno o chiederanno al cameriere. Inizialmente utilizziamo solo lo User Space per questo; in esso non utilizziamo gli interrupt per chiamare alcuna procedura dal kernel (ne parleremo più avanti).

Soluzioni per lo spazio utente

Qui faremo la stessa cosa che abbiamo fatto prima con un fork e due filosofi, gireremo in loop e aspetteremo. Ma ora saranno tutti filosofi e, per così dire, solo una forchetta, ad es. possiamo dire che mangerà solo il filosofo che ha preso questa “forchetta d'oro” dal cameriere. Per fare questo utilizziamo SpinLock.

private static SpinLock spinLock = new SpinLock();  // Наш "официант"
private void RunSpinLock(int i, CancellationToken token)
{
    while (true)
    {
        // Взаимная блокировка через busy waiting. Вызываем до try, чтобы
        // выбрасить исключение в случае ошибки в самом SpinLock.
        bool hasLock = false;
        spinLock.Enter(ref hasLock);
        try
        {
            // Здесь может быть только один поток (mutual exclusion).
            forks[Left(i)] = i + 1;  // Берем вилку сразу, без ожидания.
            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();  // Избегаем проблемы со смертью философа.
        }

        Think(i);

        if (token.IsCancellationRequested)
            break;
    }
}

SpinLock questo è un bloccante, con, grosso modo, lo stesso while(true) { if (!lock) break; }, ma con ancora più “magia” che in SpinWait (che viene utilizzato lì). Ora sa contare quelli che aspettano, addormentarli un po' e molto altro ancora. ecc. In generale, fa tutto il possibile per ottimizzare. Ma dobbiamo ricordare che questo è sempre lo stesso ciclo attivo che divora le risorse del processore e trattiene un thread, che può portare alla fame se uno dei filosofi diventa più prioritario degli altri, ma non ha un fork d'oro (problema di inversione di priorità ). Pertanto, lo utilizziamo solo per modifiche molto brevi nella memoria condivisa, senza chiamate di terze parti, blocchi annidati o altre sorprese.

Filosofi ben nutriti o programmazione competitiva in .NET

Disegnare per SpinLock. I flussi "combattono" costantemente per la forchetta d'oro. Si verificano guasti: l'area evidenziata nella figura. I nuclei non sono completamente utilizzati: solo circa 2/3 da questi quattro thread.

Un'altra soluzione qui sarebbe quella di utilizzare solo Interlocked.CompareExchange con la stessa attesa attiva mostrata nel codice sopra (nei filosofi affamati), ma questo, come già detto, teoricamente potrebbe portare al blocco.

Про Interlocked vale la pena dire che non c'è solo CompareExchange, ma anche altri metodi per la lettura E la scrittura atomica. E ripetendo la modifica, se un altro thread riesce ad apportare le sue modifiche (leggi 1, leggi 2, scrivi 2, scrivi 1 è sbagliato), può essere utilizzato per modifiche complesse a un valore (modello Interlocked Anything).

Soluzioni in modalità kernel

Per evitare di sprecare risorse in un ciclo, vediamo come bloccare un thread. In altre parole, continuando il nostro esempio, vediamo come il cameriere addormenta il filosofo e lo sveglia solo quando necessario. Per prima cosa, vediamo come farlo tramite la modalità kernel del sistema operativo. Tutte le strutture presenti spesso finiscono per essere più lente di quelle nello spazio utente. Più lentamente più volte, per esempio AutoResetEvent forse 53 volte più lento SpinLock [Richter]. Ma con il loro aiuto, puoi sincronizzare i processi nell'intero sistema, gestito o meno.

Il progetto di base qui è un semaforo, proposto da Dijkstra più di mezzo secolo fa. Un semaforo è, in poche parole, un numero intero positivo controllato dal sistema e due operazioni su di esso: aumento e diminuzione. Se non è possibile ridurre lo zero, il thread chiamante viene bloccato. Quando il numero viene aumentato da qualche altro thread/processo attivo, i thread vengono passati e il semaforo viene nuovamente diminuito del numero passato. Puoi immaginare i treni in un collo di bottiglia con un semaforo. .NET offre diversi costrutti con funzionalità simili: AutoResetEvent, ManualResetEvent, Mutex e me stesso Semaphore. Noi useremo AutoResetEvent, questo è il più semplice di questi costrutti: solo due valori 0 e 1 (falso, vero). Il suo metodo WaitOne() blocca il thread chiamante se il valore era 0 e, se 1, lo retrocede a 0 e lo salta. Un metodo Set() aumenta a 1 e lascia passare una persona, che diminuisce nuovamente a 0. Funziona come un tornello nella metropolitana.

Complichiamo la soluzione e utilizziamo il blocco per ciascun filosofo e non per tutti in una volta. Quelli. Ora più filosofi possono mangiare contemporaneamente, e non solo uno. Ma blocchiamo nuovamente l'accesso al tavolo per poter prendere correttamente le forchette, evitando condizioni di gara.

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

Per capire cosa sta succedendo qui, considera il caso in cui il filosofo non è riuscito a prendere le forche, quindi le sue azioni saranno le seguenti. Sta aspettando l'accesso al tavolo. Dopo averlo ricevuto, cerca di prendere le forchette. Non ha funzionato. Dà diritto all'accesso al tavolo (mutua esclusione). E passa il suo “tornello” (AutoResetEvent) (all'inizio sono aperti). Cade di nuovo nel ciclo, perché non ha le forchette. Cerca di prenderli e si ferma al suo “tornello”. Qualche vicino più fortunato a destra o a sinistra, finito di mangiare, sbloccherà il nostro filosofo “aprendo il suo tornello”. Il nostro filosofo la percorre (e si chiude alle sue spalle) una seconda volta. Cerca per la terza volta di prendere le forchette. Riuscito. E passa dal tornello per pranzare.

Quando ci sono errori casuali in tale codice (esistono sempre), ad esempio, un vicino verrà specificato in modo errato o verrà creato lo stesso oggetto AutoResetEvent per tutti (Enumerable.Repeat), allora i filosofi aspetteranno gli sviluppatori, perché Trovare errori in tale codice è un compito piuttosto difficile. Un altro problema di questa soluzione è che non garantisce che qualche filosofo non muoia di fame.

Soluzioni ibride

Abbiamo esaminato due approcci alla sincronizzazione, quando rimaniamo in modalità utente e giriamo in loop e quando blocchiamo il thread tramite il kernel. Il primo metodo va bene per i blocchi corti, il secondo per quelli lunghi. Spesso è necessario prima attendere brevemente affinché una variabile cambi in un ciclo, quindi bloccare il thread quando l'attesa è lunga. Questo approccio è implementato nel cosiddetto. disegni ibridi. Ha gli stessi costrutti della modalità kernel, ma ora con un ciclo in modalità utente: SemaphorSlim, ManualResetEventSlim ecc. Il design più popolare qui è Monitor, Perché in C# c'è un noto lock sintassi. Monitor questo è lo stesso semaforo con un valore massimo di 1 (mutex), ma con il supporto per l'attesa in un ciclo, la ricorsione, il modello della variabile condizionale (ne parleremo più avanti), ecc. Vediamo una soluzione con esso.

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

Anche in questo caso blocchiamo l'accesso alle forchette all'intero tavolo, ma ora sblocchiamo tutti i thread contemporaneamente, anziché quelli vicini quando qualcuno finisce di mangiare. Quelli. prima qualcuno mangia e blocca i vicini, e quando questo qualcuno finisce, ma vuole mangiare subito di nuovo, entra nel blocco e sveglia i vicini, perché il suo tempo di attesa è inferiore.

In questo modo evitiamo impasse e la fame di qualche filosofo. Usiamo un ciclo per attendere un breve periodo e bloccare il thread per un lungo periodo. Sbloccare tutti contemporaneamente è più lento che se fosse sbloccato solo il vicino, come nella soluzione con AutoResetEvent, ma la differenza non dovrebbe essere grande, perché i thread devono prima rimanere in modalità utente.

У lock la sintassi riserva alcune spiacevoli sorprese. Consigliato da usare Monitor direttamente [Richter] [Eric Lippert]. Uno di questi è quello lock esce sempre Monitor, anche se si è verificata un'eccezione, quindi un altro thread può modificare lo stato della memoria condivisa. In questi casi, spesso è meglio entrare in una situazione di stallo o terminare in qualche modo in modo sicuro il programma. Un'altra sorpresa è che Monitor utilizza blocchi di clock (SyncBlock), che sono presenti in tutti gli oggetti. Pertanto, se viene selezionato un oggetto inappropriato, si può facilmente ottenere un deadlock (ad esempio, se si blocca una stringa internata). Usiamo sempre un oggetto nascosto per questo.

Il modello Variabile condizionale consente di implementare in modo più conciso l'aspettativa di alcune condizioni complesse. In .NET è incompleto, secondo me, perché... In teoria, dovrebbero esserci più code su diverse variabili (come nei Posix Threads) e non su un blocco. Allora sarebbe possibile realizzarli per tutti i filosofi. Ma anche in questa forma permette di abbreviare il codice.

Molti filosofi o async / await

Ok, ora possiamo bloccare efficacemente i thread. E se avessimo molti filosofi? 100? 10000? Ad esempio, abbiamo ricevuto 100000 richieste al server web. Creare un thread per ogni richiesta sarà costoso, perché così tanti thread non verranno eseguiti in parallelo. Verranno eseguiti solo tanti core logici (ne ho 4). E tutti gli altri semplicemente toglieranno risorse. Una soluzione a questo problema è il modello asincrono/attende. La sua idea è che una funzione non mantiene un thread se deve attendere che qualcosa continui. E quando succede qualcosa, riprende la sua esecuzione (ma non necessariamente nello stesso thread!). Nel nostro caso, aspetteremo un bivio.

SemaphoreSlim ha per questo WaitAsync() metodo. Ecco un'implementazione che utilizza questo modello.

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

Metodo con async / await si traduce in un'astuta macchina a stati finiti, che restituisce immediatamente il suo interno Task. Attraverso di esso, puoi attendere il completamento del metodo, annullarlo e tutto ciò che puoi fare con Task. All'interno del metodo, una macchina a stati controlla l'esecuzione. La conclusione è che se non c'è ritardo, l'esecuzione è sincrona e, se c'è, il thread viene rilasciato. Per una migliore comprensione di ciò, è meglio guardare a questa macchina statale. Puoi creare catene da questi async / await metodi.

Proviamolo. Opera di 100 filosofi su una macchina con 4 nuclei logici, 8 secondi. La soluzione precedente con Monitor eseguiva solo i primi 4 thread e non eseguiva affatto il resto. Ciascuno di questi 4 thread è rimasto inattivo per circa 2 ms. E la soluzione asincrona/attende tutte le 100 operazioni, con una media di 6.8 secondi per ciascuna attesa. Naturalmente, nei sistemi reali, rimanere inattivi per 6 secondi è inaccettabile ed è meglio non elaborare così tante richieste in questo modo. La soluzione con Monitor si è rivelata per nulla scalabile.

conclusione

Come puoi vedere da questi piccoli esempi, .NET supporta molti costrutti di sincronizzazione. Tuttavia, non è sempre ovvio come utilizzarli. Spero che questo articolo sia stato utile. Per ora stiamo concludendo, ma restano ancora molte cose interessanti, ad esempio raccolte thread-safe, flusso di dati TPL, programmazione reattiva, modello di transazione software, ecc.

fonti

Fonte: habr.com

Aggiungi un commento