[C++ / Await] Await, coroutines et ce que cela peut apporter pour le développement de jeux
L’article original en anglais est disponible sur github.
Disclaimer
Je suis un développeur travaillant essentiellement dans des environnements Microsoft à l’aide de C++ et C#. Je me considère comme un développeur expérimenté, mais pas comme une sorte de gourou ou de gardien d’un savoir absolu. Cet article est le résultat de réflexions personnelles que j’ai eu ces derniers temps, et il se peut qu’il contienne des anomalies. L’article original est publié sur Github et supporte les contributions externes. Si vous voyez des choses à corriger ou à compléter, n’hésitez pas à me laisser un commentaire, ou à éditer l’article sur Github directement ! (à l’heure actuelle, j’ai déjà reçu des corrections sur l’article en anglais, merci aux contributeurs !).
Prérequis
Cet exemple de code requiert Visual Studio 2015 preview (car il s’agit du seul compilateur implémentant await pour le moment). Il a aussi été écrit pour le SDK Windows 8.1 (mais il n’y a rien de spécifique à Windows 8.1 ni même à Windows 8.0 dans le code, ca ne devrait donc pas être trop difficile de l’adapter à Windows 7 et au SDK DirectX 11).
Le code est récupérable et “forkable” depuis Github à cette adresse : https://github.com/simonferquel/AwaitInGameLoopSample. N’hésitez pas à soumettre des Pull Requests !
Pourquoi cet exemple ?
Le mot clef await et les resumable functions font parti d’une proposition de fonctionnalité pour une future version du standard C++. Vous pouvez retrouver la dernière version de la proposition ici : http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n4134.pdf. Await est généralement considéré comme un truc magique qui fait que l’on peut écrire une fonction asynchrone non bloquante dans un style séquentiel (avec tout ce dont on a l’habitude dans du code séquentiel : boucles, conditions, blocs try/catch, …). C’est excellent pour les IO asynchrones, c’est très pratique pour synchroniser divers processus asynchrones (par exemple, lire un fichier de manière asynchrone par blocs, et pousser ces blocs au fur et à mesure vers une API Web là aussi de manière asynchrone), et en terme de lisibilité et de maintenabilité, c’est juste ce qui se fait de mieux. Await est apparu il y’a maintenant quelques années en C#, et cela a complètement bouleversé la manière dont les développeurs C# écrivent du code asynchrone. Certains développeurs qui ne voulaient auparavant pas faire l’effort de faire de l’asynchronisme par peur de la complexité ont même changé d’avis grâce à await.
Alors pourquoi cet exemple un peu étrange d’utilisation d’await dans un contexte d’une boucle de jeu à 60 frames par seconde, avec un haut niveau d’exigence de performance ? C’est à cause de la manière dont await fonctionne en terme d’extensibilité, pour illustrer que cela repose sur une généralisation du concept de coroutines (await n’est pas restreint au multi-threading ou aux I/O asynchrones), et pour montrer comment cela peut permettre aux développeurs de jeux d’écrire la logique de leurs jeux d’une manière plus simple, claire et maintenable avec très peu voir pas du tout d’impact négatif sur les performances (et même peut-être dans certains cas, avec un gain de performance).
Qu’est-ce qu’une coroutine ?
Pour répondre à cette question, rappelons nous déjà de ce qu’est une routine. Une routine est quelque chose que l’ont peut appeler, qui tourne jusqu’à avoir fini ce qu’elle a à faire, et qui peut optionnellement renvoyer un résultat. En clair, il s’agit de n’importe quel API ou fonction synchrone. Une coroutine, est quelque chose que l’on peut appeler, qui finira ce qu’elle a à faire à un moment indéterminé, et qui peut optionnellement renvoyer un résultat. La notion importante ici est le moment indéterminé. Ainsi un appel à std::async peut être considéré comme un appel à une coroutine, tout comme n’importe quel appel à une API d’I/O asynchrone, ou comme n’importe quelle fonction retournant une sorte de promise (une tâche PPL Microsoft ou Interl Threading Block, un std::future,…).
Qu’entends-tu par “la généralisation du concept de coroutine” ?
Les I/O asynchrones et les tâches ne sont pas les seules types de coroutine. Await fonctionne parfaitement avec elles, mais pas seulement avec elles. N’importe quel processus qui ne se finit pas de manière synchrone peut être considérer comme une coroutine : un timer est une coroutine (qui se termine quand son temps est écoulé), une animation est une coroutine (qui se termine quand elle a atteint son état final). On peut même considérer certaines “intéractions utilisateur attendues” comme des coroutines (par exemple, dans un jeu, afficher un menu de pause peut être considérer comme une coroutine se terminant quand l’utilisateur clique sur le bouton “reprendre la partie”). Dans un jeu, on peut aussi considéré que la logique du jeu est une coroutine s’exécutant tout au long de la game loop. Le code lié à cet article illustre justement cela : il s’agit d’une application DirectX single-thread qui utilise await pour exprimer la logique de jeu sous une forme séquentielle très lisible et maintenable, alors qu’à l’exécution, elle se comporte comme une machine à état mise à jour 60 fois par seconde par la game loop.
Ok, mais à quoi ca ressemble et pourquoi est-ce si bien ?
Le programme est construit comme un jeu single-thread, autour d’une boucle update/render tout à fait classique. La logique du “jeu” est assez simple. Il s’agit de la boucle d’interaction suivante:
Tant que le moteur de jeu est actif
- faire apparaitre un message avec une animation d’entrée
- puis attendre que l’utilisateur clique
- puis faire disparaitre le message avec une animation de sortie
- puis changer la couleur de fond de la fenêtre
- puis attendre une demi seconde
- reprendre au début de la boucle
Le mot clef ici est “puis”. En effet, cela implique qu’il y a une sorte de synchronisation entre chaque étape de la boucle d’interaction. Le second point important est la notion de temps (durée des animations d’entrée et de sortie, le timer d’une demi seconde, et le temps d’attente du clic de l’utilisateur) dans la plupart des différentes étapes. Cela doit être mis en perspective avec le fait que la boucle de jeu est censée tournée à 60 frames par seconde.
Ainsi, on ne pourrait pas écrire cela sous la forme d’une boucle synchrone comme ceci :
for(;;){ message->fadeIn().WaitForCompletion(); engine->blockUntilUserClick(); engine->changeBackground(); sleep(500); }
Car cela bloquerait la boucle de jeu et du coup le jeu n’aurait plus aucune occasion d’être rendu. Pour que cela fonctionne correctement, nous devons créer une machine à état acceptant des évènements de timing (pour gérer le timer d’1/2 seconde), d’états d’animation (pour être notifier quand les anim d’entrée et de sorties sont finies), et d’input utilisateur (pour le clic de souris). A partir de ces évènements on pourra ainsi gérer les transitions d’un état à un autre. Mais cela se fera au prix d’un code beaucoup moins lisible (lire une machine à état de cette complexité est beaucoup moins aisé que de lire une simple boucle). Mais grâce à la manière dont fonctionne await avec des coroutines, et la manière dont il crée des points de suspension et de reprise, le code final de la game loop ressemble très fortement au code ci-dessus:
GameAwaitableSharedPromise<void> gameLogic(Engine* engine) { auto animatedText = std::make_shared<AnimatedText>(); engine->addSceneObject(animatedText); std::default_random_engine re((unsigned int)(std::chrono::steady_clock::now().time_since_epoch().count())); std::uniform_real_distribution<float> dist(0.0f, 0.7f); while (true) { __await animatedText->fadeIn(); __await engine->waitForMouseClick(); __await animatedText->fadeOut(); engine->changeBackground(DirectX::XMFLOAT4(dist(re), dist(re), dist(re), 1.0f)); __await engine->waitFor(duration_cast<steady_clock::duration>(.5s)); } }
On retrouve bien notre aspect séquentiel avec notre boucle infinie.
C’est super ça, mais comment ca marche ?
Awaitable coroutine objects (Désolé, j’ai pas réussi à traduire ca en francais)
Premièrement nous devons comprendre ce que fait véritablement await. Dans une resumable function, une expression await prends n’importe quel “awaitable coroutine object”, et suspend l’exécution de la fonction jusqu’à ce que l’objet notifie sa complétion (cette suspension est la raison pour laquelle une fonction resumable doit elle-même obligatoirement retourner une sorte de promise compatible avec await – la même règle existe en C# : une fonction async doit retourner une Task).
Mais qu’est-ce qu’un “awaitable coroutine object” ? Il s’agit simplement d’un objet supportant 3 fonctions externes :
- bool await_ready(TCoroutine) est appelé juste avant la suspension (pour vérifier si la suspension est vraiment nécessaire)
- void await_suspend(TCoroutine, callback) est appelé au moment de la suspension. La callback devra être appelé pour notifier la complétion de la coroutine.
- T await_resume(TCoroutine) est appelé à la reprise, et doit retourner la valeur produite par la coroutine (ou propager une exception).
Donc ce que nous devons faire, c’est faire en sorte que animatedText->fadeIn(), engine->waitForMouseClick() et engine->waitFor() retourne un objet supportant ces 3 fonctions.
GameAwaitablePromise.h contient des classes qui font exactement cela:
- GameAwaitableSharedPromise peut être copié entre différents composants (chaque copie pointant vers un même état partagé),
- et GameAwaitableUniquePromise qui a un cout moins important en termes de performances mais qui ne peut être ni copié ni déplacer (mais qui peut être retourné par pointeur : les pointeurs de type GameAwaitableUniquePromise<T>* supportent les fonctions await_<…>).
Faire en sorte que les machines à état notifient leurs complétion via un objet GameAwaitableUniquePromise
GameAwaitableUniquePromise supporte les fonctions nécessaires au mot clef await, mais il agit aussi comme un jeton de promise. C’est à dire qu’il permet à un acteur de notifier sa complétion via sa méthode setResult(). Prenons par exemple le cas de l’animation (voir le fichier animation.h), qui est clairement une machine à état (à chaque frame elle met à jour une valeur depuis un état de départ vers un état d’arrivée). Elle fournie un GameAwaitableUniquePromise qui sera notifié à la frame qui fera atteindre l’animation son état de fin. Cela indiquera à await que c’est le moment de reprendre l’exécution, et notre logique de jeu va donc se poursuivre. La même chose à été faite pour le timer (qui est implémenté comme une machine à état mise à jour à chaque frame), et pour le clic de souris.
Si l’on regarde l’implémentation de la méthode setResult, on s’aperçoit qu’elle appelle la callback passée via await_suspend. C’est cette callback qui réveille notre fonction résumable.
Await démystifié
Ainsi, nous avons réussi à démystifier await, et nous avons vu comment construire avec, quelque chose qui est asynchrone, mais qui n’implique pas forcément la création de thread, d’I/O asynchrone, ni de parallelisme. Comme cela a été le cas en C#, je n’ai aucun doute qu’await aura un impact très important dans la manière dont on écrira du code asynchrone dans le futur. Et la manière dont await nous permet de créer nos propres objets “awaitables” ou d’adapter des types de promise existants promet une variété de domaines d’application énorme !
Commentaires