
Die .NET-Plattform bietet viele vorgefertigte Synchronisationsprimitive und threadsichere Sammlungen. Wenn Sie beispielsweise bei der Entwicklung einer Anwendung einen thread-sicheren Cache oder eine Anforderungswarteschlange implementieren müssen, werden normalerweise diese vorgefertigten Lösungen verwendet, manchmal mehrere gleichzeitig. In einigen Fällen führt dies zu Leistungsproblemen: langes Warten auf Sperren, übermäßiger Speicherverbrauch und lange Speicherbereinigung.
Diese Probleme können gelöst werden, wenn wir berücksichtigen, dass Standardlösungen recht allgemein gehalten werden - sie können in unseren redundanten Szenarien einen Overhead verursachen. Dementsprechend können Sie beispielsweise Ihre eigene effektive thread-sichere Sammlung für einen bestimmten Fall schreiben.
Unter der Zwischensequenz befindet sich ein Video und eine Abschrift meines Berichts von der
DotNext- Konferenz, in der ich einige Beispiele analysiere, wenn Tools aus der Standard-.NET-Bibliothek (Task.Delay, SemaphoreSlim, ConcurrentDictionary) zu Leistungseinbußen führen, und ich schlage Lösungen vor, die auf bestimmte Aufgaben zugeschnitten sind und keine haben diese Mängel.
Zum Zeitpunkt des Berichts arbeitete er in Kontur. Kontur entwickelt verschiedene Anwendungen für Unternehmen. Das Team, in dem ich gearbeitet habe, befasst sich mit der Infrastruktur und entwickelt verschiedene Support-Services und Bibliotheken, die Entwicklern in anderen Teams bei der Erstellung von Produkt-Services helfen.
Das Infrastructure-Team baut sein Data Warehouse, ein Anwendungshosting-System für Windows und verschiedene Bibliotheken für die Entwicklung von Microservices auf. Unsere Anwendungen basieren auf einer Microservice-Architektur - alle Services interagieren über das Netzwerk miteinander und verwenden natürlich ziemlich viel asynchronen Code und Multithread-Code. Einige dieser Anwendungen sind sehr leistungskritisch und müssen in der Lage sein, viele Anforderungen zu verarbeiten.
Worüber werden wir heute sprechen?
- Multithreading und Asynchronität in .NET;
- Füllen von Synchronisationsprimitiven und Sammlungen;
- Was tun, wenn Standardansätze die Last nicht bewältigen können?
Lassen Sie uns einige Funktionen der Arbeit mit Multithread- und asynchronem Code in .NET analysieren. Schauen wir uns einige Synchronisationsprimitive und gleichzeitige Sammlungen an und sehen, wie sie im Inneren angeordnet sind. Wir werden diskutieren, was zu tun ist, wenn nicht genügend Leistung vorhanden ist, wenn die Standardklassen die Last nicht bewältigen können und ob in dieser Situation etwas getan werden kann.
Ich werde Ihnen vier Geschichten erzählen, die an unserem Produktionsstandort passiert sind.
Verlauf 1: Task.Delay & TimerQueue
Diese Geschichte ist bereits ziemlich bekannt, auch bei DotNext. Es hat jedoch eine ziemlich interessante Fortsetzung bekommen, also habe ich sie hinzugefügt. Worum geht es also?
1.1 Polling und Long Polling
Der Server führt lange Operationen aus, der Client wartet auf sie.
Abfrage: Der Client fragt den Server regelmäßig nach dem Ergebnis.
Lange Abfrage: Der Client sendet eine Anforderung mit einer langen Zeitüberschreitung, und der Server antwortet, wenn der Vorgang abgeschlossen ist.
Vorteile:
- Weniger Verkehr
- Der Kunde erfährt schneller von dem Ergebnis
Stellen Sie sich vor, wir haben einen Server, der einige lange Anforderungen verarbeiten kann, z. B. eine Anwendung, die XML-Dateien in PDF konvertiert, und es gibt Clients, die diese Aufgaben zur Verarbeitung ausführen und asynchron auf ihr Ergebnis warten möchten. Wie kann eine solche Erwartung verwirklicht werden?
Der erste Weg ist das
Abrufen . Der Client startet die Aufgabe auf dem Server und überprüft dann regelmäßig den Status dieser Aufgabe, während der Server den Status der Aufgabe zurückgibt ("abgeschlossen" / "fehlgeschlagen" / "mit einem Fehler abgeschlossen"). Der Client sendet regelmäßig Anforderungen, bis das Ergebnis angezeigt wird.
Der zweite Weg ist
langes Polling . Der Unterschied besteht darin, dass der Client Anforderungen mit langen Zeitüberschreitungen sendet. Der Server, der eine solche Anfrage erhält, meldet nicht sofort, dass die Aufgabe nicht abgeschlossen wurde, sondern versucht eine Weile zu warten, bis das Ergebnis angezeigt wird.
Was ist der Vorteil einer langen Abfrage gegenüber einer regulären Abfrage? Erstens wird weniger Verkehr erzeugt. Wir stellen weniger Netzwerkanforderungen - weniger Verkehr wird über das Netzwerk gejagt. Außerdem kann der Client das Ergebnis schneller als bei regulären Abfragen ermitteln, da er nicht auf das Intervall zwischen mehreren Abfrageanforderungen warten muss. Was wir bekommen wollen, ist verständlich. Wie werden wir dies in Code implementieren?
Aufgabe: Zeitüberschreitung
Wir möchten mit einer Zeitüberschreitung auf Task warten
warte auf SendAsync ();
Zum Beispiel haben wir eine Aufgabe, die eine Anfrage an den Server sendet, und wir möchten mit einem Timeout auf das Ergebnis warten. Das heißt, wir geben entweder das Ergebnis dieser Aufgabe zurück oder senden eine Art Fehler. Der C # -Code sieht folgendermaßen aus:
var sendTask = SendAsync(); var delayTask = Task.Delay(timeout); var task = await Task.WhenAny(sendTask, delayTask); if (task == delayTask) return Timeout;
Dieser Code startet unsere Task, deren Ergebnis wir warten möchten, und Task.Delay. Als nächstes warten wir mit Task.WhenAny entweder auf unsere Task oder auf Task.Delay. Wenn sich herausstellt, dass Task.Delay zuerst ausgeführt wird, die Zeit abgelaufen ist und wir eine Zeitüberschreitung haben, müssen wir einen Fehler zurückgeben.
Dieser Code ist natürlich nicht perfekt und kann verbessert werden. Zum Beispiel würde es nicht schaden, Task.Delay abzubrechen, wenn SendAsync früher zurückkehrt, aber das ist für uns jetzt nicht sehr interessant. Das Fazit ist, dass wir einige Leistungsprobleme bekommen, wenn wir einen solchen Code schreiben und ihn für lange Abfragen mit langen Zeitüberschreitungen anwenden.
1.2 Probleme mit langen Abfragen
- Große Auszeiten
- Viele gleichzeitige Abfragen
- => Hohe CPU-Auslastung
In diesem Fall ist das Problem der hohe Verbrauch an Prozessorressourcen. Es kann vorkommen, dass der Prozessor zu 100% voll ausgelastet ist und die Anwendung im Allgemeinen nicht mehr funktioniert. Es scheint, dass wir überhaupt keine Prozessorressourcen verbrauchen: Wir führen einige asynchrone Vorgänge durch, warten auf eine Antwort vom Server, und der Prozessor ist immer noch mit uns geladen.
In dieser Situation haben wir einen Speicherauszug aus unserer Anwendung entfernt:
~*e!clrstack System.Threading.Monitor.Enter(System.Object) System.Threading.TimerQueueTimer.Change(…) System.Threading.Timer.TimerSetup(…) System.Threading.Timer..ctor(…) System.Threading.Tasks.Task.Delay(…)
Um den Dump zu analysieren, haben wir das WinDbg-Tool verwendet. Wir haben einen Befehl eingegeben, der Stapelspuren aller verwalteten Threads anzeigt, und ein solches Ergebnis gesehen. Wir haben viele Threads in Bearbeitung, die auf eine Sperre warten. Die Monitor.Enter-Methode ist das, in das das Sperrkonstrukt in C # erweitert wird. Diese Sperre wird in Klassen namens Timer und TimerQueueTimer erfasst. In Timer kamen wir von Task.Delay, als wir versuchten, sie zu erstellen. Was ist das Wenn Task.Delay gestartet wird, wird die Sperre in der TimerQueue erfasst.
1.3 Konvoi sperren
- Viele Threads versuchen, eine Sperre zu sperren
- Unter der Sperre wird wenig Code ausgeführt
- Die Zeit wird für die Thread-Synchronisation und nicht für die Codeausführung aufgewendet.
- Threadblocks sind blockiert - sie sind nicht unendlich
Wir hatten einen Schlosskonvoi in der Anwendung. Viele Threads versuchen, dieselbe Sperre zu erfassen. Unter dieser Sperre wird ziemlich viel Code ausgeführt. Prozessorressourcen werden hier nicht für den Anwendungscode selbst ausgegeben, sondern für Vorgänge zum Synchronisieren von Threads untereinander für diese Sperre. Beachten Sie auch eine Funktion in Bezug auf .NET: Die Threads, die am Lock Convoi teilnehmen, sind Threads aus dem Thread-Pool.
Wenn Threads aus dem Thread-Pool blockiert werden, können sie dementsprechend enden - die Anzahl der Threads im Thread-Pool ist begrenzt. Es kann konfiguriert werden, es gibt jedoch noch eine Obergrenze. Sobald es erreicht ist, nehmen alle Threadpool-Threads am Sperrkonvoi teil, und jeglicher Code, der den Threadpool betrifft, wird nicht mehr in der Anwendung ausgeführt. Dies verschlechtert die Situation erheblich.
1.4 TimerQueue
- Verwaltet Timer in einer .NET-Anwendung.
- Timer werden verwendet in:
- Task.Delay
- CancellationTocken.CancelAfter
- HttpClient
TimerQueue ist eine Klasse, die alle Timer in einer .NET-Anwendung verwaltet. Wenn Sie einmal in WinForms programmiert haben, haben Sie möglicherweise Timer manuell erstellt. Für diejenigen, die nicht wissen, was Timer sind: Sie werden in Task.Delay verwendet (dies ist nur unser Fall), sie werden auch im CancellationToken in der CancelAfter-Methode verwendet. Das Ersetzen von Task.Delay durch CancellationToken.CancelAfter würde uns in keiner Weise helfen. Darüber hinaus werden Timer in vielen internen .NET-Klassen verwendet, z. B. in HttpClient.
Soweit ich weiß, haben einige Implementierungen von HttpClient-Handlern Timer. Auch wenn Sie sie nicht explizit verwenden, starten Sie Task.Delay nicht. Wahrscheinlich verwenden Sie sie trotzdem.
Schauen wir uns nun an, wie TimerQueue im Inneren angeordnet ist.
- Globaler Status (pro Appdomain):
- Doppelt verknüpfte Liste von TimerQueueTimer
- Objekt sperren - Routine-Timer-Rückrufe
- Timer nicht nach Antwortzeit sortiert
- Hinzufügen eines Timers: O (1) + Sperre
- Timer entfernen: O (1) + Sperre
- Timer starten: O (N) + Sperre
In TimerQueue gibt es einen globalen Status. Es handelt sich um eine doppelt verknüpfte Liste von Objekten vom Typ TimerQueueTimer. TimerQueueTimer enthält einen Link zu anderen TimerQueueTimer, zu Nachbarn in einer verknüpften Liste sowie den Timer und die Rückrufzeit, die beim Auslösen des Timers aufgerufen werden. Diese doppelt verknüpfte Liste ist durch ein Sperrobjekt geschützt, genau das, auf dem der Sperrkonvoi in unserer Anwendung stattgefunden hat. Ebenfalls in TimerQueue gibt es eine Routine, die Rückrufe startet, die an unsere Timer gebunden sind.
Timer sind in keiner Weise nach Antwortzeit geordnet, die gesamte Struktur ist für das Hinzufügen / Entfernen neuer Timer optimiert. Wenn die Routine gestartet wird, durchläuft sie die gesamte doppelt verknüpfte Liste, wählt die Timer aus, die funktionieren sollen, und ruft sie zurück.
Die Komplexität der Operation ist hier so. Das Hinzufügen und Entfernen eines Timers erfolgt O pro Einheit, und der Start der Timer erfolgt pro Zeile. Wenn mit der algorithmischen Komplexität alles akzeptabel ist, gibt es außerdem ein Problem: Alle diese Operationen erfassen die Sperre, was nicht sehr gut ist.
Welche Situation kann passieren? In TimerQueue sind zu viele Timer angesammelt. Wenn die Routine gestartet wird, wird die lange lineare Operation gesperrt. Zu diesem Zeitpunkt können diejenigen, die versuchen, Timer aus TimerQueue zu starten oder zu entfernen, nichts dagegen tun. Aus diesem Grund tritt ein Schleusenkonvoi auf. Dieses Problem wurde in .NET Core behoben.
Reduzieren Sie Timer-Sperrenkonflikte (coreclr # 14527)
- Scherben sperren
- TimerQueueTimer von Environment.ProcessorCount TimerQueue - Separate Warteschlangen für kurz- / langlebige Timer
- Kurzer Timer: Zeit <= 1/3 Sekunde
https://github.com/dotnet/coreclr/issues/14462
https://github.com/dotnet/coreclr/pull/14527
Wie wurde es behoben? Sie haben TimerQueue durchsucht: Anstelle einer TimerQueue, die für die gesamte AppDomain statisch war, wurden für die gesamte Anwendung mehrere TimerQueue erstellt. Wenn Threads dort ankommen und versuchen, ihre Timer zu starten, fallen diese Timer in eine zufällige TimerQueue, und die Threads haben weniger Chancen, auf einer Sperre zusammenzustoßen.
Auch in .NET Core wurden einige Optimierungen angewendet. Timer wurden in langlebige und kurzlebige unterteilt. Für sie werden jetzt separate TimerQueue verwendet. Der kurzlebige Timer wird auf weniger als 1/3 Sekunde eingestellt. Ich weiß nicht, warum eine solche Konstante gewählt wurde. In .NET Core konnten wir keine Probleme mit Timern feststellen.
https://github.com/Microsoft/dotnet-framework-early-access/blob/master/release-notes/NET48/dotnet-48-changes.mdhttps://github.com/dotnet/coreclr/labels/netfx-port-considerDieser Fix wurde auf .NET Framework, Version 4.8, zurückportiert. Das netfx-port-Consider-Tag ist im obigen Link angegeben. Wenn Sie zum .NET Core-, CoreCLR- und CoreFX-Repository wechseln, können Sie nach diesem Problem suchen, das in das .NET Framework zurückportiert wird. Es gibt jetzt ungefähr fünfzig davon. Das heißt, das Open Source .NET hat sehr geholfen, einige Fehler wurden behoben. Sie können Changelog .NET Framework 4.8 lesen: Viele Fehler wurden behoben, viel mehr als in anderen .NET-Versionen. Interessanterweise ist dieses Update in .NET Framework 4.8 standardmäßig deaktiviert. Es ist in der gesamten Ihnen bekannten Datei namens App.config enthalten
Die Einstellung in App.config, die diesen Fix aktiviert, heißt UseNetCoreTimer. Bevor .NET Framework 4.8 herauskam, mussten Sie Ihre Implementierung von Task.Delay verwenden, damit unsere Anwendung funktioniert und nicht in den Sperrkonvoi wechselt. Darin haben wir versucht, einen binären Heap zu verwenden, um effizienter zu verstehen, welche Timer jetzt aufgerufen werden sollten.
1.5 Task.Delay: native Implementierung
- Binärhaufen
- Scherben
- Es hat geholfen, aber nicht in allen Fällen
Durch die Verwendung eines binären Heaps können Sie die Routine optimieren, die Rückrufe aufruft, aber die Zeit verkürzt, die zum Entfernen eines beliebigen Timers aus der Warteschlange erforderlich ist. Dazu müssen Sie den Heap neu erstellen. Dies ist höchstwahrscheinlich der Grund, warum .NET eine doppelt verknüpfte Liste verwendet. Natürlich würde es uns hier nicht helfen, nur einen binären Heap zu verwenden. Wir mussten auch TimerQueue ausarbeiten. Diese Lösung funktionierte einige Zeit, aber trotzdem fiel alles wieder in einen Sperrkonvoi, da Timer nicht nur dort verwendet werden, wo sie explizit im Code gestartet werden, sondern auch in Bibliotheken von Drittanbietern und im .NET-Code. Um dieses Problem vollständig zu beheben, müssen Sie ein Upgrade auf .NET Framework Version 4.8 durchführen und das Update von .NET-Entwicklern aktivieren.
1.6 Task.Delay: Schlussfolgerungen
- Überall Fallstricke - auch bei den am häufigsten verwendeten Dingen
- Stresstests durchführen
- Wechseln Sie zu Core, holen Sie sich zuerst Fehlerbehebungen (und neue Fehler) :)
Was sind die Schlussfolgerungen aus dieser ganzen Geschichte? Erstens können die Fallstricke wirklich überall lokalisiert werden, selbst in den Klassen, die Sie jeden Tag verwenden, ohne beispielsweise an dieselbe Aufgabe, Task.Delay, zu denken.
Ich empfehle, Stresstests Ihrer Vorschläge durchzuführen. Dieses Problem haben wir gerade in der Phase des Lasttests festgestellt. Wir haben es dann mehrmals in der Produktion in anderen Anwendungen gedreht, aber Stresstests haben uns trotzdem geholfen, die Zeit zu verzögern, bevor wir auf dieses Problem in der Realität gestoßen sind.
Wechseln Sie zu .NET Core - Sie erhalten als Erster Fehlerbehebungen (und neue Fehler). Wo ohne neue Bugs?
Die Geschichte über die Timer ist vorbei und wir fahren mit dem nächsten fort.
Geschichte 2: SemaphoreSlim
Die folgende Geschichte handelt von dem bekannten SemaphoreSlim.
2.1 Serverdrosselung
- Es ist erforderlich, die Anzahl der gleichzeitig verarbeiteten Anforderungen auf dem Server zu begrenzen
Wir wollten die Drosselung auf dem Server implementieren. Was ist das? Sie alle kennen wahrscheinlich die Drosselung der CPU: Wenn der Prozessor überhitzt, senkt er seine Frequenz, um sich abzukühlen, und dies schränkt seine Leistung ein. So ist es hier. Wir wissen, dass unser Server N Anfragen parallel verarbeiten kann und nicht fallen kann. Was wollen wir machen Begrenzen Sie die Anzahl der gleichzeitig verarbeiteten Anforderungen auf diese Konstante und stellen Sie sie so ein, dass, wenn weitere Anforderungen eingehen, diese in die Warteschlange gestellt werden und warten, bis die zuvor eingegangenen Anforderungen ausgeführt werden. Wie kann dieses Problem gelöst werden? Es ist notwendig, eine Art Synchronisationsprimitiv zu verwenden.
Semaphore ist ein Synchronisationsprimitiv, auf das Sie N-mal warten können. Danach wartet derjenige, der zuerst N + usw. ankommt, darauf, bis diejenigen, die es früher eingegeben haben, Semaphore freigeben. Es stellt sich ungefähr so heraus: Zwei Hinrichtungsfäden, zwei Arbeiter gingen unter Semaphore, der Rest stand in der Schlange.

