【Unity】【UniTask】マルチスレッドとしてのTaskからUniTaskを眺める

Taskのマルチスレッドを実現するための機能という側面からUniTaskについて考えてみます。

Unity2020.1.10
UniTask 2.0.37

はじめに

UniTaskはUnityにおいてTaskの代わりに使えるように作られたライブラリで、処理効率に優れています。

github.com

Taskの代わりといいましたが、Taskの役割には「非同期処理」と「マルチスレッド」という二つの側面があります。
そしてこのUniTaskはどちらかというとこのうちの非同期処理の方を代替するためのものとのことです。

UniTaskはどちらかというとJavaScript的(シングルスレッドのための非同期の入れ物)に近いです。

neue cc - UniTask - Unity + async/awaitの完全でハイパフォーマンスな統合より

このあたりをしっかり理解しないことにはUniTaskを適切に使うことはできなそうです。
そこでこの記事ではTaskのマルチスレッドのための仕組みをまとめてからUniTaskについて考えてみます。

SynchronizationContextとは?

さてUniTaskやTaskについての話をする前に、まずSynchronizationContextについて理解する必要があります。
SynchronizationContextとは処理を行うスレッドを決定するためのオブジェクトです。

例えばUnityのメインスレッドで以下のようにSynchronizationContext.Currentを取得するとUnityEngine.UnitySynchronizationContextが得られることが確認できます。

using System.Threading;
using UnityEngine;

public class Example : MonoBehaviour
{
    private void Start()
    {
        // UnityEngine.UnitySynchronizationContext
        Debug.Log(SynchronizationContext.Current);
    }
}

これはUnityのメインスレッドで処理を行うためのSynchronizationContextです。
以下のように別スレッドでの処理中にSynchronizationContext.Post()を呼ぶことでメインスレッドに処理を戻すことができます。

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

public class Example : MonoBehaviour
{
    private void Start()
    {
        // UnitySynchronizationContextを取得しておく
        var unitySynchronizationContext = SynchronizationContext.Current;
        
        // スレッドIDを確認: 1(メインスレッド)
        Debug.Log(Thread.CurrentThread.ManagedThreadId);
        
        Task.Run(() =>
        {
            // 1以外(サブスレッド)
            Debug.Log(Thread.CurrentThread.ManagedThreadId);
            
            // キャプチャしたUnitySynchronizationContextを使ってメインスレッドに処理を戻す
            unitySynchronizationContext.Post(d =>
            {
                // 1
                Debug.Log(Thread.CurrentThread.ManagedThreadId);
            }, null);
        });
    }
}

SynchronizationContextはこんな感じの挙動です。

TaskAwaiterとTaskのスレッド管理

次にTaskのお話です。
Taskは以下のようにawaitすることで一部の処理をサブスレッドに移すことができます。

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

public class Example : MonoBehaviour
{
    private async void Start()
    {
        // メインスレッド
        Debug.Log(Thread.CurrentThread.ManagedThreadId);
        
        await Task.Run(() =>
        {
            // サブスレッド
            Debug.Log(Thread.CurrentThread.ManagedThreadId);
        });
        
        // awaitを抜けるとメインスレッドに戻る
        Debug.Log(Thread.CurrentThread.ManagedThreadId);
    }
}

ここで、awaitを抜けた時に処理がメインスレッドに戻っていますが、この処理は誰がやっているのでしょうか?
これを理解するにはawaitについてもう少し理解を深める必要があります。

あらためて、Taskの非同期処理を待つ際には以下のようにTaskをawaitします。

await Task.Run(() => { });

このawaitは実はTask用のものではなくGetAwaiter()というメソッドを持つオブジェクトにつけられる演算子です。
TaskクラスにはこのGetAwaiter()が実装されており、TaskAwaiterというクラスを返します。

このTaskクラスは生成されるときに元のスレッドのSynchronizationContextをキャプチャします。
さらに非同期処理の終了時にはTaskAwaiterに定義されているOnCompletedが呼ばれますが、
そのときにキャプチャしたSynchronizationContextのPost()メソッドをコールして元のスレッドに処理を戻します。

awaitを抜けた時にスレッドが戻る仕組みはこのように実現されています。

UniTaskはSynchronizationContextを使わない

さてここまでSynchronizationContextについて説明してきましたが、UniTaskはこのSynchronizationContextをそもそも使っていません。

これについて作者のneueccさんはブログに以下のように書いています。

掲げたのはNo Task, No SynchronizationContext。何故かというと、そもそもUnityの非同期って、C++のエンジン側で駆動されていて、C#スクリプティングレイヤーに戻ってくる際には既にメインスレッドで動くんですよね。 (中略) UniTaskはどちらかというとJavaScript的(シングルスレッドのための非同期の入れ物)に近いです。Taskは、そうした非同期の入れ物に加えてマルチスレッドのためなどなど、とにかく色々なものが詰まりすぎていて、あまりよろしくはない。非同期とマルチスレッドは違います。明確に分けたほうが良いでしょうし、UnityではC# JobSystemを使ったほうが良いので、カジュアルな用途以外(まぁラクですからね)ではマルチスレッドとしてのTaskの出番は少なくなるでしょう。

neue cc - UniTask - Unity + async/awaitの完全でハイパフォーマンスな統合より

つまり、UniTaskはUnityの非同期処理がシングルスレッドで十分であることに目を付けてSynchronizationContextによるPostなどの無駄な処理を省いたものということができそうです。

したがってマルチスレッド処理については上記の通りJobSystemやTaskとの使い分けも検討する必要があります。

UniTaskでマルチスレッド

なおUniTaskでもマルチスレッドの処理はできますが、SynchronizationContextをキャプチャしていないのでawait後にメインスレッドに戻るようなことはありません。

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

public class Example : MonoBehaviour
{
    private async void Start()
    {
        // メインスレッド
        Debug.Log(Thread.CurrentThread.ManagedThreadId);
        
        await ExampleAsync();
        
        // サブスレッドのまま
        Debug.Log(Thread.CurrentThread.ManagedThreadId);
    }

    private async UniTask ExampleAsync()
    {
        // メインスレッド
        Debug.Log(Thread.CurrentThread.ManagedThreadId);

        // 処理するスレッドを変える
        await UniTask.SwitchToThreadPool();
        
        // サブスレッド
        Debug.Log(Thread.CurrentThread.ManagedThreadId);
    }
}

もちろん自分でUniTask.Yield()とかUniTask.SwitchToMainThread()とかすればメインスレッドに戻せはしますが、
UniTask及びそのAwaiterの中でスレッドを戻すような処理が行われることはありません。

また、(おそらく)それゆえにTask.Wait()やTask.Resultに相当する機能がありません。

Task task;
task.Wait(); // 同期で待てる

UniTask uniTask;
//uniTask.Wait() // 存在しない

このあたりもJobSystemやTaskとUniTaskとの適切な使い分けに影響してきそうです。

参考

neue.cc