Корутина — это особый вид функции, способный приостанавливать исполнение и возобновлять его позже. В C++ корутины являются stackless (безстековыми): при приостановке управление возвращается вызывающему коду, а все данные (локальные переменные, состояние) сохраняются в отдельном объекте вне стека вызовов. Это позволяет писать последовательно выглядящий код для асинхронных операций без вложенных обратных вызовов.

Архитектура корутин

Корутина определяется появлением в теле функции ключевых слов co_await, co_yield или co_return. При этом компилятор преобразует исходный код функции в машину состояний, автоматически вставляя точки приостановки и возобновления.

Изначальный код пользователя выглядит вполне себе однозначно:

Task Foo() {
    co_await A;          // точка 1
    // … какой-то код …
    co_await B;          // точка 2
    co_return value;     // точка 3
}

Однако, компилятор идет дальше и:

  • создаёт и конфигурирует promise,

  • проверяет initial_suspend,

  • разворачивает каждую точку co_await в вызовы готовности, приостановки и возобновления,

  • оборачивает весь код в обработку исключений,

  • и в конце делает final_suspend для управляемого освобождения ресурсов.

После всех манипуляций эта функция превращается в нечто вроде:

auto foo(...) -> Task {
    // 1. Создаётся _promise_ и из него — объект Task (return object)
    promise_type promise;
    Task         task = promise.get_return_object();

    // 2. initial_suspend: корутина либо сразу стартует (suspend_never),
    //    либо ждёт внешнего resume() (suspend_always)
    if (promise.initial_suspend() == suspend_never{}) {
        goto BODY;      // сразу пойдёт в тело
    } else {
        return task;    // остановлена до первой точки co_await
    }

BODY:
    try {
        // первый co_await
        // вместо «co_await A» генерируется
        auto aw1 = A.operator co_await();   // получить awaiter
        if (!aw1.await_ready()) {           // нужно ли сразу выполнить?
            //  помещаем текущую корутину в очередь, вызываем await_suspend
            aw1.await_suspend(coroutine_handle{&promise});
            return task;  // выходим из foo(), будем возобновлены позже
        }
        aw1.await_resume();  // если ready == true, просто продолжаем

        // остальной код между точками
        // ....
        // второй co_await
        auto aw2 = B.operator co_await();
        if (!aw2.await_ready()) {
            aw2.await_suspend(coroutine_handle{&promise});
            return task;
        }
        aw2.await_resume();

        // co_return value
        promise.return_value(value);
    }
    catch (...) {
        // если где-то упало исключение — попадаем сюда
        promise.unhandled_exception();
    }

    // 3. final_suspend — приостановка в конце
    if (promise.final_suspend() == suspend_never{}) {
        // кадр сразу разрушается (автоматически удаляется), и return из Foo()
    } else {
        // кадр остаётся «живым» до явного destroy()
        return task;
    }
    return task;
}

Лицом и центром управления любой корутины является её Task, а именно, promise_type — специальный класс, который пользователь обязан определить (или полагаться на std::coroutine_traits). Он определяет поведение при создании и завершении корутины. В promise_type должны быть реализованы, по крайней мере, методы:

1) get_return_object(): создаёт «объект‑руководитель» (return object), через который вы взаимодействуете с корутиной. Обычно он содержит std::coroutine_handle (дескриптор корутины) путём вызова std::coroutine_handle<Promise>::from_promise(*this).

Task get_return_object() noexcept {
    return Task{ std::coroutine_handle<task_promise>::from_promise(*this) };
}

std::coroutine_handle передаётся в объект task. По сути, coroutine_handle является «указателем» на выделенный кадр корутины, и именно его вызов handle.resume() возобновляет выполнение. Код, созданный компилятором, приостановит или возобновит выполнение в ответ на вызовы этого дескриптора.

Кроме того, ключевое слово co_await взаимодействует с так называемыми awaitable‑объектами (объектами с оператором co_await, либо имеющими метод await_ready/await_suspend/await_resume). При написании co_await expr компилятор фактически получает «ожидаемое» (awaiter) из выражения, проверяет await_ready(), а если надо — вызывает await_suspend(coroutine_handle), позволяя, например, поставить корутину в очередь выполнения. При co_await создаётся awaiter, описывающий поведение при приостановке/возобновлении.

2) initial_suspend(): возвращает std::suspend_always или std::suspend_never, указывая, будет ли корутина сразу после вызова сразу же приостановлена или начнёт выполнение тела до первой точки co_await.

3) final_suspend(): возвращает std::suspend_always или std::suspend_never, определяя, приостанавливается ли корутина однажды в финале или сразу разрушается. В общем случае рекомендуется в final_suspend() возвращать suspend_always, чтобы требовать явного вызова.destroy() и избежать утечек памяти.

4) return_void()/return_value(): вызываются при достижении co_return, и дают возможность получить возвращаемое значение. Компилятор генерирует при co_return expr вызов promise.return_value(expr) (или return_void() для void) и затем делает goto FinalSuspend.

5) unhandled_exception(): вызывается, если в теле корутины выбрасывается исключение и оно не перехвачено. Обычно здесь сохраняют std::current_exception() для передачи исключения внешнему коду.

В результате функций всех этих методов компилятор обеспечивает автогенерацию кода. Например, при достижении co_return выполняются вызовы promise.return_value() и promise.final_suspend(). Если же корутина завершается без co_return, это эквивалентно co_return; (при отсутствии return_void() в Promise такое поведение неопределено).

Типом возвращаемого значения корутины (return object) может быть и специальный Task. Он содержит std::coroutine_handle (из promise) и через него управляет корутиной: запускает resume(), убирает ресурсы и т.д.

Процесс компиляции корутины можно суммировать так: компилятор выделяет «кадр» корутины (coroutine frame) динамически, конструирует внутри него объект promise_type, оборачивает тело функции в каркас, добавляет вызовы initial_suspend, ловит co_return и исключения, и добавляет final_suspend. При первом вызове корутины (обычно это вызов‑фабрика, возвращающая return‑object) вызывается get_return_object(), после чего код либо сразу начинает исполнять код корутины (если initial_suspend — suspend_never), либо возвращает управление с приостановкой. При возобновлении с помощью.resume() исполняются оставшиеся инструкции вплоть до следующего co_await или co_return.

Важной особенностью корутин является выделение памяти для их состояния (кадра) в runtime. По умолчанию компилятор вызывает operator new для размещения кадра корутины на куче. Кадр корутины содержит все «живые» локальные переменные, параметры, а также дескриптор promise. То есть при первой приостановке исполнения (первом co_await) текущее состояние функции (значения локальных переменных, указатель на следующую команду) сохраняется в этом кадре. После возобновления эти данные восстанавливаются и выполнение продолжается. Если Promise определяет собственную статическую версию operator new, она будет использована для аллокации кадра; иначе будет вызван глобальный operator new.

Это означает, что по умолчанию каждая корутина должна выделять память во время выполнения, чтобы хранить своё состояние. Существует практика замены аллокатора (например, для использования пула или стека), но по умолчанию это работает через обычную кучу. Когда корутина завершает жизнь (через co_return или неотловленное исключение), она выполняет promise.final_suspend(), после чего кадр корутины уничтожается: сначала вызываются деструкторы локальных объектов, затем деструктор самого promise, а в конце память освобождается через operator delete. Если же final_suspend возвращает suspend_always, фрейм остаётся подвешенным до тех пор, пока пользователь явно не вызовет handle.destroy().

Важно отметить, что статическое выделение стека для корутины не используется: все необходимые данные хранятся в этом фрейме. Это означает, что размер кадра часто заметно меньше, чем обычный стек потока, и компилятор умеет разместить в нём ровно те данные, которые «живут» между точками co_await.

Поэтому корутины могут резко снизить расход памяти по сравнению с выделением отдельного потока. Однако динамическое выделение кадров корутин накладывает издержки: фрагментация кучи, накладные расходы на new/delete, возможность исчерпания памяти и необходимость заранее правильно подобрать размер пулла. Встроенных средств управления временем жизни корутины (кроме явного вызова destroy()) нет: за планирование и отмену задач обычно отвечают внешние механизмы.


Применение корутин в многопоточных и асинхронных системах

C++20-корутины активно применяются для упрощения асинхронного и параллельного кода. Так, популярная библиотека Boost.Asio предоставляет нативную поддержку корутин: с помощью asio::co_spawn и completion‑токена use_awaitable можно написать асинхронную логику в виде обычного последовательного кода.

Например, сервер "echo" на сокетах (исключительно для примера) может выглядеть так:

asio::awaitable< void > echo( tcp::socket socket )
{
   try
   {
      char data[ 1024 ];
      for( ;; )
      {
         std::size_t n = co_await socket.async_read_some( asio::buffer( data ), 
                                                         asio::use_awaitable );
         co_await async_write( socket, asio::buffer( data, n ), 
                               asio::use_awaitable );
      }
   }
   catch( std::exception& e )
   {
      std::printf( "Echo exception: %s\n", e.what() );
   }
}

// Инициализации сервера:
int main()
{
   asio::co_spawn( my_io_context.get_executor(), 
                   echo( std::move( socket ) ), asio::detached );
}

В этом примере async_read_some(..., use_awaitable) возвращает awaitable, и co_await приостанавливает корутину до завершения операции. Переданный в co_spawn() executor определяет, где выполняется корутина. Пока операция чтения не завершится, корутина «спит» и не занимает поток. Когда данные придут, асинхронный механизм сам возобновит корутину и co_await вернёт число прочитанных байт

Таким образом, те же сетевые протоколы можно описывать линейным кодом, а само планирование берёт на себя io_context (event loop). Корутины дают «чистый» синтаксис асинхронности, но требуют явно организовывать среду выполнения (пул, loop). Boost.Asio из коробки управляет потоком задач и допускает интеграцию с корутинами через use_awaitable. В стандартной библиотекеstd::async менее замысловатым образом бросает задачу в поток (или выполняет сразу) и ждёт её через future — это более примитивный способ, без гибкости и с большим накладным расходом.


Другой сценарий — интеграция с пользовательским пуллом потоков. Простой пример: можно реализовать класс ThreadPool, который хранит очередь std::coroutine_handle<> и несколько рабочих std::thread. Ожидаемый объект (awaiter) вроде ScheduleOnPool определяет await_suspend(handle), где ставит корутину в очередь, чтобы её вызвал рабочий поток.

#include <atomic>
#include <condition_variable>
#include <coroutine>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>

// Пул потоков на базе std::thread
class ThreadPool
{
public:
   ThreadPool( size_t n )
      : mIsDone( false )
   {
      // Создаем n рабочих потоков
      for( size_t i = 0; i < n; ++i )
      {
         mWorkers.emplace_back( [ this ] { Worker(); } );
      }
   }

   ~ThreadPool()
   {
      // Сигнализируем потокам завершиться и пробуждаем их
      mIsDone = true;
      mCv.notify_all();
      // Явно ждем завершения всех std::thread
      for( auto& t : mWorkers )
      {
         if( t.joinable() )
         {
            t.join();
         }
      }

      // Уничтожаем оставшиеся корутины в очереди, чтобы избежать утечек
      std::lock_guard< std::mutex > lock( mMutex );
      while( !mTasks.empty() )
      {
         mTasks.front().destroy();
         mTasks.pop();
      }
   }

   // Добавить корутину в очередь
   void Schedule( std::coroutine_handle<> h )
   {
      {
         std::lock_guard< std::mutex > lock( mMutex );
         mTasks.push( h );
      }
      mCv.notify_one();
   }

private:
   void Worker()
   {
      while( !mIsDone )
      {
         std::coroutine_handle<> h;
         {
            std::unique_lock< std::mutex > lock( mMutex );
            mCv.wait( lock, [ & ] { return mIsDone || !mTasks.empty(); } );
            if( mIsDone && mTasks.empty() )
               return; // выходим, когда завершили все задачи
            h = mTasks.front();
            mTasks.pop();
         }
         h.resume(); // возобновляем корутину
      }
   }

