Programmation asynchrone en F#

La programmation asynchrone est un mécanisme essentiel aux applications modernes pour diverses raisons. Il existe deux cas d’usage principaux que la plupart des développeurs rencontreront :

  • Présentation d’un processus serveur qui peut traiter un nombre important de requêtes entrantes simultanées, tout en réduisant les ressources système occupées pendant que le traitement des demandes attend les entrées des systèmes ou services externes à ce processus
  • Gestion d’une interface utilisateur réactive ou d’un thread principal lors de la progression simultanée du travail en arrière-plan

Bien que le travail en arrière-plan implique souvent l’utilisation de plusieurs threads, il est important de prendre en compte les concepts d’asynchronie et de multithreading séparément. En fait, ce sont des préoccupations distinctes et l’un n’implique pas l’autre. Cet article décrit plus en détail les concepts distincts.

Définition de l’asynchronie

Le point précédent, à savoir que l'asynchronie est indépendante de l'utilisation de plusieurs threads, mérite d'être expliqué un peu plus en détail. Il existe trois concepts qui sont parfois liés, mais strictement indépendants les uns des autres :

  • Concurrence : lorsque plusieurs calculs s’exécutent dans des périodes qui se chevauchent.
  • Parallélisme : lorsque plusieurs calculs ou plusieurs parties d’un seul calcul s’exécutent exactement en même temps.
  • Asynchronie : lorsqu’un ou plusieurs calculs peuvent s’exécuter séparément du flux de programme principal.

Les trois sont des concepts orthogonaux, mais ils peuvent être facilement confondus, en particulier lorsqu’ils sont utilisés ensemble. Par exemple, vous devrez peut-être exécuter plusieurs calculs asynchrones en parallèle. Cette relation ne signifie pas que le parallélisme ou l’asynchronie s'impliquent mutuellement.

Si vous considérez l’étymologie du mot   asynchrone », il y a deux éléments en jeu :

  • « a », qui signifie « non ».
  • « synchrone », qui signifie « en même temps ».

Lorsque vous mettez ces deux termes ensemble, vous verrez que « asynchrone » signifie « pas en même temps ». Et voilà ! Cette définition n’implique pas la concurrence ou le parallélisme. Cela est également vrai dans la pratique.

En pratique, les calculs asynchrones en F# sont planifiés pour s’exécuter indépendamment du flux de programme principal. Cette exécution indépendante n’implique pas la concurrence ou le parallélisme, ni qu’un calcul se déroule toujours en arrière-plan. En fait, les calculs asynchrones peuvent même s’exécuter de manière synchrone, en fonction de la nature du calcul et de l’environnement dans lequel le calcul s’exécute.

Le principal enseignement que vous devez tirer est que les calculs asynchrones sont indépendants du flux principal du programme. Bien qu’il existe peu de garanties quant au moment ou à la façon dont un calcul asynchrone s’exécute, il existe certaines approches pour orchestrer et planifier ces opérations. Le reste de cet article explore les concepts fondamentaux de l’asynchronie F# et explique comment utiliser les types, fonctions et expressions intégrés à F#.

Concepts principaux

En F#, la programmation asynchrone est centrée autour de deux concepts fondamentaux : les calculs asynchrones et les tâches.

  • Le type Async<'T> avec les async { }expressions, qui représente un calcul asynchrone composable qui peut être démarré pour former une tâche.
  • Le type Task<'T>, avec l’task { }expression, qui représente une tâche .NET en cours d’exécution.

En règle générale, il est préférable d’utiliser task {…} plutôt que async {…} dans le nouveau code si vous interagissez avec des bibliothèques .NET qui utilisent des tâches, et si vous ne comptez pas sur des « tailcalls » de code asynchrones ou sur la propagation implicite de jetons d’annulation.

Concepts fondamentaux de l’asynchronie

Vous pouvez voir les concepts de base de la programmation « asynchrone » dans l’exemple suivant :

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

Dans l’exemple, la fonction printTotalFileBytesUsingAsync est de type string -> Async<unit>. L’appel de la fonction n’exécute pas réellement le calcul asynchrone. Au lieu de cela, elle retourne un Async<unit> qui agit comme une spécification du travail qui doit s’exécuter de manière asynchrone. Elle appelle Async.AwaitTask dans son corps, ce qui convertit le résultat d’un ReadAllBytesAsync en type approprié.

Une autre ligne importante est l’appel à Async.RunSynchronously. Il s’agit de l’une des fonctions de démarrage du module Async que vous devez appeler si vous souhaitez exécuter un calcul asynchrone F#.

