Comment fonctionnent les exceptions (dans les coulisses) en C ++

109

Je n'arrête pas de voir des gens dire que les exceptions sont lentes, mais je ne vois aucune preuve. Donc, au lieu de demander si elles le sont, je vais demander comment fonctionnent les exceptions dans les coulisses, afin que je puisse décider quand les utiliser et si elles sont lentes.

D'après ce que je sais, les exceptions sont les mêmes que pour faire un retour plusieurs fois, sauf qu'il vérifie également après chaque retour s'il doit en faire un autre ou s'arrêter. Comment vérifie-t-il quand arrêter de revenir? Je suppose qu'il y a une deuxième pile qui contient le type de l'exception et un emplacement de pile, elle retourne ensuite jusqu'à ce qu'elle y arrive. Je suppose également que la seule fois que ce deuxième tapis est touché est sur un lancer et à chaque essai / prise. AFAICT implémenter un comportement similaire avec des codes de retour prendrait le même temps. Mais ce n'est qu'une supposition, alors je veux savoir ce qui se passe réellement.

Comment fonctionnent vraiment les exceptions?

programmeur
la source

Réponses:

105

Au lieu de deviner, j'ai décidé de regarder le code généré avec un petit morceau de code C ++ et une installation Linux quelque peu ancienne.

class MyException
{
public:
    MyException() { }
    ~MyException() { }
};

void my_throwing_function(bool throwit)
{
    if (throwit)
        throw MyException();
}

void another_function();
void log(unsigned count);

void my_catching_function()
{
    log(0);
    try
    {
        log(1);
        another_function();
        log(2);
    }
    catch (const MyException& e)
    {
        log(3);
    }
    log(4);
}

Je l'ai compilé avec g++ -m32 -W -Wall -O3 -save-temps -cet j'ai regardé le fichier d'assemblage généré.

    .file   "foo.cpp"
    .section    .text._ZN11MyExceptionD1Ev,"axG",@progbits,_ZN11MyExceptionD1Ev,comdat
    .align 2
    .p2align 4,,15
    .weak   _ZN11MyExceptionD1Ev
    .type   _ZN11MyExceptionD1Ev, @function
_ZN11MyExceptionD1Ev:
.LFB7:
    pushl   %ebp
.LCFI0:
    movl    %esp, %ebp
.LCFI1:
    popl    %ebp
    ret
.LFE7:
    .size   _ZN11MyExceptionD1Ev, .-_ZN11MyExceptionD1Ev

_ZN11MyExceptionD1Ev est MyException::~MyException() , donc le compilateur a décidé qu'il avait besoin d'une copie non en ligne du destructeur.

.globl __gxx_personality_v0
.globl _Unwind_Resume
    .text
    .align 2
    .p2align 4,,15
.globl _Z20my_catching_functionv
    .type   _Z20my_catching_functionv, @function
_Z20my_catching_functionv:
.LFB9:
    pushl   %ebp
.LCFI2:
    movl    %esp, %ebp
.LCFI3:
    pushl   %ebx
.LCFI4:
    subl    $20, %esp
.LCFI5:
    movl    $0, (%esp)
.LEHB0:
    call    _Z3logj
.LEHE0:
    movl    $1, (%esp)
.LEHB1:
    call    _Z3logj
    call    _Z16another_functionv
    movl    $2, (%esp)
    call    _Z3logj
.LEHE1:
.L5:
    movl    $4, (%esp)
.LEHB2:
    call    _Z3logj
    addl    $20, %esp
    popl    %ebx
    popl    %ebp
    ret
.L12:
    subl    $1, %edx
    movl    %eax, %ebx
    je  .L16
.L14:
    movl    %ebx, (%esp)
    call    _Unwind_Resume
.LEHE2:
.L16:
.L6:
    movl    %eax, (%esp)
    call    __cxa_begin_catch
    movl    $3, (%esp)
.LEHB3:
    call    _Z3logj
.LEHE3:
    call    __cxa_end_catch
    .p2align 4,,3
    jmp .L5
.L11:
.L8:
    movl    %eax, %ebx
    .p2align 4,,6
    call    __cxa_end_catch
    .p2align 4,,6
    jmp .L14
.LFE9:
    .size   _Z20my_catching_functionv, .-_Z20my_catching_functionv
    .section    .gcc_except_table,"a",@progbits
    .align 4
.LLSDA9:
    .byte   0xff
    .byte   0x0
    .uleb128 .LLSDATT9-.LLSDATTD9
.LLSDATTD9:
    .byte   0x1
    .uleb128 .LLSDACSE9-.LLSDACSB9
.LLSDACSB9:
    .uleb128 .LEHB0-.LFB9
    .uleb128 .LEHE0-.LEHB0
    .uleb128 0x0
    .uleb128 0x0
    .uleb128 .LEHB1-.LFB9
    .uleb128 .LEHE1-.LEHB1
    .uleb128 .L12-.LFB9
    .uleb128 0x1
    .uleb128 .LEHB2-.LFB9
    .uleb128 .LEHE2-.LEHB2
    .uleb128 0x0
    .uleb128 0x0
    .uleb128 .LEHB3-.LFB9
    .uleb128 .LEHE3-.LEHB3
    .uleb128 .L11-.LFB9
    .uleb128 0x0
.LLSDACSE9:
    .byte   0x1
    .byte   0x0
    .align 4
    .long   _ZTI11MyException
.LLSDATT9:

Surprise! Il n'y a aucune instruction supplémentaire sur le chemin de code normal. Le compilateur a plutôt généré des blocs de code de correction hors ligne supplémentaires, référencés via une table à la fin de la fonction (qui est en fait placée dans une section distincte de l'exécutable). Tout le travail est effectué en coulisses par la bibliothèque standard, basée sur ces tables ( _ZTI11MyExceptionis typeinfo for MyException).

OK, ce n'était pas vraiment une surprise pour moi, je savais déjà comment ce compilateur le faisait. Poursuivant la sortie de l'assemblage:

    .text
    .align 2
    .p2align 4,,15
.globl _Z20my_throwing_functionb
    .type   _Z20my_throwing_functionb, @function
_Z20my_throwing_functionb:
.LFB8:
    pushl   %ebp
.LCFI6:
    movl    %esp, %ebp
.LCFI7:
    subl    $24, %esp
.LCFI8:
    cmpb    $0, 8(%ebp)
    jne .L21
    leave
    ret
.L21:
    movl    $1, (%esp)
    call    __cxa_allocate_exception
    movl    $_ZN11MyExceptionD1Ev, 8(%esp)
    movl    $_ZTI11MyException, 4(%esp)
    movl    %eax, (%esp)
    call    __cxa_throw
.LFE8:
    .size   _Z20my_throwing_functionb, .-_Z20my_throwing_functionb

Ici, nous voyons le code pour lancer une exception. Bien qu'il n'y ait pas eu de frais généraux supplémentaires simplement parce qu'une exception pouvait être levée, il y a évidemment beaucoup de frais généraux à lancer et à attraper une exception. La majeure partie est cachée à l'intérieur__cxa_throw , ce qui doit:

  • Parcourez la pile à l'aide des tables d'exceptions jusqu'à ce qu'il trouve un gestionnaire pour cette exception.
  • Déroulez la pile jusqu'à ce qu'elle atteigne ce gestionnaire.
  • Appelez en fait le gestionnaire.

Comparez cela avec le coût du simple retour d'une valeur, et vous voyez pourquoi les exceptions ne devraient être utilisées que pour des retours exceptionnels.

Pour finir, le reste du fichier d'assemblage:

    .weak   _ZTI11MyException
    .section    .rodata._ZTI11MyException,"aG",@progbits,_ZTI11MyException,comdat
    .align 4
    .type   _ZTI11MyException, @object
    .size   _ZTI11MyException, 8
_ZTI11MyException:
    .long   _ZTVN10__cxxabiv117__class_type_infoE+8
    .long   _ZTS11MyException
    .weak   _ZTS11MyException
    .section    .rodata._ZTS11MyException,"aG",@progbits,_ZTS11MyException,comdat
    .type   _ZTS11MyException, @object
    .size   _ZTS11MyException, 14
_ZTS11MyException:
    .string "11MyException"

Les données typeinfo.

    .section    .eh_frame,"a",@progbits
.Lframe1:
    .long   .LECIE1-.LSCIE1
.LSCIE1:
    .long   0x0
    .byte   0x1
    .string "zPL"
    .uleb128 0x1
    .sleb128 -4
    .byte   0x8
    .uleb128 0x6
    .byte   0x0
    .long   __gxx_personality_v0
    .byte   0x0
    .byte   0xc
    .uleb128 0x4
    .uleb128 0x4
    .byte   0x88
    .uleb128 0x1
    .align 4
.LECIE1:
.LSFDE3:
    .long   .LEFDE3-.LASFDE3
.LASFDE3:
    .long   .LASFDE3-.Lframe1
    .long   .LFB9
    .long   .LFE9-.LFB9
    .uleb128 0x4
    .long   .LLSDA9
    .byte   0x4
    .long   .LCFI2-.LFB9
    .byte   0xe
    .uleb128 0x8
    .byte   0x85
    .uleb128 0x2
    .byte   0x4
    .long   .LCFI3-.LCFI2
    .byte   0xd
    .uleb128 0x5
    .byte   0x4
    .long   .LCFI5-.LCFI3
    .byte   0x83
    .uleb128 0x3
    .align 4
