Vix.cpp v2.8.5 is here Read the blog
Skip to content

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.

Use the public Vix Async header:

cpp
#include <vix/async.hpp>

For examples that print output:

cpp
#include <vix/print.hpp>

A task starts suspended ​

A function returning task<T> does not immediately run when it is called.

cpp
vix::async::task<int> compute()
{
  vix::print("compute");

  co_return 42;
}

This:

cpp
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:

cpp
std::move(task).start(ctx.get_scheduler());

For a child task:

cpp
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.

cpp
#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:

text
create run()
     ↓
task is suspended
     ↓
start on scheduler
     ↓
scheduler receives coroutine handle
     ↓
ctx.run()
     ↓
scheduler resumes coroutine

The 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.

cpp
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:

text
scheduler resumes run()
        ↓
print A
        ↓
compute value
        ↓
print value
        ↓
sleep_for()
        ↓
coroutine suspends

Until 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:

cpp
co_await ctx.timers().sleep_for(
  std::chrono::milliseconds(100)
);

the coroutine waits logically, but the scheduler thread is free.

text
coroutine A
    ↓
sleep_for()
    ↓
suspended

scheduler
    ↓
coroutine B
callback C
other ready work

This 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:

text
timer expires
     ↓
timer service
     ↓
continuation posted to scheduler
     ↓
scheduler resumes coroutine
     ↓
execution continues after co_await

For networking:

text
network operation completes
          ↓
Asio backend
          ↓
continuation posted to scheduler
          ↓
coroutine resumes

The 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.

cpp
#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:

text
run()
  ↓
co_await load_value()
  ↓
run() suspends
  ↓
load_value() starts
  ↓
load_value() completes
  ↓
run() continues

The 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.

cpp
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:

text
scheduler thread
      ↓
run()
      ↓
cpu_pool().submit(...)
      ↓
run() suspends
      ↓
worker thread runs callable
      ↓
callable completes
      ↓
awaiting operation becomes ready
      ↓
coroutine resumes

The 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:

cpp
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:

cpp
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:

text
awaited operation ready now
        ↓
continue

awaited operation not ready
        ↓
suspend
        ↓
resume later

Application 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:

text
task A -> waiting for timer

task B -> waiting for TCP data

task C -> waiting for DNS

task D -> ready

The 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.

cpp
auto results = co_await vix::async::when_all(
  ctx.get_scheduler(),
  first(),
  second()
);

The tasks can progress independently:

text
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.

text
task A ───── complete ── winner
task B ─────────────── continues
task C ─────────────── continues

The 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:

text
coroutine
    ↓
await operation
    ↓
suspended
    ↓
cancellation requested
    ↓
operation reacts
    ↓
continuation becomes ready
    ↓
scheduler resumes coroutine

The 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:

cpp
vix::async::task<int> compute()
{
  throw std::runtime_error("failed");

  co_return 0;
}

An awaiting coroutine can handle the error normally:

cpp
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:

cpp
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:

text
coroutine waits for TCP
        ↓
shutdown begins
        ↓
network service cancels operation
        ↓
completion returned to scheduler
        ↓
coroutine resumes and observes shutdown
        ↓
scheduler can finish

This 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:

text
created
   ↓
suspended initially
   ↓
scheduled or awaited
   ↓
running
   ↓
┌─────────────────────┐
│ operation not ready │
└──────────┬──────────┘
           ↓
       suspended
           ↓
 operation completes
           ↓
       scheduled
           ↓
        running
           ↓
 completed or suspends again

The 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:

Released under the MIT License.