13
12

More than 3 years have passed since last update.

Unity2020.2 で 非同期ストリーム(IAsyncEnumerable)が使いたい!

Last updated at Posted at 2020-12-16

Unity 2020.2

Unity2020.2でついにC# 8.0がサポートされるようになりました。

C# 8.0にはいろいろ新機能が追加されているのですが、今回取り上げるのは「非同期ストリーム」です。

非同期ストリーム

C# 8.0の目玉機能の1つとして、「非同期ストリーム」があります。
非同期ストリームとはIEnumerableasync/await対応版である、IAsyncEnumerableとその周辺機能を指します。

非同期ストリームが使えるとたとえば次のようなことが簡単に実装できるようになります。

  • foreachの値の列挙にasync/awaitが利用できる(await foreach
  • IEnumerator時にyield returnawaitが併用できる(非同期イテレータ

RxObservableに似てますけど、用途は結構異なります。)

残念だったな!Unity2020.2は.NET Standard 2.0だ!

ではこの非同期ストリームがUnity2020.2でそのまま使えるのかというと、残念ながら使えません。

というのもIAsyncEnumerableなど、C# 8.0で利用する想定のインタフェース群は.NET Standard 2.1準拠のAPIセットとして提供されているからです。
ですがUnity 2020.2では.NET Standard 2.0準拠のAPIにしか対応していません。

そのため事実上、IAsyncEnumerableを含む非同期ストリームを素のUnity2020.2で使うことはできません。

代替としてUniTaskを使おう

非同期ストリームはとても便利なため可能であれば今すぐ使いたいです。
そこで代替として利用可能なライブラリがUniTaskです。

UniTask

UniTaskAsyncEnumerable

UniTaskではIAsyncEnumerable<T>の代替としてIUniTaskAsyncEnumerable<T>を用意してくれています。
(同様にIUniTaskAsyncEnumerator<T>もあります)

これらを用いることでC# 8.0の非同期ストリームと同等のことを実現することができます。
(さらにいうとC# 7.3でもこのUniTaskAsyncEnumerableを利用することができます)

IUniTaskAsyncEnumerableとawait foreach

UniTaskで非同期ストリームを利用する場合は、IUniTaskAsyncEnumerable<T>を用いればOKです。
Unity 2020.2でもC# 8.0await foreachは問題なく利用できるので、これと組み合わせて使いましょう。

// 非同期ストリームをIUniTaskAsyncEnumerableで実現
private async UniTask ForEachSampleAsync(IUniTaskAsyncEnumerable<string> messages, CancellationToken token)
{
    // await foreach は Unity 2020.2 でも利用可能
    await foreach (var value in messages.WithCancellation(token))
    {
        Debug.Log(value);
        await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token);
    }
}

UniTaskで非同期イテレータ

C# 8.0 + .NET Standard 2.1の環境では次のように書くことができます。

// 10秒間カウントアップする
// not Unity 2020.2
async IAsyncEnumerable<int> CreateStreamAsync()
{
    for (int i = 0; i < 10; i++)
    {
        yield return i;
        await Task.Delay(TimeSpan.FromSeconds(1));
    }
}

これをUnity 2020.2 + UniTaskで代替するとこうなります。

IUniTaskAsyncEnumerable<int> CreateStreamAsync()
{
    return UniTaskAsyncEnumerable.Create<int>(async (writer, token) =>
    {
        for (int i = 0; i < 10; i++)
        {
            await writer.YieldAsync(i);
            await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token);
        }
    });
}

イテレータが使えないので代わりにUniTaskAsyncEnumerable.Create<T>を利用してあげれば同等のことができます。

まとめ

C# 8.0の非同期イテレータを待ち望んでいた人は代替としてUniTaskを使うとよいでしょう。

詳しい資料はこちら。

おまけ

ForEachAsync, ForEachAwaitAsync, Subscribe, SubscribeAwait → await foreach

UniTaskAsyncEnumerableC# 8.0未満のC#でも利用できるようにいくつか購読用APIが用意されていました。
これらをawait foreachに書き換える場合はどうしたらいいのかを列挙します。

ForEachAsync

値を同期で消費するパターンのもの。そのまま書くだけです。
むしろForEachAsyncで書いたほうが記述量少ない。

private async UniTask ConvertedAsync(IUniTaskAsyncEnumerable<string> messages, CancellationToken token)
{
    // ForEachAsync
    await messages.ForEachAsync(x => Debug.Log(x), token);

    // await foreach
    await foreach (var x in messages.WithCancellation(token))
    {
        Debug.Log(x);
    }
}

ForEachAwaitAsync

値を非同期でawaitしながら消費するパターンのもの。

CancellationTokenを扱う場合、パフォーマンスを考えるとForEachAwaitWithCancellationAsyncを使ったほうがよいためちょっと長ったらしくなります。
そのためCancellationTokenを使うんであればawait foreachの方が素直に書けます。

private async UniTask ConvertedAsync(IUniTaskAsyncEnumerable<string> messages, CancellationToken token)
{
    // ForEachAwaitAsync + CancellationToken
    await messages.ForEachAwaitWithCancellationAsync(async (x, ct) =>
    {
        Debug.Log(x);
        await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: ct);
    }, token);


    // await foreach
    await foreach (var x in messages.WithCancellation(token))
    {
        Debug.Log(x);
        await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token);
    }
}

Subscribe

SubscribeIUniTaskAsyncEnumerable<T>を同期メソッドの文脈上で消費することができます(async/awaitの文脈上でなくても消費できる)。

用途がいくつかあるのでそれぞれ紹介します。

Forget目的で使うとき

値を非同期でForgetしながら消費するパターンのもの。
こちらはCancellationTokenを含んでいてもSubscribeの方が記述量は少ないです。

private async UniTask ConvertedAsync(IUniTaskAsyncEnumerable<string> messages, CancellationToken token)
{
    // Subscribe
    messages.Subscribe(async (x, ct) =>
    {
        Debug.Log(x);
        await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: ct);
    }, token);


    // await foreach
    await foreach (var x in messages.WithCancellation(token))
    {
        // ここが UniTask.Void になる
        UniTask.Void(async ct =>
        {
            Debug.Log(x);
            await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: ct);
        }, token);
    }
}

IObservable.Subscribeライクに使うとき

IObservable<T>.Subscribe()のように、async/awaitとは関係がない文脈上でIUniTaskAsyncEnumerable<T>を消費したいときに使い方です。
OnErrorOnCompletedを使いたい場合)

こっちは明らかにSubscribeで書いたほうがキレイになります。

// ↓ asyncメソッドではない!
private void ConvertedAsync(IUniTaskAsyncEnumerable<string> messages, CancellationToken token)
{
    // Subscribe
    messages.Subscribe(
        onNext: async x =>
        {
            Debug.Log(x);
            await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token)
        },
        onCompleted: () => Debug.Log("Done"), cancellationToken: token);


    // await foreach
    {
        UniTask.Void(async token2 =>
        {
            try
            {
                await foreach (var x in messages.WithCancellation(token2))
                {
                    // ここが UniTask.Void になる
                    UniTask.Void(async ct =>
                    {
                        Debug.Log(x);
                        await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: ct);
                    }, token2);
                }

                // OnCompleted 相当
                Debug.Log("Done");
            }
            catch (Exception e) when (!(e is OperationCanceledException))
            {
                // OnError 相当
                Debug.LogError(e);
            }
        }, token);
    }
}

SubscribeAwait

SubscribeAwaitSubscribeForgetではない版です。
ForEachAwaitAsyncに挙動が近いものです(ややこしい)。

こちらもSubscribeAwaitの方が記述量は減ります。

// ↓ asyncメソッドではない!
private void ConvertedAsync(IUniTaskAsyncEnumerable<string> messages, CancellationToken token)
{
    // SubscribeAwait
    messages.SubscribeAwait(
        onNext: async x =>
        {
            Debug.Log(x);
            await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token);
        },
        onCompleted: () => Debug.Log("Done"), cancellationToken: token);


    // await foreach
    {
        UniTask.Void(async token2 =>
        {
            try
            {
                await foreach (var x in messages.WithCancellation(token2))
                {
                    Debug.Log(x);

                    // ここをちゃんとawaitし終わってからイテレートするのがSubscribeとの違い
                    await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token2);
                }

                // OnCompleted 相当
                Debug.Log("Done");
            }
            catch (Exception e) when (!(e is OperationCanceledException))
            {
                // OnError 相当
                Debug.LogError(e);
            }
        }, token);
    }
}

いろいろあるけどどれを使えばいいのか

ForEachAsyncForEachAwaitAsyncSubscribeSubscribeAwait、これらは上手く使えば記述量を減らせるメリットがあります。
ですがどれもシグネチャが似ており、挙動をしっかり把握してから使わないと事故を起こすリスクがあります。
(あとクロージャを生成するので書き方によってはアロケートが発生したりする可能性あり)

一方のawait foreachは手続き的に書けるため挙動は把握しやすいですが、その分記述量が増えてしまします。
ただ挙動のわかりやすさとしてはawait foreachなので、困ったときはawait foreachで書いておけばいいでしょう。

13
12
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
13
12