Le récent billet intitulé Des applications toujours rapides et fluides grâce à l'asynchronisme dans Windows Runtime offre plusieurs exemples qui montrent comment le mot clé await utilisé en C# et Visual Basic permet aux développeurs d'utiliser des opérations WinRT asynchrones tout en maintenant un flux de contrôle efficace.

Dans ce billet, j'explique plus en détail le fonctionnement du mot clé await avec WinRT. Forts de ces connaissances, vous comprendrez mieux comment utiliser le mot clé await dans votre code et développer des applications de style Metro encore plus efficaces.

Pour commencer sur de bonnes bases, étudions à quoi ressemblerait un mode sans mot clé await.

Principes de base

Dans WinRT, l'asynchronie s'appuie sur une seule interface : IAsyncInfo.

public interface IAsyncInfo
{
AsyncStatus Status { get; }
HResult ErrorCode { get; }
uint Id { get; }

void Cancel();
void Close();
}

Toutes les opérations asynchrones de WinRT implémentent cette interface, qui fournit les fonctionnalités de base nécessaires à la mise en place d'une opération asynchrone, à la collecte de données sur son identité et son état, et à l'envoi des demandes d'annulation. Cependant, il manque à cette interface spécifique l'un des aspects les plus importants d'une opération asynchrone : un rappel signalant la fin de l'opération à l'écouteur. Cette fonctionnalité est volontairement séparée en quatre interfaces distinctes, qui nécessitent toutes IAsyncInfo, chaque opération asynchrone de WinRT implémentant l'une de ces quatre interfaces :

public interface IAsyncAction : IAsyncInfo
{
AsyncActionCompletedHandler Completed { get; set; }
void GetResults();
}

public interface IAsyncOperation<TResult> : IAsyncInfo
{
AsyncOperationCompletedHandler<TResult> Completed { get; set; }
TResult GetResults();
}

public interface IAsyncActionWithProgress<TProgress> : IAsyncInfo
{
AsyncActionWithProgressCompletedHandler<TProgress> Completed { get; set; }
AsyncActionProgressHandler<TProgress> Progress { get; set; }
void GetResults();
}

public interface IAsyncOperationWithProgress<TResult, TProgress> : IAsyncInfo
{
AsyncOperationWithProgressCompletedHandler<TResult, TProgress> Completed { get; set; }
AsyncOperationProgressHandler<TResult, TProgress> Progress { get; set; }
TResult GetResults();
}

Ces quatre interfaces prennent en charge toutes les combinaisons, avec ou sans résultats et avec ou sans rapports de progression. Toutes les interfaces présentent une propriété Completed, qui peut être définie sur un délégué qui est appelé au terme de l'opération. Vous ne pouvez définir le délégué qu'une seule fois. S'il est défini alors que l'opération est déjà terminée, il est immédiatement planifié ou appelé, et l'implémentation se charge alors de gérer la compétition entre l'opération qui se termine et le délégué en cours d'affectation.

Supposons maintenant que je souhaite implémenter une application de style Metro à l'aide d'un bouton XAML, de telle sorte que le bouton mette en file d'attente certaines tâches dans le pool de threads WinRT, afin de réaliser une opération consommant beaucoup de ressources de calcul. Une fois ces tâches terminées, le contenu du bouton est mis à jour et affiche le résultat de l'opération.

Comment implémentons-nous ce comportement ? La classe WinRT ThreadPool expose une méthode permettant d'exécuter des tâches de façon asynchrone en utilisant le pool :

public static IAsyncAction RunAsync(WorkItemHandler handler);

Nous pouvons utiliser cette méthode pour mettre en file d'attente les tâches consommant beaucoup de ressources de calcul, afin d'éviter de bloquer notre thread d'interface utilisateur lors de l'exécution :

private void btnDoWork_Click(object sender, RoutedEventArgs e)
{
int result = 0;
var op = ThreadPool.RunAsync(delegate { result = Compute(); });
}

Nous avons réussi à décharger les tâches du thread d'interface utilisateur auprès du pool, mais comment savoir quand les tâches sont terminées ? RunAsync renvoie une interface IAsyncAction. Nous pouvons donc utiliser un gestionnaire d'achèvement pour recevoir cette notification et exécuter l'opération suivante en réponse :

private void btnDoWork_Click(object sender, RoutedEventArgs e)
{
int result = 0;
var op = ThreadPool.RunAsync(delegate { result = Compute(); });
op.Completed = delegate(IAsyncAction asyncAction, AsyncStatus asyncStatus)
{
btnDoWork.Content = result.ToString(); // bug!
};
}

Ainsi, une fois que l'opération asynchrone mise en file d'attente dans le ThreadPool se termine, notre gestionnaire Completed est appelé et essaie de stocker le résultat dans notre bouton. Malheureusement, cela ne fonctionne pas pour le moment. En effet, le gestionnaire Completed a peu de chances d'être appelé sur le thread d'interface utilisateur. Pourtant, pour pouvoir modifier btnDoWork.Content, le gestionnaire doit être exécuté sur le thread d'interface utilisateur (dans le cas contraire, une exception associée au code d'erreur RPC_E_WRONG_THREAD est générée). Pour résoudre ce problème, nous pouvons utiliser l'objet CoreDispatcher associé à notre interface utilisateur, afin de rediriger l'invocation de façon adéquate :

private void btnDoWork_Click(object sender, RoutedEventArgs e)
{
int result = 0;
var op = ThreadPool.RunAsync(delegate { result = Compute(); });
op.Completed = delegate(IAsyncAction asyncAction, AsyncStatus asyncStatus)
{
Dispatcher.RunAsync(CoreDispatcherPriority.Normal, delegate
{
btnDoWork.Content = result.ToString();
});
};
}

Tout fonctionne maintenant correctement. Cependant, que se passe-t-il si jamais la méthode Compute génère une exception ou si quelqu'un appelle Cancel sur l'interface IAsyncAction renvoyée par ThreadPool.RunAsync ? Notre gestionnaire Completed doit être capable de gérer l'éventualité que l'interface IAsyncAction se retrouve dans l'un des trois états finaux, Completed, Error ou Canceled :

private void btnDoWork_Click(object sender, RoutedEventArgs e)
{
int result = 0;
var op = ThreadPool.RunAsync(delegate { result = Compute(); })
op.Completed = delegate(IAsyncAction asyncAction, AsyncStatus asyncStatus)
{
Dispatcher.RunAsync(CoreDispatcherPriority.Normal, delegate
{
switch (asyncStatus)
{
case AsyncStatus.Completed:
btnDoWork.Content = result.ToString();
break;
case AsyncStatus.Error:
btnDoWork.Content = asyncAction.ErrorCode.Message;
break;
case AsyncStatus.Canceled:
btnDoWork.Content = "A task was canceled";
break;
}
});
};
}

Sachant qu'il faut écrire une quantité relativement importante de code pour traiter une seule invocation asynchrone, imaginez la quantité de travail requise pour réaliser plusieurs opérations asynchrones en série... Ne serait-il pas formidable de pouvoir plutôt écrire du code similaire au code ci-dessous ?

private async void btnDoWork_Click(object sender, RoutedEventArgs e)
{
try
{
int result = 0;
await ThreadPool.RunAsync(delegate { result = Compute(); });
btnDoWork.Content = result.ToString();
}
catch (Exception exc) { btnDoWork.Content = exc.Message; }
}

Ce code fonctionne exactement comme le code précédent, à quelques différences près. Ainsi, nous n'avons pas besoin de gérer manuellement les rappels d'achèvement. Nous n'avons pas besoin de rediriger manuellement l'invocation vers le thread d'interface utilisateur. Nous n'avons pas non plus besoin de vérifier de façon explicite l'état d'achèvement. Enfin, nous n'avons pas besoin d'inverser le flux de contrôle, ce qui signifie qu'il est désormais très simple de réaliser une extension avec plus d'opérations, par exemple pour effectuer plusieurs calculs et mises à jour d'interface utilisateur au sein d'une boucle :

private async void btnDoWork_Click(object sender, RoutedEventArgs e)
{
try
{
for(int i=0; i<5; i++)
{
int result = 0;
await ThreadPool.RunAsync(delegate { result = Compute(); });
btnDoWork.Content = result.ToString();

}
}
catch (Exception exc) { btnDoWork.Content = exc.Message; }
}

Réfléchissez un instant au code que vous auriez dû écrire pour parvenir au même résultat en utilisant manuellement l'interface IAsyncAction. Voilà toute la magie des nouveaux mots clés async/await en C# et Visual Basic. Bonne nouvelle : vous pouvez en fait écrire exactement ce code et cela n'a rien de magique ! Dans la suite de ce billet, nous examinons exactement comment tout cela fonctionne en arrière-plan.

Transformations du compilateur

En marquant une méthode avec le mot clé async, le compilateur C# ou Visual Basic réécrit l'implémentation de la méthode en utilisant une machine à états. Grâce à cette dernière, le compilateur peut insérer dans la méthode des points auxquels la méthode peut suspendre et reprendre son exécution sans bloquer un thread. Ces points ne sont pas insérés au hasard. Ils sont insérés uniquement lorsque vous utilisez de façon explicite le mot clé await :

private async void btnDoWork_Click(object sender, RoutedEventArgs e)
{
...
await something; // <-- potential method suspension point
...
}

Lorsque vous attendez une opération asynchrone qui n'est pas encore terminée, le code généré par le compilateur vérifie que tous les états associés à la méthode (c'est-à-dire les variables locales) sont packagés et préservés dans le tas. La fonction retourne ensuite vers l'appelant, ce qui permet au thread sur lequel elle était exécutée de réaliser d'autres tâches. Lorsque l'opération asynchrone attendue se termine, l'exécution de la méthode reprend en utilisant l'état préservé.

Tout type exposant le modèle await peut être attendu. Ce modèle consiste principalement à exposer une méthode GetAwaiter qui renvoie un type fournissant les membres IsCompleted, OnCompleted et GetResult. Lorsque vous écrivez le code suivant,

await something;

le compilateur génère un code utilisant ces membres sur l'instance something, afin d'identifier si l'objet est déjà terminé (via IsCompleted). S'il n'est pas terminé, le code attache une continuation (via OnCompleted) qui rappelle la nécessité de poursuivre l'exécution lorsque la tâche est finalement terminée. Au terme de l'opération, les exceptions résultantes sont propagées et/ou un résultat est renvoyé (via GetResult). Par conséquent, lorsque vous écrivez le code suivant,

private async void btnDoWork_Click(object sender, RoutedEventArgs e)
{
...
await ThreadPool.RunAsync(delegate { result = Compute(); });
...
}

le compilateur le traduit en un code similaire à celui-ci :

private class btnDoWork_ClickStateMachine : IAsyncStateMachine
{
// Member fields for preserving locals and other necessary state
int $state;
TaskAwaiter<string> $awaiter;
int result;
...
// Method that moves to the next state in the state machine
public void MoveNext()
{
// Jump table to get back to the right statement upon resumption
switch (this.$state)
{
...
case 2: goto Label2;
...
}
...
// Expansion of await ...;
var tmp1 = ThreadPool.RunAsync(delegate { this.result = Compute(); });
this.$awaiter = tmp1.GetAwaiter();
if (!this.$awaiter.IsCompleted)
{
this.$state = 2;
this.$awaiter.OnCompleted(MoveNext);
return;
Label2:
}
this.$awaiter.GetResult();
...
}
}

Pour la méthode btnDoWork_Click, le compilateur génère une classe de machine à états contenant une méthode MoveNext. Tous les appels envoyés à MoveNext relancent l'exécution de la méthode btnDoWork_Click, jusqu'à atteindre le prochain mot clé await sur un élément qui n'est pas encore terminé ou jusqu'à la fin de la méthode, au premier des deux termes échus. Lorsque le code généré par le compilateur trouve une instance attendue qui n'est pas encore terminée, il marque l'emplacement actuel en utilisant une variable d'état, planifie l'exécution de la méthode de sorte qu'elle soit relancée lorsque l'instance attendue se termine, puis retourne une valeur. Lorsque l'instance attendue se termine finalement, la méthode MoveNext est appelée à nouveau et reprend l'exécution de la méthode au point où elle avait été suspendue précédemment.

