16 ноября 2010 г.

Исключения C++ и производительность (1)

Существует очень много книг с советами, как лучше программировать на С++, и все они рекомендуют использовать исключения C++ для обработки ошибок. Возможно, есть книги, в которых не рекомендуется использовать исключения, но я таких не встречал. Сейчас как раз дочитываю "Чистый код" Роберта Мартина, и в разделе "Обработка ошибок" опять встретил этот настоятельный совет. Мотивация — использование исключений делает код более чистым и понятным. С такой формулировкой трудно поспорить, но жаль, что авторы таких советов никак не поясняют в своих книгах, в каких случаях использовать исключения, а в каких лучше не использовать.

Я встречал две крайности: разработчики, которые используют исключения везде, и разработчики, которые вообще не используют исключений. В первом случае объяснением было "потому что это круто правильно и объектно-ориентированно", в другом — "потому что они дают слишком большой оверхэд". И я задумался, кто из них прав? И, вообще, какова цена использования исключений в C++? Можно ли как-то управлять этим? Может, стоит выработать для себя какие-то правила?..

Исключения и компилятор

Итак, что удалось выяснить. "Technical Report on C++ Performance" от 2006 года говорит, что на данный момент существует два основных подхода к реализации обработки исключений компиляторами. (На самом деле подходов больше, чем два, но все они так или иначе являются сочетанием этих двух.)

  • динамический ("code" approach) — во время выполнения программы динамически создаются дополнительные структуры данных, используемые для корректной передачи контекста выполнения и отслеживания объектов, подлежащих уничтожению при возникновении исключения. При использовании компилятором такого подхода расходуестся сравнительно небольшое количество дополнительной памяти. Однако, из-за того, что создание и обработка структур выполняется динамически, расходуется довольно приличное количество процессорного времени. Также недостатком такого подхода является необходимость затрат процессорного времени даже если генерирования исключения не происходит.
  • статический ("table" approach) — на этапе компиляции генерируются статические таблицы с информацией о текущем контексте выполнения, перехватчиках исключений и объектах, подлежащих уничтожению. Основное достоинство метода в том, что пока исключение не сгенерировано никаких дополнительных затрат процессорного времени не происходит. А недостаток в том, что размер статических таблиц может быть довольно большой. Кроме этого, из-за большого размера статических таблиц при возникновении исключения может быть потрачено много процессорного времени на его обработку.

Кстати, недостатки данных подходов (накладные расходы по времени и/или размеру памяти) заставили некоторых производителей встроенных систем и систем реального времени полностью отказаться от поддержки исключений. Оно и понятно.

Значит, чтобы не было неожиданностей, нужно в первую очередь выяснить, какой подход реализован в компиляторе. GCC 4.4, например, использует статический подход.

Исключения и стандарт

Стандарт определяет спецификаторы функций throw, с помощью которых можно указывать, будет ли функция генерировать исключения и какого типа. Чем нам это может помочь? Программисту это помогает лучше понимать код, а компилятору — генерировать вспомогательную информацию для обработки исключений.

Например, у меня объявлена функция int doSmthg (). Такое объявление (без спецификатора throw) означает, что эта функция может сгенерировать любое исключение. Независимо от того, действительно ли функция выбрасывает исключения или нет, компилятор сгенерирует дополнительный код, для обработки возможного исключения. Чем больше вызовов таких функций, тем больше дополнительного кода генерируется, и, соответственно, медленнее обрабатывается при работе программы.

Компилятору можно помочь, указав конкретные типы исключений, которые может генерировать функция. Например, так int doSmthg () throw (int, std::bad_exception). Для программиста, эта информация означает, что в блоке catch нужно ловить лишь исключения типа int и std::bad_exception. А для компилятора — это не просто информация, это руководство к действию.

Во-первых, по стандарту компилятор должен гарантировать, что функция не сгенерирует другого исключения, кроме описанных в спецификаторе. Достигается это просто — если при вызове функции будет сгенерировано исключение, не описанное в спецификаторе, то такое исключение будет приравнено к unhandled exception, и произойдет вызов terminate(). Грубо, но такой подход действительно гарантирует, что "нелегитимные" исключения не выйдут за пределы вызываемой функции, а значит вызывающей функции не за чем беспокоиться об обработке каких-то других исключений. (Если дело дошло до вызова terminate(), то для программиста это сигнал, что в архитектуре программы есть проблемы. Компилятор здесь ни в чём не виноват.) Такое поведение достигается генерированием дополнительных перехватчиков исключений в функции doSmthng().

Во-вторых, так как вызывающая функция имеет гарантии, то значит, программист может избавиться от лишних блоков catch в вызывающей функции. Кроме того, компилятор может выполнить оптимизацию и сгенерировать дополнительный код только для обработки исключений указанного типа. Это означает, что размер дополнительного кода уже будет меньше, чем в первом случае, а значит и времени на обработку понадобится меньше.

Если архитектура программы не подразумевает, что функция doSmthg() может генерировать исключения, то тогда нужно указать пустой спецификатор throw при объявлении функции. Вот так: int doSmthg () throw (). Это позволит компилятору вообще не генерировать никакого дополнительного кода при вызове этой функции, а значит не будет и оверхэда. При этом нельзя забывать, что, как и в предыдущем случае, любое исключение, сгенерированное внутри функции doSmthng() приведет к вызову terminate().

Надо заметить, что компиляторы вовсе не обязаны делать оптимизацию генерируемого кода обработки исключений, основанную на спецификаторах throw, однако, многие делают.

Следующий вывод, который можно сделать — нужно указывать спецификаторы throw, для облегчения работы компилятору и себе.

Продолжение: Исключения C++ и производительность (1.1)

7 комментариев:

  1. Спасибо за полезную статью, жду продолжения:) Некоторое время назад пытался проверить утверждение, что в случае, когда в коде функции явно генерируются исключения, компилятору не может ее заинлайнить. Естественно, инлайн оптимизация всегда подразумевает некоторую неопределенность и танцы с бубнами, но после нескольких небольших экспериментов, у меня получилось, что метод:
    inline void doSomething() {
    //something
    throw std::exception();
    }
    , при компиляции gcc 4.3 с исключительно полезным ключиком -Winline, не был заинлайнен. В случае, не генерирующем исключения, успешно инлайнился.

    ОтветитьУдалить
  2. Спасибо. Продолжение уже пишу.

    Что касается инлайн-функций - насколько я знаю явных запретов в стандарте не существует. У меня, например, успешно инлайнится функция, явно вызывающая throw int(1). В том числе и компилятором 4.3.

    ОтветитьУдалить
  3. Спасибо за пост. Надеюсь Вы в следующей части не преминете хотя бы упомянуть "темные стороны" использования спецификации throw, дабы не искушать некоторых :) И ещё, сейчас немного пишу под Symbian, интересная там работа с исключениями, я бы сказал в полу-автоматическом режиме.

    ОтветитьУдалить
  4. Буду Вам только благодарен, если Вы поделитесь информацией про Symbian :) Можно прямо здесь, в комментариях.

    Какие тёмные стороны спецификации throw Вы имеете в виду? Может быть, Вы говорите о статье Саттера? ( http://www.gotw.ca/publications/mill22.htm )

    Вообще, далее я планировал поделиться простенькими измерениями производительности с использованием исключений и без них. И, возможно, опытом использования стандартной библиотеки с отключенной обработкой исключений при компиляции. И пока всё.

    ОтветитьУдалить
  5. В первую очередь я имел ввиду Страуструпа. У него довольно подробно написано про спецификацию исключений, и в живую он их "попинал" :) Но в целях оптимизации все средства хороши. Интересно будет посмотреть на результаты тестов.

    А что касается Symbian'a, то это относится к ранним версиям (до 9), когда поддержка С++ исключений отсутствовала и использовалась архитектура Leave/TRAP. Соответственно не было и спецификации исключений для функций. Вместо этого использовался суффикс "L" в имени функции, которая могла вызвать Leave. Кроме этого использовались суффиксы "LC" и "LD", а так же статический CleanupStack, который можно сравнить с использованием smart pointers.

    ОтветитьУдалить
  6. Akaiten, я, к сожалению, не нашел у Страуструпа упоминания "темных сторон", кроме тех, о которых и Саттер говорил. Но и эти к производительности не имеют отношения...

    ОтветитьУдалить
  7. А нужно ли действительно использовать обработку исключений? Интересные мнения: http://hashcode.ru/questions/140546/c-зачем-нужна-обработка-исключений

    ОтветитьУдалить