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()
すべきケースが存在します。
詳細は公式ブログにて: