Bereits im Februar nahm ich am jährlichen MVP-Gipfel teil, einer Veranstaltung von Microsoft für MVPs. Ich nutzte diese Gelegenheit, um auch Boston und New York zu besuchen, zwei Vorträge über F # zu halten und den Vortrag von Channel 9 über Typanbieter aufzuzeichnen . Trotz anderer Aktivitäten (wie dem Besuch von Pubs, dem Chatten mit anderen Leuten über F # und dem langen Nickerchen am Morgen) konnte ich auch einige Diskussionen führen.
Eine Diskussion (nicht unter der NDA) war das Gespräch der Async Clinic über die neuen Schlüsselwörter in C # 5.0 - async und warten. Lucian und Stephen sprachen über häufige Probleme, mit denen C # -Entwickler beim Schreiben asynchroner Programme konfrontiert sind. In diesem Beitrag werde ich einige der Probleme aus einer F # -Perspektive betrachten. Das Gespräch war ziemlich lebhaft und jemand beschrieb die Reaktion des F # -Publikums wie folgt:
(Wenn MVPs in F # schreiben, siehe C # -Codebeispiele, kichern sie wie Mädchen)
Warum passiert das? Es stellt sich heraus, dass viele der häufigsten Fehler bei Verwendung des asynchronen F # -Modells (das in F # 1.9.2.7 erschien, 2007 veröffentlicht und mit Visual Studio 2008 ausgeliefert wurde) nicht möglich (oder viel weniger wahrscheinlich ) sind.
Fallstrick Nr. 1: Async funktioniert nicht asynchron
Kommen wir direkt zum ersten kniffligen Aspekt des asynchronen C # -Programmiermodells. Schauen Sie sich das folgende Beispiel an und versuchen Sie sich vorzustellen, in welcher Reihenfolge die Zeilen gedruckt werden (ich konnte den genauen Code im Vortrag nicht finden, aber ich erinnere mich, dass Lucian etwas Ähnliches demonstrierte):
async Task WorkThenWait()
{
Thread.Sleep(1000);
Console.WriteLine("work");
await Task.Delay(1000);
}
void Demo()
{
var child = WorkThenWait();
Console.WriteLine("started");
child.Wait();
Console.WriteLine("completed");
}
Wenn Sie glauben, dass "gestartet", "Arbeit" und "abgeschlossen" gedruckt werden, liegen Sie falsch. Der Code druckt "Arbeit", "gestartet" und "abgeschlossen", probieren Sie es selbst! Der Autor wollte mit der Arbeit beginnen (indem er WorkThenWait aufruft) und dann warten, bis die Aufgabe abgeschlossen ist. Das Problem ist, dass WorkThenWait zunächst einige umfangreiche Berechnungen durchführt (hier Thread.Sleep) und erst danach warte.
In C # wird der erste Code in einer asynchronen Methode synchron ausgeführt (im Thread des Aufrufers). Sie können dies beispielsweise beheben, indem Sie am Anfang warte Task.Yield () hinzufügen.
Entsprechender F # -Code
In F # ist dies kein Problem. Wenn Sie asynchronen Code in F # schreiben, wird der gesamte Code im asynchronen {…} Block zurückgestellt und später ausgeführt (wenn Sie ihn explizit ausführen). Der obige C # -Code entspricht dem folgenden in F #:
let workThenWait() =
Thread.Sleep(1000)
printfn "work done"
async { do! Async.Sleep(1000) }
let demo() =
let work = workThenWait() |> Async.StartAsTask
printfn "started"
work.Wait()
printfn "completed"
Offensichtlich führt die workThenWait-Funktion die Arbeit (Thread.Sleep) nicht als Teil der asynchronen Berechnung aus und sie wird ausgeführt, wenn die Funktion aufgerufen wird (und nicht, wenn der asynchrone Workflow startet). Ein übliches Muster in F # besteht darin, den gesamten Körper einer Funktion asynchron zu verpacken. In F # würden Sie Folgendes schreiben, was wie erwartet funktioniert:
let workThenWait() = async
{
Thread.Sleep(1000)
printfn "work done"
do! Async.Sleep(1000)
}
Fallstrick Nr. 2: Ergebnisse ignorieren
Hier ist ein weiteres Problem mit dem asynchronen C # -Programmiermodell (dieser Artikel stammt direkt aus Lucians Folien). Ratet mal, was passiert, wenn Sie die folgende asynchrone Methode ausführen:
async Task Handler()
{
Console.WriteLine("Before");
Task.Delay(1000);
Console.WriteLine("After");
}
Erwarten Sie, dass "Vorher" gedruckt wird, 1 Sekunde gewartet wird und dann "Nachher" gedruckt wird? Falsch! Beide Nachrichten werden ohne Verzögerung sofort gedruckt. Das Problem ist, dass Task.Delay eine Task zurückgibt und wir vergessen haben zu warten, bis sie abgeschlossen ist (mit await).
Entsprechender F # -Code
Auch dies wäre Ihnen in F # wahrscheinlich nicht begegnet. Möglicherweise schreiben Sie Code, der Async.Sleep aufruft und den zurückgegebenen Async ignoriert:
let handler() = async
{
printfn "Before"
Async.Sleep(1000)
printfn "After"
}
Wenn Sie diesen Code in Visual Studio, MonoDevelop oder Try F # einfügen, erhalten Sie sofort eine Warnung:
Warnung FS0020: Dieser Ausdruck sollte vom Typ unit sein, hat jedoch den Typ Async ‹un›. Verwenden Sie Ignorieren, um das Ergebnis des Ausdrucks zu verwerfen, oder lassen Sie das Ergebnis an einen Namen binden.
Warnung FS0020: Dieser Ausdruck muss vom Typ unit sein, ist jedoch vom Typ Async ‹un›. Verwenden Sie Ignorieren, um das Ergebnis eines Ausdrucks zu verwerfen, oder lassen Sie das Ergebnis einem Namen zuordnen.
Sie können den Code weiterhin kompilieren und ausführen. Wenn Sie jedoch die Warnung lesen, werden Sie feststellen, dass der Ausdruck Async zurückgibt, und Sie müssen mit do! Auf das Ergebnis warten.
let handler() = async
{
printfn "Before"
do! Async.Sleep(1000)
printfn "After"
}
Fallstrick Nr. 3: Asynchrone Methoden, die die Leere zurückgeben
Ein Großteil der Konversation war asynchronen Void-Methoden gewidmet. Wenn Sie async void Foo () {…} schreiben, generiert der C # -Compiler eine Methode, die void zurückgibt. Aber unter der Haube schafft und führt es eine Aufgabe aus. Dies bedeutet, dass Sie nicht vorhersagen können, wann die Arbeit tatsächlich erledigt wird.
In der Rede wurde die folgende Empfehlung zur Verwendung des asynchronen Leerenmusters ausgesprochen:
(Um Himmels willen
, hören Sie auf, asynchrone Leere zu verwenden !) Fairerweise sollte beachtet werden, dass asynchrone Leerenmethoden dies könnenSeien Sie nützlich, wenn Sie Ereignishandler schreiben. Event-Handler müssen void zurückgeben und beginnen häufig mit Arbeiten, die im Hintergrund fortgesetzt werden. Aber ich denke nicht, dass es in der MVVM-Welt wirklich nützlich ist (obwohl es auf Konferenzen sicherlich gute Demos macht).
Lassen Sie mich das Problem anhand eines Ausschnitts aus dem Artikel des MSDN-Magazins zur asynchronen Programmierung in C # demonstrieren :
async void ThrowExceptionAsync()
{
throw new InvalidOperationException();
}
public void CallThrowExceptionAsync()
{
try
{
ThrowExceptionAsync();
}
catch (Exception)
{
Console.WriteLine("Failed");
}
}
Denken Sie, dass dieser Code "Fehlgeschlagen" druckt? Ich hoffe, Sie verstehen den Stil dieses Artikels bereits ...
In der Tat wird die Ausnahme nicht behandelt, da ThrowExceptionAsync nach dem Starten des Jobs sofort beendet wird und die Ausnahme irgendwo in einem Hintergrundthread ausgelöst wird.
Entsprechender F # -Code
Wenn Sie also die Funktionen einer Programmiersprache nicht verwenden müssen, ist es wahrscheinlich am besten, diese Funktion überhaupt nicht einzuschließen. Mit F # können Sie keine asynchronen ungültigen Funktionen schreiben. Wenn Sie den Hauptteil einer Funktion in einen asynchronen {…} Block einschließen, lautet der Rückgabetyp Async. Wenn Sie Typanmerkungen verwenden und eine Einheit benötigen, erhalten Sie eine Typinkongruenz.
Mit Async.Start können Sie Code schreiben, der dem obigen C # -Code entspricht:
let throwExceptionAsync() = async {
raise <| new InvalidOperationException() }
let callThrowExceptionAsync() =
try
throwExceptionAsync()
|> Async.Start
with e ->
printfn "Failed"
Die Ausnahme wird auch hier nicht behandelt. Aber was los ist, ist offensichtlicher, weil wir Async.Start explizit schreiben müssen. Wenn wir dies nicht tun, erhalten wir eine Warnung, dass die Funktion Async zurückgibt und wir das Ergebnis ignorieren (genau wie im vorherigen Abschnitt "Ergebnisse ignorieren").
Fallstrick Nr. 4: Asynchrone Lambda-Funktionen, die void zurückgeben
Die Situation wird noch komplizierter, wenn Sie eine asynchrone Lambda-Funktion als Delegat an eine Methode übergeben. In diesem Fall leitet der C # -Compiler den Typ der Methode vom Typ des Delegaten ab. Wenn Sie einen Aktionsdelegaten (oder einen ähnlichen) verwenden, erstellt der Compiler eine asynchrone Void-Funktion, die den Job startet und Void zurückgibt. Wenn Sie den Func-Delegaten verwenden, generiert der Compiler eine Funktion, die Task zurückgibt.
Hier ist ein Beispiel von Lucians Folien. Wann endet der nächste (vollkommen korrekte) Code - eine Sekunde (nachdem alle Aufgaben gewartet haben) oder sofort?
Parallel.For(0, 10, async i =>
{
await Task.Delay(1000);
});
Sie können diese Frage nur beantworten, wenn Sie wissen, dass es nur Überladungen für For that accept Action-Delegaten gibt. Daher wird die Lambda-Funktion immer als asynchrone Leere kompiliert. Dies bedeutet auch, dass das Hinzufügen einer (möglicherweise Nutzlast-) Last eine bahnbrechende Änderung darstellt.
Entsprechender F # -Code
F # hat keine speziellen "asynchronen Lambda-Funktionen", aber Sie können eine Lambda-Funktion schreiben, die asynchrone Berechnungen zurückgibt. Eine solche Funktion gibt Async zurück, sodass sie nicht als Argument an Methoden übergeben werden kann, die einen nichtig zurückgegebenen Delegaten erwarten. Der folgende Code wird nicht kompiliert:
Parallel.For(0, 10, fun i -> async {
do! Async.Sleep(1000)
})
Die Fehlermeldung besagt lediglich, dass der Funktionstyp int -> Async nicht mit dem Aktionsdelegierten kompatibel ist (in F # sollte es int -> unit sein):
Fehler FS0041: Für Methode For stimmen keine Überladungen überein. Die verfügbaren Überlastungen werden unten (oder im Fenster Fehlerliste) angezeigt.
Fehler FS0041: Für die For-Methode wurden keine Überladungen gefunden. Die verfügbaren Überladungen werden unten (oder im Fehlerlistenfeld) angezeigt.
Um das gleiche Verhalten wie im obigen C # -Code zu erhalten, müssen wir explizit beginnen. Wenn Sie eine asynchrone Sequenz im Hintergrund ausführen möchten, können Sie dies problemlos mit Async.Start tun (das eine asynchrone Berechnung ausführt, die eine Einheit zurückgibt, sie plant und eine Einheit zurückgibt):
Parallel.For(0, 10, fun i -> Async.Start(async {
do! Async.Sleep(1000)
}))
Sie können dies natürlich schreiben, aber es ist ziemlich einfach zu sehen, was los ist. Es ist auch leicht zu erkennen, dass wir Ressourcen verschwenden, wie es die Besonderheit von Parallel ist. Zum Beispiel führt es CPU-intensive Berechnungen (die normalerweise synchrone Funktionen sind) parallel durch.
Fallstrick Nr. 5: Verschachtelungsaufgaben
Ich denke, Lucian hat diesen Stein nur aufgenommen, um die Intelligenz der Leute im Publikum zu testen, aber hier ist er. Die Frage ist, wird der folgende Code 1 Sekunde zwischen den beiden Pins zur Konsole warten?
Console.WriteLine("Before");
await Task.Factory.StartNew(
async () => { await Task.Delay(1000); });
Console.WriteLine("After");
Ganz unerwartet gibt es keine Verzögerung zwischen diesen Schlussfolgerungen. Wie ist das möglich? Die StartNew-Methode nimmt einen Delegaten und gibt Task zurück, wobei T der vom Delegaten zurückgegebene Typ ist. In unserem Fall gibt der Delegat Task zurück, sodass wir als Ergebnis Task erhalten. Warten Sie nur, bis die äußere Aufgabe abgeschlossen ist (wodurch die innere Aufgabe sofort zurückgegeben wird), während die innere Aufgabe ignoriert wird.
In C # kann dies mithilfe von Task.Run anstelle von StartNew (oder durch Entfernen von async / await in der Lambda-Funktion) behoben werden.
Können Sie so etwas in F # schreiben? Wir können eine Task erstellen, die Async mit der Funktion Task.Factory.StartNew und einer Lambda-Funktion zurückgibt, die einen asynchronen Block zurückgibt. Um auf den Abschluss der Aufgabe zu warten, müssen wir sie mit Async.AwaitTask in eine asynchrone Ausführung konvertieren. Dies bedeutet, dass wir Async <Async> erhalten:
async {
do! Task.Factory.StartNew(fun () -> async {
do! Async.Sleep(1000) }) |> Async.AwaitTask }
Auch dieser Code wird nicht kompiliert. Das Problem ist, dass das tun! erfordert Async auf der rechten Seite, empfängt jedoch tatsächlich Async <Async>. Mit anderen Worten, wir können das Ergebnis nicht einfach ignorieren. Wir müssen explizit etwas dagegen unternehmen
(Sie können Async.Ignore verwenden, um das C # -Verhalten zu reproduzieren). Die Fehlermeldung ist möglicherweise nicht so klar wie die vorherigen, gibt jedoch eine allgemeine Vorstellung:
Fehler FS0001: Es wurde erwartet, dass dieser Ausdruck den Typ Async ‹unit› hat, hier jedoch den Typ unit
Fehler FS0001: Asynchroner Ausdruck 'Einheit' erwartet, Einheitentyp vorhanden
Fallstrick Nr. 6: Async funktioniert nicht
Hier ist ein weiterer problematischer Code von Lucians Folie. Dieses Mal ist das Problem ziemlich einfach. Das folgende Snippet definiert eine asynchrone FooAsync-Methode und ruft sie vom Handler auf, der Code wird jedoch nicht asynchron ausgeführt:
async Task FooAsync()
{
await Task.Delay(1000);
}
void Handler()
{
FooAsync().Wait();
}
Das Problem ist leicht zu erkennen - wir rufen FooAsync () auf. Wait (). Dies bedeutet, dass wir eine Aufgabe erstellen und dann mit Wait das Programm blockieren, bis es abgeschlossen ist. Ein einfaches Entfernen von Wait löst das Problem, da wir nur die Aufgabe starten möchten.
Sie können denselben Code in F # schreiben, aber asynchrone Workflows verwenden keine .NET-Tasks (ursprünglich für CPU-gebundene Berechnungen konzipiert), sondern den asynchronen Typ F #, der nicht mit Wait gebündelt ist. Dies bedeutet, dass Sie schreiben müssen:
let fooAsync() = async {
do! Async.Sleep(1000) }
let handler() =
fooAsync() |> Async.RunSynchronously
Natürlich kann ein solcher Code versehentlich geschrieben werden, aber wenn Sie mit einem Problem der unterbrochenen Asynchronität konfrontiert sind , werden Sie leicht feststellen, dass der Code RunSynchronously aufruft, sodass die Arbeit - wie der Name schon sagt - synchron ausgeführt wird .
Zusammenfassung
In diesem Artikel habe ich sechs Fälle betrachtet, in denen sich das asynchrone Programmiermodell in C # auf unerwartete Weise verhält. Die meisten von ihnen basieren auf dem Gespräch von Lucian und Stephen auf dem MVP-Gipfel. Vielen Dank an beide für eine interessante Liste häufiger Fallstricke!
Für F # habe ich versucht, mithilfe asynchroner Workflows die nächstgelegenen relevanten Codefragmente zu finden. In den meisten Fällen gibt der F # -Compiler eine Warnung oder einen Fehler aus - oder das Programmiermodell hat keine (direkte) Möglichkeit, denselben Code auszudrücken. Ich denke, dies bestätigt eine Aussage, die ich in einem früheren Blog-Beitrag gemacht habe : „Das F # -Programmiermodell scheint definitiv besser für funktionale (deklarative) Programmiersprachen geeignet zu sein. Ich denke auch, dass es einfacher ist, darüber nachzudenken, was los ist. "
Schließlich sollte dieser Artikel nicht als destruktive Kritik an der Asynchronität in C # verstanden werden :-). Ich verstehe voll und ganz, warum das Design von C # denselben Prinzipien folgt wie es folgt - für C # ist es sinnvoll, Task (anstelle von separatem Async) zu verwenden, was eine Reihe von Konsequenzen hat. Und ich kann die Gründe für die anderen Lösungen verstehen - dies ist wahrscheinlich der beste Weg, um asynchrone Programmierung in C # zu integrieren. Gleichzeitig denke ich, dass F # einen besseren Job macht - teilweise wegen seiner Compositing-Fähigkeit, aber was noch wichtiger ist, wegen cooler Add-Ons wie F # -Agenten . Auch die Asynchronität in F # hat ihre Probleme (der häufigste Fehler ist, dass rekursive Schwanzfunktionen verwendet werden sollten, return! Statt do!, Um Lecks zu vermeiden), aber das ist ein Thema für einen separaten Blog-Beitrag.
PS Vom Übersetzer. Der Artikel wurde 2013 geschrieben, schien mir aber interessant und relevant genug, um ins Russische übersetzt zu werden. Dies ist mein erster Beitrag auf Habré, also tritt nicht hart.