Maîtriser l’asynchronisme de C# 5.0 – Part 1

Comprendre les motivations

imageUne des nouveautés majeures de C# 5.0 est sans aucun doute l’intégration de l’asynchronisme au niveau du langage. Avec cette offre, il devient difficile de produire des applications peu ou pas 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 à corriger lorsque le développeur n’a pas conscience des mécanismes sous-jacents. L’objectif de cet article est de répondre à quelques questions : pourquoi a-t-on besoin d’asynchronisme ? Comment définir une interface asynchrone simple et facile à maintenir ?

Motivations

Latences et satisfactions de l’utilisateur

Depuis de nombreuses années, la latence des programmes informatiques est un sujet grandissant. Dans un premier temps les applications graphiques, puis les applications Web, dernièrement le Cloud et enfin les tablettes et les smartphones, doivent offrir un niveau de disponibilité maximum afin que les utilisateurs ne souffrent pas de délais qui pénaliseraient leurs usages et leurs satisfactions.

Sur le plan technique, la latence a deux origines: un usage intensif du CPU ou des entrées/sorties poussives (disque, Réseaux …). Pour résoudre ce problème de latence, nous devrions appeler « en asynchrone » toutes les méthodes susceptibles d’être lentes.

Pour éviter toutes ambiguïtés voici comment nous pouvons définir un appel synchrone et un appel asynchrone:

· un appel synchrone : l’appelant ATTEND que la méthode se termine. Si l’appel est long, l’application reste figée et n’est plus disponible pour répondre à de nouvelles demandes.

· Un appel asynchrone : la méthode retourne immédiatement vers l’appelant et exécute un callback (ou une continuation) lorsque la méthode est terminée. L’appelant n’est pas bloqué, il reste disponible pour répondre à d’autres demandes.

Asynchronisme dans notre quotidien

Avant de détailler comment fonctionne l’asynchronisme de C# 5.0, je vous propose une petite histoire tirée de la vie réelle. Vous êtes chez vous et vous venez de constater que votre connexion internet ne fonctionne plus. Vous décidez d’appeler votre opérateur internet afin de résoudre le problème. Vous composez le numéro du support de votre opérateur et vous obtenez ce message vocal :

« Tous nos opérateurs sont actuellement occupés, le délai d’attente est d’environ 20 minutes, merci de patienter. »

Naturellement, vous n’êtes pas satisfait, et vous vous demandez si vous aurez la patience d’attendre si longtemps, car vous devez absolument partir dans 15 minutes. Vous avez une idée, vous allez demander à un ami de prendre votre téléphone afin de vous libérer de cette attente.

Dans notre petite histoire, vous êtes une application et votre ami, un thread de travail et votre opérateur, un service Web. Lorsque l’application appelle le service Web, dont la latence est extrêmement longue, l’application est figée et n’est plus disponible pour répondre à d’autres traitements. Dans ce cadre, l’utilisateur ne peut qu’attendre que le service se termine ce qui n’est pas acceptable. Dans le cas où un thread de travail prend en main l’attente du service Web, l’utilisateur ne souffre plus d’une attente forcée, et l’application est disponible pour répondre à d’autres demandes. On note que le thread de travail n’a pas une grande valeur ajoutée. Il ne fait qu’attendre le service. Dans les faits, ce thread ajoute un surcoût mémoire et une forme de complexité technique à votre application qui ne se justifie pas vraiment. Cependant, cette solution est largement utilisée, car elle est, à la fois simple à comprendre et facile à implémenter, mais entraine des risques de concurrence entre le thread de travail et le thread principal de l’application.

Imaginons une nouvelle version de notre histoire. Cette fois, l’opérateur vous répond par le message suivant :

« Tous nos opérateurs sont actuellement occupés, merci de composer votre numéro de téléphone suivit d’un caractère ‘#’, un de nos opérateurs vous rappellera dès que possible. »

