Достаточно ли времени жизни локальной лямбды в качестве обработчика завершения для co_spawn, т.е. функции с functor&&?

Вопрос

Я немного смущен или параноик, учитывая закономерность:

void setup(boost::asio::io_context &context) {
    const auto completion_handler = [](std::exception_ptr ptr) {
        if (ptr) {
            std::cout << "Rethrowing in completion handler" << std::endl;
            std::rethrow_exception(ptr);
        } else {
            std::cout << "Completed without error" << std::endl;
        }
    };
    boost::asio::co_spawn(context, coroutine_with_rethrow_completion_handler(), completion_handler);
}

Время жизни completion_handler, т. е. локальное, ок?


Рассуждение

AFAIK, конечно, любой локальный захват был бы плохим, так как они бы вышли за рамки, когда в конечном итоге boost::asio::io_context запустит этот обработчик. А как насчет времени жизни этого самого обработчика, то есть функтора, то есть самой лямбды?

boost::asio::co_spawn принимает &&, который AFAIK должен быть ссылкой на пересылку (в списке шаблонов этой функции boost есть много макросов), и идеально перенаправляет эту функцию завершения в внутренности boost::asio и документацию по co_spawn не высказывает каких-либо пожизненных замечаний по поводу токена завершения.

Поэтому я боюсь, что в конце концов в boost::asio::io_context будет храниться только ссылка на эту лямбду, то есть context, и когда мы фактически выполним лямбду в io_context::run в main после того, как лямбда выйдет за пределы области действия в setup, у нас будет UB.


Завершить MRE

#include <iostream>
#include <boost/asio.hpp>

boost::asio::awaitable<void> coroutine_with_rethrow_completion_handler() {
    std::cout << "Coroutine executes with rethrow completion handler\n";
    throw std::runtime_error("Test throw from coroutine!");
    co_return;
}

void setup(boost::asio::io_context &context) {
    const auto completion_handler = [](std::exception_ptr ptr) {
        if (ptr) {
            std::cout << "Rethrowing in completion handler" << std::endl;
            std::rethrow_exception(ptr);
        } else {
            std::cout << "Completed without error" << std::endl;
        }
    };
    boost::asio::co_spawn(context, coroutine_with_rethrow_completion_handler(), completion_handler);
}

int main() {
    boost::asio::io_context context;
    setup(context);
    std::thread t([&context]() {
        try {
            while (true) {
                context.run();
                return;
            }
        } catch (std::exception &e) {
            std::cerr << "Exception in context::run(): " << e.what() << "\n";
        }
    });
    t.join();
}

Как вы думаете, почему это ссылка на пересылку, а не ссылка на rvalue?

Caleth 05.07.2024 10:45

Лямбда без захвата ([]) похожа на простую функцию. Таким образом, completion_handler не больше и не меньше, чем указатель на эту функцию. Сама функция должна иметь время жизни от начала до конца процесса - независимо от ее области действия. Я не вижу ничего, что могло бы зависнуть... (Или сопрограммы меняют эти "правила"?)

Scheff's Cat 05.07.2024 11:00

@Caleth Потому что CompletionToken находится в списке шаблонов в определении этой перегрузки co_spawn, и если я правильно понимаю Clion, то этот BOOST_ASIO_INITFN_AUTO_RESULT_TYPE расширяется до чего-то вроде Typename Completion Token .. Completion Token&&.

Superlokkus 05.07.2024 11:15

@Scheff'sCat Все эти стирания типов и «это похоже на экземпляр функциональной структуры» вызывают у меня очень неясное представление о времени жизни лямбда-выражений, особенно когда они затем передаются в библиотеку тяжелой пересылки шаблонов. Конечно, если бы вы могли указать на параграф в стандарте C++, в котором говорится, что лямбда-выражение похоже на функцию, т.е. имеет статическое время жизни, я был бы очень благодарен :-)

Superlokkus 05.07.2024 11:16

Я на 99% уверен, что в конечном итоге он окажется нессылочным элементом данных какого-то объекта.

Caleth 05.07.2024 11:18

@Caleth Ваш 1% и мои ~ 10% неопределенности - вот что делает меня параноиком :-)

Superlokkus 05.07.2024 11:22

Извините, я скорее практик, чем языковой юрист. Я пытался гуглить, но ничего серьезного не нашел. То, что я нашел, может меня поддержать: Жизненный цикл лямбды без захвата , blog.the-pans.com/cpp-coroutine-and-lambdas-lifetime , clang.llvm.org/extra/clang-tidy /checks/cppcoreguidelines/…

Scheff's Cat 05.07.2024 11:34

Также blog.the-pans.com/cpp-coroutine-and-lambdas-lifetime

Jean-Baptiste Yunès 05.07.2024 11:40

Насколько я понимаю, один говорит, что это все еще проблематично, другой - нет, но оба только размышляют о реализации среды выполнения C++. По какой-то причине я прочитал, что лямбда похожа на структуру с членом объектов списка захвата и указателем на функцию со стертым типом. То, что код в лямбда-выражении будет указывать на статическую продолжительность хранения, кажется очевидным, но как насчет указателя в структуре? Он будет уничтожен, поэтому его разыменование позже будет UB. И, конечно же, этот шаблон взят из проекта, страдающего от UB. Вот почему я должен быть уверен ;-(

Superlokkus 05.07.2024 11:40

Я имею в виду, что сейчас кажется, что тип этой лямбды действительно является просто указателем функции на статическое хранилище, то есть функцией. Но как насчет времени жизни самого указателя? Его автоматическое хранилище настолько мертво при вызове io_context::run, что возникает большой вопрос, скопировало ли дерево вызовов co_spawn этот указатель или ссылалось на него. В первом случае все будет в порядке, так как статическое хранилище все еще существует, но в более позднем случае сначала попытается разыменовать мертвый указатель, который, к счастью, все еще указывает на статическое хранилище, то есть UB.

Superlokkus 05.07.2024 12:26
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
3
10
108
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Интересный вопрос, если для беззахватного закрытия нет UB, связанного с завершением жизни. Все потому, что замыкание должно вести себя аналогично ссылочному коду, предлагаемому стандартным документом (§ 8.4.5.1 Типы замыканий):

struct Closure {
  template<class T> auto operator()(T t) const { ... }
  template<class T> static auto lambda_call_operator_invoker(T a) {
     // forwards execution to operator()(a) and therefore has
     // the same return type deduced
     ...
  }

  template<class T> using fptr_t =
  decltype(lambda_call_operator_invoker(declval<T>())) (*)(T);
  
  template<class T> operator fptr_t<T>() const { 
     return &lambda_call_operator_invoker; 
  }
};

Если у нас есть экземпляр, его можно привести к указателю на функцию, после чего сам объект больше не нужен — приведение возвращает адрес статической функции-члена. И замыкание будет обнаружено SFINAE как нечто конвертируемое в указатель на функцию в co_spawn, после чего будет сохранен только указатель. Если уж как co_spawn реализовано, то приходится полагаться на качество реализации.

Я смущен, что вы считаете этот вопрос интересным, но отвечаете на весьма ограниченную его часть. В частности, ваш ответ зависит от того, «его можно привести к указателю на функцию», что вызывает вопрос, насколько ответ актуален на практике. Ваш ответ кажется актуальным только тогда, когда ОП редактирует свой код, чтобы он читал co_spawn(context, coroutine_with_rethrow_completion_handler(), +completion_handler); или что-то подобное?

sehe 05.07.2024 17:32

Я имею в виду, что почти попросил ссылку на стандарт cpp для пожизненных дебатов типа. Насколько я понимаю, оба ваших ответа дополняют мое понимание, то есть рабочую модель, которая: boost::asio::co_spawn будет копировать в пределах setup, по крайней мере, для этой специализации (обработчик исключений). И, написав эти строки, я только что понял, что в противном случае, независимо от типа completion_handler, пока это именованная переменная, а не std::moved, это будет небезопасно. Хм.

Superlokkus 06.07.2024 18:37
Ответ принят как подходящий

Протокол CompletionToken отделяет механизм завершения. Что сохраняется, зависит от типа токена завершения.

Механизм специализирован с помощью признака asio::async_result: https://www.boost.org/doc/libs/1_85_0/doc/html/boost_asio/reference/async_result.html#boost_asio.reference.async_result.requirements

Одним из типов, предоставляемых этим признаком, является complete_handler_type : «Конкретный тип обработчика завершения для конкретной подписи».

Априори ясно, что может потребоваться создание нового экземпляра. Это означает, что время существования экземпляра определяется асинхронной операцией, а не вызывающим кодом.

Для обычного вызываемого объекта в качестве токена завершения completion_handler_type является синонимом типа аргумента обработчика пользователя:

using H = decltype(completion_handler);
using Protocol = boost::asio::async_result<boost::asio::decay_t<H>, void(std::exception_ptr)>;
Protocol::completion_handler_type stored{std::move(completion_handler)};

Что реализовано // ЗДЕСЬ:

template <typename CompletionToken,
    BOOST_ASIO_COMPLETION_SIGNATURE... Signatures>
class completion_handler_async_result
{
public:
  typedef CompletionToken completion_handler_type; // HERE
  typedef void return_type;

  explicit completion_handler_async_result(completion_handler_type&)
  {
  }

Распад гарантирует, что любые ссылочные/константные/изменчивые уточнения будут удалены. Следовательно: обработчик копируется или перемещается.

Заключение

Как вы и ожидали, обработчик не сохраняется по ссылке. Причина, по которой он используется как универсальная ссылка, состоит в том, чтобы приспособить

  • вызываемые объекты только для перемещения
  • общие токены завершения, которые могут вести себя по-разному

_Фактически, адаптеры токенов уже могут привязывать ссылки к токену/обработчику. bind_executor/bind_cancellation_slot хранит копии, но asio::redirect_error хранит ссылку на объект error_code. Несмотря на это, сам обработчик будет скопирован/перемещен.

Из коробки

Если вы поделитесь ссылкой на код, демонстрирующий UB, я, возможно, поищу потенциальные проблемы.

Честно говоря, поскольку это была идеальная пересылка ссылки на пересылку, а параметр — именованная переменная, я предположил, что она будет принимать ее по ссылке. Но я думаю, что старый добрый std::decay предотвратил это, как в std::bind или std::thread(). Если нет, мне придется пересмотреть свое понимание пересылки ссылок. Спасибо большое, теперь мне нужно решить, кто получит оценку, +1 обоим. И база кода является коммерческой, в несколько тысяч LOC, с NDA, но спасибо :-) Лучшее предположение на данный момент, возможно, это версия clang против аномалий ibstdc++ или ошибки сокета ОС, или восходящего потока, или ложноотрицательного результата в статическом анализе...

Superlokkus 06.07.2024 18:27

Другие вопросы по теме

Похожие вопросы