Pull to refresh

Что же там такого тяжелого в обработке исключений C++?

Reading time 12 min
Views 70K
image
Исключения и связанная с ними раскрутка стека – одна из самых приятных методик в C++. Обработка исключений интуитивно понятно согласуется с блочной структурой программы. Внешне, обработка исключений представляется очень логичной и естественной.

Аккуратное использование стековых объектов позволяет создавать очень эффективный и безопасный код, где, в отличие от систем со сборкой мусора, сохраняется локальность ссылок, что дает возможность уменьшить число обращений к системе за памятью, снизить её фрагментацию, более эффективно использовать кэш памяти.

Тем не менее, в C++, исключения традиционно рассматриваются буквально как исключительные ситуации, связанные с восстановлением после ошибок. Трудно сказать, является ли это причиной или следствием того, что реализация обработки исключений компиляторами чрезвычайно дорога. Попробуем разобраться почему.

Продолжение здесь.
Как обстоят дела.

Существует два подхода к реализации обработки исключений:
  • Первый можно охарактеризовать словами – “пусть неудачник платит”. Компилятор старается минимизировать издержки в тех случаях, когда исключения не возникают. В идеале, программа не несет никакой дополнительной нагрузки, вся необходимая информация расположена в стороне от кода в некотором удобном для раскрутки виде.
  • Вторая стратегия – “понемногу платят все и всегда". Иными словами, в процессе работы программа несет определенные издержки на поддержание актуальности информации, необходимой для корректной раскрутки стека. При этом, в случае возникновения исключения, раскрутка стека обходится дешевле.

Несколько примеров:
  • GCC/SJLJ. SJLJ есть сокращение от setjmp/longjmp. Относится скорее к первому подходу, но благодаря схеме передачи управления, в нем есть еще и фиксированная плата за каждый try. В сущности, данная реализация вобрала в себя всё худшее, что присуще обоим подходам. Вплоть до четвертой версии, это был основной вариант обработки исключений.
    Как явствует из названия, передача управления осуществляется через вызов longjmp, каждый try порождает вызов setjmp. Соответствующий буфер выделяется в стеке для каждого try блока.
    В начале каждой функции создается пролог, который регистрирует текущий фрейм в стеке контекстов. Аналогично, создается эпилог, удаляющий текущий контекст с вершины стека контекстов. Рядом с каждой функцией создается вспомогательный код для очистки ресурсов.
    Если точнее, компилятор каждый возврат из функции, потенциально способный завершиться исключением, заносит в дерево как ключ, значением является указатель на код очистки, который надо в этом месте предпринять для очистки контекста функции. Линкер собирает куски этих деревьев для каждого модуля линковки в единое дерево проекта (очень грубо). Называется это чудо LSDA (language specific data area) и расположено оно в секции ".gcc_except_table".
    При возникновении исключения, на основании type_info возбуждаемого исключения отыскивается блок, который может это исключение обработать. Начиная с текущего контекста и вплоть до контекста-обработчика (с помощью навигации по фреймам вызовов), извлекаются и выполняются адреса кода, который (в зависимости от областей видимости локальных переменных) надо исполнить именно в этом месте. После чего передается управление.

    Существует предубеждение, что данный метод является весьма дорогостоящим. Уже в силу того, что на каждый try-блок вызывается setjmp, который недёшев. В самом деле, нужно полностью сохранить состояние процессора, где могут быть десятки регистров. Тогда как на момент возникновения исключения, содержимое большей части этих регистров уже бесполезно. В действительности же, компилятор поступает весьма рационально. Он разворачивает setjmp, причем, сохраняет только полезные регистры (уж эта информация у него есть). Автор сомневается, что издержки на setjmp так уж высоки.

    А вот что действительно бросается в глаза, так это объемный вспомогательный код, особенно в нетривиальных случаях. Компилятор, подобно YACC, расписывает все состояния стекового автомата. И, хотя, оптимизатор по — возможности вычищает избыточность и тривиальный код, того, что остается, более чем достаточно.
  • GCC/DW2. Это как раз пример первого подхода к обработке исключений. DW2 означает DWARF2(теперь уже и 3) – формат хранения вспомогательной, в том числе отладочной информации в исполняемом файле. Ведь отладочная информация нужна и для того, чтобы в любой момент можно было узнать значение любой переменной, в том числе и во фреймах предыдущих (верхних) вызовов. Поэтому компилятор в процессе генерации кода откладывает информацию о том, что он выделяет в стеке, в каких регистрах размещает переменные, когда их сохраняет… В действительности, этот формат не идентичен DWARF, хотя и очень близок к нему. Стандартный вариант для четвертой версии GCC.

    Концептуально, на каждый адрес кода программы хранится информация о том, как попасть в вышестоящий фрейм вызова. На практике ввиду объемности этой информации, она сжимается, фактически, вычисляется с помощью интерпретации байт-кода. Этот байт-код исполняется при возникновении исключения. Расположено всё это в секциях ".eh_frame" и ".eh_frame_hdr".
    Да, помимо всего прочего, DWARF интерпретатор представляет собой отличный backdoor, с помощью которого, подменив байт-код, можно перехватить исключение и отправить его на обработку куда душе угодно.
    GCC/DW2 использует практически такую же секцию LSDA, что и GCC/SJLJ.

    Как мы видим, издержки, связанные с раскруткой стека (в отсутствие исключений) практически отсутствуют. Однако, стоимость возбуждения исключения велика. Кроме того, нельзя не отметить сильную интеграцию архитектурно-зависимой частью компилятора и достаточно высоко-уровневыми его слоями.
  • MS VC++. Этот компилятор реализует вторую стратегию обработки.
    • Для каждой функции, которая потенциально может выбрасывать исключения, компилятор создает в качестве стековой переменной структуру из указателя на предыдущую подобную структуру, адреса функции-обработчика и вспомогательных данных. Адрес этой структуры заносится в FS:[0], который является вершиной стека этих структур. Регистр FS в Win32 используется как Thread Information Block (TIB) (GS в Win64). Создается также функция-обработчик (со своим набором данных) и эпилог, который восстанавливает FS:[0] в случае успешного завершения.
    • Компилятор создает таблицу структур – по элементу для каждого try-блока в функции. Каждый try-блок имеет индекс начала и конца в этой таблице (вложенный блок имеет вложенный интервал), соответствующий некоторому состоянию, за актуальностью этого индекса сам компилятор и следит. Таким способом компилятор реализует стек try-блоков.
    • На каждый try-блок заводится таблица catch-блоков. На каждый тип исключения заводится таблица type_info всех базовых классов в иерархии данного типа исключения.
    • Для каждой функции создается unwind таблица, каждый элемент которой содержит указатель на функцию, освобождающую некоторый ресурс и номер предыдущего элемента. В таблице может быть несколько цепочек, в зависимости от областей видимости объектов с деструкторами. В момент исключения, по индексу текущего состояния, который упоминался выше, можно найти необходимую цепочку и вызвать все необходимые деструкторы.
    • Для версии x64 вспомогательные стековые структуры по возможности переносились в .pdata, вероятно, в MS считают первую стратегию более перспективной.
    • При инициирование исключения основная работа проводится операционной системой, куда управление попадает через SYSENTER.
    Данному методу присущи те же недостатки, что и SJLJ – обширный вспомогательный код и низкая переносимость.
  • Процесс возбуждения исключения и выбора подходящего catch блока везде выглядит примерно одинаково:
    • При возбуждении исключения создается его описатель, в котором содержатся копия объекта, его type_info, указатель на деструктор
    • Поднимаясь по стеку try блоков и очищая за собой все зарегистрированные стековые объекты, (навигация по этому стеку везде разная, но суть одна), просматриваем списки catch блоков и ищем подходящий.
    • Если подходящий catch блок найден, объект-исключение становится локальной переменной, вызываем этот блок. Если catch блок принимает исключение по значению, а не ссылке, создастся его копия.
    • Если перевызова исключения не было, убиваем объект — исключение
    • «Хозяйке на заметку»:
      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)....
      
      минимум издержек
Подробности можно посмотреть здесь, здесь и здесь.

А что, если ...
А, казалось бы, всего и дел то – вызвать в нужном порядке деструкторы, тела которых уже существуют. Как же случилось, что простая, в общем-то, задача имеет такие вязкие, тяжеловесные и притом независимо развивавшиеся решения? Трудно сказать, так исторически сложилось.
Попробуем набросать решение, стараясь оставить его простым и по возможности архитектурно-независимым.
  • Первым делом выбираем стратегию — это будет второй вариант.
  • Передача управления – setjmp/longjmp
  • Создаем структуру, все потомки которой обладают способностью само — регистрироваться для возможной раскрутки.
    struct unw_item_t {
        unw_item_t ();
        virtual ~unw_item_t ();
        void unreg();
        unw_item_t  *prev_;  
    };
    
  • И еще одну, областью видимости которой является try-блок
    struct jmp_buf_splice {    
        jmp_buf_splice ();
        ~jmp_buf_splice ();    
        jmp_buf         buf_;    
        jmp_buf_splice *prev_;    
        unw_item_t      objs_;  
    };
    
  • Для простоты, будем бросать только исключения типа const char * с помощью
        extern int throw_slice (const char *str);
    
  • Несколько макросов для имитации try-блока
    // начало блока
    #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); 
    
  • Теперь покажем тела членов класса jmp_buf_splice:
    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::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;
    }
    
  • Сервисный класс – аналог std::auto_ptr:
      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 {};
  • Пример 1
    A a(1);
      TRY_BLOCK {
    	A b(2);
    	THROW_IN_BLOCK("error\n");
          std::cerr << "notreached\n";
      }
      CATCH_BLOCK_FIN {
          std::cerr << __exc;
      }
      FIN_BLOCK;

    A::A(1)
    A::A(2)
    A::~A(2)
    error
    A::~A(1)
  • Пример 2
    A a(1);
      TRY_BLOCK {
    	A b(2);
    	TRY_BLOCK {
    	  A c(3);
    	  THROW_IN_BLOCK("error\n");
    	  std::cerr << "notreached\n";
    	}
    CATCH_BLOCK_FIN {
    	  std::cerr << "." << __exc;
    	  RETHROW_IN_BLOCK;
    	}
    	FIN_BLOCK;
          std::cerr << "notreached\n";
        }
      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)
  • Пример 3
      TRY_BLOCK {
        vec_deleter_t<_A> da(new _A[3]);
        TRY_BLOCK {
    	THROW_IN_BLOCK("error\n");
    	std::cerr << "notreached\n";
        }
        CATCH_BLOCK_FIN {
          std::cerr << "." << __exc;
    	RETHROW_IN_BLOCK;
        }
        FIN_BLOCK;
        std::cerr << "notreached\n";
      }
      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

Ограничения
Такое решение обладает массой недостатков:
  • Нельзя бросать исключения в деструкторе. Деструктор unw_item_t еще не удалил ссылку на данный экземпляр, в результате деструктор будет вызван повторно.
  • Создавать объект наследованного от unw_item_t класса посредством оператора new очень опасно. Даже, если о памяти заботиться самому, такой указатель может попасть в чужой контекст или даже в чужой поток, у объекта, на который он смотрит, могут неожиданно вызвать деструктор, что кончится метаболической катастрофой.
  • Класс, наследованный от unw_item_t, не может быть агрегирован как член другого класса, иначе его деструктор вызовется дважды.
  • Описанный метод невозможно интегрировать с аппаратными исключениями.
  • Ограничения на типы исключений. Выше мы использовали только строковый указатель. Если передавать в качестве исключения примитивные типы, то может быть только один вариант. Если в качестве исключения использовать указатель на объект, то имеем возможность воспользоваться RTTI. Можно предложить что-то вроде
    #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.
  • Статический член класса jmp_buf_splice::root_slice_ реализуется либо через TLS, либо через соответствующий регистр, если есть
  • Программист по прежнему видит только объект типа Т, расположенный в data_
  • У стековых объектов без виртуальных деструкторов, таковой появляется в обертке
  • Бросать исключения в деструкторах теперь можно т.к. перед вызовом собственно деструктора мы разрегистрировались.
  • Не поддерживаем аппаратные исключения (исключения ядра), поэтому на момент возбуждения исключения компилятор знает какие регистры надо «приземлить» и обязан это сделать
  • Для штатного уничтожения стековых объектов компилятор создает вызовы деструкторов __st_wrapper'ов
  • Механизм выбора подходящего catch блока оставляем как есть. Т.е. вспомогательная табличная информация с описателями этих блоков вне кода нам всё-таки потребуется.
  • Передачу управления будем осуществлять с помощью аналога setjmp. Предлагается реализовать промежуточный (по отношению к двум описанным выше) вариант передачи управления. Setjmp обладает существенным недостатком – размер буфера довольно велик, тогда как реально используется его малая часть.
    С другой стороны, исполнение байт-кода в духе DWARF представляется весьма расточительным.
    Поэтому, вместо буфера setjmp будем хранить список регистров, требующих восстановления и сдвиги относительно указателя стека, где лежат актуальные значения. В случае вычисленного значения в регистре хранится непосредственно значение. Для этого в стеке выделяется дополнительная память и отдается сдвиг на нее. Фактически, заводится временная переменная.
    Перед возбуждением исключения компилятор выгружает все актуальные данные из регистров, в этом случае можно восстановиться без потерь.

    Всё же, стоит отметить, использование блока try — это сознательный акт, нет ничего плохого в том, что это несет за собой определенные издержки. IMHO эти (умеренные) издержки даже полезны т.к. стимулируют ответственное отношение к инструментам языка.
  • Перехват исключений при вызове оператора new и new [ ] оставляем как есть. Т.е. каждую итерацию защищаем внутренним try блоком и уничтожаем всё созданное в предыдущих итерациях, если произошло исключение, которое потом перевозбуждаем. И, конечно, отдаем обратно память, выделенную под объект[ы.]
  • Для реализации массива стековых объектов и делать ничего не надо. Но можно сохранить немного памяти, реализовав специальный стековый объект — вектор, аналогичный тому, что используется при вызове оператора new [].


Кстати.
  • Объект может узнать, что он стековый. Для этого его this должен быть в пределах сегмента стека текущего потока.
  • Значит, можно снять объект с крючка? Не представляю, зачем это может понадобиться, но такая возможность существует.
  • Раз можно снять, значит, можно и посадить. Выделить память в стеке через alloca, принудительно вызвать конструктор и подключить к механизму раскрутки стека.
  • Для архитектур с раздельными стеками данных и управления можно реализовать обработку исключений весьма эффективно, используя стек управления вместо списка.


PS: Отдельное спасибо Александру Артюшину за содержательное обсуждение.
Tags:
Hubs:
+89
Comments 38
Comments Comments 38

Articles