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

Я пишу многопользовательский игровой сервер и ищу способы, которыми новые функции C # 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 () асинхронным и передать задачи обратно в основной цикл, но:

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

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


person Generic Error    schedule 10.09.2013    source источник
comment
Интересный вопрос, +1 и добавлен тег [task-parallel-library].   -  person noseratio    schedule 10.09.2013
comment
После получения этих данных актер хочет обновить состояние игры, что должно быть выполнено в основном потоке. Является ли само обновление состояния игры синхронной или асинхронной операцией?   -  person noseratio    schedule 10.09.2013
comment
Синхронизация должна выполняться последовательно. Как правило, это быстрый однопоточный цикл со случайными фоновыми задачами, которые необходимо порождать и повторно объединять.   -  person Generic Error    schedule 10.09.2013
comment
Проверьте второй листинг в моем ответе, я думаю, что это касается только обновлений состояния игры с использованием обратных вызовов в очереди. Для обновлений акторов вы можете переключиться с Parallel.ForEach на обычный foreach, если хотите, чтобы они также выполнялись последовательно, но вы в значительной степени потеряете преимущества многоядерной архитектуры ЦП.   -  person noseratio    schedule 11.09.2013


Ответы (3)


Насколько я понимаю, суть в том, что вы хотите:

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

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

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

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

Пара моментов: -

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

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

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

  • Контекст синхронизации в консольном приложении равен нулю. Дополнительную информацию см. здесь.

  • Async на самом деле не для операций типа «запустил и забыл».

Чтобы выстрелить и забыть, вы можете использовать один из следующих вариантов в зависимости от вашего сценария:

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

Имейте в виду следующее:

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

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

person acarlon    schedule 10.09.2013
comment
Итак, я бы сделал что-то вроде Task.Run, чтобы запустить задачу, а затем иметь метод, который я могу вызвать, чтобы поставить делегата в очередь для запуска основного цикла? Мне кажется, что я создаю свою собственную очень простую версию асинхронной функциональности. - person Generic Error; 10.09.2013
comment
Я обновил свой пост информацией о await async. Фактически это не блокирует поток. - person acarlon; 10.09.2013
comment
Нет, однако, если я правильно понимаю, это приведет к тому, что остальная часть текущего метода будет настроена как продолжение, когда ожидаемая задача завершится. Это означает, что ожидание не подходит для основного цикла, который должен выполняться немедленно. - person Generic Error; 10.09.2013
comment
Я обновил свой ответ, чтобы указать, что вы уже знали об этом. Правильно, код после ожидания появится только после завершения ожидания. Если это неприемлемо, вам нужно будет реструктурировать код или запустить код в новом потоке. Асинхронный подход будет работать, если последующий код должен запускаться только после завершения async, но в вашем случае он должен быть немедленным. - person acarlon; 10.09.2013
comment
Исправление: запускать код в новом потоке должно выполняться обновление в новом потоке / пуле потоков. - person acarlon; 10.09.2013
comment
Спасибо за все мысли. Я собираюсь попробовать написать прототип, который сделает пару помощников доступными для методов Update, которым они нужны: a) Запустить задачу в пуле потоков с моим стандартным журналированием и т. Д. б) Запланируйте запуск делегата позже в основном цикле. - person Generic Error; 10.09.2013
comment
Без проблем. Это хороший вопрос. Я сделал дальнейшее обновление. - person acarlon; 10.09.2013
comment
позвольте нам продолжить обсуждение в чате - person Generic Error; 10.09.2013
comment
Я бы хотел, но должен выехать, вернусь примерно через два часа. - person acarlon; 10.09.2013

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

person Stephen Cleary    schedule 10.09.2013
comment
Привет, Стивен, я потратил довольно много времени на просмотр вашего блога и проектов, спасибо за всю вашу работу. Этот случай, возможно, немного странный, поскольку я не хочу, чтобы цикл ожидал выполнения задач, на самом деле мы могли бы пройти весь основной цикл и несколько раз вызвать Update для актора, прежде чем его фоновая задача завершится. Возможно, я сбиваю с толку цель async, поскольку на самом деле я запускаю другую задачу, которая не связана с самим обновлением. - person Generic Error; 11.09.2013
comment
Что ж, вы можете запускать каждое обновление, не await отмечая результат, но это поднимает вопросы об обработке ошибок. Рано или поздно вы захотите синхронизировать резервную копию и распространить исключения. - person Stephen Cleary; 11.09.2013

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

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

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

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

person Lummo    schedule 10.09.2013