
Werfen wir einen Blick auf konkurrierende und parallele Programmierung in .Net am Beispiel des Problems der essenden Philosophen. Der Plan ist, von der Synchronisation von Threads/Prozessen bis hin zum Actor-Modell (in den folgenden Teilen) zu gehen. Der Artikel kann nützlich sein, um sich erstmals mit dem Thema vertraut zu machen oder um bestehendes Wissen aufzufrischen.
Warum sollte man das überhaupt können? Transistoren erreichen ihre minimalen Größen, das Moore-Gesetz stößt an die Grenze der Lichtgeschwindigkeit, und daher wächst die Anzahl der Transistoren, die hergestellt werden können. Gleichzeitig wächst die Datenmenge, und die Benutzer erwarten sofortige Reaktionen der Systeme. In dieser Situation ist „gewöhnliches“ Programmieren, bei dem wir einen ausführenden Thread haben, nicht mehr effektiv. Es ist notwendig, das Problem der gleichzeitigen oder konkurrierenden Ausführung zu lösen. Diese Problematik existiert auf verschiedenen Ebenen: auf der Ebene der Threads, auf der Ebene der Prozesse, auf der Ebene der Maschinen im Netzwerk (verteilte Systeme). In .NET gibt es qualitativ hochwertige, bewährte Technologien zur schnellen und effektiven Lösung solcher Aufgaben.
Ziel
Edsger Dijkstra stellte dieses Problem seinen Studenten bereits 1965 vor. Die gängige Formulierung ist wie folgt: Es gibt eine bestimmte Anzahl von Philosophen (in der Regel fünf) und ebenso viele Gabeln. Sie sitzen an einem runden Tisch, mit den Gabeln zwischen ihnen. Die Philosophen können entweder aus ihren Tellern mit unendlichem Essen essen, nachdenken oder warten. Um zu essen, muss ein Philosoph zwei Gabeln nehmen (der letzte teilt eine Gabel mit dem ersten). Das Nehmen und Ablegen einer Gabel sind zwei getrennte Handlungen. Alle Philosophen sind still. Die Aufgabe besteht darin, einen Algorithmus zu finden, damit alle denken und nach 54 Jahren satt sind.
Zunächst versuchen wir, die Aufgabe durch die Verwendung eines gemeinsamen Speicherplatzes zu lösen. Die Gabeln liegen auf dem gemeinsamen Tisch und die Philosophen nehmen sie einfach, wenn sie möchten, und legen sie zurück. Hier entstehen Synchronisierungsprobleme, wann genau die Gabeln genommen werden sollen? Was tun, wenn keine Gabel vorhanden ist? und weitere. Aber zunächst lassen Sie uns die Philosophen starten.
Um die Threads zu starten, verwenden wir einen Thread-Pool über Task.Run Methode:
var cancelTokenSource = new CancellationTokenSource();
Action<int> create = (i) => RunPhilosopher(i, cancelTokenSource.Token);
for (int i = 0; i < philosophersAmount; i++)
{
int icopy = i;
// Aufgabe in die Thread-Pool-Warteschlange einfügen. Die RunDeadlock-Methode wird nicht sofort gestartet, sondern wartet auf ihren Thread. Asynchrone Ausführung.
philosophers[i] = Task.Run(() => create(icopy), cancelTokenSource.Token);
}Der Thread-Pool wurde zur Optimierung der Erstellung und Löschung von Threads erstellt. Dieser Pool hat eine Warteschlange mit Aufgaben, und der CLR erstellt oder löscht Threads basierend auf der Anzahl dieser Aufgaben. Es gibt einen Pool für alle AppDomains. Es ist fast immer ratsam, diesen Pool zu nutzen, da man sich nicht mit der Erstellung und Löschung von Threads sowie deren Warteschlangen beschäftigen muss. Man könnte auch ohne Pool arbeiten, müsste dann aber direkt Thread, was sinnvoll ist, wenn man die Priorität eines Threads ändern muss, wenn wir eine langwierige Operation haben, für den Foreground-Thread usw.
Anders ausgedrückt, System.Threading.Tasks.Task Klasse - das ist dasselbe Thread, aber mit vielen Annehmlichkeiten: die Möglichkeit, Aufgaben nach einer Reihe anderer Aufgaben zu starten, sie aus Funktionen zurückzugeben, sie bequem zu unterbrechen und vieles mehr. Diese sind notwendig, um async/await-Konstrukte zu unterstützen (Task-basiertes asynchrones Muster, syntaktischer Zucker für die Wartung von IO-Operationen). Darüber werden wir noch sprechen.
CancelationTokenSource wird hier benötigt, damit der Thread sich auf Signal des aufrufenden Threads selbst beenden kann.
Synchronisationsprobleme
Blocked Philosophers
Gut, wir können Threads erstellen, lassen Sie uns essen gehen:
// Кто какие вилки взял. К примеру: 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();
}
}Hier versuchen wir zuerst, die linke und dann die rechte Gabel zu nehmen. Wenn das gelingt, essen wir und legen sie wieder zurück. Das Nehmen einer Gabel ist atomar, d.h. zwei Threads können nicht gleichzeitig eine Gabel nehmen (falsch: der erste sieht, dass die Gabel frei ist, der zweite auch; der erste nimmt sie, der zweite nimmt sie). Dafür Interlocked.CompareExchange, das mit Hilfe von CPU-Instruktionen implementiert werden muss (TSL, XCHG), die einen Speicherbereich für atomar-sequenzielle Lese- und Schreiboperationen sperren. Und SpinWait ist äquivalent zur Konstruktion while(true) nur mit ein wenig 'Magie' — der Thread beansprucht die CPU (Thread.SpinWait), übergibt aber manchmal die Kontrolle an einen anderen Thread (Thread.Yeild) oder schläft ein (Thread.Sleep).
Aber diese Lösung funktioniert nicht, da die Threads bald (bei mir innerhalb von Sekunden) blockiert werden: Alle Philosophen nehmen ihre linke Gabel, aber nicht die rechte. Das Array der Gabeln hat dann folgende Werte: 1 2 3 4 5.

Auf der Abbildung: Blockierung der Threads (Deadlock). In Grün — Ausführung, in Rot — Synchronisierung, in Grau — Thread schläft. Die Rauten zeigen den Startzeitpunkt der Tasks an.
Hunger der Philosophen
Obwohl man zum Denken nicht unbedingt viel Essen braucht, kann Hunger jeden dazu bringen, die Philosophie aufzugeben. Lassen Sie uns versuchen, die Situation des Hungers der Threads in unserer Aufgabe zu simulieren. Hunger bedeutet, dass ein Thread arbeitet, aber ohne wesentliche Arbeit, mit anderen Worten, es ist dasselbe wie ein Deadlock, nur dass der Thread jetzt nicht schläft, sondern aktiv versucht, etwas zu essen, aber kein Essen vorhanden ist. Um häufige Blockierungen zu vermeiden, legen wir die Gabel zurück, wenn wir die andere nicht nehmen konnten.
// То же что и в 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 diesem Code ist wichtig, dass zwei der vier Philosophen vergessen, ihre linke Gabel zu nehmen. Dadurch essen sie mehr, während die anderen hungrig werden, obwohl die Threads die gleiche Priorität haben. Hier hungern sie nicht ganz, da die schlechten Philosophen manchmal ihre Gabeln zurücklegen. Es scheint, dass die guten etwa fünfmal weniger essen als die schlechten. Ein kleiner Fehler im Code führt dazu, dass die Leistung sinkt. Es ist auch zu beachten, dass es eine seltene Situation gibt, in der alle Philosophen die linke Gabel nehmen, die rechte nicht, sie legen die linke zurück, warten, nehmen wieder die linke usw. Diese Situation ähnelt eher einer gegenseitigen Blockade als dem Hunger. Ich konnte sie nicht reproduzieren. Unten ist ein Bild der Situation, in der zwei schlechte Philosophen beide Gabeln genommen haben, während zwei gute hungern.

Hier ist zu sehen, dass die Threads manchmal aufwachen und versuchen, Ressourcen zu bekommen. Zwei Kerne von vier machen nichts (grüner Graph oben).
Der Tod des Philosophen
Ein weiteres Problem, das das feierliche Mahl der Philosophen unterbrechen kann, ist, wenn einer von ihnen plötzlich mit einer Gabel in der Hand stirbt (und so beerdigt wird). Dann bleiben die Nachbarn ohne Essen. Ein Beispielcode für diesen Fall können Sie selbst erfinden, zum Beispiel wird der Fehler NullReferenceException entstehen, sobald der Philosoph die Gabeln nimmt. Und übrigens wird dieser Fehler unbehandelt bleiben und der aufrufende Code wird ihn nicht einfach so abfangen (dafür AppDomain.CurrentDomain.UnhandledException usw.). Daher sind Fehlerbehandler in den Threads selbst und ein ordnungsgemäßes Beenden notwendig.
Der Kellner
Wie können wir also das Problem mit Deadlocks, Hunger und Todesfällen lösen? Wir erlauben nur einem Philosophen den Zugang zu den Gabeln und fügen eine gegenseitige Ausschlussregel (mutual exclusion) für diesen Bereich hinzu. Wie machen wir diesen Kellner und wie werden die Philosophen um seine Erlaubnis bitten? Das sind interessante Fragen.
Der einfachste Weg besteht darin, dass die Philosophen den Kellner ständig um den Zugang zu den Gabeln bitten. Das heißt, die Philosophen warten jetzt nicht mehr darauf, dass eine Gabel in der Nähe ist, sondern bitten den Kellner um eine. Zunächst verwenden wir dafür nur den User Space, in dem wir keine Interrupts für das Aufrufen von Funktionen aus dem Kernel nutzen (mehr dazu folgt).
Benutzerraum-Lösungen
Hier werden wir dasselbe tun wie zuvor mit einer Gabel und zwei Philosophen, wir werden in einer Schleife kreisen und warten. Aber jetzt sind es alle Philosophen, und es gibt quasi nur eine Gabel, das heißt, nur der Philosoph, der diese "goldene Gabel" vom Kellner genommen hat, kann essen. Dafür verwenden wir SpinLock.
private static SpinLock spinLock = new SpinLock(); // Unser "Kellner"
private void RunSpinLock(int i, CancellationToken token)
{
while (true)
{
// Mutuelle Sperre durch aktives Warten. Wir rufen vor try auf, um
// eine Ausnahme im Falle eines Fehlers im SpinLock auszulösen.
bool hasLock = false;
spinLock.Enter(ref hasLock);
try
{
// Hier kann nur ein Thread sein (mutual exclusion).
forks[Left(i)] = i + 1; // Nimm die Gabel sofort, ohne zu warten.
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(); // Vermeidung des Problems mit dem Sterben des Philosophen.
}
Think(i);
if (token.IsCancellationRequested)
break;
}
}SpinLock dies ist ein Sperrmechanismus, der, grob gesagt, dasselbe while(true) { if (!lock) break; }, aber mit noch mehr „Magie“ als in SpinWait (der dort verwendet wird). Jetzt kann er wartende Philosophen zählen, sie ein wenig entspannen und vieles mehr. Insgesamt tut er alles, um die Ressourcen zu optimieren. Man muss jedoch bedenken, dass es sich immer noch um einen aktiven Zyklus handelt, der CPU-Ressourcen beansprucht und einen Prozess aufrechterhält, der zu Hunger führen kann, wenn einer der Philosophen bevorzugt wird, aber keine goldene Gabel hat (Problem der Prioritätsumkehr). Daher verwenden wir ihn nur für sehr kurze Änderungen im gemeinsamen Speicher, ohne externe Aufrufe, eingebettete Sperren und andere Überraschungen.

