はじめに
Unityにおける非同期自体は需要が高いこともあり、いい記事もたくさんあります。
しかし、コルーチンより **Task (async・await)**を使うメリットが書いてある記事や lock(非同期時における変数へのアクセス制御)についても書いてある記事が少ないのでそこらへんについて書いていきます。
**なお、実装に関してはほぼ書きません。**リンク先を参考にしてください。(というかみんな良記事を書くから私が書く理由を見出せなかった。)
非同期について深くやった理由は↓のやつを作ったからです。
FaceRigなんてもう古い!Vをやるならこれを使え!
環境
- Unity 2018.3.8f1
非同期の必要性
そもそもなんで非同期が必要か。非同期というのはかなり難しいとこです。(なのでみんな悩まされるのですが。)その理由は、デメリットをはるかに超えるメリットがあるからです。
主なメリットは二点。
- メインの処理を止めずに時間を待てる
- 処理の高速化ができる
このうち、上のものは Unity がコルーチンという形で簡単に扱えるようになっています。**Task (async・await)**は両方できます。
非同期に関する良記事まとめ
先に非同期について非常に良い記事のリンクを置いておきます。実装するときはこちらを参考にしてください。なお、私とは一切関係ありません。
- 【Unity】はじめてのコルーチン!これさえ読めば基礎はカンペキ
- 古来よりUnity非同期を実現していたコルーチンとは何者か?
- 非同期理解のためにasync/awaitとTaskの基礎を学んだ話
- 初心者のためのTask.Run(), async/awaitの使い方
- Taskを極めろ!async/await完全攻略
async・await をやる理由
正直、コルーチンより **Task (async・await)**のほうがはるかに難しいです。それでも **Task (async・await)**を使う理由は、パフォーマンスのためです。**Task (async・await)**はマシンのスペックをフルに使えるのです。
CPUって、〇コア・〇スレッドとありますよね。コルーチンは1コア・1スレッドしか使えず、 **Task (async・await)**は(調整すれば)すべて使えます。
なぜそうなるのかをちょっとイラスト化してみました。
舞台はUnity社。現実の Unity Technologies Corporation は全く関係ありません。名前の都合です。
コルーチンの場合
なお、業務は実際は関数となります。
しかし...実際に働いている人は一人しかいないので問題も起こります。Unity社ではまだ人は余っているのに。
これは、私たちにおけるフレーム落ちや、重いといった結果になります。つまり、私たちはきちんと時間と計算量を考慮に入れてコルーチンを作る必要があります。
Task (asinc・await)の場合
こんな風に雇っていたバイト君を使い倒します。
Task (asinc・await)のはまりポイント
このように大きなメリットのある Task (asinc・await)ですが、気をつけなければいけないところもあります。
Unityのオブジェクトを操作するとき
Unity社の資産は大事です。なくしたり、壊されたりするのは困ります。なので、Unity社ではバイト君たちにはいじらせるということは許可されてません。もしバイト君たちがいじろうとしたらxxx can only be called from the main thread.
というエラーが吐かれます。
ではどうするか。答えは簡単で、責任者に任せます。
public class Temp : MonoBehaviour
{
private SynchronizationContext _context;
private void Start()
{ // 現在のスレッド情報を取得。MonoBehaviourを継承したクラスはメインスレッドを取得。(責任者を覚える)
context = SynchronizationContext.Current;
Task.Run(MoveAsync);
}
//非同期にこのオブジェクトを移動させるメゾット。返り値voidはよくないけど無視。
private async void MoveAsync()
{
while(true){
//_contextに保存したスレッドでラムダ式の中身を実行させる。第二引数のnull忘れずに。
//要は責任者に実行だけさせる。
_context.Post(() =>{
var pos = transform.position;
pos.x += 1;
transform.position = pos;
}, null);
await Task.Delay(100);
}
}
}
このように、エラーで怒られるところをメインスレッドで実行させるようにします。
同じオブジェクトを操作するとき
以下の例を考えましょう。なお、変数aは数字です。
バイトA君は
- 変数aを読み込む
- aに1加える
- aに反映させる
バイトB君は
- 変数aを読み込む
- aから1引く
- aに反映させる
とします。A君とB君が同時に操作させます。結果は元と変わらないはずです。しかし、もしこれが以下の順番で起きたとします。
- A君読み込み
- B君読み込み
- A君計算
- B君計算
- A君反映
- B君反映
そうすると、B君の変更のみ適応された、つまり、元の数から1引いた数が結果となり、バグとなります。こういうことが起こるのは稀と思う人もいるかもしれませんが、マルチスレッドをやると無視できないぐらいには起こります。
これを防ぐにはlock構文を使います。
lock構文
lock構文の基本は以下のコードです。
lock(someobject){
// ここに同時にされてはまずい処理を書く。
}
lock構文では、someobject
にあたる部分にobject
を与えることにより、中の処理は同じsomeobject
のlock構文では必ず1か所のみでしか実行されません。他のところでこのlockに行きつくと、そのコードは待機します。前のlockがブロックを出たら、lockに入ります。
実際に例を見ましょう。
public class Temp : MonoBehaviour
{
private int count = 0;
private int count2 = 0;
private object lock_count = default; // どちらも
private object lock_count2 = new object(); // 同じ
private void Start()
{
Task.Run(Incriment1);
Task.Run(decriment1);
Task.Run(Incriment2);
Task.Run(decriment2);
}
private void Incriment1()
{
while(true){
lock(lock_count){
count++;
}
}
}
private void Decriment1()
{
while(true){
lock(lock_count){
count--;
}
}
}
private void Incriment2()
{
while(true){
lock(lock_count2){
count2++;
}
}
}
private void Decriment2()
{
while(true){
lock(lock_count2){
count2--;
}
}
}
}
このようにすることで矛盾なくできるようになります。このコードは一見lock要らなさそうに見えますが、必要です。(といってもこのコードもうまく動くとは思わないけど。)
lockに関する良記事
詳しくlockやそれに似たものを知りたい場合は以下の記事が参考になると思います。(例えば今回の例だとさらに早くなります。)
さいごに
非同期は難しいけど使いこなすと非常に強力です。みなさんがんばってください。