Performances des exceptions C++

Vous utilisez les exceptions en C++ et vous vous demandez quel est l'impact de celles-ci sur les performances. Cet article détaille l'influence des exceptions sur votre code.

L'article est une traduction autorisée de C++ Exception Handling and Performance par Vlad Lazarenko.

27 commentaires Donner une note à l'article (5)

Article lu   fois.

Les deux auteur et traducteur

Site personnel

Traducteur : Site personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Article original

Cet article est la traduction du billet C++ Exception Handling and Performance par Vlad Lazarenko.

II. Introduction

Les exceptions fournissent un moyen pour signaler des conditions spéciales qui modifient le flux habituel de l'exécution du programme. La gestion des exceptions peut se référer soit à l'implémentation dans un langage de programmation, soit au mécanisme matériel de l'ordinateur ou les deux à la fois.

De nombreuses personnes sont concernées par l'impact sur les performances des exceptions en C++. Par exemple, un de mes collègues croit que l'utilisation des exceptions doit être évitée à tout prix pour améliorer la vitesse d'exécution d'une application. Est-ce vrai ou pas ? Allons voir !

III. Utiliser les exceptions

Il est important de comprendre que les exceptions ne sont pas prévues pour le déroulement normal du programme, mais conçues pour les situations inattendues. Ces situations ne devraient pas arriver du tout. Toutefois, cela peut arriver, donc nous devons vérifier les erreurs. Il y a deux approches fondamentales : renvoyer un code d'erreur à partir de la fonction ou bien utiliser une exception. Par exemple, prenons une fonction qui implémente une division :

 
Sélectionnez
int divide (int x, int y) 
{ 
     return x / y; 
}

Nous devons nous assurer que le diviseur est différent de zéro, car il n'est pas possible de diviser par zéro. Il y a deux façons de le faire. Une vérification des erreurs à la C ressemblera à ceci :

 
Sélectionnez
int divide (int x, int y, int & result) 
{ 
    if (y == 0) 
        return -1; 
    result = x / y; 
    return 0; 
}

La vérification des erreurs en utilisant les exceptions C++ ressemblera à ceci :

 
Sélectionnez
int divide (int x, int y) 
{ 
    if (y == 0) 
        throw std::logic_error (“Division by zero”); 
    return x / y; 
}

L'utilisation de ces deux fonctions sera différente. Pour la fonction en C, nous allons toujours vérifier la valeur de retour afin d'être sûr de la réussite de l'opération :

 
Sélectionnez
void foo (int x, int y) 
{ 
    int result; 
    if (divide (x, y, result) == 0) 
    { 
        // Division was successful. Do something with “result”. 
    } 
    else 
    { 
        // Une erreur s'est produite ! 
    } 
}

En C++ :

 
Sélectionnez
void foo (int x, int y) 
{ 
    try 
    { 
        int result = divide (x, y); 
        // Division was successful. Do something with “result”. 
    } 
    catch (const std::logic_error &) 
    { 
        // Une erreur s'est produite ! 
    } 
}

L'utilisation des exceptions est d'autant plus pratique que le déroulement du programme devient compliqué. Que se passe-t-il si on appelle deux fois la fonction « divide » ? Dans ce cas, dans un code similaire à ce que l'on écrirait en C, nous devons effectuer deux vérifications d'erreur :

 
Sélectionnez
void foo (int x, int y) 
{ 
    int result; 
    if (divide (x, y, result) == 0) 
    { 
        // La division a réussi. Faites quelque chose avec “result”. 
    } 
    else 
    { 
        // Une erreur s'est produite ! 
    } 

    if (divide (y, x, result) == 0) 
    { 
        // La division a réussi. Faites quelque chose avec “result”. 
    } 
    else 
    { 
        // Une erreur s'est produite ! 
    } 
}

Mais en C++, seul un bloc try-catch est nécessaire :

 
Sélectionnez
void foo (int x, int y) 
{ 
    try 
    { 
        int result = divide (x, y); 
        // La division a réussi. Faites quelque chose avec “result”. 
        result = divide (x, y); 
        // La division a réussi. Faites quelque chose avec “result”, encore. 
    } 
    catch (const std::logic_error &) 
    { 
        // Une erreur s'est produite ! 
    } 
}

Maintenant, imaginons que nous devions appeler « divide » des dizaines ou centaines de fois. Et pour rendre cela encore plus complexe, imaginons que nous ayons plusieurs fonctions imbriquées et que pour chacune d'elles, nous vérifiions les erreurs. Il semble que l'utilisation des exceptions soit la voie idéale. Eh bien, c'est pour cela que les exceptions existent : pour rendre les choses plus faciles.

IV. Facilité contre vitesse

Il y a plein de choses qui vont rendre nos vies de programmeurs plus faciles. Mais quelquefois nos programmes doivent être rapides. Pas seulement rapides, mais les plus rapides du monde, ou même de tout l'univers. Nous devons sacrifier les facilités de programmation pour atteindre cet objectif. C'est le cas avec les programmes de transaction à haute fréquence, par exemple. Donc, si un programmeur écrit un tel programme, il va penser que la surcouche de la fonction « divide » avec un bloc try-catch ralentira son programme, même si c'est d'une nanoseconde par rapport au code équivalent d'erreurs en C. Il choisira la voie la plus compliquée et vérifiera le code d'erreur pour chaque appel de la fonction « divide ». Bien sûr, il prendra plus de temps pour atteindre son but, mais est-ce que ce sera la bonne décision ?

V. Sous le capot

Pour répondre à la question ci-dessus, nous devons plonger dans les détails d'implémentation et trouver comment les exceptions sont implémentées. À partir de ce point, il y a une immense différence entre le C++ et les langages de plus haut niveau tel que le Java, C#, Python ou d'autres. En C++, il y a deux approches pour la gestion d'exceptions durant l'exécution : l'approche « setjmp/longjmp » (que l'on appellera par la suite « à saut ») et la gestion d'exceptions « sans coût ».

La méthode à saut conserve le contexte lorsque l'on entre dans un bloc permettant de capturer les exceptions. Lorsque l'exception est levée, le contexte peut être immédiatement restauré sans avoir besoin d'utiliser la fenêtre de la pile d'appels. Cette méthode fournit une propagation très rapide de l'exception, mais ajoute un surcoût significatif lors de l'utilisation du gestionnaire d'exception, même lorsqu'aucune exception n'est lancée.

L'approche sans coût génère des tables statiques pour décrire les portées des exceptions. Aucun code dynamique n'est requis lors de l'entrée dans la fenêtre contenant le gestionnaire d'exception. Lorsqu'une exception est lancée, les tables sont utilisées pour contrôler un retour sur la pile d'invocation du programme afin de trouver le gestionnaire d'exception requis. Cette approche est considérablement plus lente pour la propagation des exceptions, mais n'a aucun surcoût lorsqu'aucune exception n'est lancée.

Il y a toujours des avantages et désavantages et nous avons un choix à faire. Si on prend en compte que les exceptions ne font pas partie du déroulement normal d'un programme, nous devons optimiser les cas les plus communs, soit lorsque les exceptions ne sont pas lancées et sacrifier les performances lors de leur gestion. Ainsi, de nombreux compilateurs C++ de qualité industrielle ont choisi d'utiliser l'approche sans coût.

VI. Plongeons dans l'assembleur

Retournons à notre fonction « divide » et comparons l'utilisation des codes d'erreur avec celui des exceptions sans coût. Voici le code de la première approche :

 
Sélectionnez
int divide (int x, int y, int & result) 
{ 
    if (y == 0) 
        return -1; 
    result = x / y; 
    return 0; 
} 

int foo (int & result) 
{ 
    volatile int x = 4, y = 28; 
    int d1, d2; 
    if (divide (x, y, d1) == -1) 
        return -1; 
    if (divide (y, x, d2) == -1) 
        return -1; 
    result = d1 + d2; 
    return 0; 
} 

int main () 
{ 
    int result; 
    foo (result); 
    return result; 
}

Et le code utilisant les exceptions :

 
Sélectionnez
int divide (int x, int y) 
{ 
    if (y == 0) 
        throw std::logic_error ("Division by zero"); 
    return x / y; 
} 

int foo () 
{ 
    volatile int x = 4, y = 28; 
    return divide (x, y) + divide (y, x); 
} 

int main () 
{ 
    try 
    { 
        return foo (); 
    } 
    catch (const std::exception &) 
    { 
        return -1; 
    } 
}

Et voici ce qui se passe vraiment dans l'exemple utilisant les codes de retour des fonctions (j'ai supprimé le code qui n'est pas exécuté pour des raisons de simplicité) :

 
Sélectionnez
__Z6divideiiRi: 
    pushq    %rbp 
    movq    %rsp, %rbp 
    movl    %edi, -4(%rbp) 
    movl    %esi, -8(%rbp) 
    movq    %rdx, -16(%rbp) 
    cmpl    $0, -8(%rbp) 
    jne    L2 
;; Bloc de code lorsque la valeur de retour est -1 sauté. Nous allons toujours dans L2
L2: 
    movl    -4(%rbp), %eax 
    movl    %eax, %edx 
    sarl    $31, %edx 
    idivl    -8(%rbp) 
    movl    %eax, %edx 
    movq    -16(%rbp), %rax 
    movl    %edx, (%rax) 
    movl    $0, %eax 
    popq    %rbp 
    ret 
 
__Z3fooRi: 
    pushq    %rbp 
    movq    %rsp, %rbp 
    subq    $24, %rsp 
    movq    %rdi, -24(%rbp) 
    movl    $4, -4(%rbp) 
    movl    $28, -8(%rbp) 
    movl    -8(%rbp), %ecx 
    movl    -4(%rbp), %eax 
    leaq    -12(%rbp), %rdx 
    movl    %ecx, %esi 
    movl    %eax, %edi 
    call    __Z6divideiiRi 
    cmpl    $-1, %eax 
    sete    %al 
    testb    %al, %al 
    je    L5 
;; Bloc de code lorsque la valeur de retour est -1 sauté. Nous allons toujours dans L5
L5: 
    movl    -4(%rbp), %ecx 
    movl    -8(%rbp), %eax 
    leaq    -16(%rbp), %rdx 
    movl    %ecx, %esi 
    movl    %eax, %edi 
    call    __Z6divideiiRi 
    cmpl    $-1, %eax 
    sete    %al 
    testb    %al, %al 
    je    L7 
;; Bloc de code lorsque la valeur de retour est -1 sauté. Nous allons toujours dans L7
L7: 
    movl    -12(%rbp), %edx 
    movl    -16(%rbp), %eax 
    addl    %eax, %edx 
    movq    -24(%rbp), %rax 
    movl    %edx, (%rax) 
    movl    $0, %eax 
    leave 
    ret 
 
_main: 
    pushq    %rbp 
    movq    %rsp, %rbp 
    subq    $16, %rsp 
    leaq    -4(%rbp), %rax 
    movq    %rax, %rdi 
    call    __Z3fooRi 
    movl    -4(%rbp), %eax 
    leave 
    ret

Cela fait beaucoup de vérifications d'erreurs alors que l'on présume que cela n'arrive pas souvent ! Si nous devons encore appeler la fonction « divide », nous allons devoir ajouter plus de vérifications pour la valeur de retour « -1 » et le listing assembleur deviendra plus long à cause de ces vérifications. Voyons l'apparence du code utilisant les exceptions :

 
Sélectionnez
__Z6divideii: 
    pushq    %rbp 
    movq    %rsp, %rbp 
    pushq    %r12 
    pushq    %rbx 
    subq    $32, %rsp 
    movl    %edi, -36(%rbp) 
    movl    %esi, -40(%rbp) 
    cmpl    $0, -40(%rbp) 
    jne    L2 
;; Bloc de code allouant et lançant l'exception. Nous allons toujours dans L2. 
L2: 
    movl    -36(%rbp), %eax 
    movl    %eax, %edx 
    sarl    $31, %edx 
    idivl    -40(%rbp) 
    addq    $32, %rsp 
    popq    %rbx 
    popq    %r12 
    popq    %rbp 
    ret 
 
__Z3foov: 
    pushq    %rbp 
    movq    %rsp, %rbp 
    pushq    %rbx 
    subq    $24, %rsp 
    movl    $4, -20(%rbp) 
    movl    $28, -24(%rbp) 
    movl    -24(%rbp), %edx 
    movl    -20(%rbp), %eax 
    movl    %edx, %esi 
    movl    %eax, %edi 
    call    __Z6divideii 
    movl    %eax, %ebx 
    movl    -20(%rbp), %edx 
    movl    -24(%rbp), %eax 
    movl    %edx, %esi 
    movl    %eax, %edi 
    call    __Z6divideii 
    addl    %ebx, %eax 
    addq    $24, %rsp 
    popq    %rbx 
    popq    %rbp 
    ret 
 
