LoginSignup
171
133

More than 5 years have passed since last update.

Unity2017で始めるTask(async~await)

Last updated at Posted at 2017-07-21

C#6.0時代のUnity の続きとして、書く書く詐欺だったUnityでのTask(async~await)についてになります。
(さすがにUnity2017.1が正式リリース されたので・・。)

なお、UnityでTaskを使うにあたっての事前準備はそちら を先に見てください

Task

まず、前提知識としてTaskってなんぞや? という事なんですが、まんま和訳した「仕事」ってのがしっくりくると思います。
やってほしい仕事=タスク。
スレッドと混同されがちですが、スレッドは仕事をする側で、仕事そのものでは無いんじゃないかなーと(ざっくり)

そして、Task(async,await)を扱うにあたって

  • Task.Delay
  • Task.Run
  • SynchronizedContext
  • TaskCompletionSource

あたりを分かった気でいる必要があります。

しかし、先に言っておきたいんですがTaskは難しいです。
覚えることが多く、制約も多く、苦労して使えるようになっても、
「それコルーチン(UniRX)で出来るよね?」
「なんでそんなわざわざ面倒な方法で・・・?」
「なんだ。C#分かってますアピールか。」
と言われたりします。絶対。(偏見)

なので、Unity2017でTaskが使えるようになったからって、無理してTask使わなくてもいいと思っています(特に業務・複数人でUnity使っているような環境の方は)

Unity年表上

  • Coroutine
  • UniRX
  • Task ← NEW!!

ってだけ、選択肢が増えただけ。それぐらいの立ち位置がまだいいんじゃないかなぁと個人的には思います。
(とは言え時代は動き出してしまったし、TaskにはTaskの便利な使い方があるので、難しいところ。ぐぬぬ。)

では。まずジャブから。

Task.Delay

Task.Delayは指定ミリ秒(もしくは指定TimeSpan)だけ待ってから完了するTaskです。

例として

  • 0~9の値を1秒間隔で表示したい。

という課題があるとします。

例えばこんな感じで

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

public class DotNet46Test : MonoBehaviour 
{
    public Button btn;//InspectorからuGUIのButtonをセットしておく
    private void Start()
    {
        btn.onClick.AddListener(()=>Func1());
    }

    private void Func1()
    {
        LogOutput("---Start---");
        for (var i = 0; i < 10; ++i)
        {
            Task.Delay(1000);
            LogOutput($"Count:{i}");
        }
        LogOutput("---End---");
    }

    private void LogOutput(string message)
    {
        Debug.Log(DateTime.Now.ToString("yyyy/MM/dd hh:mm:ss") + "\t" + message);
    }
}

さて。これでボタンをクリックすると、ログはどのような出力になるかわかりますでしょうか。

これは

出力
2017/07/18 05:26:37 ---Start---
2017/07/18 05:26:37 Count:0
2017/07/18 05:26:37 Count:1
2017/07/18 05:26:37 Count:2
2017/07/18 05:26:37 Count:3
2017/07/18 05:26:37 Count:4
2017/07/18 05:26:37 Count:5
2017/07/18 05:26:37 Count:6
2017/07/18 05:26:37 Count:7
2017/07/18 05:26:37 Count:8
2017/07/18 05:26:37 Count:9
2017/07/18 05:26:37 ---End---

見ての通り、Start→Count0~9→Endまで瞬にして出力されます。 全然Delayしてない。

と、いうのも、Taskはそもそも非同期に実行されるものなので、Task.Delayは正しく非同期的に1秒待っているんですが、非同期が故にその次の行のLog出力にそのまま進んでしまいます。

すなわちTask.Delayの完了を待つ処理が入っていないのでこうなってしまいます。
(スレッド自体を寝かせるThread.Sleepとは意味・振る舞いが違う!)

ContinueWith

「Taskの完了を待って、続きの処理を書きたい場合にはContinueWithを使うのがいいとばっちゃが言ってた。」

じゃぁ、こうかな?と

    private void Start()
    {
//        btn.onClick.AddListener(()=>Func1());
        btn.onClick.AddListener(()=>Func2());
    }
    private void Func2()
    {
        LogOutput("---Start---");
        for (var i = 0; i < 10; ++i)
        {
            Task.Delay(1000).ContinueWith(t => LogOutput($"Count:{i}"));
        }
        LogOutput("---End---");
    }

結果はひどいもんです。

