How to catch exceptions in async / await based on a single-threaded coroutine implementation

Is it possible to use async and wait to implement executable coroutines that work on only one thread with taste and security, do not waste cycles (this is game code) and can throw exceptions back to the calling side of the coroutine (which may be the coroutine itself)?

Background

I am experimenting with replacing (a home game project) Lua coroutine AI code (hosted in C # via LuaInterface ) with C # coroutine AI code.

• I want to run each AI (monster, let’s say), as my own coroutine (or a nested set of coroutines), so the main game stream can choose “one step” or all AIs depending on another working one (60 times per second) load.

• But for readability and ease of coding, I want to write an AI code so that its only understanding of flows is to “give” its time slice after doing any significant work; and I want to be able to “yield” to the middle method and resume the next frame with all the locals, etc. intact (as you would expect with an expectation.)

• I do not want to use IEnumerable <> and return revenue, partly due to ugliness, partly because of superstition about reporting problems, and especially that async and expectation look more logical.

Logically speaking, the pseudo-code 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 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(); } ... 

An approach

With VS 2012 Express for Windows Desktop (.NET 4.5), I'm trying to use the sample verbatim code from Jon Skeet perfectly Eduasync part 13: first look at coroutines with asynchronous browsing , which was pretty noticeable.

This source is available through this link . Do not use the provided AsyncVoidMethodBuilder.cs, as it conflicts with the release version in mscorlib (which may be part of the problem). I should have noted the provided coordinator class as implementing System.Runtime.CompilerServices.INotifyCompletion since it was required in the release version of .NET 4.5.

Despite this, creating a console application to run the sample code works just fine and exactly what I want: joint multithreading in one thread with the expectation of "exit" without ugliness of accompanying files based on IEnumerable <.

Now I edit the 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 that the exception would catch. Instead, this is not the case - in this "single-threaded" implementation of coroutine, it throws itself into the thread pool thread and therefore does not open.

Attempts to fix this approach

Looking through, I understand part of the problem. I see that console applications lack SynchronizationContext. I also understand that in a sense, asynchronous voids are not intended to distribute the results, although I'm not sure what to do with this here, and how adding tasks would help in a single-threaded implementation.

From the compiler code generated by the compiler for FirstCoroutine, I can see that using the MoveNext () implementation, all exceptions are passed to AsyncVoidMethodBuilder.SetException (), which indicates that there is no synchronization context and throws ThrowAsync (), which ends the stream thread stream, as I see .

However, my attempts to naively translate the SynchronisationContext into the application were less successful. I tried adding this one by calling SetSynchronizationContext () at the beginning of Main () and wrapping up the creation of the entire Coordinator and calling in AsyncPump (). Run (), and I can use Debugger.Break () (but not a breakpoint) in the Post () method of this class and see what this exception makes here. But this single-threaded synchronization context is simply executed sequentially; he cannot do the job of propagating the exception back to the caller. Thus, an exception occurs after the entire sequence of the Coordinator (and its catch block) is executed and cleared.

I tried an even more niave approach to get my own SynchronizationContext, the Post () method just does this immediately; it looked promising (if evil and, without a doubt, with dire consequences for any complex code called with this context active?), but it comes from the generated state machine code: AsyncMethodBuilderCore.ThrowAsync generic catch handler catches this attempt and jumps to the pool threads!

A partial "solution" is perhaps unreasonable?

Continuing to ponder, I have a partial “solution”, but I'm not sure what the consequences are, because I prefer fishing in the dark.

I can configure the Jon Skeet coordinator to create my own SynchronizationContext class, which has a link to the Coordinator itself. When the specified context is asked to send a () or Post () callback (for example, AsyncMethodBuilderCore.ThrowAsync ()), instead, it asks the Coordinator to add this to the special Actions queue.

The coordinator sets this as the current context before performing any action (continuing the coroutine or continuing asynchronous) and then restores the previous context.

After performing any action in the normal queue of the coordinator, I can insist that he performs each action in a special queue. This means that AsyncMethodBuilderCore.ThrowAsync () causes the exception to be thrown right after the corresponding continuation comes out prematurely. (There is still fishing there to extract the original exception from what AsyncMethodBuilderCore was selected.)

However, since the custom SynchronizationContext methods are not overridden, and since in the end I don’t see in myself enough understanding of what I'm doing, I would think that it will have some (unpleasant) side effects for any complex (especially asynchronous or task-oriented or really multi-threaded?) code called by coroutines, as a matter of course?

+4
source share
1 answer

An interesting puzzle.

Problem

The problem, as you noticed, is that by default, any exception that is caught using the aoid void method is caught using AsyncVoidMethodBuilder.SetException , which then uses AsyncMethodBuilderCore.ThrowAsync(); . It is difficult because once it is there, an exception will be thrown into another thread (from the thread pool). It seems, however, that this behavior should not be redefined.

However, AsyncVoidMethodBuilder is an async method builder for void methods. What about the asynchronous method of Task ? This is handled through AsyncTaskMethodBuilder . The difference with this builder is that instead of distributing it in the current synchronization context, it raises a Task.SetException to notify the user of the task that an exception has been thrown.

Decision

Knowing that the async method returned by Task stores the exception information in the returned task, we can then convert our coroutines to the task return method and use the task returned from the initial call of each coroutine to check for exceptions later, ( note ), so that no changes no routines are needed, since the async methods returning void / Task are identical).

This requires a few changes in the Coordinator class. First, add two new fields:

 private List<Func<Coordinator, Task>> initialCoroutines = new List<Func<Coordinator, Task>>(); private List<Task> coroutineTasks = new List<Task>(); 

initialCoroutines first saves coroutines added to the coordinator, and coroutineTasks stores tasks that are the result of the initial call to initialCoroutines .

Then our Start () program is designed to launch new routines, save the result and check the result of tasks between each 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 apply to the original caller.

+2
source

Source: https://habr.com/ru/post/1485697/


All Articles