Jak działają wyjątki (za kulisami) w języku c ++

109

Ciągle widzę, jak ludzie mówią, że wyjątki są powolne, ale nigdy nie widzę żadnego dowodu. Zamiast więc pytać, czy tak jest, zapytam, jak wyjątki działają za kulisami, abym mógł podejmować decyzje, kiedy ich używać i czy są powolne.

Z tego, co wiem, wyjątki są takie same, jak wykonywanie kilku zwrotów, z wyjątkiem tego, że po każdym powrocie sprawdza również, czy musi zrobić kolejny, czy zatrzymać. Jak sprawdza, kiedy przestać wracać? Wydaje mi się, że istnieje drugi stos, który przechowuje typ wyjątku i lokalizację stosu, a następnie wraca, dopóki tam nie dotrze. Domyślam się również, że jedyny przypadek, gdy ten drugi stack zostanie dotknięty, to rzut i każdy try / catch. AFAICT implementacja podobnego zachowania z kodami powrotu zajęłaby tyle samo czasu. Ale to tylko przypuszczenie, więc chcę wiedzieć, co naprawdę się dzieje.

Jak naprawdę działają wyjątki?

programista
źródło

Odpowiedzi:

105

Zamiast zgadywać, zdecydowałem się przyjrzeć wygenerowanemu kodowi z małym fragmentem kodu C ++ i nieco starą instalacją Linuksa.

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

Skompilowałem go za pomocą g++ -m32 -W -Wall -O3 -save-temps -ci spojrzałem na wygenerowany plik zespołu.

    .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

_ZN11MyExceptionD1Evjest MyException::~MyException(), więc kompilator zdecydował, że potrzebuje nieliniowej kopii destruktora.

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

Niespodzianka! W normalnej ścieżce kodu nie ma żadnych dodatkowych instrukcji. Kompilator zamiast tego wygenerował dodatkowe bloki kodu korekcji poza linią, do których odwołuje się tabela na końcu funkcji (która w rzeczywistości jest umieszczona w oddzielnej sekcji pliku wykonywalnego). Cała praca jest wykonywana za kulisami przez bibliotekę standardową, opartą na tych tabelach ( _ZTI11MyExceptionjest typeinfo for MyException).

OK, to nie było dla mnie niespodzianką, już wiedziałem, jak ten kompilator to zrobił. Kontynuując wyjście z montażu:

    .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

Tutaj widzimy kod do zgłaszania wyjątku. Chociaż nie było dodatkowego narzutu po prostu dlatego, że mógł zostać wyrzucony wyjątek, oczywiście rzucanie i przechwytywanie wyjątku wiąże się z dużym obciążeniem. Większość z nich jest ukryta w środku __cxa_throw, co musi:

  • Przechodź po stosie za pomocą tabel wyjątków, aż znajdzie procedurę obsługi tego wyjątku.
  • Rozwiń stos, aż dotrze do tego handlera.
  • Właściwie zadzwoń do obsługi.

Porównaj to z kosztem zwykłego zwrotu wartości, a zobaczysz, dlaczego wyjątki powinny być używane tylko w przypadku wyjątkowych zwrotów.

Na koniec pozostała część pliku zespołu:

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

Dane 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

Jeszcze więcej tabel obsługi wyjątków i różne dodatkowe informacje.

Tak więc wniosek, przynajmniej dla GCC na Linuksie: kosztem jest dodatkowa przestrzeń (dla programów obsługi i tabel) niezależnie od tego, czy wyjątki są wyrzucane, czy nie, plus dodatkowy koszt parsowania tabel i wykonywania programów obsługi, gdy zgłaszany jest wyjątek. Jeśli używasz wyjątków zamiast kodów błędów, a błąd jest rzadki, może być szybszy , ponieważ nie masz już narzutu związanego z testowaniem błędów.

Jeśli chcesz uzyskać więcej informacji, w szczególności co robią wszystkie __cxa_funkcje, zobacz oryginalną specyfikację, z której pochodzą:

