Исключения в C++ бесплатны до первого throw

В современном C++ хватает механизмов, реальная стоимость которых становится заметна только при определённых условиях. Исключения — один из таких случаев.
Фраза «zero‑cost exceptions» кочует из доклада в доклад с начала двухтысячных, и что она вообще значит, уже давно никто не помнит.
В голове она сжимается до «исключения бесплатные» — и понеслось: не нашли запись — кинули, формат не сошёлся — кинули.
Исключение становится обычной веткой логики. Код чистенький, ревью пройдено, нагрузочный тест зелёный.
А обещание‑то честное. Просто оно не про то.
Zero‑cost — это про путь, по которому исключение не летит: за try вы не платите вообще ничем — ни прологом, ни записью в стек, ни лишним переходом. Вся цена переехала на момент броска.
И вот там она не то чтобы маленькая.
Один
throwчерез 128 кадров стоит 26 микросекунд. Это примерно как 26 000 обычных вызовов функций. И это ещё на пустой машине, где исключение летит в гордом одиночестве.
Что именно обещает zero‑cost
Раскрутка работает по таблицам. Компилятор заранее записывает в .eh_frame и .gcc_except_table, как для каждого адреса возврата восстановить регистры, что уничтожить и есть ли тут подходящий catch.
На нормальном проходе по try не исполняется ни одной лишней инструкции: код обработчиков компилятор уносит в холодную секцию, куда на счастливом пути никто не заходит.
Одна функция, двести миллионов вызовов, ни одного броска:
try/catch, не бросаем 2.00 нс на вызов
без try/catch 1.47 нс на вызов
код возврата 1.11 нс на вызов
std::expected 1.54 нс на вызовПолнаносекунды между первыми двумя строками — это не работа раскрутчика, это запрет на инлайн внутри try.
Вторая половина времени начинается с самого throw.
Сперва __cxa_allocate_exception выделяет память под объект исключения, затем UnwindRaiseException берёт адрес возврата и идёт выяснять, какому загруженному файлу тот принадлежит и что у него написано в таблицах.
И так для каждого кадра, причём стек проходится дважды.
Первый заход — разведка: раскрутчик ползёт вверх и спрашивает, а нет ли тут обработчика.
Нашёл — возвращается вниз и идёт второй раз уже всерьёз: деструкторы, регистры, передача управления в landing pad.
Цена растёт вместе со стеком
Проверить легко: шаблонная цепочка из N кадров, на дне бросок, catch наверху.
template <int N> __attribute__((noinline)) int fr(int i) { return fr<N-1>(i) + 1; }
template <> __attribute__((noinline)) int fr<0>(int i) { throw E{i}; }Двести тысяч бросков на каждую глубину:
Кадров между throw и catch | На один бросок |
|---|---|
1 | 1347 нс |
4 | 1921 нс |
16 | 4204 нс |
64 | 13 330 нс |
128 | 26 505 нс |
Базовая цена броска — около 1,3 микросекунды, каждый следующий кадр добавляет примерно 200 наносекунд.
От наличия деструкторов это почти не зависит: найти запись для адреса возврата и проиграть по ней инструкции восстановления регистров раскрутчик обязан для каждого кадра, и дважды.
Прикинем на пальцах.
От точки, где парсер не сошёлся с ожиданиями, до 400-го ответа кадров набирается под сотню: обработчик, роутер, middleware, пул, сериализатор.
Двадцать микросекунд на одну ошибку разбора. Процент такого трафика при десяти тысячах запросов в секунду даёт две миллисекунды процессорного времени. Пустяк.
А вот когда клиент начинает долбиться невалидными запросами и доля ошибок уходит в половину, картина меняется быстро.
// бросок как ветка логики
Config& lookup(const Key& k) {
auto it = registry.find(k);
if (it == registry.end()) throw KeyNotFound{k};
return it->second;
}
// то же результатом: меняется тип возврата, throw уходит
std::expected<std::reference_wrapper<Config>, Error> try_lookup(const Key& k) {
auto it = registry.find(k);
if (it == registry.end()) return std::unexpected(Error::NoKey);
return std::ref(it->second);
}Второй вариант тоже не бесплатный: объект возврата толстеет, проверку надо писать руками, и сама собой через двадцать кадров она не пролетит. Зато цена у неё постоянная.
Выбирают тут по частоте.
Редкое и настоящее исключение (потеря соединения с базой, упавший диск, сломанный конфиг) стоит микросекунды и никому не мешает.
А ошибка валидации в каждом третьем запросе на исключительную ситуацию уже не тянет, и std::expected из C++23 обойдётся в те же полтора наносекунды, что и код возврата.
Фолклор про многопоток протух в феврале 2022
Далее поговорим про многопоточность.
Поищите про исключения и потоки — быстро наткнётесь на совет: в многопоточном сервисе не бросать, там глобальная блокировка.
Совет был верным и опирался на измерения.
В 2022 году появилась P2544R0 с говорящим названием: C++ exceptions are becoming more and more problematic.
Там померили масштабирование по ядрам на двухсокетной машине со 128 ядрами: при доле отказов в 1% один поток проходил тест за 29 мс, а 128 потоков за 1030 мс.
Замедление в тридцать пять раз от добавления ядер. Формулировка: система становится непригодной.
Блокировок тут две, и происхождение у них разное.
Первая сидит в самом раскрутчике: в libgcc был глобальный мьютекс, который защищал таблицы от изменений во время загрузки библиотек, и бумага называет корневой причиной именно его.
Вторая приходит от загрузчика: поиск таблиц шёл через
dl_iterate_phdr, а тот держит блокировку на своём списке на всё время обхода.
Обе берутся на каждый кадр каждого броска в каждом потоке.
Чинили с двух сторон.
Мьютекс внутри libgcc заменили на неблокирующее b‑дерево — оно появилось в GCC 13.
А поиск по загруженным объектам переехал на новую функцию: в glibc 2.35 (февраль 2022) добавили dlfind_object, по сути то же самое, но без блокировки: отсортированный массив и двоичный поиск.
libgcc её подхватил, и проверить это можно одной командой:
$ nm -D /lib/x86_64-linux-gnu/libgcc_s.so.1 | grep dl_find
U _dl_find_object@GLIBC_2.35Раскрутчик LLVM забрал то же в августе 2022: в обсуждении патча приводят цифры на двадцати потоках, было 1103 мс, стало 78 мс.
Результат перемеряли в конце 2024 года на GCC 14.2 и том же EPYC 7713.
При доле отказов 1% получилось 29 мс на одном потоке, 32 мс на 64 и 34 мс на 128.
Проблема закрыта.
На паре ядер картина такая же: при 1% отказов 20 мс в один поток и 20 мс в два, при 10% они же дают 159 и 163 мс.
Всё это держится на одном условии: glibc не старше 2.35.
Версия видна по ldd --version, и заглянуть туда полезно раньше, чем переписывать сервис под коды возврата. По ту сторону от февраля 2022 живёт ещё немало машин с длинным циклом поддержки.
Отдельная история с musl, на котором собрана изрядная часть контейнеров.
Там dlfind_object нет до сих пор: в текущем include/link.h объявлен только dl_iterate_phdr, а предложение добавить интерфейс обсуждали в в феврале 2026 года.
Реализация у musl своя и на глобальный мьютекс не похожа:
for(current = head; current;) {
/* ... заполняем info по текущему объекту ... */
ret = (callback)(&info, sizeof (info), data);
if (ret != 0) break;
pthread_rwlock_rdlock(&lock);
current = current->next;
pthread_rwlock_unlock(&lock);
}Блокировка здесь на чтение и берётся на один шаг по списку, так что очередь из потоков не выстраивается.
Зато список перебирается линейно, и делается это на каждый кадр: где glibc идёт двоичным поиском, musl шагает по загруженным библиотекам, дёргая rwlock на каждой, пока libgcc не скажет «нашёл».
А за скоростью тут прячется вопрос посерьёзнее.
Блокировка glibc вокруг всего обхода — это не только цена, на ней держится корректность.
В libgcc рядом лежит статический кэш на восемь записей, frame_hdr_cache, и собственной синхронизации у него нет: он рассчитывает, что взаимное исключение обеспечит загрузчик.
Плагины, dlopen и конец быстрого пути
Слово «lock‑free» в описании dlfind_object работает при негласном условии: пока никто ничего не загружает.
Появится рядом dlopen, и быстрый путь забуксует.
Восемь крошечных .so, фоновый поток гоняет их по кругу, остальные бросают через три кадра:
1 поток, загрузчик спит 1440 нс на бросок
2 потока, загрузчик спит 728 нс на бросок
1 поток, рядом dlopen/dlclose 3447 нс на бросок
2 потока, рядом dlopen/dlclose 1363 нс на бросокБросок дорожает в два с половиной раза, пока загрузчик занят.
Числа тут это общее время, делённое на все броски всех потоков, отчего строка с двумя потоками и выглядит вдвое дешевле. Масштабирование по потокам никуда не делось.
Больше всех от этого достаётся плагинным архитектурам.
Раз в несколько минут, на перезагрузке очередного модуля, хвост задержек приподнимается, и в профиле торчит раскрутчик.
С корректностью тут сложнее, чем со скоростью.
В документации glibc написано: сам dlfind_object потокобезопасен, но если рядом идёт dlclose, лезть в таблицы закрытого объекта уже небезопасно.
Синхронизировать выгрузку с раскруткой обязано приложение.
Те самые 72 килобайта на старте
Раз уж зашла речь про память под исключения, заглянем в самое её начало.
Любая программа с прилинкованной libstdc++ ещё до main выделяет аварийный пул на случай, когда __cxa_allocate_exception не смог получить память от malloc, а бросать всё равно надо.
На 64-битной машине это 73 728 байт, те самые 72 КиБ, и видно их первым же крупным выделением в трассировке.
Для сервиса пыль, для прошивки на микроконтроллере заметная часть кучи, и начиная с GCC 13 пул подкручивается переменной окружения:
GLIBCXX_TUNABLES=glibcxx.eh_pool.obj_count=0 ./appНоль отключает его совсем.
Сама настройка нужна редко, а вот механика за ней — постоянно: std::bad_alloc оказывается исключением, которому для броска нужна память.
Когда память закончилась, в дело идёт отложнный буфер фиксированного размера, и если исключений в полёте окажется больше, чем он выдержит, программа вызовет std::terminate.
Отключить исключения — значит переписать половину кода
После всего этого руки так и чешутся собрать проект с -fno-exceptions.
Флаг работает, но делает гораздо меньше, чем от него ждут.
Уходит .gcc_except_table с описаниями обработчиков, а .eh_frame остаётся на месте: на x86-64 по умолчанию включён -fasynchronous-unwind-tables, и выключать его нужно отдельно.
Аварийный пул тоже никуда не девается — он живёт в libstdc++, собранной с исключениями.
Зато забирает он кое‑что нужное: конструктор теряет единственный штатный способ сказать «не получилось», и типы с нетривиальной инициализацией переезжают на двухфазную схему с init() и проверкой флага.
А всё, что обещает бросать, из библиотеки не выпадает и продолжает собираться — at(), stoi, std::filesystem. Меняется только реакция на ошибку.
Собираем с -fno-exceptions две строчки, где at() выходит за границу.
terminate called after throwing an instance of 'std::out_of_range'
what(): vector::_M_range_check: __n (which is 100) >= this->size() (which is 2)
AbortedВ сообщении буквально написано, что исключение брошено.
Просто ловить его некому — libstdc++ собрана с исключениями и бросать не перестала, а в вашей единице трансляции обработчика нет и быть не может.
Ошибка, которая была бы штатной и восстановимой, превратилась в
abort.
С new то же самое: он по‑прежнему бросает std::bad_alloc, только теперь это всегда конец процесса.
А если исключение летит сквозь кадр, собранный с -fno-exceptions, деструкторы локальных объектов там молча пропускаются, и исключение поймается, а ресурсы останутся неосвобождёнными.
Сборка без исключений — решение на весь проект вместе с зависимостями, как в ядерных и embedded‑кодовых базах.
Флагом в CMakeLists ради хвостовых задержек так не делают.
Move без noexcept, и вектор тихо копирует
За исключения платит и код, который их вообще не ждёт.
std::vector при расширении обещает гарантию: если посреди переезда элементов в новый буфер что‑то пойдёт не так, вектор останется в прежнем состоянии.
Перемещение, которое может бросить, эту гарантию рушит: часть элементов уже переехала, старый буфер испорчен, откатывать нечем.
Поэтому vector смотрит на std::is_nothrow_move_constructible и, если move‑конструктор не помечен noexcept, спокойно копирует.
struct Buf {
char* p; size_t n;
Buf() : p(new char[16384]), n(16384) {}
~Buf() { delete[] p; }
Buf(const Buf& o) : p(new char[o.n]), n(o.n) { memcpy(p, o.p, n); }
// одна из двух строк, разница в одном слове
Buf(Buf&& o) noexcept : p(o.p), n(o.n) { o.p = nullptr; o.n = 0; } // vector перемещает
// Buf(Buf&& o) : p(o.p), n(o.n) { o.p = nullptr; o.n = 0; } // vector копирует
};Копирующий конструктор здесь по необходимости.
У некопируемого типа move_if_noexcept отдаёт rvalue‑ссылку в любом случае, и вектор перемещает даже бросающим конструктором.
Эффект появляется только там, где у вектора есть выбор.
Считаем вызовы на двух тысячах вставок:
move с noexcept копий 0 перемещений 2047
move без noexcept копий 2047 перемещений 0По времени на шестидесяти тысячах вставок это 558 мс против 860 мс — полтора раза на типе, всё копирование которого сводится к одному memcpy.
А если внутри лежит std::string или вложенный вектор, копия тянет за собой аллокацию на каждый элемент, и разрыв уходит уже в разы.
А выглядит всё безупречно.
Собирается без предупреждений, проходит тесты, на ревью к такому классу не придраться — в одной строчке просто не хватает одного слова.
static_assert(std::is_nothrow_move_constructible_v<Session>);
static_assert(std::is_nothrow_move_assignable_v<Session>);По строчке на каждый тип, который живёт в векторе.
Сборка сломается в ту минуту, когда кто‑то допишет в класс поле с бросающим перемещением.
Сюда же деструкторы, ставшие в C++11 noexcept по умолчанию: бросок оттуда не превращается в исключение, а сразу зовёт std::terminate.

Код может успешно проходить тесты и при этом терять производительность из‑за одной конструкции или настройки компилятора. Чтобы находить такие проблемы до того, как они проявятся под нагрузкой, важно понимать, как работает язык на низком уровне, и уметь проверять свои предположения измерениями.
Разобраться в этих вопросах помогут бесплатные открытые уроки:
20 октября в 20:00. «Магия чисел в C++: как заставить компилятор считать за нас». Записаться
5 ноября в 20:00. «Стенды для нагрузочного тестирования». Записаться
Больше вебинаров для разработчиков — в октябрьском дайджесте OTUS.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.