Aufgabenausdrücke

In diesem Artikel wird die Unterstützung in F# für Aufgabenausdrücke beschrieben, die mit asynchronen Ausdrücken vergleichbar sind, aber ihnen ermöglichen, .NET-Aufgaben direkt zu erstellen. Wie asynchrone Ausdrücke führen Aufgabenausdrücke Code asynchron aus, d. h. ohne die Ausführung anderer Arbeiten zu blockieren.

Asynchroner Code wird normalerweise mit asynchronen Ausdrücken erstellt. Die Verwendung von Aufgabenausdrücken wird bevorzugt, wenn Sie umfassend mit .NET-Bibliotheken zusammenarbeiten, die .NET-Aufgaben erstellen oder nutzen. Aufgabenausdrücke können auch die Leistung und die Debugerfahrung verbessern. Aufgabenausdrücke enthalten jedoch einige Einschränkungen, die weiter unten im Artikel beschrieben werden.

Syntax

task { expression }

In der vorherigen Syntax wird die durch die dargestellte expression Berechnung eingerichtet, um als .NET-Task ausgeführt zu werden. Die Aufgabe wird sofort gestartet, nachdem dieser Code ausgeführt wurde und im aktuellen Thread ausgeführt wird, bis der erste asynchrone Vorgang ausgeführt wird (z. B. asynchroner Standbymodus, asynchroner E/A- oder anderer primitiver asynchroner Vorgang). Der Typ des Ausdrucks ist Task<'T>, wobei 'T der Typ, der vom Ausdruck zurückgegeben wird, wenn das return Schlüsselwort verwendet wird.

Binden mithilfe von Let!

In einem Aufgabenausdruck sind einige Ausdrücke und Vorgänge synchron, und einige sind asynchron. Wenn Sie auf das Ergebnis eines asynchronen Vorgangs warten, verwenden Sie letanstelle einer normalen let! Bindung . Der Effekt let! besteht darin, die Ausführung für andere Berechnungen oder Threads zu ermöglichen, während die Berechnung ausgeführt wird. Nach der Rückkehr der rechten Seite der let! Bindung wird die Ausführung der übrigen Aufgabe fortgesetzt.

Der folgende Code zeigt den Unterschied zwischen let und let!. Die Codezeile, die let verwendet, erstellt einfach eine Aufgabe als Objekt, auf das Sie später warten können, indem Sie z. B. task.Wait() oder task.Result verwenden. Die Codezeile, die let! verwendet, um die Aufgabe zu starten und auf das Ergebnis zu warten.

// let just stores the result as a task.
let (result1 : Task<int>) = stream.ReadAsync(buffer, offset, count, cancellationToken)
// let! completes the asynchronous operation and returns the data.
let! (result2 : int)  = stream.ReadAsync(buffer, offset, count, cancellationToken)

F# task { } -Ausdrücke können auf die folgenden Arten asynchroner Vorgänge warten:

return-Ausdrücke

Innerhalb von Aufgabenausdrücken wird return expr verwendet, um das Ergebnis einer Aufgabe zurückzugeben.

return!-Ausdrücke

Innerhalb von Aufgabenausdrücken wird return! expr verwendet, um das Ergebnis einer anderen Aufgabe zurückzugeben. Sie entspricht der Verwendung let! und gibt dann das Ergebnis sofort zurück.

Kontrollfluss

Aufgabenausdrücke können die Kontrollflusskonstrukte for .. in .. do, while .. do, try .. with .., try .. finally .., if .. then .. else und if .. then .. umfassen. Diese können wiederum weitere Aufgabenkonstrukte enthalten, mit Ausnahme der withfinally Handler, die synchron ausgeführt werden. Wenn Sie eine asynchrone try .. finally .. benötigen, verwenden Sie eine use-Bindung in Kombination mit einem Objekt vom Typ IAsyncDisposable.

use und use! Bindungen

Innerhalb von Aufgabenausdrücken können use-Bindungen an Werte vom Typ IDisposable oder IAsyncDisposable gebunden werden. Für letzteres wird der Entsorgungsbereinigungsvorgang asynchron ausgeführt.

Zusätzlich zu let! können Sie use! verwenden, um asynchrone Bindungen auszuführen. Der Unterschied zwischen let! und use! ist identisch mit dem Unterschied zwischen let und use. Für use! wird das Objekt am Ende des aktuellen Geltungsbereichs freigegeben. Beachten Sie, dass in F# 6 kein Wert auf `null` initialisiert werden kann, auch wenn `use!` dies erlaubt.

open System
open System.IO
open System.Security.Cryptography
task {
    // use IDisposable
    use httpClient = new Net.Http.HttpClient()
    // use! Task<IDisposable>
    use! exampleDomain = httpClient.GetAsync "https://example.com/data.enc"

    // use IDisposable
    use aes = Aes.Create()
    aes.KeySize <- 256
    aes.GenerateIV()
    aes.GenerateKey()
    // do! Task
    do! File.WriteAllTextAsync("key.iv.txt", $"Key: {Convert.ToBase64String aes.Key}\nIV: {Convert.ToBase64String aes.IV}")

    // use IAsyncDisposable
    use outputStream = File.Create "secret.enc"
    // use IDisposable
    use encryptor = aes.CreateEncryptor()
    // use IAsyncDisposable
    use cryptoStream = new CryptoStream(outputStream, encryptor, CryptoStreamMode.Write)
    // do! Task
    do! exampleDomain.Content.CopyToAsync cryptoStream
}

Wertaufgaben

Wertaufgaben werden verwendet, um Zuordnungen in der aufgabenbasierten Programmierung zu vermeiden. Eine Wertaufgabe ist ein kurzlebiger Wert, der durch die Verwendung von .AsTask() in eine echte Aufgabe umgewandelt wird.

Um eine Wertaufgabe aus einem Aufgabenausdruck zu erstellen, verwenden |> ValueTask<ReturnType> oder |> ValueTask. Beispiel:

let makeTask() =
    task { return 1 }

makeTask() |> ValueTask<int>

