Aszinkron programozás az F-ben#

Az aszinkron programozás olyan mechanizmus, amely különböző okokból elengedhetetlen a modern alkalmazásokhoz. A fejlesztők többsége két elsődleges használati esetet fog tapasztalni:

  • Olyan kiszolgálói folyamat bemutatása, amely jelentős számú egyidejű bejövő kérést képes kiszolgálni, miközben a kérelemfeldolgozás során foglalt rendszererőforrások minimalizálása az adott folyamaton kívüli rendszerek vagy szolgáltatások bemeneteit várja
  • Rugalmas felhasználói felület vagy főszál karbantartása, miközben párhuzamosan halad a háttérmunka

Bár a háttérmunka gyakran több szál használatát is magában foglalja, fontos, hogy külön vegye figyelembe az aszinkron és a többszálas kapcsolat fogalmait. Valójában külön aggodalmak, és az egyik nem utal a másikra. Ez a cikk részletesebben ismerteti a különálló fogalmakat.

Aszinkron definíció

Az előző pont - hogy az aszinkronság független a több szál felhasználásától - érdemes egy kicsit tovább magyarázni. Három fogalom van, amelyek néha kapcsolódnak, de szigorúan függetlenek egymástól:

  • Konkurencia; ha több számítás végrehajtása egymást átfedő időszakokban történik.
  • Párhuzamosság; ha több számítás vagy egyetlen számítás több része pontosan ugyanabban az időben fut.
  • Aszinkronizálás; ha egy vagy több számítás a fő programfolyamattól elkülönítve hajtható végre.

Mindhárom ortogonális fogalom, de könnyen elkonferálható, különösen akkor, ha együtt használják őket. Előfordulhat például, hogy több aszinkron számítást kell párhuzamosan végrehajtania. Ez a kapcsolat nem jelenti azt, hogy a párhuzamosság vagy az aszinkronság egymásra utal.

Ha figyelembe veszi az "aszinkron" szó etimológiáját, két részből áll:

  • "a", azaz "nem".
  • "szinkron", vagyis "egyidejűleg".

Ha összeadja ezt a két kifejezést, látni fogja, hogy az "aszinkron" azt jelenti, hogy "nem egyszerre". Ennyi az egész! Ebben a definícióban nincs hatással az egyidejűség vagy a párhuzamosság. Ez a gyakorlatban is igaz.

Gyakorlati szempontból az F# aszinkron számításait a program a fő programfolyamattól függetlenül hajtja végre. Ez a független végrehajtás nem jelent egyidejűséget vagy párhuzamosságot, és azt sem jelenti, hogy a számítás mindig a háttérben történik. Valójában az aszinkron számítások szinkron módon is végrehajthatók a számítás jellegétől és a számítás környezetétől függően.

A fő teendő az, hogy az aszinkron számítások függetlenek legyenek a fő programfolyamattól. Bár az aszinkron számítások végrehajtásának időpontjára és módjára kevés garancia van, van néhány módszer a vezénylésre és az ütemezésre. A cikk további része az F# aszinkronizálás alapfogalmait és az F#-ba beépített típusokat, függvényeket és kifejezéseket ismerteti.

Alapfogalmak

Az F#-ban az aszinkron programozás két alapvető fogalom köré épül: az aszinkron számításokra és feladatokra.

  • A Async<'T> kifejezéseket tartalmazó async { } típus, amely egy feladat létrehozásához elindítható, komposztábilis aszinkron számítást jelöl.
  • A Task<'T> .NET-feladatot végrehajtó kifejezést tartalmazó típustask { }.

Általában érdemes megfontolni az új kódban való használatot task {…}async {…} , ha feladatokat használó .NET-kódtárakkal dolgozik, és nem támaszkodik az aszinkron kódszkreditálásokra vagy implicit lemondási jogkivonat propagálására.

Az aszinkron alapfogalmai

Az "async" programozás alapfogalmait az alábbi példában tekintheti meg:

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

A példában a printTotalFileBytesUsingAsync függvény típusa string -> Async<unit>. A függvény meghívása valójában nem hajtja végre az aszinkron számítást. Ehelyett egy Async<unit> olyan értéket ad vissza, amely az aszinkron végrehajtáshoz szükséges munka specifikációjaként működik. Async.AwaitTask Meghívja a törzsét, amely az eredményt ReadAllBytesAsync megfelelő típussá alakítja.

Egy másik fontos sor a hívás.Async.RunSynchronously Ez az aszinkron modul egyik kezdő függvénye, amelyet meg kell hívnia, ha ténylegesen F# aszinkron számítást szeretne végrehajtani.

Ez alapvető különbség a C#/Visual Basic programstílusban async . Az F#-ban az aszinkron számítások hideg tevékenységeknek tekinthetők. A végrehajtást explicit módon kell elkezdeni. Ennek van néhány előnye, mivel lehetővé teszi az aszinkron munka összevonását és sorrendjét sokkal egyszerűbben, mint a C# vagy a Visual Basic esetén.

Aszinkron számítások kombinálása

Íme egy példa, amely az előzőre épül számítások kombinálásával:

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

Mint látható, a main függvénynek több eleme is van. Elméletileg a következőket teszi:

  1. A parancssori argumentumok átalakítása számítások sorozatává Async<unit> a következővel Seq.map: .
  2. Hozzon létre egy olyant Async<'T[]> , amely a futtatáskor párhuzamosan ütemezi és futtatja a printTotalFileBytes számításokat.
  3. Hozzon létre egy olyant Async<unit> , amely futtatja a párhuzamos számítást, és figyelmen kívül hagyja annak eredményét (ami egy unit[]).
  4. Explicit módon futtassa a teljes komponált számítást a , blokkolással Async.RunSynchronously, amíg be nem fejeződik.

A program futtatásakor printTotalFileBytes az egyes parancssori argumentumok párhuzamosan futnak. Mivel az aszinkron számítások a programfolyamattól függetlenül futnak, nincs meghatározott sorrend, amelyben kinyomtatják az adataikat, és befejezik a végrehajtást. A számítások párhuzamosan lesznek ütemezve, de a végrehajtás sorrendje nem garantált.

Szekvencia-aszinkron számítások

Mivel Async<'T> a munka specifikációja nem egy már futó feladat, egyszerűbben hajthat végre bonyolult átalakításokat. Íme egy példa, amely aszinkron számítások egy készletét sorrendbe állítja, így egymás után hajtják végre őket.

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

Ez az ütemezés printTotalFileBytes szerint az elemek argv sorrendjében lesz végrehajtva ahelyett, hogy párhuzamosan ütemezze őket. Mivel az egymást követő műveletek csak az előző számítás végrehajtása után lesznek ütemezve, a számítások úgy vannak rendezve, hogy a végrehajtásuk ne legyen átfedésben.

Fontos Async-modulfüggvények

Amikor Aszinkron kódot ír az F#-ban, általában olyan keretrendszerrel fog működni, amely kezeli a számítások ütemezését. Ez azonban nem mindig így van, ezért érdemes megismerni az aszinkron munka ütemezéséhez használható különböző függvényeket.

Mivel az F# aszinkron számítások a munka specifikációi , nem pedig a már végrehajtó munka ábrázolása, ezért explicit módon kell kezdeni őket egy kezdő függvénnyel. Számos aszinkron indítási módszer létezik, amelyek különböző kontextusokban hasznosak. Az alábbi szakasz a leggyakoribb kezdő függvények némelyikét ismerteti.

Async.StartChild

Elindít egy gyermekszámítást egy aszinkron számításon belül. Ez lehetővé teszi több aszinkron számítás egyidejű végrehajtását. A gyermekszámítás egy lemondási jogkivonatot oszt meg a szülőszámítással. Ha a szülőszámítást megszakítja, a gyermekszámítás is megszűnik.

Aláírás:

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

A következő esetekben használja:

  • Ha egyszerre több aszinkron számítást szeretne egyszerre végrehajtani, de nem szeretné párhuzamosan ütemezni őket.
  • Ha a gyermekszámítás élettartamát a szülőszámításhoz szeretné kötni.

Mire figyeljen a következőre:

  • Több számítás Async.StartChild indítása nem ugyanaz, mint a párhuzamos ütemezés. Ha párhuzamosan szeretné ütemezni a számításokat, használja Async.Parallela következőt: .
  • A szülőszámítás lemondása az összes megkezdett gyermekszámítás lemondását váltja ki.

Async.StartImmediate

Aszinkron számítást futtat, amely azonnal elindul az aktuális operációs rendszer szálán. Ez akkor hasznos, ha frissítenie kell valamit a hívó szálon a számítás során. Ha például egy aszinkron számításnak frissítenie kell egy felhasználói felületet (például frissítenie kell egy folyamatjelző sávot), akkor Async.StartImmediate azt kell használni.

Aláírás:

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

A következő esetekben használja:

  • Ha frissítenie kell valamit a hívó szálon egy aszinkron számítás közepén.

Mire figyeljen a következőre:

  • Az aszinkron számításban lévő kód minden olyan szálon fut, amelyen az egyik éppen be van ütemezve. Ez problémás lehet, ha a szál valamilyen módon érzékeny, például egy felhasználói felületi szál. Ilyen esetekben Async.StartImmediate valószínűleg nem megfelelő a használata.

Async.StartAsTask

Végrehajt egy számítást a szálkészletben. Task<TResult> A megfelelő állapotban befejezett eredményt ad vissza, miután a számítás leáll (létrehozza az eredményt, kivételt ad ki vagy megszakítja). Ha nincs megadva lemondási jogkivonat, akkor a rendszer az alapértelmezett lemondási jogkivonatot használja.

Aláírás:

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

A következő esetekben használja:

  • Ha be kell hívnia egy .NET API-t, amely aszinkron Task<TResult> számítások eredményét jeleníti meg.

Mire figyeljen a következőre:

  • Ez a hívás egy további Task objektumot foglal le, amely növelheti a többletterhelést, ha gyakran használják.

Async.Parallel

Az aszinkron számítások sorozatát ütemezi, amelyeket párhuzamosan kell végrehajtani, és az eredmények tömbjét adja meg a megadott sorrendben. A párhuzamosság mértéke igény szerint hangolható/szabályozható a maxDegreeOfParallelism paraméter megadásával.

Aláírás:

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

Mikor érdemes használni:

  • Ha egyszerre kell futtatnia egy számításkészletet, és nem kell függenie a végrehajtás sorrendjéről.
  • Ha nem követeli meg a párhuzamosan ütemezett számítások eredményeit, amíg az összes befejeződött.

Mire figyeljen a következőre:

  • Az eredményként kapott értéktömbhöz csak akkor férhet hozzá, ha az összes számítás befejeződött.
  • A számítások minden alkalommal le lesznek futtatva, amikor végül ütemezettek lesznek. Ez a viselkedés azt jelenti, hogy nem támaszkodhat a végrehajtásuk sorrendjére.

Async.Sequential

Aszinkron számítások sorozatát ütemezi, amelyeket az átadásuk sorrendjében kell végrehajtani. Az első számítást végrehajtjuk, majd a következőt, és így tovább. A számítások párhuzamosan nem lesznek végrehajtva.

Aláírás:

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

Mikor érdemes használni:

  • Ha több számítást kell végrehajtania sorrendben.

Mire figyeljen a következőre:

  • Az eredményként kapott értéktömbhöz csak akkor férhet hozzá, ha az összes számítás befejeződött.
  • A számítások a függvénynek átadott sorrendben lesznek futtatva, ami azt jelentheti, hogy az eredmények visszaadása előtt több idő telik el.

Async.AwaitTask

Egy aszinkron számítást ad vissza, amely megvárja, amíg a megadott Task<TResult> befejeződik, és eredményként adja vissza Async<'T>

Aláírás:

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

A következő esetekben használja:

  • Ha olyan .NET API-t használ, amely egy F# aszinkron számításon belül ad vissza egy Task<TResult> értéket.

Mire figyeljen a következőre:

  • A kivételek a feladat párhuzamos kódtárának konvencióját követve vannak becsomagolva AggregateException . Ez a viselkedés eltér attól, hogy az F# async általában hogyan fedi fel a kivételeket.

Async.Catch

Létrehoz egy aszinkron számítást, amely végrehajt egy adott Async<'T>, visszaadott Async<Choice<'T, exn>>. Ha a megadott Async<'T> művelet sikeresen befejeződött, a függvény az eredményül kapott értékkel ad vissza egy Choice1Of2 értéket. Ha a kivétel a befejezés előtt ki van dobva, akkor a rendszer a létrehozott kivétellel ad vissza egy Choice2of2 kivételt. Ha egy olyan aszinkron számításhoz használják, amely önmagában sok számításból áll, és az egyik számítás kivételt jelent, a teljes számítás leáll.

Aláírás:

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

A következő esetekben használja:

  • Ha olyan aszinkron munkát végez, amely kivétellel meghiúsulhat, és ezt a kivételt a hívóban szeretné kezelni.

Mire figyeljen a következőre:

  • Kombinált vagy szekvenált aszinkron számítások használatakor az átfogó számítás teljesen leáll, ha az egyik "belső" számítás kivételt jelent.

Async.Ignore

Létrehoz egy aszinkron számítást, amely az adott számítást futtatja, de elveti az eredményt.

Aláírás:

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

A következő esetekben használja:

  • Ha olyan aszinkron számítással rendelkezik, amelynek az eredménye nem szükséges. Ez hasonló a ignore nem aszinkron kód függvényéhez.

