Async/await : Pensez-vous à paralléliser vos appels asynchrones ?

L’asynchronisme permis par async/await est devenu très banal dans notre code au quotidien. La plupart des API sont devenues asynchrones tout comme de plus en plus de méthodes de notre propre code. Il est fréquent d’enchainer plusieurs appels asynchrones dans une même méthode et ces appels s’exécutent alors les uns après les autres. Pourtant, il n’est pas toujours nécessaire conserver cette séquentialité.

En effet, si async/await simplifie l’appel de code asynchrone, il permet aussi de simplifier la parallélisation de ces appels lorsqu’ils ne sont pas couplés (dépendants les uns des autres). C’est dommage de ne pas en profiter, car cela permet d’optimiser son code très facilement !

Si vous vous demandez quelle est la différence entre séquentiel, parallèle, synchrone, asynchrone, je vous renvoie vers un précédent article qui détaille ces notions pas à pas.

Exemple d’un enchainement d’appels asynchrones couplés

1. Ouvrir un fichier

2. Ecrire dans le fichier que l’on vient d’ouvrir

3. Refermer le fichier dans lequel on vient d’écrire

Ces 3 étapes doivent se faire de manière séquentielle.

 // Obtient un flux de sortie pour le fichier SessionState file et écrit l'état de manière asynchrone
StorageFile file = await ApplicationData.Current.LocalFolder.CreateFileAsync(sessionStateFilename, CreationCollisionOption.ReplaceExisting);
using (Stream fileStream = await file.OpenStreamForWriteAsync())
{
    sessionData.Seek(0, SeekOrigin.Begin);
    await sessionData.CopyToAsync(fileStream);
    await fileStream.FlushAsync();
}
 

Exemple d’un enchainement d’appels asynchrones non couplés

1. Lancer 3 requêtes http et traiter chacun des flux résultat (ici on récupère simplement la longueur de chaque flux)

 HttpClient client = new HttpClient();

var download1Length = await ProcessURLAsync("https://msdn.microsoft.com", client);
var download2Length = await ProcessURLAsync("https://msdn.microsoft.com/en-us/library/hh156528(VS.110).aspx", client);
var download3Length = await ProcessURLAsync("https://msdn.microsoft.com/en-us/library/67w7t67f.aspx", client);
 var total = download1Length + download2Length + download3Length;

Ces 3 requêtes peuvent être traitées dans n’importe quel ordre, le résultat sera le même.

On pourrait donc tout aussi bien exécuter ces 3 requêtes http en parallèle, pour gagner du temps.

La version parallélisée avec Task.WhenAll

La méthode Task.WhenAll s’appelle de manière asynchrone et permet de rendre la main à la méthode appelante jusqu’à ce que les tâches passées en paramètres (ici download1, download2, download3)  aient toutes terminé leur exécution.

 HttpClient client = new HttpClient();

// Create and start the tasks. As each task finishes, DisplayResults 
// displays its length.
Task<int> download1 = ProcessURLAsync("https://msdn.microsoft.com", client);
Task<int> download2 = ProcessURLAsync("https://msdn.microsoft.com/en-us/library/hh156528(VS.110).aspx", client);
Task<int> download3 = ProcessURLAsync("https://msdn.microsoft.com/en-us/library/67w7t67f.aspx", client);

int[] res = await Task.WhenAll(download1, download2, download3);
var total = res.Sum();

Vous pouvez aussi lui passer une collection de tâches, à la place des 3 tâches individuelles “à la mode param” :

 Task<int>[] tasks = new Task<int>[] {
   ProcessURLAsync("https://msdn.microsoft.com", client),           
   ProcessURLAsync("https://msdn.microsoft.com/en-us/library/hh156528(VS.110).aspx", client),
   ProcessURLAsync("https://msdn.microsoft.com/en-us/library/67w7t67f.aspx", client)};

int[] res = await Task.WhenAll(tasks);
var total = res.Sum();
 

Dans quels cas ?

C’est évidemment pour des tâches qui sont un peu longues que la parallélisation aura le maximum d’intérêt. Dans notre exemple (en exagérant) si chaque appel http prend 1 seconde, notre code mettra 1 seconde à s’exécuter avec Task.WhenAll au lieu de 3 secondes avec les 3 await.

Un autre cas où la parallélisation peut être intéressante sous Windows 8: lorsque vous manipulez votre cache de données off-line. On travaille souvent sur plusieurs fichiers totalement indépendant, au démarrage de l’application lorsque l’on charge les données du cache ou lorsqu’on les y sauvegarde.

 var t1 = SaveConfigStateAsync(MainSettings.OFFLINE_CONFIG, lastConfig);
var t2 = SaveConfigStateAsync(MainSettings.OFFLINE_CONFIG_VIRTUAL, lastConfigVirtual);
// Saves the last Cams images for offline
var t3 = SaveCamStateAsync();

await Task.WhenAll(t1, t2, t3);
 
 
 
 

A ne pas confondre avec…

Attention, ne confondez pas Task.WhenAll avec Task.WaitAll !

Task.WaitAll va attendre la fin de l’exécution des tâches de manière SYNCHRONE. Mélanger du code synchrone et du code asynchrone c’est un peu comme mettre un chien et un chat dans la même pièce : ça finit souvent mal Sourire. Surtout si ce code est exécuté depuis le thread de l’UI !

Dans mon exemple, justement, on aurait un bel interblocage si j’appellait ce code depuis un évènement dans le code-behind de ma page:

 
 async void navigationHelper_SaveState(object sender, SaveStateEventArgs e)
{
     Task<int>[] tasks = new Task<int>[] {
       ProcessURLAsync("https://msdn.microsoft.com", client),           
       ProcessURLAsync("https://msdn.microsoft.com/en-us/library/hh156528(VS.110).aspx", client),
       ProcessURLAsync("https://msdn.microsoft.com/en-us/library/67w7t67f.aspx", client)};

    Task.WaitAll(tasks);
    var total = tasks.Sum(t => t.Result);

}

En effet, Task.WaitAll, bloque le thread de l’UI et son contexte dont ont besoin les appels asynchrones à ProcessURLAsync lorsqu’ils finissent leur exécution : c’est ce qui entraine l’interblocage.

Ce n’est pas le sujet de cet article, mais si vous souhaitez trouver des solutions pour mixer du code synchrone et du code asynchrone, je vous recommande l’article Should I expose synchronous wrappers for asynchronous methods? de Stephen Toubs.