はじめに
Unityでasync/await
を利用した非同期処理を触り、色々と学びがあったため記載できればと思います。
内容は今後後付けすると思いますが、まずはこういう風に触ったらこういう挙動になるという書きやすいところから書いてみたいと思います。
実行環境
Unity2018.2.4
C#でのマルチスレッド
導入として、以下のような実行に1秒間かかる重いメソッドHeavyMethod
があると考えます。
(今回はThread.Sleep(1000)
で代用)
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
直接スレッドを生成する場合です。
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
スレッドプールを利用する場合です。
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
デリゲートを利用する場合です。
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()
でスレッドプールに重い処理を投げています。
IAsyncResult
のIsCompleted
をメインスレッドで終了をポーリングしています。
また、EndInvoke()
でデリゲートを介して、スレッドから値を受け取っています。
Task
Taskを利用した場合です。
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を組み合わせて利用した場合です。
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.ManagedThreadId
APIを使うことで知ることができます。
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
にして実行してみたいと思います。
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後に戻るスレッドが停止しているためにデッドロックが発生し、フリーズします。
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));
// ここも呼び出し元のスレッド
}
}
これを避けるためにはコードを以下のように修正します。
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を出力すると以下のようになります。
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をコールしてみたときの出力は以下のようになります。
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を叩くコードは前述した通り、エラーになります。
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
});
}
}
このコードを以下のように修正します。
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を呼ぶことができます。