[C++/Qt] Performance de l'utilisation de QSharedPointer

Présentation

Qt est un framework orienté objet écrit en C++ et permettant de faire des interfaces graphiques. Ce framework est utilisé par le projet KDE depuis ses débuts pour en faire un environnement de bureau très complet.

Qt fournit un ensemble de pointeur intelligent1 permettant de gérer plus facilement la mémoire. Le but est alors de ne plus avoir à supprimer des objets. La suppression se fera soit par un pointeur intelligent soit par le système de hiérarchie d'objet existant en Qt (l'objet père qui supprime l'ensemble des objets fils qui lui sont rattachés).

Qt propose l'ensemble des pointeurs intelligents suivants:

  • QSharedDataPointer / QSharedData : ces deux classes utilisées ensemble permettent d'écrire un objet avec partage implicite. Cela signifie que l'objet fonctionnera comme la classe QString. Tant que l'objet est copié, passé en paramètre, .... l'objet n'est pas dupliqué (tous les objets pointes vers le même espace mémoire). Au moment où l'objet est modifié, l'objet est dupliqué. C'est ce qu'on appelle le COW2.
  • QExplictlySharedDataPointer / QSharedData : QExplicitlySharedDataPointer est une variante de QSharedDataPointer. Ce pointeur intelligent, comme son nom l'indique, est détaché uniquement lorsque la méthode detach() est appelée explicitement. Cette classe permet de faire des objets qui fonctionnent comme des pointeurs mais qui sont utilisés sans la notion de pointeur (le *). La suppression des données partagées se fait donc quand tous les objets ne sont plus utilisés.
  • QScopedPointer : Ce pointeur est le plus simple. Il permet de déclarer un pointeur sur le tas et s'occupe de la destruction de l'objet, lorsque le programme sort de la portée du bloc. Cela permet de ne plus se soucier de la libération du pointeur dans les cas d'erreur (exception, retour avant la fin de la fonction car le fichier n'a pu être ouvert, ...).
  • QSharedPointer : Le pointeur dont on parlera dans la suite de ce billet. Il permet de partager non plus des données (comme le fait QSharedData) mais de partager un pointeur3. Nous allons voir dans la suite du billet, comment simplement utiliser ce pointeur, et les performances de ce pointeur par rapport à un pointeur standard.

Sommaire

Utilisation de QSharedPointer

A quoi sert-il ?

L'objet QSharedPointer fait partie des pointeurs intelligents. Ces pointeurs permettent de gérer automatiquement la libération de la mémoire (plus besoin de faire delete ptr; quand le pointeur n'est plus utilisé) tout en restant utilisable comme un pointeur normal.

QSharedPointer fonctionne par comptage de référence. Après la déclaration, à chaque affectation, on augmente le compteur de référence, lorsqu'on quitte la portée du bloc, on décrémente le compteur de référence. QSharedPointer détruit donc automatiquement le pointeur quand il n'existe plus aucune référence vers ce pointeur. QSharedPointer vient donc comme une encapsulation de notre pointeur.

QSharedPointer vers la même adresse

Comment l'utiliser ?

La déclaration d'un pointeur en C, se fait en écrivant MyObject*. La syntaxe en utilisant un QSharedPointer se fait en écrivant QSharedPointer<MyObject>. Par la suite dans le programme, l'utilisation du pointeur QSharedObject se fera de la même manière qu'un pointeur C. (Avec l'opérateur -> pour appeler un membre, une méthode, ...) . Appelons dans la suite pointeur C, les pointeurs standards et QSharedPointer, le pointeur intelligent.

Afin d'éviter d'avoir un pointeur normal pouvant être supprimé à tout moment dans l'application, lors de l'utilisation de QSharedPointer, il ne faut utiliser le pointeur C résultant du new que pour la création du QSharedPointer. On peut donc directement créer le QSharedPointer en utilisant le constructeur QSharedPointer ( T * ptr ) qui prend en paramètre le pointeur C. C'est entre ces parenthèses que nous allons créer le pointeur C4.