Natürlich ist Semaphore für uns nicht sehr geeignet, es ist in .NET synchron, also haben wir SemaphoreSlim genommen und diesen Code geschrieben:
var semaphore = new SemaphoreSlim(N); … await semaphore.WaitAsync(); await HandleRequestAsync(request); semaphore.Release();
Wir erstellen SemaphoreSlim, warten Sie, unter Semaphore bearbeiten wir Ihre Anfrage, danach veröffentlichen wir Semaphore. Es scheint, dass dies eine ideale Implementierung der Server-Drosselung ist und nicht mehr besser sein kann. Aber alles ist viel komplizierter.
2.2 Serverdrosselung: Komplikation
- Anfragen in LIFO-Reihenfolge bearbeiten
- SemaphoreSlim
- Concurrentstack
- TaskCompletionSource
Wir haben die Geschäftslogik ein wenig vergessen. Die Anforderungen, die zur Drosselung kommen, sind echte http-Anforderungen. In der Regel haben sie eine Zeitüberschreitung, die von denjenigen festgelegt wird, die diese Anforderung automatisch gesendet haben, oder eine Zeitüberschreitung des Benutzers, der nach einiger Zeit F5 drückt. Wenn Sie also Anforderungen in einer Warteschlangenreihenfolge wie ein reguläres Semaphor verarbeiten, werden möglicherweise zuerst die Anforderungen aus der Warteschlange verarbeitet, für die eine Zeitüberschreitung aufgetreten ist. Wenn Sie in Stapelreihenfolge arbeiten und zuerst die zuletzt verarbeiteten Anforderungen verarbeiten, tritt ein solches Problem nicht auf.
Zusätzlich zu SemaphoreSlim mussten wir ConcurrentStack, TaskCompletionSource, verwenden, um viel Code um all dies zu wickeln, damit alles in der von uns benötigten Reihenfolge funktionierte. TaskCompletionSource ist so etwas wie CancellationTokenSource, jedoch nicht für CancellationToken, sondern für Task. Sie können eine TaskCompletionSource erstellen, eine Aufgabe daraus ziehen, sie ausgeben und dann TaskCompletionSource mitteilen, dass Sie das Ergebnis für diese Aufgabe festlegen müssen. Diejenigen, die auf diese Aufgabe warten, werden von diesem Ergebnis erfahren.
Wir haben es alle umgesetzt. Der Code ist schrecklich. und am schlimmsten war, dass es nicht funktionierte.
Einige Monate nach dem Start der Verwendung in einer ziemlich stark ausgelasteten Anwendung ist ein Problem aufgetreten. Auf die gleiche Weise wie im vorherigen Fall ist der CPU-Verbrauch auf 100% gestiegen. Wir haben das Gleiche getan, den Dump entfernt, ihn in WinDbg angeschaut und wieder den Schleusenkonvoi gefunden.