出力
2017/07/18 05:28:38 ---Start---
2017/07/18 05:28:38 ---End---
2017/07/18 05:28:39 Count:10
2017/07/18 05:28:39 Count:10
2017/07/18 05:28:39 Count:10
2017/07/18 05:28:39 Count:10
2017/07/18 05:28:39 Count:10
2017/07/18 05:28:39 Count:10
2017/07/18 05:28:39 Count:10
2017/07/18 05:28:39 Count:10
2017/07/18 05:28:39 Count:10
2017/07/18 05:28:39 Count:10

あちゃー。これは

  • Task.Delay(1000) で1秒待って
  • ContinueWith(後続処理)でログ出力。

が一塊のTaskとして、10連続で呼ばれるので、1秒に1回Logが出るという事にはならず、1秒待ってから。しかもラムダ式がfor文のiをキャプチャするので、全部Count:10になってしまいます。 悪化しとるがな

ちがう。違うんだ。
なんで今までUnityでコルーチン使えば

    private IEnumerable CountIterator()
    {
        LogOutput("---Start---");
        for (var i = 0; i < 10; ++i)
        {
            yield return new WaitForSeconds(1);
            LogOutput($"Count:{i}");
        }
        LogOutput("---End---");
    }

で、StartCoroutine(CountIterator());するだけだったのに!Taskってなんなんだよ!

貴方は裏切られた気持ちでいっぱいになります(なりません)

そんな貴方に async await

コルーチン方式で書けるんであれば、じつはasync~await方式に変えるのは簡単です。

1.終了を待ちたいTaskにawaitを付ける。

awaitを付けたTaskは完了待ち+結果取り出しになります。(結果取り出しについては後述)

        private void Func3()
        {
            LogOutput("---Start---");
            for (var i = 0; i < 10; ++i)
            {
                await Task.Delay(1000);
                LogOutput($"Count:{i}");
            }
            LogOutput("---End---");
        }

これで、Task.Delayの終了をそこで待つので、1秒間隔でCount:0~9が表示されそうです。
まぁ、これじゃビルド通らないんですけど

というのも、awaitはasync付きのメソッド(またはラムダ式)の中にしか書けないという制約があるんですね。
なので、
2.awaitを付けたからには、async をメソッド(ラムダ式)に付ける

        private async void Func3()
        {
            LogOutput("---Start---");
            for (var i = 0; i < 10; ++i)
            {
                await Task.Delay(1000);
                LogOutput($"Count:{i}");
            }
            LogOutput("---End---");
        }

おや。簡単ですね。 これで完成!!? いや、あともうちょっと。

3. async void は良いこと無いので基本async Task に変える

詳しいことはまたいつか書きますが、世の中にはasync void 警察がいて、軽い気持ちでasync voidを残しておくとすごく叱責されるので注意してください。

さておき、最終形としては

    private void Start()
    {
        //btn.onClick.AddListener(()=>Func1());
        //btn.onClick.AddListener(()=>Func2());
        btn.onClick.AddListener(()=>Func3());
    }
    private async Task Func3()
    {
        LogOutput("---Start---");
        for (var i = 0; i < 10; ++i)
        {
            await Task.Delay(1000);
            LogOutput($"Count:{i}");
        }
        LogOutput("---End---");
    }

ボタンを押すと、

出力
2017/07/18 05:30:37 ---Start---
2017/07/18 05:30:38 Count:0
2017/07/18 05:30:39 Count:1
2017/07/18 05:30:40 Count:2
2017/07/18 05:30:41 Count:3
2017/07/18 05:30:42 Count:4
2017/07/18 05:30:43 Count:5
2017/07/18 05:30:44 Count:6
2017/07/18 05:30:45 Count:7
2017/07/18 05:30:46 Count:8
2017/07/18 05:30:47 Count:9
2017/07/18 05:30:47 ---End---

ようやくCount0~9が1秒間隔で出力されました。

これってどのスレッドが実行しているの・・?

さて。このFunc3は非同期処理っぽいですが、スレッド的にはどうなっているんでしょうか。

実のところ、メインスレッド以外で処理されているのは Task.Delay(1000)だけです。
1000ミリ秒を別のスレッドで休んでるだけで、全体的にはメインスレッドで処理されています。
LogOutput処理をちょっと改造してThread.CurrentThread.ManagedThreadIdも出力してみるとよくわかります
(ついでにTask.DelayにContinueWithを使った後続処理でもLogを出力してみました。)

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

public class DotNet46Test : MonoBehaviour {
    public Button btn;//InspectorからuGUIのButtonをセットしておく
    private void Start()
    {
        LogOutput("MainThreadID");
        btn.onClick.AddListener(() => Func3());
    }
    private async Task Func3()
    {
        LogOutput("---Start---");
        for (var i = 0; i < 10; ++i)
        {
            await Task.Delay(1000).ContinueWith(t=>LogOutput($"ContinueWith:{i}"));
            LogOutput($"Count:{i}");
        }
        LogOutput("---End---");
    }

