【Unity】【C#】Unityでasync/await、Taskを完全に理解する!基礎から応用まで非同期処理を総まとめ

Unityでasync/await、Taskを実用レベルでしっかり理解するために必要な情報をまとめました。

Unity2018.2
C#6

はじめに - async?await?Taskとは?

async/await、Taskを学ぶ上で最初にするべきことは非同期処理の概念を理解することです。

次の記事では非同期処理の概念とasync/await、Taskの基本的な使い方をわかりやすく説明しています。
必要に応じて参照してください。

light11.hatenadiary.com

MonoBehaviourのメソッド(Startとか)を非同期メソッドにする

前節で紹介した記事の最後で、MonoBehaviourのStart()から非同期メソッドを呼ぶときには
var _ = ExampleAsync();のように書くという話をしました。
それでも問題ないのですが、そもそもこのStart()メソッドを非同期メソッドにしてしまう、という手もありそうです。

private async Task Start()
{
    await ExampleAsync();
}

戻り値はTaskにしてますが、これらは基本的にUnityからしか呼ばれないメソッドになるので、
例外的なケースとしてasync voidでも問題はないんじゃないかなーとも思います。

結果を返すTaskを作る

例えばファイルを読み込んで中身のテキストを返す非同期メソッドなど、
結果を呼び出し元に渡す必要がある場合は次のようにGenericで型を指定します。

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

public class Example : MonoBehaviour {

    private async Task Start()
    {
        var result = await ExampleAsync();
        Debug.Log(result); // 1
    }
    
    // Genericで型を指定
    private async Task<int> ExampleAsync()
    {
        await Task.Run(() => {
            Thread.Sleep(1000);
        });
        // Genericで指定した型をreturnする
        return 1;
    }
}

複数のTaskを直列で実行する

Taskを直列で実行するのは特に難しくないです。何回もawaitするだけです。

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

public class Example : MonoBehaviour {

    private async Task Start()
    {
        for (int i = 0; i < 10; i++) {
            await ExampleAsync(i);
        }
        Debug.Log("end all.");
    }
    
    private async Task ExampleAsync(int index)
    {
        Debug.Log("start : " + index);
        await Task.Run(() => {
            Thread.Sleep(1000 * index);
        });
        Debug.Log("end : " + index);
    }
}

複数のTaskを並列で実行する

Taskを並列で処理するにはTask.WhenAllを使う必要があります。
これによりリスト内のすべてのTaskの完了を待つことができます。

using UnityEngine;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;

public class Example : MonoBehaviour {

    private async Task Start()
    {
        var tasks = new List<Task>();
        for (int i = 0; i < 10; i++) {
            tasks.Add(ExampleAsync(i)); // この時点でそれぞれのTaskは開始する
        }
        await Task.WhenAll(tasks); // Task.WhenAll()ですべてのTaskの完了を待つ
        Debug.Log("end all.");
    }
    
    private async Task ExampleAsync(int index)
    {
        Debug.Log("start : " + index);
        await Task.Run(() => {
            Thread.Sleep(1000 * index);
        });
        Debug.Log("end : " + index);
    }
}

結果を受け取る場合はこんな感じです。

using UnityEngine;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;

public class Example : MonoBehaviour {

    private async Task Start()
    {
        var tasks = new List<Task<int>>();
        for (int i = 0; i < 10; i++) {
            tasks.Add(ExampleAsync(i));
        }
        var results = await Task.WhenAll(tasks);
        foreach (var result in results) {
            Debug.Log(result);
        }
    }
    
    private async Task<int> ExampleAsync(int index)
    {
        Debug.Log("start : " + index);
        await Task.Run(() => {
            Thread.Sleep(1000 * index);
        });
        Debug.Log("end : " + index);
        return index;
    }
}

結果の型がバラバラな場合はこんな感じにするしかなさそうです。多分。

using UnityEngine;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;

public class Example : MonoBehaviour {

    private async Task Start()
    {
        Task<int> taskInt = ExampleAsyncInt();
        Task<float> taskFloat = ExampleAsyncFloat();
        await Task.WhenAll(taskInt, taskFloat);
        Debug.Log(taskInt.Result);
        Debug.Log(taskFloat.Result);
    }
    
    private async Task<int> ExampleAsyncInt()
    {
        await Task.Run(() => Thread.Sleep(1000));
        return 1;
    }
    private async Task<float> ExampleAsyncFloat()
    {
        await Task.Run(() => Thread.Sleep(1000));
        return 0.01f;
    }
}

Taskをキャンセルする

キャンセルの方法は少し独特です。

  1. Cancel()メソッドを持つCancellationTokenSourceTokenを非同期メソッドに渡す
  2. 非同期メソッド内でキャンセルチェックするタイミングでCancellationToken.ThrowIfCancellationRequested();を呼ぶ
  3. キャンセルされていたら例外が発生するので非同期メソッド呼び出し元でキャッチ

コードを見てもらったほうが早そうです。

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

public class Example : MonoBehaviour {

    private CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();

    private async Task Start()
    {
        try {
            Debug.Log("start.");
            // キャンセルしたら例外を投げてもらえるようにCancellationToken.Tokenを渡す
            await ExampleAsync(_cancellationTokenSource.Token);
            Debug.Log("end.");
        }
        catch (OperationCanceledException e) {
            // キャンセル処理はここ
            if (e.CancellationToken == _cancellationTokenSource.Token) {
                Debug.Log("canceled.");
            }
        }
    }
    
    private async Task ExampleAsync(CancellationToken cancellationToken = default(CancellationToken))
    {
        cancellationToken.ThrowIfCancellationRequested(); // キャンセルチェック(この時点でキャンセルされていたら例外を投げる)

        for (int i = 0; i < 5; i++) {
            await Task.Run(() => Thread.Sleep(1000));
            cancellationToken.ThrowIfCancellationRequested(); // キャンセルチェック(この時点でキャンセルされていたら例外を投げる)
        }
    }

    private void Update()
    {
        // Cを押したらキャンセル
        if (Input.GetKeyDown(KeyCode.C)) {
            // キャンセル処理
            _cancellationTokenSource.Cancel();
        }
    }

    private void OnDestroy()
    {
        // 破棄時にはキャンセル
        _cancellationTokenSource.Cancel();
    }
}

このあたりは次の記事を参考にさせていただきました。

qiita.com

例外処理について

例外処理は次のように書きます。

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

public class Example : MonoBehaviour {

    private async Task Start()
    {
        try {
            await ExampleAsync();
        }
        catch {
            // Catchできる
            Debug.Log("catch.");
        }
    }
    
    private async Task ExampleAsync()
    {
        await Task.Run(() => Thread.Sleep(1000));
        throw new Exception("example exception.");
    }
}

ただawaitしてないとCatchできないっぽいです。
これは注意しないといけなそうです。

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

public class Example : MonoBehaviour {

    private void Start()
    {
        try {
            // awaitしてない
            var _ = ExampleAsync();
        }
        catch {
            // Catchできない
            Debug.Log("catch.");
        }
    }
    
    private async Task ExampleAsync()
    {
        await Task.Run(() => Thread.Sleep(1000));
        throw new Exception("example exception.");
    }
}

このあたりは次の記事を参考にさせていただきました。

orange-lily27.hatenablog.com

タイムアウト処理

タイムアウトタイムアウト時間だけDelayするTaskを別で作って、
早く返ってきた方がどちらかで判定します。

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

public class Example : MonoBehaviour {

    private async Task Start()
    {
        var timeoutMillis = 2000;
        var task = ExampleAsync();
        if (await Task.WhenAny(task, Task.Delay(timeoutMillis)) == task) {
            Debug.Log("end.");
        }
        else {
            Debug.Log("timeout.");
        }
    }
    
    private async Task ExampleAsync()
    {
        await Task.Run(() => Thread.Sleep(3000));
    }
    
}

下記の記事ではCancellationTokenを使った方法も紹介されています。
どちらを使うかはケースバイケースな感じがします。

qiita.com

await後に元のスレッドに戻さない

Unityではawaitの前後で同じスレッドで処理が行われるようです。

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

public class Example : MonoBehaviour {

    private async Task Start()
    {
        await ExampleAsync();
    }
    
    private async Task ExampleAsync()
    {
        Debug.Log(Thread.CurrentThread.ManagedThreadId); // 1
        await Task.Run(() => Thread.Sleep(1000));
        Debug.Log(Thread.CurrentThread.ManagedThreadId); // 1
    }
}

Task.ConfigureAwait(false)とするとTaskが実行されたスレッドでその後の処理が行われます。

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

public class Example : MonoBehaviour {

    private async Task Start()
    {
        await ExampleAsync();
    }
    
    private async Task ExampleAsync()
    {
        Debug.Log(Thread.CurrentThread.ManagedThreadId);
        await Task.Run(() => Thread.Sleep(1000)).ConfigureAwait(false); // ConfigureAwait(false)にするとこのTaskと同じスレッドで
        Debug.Log(Thread.CurrentThread.ManagedThreadId); // これが実行される
    }
}

参考

qiita.com

orange-lily27.hatenablog.com

qiita.com

【Unite Tokyo 2018】さては非同期だなオメー!async/await完全に理解しよう