🛠️

Task/ValueTask を直接返せる場合でも原則非同期メソッド (async/await) にしたほうが良い

2023/09/04に公開

ユーザーコードでは Task/ValueTask (ジェネリック版含む) を直接返すことが可能の場合でも原則として非同期メソッドにして await することをおすすめします。

原則として、というのはこの記事で紹介するようなポイントを理解した上で最適化のために行うのは良いのですが、ユーザーコードでは落とし穴にハマるのを避けるためにほとんどのケースで素直に非同期メソッドにした方がよい、という話です。

Task/ValueTask を直接返す、返さないとは

そもそも Task/ValueTask をそのまま返すコードというのはどういうものかというと、次のようなコードです。

public async Task Main() => await NantokaAsync();

public ValueTask NantokaAsync()
{
    // 非同期を必要としない WriteAsync を呼び出す前の準備処理
    var message = "Hello Konnichiwa!";
    return WriteAsync(message); // ValueTask なのでそのまま返せる
}

public async ValueTask WriteAsync(string message)
{
    await Task.Delay(100);
    Console.WriteLine($"WriteAsync: {message}");
}

このコードでは NantokaAsyncWriteAsync を呼び出す前に準備する処理がありますが、その処理自体は非同期ではないため NantokaAsyncasync にせず WriteAsyncTask をそのまま返すことができます。

このテクニックを使用すると NantokaAsync は普通の同期メソッドであり、非同期メソッドのステートマシンを作る必要がないため呼び出し効率を良くできるというメリットがあり、フレームワークのコードではたまに見かけます。(ChannelWriter.WriteAsync の例, Stream.ReadAsync の例)

しかしユーザーコードでは次のように非同期メソッド (async/await) の形にして待機することを強くおすすめします。

public async Task Main() => await NantokaAsync();

public async ValueTask NantokaAsync()
{
    // 非同期を必要としない WriteAsync を呼び出す前の準備処理
    var message = "Hello Konnichiwa!";
    await WriteAsync(message); // FIX: await する
}

public async ValueTask WriteAsync(string message)
{
    await Task.Delay(100);
    Console.WriteLine($"WriteAsync: {message}");
}

非同期メソッドにして await した方がよい理由

何故わざわざ非同期メソッドにしたほうが良いのかについてですが、これはいくつか落とし穴があり、落とし穴を避けたり/デメリットを理解して普通のコードを書くというのは難しく、都度どうするか判断するのも煩わしいからです。そもそも大体が最適化の割に合わないでしょう。

ここでは直接 Task/ValueTask を返した場合の落とし穴を紹介します。

落とし穴1: using のスコープ

using のスコープの落とし穴は Task/ValueTask をそのまま返せると知っていると一度はやったことがある人も多いかもしれません。例えば次のようなコードです。

public static async Task Main() => await NantokaAsync();

public static ValueTask NantokaAsync()
{
    var message = "Hello Konnichiwa!";

    using (var conn = new NanikaConnection())
    {
        return WriteAsync(conn, message); // ValueTask そのまま返せる
    }
}

public static async ValueTask WriteAsync(NanikaConnection conn, string message)
{
    await Task.Delay(100);
    await conn.WriteAsync(message);
}

public class NanikaConnection : IDisposable
{
    private bool _disposed;
    public async Task WriteAsync(string message)
    {
        if (_disposed) throw new ObjectDisposedException(nameof(NanikaConnection));
        Console.WriteLine($"NanikaConnection.WriteAsync: {message}");
    }

    public void Dispose()
    {
        Console.WriteLine("NanikaConnection.Dispose");
        _disposed = true;
    }
}

これを実行すると次のような例外が発生します。

NanikaConnection.Dispose
Unhandled exception. System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'NanikaConnection'.
   at Program.NanikaConnection.WriteAsync(String message) in C:\Path\To\ConsoleApp1\Program.cs:line 26
   at Program.WriteAsync(NanikaConnection conn, String message) in C:\Path\To\ConsoleApp1\Program.cs:line 18
   at Program.Main() in C:\Path\To\ConsoleApp1\Program.cs:line 3
   at Program.<Main>()

このコードは WriteAsync をその場で await していないので NantokaAsyncreturn の時点で using スコープが終わって conn.Dispose が呼び出されます。しかし、WriteAsync には conn が渡されており WriteAsync はまだ実行中ですので、途中で不整合(ObjectDisposedException)が発生する状況となります。

このような不具合は大体動かせばすぐに気付くのですが、タイミングによっては起きなかったり、Dispose チェックがないといった場合で気付かないということもあります。いずれにせよこれは非同期メソッドにすることで修正できます。

public async ValueTask NantokaAsync()
{
    var message = "Hello Konnichiwa!";

    using (var conn = new NanikaConnection())
    {
        await WriteAsync(conn, message); // await する
    }
}

落とし穴2: スタックトレースから呼び出し履歴の欠落

Task/ValueTask をそのまま返した場合、スタックトレースから呼び出し履歴が欠落してしまうことがあります。

例えば次のような末端で例外を吐くようなコードを実行した結果のスタックトレースを見てみます。

public class Program
{
    public static async Task Main()
        => await NantokaAsync();

    public static ValueTask NantokaAsync()
    {
        return WriteAsync(); // ValueTask をそのまま返す
    }

    public static async ValueTask WriteAsync()
    {
        throw new InvalidOperationException();
    }
}

これを実行すると例外時に次のようなスタックトレースを確認できます。

Unhandled exception. System.InvalidOperationException: Operation is not valid due to the current state of the object.
   at Program.WriteAsync() in C:\Path\To\ConsoleApp1\Program.cs:line 13
   at Program.Main() in C:\Path\To\ConsoleApp1\Program.cs:line 4
   at Program.<Main>()

よく見ると NantokaAsync を呼び出している履歴がありません。これは WriteAsyncValueTask を待機しているのが実質的に Main だからです。

そしてこれが特に困るケースとして次のような分岐があるコードです。

public static async Task Main()
    => await NantokaAsync();

public static ValueTask NantokaAsync()
{
    if (DateTimeOffset.Now.Second % 2 == 0) // 条件はサンプルなので適当
    {
        // 最適化パス
        return WriteAsync("Hello");
    }

    // 何か重たい処理をする
    // ...
    return WriteAsync("Konnichiwa");
}

public static async ValueTask WriteAsync(string message)
{
    throw new InvalidOperationException();
}

このような Task/ValueTask をそのまま返すようなコードで分岐があった場合、最終的にどちらの WriteAsync の呼び出しパスで例外となったのかがわからずデバッガビリティーの低下を招きます。デバッグ実行で実行時にブレークした際にもコールスタックに表示されません。

こちらも NantokaAsync を非同期メソッドに書き換えてみましょう。

public static async ValueTask NantokaAsync()
{
    if (DateTimeOffset.Now.Second % 2 == 0) // 条件はサンプルなので適当
    {
        // 最適化パス
        await WriteAsync("Hello");
        return;
    }

    // 何か重たい処理をする
    // ...
    await WriteAsync("Konnichiwa");
}

これを実行すると次のようなスタックトレースになります。

Unhandled exception. System.InvalidOperationException: Operation is not valid due to the current state of the object.
   at Program.WriteAsync(String message) in C:\Path\To\ConsoleApp1\Program.cs:line 22
   at Program.NantokaAsync() in C:\Path\To\ConsoleApp1\Program.cs:line 11
   at Program.Main() in C:\Path\To\ConsoleApp1\ConsoleApp30\Program.cs:line 4
   at Program.<Main>()

変更後は NantokaAsync の11行目(ここでは1つ目)で呼び出している WriteAsync で例外になっているということがわかります。デバッガーでブレークした場合のコールスタックにもちゃんと NantokaAsync が存在します。

落とし穴3: AsyncLocal の漏洩

AsyncLocal という非同期メソッド間で値が保持/伝播できる仕組みがあります。これは非同期メソッドを実行時に値がコピーされ、呼び出し先に伝播していくという特性を持ちます。また「値はコピーされる」ため AsyncLocal<int> のような値型の場合は呼び出し先の非同期メソッドで値を変更しても呼び出し元には影響しません。

それでは次のようなコードでの挙動を見てみます。

AsyncLocal<int> _asyncLocalValue = new AsyncLocal<int>();

public async Task Main()
{
    _asyncLocalValue.Value = 123;
    Console.WriteLine($"Main(Before): _asyncLocalValue.Value = {_asyncLocalValue.Value}");
    await NantokaAsync();
    Console.WriteLine($"Main(After): _asyncLocalValue.Value = {_asyncLocalValue.Value}");
}

public ValueTask NantokaAsync() // 同期メソッド
{
    Console.WriteLine($"NantokaAsync(Before): _asyncLocalValue.Value = {_asyncLocalValue.Value}");
    try
    {
        _asyncLocalValue.Value = 456;
        var message = "Hello Konnichiwa!";
        return WriteAsync(message); // ValueTask を直接返す
    }
    finally
    {
        Console.WriteLine($"NantokaAsync(After): _asyncLocalValue.Value = {_asyncLocalValue.Value}");
    }
}

public async ValueTask WriteAsync(string message)
{
    Console.WriteLine($"WriteAsync(Before): _asyncLocalValue.Value = {_asyncLocalValue.Value}");
    try
    {
        _asyncLocalValue.Value = 567;
        await Task.Delay(100);
        Console.WriteLine($"WriteAsync: {message}");
    }
    finally
    {
        Console.WriteLine($"WriteAsync(After): _asyncLocalValue.Value = {_asyncLocalValue.Value}");
    }
}

このコードの実行結果は次のようになります。

Main(Before): _asyncLocalValue.Value = 123
NantokaAsync(Before): _asyncLocalValue.Value = 123
WriteAsync(Before): _asyncLocalValue.Value = 456
NantokaAsync(After): _asyncLocalValue.Value = 456
WriteAsync: Hello Konnichiwa!
WriteAsync(After): _asyncLocalValue.Value = 567
Main(After): _asyncLocalValue.Value = 456

最終的に _asyncLocalValue の値は 456 になっていますが、通常 await NantokaAsync() 呼び出し元は呼び出し先で AsyncLocal の値が書き換えた値になることは想定しないはずです。

これは「値のコピー」が発生するタイミングが非同期メソッド実行時の ExecutionContext の操作なので、同期メソッドである NantokaAsync を実行しても何もしていないことによります。結果として AsyncLocal の値の変更が漏れ出すという現象が発生します。

これもまた修正するには非同期メソッドにすればよいので NantokaAsync を次のように書き換えてみます。

public async ValueTask NantokaAsync()
{
    Console.WriteLine($"NantokaAsync(Before): _asyncLocalValue.Value = {_asyncLocalValue.Value}");
    try
    {
        _asyncLocalValue.Value = 456;
        var message = "Hello Konnichiwa!";
        await WriteAsync(message);
    }
    finally
    {
        Console.WriteLine($"NantokaAsync(After): _asyncLocalValue.Value = {_asyncLocalValue.Value}");
    }
}

コードを書き換えた後の実行結果は次のようになり、期待通り 123 のままであることを確認できます。

Main(Before): _asyncLocalValue.Value = 123
NantokaAsync(Before): _asyncLocalValue.Value = 123
WriteAsync(Before): _asyncLocalValue.Value = 456
WriteAsync: Hello Konnichiwa!
WriteAsync(After): _asyncLocalValue.Value = 567
NantokaAsync(After): _asyncLocalValue.Value = 456
Main(After): _asyncLocalValue.Value = 123

余談

非同期メソッドに書き換え前でも NantokaAsync の呼び出しの前後で ExecutionContext のキャプチャとリストアをしてあげると、期待通りになることがわかります。

var executionContext = ExecutionContext.Capture();
await NantokaAsync();
ExecutionContext.Restore(executionContext);

まとめ

繰り返しになりますがユーザーコードでは Task/ValueTask を直接返すよりも非同期メソッド (async/await) にしておいたほうが落とし穴に避けるのに有用です。

実際のところ using でのミスは大体すぐ気付きますし AsyncLocal の漏洩はレアケースなので、避けるべき理由の大半は例外時のスタックトレース問題で、これは実際の開発体験に直結してきますのでインパクトが大きいです。

一方で基盤的な下まわりのコードでは I/O をはじめとする高頻度で呼ばれるコードパスなどで Task/ValueTask を直接返す意味を持つケースもある、という点は知っておいて損はないかもしれません。

参考

Discussion