【CSharp】Taskを0から説明してみる
こんにちは、たかせです。
前回の記事は思い出を書き並べるだけで終わってしまったので、今回はそれとなくエンジニアっぽいことを書こうと思います。
非同期処理は全部Taskで書いているよ!
今どきはUniTaskでしょ...
なにそれ美味しいの
とまぁ、いろいろな方がいるかなぁとは思うんですが、今回は主に初心者向けなお話です。Unityでゲームの開発していてScriptも経験があるが、複雑な実装はちょっと...くらいの方を想定しています。
結論から言うと、UniTaskを使いましょう。まだまだ経験も浅いので大きな事は言えませんが、Taskとほぼほぼ同じ使い方ができ、パフォーマンス面でも優れるUniTaskを使わない理由はありません。強いて言えば、標準のAssetではないのでインポートに一手間かかりますが、AssetStoreで公開されているのでさして問題にはならないでしょう。
もう一つの結論を言うと、たかせの開発しているBeyondTheFieldをフォローしてほしいです。質問も称賛も野次も何でも受付中です。
目次
- Taskって何?
- Taskの特徴をイメージしてみる
- Taskの使い方(基本編)
- Taskの使い方(応用編)
- async / await
Taskって何?
次のような状況において便利なクラスです。
- メインスレッドを動かしたまま、裏で通信を走らせたい
- 複数の処理を同時に実行して、完了までの時間を短縮したい
- 描画には関係ない処理をメインスレッドから追い出して、動作の軽量化を図りたい
Taskの特徴をイメージしてみる
具体的なTaskの使い方に入る前に、Taskの特徴をイメージをしましょう。
「メインスレッドを動かしたまま、裏で通信を走らせたい」という課題が生まれる理由は、スレッドは複数の処理を同時に実行できないからです。例えば、
- BGMを再生する
- 次に再生するBGMをサーバから取得する
という2つの処理を受け取ったスレッドは、データを取得している間、BGMを再生することができません。
これを回避するためには、次に再生するBGMの取得を別のスレッドに依頼して、その完了を待つことが有効ですね。いわゆる非同期処理というやつです。そして、「処理を依頼して完了を待つ」実装に役立つのがTaskです。
つまりこの回避策は、「次に再生するBGMの取得を非同期Taskで行う」と言い換えることができます。
スレッドという4文字がゲシュタルト崩壊してきた方は、人に置き換えてイメージすると良いかもしれません。例えば僕のようなシングルタスクな人間がたくさんのタスクに追われたときは、隣の同僚に手伝ってもらいつつ自分のタスクを終わらせようとするでしょう。ほら、スレッドが人だとすればTaskはタスクとなり、ぴったり一致します。
まとめると、次の2つを念頭に置きつつ読み進めて欲しいな、という節でした。
- Taskには依頼者と実行者がいる
- 先の例では、メインスレッドが依頼し、別スレッドが実行しました
- Taskには完了がある
- 先の例では、「BGMの再生を続ける」処理はTaskに向きません
(それにしてもひどい図ですね)
Taskの使い方(基礎編)
歩くUnityちゃんを作ってみましょう。
using UnityEngine; public class SampleScript : MonoBehaviour { private void Update() { ユニティちゃんを一歩進める(); } private void ユニティちゃんを一歩進める() { //割愛 } }
驚異的な速度で歩くユニティちゃんが完成しました。このユニティちゃんに、定期的な進捗報告をさせるとします。
using System.Threading; using UnityEngine; public class SampleScript : MonoBehaviour { private int 歩数; private void Update() { ユニティちゃんを一歩進める(); if (歩数 % 100 == 0) { 歩数を報告する(); } } private void ユニティちゃんを一歩進める() { 歩数++; //割愛 } private void 歩数を報告する() { Debug.Log("報告しています..."); サーバに歩数を報告する(歩数); Debug.Log("報告しました!"); } }
Updateメソッドの中で報告処理を呼んでしまいました...。このユニティちゃん、報告のたびに足を止める超マイペースなユニティちゃんとなってしまいます。歩きながら報告できるよう、報告処理を非同期Task化しま
using System.Threading; using System.Threading.Tasks; using UnityEngine; public class SampleScript : MonoBehaviour { private int 歩数; private void Update() { ユニティちゃんを一歩進める(); if (歩数 % 100 == 0) { 歩数を報告する(); } } private void ユニティちゃんを一歩進める() { 歩数++; //割愛 } private void 歩数を報告する() { new Task(() => { Debug.Log($"この報告は実行されません"); サーバに歩数を報告する(歩数); Debug.Log("報連相のできないユニティちゃんですね"); }); } }
した!
これだけです。Taskクラスのコンストラクタに非同期化したい処理を渡すだけです。シンプルですね。ただしこのScriptには問題があり、報告処理は実行されません。Taskの開始命令が足りていないのです。次のScriptのように、生成したTaskには開始を命令する必要があります。
using System.Threading; using System.Threading.Tasks; using UnityEngine; public class SampleScript : MonoBehaviour { private int 歩数; private void Update() { ユニティちゃんを一歩進める(); if (歩数 % 100 == 0) { 歩数を報告する(); } } private void ユニティちゃんを一歩進める() { 歩数++; //割愛 } private void 歩数を報告する() { var task = new Task(() => { Debug.Log($"報告しています..."); サーバに歩数を報告する(歩数); Debug.Log("報告しました!"); }); task.Start(); } }
これにて、歩きながら報告のできるユニティちゃんの出来上がりです。
まとめると、Taskを使う手順は次の3つです。
- System.Threading.Tasks.Taskクラスをインスタンス化し、
- 非同期化したい処理を与えてから、
- 開始を命令する
開始を命令しなければTaskは始まらない点、忘れずにいてください。逆に言えば、開始済みのTaskがあるときは必ずどこかに命令処理があります。それは今回と同じStart()
であったり、Start()
とは全く違う別の書き方であったり、状況に応じて様々でありますが、「Taskは開始を命令しないと始まらない」というルールを覚えておくと後のデバッグで役立ちます。
※Taskを使うには .NET framework 4 以上が必要です。こちら等を参考にバージョンアップをお願いします
Taskの使い方(応用編)
前節では基礎的な使い方を説明しましたが、実際の開発ではもっと別の形でTaskを応用していくことが多いです。例えば、
- いちいち
Start()
呼ぶのめんどくね?
とか、
- Taskから戻り値がほしい!
- 複数のTaskを同時に実行したい!
- 1つ目のTaskから戻り値を受け取って2つ目に渡したい!
とかとかとか。
いちいちStart()
呼ぶのめんどくね?
個人的にはそこまで面倒に感じませんが、TaskにはStart()
を省略できる書き方があります。
//Startを省略して実行する Task.Run(() => { Debug.Log($"報告しています..."); サーバに歩数を報告する(歩数); Debug.Log("報告しました!"); });
省略しないケースと比べると、Run
というメソッドが増えた代わりにStart
が減っていますね。
//もともとのソース var task = new Task(() => { Debug.Log($"報告しています..."); サーバに歩数を報告する(歩数); Debug.Log("報告しました!"); }); task.Start();
Run
からTaskを知ってしまうと、「開始を命令する必要がある」という点が抜けがちなので、そこだけ注意が必要です
Taskから戻り値がほしい!
例えば次にように、非同期通信の結果を変更対象のGameObjectに反映させたいとしましょう。
//エラーになる例 private void Awake() { var task = new Task(() => { gameObject.name = サーバから新しい名前をもらう(); }); task.Start(); }
このスクリプトを実行すると次のエラーが発生し、正しく反映させることができません。
get_gameObject can only be called from the main thread.
非同期Taskはメインスレッドとは別のスレッドで動作することを思い出してください。UnityのGameObjectには別スレッドから変更できないという制約があり、その制約を破ると上記のエラーが発生します。
さて、どうするか。
こうしたくないですか?
//こうやってエラーを回避したい例 private void Awake() { var task = new Task(() => { return サーバから新しい名前をもらう(); }); //タスクを開始し、終了したら戻り値を受け取る var サーバからもらった新しい名前 = task.Start(); gameObject.name = サーバからもらった新しい名前; }
したいでしょう。ぜひしましょう。
ただし、上記のスクリプトをそのまま記述してもコンパイルは通りません。Taskから戻り値を受け取りたいときは次のように書きます。
//回避できる例 private async void Awake() { var task = new Task<string>(() => { return サーバから新しい名前をもらう(); }); task.Start(); //タスクが終了したら戻り値を受け取る var サーバからもらった新しい名前 = await task; //メインスレッドで変更するのでエラーは発生しない! gameObject.name = サーバからもらった新しい名前; }
新しい要素がどっと増えましたね。一つずつ見ていきましょう。
1. await
async / await と言えば聞いたことがある人も多いんじゃないでしょうか。await演算子を振る舞いで説明すれば、「Taskの完了したら戻り値を受け取って次に進む」です。まぁきっと語弊があるでしょうが今回はこういう説明で。「Taskが完了したら」っていう点がミソで、逆を言えばTaskを完了するまで次の処理は実行されません。
var task = new Task<string>(() => { var x = 3; Thread.Sleep(3 * 60 * 1000) return x; }); task.Start(); var 待った分数 = await task; Debug.Log($"{待った分数}経ったよほらカップラメーン開けて!") }
みたいな。
なお、await演算子を利用するためにはルールがあり、利用するメソッドにasync修飾子をつけなければいけません。図の3番にあるやつです。なぜつけなければいけないか?と疑問を持つ人もいるかも知れませんが、「awaitのための特別なプログラムを書くとかまじめんどい良い感じにコンパイルしてわかるでしょ?」とおねだりしてるくらいに覚えればよいかと。async修飾子のおかげで我々は、息を吐くように async / await を利用できるわけです。
2. new Task<string>
new
はクラスをインスタンス化する時の命令、Task
はインスタンス化したいクラスを明示する記述です。
では<string>
は?
Taskの戻り値の型を明示する記述となります。先の例では、サーバから新しいGameObjectの名前、つまりstring型の値をもらいましたね。
なぜこんなことを書かなければいけないのか!という疑問に答えるにはいささか長くなりすぎたので、割愛させてください。ここでは、「Taskから戻り値を受け取るときはその型を明示する必要がある」と覚えてほしいです。
3. async
await演算子の説明でも話したように、awaitの利用に必要な記述です。これをつけると、コンパイラがいい感じにプログラムを解釈してくれます。
終わりに
いかがだったでしょうか。
今更感のある説明にはなってしまいましたが、Taskを動作から理解しようとすると何分躓くことも多いだろうなぁとぼんやり思っていたので、今回は必要性から説明するよう心がけてみました。
複数のTaskを連結させる方法とか、同時に実行する方法とか、UniTaskについてとか、今回説明しそこねたいろいろは次回に回したいと思います。
僕自身C#の経験は長くないので、もし誤りなどがあればご指摘いただけると幸いです。