[Перевод] Альтернативы исключениям С++ и зачем они нужны

[Перевод] Альтернативы исключениям С++ и зачем они нужны

Современные тенденции в области аппаратного обеспечения ведут к тому, что использование исключений на C++ всё труднее и труднее оправдать. В представленной работе эта проблема иллюстрируется наглядно, даётся её количественная оценка и обсуждаются потенциальные будущие направления исправления исключений. Материалом делимся к старту курса по разработке на С++.


1. Введение

Во многих проектах исключения на C++ по ряду причин избегаются или даже активно отключаются (подробное обсуждение см. в [P0709R4]). И, хотя в C++ исключения — это механизм сообщения об ошибках по умолчанию, печальная реальность такова, что есть все основания их избегать. Имеющаяся тенденция к увеличению числа вычислительных ядер фактически делает исключения неустойчивыми, по крайней мере в их нынешней реализации. Начнём с описания и количественной оценки проблемы, а затем обсудим возможные пути её устранения. Исходный код для всех экспериментов доступен в [ep].

Посмотрим на этот небольшой фрагмент кода:

struct invalid_value {};

void do_sqrt(std::span 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++ две основные проблемы:

  1. Исключения выделяются в динамической памяти из-за наследования и нелокальных конструкций, таких как std::current_exception. Это препятствует проведению базовых оптимизаций, например преобразованию throw в goto, потому что динамически выделяемый объект исключения должен быть виден другими частями программы. И это ведёт к проблемам с бросанием исключений в ситуациях нехватки памяти.

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

Благодарности

Спасибо Эмилю Дочевски и Петру Димову за обратную связь.

А до этого момента мы поможем вам освоить С++ с самого начала или прокачать ваши навыки в профессии, востребованной в любое время:

Выбрать другую востребованную профессию.

Ссылки из статьи
Краткий каталог курсов и профессий
 

Источник

Читайте также