De C# à C++ : elle est où la TPL ? Vive la PPL ! (partie 1)

Il y a pleins de bonnes raisons de faire du C++ et si comme moi vous avez plus une base de C# que de C++, vous pourriez être intéressé par cette série de posts. Aujourd’hui nous allons nous intéresser à comment transposer nos utilisations de la Task Parallel Library (TPL) .NET en C++ en utilisant la Parallel Patterns Library (PPL) de C++. J’espère qu’à la fin de ce post, vous aurez un peu moins mal à la tête en lisant la prose de Simon :).

Petit rappel des épisodes de la série :

En .Net une Task permet de créer et de représenter un traitement asynchrone. Sans ordre contraire, le runtime utilise un thread pool pour éviter de créer de threads à chaque fois (c’est une opération couteuse). Nous retrouvons le même comportement avec les task de la PPL en C++.

 

Créer sa première tâche

La première des choses à faire est de rajouter le bons headers. Dans notre cas ils sont deux :

  • ppl.h : contient le coeur de la PPL
  • ppltasks : contient ce qui est relatifs aux tasks

Dans la suite du post, je vais volontairement laisser les namespace (concurrency) afin de bien savoir où se trouvent les objets manipulés.

Le plus simple alors pour créer une task est d’utiliser la méthode concurrency::create_task en lui passant une lambda. C’est aussi à ce moment la que l’on se souvient des table de smileys que l’on nous faisait apprendre par coeur (<3) en primaire :

auto task =concurrency::create_task(
    [](){

        MyDemoClass myDemoClass;
        return myDemoClass.DoSomethingConsumingCpu();
    }
);

Pourquoi tous ces smileys ? Vous remarquerez les crochets [] dans cet exemple: ils permettent de spécifier les objets qui seront capturés par la lambda exécutée. En .Net cela aurait été fait automatiquement pour vous mais en C++, il est de votre responsabilité de gérer la durée de vie de vos objets. Ce n’est pas vraiment spécifique aux tasks et lié aux lambdas mais un rappel est toujours bon. D’ailleurs si vous voulez en savoir plus, Simon en parle en détails dans son article :

Dans notre cas, nous ne capturons aucun objet. Nous aurions donc aussi pu instancier l’objet MyDemoClass hors de la task et le passer à la lambda. Dans cet exemple nous le passons par référence plutôt que par copie en utilisant l’opérateur ‘&’. Comme nous allons le voir juste après, c’est une mauvaise pratique :

MyDemoClass myDemoClass;
auto task = concurrency::create_task(
    [&myDemoClass](){
        int result = myDemoClass.DoSomethingConsumingCpu();
        return result;
}
);

Attention, comme c’est vous qui gérez la durée de vie de vos objets, il est important de s’assurer qu’ils sont bien “en vie” lorsque la lambda sera exécutée. Le code précédent tel quel produirait une exception car myDemoClass aura disparu de la pile pendant l’exécution de la task. Une bonne pratique est donc de passer ses paramètres par valeur. Il est aussi possible d’utiliser un smart pointer dans le cas d’objets dynamiques :

std::shared_ptr<MyDemoClass> myDemoClassPtr = std::make_shared<MyDemoClass>();
auto task = concurrency::create_task(
    [myDemoClassPtr](){ return myDemoClassPtr->DoSomethingConsumingCpu;    });

À noter qu’il est aussi possible d’utiliser ces valeurs spéciales dans la clause de capture :

  • = : capture toutes les variables utilisées dans la lambda par valeur
  • & : capture toutes les variables utilisées dans la lambda par référence

Ici ma lambda va être exécutée sur un autre thread et la tache représentant cette exécution est retournée par l’appel à concurrency::create_task. La tâche est de type concurrency::task<int> car elle ne retourne un int dans notre exemple. Si elle n’avait rien retourné elle aurait été de type concurrency::task<void> (et non pas concurrency::task comme un développeur .Net pourrait s’y attendre) .

 

Retrouver le résultat et le chaining de task

Le résultat du traitement d’une tâche est très simple à avoir : il faut appeler la méthode “get” sur l’objet task. Cette méthode est bloquante et si vous l’appelez dans le thread UI, elle bloquera donc votre rendu graphique. Cela est par contre très pratique dans une application console lorsque vous voulez attendre que votre action soit terminée avant de terminer l’application. Il existe aussi une fonction “wait” ne retournant pas de valeur permettant d’attendre la complétion d’une tâche. Cela donne cela dans l’exemple précédent :

int _tmain(int argc, _TCHAR* argv [])
{
    MyDemoClass myDemoClass;
    auto task = concurrency::create_task(
        [myDemoClass](){ return myDemoClass.DoSomethingConsumingCpu();    });

    //Lecture du résultat de la tâche
    int result = task.get();

    //Retourne directement car la tâche est déjà terminée
    task.wait();

    return result;
}

Aussi, il est courant de vouloir faire quelque chose à partir de la valeur d’une tâche sans forcément retourner sur le thread initial. En C# la solution serait d’utiliser le mot clef await ou d’utiliser les méthodes ContinueWirth présente sur les Tasks. Les task C++ proposent quant à elle un mécanisme de chaining bien pratique ressemblant à ce que l’on peut faire avec les promises de javascript.  Vous pouvez appeler la méthode “then” sur un objet task en lui fournissant une lambda prenant comme paramètre :

  • Soit un paramètre du même type que la tâche que l’on veut “chainer”.  Cette méthode sera appelée une fois la précédente tâche exécutée. Elle est exécutée sur un autre thread que le thread exécutant la première lambda.
  • Soit une task du même type que la task initiale. Cela vous permet de tracer le résultat d’une tâche sans forcément nécessiter d’obtenir le résultat de celle-ci. Pour obtenir le résultat il faudrait appeler la méthode get comme vu précédemment. Il est aussi possible de retourner la task reçue.

 

Il est bien sûr possible de chainer plusieurs task à la fois. L’objet final est une task retournant un objet du même type que le dernier retourné dans la chaine. Cet exemple démontre les différents cas de figure :

auto secondTask = task.then([](int result){

    //Ici on récupère directement le résultat 
    return result;
}).then([](concurrency::task<int> taskResult){
    //on ne fait rien et on renvoit la task
    return taskResult;
}).then([](concurrency::task<int> taskResult){

    //utilisation de la tâche pour lire l'int
    int intResult = taskResult.get();

    //Retour d'un objet de type différent
    //  secondTask est donc de type concurrency::task<const wchar_t*>
    return L"Le résultat du traitement est : " + intResult;

});

//Lecture du résultat "global" = un wstring
std::wstring result = secondTask.get();

ATTENTION : lorsque vous chainez avec une lambda prenant une task en paramètre il est important de toujours faire un get sur celle-ci. En effet, si vous ne le faite pas et qu’une exception s’est produite, elle sera perdue – et fera donc crasher l’application. La seule exception, est si vous retournez la task d’entrée dans la lambda. C’est le moment de parler de gestion des erreurs.

Gestion des erreurs

En C#, les erreurs sont gérées à l’aide des exceptions. Cela tombe bien car le même mécanisme est utilisé par les tasks de la PPL C++. Si une exception est levée (vous essayiez d’allouer trop de mémoire par exemple) alors vous pouvez l’attraper au moment de faire le get ou le wait sur la task. L’exception va donc remonter jusqu’au contexte appelant.

Dans le cas ou vous êtes dans une lambda de chaining(then) prenant une task en argument, alors l’exception ne sera levée que si vous faites un get sur la task. Autrement, elle suivra un chemin “classique”. Lorsqu’une exception est levée dans une chaine, seul les lambdas prenant une task en paramètre seront ainsi exécutées.

Les recommandations pour traiter les erreurs sont alors les suivantes :

  • Utiliser des lambdas de continuations prenant des tasks en paramètre pour gérer les erreurs. Comme nous venons de le voir, ces lambdas seront toujours exécutées et vous ne perdrez pas d’exception dans la nature.
  • Utiliser des blocks try/catch autour des appels à “wait/get” pour catcher les exceptions et faire le traitement approprié.

Si vous “oubliez” de gérer une exception, cela aura le fâcheux effet de faire crasher votre application. Bien sûr Visual Studio vous fera gentiment un breakpoint au bon endroit si cela se produit pendant une séance de debug.

Crash d'une application Console suite à une exception

 

 

Les opérateurs de tasks

Les développeurs C++ ont la fameuse habitude de s’amuser à redéfinir les opérateurs des objets qu’ils créent. C’est aussi le cas pour les tasks et en tant que développeur C# je trouve cela super cool comme écriture. En voici deux qui pourront vous être utiles :

  • && : combine plusieurs tasks en une seule attendant que toutes soient terminées. La task retourne une exception si une exception est levée dans une des tasks combinées.
  • || : combine plusieurs tasks en une seule attendant qu’une soit terminée. Si une exception est levée, les autres tâches continuent et l’exception n’est pas levée lors du get. Attention donc à ne pas perdre cette exception en chainant avec une lambda prenant une task en paramètre.

En réalité ce sont simplement des helpers vers deux méthodes concurrency::when_all et concurrency::when_any qui produisent le même type de résultats. Comme vous pouvez le voir dans l’exemple ci dessous, à part avoir l’avantage d’utiliser tous les types d’accolades, de parenthèses et de crochets de votre clavier, la syntaxe est beaucoup moins sympa :

//Création d'un array de tasks
std::array<concurrency::task<int>, 2> tasks = { task, secondTask };

auto joinTask = concurrency::when_all(tasks.begin(), tasks.end());
joinTask.wait();

auto whenAnyTask = concurrency::when_any(tasks.begin(), tasks.end());
whenAnyTask.wait();

Dernier point, il est obligatoire que les tasks en entrée soient du même type (qu’elles retournent un objet de même type).

 

C’est tout pour aujourd’hui

L’objectif de cette première partie était de vous donner les bases et de démystifier un peu le C++. On peut déjà voir que finalement on n’est pas non plus si loin des concepts que nous connaissons en C#.

À bientôt pour la seconde partie où nous parlerons peut être de ces sujets :

  • L’annulation des tasks,
  • Les groupes de tasks
  • Les contextes d’exécution de tasks

Petit bonus que je garde depuis plusieurs mois sur mon bureau, mon premier smiley en C++ :

CPPoverOtherLanguages

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus