await 中の Task をキャンセルしてみる

こんにちは。ゴールデンウィークになっても春休みの課題を終わらせていない azyobuzin です。最近また C# 関連記事が書けるようになってきました。……ええ、 Android の進捗ダメです。
さて、なぜか Owner 権限を持っている CoreTweet プロジェクトで async に対応しようとしたところ ContinueWith と TaskCompletionSource にいじめられまくってるわけですが、ここまで頑張ってキャンセルを実装したところで、 await での動作はどうなるんだろう?と思っていくらか試してみました。
普通にキャンセルしてみる
ご存知の通り Task.Run 内で OperationCanceledException を投げればキャンセル扱いになるので、まずは普通にやってみましょう。
using System; using System.Diagnostics; using System.Threading.Tasks; namespace TaskCancelTest { class Program { static void Main(string[] args) { Do().Wait(); } static async Task Do() { var t = Task.Run(() => { throw new OperationCanceledException(); }); try { await t; Debugger.Break(); } catch { Debugger.Break(); } } } }
これを実行すると、 catch の Debugger.Break() に到達してブレークします。

例外は OperationCanceledException で、スタックトレースを見てみると、 18 行目、 throw の行で例外が発生したことになっています。

また Task は IsCanceled, IsCompleted が true 、 IsFaulted が false となっています。
まあ予想通りだと思います。 OperationCanceledException で catch してあげれば、キャンセルされたことを受け取れるように出来ます。
独自のキャンセル例外を用意してみる
次に
class MyTestCancelException : OperationCanceledException { }
という例外クラスを用意して、さっきのコードの throw の行を書き換えてみます。

例外は MyTestCancelException(スクリーンショットと違うのは許して)で、

Task の状態はさっきと同じです。
……え? Task の Exception が null なのに独自エラーがそのまま返ってくる!?!?!?これはキモい。
ContinueWith でキャンセルしてみる
今度は Task の生成をこんな感じにしてみましょう。
Task.Run(() => Debug.WriteLine("I'm an async man.")) .ContinueWith(_ => { throw new OperationCanceledException(); })
すると…?

Faulted だ!!
ContinueWith では CancellationToken のみでキャンセルが可能なようです。
(追記)OperationCanceledException のコンストラクタに CancellationToken を渡すことでキャンセル判定にしてくれます。
しかし、キャンセルの処理は相当うまくて、最初からキャンセル要求された CancellationToken が渡された場合、前の Task も実行しないようにしてくれるようです。この検証コードの source.Cancel() の位置をいろいろいじると楽しいかも。
var source = new CancellationTokenSource(); var t = Task.Run(() => { Debug.WriteLine("I'm an async man."); source.Cancel(); }).ContinueWith(_ => { Debug.WriteLine("ContinueWith"); }, source.Token);

このときの Task の状態は Canceled で、例外は TaskCanceledException 、スタックトレースは await の内部で呼び出される TaskAwaiter.GetResult となっています。
例外を吐かずにキャンセルしてみる
みんな大好き(吐血) TaskCompletionSource を使って遅延キャンセルをやってみます。
static Task<string> MakeTask() { var tcs = new TaskCompletionSource<string>(); Task.Delay(500).ContinueWith(_ => tcs.TrySetCanceled()); return tcs.Task; }
結果は…… TaskCanceledException です。はい、 ContinueWith をやったときになんとなく察しはついてました。
まとめ
いまいち Task 系は非 public なところでごにょごにょしているようで見通しが悪いですね。とにかくキャンセルを捕まえるにはどんな実装になっていようと、 OperationCanceledException を受け取れば良さそうです。
うーん、 CoreTweet の非同期コードを LINQPad で試すとキャンセルしても awaiting から返ってこないのはどこか実装が間違ってるのかな。。 ContinueWith でつなげるとちゃんと Canceled な Task が返ってくるし、そのまま complete ってなるし意味がわからない。