LoginSignup
2
1

「await 出来たらする」メソッド

Last updated at Posted at 2024-03-04

更新履歴: ValueTask<T> と独自の Task-like への完全対応(仮)
 

待てたら待つメソッドがなぜ必要かというと、ジェネリック型の T が待てる型なら待ちたい、という需要から。課題はあるが Unity でも動作する。

待てたら待つ

普段使い出来るものであれば、以下のオーバーロードを定義しておけば解決できる。

(パッと見一つのメソッドで処理出来ているように見えるだけで普段使いする意味はない)

サンプルコード
async static Task AwaitIfPossible(Task t) => await t;
async static Task AwaitIfPossible<T>(Task<T> t) => await t;
async static ValueTask AwaitIfPossible(ValueTask t) => await t;
async static ValueTask AwaitIfPossible<T>(ValueTask<T> t) => await t;

// ※ 型引数がないジェネリック型の呼び出しはインターフェイスよりも <T>(T value) が優先されてしまうので
// インターフェイスのオーバーロードが選ばれるようにするには <T>(T value) ではなく (object) にする必要がある
//async static ValueTask AwaitIfPossible<T>(T value) { }
async static ValueTask AwaitIfPossible(object value) { }  // 警告が出るがしょうがない

// 独自の Task-like に対応するためのインターフェイス
public interface IAwaitableContract<T>  // <-- マウスオーバーで [待機可能] と出る!
{
    TaskAwaiter<T> GetAwaiter();
}

async static Task AwaitIfPossible<T>(IAwaitableContract<T> i) => await i;  // 待てる!!

※ 外部由来の Task-like 型には対応できない(ダックタイピングなので Reflection で GetAwaiter の戻り値に AsyncMethodBuilder 属性が付与されているか確認?)

利用方法

await AwaitIfPossible(0);
await AwaitIfPossible(10f);
await AwaitIfPossible("Hello, world.");
await AwaitIfPossible(anything);

待てるか判定する

待てたら待つと同様のオーバーロードを定義しておけば良い。

サンプルコード
static bool IsAwaitable(Task _) => true;
static bool IsAwaitable<T>(Task<T> _) => true;
static bool IsAwaitable(ValueTask _) => true;
static bool IsAwaitable<T>(ValueTask<T> _) => true;
static bool IsAwaitable<T>(IAwaitableContract<T> _) => true;
static bool IsAwaitable(object _) => false;

ジェネリック型での利用時に癖がある

「待てたら待つ」メソッドが必要な理由はジェネリック型の Tawait 出来る場合はする為なのだが、先に結論から言えば上記のメソッドを定義しただけでは解決できない。

テストコード

実行環境: https://dotnetfiddle.net/

using System;
using System.Threading.Tasks;

public class Program
{
    static void Which(Task v) => Console.WriteLine("Task");
    static void Which<T>(Task<T> v) => Console.WriteLine("Task<T>");
    static void Which(ValueTask v) => Console.WriteLine("ValueTask");
    static void Which<T>(ValueTask<T> v) => Console.WriteLine("ValueTask<T>");
    static void Which(object v) => Console.WriteLine("object");
    
    class Generic<T>
    {
        static Generic() => Which(default(T)!);
        public Generic() => Console.WriteLine("default(T).GetType: " + default(T)!?.GetType());
        
        public void DoIt(T value)
        {
            Which(value);
        }
    }

    public static void Main()
    {
        Console.WriteLine("// ctor overload");
        var i = new Generic<int>();
        var ti = new Generic<Task<int>>();
        var vti = new Generic<ValueTask<int>>();
        Console.WriteLine();

        Console.WriteLine("// default(T) overload in Main()");
        Which(default(int)!);
        Which(default(Task<int>)!);
        Which(default(ValueTask<int>)!);
        Console.WriteLine();

        Console.WriteLine("// DoIt overload");
        i.DoIt(0);
        ti.DoIt(Task.FromResult<int>(0));
        vti.DoIt(ValueTask.FromResult<int>(0));
        Console.WriteLine();
    }
}

実行結果

// ctor overload
object
default(T).GetType: System.Int32
object
default(T).GetType: 
object
default(T).GetType: System.Threading.Tasks.ValueTask`1[System.Int32]

// default(T) overload in Main()
object
Task<T>
ValueTask<T>

// DoIt overload
object
object
object

制約のない T 型引数

結果を見るに、型制約の無いジェネリック型の Tobject または型制約のないジェネリック型メソッドのオーバーロードを呼ぶようになっていると思われ、加えてコンパイル時に呼び出し先が確定してしまっているように見受けられる。

一方で default(T) は適切に自身の型をランタイム時に理解しているのでちょっと腹が立つ。

ValueTask<T> 構造体

それならば、と object のオーバーロードを以下に書き換えると別の問題が発生する。

とにかく ValueTask<T> の存在が厄介で、構造体だから継承関係も無いし IEquatable<ValueTask<T>> しか実装してないしでどうしようもない。型付けされたインスタンスを渡す以外でまともに判定することは不可能に近い。

async static Task AwaitIfPossible(object value)
{
    if (value is Task task)  // Task & Task<T>
        await Task;
    
    else if (value is ValueTask vt)
        await vt;

    // ValueTask<T> の T が不明な状態で Reflection 無しで簡単・安全にキャストする手段がない
    // Task<T> と違って構造体だから継承関係もなく IEquatable 以外のインターフェイスの実装もない
    else if (typeof(ValueTaskT<>).IsAssignableFrom(value.GetType()))
    {
        // このパスに到達することはない
    }
}

// 型引数なしのジェネリックはそのまま「型引数なしのジェネリック」という型になっている
Console.WriteLine(typeof(ValueTask<>).ToString());     //...ValueTask`1[TResult]
Console.WriteLine(typeof(ValueTask<int>).ToString());  //...ValueTask`1[System.Int32]

実行時にオーバーロードを解決する

ガッツリ System.Reflection するのもアリだが、出来れば既に定義されている ValueTask<T> のオーバーロードを呼ぶ形で納めたい。

簡単そうなのと、await による IAsyncStateMachine 絡み(?)の処理の諸々の責任を持ちたくないというのがその理由

ValueTask<T> の持つ、早期リターン等すぐに結果が出る処理なら Task よりも良い(マネージドオブジェクトを確保しない)というメリットについては考えないものとして、実装は以下のようになった。

using System;
using System.Threading.Tasks;
using System.Reflection;

public class Program
{
    public static void Main()
    {
        RunVTMethod(0);
        RunVTMethod("Hello, world.");
        RunVTMethod(ValueTask.FromResult<int>(0));
        RunVTMethod(ValueTask.FromResult<float>(0));
    }
    
    async public static void AwaitIfPossible<T>(ValueTask<T> t)
    {
        Console.WriteLine("AwaitIfPossible: " + typeof(T));
        await t;
    }

    static MethodInfo VTGenMethod;
    static void RunVTMethod(object value)
    {
        if (VTGenMethod == null)
        {
            foreach (var m in typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.Public))
            {
                var parameters = m.GetParameters();
                foreach (var p in parameters)
                {
                    if (p.ParameterType.Name == typeof(ValueTask<>).Name)
                    {
                        VTGenMethod = m;
                        goto DONE;
                    }
                }
                continue;
            DONE:
                break;
            }
        }
        if (VTGenMethod == null)
            throw new Exception("no method found");

        var t = value.GetType();
        if (t.Name != typeof(ValueTask<>).Name)
        {
            Console.WriteLine("Unsupported: " + t.ToString());
            return;
        }

        var gt = t.GetGenericArguments()[0];
        var gm = VTGenMethod.MakeGenericMethod(gt);
        
        gm.Invoke(null, new object[]{ value });
    }
}

実行結果

Unsupported: System.Int32
Unsupported: System.String
AwaitIfPossible: System.Int32
AwaitIfPossible: System.Single

動いた。

Unity で動くのか問題

Unity でも System.Reflection は問題なく動くという公式文書がある。(URLは失念)

Preserve(UnityEngine.Scripting.PreserveAttribute)で未使用のオーバーロードがビルド時に削除されないようにするのは当然として、await の実行まで上手く行くかどうか。

試してみる。

Unity エディター / Mono ビルド

少し修正を加えた以下のコードは、Unity エディターと Mono バックエンドでビルドしたアプリなら問題なく動作する。

約10秒後にログが出る/ビルドしたアプリなら終了する

Task / TaskAwaiter<T> を返さない同期メソッドなのでコールバックで無理やり終了を待つコードになっている。

テストコード

実行環境: Unity 2021 LTS

using System;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public class AwaitableBehaviour : MonoBehaviour
{
    async void Start()
    {
        var intSource = new TaskCompletionSource<int>();
        var floatSource = new TaskCompletionSource<float>();

        int count = 4;
        ValueTaskAwaiter.Await(0, () => count--);
        ValueTaskAwaiter.Await("Hello, world.", () => count--);
        ValueTaskAwaiter.Await(new ValueTask<int>(intSource.Task), () => count--);
        ValueTaskAwaiter.Await(new ValueTask<float>(floatSource.Task), () => count--);

        // 別スレッドで10秒後にタスクを完了させる
        Task.Run(() =>
        {
            Thread.Sleep(10000);
            intSource.SetResult(0);
            floatSource.SetResult(0);
        });


        Debug.Log("Start waiting...");

        // 待つ!!
        while (count != 0)
        {
            transform.position = UnityEngine.Random.insideUnitSphere * 0.025f;  // 非同期動作の確認
            await Task.Yield();  // await が実際には「待つ」じゃなく yield return と同等だというのが分かりやすい
        }

        gameObject.SetActive(false);
        Debug.Log("Application Quit");
        Application.Quit(0);
    }


    public static class ValueTaskAwaiter
    {
        async public static ValueTask AwaitImpl<T>(ValueTask<T> t, Action onFinished)
        {
            Debug.Log("AwaitIfPossible Start: " + typeof(T));
            await t;

            Debug.Log("AwaitIfPossible Done: " + typeof(T));
            onFinished?.Invoke();
        }

        private static MethodInfo ValueTaskGenericMethod;
        public static void Await(object value, Action onFinished)
        {
            if (value is null)
                return;

            if (ValueTaskGenericMethod == null)
            {
                foreach (var m in typeof(ValueTaskAwaiter).GetMethods(BindingFlags.Static | BindingFlags.Public))
                {
                    var parameters = m.GetParameters();
                    foreach (var p in parameters)
                    {
                        if (p.ParameterType.Name == typeof(ValueTask<>).Name)
                        {
                            ValueTaskGenericMethod = m;
                            goto DONE;
                        }
                    }
                    continue;
                DONE:
                    break;
                }
            }

            if (ValueTaskGenericMethod == null)
            {
                throw new Exception("no method found");
            }

            var t = value.GetType();
            if (t.Name != typeof(ValueTask<>).Name)
            {
                Debug.Log("Unsupported: " + t.ToString());
                onFinished?.Invoke();
                return;
            }


            var gt = t.GetGenericArguments()[0];
            var gm = ValueTaskGenericMethod.MakeGenericMethod(gt);

            gm.Invoke(null, new object[] { value, onFinished });
        }
    }

}

IL2CPP で発生するエラー

上記のコードは IL2CPP でビルドは出来るが実行すると以下のエラーを吐く。

ExecutionEngineException: Attempting to call method 'AwaitableBehaviour+ValueTaskAwaiter::AwaitValueTask<System.Int32>' for which no ahead of time (AOT) code was generated.

エラーを見ての通り、メソッドがビルドに含まれていないので必要な型引数を持ったメソッドを明示的に呼び出しビルドに含める必要がある。

[UnityEngine.Scripting.Preserve]
static void IncludeMethodsInBuild()
{
    AwaitImpl<int>(default!, default!);    // 型引数 T は ValueTask<T> なので使えない
    AwaitImpl<float>(default!, default!);  // 必要なオーバーロード全てを明示的に呼ぶ必要がある
}

ValueTask<T> の型引数問題

ここで厄介なのが、<T>(T task) だと await 不可能なので、メソッドのシグネチャは <T>(ValueTask<T>) でなければならない点。

ジェネリック型に必要な型引数を持ったメソッドをビルドに含める目的で

[Preserve] static void PreserveMethod() => AwaitImpl<T>(default!, default!);

を追加したとしても、待ちたいのは T であり、しかしビルドに含まれるのは ValueTask<ValueTask<T>> を対象としたメソッドになってしまうのだ。

加えて ValueTask<T> は構造体で型引数の制約に使うことも出来ないので、ちょっと手の打ちようがない状態。

利用者側に登録を求める方式

利用者側に登録の責任を押しつけ必要なメソッドを IL2CPP ビルドに含める方法もある。

public static void RequestUseOfValueTaskType<T>() => AwaitImpl<T>(default!, default!);

// 利用者側
class MyClass<T>
{
    ValueTask<T> myValue;

    static MyClass()
    {
        RequestUseOfValueTaskType<T>();  // 👈 登録しないと動作しません!くれぐれもお忘れなきよう!!
    }
}

IL2CPP 対応案(1)

ランタイム上でのオーバーロードの解決という手段は諦め AsTask() でタスク型に変換する形に変え、エディター上のテストが上手く行ったのでビルドしてみると、、、上手く行かず。

(特にエラーは出ないが await が機能せず即終了する)

テストコード

実行環境: Unity 2021 LTS

using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public class AwaitableBehaviour : MonoBehaviour
{
    async void Start()
    {
        var intSource = new TaskCompletionSource<int>();
        var floatSource = new TaskCompletionSource<float>();

        var a = ValueTaskAwaiter.Await(0);
        var b = ValueTaskAwaiter.Await("Hello, world.");
        var c = ValueTaskAwaiter.Await(new ValueTask<int>(intSource.Task));
        var d = ValueTaskAwaiter.Await(new ValueTask<float>(floatSource.Task));

        // 別スレッドで10秒後にタスクを完了させる
        Task.Factory.StartNew(() =>
            {
                Thread.Sleep(10000);
                intSource.SetResult(0);
                floatSource.SetResult(0);
            },
            CancellationToken.None,
            TaskCreationOptions.DenyChildAttach,
            TaskScheduler.Default);

        Debug.Log("Start waiting...");

        await Task.WhenAll(a, b, c, d);

        gameObject.SetActive(false);
        Debug.Log("Application Quit");
        Application.Quit(0);
    }

    void Update()
    {
        transform.position = UnityEngine.Random.insideUnitSphere * 0.025f;  // 非同期動作か確認の為
    }


    public class ValueTaskAwaiter
    {
        public static async Task Await(object value)
        {
            if (value is null)
            {
                return;
            }

            var valueType = value.GetType();
            if (valueType.Name != typeof(ValueTask<>).Name)
            {
                Debug.LogWarning("Unsupported: " + value.GetType().ToString());
                return;
            }

            foreach (var AsTask in valueType.GetMethods())
            {
                if (AsTask.Name != nameof(AsTask))
                    continue;
                Debug.Log(AsTask);

                var taskOb = AsTask.Invoke(value, null);
                if (taskOb is Task t)
                {
                    Debug.Log(t.ToString());
                    await t;
                    break;
                }
            }
        }

    }

}

以下のテストはまともに動作するためタスク型それ自体に問題はない。

var intSource = new TaskCompletionSource<int>();

// 別スレッドで10秒後にタスクを完了させる
Task.Factory.StartNew(() =>
    {
        Thread.Sleep(10000);
        intSource.SetResult(0);
        floatSource.SetResult(0);
    },
    CancellationToken.None,
    TaskCreationOptions.DenyChildAttach,
    TaskScheduler.Default);

if (intSource.Task is Task task)
{
    await task;
    goto QUIT;
}

エラーが出ず処理自体は先に進んでいるのは謎だが、先の例と同様 AsTask が IL2CPP ビルドに含まれていないことが原因と思われる。そして下記を加えると予想通り成功する。

が、すでに述べたように「T は待てるのか」の判定が必要なので、機械的にこのコードをビルドに含めることは難しい。

var ti = new ValueTask<int>(intSource.Task).AsTask();
var tf = new ValueTask<float>(floatSource.Task).AsTask();

IL2CPP 対応案(2)

紆余曲折あり、結局ダックタイピングで用いられる GetAwaiter を使う実装に変更し async メソッド化と型引数 T 問題は解決できた。どうしても Action の生成とアクションへの bool 値の取り込みは避けることが出来なかったが、まあ良いだろう。

GetAwaiter はメソッド名しか見ていないのに INotifyCompletion はしっかりと定義されているのは、こういう用途を見据えてだろうか。IsCompleted も含めてくれよと思うが、一方で呼び出し回数が読めず尋常じゃないことになりそうだから無くて正解なのかもしれない、とも。

シグネチャは <T>(T value) に戻し、インターフェイスはオーバーロードではなくメソッド内に分岐を含める、というのが値型のボックス化を防ぐ意味でも良さそうだという結論。

ただこの実装には結構深刻な問題があって、それはエラーが発生したとしても ValueTask<T> は成功裏に終了するという非同期メソッドになってしまっている事。

つまり、避けたかったインターフェイス/型安全性の無いタスク回りの処理の責任を負う必要があるということだ。

await Task.Yield()return しか行っていないのは ValueTask<T> のみなので他のコードパスは問題ない。Task 型に変え例外をスローすれば適切に IsFaulted 他がセットされる。

Managed Stripping Level が Medium かそれ以上だと上手く動かない。Preserve を追加すると動く可能性がある。

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

public class AwaitableBehaviour : MonoBehaviour
{
    async void Start()
    {
        var intSource = new TaskCompletionSource<int>();
        var floatSource = new TaskCompletionSource<float>();

        // 別スレッドで10秒後にタスクを完了させる
        Task.Delay(10000).ContinueWith(_ =>
            {
                Thread.Sleep(10000);
                intSource.SetResult(0);
                floatSource.SetResult(0);
            });

        var a = ObjectAwaiter.Await(0);
        var b = ObjectAwaiter.Await("Hello, world.");
        var c = ObjectAwaiter.Await(new ValueTask<int>(intSource.Task));
        var d = ObjectAwaiter.Await(new ValueTask<float>(floatSource.Task));

        Debug.Log("Start waiting...");

        // 待つ!!
        while (!Task.WhenAll(a, b, c, d).IsCompleted)
        {
            transform.position = UnityEngine.Random.insideUnitSphere * 0.025f;  // 非同期動作か確認の為
            await Task.Yield();  // await が実際には「待つ」じゃなく yield return と同等だというのが分かりやすい
        }

        gameObject.SetActive(false);
        Debug.Log("Application Quit");
        Application.Quit(0);
    }


    public static class ObjectAwaiter
    {
        public static async Task Await<T>(T value)
        {
            if (value is Task t)
            {
                await t;
                return;
            }
            else if (value is ValueTask vt)
            {
                await vt;
                return;
            }
            else if (value is not null)
            {
                var valueType = value.GetType();
                if (valueType.Name == typeof(ValueTask<>).Name)
                {
                    // Result を実行しても良いが同期処理なので進むべき処理が進まない
                    // (オブジェクトの位置をランダムに動かすパスにたどり着かない)
                    foreach (var GetAwaiter in valueType.GetMethods())
                    {
                        if (GetAwaiter.Name != nameof(GetAwaiter))
                            continue;
                        //Debug.Log(GetAwaiter);

                        var awaiter = GetAwaiter.Invoke(value, null);
                        if (awaiter is not INotifyCompletion comp)
                            continue;

                        bool isRunning = true;
                        comp.OnCompleted(() => isRunning = false);
                        while (isRunning)
                        {
                            await Task.Yield();
                        }
                        //onFinished?.Invoke();
                        return;
                    }
                }
            }

            Debug.LogWarning("Unsupported: " + value.GetType().ToString());
            //onFinished?.Invoke();
        }
    }
}

IL2CPP 対応案(TODO)

次項参照。

--

例外やキャンセルをどう扱うかは悩ましい。やはり事前に宣言した ValueTask<T> 向けの async/await メソッドのオーバーロードをランタイム中に呼び出すのが一番安全に思える。

明示的にメソッドを呼ばなければならない問題さえどうにかすればいいので、link.xml を自動生成するか ValueTask<T> の型引数を Reflection で調べて必要なメソッドを取り込むための呼び出しを一覧する?

使われている全ての ValueTask<T> のリストアップするのは面倒ではあるが、全てのアセンブリ内のクラスのフィールド/プロパティーを舐めるだけではあるので不可能でもない。後はそこから T を引くだけ。

IL2CPP 対応案(結果の型 T に依存しない)

待てたら待つを実現するにあたって何が障害になっているのかを改めて考えてみると、ValueTask<T>(または独自の Task-like 型)が持っている結果の型 T の存在に行き着く。

Task<T> が問題とならなかったのは素直に継承関係を持ったクラス実装になっていたからであり、結果アリと結果ナシのクラスから不要なモノを排除したい、使い勝手よりも最適化、と考えた場合には継承しないという選択肢もあり得るし async/await の仕様もそれを許容している。もしタスク型に継承関係が無かったのなら ValueTask<T> 同様、安全なキャストが不可能という問題に直面していただろう。

(冒頭で示した IAwaitableContract<T> インターフェイスはこの問題を抱えている)

よって結果の型 T に依存せずに待てたら待つ、を実現するのが一番ベストな選択肢だ。

IAwaitableContract (reviced)

Task Task<T> ValueTask ValueTask<T> のインターフェイスの中から待てたら待つの為に必要でかつ結果の型に依存せず、独自の Task-like でも実装可能であろうメンバーを抽出してみると以下のようになる。

public interface IAwaitableContract
{
    // 即座に成功裏に終了したかの確認は ValueTask<T> のような実装を効率よく「待てたら待つ」する為に必要
	bool IsCompletedSuccessfully { get; }

    // 待つ必要があるのか確認
	bool IsCompleted { get; }

    // 冗長な名前だが、独自の Task-like に実装した際に間違って使ってしまわないようにするための措置
    // IsCompleted で十分に思えるがインターフェイス越しのメソッド呼び出しを回避するために必要(要検証
    // ※ ValueTask<T> の場合は IsCompleted が単純な値チェックではないというのも気になる
	void RegisterContinuationAndDiscardAwaiter(Action act);
        // Awaiter に登録するだけ => GetAwaiter().OnCompleted(act);

    // IsCanceled、IsFaulted を調べてエラーなら例外を取得して、とインターフェイスを増やしていくよりも
    // 「待てたら待つ」からリスローするのが一番効率が良い。
	void ThrowIfFailedOrCanceled();
        // 例外チェックのメソッドが無いなら結果を取得し破棄するだけで良い => _ = Result;

    // GetAwaiter() は await という糖衣構文に近いキーワードを使うためだけに必要なので実装は求めない
    // ※ GetAwaiter がメソッド名しか見ていないのは Task と Task<T> に継承関係を作る為にはどうにかして
    //   返す型の違う同一シグネチャを実装する必要があったから?(明示的にインターフェイスを実装する手があるが
}

// ValueTask<T> をコピペして独自の Task-like を作る場合は以下の2つのメソッドだけ実装すれば良い(未確認
public struct MyValueTask<T> :  IAwaitableContract,  IEquatable<MyValueTask<T>>
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]  // IL2CPP には効く
    public void RegisterContinuationAndDiscardAwaiter(Action act)
        => GetAwaiter().OnCompleted(act);  // ValueTaskAwaiter<T> のカスタムは不要
                                           // ※ タスクを何度も await するべきではない、というのは
                                           // 同じタスクに複数の Awaiter が存在する状況が良くないから

    [MethodImpl(MethodImplOptions.AggressiveInlining)]  // IL2CPP には効く
    public void ThrowIfFailedOrCanceled() => _ = Result;  // <-- 例外を投げるメソッドが無いので
}

実装例(ValueTask<T>含む)

このインターフェイスを使用して「待てたら待つ」を実現すると以下の通りになる。

...

else if (value is IAwaitableContract awaitable)  // 👈👈👈
{
	if (awaitable.IsCompletedSuccessfully)
    {
        // ValueTask<T> の場合は 90% のケースでこのパスを通るべきで、そうでないなら利用を見合わせた方が良い
		return;
    }

#if ENABLE_IL2CPP
    // IL2CPP であればインターフェイス越しのメソッド呼び出し(C# だとインライン化されない)を考えなくていい
    // 直接 while (!awaitable.IsCompleted) await Task.Yield(); しても良い(要検証
#else
	if (!awaitable.IsCompleted)
    {
        // アクションの割り当てを避けるには static Action を持った静的クラスが必要だが実現は不可能。
        // ※ やって出来ないことはないだろうが ValueTask<T> のように構造体だった場合はコピーが必要になる(多分
		bool isRunning = true;
		awaitable.RegisterContinuationAndDiscardAwaiter(() => isRunning = false);
		while (isRunning)
        {
            await Task.Yield();
        }

        // 参考: 匿名型とアクションの割り当て・呼び出しは概ねこんな感じ
        sealed class AnonymousLambda {
            bool isRunning;
            void DoIt() => isRunning = false;
        }
        var lambda = new AnonymousLambda();
        lambda.isRunning = true;
        awaitable.RegisterContinuationAndDiscardAwaiter(new Action(lambda.DoIt));

	}
#endif

    // 例外のリスロー(ValueTask<T>.Result は例外を AggregateException に包むので取り出す
    try
    {
        awaitable.ThrowIfFailedOrCanceled();
    }
    catch (AggregateException exc) when (exc.InnerExceptions.Count == 1)  // 一応
    {
        // 注: 包まれた OperationCanceledException を投げないと IsCanceled がセットされない
        throw exc.InnerException;
    }
    catch
    {
        throw;
    }
}

else if (value is not null)  // ValueTask<T> でも IAwaitableContract と同様のロジックが組める
                             // 例外のリスローには Result を使う(GetValue が状況に応じて例外を投げる
{
    var type = value.GetType();

    // メソッドは型ごとに使いまわせるのでキャッシュする
    if (!cache_typeToVTRef.ContainsKey(type))
    {
        if (type.Name == typeof(ValueTask<>).Name)
            if (!ValueTaskReflection.TryGet(type, out var found))
                return;
            cache_typeToVTRef.Add(type, found);
        else
            return;
    }

    var vtr = cache_typeToVTRef[type];
    if ((bool)vtr.IsCompletedSuccessfully.GetValue(value))
    {
        return;
    }

    // 前出のサンプルコード参照
    ...
}


readonly static Dictionary<Type, ValueTaskReflection> cache_typeToVTRef = new();
public sealed class ValueTaskReflection
{
    public PropertyInfo IsCompletedSuccessfully;
    public PropertyInfo IsCompleted;
    public PropertyInfo Result;
    public MethodInfo GetAwaiter;

    public static bool TryGet(Type type, out ValueTaskReflection? result)
    {
        result = null;
        ...
    }
}

IL2CPP 対応案(TODO: Task 戻り値版)

INotifyCompletion.OnCompleted にアクションを登録する実装であれば、内部で await せずに Task 型を返すメソッドにしたほうが同期コンテキスト上で回るループ(回数が不明でかつ一切のウェイトのない while)とステートマシンを排除できるのでより良い結果が得られる可能性がある。(要検証

(実際にはループではなく自身の進行状況を確認し終了状態でなければ再度同期コンテキストに自身をキューとして積み直す、という処理)

例外をそのまま利用してキャンセル状態のタスクを作る方法が無い? TaskCompletionSource のインスタンスをわざわざ作るしか無い?

TaskCompletionSource.SetCanceled でキャンセル状態のタスクを作ったとして、スタックトレースがどうなるか分からない問題もある。

https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs#L113

static Task AwaitIfPossible<T>(T value)
{
    if (value is Task t)
    {
        return t;
    }
    else if (value is ValueTask vt)
    {
        return vt.AsTask();
    }
    else if (value is IAwaitableContract awaitable)
    {
        if (awaitable.IsCompletedSuccessfully)
        {
            return Task.CompletedTask;
        }

        if (!awaitable.IsCompleted)
        {
            var acs = new AwaitableCompletionSource(awaitable);
            awaitable.RegisterContinuationAndDiscardAwaiter(acs.OnCompleted);
            return acs.Task;
        }

        return CreateTaskFromAwaitable(awaitable);
    }
    else if (...)
    {
        ...
    }

    return Task.CompletedTask;
    // 戻り値のタスクを await すればもちろんループは回るが無駄な入れ子構造のループは取り除かれている
}

// 注:例外発生時は結局内部にタスクを持つことになるので ValueTask を使う意味はない
// 注:Task.FromException(new OperationCanceledException()) だと IsCanceled がセットされない
// TaskCompletionSource(マネージド)を作るぐらいなら IAsyncStateMachine(構造体)を作った方が
// キャンセル状態でオリジナルの例外を維持したタスクを効率よく作れそうという判断(要検証
static async /*Value*/Task CreateTaskFromAwaitable(IAwaitableContract awaitable)
{
    try
    {
        awaitable.ThrowIfFailedOrCanceled();
    }
    catch (AggregateException exc) when (exc.InnerExceptions.Count == 1)  // 一応
    {
        // 注:包まれた OperationCanceledException を投げないと IsCanceled がセットされない
        //   InnerException の型は調べるべき??
        throw exc.InnerException;
    }
    catch
    {
        throw;
    }
}


// 参考
/// <code>
/// * This older test is still valid in 2024. Tested on .Net 8.0 and Unity 2021 (C# 9.0)
/// --> https://gist.github.com/ufcpp/b2e64d8e0165746effbd98b8aa955f7e
/// 
/// ** StaticLambda (`StaticClass { static Action = () => { ... }; }`) is most FAST in my test.
/// ** In Unity IL2CPP, only StaticSMethod (`StaticClass { static Action = StaticMethod; }`) is SLOWER than others.
/// ** Curried technique doesn't make sense in .Net 8.0 environment. (super slower than others)
/// ** OnTheFly is `DoSomething(() => { ... })`, OnTheFlyStatic is `DoSomething(static () => { ... })`.
/// </code>
/// * faster to slower order
/// <para>
/// | IL2CPP
/// |--------
/// | StaticLambda, OnTheFly, OnTheFlyStatic, CurriedNop >>>>> StaticSMethod
/// </para>
/// <para>
/// | .NET 8
/// |--------
/// | StaticLambda >> OnTheFly, OnTheFlyStatic >>>>>>>> CurriedNop >>>> StaticSMethod
/// </para>
internal readonly struct AwaitableCompletionSource
{
    // よく見るスタイル(今回は使えない
    readonly public static Action<TaskCompletionSource, IAwaitableContract> OnCompleted
        = static (tcs, awaitable) =>
        {
            tcs.SetFromTask(CreateTaskFromAwaitable(awaitable));
        }

    readonly TaskCompletionSource tcs = new();
    readonly IAwaitableContract awaitable;
    public AwaitableCompletionSource(IAwaitableContract awaitable)
        => this.awaitable = awaitable;

    readonly public Task Task => tcs.Task;
    readonly public Action OnCompleted => tcs.SetFromTask(CreateTaskFromAwaitable(awaitable));
}

// 割り当て不要で待てる数の上限を決めれば静的クラス化も可能
// ただ、ACS は構造体にしたし TCS と awaitable はどのみち割り当て必須なので完全に無駄っぽい
// 一応アクションの割り当ては無くなるが、、、
// ※ そもそも論として非同期処理自体が完全に無駄で、別スレッドで処理できるものは処理すべき
//   別スレッドなら CPU 使用率も 100% を目指せるし await しなければステートマシンも要らない
//   オーバーヘッド(微小レベル)を増やしてでもメインスレッドで疑似的に並列処理するのが async/await 機構
//   最初から別スレッド処理にしとけば頭に TCS を作ってケツで値を設定するだけで何時でも async 対応できる
//   CancellationToken てのがあってそれを使うとキレイに中断させることが出来るんだ、ってわけでもない
//   それは単に実装がキレイなだけで雑に作ればキャンセル時に問題が起きる
// C# 12.0 --> [InlineArray(N)] 使える?
internal static class CrazyImpl
{
    readonly object lock1 = new();  // make it thread-safe!!
    ...
    readonly object lock16 = new();

    TaskCompletionSource tcs1;
    IAwaitableContract ac1;
    // スレッドセーフで tcs# をヌルにするアクションにする必要がある。。。
    public static Action Act1 = () => tcs1.SetFromTask(CreateTaskFromAwaitable(ac1));
    ...

    public static ref Action TryGet(IAwaitableContract awaitable, out Task result)
    {
        // find unused slot (tcs# == null) then renew TCS and return Action and Task
        if (...)
            ...
        else
            var acs = new AwaitableCompletionSource(awaitable)
            result = acs.Task;
            return ref acs.OnCompleted;
    }
}

// ummmm....
awaitable.RegisterContinuationAndDiscardAwaiter(CrazyImpl.TryGet(awaitable, out var task));
return task;

TODO: IValueTaskSource IValueTaskSource<T>

これらのインターフェイスを実装して得られるメリットは ValueTask の生成をスタック上で完結できることだ。

AwaitIfPossible の戻り値を Task から ValueTask に変更し IValueTaskSource を実装した何かでなんか上手いことやる予定になっている。

(👆 構造体ならプールする必要は無さそうな?)

ValueTask 内部で IValueTaskSource にボックス化される度に暗黙的にマネージドオブジェクトが作られては破棄される事になるので、構造体ではなくクラスに実装してインスタンスをプールしないと IValueTaskSource を使う意味がない。。。(という程ではないか? タスク型が単純なボクシングオブジェクトに代わるから使う意味はある?)

なんとなくプーリングよりボックス化の方が良さそうな気がする。大量にかつ同時に必要になったらプール内のオブジェクト数が大変なことになるし、それらが必要なのはその時だけ。そもそも同時に大量に信じられないレベルでタスクを回すこと自体に問題(ではないが)があるし、そういう状況を理解したうえで何かしらのライブラリに「イイ感じの処理」を期待している状況が存在するとは思えない。

(むしろそういう状況に気づきやすくするために下手な最適化はしない方が良さそうな? 大量のタスクの実行が限定的なモノでスパイク起こしてでも GC に回収させるべきオブジェクトなのか、恒常的な処理でプーリングすべきものなのかはライブラリ側から判断することは出来ない。プーリングで勝手に抱え込むと「○○後は動作が不安定になります。再起動をお勧めします」がライブラリ側の処理を原因として起きかねない。※ ClearCachedObjects() を用意して実行してもらうって手もある)

大量に IValueTaskSource が消費されない状態になれば、ボックス化オブジェクト破棄の GC への負荷も無視できるレベルに思える。

プーリングは Microsoft.Extensions.ObjectPool<T> が Unity だと使えないから面倒。。。Unity 製の UnityEngine.Pool.ObjectPool<T> 使う? ConcurrentBag<T> を使ったサンプルで十分??

https://learn.microsoft.com/ja-jp/dotnet/standard/collections/thread-safe/how-to-create-an-object-pool

--

👇 アロケなしでキャンセル状態のタスクを作るのが目的だから馬鹿正直に実装する必要はない(ハズ

(完了したタスク以外でも使えるようにしないと未完了時のコードパスから通常版の ValueTask を使う処理を取り除けない)

/// <summary>Only works correctly for completed tasks.</summary>
internal /*ref*/ struct NonAllocCompletedTaskSource : IValueTaskSource
{
    /// <summary>Usage: `(this.Error ??= new()).Capture(exception);`</summary>
    public ExceptionDispatchInfo? Error; // public フィールドは構造体の防衛的コピーを防ぐ
    readonly void GetResult(short token) => Error?.Throw();

    readonly public ValueTaskSourceStatus GetStatus(short token)
    {
        if (Error == null)
            ...
        // OperationCanceledException を特別扱い
        //https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Sources/ManualResetValueTaskSourceCore.cs#L84
        else if (Error.SourceException is OperationCanceledException)
            ...
        else
            ...
    }

    readonly public void OnCompleted(Action<object> continuation, object state, short token, ValueTaskSourceOnCompletedFlags flags)
    {
        // ココで初めて ManualResetValueTaskSourceCore<object> を作って処理を委譲する(可能か??
        //https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Sources/ManualResetValueTaskSourceCore.cs#L120
    }
}


// 利用イメージ
var nacts = new NonAllocCompletedTaskSource();
try
{
    awaitable.ThrowIfFailedOrCanceled();
}
catch (AggregateException exc) when (exc.InnerExceptions.Count == 1)  // 一応
{
    (nacts.Error ??= new()).Capture(exc.InnerException);
}
catch (Exception exc)
{
    (nacts.Error ??= new()).Capture(exc);
}

return new ValueTask(nacts);  // エラーが起きなければ構造体 x2

付録

IL2CPP 関連のおまけ。

IL2CPP で出力されるメソッド一覧

明示的に呼び出している ValueTask<T> のメソッドが存在しない場合、いったい何が IL2CPP 向けに出力されるのか。

Unity 2021 LTS の新規プロジェクトで確認する。

ValueTask<T> の参照のみ

using System.Threading.Tasks;
using UnityEngine;

public class ValueTaskGenerics : MonoBehaviour
{
    public ValueTask<int> ValueTask;

    void Start()
    {
    }
}

GetAwaiter が無い!

[AsyncMethodBuilder(typeof(AsyncValueTaskMethodBuilder<>))]
[StructLayout(3)]
public readonly struct ValueTask<TResult> : IEquatable<ValueTask<TResult>>
    public override int GetHashCode()
    public override bool Equals(object obj)
    public bool Equals(ValueTask<TResult> other)
    public bool IsCompletedSuccessfully
    public TResult Result
    public override string ToString()

    private static Task<TResult> s_canceledTask;
    internal readonly object _obj;
    internal readonly TResult _result;
    internal readonly short _token;
    internal readonly bool _continueOnCapturedContext;

形だけで良いから await した版

using System.Threading.Tasks;
using UnityEngine;

public class ValueTaskGenerics : MonoBehaviour
{
    public ValueTask<int> ValueTask;

    void Start()
    {
    }

    // 👇 ValueTask<T> に対する await が GetAwaiter の出力に必要なので使わないとしても入れる
    async void Test<T>(ValueTask<T> test)
    {
        await test;
    }
}

IsCompletedGetAwaiter が増えた!

[AsyncMethodBuilder(typeof(AsyncValueTaskMethodBuilder<>))]
[StructLayout(3)]
public readonly struct ValueTask<TResult> : IEquatable<ValueTask<TResult>>
    public override int GetHashCode()
    public override bool Equals(object obj)
    public bool Equals(ValueTask<TResult> other)
    public bool IsCompleted
    public bool IsCompletedSuccessfully
    public TResult Result
    public ValueTaskAwaiter<TResult> GetAwaiter()
    public override string ToString()

    private static Task<TResult> s_canceledTask;
    internal readonly object _obj;
    internal readonly TResult _result;
    internal readonly short _token;
    internal readonly bool _continueOnCapturedContext;

--

推定される IL2CPP の挙動は、まずトランスコードすべきジェネリック型メソッドの一覧を作り、その後に使われている型引数すべてのバリエーションのメソッドを呼び出されているかに関係なく C++ に変換する、というもので、おそらく間違いはない。

特定の型引数を await する必要はなく、プロジェクト内で利用している全ての T 型の C++ コードがワンセットで出力される。

ValueTask<T> のコスト

AOT コンパイルされた Result のコードを見ると ValueTask<T> を使う意味はあるのか? と思える。

九割のケースで即時結果が出るような処理でも無い限りはコスト(と言える程ではないが)の方が勝るんではないだろうか。ValueTask<T> 由来の面倒さを抱え込むことを考えると Task<T> で良いと思える。

ValueTask<T> は即時終了しない限りは Task<T> 同時に生成し処理を委譲する。特別な何か、マネージドオブジェクトを使わない効率的な処理を行っているわけではない。本当に即時終了したときのみ価値がある実装になっている。

ジェネリック型 T の場合は ValueTask<T> を中心に考えて参照渡し(in ref out)にするか、小さい値型に合わせて値渡しにするかの決断も必要(気にし過ぎ)になる。sizeof(T) で16バイト以下なら~~ も可能ではあるが、、、(※ ValueTask<T> は object, bool, short, TResult のフィールドを持っている)

https://stackoverflow.com/questions/1082311/why-should-a-net-struct-be-less-than-16-bytes

同じタスクを何度も await するなとは言われているけども、Array.Empty<T>() みたいに終了状態のタスク(結果の差し替え可能)を用意したほうが良いんじゃないだろうか? Task<int>.CompletedTask(0) みたいな。

複数の場所から同時に一つのインスタンスに結果を設定するとバグるから無理。

public TResult Result
{
    [MethodImpl(256)]  //AggressiveInlining
    get
    {
        object obj = this._obj;
        if (obj == null)
        {
            return this._result;
        }
        Task<TResult> task = obj as Task<TResult>;
        if (task != null)
        {
            TaskAwaiter.ValidateEnd(task);
            return task.ResultOnSuccess;
        }
        return Unsafe.As<IValueTaskSource<TResult>>(obj).GetResult(this._token);
    }
}

早々に結果が出る場合は Task.CompletedTask を返すようにして、値の取得には別のインターフェイスを使用するというロジックならほんの少し手間は増えるがタスク型のインスタンスの割り当てを回避できる。

が、Task.CompletedTask が返せるメソッドの宣言は結構面倒で、別スレッドで処理を行う非同期処理を実装する場合に(ほぼ)限られる(ハズ

ValueTaskAwaiter<TResult>

ValueTaskAwaiter<TResult> の IL2CPP ビルドに含まれるメソッド一覧。

public readonly struct ValueTaskAwaiter<TResult> : ICriticalNotifyCompletion
    public bool IsCompleted
    public TResult GetResult()
    public void UnsafeOnCompleted(Action continuation)

    internal ValueTaskAwaiter(ValueTask<TResult> value)
    private readonly ValueTask<TResult> _value;

おわりに

MonoBehaviour に Start メソッドしかないのに Update 相当の処理が行われるのは面白いですよね。

いずれ「非同期処理は Unity のコルーチンとほぼ同じ仕組み」「awaityield return」「そもそも Unity にコルーチンは存在しない」なんていう、全然伝わらない話も纏めてみたいですね。

--

以上です。お疲れ様でした。

2
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1