12
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Awaitable 使いづらそう問題

Last updated at Posted at 2024-04-15

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

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

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

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

ちなみに BackgroundThreadAsyncTask.Run してるだけ

問題

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

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

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

!!

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

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

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

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)系のライブラリに非同期スケジューラーが搭載されている理由にもなっています。

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 するというのも一つの手。

非同期処理は

  • 誤)GUI の更新をブロックしない
  • 正)あらゆる処理をブロックしない

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

追記: Awaitable の注意点

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

感想文

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 ベータだから無かった事にしても問題ない。

GetResult メソッドは未定義動作

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

(それなら internal にしといてください)

複数回 await 出来ない

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

Task ではなく ValueTask@juner さんご指摘ありがとうございます!)

--

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

以下付録

※ 実質的に RunOnMainThread では?

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

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

// 1が表示される(Post なので絶対に2にならない)
Debug.Log(test);


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

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

Debug.Log(test);

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

Awaitable から Task への変換

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

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

return tcs.Task as Task;

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

Awaitable.FromAsyncOperation

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

AwaitableCompletionSource

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

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

例)2回 await する非同期メソッド

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(継続タスク)

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

// 👇 非同期ではなく同期メソッドに変換される
/*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 の回数をループ30回につき1回など制限を掛け上記のメインループの流れを止めることで GUI の反応が悪くなったり画面がカクつくようになる。

--

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

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

おわりに

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

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

--

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

12
7
1

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
12
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?