Share via


Asynchroon programmeren in F#

Asynchrone programmering is een mechanisme dat essentieel is voor moderne toepassingen om verschillende redenen. Er zijn twee primaire use cases die de meeste ontwikkelaars tegenkomen:

  • Het presenteren van een serverproces dat een aanzienlijk aantal gelijktijdige binnenkomende aanvragen kan verwerken, terwijl het minimaliseren van de systeembronnen die worden gebruikt terwijl de aanvraagverwerking wacht op invoer van systemen of services buiten dat proces
  • Een responsieve gebruikersinterface of hoofdthread onderhouden terwijl tegelijkertijd het achtergrondwerk wordt voortgezet

Hoewel achtergrondwerk vaak betrekking heeft op het gebruik van meerdere threads, is het belangrijk om de concepten van asynchroon en multithreading afzonderlijk te overwegen. In feite zijn ze afzonderlijke zorgen en één impliceert niet de andere. In dit artikel worden de afzonderlijke concepten in meer detail beschreven.

Asynchroon gedefinieerd

Het vorige punt, dat asynchroon is, is onafhankelijk van het gebruik van meerdere threads, het is de moeite waard een beetje verder uit te leggen. Er zijn drie concepten die soms gerelateerd zijn, maar strikt onafhankelijk van elkaar:

  • Concurrency; wanneer meerdere berekeningen worden uitgevoerd in overlappende perioden.
  • Parallellisme; wanneer meerdere berekeningen of meerdere onderdelen van één berekening op exact hetzelfde moment worden uitgevoerd.
  • Asynchroon; wanneer een of meer berekeningen afzonderlijk van de hoofdprogrammastroom kunnen worden uitgevoerd.

Alle drie zijn orthogonale concepten, maar kunnen eenvoudig worden samengevoegd, vooral wanneer ze samen worden gebruikt. U moet bijvoorbeeld mogelijk meerdere asynchrone berekeningen parallel uitvoeren. Deze relatie betekent niet dat parallellisme of asynchroon elkaar impliceren.

Als u de etymologie van het woord 'asynchroon' beschouwt, zijn er twee onderdelen betrokken:

  • "a", wat "niet" betekent.
  • "synchroon", wat 'tegelijkertijd' betekent.

Wanneer u deze twee termen samenbrengt, ziet u dat 'asynchroon' 'niet tegelijkertijd' betekent. Dat is het! Er is geen implicatie van gelijktijdigheid of parallelle uitvoering in deze definitie. Dit geldt ook in de praktijk.

In praktische termen worden asynchrone berekeningen in F# gepland om onafhankelijk van de hoofdprogrammastroom uit te voeren. Deze onafhankelijke uitvoering impliceert geen gelijktijdigheid of parallelle uitvoering en impliceert ook niet dat een berekening altijd op de achtergrond plaatsvindt. Asynchrone berekeningen kunnen zelfs synchroon worden uitgevoerd, afhankelijk van de aard van de berekening en de omgeving waarin de berekening wordt uitgevoerd.

Het belangrijkste wat u moet hebben, is dat asynchrone berekeningen onafhankelijk zijn van de hoofdprogrammastroom. Hoewel er weinig garanties zijn over wanneer of hoe een asynchrone berekening wordt uitgevoerd, zijn er enkele benaderingen voor het organiseren en plannen van deze berekeningen. In de rest van dit artikel worden de kernconcepten voor F# asynchroon besproken en wordt uitgelegd hoe u de typen, functies en expressies gebruikt die zijn ingebouwd in F#.

Basisconcepten

In F# is asynchrone programmering gecentreerd rond twee kernconcepten: asynchrone berekeningen en taken.

  • Het Async<'T> type met async { } expressies, dat een composeerbare asynchrone berekening vertegenwoordigt die kan worden gestart om een taak te vormen.
  • Het Task<'T> type, met task { } expressies, die een uitvoerende .NET-taak vertegenwoordigt.

