Dela via


Asynkron programmering i F#

Asynkron programmering är en mekanism som är nödvändig för moderna program av olika skäl. Det finns två primära användningsfall som de flesta utvecklare kommer att stöta på:

  • Presentera en serverprocess som kan betjäna ett stort antal samtidiga inkommande begäranden, samtidigt som systemresurserna minimeras medan bearbetning av begäranden väntar på indata från system eller tjänster utanför den processen
  • Upprätthålla ett dynamiskt användargränssnitt eller en huvudtråd samtidigt som bakgrundsarbetet fortsätter

Även om bakgrundsarbete ofta omfattar användning av flera trådar är det viktigt att tänka på begreppen asynkron och multitrådning separat. I själva verket är de separata problem, och den ena antyder inte den andra. I den här artikeln beskrivs de separata begreppen mer detaljerat.

Asynkronitet definieras

Föregående punkt – att asynkronitet är oberoende av användningen av flera trådar – är värd att förklara lite längre. Det finns tre begrepp som ibland är relaterade, men strikt oberoende av varandra:

  • Samtidighet; när flera beräkningar körs under överlappande tidsperioder.
  • Parallellitet; när flera beräkningar eller flera delar av en enda beräkning körs på exakt samma gång.
  • Asynkron; när en eller flera beräkningar kan köras separat från huvudprogrammets flöde.

Alla tre är ortoggoniska begrepp, men kan enkelt sammanflätas, särskilt när de används tillsammans. Du kan till exempel behöva köra flera asynkrona beräkningar parallellt. Den här relationen innebär inte att parallellitet eller asynkronitet innebär varandra.

Om du tänker på etymologin för ordet "asynkron" finns det två delar:

  • "a", som betyder "inte".
  • "synkron", vilket betyder "samtidigt".

När du sätter ihop dessa två termer ser du att "asynkron" betyder "inte samtidigt". Det var allt! Det finns ingen konsekvens av samtidighet eller parallellitet i den här definitionen. Detta gäller även i praktiken.

I praktiken schemaläggs asynkrona beräkningar i F# att köras oberoende av huvudprogrammets flöde. Den här oberoende körningen innebär inte samtidighet eller parallellitet, och det innebär inte heller att en beräkning alltid sker i bakgrunden. I själva verket kan asynkrona beräkningar till och med köras synkront, beroende på beräkningens natur och den miljö som beräkningen körs i.

Det viktigaste du bör ha är att asynkrona beräkningar är oberoende av huvudprogramflödet. Även om det finns få garantier för när eller hur en asynkron beräkning körs finns det vissa metoder för att orkestrera och schemalägga dem. Resten av den här artikeln utforskar grundläggande begrepp för F#-asynkronisering och hur du använder de typer, funktioner och uttryck som är inbyggda i F#.

Huvudkoncept

I F# är asynkron programmering centrerad kring två grundläggande begrepp: asynkrona beräkningar och uppgifter.

  • Typen Async<'T> med async { } uttryck, som representerar en sammansättningsbar asynkron beräkning som kan startas för att bilda en uppgift.
  • Typen Task<'T> , med task { } uttryck, som representerar en körande .NET-uppgift.

I allmänhet bör du överväga att använda task {…} över async {…} i ny kod om du samverkar med .NET-bibliotek som använder tasks, och om du inte förlitar dig på asynkrona tailcalls eller implicit spridning av annulleringstoken.

Grundläggande begrepp för asynkronisering

Du kan se de grundläggande begreppen för "asynkron" programmering i följande exempel:

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

I exemplet printTotalFileBytesUsingAsync är funktionen av typen string -> Async<unit>. Att anropa funktionen kör faktiskt inte den asynkrona beräkningen. I stället returneras en Async<unit> som fungerar som en specifikation av det arbete som ska köras asynkront. Den anropar Async.AwaitTask i sin brödtext, vilket konverterar resultatet av ReadAllBytesAsync till en lämplig typ.

En annan viktig rad är anropet till Async.RunSynchronously. Det här är en av startfunktionerna för Async-modulen som du måste anropa om du vill köra en Asynkron F#-beräkning.

