C# 如何在基于异步/等待的单线程协同路由实现中捕获异常

C# 如何在基于异步/等待的单线程协同路由实现中捕获异常,c#,asynchronous,game-engine,async-await,coroutine,C#,Asynchronous,Game Engine,Async Await,Coroutine,是否可以使用async和Wait来优雅而安全地实现性能协同路由,它只在一个线程上运行,不浪费周期(这是游戏代码),并且可以将异常抛出回协同路由的调用方(可能是协同路由本身) 背景 我正在尝试用C#coroutine AI代码替换(宠物游戏项目)Lua coroutine AI代码(托管在C#via中) •我希望将每个AI(比如怪物)作为自己的协同程序(或嵌套的协同程序集)运行,这样主游戏线程可以在每帧(每秒60次)中选择“单步”部分或全部AI,具体取决于其他工作负载 •但为了易读性和易于编码,我

是否可以使用async和Wait来优雅而安全地实现性能协同路由,它只在一个线程上运行,不浪费周期(这是游戏代码),并且可以将异常抛出回协同路由的调用方(可能是协同路由本身)

背景

我正在尝试用C#coroutine AI代码替换(宠物游戏项目)Lua coroutine AI代码(托管在C#via中)

•我希望将每个AI(比如怪物)作为自己的协同程序(或嵌套的协同程序集)运行,这样主游戏线程可以在每帧(每秒60次)中选择“单步”部分或全部AI,具体取决于其他工作负载

•但为了易读性和易于编码,我想编写人工智能代码,使其唯一的线程感知是在完成任何重要工作后“屈服”其时间片;我希望能够“屈服”mid方法,并在所有局部变量等保持不变的情况下恢复下一帧(正如您所期望的那样)

•我不想使用IEnumerable和yield-return,部分原因是因为它的丑陋,部分原因是对报告问题的迷信,尤其是异步和等待看起来更符合逻辑

从逻辑上讲,主游戏的伪代码:

void MainGameInit()
{
    foreach (monster in Level)
        Coroutines.Add(() => ASingleMonstersAI(monster));
}

void MainGameEachFrame()
{        
     RunVitalUpdatesEachFrame();
     while (TimeToSpare())
          Coroutines.StepNext() // round robin is fine
     Draw();
}                
至于人工智能:

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();
}
...
并按如下方式编辑Main():

private static async void FirstCoroutine(Coordinator coordinator) 
{ 
    await coordinator;
    throw new InvalidOperationException("First coroutine failed.");
}
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);
    }
}
我天真地希望能抓住这个例外。相反,它不是——在这个“单线程”协程实现中,它是在线程池线程上抛出的,因此是未捕获的

尝试修复此方法

通过阅读,我了解了问题的一部分。我发现控制台应用程序缺少SynchronizationContext。我还收集到,在某种意义上,异步空洞并不是为了传播结果,尽管我不知道在这里该怎么做,也不知道添加任务在单线程实现中会有什么帮助

我可以从编译器为FirstCorroutine生成的状态机代码中看出,通过它的MoveNext()实现,任何异常都会传递给AsyncVoidMethodBuilder.SetException(),这会发现缺少同步上下文,并调用ThrowAsync(),正如我所看到的,它最终会在线程池线程上结束

然而,我天真地将SynchronizationContext移植到应用程序上的尝试并不成功。我尝试添加,在Main()的开头调用SetSynchronizationContext(),并将整个协调器创建和调用包装在AsyncPump().Run()中,我可以在该类的Post()方法中调试.Break()(但不是breakpoint),并查看这里是否出现异常。但是,单线程同步上下文只是串行执行;它无法将异常传播回调用方。因此,在整个协调程序序列(及其捕获块)完成并清除灰尘后,异常就会出现

我尝试了一种更具创造性的方法来派生我自己的SynchronizationContext,它的Post()方法只是立即执行给定的操作;这看起来很有希望(如果是邪恶的,毫无疑问会对任何在该上下文激活的情况下调用的复杂代码造成可怕的后果?),但这与生成的状态机代码AsyncMethodBuilderCore相冲突。ThrowAsync的通用捕获处理程序捕获此尝试,并重新插入线程池

部分“解决方案”,可能不明智?

继续思考,我有一个部分的“解决方案”,但我不确定后果是什么,因为我宁愿在黑暗中钓鱼

我可以定制Jon Skeet的协调器,以实例化它自己的SynchronizationContext派生类,该类具有对协调器本身的引用。当要求所述上下文发送()或Post()回调时(例如通过AsyncMethodBuilderCore.ThrowAsync()),它会要求协调器将其添加到一个特殊的操作队列中

协调器在执行任何操作(协同路由或异步继续)之前将其设置为当前上下文,然后恢复以前的上下文

在协调器的常规队列中执行任何操作之后,我可以坚持它执行特殊队列中的每个操作。这意味着AsyncMethodBuilderCore.ThrowAsync()会导致在相关延续过早退出后立即引发异常。(要从AsyncMethodBuilderCore抛出的异常中提取原始异常,仍有一些工作要做。)

然而,由于定制SynchronizationContext的其他方法没有被覆盖,并且由于我最终对我正在做的事情缺乏适当的线索,我认为这将对任何复杂的(特别是异步的或面向任务的,或真正的多线程的?)产生一些(不愉快的)副作用当然,协同程序调用的代码?

有趣的难题

问题 正如您所指出的,问题在于默认情况下,使用void异步方法捕获的任何异常都是使用
AsyncVoidMethodBuilder.SetException
捕获的,然后使用
AsyncMethodBuilderCore.ThrowAsync()。很麻烦,因为一旦出现异常,就会在另一个线程(从线程池)上抛出异常。似乎没有任何方法可以覆盖此行为

但是,
asynchvoidmethodbuilder
void
方法的异步方法生成器。那么
任务
异步方法呢?这是通过
AsyncTaskMethodBuilder
处理的。与此生成器的不同之处在于,它没有将其传播到当前同步上下文,而是调用
Task.SetException
来通知任务的用户引发了异常

解决办法 知道一个
Task
returning异步方法在返回的任务中存储异常信息,我们就可以将我们的协同路由转换为任务返回方法,并在以后使用从每个协同路由的初始调用返回的任务来检查异常。(th
private List<Func<Coordinator, Task>> initialCoroutines = new List<Func<Coordinator, Task>>();
private List<Task> coroutineTasks = new List<Task>();
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();
}