Expressions de tâche

Cet article décrit la prise en charge de F# pour les expressions de tâche, qui sont similaires aux expressions asynchrones , mais vous permettent de créer directement des tâches .NET. Comme les expressions asynchrones, les expressions de tâche exécutent du code de manière asynchrone, autrement dit, sans bloquer l’exécution d’autres travaux.

Le code asynchrone est normalement créé à l’aide d’expressions asynchrones. L’utilisation d’expressions de tâche est préférée lors de l’interopérabilité étendue avec les bibliothèques .NET qui créent ou consomment des tâches .NET. Les expressions de tâche peuvent également améliorer les performances et l’expérience de débogage. Toutefois, les expressions de tâche présentent certaines limitations, qui sont décrites plus loin dans l’article.

Syntaxe

task { expression }

Dans la syntaxe précédente, le calcul représenté par expression est configuré pour s’exécuter en tant que tâche .NET. La tâche est démarrée immédiatement après l’exécution de ce code et s’exécute sur le thread actuel jusqu’à ce que sa première opération asynchrone soit effectuée (par exemple, une veille asynchrone, des E/S asynchrones asynchrones ou une autre opération asynchrone primitive). Le type de l’expression est Task<'T>, où 'T est le type retourné par l’expression lorsque le return mot clé est utilisé.

Liaison à l’aide de let !

Dans une expression de tâche, certaines expressions et opérations sont synchrones, et certaines sont asynchrones. Lorsque vous attendez le résultat d’une opération asynchrone, au lieu d’une liaison ordinaire let , vous utilisez let!. L’effet est de permettre à l’exécution de let! continuer sur d’autres calculs ou threads à mesure que le calcul est en cours d’exécution. Une fois que le côté droit de la let! liaison est retourné, le reste de la tâche reprend l’exécution.

Le code suivant montre la différence entre let et let!. Ligne de code qui utilise let simplement crée une tâche en tant qu’objet que vous pouvez attendre ultérieurement à l’aide, par exemple, task.Wait() ou task.Result. La ligne de code qui utilise let! démarre la tâche et attend son résultat.

// let just stores the result as a task.
let (result1 : Task<int>) = stream.ReadAsync(buffer, offset, count, cancellationToken)
// let! completes the asynchronous operation and returns the data.
let! (result2 : int)  = stream.ReadAsync(buffer, offset, count, cancellationToken)

Les expressions F# task { } peuvent attendre les types d’opérations asynchrones suivants :

Expressions return

Dans les expressions de tâche, return expr permet de retourner le résultat d’une tâche.

Expressions return!

Dans les expressions de tâche, return! expr permet de retourner le résultat d’une autre tâche. Elle équivaut à utiliser let! , puis à retourner immédiatement le résultat.

Flux de contrôle

Les expressions de tâche peuvent inclure les constructions for .. in .. dode flux de contrôle, , while .. dotry .. with .., try .. finally .., , if .. then .. elseet if .. then ... Celles-ci peuvent à leur tour inclure d’autres constructions de tâches, à l’exception des with gestionnaires et finally des gestionnaires, qui s’exécutent de manière synchrone. Si vous avez besoin d’une liaison asynchrone try .. finally .., utilisez une use liaison en combinaison avec un objet de type IAsyncDisposable.

use et use! liaisons

Dans les expressions de tâche, use les liaisons peuvent être liées à des valeurs de type IDisposable ou IAsyncDisposable. Pour ce dernier, l’opération de nettoyage de la suppression est exécutée de manière asynchrone.

En plus de let!, vous pouvez utiliser use! pour effectuer des liaisons asynchrones. La différence entre let! et use! est la même que la différence entre let et use. Pour use!, l’objet est supprimé à la fermeture de l’étendue actuelle. Notez que dans F# 6, use! n’autorise pas l’initialisation d’une valeur à null, même si use elle le fait.

open System
open System.IO
open System.Security.Cryptography
task {
    // use IDisposable
    use httpClient = new Net.Http.HttpClient()
    // use! Task<IDisposable>
    use! exampleDomain = httpClient.GetAsync "https://example.com/data.enc"

    // use IDisposable
    use aes = Aes.Create()
    aes.KeySize <- 256
    aes.GenerateIV()
    aes.GenerateKey()
    // do! Task
    do! File.WriteAllTextAsync("key.iv.txt", $"Key: {Convert.ToBase64String aes.Key}\nIV: {Convert.ToBase64String aes.IV}")

    // use IAsyncDisposable
    use outputStream = File.Create "secret.enc"
    // use IDisposable
    use encryptor = aes.CreateEncryptor()
    // use IAsyncDisposable
    use cryptoStream = new CryptoStream(outputStream, encryptor, CryptoStreamMode.Write)
    // do! Task
    do! exampleDomain.Content.CopyToAsync cryptoStream
}

Tâches de valeur

Les tâches de valeur sont des structs utilisés pour éviter les allocations dans la programmation basée sur les tâches. Une tâche de valeur est une valeur éphémère qui est transformée en une tâche réelle à l’aide .AsTask()de .

Pour créer une tâche de valeur à partir d’une expression de tâche, utilisez |> ValueTask<ReturnType> ou |> ValueTask. Par exemple:

let makeTask() =
    task { return 1 }

makeTask() |> ValueTask<int>

