Execution Model
Vix Async uses cooperative coroutine execution.
A coroutine runs until it returns or reaches a suspension point. When it suspends, the scheduler is free to execute other ready work. Once the awaited operation completes, the coroutine continuation is placed back on the scheduler and execution continues from the point after co_await.
This model is shared by tasks, timers, CPU work, networking, signals, and task composition.
Header
Use the public Vix Async header:
#include <vix/async.hpp>For examples that print output:
#include <vix/print.hpp>A task starts suspended
A function returning task<T> does not immediately run when it is called.
vix::async::task<int> compute()
{
vix::print("compute");
co_return 42;
}This:
auto task = compute();creates the coroutine and its frame, but compute has not printed anything yet.
The task begins when it is awaited by another coroutine or explicitly started on a scheduler.
For a root task:
std::move(task).start(ctx.get_scheduler());For a child task:
int value = co_await compute();This lazy start is important because task creation and task execution are separate operations.
Starting a root task
The first task in an asynchronous program has no parent coroutine to await it.
It must be attached to a scheduler explicitly.
#include <vix/async.hpp>
#include <vix/print.hpp>
vix::async::task<void> run()
{
vix::print("running");
co_return;
}
int main()
{
vix::async::io_context ctx;
std::move(run()).start(ctx.get_scheduler());
ctx.run();
return 0;
}The sequence is:
create run()
↓
task is suspended
↓
start on scheduler
↓
scheduler receives coroutine handle
↓
ctx.run()
↓
scheduler resumes coroutineThe task does not need its own thread.
It runs when the scheduler resumes it.
Running until suspension
Once resumed, a coroutine executes like ordinary C++ code.
vix::async::task<void> run(vix::async::io_context& ctx)
{
vix::print("A");
int value = 21 * 2;
vix::print("value:", value);
co_await ctx.timers().sleep_for(
std::chrono::milliseconds(100)
);
vix::print("B");
}Everything before co_await runs synchronously on the scheduler thread.
The coroutine only gives control back when the awaited operation suspends it.
The execution is therefore:
scheduler resumes run()
↓
print A
↓
compute value
↓
print value
↓
sleep_for()
↓
coroutine suspendsUntil that suspension point is reached, no other ready coroutine can execute on the same scheduler thread.
Suspension does not block the scheduler
When a coroutine suspends, its frame remains alive, but it is no longer executing.
For a timer:
co_await ctx.timers().sleep_for(
std::chrono::milliseconds(100)
);the coroutine waits logically, but the scheduler thread is free.
coroutine A
↓
sleep_for()
↓
suspended
scheduler
↓
coroutine B
callback C
other ready workThis is the main difference between asynchronous waiting and blocking the current thread.
A blocking call occupies the thread.
A suspended coroutine does not.
Resuming after an asynchronous operation
When the awaited operation completes, the coroutine must become ready again.
A timer follows this path:
timer expires
↓
timer service
↓
continuation posted to scheduler
↓
scheduler resumes coroutine
↓
execution continues after co_awaitFor networking:
network operation completes
↓
Asio backend
↓
continuation posted to scheduler
↓
coroutine resumesThe backend thread is responsible for completing the operation, but normal coroutine continuation returns through the Vix scheduler.
Child tasks
A task can await another task directly.
#include <vix/async.hpp>
#include <vix/print.hpp>
vix::async::task<int> load_value()
{
co_return 42;
}
vix::async::task<void> run()
{
int value = co_await load_value();
vix::print("value:", value);
}The parent task suspends while the child runs.
Conceptually:
run()
↓
co_await load_value()
↓
run() suspends
↓
load_value() starts
↓
load_value() completes
↓
run() continuesThe returned value becomes the result of the co_await.
If the child task throws, the exception is propagated through the await boundary.
CPU work changes execution threads
A CPU pool submission is different from a normal child task because the callable itself runs on a worker thread.
vix::async::task<void> run(vix::async::io_context& ctx)
{
int value = co_await ctx.cpu_pool().submit([](){
return 21 * 2;
});
vix::print("value:", value);
}The execution flow becomes:
scheduler thread
↓
run()
↓
cpu_pool().submit(...)
↓
run() suspends
↓
worker thread runs callable
↓
callable completes
↓
awaiting operation becomes ready
↓
coroutine resumesThe CPU pool exists because expensive synchronous work should not occupy the scheduler thread.
The coroutine itself still follows the same suspend and resume model.
Long synchronous work still blocks
Coroutines do not automatically make synchronous code asynchronous.
This code still blocks the scheduler:
vix::async::task<void> run()
{
perform_expensive_work();
co_return;
}No suspension occurs while perform_expensive_work() executes.
The scheduler cannot process another coroutine until the function returns or the current coroutine reaches a suspension point.
For work that should run elsewhere:
co_await ctx.cpu_pool().submit([](){
perform_expensive_work();
});The distinction is important because co_await only helps when the awaited operation can actually suspend.
Ready does not always mean suspended
An awaited operation can sometimes complete without needing a real asynchronous wait.
In that case, the coroutine may continue immediately rather than leaving the scheduler and returning later.
The exact behavior depends on the awaitable.
The important model is:
awaited operation ready now
↓
continue
awaited operation not ready
↓
suspend
↓
resume laterApplication code normally does not need to inspect that distinction manually.
Several tasks can be in progress
A single scheduler thread can manage many tasks because most of them may be suspended at any given moment.
For example:
task A -> waiting for timer
task B -> waiting for TCP data
task C -> waiting for DNS
task D -> readyThe scheduler only executes work that is currently ready.
It does not dedicate one scheduler thread to each task.
This is why the runtime can coordinate several independent operations even though coroutine continuations normally return to one event-loop thread.
when_all
when_all starts several tasks and suspends until all of them have completed.
auto results = co_await vix::async::when_all(
ctx.get_scheduler(),
first(),
second()
);The tasks can progress independently:
first ──────── complete ─────┐
│
second ───────────── complete ─┼─ when_all resumes
│
┘when_all itself does not create extra worker threads.
The tasks use whatever asynchronous services their own operations require.
when_any
when_any resumes when the first supplied task completes.
task A ───── complete ── winner
task B ─────────────── continues
task C ─────────────── continuesThe losing tasks are not automatically cancelled.
They remain active after when_any returns.
This means the scheduler and services they depend on must remain alive until those tasks can finish.
The returned result only contains the completed slot for the winning task.
Cancellation and execution
Cancellation is also cooperative.
A cancellation request does not forcibly stop a coroutine in the middle of arbitrary C++ execution.
For an operation that supports cancellation:
coroutine
↓
await operation
↓
suspended
↓
cancellation requested
↓
operation reacts
↓
continuation becomes ready
↓
scheduler resumes coroutineThe coroutine then observes the cancellation through the result or exception defined by that operation.
A CPU callable that is already running must cooperate explicitly if it wants to stop early.
Exceptions
Exceptions inside an awaited task are stored by the coroutine and rethrown when the awaiting code resumes.
For example:
vix::async::task<int> compute()
{
throw std::runtime_error("failed");
co_return 0;
}An awaiting coroutine can handle the error normally:
vix::async::task<void> run()
{
try
{
int value = co_await compute();
vix::print("value:", value);
}
catch (const std::exception& error)
{
vix::print("error:", error.what());
}
}This keeps asynchronous task errors inside normal C++ exception flow.
Detached tasks are different because there is no awaiting coroutine available to receive their exception. Their behavior is covered in Spawn and Detached Tasks.
The scheduler is cooperative
The scheduler cannot preempt ordinary C++ code.
If a coroutine executes:
while (true)
{
perform_work();
}without returning or suspending, the scheduler remains inside that coroutine indefinitely.
Other tasks cannot progress on that scheduler thread.
Good Async code therefore keeps scheduler-thread work short and moves long CPU or blocking operations to the appropriate service.
Shutdown and suspended work
Shutdown must account for coroutines that are still suspended inside services.
For example:
coroutine waits for TCP
↓
shutdown begins
↓
network service cancels operation
↓
completion returned to scheduler
↓
coroutine resumes and observes shutdown
↓
scheduler can finishThis is why io_context::shutdown() stops services before completing scheduler shutdown.
Pending continuations still need a valid scheduler while the runtime is leaving those asynchronous operations.
Execution model summary
A Vix Async task normally moves through these states:
created
↓
suspended initially
↓
scheduled or awaited
↓
running
↓
┌─────────────────────┐
│ operation not ready │
└──────────┬──────────┘
↓
suspended
↓
operation completes
↓
scheduled
↓
running
↓
completed or suspends againThe model is cooperative. A task runs until it suspends or completes. Services make suspended tasks ready again, and the scheduler decides when ready continuations execute.
Next step
Continue with Tasks to look more closely at task<T>, task ownership, values, exceptions, and the difference between root and awaited tasks.
Then read: