Build Insiderオピニオン:岩永信之(10)
C# 7、そしてその先へ: 非同期処理(後編)- 非同期シーケンス
C#(とVisual Basic)が切り開いた非同期処理の新たな世界。そこにはまだ課題もある。これを克服する方法として、前後編の後編となる今回は「非同期シーケンス」がC# 7でどうなるかを見てみよう。
前編の冒頭で、「要望はあるのに現状の非同期メソッドではできないこと」として以下の2つがあると説明した。
- (1)戻り値の型に
void、Task、Task<T>しか認めていない - (2)非同期シーケンス(
awaitとyield returnの混在)に対応していない
前編では、(1)非同期メソッドの戻り値の問題と、C# 7~将来のC#で検討されている解決策について説明した。後編となる今回は、(2)非同期シーケンスについて話していこう。
非同期シーケンス
C# 5.0で非同期メソッドが導入されたとき以来ずっと要望として挙がっていることの一つに、非同期シーケンス(async sequence)というものがある。非同期シーケンスは、要するに、IEnumerable<T>的なデータ列(シーケンス)の非同期版である。
非同期シーケンスに求められる構文
まず、IEnumerable<T>インターフェース(System.Collections.Generic名前空間)の非同期版が必要となる。リスト8に示すIAsyncEnumerable<T>のようなインターフェースになるだろう。実際にはいくつかのパターンが検討されていて、リスト4はその中で一番シンプルな形である(後述するが恐らく機能不足)。
|
public interface IAsyncEnumerable<T>
{
IAsyncEnumerator<T> GetEnumerator();
}
public interface IAsyncEnumerator<T>
{
T Current { get; }
Task<bool> MoveNextAsync();
}
|
そして、非同期シーケンスに対しては、生成する側と消費する側の両方で新しい構文が求められる。
非同期シーケンスの生成構文(非同期イテレーター)
生成する側に関して出ている要望は、イテレーターブロック(yield return)の非同期版(非同期イテレーター)である。要するに、「yield returnとawaitを混在させたい」というものだ。例えば、リスト9のようになる。
|
static async IAsyncEnumerable<string> GetNamesAsync()
{
const int pages = 10;
for (int i = 0; i < pages; i++)
{
// 実際にはネットワークアクセスなどがあるものとする
// 1件1件ではなく、まとまった件数ずつダウンロードする
var names = await Task.Delay(1000)
.ContinueWith(_ => new[] { "a", "b", "c" });
foreach (var name in names)
{
yield return name;
}
}
}
|
実は、イテレーターブロック(yield return)と非同期メソッド(await)に対してC#コンパイラーが生成するコードは非常に似ている。従って、この2つを統合することもそこまで難しくないはずである。
構文的には、async修飾子が付いていて、かつ、メソッド内部でyieldキーワードが使われていれば非同期イテレーターとして扱えばよい。つまり、非同期メソッドとイテレーターブロックの組み合わせで実現できる。
非同期シーケンスの消費構文(非同期foreach)
一方、消費する側に関する要望は、要するにforeachステートメントの拡張である。既存のforeachステートメントと全く同じ構文で非同期シーケンスの消費をできるようにするか、あるいは、リスト10に示すように少し変化を付けるかはまだ決まっていないが、これに類する何らかの構文が必要だろう。
|
static async Task ShowNamesAsync(IAsyncEnumerable<string> names)
{
// 変数の前にasyncを付ける。awaitになる可能性もあり
foreach (async var name in names)
{
Console.WriteLine(name);
}
}
|
このようなコードに対して、通常のforeachステートメントと同種のコード展開をすることなるだろう。例えばリスト10のコードであれば、リスト11のように展開されるはずだ。
|
static async Task ShowNamesAsync1(IAsyncEnumerable<string> names)
{
var e = names.GetEnumerator();
try
{
while (await e.MoveNextAsync())
{
var name = e.Current;
Console.WriteLine(name);
}
}
finally
{
if (e is IDisposable)
((IDisposable)e).Dispose();
}
}
|
課題と対策
リスト8~リスト11のコードだけ見ていると、すでにかなり具体的な案があるように見え、いつでも実装できそうに思えるかもしれない。それがいまだに実装されていないのは、それなりに課題を抱えているからである。
パフォーマンス
非同期シーケンスの利用場面を考えたとき、リスト9の例のように、非同期処理はまとまった件数ずつ行うことは多いと思われる。一方で、リスト11のように、消費側では毎回await演算している。リスト12に、要点を抜き出して並べてみよう。
|
// 生成側のコード
// namesには3件ずつ入るので、実際に非同期処理が必要なのは3件に1回だけ
var names = await Task.Delay(1000)
.ContinueWith(_ => new[] { "a", "b", "c" });
foreach (var name in names)
yield return name;
// 消費側のコード
// 毎回awaitしている
while (await e.MoveNextAsync())
{
var name = e.Current;
Console.WriteLine(name);
}
|
前編でValueTask構造体の需要について説明したように、実際に非同期処理を必要としない場面でTaskクラスのインスタンスが作られると無駄なコストになる。リスト12の例ではたかだか3件に1回という比率だが、実際には一度の非同期処理で取得するのは数百~数千件くらいのもっと大きな単位になることが多いだろう。すなわち、非同期シーケンスの列挙は、実のところ大部分が非同期処理にならないのである。
このコストについては解決のめどが立っている。awaitのたびにTaskクラスのインスタンスが作られるのが問題なので、そのインスタンスをキャッシュして使えばいい。幸い、MoveNextAsyncメソッドの結果はbool型で、trueとfalseの2値しか取らず、たった2つのインスタンスのキャッシュで済む。
そもそも、実際に非同期になることが少ない可能性が高いのであれば、最初からMoveNextAsyncメソッドの戻り値をValueTask構造体にすべきという話もある。すなわち、IAsyncEnumerableインターフェースの実装は、リスト8ではなく、リスト13のようにすべきかもしれない。
|
public interface IAsyncEnumerable<T>
{
IAsyncEnumerator<T> GetEnumerator();
}
public interface IAsyncEnumerator<T>
{
T Current { get; }
ValueTask<bool> MoveNextAsync(); // 戻り値はValueTask
}
|
戻り値の型
非同期メソッド(C# 5.0当初はTaskクラスだけ返せれば十分だった)の場合と違って、非同期シーケンスの戻り値の型として何が好ましいのか、1つの型には決めかねるという問題がある。
前節で出てきたように、Taskクラス版とValueTask構造体版が必要かもしれない。また、既存のライブラリとの相互運用やログ記録など応用も今のうちから考えておいていいだろう。
以上のことから、恐らく、非同期シーケンスに関しては、最初から任意の戻り値を認めることになる。すなわち、Task-likeの条件で説明したような一連のメソッドを持っていれば非同期シーケンスとして扱えるようにするだろう。
LINQ
現在、LINQを代表として、IEnumerable<T>インターフェースに対する操作がいろいろとある。IEnumerable<T>インターフェースの非同期版(IAsyncEnumerable<T>インターフェース)を用意するのであれば、IEnumerable<T>インターフェースに対する操作は一通り、IAsyncEnumerable<T>インターフェースに対してもできてほしい。そうなると、リスト14に示すように、同期・非同期の組み合わせで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種類を実装してくれるよう頼んで回るしかないだろうか。そういうわけにもいかないだろうから、言語的な何らかのサポート機能が欲しくなる。すなわち、IEnumerable<T>インターフェースからIAsyncEnumerable<T>インターフェース、Func<T>クラスからFunc<Task<T>>クラスへの対応付けや、クエリ式に対する拡張が必要ということである。
非同期シーケンスのDispose
前掲のリスト11を見ての通り、foreachステートメントの展開結果には、finally句内でのDispose処理(usingステートメントがやっているのと同じ後処理)が必要である。これは例えば、Fileクラス(System.IO名前空間)のReadLinesメソッドのようなものを見ればその必要性が分かるだろう。foreachステートメント中でファイルを参照し続けるので、breakやreturn、throwでループを途中で抜けても、必ず後処理を呼ばなければならない。
イテレーターブロックを使う場合、後処理はfinally句に書けばよい。リスト15に示すように、finally句に書いた処理は常に呼ばれるようになる(一方で、finally句ではない部分は呼ばれない可能性がある)。
|
static IEnumerable<int> Producer()
{
try
{
Console.Write("begin, ");
yield return 1;
Console.Write("after 1, ");
}
finally
{
// この部分はDisposeメソッドに展開される
Console.WriteLine("end");
}
}
static void Consumer()
{
var items = Producer();
// こちらのループの結果:
// begin, consume 1, after 1, end
foreach (var item in items)
{
Console.Write($"consume {item}, ");
}
// こちらのループの結果:
// begin, consume 1, end
// (yield returnの後ろのafter 1は実行されない)
foreach (var item in items)
{
Console.Write($"consume {item}, ");
break; // breakが1つあるだけで挙動が変わる
}
}
|
これと同様のことは非同期シーケンスでも必要になるだろう。すなわち、非同期イテレーター側ではfinally句に後処理を書き、非同期foreach側はfinally句内でDisposeメソッドを呼び出す必要がある。
問題は、finally句内でawait演算を使った場合である。Disposeメソッドにも非同期処理が必要になる。つまり、非同期シーケンスに関する機能と同時に、「非同期usingステートメント」のようなものも考える必要が出てくる。当然、IDisposableインターフェースの非同期版、恐らくIAsyncDisposableというような名前になるであろうインターフェースも考えなければならない。
キャンセルの手段
一般に、非同期処理では、処理を途中でキャンセルできる手段を提供する必要がある。.NETの場合は、メソッドの引数でCancellationToken構造体(System.Threading名前空間)を受け取る方法が一般的である。
しかし、非同期シーケンスの場合、生成側(非同期イテレーター)と消費側(非同期foreach)の間でCancellationToken構造体をどうやって受け渡すかで悩むことになる。リスト16に示すようなパターンが考えられる。
|
// 非同期イテレーター自身が引数で受け取るべき?
// この場合、enumerable自体がキャンセル対象
// 複数回GetEnumeratorでenumeratorを得た場合、その全てがキャンセルされる
static async IAsyncEnumerable<string> Producer(CancellationToken c)
{
var names = await Task.Delay(1000).ContinueWith(_ => new[] { "a", "b", "c" });
foreach (var name in names)
yield return name;
}
static async Task Consumer(IAsyncEnumerable<string> names)
{
var cts = new CancellationTokenSource();
// GetEnumeratorの時点で受け取るべき?
// この場合、enumerator単位でのキャンセルになる
// これが恐らく一番適切な粒度。ただ、以下の課題あり:
// ・foraechのどこでTokenを渡せばいいか?
// ・非同期イテレーター側でTokenをどう受け取ればいいか?
var e = names.GetEnumerator(cts.Token);
// MoveNextAsyncの時点で受け取るべき?
// 値1個1個でキャンセル制御したい?
while (await e.MoveNextAsync(cts.Token))
{
var name = e.Current;
Console.WriteLine(name);
}
}
|
enumerable単位、enumerator単位、MoveNextAsync単位の3種類が考えられる。利便性がよいのは恐らくenumerator単位だろう。しかしその場合、コード中のコメントにも書いたが、CancellationToken構造体を非同期foreachステートメントの側でどうやって渡し、非同期イテレーター側でどうやって受け取るかが課題である。恐らく、リスト17のような文法が必要となる。
|
static async IAsyncEnumerable<string> Producer()
{
var names = await Task.Delay(1000).ContinueWith(_ => new[] { "a", "b", "c" });
foreach (var name in names)
{
// async文脈キーワードの追加
// ここから、enumerator単位で渡されたCancellationTokenを受け取れる
async.CancellationToken.ThrowIfCancellationRequested()
yield return name;
}
}
static async Task Consumer(IAsyncEnumerable<string> names)
{
var cts = new CancellationTokenSource();
// 通常のforeachと違って、非同期foreachはenumeratorを受け付ける
foreach (async var name in Producer().GetEnumerator(cts.Token))
Console.WriteLine(name);
}
}
|
非同期イテレーター側にはasyncキーワードなどを追加し、そこからCancellationToken構造体を受け取る。非同期foreach側はenumeratorの列挙もできるようにし、CancellationToken構造体を渡す必要があれば、直接GetEnumeratorメソッドに渡すようにする。
まとめ
C# 7よりも先の話になりそうだが、非同期シーケンスに対する言語的なサポートとして、非同期イテレーターや非同期foreach構文が検討されている。
一見、既存の構文の組み合わせでできそうに見え、C# 7といわず、今のC#に入っていそうに思えるかもしれない。しかし、詳細を詰めてみるといろいろと課題が残されている。
課題のいくつかは、非同期メソッドの任意Task-like対応の延長として解決できそうなものがある。まずはTask-likeの作業を終え、その次の段階として非同期シーケンスに取り組んでいくというように、順を追って解決していくことになるだろう。
岩永 信之(いわなが のぶゆき)
※以下では、本稿の前後を合わせて5回分(第8回~第12回)のみ表示しています。
連載の全タイトルを参照するには、[この記事の連載 INDEX]を参照してください。
8. 見えてきたC# 7: C#の短期リリースサイクル化
C# 7にはどんな新機能が含まれるのかが見えてきた。これまでと比べて、C# 7はかなり速いペースでのリリースとなる。その背景にはどんな事情があるのだろうか。
9. C# 7、そしてその先へ: 非同期処理(前編) - Task-like
C#の進化の中でも「非同期メソッド」はコーディング方法を大きく変えるほど革新的だったが、そこにはまだ課題もある。C# 7~将来のC#で、非同期処理はどう進化するのか、前後編で見ていこう。
10. 【現在、表示中】≫ C# 7、そしてその先へ: 非同期処理(後編)- 非同期シーケンス
C#(とVisual Basic)が切り開いた非同期処理の新たな世界。そこにはまだ課題もある。これを克服する方法として、前後編の後編となる今回は「非同期シーケンス」がC# 7でどうなるかを見てみよう。
11. nullが生まれた背景と現在のnullの問題点 ― null参照問題(前編)
Cの系譜を継ぐC#ではnullが長らく使い続けられてきたが、最近ではその存在が大きな問題だと認識されている。前後編でこの問題を取り上げ、今回(前編)はnullを取り巻く事情について考察する。
12. C#でのnull参照問題への取り組み ― null参照問題(後編)
最近のC#ではnullの存在が大きな問題となっている。前回(前編)で説明したnullの事情を踏まえ、今回(後編)は、将来のC#がnullをどう取り扱っていくのかを見ていく。
