c# - 如何在基于异步/等待的单线程协程实现中捕获异常

标签 c# asynchronous game-engine async-await coroutine

是否可以使用async并等待以高雅而安全地实现高性能的协程,这些协程仅在一个线程上运行,不浪费周期(这是游戏代码),并且可以将异常返回给协程的调用方(可能是一个协程)协程本身)?

背景

我正在尝试用C#协同程序AI代码替换(宠物游戏项目)Lua协同程序AI代码(通过LuaInterface托管在C#中)。

•我想将每个AI(例如怪物)作为自己的协程(或嵌套的协程)运行,以便主游戏线程可以每帧(每秒60次)选择“单步执行”部分或全部人工智能取决于其他工作量。

•但是出于可读性和易于编码的目的,我想编写AI代码,使其唯一的线程感知功能是在完成任何重要工作后“屈服”其时间片;并且我希望能够“屈服”中间方法,并在所有本地人完好无损的情况下恢复下一帧(正如您期望的那样。)

•我不想使用IEnumerable <>来获得 yield ,部分是由于丑陋,部分是由于对所报告问题的迷信,尤其是异步和等待看起来更合乎逻辑。

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

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

void MainGameEachFrame()
{        
     RunVitalUpdatesEachFrame();
     while (TimeToSpare())
          Coroutines.StepNext() // round robin is fine
     Draw();
}                

对于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();
}
...

方法

在Windows 2012桌面版VS 2012 Express(.NET 4.5)中,我试图逐字使用Jon Skeet出色的Eduasync part 13: first look at coroutines with async中的示例代码,这令人大开眼界。

该源可用via this link。不使用提供的AsyncVoidMethodBuilder.cs,因为它与mscorlib中的发行版冲突(这可能是问题的一部分)。我必须将提供的Coordinator类标记为实现System.Runtime.CompilerServices.INotifyCompletion,因为.NET 4.5的发行版要求这样做。

尽管如此,创建一个控制台应用程序来运行示例代码仍然可以很好地工作,而这正是我想要的:在单线程上进行协作多线程,并以“yield”作为等待,而不会出现基于IEnumerable <>的协程的丑陋情况。

现在,我按如下方式编辑示例FirstCoroutine函数:
private static async void FirstCoroutine(Coordinator coordinator) 
{ 
    await coordinator;
    throw new InvalidOperationException("First coroutine failed.");
}

然后编辑Main(),如下所示:
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。从某种意义上讲,我也收集到异步虚空并不是用来传播结果的,尽管我不确定在此如何做,也不确定添加任务如何在单线程实现中有所帮助。

我可以从编译器为FirstCoroutine生成的状态机代码中看到,通过其MoveNext()实现,任何异常都传递给AsyncVoidMethodBuilder.SetException(),这会发现缺少同步上下文并调用ThrowAsync()并最终到达线程池就像我看到的那样。

但是,我天真的将SynchronisationContext嫁接到应用程序上的尝试并没有成功。我尝试添加this one,在Main()的开头调用SetSynchronizationContext(),然后将整个Coordinator创建并包装在AsyncPump()。Run()中,然后可以在该类中使用Debugger.Break()(但不能设置断点) 'Post()方法,然后在这里看到异常。但是,单线程同步上下文只是按顺序执行。它无法完成将异常传播回调用者的工作。因此,在完成了整个协调程序序列(及其捕获块)并进行了尘土处理后,异常异常严重。

我尝试了更灵活的方法来派生我自己的SynchronizationContext,该方法的Post()方法立即立即执行给定的Action;这看起来很有希望(如果邪恶,并且对于在该上下文处于 Activity 状态的任何复杂代码毫无疑问会带来可怕的后果?),但这与生成的状态机代码不符:AsyncMethodBuilderCore.ThrowAsync的通用catch处理程序将捕获此尝试并重新抛出线程池!

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

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

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

协调器在执行任何操作(协程或异步继续)之前将其设置为当前上下文,然后再还原之前的上下文。

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

但是,由于自定义SynchronizationContext的其他方法没有被覆盖,并且由于我最终对自己正在做的事情缺乏足够的了解,因此我认为对于任何复杂的问题(尤其是异步或任务),这都会产生一些(不愉快的)副作用定向程序,还是真正的多线程?)由协程调用的代码是理所当然的吗?

最佳答案

有趣的难题。

问题

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

但是,AsyncVoidMethodBuildervoid方法的异步方法构建器。 Task异步方法呢?这是通过AsyncTaskMethodBuilder处理的。与该构建器的不同之处在于,它没有将其传播到当前的同步上下文,而是调用Task.SetException来通知任务的用户抛出了异常。

一个办法

知道返回Task的异步方法会将异常信息存储在返回的任务中,然后我们可以将协程转换为任务返回方法,并使用从每个协程的初始调用返回的任务在以后检查异常。 (请注意,因为void/返回异步任务的任务相同,所以无需更改例程)。

这需要对Coordinator类进行一些更改。首先,我们添加两个新字段:

private List<Func<Coordinator, Task>> initialCoroutines = new List<Func<Coordinator, Task>>();
private List<Task> coroutineTasks = new List<Task>();
initialCoroutines存储最初添加到协调器的协程,而coroutineTasks存储初始调用initialCoroutines产生的任务。

然后,我们的Start()例程适用于运行新例程,存储结果,然后在每个新操作之间检查任务的结果:
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();
}

这样,异常就会传播到原始调用方。

关于c# - 如何在基于异步/等待的单线程协程实现中捕获异常,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/17054055/

相关文章:

javascript - 执行函数作为异步 Node.js 脚本的最后一件事

.net - 提供异步编程模型 : Should I?,如果是,应该是 VerbAsync() 还是 BeginVerb()?

android - 哪个游戏框架很容易在android中开发2D游戏应用程序?

c# - Chris Lomont 的 C# FFT - 它是如何工作的

c# - WPF : type special characters 中带有 TextBox 的 TreeViewItem

c# - 为什么要使用 SerializeField?

c# - 使用异步 Web 服务时,什么应该 "await"什么不应该?

c# - 在每个 MSTest 测试方法开始时重置静态变量

c# - 总是在 Override 中调用 base.Method 而不为简单脚本提及它

python - 在 Django 中编写多人游戏