Il s’agit d’une différence fondamentale avec le style C#/Visual Basic de la programmation async. En F#, les calculs asynchrones peuvent être considérés comme des tâches à froid. Ils doivent être explicitement démarrés pour s’exécuter réellement. Cela présente quelques avantages, car cela vous permet de combiner et de séquencer le travail asynchrone beaucoup plus facilement qu’en C# ou Visual Basic.

Combiner des calculs asynchrones

Voici un exemple qui s’appuie sur le précédent en combinant des calculs :

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

Comme vous pouvez le voir, la fonction main présente quelques éléments supplémentaires. Conceptuellement, elle effectue les opérations suivantes :

  1. Transformez les arguments de ligne de commande en une séquence de calculs Async<unit> avec Seq.map.
  2. Créez un Async<'T[]> qui planifie et exécute les calculs printTotalFileBytes en parallèle lorsqu’il s’exécute.
  3. Créez un Async<unit> qui exécute le calcul parallèle et ignore son résultat (qui est un unit[]).
  4. Exécutez explicitement le calcul composé global avec Async.RunSynchronously, qui bloque jusqu’à ce qu’il se termine.

Lorsque ce programme s’exécute, printTotalFileBytes s’exécute en parallèle pour chaque argument de ligne de commande. Étant donné que les calculs asynchrones s’exécutent indépendamment du flux de programme, il n’existe aucun ordre défini dans lequel ils impriment leurs informations et terminent de s’exécuter. Les calculs seront planifiés en parallèle, mais leur ordre d’exécution n’est pas garanti.

Calculs asynchrones de séquence

Étant donné que Async<'T> est une spécification de travail plutôt que d’une tâche déjà en cours d’exécution, vous pouvez effectuer facilement des transformations plus complexes. Voici un exemple qui séquence un ensemble de calculs asynchrones afin qu’ils s’exécutent l’un après l’autre.

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

Cela planifie l’exécution par printTotalFileBytes dans l’ordre des éléments de argv plutôt que de les planifier en parallèle. Étant donné que chaque opération successive ne sera planifiée qu’après l’exécution du calcul précédent, les calculs sont séquencés afin qu’il n’y ait pas de chevauchement dans leur exécution.

Fonctions importantes du module Async

Lorsque vous écrivez du code asynchrone en F#, vous interagirez généralement avec une infrastructure qui gère la planification des calculs pour vous. Toutefois, ce n’est pas toujours le cas, il est donc judicieux de comprendre les différentes fonctions qui peuvent être utilisées pour planifier le travail asynchrone.

Étant donné que les calculs asynchrones F# sont une spécification de travail plutôt qu’une représentation du travail déjà en cours d’exécution, ils doivent être explicitement démarrés avec une fonction de départ. Il existe de nombreuses méthodes de démarrage asynchrones qui sont utiles dans différents contextes. La section suivante décrit certaines des fonctions de démarrage les plus courantes.

Async.StartChild

Démarre un calcul enfant dans un calcul asynchrone. Cela permet à plusieurs calculs asynchrones d’être exécutés simultanément. Le calcul enfant partage un jeton d’annulation avec le calcul parent. Si le calcul parent est annulé, le calcul enfant est également annulé.

Signature :

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

Quand l’utiliser :

  • Lorsque vous souhaitez exécuter plusieurs calculs asynchrones simultanément plutôt qu’un à la fois, mais ne les avez pas planifiés en parallèle.
  • Lorsque vous souhaitez lier la durée de vie d’un calcul enfant à celui d’un calcul parent.

À quoi faut-il faire attention ?

  • Le démarrage de plusieurs calculs avec Async.StartChild n’est pas le même que la planification en parallèle. Si vous souhaitez planifier des calculs en parallèle, utilisez Async.Parallel.
  • L’annulation d’un calcul parent déclenche l’annulation de tous les calculs enfants qu’il a démarrés.

Async.StartImmediate

Exécute un calcul asynchrone, en commençant immédiatement sur le thread du système d’exploitation actuel. Ceci est utile si vous devez mettre à jour quelque chose sur le thread appelant pendant le calcul. Par exemple, si un calcul asynchrone doit mettre à jour une interface utilisateur (par exemple, mettre à jour une barre de progression), Async.StartImmediate doit être utilisé.

Signature :

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

Quand l’utiliser :

  • Lorsque vous devez mettre à jour quelque chose sur le thread appelant au milieu d’un calcul asynchrone.

À quoi faut-il faire attention ?

  • Le code dans le calcul asynchrone s’exécutera sur n’importe quel thread sur lequel il est programmé. Cela peut être problématique si ce thread est sensible d’une certaine manière, tel qu’un thread d’interface utilisateur. Dans ce cas, il est probable qu’il n’est pas approprié d’utiliser Async.StartImmediate.

Async.StartAsTask

Exécute un calcul dans le pool de threads. Retourne un Task<TResult> qui sera terminé à l’état correspondant une fois le calcul terminé (produit le résultat, génère une exception ou est annulé). Si aucun jeton d’annulation n’est fourni, le jeton d’annulation par défaut est utilisé.

Signature :

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

Quand l’utiliser :

  • Lorsque vous devez appeler une API .NET qui génère un Task<TResult> pour représenter le résultat d’un calcul asynchrone.

À quoi faut-il faire attention ?

  • Cet appel alloue un objet Task supplémentaire, ce qui peut augmenter la surcharge si elle est souvent utilisée.

Async.Parallel

Planifie une séquence de calculs asynchrones à exécuter en parallèle, ce qui génère un tableau de résultats dans l’ordre dans lequel ils ont été fournis. Le degré de parallélisme peut être éventuellement paramétré/limité en spécifiant le paramètre maxDegreeOfParallelism.

Signature :

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

Quand l’utiliser :

  • Si vous avez besoin d’exécuter un ensemble de calculs en même temps et que vous n’avez aucune dépendance sur leur ordre d’exécution.
  • Si vous n’avez pas besoin de résultats de calculs planifiés en parallèle tant qu’ils n’ont pas tous été terminés.

À quoi faut-il faire attention ?

  • Vous ne pouvez accéder au tableau de valeurs résultant qu’une fois tous les calculs terminés.
  • Les calculs sont exécutés chaque fois qu’ils finissent par être planifiés. Ce comportement signifie que vous ne pouvez pas compter sur leur ordre d’exécution.

Async.Sequential

Planifie une séquence de calculs asynchrones à exécuter dans l’ordre dans lequel ils sont passés. Le premier calcul sera exécuté, puis le suivant, et ainsi de suite. Aucun calcul n’est exécuté en parallèle.

Signature :

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

Quand l’utiliser :

  • Si vous devez exécuter plusieurs calculs dans l’ordre.

À quoi faut-il faire attention ?

  • Vous ne pouvez accéder au tableau de valeurs résultant qu’une fois tous les calculs terminés.
  • Les calculs seront exécutés dans l’ordre dans lequel ils sont passés à cette fonction, ce qui peut signifier que plus de temps s’écoulera avant que les résultats ne soient retournés.

Async.AwaitTask

Retourne un calcul asynchrone qui attend la fin de l’opération Task<TResult> donnée et retourne son résultat sous la forme d’un Async<'T>

Signature :

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

Quand l’utiliser :

  • Lorsque vous consommez une API .NET qui retourne un Task<TResult> au sein d’un calcul asynchrone F#.

À quoi faut-il faire attention ?

  • Les exceptions sont encapsulées dans AggregateException suivant la convention de la bibliothèque parallèle de tâches. Ce comportement diffère de la façon dont F# async expose généralement les exceptions.

Async.Catch

Crée un calcul asynchrone qui exécute un Async<'T> donné, retournant un Async<Choice<'T, exn>>. Si le Async<'T> donné se termine correctement, un Choice1Of2 est retourné avec la valeur résultante. Si une exception est levée avant sa fin, un Choice2of2 est retourné avec l’exception levée. S’il est utilisé sur un calcul asynchrone qui est lui-même composé de nombreux calculs, et l’un de ces calculs génère une exception, le calcul englobant est entièrement arrêté.

Signature :

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

Quand l’utiliser :

  • Lorsque vous effectuez un travail asynchrone qui peut échouer avec une exception et que vous souhaitez gérer cette exception dans l’appelant.

À quoi faut-il faire attention ?

  • Lorsque vous utilisez des calculs asynchrones combinés ou séquencés, le calcul englobant s’arrête entièrement si l’un de ses calculs « internes » génère une exception.

Async.Ignore

Crée un calcul asynchrone qui exécute le calcul donné, mais supprime son résultat.

Signature :

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

Quand l’utiliser :

  • Lorsque vous disposez d’un calcul asynchrone dont le résultat n’est pas nécessaire. Cela est analogue à la fonction ignore pour le code non asynchrone.

À quoi faut-il faire attention ?

  • Si vous devez utiliser Async.Ignore parce que vous souhaitez utiliser Async.Start ou une autre fonction qui nécessite Async<unit>, envisagez si ignorer le résultat est acceptable. Évitez d’ignorer les résultats simplement pour s’adapter à une signature de type.

Async.RunSynchronously

Exécute un calcul asynchrone et attend son résultat sur le thread appelant. Propage une exception si le calcul en génère une. Cet appel est bloquant.

Signature :

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

Quand l’utiliser :

  • Si vous en avez besoin, utilisez-le une seule fois dans une application : au point d’entrée d’un exécutable.
  • Lorsque vous ne vous souciez pas des performances et que vous souhaitez exécuter un ensemble d’autres opérations asynchrones à la fois.

À quoi faut-il faire attention ?

  • L’appel de Async.RunSynchronously bloque le thread appelant jusqu’à la fin de l’exécution.

Async.Start

Démarre un calcul asynchrone qui retourne unit dans le pool de threads. N’attend pas son achèvement et/ou observe un résultat d’exception. Les calculs imbriqués démarrés avec Async.Start sont démarrés indépendamment du calcul parent qui les a appelés ; leur durée de vie n’est liée à aucun calcul parent. Si le calcul parent est annulé, aucun calcul enfant n’est annulé.

Signature :

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

Utilisez uniquement quand :

  • Vous avez un calcul asynchrone qui ne donne pas de résultat et/ou nécessite le traitement d’un résultat.
  • Vous n’avez pas besoin de savoir quand un calcul asynchrone se termine.
  • Vous ne vous souciez pas du thread sur lequel un calcul asynchrone s’exécute.
  • Vous n’avez pas besoin de connaître ou de signaler des exceptions résultant de l’exécution.

À quoi faut-il faire attention ?

  • Les exceptions déclenchées par les calculs démarrés avec Async.Start ne sont pas propagées à l’appelant. La pile des appels sera complètement déroulée.
  • Tout travail (tel que l’appel de printfn) démarré avec Async.Start ne provoque pas d’effet sur le thread principal de l’exécution d’un programme.

Interopérabilité avec .NET

Si vous utilisez la programmation async { }, vous devrez peut-être interagir avec une bibliothèque .NET ou une base de code C# qui utilise une programmation asynchrone de style async/await. Étant donné que C# et la majorité des bibliothèques .NET utilisent les types Task<TResult> et Task comme abstractions principales, cela peut changer la façon dont vous écrivez votre code asynchrone F#.

L’une des options consiste à passer à l’écriture de tâches .NET directement à l’aide de task { }. Vous pouvez également utiliser la fonction Async.AwaitTask pour attendre un calcul asynchrone .NET :

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

Vous pouvez utiliser la fonction Async.StartAsTask pour passer un calcul asynchrone à un appelant .NET :

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

Pour utiliser des API qui utilisent Task (autrement dit, les calculs asynchrones .NET qui ne retournent pas de valeur), vous devrez peut-être ajouter une fonction supplémentaire qui convertira un Async<'T> en un Task :

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

Il existe déjà un Async.AwaitTask qui accepte un Task en tant qu’entrée. Avec cette fonction et la fonction startTaskFromAsyncUnit précédemment définie, vous pouvez démarrer et attendre des types Task à partir d’un calcul asynchrone F#.

Écriture de tâches .NET directement dans F#

En F#, vous pouvez écrire des tâches directement à l’aide de task { }, par exemple :

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

Dans l’exemple, la fonction printTotalFileBytesUsingTasks est de type string -> Task<unit>. L’appel de la fonction commence à exécuter la tâche. L’appel à task.Wait() attend la fin de la tâche.

Relation avec le multithreading

Bien que le threading soit mentionné tout au long de cet article, il y a deux choses importantes à retenir :

  1. Il n’existe aucune affinité entre un calcul asynchrone et un thread, sauf s’il a démarré explicitement sur le thread actuel.
  2. La programmation asynchrone en F# n’est pas une abstraction pour le multithreading.

Par exemple, un calcul peut réellement s’exécuter sur le thread de son appelant, en fonction de la nature du travail. Un calcul peut également « sauter » entre les threads, en les empruntant pendant un petit temps pour effectuer des tâches utiles entre les périodes d'« attente » (par exemple, lorsqu’un appel réseau est en transit).

Bien que F# offre certaines capacités de démarrage d’un calcul asynchrone sur le thread actuel (ou explicitement pas sur le thread actuel), l’asynchronie n’est généralement pas associée à une stratégie de threading particulière.

Voir aussi