.LEFDE3:
.LSFDE5:
    .long   .LEFDE5-.LASFDE5
.LASFDE5:
    .long   .LASFDE5-.Lframe1
    .long   .LFB8
    .long   .LFE8-.LFB8
    .uleb128 0x4
    .long   0x0
    .byte   0x4
    .long   .LCFI6-.LFB8
    .byte   0xe
    .uleb128 0x8
    .byte   0x85
    .uleb128 0x2
    .byte   0x4
    .long   .LCFI7-.LCFI6
    .byte   0xd
    .uleb128 0x5
    .align 4
.LEFDE5:
    .ident  "GCC: (GNU) 4.1.2 (Ubuntu 4.1.2-0ubuntu4)"
    .section    .note.GNU-stack,"",@progbits

Encore plus de tableaux de gestion des exceptions et des informations supplémentaires assorties.

Donc, la conclusion, au moins pour GCC sous Linux: le coût est de l'espace supplémentaire (pour les gestionnaires et les tables), que des exceptions soient lancées ou non, plus le coût supplémentaire d'analyse des tables et d'exécution des gestionnaires lorsqu'une exception est levée. Si vous utilisez des exceptions au lieu de codes d'erreur et qu'une erreur est rare, cela peut être plus rapide , car vous n'avez plus la charge de tester les erreurs.

Si vous souhaitez plus d'informations, en particulier ce que font toutes les __cxa_fonctions, consultez la spécification d'origine dont elles proviennent:

CesarB
la source
23
Donc résumé. Pas de coût si aucune exception n'est levée. Certains coûts lorsqu'une exception est levée, mais la question est «Ce coût est-il supérieur à l'utilisation et au test des codes d'erreur jusqu'au code de gestion des erreurs».
Martin York
5
Les coûts d'erreur sont en effet probablement plus élevés. Le code d'exception est probablement encore sur le disque! Étant donné que le code de gestion des erreurs est supprimé du code normal, le comportement du cache dans les cas sans erreur s'améliore.
MSalters
Sur certains processeurs, tels que l'ARM, retourner à une adresse huit octets "supplémentaires" après une instruction "bl" [branch-and-link, également connue sous le nom de "call"] coûterait le même prix que le retour à l'adresse qui suit immédiatement le "bl". Je me demande comment l'efficacité du simple fait d'avoir chaque "bl" suivi de l'adresse d'un gestionnaire "d'exception entrante" se comparerait à celle d'une approche basée sur une table, et si des compilateurs font une telle chose. Le plus grand danger que je puisse voir serait que des conventions d'appel incompatibles pourraient provoquer un comportement farfelu.
supercat
2
@supercat: vous polluez votre I-cache avec le code de gestion des exceptions de cette façon. Il y a une raison pour laquelle le code de gestion des exceptions et les tables ont tendance à être loin du code normal, après tout.
CesarB
1
@CesarB: Un mot d'instruction après chaque appel. Cela ne semble pas trop scandaleux, d'autant plus que les techniques de gestion des exceptions utilisant uniquement du code «extérieur» exigent généralement que le code maintienne un pointeur de trame valide à tout moment (ce qui dans certains cas peut nécessiter 0 instruction supplémentaire, mais dans d'autres, plus de une).
supercat
13

La lenteur des exceptions était vraie autrefois.
Dans la plupart des compilateurs modernes, cela n'est plus vrai.

Remarque: ce n'est pas parce que nous avons des exceptions que nous n'utilisons pas non plus de codes d'erreur. Lorsque l'erreur peut être gérée localement, utilisez des codes d'erreur. Lorsque les erreurs nécessitent plus de contexte pour la correction, utilisez les exceptions: je l'ai écrit de manière beaucoup plus éloquente ici: Quels sont les principes qui guident votre politique de gestion des exceptions?

Le coût du code de gestion des exceptions lorsqu'aucune exception n'est utilisée est pratiquement nul.

Lorsqu'une exception est levée, du travail est effectué.
Mais vous devez comparer cela avec le coût de retour des codes d'erreur et de les vérifier jusqu'au point où l'erreur peut être traitée. Les deux prennent plus de temps à écrire et à maintenir.

Il y a aussi un piège pour les novices: bien
que les objets Exception soient censés être petits, certaines personnes y mettent beaucoup de choses. Ensuite, vous avez le coût de la copie de l'objet d'exception. La solution est double:

  • Ne mettez pas de choses supplémentaires dans votre exception.
  • Capture par référence const.

À mon avis, je parierais que le même code avec des exceptions est soit plus efficace, soit au moins aussi comparable que le code sans les exceptions (mais a tout le code supplémentaire pour vérifier les résultats des erreurs de fonction). N'oubliez pas que vous n'obtenez rien gratuitement, le compilateur génère le code que vous auriez dû écrire en premier lieu pour vérifier les codes d'erreur (et généralement le compilateur est beaucoup plus efficace qu'un humain).

