TD 1 • Lecture asynchrone
Cartouche
| Champ | Valeur |
|---|---|
| Auteur·e | Élise |
| Édition | 2024-03-04 |
| Durée | 2 séances |
| Taille des équipes | 1~2 personnes login des auteur·rice·s dans author.txt |
| Rendu | via git, dépôt 2023_async_reader, droits en lecture à delivery_collector |
Préambule
Notation
2,5 points par étape
Un rendu sale et du code qui ne respecte pas la norme peut vous faire perdre une partie ou la totalité des points acquis.
Dépôt de référence
Vous pouvez utiliser le dépôt de référence pour faciliter le formatage automatique de votre éditeur de code et argumenter son auto-complétion. https://git.ecole-89.com/eriizu/cpp_reference
Introduction
Ceci est le premier TD d’une série destinée à étudier les stratégies qui permettent :
- d’avoir un jeu asynchrone sur un seul thread en évitant toute logique bloquante ;
- de pouvoir héberger plusieurs parties sur un seul thread ; puis
- d’utiliser plusieurs threads.
Aujourd’hui vous allez étudier une architecture asynchrone pour lire des événements depuis une fenêtre ou depuis le terminal. On va voir qu’avec les flux C++ on ne peut pas réellement faire de l’asynchrone sur un seul thread, mais on va contourner le problème qui de toute façon n’est pas très important pour la réalisation d’un jeu graphique en réseau.
Étape 1 : lire sur un flux
Fichier·s à rendre : step1.main.cpp
Écrivez un main qui lit sur l’entrée standard et affiche chaque ligne lue.
Étape 2 : lire depuis une fenêtre
Fichier·s à rendre : step2.main.cpp
Écrivez un main qui :
- ouvre une fenêtre SFML ;
- boucle sur les événements reçus tant que la fenêtre est ouverte :
- ajoute à une chaîne la lettre associée à chaque touche pressée,
- affiche la chaîne lorsque la touche entrée est pressée et vide la chaîne retenue.
Étape 3 : classe GfxReader
Fichier·s à rendre : {GfxReader.{cpp,hpp},step3.main.cpp}
On va créer une classe qui se charge de l’ouverture d’une fenêtre, le stockage des caractères lus et qui nous propose une fonction membre get_line pour récupérer la chaîne lue quand elle est disponible. La fonction boucle tant que la ligne n’est pas complète (tant qu’on a pas pressé entrée). La boucle doit s’arrêter si la fenêtre est fermée.
class GfxReader {
public:
GfxReader();
~GfxReader() = default;
std::string get_line();
private:
sf::Windows _win;
std::string _collector;
};
Implémentez la classe de sorte que le main suivant ait le même comportement que l’étape 2 :
int main(void)
{
GfxReader reader;
std::string line;
line = reader.get_line();
while (!line.empty()) {
std::cout << line << "\n";
line = reader.get_line();
}
return 0;
}
Étape 4 : GfxReader non bloquant
Fichier·s à rendre : {GfxReader.{cpp,hpp},step4.main.cpp}
Maintenant, essayons de travailler non pas avec une fenêtre graphique mais plusieurs. Rien ne nous empêche d’instancier plusieurs GfxReader, mais en l’état on bloque dans get_line. Un peu comme un appel à std::getline bloquerait.
Objectif de l’étape : écrire un programme qui reçoit en argument le nombre de fenêtres graphiques à ouvrir et sur lesquelles on doit lire des lignes.
Pour y parvenir : on doit rendre notre GfxReader non bloquant (c’est à dire que la fonction get_line ne bloque pas s’il n’y a pas encore de ligne disponible.)
Modifiez GfxReader::get_line. Son type de renvoi doit devenir std::optional<std::string>. C’est un type qui permet de renvoyer une valeur ou rien. C’est comme renvoyer null avec un pointeur, sauf qu’on a pas besoin de malloc notre std::string.
Le nouveau comportement que doit avoir get_line c’est de gérer tous les événements disponibles sur la fenêtre jusqu’à ce :
- qu’il n’y ait plus d’événements disponibles : elle doit renvoyer
{}; ou - que l’utilisateur appuie sur entrée : elle doit renvoyer la chaîne de caractères collectés.
Ensuite, adaptez votre main pour qu’il prenne en argument un nombre de GfxReader à instancier. Vous devez instancier les GfxReader en nombre demandé et vous devez vérifier, en boucle, sur chacun s’il y a une ligne de prête. Dès qu’une ligne est prête affichez-là, accompagnée de l’index du GfxReader qui l’a produite.
Dès qu’on appuie sur entrée sur une fenêtre, la ligne doit s’afficher, sans temps d’attente perceptible.
Pour gérer les fermetures de fenêtre, une solution c’est d’ajouter une fonction membre done à GfxReader qui renvoie true si la classe a terminé (ici c’est lorsque la fenêtre a été fermée).
Si un GfxReader a terminé, supprimez-le de la collection de votre programme.
Étape 5 : Séparation des responsabilités
Fichier·s à rendre : {GfxReader.{cpp,hpp},step5.main.cpp}
Au lieu d’avoir une fonction membre get_line qui gère les événements et qui renvoie une chaîne si elle est disponible, une conception plus commune serait d’avoir deux fonctions membres différentes :
- une qui gère les événements ;
- l’autre qui renvoie la chaîne.
Déportez la gestion d’événements à une fonction process_events de sorte que get_line se contente de renvoyer une chaîne si elle est prête ou {} si elle ne l’est pas.
Copiez le main de l’étape précédente et adaptez-le pour qu’il appelle process_events avant de vérifier s’il y a une chaîne de prête.
Si on appelle jamais process_events, get_line renverra systématiquement {}.
Étape 6 : TermReader non bloquant
Fichier·s à rendre : {TermReader.{cpp,hpp},step6.main.cpp}
Lorsque l’on travaille avec des flux, on a un problème majeur. Les opérations sur les flux sont bloquantes. Et on ne peut rien y faire.
On va être obligé de recourir aux threads de sorte à ne pas bloquer sur le thread principal.
Théorie async et future
On va s’intéresser à deux outils :
std::asyncstd::future
En C++ lancer une tâche sur un autre thread c’est plutôt facile.
#include <chrono>
#include <future>
#include <iostream>
#include <thread>
unsigned int block_for(unsigned int seconds)
{
std::this_thread::sleep_for(std::chrono::seconds(seconds));
return seconds;
}
int main(void)
{
auto future = std::async([]() { return block_for(5); });
while (future.wait_for(std::chrono::milliseconds(100)) != std::future_status::ready) {
std::cout << "not ready\n";
}
unsigned int result = future.get();
std::cout << "done, res is " << result << "\n";
}
La fonction std::async :
- prend un pointeur sur une fonction à lancer sur un autre
thread, ici on lui passe une lambda qui appelleblock_for; et - renvoie un
std::future, c’est une classe qui permet de récupérer une variable produite par un autrethreadlorsqu’il a fini, on sait qu’on peutgetla variable si un appel àwait_fornous renvoiestd::future_status::ready.

Votre tâche
Écrivez une classe TermReader avec les mêmes fonctions membres que GfxReader. Elle doit, à l’aide des outils qu’on vient de voir :
- vérifier si une ligne a été lue et la stocker lorsqu’on appelle
process_events; et - si une ligne a été lue, la renvoyer lorsqu’on appelle
TermReader::get_line.
Lorsqu’on utilise std::getline sur un flux, on sait qu’il n’y a plus rien à lire lorsque la condition suivante passe :
std::getline(is, str);
if (!is) {
// il n'y a rien à lire, le flux est en "erreur"
}
Lorsque vous vous rendez compte que le flux est en erreur, faite comme lorsque votre fenêtre de GfxReader est fermée : faites en sorte que votre fonction TermReader::done renvoie true. Ne relancez pas de std::async pour la lecture.
Écrivez un main qui démontre le bon fonctionnement de votre classe.
Étape 7 : Interface
Fichier·s à rendre : {IReader.hpp,{TermReader,GfxPlayer}.{cpp,hpp},step7.main.cpp}
Un réflexe important en programmation orientée objet, c’est de se rendre compte quand plusieurs classes partagent le même genre de métier et ont des fonctions membres similaires. En l’occurrence, c’est le cas de GfxReader et TermReader.
Un nom d’interface tout trouvé serait IReader.
Écrivez l’interface IReader et faites héritez GfxReader et TermReader de cette interface.
Écrivez un main qui démontre le bon fonctionnement de votre interface.
Théorie : unique_ptr
Lorsqu’on travaille avec des interfaces, on peut avoir un problème pour faire des collections d’objets qui sont d’un type d’interface.
Là où on peut faire un vector sur des TermReader ou des GfxReader, on ne peut pas en faire un sur des IReader directement, car il est impossible d’instancier un IReader. Cela-dit, il est possible de détenir une référence ou une adresse qui pointe sur IReader.
Par exemple, le code suivant ne fonctionne pas :
std::vector<IReader> vec;
TermReader reader(std::cin);
vec.push_back(reader);
Une solution, serait d’avoir recours aux pointeurs.
std::vector<IReader *> vec;
vec.push_back(new TermReader(std::cin));
Ça, le compilateur nous laisse faire, non sans nous apporter de soucis : si on retire un élément du vector, il ne sera pas delete pour nous, ce qui introduit une fuite mémoire. Pourtant les conteneurs de la STL savent appeler les destructeurs des objets qu’ils stockent. Il existe une solution qui tire avantage de cette fonctionnalité : mettre nos pointeurs dans un objet qui lorsqu’il sera détruit libérera le pointeur avec delete.
La classe d’un tel objet existe déjà, c’est std::unique_ptr. Lorsqu’on donne une adresse à un unique_ptr on dit qu’il en devient le propriétaire (c’est une façon de dire que c’est son problème de libérer la mémoire associée à ce pointeur.)
Les unique_ptr font partie d’un ensemble de classes que l’on appelle des smart pointers.