and! Bindungen (beginnend mit F# 10)

Innerhalb von Aufgabenausdrücken ist es möglich, gleichzeitig auf mehrere asynchrone Vorgänge (Task<'T>, ValueTask<'T>Async<'T> usw.) zu warten. Vergleichen:

// We'll wait for x to resolve and then for y to resolve. Overall execution time is sum of two execution times.
let getResultsSequentially() =
    task {
        let! x = getX()
        let! y = getY()
        return x, y
    }

// x and y will be awaited concurrently. Overall execution time is the time of the slowest operation.
let getResultsConcurrently() =
    task {
        let! x = getX()
        and! y = getY()
        return x, y
    }

Hinzufügen von Abbruch-Token und Überprüfungen

Im Gegensatz zu F#-Async-Ausdrücken übergeben Task-Ausdrücke kein Abbruchtoken und führen keine impliziten Abbruchprüfungen durch. Wenn Ihr Code ein Abbruchtoken erfordert, sollten Sie das Abbruchtoken als Parameter angeben. Beispiel:

open System.Threading

let someTaskCode (cancellationToken: CancellationToken) =
    task {
        cancellationToken.ThrowIfCancellationRequested()
        printfn $"continuing..."
    }

Wenn Sie beabsichtigen, den Code ordnungsgemäß abzubrechen, überprüfen Sie sorgfältig, ob Sie das Abbruchtoken an alle .NET-Bibliotheksvorgänge übergeben, die den Abbruch unterstützen. Zum Beispiel hat Stream.ReadAsync mehrere Überladungen, von denen eine ein Abbruchtoken akzeptiert. Wenn Sie diese Überladung nicht verwenden, kann dieser spezifische asynchrone Lesevorgang nicht abgebrochen werden.

Gebundene Parallelität und Ergebnispipeline

Um zu begrenzen, wie viele Vorgänge gleichzeitig beginnen, stellt FSharp.Core 11 bereit Task.parallelLimit. Übergeben Sie Fabriken, die Abbruchtoken akzeptieren, anstatt Aufgaben, die bereits gestartet wurden:

open System.IO
open System.Threading

let readFiles (paths: seq<string>) (ct: CancellationToken) =
    paths
    |> Seq.map (fun path ->
        fun token -> File.ReadAllTextAsync(path, token))
    |> Task.parallelLimit 4 ct

Das Ergebnisarray behält die Eingabereihenfolge bei. Ein Fehler fordert den Abbruch gleichgeordneter Arbeiten an, und jeder Vorgang muss sein Token beobachten. Task.sequential führt Fabriken einzeln aus; Task.parallelDoLimit und Task.sequentialDo anstelle eines Arrays zurückgeben unit .

Informationen Task.mapzu , Task.bindund Task.catch Ergebnispipelinen und übereinstimmenden Async/ValueTask-Hilfsgeräten finden Sie unter "Async" und "Task functions".

Hintergrundaufgaben

Standardmäßig werden .NET-Aufgaben geplant, wenn SynchronizationContext.Current vorhanden ist. Dadurch können Aufgaben als kooperative, ineinandergreifende Agenten dienen, die im Benutzeroberflächenthread ausgeführt werden, ohne die Benutzeroberfläche zu blockieren. Falls nicht vorhanden, werden Aufgabenfortsetzungen an den .NET-Threadpool übergeben.

In der Praxis ist es häufig wünschenswert, dass Bibliothekscode, der Aufgaben generiert, den Synchronisierungskontext ignoriert und stattdessen bei Bedarf zum .NET-Threadpool wechselt. Sie können dies mit backgroundTask { } erreichen:

backgroundTask { expression }

Eine Hintergrundaufgabe ignoriert alle SynchronizationContext.Current in folgendem Sinne: Wenn sie in einem Thread mit nicht NULL SynchronizationContext.Current gestartet wird, wechselt sie mit Task.Run zu einem Hintergrundthread im Threadpool. Wenn er in einem Thread mit NULL SynchronizationContext.Currentgestartet wird, wird er für denselben Thread ausgeführt.

Hinweis

In der Praxis bedeutet dies, dass ConfigureAwait(false)-Aufrufe in der Regel nicht im F#-Aufgabencode erforderlich sind. Aufgaben, die im Hintergrund ausgeführt werden sollen, sollten mit backgroundTask { ... } erstellt werden. Jede äußere Aufgabenbindung an eine Hintergrundaufgabe wird nach Abschluss der Hintergrundaufgabe erneut synchronisiert SynchronizationContext.Current .

Einschränkungen von Aufgaben in Bezug auf Tailcalls

Im Gegensatz zu F#-asynchronen Ausdrücken unterstützen Task-Ausdrücke keine Tail-Calls. Das heißt, wenn return! ausgeführt wird, wird der aktuelle Vorgang als wartend auf den Vorgang registriert, dessen Ergebnis zurückgegeben wird. Dies bedeutet, dass rekursive Funktionen und Methoden, die mit Aufgabenausdrücken implementiert werden, ungebundene Aufgabenketten erstellen können, und diese können ungebundene Stapel oder Heaps verwenden. Betrachten Sie z. B. den folgenden Code:

let rec taskLoopBad (count: int) : Task<string> =
    task {
        if count = 0 then
            return "done!"
        else
            printfn $"looping..., count = {count}"
            return! taskLoopBad (count-1)
    }

let t = taskLoopBad 10000000
t.Wait()

Dieser Codierungsstil sollte nicht mit Aufgabenausdrücken verwendet werden – er erstellt eine Kette von 100000000 Aufgaben und verursacht eine StackOverflowException. Wenn für jeden Schleifenaufruf ein asynchroner Vorgang hinzugefügt wird, verwendet der Code im Wesentlichen einen ungebundenen Heap. Erwägen Sie, diesen Code zu ändern, damit eine explizite Schleife verwendet wird, z. B.:

let taskLoopGood (count: int) : Task<string> =
    task {
        for i in count .. 1 do
            printfn $"looping... count = {count}"
        return "done!"
    }

let t = taskLoopGood 10000000
t.Wait()

Wenn asynchrone Tailcalls erforderlich sind, verwenden Sie einen asynchronen Ausdruck in F#, der Tailcalls unterstützt. Beispiel:

let rec asyncLoopGood (count: int) =
    async {
        if count = 0 then
            return "done!"
        else
            printfn $"looping..., count = {count}"
            return! asyncLoopGood (count-1)
    }

let t = asyncLoopGood 1000000 |> Async.StartAsTask
t.Wait()

Aufgabenimplementierung

Aufgaben werden mit resumable Code implementiert, einem neuen Feature in F# 6. Aufgaben werden vom F#-Compiler in "Resumable State Machines" kompiliert. Diese werden im Resumable Code RFC und in einer F#-Compiler-Community-Sitzung ausführlich beschrieben.

Asynchrone Laufzeitexperimente

Generatorautoren können mit .NET asynchronen Laufzeit experimentieren. Ein vom Compiler generierter Zustandscomputer speichert normalerweise den Zustand, der nach einem Warten fortgesetzt werden muss. Bei asynchroner Laufzeit wird stattdessen eine speziell markierte Methode angehalten und fortgesetzt.

Hinweis

Dieser Prototyp verwendet ein F#11.2-Feature. Aktivieren <LangVersion>preview</LangVersion> Sie im .NET 11.0.100 SDK einen Compiler mit runtime-async-Unterstützung. Es benötigt außerdem Laufzeit- und Referenzassemblys, die MethodImplOptions.Async verfügbar sind und verfügbar sind System.Runtime.CompilerServices.AsyncHelpers.Await. Referenzassemblys beschreiben die APIs zur Kompilierungszeit; die ausgeführte .NET Laufzeit muss das Verhalten des Anhaltens/Fortsetzens implementieren.

Die überprüfte Komponentenkombination des Beispiels lautet:

Component Version oder Anforderung
Compiler Der F# 11-Compiler, der von FSharp.Compiler.Service 43.13.101-rc2.26473.112bereitgestellt wird.
FSharp.Core Package 11.0.101-rc2.26473.112, using lib/net10.0/FSharp.Core.dll. Dieser Build liefert StateMachineHelpers.__runtimeAsyncReturn; die netstandard2.0 und netstandard2.1 builds nicht.
Project Ziel net11.0.
Laufzeit- und Referenzassemblys 11.0.0-rc.1.26425.128.NET .

Diese getesteten Komponenten sind keine einzige SDK-Verteilung. .NET 10.0.12 macht die Referenz-APIs verfügbar, aber die Unterbrechungs-/Fortsetzungsprüfung schlägt bei der Laufzeit mit TypeLoadException. Verwenden Sie oben die überprüfte Laufzeit-/Referenzkombination; Weder Vorschaumodus noch API-Anwesenheitsinformationen unterstützen Laufzeit.

Die folgende Aufrufwebsite verwendet einen anwendungsdefinierten Generator, um eine Datei asynchron zu lesen:

let readManifest (path: string) =
    runtimeTask {
        let! text = System.IO.File.ReadAllTextAsync path
        return text.Length
    }

Der Generator verbindet den Berechnungsausdruck mit dem systeminternen Laufzeit-asynchronen Rückgabe- und Await-Hilfsprogramm. Es handelt sich nicht um einen neuen integrierten task Generator:

open System.Threading.Tasks
open System.Runtime.CompilerServices
open Microsoft.FSharp.Core.CompilerServices

type RuntimeTaskBuilder() =
    member inline _.Delay([<InlineIfLambda>] code: unit -> 'T) = code
    member inline _.Run([<InlineIfLambda>] code: unit -> 'T) : Task<'T> =
        StateMachineHelpers.__runtimeAsyncReturn (code())
    member inline _.Return(value: 'T) = value
    member inline _.Bind(work: Task<'T>, [<InlineIfLambda>] next: 'T -> 'U) =
        next (AsyncHelpers.Await work)

let runtimeTask = RuntimeTaskBuilder()

__runtimeAsyncReturn markiert die eingeschlossene Methode für die asynchrone Laufzeit. Innerhalb dieser Methode AsyncHelpers.Await können Sie die nicht abgeschlossene Arbeit anhalten und die Steuerung an den Aufrufer zurückgeben und dann mit dem Ergebnis fortsetzen. Es handelt sich nicht um einen blockierenden .Result Anruf.

Der Compiler überprüft unterstützte Anhaltemuster; byrefs können nicht über eine Aussetzung leben. Das Beispiel definiert Delaynur eine Überladung, RunReturnBind nicht einen Produktions-Generator mit Abbruch, Schleifen oder asynchroner Entsorgung.

Aktivieren Sie das F# 11.2-Feature, bei dem der Inline-Generator definiert und erweitert wird. Aufrufer einer bereits kompilierten readManifest API benötigen sie nicht. Gewöhnliche task Ausdrücke behalten ihre Implementierung bei. Verwenden Sie AsyncHelpers.Await nur innerhalb der asynchronen Laufzeitmethoden. Siehe die asynchrone Laufzeitspezifikation.

Das vollständige Beispielprojekt enthält einen Konsoleneinstiegspunkt. Führen Sie dotnet run -- manifest.txt aus dem Verzeichnis mit kompatiblen Tools und einer zu lesenden Datei aus. Eine Datei, die genau manifest, ohne eine nachfolgende Neulinie enthält, erzeugt 8. Die Konsoleneinstiegspunktblöcke für den Abschluss; readManifest selbst gibt zurück Task<int>.

Siehe auch