Maîtriser l’asynchronisme de C# 5.0 - Part 3

Implémentation asynchrone de C# 5.0

imageUne des nouveautés majeures de C# 5.0 est sans aucun doute l’intégration de l’asynchronisme sur le plan du langage. Avec cette offre, il devient difficile de produire des applications peu disponibles aux yeux de l’utilisateur. Cependant, l’expérience montre que cette grande nouveauté n’est pas toujours bien comprise et que des erreurs sont parfois difficiles à résoudre lorsque le développeur n’a pas conscience des mécanismes sous-jacents. L’objectif est de répondre à la question : comment est implémentée cette nouvelle technologie asynchrone ?

Asynchronisme en C# 5.0

Présentation

L’ensemble des points négatifs avec les modèles APM, EAP, TAP, évoqués dans l'article précédent, ont poussé les équipes Microsoft à développer une solution asynchrone intégrée au niveau des langages C# 5.0 et VB 11. La motivation était de produire du code 100% asynchrone, facilement maintenable dans un contexte fortement impératif.

Dans l’exemple suivant, nous réutilisons la classe FileStream qui offre une nouvelle méthode orientée TAP, ReadAsync qui retourne un type Task<int>. Une des conséquences de l’apparition de l’asynchronisme dans les langages C# et VB est qu’une très grande partie des classes du Framework .NET 4.5 déclinent de nouvelles méthodes orientées TAP prenant en charge l’asynchronisme. Par convention, le nom des méthodes doit être post-fixé avec le mot Async.

async static public void ReadAsync(string filename)

{

    var buffer = new byte[100];

    using (var fs = new FileStream(filename, FileMode.Open, FileAccess.Read,

                FileShare.Read, 1024, FileOptions.Asynchronous))

    {

        int bytesRead = await fs.ReadAsync(buffer, 0, buffer.Length);

        Console.WriteLine("Number of bytes read={0}", bytesRead);

        Console.WriteLine(BitConverter.ToString(buffer, 0, bytesRead));

    }

}

Le code est bien plus simple que la version APM, EAP et TAP décrite dans l'article précèdent. Le code semble à la fois séquentiel et facile à maintenir. Il est temps de détailler, comment ce code fonctionne, afin de mieux appréhender ce nouveau modèle. À l’instar des modèles APM, EAP et TAP, une demande de lecture au driver NTFS sous-jacent est réclamée et retourne immédiatement. Une structure IRP interne permet d’édifier un lien avec le code de continuation qui permettra de récupérer le résultat (pour plus d’explication, relire l'article précèdent). Pour l’instant, je ne vous ai pas expliqué d’où vient cette continuation.

        int bytesRead = await fs.ReadAsync(buffer, 0, buffer.Length);

        Console.WriteLine("Number of bytes read={0}", bytesRead);

        Console.WriteLine(BitConverter.ToString(buffer, 0, bytesRead));

 

Tout le code en jaune, comprenant la déclaration int bytesRead jusqu’à la dernière ligne Console.WriteLine(BitConverter.ToString(Buffer, 0, bytesRead)); est capturée par le compilateur afin de devenir une continuation. Cette capture est réalisée par le compilateur à votre insu : lorsque la combinaison des deux identifiants async et await est utilisée dans le contexte décrit précédemment, alors le compilateur va prendre en charge l’implémentation de l’asynchronisme pour vous.

Nous pouvons aussi reprendre l’exemple EAP de l'article précédent, pour le transformer avec l’offre asynchrone de C# 5.0.

 

string html = await new WebClient().DownloadStringTaskAsync("https://blogs.msdn.com/b/nativeconcurrency/");

listView.Items.Add(new PageLength(uri, uri.Length));

txtResult.Text = "LENGTH: " + html.Length.ToString("N0");

 

