はなちるのマイノート

Unityをメインとした技術ブログ。自分らしくまったりやっていきたいと思いますー!

【C#, Unity】C#7.0から導入された「ValueTask<TResult>」を利用してヒープアロケーションを減らしたかった(実験付き)

はじめに

今回はC#7.0より導入されたValueTask<TResult>について取り上げたいと思います。
learn.microsoft.com

まずValueTask<TResult>の説明の前に、Task<TResult>を利用した非同期処理には以下のような問題点が指摘されていました。

  • 非同期メソッドでも同期処理になる場合が多い
  • Taskclassなのでnewの度にManaged HeapAllocationが発生する
  • 同期処理の時はTaskを生成しないようにしたい(完了済みのTaskが生成されてしまう)

これらを解決するために登場したのがValueTask<TResult>で、以下のような対応をしています。

  • ValueTask<TResult>は構造体(値型)
  • TResultもしくはTask<TResult>を格納する(Task<TResult>nullならTResultを返す)

こうすることで同期処理の場合はTask<TResult>を生成せず、Managed Heapの無駄なAllocationが発生しなくできたというわけですね。

使い方

基本Task<TResult>と変わりません。(私が知らないだけの可能性大)

private static async Task<int> SampleTaskAsync(int value)
{
    if (value == 1000)
    {
        await Task.Delay(1);
        return 0;
    }
        
    // 完了済みのTaskのインスタンスが作られる
    return 1;
}

private static async ValueTask<int> SampleValueTaskAsync(int value)
{
    if (value == 1000)
    {
        await Task.Delay(1);
        return 0;
    }
        
    // Taskのインスタンスは生成されない
    return 1;
}

ただし同期処理が全く行われない場合は、むしろValueTask<TResult>を利用すると無駄なメモリが確保されてしまいます。注意してください。

実験

Unityを利用するとヒープアロケーションを可視化できる(Profilerという機能でGC Allocatoinが調べられる)ので、Unityを活用して実験してみます。

public class Test2 : MonoBehaviour
{
    private void Update()
    {
        for (var i = 0; i < 1000; i++)
        {
            SampleTaskAsync(0).Wait();
        }
    }

    private static async Task<int> SampleTaskAsync(int value)
    {
        if (value == 1000)
        {
            await Task.Delay(1);
            return 0;
        }

        // 完了済みのTaskのインスタンスが作られる
        return 1;
    }

    private static async ValueTask<int> SampleValueTaskAsync(int value)
    {
        if (value == 1000)
        {
            await Task.Delay(1);
            return 0;
        }

        // Taskのインスタンスは生成されない
        return 1;
    }
}

UnityのProfilerで調べたところ、まさかのどっちもGC Allocがゼロになっていました。

Profilerの範囲対象外なのか、まさかインスタンスが使い回されるように最適化が入っているのか。
詳細は私にはわかっていません。

よければ有志の方コメント等にて教えてくださると嬉しいです。