Asynchrone Programmierung in .NET: Best Practices

Das Aufkommen von async / await in C # hat zu einer Neudefinition des Schreibens von einfachem und korrektem Parallelcode geführt. Mit der asynchronen Programmierung lösen Programmierer häufig nicht nur die Probleme mit den Threads, sondern führen auch neue ein. Deadlocks und Flüge gehen nirgendwo hin - sie werden nur schwieriger zu diagnostizieren.



Dmitry Ivanov - Software Analysis TeamLead bei Huawei, einem ehemaligen JetBrains Rider-Techniker und Entwickler des ReSharper-Kerns: Datenstrukturen, Caches, Multithreading und ein regelmäßiger Redner auf der DotNext- Konferenz .

Unter der Zwischensequenz - Videoaufzeichnung und Texttranskription von Dmitrys Bericht von der DotNext 2019 Piter-Konferenz.



Weitere Erzählung im Namen des Sprechers.

In Multithread- oder asynchronem Code bricht häufig etwas zusammen. Der Grund könnte sowohl Deadlock als auch Race sein. In der Regel stürzt ein Rennen einmal von tausend ab, oft nicht lokal, sondern nur auf einem Build-Server, und es dauert mehrere Tage, bis es abgefangen wird. Ich bin mir sicher, dass dies für viele eine vertraute Situation ist.

Wenn ich mir selbst von erfahrenen Entwicklern asynchronen Code anschaue, denke ich außerdem, dass einige Dinge dreimal kürzer und korrekter aufgeschrieben werden können.

Dies deutet darauf hin, dass das Problem nicht bei Menschen liegt, sondern beim Instrument. Die Leute benutzen das Tool einfach und möchten, dass es ihr Problem löst. Das Tool selbst verfügt über eine sehr große Anzahl von Funktionen (manchmal sogar überflüssig), Einstellungen und einen impliziten Kontext, was dazu führt, dass es sehr einfach ist, es falsch zu verwenden. Versuchen wir herauszufinden, wie Sie async / await verwenden und mit einer Klasse Taskin .NET arbeiten.

Planen


  • Probleme mit Ansätzen, die mit async / await gelöst werden.
  • Beispiele für kontroverses Design.
  • Eine Aufgabe aus dem wirklichen Leben, die wir asynchron lösen werden.


Async / warte und zu lösende Probleme




Warum brauchen wir Async / Warten? Angenommen, wir haben Code, der mit Shared Shared Memory funktioniert.

Zu Beginn der Arbeit lesen wir die Anforderung, in diesem Fall die Datei aus der Blockierungswarteschlange (z. B. aus dem Internet oder von der Festplatte), unter Verwendung der Blockierungsanforderung Dequeue (Blockierungsanforderungen werden in den Bildern mit Beispielen rot markiert).

Dieser Ansatz erfordert viele Threads, und jeder Thread benötigt Ressourcen, wodurch der Scheduler belastet wird. Dies ist jedoch nicht das Hauptproblem. Angenommen, Benutzer könnten Betriebssysteme so umschreiben, dass diese Systeme sowohl hunderttausend als auch eine Million Threads unterstützen. Das Hauptproblem ist jedoch, dass einige Threads einfach nicht genommen werden können. Sie haben beispielsweise einen Benutzeroberflächenthread. Es gibt keine normalen adäquaten UI-Frameworks, bei denen der Zugriff auf Daten noch nicht nur von einem Thread aus erfolgt. UI-Thread kann nicht blockiert werden. Und um es nicht zu blockieren, benötigen wir asynchronen Code.

Lassen Sie uns nun über die zweite Aufgabe sprechen. Nachdem wir die Datei gelesen haben, muss sie irgendwie verarbeitet werden. Wir werden es parallel tun.

Viele von Ihnen haben gehört, dass Parallelität nicht dasselbe ist wie Asynchronität. In diesem Fall stellt sich die Frage: Kann Asynchronität dazu beitragen, parallelen Code kompakter, schöner und schneller zu schreiben?

Die letzte Aufgabe besteht darin, mit gemeinsam genutztem Speicher zu arbeiten. Müssen wir diesen Mechanismus mit Sperren ziehen, mit asynchronem Code synchronisieren oder kann dies irgendwie vermieden werden? Kann async / warte dabei helfen?

Pfad zum Async / Warten


Betrachten wir die Entwicklung der asynchronen Programmierung im Allgemeinen in der Welt und in .NET.

Ruf zurück


Void Foo(params, Action callback) {…}
 

Void OurMethod() {//synchronous code
 
    Foo(params,() =>{//asynchronous code;continuation
    });
}

Die asynchrone Programmierung begann mit Rückrufen. Das heißt, zuerst müssen Sie einen Teil des Codes synchron aufrufen und den zweiten Teil - asynchron. Sie lesen beispielsweise aus einer Datei und wenn die Daten fertig sind, werden sie Ihnen irgendwie zugestellt. Dieser asynchrone Teil wird als Rückruf übergeben .

Weitere Rückrufe


void Foo(params, Action callback) {...} 
void Bar(Action callback) {...}
void Baz(Action callback) {...}

void OurMethod() {
    ... //synchronous code
    
    Foo(params, () => { 
      ... //continuation 1 
      Bar(() => {
        //continuation 2
        Baz(() => {
          //continuation 3
        }); 
      });
    });
}

So können Sie von einem Rückruf aus einen weiteren Rückruf registrieren , von dem aus Sie einen dritten Rückruf registrieren können, und am Ende wird alles zu einer Rückruf- Hölle .



Rückruf: Ausnahmen



void Foo(params, Action onSuccess, Action onFailure) {...}


void OurMethod() {
    ... //synchronous code 
    Foo(params, () => {
      ... //asynchronous code on success 
    },
    () => {
        ... //asynchronous code on failure
    }); 
}

Wie arbeite ich mit Ausnahmen? Zum Beispiel zeigt ReSharper, wenn es separat auf Ausnahmen und eine gute Ausführung reagiert, nicht die schönsten Codeteile - es gibt separate Rückrufe für eine Ausnahmesituation und für eine erfolgreiche Fortsetzung. Das Ergebnis ist eine solche Rückrufhölle , aber nicht linear, sondern baumartig, was völlig verwirrend sein kann.



In .NET wird der erste Rückrufansatz als Asynchronous Programming Model (APM) bezeichnet. Die Methode wird aufgerufen AsyncCallback, was im Wesentlichen dieselbe ist wie Action, aber der Ansatz weist einige Merkmale auf. Zuallererst sollten Methoden mit dem Wort „Begin“ beginnen (das Lesen aus einer Datei ist BeginRead), das einige zurückgibt AsyncResult. SelbstAsyncResult- Dies ist ein Handler, der weiß, dass der Vorgang abgeschlossen wurde und über einen Mechanismus verfügt WaitHandle. Sie WaitHandlekönnen warten, bis der Vorgang asynchron abgeschlossen ist. Auf der anderen Seite können Sie aufrufen EndOperation, EndReaddh synchron machen und hängen (was einer Eigenschaft sehr ähnlich ist Task.Result).

Dieser Ansatz weist eine Reihe von Problemen auf. Erstens schützt es uns nicht vor der Hölle des Rückrufs . Zweitens bleibt völlig unklar, was mit Ausnahmen zu tun ist. Drittens ist nicht klar, auf welchem ​​Thread dieser Rückruf aufgerufen wird - wir haben keine Kontrolle über den Aufruf. Viertens stellt sich die Frage, wie Code mit Rückrufen kombiniert werden kann.



Das zweite Modell heißt ereignisbasiertes asynchrones Muster. Dies ist ein reaktiver Rückrufansatz. Die Idee der Methode ist, dass wir OperationNameAsyncein Objekt mit abgeschlossenem Ereignis an die Methode übergeben und dieses Ereignis abonnieren. Wie Sie bemerkt haben, BeginOperationNameändert sich zu OperationNameAsync. Verwirrung kann auftreten, wenn Sie in die Socket-Klasse gehen, in der zwei Muster gemischt werden: ConnectAsyncund BeginConnect.

Bitte beachten Sie, dass Sie anrufen müssen, um abzubrechen OperationNameAsyncCancel. Da dies in .NET nirgendwo anders zu finden ist, sendet normalerweise jeder CancellationToken s . Wenn Sie also versehentlich auf eine Methode in der Bibliothek stoßen, die mit endet Async, müssen Sie verstehen, dass sie nicht unbedingt zurückgegeben wird Task, sondern eine ähnliche Konstruktion zurückgeben kann.



Stellen Sie sich ein Modell vor, das in Java als bekannt istFutures in JavaScript als Versprechen und in .NET als asynchrone Aufgabenmuster, mit anderen Worten als "Aufgaben". Bei dieser Methode wird davon ausgegangen, dass Sie über ein Berechnungsobjekt verfügen und den Status dieses Objekts (ausgeführt oder beendet) anzeigen können. In .NET gibt es eine sogenannte RnToCompletion, bequeme Trennung von zwei Status: dem Start der Aufgabe und dem Abschluss der Aufgabe. Ein häufiger Fehler tritt auf, wenn ein Verfahren auf einer Aufgabe aufgerufen wird , IsCompleteddass die Renditen nicht erfolgreiche Fortsetzung, aber RnToCompletion, Canceledund Faulted. Daher sollte sich das Ergebnis des Klickens auf "Abbrechen" in der UI-Anwendung von der Rückgabe von Ausnahmen (Ausführungen) unterscheiden. In .NET wurde eine Unterscheidung getroffen: Wenn die Ausführung Ihr Fehler ist, den Sie sichern möchten, klicken Sie auf Abbrechen- Zwangsbetrieb.

In .NET wurde auch ein Konzept eingeführt TaskScheduler- es ist eine Art Abstraktion über Threads, die angibt, wo die Aufgabe ausgeführt werden soll. In diesem Fall wurde die Stornierungsunterstützung auf Entwurfsebene entworfen. Fast alle Vorgänge in der Bibliothek in .NET CancellationTokenkönnen übergeben werden. Dies funktioniert nicht für alle Sprachen: In Kotlin können Sie beispielsweise Aufgaben rückgängig machen, in .NET jedoch nicht. Die Lösung kann die Aufteilung der Verantwortung zwischen denjenigen, die die Aufgabe stornieren, und der Aufgabe selbst sein. Wenn Sie eine Aufgabe erhalten, können Sie sie nur explizit abbrechen - Sie müssen sie weitergeben CancellationToken.

Mit einem speziellen Objekt TaskCompletionSourekönnen Sie auf einfache Weise alte APIs anpassen, die dem ereignisbasierten asynchronen Muster oder dem asynchronen Programmiermodell zugeordnet sind. Es gibt ein Dokument, das Sie lesen müssen, wenn Sie Aufgaben programmieren. Es beschreibt alle Vereinbarungen in Bezug auf Tasas. Beispielsweise sollte jede Methode, die die Aufgabe zurückgibt, sie in einem laufenden Zustand zurückgeben, was bedeutet, dass dies nicht möglich ist Created, während alle derartigen Vorgänge enden müssen Async.

Fortsetzungen kombinieren


Task ourMethod() {
  return Task.RunSynchronously(() =>{
    ... //synchronous code
  })
  .ContinueWith(_ =>{
    Foo(); //continuation 1
  })
  .ContinueWith(_ =>{
    Bar(); //continuation 2
  })
  .ContinueWith(_ =>{
    Baz(); //continuation 3
  })
}

Die Kombination kann unter Berücksichtigung der Rückrufhölle in einer lineareren Form erscheinen, obwohl sich wiederholende Codeteile mit minimalen Änderungen vorhanden sind. Es scheint, dass sich der Code auf diese Weise verbessert, aber auch hier gibt es Fallstricke.

Aufgaben starten und fortsetzen


Task.Factory.StartNew(Action, 
  TaskCreationOptions, 
  TaskScheduler, 
  CancellationToken
)
Task.ContinueWith(Action<Task>, 
  TaskContinuationOptions, 
  TaskScheduler, 
  CancellationToken
)

Wenden wir uns beim Start der Standardaufgabe drei Parametern zu: Der erste sind die Optionen zum Starten der Aufgabe, der zweite ist der scheduler, auf dem die Aufgabe gestartet wird, und der dritte - CancellationToken.



TaskScheduler gibt an, wo die Aufgabe beginnt, und ist ein Objekt, das Sie unabhängig überschreiben können. Sie können beispielsweise eine Methode überschreiben Queue. Wenn Sie das tun TaskSchedulerfür thread pooldas Verfahren Queuenimmt einen Faden aus thread poolund sendet Ihre Aufgabe dort.

Wenn Sie schedulerden Hauptthread übernehmen, wird alles in eine Warteschlange gestellt, und Aufgaben werden nacheinander im Hauptthread ausgeführt. Das Problem ist jedoch, dass Sie in .NET Aufgaben ausführen können, ohne sie zu übergeben TaskScheduler. Es stellt sich die Frage: Wie berechnet .NET dann, welche Aufgabe an es übergeben wurde? Wenn die Aufgabe von StartNewinnen beginntAction, ThreadStatic. Currentausgestellt in dem TaskScheduler, den wir ihr gegeben haben.

Dieses Design erscheint aufgrund des impliziten Kontexts eher kontrovers. Es gab Fälle, in denen es TaskSchedulerasynchronen Code enthielt, der irgendwo sehr tief geerbt TaskScheduler.Currentund mit einem anderen Scheduler überlappt wurde, was zu Deadlocks führte. In diesem Fall können Sie die Option verwenden TaskCreationOption.HideScheduler. Dies ist eine Alarmglocke, die besagt, dass wir eine Option haben, die die ThreadStaticEinstellung überschreibt .

Bei Fortsetzungen ist alles gleich. Es stellt sich die Frage: Woher kommt es TaskSchedulerfür Fortsetzungen? Zunächst wird die Methode verwendet, mit der Sie begonnen haben Continuation. Es stammt auch TaskScheduleraus ThreadStatic. Es ist wichtig, dass Fortsetzungen für Async / Warten sehr unterschiedlich funktionieren.



Wir wenden uns den Parametern TaskCreationOptionsund zu TaskContinuationOptions. Ihr Hauptproblem ist, dass es viele von ihnen gibt. Einige dieser Parameter heben sich gegenseitig auf, andere schließen sich gegenseitig aus. Alle diese Parameter können in allen möglichen Kombinationen verwendet werden, daher ist es schwierig, alles im Auge zu behalten, was mit Sehnsucht passieren kann. Einige dieser Optionen funktionieren völlig unverständlich.



Zum Beispiel stellen die Parameter ExecuteSynchronouslyund RunContinuationsAsynchronouslyzwei mögliche Anwendungsoptionen dar. Ob die Fortsetzung jedoch synchron oder asynchron gestartet wird, hängt von so vielen Dingen ab, die Sie nicht kennen.



Ein weiteres Beispiel: Wir haben die Aufgabe gestartet, die Fortsetzung gestartet und gleichzeitig zwei Parameter angegebenTaskContinuations.ExecuteSynchronouslyDanach starteten sie die Fortsetzung asynchron. Wird es auf demselben Stapel ausgeführt, auf dem die vorherige Aufgabe endet, oder wird es auf übertragen thread pool? In diesem Fall gibt es eine dritte Option: es kommt darauf an.



TaskCompletionSource


Überlegen Sie TaskCompletionSource. Wenn Sie eine Aufgabe erstellen, setzen Sie das Ergebnis durch SetResult, um die vorherigen asynchronen Muster an die Aufgabenwelt anzupassen. Sie TaskCompletionSourcekönnen anfordern tcs.Task, und diese Aufgabe wird finishbeim Aufrufen in einen Status versetzt tcs.SetResult. Wenn Sie dies jedoch im Thread-Pool ausführen , kommt es zu einem Deadlock . Die Frage ist, warum, wenn wir nicht einmal synchron etwas geschrieben haben?



Wir erstellen TaskCompletionSource, starten eine neue Aufgabe und haben einen zweiten Thread, der etwas in dieser Aufgabe startet. Es geht über und fällt für hundert Millisekunden in Erwartung. Dann wartet unser Hauptfaden - grün - und das wars. Er gibt den Stapel frei, der Stapel hängt und wartet darauf, in einer Fortsetzung aufgerufen zu werdentask.Waitwenn tcsausgesetzt.

Im blauen Faden kommen wir zu tcsund dann zum interessantesten. Basierend auf internen Überlegungen von .NET TaskCompletionSourceglaubt er , dass die Fortsetzung tcssynchron durchgeführt werden kann, dh direkt auf demselben Stapel, dann task.Waitsynchron auf demselben Stapel. Das ist sehr seltsam, obwohl wir noch nirgendwo geschrieben haben ExecuteSynchronously. Dies ist wahrscheinlich das Problem beim Mischen von synchronem und asynchronem Code.



Ein weiteres Problem dabei TaskCompletionSourceist, dass Sie beim Aufrufen SetResultunter der Sperre keinen beliebigen Code aufrufen können, da Sie unter der Sperre nur einige kleine granulare Aktivitäten ausführen können. Laufen Sie unter einigen AktionenEs ist unmöglich, von dort zu kommen, wo sie herkommen. Wie kann man dieses Problem lösen?

var  tcs  =  new   TaskCompletionSource<int>(
       TaskContinuationsOptions.RunContinuationsAsynchronously  
) ;
lock(mylock)
{  
    tcs.SetResult(O); 
});

Es TaskCompletionSourcelohnt sich , nur zur Anpassung von nicht Task- Code in Bibliotheken zu verwenden. Fast alles andere kann durch Warten gelöst werden. In diesem Fall wird immer dringend empfohlen, den Parameter "TaskCompletionSource.RunContinuationsAsynchronously" vorzuschreiben . Sie müssen fast immer eine Fortsetzung asynchron ausführen. In diesem Fall haben Sie tcs.SetResultetwas, unter dem nichts gestartet wird.



Warum sollte die Fortsetzung synchron erfolgen? Weil es RunContinuationsAsynchronouslysich auf Folgendes bezieht ContinueWithund nicht auf unser. Damit er sich auf unsere beziehen kann, müssen Sie Folgendes schreiben:



Dieses Beispiel zeigt, wie Parameter nicht intuitiv sind, wie sie sich überschneiden, wie sie kognitive Komplexität einführen - es ist so schwierig zu schreiben.

Eltern-Kind-Hierarchie


Task.Factory.StartNew(() => 
{
  //... some parent activity

   Task.Factory.StartNew(() => {
      //... some child activity
   })

})
.ContinueWith(...) // don’t wait for child

Es gibt andere Optionen für die Verwendung von Parametern. Beispielsweise entsteht eine Eltern-Kind- Hierarchie , wenn Sie eine Aufgabe starten und eine andere darunter ausführen. In diesem Fall, wenn Sie schreiben ContinueWith, Sie ContinueWithwerden nicht für die Aufgabe innerhalb gestartet warten.



Wenn Sie schreiben TaskCreationOptions.AttachedToParent, wird es ContinueWithwarten. Sie können diese Eigenschaft in Ihren Produkten verwenden. Ich denke, jeder kann sich ein Beispiel einfallen lassen, in dem es eine Hierarchie von Aufgaben gibt, wobei die Aufgabe auf die Unteraufgabe und die Unteraufgabe auf ihre Unteraufgaben wartet. Sie müssen nirgendwo schreiben WaitForChildren, diese Wartezeit erfolgt asynchron. Das heißt, der Hauptteil der übergeordneten Aufgabe endet und danach beginnt die Fortsetzung der übergeordneten Aufgabe erst, wenn die untergeordneten Aufgaben funktionieren.

Task.Factory.StartNew(() => 
{
  //... some parent activity
  Foo(); 

})
.ContinueWith(...) // still wait for child

void Foo() { 
   Task.Factory.StartNew(() => {
      //... parent task to attach is in ThreadStatic
   }, TaskCreationOptions.AttachedToParent); 
}

Möglicherweise liegt ein Problem vor, bei dem die Aufgabe irgendwo in übertragen ThreadStaticwird. Dann wird alles, mit dem Sie begonnen AttachedToParenthaben, zu dieser übergeordneten Aufgabe hinzugefügt, bei der es sich um eine Alarmglocke handelt.

Task.Factory.StartNew(() => 
{
  //... some parent activity

  Foo();
}, TaskCreationOptions.DenyChildAttach)
.ContinueWith(...) // don’t wait for child

void Foo() { 
   Task.Factory.StartNew(() => {
      //... some child activity
   }, TaskCreationOptions.AttachedToParent); 
}

Auf der anderen Seite gibt es eine Option, die die vorherige Option aufhebt DenyChildAttach. Eine solche Anwendung kommt ziemlich oft vor.

Task.Run(() => 
{
  //... some parent activity

  Foo(); 

})
.ContinueWith(...) //don’t wait for child

void Foo() { 
   Task.Factory.StartNew(() => {
      //... some child activity
    }, TaskCreationOptions.AttachedToParent); 
}

Es sei daran erinnert, dass Task.Rundies die Standardmethode für den Start ist, was standardmäßig impliziert DenyChildAttach.

Der implizite Kontext, den Sie eingeben, ThreadStaticerhöht die Komplexität. Sie verstehen nicht, wie die Aufgabe funktioniert, da Sie den Kontext kennen müssen. Ein weiteres Problem, das auftreten kann, hängt mit dem Ruhezustand von async / await zusammen. Das liegt daran, dass Sie in async / await keine Aufgaben, sondern Aktionen haben. Fortsetzung ist keine ehrliche Aufgabe, sondern Handeln. Wenn Sie asynchronen / wartenden Code schreiben, müssen Sie ihn nicht verwenden AttachedToParent, da Sie die Aufgaben zum Warten auf warten explizit verknüpfen. Dies ist der richtige Ansatz.



Sie haben sechs Möglichkeiten, wie Sie eine Fortsetzung starten können. Sie haben die Aufgabe gestartet, gestartetContinueWith. Frage: Welchen Status wird diese Fortsetzung haben? Es gibt fünf mögliche Antworten:

  • Die allgemeine Fortsetzung wird erfolgreich abgeschlossen. RunToCompletion wird ausgeführt.
  • Die Aufgabe ist fehlerhaft.
  • Stornierung erfolgt;
  • Die Aufgabe wird überhaupt nicht abgeschlossen sein, sie wird in einer Art Schwebe sein.
  • Option - "hängt davon ab".



In diesem Fall befindet sich die Aufgabe im Status "Abgebrochen", obwohl das Wort "Abgebrochen" nirgendwo ist. Hier werfen wir die Rezeption und tun nichts. Das Problem ist, dass Sie beim Lesen des Codes eines anderen mit vielen Optionen - auch wenn Sie vor 10 Minuten von diesen Optionen gewusst haben - immer noch vergessen, was hier passiert. Also nicht schreiben.

Stornierung



Task.Factory.StartNew(() => 
{
    throw new OperationCanceledException(); 
});

                                                      Failed

Der dritte Parameter beim Starten einer Aufgabe ist die Stornierung. Sie schreiben OperationCanceledException, dh eine spezielle Aktion, die die Aufgabe in den Status "Abgebrochen" versetzt. In diesem Fall befindet sich die Aufgabe im Status "Fehlgeschlagen", da nicht alle OperationCanceledExceptiongleich sind.

Task.Factory.StartNew(() => 
{
    throw new OperationCanceledException(cancellationToken); 
}, cancellationToken);

                                                      Canceled

Damit die Aufgabe ausgeführt werden kann, Canceledmüssen Sie sie OperationCanceledExceptionzusammen mit dem CancellationToken werfen . In Wirklichkeit tun Sie dies niemals explizit, sondern auf folgende Weise:

Task.Factory.StartNew(() => 
{
    cancellationToken.ThrowIfCancellationRequested(); 
}, cancellationToken);
                                                       Canceled

Ist es notwendig, CancellationToken zu unterscheiden? Irgendwo innerhalb der Aufgabe überprüfen Sie, ob jemand Sie gelöscht hat: Wurfabbruch, dann wird die Aufgabe in den Status versetzt Canceled. Oder jemand hat zur Laufzeit auf "Abbrechen" geklickt und die Aufgabe abgebrochen. Unsere Praxis bei JetBrains legt nahe, dass Sie nicht zwischen diesen Token unterscheiden müssen. Wenn Sie eine OperationCanceledException erhalten - eine spezielle Art, die auftritt, wenn eine Stornierung erfolgt ist, können Sie sie unterscheiden. In diesem Fall müssen Sie die Aufgabe nur normal ausführen, sich nicht anmelden und sich nach Erhalt der Ausführung anmelden.

Tiefer Stapel


Task.Factory.StartNew(() => 
{
    Foo();
}, cancellationToken);

  void Foo() { 
     Bar() {
       ...
          Baz() {
             //how to get cancellation token?
          } 
    }
}

Angenommen, Sie haben einen tiefen Stapel. Dies CancellationTokenist der einzige explizite Parameter, den wir besprochen haben. Es muss überall durch absolut alle Hierarchien übertragen werden. Was soll ich tun, wenn Sie bei einer tiefen Hierarchie Ihre Aufgabe irgendwo auf der untersten Ebene abbrechen müssen, um den Empfang auszuschalten? Es gibt so einen besonderen Trick, den wir verwenden. Er heißt AsyncLocal.

static AsyncLocal<Cancelation> asyncLocalCancellation;

Task.Factory.StartNew(() => 
{
     asyncLocalCancellation.Set(cancellationToken) 
    Foo();
}, cancellationToken); // use AsyncLocal to put cancellation int

  void Foo() { 
     async Bar() {
      ...
         Baz() {
             asyncLocalCancellation.Value.CheckForInterrupt(); 
         }
   } 
}

Dies ist dasselbe wie ThreadStaticnur das spezielle ThreadLocal, das asynchrone / wartende Code-Trips überlebt. Da Ihr Code asynchron ist und Sie diese Kancellation haben, geben Sie ihn ein AsyncLocalund irgendwo auf einer tiefen Ebene können Sie " CheckForInterrupt Throw If Cancellation Requested" sagen . Auch dies ist der einzige Parameter CancellationToken, der den gesamten Code vollständig verschmieren muss. Meiner Meinung nach müssen Sie jedoch für die meisten Aufgaben nur wissen, was passiert ist OperationCanceledException, und daraus eine Schlussfolgerung ziehen, die besagt: Abgebrochen oder fehlgeschlagen.

Kognitive Komplexität


Task.Factory.StartNew(Action, 
    TaskCreationOptions, 
    TaskScheduler, 
    CancellationToken
)
                                                   JetBrains.Lifetimes

