-100p

-10p

+10p

+100p

TaskとCoroutineの比較

C# TaskよりUnity Coroutineをお勧めする理由。中断処理がとても面倒なTask。

関連ページ
Unityを使ってる人でコルーチンを知らない人はいないと思う。非同期処理を扱うのにとても便利な機能。
一方で2010年に公開された.NET Framework 4から、C#そのものに非同期処理を扱うTaskというコンポーネントが追加された。

Taskが公開された直後は、もうCoroutineは古いと言ってTaskを盛んに持て囃すのが流行った。
たしかにTaskにはCoroutineには無い良い部分があるものの、両方使ってみた感想としてはCoroutineの方が使いやすいというのが自分の結論。
理由は中断処理にある。

シンプルなコードで比較

最初に、全く同じ実装をCoroutineとTaskで行い、両者の比較をしていく。
実装する内容は、右のボタンを押すと星が5秒かけて右端に移動し、右端に到達すると星が点滅する。
左のボタンを押すと星が3秒かけて左端に移動し、左端に到達すると星が点滅する。
星の移動中は、上のテキストに経過時間が表示される。


補足として今回は、星の移動処理に関してのみTaskでもCoroutineでもなく Tween を使っていく。(便利なので)

Coroutineを使った非同期処理

using DG.Tweening;
using UnityEngine;
using UnityEngine.UI;
using System.Collections;

/// <summary>

/// Coroutineを使ったアニメーション実装

/// </summary>

public class TestCoroutine : MonoBehaviour
{
    [SerializeField]
    private Button rightButton;
    [SerializeField]
    private Button leftButton;
    [SerializeField]
    private Text infoText;
    [SerializeField]
    private Transform star;

    private float rightSlideSec = 5f;
    private float leftSlideSec = 3f;
    private float rightEndPosX = 500;
    private float leftEndPosX = -500;
    private int flashCount = 3;
    private float flashInterval = 0.2f;

    private void Start()
    {
        
//ボタンイベントをスクリプトから登録

        rightButton.onClick.AddListener(OnTapRightButton);
        leftButton.onClick.AddListener(OnTapLeftButton);
    }

    
/// <summary>

    
/// 右のボタンを押した時の処理

    
/// </summary>

    private void OnTapRightButton()
    {
        StartCoroutine(CommonSlideAction(rightEndPosX, rightSlideSec));
    }

    
/// <summary>

    
/// 左のボタンを押した時の処理

    
/// </summary>

    private void OnTapLeftButton()
    {
        StartCoroutine(CommonSlideAction(leftEndPosX, leftSlideSec));
    }

    
/// <summary>

    
/// アニメーションの共通処理

    
/// </summary>

    private IEnumerator CommonSlideAction(float endPosX, float duration)
    {
        infoText.text = "経過時間0秒";

        
//星をTweenを使って移動させ、1秒置きにテキストを更新する

        star.DOLocalMoveX(endPosX, duration).SetEase(Ease.Linear);
        var remainTime = duration;
        while (remainTime > 0)
        {
            yield return new WaitForSeconds(1);
            remainTime--;
            infoText.text = "経過時間" + (duration - remainTime) + "秒";
        }

        
//星が目的地に着いたら点滅させる

        for(int i = 0; i < flashCount * 2; i++)
        {
            
//偶数かどうか判定

            var isEven = i % 2 == 0;
            
//偶数なら星を非表示、奇数なら表示

            star.gameObject.SetActive(isEven ? false : true);
            yield return new WaitForSeconds(flashInterval);
        }

        infoText.text = "やりましたね!!";
    }
}
返り値
 IEnumerator 
で定義された関数を、
 StartCoroutine 
関数で読んで非同期処理を行っている。
実際の待機処理のコードは
 yield return new WaitForSeconds 
の部分。

Taskを使った非同期処理

using DG.Tweening;
using UnityEngine;
using UnityEngine.UI;
using System.Threading.Tasks;

/// <summary>

/// Taskを使ったアニメーション実装

/// </summary>

public class TestTask : MonoBehaviour
{
    [SerializeField]
    private Button rightButton;
    [SerializeField]
    private Button leftButton;
    [SerializeField]
    private Text infoText;
    [SerializeField]
    private Transform star;

    private float rightSlideSec = 5f;
    private float leftSlideSec = 3f;
    private float rightEndPosX = 500;
    private float leftEndPosX = -500;
    private int flashCount = 3;
    private float flashInterval = 0.2f;

    private void Start()
    {
        
//ボタンイベントをスクリプトから登録

        rightButton.onClick.AddListener(OnTapRightButton);
        leftButton.onClick.AddListener(OnTapLeftButton);
    }

    
/// <summary>

    
/// 右のボタンを押した時の処理

    
/// </summary>

    private void OnTapRightButton()
    {
        CommonSlideAction(rightEndPosX, rightSlideSec);
    }

    
/// <summary>

    
/// 左のボタンを押した時の処理

    
/// </summary>

    private void OnTapLeftButton()
    {
        CommonSlideAction(leftEndPosX, leftSlideSec);
    }

    
/// <summary>

    
/// アニメーションの共通処理

    
/// </summary>

    private async Task CommonSlideAction(float endPosX, float duration)
    {
        infoText.text = "経過時間0秒";

        
//星をTweenを使って移動させ、1秒置きにテキストを更新する

        star.DOLocalMoveX(endPosX, duration).SetEase(Ease.Linear);
        var remainTime = duration;
        while (remainTime > 0)
        {
            
//Task.Delayはミリ秒で管理するので、1000を渡すと1秒待機になる

            await Task.Delay(1000);
            remainTime--;
            infoText.text = "経過時間" + (duration - remainTime) + "秒";
        }

        
//星が目的地に着いたら点滅させる

        for(int i = 0; i < flashCount * 2; i++)
        {
            
//偶数かどうか判定

            var isEven = i % 2 == 0;
            
//偶数なら星を非表示、奇数なら表示

            star.gameObject.SetActive(isEven ? false : true);
            
//Task.Delayにはintしか渡せない。0.2*1000で200ミリ秒、つまり0.2秒待機

            await Task.Delay(Mathf.FloorToInt(flashInterval * 1000));
        }

        infoText.text = "やりましたね!!";
    }
}
Coroutineの
 yeild return new WaitForSeconds 
に相当するのが
 await Task.Delay 

ただしTaskはミリ秒が基本なので、1秒待ちたい場合はawait Task.Delay(1000)と記述する。

関数を宣言するとき、Coroutineは返り値に
 IEnumerator 
を指定するが、Taskは
 async Task 
と入力する。
またその関数を呼ぶ時にStartCoroutineと入力する必要がなく、通常の関数と呼び出し方法が変わらない。
IEnumeratorの関数内ではyield return ~の待機処理コードが無いとエラーが出るが、aync Taskでは待機処理コードが無くても別に問題ない。

以上の細かい違いがあるが、このレベルの実装では正直どっちでもいい。

中断処理から比較

さて、もし右のボタンを押した後、その処理が完了する前に左のボタンを押すと下の動画のようになる。

右にスライドする処理と左にスライドする処理が同時に走っている。
しかも右スライドの方がアニメーション時間が長いので、左のボタンを右ボタンの後に押しても最終的な星の座標は右端になっている。

こうした不自然で予期できない挙動を阻止するため、ゲーム実装では頻繁に中断処理を差し込むことになる。
修正した結果は次の通り。

Coroutineの中断処理

using DG.Tweening;
using UnityEngine;
using UnityEngine.UI;
using System.Collections;

/// <summary>

/// Coroutineを使ったアニメーション実装

/// </summary>

public class TestCoroutine : MonoBehaviour
{
    [SerializeField]
    private Button rightButton;
    [SerializeField]
    private Button leftButton;
    [SerializeField]
    private Text infoText;
    [SerializeField]
    private Transform star;

    private float rightSlideSec = 5f;
    private float leftSlideSec = 3f;
    private float rightEndPosX = 500;
    private float leftEndPosX = -500;
    private int flashCount = 3;
    private float flashInterval = 0.2f;

    
//中断処理のための変数を追加

    private Coroutine slideCoroutine;
    private Tweener slideTweener;

    private void Start()
    {
        
//ボタンイベントをスクリプトから登録

        rightButton.onClick.AddListener(OnTapRightButton);
        leftButton.onClick.AddListener(OnTapLeftButton);
    }

    
/// <summary>

    
/// 右のボタンを押した時の処理

    
/// </summary>

    private void OnTapRightButton()
    {
        
//実行中のコルーチンがある場合中断

        if (slideCoroutine != null)
        {
            StopCoroutine(slideCoroutine);
            slideTweener.Kill();
        }
        slideCoroutine = StartCoroutine(CommonSlideAction(rightEndPosX, rightSlideSec));
    }

    
/// <summary>

    
/// 左のボタンを押した時の処理

    
/// </summary>

    private void OnTapLeftButton()
    {
        
//実行中のコルーチンがある場合中断

        if (slideCoroutine != null)
        {
            StopCoroutine(slideCoroutine);
            slideTweener.Kill();
        }
        slideCoroutine = StartCoroutine(CommonSlideAction(leftEndPosX, leftSlideSec));
    }

    
/// <summary>

    
/// アニメーションの共通処理

    
/// </summary>

    private IEnumerator CommonSlideAction(float endPosX, float duration)
    {
        
//中断を実行したタイミングによっては後半の点滅で

        
//star.gameOjbectが消えてる可能性があるのでtrueにする

        star.gameObject.SetActive(true);

        infoText.text = "経過時間0秒";

        
//星をTweenを使って移動させ、1秒置きにテキストを更新する

        
//中断処理のため実行したTweenは変数に保存

        slideTweener = star.DOLocalMoveX(endPosX, duration).SetEase(Ease.Linear);
        var remainTime = duration;
        while (remainTime > 0)
        {
            yield return new WaitForSeconds(1);
            remainTime--;
            infoText.text = "経過時間" + (duration - remainTime) + "秒";
        }

        
//星が目的地に着いたら点滅させる

        for(int i = 0; i < flashCount * 2; i++)
        {
            
//偶数かどうか判定

            var isEven = i % 2 == 0;
            
//偶数なら星を非表示、奇数なら表示

            star.gameObject.SetActive(isEven ? false : true);
            yield return new WaitForSeconds(flashInterval);
        }

        infoText.text = "やりましたね!!";
    }
}
 private Coroutine slideCoroutine; 
、この変数に実行するCoroutineをあらかじめ保存する。
次にボタンを押す際にはまず実行中のコルーチンを停止させる。

また星の移動にはTweenを使っているが、TweenはCoroutineとは関係ない独立したスレッドで動いている。
なので
 private Tweener slideTweener; 
で実行するTweenを保存させている。Coroutineの停止と同じタイミングで停止を呼ぶ。

Taskの中断処理

using DG.Tweening;
using UnityEngine;
using UnityEngine.UI;
using System.Threading.Tasks;
using System.Threading;

/// <summary>

/// Taskを使ったアニメーション実装

/// </summary>

public class TestTask : MonoBehaviour
{
    [SerializeField]
    private Button rightButton;
    [SerializeField]
    private Button leftButton;
    [SerializeField]
    private Text infoText;
    [SerializeField]
    private Transform star;

    private float rightSlideSec = 5f;
    private float leftSlideSec = 3f;
    private float rightEndPosX = 500;
    private float leftEndPosX = -500;
    private int flashCount = 3;
    private float flashInterval = 0.2f;

    
//中断処理のための変数を追加

    private CancellationTokenSource slideTokenSource;
    private Tweener slideTweener;

    private void Start()
    {
        
//ボタンイベントをスクリプトから登録

        rightButton.onClick.AddListener(OnTapRightButton);
        leftButton.onClick.AddListener(OnTapLeftButton);
    }

    
/// <summary>

    
/// 右のボタンを押した時の処理

    
/// </summary>

    private void OnTapRightButton()
    {
        
//実行中のタスクがある場合中断

        if (slideTokenSource != null)
        {
            slideTokenSource.Cancel();
            slideTweener.Kill();
        }
        slideTokenSource = new CancellationTokenSource();
        CommonSlideAction(rightEndPosX, rightSlideSec, slideTokenSource.Token);
    }

    
/// <summary>

    
/// 左のボタンを押した時の処理

    
/// </summary>

    private void OnTapLeftButton()
    {
        
//実行中のタスクがある場合中断

        if (slideTokenSource != null)
        {
            slideTokenSource.Cancel();
            slideTweener.Kill();
        }
        slideTokenSource = new CancellationTokenSource();
        CommonSlideAction(leftEndPosX, leftSlideSec, slideTokenSource.Token);
    }

    
/// <summary>

    
/// アニメーションの共通処理

    
/// </summary>

    private async Task CommonSlideAction(float endPosX, float duration, CancellationToken token)
    {
        
//中断を実行したタイミングによっては後半の点滅で

        
//star.gameOjbectが消えてる可能性があるのでtrueにする

        star.gameObject.SetActive(true);

        infoText.text = "経過時間0秒";

        
//星をTweenを使って移動させ、1秒置きにテキストを更新する

        
//中断処理のため実行したTweenは変数に保存

        slideTweener = star.DOLocalMoveX(endPosX, duration).SetEase(Ease.Linear);
        var remainTime = duration;
        while (remainTime > 0)
        {
            
//Task.Delayはミリ秒で管理するので、1000を渡すと1秒待機になる

            await Task.Delay(1000);
            
//タスクの中断の命令が出ていればreturn

            if(token.IsCancellationRequested)
            {
                return;
            }
            remainTime--;
            infoText.text = "経過時間" + (duration - remainTime) + "秒";
        }

        
//星が目的地に着いたら点滅させる

        for(int i = 0; i < flashCount * 2; i++)
        {
            
//偶数かどうか判定

            var isEven = i % 2 == 0;
            
//偶数なら星を非表示、奇数なら表示

            star.gameObject.SetActive(isEven ? false : true);
            
//Task.Delayにはintしか渡せない。0.2*1000で200ミリ秒、つまり0.2秒待機

            await Task.Delay(Mathf.FloorToInt(flashInterval * 1000));
            
//タスクの中断の命令が出ていればreturn

            if(token.IsCancellationRequested)
            {
                return;
            }
        }

        infoText.text = "やりましたね!!";
    }
}
Taskの中断処理では
 StopCoroutine 
ではなく
 CancellationTokenSource 
 CancellationToken 
を使う。
これが直感的でなく非常に分かりずらい。
中断命令はCancellationTokenSourceで行うが、中断命令が出されたかどうかのフラグはCancellationTokenを見る必要がある。

Taskの中断処理は、StopCoroutineのようにCoroutine関数の外部で行うのではなく、Task関数の内部で行う必要がある。
下が実際の中断処理のコード。
            if(token.IsCancellationRequested)
            {
                return;
            }
しかもTask関数の内部の全てのawait Task.Delayの後ろに中断処理を書き込む必要がある。
これが厄介で、長いアニメーションでは頻繁にTask.Delayを行うので、無意味に冗長で汚いコードになる。抜けも起きやすい。

Taskに関する不満はこれ一点で、他は素晴らしいところもある。
例えば返り値を受け取ることができたり、最近のTweenではAsyncWaitForCompletionというTaskに変換する機能があって便利。
ただこの中断処理一点の不満があまりに大きすぎる。
0
0

-100p

-10p

+10p

+100p