Cette nouvelle version est bien plus intéressante, car elle libère l’application immédiatement après l’appel du service Web. Pour édifier un lien entre l’appel au service (qui ressemble plus à un enregistrement) un ‘ticket’ (dans notre exemple, le numéro de téléphone) permet de vous notifier lorsque le service web sera disponible. Vous n’avez plus besoin de créer un thread de travail dédié, un simple thread du pool de threads, sera utilisé brièvement pour vous prévenir que le résultat du traitement est disponible.

Pour conclure nos deux histoires, je vous propose une synthèse sur l’opposition entre un appel synchrone et un appel asynchrone

Appel synchrone et Concurrence

· L’appelant gère la concurrence

· Démarre un autre thread qui attend la fin d’un appel à forte latence

· Pas besoin de callback/continuation

On qualifie ce type d’implémentation : « Asynchronisme au-dessus du mode synchrone »

Ce type d’implémentation devrait être évité, car elle consomme des ressources inutilement

Appel asynchrone

· L’appelé gère la concurrence

· Pas d’utilisation de thread supplémentaire (si périphérique avec E/S)

· Pas d’attente pour l’appelant

· Besoin d’un callback/continuation

On qualifie ce type d’implémentation : « 100% asynchrone »

Ce type d’implémentation est parfaite.

Les avantages d’une implémentation 100% asynchrone sont nombreux. Premièrement, l’asynchronisme offre une meilleure combinaison efficacité/Mise à l’échelle (évite de bloquer des threads). Deuxièmement, le 100% asynchrone offre une solution bien plus facile à développer : l’appelant n’a pas à coder une couche de plomberie, l’appelant peut difficilement rencontrer des problèmes de threading, enfin il offre une forme d’abstraction vis-à-vis de la concurrence.

Cependant une bonne implémentation ne présage pas d’une bonne API. En effet, il est parfaitement possible de proposer une implémentation 100% asynchrone, mais difficilement utilisable par un développeur.

Offrir une solution simple

Une bonne offre asynchrone devrait utiliser une implémentation 100% asynchrone. Cependant, entre la théorie et la pratique, il y a parfois un écart important, car pour qu’une solution soit adoptée par la majorité des développeurs, il faut que celle-ci soit facile à utiliser. L’objectif dans ce chapitre est de se concentrer sur l’API et non sur l’implémentation.

Code synchrone à transformer

Imaginons une solution asynchrone facile à utiliser en partant d’une simple méthode synchrone : GetWebPage dont l’intention est de retourner la page html d’un site web en fournissant l’URL de la page.

string GetWebPage (string uri)

{

  ...

  ...

}

 

void Test()

{

    string html = GetWebPage("https://blogs.msdn.com/b/pfxteam/");

    Console.WriteLine (html);

}

Première proposition

Comment rendre l’appel à GetWebPage asynchrone ? L’idée la plus simple serait sans doute d’utiliser le type générique Action<TResult> afin de passer une continuation à notre nouvelle API que nous appellerons GetWebPageAsync.

void GetWebPageAsync (string uri, Action<string> continuation)

{

  ...

  ...

}

 

void Test()

{

  GetWebPageAsync("https://blogs.msdn.com/b/pfxteam/", Console.WriteLine);

}

Cette première définition est sympathique, mais pas concluantesi la méthode rencontre un problème. Cette API ne permet pas une prise en compte des erreurs et n’offre pas de contrôle sur le résultat.

Seconde proposition

Si vous pratiquez déjà la programmation parallèle avec Visual Studio 2010, vous devez connaître le type Task. Dans notre exemple, nous allons profiter du type générique Task<TResult> pour retourner une information plus riche afin d’offrir plus de flexibilité à notre API. Vous pourriez faire une analogie rapide entre le type Task et le parallélisme, mais dans notre cas, nous réutilisons le type Task pour retourner une information et non pour paralléliser du code. Dans Visual Studio 2012, le type Task unifie tous les traitements concurrents. Dans le vocabulaire multitâche, on parle de concurrence pour qualifier tout traitement utilisant un ou plusieurs threads. L’espace de la concurrence comprend plusieurs sous-domaines, par exemple le parallélisme. Le type Task recouvre tous les domaines de la concurrence sous la forme traitement asynchrone. Il n’y a donc pas d’ambiguïté lorsque nous utilisons le type Task pour retourner le résultat du traitement asynchrone.

