Freigeben über


Asynchrone Programmierung in F#

Die asynchrone Programmierung ist ein Mechanismus, der aus verschiedenen Gründen für moderne Anwendungen unerlässlich ist. Es gibt zwei primäre Anwendungsfälle, auf die die meisten Entwickler stoßen:

  • Darstellen eines Serverprozesses, der eine erhebliche Anzahl gleichzeitiger eingehender Anforderungen verarbeiten kann, während die systemressourcen minimiert werden, während die Anforderungsverarbeitung auf Eingaben von Systemen oder Diensten außerhalb dieses Prozesses wartet.
  • Verwalten einer reaktionsfähigen Benutzeroberfläche oder eines Hauptthreads, während gleichzeitig Hintergrundarbeit ausgeführt wird

Obwohl Hintergrundarbeit häufig die Verwendung mehrerer Threads umfasst, ist es wichtig, die Konzepte von Asynchronie und Multithreading separat zu berücksichtigen. Tatsächlich sind sie getrennte Bedenken, und man impliziert nicht das andere. In diesem Artikel werden die separaten Konzepte ausführlicher beschrieben.

Definition von Asynchronität

Der vorherige Punkt - die Asynchronie ist unabhängig von der Nutzung mehrerer Threads - lohnt sich noch ein bisschen weiter zu erläutern. Es gibt drei Konzepte, die manchmal miteinander verbunden sind, aber streng unabhängig voneinander:

  • Gleichzeitigkeit; wenn mehrere Berechnungen in überlappenden Zeiträumen ausgeführt werden.
  • Parallelismus; wenn mehrere Berechnungen oder mehrere Teile einer einzelnen Berechnung gleichzeitig ausgeführt werden.
  • Asynchronie; wenn eine oder mehrere Berechnungen getrennt vom Hauptprogrammfluss ausgeführt werden können.

Alle drei sind orthogonale Konzepte, können aber leicht zusammengeblasen werden, insbesondere, wenn sie zusammen verwendet werden. Beispielsweise müssen Sie möglicherweise mehrere asynchrone Berechnungen parallel ausführen. Diese Beziehung bedeutet nicht, dass Parallelität oder Asynchronität einander impliziert.

Wenn Sie die Etymologie des Worts "asynchron" in Betracht ziehen, sind zwei Teile beteiligt:

  • „a“, was „nicht“ bedeutet.
  • „synchron“, was „gleichzeitig“ bedeutet.

Wenn Sie diese beiden Begriffe zusammensetzen, sehen Sie, dass "asynchron" "nicht gleichzeitig" bedeutet. Das ist alles! Es gibt keine Implikation auf Nebenläufigkeit oder Parallelität in dieser Definition. Dies gilt auch in der Praxis.

In der Praxis werden asynchrone Berechnungen in F# so geplant, dass sie unabhängig vom Hauptprogrammablauf ausgeführt werden. Die unabhängige Ausführung impliziert weder Nebenläufigkeit noch Parallelität, noch bedeutet sie, dass eine Berechnung immer im Hintergrund erfolgt. Tatsächlich können asynchrone Berechnungen sogar synchron ausgeführt werden, abhängig von der Art der Berechnung und der Umgebung, in der die Berechnung ausgeführt wird.

Die wichtigste Erkenntnis, die Sie haben sollten, ist, dass asynchrone Berechnungen unabhängig vom Hauptprogrammfluss sind. Obwohl es nur wenige Garantien gibt, wann oder wie eine asynchrone Berechnung ausgeführt wird, gibt es einige Ansätze zur Orchestrierung und Planung. Im restlichen Artikel werden die Kernkonzepte für F#-Asynchronie und die Verwendung der in F# integrierten Typen, Funktionen und Ausdrücke erläutert.

Kernkonzepte

In F# zentriert sich die asynchrone Programmierung auf zwei Kernkonzepte: asynchrone Berechnungen und Aufgaben.

  • Der Async<'T> Typ mit async { } Ausdrücken, der eine komponierbare asynchrone Berechnung darstellt, die gestartet werden kann, um eine Aufgabe zu bilden.
  • Der Task<'T> Typ mit task { } Ausdrücken, der eine ausgeführte .NET-Aufgabe darstellt.

