This article is also available in english.
TL;DR: Cet article est une discussion à propos de la manière dont C# 5.0 async capture le contexte d'exécution avant d'exécuter une méthode asynchrone, ce qui permet de facilement rester sur la Thread UI pour accéder aux éléments visuels, mais qui peut être problématique lors de l'exécution de taches demandantes en CPU. En dehors du UI Thread, une méthode async peut sauter de Thread en Thread, cassant tout code qui est dépendant de la thread sur laquelle il s'exécute.
C# 5.0 a introduit uniquement deux fonctionnalités dans le langage (async et caller information) et un correctif pour un piège (un changement de scope pour les variables lifted dans une lambda utilisée dans un foreach)
Le plus gros ajout cela dit, en termes de magie de compilateur, est async. Cela a été couvert de partout, et je vais simplement commenter l'ajout de cette fonctionnalité en soi, et les impacts lorsque elle est utilisée.
Cet article est le premier d'une série de petits articles à propos de trucs et astuces à propos de C# async que je vais tenter de couvrir pour donner un peu plus détails en profondeur, et permettre aux développeurs d'en éviter se problèmes.
C# 5.0 Async : Les Objectifs
La programmation asynchrone a été une préoccupation cle au cours des dernières années, à l'origine pour permettre d'avoir des interfaces utilisateurs plus fluides. Les GUI sur Windows utilisent toutes le concept de Thread UI, qui prend la forme d'une pompe a messages qui devrait toujours traiter des messages. Si cette pompe est bloquée, parce qu’un clic de bouton est en train d'exécuter du code bloquant, le GUI gèle.
C'est une très mauvaise expérience utilisateur, et Microsoft comme bien d'autres, ont commencé à suggérer (ou forcer, comme en Silverlight ou WinRT) aux développeurs d'utiliser des API asynchrones.
En prenant la direction de l'asynchrone, en utilisant des techniques existantes comme les évènements à base de message de fin, ou le pattern Begin/End, le plus gros challenge est le maintien d'un état entre chaque appel asynchrone. Cela nécessite que le développeur crée sa propre machine à état pour gérer le passage d'état entre chaque étape de cette "opération" logique asynchrone. C'est du code de plomberie assez complexe, et nécessiter son écriture à chaque fois est consommateur en temps et très source d’erreurs.
Du point de vue du développeur, async en C# essaye de faire en sorte de donner une apparence synchrone a du code asynchrone. L'asynchronisme est un gros problème en C#, et l'équipe de C# a introduit cette fonctionnalité pour aider les développeurs lorsqu'ils ont a s'y confronter.
Un autre objectif d'async en C# est la capacité de faire du Thread Yielding, en permettant de relâcher explicitement le contrôle de l'exécution, permettant ainsi à une Thread d'être utilisée de manière plus efficace, en n'attendant pas après un traitement distant. D'une certaine manière, cela ressemble au concept des Fibres Win32, ou plus généralement au multitâche coopératif.
D'après ce que je peux en comprendre, l'objectif à long terme est que les développeurs favoriseront l'écriture de méthodes asynchrones qui permettront de relâcher la Thread UI lorsqu'il y a un appel asynchrone.
C# 5.0 Async, Magie Niveau 1
Lorsque l'on commence à l'utiliser, on a la sensation de faire de la magie, au premier abord. Il suffit juste de placer quelques async et await par-ci, parla, et ça fonctionne.
Le premier niveau de magie se trouve dans la machine à état qui est généré par le compilateur, de la même manière qu'un développeur le ferait. Pour le développeur, tout ceci est presque transparent. Une méthode async est alors une série de delegates chaines qui sont exécutes après chaque utilisation du mot clé await.
Il suffit simplement de jeter un œil à ce que ILSpy produit pour voir les entrailles d'une méthode async, ou l'on constate que la méthode est coupée en petits morceaux, déclenches par la présence du mot clé await.
On peut constater que toutes les méthodes async sont placées dans des "Display Class", et plus spécifiquement dans une méthode nommée MoveNext.
Bien que savoir que cette génération de code est présente est plus anecdotique qu'utile, cela aide malgré tout à comprendre le fonctionnement global d'une méthode async.
Le SyncronizationContext, Magie Niveau 2
Si vous avez déjà écrit du code asynchrone (sans async), vous devez connaitre les méthodes comme Dispatcher.BeginInvoke ou Form.BeginInvoke, qui permettent d'exécuter du code sur la Thread UI. Ainsi, à moins que le framework ne le fasse pour vous (comme WebClient en Silverlight) vous devez utiliser une de ces méthodes pour mettre à jour votre UI lorsque le travail asynchrone est terminé.
Vous devez alors vous demander... Comment une méthode async permet la mise à jour du UI ? C'est la Magie Niveau 2, connue sous le nom de SynchronizationContext.
Le contexte de synchronisation est très grossièrement une abstraction d'une queue de messages, ou il est possible de mettre en queue de manière synchrone ou asynchrone du travail, puis d'être notifié lorsque c'est terminé. Le Dispatcher de Silverlight et WPF en disposent d'un qui est automatiquement assigne lors de l'exécution de code relie au UI.
Lors de la création d'une méthode async, le compilateur s'appuie sur AsyncTaskMethodBuilder. C'est une classe de la BCL qui est utilisée comme un coordinateur de la magie qui fait en sorte que lorsqu'une méthode async est créée, le contexte de synchronisation courant de l'appelant est capturé. Par la suite, ce contexte est réutilisé pour mettre en queue du code de la méthode async a exécuter après le premier await, de manière a ce qu'il reste dans le même contexte.
Bien que ce soit à l'interne un peu complexe, c'est très efficace lorsque l'on travaille avec la Thread UI dans une méthode async. Il n'y a alors presque plus besoin de se préoccuper de revenir sur la Thread UI pour la mettre à jour, puisque le SynchronizationContext a ramène automatiquement le flot d'exécution dessus.
Asynchronisme vs. Concurrence
Maintenant que le code est exécuté dans une méthode async, on pourrait être tenté de penser que peu importe ce que le code va faire, le UI ne va pas "geler", puisque l'on est dans une méthode async.
On peut alors être tenté de faire un appel web, déserialiser son contenu dans la méthode async. On constate alors très rapidement que le UI gèle malgré tout lorsque l'on déserialize un gros document JSON/XML.
Il y a une chose à se rappeler avec l'asynchronisme. Ce n'est pas de la concurrence.
La partie compliquée ou le SynchronizationContext ramène sur le UI Thread, cela ramène vraiment dessus. La déserialization de JSON est une opération qui implique principalement le CPU, ce qui va faire en sorte que la Thread courante va être bloquée, nous ramenant au point de départ: Le UI gèle ou "rame".
A ce moment, on veut de la concurrence, là ou async n’aide pas beaucoup. Il est nécessaire d'exécuter du code dans une autre Thread, soit par Thread, Task ou le ThreadPool, ... Là où il est nécessaire de protéger (lock) les ressources correctement, la gestion correcte de l'abandon, et tous ces autres petits détails piquants.
A partir du moment où l'on sort du contexte d'une méthode async attachée au UI, il est nécessaire de communiquer avec le UI manuellement, principalement en utilisant Dispatcher.BeginInvoke et similaires.
Pour ma part, je pense que ce sera (et est déjà) un des plus gros malentendus de l'utilisation de async, ou les développeurs vont penser qu'ils vont faire du travail parallèle en utilisant async, malgré que ce ne soit pas le cas.
Cycle de Vie des Méthodes Async
Si l'on considère ce code :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
private async void Button_Click_1(object sender, RoutedEventArgs e)
{
await Test();
var t = Task.Run(async () => await Test());
await t;
}
private async Task Test()
{
Debug.WriteLine("SyncContext : {0}", SynchronizationContext.Current);
Debug.WriteLine("1. {0}", Thread.CurrentThread.ManagedThreadId);
await Task.Delay(1000);
Debug.WriteLine("2. {0}", Thread.CurrentThread.ManagedThreadId);
await Task.Delay(1000);
Debug.WriteLine("3. {0}", Thread.CurrentThread.ManagedThreadId);
await Task.Delay(1000);
Debug.WriteLine("4. {0}", Thread.CurrentThread.ManagedThreadId);
} |
Qui, exécuté produit ceci :
SyncContext : System.Windows.Threading.DispatcherSynchronizationContext
1. 10
2. 10
3. 10
4. 10
SyncContext :
1. 6
2. 12
3. 12
4. 6
Il faut remarquer que la première exécution de la méthode async reste sur la même Thread, en utilisant le DispatcherSynchronizationContext. C'est la raison pour laquelle il est possible d'exécuter du code qui modifie le UI dans cette méthode.
Dans le cas de WPF/Silverlight/Metro, le DispatcherSynchronizationContext est automatiquement assigné dans une variable du TLS (l'attribut ThreadStatic), ou chacun des callbacks UI y aura accès, et par extension, toute méthode async exécutée dans ce contexte.
Mais lorsque cette même méthode est exécutée a l'intérieur d'une Thread démarrée manuellement, son comportement est complètement diffèrent. Le code démarre sur la Thread 6, continue sur la Thread 12, puis à nouveau sur 6.
La raison de ceci est l'absence de contexte de synchronisation lorsque la méthode async a été démarrée. Le comportement par défaut d'une méthode async, qui est gérée par AsyncTaskMethodBuilder, est de mettre en queue du travail sur le ThreadPool. Par nature, il n'est pas possible de choisir quelle Thread sera utilisée pour exécuter du travail mis en queue, ce qui provoque ces changement de thread.
Il ne faut donc pas prendre pour acquis qu'il est possible d'exécuter du code UI dans une méthode async, et qu'il est plutôt dangereux de s'appuyer dessus.
Migrer du Code Dépendant du Threading Context
Si l'on considère ce vieux code, qui pouvait bien s'exécuter en WinForms 2.0 :
1 2 3 4 5 6 7 8 9 |
private void Button_Click_1(object sender, RoutedEventArgs e)
{
_lock.AcquireWriterLock(1000);
// A context sensitive operation
Thread.Sleep(3000);
_lock.ReleaseWriterLock();
} |
Ce n'est pas le genre de code que l'on veut voir derrière un gestionnaire d'évènement UI, mais l'utilisation d'un ReaderWriterLock peut définitivement se trouver dans un service d'arrière plan, donc continuons.
Considérons que nous devons porter ce code à WinRT, et au lieu d'appeler Thread.Sleep, il faut appeler une méthode async qui va faire des accès aux fichiers :
1 2 3 4 5 6 7 8 9
|
private async void Button_Click_2(object sender, RoutedEventArgs e)
{
_lock.AcquireWriterLock(1000);
// Do some context sensitive stuff...
await Task.Delay(1000);
_lock.ReleaseWriterLock();
} |
Tout va s'exécuter correctement.
Mais on remarque que le code gèle le UI, alors on le place dans une Thread d'arrière-plan:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
private void Button_Click_3(object sender, RoutedEventArgs e)
{
Task.Run(
async () =>
{
try
{
_lock.AcquireWriterLock(1000);
// Do some context sensitive stuff...
await Task.Delay(1000);
_lock.ReleaseWriterLock();
}
catch (Exception ex)
{
Debug.WriteLine(ex);
}
}
);
} |
(Note: Nous reviendrons sur les lambda async, alors continuons :) )
Ce code va très certainement ne pas s'exécuter, probablement neuf fois sur dix.
La raison est que le code s'exécute sur une thread d'arrière-plan, et que le code après le premier await va très probablement ne pas s'exécuter sur la même thread que le code avant le await.
On pourrait avoir de la chance et que le code s'exécute sur la même Thread. Le code pourrait marcher. Des fois.
C'est la raison pour laquelle il n'est pas autorisé d'utiliser le mot clé lock dans une méthode async, puisque le compilateur ne peut pas s'assurer du fait que le code va être exécuté sur la même thread, puisque cela dépend de l'appelant de la méthode.
A Retenir
- Jeter un œil au code généré derrière une méthode async, c'est très instructif.
- Une méthode async n'est pas garantie de rouler sur la Thread UI, elle utilise le SynchronizationContext ambiant.
- Attention au code dépendant de la thread en cour dans une méthode async, puisque la Thread courante pourrait changer au cours de l'exécution de la méthode, dépendant du SynchronizationContext ambiant.
A bientôt pour la suite de cette petite série !