Understanding C++20 Coroutines and Their Application in a Game Scheduler
The article walks through C++20 coroutines—from basic suspension mechanics and the roles of coroutine_handle, promise_type, and awaitable methods—to constructing a lightweight game scheduler that manages task lifetimes, await modes, and RPC integration, and demonstrates how this approach mirrors Python’s yield‑based skill system while offering clearer, high‑performance native code.
This article provides a step‑by‑step introduction to C++20 coroutines, starting with the language mechanisms and then showing how to build a task scheduler that is simpler and more constrained than a C++17 implementation.
Coroutines are explained as functions that can be suspended and resumed multiple times, retaining state between calls. The article likens this behavior to a soft interrupt, where each yield saves the execution context and each resume restores it.
The core components of a coroutine are described:
coroutine_handle<> – manages the coroutine’s lifetime.
promise_type – configures coroutine behavior (initial/final suspend, return value) and acts as a bridge between the coroutine and the external system.
Three essential functions of an awaitable object are highlighted:
await_ready() – decides whether the coroutine should suspend.
await_suspend() – performs actions while the coroutine is suspended (e.g., start an async operation).
await_resume() – runs when the coroutine resumes and can return a value to the coroutine.
A minimal example of a resumable coroutine is shown:
#include <iostream>
#include <resumable>
using namespace std;
struct resumable_thing {
struct promise_type {
resumable_thing get_return_object() {
return resumable_thing(coroutine_handle<promise_type>::from_promise(*this));
}
auto initial_suspend() { return suspend_never{}; }
auto final_suspend() { return suspend_never{}; }
void return_void() {}
};
coroutine_handle<promise_type> _coroutine = nullptr;
resumable_thing() = default;
resumable_thing(resumable_thing const&) = delete;
resumable_thing& operator=(resumable_thing const&) = delete;
resumable_thing(resumable_thing&& other) : _coroutine(other._coroutine) { other._coroutine = nullptr; }
resumable_thing& operator=(resumable_thing&& other) {
if (&other != this) { _coroutine = other._coroutine; other._coroutine = nullptr; }
return *this;
}
explicit resumable_thing(coroutine_handle<promise_type> coroutine) : _coroutine(coroutine) {}
~resumable_thing() { if (_coroutine) _coroutine.destroy(); }
void resume() { _coroutine.resume(); }
};
resumable_thing counter() {
cout << "counter: called\n";
for (unsigned i = 1;; i++) {
co_await std::suspend_always{};
cout << "counter:: resumed (#" << i << ")\n";
}
}
int main() {
cout << "main: calling counter\n";
resumable_thing the_counter = counter();
cout << "main: resuming counter\n";
the_counter.resume();
the_counter.resume();
the_counter.resume();
the_counter.resume();
the_counter.resume();
cout << "main: done\n";
return 0;
}The article then dives into the design of a C++20‑based scheduler. It defines several AwaitMode values (e.g., AwaitNever , AwaitNextframe , AwaitForNotifyWithTimeout ) that control how a task is re‑queued after a suspension point.
Key scheduler functions are presented:
void Scheduler::Update() {
// handle tasks that need to be killed
while (!mNeedKillArray.empty()) {
auto tid = mNeedKillArray.front();
mNeedKillArray.pop();
ISchedTask* tmpTask = GetTaskById(tid);
if (tmpTask) DestroyTask(tmpTask);
}
// run tasks scheduled for this frame
decltype(mFrameStartTasks) tmpFrameTasks;
mFrameStartTasks.swap(tmpFrameTasks);
while (!tmpFrameTasks.empty()) {
auto task_id = tmpFrameTasks.front();
tmpFrameTasks.pop();
ISchedTask* task = GetTaskById(task_id);
if (task) AddToImmRun(task);
}
}
void Scheduler::AddToImmRun(ISchedTask* schedTask) {
LOG_PROCESS_ERROR(schedTask);
schedTask->Run();
if (schedTask->IsDone()) {
DestroyTask(schedTask);
return;
}
switch (schedTask->GetAwaitMode()) {
case AwaitMode::AwaitNever:
AddToImmRun(schedTask);
break;
case AwaitMode::AwaitNextframe:
AddToNextFrameRun(schedTask);
break;
case AwaitMode::AwaitForNotifyNoTimeout:
case AwaitMode::AwaitForNotifyWithTimeout:
HandleTaskAwaitForNotify(schedTask, schedTask->GetAwaitMode(), schedTask->GetAwaitTimeout());
break;
case AwaitMode::AwaitDoNothing:
break;
default:
RSTUDIO_ERROR(CanNotRunToHereError());
}
}Resuming a task from an awaitable is done via ResumeTaskByAwaitObject , which extracts the task ID from the awaitable, binds the resume object, and re‑queues the task for immediate execution.
template<typename E>
auto ResumeTaskByAwaitObject(E&& awaitObj) -> std::enable_if_t<std::is_base_of<ResumeObject, E>::value> {
auto tid = awaitObj.taskId;
if (IsTaskInAwaitSet(tid)) {
ISchedTask* task = GetTaskById(tid);
if (task) {
task->BindResumeObject(std::forward<E>(awaitObj));
AddToImmRun(task);
}
OnTaskAwaitNotifyFinish(tid);
}
}An example awaitable used for RPC calls is provided:
class RpcRequest {
public:
RpcRequest(const GameServiceCallerPtr& proxy, std::string_view funcName, reflection::Args&& arg, int timeoutMs)
: mProxy(proxy), mFuncName(funcName), mArgs(std::forward<reflection::Args>(arg)), mTimeoutMs(timeoutMs) {}
bool await_ready() { return false; }
void await_suspend(coroutine_handle<>) const noexcept {
auto* task = rco_self_task();
auto context = std::make_shared<ServiceContext>();
context->TaskId = task->GetId();
context->Timeout = mTimeoutMs;
mProxy->DoDynamicCall(mFuncName, std::move(mArgs), context);
task->DoYield(AwaitMode::AwaitForNotifyNoTimeout);
}
RpcResumeObject* await_resume() const noexcept { return rco_get_resume_object(RpcResumeObject); }
private:
GameServiceCallerPtr mProxy;
std::string mFuncName;
reflection::Args mArgs;
int mTimeoutMs;
};A complete coroutine task example demonstrates creating tasks, looping with co_await , spawning child tasks, waiting for their completion, and performing an RPC call:
mScheduler.CreateTask20([clientProxy]() -> rstudio::logic::CoResumingTaskCpp20 {
auto* task = rco_self_task();
printf("step1: task %llu\n", task->GetId());
co_await rstudio::logic::cotasks::NextFrame{};
printf("step2 after yield!\n");
for (int c = 0; c < 5; ++c) {
printf("in while loop c=%d\n", c);
co_await rstudio::logic::cotasks::Sleep(1000);
}
for (int c = 0; c < 5; ++c) {
printf("in for loop c=%d\n", c);
co_await rstudio::logic::cotasks::NextFrame{};
}
auto newTaskId = co_await rstudio::logic::cotasks::CreateTask(false, []() -> rstudio::logic::CoResumingTaskCpp20 {
printf("from child coroutine!\n");
co_await rstudio::logic::cotasks::Sleep(2000);
printf("after child coroutine sleep\n");
});
printf("new task create in coroutine: %llu\n", newTaskId);
printf("Begin wait for task!\n");
co_await rstudio::logic::cotasks::WaitTaskFinish{newTaskId, 10000};
printf("After wait for task!\n");
RpcRequest rpcReq{clientProxy, "DoHeartBeat", rstudio::reflection::Args{3}, 5000};
auto* rpcret = co_await rpcReq;
if (rpcret->rpcResultType == rstudio::network::RpcResponseResultType::RequestSuc) {
assert(rpcret->totalRet == 1);
int retval = rpcret->retValue.to
();
assert(retval == 4);
printf("rpc coroutine run suc, val = %d!\n", retval);
} else {
printf("rpc coroutine run failed! result = %d \n", (int)rpcret->rpcResultType);
}
co_await rstudio::logic::cotasks::Sleep(5000);
printf("step4, after 5s sleep\n");
co_return rstudio::logic::CoNil;
});The article also compares a Python coroutine‑based skill system with a C++20 implementation. The Python version uses yield to pause between skill steps, while the C++ version expresses the same logic with co_await , co_return , and the custom scheduler, resulting in clearer, more maintainable code.
In conclusion, C++20 coroutines, combined with a purpose‑built scheduler, provide a powerful yet low‑cognitive‑load way to write asynchronous game logic, bringing the ergonomics of scripting languages to high‑performance native code.
Tencent Cloud Developer
Official Tencent Cloud community account that brings together developers, shares practical tech insights, and fosters an influential tech exchange community.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.