Diesmal fand der Lock-Konvoi in SemaphoreSlim.WaitAsync und SemaphoreSlim.Release statt. Es stellte sich heraus, dass es in SemaphoreSlim eine Sperre gibt, die nicht sperrenfrei ist. Dies stellte sich für uns als ziemlich schwerwiegender Nachteil heraus.

In SemaphoreSlim gibt es einen internen Zustand (ein Zähler dafür, wie viele Arbeiter noch darunter gehen können) und eine doppelt verknüpfte Liste derer, die auf dieses Semaphor warten. Die Ideen hier sind ungefähr gleich: Sie können an diesem Semaphor warten, Sie können Ihre Erwartung stornieren - diese Warteschlange zu verlassen. Es gibt ein Schloss, das unser Leben ruiniert hat.
Wir entschieden uns: runter mit all dem schrecklichen Code, den wir schreiben mussten.

Schreiben wir unser Semaphor, das sofort sperrfrei ist und sofort in Stapelreihenfolge funktioniert. Das Warten abzubrechen ist uns nicht wichtig.

Definieren Sie diese Bedingung. Hier ist die Nummer currentCount - so viele Plätze sind noch im Semaphor übrig. Wenn in Semaphore keine Plätze mehr vorhanden sind, ist diese Zahl negativ und zeigt an, wie viele Mitarbeiter sich in der Warteschlange befinden. Es wird auch einen ConcurrentStack geben, der aus TaskCompletionSource'ov besteht - dies ist nur ein Stapel Kellner'ov, aus dem sie bei Bedarf gezogen werden. Schreiben wir die WaitAsync-Methode.
var decrementedCount = Interlocked.Decrement(ref currentCount); if (decrementedCount >= 0) return Task.CompletedTask; var waiter = new TaskCompletionSource<bool>(); waiters.Push(waiter); return waiter.Task;
Zuerst verringern wir den Zähler, nehmen einen Platz für uns im Semaphor ein, wenn wir freie Plätze hatten, und dann sagen wir: „Das war's, du bist unter das Semaphor gegangen“.
Wenn es in Semaphore keine Stellen gab, erstellen wir eine TaskCompletionSource, werfen sie auf den Stapel von waiter'ov und geben Task an die Außenwelt zurück. Wenn die Zeit gekommen ist, wird diese Aufgabe funktionieren, und der Arbeiter kann seine Arbeit fortsetzen und wird unter Semaphor gehen.
Schreiben wir nun die Release-Methode.
var countBefore = Interlocked.Increment(ref currentCount) - 1; if (countBefore < 0) { if (waiters.TryPop(out var waiter)) waiter.TrySetResult(true); }
Die Freigabemethode lautet wie folgt:
- Freier Platz im Semaphor
- Inkrementiere currentCount
Wenn wir anhand von currentCount erkennen können, ob sich im Stapel ein Kellner befindet, über den wir signalisieren müssen, ziehen wir diesen Kellner aus dem Stapel und signalisieren. Hier ist der Kellner eine TaskCompletionSource. Frage zu diesem Code: Es scheint logisch zu sein, aber funktioniert es überhaupt? Welche Probleme gibt es? Es gibt eine Nuance in Bezug darauf, wo Continuation'y und TaskCompletionSource'y gestartet werden.

