BETA

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

投稿日:2019-12-24
最終更新:2019-12-29

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

さて、並行並列プログラミングを愛し、スレッドプールに日々感謝を捧げているみなさんは、 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> たまに非同期なメソッド() {  
    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プロパティの挙動は未定義だし、それと同じじゃないかという意見をいただきました。その通りだと思います。

.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 が嫌いだということです。終わり。

技術ブログをはじめよう Qrunch(クランチ)は、プログラマの技術アプトプットに特化したブログサービスです
駆け出しエンジニアからエキスパートまで全ての方々のアウトプットを歓迎しております!
or 外部アカウントで 登録 / ログイン する
クランチについてもっと詳しく

この記事が掲載されているブログ

@azyobuzinの技術ブログ

よく一緒に読まれる記事

0件のコメント

ブログ開設 or ログイン してコメントを送ってみよう