Chargement de données asynchrones en C++

June 2015 · 6 minute(s) de lecture

Dans certaines applications, il est nécessaire de charger beaucoup de données indépendantes avant de pouvoir faire tourner notre programme. Dans ce billet nous allons voir comment utiliser les nouveautés du C++11 afin de charger dynamiquement et de manière performante nos ressources.

Prenons comme exemple un jeu vidéo (sans blague), nous avons besoin de charger quelques ressources afin de pouvoir lancer notre jeu. La manière la plus simple et la plus intuitive consiste à charger toutes nos ressources les unes après les autres. Ce n’est pas un problème en soi, mais quand le jeu devient imposant et qu’il est nécessaire de charger des centaines de ressources, ça peut vite devenir lent voire très lent.

Dans notre jeu et afin de faciliter la compréhension de cet article, nous allons charger trois types de ressources différentes :

Voici le processus de chargement facile et rapide :

async-linear

Comme on peut le voir, chaque ressource est donc chargée une par une, ce qui nous donne un total de huit secondes.

Alors comment pouvons-nous améliorer ça ?

Au lieu de charger à la suite nos ressources, on va les charger de manière asynchrone. Alors vous vous demandez sans doute pourquoi on utilise pas de simples thread ? Pour plusieurs raisons, vous allez comprendre.

Utilisation std::async

La fonction std::async est nouvelle en C++11, elle est de très haut niveau car elle donne une abstraction élevée sur la manière de lancer un thread mais surtout d’en récupérer le résultat. Le fonctionnement asynchrone repose sur le principe des futures et promises.

Cela signifie en gros, que vous désirez un résultat (future) mais que ce dernier n’est peut-être pas encore disponible mais qu’il le sera tôt ou tard (délivré via la promise). C’est bien pour ça que ça porte le nom de promesse. En règle générale vous n’aurez pratiquement jamais besoin de manipuler les promises en C++, seules les future vous seront vraiment utiles.

En d’autres termes, nous allons paralléliser le chargement de nos ressources dans des thread et nous récupérerons leurs résultats via nos promises. Il est à noter qu’il n’est pas nécessaire de lancer un thread pour récupérer un résultat, on pourrait très bien se contenter de simples évaluations paresseuses où le résultat sera déterminé lors de la demande du résultat.

async-thread

Comme vous pouvez le voir, maintenant, toutes nos ressources seront chargées de manière parallèle et notre temps total sera tout simplement le temps le plus long.

Du code !

Avant toute chose, sachez qu’il est important que les fonctions de chargement soient totalement pures c’est à dire qu’elles ne doivent pas intéragir avec des variables globales.

Description des ressources

Nous utilisons de simples structures afin de rendre notre exemple bien plus concis. On ajoute aussi quelques aliases :

#include <chrono>
#include <future>
#include <iostream>
#include <string>
#include <thread>
#include <vector>

using namespace std::chrono_literals;

struct Spell {
    std::string name;
    std::string type;
    short mp;
};

struct Quest {
    std::string name;
    short level;
};

struct Item {
    std::string name;
    short weight;
};

// Shortcut
using Spells = std::vector<Spell>;
using Quests = std::vector<Quest>;
using Items = std::vector<Item>;

// Futures
using SpellFuture = std::future<Spells>;
using QuestFuture = std::future<Quests>;
using ItemFuture = std::future<Items>;

Fonctions de chargement

Comme dit précédemment, nous implémentons des fonctions pures. Elles sont totalement indépendantes et ne font que retourner un tableau. Afin de rentrer dans le cadre de l’exemple, nous simulons le temps de chargement avec la fonction std::this_thread::sleep_for.

Spells loadSpells()
{
    Spells spells;

    spells.push_back({"Fire", "blast", 12});
    spells.push_back({"Ice", "blast", 12});
    spells.push_back({"Heal", "cure", 4});

    // Simulate 2 seconds
    std::this_thread::sleep_for(2s);

    return spells;
}

Quests loadQuests()
{
    Quests quests;

    quests.push_back({"Kill one moko", 1});
    quests.push_back({"Destroy all", 100});

    // Simulate 4 seconds
    std::this_thread::sleep_for(4s);

    return quests;
}

