Filosofi sazi o programmazione concorrente su .NET

Filosofi sazi o programmazione concorrente su .NET

Esploriamo come funziona la programmazione concorrente e parallela in .NET, utilizzando il problema dei filosofi a cena come esempio. Il piano è coprire tutto, dalla sincronizzazione dei thread/processi, fino al modello degli attori (nelle parti successive). Questo articolo può essere utile per un'introduzione iniziale o per rinfrescare le proprie conoscenze.

Perché è importante saperlo? I transistor stanno raggiungendo le loro dimensioni minime, la legge di Moore incontra il limite della velocità della luce e quindi la crescita è osservata nel numero di transistor, che possono essere prodotti in maggiore quantità. Allo stesso tempo, la quantità di dati cresce, e gli utenti si aspettano una reazione immediata dai sistemi. In una tale situazione, la programmazione "normale", con un solo thread in esecuzione, non è più efficace. È necessario affrontare il problema dell'esecuzione simultanea o concorrente. Inoltre, questo problema esiste a diversi livelli: a livello di thread, a livello di processi, a livello di macchine in rete (sistemi distribuiti). In .NET ci sono tecnologie di qualità, provate nel tempo, per risolvere rapidamente ed efficacemente tali problemi.

Compito

Edsger Dijkstra ha posto questa problematica ai suoi studenti già nel 1965. La formulazione consolidata è la seguente. Ci sono un certo numero (di solito cinque) di filosofi e altrettante forchette. Sono seduti attorno a un tavolo rotondo, con le forchette tra di loro. I filosofi possono mangiare dai loro piatti con cibo illimitato, riflettere o aspettare. Per mangiare, un filosofo deve prendere due forchette (l'ultimo condivide una forchetta con il primo). Prendere e posare una forchetta sono due azioni distinte. Tutti i filosofi sono silenziosi. La sfida è trovare un algoritmo affinché tutti pensino e siano sazi anche dopo 54 anni.

Iniziamo a risolvere questo problema utilizzando uno spazio condiviso. Le forchette sono posizionate su un tavolo comune e i filosofi le prendono semplicemente quando devono mangiare e le ripongono. Qui sorgono problemi di sincronizzazione, come quando è giusto prendere le forchette? Cosa fare se non ci sono forchette? e così via. Ma prima di tutto, facciamo partire 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;
    // Inserire il compito nella coda del pool di thread. Il metodo RunDeadlock non viene avviato 
    // immediatamente, ma attende il proprio thread. Avvio asincrono.
    philosophers[i] = Task.Run(() => create(icopy), cancelTokenSource.Token);
}

Il pool di thread è stato creato per ottimizzare la creazione e la cancellazione dei thread. Questo pool ha una coda di attività e il CLR crea o rimuove thread a seconda del numero di tali attività. C'è un pool per tutti gli AppDomain. Questo pool dovrebbe essere utilizzato quasi sempre, poiché non è necessario preoccuparsi della creazione, cancellazione dei thread, delle loro code, ecc. Si può fare anche senza pool, ma in tal caso è necessario utilizzare direttamente Thread, è conveniente nei casi in cui è necessario modificare la priorità di un thread, quando abbiamo un'operazione lunga, per un thread in primo piano e altro ancora.

In altre parole, System.Threading.Tasks.Task classe è la stessa Thread, ma con varie comodità: la possibilità di avviare un task dopo un gruppo di altri task, restituirli da funzioni, interromperli facilmente e molto altro. Sono necessari per supportare le costruzioni async/await (Task-based Asynchronous Pattern, zucchero sintattico per aspettare operazioni IO). Di questo parleremo ulteriormente.

CancelationTokenSource qui è necessario affinché il thread possa terminare in modo autonomo su segnale del thread chiamante.

Problemi di sincronizzazione

Filosofi bloccati

Bene, sappiamo creare thread, proviamo a pranzare:

// Кто какие вилки взял. К примеру: 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 prima proviamo a prendere la forchetta sinistra e poi quella destra e, se ci riesce, mangiamo e le rimettiamo a posto. Prendere una forchetta è atomico, cioè due thread non possono prenderne una contemporaneamente (falso: il primo legge che la forchetta è libera, anche il secondo, il primo prende, il secondo prende). Per questo Interlocked.CompareExchange, che deve essere implementato usando l'istruzione del processore (TSL, XCHG), che blocca una sezione di memoria per la lettura e scrittura atomica in sequenza. E SpinWait è equivalente alla costruzione while(true) solo con un pizzico di «magia» — il thread occupa il processore (Thread.SpinWait), ma a volte cede il controllo a un altro thread (Thread.Yeild) o si addormenta (Thread.Sleep).

Ma questa soluzione non funziona, poiché i thread vengono presto (per me in un secondo) bloccati: tutti i filosofi prendono la loro forchetta sinistra, ma non quella destra. L'array di fork ha allora un significato: 1 2 3 4 5.

Filosofi sazi o programmazione concorrente su .NET

Nell'immagine, il blocco dei thread (deadlock). In verde — esecuzione, in rosso — sincronizzazione, in grigio — il thread è in pausa. I rombi indicano il tempo di avvio dei Task.

Fame dei filosofi

Sebbene per pensare non serva speciale cibo, la fame può costringere chiunque a lasciare la filosofia. Proviamo a simulare una situazione di fame dei thread nel nostro compito. La fame è quando un thread è attivo, ma non svolge un lavoro sostanziale, in altre parole, è lo stesso deadlock, solo che ora il thread non è in pausa, ma cerca attivamente di 'mangiare', ma non c'è cibo. Per evitare frequenti blocchi, metteremo la forchetta indietro se non riusciamo a prendere 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)
    );
}

In questo codice, ciò che è importante è che due dei quattro filosofi dimenticano di prendere la forchetta sinistra. Così facendo, mangiano più cibo, mentre gli altri iniziano a digiuno, nonostante i thread abbiano la stessa priorità. Qui non stanno proprio digiunando, poiché i filosofi scorretti rimettendo di tanto in tanto le loro forchette. Risulta che i filosofi buoni mangiano circa cinque volte meno rispetto ai cattivi. Quindi, un piccolo errore nel codice porta a una riduzione delle prestazioni. È importante notare che potrebbe verificarsi una situazione rara in cui tutti i filosofi prendono la forchetta sinistra, la forchetta destra non c'è, rimettono la sinistra, aspettano, riprendono la sinistra, e così via. Anche questa situazione è una forma di digiuno, più simile a un blocco reciproco. Non sono riuscito a riprodurla. Qui sotto c'è un'immagine per la situazione in cui due cattivi filosofi hanno preso entrambe le forchette, mentre due buoni stanno digiunando.

Filosofi sazi o programmazione concorrente su .NET

Qui si vede che i thread si risvegliano a volte e provano a ottenere le risorse. Due core su quattro non fanno nulla (grafico verde in alto).

Morte del filosofo

E un altro problema che può interrompere il glorioso pranzo dei filosofi è se uno di loro muore improvvisamente con le posate in mano (e lo seppelliscono così). In tal caso, i vicini resteranno senza pranzo. Puoi inventare un esempio di codice per questa situazione, ad esempio viene sollevato NullReferenceException dopo che il filosofo prende le posate. E, a proposito, l'eccezione non sarà gestita e il codice chiamante non la catturerà semplicemente (per questo AppDomain.CurrentDomain.UnhandledException e altro). Pertanto, i gestori degli errori sono necessari nei thread stessi e con una corretta terminazione.

Cameriere

Bene, come possiamo risolvere questo problema di deadlock, starvation e morti? Consentiamo solo a un filosofo di prendere le posate, aggiungiamo l'esclusione mutua (mutual exclusion) dei thread per questo punto. Come possiamo fare questo? Supponiamo che accanto ai filosofi ci sia un cameriere, che dà il permesso a un filosofo di prendere le posate. Come realizziamo questo cameriere e come i filosofi gli chiederanno, sono domande interessanti.

