Обработка ошибок времени выполнения (runtime error) очень важна во многих ситуациях, с которыми мы сталкиваемся при разработке софта — от некорректного пользовательского ввода, до поврежденных сетевых пакетов. Приложение не должно падать, если пользователь вдруг загрузил PNG вместо PDF, или отключил сетевой кабель при обновлении ПО. Пользователь рассчитывает, что программа будет работать, чтобы ни случилось и, либо обрабатывать внештатные ситуации в фоновом режиме, либо предлагать ему выбрать вариант решения проблемы посредством сообщения, отправленного через дружественный интерфейс.
Обработка исключений может оказаться запутанной, сложной задачей, и, что принципиально важно для многих разработчиков С++, она может сильно замедлить работу приложения. Но, как и во многих других случаях, есть несколько способов решения этой проблемы. Далее мы углубимся в процесс обработки исключений на C++, разберемся с его подводными камнями и увидим, как это может повлиять на скорость работы вашего приложения. Кроме того, мы рассмотрим альтернативы, которые можно использовать, чтобы сократить накладные расходы.
В этой статье я не буду призывать вас отказаться от использования исключений полностью. Они должны применяться, но применяться именно тогда, когда этого избежать невозможно: например, как сообщить об ошибке, которая произошла внутри конструктора? Мы в основном будем рассматривать использование исключений для обработки ошибок времени выполнения. Использование тех альтернатив, о которых мы будем говорить, позволит вам разрабатывать более надежные и легко сопровождаемые приложения.
Быстрый тест производительности
Насколько медленнее в С++ работают исключения, по сравнению с обычными механизмами управления ходом выполнения программы?
Исключения явно работают медленнее, чем простые операции break или return. Но давайте узнаем, насколько медленнее!
В примере ниже у нас написана простая функция, которая рандомно генерирует числа и на основе проверки одного сгенерированного числа выдает / не выдает сообщение об ошибке.
Мы тестировали несколько вариантов реализации обработки ошибок:
- Выбрасываем исключение с целочисленным аргументом. Хотя такое не особо применяется на практике, это самый простой вариант использования исключений в С++. Так мы избавляемся от излишней сложности в реализации нашего теста.
- Выбрасываем std::runtime_error, которая может передавать текстовое сообщение. Такой вариант, в отличие от предыдущего, гораздо чаще применяется в реальных проектах. Посмотрим, даст ли ощутимый прирост по накладным затратам второй вариант по сравнению с первым.
- Пустой return.
- Возвращаем int error code в стиле C
Для запуска тестов мы использовали простую библиотеку Google benchmark library. Она многократно запускала каждый тест в цикле. Далее я опишу, как все происходило. Нетерпеливые читатели могут сразу перейти к результатам.
Тестовый код
Наш суперсложный генератор рандомных чисел:
const int randomRange = 2; // будем генерировать число между 0 и 2.
const int errorInt = 0; // каждый раз выдаем ошибку, если число равно 0.
int getRandom() {
return random() % randomRange;
}
Тестовые функции:
// 1.
void exitWithBasicException() {
if (getRandom() == errorInt) {
throw -2;
}
}
// 2.
void exitWithMessageException() {
if (getRandom() == errorInt) {
throw std::runtime_error("Halt! Who goes there?");
}
}
// 3.
void exitWithReturn() {
if (getRandom() == errorInt) {
return;
}
}
// 4.
int exitWithErrorCode() {
if (getRandom() == errorInt) {
return -1;
}
return 0;
}
Все, теперь мы можем использовать Google benchmark library:
// 1.
void BM_exitWithBasicException(benchmark::State& state) {
for (auto _ : state) {
try {
exitWithBasicException();
} catch (int ex) {
// Исключение обработано, переходим к следующей итерации.
}
}
}
// 2.
void BM_exitWithMessageException(benchmark::State& state) {
for (auto _ : state) {
try {
exitWithMessageException();
} catch (const std::runtime_error &ex) {
// Исключение обработано, переходим к следующей итерации
}
}
}
// 3.
void BM_exitWithReturn(benchmark::State& state) {
for (auto _ : state) {
exitWithReturn();
}
}
// 4.
void BM_exitWithErrorCode(benchmark::State& state) {
for (auto _ : state) {
auto err = exitWithErrorCode();
if (err < 0) {
// `handle_error()` … как-нибудь сами
}
}
}
// Добавляем тесты
BENCHMARK(BM_exitWithBasicException);
BENCHMARK(BM_exitWithMessageException);
BENCHMARK(BM_exitWithReturn);
BENCHMARK(BM_exitWithErrorCode);
// Запускаем !
BENCHMARK_MAIN();
Для тех, кто хочет прикоснуться к прекрасному, мы разместили полный код здесь.
Результаты
В консоли мы видим разные результаты работы тестов, в зависимости от параметров компиляции:
Debug -O0:
------------------------------------------------------------------
Benchmark Time CPU Iterations
------------------------------------------------------------------
BM_exitWithBasicException 1407 ns 1407 ns 491232
BM_exitWithMessageException 1605 ns 1605 ns 431393
BM_exitWithReturn 142 ns 142 ns 5172121
BM_exitWithErrorCode 144 ns 143 ns 5069378
Release -O2:
------------------------------------------------------------------
Benchmark Time CPU Iterations
------------------------------------------------------------------
BM_exitWithBasicException 1092 ns 1092 ns 630165
BM_exitWithMessageException 1261 ns 1261 ns 547761
BM_exitWithReturn 10.7 ns 10.7 ns 64519697
BM_exitWithErrorCode 11.5 ns 11.5 ns 62180216
(Запускали на MacBook Pro 2.5GHz i7 2015 года)
Результаты поразительные! Посмотрите на огромный разрыв между скоростью работы кода с использованием исключений и без них (пустой return и errorCode). А с оптимизацией компилятора этот разрыв еще больше!
Бесспорно, это отличный тест. Компилятор, возможно, делает достаточно серьезную оптимизацию кода для тестов 3 и 4, но в любом случае разрыв большой. Таким образом, это позволяет нам оценить накладные расходы при использовании исключений.
Благодаря модели “zero-cost” в большинстве реализаций блоков try в С++ дополнительных накладных расходов нет. А вот catch-блок работает на порядок медленнее. На нашем простом примере можно убедиться, насколько медленно может работать выбрасывание и отлавливание исключений. Даже на таком маленьком стеке вызовов! А с увеличением глубины стека накладные расходы будут линейно расти. Вот почему так важно стараться отлавливать исключение как можно “ближе” к коду, который его выбросил.
Хорошо, мы выяснили, что исключения работают медленно. Тогда, может, хватит это терпеть? Но не все так однозначно.
Почему все продолжают использовать исключения?
О преимуществах исключений хорошо сказано в документе Technical Report on C++ Performance (глава 5.4):
Использование исключений изолирует код с обработкой ошибок от кода, управляющего работой программы в штатном режиме, и в отличие от errorCode-подхода [в стиле С], нельзя игнорировать исключения или забыть о них. Кроме того, автоматическое уничтожение объектов программного стека при выбрасывании исключения помогает избежать утечек памяти или освободить другие ресурсы. При использовании исключений любая найденная проблема не может быть проигнорирована, так как невозможность отловить и обработать исключение всегда приводит к аварийному завершению работы программы.
Еще раз: главная мысль — это невозможность проигнорировать и забыть про исключение. Это делает исключения очень мощным встроенным инструментом С++, способным заменить непростую обработку через error code, оставшуюся нам в наследство от языка С.
Более того, исключения очень полезны в ситуациях, которые напрямую не связаны с работой программы, например, “жесткий диск заполнен” или “сетевой кабель поврежден”. В таких ситуациях исключения — идеальный вариант.
Но как же все-таки лучше поступить с обработкой ошибок, непосредственно связанных с работой программы? В любом случае нам нужен механизм, который бы однозначно указывал разработчику на то, что он должен проверить ошибку, обеспечивал бы его достаточной информацией о ней (если она возникла), передавая ее в виде сообщения или в каком-то другом формате. Похоже, что мы опять возвращаемся к встроенным исключениям, но как раз сейчас и пойдет речь про альтернативное решение.
Expected
Помимо накладных расходов, у исключений есть еще один недостаток: они работают последовательно: исключение нужно отловить и обработать сразу же, как только его выбросили, невозможно отложить это на потом.
Что мы можем поделать с этим?
Оказывается выход есть. Профессор Андрей Александреску за нас придумал специальный класс под названием Expected. Он позволяет создавать объект класса T (если все идет нормально) или объект класса Exception (если возникла ошибка). То есть либо то, либо другое. Или, или.
По сути, это обертка над структурой данных, которая в С++ называется union.
Поэтому мы проиллюстрировали его идею так:
template <class T>
class Expected {
private:
// Наш union: создается объект, либо выдаем ошибку. Оба варианта сразу невозможны
union {
T value;
Exception exception;
};
public:
// Инициализируем объект класса `Expected` объектом класса T, если нет ошибки.
Expected(const T& value) ...
// Инициализируем объект класса `Expected` объектом класса Exception, если что-то пошло не так
Expected(const Exception& ex) ...
// Метод для проверки факта: произошла ошибка или нет
bool hasError() ...
// Экземпляр класса T
T value() ...
// Метод для доступа к информации об ошибке (экземпляр класса Exception)
Exception error() ...
};
Полноценная реализация безусловно будет сложнее. Но главная идея Expected состоит в том, что в одном и том же объекте (Expected& e) к нам могут прийти как данные для штатной работы программы (которые будут иметь заведомо известный нам тип и формат: T& value), так и данные об ошибке (Exception& ex). Поэтому очень легко проверить, что пришло к нам в очередной раз (например, используя метод hasError).
Однако теперь никто не заставит нас обрабатывать исключение сию секунду. У нас не будет вызова throw() и catch-блока. Вместо этого мы можем обратиться к нашему объекту класса Exception тогда, когда нам будет удобно.
Производительности тест для Expected
Напишем аналогичные тесты производительности для нашего нового класса:
// 5. Expected! Testcase 5
Expected<int> exitWithExpected() {
if (getRandom() == errorInt) {
return std::runtime_error("Halt! If you want..."); // ВНИМАНИЕ: return, а не throw!
}
return 0;
}
// Benchmark.
void BM_exitWithExpected(benchmark::State& state) {
for (auto _ : state) {
auto expected = exitWithExpected();
if (expected.hasError()){
// Handle in our own time.
}
// Or we can use the value...
// else {
// doSomethingInteresting(expected.value());
// }
}
}
// Добавляем тест
BENCHMARK(BM_exitWithExpected);
// Запускаем
BENCHMARK_MAIN();
Барабанная дробь!!!
Debug -O0:
------------------------------------------------------------------
Benchmark Time CPU Iterations
------------------------------------------------------------------
BM_exitWithExpected 147 ns 147 ns 4685942
Release -O2:
------------------------------------------------------------------
Benchmark Time CPU Iterations
------------------------------------------------------------------
BM_exitWithExpected 57.5 ns 57.5 ns 11873261
Неплохо! Для нашего std::runtime_error без оптимизации нам удалось сократить время работы с 1605 до 147 наносекунд. С оптимизацией все выглядит еще лучше: падение с 1261 до 57,5 наносекунд. Это более чем в 10 раз быстрее, чем с -O0 и более чем в 20 раз быстрее, чем с -O2.
Так что по сравнению с встроенными исключениями Expected работает в разы быстрее, а также дает нам более гибкий механизм обработки ошибок. Кроме того, он обладает семантической чистотой и избавляет от необходимости жертвовать сообщениями об ошибках и лишать пользователей качественной обратной связи.
Вывод
Исключения не абсолютное зло. А иногда и вовсе добро, так как они чрезвычайно эффективно работают по своему прямому назначению: в исключительных обстоятельствах. Мы начинаем сталкиваться с проблемами только тогда, когда используем их там, где доступны гораздо более эффективные решения.
Наши тесты, несмотря на то, что они довольно просты, показали: можно сильно сократить накладные расходы, если не отлавливать исключения (медленный catch-блок) в тех случаях, когда достаточно просто отправить данные (используя return).
В этой статье мы также кратко познакомились с классом Expected и с тем, как мы можем использовать его для ускорения процесса обработки ошибок. Expected обеспечивает простоту отслеживания хода работы программы, а также позволяет нам быть более гибкими и отправлять сообщения пользователям и разработчикам.
kotbaun
круто
а для Java подобное тестирование планируется?