Async/ожидает замены сопрограмм

Я использую итераторы С# в качестве замены сопрограмм, и он отлично работает. Я хочу переключиться на async/await, поскольку я думаю, что синтаксис чище, и это дает мне безопасность типа. В этом (устаревшем) сообщении в блоге Jon Skeet показывает возможный способ его реализации.

Я решил немного по-другому (путем реализации моего собственного SynchronizationContext и использования Task.Yield). Это отлично работает.

Тогда я понял, что будет проблема; в настоящее время сопрограмма не должна заканчиваться. Его можно прекратить изящно в любой момент, когда он уступает. У нас может быть такой код:

private IEnumerator Sleep(int milliseconds)
{
    Stopwatch timer = Stopwatch.StartNew();
    do
    {
        yield return null;
    }
    while (timer.ElapsedMilliseconds < milliseconds);
}

private IEnumerator CoroutineMain()
{
    try
    {
        // Do something that runs over several frames
        yield return Coroutine.Sleep(5000);
    }
    finally
    {
        Log("Coroutine finished, either after 5 seconds, or because it was stopped");
    }
}

Работа с сопрограммой работает, отслеживая все счетчики в стеке. Компилятор С# генерирует функцию Dispose, которая может быть вызвана, чтобы гарантировать, что блок "finally" корректно вызывается в CoroutineMain, даже если перечисление не завершено. Таким образом, мы можем прекратить coroutine изящно и все еще гарантировать, что, наконец, вызывается блоки, вызывая Dispose для всех объектов IEnumerator в стеке. Это в основном ручная размотка.

Когда я написал свою реализацию с помощью async/await, я понял, что мы потеряем эту функцию, если я не ошибаюсь. Затем я просмотрел другие решения coroutine, и это не похоже на то, что версия Jon Skeet также обрабатывает ее.

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

Я что-то недопонимаю, и можно ли это сделать проще? Или мне нужно сделать способ исключения для этого?

EDIT: запрашивается дополнительная информация/код, поэтому некоторые из них. Я могу гарантировать, что это будет работать только на одном потоке, поэтому здесь нет потоков. Наша текущая реализация coroutine немного похожа на это (это упрощено, но работает в этом простом случае):

public sealed class Coroutine : IDisposable
{
    private class RoutineState
    {
        public RoutineState(IEnumerator enumerator)
        {
            Enumerator = enumerator;
        }

        public IEnumerator Enumerator { get; private set; }
    }

    private readonly Stack<RoutineState> _enumStack = new Stack<RoutineState>();

    public Coroutine(IEnumerator enumerator)
    {
        _enumStack.Push(new RoutineState(enumerator));
    }

    public bool IsDisposed { get; private set; }

    public void Dispose()
    {
        if (IsDisposed)
            return;

        while (_enumStack.Count > 0)
        {
            DisposeEnumerator(_enumStack.Pop().Enumerator);
        }

        IsDisposed = true;
    }

    public bool Resume()
    {
        while (true)
        {
            RoutineState top = _enumStack.Peek();
            bool movedNext;

            try
            {
                movedNext = top.Enumerator.MoveNext();
            }
            catch (Exception ex)
            {
                // Handle exception thrown by coroutine
                throw;
            }

            if (!movedNext)
            {
                // We finished this (sub-)routine, so remove it from the stack
                _enumStack.Pop();

                // Clean up..
                DisposeEnumerator(top.Enumerator);


                if (_enumStack.Count <= 0)
                {
                    // This was the outer routine, so coroutine is finished.
                    return false;
                }

                // Go back and execute the parent.
                continue;
            }

            // We executed a step in this coroutine. Check if a subroutine is supposed to run..
            object value = top.Enumerator.Current;
            IEnumerator newEnum = value as IEnumerator;
            if (newEnum != null)
            {
                // Our current enumerator yielded a new enumerator, which is a subroutine.
                // Push our new subroutine and run the first iteration immediately
                RoutineState newState = new RoutineState(newEnum);
                _enumStack.Push(newState);

                continue;
            }

            // An actual result was yielded, so we've completed an iteration/step.
            return true;
        }
    }

    private static void DisposeEnumerator(IEnumerator enumerator)
    {
        IDisposable disposable = enumerator as IDisposable;
        if (disposable != null)
            disposable.Dispose();
    }
}

Предположим, что мы имеем следующий код:

private IEnumerator MoveToPlayer()
{
  try
  {
    while (!AtPlayer())
    {
      yield return Sleep(500); // Move towards player twice every second
      CalculatePosition();
    }
  }
  finally
  {
    Log("MoveTo Finally");
  }
}

private IEnumerator OrbLogic()
{
  try
  {
    yield return MoveToPlayer();
    yield return MakeExplosion();
  }
  finally
  {
    Log("OrbLogic Finally");
  }
}

Это будет создано путем передачи экземпляра счетчика OrbLogic в Coroutine и последующего его запуска. Это позволяет нам отмечать сопрограмму каждого кадра. Если игрок убивает шар, сопрограмма не заканчивается; Dispose просто вызывается на сопрограмме. Если MoveTo был логически в блоке "try", то вызов Dispose в верхней части IEnumerator будет семантически сделать блок finally в MoveTo. Затем после этого будет выполнен блок finally в OrbLogic. Обратите внимание, что это простой случай, и случаи намного сложнее.

Я пытаюсь реализовать подобное поведение в версии async/await. Код этой версии выглядит следующим образом (ошибка проверки опущена):

public class Coroutine
{
    private readonly CoroutineSynchronizationContext _syncContext = new CoroutineSynchronizationContext();

    public Coroutine(Action action)
    {
        if (action == null)
            throw new ArgumentNullException("action");

        _syncContext.Next = new CoroutineSynchronizationContext.Continuation(state => action(), null);
    }

    public bool IsFinished { get { return !_syncContext.Next.HasValue; } }

    public void Tick()
    {
        if (IsFinished)
            throw new InvalidOperationException("Cannot resume Coroutine that has finished");

        SynchronizationContext curContext = SynchronizationContext.Current;
        try
        {
            SynchronizationContext.SetSynchronizationContext(_syncContext);

            // Next is guaranteed to have value because of the IsFinished check
            Debug.Assert(_syncContext.Next.HasValue);

            // Invoke next continuation
            var next = _syncContext.Next.Value;
            _syncContext.Next = null;

            next.Invoke();
        }
        finally
        {
            SynchronizationContext.SetSynchronizationContext(curContext);
        }
    }
}

public class CoroutineSynchronizationContext : SynchronizationContext
{
    internal struct Continuation
    {
        public Continuation(SendOrPostCallback callback, object state)
        {
            Callback = callback;
            State = state;
        }

        public SendOrPostCallback Callback;
        public object State;

        public void Invoke()
        {
            Callback(State);
        }
    }

    internal Continuation? Next { get; set; }

    public override void Post(SendOrPostCallback callback, object state)
    {
        if (callback == null)
            throw new ArgumentNullException("callback");

        if (Current != this)
            throw new InvalidOperationException("Cannot Post to CoroutineSynchronizationContext from different thread!");

        Next = new Continuation(callback, state);
    }

    public override void Send(SendOrPostCallback d, object state)
    {
        throw new NotSupportedException();
    }

    public override int Wait(IntPtr[] waitHandles, bool waitAll, int millisecondsTimeout)
    {
        throw new NotSupportedException();
    }

    public override SynchronizationContext CreateCopy()
    {
        throw new NotSupportedException();
    }
}

Я не вижу, как реализовать подобное поведение версии итератора, используя это. Извините заранее за длинный код!

EDIT 2: Новый метод работает. Это позволяет мне делать такие вещи, как:

private static async Task Test()
{
    // Second resume
    await Sleep(1000);
    // Unknown how many resumes
}

private static async Task Main()
{
    // First resume
    await Coroutine.Yield();
    // Second resume
    await Test();
}

Это обеспечивает очень хороший способ создания ИИ для игр.

Ответ 1

Я использую итераторы С# в качестве замены сопрограмм, и это было отлично работает. Я хочу переключиться на async/wait, так как я думаю, что синтаксис чище, и это дает мне безопасность типа...

ИМО, это очень интересный вопрос, хотя мне потребовалось некоторое время, чтобы его полностью понять. Возможно, вы не представили достаточного примера кода для иллюстрации концепции. Полное приложение поможет, поэтому я сначала попытаюсь заполнить этот пробел. Следующий код иллюстрирует шаблон использования, как я его понял, пожалуйста, исправьте меня, если я ошибаюсь:

using System;
using System.Collections;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApplication
{
    // https://stackoverflow.com/q/22852251/1768303

    public class Program
    {
        class Resource : IDisposable
        {
            public void Dispose()
            {
                Console.WriteLine("Resource.Dispose");
            }

            ~Resource()
            {
                Console.WriteLine("~Resource");
            }
        }

        private IEnumerator Sleep(int milliseconds)
        {
            using (var resource = new Resource())
            {
                Stopwatch timer = Stopwatch.StartNew();
                do
                {
                    yield return null;
                }
                while (timer.ElapsedMilliseconds < milliseconds);
            }
        }

        void EnumeratorTest()
        {
            var enumerator = Sleep(100);
            enumerator.MoveNext();
            Thread.Sleep(500);
            //while (e.MoveNext());
            ((IDisposable)enumerator).Dispose();
        }

        public static void Main(string[] args)
        {
            new Program().EnumeratorTest();
            GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true);
            GC.WaitForPendingFinalizers();
            Console.ReadLine();
        }
    }
}

Здесь Resource.Dispose вызывается из-за ((IDisposable)enumerator).Dispose(). Если мы не назовем enumerator.Dispose(), тогда нам придется раскомментировать //while (e.MoveNext()); и пусть итератор закончит изящно, для правильного размотки.

Теперь я считаю, что лучший способ реализовать это с помощью async/await - использовать пользовательский awaiter

using System;
using System.Collections;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApplication
{
    // https://stackoverflow.com/q/22852251/1768303
    public class Program
    {
        class Resource : IDisposable
        {
            public void Dispose()
            {
                Console.WriteLine("Resource.Dispose");
            }

            ~Resource()
            {
                Console.WriteLine("~Resource");
            }
        }

        async Task SleepAsync(int milliseconds, Awaiter awaiter)
        {
            using (var resource = new Resource())
            {
                Stopwatch timer = Stopwatch.StartNew();
                do
                {
                    await awaiter;
                }
                while (timer.ElapsedMilliseconds < milliseconds);
            }
            Console.WriteLine("Exit SleepAsync");
        }

        void AwaiterTest()
        {
            var awaiter = new Awaiter();
            var task = SleepAsync(100, awaiter);
            awaiter.MoveNext();
            Thread.Sleep(500);

            //while (awaiter.MoveNext()) ;
            awaiter.Dispose();
            task.Dispose();
        }

        public static void Main(string[] args)
        {
            new Program().AwaiterTest();
            GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true);
            GC.WaitForPendingFinalizers();
            Console.ReadLine();
        }

        // custom awaiter
        public class Awaiter :
            System.Runtime.CompilerServices.INotifyCompletion,
            IDisposable
        {
            Action _continuation;
            readonly CancellationTokenSource _cts = new CancellationTokenSource();

            public Awaiter()
            {
                Console.WriteLine("Awaiter()");
            }

            ~Awaiter()
            {
                Console.WriteLine("~Awaiter()");
            }

            public void Cancel()
            {
                _cts.Cancel();
            }

            // let the client observe cancellation
            public CancellationToken Token { get { return _cts.Token; } }

            // resume after await, called upon external event
            public bool MoveNext()
            {
                if (_continuation == null)
                    return false;

                var continuation = _continuation;
                _continuation = null;
                continuation();
                return _continuation != null;
            }

            // custom Awaiter methods
            public Awaiter GetAwaiter()
            {
                return this;
            }

            public bool IsCompleted
            {
                get { return false; }
            }

            public void GetResult()
            {
                this.Token.ThrowIfCancellationRequested();
            }

            // INotifyCompletion
            public void OnCompleted(Action continuation)
            {
                _continuation = continuation;
            }

            // IDispose
            public void Dispose()
            {
                Console.WriteLine("Awaiter.Dispose()");
                if (_continuation != null)
                {
                    Cancel();
                    MoveNext();
                }
            }
        }
    }
}

Когда придет время расслабиться, я запрошу отмену внутри Awaiter.Dispose и выведет конечный автомат на следующий шаг (если есть ожидающее продолжение). Это приводит к наблюдению за отменой внутри Awaiter.GetResult (который вызывается кодом, генерируемым компилятором). Это выдает TaskCanceledException и далее разматывает оператор using. Таким образом, Resource получает надлежащее распоряжение. Наконец, задача переходит в отмененное состояние (task.IsCancelled == true).

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

Это действительно даст вам больше свободы, чем при использовании IEnumerator/yield. Вы можете использовать try/catch внутри вашей логики coroutine, и вы можете наблюдать за исключениями, отменой и результатом непосредственно через объект Task.

Обновлено, AFAIK нет аналогии для итератора, сгенерированного IDispose, когда дело доходит до состояния async. Вам действительно нужно управлять конечным автоматом до конца, когда вы хотите его отменить/отключить. Если вы хотите объяснить какое-то небрежное использование try/catch, препятствующее аннулированию, я думаю, что лучшее, что вы могли бы сделать, это проверить, не содержит ли _continuation внутри Awaiter.Cancel (после MoveNext) и выбросить фатальное исключение вне диапазона (используя вспомогательный метод async void).