On peut instancier un unique_ptr ainsi :
#include <memory>
int main(void)
{
std::unique_ptr<IReader> reader_ptr{new TermReader(std::cin)};
while (!reader_ptr->done()) {
reader_ptr->process_events();
auto potential_line = reader_ptr->get_line();
if (potential_line) {
std::cout << "got: " << *potential_line << "$\n";
}
}
// lorsque la fonction renvoie, "reader_ptr" est libéré avec "delete"
}
Les objets de type unique_ptr peuvent être stockés dans un vector :
int main(void)
{
std::vector<std::unique_ptr<IReader>> vec;
vec.emplace_back(new TermReader(std::cin));
vec.emplace_back(new GfxReader);
vec.emplace_back(new GfxReader);
vec.emplace_back(new GfxReader);
// [...] on travaille avec nos reaaders
return 0;
// tous sont libérés à la destruction du vector
}
Étape 8 : collection
Fichier·s à rendre : {TermReader.{cpp,hpp},step8.main.cpp}
Écrivez un programme qui lance un TermReader et autant de GfxReader que demandé en argument du programme. Écrivez une fonction read_on_all qui surveille tous les IReader
void read_on_all(std::vector<std::unique_ptr<IReader>> &readers);
Lorsqu’un lecteur renvoie une ligne :
- affichez-la ; et
- si la ligne commence par le mot
shutdown, affichezshutting downsur la sortie standard et provoquez l’arrêt de la fonction et du programme.
À la destruction du TermPlayer on peut savoir s’il reste un thread qui peut bloquer, en vérifiant le std::future que std::async vous a renvoyé en dernier. La fonction membre valid renvoie true si un thread est en cours d’exécution. Si un thread est entrain de bloquer sur la lecture affichez le message suivant : “press enter for reader to stop.”
Conclusion
Assurez-vous d’avoir bien compris toutes les étapes qui viennent de passer. Si vous avez des questions ou avez fait l’une d’entre elle sans trop comprendre ce qu’il c’est passé : faites-le moi savoir.
On vient de découvrir comment faire du multitâche sur un seul thread et c’est très important pour la suite. Avec ses principes on peut concevoir un jeu ou un programme, dans n’importe quel langage de programmation si on y retrouve ces concepts, capable de gérer plusieurs joueurs, clients, parties, lobbies, tâches, etc.
En programmant des classes qui ne bloquent pas lorsqu’elle attendent quelque chose : on s’est offert la possibilité d’orchestrer le déroulement de plusieurs tâches indépendantes.
Vous avez peut-être déjà une idée de comment est-ce que l’on va utiliser ses outils là pour faire notre jeu, dans tous les cas nous le verrons lors du prochain TD.