lifetime.Start(TaskScheduler, Action) //puts lifetime in AsyncLocal

lifetime.StartMainRead(Action) 
lifetime.StartMainWrite(TaskScheduler, Action) 
lifetime.StartBackgroundRead(TaskScheduler, Action)

Je schwieriger der Code beim Starten der Aufgabe zu lesen ist, desto höher ist das Fehlerrisiko. Wenn Sie sich den Code nach einem Jahr ansehen, werden Sie vergessen, was er tut, da es eine große Anzahl von Parametern gibt. Wir haben jedoch die JetBrains.Lifetimes- Bibliothek , die moderne Lebensdauern bietet, ein gut optimiertes CancellationToken, mit dem die Start-Methode neu geschrieben und das Problem mit sich wiederholenden Codeteilen wie mit Task.Factory.StartNewund gelöst wurde TaskCreationOptions.

Es gibt eine kleine Anzahl von Schedulern, mit denen Sie eine Aufgabe im Hauptthread mit Lesesperre planen können. Das heißt, die Lesesperre wird nicht explizit ausgewählt, sondern ist ein spezieller Scheduler, der Ihren Code im Hauptthread mit der Lesesperre plant, sowie der Haupt-Thread mit Schreibsperre, Hintergrund-Thread - und jetzt werden die Methoden sehr einfach, um das Mischen zu starten. Gleichzeitig werden die Lebensdauern automatisch abgebrochen AsyncLocal, was den Code erheblich vereinfacht.



Mal sehen, wie async / await diese Probleme löst und welche Probleme sie verursachen.

In diesem Beispiel wird ein Teil des Codes synchron ausgeführt und wartet dann auf asynchronen Code. Erstens ist es gut, dass es viel weniger sich wiederholende Codeteile gibt ( Kesselplatte ). Zweitens ist es gut, dass asynchroner Code dem synchronen Code sehr ähnlich ist. Genau dafür ist async / await gedacht . Sie können asynchron genauso schreiben wie synchron, ohne Threads zu belegen.

Was wird der Compiler in diesem Fall bereitstellen? Der synchrone Code wird synchron ausgeführt. InnerAsyncDanach wird die Task synchron ausgeführt. Woher kommt das spezielle GetAwaiter-Objekt? In diesem Fall sind wir interessiert TaskAwaiter. Sie können Ihren Kellner für absolut jedes Objekt schreiben. Infolgedessen warten wir, bis die Aufgabe abgeschlossen ist, InnerAsyncund führen sie synchron aus continuationCode. Wenn die Aufgabe nicht abgeschlossen wurde, wird der ContinuationCode im Kontextplaner geplant . Es kann sein, dass, obwohl Sie warten geschrieben haben , absolut alles synchron aufgerufen wird.

async Task MyFuncAsync() { 
  synchronousCode();
   await InnerAsync();
   await Task.Yield(); //guaranteed !IsCompleted 
   continuationCode();
}

Es gibt einen Trick Task.Yield- dies ist eine spezielle Aufgabe, die sicherstellt, dass der Kellner nicht immer zu Ihnen zurückkehrt IsCompleted. Dementsprechend wird continuationes an dieser Stelle nicht synchron aufgerufen. Für einen UI-Thread kann dies wichtig sein, da Sie diesen Thread nicht lange verwenden.



Wie wähle ich einen Thread für die Fortsetzung aus? Die Philosophie von async / await lautet : Sie schreiben asynchronen Code genauso wie synchron. Wenn Sie einen Thread-Pool haben , spielt dies für Sie keine Rolle - ContinuationCode wird auf einem anderen Thread ausgeführt. Unabhängig davon, ob es InnerAsyncabgeschlossen wurde, als Sie " Warten" sagten oder nicht, benötigen Sie alles, um auf dem UI-Thread ausgeführt zu werden.

Der Mechanismus für das Warten auf Aufgaben ist wie folgt: Es wird genommen static, es wird aufgerufenSynchronizationContextund daraus entsteht TaskScheduler. SynchronizationContext ist eine Sache mit der Post-Methode, die der Methode sehr ähnlich ist Queue. In der Tat TaskScheduler, was früher war, nimmt es einfach SynchronizationContextund führt durch Post seine Aufgabe darauf aus.

async Task MyFuncAsync() { 
  synchronousCode();

    await InnerAsync().ConfigureAwait(false);
    continuationCode(); 
}

Es gibt eine Möglichkeit, dieses Verhalten mithilfe eines Parameters zu ändern ContinueOnCapturedContext. Die ekelhafteste API in .NET heißt ConfigureAwait. In diesem Fall erstellt die API einen speziellen Kellner, der sich von dem unterscheidet TaskAwaiter, der die Fortsetzung verschiebt. Er wird auf demselben Thread ausgeführt, in demselben Kontext, in dem die Methode beendet wurde InnerAsync und in dem die Aufgabe beendet wurde.

async Task MyFuncAsync() { 
  synchronousCode();

    await InnerAsync().ConfigureAwait(continueOnCapturedContext: false); 
    continuationCode(); //code must be absolutely context-agnostic
}

Im Internet gibt es eine wahnsinnige Menge an Ratschlägen: Wenn Sie einen Deadlock haben , verschmieren Sie bitte Ihren gesamten ConfigureAwait-Code, und alles wird gut. Das ist der falsche Weg. ConfigureAwaitkann in Fällen verwendet werden, in denen Sie die Leistung geringfügig verbessern möchten, oder am Ende der Methode in einigen Bibliotheksmethoden.

Deadlocks


async Task MyFuncAsync() { //UI thread 
  synchronousCode();

    await Task.Delay(10).ConfigureAwait(continueOnCapturedContext: false); 
    continuationCode();
}
myFuncAsync().Wait() //on UI thread

Dies ist ein klassischer Deadlock . Auf dem UI-Thread warteten sie zehn Sekunden und taten es Wait. Aufgrund dessen, was Sie getan haben Wait, wird es continuationCodeniemals gestartet, Waitdaher wird es niemals zurückkehren. All dies findet ganz am Anfang statt.

async Task OnBluttionClick() { //UI thread 
  int v = Button.Text.ParseInt();

    await Task.Delay(10).ConfigureAwait(continueOnCapturedContext: false); 
  Button.Text.Set((v+1).ToString());
}
myFuncAsync().Wait() //on UI thread

Stellen Sie sich vor, dies ist eine echte Aktivität. Wir haben auf die Schaltfläche geklickt, sie genommen Button.ParseInt, abgewartet und geschrieben. ConfigureAwaitWir sagen: "Bitte schließen Sie unseren UI-Stream nicht, führen Sie die Fortsetzung durch." Das Problem ist, dass der zweite Teil danach ConfigureAwaitauch auf dem UI-Thread ausgeführt werden soll, da dies die Philosophie des Wartens ist . Das heißt, Ihr asynchroner Code sieht genauso aus wie synchroner Code und wird im selben Kontext ausgeführt. In diesem Fall liegt natürlich ein Fehler vor. Und außerdem Button.Text.Setkann eine beliebige Anzahl von Methodenaufrufen, die auch ihren Kontext übernehmen. Was ist in dieser Situation zu tun? Du kannst das:

async Task MyFuncAsync() { //UI thread 
  synchronousCode();

    await Task.Delay(10).ConfigureAwait(continueOnCapturedContext: false); 
    continuationCode(); //The same UI context
}
PumpUntil(() => task.IsCompleted);
//VS synchronization contexts always pump on any Wait

Bei einem UI-Thread müssen Sie dies Waitfür Threads mit einer gemeinsamen Nachrichtenwarteschlange verbieten . Anstatt zu tun Waitoder zu schreiben ConfigureAwait, können Sie diese Nachrichtenwarteschlange pumpen, und gleichzeitig wird auch das Kontinuum gepumpt. Wenn Sie synchronen und asynchronen Code nicht mischen können, sollten Sie sie nicht mischen. Aber manchmal kann dies nicht vermieden werden.

Zum Beispiel haben Sie alten Code und müssen ihn mischen, dann pumpen Sie den UI-Stream. Visual Studio pumpt den UI-Thread auf Erwartungen, es hat sich sogar SynchronizationContextein wenig geändert. Wenn Sie bei einem beliebigen Vorgang in WaitHandle wechseln Wait, wird Ihr UI-Stream beim Aufhängen gepumpt. Daher wählen sie zwischen Deadlocks und Rennen zugunsten von Rennen.

Pump bis- Dies ist eine nicht ideale API. Wenn Sie also eine zufällige Kontinuität an einem beliebigen Ort durchführen, kann es zu Nuancen kommen. Es gibt leider keinen anderen Weg. Mischen Sie synchrone und asynchrone Codes. Wenn überhaupt, ist der ganze Fahrer an den alten Orten so angeordnet, dass es manchmal auch Nuancen gibt.

Kontext ändern


async Task MyFuncAsync() { 
  synchronousCode(); // on initial context

    await myTaskScheduler;
    continuationCode(); //on scheduler context 
}

Es gibt noch eine andere interessante Möglichkeit, async / await zu verwenden . Sie können schreiben , Awaiterauf schedulerüber Themen und springen. Ich habe Beiträge in Visual Studio gelesen, sie haben sehr lange geschrieben, dass es nicht gut ist, mitten in der Methode hin und her zu springen, aber jetzt machen sie es selbst. Visual Studio verfügt über eine API, die über Scheduler auf Threads springt. Für den normalen Gebrauch ist dies nicht gut.

Strukturierte Parallelität


async Task MyFuncAsync() { 
  synchronousCode(); // on initial context

    await Task.Factory.StartNew(() => {...}, myTaskScheduler);
    continuationCode(); //on initial context 
}

Um bequem in den neuen Kontext einzutauchen und zum alten zurückzukehren, sollte ein struktureller Wettbewerb oder eine strukturelle Parallelität aufgebaut werden. In den sechziger Jahren wurde der GoTo-Operator beispielsweise als schädlich angesehen, weil er die Strukturalität verletzte. So ist es hier. Das Springen auf Fäden verletzt die Struktur. Überraschenderweise scheint die Verwendung einer asynchronen Zustandsmaschine ein guter Ausweg zu sein. Das heißt, wenn Ihre übliche Struktur verletzt wird, Sie auf GoTo springen, können Sie die Thread-Struktur verletzen: Warten Sie , mischen Sie sie mit Tags. Dies ist eine äußerst seltsame und seltene Situation, wenn Sie dies tun müssen. Trotzdem ist es besser, wenn das Warten auf den gleichen Kontext zurückkehrt. Daher hat der Thread-Pool nicht denselben Thread, sondern denselben Kontext wie ursprünglich.

Sequentielles Verhalten


Warum ist Warten nicht dasselbe wie parallele Ausführung? Warten auf Ausführung ist sequentielle Ausführung. In diesem Fall starten wir die erste Aufgabe, warten darauf, starten die zweite Aufgabe - wir warten. Wir haben keine Parallelität. Für die meisten Anwendungen ist keine Parallelität erforderlich. Parallelität selbst ist komplexer als Sequenz. Seriencode ist einfacher als parallel, es ist ein Axiom. Aber manchmal müssen Sie etwas in parallelem Code ausführen, und Sie tun es so:

async Task MyAsync() {

  var task1 = StartTask1Async();
  await task1;

  var task2 = StartTask2Async();
  await task2; 
}

Gleichzeitiges Verhalten


async Task MyAsync() {
  var task1 = StartTask1Async();
  var task2 = StartTask2Async();

  await task1;
  await task2; 
}

Hier beginnen die Aufgaben parallel. Es ist klar, dass Methoden die Aufgabe sofort in einem laufenden Zustand zurückgeben können, dann gibt es keine Parallelität. Nehmen wir an, beide Aufgaben werfen eine Hinrichtung. Und Sie haben auf die erste Aufgabe gewartet und sind dann beim ersten Warten losgefahren. Das heißt, sobald Sie geschrieben haben await task1, sind Sie gestartet und haben nicht verarbeitet exception task2. Interessanterweise ist dies ein absolut gültiger Code. Und dieser Code hat .NET dazu geführt, dass sich in Version 4.5 das Verhalten beim Arbeiten mit Ausführungen geändert hat.

Ausnahmebehandlung


async Task MyAsync() {
  var task1 = StartTask1Async();
  var task2 = StartTask2Async(); 

  await task1;
  await task2;

  // if task1 throws exception and task2 throws exception we only throw and
  // handle task1’s exception

  //4.0 -> 4.5 framework: unhandled exceptions now don’t crush process
  //still visible in UnobservedExceptionHandler
}

Bisher haben nicht behandelte Ausführungen den Prozess einfach ausgelöst. Wenn Sie keine Ausführung abgefangen haben UnobservedExceptionHandler(dies ist auch eine static, die Sie an Scheduler anhängen können), wurde dieser Prozess nicht ausgeführt. Dies ist ein absolut gültiger Code. Obwohl .NET sein Verhalten geändert hat, wurde die Einstellung beibehalten, um das Verhalten in die entgegengesetzte Richtung zurückzugeben.

async  Task  MyAsync(CancellationToken cancellationToken)  {  

  await  SomeTask1  Async(cancellationToken); 
 
  await  Some Task2Async( cancellation  Token); 
  //you should always pass use async API with cancelationToken  if possible 
} 
  
try { 
    await  MyAsync( cancellation  Token); 
} catch (OperationException e) { // do nothing: OCE happened
} catch (Exception e) { 
    log.Error(e);
}

