【C#】Task.WhenAnyは例外スローしない話&正しくハンドリングする方法

C#のTask.WhenAnyで例外を正しくハンドリングする方法です。

WhenAnyは例外をスローしない

.NETでは、複数のTaskを待機する方法として、全てのTaskの完了を待機するTask.WhenAllといずれかのTaskの完了を待機するTask.WhenAnyがあります。

このうちTask.WhenAllは、待機するTaskのうちのいずれかが例外をスローしたときに、このWhenAllが返すTaskも例外をスローします。

public sealed class Tests
{
    [Test]
    public async Task WhenAllTest()
    {
        var foo = FooAsync(); // 例外を吐くTask
        var bar = BarAsync();
        // WhenAllで両方が終わるまで待機
        var task = Task.WhenAll(foo, bar);
        Exception exception = null;
        try
        {
            await task;
        }
        catch (Exception e)
        {
            exception = e;
        }

        // 例外がスローされたことを確認
        Assert.That(exception, Is.Not.Null);
    }

    private async Task FooAsync()
    {
        await Task.Run(() => throw new Exception("Foo Exception"));
    }

    private async Task BarAsync()
    {
        await Task.Delay(100);
    }
}

(実際にはここでスローされる例外は「元となるTaskがスローした例外たちのうちのいずれか」になるので、ちゃんとハンドリングするにはTask.Exception (AggregateException)を見る必要がありますがそのあたりは割愛します

さてこれに対してTask.WhenAnyは、元となるTaskが例外をスローしてもWhenAnyのTaskは例外をスローしません。

public sealed class Tests
{
    [Test]
    public async Task WhenAnyTest()
    {
        var foo = FooAsync(); // 例外を吐くTask
        var bar = BarAsync();
        // WhenAnyでどちらかが終わるまで待機
        // 例外を吐くFooAsyncの方が先に終わる実装にしてある
        var task = Task.WhenAny(foo, bar);
        Exception exception = null;
        try
        {
            await task;
        }
        catch (Exception e)
        {
            exception = e;
        }

        // 例外がスローされていないことを確認
        Assert.That(exception, Is.Null);
    }

    private async Task FooAsync()
    {
        await Task.Run(() => throw new Exception("Foo Exception"));
    }

    private async Task BarAsync()
    {
        await Task.Delay(100);
    }
}

このようにTask.WhenAnyは元となるTaskが例外をスローしていたとしても例外を吐かずに常に正常に完了状態になります。

WhenAnyで正しく例外をハンドリングする

WhenAnyをawaitすると、最初に完了したTaskを得ることができます。
このTaskを以下のようにawait(つまり2回await)すると、例外が発生している場合には例外がスローされるので、ハンドリングすることができます。

public sealed class Tests
{
    [Test]
    public async Task WhenAnyTest()
    {
        var foo = FooAsync(); // 例外を吐くTask
        var bar = BarAsync();
        // WhenAnyをawaitすると最初に完了したTask(今回はfoo)を取得できる
        var completedTask = await Task.WhenAny(foo, bar);
        
        // 最初に完了したTaskをawaitすると例外をスローすることを確認
        Exception? exception = null;
        try
        {
            await completedTask;
        }
        catch (Exception e)
        {
            exception = e;
        }

        Assert.That(exception, Is.Not.Null);
        Assert.That(exception?.Message, Is.EqualTo("Foo Exception"));
    }

    private async Task FooAsync()
    {
        await Task.Run(() => throw new Exception("Foo Exception"));
    }

    private async Task BarAsync()
    {
        await Task.Delay(100);
    }
}

またもちろん以下のように、各Taskの状態を見てハンドリングすることも可能です。

public sealed class Tests
{
    [Test]
    public async Task WhenAnyTest()
    {
        var foo = FooAsync();
        var bar = BarAsync();
        var completedTask = await Task.WhenAny(foo, bar);
        
        Assert.Multiple(() =>
        {
            Assert.That(completedTask, Is.EqualTo(foo));
            Assert.That(foo.IsCompleted, Is.True);
            Assert.That(bar.IsCompleted, Is.False);
            Assert.That(foo.IsFaulted, Is.True);
            Assert.That(bar.IsFaulted, Is.False);
            Assert.That(foo.Exception!.InnerException!.Message, Is.EqualTo("Foo Exception"));
        });
    }

    private async Task FooAsync()
    {
        await Task.Run(() => throw new Exception("Foo Exception"));
    }

    private async Task BarAsync()
    {
        await Task.Delay(100);
    }
}

参考

learn.microsoft.com

learn.microsoft.com