Task<IDisposable>を返すとリソース解放漏れがおきやすい
public async Task<FileStream> GetFileStreamAsync() { ... }
async Task AMethodAsync()
{
// 警告されないawait忘れ
using var stream = GetFileStreamAsync();
...
}
このコードの問題点は、GetFileStreamAsync()の呼び出し時にawaitを付けていないため、FileStreamがDispose()されないことです。代わりにTask<FileStream>がDispose()されてしまいます。
Task<TResult>自体がIDisposableを実装しているために、コンパイラは警告を出せない。このため、このバグは気づかれにくくなります。
このようなリソース解放漏れを引き起こしやすいAPIの採用は、極力避けるべきです。
この投稿では、その回避方法について説明します。
方法1: ValueTask<TResult>を使用する
usingでタスクの結果を使うことが想定されるメソッドでは、Task<TResult>の代わりに ValueTask<TResult>を返すことで、この問題を防げます。
ValueTask<TResult>はIDisposableを実装していないため、間違えてawaitを付けずに使った場合、コンパイルエラーになります。
public async ValueTask<FileStream> GetFileStreamAsync() { ... }
async Task AMethodAsync()
{
using var stream = await GetFileStreamAsync();
// ^^^^^ これがないとコンパイルエラー
...
}
これでこの類のリソースの解放漏れは防げます。
ただし、ValueTask<TResult>には以下のような制約があります:
-
awaitを複数回使えない -
AsTaskを複数回呼び出せない - 未完了の状態で
ResultやGetAwaiter().GetResult()を呼ぶと未定義動作 - 上記のプロパティやメソッドを複数回使うと同様に未定義動作
-
WhenAllやWhenAnyに直接渡せない
とはいえ、タスクを同期的に扱う範囲では、実用上大きな問題はありません。
方法2: AsyncExライブラリのAwaitableDisposable<T>を使用する
Stephen Cleary 氏のAsyncExライブラリでは、AwaitableDisposable<T>という型を用いてこの問題に対処しています。
この型の特徴は以下の通りです:
-
Task<TResult>をラップして返す (TResultはIDisposableを実装) - 自身は
IDisposableを実装しない -
awaitすると内部のTResultを返す
詳細は このStackOverflowの回答が参考になります。
この仕組みを使うと、以下のように書けます。
public AwaitableDisposable<FileStream> GetFileStreamAsync() { ... }
async Task AMethodAsync()
{
using var stream = await GetFileStreamAsync();
// ^^^^^ これがないとコンパイルエラー
...
}
この型は自作しなくても、Nito.AsyncExパッケージを使えば簡単に導入できます。
ValueTask<TResult>を使っていないので、前述の未定義動作の懸念もなくなります。
「未定義動作? 地球が爆発するかもしれない!こわい!」という方にも安心です。
そもそも、なぜTask<TResult>がIDisposableを実装しているのか
「TaskをDispose()しているコードなんて見たことない」という方も多いと思います。
実際、通常の使用ではDispose()は不要です。
とはいえ、極めてまれにDispose()すべきケースが存在します。
詳細は公式ブログにて: