Пока писал эту сугубо техническую статью, Хабр успел превратиться в местное отделение ВОЗ и теперь мне даже стыдно ее публиковать… но в душе теплится надежда, что айтишники еще не разбежались и она найдет своего читателя. Или нет?
Меня всегда восхищала стандартная библиотека Си, да и сам Си — при всей своей минималистичности от них так и веет духом тех самых первых красноглазиков хакеров. В черновике первого официального стандарта (ANSI C, он же C89, он же ANS X3.159-1989, он же, позднее, C90 и IEC 9899:1990) определяется 145 функций и макросов, из них около 25 — это вариации (ввиду отсутствия в языке перегрузок), а 26 чисто математических. K&R во второй редакции² приводят 114 функций (плюс математические), считая остальные за экзотику. В черновике³ C11 функций уже 348, но больше сотни — математика, а еще штук 90 это «перегрузки». А теперь посмотрим на Boost, где одних только библиотек — 160. Чур меня…
И среди этой сотни-полутора функций всегда были: обработка сигналов, вариативные функции (которые до интерпретируемого PHP дошли 25 лет спустя, а в Delphi, бурно развивавшемся одно время, их нет до сих пор) и порядка 50 строковых функций вроде printf() (м-м-м… JavaScript), strftime() (…) и scanf() (дешевая альтернатива регуляркам).
А еще всегда были setjmp()/longjmp(), которые позволяют реализовать привычный по другим языкам механизм исключений, не выходя за рамки переносимого Си. Вот о них и поговорим — Quake World, стеки, регистры, ассемблеры и прочая матчасть, а вишенкой будет занятная статистика (спойлер: Visual Studio непостоянна, как мартовский заяц, а throw saneex.c
в два раза быстрее всех).
¹ По результатам замеров в статье.
² Кстати, книга великолепная. 270 страниц, из которых 80 — это краткий пересказ стандарта. Или в то время еще не умели растекаться мыслью по древу и конвертировать это в гонорар, или авторы были выше этого. K&R — старая школа, чо.
³ Из особо достоверных источников известно, что финальные версии стандартов ANSI и ISO продаются за деньги, а черновики бесплатны. Но это не точно.
⁴ Да, я тоже не люблю «сокращалки» вроде TinyURL, но парсер Хабра считает URL частью текста и ругается на длинный текст до ката, яко Твіттер поганий. Дальше этого не будет, честно-честно. Параноикам могу посоветовать urlex.org.
Оглавление:
Итак, герои нашей программы — setjmp()/longjmp(), определенные в setjmp.h, которые любят вместе сокращать как «SJLJ» (хотя мне это слово не нравится, напоминает одну печально известную аббревиатуру). Они появились в C89 и, в общем-то, уходить не собираются, но про них не все знают (знать не значит использовать — знание полезно, а использование — как повезет).
Справедливости ради надо сказать, что на Хабре уже были статьи, посвященные этой теме, в особенности отличная статья от zzeng. В англоязычной Сети, конечно, тоже имеется, плюс можно найти реализации вроде такой или даже вот такой¹, но, на мой взгляд, у них есть фатальный недостаток результат или не до конца привычен (к примеру, нельзя выбрасывать исключения повторно), или используются механизмы не по стандарту.
¹ CException хочется отметить особо — всего 60 строчек, пишут, что работает быстро, тоже ANSI C, но у него нет finally и текстовых сообщений, что для меня принципиально важно.
Вообще, использовать исключения или нет — вечный спор тупоконечников с остроконечниками в любом языке, и я призываю тех, кто по другую сторону баррикад, или пройти мимо, или прочитать материал и отложить его в свою копилку знаний, пусть даже на полку «чего только не тащат в нашу уютненькую сишечку». (Главное, чтобы спорщики не забывали, что ни одна программа на Си по-настоящему от «исключений» не свободна, ибо проверка errno не спасет при делении на ноль. Сигналы — те же яйца, только в профиль.)
Для меня лично исключения это инструмент, который позволяет:
- не думать в каждом конкретном месте, что что-то может пойти не так, если это самое место все равно с этим ничего не может сделать (ресурсы не заблокированы, память не выделена — можно прерываться немедленно, без
if (error) return -1;
) - когда что-то и впрямь пошло не так — сохранить как можно больше информации, от кода ошибки и имени файла до значения важных переменных и других исключений, которые породили эту ситуацию
Но обо всем по порядку. Как это у нас принято, начнем с матчасти.
Как работают setjmp()/longjmp()
Регистры, стек и все-все-все
В двух словах, longjmp() — это нелокальный goto, а setjmp() — пророк его способ задания метки этому goto в run-time. Короче, «goto на стероидах». И, как и любые стероиды, то бишь, goto, они могут нанести непоправимый вред вашему коду — превратить его в такую лапшу, которая для goto просто вне досягаемости. Посему лучше всего их использовать не напрямую, а внутри какой-нибудь обертки, задающей четкую иерархию переходов (как то исключения — вверх по стеку в пределах явно обозначенных блоков «try»).
Помните, я говорил в начале, что от Си и, конкретно, от setjmp.h
прямо веет черт^W юниксовщиной? Так вот, вы вызываете setjmp() один раз, а она возвращается сколько угодно раз (но, как минимум, один). Да, в обычном мире смузихлебы вызывают функцию и она возвращается один раз, а в Советской России функция вызывает вас сама, сколько раз ей хочется и когда ей этого хочется. Такие дела.
Эта концепция, кстати, воплотилась не только в setjmp() — fork() в POSIX делает нечто очень похожее. Я помню, когда я впервые знакомился с *nix’овыми API после десятка лет работы исключительно с WinAPI, мне просто сносило крышу — в моих ментальных шаблонах не укладывалось, что функции могут вот так себя вести. Как метко говорят — «а что, так можно было?»… Но мы отвлеклись.
Думаю, все читающие в курсе, что основной элемент рантайма — это стек, на котором лежат параметры и (некоторые) локальные переменные данной функции. Вызываешь новую функцию — стек растет (причем у Intel’а — вниз), выходишь — тает (у Intel’а — да-да, вверх). Вот примерчик:
void sub(int s) { char buf[256]; sub(2); } int main(int m) { sub(1); }
Есть такой занятный компилятор — tcc (Tiny C Compiler) от известного программиста-парохода Ф. Беллара. tcc практически не делает оптимизаций и код после него очень приятно смотреть в дизассемблере. Он генерирует такое тело для sub() (в нотации Intel, опуская пролог и эпилог):
sub esp, 100h ; выделяем место под локальную переменную mov eax, 2 ; передаем параметр push eax call sub_401000 ; вызываем sub() add esp, 4 ; очищаем стек после возврата (= cdecl)
Вот схемка происходящего со стеком:
Вот эти оранжевые цифры по центру — это указатель на вершину стека (который у Intel… ну, вы поняли). Указатель хранится в регистре ESP (RSP на x86_64). setjmp() сохраняет текущее значение ESP/RSP, плюс другие служебные регистры, в область памяти jmp_buf, которую вы ему передаете. Если происходит вызов longjmp() далее по курсу (из этой же функции или из подфункции) — указатель восстанавливается и получается, что следом автоматически восстанавливается и окружение функции, где был вызван setjmp(), а все вызванные ранее подфункции моментально завершаются (возвращаются). Эдакий откат во времени, «undo» для рантайма (конечно, с большой натяжкой).
В следующем примере setjmp() поместит в jmp значение указателя FEF8h
(FDF0h
и т.д. — красные стрелки на схеме выше) и функция продолжит выполнение, как обычно:
void sub(int s) { char buf[256]; jmp_buf jmp; setjmp(jmp); sub(2); }
Но, конечно, есть нюанс™:
- нельзя прыгать между потоками (setjmp() в одном, longjmp() в другом), потому как, очевидно, у каждого потока свой стек
- если функция, которая вызвала setjmp(), уже вернулась, то «реанимировать» ее не выйдет — программа впадет в undefined behavior (и это не лечится)
- компилятор использует регистры для хранения переменных — они, видите ли, быстрее работают! — а регистры, внезапно, хранятся отдельно от стека и, хотя setjmp() могла сохранить их состояние на момент вызова, она и longjmp() не знают, что с ними происходило после вызова setjmp()
Затирание переменных или, по-русски, clobbering
Последний момент особенно интересен. Пример:
#include <stdlib.h> #include <stdio.h> #include <setjmp.h> int main(void) { int i; jmp_buf jmp; i = rand(); if (setjmp(jmp) == 0) { i = rand(); printf("%d\n", i); longjmp(jmp, 1); } else { printf("%d\n", i); } }
Вопрос залу: будут ли числа в консоли совпадать?
Правильный ответ: зависит от воли звезд. Так-то!
Посмотрим, что происходит на примере gcc. Если скомпилировать с -O0, то числа будут совпадать, а в дизассемблере мы увидим вот это:
; int main(void) { push ebp ; пролог (создается stack frame) mov ebp, esp ; EBP указывает на стек ниже ESP (если по схеме) sub esp, E0h ... call _rand ; результат возвращается в EAX mov [ebp-D4h], eax ; это i = rand(); где i на стеке (EBP-D4h) ... ; if (... == 0) { ; вызов setjmp() и возврат из нее до прыжка call _rand mov [ebp-D4h], eax ; снова i = rand(); на стеке ; printf("%d\n", i); mov eax, [ebp-D4h] ; передаем i со стека как параметр mov esi, eax lea edi, format ; передаем строку "%d\n" mov eax, 0 call _printf ... ; } else { ; вторичный возврат из setjmp() после прыжка mov eax, [ebp-D4h] ; снова передаем i, как в ветке выше mov esi, eax lea edi, format ; "%d\n" mov eax, 0 call _printf
Как видно, компилятор не заморачивался и поместил переменную i в стек (по адресу EBP - D4h
). Если смотреть на всю ту же схемку, то:
- вместо буфера на 256 char мы имеем int и jmp_buf, размер которых на моей системе 4 и 200 байт соответственно, плюс 20 байт для чего-то потребовалось компилятору, так что на стеке под локальные переменные выделилось 224 байта (E0h) вместо 100h, как в том примере
- ESP на момент вызова setjmp() равен
FFF8h - E0h = FF18h
(вместоFEF8h
), это значение и сохраняется в jmp
- конечно, это значение условно, в реальности оно будет иным
- и первое присваивание i, и второе меняют значение i в стеке (по адресу
FF18h
) - longjmp() сбрасывает указатель стека обратно в
FF18h
, но, так как переменная i не выходит за эти границы, она по-прежнему доступна, равно как и другая переменная (jmp), и параметры main() (буде они есть)
- в этом примере ESP и так не менялся, но longjmp() легко мог бы быть внутри другой функции, вызванной из main()
А вот если включить хотя бы -O1, то картина изменится:
; пролога и stack frame больше нет, используется значение ESP напрямую sub esp, E8h ... call _rand mov [esp+E8h-DCh], eax ; i = rand(); в стеке, как и с -O0 ... ; -O1 почему-то решило, что выполнение else более вероятно, чем ; if (setjmp() == 0) (хотя по-моему наоборот), и переставило ; их местами; здесь я вернул прежний порядок для понятности ; if (... == 0) { call _rand mov esi, eax ; ВНИМАНИЕ! запись i в регистр ; printf("%d\n", i); lea edi, format ; "%d\n" mov eax, 0 call _printf ... ; } else { mov esi, [esp+E8h-DCh] ; ВНИМАНИЕ! чтение i со стека lea edi, format ; "%d\n" mov eax, 0 call _printf
Вдобавок, с -O1 gcc при компиляции ругается страшными словами:
test.c:6:11: warning: variable ‘i’ might be clobbered by ‘longjmp’ or ‘vfork’ [-Wclobbered]
Что мы здесь видим? Вначале i помещается в регистр, но в первой ветке (внутри if) gcc, видимо сочтя i не используемой после первого printf(), помещает новое значение сразу в ESI, а не в стек (через ESI оно передается дальше в printf(), см. ABI, стр. 22 — RDI (format), RSI (i), …). Из-за этого:
- в стеке по адресу
ESP + E8h - DCh
остается старое значение rand() - в ESI оказывается новое значение
- printf() (первый вызов) принимает (новое) значение из регистра
- longjmp() сбрасывает указатель стека, но не восстанавливает изменившиеся регистры, которые используются функциями для локальных переменных при включенных оптимизациях
- второй вызов printf() (в else) читает значение, как положено, из стека, то бишь старое
- но даже если бы оно читалось из ESI, то после прыжка в этом регистре был бы мусор (вероятно, из printf() или самого longjmp())
Или, если переписать это обратно на Си:
stack[i] = rand(); // i = rand(); изменение стека (1) if (setjmp(jmp) == 0) { ESI = rand(); // i = rand(); изменение регистра (2) printf("%d\n", ESI); // печать значения (2) longjmp(jmp, 1); // прыжок } else { printf("%d\n", stack[i]); // печать значения (1) // или могло бы быть так: printf("%d\n", ESI); // использование регистра, где уже кто-то // "побывал" (первый printf() или longjmp()) }
Честно говоря, мне не понятно, почему gcc результат первого rand() не помещает сразу в ESI или в другой регистр (даже при -O3). На SO пишут, что в режиме x86_64 (под который я компилировал пример) сохраняются все регистры, кроме EAX. Зачем промежуточное сохранение в стек? Я предположил, что gcc отследил printf() в else после longjmp(), но если убрать второй rand() и этот printf() — результат не меняется, i так же вначале пишется в стек.
Если кто может пролить свет на сию тайну — прошу в комментарии.
Квалификатор volatile
Решение проблемы «летучих переменных» — квалификатор volatile (дословно — «летучий»). Он заставляет компилятор всегда помещать переменную в стек, поэтому наш код будет работать, как ожидается, при любом уровне оптимизаций:
volatile int i;
Единственное изменение при -O1 будет в теле if:
; было: call _rand mov esi, eax ; стало: call _rand mov [rsp+E8h-DCh], eax mov esi, [rsp+E8h-DCh] ; или можно переписать так: call _rand mov esi, eax mov [rsp+E8h-DCh], eax
Как видим, компилятор продублировал присвоение в стек (сравните):
if (setjmp(jmp) == 0) { ESI = stack[i] = rand();
Случаи использования IRL
Итак, если соблюдать меры предосторожности — не прыгать между потоками и между завершившимися функциями и не использовать изменившиеся не-volatile переменные после прыжка, то SJLJ позволяет нам беспроблемно перемещаться по стеку вызовов в произвольную точку. И не обязательно быть адептом секты свидетелей исключений — сопротивление бесполезно, ибо SJLJ уже давно заполонили всю планету среди нас:
- Википедия предлагает использовать их для реализации корутин в Си (я бы не стал — как бы чего не вышло, хотя ldir такую технику тоже упоминал)
- на Хабре alexkalmuk писал про юнит-тесты в Эльбрусе на основе SJLJ (+ вторая статья), а dzeban — про профилирование в Linux
- также местные писали про быстрый интерпретатор (осторожно: очень сильное колдунство от Atakua), про обработку ошибок в x86emu (NWOcs) и в libpng (в libjpeg-turbo аналогично)
- в 2017 Skapix писал про pthreads, а kutelev — про прыжки из обработчиков сигналов
- тут пишут, что эта парочка использовалась в Symbian
- а в Quake World она использовалась совершенно точно — см. перевод археологических раскопок от PatientZero и, собственно, исходники (там есть и пара других мест)
Последний пример, на мой взгляд, наиболее хрестоматийный — это обработка ошибок и других состояний, когда нужно выйти «вот прямо сейчас», с любого уровня, при этом вставлять везде проверки на выход утомительно, а где-то и не возможно (библиотеки). Кстати, еще один пример был описан в проекте DrMefistO.
Конкретно в Quake World запускается бесконечный цикл в WinMain(), где каждая новая итерация устанавливает jmp_buf, а несколько функций могут в него прыгать, таким образом реализуя «глубокий continue»:
// WinQuake/host.c jmp_buf host_abortserver; void Host_EndGame (char *message, ...) { ... if (cls.demonum != -1) CL_NextDemo (); else CL_Disconnect (); longjmp (host_abortserver, 1); } void Host_Error (char *error, ...) { ... if (cls.state == ca_dedicated) Sys_Error ("Host_Error: %s\n",string); // dedicated servers exit CL_Disconnect (); cls.demonum = -1; inerror = false; longjmp (host_abortserver, 1); } void _Host_Frame (float time) { static double time1 = 0; static double time2 = 0; static double time3 = 0; int pass1, pass2, pass3; if (setjmp (host_abortserver) ) return; // something bad happened, or the server disconnected ... } // QW/client/sys_win.c int WINAPI WinMain (...) { ... while (1) { ... newtime = Sys_DoubleTime (); time = newtime - oldtime; Host_Frame (time); oldtime = newtime; } /* return success of application */ return TRUE; }
Производительность
Один из доводов, который приводят против использования исключений — их отрицательное влияние на производительность. И действительно, в исходниках setjmp() в glibc видно, что сохраняются почти все регистры общего назначения ЦП. Тем не менее:
- само собой разумеется, что ни исключения в общем, ни SJLJ/
saneex.c
в частности и не предполагаются к применению во внутренностях числодробилок - современные те-кхе…кхе-нологии (
извиняюсь, электрон в горло попал) таковы, что сохранение лишнего десятка-другого регистров — это самая малая из проблем, которые они в себе несут - если скорость критична, а исключений хочется — есть механизмы zero-cost exceptions (или, точнее, zero-cost try), которые радикально снижают нагрузку при входе в блок try, оставляя всю грязную работу на момент обработки (выброса) — а так как исключения это не goto и должны использоваться, гм, в исключительных ситуациях, то на производительности такой «перекос» сказывается, э-э, исключительно положительно
«Честные» zero-cost exceptions особенно полезны в том плане, что избавляют от более медленных volatile-переменных, которые иначе размещаются в стеке, а не в регистрах (именно поэтому они и не затираются longjmp()). Тем не менее, их поддержка это уже задача для компилятора и платформы:
- В Windows есть SEH и VEH, последний подвезли в XP.
- В gcc было несколько разных вариантов — вначале на основе SJLJ, потом DWARF, коего на сегодняшний день было пять версий (DWARF применяется и в clang). На эту тему см. отменные статьи zzeng: тыц и тыц, и сайт dwarfstd.org.
- В комментарии к другой статье камрад nuit дал наводку на интересный проект libunwind, но использовать его только ради исключений — это как стрелять из воробьев по пушкам (больно большой).
И, хотя saneex.c
не претендует на пальму zero-cost (ее пальма — это переносимость), так ли уж страшен setjmp(), как его малюют? Может, это суеверие? Чтобы не быть голословными — померяем.
Тестовая среда
Я набросал два бенчмарка «на коленке», которые в main() в цикле 100 тысяч раз входят в блок try/catch и делают или не делают throw().
Исходник бенчмарка на C:
#include <stdio.h> #include <time.h> #include "saneex.h" int main(void) { for (int i = 0; i < 100000; i++) { try { // либо ("выброс" = да): throw(msgex("A quick fox jumped over a red dog and a nyancat was spawned")); // либо ("выброс" = нет): time(NULL); } catchall { fprintf(stderr, "%s\n", curex().message); } endtry } }
Исходник на С++ (я адаптировал пример с Википедии, вынеся объявление вектора за цикл и заменив cerr <<
на fprintf()):
#include <iostream> #include <vector> #include <stdexcept> #include <time.h> int main() { std::vector<int> vec{ 3, 4, 3, 1 }; for (int i = 0; i < 100000; i++) { try { // либо ("выброс" = да): int i{ vec.at(4) }; // либо ("выброс" = нет): time(NULL); } catch (std::out_of_range & e) { // << вместо fprintf() вызывает замедление цикла на 25-50% //std::cerr << "Accessing a non-existent element: " << e.what() << '\n'; fprintf(stderr, "%s\n", e.what()); } catch (std::exception & e) { //std::cerr << "Exception thrown: " << e.what() << '\n'; fprintf(stderr, "%s\n", e.what()); } catch (...) { //std::cerr << "Some fatal error\n"; fprintf(stderr, "Some fatal error"); } } return 0; }
Тестировалось все на одной машине в двух ОС (обе 64-битные):
- Windows 10 2019 LTSC под PowerShell с помощью
Measure-Command { test.exe 2>$null }
- последний Live CD Ubuntu с помощью встроенной time
Также я попробовал замерить исключения в Windows через расширения __try/__except, взяв другой пример с Википедии:
#include <windows.h> #include <stdio.h> #include <vector> int filterExpression(EXCEPTION_POINTERS* ep) { ep->ContextRecord->Eip += 8; return EXCEPTION_EXECUTE_HANDLER; } int main() { static int zero; for (int i = 0; i < 100000; i++) { __try { zero = 1 / zero; __asm { nop nop nop nop nop nop nop } printf("Past the exception.\n"); } __except (filterExpression(GetExceptionInformation())) { printf("Handler called.\n"); } } }
Однако вектор включить в цикл не вышло — компилятор сообщил, что:
error C2712: Cannot use __try in functions that require object unwinding
Так как накладываемые ограничения на код идут вразрез с принципом привычности, о котором я говорил в начале, я не внес эти результаты в таблицу ниже. Ориентировочно это 1100-1300 мс (Debug или Release, x86) — быстрее, чем стандартные исключения в VS, но все равно медленнее, чем они же в g++.
Результаты
№ Компилятор Конфиг Платф Механизм Выброс Время (мс)¹ saneex медленнее 1. VS 2019 v16.0.0 Debug x64 saneex.c да 9713 / 8728 = 1.1 в 1.8 / 1.8 2. VS 2019 v16.0.0 Debug x64 saneex.c нет 95 / 46 = 2 в 4.5 / 2.3 3. VS 2019 v16.0.0 Debug x64 C++ да 5449 / 4750² = 1.6 4. VS 2019 v16.0.0 Debug x64 C++ нет 21 / 20 = 1 5. VS 2019 v16.0.0 Release x64 saneex.c да 8542³ / 182 = 47 в 1.8 / 0.4 6. VS 2019 v16.0.0 Release x64 saneex.c нет 80³ / 23 = 3.5 в 8 / 1.8 7. VS 2019 v16.0.0 Release x64 C++ да 4669³ / 420 = 11 8. VS 2019 v16.0.0 Release x64 C++ нет 10³ / 13 = 0.8 9. gcc 9.2.1 -O0 x64 saneex.c да 71 / 351 = 0.2 в 0.2 / 0.6 10. gcc 9.2.1 -O0 x64 saneex.c нет 6 / 39 = 0.2 в 1.5 / 1.1 11. g++ 9.2.1 -O0 x64 C++ да 378 / 630 = 0.6 12. g++ 9.2.1 -O0 x64 C++ нет 4 / 37 = 0.1 13. gcc 9.2.1 -O3 x64 saneex.c да 66 / 360 = 0.2 в 0.2 / 0.6 14. gcc 9.2.1 -O3 x64 saneex.c нет 5 / 23 = 0.2 в 1 / 0.6 15. g++ 9.2.1 -O3 x64 C++ да 356 / 605 = 0.6 16. g++ 9.2.1 -O3 x64 C++ нет 5 / 38 = 0.1
¹ В столбце Время добавлены замеры одного из читателей на Windows 7 SP1 x64 с VS 2017 v15.9.17 и gcc под cygwin.
² Крайне странный факт: если fprintf() заменить на cerr <<
, то время выполнения сократится в 3 раза: 1386/1527 мс.
³ VS в релизных сборках на моей системе выдает очень непостоянные результаты, поэтому в дальнейших рассуждениях я использую цифры читателя.
Результаты получились… интересные:
- Показатели сильно плавают на разных машинах и/или окружениях и особенно «чудит» VS. Чем это вызвано — непонятно.
- Использование
cerr <<
вместо fprintf() в паре с выбросом исключения в VS в отладочной сборке ускоряет цикл в 3-4 раза (строка 3). ЧЯДНТ? - Во всех случаях расходы на блок try в отсутствие throw — мизерные (4-28 мс на 100 тысяч итераций).
- Не считая «разогнанного» Debug в VS, выброс исключений в
saneex.c
быстрее, чем во встроенных языковых конструкциях (в 2.3 раза быстрее VS, в 5 раз быстрее gcc/g++), а try без throw — помедленнее, но речь идет о единицах миллисекунд. Вот это поворот!
Что тут можно сказать… Есть о чем похоливарить. Добро пожаловать в комментарии!
Для меня самый важный use-case — это много блоков try с крайне редкими throw («лови много, бросай мало»), а он зависит практически только от скорости setjmp(), причем производительность последнего, судя по таблице, далеко не так плоха, как часто думают. Косвенно это подтверждается и вот этой статьей, где автор после замеров делает вывод, что один вызов setjmp() равен двум вызовам пустых функций в OpenBSD и полутора (1.45) — в Solaris. Причем эта статья от 2005 года. Единственное «но» — сохранять нужно без сигнальной маски, но она обычно и не интересна.
Ну, а напоследок…
Виновник торжества — saneex.c
Библиотека, чей пример был на КДПВ:
- может компилироваться даже в Visual Studio
- поддерживает любую вложенность блоков, throw() из любого места, finally и несколько catch на блок (по коду исключения)
- не выделяет память и не использует указатели (все в static)
- опционально-многопоточная (__thread/_Thread_local)
- в public domain (CC0)
Интересующиеся могут найти ее исходники на GitHub. Ниже я кратко на одном примере покажу, как ей пользоваться и какие есть подводные камни. Код примера из saneex-demo.c
в репозитории:
01. #include <stdio.h> 02. #include "saneex.h" 03. 04. int main(void) { 05. sxTag = "SaneC's Exceptions Demo"; 06. 07. try { 08. printf("Enter a message to fail with: [] [1] [2] [!] "); 09. 10. char msg[50]; 11. thrif(!fgets(msg, sizeof(msg), stdin), "fgets() error"); 12. 13. int i = strlen(msg) - 1; 14. while (i >= 0 && msg[i] <= ' ') { msg[i--] = 0; } 15. 16. if (msg[0]) { 17. errno = atoi(msg); 18. struct SxTraceEntry e = newex(); 19. e = sxprintf(e, "Your message: %s", msg); 20. e.uncatchable = msg[0] == '!'; 21. throw(e); 22. } 23. 24. puts("End of try body"); 25. 26. } catch (1) { 27. puts("Caught in catch (1)"); 28. sxPrintTrace(); 29. 30. } catch (2) { 31. puts("Caught in catch (2)"); 32. errno = 123; 33. rethrow(msgex("calling rethrow() with code 123")); 34. 35. } catchall { 36. printf("Caught in catchall, message is: %s\n", curex().message); 37. 38. } finally { 39. puts("Now in finally"); 40. 41. } endtry 42. 43. puts("End of main()"); 44. }
Программа выше читает сообщение, бросает исключение и обрабатывает его в зависимости от пользовательского ввода:
- если ничего не ввести — исключение выброшено не будет, и мы увидим:
End of try body Now in finally End of main()
- если ввести текст, начинающийся с единицы, то будет создано исключение с этим кодом (1), оно будет поймано в первом блоке
catch (1)
(26.), а на экране появится:
Caught in catch (1) Your message: 1 hello, habr! ...at saneex-demo.c:18, code 1 Now in finally End of main()
- если ввести двойку, то исключение будет поймано (30.), выброшено новое (со своим кодом, текстом и прочим) с сохранением предыдущей информации в цепочке (33.), дойдет до внешнего обработчика и программа завершится:
Caught in catch (2) Now in finally Uncaught exception (code 123) - terminating. Tag: SaneC's Exceptions Demo Your message: 2 TM! kak tam blok4ain? ...at saneex-demo.c:18, code 2 calling rethrow() with code 123 ...at saneex-demo.c:33, code 123 rethrown by ENDTRY ...at saneex-demo.c:41, code 123
- если ввести
!
, то исключение получится «неуловимым» (uncatchable; 20.) — оно пройдет сквозь все блоки try выше по стеку, вызывая их обработчики (как catch, так и finally), пока не дойдет до внешнего и не завершит процесс — гуманный аналог abort():
Caught in catch (1) Your message: ! it is a good day to die ...UNCATCHABLE at saneex-demo.c:18, code 0 Now in finally Uncaught exception (code 0) - terminating. Tag: SaneC's Exceptions Demo Your message: ! it is a good day to die ...UNCATCHABLE at saneex-demo.c:18, code 0 UNCATCHABLE rethrown by ENDTRY ...at saneex-demo.c:41, code 0
- наконец, если ввести тройку, то исключение попадет в catchall (35.), где просто будет выведено его сообщение:
Caught in catchall, message is: Your message: 3 we need more gold Now in finally End of main()
Остальные «фичи»
Потокобезопасность. По умолчанию ее нет, но если у вас нормальный компилятор (не MSVC¹), то C11 спасет отца народов за счет помещения важных переменных в локальную область потока (TLS):
#define SX_THREAD_LOCAL _Thread_local
¹ Последние годы у Microsoft имеются какие-то подвижки на почве open source, но всем по дело идет медленно, хотя и лучше, чем 8 лет назад, так что мы пока держимся.
sxTag (05.) — строка, которая выводится вместе с непойманным исключением в stderr. По умолчанию — дата и время компиляции (__DATE__ __TIME__).
Создание SxTraceEntry (записи в stack trace). Есть несколько полезных макросов — оберток над (struct SxTraceEntry) {...}
:
newex()
— этот был в примере; присваивает __FILE__, __LINE__ и код ошибки = errno (что удобно после проверки результата вызова системной функции, как в примере после fgets(); 11.)
- код меньше 1 становится 1 (ибо setjmp() возвращает 0 только при первом вызове), поэтому
catch (0)
никогда не сработает
- код меньше 1 становится 1 (ибо setjmp() возвращает 0 только при первом вызове), поэтому
msgex(m)
— как newex(), но также устанавливает текст ошибки (константное выражение)exex(m, e)
— как msgex(), но также прицепляет к исключению произвольный указатель; его память будет освобождена через free() автоматически:
try { TimeoutException *e = malloc(sizeof(*e)); e->elapsed = timeElapsed; e->limit = MAX_TIMEOUT; errno = 146; throw(exex("Connection timed out", e)); } catch (146) { printf("%s after %d\n", curex().message, // читаем через void *SxTraceEntry.extra: ((TimeoutException *) curex().extra)->elapsed); } endtry
И, конечно, есть мои любимые designated initializers из все того же C99 (работают в Visual Studio 2013+):
throw( (struct SxTraceEntry) {.message = "kaboom!"} );
Выброс исключения:
throw(e)
— бросает готовый SxTraceEntryrethrow(e)
— аналогично throw(), но не очищает текущий stack trace; может использоваться только внутри catch/catchallthrif(x, m)
— макрос; приif (x)
создает SxTraceEntry с текстом x + m и «выбрасывает» егоthri(x)
— как thrif(), только с пустым m
Макросы нужны для удобного «преобразования» результата типичного библиотечного вызова в исключение — как в примере с fgets() (11.), если функция не смогла прочитать ничего. Конкретно с fgets() это не обязательно обозначает ошибку (это может быть просто EOF: ./a.out </dev/null
), но других подходящих функций в том примере не используется. Вот более жизненный:
thri(read(0xBaaD, buf, nbyte)); // errno = 9, "Bad file descriptor" // Assertion error: read(0xBaaD, buf, nbyte);
…И «особенности реализации»
Их всего две с половиной (но зато какие!):
- блок обязан заканчиваться на endtry — здесь происходит завершение процесса при отсутствии обработчика (блока try) выше по стеку
- эту ошибку компилятор, скорее всего, поймает, ибо try открывает три
{
, а endtry их закрывает
- эту ошибку компилятор, скорее всего, поймает, ибо try открывает три
- нельзя делать return между try и endtry — это самый жирный минус, но моя фантазия не нашла способов отловить эту ситуацию; принимаются идеи и PR
- естественно, goto внутрь и наружу тоже под запретом, но разве его кто-то использует?
</sarcasm>
- естественно, goto внутрь и наружу тоже под запретом, но разве его кто-то использует?
Что касается «половины», то это уже разобранный ранее volatile. «Прием» исключения — это повторный вход в середину функции (см. longjmp()), поэтому, если значение переменной было изменено внутри тела try, то такая переменная не должна использоваться в catch/catchall/finally и после endtry, если она не объявлена как volatile. Компилятор заботливо предупредит о такой проблеме. Вот наглядный пример:
int foo = 1; try { foo = 2; // здесь можно использовать foo } catchall { // а здесь уже нет! } finally { // и здесь тоже! } endtry // и здесь нельзя!
С volatile переменную можно использовать где угодно:
volatile int foo = 1; try { ...
Итог: как это работает
У каждого потока есть два статически-выделенных (глобальных) массива:
struct SxTryContext
— информация о блоках try, внутри которых мы сейчас находимся — в частности, jmp_buf на каждый из них; например, здесь их два:
try { try { // мы здесь } endtry } endtry
struct SxTraceEntry
— текущий stack trace, то есть объекты, переданные кодом снаружи для идентификации исключений; их может быть больше или меньше, чем блоков try:
try { // один SxTryContext try { // два SxTryContext // ноль SxTraceEntry throw(msgex("Первый пошел!")); // один SxTraceEntry } catchall { // один SxTraceEntry rethrow(msgex("Второй к бою готов!")); // два SxTraceEntry (*) } endtry } endtry
Если в коде выше вместо rethrow() использовать throw(), то объектов SxTraceEntry (*)
будет не два, а один — предыдущей будет удален (stack trace будет очищен). Кроме того, можно вручную добавить элемент в цепочку через sxAddTraceEntry(e)
.
try и другие элементы конструкции суть макросы (— ваш К. О.). Скобки { }
после них не обязательны. В итоге, все это сводится к следующему псевдокоду:
try { int _sxLastJumpCode = setjmp(add_context()¹); bool handled = false; if (_sxLastJumpCode == 0) { throw(msgex("Mama mia!")); clearTrace(); sxAddTraceEntry(msgex(...)); if (count_contexts() == 0) { fprintf(stderr, "Shurik, vsё propalo!"); sxPrintTrace(); exit(curex().code); } else [ longjmp(top_context()); } } catch (9000) { } else if (_sxLastJumpCode == 9000) { handled = true; } catchall { } else { handled = true; } finally { } // здесь действия в finally { } } endtry remove_context(); if (!handled) { // как выше с throw() }
¹ Имена с _ в библиотеке не используются, это абстракции.
Думаю, после подробных объяснений, как работает SJLJ, что-то еще здесь комментировать излишне, а потому позвольте откланяться и предоставить слово уже вам.
ссылка на оригинал статьи https://habr.com/ru/post/491084/
Добавить комментарий