url: https://github.com/ganyariya/UnityPlayground/commit/a09af105780fb8f9a4860925e343e06f41122a07
title: "AsyncAwait について調べる · ganyariya/UnityPlayground@a09af10"
description: "Contribute to ganyariya/UnityPlayground development by creating an account on GitHub."
host: github.com
favicon: https://github.githubassets.com/favicons/favicon.svg
image: https://opengraph.githubassets.com/8daa026999ca9e7b63f1aed7e907995953d5e8f8f2f019f022a6ee96569c0aa0/ganyariya/UnityPlayground/commit/a09af105780fb8f9a4860925e343e06f41122a07

前提として下記を調べています。 同期・非同期処理と並列・平行処理は別物だよ

Thread

C# 1.0 のころから存在するスレッドを扱うための機能です。 コールバック地獄になる・値のやり取りが面倒、など扱いづらいです。 また、自前でスレッドを生成することになり処理負荷が高いです。

using System;
using System.Threading;
using UnityEngine;
 
public class AsyncAwait_Thread : MonoBehaviour
{
    // Start is called once before the first execution of Update after the MonoBehaviour is created
    void Start()
    {
        Debug.Log($"Main Thread ID: {System.Threading.Thread.CurrentThread.ManagedThreadId}");
 
        Action finalCallback = () =>
        {
            Debug.Log($"Final Callback ID: {System.Threading.Thread.CurrentThread.ManagedThreadId}");
            Debug.Log("Final callback");
            Debug.Log("Start method is done");
        };
 
        Action threadBCallback = () =>
        {
            Debug.Log($"Thread B ID: {System.Threading.Thread.CurrentThread.ManagedThreadId}");
            Debug.Log("Thread B Start");
            Thread.Sleep(5000);
            Debug.Log($"Thread B ID: {System.Threading.Thread.CurrentThread.ManagedThreadId}");
            Debug.Log("Thread B End");
 
            finalCallback();
        };
 
        Action threadACallback = () =>
        {
            Debug.Log($"Thread A ID: {System.Threading.Thread.CurrentThread.ManagedThreadId}");
            Debug.Log("Thread A Start");
            Thread.Sleep(5000);
            Debug.Log($"Thread A ID: {System.Threading.Thread.CurrentThread.ManagedThreadId}");
            Debug.Log("Thread A End");
 
            var threadB = new Thread(new ThreadStart(threadBCallback));
            threadB.Start();
            threadB.Join();
        };
 
        var threadA = new Thread(new ThreadStart(threadACallback));
        threadA.Start();
 
        Debug.Log("Calling Start method");
    }
 
// Update is called once per frame
    void Update()
    {
    }
}

Imgur

Task

Thread の辛さを解消するために C# 4.0 から Task が導入されました。 #スレッドプール が .NET によって管理されており、 Task.Run を実行するとスレッドプールの任意のスレッドが駆り出されそこで実行されます。 自前でスレッドの生成・破棄するとコストがかかるうえに管理が大変です。 スレッドプールを意識せずに使える Task.Run のほうが便利です。

Task を連続的に実行させたい場合 ContinueWith で Promise Callback のように書く必要があります。 エラー判定については IsFaulted のように手動でチェックしないといけず try/catch の例外処理は行えません。 これらの不自由さは解決されていません。

また注意点として Task 自体は非同期操作を抽象化したクラスであり、スレッドプールで実行しなければならない、などは定義されていません。 後述するように async/await では別の方法を利用して非同期処理が実現されます。

using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
 
public class AsyncAwait_Task : MonoBehaviour
{
    int TaskA(int initialValue)
    {
        Debug.Log($"Task A ID: {Thread.CurrentThread.ManagedThreadId}");
        Debug.Log("Task A Start");
        Thread.Sleep(5000);
 
        int result = initialValue + 1;
 
        Debug.Log($"Task A ID: {Thread.CurrentThread.ManagedThreadId}");
        Debug.Log($"Task A End with result: {result}");
 
        return result;
    }
 
    int TaskB(int resultA)
    {
        Debug.Log($"Task B ID: {Thread.CurrentThread.ManagedThreadId}");
        Debug.Log("Task B Start");
        Thread.Sleep(5000);
 
        int result = resultA * 2 + 2;
        Debug.Log($"Task B ID: {Thread.CurrentThread.ManagedThreadId}");
        Debug.Log($"Task B End with result: {result}");
        return result;
    }
 
    // Start is called once before the first execution of Update after the MonoBehaviour is created
    void Start()
    {
        Debug.Log($"Main Thread ID: {Thread.CurrentThread.ManagedThreadId}");
 
        int initialValue = 100;
 
        // TaskA をスレッドプール上の任意のスレッドで実行してもらう
        Task<int> taskA = Task.Run(() => TaskA(initialValue));
 
        // TaskA が完了したら ContinueWith で与えたコールバック関数を実行してもらう、ということを指示する
        Task<int> taskB = taskA.ContinueWith(tA =>
        {
            if (tA.IsFaulted)
            {
                Debug.Log("Task A Failed");
                // return 0; // エラー処理が面倒
                return Task.FromException<int>(tA.Exception.InnerException);
            }
 
            Debug.Log("Task A Succeeded"); 
            int resultA = tA.Result;
            
            // TaskB を起動する
            return Task.Run(() => TaskB(resultA));
        }).Unwrap();
 
        Task finalTask = taskB.ContinueWith(tB =>
        {
            if (tB.IsFaulted)
            {
                Debug.Log("Task B Failed");
                return;
            }
 
            Debug.Log("Final Task");
            Debug.Log($"Final callback id: {Thread.CurrentThread.ManagedThreadId}");
            Debug.Log($"Final result: {tB.Result}");
            Debug.Log("Start method is done");
        });
        // finalTask は裏のスレッドプールで走らせる
        // finalTask.Wait(); を実行してしまうと、Unity Start メソッドが終わらず10sゲームが遊べない
 
        Debug.Log("Calling Start method");
    }
}

Imgur

async/await

Thread/Task のコールバック地獄を解決するために C# 5.0 から導入されました。

async メソッド内で await を実行すると、そのメソッドの処理が中断されてメインスレッドが返されます。 たとえば下記の例では TaskA メソッドの await Task.Delay を呼び出した時点で TaskA メソッドの処理は中断され、メインスレッドが他の GameObject の Start メソッドを処理できるようになります。 Delay 5s が経過したら、一時中断された処理が再開され、再びメインスレッドが処理を継続します。

using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
 
public class AsyncAwait_AsyncAwait : MonoBehaviour
{
    async Task<int> TaskA(int initialValue)
    {
        // ここも 1 になることに注意する(メインスレッド)
        Debug.Log($"Task A ID: {Thread.CurrentThread.ManagedThreadId}");
        Debug.Log("Task A Start");
        
        // ここまで Unity メインスレッドで実行される
        // await が呼び出されると、一時的に処理を中断し Unity メインスレッドを他の GameObject へ返す
        await Task.Delay(5000);
 
        // 5s 経過すると Unity メインスレッドでそのまま処理が再開される
        int result = initialValue + 1;
 
        // ここも 1 になることに注意する(メインスレッド)
        Debug.Log($"Task A ID: {Thread.CurrentThread.ManagedThreadId}");
        Debug.Log($"Task A End with result: {result}");
 
        return result;
    }
 
    async Task<int> TaskB(int resultA)
    {
        Debug.Log($"Task B ID: {Thread.CurrentThread.ManagedThreadId}");
        Debug.Log("Task B Start");
        await Task.Delay(5000);
 
        int result = resultA * 2 + 2;
        Debug.Log($"Task B ID: {Thread.CurrentThread.ManagedThreadId}");
        Debug.Log($"Task B End with result: {result}");
        return result;
    }
 
    async Task CannotCreateGameObjectWithoutMainThread()
    {
        // Unity メインスレッドの場合は実行できる
        new GameObject("hoge");
    }
 
    // Start is called once before the first execution of Update after the MonoBehaviour is created
    async void Start()
    {
        Debug.Log($"Main Thread ID: {Thread.CurrentThread.ManagedThreadId}");
 
        int initialValue = 100;
 
        int resultB = -1;
        try
        {
            // TaskA(initialValue) によって TaskA メソッドの処理が開始される
            // TaskA メソッド内の await が実行されるまで、 TaskA は Unity メインスレッドでそのまま実行される
            // TaskA メソッド内の await が実行されたら一時的に処理を中断し、Start メソッドは一時中断となる
            // → これによって Unity UI が固まることなく TaskA 5s 待機を実現できる
            var resultA = await TaskA(initialValue);
 
            // 5s 経過して TaskA が完了すると、 Unity メインスレッド が再度次の行の実行を再開する
            resultB = await TaskB(resultA);
 
            await CannotCreateGameObjectWithoutMainThread();
        }
        // 例外処理が try/catch で書けるため楽
        catch (Exception e)
        {
            throw new Exception($"Error {e.Message}");
        }
 
        await Task.Run(() =>
        {
            Debug.Log("Final Task");
            Debug.Log($"Final callback id: {Thread.CurrentThread.ManagedThreadId}");
            Debug.Log($"Final result: {resultB}");
            Debug.Log("Start method is done");
        });
 
        Debug.Log("Calling Start method");
    }
}

Imgur

async/await は従来の Task とそもそも仕組みが異なる

Task の例では TaskA, TaskB がメインスレッドと異なるスレッドで実行されていました。 これは Task のみで非同期処理を実現した場合、スレッドプールを利用して別スレッドとして実行しないといけないためです。 Task.Run を利用して、別スレッドプールを呼び出さないといけません。

しかし、 async/await の例では TaskA, TaskB はそれぞれメインスレッドでそのまま実行されていました。 これは await というキーワードがあったとき、 C# コンパイラがステートマシンを利用するコードを自動生成します。

クラスとしての Task は非同期操作を表すためのものでしかありません。 https://learn.microsoft.com/ja-jp/dotnet/api/system.threading.tasks.task?view=net-8.0

Task.Run で呼び出せばスレッドプールを利用した別スレッドで実行されます。 また、 async/await で呼び出せば await で呼び出したときのスレッドで一時処理を中断したうえで実行されます。 await 処理の後続処理は SynchronizationContext で決められており Unity であればメインスレッドで実行されます。

Task 自体はただの「非同期処理を扱うためのメッセージクラス」であり、それをどう呼び出すかで実行され方が異なります。 Task.Run, async/await では実行方法が異なることに注意します。

async/await はどうやって実現されているのか

StateMachine については下記を見ると良さそうです。

https://blog.neno.dev/entry/2023/05/27/152855 https://www.docswell.com/s/DeNA_Tech/ZP239L-unitycommunity-05 https://zenn.dev/meson_tech_blog/articles/implement-awaiter-for-unity https://annulusgames.com/blog/async-await/