アジョブジ星通信

進捗が出た頃に更新されるブログ。

私、ValueTaskは限定的でって言ったよね!

メリークリスマス! みなさんは、見てないアニメをネタにするなんてしてませんよね? 私は「俺を好きなのはお前だけかよ」しか見てません。

さて、並行並列プログラミングを愛し、スレッドプールに日々感謝を捧げているみなさんは、 ValueTask を活用しているでしょうか? 私は .NET Core 2.1 以降の ValueTask が大嫌いです。この嫌いという気持ちを、歴史を紐解きながら解説していきたいと思います。

ValueTask<TResult> の登場

最初の ValueTask である、 ValueTask<TResult> の登場は、 C# 7 と同時でした。 C# 7 では、 async/await が拡張され、ユーザーが新たに Task のような何か(DSL のために Task を拡張したり、コルーチンの仕組みを作ったり……)を定義できるようになりました。詳しくは、 ++C++ の解説にぶん投げることにします。

では、なぜ Task<TResult> があるにも関わらず、 ValueTask<TResult> が生まれたか、それは無駄な new(ヒープアロケーション)をめちゃくちゃ気にしたからです。

例を示します。次のメソッドは、ほとんどの場合において、 Task を await することなく値を返します。すなわち、 たまに非同期なメソッド() を呼び出して、戻り値の Task<X> が返ってきた時点で、すでにその Task<X> は完了状態になっています。

Task<X> たまに非同期なメソッド() {
    if (ほとんどtrueにならない条件) {
        await 非同期処理();
    }

    return 値;
}

しかし、戻り値の型は Task<X> なので、どこか(標準ライブラリ内)で new Task<X>() というクラスのインスタンス化、すなわちヒープアロケーションが行われます。

ここで、パフォーマンス厨は考えました。最初から結果が決まっている Task<TResult> をわざわざ作成する必要なくない? と。 そして生まれたのが次のような構造体を作るアイデアでした。

public struct ValueTask<TResult> {
    internal readonly Task<TResult> _task; // null でないなら、この _task を await する
    internal readonly TResult _result; // _task が null なら、この値が結果の値
}

最初から結果が決まっているなら (_task, _result) = (null, 値) をセットすればいいだけなので、ヒープアロケーションが発生しません。逆に結果が決まっていないなら、むしろ TResult のぶんだけメモリ使用量が多いので損しますが、そういうことがレアなケースに使用するという前提なら問題ないし、そもそも微々たるものです。

こうして、パフォーマンス厨がよろこぶ素晴らしい ValueTask<TResult> が誕生しました。めでたし、めでたし。

何を思ったのか IValueTaskSource

.NET Core 2.1、迷走のはじまりです。 ValueTask<TResult> の変更と同時に ValueTask(非ジェネリック)が誕生しました。

発想はこうです: Task なんて一度 await したら捨てられるじゃん。インスタンスを使いまわして、もっとヒープアロケーション回数減らさない?

そこで登場したのが、 IValueTaskSource および IValueTaskSource<TResult> インターフェイスです。このインターフェイスを実装したインスタンスを使いまわすことで、結果が決まっていなくても、毎回 Task インスタンスを作成する必要がない、そういう寸法です。

ValueTask は、 IValueTaskSource インスタンスと、 short 型の token を保持します。 token とは、 ValueTaskIValueTaskSource に対して正しいタスクであるという証明をするための勘合です。間違っていたら例外を吐くことで、不適切な使用方法を防ぎます。

IValueTaskSource は、例えば、こういう使い方ができます: 次のクラスでは、何度も ReadAsync を呼び出すことで、データを取得できます。このクラスは、スレッドセーフではないため、複数のスレッドから同時に ReadAsync が呼び出される心配はしないことにします。

using System.Threading.Tasks;
using System.Threading.Tasks.Sources;

class なんちゃってAsyncStream : IValueTaskSource<int>
{
    // 結果の書き込み先
    private byte[] _buffer = null;

    // IValueTaskSource を実装するためのロジック
    // https://qiita.com/skitoy4321/items/31a97e03665bd7bcc8ca が詳しい
    private ManualResetValueTaskSourceCore<int> _helper;

    /// <param name="buffer">読み取ったデータを格納する配列</param>
    /// <returns>何バイト読み取ったか</returns>
    public ValueTask<int> ReadAsync(byte[] buffer)
    {
        _buffer = buffer;

        _helper.Reset(); // token を新しくする
        return new ValueTask<int>(this, _helper.Version);
    }

    /// <summary>どこかからこれを呼び出すことで、待機中のタスクが完了する</summary>
    protected void SetResult(ReadOnlySpan<byte> result)
    {
        result.CopyTo(_buffer);
        _helper.SetResult(result.Length);
    }

    // IValueTaskSource<int> の実装
    int IValueTaskSource<int>.GetResult(short token)
        => _helper.GetResult(token);

    ValueTaskSourceStatus IValueTaskSource<int>.GetStatus(short token)
        => _helper.GetStatus(token);

    void IValueTaskSource<int>.OnCompleted(
        Action<object> continuation, object state,
        short token, ValueTaskSourceOnCompletedFlags flags)
        => _helper.OnCompleted(continuation, state, token, flags);
}

var stream = new なんちゃってAsyncStream();
var buffer = new byte[1024];
while (true)
{
    // ストリームの終わりまで読み取る
    var bytesRead = await stream.ReadAsync(buffer);
    if (bytesRead == 0) break;
}

確かに ReadAsync メソッドで、 Task インスタンスを作成せずに非同期な仕組みを作ることができました。例として、ストリームからの読み取りを示しましたが、このようにループから何度も呼び出されるようなユースケースでは、効果を発揮するかもしれません。

大きな代償

さて、ここまで、メリットを説明するみたいな流れになっていましたが、 IValueTaskSource を導入したことによる代償は大きいという話をしましょう。

.NET Core 2.1 以降の ValueTask のドキュメントをよくご覧ください。次のような制約が書いてあります(機械翻訳が残念なので、私が雑に翻訳)。

ValueTask インスタンスに対して、次の操作を行わないでください。

  • 複数回 await する。
  • AsTask メソッドを複数回呼び出す。
  • 上記の方法を2回以上使うこと(例えば AsTask を呼び出した後に ValueTask のほうを await する)。

これらの操作を行った場合、結果は未定義です。

https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.valuetask?view=netcore-3.1#remarks

さて、いかがでしょうか? これを見て、あなたは正しく ValueTask を扱える自信があるでしょうか?

多くの場合、戻り値の ValueTask はそのまま await されるだけなので、問題ないでしょう。しかし、「多くの場合」であり、すべての場合ではないのです。これは次に示すように、大きな問題です。

「結果は未定義です」

C# において、未定義の動作は従来 unsafe ブロックの中でのみ行うことができました。もちろん、ライブラリによっては、不適切な使い方をすると未定義の動作を起こすかもしれませんが、それはライブラリの設計が悪いのです。

ValueTask において、上記の操作を行おうとしたとき、コンパイラはそのような制約を知りません。したがって、未定義動作を防ぐことができるのは、プログラムを書くあなたの注意だけです。

多くの高級プログラミング言語は、未定義な動作を許容しないよう設計されてきました。 C# では、初期化していない変数を読み取ることはできないし、 null を参照すると必ず NullReferenceException が発生します。そんな未定義から距離を取っていた言語の標準ライブラリで、 unsafe の指定もなく、未定義動作を起こせると、そう言っているのです。これが標準ライブラリの設計バランスとは到底思えません。

追記 12/29: IEnumerable だって、 MoveNextfalse を返すとき、 Current プロパティの挙動は未定義だし、それと同じじゃないかという意見をいただきました。その通りだと思います。

追記 2020/1/3: 未定義と IValueTaskSource の関係

この未定義というのはどこから来るかというと、 IValueTaskSource の性質に原因があります。

IValueTaskSource は、 ValueTask が持つ token を使って、どのタスクに関する情報を要求しているのか識別しているのでした。そして、タスクが完了したとき、 GetResult が呼び出されるわけですが、このとき、 2 回目以上の await などの操作がされないと仮定していいなら、現在の token を無効化していいわけです。実際、先の例で使用した ManualResetValueTaskSourceCore は、最新の token 以外を使用すると例外を吐きます。

IValueTaskSource の各メソッドの実装は、一度 GetResult された token について、それ以降使用されることを想定しなくていい(通常、できる範囲で例外を吐きます)ということの裏返しが、この未定義というものになります。また、同じ token について、 2 回以上 OnCompleted が呼ばれることも想定しなくていいです。

このように、未定義としたことによって、 IValueTaskSource の実装者は、さまざまな並行に関する事項を考えない、パフォーマンスに特化した実装を行えるようになっています。その一方で、もし 2 回以上 await などの操作を行ってしまった場合に、正しく失敗する保証がなくなっています。したがって、もしあなたが使い方を間違えてしまったら、多くの場合例外が発生しますが、いくら待っても値が返ってこないかもしれないし、例外ではなくプロセスごと落ちるかもしれないわけです。デバッグが非常につらそうですね。

.NET Core 2.0 から 2.1 で制約が変わった

.NET Core 2.0 までは、最初に示した ValueTask<TResult> しかありませんでした。すなわち何度 await しても問題なかったのです。しかし .NET Core 2.1 では、まったく同じ名前のまま、 IValueTaskSource を導入したのです。その結果、 .NET Core 2.0 で動作していたプログラムのセマンティクスがいつの間にか「未定義」になっている可能性があるのです。

これだけの破壊をして得られるメリットは?

あるなら教えてください。

私は、初期の ValueTask<TResult> を、多くの場合すぐに結果が得られるケースにおいて多用していました。しかし、 IValueTaskSource が加わり、型名 ValueTask<TResult> を見ただけでは、どのような動作をするのかを知ることはできず、内部で IValueTaskSource を使っていないことを祈ることでしか、従来の使い方ができなくなってしまいました。私の中で、 ValueTask とは、 C# の非同期プログラミングで細心の注意を払って利用するもののひとつになったのです。並行そのものの難しさの本質とはまったく関係ないにもかかわらず。そして、初期のメリットを享受しようにも、危なくて使いたくない上、構造体のサイズは大きくなっているのです。何を目指したかったのでしょうか? 標準ライブラリが早すぎる最適化をして許されるのでしょうか? 少なくともこのレベルの破壊はコミュニティライブラリのみで行われるべき野心的取り組みだったと感じています。

まとめると、私は ValueTask が嫌いだということです。終わり。