Mire figyeljen a következőre:

  • Ha azért kell használnia Async.Ignore , mert használni szeretné Async.Start , vagy egy másik függvényt igényel Async<unit>, fontolja meg, hogy az eredmény elvetése rendben van-e. Kerülje az eredmények elvetését, csak a típusadákulatok elférése érdekében.

Async.RunSynchronously

Aszinkron számítást futtat, és várja az eredményét a hívó szálon. Kivétel propagálása, ha a számítás egy eredményt ad. Ez a hívás blokkolva van.

Aláírás:

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

Mikor érdemes használni:

  • Ha szüksége van rá, csak egyszer használja egy alkalmazásban – a végrehajtható fájl belépési pontján.
  • Ha nem érdekli a teljesítmény, és egy sor más aszinkron műveletet szeretne egyszerre végrehajtani.

Mire figyeljen a következőre:

  • A hívás Async.RunSynchronously letiltja a hívó szálat, amíg a végrehajtás befejeződik.

Async.Start

Elindít egy aszinkron számítást, amely a szálkészletben tér vissza unit . Nem várja meg a befejezést, és/vagy nem figyeli meg a kivétel kimenetelét. A beágyazott számításokat Async.Start a rendszer az őket elnevezett szülőszámítástól függetlenül indítja el, élettartamuk nincs szülőszámításhoz kötve. Ha a szülőszámítás megszakad, a gyermekszámítások nem lesznek megszakítva.

Aláírás:

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

Csak akkor használja, ha:

  • Olyan aszinkron számítással rendelkezik, amely nem eredményez eredményt, és/vagy feldolgozást igényel.
  • Nem kell tudnia, hogy mikor fejeződik be az aszinkron számítás.
  • Nem érdekli, hogy melyik szálon fut egy aszinkron számítás.
  • Nem kell tisztában lennie a végrehajtásból eredő kivételekkel vagy jelentésekkel.

Mire figyeljen a következőre:

  • A kezdő számítások által kiváltott kivételeket Async.Start a hívó nem propagálja. A hívásverem teljesen fel lesz oldva.
  • Minden olyan munka (például hívás printfn), amely a Async.Start program végrehajtásának fő szálára hatással van, nem fog bekövetkezni.

Együttműködés a .NET-tel

Programozás használata esetén async { } előfordulhat, hogy aszinkron/várakozás stílusú aszinkron programozást használó .NET-kódtárral vagy C#-kódbázissal kell együttműködnie. Mivel a C# és a .NET-kódtárak többsége alapvető absztrakcióként használja a Task<TResult> típusokat, Task ez megváltoztathatja az F# aszinkron kód írását.

Az egyik lehetőség a .NET-feladatok írása közvetlenül task { }a . Másik lehetőségként használhatja a Async.AwaitTask függvényt egy .NET aszinkron számításra:

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

A Async.StartAsTask függvény használatával aszinkron számítást adhat át egy .NET-hívónak:

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

Ha olyan API-kat szeretne használni Task (azaz .NET aszinkron számításokat, amelyek nem adnak vissza értéket), előfordulhat, hogy hozzá kell adnia egy további függvényt, amely átalakítja TaskAsync<'T> a következőt:

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

Már van olyan, Async.AwaitTask amely bemenetként fogad el egy Task értéket. Ezzel és a korábban definiált startTaskFromAsyncUnit függvénnyel megkezdheti és várhatja Task a típusokat egy F# aszinkron számításból.

.NET-feladatok írása közvetlenül F nyelven#

Az F#-ban közvetlenül task { }is írhat feladatokat, például:

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

A példában a printTotalFileBytesUsingTasks függvény típusa string -> Task<unit>. A függvény meghívása elkezdi végrehajtani a feladatot. A hívás megvárja task.Wait() a feladat befejezését.

Többszálas kapcsolat

Bár ebben a cikkben a szálkezelésről van szó, két fontos dologra kell emlékezni:

  1. Nincs affinitás az aszinkron számítás és a szál között, kivéve, ha az aktuális szálon kifejezetten elindult.
  2. Az Aszinkron programozás az F#-ban nem absztrakció a többszálas használathoz.

Előfordulhat például, hogy a számítás a hívó szálán fut, a munka jellegétől függően. A számítások is "ugrik" között szálak, kölcsön őket egy kis ideig, hogy hasznos munkát között "várakozás" (például amikor egy hálózati hívás van folyamatban).

Bár az F# lehetővé teszi az aszinkron számítások indítását az aktuális szálon (vagy kifejezetten nem az aktuális szálon), az aszinkronság általában nincs társítva egy adott szálkészítési stratégiával.

Lásd még