Комментарии 44
Я правильно понял, что само исключение у вас это структура, в которой есть сообщение, строка где его кинули, и указатель еще на что-то?
Не уловил, можно ли будет посмотреть стектрейс, как скажем в Java, все строки, по которым проходил вызов до точки, где кинули исключение. На мой взгляд, для Java исключений это чуть ли не самое удобное, что дают исключения. Причем без усилий вообще.
Я правильно понял, что само исключение у вас это структура, в которой есть сообщение, строка где его кинули, и указатель еще на что-то?
Да: https://github.com/ProgerXP/SaneC/blob/master/saneex.h#L187
struct SxTraceEntry {
int code;
char uncatchable;
char file[SX_MAX_TRACE_STRING];
int line;
char message[SX_MAX_TRACE_STRING];
void *extra;
};
можно ли будет посмотреть стектрейс, как скажем в Java, все строки, по которым проходил вызов до точки, где кинули исключение
Конечно, в этом и смысл. На КДПВ справа внизу — именно такой stack trace.
Uncaught exception (code 1) - terminating.
Feeling blue today...
...at saneex-demo.c:7, code 0
rethrown by ENDTRY
...at saneex-demo.c:11, code 1
Bye-bye my little pony
...at saneex-demo.c:18, code 0
rethrown by ENDTRY
...at saneex-demo.c:19, code 1
Структура доступна в рантайме, ее можно проитерировать через sxWalkTrace()
:
https://github.com/ProgerXP/SaneC/blob/master/saneex.c#L42
Причем без усилий вообще.
Да, "без усилий вообще" — это как раз "привычный по другим языкам механизм исключений" для меня. saneex
это дает из коробки.
Кстати, построение вот этого самого стека, насколько я помню — это как раз то, что в Java сильно снижает производительность при использовании исключений. На хабре даже пару раз упоминали небольшой хак, как все ускорить, кидая исключения без stacktrace.
построение вот этого самого стека, насколько я помню — это как раз то, что в Java сильно снижает производительность при использовании исключений.
Я с низким уровнем в JVM мало знаком, но, как пишут на SO, там используется подход zero-cost exceptions (см. в статье), то есть вся обработка делается в момент поимки исключения (если оно возникает). А Java — высокоуровневый язык с синхронизациями, объектами и прочим, поэтому такая обработка затратна.
В saneex и "голом" С все наоборот — throw это почти что один longjmp(), который, фактически, только сбрасывает указатель стека (ESP). Затраты на этот сброс околонулевые, что показывают мои замеры, по которым throw/longjmp() в C быстрее, чем throw в C++ (где, как и в JVM, дело не ограничивается только изменением ESP).
"Построение стека" происходит по мере вызовов try — там копируются параметры исключения (file, message и пр.) в статический массив, плюс вызывается setjmp(). Как раз последний является лимитирующим фактором, но от него избавиться нельзя никак, не уходя от C99. Но даже там счет идет на единицы-десятки мс при 100к повторений.
Про построение стека интересно — будут ли в нем учтены файлы и вызовы функций по стеку, в которых нет макросов (они ведь макросы?) try-catch? Если да, то как? Если я правильно понял, то они разворачиваются во что-то, использующее FILE (что-то такое), но как провалиться вниз к вызывающему файлу непонятно...
будут ли в нем учтены файлы и вызовы функций по стеку, в которых нет макросов (они ведь макросы?) try-catch?
Не будут, так как новая запись во внутренний стек saneex добавляется только при входе в блок try. Вероятно, можно придумать способ добавлять в него вызовы при входе в любую функцию, но точно не стандартными средствами.
Оданко проблемы в этом нет, так как обычно при возникновении исключения вполне достаточно функций с try/catch внутри, а все промежуточные не интересуют.
Пример:
1 #include "saneex.h"
2
3 int subsub(void) {
4 throw(msgex("Test"));
5 }
6
7 int sub(void) {
8 subsub();
9 }
10
11 int main(void) {
12 try {
13 sub();
14 } endtry
15 }
Вывод:
Uncaught exception (code 1) - terminating.
Test
...at saneex-demo.c:4, code 0
rethrown by ENDTRY
...at saneex-demo.c:14, code 1
Как видите, в стеке только функции main (строка 14 с endtry) и subsub (4 с throw). Функции sub (строки 7-9) нет.
Я уже привык к тому, что если кто-то выкладывает контекстную ссылку — там нечто очень желательное к ознакомлению.
Ну, 9600 бод и все-все-все вполне попадает в категорию "очень желательно" :)
А если серьезно, то эта статья с претензией на "долгосрочный" справочный материал, поэтому ссылки на стандарт(ы), альтернативы и похожие работы обязаны присутствовать. Но вы ничего не упустите, если не будете по ним ходить, просто придется мне верить на слово.
Жаль на Хабре нет какого-нибудь автоматического сборщика ссылок, например вы ставите контекстную url — она автоматически превращается по випу как в Википедии (маленький номер в superscript со ссылкой), а внизу статьи полный список с расшифровкой.
Мне кажется это идея для Хабра — сделать выбираемый пользовательский стиль, который будет показывать статью либо как оформил автор, либо в википедийном стиле.
Статья, безусловно, в закладки.
Но несмотря на это я тоже сторонник тёплого лампового fprintf.
Про более медленную работу fprinf по сравнению с cerr << в общем-то понятно — printf это комбайн на все случаи жизни и под многие типы переменных, ему приходится разбирать строку форматирования. Вывод в cerr, полагаю, пользуется какими-то узкотипизированными форматёрами.
Здесь интересно другое (см. таблицу, №3 и сноску): fprintf() в коде бенчмарка работает в 2-3 раза быстрее всегда, кроме одного случая в Visual Studio, когда в цикле выбрасывается исключение — тогда внезапно << начинает работать в 3 раза быстрее fprintf(). Причем это подтвердилось у другого читателя. Я не могу это никак объяснить. И это не повторяется на gcc.
Насколько я понимаю, кэш в x86/x64 сделан так, чтобы программисту не нужно было о нем думать вообще — его как бы нет. За его корректность отвечает ЦП и переключение между ядрами для программы прозрачно. При использовании нескольких потоков это не отменяет необходимость синхронизации (atomic, критические секции и прочее), но сам кэш при этом всегда остается корректным (с точки зрения программы).
Беглый поиск выдал вот такой вопрос-ответ на SO:
x86 CPUs use a variation on the MESI protocol (MESIF for Intel, MOESI for AMD) to keep their caches coherent with each other (including the private L1 caches of different cores). A core that wants to write a cache line has to force other cores to invalidate their copy of it before it can change its own copy from Shared to Modified state.
You don't need any fence instructions (like MFENCE) to produce data in one thread and consume it in another on x86, because x86 loads/stores have acquire/release semantics built-in. You do need MFENCE (full barrier) to get sequential consistency.
То же самое относится к многопроцессорным системам.
volitale в статье нужен, поскольку, как красочно подметил автор, setjmp может быть возвращена несколько раз, что бы компилятор был готов к такому исходу.
Декларация volatile может быть использована для описания объекта, соответствующего memory-mapped порту ввода-вывода или объекту к которому осуществляется доступ из прерываний. Действия на объявленных таким образом переменных не должны быть оптимизированы или переупорядочены, за исключением разрешенных правилами оценки выражений
Заметьте, достаточно одного volatile, чтобы переменная соответствовала чему-то в памяти (т.е. не оптимизировалась размещением в регистре). Про абстрактную машину и правила оценки глава 5.1.2, но там нужно вдумчивое чтение.
Спасибо за статью. Обожаю такие подробные обзоры и погружения в детали.
И с временем публикации тоже все правильно получилось, на фоне потока непрерывных новостей о коронавирусе она вселяет настоящий оптимизм.
«Вирусы приходят и уходят, а Си вечно молодой, вечно пьяный.»
нельзя делать return между try и endtry — это самый жирный минус, но моя фантазия не нашла способов отловить эту ситуацию; принимаются идеи и PR
B gcc можно поставить хук на выход из ф-ции с момщью -finstrument-functions
и так ловить
Имелось в виду использование стандартных переносимых конструкций, как, например, тройное {
(как сделано для try и endtry) или #define return abort
(но макросы не рекурсивны).
стандартных переносимых конструкций, как, например, тройное { (как сделано для try и endtry) или #define return abort (но макросы не рекурсивны).
А можно про это подробнее, не совсем понял что вы имели ввиду.
Рассмотрим две проблемы из статьи:
блок обязан заканчиваться на endtry
Задача компилятора/среды программирования — максимально разгрузить программиста от контроля за мелочами (синтаксиса, платформы и прочего). В контексте моей библиотеки, может быть такой случай использования:
try {
func();
} endtry // <<<
Допустим, код выше (где try и endtry это макросы) разворачивается в такой:
if (xxx) // "try"
{
func();
}
if (!handled) ... // "endtry'
Что будет, если программист забудет endtry?
if (xxx) // "try"
{
func();
}
Последней строчки нет. При этом в endtry происходит очистка стека и его пропуск является фатальной ошибкой, которую не поймать даже в рантайме (с точки зрения saneex блок try продолжает быть открытым до завершения процесса). А забыть endtry очень легко.
Поэтому в saneex (см. исходники):
#define try {{{ if (_sxEnterTry2( setjmp(*_sxEnterTry()) ))
#define endtry _sxLeaveTry(__FILE__, __LINE__); }}}
Обратите внимание на {{{
и }}}
. Теперь, если пропустить endtry, код будет таким:
{{{ if (xxx) // "try"
{
func();
}
Это, очевидно, является синтаксической ошибкой, о чем компилятор сразу предупредит. Сравните с:
{{{ if (xxx) // "try"
{
func();
}
if (!handled) ... }}} // "endtry"
Забыть endtry и не заметить этого теперь можно только если пропустить 3 закрывающие скобки помимо собственно endtry, а это сложно сделать.
нельзя делать return между try и endtry
Теперь такой пример:
try {
return func();
} endtry
Это является точно такой же фатальной ошибкой, т.к. вызывает повреждение стека. Скобки нам уже не помогут, потому что endtry на месте. Можно было бы сделать что-то подобное:
#define try #define return abort();
И тогда:
try {
abort(); func();
} endtry
В этом случае в рантайме при попытке выполнить такой блок гарантированно получили бы падение. Еще можно было бы #define return $#@!
, чтобы вызвать синтаксическую ошибку — однако макросы не раскрываются рекурсивно, так что это не сработает и за пропущенным return, в отличии от endtry, приходится следить программисту.
Вот это я и имел в виду.
Еще можно было бы #define return $#@!, чтобы вызвать синтаксическую ошибку — однако макросы не раскрываются рекурсивно, так что это не сработает и за пропущенным return, в отличии от endtry, приходится следить программисту.
Рекурсия не получится но можно использовать include
К примеру:
test.c:
#define TRY #include "inc.h"
int main(){
TRY
return 0;
}
inc.h:
#define return $#@!
gcc -E test.c:
int main(){
#include "inc.h"
return 0;
}
Спасибо за статью и стиль изложения.
Единственное что смутило: int i{ vec.at(4) }; — это C++ головного мозга?
"Диназавр" на сегодня это единственное человекочитаемое представление того, что, собственно, происходит внутри ЦП, так что вымирание ему не грозит. На многие вопросы однозначный ответ дает только ассемблер, иначе такое "исследование" будет сродни лечению по фотографиям.
Да и полезная разминка для мозгов, коим грозит тотальное заржавление из-за этих ваших "питонов" и прочих "жабоскриптов" :) </irony
Можно ловить двойные исключения из деструкторов, которые считаются, что они смертельны для программы. Небольшая утечка возможна, но программа продолжает работать.
std::set_terminate([](){
std::cout << "Unhandled exception "<<" uncaught_exceptions["<<std::uncaught_exceptions()<<']'<<" \n" << std::flush;
cout<<"begin long jump\n";
longjmp(jmpBuf,1);
});
if(setjmp(jmpBuf)==0) {
try {
....... // <<< возникает двойное исключение в деструкторе
} catch (...) {
cout << "catch\n"; // сюда не доходит, а идет в set_terminate
}
}else{
cout<<"longjump completed";
}
saneex.c: try/catch/finally на базе setjmp/longjmp (C99) быстрее стандартных исключений C++¹