Pour le compilateur, peu importe qu'une méthode IAsyncAction soit ou non attendue. En effet, l'important est que le modèle avec lequel une liaison doit être établie soit disponible. Bien évidemment, vous savez à quoi ressemble l'interface IAsyncAction et vous avez pu constater qu'elle ne contient pas de méthode GetAwaiter similaire à celle attendue par le compilateur. Par conséquent, comment la compilation et l'exécution peuvent-elles se dérouler correctement ? Pour mieux comprendre comment cela fonctionne, vous devez d'abord comprendre les types .NET Task et Task<TResult> (la représentation centrale des opérations asynchrones dans .NET Framework) et la manière dont ils sont liés à await.

Conversion en tâches

.NET Framework 4.5 inclut l'ensemble des types et des méthodes nécessaires pour prendre en charge l'attente des instances Task et Task<TResult> (Task<TResult> est un dérivé de Task). Task et Task<TResult> exposent tous deux les méthodes d'instance GetAwaiter, qui renvoient respectivement les types TaskAwaiter et TaskAwaiter<TResult> exposant les membres IsCompleted, OnCompleted et GetResult nécessaires et suffisants pour satisfaire les compilateurs C#et Visual Basic. IsCompleted renvoie une valeur booléenne indiquant si l'exécution de la tâche est terminée au moment de l'accès à la propriété. OnCompleted attache à la tâche un délégué de continuation qui est appelé au terme de la tâche (si la tâche est déjà terminée au moment où OnCompleted est appelé, le délégué de continuation est planifié en vue d'une exécution asynchrone). GetResult renvoie le résultat de la tâche si elle se termine à l'état TaskStatus.RanToCompletion (il renvoie la valeur void pour le type non générique Task), génère une exception OperationCanceledException si la tâche se termine à l'état TaskStatus.Canceled et génère l'exception qui a provoqué l'échec de la tâche, si cette dernière s'est terminée à l'état TaskStatus.Faulted.

Si nous disposons d'un type personnalisé et que nous voulons qu'il prenne en charge l'attente, deux options principales s'offrent à nous. Nous pouvons tout d'abord implémenter le modèle await complet de façon manuelle pour notre type personnalisé, en fournissant une méthode GetAwaiter qui renvoie un type d'awaiter personnalisé qui est en mesure de gérer les continuations, la propagation d'exceptions et les autres éléments similaires. La deuxième solution consiste à implémenter la possibilité de convertir notre type personnalisé en tâche, puis à nous appuyer ensuite sur le support intégré pour que les tâches d'attente attendent notre type spécial. Examinons de plus près cette deuxième approche.

.NET Framework intègre un type TaskCompletionSource<TResult> qui facilite ce type de conversion. TaskCompletionSource<TResult> crée un objet Task<TResult> et vous offre des méthodes SetResult, SetException et SetCanceled, que vous pouvez utiliser pour contrôler directement quand et dans quel état la tâche correspondante doit se terminer. Ainsi, vous pouvez utiliser TaskCompletionSource<TResult> comme une sorte de shim ou de proxy, pour représenter une autre opération asynchrone, par exemple une opération asynchrone WinRT.

Supposons pendant quelques instants que vous ne savez pas que vous pouvez attendre directement des opérations WinRT. Comment pourriez-vous alors procéder ?

IAsyncOperation<string> op = SomeMethodAsync();
string result = await ...; // need something to await

Vous pourriez créer un TaskCompletionSource<TResult> et l'utiliser comme proxy pour représenter l'opération asynchrone WinRT, puis attendre la tâche correspondante. Essayons. Nous devons commencer par instancier un TaskCompletionSource<TResult>, afin de pouvoir attendre sa tâche Task :

IAsyncOperation<string> op = SomeMethodAsync();
var tcs = new TaskCompletionSource<TResult>();
...
string result = await tcs.Task;

Ensuite, comme nous l'avons vu dans l'exemple ci-dessus portant sur l'utilisation manuelle des gestionnaires Completed des opérations WinRT asynchrones, nous devons attacher un rappel à l'opération asynchrone, pour savoir quand elle se termine :

IAsyncOperation<string> op = SomeMethodAsync();

var tcs = new TaskCompletionSource<TResult>();

op.Completed = delegate
{
...
};
string result = await tcs.Task;

Au sein de ce rappel, nous devons ensuite transférer l'état d'achèvement de IAsyncOperation<TResult> à la tâche :

IAsyncOperation<string> op = SomeMethodAsync();
var tcs = new TaskCompletionSource<TResult>();
op.Completed = delegate
{
switch(operation.Status)
{
AsyncStatus.Completed:
tcs.SetResult(operation.GetResults());
break;
AsyncStatus.Error:
tcs.SetException(operation.ErrorCode);
break;
AsyncStatus.Canceled:
tcs.SetCanceled();
break;
}
};
string result = await tcs.Task;

C'est tout ! Les opérations WinRT asynchrones garantissent l'appel adéquat du gestionnaire Completed, même si le gestionnaire est inscrit alors que l'opération est terminée. Ainsi, aucune action spécifique n'est requise pour inscrire le gestionnaire qui se trouve en compétition avec l'achèvement de l'opération. Les opérations WinRT asynchrones s'occupent également de déposer la référence auprès du gestionnaire Completed au terme de l'opération. Ainsi, aucune action spécifique n'est requise pour affecter la valeur null à Completed lorsque notre gestionnaire est appelé. En fait, le gestionnaire Completed n'est défini qu'une seule fois : dès lors qu'il a été défini, une erreur se produit si vous essayez de le redéfinir.

Avec cette approche, un mappage un-à-un est mis en place entre l'état dans lequel l'opération WinRT asynchrone se termine et l'état dans lequel la tâche qui la représente se termine :

État AsyncStatus final

Conversion en état TaskStatus

Qui, en cas de mise en attente...

Completed

RanToCompletion

Renvoie le résultat de l'opération (ou la valeur void)

Error

Faulted

Génère l'exception relative à l'échec de l'opération

Canceled

Canceled

Génère une exception OperationCanceledException

Bien évidemment, le code réutilisable que nous avons écrit pour gérer ce mot clé await spécifique deviendrait très rapidement fastidieux si nous devions l'écrire à chaque fois que nous souhaitons attendre une opération WinRT asynchrone. Pour suivre les bonnes pratiques, nous pouvons encapsuler ce code réutilisable dans une méthode utilisable à l'infini. Utilisons cette méthode sous forme de méthode d'extension, qui convertit l'opération WinRT asynchrone en tâche :

public static Task<TResult> AsTask<TResult>(
this IAsyncOperation<TResult> operation)
{
var tcs = new TaskCompletionSource<TResult>();
operation.Completed = delegate
{
switch(operation.Status)
{
AsyncStatus.Completed:
tcs.SetResult(operation.GetResults());
break;
AsyncStatus.Error:
tcs.SetException(operation.ErrorCode);
break;
AsyncStatus.Canceled:
tcs.SetCanceled();
break;
}
};
return tcs.Task;
}

Avec cette méthode d'extension, je peux maintenant écrire le code suivant :

IAsyncOperation<string> op = SomeMethodAsync();
string result = await op.AsTask();

Ou un code plus simple encore :

string result = await SomeMethodAsync().AsTask();

C'est mieux ! Bien évidemment, cette fonctionnalité AsTask, similaire à une conversion de type, sera très prisée des développeurs utilisant WinRT à partir de C# et Visual Basic, et vous n'êtes donc pas obligé d'écrire votre propre implémentation : des méthodes de ce type sont déjà intégrées à .NET 4.5. L'assembly System.Runtime.WindowsRuntime.dll contient ces méthodes d'extension pour les interfaces WinRT asynchrones :

namespace System
{
public static class WindowsRuntimeSystemExtensions
{
// IAsyncAction

public static Task AsTask(
this IAsyncAction source);
public static Task AsTask(
this IAsyncAction source,
CancellationToken cancellationToken);

// IAsyncActionWithProgress

public static Task AsTask<TProgress>(
this IAsyncActionWithProgress<TProgress> source);
public static Task AsTask<TProgress>(
this IAsyncActionWithProgress<TProgress> source,
IProgress<TProgress> progress);
public static Task AsTask<TProgress>(
this IAsyncActionWithProgress<TProgress> source,
CancellationToken cancellationToken);
public static Task AsTask<TProgress>(
this IAsyncActionWithProgress<TProgress> source,
CancellationToken cancellationToken,
IProgress<TProgress> progress);

// IAsyncOperation

public static Task<TResult> AsTask<TResult>(
this IAsyncOperation<TResult> source);
public static Task<TResult> AsTask<TResult>(
this IAsyncOperation<TResult> source,
CancellationToken cancellationToken);

// IAsyncOperationWithProgress

public static Task<TResult> AsTask<TResult, TProgress>(
this IAsyncOperationWithProgress<TResult, TProgress> source);
public static Task<TResult> AsTask<TResult, TProgress>(
this IAsyncOperationWithProgress<TResult, TProgress> source,
IProgress<TProgress> progress);
public static Task<TResult> AsTask<TResult, TProgress>(
this IAsyncOperationWithProgress<TResult, TProgress> source,
CancellationToken cancellationToken);
public static Task<TResult> AsTask<TResult, TProgress>(
this IAsyncOperationWithProgress<TResult, TProgress> source,
CancellationToken cancellationToken,
IProgress<TProgress> progress);

...
}
}

Chacune des quatre interfaces dispose d'une surcharge AsTask sans paramètre, similaire à celle que nous avons écrite de A à Z. Par ailleurs, chacune d'entre elles possède également une surcharge qui accepte un CancellationToken. Ce jeton constitue le mécanisme courant utilisé dans .NET pour permettre une annulation composable et coopérative. Vous transmettez un jeton à toutes vos opérations asynchrones et lorsque l'annulation est demandée, l'annulation de toutes ces opérations asynchrones est également demandée. Pour illustrer simplement ce principe (puisque comme vous le savez maintenant, une API de ce type est déjà disponible), posons-nous la question suivante : comment créer notre propre surcharge AsTask(CancellationToken) ? CancellationToken fournit une méthode Register qui accepte l'appel d'un délégué suite à une demande d'annulation. Nous pouvons simplement fournir un délégué qui appelle Cancel sur l'objet IAsyncInfo pour transférer la demande d'annulation :

public static Task<TResult> AsTask<TResult>(
this IAsyncOperation<TResult> operation,
CancellationToken cancellationToken
{
using(cancellationToken.Register(() => operation.Cancel()))
return await operation.AsTask();
}

Bien que l'implémentation intégrée à .NET 4.5 ne soit pas strictement identique, sa logique est similaire.

Pour IAsyncActionWithProgress<TProgress> et IAsyncOperationWithProgress<TResult,TProgress>, il existe également des surcharges acceptant une interface IProgress<TProgress>. IProgress<T> est une interface .NET dont les méthodes peuvent accepter de signaler une progression. La méthode AsTask connecte simplement un délégué à la propriété Progress de l'opération WinRT asynchrone, de façon à transférer les informations de progression vers IProgress. Là encore, examinons uniquement à titre d'exemple comment cela pourrait être implémenté manuellement :

public static Task<TResult> AsTask<TResult,TProgress>(
this IAsyncOperationWithProgress<TResult> operation,
IProgress<TProgress> progress
{
operation.Progress += (_,p) => progress.Report(p);
return operation.AsTask();
}

Attente directe d'une opération WinRT asynchrone

Nous savons maintenant qu'il est possible de créer des tâches pour représenter une opération WinRT asynchrone, de façon à pouvoir ensuite attendre ces tâches. Qu'en est-il exactement si nous souhaitons attendre les opérations WinRT ? En d'autres termes, si nous pouvons tout à fait écrire le code suivant,

await SomeMethodAsync().AsTask();

dans les cas où nous n'avons pas besoin de fournir un CancellationToken ou un IProgress<T>, ne serait-il pas formidable de pouvoir éviter de coder l'appel avec AsTask ?

await SomeMethodAsync();

Bien sûr, c'est tout à fait possible, comme nous l'avons vu au début de ce billet. Vous vous souvenez que le compilateur s'attend à trouver une méthode GetAwaiter renvoyant un type d'awaiter adéquat ? Le type WindowsRuntimeSystemExtensions de System.Runtime.WindowsRuntime.dll déjà mentionné inclut justement les méthodes d'extension GetAwaiter pour les quatre interfaces WinRT asynchrones :

namespace System
{
public static class WindowsRuntimeSystemExtensions
{
...
public static TaskAwaiter GetAwaiter(
this IAsyncAction source);
public static TaskAwaiter<TResult> GetAwaiter<TResult>(
this IAsyncOperation<TResult> source);
public static TaskAwaiter GetAwaiter<TProgress>(
this IAsyncActionWithProgress<TProgress> source);
public static TaskAwaiter<TResult> GetAwaiter<TResult, TProgress>(
this IAsyncOperationWithProgress<TResult, TProgress> source);
}
}

Vous remarquerez le type de retour de chacune de ces méthodes : TaskAwaiter ou TaskAwaiter<TResult>. Chacune de ces méthodes exploite les awaiters de tâche existants intégrés à .NET Framework. Forts de vos connaissances sur AsTask, vous pouvez sans doute deviner comment ils sont implémentés. L'implémentation réelle de .NET Framework correspond presque exactement à ceci :

public static TaskAwaiter GetAwaiter(
this IAsyncAction source)
{
return source.AsTask().GetAwaiter();
}

Par conséquent, ces deux lignes aboutissent toutes les deux au même comportement :

await SomeMethodAsync().AsTask();
await SomeMethodAsync();

Personnalisation du comportement d'attente

Comme nous l'avons déjà expliqué, TaskAwaiter et TaskAwaiter<TResult> fournissent tous les membres nécessaires pour répondre aux attentes du compilateur en termes d'awaiter :

bool IsCompleted { get; }
void OnCompleted(Action continuation);
TResult GetResult(); //returns void on TaskAwaiter

Le membre le plus intéressant est ici OnCompleted, car il est chargé d'appeler le délégué de continuation lorsque l'opération attendue se termine. OnCompleted fournit un comportement de redirection spécifique pour permettre d'exécuter le délégué de continuation au bon endroit.

Par défaut, lorsque la méthode OnCompleted de l'awaiter de la tâche est appelée, elle note le contexte SynchronizationContext actuel, qui correspond à une représentation abstraite de l'environnement dans lequel le code s'exécute. Sur le thread d'interface utilisateur d'une application de style Metro, SynchronizationContext.Current renvoie une instance du type interne WinRTSynchronizationContext. SynchronizationContext fournit une méthode Post qui accepte un délégué et exécute ce délégué à l'endroit adéquat en fonction du contexte. WinRTSynchronizationContext inclut un CoreDispatcher dans un wrapper et utilise son RunAsync pour rappeler le délégué de façon asynchrone sur le thread d'interface utilisateur (comme nous l'avons déjà fait manuellement dans ce billet). Lorsque la tâche attendue se termine, le délégué transmis à OnCompleted est planifié pour l'exécution (via la méthode Post) vers le SynchronizationContext en cours au moment de l'appel de la méthode OnCompleted. C'est ainsi que vous pouvez écrire du code utilisant await dans votre logique d'interface utilisateur sans vous préoccuper d'une quelconque redirection vers le thread adéquat : l'awaiter de la tâche s'en occupe à votre place.

Bien évidemment, dans certaines situations, vous ne souhaitez pas que ce comportement de redirection par défaut ait lieu. Ces situations surviennent fréquemment dans les bibliothèques : de nombreux types de bibliothèques n'accordent aucune espèce d'importance à la manipulation des contrôles d'interface utilisateur ou des threads spécifiques sur lesquels elles sont exécutées. Ainsi, en termes de performances, il est utile de pouvoir s'affranchir du traitement associé aux redirections entre les différents threads. Pour s'adapter aux codes souhaitant désactiver ce comportement de redirection par défaut, Task et Task<TResult> fournissent des méthodes ConfigureAwait. ConfigureAwait accepte un paramètre booléen continueOnCapturedContext. Si la valeur true est transmise, le comportement par défaut est utilisé. Si la valeur false est transmise, le système n'a pas besoin de rediriger par force l'invocation du délégué vers le contexte d'origine, et peut en revanche exécuter le délégué là où le système juge cette exécution adaptée.

Ainsi, si vous souhaitez attendre une opération WinRT sans forcément rediriger le reste de l'exécution vers le thread d'interface utilisateur, au lieu d'écrire

await SomeMethodAsync();

ou

await SomeMethodAsync().AsTask();

vous pouvez écrire

await SomeMethodAsync().AsTask()
.ConfigureAwait(continueOnCapturedContext:false);

ou plus simplement

await SomeMethodAsync().AsTask().ConfigureAwait(false);

Quand utiliser AsTask ?

Si vous souhaitez simplement appeler une opération WinRT asynchrone et attendre qu'elle se termine, l'approche la plus simple consiste à attendre directement l'opération WinRT asynchrone :

await SomeMethodAsync();

En revanche, si vous souhaitez profiter de plus de possibilités de contrôle, vous devez utiliser AsTask. Vous avez déjà pu apprécier son utilité dans quelques cas :

  • Prise en charge de l'annulation via CancellationToken

    CancellationToken token = ...;
    await SomeMethodAsync().AsTask(token);

  • Prise en charge des rapports de progression via IProgress<T>
    IProgress<TProgress> progress = ...;
    await SomeMethodAsync().AsTask(progress);
  • Suppression du comportement par défaut de redirection des continuations via ConfigureAwait
    await SomeMethodAsync().AsTask().ConfigureAwait(false);

AsTask peut également s'avérer utile dans certaines situations importantes.

L'un de ces cas concerne la capacité de Task à prendre en charge plusieurs continuations. Les types d'opérations WinRT asynchrones prennent en charge un seul délégué enregistré avec Completed (Completed est plus une propriété qu'un événement), et celui-ci ne peut être défini qu'une seule fois. Ce comportement est adéquat dans la plupart des cas, lorsque vous souhaitez simplement attendre l'opération une seule fois. Par exemple, au lieu d'appeler une méthode synchrone,

SomeMethod();

vous appelez et attendez une contrepartie asynchrone,

await SomeMethodAsync();

en maintenant logiquement le même flux de contrôle que si vous aviez utilisé la contrepartie synchrone. Cependant, dans certains cas, vous souhaitez pouvoir attacher plusieurs rappels ou attendre la même instance plusieurs fois. Contrairement aux interfaces WinRT asynchrones, le type Task peut être attendu autant de fois que vous le souhaitez et/ou sa méthode ContinueWith peut être utilisée aussi souvent que vous le souhaitez, pour prendre en charge n'importe quelle quantité de rappels. Ainsi, vous pouvez utiliser AsTask pour obtenir une tâche pour votre opération WinRT asynchrone, puis attacher vos rappels à Task au lieu de les attacher directement à l'opération WinRT asynchrone.

var t = SomeMethodAsync().AsTask();
t.ContinueWith(delegate { ... });
t.ContinueWith(delegate { ... });
t.ContinueWith(delegate { ... });

Autre situation où AsTask peut s'avérer utile : lorsque vous utilisez des méthodes fonctionnant par rapport aux types Task ou Task<TResult>. Les méthodes de combinateur telles que Task.WhenAll ou Task.WhenAny fonctionnent par rapport à Task et non par rapport aux interfaces WinRT asynchrones. Par conséquent, si vous souhaitez pouvoir appeler plusieurs opérations WinRT asynchrones, puis attendre qu'elles se terminent entièrement ou partiellement, via await, vous pouvez utiliser AsTask pour simplifier l'opération. Ainsi, cet await prend fin dès que l'une des trois opérations fournies se termine, et renvoie la représentation Task correspondante :

Task firstCompleted = await Task.WhenAny(
SomeMethod1Async().AsTask(),
SomeMethod2Async().AsTask(),
SomeMethod3Async().AsTask());

Conclusion

La quantité de fonctionnalités offertes par WinRT grâce aux opérations asynchrones est tout bonnement impressionnante. En outre, la quantité d'API exposées témoigne de l'importance de la réactivité sur cette plateforme. Par conséquent, le modèle de programmation utilisé pour gérer ces opérations est très sollicité : pour le code C# et Visual Basic, await et AsTask se montrent à la hauteur. J'espère que ce billet vous aura permis de comprendre en détail l'utilisation de ces fonctionnalités et que vous pourrez ainsi développer de façon efficace des applications de style Metro.

Pour en savoir plus, je vous recommande de consulter les ressources suivantes :

Stephen Toub
Visual Studio