Implémentation du modèle asynchrone basé sur des tâches

Vous pouvez implémenter le modèle asynchrone basé sur des tâches (TAP) de trois façons : à l’aide des compilateurs C# et Visual Basic dans Visual Studio, manuellement, ou par une combinaison des méthodes du compilateur et des méthodes manuelles. Les sections suivantes abordent chacune de ces méthodes en détail. Vous pouvez utiliser le modèle TAP pour implémenter à la fois des opérations asynchrones liées aux calculs et liées aux E/S. La section Charges de travail aborde chacun des types d'opérations.

Génération de méthodes TAP

Utilisation des compilateurs

À partir de .NET Framework 4.5, toute méthode attribuée avec le mot clé async (Async en Visual Basic) est considérée comme une méthode asynchrone. Les compilateurs C# et Visual Basic effectuent alors les transformations nécessaires pour implémenter la méthode de façon asynchrone à l’aide du modèle TAP. Une méthode asynchrone doit retourner un objet System.Threading.Tasks.Task ou System.Threading.Tasks.Task<TResult>. Dans le dernier cas, le corps de la fonction doit retourner un TResult. Le compilateur garantit ensuite que ce résultat est rendu disponible via l’objet de tâche qui en résulte. De même, toutes les exceptions non gérées dans le corps de la méthode sont marshalées vers la tâche de sortie et provoquent la fin de la tâche résultante avec l’état TaskStatus.Faulted. Une exception à cette règle est levée quand un OperationCanceledException (ou un type dérivé) n’est pas géré, auquel cas la tâche qui en résulte se termine avec l’état TaskStatus.Canceled.

Génération manuelle des méthodes TAP

Vous pouvez implémenter le modèle TAP manuellement pour mieux contrôler l'implémentation. Le compilateur s'appuie sur la surface publique exposée depuis l'espace de noms System.Threading.Tasks et sur les types de prise en charge de l'espace de noms System.Runtime.CompilerServices. Pour implémenter le modèle TAP vous-même, créez un objet TaskCompletionSource<TResult>, effectuez l'opération asynchrone, et lorsqu'elle est terminée, appelez la méthode SetResult, SetException ou SetCanceled, ou bien la version Try de l'une de ces méthodes. Quand vous implémentez une méthode TAP manuellement, vous devez achever la tâche qui en résulte quand l’opération asynchrone représentée se termine. Par exemple :

public static Task<int> ReadTask(this Stream stream, byte[] buffer, int offset, int count, object state)
{
    var tcs = new TaskCompletionSource<int>();
    stream.BeginRead(buffer, offset, count, ar =>
    {
        try { tcs.SetResult(stream.EndRead(ar)); }
        catch (Exception exc) { tcs.SetException(exc); }
    }, state);
    return tcs.Task;
}
<Extension()>
Public Function ReadTask(stream As Stream, buffer() As Byte,
                         offset As Integer, count As Integer,
                         state As Object) As Task(Of Integer)
    Dim tcs As New TaskCompletionSource(Of Integer)()
    stream.BeginRead(buffer, offset, count, Sub(ar)
                                                Try
                                                    tcs.SetResult(stream.EndRead(ar))
                                                Catch exc As Exception
                                                    tcs.SetException(exc)
                                                End Try
                                            End Sub, state)
    Return tcs.Task
End Function

Approche hybride

Il peut s’avérer utile d’implémenter le modèle TAP manuellement pour déléguer la logique de base de l’implémentation du compilateur. Par exemple, vous pouvez utiliser l’approche hybride quand vous voulez vérifier des arguments en dehors d’une méthode asynchrone générée par le compilateur, pour que les exceptions puissent être échappées vers l’appelant direct de la méthode, plutôt que d’être exposées par l’objet System.Threading.Tasks.Task :

public Task<int> MethodAsync(string input)
{
    if (input == null) throw new ArgumentNullException("input");
    return MethodAsyncInternal(input);
}

private async Task<int> MethodAsyncInternal(string input)
{

   // code that uses await goes here

   return value;
}
Public Function MethodAsync(input As String) As Task(Of Integer)
    If input Is Nothing Then Throw New ArgumentNullException("input")

    Return MethodAsyncInternal(input)
