
Vediamo come è strutturata la programmazione concorrente e parallela in .NET, attraverso l'esempio del problema dei filosofi a cena. Il piano è di passare dalla sincronizzazione dei thread/processi, fino al modello degli attori (nelle parti seguenti). L'articolo potrebbe essere utile per un primo approccio o per rinfrescare le proprie conoscenze.
Perché è importante sapere fare questo? I transistor stanno raggiungendo la loro dimensione minima, la legge di Moore si scontra con il limite della velocità della luce e quindi la crescita è visibile nel numero, è possibile realizzare più transistor. Nel contempo, il volume dei dati aumenta e gli utenti si aspettano una risposta immediata dai sistemi. In questa situazione, la programmazione "normale", con un solo thread in esecuzione, non è più efficace. È necessario trovare un modo per risolvere il problema dell'esecuzione simultanea o concorrente. Inoltre, questo problema si presenta a diversi livelli: a livello di thread, a livello di processi, a livello di macchine in rete (sistemi distribuiti). In .NET esistono tecnologie di alta qualità, collaudate nel tempo, per risolvere rapidamente ed efficacemente tali problematiche.
Compito
Edsger Dijkstra propose questo problema ai suoi studenti già nel 1965. La formulazione consolidata è la seguente. Ci sono un certo numero (di solito cinque) di filosofi e un numero uguale di forchette. Essi si siedono a un tavolo rotondo, con le forchette tra di loro. I filosofi possono mangiare dai loro piatti con cibo infinito, pensare 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. L'obiettivo è trovare un algoritmo tale che tutti possano pensare ed essere sazi anche dopo 54 anni.
Iniziamo a provare a risolvere questo problema utilizzando una sezione condivisa. Le forchette sono posizionate su un tavolo comune e i filosofi le prendono semplicemente quando ci sono e le ripongono. Qui sorgono problemi di sincronizzazione, quando esattamente si devono prendere le forchette? Cosa fare se non ci sono forchette? e altri. Ma per ora, iniziamo ad avviare i filosofi.
Per avviare i thread usiamo 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;
// Mettere il compito nella coda del pool di thread. Il metodo RunDeadlock non viene eseguito
// 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 compiti e il CLR crea o rimuove thread in base al numero di questi compiti. Un pool per tutti gli AppDomain. Questo pool dovrebbe essere utilizzato quasi sempre, poiché non è necessario preoccuparsi di creare, eliminare thread, delle loro code, ecc. Si può anche fare senza pool, ma in tal caso sarà necessario utilizzare direttamente Thread, è opportuno per i casi in cui è necessario cambiare la priorità a un thread, quando abbiamo un'operazione lunga, per un thread di primo piano e altro.
In altre parole, System.Threading.Tasks.Task classe è la stessa Thread, ma con vari comfort: la possibilità di avviare un task dopo un blocco di altri task, restituirli da funzioni, interromperli facilmente e altro ancora. Sono necessari per supportare le strutture async/await (Task-based Asynchronous Pattern, zucchero sintattico per attendere l'operazione IO). Di questo parleremo ancora.
CancelationTokenSource è necessario qui affinché il thread possa terminare autonomamente su segnale del thread chiamante.
Problemi di sincronizzazione
Filosofi bloccati
Bene, sappiamo come 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 proviamo prima a prendere la forchetta sinistra e poi quella destra e se riusciamo, mangiamo e le rimettiamo a posto. Prendere una forchetta è atomico, cioè due thread non possono prendere la stessa contemporaneamente (errato: il primo legge che la forchetta è libera, il secondo anche, il primo prende, il secondo prende). Per questo Interlocked.CompareExchange, che deve essere implementato tramite un'istruzione del processore (TSL, XCHG), che blocca un'area di memoria per una lettura e scrittura atomiche sequenziali. 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.Yield) o si addormenta (Thread.Sleep).
Ma questa soluzione non funziona, poiché i thread si bloccano presto (a me nell'arco di un secondo): tutti i filosofi prendono la propria forchetta sinistra, ma quella destra no. L'array forks allora ha i valori: 1 2 3 4 5.

Nell'immagine, il blocco dei flussi (deadlock). In verde l'esecuzione, in rosso la sincronizzazione, in grigio il flusso è in attesa. I rombi indicano il tempo di avvio dei Task.
Fame dei filosofi
Anche se per pensare non è necessaria una grande quantità di cibo, la fame è in grado di far abbandonare la filosofia a chiunque. Proveremo a simulare la situazione di fame dei flussi nel nostro compito. La fame è quando un flusso sta lavorando, ma senza fare sostanziale lavoro, in altre parole è lo stesso deadlock, solo che ora il flusso non dorme, ma cerca attivamente di 'mangiare', ma non c'è cibo. Per evitare il blocco frequente, riporremo la forchetta se non riusciamo a 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)
);
}In questo codice è importante notare che due dei quattro filosofi dimenticano di riporre la loro forchetta sinistra. E così, mangiano più cibo, mentre gli altri iniziano a morire di fame, anche se i flussi hanno la stessa priorità. Qui non stanno esattamente morendo di fame, poiché i filosofi scarsi a volte mettono indietro le loro forchette. Risultando, i buoni mangiano circa cinque volte meno dei cattivi. Quindi, un piccolo errore nel codice porta a una diminuzione delle prestazioni. Va anche notato che c'è una situazione rara, in cui tutti i filosofi prendono la forchetta sinistra, non hanno la destra, ripongono la sinistra, aspettano, riprendono di nuovo la sinistra e così via. Questa situazione è anch'essa fame, più simile a un deadlock. Non sono riuscito a riprodurla. Qui sotto c'è un'immagine per la situazione in cui due filosofi scarsi hanno preso entrambe le forchette, mentre due buoni stanno morendo di fame.

Qui è visibile che i flussi a volte si risvegliano e cercano di ottenere una risorsa. Due core su quattro non stanno facendo nulla (grafico verde in alto).
Morte del filosofo
E c'è un altro problema che potrebbe interrompere il glorioso pranzo dei filosofi: se uno di loro muore improvvisamente con le forchette in mano (e lo seppelliscono così). Allora i vicini rimarranno senza pranzo. Puoi inventare tu stesso un esempio di codice per questo caso, ad esempio si genera NullReferenceException dopo che il filosofo prende le forchette. E, tra l'altro, l'eccezione non sarà gestita e il codice chiamante non la catturerà così semplicemente (per questo AppDomain.CurrentDomain.UnhandledException ecc.). Pertanto, i gestori di errori sono necessari all'interno dei flussi e con un buon termine.
Cameriere
Bene, come possiamo risolvere questo problema con i deadlock, la fame e le morti? Consentiremo solo un filosofo alla volta di utilizzare le posate, aggiungendo l'esclusione reciproca (mutual exclusion) dei thread per questo posto. Come possiamo fare questa cosa? Supponiamo che accanto ai filosofi ci sia un cameriere che concede il permesso a un solo filosofo di prendere le posate. Come possiamo realizzare questo cameriere e come i filosofi gli chiederanno, domande interessanti.
Il modo più semplice è quando i filosofi chiederanno costantemente al cameriere di dare accesso alle posate. Cioè, ora i filosofi non aspetteranno le posate accanto, ma aspetteranno o chiederanno al cameriere. Per iniziare, utilizzeremo solo lo User Space, in cui non utilizziamo interruzioni per chiamare procedure dal kernel (di cui parleremo più avanti).
Soluzioni nello spazio utente
Qui faremo anche quello che facevamo prima con una forchetta e due filosofi, ci muoveremo in un ciclo e aspetteremo. Ma ora saranno tutti i filosofi e ci sarà solo una forchetta, cioè si può dire che può mangiare solo il filosofo che ha preso questa "forchetta d'oro" dal cameriere. A tal fine utilizziamo SpinLock.
private static SpinLock spinLock = new SpinLock(); // Il nostro "cameriere"
private void RunSpinLock(int i, CancellationToken token)
{
while (true)
{
// Deadlock attraverso il busy waiting. Chiamiamo fino a try, per
// lanciare un'eccezione in caso di errore nel SpinLock stesso.
bool hasLock = false;
spinLock.Enter(ref hasLock);
try
{
// Qui può esserci solo un thread (mutual exclusion).
forks[Left(i)] = i + 1; // Prendiamo la forchetta subito, 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 bloccante, con, a grandi linee, lo stesso while(true) { if (!lock) break; }, ma con ancora più "magia" rispetto a 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 questo è comunque un ciclo attivo, che consuma risorse della CPU e mantiene il flusso, che può portare a fame, se uno dei filosofi diventa prioritario rispetto agli altri, ma non ha la forchetta d'oro (Priority Inversion problem). Pertanto, lo utilizziamo solo per piccole modifiche nella memoria condivisa, senza alcuna chiamata esterna, lock nidificati, e altri sorprese.

Illustrazione per SpinLock. I thread sono costantemente "in guerra" per la forchetta d'oro. Ci sono dei fallimenti — nell'immagine l'area evidenziata. I nuclei non vengono utilizzati completamente: solo circa 2/3 con questi quattro thread.
Un'altra soluzione qui sarebbe utilizzare solo Interlocked.CompareExchange con la stessa attesa attiva, come mostrato nel codice sopra (negli filosofi affamati), ma questo, come già detto, può teoricamente portare a un blocco.
Su Interlocked vale la pena dire che ci sono non solo CompareExchange, ma anche altri metodi per la lettura e scrittura atomica. E attraverso la ripetizione della modifica in caso in cui un altro thread riesca a apportare le proprie modifiche (lettura 1, lettura 2, scrittura 2, scrittura 1 è cattiva), può essere utilizzato per modifiche complesse di un singolo valore (modello Interlocked Anything).
Soluzioni in modalità kernel
Per evitare di perdere risorse nel ciclo, vediamo come possiamo bloccare il thread. In altre parole, continuando il nostro esempio, vediamo come il cameriere possa far addormentare il filosofo e svegliarlo solo quando necessario. Prima di tutto, consideriamo come farlo attraverso la modalità kernel del sistema operativo. Tutte le strutture lì tendono ad essere più lente di quelle nello spazio utente. Più lente di diversi fattori, ad esempio AutoResetEvent può essere 53 volte più lento SpinLock [Richter]. Ma con il loro aiuto si possono sincronizzare i processi in tutto il sistema, gestiti o meno.
La struttura principale qui è un semaforo, proposto da Dijkstra più di mezzo secolo fa. Un semaforo è, in termini semplici, un numero intero positivo, controllato da un sistema, e due operazioni su di esso: aumentare e diminuire. Se non è possibile diminuire a zero, il thread chiamante si blocca. Quando il numero viene aumentato da qualche altro thread/processo attivo, i thread vengono sbloccati, e il semaforo viene nuovamente diminuito dal numero di thread che sono passati. Si possono immaginare dei treni in un punto stretto con un semaforo. .NET offre diverse strutture con funzioni simili: AutoResetEvent, ManualResetEvent, Mutex e stesso Semaphore. Useremo AutoResetEvent, è la più semplice di queste strutture: solo due valori 0 e 1 (false, true). Il suo metodo WaitOne() blocca il thread chiamante se il valore era 0, e se era 1, lo abbassa a 0 e lo allow. Il metodo Set() aumenta a 1 e sblocca un thread in attesa, che a sua volta abbassa a 0. Funziona come un tornello nella metropolitana.
Complichiamo la soluzione e utilizziamo un blocco per ogni filosofo, invece di uno per tutti insieme. Cioè, ora ci possono essere più filosofi contemporaneamente, non solo uno. Ma di nuovo blocchiamo l'accesso al tavolo, per prendere correttamente, evitando condizioni di competizione (race conditions), le posate.
// Для блокирования отдельного философа.
// Инициализируется: 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 stia succedendo, consideriamo il caso in cui un filosofo non riesce a prendere le posate, allora le sue azioni saranno queste. Aspetta di accedere 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). Ritorna nel ciclo, poiché non ha le posate. Prova a prenderle e si ferma al suo 'tornello'. Un vicino più fortunato a destra o sinistra, che ha finito di mangiare, sblocca il nostro filosofo, 'aprendo il suo tornello'. Il nostro filosofo lo attraversa (e si chiude dietro di lui) per la seconda volta. Prova per la terza volta a prendere le posate. Con successo. E passa il suo tornello per pranzare.
Quando ci saranno errori casuali in un tale codice (ce ne sono sempre), ad esempio, un vicino può essere indicato in modo errato o è stato creato lo stesso oggetto AutoResetEvent per tutti (Enumerable.Repeat), allora i filosofi dovranno aspettare gli sviluppatori, poiché trovare errori in un tale codice è un compito piuttosto complesso. Un ulteriore problema con questa soluzione è che non garantisce che qualche filosofo non inizi a morire di fame.
Soluzioni ibride
Abbiamo esaminato due approcci alla sincronizzazione, quando rimaniamo in modalità utente e giriamo in un ciclo e quando blocchiamo il thread attraverso il kernel. Il primo metodo è buono per brevi blocchi, il secondo per blocchi prolungati. Spesso è necessario prima attendere brevemente un cambiamento di variabile in un ciclo e poi bloccare il thread quando l'attesa è lunga. Questo approccio è implementato nelle cosiddette strutture ibride. Qui ci sono le stesse costruzioni che erano per la modalità kernel, ma ora con un ciclo in modalità utente: SemaphoreSlim, ManualResetEventSlim e altri. La costruzione più popolare qui è Monitora, poiché in C# c'è un ben noto lock sintassi. Monitora è lo stesso semaforo con un valore massimo di 1 (mutex), ma con supporto per attesa in ciclo, ricorsione, modello Condition Variable (di cui parleremo dopo) e altro. Esaminiamo 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 nuovamente tutto il tavolo per l'accesso alle posate, ma ora sbloccano tutti i thread contemporaneamente, e non i vicini, quando qualcuno smette di mangiare. Cioè, prima qualcuno mangia e blocca i vicini, e quando questo qualcuno finisce, ma vuole subito mangiare di nuovo, va in blocco e sveglia i suoi vicini, poiché il suo tempo di attesa è minore.
Così evitiamo i deadlock e la fame di qualche filosofo. Utilizziamo un ciclo per un'attesa breve e blocchiamo il thread per lunghe attese. Lo sblocco immediato di tutti funziona più lentamente rispetto a se ci fosse stato uno sblocco solo del vicino, come nella soluzione con AutoResetEvent, ma la differenza non dovrebbe essere grande, dato che i thread devono rimanere in modalità utente inizialmente.
A lock la sintassi ha brutte sorprese. Si raccomanda di utilizzare Monitora direttamente [Richter] [Eric Lippert]. Una di esse è che lock esce sempre da Monitora, anche se si è verificata un'eccezione, e allora un altro thread può modificare lo stato della memoria condivisa. In questi casi, è meglio spesso andare in deadlock o trovare un modo sicuro per terminare il programma. Un'altra sorpresa è che il Monitor utilizza blocchi di sincronizzazione (SyncBlock), che ci sono in tutti gli oggetti. Pertanto, se viene scelto un oggetto inadeguato, si può facilmente incorrere in un deadlock (ad esempio, se si blocca una stringa internata). Utilizziamo sempre un oggetto nascosto per questo.
Il pattern Condition Variable consente di implementare in modo più conciso l'attesa di una condizione complessa. In .NET non è completo, a mio avviso, poiché idealmente dovrebbero esserci più code su più variabili (come in Posix Threads), e non su un solo lock. Allora si potrebbe fare per tutti i filosofi. Ma anche in questa forma permette di ridurre il codice.
Molti filosofi o async / await
Bene, ora sappiamo come bloccare i thread in modo efficiente. Ma cosa succede se abbiamo molti filosofi? 100? 10.000? Ad esempio, ci sono arrivati 100.000 richieste su un server web. Creare un thread per ogni richiesta sarebbe troppo impegnativo, poiché così tanti thread non verrebbero eseguiti in parallelo. Saranno eseguiti solo tanti quanti sono i core logici (io ne ho 4). E tutti gli altri semplicemente ruberebbero risorse. Una delle soluzioni a questo problema è il pattern async / await. L'idea è che la funzione non occupa un thread, se per il suo proseguimento deve attendere qualcosa. E quando succede ciò che deve avvenire, riprende la sua esecuzione (ma non necessariamente nello stesso thread!). Nel nostro caso, aspetteremo le posate.
SemaphoreSlim ha per questo WaitAsync() metodo. Ecco un'implementazione utilizzando 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 con async / await viene tradotto in un astuto automa a stati finiti, che restituisce immediatamente il suo interno Task. Attraverso di esso è possibile attendere il completamento del metodo, annullarlo e tutto il resto che si può fare con Task. All'interno del metodo, l'automa a stati controlla l'esecuzione. La sostanza è che se non ci sono ritardi, l'esecuzione è sincrona, e se ci sono, il thread viene liberato. Per comprendere meglio questo, è meglio guardare questo automa a stati. È possibile creare catene di questi async / await metodi.
Testiamo. 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 era inattivo per circa 2 ms. E la soluzione con async / await eseguiva tutti e 100, con una media di attesa di 6,8 secondi per ciascuno. Ovviamente, in sistemi reali, un'attesa di 6 secondi è inaccettabile 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 piccoli esempi, .NET supporta molte costruzioni di sincronizzazione. Non è sempre chiaro, però, come utilizzarle. Spero che questo articolo si sia rivelato utile. Per ora chiudiamo qui, ma rimangono ancora molte cose interessanti, come le collezioni thread-safe, TPL Dataflow, programmazione reattiva, modello di Software Transaction e altro.
Fonti
- Visualizzazione dei thread:
- MSDN: , e molto altro.
- [Richter] — CLR via C#, Jeffrey Richter
- [Eric Lippert] —
- Immagine — «Danza tra le spade», G. Semiradsky
Fonte: habr.com