Sehen Sie, wie die Verarbeitung der Ausführung verläuft. CancellationToken-s muss übertragen werden, es ist notwendig, CancellationToken-s den gesamten Code zu "verschmieren". Das normale Verhalten von Async ist, dass Sie nirgendwo nachsehen, sondern Task.Status ancellationTokenmit asynchronem Code genauso arbeiten wie mit synchronem. Das heißt, im Falle einer Stornierung erhalten Sie eine Ausführung, und in diesem Fall tun Sie nichts, wenn Sie sie erhalten OperationCanceledException.

Der Unterschied zwischen dem Status "Abgebrochen" und "Fehlerhaft" besteht darin, dass Sie nicht OperationCanceledExceptiondie übliche Ausführung erhalten haben. Und in diesem Fall können wir es versprechen, Sie müssen nur eine Ausführung erhalten und daraus Schlussfolgerungen ziehen. Wenn Sie die Aufgabe explizit über Aufgabe gestartet hätten, wären Sie geflogen AggregateException. Und im asynchronen Fall AggregateExceptionwerfen sie immer die allererste Ausführung, die darin enthalten war (in diesem Fall - OperationCanceled).

In der Praxis


Synchrone Methode


DataTable<File, ProcessedFile> sharedMemory;

// in any thread
void SynchronousWorker(...) {
  File f = blockingQueue.Dequeue(); 
  ProcessedFile p = ProcessInParallel(f);

  lock (_lock) { 
    sharedMemory.add(f, p);
  } 
}

Zum Beispiel arbeitet ein Dämon in ReSharper - einem Editor, der die Datei für Sie tönt. Wenn die Datei im Editor geöffnet wird, gibt es eine Aktivität, die sie in eine Blockierungswarteschlange stellt. Unser Prozess workerliest von dort aus, führt dann eine Reihe verschiedener Aufgaben mit dieser Datei aus, färbt sie, analysiert, erstellt und fügt diese Dateien hinzu sharedMemory. Mit einem sharedMemorySchloss arbeiten bereits andere Mechanismen damit.

Asynchrone Methode


Wenn Code auf asynchrone Umschreiben, werden wir zunächst einmal ersetzen Sie es voidmit async Task. Schreiben Sie am Ende unbedingt das Wort „Async“. Alle asynchronen Methoden müssen mit Async enden - dies ist eine Konvention.

DataTable<File, ProcessedFile> sharedMemory;
// in any thread
async Task WorkerAsync(...) {

  File f = blockingQueue.Dequeue(); 

  ProcessedFile p = ProcessInParallel(f);

  lock (_lock) { 
    sharedMemory.add(f, p);
  } 
}

Danach müssen Sie etwas mit unserem tun blockingQueue. Wenn es ein synchrones Grundelement gibt, muss es natürlich ein asynchrones Grundelement geben.



Dieses Grundelement heißt Kanal: die Kanäle, die im Paket leben System.Threading.Channels. Sie können begrenzte und unbegrenzte Kanäle und Warteschlangen erstellen, auf die Sie asynchron warten können. Darüber hinaus können Sie einen Kanal mit dem Wert "Null" erstellen, dh er hat überhaupt keinen Puffer. Solche Kanäle werden als Rendezvous-Kanäle bezeichnet und in Go und Kotlin aktiv beworben. Und im Prinzip ist dies ein sehr gutes Muster, wenn es möglich ist, Kanäle in asynchronem Code zu verwenden. Das heißt, wir ändern die Warteschlange in den Kanal, in dem es Methoden ReadAsyncund gibt WriteAsync.

ProcessInParallel ist eine Reihe von parallelem Code, der die Verarbeitung einer Datei übernimmt und in eine solche umwandeltProcessedFile. Kann uns Async helfen, nicht asynchronen, sondern parallelen Code kompakter zu schreiben?

Vereinfachen Sie den parallelen Code


Der Code kann folgendermaßen umgeschrieben werden:

DataTable<File, ProcessedFile> sharedMemory;

// in any thread
async Task WorkerAsync(...) {

  File f = await channel.ReadAsync();

  ProcessedFile p = await ProcessInParallelAsync(f);

  lock (_lock) { 
    sharedMemory.add(f, p);
  } 
}



Wie sehen sie aus ProcessInParallel? Zum Beispiel haben wir eine Datei. Zuerst teilen wir es in Lexeme auf, und wir können zwei Aufgaben parallel ausführen: Erstellen von Suchcaches und Erstellen eines Syntaxbaums. Danach folgt die Aufgabe, nach semantischen Fehlern zu suchen. Hierbei ist es wichtig, dass alle diese Aufgaben einen gerichteten azyklischen Graphen bilden. Das heißt, Sie können einige Teile in parallelen Threads ausführen, andere nicht, und es gibt offensichtlich Abhängigkeiten, welche Aufgabe auf andere Aufgaben warten soll. Sie erhalten ein Diagramm solcher Aufgaben, Sie möchten sie irgendwie entlang der Threads verteilen. Ist es möglich, es schön und fehlerfrei zu schreiben? In unserem Code wurde dieses Problem mehrmals auf unterschiedliche Weise gelöst. Es kommt selten vor, dass dieser Code fehlerfrei geschrieben wird.



Wir definieren dieses Aufgabendiagramm wie folgt: Nehmen wir an, dass jede Aufgabe andere Aufgaben hat, von denen sie abhängt, und schreiben dann mithilfe des ExecuteBefore-Wörterbuchs das Grundgerüst unserer Methode.

Skelettlösungen


Dictionary<Action<ProcessedFile>, Action<ProcessedFile>[]> ExecuteBefore; async Task<ProcessedFile> ProcessInParallelAsync() {
  var res = new ProcessedFile();


  // lots of work with toposort, locks, etc.

  return res; 
}

Wenn Sie dieses Problem direkt lösen, müssen Sie eine topologische Sortierung dieses Diagramms durchführen. Nehmen Sie dann eine Aufgabe, die keine abhängigen Aufgaben hat, führen Sie sie aus, analysieren Sie die Struktur unter einem Schloss und sehen Sie, welche Aufgaben keine abhängigen Aufgaben haben. Lauf, zerstreue sie irgendwie durch Task Runner. Wir schreiben es etwas kompakter: topologische Sortierung des Graphen + Ausführung solcher Aufgaben auf verschiedenen Threads.

Async faul


Dictionary<Action<ProcessedFile>, Action<ProcessedFile>[]> ExecuteBefore;
async Task<ProcessedFile> ProcessInParallelAsync() {
  var res = new ProcessedFile();
  var lazy = new Dictionary<Action<ProcessedFile>, Lazy<Task>>(); 
  foreach ((action, beforeList) in ExecuteBefore)
    lazy[action] = new Lazy<Task>(async () => 
    {
      await Task.WhenAll(beforeList.Select(b => lazy[b].Value)) 
      await Task.Yield();
      action(res);
}
  await Task.WhenAll(lazy.Values.Select(l => l.Value)) 
  return res;
}

Es gibt ein Muster namens Async Lazy. Wir erstellen unsere, ProcessedFileauf denen verschiedene Aktionen ausgeführt werden sollen. Erstellen wir ein Wörterbuch: Wir formatieren jede unserer Phasen (Action ProcessedFile) in eine Aufgabe oder besser gesagt in Lazy from Task und führen sie entlang des Originaldiagramms aus. Die Variable actionhat die Aktion selbst und in beforeList - die Aktionen, die vor unserer ausgeführt werden müssen. Dann erstellen Lazyaus action. Wir schreiben in Aufgabe await. Wir warten also auf alle Aufgaben, die vorher erledigt werden müssen. Wählen Sie in beforeList diejenige aus Lazy, die sich in diesem Wörterbuch befindet.

Bitte beachten Sie, dass hier nichts synchron ausgeführt wird, damit dieser Code nicht auffällt ItemNotFoundException in Dictionary. Wir führen alle Aufgaben aus, die vor uns lagen, und führen eine Suche nach Maßnahmen durchLazy Task. Dann führen wir unsere Aktion aus. Am Ende müssen Sie nur jede Aufgabe zum Starten auffordern, sonst wissen Sie nie, ob etwas nicht gestartet wurde. In diesem Fall hat nichts begonnen. Das ist die Lösung. Diese Methode ist in 10 Minuten geschrieben, es ist absolut offensichtlich.

So traf asynchroner Code unsere Entscheidung, zunächst belegte er einige Bildschirme mit komplexem Konkurrenzcode. Hier ist er absolut konsequent. Ich benutze es nicht einmal ConcurrentDictionary, ich benutze das übliche Dictionary, weil wir nichts wettbewerbsfähig darauf schreiben. Es gibt einen konsistenten, konsistenten Code. Wir lösen das Problem, parallelen Code mit Async-s zu schreiben, wunderbar, das heißt - ohne Fehler.

Sperren loswerden


DataTable<File, ProcessedFile> sharedMemory;

// in any thread
async Task WorkerAsync(...) {

  File f = await channel.ReadAsync();

  ProcessedFile p = await ProcessInParallelAsync(f);

    lock (_lock) {
      sharedMemory.add(f, p);
   }
 }

Lohnt es sich, Async und diese Sperren einzuschalten? Jetzt gibt es alle Arten von asynchronen Sperren, asynchrone Semaphoren, dh einen Versuch, die Grundelemente zu verwenden, die sich in synchronem und asynchronem Code befinden. Dieses Konzept scheint falsch zu sein, da Sie mit der Sperre etwas vor paralleler Ausführung schützen. Unsere Aufgabe ist es, die parallele Ausführung in eine sequentielle zu übersetzen, da dies einfacher ist. Und wenn es einfacher ist, gibt es weniger Fehler.

Channel<Pair<File, ProcessedFile>> output;
// in any thread
async Task WorkerAsync(...) {

  File f = await channel.ReadAsync();

  ProcessedFile p = await ProcessInParallelAsync(f);
  
  await output.WriteAsync(); 
}

Wir können einen Kanal erstellen und dort ein paar Datei- und Verarbeitungsdateien ablegen, und ReadAsynceine andere Prozedur wird diesen Kanal verarbeiten und dies nacheinander tun. Lock selbst schützt nicht nur die Struktur, sondern linearisiert im Wesentlichen den Zugriff, ein Ort, an dem alle Threads von aufeinanderfolgenden Threads parallel werden. Und wir ersetzen dies explizit durch den Kanal.



Die Architektur ist wie folgt: Arbeiter empfangen Dateien von inputund senden sie irgendwo an den Prozessor, der auch alles nacheinander verarbeitet, es gibt keine Parallelität. Der Code sieht viel einfacher aus. Ich verstehe, dass nicht alles auf diese Weise getan werden kann. Eine solche Architektur funktioniert nicht immer, wenn Sie Datenpipes erstellen können.



Es kann sein, dass Sie einen zweiten Kanal haben, der in Ihren Prozessor gelangt, und dass aus den Kanälen kein azyklisch gerichteter Graph gebildet wird, sondern ein Graph mit Zyklen. Dies ist ein Beispiel, das Roman Elizarov 2018 KotlinConf sagte. Er schrieb ein Beispiel über Kotlin mit diesen Kanälen, und dort gab es Zyklen, und dieses Beispiel wurde heruntergefahren. Das Problem war, dass in der asynchronen Welt alles komplizierter wird, wenn Sie solche Zyklen in einem Diagramm haben. Asynchrone Deadlocks sind insofern schlecht, als sie viel schwieriger zu lösen sind als synchron, wenn Sie einen Stapel von Threads haben, und es ist klar, woran sie hängen. Daher ist es ein Werkzeug, das korrekt verwendet werden muss.

Zusammenfassung


  • Vermeiden Sie die Synchronisation in asynchronem Code.
  • Seriencode ist einfacher als parallel.
  • Asynchroner Code kann einfach sein und ein Minimum an Parametern und einen impliziten Kontext verwenden, die sein Verhalten ändern.

Wenn Sie die Gewohnheit entwickelt haben, synchronen Code zu schreiben, und selbst wenn der asynchrone Code dem synchronen Code sehr ähnlich ist, müssen Sie keine Grundelemente dorthin ziehen, wie Sie es bei synchronem Code gewohnt sind async mutex. Verwenden Sie nach Möglichkeit Feeds und andere Primitive für die Nachrichtenübermittlung .

Seriencode ist einfacher als parallel. Wenn Sie Ihre Architektur so schreiben können, dass sie sequentiell aussieht, ohne parallelen Code auszuführen und zu sperren, schreiben Sie die Architektur sequentiell.

Und das Letzte, was wir aus einer Vielzahl von Beispielen mit Aufgaben gesehen haben. Versuchen Sie beim Entwerfen Ihres Systems, sich weniger auf den impliziten Kontext zu verlassen. Der implizite Kontext führt zu einem Missverständnis dessen, was im Code geschieht, und Sie können implizite Probleme in einem Jahr vergessen. Und wenn eine andere Person an diesem Code arbeitet und etwas darin wiederholt, kann dies zu Schwierigkeiten führen, von denen Sie einmal wussten, und der neue Programmierer weiß es aufgrund des impliziten Kontexts nicht. Infolgedessen ist ein schlechtes Design durch eine große Anzahl von Parametern, deren Kombination und impliziten Kontext gekennzeichnet.

Was zu lesen



-10 . DotNext .

All Articles