【ゲームAI考察 #2】ゲームAIで考える非同期/マルチスレッド処理

# はじめに+概要
今回の記事の目的は以下の通りです。

- **ゲームAIの実装を例に非同期orマルチスレッド処理の使いどころを探る。**
- **非同期処理、マルチスレッド処理について「なんとなく」じゃない理解をする。**

あと、タイトルに **#2** とあるのは下の記事の続きというテイでこの文章を書いているからです。
ご興味ありましたら#1も読んでみてください。

[【ゲームAI考察 #1】汎用ゲームAIから条件分岐を徹底排除！！](https://qiita.com/cushionA/items/8a7c3d5fe614c8f0a88a)

### <font color="Deepskyblue">【今回の検証について】</font>
この記事で行う検証について説明します。
もし一言で表すなら次のような感じになるでしょうか。

<font color="Salmon" size="5">**「非同期orマルチスレッド処理を組み込むことで、ゲームAIのパフォーマンスを向上できるか検証」**</font>

さらに言うと**通常の同期処理との比較の過程で、非同期/マルチスレッド処理について理解を深められたらいいな**という感じでもあります。

### <font color="Deepskyblue">【なぜAIを題材にするのか？】</font>
それは **ゲームAIに「待ち」の処理があるから**です。
たとえばですが、アクションゲームの敵キャラがn秒に一回行動を切り替えるための判断をする、と考えてください。
この時、**何度も「n秒待つ」という作業が繰り返されて**います。
それが私の言う「待ち」です。

そして検証前のあやふやな理解ですが、非同期処理orマルチスレッド処理は次のような場合に使うものであるという認識でした。
そのため時間待ちの処理でこの検証をすると決めました。

<dl>
  <dt><font size="4"><strong>非同期処理</strong></font></dt>
  <dd>時間がかかる処理をするとき、その処理の完了を待たないのが非同期処理。</dd>
  <dd>また、<strong><font color="Salmon">cpuがいい感じに実行して処理の完了時にドンピシャで結果を返してくれる（はず）。</font></strong></dd>
  <dd>なのでメインループで毎フレーム繰り返すより負荷が軽い。（はず）</dd>
  <dt><font size="4"><strong>マルチスレッド処理</strong></font></dt>
  <dd>ある処理をメインスレッド以外で実行することでCPUをフル稼働！</dd>
  <dd>また、<strong><font color="Salmon">cpuがいい感じのタイミングでたまにスレッドを切り替えて実行する。（はず）</font></strong></dd>
  <dd>なのでメインループで毎フレーム繰り返すより負荷が軽い（はず）。</dd>
</dl>

これが正しければ当然、同期処理より大幅に負荷が軽減できているはずですね。
早速調べていきましょう。

# 検証実施
### <font color="Deepskyblue">【使用するもの】</font>
<dl>
  <dt><strong>Unity（ver 6000.0.35f1）</strong></dt>
  <dd>C#でコーディングできるゲームエンジンです。</dd>
  <dd>今回の検証では実際に<strong>同期、非同期、マルチスレッド</strong>の<strong>それぞれで時間待ちを実装したゲームAI</strong>を動かしてみます。</dd>
  <dt><strong>Unitask</strong></dt>
  <dd>非同期、マルチスレッド処理に使用するライブラリです。</dd>
  <dd>こちらは<strong>Cygames社のライブラリ</strong>で、かなりお手軽に非同期処理やマルチスレッド処理を書けます。</dd>
  <dd><strong>これに関しては別にリンクを追記します。</strong></dd>
    <dt><strong>Profile Analyzer</strong></dt>
  <dd>Unityのパッケージで、Profilerというデバッグ用機能で取得したログを詳細に分析できます。</dd>
  <dd>今回の検証ではこのツールを使用してそれぞれのAIのパフォーマンスを比較します。</dd>
</dl>

:::note info
<font color="Limegreen">**追記**</font> 
**Unitaskのリンク**
[**ユーザー名：Cysharp様**によるUnitaskのリポジトリ（記事投稿日の時点でMITライセンス）](https://github.com/Cysharp/UniTask)

**Profile Analyzerの使用にあたって参考にさせていただいた記事**
[**@mao_様**の記事『【Unity】CPU Profilerの結果を分析できる「Profile Analyzer」が便利という話』](https://qiita.com/mao_/items/f8e61bf1d9d5aa5d7fe4)

:::

### <font color="Deepskyblue">【検証の概要】</font>
細かい検証の内容は次の項目で説明しますが、ここでは最低限の前提をお伝えします。
**つまり面倒なら次の項目は読まなくてもいいということ**（代わりにここは読んでいただけると……）。

- **実行環境は固定60フレーム。**
　**<font color="Deepskyblue">→Profilerの仕様上可変フレームレートでのパフォ－マンスの比較が難しいため。**
- **オブジェクト生成・破棄の影響を除くために200フレーム目から1400フレーム目までを切り出して比較。**
- **実行環境は2Dゲームのプロジェクト。</font>**
- **検証に使用するゲームAIは二秒に一回移動方向（左右）を変えるだけの単純なもの。**
　**<font color="Deepskyblue">→この二秒に一回、の時間待ち処理を非同期/マルチスレッドに置き換える。</font>**
- **同期、非同期（2パターン）、マルチスレッド、の四種類のAIを用意。**
- **<font color="Salmon">以下の画像</font>のようなAI付きオブジェクトのprefabを1000個生成して、30秒間動かした結果でパフォーマンスを比較。**

|<font color="salmon"> **オブジェクト** </font>|
|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/dc1e53b5-6467-f3b4-8ce1-003296534bd0.png)|


|<font color="salmon"> **生成した様子** </font>|
|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/c3b81c0a-7255-a6bd-2f55-477860505dc7.png)|

また、最後に今回はマルチスレッドの処理のパフォーマンスを見ることから、**メインスレッドの負荷に加えてスレッドプールの使用状況も確認したい**です。
とはいえ実際に見たところ動いているスレッドが予想以上に多くて困ってしまいました。

|<font color="gray"> **（多すぎ…）** </font>|
|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/8de84e6f-dea8-48dc-813a-b699be9c5bc7.png)|

そこでマルチスレッド処理の中に ```Debug.Log($"スレッド名：{Thread.CurrentThread.Name}")```を差し込んだところ **```Thread Pool Worker```と言う名前のスレッド** だと分かりました。
そちらを確認していきます。

|<font color="black"> **スレッド名** </font>|
|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/d0a21603-4dda-4041-a496-1f8b40d75715.png)|

<!-- open属性あり -->
<details><summary><strong>検証の詳細（読み飛ばしてもOK）</strong></summary>

### <font color="Deepskyblue">【検証の詳細】</font>
ここでは検証の詳細についてコードを絡めながら説明します。
まず**同期、非同期（2パターン）、マルチスレッド**の四種類のAIが、それぞれどのように時間待ち処理を行っているのかを示します。
正直上手く実装できている自信がないため<font size="6">**実装のご指摘をいただけると嬉しいです**</font>。

#### 同期処理のAIのコード
一つ目のAIであり、比較の基準となる同期処理のコードは以下です。

<!-- open属性なし -->
<details><summary> <font color="Deepskyblue"><strong>同期待ちコード（展開）</strong></font></summary>

```C#:SyncAI.cs
using UnityEngine;

public class SyncAI : AsyncTestBase
{

    private void Start()
    {
        base.Initialize();
    }

    /// <summary>
    /// ループ内で判断を行う。
    /// </summary>
    private void Update()
    {
        // 判断をパスしたなら移動判断をする。
        if ( IntervalEndJudge() )
        {
            base.MoveJudgeAct();
        }
    }
}
```
</details>

見た通りカンタンな作りになっています。
処理の内容は次の通りです。
- **```IntervalEndJudge()```メソッドで毎フレーム時間経過をチェック。**
- **二秒経過しているようなら ```base.MoveJudgeAct()```メソッドで移動方向（左右）を変更。**

ついでに```IntervalEndJudge()```メソッドの内部は以下のようになっています。

<!-- open属性なし -->
<details><summary><font color="Deepskyblue"><strong>時間チェックメソッド(展開)</strong></font></summary>

```C#:AsyncTestBase.cs
    /// <summary>
    /// 判断間隔の待機が完了したかを判定するメソッド。<br></br>
    /// 非同期、同期などのコード間で公平を期すため、同じ処理を使うために関数化。
    /// </summary>
    /// <returns></returns>
    protected bool IntervalEndJudge()
    {
        // テスト継続中(isTestフラグ真)
        // かつ前回の判断実行時間（lastjudgeの値）から設定したインターバル(status.judgeInterval)以上の時間が経過
        // ＝ 判断に必要な待機時間が経過したと判定し真を返す。
        return GameManager.instance.isTest && ((GameManager.instance.NowTime - lastJudge) > status.judgeInterval);

        // ちなみにNowTimeプロパティは Time.time の値と同じ。
        // TimeクラスではC++のDLLを呼んでいるようなので、1フレームに一回、シングルトンで値を取得・更新するだけにしたい意図でプロパティにした。
    }
```
</details>

#### 非同期処理のAIのコード
これまで何度か触れたように、非同期処理のAIは二つ用意してあります。(いずれもUnitaskを使用)
そのうちの片方は```IntervalEndJudge()```メソッドを利用して時間を判定する **```AsyncAI.cs```** です。
```IntervalEndJudge()```メソッドを利用する意図は、判定に同期処理と共通の関数を利用することで、条件をそろえて公平を期するためです。

<!-- open属性なし -->
<details><summary><font color="Deepskyblue"><strong>非同期1のコード(展開)</strong></font></summary>

```C#:AsyncAI.cs
using UnityEngine;
using Cysharp.Threading.Tasks;
using System.Threading;
using System;

public class AsyncAI : AsyncTestBase
{

    /// <summary>
    /// 初期化して非同期判断ループ開始。
    /// </summary>
    void Start()
    {
        // 初期化
        base.Initialize();

        // 非同期ループ開始。
        AsyncJudge().Forget();
    }

    /// <summary>
    /// 非同期の判断メソッド。<br></br>
    /// 指定秒数ごとに行動判断をするループを回し続ける。
    /// </summary>
    /// <returns></returns>
    async UniTaskVoid AsyncJudge()
    {
        // キャンセルトークン取得
        CancellationToken token = GameManager.instance.cToken.Token;

        // キャンセルされるまで続ける。
        // トークンをこんな使い方しているのは、トークンを渡すとcancellationToken.Cancel()が例外を投げるから。
        // ＝ 例外処理をしたくないから。
        // もっといい方法があれば教えてください。
        while ( token.IsCancellationRequested == false )
        {
            // 判断間隔条件が真になるまで待つ。
            // 公平を期するために同期処理と同じ判断処理を使う。
            await UniTask.WaitUntil(() => IntervalEndJudge());

            // 行動判断
            MoveJudgeAct();
        }
    }

}

```
</details>

そしてもう一つの非同期処理のAIでは、時間判定に別の処理を使用しています。
すなわちUnitaskの時間待ちで最もポピュラー（多分）な ```UniTask.WaitForSeconds()```メソッドを使用しているということです。
共通の処理にこだわらずに書いた非同期処理のAIになります。
なお、 **```AsyncAI.cs```との違いは```AsyncJudge()```内部だけ** ですのでコードはその部分だけです。

<!-- open属性なし -->
<details><summary><font color="Deepskyblue"><strong>非同期2のコード(展開)</strong></font></summary>

```C#:AltAsyncAI.cs
    /// <summary>
    /// 非同期の判断メソッド。<br></br>
    /// 指定秒数ごとに行動判断をするループを回し続ける。
    /// </summary>
    /// <returns></returns>
    async UniTaskVoid AsyncJudge()
    {
        // トークン取得
        CancellationToken token = GameManager.instance.cToken.Token;

        // キャンセルされるまで続ける。
        while ( token.IsCancellationRequested == false )
        {
            // 忖度なしで一番いい？ 処理をつかう
            await UniTask.WaitForSeconds(status.judgeInterval);

            // 行動判断
            MoveJudgeAct();
        }
    }
```
</details>

#### マルチスレッド処理のAIのコード
最後にマルチスレッド処理のAIのコードを貼ります。
マルチスレッドでは次のように、スレッドを切り替えてから10msごとに時間の判断を行うような処理にしています。
こちらも **```AsyncAI.cs```との違いは```AsyncJudge()```内部だけ** ですのでコードはその部分だけになります。

<!-- open属性なし -->
<details><summary><font color="Deepskyblue"><strong>マルチスレッドAIコード(展開)</strong></font></summary>

```C#:MTAsyncAI.cs
    /// <summary>
    /// マルチスレッドの判断メソッド。<br></br>
    /// スレッドを切り替えてループを回し続ける。
    /// </summary>
    /// <returns></returns>
    async UniTaskVoid AsyncJudge()
    {
        // トークン取得
        CancellationToken token = GameManager.instance.cToken.Token;

        while ( token.IsCancellationRequested == false )
        {
            // プールしたスレッドで時間待ちする。
            // これ以降の処理をスレッドプールへ切り替え。
            await UniTask.SwitchToThreadPool();

            // ちゃんと書けてるかわかりませんがマルチスレッド処理
            while ( IntervalEndJudge() == false )
            {
                // 100ms(0.1)秒ごとに時間を確認
                Thread.Sleep(100);
            }

            // 設定した時間が経過したらメインスレッドに戻す
            await UniTask.SwitchToMainThread();

            // 行動判断
            MoveJudgeAct();
        }
```
</details>

```Thread.Sleep()```メソッドで100ms（0.1秒）ごとに判断しています。
固定60fの１フレームが0.03秒なので、時間の経過の判断の回数がグッと減ると期待できます。
1フレームごとに判断する同期処理に比べると、```IntervalEndJudge()```の実行回数は3分の1になるはずです。

#### 検証の補足
今回の検証では主にパフォーマンスを見るのが趣旨です。
しかし、同じくらい大切……というより前提となるべきなのが**正確性**であると思います。
つまり、1000個のオブジェクトが、30秒間の間、2秒に一回判断をするとして。
**```IntervalEndJudge()```の実行回数は 30 × (1/2) × 1000 で15000回になるべき**なのです。
ですが、非同期/マルチスレッド処理を使用した結果正しい間隔で処理が実行されなくなり、**2~10秒ごとに判断をするAIができてはまずい**です。
そのため、パフォーマンス比較前に一定の正確性が担保されていることを確認しました。

<details><summary><font color="Deepskyblue"><strong>テスト終了時のデータ出力コード(展開)</strong></font></summary>

```C#:GameManager.cs

            // 終了処理開始。
            isTest = false;
            cToken.Cancel();

            // テスト用のAsyncTestBase継承オブジェクトを収集。
            AsyncTestBase[] objects = FindObjectsByType<AsyncTestBase>(sortMode: FindObjectsSortMode.None);

            // 基準となる判断回数の期待値を取得。終了時間を判断間隔で割る。
            int baseCount = (int)Mathf.Floor(endTime / objects[0].JudgeInterval);

            // 期待値と実際の判断回数との間の差異を格納する。
            long divide = 0;

            for ( int i = 0; i < objects.Length; i++ )
            {
                // AsyncTestBase継承オブジェクトが持つ実判断回数を使用して期待値との誤差を出す。
                // 期待値 - 実判断回数 を加算し続ける。
                divide += baseCount - objects[i].judgeCount;
            }

            // テスト結果出力ファイルに新しい行を追加して書き出す。
            using ( StreamWriter sw = new StreamWriter(resultPath, true) )
            {
                sw.WriteLine($"テスト区分：{caseType.ToString()}");
                sw.WriteLine($"実行日時：{DateTime.Now.ToString()}");
                sw.WriteLine($"期待値：{baseCount * genNumber}回 に対して実測値：{divide}回 の差異。");
                sw.WriteLine($"平均誤差：{divide / genNumber} オブジェクト数：{genNumber}個");
                sw.WriteLine($"総処理時間：{totalTime}");
                sw.WriteLine(string.Empty);// 空行
            }

            // テスト終了。
            EditorApplication.ExitPlaymode();
```
</details>

このコードにより出力されたデータが以下です。

<table>
  <caption><strong>テスト結果</strong></caption>
  <thead>
    <tr>
      <th>テスト区分</th>
      <th>実行日時</th>
      <th>期待値の実行回数</th>
      <th>誤差の総計</th>
      <th>実際の実行回数</th>
      <th>平均誤差（総誤差/オブジェクト数）</th>
      <th>オブジェクト数</th>
      <th>総処理時間</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>同期</strong></td>
      <td>2025/02/11 1:15:12</td>
      <td>15000回</td>
      <td>1000回</td>
      <td>14000回</td>
      <td><strong>1</strong></td>
      <td>1000個</td>
      <td>1475428260000</td>
    </tr>
    <tr>
      <td><strong>非同期</strong></td>
      <td>2025/02/11 1:16:18</td>
      <td>15000回</td>
      <td>1000回</td>
      <td>14000回</td>
      <td><strong>1</strong></td>
      <td>1000個</td>
      <td>1474608470000</td>
    </tr>
    <tr>
      <td><strong>別処理非同期</strong></td>
      <td>2025/02/11 1:17:13</td>
      <td>15000回</td>
      <td>1000回</td>
      <td>14000回</td>
      <td><strong>1</strong></td>
      <td>1000個</td>
      <td>1480289270000</td>
    </tr>
    <tr>
      <td><strong>マルチスレッド</strong></td>
      <td>2025/02/12 0:54:12</td>
      <td>15000回</td>
      <td>1000回</td>
      <td>14000回</td>
      <td><strong>1</strong></td>
      <td>1000個</td>
      <td>1451073450000</td>
    </tr>
  </tbody>
</table>

四つのAIで全く同じ誤差でした。
~~総処理時間列でネタバレしてるのは見なかったことにしてくださいね。~~
以上から非同期/マルチスレッド処理でも時間待ちには最低限の正確性があり、パフォーマンスの比較が可能な土俵に立っていると判断しました。

</details>

# 結果

ここからは **ProfilerAnalyzer**を使ってパフォーマンスを見ていきます。
先述の通り比較の範囲は**AIの処理以外の影響を除くために200フレーム目から1400フレーム目まで**です。
が、その前に軽く使い方について触れておきます。（読まなくてもいいです）

### <font color="Deepskyblue">**【ProfileAnalizerの使い方】**</font>

<details><summary>展開で表示</summary>

ProfileAnalizerはパッケージマネージャからインストールすることができます。

|<font color="black"> **パッケージマネージャーの表示** </font>|
|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/ea88abb1-792e-4bd1-8c32-dcffe4865c1f.png)|

そしてこのツールを使うためには、まず通常のProfilerで記録したデータが必要です。

|<font color="black"> **Profilerの記録** </font>|
|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/85078ee0-e940-4228-af16-8da77938442b.png)|

この時**Deep Profile**しておくとメソッドごとの処理負荷を確認出来て便利です。
また、60fpsとはいえ通常は30秒間もの長さの記録を取ることはできません。
こうした検証の際はProfilerのウインドウの右上の **：** から **Preference** に飛び、<font color="Salmon">**FrameCount**</font>の値を変えて記録可能時間を伸ばしましょう。

|<font color="black"> **設定の流れ** </font>|
|:-:|
|**Preferenceに飛ぶ**|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/ab4c942d-481d-47a8-83e5-75ffd92c6ec7.png)|
|**FrameCountの値を増やす**|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/3c36ea82-3154-4a0d-87d9-254647fd2dce.png)|

そうして記録したデータをProfilerに表示したまま、```Window->Analysis->ProfileAnalizer```でツールを開きます。
そして**Pull Data**のボタンを押すと、Profilerに表示されているデータの分析結果が出てきます。

|<font color="black"> **データ表示手順** </font>|
|:-:|
|**Profilerにデータを表示したままPull Data**|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/02e6e174-1c7c-4285-8739-9c5118e1a094.png)|
|**するとデータの分析結果が出てくる**|
|![Sync.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/cf7f44f5-33de-4110-9ec3-c2338cdbf9df.png)|

</details>


### <font color="Deepskyblue">**【同期処理のパフォーマンス】**</font>

|<font color="red"> **同期処理の結果（クリック：別タブで拡大表示）** </font>|
|:-:|
|![Sync.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/cf7f44f5-33de-4110-9ec3-c2338cdbf9df.png)|

上記の画像が同期処理で1000のオブジェクトを30秒動かした結果です。
かなり情報量が多いため整理していきましょう。
まず、このProfileAnalizerというツールでは特定の処理の分析結果だけを抜き出すことが可能ですのでフィルターをかけます。

フィルターには見たい処理の名前を書く <font color="Salmon">**Name Filter**</font>と排除したい処理の名前を書く <font color="Deepskyblue">**Exclude Filter**</font>があります。

|<font color="black"> **フィルター二種** </font>|
|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/665f16f4-3163-4c62-8f62-b28b6b712fdf.png)|

まずは**NameFilterに <font color="Salmon">SyncAI</font>というワードを指定しましょう**。
このSyncAIとは同期処理AIのコンポーネントの名前です。

|<font color="black"> **インスペクタのコンポーネント** </font>|
|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/0c1a6666-4bdf-41e9-98cc-84f2a408502c.png)|

ということで、コンポーネント名でフィルターをかけて表示した結果がこちらになります。

|<font color="red"> **同期処理の結果（フィルター有）** </font>|
|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/3a8b0143-574b-48c1-86b9-a044b2f36a7f.png)|

だいぶ見やすくなりましたね。
ではここで表示されているどの情報に着目するか、という点を次は決めたいと思います。
色々と列がありますが、今回は　**Median（中央値）とMean（平均値）**　の列を基準としたいと思います。
これらは指定した範囲（ここでは200~1400フレーム）の間での、該当の処理の処理時間の中央値と平均値を表示する列です。
すなわち <font color="Salmon">**大体どれくらいの時間で Assembly-CSharp.dll!::SyncAI.Update() が処理されているか**</font>を確認できます。

<table>
  <caption><strong>処理時間</strong></caption>
  <thead>
    <tr>
      <th>処理種別</th><th>中央値（Median）</th><th>平均値（Mean）</th>
    </tr>
  </thead>
  <tr>
    <td><strong>同期処理</strong></td> <td>0.26 ms</td><td>1.09 ms</td>
  </tr>
</table>

以降は同じ条件で処理時間を抽出していきます。

### <font color="Deepskyblue">【同期 vs 非同期1】</font>
一つ目の非同期処理のAIに対して、コンポーネント名でフィルターをかけた結果が以下です。

|<font color="red"> **非同期処理の結果（フィルター有）** </font>|
|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/58ec2a87-5406-42de-94c0-83f0b235d82e.png)|

そして同期処理との比較が以下になります。

<table>
  <caption><strong>処理時間比較</strong></caption>
  <thead>
    <tr>
      <th>処理種別</th><th>中央値（Median）</th><th>平均値（Mean）</th>
    </tr>
  </thead>
  <tr>
    <td><strong>同期処理</strong></td> <td>0.26 ms</td><td> <font color="red"><strong>1.09 ms</strong></font></td>
  </tr>
    <tr>
    <td><strong>非同期処理1</strong></td> <td>0.26 ms</td><td> <font color="deepSkyBlue"><strong>0.27 ms</strong></font></td>
  </tr>
</table>

結果としては、平均値で非同期がかなり優れているように見えます。
ここは非同期の勝利、ということで二個目の非同期の比較に移ります。

### <font color="Deepskyblue">【同期 vs 非同期2】</font>
二つ目の非同期処理のAIに対して、コンポーネント名でフィルターをかけた結果が以下です。

|<font color="red"> **非同期処理2の結果（フィルター有）** </font>|
|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/ae205071-8f16-4444-b867-d8ae1bd6040d.png)|

**ない、なにも……。**

何故でしょうか？
分かりませんね。
ともかく、こんな時確認すべきは中身の処理です。
そして調べた結果、この非同期処理2で使用している ```UniTask.WaitForSeconds()```では、毎フレームとある処理を呼び出していると判明しました。（掘り下げは後回し）
それは ```UniTask.dll!::DelayPromise.MoveNext()``` です。
よってこちらでフィルターをかけると、ちゃんと毎フレームの待機処理の結果を出せました。

|<font color="red"> **非同期処理2の結果（フィルター変更）** </font>|
|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/33ca562b-9be7-4346-a2d6-27689f1e2660.png)|

そして同期処理との比較が以下になります。

<table>
  <caption><strong>処理時間比較</strong></caption>
  <thead>
    <tr>
      <th>処理種別</th><th>中央値（Median）</th><th>平均値（Mean）</th>
    </tr>
  </thead>
  <tr>
    <td><strong>同期処理</strong></td> <td> <font color="deepSkyBlue"><strong>0.26 ms</strong></font></td><td> <font color="deepSkyBlue"><strong>1.09 ms</strong></font></td>
  </tr>
    <tr>
    <td><strong>非同期処理2</strong></td> <td><font color="red"><strong>0.28 ms</strong></font></td><td> <font color="red"><strong>1.90 ms</strong></font></td>
  </tr>
</table>

結果としては全体的に同期処理が優れているようです。
奇しくも同期処理1の時と逆転しました。
同じ非同期なのに。
これがどういうことなのかはまた後で考えるとして、最後のマルチスレッドの比較に移りましょう。

### <font color="Deepskyblue">【同期 vs マルチスレッド　と 全体のまとめ】</font>
マルチスレッド処理で時間待ち処理を行った結果は以下です。

|<font color="red"> **マルチスレッドの結果（フィルター無）** </font>|
|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/19dcb172-f0dc-4780-af5c-6f66f013ecf9.png)|

**上記、情報量が多いのはフィルター無しだからです**。
ではなぜフィルター無しなのか。

それは <strong><font color="Salmon">どうフィルターしていいのか全く分からなかったから</font></strong>です。
**というより、仕様上 ```Thread.Sleep()``` でフレームをまたぐため、比較対象となる毎フレーム実行される処理がない？のです。**

ですから比較のやり方を変えなければなりません。
具体的に言うと、フレームをまたいで処理を比較できるようになる必要があります。
そのためにはまず、そもそもどんな名前の処理が「待ち」の処理に相当するのかを確認しなければなりません。
この方法は簡単です。
以下のコードをマルチスレッドのAIの時間待機処理に差し込むだけです。

```Profiler.BeginSample("Wait Process")```

これで待機処理の一部に**Profilerで追跡可能な ```Wait Process``` という名前がつきました。**
この処理を行った上で、Profile Analyzerから通常のProfilerに戻りましょう。

すると以下の結果を確認できます。

|<font color="red"> **Profiler上でWait Processを追跡** </font>|
|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/56ca261a-b74d-47e5-a47f-351ca393e7c2.png)|

```Wait Process```を発見出来ました。
とはいえ、このマルチスレッド処理全体がイコールで```Wait Process```である、というわけではないはずです。
先述の通り、この名前をつけたのはあくまで一部ですので。
```Wait Process```を含むツリーを上に登っていくと、メソッド名を含むそれっぽいマーカーを見つけました。
<strong>```Assembly-CSharp.dll!::<AsyncJudge>d__2.MoveNext()```</strong>です。

ただぽい、では良くないので色々調べた結果（こちらは最後に参考文献として紹介します）、簡単に言うと次のようなことが起こっていると分かりました。

Asyncメソッドは完了/未完了などの状態を管理するための型（ステートマシンと言います）のオブジェクトを生成する。
そしてそのオブジェクトの```MoveNext()``` メソッド内に非同期メソッドの処理部分が変換される。
よって時間待ちメソッドは<strong>```Assembly-CSharp.dll!::<AsyncJudge>d__2.MoveNext()```</strong>で間違いないです。

|<font color="red"> **時間待ち処理の本体** </font>|
|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/c5a08956-3e31-4d2f-ab2e-222287fbdc72.png)|

ではこちらのマーカーの処理をどのように他と比較するのかを考えましょう。
パッと思いつくのは、やはり平均値を総時間に変換するやり方でしょうか。
つまり**平均値 × 実行回数 = 総実行時間**となります。
こうすれば **毎フレーム実行されるが一回の時間が短い処理**と **毎フレーム実行されないが一回の時間が長い処理**を比較も出来そうですね。
そしてその実行回数は**count**列に記載されているので簡単に計算出来そうです。
そして計算した結果は以下の表になります。

<table>
  <caption><strong>総処理時間比較</strong></caption>
  <thead>
    <tr>
      <th>処理種別</th><th>平均時間</th><th>実行回数</th><th>総処理時間</th>
    </tr>
  </thead>
  <tr>
    <td><strong>同期処理</strong></td><td>1.09 ms</td><td>1201000</td><td><strong>1309090 ms</strong></font></td>
  </tr>
  <tr>
    <td><strong>非同期処理1</strong></td><td>0.24 ms</td><td>1201000</td><td><strong>324270 ms</strong></font></td>
  </tr>
    <tr>
    <td><strong>非同期処理2</strong></td><td>1.90 ms</td><td>1201000</td><td><strong>2281900 ms</strong></font></td>
  </tr>
    <tr>
    <td><strong>マルチスレッド処理</strong></td><td>548.11 ms</td><td>56290</td><td><strong>30846920 ms</strong></font></td>
  </tr>
</table>

**ダントツで最悪です……。**
ただこれがイコールでパフォーマンス最悪、と見ることもできないとは思います。
あくまで処理時間、であり処理負荷ではありませんから。
特に私のコードは以下のように ```Thread.Sleep()```が恐らく処理時間の大半を占めています。

<!-- open属性なし -->
<details><summary><font color="deepSkyBlue"><strong>マルチスレッドコード(展開)</strong></font></summary>

```C#:MTAsyncAI.cs
    while ( IntervalEndJudge() == false )
    {
      Thread.Sleep(100);
    }
```
</details>

ですから処理時間、という枠を飛び越えて処理負荷を見るにはどうしたらいいのか。
その答えは <strong><font color="Salmon">総フレーム数の多さ = 処理の軽さ</font></strong>という基準です。

というのも、ふと思ったのですがこの検証では全て固定60フレームで30秒間の記録なのに総フレーム数、つまりプロファイラの記録の長さにばらつきがありましたね。
これは**60フレームを維持できなかった瞬間があるせい**です。
本来なら**60(fps) × 30(second) = 1800**となるべきところが、処理落ちした分だけ1800から欠けているのです。

ではこの単純明快な基準で再び比較してみましょう。

<table>
  <caption><strong>総処理時間比較</strong></caption>
  <thead>
    <tr>
      <th>処理種別</th><th>フレーム数</th><th>順位</th>
    </tr>
  </thead>
  <tr>
    <td><strong>同期処理</strong></td><td>1656</td><td><strong>2位</strong></td>
  </tr>
  <tr>
    <td><strong>非同期処理1</strong></td><td>1588</td><td><strong>3位</strong></td>
  </tr>
    <tr>
    <td><strong>非同期処理2</strong></td><td>1573</td><td><strong>4位</strong></td>
  </tr>
    <tr>
    <td><strong>マルチスレッド処理</strong></td><td>1668</td><td><strong>1位</strong></td>
  </tr>
</table>

<strong><font color="red">おおおおお、大逆転！！！</font></strong>
**マルチスレッド、同期、非同期2、非同期1の順になってしまいました！！！**

それではこれで検証を終わります。
次は分析の時間です。

# 分析
ここからはなぜこのような結果になったのか、ということを分析していきたいと思います。
まずマルチスレッド処理が一位になった理由ですが、これはなんとなく分かります。
```Thread.Sleep(100)```により処理回数が大幅に減ったことです。

|<font color="red"> **Sleepの長さによる処理負荷の変化** </font>|
|:-:|
|<strong>Sleep(1000)</strong>：<strong><font color="Deepskyblue"> 1687 フレーム</font></strong>|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/89a66a54-b508-46a8-be97-7c0df4e3543b.png)|
|<strong>Sleep(10)</strong>：<strong><font color="Deepskyblue"> 1654 フレーム</font></strong>|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/ca228aac-cb7c-4359-ac5c-b0ff91a23974.png)|
|<strong>Sleep(1)</strong>：<strong><font color="Deepskyblue"> 1649 フレーム</font></strong>|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/b6c229e7-63e7-4b3e-b760-dae4d71c8667.png)|

試してみた結果、確かにSleepの長さとパフォーマンスには相関関係がありそうです。
しかし ```Sleep(1)```でも非同期処理よりは軽いのを見ると、単純に時間待ちをスレッドプールに分散したことが処理負荷を改善させていそうですね。
ついでに**Sleep(1000)にまで判断間隔を広げるとかなり誤差が出ることも分かりました。**

次になぜ非同期処理が重かったのかを確認します。
これを知るためにはUnitaskの実装を見る必要がありそうです。
そして、また省略してしまい申し訳ないのですが、UniTaskの中身は以下のようになっているようです。

**結果を待ち受ける内容をMonoBehaiver.Updateに登録し、毎フレーム結果を確認する。**
<strong><font color="Salmon">つまり、やっていることが同期処理と同じなのです。</font></strong>

**非同期処理で時間待ち処理をすることは、Updateで毎フレーム時間を確認する処理に、Task関連の余計な負荷を追加しただけだと言えます。**

これでは遅くなって当然ですね。
というか、Updateで毎フレーム時間の経過を確認するような処理もある意味では非同期処理と言えないこともない気がしてきました……。
何故なら、時間が経つのを待っていませんからね。

# 終わりに

この検証を経て、私の非同期/マルチスレッド処理への理解は以下のように変わりました。

<dl>
  <dt><font size="4"><strong>非同期処理</strong></font></dt>
  <dd>時間がかかる処理をするとき、その処理の完了を待たないのが非同期処理。</dd>
  <dd>しかし、<strong><font color="Salmon">cpuがいい感じに実行して処理の完了時を通知してくれる、なんてことはない。</font></strong></dd>
  <dd>どこかのタイミングで処理の進行をチェックしなければならず、Unitaskの場合はメインループで毎フレーム確認している。</dd>
  <dd><strong>単純に、ある処理の完了を待たずに実行しつつ、その進行を把握したい！という時に使うのが基本。</strong></dd>
  <dd> </dd>
  <dd>以下おまけメモ</dd>
  <dd>ステートマシン→コンパイラによってメソッドを状態管理可能な形に変換されたもの。</dd>
  <dd>AsyncMethodBuilder→Asyncメソッドの返り値に準じて選択される、ステートマシンを管理するための構造体。ステートマシンを操作し、その状態をTask型のオブジェクトに反映する。</dd>
  <dd>Taskオブジェクト→結果や状態を格納するオブジェクト。</dd>
  <dt><font size="4"><strong>マルチスレッド処理</strong></font></dt>
  <dd>ある処理をメインスレッド以外で実行することでCPUをフル稼働！</dd>
  <dd>また、<strong><font color="Salmon">cpuがいい感じのタイミングでたまにスレッドを切り替えて実行する。（本当にこんな感じっぽい）</font></strong></dd>
  <dd>なのでメインループで毎フレーム繰り返すより負荷が軽い。</dd>
  <dd>しかしスレッドは別のスレッドの処理と同期しないため、マルチスレッド処理が誤差やバグの原因にもなりうる。</dd>
</dl>

それでは、今回はこのくらいで終わります。
感想としては、非同期処理への認識が特に大きく変わりましたね。
次の記事を書くなら、ちょうど気になっているJobシステムでも実験してみたいです。
もし何かいいネタあれば教えてください。

ここまで読んでくださりありがとうございました。
書いたコードや考察に関して、ご指摘があればどんどん教えていただけると嬉しいです！！！

**追記**：Profile Analyzerで比較用のCompareモードを使用していないのはMean（平均）が見えなくなってしまうからです。ご不明に思われた方いらっしゃいましたら申し訳ありません。

# 非同期処理に関する参考文献
- [Unityの機能をawait可能にしてasync / awaitについて学ぶ](https://zenn.dev/meson/articles/implement-awaiter-for-unity)
- [非同期メソッド入門 (7) - 内部実装を覗く](https://zenn.dev/meson/articles/implement-awaiter-for-unity)

# おまけ！　～cpuがいい感じのタイミングでたまにスレッドを切り替えて実行する とは？～

私はこの記事を投稿した時、**マルチスレッド処理はcpuがいい感じのタイミングでたまにスレッドを切り替えて実行する**とのたまいました。
しかしこれは、実際にいどのくらい呼ばれているのかを確認する必要があると思います。

そこで以下のようなコードを書いてマルチスレッド処理の実行回数を記録することにしました。

<!-- open属性なし -->
<details><summary><font color="deepSkyBlue"><strong>マルチスレッド処理の回数確認コード(展開)</strong></font></summary>

```C#

   // 別スレッドで処理を行う。
   await UniTask.SwitchToThreadPool();

    // ループ一回ごとに long型のmtCounterを加算していく。
    while ( IntervalEndJudge() == false )
    {
      mtCounter++;
    }

   // メインスレッドに戻す
   await UniTask.SwitchToMainThread();
```
</details>

さらにUpdateループの回数も数えて、フレームのループとマルチスレッド処理のループの比率を求めてみます。

<!-- open属性なし -->
<details><summary><font color="deepSkyBlue"><strong>マルチスレッドとUpdateの比率確認コード(展開)</strong></font></summary>

```C#:.cs
    private void Update()
    {
        frameCounter++;
        Debug.Log($"マルチスレッドは{mtCounter}回、フレームは{frameCounter}フレーム");
        Debug.Log($"マルチスレッドはUpdateの約{mtCounter / frameCounter}倍");
    }
```
</details>

そしてその結果がこれです。
オブジェクトは一つで、60秒実行しました。

|<font color="red"> **Updateループと非同期処理処理の実行頻度** </font>|
|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3970709/4569d24e-64b8-45a1-a68a-5f2a2354b154.png)|

すごい差ですね。
これは想像よりだいぶ多いです。
こうして実際に確かめてわかりましたが、**マルチスレッド処理はcpuがいい感じのタイミングでたまにスレッドを切り替えて実行する**というのは正確な表現ではなさそうです。
Updateループなどと違い、フレームに縛られることなくずっと実行されていますねこれは。
ゲーム以外の通常のコンソールアプリ、あとは趣味の競技プログラミングでいつも実行しているコードに近い挙動です。

マルチスレッド処理はいつ実行されているの？　という疑問の答えは**ずっと実行されている**で良さそうです。
すごく小さな単位の時間の中で、cpuがいい感じのタイミングでスレッドを切り替えてはいるのでしょうが。