Awaitable 使いづらそう問題

Unity 2023 改め Unity 6 で新登場の `Awaitable` の癖が強い問題。


- [なぜ `await` するだけで実行スレッドが変わるのか](#なぜ-await-するだけで実行スレッドが変わるのか)
- [問題](#問題)
    - [！！](#)
- [素直に `await` すれば良いじゃん？](#素直に-await-すれば良いじゃん)
- [追記： `Awaitable` の注意点](#追記-awaitable-の注意点)
    - [`GetResult` メソッドは未定義動作](#getresult-メソッドは未定義動作)
    - [複数回 await 出来ない](#複数回-await-出来ない)
- [※ 実質的に `RunOnMainThread` では？](#-実質的に-runonmainthread-では)
- [※ `Awaitable` から `Task` への変換](#-awaitable-から-task-への変換)
- [※ `async` メソッドを実装したときに起きること](#-async-メソッドを実装したときに起きること)
    - [Continuation（継続タスク）](#continuation継続タスク)
- [非同期処理の大まかな流れ](#非同期処理の大まかな流れ)
- [おわりに](#おわりに)



# なぜ `await` するだけで実行スレッドが変わるのか

まずは `Awaitable.MainThreadAsync` の仕組みについて。

Unity 公式の `UnityCsReference` を見ればわかる通り、メインスレッドで実行されている同期コンテキストに対して `Post` しているからですね。

https://github.com/Unity-Technologies/UnityCsReference/blob/master/Runtime/Export/Scripting/Awaitable.Threading.cs#L31

> ちなみに `BackgroundThreadAsync` は `Task.Run` してるだけ





# 問題

さて、セッター／ゲッターのような async/await コンテキストが使えない状況で `Awaitable.MainThreadAsync()` を使おうとした場合、いつものタスクの感覚で

```csharp
Awaitable.MainThreadAsync().GetAwaiter().GetResult();
```

すれば良いんじゃないか？ と考えました。

https://qiita.com/sator_imaging/items/6c60e462417a5235778a



## ！！

しかし、これは想定通りに動きません。上記のコードには continuation が存在しないので全く意味がないわけです。

そもそも「await すると実行スレッドが変わる」という認識が間違いで、実際には「continuation **のみ**」がメインスレッドで実行されるというヒドい罠です。

> ソースコードを見た後はそりゃそうだろ GetResult は何もしてないし、なんだけど await 出来るつまりタスクと同じってコト？ と捉えてしまうとマズいことになる。「Continuation とは？」については後述。

```csharp
public class AwaitableTest : MonoBehaviour
{
    void Start()
    {
        // 1 が表示される
        Debug.Log("メインスレッド: " + Thread.CurrentThread.ManagedThreadId);

        // ワーカースレッドで実行すると、、、
        ThreadPool.QueueUserWorkItem(_ =>
        {
            // 想定通り Non-1 が表示される
            Debug.Log("ワーカー: " + Thread.CurrentThread.ManagedThreadId);

            // 問題のコレは continuation が存在しないため何も起きない／起こさない。
            // await と組み合わせた時のみ効力を発揮する。
            Awaitable.MainThreadAsync().GetAwaiter().GetResult();

            // ワーカースレッドの ID が表示される
            Debug.Log("メイン？: " + Thread.CurrentThread.ManagedThreadId);

            // 非 async メソッドで await せずにメインスレッドにジョブを投げるにはこうする必要がある
            Awaitable.MainThreadAsync().OnCompleted(() =>
            {
                // 1 が表示される
                Debug.Log("メインスレッド！！: " + Thread.CurrentThread.ManagedThreadId);
            });

            // MainThreadAsync 後もワーカースレッドの ID が表示される
            Debug.Log("メイン？: " + Thread.CurrentThread.ManagedThreadId);
        });


        // Unity の期待する使い方をすれば await 後にメインスレッドに移行しているように感じられる書き味になる
        ThreadPool.QueueUserWorkItem(async _ =>  // 👈 async
        {
            Debug.Log("Expected ワーカー: " + Thread.CurrentThread.ManagedThreadId);
            await Awaitable.MainThreadAsync();   // 👈 await 以後はメインスレッド（と錯覚してしまうが実際は違う
            Debug.Log("Expected メイン！: " + Thread.CurrentThread.ManagedThreadId);
            Debug.Log("Expected メイン！: " + Thread.CurrentThread.ManagedThreadId);
            Debug.Log("Expected メイン！: " + Thread.CurrentThread.ManagedThreadId);
        });
    }
}
```





# 素直に `await` すれば良いじゃん？

await したくない／出来ないケースが存在します。前出の非同期処理が不可能な getter / setter の例もそうですが、イベントのコールバックなんかも同様です。

そして async/await コンテキストの問題（？）の一つに `await` しないとメソッドが即時終了するというものがあります。これは Rx（ReactiveExtensions）系のライブラリに非同期スケジューラーが搭載されている理由にもなっています。

```csharp
event Action Callback;

// これだと「awaitしているのに」待たないで実行されてしまい毎回順番が変わってしまう
// DownloadAsync だった場合は「順番に非同期で」ではなく「同時に非同期で」ダウンロードすることになる
var rng = new System.Random();
Callback += async () => { await Task.Delay(rng.Next(100, 200)); Debug.Log("No.1"); };
Callback += async () => { await Task.Delay(rng.Next(100, 200)); Debug.Log("No.2"); };
Callback += async () => { await Task.Delay(rng.Next(100, 200)); Debug.Log("No.3"); };
Callback += async () => { await Task.Delay(rng.Next(100, 200)); Debug.Log("No.4"); };
Callback += async () => { await Task.Delay(rng.Next(100, 200)); Debug.Log("No.5"); };
Callback += async () => { await Task.Delay(rng.Next(100, 200)); Debug.Log("No.6"); };
Callback.Invoke();

// こうすることでコールバックの実行順を保証できる（順次実行させることが出来る
// ※ ただし同期的に実行されるので GUI の更新をブロックしたくないなら非同期スケジューラーを使う必要がある
var rng = new System.Random();
Callback += () => { Task.Delay(rng.Next(100, 200)).Wait(); Debug.Log("No.1"); };
Callback += () => { Task.Delay(rng.Next(100, 200)).Wait(); Debug.Log("No.2"); };
Callback += () => { Task.Delay(rng.Next(100, 200)).Wait(); Debug.Log("No.3"); };
Callback += () => { Task.Delay(rng.Next(100, 200)).Wait(); Debug.Log("No.4"); };
Callback += () => { Task.Delay(rng.Next(100, 200)).Wait(); Debug.Log("No.5"); };
Callback += () => { Task.Delay(rng.Next(100, 200)).Wait(); Debug.Log("No.6"); };
Callback.Invoke();
```

非同期処理を考えてない API に非同期処理を突っ込まざるを得ないこともあるわけで、実行順の保証が頻繁に要求されない／GUIロックが問題にならないのなら手っ取り早く Wait するというのも一つの手。

:::note info

非同期処理は
- 誤）GUI の更新をブロックしない
- 正）あらゆる処理をブロックしない

という技術なので、イベントの実行順が変わる（イベント発行がブロックされない）のは想定通りの動作ではあるが、やっぱりイメージするのは GUI だけがブロックされない結果なので「なんで？ await してるよ？」となる。

:::





# 追記： `Awaitable` の注意点

ソースコードにコメントで色々と書いてありました。いくつかピックアップしてみます。

<p><details lang="ja" --open><summary>感想文</summary>

Awaitable MainThreadAsync BackgroundThreadAsync は `async` `await` という糖衣構文に強く依存しているし、ソースコードが確認できない他の Awaitable メンバーも同じような実装の可能性があるのであまり使いたくない印象。

非同期処理は Task-like 型と IAsyncStateMachine, AsyncMethodBuilder の組み合わせで成り立っていて、動くようにするには結構な量のボイラープレート／テンプレートコードが必要になる。そして async/await というキーワードはそれらを省略するためのキーワードに過ぎない。

のだが！ それらに強く依存してしまっているのが Awaitable でかなりピーキーな API になっている。下手に Task-like 風味なのも余計で GetAwaiter は this を返し GetResult は何もしない、ただ誤解を与えるだけの存在になっている。その結果あれ？ タスクと同じ感覚で使うと動作がおかしくね？ となる。というかなった。

INotifyCompletion を実装しているだけなんだから AsyncOperation を発展させる形で良かったんじゃないか？ 俺々ライブラリだし思い付いちゃったし突飛なことやっちゃお、、、が公式に乗ってしまった感じ。特殊な構文を取り入れてヌル合体演算子や条件演算子が使えなくなっちゃったのと同じ轍を踏んでない？ await で暗黙的に Continuation が作られるのを逆手に取った面白いテクニックだと思うけど今なら Unity 6 ベータだから無かった事にしても問題ない。

<!------- End of Details JA Tag -------></details></p>


https://github.com/Unity-Technologies/UnityCsReference/blob/master/Runtime/Export/Scripting/Awaitable.cs

https://github.com/Unity-Technologies/UnityCsReference/blob/master/Runtime/Export/Scripting/Awaitable.Synchronization.cs


## `GetResult` メソッドは未定義動作

ジョブの完了前に限りますが、通常のタスクの感覚で GetResult しても結果を待つことはせず未定義動作になるんだそうです。

（それなら `internal` にしといてください）


## 複数回 await 出来ない

.NET のタスクと違って、とあるが ~~Task にも同様の注意書きがあったハズ。~~

[Task ではなく ValueTask](https://qiita.com/sator_imaging/items/76fdd84373fca79269e2#comment-9e11332bab9053c59153)（@juner さんご指摘ありがとうございます！）


--


とにかく `await` との組み合わせで使うこと以外は考えていないように見受けられる。Task-like と捉えるべきではない。とりま GetResult を internal にしよう。


以下付録



# ※ 実質的に `RunOnMainThread` では？

※ 実行完了を待つ必要が無ければそのように使えます。

```csharp
var test = 1;
Awaitable.MainThreadAsync().OnCompleted(() => test = 2);

// １が表示される（Post なので絶対に２にならない）
Debug.Log(test);


// ２になるのを待つ場合はこうする
var test = 1;

using (var wait = new ManualResetEvent(false))
{
    Awaitable.MainThreadAsync().OnCompleted(() => {
        test = 2;
        wait.Set();
    });
    wait.WaitOne();
}

Debug.Log(test);
```

> 待たないと２にならない理由は UnityCsReference にある Post の実装を見ると分かります。

https://github.com/Unity-Technologies/UnityCsReference/blob/master/Runtime/Export/Scripting/UnitySynchronizationContext.cs





# ※ `Awaitable` から `Task` への変換

`UnityEngine.Awaitable` から `System.Threading.Tasks.Task` への変換が面倒なのも地味に使い辛いポイント。とにかく Unity の考えた使い方以外が制限され過ぎ。なんでこうなった？

```csharp
var tcs = new TaskCompletionSource<object?>();
awaitable.OnCompleted(() => tcs.SetResult(null));

return tcs.Task as Task;
```

> 出来ないと既存の `Task` `ValueTask` エコシステムに乗せられない。



#### `Awaitable.FromAsyncOperation`

https://docs.unity3d.com/2023.1/Documentation/ScriptReference/Awaitable.FromAsyncOperation.html

なんてものもあるけど、`ValueTask` 型への変換が可能ならメリットだろうけど Unity の非同期処理に関わるモノをいまさら非タスクな `Awaitable` 型に集約する意味はあるんだろうか？ `Awaitable` には何の拡張メソッドも何も無いんだが？



#### `AwaitableCompletionSource`

https://docs.unity3d.com/2023.1/Documentation/ScriptReference/AwaitableCompletionSource.html

`Awaitable.WhenAll/WhenAny` も出来ない Awaitable を作る意味とは。





# ※ `async` メソッドを実装したときに起きること

例）２回 await する非同期メソッド

```csharp
async Task WhatsHappeningInAsyncMethod()
{
    Debug.Log(Thread.CurrentThread.ManagedThreadId);
    Debug.Log(Thread.CurrentThread.ManagedThreadId);
    Debug.Log(Thread.CurrentThread.ManagedThreadId);

    await Awaitable.MainThreadAsync();
    Debug.Log("What メインスレッド: " + Thread.CurrentThread.ManagedThreadId);
    Debug.Log("What メインスレッド: " + Thread.CurrentThread.ManagedThreadId);
    Debug.Log("What メインスレッド: " + Thread.CurrentThread.ManagedThreadId);

    await Awaitable.BackgroundThreadAsync();
    Debug.Log("What ワーカー: " + Thread.CurrentThread.ManagedThreadId);
    Debug.Log("What ワーカー: " + Thread.CurrentThread.ManagedThreadId);
    Debug.Log("What ワーカー: " + Thread.CurrentThread.ManagedThreadId);
}
```


## Continuation（継続タスク）

上記の非同期メソッドはコンパイルの結果以下のように変換される。

```csharp
// 👇 非同期ではなく同期メソッドに変換される
/*async*/ Task WhatsHappeningInAsyncMethod()
{
    // await 以前はそのまま
    Debug.Log(Thread.CurrentThread.ManagedThreadId);
    Debug.Log(Thread.CurrentThread.ManagedThreadId);
    Debug.Log(Thread.CurrentThread.ManagedThreadId);

    // await 以後のコードは continuation として分離・登録される
    Awaitable.MainThreadAsync().OnCompleted(Continuation);

    // ココで async メソッドとしては終了する（async メソッドが即時終了する理由
    return;
}

// ※ await 到達後にメソッドを抜けるので、ココで別の同期メソッドを実行するチャンスが生まれるという事

// 暗黙的に宣言される継続メソッドは Awaitable.MainThreadAsync によって同期コンテキストに Post
// されメインスレッドで実行される。まるで実行スレッドを切り替えたような結果が得られるのはこの構造のおかげ
void Continuation()
{
    Debug.Log("What メインスレッド: " + Thread.CurrentThread.ManagedThreadId);
    Debug.Log("What メインスレッド: " + Thread.CurrentThread.ManagedThreadId);
    Debug.Log("What メインスレッド: " + Thread.CurrentThread.ManagedThreadId);

    // 複数の await がある場合は Continuation 内でさらに分解される
    Awaitable.BackgroundThreadAsync().OnCompleted(Continuation2nd);

    return;
}

// ※ 同上

// await の数だけ暗黙的な継続メソッドが宣言されるし async 内の await も再帰的に分解される
void Continuation2nd()
{
    Debug.Log("What ワーカー: " + Thread.CurrentThread.ManagedThreadId);
    Debug.Log("What ワーカー: " + Thread.CurrentThread.ManagedThreadId);
    Debug.Log("What ワーカー: " + Thread.CurrentThread.ManagedThreadId);

    return;
}
```

※ 上記はあくまでイメージです。





# 非同期処理の大まかな流れ

上記の通り、元 async メソッドは await 直前まで実行し同期コンテキストに継続ジョブを登録、即時終了する「同期」メソッドに変換される。つまり即時終了したそのタイミングで別の同期メソッドや元 async メソッドが実行可能になるということ。

メインスレッドのメインループ内の同期処理のイメージは、

1. `syncMethod1() --> sync2() --> monoBehaviour.Update() --> syncCtx.Run() --> 次へ`
2. `syncMethod1() --> sync2() --> monoBehaviour.Update() --> syncCtx.Run() --> 次へ`
3. 繰り返し

となり、await に到達するたびにメソッドを抜けるとこでメインループを進めることができ、結果同一スレッド内で複数の処理を同時に処理しているように「錯覚」させている。

※ なので await の回数をループ３０回につき１回など制限を掛け上記のメインループの流れを止めることで GUI の反応が悪くなったり画面がカクつくようになる。

--

なお、この処理の流れだと途中で処理が終わってしまう元 async メソッドの進行状況を管理する必要があるが、それを担っているのが `Task` `ValueTask` クラス。つまり処理内容それ自体は `IEnumerator` や Unity のコルーチンと全く同じものという事。



> 状態管理によって合間に別の処理を挟むテクニック自体は古くから存在し Unity のコルーチンもそのうちの一つ





# おわりに

`Awaitable.MainThreadAsync` は雑にメインスレッドに処理を移したい時には便利そうではあるんですよね。ちょっとテストしたいとかそういう時。

<!--
相方の `Awaitable.BackgroundThreadAsync` は `Task.Run` してるだけっていう潔さ。完全に糖衣構文ですね。

ヌル合体演算子／条件演算子と同じで、Unity アナライザーで「`MainThreadAwaitable` `BackgroundThreadAwaitable` を待っても意味がありません」っていう注釈を表示しなきゃいけない余計な方便が増えちゃった気がする。
-->

とは言え async/await の隙を巧妙に突いたテクニックじゃなくて普通に `RunOnMainThread` として実装するか `UnitySynchronizationContext` のインスタンスを取得できるようにして欲しいですね。マジで。


--


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