¿Cómo funcionan las excepciones (detrás de escena) en c ++

109

Sigo viendo que la gente dice que las excepciones son lentas, pero nunca veo ninguna prueba. Entonces, en lugar de preguntar si lo son, preguntaré cómo funcionan las excepciones entre bastidores, para poder tomar decisiones sobre cuándo usarlas y si son lentas.

Por lo que sé, las excepciones son lo mismo que hacer una devolución varias veces, excepto que también verifica después de cada devolución si necesita hacer otra o detenerse. ¿Cómo comprueba cuándo dejar de devolver? Supongo que hay una segunda pila que contiene el tipo de excepción y una ubicación de la pila, luego regresa hasta que llega allí. También supongo que la única vez que se toca esta segunda pila es en un lanzamiento y en cada intento / recepción. AFAICT implementar un comportamiento similar con códigos de retorno tomaría la misma cantidad de tiempo. Pero todo esto es solo una suposición, así que quiero saber qué sucede realmente.

¿Cómo funcionan realmente las excepciones?

programador
fuente
1
Echa un vistazo a: stackoverflow.com/questions/106586/…
Martin York
También: stackoverflow.com/questions/1331220/…
Jonas Byström

Respuestas:

105

En lugar de adivinar, decidí mirar el código generado con un pequeño fragmento de código C ++ y una instalación de Linux algo antigua.

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

Lo compilé g++ -m32 -W -Wall -O3 -save-temps -cy miré el archivo de ensamblaje generado.

    .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

_ZN11MyExceptionD1Eves decir MyException::~MyException(), el compilador decidió que necesitaba una copia no en línea del destructor.

.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! No hay instrucciones adicionales en la ruta del código normal. En cambio, el compilador generó bloques de código de reparación extra fuera de línea, referenciados mediante una tabla al final de la función (que en realidad se coloca en una sección separada del ejecutable). Todo el trabajo se realiza entre bastidores mediante la biblioteca estándar, basada en estas tablas ( _ZTI11MyExceptionis typeinfo for MyException).

De acuerdo, eso no fue una sorpresa para mí, ya sabía cómo lo hacía este compilador. Continuando con la salida de montaje:

    .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

Aquí vemos el código para lanzar una excepción. Si bien no hubo una sobrecarga adicional simplemente porque podría lanzarse una excepción, obviamente hay una gran sobrecarga al lanzar y capturar una excepción. La mayor parte está oculta dentro __cxa_throw, que debe:

  • Recorra la pila con la ayuda de las tablas de excepción hasta que encuentre un controlador para esa excepción.
  • Desenrolla la pila hasta que llegue a ese controlador.
  • De hecho, llama al controlador.

Compare eso con el costo de simplemente devolver un valor, y verá por qué las excepciones deben usarse solo para devoluciones excepcionales.

Para terminar, el resto del archivo de ensamblaje:

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

Los datos de 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

Incluso más tablas de manejo de excepciones e información adicional variada.

Entonces, la conclusión, al menos para GCC en Linux: el costo es espacio adicional (para los controladores y tablas) independientemente de si se lanzan o no excepciones, más el costo adicional de analizar las tablas y ejecutar los controladores cuando se lanza una excepción. Si usa excepciones en lugar de códigos de error, y un error es poco común, puede ser más rápido , ya que ya no tiene la sobrecarga de probar los errores.

En caso de que desee obtener más información, en particular qué hacen todas las __cxa_funciones, consulte la especificación original de la que provienen:

CesarB
fuente
23
Así que resumen. No tiene costo si no se hacen excepciones. Algunos cuestan cuando se lanza una excepción, pero la pregunta es '¿Es este costo mayor que usar y probar códigos de error hasta el código de manejo de errores'.
Martin York
5
Es probable que los costos de error sean mayores. ¡Es muy posible que el código de excepción todavía esté en el disco! Dado que el código de manejo de errores se elimina del código normal, el comportamiento de la caché en los casos en los que no hay errores mejora.
MSalters
En algunos procesadores, como ARM, regresar a una dirección ocho bytes "extra" después de una instrucción "bl" [rama y enlace, también conocida como "llamada"] costaría lo mismo que regresar a la dirección inmediatamente después de la "licenciado en Derecho". Me pregunto cómo se compararía la eficiencia de tener simplemente cada "bl" seguido de la dirección de un controlador de "excepción entrante" con la de un enfoque basado en tablas, y si algún compilador hace tal cosa. El mayor peligro que puedo ver sería que las convenciones de llamadas no coincidentes podrían causar un comportamiento extraño.
supercat
2
@supercat: está contaminando su I-cache con código de manejo de excepciones de esa manera. Hay una razón por la que el código de manejo de excepciones y las tablas tienden a estar lejos del código normal, después de todo.
CesarB
1
@CesarB: Una palabra de instrucción después de cada llamada. No parece demasiado escandaloso, especialmente dado que las técnicas para el manejo de excepciones que usan solo código "externo" generalmente requieren que el código mantenga un puntero de marco válido en todo momento (que en algunos casos puede requerir 0 instrucciones adicionales, pero en otros puede requerir más de uno).
supercat
13

Excepciones siendo lenta era cierto en los viejos tiempos.
En la mayoría de los compiladores modernos, esto ya no es cierto.

Nota: El hecho de que tengamos excepciones no significa que no usemos códigos de error también. Cuando el error se pueda manejar localmente, use códigos de error. Cuando los errores requieren más contexto para la corrección, use excepciones: Lo escribí con mucha más elocuencia aquí: ¿Cuáles son los principios que guían su política de manejo de excepciones?

El costo del código de manejo de excepciones cuando no se utilizan excepciones es prácticamente cero.

Cuando se lanza una excepción, se realiza algún trabajo.
Pero debe comparar esto con el costo de devolver los códigos de error y verificarlos hasta el punto donde se puede manejar el error. Ambos requieren más tiempo para escribir y mantener.

También hay un problema para los principiantes:
aunque se supone que los objetos de excepción son pequeños, algunas personas ponen muchas cosas dentro de ellos. Entonces tienes el costo de copiar el objeto de excepción. La solución es doble:

  • No pongas cosas adicionales en tu excepción.
  • Capturar por referencia constante.

En mi opinión, apostaría a que el mismo código con excepciones es más eficiente o al menos tan comparable como el código sin las excepciones (pero tiene todo el código adicional para verificar los resultados del error de función). Recuerde que no obtiene nada gratis, el compilador está generando el código que debería haber escrito en primer lugar para verificar los códigos de error (y generalmente el compilador es mucho más eficiente que un humano).

Martin York
fuente
1
Apostaría a que las personas dudan en usar excepciones, no por la lentitud percibida, sino porque no saben cómo se implementan y qué le están haciendo a su código. El hecho de que parezcan mágicos molesta a muchos de los más cercanos al metal.
Speedplane
@speedplane: supongo. Pero el objetivo de los compiladores es que no necesitamos comprender el hardware (proporciona una capa de abstracción). Con los compiladores modernos, dudo que pueda encontrar una sola persona que comprenda todas las facetas de un compilador C ++ moderno. Entonces, ¿por qué comprender las excepciones es diferente de comprender la característica compleja X?
Martin York
Siempre es necesario tener alguna idea de lo que hace el hardware, es una cuestión de grado. Muchos de los que utilizan C ++ (sobre Java o un lenguaje de secuencias de comandos) a menudo lo hacen por rendimiento. Para ellos, la capa de abstracción debe ser relativamente transparente, para que tengas una idea de lo que está sucediendo en el metal.
Speedplane
@speedplane: Entonces deberían usar C donde la capa de abstracción es mucho más delgada por diseño.
Martin York
12

Hay varias formas de implementar excepciones, pero normalmente dependerán de algún soporte subyacente del sistema operativo. En Windows, este es el mecanismo estructurado de manejo de excepciones.

Hay una discusión decente de los detalles en Code Project: cómo un compilador de C ++ implementa el manejo de excepciones

La sobrecarga de las excepciones se produce porque el compilador tiene que generar código para realizar un seguimiento de qué objetos deben destruirse en cada marco de pila (o más precisamente en el ámbito) si una excepción se propaga fuera de ese ámbito. Si una función no tiene variables locales en la pila que requieran la llamada de destructores, entonces no debería tener una penalización de rendimiento con el manejo de excepciones.

El uso de un código de retorno solo puede desenrollar un solo nivel de la pila a la vez, mientras que un mecanismo de manejo de excepciones puede retroceder mucho más en la pila en una operación si no hay nada que hacer en los marcos de pila intermedios.

Rob Walker
fuente
"La sobrecarga de las excepciones se produce porque el compilador tiene que generar código para realizar un seguimiento de qué objetos deben ser destruidos en cada marco de pila (o más precisamente alcance)" ¿No tiene el compilador que hacer eso de todos modos para destruir objetos de una devolución?
No. Dada una pila con direcciones de retorno y una tabla, el compilador puede determinar qué funciones están en la pila. De ahí, qué objetos deben haber estado en la pila. Esto se puede hacer después de lanzar la excepción. Un poco caro, pero solo es necesario cuando se lanza una excepción.
MSalters
hilarante, me estaba preguntando "no sería genial si cada marco de pila hiciera un seguimiento de la cantidad de objetos en él, sus tipos, nombres, para que mi función pudiera excavar en la pila y ver qué ámbitos heredó durante la depuración" , y de alguna manera, esto hace algo así, pero sin declarar siempre manualmente una tabla como la primera variable de cada ámbito.
Dmitry
6

Matt Pietrek escribió un excelente artículo sobre el manejo estructurado de excepciones de Win32 . Si bien este artículo se escribió originalmente en 1997, todavía se aplica hoy (pero, por supuesto, solo se aplica a Windows).

Greg Hewgill
fuente
5

Este artículo examina el problema y básicamente descubre que, en la práctica, las excepciones tienen un costo de tiempo de ejecución, aunque el costo es bastante bajo si no se lanza la excepción. Buen artículo, recomendado.

Alastair
fuente
0

Todas buenas respuestas.

Además, piense en lo más fácil que es depurar el código que hace "si verifica" como puertas en la parte superior de los métodos en lugar de permitir que el código arroje excepciones.

Mi lema es que es fácil escribir código que funcione. Lo más importante es escribir el código para la próxima persona que lo vea. En algunos casos, serás tú en 9 meses y no querrás maldecir tu nombre.

Kieveli
fuente
Estoy de acuerdo en común, pero en algunos casos las excepciones pueden simplificar el código. Piense en el manejo de errores en los constructores ... - las otras formas serían a) devolver códigos de error por parámetros de referencia ob) establecer variables globales
Uhli