Dans cet exemple, tout le code en jaune est capturé par le compilateur pour devenir une continuation. Sur le plan de l’exécution, le code capturé s’exécute au-delà de l’appel de la méthode. En effet, l’appel à la méthode DownloadString retourne immédiatement vers l’appelant. Ce n’est qu’une fois la page téléchargée, que le code capturé par le compilateur est exécuté. Par défaut, le code de la continuation s’exécute dans le thread de l’appelant. Généralement dans un contexte graphique, ce comportement vous conviendra certainement. Cependant, si vous développez une librairie sans affinité graphique ou plus simplement du code serveur, vous pouvez configurer l’appel d’un code await, via la méthode Task.ConfigureAwait. Cette méthode permet à travers un booléen de préciser, si le code de la continuation doit se synchroniser ou pas avec le contexte de l’appelant. Par exemple dans le code suivant nous signalons que nous ne souhaitons pas de synchronisation en passant la valeur false à la méthode ConfigureAwait.

 

        int bytesRead = await fs.ReadAsync(buffer, 0, buffer.Length)

                             .ConfigureAwait(false);

       

L’exemple suivant reprend l’exemple de la méthode PopulateAsync, mais dans un contexte un peu plus complexe. C’est un fragment tiré d’une application WPF, qui cette fois boucle sur une liste d’URIs.

 

async void PopulateAsync()

{

       listView.Items.Clear();

       btnGo.IsEnabled = false;

       btnCancel.IsEnabled = true;

       txtResult.Text = String.Empty;

       try

       {

             int totalLength = 0;

             foreach (string uri in _uris)

             {

                    string html = await new WebClient().DownloadStringTaskAsync(uri);

                    totalLength += html.Length;

                    listView.Items.Add(new PageLength(uri, uri.Length));

             }

             txtResult.Text = "TOTAL LENGTH: " + totalLength.ToString("N0");

       }

       catch (Exception ex)

       {

             txtResult.Text = "ERROR: " + ex.Message;

       }

       finally

       {

             btnGo.IsEnabled = true;

             btnCancel.IsEnabled = false;

       }

}

 

Ce dernier exemple démontre toute la puissance de l’offre asynchrone C# 5.0 (difficile de faire moins invasif). Toutes les offres précédentes, APM, EAP et TAP (voir l'article précédent) sont parfaitement incapables de proposer l'équivalent de ce code, sans devenir un plat de spaghettis.

 

Communication asynchrone

Dans l’article précèdent où nous avons présenté les offres asynchrones, APM, EAP et TAP, nous avons commenté les « véhicules » de communication asynchrones permettant de transmettre « le résultat » une fois, le traitement asynchrone terminé. Dans l’offre TAP, une instance du type Task ou Task<TResult> est utilisé. Le support des identifiants async et await assurent parfaitement cette transparence. La motivation de l’offre asynchrone de C# 5.0 est d’être la moins invasive possible vis-à-vis de votre code original synchrone.

 

Comprendre l’implémentation

Introduction

Dans les modèles précédents, nous avons détaillé le fonctionnement sous-jacent de chaque solution asynchrone. Dans le cadre de l’offre async/await, le sujet est un peu plus vaste. En effet, nous pouvons distinguer deux sujets :

· Le pattern await

· La capture des variables.

 Pattern await

Voici quelques explications qui vous permettront de mieux comprendre l’implémentation interne du mot clef await. Le mot clef await est placé au sein d’une méthode préfixée par l’identifiant async, afin de permettre une simplification de la construction de la continuation.

 

var result = await expression ;

statment(s);

 

Cette fonctionnalité est traduite par le compilateur par le fragment ci-dessous.

 

var awaiter = await expression.GetWaiter();

