17
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

[C#,Unity]asyncとcancelとfinallyの話

Posted at

はじめに

async/awaitを使用すると簡単に非同期メソッドを書くことができます。
非同期メソッドの中でも同期メソッドのようにtry/finallyが使用できますが少し注意が必要な点があります。
今回はその点について解説していきます。
以降のサンプルではUnityとUniTaskを使用します。

finallyで処理が実行されるタイミング

処理の後に必ず実行して欲しい処理がある場合、以下のように書くことができます。

async UniTask HogeAsync(CancellationToken cancellationToken)
{
    try
    {
        Debug.Log("Start");
        // 1秒待機
        await UniTask.Delay(1000, cancellationToken: cancellationToken);
        Debug.Log("Finish");
    }
    finally
    {
        // 必ず実行して欲しい処理
        Debug.Log("Finally");
    }
}

これは同期処理と同じなので特に迷うことはないと思います。
finally句の処理はキャンセルされたときでも実行されます。

void Start()
{
    var cts = new CancellationTokenSource();
    // 処理を実行
    HogeAsync(cts.Token).Forget();
    // すぐにキャンセル
    cts.Cancel();
}

image.png

ここで注意が必要なのは finallyの処理が実行されるタイミング です。
Cancelメソッドを呼んだ時にfinallyの処理が実行されそうな気がしますが実際には実行されません。
これはCancelの後にDebug.Logを書けばすぐにわかります。


void Start()
{
    var cts = new CancellationTokenSource();
    // 処理を実行
    HogeAsync(cts.Token).Forget();
    // すぐにキャンセル
    cts.Cancel();
    Debug.Log("After Cancel");
}

image.png

After CancelStartFinally の間に挟まっていることから Cancelメソッドを呼んだタイミングではfinallyの処理が実行されてないことが分かります。
では一体どこで実行されているのか。
その答えはスタックトレースを見ればわかります。

Finally
UnityEngine.Debug:Log (object)
Hoge/<HogeAsync>d__5:MoveNext () (at Assets/Hoge.cs:69)
Cysharp.Threading.Tasks.CompilerServices.AsyncUniTask`1<Hoge/<Hoge2Async>d__5>:Run () (at Assets/Plugins/UniTask/Runtime/CompilerServices/StateMachineRunner.cs:189)
Cysharp.Threading.Tasks.AwaiterActions:Continuation (object) (at Assets/Plugins/UniTask/Runtime/UniTask.cs:21)
Cysharp.Threading.Tasks.UniTaskCompletionSourceCore`1<object>:TrySetCanceled (System.Threading.CancellationToken) (at Assets/Plugins/UniTask/Runtime/UniTaskCompletionSource.cs:186)
Cysharp.Threading.Tasks.UniTask/DelayPromise:MoveNext () (at Assets/Plugins/UniTask/Runtime/UniTask.Delay.cs:549)
Cysharp.Threading.Tasks.Internal.PlayerLoopRunner:RunCore () (at Assets/Plugins/UniTask/Runtime/Internal/PlayerLoopRunner.cs:176)
Cysharp.Threading.Tasks.Internal.PlayerLoopRunner:Update () (at Assets/Plugins/UniTask/Runtime/Internal/PlayerLoopRunner.cs:146)
Cysharp.Threading.Tasks.Internal.PlayerLoopRunner:Run () (at Assets/Plugins/UniTask/Runtime/Internal/PlayerLoopRunner.cs:105)

PlayerLoopRunner.Runの中で最終的にfinallyの処理が呼ばれています。
詳細な処理を知る必要はありませんが大事なのは いつ処理が実行されるかはawait対象やその他の要因に依存する ということです。
今回のケースではCancelした後に別のタイミングで実行されましたが逆にCancel内で処理が実行される場合もあります。
どちらにせよCancelしたらすぐにfinallyの処理が実行されると思っていると思わぬバグの元になります。

Cancel内で処理が実行されるケース
async UniTask HogeAsync(CancellationToken cancellationToken)
{
    try
    {
        Debug.Log("Start");
        // 1秒待機
        // await UniTask.Delay(1000, cancellationToken: cancellationToken);
        // Delayの引数にCancellationTokenを渡さずにAttachExternalCancellationを使用する
        await UniTask.Delay(1000).AttachExternalCancellation(cancellationToken);
        Debug.Log("Finish");
    }
    finally
    {
        // 必ず実行して欲しい処理
        Debug.Log("Finally");
    }
}

実行する側のコードは同じでも以下の結果になります。

image.png

Start -> Finally -> After Cancel の順になっていることが分かります。
スタックトレースを見るとCancelの内部でfinallyの処理が行われていることが明確です。

Finally
UnityEngine.Debug:Log (object)
Hoge/<HogeAsync>d__5:MoveNext () (at Assets/Hoge.cs:70)
Cysharp.Threading.Tasks.CompilerServices.AsyncUniTask`1<Hoge/<HogeAsync>d__5>:Run () (at Assets/Plugins/UniTask/Runtime/CompilerServices/StateMachineRunner.cs:189)
Cysharp.Threading.Tasks.AwaiterActions:Continuation (object) (at Assets/Plugins/UniTask/Runtime/UniTask.cs:21)
Cysharp.Threading.Tasks.UniTaskCompletionSourceCore`1<Cysharp.Threading.Tasks.AsyncUnit>:TrySetCanceled (System.Threading.CancellationToken) (at Assets/Plugins/UniTask/Runtime/UniTaskCompletionSource.cs:186)
Cysharp.Threading.Tasks.UniTaskExtensions/AttachExternalCancellationSource:CancellationCallback (object) (at Assets/Plugins/UniTask/Runtime/UniTaskExtensions.cs:273)
System.Threading.CancellationTokenSource:Cancel ()
Hoge:Start () (at Assets/Hoge.cs:82)

問題が発生するケース

以下のようなコードを書いたとき問題が発生します。

[SerializeField]
GameObject nowLoadingObject;
[SerializeField]
RawImage image;
[SerializeField]
Button loadButton;

// 画像をWebから取得して表示する
// 取得中はロード中を示すオブジェクトを表示する
private async UniTask LoadAsync(string url, CancellationToken cancellationToken)
{
    try
    {
        // ロード中を表すオブジェクトを表示する
        nowLoadingObject.SetActive(true);
        // ロード中は画像オブジェクトを非表示にする
        image.gameObject.SetActive(false);
        // Webから画像を取得して表示
        image.texture = await FetchAsync(url, cancellationToken);
        // 画像オブジェクトを表示にする
        image.gameObject.SetActive(true);
    }
    finally
    {
        // ロードが終わったら非表示にする
        if (nowLoadingObject) nowLoadingObject.SetActive(false);
    }
}

void Start()
{
    const string imageUrl = "...";
    CancellationTokenSource cts = default;
    loadButton.OnClickAsObservable()
        .Subscribe(_ =>
        {
            // 前回のロードをキャンセルする
            cts?.Cancel();
           
            cts = new CancellationTokenSource();
            LoadAsync(imageUrl, cts.Token).Forget();
        })
        .AddTo(this);
}

上記のコードで期待しているのはロード中はロード中の表示を行い終了したらロード中の表示を消すことです。

load.gif

このコードでは前回のロードをキャンセルして新しいロードを始めたときにロード中を表すオブジェクトが表示されない可能性があります。

load2.gif

これは1回目の非表示処理が2回目のロードが始まった後に発生してしまうためです。
このようにCancel後にすぐにfinallyの処理が実行されることを期待してしまうとバグの原因になってしまいます。

対策

この問題を解決するためにはいくつかの案が考えられます。

ロード回数をカウントする

ロードするたびにカウンターを+1、ロードが終わるたびにカウンターを-1します。
カウンターが0のときのみロード中を表すオブジェクトを非表示にすれば問題は発生しません。

private int loadCount;

private async UniTask LoadAsync(string url, CancellationToken cancellationToken)
{
    try
    {
        // ロードの度にカウンターを+1
        loadCount++;
        nowLoadingObject.SetActive(true);
        image.gameObject.SetActive(false);
        image.texture = await FetchAsync(url, cancellationToken);
        image.gameObject.SetActive(true);
    }
    finally
    {
        // ロードが終わるたびにカウンターを-1
        loadCount--;
        // 0になったとき == すべてのロードが終了したとき
        if (loadCount == 0)
        {
            if (nowLoadingObject) nowLoadingObject.SetActive(false);
        }
    }
}

実行毎にバージョンをつける

これは説明するよりコードを見たほうが分かりやすいです。

private int version;

private async UniTask LoadAsync(string url, CancellationToken cancellationToken)
{
    // 処理のバージョンを付ける
    version = unchecked(version + 1);
    var _version = version;
    try
    {

        nowLoadingObject.SetActive(true);
        image.gameObject.SetActive(false);
        image.texture = await FetchAsync(url, cancellationToken);
        image.gameObject.SetActive(true);
    }
    finally
    {
        // 処理のバージョンと現在のバージョンが同じ場合だけ非表示にする
        if (_version == version)
        {
            if (nowLoadingObject) nowLoadingObject.SetActive(false);
        }
    }
}

前回の実行が終了するまで待つ

確実に前回のロード処理が終わってから処理を実行するようにします。

private bool isLoading;

private async UniTask LoadAsync(string url, CancellationToken cancellationToken)
{
    // 他のロード処理が実行中は待つ
    await UniTask.WaitUntil(() => !isLoading, cancellationToken: cancellationToken);
    try
    {
        // ロード中の場合はフラグを立てておく
        isLoading = true;
        nowLoadingObject.SetActive(true);
        image.gameObject.SetActive(false);
        image.texture = await FetchAsync(url, cancellationToken);
        image.gameObject.SetActive(true);
    }
    finally
    {
        // ロードが終了したらフラグをおろす
        isLoading = false;
        if (nowLoadingObject) nowLoadingObject.SetActive(false);
    }
}

そもそもキャンセルしない/ロード中は無視する/許容する

処理を必ずしも完全にキャンセル可能にする必要はありません。
ロード中はユーザーの操作によって再度ロードすることができないケース、ロード中にボタンを押しても何も反応しなくするケース、一度キャンセルするとそれ以降はその処理を実行することがないケースなどは何も対策せず許容してもいいと思います。

void Start()
{
    const string imageUrl = "...";

    // ケース1
    // 画面全体にNowLoadingObjectが表示されるのでボタンが押せなくなる
    loadButton.OnClickAsObservable()
        .Subscribe(_ =>
        {
            LoadAsync(imageUrl, this.GetCancellationTokenOnDestroy()).Forget();
        })
        .AddTo(this);

    // ケース2
    // ロード中はボタン押されても無視する
    loadButton.OnClickAsObservable()
        .Where(_ => !isLoading)
        .Subscribe(_ =>
        {
            LoadAsync(imageUrl, this.GetCancellationTokenOnDestroy()).Forget();
        })
        .AddTo(this);

    // ケース3
    // シーン開始時に一度だけロードする
    // シーンのアンロード時に処理が続くと嫌なのでキャンセルされるようにしておく
    LoadAsync(imageUrl, this.GetCancellationTokenOnDestroy()).Forget();
}

おわりに

分かっていればそれだけの問題ですが意外とめんどくさい問題なので記事にしてみました。
他にもこのように解決できる、こうした方がいいなどがあればぜひコメントしてください。

17
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?