【C#】TaskのDispose

結構色々な資料を読んだつもりだったんですが、TaskはDisposeメソッドを持っていると言うことを昨日初めて知って、結構驚きました。

世の中に出回っている(MSDNを含む)資料でTaskをusingに入れているものを見たことがなかったので、IDisposableも実装してないだろうと勝手に思い込んでいたんですが、よく読んだらちゃんと書かれてますね

[HostProtectionAttribute(SecurityAction.LinkDemand, Synchronization = true, 
	ExternalThreading = true)]
public class Task : IAsyncResult, IDisposable

このことに対して疑問を持っている人結構いるみたいで、最終回答的なものを見つけたのでちょっと翻訳してみます。まぁ、かなり意訳するけども。

<以下翻訳>

TaskはDisposeすべきか?

このような質問が非常に多く寄せられている。

TaskにはIDisposableが実装されており、Disposeメソッドが公開されている。これは全てのTaskをDisposeすべきという意味か?

要約

ここにこの質問に対する短い回答を用意した。

「TaskのDisposeについて悩む必要はない。」

もうちょっと長く回答するならこうなる。

「TaskのDisposeについて悩む必要はない。ただ、パフォーマンスやスケーラビリティのテスト時に目標を達成するためにDisposeが必要なこともある。もしDisposeする必要があることを発見し、なおかつDisposeすることが簡単だと感じたら(例えば、その処理が100%絶対に完了しているとわかっていたり、もう使わないオブジェクトだったりしたら)そうすればいいだろう。」

さて、これから載せる長い回答については、コーヒーでも飲みながらじっくり読んで欲しい…。

なぜTaskにDisposeメソッドがあるのか

.NET Frameworkの設計ガイドラインとして、クラス中に他のIDisposableなリソースを持っている場合はそのクラスもIDisposableを実装すべきというものがある。Taskはそのパターンに該当する。内部的な処理として、Taskにはそのタスクが完了するまで待機することが出来るWaitHandleを割り当てることがある。更にWaitHandleはIDisposableを実装したSafeWaitHandleを持っている。SafeWaitHandleではネイティブなハンドルリソースがラップされている。SafeWaitHandleをDisposeしなければ当然メモリリークの原因となりかねない。TaskにIDisposableが実装されているのは、開発者たちが積極的に、マナーとしてこういったリソースを開放するためである。

問題点

もし一つ一つのシングルタスクにWaitHandleが割り当てられているなら、積極的にDisposeした方がパフォーマンスの観点からも良いだろう。しかし、実際にWaitHandleが割り当てられているケースは滅多にない。.NET 4の場合においては、WaitHandleは数少ない状況下でのみ遅延初期化される。例えば、Taskの((IAsyncResult)task).AsyncWaitHandleが明示的に実装しているインターフェースのプロパティにアクセスするだとか、Taskを使用する処理の一環としてTask.WaitAllメソッドもしくはTask.WaitAnyメソッドが呼ばれた時に、そのタスクが完了していなかったといった場合に割り当てられる。つまり、大量のTaskでWaitAll/WaitAnyメソッドが呼ばれているだとかそういう場合であればDisposeした方がいい、というのが、「Disposeすべきか」という少し難しい質問の回答となるだろう。

また、.NET 4ではTaskをDisposeした後にそのTaskが持つメンバにアクセスするとほとんどの場合ObjectDisposedExceptionがスローされた。これは(パフォーマンスなどの事情から)処理実行済みのタスクをキャッシュすることを難しくしている。なぜなら、何処かでTaskをDisposeしてしまうと、その後ResultやContinueWithのようなTaskの重要なメンバにアクセスすることが出来なくなってしまうからだ。

さらに厄介な問題も発生する。例えば以下のようなfork/joinパターンがあったとしたら、どのようにDisposeすればいいかはすぐにわかるだろう。

var tasks = new Task[3]; 
tasks[0] = Compute1Async(); 
tasks[1] = Compute2Async(); 
tasks[2] = Compute3Async(); 
Task.WaitAll(tasks); 
foreach(var task in tasks) task.Dispose();

しかし、継続して非同期処理を行う場合、以下のように複雑になってくる。