Il modo più semplice è quando i filosofi chiedono costantemente al cameriere di dare loro accesso alle posate. Cioè, ora i filosofi non aspettano più le posate a portata di mano, ma aspettano o chiedono al cameriere. Iniziamo usando solo lo Spazio Utente, in cui non usiamo interruzioni per invocare procedure del nucleo (di cui parleremo dopo).

Soluzioni nello spazio utente

Qui faremo anche quello che in precedenza facevamo con una posata e due filosofi; gireremo in ciclo e aspetteremo. Ma ora saranno tutti i filosofi e ci sarà in pratica solo una posata, cioè si può dire che può mangiare solo il filosofo che ha preso questa "posata d'oro" dal cameriere. Per questo useremo SpinLock.

private static SpinLock spinLock = new SpinLock();  // Il nostro "cameriere"
private void RunSpinLock(int i, CancellationToken token)
{
    while (true)
    {
        // Blocco reciproco tramite attesa attiva. Chiamiamo fino a try, per
        // sollevare un'eccezione in caso di errore nello SpinLock.
        bool hasLock = false;
        spinLock.Enter(ref hasLock);
        try
        {
            // Qui può esserci solo un thread (mutual exclusion).
            forks[Left(i)] = i + 1;  // Prendiamo la forchetta immediatamente, senza aspettare.
            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();  // Evitiamo il problema della morte del filosofo.
        }

        Think(i);

        if (token.IsCancellationRequested)
            break;
    }
}

SpinLock è un blocco, più o meno, con lo stesso while(true) { if (!lock) break; }, ma con ancora più «magia» di SpinWait (che viene utilizzato lì). Ora è in grado di contare quelli in attesa, di farli addormentare un po' e molto altro. In generale, fa tutto il possibile per ottimizzare. Ma bisogna ricordare che si tratta pur sempre di un ciclo attivo che consuma risorse della CPU e mantiene un flusso, il quale può portare a starvazione se uno dei filosofi diventa prioritario rispetto agli altri, ma non dispone di una forchetta d’oro (problema dell'inversione di priorità). Pertanto, lo utilizziamo solo per cambiamenti molto brevi nella memoria condivisa, senza chiamate esterne, blocchi nidificati e altre sorprese.

Filosofi sazi o programmazione concorrente su .NET

Illustrazione per SpinLock. I thread sono costantemente "in guerra" per la forchetta d'oro. Si verificano fallimenti — nell'area evidenziata nell'illustrazione. I core non sono utilizzati completamente: solo circa 2/3 da questi quattro thread.

Un'altra soluzione qui sarebbe utilizzare solo Interlocked.CompareExchange con la stessa attesa attiva, come mostrato nel codice sopra (nei filosofi affamati), ma questo, come già detto, può teoricamente portare a un blocco.

Riguardo Interlocked è importante dire che lì non c'è solo CompareExchange, ma anche altri metodi per la lettura e la scrittura atomica. E tramite ripetute modifiche nel caso in cui un altro thread riesca a apportare le proprie modifiche (lettura 1, lettura 2, scrittura 2, scrittura 1, errata), può essere utilizzato per modifiche complesse di un singolo valore (pattern Interlocked Anything).

Soluzioni in modalità kernel

Per evitare la perdita di risorse in un ciclo, vediamo come possiamo bloccare un thread. In altre parole, continuando il nostro esempio, vediamo come un cameriere faccia addormentare un filosofo e lo risvegli solo quando è necessario. Innanzitutto, analizziamo come farlo attraverso la modalità kernel del sistema operativo. Tutte le strutture lì si rivelano spesso più lente di quelle nello spazio utente. Più lente di diversi ordini di grandezza, ad esempio AutoResetEvent può essere fino a 53 volte più lenta SpinLock [Richter]. Ma con il loro aiuto è possibile sincronizzare processi in tutto il sistema, sia gestiti che non.