   std::vector< std::thread > mWorkers;          // рабочие потоки
   std::queue< std::coroutine_handle<> > mTasks; // очередь корутин
   std::mutex mMutex;                            // защитный мьютекс
   std::condition_variable mCv;                  // условная переменная
   std::atomic< bool > mIsDone;                  // флаг завершения
};

// Awaiter для планирования на пул
struct ScheduleOnPool
{
   ThreadPool& mPool;

   bool await_ready() const noexcept
   {
      return false;
   }
   void await_suspend( std::coroutine_handle<> h ) noexcept
   {
      mPool.Schedule( h ); // ставим в очередь
   }
   void await_resume() noexcept
   {
   }
};

// Простейший Task для корутины
struct Task
{
   struct promise_type
   {
      Task get_return_object()
      {
         return Task{ std::coroutine_handle< promise_type >::from_promise( *this ) };
      }
      std::suspend_never initial_suspend() noexcept
      {
         return {};
      }
      std::suspend_always final_suspend() noexcept
      {
         return {};
      } // кадр корутины остается до destroy()
      void return_void() noexcept
      {
      }
      void unhandled_exception() noexcept
      {
         // Обрабатываем исключение, выводим описание
         try
         {
            throw;
         }
         catch( const std::exception& e )
         {
            std::cerr << "Coroutine error: " << e.what() << std::endl;
         }
         catch( ... )
         {
            std::cerr << "Coroutine unknown exception" << std::endl;
         }
      }
   };
   std::coroutine_handle< promise_type > mCoro;
};

// Пример корутины: демонстрация переключения потока
Task Example( ThreadPool& pool )
{
   std::cout << "До переключения (" << std::this_thread::get_id() << ")\n";
   co_await ScheduleOnPool{ pool };
   std::cout << "После переключения (" << std::this_thread::get_id() << ")\n";
}

int main()
{
   ThreadPool pool{ 2 };  // пул из 2 потоков
   Example( pool );       // запускаем корутину
   std::this_thread::sleep_for( std::chrono::seconds( 1 ) ); 
   // даем время на выполнение
   return 0;
}

После компиляции такой корутины локальные переменные будут сохранены в кадре, и когда пул вызовет resume(), функция продолжит работу на одном из потоков пула. Это позволяет легко организовать перенос вычислений между потоками, избегать блокировок основного потока и реализовать гибкую стратегию выполнения асинхронных операций с полной поддержкой сопрограмм, где жизненный цикл и локальные переменные корутины сохраняются автоматически внутри кадра корутины, пока она «спит» в очереди.


Типичные ошибки при использовании корутин и стратегии их решения

Первое, и пожалуй, самое очевидное - игнорирование сложности асинхронности. Шутка «пиши асинхронный код как синхронный» скрывает опасность. Использование co_await скрывает точки приостановки, и довольно легко забыть про сложные состояния между ними. Ниже кратко пробежимся по ошибкам:

Стандарт гласит, что одновременное resume() одной и той же корутины из разных потоков — это UB и может вызвать гонку. Например, если вы спланировали корутину на несколько потоков, убедитесь, что каждый экземпляр корутины не запускается параллельно. Наконец, дважды проверьтеawait_suspend/await_resume: ошибки в этих методах часто приводят к блокировке или сбою. Если await_suspend не организует явного возобновления корутины (например, забывает передать coroutine_handle в планировщик), то корутина навсегда повиснет. И наоборот, неправильный тип или семантика await_resume может вернуть некорректные значения или вызвать UB. Наконец, использование co_await в лямбдах с захватами крайне опасно: захваченные переменные разрушаются при первом приостановлении, что приводит к use‑after‑free

Так, библиотека libcoro предупреждает: «рекомендуется не использовать захватывающие лямбды как корутины; передавайте данные в корутину через параметры, чтобы гарантировать их жизнь. Лямбда‑захваты будут уничтожены при первой приостановке и, если используются позже, вы получите UAF (Use-After-Free)».

Иначе говоря, любая передача аргументов по ссылке в корутину опасна, если эти объекты могут исчезнуть при приостановке. Например:

Task< void > foo( const std::string& msg )
{
   co_await some_io();
   // здесь msg уже не валиден, если вызов был foo("temp");
   std::cout << msg;
   co_return;
}

или

asio::awaitable< void > echo( asio::ip::tcp::socket socket )
{
   std::string s = "hello";
   std::span< char > buf( s.data(), s.size() ); 
   // span указывает на локальную строку

   co_await async_write( socket, buf ); // корутина может приостановиться здесь
   // если s к этому моменту уничтожена — buf становится висячим указателем
}

В обычной функции передача const std::string& была бы безопасна, но корутина может приостановиться, а оригинальная строка уйти из области видимости. Решение — передавать по значению или гарантировать, что объекты живут достаточно долго (например, через shared_ptr). В частности, можно использовать статическую проверку аргументов в promise_type, чтобы на этапе компиляции отловить неподходящие типы. Соответствие между возвращаемым типом и promise_type устанавливается через шаблон std::coroutine_traits<R, Args...>, требующий наличия у R вложенного promise_type.


Пропущенные co_await – крайне распространённая проблема. Если вы запускаете корутину без ожидания (co_await или sync_wait), то она фактически не будет выполнена до конца, а ваш код «висит» без явной ошибки. Поэтому часто корутины помечают [[nodiscard]], чтобы не забыть co_await;

Task< int > Compute()
{
   co_await SomeAsyncOp();

   co_return 42; // Если забыть вызвать co_return вообще, 
                 // функция не станет корутиной.
}

void F()
{
   Compute(); // Никакого co_await - корутина не запущена, логика «зависает»
}

Инициализация и финализация (initial_suspend/final_suspend) может также приводить к утечкам или двойному вызову деструктора (если кадр будет удалён вручную, а потом ещё раз автоматически).

std::coroutine_handle по умолчанию не уничтожает своё состояние — если вы не вызовете handle.destroy() (или не дойдёте до естественного конца корутины), то объект корутины останется в памяти, как при забытом new. Например, генераторы (типа cppcoro::generator) обычно не завершаются автоматически, поэтому программист обязан явно уничтожать их coroutine_handle после использования. Если этого не сделать, произойдёт утечка памяти (аналогично несделанному delete).

auto final_suspend() noexcept { return std::suspend_always{}; }

Поэтому необходимо либо возвращать suspend_never в final_suspend() (что автоматически удаляет кадр по завершении), либо явно вызывать coro.destroy() из деструктора объекта‑обёртки.

auto h = foo();      // coroutine_handle

h.resume();          // выполняем

h.destroy();         // вручную уничтожаем

Неплохим вариантом будет использовать RAII-обёртку Task, которая в деструкторе вызывает handle.destroy()


Частая ошибка — перебирать контейнер и вызывать в цикле co_await внутри него, поскольку это некорректная логика обхода контейнеров. Почему это неочевидно? Потому что в обычных синхронных циклах всё работает предсказуемо: цикл сразу проходит от начала до конца. Но с co_await цикл может «замёрзнуть» посреди выполнения, и что угодно может произойти между итерациями с самим контейнером, после чего неминуемо жди UB.

Между итерациями корутина может приостановиться, и к тому моменту сам контейнер может измениться (например, другие потоки удалили элементы), task по ссылке может стать висячей ссылкой.

asio::awaitable<void> HandleTasks(std::vector<asio::awaitable<void>>& tasks)
{
    for (auto& task : tasks)
    {
        co_await task; // Между итерациями контейнер может измениться
    }
}

