High-performance async/await, zero-allocation, cooperatively scheduled coroutines for .NET
Routinely is a lightweight coroutine library designed for game engines and applications that require high-performance asynchronous operations. It provides explicit single thread tick-based control flow, achieving high performance with minimal overhead while maintaining zero GC pressure.
async Coroutine Work()
{
// Resume next tick
await Coroutine.Yield();
// Await another coroutine
await OtherWork();
// Await a coroutine with a result
var result = await ResultWork();
}dotnet add package Routinely --prerelease
using Routinely;
var isRunning = true;
var helloWorldCo = HelloWorld();
// Application loop
while (isRunning)
{
// Tick all coroutines
Coroutine.ResumeAll();
// Exit loop when all coroutines have completed
if(Coroutine.Count == 0)
{
isRunning = false;
}
}
// Simple coroutine
async Coroutine HelloWorld()
{
Console.WriteLine("Hello...");
// Resume next tick
await Coroutine.Yield();
Console.WriteLine("...World!");
}async Coroutine Work()
{
for (int i = 0; i < 10; i++)
{
await Coroutine.Yield();
}
}
// Start without tracking
Work().Forget();async Coroutine<int> Work()
{
int total = 0;
for (int i = 0; i < 100; i++)
{
total += 1;
await Coroutine.Yield();
}
return total;
}Routinely is built around fast immediate cancellation. Coroutines that don't access long lived resources can simply be canceled.
async Coroutine Work()
{
// Loop forever until cancelled
while(true)
{
await Coroutine.Yield();
}
}
var work = Work();
// Immediately stops the coroutine
work.Cancel(); async Coroutine Work()
{
// Enter a cancellation contract scope
using var contract = await CancellationContract.Enter();
try
{
// Get some long-lived resource that needs cleanup
AcquireResource();
// Do work that may be cancelled
await Coroutine.Yield().Enforce(contract);
// Do more work that may be cancelled
await Coroutine.Yield().Enforce(contract);
// Await a nested coroutine
await NestedWork().Enforce(contract);
}
finally
{
// Always runs, even if cancelled
ReleaseResource();
}
}
var work = Work();
work.Cancel(); // Triggers cancellation finally block ensures cleanupasync Coroutine Work()
{
using var contract = await CancellationContract.Enter();
try
{
await OtherWork().Enforce(contract);
await MoreWork().Enforce(contract);
}
catch (CancellationException)
{
// Run async cleanup that outlives this coroutine
// This gets its own execution stack, allowing it to continue even after this coroutine is cancelled
await WorkCanceled();
}
}async Coroutine BigWork()
{
var workCos = new[]
{
WorkA(),
WorkB(),
WorkC()
};
await Coroutine.WhenAll(workCos);
}async Coroutine<int[]> BigWorkWithResults()
{
var workCos = new[]
{
WorkA(),
WorkB(),
WorkC()
};
var results = await Coroutine.WhenAll(workCos);
return results;
}var workCos = new[]
{
WorkA(),
WorkB(),
WorkC()
};
var results = new int[workCos.Length];
async Coroutine BigWorkNonAlloc(Coroutine<int>[] workCos, int[] results)
{
await Coroutine.WhenAll(workCos, results);
}async Coroutine Race()
{
var requests = new[]
{
RaceA(),
RaceB(),
RAceC()
};
await Coroutine.WhenAny(requests);
}async Coroutine<(int index, int result)> RaceWithResults()
{
var requests = new[]
{
RaceA(),
RaceB(),
RAceC()
};
var fastestCo = await Coroutine.WhenAny(requests);
return await fastestCo; // Await the winning coroutine to get its result
}async Coroutine StateA()
{
// Do some work in StateA...
// Switch to StateB on the next tick
await Coroutine.SwitchTo(StateB);
}
async Coroutine StateB()
{
// Do some work in StateB...
// Switch to StateA on the next tick
await Coroutine.SwitchTo(StateA);
}async Coroutine StateA()
{
// Do some work in StateA...
// Switch to StateB on the next tick without allocating a closure
await Coroutine.SwitchTo(this, static @this => @this.StateB());
}
async Coroutine StateB()
{
// Do some work in StateB...
// Switch to StateA on the next tick without allocating a closure
await Coroutine.SwitchTo(this, static @this => @this.StateA());
}async Coroutine StateA()
{
await Coroutine.SwitchTo(this, static @this => @this.StateB());
}
async Coroutine StateB()
{
await Coroutine.SwitchTo(this, static @this => @this.StateA());
}
// Will bounce back and forth between StateA and StateB indefinitely on ResumeAll
var stateCo = StateA();
// Cancels the current coroutine state, preventing infinite recursion and allowing cleanup if needed.
// The stateCo handle will always show as IsCompleted = false until it gets cancelled or a switched to coroutine returns.
stateCo.Cancel(); SwitchTo is a terminal operation. The coroutine callstack prior to the SwitchTo call is freed and will never resume. This means that any code after the
SwitchTo call will never execute.
async Coroutine Work()
{
// Switches but will never receive a result.
await Coroutine.SwitchTo(VoidWork);
// Unreachable code - the callstack is freed after SwitchTo and will never resume here
await Coroutine.Yield();
}Currently only supports switching between Coroutine to Coroutine (no generic overloads yet). While you can switch from a
Coroutine<T> to a Coroutine you won't be able to return a result.
async Coroutine<int> IntWork()
{
// Switches to a coroutine but can't return an int result
await Coroutine.SwitchTo(VoidWork);
// Unreachable code - the callstack is freed after SwitchTo and will never resume here
return 1;
}Type safety of switching between Coroutine<T1> and Coroutine<T2>is not enforced by the compiler.
async Coroutine<string> StringWork()
{
await Coroutine.Yield();
return "Hello";
}
async Coroutine<int> IntWork()
{
// Switches to a Coroutine<string> but the compiler won't catch this, it will still work however
await Coroutine.SwitchTo(StringWork);
// Unreachable code - the callstack is freed after SwitchTo and will never resume here
return 1;
}TLDR: When using SwitchTo with non void coroutines, expect the unexpected!
async Task TaskWork()
{
// Async work
}
async ValueTask ValueTaskWork()
{
// Async work
}
async Coroutine Work()
{
await Coroutine.FromTask(TaskWork());
await Coroutine.FromValueTask(ValueTaskWork());
}async Task CancellableTaskWork(CancellationToken token)
{
// Async work that supports cancellation
}
async ValueTask CancellableValueTaskWork(CancellationToken token)
{
// Async work that supports cancellation
}
async Coroutine Work()
{
await Coroutine.FromTask(ct => CancellableTaskWork(ct));
await Coroutine.FromValueTask(ct => CancellableValueTaskWork(ct));
}
var workCo = Work();
workCo.Cancel();The Cancel overload that provides a cancellation token will hook into the cancellation contract pattern under the hood.
This means that if the coroutine is cancelled while awaiting a task from FromTask or FromValueTask the task gets cancelled.
Coroutine contexts can be created to allow executing coroutines to run in the current context and to
switch their execution context mid-execution where necessary. The first context created wraps the default
coroutine context (the one used by ResumeAll if no initial contexts are created).
var highPriority = Coroutine.CreateContext();
var lowPriority = Coroutine.CreateContext();
var lowPriorityCounter = 0;
var isRunning = true;
while(isRunning)
{
Coroutine.SetContext(highPriority);
// Create a new coroutine in the high priority context
Work().Forget();
Coroutine.ResumeAll();
// Execute every 5 ticks
if(lowPriorityCounter++ % 5 == 0)
{
Coroutine.SetContext(lowPriority);
// Create a new coroutine in the low priority context
Work().Forget();
Coroutine.ResumeAll();
}
}
async Coroutine Work()
{
await Coroutine.Yield();
}var highPriority = Coroutine.CreateContext();
var lowPriority = Coroutine.CreateContext();
async Coroutine VariabePriorityWork()
{
var count = 0;
// Force the coroutine to the high priority context when first executed
await Coroutine.Context(highPriority);
while(count++ < 5)
{
await Coroutine.Yield();
}
// Force to the low priority context after 5 ticks
await Coroutine.Context(lowPriority);
await Coroutine.Yield();
}await Coroutine.Context(...)
call will complete synchronously and the coroutine will continue to the next await. Changing a coroutine's
context moves the entire callstack from the current context to the new context.
If a coroutine switches context multiple times the callstack will move back and forth between contexts with each switch.
- A coroutine cannot directly await a Task. Use Coroutine.FromTask() instead. Direct await will cause a build failure as framework task like types can't match the interface requirements
of
ICoroutineNotifyAwaiting. - Coroutines are designed to be short lived handles to cooperatively scheduled work. The library aggressively recycles resources used to maintain
a coroutine's execution state (e.g. it's callstack, core state, async state machine and results/exceptions). By default they will persist for one tick after completion
before being recycled. If
Forgetis used the coroutine will be recycled immediately after completion. - When a coroutine awaits another coroutine, the awaited coroutine becomes part of the callstack of the awaiting coroutine. This means that a coroutine can only be awaited once. Attempting to await it a second time is an exception condition.
- If a coroutine returns false for
HasContextall other state properties of that coroutine will also return false. Awaiting a coroutine thatHasContext == falseis an exception condition. - The library currently only supports running on the main thread. Attempting to run coroutines on a worker thread will cause unexpected behavior. Support for running on worker threads is planned for a future release.
The Routinely.BenchMark project tests coroutine execution across various load profiles:
- Simple execution: 1 → 1K → 10K → 100K coroutines
- Nested coroutines: Multi-level call stacks
- Coroutines with results: Generic return values
- WhenAll/WhenAny coordination: Concurrent operations
- SwitchTo recursion: Tail call scenarios without stack growth
Some highlights from the benchmarks for single coroutine life cycle (create → yield -> complete) at different scales:
| Method | Coroutines | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated |
|---|---|---|---|---|---|---|---|---|
| Single_Async | 1 | 35.115 ns | 0.1519 ns | 0.1421 ns | - | - | - | - |
| Single_Async | 1000 | 36,676.678 ns | 41.1863 ns | 38.5256 ns | - | - | - | - |
| Single_Async | 10000 | 371,381.077 ns | 689.1385 ns | 644.6205 ns | - | - | - | - |
| Single_Async | 100000 | 3,982,885.397 ns | 3,583.8487 ns | 2,992.6760 ns | - | - | - | - |
There is a Results folder in the benchmark project with detailed results for all benchmarks. These benchmarks were run on a Ryzen 9950x3d processor. Will add more on different hardware in the future.
Routinely is fully compatible with AOT compilation.
- ✅ Core coroutine runtime (async/await)
- ✅ Zero-allocation execution
- ✅ SwitchTo for tail call recursion without stack growth
- ✅ Contract-based cancellation with cleanup
- ✅ WhenAll/WhenAny coordination
- ✅ Forget() fire-and-forget pattern
- ✅ Task/ValueTask interop
- ✅ 93% test coverage
-
Coroutine<TResult>support forSwitchTo - Roslyn analyzers for common pitfalls (
SwitchTomisuse, awaiting non-context coroutines, etc.) - Support for running on worker threads
Contributions are welcome!
# Run tests
dotnet test
# Run benchmarks
cd Routinely.BenchMark
dotnet run -c Release
# Run code coverage
cd Routinely.UnitTests
.\run-coverage.ps1MIT License - see LICENSE file for details.
Questions? Open an issue or start a discussion!