Det här är en grundläggande skillnad med programmeringsstilen async C#/Visual Basic. I F#kan asynkrona beräkningar betraktas som kalla uppgifter. De måste uttryckligen startas för att faktiskt köras. Detta har vissa fördelar eftersom du kan kombinera och sekvensisera asynkront arbete mycket enklare än i C# eller Visual Basic.

Kombinera asynkrona beräkningar

Här är ett exempel som bygger på det föregående genom att kombinera beräkningar:

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

Som du ser, har funktionen main en hel del fler element. Konceptuellt gör den följande:

  1. Omvandla kommandoradsargumenten till en sekvens med Async<unit> beräkningar med Seq.map.
  2. Skapa en Async<'T[]> som schemalägger och kör printTotalFileBytes beräkningen parallellt när den körs.
  3. Skapa ett Async<unit> som kör parallellberäkningen och ignorera dess resultat (som är en unit[]).
  4. Kör explicit den övergripande sammansatta beräkningen med Async.RunSynchronouslyoch blockera tills den är klar.

När detta program körs, körs printTotalFileBytes parallellt för varje kommandoradsargument. Eftersom asynkrona beräkningar körs oberoende av programflödet finns det ingen definierad ordning där de skriver ut sin information och slutför körningen. Beräkningarna kommer att schemaläggas parallellt, men deras tidskörningsordning är inte garanterad.

Sekvensera asynkrona beräkningar

Eftersom Async<'T> är en arbetsspecifikation snarare än en uppgift som redan körs kan du enkelt utföra mer invecklade omvandlingar. Här är ett exempel som sekvenserar en uppsättning asynkrona beräkningar så att de körs en efter en.

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

Detta schemalägger printTotalFileBytes att köras i samma ordning som elementen i argv i stället för att schemalägga dem så att de körs parallellt. Eftersom varje efterföljande åtgärd inte schemaläggs förrän den föregående beräkningen blivit utförd, sekvenseras beräkningarna så att det inte finns någon överlappning i deras körning.

Viktiga Async-modulfunktioner

När du skriver asynkron kod i F# interagerar du vanligtvis med ett ramverk som hanterar schemaläggning av beräkningar åt dig. Detta är dock inte alltid fallet, så det är bra att förstå de olika funktioner som kan användas för att schemalägga asynkront arbete.

Eftersom Asynkrona F#-beräkningar är en arbetsspecifikation i stället för en representation av arbete som redan körs, måste de uttryckligen startas med en startfunktion. Det finns många Async-startmetoder som är användbara i olika kontexter. I följande avsnitt beskrivs några av de vanligaste startfunktionerna.

Async.StartChild

Startar en delberäkning inom en asynkron beräkning. Detta gör att flera asynkrona beräkningar kan köras samtidigt. Den underordnade beräkningen delar en annulleringstoken med den överordnade beräkningen. Om den överordnade beräkningen avbryts avbryts även den underordnade beräkningen.

Underskrift:

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

När du ska använda:

  • När du vill köra flera asynkrona beräkningar samtidigt i stället för en i taget, men inte har schemalagt dem parallellt.
  • När du vill slå samman livslängden för en underordnad beräkning med den för en överordnad beräkning.

Vad du bör se upp för:

  • Att starta flera beräkningar med Async.StartChild är inte detsamma som att schemalägga dem parallellt. Om du vill schemalägga beräkningar parallellt använder du Async.Parallel.
  • Att avbryta en överordnad beräkning kommer att utlösa avbrott av alla underordnade beräkningar den startade.

Async.StartImmediate

Kör en asynkron beräkning med början omedelbart på den aktuella operativsystemtråden. Det här är användbart om du behöver uppdatera något i den anropande tråden under beräkningen. Om en asynkron beräkning till exempel måste uppdatera ett användargränssnitt (till exempel uppdatera ett förloppsfält) ska det Async.StartImmediate användas.

Underskrift:

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

När du ska använda:

  • När du behöver uppdatera något i den anropande tråden mitt i en asynkron beräkning.

Vad du bör se upp för:

  • Kod i den asynkrona beräkningen körs på den tråd som en råkar vara schemalagd på. Detta kan vara problematiskt om tråden på något sätt är känslig, till exempel en UI-tråd. I sådana fall Async.StartImmediate är sannolikt olämpligt att använda.

Async.StartAsTask

Kör en beräkning i trådpoolen. Returnerar ett Task<TResult> som kommer att slutföras i motsvarande tillstånd när beräkningen avslutas (ger resultatet, utlöser undantag eller avbryts). Om ingen annulleringstoken anges används standardtoken för annullering.

Underskrift:

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

När du ska använda:

  • När du behöver anropa till ett .NET-API som ger en Task<TResult> för att representera resultatet av en asynkron beräkning.

Vad du bör se upp för:

  • Det här anropet allokerar ytterligare ett Task objekt, vilket kan öka kostnaderna om det används ofta.

Async.Parallel

Schemalägger en sekvens med asynkrona beräkningar som ska köras parallellt, vilket ger en matris med resultat i den ordning de angavs. Graden av parallellitet kan justeras/begränsas genom att ange parametern maxDegreeOfParallelism .

Underskrift:

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

När du ska använda den:

  • Om du behöver köra en uppsättning beräkningar samtidigt och inte är beroende av deras körningsordning.
  • Om du inte behöver resultat från beräkningar som schemalagts parallellt tills alla har slutförts.

Vad du bör se upp för:

  • Du kan bara komma åt den resulterande matrisen med värden när alla beräkningar har slutförts.
  • Beräkningar kommer att köras närhelst de till slut schemaläggs. Det här beteendet innebär att du inte kan förlita dig på ordningen de körs i.

Async.Sequential

Schemalägger en sekvens med asynkrona beräkningar som ska köras i den ordning som de skickas. Den första beräkningen körs, sedan nästa och så vidare. Inga beräkningar körs parallellt.

Underskrift:

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

När du ska använda den:

  • Om du behöver köra flera beräkningar i ordning.

Vad du bör se upp för:

  • Du kan bara komma åt den resulterande matrisen med värden när alla beräkningar har slutförts.
  • Beräkningar körs i den ordning de skickas till den här funktionen, vilket kan innebära att mer tid förflutit innan resultatet returneras.

Async.AwaitTask

Returnerar en asynkron beräkning som väntar på att den angivna Task<TResult> ska slutföras och returnerar resultatet som en Async<'T>

Underskrift:

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

När du ska använda:

  • När du använder ett .NET-API som returnerar en Task<TResult> inom en asynkron F#-beräkning.

Vad du bör se upp för:

  • Undantag omsluts i enlighet med konventionen i AggregateException Aktivitetsparallellt bibliotek. Det här beteendet skiljer sig från hur F#-asynkrona undantag vanligtvis visas.

Async.Catch

Skapar en asynkron beräkning som kör en viss Async<'T>, returnerar en Async<Choice<'T, exn>>. Om den angivna Async<'T> slutförs returneras en Choice1Of2 med det resulterande värdet. Om ett undantag utlöses innan det slutförs returneras ett Choice2of2 med det upphöjda undantaget. Om den används i en asynkron beräkning som i sig består av många beräkningar, och en av dessa beräkningar genererar ett undantag, stoppas den omfattande beräkningen helt.

Underskrift:

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

När du ska använda:

  • När du utför asynkront arbete som kan misslyckas med ett undantag och du vill hantera undantaget i anroparen.

Vad du bör se upp för:

  • När du använder kombinerade eller sekvenserade asynkrona beräkningar stoppas den omfattande beräkningen helt om en av dess "interna" beräkningar utlöser ett undantag.

Async.Ignorera

Skapar en asynkron beräkning som kör den angivna beräkningen men släpper resultatet.

Underskrift:

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

När du ska använda:

  • När du har en asynkron beräkning vars resultat inte behövs. Detta motsvarar ignore funktionen för icke-asynkron kod.

Vad du bör se upp för:

  • Om du måste använda Async.Ignore för att du vill använda Async.Start eller en annan funktion som kräver Async<unit>kan du överväga om det är okej att ta bort resultatet. Undvik att ignorera resultat bara för att passa en typsignatur.

Async.RunSynchronously

Kör en asynkron beräkning och väntar på resultatet i den anropande tråden. Sprider ett undantag om beräkningen ger ett. Det här anropet blockerar.

Underskrift:

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

När du ska använda den:

  • Om du behöver det använder du det bara en gång i ett program – vid startpunkten för en körbar fil.
  • När du inte bryr dig om prestanda och vill köra en uppsättning andra asynkrona åtgärder samtidigt.

Vad du bör se upp för:

  • När man anropar Async.RunSynchronously blockeras den anropande tråden tills körningen har slutförts.

Async.Start

Startar asynkron beräkning som returnerar unit i trådpoolen. Väntar inte på att den ska slutföras och/eller observerar ett undantagsresultat. Kapslade beräkningar som startas med Async.Start startas oberoende av den överordnade beräkningen som anropade dem. Deras livslängd är inte kopplad till någon överordnad beräkning. Om den överordnade beräkningen avbryts så avbryts inga underordnade beräkningar.

Underskrift:

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

Använd endast när:

  • Du har en asynkron beräkning som inte ger något resultat och/eller kräver att man bearbetar ett.
  • Du behöver inte veta när en asynkron beräkning slutförs.
  • Du bryr dig inte om vilken tråd en asynkron beräkning körs på.
  • Du behöver inte vara medveten om eller rapportera undantag som härrör från exekveringen.

Vad du bör se upp för:

  • Undantag som genereras av beräkningar som startats med Async.Start sprids inte till anroparen. Anropsstacken kommer att avvecklas helt.
  • Något arbete (till exempel anrop printfn) som startas med Async.Start kommer inte att orsaka att effekten inträffar på huvudtråden under ett programs körning.

Samverka med .NET

Om du använder async { }-programmering kan du behöva samverka med ett .NET-bibliotek eller en C#-kodbas som använder asynkron programmering av typen async/await. Eftersom C# och majoriteten av .NET-biblioteken använder typerna Task<TResult> och Task som sina kärnabstraktioner kan detta ändra hur du skriver din Asynkrona F#-kod.

Ett alternativ är att växla till att skriva .NET-uppgifter direkt med hjälp av task { }. Du kan också använda Async.AwaitTask funktionen för att invänta en .NET-asynkron beräkning:

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

Du kan använda Async.StartAsTask funktionen för att skicka en asynkron beräkning till en .NET-anropare:

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

Om du vill arbeta med API:er som använder Task (dvs. .NET async-beräkningar som inte returnerar ett värde) kan du behöva lägga till ytterligare en funktion som konverterar en Async<'T> till en Task:

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

Det finns redan en Async.AwaitTask som accepterar en Task som indata. Med den här och den tidigare definierade startTaskFromAsyncUnit funktionen kan du starta och invänta Task typer från en F#-asynkron beräkning.

Skriva .NET-uppgifter direkt i F#

I F# kan du skriva uppgifter direkt med hjälp av task { }, till exempel:

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

I exemplet printTotalFileBytesUsingTasks är funktionen av typen string -> Task<unit>. Om du anropar funktionen startas aktiviteten. Anropet till task.Wait() väntar på att uppgiften ska slutföras.

Relation till multitrådning

Även om trådning nämns i hela den här artikeln finns det två viktiga saker att komma ihåg:

  1. Det finns ingen tillhörighet mellan en asynkron beräkning och en tråd, såvida den inte uttryckligen startas i den aktuella tråden.
  2. Asynkron programmering i F# är inte en abstraktion för flera trådar.

En beräkning kan till exempel faktiskt köras på anroparens tråd, beroende på arbetets natur. En beräkning kan också "hoppa" mellan trådar och låna dem under en kortare tid för att göra nyttigt arbete under väntetider (till exempel när ett nätverksanrop är på väg).

Även om F# ger vissa möjligheter att starta en asynkron beräkning på den aktuella tråden (eller uttryckligen inte på den aktuella tråden), är asynkron i allmänhet inte associerad med en viss trådstrategi.

Se även