Как стать автором
Обновить

Комментарии 40

Я не очень понял про проблемы многопоточности.

Ну будет у нас 100500 ядер. Но в любом случае исключение, выброшенное ядром 123, будет ловиться кодом ядра 123. А между ядрами какой-то механизм междуядерного взаимодействия.

А зачем требуется разбирать исключения в других тредах?

Думаю это зависит от задачи, но пойманное исключение в конкретном потоке можно записать в расшаренный std::excertion_ptr и в ожидающем потоке прокинуть его через std::rethrow_exception

Погодите, я пока всё ещё не понимаю.

(я программировал на C, а с C++ знаком постольку-поскольку).

Итак происходит исключение. Оно транслируется вверх по стеку вызовов. Всё это происходит в потоке.

"Поймать" - это пока ещё дело треда

"и записать в расшаренный std::что-то там" - это уже новая задача межтредовой коммуникации.

Если задачи ловли и задачи межтредовой коммуникации разнесены, то зачем нужны мютексы/локи при генерации и ловле исключений?

Разбор всегда в одном потоке. Но он использует глобальные таблицы адресов, которые могут меняться в момент загрузки или выгрузки динамических библиотек (другими потоками). Чтобы это не вызывало проблем, текущая реализация берет глобальный лок, из-за чего исключения на всех потоках сериализуются - исключения в других потоках тормозят обработку исключений вашим.

Исправление текущей ситуации о котором они пишут - заменить глобальный лок на read-write lock, брать его на запись только при операциях с shared library, но это ломает ABI.

"глобальные таблицы адресов"

при размотке стека foo(bar(baz())) кто-то параллельно может изменить позицию функции bar?

или что-то другое имеется ввиду?

Имеется в виду, что в структуре данных где мы держим foo, bar, и baz, могут появляться или исчезать элементы. Наши foo, bar, и baz не меняются, но структура данных - меняется. Тупой пример: у вас есть std::vector<>, добавление элементов в него может привести к релокации данных, ломая итераторы в других потоках. Это надо защищать синхронизацией, текущая реализация везде довольно примитивная.

Эм, я всё равно не понимаю связи с тредами.

foo-bar-baz могут по дороге распространения ошибки, конечно, накапливать std::verctor каких-то связанных с ошибкой данных.

Но кажется, что

  1. поменять положение в памяти они не могут (иначе указатели на функции пошли бы лесом)

  2. Даже в очень сложных программах глубины стеков почти никогда не достигают нескольких десятков (рекурсивные функции пока не рассматриваем). Соответственно для распространения ошибок вполне можно использовать предаллоцированный объект. Этот объект будет аллоцироваться per-thread.

И снова получается при выбрасывании исключения блокировки не нужны.

Но зачем-то их делают, зачем?

Вам стоит самые базы синхронизации почитать где-нибудь. Представьте используете вы глобальный массив arr известных адресов функций, и пишите вы код для поиска в нем вроде:

for (int i = 0; i < arr.size(); ++i) {
auto* p = arr[i];
...
}

И вот в середине этого цикла другой поток скажем удаляет элемент из массива, после того как i мы уже сравнили с длиной массива, но до того как прочитали arr[i] - мы можем прочитать за границей массива. Или мы прочитали первые 10 элементов, а элементы с 5 по 10 были только что удалены, на их место попали элементы с 11 по 16, а мы эти индексы уже пропустили и эти элементы не увидим. Или другой поток добавляет что-то, arr уже реалоцировали в новое место, но в него еще не скопировали старые данные - просто прочитаем мусор. Да что угодно может быть без синхронизации.

А где такой код может встретиться-то?

foo(bar(baz()))

  • когда вызывается foo в массив arr поместили её адрес.

  • когда вызываетс bar в массив arr поместили её адрес

и так далее.

Когда разматывается стек при ошибке, то массив arr

  • из данного треда менять некому

  • из соседних тредов менять незачем

PS: когда человек говорит "вам стоит почитать азы", я предполагаю что он их сам почитал и может объяснить. :)

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

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

Исключения не должны добавлять никакого оверхеда вообще если исключения не кидаются.

  1. Это просто один из взглядов.

  2. Во многих языках (в том числе и C++) имеется некоторый оверхед даже на просто вызов функции. А так же взгляды на то, кто чистит память аргументов, например.

Вот в приведённом выше примере обработки ошибок Golang-стиля исключения вообще не кидаются, а оверхед есть.

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

Организация доступа к этой таблице - вопрос межтредового взаимодействия, а не обработки исключений. Локи - просто один из способов такого взаимодействия.

Кто мешает, например, держать столько копий этой таблицы сколько есть тредов? Либо сделать (иной) lockfree доступ к этой таблице?

Либо сделать (иной) lockfree доступ к этой таблице?
Ничего не мешает, об этом в статье также написано. Вот только компиляторы уже сделали так как сделали. Для переделки нужно перекомпилировать все существующие с++ бинарники.

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

А я ещё один вопрос не понимаю.

вот код, выбросил исключение

код, его вызывающий его поймал и обработал

в каком месте тут нужна таблица всех функций приложения?

Если преобразования адреса-имена функций - не нужны (стек печатать мы не хотим), зачем нам эта таблица?

Честно сказать не знаю. Я как-то пытался разобраться как именно работает механизм исключений, но там такая кроличья нора (точнее несколько нор для каждой платформы/компилятора), что соваться не слишком хочется. Да и не нужно это обычно. Но могу попробовать порассуждать.

Ключевой вопрос тут: а как вы найдете где находится обработчик исключения? Причем место обработчика нужно знать уже в моменте выброса исключения (иначе может статься, что обработчика нет, а мы уже весь стек до __start() раскрутили и потеряли весь контекст где произошла ошибка. И все дампы теперь бесполезны). Тут нужны какие-то таблицы сопоставляющие адреса функций и обработчики. И при раскрутке стека ходить по этим таблицам. А эти таблицы как раз могут меняться в добавлением / удалением функций (загрузке / выгрузке шареных либ, а может ещё в каких-то случаях).

Ещё есть таблицы «всех функций» при компилировании c -fomit-frame-pointer. Иначе без таблицы просто нельзя будет получить текущий стек (ну ладно, можно анализируя код, но это будет очень долго). Они, возможно, тоже блокируются при подобных операциях.

Таблица чтобы обработать свертку стека (деструкторы) по пути от исключения до обработчика. Ну и чтобы обработчик найти

Ядром - да, но тут мне пришла аналогия из Пиратов Карибского Моря.
"Всем ни с места! Я обронил мозги."
И что-то похожее происходит во время исключений в... да практически во всех ЯП.

Если исключения случились в других потоках, то часть тредов повисает в ожидании анвиндинга из-за глобального лока, как минимум. И речь не о разборе, а именно о бросании исключения.

самых распространённых машин скоро будет 256 и более ядер

Такое количество ядер мигом упрётся огромное количество каналов памяти, которые необходимы что бы обеспечить подвод и отвод данных которые эти ядра должны обрабатывать.
У threadripper'ов уже 64 ядра (128 потоков). Вполне себе работают.

Для этого (в том числе) наращивают объёмы кэшей и углубляют их иерархию уровней.

Как обычно, проблема не в исключениях, а в голове. Точнее, в архитектуре. Если архитектура спроектирована нормально — в ней не будет ни одного процента, ни даже 0.1% исключений. Потому что исключения — это, блин, исключения! Они сделаны для того чтобы контролируемо завершить программу или поток выполнения с записью телеметрии. Проценты могут быть только тогда, когда нижний слой фигачит исключения (как в примере, если элемент массива отрицателен) — а верхний слой их игнорит и продолжает обработку. На одной из конференций по поводу исключений, было сказано очень правильно: «Перехвативший исключение слой обязан либо принять меры по устранению ПРИЧИНЫ вызвавшей исключение, либо (если он не может или не уполномочен принять такое решение) — передать исключение выше (скорее всего, освободив связанные с ним какие-то ресурсы)».

И даже если вы не уполномочены завершить программу целиком (например это контроллер тормозов какой-то или сервер критичный) — нужно завершать поток выполнения если причина выкидывания исключений неустранима. Тогда есть шанс хотя бы что устройство свалится в какой-то safe-mode или direct-control, или в другую контролируемую деградацию. Или хотя бы у человека лампочка красная высветится об отказе… А если вы стреляете исключениями в 1% случаев выполнения и продолжаете — это уже деградация неконтролируемая и невидимая. И потому — вдвойне опасная. И падение производительности в такой ситуации может оказаться наименьшей из возникающих проблем…
В при чем тут архитектура? Тут скорее дело в стандартной библиотеке, которая не предоставляет других способов получить ошибку (я помню non throwing варианты функций только в std::filesystem).

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

ps. Есть конечно вариант писать код так, чтобы исключений не возникало, ну то есть, например, запретить использовать std::map::at и всегда искать через std::map::find. И так для каждого контейнера. Но без автоматических проверок, такой способ, к сожалению, не скейлится =(

исключения, как способ сообщить об ошибке сейчас принято как дефолтное поведение

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);
}
smthWrongWithHumans : 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;
}

смысл только в том что exceptions - мы всегда делаем от std и всегда можем взять what(). Почему - ну вот так - хочу.

Ну и комментарию - обработка ошибки работы с файлом - может обрабатываться где-то вообще в др. месте.

Ну и пример кода - был больше про абстракцию, что все ошибки через исключения и отлавливаются они хз где.

if ((file == nullptr) && (text == nullptr)) throw (int)EINVAL;

А тут точно должно быть "И" а не "ИЛИ" ?

Ну, тут традиционный водораздел проходит в том, где находится зона контроля - в момент эксплуатции или в момент передачи в эксплуатацию. Обе стороны имеют аргументы, и де-факто одна сторона никогда не победит.

Если архитектура спроектирована нормально — в ней не будет ни одного
процента, ни даже 0.1% исключений. Потому что исключения — это, блин,
исключения!

Вот возьмём golang.

(псевдокод)

result, error = foo()

if error {

return error

}

Примерно такой паттерн (или надстройки над ним) чуть менее чем везде.

Исключений - 0% (ну их нет в синтаксисе языка)

Однако если мы этот кодовый паттерн завернём в синтаксис try catch, то что изменится? Ассемблерный или байткод останется тем же самым.

Система продолжит оставаться "спроектированной нормально" или перестанет быть таковой?

Это плохой пример.

Исключения позволяют вообще не анализировать код возврата из функции, т.к. оно будет проброшено через все вложенные вызовы сразу до блока try ... catch включая функции не возвращающие значения.

Исключения позволяют быть оригинальным и посмотрев на сигнатуру метода можно долго недоумевать какое исключение может прилететь из недр особенно в следующих версиях кода. Более того можно выбрасывать в качестве исключение вообще что угодно, даже то что не ясно как потом ловить.
int main(int argc, char const *argv[]) {
	try { throw [](){}; }
	catch(...) { return 1; }
	return 0;
}

Да, плохой код можно писать на любом языке.

result, error парадигма тоже часто толкает разработчиков к обобщённым типам error. Возврат обобщённого типа error мало чем отличается от трёх точек.

Вообще исключения тоже вносят много хаоса и толкают разработчиков в разные стороны.
Например вы передали в код поток, и внутри работает с ним пытаетесь ловить исключения и всё вроде хорошо, пока вам не передали сетевой поток который умеет кидать другие исключения. И они кидаются глубоко внутри кода.
Отсюда вытекает что не реализация должна обрабатывать исключения, а тот кто её запустил и знает какие сущности ей были переданы.
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) {
      ...
    });
    ...
  });
}
Правда выглядит, конечно, не очень.

Исключения - это просто СЕМАНТИКА обработки ошибок.

В Golang отказались от неё, в итоге разработчики делают гораздо больше телодвижений (и следовательно ошибок).

умеет кидать другие исключения

Кажется этот вопрос лечится сведением всех видов исключений к наследованию от одного и того же класса и разбиению наследников в несколько категорий.

Сведение к классам делают и для ошибок.

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

Это плохой пример.

Давайте разбираться

Исключения позволяют вообще не анализировать код возврата из функции

Вышеприведённый псевдокод разве где-то анализирует код возврата из функции? Нет.

Алгоритм такой: если функция вернула ошибку - транслировать её вверх по стеку пока не найдётся заинтересованный и обработает её.

Если допустить что ВСЕ функции пишутся в таком паттерне (а программы на Golang очень часто приходят к этой парадигме), то в чём будет отличие от Exception? Только в семантике.

Теоретически уже сейчас можно написать препроцессор для golang, который эти конструкции заменит на обычный try catch. И, уверен, что если кто-то такой препроцессор напишет, то программисты на Golang будут очень счастливы. Ибо этот постоянный геморрой с их плеч переложится на плечи компилятора

Дело в том, что в рантайме как раз не исполняется код типа if(error). И вверх по стеку ничего не транслируется.


В случае выброса исключения специальный код на низком уровне начинает анализировать стек-фреймы в поисках места куда можно передать управление для обработки исключения. Заодно ещё и удаляются все ресурсы, которые были аллоцированы в пройденых стек-фреймах.

Так, судя по-всему с предыдущей веткой обсуждения есть некоторое непонимание. Когда писалось про «нет ни одного, ни 0.1% исключений» — это не значит, что мы от исключений отказываемся и переходим на проверку return-values. Нет — это означает что люди, которые дизайнили механизм исключений в языке и рантайме, делали это под социальный контракт: «Выброс исключения в программе — это настолько редкое явление, что возможным падением производительности можно пренебречь». Я помню еще первую большую книгу Страуструпа, в которой вводился механизм исключений. Там говорилось, что это теперь будет такой удобный механизм сообщения о том, что в коде что-то пошло сильно не так. И давайте разбираться, откуда в коде может что-то пойти не так — и когда мы будем кидать исключения?

— Пользовательский ввод. Мы пытаемся с ним что-то сделать, и возникает ошибка. Поскольку «можно сделать защиту от дурака, но только от неизобретательного» — ошибки могут быть любые, и исключения тут совершенно к месту. Однако, никакого влияния на производительность не будет, потому что пользователь — по сравнению с компьютером — совершенный тормоз, и исключения по его вине будут происходить от нескольких раз в секунду до одного раза в десятки секунд. Для современного ПК — это на уровне погрешности.

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

— Внешние события (обычно, это сеть). Тут тоже есть где и как накидать себе исключений до потери производительности. Но и это тоже вопрос архитектуры. Если у вас из соединения лезет мусор, который нельзя обработать — нужно либо уведомлять вышележащий уровень (пусть скорость понизит, или еще что), либо рвать соединение через несколько исключений. При этом есть надежда, что всплеск реконнектов увидится на сетевом оборудовании, сгенерируется алерт или хотя бы случится троттлинг (ddos protection). В любом случае, это не та ситуация, когда у нас из всех потоков шарашат исключения, а мы несмотря на это — продолжаем.

Какие выводы? Если накладные расходы связанные с обработкой исключений начинают существенно ограничивать производительность приложения в целом — необходимо переработать архитектуру приложения. В нем явно что-то не так! Иногда архитектуру переработать нельзя по внешним причинам — и тогда просто не нужно использовать в критичных местах механизм исключений (прямые вызовы libc никто не отменял!).

Собственно, Страуструп предупреждал, что механизм исключений не имеет гарантированной производительности…

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

@honyaki или любой другой представитель skillfactory (спонсоров этой статьи), ответьте, пожалуйста, на пару вопросов.
1. У вас есть курс по C++, на котором правда разбираются такие вещи? Этот курс в вашей системе грейдов для какого уровня (junior, middle, senior)?
2. Есть ли аналогичного уровня материалы по Data Science? Если да, то есть ли у вас курсы, в которых об этом рассказывают?

Альтернативное мнение с бенчмарками и совсем другими выводами: Коды ошибок — это гораздо медленнее, чем исключения

Из двух статей можно составить более полную картину :)

Зарегистрируйтесь на Хабре, чтобы оставить комментарий