Betrachten Sie diesen Code. Wir haben eine TaskCompletionSource erstellt und zwei Tasks gestartet. Die erste Aufgabe zeigt eine Einheit an, setzt das Ergebnis auf eine TaskCompletionSource und zeigt dann eine Zwei auf der Konsole an. Die zweite Task wartet auf diese TaskCompletionSource, auf ihre Task und blockiert dann für immer ihren Thread aus dem Thread-Pool.
Was wird hier passieren? Aufgabe 2 bei der Kompilierung wird in zwei Methoden unterteilt, von denen die zweite eine Fortsetzung ist, die Thread.Sleep enthält. Nach dem Festlegen des Ergebnisses der TaskCompletionSource wird diese Fortsetzung in demselben Thread ausgeführt, in dem die erste Task ausgeführt wurde. Dementsprechend wird der Fluss der ersten Aufgabe für immer blockiert und die Zwei zur Konsole werden nicht mehr gedruckt.
Interessanterweise habe ich versucht, diesen Code zu ändern, und wenn ich die Ausgabe an die Konsoleneinheit entfernt habe, wurde die Fortsetzung für einen anderen Thread aus dem Thread-Pool gestartet und die Zwei gedruckt. In welchen Fällen wird die Fortsetzung im selben Thread ausgeführt und in welchen - zum Thread-Pool gelangen - eine Frage an die Leser.
var tcs = new TaskCompletionSource<bool>( TaskCreationOptions.RunContinuationsAsynchronously); Task.Run(() => tcs.TrySetResult(true));
Um dieses Problem zu lösen, können wir entweder eine TaskCompletionSource mit dem entsprechenden RunContinuationsAsynchronously-Flag erstellen oder die TrySetResult-Methode in Task.Run/ThreadPool.QueueUserWorkItem aufrufen, damit sie nicht in unserem Thread ausgeführt wird. Wenn es in unserem Thread ausgeführt wird, können unerwünschte Nebenwirkungen auftreten. Darüber hinaus gibt es ein zweites Problem, auf das wir noch näher eingehen werden.

