Bagaimana cara kerja pengecualian (di balik layar) di c ++

109

Saya terus melihat orang mengatakan bahwa pengecualian itu lambat, tetapi saya tidak pernah melihat bukti apa pun. Jadi, alih-alih menanyakan apakah ada, saya akan bertanya bagaimana cara kerja pengecualian di belakang layar, sehingga saya dapat membuat keputusan kapan harus menggunakannya dan apakah lambat.

Dari apa yang saya tahu, pengecualian sama dengan melakukan pengembalian beberapa kali, kecuali bahwa itu juga memeriksa setelah setiap pengembalian apakah perlu melakukan yang lain atau berhenti. Bagaimana cara memeriksa kapan harus berhenti kembali? Saya kira ada tumpukan kedua yang menyimpan jenis pengecualian dan lokasi tumpukan, itu kemudian mengembalikan sampai tiba di sana. Saya juga menebak bahwa satu-satunya saat tumpukan kedua ini disentuh adalah pada lemparan dan pada setiap percobaan / tangkapan. AFAICT yang menerapkan perilaku serupa dengan kode pengembalian akan membutuhkan waktu yang sama. Tapi ini semua hanya tebakan, jadi saya ingin tahu apa yang sebenarnya terjadi.

Bagaimana cara kerja pengecualian?

pro-gramer
sumber

Jawaban:

105

Alih-alih menebak-nebak, saya memutuskan untuk benar-benar melihat kode yang dihasilkan dengan sepotong kecil kode C ++ dan instalasi Linux yang agak lama.

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

Saya mengkompilasinya dengan g++ -m32 -W -Wall -O3 -save-temps -c, dan melihat file assembly yang dihasilkan.

    .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

_ZN11MyExceptionD1Evadalah MyException::~MyException(), jadi kompilator memutuskan bahwa ia memerlukan salinan destruktor non-inline.

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

Mengherankan! Tidak ada instruksi tambahan sama sekali di jalur kode normal. Kompiler malah menghasilkan blok kode perbaikan out-of-line ekstra, yang direferensikan melalui tabel di akhir fungsi (yang sebenarnya diletakkan di bagian terpisah dari executable). Semua pekerjaan dilakukan di belakang layar oleh pustaka standar, berdasarkan tabel-tabel _ZTI11MyExceptionini typeinfo for MyException.

Oke, itu sebenarnya bukan kejutan bagi saya, saya sudah tahu bagaimana kompiler ini melakukannya. Melanjutkan dengan keluaran perakitan:

    .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

Di sini kita melihat kode untuk melakukan pengecualian. Meskipun tidak ada biaya tambahan hanya karena pengecualian mungkin dilemparkan, jelas ada banyak biaya tambahan dalam benar-benar melempar dan menangkap pengecualian. Sebagian besar tersembunyi di dalam __cxa_throw, yang harus:

  • Berjalan tumpukan dengan bantuan tabel pengecualian sampai menemukan penangan untuk pengecualian itu.
  • Lepaskan tumpukan hingga mencapai penangan itu.
  • Benar-benar panggil pawang.

Bandingkan itu dengan biaya hanya mengembalikan nilai, dan Anda melihat mengapa pengecualian harus digunakan hanya untuk pengembalian yang luar biasa.

Untuk menyelesaikan, sisa file assembly:

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

Data 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

Bahkan lebih banyak tabel penanganan pengecualian, dan berbagai informasi tambahan.

Jadi, kesimpulannya, setidaknya untuk GCC di Linux: biaya adalah ruang ekstra (untuk penangan dan tabel) terlepas dari apakah pengecualian dilempar atau tidak, ditambah biaya tambahan untuk mengurai tabel dan menjalankan penangan saat pengecualian dilempar. Jika Anda menggunakan pengecualian, bukan kode kesalahan, dan kesalahan jarang terjadi, ini bisa lebih cepat , karena Anda tidak memiliki overhead untuk menguji kesalahan lagi.

Jika Anda menginginkan informasi lebih lanjut, khususnya apa yang dilakukan semua __cxa_fungsi, lihat spesifikasi asli asalnya:

CesarB
sumber
23
Jadi ringkasan. Bukan biaya jika tidak ada pengecualian. Beberapa biaya ketika pengecualian dilemparkan, tetapi pertanyaannya adalah 'Apakah ini biaya lebih besar daripada penggunaan dan pengujian kode kesalahan sepanjang jalan kembali ke kode penanganan kesalahan'.
Martin York
5
Biaya kesalahan memang cenderung lebih besar. Kode pengecualian sangat mungkin masih ada di disk! Karena kode penanganan kesalahan dihapus dari kode normal, perilaku cache dalam kasus non-kesalahan meningkat.
MSalters
Pada beberapa prosesor, seperti ARM, mengembalikan ke alamat delapan byte "ekstra" melewati instruksi "bl" [branch-and-link, juga dikenal sebagai "call"] akan dikenakan biaya yang sama dengan mengembalikan ke alamat segera setelah perintah "bl". Saya bertanya-tanya bagaimana efisiensi hanya dengan memiliki setiap "bl" diikuti dengan alamat dari sebuah "pengecualian masuk" akan dibandingkan dengan pendekatan berbasis tabel, dan apakah ada kompiler yang melakukan hal seperti itu. Bahaya terbesar yang dapat saya lihat adalah bahwa konvensi panggilan yang tidak sesuai dapat menyebabkan perilaku yang aneh.
supercat
2
@supercat: Anda mengotori I-cache dengan kode penanganan pengecualian seperti itu. Ada alasan mengapa kode dan tabel penanganan pengecualian cenderung jauh dari kode normal.
CesarB
1
@ CesarB: Satu kata instruksi setelah setiap panggilan. Tampaknya tidak terlalu keterlaluan, terutama mengingat bahwa teknik untuk penanganan pengecualian hanya menggunakan kode "luar" biasanya mengharuskan kode tersebut mempertahankan penunjuk bingkai yang valid setiap saat (yang dalam beberapa kasus mungkin memerlukan 0 instruksi tambahan, tetapi dalam kasus lain mungkin memerlukan lebih dari satu).
supercat
13

Pengecualian lambat memang benar di masa lalu.
Dalam kebanyakan kompilator modern, hal ini tidak berlaku lagi.

Catatan: Hanya karena kami memiliki pengecualian tidak berarti kami tidak menggunakan kode kesalahan juga. Ketika kesalahan dapat ditangani secara lokal, gunakan kode kesalahan. Ketika kesalahan membutuhkan lebih banyak konteks untuk koreksi, gunakan pengecualian: Saya menulisnya dengan lebih fasih di sini: Prinsip-prinsip apa yang memandu kebijakan penanganan pengecualian Anda?

Biaya kode penanganan pengecualian ketika tidak ada pengecualian yang digunakan praktis nol.

Saat pengecualian dilemparkan, ada beberapa pekerjaan yang dilakukan.
Tetapi Anda harus membandingkan ini dengan biaya pengembalian kode kesalahan dan memeriksa semuanya kembali ke titik di mana kesalahan dapat ditangani. Keduanya lebih memakan waktu untuk menulis dan memelihara.

Juga ada satu gotcha untuk pemula:
Meskipun objek Exception seharusnya kecil, beberapa orang memasukkan banyak barang ke dalamnya. Kemudian Anda memiliki biaya untuk menyalin objek pengecualian. Solusinya ada dua kali lipat:

  • Jangan menaruh barang ekstra dalam pengecualian Anda.
  • Tangkap dengan referensi const.

Menurut pendapat saya, saya berani bertaruh bahwa kode yang sama dengan pengecualian lebih efisien atau setidaknya sebanding dengan kode tanpa pengecualian (tetapi memiliki semua kode tambahan untuk memeriksa hasil kesalahan fungsi). Ingat Anda tidak mendapatkan apa pun secara gratis, kompilator menghasilkan kode yang seharusnya Anda tulis di tempat pertama untuk memeriksa kode kesalahan (dan biasanya kompilator jauh lebih efisien daripada manusia).

Martin York
sumber
1
Saya berani bertaruh bahwa orang ragu menggunakan pengecualian, bukan karena dianggap lambat, tetapi karena mereka tidak tahu bagaimana penerapannya dan apa yang mereka lakukan pada kode Anda. Fakta bahwa mereka tampak seperti sihir menjengkelkan banyak tipe yang mendekati logam.
speedplane
@ speedplane: Kurasa. Tetapi inti dari kompiler adalah agar kita tidak perlu memahami perangkat keras (ini menyediakan lapisan abstraksi). Dengan kompiler modern, saya ragu apakah Anda dapat menemukan satu orang yang memahami setiap aspek kompiler C ++ modern. Jadi, mengapa memahami pengecualian berbeda dengan memahami fitur kompleks X.
Martin York
Anda selalu perlu memiliki gagasan tentang apa yang dilakukan perangkat keras, ini masalah derajat. Banyak yang menggunakan C ++ (melalui Java atau bahasa skrip) sering melakukannya untuk kinerja. Bagi mereka, lapisan abstraksi harus relatif transparan, sehingga Anda memiliki gambaran tentang apa yang terjadi pada logam.
speedplane
@ speedplane: Maka mereka harus menggunakan C dimana lapisan abstraksinya jauh lebih tipis menurut desain.
Martin York
12

Ada beberapa cara untuk menerapkan pengecualian, tetapi biasanya cara tersebut bergantung pada beberapa dukungan mendasar dari OS. Di Windows, ini adalah mekanisme penanganan pengecualian terstruktur.

Ada diskusi yang layak tentang detail pada Proyek Kode: Bagaimana kompilator C ++ mengimplementasikan penanganan pengecualian

Overhead of exception terjadi karena compiler harus menghasilkan kode untuk melacak objek mana yang harus dihancurkan di setiap frame tumpukan (atau lebih tepatnya cakupan) jika pengecualian menyebar keluar dari cakupan itu. Jika suatu fungsi tidak memiliki variabel lokal pada stack yang memerlukan pemanggilan destructors maka ia seharusnya tidak memiliki penanganan pengecualian wrt penalti kinerja.

Menggunakan kode kembali hanya dapat melepas satu tingkat tumpukan pada satu waktu, sedangkan mekanisme penanganan pengecualian dapat melompat lebih jauh ke bawah tumpukan dalam satu operasi jika tidak ada yang bisa dilakukannya dalam bingkai tumpukan menengah.

Rob Walker
sumber
"Overhead of exception terjadi karena compiler harus menghasilkan kode untuk melacak objek mana yang harus dihancurkan di setiap frame tumpukan (atau lebih tepatnya cakupan)" Bukankah kompilator harus melakukan itu untuk menghancurkan objek dari pengembalian?
Tidak. Mengingat tumpukan dengan alamat pengirim dan tabel, kompilator dapat menentukan fungsi mana yang ada di tumpukan. Dari situ, objek mana yang pasti ada di tumpukan. Ini bisa dilakukan setelah pengecualian dilemparkan. Agak mahal, tetapi hanya diperlukan ketika pengecualian benar-benar dilemparkan.
MSalters
Kocaknya, saya hanya bertanya-tanya pada diri sendiri "bukankah akan keren jika setiap frame tumpukan melacak jumlah objek di dalamnya, jenis, nama, sehingga fungsi saya dapat menggali tumpukan dan melihat cakupan apa yang diwarisi selama debugging" , dan dengan cara tertentu, ini melakukan sesuatu seperti itu, tetapi tanpa selalu mendeklarasikan tabel secara manual sebagai variabel pertama dari setiap cakupan.
Dmitry
6

Matt Pietrek menulis artikel yang sangat bagus tentang Win32 Structured Exception Handling . Meskipun artikel ini aslinya ditulis pada tahun 1997, itu masih berlaku sampai sekarang (tetapi tentu saja hanya berlaku untuk Windows).

Greg Hewgill
sumber
5

Artikel ini membahas masalah dan pada dasarnya menemukan bahwa dalam praktiknya ada biaya run-time untuk pengecualian, meskipun biayanya cukup rendah jika pengecualian tidak dilemparkan. Artikel bagus, direkomendasikan.

Alastair
sumber
2

Seorang teman saya menulis sedikit bagaimana Visual C ++ menangani pengecualian beberapa tahun yang lalu.

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

Nils Pipenbrinck
sumber
0

Semua jawaban bagus.

Selain itu, pikirkan tentang betapa lebih mudahnya men-debug kode yang melakukan 'if check' sebagai gerbang di bagian atas metode alih-alih mengizinkan kode untuk melempar pengecualian.

Moto saya adalah mudah untuk menulis kode yang berhasil. Yang paling penting adalah menulis kode untuk orang berikutnya yang melihatnya. Dalam beberapa kasus, ini adalah Anda dalam 9 bulan, dan Anda tidak ingin mengutuk nama Anda!

Kieveli
sumber
Saya setuju kesamaan, tetapi dalam beberapa kasus pengecualian dapat menyederhanakan kode. Pikirkan tentang penanganan kesalahan dalam konstruktor ... - cara lain adalah a) mengembalikan kode kesalahan dengan parameter referensi atau b) mengatur variabel global
Uhli