awaiter.OnCompleted(() =>

{

            var result = awaiter.GetResult();

             statment(s);

}

 

Si le traitement est terminé avant que la continuation ne soit lancée, le compilateur engendre un raccourci afin d’exécuter immédiatement en mode synchrone, le code de la continuation, comme l’illustre le code ci-dessous.

 

var awaiter = DownloadPageAsync(uri).GetAwaiter();

 

if (awaiter.IsCompleted)

       Console.WriteLine (awaiter.GetResult());

else  

       awaiter.OnCompleted (() => Console.WriteLine(awaiter.GetResult());

 

L’essentiel des appels des API asynchrones qui utilisent le mot clef await retourneront une tâche TPL. Cependant, le compilateur C# n’impose pas en interne ce type de résultat. Il est parfaitement légitime d’attendre autre chose qu’une tâche. La seule contrainte est que l’objet retourné supporte quelques méthodes bien spécifiques. L’implémentation async/await impose que l’objet retourné contienne une méthode nommée GetAwaiter (cependant une méthode d’extension du même nom serait compatible aussi). Cette méthode doit retourner un objet ou une valeur, qui supporte les trois points suivants :

 

· L’objet doit supporter une propriété IsCompleted de type bool qui permet au code généré par le compilateur d’engendrer un raccourci: si l’exécution est déjà terminée: éviter de perdre du temps à enregistrer une continuation pour rien. Le code situé juste après le mot clef await, est alors exécuté dans la foulé.

 

· L’objet doit aussi supporter une méthode nommée GetResult. Le type de retour est sélectionné en fonction du type de l’expression await. Dans notre exemple c’est un type string qui doit être retourné.

 

· Enfin, l’objet doit fournir une méthode de type callback dans le cas où la propriété IsCompleted retourne false, le code généré pour l’expression await créera un délégué qui exécutera le reste de la méthode. Pour ce support le compilateur exige le support de l’implémentation de l’interface INotifyCompletion ou l’implémentation de l’interface ICriticalNotificationCompletion. Ces deux interfaces sont similaires et définissent une seule méthode ; respectivement OnCompleted et UnsafeOnCompleted qui est passé à une instance d’un délégué Action, que l’objet retourné appellera une fois que l’opération asynchrone sera terminée. La sélection de l’implémentation d’une des deux interfaces est à la charge du compilateur en fonction du contexte. Par défaut c’est l’implémentation de l’interface ICriticalNotificationCompletion qui est utilisé si l’exécution du code est considérée Full Trust. Notons que cette interface est marquée par un attribut de sécurité SecurityCriticalAttribut. Enfin, c’est l’interface INotifyCompletion qui est utilisée dans le cadre d’un code marqué Partial Trust.

 

Pour synthétiser tous ces éléments, je vous propose un exemple simple qui illustre le code simplifié d’une classe contenant un appel avec await, qu’on appelle FooWithAwait, générée par le compilateur.

 

using System.Threading.Tasks;

using System.Runtime.CompilerServices;

 

public class FooWithAwait

{

    public SimpleAwaiter GetAwaiter()

    {

        return new SimpleAwaiter();

    }

 

    public class SimpleAwaiter : INotifyCompletion

    {

        public bool IsCompleted { get { return true; } }

 

        public string GetResult()

        {

            return "Hello Async/Await";

        }

 

        public void OnCompleted(Action continuation)

        {

            throw new NotImplementedException();

        }

    }

 

    public static async void UseFooWithAwaitAsync()

    {

        string result = await UseCompilerAsync();

        Console.WriteLine(result);

    }

 

    public static FooWithAwait UseCompilerAsync()

    {

        return new FooWithAwait();

    }

}

 

Ce type contient naturellement une méthode GetAwaiter qui retourne une classe interne SimpleAwaiter. La classe SimpleAwaiter implémente naïvement l’interface INotifyCompletion. Cette interface est définie au sein de l’espace de nom System.Runtime.CompilerServices. La propriété IsCompleted retourne systématiquement true, ce qui signifie que la méthode OnCompleted ne sera jamais appelée. C’est pour cette raison qu’elle n’est pas implémentée. La méthode UseFooWithAwaitAsync est une méthode asynchrone qui respecte la syntaxe async/await: la signature de la méthode est préfixée par le mot clef async, le nom de la méthode est post fixé par le mot Async. Le corps de la méthode utilise le mot clef await sur l’appel d’une méthode appelée UseCompilerAsync. Cette méthode est une fabrique, elle instancie FooWithAwait qui implémente le pattern await du compilateur C# 5.0.

 

Pour instancier cette classe, je vous propose ce fragment de code, typique d’un programme console :

 

class Program

{

    static void Main()

    {

        FooWithAwait.UseFooWithAwaitAsync();

        Console.ReadKey();

    }

}

 

Si vous lancez ce petit programme, vous obtiendrez dans votre console le message « Hello Async/Await ».

 

Capture des variables

Pour l’implémentation d’async/await, les ingénieurs Microsoft ont appliqué le même principe utilisé dans le cadre de l’implémentation simplifiée du pattern Énumérateur avec des mots "yield return" et "yield break" : une machine à états générés par le compilateur. Cette machine à états est engendrée afin de prendre en compte un déroulement correct d’appels d’une ou plusieurs méthodes asynchrones, pouvant lever des exceptions au sein de boucles, par exemple.

Cependant, la vraie puissance du couple async/await réside dans la capture des variables. En effet, vous pouvez placer des appels await, n’importe où dans votre code (même dans des boucles, des événements et même des expressions lambda), excepté au sein de blocs catch et finally, des blocs de code verrouillés par le mot clef lock, au sein d’un contexte unsafe où dans le point d’entrée d’un programme (Main).

 

Pour étudier la transformation d’un code décoré avec le couple async/await, je vous propose la méthode ci-dessous qui reprend en partie un exemple précédent.

 

static async Task<List<string>> DownloadPagesAsync(IEnumerable<string> uris)

{

    var result = new List<string>();

 

    using (var wc = new WebClient())

    {

        foreach (var uri in uris)

        {

            result.Add(await wc.DownloadStringTaskAsync(uri));

        }

    }

    return result;

}

 

Lorsque la méthode DownloadPagesAsync va exécuter la méthode DownloadStringTaskAsync, pour la première fois, le retour de cette méthode va retourner immédiatement vers l’appelant en vertu du fonctionnement du couple async/await. Lorsque la continuation sera terminée, le programme va reprendre son exécution là où il l’avait laissée avec toutes les variables correctement renseignées au sein de la boucle. Le compilateur génère un système de capture des variables respectant le contexte d’exécution courant. La phase où l’expression await retourne vers l’appelant est complètement pris en charge par le compilateur. Le compilateur doit aussi gérer un fil d’exécution identique à la version synchrone, ce qui signifie que dans notre exemple les résultats de toutes les itérations de notre boucle doivent être ordonnés à l’identique à l'exécution synchrone. Sans les mots clefs async/await, il serait extrêmement difficile de coder à la main une solution équivalente et encore plus difficile de la maintenir, d’où l’importance de la prise en charge du compilateur.

 

Pour vous sensibiliser au travail réalisé par le compilateur, je vous propose d’étudier une implémentation personnalisée d’une machine à états, similaire (mais très simplifiée) à ce que génère le compilateur C# 5.0. Cette nouvelle implémentation (ci-dessous) peut sembler intimidante. La difficulté provient de la traduction de la boucle qui itère la liste des uris pour appeler successivement la méthode asynchrone DownloadStringTaskAsync. Mais pas d’inquiétude, car si vous avez compris tous les éléments précédents, cette méthode ne devrait pas vous poser de problèmes de compréhension.

 

static Task<List<string>> DownloadPagesAsync3(IEnumerable<string> uris)

{

    var tcs = new TaskCompletionSource<List<string>>();

    IEnumerator e = null;

    int state = 0;

    Task<string> downloadTask = null;

    var results = new List<string>();

    var wc = new WebClient();

    Action moveNext = null;

 

    moveNext = () =>

    {

        bool shouldDispose = true;

        try

        {

            switch (state)

            {

                case 0:

                {

                    e = uris.GetEnumerator();

                    goto case 1;

                }

                case 1:

                {

                    if (e.MoveNext())

                    {

                        var uri = (string) e.Current;

                        downloadTask = wc.DownloadStringTaskAsync(uri);

                        state = 2;

                        shouldDispose = false;

                        downloadTask.ContinueWith(_ => moveNext());

                    }

                    else

                    {

                        tcs.TrySetResult(results);

                    }

                }

                break;

                case 2:

                {

                    if (downloadTask.IsFaulted)

                    {

                        tcs.TrySetException(downloadTask.Exception);

                        return;

                    }

                    else

                    {

                        results.Add(downloadTask.Result);

                        goto case 1;

                    }

                }

            }

        }

        finally

        {

            if (shouldDispose)

                wc.Dispose();

        }

    };

 

    moveNext();

           

    return tcs.Task;

}

 

La signature de la méthode ne change pas, mais comme vous pouvez le constater l’implémentation est totalement bouleversée. Pour simuler le travail du compilateur, la première chose à faire est de produire une « méthode TAP ». Pour cela, nous créons une nouvelle instance d'un TaskCompletionSource <T>, tel que nous l’avons présenté dans l’article précédent.

 

var tcs = new TaskCompletionSource<List<string>>();

 

Puis nous déclarons une liste de variables qui sont utilisées dans l’implémentation de la boucle via une machine à états :

 

· IEnumerator e = null;

 

o La variable e représente l’énumérateur de la liste des URLs des pages à télécharger.

 

· int state = 0;

 

o La variable state représente l’état courant de la machine à états.

 

· Task<string> downloadTask = null;

 

o La variable downloadTask représente un retour de l’appel à la méthode DownloadStringTaskAsync.

 

 

· var results = new List<string>();

o La variable results sera utilisée pour entretenir les résultats des appels asynchrones de la méthode wc.DownloadStringTaskAsync.

 

· WebClient wc = WebClient();

 

o La variable wc représente une instance du type WebClient utilisé pour télécharger les pages Web.

 

· Action moveNext = null;

 

o La variable moveNext représente l’action courante de la machine à états. L’action est initialisée par une expression lambda qui par nature capture toutes les variables utilisées.

 

La première étape consiste à initialiser l’action moveNext par une expression lambda contenant tout le code de la machine à états. Cette machine à états va être animée par la variable state au cours des appels successifs à la variable moveNext. L’action est appelée à la sortie de la méthode DownloadPagesAsync avant de retourner la propriété Task de l’instance TaskCompletionSource<List<string>>. Cependant, vous pouvez remarquer dans le corps de la machine à états, la ligne suivante :

 

downloadTask.ContinueWith(_ => moveNext());

 

Pour conserver les variables de chaque itération interrompue par le retour à l’appelant de la méthode DownloadPagesAsync, la continuation de la tâche de retour de la méthode DownloadStringTaskAsync est renseignée par l’action moveNext, créant ainsi une nouvelle tâche contenant une capture des variables associées, dont l’état courant. Initialement la variable state est fixée à 0.

 

case 0:

{

    e = uris.GetEnumerator();

    goto case 1;

}

 

La variable state étant fixée à 0 nous déroulons ce fragment de code qui permet de récupérer un énumérateur sur la liste des URIs. Puis le code se poursuit à la case 1 :

 

case 1:

{

    if (e.MoveNext())

    {

        var uri = (string) e.Current;

        downloadTask = wc.DownloadStringTaskAsync(uri);

        state = 2;

        shouldDispose = false;

        downloadTask.ContinueWith(_ => moveNext());

    }

    else

    {

        tcs.TrySetResult(results);

    }

}

 

La case 1 permet d’itérer les éléments de la liste des URIs via l’interface IEnumerable et sa méthode MoveNext. Si la méthode retourne false, il n’y a plus d’élément à itérer, le code peut donc stocker la liste des résultats, results, dans l’instance du type TaskCompletionSource<List<string>> via la méthode TrySetResult.

 

    else

    {

        tcs.TrySetResult(results);

    }

 

Si la méthode MoveNext retourne true, alors la valeur courante de l’item est récupérée via l’appel à la méthode DownloadStringTaskAsync. Le résultat de l’appel est stocké dans la variable downloadTask. La variable state est modifiée et passe de 0 à 2. La variable shouldDispose repasse à false car nous somme en cours de traitement et nous ne devons pas détruire prématurément l’instance du WebClient. Puis nous passons au code de continuation où l’expression lambda relance l’action moveNext. Mais cette fois la variable state représentant l’état courant de la machine à états est fixé à 2 et le code de l’action moveNext est plongé dans un thread du Pool de Threads.

Le code de l’action moveNext reprend une nouvelle exécution et aboutit dans la portion de code suivante :

 

case 2:

{

    if (downloadTask.IsFaulted)

    {

        tcs.TrySetException(downloadTask.Exception);

        return;

    }

    else

    {

        result.Add(downloadTask.Result);

        goto case 1;

    }

}

 

Si la tâche retournée par la méthode DownloadStringTaskAsync a échoué, cela signifie qu’une exception a été levée. Cette exception est enregistrée comme instance du type TaskCompletionSource<List<string>> via la méthode TrySetException puis le code retourne vers l’appelant. Si la tâche n’a pas échoué, le résultat est inséré dans la liste des résultats via la variable result. Puis le code retourne à la case 1 que nous avons déjà commentée.

 

À ce stade, nous comprenons que la machine à états va se poursuivre de la même manière tant que la liste des URIs n’a pas été totalement visitée et qu’il n’y a pas d’erreur.

 

Avec cette étude, vous devriez apprécier que le codage d’une machine à états à la main afin de retrouver le comportement asynchrone similaire à la version async/await, est particulièrement compliqué. Il est évident que le code devient difficile à lire et très difficile à faire évoluer. Finalement, le principe utilisé par Microsoft est simple, puisque le code engendré pour obtenir une solution pertinente est complexe, alors, autant laisser le compilateur générer le code, afin que le développeur gagne en efficacité et en productivité.

Pour des raisons de compréhension, la machine à états présentée est bien plus simple que celle générée par le compilateur. L’utilisation du pattern await a été volontairement oubliée afin d’obtenir un code plus facile à comprendre. L’objectif était de vous expliquer comment on peut gérer la capture des variables dans le cadre d’une boucle faisant appel à une méthode asynchrone. Vous pouvez retenir que le compilateur C# 5.0, lorsqu’il rencontre une méthode utilisant async/await, engendre sous le capot un code utilisant à la fois le pattern await et une machine à états dédiée au contexte courant.

Conclusion

Techniquement, on peut résumer la nouvelle offre asynchrone C# 5.0, par la combinaison de trois éléments:

· Utilisation du pattern TAP (Task-based Asynchronous Pattern),

· L’implémentation du Pattern Await,

· Un système de capture des variables implémenté via une machine à états.

C’est une offre qui permet d’obtenir de l’asynchronisme avec une complexité cyclomatique extrêmement réduite vis-à-vis des propositions asynchrones précédentes (voir l’article précèdent). Sur le plan de l’implémentation, l’offre asynchrone C# 5.0 repose sur un mode 100% asynchrone pour des E/S, comme les offres précédentes (voir l’article précèdent).

Conclusion

Dans cette troisième partie consacrée à l’implémentation de l’offre asynchrone avec C# 5.0, nous avons détaillé comment cette nouvelle fonctionnalité est modélisée en interne, afin de comprendre son implémentation. Cette nouvelle fonctionnalité est sans doute la fonctionnalité majeure de C# 5.0. Elle apporte un service considérable aux développeurs utilisant Visual Studio 2012 pour produire des applications .NET 4.5 ou pour proposer sur le Store Microsoft de nouvelles applications « Fast & Fluid ».

 

A bientôt

 

Bruno