Sdílet prostřednictvím


Asynchronní programování v F#

Asynchronní programování je mechanismus, který je nezbytný pro moderní aplikace z různých důvodů. Většina vývojářů se setká se dvěma hlavními případy použití:

  • Představujeme proces serveru, který může obsluhovat značný počet souběžných příchozích požadavků, při současném minimalizování systémových prostředků využívaných během čekání na vstupy ze systémů nebo služeb, které jsou externí vůči tomuto procesu.
  • Udržování responzivního uživatelského rozhraní nebo hlavního vlákna při souběžné práci na pozadí

I když práce na pozadí často zahrnuje využití více vláken, je důležité vzít v úvahu koncepty asynchrony a více vláken samostatně. Ve skutečnosti se jedná o samostatné obavy a jeden z nich neznamená druhý. Tento článek podrobněji popisuje samostatné koncepty.

Asynchronicita definována

Předchozí bod - že asynchronnost je nezávislá na využití více vláken - stojí za to podrobněji vysvětlit. Existují tři koncepty, které někdy souvisejí, ale přísně nezávislé na sobě:

  • Souběžnost; při provádění více výpočtů v překrývajících se časových obdobích.
  • Rovnoběžnost; při spuštění více výpočtů nebo několika částí jednoho výpočtu ve stejnou dobu.
  • Asynchronní; pokud se jeden nebo více výpočtů může spouštět odděleně od hlavního toku programu.

Všechny tři jsou ortogonální koncepty, ale mohou být snadno zaměněny, zejména když se používají společně. Možná budete například muset paralelně spustit několik asynchronních výpočtů. Tento vztah neznamená, že paralelismus nebo asynchronie vzájemně implikují jeden druhého.

Pokud uvažujete o etymologii slova "asynchronní", existují dvě části:

  • "a", což znamená "ne".
  • "synchronní", což znamená "ve stejnou dobu".

Když tyto dva termíny spojíte dohromady, uvidíte, že "asynchronní" znamená "ne ve stejnou dobu". To je to! V této definici neexistuje žádný implikace souběžnosti ani paralelismu. To platí i v praxi.

V praxi jsou asynchronní výpočty v jazyce F# naplánovány tak, aby se spouštěly nezávisle na hlavním toku programu. Toto nezávislé spuštění neznamená souběžnost ani paralelismus, ani neznamená, že výpočet se vždy děje na pozadí. Asynchronní výpočty se můžou dokonce spouštět synchronně v závislosti na povaze výpočtu a prostředí, ve které se výpočetní výkon provádí.

Hlavní poznatky, které byste měli mít, je, že asynchronní výpočty jsou nezávislé na hlavním toku programu. I když existuje několik záruk o tom, kdy nebo jak se provádí asynchronní výpočty, existují některé přístupy k jejich orchestraci a plánování. Zbytek tohoto článku popisuje základní koncepty asynchronní synchronizace jazyka F# a způsob použití typů, funkcí a výrazů integrovaných do jazyka F#.

Klíčové koncepty

V jazyce F# je asynchronní programování zaměřené na dva základní koncepty: asynchronní výpočty a úlohy.

  • Typ Async<'T> s async { } výrazy, který představuje kompozibilní asynchronní výpočty, které lze spustit pro vytvoření úkolu.
  • Typ Task<'T>, který zahrnuje task { } výrazy, představující spuštěnou úlohu .NET.

Obecně byste měli zvážit použití task {…} namísto async {…} v novém kódu, pokud spolupracujete s knihovnami .NET, které používají vlákna, a pokud nespoléháte na asynchronní koncová volání nebo implicitní šíření tokenů zrušení.

Základní koncepty async

Základní koncepty "asynchronního" programování si můžete prohlédnout v následujícím příkladu:

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

V příkladu printTotalFileBytesUsingAsync je funkce typu string -> Async<unit>. Volání funkce ve skutečnosti nespustí asynchronní výpočty. Místo toho vrátí Async<unit> funkci, která funguje jako specifikace práce, která se má spouštět asynchronně. Volá Async.AwaitTask ve svém těle, což převádí výsledek ReadAllBytesAsync na odpovídající typ.

Dalším důležitým řádkem je volání Async.RunSynchronously. Jedná se o jednu z funkcí, které spouští asynchronní modul, které budete muset volat, pokud chcete skutečně spustit asynchronní výpočet jazyka F#.

Jedná se o základní rozdíl ve stylu async programování jazyka C#/Visual Basic. V jazyce F# si asynchronní výpočty můžete představit jako studené úlohy. Je potřeba je výslovně spustit, aby mohly opravdu běžet. To má určité výhody, protože umožňuje kombinovat a sekvencovat asynchronní práci mnohem snadněji než v jazyce C# nebo Visual Basic.

Kombinování asynchronních výpočtů

Tady je příklad, který vychází z předchozího příkladu zkombinováním výpočtů:

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

Jak vidíte, main funkce má několik dalších prvků. Koncepčně provede následující:

  1. Transformujte argumenty příkazového Async<unit> řádku na sekvenci výpočtů pomocí Seq.map.
  2. Vytvořte Async<'T[]>, který plánuje a spouští printTotalFileBytes výpočty paralelně během svého běhu.
  3. Vytvořte Async<unit>, která spustí paralelní výpočet a ignoruje jeho výsledek (což je unit[]).
  4. Explicitně spusťte celkový složený výpočet s Async.RunSynchronously a pokračujte blokováním, dokud se nedokončí.

Když se tento program spustí, printTotalFileBytes spustí se paralelně pro každý argument příkazového řádku. Vzhledem k tomu, že asynchronní výpočty se spouštějí nezávisle na toku programu, není definováno žádné pořadí, ve kterém vytisknou informace a dokončí provádění. Výpočty budou naplánovány paralelně, ale jejich pořadí provádění není zaručeno.

Sekvenční asynchronní výpočty

Vzhledem k tomu Async<'T> , že se jedná o specifikaci práce místo již spuštěné úlohy, můžete snadno provádět složitější transformace. Tady je příklad, který sekvencuje sadu asynchronních výpočtů, aby se jedna po druhé spustila.

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

Tím se naplánuje printTotalFileBytes ke spuštění v pořadí prvků argv místo jejich paralelního naplánování. Vzhledem k tomu, že každá následná operace nebude naplánovaná až po dokončení předchozího výpočtu, jsou výpočty sekvencovány tak, aby se jejich provádění nepřekrývaly.

Důležité funkce modulu Async

Při psaní asynchronního kódu v jazyce F# budete obvykle pracovat s architekturou, která zpracovává plánování výpočtů za vás. To však není vždy případ, takže je dobré pochopit různé funkce, které lze použít k naplánování asynchronní práce.

Vzhledem k tomu, že asynchronní výpočty jazyka F# představují specifikaci práce, nikoli reprezentaci již spuštěné práce, musí být explicitně spuštěny s počáteční funkcí. Existuje mnoho asynchronních metod spouštění , které jsou užitečné v různých kontextech. Následující část popisuje některé z nejběžnějších spouštěcích funkcí.

Async.StartChild

Spustí podřízený výpočet v rámci asynchronního výpočtu. To umožňuje souběžné spouštění několika asynchronních výpočtů. Podřízená výpočetní úloha sdílí token zrušení s nadřazenou výpočetní úlohou. Pokud je nadřazený výpočet zrušen, je také zrušen podřízený výpočet.

Podpis:

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

Kdy použít:

  • Pokud chcete souběžně spouštět více asynchronních výpočtů, nikoli jeden současně, ale nechcete je mít naplánované paralelně.
  • Pokud chcete svázat životnost podřízeného výpočtu s nadřazeným výpočtem.

Na co si dát pozor:

  • Zahájení několika výpočtů s Async.StartChild není totéž jako jejich plánování v paralelním režimu. Pokud chcete plánovat výpočty paralelně, použijte Async.Parallel.
  • Zrušením nadřazeného výpočtu dojde ke zrušení všech podřízených výpočtů, které spustil.

Async.StartImmediate

Spustí asynchronní výpočet, který se spustí okamžitě na aktuálním vlákně operačního systému. To je užitečné, pokud potřebujete během výpočtu něco aktualizovat ve volajícím vlákně. Pokud například asynchronní výpočet musí aktualizovat uživatelské rozhraní (například aktualizovat indikátor průběhu), Async.StartImmediate měl by se použít.

Podpis:

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

Kdy použít:

  • Když potřebujete něco aktualizovat ve volajícím vlákně uprostřed asynchronního výpočtu.

Na co si dát pozor:

  • Kód v asynchronním výpočtu poběží na jakémkoli vlákně, na které bude zrovna naplánován. To může být problematické, pokud je vlákno nějakým způsobem citlivé, například vlákno uživatelského rozhraní. V takových případech Async.StartImmediate je pravděpodobně nevhodné použít.

Async.StartAsTask

Vykoná výpočet ve fondu vláken. Task<TResult> Vrátí hodnotu, která bude dokončena v odpovídajícím stavu po ukončení výpočtu (vytvoří výsledek, vyvolá výjimku nebo se zruší). Pokud není zadaný žádný token zrušení, použije se výchozí token zrušení.

Podpis:

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

Kdy použít:

  • Když potřebujete zavolat rozhraní .NET API, které poskytuje Task<TResult> k reprezentaci výsledku asynchronního výpočtu.

Na co si dát pozor:

  • Toto volání přidělí další Task objekt, který může zvýšit režii, pokud je často používán.

Async.Parallel

Naplánuje sekvenci asynchronních výpočtů, které se mají spouštět paralelně a poskytují pole výsledků v pořadí, v jakém byly zadány. Stupeň paralelismu lze volitelně ladit nebo omezovat zadáním parametru maxDegreeOfParallelism .

Podpis:

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

Kdy ji použít:

  • Pokud potřebujete spustit sadu výpočtů najednou a nemusíte se spoléhat na jejich pořadí provádění.
  • Pokud nevyžadujete výsledky z výpočtů naplánovaných paralelně, dokud se nedokončí všechny.

Na co si dát pozor:

  • Výslednou matici hodnot můžete získat pouze po dokončení všech výpočtů.
  • Výpočty budou spuštěny, jakmile budou naplánovány. Toto chování znamená, že nemůžete spoléhat na jejich pořadí provádění.

Async.Sekvenční

Naplánuje sekvenci asynchronních výpočtů, které se mají spustit v pořadí, v jakém jsou předány. První výpočet se spustí, pak další atd. Nespustí se paralelně žádné výpočty.

Podpis:

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

Kdy ji použít:

  • Pokud potřebujete provést více výpočtů v pořadí.

Na co si dát pozor:

  • Výslednou matici hodnot můžete získat pouze po dokončení všech výpočtů.
  • Výpočty budou spuštěny v pořadí, v jakém jsou předány této funkci, což může znamenat, že více času uplyne před vrácením výsledků.

Async.AwaitTask

Vrátí asynchronní výpočet, který čeká na dokončení dané hodnoty Task<TResult> a vrátí výsledek jako Async<'T>

Podpis:

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

Kdy použít:

  • Pokud používáte rozhraní .NET API, které vrací Task<TResult> v rámci asynchronního výpočtu jazyka F#.

Na co si dát pozor:

  • Výjimky jsou zabalené podle AggregateException konvence paralelní knihovny úloh. Toto chování se liší od toho, jak asynchronní jazyk F# obecně zpřístupní výjimky.

Async.Catch

Vytvoří asynchronní výpočet, který spustí danou Async<'T> a vrátí Async<Choice<'T, exn>>. Pokud se daný Async<'T> úspěšně dokončí, je vrácena Choice1Of2 s výslednou hodnotou. Pokud je před dokončením vyvolána výjimka, vrátí se Choice2of2 s danou výjimkou. Pokud se používá u asynchronního výpočtu, který se skládá z mnoha výpočtů, a jeden z těchto výpočtů vyvolá výjimku, zahrnující výpočty se úplně zastaví.

Podpis:

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

Kdy použít:

  • Při provádění asynchronní práce, která může selhat kvůli výjimce a chcete tuto výjimku zpracovat ve volajícím.

Na co si dát pozor:

  • Při použití kombinovaných nebo sekvenčních asynchronních výpočtů se celkový výpočet úplně zastaví, pokud některý z jeho "interních" výpočtů vyvolá výjimku.

Async.Ignore

Vytvoří asynchronní výpočet, který spustí daný výpočet, ale sníží jeho výsledek.

Podpis:

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

Kdy použít:

  • Pokud máte asynchronní výpočet, jehož výsledek není potřeba. To je podobné funkci ignore pro nesynchronní kód.

Na co si dát pozor:

  • Pokud je nutné použít Async.Ignore , protože chcete použít Async.Start nebo jinou funkci, která vyžaduje Async<unit>, zvažte, zda je odstranění výsledku v pořádku. Vyhněte se vyřazování výsledků jen proto, aby odpovídaly signatuře typu.

Async.RunSynchronously

Spustí asynchronní výpočet a očekává jeho výsledek ve volajícím vlákně. Rozšíří výjimku, pokud výpočet vyvolá výjimku. Toto volání je blokující.

Podpis:

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

Kdy ji použít:

  • Pokud ho potřebujete, použijte ho jenom jednou v aplikaci – v vstupním bodě spustitelného souboru.
  • Pokud vás nezajímá výkon a chcete spustit sadu dalších asynchronních operací najednou.

Na co si dát pozor:

  • Volání Async.RunSynchronously blokuje volající vlákno, dokud provádění neskončí.

Async.Start

Spustí asynchronní výpočet, který se vrátí ve fondu vláken jako unit. Nečeká na jeho dokončení a/nebo nezohledňuje výsledek výjimky. Vnořené výpočty, které začínají s Async.Start, jsou spouštěny nezávisle na nadřazeném výpočtu, který je volal. Jejich životnost není svázána s žádným nadřazeným výpočtem. Pokud je nadřazený výpočet zrušen, nebudou zrušeny žádné podřízené výpočty.

Podpis:

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

Použít pouze v případech:

  • Máte asynchronní výpočet, který nevyvolá výsledek nebo vyžaduje zpracování jednoho.
  • Nemusíte vědět, kdy se dokončí asynchronní výpočet.
  • Nezajímá vás, na kterém vlákně běží asynchronní výpočty.
  • Nemusíte být informováni o výjimkách ani je hlásit, které vzniknou při provádění.

Na co si dát pozor:

  • Výjimky vyvolané výpočty zahájenými s Async.Start nejsou předávány volajícímu. Zásobník volání bude zcela rozvinut.
  • Jakákoli práce (například volání printfn) spuštěná s Async.Start nevyvolá efekt v hlavním vlákně programu.

Spolupráce s .NET

Pokud používáte async { } programování, možná budete muset spolupracovat s knihovnou .NET nebo kódovým základem jazyka C#, který využívá asynchronní programování ve stylu async/await. Vzhledem k tomu, že C# a většina knihoven .NET používají typy Task<TResult> a Task jako své základní abstrakce, může to změnit způsob, jakým píšete asynchronní kód v jazyce F#.

Jednou z možností je přepnout na zápis úloh .NET přímo pomocí task { }. Alternativně můžete použít Async.AwaitTask funkci k vyčkání asynchronního výpočtu .NET:

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

Funkci Async.StartAsTask můžete použít k předání asynchronního výpočtu volajícímu .NET.

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

Pokud chcete pracovat s rozhraními API, která používají Task (tj. asynchronní výpočty .NET, které nevrací hodnotu), možná budete muset přidat další funkci, která převede hodnotu Async<'T> na Task:

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

Existuje již objekt Async.AwaitTask , který přijímá Task jako vstup. S touto a dříve definovanou startTaskFromAsyncUnit funkcí můžete zahájit a čekat na Task typy z asynchronního výpočtu v jazyce F#.

Psaní úloh .NET přímo v jazyce F#

V jazyce F# můžete psát úkoly přímo pomocí task { }, například:

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

V příkladu printTotalFileBytesUsingTasks je funkce typu string -> Task<unit>. Volání funkce spustí úlohu. Volání na task.Wait() čeká na dokončení úkolu.

Vztah k vícevláknovosti

I když je v tomto článku zmiňováno vlákna, je třeba pamatovat na dvě důležité věci:

  1. Mezi asynchronním výpočtem a vláknem neexistuje spřažení, pokud není explicitně spuštěno v aktuálním vlákně.
  2. Asynchronní programování v jazyce F# není abstrakcí pro více vláken.

Výpočet může například běžet ve vlákně volajícího v závislosti na povaze práce. Výpočet by také mohl "přeskakovat" mezi vlákny, dočasně je využívat k provádění užitečné práce v obdobích čekání (například při přenosu po síti).

I když jazyk F# poskytuje určité možnosti pro zahájení asynchronního výpočtu v aktuálním vlákně (nebo explicitně ne v aktuálním vlákně), asynchrony obecně není přidružená ke konkrétní strategii dělení na vlákna.

Viz také