Come funzionano le eccezioni (dietro le quinte) in c ++


109

Continuo a vedere la gente dire che le eccezioni sono lente, ma non vedo mai alcuna prova. Quindi, invece di chiedere se lo sono, chiederò come funzionano le eccezioni dietro le quinte, così posso prendere decisioni su quando usarle e se sono lente.

Da quello che so, le eccezioni sono le stesse di un ritorno un mucchio di volte, tranne per il fatto che controlla anche dopo ogni ritorno se deve farne un altro o fermarsi. Come controlla quando smettere di tornare? Immagino che ci sia un secondo stack che contiene il tipo di eccezione e una posizione dello stack, quindi restituisce finché non arriva lì. Immagino anche che l'unica volta che questo secondo stack viene toccato sia su un lancio e su ogni try / catch. AFAICT l'implementazione di un comportamento simile con codici di ritorno richiederebbe la stessa quantità di tempo. Ma è solo un'ipotesi, quindi voglio sapere cosa succede veramente.

Come funzionano davvero le eccezioni?



Risposte:


105

Invece di indovinare, ho deciso di guardare effettivamente il codice generato con un piccolo pezzo di codice C ++ e un'installazione Linux un po 'vecchia.

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

L'ho compilato con g++ -m32 -W -Wall -O3 -save-temps -ce ho guardato il file assembly generato.

    .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è MyException::~MyException(), quindi il compilatore ha deciso che aveva bisogno di una copia non inline del distruttore.

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

Sorpresa! Non ci sono istruzioni aggiuntive sul normale percorso del codice. Il compilatore ha invece generato blocchi di codice di correzione extra fuori linea, referenziati tramite una tabella alla fine della funzione (che in realtà è posta in una sezione separata dell'eseguibile). Tutto il lavoro viene svolto dietro le quinte dalla libreria standard, basata su queste tabelle ( _ZTI11MyExceptionè typeinfo for MyException).

OK, in realtà non è stata una sorpresa per me, sapevo già come lo faceva questo compilatore. Continuando con l'output dell'assemblaggio:

    .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

Qui vediamo il codice per lanciare un'eccezione. Sebbene non ci fosse alcun sovraccarico extra semplicemente perché potrebbe essere generata un'eccezione, ovviamente c'è molto sovraccarico nel lanciare e catturare un'eccezione. La maggior parte è nascosta all'interno __cxa_throw, che deve:

  • Percorri lo stack con l'aiuto delle tabelle delle eccezioni finché non trova un gestore per quell'eccezione.
  • Svolgi lo stack finché non arriva a quel gestore.
  • Effettivamente chiama il gestore.

Confrontalo con il costo della semplice restituzione di un valore e vedrai perché le eccezioni dovrebbero essere utilizzate solo per resi eccezionali.

Per finire, il resto del file di assieme:

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

I dati 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

Ancora più tabelle per la gestione delle eccezioni e informazioni extra assortite.

Quindi, la conclusione, almeno per GCC su Linux: il costo è lo spazio extra (per i gestori e le tabelle) indipendentemente dal fatto che vengano lanciate o meno eccezioni, più il costo aggiuntivo per l'analisi delle tabelle e l'esecuzione dei gestori quando viene generata un'eccezione. Se usi le eccezioni invece dei codici di errore e un errore è raro, può essere più veloce , poiché non hai più il sovraccarico di testare gli errori.

Nel caso in cui desideri maggiori informazioni, in particolare su cosa fanno tutte le __cxa_funzioni, consulta le specifiche originali da cui provengono:


23
Quindi sommario. Nessun costo se non vengono generate eccezioni. Un po 'di costo quando viene generata un'eccezione, ma la domanda è: "Questo costo è maggiore dell'utilizzo e del test dei codici di errore fino al codice di gestione degli errori".
Martin York

5
I costi di errore sono probabilmente maggiori. Il codice di eccezione è molto probabilmente ancora su disco! Poiché il codice di gestione degli errori viene rimosso dal codice normale, il comportamento della cache nei casi di non errore migliora.
MSalters

Su alcuni processori, come ARM, il ritorno a un indirizzo di otto byte "extra" oltre un'istruzione "bl" [branch-and-link, noto anche come "call"] costerebbe lo stesso che tornare all'indirizzo immediatamente successivo al "bl". Mi chiedo come l'efficienza di avere semplicemente ogni "bl" seguito dall'indirizzo di un gestore di "eccezioni in entrata" sarebbe paragonabile a quella di un approccio basato su tabelle e se qualche compilatore fa una cosa del genere. Il pericolo più grande che posso vedere sarebbe che convenzioni di chiamata non corrispondenti potrebbero causare comportamenti stravaganti.
supercat

2
@supercat: stai inquinando la tua I-cache con il codice di gestione delle eccezioni in questo modo. C'è una ragione per cui il codice di gestione delle eccezioni e le tabelle tendono ad essere lontani dal codice normale, dopotutto.
CesarB

1
@CesarB: una parola di istruzione dopo ogni chiamata. Non sembra troppo oltraggioso, soprattutto dato che le tecniche per la gestione delle eccezioni utilizzando solo codice "esterno" generalmente richiedono che il codice mantenga sempre un puntatore a frame valido (che in alcuni casi potrebbe richiedere 0 istruzioni aggiuntive, ma in altri potrebbe richiedere più di uno).
supercat

13

Le eccezioni che erano lente erano vere ai vecchi tempi.
Nella maggior parte dei compilatori moderni questo non è più vero.

Nota: solo perché abbiamo delle eccezioni non significa che non utilizziamo anche i codici di errore. Quando l'errore può essere gestito localmente, utilizzare i codici di errore. Quando gli errori richiedono più contesto per la correzione, usa le eccezioni: l'ho scritto in modo molto più eloquente qui: Quali sono i principi che guidano la tua politica di gestione delle eccezioni?

Il costo del codice di gestione delle eccezioni quando non vengono utilizzate eccezioni è praticamente zero.

Quando viene generata un'eccezione, viene eseguito del lavoro.
Ma devi confrontarlo con il costo della restituzione dei codici di errore e controllarli fino al punto in cui l'errore può essere gestito. Entrambi richiedono più tempo per scrivere e mantenere.

C'è anche un trucco per i principianti:
sebbene si supponga che gli oggetti Eccezione siano piccoli, alcune persone mettono molte cose al loro interno. Quindi hai il costo di copiare l'oggetto eccezione. La soluzione è duplice:

  • Non inserire cose extra nella tua eccezione.
  • Cattura per riferimento const.

A mio parere scommetterei che lo stesso codice con le eccezioni è più efficiente o almeno paragonabile al codice senza le eccezioni (ma ha tutto il codice extra per controllare i risultati degli errori di funzione). Ricorda che non ottieni nulla gratuitamente, il compilatore sta generando il codice che avresti dovuto scrivere in primo luogo per controllare i codici di errore (e di solito il compilatore è molto più efficiente di un essere umano).


1
Scommetto che le persone esitano a usare le eccezioni, non a causa di una lentezza percepita, ma perché non sanno come vengono implementate e cosa stanno facendo al tuo codice. Il fatto che sembrino magici infastidisce molti dei tipi vicini al metallo.
speedplane

@speedplane: suppongo. Ma il punto centrale dei compilatori è che non abbiamo bisogno di capire l'hardware (fornisce un livello di astrazione). Con i compilatori moderni dubito che tu possa trovare una sola persona che capisca ogni aspetto di un moderno compilatore C ++. Allora perché la comprensione delle eccezioni è diversa dalla comprensione della complessa caratteristica X.
Martin York

Devi sempre avere un'idea di cosa sta facendo l'hardware, è una questione di grado. Molti che utilizzano C ++ (su Java o un linguaggio con script) lo fanno spesso per le prestazioni. Per loro, lo strato di astrazione dovrebbe essere relativamente trasparente, in modo da avere un'idea di cosa sta succedendo nel metallo.
speedplane

@speedplane: Quindi dovrebbero usare C dove lo strato di astrazione è molto più sottile in base alla progettazione.
Martin York

12

Esistono diversi modi per implementare le eccezioni, ma in genere si baseranno su un supporto sottostante dal sistema operativo. Su Windows questo è il meccanismo strutturato di gestione delle eccezioni.

C'è una discreta discussione dei dettagli sul progetto di codice: come un compilatore C ++ implementa la gestione delle eccezioni

Il sovraccarico delle eccezioni si verifica perché il compilatore deve generare codice per tenere traccia di quali oggetti devono essere distrutti in ogni frame dello stack (o più precisamente nell'ambito) se un'eccezione si propaga fuori da tale ambito. Se una funzione non ha variabili locali nello stack che richiedono la chiamata di distruttori, non dovrebbe avere una penalizzazione delle prestazioni rispetto alla gestione delle eccezioni.

L'utilizzo di un codice di ritorno può svolgere solo un singolo livello dello stack alla volta, mentre un meccanismo di gestione delle eccezioni può saltare molto più indietro nello stack in un'unica operazione se non c'è nulla da fare negli stack frame intermedi.


"Il sovraccarico delle eccezioni si verifica perché il compilatore deve generare codice per tenere traccia di quali oggetti devono essere distrutti in ogni frame dello stack (o più precisamente nell'ambito)" Il compilatore non deve farlo comunque per distruggere gli oggetti da un ritorno?

No. Dato uno stack con indirizzi di ritorno e una tabella, il compilatore può determinare quali funzioni si trovano nello stack. Da quello, quali oggetti dovevano essere in pila. Questa operazione può essere eseguita dopo che è stata generata l'eccezione. Un po 'costoso, ma necessario solo quando viene effettivamente generata un'eccezione.
MSalters

esilarante, mi stavo solo chiedendo "non sarebbe bello se ogni stack frame tenesse traccia del numero di oggetti al suo interno, i loro tipi, nomi, in modo che la mia funzione possa scavare lo stack e vedere quali ambiti ha ereditato durante il debug" , e in un certo senso, questo fa qualcosa del genere, ma senza dichiarare manualmente sempre una tabella come prima variabile di ogni ambito.
Dmitry


5

Questo articolo esamina il problema e fondamentalmente rileva che in pratica esiste un costo di runtime per le eccezioni, sebbene il costo sia piuttosto basso se l'eccezione non viene generata. Buon articolo, consigliato.



0

Tutte buone risposte.

Inoltre, pensa a quanto sia più facile eseguire il debug del codice che esegue "if checks" come porte all'inizio dei metodi invece di consentire al codice di generare eccezioni.

Il mio motto è che è facile scrivere codice che funzioni. La cosa più importante è scrivere il codice per la prossima persona che lo guarda. In alcuni casi, sei tu tra 9 mesi e non vuoi maledire il tuo nome!


Sono d'accordo in comune, ma in alcuni casi le eccezioni possono semplificare il codice. Pensa alla gestione degli errori nei costruttori ... - gli altri modi sarebbero a) restituire i codici di errore per parametri di riferimento o b) impostare le variabili globali
Uhli
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.