24 ноября 2010 г.

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

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

Итак, я знаю, что в общем случае при обработке сгенерированного исключения происходят дополнительные затраты процессорного времени. Какие они и чем мне это грозит?

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

В книге Херба Саттера "Exceptional C++" есть пример разработки класса Stack, в котором операция извлечения значения с верхушки стека была реализована примерно так:
T& Stack<T>::Top ()
{
  if (0 == vused_)
  {
    throw "empty stack";
  }

  return data_[vused_ - 1];
}
То есть, если стек пуст, то программа выбросит исключение, чтобы уведомить пользователя (программиста) о том, что в стеке ничего нет. Хорошо, но предположим, что есть некоторый класс, который реализован по принципу pull и регулярно тянет из стека данные до тех пор, пока не получит исключение. Затем стек заполняется, и класс снова тянет данные из него... и так всё время. Конечно, этот пример притянут за уши, но в реальной жизни такие конструкции всё же встречаются.

Интересный момент в том, что соотношение успешных и неуспешных вызовов Top() может быть различным. Например, стек может заполняться одним элементам и затем передаваться на обработку. В этом случае, исключение будет генерироваться в 50% вызовов. Или можно заполнять стек 100 элементами, тогда исключение будет генерироваться всего в 1% вызовов. Хотелось бы знать разницу между такими сценариями.
* * *

Я написал простенькую программку, с помощью которой хочу увидеть зависимость накладных расходов от количества генерируемых исключений. Я знаю, что компилятор GCC использует статический подход, поэтому по идее до вызова исключения производительность программы не должна изменяться.

Программа циклически выполняет вызовы функции bool func(), которая в случае "ошибки" возвращает false или генерирует исключение. В случае, когда "ошибки" нет, функция просто возвращает true. Количество вызовов — MaxNum (у меня равно 10,000,000). Ошибка генерируется с помощью генератора случайных чисел, процент "ошибочных" ситуаций задается в командной строке. Ведется подсчет количества "ошибок", в конце выводится статистика. Для сравнения производительности программы с исключениями и без них добавлен макрос _USE_EXCEPTIONS, который нужно задавать при компиляции.

  1. #include <cstdlib>
  2. #include <iostream>
  3.  
  4. using std::cout;
  5. using std::endl;
  6.  
  7. const size_t MaxNum = 10000000;
  8. float Percent = 0;
  9. float Divisor = 0;
  10.  
  11. bool func ()
  12. #ifdef _USE_EXCEPTIONS
  13. throw (int)
  14. #else
  15. throw ()
  16. #endif
  17. {
  18.   const int x = rand();
  19.   if (x > (RAND_MAX - RAND_MAX/Divisor))
  20.   {
  21. #ifdef _USE_EXCEPTIONS
  22.     throw 1;
  23. #else
  24.     return false;
  25. #endif
  26.   }
  27.  
  28.   return true;
  29. }
  30.  
  31. int main (int argc, char **argv)
  32. {
  33.   Percent = atof(argv[1]);
  34.   Divisor = 100 / Percent;
  35.  
  36.   srand(1);
  37.  
  38.   size_t trueCounter = 0;
  39.   size_t falseCounter = 0;
  40.  
  41.   size_t n = MaxNum;
  42.   while (n--)
  43.   {
  44.     try
  45.     {
  46.       const bool r = func();
  47. #ifndef _USE_EXCEPTIONS
  48.       if (r)
  49. #endif
  50.       {
  51.         trueCounter += 1;
  52.       }
  53. #ifndef _USE_EXCEPTIONS
  54.       else
  55.       {
  56.         falseCounter += 1;
  57.       }
  58. #endif
  59.     }
  60.     catch (int)
  61.     {
  62.       falseCounter += 1;
  63.     }
  64.   }
  65.  
  66.   cout << "false: " << falseCounter << endl;
  67.   cout << "true:  " << trueCounter << endl;
  68.   cout << "percentage ~" << float(falseCounter) / MaxNum * 100 << endl;
  69.   return 0;
  70. }
  71.  

Исходный файл можно взять здесь. Для чистоты эксперимента программа компилируется с включенной оптимизацией. использовался GCC версии 4.4.
Без исключений:
g++ -O3 a.cpp
С исключениями:
g++ -O3 -D_USE_EXCEPTIONS a.cpp

Запуск программы:
./a.out <PERCENT>, где PERCENT — процент ошибки. Программа запускалась для значений: 0.00001 (типа нуль), 0.001, 0.1, 1, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100.

Время выполнения программы представлено в таблице:
Процент ошибки PERCENT (%)Без исключений (сек)С исключениями (сек)
00,1100,110
0.0010,1100,110
0.10,1100,130
10,1100,340
100,1302,430
200,1504,680
300,1706,950
400,1809,380
500,17511,450
600,18013,950
700,17016,150
800,15018,350
900,13020,550
1000,11022,700

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

А вот время выполнения программы с исключениями линейно зависит от количества генерируемых исключений. При отсутствии или малом количестве генерируемых исключений время выполнения соизмеримо с программой, не использующей исключения. Затем при увеличении количества исключений время выполнения программы растет. Это подтверждает утверждение, что при использовании статического метода оверхэд не появляется, пока исключение не сгенерировано. Последняя строка (100%) соответсвует клиническому случаю, когда программа вообще не использует возвращаемые коды ошибок, а управляется только посредством исключений (я своими глазами видел проект с таким кодом).

Аналогичные тесты я запускал и для компилятора cl.exe из Microsoft Visual Studio Express. Вид графиков был аналогичным, разница была лишь в цифрах и соотношениях. Например, для строки 100% разница была не в 200 раз как здесь, а всего в 120. Надо еще добавить, что компилятор от Microsoft использует смесь табличного и динамического подхода, и каждый блок try-catch реализован через работу со стеком. Это привело к тому, что небольшая разница по времени выполнения наблюдалась даже при отсутствии "ошибок".

В общем, разница видна невооруженным взглядом. Даже при 10% "ошибок" разница во времени больше, чем в 20 раз. И дальше разница только увеличивается. Какой вывод можно сделать?

Думаю, ответ прячется в самом названии механизма exceptions — "исключения", то есть события, которые происходят настолько редко, что являются исключениями из правил обычного поведения программы. Если вероятность возникновения некоего события в программе равна 50%, то вряд ли такое событие можно назвать исключением, скорее это часть нормального поведения. А вот, если предполагаемое событие может возникать очень редко, и что самое главное, если возникновение этого события делает невалидным текущий сценарий поведения и приводит к его завершению, то такое событие можно отнести к исключениям. Следовательно, механизм исключений лучше всего использовать для различного рода критических событий, типа нехватки памяти, недоступность каких-то других ресурсов, критические ошибки доступа к ячейкам памяти и т. п.
* * *

Возвращаясь к примеру с классом Stack, можно попытаться оценить частоту возникновения исключения и реализовать тот или иной подход, в зависимости от результатов. Но, думаю, в данном случае не нужно городить огород и достаточно защитить вызов метода Top() дополнительным вызовом метода isEmpty(), который возвращает true, если стек пустой:
Stack stack;
...
if (!stack.isEmpty())
{
  T element = stack.Top();
  ...
}
...
В такая конструкция практически не добавляет оверхэда, но при этом, если вызов Top() сгенерирует исключение, то это будет сигнализировать о том, что в работе программы произошел неожиданный сбой и выполняющийся сценарий должен быть завершен. На мой взгляд, такое использование исключений вполне разумно.

Заключение

Я не думаю, что в ближайшее время подходы к реализации обработки исключений компиляторами существенно изменятся. Подозреваю даже, что подходы примерно одинаковы в реализациях других языков программирования (если это не так, любопытно было бы ознакомиться с альтернативой). Поэтому мое мнение такое: механизм исключений — это нужная и полезная вещь, но использовать её нужно с умом, исходить не только из оценок производительности, но и с точки зрения логики кода.

Закончить можно цитатой из MSDN, которая в данном случае подходит как нельзя кстати:
Because of the nature of exception handling and the extra overhead involved, exceptions should be used only to signal the occurrence of unusual or unanticipated program events. Exception handlers should not be used to redirect the program's normal flow of control. For example, an exception should not be thrown in cases of potential logic or user input errors, such as the overflow of an array boundary. In these cases, simply returning an error code may be simpler and more concise. Judicious use of exception handling constructs makes your program easier to maintain and your code more readable.


UPDATE (15 дек 2010): Вопрос о том, когда нужно и не нужно использовать исключения, также рассматривается в книге Э. Ханта и Д. Томаса "Программист-прагматик", гл. 24 "Случаи, в которых используются исключения".

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

  1. В Python исключения используются довольно часто. Например StopIteration нужен для выхода из цикла

    ОтветитьУдалить
  2. Сергей, если говорить о производительности, то я не думаю, что графики для программы на Python будут какого-то другого вида. Разница будет только в соотношениях, например, не в 200 раз, как для C++, а, скажем, всего в 8.

    ОтветитьУдалить
  3. Спасибо за статью! Сам давно хотел померить.

    ОтветитьУдалить
  4. Программа на C++ не бросающая исключений но с кодом заключенным в try catch работает медленней в сотни раз медленней и в производительных приложениях этого достаточно чтобы считать использование исключений не допустимыми.

    ОтветитьУдалить
    Ответы
    1. Возможно, Вы не читали первую часть статьи - производительность в первую очередь зависит от компилятора, от того, какой код он генерирует. У многих компиляторов, как Вы можете видеть из таблицы, с этим все в порядке.

      Удалить
  5. Добрый день, а не могли бы Вы более подробно описать структуру программы (сделать подробные комментарии). А так же рассказать почему когда я пытаюсь запустить ее в MS Visual Studio 2008 у меня выдается непонятная ошибка, сразу после запуска программы.

    Для связи: gerasimoves@arkaim-soft.ru

    ОтветитьУдалить
    Ответы
    1. Программа приведена здесь полностью. Подробному описанию посвящен целый абзац прямо перед текстом программы.

      Почему у вас ошибка в студии сказать можно будет лишь после того, когда можно будет увидеть текст ошибки.

      Удалить
    2. Debug Assertion Failed!
      Program:....
      File:.../atof.c
      Line: 55
      Expression: nptr != NULL

      вот текст ошибки

      Удалить
    3. "Запуск программы: ./a.out PERCENT, где PERCENT — процент ошибки."

      Удалить