- PVSM.RU - https://www.pvsm.ru -

Исключения и связанная с ними раскрутка стека – одна из самых приятных методик в C++. Обработка исключений интуитивно понятно согласуется с блочной структурой программы. Внешне, обработка исключений представляется очень логичной и естественной.
Аккуратное использование стековых объектов позволяет создавать очень эффективный и безопасный код, где, в отличие от систем со сборкой мусора, сохраняется локальность ссылок, что дает возможность уменьшить число обращений к системе за памятью, снизить её фрагментацию, более эффективно использовать кэш памяти.
Тем не менее, в C++, исключения традиционно рассматриваются буквально как исключительные ситуации, связанные с восстановлением после ошибок. Трудно сказать, является ли это причиной или следствием того, что реализация обработки исключений компиляторами чрезвычайно дорога. Попробуем разобраться почему.
Как обстоят дела.
Существует два подхода к реализации обработки исключений:
Несколько примеров:
Существует предубеждение, что данный метод является весьма дорогостоящим. Уже в силу того, что на каждый try-блок вызывается setjmp, который недёшев. В самом деле, нужно полностью сохранить состояние процессора, где могут быть десятки регистров. Тогда как на момент возникновения исключения, содержимое большей части этих регистров уже бесполезно. В действительности же, компилятор поступает весьма рационально. Он разворачивает setjmp, причем, сохраняет только полезные регистры (уж эта информация у него есть). Автор сомневается, что издержки на setjmp так уж высоки.
А вот что действительно бросается в глаза, так это объемный вспомогательный код, особенно в нетривиальных случаях. Компилятор, подобно YACC, расписывает все состояния стекового автомата. И, хотя, оптимизатор по — возможности вычищает избыточность и тривиальный код, того, что остается, более чем достаточно.
Концептуально, на каждый адрес кода программы хранится информация о том, как попасть в вышестоящий фрейм вызова. На практике ввиду объемности этой информации, она сжимается, фактически, вычисляется с помощью интерпретации байт-кода. Этот байт-код исполняется при возникновении исключения. Расположено всё это в секциях ".eh_frame" и ".eh_frame_hdr".
Да, помимо всего прочего, DWARF интерпретатор представляет собой отличный backdoor, с помощью которого, подменив байт-код, можно перехватить исключение и отправить его на обработку куда душе угодно.
GCC/DW2 использует практически такую же секцию LSDA, что и GCC/SJLJ.
Как мы видим, издержки, связанные с раскруткой стека (в отсутствие исключений) практически отсутствуют. Однако, стоимость возбуждения исключения велика. Кроме того, нельзя не отметить сильную интеграцию архитектурно-зависимой частью компилятора и достаточно высоко-уровневыми его слоями.
Данному методу присущи те же недостатки, что и SJLJ – обширный вспомогательный код и низкая переносимость.
some_exception exc("oioi");
throw exc;
порождает лишний конструктор копирования / деструктор
throw *new some_exception("oioi");
дает утечку памяти
catch(some_exception exc) ...
опять лишний вызов конструктора и деструктора
catch(const some_exception *exc) ...
исключение пролетит мимо, если не бросить именно указатель
throw some_exception("oioi");
...
catch ([const] some_exception &exc)....
минимум издержек
Подробности можно посмотреть здесь [2], здесь [3] и здесь [4].
А что, если ...
А, казалось бы, всего и дел то – вызвать в нужном порядке деструкторы, тела которых уже существуют. Как же случилось, что простая, в общем-то, задача имеет такие вязкие, тяжеловесные и притом независимо развивавшиеся решения? Трудно сказать, так исторически сложилось.
Попробуем набросать решение, стараясь оставить его простым и по возможности архитектурно-независимым.
struct unw_item_t {
unw_item_t ();
virtual ~unw_item_t ();
void unreg();
unw_item_t *prev_;
};
struct jmp_buf_splice {
jmp_buf_splice ();
~jmp_buf_splice ();
jmp_buf buf_;
jmp_buf_splice *prev_;
unw_item_t objs_;
};
extern int throw_slice (const char *str);
// начало блока
#define TRY_BLOCK {
jmp_buf_splice __sl;
const char *__exc = (const char *)setjmp (__sl.buf_);
if (NULL == __exc) {
...
// что-то вроде catch(…) т.к. мы бросаем только const char*
#define CATCH_BLOCK_FIN
} else {
...
// конец блока
#define FIN_BLOCK
}
}
...
// бросаем исключение
#define THROW_IN_BLOCK(exc)
throw_slice (exc);
...
// перебрасываем исключение наверх, __exc определено в TRY_BLOCK
#define RETHROW_IN_BLOCK
throw_slice (__exc);
static jmp_buf_splice *root_slice_ = NULL;
jmp_buf_splice::jmp_buf_splice ()
{
objs_ = NULL;
prev_ = root_slice_;
root_slice_ = this;
}
jmp_buf_splice::~jmp_buf_splice ()
{
root_slice_ = prev_;
}
Здесь приведен вариант для однопоточной реализации. При наличии нескольких потоков, вместо root_slice_ мы должны будем использовать TLS, аналогично тому, например, как это делает GCC.
unw_item_t::unw_item_t ()
{
if (NULL != root_slice_)
{
prev_ = root_slice_->objs_;
root_slice_->objs_ = this;
}
}
unw_item_t::~unw_item_t ()
{
unreg();
}
unw_item_t::unreg ()
{
if (NULL != root_slice_ &&
(prev_ != reinterpret_cast<unw_item_t *>(~0)))
{
root_slice_->objs_ = prev_;
prev_ = reinterpret_cast<unw_item_t *>(~0);
}
}
static int pop_slice ()
{
jmp_buf_splice *sl = root_slice_;
assert (NULL != sl);
root_slice_ = sl->prev_;
return 0;
}
int throw_slice (const char *str, bool popstate)
{
if (NULL == str)
return -1;
jmp_buf_splice *sl = root_slice_;
unw_item_t *obj = root_slice_->objs_;
while (NULL != obj)
{
unw_item_t *tmp = obj;
obj = obj->prev_;
tmp->~unw_item_t ();
}
if (popstate)
pop_slice ();
longjmp (sl->buf_, int(str));
return 0;
}
template<typename cl>
class deleter_t : public unw_item_t {
public:
deleter_t (cl *obj){ptr_ = obj;};
virtual ~deleter_t () {delete ptr_;};
private:
cl *ptr_;
deleter_t ();
deleter_t (const deleter_t &);
deleter_t &operator= (const deleter_t &);
};
template<typename cl>
class vec_deleter_t : public unw_item_t {
public:
vec_deleter_t (cl *obj){ptr_ = obj;};
virtual ~ vec_deleter_t () {delete [] ptr_;};
private:
cl *ptr_;
vec_deleter_t ();
vec_deleter_t (const vec_deleter_t &);
vec_deleter_t &operator= (const vec_deleter_t &);
};
class _A {
public:
_A():val_(++cnt_){printf ("A::A(%d)n",val_);}
_A(int i):val_(i){printf ("A::A(%d)n",val_);}
virtual ~_A(){printf ("A::~A(%d)n",val_);}
static int cnt_;
};
int _A::cnt_ = 0;
class A : public unw_item_t, _A {};
A a(1);
TRY_BLOCK {
A b(2);
THROW_IN_BLOCK("errorn");
std::cerr << "notreachedn";
}
CATCH_BLOCK_FIN {
std::cerr << __exc;
}
FIN_BLOCK;
A::A(1)
A::A(2)
A::~A(2)
error
A::~A(1)
A a(1);
TRY_BLOCK {
A b(2);
TRY_BLOCK {
A c(3);
THROW_IN_BLOCK("errorn");
std::cerr << "notreachedn";
}
CATCH_BLOCK_FIN {
std::cerr << "." << __exc;
RETHROW_IN_BLOCK;
}
FIN_BLOCK;
std::cerr << "notreachedn";
}
CATCH_BLOCK_FIN {
std::cerr << ".." << __exc;
}
FIN_BLOCK;
A::A(1)
A::A(2)
A::A(3)
A::~A(3)
.error
A::~A(2)
..error
A::~A(1)
TRY_BLOCK {
vec_deleter_t<_A> da(new _A[3]);
TRY_BLOCK {
THROW_IN_BLOCK("errorn");
std::cerr << "notreachedn";
}
CATCH_BLOCK_FIN {
std::cerr << "." << __exc;
RETHROW_IN_BLOCK;
}
FIN_BLOCK;
std::cerr << "notreachedn";
}
CATCH_BLOCK_FIN {
std::cerr << ".." << __exc;
}
FIN_BLOCK;
A::A(1)
A::A(2)
A::A(3)
.error
A::~A(3)
A::~A(2)
A::~A(1)
..error
Ограничения
Такое решение обладает массой недостатков:
#define CATCH_BLOCK_TYPED(t)
} else if (NULL != dynamic_cast<t>(__exc)) {
И это даст нам возможность использовать исключения разных типов. Но тогда невозможно бросать исключения примитивных типов.
И всё же.
Несмотря на описанные ограничения, описанный метод обладает неотъемлемыми достоинствами:
Существует ли возможность устранить недостатки данного метода, сохранив его преимущества? И да, и нет. Пользуясь исключительно средствами C++, это сделать невозможно.
К чему клонит автор.
В порядке технического бреда подумаем, как надо модифицировать компилятор, чтобы корректно реализовать вышеописанную схему?
Чего не хватало в вышеприведенном решении? Знания о том, как был порожден объект.
Например, если объект построен на памяти, выделенной из общей кучи и может мигрировать между потоками, его ни в коем случае нельзя регистрировать в потоко-зависимом стеке. Не стоит нигде регистрировать объект, агрегированный в другой объект.
А с объектом того же типа, но на стековой памяти, это сделать необходимо. Конечно, есть возможность отдать указатель на этот стековый объект в другой поток, но трудно представить, в какой ситуации это могло бы быть полезным.
Итак:
template<class T>
class __st_wrapper : public unw_item_t {
public:
virtual ~__st_wrapper()
{
unreg();
((T*)data_)->T::~T();
};
private:
char data_[sizeof(T)];
};
а так же вызов нужного конструктора T.
Всё же, стоит отметить, использование блока try — это сознательный акт, нет ничего плохого в том, что это несет за собой определенные издержки. IMHO эти (умеренные) издержки даже полезны т.к. стимулируют ответственное отношение к инструментам языка.
Кстати.
PS: Отдельное спасибо Александру Артюшину за содержательное обсуждение.
Автор: zzeng
Источник [5]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/news/52622
Ссылки в тексте:
[1] TIB: http://en.wikipedia.org/wiki/Win32_Thread_Information_Block
[2] здесь: http://www.hexblog.com/wp-content/uploads/2012/06/Recon-2012-Skochinsky-Compiler-Internals.pdf
[3] здесь: http://www.slideshare.net/hackitoergosum/hes2011-james-oakley-and-sergey-bratusexploitingthehardworkingdwarf
[4] здесь: http://www.codeproject.com/Articles/2126/How-a-C-compiler-implements-exception-handling
[5] Источник: http://habrahabr.ru/post/208006/
Нажмите здесь для печати.