[Перевод] Коды ошибок — это гораздо медленнее, чем исключения

На современных 64-битных PC-архитектурах использование C++-исключений означает всего лишь добавление к функциям недостижимого кода с вызовами деструктора и ухудшение производительность менее чем на 1%. Такие небольшие ухудшения производительности сложно даже измерить. Обработка редких ошибок с использованием возвращаемых значений требует дополнительных операций ветвления, которые, в реалистичных сценариях, замедляют программы примерно на 5%. Такой подход, кроме того, менее удобен, чем использование исключений. Если выбрасывается исключение, то на «раскрутку» каждого кадра стека тратится примерно 2 мкс.

_guuznudjkjugdp_j3-u1vhn28u.jpeg

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

Но как велико это воздействие на производительность? На большинстве современных 64-битных платформ исключения реализованы так, что системные ресурсы на них практически не тратятся в том случае, если они не выбрасываются. В сгенерированных функциях нет проверок на предмет выброшенных исключений, выполнение кода, при обработке исключения, переключается на специальные функции и специальные данные. Но нельзя сказать о том, что использование исключений вообще никак не сказывается на производительности. Иногда возникает необходимость в обработке ошибок, возникающих очень редко. Один из возможных вариантов решения подобной задачи заключается в полной остановке программы. Из-за этого на диске остаются не полностью сформированные данные, что ведёт к очень неприятным впечатлениям, которые испытывают пользователи соответствующих программ. Например, по такой схеме работают Unreal Engine и Unity Engine. Там неправильное использование API в коде приводит к аварийному завершению работы редактора. Он будет продолжать останавливаться до тех пор, пока вручную не будут удалены некорректные бинарные файлы. Ещё один вариант решения задачи обработки ошибок представляет собой обработку кодов ошибок. При таком подходе функции сообщают о том, что не могут нормально работать. Предполагается, что код, вызывающий такие функции, может адекватно отреагировать на подобные сообщения. Это не очень удобно для программистов, так как требует выполнения дополнительных проверок после возврата из функции, но часто этот подход используется из соображений, связанных с производительностью.

Но как, всё же, эти подходы влияют на производительность кода? Я испытал их на реалистичных примерах, в которых реализованы механизмы, обычно применяемые в играх.

В каких ситуациях не стоит использовать исключения?


«Исключение» — это, как можно судить из названия, нечто такое, что направлено на обработку неких особых, исключительных событий. При возникновении таких событий неприменимы обычные правила той системы, в которой они возникли. В мире программирования это — ситуация, в которой что-то идёт не так, как запланировано, не является частью обычного сценария работы системы. Это — ошибка. Пользователь что-то неправильно ввёл, отказало сетевое соединение, данные оказались повреждёнными, по сети пришёл испорченный пакет, не удалось инициализировать устройство, не получилось найти нужный файл, программист ошибся, составляя текст программы…

Во многих из подобных случаев программа не должна просто полностью останавливаться. Если программа останавливается после того, как пользователь неправильно заполнил какое-нибудь поле, то это очень плохо. Ведь это приводит к потере всех несохранённых данных и к тому, что пользователю приходится перезапускать программу и ждать до того момента, пока она не придёт в работоспособное состояние. Проблемы с сетевыми соединениями хорошо поддаются решению. Обычно в подобных ситуациях программы просто пробуют переподключиться. Если испорченный пакет вызывает «падение» программы, то это — прямая дорога для атаки на эту программу. Такую атаку может совершить любой, способный отправить неправильно сформированный пакет. Такие проблемы можно решить, пользуясь исключениями. Выбрасывание исключений — это медленно, но те части кода, которые не представляют его основной функционал, не обязательно должны быть оптимизированы.

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

В современных 64-битных архитектурах используется модель «zero-cost exceptions» («исключения нулевой стоимости»). При таком подходе обработка ошибок с применением исключений оптимизируется исключительно в расчёте на правильное выполнение программ, когда исключения не выбрасываются. Делается это ценой очень низкой производительности обработки выброшенных исключений.

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

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

Тест №1: парсинг XML-данных


Для проведения этого теста я написал XML-парсер. Я решил написать именно парсер, так как подобная программа может дать сбой во многих ситуациях и не зависит от подсистемы ввода/вывода. Эта программа, определённо, не рассчитана на соответствие стандартам, не гарантирован её отказ при встрече с любыми ошибками. Но она может разбирать обычные конфигурационные XML-файлы и должна завершать работу с ошибкой в большинстве случаев, когда файл синтаксически некорректен. Её код представлен низкоуровневыми конструкциями и должен быть достаточно быстрым (в районе 150 МиБ/с), но я его не оптимизировал и использовал STL-контейнеры для повышения удобства работы с ним (в противоположность применения «прямого» парсинга). Я написал эту программу с множеством проверок #ifdef, применяемых для переключения между вариантами программы, в которых используются исключения, коды ошибок и остановка при возникновении ошибки. Управление этим всем осуществляется с помощью аргументов компилятора. В результате различие между разными вариантами программы будет заключаться лишь в том, какой именно механизм обработки ошибок используется в конкретном варианте.

Я испытал производительность этой программы с использованием XML-файла, имитирующего типичный конфигурационный файл какой-нибудь компьютерной игры. Он имеет размер 32 КиБ и полностью загружается в память перед началом испытания. Процедура парсинга файла повторяется 10000 раз, данные по длительности выполнения этой операции усредняются. Это повторяется 10 раз для того чтобы обеспечить погрешность измерений, не превышающую 1%.

Код скомпилирован с использованием GCC 9 на Ubuntu 20.04. В компьютере, на котором выполнялись измерения, установлен процессор Intel i7–9750H, его максимальная частота в однопоточном режиме составляет 4,5 ГГц. Я запускал все варианты теста один за другим, ничего не делая между запусками. Поступил я именно так для того чтобы добиться одинакового влияния на мои тесты использования кеша другими программами. Но даже при таком подходе в полученных мной данных были такие, которые сильно выбивались из общей картины. Я от них избавился.

Та версия программы, которая останавливалась при возникновении ошибки, была столь же быстрой, как та, в которой использовались исключения. А вот версия, в которой использовались коды ошибок, оказалась на 5% медленнее.

По каким-то причинам, в том случае, когда ошибки обрабатывались специальной функцией, которая выводит сведения об ошибке и завершает программу, это приводило к небольшому (примерно на 1%) падению производительности в сравнении с версией программы, в которой применяются исключения. Мне понадобилось использовать макрос для того чтобы сделать результаты подобных измерений сравнимыми со скоростью кода, использующего исключения. То же самое повторялось и в других тестах.

Тест №2: заполнение структур данных классов разобранными XML-данными


Для этого теста я подготовил несколько классов, которые должны были представлять структуры XML-файла. Я написал код, в котором выполняется заполнение структур данных этих классов с использованием распарсенных XML-структур. Этот код был примерно в 10 раз быстрее, возможно из-за того, что тут было гораздо меньше операций динамического выделения памяти.

Различия в измерениях, выполненных для кода с исключениями и для кода без обработки ошибок, укладываются в допустимую погрешность измерений, но на выполнение кода с исключениями ушло на 0,6% больше времени. А вот вариант программы, в котором использовались коды ошибок, был на 4% медленнее. С похожим замедлением я сталкивался в тех случаях, когда забывал пользоваться семантикой перемещения.

Тест №3: чтение данных из бинарного потока и обработка сообщений разных типов


Этот тест имитирует использование асинхронного API для чтения данных из TCP-сокета (вроде Boost Asio или Unix Sockets). Подобные API используются так: из потока читается некоторый объём данных, потом эти данные обрабатываются, а потом читается новая порция данных. Для повышения скорости работы программы и для того, чтобы в ходе теста приходилось бы передавать меньше информации, данные представлены в бинарном виде. Так как в играх сетевые данные передаются постоянно, ожидание конца потока нецелесообразно.

В моём «API» применяются сообщения трёх типов, указывающие на различные изменения в «игре». Эти сообщения имеют разную длину. Поэтому при обработке данных из потока нельзя точно узнать о том, всё ли сообщение было в нём передано. В результате функция, которая идентифицирует сообщения и вызывает соответствующий код для их парсинга, часто даёт сбои. Происходит это даже в том случае, когда система работает в штатном режиме. Это значит, что для обработки подобных ошибок исключения использовать нельзя. Другие ошибки обрабатываются с помощью исключений (в том варианте программы, где используются исключения). Это — появление сообщений, тип которых определить не удаётся, неверная идентификация объектов, необычно сильные изменения значений (это — либо последствия действий читеров, либо признак повреждения данных)

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

Результаты этого теста похожи на результаты предыдущих испытаний. Код, в котором для обработки использовались исключения, на 0,8% медленнее, чем код, который просто останавливается при возникновении ошибки. Это значение тоже находится в пределах допустимой погрешности измерений. А вот код, в котором применяются коды ошибок, оказался медленнее на 6%.

Результаты


Результаты тестов сведены в следующую таблицу. За 100% приняты результаты тех вариантов кода, которые просто останавливаются при возникновении ошибки.
Допустимая ошибка измерений составляла около 1%. Поэтому те версии кода, которые выбрасывают исключения, могут быть, на самом деле, не медленнее чем те, которые просто останавливаются при возникновении ошибки. Возможно, имеющаяся разница между ними иллюстрирует последствия неких «невидимых» решений компилятора вроде встраивания функций. А вот время, необходимое на выполнение кода, в котором используются коды ошибок, стабильно больше, чем время, необходимое на выполнение кода других вариантов программы.

Исходный код программы можно найти здесь.

Обработка ошибок и чистый код


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

Вот — строка из сектора инициализации конструктора, использованного в тесте №2:

    animation(*source.getChild("animation")),


Она осуществляет перенаправление дочернего XML-тега animation из её аргумента в конструктор члена класса animation. Конструктор может дать сбой из-за некорректного содержимого XML-тега. Сбой может дать функция getChild, что может случиться из-за отсутствия всего тега. Подобные ошибки прерывают создание структуры, они могут помешать и ещё каким-то процессам в коде, находящемся в блоке catch.

Если сведения об ошибках передаются через возвращаемые значения (или через выходные аргументы), то код будет выглядеть примерно так:

std::shared_ptr animationTag;
auto problem = source.getChild("animation", animationTag);
if (problem)
    return problem;
problem = animation.fromTag(animationTag);
if (problem)
    return problem;


Этот код, используя макросы, можно сократить (обычно лямбда-выражения могут заменить макросы, но не в этом случае):

std::shared_ptr animationTag;
PROPAGATE_ERROR(source.getChild("animation", animationTag));
PROPAGATE_ERROR(animation.fromTag(animationTag));


Но даже этот пример, где применяются макросы, скрывающие части, которые повторяются чаще всего, оказывается в три раза длиннее первого однострочного примера.

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

У написания кода таким способом нет преимуществ. Увеличение длины программы усложняет логику. В коде появляются дополнительные объявления, выходные аргументы, макросы. Ошибку можно случайно проглядеть. Этот подход не даёт нормально пользоваться идиомой RAII. И код, в любом случае, из-за большого числа ранних возвратов, должен быть устойчив к исключениям.

Другие результаты


В режиме отладки использование исключений заметно воздействует на производительность. Замедление составляет примерно 2%. Но это всё равно быстрее, чем вариант, в котором используются коды ошибок.

Добавление в код множества дополнительных (ненужных) блоков try тоже плохо сказывается на производительности. Возникает такое ощущение, что за снижение производительности версий тестов, в которых используется обработка исключений, ответственны именно блоки try. Но эксперименты это не подтвердили.

Отключение RTTI при использовании исключений не оказало заметного влияния на производительность блоков try (в этот раз объект исключения, который нужно было перехватить с помощь catch(…), был недоступен, и сообщение об ошибке надо было сохранить в переменной thread_local).

Если же исключения, и правда, выбрасываются, то результаты уже сильно отличаются друг от друга. Обработка исключения занимает примерно 2 микросекунды на каждую функцию, из которой осуществляется выход. Это немного, но эквивалентно замедлению, вызываемому примерно десяти тысячам вызовов функций, в которых используются коды ошибок. В результате использование исключений неэффективно в том случае, если вероятность того, что они будут выброшены, по грубым оценкам, превышает 0,01%. Это не должно оказывать сильного воздействия на код, где используются исключения. Ведь цель разработчика заключается в том, чтобы обеспечить быстрое выполнение программ при их правильном использовании. Хотя программы должны завершаться корректно, нет нужды оптимизировать те их части, которые отвечают за обработку ошибок.

Использование исключений увеличивает размеры исполняемых файлов. Так, размер исполняемого файла тестовой программы, в котором используются исключения, составляет 74,5 КиБ. А файл, в котором исключения не используются, имеет размер 64 КиБ. Если ещё отключить RTTI, то размер файла уменьшается до 54,8 КиБ. Я не изучал вопрос о том, что именно вызывает подобные изменения, но уже само изменение размеров файла говорит о том, что при подготовке исполняемых файлов для встраиваемых платформ, ресурсы которых ограничены, может понадобиться отключить исключения.

Я, кроме того, проанализировал сгенерированный компилятором код с помощью Compiler Explorer. Включение исключений не меняет тела функций (то есть — не вносит в код дополнительного ветвления или дополнительных возвращаемых значений). Но функции оканчиваются блоком кода для обработки исключений, который обычно недостижим (блоки try тоже обычно входят в состав недостижимого кода). Этот код вызывает деструкторы и возвращает управление функции обработки исключений. Этот код, хотя он и не выполняется, занимает кеш (это похоже на ранний возврат из функции). Этот код не генерируется для функций, которые не выделяют память в стеке для чего-либо, использующего деструкторы, или для функций, помеченных как noexcept. В результате очень важный код, сильно влияющий на производительность, которому не нужно обрабатывать ошибки, можно оптимизировать с помощь noexcept. А если он использует что-то такое, что может выбросить исключение, оптимизировать его можно, избегая выделения памяти в стеке под объекты с деструкторами (но я не изучал вопроса о том, быстрее ли C++, используемый как «C с исключениями», чем C).

Итоги


Ошибки удобнее всего обрабатывать с использованием исключений. Я протестировал воздействие разных механизмов обработки ошибок на реалистичных примерах. На 64-битных архитектурах включение использования исключений приводит к замедлению кода примерно на 1% в сравнении с кодом, который просто останавливается при возникновении ошибки. Коды ошибок, обычная альтернатива исключениям, используемая для обработки ошибок, после возникновения которых работу можно продолжить, снижают производительность примерно на 5%. В результате отключение обработки исключений в программах, рассчитанных на PC-архитектуры, не только вызывает неудобства, но и, вероятно, плохо влияет на производительность.

Как вы обрабатываете ошибки в своих C++-проектах?

8xzqbhb0at3_pjylb5c4366w_t8.png

oug5kh6sjydt9llengsiebnp40w.png

© Habrahabr.ru