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í:

  • Prezentování procesu serveru, který může obsluhovat velký počet souběžných příchozích požadavků, a současně minimalizovat systémové prostředky obsazené při zpracování požadavků čeká vstupy ze systémů nebo služeb, které jsou pro tento proces externí.
  • 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.

Asynchrony definované

Předchozí bod - že asynchrony je nezávislý na využití více vláken - stojí za to vysvětlit trochu dále. 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.
  • Paralelnost; 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 orthogonální koncepty, ale mohou být snadno nafoukané, zejména když se používají společně. Možná budete například muset paralelně spustit několik asynchronních výpočtů. Tato relace neznamená, že paralelismus ani asynchronní znaménnost vzájemně znamenají.

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". A 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> s task { } výrazy, které představují spuštěnou úlohu .NET.

Obecně byste měli zvážit použití task {…}async {…} v novém kódu, pokud spolupracujete s knihovnami .NET, které používají úlohy, a pokud nespoléháte na asynchronní tailcalls nebo implicitní šíření tokenu 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 v těle, což převede 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. Musí být explicitně spuštěny, aby se skutečně spustily. 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. Async<'T[]> Vytvořte plán, který plánuje a spouští printTotalFileBytes výpočty paralelně při spuštění.
  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 blokujícími Async.RunSynchronously, dokud se neskončí.

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 provádění v pořadí prvků, které argv se neplánují paralelně. 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čet sdílí token zrušení s nadřazeným výpočtem. 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>>

Ideální v těchto situacích:

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

Co je potřeba hlídat:

  • Paralelní plánování několika výpočtů Async.StartChild není stejné jako jejich paralelní plánování. Pokud chcete plánovat výpočty paralelně, použijte Async.Parallel.
  • Zrušením nadřazeného výpočtu se aktivuje 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

Ideální v těchto situacích:

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

Co je potřeba hlídat:

  • Kód v asynchronním výpočtu se spustí na libovolném vlákně, na které se má naplánovat. 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

Provede 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>

Ideální v těchto situacích:

  • Když potřebujete volat rozhraní .NET API, které představuje Task<TResult> výsledek asynchronního výpočtu.

Co je potřeba hlídat:

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

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.

Co je potřeba hlídat:

  • 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ždy, když skončí naplánované. 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í.

Co je potřeba hlídat:

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

Ideální v těchto situacích:

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

Co je potřeba hlídat:

  • 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>, vrací .Async<Choice<'T, exn>> Pokud se daná Async<'T> hodnota úspěšně dokončí, Choice1Of2 vrátí se výsledná hodnota. Pokud je před dokončením vyvolána výjimka, Choice2of2 vrátí se s vyvolanou 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>>

Ideální v těchto situacích:

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

Co je potřeba hlídat:

  • Při použití kombinovaných nebo sekvencovaných asynchronních výpočtů se zahrnutí výpočtů ú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>

Ideální v těchto situacích:

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

Co je potřeba hlídat:

  • 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 zahození výsledků tak, aby odpovídaly podpisu 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 je výpočet výnosný. Toto volání blokuje.

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.

Co je potřeba hlídat:

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

Async.Start

Spustí asynchronní výpočet, který se vrátí unit ve fondu vláken. Nečeká na jeho dokončení ani nečeká na výsledek výjimky. Vnořené výpočty zahájené se Async.Start spouští nezávisle na nadřazené výpočtu, který je volal. Jejich životnost není svázaná 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.
  • Nemáte žádné informace o výjimkách nebo o výjimkách sestav, které jsou výsledkem provádění.

Co je potřeba hlídat:

  • Výjimky vyvolané výpočty zahájené s Async.Start volajícím se nerozšířují. Zásobník volání bude zcela unwound.
  • Jakákoli práce (například volání printfn) spuštěná Async.Start s sebou nezpůsobí, že dojde k efektu v hlavním vlákně provádění programu.

Spolupráce s .NET

Pokud používáte async { } programování, možná budete muset spolupracovat s knihovnou .NET nebo základem kódu jazyka C#, který používá asynchronní async/await-style asynchronní programování. Vzhledem k tomu, že jazyk C# a většina knihoven .NET používají Task<TResult> tyto typy a Task typy jako jejich základní abstrakce, může to změnit způsob psaní asynchronního kódu jazyka 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 můžete použít Async.StartAsTask 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 začít a očekávat Task typy z asynchronního výpočtu jazyka 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 začne spouštět úlohu. Volání, které task.Wait() čeká na dokončení úkolu.

Vztah k více vláknům

I když je v tomto článku uvedeno vlákno, je potřeba si zapamatovat 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, půjčovat je po malou dobu, aby bylo možné provádět užitečnou práci mezi obdobími čekání (například při přenosu síťového hovoru).

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é