
Voyons comment fonctionne la programmation concurrente et parallèle en .Net, à travers le problème des philosophes qui dînent. Le plan est d'aborder tout, de la synchronisation des threads/processus au modèle des acteurs (dans les prochaines parties). Cet article peut être utile pour une première approche ou pour rafraîchir ses connaissances.
Pourquoi est-ce important de savoir faire cela ? Les transistors atteignent leur taille minimale, la loi de Moore se heurte à la limite de la vitesse de la lumière et par conséquent, la croissance se vérifie par le nombre de transistors : on peut en fabriquer davantage. En même temps, la quantité de données augmente et les utilisateurs attendent une réaction immédiate des systèmes. Dans cette situation, la programmation « normale », où nous avons un seul thread d'exécution, devient inefficace. Il faut trouver une solution pour le problème de l'exécution simultanée ou concurrente. Ce problème se pose à différents niveaux : au niveau des threads, au niveau des processus, et au niveau des machines dans un réseau (systèmes distribués). En .NET, il existe des technologies de qualité, éprouvées par le temps, pour résoudre rapidement et efficacement ces problèmes.
La tâche
Edsger Dijkstra posait cette question à ses étudiants dès 1965. La formulation établie est la suivante. Il y a un certain nombre (généralement cinq) de philosophes et autant de fourchettes. Ils sont assis autour d'une table ronde, avec les fourchettes entre eux. Les philosophes peuvent manger de leurs assiettes avec une nourriture infinie, penser ou attendre. Pour manger, un philosophe doit prendre deux fourchettes (le dernier partage une fourchette avec le premier). Prendre et poser une fourchette sont deux actions distinctes. Tous les philosophes sont silencieux. La tâche est de trouver un algorithme permettant à tous de penser et d'être rassasiés même après 54 ans.
Tout d'abord, essayons de résoudre ce problème à l'aide d'un espace partagé. Les fourchettes sont sur la table commune et les philosophes les prennent simplement lorsqu'elles sont disponibles, puis les reposent. Cela soulève des problèmes de synchronisation, comme quand prendre les fourchettes ? Que faire si les fourchettes ne sont pas là ? etc. Mais d'abord, lançons les philosophes.
Pour démarrer les threads, utilisons un pool de threads via Task.Run méthode :
var cancelTokenSource = new CancellationTokenSource();
Action create = (i) => RunPhilosopher(i, cancelTokenSource.Token);
for (int i = 0; i create(icopy), cancelTokenSource.Token);
}Le pool de threads est créé pour optimiser la création et la suppression de threads. Ce pool possède une file d'attente de tâches et le CLR crée ou supprime des threads en fonction du nombre de ces tâches. Un pool pour tous les AppDomain. Ce pool doit être utilisé presque toujours, car il n'est pas nécessaire de se soucier de la création, de la suppression des threads, de leurs files d'attente, etc. Il est possible de fonctionner sans pool, mais alors il faudra les utiliser directement. Fil, c'est pertinent dans les cas où il est nécessaire de changer la priorité d'un thread, lorsqu'une opération prend du temps, pour le thread principal, etc.
En d'autres termes, System.Threading.Tasks.Task classe — c'est le même que Fil, mais avec divers avantages : la possibilité de lancer une tâche après un bloc d'autres tâches, de les retourner depuis des fonctions, de les interrompre facilement, etc. Elles sont nécessaires pour le support des constructions async/await (Task-based Asynchronous Pattern, syntaxe simplifiée pour attendre une opération IO). Nous en parlerons encore.
CancelationTokenSource est nécessaire ici, afin que le thread puisse se terminer lui-même sur signal du thread appelant.
Problèmes de synchronisation
Philosophes bloqués
Bien, nous savons créer des threads, essayons donc de déjeuner :
// Кто какие вилки взял. К примеру: 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();
}
}Ici, nous essayons d'abord de prendre la fourchette de gauche, puis celle de droite, et si cela fonctionne, nous mangeons et les remettons. La prise d'une fourchette est atomique, c'est-à-dire que deux threads ne peuvent pas prendre la même en même temps (incorrect : le premier lit que la fourchette est libre, le second aussi, le premier prend, le second prend). Pour cela, Interlocked.CompareExchange, qui doit être réalisé par une instruction processeur (TSL, XCHG), qui bloque une section de mémoire pour une lecture et une écriture atomiques successives. Un SpinWait est équivalent à la construction while(true) mais avec une petite « magie » — le thread occupe le processeur (Thread.SpinWait), mais parfois transmet le contrôle à un autre thread (Thread.Yeild) ou dort (Thread.Sleep).
Mais cette solution ne fonctionne pas, car les threads se bloquent rapidement (chez moi en une seconde) : tous les philosophes prennent leur fourchette de gauche, mais pas de droite. Le tableau forks a alors les valeurs : 1 2 3 4 5.

