Remarque
L’accès à cette page nécessite une autorisation. Vous pouvez essayer de vous connecter ou de modifier des répertoires.
L’accès à cette page nécessite une autorisation. Vous pouvez essayer de modifier des répertoires.
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, ils 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 - que l’asynchronie est indépendante de l’utilisation de plusieurs threads - vaut la peine d’expliquer un peu plus loin. 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 peuvent être facilement conflatés, 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 impliquent l’un de l’autre.
Si vous considérez l’étymologie du mot « asynchrone », deux éléments sont impliqués :
- « a », qui signifie « non ».
- « synchrone », ce qui signifie « en même temps ».
Lorsque vous mettez ces deux termes ensemble, vous verrez que « asynchrone » signifie « pas en même temps ». C’est tout ! Il n’y a aucune implication de concurrence ou de parallélisme dans cette définition. 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 l’accès concurrentiel ou le parallélisme, ni n’implique-t-elle qu’un calcul se produit 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.
La principale chose à prendre, c’est que les calculs asynchrones sont indépendants du flux de programme principal. 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 de base
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 des expressionsasync { }, qui représente un calcul asynchrone composable pouvant être démarré pour former une tâche. - Le type
Task<'T>, avec des expressionstask { }, qui représente une tâche .NET en cours d’exécution.
En général, vous devriez envisager 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 vous appuyez pas sur les appels de code asynchrone ou la propagation implicite des jetons d'annulation.
Concepts fondamentaux d’async
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 printTotalFileBytesUsingAsync fonction est de type string -> Async<unit>. L’appel de la fonction n’exécute pas réellement le calcul asynchrone. Au lieu de cela, il retourne un Async<unit> qui agit comme une spécification du travail qui doit s’exécuter de manière asynchrone. Il appelle Async.AwaitTask dans son corps, ce qui convertit le résultat de ReadAllBytesAsync en un 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 de programmation C#/Visual Basic de async. Dans 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 il vous permet de combiner et de séquencer le travail asynchrone beaucoup plus facilement que dans 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 main fonction a quelques éléments supplémentaires. Conceptuellement, il effectue les opérations suivantes :
- Transformez les arguments de la ligne de commande en une séquence de
Async<unit>calculs avecSeq.map. - Créez un
Async<'T[]>qui planifie et exécute les calculs deprintTotalFileBytesen parallèle pendant son exécution. - Créez un
Async<unit>qui exécutera le calcul parallèle et ignorez son résultat (qui est ununit[]). - Exécutez explicitement le calcul composé global avec
Async.RunSynchronously, bloquant 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 l’exécution. 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é qu’il Async<'T> s’agit d’une spécification de travail plutôt que d’une tâche déjà en cours d’exécution, vous pouvez effectuer des transformations plus complexes facilement. 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 printTotalFileBytes pour une exécution 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 interagissez 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 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 faire attention :
- Avec
Async.StartChild, le démarrage de plusieurs calculs n'est pas la même chose que leur programmation en parallèle. Si vous souhaitez planifier des calculs en parallèle, utilisezAsync.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), il Async.StartImmediate doit être utilisé.
Signature:
computation: Async<unit> * ?cancellationToken: CancellationToken -> unit
Quand utiliser :
- Lorsque vous devez mettre à jour quelque chose sur le thread appelant au milieu d’un calcul asynchrone.
À quoi faire attention :
- Le code dans le calcul asynchrone s'exécutera sur le 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 de tels cas, il est probable qu'il est inapproprié 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 utiliser :
- Lorsque vous devez appeler une API .NET qui renvoie un Task<TResult> pour représenter le résultat d'un calcul asynchrone.
À quoi faire attention :
- Cet appel alloue un objet supplémentaire
Task, 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 maxDegreeOfParallelism paramètre.
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 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 elles sont passées. 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 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 dans l’ordre dans lequel ils sont passés à cette fonction, ce qui peut signifier qu’un plus grand temps s’écoule avant que les résultats ne soient retournés.
Async.AwaitTask
Retourne un calcul asynchrone qui attend la fin de l’opération donnée Task<TResult> et retourne son résultat sous la forme d’un Async<'T>
Signature:
task: Task<'T> -> Async<'T>
Quand utiliser :
- Lorsque vous consommez une API .NET qui retourne un Task<TResult> dans une computation asynchrone F#.
À quoi faire attention :
- Les exceptions sont encapsulées dans AggregateException conformément à la convention de la bibliothèque parallèle de tâches. Ce comportement est différent de la manière dont les exceptions sont généralement gérées en F# async.
Async.Catch
Crée un calcul asynchrone qui exécute un calcul donné Async<'T>, en retournant un Async<Choice<'T, exn>>. Si l’opération donnée Async<'T> se termine avec succès, alors 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. Dans le cas où il est utilisé sur un calcul asynchrone lui-même composé de nombreux calculs, et que l’un de ces calculs lève une exception, le calcul englobant sera entièrement arrêté.
Signature:
computation: Async<'T> -> Async<Choice<'T, exn>>
Quand 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 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 » lève 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 utiliser :
- Lorsque vous disposez d’un calcul asynchrone dont le résultat n’est pas nécessaire. Ceci est analogue à la fonction
ignorepour le code non asynchrone.
À quoi faire attention :
- Si vous devez utiliser
Async.Ignoreparce que vous souhaitez utiliserAsync.Startou une autre fonction qui nécessiteAsync<unit>, demandez-vous s'il est acceptable de jeter le résultat. É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-la 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 faire attention :
- L’appel de
Async.RunSynchronouslybloque 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 commencent indépendamment du calcul parent qui les a appelés ; leur durée de vie n'est pas liée à un calcul parent quelconque. Si le calcul parent est annulé, aucun calcul enfant n’est annulé.
Signature:
computation: Async<unit> * ?cancellationToken: CancellationToken -> unit
Utilisez uniquement quand :
- Vous disposez d’un calcul asynchrone qui ne génère pas de résultat et/ou nécessite un traitement.
- 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 faire attention :
- Les exceptions déclenchées par les calculs effectués avec
Async.Startne sont pas propagées à l’appelant. La pile des appels sera complètement déroulée. - Tout travail (tel que l’appel
printfn) démarré avecAsync.Startne provoquera pas l'effet sur le thread principal du 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 Task<TResult> types et Task les types comme abstractions principales, cela peut changer la façon dont vous écrivez votre code asynchrone F#.
L'une des options est d'écrire directement des tâches .NET en utilisant task { }. Vous pouvez également utiliser la Async.AwaitTask fonction pour attendre un calcul asynchrone .NET :
let getValueFromLibrary param =
async {
let! value = DotNetLibrary.GetValueAsync param |> Async.AwaitTask
return value
}
Vous pouvez utiliser la Async.StartAsTask fonction 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à une Async.AwaitTask qui accepte une 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#.
Attendre les tâches et l’annulation du flux
FSharp.Core 11 ajoute Async.Await et Async.StartTaskImmediate. Utilisez le formulaire de fabrique lorsque vous démarrez une tâche à l’intérieur d’un flux de travail afin que l’opération reçoive le jeton d’annulation du flux de travail :
open System.IO
let tryReadFile path =
async {
try
let! contents =
Async.StartTaskImmediate(fun ct -> File.ReadAllTextAsync(path, ct))
return Some contents
with :? FileNotFoundException ->
return None
}
L’échec d’une seule tâche est transmis directement au gestionnaire d’exception correspondant, sans encapsulage supplémentaire AggregateException. Un agrégat contenant plusieurs exceptions reste un agrégat. Le contrat existant Async.AwaitTask n’est pas modifié.
Pour le pont de retour, la concurrence bornée et les transformations de résultat, consultez les fonctions asynchrones et de tâches.
É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 printTotalFileBytesUsingTasks fonction est de type string -> Task<unit>. L'appel de la fonction lance l'exécution de la tâche.
L’appel à task.Wait() attend que la tâche soit terminée.
Relation avec le multithreading
Bien que le threading soit mentionné tout au long de cet article, il y a deux choses importantes à retenir :
- Il n’existe aucune affinité entre un calcul asynchrone et un thread, sauf si elle a démarré explicitement sur le thread actuel.
- 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
- Modèle de programmation asynchrone F#
- Leo Gorodinski’s F# Async Guide
- Guide de la programmation asynchrone de F# pour le plaisir et le profit
- Async in C# and F#: Asynchronous gotchas in C# (Async dans C# et F# : les pièges asynchrones dans C#)