4

是否可以使用 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();
}
...

该方法

使用 VS 2012 Express for Windows Desktop (.NET 4.5),我正在尝试逐字使用 Jon Skeet 出色的Eduasync 第 13 部分中的示例代码:首先看看带有异步的协程,这让我大开眼界。

该来源可通过此链接获得。不使用提供的 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。我还收集到,在某种意义上,异步 void 并不打算传播结果,尽管我不确定在这里该怎么做,也不确定添加任务将如何帮助单线程实现。

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

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

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

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

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

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

协调器在执行任何动作(协程或异步继续)之前将此设置为当前上下文,然后恢复之前的上下文。

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

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

4

1 回答 1

2

有趣的谜题。

问题

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

但是,AsyncVoidMethodBuilder是方法的异步方法构建器voidTask异步方法呢?这是通过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();
}

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

于 2013-06-12T02:09:23.057 回答