{
    // Création du pointeur intelligent à partir d'un pointeur normal.
    QSharedPointer<MyObject> ptr(new MyObject());

    // Utilisation du pointeur intelligent comme un pointeur normal.
    if (ptr)
    {
        ptr->setMembre(maValeur);
    }

    // Appel d'une méthode utilisant ce pointeur
    maMethode(ptr);
}

Lorsque l'on quitte le bloc, si le comptage de référence tombe à 0, on supprime le pointeur. A l'intérieur de maMethode() le nombre de référence sera passé à 2. Si la méthode utilise le pointeur mais ne l'assigne nul part, le nombre de référence devrait être retombé à 1 et donc ici sera décrémenté à 0.

Si par contre, maMethode() fait des opérations d'assignation de ptr et conserve une copie, le comptage ne tombera pas à 0 tant que l'objet restera utilisé (assigné) ailleurs.

Regardons un exemple de maMethode() :

void maMethode(QSharedPointer<MyObject> ptr)
{  
    ptr->setMembre2(maValeur);

Au début du bloc, ici le comptage de référence est à 2 et sera décrémenté à la sortie de la méthode. On peut modifier les membres de ptr, et dans ce cas pas de changement du comptage de référence.

    this->monPtr = ptr;
}

Au contraire, on peut également l'assigner à un autre objet. Dans ce cas le comptage de référence de cet objet passera à 3. A la sortie de la méthode il sera décrémenté et passera alors à 2. L'objet ne sera pas supprimé tant qu'on ne fera pas un this->monPtr.clear() ou que this ne sera pas détruit.

Si on veut garder une référence d'un pointeur mais qu'on ne souhaite pas que celle-ci incrémente le nombre de référence du QSharedPointer, il est possible de créer un pointeur faible. Ce pointeur passe par l'objet QWeakPointer. Pour obtenir ce type de pointeur, il suffit de faire :

QWeakPointer<MyObject> ptrW = ptr->toWeakRef ();

ptrW n'incrémente donc pas le comptage de référence, cela signifie donc que le pointeur peut être détruit même si un objet QWeakPointer existe. Il sera alors possible de faire un ptrW.isNull() pour savoir si le pointeur est toujours valide. Si l'utilisateur a également besoin d'avoir accès à un membre de l'objet, il pourra le transformer en QSharedPointer avant de l'utiliser sauf si le pointeur est null.

QSharedPointer<MyObject> ptr2 = ptrW->toStrongRef ();
if (ptr) 
{
    ptr->maMethodePtr();
}

Il faut tester que ptr2 est encore valide, car tant que la transformation du pointeur faible vers le QSharedPointer n'a pas encore été fait, il est possible que le nombre de référence vers l'objet soit tombé à 0 et qu'il ait été supprimé.

Comment utiliser this

Un des points peu pratique de l'utilisation de QSharedPointer est que le comptage de référence ne fonctionne pas si plusieurs QSharedPointer pointent vers le même objet mais ont tous été créés à partir du pointeur C. Prenons par exemple, le cas suivant :

MyObject * ptr = new MyObject();

QSharedPointer<MyObject> ptr1 = QSharedPointer(ptr);
QSharedPointer<MyObject> ptr2 = QSharedPointer(ptr);

Le problème d'écrire ces lignes ainsi, et que pour ptr1 comme pour ptr2, l'objet n'est référencé qu'une fois. Ainsi le premier qui tombera à 0 détruira l'objet, alors que l'autre pourrait encore l'utiliser. Il faut donc écrire les choses comme suite :

Deux QSharedPointer créé vers la même adresse

QSharedPointer<MyObject> ptr1 = QSharedPointer(new MyObject());
QSharedPointer<MyObject> ptr2 = ptr1;

Ainsi ptr1 et ptr2 ont bien chacun connaissance de l'existence de l'autre. Cela contraint donc à remplacer toutes les déclarations du type MyObject* par QSharedPointer<MyObject>. Ceci est donc à faire dans les paramètres des méthodes, dans les membres, dans la déclaration des variables locales, ... . On ne peut donc plus utiliser le pointeur C MyObject* directement, mais seulement au travers de QSharedPointer ou de QWeakPointer.

Cela commence à poser problème lors de l'utilisation de this dans un objet. Imaginons une méthode d'un objet mettant à jour des membres fils avec en paramètre le père. Nous aurions alors tendance à écrire ceci :

void Object2::setParent(QSharedPointer<MyObject> parent)
{
    ...
}

....

void MyObject::setMember(Object2 * obj)
{
    _membre = obj;
    if (obj)
    {
        obj->setParent(QSharedPointer<MyObject>(this));
    }
}

Ceci ne marchera pas car on créerait un nouvel objet QSharedPointer commençant son comptage de référence à 1, alors que nous en avons déjà au moins un autre pointant vers notre instance5. MyObject pourrait alors être détruit alors qu'il est encore utilisé par Object2.

Pour éviter cela, il faut alors passer par un pointeur intelligent this. Pour cela nous allons utiliser deux choses :

  • Un membre nommé _this de type pointeur intelligent QWeakPointer, contenant une référence à l'objet lui même. (Nous n'utilisons pas un QSharedPointer, pour éviter une référence circulaire, voir le paragraphe suivant).
  • Une méthode statique utilisée pour la création (nous n'allons plus utiliser le constructeur, car à ce moment, il n'existe pas encore de QSharedPointer pointant vers notre objet).

Voici un exemple de comment écrire le constructeur maison :