_main: 
    pushq    %rbp 
    movq    %rsp, %rbp 
    pushq    %rbx 
    subq    $24, %rsp 
    call    __Z3foov 
    movl    %eax, %ebx 
    movl    %ebx, %eax 
    addq    $24, %rsp 
    popq    %rbx 
    popq    %rbp 
    ret 
;; Le code de dépliage de la pile est appelé à partir de la table d'exception générée statiquement. Ce code est retiré et n'est jamais atteint dans notre exemple.

Voilà qui est beaucoup mieux ! Nous avons réussi à éviter deux vérifications de code de retour inutiles. Sinon, le reste du code qui va être exécuté est exactement le même.

VII. Exceptions à saut

Disons maintenant que nous avons un compilateur qui utilise l'approche « setjmp/longjmp » pour implémenter les exceptions. Même avec cette approche, la gestion des exceptions peut être plus rapide que la vérification des codes d'erreur. Prenons l'exemple suivant :

 
Sélectionnez
while (doContinue) { 
    try { 
        doSomeWork (); 
    } 
    catch (...) { /* do something about it! */ } 
}

… qui sera évidemment plus lent que :

 
Sélectionnez
while (doContinue) { 
    if (doSomeWork () != 0) { 
        /* do something about it! */ 
    } 
}

… mais qu'en est-il de :

 
Sélectionnez
while (doContinue) { 
    try { 
        do { 
            doSomeWork (); 
        } while (doContinue); 
        break; 
    } catch (...) { /* do something about it! */ } 
}

Dans l'exemple ci-dessus, nous définissons le point de remontée une seule fois et évitons de vérifier la valeur de retour de la fonction plusieurs fois. Bien sûr, ceci est le scénario, en supposant que les situations exceptionnelles n'arrivent pratiquement jamais. Mais c'est une supposition raisonnable. Dans le cas contraire, cette situation devrait être traitée comme un déroulement normal pour le programme et donc gérée autrement, sans exception, rendant le programme identique pour les programmeurs C et C++. Notez que l'optimisation ci-dessus n'a aucun sens avec le mécanisme sans coût.

VIII. Connaître quel mécanisme d'exceptions est utilisé

Malheureusement, le seul moyen pour trouver quel est le mécanisme utilisé pour la gestion des exceptions est de compiler un petit programme les utilisant et d'analyser l'assembleur généré.

IX. Autres considérations de performances

L'utilisation des exceptions rendra le binaire plus gros, quel que soit le mécanisme utilisé pour la gestion des exceptions. Donc si la taille du programme est plus importante que la vitesse d'exécution, les exceptions ne devraient pas être utilisées. Dans le cas où vous souhaitez avoir le maximum de performance avec un impact minimal sur la taille, vous devez rajouter des phases de tests afin de savoir quelle est la meilleure combinaison de code utilisant les retours de fonctions ou celui utilisant les exceptions afin d'atteindre vos objectifs(1).

X. Conclusion

Avoir de bonnes performances peut signifier de nombreuses choses différentes : de la vitesse d'exécution à la taille du binaire, en passant par le temps de développement.

Dans cet article, nous avons discuté de la vitesse d'exécution d'une application utilisant un contrôle des erreurs avec les retours de fonctions, à la C, relativement à une utilisant les exceptions, ainsi que du temps de développement nécessaire pour écrire le même programme avec ces deux approches.

Si la taille du binaire du programme compilé est le facteur le plus important, alors les exceptions ne devraient pas être utilisées.

Si la vitesse d'exécution ou la facilité de développement (ou les deux !) est le facteur le plus important alors un programmeur qui décide d'abandonner la facilité d'utilisation des exceptions pour la gestion des erreurs avec des codes de retour des fonctions, passera non seulement plus de temps à écrire le code, rendant le code plus compliqué, à se heurter au problème de décrire l'erreur (surtout dans un environnement multithread et en particulier lorsque le stockage local du thread ne peut pas être utilisé à cause d'une gestion des exceptions dans un autre thread lors de la programmation asynchrone), mais aussi rendra le programme plus lent.

XI. Références

XII. Divers

Merci à Flob90, Luc Hermitte et gbdivers pour son support à la traduction de cet article. Merci à ClaudeLELOUP pour sa relecture orthographique !

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   


Note de traduction : toutefois, il arrive toujours un moment, où, lorsque le nombre de if ajoutés est énorme, la taille dépassera celle du binaire utilisant les exceptions.

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2013 Vlad Lazarenko. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.