Кажется надуманным? В нашем примере это мог быть сервер, который хранит список активных сессий и в цикле надо вызвать co_await на каждой (в это же время может прийти disconnect от клиента, и его сессия будет удалена другим потоком или корутиной). Или более очевидный пример, когда это циклы co_await в обработке GUI‑событий, где пользователь может закрыть окно или вкладку во время ожидания.

Решение — либо копировать контейнер/список задач заранее, либо собирать все задачи и ждать их группой, либо обеспечивать сохранность элементов. Если вы точно знаете, что контейнер не изменится во время обхода (например, он const, или вы единственный владелец), можно оставить co_await в цикле, но только по значению или shared_ptr.


Еще один небольшой, но существенный момент — переполнение памяти кадра. Иными словами, это аналог стека — но в куче, и содержит только то, что живо между suspend/resume. Слишком большие локальные данные могут раздуть размер кадра, и хотя кадр меньше полноценного стека, всё же размер его растёт с количеством «живых» переменных между co_await. Например, вложенные большие буферы сохранятся в кадре, что может неожиданно увеличить расход памяти.

Task< void > BigBufferCoroutine()
{

   std::array< char, 10'000 > buffer; // крупный буфер

   FillBuffer( buffer ); // выполняем что-то

   co_await SomeAsyncOperation(); // приостановка

   UseBuffer( buffer );
}

buffer жив до вызова UseBuffer(), значит, он сохраняется в кадре, а значит весь массив 10 KB попадёт в heap-аллоцированный кадр. Если таких корутин тысячи - это гигантский расход памяти.

Нужно минимизировать объём сохраняемых данных. Если данные не нужны после co_await — приостановку лучше делать после тяжёлых переменных:

Task< void > GoodOrder()
{
   co_await DoSomething(); // сначала приостановка

   std::array< char, 10'000 > buffer; // после — буфер

   Use( buffer );
}

Теперь буфер не сохраняется в кадре вообще — он появится только при resume(). Можно также передавать буферы по указателю (но тогда нужно управлять их временем жизни).

auto buffer = std::make_shared< std::vector< char > >( 10'000 );

FillBuffer( *buffer );

co_await SomeAsyncOp( buffer ); // буфер остаётся в куче, 
                                // кадр содержит только указатель

Если корутина бросает исключение, оно будет передано в promise.unhandled_exception(). По умолчанию этот метод просто вызывает std::terminate. Ваша реализация unhandled_exception() должна корректно обрабатывать ошибки, и тем более уж в продакшене надо позаботиться об информировании вызывающего кода об ошибке (например, через дополнительное future или логирование).

void unhandled_exception()
{
   try
   {
      throw; // повторно выбрасываем для анализа
   }
   catch( const std::exception& e )
   {
      std::cerr << "[unhandled_exception] Стандартное исключение: " 
                << e.what() << "\n";
   }
   catch( ... )
   {
      std::cerr << "[unhandled_exception] Неизвестное исключение. Завершаем.\n";
      std::terminate(); // жёстко падаем (Конечно же, так делать не стоит)
   }
}

Поскольку корутины в C++ — это stackless единицы асинхронного исполнения: все переключения контекста происходят исключительно в тех точках, где встречается co_await. В отличие от традиционных стековых корутин (fibers, goroutines и т. п.), где можно предсказать и контролировать точки сохранения и восстановления стека, у C++‑корутин нет собственного стека и нет гарантий, что исполнение вернётся на тот же поток.

В сценариях с многопоточным планировщиком это может привести к трудноотлавливаемым гонкам и дедлокам. Представьте, что две логически независимые секции кода обе используют co_await внутри критического раздела: оригинально захваченный std::mutex или thread_local‑переменная могут неожиданно сменить поток исполнения после co_await, и обе корутины «считают», что они единственные обладатели ресурса. В результате происходит некорректная синхронизация — от банальной потери данных до UB и взаимоблокировки.

Следовательно, использование традиционных блокирующих примитивов (например, std::mutex с lock_guard) и синхронных операций ввода‑вывода внутри корутин считается анти‑паттерном. Если корутина захватила мьютекс, затем выполнила блокирующий вызов (read(), recv(), sleep() и т. п.) и была приостановлена планировщиком, другие корутины на том же потоке окажутся пожизненно заблокированы. Это смертельно опасно, особенно: если на том же потоке другие корутины; если планировщик однопоточный (как в asio без thread_pool); если co_await может приостановить выполнение и заблокировать очередь и тд и тд.

Иначе говоря, если корутина захватила mutex и произошло переключение, другая корутина на том же потоке не сможет войти в критическую секцию — это UB. То же самое случится, если await_suspend пытается взять mutex и блокируется:

Task< void > Blocked()
{

   std::lock_guard< std::mutex > lock( m );

   call_blocking_io(); // блокируем поток

   co_return;
}

Проблема: корутина захватывает мьютекс и выполняет блокирующую операцию. Планировщик переключается, и другой корутин на том же потоке попытается захватить m ‑происходит дедлок или UB. Лучше не использовать б локирующие операции или std::mutex внутри корутины. Вместо этого применяйте асинхронный ввод‑вывод, асинхронные мьютексы (например, coro::mutex), или минимизируйте критические секции вне co_await. Не стоит полагаться на thread_local в корутинах, которые могут мигрировать между потоками, — вместо этого передавайте контекст явно через параметры или храните его в привязанном к корутине promise_type.

В целом, антипаттерны сводятся к пренебрежению моделями памяти и потока: захватывать локальные данные по ссылке, оперировать глобальными переменными без защиты, использовать thread_local через co_await и т. п. Важно понимать, что корутина — это не «автоматический поток», а просто удобный способ непрерывного асинхронного кода. Корутины не устраняют проблем многопоточности, они их лишь немного упрощают (синтаксически). В целом, избежание большинства проблем сводится к чёткому пониманию «точек приостановки» и тому, какие данные переживают переходы.

Сравнение с другими асинхронными подходами в C++

C++20-корутины — это один из нескольких инструментов для асинхронного программирования в C++. Рассмотрим некоторые сравнения:

  • Фактически Asio предоставляет свою модель асинхронного ввода‑вывода на базе Completion Token. До C++20 чаще использовались колбэки или специальные генераторы yield_context. С появлением co_await и токена use_awaitable Asio перешёл к единому интерфейсу: любая I/O‑операция, вызываемая с use_awaitable, возвращает тип awaitable<T>, и её можно co_await. Как видно из документации Asio: co_await socket.async_read_some(buffer, use_awaitable) приостанавливает корутину до завершения чтения.

    В этом изменении заключена кардинальная разница в парадигме: без корутин вам пришлось бы писать явные колбэки и схемы async_result, а с корутинами вы получаете более линейный код. Однако работа под капотом остаётся событийно‑ориентированной: при срабатывании события io_context вызывает .resume() для соответствующих корутин. Boost.Asio по сути выступает executor‑ом, и C++‑корутины здесь лишь средство упрощения записи; производительность же зависит от Asio и часто превосходит простые потоки.

  • std::async и std::future. Это более старый способ параллелизма: std::async(std::launch::async, f) запускает функцию f в отдельном потоке (либо сразу, либо отложенно) и возвращает std::future. Однако у него слабая гибкость: при таком подходе каждый async часто соответствует новому потоку ОС, что дорого. К тому же отмены у future нет, и вы не можете приостановить функцию внутри без своей логики. По сути, если вы просто обернёте корутину в поток, вы тем самым воссоздаёте std::async, но если сила продолжений (continuations) в том, чтобы композировать асинхронные операции, а std::future здесь вам не поможет». Другими словами, корутины задумывались для того, чтобы не создавать лишние потоки и переключения контекста, а выстраивать цепочки async‑операций («неблокирующих» задач) более эффективно.

  • Другие библиотеки. Существует также Boost.Coroutine (stackful co‑routine) и другие сторонние фреймворки (Qt, Folly). Они либо требуют явных переключений контекста (stackful) либо используют std::async‑подобные модели. C++20-корутины — это stackless‑модель, поэтому они принципиально отличаются от Boost.Coroutine (который предоставляет примитивы типа fibers).

Заключение

Без лишней доли иронии, C++20-корутины — мощный и низкоуровневый механизм для асинхронного программирования. Они обеспечивают минимальные накладные расходы на «переключение» контекста благодаря отсутствию собственного стека и хранению состояния вне него. Вместе с тем, они требуют тщательного управления временем жизни объектов и учёта асинхронных сложностей (явному проектированию точек приостановки), поскольку каждая co_await может разделить выполнение и привести к гонкам или неопределённому поведению, если не продумать логику владения данными. Правильное использование promise_type, await/suspend точек позволяет строить надёжные высокопроизводительные системы. Будущее асинхронного C++ неразрывно связано с развитием P2300 и смежных предложений, уже утверждённых в C++26, которые дополнят и унифицируют модель планировщиков и исполнителей для корутин. Внедряя эти практики и следя за эволюцией стандарта, вы сможете создавать масштабируемые, безопасные и высокопроизводительные системы на базе современных возможностей C++.

Косинцев Артём

Инженер-программист

Комментарии (9)


  1. SIISII
    11.05.2025 17:16

    А ещё сопрограммы могут быть весьма сложны при отладке. Вплоть до такой степени, что нормальная отладка не только их самих, но и окружающего кода становится невозможной: у меня такое было в KEIL. Сам компилятор (ARMCLANG) генерирует нормальный код, но у отладчика наличие сопрограмм начисто срывает крышу, из-за чего отладка возможна исключительно на уровне дизассемблированного кода. Похоже, KEIL не способен корректно интерпретировать отладочную информацию -- и вряд ли будет способен в будущем; скорей, ARM полностью переведёт разработку в VS Code, фактически свалив заботу об инструментах на "сообщество".


    1. Sap_ru
      11.05.2025 17:16

      Eclipse CDT замечательно компилирует и отлаживает под ARM стандартным тулчейном.


      1. monah_tuk
        11.05.2025 17:16

        Там gdb и gcc, т.е. как раз "плечи сообщества". У кейла всё своё.


      1. SIISII
        11.05.2025 17:16

        Увы, по ряду причин мне нужен именно KEIL. Но, если бы я полностью был свободен в выборе, то смотрел бы в сторону VS Code: к Эклипсе в любых её проявлениях у меня стойкая нелюбовь, скажем так. Впрочем, в данном случае это уже вторично.


        1. Arenoros
          11.05.2025 17:16

          впервые слышу про это ПО, а какие причины и мотивы использовать именно его? просто любопытно, так как сам пишу под разные arm, но видимо из-за того что не имею дело именно с микроконтроллерами, ни когда не слышал про них.


          1. SIISII
            11.05.2025 17:16

            Это основная среда разработки под микроконтроллеры и классические микропроцессоры (которые ARMv6 и более ранние -- до ядер серии Cortex, в общем) от ARM. У них есть ещё DS-5 на основе Эклипсы, но оно уж очень платное, громоздкое и всё такое прочее, и для микроконтроллеров смысл его использования стремится к нулю (а вот для современных микропроцессоров ARM ничего другого не предлагает -- но за бешеные деньги). Ну а главный конкурент на МК -- IAR, но он не ARMовский.


  1. 9241304
    11.05.2025 17:16

    Практическое руководство по применению седла к корове (так уж оно выглядит, сори)

    Но ничего. Ещё несколько стандартов, и приведут это к юзабельному состоянию. Может быть. Если оно ещё кому-то будет нужно)


  1. uxgen
    11.05.2025 17:16

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

    Я добавлял корутины поверх уже готовой системы тасков и промисов. Тогда уже использовались правильные примитивы синхронизаций типа зависимостей между тасками, асинхронный мьютекс, что-то похожее на семафор и тд.


    1. Lantris Автор
      11.05.2025 17:16

      Так и есть, в большей части это вопрос правильной работы в параллелизме.

      Те же Senders/Receivers в C++26 тоже не больший ад, чем просто новый инструмент.