【问题标题】:How to catch exceptions in async / await based single-threaded coroutine implementation如何在基于异步/等待的单线程协程实现中捕获异常
【发布时间】:2013-06-07 21:21:28
【问题描述】:

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

背景

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

• 我想将每个 AI(例如怪物)作为其自己的协程(或嵌套的协程集)运行,这样主游戏线程可以每帧(每秒 60 次)可以选择“单步”一些或所有 AI,具体取决于其他工作负载。

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

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

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

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

方法

借助适用于 Windows 桌面 (.NET 4.5) 的 VS 2012 Express,我正尝试逐字使用 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 的发布版本所要求的。

尽管如此,创建一个运行示例代码的控制台应用程序运行良好,这正是我想要的:在单个线程上以 await 作为“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。我还收集到,在某种意义上,异步 void 并不打算传播结果,尽管我不确定在这里该怎么做,也不确定添加任务对单线程实现有何帮助。

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

但是,我尝试将 SynchronisationContext 天真地移植到应用程序上并没有成功。我尝试添加this one,在 Main() 的开头调用 SetSynchronizationContext(),并将整个 Coordinator 创建和调用包装在 AsyncPump().Run() 中,我可以在 Debugger.Break()(但不是断点)中该类的 Post() 方法,并看到异常出现在此处。但是那个单线程同步上下文只是串行执行;它无法完成将异常传播回调用者的工作。因此,在整个 Coordinator 序列(及其 catch 块)完成并除尘后,异常就会出现。

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

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

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

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

Coordinator 在执行任何 Action(协程或异步继续)之前将其设置为当前上下文,然后恢复之前的上下文。

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

但是,由于自定义 SynchronizationContext 的其他方法没有被覆盖,并且由于我最终缺乏关于我在做什么的体面的线索,所以我认为这对于任何复杂的(尤其是异步或面向任务,还是真正的多线程?)协程调用的代码理所当然?

【问题讨论】:

  • 我建议您不要在生产代码中使用任何基于 await 的协程。我认为将协程重组为参与者(Task 加上传入消息的BufferBlock)会更清晰,您可以选择一次安排一个(使用ConcurrentExclusiveSchedulerPair.ExclusiveScheduler)。
  • 我必须承认我对演员模型知之甚少——这种方法是否允许协作多线程(明确地“让步”以结束 AI 在这一帧的活动,与所有本地人一起恢复下一帧,调用堆栈等显然完好无损)?因为这就是我在这里寻找的。我根本不需要传递太多数据,除非通过函数调用;但我确实希望明确控制 AI 的时间片,而不必手动将代码重构为状态机(a la await 的内部实现)。感谢您的任何想法!

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


【解决方案1】:

有趣的谜题。

问题

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

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

解决方案

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

这需要对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();
}

这样,异常就会传播给原始调用者。

【讨论】:

    猜你喜欢
    • 2015-09-27
    • 2020-07-08
    • 1970-01-01
    • 1970-01-01
    • 2018-10-01
    • 2014-12-01
    • 2022-01-18
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多