yield return などのような形で IEnumerable<T>
を生成する Interactive Extensions のメソッド EnumerableEx.Create
ですが、素直に使うとusingやfinallyなどの後始末が行われないケースが出てきます。
Interactive Extensions(Ix)のEnumerableEx.Createを使う時の注意点と対策 - PITABlog
上の記事では破棄すべきリソースなどをIYielder相当のクラスに登録することで問題を解決しています。
いや、でも、using使いたいじゃん。標準の yield return でも await でも扱えるんだし。
そうです、async/await自体は例外が飛ぼうと後始末を行える能力を持っているはずです。
ケース1. yield breakしたい
ちょうど IYielder.Break()
というメソッドがあります。が、これを使うと、それ以降継続が呼び出されないので、当然後始末も行われません。
上のメソッドのことは忘れてreturnしましょう。
EnumerableEx.Create<int>(async y => { using (var hoge = new Resource()) { await y.Return(100); return;//await y.Break(); await y.Return(200); } }).ForEach(Console.WriteLine);
IYield.Return()
が呼ばれないままに continuation
から戻った場合は列挙が終了したものと扱われるので、 IYield.Break()
を呼ばずにジェネレータ関数を抜けることに何ら問題はありません。こうすれば通常のasyncメソッド同様に後始末も実行されます。
ケース2. 列挙を途中でやめたい
これはそもそも IEnumerable<T>
だけでは実現できない話ですが、foreachに限って言えば IDisposable.Dispose()
が呼ばれます。これを利用することで、列挙を中止した場合でも後始末を行うことができます。
非同期メソッドはTaskとして扱えますから、Taskのキャンセル処理と同じように、CancellationTokenを使うことができます。ただ非同期メソッドの戻り値の型がvoidだとOperationCancelledExceptionの処理もやってくれないので、Task型に変更します。
というかvoidな非同期メソッドはawaitできないので、外側で例外を捕まえること自体できない仕様です。元の実装でもそうなっていたので、後述するサンプルコードではMoveNextのタイミングでthrowしています。
非同期メソッドでCancellationTokenを使うと多分普通はこういう形になるわけですが・・・
async Task FooAsync(CancellationToken ct) { await hoge.GetFooAsync(ct); await hoge.GetBarAsync(ct); }
今の設計だとジェネレータ関数を呼び出すクラスが IYield.Return()
も処理していますから、IYield.Return()
にはCancellationTokenを渡さないことにします。またCancellationTokenはジェネレータ関数内で他の非同期メソッドを呼ぶときにも使えますが、foreachだけのことを考えると MoveNext()
の途中でDisposeされることはないので*1、隠ぺいしてしまいます。
そんな感じでこうなりました。
interface IAwaitable : ICriticalNotifyCompletion { IAwaitable GetAwaiter(); bool IsCompleted { get; } void GetResult(); } static class Yielder { public static IEnumerable<T> Create<T>(Func<Func<T, IAwaitable>, Task> create) { foreach (var x in new Yielder<T>(create)) yield return x; } } class Yielder<T>: IAwaitable, IDisposable { private Action _continuation; private CancellationTokenSource _cts; private Task _task; public Yielder(Func<Func<T, IAwaitable>, Task> create) { _cts = new CancellationTokenSource(); _continuation = () => _task = create(v => { this.Current = v; return this; }); } public IAwaitable GetAwaiter() => this; public bool IsCompleted => false; [SecurityCritical] public void UnsafeOnCompleted(Action continuation) { _continuation = continuation; } public void OnCompleted(Action continuation) { _continuation = continuation; } public void GetResult() { _cts.Token.ThrowIfCancellationRequested(); } public Yielder<T> GetEnumerator() => this; public T Current { get; private set; } public void Reset() { throw new NotSupportedException(); } public bool MoveNext() { _continuation(); // Task内で発生した例外をMoveNextのコンテキストで投げる if(_task.IsFaulted) throw _task.Exception.InnerException; return !_task.IsCompleted; } public void Dispose() { if (!_task.IsCompleted) { _cts.Cancel(); // 初回のMoveNextの前に呼ばれることは考慮してない _continuation(); } _task.Dispose(); } }
で、例えばこう。
Yielder.Create<int>(async yield => { using (var hoge = new Resource()) { await yield(100); await yield(200); await yield(300); } }).Take(2).ForEach(Console.WriteLine);
voidではなくTaskで受け取る最大の理由はOperationCanceledExceptionを処理してくれるからですが、終了判定を task.IsCompleted
に頼ることでフラグ管理も省略できました。
ちなみにこのCancellationTokenSourceの使い方だと、boolかなにかでフラグを持っておいて、自分でOperationCanceledExceptionを投げても大丈夫だと思います。
あとがき
ところでまあ無理もないと言えば無理もないんですが、型パラメータ推論できないですね。これができると匿名型とか云々。
*1:外部からCancellationTokenを渡しておいて、非同期に列挙を中止する、といった場合には使える。