class MyObject
{
public:
    static QSharedPointer<MyObject> create(QString parametre)
    {
        QSharedPointer<MyObject> ptr(new MyObject(parametre);
        ptr->_this = ptr.toWeakRef();
        return ptr;
    }
private:
    MyObject(QString parametre)
    {
        ...
    }

    QWeakPointer<MyObject> _this;
};

Le constructeur devient alors privé (ou protégé si on a besoin de la notion d'héritage) afin d'obliger l'utilisateur de la classe à utiliser notre méthode de création. Dans notre nouvelle méthode de création create, qui est une méthode statique, nous allons créer le pointeur et initialiser le QWeakPointer de notre objet avec le pointeur intelligent que nous venons de créer. Nous retournons un QSharedPointer. La méthode create devient alors notre nouveau constructeur, mais créant des instances d'objets de type QSharedPointer<MyObject> et non plus des instances d'objet MyObject*.

Notre méthode setMember() peut alors être ré-écrite :

void MyObject::setMember(Object2 * obj)
{
    _membre = obj;
    if (obj)
    {
        obj->setParent(_this.toStrongRef());
    }
}

Comment éviter les références circulaires

Le principe d'une référence circulaire est qu'un objet A référence l'objet B et l'objet B référence l'objet A.

Voici par exemple, un cas de référence circulaire :

class A
{
public:
    static create()
    {
        QSharedPointer<A> ptr(new A());
        return ptr;
    }
    ~A()
    {
    }
private:
    A()
    {
        b = B::create();
        b->setA(_this);
    }
    QSharedPointer<B> b;
    QWeakPointer<A> _this;
};

class B
{
public:
    static QSharedPointer<B> create() 
    {
        QSharedPointer<B> ptr(new B());
        return ptr;
    }
    void setA(QSharedPointer<A> b);
    QSharedPointer<A> getA();
private:
    QSharedPointer<A>  a;
};

Cela peut-être aussi le cas, si par exemple une instance d'objet C référence des instances d'objets fils C, qui possèdent eux-même un pointeur vers l'objet C père.

class C
{
public:
    static QSharedPointer<C> create()
    {
        QSharedPointer<C> ptr(new C());
        _this = ptr.toWeakPtr();
        return ptr;
    }

    ~C()
    {
    }

    void addChild(QSharedPointer<C> c)
    {
        _childs.append(c);
        c->setParent(_this);
    }

    void setParent(QSharedPointer<C> c);
    QSharedPointer<C> getParent();
private:
    C()
    {
    }

    QWeakPointer<C> _this;
    QSharedPointer<C> _parent;
    QList< QSharedPointer<C> > _childs;
};

Dans ces cas là, on a :- L'objet A possède la référence vers l'objet B- L'objet B possède une référence vers l'objet A.- Même principe avec l'objet C

Reference circulaire de QSharedPointer

Dans ce cas, il restera alors toujours une référence vers A, et une vers B, même si plus aucune variable ne référence ces objets. Cette référence circulaire fait que l'objet ne sera jamais détruit même si on n'a plus besoin de l'objet.

Si on décide que l'objet A sera l'objet maitre (donc que sa destruction engendrera la destruction de l'objet B), on peut alors écrire les choses ainsi pour l'objet B :

class B
{
public:
    static QSharedPointer<B> create() 
    {
        QSharedPointer<B> ptr(new B());
        return ptr;
    }
    void setA(QWeakPointer<A> b);
    QWeakPointer<A> getA();
private:
    QWeakPointer<A>  a;
};

Dans ce cas, avec l'utilisation d'un QWeakPointer, lorsque qu'il n'existera plus de référence vers l'objet A, le pointeur faible a sera mis à jour comme ne contenant plus de référence6. L'instance de l'objet A sera réellement détruite. Il n'y aura alors plus aucune référence vers l'objet B qui sera alors également détruit7.

Utilisation dans les applications multi-thread.

L'utilisation de QSharedPointer simplifie l'écriture des applications multi-thread (les objets QSharedPointer et QWeakPointer sont thread-safe).

Dans ces applications il n'y a alors plus besoin de se soucier si l'objet est en cours d'utilisation ailleurs dans l'application avant de le supprimer8. Lorsqu'un pointeur ne devient plus utilisé dans un thread donné, il ne sera détruit que s'il n'y a pas d'autres références dans d'autres threads de l'application9.

Avec l'utilisation de QWeakPointer, un thread pourra tester l'existence du pointeur avant d'effectuer une opération et pourra aviser le cas échéant sans faire planter toute l'application10.

Utilisation d'un pool

Si la création et la destruction d'un objet est coûteux, il est envisageable de diminuer le coût de destruction et de création d'un thread en utilisant un Pool d'objet. Dans ce cas l'objet QQueue pourra être utilisé pour représenter notre Pool.

Lors de la demande de création, en utilisant notre méthode create ci-dessus, on prend alors une valeur du pool (si disponible) et on la retourne sous forme d'un QSharedPointer.

QSharedPointer<MyObject> MyObject::create()
{
    MyObject * c_ptr;
    if (_queue.size())
    {
    c_ptr = _queue.dequeue();
    }
    else
    { 
    c_ptr = new MyObject();
    }
    QSharedPointer<MyObject> ptr(c_ptr, ReturnToPool);
    ptr->_this = ptr.toWeakRef();
    return ptr;
}

Dans l'exemple ci-dessus11, on demande à la queue, qui doit être une variable globale ou statique, un élément, et si ce n'est pas possible, on crée un nouvel objet de type MyObject (dont on suppose la création coûteuse).

Lors de la création du QSharedPointer on utilise alors le constructeur QSharedPointer ( T * ptr, Deleter deleter ) sur lequel on définit une méthode Deleter nommée ReturnToPool dont le but est de remettre les objets en pool.

static void ReturnToPool(MyObject *obj)
{
    if (_queue.size() < MAX_SIZE_QUEUE)
    {
        _queue.enqueue(obj);
    }
    else
    {
        delete obj;
    }
}

Dans ce cas de retour au pool, si le pool est rempli, on détruit l'objet (pour éviter de consommer trop de mémoire), sinon on l'ajoute au pool. Dans ce cas, le pool est agrandi au fur et à mesure des besoins, jusqu'à une taille limite.

Bien sûr il faut que la performance de l'utilisation d'un pool soit plus intéressante que celle de la création de l'objet et de son initialisation.

Attention : Ce point ne fonctionne, par contre, pas si l'objet (MyObject) est un descendant de QObject. En effet QObject garde une référence du QSharedPointer en mémoire et lors de la réutilisation du QObject une erreur indique que l'objet n'a pas été détruit et est déjà utilisé par un QSharedPointer. On n'a pas le problème avec std::tr1::shared_ptr

Benchmark

Le but du benchmark est de se faire une idée sur les performances d'une application utilisant des QSharedPointer à la place des pointeurs normaux. Attention, ce bench ne prend pas en compte le besoin potentiel de Mutex, de comptage de référence manuel, ... dans les applications multi-thread qui pourrait être nécessaire pour ne pas supprimer le pointeur si besoin.

Dans ce test nous allons tester également (en comparaison), le pointeur intelligent du C++0x12.

Nous allons donc tester les opérations courantes de création, destruction, modification, affectation.

Code source

Le code source est disponible, attaché au billet. Dans la suite du billet, seuls les morceaux intéressants du benchmark seront décris. Le benchmark utilise QTest. Nous avons créé un objet bidon ObjetTest qui dans le constructeur allouera un pointeur et remplira une liste, et le destructeur supprime ce pointeur (et forcément la liste).

Pour que chaque test soit indépendant, le jeu de test sera initialisé avant le début de chaque QBENCHMARK et détruit à la fin du bloc. Nous aurons quatre méthodes :

  • Allocation
  • Modification
  • Affectation
  • Nettoyage

Pour chaque test nous allons faire le test avec

  • un pointeur C standard
  • le pointeur QSharedPointer de Qt
  • le pointeur std::tr1::shared_ptr de C++0x

Pour le test d'allocation et le test de nettoyage, nous allons également utiliser l'optimisation possible, vu ci-dessus, d'un Pool d'objet. Nous allons faire le test avec :

  • le pointeur QSharedPointer de Qt
  • le pointeur std::tr1::shared_ptr de C++0x

Le jeu de test

Test de l'allocation

La création du pointeur en utilisant QSharedPointer instancie le pointeur ainsi que le QSharedPointer. Le temps d'exécution est donc potentiellement deux fois plus long (voir le benchmark à la fin de ce billet).

Pour la création du pool, nous allons utiliser une méthode qui créera le pointeur s'il n'est pas dans le pool, et sinon prendra le pointeur du pool. Dans notre cas de test, il y aura toujours une valeur dans le pool, que l'on aura rempli au préalable.

La méthode createFromPool() et createFromBoostPool() est sensiblement identique :

QSharedPointer<ObjetTest> createFromPool()
{
    ObjetTest * c_ptr;
    if (_queue.size())
    {
        c_ptr = _queue.dequeue();
    }
    else
    {
        c_ptr = new ObjetTest();
    }

    QSharedPointer<ObjetTest> ptr(c_ptr, returnToPool);
    return ptr;
}
Méthode Code
C Pointer ObjetTest* ptr = new ObjetTest();
Qt Smart Pointer QSharedPointer<ObjetTest> ptr(new ObjetTest());
Qt Smart Pointer as Pool QSharedPointer<ObjetTest> ptr = createFromPool ();
C++0x Smart Pointer std::tr1::shared_ptr<ObjetTest> ptr(new ObjetTest());
C++0x Smart Pointer as Pool std::tr1::shared_ptr<ObjetTest> ptr = createFromBoostPool ();
Test de Modification d'une donnée

Pour la modification d'une donnée, on génère un nombre aléatoire que l'on va stocker (toujours le même pour chaque test, cela n'a pas d'importance). La génération du nombre aléatoire se fait en dehors du bloc, pour éviter de polluer le test avec le calcul d'un nombre aléatoire. Ici il n'y a pas création d'affectation du pointeur, juste une affectation d'une valeur dans le contenu du pointeur. La syntaxe pour le C, et pour le pointeur intelligent est identique.

Méthode Code
C Pointer / Qt Smart Pointer / C++0x Smart Pointer obj->value = random_number;
Test d'affectation

Pour l'affectation nous allons créer une nouvelle variable qui pointera sur le même pointeur, et sur lequel on fera une modification. La création d'un pointeur peut arriver par exemple lors du passage du pointeur à une fonction, ou lors de la déclaration d'une variable devant contenir la même valeur. Cette déclaration supplémentaire a peu d'impact pour un pointeur C mais pour un pointeur intelligent oblige la création d'un objet, et l'incrément d'un nombre d'instance (qu'on décrémente ici dans la même boucle).

Méthode Code
C Pointer ObjetTest * obj2 = obj;
obj2->value = random_number;
Qt Smart Pointer QSharedPointer<ObjetTest> obj2 = obj;
obj2->value = random_number;
C++0x Smart Pointer std::tr1::shared_ptr<ObjetTest> obj2 = obj;
obj2->value = random_number;
Test de destruction

Pour ce test, nous allons initialiser une liste de pointeur, et pour le benchmark, nous allons supprimer un à un chaque élément de la liste.La destruction du pointeur en C se fait par un delete. Pour le pointeur ''intelligent', il n'y a pas de destruction explicite. Nous allons juste supprimer le pointeur de la liste, le pointeur sera alors automatiquement détruit car il n'y aura plus de référence vers ce pointeur.

Pour le cas de test utilisant la notion du Pool, on aura créé le pointeur avec le delete returnToPool() :

void returnToPool(ObjetTest *obj)
{
    _queue.enqueue(obj);
}

Cette méthode ne fait pas de réelle destruction, mais juste un ajout de l'objet au pool.

Méthode Code
C Pointer delete c_ptr_list.at(0);
c_ptr_list.removeFirst ();
Qt Smart Pointer smart_ptr_list.removeFirst ();
Qt Smart Pointer as Pool
C++0x Smart Pointer
C++0x Smart Pointer as Pool
Résultat du test

Le test a été fait en utilisant la version 4.6.3 de Qt. Test effectué pour 5 000 000 itérations.

Pointeur C Pointeur Qt Pointeur C++0x Pool en utilisant QSharedPointer Pool en utilisant std::tr1::shared_ptr
Allocation 0.0004275 msec 0.0007692 msec 0.0006604 msec 0.0002590 msec 0.0002286 msec
Modification 0.000010 msec 0.000012 msec 0.000012 msec
Affectation 0.000010 msec 0.0000386 msec 0.0000230 msec
Destruction 0.000190 msec 0.0003161 msec 0.0003359 msec 0.0004003 msec 0.0003601 msec

Conclusion que l'on peut en tirer, le pointeur C est ce qu'il y a de plus rapide à partir du moment où on fait de l'allocation de l'affectation ou de la destruction. Par contre il n'apporte pas la souplesse qu'apporte les pointeurs intelligents entre autre pour les applications multi-threadé.

On remarque que le pointeur C++0x est plus rapide pour la création, mais apparemment plus lent en destruction. Il est également possible avec le pool de gagner en performance (surtout en création). Par contre le coût de destruction de l'objet n'est pas encore assez fort pour y gagner en utilisant le pool.

Ensuite il est important de se faire son propre jugement selon ses besoins. Si besoin le source est attaché, vous pouvez faire vos propres tests.

Source du test

Vous pouvez trouver les sources du test au lien suivant.


  1. en anglais : smart-pointer 

  2. COW = Copy On Write 

  3. Ce pointeur est l'équivalent du pointeur intelligent [boost::shared_ptr][] du projet Boost. Boost est une librairie qui ajoute beaucoup de facilité pour les programmes en C++, comme par exemple les smart-pointer dont certains seront inclus dans C++0x, ou de la boucle std::for_each

  4. Si à un moment donné il faut utiliser le pointeur C pour une raison quelconque, on peut utiliser ptr.data() mais il faut s'assurer que le pointeur ne sera pas détruit en déclarant un QSharedPointer dans le même bloc utilisant le pointeur C. Le QSharedPointer ne devra être détruit qu'après utilisation du pointeur C. Ceci peut être fait dans certain cas pour des raisons de performance. 

  5. Sinon nous ne serions plus là pour lancer la méthode 

  6. Le QWeakPointeur ne gardant pas d'instance d'objet, car il n'incrémente pas le compteur de référence 

  7. s'il n'existe pas de référence vers l'objet B ailleurs dans l'application 

  8. Attention quand même, QSharedPointer protège le pointeur mais pas le contenu 

  9. d'autres threads pouvant inclure le thread principal 

  10. Si l'objet est supprimé, QWeakPointer, sera alors remis à null 

  11. Dans le code en question, il faudrait ajouter la notion de mutex autour de la gestion de la queue, en cas de création parallèle. 

  12. Le pointeur intelligent shared_ptr de C++0x à pour origine le pointeur Boost 

Vus : 54
Publié par Ulrich Van Den Hekke : 73