Schauen Sie sich die WaitAsync- und Release-Methoden an und versuchen Sie, ein anderes Problem in der Release-Methode zu finden.
Höchstwahrscheinlich ist es so einfach unmöglich, sie zu finden. Hier gibt es ein Rennen.

Dies liegt an der Tatsache, dass bei der WaitAsync-Methode die Statusänderung nicht atomar ist. Zuerst dekrementieren wir den Zähler und schieben erst dann den Kellner auf den Stapel. Wenn es so kommt, dass Release zwischen Dekrement und Push ausgeführt wird, wird es möglicherweise beendet, damit nichts aus dem Stapel gezogen wird. Dies muss berücksichtigt werden, und warten Sie bei der Freigabemethode, bis der Kellner auf dem Stapel angezeigt wird.
var countBefore = Interlocked.Increment(ref currentCount) - 1; if (countBefore < 0) { Waiter waiter; var spinner = new SpinWait(); while (!waiter.TryPop(out waiter)) spinner.SpinOnce(); waiter.TrySetResult(true); }
Hier machen wir es in einer Schleife, bis wir es schaffen, es herauszuziehen. Um Prozessorzyklen nicht noch einmal zu verschwenden, verwenden wir SpinWait.
In den ersten Iterationen dreht es sich in einer Schleife. Wenn es viele Iterationen gibt, wird der Kellner lange Zeit nicht angezeigt, und unser Thread wechselt zu Thread.Sleep, um keine CPU-Ressourcen erneut zu verschwenden.
Tatsächlich ist das Semaphor der LIFO-Ordnung nicht nur unsere Idee.
LowLevelLifoSemaphore
- Synchron
- Unter Windows wird der E / A-Abschlussport als Windows-Stapel verwendet
https://github.com/dotnet/corert/blob/master/src/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.cs
Es gibt ein solches Semaphor in .NET selbst, aber nicht in CoreCLR, nicht in CoreFX, sondern in CoreRT. Es ist manchmal sehr nützlich, einen Blick in das .NET-Repository zu werfen. Es gibt ein Semaphor namens LowLevelLifoSemaphore. Dieses Semaphor würde uns sowieso nicht passen: Es ist synchron.
Bemerkenswerterweise funktioniert es unter Windows über IO Completion-Ports. Sie haben die Eigenschaft, dass Threads auf sie warten können, und diese Threads werden nur in der LIFO-Reihenfolge freigegeben. Diese Funktion wird dort verwendet, es ist wirklich LowLevel.
2.3 Schlussfolgerungen:
- Hoffen Sie nicht, dass die Füllung des Frameworks unter Ihrer Last überlebt
- Es ist einfacher, ein bestimmtes Problem zu lösen als im allgemeinen Fall.
- Stresstests helfen nicht immer
- Vorsicht vor Blockierung
Was sind die Schlussfolgerungen aus dieser ganzen Geschichte? Hoffen Sie zunächst nicht, dass einige Klassen aus dem Framework, das Sie aus der Standardbibliothek verwenden, mit Ihrer Last fertig werden. Ich möchte nicht sagen, dass SemaphoreSlim schlecht ist, es hat sich gerade in diesem Szenario als ungeeignet herausgestellt.
Es stellte sich heraus, dass es für uns viel einfacher war, unser Semaphor für eine bestimmte Aufgabe zu schreiben. Beispielsweise wird das Abbrechen des Wartens nicht unterstützt. Diese Funktion ist im üblichen SemaphoreSlim verfügbar, wir haben sie nicht, aber dies ermöglichte es uns, den Code zu vereinfachen.
Lasttests helfen zwar, helfen aber möglicherweise nicht immer.
.NET , — . lock, : « ?» CPU 100%, lock', , , - .NET. .
.
3: (A)sync IO
/, .

