LoginSignup
42
43

More than 5 years have passed since last update.

async/awaitからC#のマルチスレッドについて調べた

Last updated at Posted at 2018-09-11

はじめに

Unityでasync/awaitを利用した非同期処理を触り、色々と学びがあったため記載できればと思います。
内容は今後後付けすると思いますが、まずはこういう風に触ったらこういう挙動になるという書きやすいところから書いてみたいと思います。

実行環境

Unity2018.2.4

C#でのマルチスレッド

導入として、以下のような実行に1秒間かかる重いメソッドHeavyMethodがあると考えます。
(今回はThread.Sleep(1000)で代用)

sample01.cs
using System.Threading;
using UnityEngine;
public class Test : MonoBehaviour
{
    void Start()
    {
        print("[Before] " + Time.realtimeSinceStartup);
        print(HeavyMethod()); // <= ここでアプリが1秒止まってしまう。
        print("[After] " + Time.realtimeSinceStartup);
    }

    string HeavyMethod()
    {
        Thread.Sleep(1000);
        return "Complete";
    }
}

このHeavyMethod関数をメインスレッドで実行すれば1秒間アプリが止まることになるため、ワーカースレッドでの実行を行う必要があります。
また、マルチスレッド化にあたって、以下の出力を順番を変えずに移植を行う必要があります。

[Before] 1.620573
Complete
[After] 2.62794

これをC#で実現するためのいくつかの方法を書いてみます。

Thread

直接スレッドを生成する場合です。

sample02.cs
using System.Collections;
using System.Threading;
using UnityEngine;
public class Test : MonoBehaviour
{
    IEnumerator Start()
    {
        print("[Before] " + Time.realtimeSinceStartup);
        string output = string.Empty;
        Thread t = new Thread(new ThreadStart(() =>
        {
            output = HeavyMethod();
        }));
        t.Start();
        while (t.IsAlive)
        {
            yield return null;
        }
        print(output);
        print("[After] " + Time.realtimeSinceStartup);
    }

    string HeavyMethod()
    {
        Thread.Sleep(1000);
        return "Complete";
    }
}

new Thread()を行って新しくスレッドを生成して、別スレッドで重い処理を実行しています。
スレッドの終了をIsAliveをメインスレッドでポーリングして検知するようにしています。
また、スレッドは値を返却できないため、メインスレッドで受け渡し用の変数outputを作っています。

ThreadPool

スレッドプールを利用する場合です。

sample03.cs
using System.Collections;
using System.Threading;
using UnityEngine;
public class Test : MonoBehaviour
{
    IEnumerator Start()
    {
        print("[Before] " + Time.realtimeSinceStartup);
        bool isComplete = false;
        string output = string.Empty;
        ThreadPool.QueueUserWorkItem(new WaitCallback((state) =>
        {
            output = HeavyMethod();
            isComplete = true;
        }));
        while (!isComplete)
        {
            yield return null;
        }
        print(output);
        print("[After] " + Time.realtimeSinceStartup);
    }

    string HeavyMethod()
    {
        Thread.Sleep(1000);
        return "Complete";
    }
}

ThreadPool.QueueUserWorkItem()でスレッドプールに重い処理を投げています。
スレッドと同じく、スレッドプールも値を返却できないため、メインスレッドで受け渡し用の変数outputを作っています。
また、スレッドプールには終了を検知する仕組みもないため、isCompleteというフラグを作成してメインスレッドでポーリングできるようにしています。

delegate

デリゲートを利用する場合です。

sample04.cs
using System;
using System.Collections;
using System.Threading;
using UnityEngine;
public class Test : MonoBehaviour
{
    delegate string HeavyMethodDelegate();

    IEnumerator Start()
    {
        print("[Before] " + Time.realtimeSinceStartup);
        HeavyMethodDelegate worker = new HeavyMethodDelegate(HeavyMethod);
        IAsyncResult ar = worker.BeginInvoke(null, null);
        while (!ar.IsCompleted)
        {
            yield return null;
        }
        print(worker.EndInvoke(ar));
        print("[After] " + Time.realtimeSinceStartup);
    }

    string HeavyMethod()
    {
        Thread.Sleep(1000);
        return "Complete";
    }
}

BeginInvoke()でスレッドプールに重い処理を投げています。
IAsyncResultIsCompletedをメインスレッドで終了をポーリングしています。
また、EndInvoke()でデリゲートを介して、スレッドから値を受け取っています。

Task

Taskを利用した場合です。

sample05.cs
using System.Collections;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class Test : MonoBehaviour
{
    IEnumerator Start()
    {
        print("[Before] " + Time.realtimeSinceStartup);
        var task = Task.Run(() => HeavyMethod());
        while (!task.IsCompleted)
        {
            yield return null;
        }
        print(task.Result);
        print("[After] " + Time.realtimeSinceStartup);
    }

    string HeavyMethod()
    {
        Thread.Sleep(1000);
        return "Complete";
    }
}

Task.Runでスレッドプールに重い処理を投げています。
IsCompletedフラグを使ってメインスレッドで処理の完了を待機しています。
Resultを使ってスレッドから値を受け取っています。

Task + async/await

Taskとasync/awaitを組み合わせて利用した場合です。

sample06.cs
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class Test : MonoBehaviour
{
    async void Start()
    {
        print("[Before] " + Time.realtimeSinceStartup);
        print(await Task.Run(() => HeavyMethod()));
        print("[After] " + Time.realtimeSinceStartup);
    }

    string HeavyMethod()
    {
        Thread.Sleep(1000);
        return "Complete";
    }
}

Task.Runでスレッドプールに重い処理を投げています。
async/await構文を使ってメインスレッドで処理の完了待機と値の受け取りを行っています。
その他の書き方と違い、コルーチンを使ったポーリングの必要がありません。

async/awaitの使い方

async/await構文の書き方は以下などに詳しく載っていますので、リンクだけ貼ります。

Async および Await を使用した非同期プログラミング (C#)
https://docs.microsoft.com/ja-jp/dotnet/csharp/programming-guide/concepts/async/

async (C# リファレンス)
https://docs.microsoft.com/ja-jp/dotnet/csharp/language-reference/keywords/async

await (C# リファレンス)
https://docs.microsoft.com/ja-jp/dotnet/csharp/language-reference/keywords/await

async/awaitのスレッドの切り替わりについて

Thread.CurrentThread.ManagedThreadId

async/awaitのスレッドの切り替わりをログを出力することで確認します。
今のコードがどのスレッドで実行されているかはThread.CurrentThread.ManagedThreadIdAPIを使うことで知ることができます。

sample07.cs
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class Test : MonoBehaviour
{
    async void Start()
    {
        print("[1] " + Thread.CurrentThread.ManagedThreadId);
        await Task.Run(() => HeavyMethod());
        print("[4] " + Thread.CurrentThread.ManagedThreadId);
    }

    void HeavyMethod()
    {
        print("[2] " + Thread.CurrentThread.ManagedThreadId);
        Thread.Sleep(1000);
        print("[3] " + Thread.CurrentThread.ManagedThreadId);
    }
}

[1] ThreadID : 1
[2] ThreadID : 245
[3] ThreadID : 245
[4] ThreadID : 1

上のように、Task.Runの内部に入ったときにThreadIDが1から245になり、スレッドの切り替えが行われていることが分かります。
その後、メインスレッドに戻ったときに再度245から1に戻っていることも分かります。

ConfigureAwait

TaskにはConfigureAwaitというタスクが終了するときに、呼び出し元のスレッドに戻って後続の処理を行うか、現在のスレッドで後続の処理を行うかを選択できるAPIが用意されています。
こちらはデフォルトではTrueになっており、呼び出し元のスレッドに戻るようになっています。
こちらをFalseにして実行してみたいと思います。

sample08.cs
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class Test : MonoBehaviour
{
    async void Start()
    {
        print("[1] ThreadID : " + Thread.CurrentThread.ManagedThreadId);
        await Task.Run(() => HeavyMethod()).ConfigureAwait(false);
        print("[4] ThreadID : " + Thread.CurrentThread.ManagedThreadId);
    }

    void HeavyMethod()
    {
        print("[2] ThreadID : " + Thread.CurrentThread.ManagedThreadId);
        Thread.Sleep(1000);
        print("[3] ThreadID : " + Thread.CurrentThread.ManagedThreadId);
    }
}

[1] ThreadID : 1
[2] ThreadID : 255
[3] ThreadID : 255
[4] ThreadID : 255

呼び出し元のStartの[4]のログ出力がワーカースレッドで行われる挙動になっています。
ConfigureAwait(false)を設定するメリット、デメリットを述べたいと思います。

メリット

  • パフォーマンスに優れています。

.NETで非同期ライブラリを正しく実装する
https://www.infoq.com/jp/articles/Async-API-Design
あるデモでLucianはデフォルトのConfigureAwait(true)を使うと、ConfigureAwait(false)を使う場合より14倍遅いことを示しています。
1回の呼び出しの時間はわずかですが、ループの中で何千回も呼び出された場合、その時間が積み重なってしまいます。

  • 呼び出し側でTask.Wait()を使われた場合でもアプリケーションがフリーズしません。

Taskの終了を待つAPIとして、Task.Wait()というタスクが完了するまで現在のスレッドを停止させるAPIが用意されています。
これを以下のコードのように叩いた場合、タスクのawait後に戻るスレッドが停止しているためにデッドロックが発生し、フリーズします。

sample09.cs
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class Test : MonoBehaviour
{
    void Start()
    {
        Task task = HeavyMethodAsync();
        task.Wait();
    }

    async Task HeavyMethodAsync()
    {
        // ここは呼び出し元のスレッド
        await Task.Run(() => Thread.Sleep(1000));
        // ここも呼び出し元のスレッド
    }
}

これを避けるためにはコードを以下のように修正します。

sample10.cs
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class Test : MonoBehaviour
{
    void Start()
    {
        Task task = HeavyMethodAsync();
        task.Wait();
    }

    async Task HeavyMethodAsync()
    {
        // ここは呼び出し元のスレッド
        await Task.Run(() => Thread.Sleep(1000)).ConfigureAwait(false);
        // ここはワーカースレッド
    }
}

デメリット

  • ConfigureAwait(false)以下でUnityAPIを呼ぶことができません。

sample10.csのポイントでスレッドIDを出力すると以下のようになります。

sample11.cs
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class Test : MonoBehaviour
{
    void Start()
    {
        print("[1] ThreadID : " + Thread.CurrentThread.ManagedThreadId);
        Task task = HeavyMethodAsync();
        task.Wait();
        print("[4] ThreadID : " + Thread.CurrentThread.ManagedThreadId);
    }

    async Task HeavyMethodAsync()
    {
        print("[2] ThreadID : " + Thread.CurrentThread.ManagedThreadId);
        await Task.Run(() => Thread.Sleep(1000)).ConfigureAwait(false);
        print("[3] ThreadID : " + Thread.CurrentThread.ManagedThreadId);
    }
}

[1] ThreadID : 1
[2] ThreadID : 1
[3] ThreadID : 187
[4] ThreadID : 1

[3]の部分では前述した通り、ワーカースレッドでプログラムは動作しています。
ここで以下のようにUnityAPIをコールしてみたときの出力は以下のようになります。

sample12.cs
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class Test : MonoBehaviour
{
    void Start()
    {
        print("[1] ThreadID : " + Thread.CurrentThread.ManagedThreadId);
        Task task = HeavyMethodAsync();
        task.Wait();
        print("[4] ThreadID : " + Thread.CurrentThread.ManagedThreadId);
    }

    async Task HeavyMethodAsync()
    {
        print("[2] ThreadID : " + Thread.CurrentThread.ManagedThreadId);
        await Task.Run(() => Thread.Sleep(1000)).ConfigureAwait(false);
        print("[3] ThreadID : " + Thread.CurrentThread.ManagedThreadId);
        gameObject.name = "HeavyMethodAsync"; // gameObjectにアクセス
    }
}

[1] ThreadID : 1
[2] ThreadID : 1
[3] ThreadID : 200
get_gameObject can only be called from the main thread.
UnityException: get_gameObject can only be called from the main thread.

UnityAPIはメインスレッド以外で普通に使うと上のような例外が発生するので注意が必要です。

UnityAPIをワーカースレッドで利用する

最後にUnityAPIをワーカースレッドで利用する方法を記載したいと思います。
以下のようなワーカースレッドでUnityAPIを叩くコードは前述した通り、エラーになります。

sample13.cs
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class Test : MonoBehaviour
{
    async void Start()
    {
        await Task.Run(() =>
        {
            Thread.Sleep(1000);
            gameObject.name = "Test"; // UnityException
        });
    }
}

このコードを以下のように修正します。

sample14.cs
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class Test : MonoBehaviour
{
    async void Start()
    {
        SynchronizationContext context = SynchronizationContext.Current;
        await Task.Run(() =>
        {
            context.Post(_ =>
            {
                gameObject.name = "Test";
            }, null);
            Thread.Sleep(1000);
        });
    }
}

SynchronizationContext context = SynchronizationContext.Current;をメインスレッドで行い、コンテキストを保持しておきます。
このコンテキストを使って、ワーカースレッドでメインスレッドに向けてUnityAPIをPostします。
この手順を踏むことでワーカースレッドのブロックの中でUnityAPIを呼ぶことができます。

42
43
0

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
42
43