Over het algemeen moet u overwegen om nieuwe code te async {…} gebruiken task {…} als u werkt met .NET-bibliotheken die gebruikmaken van taken en als u niet vertrouwt op asynchrone code tailcalls of impliciete doorgifte van annuleringstokens.

Kernconcepten van asynchroon

In het volgende voorbeeld ziet u de basisconcepten van 'asynchrone' programmering:

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

In het voorbeeld is de printTotalFileBytesUsingAsync functie van het type string -> Async<unit>. Als u de functie aanroept, wordt de asynchrone berekening niet daadwerkelijk uitgevoerd. In plaats daarvan wordt er een Async<unit> geretourneerd die fungeert als een specificatie van het werk dat asynchroon moet worden uitgevoerd. Het roept Async.AwaitTask in de hoofdtekst aan, waardoor het resultaat wordt ReadAllBytesAsync geconverteerd naar een geschikt type.

Een andere belangrijke regel is de aanroep naar Async.RunSynchronously. Dit is een van de beginfuncties van de Async-module die u moet aanroepen als u daadwerkelijk een A#-asynchrone berekening wilt uitvoeren.

Dit is een fundamenteel verschil met de C#/Visual Basic-stijl van async programmeren. In F# kunnen asynchrone berekeningen worden beschouwd als koude taken. Ze moeten expliciet worden gestart om daadwerkelijk uit te voeren. Dit heeft enkele voordelen, omdat u asynchroon werk veel gemakkelijker kunt combineren en sequentieereren dan in C# of Visual Basic.

Asynchrone berekeningen combineren

Hier volgt een voorbeeld dat voortbouwt op de vorige door berekeningen te combineren:

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

Zoals u kunt zien, heeft de main functie nog een paar elementen. Conceptueel doet dit het volgende:

  1. Transformeer de opdrachtregelargumenten in een reeks Async<unit> berekeningen met Seq.map.
  2. Maak een Async<'T[]> die de printTotalFileBytes berekeningen parallel plant en uitvoert wanneer deze wordt uitgevoerd.
  3. Maak een Async<unit> waarmee de parallelle berekening wordt uitgevoerd en het resultaat wordt genegeerd (een unit[]).
  4. Voer expliciet de totale samengestelde berekening uit met Async.RunSynchronously, die wordt geblokkeerd totdat deze is voltooid.

Wanneer dit programma wordt uitgevoerd, printTotalFileBytes wordt deze parallel uitgevoerd voor elk opdrachtregelargument. Omdat asynchrone berekeningen onafhankelijk van de programmastroom worden uitgevoerd, is er geen gedefinieerde volgorde waarin ze hun gegevens afdrukken en de uitvoering voltooien. De berekeningen worden parallel gepland, maar hun uitvoeringsvolgorde is niet gegarandeerd.

Asynchrone berekeningen sequentieer

Omdat Async<'T> dit een specificatie is van werk in plaats van een taak die al wordt uitgevoerd, kunt u eenvoudig complexere transformaties uitvoeren. Hier volgt een voorbeeld waarmee een set Asynchrone berekeningen wordt gesequentieerd, zodat ze er achter elkaar worden uitgevoerd.

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

Hiermee wordt de printTotalFileBytes uitvoering gepland in de volgorde van de elementen van argv in plaats van ze parallel te plannen. Omdat elke opeenvolgende bewerking pas wordt gepland nadat de voorgaande berekening is voltooid, worden de berekeningen gesequentieerd zodat de uitvoering ervan niet overlapt.

Belangrijke functies van Async-module

Wanneer u asynchrone code schrijft in F#, werkt u meestal met een framework dat de planning van berekeningen voor u afhandelt. Dit is echter niet altijd het geval, dus het is goed om de verschillende functies te begrijpen die kunnen worden gebruikt om asynchroon werk te plannen.

Omdat F# asynchrone berekeningen een specificatie zijn van werk in plaats van een weergave van werk dat al wordt uitgevoerd, moeten ze expliciet worden gestart met een beginfunctie. Er zijn veel Asynchrone beginmethoden die nuttig zijn in verschillende contexten. In de volgende sectie worden enkele van de meest voorkomende beginfuncties beschreven.

Async.StartChild

Hiermee start u een onderliggende berekening binnen een asynchrone berekening. Hierdoor kunnen meerdere asynchrone berekeningen gelijktijdig worden uitgevoerd. De onderliggende berekening deelt een annuleringstoken met de bovenliggende berekening. Als de bovenliggende berekening wordt geannuleerd, wordt de onderliggende berekening ook geannuleerd.

Handtekening:

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

Wanneer gebruiken:

  • Als u meerdere asynchrone berekeningen tegelijk wilt uitvoeren in plaats van één voor één, maar deze niet parallel wilt plannen.
  • Wanneer u de levensduur van een onderliggende berekening wilt koppelen aan die van een bovenliggende berekening.

Waar moet u op letten:

  • Het starten van meerdere berekeningen met Async.StartChild is niet hetzelfde als het parallel plannen ervan. Als u berekeningen parallel wilt plannen, gebruikt Async.Parallelu .
  • Als u een bovenliggende berekening annuleert, worden alle onderliggende berekeningen die zijn gestart, geannuleerd.

Async.StartImmediate

Voert een asynchrone berekening uit, die direct begint op de huidige thread van het besturingssysteem. Dit is handig als u iets moet bijwerken op de aanroepende thread tijdens de berekening. Als een asynchrone berekening bijvoorbeeld een gebruikersinterface moet bijwerken (zoals het bijwerken van een voortgangsbalk), Async.StartImmediate moet deze worden gebruikt.

Handtekening:

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

Wanneer gebruiken:

  • Wanneer u iets moet bijwerken op de aanroepende thread in het midden van een asynchrone berekening.

Waar moet u op letten:

  • Code in de asynchrone berekening wordt uitgevoerd op de thread waarop een moet worden gepland. Dit kan problematisch zijn als die thread op een of andere manier gevoelig is, zoals een UI-thread. In dergelijke gevallen Async.StartImmediate is het waarschijnlijk ongepast om te gebruiken.

Async.StartAsTask

Voert een berekening uit in de threadpool. Retourneert een Task<TResult> die wordt voltooid in de bijbehorende status zodra de berekening is beëindigd (produceert het resultaat, genereert uitzondering of wordt geannuleerd). Als er geen annuleringstoken is opgegeven, wordt het standaardannuleringstoken gebruikt.

Handtekening:

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

Wanneer gebruiken:

  • Wanneer u een .NET-API moet aanroepen die resulteert in een Task<TResult> weergave van het resultaat van een asynchrone berekening.

Waar moet u op letten:

  • Met deze aanroep wordt een extra Task object toegewezen, waardoor de overhead kan toenemen als deze vaak wordt gebruikt.

Async.Parallel

Hiermee wordt een reeks asynchrone berekeningen gepland die parallel moeten worden uitgevoerd, wat resulteert in een matrix met resultaten in de volgorde waarin ze zijn opgegeven. De mate van parallelle uitvoering kan optioneel worden afgestemd/beperkt door de maxDegreeOfParallelism parameter op te geven.

Handtekening:

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

Wanneer moet u deze gebruiken:

  • Als u een set berekeningen tegelijk moet uitvoeren en geen vertrouwen hebt op de volgorde van uitvoering.
  • Als u geen resultaten nodig hebt van berekeningen die parallel zijn gepland totdat ze allemaal zijn voltooid.

Waar moet u op letten:

  • U hebt alleen toegang tot de resulterende matrix met waarden zodra alle berekeningen zijn voltooid.
  • Berekeningen worden uitgevoerd wanneer ze uiteindelijk worden gepland. Dit gedrag betekent dat u niet kunt vertrouwen op hun volgorde van uitvoering.

Async.Sequentiële