Task<string> GetWebPageAsync (string uri)

{

  ...

  ...

}

Le type générique Task<TResult> propose plusieurs propriétés intéressantes : Result, Exception et une méthode ContinueWith(). La propriété Result est synchrone et rend la main une fois le résultat renseigné. La propriété Exception contient l’exception respective au problème rencontré si la méthode a échoué. Enfin la méthode ContinueWith permet de passer une expression lambda qui sera exécutée dans un thread du pool de threads une fois le traitement terminé.

void Test()

{

    GetWebPageAsync("https://blogs.msdn.com/b/pfxteam/").ContinueWith(task =>

    Console.WriteLine (task.Result));

}

Cette dernière proposition, semble séduisante, car elle offre à la fois un excellent contrôle sur l’exécution du traitement et est simple d’utilisation.

Code synchrone plus complexe à transformer

Imaginons maintenant un autre exemple synchrone un peu plus compliqué que le précédent, en introduisant une boucle autour de notre appel GetWebPage :

string GetWebPage (string uri)

{

  ...

  ...

}

 

void Test()

{

  for (int i = 0; i < 5; i++)

  {

    string html = GetWebPage("https://blogs.msdn.com/b/pfxteam/");

    Console.WriteLine (html);

  }

}

Cette version est bien plus compliquée à transformer en asynchrone. La boucle ‘for’ nous impose de nouvelles contraintes pour implémenter une API asynchrone. Une implémentation possible est d’utiliser la récursivité pour assurer ordre des appels à la méthode GetWebPageAsync asynchrone, afin de respecter le comportement de la version synchrone.

Task<string> GetWebPageAsync(string uri)

{

  ...

  ...

}

 

int _i = 0;

 

void Test()

{

    GetWebPageAsync("https://blogs.msdn.com/b/pfxteam/").ContinueWith (task =>

    {

        Console.WriteLine (task.Result);

 

        if (++_i < 5)

            Test();

    });

}

Pour maintenir notre algorithme, nous sommes obligés d’introduire une variable membre « _i », afin de maintenir l’état courant de la boucle. Cette implémentation illustre les limites d’un support de l’asynchronisme dans le cadre d’une structure de contrôle de type boucle. Cette implémentation n’est donc pas satisfaisante, car le code est trop complexe pour assurer une compréhension et une maintenance facile du code. On constate que la définition d’une API asynchrone à la fois simple et intégrable avec des structures de contrôle n’est pas une mince affaire.

Proposition avec C# 5.0

Pour conclure cette étude, nous illustrons notre exemple avec l’offre C# 5.0:

Task<string> GetWebPageAsync (string uri)

{

  ...

  ...

}

 

async void Test()

{

  for (int i = 0; i < 5; i++)

  {

    string html = await GetWebPageAsync("https://blogs.msdn.com/b/pfxteam/");

    Console.WriteLine (html);

  }

}

Quelques explications sur l’utilisation de cette nouvelle offre : pour activer l’asynchronisme de C# 5.0, il vous faut utiliser de nouveaux identifiants async et await dans un contexte précis. Le mot async doit apparaître au début de la signature de la méthode (ou d’une expression lambda) contenant un ou plusieurs appels asynchrones utilisant le mot await : il précède l’appel d’une méthode asynchrone qui se doit de retourner une instance du type Task si la méthode asynchrone ne retourne rien ou une instance de Task<TResult> si la méthode retourne un type TResult.

Conclusion

Dans cet article nous avons posé le décor à travers deux points : les motivations de l’asynchronisme et comment définir une interface utilisable. Ces deux points sont très importants, mais ne préjugent pas de l’implémentation sous-jacente. En effet, nous pouvons noter que la manière dont l’implémentation est construite n’a pas été détaillée dans cette première partie. Dans la seconde partie, nous plongerons dans l’offre asynchrone Microsoft avant C# 5.0.

 

A bientôt

Bruno