С++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 будет скормлена вся информация о краше, после чего он сможет в привычной (не специфичной для обработчиков сигналов) манере отослать информацию о краше во все возможные коллекторы, но у этого подхода есть несколько проблем:

  1. Для коммуникации между процессами придётся выдумывать свой протокол или кодировать информацию в один из существующих. Реализовать кодирование protobuf или json внутри обработчика сигналов — это отдельная задача, по сей день не решённая (по крайней мере я на эту тему ничего не слышал). Свой протокол — это сложность ровно по той же самой причине. И придётся не только кодировать сообщения, а ещё и декодировать их на стороне другого процесса. В общем, весёлого мало.

  2. Это требует поставлять где-то рядом с вашим приложением дополнительный исполняемый файл, что само по себе уже является некоторой трудностью, а для некоторого ПО это требование и вовсе может быть недопустимым. Да и даже если у вас получилось его поставить — начинаются проблемы с тем, насколько вы доверяете системе, в которой будет работать (и крашиться) ваше приложение. Минимальное, что может сделать непутёвый пользователь — это переместить исполняемый файл вашего приложения или обработчика крашей, из-за чего вы не сможете получить к нему доступ.

В данный момент 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. И с ним есть сразу несколько проблем:

  1. Его нельзя привести к CoroutinePromiseType, так как стандарт не даёт абсолютно никаких гарантий касательно того, CoroutinePromiseType будет лежать в начале саллоцированного буфера, хоть фактически это и происходит именно так на gcc и clang.

  2. Даже если привести его к 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, так как позволяет нам:

  1. Не обращаться к CoroutinePromiseType после того как был позван его деструктор (ссылка на аллокатор достаётся и кешируется на стеке)

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

Отмена выполнения корутины без головных болей

Так как большую часть времени я работал с 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("aboba\n");
    }
  } 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, которая

  1. Конкатенирует списки ошибок из двух типов Error

  2. Удаляет из списка все дупликаты

  3. Удаляет из списка специальный тип NoError, если он там есть

  4. Если в списке остался один тип — возвращает его, а иначе возвращает список ошибок, обёрнутый в 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 stdout\n"));
  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")