CesarB
źródło
23
Więc podsumowanie. Nie kosztuje, jeśli nie ma wyjątków. Pewien koszt, gdy zostanie zgłoszony wyjątek, ale pytanie brzmi: „Czy ten koszt jest większy niż użycie i testowanie kodów błędów aż do kodu obsługi błędów”.
Martin York,
5
Koszty błędów są rzeczywiście prawdopodobnie większe. Kod wyjątku prawdopodobnie nadal znajduje się na dysku! Ponieważ kod obsługi błędów jest usuwany ze zwykłego kodu, poprawia się zachowanie pamięci podręcznej w przypadkach bez błędów.
MSalters
Na niektórych procesorach, takich jak ARM, powrót do adresu ośmiu „dodatkowych” bajtów za instrukcją „bl” [rozgałęzienie i łącze, znane również jako „wywołanie”] będzie kosztował tyle samo, co powrót do adresu bezpośrednio po „bl”. Zastanawiam się, jak wydajność zwykłego posiadania każdego „bl”, po którym następuje adres procedury obsługi „przychodzącego wyjątku”, mogłaby porównać się z wydajnością podejścia opartego na tabelach i czy jakiekolwiek kompilatory to robią. Największym niebezpieczeństwem, jakie widzę, jest to, że niedopasowane konwencje połączeń mogą powodować dziwaczne zachowanie.
supercat
2
@supercat: w ten sposób zanieczyszczasz swoją pamięć podręczną I-cache kodem obsługującym wyjątki. Jest powód, dla którego kod obsługi wyjątków i tabele są w końcu dalekie od normalnego kodu.
CesarB
1
@CesarB: jedno słowo instrukcji po każdym wywołaniu. Nie wydaje się zbyt skandaliczne, zwłaszcza biorąc pod uwagę, że techniki obsługi wyjątków przy użyciu tylko „zewnętrznego” kodu generalnie wymagają, aby kod utrzymywał prawidłowy wskaźnik ramki przez cały czas (co w niektórych przypadkach może wymagać 0 dodatkowych instrukcji, ale w innych może wymagać więcej niż jeden).
supercat
13

Wyjątkiem jest powolny było prawdziwe w dawnych czasach.
W większości współczesnych kompilatorów nie jest to już prawdą.

Uwaga: tylko dlatego, że mamy wyjątki, nie oznacza, że ​​nie używamy również kodów błędów. Jeśli błąd można obsłużyć lokalnie, użyj kodów błędów. Kiedy błędy wymagają więcej kontekstu do korekty, użyj wyjątków: napisałem to znacznie bardziej elokwentnie tutaj: Jakie zasady kierują twoją polityką obsługi wyjątków?

Koszt kodu obsługi wyjątków, gdy nie są używane żadne wyjątki, jest praktycznie zerowy.

Gdy zostanie zgłoszony wyjątek, część pracy jest wykonywana.
Ale musisz to porównać z kosztem zwracania kodów błędów i sprawdzania ich aż do momentu, w którym błąd można obsłużyć. Zarówno pisanie, jak i konserwacja bardziej czasochłonne.

Jest też jedna pułapka dla nowicjuszy:
chociaż obiekty Exception mają być małe, niektórzy ludzie wkładają do nich wiele rzeczy. Wtedy masz koszt kopiowania obiektu wyjątku. Rozwiązanie jest dwojakie:

  • Nie umieszczaj dodatkowych rzeczy w swoim wyjątku.
  • Złap przez odwołanie do stałej.

Moim zdaniem założyłbym się, że ten sam kod z wyjątkami jest albo bardziej wydajny, albo przynajmniej tak samo porównywalny jak kod bez wyjątków (ale ma cały dodatkowy kod do sprawdzania wyników błędów funkcji). Pamiętaj, że nie dostajesz nic za darmo, kompilator generuje kod, który powinieneś był napisać w pierwszej kolejności, aby sprawdzić kody błędów (a zwykle kompilator jest znacznie wydajniejszy niż człowiek).

Martin York
źródło
1
Założę się, że ludzie wahają się przed używaniem wyjątków, nie z powodu jakiejkolwiek spostrzeganej powolności, ale dlatego, że nie wiedzą, jak są zaimplementowane i co robią z twoim kodem. Fakt, że wydają się magiczne, irytuje wiele osób bliskich metalowi.
szybowiec
@speedplane: Chyba. Jednak celem kompilatorów jest to, że nie musimy rozumieć sprzętu (zapewnia warstwę abstrakcji). Wątpię, czy przy nowoczesnych kompilatorach można znaleźć jedną osobę, która rozumie każdy aspekt współczesnego kompilatora C ++. Dlaczego więc rozumienie wyjątków różni się od rozumienia złożonej funkcji X.
Martin York
Zawsze musisz mieć jakieś pojęcie o tym, co robi sprzęt, to kwestia stopnia. Wiele osób korzystających z C ++ (zamiast języka Java lub języka skryptowego) często robi to ze względu na wydajność. Dla nich warstwa abstrakcji powinna być względnie przezroczysta, abyś miał pojęcie o tym, co się dzieje w metalu.
szybowiec
@speedplane: Następnie powinni używać C, w którym warstwa abstrakcji jest znacznie cieńsza zgodnie z projektem.
Martin York,
12

Istnieje wiele sposobów implementacji wyjątków, ale zazwyczaj będą one polegać na pewnym podstawowym wsparciu systemu operacyjnego. W systemie Windows jest to ustrukturyzowany mechanizm obsługi wyjątków.

Istnieje przyzwoita dyskusja na temat szczegółów projektu kodu: jak kompilator C ++ implementuje obsługę wyjątków

Narzut wyjątków występuje, ponieważ kompilator musi wygenerować kod, aby śledzić, które obiekty muszą zostać zniszczone w każdej ramce stosu (lub dokładniej w zakresie), jeśli wyjątek propaguje się poza ten zakres. Jeśli funkcja nie ma zmiennych lokalnych na stosie, które wymagają wywołania destruktorów, nie powinna mieć obniżonej wydajności w związku z obsługą wyjątków.

Użycie kodu powrotu może rozwinąć tylko jeden poziom stosu na raz, podczas gdy mechanizm obsługi wyjątków może przeskoczyć znacznie dalej w dół stosu w jednej operacji, jeśli nie ma nic do zrobienia w pośrednich ramkach stosu.

Rob Walker
źródło
„Narzut wyjątków występuje, ponieważ kompilator musi wygenerować kod, aby śledzić, które obiekty muszą zostać zniszczone w każdej ramce stosu (a dokładniej w zakresie)”. Czy kompilator i tak nie musi tego robić, aby zniszczyć obiekty z powrotu?
Nie. Mając stos z adresami zwrotnymi i tabelą, kompilator może określić, które funkcje znajdują się na stosie. Z tego, jakie przedmioty musiały być na stosie. Można to zrobić po zgłoszeniu wyjątku. Trochę drogie, ale potrzebne tylko wtedy, gdy faktycznie zgłaszany jest wyjątek.
MSalters
przezabawne, zastanawiałem się tylko „czy nie byłoby fajnie, gdyby każda ramka stosu śledziła liczbę obiektów w niej, ich typy, nazwy, aby moja funkcja mogła przekopać stos i zobaczyć, jakie zakresy odziedziczyła podczas debugowania” iw pewnym sensie robi to coś podobnego, ale bez ręcznego deklarowania zawsze tabeli jako pierwszej zmiennej w każdym zakresie.
Dmitry
6

Matt Pietrek napisał doskonały artykuł o obsłudze wyjątków strukturalnych w Win32 . Chociaż ten artykuł został pierwotnie napisany w 1997 r., Nadal obowiązuje (ale oczywiście dotyczy tylko systemu Windows).

Greg Hewgill
źródło
5

W tym artykule przeanalizowano ten problem i zasadniczo stwierdzono, że w praktyce wyjątki są kosztowne w czasie wykonywania, chociaż koszt jest dość niski, jeśli wyjątek nie zostanie zgłoszony. Dobry artykuł, polecam.

Alastair
źródło
2

Mój przyjaciel napisał kilka lat temu, jak Visual C ++ radzi sobie z wyjątkami.

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

Nils Pipenbrinck
źródło
0

Wszystkie dobre odpowiedzi.

Pomyśl także o tym, o ile łatwiej jest debugować kod, który wykonuje „jeśli sprawdza” jako bramy na początku metod, zamiast zezwalać kodowi na zgłaszanie wyjątków.

Moją dewizą jest to, że łatwo jest napisać działający kod. Najważniejsze jest napisanie kodu dla następnej osoby, która go obejrzy. W niektórych przypadkach to Ty za 9 miesięcy i nie chcesz przeklinać swojego imienia!

Kieveli
źródło
Wspólnie się zgadzam, ale w niektórych przypadkach wyjątki mogą uprościć kod. Pomyśl o obsłudze błędów w konstruktorach ... - inne sposoby to a) zwracanie kodów błędów przez parametry odniesienia lub b) ustawianie zmiennych globalnych
Uhli