ESPNFrom superstar signings to under-the-radar finds: How each of MLB's four remaining teams were builtPunchSupreme Court delivered 411 judgments in one year – CJNThe Jerusalem PostIDF probes suspected negligence after officer killed in Humvee accident in southern LebanonESPN DeportesEx presidente Barça: Que Messi se despidaRTP Desporto12h30 Históricos de Betis e Lens anteveem Porto e SportingBollywood HungamaRian Mukherjee wins Best Child Actor at Los Angeles Film Awards 2026 for Hindi film ManzilZDF heuteEntdecken Sie das ZDF-NachrichtenstudioIl Fatto Quotidiano“Barbara D’Urso porterà a casa circa 200 mila euro complessivi, cioè 15 mila euro a puntata”: ecco il cachet della giurata di “Ballando con le stelle”CBS NewsSaudi airport stays shut as Houthi attacks kill 20 people in a week7sur7Toute attaque contre l’Otan serait “totalement vaine”, assure son chefWirtualna PolskaTusk o policji w szkołach. "Tak, jesteśmy przeczuleni"RTL BoulevardTennisser Van de Zandschulp boekt in Shanghai knappe zege op Fils
The Daily Newsstand · Free, Always
Monday, October 12, 2026

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

Translate

В современном 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 берёт адрес возврата и идёт выяснять, какому загруженному файлу тот принадлежит и что у него написано в таблицах.

И так для каждого кадра, причём стек проходится дважды.

  1. Первый заход — разведка: раскрутчик ползёт вверх и спрашивает, а нет ли тут обработчика.

  2. Нашёл — возвращается вниз и идёт второй раз уже всерьёз: деструкторы, регистры, передача управления в 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.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.