Современные тенденции в области аппаратного обеспечения ведут к тому, что использование исключений на C++ всё труднее и труднее оправдать. В представленной работе эта проблема иллюстрируется наглядно, даётся её количественная оценка и обсуждаются потенциальные будущие направления исправления исключений. Материалом делимся к старту курса по разработке на С++.
1. Введение
Во многих проектах исключения на C++ по ряду причин избегаются или даже активно отключаются (подробное обсуждение см. в [P0709R4]). И, хотя в C++ исключения — это механизм сообщения об ошибках по умолчанию, печальная реальность такова, что есть все основания их избегать. Имеющаяся тенденция к увеличению числа вычислительных ядер фактически делает исключения неустойчивыми, по крайней мере в их нынешней реализации. Начнём с описания и количественной оценки проблемы, а затем обсудим возможные пути её устранения. Исходный код для всех экспериментов доступен в [ep].
Посмотрим на этот небольшой фрагмент кода:
struct invalid_value {};
void do_sqrt(std::span<double> values) {
for (auto& v : values) {
if (v < 0) throw invalid_value{};
v = std::sqrt(v);
}
}
Здесь выполняются довольно дорогостоящие вычисления и, если встречается недопустимое значение, выдаётся исключение. Производительность кода зависит от вероятности возникновения исключения. Чтобы протестировать эту производительность, вызываем код 100 000 раз, используя массив из 100 элементов типа double со значением 1.0. Чтобы вызвать ошибку, мы с определённой вероятностью задаём одно значение этого массива равным –1. Для всей рабочей нагрузки процессор AMD Ryzen 9 5900X выдаёт следующие цифры выполнения (в миллисекундах), в зависимости от числа потоков и процента сбоев:
Потоки |
1 |
2 |
4 |
8 |
12 |
---|---|---|---|---|---|
0,0% сбоев |
19 мс |
19 мс |
19 мс |
19 мс |
19 мс |
0,1% сбоев |
19 мс |
19 мс |
19 мс |
19 мс |
20 мс |
1,0% сбоев |
19 мс |
19 мс |
20 мс |
20 мс |
23 мс |
10% сбоев |
23 мс |
34 мс |
59 мс |
168 мс |
247 мс |
В первом столбце время выполнения увеличивается с ростом процента сбоев, но это увеличение скромное и ожидаемое, ведь исключения связаны с «исключительными» ситуациями, поэтому 10% сбоев — уже довольно высокий показатель. В последнем столбце с 12 потоками увеличение происходит намного раньше, хотя уже при 1% сбоев время выполнения возросло значительно, а при 10% накладные расходы и вовсе неприемлемы.
Эти цифры измерены в системе Linux с использованием gcc 11.2, но аналогичные результаты были с clang 13 и компилятором Microsoft C++ на Windows. Главная причина заключается в том, что с помощью раскрутки захватывается глобальный мьютекс, чтобы защитить раскручивающиеся таблицы от одновременных изменений из общих библиотек. Это чревато катастрофическими последствиями для производительности сегодняшних и будущих машин. Ryzen — это простой процессор для настольных ПК. Когда мы проводим тот же эксперимент на AMD EPYC 7713 с двумя сокетами, 128 ядрами и 256 контекстами выполнения, получаем следующие цифры:
Потоки |
1 |
2 |
4 |
8 |
16 |
32 |
64 |
128 |
---|---|---|---|---|---|---|---|---|
0,0% сбоев |
24 мс |
26 мс |
26 мс |
30 мс |
29 мс |
29 мс |
29 мс |
31 мс |
0,1% сбоев |
29 мс |
29 мс |
29 мс |
29 мс |
30 мс |
30 мс |
31 мс |
105 мс |
1,0% сбоев |
29 мс |
30 мс |
31 мс |
34 мс |
58 мс |
123 мс |
280 мс |
1030 мс |
10% сбоев |
36 мс |
49 мс |
129 мс |
306 мс |
731 мс |
1320 мс |
2703 мс |
6425 мс |
Здесь проблемы с производительностью начинаются уже при 0,1% сбоев, а при 1% или более система становится непригодной. Становится трудно оправдать использование исключений в C++, сложно прогнозировать их производительность, которая сильно падает при высокой конкурентности.
С другой стороны и в отличие от большинства альтернатив, о которых пойдёт речь далее, традиционные исключения на C++ имеют преимущество: у них (почти) нулевые накладные расходы, если сравнивать с полным отсутствием проверки на наличие ошибок, пока не возникает исключение. Мы можем измерить это с помощью фрагмента кода, в котором выполняется огромное количество вызовов функции и немного дополнительной работы в каждом вызове:
struct invalid_value {};
unsigned do_fib(unsigned n, unsigned max_depth) {
if (!max_depth) throw invalid_value();
if (n <= 2) return 1;
return do_fib(n - 2, max_depth - 1) + do_fib(n - 1, max_depth - 1);
}
На Ryzen мы получаем такое время выполнения для 10 000 вызовов с n = 15 (и определённой вероятностью max_depth, равной 13, что вызывает исключение):
Потоки |
1 |
2 |
4 |
8 |
12 |
---|---|---|---|---|---|
0,0% сбоев |
12 мс |
12 мс |
12 мс |
14 мс |
14 мс |
0,1% сбоев |
14 мс |
14 мс |
14 мс |
14 мс |
15 мс |
1,0% сбоев |
14 мс |
14 мс |
14 мс |
15 мс |
15 мс |
10% сбоев |
18 мс |
20 мс |
27 мс |
64 мс |
101 мс |
При использовании исключений C++ результаты аналогичны описанному выше сценарию с sqrt. Мы включаем их здесь, потому что для альтернатив, о которых пойдёт речь далее, сценарий с fib — худший вариант и намного дороже, чем сценарий с sqrt. И снова у нас проблема снижения производительности при увеличении конкурентности.
2. Главная причина
У традиционных исключений на C++ две основные проблемы:
Исключения выделяются в динамической памяти из-за наследования и нелокальных конструкций, таких как std::current_exception. Это препятствует проведению базовых оптимизаций, например преобразованию throw в goto, потому что динамически выделяемый объект исключения должен быть виден другими частями программы. И это ведёт к проблемам с бросанием исключений в ситуациях нехватки памяти.
Раскрутка исключений фактически однопоточная, потому что благодаря табличной логике раскрутки, используемой в современных компиляторах C++, чтобы защитить таблицы от одновременных изменений, захватывается глобальный мьютекс. Это имеет катастрофические последствия для ситуаций с большим количеством вычислительных ядер, делая исключения практически непригодными на таких машинах.
Первая проблема кажется неразрешимой без изменений языка. Есть много конструкций, таких как throw; или current_exception, в которых применяется этот механизм. Обратите внимание: они могут возникать в любой части программы, в частности в любой функции, вызываемой блоком catch, который не является встроенным, поэтому мы обычно не можем просто исключить конструкцию объекта. Вторая проблема потенциально может быть решена с помощью сложной реализации, но она наверняка приведёт к нарушению ABI и потребует тщательной координации всех задействованных компонентов, в том числе общих библиотек.
3. Альтернативы
Появилось довольно много предложений с альтернативами традиционным исключениям. Рассмотрим некоторые из них. Во всех этих подходах решается проблема глобального мьютекса, поэтому производительность многопоточности идентична однопоточной производительности, и мы покажем только однопоточные результаты. Исходный код для просмотра цифр по всей производительности доступен в [ep]. Основная проблема альтернатив: несмотря на то, что они отлично справляются со сценарием sqrt, у большинства из них значительные накладные расходы на производительность для сценария с fib. Поэтому просто заменить традиционные исключения становится затруднительно.
3.1. std::expected
В предложении std::expected [P0323R11] представлен тип варианта, содержащий либо значение, либо объект ошибки, который может использоваться вместо бросания исключения для распространения состояния ошибки в виде возвращаемого значения. Это решает проблему производительности sqrt, но сопряжено со значительными накладными расходами времени выполнения fib:
Процент сбоев |
0,0% |
0,1% |
1,0% |
10% |
---|---|---|---|---|
sqrt |
18 мс |
18 мс |
18 мс |
16 мс |
fib |
63 мс |
63 мс |
63 мс |
63 мс |
Однопоточный код fib с std::expected более чем в 4 раза медленнее, чем при использовании традиционных исключений. Конечно, накладные расходы меньше, когда сама функция более дорогостоящая, как в ситуации sqrt. Тем не менее эти расходы настолько высоки, что std::expected нельзя признать хорошей заменой общего назначения для традиционных исключений.
3.2. boost::LEAF
В другом предложении [P2232R0] вместо передачи возможно сложных объектов ошибок предполагается, что гораздо эффективнее было бы перехватывать объекты по значению, а не по ссылке. При перехвате по значению по месту бросания исключения можно определить принимающий перехват, а затем сразу поместить объект ошибки в память стека, указанную в блоке try/catch. Сама ошибка может быть распространена в виде одного бита. При использовании реализации boost::LEAF такой схемы мы получаем следующие цифры производительности:
Процент сбоев |
0,0% |
0,1% |
1,0% |
10% |
---|---|---|---|---|
sqrt |
18 мс |
18 мс |
18 мс |
16 мс |
fib |
23 мс |
22 мс |
22 мс |
22 мс |
Здесь накладные расходы гораздо меньше, чем у std::expected, но всё равно они есть. По сценарию с fib наблюдается замедление примерно на 60% по сравнению с традиционными исключениями, так что проблема остаётся.
Обратите внимание: LEAF значительно выигрывает от использования здесь -fno-exceptions. При применении исключений в случае с fib требуется 29 мс, даже если не выбрасывается ни одного исключения. Это показывает, что у исключений накладные расходы на самом деле ненулевые. Исключения вызывают накладные расходы из-за пессимизации другого кода.
3.3. Выбрасывание значений
В предложении по бросанию значений [P0709R4] (известном как Herbceptions) предполагается не допускать выбрасывания произвольных исключений, а использовать определённый класс исключений, который можно передать с помощью двух значений регистра. Сам индикатор исключения передаётся с помощью флага состояния процессора при возвращении из функции. Это хорошая идея, но она не реализуется в чистом C++ из-за отсутствия контроля над флагами состояния процессора. Поэтому мы протестировали две альтернативы: одну в качестве упрощения чистого C++, где значение результата без исключений должно быть с наибольшим размером указателя для оптимальной производительности, и одну жёстко заданную реализацию Herbception со встроенным ассемблером.
Вот цифры производительности:
Процент сбоев |
0,0% |
0,1% |
1,0% |
10% |
---|---|---|---|---|
Эмуляция C++ |
||||
sqrt |
18 мс |
18 мс |
18 мс |
16 мс |
fib |
19 мс |
18 мс |
18 мс |
18 мс |
Ассемблер |
||||
sqrt |
18 мс |
18 мс |
18 мс |
16 мс |
fib |
13 мс |
13 мс |
13 мс |
13 мс |
Это уже близко к приемлемой замене традиционных исключений на C++. На удачном пути выполнения, когда не возникает исключений, ещё наблюдается некоторое замедление, но эти накладные расходы невелики (~25% при использовании C++ и ~10% при использовании ассемблера) в сценарии, где почти ничего не делается, кроме вызова других функций. Эта альтернатива превосходит традиционные исключения, если процент сбоев выше. И намного лучше проявляет себя в многопоточных приложениях.
3.4. Исправление традиционных исключений
На самом деле реализация бесконфликтной раскрутки исключений возможна, несмотря на то, что ни в одном из ведущих компиляторов C++ она не реализована. Мы сделали экспериментальную реализацию, в которой поменяли логику исключений gcc, чтобы регистрировать все раскручивающиеся таблицы в B-дереве с помощью связанности оптимистичной блокировки. Это обеспечивает полностью параллельную раскрутку исключений: все разные потоки могут раскручиваться параллельно без какой-либо необходимости в атомарных записях, пока нет одновременных операций из общих библиотек. Открытие/закрытие общей библиотеки приводит к полной блокировке, но она должна происходить редко. С такой структурой данных мы можем выполнять параллельную раскрутку и получать многопоточную производительность, почти идентичную однопоточной, как на 12, так и на 128 ядрах.
Кажется, идеальное решение, но его трудно реализовать на практике. Оно чревато нарушением существующего ABI, и все общие библиотеки компилировались бы с этой новой моделью, иначе раскрутка обрывается. В других альтернативных механизмах ABI тоже нарушается, но там нарушение локально для кода, в котором используются эти новые механизмы. Изменение традиционного механизма раскрутки требует координации всех связанных между собой артефактов кода. Это могло бы произойти только в том случае, если бы в стандарте C++ предписывалось проведение бесконфликтной раскрутки. Но даже тогда внедрить новый ABI — трудная задача.
Не столь радикальным изменением стала бы замена глобального мьютекса на rwlock, но, к сожалению, сделать это тоже нелегко. Раскрутка — это не библиотечная функция в чистом виде, а переход между раскрутчиком и кодом приложения/компилятора, и в имеющимся коде учитывается тот факт, что он защищён глобальной блокировкой. В libgcc манипуляции общим состоянием осуществляются с помощью обратного вызова из dl_iterate_phdr, а переключение на rwlock приводит к гонкам данных. Здесь, конечно, можно внести изменения, но это тоже было бы нарушением ABI.
И нынешнее построение исключений, по сути, неоптимально для эффективных реализаций. Например, хотелось бы выполнить следующее преобразование:
struct ex {};
...
int x;
try {
if (y<0) throw ex{};
x=1;
} catch (const ex&) {
foo();
x=2;
}
=>
int x;
if (y<0) { foo(); x=2; } else x=1;
Но не можем, так как в функции foo() могут ожидать подобные сюрпризы:
void foo() {
if (random() < 10) some_global = std::current_exception();
if (random() < 100) throw;
}
Поэтому исключения всегда доступны глобально и разработка более эффективных реализаций затруднительна. И мы увидели ограничения на практике: даже при раскрутке, полностью свободной от блокировок, мы столкнулись с проблемами масштабируемости на очень большом количестве потоков и высокой частоте ошибок (256 потоков, 10% сбоев). Они были гораздо менее серьёзными, чем при однопоточной раскрутке, тем не менее ясно, что другие части обработки традиционных исключений из-за глобального состояния тоже не масштабируются. А это веский аргумент в пользу механизма исключений, где применяется только локальное состояние.
4. Движемся вперёд
Чтобы оставаться актуальным, нынешний механизм исключений C++ должен измениться. У самых распространённых машин скоро будет 256 и более ядер. Современные реализации не могут с этим справиться. Главный вопрос: какую использовать стратегию смягчения последствий?
Выбрасывание значений [P0709R4] кажется довольно привлекательным: это один из самых быстрых подходов, без блокировок, позволяет преобразовывать throw в goto и не требует глобальной координации всех библиотек. Но не хватает способа интеграции этого механизма в язык, в частности в стандартную библиотеку. Механизм будет включён в том смысле, что придётся выполнять повторную компиляцию исходного кода, чтобы получить механизм выбрасывания значений, но это нормально. Вопрос в том, как добиться совместимости на уровне исходного кода? Механизмы переключения на основе флагов компилятора кажутся опасными с точки зрения правила одного определения (ODR). Поэтому переключение, например, с std:: на std2:: стало бы очень агрессивным изменением. Пока неясно, какова лучшая стратегия. Но что-то должно быть сделано, иначе всё больше и больше людей будут вынуждены использовать -fno-exceptions и прибегать к сделанным на коленке решениям, чтобы избежать проблем с производительностью современных машин.
Благодарности
Спасибо Эмилю Дочевски и Петру Димову за обратную связь.
А до этого момента мы поможем вам освоить С++ с самого начала или прокачать ваши навыки в профессии, востребованной в любое время:
Выбрать другую востребованную профессию.
Ссылки из статьи
[EP] Thomas Neumann. C++ exception performance experiments. URL: https://github.com/neumannt/exceptionperformance
[P0323R11] JF Bastien, Jonathan Wakely, Vicente Botet. std::expected. URL: https://wg21.link/p0323r11
[P0709R4] Herb Sutter. Zero-overhead deterministic exceptions: Throwing values. URL: https://wg21.link/p0709r4
[P2232R0] Emil Dotchevski. Zero-Overhead Deterministic Exceptions: Catching Values. URL: https://wg21.link/p2232r0
Репозиторий: https://github.com/neumannt/exceptionperformance
Краткий каталог курсов и профессий
Data Science и Machine Learning
Python, веб-разработка
Мобильная разработка
Java и C#
От основ — в глубину
А также
Комментарии (35)
kovserg
08.03.2022 23:55+3самых распространённых машин скоро будет 256 и более ядер
Такое количество ядер мигом упрётся огромное количество каналов памяти, которые необходимы что бы обеспечить подвод и отвод данных которые эти ядра должны обрабатывать.napa3um
09.03.2022 04:56+1Для этого (в том числе) наращивают объёмы кэшей и углубляют их иерархию уровней.
ruomserg
09.03.2022 07:52+15Как обычно, проблема не в исключениях, а в голове. Точнее, в архитектуре. Если архитектура спроектирована нормально — в ней не будет ни одного процента, ни даже 0.1% исключений. Потому что исключения — это, блин, исключения! Они сделаны для того чтобы контролируемо завершить программу или поток выполнения с записью телеметрии. Проценты могут быть только тогда, когда нижний слой фигачит исключения (как в примере, если элемент массива отрицателен) — а верхний слой их игнорит и продолжает обработку. На одной из конференций по поводу исключений, было сказано очень правильно: «Перехвативший исключение слой обязан либо принять меры по устранению ПРИЧИНЫ вызвавшей исключение, либо (если он не может или не уполномочен принять такое решение) — передать исключение выше (скорее всего, освободив связанные с ним какие-то ресурсы)».
И даже если вы не уполномочены завершить программу целиком (например это контроллер тормозов какой-то или сервер критичный) — нужно завершать поток выполнения если причина выкидывания исключений неустранима. Тогда есть шанс хотя бы что устройство свалится в какой-то safe-mode или direct-control, или в другую контролируемую деградацию. Или хотя бы у человека лампочка красная высветится об отказе… А если вы стреляете исключениями в 1% случаев выполнения и продолжаете — это уже деградация неконтролируемая и невидимая. И потому — вдвойне опасная. И падение производительности в такой ситуации может оказаться наименьшей из возникающих проблем…myxo
09.03.2022 12:10В при чем тут архитектура? Тут скорее дело в стандартной библиотеке, которая не предоставляет других способов получить ошибку (я помню non throwing варианты функций только в std::filesystem).
И есть много ошибок, для устранения которых не нужно ничего делать, просто вернуть эту ошибку пользователю и продолжить работу. Но кроме исключений стандартная библиотека не дает особо вариантов.
ps. Есть конечно вариант писать код так, чтобы исключений не возникало, ну то есть, например, запретить использовать std::map::at и всегда искать через std::map::find. И так для каждого контейнера. Но без автоматических проверок, такой способ, к сожалению, не скейлится =(
ivfilin
09.03.2022 13:20исключения, как способ сообщить об ошибке сейчас принято как дефолтное поведение
smthWrongWithHumans : public (std::exceptions) { public: smthWrongWithHumans(int count):humanCount(count) {}; ... private: int humanCount{0}; } //где-то в коде без исключений void newShelterPopulationInfoRcv(int humansCount) { sendNewFoodDistribution(TotalFood/humansCount); //что делать если людь закончился } //где-то в коде с проверкой errorCode newShelterPopulationInfoRcv(int humansCount) { if (humansCount = 0) { return errorCode::needNewHumans; } if (humansCount < 0) { return errorCode::needNewHumans & errorCode::FoodAttacs & errorCode::needSweepTeam; } sendNewFoodDistribution(TotalFood/humansCount); return errorCode::JustSmileAndWave; } //а теперь на исключениях void newShelterPopulationInfoRcv(int humansCount) { if (humansCount <= 0) { throw smthWrongWithHumans(humansCount); // пусть братва из вышележащего сервиса разирается return; } sendNewFoodDistribution(TotalFood/humansCount); }
hard2018
09.03.2022 18:52smthWrongWithHumans : public (std::exceptions) { public: smthWrongWithHumans(int count):humanCount(count) {}; ... private: int humanCount{0}; } ... void newShelterPopulationInfoRcv(int humansCount) { if (humansCount <= 0) { throw smthWrongWithHumans(humansCount); // пусть братва из вышележащего сервиса разирается return; } sendNewFoodDistribution(TotalFood/humansCount); }
Зачем шило заворачивать в капусту?
Можно проще, привести ЯВНО переменную к типу int
Вот так
throw (int)humansCount;
Просто укажите, что у вас исключения целочисленного типа.
Я делаю так:
int findtext(const wchar_t* file, const wchar_t* text) { FILE* hfile = nullptr; wchar_t* memptr = nullptr; intptr_t hfind = 0; _finddata_t data; _fsize_t& size = data.size; int retval = 0; try { if ((file == nullptr) && (text == nullptr)) throw (int)EINVAL; hfind = _findfirst(file, &data); if (hfind == 0) throw (int)ENFILE; _findclose(hfind); hfind = 0; memptr = (wchar_t*)malloc(size); if (memptr == nullptr) throw (int)ENOMEM; hfile = fopen(file, "rt"); if (hfile == nullptr) throw (int)EACCES; fread(memptr, sizeof(wchar_t), size, hfile); fclose(hfile); hfile = nullptr; retval = _memfind(memptr, text); } catch (int err) { _set_errno(err); }; if (hfile) { fclose(hfile); hfile = nullptr; }; if (memptr) { memset(memptr, '/0', _msize(memptr)); free(memptr); memptr = nullptr; }; if (hfind) {_findclose(hfind); hfind = 0; }; return retval; }
Такой подход делает код совместимым, т. к. исключения допустимы не везде. Вместо этого мы используем номера ошибок в POSIX. Механизм исключения используется, чтобы передать управление в хвост.
У меня была идея использовать сигналы. Пишем обработчик сигнала, а в самой функции, что я привёл, пишем raise(SIGINT), я не знаю насколько это правильно.
void handler(int signo) { if (signo == SIGINT) perror("Error: "); } int findtext(const wchar_t* file, const wchar_t* text) { FILE* hfile = nullptr; wchar_t* memptr = nullptr; intptr_t hfind = 0; _finddata_t data; _fsize_t& size = data.size; int retval = 0; try { if ((file == nullptr) && (text == nullptr)) throw (int)EINVAL; hfind = _findfirst(file, &data); if (hfind == 0) throw (int)ENFILE; _findclose(hfind); hfind = 0; memptr = (wchar_t*)malloc(size); if (memptr == nullptr) throw (int)ENOMEM; hfile = fopen(file, "rt"); if (hfile == nullptr) throw (int)EACCES; fread(memptr, sizeof(wchar_t), size, hfile); fclose(hfile); hfile = nullptr; retval = _memfind(memptr, text); } catch (int err) { _set_errno(err); raise(SIGINT); }; if (hfile) { fclose(hfile); hfile = nullptr; }; if (memptr) { memset(memptr, '/0', _msize(memptr)); free(memptr); memptr = nullptr; }; if (hfind) {_findclose(hfind); hfind = 0; }; return retval; }
ivfilin
09.03.2022 20:12+2смысл только в том что exceptions - мы всегда делаем от std и всегда можем взять what(). Почему - ну вот так - хочу.
Ну и комментарию - обработка ошибки работы с файлом - может обрабатываться где-то вообще в др. месте.
Ну и пример кода - был больше про абстракцию, что все ошибки через исключения и отлавливаются они хз где.
svpcom
11.03.2022 02:00if ((file == nullptr) && (text == nullptr)) throw (int)EINVAL;
А тут точно должно быть "И" а не "ИЛИ" ?
amarao
09.03.2022 16:26+1Ну, тут традиционный водораздел проходит в том, где находится зона контроля - в момент эксплуатции или в момент передачи в эксплуатацию. Обе стороны имеют аргументы, и де-факто одна сторона никогда не победит.
git-merge
09.03.2022 17:52Если архитектура спроектирована нормально — в ней не будет ни одного
процента, ни даже 0.1% исключений. Потому что исключения — это, блин,
исключения!Вот возьмём golang.
(псевдокод)
result, error = foo()
if error {
return error
}
Примерно такой паттерн (или надстройки над ним) чуть менее чем везде.
Исключений - 0% (ну их нет в синтаксисе языка)
Однако если мы этот кодовый паттерн завернём в синтаксис
try catch
, то что изменится? Ассемблерный или байткод останется тем же самым.
Система продолжит оставаться "спроектированной нормально" или перестанет быть таковой?rsashka
09.03.2022 18:34+1Это плохой пример.
Исключения позволяют вообще не анализировать код возврата из функции, т.к. оно будет проброшено через все вложенные вызовы сразу до блока try ... catch включая функции не возвращающие значения.
kovserg
09.03.2022 21:58Исключения позволяют быть оригинальным и посмотрев на сигнатуру метода можно долго недоумевать какое исключение может прилететь из недр особенно в следующих версиях кода. Более того можно выбрасывать в качестве исключение вообще что угодно, даже то что не ясно как потом ловить.
int main(int argc, char const *argv[]) { try { throw [](){}; } catch(...) { return 1; } return 0; }
git-merge
09.03.2022 23:23+1Да, плохой код можно писать на любом языке.
result, error
парадигма тоже часто толкает разработчиков к обобщённым типамerror
. Возврат обобщённого типаerror
мало чем отличается от трёх точек.kovserg
11.03.2022 12:38Вообще исключения тоже вносят много хаоса и толкают разработчиков в разные стороны.
Например вы передали в код поток, и внутри работает с ним пытаетесь ловить исключения и всё вроде хорошо, пока вам не передали сетевой поток который умеет кидать другие исключения. И они кидаются глубоко внутри кода.
Отсюда вытекает что не реализация должна обрабатывать исключения, а тот кто её запустил и знает какие сущности ей были переданы.void code(Context *ctx,Input *input) { try { ... } catch(OverrideCase &e) { ... } catch(...) { ctx->handle(); } ... inner_function(ctx,...); ... try { ... } catch(OverrideCase &e) { ... } catch(...) { ctx->handle(); } } void Context::handle() { try { throw; } catch(Case1 &e1) { return; } // ignored case catch(Case2 &e1) { throw TerminateCase(e1); } // convert exception type ... catch(CaseN &eN) { ... } catch(...) { handle_runtime_updated_cases(); } }
А еще лучше это всё в лямбды завернуть
Правда выглядит, конечно, не очень.void code(Context *ctx,Input *inp) { ctx->crutial("initialize",[=]() { ... ctx->optional("set hints",[=]() { ... }); ctx->repeat_on_fail("try to connect",[=](int iteration) { ... }); ... }); }
git-merge
09.03.2022 23:21Это плохой пример.
Давайте разбираться
Исключения позволяют вообще не анализировать код возврата из функции
Вышеприведённый псевдокод разве где-то анализирует код возврата из функции? Нет.
Алгоритм такой: если функция вернула ошибку - транслировать её вверх по стеку пока не найдётся заинтересованный и обработает её.
Если допустить что ВСЕ функции пишутся в таком паттерне (а программы на Golang очень часто приходят к этой парадигме), то в чём будет отличие от Exception? Только в семантике.
Теоретически уже сейчас можно написать препроцессор для golang, который эти конструкции заменит на обычный try catch. И, уверен, что если кто-то такой препроцессор напишет, то программисты на Golang будут очень счастливы. Ибо этот постоянный геморрой с их плеч переложится на плечи компилятораlorc
10.03.2022 10:19+2Дело в том, что в рантайме как раз не исполняется код типа if(error). И вверх по стеку ничего не транслируется.
В случае выброса исключения специальный код на низком уровне начинает анализировать стек-фреймы в поисках места куда можно передать управление для обработки исключения. Заодно ещё и удаляются все ресурсы, которые были аллоцированы в пройденых стек-фреймах.
ruomserg
10.03.2022 07:32+1Так, судя по-всему с предыдущей веткой обсуждения есть некоторое непонимание. Когда писалось про «нет ни одного, ни 0.1% исключений» — это не значит, что мы от исключений отказываемся и переходим на проверку return-values. Нет — это означает что люди, которые дизайнили механизм исключений в языке и рантайме, делали это под социальный контракт: «Выброс исключения в программе — это настолько редкое явление, что возможным падением производительности можно пренебречь». Я помню еще первую большую книгу Страуструпа, в которой вводился механизм исключений. Там говорилось, что это теперь будет такой удобный механизм сообщения о том, что в коде что-то пошло сильно не так. И давайте разбираться, откуда в коде может что-то пойти не так — и когда мы будем кидать исключения?
— Пользовательский ввод. Мы пытаемся с ним что-то сделать, и возникает ошибка. Поскольку «можно сделать защиту от дурака, но только от неизобретательного» — ошибки могут быть любые, и исключения тут совершенно к месту. Однако, никакого влияния на производительность не будет, потому что пользователь — по сравнению с компьютером — совершенный тормоз, и исключения по его вине будут происходить от нескольких раз в секунду до одного раза в десятки секунд. Для современного ПК — это на уровне погрешности.
— Некорректные показания датчика. Например, мы извлекаем квадратный корень из расстояния, которое мы получаем от одометра. Внезапно расстояние становится отрицательным. Это, собственно то, на что я спроецировал пример в статье. Тут можно генерировать бесконечное число исключений — но проблема должна быть решена архитектурно. Если датчик несколько раз прислал недостоверные (невозможные) значения — он должен быть отключен. Это — базовый принцип безопасной деградации системы. Работа по недостоверным значения датчика — так себе идея…
— Внешние события (обычно, это сеть). Тут тоже есть где и как накидать себе исключений до потери производительности. Но и это тоже вопрос архитектуры. Если у вас из соединения лезет мусор, который нельзя обработать — нужно либо уведомлять вышележащий уровень (пусть скорость понизит, или еще что), либо рвать соединение через несколько исключений. При этом есть надежда, что всплеск реконнектов увидится на сетевом оборудовании, сгенерируется алерт или хотя бы случится троттлинг (ddos protection). В любом случае, это не та ситуация, когда у нас из всех потоков шарашат исключения, а мы несмотря на это — продолжаем.
Какие выводы? Если накладные расходы связанные с обработкой исключений начинают существенно ограничивать производительность приложения в целом — необходимо переработать архитектуру приложения. В нем явно что-то не так! Иногда архитектуру переработать нельзя по внешним причинам — и тогда просто не нужно использовать в критичных местах механизм исключений (прямые вызовы libc никто не отменял!).
Собственно, Страуструп предупреждал, что механизм исключений не имеет гарантированной производительности…
Qualab
10.03.2022 10:49Ну вот случилось исключение: кончилась память, кончился хард, перестал писаться лог - какая мне разница как при этом просела производительность.
Надо выйти, починить и перезапустить методы, либо завершить работу.
Куда лучше чем альтернативы типа проверять после каждой функции всё ли везде хорошо, при отключенных отключениях (либо вообще грохнуться).
git-merge
Я не очень понял про проблемы многопоточности.
Ну будет у нас 100500 ядер. Но в любом случае исключение, выброшенное ядром 123, будет ловиться кодом ядра 123. А между ядрами какой-то механизм междуядерного взаимодействия.
А зачем требуется разбирать исключения в других тредах?
vlad1q
Думаю это зависит от задачи, но пойманное исключение в конкретном потоке можно записать в расшаренный std::excertion_ptr и в ожидающем потоке прокинуть его через std::rethrow_exception
git-merge
Погодите, я пока всё ещё не понимаю.
(я программировал на C, а с C++ знаком постольку-поскольку).
Итак происходит исключение. Оно транслируется вверх по стеку вызовов. Всё это происходит в потоке.
"Поймать" - это пока ещё дело треда
"и записать в расшаренный std::что-то там" - это уже новая задача межтредовой коммуникации.
Если задачи ловли и задачи межтредовой коммуникации разнесены, то зачем нужны мютексы/локи при генерации и ловле исключений?
mentin
Разбор всегда в одном потоке. Но он использует глобальные таблицы адресов, которые могут меняться в момент загрузки или выгрузки динамических библиотек (другими потоками). Чтобы это не вызывало проблем, текущая реализация берет глобальный лок, из-за чего исключения на всех потоках сериализуются - исключения в других потоках тормозят обработку исключений вашим.
Исправление текущей ситуации о котором они пишут - заменить глобальный лок на read-write lock, брать его на запись только при операциях с shared library, но это ломает ABI.
git-merge
"глобальные таблицы адресов"
при размотке стека
foo(bar(baz()))
кто-то параллельно может изменить позицию функцииbar
?или что-то другое имеется ввиду?
mentin
Имеется в виду, что в структуре данных где мы держим foo, bar, и baz, могут появляться или исчезать элементы. Наши foo, bar, и baz не меняются, но структура данных - меняется. Тупой пример: у вас есть std::vector<>, добавление элементов в него может привести к релокации данных, ломая итераторы в других потоках. Это надо защищать синхронизацией, текущая реализация везде довольно примитивная.
git-merge
Эм, я всё равно не понимаю связи с тредами.
foo-bar-baz могут по дороге распространения ошибки, конечно, накапливать std::verctor каких-то связанных с ошибкой данных.
Но кажется, что
поменять положение в памяти они не могут (иначе указатели на функции пошли бы лесом)
Даже в очень сложных программах глубины стеков почти никогда не достигают нескольких десятков (рекурсивные функции пока не рассматриваем). Соответственно для распространения ошибок вполне можно использовать предаллоцированный объект. Этот объект будет аллоцироваться per-thread.
И снова получается при выбрасывании исключения блокировки не нужны.
Но зачем-то их делают, зачем?
mentin
Вам стоит самые базы синхронизации почитать где-нибудь. Представьте используете вы глобальный массив
arr
известных адресов функций, и пишите вы код для поиска в нем вроде:for (int i = 0; i < arr.size(); ++i) {
auto* p = arr[i];
...
}
И вот в середине этого цикла другой поток скажем удаляет элемент из массива, после того как
i
мы уже сравнили с длиной массива, но до того как прочиталиarr[i]
- мы можем прочитать за границей массива. Или мы прочитали первые 10 элементов, а элементы с 5 по 10 были только что удалены, на их место попали элементы с 11 по 16, а мы эти индексы уже пропустили и эти элементы не увидим. Или другой поток добавляет что-то,arr
уже реалоцировали в новое место, но в него еще не скопировали старые данные - просто прочитаем мусор. Да что угодно может быть без синхронизации.git-merge
А где такой код может встретиться-то?
foo(bar(baz()))
когда вызывается foo в массив
arr
поместили её адрес.когда вызываетс bar в массив
arr
поместили её адреси так далее.
Когда разматывается стек при ошибке, то массив
arr
из данного треда менять некому
из соседних тредов менять незачем
PS: когда человек говорит "вам стоит почитать азы", я предполагаю что он их сам почитал и может объяснить. :)
mentin
Исключения не должны добавлять никакого оверхеда вообще если исключения не кидаются. Поэтому никакого локального
arr
на поток нет, при вызовах никуда не помещают никаких адресов, кроме обычного стека вызовов.Есть глобальная таблица всех функций, которые в принципе участвуют в раскрутке стека, и при раскрутке адреса из стека ищутся в этой таблице. Вот эту глобальную таблицу и надо защищать.
git-merge
Это просто один из взглядов.
Во многих языках (в том числе и C++) имеется некоторый оверхед даже на просто вызов функции. А так же взгляды на то, кто чистит память аргументов, например.
Вот в приведённом выше примере обработки ошибок Golang-стиля исключения вообще не кидаются, а оверхед есть.
Организация доступа к этой таблице - вопрос межтредового взаимодействия, а не обработки исключений. Локи - просто один из способов такого взаимодействия.
Кто мешает, например, держать столько копий этой таблицы сколько есть тредов? Либо сделать (иной) lockfree доступ к этой таблице?
myxo
В принципе можно было бы сделать включение этого режима через какой-то флаг и переправить ответственность за abi на клиентов, тем кому это реально нужно.
git-merge
А я ещё один вопрос не понимаю.
вот код, выбросил исключение
код, его вызывающий его поймал и обработал
в каком месте тут нужна таблица всех функций приложения?
Если преобразования адреса-имена функций - не нужны (стек печатать мы не хотим), зачем нам эта таблица?
myxo
Честно сказать не знаю. Я как-то пытался разобраться как именно работает механизм исключений, но там такая кроличья нора (точнее несколько нор для каждой платформы/компилятора), что соваться не слишком хочется. Да и не нужно это обычно. Но могу попробовать порассуждать.
Ключевой вопрос тут: а как вы найдете где находится обработчик исключения? Причем место обработчика нужно знать уже в моменте выброса исключения (иначе может статься, что обработчика нет, а мы уже весь стек до __start() раскрутили и потеряли весь контекст где произошла ошибка. И все дампы теперь бесполезны). Тут нужны какие-то таблицы сопоставляющие адреса функций и обработчики. И при раскрутке стека ходить по этим таблицам. А эти таблицы как раз могут меняться в добавлением / удалением функций (загрузке / выгрузке шареных либ, а может ещё в каких-то случаях).
Ещё есть таблицы «всех функций» при компилировании c -fomit-frame-pointer. Иначе без таблицы просто нельзя будет получить текущий стек (ну ладно, можно анализируя код, но это будет очень долго). Они, возможно, тоже блокируются при подобных операциях.
AlanDrakes
Ядром - да, но тут мне пришла аналогия из Пиратов Карибского Моря.
"Всем ни с места! Я обронил мозги."
И что-то похожее происходит во время исключений в... да практически во всех ЯП.
domix32
Если исключения случились в других потоках, то часть тредов повисает в ожидании анвиндинга из-за глобального лока, как минимум. И речь не о разборе, а именно о бросании исключения.