End Function

Private Async Function MethodAsyncInternal(input As String) As Task(Of Integer)

    ' code that uses await goes here

    return value
End Function

La délégation est également utile quand vous implémentez une optimisation de chemin d'accès rapide et souhaitez retourner une tâche mise en cache.

Charges de travail

Vous pouvez implémenter des opérations asynchrones liées aux calculs et liées aux E/S en tant que méthodes TAP. Toutefois, quand des méthodes TAP sont exposées publiquement depuis une bibliothèque, elles doivent uniquement être fournies pour les charges de travail qui impliquent des opérations d'E/S (elles peuvent également impliquer des calculs, mais ne doivent pas consister uniquement de calculs). Si une méthode est entièrement liée aux calculs, elle doit uniquement être exposée en tant qu’implémentation synchrone. Le code qui la consomme peut alors décider s'il faut encapsuler un appel à cette méthode synchrone dans une tâche pour décharger le travail vers un autre thread ou pour atteindre un parallélisme. En revanche, si une méthode est liée aux E/S, elle doit uniquement être exposée en tant qu’implémentation asynchrone.

Tâches liées aux calculs

La classe System.Threading.Tasks.Task convient parfaitement aux opérations de calcul intensives. Par défaut, elle tire parti de la prise en charge spéciale de la classe ThreadPool pour assurer une exécution efficace. Elle permet également de contrôler de manière précise le moment, l'endroit et la manière dont sont exécutés les calculs asynchrones.

Vous pouvez générer des tâches liées au calcul de la manière suivante :

  • Dans .NET Framework 4.5 et versions ultérieures (notamment .NET Core et .NET 5+), utilisez la méthode statique Task.Run comme raccourci de TaskFactory.StartNew. Vous pouvez utiliser Run pour lancer facilement une tâche liée aux calculs qui cible le pool de threads. Il s’agit du mécanisme privilégié pour lancer une tâche liée aux calculs. StartNew ne doit s'utiliser directement que quand vous souhaitez contrôler la tâche de manière plus précise.

  • Dans .NET Framework 4, utilisez la méthode TaskFactory.StartNew qui accepte un délégué (en général un Action<T> ou un Func<TResult>) à exécuter de façon asynchrone. Si vous fournissez un délégué Action<T>, la méthode retourne un objet System.Threading.Tasks.Task qui représente l'exécution asynchrone de ce délégué. Si vous fournissez un délégué Func<TResult>, la méthode retourne un objet System.Threading.Tasks.Task<TResult>. Les surcharges de la méthode StartNew acceptent un jeton d’annulation, (CancellationToken), des options de création de tâche (TaskCreationOptions) et un planificateur de tâches (TaskScheduler) qui permettent un contrôle précis de la planification et de l’exécution de la tâche. Une instance de fabrique qui cible le planificateur de tâches actuel est disponible sous la forme d’une propriété statique (Factory) de la classeTask, par exemple Task.Factory.StartNew(…).

  • Utilisez les constructeurs du type Task et de la méthode Start pour générer et planifier la tâche séparément. Les méthodes publiques doivent retourner uniquement les tâches qui ont déjà été lancées.

  • Utilisez les surcharges de la méthode Task.ContinueWith. Cette méthode crée une nouvelle tâche qui est planifiée quand l’exécution d’une autre tâche se termine. Certaines surcharges ContinueWith acceptent un jeton d’annulation, des options de continuation et un planificateur de tâches pour un meilleur contrôle de la planification et de l’exécution de la tâche de continuation.

  • Utilisez les méthodes TaskFactory.ContinueWhenAll et TaskFactory.ContinueWhenAny. Ces méthodes créent une nouvelle tâche qui est planifiée quand une partie ou l’intégralité d’un ensemble de tâches fourni se termine. Ces méthodes fournissent également des surcharges permettant de contrôler la planification et l’exécution de ces tâches.

Dans les tâches liées aux calculs, le système peut empêcher l'exécution d'une tâche planifiée s'il reçoit une demande d'annulation avant l'exécution de la tâche. Par conséquent, si vous fournissez un jeton d'annulation (un objet CancellationToken), vous pouvez passer ce jeton au code asynchrone qui surveille le jeton. Vous pouvez également fournir le jeton pour l'une des méthodes mentionnées précédemment telles que StartNew ou Run, pour que le runtime Task puisse également surveiller le jeton.

Prenons, par exemple, une méthode asynchrone qui génère le rendu d'une image. Le corps de la tâche peut interroger le jeton d’annulation pour que l’exécution du code puisse s’arrêter plus tôt si une demande d’annulation arrive pendant le rendu. De plus, si la demande d'annulation arrive avant le début du rendu, vous pourrez empêcher l'opération de rendu :

internal Task<Bitmap> RenderAsync(
              ImageData data, CancellationToken cancellationToken)
{
    return Task.Run(() =>
    {
        var bmp = new Bitmap(data.Width, data.Height);
        for(int y=0; y<data.Height; y++)
        {
            cancellationToken.ThrowIfCancellationRequested();
            for(int x=0; x<data.Width; x++)
            {
                // render pixel [x,y] into bmp
            }
        }
        return bmp;
    }, cancellationToken);
}
Friend Function RenderAsync(data As ImageData, cancellationToken As _
                            CancellationToken) As Task(Of Bitmap)
    Return Task.Run(Function()
                        Dim bmp As New Bitmap(data.Width, data.Height)
                        For y As Integer = 0 to data.Height - 1
                            cancellationToken.ThrowIfCancellationRequested()
                            For x As Integer = 0 To data.Width - 1
                                ' render pixel [x,y] into bmp
                            Next
                        Next
                        Return bmp
                    End Function, cancellationToken)
End Function

Les tâches liées aux calculs se terminent avec l’état Canceled si au moins l’une des conditions suivantes est remplie :

  • Une demande d’annulation arrive via l’objet CancellationToken, qui est fourni en tant qu’argument à la méthode de création (par exemple, StartNew ou Run) avant que la tâche ne passe à l’état Running.

  • Une exception OperationCanceledException n’est pas gérée dans le corps d’une telle tâche. Cette exception contient le même CancellationToken qui est passé à la tâche et ce jeton indique qu’une annulation a été demandée.

Si une autre exception n’est pas gérée dans le corps de la tâche, la tâche se termine avec l’état Faulted, et toute tentative d’attendre la tâche ou d’accéder à son résultat provoque la levée d’une exception.

Tâches d’E/S

Pour créer une tâche qui ne doit pas être sauvegardée directement par un thread pour l’intégralité de son exécution, utilisez le type TaskCompletionSource<TResult>. Ce type expose une propriété Task qui retourne une instance Task<TResult> associée. Le cycle de vie de cette tâche est contrôlé par les méthodes TaskCompletionSource<TResult> telles que SetResult, SetException et SetCanceled, et leurs variantes TrySet.

Supposons que vous vouliez créer une tâche devant se terminer après une période spécifiée. Par exemple, vous pouvez vouloir différer une activité dans l'interface utilisateur. La classe System.Threading.Timer fournit déjà la possibilité d'appeler un délégué de façon asynchrone après une période donnée, et grâce à TaskCompletionSource<TResult>, vous pouvez ajouter un Task<TResult> au minuteur, par exemple :

public static Task<DateTimeOffset> Delay(int millisecondsTimeout)
{
    TaskCompletionSource<DateTimeOffset> tcs = null;
    Timer timer = null;

    timer = new Timer(delegate
    {
        timer.Dispose();
        tcs.TrySetResult(DateTimeOffset.UtcNow);
    }, null, Timeout.Infinite, Timeout.Infinite);

    tcs = new TaskCompletionSource<DateTimeOffset>(timer);
    timer.Change(millisecondsTimeout, Timeout.Infinite);
    return tcs.Task;
}
Public Function Delay(millisecondsTimeout As Integer) As Task(Of DateTimeOffset)
    Dim tcs As TaskCompletionSource(Of DateTimeOffset) = Nothing
    Dim timer As Timer = Nothing

    timer = New Timer(Sub(obj)
                          timer.Dispose()
                          tcs.TrySetResult(DateTimeOffset.UtcNow)
                      End Sub, Nothing, Timeout.Infinite, Timeout.Infinite)

    tcs = New TaskCompletionSource(Of DateTimeOffset)(timer)
    timer.Change(millisecondsTimeout, Timeout.Infinite)
    Return tcs.Task
End Function

La méthode Task.Delay est fournie à cet effet, et vous pouvez l’utiliser dans une autre méthode asynchrone, par exemple, pour implémenter une boucle d’interrogation asynchrone :

public static async Task Poll(Uri url, CancellationToken cancellationToken,
                              IProgress<bool> progress)
{
    while(true)
    {
        await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
        bool success = false;
        try
        {
            await DownloadStringAsync(url);
            success = true;
        }
        catch { /* ignore errors */ }
        progress.Report(success);
    }
}
Public Async Function Poll(url As Uri, cancellationToken As CancellationToken,
                           progress As IProgress(Of Boolean)) As Task
    Do While True
        Await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken)
        Dim success As Boolean = False
        Try
            await DownloadStringAsync(url)
            success = true
        Catch
            ' ignore errors
        End Try
        progress.Report(success)
    Loop
End Function

La classe TaskCompletionSource<TResult> ne possède pas d'équivalent non générique. Toutefois, Task<TResult> dérive de Task. Vous pouvez donc utiliser l’objet TaskCompletionSource<TResult> générique pour les méthodes d’E/S qui ne font que retourner une tâche. Pour ce faire, vous pouvez utiliser une source avec un TResult factice (Boolean est un bon choix par défaut, mais si vous avez peur que l'utilisateur de la Task en fasse une classe de base Task<TResult>, vous pouvez utiliser un type TResult privé à la place). Par exemple, la méthode Delay de l'exemple précédent retourne l'heure actuelle, ainsi que le décalage résultant (Task<DateTimeOffset>). Si une telle valeur n'est pas nécessaire, la méthode peut être codée comme suit (notez le changement du type de retour et le remplacement de l'argument par TrySetResult) :

public static Task<bool> Delay(int millisecondsTimeout)
{
     TaskCompletionSource<bool> tcs = null;
     Timer timer = null;

     timer = new Timer(delegate
     {
         timer.Dispose();
         tcs.TrySetResult(true);
     }, null, Timeout.Infinite, Timeout.Infinite);

     tcs = new TaskCompletionSource<bool>(timer);
     timer.Change(millisecondsTimeout, Timeout.Infinite);
     return tcs.Task;
}
Public Function Delay(millisecondsTimeout As Integer) As Task(Of Boolean)
    Dim tcs As TaskCompletionSource(Of Boolean) = Nothing
    Dim timer As Timer = Nothing

    Timer = new Timer(Sub(obj)
                          timer.Dispose()
                          tcs.TrySetResult(True)
                      End Sub, Nothing, Timeout.Infinite, Timeout.Infinite)

    tcs = New TaskCompletionSource(Of Boolean)(timer)
    timer.Change(millisecondsTimeout, Timeout.Infinite)
    Return tcs.Task
End Function

Tâches à la fois liées aux calculs et aux E/S

Les méthodes asynchrones peuvent comprendre à la fois des opérations de calcul et d'E/S. En réalité, les opérations asynchrones sont souvent combinées au sein de plus grandes opérations mixtes. Par exemple, la méthode RenderAsync de l'exemple précédent permettait d'exécuter une opération de calcul intensive pour restituer une image basée sur des imageData d'entrée. Ces imageData peuvent provenir d'un service web auquel vous pouvez accéder de manière asynchrone :

public async Task<Bitmap> DownloadDataAndRenderImageAsync(
    CancellationToken cancellationToken)
{
    var imageData = await DownloadImageDataAsync(cancellationToken);
    return await RenderAsync(imageData, cancellationToken);
}
Public Async Function DownloadDataAndRenderImageAsync(
             cancellationToken As CancellationToken) As Task(Of Bitmap)
    Dim imageData As ImageData = Await DownloadImageDataAsync(cancellationToken)
    Return Await RenderAsync(imageData, cancellationToken)
End Function

Cet exemple montre également comment un jeton d'annulation peut être associé à un thread par l'intermédiaire de plusieurs opérations asynchrones. Pour plus d’informations, consultez la section relative à l’annulation dans Utilisation du modèle asynchrone basé sur les tâches.

Voir aussi