Im Allgemeinen sollten Sie die Verwendung von task {…} statt async {…} in neuem Code in Betracht ziehen, wenn Sie mit .NET-Bibliotheken zusammenarbeiten, die Aufgaben verwenden, und wenn Sie nicht auf asynchrone Code-Tailcalls oder implizite Abbruchtokenweitergabe angewiesen sind.

Kernkonzepte der asynchronen Programmierung

Die grundlegenden Konzepte der "asynchronen" Programmierung finden Sie im folgenden Beispiel:

open System
open System.IO

// Perform an asynchronous read of a file using 'async'
let printTotalFileBytesUsingAsync (path: string) =
    async {
        let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    printTotalFileBytesUsingAsync "path-to-file.txt"
    |> Async.RunSynchronously

    Console.Read() |> ignore
    0

Im Beispiel ist die printTotalFileBytesUsingAsync Funktion vom Typ string -> Async<unit>. Durch Aufrufen der Funktion wird die asynchrone Berechnung nicht ausgeführt. Stattdessen wird ein Async<unit> Wert zurückgegeben, der als Spezifikation der Arbeit fungiert, die asynchron ausgeführt werden soll. Er ruft Async.AwaitTask in seinem Körper auf, wobei das Ergebnis von ReadAllBytesAsync in einen geeigneten Typ konvertiert wird.

Eine weitere wichtige Zeile ist der Anruf an Async.RunSynchronously. Dies ist eine der Async-Modulstartfunktionen, die Sie aufrufen müssen, wenn Sie tatsächlich eine asynchrone F#-Berechnung ausführen möchten.

Dies ist ein grundlegender Unterschied mit dem C#/Visual Basic-Stil der async Programmierung. In F# können asynchrone Berechnungen als Kalte Vorgänge betrachtet werden. Sie müssen explizit gestartet werden, um tatsächlich ausgeführt zu werden. Dies hat einige Vorteile, da es Ihnen ermöglicht, asynchrone Arbeit viel einfacher zu kombinieren und zu sequenzieren als in C# oder Visual Basic.

Kombinieren asynchroner Berechnungen

Hier ist ein Beispiel, das auf dem vorherigen basiert, indem Berechnungen kombiniert werden:

open System
open System.IO

let printTotalFileBytes path =
    async {
        let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    argv
    |> Seq.map printTotalFileBytes
    |> Async.Parallel
    |> Async.Ignore
    |> Async.RunSynchronously

    0

Wie Sie sehen können, verfügt die main Funktion über einige weitere Elemente. Konzeptionell wird Folgendes ausgeführt:

  1. Transformieren Sie die Befehlszeilenargumente in eine Abfolge von Async<unit> Berechnungen mit Seq.map.
  2. Erstellen von Async<'T[]>, um die printTotalFileBytes-Berechnungen zeitlich zu planen und parallel auszuführen.
  3. Erstellen Sie eine Async<unit> , die die parallele Berechnung ausführt, und ignorieren Sie das Ergebnis (dies ist ein unit[]).
  4. Führen Sie die insgesamt kombinierte Berechnung explizit mit Async.RunSynchronously aus und blockieren Sie, bis sie abgeschlossen ist.

Wenn dieses Programm ausgeführt wird, printTotalFileBytes wird parallel für jedes Befehlszeilenargument ausgeführt. Da asynchrone Berechnungen unabhängig vom Programmablauf ausgeführt werden, gibt es keine definierte Reihenfolge, in der sie ihre Informationen drucken und die Ausführung beenden. Die Berechnungen werden parallel geplant, aber ihre Ausführungsreihenfolge ist nicht garantiert.

Sequenz asynchroner Berechnungen

Da Async<'T> es sich um eine Spezifikation der Arbeit und nicht um eine bereits ausgeführte Aufgabe handelt, können Sie komplexere Transformationen einfacher ausführen. Hier ist ein Beispiel, das eine Reihe von Asynchronen Berechnungen sequenziert, sodass sie nacheinander ausgeführt werden.

let printTotalFileBytes path =
    async {
        let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    argv
    |> Seq.map printTotalFileBytes
    |> Async.Sequential
    |> Async.Ignore
    |> Async.RunSynchronously
    |> ignore

Dadurch wird printTotalFileBytes so geplant, dass es in der Reihenfolge der Elemente von argv ausgeführt wird, statt parallel. Da jeder aufeinander folgende Vorgang erst geplant wird, nachdem die vorherige Berechnung abgeschlossen wurde, werden die Berechnungen so sequenziert, dass es keine Überlappung in der Ausführung gibt.

Wichtige Async-Modulfunktionen

Wenn Sie asynchronen Code in F# schreiben, interagieren Sie in der Regel mit einem Framework, das die Planung von Berechnungen für Sie behandelt. Dies ist jedoch nicht immer der Fall, daher ist es gut, die verschiedenen Funktionen zu verstehen, die zum Planen asynchroner Arbeiten verwendet werden können.

Da es sich bei asynchronen F#-Berechnungen nicht um eine Darstellung der bereits ausgeführten Arbeit handelt, müssen sie explizit mit einer Startfunktion gestartet werden. Es gibt viele Async-Startmethoden , die in verschiedenen Kontexten hilfreich sind. Im folgenden Abschnitt werden einige der gängigeren Startfunktionen beschrieben.

Async.StartChild

Startet eine untergeordnete Berechnung in einer asynchronen Berechnung. Dadurch können mehrere asynchrone Berechnungen gleichzeitig ausgeführt werden. Die untergeordnete Berechnung verwendet gemeinsam mit der übergeordneten Berechnung ein Abbruchtoken. Wenn die übergeordnete Berechnung abgebrochen wird, wird auch die untergeordnete Berechnung abgebrochen.

Unterschrift:

computation: Async<'T> * ?millisecondsTimeout: int -> Async<Async<'T>>

Verwendungsbedingungen:

  • Sie möchten mehrere asynchrone Berechnungen gleichzeitig und nicht einzeln nacheinander ausführen, haben diese aber nicht parallel geplant.
  • Sie möchten die Lebensdauer einer untergeordneten Berechnung an die Lebensdauer einer übergeordneten Berechnung binden.

Worauf Sie achten sollten:

  • Das Starten mehrerer Berechnungen mit Async.StartChild ist nicht dasselbe wie deren parallele Ausführung zu planen. Wenn Sie Berechnungen parallel planen möchten, verwenden Sie Async.Parallel.
  • Beim Abbrechen einer übergeordneten Berechnung werden alle gestarteten untergeordneten Berechnungen abgebrochen.

Async.StartImmediate

Führt eine asynchrone Berechnung aus, beginnend sofort im aktuellen Betriebssystemthread. Dies ist hilfreich, wenn Sie während der Berechnung etwas im aufrufenden Thread aktualisieren müssen. Wenn beispielsweise eine asynchrone Berechnung eine Benutzeroberfläche aktualisieren muss (z. B. eine Statusanzeige aktualisieren), sollte dazu Async.StartImmediate verwendet werden.

Unterschrift:

computation: Async<unit> * ?cancellationToken: CancellationToken -> unit

Verwendungsbedingungen:

  • Wenn Sie etwas im aufrufenden Thread in der Mitte einer asynchronen Berechnung aktualisieren müssen.

Worauf Sie achten sollten:

  • Code in der asynchronen Berechnung wird in dem Thread ausgeführt, in dem er gerade geplant ist. Dies kann problematisch sein, wenn dieser Thread auf irgendeine Weise vertraulich ist, z. B. ein UI-Thread. In solchen Fällen ist die Verwendung von Async.StartImmediate wahrscheinlich unangemessen.

Async.StartAsTask

Führt eine Berechnung im Threadpool aus. Gibt Task<TResult> zurück, das nach Beendigung der Berechnung im entsprechenden Zustand abgeschlossen wird (Erzeugen eines Ergebnisses, Auslösen einer Ausnahme oder Abbruch). Wenn kein Abbruchtoken bereitgestellt wird, wird das Standardabbruchtoken verwendet.

Unterschrift:

computation: Async<'T> * ?taskCreationOptions: TaskCreationOptions * ?cancellationToken: CancellationToken -> Task<'T>

Verwendungsbedingungen:

  • Wenn Sie eine .NET-API aufrufen müssen, die ein Task<TResult> erzeugt, um das Ergebnis einer asynchronen Berechnung darzustellen.

Worauf Sie achten sollten:

  • Dieser Aufruf weist ein zusätzliches Task Objekt zu, was den Aufwand erhöhen kann, wenn es häufig verwendet wird.

Async.Parallel

Plant eine Abfolge von asynchronen Berechnungen, die parallel ausgeführt werden sollen, ergibt ein Array von Ergebnissen in der Reihenfolge, in der sie angegeben wurden. Der Grad der Parallelität kann optional abgestimmt/gedrosselt werden, indem der maxDegreeOfParallelism Parameter angegeben wird.

Unterschrift:

computations: seq<Async<'T>> * ?maxDegreeOfParallelism: int -> Async<'T[]>

Wann sie verwendet werden soll:

  • Wenn Sie eine Reihe von Berechnungen gleichzeitig ausführen müssen und keine Abhängigkeit von ihrer Ausführungsreihenfolge haben.
  • Wenn Sie keine Ergebnisse aus Berechnungen benötigen, die parallel geplant wurden, bevor sie alle abgeschlossen sind.

Worauf Sie achten sollten:

  • Sie können nur auf das resultierende Array von Werten zugreifen, nachdem alle Berechnungen abgeschlossen sind.
  • Berechnungen werden immer dann ausgeführt, wenn sie geplant werden. Dieses Verhalten bedeutet, dass Sie sich nicht auf ihre Ausführungsreihenfolge verlassen können.

Async.Sequential

Plant eine Abfolge asynchroner Berechnungen, die in der Reihenfolge ausgeführt werden, in der sie übergeben werden. Die erste Berechnung wird ausgeführt, dann der nächste usw. Es werden keine Berechnungen parallel ausgeführt.

Unterschrift:

computations: seq<Async<'T>> -> Async<'T[]>

Wann sie verwendet werden soll:

  • Wenn Sie mehrere Berechnungen in der Reihenfolge ausführen müssen.

Worauf Sie achten sollten:

  • Sie können nur auf das resultierende Array von Werten zugreifen, nachdem alle Berechnungen abgeschlossen sind.
  • Berechnungen werden in der Reihenfolge ausgeführt, in der sie an diese Funktion übergeben werden. Dies kann bedeuten, dass mehr Zeit verstrichen ist, bevor die Ergebnisse zurückgegeben werden.

Async.AwaitTask

Gibt eine asynchrone Berechnung zurück, die darauf wartet, dass die angegebene Task<TResult> abgeschlossen wird, und gibt ihr Ergebnis als Async<'T> zurück.

Unterschrift:

task: Task<'T> -> Async<'T>

Verwendungsbedingungen:

  • Wenn Sie eine .NET-API verwenden, die eine Task<TResult> in einer asynchronen F#-Berechnung zurückgibt.

Worauf Sie achten sollten:

  • Ausnahmen werden in AggregateException gemäß der Konvention der Task Parallel Library umschlossen. Dieses Verhalten unterscheidet sich von der Art und Weise, wie F#-Async im Allgemeinen Ausnahmen meldet.

Async.Catch

Erstellt eine asynchrone Berechnung, die eine bestimmte Async<'T> ausführt und ein Async<Choice<'T, exn>> zurückgibt. Wenn das angegebene Async<'T> erfolgreich abgeschlossen wird, wird ein Choice1Of2 mit dem resultierenden Wert zurückgegeben. Wenn vor dem Abschluss eine Ausnahme ausgelöst wird, wird ein Choice2of2 mit der ausgelösten Ausnahme zurückgegeben. Wenn sie für eine asynchrone Berechnung verwendet wird, die sich aus vielen Berechnungen zusammensetzt, und eine dieser Berechnungen eine Ausnahme auslöst, wird die umschließende Berechnung vollständig beendet.

Unterschrift:

computation: Async<'T> -> Async<Choice<'T, exn>>

Verwendungsbedingungen:

  • Sie führen eine asynchrone Arbeit aus, die u. U. mit einer Ausnahme fehlschlägt, und Sie möchten diese Ausnahme im Aufrufer behandeln.

Worauf Sie achten sollten:

  • Bei verwendung kombinierter oder sequenzierter asynchroner Berechnungen wird die umschließende Berechnung vollständig beendet, wenn eine der "internen" Berechnungen eine Ausnahme auslöst.

Async.Ignore

Erstellt eine asynchrone Berechnung, die die angegebene Berechnung ausführt, deren Ergebnis jedoch verworfen wird.

Unterschrift:

computation: Async<'T> -> Async<unit>

Verwendungsbedingungen:

  • Wenn Sie über eine asynchrone Berechnung verfügen, deren Ergebnis nicht benötigt wird. Dies entspricht der ignore Funktion für nicht asynchronen Code.

Worauf Sie achten sollten:

  • Wenn Sie Async.Ignore verwenden müssen, weil Sie Async.Start oder eine andere Funktion nutzen möchten, die Async<unit> erfordert, überlegen Sie, ob es in Ordnung ist, das Ergebnis zu ignorieren. Vermeiden Sie es, Ergebnisse zu verwerfen, um lediglich eine Typsignatur anzupassen.

Async.RunSynchronously

Führt eine asynchrone Berechnung aus und wartet auf das Ergebnis des aufrufenden Threads. Gibt eine Ausnahme weiter, wenn sich bei der Berechnung eine ergibt. Dieser Aufruf wird blockiert.

Unterschrift:

computation: Async<'T> * ?timeout: int * ?cancellationToken: CancellationToken -> 'T

Wann sie verwendet werden soll:

  • Wenn Sie es benötigen, verwenden Sie es nur einmal in einer Anwendung – am Einstiegspunkt für eine ausführbare Datei.
  • Wenn Sie sich nicht um die Leistung kümmern und eine Reihe anderer asynchroner Vorgänge gleichzeitig ausführen möchten.

Worauf Sie achten sollten:

  • Durch Aufrufen Async.RunSynchronously wird der aufrufende Thread blockiert, bis die Ausführung abgeschlossen ist.

Async.Start

Startet eine asynchrone Berechnung, die unit im Threadpool zurückgibt. Es wird nicht auf den Abschluss gewartet und/oder nicht auf ein Ausnahmeergebnis geachtet. Geschachtelte Berechnungen, die mit Async.Start gestartet werden, werden unabhängig von der sie aufrufenden übergeordneten Berechnung gestartet. Ihre Lebensdauer ist nicht an eine übergeordnete Berechnung gebunden. Wenn die übergeordnete Berechnung abgebrochen wird, werden keine untergeordneten Berechnungen abgebrochen.

Unterschrift:

computation: Async<unit> * ?cancellationToken: CancellationToken -> unit

Verwenden Sie nur, wenn:

  • Sie verfügen über eine asynchrone Berechnung, die kein Ergebnis liefert und/oder eine Verarbeitung erfordert.
  • Sie müssen nicht wissen, wann eine asynchrone Berechnung abgeschlossen ist.
  • Sie achten nicht darauf, auf welchem Thread eine asynchrone Berechnung ausgeführt wird.
  • Sie müssen keine Ausnahmen beachten oder melden, die sich aus der Ausführung ergeben.

Worauf Sie achten sollten:

  • Ausnahmen, die von Berechnungen mit Async.Start ausgelöst werden, werden nicht an den Aufrufer weitergegeben. Die Aufrufliste wird vollständig abgewickelt.
  • Jede Arbeit (wie das Aufrufen von printfn), die mit Async.Start begonnen wird, verursacht nicht, dass die Wirkung auf dem Hauptthread der Programmausführung eintritt.

Interoperabilität mit .NET

Bei Verwendung der async { } Programmierung müssen Sie möglicherweise mit einer .NET-Bibliothek oder einer C#-Codebasis zusammenarbeiten, die asynchrone Programmierung im asynchronen/await-Stil verwendet. Da C# und der Großteil der .NET-Bibliotheken die Task<TResult> Und Task Typen als kerne Abstraktionen verwenden, kann sich dies ändern, wie Sie Ihren asynchronen F#-Code schreiben.

Eine Möglichkeit besteht darin, .NET-Tasks direkt mithilfe von task { } zu schreiben. Alternativ können Sie die Async.AwaitTask Funktion verwenden, um auf eine asynchrone .NET-Berechnung zu warten:

let getValueFromLibrary param =
    async {
        let! value = DotNetLibrary.GetValueAsync param |> Async.AwaitTask
        return value
    }

Sie können die Async.StartAsTask Funktion verwenden, um eine asynchrone Berechnung an einen .NET-Aufrufer zu übergeben:

let computationForCaller param =
    async {
        let! result = getAsyncResult param
        return result
    } |> Async.StartAsTask

Um mit APIs zu arbeiten, die Task verwenden (d. h. .NET asynchrone Berechnungen, die keinen Wert zurückgeben), müssen Sie möglicherweise eine zusätzliche Funktion hinzufügen, die ein Async<'T> in ein Task konvertiert.

module Async =
    // Async<unit> -> Task
    let startTaskFromAsyncUnit (comp: Async<unit>) =
        Async.StartAsTask comp :> Task

Es gibt bereits eine Async.AwaitTask, die eine Task als Eingabe akzeptiert. Damit und mit der zuvor definierten startTaskFromAsyncUnit-Funktion können Sie Task-Typen aus einer asynchronen F#-Berechnung starten und darauf warten.

Schreiben von .NET-Aufgaben direkt in F#

In F# können Sie Aufgaben direkt mit task { } schreiben, z. B.:

open System
open System.IO

/// Perform an asynchronous read of a file using 'task'
let printTotalFileBytesUsingTasks (path: string) =
    task {
        let! bytes = File.ReadAllBytesAsync(path)
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    let task = printTotalFileBytesUsingTasks "path-to-file.txt"
    task.Wait()

    Console.Read() |> ignore
    0

Im Beispiel ist die printTotalFileBytesUsingTasks Funktion vom Typ string -> Task<unit>. Das Aufrufen der Funktion beginnt mit der Ausführung der Aufgabe. Der Aufruf von task.Wait() wartet auf den Abschluss der Task.

Beziehung zu Multithreading

Obwohl Threading in diesem Artikel erwähnt wird, gibt es zwei wichtige Dinge zu beachten:

  1. Es gibt keine Affinität zwischen einer asynchronen Berechnung und einem Thread, es sei denn, sie wurde explizit im aktuellen Thread gestartet.
  2. Die asynchrone Programmierung in F# ist keine Abstraktion für Multithreading.

Eine Berechnung kann beispielsweise je nach Art der Arbeit tatsächlich im Thread des Aufrufers ausgeführt werden. Eine Berechnung kann auch zwischen Threads „springen“ und diese für einen kurzen Zeitraum ausleihen, um zwischen „Wartephasen“ (z. B. wenn ein Netzwerkanruf übertragen wird) nützliche Arbeit zu erledigen.

Obwohl F# einige Fähigkeiten zum Starten einer asynchronen Berechnung für den aktuellen Thread (oder explizit nicht für den aktuellen Thread) bietet, ist Asynchronie im Allgemeinen nicht mit einer bestimmten Threadingstrategie verknüpft.

Siehe auch