「素人は迂闊に近づくな、身を亡ぼすぞ…」
そんなイメージで今まで近づかなかった人に、async / await / Task を説明します。
素人と玄人の境界線は、知らないものに飛び込めるかどうかでほとんど決まる
スレッドとか非同期の説明
スレッドとか非同期ってなんやねん
簡単に言えば「別の平行世界線」。
A という世界線と、B という世界線が同時並行して存在する。そんな厨二心をくすぐる存在です。
厳密には違ったりしますが、そういうややこしいのはヌキで行きましょう。
平行世界線(スレッド)って、必要ある?
例えばこんな時。
- 時間がかかる、膨大な計算を行い、データを生成する
- 時間がかかる、巨大なファイルを読み込む
- 時間がかかる、ネットに通信し、その結果を待っている
時間がかかる、というのがポイント。
アプリやゲームで数秒画面が固まった場合、人は不安を覚えます。バグったのか? とか UX が悪い、とか言いだすでしょう。
状況によっては1秒未満でも不満を覚えます。
ゲームプレイ中のオートセーブで、セーブする度にゲームが 0.1 秒とか止まってたら「オートセーブうざ」ってなりますよね?
現在の状況をセーブしてほしい。でも、それによってユーザーは待たされたくない。
これが、平行世界線を欲するケースです。
平行世界線の罠
シュタインズゲートでは A の世界線と B の世界線が交わることはありませんでしたが、スレッドは「A と B が交わる必要があります」。
例えば A は B の処理(セーブとしましょう)が終わるまで、こんな表示をさせるとします。
と、いうことはセーブが終了すると、この表示は消す必要があります。
表示を消すのは A です。セーブ終了(がわかるの)は B です。
少なくとも A は B の終了を知る必要があります。
もしかしたら、B はエラーで終わってしまったかもしれません。
その場合 A では、エラーになった事を表示するでしょう。エラー内容を B からもらって。
この同期、伝言ゲームは口で言うほど容易くありません。
あらゆるルートが問題なく動作するよう設計するのは、熟練者といえど厳しいものです。
心しておきましょう。
これを甘く見てると、ほんと痛い目に合う
最初は Task.Run
Task.Run は最も簡単なスレッド生成方法。
test() は A の世界線、longTimeDelay() は B の世界線になります。
void Awake() { test(); } void test() { Task.Run( () => longTimeDelay() ); } void longTimeDelay() { // 3 秒かかる重い処理 System.Threading.Thread.Sleep(3000); Debug.Log("finished."); }
慣れない場合、これから始めるのが1番いいと思います。
UnityEngine にはアクセスしないこと
longTimeDelay() の中で UnityEngine は一切アクセスしない、と覚えておくといいでしょう。
いやいや、それは大げさでしょ!
確かに大げさにいいました。UnityEngine.Debug.Log は B の世界線でも使えます。
が、UnityEngine.Random.Range(1, 10) は A の世界専用です。B で実行したら死にます。
void Awake() { test(); } void test() { Task.Run( () => longTimeDelay() ); } void longTimeDelay() { // ××× ここで処理が止まり、これ以降実行されない UnityEngine.Random.Range(1, 10); // 3 秒かかる重い処理 System.Threading.Thread.Sleep(3000); Debug.Log("finished."); }
これ以外にも、例えば Application.persistentDataPath にアクセスしただけで死にます。マジかよ。
世の中には A の世界線でしか生きられないものが存在します。Unity 系は大体そうです。なので、「別スレッド(B の世界線)では Unity にアクセスしない」と思っておいた方が安全です。
コルーチンならアクセスできるのに
コルーチンなら Unity にアクセスできるのに!
こんな風に思う人がいるかもいれませんが、コルーチンは A の世界線を分けあってるだけに過ぎない、スレッダー界隈では下に見られている存在です。
なんかメチャクチャな事言い出しましたがノリです。すみません。
こんな事言ってますがコルーチンさんにはいつもお世話になっております。
A の世界線を分け合っているだけなので、その証拠にコルーチンで重い処理を回すと、他が全て止まってしまいます。これではスレッドの利点は得られませんね。
どうしてもスレッドからアクセスしたい
どうしても B から UnityEngine.Random.Range したいんだ! という場合、A の世界線にマーキングしておき、都度呼び出す事はできます。
System.Threading.SynchronizationContext context; void Awake() { context = System.Threading.SynchronizationContext.Current; test(); } void test() { Task.Run( () => longTimeDelay() ); } void longTimeDelay() { int val = 0; context.Post( _ => { val = UnityEngine.Random.Range(1, 10); }, null ); // 3 秒待つ System.Threading.Thread.Sleep(3000); Debug.Log($"finished. val={val}"); }
5 行目で世界線のマーキング、18-22 が世界の呼び出しです。
ちょっと面倒ですが、この指定が必要な要件は結構あります。
戻り値が欲しければ async~await
B の平行世界線が終わるのを待って、戻り値を得る。
void Awake() { var _ = test(); } async Task test() { int val = await Task.Run( () => longTimeDelay() ); Debug.Log($"finished. val={val}"); } int longTimeDelay() { System.Random rand = new System.Random(); int val = rand.Next(1, 10); // 3 秒待つ System.Threading.Thread.Sleep(3000); return val; }
戻り値を得るためには、B の終了を待たなければいけません。これが await です。
なお、await を記述したメソッドには必ず async をつけるというルールがあります。
async void ではなく async Task、非同期で込み入ったプログラムを書いている人ほど「このルールは絶対」という珠玉のルールです。守りましょう。
async void は筆頭問題児
void Awake() { test(); } async void test() { await test1(); test2(); await test3(); } async Task test1() { await Task.Run( () => longTimeDelay("test1") ); } async void test2() { await Task.Run( () => longTimeDelay("test2") ); } async Task test3() { await Task.Run( () => longTimeDelay("test3") ); } void longTimeDelay(string name) { Debug.Log($"start. name={name}"); System.Threading.Thread.Sleep(3000); Debug.Log($"end. name={name}"); }
test() は test1 -> test2 -> test3 と、非同期メソッドを同期的に(順番に)実行したいと思っています。
ただし、test2 は async void なので await をつけません(つけられません)。
なんとなくイメージ的には「async void の方が await つけなくていい分楽じゃね?」と思ってしまいそうです。
ところがログでは test2 開始直後すぐに test3 が実行されてしまいます。
test2 は async void がついておらず await も使えず、結果として同期実行できないのです。
そもそも test() のような書き方が間違いなのですが、test2 も半端に async がついてるし、ボーッとしてこのように間違えた書き方をしちゃいそうです。
こうなったら致命傷。test3 が test2 実行後のデータを必要としていた場合、普通に落ちます。
実際に運用するコードであれば「test2 が間にあえば動く、間に合わない時だけ落ちる」という原因不明の不具合になることが多いでしょう。
この落し穴を防ぐためのセーフティネットとして、なるべく async Task にしておくことです。
async void test() のように、async void は「これ以上は上から呼ばれないメソッド」にしましょう。
エラーハンドリング
B の世界線で起こったエラーを、A に伝える方法を紹介。
他にもあると思いますが、とりあえず使いそうなのを。
async void Awake() { // パターン1 try { string result = await methodASync("123456"); } catch (Exception ex) { Debug.LogError(ex.Message); } // パターン2 Task<string> task = methodASync("123456"); await task.ContinueWith( (t) => { if (t.Exception != null) { Debug.LogError(t.Exception.InnerExceptions[0].Message); } } ); } async Task<string> methodASync(string arg) { // なんかエラーが発生したと仮定 throw new Exception("error"); await Task.Delay(3000); return arg + "abcdef"; }
先ほどまでは「同期メソッドを非同期で呼び出す」でしたが、今回は「非同期メソッドを非同期で呼び出す」です。
methodASync が B の世界、Awake が A の世界線です。
await つけてるから結局同期的
パターン1は await を使う方法、パターン2は await を使わない方法です。
とりあえずパターン1だけ覚えておけばいいと思います。
パターン2は、Task.WhenAll とか使いだすと有用?
参考にしたサイト
先人たちに大感謝。