TaskとValueTaskの使い分け、或いはValueTaskSupplementによる福音
- 2019-08-26
ValueTaskSupplementというライブラリを新しく作って公開しました!
これは、ValueTaskにWhenAny, WhenAll, Lazyを追加するという代物で、それだけだとヘーそーなんだー、としか思えないと思われます。しかし、ValueTaskを使っていくと、めっちゃくちゃ欲しくなる機能になってます。ないと死ぬレベルで。
と、いうわけで、なんでこれが必要なのか、っていうところから説明します。
TaskとValueTask
C# 5.0にasync/awaitが導入された当初はTaskしか存在しませんでした。標準APIのあらゆるメソッドにasyncメソッドを生やすなど、Microsoftの多大な努力により、C#はいち早く非同期時代を迎え、async/awaitは多用(濫用とも言う)されるようになりました。しかし、多用された結果、当初思ってたよりもTaskのオーバーヘッド多くね?同期をラップするだけのシチュエーションも少なくなくね?ということに気付き、C# 7.0から登場したのがValueTaskです。
登場当時のValueTaskは T | Task[T]
という、もし中身が同期の場合はTを、非同期の場合はTaskをラップしたものとして存在しました。なので、TaskとValueTaskの使い分けは、中身が非同期の場合が確定している場合はラップが不要で、かつ(当時)スタンダードな定義に沿うTaskを基本に考えていくのが良いでしょう。と、されていました。
が、しかし、実際にアプリケーションを作っていくと、都度使い分けなんて考えられるものじゃないし、ValueTaskのオーバーヘッドといってもstructでラップするだけの話でそこまで大きいわけじゃない(同期のものをTaskで定義したほうがよほど大きい)ので、普通にアプリケーションで定義する場合のルールは全部ValueTaskでいーんじゃね?と思っていたりは、私の個人的な見解どまりでありました。
そして、更にパフォーマンスを追求する中で、ValueTask->Task変換のオーバーヘッドをなくし、中身をそれぞれに特化したコードを挟み込めるように IValueTaskSource というものが導入されました。これによりValueTaskは T | Task[T] | IValueTaskSource
のどれかの状態を持つという共用体となり、個別に実装されたシナリオでは中身がTask[T]の場合よりもIValueTaskSourceの場合のほうがパフォーマンスが高いということで、名実ともにValueTaskの天下の時代が始まりました。
大々的にパブリックAPIにも露出してくるのは.NET Core 3以降だと思われますが、今でも問題なく使える状態&KestrelやSystem.IO.PipelinesにはValueTaskによるAPIが既に露出しています。MagicOnionのフィルターもValueTaskだったりしたりしましたり。
なお、別の世界線ではUniTaskというものも存在しますが、これは ValueTask + IValueTaskSource に近い代物です。つまり別にTaskなんていらなかったんや……。
ValueTaskの欠点
そんなValueTask最大の欠点は、ユーティリティの欠如。つまり、WhenAllやWhenAnyができない。それらが必要な際はAsTaskでTaskに変換する必要がありました。が、Taskに変換する時点でオーバーヘッドじゃーん。しかもいちいちAsTaskするのはクソ面倒くさい!せっかく IValueTaskSource があるなら、IValueTaskSourceを使ってネイティブなValueTask用のWhenAllやWhenAnyを作ればハイパフォーマンスじゃん!というわけで、それらを提供するのがValueTaskSupplementです。
using ValueTaskSupplement; // namespace
async ValueTask Demo()
{
// `ValueTaskEx`が使う唯一の型です
// こんな風な別々の型のValueTaskがあったとしても
ValueTask<int> task1 = LoadAsyncA();
ValueTask<string> task2 = LoadAsyncB();
ValueTask<bool> task3 = LoadAsyncC();
// awaitできて、タプル記法でサクッと分解できて便利!
var (a, b, c) = await ValueTaskEx.WhenAll(task1, task2, task3);
// WhenAnyでは int winIndexでどれが最初に値を返したか判定できます
var (winIndex, a, b, c) = await ValueTaskEx.WhenAny(task1, task2, task2);
// Timeoutみたいなものの実装はこんな風に
var (hasLeftResult, value) = await ValueTaskEx.WhenAny(task1, Task.Delay(TimeSpan.FromSeconds(1)));
if (!hasLeftResult) throw new TimeoutException();
// Lazyも用意されています!
// awaitを呼ぶまで遅延&値がキャッシュされるAsyncLazyのような代物ですが
// 型がValueTask<T>そのものなので、フィールドに保持したまま、WhenAllなどがそのまま書けて便利
ValueTask<int> asyncLazy = ValueTaskEx.Lazy(async () => 9999);
}
と、いったように、ただのTask.Xxxよりも更に便利になった機能が追加されていて、もう全部ValueTaskで統一でいいっしょ、って気になれます(特に var (a, b, c) = await ....)が便利ですよ!
まとめ
時代はValueTask。Taskのことは忘れて全部ValueTaskで良いのですー、良いのですー。そして、ValueTaskで統一したら、すぐに標準のまんまじゃしんどいのですー、ってことに気づくでしょふ。そこでValueTaskSupplementですよ、っという流れです。絶対そうなります。というわけで諦めて(?)使いましょう。
ところで、最近よくCygames Engineers' Blogに寄稿しているのですが、なんとなくの私の中の使い分けは、Unityに関する成分が含まれる(新規)ライブラリはCygamesのブログのほうに、そうじゃないものはここに、みたいな気持ちではいます。まぁ、どっちも見ていただければればですです。
また、直近イベントでは9月4日にCEDEC 2019で「Unity C# × gRPC × サーバーサイドKotlinによる次世代のサーバー/クライアント通信 〜ハイパフォーマンスな通信基盤の開発とMagicOnionによるリアルタイム通信の実現〜」、9月26日にUnite Tokyo 2019で「Understanding C# Struct All Things」というセッションを行うので、是非是非見に来てください!