Abbildung für SpinLock. Die Threads kämpfen ständig um die goldene Gabel. Es kommt zu Ausfällen – im Bild hervorgehobener Bereich. Die Kerne werden nicht vollständig genutzt: nur etwa 2/3 dieser vier Threads.
Eine andere Lösung wäre hier, nur Interlocked.CompareExchange mit demselben aktiven Warten, wie im obigen Code (bei hungernden Philosophen) gezeigt, aber das könnte, wie bereits erwähnt, theoretisch zu einer Blockade führen.
Über Interlocked es sollte gesagt werden, dass dort nicht nur CompareExchange, sondern auch andere Methoden für atomare Lese- und Schreibvorgänge. In Fällen wiederholter Änderungen, wenn ein anderer Thread seine Änderungen vornimmt (Lesezugriff 1, Lesezugriff 2, Schreibzugriff 2, Schreibzugriff 1 ist fehlerhaft), kann dies für komplexe Änderungen eines Wertes verwendet werden (Interlocked Anything-Muster).
Lösungen im Kernel-Modus
Um Ressourcenverluste im Loop zu vermeiden, betrachten wir, wie man einen Thread blockieren kann. Anders gesagt, um unser Beispiel fortzusetzen, schauen wir uns an, wie ein Kellner den Philosophen in den Schlaf versetzt und ihn nur dann weckt, wenn es nötig ist. Zunächst betrachten wir, wie dies über den Kernel-Modus des Betriebssystems geschehen kann. Alle Strukturen dort sind oft langsamer als die im Benutzerspeicher. In einigen Fällen bis zu mehreren Malen langsamer, zum Beispiel AutoResetEvent kann bis zu 53 Mal langsamer sein SpinLock [Richter]. Aber damit können Prozesse im gesamten System synchronisiert werden, egal ob sie gesteuert werden oder nicht.
Die grundlegende Struktur hier ist ein Semaphore, der von Dijkstra vor über einem halben Jahrhundert vorgeschlagen wurde. Ein Semaphore ist, vereinfacht gesagt, eine positive ganze Zahl, die von einem System verwaltet wird, sowie zwei Operationen darauf – erhöhen und verringern. Wenn eine Verringerung nicht möglich ist, also null ist, wird der aufrufende Thread blockiert. Wenn die Zahl von einem anderen aktiven Thread/Prozess erhöht wird, werden die Threads durchgelassen und der Semaphore wird wieder auf die Anzahl der durchgelassenen verringert. Man kann sich Züge an einer Engstelle mit einem Semaphore vorstellen. .NET bietet mehrere Konstruktionen mit ähnlichen Funktionen an: AutoResetEvent, ManualResetEvent, Mutex und selbst Semaphore. Wir werden AutoResetEvent, dies ist die einfachste dieser Konstruktionen: nur zwei Werte 0 und 1 (false, true). Ihre Methode WaitOne() blockiert den aufrufenden Thread, wenn der Wert 0 war, und wenn 1, senkt sie auf 0 und lässt ihn durch. Die Methode Set() erhöht auf 1 und lässt einen wartenden Thread durch, der dann wieder auf 0 senkt. Es funktioniert wie ein Drehkreuz in der U-Bahn.
Wir werden die Lösung komplizierter gestalten und für jeden Philosophen eine Sperre anstelle einer globalen verwenden. Das bedeutet, dass jetzt mehrere Philosophen gleichzeitig am Tisch sein können, nicht nur einer. Dennoch sperren wir erneut den Zugang zum Tisch, um korrekt zu agieren und Rennbedingungen (race conditions) zu vermeiden, während wir die Gabeln nehmen.
// Для блокирования отдельного философа.
// Инициализируется: 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();
}Um zu verstehen, was hier passiert, betrachten wir den Fall, wenn es dem Philosophen nicht gelingt, die Gabeln zu nehmen. Dann wird sein Vorgehen folgendermaßen aussehen: Er wartet auf den Zugang zum Tisch. Sobald er diesen erhält, versucht er, die Gabeln zu greifen. Es klappt nicht. Er gibt den Zugang zum Tisch zurück (wechselseitiger Ausschluss). Und er passiert seinen "Drehkreuz" (AutoResetEvent) (anfangs sind sie geöffnet). Er landet erneut im Zyklus, da er keine Gabeln hat. Er versucht, sie zu nehmen und hält an seinem "Drehkreuz" an. Ein erfolgreicher Nachbar rechts oder links, der mit dem Essen fertig ist, entriegelt unseren Philosophen, indem er sein Drehkreuz "öffnet". Unser Philosoph passiert es (und es schließt sich hinter ihm) ein zweites Mal. Er versucht ein drittes Mal, die Gabeln zu nehmen. Es gelingt. Und er passiert sein Drehkreuz, um zu essen.
Wenn in einem solchen Code zufällige Fehler auftreten (die gibt es immer), zum Beispiel weil der Nachbar falsch angegeben wurde oder das gleiche Objekt erstellt wurde. AutoResetEvent für alle (Enumerable.Repeat), dann werden die Philosophen bereits auf Entwickler warten, da die Fehlersuche in solchem Code ziemlich herausfordernd ist. Ein weiteres Problem dieser Lösung ist, dass sie nicht garantiert, dass irgendein Philosoph nicht beginnt zu hungern.
Hybride Lösungen
Wir haben zwei Ansätze zur Synchronisation betrachtet: den, bei dem wir im Benutzermodus bleiben und uns in einer Schleife drehen, und den, bei dem wir den Thread über den Kernel blockieren. Die erste Methode ist gut für kurze Sperren, die zweite für längere. Oft ist es erforderlich, zunächst kurz auf eine Veränderung der Variablen in der Schleife zu warten und dann den Thread zu blockieren, wenn die Wartezeit lang ist. Dieser Ansatz wird in den sogenannten hybriden Konstruktionen umgesetzt. Hier gibt es dieselben Konstruktionen wie für den Kernelmodus, aber jetzt mit einer Schleife im Benutzermodus: SemaphoreSlim, ManualResetEventSlim u.a. Die beliebteste Konstruktion hier ist Monitor, da es in C# einen weithin bekannten lock Syntax gibt. Monitor Das ist dasselbe Semaphore mit einem Maximalwert von 1 (Mutex), jedoch mit Unterstützung für das Warten in einer Schleife, Rekursion, das Condition Variable Muster (darüber später) und mehr. Schauen wir uns eine Lösung damit an.
// Спрячем объект для Монитора от всех, чтобы без дедлоков.
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);
}
}Hier blockieren wir wieder den gesamten Tisch für den Zugriff auf die Gabeln, aber jetzt entsperren wir sofort alle Threads, und nicht nur die Nachbarn, wenn jemand mit dem Essen fertig ist. Das bedeutet, zuerst isst jemand und blockiert die Nachbarn, und wenn diese Person fertig ist, aber sofort wieder essen möchte, geht sie in die Blockierung und weckt ihre Nachbarn, da ihre Wartezeit kürzer ist.
So vermeiden wir Deadlocks und das Verhungern eines bestimmten Philosophen. Wir verwenden eine Schleife für eine kurze Wartezeit und blockieren den Thread für eine lange Zeit. Die sofortige Entsperrung aller funktioniert langsamer, als wenn nur der Nachbar entsperrt würde, wie im Fall mit AutoResetEvent, aber der Unterschied sollte nicht groß sein, da die Threads zuerst im Benutzermodus bleiben sollten.
U lock In der Syntax gibt es unangenehme Überraschungen. Es wird empfohlen, Monitor direkt [Richter] [Eric Lippert] zu verwenden. Eine davon ist, dass lock es immer aus Monitor, selbst wenn es eine Ausnahme gab, und dann kann ein anderer Thread den Zustand des gemeinsamen Speichers ändern. In solchen Fällen ist es oft besser, in einen Deadlock zu gehen oder das Programm auf sichere Weise zu beenden. Eine weitere Überraschung besteht darin, dass der Monitor Synchronisationsblöcke verwendet (SyncBlock), die in allen Objekten vorhanden sind. Wenn also ein ungeeignetes Objekt gewählt wird, kann leicht eine Blockade entstehen (zum Beispiel, wenn ein Lock auf einer internierten Zeichenfolge gesetzt wird). Wir verwenden immer ein verborgenes Objekt dafür.
Das Condition Variable Muster ermöglicht eine prägnantere Implementierung von Warteschleifen für bestimmte komplexe Bedingungen. In .NET ist es meiner Meinung nach unvollständig, da es dort mehrere Warteschlangen für mehrere Variablen geben sollte (wie in Posix Threads), anstatt nur ein Lock zu verwenden. Damit könnte man sie für alle Philosophen erstellen. Aber auch in dieser Form hilft es, den Code zu vereinfachen.
Viele Philosophen oder async / await
Gut, wir können jetzt effektiv Streams blockieren. Aber was, wenn wir viele Philosophen haben? 100? 10000? Angenommen, wir erhalten 100000 Anfragen an den Webserver. Für jede Anfrage einen Thread zu erstellen, wäre ineffizient, da nicht so viele Threads gleichzeitig ausgeführt werden können. Es können nur so viele ausgeführt werden, wie es logische Kerne gibt (ich habe 4). Alle anderen würden nur Ressourcen beanspruchen. Eine Lösung für dieses Problem ist das async/await-Muster. Die Idee ist, dass eine Funktion keinen Thread blockiert, wenn sie auf etwas warten muss, um fortzufahren. Wenn das, worauf gewartet wird, eintritt, wird die Ausführung fortgesetzt (aber nicht unbedingt im gleichen Thread!). In unserem Fall werden wir auf die Gabel warten.
SemaphoreSlim hat dafür WaitAsync() Methodus. Hier ist die Implementierung unter Verwendung dieses Musters.
// Запуск такой же, как раньше. Где-нибудь в программе:
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();
}Die Methode wird async / await in einen raffinierten endlichen Automaten übersetzt, der sofort seine interne Task zurückgibt.. Damit kann man auf den Abschluss einer Methode warten, sie abbrechen und alles weitere tun, was man mit einer Task machen kann. Innerhalb der Methode kontrolliert ein endlicher Automat die Ausführung. Das Wesentliche ist, dass wenn keine Verzögerung vorhanden ist, die Ausführung synchron erfolgt, und wenn es eine Verzögerung gibt, wird der Thread freigegeben. Für ein besseres Verständnis ist es hilfreich, sich diesen endlichen Automaten anzusehen. Man kann Ketten aus diesen async / await Methoden erstellen.
Lass uns testen. Die Arbeit von 100 Philosophen auf einer Maschine mit 4 logischen Kernen, 8 Sekunden. Die vorherige Lösung mit Monitor hat nur die ersten 4 Threads ausgeführt, die anderen wurden überhaupt nicht bearbeitet. Jeder dieser 4 Threads hatte eine Wartezeit von etwa 2 ms. Die Lösung mit async / await hingegen hat alle 100 ausgeführt, wobei jeder im Durchschnitt 6,8 Sekunden wartete. Natürlich ist eine Wartezeit von 6 Sekunden in realen Systemen inakzeptabel, und es ist besser, so viele Anfragen nicht zu verarbeiten. Die Lösung mit Monitor erwies sich als überhaupt nicht skalierbar.
Fazit
Wie aus diesen kleinen Beispielen hervorgeht, unterstützt .NET viele Synchronisationskonstrukte. Nicht immer ist jedoch offensichtlich, wie man sie verwendet. Ich hoffe, dieser Artikel war hilfreich. Damit beenden wir zunächst, aber es gibt noch viele interessante Themen, wie threadsichere Sammlungen, TPL Dataflow, reaktive Programmierung, das Software Transaction Model und mehr.
Quellen
- Visualisierung von Threads:
- MSDN: , und viele mehr.
- [Richter] — CLR via C#, Jeffrey Richter
- [Eric Lippert] —
- Bild — „Tanz zwischen Schwertern“, G. Semiradski
Quelle: habr.com
