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

# はじめに

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

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

# `SynchronizationContext`とは何なのか

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

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

# 使い方

## 取得方法

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

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

```text:結果
UnityEngine.UnitySynchronizationContext
```

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

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

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

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


```cs:スレッドプールで処理後に、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);
}
```

```text:実行結果
メインスレッドID:1
スレッドプールのスレッドID:19
処理が終わった！
実行後のスレッドID:19
```

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

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

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

```cs
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);
}
```

```text:実行結果
メインスレッドID:1
スレッドプールのスレッドID:21
処理が終わった！
実行後のスレッドID:1
```

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

## `SynchronizationContext`はいつでも取得できるのか？

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

```cs: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();
}
```

```text:実行結果
メインスレッド:True
新しく作ったスレッド:False
スレッドプール:False
```

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

### つまり？

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

# 「SynchronizationContext」と「Taskのawait」

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

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


```cs: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);
}
```

```text:実行結果
メインスレッドID:1
await前のスレッドID:1
スレッドプールのID:24
await後のスレッドID:1
```

## `SynchronizationContext.Current`を無視したい場合

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

```cs:メインスレッドに戻したくない場合
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);
}
```

```text:実行結果
メインスレッドID:1
await前のスレッドID:1
スレッドプールのID:21
await後のスレッドID:21
```

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

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

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

```cs
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);
}
```

```text:実行結果
メインスレッドID:1
await前のスレッドID:1
スレッドプールのID:22
await後のスレッドID:22
SynchronizationContextで戻ってきたスレッドID:1
```

# `Task`の`Wait()`や`Result`の安易な呼び出しはデッドロックを引き起こす

## `Task.Wait()`

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

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

```cs: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`を用いるようにしましょう。

```cs: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](https://qiita-image-store.s3.amazonaws.com/0/47146/80db833f-0695-15a2-09b3-bec1ccafc222.png)

## デッドロックの回避策

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

```cs: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)`にして元のスレッドに戻ってこないようにする

```cs
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`で待ち受けるようにする

# 参考資料

* [非同期処理とディスパッチャー](https://ufcpp.wordpress.com/2012/04/26/非同期処理とディスパッチャー/)
* [async/await ～非同期なライブラリは楽じゃない～](http://qwerty2501.hatenablog.com/entry/2014/04/24/235849)
* [HttpClient詳解、或いは非同期の落とし穴について](https://www.slideshare.net/neuecc/httpclient)