おれんじりりぃぶろぐ

きっと何者にもなれないエンジニアのブログ

UnityにおけるAwaitAsyncを使った非同期実装について①

概略

Unity2018.1から正式に.Net4.7、C#6への対応がなされます。 C#5からは、非同期的な実装をまるで同期的に記述できるasync/awaitというキーワードが導入されました。今回はasync/awaitを使ったUnityにおけるマルチスレッドプログラミングについて解説を行います。

非同期実装を行う必要性とは(・・?)

同期実装とは、プログラムを記述通り順番に実行していきます。対して、非同期実装では記述順とは異なる順序での実行がなされます。どうして非同期に処理を行う必要があるのでしょうか。

スクリーンショット 2018-04-13 20.13.05.png (69.3 kB)

この図は同期処理を表したものです。HeavyMethod()の実行中は、メインスレッドを専有してしまいUIが固まる、イベントが受けられないなどといった状況に陥ってしまいます。 非同期処理の必要性は、メインスレッドの専有を避けることにあります。

非同期実装を行う方法

  • Unityコルーチン
  • async/await(C#5以降)

Unityでは、非同期な処理を行えるようにコルーチンの仕組みが用意されています。

スクリーンショット 2018-04-13 20.09.49.png (65.8 kB)

コルーチンでは、各処理を高速に切り替え一つの処理がメインスレッドを専有しない仕組みになっています。しかし、全ての処理をメインスレッドで行っているため処理限界に達することがあります。 重たい処理に関してはメインスレッドではなく別スレッドで実行し処理が終わったらメインスレッドに戻ることが求められます。

スクリーンショット 2018-04-13 20.32.30.png (73.6 kB)

C#5以前でもThreadを扱うクラスがありマルチスレッドな実装を行うことは可能でした。しかし、低レベルなAPIレベルでしか提供がなされておらずやや扱いにくい印象でした。C#5以降ではこれを解消する直感的かつ簡潔にまるで同期処理のように非同期処理が記述できるasync/awaitの仕組みがで導入されました。

Taskの説明

マルチスレッドな非同期処理を見ていく前にTaskの説明を行います。 Taskはある一連の処理をまとめた一つの単位になります。

var task = Task.Run(() =>
{
    Debug.Log("HelloWorld");
    Debug.Log("HelloWorld");
    Debug.Log("HelloWorld");
});

これはデバッグログを3回出力するという一つのTaskになります。 Task.Runは、Taskのファクトリメソッドになります。 Taskでの処理は自動的にスレッドプールになります。

マルチスレッドの実装

さて、Taskについての理解ができました。 このTask単位でマルチスレッドに行う方法を見ていきます。

シンプルなマルチスレッド実装

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

public class AsyncSampleScene : MonoBehaviour {

    private async void Start()
    {
        Debug.Log($"ID {Thread.CurrentThread.ManagedThreadId}, Start Start()");
        await TestAsyncMethod();
        Debug.Log($"ID {Thread.CurrentThread.ManagedThreadId}, End Start()");
    }

    private async Task TestAsyncMethod()
    {
        await Task.Run(() => {
            Debug.Log($"ID {Thread.CurrentThread.ManagedThreadId}, [Async] 1");
            Thread.Sleep(1000); 
            Debug.Log($"ID {Thread.CurrentThread.ManagedThreadId}, [Async] 2");
            Thread.Sleep(1000);
            Debug.Log($"ID {Thread.CurrentThread.ManagedThreadId}, [Async] 3");
            Thread.Sleep(1000);
        });
    }
}

結果

ID 1, Start Start() //メインスレッド
ID 86, [Async] 1 // 別スレッド
ID 86, [Async] 2 // 別スレッド
ID 86, [Async] 3 // 別スレッド
ID 1, End Start() // メインスレッド

(ここでのIDはスレッドごとに割り当てられる一意のIDです)

メインスレッドでログを1回出力し、別のスレッドで処理を実行し、最後にまたメインスレッドに戻ってログを出力しています。 処理を別スレッドに逃すことができ、その完了をハンドリングしてメインスレッドに戻ることができました。 ポイントは、awaitとTask.Runです。 実行関数の前にawaitをつけるとTestAsyncMethod()の完了を待って、処理が終わったら後続処理をメインスレッドで続行してくれます。awaitを使ったメソッドではシグネチャにasyncをつけることになっています。 Task.RunはTaskのファクトリーメソッドであり、この中のTaskを別スレッドで実行してくれます。

返り値がある場合は以下のようになります。

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

public class AsyncSampleScene : MonoBehaviour
{
    private async void Start()
    {
        Debug.Log($"ID {Thread.CurrentThread.ManagedThreadId}, Start Start()");
        int j = await TestAsyncMethod();
        Debug.Log($"ID {Thread.CurrentThread.ManagedThreadId}, {j}");
    }

    private async Task<int> TestAsyncMethod()
    {
        int j = await Task.Run<int>(() =>
        {
            int i = 0;
            Debug.Log($"ID {Thread.CurrentThread.ManagedThreadId}, [Async] 1");
            i++;
            Thread.Sleep(1000); // 擬似的な重たい処理
            Debug.Log($"ID {Thread.CurrentThread.ManagedThreadId}, [Async] 2");
            i++;
            Thread.Sleep(1000);
            Debug.Log($"ID {Thread.CurrentThread.ManagedThreadId}, [Async] 3");
            i++;
            Thread.Sleep(1000);
            return i;
        });
        return j;
    }
}

例外処理

別スレッドで発生した例外もキャッチすることが可能です。

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

public class AsyncSampleScene : MonoBehaviour {

    private async void Start()
    {
        Debug.Log($"ID {Thread.CurrentThread.ManagedThreadId}, Start Start()");
        try
        {
            await TestAsyncMethod();
        }
        catch (Exception e)
        {
            Debug.Log(e);
        }
        finally
        {
            Debug.Log($"ID {Thread.CurrentThread.ManagedThreadId}, End Start()");
        }
    }

    private async Task TestAsyncMethod()
    {
        await Task.Run(() => {
            Debug.Log($"ID {Thread.CurrentThread.ManagedThreadId}, [Async] 1");
            throw new Exception("Exception!");
        });
    }
}

結果

ID 1, Start Start()
ID 272, [Async] 1
System.Exception: Exception!
ID 1, End Start()

Task.Wait()の罠

次に示すのは、デットロックを引き起こすヤバイコードです。 (UnityEditorで実行すると応答なくなって強制終了するしかなくなります)

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

public class AsyncSampleScene : MonoBehaviour {

    private async void Start()
    {
        Debug.Log($"ID {Thread.CurrentThread.ManagedThreadId}, Start Start()");
        Task t = KusoAsyncMethod();
        t.Wait(); // tのTaskが終わるまで待つ
        Debug.Log($"ID {Thread.CurrentThread.ManagedThreadId}, End Start()");
    }

    private async Task KusoAsyncMethod()
    {
        await Task.Run(() => {
            Debug.Log($"ID {Thread.CurrentThread.ManagedThreadId}, [Async] 1");
            Thread.Sleep(1000);
            Debug.Log($"ID {Thread.CurrentThread.ManagedThreadId}, [Async] 2");
            Thread.Sleep(1000);
            Debug.Log($"ID {Thread.CurrentThread.ManagedThreadId}, [Async] 3");
            Thread.Sleep(1000);
        });
    }
}

なぜこのようなことになってしまうのでしょうか。

スクリーンショット 2018-04-14 0.06.22.png (111.2 kB)

図にすると上図のような状態に陥ってしまいます。 一方で、awaitを使うと下図になり挙動が異なることが分かります。

スクリーンショット 2018-04-14 0.27.05.png (105.1 kB)

まとめ

  • Task、async/awaitを使うことでマルチスレッドな非同期処理を直感的に記述することができる
  • Taskクラスを使うとスレッドプールを自動で行ってくれる
  • 別スレッドで発生した例外もキャッチすることができる
  • Task.Wait()またはTask.Result()をデットロックに陥る危険性がある

参考