Megosztás a következőn keresztül:


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 szerverfolyamat bemutatása, amely képes nagyszámú egyidejű bejövő kérést kiszolgálni, miközben a rendszererőforrások minimalizálása mellett várakozik az adott folyamat külső rendszerekből vagy szolgáltatásoktól érkező adatokra.
  • 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.

Az aszinkronitás definíciója

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", vagyis "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 utalás az egyidejűségre vagy a párhuzamosságra. 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ánosságban érdemes megfontolni az task {…} használatát async {…} helyett új kódban, ha .NET könyvtárakkal dolgozik, amelyek feladatokat használnak, és nem támaszkodik aszinkron kód visszatérési hívásokra vagy implicit lemondási tokenterjesztésre.

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. Annak a törzsében meghívja a Async.AwaitTask-t, amely az ReadAllBytesAsync eredményét megfelelő típussá alakítja.

Egy másik fontos sor a Async.RunSynchronously hívása. 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 argumentumokat Async<unit>-val végzett számítások sorozatává alakítja át 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. Futtassa explicit módon a teljes komponált számítást Async.RunSynchronously-val, és blokkolja a folyamatot, 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

printTotalFileBytes az argv elemeinek sorrendjében kerül végrehajtásra, ahelyett, hogy párhuzamosan ütemeznék ő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 törlési tokent 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>>

Mikor érdemes használni:

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

Mire figyeljen:

  • Több számítás Async.StartChild indítása nem ugyanaz, mint a párhuzamos végrehajtásuk ütemezése. Ha párhuzamosan szeretné ütemezni a számításokat, használja Async.Parallel.
  • A szülőszámítás megszakítása az összes általa megkezdett gyermekszámítás megszakítá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

Mikor érdemes használni:

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

Mire figyeljen:

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

Mikor érdemes használni:

  • Amikor egy .NET API-t kell meghívnia, amely Task<TResult>-t ad vissza egy aszinkron számítás eredményének reprezentálására.

Mire figyeljen:

  • 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 nincs szüksége a párhuzamosan ütemezett számítások eredményeire addig, amíg mind be nem fejeződik.

Mire figyeljen:

  • 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 akkor lesznek lefuttatva, amikor végül ütemezésre kerülnek. 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:

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

Mikor érdemes használni:

  • 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 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 megadott Async<'T>-t, és visszaad egy Async<Choice<'T, exn>>-et. 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 egy kivétel kerül dobásra a befejezés előtt, akkor a Choice2of2 visszatér a keletkező kivétellel. 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>>

Mikor érdemes használni:

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

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

Mikor érdemes használni:

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

  • 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 azért, hogy megfeleljen a típusdeklarációknak.

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 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 adja vissza az unit értéket. Nem várja meg a befejezést, és/vagy nem figyeli meg a kivétel kimenetelét. A beágyazott számítások, amelyek Async.Start-val indulnak, a számításokat indító szülőszámítástól függetlenül indulnak 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 Async.Start által megkezdett számítások kiváltotta kivételek nem kerülnek továbbításra a hívónak. A hívásverem teljesen feloldódik.
  • Bármilyen munka (például printfn hívás), amelyet a Async.Start-val kezdenek, nem okozza, hogy a hatás a program végrehajtásának fő szálán történjen.

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

Amikor a async { } programozási nyelvet használja, előfordulhat, hogy egy .NET-könyvtárral vagy egy C#-kódbázissal kell együttműködnie, amely async/await stílusú aszinkron programozást használ. Mivel a C# és a .NET-könyvtárak többsége az Task<TResult> és Task típusokat alapvető absztrakcióként használja, ez megváltoztathatja, hogyan írja az F# aszinkron kódot.

Az egyik lehetőség a .NET-feladatok közvetlen írása a task { } használatával. 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 Async<'T>-t Task-vé.

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 elindíthat és várakozhat Task típusokat egy F# aszinkron számítás során.

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

Az F#-ban közvetlenül megírhatja a feladatokat task { } is, 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 task.Wait() hívás megvárja a feladat befejezését.

Kapcsolat a többszálú feldolgozással

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

  1. Az aszinkron számítás és a szál között nincs affinitás, kivéve, ha azt kifejezetten az aktuális szálon indították el.
  2. Az F# nyelv aszinkron programozása nem absztrakció a többszálúság kezelésére.

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 ugorhatnak a szálak között, kölcsön vehetjük őket rövid időre, hogy hasznos munkát végezzenek a várakozás periódusai között (például amikor egy hálózati hívás folyamatban van).

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