Help us understand the problem. What is going on with this article?

【Unity開発者向け】「SynchronizationContext」と「Taskのawait」

More than 1 year has passed since last update.

はじめに

Unity 2018から.NET 4.6がstableになり、async/awaitTask使いこなせるようになっておく必要がでてきました。
今回はそれらの裏に隠れている、SynchronizationContextという機構とその使い方について紹介します。

なお、この記事はUnity2018.1bを基準に書いています。

SynchronizationContextとは何なのか

SynchronizationContextとは、ざっくり説明すると「指定した処理を指定したスレッドで実行するために用いるオブジェクト」です。
非同期処理を実行する場合、あるスレッドから別スレッドに処理を移譲することになります。
このとき、「非同期処理が終わったときに元のスレッドに処理を戻す」という需要が発生します。
これを解決するのがSynchronizationContextというオブジェクトです。

UniRxを普段利用されている方はSchedulerをイメージしていいただければいいかと思います。

使い方

取得方法

SynchronizationContextを利用するとなった場合、まずこのオブジェクトを取得する必要があります。
Unityメインスレッド(重要)SynchronizationContextを利用する場合は、SynchronizationContext.Currentにアクセスすることで有効なSynchronizationContextを取得することができます。

UnityメインスレッドでSynchronizationContextを取得する
void Start()
{
    var context = SynchronizationContext.Current;
    Debug.Log(context);
}
結果
UnityEngine.UnitySynchronizationContext

SynchronizationContextを使って別スレッドからUnityメインスレッドに処理を渡す

SynchronizationContext.Postメソッドを実行することで、該当の処理をUnityメインスレッドにて実行することができます。
SynchronizationContextを用いた場合と用いない場合とで挙動の違いを示します。

比較 : SynchronizationContextを用いず、別スレッドで処理を継続させる

次の処理はSynchronizationContext利用せず、処理をスレッドプール上にてそのまま継続した場合のコードです。

スレッドプールで処理後に、SynchronizationContextを利用せず処理を継続する
void Start()
{
    Debug.Log("メインスレッドID:" + Thread.CurrentThread.ManagedThreadId);

    // スレッドプール上にて処理を実行する
    ThreadPool.QueueUserWorkItem(_ =>
    {
        Debug.Log("スレッドプールのスレッドID:" + Thread.CurrentThread.ManagedThreadId);

        // なにか処理
        Thread.Sleep(100);

        EndAction();
    });
}

void EndAction()
{
    Debug.Log("処理が終わった!");
    Debug.Log("実行後のスレッドID:" + Thread.CurrentThread.ManagedThreadId);
}
実行結果
メインスレッドID:1
スレッドプールのスレッドID:19
処理が終わった!
実行後のスレッドID:19

EndAction()がそのままスレッドプール上で処理が行われています。

比較 : SynchronizationContextを用いて、非同期処理をメインスレッド戻す

次の処理はSynchronizationContext利用し、処理をメインスレッドに戻した場合のコードです。

void Start()
{
    // メインスレッドのSynchronizationContextを先に確保しておく
    var context = SynchronizationContext.Current;

    Debug.Log("メインスレッドID:" + Thread.CurrentThread.ManagedThreadId);

    // スレッドプール上にて処理を実行する
    ThreadPool.QueueUserWorkItem(_ =>
    {
        Debug.Log("スレッドプールのスレッドID:" + Thread.CurrentThread.ManagedThreadId);

        // なにか処理
        Thread.Sleep(100);

        // 確保したSynchronizationContextを使ってメインスレッドに処理を戻す
        context.Post(__ =>
        {
            EndAction();
        }, null);
    });
}

void EndAction()
{
    Debug.Log("処理が終わった!");
    Debug.Log("実行後のスレッドID:" + Thread.CurrentThread.ManagedThreadId);
}
実行結果
メインスレッドID:1
スレッドプールのスレッドID:21
処理が終わった!
実行後のスレッドID:1

SynchronizationContext.Postを用いた場合、EndAction()がメインスレッドに戻ってきて実行されていることがわかります。
このように、SynchronizationContextを用いることで簡単に元のスレッドに処理を戻すことができるようになります。

SynchronizationContextはいつでも取得できるのか?

この便利なSynchronizationContextですが、(Unityで動いているコードであれば)Unityメインスレッド上でしか取得することができません。

SynchronizationContext.CurrentはUnityメインスレッドでしか取得できない
void Start()
{
    Debug.Log("メインスレッド:" + (SynchronizationContext.Current != null));

    ThreadPool.QueueUserWorkItem(_ =>
    {
        Debug.Log("スレッドプール:" + (SynchronizationContext.Current != null));
    });

    new Thread(() =>
    {
        Debug.Log("新しく作ったスレッド:" + (SynchronizationContext.Current != null));
    }).Start();
}
実行結果
メインスレッド:True
新しく作ったスレッド:False
スレッドプール:False

なぜメインスレッド以外ではnullになってしまうかというと、SynchronizationContext.Currentはユーザが手動で設定する必要がある項目だからです。
UnityメインスレッドでSynchronizationContext.Currentが使えるのは、UnityEngineが気を利かせてSynchronizationContextを設定してくれているからに過ぎません。

つまり?

  • SynchronizationContext.Currentから有効なインスタンスを取得できるのはUnityメインスレッド上のみ
  • それ以外は手動で設定する必要がある(が、手動で設定が必要になるようなことはUnity開発ではほぼ無い)

「SynchronizationContext」と「Taskのawait」

UnityメインスレッドでTaskのawaitをした場合

この便利なSynchronizationContextですが、実はTaskawaitした際にも暗黙的に利用されています。
Taskawaitしたときに、デフォルトでは呼び出し元スレッドのSynchronizationContext.Currentに基づいてawait以降の処理を継続するという仕組みになっています。
そのため、Unityメインスレッドでawaitを実行した場合、await後の処理もそのままUnityメインスレッドで継続して実行されるようになっています。

Unityメインスレッドでawait
void Start()
{
    Debug.Log("メインスレッドID:" + Thread.CurrentThread.ManagedThreadId);

    Work();
}

async Task Work()
{
    Debug.Log("await前のスレッドID:" + Thread.CurrentThread.ManagedThreadId);

    // スレッドプール上で処理を実行して終わるのを待つ
    await Task.Run(() =>
    {
        Debug.Log("スレッドプールのID:" + Thread.CurrentThread.ManagedThreadId);
        Thread.Sleep(100);
    });

    Debug.Log("await後のスレッドID:" + Thread.CurrentThread.ManagedThreadId);
}
実行結果
メインスレッドID:1
await前のスレッドID:1
スレッドプールのID:24
await後のスレッドID:1

SynchronizationContext.Currentを無視したい場合

もしawait後にUnityメインスレッドに戻っては困るような場合(そのままスレッドプール上で処理を続けたい場合)は、Task.ConfigureAwait(false)を指定します。
ConfigureAwaitは「await後にSynchronizationContextを用いるかどうか」の設定で、falseに設定することでTaskの実行スレッドをそのまま継続するようになります。(デフォルトはtrueになっています。)

メインスレッドに戻したくない場合
void Start()
{
    Debug.Log("メインスレッドID:" + Thread.CurrentThread.ManagedThreadId);

    Work();
}

async Task Work()
{
    Debug.Log("await前のスレッドID:" + Thread.CurrentThread.ManagedThreadId);

    // スレッドプール上で処理を実行して終わるのを待つ
    await Task.Run(() =>
    {
        Debug.Log("スレッドプールのID:" + Thread.CurrentThread.ManagedThreadId);
        Thread.Sleep(100);
    }).ConfigureAwait(false); // ConfigureAwait(false)のためメインスレッド戻らない

    Debug.Log("await後のスレッドID:" + Thread.CurrentThread.ManagedThreadId);
}
実行結果
メインスレッドID:1
await前のスレッドID:1
スレッドプールのID:21
await後のスレッドID:21

やっぱり最後はメインスレッドに戻したい場合

Task.ConfigureAwait(false)を指定したはいいものの、やはり最後はメインスレッドに戻したいとなった場合は手動でSynchronizationContext.Postを呼び出す必要があります。

ただしその場合、await後に`SynchronizationContext.Currentにアクセスしてもnullが返ってきてしまうため、await実行前(メインスレッド上で処理している間)にSynchronizationContextを確保しておく必要があります。

void Start()
{
    Debug.Log("メインスレッドID:" + Thread.CurrentThread.ManagedThreadId);

    Work();
}

async Task Work()
{
    // メインスレッドで実行している間にSynchronizationContextを確保しておく
    var context = SynchronizationContext.Current;

    Debug.Log("await前のスレッドID:" + Thread.CurrentThread.ManagedThreadId);

    await Task.Run(() =>
    {
        Debug.Log("スレッドプールのID:" + Thread.CurrentThread.ManagedThreadId);
        Thread.Sleep(100);
    }).ConfigureAwait(false); // ConfigureAwait(false)のためメインスレッド戻らない

    Debug.Log("await後のスレッドID:" + Thread.CurrentThread.ManagedThreadId);

    //メインスレッドに処理を戻す
    context.Post(_ =>
    {
        Debug.Log("SynchronizationContextで戻ってきたスレッドID:" + Thread.CurrentThread.ManagedThreadId);
    }, null);
}
実行結果
メインスレッドID:1
await前のスレッドID:1
スレッドプールのID:22
await後のスレッドID:22
SynchronizationContextで戻ってきたスレッドID:1

TaskWait()Resultの安易な呼び出しはデッドロックを引き起こす

Task.Wait()

TaskにはWait()という、「非同期処理の完了をスレッドをブロックして同期的に待つ」という大変危険なメソッドが用意されています。

というのも、このWait()はスレッドをブロックするだけでも結構ヤバイのですが、SynchronizationContext.Currentが設定されている状態でWait()すると簡単にデッドロックを起こしてしまいます。

Wait()でデッドロックを起こす例
void Start()
{
    // ここでTaskの実行開始
    var task = Work();

    // デッドロック!(Unityがフリーズする)
    task.Wait();
}

async Task Work()
{
    Debug.Log("実行開始");

    await Task.Delay(1000);

    Debug.Log("実行終了");
}

Task.Result

また、Taskの結果を取得するResultというプロパティもWait()と同様にスレッドをブロックするため、安易に用いるとデッドロックを起こします。
そのためTaskの返り値を取得する場合はawaitを用いるようにしましょう。

Resultでデッドロックを起こす例
void Start()
{
    var task = ReadTextAsync("data.txt");

    // やってることはWait()と同じでデッドロックを起こす
    var result = task.Result;

    Debug.Log(result);
}

/// <summary>
/// 指定したファイルを非同期で読み込む
/// </summary>
private async Task<string> ReadTextAsync(string filePath)
{
    using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
    using (var reader = new StreamReader(stream))
    {
        return await reader.ReadToEndAsync();
    }
}

デッドロックを起こす理由

デッドロックを起こす理由は、「SynchronazationContext.Postを使ってメインスレッドへ処理を戻そうにも、Wait()によってメインスレッドがブロックされているため、何もできなくなる」からです。
つまり、「自分が実行しないと終了しない処理が終了するのを自分が待ち続ける」みたいな状態になってしまってデッドロックになってしまいます。

DeadLock.png

デッドロックの回避策

回避策1.ちゃんとawaitする

awaitで結果を待つ
async void Start()
{
    var task = ReadTextAsync("data.txt");

    // awaitで結果を待つ
    var result = await task;

    Debug.Log(result);
}

/// <summary>
/// 指定したファイルを非同期で読み込む
/// </summary>
private async Task<string> ReadTextAsync(string filePath)
{
    using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
    using (var reader = new StreamReader(stream))
    {
        return await reader.ReadToEndAsync();
    }
}

回避策2. ConfigureAwait(false)にして元のスレッドに戻ってこないようにする

void Start()
{
    var task = ReadTextAsync("data.txt");

    var result = task.Result;

    Debug.Log(result);
}

/// <summary>
/// 指定したファイルを非同期で読み込む
/// </summary>
private async Task<string> ReadTextAsync(string filePath)
{
    using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
    using (var reader = new StreamReader(stream))
    {
        // ConfigureAwait(false)でメインスレッド戻らないようにする
        return await reader.ReadToEndAsync().ConfigureAwait(false);
    }
}

まとめ

  • SynchronizationContext を使うと処理の実行スレッドを切り替えることができる
  • Unityメインスレッドにはデフォルトで有効なSynchronizationContextが設定済み(SynchronizationContext.Currentで取得可能)
  • Unityメインスレッドでawaitを実行した場合はちゃんとメインスレッド戻ってくる
  • Task.Wait()Task.Resultはデッドロックは簡単に引き起こすので、できるだけawaitで待ち受けるようにする

参考資料

toRisouP
virtualcast
VRシステム(バーチャルキャスト)の開発、運営、企画
https://virtualcast.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away