Reputation: 3399
Is it possible to use async and await to tastefully and safely implement performant coroutines, which run only on one thread, don't waste cycles (this is game code) and can throw exceptions back to the caller of the coroutine (which might be a coroutine itself)?
Background
I'm experimenting with replacing (pet game project) Lua coroutine AI code (hosted in C# via LuaInterface) with C# coroutine AI code.
• I want to run each AI (monster, say) as its own coroutine (or nested set of coroutines), such that the main game thread can each frame (60 times per second) can choose to "single step" some or all of the AIs depending on other workload.
• But for legibility and ease of coding, I want to write the AI code such that its only thread-awareness is to "yield" its time slice after doing any significant work; and I want to be able to "yield" mid method and resume next frame with all locals etc. intact (as you'd expect with await.)
• I don't want to use IEnumerable<> and yield return, partly due to the ugliness, partly due to superstition over reported problems, and most especially as async and await look like a better logical fit.
Logically speaking, pseudocode for the main game:
void MainGameInit()
{
foreach (monster in Level)
Coroutines.Add(() => ASingleMonstersAI(monster));
}
void MainGameEachFrame()
{
RunVitalUpdatesEachFrame();
while (TimeToSpare())
Coroutines.StepNext() // round robin is fine
Draw();
}
and for the AI:
void ASingleMonstersAI(Monster monster)
{
while (true)
{
DoSomeWork(monster);
<yield to next frame>
DoSomeMoreWork(monster);
<yield to next frame>
...
}
}
void DoSomeWork(Monster monster)
{
while (SomeCondition())
{
DoSomethingQuick();
DoSomethingSlow();
<yield to next frame>
}
DoSomethingElse();
}
...
The Approach
With VS 2012 Express for Windows Desktop (.NET 4.5), I'm attempting to use the sample code verbatim from Jon Skeet's excellent Eduasync part 13: first look at coroutines with async which was quite an eye opener.
That source available via this link. Not using the provided AsyncVoidMethodBuilder.cs since it conflicts with the release version in mscorlib (which may be part of the problem). I had to mark the provided Coordinator class as implementing System.Runtime.CompilerServices.INotifyCompletion since that's required by the release version of .NET 4.5.
Despite this, creating a console application to run the sample code works nicely and is exactly what I want: cooperative multithreading on a single thread with await as "yield", without the ugliness of IEnumerable<> based coroutines.
Now I edit the sample FirstCoroutine function as follows:
private static async void FirstCoroutine(Coordinator coordinator)
{
await coordinator;
throw new InvalidOperationException("First coroutine failed.");
}
And edit Main() as follows:
private static void Main(string[] args)
{
var coordinator = new Coordinator {
FirstCoroutine,
SecondCoroutine,
ThirdCoroutine
};
try
{
coordinator.Start();
}
catch (Exception ex)
{
Console.WriteLine("*** Exception caught: {0}", ex);
}
}
I naively hoped the exception would be caught. Instead it isn't - in this "single threaded" coroutine implementation, it's thrown on a thread pool thread and hence uncaught.
Attempted Fixes to This Approach
Through reading around I understand part of the problem. I gather console applications lack a SynchronizationContext. I also gather that in some sense async voids are not intended to propagate results, though I'm not sure what to do about it here nor how adding Tasks would help in a single-threaded implementation.
I can see from the compiler generated state machine code for FirstCoroutine that via its MoveNext() implementation, any exceptions are passed to AsyncVoidMethodBuilder.SetException(), which spots the lack of synchronization context and calls ThrowAsync() which ends up on the thread pool thread just as I'm seeing.
However my attempts to naively graft a SynchronisationContext onto the app have been less than successful. I tried adding this one, calling SetSynchronizationContext() at the start of Main(), and wrapping the whole Coordinator creation and call in AsyncPump().Run(), and I can Debugger.Break() (but not breakpoint) in that class' Post() method and see that the exception makes it here. But that single-threaded sync context simply executes in series; it can't do the work of propagating the exception back to the caller. So the exception rocks up after the whole Coordinator sequence (and its catch block) are done and dusted.
I tried the even more niave approach of deriving my own SynchronizationContext whose Post() method simply executes the given Action immediately; this looked promising (if evil and no doubt with horrible ramifications for any complex code called with that context active?) but this runs afoul of the generated state machine code: AsyncMethodBuilderCore.ThrowAsync's generic catch handler catches this attempt and rethrows onto the thread pool!
Partial "Solution", Probably Unwise?
Continuing to mull, I have a partial "solution", but I'm not sure what the ramifications are as I'm rather fishing in the dark.
I can customise Jon Skeet's Coordinator to instantiate its own SynchronizationContext-derived class which has a reference to the Coordinator itself. When said context is asked to Send() or Post() a callback (such as by AsyncMethodBuilderCore.ThrowAsync()), it instead asks the Coordinator to add this to a special queue of Actions.
The Coordinator sets this as the current context before executing any Action (coroutine or async continuation), and restores the previous context afterwards.
After executing any Action in the Coordinator's usual queue, I can insist it executes every action in the special queue. This means that AsyncMethodBuilderCore.ThrowAsync() causes an exception to be thrown immediately after the relevant continuation exits prematurely. (There's still some fishing around to do to extract the original exception from the one thrown by AsyncMethodBuilderCore.)
However since the custom SynchronizationContext's other methods are not overridden, and since I ultimately lack decent clue about what I'm doing, I'd have thought this is going to have some (unpleasant) side effects for any complex (esp. async or Task oriented, or genuinely multi-threaded?) code called by the coroutines as a matter of course?
Upvotes: 4
Views: 1046
Reputation: 8243
Interesting Puzzle.
The problem, as you noted, is that by default any exception that is caught when using a void async method is caught using AsyncVoidMethodBuilder.SetException
, which then uses AsyncMethodBuilderCore.ThrowAsync();
. Troublesome, because once it's there, the exception will be thrown on another thread (from the thread pool). There doesn't seem to be anyway to override this behavior.
However, AsyncVoidMethodBuilder
is the async method builder for void
methods. What about a Task
async method? That's handled via the AsyncTaskMethodBuilder
. On difference with this builder is that instead of propagating it to the current synchronization context, it calls Task.SetException
to notify the user of the task that an exception was thrown.
Knowing that a Task
returning async method stores exception information in the task returned, we can then convert our coroutines into task-returning-method and use the task returned from the initial invocation of each coroutine to check for exceptions later on. (note that no changes to the routines are needed as void/Task returning async methods are identical).
This requires a couple changes to the Coordinator
class. First, we add two new fields:
private List<Func<Coordinator, Task>> initialCoroutines = new List<Func<Coordinator, Task>>();
private List<Task> coroutineTasks = new List<Task>();
initialCoroutines
stores the coroutines added to the coordinator initially, while coroutineTasks
stores the tasks that result from the initial invocation of initialCoroutines
.
Then our Start() routine is adapted to run the new routines, store the result, and then check the result of the tasks between every new action:
foreach (var taskFunc in initialCoroutines)
{
coroutineTasks.Add(taskFunc(this));
}
while (actions.Count > 0)
{
Task failed = coroutineTasks.FirstOrDefault(t => t.IsFaulted);
if (failed != null)
{
throw failed.Exception;
}
actions.Dequeue().Invoke();
}
And with that, exceptions are propagated to the original caller.
Upvotes: 2