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 Hauptanwendungsfälle, auf die die meisten Entwickler stoßen:

  • Darstellung eines Serverprozesses, der eine erhebliche Anzahl gleichzeitig eingehender Anforderungen verarbeiten kann, wobei die beanspruchten 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 bei gleichzeitig fortschreitender Verarbeitung im Hintergrund

Obwohl beider Verarbeitung im Hintergrund häufig die mehrere Threads verwendet werden, müssen die Konzepte für Asynchronität und Multithreading separat berücksichtigt werden. De facto handelt es sich um getrennte Belange, wobei der eine nicht den anderen impliziert. In diesem Artikel werden die separaten Konzepte ausführlicher beschrieben.

Definition von Asynchronität

Der obige Punkt, die Unabhängigkeit der Asynchronität von der Nutzung mehrerer Threads, muss etwas eingehender erläutert werden. Es gibt drei Konzepte, die manchmal zusammenhängen, aber strikt unabhängig voneinander sind:

  • Nebenläufigkeit: Ausführung mehrerer Berechnungen in überlappenden Zeiträumen.
  • Parallelität: Ausführung mehrerer Berechnungen oder verschiedener Teile einer einzelnen Berechnung zur gleichen Zeit.
  • Asynchronität: Ausführung von Berechnungen separat vom Hauptprogrammablauf.

Alle drei sind orthogonale Konzepte, können aber leicht verschmolzen werden, insbesondere wenn sie zusammen verwendet werden. Beispielsweise kann es sein, dass mehrere asynchrone Berechnungen parallel ausgeführt werden müssen. Diese Beziehung bedeutet nicht, dass Parallelität oder Asynchronität einander implizieren.

Wenn Sie die Etymologie des Worts „asynchron“ betrachten, besteht es aus zwei Teilen:

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

Wenn Sie diese beiden Begriffe zusammensetzen, sehen Sie, dass „asynchron“ „nicht zur gleichen Zeit“ 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 erfolgt die Ausführung von asynchronen Berechnungen in F# unabhängig vom Hauptprogrammablauf. Diese unabhängige Ausführung impliziert weder Nebenläufigkeit noch Parallelität, und auch nicht, dass eine Berechnung immer im Hintergrund stattfindet. Tatsächlich können asynchrone Berechnungen sogar synchron ausgeführt werden. Dies hängt von der Art der Berechnung und der Umgebung ab, in der die Berechnung ausgeführt wird.

Die wichtigste Erkenntnis, die Sie mitnehmen sollten, ist, dass asynchrone Berechnungen unabhängig vom Hauptprogrammablauf sind. Obwohl es nur wenige Garantien dafür gibt, wann oder wie eine asynchrone Berechnung ausgeführt wird, gibt es einige Ansätze, um sie zu orchestrieren und zeitlich zu planen. Im weiteren Verlauf dieses Artikels werden die wichtigsten Konzepte für die F#-Asynchronität und die Verwendung der in F# integrierten Typen, Funktionen und Ausdrücke erläutert.

Kernkonzepte

In F# basiert die asynchrone Programmierung auf zwei zentralen Konzepten: asynchrone Berechnungen und Tasks.

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

Im Allgemeinen sollten Sie in neuem Code task {…} gegenüber async {…} bevorzugen, wenn Interoperabilität mit .NET-Bibliotheken erforderlich ist, die Tasks verwenden, und wenn Sie keine asynchronen Codeendeaufrufe und keine implizite Weitergabe von Abbruchtoken nutzen.

Zentrale Konzepte von „async“

Das folgende Beispiel veranschaulicht die grundlegenden Konzepte der „async“-Programmierung:

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 weist die printTotalFileBytesUsingAsync-Funktion den Typ string -> Async<unit> auf. Beim Aufruf der Funktion wird tatsächlich keine asynchrone Berechnung ausgeführt. Stattdessen wird ein Async<unit> zurückgegeben, das als Spezifikation der Verarbeitung fungiert, die asynchron ausgeführt werden soll. Im Code wird Async.AwaitTask aufgerufen, wodurch das Ergebnis von ReadAllBytesAsync in einen geeigneten Typ konvertiert wird.

Eine weitere wichtige Zeile ist der Aufruf von Async.RunSynchronously. Dabei handelt es sich um eine der Startfunktionen für das Async-Modul, die Sie aufrufen müssen, wenn Sie tatsächlich eine asynchrone F#-Berechnung ausführen möchten.

Dies ist ein grundlegender Unterschied zur async-Programmierung in C# und Visual Basic. In F# können asynchrone Berechnungen als kalte Tasks betrachtet werden. Sie müssen explizit gestartet werden, damit sie tatsächlich ausgeführt werden. Dies hat einige Vorteile, da Sie eine asynchrone Verarbeitung viel einfacher kombinieren und sequenzieren können als in C# oder Visual Basic.

