Wie funktionieren Ausnahmen (hinter den Kulissen) in c ++?

109

Ich sehe immer wieder Leute sagen, dass Ausnahmen langsam sind, aber ich sehe nie einen Beweis. Anstatt zu fragen, ob dies der Fall ist, werde ich fragen, wie Ausnahmen hinter den Kulissen funktionieren, damit ich entscheiden kann, wann sie verwendet werden sollen und ob sie langsam sind.

Soweit ich weiß, sind Ausnahmen die gleichen wie eine mehrfache Rückgabe, mit der Ausnahme, dass nach jeder Rückgabe auch geprüft wird, ob eine weitere Rückgabe erforderlich ist oder gestoppt werden muss. Wie wird überprüft, wann die Rückkehr beendet werden soll? Ich denke, es gibt einen zweiten Stapel, der den Typ der Ausnahme und einen Stapelspeicherort enthält. Er kehrt dann zurück, bis er dort ankommt. Ich vermute auch, dass dieser zweite Stapel nur bei einem Wurf und bei jedem Versuch / Fang berührt wird. AFAICT, das ein ähnliches Verhalten mit Rückkehrcodes implementiert, würde dieselbe Zeit in Anspruch nehmen. Aber das ist alles nur eine Vermutung, also möchte ich wissen, was wirklich passiert.

Wie funktionieren Ausnahmen wirklich?

Pro-Gramer
quelle

Antworten:

105

Anstatt zu raten, habe ich mich entschlossen, den generierten Code mit einem kleinen Stück C ++ - Code und einer etwas alten Linux-Installation zu betrachten.

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);
}

Ich habe es mit kompiliert g++ -m32 -W -Wall -O3 -save-temps -cund mir die generierte Assembly-Datei angesehen.

    .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

_ZN11MyExceptionD1Evist MyException::~MyException(), so dass der Compiler entschieden uns, es nicht-Inline - Kopie des destructor benötigt.

.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:

Überraschung! Auf dem normalen Codepfad gibt es überhaupt keine zusätzlichen Anweisungen. Der Compiler generierte stattdessen zusätzliche Offline-Fixup-Codeblöcke, auf die über eine Tabelle am Ende der Funktion verwiesen wird (die tatsächlich in einem separaten Abschnitt der ausführbaren Datei abgelegt ist). Alle Arbeiten werden hinter den Kulissen von der Standardbibliothek ausgeführt, die auf diesen Tabellen ( _ZTI11MyExceptionis typeinfo for MyException) basiert .

OK, das war eigentlich keine Überraschung für mich, ich wusste bereits, wie dieser Compiler das gemacht hat. Fortsetzung der Baugruppenausgabe:

    .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

Hier sehen wir den Code zum Auslösen einer Ausnahme. Während es keinen zusätzlichen Overhead gab, nur weil eine Ausnahme ausgelöst werden könnte, ist es offensichtlich viel Overhead, eine Ausnahme tatsächlich zu werfen und abzufangen. Das meiste davon ist darin verborgen __cxa_throw, was sein muss:

  • Gehen Sie den Stapel mithilfe der Ausnahmetabellen durch, bis ein Handler für diese Ausnahme gefunden wird.
  • Wickeln Sie den Stapel ab, bis er zu diesem Handler gelangt.
  • Rufen Sie den Handler an.

Vergleichen Sie dies mit den Kosten für die einfache Rückgabe eines Werts, und Sie sehen, warum Ausnahmen nur für außergewöhnliche Rückgaben verwendet werden sollten.

Zum Abschluss der restlichen Assembly-Datei:

    .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"

Die typeinfo Daten.

    .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

Noch mehr Ausnahmebehandlungstabellen und verschiedene zusätzliche Informationen.

Die Schlussfolgerung, zumindest für GCC unter Linux: Die Kosten sind zusätzlicher Speicherplatz (für die Handler und Tabellen), unabhängig davon, ob Ausnahmen ausgelöst werden, sowie die zusätzlichen Kosten für das Parsen der Tabellen und das Ausführen der Handler, wenn eine Ausnahme ausgelöst wird. Wenn Sie Ausnahmen anstelle von Fehlercodes verwenden und ein Fehler selten ist, kann er schneller sein , da Sie nicht mehr den Aufwand haben, auf Fehler zu testen.

Wenn Sie weitere Informationen wünschen, insbesondere zu allen __cxa_Funktionen, lesen Sie die ursprüngliche Spezifikation, aus der sie stammen:

CesarB
quelle
23
Also Zusammenfassung. Keine Kosten, wenn keine Ausnahmen geworfen werden. Einige Kosten, wenn eine Ausnahme ausgelöst wird, aber die Frage lautet: Sind diese Kosten höher als die Verwendung und das Testen von Fehlercodes bis hin zum Fehlerbehandlungscode?
Martin York
5
Die Fehlerkosten sind in der Tat wahrscheinlich höher. Der Ausnahmecode befindet sich möglicherweise noch auf der Festplatte! Da der Fehlerbehandlungscode aus dem normalen Code entfernt wird, verbessert sich das Cache-Verhalten in Fällen ohne Fehler.
MSalters
Auf einigen Prozessoren, wie dem ARM, würde die Rückkehr zu einer Adresse acht "zusätzliche" Bytes nach einem "bl" -Befehl (Branch-and-Link, auch als "Aufruf" bezeichnet) das gleiche kosten wie die Rückkehr zur Adresse unmittelbar nach dem "bl". Ich frage mich, wie die Effizienz, einfach jedes "bl" gefolgt von der Adresse eines Handlers für "eingehende Ausnahmen" zu haben, mit der eines tabellenbasierten Ansatzes verglichen werden kann und ob Compiler so etwas tun. Die größte Gefahr, die ich sehen kann, besteht darin, dass nicht übereinstimmende Anrufkonventionen verrücktes Verhalten verursachen können.
Supercat
2
@supercat: Auf diese Weise verschmutzen Sie Ihren I-Cache mit Code für die Ausnahmebehandlung. Es gibt einen Grund, warum der Code und die Tabellen für die Ausnahmebehandlung immerhin weit vom normalen Code entfernt sind.
CesarB
1
@CesarB: Ein Anweisungswort nach jedem Aufruf. Scheint nicht zu empörend, insbesondere angesichts der Tatsache, dass Techniken zur Ausnahmebehandlung, bei denen nur "externer" Code verwendet wird, im Allgemeinen erfordern, dass der Code jederzeit einen gültigen Frame-Zeiger beibehält (was in einigen Fällen 0 zusätzliche Anweisungen erfordert, in anderen jedoch möglicherweise mehr als einer).
Supercat
13

Ausnahmen, die langsam waren, waren früher wahr.
In den meisten modernen Compilern gilt dies nicht mehr.

Hinweis: Nur weil wir Ausnahmen haben, heißt das nicht, dass wir auch keine Fehlercodes verwenden. Wenn Fehler lokal behandelt werden können, verwenden Sie Fehlercodes. Wenn Fehler mehr Kontext für die Korrektur erfordern, verwenden Sie Ausnahmen: Ich habe es hier viel beredter geschrieben: Welche Grundsätze leiten Ihre Richtlinie zur Ausnahmebehandlung?

Die Kosten für den Code zur Ausnahmebehandlung, wenn keine Ausnahmen verwendet werden, sind praktisch Null.

Wenn eine Ausnahme ausgelöst wird, werden einige Arbeiten ausgeführt.
Sie müssen dies jedoch mit den Kosten für die Rückgabe von Fehlercodes vergleichen und diese bis zu dem Punkt überprüfen, an dem der Fehler behandelt werden kann. Beides ist zeitaufwändiger zu schreiben und zu warten.

Es gibt auch ein Problem für Anfänger:
Obwohl Ausnahmeobjekte klein sein sollen, stecken einige Leute viel Zeug in sie. Dann haben Sie die Kosten für das Kopieren des Ausnahmeobjekts. Die Lösung dort ist zweifach:

  • Fügen Sie Ihrer Ausnahme keine zusätzlichen Dinge hinzu.
  • Fang durch konstante Referenz.

Meiner Meinung nach würde ich wetten, dass derselbe Code mit Ausnahmen entweder effizienter oder mindestens so vergleichbar ist wie der Code ohne Ausnahmen (verfügt jedoch über den gesamten zusätzlichen Code zur Überprüfung der Funktionsfehlerergebnisse). Denken Sie daran, dass Sie nichts umsonst bekommen. Der Compiler generiert den Code, den Sie ursprünglich geschrieben haben sollten, um Fehlercodes zu überprüfen (und normalerweise ist der Compiler viel effizienter als ein Mensch).

Martin York
quelle
1
Ich würde wetten, dass die Leute zögern, Ausnahmen zu verwenden, nicht wegen einer wahrgenommenen Langsamkeit, sondern weil sie nicht wissen, wie sie implementiert sind und was sie mit Ihrem Code machen. Die Tatsache, dass sie wie Magie wirken, irritiert viele der metallnahen Typen.
Speedplane
@speedplane: Ich nehme an. Der springende Punkt bei Compilern ist jedoch, dass wir die Hardware nicht verstehen müssen (sie bietet eine Abstraktionsschicht). Bei modernen Compilern bezweifle ich, dass Sie eine einzige Person finden könnten, die alle Facetten eines modernen C ++ - Compilers versteht. Warum unterscheidet sich das Verständnis von Ausnahmen vom Verständnis komplexer Merkmale X.
Martin York
Sie müssen immer eine Vorstellung davon haben, was die Hardware tut, es ist eine Frage des Grades. Viele, die C ++ (über Java oder eine Skriptsprache) verwenden, tun dies häufig aus Leistungsgründen. Für sie sollte die Abstraktionsschicht relativ transparent sein, damit Sie eine Vorstellung davon haben, was im Metall vor sich geht.
Speedplane
@speedplane: Dann sollten sie C verwenden, wo die Abstraktionsschicht von Natur aus viel dünner ist.
Martin York
12