Hiermee wordt een reeks asynchrone berekeningen gepland die moeten worden uitgevoerd in de volgorde waarin ze worden doorgegeven. De eerste berekening wordt uitgevoerd, vervolgens de volgende, enzovoort. Er worden geen berekeningen parallel uitgevoerd.

Handtekening:

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

Wanneer moet u deze gebruiken:

  • Als u meerdere berekeningen in volgorde wilt uitvoeren.

Waar moet u op letten:

  • U hebt alleen toegang tot de resulterende matrix met waarden zodra alle berekeningen zijn voltooid.
  • Berekeningen worden uitgevoerd in de volgorde waarin ze worden doorgegeven aan deze functie, wat kan betekenen dat er meer tijd verstreken wordt voordat de resultaten worden geretourneerd.

Async.AwaitTask

Retourneert een asynchrone berekening die wacht totdat de gegeven Task<TResult> is voltooid en retourneert het resultaat als een Async<'T>

Handtekening:

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

Wanneer gebruiken:

  • Wanneer u een .NET-API gebruikt die een Task<TResult> Asynchrone F#-berekening retourneert.

Waar moet u op letten:

  • Uitzonderingen worden verpakt volgens AggregateException de conventie van de taakparallelbibliotheek. Dit gedrag verschilt van de manier waarop F# asynchroon in het algemeen uitzonderingen optreedt.

Async.Catch

Hiermee maakt u een asynchrone berekening waarmee een bepaalde Async<'T>berekening wordt uitgevoerd en een Async<Choice<'T, exn>>. Als de opgegeven Async<'T> waarde is voltooid, wordt er een Choice1Of2 geretourneerd met de resulterende waarde. Als er een uitzondering wordt gegenereerd voordat deze is voltooid, wordt er een Choice2of2 geretourneerd met de verhoogde uitzondering. Als deze wordt gebruikt voor een asynchrone berekening die zelf bestaat uit veel berekeningen en een van deze berekeningen een uitzondering genereert, wordt de omvattende berekening volledig gestopt.

Handtekening:

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

Wanneer gebruiken:

  • Wanneer u asynchroon werk uitvoert dat kan mislukken met een uitzondering en u deze uitzondering in de aanroeper wilt afhandelen.

Waar moet u op letten:

  • Wanneer u gecombineerde of gesequentieerde asynchrone berekeningen gebruikt, stopt de omvattende berekening volledig als een van de interne berekeningen een uitzondering genereert.

Async.Ignore

Hiermee maakt u een asynchrone berekening die de opgegeven berekening uitvoert, maar het resultaat ervan daalt.

Handtekening:

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

Wanneer gebruiken:

  • Wanneer u een asynchrone berekening hebt waarvan het resultaat niet nodig is. Dit is vergelijkbaar met de ignore functie voor niet-asynchrone code.

Waar moet u op letten:

  • Als u moet gebruiken Async.Ignore omdat u wilt gebruiken Async.Start of een andere functie die vereist Async<unit>is, overweeg dan of het verwijderen van het resultaat in orde is. Vermijd het verwijderen van resultaten om alleen een typehandtekening aan te passen.

Async.RunSynchronly

Voert een asynchrone berekening uit en wacht op het resultaat van de aanroepende thread. Hiermee wordt een uitzondering doorgegeven als de berekening één oplevert. Deze aanroep blokkeert.

Handtekening:

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

Wanneer moet u deze gebruiken:

  • Als u deze nodig hebt, gebruikt u deze slechts eenmaal in een toepassing, op het ingangspunt voor een uitvoerbaar bestand.
  • Als u niet om prestaties geeft en een set andere asynchrone bewerkingen tegelijk wilt uitvoeren.

Waar moet u op letten:

  • Het aanroepen blokkeert de aanroepende Async.RunSynchronously thread totdat de uitvoering is voltooid.

Async.Start

Hiermee start u een asynchrone berekening die wordt geretourneerd unit in de threadpool. Wacht niet tot de voltooiing is voltooid en/of bekijkt een uitzonderingsresultaat. Geneste berekeningen die zijn gestart, Async.Start worden onafhankelijk van de bovenliggende berekening gestart die ze worden genoemd. Hun levensduur is niet gekoppeld aan een bovenliggende berekening. Als de bovenliggende berekening wordt geannuleerd, worden er geen onderliggende berekeningen geannuleerd.

Handtekening:

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

Alleen gebruiken wanneer:

  • U hebt een asynchrone berekening die geen resultaat oplevert en/of verwerking van een berekening vereist.
  • U hoeft niet te weten wanneer een asynchrone berekening is voltooid.
  • Het maakt u niet uit op welke thread een asynchrone berekening wordt uitgevoerd.
  • U hoeft zich niet bewust te zijn van uitzonderingen of uitzonderingen te rapporteren die het gevolg zijn van de uitvoering.

Waar moet u op letten:

  • Uitzonderingen die zijn gegenereerd door berekeningen die zijn gestart, Async.Start worden niet doorgegeven aan de aanroeper. De aanroepstack is volledig onwikkeld.
  • Elk werk (zoals aanroepen printfn) waarmee is gestart Async.Start , zorgt er niet voor dat het effect plaatsvindt op de hoofdthread van de uitvoering van een programma.

Samenwerken met .NET

async { } Als u programmeren gebruikt, moet u mogelijk samenwerken met een .NET-bibliotheek of C#-codebasis die gebruikmaakt van asynchrone asynchrone programmering in asynchrone stijl. Omdat C# en het merendeel van de .NET-bibliotheken de Task<TResult> en Task typen gebruiken als kernabstracties, kan dit veranderen hoe u uw Asynchrone F#-code schrijft.

Een optie is om rechtstreeks over te schakelen naar het schrijven van .NET-taken met behulp van task { }. U kunt de Async.AwaitTask functie ook gebruiken om te wachten op een asynchrone .NET-berekening:

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

U kunt de Async.StartAsTask functie gebruiken om een asynchrone berekening door te geven aan een .NET-aanroeper:

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

Als u wilt werken met API's die gebruikmaken van (dat wil gezegd Task .NET asynchrone berekeningen die geen waarde retourneren), moet u mogelijk een extra functie toevoegen waarmee een Async<'T> wordt geconverteerd naar een Task:

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

Er is al een Async.AwaitTask die een Task als invoer accepteert. Met deze en de eerder gedefinieerde startTaskFromAsyncUnit functie kunt u typen starten en wachten Task op een F#-asynchrone berekening.

.NET-taken rechtstreeks in F schrijven#

In F# kunt u taken rechtstreeks schrijven met task { }bijvoorbeeld:

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

In het voorbeeld is de printTotalFileBytesUsingTasks functie van het type string -> Task<unit>. Het aanroepen van de functie begint met het uitvoeren van de taak. De aanroep om te task.Wait() wachten totdat de taak is voltooid.

Relatie met multithreading

Hoewel threading in dit artikel wordt genoemd, zijn er twee belangrijke dingen om te onthouden:

  1. Er is geen affiniteit tussen een asynchrone berekening en een thread, tenzij deze expliciet is gestart op de huidige thread.
  2. Asynchrone programmering in F# is geen abstractie voor multithreading.

Een berekening kan bijvoorbeeld daadwerkelijk worden uitgevoerd op de thread van de aanroeper, afhankelijk van de aard van het werk. Een berekening kan ook 'springen' tussen threads, waardoor ze gedurende een kleine hoeveelheid tijd kunnen worden geleend om nuttige werkzaamheden uit te voeren tussen perioden van 'wachten' (bijvoorbeeld wanneer een netwerkoproep onderweg is).

Hoewel F# een aantal mogelijkheden biedt om een asynchrone berekening te starten op de huidige thread (of expliciet niet op de huidige thread), wordt asynchroon over het algemeen niet gekoppeld aan een bepaalde threadingstrategie.

Zie ook