Compute1Async().ContinueWith(t1 => 
{ 
    t1.Dispose(); 
    … 
});

この方法であれば確かにCompute1AsyncはDisposeされる、だが、ContinueWithで生まれたTaskはDisposeされていない。じゃあ同様にContinueWithでDisposeすればいいのか?

Compute1Async().ContinueWith(t1 => 
{ 
    t1.Dispose(); 
    … 
}).ContinueWith(t2 => t2.Dispose());

当然これはダメで、2つ目のContinueWithで生まれたTaskはDisposeされない。こうしてみるのはどうだろう。その場しのぎとして新たに追加されたasync/awaitキーワードを使ってみる。先ほどの例をawaitを使って表現するとこうなる。

string s1 = await Compute1Async(); 
string s2 = await Compute2Async(s1); 
string s3 = await Compute3Async(s2);

もしDisposeをしたかったらこんな風に書き換えればOK。

string s1 = null, s2 = null, s3 = null; 
using(var t1 = Compute1Async()) 
    s1 = await t1; 
using(var t2 = Compute2Async(s1)) 
    s2 = await t2; 
using(var t3 = Compute3Async(s2)) 
    s3 = await t3;

こりゃひどい。

解決策

こんなことをしなくて済むように.NET 4.5ではTask.Disposeに関していくつかの変更を加えた。

  1. TaskのWaitHandleが割り当てられる可能性を減らした。WaitAllとWaitAnyをWaitHanldeに依存しないよう再実装し、その辺のことを考慮しなくても済むようにasync/awaitキーワードを導入した。これによってWaitHandleを使用するケースがIAsyncResult.AsyncWaitHandleへのアクセスだけに絞られることになったし、普通そんなことはしないだろう。つまり、わざわざDisposeしなくてもよくなった。
  2. TaskがDisposeされた後でも使用可能になるよう修正した。これによってDisposeした後でもTaskのパブリックなメンバにはアクセスすることが出来るし、当然Disposeしないでアクセスした時と同じ結果を得ることが出来る。Dispose後にアクセス出来ないメンバはIAsyncResult.AsyncWaitHandleだけで、アクセスしようとするとObjectDisposedExceptionをスローする。これによって快適に処理後のTaskをキャッシュすることが可能となった。付け加えておくと、await/asyncキーワードによってIAsyncResultを使用するパターンを大幅に減らしたので、Taskの継続処理としてIAsyncResult.AsyncWaitHandleを使うようなことはまずないだろう。
  3. 「.NET for Metro style apps」で参照しているアセンブリではTaskにIDisposableを実装させてさえいない。だから、.NET for Metro style appsやそれに関わるポータブルなライブラリでは、そもそもDisposeなんかしなくて良い。

ガイダンス

さて、最初の短い答えに戻ろう。「TaskのDisposeについて悩む必要はない。」Disposeするべきパターンを見つけるのは難しいが、ほとんどの場合Disposeは不要だし、そもそも参照しているアセンブリによってはDispose自体がまず出来ない。

<以上翻訳>

つまり、「.NET 4.5にバージョンアップすれば何の問題もないよ。.NET 4だったらWaitAny/WaitAllを使ってるときだけでいいよ。IAsyncResult.AsyncWaitHandleをいじってたら別だけど、お前らそんなことしてんの?」ってとこですかね。

コメント欄も結構面白い質問があって、「Waitを使ってたらどーすればいいの?」と言うのがあったんですが、”using Wait() does not use the Task’s WaitHandle, neither in .NET 4 nor in .NET 4.5.”とのことなので気にしなくてよさそうです。

後、「CancellationTokenSourceはどうやってDisposeするべきなの?」と言うのがあったんですが、「CancellationTokenSourceもTaskと同じような形で(つまり、遅延初期化される前提で)WaitHandleを持ってるんだけど、CancellationTokenSourceでもそれを使うことはほとんどないからよっぽどの事情がない限りしなくていいよ。ただ、CreateLinkedTokenSourceメソッドを使って作った場合であれば、CancelAfterメソッドなんかで使う内部のタイマーをDisposeしてくれるから、そっちの意味ではやっといた方がいいかもね。」とのこと。