Différences entre la TPL et async/await par l'exemple

 

 

Différences entre la TPL et async/await par l'exemple

Rate This
  • Comments 3

 

Entre nos machines multi-cores, nos services hébergés dans les nuages et nos interfaces graphiques souhaitées de plus en plus réactives, les questions touchant à l’asynchronisme et au parallélisme ne peuvent plus être reléguées au second plan.


Ce sont des sujets officiellement  à la mode et c’est tant mieux ! :

·         La Task Parallel Library intégrée à .Net 4 offre un framework de plus haut niveau que les threads et permet de simplifier l’écriture de code parallèle.

·         La CTP Async pour Visual Studio 2010 est arrivée quant à elle très récemment et permet d’écrire plus simplement un appel asynchrone.

Mais le code parallèle n’est-il pas asynchrone ? La TPL ne permet-elle pas de réaliser l’équivalent du couple async/await ?

A travers un exemple tout simple, explorons les différentes possibilités de parallélisme et/ou asynchronisme que nous offrent la TPL et le couple async/await.

Dans ce post, nous aborderons les sujets suivants:

  1. Appel des actions  en séquentiel
  2. Appel des actions en parallèle avec Parallel.Invoke()
  3. Appel des actions en parallèle avec Parallel.ForEach()
  4. Appel des actions en parallèle avec PLinq
  5. Appel des actions en parallèle et asynchrone avec l’objet Task
  6. Appel des actions en parallèle et asynchrone avec continuation
  7. Appel des actions en séquentiel asynchrone avec async/await
  8. Appel des actions en parallèle et asynchrone avec async/await

Voici le code qui constitue la base de notre exemple, ce sont 3 actions qui attendent 1s, 2s et 3 secondes:

[j’en profite pour définir les actions de 3 manières différentes, mais c’est sans incidence sur la suite de l’exemple]


static void Action1()
{
    Thread.Sleep(1000);
    Trace.WriteLine("\tFin Action1");
}
 
// ----------------------------------------------------------
// Différentes manières de définir des actions
// ----------------------------------------------------------
 
// méthode nommée : Action1
Action action1 = Action1;
 
// méthode anonyme
Action action2 = delegate()
{
    Thread.Sleep(2000);
    Trace.WriteLine("\tFin Action2");
};
 
// lambda
Action action3 =
    () => { Thread.Sleep(3000); Trace.WriteLine("\tFin Action3"); };

[Je rappelle que le type Action est un delegate de type void]

En résumé, le fonctionnel des actions est très simple :

  • action1 attend 1 seconde
  • action2 attend 2 secondes
  • action3 attend 3 secondes

 


1. Appel des actions  en séquentiel

Si j’appelle ces 3 actions (par-exemple déclenchées sur click d’un bouton) de manière classique, c’est à dire de manière séquentielle, j’écrirais le code suivant :

private void Button_Click(object sender, RoutedEventArgs e)
{
    Trace1();
    Sequentiel();
    Trace4();
}
 
// Appel synchrone des actions en séquentiel
void Sequentiel()
{
    Trace2();
 
    action1();
    action2();
    action3();
 
    Trace3();
}

Voici le code utilisé pour les traces, il n’est pas très important, excepté que Trace3() affiche le temps qui s’est écoulé entre Trace2 et Trace3.

static Stopwatch sw = Stopwatch.StartNew();
 
Action Trace1 = () => Trace.WriteLine("**************\n1 >");
Action Trace2 = () => { Trace.WriteLine("2 >>"); sw.Restart(); };
Action Trace3 = () => { Trace.WriteLine(sw.Elapsed); Trace.WriteLine("3 <<"); };
Action Trace4 = () => Trace.WriteLine("4 <");

A l’exécution, j’obtiens le résultat suivant:

**************
1 >
2 >>
Fin Action1
Fin Action2
Fin Action3
00:00:06.0063690
3 <<
4 <

 

Les 3 actions se sont exécutées en 6 secondes :
Nos 3 actions ont démarrées chacune après la fin de la précédente (séquentiel) et rien d’autre ne s’est passé entre temps : la ligne de code qui suit (Trace3() ) ne s’est exécutée qu’après la fin de l’exécution des 3 tâches (synchrone).

Le lot de tâches s’est donc exécuté de manière séquentielle et synchrone.


Bon jusque là ce n’est pas très compliqué, les appels séquentiels c’est le genre de code que l’on écrit habituellement. Si je regarde mes 3 tâches d’un peu plus près, je constate qu’il n’y pas de couplage entre elles. Peu importe l’ordre dans lequel elles s’exécutent, le résultat fonctionnel sera le même. Profitons donc de cette caractéristique pour optimiser l’exécution de ces actions et utilisons la TPL pour les lancer en parallèle.

La TPL (Task Parallel Library) fournit un framework de plus haut niveau que les threads : elle se base sur un mécanisme de tâches (Task) que l’on peut paralléliser, attendre, chainer de manière conditionnelle, etc.
Nous verrons cela au fil des exemples, mais commençons par une approche simple en profitant au maximum de cette couche d’abstraction de niveau supérieur.

Comment exécuter nos tâches en parallèle, pour répartir la charge de travail sur plusieurs processeurs ?

Pour notre exemple, la TPL nous permet de réaliser cela de différentes manières:

 

2. Appel des actions en parallèle avec Parallel.Invoke()

Parallel.Invoke permet d’exécuter une collection d’actions en parallèle et rend la main une fois toutes les actions terminées, fournissant ainsi un mécanisme de synchronisation.
Nous plaçons donc nos actions dans un tableau.

private void Button_ParallelInvoke(object sender, RoutedEventArgs e)
{
    Trace1();
 
    ParalleleInvoke();
 
    Trace4();
}
 
void ParalleleInvoke()
{
    Action[] actions = { action1, action2, action3 };
 
    Trace2();
 
    // Appel des actions en parallèle par Invoke
    Parallel.Invoke(actions);
 
    Trace3();
}

A l’exécution on obtient:

**************
1 >
2 >>
Fin Action1
Fin Action2
Fin Action3
00:00:03.0036246
3 <<
4 <

 

Remarquez que l’exécution du lot d’actions n’a pris que 3 secondes (=la durée de l’action la plus longue) et que l’exécution de Parallel.Invoke est synchrone : la ligne de code Trace3() ne s’est exécutée qu’après la fin des 3 actions.
C’est la TPL qui se charge du travail de synchronisation à notre place.
Le lot d’actions s’est exécuté de manière parallèle et synchrone au sens où Parallel.Invoke est synchronisé avec la fin de l’exécution des actions.

 

De la même manière, on peut utiliser d’autres mécanismes de la TPL pour obtenir le même résultat pour notre exemple comme par-exemple Parallel.ForEach qui permet de paralléliser le code appliqué aux éléments d’une collection.
Vous pouvez évidemment profiter de Parallel.ForEach très facilement dans vos projets, du moment qu’il n’y a pas de ressource critique non protégée dans le code du foreach.


 

3. Appel des actions en parallèle avec Parallel.ForEach

private void Button_ParallelForeach(object sender, RoutedEventArgs e)
{
Trace1();
ParallelForeach();
Trace4();
}

void ParallelForeach()
{
Action[] actions = { action1, action2, action3 };

Trace2();

// Appel des actions en parallèle
Parallel.ForEach(actions, a => a());

Trace3();
}

A l’exécution on obtient:
**************
1 >
2 >>
Fin Action1
Fin Action2
Fin Action3
00:00:03.0590707
3 <<
4 <

Le lot d’actions s’est exécuté de manière parallèle et synchrone comme dans l’exemple précédent avec Parallel.Invoke.

 

PLinq nous fournit une implémentation parallèle de Linq To Objects. Nous modifions quelque peu notre exemple pour partir d’une collection d’entiers qui représente les délais d’attente pour chaque action.



4. Appel des actions en parallèle avec PLinq

private void Button_PLinq(object sender, RoutedEventArgs e)
{
    Trace1();
    ParallelLinq();
    Trace4();
}
 
// PLinq
void ParallelLinq()
{
    Trace2();
 
    int[] delays = { 1000, 2000, 3000 };
    Action<int> action =
        (ms) => { Thread.Sleep(ms); Trace.WriteLine("\tFin Action"); };
 
    delays.AsParallel().ForAll(ms => action(ms));
 
    Trace3();
}
 
A l’exécution on obtient:
 
**************
1 >
2 >>
Fin Action
Fin Action
Fin Action
00:00:03.0056306
3 <<
4 <

Le lot d’actions s’est exécuté encore une fois de manière parallèle et synchrone.
 

 

Imaginons maintenant que nous souhaitions conserver le parallélisme, mais ne souhaitions plus exécuter le lot de tâches de manière synchrone, c’est à dire continuer l’exécution du code qui suit l’appel aux actions sans attendre qu’elles se terminent.
Pour cela, nous allons travailler directement sur les objets Task fournis par la TPL.



5. Appel des actions en parallèle et asynchrone avec l’objet Task

On utilise la Task factory qui à partir d’une action, instancie une Task et la démarre.

void Button_TaskPara(object sender, RoutedEventArgs e)
{
    Trace1();
    TaskPara();
    Trace4();
}
 
void TaskPara()
{
    Trace2();
 
    // Lance les 3 tâches en // sans attendre leur fin
    Task.Factory.StartNew(action1);
    Task.Factory.StartNew(action2);
    Task.Factory.StartNew(action3);
 
    Trace3();
}

A l’exécution on obtient:


**************
1 >
2 >>
00:00:00.0019663
3 <<
4 <
Fin Action1
Fin Action2
Fin Action3
 

Remarquez que cette fois-ci, les traces s’affichent suivant la séquence 1, 2, 3, 4 et les traces des actions n’arrivent que plus tard. StartNew() démarre les tâches et passe à la suite, sans qu’il y ait de mécanisme de synchronisation. Le lot de tâches s’exécute de manière parallèle et asynchrone .


 

C’est intéressant de lancer les actions de manière parallèle et asynchrone : on libère le thread du dispatcher qui permet de conserver une interface réactive.
Malgré tout, je suis un peu embêtée car maintenant je ne sais plus combien de temps prend l’exécution de mes 3 actions.
Comment faire pour conserver l’asynchronisme, le parallélisme ainsi que l’appel à trace3 (qui affiche le temps qui s’est écoulé entre le début et la fin de mes actions) qui devrait plutôt se faire après l’exécution de mes 3 actions ?

C’est là qu’interviennent les méthodes de continuation des objets Tasks et c’est aussi là que la TPL devient encore plus intéressante.



6. Appel des actions en parallèle et asynchrone avec continuation

La méthode  ContinueWhenAll() permet de chainer une tâche de continuation qui s’exécutera lorsque toutes les actions seront terminées. Ces tâches s’exécuteront en parallèle et de manière asynchrone: ContinueWhenAll rend la main immédiatement.

void Button_TaskPara(object sender, RoutedEventArgs e)
{
    Trace1();
    TaskParaContinue();
    Trace4();
}
 
void TaskParaContinue()
{
    Action[] actions = { action1, action2, action3 };
 
    Trace2();
    Task.Factory.ContinueWhenAll(
            actions.Select(a => Task.Factory.StartNew(a)).ToArray(),
            (at) => Trace3());
}

On obtient le résultat suivant à l’exécution:

**************
1 >
2 >>
4 <
Fin Action1
Fin Action2
Fin Action3
00:00:03.0081844
3 <<

Cette fois, on exécute bien le lot de tâches de manière asynchrone : Trace4 s’affiche avant la fin des actions. Pour autant, on a pu chainer nos actions à une tâche de continuation qui contient Trace3() et qui nous permet de vérifier que nos tâches s’exécutent bien en parallèle : elles se terminent au bout de 3 secondes.
 
 

 

Bon ben c’est bien sympa tout ça…mais du coup, à quoi peut bien nous servir le pattern async/await ?
Pour rappel, la pattern async/await est disponible pour le moment avec la CTP async pour Visual Studio.


7. Appel des actions de manière séquentielle et asynchrone avec async/await

Installez la CTP Async pour Visual Studio 2010 et référencez l’assembly AsyncCtpLibrary pour pouvoir utiliser async et await.

(Attention, la CTP n'est pas compatible avec le SP1 bêta de Visual Studio 2010 !)

Une méthode qui contient un appel asynchrone doit toujours être préfixée par le mot-clé async.
Le mot clé await me permet de lancer une tâche de manière asynchrone et de ressortir immédiatement du contexte de ma méthode préfixée par async. On ne reviendra exécuter la ligne de code qui suit l’appel à la tâche uniquement lorsque celle-ci sera terminée. Pour nos 3 actions, cela revient à les exécuter de manière séquentielle, mais en asynchrone Confus.

Pour ceux qui connaissent yield, await est aux tâches ce que yield est aux collections Premier de la classe.

En fait, c’est beaucoup plus facile à comprendre en regardant le résultat de l’exécution du code suivant:

private void Button_Async(object sender, RoutedEventArgs e)
{
    Trace1();
    AsyncProcSeq();
    Trace4();
}
 
async void AsyncProcSeq()
{
    Trace2();
 
    await Task.Factory.StartNew(action1);
    await Task.Factory.StartNew(action2);
    await Task.Factory.StartNew(action3);
 
    Trace3();
}

On obtient le résultat suivant à l’exécution:

**************
1 >
2 >>
4 <
    Fin Action1
    Fin Action2
    Fin Action3
00:00:06.0407339
3 <<


Le premier appel asynchroneawait Task.Factory.StartNew(action1 ) ) rend immédiatement la main en ressortant du contexte de la méthode AsyncProcSeq, et en exécutant la suite des lignes de code de la méthode appelante, à savoir Trace4() (un peu comme avec yield).
A la fin de l’exécution de action1, l’exécution asynchrone de l’action2 sera déclenchée, et il en sera de même ensuite pour action3 une fois que l’exécution de action2 sera terminée.

Trace3() sera appelé à la fin de l’exécution des 3 actions, tout comme avec le code de continuation de tout à l’heure. Attention, par contre les 3 actions ne s’exécutent pas en parallèle ici, mais bien de manière séquentielle : elles ont mis 6 secondes à s’exécuter.

L’appel asynchrone async/await est donc très différent du démarrage d’une tâche parallèle dont on attend la fin !
Le chemin d’exécution sera en effet tout autre.

Si l’on souhaite exécuter ces actions en parallèle tout en conservant cette simplicité d’écriture, il suffit de coupler la TPL à async/await de la manière suivante:



8. Appel des actions en parallèle et asynchrone avec async/await et la TPL

private void Button_AsyncPara(object sender, RoutedEventArgs e)
{
    Trace1();
    AsyncProcPara();
    Trace4();
}
 
async void AsyncProcPara()
{
    Action[] actions = { action1, action2, action3 };
 
    Trace2();
 
    await Task.Factory.StartNew(() => Parallel.Invoke(actions));
 
    Trace3();
}

A l’exécution, on obtient le résultat suivant:

**************
1 >
2 >>
4 <
Fin Action1
Fin Action2
Fin Action3
00:00:03.0168947
3 <<


Les actions se sont exécutées en parallèle (les actions ne prennent que 3 secondes pour s’exécuter) et de manière asynchrone (on rend la main à l’appelant) tout en assurant l’équivalent d’une continuation pour toutes les lignes de code qui apparaissent à la suite de l’appel asynchrone c’est à dire Trace3().


 

Voilà, la boucle est bouclée !

Il est à noter que le pattern async/await sera plutôt utilisé dans les cas où l’on a besoin d’effectuer une séquence d’appels asynchrones de manière séquentielle (c’est à dire comme dans démo 7.) comme par-exemple des appels à des web services en cascade.
En effet, il simplifie grandement l’écriture d’un code de continuation qui aurait été nécessaire avec l’utilisation de la TPL (cf démo no 6).

Pour aller plus loin:

A vos claviers !

Leave a Comment
  • Please add 5 and 7 and type the answer here:
  • Post
  • Hum, sympa le CTP Async pour Visual Studio 2010 ! Sauf qu'il ne supporte pas Windows 7 en 64 bits. Bien entendu ce n'est pas écrit dans les system requirements...

  • Bonjour Adrien,

    Je travaille sur Win 7 x64 et la CTP Async s'installe et fonctionne correctement.

    Par contre, la CTP n'est pas compatible avec VS 2010 SP1 bêta. Ceci devrait être corrigé dans la version définitive du SP1.

    Peut-être est-ce ce souci que vous rencontrez...

    Cordialement,

  • Très clair. Il n'est pas inutile en effet de préciser les différences entre parallélisme et asynchronisme.

    A noter que les langages d'acteurs vont encore plus loin.

    Ce sont des langages objets dans lesquels tous les appels de méthode sont par défaut asynchrones.

    Il faut donc spécifier explicitement les points de synchronisation lorsqu'il y en besoin, alors qu'implicitement tout est asynchrone.

Page 1 of 1 (3 items)
Page 1 of 4 (87 items) 1234