问题 我如何处理异步任务我不想等待?


我正在编写一个多人游戏服务器,我正在研究新的C#async / await功能的方法 帮我。服务器的核心是一个循环,它可以快速更新游戏中的所有演员 能够:

while (!shutdown)
{
    foreach (var actor in actors)
        actor.Update();

    // Send and receive pending network messages
    // Various other system maintenance
}

这个循环需要处理数千个actor并且每秒更新多次以保持 游戏运行顺畅。有些演员偶尔会在更新功能中执行缓慢的任务 从数据库中获取数据,这是我想要使用异步的地方。一旦检索到这些数据 演员想要更新游戏状态,这必须在主线程上完成。

由于这是一个控制台应用程序,我打算编写一个可以调度的SynchronizationContext 挂起代表到主循环。这允许这些任务在完成后更新游戏 并允许未处理的异常被抛入主循环。我的问题是,如何编写异步 更新功能?这非常好用,但打破了不使用async void的建议:

Thing foo;

public override void Update()
{
    foo.DoThings();

    if (someCondition) {
        UpdateAsync();
    }
}

async void UpdateAsync()
{
    // Get data, but let the server continue in the mean time
    var newFoo = await GetFooFromDatabase();

    // Now back on the main thread, update game state
    this.foo = newFoo;
}

我可以使Update()异步并将任务传播回主循环,但是:

  • 我不想为数千个永远不会使用它的更新增加开销。
  • 即使在主循环中,我也不想等待任务并阻止循环。
  • 等待任务将导致死锁,因为它需要在等待线程上完成。

我怎么办这些我无法等待的任务?我唯一一次想知道他们都是 完成是我关闭服务器,但我不想收集生成的每个任务 可能需要几个星期的更新。


8179
2017-09-10 03:01


起源

一个有趣的问题,+ 1并添加了[task-parallel-library]标签。 - Noseratio
一旦检索到该数据,actor就想要更新游戏状态,这必须在主线程上完成。 游戏状态更新本身是同步还是异步操作? - Noseratio
同步,应该是串行完成的。这通常是一个快速的单线程循环,偶尔需要生成和重新加入后台任务。 - Generic Error
检查我的答案中的第二个列表,我认为它只是用于使用排队回调的游戏状态更新。对于演员更新,您可以切换 Parallel.ForEach 经常 foreach 如果您希望它们也可以串行执行,但您几乎可以放弃多核CPU架构的优势。 - Noseratio


答案:


我的理解是,它的关键在于你想要:

while (!shutdown)
{
    //This should happen immediately and completions occur on the main thread.
    foreach (var actor in actors)
        actor.Update(); //includes i/o bound database operations

    // The subsequent code should not be delayed
   ...
}

while循环在主控制台线程中运行的位置。这是一个紧密的单线程循环。你可以并行运行foreach,但是你仍然会等待运行时间最长的实例(i / o绑定操作从数据库中获取数据)。

等待异步不是此循环中的最佳选项,您需要在线程池上运行这些i / o数据库任务。在线程池上,async await对于释放池线程很有用。

那么,接下来的问题是如何将这些完成返回到主线程。好吧,看起来你需要一些与主线程上的消息泵相当的东西。看到 这个帖子 有关如何做到这一点的信息,虽然这可能有点沉重。您可以拥有一个完整队列,您可以通过while循环在主线程中检查这些队列。你会使用其中一个 并发数据结构 要做到这一点,以便它是所有线程安全的,然后设置Foo,如果它需要设置。

似乎有一些空间来合理化这种对演员和线程的轮询,但是在不知道应用程序的细节的情况下很难说。

几点: -

  • 如果您没有在某个任务上等待,则主控制台线程将退出,您的应用程序也将退出。看到 这里 详情。

  • 正如您所指出的,等待异步不会阻塞当前线程,但它确实意味着await之后的代码只会在完成await时执行。

  • 完成可能会或可能不会在调用线程上完成。您已经提到了同步上下文,所以我不会详细介绍。

  • 控制台应用程序上的同步上下文为空。看到 这里 供参考。

  • Async并不适用于即发即弃型操作。

对于火灾和遗忘,您可以根据您的情况使用以下选项之一:

  • 使用Task.Run或Task.StartNew。看到 这里有差异
  • 对于在您自己的线程池下运行的长时间运行方案,请使用生产者/消费者类型模式。

请注意以下事项: -

  • 您将需要处理生成的任务/线程中的异常。如果您没有观察到任何异常,您可能想要处理这些,甚至只是为了记录它们的出现。请参阅有关的信息 未被观察到的例外
  • 如果您的进程在这些长时间运行的任务在队列中或者启动它们将无法运行时死亡,那么您可能需要某种持久性机制(数据库,外部队列,文件)来跟踪这些操作的状态。

如果您想了解这些任务的状态,那么您需要以某种方式跟踪它们,无论是内存列表,还是通过查询自己的线程池的队列或查询持久性机制。关于持久性机制的好处在于它对崩溃具有弹性,在关机期间你可以立即关闭,然后在重新启动时获取最终结果(这当然取决于任务在其中运行的重要性)某个时间段)。


9
2017-09-10 03:31



那么我会做一些像Task.Run这样的事情来启动任务,然后有一个方法我可以调用队列一个委托来运行主循环吗?感觉有点像我正在构建我自己非常简单的异步功能版本。 - Generic Error
我已经更新了我的帖子,其中包含有关等待异步的信息。它实际上并不阻止线程。 - acarlon
不,但是如果我理解正确的话,它将导致当前方法的其余部分被设置为等待任务完成时的延续。这意味着等待不适合必须立即进行的主循环。 - Generic Error
我已更新我的回复,表明您已经意识到这一点。正确,await之后的代码只会在await完成后发生。如果这是不可接受的,您将需要重新构建代码或在新线程上启动代码。如果后续代码仅在异步完成时运行,则异步方法将起作用,但在您的情况下,它需要立即执行。 - acarlon
更正:“在新线程上启动代码”应该是“在新线程/线程池上执行更新” - acarlon


首先,我建议您不要使用自己的 SynchronizationContext;我有一个可用作为我的一部分 AsyncEx库 我通常用于控制台应用程序。

至于你的更新方法,它们应该返回 Task。我的AsyncEx库有许多“任务常量”,当你有一个方法时它很有用 威力 是异步的:

public override Task Update() // Note: not "async"
{
  foo.DoThings();

  if (someCondition) {
    return UpdateAsync();
  }
  else {
    return TaskConstants.Completed;
  }
}

async Task UpdateAsync()
{
  // Get data, but let the server continue in the mean time
  var newFoo = await GetFooFromDatabase();

  // Now back on the main thread, update game state
  this.foo = newFoo;
}

回到你的主循环,解决方案还不是那么清楚。如果你希望每个演员在继续下一个演员之前完成,那么你可以这样做:

AsyncContext.Run(async () =>
{
  while (!shutdown)
  {
    foreach (var actor in actors)
      await actor.Update();
    ...
  }
});

或者,如果你想同时启动所有演员并等待他们全部完成,然后再转到下一个“勾号”,你可以这样做:

AsyncContext.Run(async () =>
{
  while (!shutdown)
  {
    await Task.WhenAll(actors.Select(actor => actor.Update()));
    ...
  }
});

当我在上面说“同时”时,它实际上是按顺序启动每个actor,因为它们都在主线程上执行(包括 async 延续),没有实际的 同时 行为;每个“代码块”将在同一个线程上执行。


2
2017-09-10 13:07



嗨,斯蒂芬,我花了很多时间浏览你的博客和项目,谢谢你的所有工作。这种情况可能有些奇怪,因为我不希望循环等待任务,实际上我们可能会经历整个主循环并在后台任务完成之前多次调用actor上的Update。也许我混淆了异步的目的,因为我真正在做的是启动另一个与Update本身无关的任务。 - Generic Error
好吧,你可以不用触发每次更新 await结果,但这带来了关于错误处理的问题。迟早你会想要“同步”备份并传播异常。 - Stephen Cleary


我强烈建议您观看此视频或只是看一下幻灯片: 在Microsoft Visual C#和Visual Basic中使用异步的三个基本技巧

根据我的理解,你应该在这个场景中做的事情是返回 Task<Thing> 在 UpdateAsync 甚至可能 Update

如果您在主循环外部使用'foo'执行某些异步操作,那么在将来的顺序更新期间异步部分完成时会发生什么?我相信你真的想等待所有的更新任务完成,然后一次性交换你的内部状态。

理想情况下,您将首先启动所有慢速(数据库)更新,然后执行其他更快速的更新,以便尽快准备好整个集合。


0
2017-09-10 06:12