    private void LogOutput(string message)
    {
        Debug.Log(DateTime.Now.ToString("yyyy/MM/dd hh:mm:ss") + "\t" + message + ":" + Thread.CurrentThread.ManagedThreadId);
    }
}
出力
2017/07/18 10:58:35 MainThreadID:1
2017/07/18 10:58:38 ---Start---:1
2017/07/18 10:58:39 ContinueWith:0:83
2017/07/18 10:58:40 Count:0:1
2017/07/18 10:58:41 ContinueWith:1:82
2017/07/18 10:58:41 Count:1:1
・
・
・
2017/07/18 10:58:49 ContinueWith:9:43
2017/07/18 10:58:49 Count:9:1
2017/07/18 10:58:49 ---End---:1

Count:0~9はすべてThreadID:1(メインスレッドIDと同じ)で、
逆にContinueWithでの後続処理のThreadIDはすべて異なるという結果に。

メインスレッドで動いているということはどういうことかというと、重い処理を走らせたら画面が止まるということです。 async付けたら非同期、メインスレッド外だと思っている人も時々いるようですが、そうではないです。
でも・・え。止まっちゃダメじゃん。 何のためのTask,非同期なのさ。

Task.Run

では、明示的に何か処理をメインスレッド以外で動かしたいとなった場合にどうすればよいかというと、Task.Runを使うと別スレッドで(しかもスレッドプールというイカした仕組みで)動くようになります。

テストのために重い処理を持ってきました。
大分昔に書いた記事ですが、
Unityでブラー画像動的生成
の、アルファチャンネルをブラー化したTexture2Dを作成する処理です。

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

public static class BuildTexture
{
    private static Dictionary<Texture2D, Texture2D> _textureCache = new Dictionary<Texture2D, Texture2D>();

    public static Texture2D CreateBlurTexture(Texture2D tex, float sig, bool isCache = true)
    {
        if (isCache && _textureCache.ContainsKey(tex)) return _textureCache[tex];

        int W = tex.width;
        int H = tex.height;
        int Wm = (int)(Mathf.Ceil(3.0f * sig) * 2 + 1);
        int Rm = (Wm - 1) / 2;

        //フィルタ
        float[] msk = new float[Wm];

        sig = 2 * sig * sig;
        float div = Mathf.Sqrt(sig * Mathf.PI);

        //フィルタの作成
        for (int x = 0; x < Wm; x++)
        {
            int p = (x - Rm) * (x - Rm);
            msk[x] = Mathf.Exp(-p / sig) / div;
        }

        var src = tex.GetPixels(0).Select(x => x.a).ToArray();
        var tmp = new float[src.Length];
        var dst = new Color[src.Length];

        //垂直方向
        for (int x = 0; x < W; x++)
        {
            for (int y = 0; y < H; y++)
            {
                float sum = 0;
                for (int i = 0; i < Wm; i++)
                {
                    int p = y + i - Rm;
                    if (p < 0 || p >= H) continue;
                    sum += msk[i] * src[x + p * W];
                }
                tmp[x + y * W] = sum;
            }
        }
        //水平方向
        for (int x = 0; x < W; x++)
        {
            for (int y = 0; y < H; y++)
            {
                float sum = 0;
                for (int i = 0; i < Wm; i++)
                {
                    int p = x + i - Rm;
                    if (p < 0 || p >= W) continue;
                    sum += msk[i] * tmp[p + y * W];
                }
                dst[x + y * W] = new Color(1, 1, 1, sum);
            }
        }

        var createTexture = new Texture2D(W, H);
        createTexture.SetPixels(dst);
        createTexture.Apply();

        if (isCache)
        {
            _textureCache.Add(tex, createTexture);
        }

        return createTexture;
    }

    public static void Release()
    {
        _textureCache.Clear();
    }
}

そして、このブラー処理を呼び出す処理も用意しました。

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

public class BlurTest : MonoBehaviour
{
    public Button button;
    public SpriteRenderer spriteRenderer;

    void Start ()
    {
        button?.onClick.AddListener(() => {
            var btex = BuildTexture.CreateBlurTexture(spriteRenderer.sprite.texture, 32.0f, false);
            spriteRenderer.sprite = Sprite.Create(btex, spriteRenderer.sprite.rect, new Vector2(0.5f, 0.5f));
        });
    }
}

ボタンをクリックすると、同じ場所に重ねてある片方のSpriteのTextureをアルファチャンネルをブラー化したテクスチャに変更するというものです。