lock convoy, stack trace Overlapped PinnableBufferCache. lock. : Overlapped PinnableBufferCache?
OVERLAPPED — Windows, /. , . , . , lock convoy. , lock convoy, , .

, , .NET 4.5.1 4.5.2. .NET 4.5.2, , .NET 4.5.2. .NET 4.5.1 OverlappedDataCache, Overlapped — , , . , lock-free, ConcurrentStack, . .NET 4.5.2 : OverlappedDataCache PinnableBufferCache.
? PinnableBufferCache , Overlapped , , — . , , . PinnableBufferCache . , lock-free, ConcurrentStack. , . , , - lock-free list lock'.
3.1 PinnableBufferCache
LockConvoy:
lock convoy , - . list , lock , , .
PinnableBufferCache , . :
PinnableBufferCache_System.ThreadingOverlappedData_MinCount
, . : « ! - ». -:
Environment.SetEnvironmentVariable( "PinnableBufferCache_System.Threading.OverlappedData_MinCount", "10000"); new Overlapped().GetHashCode(); for (int i = 0; i < 3; i++) GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
? , Overlapped , , . , , , , PinnableBufferCache lock convoy'. , .
.NET Core PinnableBufferCache
, OverlappedData . , , Garbage collector , . .NET Core . .NET Framework, , .
3.2 :
, . , .NET , . , , .NET Core. , , -.
key-value .
4: Concurrent key-value collections
.NET concurrent-. lock-free ConcurrentStack ConcurrentQueu, . ConcurrentDictionary, . lock-free , , . ConcurrentDictionary?
4.1 ConcurrentDictionary
:
Vorteile:
- (TryAdd/TryUpdate/AddOrUpdate)
- Lock-free
- Lock-free enumeration
, memory-, , . , , .NET Framework. . , , (enumeration) lock-free. , .
, , - .NET. key-value - :

-, bucket'. bucket', . , bucket , .
— , ConcurrentDictionary. ConcurrentDictionary «-» . , , , memory traffic. ConcurrentDictionary, lock'. — .
, Dictionary.

Dictionary , Concurrent, . : buckets, entries. buckets bucket' entries. «-» entries. . «-» int, bucket'.
memory overhead, ConcurrentDictionary Dictionary.

Dictionary. Memory overhea' , . Dictionary overhead - , int'. 8 .
ConcurrentDictionary. ConcurrentDictionary ConcurrentDictionary.Node. , . int hashCode . , table ( 16 ), int hashCode . , 64- 28 overhead'. Dictionary.
memory overhead', ConcurrentDictionary GC , . Benchmark. ConcurrentDictionary , GC.Collect. ?

. ConcurrentDictionary 10 , , , . Dictionary . , , , . .
, ConcurrentDictionary?
4.2
- TTL
- Dictionary+lock
- Sharding
. ConcurrentDictionary. 10 . , . TTL , . Dictionary lock'. , , lock . Dictionary lock' , - , lock. , .
4.3
- in-memory <Guid,Guid>
- >10 6
. — , in-memory Guid' Guid, . . - - , . , 15 . . Semaphore ConcurrentDictionary.

, lock-free , overhead GC. , . , , , . , - , , . , , Large Object Heap. ?
, , Dictionary .

Dictionary bucket', Entry. Entry , , , .

Dictionary , , . , - .
, - ? -, , , , . . Dictionary, , buckets, entries, Interlocked. , .
Dictionary
- ,
- , ?
— Resize buckets entries
— -
— Dictionary.Entry
— -
https://blogs.msdn.microsoft.com/tess/2009/12/21/high-cpu-in-net-app-using-a-static-generic-dictionary/
, Dictionary - bucket'. , . , , . , , .
Entry Dictionary. - - . , .

.NET Framework 1.1. Hashtable, Dictionary, object'. MSDN , . , -. . , Hashtable . , .
4.4 Dictionary.Entry

? Dictionary.Entry , , 8 , , , , . Wie kann man das machen?
bool writing; int version; this.writing = true; buckets[index] = …; this.version++; this.writing = false;
: ( , ) int-. , . , , , , .
bool writing; int version; while (true) { int version = this.version; bucket = bickets[index]; if (this.writing || version != this.version) continue; break; }
, , . , . , 8 .
4.5 -
, .

Dictionary bucket , .
Dictionary, . : 0 2. bucket, 1 2. ? 0. , , 2. . , 2, , , 1. 1 2 — bucket. , , . 1 — , bucket. Hashtable , bucket' -. —
double hashing .
4.6
. , Buckets, Entries ( Buckets, Entries). - , , , , .
. , .
: , , , , . , , .

, , — .
? , - 2. - Capacity , . — 2. , . 2. ? , , , . - , , 3. , , , , , .
, Hashtable, . , double hashing. , , , .
, , — , . Hashtable. , — — . . , bucket', - , . .
, , lock-free LOH.

lock-free ? MSDN Hashtable , . , , .

, , , bucket'. Dictionary bucket', -, bucket' . - bucket, bucket . , .
, Large Object Heap.

. CustomDictionary CustomDictionarySegment . Dictionary, , . — Dictionary, . , Large Object Heap. , bucket' . , , , bucket, - - .
. ConcurrentDictionary, .NET, , .
4.7
? .NET . . , , . - — - . , , , .
- , , , , . , , , , , . — , , .
Nützliche Links
— ConcurrentDictionary. , , (
Diafilm ), .
GitHub. — , , LIFO-Semaphore, . , .
6-7 DotNext 2019 Moscow «.NET: » , .NET Framework .NET Core, , .