Es gibt eine Reihe von Möglichkeiten, wie Sie Ausnahmen implementieren können. In der Regel hängen diese jedoch von der Unterstützung des Betriebssystems ab. Unter Windows ist dies der strukturierte Mechanismus zur Behandlung von Ausnahmen.

Die Details zu Code Project werden ausführlich diskutiert: Wie ein C ++ - Compiler die Ausnahmebehandlung implementiert

Der Overhead von Ausnahmen tritt auf, weil der Compiler Code generieren muss, um zu verfolgen, welche Objekte in jedem Stapelrahmen (oder genauer gesagt im Bereich) zerstört werden müssen, wenn sich eine Ausnahme aus diesem Bereich ausbreitet. Wenn eine Funktion keine lokalen Variablen auf dem Stapel hat, für die Destruktoren aufgerufen werden müssen, sollte sie keine Leistungseinbußen bei der Ausnahmebehandlung aufweisen.

Die Verwendung eines Rückkehrcodes kann jeweils nur eine Ebene des Stapels abwickeln, während ein Ausnahmebehandlungsmechanismus in einem Vorgang viel weiter nach unten springen kann, wenn in den Zwischenstapelrahmen nichts zu tun ist.

Rob Walker
quelle
"Der Overhead von Ausnahmen tritt auf, weil der Compiler Code generieren muss, um zu verfolgen, welche Objekte in jedem Stapelrahmen (oder genauer gesagt im Bereich) zerstört werden müssen." Muss der Compiler dies nicht trotzdem tun, um Objekte aus einer Rückgabe zu zerstören?
Nein. Bei einem Stapel mit Rücksprungadressen und einer Tabelle kann der Compiler bestimmen, welche Funktionen sich auf dem Stapel befinden. Daraus müssen sich welche Objekte auf dem Stapel befunden haben. Dies kann erfolgen, nachdem die Ausnahme ausgelöst wurde. Ein bisschen teuer, wird aber nur benötigt, wenn tatsächlich eine Ausnahme ausgelöst wird.
MSalters
Ich habe mich nur gefragt: "Wäre es nicht cool, wenn jeder Stapelrahmen die Anzahl der darin enthaltenen Objekte, ihre Typen und Namen verfolgen würde, damit meine Funktion den Stapel ausgraben und sehen könnte, welche Bereiche er beim Debuggen geerbt hat?" In gewisser Weise funktioniert dies in etwa so, ohne jedoch manuell immer eine Tabelle als erste Variable jedes Bereichs zu deklarieren.
Dmitry
6

Matt Pietrek hat einen ausgezeichneten Artikel über Win32 Structured Exception Handling geschrieben . Während dieser Artikel ursprünglich 1997 geschrieben wurde, gilt er noch heute (aber natürlich nur für Windows).

Greg Hewgill
quelle
5

Dieser Artikel untersucht das Problem und stellt im Grunde fest, dass Ausnahmen in der Praxis Laufzeitkosten verursachen, obwohl die Kosten relativ niedrig sind, wenn die Ausnahme nicht ausgelöst wird. Guter Artikel, empfohlen.

Alastair
quelle
2

Ein Freund von mir hat vor einigen Jahren geschrieben, wie Visual C ++ mit Ausnahmen umgeht.

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

Nils Pipenbrinck
quelle
0

Alles gute Antworten.

Denken Sie auch darüber nach, wie viel einfacher es ist, Code zu debuggen, der "if-Checks" als Gates am Anfang von Methoden ausführt, anstatt dem Code zu erlauben, Ausnahmen auszulösen.

Mein Motto ist, dass es einfach ist, Code zu schreiben, der funktioniert. Das Wichtigste ist, den Code für die nächste Person zu schreiben, die ihn sich ansieht. In einigen Fällen bist du es in 9 Monaten und du willst deinen Namen nicht verfluchen!

Kieveli
quelle
Ich stimme gemeinsam zu, aber in einigen Fällen können Ausnahmen den Code vereinfachen. Denken Sie an die Fehlerbehandlung in Konstruktoren ... - Die anderen Möglichkeiten wären: a) Fehlercodes über Referenzparameter zurückgeben oder b) globale Variablen setzen
Uhli