Kombinieren asynchroner Berechnungen

Das folgende Beispiel baut auf dem vorherigen auf und kombiniert Berechnungen:

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, verfügt die main-Funktion über einige weitere Elemente. Konzeptionell ist der Ablauf wie folgt:

  1. Transformieren der Befehlszeilenargumente in eine Sequenz 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 von Async<unit>, um die parallele Berechnung auszuführen und das Ergebnis (unit[]) zu ignorieren.
  4. Explizite Ausführung der zusammengesetzten Gesamtberechnung mit Async.RunSynchronously mit Blockierung bis zum Abschluss.

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

Sequenzieren asynchroner Berechnungen

Da es sich bei Async<'T> um eine Verarbeitungsspezifikation und nicht um eine bereits ausgeführte Task handelt, können Sie problemlos komplexere Transformationen ausführen. Im folgenden Beispiel werden mehrere Async-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 die Ausführung von printTotalFileBytes in der Reihenfolge der Elemente von argv und nicht parallel geplant. Da jede nachfolgende Operation erst geplant wird, nachdem die vorherige Berechnung beendet wurde, werden die Berechnungen so sequenziert, dass es keine Überlappung bei ihrer Ausführung gibt.

Wichtige Async-Modulfunktionen

Wenn Sie asynchronen Code in F# schreiben, erfolgt normalerweise eine Interaktion mit einem Framework, das die Planung von Berechnungen für Sie übernimmt. Dies ist jedoch nicht immer der Fall. Daher ist es ratsam, die verschiedenen Funktionen zu verstehen, die zum Planen einer asynchronen Verarbeitung verwendet werden können.

Da es sich bei asynchronen F#-Berechnungen um eine Spezifikation der Verarbeitung und nicht um eine Darstellung der bereits ausgeführten Verarbeitung 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ängigsten Startfunktionen beschrieben.

Async.StartChild

Startet eine untergeordnete Berechnung in einer asynchronen Berechnung. Dies ermöglicht die gleichzeitige Ausführung mehrerer asynchroner Berechnungen. Die untergeordnete Berechnung verwendet gemeinsam mit der übergeordneten Berechnung ein Abbruchtoken. Wenn die übergeordnete Berechnung abgebrochen wird, wird auch die untergeordnete Berechnung abgebrochen.

Signatur:

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

Verwendung

  • 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 müssen:

  • Das Starten mehrerer Berechnungen mit Async.StartChild ist nicht identisch mit der parallelen Planung. Verwenden Sie Async.Parallel, wenn Sie Berechnungen parallel planen möchten.
  • Beim Abbrechen einer übergeordneten Berechnung werden alle gestarteten untergeordneten Berechnungen abgebrochen.

Async.StartImmediate

Führt eine asynchrone Berechnung aus, die sofort im aktuellen Betriebssystemthread beginnt. Dies ist hilfreich, wenn während der Berechnung im aufrufenden Thread etwas aktualisiert werden muss. Wenn beispielsweise eine asynchrone Berechnung eine Benutzeroberfläche aktualisieren muss (z. B. Aktualisieren eines Statusbalkens), sollte Async.StartImmediate verwendet werden.

Signatur:

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

Verwendung

  • In der Mitte einer asynchronen Berechnung muss im aufrufenden Thread eine Aktualisierung durchgeführt werden.

Worauf Sie achten müssen:

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

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.

Signatur:

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

Verwendung

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

Worauf Sie achten müssen:

  • Dieser Aufruf ordnet ein zusätzliches Task-Objekt zu, was bei häufiger Verwendung den Overhead erhöhen kann.

Async.Parallel

Plant eine Sequenz von asynchronen Berechnungen, die parallel ausgeführt werden sollen, und ergibt ein Array von Ergebnissen in der Reihenfolge, in der sie angegeben wurden. Der Grad der Parallelität kann optional durch Angabe des maxDegreeOfParallelism-Parameters optimiert/gedrosselt werden.

Signatur:

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

Verwendung:

  • Sie müssen mehrere Berechnungen gleichzeitig ausführen und die Ausführungsreihenfolge spielt keine Rolle.
  • Sie benötigen keine Ergebnisse aus parallel geplanten Berechnungen, bevor alle abgeschlossen wurden.

Worauf Sie achten müssen:

  • Sie können erst auf das resultierende Array von Werten zugreifen, nachdem alle Berechnungen beendet wurden.
  • Berechnungen werden immer dann ausgeführt, wenn sie geplant werden. Dieses Verhalten bedeutet, dass Sie sich nicht auf die Reihenfolge ihrer Ausführung verlassen können.

Async.Sequential

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

Signatur:

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

Verwendung:

  • Sie müssen mehrere Berechnungen nacheinander ausführen.

