はじめに
最近、Unity/C#でもちょいちょい非同期処理を書いているんですが、なんだかんだやっぱり非同期は難しいです。
そこで、GoogleでTaskやasync/awaitについて検索すると、「TaskをWait()するのはダメ!」とか「async voidは基本的に使っちゃダメ!」といった記事はよく見かけるのですが、じゃあどう書けばいいんじゃと思って探してみると、なかなか記事が見つかりません。見つかっても、「UIのクリックイベントにasync voidのメソッドを登録して...」という記事が多いです。
残念ながら僕はUnityユーザなので、コードを書いてて非同期処理をしたいとき、処理のきっかけはUIのクリックイベントではない時の方が多く、あまり参考にすることができませんでした。
ということで、UnityでTask, async/awaitを使おうとしたときに僕が困ったことや、その結果わかってきた使い方をできるだけ具体的に書いていこうと思います。
何個かブログを書くかもしれませんが、今回の主役はTask.Run()
です。
なお、マサカリは大歓迎です。
関連記事
この記事を読む前に、以下におすすめの記事を載せます。読んだことがない人は事前に読んでみてください。
なお、今回の記事ではUniRxについては触れていきません。
Task.Run()
さて、なにかとよく出てくるTask.Run()
ですが、こいつは一体何をしてくれる関数なのでしょうか。
詳しくはこの記事に書いてありますが、ざっくり言ってしまうと、「**同期処理(もともと非同期処理ではなかった処理)**をまとめてタスクを作り、別スレッドで実行する」関数です。
個人的には、以下の3つの使い方を押さえておけば大体何とかなると思います。
- 重い同期処理(返り値なし)を非同期にしたい場合
- 重い同期処理(返り値あり)を非同期にしたい場合
- 非同期で無限ループを回したい場合
1. 重い同期処理(返り値なし)を非同期にしたい場合
まず簡単な例として、以下のような重い処理が必要になったとします。
private void HeavyMethod(string str)
{
// 何か重い処理
Thread.Sleep(1000);
// 重い処理をした続きの処理
SomethingNextMethod(str);
}
private void SomethingNextMethod(string str)
{
// 何か続きの処理
Debug.Log(str);
}
よく見るサンプルコードですね。
このとき、HeavyMethod()
の中にはawait
を使う処理がないものとします。
このままUnityのメインスレッドで、HeavyMethod()
を呼ぶと、画面がガッと止まってしまうので、重い処理の部分をTask.Run()
で別スレッドで処理を行ってあげる必要があります。
具体的にはこうです。
public void HogeHoge()
{
Task.Run(() => HeavyMethod("hoge"));
}
実をいうと個人的にはこの書き方は好きではないのですが、こうすればHogeHoge()
メソッドを発動することで、画面のフリーズを避けることができます。
Task.Run()の落とし穴
...が、実はこのコードには少し問題があります。
それは、SomethingNextMethod()
以降の処理が行われるスレッドに関してです。
最初に言ったとおり、Task.Run()
は引数として与えられた処理を別スレッドで実行します。つまり、本来別スレッドで行ってほしくないSomethingNextMethod()
の処理まで別スレッドで行われてしまうということです。これはメソッドだけでなく、Task.Run()
内で発火したイベントでも同様のことが起こります。とんだ落とし穴です。
「別スレッドで処理が続くと何がダメなの?」と思うかもしれませんが、実は「メインスレッドからしか実行できないメソッド」というのはいくつも存在します。個人的な経験だと、IBMのWatsonで音声認識を開始するときのメソッドがそうでした。
で、これを何とかするためには、SynchronizationContextというものを使う必要があります。
僕の記事では詳しく紹介できませんが、@toRisouP さんのこちらの記事に分かりやすく書いてあります。
このオブジェクトを使えば、自分の指定したスレッドで処理を走らせることができます。現在の処理が走っているスレッドはThread.CurrentThread.ManagedThreadId
で確認することができるので、ちょっと以下のコードをUnityで走らせて確認してみましょう。
/// <summary>
/// メインスレッドに処理を戻すためのオブジェクト
/// </summary>
private SynchronizationContext _mainContext;
private void Start()
{
_mainContext = SynchronizationContext.Current;
Hoge();
}
public void Hoge()
{
Debug.Log($"Main thread ID : " + Thread.CurrentThread.ManagedThreadId);
Task.Run(() => HeavyMethod());
}
public void HeavyMethod()
{
// 何か重い処理
Thread.Sleep(1000);
// そのままメソッドを続けて書いた場合
SomethingNextMethod("Hoge");
// メインスレッドに処理を戻したい場合
_mainContext.Post(_ => SomethingNextMethod("Fuga"), null);
}
private void SomethingNextMethod(string str)
{
Debug.Log($"[{str}] Thread ID : " + Thread.CurrentThread.ManagedThreadId);
}
結果は、以下のようになりました。
SynchronizationContextを使って実行スレッドを指定しなかった場合では、処理が別スレッドで走ってしまっていることがわかると思います。ちなみに、僕が実行したときはTask.Run()
でスレッド番号92が使われていますが、Task.Run()
は内部でスレッドプール
という仕組みを使っているため、毎回実行スレッドが変わります。
スレッド関係のバグは気づかないところで起きてたりもするので、特に意図がない場合はSynchronizationContextを使って、処理はメインスレッドに戻したほうが良いでしょう。
2. 重い同期処理(返り値あり)を非同期にしたい場合
前述の落とし穴を見ていて、こう思った方もいるかもしれません。
「Task.Run()
でメソッドを発動させるのが少し面倒なら、HeavyMethod()
で値を返すようにして、HogeHoge()
内で続きの処理を書けばいいのでは?」と。
はい。詳しくは後述しますが、実はawait
修飾子を使うことで、スレッドを意識せずに処理をつなげて書くことができます。
ということで、次にHeavyMethod()
に返り値がある場合を見てみましょう。
具体的には以下のようなメソッドがあり、HeavyMethod()
の処理を実行した後に、その結果を使って何かしらの処理を続けたいとします。
private string HeavyMethod(string str)
{
// 何か重い処理
Thread.Sleep(1000);
return str + "fuga";
}
これを非同期で処理する場合は、以下のように書きます。
public async Task HogeHogeAsync()
{
// 重い処理を非同期で実行し、その結果を得る
string result = await Task.Run(() => HeavyMethod("hoge"));
// 重い処理をした続きの処理
SomethingNextMethod(result);
}
private void SomethingNextMethod(string str)
{
Debug.Log(str);
}
ようやくasync/awaitが出てきました。こいつらがやっていることは以下の通りです。
-
async
- メソッド内で
await
修飾子を使えるようにする
- メソッド内で
-
await
-
Task.Run()
で作成したタスクの実行が終了するまで、HogeHogeAsync()
メソッドの処理を止める
(元のスレッドをフリーズさせるわけではない) - タスクが終了した場合は、
Task.Run()
が返してくるTask<string>
からstringを抜き出して返し、元のスレッドでHogeHogeAsync()
内の処理を続行する
(ただしUnity上での挙動 - 参考:https://www.slideshare.net/UnityTechnologiesJapan/unite-tokyo-2018asyncawait)
-
このawait
の働きのおかげで、実行スレッドを手動でもとに戻してあげる必要がなくなりました。
このように、await
は使う側がスレッドやTask
を意識せずに処理を続けて書けるようにしてくれる構文なんですね。
よっしゃ、勝ったな。非同期完全に理解した。
あとはHogeHogeAsync()
を使うだけ......
...ん?
落とし穴その2(HogeHogeAsync()を使ってみる)
HogeHogeAsync()
を別のメソッドから使うと、こんな警告が出てきました。
えっ、なにこれは。
とりあえず実行は出来るみたいだけど、気味が悪いな......なんとかしよう。
試行その1:await
を追加してみる
Visual Studioさんにawait
つけろって言われてるんなら、素直につけてみましょう。
警告どころかエラーになりました。
内容は「await
使ってるのにFugaFuga()
メソッドにasync
修飾子がついてないよ」というもの。
なんだ、じゃあasync
つければいいや。
よし、警告は消えたな。
...ん?
ああああ、async void
!!、**async void
**じゃないか!!!
こりゃだめだ、しかもこれ結局FugaFuga()
を使うメソッドまで、無限にasync
をつけなきゃいけないじゃないか!
FugaFuga()
が別スレッドから呼ばれない限り、これでいいです。
FugaFuga()
が別スレッドから呼ばれるなら、HogeHogeAsync()
内の例外が握りつぶされないように、ちゃんとエラーハンドリングしてSynchronizationContext
のPost()
メソッドで例外をメインスレッドに投げ直してあげてください(2020/5/10 修正)
試行その2:HogeHogeAsync()
を改変してみる
await
を一度使うと、それを使う側まですべてasync/await
修飾子をつけなきゃいけない...(思い込み)
じゃあawait
を一度も使わなければいいのでは???
そういえばTask
にはtask.Result
ってプロパティがあったな...
await
ではなくこれを使えばいいのでは???
private void FugaFuga()
{
HogeHoge();
}
public void HogeHoge()
{
// Taskはawaitせず直接受け取って
var task = Task.Run(() => HeavyMethod("hoge"));
// task.Resultで直接、値にアクセスする
var result = task.Result; // ここで結局スレッドが止まる
SomethingNextMethod(result);
}
はい、デッドロックです。
実はtask.Result
の内部ではTask
をWait()
する処理が走ります。
そのとき、なぜデッドロックが発生するかは過去にたくさんの記事が書いてあるので見てみてください。
Task.Run()
の中の処理は別のスレッドプールで走るため、task.Result
でメインスレッドを止めてもデッドロックはしませんが、結局処理が完了するまでメインスレッドが止まってしまうため、非同期処理ではなくなってしまいます。
これはC#組み込みのasync Task
メソッドでも同じ挙動になりますが、実行時のスレッドでタスクの完了を待ち受けるasync Task
メソッドでtask.Result
すると、タスクの完了を待ち受けるスレッドを停止してしまうことになり、デッドロックするので注意です。(2020/5/10 修正)
じゃあどうするか
エラーハンドリングさえちゃんとしとけば試行1のやり方でいいです(2020/5/10 修正)
試行1、試行2はダメでした。
じゃあどうすればいいかというと、こう書くことです。
private void FugaFuga()
{
var task = HogeHogeAsync();
}
public async Task HogeHogeAsync()
{
string result = await Task.Run(() => HeavyMethod("hoge"));
SomethingNextMethod(result);
}
追加されたのは、FugaFuga()
メソッドの中で、HogeHogeAsync()
をawait
せずに、ただのTask
を変数task
に代入する処理だけです。これで警告が消えます。
そのあとの処理には全く影響がないのに、なぜこれで警告が消えるのでしょうか。
そのヒントがこのページに書いてあります。
async、awaitそしてTaskについて(非同期とは何なのか)
以下引用です
同期処理
処理が終わるまで待つ非同期処理
処理が終わるまで待つかどうかを、利用者に委ねる
ここに書いてある通り、async
のついたメソッドは利用者側で、処理を待つことも、待たないこともできます。
また、Taskを極めろ!async/await完全攻略には、以下のようにも書いてあります。
本来、Taskは好きなときに好きなようにWaitしても全く問題ないものだった
つまり、本来はこういう書き方ができたはずだったのでしょう。
実際、ConfigureAwait(false)
を使えば以下のようなコードでもデッドロックを回避できます。
private void FugaFuga()
{
// HogeHogeAsync()のタスク(Task<string>)をそのまま受け取り
var task = HogeHogeAsync();
// taskの完了を待つ
task.Wait();
// taskの処理は終了しているので、Resultを参照
SomethingNextMethod(task.Result);
}
public async Task<string> HogeHogeAsync()
{
return await Task.Run(() => HeavyMethod("hoge"));
// return await Task.Run(() => HeavyMethod("hoge")).ConfigureAwait(false); // これならデッドロックしない
}
エディタの立場に立ってこれを加味すると、「あとでWait()
するかもしれないんだし、すぐにawait
しないにしてもとりあえずTask
は受け取っておきなよ。処理投げっぱなしは良くないよ」という気持ちになるのでしょう。
これが、あの「この呼び出しを待たないため、現在のメソッドの...
」という警告の原因ではないかと思います。
余談ですが、僕は「タスクを変数_
として受け取っておき、その後一切処理をしない」ことを、暗黙的に「そこの非同期処理は完了待ちをしない」ことを明示する目印としています。
なので僕は、1.重い処理に返り値がない場合のような処理を書く時も、以下のような書き方を好んでいます。
// 特に問題ないけど、あまり好きではない
Task.Run(() => HeavyMethod("hoge"));
// C# 6.0ならこう
var _ = Task.Run(() => HeavyMethod("hoge"));
// C# 7.0以降ならこう ... discards (値の破棄)
_ = Task.Run(() => HeavyMethod("hoge"));
複数のタスクを複雑に組み合わせて一つのタスクを作りたいときは、何か意味のある名前を付けてあげればいいと思います。
3. 非同期で無限ループを回す
最後に非同期で無限ループを回したいときの使い方です。
使いどころとしては、自前でオレオレネットワークライブラリを書いてたりすると使う必要が出てきます。
例えば以下のコードは、クライアントとTCP通信するサーバ側の処理の雛形です。
/// <summary>
/// クライアントと非同期で受信・送信処理を開始する
/// </summary>
public void Communicate()
{
_communicateLoop = true;
var _ = Task.Run(() =>
{
while (_communicateLoop)
{
try
{
// クライアントとの送信・受信処理
}
catch (Exception e)
{
Debug.LogWarning("[Server] " + e);
}
}
});
}
Task.Run()
の中にラムダ式ですべてのコードが書いてあるだけで今まで書いてきたことと特に違いはなく、無限ループなので処理の待ち受けをしていません。
もしwhile
の中からイベントを発火するようなことがあれば、SynchronizationContextの使用を考えるのも1.重い処理に返り値がない場合と同様です。
さいごに
今回の記事では、Task.Run()
を使った基本的な非同期処理について書いていきましたが、別に「非同期処理といえばTask.Run()
」という訳では全くありません。
むしろasync/await
だけで事足りることの方が多いと思いますし、個人的にも無限ループくらいにしか使いません(だいたい非同期処理したい時にはデフォルトでawait
できるメソッドが存在する)。
しかし、Task.Run()
とasync/await
の関係がよく分からず、一時期混乱していたので今回の記事を書こうと思いました。
今回の内容が、誰かのお役に立てば幸いです。
もし気力が残ってたら、次回はTask.Run()
を使わない非同期処理や、Task
そのものについて書けたらと思います。
最後まで読んでいただきありがとうございました。
- 追記
- Unityのawaitの挙動について補足(2018/12/06)
参考:https://www.slideshare.net/UnityTechnologiesJapan/unite-tokyo-2018asyncawait -
Task.Run()ではなく用意されている非同期メソッドを使おう(2019/09/16)
- 無駄なTask.Run()を消そうという記事をアップロードしました
- Unityのawaitの挙動について補足(2018/12/06)