and! liaisons (à partir de F# 10)

Dans les expressions de tâche, il est possible d’attendre simultanément plusieurs opérations asynchrones (Task<'T>, ValueTask<'T>Async<'T> etc.). Comparer:

// We'll wait for x to resolve and then for y to resolve. Overall execution time is sum of two execution times.
let getResultsSequentially() =
    task {
        let! x = getX()
        let! y = getY()
        return x, y
    }

// x and y will be awaited concurrently. Overall execution time is the time of the slowest operation.
let getResultsConcurrently() =
    task {
        let! x = getX()
        and! y = getY()
        return x, y
    }

Ajout de jetons d’annulation et de vérifications d’annulation

Contrairement aux expressions asynchrones F#, les expressions de tâche ne passent pas implicitement un jeton d’annulation et n’effectuent pas implicitement de vérifications d’annulation. Si votre code nécessite un jeton d’annulation, vous devez spécifier le jeton d’annulation en tant que paramètre. Par exemple:

open System.Threading

let someTaskCode (cancellationToken: CancellationToken) =
    task {
        cancellationToken.ThrowIfCancellationRequested()
        printfn $"continuing..."
    }

Si vous envisagez de rendre votre code annulable correctement, vérifiez soigneusement que vous transmettez le jeton d’annulation à toutes les opérations de bibliothèque .NET qui prennent en charge l’annulation. Par exemple, Stream.ReadAsync il existe plusieurs surcharges, dont l’une accepte un jeton d’annulation. Si vous n’utilisez pas cette surcharge, cette opération de lecture asynchrone spécifique ne peut pas être annulée.

Accès concurrentiel limité et pipelines de résultats

Pour limiter le nombre d’opérations démarrées à la fois, FSharp.Core 11 fournit Task.parallelLimit. Passez des fabriques qui acceptent des jetons d’annulation, plutôt que des tâches qui ont déjà démarré :

open System.IO
open System.Threading

let readFiles (paths: seq<string>) (ct: CancellationToken) =
    paths
    |> Seq.map (fun path ->
        fun token -> File.ReadAllTextAsync(path, token))
    |> Task.parallelLimit 4 ct

Le tableau de résultats conserve l’ordre d’entrée. Une défaillance demande l’annulation du travail frère, et chaque opération doit observer son jeton. Task.sequential exécute des usines un par un ; Task.parallelDoLimit et Task.sequentialDo retourne unit au lieu d’un tableau.

Pour Task.maples pipelines , Task.bindet Task.catch les pipelines de résultats et les helpers Async/ValueTask correspondants, consultez les fonctions Async et de tâche.

Tâches en arrière-plan

Par défaut, les tâches .NET sont planifiées à l’aide SynchronizationContext.Current du cas échéant. Cela permet aux tâches de servir d’agents coopératifs et entrelacés s’exécutant sur un thread d’interface utilisateur sans bloquer l’interface utilisateur. S’il n’est pas présent, les continuations de tâches sont planifiées dans le pool de threads .NET.

Dans la pratique, il est souvent souhaitable que le code de la bibliothèque qui génère des tâches ignore le contexte de synchronisation et bascule toujours vers le pool de threads .NET, si nécessaire. Vous pouvez effectuer cette opération à l’aide backgroundTask { }de :

backgroundTask { expression }

Une tâche en arrière-plan ignore SynchronizationContext.Current les éléments suivants : si elle a démarré sur un thread avec une valeur non null SynchronizationContext.Current, elle bascule vers un thread d’arrière-plan dans le pool de threads à l’aide Task.Runde . Si elle a démarré sur un thread avec null SynchronizationContext.Current, elle s’exécute sur ce même thread.

Remarque

Dans la pratique, cela signifie que les appels à ConfigureAwait(false) ne sont généralement pas nécessaires dans le code de tâche F#. Au lieu de cela, les tâches destinées à s’exécuter en arrière-plan doivent être créées à l’aide backgroundTask { ... }de . Toute liaison de tâche externe à une tâche en arrière-plan est resynchronisée à l’achèvement SynchronizationContext.Current de la tâche en arrière-plan.

Limitations des tâches concernant les appels de queue

Contrairement aux expressions asynchrones F#, les expressions de tâche ne prennent pas en charge les tailcalls. Autrement dit, lorsqu’elle return! est exécutée, la tâche actuelle est inscrite en attente de la tâche dont le résultat est retourné. Cela signifie que les fonctions et méthodes récursives implémentées à l’aide d’expressions de tâche peuvent créer des chaînes de tâches sans limite, et celles-ci peuvent utiliser une pile ou un tas sans limite. Considérons par exemple le code suivant :

let rec taskLoopBad (count: int) : Task<string> =
    task {
        if count = 0 then
            return "done!"
        else
            printfn $"looping..., count = {count}"
            return! taskLoopBad (count-1)
    }

let t = taskLoopBad 10000000
t.Wait()

Ce style de codage ne doit pas être utilisé avec des expressions de tâche . Il crée une chaîne de 1000000 tâches et provoque un StackOverflowException. Si une opération asynchrone est ajoutée sur chaque appel de boucle, le code utilise un tas essentiellement indépendant. Envisagez de changer ce code pour utiliser une boucle explicite, par exemple :

let taskLoopGood (count: int) : Task<string> =
    task {
        for i in count .. 1 do
            printfn $"looping... count = {count}"
        return "done!"
    }

let t = taskLoopGood 10000000
t.Wait()

Si des tailcalls asynchrones sont requis, utilisez une expression asynchrone F#, qui prend en charge les tailcalls. Par exemple:

let rec asyncLoopGood (count: int) =
    async {
        if count = 0 then
            return "done!"
        else
            printfn $"looping..., count = {count}"
            return! asyncLoopGood (count-1)
    }

let t = asyncLoopGood 1000000 |> Async.StartAsTask
t.Wait()

Implémentation de tâche

Les tâches sont implémentées à l’aide du code pouvant être repris, une nouvelle fonctionnalité en F# 6. Les tâches sont compilées en « Machines d’état pouvant être reprise » par le compilateur F#. Ces informations sont décrites en détail dans la RFC du code pouvant être reprise et dans une session de la communauté du compilateur F#.

Expériences asynchrones d’exécution

Les auteurs du générateur peuvent expérimenter .NET synchronisation du runtime. Un ordinateur d’état généré par le compilateur stocke normalement l’état nécessaire pour continuer après une attente. Avec l’async du runtime, le runtime suspend et reprend une méthode spécialement marquée à la place.

Remarque

Ce prototype utilise une fonctionnalité F# 11.2. Dans le sdk .NET 11.0.100, activez <LangVersion>preview</LangVersion> un compilateur contenant la prise en charge asynchrone du runtime. Il a également besoin d’assemblys runtime et de référence exposant MethodImplOptions.Async et System.Runtime.CompilerServices.AsyncHelpers.Await. Les assemblys de référence décrivent les API au moment de la compilation ; l’exécution .NET runtime doit implémenter le comportement de pause/reprise.

La combinaison de composants vérifiés de l’exemple est la suivante :

Component Version ou condition requise
Compilateur Compilateur F# 11 fourni par FSharp.Compiler.Service 43.13.101-rc2.26473.112.
FSharp.Core Empaquetage 11.0.101-rc2.26473.112, utilisation lib/net10.0/FSharp.Core.dllde . Cette build fournit StateMachineHelpers.__runtimeAsyncReturn; les builds et netstandard2.1 les netstandard2.0 builds ne le font pas.
cible Project net11.0.
Assemblys runtime et référence 11.0.0-rc.1.26425.128.NET .

Ces composants testés ne sont pas une distribution unique du Kit de développement logiciel (SDK). .NET 10.0.12 expose les API de référence, mais la vérification de pause/reprise échoue sur son exécution avec TypeLoadException. Utilisez la combinaison runtime/référence vérifiée ci-dessus ; ni le mode d’aperçu ni la présence d’API ne fournissent la prise en charge du runtime.

Le site d’appel suivant utilise un générateur défini par l’application pour lire un fichier de façon asynchrone :

let readManifest (path: string) =
    runtimeTask {
        let! text = System.IO.File.ReadAllTextAsync path
        return text.Length
    }

Le générateur connecte l’expression de calcul à l’intrinsèque de retour asynchrone runtime et attendent l’assistance. Ce n’est pas un nouveau générateur intégré task :

open System.Threading.Tasks
open System.Runtime.CompilerServices
open Microsoft.FSharp.Core.CompilerServices

type RuntimeTaskBuilder() =
    member inline _.Delay([<InlineIfLambda>] code: unit -> 'T) = code
    member inline _.Run([<InlineIfLambda>] code: unit -> 'T) : Task<'T> =
        StateMachineHelpers.__runtimeAsyncReturn (code())
    member inline _.Return(value: 'T) = value
    member inline _.Bind(work: Task<'T>, [<InlineIfLambda>] next: 'T -> 'U) =
        next (AsyncHelpers.Await work)

let runtimeTask = RuntimeTaskBuilder()

__runtimeAsyncReturn marque la méthode englobante pour l’async runtime. Dans cette méthode, AsyncHelpers.Await vous pouvez suspendre le travail non terminé et retourner le contrôle à l’appelant, puis reprendre avec le résultat. Il ne s’agit pas d’un appel bloquant .Result .

Le compilateur vérifie les modèles de suspension pris en charge ; les byrefs ne peuvent pas rester en vie pendant une suspension. L’exemple définit Delayuniquement , et RunReturnune Bind surcharge, et non un générateur de production avec annulation, boucles ou suppression asynchrone.

Activez la fonctionnalité F# 11.2 dans laquelle le générateur inline est défini et développé. Les appelants d’une API déjà compilée readManifest n’en ont pas besoin. Les expressions ordinaires task conservent leur implémentation. Utilisez AsyncHelpers.Await uniquement les méthodes runtime-async ; consultez la spécification asynchrone du runtime.

L’exemple de projet complet inclut un point d’entrée de console. À partir de son répertoire, exécutez dotnet run -- manifest.txt avec des outils compatibles et un fichier à lire. Un fichier contenant exactement manifest, sans ligne de fin, produit 8. Blocs de point d’entrée de la console pour l’achèvement ; readManifest elle-même retourne Task<int>.

Voir aussi