Worauf Sie achten müssen:

  • Sie können erst auf das resultierende Array von Werten zugreifen, nachdem alle Berechnungen beendet wurden.
  • Berechnungen werden in der Reihenfolge ausgeführt, in der sie an diese Funktion übergeben werden. Dies kann bedeuten, dass es länger dauert, bis die Ergebnisse zurückgegeben werden.

Async.AwaitTask

Gibt eine asynchrone Berechnung zurück, die auf das Ende von Task<TResult> wartet und das Ergebnis als Async<'T> zurückgibt.

Signatur:

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

Verwendung

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

Worauf Sie achten müssen:

  • 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 ein angegebenes Async<'T> ausführt, und gibt ein Async<Choice<'T, exn>> zurück. 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 bei Verwendung für eine asynchrone Berechnung, die selbst aus vielen Berechnungen besteht, eine dieser Berechnungen eine Ausnahme auslöst, wird die umfassende Berechnung insgesamt beendet.

Signatur:

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

Verwendung

  • 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 müssen:

  • Wenn kombinierte oder sequenzierte asynchrone Berechnungen verwendet werden, wird die umschließende Berechnung insgesamt beendet, wenn eine der „internen“ Berechnungen eine Ausnahme auslöst.

Async.Ignore

Erstellt eine asynchrone Berechnung, die die angegebene Berechnung ausführt, aber kein Ergebnis zurückgibt.

Signatur:

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

Verwendung

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

Worauf Sie achten müssen:

  • Wenn Sie Async.Ignore verwenden müssen, weil Sie Async.Start oder eine andere Funktion verwenden möchten, die Async<unit> erfordert, sollten Sie erwägen, ob das Verwerfen des Ergebnisses in Ordnung ist. Vermeiden Sie es, Ergebnisse zu verwerfen, um lediglich eine Typsignatur anzupassen.

Async.RunSynchronously

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

Signatur:

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

Verwendung:

  • Verwenden Sie dies bei Bedarf nur einmal in einer Anwendung – am Einstiegspunkt für eine ausführbare Datei.
  • Die Leistung spielt keine Rolle und Sie möchten mehrere andere asynchrone Operationen gleichzeitig ausführen.

Worauf Sie achten müssen:

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

Async.Start

Startet eine asynchrone Berechnung, die unit zurückgibt, im Threadpool. 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 übergeordneten Berechnung gestartet, die sie aufgerufen hat. Ihre Lebensdauer ist nicht an übergeordnete Berechnungen gebunden. Wenn die übergeordnete Berechnung abgebrochen wird, werden keine untergeordneten Berechnungen abgebrochen.

Signatur:

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

Verwenden Sie dies nur in folgenden Fällen:

  • Sie verfügen über eine asynchrone Berechnung, die kein Ergebnis ergibt und/oder eine Verarbeitung eines Ergebnisses erfordert.
  • Sie müssen nicht wissen, wann eine asynchrone Berechnung abgeschlossen ist.
  • Es ist egal, in welchem Thread eine asynchrone Berechnung ausgeführt wird.
  • Sie müssen keine Ausnahmen erkennen oder melden, die sich aus der Ausführung ergeben.

Worauf Sie achten müssen:

  • Ausnahmen, die von Berechnungen ausgelöst werden, die mit Async.Start gestartet wurden, werden nicht an den Aufrufer weitergegeben. Die Aufrufliste wird vollständig abgewickelt.
  • Alle mit Async.Start gestarteten Verarbeitungen (z. B. Aufrufen von printfn) führen nicht dazu, dass Auswirkungen im Hauptthread der Ausführung eines Programms auftreten.

Interoperabilität mit .NET

Wenn Sie die async { }-Programmierung verwenden, müssen Sie möglicherweise mit einer .NET-Bibliothek oder C#-Codebasis zusammenarbeiten, die eine asynchrone async/await-Programmierung verwendet. Da C# und die Mehrheit der .NET-Bibliotheken die Typen Task<TResult> und Task als zentrale Abstraktionen verwenden, kann sich dies darauf auswirken, wie Sie Ihren asynchronen F#-Code schreiben.

Eine Möglichkeit besteht darin, .NET-Tasks direkt mit 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

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

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

Es gibt bereits ein Async.AwaitTask, das 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-Tasks direkt in F#

In F# können Sie Tasks 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 weist die printTotalFileBytesUsingTasks-Funktion den Typ string -> Task<unit> auf. Durch Aufrufen der Funktion wird die Task ausgeführt. 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 besteht 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.

Beispielsweise kann eine Berechnung abhängig von der jeweiligen Verarbeitung sogar 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 Möglichkeiten bietet, eine asynchrone Berechnung im aktuellen Thread (oder explizit nicht im aktuellen Thread) zu starten, ist die Asynchronität im Allgemeinen an keine bestimmte Threadingstrategie gebunden.

Siehe auch