Martin York
la source
1
Je parierais que les gens hésitent à utiliser des exceptions, non à cause d'une lenteur perçue, mais parce qu'ils ne savent pas comment elles sont implémentées et ce qu'elles font à votre code. Le fait qu'ils ressemblent à de la magie irrite beaucoup de types proches du métal.
speedplane
@speedplane: Je suppose. Mais tout l'intérêt des compilateurs est que nous n'ayons pas besoin de comprendre le matériel (il fournit une couche d'abstraction). Avec les compilateurs modernes, je doute que vous puissiez trouver une seule personne qui comprend toutes les facettes d'un compilateur C ++ moderne. Alors, pourquoi la compréhension des exceptions est-elle différente de la compréhension de la fonctionnalité complexe X.
Martin York
Il faut toujours avoir une idée de ce que fait le matériel, c'est une question de degré. Beaucoup de ceux qui utilisent C ++ (sur Java ou un langage de script) le font souvent pour les performances. Pour eux, la couche d'abstraction doit être relativement transparente, afin que vous ayez une idée de ce qui se passe dans le métal.
speedplane
@speedplane: Ensuite, ils devraient utiliser C où la couche d'abstraction est beaucoup plus mince par conception.
Martin York
12

Vous pouvez implémenter des exceptions de différentes manières, mais elles reposent généralement sur une prise en charge sous-jacente du système d'exploitation. Sous Windows, il s'agit du mécanisme de gestion des exceptions structuré.

Il y a une discussion décente sur les détails sur Code Project: Comment un compilateur C ++ implémente la gestion des exceptions

La surcharge des exceptions se produit car le compilateur doit générer du code pour garder une trace des objets qui doivent être détruits dans chaque cadre de pile (ou plus précisément la portée) si une exception se propage hors de cette portée. Si une fonction n'a pas de variables locales sur la pile qui nécessitent l'appel de destructeurs, elle ne devrait pas avoir de pénalité de performances pour la gestion des exceptions.

L'utilisation d'un code de retour ne peut dérouler qu'un seul niveau de la pile à la fois, alors qu'un mécanisme de gestion des exceptions peut sauter beaucoup plus loin dans la pile en une seule opération s'il n'y a rien à faire dans les cadres intermédiaires de la pile.

Rob Walker
la source
"La surcharge des exceptions se produit parce que le compilateur doit générer du code pour garder une trace des objets qui doivent être détruits dans chaque frame de pile (ou plus précisément la portée)" Le compilateur n'a-t-il pas à faire cela de toute façon pour détruire les objets d'un retour?
Non. Étant donné une pile avec des adresses de retour et une table, le compilateur peut déterminer quelles fonctions sont sur la pile. À partir de là, quels objets doivent avoir été sur la pile. Cela peut être fait après la levée de l'exception. Un peu cher, mais nécessaire uniquement lorsqu'une exception est réellement levée.
MSalters
hilarant, je me demandais simplement "ne serait-il pas cool que chaque cadre de pile garde une trace du nombre d'objets qu'il contient, de leurs types, de leurs noms, afin que ma fonction puisse creuser la pile et voir de quelles étendues elle a hérité pendant le débogage" , et d'une certaine manière, cela fait quelque chose comme ça, mais sans toujours déclarer manuellement une table comme la première variable de chaque portée.
Dmitry
6

Matt Pietrek a écrit un excellent article sur la gestion structurée des exceptions Win32 . Bien que cet article ait été écrit à l'origine en 1997, il s'applique toujours aujourd'hui (mais ne s'applique bien sûr qu'à Windows).

Greg Hewgill
la source
5

Cet article examine le problème et constate que dans la pratique, les exceptions coûtent au temps d'exécution, bien que le coût soit assez faible si l'exception n'est pas levée. Bon article, recommandé.

Alastair
la source
2

Un de mes amis a écrit un peu comment Visual C ++ gère les exceptions il y a quelques années.

http://www.xyzw.de/c160.html

Nils Pipenbrinck
la source
0

Toutes les bonnes réponses.

Pensez également à combien il est plus facile de déboguer du code qui fait des «si vérifications» comme des portes en haut des méthodes au lieu de permettre au code de lever des exceptions.

Ma devise est qu'il est facile d'écrire du code qui fonctionne. Le plus important est d'écrire le code pour la prochaine personne qui le regarde. Dans certains cas, c'est vous dans 9 mois et vous ne voulez pas maudire votre nom!

Kieveli
la source
Je suis d'accord en commun, mais dans certains cas, des exceptions peuvent simplifier le code. Pensez à la gestion des erreurs dans les constructeurs ... - les autres moyens seraient a) de renvoyer des codes d'erreur par des paramètres de référence ou b) de définir des variables globales
Uhli