CPU Offloading
Vix Async keeps coroutine continuations on the scheduler thread.
That works well for short asynchronous control flow, but not for long-running synchronous computation. If a coroutine spends too much time executing ordinary C++ code, the scheduler cannot make progress on other ready tasks.
CPU offloading moves that work to the Async thread pool.
Header
Use the public Vix Async header:
#include <vix/async.hpp>For examples that print output:
#include <vix/print.hpp>The CPU pool API lives in:
vix::async::coreThe problem
A coroutine is not automatically parallel.
Consider:
using namespace vix::async::core;
task<void> run()
{
perform_expensive_computation();
co_return;
}perform_expensive_computation() runs synchronously on the thread that resumed the coroutine.
If that coroutine is running through the Vix scheduler:
scheduler
↓
run()
↓
expensive computation
↓
scheduler cannot process other ready workNo suspension occurs until the computation returns.
Returning task<void> does not change that.
Move the work to the CPU pool
Use cpu_pool().submit() when the coroutine needs to wait for the result.
task<void> run(io_context& ctx)
{
int result = co_await ctx.cpu_pool().submit([](){
return perform_expensive_computation();
});
vix::print("result:", result);
}The flow becomes:
scheduler thread
↓
submit work
↓
coroutine suspends
↓
CPU worker executes callable
↓
result becomes ready
↓
coroutine resumesThe scheduler thread is no longer occupied by the computation.
What should be offloaded
CPU offloading is appropriate for synchronous work that would otherwise keep the scheduler busy for too long.
Examples include:
- image processing
- compression
- parsing large inputs
- cryptographic computation
- large transformations
- expensive numerical work
- synchronous libraries that perform significant CPU work
- other blocking synchronous functions when no asynchronous API is available
The important property is not the function name. It is whether the call occupies the scheduler thread while other asynchronous work should remain responsive.
What should stay on the scheduler
Short application logic normally belongs directly inside the coroutine.
For example:
task<void> process()
{
int value = 42;
value *= 2;
vix::print("value:", value);
co_return;
}Moving every small computation to a worker would add queueing, synchronization, and thread switching without solving a real problem.
The scheduler is designed to execute ordinary coroutine code.
Offload work when its synchronous execution becomes expensive enough to interfere with the event loop.
Asynchronous waiting is not CPU work
A timer should not be moved to the CPU pool.
Avoid:
co_await ctx.cpu_pool().submit([](){
std::this_thread::sleep_for(
std::chrono::seconds(1)
);
});Vix already has an asynchronous timer service:
co_await ctx.timers().sleep_for(
std::chrono::seconds(1)
);The first version occupies a worker thread for one second.
The second version suspends the coroutine while the timer service waits efficiently.
The same principle applies to networking.
Do not move a synchronous socket wait to the CPU pool when Vix Async already provides an asynchronous TCP, UDP, or DNS operation.
CPU offloading is not asynchronous I/O
These two cases solve different problems.
CPU offloading
↓
execute synchronous work on another thread
asynchronous I/O
↓
suspend until an external operation becomes readyFor CPU work:
co_await ctx.cpu_pool().submit([](){
return compute();
});For a timer:
co_await ctx.timers().sleep_for(100ms);For networking:
co_await stream->async_read(buffer);All three suspend the caller, but the underlying execution is different.
Returning a result
The normal CPU offloading pattern is:
auto result = co_await ctx.cpu_pool().submit([](){
return compute();
});The worker executes the callable and stores its result.
The coroutine resumes with that value after the worker finishes.
For example:
task<void> run(io_context& ctx)
{
std::size_t count = co_await ctx.cpu_pool().submit([](){
return count_records();
});
vix::print("records:", count);
}The continuation after co_await is again part of the normal coroutine workflow.
Void CPU work
When the callable has no result:
co_await ctx.cpu_pool().submit([](){
rebuild_index();
});the returned operation is a task<void>.
The coroutine still waits for completion before continuing.
task<void> run(io_context& ctx)
{
co_await ctx.cpu_pool().submit([](){
rebuild_index();
});
vix::print("index rebuilt");
}Use this when the caller cares that the work finished even though no value is returned.
Fire-and-forget CPU work
Use post() when the caller intentionally does not need completion or a result.
bool accepted = ctx.cpu_pool().post([](){
rebuild_cache();
});Check whether the pool accepted the job:
if (!accepted)
{
vix::print("work rejected");
}The distinction is:
submit()
↓
caller observes completion
post()
↓
caller only observes acceptanceIf the caller needs to know whether the operation later succeeded, post() is the wrong boundary.
Exceptions
Exceptions from submit() remain observable by the awaiting coroutine.
task<void> run(io_context& ctx)
{
try
{
int result = co_await ctx.cpu_pool().submit([]() -> int {
throw std::runtime_error("failed");
});
vix::print("result:", result);
}
catch (const std::exception& error)
{
vix::print("error:", error.what());
}
}The worker catches the exception and transfers it through the asynchronous result.
For post(), there is no awaiting coroutine. Exceptions from the posted callable are consumed by the pool.
If failure matters, use submit().
Several CPU jobs
Independent CPU jobs can be composed with when_all.
task<void> run(io_context& ctx)
{
auto results = co_await when_all(
ctx.get_scheduler(),
ctx.cpu_pool().submit([](){
return compute_left();
}),
ctx.cpu_pool().submit([](){
return compute_right();
})
);
vix::print("left:", std::get<0>(results));
vix::print("right:", std::get<1>(results));
}The jobs can be consumed by different workers while the waiting coroutine remains suspended.
Conceptually:
scheduler
↓
when_all
/ \
/ \
submit A submit B
↓ ↓
worker worker
\ /
\ /
both complete
↓
coroutineThe thread pool provides parallel worker execution.
when_all provides coroutine coordination.
Sequential submissions remain sequential
This code does not run both computations at the same time:
int first = co_await ctx.cpu_pool().submit([](){
return compute_first();
});
int second = co_await ctx.cpu_pool().submit([](){
return compute_second();
});The second submission is not reached until the first co_await completes.
Use sequential code when there is a dependency:
compute first
↓
use first result
↓
compute secondUse composition only when the work is genuinely independent.
Avoid unnecessary worker hops
Consider:
int value = co_await ctx.cpu_pool().submit([](){
return 42;
});This is valid, but moving return 42 to another thread provides no benefit.
The operation now requires:
scheduler
↓
worker queue
↓
worker
↓
completion
↓
schedulerinstead of simply computing the value locally.
Offloading is useful when the work justifies that boundary.
Blocking synchronous libraries
Sometimes an application depends on a library that only exposes a blocking API.
For example:
auto result = blocking_library_call();If this operation can take significant time and cannot be replaced with an asynchronous API, it can be isolated on the CPU pool:
auto result = co_await ctx.cpu_pool().submit([](){
return blocking_library_call();
});This protects the scheduler thread.
It does not make the underlying library asynchronous. A worker thread is still blocked until the call returns.
This distinction matters when many such operations can happen simultaneously, because worker threads are a finite resource.
Worker threads are finite
The pool has a fixed number of workers.
If every worker is occupied:
worker 1 -> busy
worker 2 -> busy
worker 3 -> busy
worker 4 -> busy
new job -> waits in queueAdditional submitted work remains queued until a worker becomes available.
This is another reason not to use the CPU pool as a replacement for asynchronous timers or networking. Thousands of suspended network operations do not need thousands of worker threads, while thousands of blocking calls eventually compete for a finite worker pool.
Avoid nested blocking dependencies
A worker should not synchronously depend on another job from the same limited pool in a way that requires another worker to make progress.
Conceptually:
worker A
↓
waits synchronously for job B
job B
↓
waiting for free workerIf all workers enter this pattern, progress can stop.
Prefer coroutine composition outside the worker:
auto first = ctx.cpu_pool().submit([](){
return compute_first();
});
auto second = ctx.cpu_pool().submit([](){
return compute_second();
});
auto results = co_await when_all(
ctx.get_scheduler(),
std::move(first),
std::move(second)
);Keep worker callables self-contained whenever possible.
Cancellation before execution
submit() can receive a cancel_token.
cancel_source source;
auto result = co_await ctx.cpu_pool().submit(
[](){
return compute();
},
source.token()
);If the job reaches a worker after cancellation has already been requested, the callable is skipped.
The awaiting coroutine receives:
errc::canceledthrough std::system_error.
This can prevent queued work from starting after the application no longer needs it.
Cancellation after execution begins
Once arbitrary C++ is running, the pool does not forcibly interrupt it.
queued
↓
cancellation
↓
skip callable
running
↓
cancellation
↓
callable keeps executingIf long-running CPU work needs cancellation after it starts, it must cooperate.
cancel_token token = source.token();
co_await ctx.cpu_pool().submit(
[token](){
while (!token.is_cancelled())
{
if (!process_next_chunk())
{
break;
}
}
}
);The callable chooses safe points where cancellation can be observed.
Chunk long computations when useful
Some CPU algorithms naturally support incremental work.
Instead of one large opaque operation:
co_await ctx.cpu_pool().submit([](){
process_everything();
});a callable can periodically inspect cancellation:
co_await ctx.cpu_pool().submit(
[token](){
for (std::size_t i = 0; i < chunk_count(); ++i)
{
if (token.is_cancelled())
{
return;
}
process_chunk(i);
}
}
);This does not make the worker preemptive.
It simply gives the application explicit interruption points.
Data passed to workers
A submitted callable may execute after the submitting coroutine has suspended.
Captured references must therefore remain valid until the worker has finished using them.
Be careful with:
ctx.cpu_pool().submit([&value](){
process(value);
});The referenced object must outlive the worker operation.
Capturing an owned value can make the relationship clearer:
auto result = co_await ctx.cpu_pool().submit(
[data = std::move(data)](){
return process(data);
}
);Normal C++ ownership rules still apply across the worker boundary.
Shared state
Several worker jobs can execute concurrently.
If they modify shared state:
ctx.cpu_pool().post([&state](){
update(state);
});
ctx.cpu_pool().post([&state](){
update(state);
});normal C++ synchronization rules apply.
The thread pool does not automatically serialize access to shared objects.
Use appropriate synchronization or redesign the work so workers operate on independent data.
For many workloads, isolated inputs and explicit result transfer are easier to reason about than shared mutable state.
Scheduler state belongs on the scheduler
A useful design pattern is:
scheduler
↓
prepare immutable input
↓
CPU worker
↓
compute result
↓
scheduler
↓
apply result to application stateFor example:
task<void> run(io_context& ctx)
{
auto input = make_input();
auto result = co_await ctx.cpu_pool().submit(
[input = std::move(input)](){
return compute(input);
}
);
apply_result(result);
}The worker performs isolated computation.
After co_await, the coroutine returns to the Async execution flow and can update scheduler-owned application state.
This reduces unnecessary synchronization between worker threads.
Pool shutdown
When the pool stops, it rejects new submissions but drains work that was already accepted.
accepted jobs
↓
stop requested
↓
new jobs rejected
↓
accepted jobs finish
↓
workers exitA submit() operation that cannot be accepted reports:
errc::rejectedrather than suspending forever.
This matters during application shutdown, when new CPU work may race with service teardown.
Choosing the execution path
A useful decision model is:
Does the operation already have a Vix async API?
│
├── yes -> await that API
│
└── no
↓
Is the synchronous work expensive or blocking?
│
├── no -> run it directly
│
└── yes -> CPU poolExamples:
small calculation
-> scheduler thread
timer wait
-> timer service
TCP read
-> network service
DNS lookup
-> network service
large compression job
-> CPU pool
blocking third-party computation
-> CPU poolThe goal is not to move everything away from the scheduler. It is to keep the scheduler available for asynchronous coordination while expensive synchronous work executes where it belongs.
Next step
Continue with Networking to see how Vix Async handles TCP, UDP, and DNS without consuming CPU worker threads while waiting for I/O.
Then read: