コルーチンの代わりとしての async/await 質問する

コルーチンの代わりとしての async/await 質問する

私はコルーチンの代わりに C# イテレータを使用しており、うまく機能しています。構文がより明確で、型の安全性が得られるため、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");
    }
}

コルーチンは、スタック内のすべての列挙子を追跡することで機能します。C# コンパイラは、列挙が終了していなくても、 でDispose'finally' ブロックが正しく呼び出されるようにするために呼び出すことができる関数を生成します。このようにして、スタック上のすべてのオブジェクトCoroutineMainを呼び出すことで、コルーチンを正常に停止し、finally ブロックが呼び出されることを確認できます。これは基本的に手動でのアンワインドです。DisposeIEnumerator

async/await を使用して実装を記述したとき、私が間違っていなければ、この機能は失われるだろうと認識しました。その後、他のコルーチン ソリューションを調べたところ、Jon Skeet のバージョンでもこの機能が何らかの方法で処理されているようには見えませんでした。

これを処理する方法として私が思いつくのは、独自のカスタム 'Yield' 関数を用意して、コルーチンが停止したかどうかをチェックし、そのことを示す例外を発生させることだけです。これが上位に伝播し、finally ブロックを実行してから、ルート付近のどこかでキャッチされます。ただし、サードパーティのコードが例外をキャッチする可能性があるため、これはあまり良い方法ではありません。

私は何かを誤解しているのでしょうか。また、これをもっと簡単な方法で行うことは可能でしょうか。それとも、これを行うには例外的な方法をとる必要があるのでしょうか。

編集: 詳細情報/コードが要求されたので、ここにいくつか示します。これは単一のスレッドでのみ実行されるため、スレッド化は行われません。現在のコルーチン実装は次のようになります (簡略化されていますが、この単純なケースでは機能します)。

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 列挙子のインスタンスをコルーチンに渡して実行することで作成されます。これにより、フレームごとにコルーチンをティックできます。プレイヤーがオーブを倒した場合、コルーチンは実行を完了しません。; Dispose はコルーチンで単純に呼び出されます。MoveTo論理的に 'try' ブロック内にある場合、先頭で Dispose を呼び出すとIEnumerator、意味的にfinallyブロックがMoveTo実行されます。その後、finallyOrbLogic 内のブロックが実行されます。これは単純なケースであり、ケースははるかに複雑であることに注意してください。

同様の動作を 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();
    }
}

これを使用してイテレータ バージョンと同様の動作を実装する方法がわかりません。コードが長くなってしまったことをあらかじめお詫びします。

編集 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();
}

これは、ゲーム用の AI を構築するための非常に優れた方法を提供します。

ベストアンサー1

更新しました、フォローアップのブログ投稿:C# 8.0 と IAsyncEnumerable を使用した非同期コルーチン


私はコルーチンの代わりに C# イテレータを使用しており、うまく機能しています。構文がより明確で、型の安全性も得られるため、async/await に切り替えたいと思っています...

私にとって、これは非常に興味深い質問ですが、完全に理解するまでに少し時間がかかりました。おそらく、概念を説明するのに十分なサンプル コードが提供されなかったのでしょう。完全なアプリがあれば役立つので、まずはこのギャップを埋めてみます。次のコードは、私が理解した使用パターンを示しています。間違っていたら訂正してください。

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カスタムアウェイター:

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) に遷移します。

私の意見では、これは現在のスレッドにカスタム同期コンテキストをインストールするよりもシンプルで直接的なアプローチです。マルチスレッドに簡単に適応できます(詳細はここ)。

IEnumeratorこれにより、 / を使用する場合よりも自由度が高くなりますyield。コルーチン ロジック内で使用できtry/catch、例外、キャンセル、結果をオブジェクトを介して直接監視できますTask

更新しましたIDispose、私の知る限り、状態マシンに関しては、イテレータが生成した に類似するものはありませんasync。キャンセル/巻き戻しをしたい場合は、状態マシンを終了させる必要があります。キャンセルを防止するための不注意な使用を考慮したい場合は、 の内部( の後)が null でないかどうかを確認し、致命的な例外をスローするのtry/catchが最善だと思います。_continuationAwaiter.CancelMoveNext帯域外(ヘルパーasync voidメソッドを使用)。

おすすめ記事