- PVSM.RU - https://www.pvsm.ru -
Каждый уважающий себя C+±разработчик однажды делал что-то неприличное в продуктовом коде: разыменовывал нулевой указатель, лез в чужую память, делил на ноль и/или переполнял знаковое целое.
В общем случае правильным будет сказать, что это неопределённое поведение, а следовательно у нас нет никаких гарантий касательно того, что именно произойдёт, а значит, что так лучше просто не делать. Как бы то ни было, подобные ситуации всё же случаются время от времени.
И что же мы имеем на практике? Несмотря на то, что формально может произойти что-угодно, на практике мы имеем то, что целочисленное деление на ноль генерирует процессорное прерывание на x64_86, которое для пользовательского кода на Linux превратится в SIGFPE, а разыменование nullptr или доступ в память другого процесса аукнется нашему приложению поднятием SIGSEGV. Предположу, что на MacOS и остальных POSIX-системах всё работает примерно так же. В Windows обработчики сигналов сильно обрезаны и в данный момент поддержки Windows в моём решении нет, так что о ней будет сказано несколько слов в конце. Поднятый сигнал, разумеется, можно программно обработать с помощью пользовательского обработчика вместо дефолтного.
А что нам, собственно, делать, если случилось целочисленное деление на ноль? Главное, что хочется сделать — это отправить все логи (и любую другую информацию о состоянии системы), которые находятся в буферах приложения, в коллектор, чтобы умные люди смогли изучить их, понять где баг, и исправить его. Отправка логов на удалённые сервера может быть выполнена асинхронно, не говоря уже о том, что их может быть полезно записать ещё и на диск или ещё куда-то. Почему неблокирующий ввод-вывод в среднем по палате круче и быстрее блокирующего объяснять, я думаю, не требуется.
Однако, сразу же возникает несколько вопросов:
Cкорее всего, в приложении уже используется какая-то библиотека для асинхронщины (boost-asio, например). Можно было бы использовать её же для работы в обработчике, но обработчики сигналов — это среда с очень большим количеством ограничений. Например, крайне нежелательно использовать глобальные объекты (их список стоит максимально ограничить); можно использовать только список системных вызовов, строго одобренный партией [1] (и среди него нет таких важных вещей, как спавн потоков и аллокация памяти у системы); нельзя использовать исключения, так как под них может аллоцироваться память; а также нельзя использовать мьютексы, что сразу же отсекает большую часть стандартной библиотеки, которая может аллоцировать память или работать с системными вызовами. Ни один разумный корутинный фреймворк (или даже некорутинные фреймворки для асинхронного IO) не станет реализовывать подобные ограничения у себя просто ради того, чтобы им можно было пользоваться в том числе в обработчиках сигналов, так как это, мягко говоря, корнер-кейс. Поэтому нужно специализированное решение.
Можно, но не стоит. Скорее всего вы получите адекватный результат работы в большинстве случаев, но не во всех. Готовы ли вы жить, зная, что существует вероятность того, что однажды ваш клиент зарепортит вам краш приложения без какой-либо дополнительной информации?
Какая такая страшная ситуация вообще может случиться, если проигнорировать ограничения, упомянутые ранее?
Начнём с глобальных объектов. Зачастую ваш обработчик не знает, где произошёл краш. А произойти он мог именно при обращении приложения к какому-то из глобальных объектов. Раз в нём случился краш — он находится в каком-то невалидном состоянии на момент вызова обработчика сигналов и обращение к нему можно смело классифицировать как 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;
}
Это медленней. А обрабатывать краши быстро может быть важно как с точки зрения минимизации даунтайма сервиса (если например настроен авторестарт в systemd), так и с точки зрения сохранности информации для диагностики, если вдруг система настроена так, что надолго зависшее в обработчике приложение автоматически убивается спустя время.
В опенсурсе решение одно - corosig [2]. corosig написан на c++20 (более новый стандарт не хотелось брать, чтобы сделать библиотеку доступнее) и предоставляет фреймворк для работы с корутинами (а соответственно и асинхронного ввода-вывода) внутри обработчиков сигналов. В данный момент поддерживаются Linux и MacOS, хотя код написан так, чтобы заводиться в целом под любые POSIX-системы, поэтому не должно быть большой проблемой запустить его на условном BSD.
Ближайшим аналогом можно назвать pigweed-async. С чтения их статьи [3] я начинал разработку 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 библиотеки.
Корутины C++20 — это функции, способные приостанавливать выполнение (co_await/co_yield) и возобновлять его позже, сохраняя локальное состояние. Компилятор преобразует их в конечный автомат, состояние которого в общем случае аллоцируется на хипе, управляемый через promise_type, который связывает результат и механизм возобновления выполнения.
В целом тема не новая, поэтому останавливаться я на ней не буду. Да и cppreference [4] скорее всего лучше справится с рассказом, чем я.
Здесь не будет рассказано никаких откровений. Только пара слов о том, как работает примерно любой однопоточный корутинный планировщик (он же реактор).
Единственная часть, которая требует сколько-нибудь весомых обращений к операционной системе — это поллинг событий в файловых дескрипторах (всё же всё 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 несколько лет и тем более не программировал под неё (Бог уберёг), поэтому дальнейшие мои слова воспринимайте с долей недоверия, ведь основываются они главным образом на чтении статей в интернете и технической документации, и не подкреплены реальным опытом.
Во первых, я не до конца уверен, что в случае странностей в программе поднимаются такие же сигналы, как на 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
Источник [5]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/asynchronous/456899
Ссылки в тексте:
[1] одобренный партией: https://man7.org/linux/man-pages/man7/signal-safety.7.html
[2] corosig: https://github.com/bugsnotabunny/corosig
[3] статьи: https://pigweed.dev/blog/05-coroutines.html
[4] cppreference: https://en.cppreference.com/cpp/language/coroutines
[5] Источник: https://habr.com/ru/articles/1071994/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1071994
Нажмите здесь для печати.