Что мне делать с задачами async, которые я не хочу ждать?

Я пишу многопользовательский игровой сервер и смотрю, как новые функции С# async/await могут Помоги мне. Ядром сервера является цикл, который обновляет всех участников в игре так же быстро, как и могут:

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

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

Этот цикл необходим для обработки тысяч участников и обновления несколько раз в секунду, чтобы сохранить игра работает гладко. Некоторые участники иногда выполняют медленные задачи в своих функциях обновления, таких как как выборка данных из базы данных, где я хотел бы использовать async. После получения этих данных актер хочет обновить состояние игры, которое должно быть выполнено в основном потоке.

Как это консольное приложение, я планирую написать 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() async и протаить задачи обратно в основной цикл, но:

  • Я не хочу добавлять накладные расходы на тысячи обновлений, которые никогда не будут использовать его.
  • Даже в основном цикле я не хочу ждать задач и блокировать цикл.
  • Ожидание задачи приведет к тупиковой ситуации, так как она должна завершиться в ожидающем потоке.

Что мне делать со всеми этими задачами, которые я не могу ждать? Единственный раз, когда мне захочется узнать, что они все завершено, когда я закрываю сервер, но я не хочу собирать каждую задачу, созданную возможно, недели обновлений.

Ответ 1

Я понимаю, что суть этого в том, что вы хотите:

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 параллельно, но тогда вы все равно будете ждать самого длинного работающего экземпляра (операция привязки ввода-вывода для получения данных из базы данных).

Ожидать, что async не самый лучший вариант в этом цикле, вам нужно запустить эти задачи базы данных ввода-вывода в пуле потоков. В потоковом пуле async await было бы полезно освободить потоки пула.

Итак, следующий вопрос - как вернуть эти доработки в ваш основной поток. Ну, похоже, вам нужно что-то эквивалентное сообщению насоса на вашей основной нити. См. этот пост для получения информации о том, как это сделать, хотя это может быть немного тяжело. Вы можете просто иметь очередь завершения сортировки, которую вы проверяете на основном потоке в каждом проходе через ваш цикл Loop. Вы должны использовать одну из параллельных структур данных, чтобы сделать это, чтобы все было безопасно в потоке, а затем установить Foo, если он необходимо установить.

Кажется, что есть место для рационализации этого опроса актеров и потоков, но, не зная подробностей приложения, трудно сказать.

Несколько пунктов: -

  • Если у вас нет ожиданий выше задачи, ваш основной поток консоли выйдет, и ваше приложение тоже будет. Подробнее см. здесь.

  • Как вы уже указали, подождите, пока async не заблокирует текущий поток, но это означает, что код, следующий за ожиданием, будет выполняться только по завершении ожидания.

  • Заполнение может быть завершено или не может быть выполнено в вызывающем потоке. Вы уже упоминали контекст синхронизации, поэтому я не буду вдаваться в подробности.

  • Контекст синхронизации имеет значение null в приложении консоли. Подробнее см. здесь.

  • Async не работает для операций типа "огонь-и-забыть".

Для пожара и забывания вы можете использовать один из этих вариантов в зависимости от вашего сценария:

  • Используйте Task.Run или Task.StartNew. См. здесь для различий.
  • Используйте шаблон производителя/потребителя для сценариев с длинными сценариями, запущенных под вашим собственным файловым пулом.

Обратите внимание на следующее: -

  • Чтобы вам пришлось обрабатывать исключения в ваших нерешенных задачах/потоках. Если есть какие-то исключения, которые вы не соблюдаете, вы можете обращаться с ними, даже для регистрации их происшествий. См. Информацию о ненаблюдаемых исключениях.
  • Если ваш процесс умирает, когда эти длительные запущенные задачи находятся в очереди или запускаются, они не будут запущены, поэтому вам может понадобиться какой-то механизм сохранения (база данных, внешняя очередь, файл), которая отслеживает состояние этих операций,

Если вы хотите узнать о состоянии этих задач, вам нужно будет каким-то образом их отслеживать, будь то список в памяти или путем запроса очередей для вашего собственного пула потоков или путем запроса механизм сохранения. Самое приятное в механизме персистентности заключается в том, что он устойчив к сбоям и во время выключения вы можете сразу закрыть его, а затем забрать, когда вы закончили работу при перезагрузке (это, конечно, зависит от того, насколько важно, чтобы задачи выполнялись внутри определенный таймфрейм).

Ответ 2

Во-первых, я рекомендую вам не использовать свой собственный 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()));
    ...
  }
});

Когда я говорю "одновременно" выше, он фактически запускает каждого актора по порядку, и поскольку все они выполняются в основном потоке (включая продолжения async), фактического одновременного поведения нет; каждый "патрон кода" будет выполняться в одном и том же потоке.

Ответ 3

Я очень рекомендую посмотреть это видео или просто взглянуть на слайды: Три основных совета по использованию Async в Microsoft Visual С# и Visual Basic

Из моего понимания, что вы, вероятно, должны делать в этом сценарии, возвращается Task<Thing> в UpdateAsync и, возможно, даже Update.

Если вы выполняете некоторые асинхронные операции с 'foo' вне основного цикла, что происходит, когда асинхронная часть завершается во время будущего последовательного обновления? Я считаю, что вы действительно хотите дождаться завершения всех своих задач обновления и затем заменить свое внутреннее состояние за один раз.

В идеале вы сначала начнете все медленные (базы данных) обновления, а затем выполните другие более быстрые, чтобы весь набор был готов как можно скорее.