【C#】【Unity】Taskによる非同期処理をWait()したときにデッドロックする原因と対策

C#で非同期処理におけるスレッドの仕組みとWait()によるデッドロックについてまとめました。

Unity2019.4

非同期処理におけるスレッドの基本

まず非同期処理におけるスレッドの基本的な挙動を確認します。

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

public class Example : MonoBehaviour
{
    private async void Start()
    {
        // Startで呼んでるので最初はメインスレッド(Thread.CurrentThread.ManagedThreadIdが1)
        
        await SomeAsyncMethod();
        
        // await後もメインスレッド
    }

    private async Task SomeAsyncMethod()
    {
        // ここは呼び出し元のスレッド(メインスレッド)で処理
        
        await Task.Run(() =>
        {
            // ここは別スレッドで処理される
        }); 
        
        // ここはメインスレッドに戻ってくる
    }
}

上記のように非同期メソッド内でTaskを走らせると、その部分だけが別スレッドで処理されます。
そのTaskをawaitした場合、awaitが終わったら元のスレッドに戻ってきます。

ちなみにこのあたりの仕組みは本記事ではふわっとした説明に留めますが、
以下の記事でもう少し詳しくまとめているので必要に応じて参照してください。

light11.hatenadiary.com

Waitによるデッドロック

さてここで以下のようにTaskをWait()すると非同期処理を同期処理として扱うことができます。
ただしこれを不用意に行うとデッドロックが起こるので注意が必要です。

以下はWait()がデッドロックを引き起こす例です。

using System.Threading.Tasks;
using UnityEngine;

public class Example : MonoBehaviour
{
    private void Start()
    {
        // 非同期メソッドをWaitする
        
        SomeAsyncMethod().Wait();
        
        // ここに書いた処理は処理されない(デッドロックしてる)
    }

    private async Task SomeAsyncMethod()
    {
        // ここに書いた処理はメインスレッドで処理される
        
        // このTaskは本来非同期で行われるが、Waitされているのでここでメインスレッドがロックされる
        // 終わったらメインスレッドに帰ってくるはずだがその時メインスレッドはロックされているのでデッドロック
        await Task.Run(() =>
        {
        });
        
        // ここに書いた処理は処理されない(デッドロックしてる)
    }
}

上記からわかるように、あるTaskをWait()するとその非同期メソッド内で最初にスレッドが移るタイミングで元のスレッドがロックされます。
そしてこのロックはWait()したTaskの処理が完了するまで続きます。

上記ではTask内でawaitしている部分でスレッドが以降し、await後にメインスレッドに戻ろうとするものの、
その時にメインスレッドはまだロックされている状態なのでデッドロックが発生しています。

ConfigureAwait(false)の挙動

このWait()によるデッドロックを防ぐにはConfigureAwait()の挙動を正しく理解する必要があります。
以下のようにConfigureAwait(false)とすると、awaitした後に元のスレッドに戻らず非同期の処理が行われたスレッドで処理されます。

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

public class Example : MonoBehaviour
{
    private async void Start()
    {
        //メインスレッド
        
        await SomeAsyncMethod();
        
        // ここはメインスレッド
    }

    private async Task SomeAsyncMethod()
    {
        // メインスレッド
        
        await Task.Run(() =>
        {
            // 別スレッド
        }).ConfigureAwait(false);
        
        // ※※ConfigureAwait(false)によりここが別スレッドのままになる※※

        // ただしこの非同期メソッドから抜けたらメインスレッドに戻る
    }
}

Wait()によるデッドロックを防ぐ

さてこのようにConfigureAwait(false)にしておけばawait後のスレッドがロックされていない状態になるので、デッドロックは発生しません。
以下はデッドロックを引き起こさないWait()の例です。

using System.Threading.Tasks;
using UnityEngine;

public class Example : MonoBehaviour
{
    private void Start()
    {
        //メインスレッド
        
        // 非同期メソッドをWaitする
        SomeAsyncMethod().Wait();
                
        // ここに書いた処理はメインスレッドで処理される
    }

    private async Task SomeAsyncMethod()
    {
        // ここに書いた処理はメインスレッドで処理される
        
        // このTaskは本来非同期で行われるが、Waitされているのでこの時点でメインスレッドがロックされる
        // ConfigureAwaitをfalseにしているのでawait後は別のスレッドに移る = デッドロックにはならない
        await Task.Run(() =>
        {
        }).ConfigureAwait(false);
        
        // このメソッドから抜けたらメインスレッドのロックが解除される
    }
}

ちなみにWaitによるスレッドのロックはあくまで別スレッドに移るタイミングで行われるので、
以下のように同期処理しかしないTaskをWaitしても何もデッドロックは発生しません。

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

public class Example : MonoBehaviour
{
    private void Start()
    {        
        SomeAsyncMethod().Wait();
    }

    // 同期処理しかしていないTask
    private async Task SomeAsyncMethod()
    {
        Debug.Log("test");
    }
}

ConfigureAwait(false)は書くべきか

さてでは非同期のコードを書くときにConfigureAwait(false)は書いておくべきでしょうか。
これに関しては、必ず一つのスレッドで処理しなければいけない理由がない限りできる限り書く、というのが正しそうです。
またより低レイヤーのコードほど、できる限り同一スレッドの制約が起こらないようにしてConfigureAwait(false)を書いておくべきといえそうです。

この辺りは以下の記事の「ConfigureAwait(false)のススメ」の項目が参考になります。

qiita.com

参考

qiita.com

関連

light11.hatenadiary.com

light11.hatenadiary.com

light11.hatenadiary.com

light11.hatenadiary.com