image.png
  ↓
image.png
(オーラをまとった!!)

しかしながら、これは全てメインスレッドで動いているので、僕のPC環境だとボタンを押してから2~3秒UnityEditorそのものが固まります。

では。この処理を非同期(別スレッドで並列処理)できるように変更します。

このブラー化する処理で明らかに重そうなのはこの、恐怖の3重ループ*2で全てのピクセルの近傍ピクセルを走査しまくっているところです。

重いところ
        //垂直方向
        for (int x = 0; x < W; x++)
        {
            for (int y = 0; y < H; y++)
            {
                float sum = 0;
                for (int i = 0; i < Wm; i++)
                {
                    int p = y + i - Rm;
                    if (p < 0 || p >= H) continue;
                    sum += msk[i] * src[x + p * W];
                }
                tmp[x + y * W] = sum;
            }
        }
        //水平方向
        for (int x = 0; x < W; x++)
        {
            for (int y = 0; y < H; y++)
            {
                float sum = 0;
                for (int i = 0; i < Wm; i++)
                {
                    int p = x + i - Rm;
                    if (p < 0 || p >= W) continue;
                    sum += msk[i] * tmp[p + y * W];
                }
                dst[x + y * W] = new Color(1, 1, 1, sum);
            }
        }

なので、この処理をTask.Runで別スレッド処理に逃がしてあげます。

        Task.Run(() => {
            //垂直方向
            //<省略>
            //水平方向
            //<省略>
        });

このTaskの終了を待たずにdstを使う後続処理を走らせるわけにはいかないので、awaitキーワードを追加します。

        await Task.Run(() => {
            //垂直方向
            //<省略>
            //水平方向
            //<省略>
        });

awaitを使うからには、asyncをメソッドに付けます(2回目)

    public static async Texture2D CreateBlurTexture(Texture2D tex, float sig, bool isCache = true)

asyncメソッドは戻り値がTaskにする必要があるので、Taskに。 しかもこれはもともとTexture2Dを返すメソッドなので、
Task<Texture2D>に変更します(あと、非同期処理にするので、メソッド名にAsync等を付けるのが一般的です。)

    public static async Task<Texture2D> CreateBlurTextureAsync(Texture2D tex, float sig, bool isCache = true)

これで、ブラー処理自体のTask化が完了しました。
もちろん、呼び出し元にも変更が必要です(メソッド名も変えちゃったし)

    void Start ()
    {
        button?.onClick.AddListener(() => {
            var btex = BuildTexture.CreateBlurTexture(spriteRenderer.sprite.texture, 32.0f, false);
            spriteRenderer.sprite = Sprite.Create(btex, spriteRenderer.sprite.rect, new Vector2(0.5f, 0.5f));
        });
    }

まず、メソッド名が変わってますし、非同期処理なので、awaitで待つようにしてあげる必要があります。

await追加、メソッド名変更
    void Start ()
    {
        button?.onClick.AddListener(() => {
            var btex = await BuildTexture.CreateBlurTextureAsync(spriteRenderer.sprite.texture, 32.0f, false);
            spriteRenderer.sprite = Sprite.Create(btex, spriteRenderer.sprite.rect, new Vector2(0.5f, 0.5f));
        });
    }

そしてawaitを使うにはasyncキーワードが必要になります(3回目)
しかし、これはラムダ式・・・。 どこにasyncを・・・。 となりがちですが、ラムダ式への引数の前、今回は引数無しなので()の手前でOKです。

async追加
    void Start ()
    {
        button?.onClick.AddListener(async() => {
            var btex = await BuildTexture.CreateBlurTextureAsync(spriteRenderer.sprite.texture, 32.0f, false);
            spriteRenderer.sprite = Sprite.Create(btex, spriteRenderer.sprite.rect, new Vector2(0.5f, 0.5f));
        });
    }

これで、呼び出し元も修正完了です。
なんとこれだけの修正でTask化され、無事UnityEditorが固まらなくなるのです!!

「完了?あれ? でもCreateBlurTextureAsyncの戻り値はTexture2DからTask<Texture2D>に変更してたよね?変数btexにはTask<Texture2D>が入っているのでは?」

という当然の疑問が残りますが、これで問題はありません。

これがawaitの機能。 終了を待つと同時に、Taskの結果を取り出してくれるのです。 (伏線回収)
本来、Taskから結果(T)を取得するにはTaskのResultプロパティを参照する必要があるのですが、awaitはそのResultを勝手に取り出してくれるのでした。 超便利。

どうでしょう。Task化。そんな難しくないですね!?(しかし、本当の沼はこれからなのでした)

SynchronizedContext

さて。先ほどのCreateBlurTextureのTask化(Async化)ですが、あえてちょっとミスリードさせました。

このブラー化する処理で明らかに重そうなのはこの、恐怖の3重ループ*2で全てのピクセルの近傍ピクセルを走査しまくっているところです。
...
なので、この処理をTask.Runで別スレッド処理に逃がしてあげます。

はい。ここですね。
さも、「ボトルネックなのでTask.Runで別スレッドに逃がしましたよ」的な感じで書きましたが、実際にはそこぐらいしかTask.Runで逃がせられなかったのです。

と、いうのも、Task.RunTask.Delay、それらをContinueWithした後続処理なんかは、別スレッドで動いています。
そして、Unity固有の処理はメインスレッド以外からアクセスするとエラーになるという制約があります。

例えば

Task.Run(()=>Debug.Log(transform.position));

たったこの1行でアウトです。ログには

image.png
get_transform can only be called from the main thread.

こんな感じで「transformはメインスレッドからしか呼ばせないよ!!」と言われちゃいます。transform取るくらい・・・って思わなくもないんですが。

同じように例えばTexture2Dの横幅(Width)をTask.Runの中で取得したりすると同じく
image.png
超怒られます。

なので、今回はUnityに関係ない、配列操作部分だけをTask.Runで非同期化したわけです。

でも、なんとかしてTask.Run(やTask.Delayの後続タスク等)でもUnityの処理が呼びたい!!

そんな時に使うのがSynchronizedContext(同期コンテキスト)です。

方法は2つ

方法1.メインスレッドでSynchronizationContext.Currentを取得し、Postを使ってUnityの処理を呼ぶ

方法1例
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public class ContextTest : MonoBehaviour
{
    void Start()
    {
        var context = SynchronizationContext.Current;
        Task.Run(async () => {
            await Task.Delay(1000);
            //Debug.Log(transform.position);    //ここだと別スレッドなのでエラー
            context.Post(state => Debug.Log(transform.position), null);
        });
    }
}

これはエラーになりません。
javaのrunOnUiThread的な使い方ですね。
なお間違ってもSynchronizationContext.CurrentをTask.Runの中で取得したりしないように。 多分nullが入ってます。
そして、Postした処理は即時実行されるわけでもなく、さらにPost終了を待機できるわけでもないので要注意です。 Postなので、通知専用。 非同期処理の進捗表示ぐらいにしか使えなさそう。

方法2.メインスレッドでSynchronizationContext.Currentを取得し、awaitによって戻るContextを先にSynchronizationContext.SetSynchronizationContextでセットしておく

方法2例
    void Start()
    {
        var context = SynchronizationContext.Current;
        Task.Run(async () => {
            SynchronizationContext.SetSynchronizationContext(context);
            //Debug.Log(transform.position);    //ここだとawait前なのでエラー
            await Task.Delay(1000);
            Debug.Log(transform.position);      //ここだとOK
        });
    }

Task.RunTask.Delayなんかは別スレッドで処理されているというのは既に何度も出てきてるんですが、awaitで別スレッドの結果を待って何事もなかったようにスレッドがメインスレッド(というか、await前のスレッド)に戻るのはこのawaitがTaskを呼び出した瞬間のSynchronizedContextをキャプチャしておいて、終了後、キャプチャしておいたSynchronizedContextのPostを使って後続処理を走るように上手くやっているからとかなんとか・・・(あってるのかな・・・?)

なので先に自分でSetSynchronizationContextを呼んでセットしてあげれば、await後にはUnityの処理を呼んでもよいという寸法。うーん、ややこしやややこしや。

どちらにせよ、Task.Runの中でUnity絡みの操作を行うのは面倒ですが・・・

そもそもそもそも

そのTask.Run要りますか?
今回の例ではもちろん必要ないですが、世の中には不要なTask.Runで溢れかえっている可能性が高いです。
無駄なTask.Runによるラップが無ければ、SynchronizedContextを使ったり、特別意識する必要も(そんなに)ないんじゃないかなーと。

もっと書きたいけど、書ききれなかったもの

  • TaskCompletionSource
  • CancellationTokenSource(CancellationToken)

いつか書きます。
あと、ボタンのクリックを待つTaskを作る拡張メソッドだったり、それを組み合わせて
「10秒経過かその間にボタンがクリックされたら次の処理」
みたいなコルーチンでやると意外と面倒というか、状態を保持する必要がある処理があっさり書けたりするので、実例交えて紹介したかったんですが。

すでにそこそこな長さになってきたので、それらも含めまたおいおい・・・。

良いTaskライフを

171
133
2

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
171
133