Sur l'illustration, le blocage des threads (deadlock). En vert — exécution, en rouge — synchronisation, en gris — thread en sommeil. Les losanges indiquent le temps de démarrage des Tâches.
La famine des philosophes
Bien que pour penser, il ne faille pas beaucoup de nourriture, la faim peut pousser quiconque à abandonner la philosophie. Essayons de modéliser une situation de famine de threads dans notre tâche. La famine — c'est lorsque le thread fonctionne, mais sans travail substantiel, en d'autres termes, c'est comme un deadlock, sauf que maintenant le thread ne dort pas, mais cherche activement à se nourrir, sans succès. Pour éviter une charge fréquente, nous remettrons la fourchette en arrière si nous n'avons pas pu en prendre une autre.
// То же что и в 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)
);
}Dans ce code, il est important de noter que deux des quatre philosophes oublient de remettre leur fourchette de gauche. Ainsi, ils mangent plus, tandis que les autres commencent à souffrir de la faim, bien que les threads aient la même priorité. Ici, ils ne souffrent pas totalement de la faim, car les mauvais philosophes remettent parfois leurs fourchettes. Il se trouve que les bons mangent environ 5 fois moins que les mauvais. Une petite erreur dans le code entraîne donc une chute de performance. Il convient de noter qu'il est possible qu'une situation rarissime survienne lorsque tous les philosophes prennent la fourchette de gauche, sans celle de droite ; ils remettent la gauche, attendent, reprennent la gauche, etc. Cette situation ressemble à de la famine, plus proche du blocage mutuel. Je n'ai pas réussi à la reproduire. Ci-dessous, une image de la situation où deux mauvais philosophes prennent les deux fourchettes, tandis que deux bons souffrent de la faim.

Ici, on peut voir que les threads se réveillent parfois et essaient d'obtenir une ressource. Deux cœurs sur quatre ne font rien (courbe verte en haut).
La mort du philosophe
Une autre problématique qui peut interrompre le bel repas des philosophes est si l'un d'eux meurt soudainement avec des fourchettes dans les mains (et qu'il est ainsi enterré). Les voisins resteront alors sans déjeuner. Vous pouvez imaginer un exemple de code pour ce cas, par exemple, une exception est levée. NullReferenceException après que le philosophe a pris les fourchettes. D'ailleurs, l'exception ne sera pas traitée et le code appelant ne pourra pas la capturer (pour cela, AppDomain.CurrentDomain.UnhandledException etc.). C'est pourquoi des gestionnaires d'erreurs sont nécessaires dans les threads eux-mêmes et pour une terminaison correcte.
Serveur
Bien, comment pouvons-nous résoudre ce problème de blocages mutuels, de famine et de morts ? Nous allons permettre à un seul philosophe d'accéder aux fourchettes, en ajoutant une exclusion mutuelle pour cet endroit. Comment allons-nous faire cela ? Supposons qu'il y ait un serveur à côté des philosophes qui donne la permission à un seul philosophe de prendre les fourchettes. Comment allons-nous créer ce serveur et comment les philosophes vont-ils lui demander de les utiliser ? Ce sont des questions intéressantes.
La méthode la plus simple consiste à ce que les philosophes demandent constamment au serveur d'accéder aux fourchettes. C'est-à-dire qu'au lieu d'attendre la fourchette à côté d'eux, les philosophes attendront ou demanderont au serveur. Pour cela, nous utiliserons d'abord uniquement l'espace utilisateur, sans interruptions pour appeler des procédures du noyau.
Solutions dans l'espace utilisateur
Ici, nous allons faire la même chose que précédemment avec une seule fourchette et deux philosophes : nous allons tourner en boucle et attendre. Mais maintenant, ce seront tous les philosophes et ce ne sera qu'une seule fourchette, c'est-à-dire qu'on peut dire qu'il n'y aura à manger que pour le philosophe qui prendra cette "fourchette en or" auprès du serveur. Pour cela, nous utiliserons SpinLock.
private static SpinLock spinLock = new SpinLock(); // Notre "serveur"
private void RunSpinLock(int i, CancellationToken token)
{
while (true)
{
// Blocage mutuel via busy waiting. Nous appelons jusqu'à try, pour
// lancer une exception en cas d'erreur dans le SpinLock.
bool hasLock = false;
spinLock.Enter(ref hasLock);
try
{
// Ici, il ne peut y avoir qu'un seul thread (exclusion mutuelle).
forks[Left(i)] = i + 1; // Prenons la fourchette directement, sans attendre.
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(); // Évitons le problème de la mort du philosophe.
}
Think(i);
if (token.IsCancellationRequested)
break;
}
}SpinLock c'est un verrou, avec à peu près la même fonction while(true) { if (!lock) break; }, mais avec encore plus de « magie » que dans SpinWait (qui est utilisé là-bas). Maintenant, il peut compter les attentes, les endormir un peu et bien d'autres choses. En gros, il fait tout pour optimiser. Mais rappelez-vous que c'est toujours le même cycle actif qui consomme des ressources processeur et maintient le flux, ce qui peut entraîner une famine si l'un des philosophes devient prioritaire par rapport aux autres, mais n'a pas de fourchette en or (problème d'inversion de priorité). Par conséquent, nous l'utilisons uniquement pour des changements très très courts dans la mémoire commune, sans appels externes, verrouillages imbriqués et autres surprises.

Illustration pour SpinLock. Les flux « se battent » constamment pour la fourchette en or. Des échecs se produisent - la zone en surbrillance sur l'illustration. Les cœurs ne sont pas utilisés entièrement : seulement environ 2/3 par ces quatre flux.
Une autre solution ici serait d'utiliser uniquement Interlocked.CompareExchange avec la même attente active, comme montré dans le code ci-dessus (dans les philosophes affamés), mais cela, comme déjà mentionné, peut théoriquement entraîner un blocage.
À propos de Interlocked il convient de mentionner qu'il n'y a pas seulement CompareExchange, mais aussi d'autres méthodes pour la lecture et l'écriture atomiques. Et à travers ce type de répétition lors d'une modification au cas où un autre flux parvient à apporter ses modifications (lecture 1, lecture 2, écriture 2, écriture 1 mauvaise), il peut être utilisé pour des changements complexes d'une seule valeur (modèle Interlocked Anything).
Solutions en mode noyau
Pour éviter de perdre des ressources dans la boucle, voyons comment nous pouvons bloquer le fil. En d'autres termes, en poursuivant notre exemple, voyons comment le serveur endormira le philosophe et le réveillera seulement quand cela sera nécessaire. Commençons par examiner comment cela peut être réalisé en mode noyau du système d'exploitation. Toutes les structures là-bas s'avèrent souvent plus lentes que celles qui se trouvent dans l'espace utilisateur. Plus lentes de plusieurs fois, par exemple AutoResetEvent peut être jusqu'à 53 fois plus lente SpinLock [Richter]. Mais avec leur aide, nous pouvons synchroniser les processus à travers le système, qu'ils soient gérés ou non.
La structure principale ici est un sémaphore, proposé par Dijkstra il y a plus d'un demi-siècle. Un sémaphore, pour simplifier, est un nombre entier positif, géré par le système, avec deux opérations possibles : augmenter et diminuer. Si la diminution n'est pas possible (à zéro), le fil d'exécution appelant est bloqué. Lorsque le nombre est augmenté par un autre fil ou processus actif, les fils sont autorisés à passer, et le sémaphore est à nouveau diminué en fonction du nombre passé. On peut imaginer des trains dans un passage étroit avec un sémaphore. .NET propose plusieurs constructions avec des fonctions similaires : AutoResetEvent, ManualResetEvent, Mutex et lui-même Semaphore. Nous allons utiliser AutoResetEvent, c'est la plus simple de ces constructions : elle n'a que deux valeurs, 0 et 1 (faux, vrai). Sa méthode WaitOne() bloque le fil d'exécution appelant si la valeur était 0, et si c'est 1, elle la réduit à 0 et le laisse passer. La méthode Set() augmente à 1 et permet à un en attente de passer, qui d'ailleurs la ramène à 0. Elle fonctionne comme un tourniquet dans le métro.
Complexifions la solution et utilisons un verrou pour chaque philosophe, plutôt que pour tous en même temps. Cela signifie qu'il peut maintenant y avoir plusieurs philosophes en même temps, pas seulement un. Mais encore une fois, nous bloquons l'accès à la table pour prendre les fourchettes correctement, en évitant les conditions de concurrence (race conditions).
// Для блокирования отдельного философа.
// Инициализируется: 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();
}Pour comprendre ce qui se passe ici, examinons le cas où le philosophe n'a pas pu prendre les fourchettes, ses actions seront les suivantes. Il attend l'accès à la table. Une fois obtenu, il essaie de prendre les fourchettes. Cela ne fonctionne pas. Il relâche l'accès à la table (exclusion mutuelle). Et passe par son "tourniquet" (AutoResetEvent) (au départ, ils sont ouverts). Il retombe dans la boucle, car il n'a pas les fourchettes. Il essaie de les prendre et s'arrête à son "tourniquet". Un voisin plus chanceux à droite ou à gauche, ayant fini de manger, débloque notre philosophe en "ouvrant son tourniquet". Notre philosophe le traverse (et il se ferme derrière lui) pour la deuxième fois. Il essaie de prendre les fourchettes une troisième fois. Avec succès. Et il passe son tourniquet pour déjeuner.
Lorsque dans un tel code des erreurs aléatoires se produisent (elles existent toujours), par exemple si un voisin est mal désigné ou si le même objet est créé AutoResetEvent pour tous (Enumerable.Repeat), alors les philosophes attendront déjà les développeurs, car trouver des erreurs dans un tel code est une tâche assez difficile. Un autre problème avec cette solution est qu'elle ne garantit pas qu'un philosophe ne commencera pas à mourir de faim.
Solutions hybrides
Nous avons examiné deux approches de synchronisation : lorsque nous restons en mode utilisateur et faisons une boucle, et lorsque nous bloquons le thread via le noyau. La première méthode est bonne pour de courtes verrouillages, la deuxième pour des verrouillages prolongés. Souvent, il est nécessaire d’attendre brièvement le changement d’une variable dans une boucle, puis de bloquer le thread lorsque l'attente est longue. Cette approche est mise en œuvre dans ce qu'on appelle des constructions hybrides. Il y a ici les mêmes constructions qui étaient pour le mode noyau, mais maintenant avec une boucle en mode utilisateur : SemaphorSlim, ManualResetEventSlim et al. La construction la plus populaire ici est Surveillance, car en C# il y a le célèbre lock syntaxe. Surveillance C'est le même sémaphore avec une valeur maximale de 1 (mutex), mais avec un support d'attente dans la boucle, de la récursivité, du pattern Condition Variable (en parler ci-dessous) et autres. Regardons une solution avec lui.
// Спрячем объект для Монитора от всех, чтобы без дедлоков.
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);
}
}Ici, nous bloquons à nouveau toute la table pour accéder aux fourchettes, mais maintenant nous débloquons tous les threads en même temps, et non les voisins, quand quelqu'un termine de manger. C'est-à-dire qu'au début, quelqu'un mange et bloque les voisins, et quand cette personne a terminé mais souhaite recommencer à manger immédiatement, elle s'en va dans un verrou et réveille ses voisins, car son temps d'attente est plus court.
Ainsi, nous évitons les blocages et la famine d'un philosophe. Nous utilisons une boucle pour une attente courte et bloquons le thread pour longtemps. Le déblocage de tous en même temps fonctionne moins rapidement que s'il n'y avait le déblocage que du voisin, comme dans la solution avec AutoResetEvent, mais la différence ne devrait pas être trop grande, car les threads doivent d'abord rester en mode utilisateur.
Il y a lock La syntaxe a des surprises désagréables. Il est recommandé d'utiliser Surveillance directement [Richter] [Eric Lippert]. L'une d'elles est que lock sort toujours de Surveillance, même s'il y a une exception, et alors un autre thread peut modifier l'état de la mémoire partagée. Dans ces cas-là, il vaut souvent mieux entrer dans un blocage ou terminer le programme de manière sûre. Une autre surprise est que Monitor utilise des blocs de synchronisation (SyncBlock), qui existent dans tous les objets. Par conséquent, si un objet inapproprié est choisi, il est facile de provoquer un blocage (par exemple, si vous faites un lock sur une chaîne internée). Toujours utiliser un objet caché pour cela.
Le motif Condition Variable permet de réaliser plus succinctement l'attente d'une condition complexe. Dans .NET, il est incomplet, à mon avis, car il devrait y avoir plusieurs files d'attente pour plusieurs variables (comme dans Posix Threads), et non pas pour un seul verrou. Cela permettrait de les appliquer à tous les philosophes. Mais même dans cette forme, il permet de réduire le code.
Beaucoup de philosophes ou async / await
Bien, maintenant nous savons comment bloquer efficacement les threads. Mais que se passe-t-il si nous avons beaucoup de philosophes ? 100 ? 10 000 ? Par exemple, si 100 000 requêtes arrivent sur le serveur web. Créer un thread pour chaque requête serait coûteux, car autant de threads ne peuvent pas s'exécuter en parallèle. Seuls autant de threads que de cœurs logiques seront exécutés (j'en ai 4). Et tous les autres vont simplement consommer des ressources. L'une des solutions à ce problème est le motif async / await. L'idée est que la fonction ne bloque pas le thread si elle doit attendre quelque chose pour continuer. Et quand cet événement se produit, elle reprend son exécution (mais pas nécessairement dans le même thread !). Dans notre cas, nous allons attendre la fourchette.
SemaphoreSlim possède à cet effet WaitAsync() méthode. Voici une implémentation utilisant ce motif.
// Запуск такой же, как раньше. Где-нибудь в программе:
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();
}La méthode avec async / await se traduit par un automate fini astucieux, qui retourne immédiatement son interne Task. Via celui-ci, on peut attendre la fin de la méthode, l'annuler et tout autre traitement qu'on peut faire avec un Task. À l'intérieur de la méthode, l'automate contrôle l'exécution. L'essentiel est que s'il n'y a pas de délai, l'exécution est synchrone, et s'il y en a un, le thread est libéré. Pour mieux comprendre cela, il vaut mieux regarder cet automate. On peut créer des chaînes de ces async / await méthodes.
Testons. Le travail de 100 philosophes sur une machine avec 4 cœurs logiques, 8 secondes. La solution précédente avec Monitor exécutait seulement les 4 premiers threads, tandis que les autres ne s'exécutaient pas du tout. Chacun de ces 4 threads était inactif pendant environ 2 ms. En revanche, la solution avec async / await exécutait les 100, chaque thread attendant en moyenne 6,8 secondes. Bien sûr, dans les systèmes réels, un temps d'attente de 6 secondes est inacceptable et il vaut mieux ne pas traiter autant de requêtes de cette manière. La solution avec Monitor s'est avérée totalement non évolutive.
Conclusion
Comme on peut le voir dans ces petits exemples, .NET prend en charge de nombreuses constructions de synchronisation. Cependant, il n'est pas toujours évident de savoir comment les utiliser. J'espère que cet article vous a été utile. Nous nous arrêtons ici pour l'instant, mais il reste encore beaucoup de choses intéressantes à explorer, comme les collections thread-safe, TPL Dataflow, la programmation réactive, le modèle de transaction logicielle, etc.
Sources
- Visualisation des flux :
- MSDN : , et bien d'autres.
- [Richter] — CLR via C#, Jeffrey Richter
- [Eric Lippert] —
- Image — « Danse parmi les épées », G. Semiradski
Source : habr.com
