Como funcionam as exceções (nos bastidores) em c ++


109

Continuo vendo as pessoas dizerem que as exceções são lentas, mas nunca vejo nenhuma prova. Portanto, em vez de perguntar se são, vou perguntar como as exceções funcionam nos bastidores, para que eu possa tomar decisões sobre quando usá-las e se são lentas.

Pelo que eu sei, exceções são o mesmo que fazer um retorno várias vezes, exceto que ele também verifica após cada retorno se precisa fazer outro ou parar. Como ele verifica quando deve parar de retornar? Eu acho que há uma segunda pilha que contém o tipo de exceção e uma localização da pilha, ela retorna até chegar lá. Eu também estou supondo que a única vez que esta segunda pilha é tocada é em um lançamento e em cada tentativa / pega. A implementação de AFAICT de um comportamento semelhante com códigos de retorno levaria a mesma quantidade de tempo. Mas tudo isso é apenas um palpite, então quero saber o que realmente acontece.

Como as exceções realmente funcionam?



Respostas:


105

Em vez de adivinhar, decidi realmente olhar para o código gerado com um pequeno pedaço de código C ++ e uma instalação um pouco antiga do Linux.

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

Compilei-o com g++ -m32 -W -Wall -O3 -save-temps -ce examinei o arquivo de montagem gerado.

    .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(), então o compilador decidiu que precisava de uma cópia não embutida do destruidor.

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

Surpresa! Não há instruções extras no caminho de código normal. Em vez disso, o compilador gerou blocos de código de correção fora de linha extras, referenciados por meio de uma tabela no final da função (que na verdade é colocada em uma seção separada do executável). Todo o trabalho é feito nos bastidores pela biblioteca padrão, com base nessas tabelas ( _ZTI11MyExceptionis typeinfo for MyException).

OK, isso não foi realmente uma surpresa para mim, eu já sabia como esse compilador fazia isso. Continuando com a saída da montagem:

    .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

Aqui, vemos o código para lançar uma exceção. Embora não haja sobrecarga extra simplesmente porque uma exceção pode ser lançada, obviamente há muita sobrecarga em realmente lançar e capturar uma exceção. A maior parte está escondida dentro __cxa_throw, que deve:

  • Percorra a pilha com a ajuda das tabelas de exceção até encontrar um manipulador para essa exceção.
  • Desenrole a pilha até chegar ao manipulador.
  • Na verdade, chame o manipulador.

Compare isso com o custo de simplesmente retornar um valor e você verá por que as exceções devem ser usadas apenas para retornos excepcionais.

Para finalizar, o resto do arquivo de montagem:

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

Os dados 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

Ainda mais tabelas de tratamento de exceções e informações extras variadas.

Portanto, a conclusão, pelo menos para GCC no Linux: o custo é espaço extra (para os manipuladores e tabelas) se as exceções são lançadas ou não, mais o custo extra de analisar as tabelas e executar os manipuladores quando uma exceção é lançada. Se você usar exceções em vez de códigos de erro, e um erro é raro, ele pode ser mais rápido , pois você não tem mais a sobrecarga de testar os erros.

Caso queira mais informações, em particular o que todas as __cxa_funções fazem, consulte a especificação original de onde vieram:


23
Portanto, resumo. Não custa se nenhuma exceção for lançada. Algum custo quando uma exceção é lançada, mas a questão é 'Esse custo é maior do que usar e testar códigos de erro até o código de tratamento de erros'.
Martin York

5
Os custos de erro são provavelmente maiores. O código de exceção provavelmente ainda está no disco! Como o código de tratamento de erros é removido do código normal, o comportamento do cache em casos sem erros é aprimorado.
MSalters em

Em alguns processadores, como o ARM, retornar a um endereço de oito bytes "extras" após uma instrução "bl" [branch-and-link, também conhecido como "call"] custaria o mesmo que retornar ao endereço imediatamente após o "bl". Eu me pergunto como a eficiência de simplesmente ter cada "bl" seguido pelo endereço de um manipulador de "exceção de entrada" se compara com a de uma abordagem baseada em tabela, e se algum compilador faz isso. O maior perigo que posso ver é que as convenções de chamada incompatíveis podem causar um comportamento estranho.
supercat

2
@supercat: você está poluindo seu I-cache com código de manipulação de exceção dessa forma. Há um motivo pelo qual o código de tratamento de exceções e as tabelas tendem a estar distantes do código normal, afinal.
CesarB de

1
@CesarB: Uma palavra de instrução após cada chamada. Não parece muito ultrajante, especialmente considerando que as técnicas de tratamento de exceção usando apenas código "externo" geralmente exigem que o código mantenha um ponteiro de frame válido em todos os momentos (o que em alguns casos pode exigir 0 instruções extras, mas em outros pode exigir mais do que 1).
supercat

13

A lentidão das exceções era verdade nos velhos tempos.
Na maioria dos compiladores modernos, isso não é mais verdade.

Nota: Só porque temos exceções, não significa que não usamos códigos de erro também. Quando o erro puder ser tratado localmente, use os códigos de erro. Quando os erros exigem mais contexto para correção, use exceções: Eu escrevi muito mais eloquentemente aqui: Quais são os princípios que orientam sua política de tratamento de exceções?

O custo do código de tratamento de exceções quando nenhuma exceção está sendo usada é praticamente zero.

Quando uma exceção é lançada, algum trabalho é realizado.
Mas você tem que comparar isso com o custo de retornar códigos de erro e verificá-los até o ponto em que o erro pode ser tratado. Ambos consomem mais tempo para escrever e manter.

Também há uma pegadinha para os novatos:
embora os objetos Exception devam ser pequenos, algumas pessoas colocam muitas coisas dentro deles. Então você tem o custo de copiar o objeto de exceção. A solução é dupla:

  • Não coloque coisas extras em sua exceção.
  • Capture por referência const.

Na minha opinião, eu apostaria que o mesmo código com exceções é mais eficiente ou pelo menos tão comparável quanto o código sem as exceções (mas tem todo o código extra para verificar os resultados de erro da função). Lembre-se de que você não está obtendo nada de graça - o compilador está gerando o código que você deveria ter escrito em primeiro lugar para verificar os códigos de erro (e geralmente o compilador é muito mais eficiente do que um humano).


1
Aposto que as pessoas hesitam em usar exceções, não por causa de qualquer lentidão percebida, mas porque não sabem como elas são implementadas e o que estão fazendo com seu código. O fato de eles parecerem mágicos irrita muitos dos tipos próximos do metal.
speedplane

@speedplane: suponho. Mas o ponto principal dos compiladores é que não precisamos entender o hardware (ele fornece uma camada de abstração). Com compiladores modernos, duvido que você possa encontrar uma única pessoa que entenda todas as facetas de um compilador C ++ moderno. Então, por que entender exceções é diferente de entender o complexo recurso X.
Martin York

Você sempre precisa ter alguma ideia do que o hardware está fazendo, é uma questão de grau. Muitos que usam C ++ (em Java ou uma linguagem de script) geralmente o fazem para desempenho. Para eles, a camada de abstração deve ser relativamente transparente, para que você tenha uma ideia do que está acontecendo no metal.
speedplane

@speedplane: Então, eles deveriam estar usando C, onde a camada de abstração é muito mais fina por design.
Martin York

12

Existem várias maneiras de implementar exceções, mas normalmente elas contarão com algum suporte subjacente do sistema operacional. No Windows, este é o mecanismo estruturado de tratamento de exceções.

Há uma discussão decente sobre os detalhes no Projeto de código: Como um compilador C ++ implementa o tratamento de exceções

A sobrecarga de exceções ocorre porque o compilador precisa gerar código para manter o controle de quais objetos devem ser destruídos em cada frame de pilha (ou mais precisamente no escopo) se uma exceção se propagar fora desse escopo. Se uma função não tiver variáveis ​​locais na pilha que exijam que destruidores sejam chamados, ela não deve ter uma penalidade de desempenho em relação ao tratamento de exceções.

Usar um código de retorno só pode desfazer um único nível da pilha por vez, enquanto um mecanismo de tratamento de exceção pode pular muito mais para baixo na pilha em uma operação se não houver nada para fazer nos quadros de pilha intermediários.


"A sobrecarga de exceções ocorre porque o compilador tem que gerar código para manter o controle de quais objetos devem ser destruídos em cada frame de pilha (ou mais precisamente no escopo)" O compilador não precisa fazer isso de qualquer maneira para destruir os objetos de um retorno?

Não. Dada uma pilha com endereços de retorno e uma tabela, o compilador pode determinar quais funções estão na pilha. A partir daí, quais objetos devem estar na pilha. Isso pode ser feito depois que a exceção é lançada. Um pouco caro, mas necessário apenas quando uma exceção é realmente lançada.
MSalters em

hilário, eu estava pensando comigo mesmo "não seria legal se cada estrutura de pilha rastreasse o número de objetos nela, seus tipos, nomes, para que minha função pudesse cavar a pilha e ver quais escopos ela herdou durante a depuração" , e de certa forma, isso faz algo parecido, mas sem sempre declarar manualmente uma tabela como a primeira variável de cada escopo.
Dmitry


5

Este artigo examina o problema e basicamente descobre que, na prática, há um custo de tempo de execução para exceções, embora o custo seja bastante baixo se a exceção não for lançada. Bom artigo, recomendado.



0

Todas boas respostas.

Além disso, pense em como é muito mais fácil depurar código que faz 'se verifica' como portas no topo dos métodos, em vez de permitir que o código lance exceções.

Meu lema é que é fácil escrever um código que funcione. O mais importante é escrever o código para a próxima pessoa que o olhar. Em alguns casos, é você em 9 meses, e você não quer xingar seu nome!


Concordo em comum, mas em alguns casos as exceções podem simplificar o código. Pense no tratamento de erros em construtores ... - as outras maneiras seriam a) retornar códigos de erro por parâmetros de referência ou b) definir variáveis ​​globais
Uhli 01 de
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.