La costruzione principale qui è un semaforo, proposto da Dijkstra oltre mezzo secolo fa. Un semaforo è, semplificando, un numero intero positivo gestito dal sistema, con due operazioni su di esso: aumentare e diminuire. Se non è possibile diminuire, scende a zero e il thread chiamante viene bloccato. Quando il numero viene aumentato da un altro thread/processo attivo, i thread vengono passati e il semaforo viene diminuito di nuovo in base al numero passato. Si può immaginare un treno in un punto stretto con un semaforo. .NET offre diverse costruzioni con funzioni simili: AutoResetEvent, ManualResetEvent, Mutex e di esso Semaphore. Useremo AutoResetEvent, questa è la più semplice di queste costruzioni: solo due valori 0 e 1 (false, true). Il suo metodo WaitOne() blocca il thread chiamante se il valore è 0, e se è 1, lo abbassa a 0 e lo passa. Il metodo Set() aumenta a 1 e passa uno in attesa, che lo abbassa di nuovo a 0. Funziona come un tornello nella metropolitana.

Complichiamo la soluzione e utilizziamo un blocco per ogni filosofo, e non per tutti insieme. Cioè, ora possono esserci più filosofi contemporaneamente, non solo uno. Ma blocchiamo di nuovo l'accesso al tavolo per prendere le posate in modo corretto, evitando le 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 accadendo, consideriamo il caso in cui il filosofo non riesce a prendere le posate, allora le sue azioni saranno le seguenti. Attende l'accesso al tavolo. Una volta ottenuto, prova a prendere le posate. Non ci riesce. Rilascia l'accesso al tavolo (mutua esclusione). E passa il suo "tornello" (AutoResetEvent) (inizialmente sono aperti). Rientra nel ciclo, poiché non ha posate. Prova a prenderle e si ferma al suo "tornello". Qualche vicino più fortunato a destra o a sinistra, che ha finito di mangiare, sblocca il nostro filosofo, "aprendo il suo tornello". Il nostro filosofo lo attraversa (e il tornello si chiude dietro di lui) per la seconda volta. Prova una terza volta a prendere le posate. Con successo. E passa il suo tornello per pranzare.

Quando nel codice si verificano errori casuali (ce ne sono sempre), ad esempio se viene indicato erroneamente un vicino o se viene creato lo stesso oggetto AutoResetEvent per tutti (Enumerable.Repeat), allora i filosofi dovranno già aspettare gli sviluppatori, poiché trovare errori in un codice del genere è un compito piuttosto complesso. Un altro problema di questa soluzione è che non garantisce che qualche filosofo non inizi a digiunare.

Soluzioni ibride

Abbiamo esaminato due approcci alla sincronizzazione: quando rimaniamo in modalità utente e giriamo in un ciclo, e quando blocchiamo il thread tramite il kernel. Il primo metodo è buono per brevi blocchi, il secondo per blocchi più lunghi. Spesso è necessario attendere inizialmente brevemente una modifica della variabile nel ciclo, e poi bloccare il thread quando l'attesa è lunga. Questo approccio è implementato nelle cosiddette strutture ibride. Qui ci sono le stesse strutture che erano per la modalità kernel, ma ora con un ciclo in modalità utente: SemaphorSlim, ManualResetEventSlim e altri. La costruzione più popolare qui è Monitor, poiché in C# c'è un noto lock sintassi. Monitor è lo stesso semaforo con un valore massimo di 1 (mutex), ma con supporto per l'attesa in ciclo, ricorsione, il pattern della Condition Variable (di cui parleremo sotto) e altro ancora. Analizziamo la 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);
    }
}

Qui blocchiamo di nuovo l'intero tavolo per l'accesso alle forchette, ma ora sblocchiamo tutto il flusso contemporaneamente anziché solo i vicini quando qualcuno finisce di mangiare. Cioè, inizialmente, qualcuno mangia e blocca i vicini, e quando questa persona finisce e desidera mangiare di nuovo immediatamente, passa nel blocco e sveglia i suoi vicini, poiché il suo tempo di attesa è inferiore.

In questo modo evitiamo i deadlock e la fame di qualche filosofo. Usiamo un ciclo per brevi attese e blocchiamo il flusso per periodi prolungati. Sbloccare immediatamente tutti funziona più lentamente rispetto a sbloccare solo il vicino, come nella soluzione con AutoResetEvent, ma la differenza non dovrebbe essere grande, poiché i flussi devono rimanere in modalità utente all'inizio.

A lock La sintassi presenta spiacevoli sorprese. Si raccomanda di usare Monitor direttamente [Richter] [Eric Lippert]. Uno di questi è che lock esce sempre da Monitor, anche se c'era un'eccezione, e allora un altro flusso può modificare lo stato della memoria condivisa. In questi casi, è spesso meglio entrare in deadlock o in qualche modo terminare il programma in modo sicuro. Un'altra sorpresa è che Monitor utilizza blocchi di sincronizzazione (SyncBlock), che sono presenti in tutti gli oggetti. Pertanto, se viene selezionato un oggetto non appropriato, è facile trovarsi in un deadlock (ad esempio, se si effettua un lock su una stringa internata). Utilizziamo sempre un oggetto nascosto per questo.

Il pattern Condition Variable consente di implementare più brevemente l'attesa di una qualche condizione complessa. In .NET è incompleto, a mio avviso, poiché dovrebbe esserci più di una coda su diverse variabili (come nei Posix Threads), e non solo su un lock. Allora sarebbe possibile crearle per tutti i filosofi. Ma anche in questa forma consente di ridurre il codice.

Molti filosofi oppure async / await

Bene, ora sappiamo come bloccare efficacemente i flussi. Ma cosa succede se abbiamo molti filosofi? 100? 10.000? Ad esempio, ci sono arrivati 100.000 richieste al server web. Creare un thread per ogni richiesta sarebbe gravoso, poiché non sarà possibile eseguire così tanti thread in parallelo. Solo il numero di thread che corrisponde ai core logici (io ne ho 4) sarà eseguito. Tutti gli altri semplicemente consumeranno risorse. Una soluzione a questo problema è il pattern async/await. L'idea è che la funzione non occupa un thread se ha bisogno di aspettare qualcosa per continuare. E quando quella cosa accade, riprende la sua esecuzione (ma non necessariamente nello stesso thread!). Nel nostro caso, aspetteremo la fork.

SemaphoreSlim ha per questo WaitAsync() metodo. Ecco un'implementazione che usa questo 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();
}

Il metodo async / await viene traslato in un astuto automa finale che restituisce immediatamente il proprio interno Compito. Attraverso di esso è possibile attendere il completamento di un metodo, annullarlo e fare tutto ciò che si può fare con Task. All'interno del metodo, un automa finito controlla l'esecuzione. La sostanza è che se non ci sono ritardi, l'esecuzione è sincrona, mentre se ci sono, il thread si libera. Per una migliore comprensione di questo è meglio osservare questo automa finito. È possibile creare catene di questi async / await metodi.

Testiamo. Il lavoro di 100 filosofi su una macchina con 4 core logici, 8 secondi. La soluzione precedente con Monitor eseguiva solo i primi 4 thread, mentre gli altri non venivano eseguiti affatto. Ognuno di questi 4 thread ha atteso circa 2 ms. La soluzione con async / await ha invece eseguito tutti e 100, con un tempo medio di attesa di 6,8 secondi ciascuno. Certamente, in sistemi reali, un'attesa di 6 secondi non è accettabile e sarebbe meglio non elaborare così tante richieste in questo modo. La soluzione con Monitor si è rivelata completamente non scalabile.

Conclusione

Come si può vedere da questi brevi esempi, .NET supporta molte costrizioni per la sincronizzazione. Tuttavia, non è sempre ovvio come utilizzarle. Spero che questo articolo sia stato utile. Per ora concludiamo qui, ma ci sono molte altre cose interessanti da esplorare, come le collezioni thread-safe, TPL Dataflow, programmazione reattiva, il modello di transazione software e altro ancora.

Fonti

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster