С++20 и асинхронный ввод-вывод для обработки крашей приложения
Каждый уважающий себя C+±разработчик однажды делал что-то неприличное в продуктовом коде: разыменовывал нулевой указатель, лез в чужую память, делил на ноль и/или переполнял знаковое целое.
В общем случае правильным будет сказать, что это неопределённое поведение, а следовательно у нас нет никаких гарантий касательно того, что именно произойдёт, а значит, что так лучше просто не делать. Как бы то ни было, подобные ситуации всё же случаются время от времени.
И что же мы имеем на практике? Несмотря на то, что формально может произойти что-угодно, на практике мы имеем то, что целочисленное деление на ноль генерирует процессорное прерывание на x64_86, которое для пользовательского кода на Linux превратится в SIGFPE, а разыменование nullptr или доступ в память другого процесса аукнется нашему приложению поднятием SIGSEGV. Предположу, что на MacOS и остальных POSIX-системах всё работает примерно так же. В Windows обработчики сигналов сильно обрезаны и в данный момент поддержки Windows в моём решении нет, так что о ней будет сказано несколько слов в конце. Поднятый сигнал, разумеется, можно программно обработать с помощью пользовательского обработчика вместо дефолтного.
А что нам, собственно, делать, если случилось целочисленное деление на ноль? Главное, что хочется сделать — это отправить все логи (и любую другую информацию о состоянии системы), которые находятся в буферах приложения, в коллектор, чтобы умные люди смогли изучить их, понять где баг, и исправить его. Отправка логов на удалённые сервера может быть выполнена асинхронно, не говоря уже о том, что их может быть полезно записать ещё и на диск или ещё куда-то. Почему неблокирующий ввод-вывод в среднем по палате круче и быстрее блокирующего объяснять, я думаю, не требуется.
Однако, сразу же возникает несколько вопросов:
Почему бы не сделать это всё в обработчике сигналов так же, как и в остальном приложении?
Cкорее всего, в приложении уже используется какая-то библиотека для асинхронщины (boost-asio, например). Можно было бы использовать её же для работы в обработчике, но обработчики сигналов — это среда с очень большим количеством ограничений. Например, крайне нежелательно использовать глобальные объекты (их список стоит максимально ограничить); можно использовать только список системных вызовов, строго одобренный партией (и среди него нет таких важных вещей, как спавн потоков и аллокация памяти у системы); нельзя использовать исключения, так как под них может аллоцироваться память; а также нельзя использовать мьютексы, что сразу же отсекает большую часть стандартной библиотеки, которая может аллоцировать память или работать с системными вызовами. Ни один разумный корутинный фреймворк (или даже некорутинные фреймворки для асинхронного IO) не станет реализовывать подобные ограничения у себя просто ради того, чтобы им можно было пользоваться в том числе в обработчиках сигналов, так как это, мягко говоря, корнер-кейс. Поэтому нужно специализированное решение.
Почему бы просто не проигнорировать ограничения signal-safe программирования?
Можно, но не стоит. Скорее всего вы получите адекватный результат работы в большинстве случаев, но не во всех. Готовы ли вы жить, зная, что существует вероятность того, что однажды ваш клиент зарепортит вам краш приложения без какой-либо дополнительной информации?
Какая такая страшная ситуация вообще может случиться, если проигнорировать ограничения, упомянутые ранее?
Начнём с глобальных объектов. Зачастую ваш обработчик не знает, где произошёл краш. А произойти он мог именно при обращении приложения к какому-то из глобальных объектов. Раз в нём случился краш — он находится в каком-то невалидном состоянии на момент вызова обработчика сигналов и обращение к нему можно смело классифицировать как UB. Выбирать глобальные объекты, с которыми будет идти взаимодействие в обработчике краша, стоит очень консервативно, а на случай, если вдруг наш «доверенный» объект окажется под капотом гнилым и повторно крашнет приложение, у обработчика краша должен быть свой собственный запасной обработчик, который можно будет позвать, если вдруг в нём что-то пойдёт не так (SIG_DFL — достаточно хороший вариант в этом смысле).
Теперь немного о мьютексах. Может возникнуть такая ситуация, что лок на мьютексе был захвачен аккурат перед вызовом обработчика сигналов (который может быть вызван примерно в любом месте в коде). Если внутри обработчика сигналов будет попытка захватить этот же лок повторно — приложение навсегда зависнет на попытке захватить мьютекс, так как освободить его на том же потоке не получится.
Ограничение на мьютексы объясняет в том числе и то, что мы вынуждены использовать строго одобренный список системных вызовов и функций стандартной библиотеки C, так как многие функции стандартной библиотеки C либо работают с глобальными объектами, либо захватывают какие-либо мьютексы, чтобы гарантировать потокобезопасность (printf хотя бы). То же самое относится и к системным вызовам, например mmap, потому что они также могут захватывать какие-то локи в kernel-space.
Почему бы просто не вернуться из обработчика в главный цикл событий?
Хочется сказать, что раз обработчики такие неудобные — давайте выставим флаг и вернёмся в главный цикл событий. Там его проверим и отошлём всю важную диагностическую информацию по-старинке.
Так сделать просто нельзя, если случился краш, хоть это и адекватный подход, когда нам просто приходит сигнал от другого процесса, а с текущим всё нормально. Нельзя полагаться на то, что после деления на ноль или любого другого подобного сценария программа будет в достаточно валидном состоянии, чтобы дойти в главном цикле событий до обработки краша. Более того, конкретно при делении на ноль процессор вероятно попробует заново воспроизвести инструкцию, которая вызвала обработчик сигнала. И, если делитель числа не был изменён в обработчике, снова произойдёт целочисленное деление на 0, что вызовет обработчик сигнала повторно и мы получим дедлок.
Всё таки — а зачем использовать корутины, а не колбеки?
Такой код хуже читается и, скорее всего, будет хуже оптимизирован, чем корутины.
Оптимизацией корутинного состояния по большей занимается компилятор С++, а не разработчики корутинных библиотек. Компилятор имеет полное представление о том, что происходит в программе, а значит имеет больше возможностей для оптимизации. Кроме того, континьюатор — это всегда аллокация, так как его нужно куда-то руками сложить. В корутинах, во-первых, можно использовать awaiters, которые могут явно не использовать никакой динамической памяти, а во-вторых, даже в тех случаях, когда речь идёт о корутинах, в некоторых случаях может произойти coroutine-allocation-elision, из-за чего стек новой корутины будет встроен в стек предыдущей.
А эстетическое превосходство кода на корутинах лучше продемонстрировать (тем более, что это несложно). Вот пример использования корутин в гипотетическом фреймворке, который я дополнил версией того же самого кода, написанного на континьюаторах:
fut<void> sleep(std::chrono::seconds);
fut<int> read();
fut<void> write(int n);
fut<int> fetch_and_increment_continuators()
{
return read().then([](int n) {
return sleep(1s).then([n]() {
return write(n + 1).then([n]() {
return fut<int>::ready(n);
});
});
});
}
// выглядит так же просто, как синхронный код btw
fut<int> fetch_and_increment_coro() {
auto n = co_await read();
co_await sleep(1s);
auto new_n = n + 1;
co_await write(new_n);
co_return n;
}
Раз всё так непросто, то почему бы нам просто не использовать блокирующий IO?
Это медленней. А обрабатывать краши быстро может быть важно как с точки зрения минимизации даунтайма сервиса (если например настроен авторестарт в systemd), так и с точки зрения сохранности информации для диагностики, если вдруг система настроена так, что надолго зависшее в обработчике приложение автоматически убивается спустя время.
Какие существуют решения для асинхронщины в обработчиках сигналов?
В опенсурсе решение одно - corosig. corosig написан на c++20 (более новый стандарт не хотелось брать, чтобы сделать библиотеку доступнее) и предоставляет фреймворк для работы с корутинами (а соответственно и асинхронного ввода-вывода) внутри обработчиков сигналов. В данный момент поддерживаются Linux и MacOS, хотя код написан так, чтобы заводиться в целом под любые POSIX-системы, поэтому не должно быть большой проблемой запустить его на условном BSD.
Ближайшим аналогом можно назвать pigweed-async. С чтения их статьи я начинал разработку corosig и готов посоветовать её почитать, хотя по-большому счёту мысли оттуда будут повторены здесь с некоторыми дополнениями. Однако, их библиотека целится в embedded-разработку, а не в обработчики сигналов. Это означает, что как минимум ограничения на системные вызовы не такие сильные, почему pigweed и использует у себя, например, спавн потоков и небезопасный вызов epoll.
Стоит также отметить и то, что один из альтернативных подходов к обработке краша — это заспавнить процесс, которому в его stdout будет скормлена вся информация о краше, после чего он сможет в привычной (не специфичной для обработчиков сигналов) манере отослать информацию о краше во все возможные коллекторы, но у этого подхода есть несколько проблем:
-
Для коммуникации между процессами придётся выдумывать свой протокол или кодировать информацию в один из существующих. Реализовать кодирование protobuf или json внутри обработчика сигналов — это отдельная задача, по сей день не решённая (по крайней мере я на эту тему ничего не слышал). Свой протокол — это сложность ровно по той же самой причине. И придётся не только кодировать сообщения, а ещё и декодировать их на стороне другого процесса. В общем, весёлого мало.
-
Это требует поставлять где-то рядом с вашим приложением дополнительный исполняемый файл, что само по себе уже является некоторой трудностью, а для некоторого ПО это требование и вовсе может быть недопустимым. Да и даже если у вас получилось его поставить — начинаются проблемы с тем, насколько вы доверяете системе, в которой будет работать (и крашиться) ваше приложение. Минимальное, что может сделать непутёвый пользователь — это переместить исполняемый файл вашего приложения или обработчика крашей, из-за чего вы не сможете получить к нему доступ.
В данный момент corosig способен решать большую часть задач асинхронного программирования на POSIX-системах:
-
Прерывание работы текущей корутины на минимальное время, чтобы дать поработать другим (
Yield) -
Прерывание работы текущей корутины до момента, пока в файловом дескрипторе не станет готовым для чтения/записи (
PollEvent) -
Прерывание работы текущей корутины на заданный срок (
Sleep) -
Асинхронный ввод-вывод над пайпами, сокетами, стандартными устройствами ввода-вывода, а также файлами (хотя конкретно на linux они на самом деле блокирующие, так как poll всегда считает их готовыми) (
File,StdIn,StdOut,PipeWrite,PipeRead,TcpSocket,UdpSocket) -
Получение новых соединений (
TcpListener) -
Резолв ascii-доменных имён в IP-адреса с возможностью тонкой настройки поведения локальных и системных кешей доменных имён (
dns::Resolver,dns::CachelessResolverи др.) -
Межкорутинная синхронизация (
Promise,Semaphore,when_all,when_all_succeed,with_timeout) -
Запуск задач в фоновом режиме (
BackgroundTask) -
Отмена запущенных корутин (Подробнее далее)
Далее мне хотелось бы поговорить о некоторых интересных (как мне кажется) технических деталях реализации corosig, после чего уже перейти к примерам использования API библиотеки.
Немного о корутинах С++20
Корутины C++20 — это функции, способные приостанавливать выполнение (co_await/co_yield) и возобновлять его позже, сохраняя локальное состояние. Компилятор преобразует их в конечный автомат, состояние которого в общем случае аллоцируется на хипе, управляемый через promise_type, который связывает результат и механизм возобновления выполнения.
В целом тема не новая, поэтому останавливаться я на ней не буду. Да и cppreference скорее всего лучше справится с рассказом, чем я.
Реактор - место, которое решает, какие корутины можно продолжить
Здесь не будет рассказано никаких откровений. Только пара слов о том, как работает примерно любой однопоточный корутинный планировщик (он же реактор).
Единственная часть, которая требует сколько-нибудь весомых обращений к операционной системе — это поллинг событий в файловых дескрипторах (всё же всё IO неизбежно происходит на стороне OS). Для этого есть разные варианты, специфичные для каждой OS. kqueue на MacOS и BSD, epoll на linux и IOCP на Windows. На всех POSIX системах доступен poll, и именно его и использует corosig, так как всё остальное не находится в списках сигнально-безопасных системных вызовов. Очередь, корутины в которой ждут возникновения события в файловом дескрипторе, я для простоты буду называть PollList.
Остальные 2 части — это очередь готовых к продолжению задач и очередь для задач, которые станут готовыми по истечении времени. Эти очереди для простоты мы назовём ReadyList и SleepList.
Зачастую системные вызовы для поллинга принимают в себя таймаут одним из аргументов. Чему он должен быть равен? В общем случае он должен быть равен времени, через которое станет готова хотя бы одна корутина из SleepList. Если же есть хоть одна корутина в ReadyList, таймаут должен быть таким, чтобы системная функция поллинга мгновенно вернулась. Если же и ReadyList, и SleepList пусты — ждать можно вечность. В теории. Но на практике иногда операционная система может забыть разбудить poll после появления события, если мониторится большое количество файловых дескрипторов. Поэтому вместо вечного ожидания poll спит по секунде, просыпаясь время от времени и проверяя возникновение новых событий.
Как должны выглядеть все эти очереди? Строго говоря, это уже деталь реализации, которая имеет шансы выглядеть по-разному. Однако, чаще всего эти очереди — это интрузивные списки или бинарные деревья, если задачи нужно сортировать по какому-то специфичному правилу (SleepList сортируется по таймстампам). promise_type корутин знает о том, что его могут добавить в очередь готовых задач, что позволяет совершать «примитивные» операции, навроде переключения потока на другую корутину или ожидания события в файле, более простыми под капотом. Вместо потенциальной аллокации места под задачу в реакторе мы получаем присвоение нескольких указателей, что даёт возможность говорить о большей стабильности (аллокация могла бы провалиться) и, вероятно, большем быстродействии.
В corosig используются интрузивные списки (и другие контейнеры) из boost::intrusive.
Корутины без динамической памяти
Итак, я успел сказать 2 противоречащие друг другу вещи:
— В обработчиках сигналов можно и нужно писать корутинированный код — Корутины всегда или почти всегда динамически аллоцируют место под своё состояние (напомню, что в обработчиках сигналов нельзя аллоцировать память)
Чтобы решить это противоречие, был написан аллокатор, работающий с предаллоцированным буфером фиксированного размера. Аллокатор лежит в реакторе, ссылка на который передаётся первым аргументом в каждую корутину:
Fut<int> do_stuff(Reactor&, ...) noexcept;
Зачем первым аргументом? Дело в том, что при создании состояния корутины сначала зовётся переопределённый в promise_type operator new(), если он переопределён, а далее позовётся конструктор promise_type. В обоих случаях будет передан список аргументов корутины. Из него-то мы и достанем реактор и обратимся к аллокатору внутри него:
struct CoroutinePromiseType {
CoroutinePromiseType(Reactor &reactor, // #1
NotReactor auto const &...) noexcept
: m_reactor{reactor} {
}
CoroutinePromiseType( // #2
NotReactor auto const &..., // some object which has coroutinized method
Reactor &reactor,
NotReactor auto const &...) noexcept
: m_reactor{reactor} {
}
...
static void *operator new(size_t n, // #1
Reactor &reactor,
NotReactor auto const &...) noexcept {
...
return reactor.allocator().allocate(n, alignof(std::max_align_t));
}
static void *operator new(size_t n, // #2
NotReactor auto const &, // some object which has coroutinized method
Reactor &reactor,
NotReactor auto const &...) noexcept {
...
return reactor.allocator().allocate(n, alignof(std::max_align_t));
}
...
}
template<typename T, typename E>
struct Fut {
...
using promise_type = CoroutinePromiseType;
...
}
Fut<int> coro(Reactor &r) noexcept { // #1 operator new and ctor are called
co_return 10;
}
struct Bar {
Fut<int> coro(Reactor &r) noexcept { // #2 operator new and ctor are called
co_return 20;
}
}
Помимо того, что мы переопределили operator new, нужно также переопределить оператор delete, но вовсе не для того, о чём вы подумали:
struct CoroutinePromiseType {
...
static void operator delete(void* ptr) {
// nothing to do in here since reactor is not accessible. instead, a coro frame is released when
// future is destroyed
}
...
}
Да, в нём не будет происходить абсолютно ничего. operator delete зовётся, если не получилось применить coroutine-allocation-elision и корутина тем или иным образом завершилась. Причём перед ним зовётся деструктор CoroutinePromiseType. Указатель, который в него передаётся — это тот же самый указатель, который был получен через operator new. И с ним есть сразу несколько проблем:
-
Его нельзя привести к CoroutinePromiseType, так как стандарт не даёт абсолютно никаких гарантий касательно того, CoroutinePromiseType будет лежать в начале саллоцированного буфера, хоть фактически это и происходит именно так на gcc и clang.
-
Даже если привести его к CoroutinePromiseType, это будет указателем на объект, у которого уже был вызван деструктор. Использовать данные оттуда — это UB.
Поэтому память освобождается в деструкторе футуры:
~Fut() {
if (m_handle.value != nullptr) {
Reactor &reactor = promise().m_reactor;
void *addr = m_handle.address();
bool needs_dealloc = promise().m_needs_dealloc;
m_handle.destroy();
if (needs_dealloc) { // coroutine-allocation-elision не применили
reactor.allocator().deallocate(addr);
}
m_handle = nullptr;
}
}
Тут, к сожалению, опять есть некоторые проблемы.
Во-первых, чтобы это работало, в рантайме проверять, что корутине нужна деаллокация. Этого бы можно было избежать, если бы корутина деаллоцировалась в операторе delete, но это невохможно по вышеупомянутым причинам.
Во-вторых, строгих гарантий касательно того, что std::coroutine_handle<>::address() будет эквивалентен тому, что было получено из operator new, нет. Однако я не вижу даже гипотетических причин возвращать что-то другое. И многочисленные тесты в проекте адекватно работают на gcc-16 и clang-22, что доказывает, что на практике возвращается нужный для деаллокации указатель. Надеюсь, что данное поведение будет уточнено в одном из грядущих стандартов.
Это очевидно лучше удаления в operator delete, так как позволяет нам:
-
Не обращаться к CoroutinePromiseType после того как был позван его деструктор (ссылка на аллокатор достаётся и кешируется на стеке)
-
Привязка лайфтайма состояния корутины к лайфтайму футуры даёт неожиданный и притом очень полезный сайд-эффект, которому будет посвящён следующий раздел
Отмена выполнения корутины без головных болей
Так как большую часть времени я работал с seastar, я буду говорить именно о нём. Отменить запущенную задачу в нём — это адская боль. Просто позвать. destroy() на фрейме корутины нельзя — на стеке корутины могут находиться объекты с неработающим RAII из-за требования позвать футурированный метод. close() и сделать co_await на результате. Даже если опустить этот факт — помимо. destroy() на фрейме корутины, необходимо позвать. destroy() на фреймах всех корутин, которые находятся выше в стеке вызовов. А сделать это, просто позвав. destroy() в корне стека вызовов, не особо возможно.
Всё таки, совсем без отмены в корутинах нельзя, и на этот счёт у seastar есть костыль — seastar::abort_source. Пользователь говорит, что ему нужно сделать abort, а потом в своей корутине на всех уровнях во всех местах, где можно безопасно сделать аборт (небезопасными места становятся из-за требования корутинированных методов .close() на объектах перед их уничтожением), руками проверяет, что отмена была запрошена. Также валиден вариант после проверки abort_source где-то наверху стека вызовов бросить исключение, чтобы все корутины ниже были завершены, так как сами перебросят это исключение ещё дальше.
Вот пример подобного кода на seastar:
seastar::future<> write_aboba_10_times(seastar::output_stream<char> out);
// требуется писать отдельную абортируемую корутину.
// переиспользовать внутри этой функции write_aboba_10_times невозможно,
// так как внутри неё нет проверок abort_source
seastar::future<> write_aboba_10_times_abortable(
seastar::abort_source& as,
seastar::output_stream<char> out) {
std::exception_ptr eptr;
try {
for (size_t i = 0;
i < 10 && !as.abort_requested(); // требуется явная проверка abort_source
++i) {
// в случае аборта мы всё равно дождёмся конца текущей записи.
// если речь идёт о записи большого куска данных, то есть шанс
// провести в этой корутине довольно долгое время
co_await out.write("aboban");
}
} catch(...) {
eptr = std::current_exception();
}
co_await out.close();
if (eptr) {
std::rethrow_exception(std::move(eptr));
}
co_return;
}
В corosig такого цирка нет именно благодаря привязке лайфтайма корутины к к лайфтайму футуры. Стандарт нам гарантирует, что если был вызван std::coroutine_handle<>::destroy(), то позовутся деструкторы у всех объектов, которые лежат на фрейме корутины, в том числе и футур, которые владеют объектами всех дочерних корутин. Вкупе с тем, что все объекты в библиотеке corosig разрушаются без ожиданий результатов ‘специальных’ методов через co_await, это даёт возможность отменять любые корутины в любой момент. Этой фичей пользуются в том числе и некоторые модули библиотеки в своей реализации (например, dns::CachelessResolver для отмены повторной отправки UDP-запросов, когда ответ на один из них был получен).
Возврат ошибок без исключений
C++ без исключений — тема не новая и по её поводу в стандартной библиотеке уже давно есть std::optional и std::expected. std::expected, к сожалению, появился только в c++23, что помешало использовать его. Поэтому было принято решение написать свой класс Result (чтобы было как в расте, ага).
У Result есть 2 шаблонных параметра — значение и ошибка.
template <typename R, typename E>
struct [[nodiscard]] Result;
Для того, чтобы вызывать правильный конструктор под ошибку или результат — есть 2 вспомогательных типа: Ok и Failure. Ошибку можно положить только через Failure. Подобное ограничение сделано, чтобы места возврата ошибок было так же удобно искать по коду, как и места проброса исключений по ключевому слову throw.
Result<int, AllocationError> res1 = Ok{10};
Result<int, AllocationError> res2 = 10; // то же самое, что конструктор с Ok{10}
Result<int, AllocationError> res3 = Failure{AllocationError{}};
Result<int, AllocationError> res4 = AllocationError{}; // не скомпилируется
Для случаев, когда требуется вернуть один из нескольких типов ошибок, существует класс Error, который под капотом является обыкновенным std::variant. Воврат ошибок напрямую через std::variant не вполне подошёл, так как std::variant не умеет делать преобразования из одного варианта в случаях, когда из альтернативы первого содержатся во втором в другом порядке или их список расширен. Например:
Result<void, Error<AllocationError, SyscallError>> res1 = Failure{AllocationError{}};
Result<void, Error<AllocationError, SyscallError>> res2 = Failure{SyscallError::current()};
Result<void, Error<AllocationError, SyscallError, CriticalError>> res3 = res1;
Result<void, Error<SyscallError, AllocationError>> res3 = res1;
Кроме того, в случаях, когда список возможных ошибок надо расширить в рамках метапрограммирования, существует утилита extend_error, которая
-
Конкатенирует списки ошибок из двух типов Error
-
Удаляет из списка все дупликаты
-
Удаляет из списка специальный тип
NoError, если он там есть -
Если в списке остался один тип — возвращает его, а иначе возвращает список ошибок, обёрнутый в
Error
Для наглядности:
struct A1 {};
struct A2 {};
struct A3 {};
struct A4 {};
static_assert(std::same_as<extend_error<Error<A1, A2>, A3, Error<A4>>, Error<A1, A2, A3, A4>>);
static_assert(std::same_as<extend_error<A1, A2, A3, A4>, Error<A1, A2, A3, A4>>);
static_assert(std::same_as<extend_error<A1, A2, A3, A4, A4, A4>, Error<A1, A2, A3, A4>>);
static_assert(std::same_as<extend_error<Error<A1, A2, A3>, A2, A3, A4, A4, A4>, Error<A1, A2, A3, A4>>);
static_assert(std::same_as<extend_error<Error<A1, A2, A3>, A4, NoError>, Error<A1, A2, A3, A4>>);
static_assert(std::same_as<extend_error<A1, A1>, A1>);
Правила преобразования для Result определены весьма либерально, что добавляет удобства при возврате в функциях. Например, Result<R1, E1> одного типа можно превратить в Result<R2, E2> другого, если R1 конвертируемо в R2, а E1 конвертируемо в E2.
Result<int, AllocationError> do_stuff() noexcept;
Result<int, Error<AllocationError, SyscallError>> do_complex_stuff() noexcept {
...
return do_stuff();
}
Для удобства также определены макросы
-
COROSIG_TRY- присвоить value в указанную переменную, если оно есть вResult, иначе вернуть ошибку из функции -
COROSIG_TRYV- если есть ошибка, вернуть её из функции, иначе просто проигнорировать value
Есть также аналоги, которые кастят возвращаемую ошибку к указанному типу, что бывает полезно для функций с deduced return type: COROSIG_TRYT, COROSIG_TRYTV
Result<void, NontrivialError> do_stuff() noexcept {
COROSIG_TRY(size_t metric, get_metric());
COROSIG_TRYV(post_metric(metric));
return Ok{};
}
Помимо этого стоит сказать, что Fut<R, E> после co_await разрешится именно в Result (и по большому счёту просто им под капотом и является), из-за чего всё вышесказанное в большой степени применимо и к корутинированному коду. Единственный нюанс здесь - это то, что в абсолютно любой корутине может случиться AllocationError, что значит, что этот тип ошибки будет сопровождать любую футуру:
Fut<void, AllocationError> do_ok() noexcept {
co_return Ok{};
}
Fut<void, AllocationError> do_failure() noexcept {
co_return Failure{AllocationError{}};
}
Fut<void, Error<AllocationError, ServerCommunicationError, PostMetricError>> do_stuff() noexcept {
COROSIG_CO_TRY(size_t metric, co_await get_metric_from_remote_server());
COROSIG_CO_TRYV(co_await post_metric(metric));
co_return Ok{};
}
Параллельная отправка логов в случае краша
Итак, вот мы и добрались до практических примеров. Давайте начнём с того, как у нас лежат данные.
Для начала решим, что наше решение для логгирования — это глобальный синглтон (как оно зачастую и бывает), в котором есть несколько выводов, каждый из которых умеет возвращать список неотправленных логов (для простоты примера список представлен как std::span), а также есть возможность получить список выводов из глобального синглтона:
using namespace corosig;
struct FilesystemOutput {
...
std::span<std::string_view> unsent_logs();
std::filesystem::path path;
};
struct RemoteOutput {
...
std::span<std::string_view> unsent_logs();
enum class Protocol : uint8_t {
TCP, UDP
};
std::string name_or_addr;
uint16_t port;
Protocol proto;
};
struct StdoutOutput {
...
std::span<std::string_view> unsent_logs();
enum class Kind : uint8_t {
STDOUT, STDERR
};
Kind kind;
};
struct AnyOutput : std::variant<FilesystemOutput, RemoteOutput, StdoutOutput> {
};
struct Logger {
...
void configure(std::span<AnyOutput> outputs);
std::span<AnyOutput> outputs();
...
};
std::unique_ptr<Logger> g_logger = ...;
Теперь, когда заложен фундамент, можно перейти к написанию обработчика крашей. Начнём с того, что разберёмся, как вывести логи в StdoutOutput, так как концептуально он самый простой
StdOut resolve_output(StdoutOutput::Kind kind) noexcept {
using enum StdoutOutput::Kind;
switch (kind) {
case STDOUT: return StdOut::stdout();
case STDERR: return StdOut::stderr();
}
std::unreachable();
}()
}
Fut<void, Error<AllocationError, SyscallError>> write_to_stdout(
Reactor &r, // напомню, что реактор первым аргументом — это обязательное для всех корутин требование
StdoutOutput const& logger_output) noexcept {
StdOut stdout = resolve_output(logger_output.kind);
COROSIG_CO_TRYV(co_await stdout.write(r, "Printing logs to stdoutn"));
for (std::string_view log : logger_output.unsent_logs()) {
COROSIG_CO_TRYV(co_await stdout.write(r, log));
}
COROSIG_CO_TRYV(co_await stdout.write(r, "n"));
co_return Ok{};
}
Далее, выведем логи в файловую систему. Концептуальных отличий тут нет:
Fut<void, Error<AllocationError, SyscallError>> write_to_file(
Reactor &r,
FilesystemOutput const& logger_output) noexcept {
using enum File::OpenFlags;
COROSIG_CO_TRY(auto file, co_await File::open(r, logger_output.path.c_str(), CREATE | WRONLY));
for (std::string_view log : logger_output.unsent_logs()) {
COROSIG_CO_TRYV(co_await file.write(r, log));
}
co_return Ok{};
}
Чуть менее скучно будет выглядеть вывод на удалённый сервер, так как нам потребуется помимо всего такого прочего ещё и потенциально зарезолвить доменное имя сервера, да и протоколы отличаются:
Fut<void, Error<AllocationError, SyscallError>> send_via_tcp(
Reactor &r,
SockaddrStorage const& addr,
std::span<std::string_view> unsent_logs) noexcept {
COROSIG_CO_TRY(auto socket, co_await TcpSocket::connect(r, addr));
for (std::string_view log : unsent_logs) {
COROSIG_CO_TRYV(co_await socket.write(r, log));
}
co_return Ok{};
};
Fut<void, Error<AllocationError, SyscallError>> send_via_udp(
Reactor &r,
SockaddrStorage const& addr,
std::span<std::string_view> unsent_logs) noexcept {
COROSIG_CO_TRY(auto socket, UdpSocket::unbound());
for (std::string_view log : unsent_logs) {
COROSIG_CO_TRYV(co_await socket.send_to(r, log, UDP_SERVER_ADDR));
}
co_return Ok{};
};
Fut<IpvNAddr, Error<AllocationError, SyscallError, ResolveError>> resolve_name_or_addr(
Reactor& r,
std::string_view name_or_addr,
dns::Resolver<>& dns_resolver) noexcept {
auto dns_server_addrs = std::to_array({Ipv4Addr::parse("1.1.1.1")->to_sockaddr(dns::STANDARD_PORT)});
std::optional res = IpvNAddr::parse(name);
if (!res) {
co_return co_await dns_resolver.resolve_name1(name_or_addr, dns_server_addrs, name_or_addr);
}
co_return *res;
}
Fut<void, Error<AllocationError, SyscallError>> send_to_remote(
Reactor &r,
dns::Resolver<>& dns_resolver, // никакой кастомизации не требуется, так что выбран дефолтный резолвер
RemoteOutput const& logger_output) noexcept {
COROSIG_CO_TRY(IpvNAddr remote_addr, co_await resolve_name(r, logger_output.name_or_addr, dns_resolver));
using enum RemoteOutput::Protocol;
switch(logger_output.proto) {
case UDP: co_return co_await send_via_udp(r, addr, logger_output.unsent_logs());
case TCP: co_return co_await send_via_tcp(r, addr, logger_output.unsent_logs());
}
std::unreachable();
}
На будущее заведём себе вспомогательную функцию, которая возвращает dns::Resolver, проинициализированный сидом из /dev/urandom
Fut<dns::Resolver<>, Error<AllocationError, SyscallError>> make_dns_resolver(Reactor &r) noexcept {
std::array<char, 32> rnd_seed;
COROSIG_CO_TRYV(co_await read_dev_urandom(r, rnd_seed));
COROSIG_CO_TRY(auto cacheless_resolver,
dns::CachelessResolver::make(rnd_seed, Ipv4Addr{}.to_sockaddr()));
co_return dns::Resolver{
dns::Cache<>{r, dns::HOSTS_FILE_PATH, r.allocator()},
std::move(cacheless_resolver),
};
}
Теперь дело за малым — создать задачу на отправку под каждый output и дождаться их всех. Именно эта функция и будет точкой входа в мир корутин для нашего обработчика сигналов. Вторым аргументом будет приниматься номер поднятого сигнала (хотя конкретно здесь он проигнорируется, так как нет большого смысла дифференциировать поведение в зависимости от происходящего в нём).
Fut<void, Error<AllocationError, SyscallError>> send_all_unsent_logs(
Reactor &r,
int) noexcept {
COROSIG_CO_TRY(dns::Resolver resolver, co_await make_dns_resolver(r));
co_return co_await parallel_foreach(g_logger.outputs(), [&](Reactor& r, AnyOutput const& output) {
return std::visit([&](OUTPUT const& output) -> Fut<void, AllocationError> {
if constexpr (std::same_as<OUTPUT, FilesystemOutput>) {
(void)co_await write_to_file(r, output);
} else if constexpr (std::same_as<OUTPUT, StdoutOutput>) {
(void)co_await write_to_stdout(r, output);
} else if constexpr (std::same_as<OUTPUT, RemoteOutput>) {
(void)co_await send_to_remote(r, resolver, output);
}
co_return Ok{};
}, output);
});
}
Ну и в main подменим стандартный обработчик сигналов на тот, который нужен нам, задав размер куска стека, который будет отдан для динамических аллокаций (16 килобайт для такого простого обработчика как наш — это с очень большим запасом)
void init_logger();
int main() {
init_logger();
for (int signal : {SIGABRT, SIGFPE, SIGILL, SIGBUS, SIGSEGV, SIGSYS, SIGXCPU, SIGXFSZ}) {
corosig::set_sighandler<1024 * 16, send_all_unsent_logs>(signal);
}
...
}
Про Windows
Яне пользовался windows несколько лет и тем более не программировал под неё (Бог уберёг), поэтому дальнейшие мои слова воспринимайте с долей недоверия, ведь основываются они главным образом на чтении статей в интернете и технической документации, и не подкреплены реальным опытом.
Во первых, я не до конца уверен, что в случае странностей в программе поднимаются такие же сигналы, как на POSIX, но это, как выяснилось, и не важно, ведь обработчики сигналов на Windows абсолютно немощные и были добавлены Майкрософт только ради формального соответствия стандарту C. Начнём с того, что они не являются асинхронными. Когда процесс получает сигнал, обработчик запускается в отдельном потоке, и Майкрософт не гарантирует безопасности в нём вообще ни для каких системных вызовов. Всё, что в нём допустимо сделать — выставить глобальный флаг для дальнейшей обработки его в главном цикле событий. Для обработки же абнормальных ситуаций существуют SEH (structured exception handlers) и VEH (vectorized exception handlers). Оба этих механизма разрешают использование большинства системных вызовов, поэтому в них можно заниматься IO, в том числе асинхронно.
SEH требует от пользователя модифицировать основной код своей программы и к тому же мешает ряду оптимизаций, так как компилятору, например, приходится быть готовым к тому, что операции над int могут бросить исключения. По этим причинам его для портирования я рассматривать не буду.
VEH — это наш бро. Концептуально он очень схож с обработчиками сигналов в POSIX. Вероятнее всего, если у меня однажды дойдут руки до портирования на windows — это будет производиться именно на фундаменте VEH
Что дальше?
Библиотека уже сейчас покрывает большинство сценариев асинхронной диагностики на POSIX. Но останавливаться на этом я не собираюсь. В планах:
-
Добавить в CI пайплайны на 32-битных системах. Хочется убедиться, что corosig нормально работает даже на
парковкекартошке. Ну и исправить любые непортируемости, которые есть, само собой, тоже нужно. -
Портировать библиотеку на Windows с использованием VEH
-
Добавить больше утилит для синхронизации между корутинами. Как минимум не хватает корутинных Mutex и SharedMutex и асинхронных очередей
-
Написать инструменты для отправки задач на другие потоки. Несмотря на то, что обработчики сигналов не должны создавать свои потоки, они в теории могли бы безопасно обмениваться объектами с другими потоками через список, который для указателей на соседние элементы использует атомик. Мне видится, что из всех идей эта может даться сложнее всего, так как концептуально она для библиотеки новая
А тем временем, вы уже сейчас можете попробовать corosig в своём проекте и задудосить какой-нибудь эндпоинт мольбами о помощи сразу же после разыменования нулевого указателя.
К тому же, если в своём проекте вы используете xmake, сделать это можно вот настолько просто:
add_repositories("corosig-repo git@github.com:bugsnotabunny/corosig.git v0.1.2")
add_requires("corosig v0.1.2", { external = true })
-- override boost settings and version, if needed
add_requireconfs("corosig.boost", { override = true, version = "1.90.0" })
target("main")
set_kind("binary")
add_packages("corosig")
add_files("main.cpp")
Автор: bugsnotabunny
