ValueTaskをいつどのように使うかを理解する
この記事は2018年の以下の記事の翻訳です。
https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/
Blog
https://devblogs.microsoft.com/dotnet/author/toub/
by Stephen Toub
目次
- Task
- ValueTask<TResult> と同期完了
- ValueTask<TResult> と非同期完了
- ジェネリックではない ValueTask
- IValueTaskSource / IValueTaskSource<T> の実装
- ValueTask の正しい使い方
- 新しい非同期 API はすべて ValueTask / ValueTask<TResult> を返すべき?
- ValueTask / ValueTask<TResult> のこれから
.NET Framework 4 で System.Threading.Tasks 名前空間が導入され、Task クラスが追加されました。Task および派生クラス Task<TResult> は、C# 5 の async / await キーワードによって確立された非同期プログラミングモデルの中心的存在となり、.NET のプログラミングにおいて重要な役割を果たしています。本記事では、新しく導入された ValueTask / ValueTask<TResult> 型について取り上げます。これらは、特に高頻度で呼び出されるようなシナリオにおいて、アロケーション (ヒープ割り当て) のオーバーヘッドを減らしパフォーマンスを向上させることを目的としています。
Task
Task は複数の目的を果たしますが、その中核的な機能は「ある操作が最終的に完了することを表す約束 (promise)」である点にあります。操作を開始して Task を受け取り、その Task が完了するときに操作も完了します。これは以下のようなさまざまなタイミングで起こり得ます。
- 操作開始時に同期的に完了する (例: すでにバッファリングされているデータを即座に取得する場合)。
- 非同期的に完了するが、Task を受け取った時点ではすでに完了しているほど早い場合。
- 実際に非同期で完了する (例: ネットワークを介してデータを取得するようなケースで、Task を受け取った後に完了する)。
操作が非同期で完了する可能性があるため、結果を受け取るには以下のいずれかの方法が必要です。
- 完了をブロックして待機する (ただし多くの場合、非同期にしたメリットが薄れる)。
- コールバックを渡しておいて、操作完了時に呼び出してもらう。
.NET Framework 4 では、ContinueWith メソッドで明示的にコールバック (デリゲート) を登録する方法が取られていました。
SomeOperationAsync().ContinueWith(task =>
{
try
{
TResult result = task.Result;
UseResult(result);
}
catch (Exception e)
{
HandleException(e);
}
});
しかし、.NET Framework 4.5 と C# 5 以降では、await によってこれがさらに簡潔に書けるようになっています。下記のように書くと、操作が同期的に完了しようが、非常に早い非同期的な完了だろうが、Task を受け取った後に完了しようが、すべて同じコードで正しく動作します。
TResult result = await SomeOperationAsync();
UseResult(result);
Task はクラスであり、その柔軟性は多くの利点をもたらします。たとえば、複数回の await が可能で、同時に複数の利用者が await することもできます。また、Task を辞書に格納して将来何度も await することもできます。これを利用すれば、非同期結果のキャッシュとして利用可能です。必要に応じて同期的に完了を待つ (Wait() など) といった使い方もできます。さらに、非同期操作を組み合わせる様々な「コンビネータ (combinators)」も利用できます。たとえば「どれか一つが完了するのを待つ」 (WhenAny) といった操作です。
しかし、こういった柔軟性は、次のようなもっとも一般的なケース——ただ非同期操作を呼び出して、その戻り値の Task を await するだけ——においては、過剰な機能かもしれません。
TResult result = await SomeOperationAsync();
UseResult(result);
このケースでは、Task を複数回 await する必要はなく、同時に await する必要もなく、ブロックして待つ必要もコンビネータを使う必要もありません。ただ単に「将来完了する非同期操作」の結果を await したいだけです。
一方で、Task がクラスであることは、パフォーマンスを気にするシナリオでは問題になる場合があります。特に多くのインスタンスが生成される高負荷のシステムで、クラスのインスタンス化によるアロケーションが増えればガーベッジコレクション (GC) の負担も増大し、リソースが別の作業に割けなくなる可能性があります。
ランタイムやコアライブラリでは、このようなケースをいくつか最適化しています。例えば、以下のように書いた場合:
public async Task WriteAsync(byte value)
{
if (_bufferedCount == _buffer.Length)
{
await FlushAsync();
}
_buffer[_bufferedCount++] = value;
}
この操作が同期的に完了する場合 (つまりバッファにまだ空きがある場合) には、特に返すべき結果や値がないため、実際には追加の Task インスタンスを作らなくても済みます。実際、このシナリオではランタイムは単一のシングルトン Task.CompletedTask を再利用して返すことができます。
あるいは、以下のように書いたとします。
public async Task<bool> MoveNextAsync()
{
if (_bufferedCount == 0)
{
await FillBuffer();
}
return _bufferedCount > 0;
}
この場合も、一般的にはバッファに何らかのデータがあるケースが多いかもしれません。もしデータがあれば同期的に true を返すだけです。結果が true と false の二値しかないため、ランタイムは Task<bool> を2つ (それぞれ Result が true と false) 用意し、それらを使い回すことが可能です。従って、同期完了する場合には再利用された Task<bool> を返すため、新たなアロケーションは不要になります。ただし、本当に非同期に完了する場合は、新しく Task<bool> を生成する必要があります。操作が完了する前にタスクを呼び出し元へ返し、そこに結果 (や例外) を保存しなければならないからです。
ランタイムは他の型についても小さい範囲で同様のキャッシュを保持していますが、すべての型に対して行うのは不可能です。例えば、以下のようなメソッド:
public async Task<int> ReadNextByteAsync()
{
if (_bufferedCount == 0)
{
await FillBuffer();
}
if (_bufferedCount == 0)
{
return -1;
}
_bufferedCount--;
return _buffer[_position++];
}
もしこれが同期的に完了するようなケースが多くても、戻り値が int には約 40 億通りの値があり、そのすべてをキャッシュすることは現実的ではありません。ランタイムは int の小さな範囲だけはキャッシュしていますが、例えば戻り値が 42 の Task<int> はキャッシュされていないため、そのときは新たに Task<int> を作らなくてはなりません (Task.FromResult(42) のように)。
多くのライブラリ実装では、さらに独自のキャッシュでこれを最適化しようとします。例えば、.NET Framework 4.5 の MemoryStream.ReadAsync は常に同期的に完了します (メモリ上のデータを読むだけなので)。戻り値は Task<int> で、返される int は読み取られたバイト数を示します。これはよくループで繰り返し呼ばれ、かつ同じバイト数だけ毎回要求されることが多いので、MemoryStream は自前で直近の完了結果をキャッシュし、同じ結果の場合は同じ Task<int> を返すようにしています。それによって繰り返しアロケーションを削減しているのです。
それでもなお、多くのケースでは同期完了していても (結果がキャッシュ可能なものでもない限り) Task<TResult> のアロケーションは避けられません。
ValueTask<TResult> と同期完了
こうした背景から、.NET Core 2.0 (および System.Threading.Tasks.Extensions NuGet パッケージとして .NET の以前のバージョン) で ValueTask<TResult> という新しい構造体が導入されました。
ValueTask<TResult> は、TResult または Task<TResult> を包む (wrap) ことができる構造体です。これにより、async メソッドの戻り値として使用した際に、メソッドが同期完了した場合は新しいオブジェクトを割り当てる必要がなくなります。単に TResult をそのまま ValueTask<TResult> のフィールドに収めて返すだけで済むのです。メソッドが非同期完了した場合にのみ Task<TResult> の割り当てが必要になります (さらに言うと、例外が投げられた場合にも ValueTask<TResult> だけではなく Task<TResult> を使って結果を表します。これは構造体のサイズを抑え、成功パスを最適化するための設計です)。
これにより、例えば以下のような MemoryStream.ReadAsync 実装を考えた場合:
public override ValueTask<int> ReadAsync(byte[] buffer, int offset, int count)
{
try
{
int bytesRead = Read(buffer, offset, count);
return new ValueTask<int>(bytesRead);
}
catch (Exception e)
{
return new ValueTask<int>(Task.FromException<int>(e));
}
}
このように書くことで、同期完了する場合は ValueTask<int>(bytesRead) を返し、非同期完了する場合には ValueTask<int>(Task.FromException<int>(e)) や ValueTask<int>(Task.FromResult(result)) のようにラップした Task<int> を使います。これによって、同期完了パスでの余計なアロケーションが不要になります。
ValueTask<TResult> と非同期完了
メソッドが同期完了した場合に新たな Task<TResult> の割り当てを回避できるのは大きなメリットです。これは .NET Core 2.0 で ValueTask<TResult> が導入された理由そのものです。そのため、高頻度な呼び出しが想定されるメソッドには、現在では Task<TResult> ではなく ValueTask<TResult> を戻り値として定義するケースが増えています。たとえば、.NET Core 2.1 で追加された Stream.ReadAsync (バッファとして Memory<byte> を受け取るオーバーロード) は ValueTask<int> を返すようになりました。多くの Stream 実装 (特に同期完了が多いもの) で、アロケーションを減らすための設計です。
ただ、高負荷のサービスなどでは、同期完了パスだけでなく非同期完了パスでのアロケーションも重要です。というのも、非同期完了する場合にも、結果の完了を表すユニークなオブジェクトが必要になるからです。呼び出し元のコードが await するためには、「いつ操作が完了したか」を知るためのコールバック登録先や結果を保持するインスタンスが不可欠になります。しかし、そのオブジェクトが再利用可能であれば、API 側で一度生成したインスタンスをプールしておき、同時実行でなければ再利用できる可能性があります。
.NET Core 2.1 では ValueTask<TResult> が強化され、TResult そのものや Task<TResult> だけではなく、新たに導入された IValueTaskSource<TResult> をラップできるようになりました。これによりプールを利用したオブジェクトの再利用が可能となります。IValueTaskSource<TResult> は次のようなインターフェイスです。
public interface IValueTaskSource<out TResult>
{
ValueTaskSourceStatus GetStatus(short token);
void OnCompleted(Action<object> continuation, object state, short token, ValueTaskSourceOnCompletedFlags flags);
TResult GetResult(short token);
}
- GetStatus は ValueTask<TResult>.IsCompleted などのプロパティを実装するために使われ、操作が未完了かどうか、成功・失敗で完了したかなどを返します。
- OnCompleted は ValueTask<TResult> の awaiter が、操作完了時に呼び出すコールバック (continuation) を登録するために呼び出します。
- GetResult は操作の結果を取得するために呼び出され、成功結果 TResult を返すか、あるいは失敗していれば例外を投げます。
ほとんどの開発者はこのインターフェイスを直接触る必要はありません。ValueTask<TResult> を返すメソッドを呼び出して await すれば、内部でこれがどのように動いているかを意識せずに使えるようになっています。このインターフェイスは、高パフォーマンスが必要な一部のシナリオ (自前でプールを管理したい場合など) でのみ必要となるでしょう。
この機能は .NET Core 2.1 のいくつかの API にも使われています。代表的なのは Socket.ReceiveAsync / SendAsync のメモリ系オーバーロードです:
public ValueTask<int> ReceiveAsync(Memory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken = default);
返り値が ValueTask<int> になっており、もし操作が同期完了すれば ValueTask<int>(result) で割り当てなしに返せます。操作が非同期で完了する場合でも、ソケット側でプールしたオブジェクト (内部で IValueTaskSource<int> を実装) を再利用し
IValueTaskSource<int> vts = …;
return new ValueTask<int>(vts);
のようにして返すことができます。Socket 実装では、受信と送信それぞれに対して一度に1つだけ同時実行される限りはアロケーションが発生しないようなプールを持っています。この仕組みは NetworkStream にも使われており、Stream.ReadAsync(Memory<byte> buffer, CancellationToken) を呼ぶと、内部で Socket.ReceiveAsync が呼ばれるため、結果的にアロケーションフリーになります。
ジェネリックではない ValueTask
ValueTask<TResult> は「同期完了パスのアロケーション削減」が主目的でしたが、async Task メソッドの戻り値としては、同期完了を表す場合に常に Task.CompletedTask シングルトンを返せば割り当てを減らせるため、ジェネリックではない ValueTask は当初そこまで重要ではありませんでした。
しかし、前述のように「非同期完了時もプールによるアロケーション削減をしたい」となると、ジェネリックではない非同期メソッド (戻り値が void 的なもの) についても、同様の仕組みが必要になります。そこで .NET Core 2.1 で ValueTask (非ジェネリック) と IValueTaskSource が導入され、ValueTask<TResult> の機能を踏襲した形となっています。
IValueTaskSource / IValueTaskSource<T> の実装
ほとんどの開発者はこれらのインターフェイスを実装する必要はありません。また、実装は簡単でもありません。もし必要になる場合は、.NET Core 2.1 内部のいくつかのサンプル実装を参照できます。
- AwaitableSocketAsyncEventArgs
- AsyncOperation<TResult>
- DefaultPipeReader
.NET Core 3.0 以降では、こうした実装を支援するための ManualResetValueTaskSourceCore<TResult> という構造体が用意される予定です。これをラップする形で IValueTaskSource<TResult> / IValueTaskSource を実装すると、手軽に独自のプール対応を実装できます。詳しくは次のイシューを参照してください:
https://github.com/dotnet/corefx/issues/32664
ValueTask の正しい使い方
表面的な型の違いとして、ValueTask / ValueTask<TResult> は Task / Task<TResult> と比べて使い方が限られています。基本的にはただ await するだけの使い方が想定されています。
ただし、ValueTask / ValueTask<TResult> は内部で再利用されるオブジェクトを包んでいる可能性があるため、Task / Task<TResult> にはない大きな制約があります。具体的には、以下の操作は絶対に行ってはいけません。
複数回の await
- 基になるオブジェクトがすでにリサイクルされているかもしれません。
Task/Task<TResult>は完了後も再利用されることはなく、何度もawaitできますが、ValueTask/ValueTask<TResult>はそうではありません。
同時に複数回の await
- そもそも (1) と同じですが、並列で
awaitすれば当然複数回のawaitとなります。内部レースコンディションを引き起こす可能性があります。
GetAwaiter().GetResult() を、操作が完了していない状態で呼び出す
IValueTaskSource/IValueTaskSource<TResult>の実装がブロッキングを許可しているとは限りません。完了前に結果を取得しようとするとレースコンディションが発生する恐れがあります。Task/Task<TResult>の場合はWait()相当のブロッキングが可能ですが、ValueTask/ValueTask<TResult>では保証されません。
もしどうしても上記のような操作をしたい場合は、まず AsTask() を呼び出して Task / Task<TResult> を取得し、それに対して操作してください。その後、その ValueTask / ValueTask<TResult> は再度使わないでください。
簡単にまとめると、ValueTask / ValueTask<TResult> を受け取ったら、以下のいずれかだけ行うのが正しい使い方です。
- そのまま await する (必要に応じて .ConfigureAwait(false) を付与)。
- すぐに .AsTask() を呼んで Task / Task<TResult> に変換し、以降は ValueTask / ValueTask<TResult> を触らない。
// ValueTask<int> を返すメソッドがあるとして…
public ValueTask<int> SomeValueTaskReturningMethodAsync();
// [○] 良い使い方
int result = await SomeValueTaskReturningMethodAsync();
// [○] ConfigureAwait
int result = await SomeValueTaskReturningMethodAsync().ConfigureAwait(false);
// [○] AsTask
Task<int> t = SomeValueTaskReturningMethodAsync().AsTask();
// [△] 悪くはないが注意
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
// ローカル変数に入れる時点で誤用のリスクが上がるが、使い方によってはOKの可能性も
// [×] 複数回の await はNG
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
int result1 = await vt;
int result2 = await vt; // 2回めはダメ
// [×] 同時に await もNG (そもそも複数回の await)
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
Task.Run(async () => await vt);
Task.Run(async () => await vt); // 競合
// [×] 完了前に GetAwaiter().GetResult() でブロックするのはNG
ValueTask<int> vt = SomeValueTaskReturningMethodAsync();
int result = vt.GetAwaiter().GetResult(); // 望ましい動作は保証されない
高度なパターンとして、ValueTask / ValueTask<TResult> が提供するプロパティ (IsCompleted や IsCompletedSuccessfully など) を使って処理を分岐する方法があります。これは非常に高頻度のパスで「同期完了なら追加のオーバーヘッドを避ける」ようなケースで有用です。ただし、この場合も、一度でも .Result を取得したり await したりしたら、その ValueTask を再利用しないというルールを守る必要があります。
たとえば、.NET Core 2.1 の SocketsHttpHandler では以下のようにして、同期完了パスではキャンセル登録をスキップし、非同期完了パスの場合にだけキャンセル処理をフックしています。
int bytesRead;
{
ValueTask<int> readTask = _connection.ReadAsync(buffer);
if (readTask.IsCompletedSuccessfully)
{
bytesRead = readTask.Result;
}
else
{
using (_connection.RegisterCancellation())
{
bytesRead = await readTask;
}
}
}
このように IsCompletedSuccessfully をチェックして同期完了かどうかを判定し、非同期時だけ追加コストを払うパターンは、パフォーマンス計測の結果次第ではメリットがあります。
新しい非同期 API はすべて ValueTask / ValueTask<TResult> を返すべき?
結論としては、「いいえ」。デフォルトは依然として Task / Task<TResult> です。
ここまで述べたように、Task / Task<TResult> のほうが取り扱いは簡単で、誤用のリスクも低いです。よほどアロケーション削減が求められるシナリオでなければ、Task / Task<TResult> のほうが適切です。また、マイクロベンチマークでは、Task<TResult> を await するほうが ValueTask<TResult> よりもわずかに高速になる傾向もあります。もし戻り値が bool やごく小さな整数などで、キャッシュが使える可能性があれば、Task.FromResult(true) / Task.FromResult(0) なども効率的です。さらに、ValueTask<TResult> は複数フィールドを持つため、async メソッドのステートマシンに格納されるときに多少サイズが増える、という欠点もあります。
- そのため、次のような状況でない限りは Task / Task<TResult> が推奨です。
- API 利用者が直接
awaitすることが明確で、複雑な使い方をしない。 - アロケーション削減が非常に重要。
- 同期完了が起こりうるケースがかなり多い、もしくは非同期完了時に効果的なプールが利用できる。
- (抽象メソッドやインターフェイス メソッドの場合) 実装側で上記のような最適化を行う可能性がある。
ValueTask / ValueTask<TResult> のこれから