Items loadItems()
{
    Items items;

    items.push_back({"Potion", 1});
    items.push_back({"Elixir", 5});
    items.push_back({"Moko's skin", 10});

    // Simulate 2 seconds
    std::this_thread::sleep_for(2s);

    return items;
}

Puis le main

Et voici le main, il instancie des objets std::future où l’on précise le retour attendu via son paramètre de template. Ensuite, nous attendons pour chaque future le résultat. On se fiche principalement de quel résultat nous souhaitons avoir en premier car dans tous les cas, ça sera le temps le plus long d’une des fonction que nous attendrons. En effet, la fonction std::future::get de notre objet future est spéciale, soit elle bloque soit elle retourne notre valeur immédiatement si notre thread s’est terminé.

Ce qui signifie que si j’attends le retour de ma fonction loadQuests alors que 3 secondes se sont déjà écoulées, la fonction std::future::get n’attendra qu’une seule seconde. Ainsi vous l’aurez compris, si quatre secondes se sont écoulées alors la fonction std::future::get retournera immédiatement.

int main()
{
    // Keep track of elapsed time
    auto start = std::chrono::high_resolution_clock::now();

    // Start all loading-functions
    SpellFuture spellFuture = std::async(std::launch::async, loadSpells);
    QuestFuture questFuture = std::async(std::launch::async, loadQuests);
    ItemFuture itemFuture = std::async(std::launch::async, loadItems);

    // Wait for all, whatever which one we wait, we will at most, wait 4 seconds.
    Spells spells = spellFuture.get();
    Quests quests = questFuture.get();
    Items items = itemFuture.get();

    auto now = std::chrono::high_resolution_clock::now();
    std::cout << "Ressources loaded, time elapsed: " << std::chrono::duration_cast<std::chrono::milliseconds>(now - start).count() << std::endl;

    for (const Spell &spell : spells)
        std::cout << "--> Spell " << spell.name << ", type: " << spell.type << ", cost: " << spell.mp << std::endl;
    for (const Quest &quest : quests)
        std::cout << "--> Quest " << quest.name << ", level: " << quest.level << std::endl;
    for (const Item &item : items)
        std::cout << "--> Item " << item.name << ", weight: " << item.weight << std::endl;

    return 0;
}

Sur ma machine, j’ai ce résultat, évidemment il se peut que les 4 secondes varient légèrement :

Ressources loaded, time elapsed: 4011
--> Spell Fire, type: blast, cost: 12
--> Spell Ice, type: blast, cost: 12
--> Spell Heal, type: cure, cost: 4
--> Quest Kill one moko, level: 1
--> Quest Destroy all, level: 100
--> Item Potion, weight: 1
--> Item Elixir, weight: 5
--> Item Moko's skin, weight: 10

Cependant, il reste un problème. En effet, nous n’avons pas pris en compte que le chargement des ressources peut échouer. Évidemment, il n’est pas possible de lancer une exception dans un thread et de la récupérer depuis un autre. Néanmoins, il est possible de stocker une exception et de la relancer plus tard. Oui oui, vous avez bien entendu. Stocker une exception. Seul hic, vous perdez évidemment la pile d’appel originale.

Dans notre cas, c’est la fonction std::future::get qui nous relancera l’exception que nous avions lancé dans notre fonction de chargement !

Lancer une exception depuis loadQuest

Modifions notre code légèrement afin de lancer et de récupérer notre exception :

Quests loadQuests()
{
    throw std::runtime_error("Invalid quest file");
}

int main()
{
    QuestFuture questFuture = std::async(std::launch::async, loadQuests);

    try {
        questFuture.get();
    } catch (const std::exception &ex) {
        std::cerr << "Failed to load resources: " << ex.what() << std::endl;
    }

    return 0;
}

Et évidemment, nous entrons bien dans la clause catch :

Failed to load resources: Invalid quest file

Voilà, cet article est terminé et j’espère maintenant que vous allez apprécier les nouvelles fonctionnalités asynchrones qu’offre C++11. Vous trouverez en annexe les deux codes avec et sans exceptions.

Autres ressources

Comme dit précédemment, std::async est la fonction la plus simple d’accès, si vous avez besoin de plus de flexibilité, vous pouvez jeter un coup d’oeil à std::packaged_task qui est bien plus bas niveau.

Autres cas d’utilisation

Ce cas d’utilisation s’applique principalement quand vous pouvez travailler sur des objets totalement indépendants. On peut noter :