BT

最新技術を追い求めるデベロッパのための情報コミュニティ

寄稿

Topics

地域を選ぶ

InfoQ ホームページ ニュース .NETの将来:非同期ストリーム

.NETの将来:非同期ストリーム

原文(投稿日:2017/05/01)へのリンク

VB/C#でasync/awaitが発表されてから、開発者たちはIEnumerableの非同期版について問い続けている。しかしC# 7とValueTaskが登場するまで、パフォーマンスの観点から潜在的に困難だった。

C#の過去のバージョンでは、開発者がawaitを使用するたびに、暗黙的にメモリアロケーションが発生していた。10,000の項目を数え上げる場合、10,000のTaskオブジェクトがアロケートされる可能性がある。タスクキャッシングを用いたとしてもこれは多い。ValueTaskは特定の条件下でのみメモリアロケーションを発生させる。これを用いると、IAsyncEnumerable<T>のアイディアは実現可能なように思えてくる。

これを心に留めながら、2015年9月の非同期ストリームの提案を見てみよう。

IAsyncEnumerable<T> と IAsyncEnumerator<T>

このインターフェイスの組はIEnumerable<T>を非同期的に補完するものだ。これらはこのように単純化される。

public interface IAsyncEnumerable<T>
{
  public IAsyncEnumerator<T> GetEnumerator();
}

public interface IAsyncEnumerator<out T>
{
  public T Current { get; }
  public Task<bool> MoveNextAsync();
}

ご覧のとおり、IEnumeratorのDisposeやResetメソッドは見当たらない。ResetはCOMの互換性のためだけに存在するものであり、わずかなenumeratorよりも多くこのメソッドを実装するのであれば驚くべきことだ。Disposeはおそらく、全てのenumeratorが必ず使い捨てであるという想定が誤りだと多くの人々が思っているために、取り除かれたのだろう。

MoveNextを非同期にすることだけでも、2つの利点がある。

Task<T>をキャッシュするよりもTask<bool>をキャッシュするほうが遥かに楽だ。これはメモリアロケーションの数が削減可能であることを意味する。

IEnumerator<T>を既にサポートしているクラスは、1つメソッドを追加するだけでよい。

上述したように、メモリアロケーションは“騒がしい”非同期ライブラリの主な懸念の1つだ。IAsyncEnumeratorを実装するクラスにとっては、これは必ずしも問題にならない。

非同期なシーケンスでforeachを使用したとしよう。このときシーケンスは裏側でバッファされ、その間の99.9%で各要素はローカルに、かつ同期的に利用可能だ。完了したTaskがawaitされたら常に、コンパイラは重い処理を避け、停止せずにタスクの外から直接値を取得する。与えられたメソッドコールでawaitされたTaskが全て完了したら、メソッドはステートマシンや継続として保存するためのデリゲートをアロケートすることはない。いずれも、必要なときに一度だけ構成されるからだ。

asyncメソッドがreturnステートメントに同期的にたどり着いたとしても、awaitが停止していなければ、返却するためにTaskを構成する必要がある。そのため、通常は依然として一回のアロケーションが必要だ。しかし、コンパイラが使用するヘルパーAPIは、実際にはtrueとfalseなど特定の共通の値に対する完了したTaskをキャッシュする。要約すると、バッファされたシーケンスでMoveNextAsyncを呼ぶと、一般的には何もアロケートせず、呼び出しメソッドも多くの場合同様だ。

しかし、データが必ずしもバッファする必要がなければどうだろう?

推測: これはValueTaskや他のカスタムタスク型が適合しうる場合のことだ。理論的に、enumeratorがMoveNextAsyncを呼んだときに完了フラグをクリアする“リセット可能なタスク”を用意することは可能である。こうした最適化はまだ議論されておらず、実現しないかもしれないが、C# 7が提供するものに近い。

非同期 LINQ

2015年の提案の続きを見てみよう。次に考えるのはLINQについてだ。LINQに関する主な懸念の1つは、同期/非同期のソースと同期/非同期のデリゲートの間で非常に多くの組み合わせが発生することだ。例えば、シンプルなWhere関数は4つのオーバーロードを要する。

public static IEnumerable<T> Where<T>(this IEnumerable<T> source, Func<T, bool> predicate);
public static IAsyncEnumerable<T> Where<T>(this IAsyncEnumerable<T> source, Func<T, bool> predicate);
public static IAsyncEnumerable<T> Where<T>(this IEnumerable<T> source, Func<T, Task<bool>> predicate);
public static IAsyncEnumerable<T> Where<T>(this IAsyncEnumerable<T> source, Func<T, Task<bool>> predicate);

提案では続けて述べている。

そのため、LINQの表面積を4倍にするか、新たに明示的な変換、例えばEnumerable<T>からIAsyncEnumerable<T>に、Func<S, T>からFunc<S, Task<T>>に、といったことを導入する必要がある。考えなければならないことはあるが、いずれにせよ非同期なシーケンスでLINQを使用することは価値があると思う。

4Xのメソッド数の増加は、複数のデリゲートを必要とするLINQ演算としては過小評価ですらあるかもしれない。

もう一つの問題は、TaskベースとValueTask ベースのデリゲートのいずれを使用するかということだ。2017年のドキュメントでは、“非同期デリゲート(効率のためValueTask を使用)のオーバーロードが欲しい”という一行がある。

言語サポート

明らかに、IAsyncEnumerable<T>で初めに使いたくなるのは非同期のforeachだ。しかし、Taskオブジェクトをコードで実際に見たことがなければ、どう見えるだろう?議論では以下のような案がある。

foreach (string s in asyncStream) { ... } //implied await
await foreach (string s in asyncStream) { ... }
foreach (await string s in asyncStream) { ... }
foreach async (string s in asyncStream) { ... }
foreach await (string s in asyncStream) { ... }

同様の問題はConfigureAwait(パフォーマンス上の理由でライブラリにとって重要)などを行うときに起こる。Taskに手をかけていない場合、どうやってConfigureAwaitできるだろうか?最良の解は、IAsyncEnumerable<T>にもConfigureAwait拡張メソッドを追加することだ。このメソッドはラッパーシーケンスを返し、そのラッパーシーケンスはラッパーenumeratorを返し、そのラッパーenumeratorのMoveNextAsyncはタスクのConfigureAwaitを呼んだ結果を返し、その結果はラップされたenumeratorのMoveNextAsyncが返す。

提案は続く。

これを機能させるため、非同期のforeachは同期のforeachが今日あるような、パターンベースのものであることが重要だ。ここでは幸運にも、オブジェクトが公式の“インターフェイス”を実装している、しないに関わらずGetEnumerator、MoveNext、Currentメンバーを呼ぶことができる。その理由は、Task.ConfigureAwaitの戻り値がTaskではないからだ。

我々の推測に立ち返ってみると、このことはクラスがValueTaskなどに基づいたカスタムenumeratorを提供しながらIAsyncEnumerable<T>をサポートできるようになるかもしれない、ということを意味する。これはジェネリックなIEnumerable<T> と、より高いパフォーマンスをもたらす構造体ベースの他のenumeratorに対して、List<T>がいかに機能するかと同様だ。

キャンセルトークン

2017年1月のミーティングノートに話を進め、非同期ストリームのレジュームについて話そう。まずはキャンセルトークンに関するトリッキーな問題について。

GetAsyncEnumeratorはオプションでキャンセルトークンを受け付けるが、どのような形になるだろうか?ノートより。

1. 他のオーバーロードを持たせる :-(

2. デフォルト引数を持たせる (CLS :-()

3. 拡張メソッドを持たせる (スコープ内に拡張メソッドが必要)

C#が長い間デフォルト引数をサポートしているにもかかわらず、CLSまたは 共通言語仕様が実質的にそれを制限しているというのは興味深い。もしこの言葉に馴染みがなければ、CLSは.NETプラットフォーム上の全ての言語で想定される、最小限の機能セットを定義している。このトークンのもう一つの側面は、ほとんどのライブラリ、特に基礎的なものは、CLS互換でなければならないということだ。

API仕様によれば、IAsyncEnumerable<T>がキャンセルトークンを取得する方法は非常に明確だ。明確でないのは、foreachブロックのようなイテレータがどうやって取得するかだ。何らかのバックドアメソッドでステートマシンから引きずり出そうとしているが、enumeratorのインターフェイスを変える必要があるかもしれない。

TryMoveNext?

先のパフォーマンスの議論に戻ると、同期呼び出しをせず、データが既に取得可能かどうかをチェックできるとしたらどうだろう?

これはbool? TryMoveNext()メソッド追加の背後にある理論だ。true/falseだったら期待どおりに機能するが、nullが返ってきたらMoveNextAsyncを呼び、追加のデータがあるかどうかを判定する必要がある。

明示的なチャンクはその代わりになるかもしれない。そこでは、それぞれの呼び出しがバッファされたデータを表すenumerable型を返す。

public IAsyncEnumerable<IEnumerable<Customer>> GetElementsAsync();

いずれの提案もプロバイダー側とコンシューマー側それぞれに課題があり、決定は保留中だ。

 
 

Rate this Article

Relevance
Style
 
 

この記事に星をつける

おすすめ度
スタイル

BT