HalfMaid.Async
0.9.1
See the version list below for details.
dotnet add package HalfMaid.Async --version 0.9.1
NuGet\Install-Package HalfMaid.Async -Version 0.9.1
<PackageReference Include="HalfMaid.Async" Version="0.9.1" />
paket add HalfMaid.Async --version 0.9.1
#r "nuget: HalfMaid.Async, 0.9.1"
// Install HalfMaid.Async as a Cake Addin #addin nuget:?package=HalfMaid.Async&version=0.9.1 // Install HalfMaid.Async as a Cake Tool #tool nuget:?package=HalfMaid.Async&version=0.9.1
HalfMaid.Async
Copyright © 2023 by Sean Werkema
Licensed under the MIT open-source license
Contents
Overview
This repository contains the HalfMaidGames Async library, which is designed to solve a common problem in video-game programming in C#: The difficulty of building video-game state machines.
Instead of using switch
-statements or hacking IEnumerable
generators for your state machines, you can use nice, clean async
/await
-based programming for each actor in your game, and it scales very well to complex use cases.
Importantly, even though this uses async
/await
, all of your tasks will always run on a single thread: This library carefully uses async
/await
to time-slice a single thread, just like you would write using switch
statements or IEnumerable
generators, but with much simpler code. This does not use the thread pool, and it does not trigger work in other threads: You are in total control of what gets run and where it gets run, the behavior you need for the update cycle in a game loop.
Installation
You can install the latest HalfMaidGames.Async library as a Nuget package.
The package is multi-targeted for .NET Core 2.1, .NET Core 3.1, .NET 5.0, and .NET 6.0+ to provide maximum backward compatibility. .NET Framework 4.x and earlier and .NET Core 1.x are not supported.
Example & Rationale
The Problem
Consider an enemy character that moves back and forth every ten seconds. In a state-machine-based model, you might write code that's something like this:
public class BackAndForthEnemy
{
public float X;
public float Y;
public int State;
public int Counter;
public void Update()
{
switch (State) {
case 0:
// Initial state.
State = 1;
Counter = 600;
break;
case 1:
// Move right for 10 seconds.
X += 0.1f;
if (--Counter == 0)
{
State = 2;
Counter = 600;
}
break;
case 2:
// Move left for 10 seconds.
X -= 0.1f;
if (--Counter == 0)
{
State = 1;
Counter = 600;
}
break;
}
}
}
Every 1/60th of a second, we perform a little bit of the action — but it's completely tangled. The logic and control flow is inside-out, because you need to return to the main game loop after every update.
The Ideal
What you really want to be able to write is simple, procedural code like this:
public class BackAndForthEnemy
{
public float X;
public float Y;
public void Main()
{
// Loop forever.
while (true)
{
// Move right for 10 seconds.
Move(+0.1f, 10);
// Move left for 10 seconds.
Move(+0.1f, 10);
}
}
private void Move(float amountPerFrame, float seconds)
{
for (float i = 0; i < seconds * 60; i++)
{
X += amountPerFrame;
wait_for_next_frame;
}
}
}
The Solution
With the HalfMaidGames Async library, you can use C#'s async
/await
to write code that looks almost exactly like the procedural example above:
public class BackAndForthEnemy : AsyncGameObjectBase
{
public float X;
public float Y;
public async GameTask Main()
{
// Loop forever.
while (true)
{
// Move right for 10 seconds.
await Move(+0.1f, 10);
// Move left for 10 seconds.
await Move(+0.1f, 10);
}
}
private async GameTask Move(float amountPerFrame, float seconds)
{
for (float i = 0; i < seconds * 60; i++)
{
X += amountPerFrame;
await Next();
}
}
}
The Async library "magically" interrupts the methods at each call to Next()
, and it resumes at that exact point in the next frame, so that the code for your actors can be written as though each one is just logical, procedural code.
Running it
How do you use your new async
-based enemy? The Async library contains a GameTaskRunner
that is responsible for running these "game tasks." Each frame, the runner makes each "game task" run forward until the task calls Next()
or exits.
Using the runner is extremely simple:
public void ExampleProgram()
{
// Create a new runner, and use it as the default runner for everything
// that inherits from AsyncGameObjectBase.
GameTaskRunner runner = new GameTaskRunner();
AsyncGameObjectBase.Runner = runner;
// Create our enemy and start running it.
BackAndForthEnemy enemy = new BackAndForthEnemy();
runner.StartImmediately(enemy.Main);
while (true)
{
// Run whatever GameTasks are in progress for one frame.
runner.RunNextFrame();
// Render the next frame (pseudocode: however you display frames).
graphics.Clear();
enemy.Render(graphics);
graphics.SwapBuffers();
}
}
In short, the only methods you really need to know on the runner are StartImmediately()
, which starts running an async
method, and RunNextFrame()
, which runs anything that needs to run for the next frame.
Usage
GameTasks
Any method that returns a GameTask
can be run by the runner. However, typically you will want to use Task
-like patterns:
- Declare your
GameTask
methodsasync
. - Use
await
inside them when invoking otherGameTask
methods. - You can
await Next()
orawait Delay()
as deeply in the call chain as you want, as long as all callersawait
your method as well. - If your method needs to return a value, return
GameTask<T>
.
Detailed Samples
Declaring methods that either use Next()
or Delay()
, or that call methods that use Next()
or Delay()
:
public async GameTask MyMethod()
{
...
await runner.Next();
...
}
Declaring GameTask
methods that return data:
int amount = await MyMethod();
...
public async GameTask<int> MyMethod()
{
...
await runner.Next();
...
return 5;
}
Waiting until the next frame to perform actions slowly:
...do something...
await runner.Next();
...do something...
await runner.Next();
...do something...
Waiting for many frames to perform actions even slower:
...do something...
await runner.Delay(10);
...do something...
(The parameter to the Delay()
method is the number of frames to wait, not milliseconds or seconds. And the duration of a frame depends solely on how many frames you choose to execute per second — on how often you call runner.RunNextFrame()
.)
Complete Example
Here's a simple example showing all of these pieces together to build an enemy that "thinks" for a few seconds and then moves in a random direction for a few seconds. If this were built as a traditional state machine, the code would be much more complex, and much harder to read and to modify, but as async
-style code, it's simple and straightforward:
public class RandomEnemy
{
private const int FramesPerSecond = 60;
private Random _random = new Random();
public Vector2 Position;
public bool IsDead;
public async GameTask Main()
{
while (!IsDead)
{
Direction d = await ChooseRandomDirection();
await Move(d, 3.0f /*seconds*/);
}
}
private async GameTask<Direction> ChooseRandomDirection()
{
await Delay(FramesPerSecond * 3 /*seconds*/);
return (Direction)(_random.Next() % 4);
}
private async Move(Direction d, float time)
{
Vector2 movement = d.ToUnitVector() * 0.1;
for (float i = 0; i < time * FramesPerSecond; i++)
{
Position += movement;
await Next();
}
}
}
Starting GameTasks
"Registering" your new actor to run inside a GameTaskRunner
involves little more than starting the outermost method of your code:
GameTaskRunner runner = new GameTaskRunner();
RandomEnemy enemy = new RandomEnemy();
runner.StartImmediately(enemy.Main);
You can "register" on-the-fly lambda code as well: Any method that is declared async GameTask
can be managed by a runner:
GameTaskRunner runner = new GameTaskRunner();
RandomEnemy enemy = new RandomEnemy();
runner.StartImmediately(async () => {
...do something...
await runner.Delay(10);
...do something more...
});
Main Loop
You may be wondering what you need to change in your main loop to support this, or you may be concerned about your ability to integrate this in your existing engine. Don't worry! This is designed to be very easy to integrate:
- You must create at least one
GameTaskRunner
instance. - You must call
runner.StartImmediately()
orrunner.StartYielded()
to start anyasync GameTask
methods. - You must call
runner.RunNextFrame()
at least once per frame. - You should call
runner.RunUntilAllTasksFinish()
before your game exits, if there's any chance any tasks are still running and you want them to finish.
These requirements should be compatible with most game engines and game frameworks, even those you write yourself.
Importantly, unlike Task.Run()
, all GameTask
s managed by the GameTaskRunner
are run on the calling thread. When you call runner.RunNextFrame()
, each GameTask
runs synchronously until it invokes await Next()
or await Delay()
, and then the next GameTask
runs synchronously after it, until all have had a chance to execute. async
/await
here does not mean running in another thread, but rather cooperative multitasking in a single thread: the same threading behavior that you would produce using simpler switch
-based state machines.
You can create more than one GameTaskRunner
instance, if you want to support localized tasks in a part of your code base. The runners are independent of each other, and unlike many task-async libraries, there are no static fields or properties on any of the GameTask
-related classes.
But in other words, your integration can be this simple:
while (true)
{
runner.RunNextFrame();
...update...
...render...
...wait for next frame...
}
Or if you're using a framework that exposes an OnUpdate
-like event handler that executes each frame, you can simply put RunNextFrame()
inside OnUpdate
:
public GameTaskRunner Runner { get; }
public MyClass()
{
Runner = new GameTaskRunner();
}
protected override void OnUpdate()
{
base.OnUpdate();
Runner.RunNextFrame();
...do any other updates you need here..
}
Some form of these patterns should fit nearly every game written in C#.
AsyncGameObjectBase
You are not required to use AsyncGameObjectBase
. It's a simple, small optional base class that makes accessing the runner's methods easy by anything that inherits it:
public abstract class AsyncGameObjectBase
{
public static GameTaskRunner Runner { get; set; }
public GameTaskYieldAwaitable Next() => Runner.Next();
public GameTaskYieldAwaitable Delay(int frames) => Runner.Delay(frames);
public ExternalTaskAwaitable RunTask(Func<Task> task) => Runner.RunTask(task);
}
External Tasks
Sometimes you may want to use a "normal" Task
object within the scope of a GameTask
. For example, you may need to perform slow file I/O, or network I/O, and you would still like your game loop to run. The GameTaskRunner
provides a special method, RunTask()
, that allows Task
objects to be integrated within a GameTask
's execution.
Recall above that GameTask
s are run synchronously until each reaches await Next()
or await Delay()
: This behavior is very different from the normal usage of await
. Therefore, RunTask()
is needed to "connect" Task
objects, which have an inherent notion of threading and asynchronicity, to GameTask
s, which are state machines in disguise.
It is not hard to embed a Task
inside a GameTask
: Simply await RunTask(task)
inside your GameTask
method, as in the example below, similarly to how you might call Task.Run(task)
:
public async GameTask DoSomething()
{
...
await Next();
...
await RunTask(async () => {
...
await Task.Delay(1000); // Do Task-based slow operations
...
});
...
await Next();
...
}
Inside the body of the task passed to RunTask()
, you may use any normal Task
object. When that Task
completes, the outer GameTask
will then continue on the next available frame of execution. Each real Task
will be executed on its own thread using the standard .NET thread pool. While a Task
is executing, the GameTask
around it will be put to sleep and will not block the runner.
Bulk Cancellation
The GameTaskRunner
includes special logic for cancelling all active GameTasks at the same time. For example, you may need to do this when your game switches states (start screen --> main gameplay) and needs to use a completely different set of GameTasks in the new state. Or you may need it when your game exits or when it saves to disk, to be able to stop all GameTasks at once.
There are is a special API on GameTaskRunner
for these needs:
CancelAllTasks(createException, handleUncaughtExceptions)
- Raise an exception inside every active GameTask. Both parameters are optional.
By default, a TaskCanceledException
will be raised. You can instead pass a Func<Exception>
to CancelAllTasks()
as its first parameter; this method must construct an instance of an exception to be raised. It will be invoked once per GameTask.
Cancellation exceptions are normally discarded by CancelAllTasks()
if they rise fully outside the task. You can provide alternative handling by passing an Action<Action>
to CancelAllTasks()
as its second parameter. Your handleUncaughtExceptions
function should invoke the action given to it, wrapping it in appropriate exception handling. For example:
runner.CancelAllTasks(() => new FooException(), MyExceptionHandler);
...
private void MyExceptionHandler(Action action)
{
try
{
// Continue running the task. A FooException() will be
// raised wherever it last paused.
action();
}
catch (FooException)
{
// Do something special here.
}
}
If external Task
s are active that were started by runner.RunTask()
, CancelAllTasks()
will wait for them to complete before cancelling the GameTask
s that invoked them: It cannot automatically cancel external Task
s. If you intend to cancel a GameTask
that calls runner.RunTask()
, you should provide a means to cancel that external task yourself, such as by triggering a CancellationToken
before calling runner.CancelAllTasks()
.
Note that while there is support for CancelAllTasks()
, there is presently no way to cancel a single task: It's all-or-nothing.
Empty Methods and Fast Results
Just as with Task
, it can be beneficial sometimes to create a "finished" GameTask
. For example, you may have a base class that declares this:
public abstract GameTask<int> Calculate();
You need to implement that method in your child class, since it's required, but what do you do if you already have the result? You could use async
:
// Compiler complains that you never use await!
public override async GameTask<int> Calculate()
{
return 0;
}
But the compiler is right to complain here: You have the cost of setting up the async
state machine, and then you never use it. Both GameTask
classes include methods that you can use to avoid this overhead, that are suitable for tasks that have already finished:
public override GameTask<int> Calculate()
{
return GameTask<int>.FromResult(0);
}
The syntax matches that of Task<int>.FromResult()
by design, and it fits similar use cases.
There is also a GameTask.Completed()
which returns, as its name implies, a completed GameTask
:
public override GameTask MainAsync()
{
return GameTask.Completed();
}
Prefer these patterns in situations where the method must return GameTask
or GameTask<T>
but where you don't need to await
for a future frame or for an action to complete.
APIs
There are relatively few public APIs, as the library mostly relies on standard async
/await
mechanics to function. But here are the ones that are exposed:
GameTask
This is a class
that represents an active task for a function that otherwise would return void
. It may be executed via normal await
/async
.
This is combined with its own builder type to keep heap overhead as low as possible. It consists of about 5 or 6 pointers' worth of data.
GameTask
may be safely copied and moved around, since it is only a reference to a class and some additional methods.
Do not attempt to instantiate a GameTask()
yourself: GameTask.Create()
should only be called by the C# compiler.
Property
GameTaskStatus Status
: The current status of this task, eitherInProgress
,Success
(completed without an exception), orFailed
(threw an exception).Property
bool IsCompleted
: True if this task has ended (via normal completion or an exception), false if it is stillInProgress
. Required by the C# compiler.Property
GameTask Task
: A reference to this same class. Required by the C# compiler.Property
Exception Exception
: If an exception was thrown by this task, this is the exception. May benull
.Property
ExceptionDispatchInfo ExceptionDispatchInfo
: If an exception was thrown by this task, this is its captured dispatch information, which allows it to be re-thrown with a correct stack trace. May benull
.Static method
Create()
: Creates a newGameTask()
. Do not call this; it will be called automatically by the C# compiler's generated code as necessary.Static method
Completed()
: Creates aGameTask
that has already completed. Useful for returning immediately from a method that must return aGameTask
but where you don't want to declare itasync
because there's no work to do, or the work is fast and synchronous.Method
Start<TStateMachine>(ref TStateMachine)
: Start the given state machine. Required by the C# compiler. Do not call this directly.Method
SetStateMachine(IAsyncStateMachine)
: Switch state machines. Required by the C# compiler, and deprecated. Do not call this directly.Method
SetException(Exception)
: Notify this task that an exception has been raised. Required by the C# compiler. Do not call this directly, or you will break the task.Method
SetResult()
: Notify this task that it has completed successfully. Required by the C# compiler. Do not call this directly, or you will break the task.Method
AwaitOnCompleted<TWaiter, TStateMachine>(ref TWaiter, ref TStateMachine)
: Tell the given task how to continue after a wait completes, which is to invoke the next phase of the given state machine. Do not call this directly, or you will break the task.Method
AwaitUnsafeOnCompleted<TWaiter, TStateMachine>(ref TWaiter, ref TStateMachine)
: Tell the given task how to continue after a wait completes, which is to invoke the next phase of the given state machine. This version can avoid switching environments, but is the currently same asAwaitOnCompleted()
. Do not call this directly, or you will break the task.Method
GetAwaiter()
: Returns aGameTaskAwaiter
that can be used byawait
to trigger any continued computation in this task. You generally do not need to call this.
This class has many public methods that are required to implement the AsyncMethodBuilder
pattern. Even though they are declared public
, they should only be invoked by the C# compiler itself. As a general rule, don't touch any part of a GameTask
other than its public properties.
This class is not thread-safe.
GameTask<T>
This is a similar class
to GameTask
, and most of the above description applies. This inherits from GameTask
. It also has the following notable changes:
Property
T Result
: The result (return value) of this task after it has successfully completed. Will bedefault(T)
until the task successfully completes.Static method
FromResult(T)
: Creates aGameTask<T>
that has already completed with the given value. Useful for returning immediately from a method that must return aGameTask<T>
but where you don't want to declare itasync
because there's no work to do, or the work is fast and synchronous.Method
SetResult(T)
: Notify this task that it has completed successfully and returned aT
. Required by the C# compiler. Do not call this directly, or you will break the task.Method
GetAwaiter()
: Returns aGameTaskAwaiter<T>
that can be used byawait
to trigger any continued computation in this task. You generally do not need to call this.
This class is not thread-safe.
GameTaskRunner
This manages the active state of a group of tasks, and can run those tasks forward to a specific point in time, either one frame, several frames, or all frames.
This class is not thread-safe except where noted below.
Constructor
GameTaskRunner()
- Construct a new runner. No parameters are required.Property
TaskCount
- This returns a count of how manyInProgress
GameTask
s are being tracked by the runner. When this count reaches zero, allGameTask
s have either completed successfully or thrown exceptions, and none have any remaining work. This property is thread-safe, and may be queried by any thread at any time. (Note, however, that it is point-in-time information, so it may change immediately after you read it!)Method
EnqueueFuture(Action action, int frames)
- Enqueue an action to occur at some point in the future (the current time plus the given number of frames), duringRunNextFrame()
. This call is thread-safe, and is a way for an external thread to push work onto the runner's thread.Method
Next()
- This returns an awaitable that resolves during the next frame of execution. It should always be called asawait Next()
. It is conceptually similar toawait Task.Yield()
.Method
Delay(int frames)
- This returns an awaitable that resolves in a future frame of execution. It should always be called asawait Delay(frames)
. It is conceptually similar toawait Task.Delay(msec)
.Method
StartImmediately(Func<GameTask> action)
- This causes the given action to be started (run/called/invoked) immediately; if it encounters anawait
during its execution that would cause it to block, its continuation will be registered with the runner, and then this call will return. This is conceptually similar toTask.Run(action)
.Method
StartYielded(Func<GameTask> action)
- This causes the given action to be started (run/called/invoked) during the next frame, and returns immediately. This is conceptually similar to a pattern likeTask.Run(async () => { await Task.Yield(); await action(); })
. This method is thread-safe, and is a way for an external thread to push work onto the runner's thread.Method
RunUntilAllTasksFinish()
- This executes all remaining registered tasks in a tight loop until allGameTask
s and externalTask
s have either finished successfully or thrown exceptions, and then it returns. This should be used at the end of your program (or of theGameTaskRunner
's lifetime) to ensure that anyfinally
orusing
statements within any active tasks are eventually properly completed.Method
RunNextFrame()
- Run exactly one subsequent frame's worth of execution for any registered tasks. As soon as all tasks have either completed or have invokedNext()
orDelay()
to wait for a subsequent frame, this method returns.Method
RunTask(Func<Task> task)
- Allow a traditional I/O task to be executed and managed by the task runner. TheTask
will be executed by the thread pool, but will be resumed on the original thread.Method
CancelAllTasks<TException>(Func<TException> createException, Action<Action>? handleUncaughtExceptions)
- Cancel all active GameTasks by raising exceptions inside them. You can provide an optional custom function to create the exceptions. You can provide an optional custom handler for any uncaught exceptions. If a creation function is not provided, this will createTaskCanceledException
s on its own.
AsyncGameObjectBase
This is a convenience class. You do not need to inherit from it, but doing so can simplify calling methods like GameTaskRunner.Next()
in your own code.
Static property
GameTaskRunner Runner
- The runner that will be used by this object. This is initialized by default tonew GameTaskRunner()
.Method
Next()
- A simple proxy toRunner.Next()
, this allows child classes to simply writeawait Next()
.Method
Delay(int frames)
- A simple proxy toRunner.Delay(frames)
, this allows child classes to simply writeawait Delay(frames)
.Method
RunTask(Func<Task> task)
- A simple proxy toRunner.RunTask(task)
, this allows child classes to simply writeawait RunTask(...)
.
This class is nothing but proxies to GameTaskRunner
, so it has the same thread-safety rules that the runner has.
GameTaskYieldAwaitable
This is the struct
type returned by runner.Next()
and runner.Delay()
. It is an "awaitable" type, which is a pattern-based — not inheritance-based — concept. It is readonly
, and may be safely copied by value. You generally do not need to interact with this directly, and it is only included here for completeness.
Field
GameTaskRunner Runner
- The runner that will continue this awaitable in a subsequent frame.Field
int FrameCount
- The number of frames that should elapse before this awaitable should continue,1
for a call toNext()
, and identical to the value passed intoDelay(frames)
.Property
bool IsCompleted
- Whether this awaitable has been continued. Required by the C# compiler. Always returns false.Method
GetAwaiter()
- Returns this object. Required by the C# compiler.Method
OnCompleted(continuation)
- Registers work to be performed when this awaitable completes. Required by the C# compiler.Method
GetResult()
- Called when the awaitable completes. Required by the C# compiler. May raise an exception if the awaitable failed to complete or was cancelled.
ExternalTaskAwaitable
This is the class
type returned by runner.RunTask()
. It is an "awaitable" type, which is a pattern-based — not inheritance-based — concept. It is immutable. You generally do not need to interact with this directly, and it is only included here for completeness.
Field
GameTaskRunner Runner
- The runner that will continue this awaitable in a subsequent frame.Property
bool IsCompleted
- Whether this awaitable has been continued. Required by the C# compiler. Always returns false.Method
GetAwaiter()
- Returns this object. Required by the C# compiler.Method
OnCompleted(continuation)
- Registers work to be performed when this awaitable completes. Required by the C# compiler.Method
GetResult()
- Called when the awaitable completes. Required by the C# compiler.
GameTaskAwaiter
This struct
is returned by GameTask.GetAwaiter()
and is used to wait for the completion of the GameTask
. It is readonly
. It contains very little:
- Field
GameTask Task
: A reference to the task to wait for. - Property
bool IsCompleted
: Whether theGameTask
has completed or not. Required by the C# compiler. - Constructor
GameTaskAwaiter(GameTask)
: Construct a new awaiter for the given task. - Method
GetResult()
: Called automatically by the C# compiler's generated code to notify the awaiter that theawait
has completed. Required by the C# compiler. Do not call this directly. - Method
OnCompleted(Action)
: Called by the C# compiler's generated code to register a continuation to execute after the task completes. Required by the C# compiler. Do not call this directly. - Method
UnsafeOnCompleted(Action)
: Called by the C# compiler's generated code to register a continuation to execute after the task completes, in situations where the execution and synchronization contexts do not need to change. Required by the C# compiler. Do not call this directly.
In short, you shouldn't invoke this directly, and you will probably never notice it exists.
GameTaskAwaiter<T>
This is nearly identical to the struct
above, but designed for a GameTask<T>
instead. It does not inherit from GameTaskAwaiter
because struct
types cannot inherit.
As with GameTaskAwaiter
, you shouldn't invoke this directly, and you will probably never notice it exists.
FAQ
What's the difference between
Task
andGameTask
?The standard
Task
object is designed around the thread pool: It's intended to be a lot like threading, but easier, and with better performance.Task
generally uses the C#ThreadPool
for scheduling, and will use it for any situation when it needs to execute something and doesn't know where else to put it.GameTask
is similar in some ways, but is designed not just to fit the resource constraints of a video game, but to embody a very different concept, that of time-slicing a single thread. In a video game, you need to be certain of what will and won't execute during the current frame. You need to know that X object will run a certain chunk of code in the current thread and then stop and then wait to be told to continue in the next frame.This library is designed to make that kind of predictable time-slicing easy, but still using object-oriented programming and functional-programming, and not switching to alternative programming models like ECS.
Can I call child methods within an
async GameTask
method?Sure! That's part of the point, and part of why using
async
/await
is better than usingswitch
-based state machines: You can call deeper and deeper, and organize your code using normal software-engineering principles.So just like with
Task
-based async, the child methods must be declaredasync GameTask
too if they need to invokerunner.Next()
orrunner.Delay()
orrunner.RunTask()
, and you'll also need toawait
them.Of course, if they don't need to wait for a frame or a result, you can just call them directly.
What about memory overhead? How expensive is a
GameTask
?A
GameTask
is not substantially more expensive than anIEnumerable
/yield
pattern. Eachasync
method has two objects on the heap to represent it: The first is a state machine (IAsyncStateMachine
) which stores both its code state (i.e., an integer representing which code to run next) and its data state (its local variables). The second object is aGameTask
, which provides sufficient information to pause and resume the .NET runtime from a pausedawait
in the state machine. AGameTask
contains about 5 to 6 pointers' worth of data (~24 bytes on a 32-bit CPU, ~48 bytes on a 64-bit CPU).If you were to hand-implement the state machine using a
switch
statement, you would likely have an equivalent of the first object to represent both the code and data state, and no equivalent of the second object.Either way, a
GameTask
is not hugely expensive: It is a single extra object, measured in bytes, not kilobytes. It is allocated the first time a method is entered, and garbage-collected when the method completes, and exists for the full lifetime in between. No matter how manyawait
invocations the method contains, the sameGameTask
is used for the full dynamic extent of the method.What about CPU overhead? How slow is
async
/await
?The
async
/await
mechanics will likely be slower than a hand-implementedswitch
statement, which will likely be slower than a bulk-update system like ECS. Pausing and resuming a method is not free.However, that overhead is still measured in nanoseconds: You can have tens if not hundreds of thousands of
GameTask
s updating in a single frame and still meet 60 FPS.Moreover, because
RunNextFrame()
uses an internal priority-queue-based scheduler, anyGameTask
that is waiting onrunner.Delay()
orrunner.RunTask()
will have zero cost until thatGameTask
finally resumes. You can have hundreds of thousands of "sleeping" objects, and if only one wakes up per frame, you pay little more CPU than the cost of its execution.Do exceptions work inside an
async GameTask
?Exceptions are fully supported: If exceptions get raised, you
try
/catch
/finally
them just like you would anywhere else in your code, and an outertry
/catch
/finally
can catch exceptions from deep inside anasync
/await
call stack.The stack trace of the exception will show the full logical call stack to get to where it was thrown: Even if it was thrown many frames after the outermost
async
method was invoked, the outermostasync
method will still appear in the stack trace.Does
using
work inside anasync GameTask
?Just like exceptions, this works like you think it should. A
using
with anawait
in the middle will invokeDispose()
when the method finally completes, even if that's many frames in the future.Do I need to inherit my objects from
AsyncGameObjectBase
?No! You can have your own inheritance hierarchies. I include this for convenience, not because it's required. The entire source code (minus comments) for
AsyncGameObjectBaase
is presented below to show you how simple it is and how easy it is to choose to use it or not use it:public abstract class AsyncGameObjectBase { public static GameTaskRunner Runner { get; set; } = new GameTaskRunner(); [MethodImpl(MethodImplOptions.AggressiveInlining)] protected AsyncGameObjectBase() { } [MethodImpl(MethodImplOptions.AggressiveInlining)] public GameTaskYieldAwaitable Next() => Runner.Next(); [MethodImpl(MethodImplOptions.AggressiveInlining)] public GameTaskYieldAwaitable Delay(int frames) => Runner.Delay(frames); public ExternalTaskAwaitable RunTask(Func<Task> task) => Runner.RunTask(task); }
As you can see, there's very little to it, and it does nothing more than forward calls to the
GameTaskRunner
class. You can copy-and-paste the above methods into your own base class if you have a custom inheritance hierarchy but still want the convenience of being able to simply writeawait Next();
in your code.Can I have more than one
GameTaskRunner
? Is anythingstatic
?You can have as many
GameTaskRunner
instances as you want; each will run theasync GameTask
methods started inside it. Nothing is declaredstatic
in the entire library except for astatic
runner instance in theAsyncGameObjectBase
class, which is only included to make simple use cases easy: You are not required to use it.It may even be useful in some cases to have multiple
GameTaskRunner
instances: For example, one to manage enemies in the main gameplay, and another to manage actions in, say, a popup menu only while it's open.It's up to you to decide how many or how few runners you need.
What about thread safety?
GameTask
andGameTaskRunner
are not thread-safe. Some parts ofGameTaskRunner
are, but not all of it is. You should only ever use aGameTask
orGameTaskRunner
in the thread that created it, with three notable exceptions to this rule:runner.StartYielded(...gameTask...)
can be safely called by other threads to start aGameTask
on the runner's thread.runner.TaskCount
can safely be queried from any thread.await runner.RunTask(...task...)
will kick off the givenTask
on the thread pool: ThatTask
will run in parallel to the thread that started it, possibly on another CPU core. However, when theawait
completes, it will resume back on the original calling thread duringRunNextFrame()
.
Do not assume any other methods on
GameTaskRunner
are thread-safe.How do I safely clean up after
async GameTask
s?You need to make sure that you clean up your tasks when you're done with running them, or
try
/catch
/finally
andusing
andrunner.RunTask()
may not work correctly inside them."Done" in this context means that you're not going to use this
GameTaskRunner
anymore, either because you've made a major transition in your code (i.e., main menu --> gameplay) where the previous tasks don't matter anymore, or because you're exiting the game. It's up to you to decide when "done" happens.When you're done with a runner and all of the
GameTask
s inside it, callrunner.RunUntilAllTasksFinish()
. This will ensure that every task has fully completed before it returns, and it will block until no more tasks remain.This is also why
runner.CancelAllTasks()
exists: It lets you throw an exception inside each task, which can be used to shut them down more cleanly than simply dropping them on the floor. Make sure tocatch
in yourGameTask
code whatever exception you raise, though, if you want your task to know it's being killed!Typically, you'll want to use a pattern like this to shut everything down cleanly:
public void ExitMyGame() { runner.CancelAllTasks(); runner.RunUntilAllTasksFinish(); }
The first call will attempt to exit every task reasonably cleanly, and the second call won't continue until everything definitely has exited.
The
GameTaskRunner
does not implementIDisposable
; if you wantDispose()
-like behavior, you can implement it yourself by calling those two methods as shown above.If you don't care that
try
/catch
/finally
andusing
andrunner.RunTask()
may not finish inside yourGameTask
s, you can always skip theRunUntilAllTasksFinish()
step, and just let GC collect both the runner and theGameTask
s when it wants to, but that often requires care not to use those language features.What about debugging?
When debugging C# code that uses
async GameTask
, there are a few important points to be aware of:Stepping over an
await
may produce weird results, because you may not reach the other side of it until many frames later. It is better to set a breakpoint below it than to try to step over it in a debugger.The debugger call stack will typically show the real call stack and will be very shallow, only showing the currently-executing innermost state machine:
GameLoop()
-->runner.RunNextFrame()
-->DeepAsyncMethod()
. In the future, I may try for better debugger integration, but for now, don't be surprised by the debugger's call stack being unhelpful.To find out the logical call stack, you can throw an exception and immediately catch it:
private async GameTask DeepAsyncMethod() { ... await runner.Next(); ... try { throw Exception(); } catch (Exception e) { // Set a breakpoint on the line below. string logicalStackTrace = e.StackTrace; } }
In the above example, the
logicalStackTrace
will show whichasync GameTask
methods were called en route to arrive atDeepAsyncMethod()
.
Which .NET is this compatible with? Why are there versions for so many different .NET releases?
Because each .NET release supports different functionality, and I use conditional compilation to support that added functionality where possible:
.NET Core 2.x and 3.x, and .NET 5 use more-or-less the same build. Separate versions are included because each platform optimizes the code slightly differently.
.NET 6 provides a new
PriorityQueue<T,S>
class, which I use on the newer platforms where it exists. (To support older .NET Core and .NET 5, this library contains its own hacked copy of thatPriorityQueue<T,S>
class.) This build should be compatible with .NET 7 and .NET 8 as well..NET Framework 4.x is not supported and will not be supported, because even though it supports
async
/await
, it does not includeAsyncMethodBuilder
, which is required for custom task types likeGameTask
to work..NET Core 1.x is not supported and is too niche to support. Consider upgrading to a newer .NET if you're on .NET Core 1.x.
Contributors & Thanks
This library was the result of two years of me banging with rocks on the C# async
/await
model to make it do something it wasn't really meant to do, in the face of really poor documentation on how it actually works from Microsoft. I tried to do this at least a dozen times before I finally figured out the core of how to get it to work in April 2023, with critical enhancements in May 2023.
I am indebted to Oleksii Nikiforov and to Bartosz Sypytkowski and to Matthew Thomas for their hard work plumbing the depths of C# async
/await
. Old versions of .NET Reflector really helped to untangle what was going on inside the early async
/await
generated code too.
I would also like to thank Microsoft as well for releasing the .NET code under an open-source license so it could be studied. Without being able to read through Task.cs
a few hundred times, I don't think I'd have been able to pull this off.
As implemented, this seems to cover most major use cases I can think of. It has no bugs that I know of, but if you find one, please feel free to report one. (Note that the fact that Visual Studio cannot report logical GameTask stack frames is not a bug: It's a useful but missing feature.)
Please feel free to use this library for any purpose you see fit, as per the terms of the MIT Open-Source License. (It's also a good case study for how to async
/await
can be made to do cooperative multitasking, which was nearly undocumented before I wrote this!)
I hope you find this useful, and find that it makes your code nicer and simpler!
-- Sean Werkema
Product | Versions Compatible and additional computed target framework versions. |
---|---|
.NET | net5.0 is compatible. net5.0-windows was computed. net6.0 is compatible. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. |
.NET Core | netcoreapp2.1 is compatible. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 is compatible. |
-
.NETCoreApp 2.1
- No dependencies.
-
.NETCoreApp 3.1
- No dependencies.
-
net5.0
- No dependencies.
-
net6.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.