Программирование C++Современный C++Разработчик C++ инфраструктуры и асинхронных систем

Назовите механизм, определяющий, начнёт ли тело корутины выполняться сразу после вызова или останется приос...

Назовите механизм, определяющий, начнёт ли тело корутины выполняться сразу после вызова или останется приостановленным.

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Эту семантику задаёт метод promise_type::initial_suspend(). Если он возвращает std::suspend_never, тело корутины начинает выполняться сразу; если std::suspend_always, вызов создаёт корутину в приостановленном состоянии, а выполнение начнётся только после resume() через сохранённый дескриптор.

Исторический контекст

Корутины появились в стандарте C++20, чтобы поддержать кооперативное приостанавливание и возобновление функций без ручного управления состоянием. До этого подобное поведение обычно реализовывали через конечные автоматы, callback-функции или специализированные библиотеки.

Разделение на promise_type, кадр корутины и awaiter позволяет библиотеке определить модель выполнения: eager — начать работу при вызове, или lazy — отложить её до явного запуска.

Постановка проблемы

Вызов корутины не обязательно означает немедленное выполнение её тела. Он создаёт или подготавливает кадр корутины, объект promise_type и возвращаемый объект, после чего решение о первом запуске принимает initial_suspend().

Ошибка в понимании этого механизма приводит к преждевременному выполнению операций, неожиданному отсутствию побочных эффектов или корутинам, которые вообще никогда не запускаются. Для ленивой корутины возвращаемый объект обязан надёжно владеть дескриптором и предоставить способ возобновления.

Подробное решение

После вызова корутины компилятор создаёт её кадр и объект promise_type, получает возвращаемый объект через get_return_object(), а затем применяет awaiter, возвращённый initial_suspend().

std::suspend_never означает, что начальная точка приостановки сразу считается пройденной: выполнение тела начинается до возврата из вызова корутины. std::suspend_always оставляет корутину приостановленной; вызывающий код получает объект, обычно содержащий std::coroutine_handle, и должен вызвать resume().

Пример ленивой корутины:

#include <coroutine> #include <iostream> struct Task { std::coroutine_handle<> h; struct promise_type { Task get_return_object() noexcept { return {std::coroutine_handle<promise_type>::from_promise(*this)}; } std::suspend_always initial_suspend() noexcept { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void return_void() noexcept {} void unhandled_exception() noexcept {} }; ~Task() { if (h) h.destroy(); } }; Task work() { std::cout << "body "; co_return; } int main() { auto task = work(); std::cout << "after call "; task.h.resume(); }

При таком initial_suspend() сначала выполняется вывод after call, а затем после resume() — тело корутины. Если заменить его на std::suspend_never, тело начнёт выполняться внутри work() до возврата управления в main.

initial_suspend() не определяет последующие точки остановки: их задают co_await, co_yield и завершение корутины. Также он не управляет временем жизни кадра. При final_suspend() == std::suspend_always корутина остаётся завершённой, но её кадр можно безопасно уничтожить через destroy(); при этом дескриптор должен принадлежать объекту, который корректно управляет его временем жизни.

Выбор между режимами — это компромисс. Ленивый запуск удобен для задач, которые должны начинаться только после планирования или явного запроса данных, но требует протокола запуска и владения дескриптором. Немедленный запуск проще для fire-and-forget-сценариев, однако усложняет контроль над моментом начала работы и требует особенно внимательно проектировать время жизни захватываемых объектов.

Ситуация из практики

Команда реализует асинхронный запрос, возвращающий объект задачи. Запрос не должен отправляться при простом создании объекта: сначала его нужно передать планировщику, который выберет поток и момент запуска.

Рассматривались два варианта. При std::suspend_never запрос стартовал бы немедленно, что упрощает интерфейс, но нарушает контракт планировщика и может выполнять работу в неожиданном потоке. При std::suspend_always объект задачи можно поставить в очередь, а затем вызвать resume() из планировщика; минусом становится необходимость корректного владения дескриптором и обработки отмены.

Выбран std::suspend_always: он соответствует требованию отложенного запуска. Возвращаемый тип владеет кадром корутины, планировщик владеет задачей до её запуска, а после завершения кадр уничтожается владельцем. Это предотвращает как преждевременный сетевой запрос, так и утечку кадра.

Что кандидаты часто упускают

  1. Обязан ли вызов корутины выполнить хотя бы часть её тела?

Нет. При std::suspend_always в initial_suspend() тело может не выполниться вообще, если вызывающий код не вызовет resume(), а владелец задачи уничтожит кадр. Поэтому создание объекта задачи не следует трактовать как запуск операции.

  1. Можно ли безусловно вызвать resume() после завершения корутины?

Нет. Если корутина уже достигла final_suspend, её дескриптор обозначает завершённую корутину. Повторный resume() для завершённой корутины приводит к неопределённому поведению. Перед возобновлением обычно проверяют handle.done(), а после завершения передают владение кадром его владельцу для вызова destroy().

  1. Почему initial_suspend() часто возвращает std::suspend_always в типах задач?

Такой выбор отделяет создание задачи от её планирования. Тип задачи может сохранить дескриптор, зарегистрировать его в исполнительной системе, связать с отменой и только затем разрешить выполнение. Цена — необходимость определить правила владения, обработать исключения и гарантировать, что задача не будет уничтожена до запуска или завершения.