😵

async/awaitがわからない

2022/08/27に公開

Unity開発では、日頃からTaskやUniTaskを使う機会が多く、便利に使わせてもらっていますが、async/awaitが何なのかよく理解しないまま雰囲気で使っていたので、async/awaitを使ったAwaitableパターンというものを独自実装をして検証してみました。

調べているうちに、こちらの大変参考になりそうな記事を見つけたのですが、自分にはまだ早すぎて理解が追いつけなかったので、簡単なテストコードの理解から始めることにしましたw

https://zenn.dev/meson/articles/implement-awaiter-for-unity

テストコード

Awaitableパターンの独自実装は、こちらのサイトを参考にしました。

https://atmarkit.itmedia.co.jp/ait/articles/1211/02/news066.html

どうやら、Awaitableパターンとは、特定のインターフェースや、メソッドを実装したクラスを、コンパイラが認識すると、async/await演算子を使えるようになる仕組みのようです。

試しに、現在時刻を非同期で取得して表示するテストコードを書いてみました。

using System;
using System.Runtime.CompilerServices;
using System.Threading;
using UnityEngine;

namespace AwaitTest
{
    // テスト用のMonoBehaviour
    public class MyAwaiterTest : MonoBehaviour
    {
        private async void Start()
        {
            Log("Start");
            var result = await MyAwaitable<DateTime>.StartMyWork(() =>
            {
                Log("Working");
                Thread.Sleep(5000);
                return DateTime.Now;
            });
            Log("End");
            Log($"Result: {result}");
        }

        public static void Log(string str)
        {
            Debug.Log($"[{Thread.CurrentThread.ManagedThreadId}] {str}");
        }
    }
    
    public class MyAwaitable<T>
    {
        private MyAwaitable() {}

        // 完了フラグ
        public bool MyIsCompleted { get; private set; }
        
        // 実行結果を保持する。
        public T MyResult { get; private set; }

        // 処理完了後に呼ぶデリゲート
        private Action MyOnCompleted { get; set; }

        // Awaitableに必須のメソッド。名前は変えられない。
        public MyAwaiter<T> GetAwaiter()
        {
            MyAwaiterTest.Log($"GetAwaiter");
            return new MyAwaiter<T>(this);
        }
        
        private void DoMyWork(Func<T> action)
        {
            MyAwaiterTest.Log($"DoMyWork");
            ThreadPool.QueueUserWorkItem(_ =>
            {
                MyAwaiterTest.Log($"ExecuteQueue: Start");
                MyResult = action();
                MyIsCompleted = true;
                MyOnCompleted?.Invoke();
                MyAwaiterTest.Log($"ExecuteQueue: End");
            });
        }

        // 完了時に呼び出される処理の登録。
        public void MyContinueWith(Action action)
        {
            MyAwaiterTest.Log($"MyContinueWith: SetAction");
            MyOnCompleted = action;
            if (MyIsCompleted && MyOnCompleted != null)
            {
                // すでに完了している場合は即時呼び出し
                MyAwaiterTest.Log($"MyContinueWith: ExecuteAction");
                MyOnCompleted();
            }
        }

        // 非同期処理を開始する。
        public static MyAwaitable<T> StartMyWork(Func<T> action)
        {
            MyAwaiterTest.Log($"StartMyWork");
            var awaitable = new MyAwaitable<T>();
            awaitable.DoMyWork(action);
            return awaitable;
        }
    }
    
    public class MyAwaiter<T> : INotifyCompletion
    {
        private readonly MyAwaitable<T> _myTarget;

        public MyAwaiter(MyAwaitable<T> myTarget)
        {
            _myTarget = myTarget;
        }
        
        // Awaiterに必須のプロパティ。処理が完了しているかどうかを返す。名前は変えられない。
        public bool IsCompleted
        {
            get
            {
                MyAwaiterTest.Log($"IsCompleted: {_myTarget.MyIsCompleted}");
                return _myTarget.MyIsCompleted;
            }
        }

        // Awaiterに必須のメソッド。処理の結果を返す。名前は変えられない。
        public T GetResult()
        {
            MyAwaiterTest.Log($"GetResult");
            return _myTarget.MyResult;
        }
        
        // Awaiterに必須のメソッド。INotifyCompletionの実装。
        // 渡されたデリゲートを、対象となる非同期処理の完了時にコールバックされるように登録する。
        public void OnCompleted(Action continuation)
        {
            MyAwaiterTest.Log($"OnCompleted");
            
            // 実行スレッドの同期コンテキスト上で継続処理を実行
            var context = SynchronizationContext.Current;
            _myTarget.MyContinueWith(() =>
            {
                MyAwaiterTest.Log($"MyContinueWith: Start");
                context.Post(_ =>
                {
                    MyAwaiterTest.Log($"MyContinueWith: Post");
                    continuation();
                }, null);
            });
        }
    }
}

My~というメソッドやプロパティは、自由に変更可能な名前です。GetAwaiterIsCompletedGetResultなどは、インターフェースで定義はされていませんが、名前を変えると、Awaitableパターンと認識されずにコンパイルエラーになります。なんだか違和感がありますが、参考サイトで触れられているようにダック・タイピング的な制約になっているようです。

実行結果

適当なGameObjectにMyAwaiterTestをアタッチして、Unityエディタから実行すると、このようなログが取得できました。緑背景のログは、別スレッドのログです。

別スレッドに処理を逃しつつ、裏で後続処理の登録や、処理の完了確認を行っているようでした。ログのスタックトレースを確認してみると、System.Runtime.CompilerServices.AsyncVoidMethodBuilderというクラスがAwaiterの取得を行ったり、IsCompletedフラグを確認していました。

ログを取得することによって、Awaitableパターンの処理の流れを大雑把に把握することができました。次の疑問は、AsyncVoidMethodBuilderとは一体何者なのか…

<日記はここで終わっている>

AsyncVoidMethodBuilderの仕組み

検証環境

Unity: 2021.3.8f1
OS: Windows10

Discussion