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.

Asynkron definierad

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", vilket 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 uppgifter, och om du inte förlitar dig på asynkrona kod tailcalls eller implicit annulleringstokenspridning.

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 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 main har funktionen 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 det här programmet körs printTotalFileBytes körs 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 schemaläggs parallellt, men deras körningsordning garanteras inte.

Sekvensa 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 sekvenser en uppsättning Async-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 ordning på elementen argv i stället för att schemalägga dem parallellt. Eftersom varje efterföljande åtgärd inte schemaläggs förrän den föregående beräkningen har slutförts, sekvenseras beräkningen 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 underordnad beräkning i 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.

Signatur:

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

Används för att:

  • 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 koppla livslängden för en underordnad beräkning till 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.
  • Om du avbryter en överordnad beräkning avbryts alla underordnade beräkningar som 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.

Signatur:

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

Används för att:

  • 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.

Signatur:

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

Används för att:

  • 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 .

Signatur:

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 körs när de schemaläggs. Det här beteendet innebär att du inte kan förlita dig på deras körningsordning.

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.

Signatur:

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>

Signatur:

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

Används för att:

  • När du använder ett .NET-API som returnerar en Task<TResult> 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.

Signatur:

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

Används för att:

  • 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.Ignore

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

Signatur:

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

Används för att:

  • 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.

Signatur:

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:

  • Anrop Async.RunSynchronously blockerar den anropande tråden tills körningen har slutförts.

Async.Start

Startar en asynkron beräkning som returneras 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 avbryts inga underordnade beräkningar.

Signatur:

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 bearbetning av en.
  • 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 körningen.

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 vara helt oansluten.
  • Allt arbete (till exempel anrop printfn) som startas med Async.Start leder inte till att effekten inträffar på huvudtråden i 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 asynkron programmering i asynkron asynkron programmering. 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 väntar tills task.Wait() uppgiften har slutförts.

Relation till flera trådar

Ä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 liten tid för att göra användbart arbete mellan perioder av "väntar" (till exempel när ett nätverksanrop är under överföring).

Ä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