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::async
  • std::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 appelle block_for ; et
  • renvoie un std::future, c’est une classe qui permet de récupérer une variable produite par un autre thread lorsqu’il a fini, on sait qu’on peut get la variable si un appel à wait_for nous renvoie std::future_status::ready.

Pasted image 20240301122934.png

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.

smart_ptr.png

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, affichez shutting down sur 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.