vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

【C#】Task の話 その 5

はじめに

今回は例外について。

async void と async Task

async/await に関連するアンチパターンの一つに、 「async void を使う」というものがあります。

前回取り上げた await は、戻り値が awaitable (例: TaskAwaiter) である必要があるため、 async void の場合 await できません。

これに起因して、結果がわからないから禁止、という内容となります。

…なんですが、どうしても一番の呼び出し元は async void になる場合があるよねぇ、とも思っていました。

特に Unity の場合、(今だと Job System なるものがあるというので状況が違うかもしれませんが) UI のイベントも同期的なものとして返ってきます。

そこから async/await で処理を実行しようとすれば、 async void を使うことになります。

では、 async void だとどのようにマズいのかを見てみることにします。

※とはいいつつググるといくらでも出てくるんですけど。

Exception がキャッチできない

結果が受け取られないということに類似して、 async void だと Exception がキャッチできない、という問題があります。

Program.cs

using System;
using System.Threading.Tasks;

namespace TaskSample
{
    class Program
    {
        static async Task Main(string[] args)
        {
            var player = new TaskSamplePlayer();
            
            // 呼び出し先で async/await 使用.
            // 呼び出し元も await 使用.
            try
            {
                await player.PlayAsync();
            }
            catch (Exception e)
            {
                Console.WriteLine("[1] " + e.Message);
            }
            // 呼び出し先では async/await を使用しない.
            // 呼び出し元では await 使用.
            try
            {
                await player.Play();
            }
            catch (Exception e)
            {
                Console.WriteLine("[2] " + e.Message);
            }
            // 呼び出し先では async/await を使用しない.
            // 呼び出し元でも await を使用しない.
            try
            {
                player.Play();
            }
            catch (Exception e)
            {
                Console.WriteLine("[3] " + e.Message);
            }
            // 呼び出し先で async void を使用.
            // void なので呼び出し元では await できない. 
            try
            {
                player.PlayVoidAsync();
            }
            catch (Exception e)
            {
                Console.WriteLine("[4] " + e.Message);
            }   
            Console.WriteLine("end");
        }       
    }
}

TaskSamplePlayer.cs

using System;
using System.Threading.Tasks;

namespace TaskSample
{
    public class TaskSamplePlayer
    {
        public async Task PlayAsync()
        {
            await Task.Run(() =>
            {
                throw new Exception("Error: PlayAsync");
            });
        }
        public Task Play()
        {
            return Task.Run(() => { 
                
                throw new Exception("Error: Play");
            });
        }
        public async void PlayVoidAsync()
        {
            await Task.Run(() =>
            {
                throw new Exception("Error: PlayVoidAsync");
            });
        }
    }
}

結果は下記の通りです。

[1] Error: PlayAsync
[2] Error: Play
end

await してないと try - catch で Exception がキャッチできない、という結果となりました。

理由はいつものごとく?メインスレッドのでの try - catch の処理が先に終わってしまい、後からワーカースレッドで例外が発生する状態になるからですね。

これを考えると、あるメソッドで async void を返す場合、そのメソッドを呼び出すメソッドではなく、自分自身でエラー処理の責務を負う必要があることになります。

複数の Task を実行する

async/await の Exception を調べる中で面白いものを見つけたので。

複数の Task を連続・または同時に実行する場合、その待ち方によって結果が変わる場合があります。

Program.cs

using System;
using System.Threading.Tasks;

namespace TaskSample
{
    class Program
    {
        static async Task Main(string[] args)
        {
            var player = new TaskSamplePlayer();
            
            Console.WriteLine("1");
            // await
            try
            {
                await player.PlayAsync();
                await player.PlayAsync2();
            }
            catch (Exception e)
            {
                Console.WriteLine("[1] " + e.GetType() + " [Message] " + e.Message);
            }
            Console.WriteLine("2");
            // Wait()
            try
            {
                player.PlayAsync().Wait();
                player.PlayAsync2().Wait();
            }
            catch (Exception e)
            {
                Console.WriteLine("[2] " + e.GetType() + " [Message] " + e.Message);
            }
            Console.WriteLine("3");
            // WaitAll()
            try
            {
                var p1 = player.PlayAsync();
                var p2 = player.PlayAsync2();

                Task.WaitAll(p1, p2);
            }
            catch (Exception e)
            {
                Console.WriteLine("[3] " + e.GetType() + " [Message] " + e.Message);
            }
            Console.WriteLine("4");
            // await WhenAll() 
            try
            {
                var p1 = player.PlayAsync();
                var p2 = player.PlayAsync2();

                await Task.WhenAll(p1, p2);
            }
            catch (Exception e)
            {
                Console.WriteLine("[4] " + e.GetType() + " [Message] " + e.Message);
            }   
            Console.WriteLine("end");
        }       
    }
}

TaskSamplePlayer.cs

using System;
using System.Threading.Tasks;

namespace TaskSample
{
    public class TaskSamplePlayer
    {
        public async Task PlayAsync()
        {
            Console.WriteLine("Start PlayAsync");
            await Task.Run(() =>
            {
                throw new ArgumentException("Error: PlayAsync");
            });
        }
        public async Task PlayAsync2()
        {
            Console.WriteLine("Start PlayAsync2");
            await Task.Run(() => 
            { 
                throw new InvalidOperationException("Error: PlayAsync2");
            });
        }
    }
}

2 つの Task を見分けるため、 Exception の内容を変えたりしていますが、内容は変わらないはずです。

で、結果はこうなります。

1
Start PlayAsync
[1] System.ArgumentException [Message] Error: PlayAsync

2
Start PlayAsync
[2] System.AggregateException [Message] One or more errors occurred. (Error: Pla
yAsync)

3
Start PlayAsync
Start PlayAsync2
[3] System.AggregateException [Message] One or more errors occurred. (Error: Pla
yAsync) (Error: PlayAsync2)

4
Start PlayAsync
Start PlayAsync2
[4] System.ArgumentException [Message] Error: PlayAsync

end

わかりづらいので説明を追加すると

1. await を使って Task をそれぞれ待った場合

2 つの Task の内最初のものだけが実行され、 Task 内で発生した ArgumentException が返っています。

2. Wait() を使って Task をそれぞれ待った場合

2 つの Task の内最初のものだけが実行され、 AggregateException が返っています。

3. WaitAll() を使って Task をまとめて待った場合

両方の Task が実行され、 AggregateException が返っています。

Message には両方の内容が含まれています。

4. await WhenAll() を使って Task をまとめて待った場合

両方の Task が実行され、 Message には両方の内容が含まれますが、 Exception の Type は最初の Task のものだけが含まれています。

Exception の投げ方

今回のサンプルでは 全部 Exception でキャッチする、という雑いやり方をしていたためキャッチできていましたが、ちゃんと Exception の種類ごとに受け取って適切な処理をしようとすると、待ち方にも気を配る必要がありますね。

どこからこの違いは生まれるのでしょうか。

ということでコードを追ってみますよ。

1. await を使って Task をそれぞれ待った場合

await ということで前回登場した TaskAwaiter かな?とも思ったのですが、名前通り Task の完了を待ち受けているだけで、関連しそうなコードは見つかりませんでした。

ということで Task を探ってみます。

なお Task には限らないと思いますが、基本的に Exception は ThrowHelper というクラスが一括で投げているようです。

https://github.com/dotnet/coreclr/blob/master/src/System.Private.CoreLib/shared/System/ThrowHelper.cs

例えば Task の InternalStartNew ではこのようにしています。

~省略~
if (scheduler == null)
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.scheduler);
~省略~

ThrowHelper の中身を見ると、大量の throw new XXXException と、 ExceptionArgument(enum) などが定義されていました。

ではその 2 も参考にコードを読み進めてみますよ。

ふむふむ。

特に変わったところはないな。。。

。。。

そりゃそうですね。 Task を一つだけ実行して、その中で発生した Exception がそのまま返ってるんだから orz

と、というわけで気を取り直して、 Wait() を見てみますよ。

2. Wait() を使って Task をそれぞれ待った場合

では Wait() ですね。

Wait() もオーバーロードされており、デフォルト値を入れて下記が呼ばれます。

Task.cs

~省略~
public bool Wait(int millisecondsTimeout, CancellationToken cancellationToken)
{
~省略~
    if (IsWaitNotificationEnabledOrNotRanToCompletion) // avoid a few unnecessary volatile reads if we completed successfully
    {
        // Notify the debugger of the wait completion if it's requested such a notification
        NotifyDebuggerOfWaitCompletionIfNecessary();

        // If cancellation was requested and the task was canceled, throw an 
        // OperationCanceledException.  This is prioritized ahead of the ThrowIfExceptional
        // call to bring more determinism to cases where the same token is used to 
        // cancel the Wait and to cancel the Task.  Otherwise, there's a race condition between
        // whether the Wait or the Task observes the cancellation request first,
        // and different exceptions result from the different cases.
        if (IsCanceled) cancellationToken.ThrowIfCancellationRequested();

        // If an exception occurred, or the task was cancelled, throw an exception.
        ThrowIfExceptional(true);
    }

    Debug.Assert((m_stateFlags & TASK_STATE_FAULTED) == 0, "Task.Wait() completing when in Faulted state.");

    return true;
}
~省略~

どうやら関係がありそうなのは ThrowIfExceptional のようです。

Task.cs

~省略~
internal void ThrowIfExceptional(bool includeTaskCanceledExceptions)
{
    Debug.Assert(IsCompleted, "ThrowIfExceptional(): Expected IsCompleted == true");

    Exception exception = GetExceptions(includeTaskCanceledExceptions);
    if (exception != null)
    {
        UpdateExceptionObservedStatus();
        throw exception;
    }
}
~省略~

この GetExceptions で AggregateException が返されています。

あとここでは返ってきた AggregateException をそのまま投げていますね。

3. WaitAll() を使って Task をまとめて待った場合

こちらは WaitAllCore() から AggregateException を投げているようです。

Task.cs

~省略~
private static bool WaitAllCore(Task[] tasks, int millisecondsTimeout, CancellationToken cancellationToken)
{
~省略~
    if (returnValue && (exceptionSeen || cancellationSeen))
    {
        // If the WaitAll was canceled and tasks were canceled but not faulted, 
        // prioritize throwing an OCE for canceling the WaitAll over throwing an 
        // AggregateException for all of the canceled Tasks.  This helps
        // to bring determinism to an otherwise non-determistic case of using
        // the same token to cancel both the WaitAll and the Tasks.
        if (!exceptionSeen) cancellationToken.ThrowIfCancellationRequested();

        // Now gather up and throw all of the exceptions.
        foreach (var task in tasks) AddExceptionsForCompletedTask(ref exceptions, task);
        Debug.Assert(exceptions != null, "Should have seen at least one exception");
        ThrowHelper.ThrowAggregateException(exceptions);
    }

    return returnValue;
}
~省略~

この辺りの扱いの違いは、どこから来ているのでしょうか。

気になるところではありますが、終わりそうにない気がするのでこの辺に留めておきます。

4. await WhenAll() を使って Task をまとめて待った場合

個人的にはこの結果が一番不思議な感じがします。

AggregateException が返るわけではないけど Message はまとめられているという。

Task.cs

~省略~
public static Task WhenAll(params Task[] tasks)
{
 ~省略~
    Task[] tasksCopy = new Task[taskCount];
    for (int i = 0; i < taskCount; i++)
    {
        Task task = tasks[i];
        if (task == null) ThrowHelper.ThrowArgumentException(ExceptionResource.Task_MultiTaskContinuation_NullTask, ExceptionArgument.tasks);
        tasksCopy[i] = task;
    }

    // The rest can be delegated to InternalWhenAll()
    return InternalWhenAll(tasksCopy);
}
~省略~

Task.cs

~省略~
private static Task InternalWhenAll(Task[] tasks)
{
    Debug.Assert(tasks != null, "Expected a non-null tasks array");
    return (tasks.Length == 0) ? // take shortcut if there are no tasks upon which to wait
        Task.CompletedTask :
        new WhenAllPromise(tasks);
}
~省略~

tasks の要素数は 0 ではない( 2 )ため、 WhenAllPromise のインスタンスが返されます。

Task.cs

~省略~
private sealed class WhenAllPromise : Task, ITaskCompletionAction
{
~省略~
    internal WhenAllPromise(Task[] tasks) :
        base()
    {
~省略~
        m_tasks = tasks;
        m_count = tasks.Length;

        foreach (var task in tasks)
        {
            if (task.IsCompleted) this.Invoke(task); // short-circuit the completion action, if possible
            else task.AddCompletionAction(this); // simple completion action
        }
    }

    public void Invoke(Task completedTask)
    {
~省略~
        // Decrement the count, and only continue to complete the promise if we're the last one.
        if (Interlocked.Decrement(ref m_count) == 0)
        {
            // Set up some accounting variables
            List observedExceptions = null;
            Task canceledTask = null;

            // Loop through antecedents:
            //   If any one of them faults, the result will be faulted
            //   If none fault, but at least one is canceled, the result will be canceled
            //   If none fault or are canceled, then result will be RanToCompletion
            for (int i = 0; i < m_tasks.Length; i++)
            {
                var task = m_tasks[i];
                Debug.Assert(task != null, "Constituent task in WhenAll should never be null");

                if (task.IsFaulted)
                {
                    if (observedExceptions == null) observedExceptions = new List();
                    observedExceptions.AddRange(task.GetExceptionDispatchInfos());
                }
                else if (task.IsCanceled)
                {
                    if (canceledTask == null) canceledTask = task; // use the first task that's canceled
                }

                // Regardless of completion state, if the task has its debug bit set, transfer it to the
                // WhenAll task.  We must do this before we complete the task.
                if (task.IsWaitNotificationEnabled) this.SetNotificationForWaitCompletion(enabled: true);
                else m_tasks[i] = null; // avoid holding onto tasks unnecessarily
            }

            if (observedExceptions != null)
            {
                Debug.Assert(observedExceptions.Count > 0, "Expected at least one exception");

                //We don't need to TraceOperationCompleted here because TrySetException will call Finish and we'll log it there

                TrySetException(observedExceptions);
            }
~省略~
        }
        Debug.Assert(m_count >= 0, "Count should never go below 0");
    }
~省略~
}
~省略~

待つ対象となっている Task を一つずつ実行し、失敗したら ExceptionDispatchInfo (発生した Exception の保持やその Exception を投げたりするようです)に追加し、 1 つ以上 Exception があれば TrySetException() に渡しているようです。

https://github.com/dotnet/coreclr/blob/master/src/System.Private.CoreLib/shared/System/Runtime/ExceptionServices/ExceptionDispatchInfo.cs

で、ここから若干ややこしいのですが、この TrySetException() というのは Task< TResult> クラスで定義されているのですが、このクラスは Future.cs にあります。

https://github.com/dotnet/coreclr/blob/master/src/System.Private.CoreLib/shared/System/Threading/Tasks/Future.cs

Future.cs

~省略~
internal bool TrySetException(object exceptionObject)
{
~省略~
    bool returnValue = false;

    // "Reserve" the completion for this task, while making sure that: (1) No prior reservation
    // has been made, (2) The result has not already been set, (3) An exception has not previously 
    // been recorded, and (4) Cancellation has not been requested.
    //
    // If the reservation is successful, then add the exception(s) and finish completion processing.
    //
    // The lazy initialization may not be strictly necessary, but I'd like to keep it here
    // anyway.  Some downstream logic may depend upon an inflated m_contingentProperties.
    EnsureContingentPropertiesInitialized();
    if (AtomicStateUpdate(TASK_STATE_COMPLETION_RESERVED,
        TASK_STATE_COMPLETION_RESERVED | TASK_STATE_RAN_TO_COMPLETION | TASK_STATE_FAULTED | TASK_STATE_CANCELED))
    {
        AddException(exceptionObject); // handles singleton exception or exception collection
        Finish(false);
        returnValue = true;
    }

    return returnValue;
}
~省略~

ここでは AddException() で ExceptionDispatchInfo を追加していますが、このメソッドは Task クラスにあります。

AddException() もオーバーロードがあり、第二引数に false を設定して下記を呼びます。

Task.cs

~省略~
internal void AddException(object exceptionObject, bool representsCancellation)
{
~省略~

    // Lazily initialize the holder, ensuring only one thread wins.
    var props = EnsureContingentPropertiesInitialized();
    if (props.m_exceptionsHolder == null)
    {
        TaskExceptionHolder holder = new TaskExceptionHolder(this);
        if (Interlocked.CompareExchange(ref props.m_exceptionsHolder, holder, null) != null)
        {
            // If someone else already set the value, suppress finalization.
            holder.MarkAsHandled(false);
        }
    }

    lock (props)
    {
        props.m_exceptionsHolder.Add(exceptionObject, representsCancellation);
    }
}
~省略~

ここでは TaskExceptionHolder に ExceptionDispatchInfo を追加しています。

https://github.com/dotnet/coreclr/blob/master/src/System.Private.CoreLib/shared/System/Threading/Tasks/TaskExceptionHolder.cs

Add() では第二引数の値が false のため AddFaultException() を呼んでいます。

TaskExceptionHolder.cs

~省略~
private void AddFaultException(object exceptionObject)
{
    Debug.Assert(exceptionObject != null, "AddFaultException(): Expected a non-null exceptionObject");

    // Initialize the exceptions list if necessary.  The list should be non-null iff it contains exceptions.
    var exceptions = m_faultExceptions;
    if (exceptions == null) m_faultExceptions = exceptions = new List(1);
    else Debug.Assert(exceptions.Count > 0, "Expected existing exceptions list to have > 0 exceptions.");

    // Handle Exception by capturing it into an ExceptionDispatchInfo and storing that
    if (exceptionObject is Exception exception)
    {
        exceptions.Add(ExceptionDispatchInfo.Capture(exception));
    }
    else
    {
        // Handle ExceptionDispatchInfo by storing it into the list
        if (exceptionObject is ExceptionDispatchInfo edi)
        {
            exceptions.Add(edi);
        }
        else
        {
            // Handle enumerables of exceptions by capturing each of the contained exceptions into an EDI and storing it
            if (exceptionObject is IEnumerable exColl)
            {
~省略~
                foreach (var exc in exColl)
                {
~省略~
                    exceptions.Add(ExceptionDispatchInfo.Capture(exc));
                }
~省略~
            }
            else
            {
                // Handle enumerables of EDIs by storing them directly
                if (exceptionObject is IEnumerable ediColl)
                {
                    exceptions.AddRange(ediColl);
~省略~
                }
                // Anything else is a programming error
                else
                {
                    throw new ArgumentException(SR.TaskExceptionHolder_UnknownExceptionType, nameof(exceptionObject));
                }
            }
        }
    }

    if (exceptions.Count > 0)
        MarkAsUnhandled();
}
~省略~

ここでは ExceptionDispatchInfo のリストに値を追加しています。

で、先ほどから出てきている MarkAsHandled() 、 MarkAsUnhandled() というのは GC のファイナライズを実行する・しないを指定しているようです。

ファイナライズで何が実行されるかというと

TaskExceptionHolder.cs

~省略~
~TaskExceptionHolder()
{
    if (m_faultExceptions != null && !m_isHandled)
    {
        // We will only propagate if this is truly unhandled. The reason this could
        // ever occur is somewhat subtle: if a Task's exceptions are observed in some
        // other finalizer, and the Task was finalized before the holder, the holder
        // will have been marked as handled before even getting here.

        // Publish the unobserved exception and allow users to observe it
        AggregateException exceptionToThrow = new AggregateException(
            SR.TaskExceptionHolder_UnhandledException,
            m_faultExceptions);
        UnobservedTaskExceptionEventArgs ueea = new UnobservedTaskExceptionEventArgs(exceptionToThrow);
        TaskScheduler.PublishUnobservedTaskException(m_task, ueea);
    }
}
~省略~

TaskScheduler.PublishUnobservedTaskException() で何をしているかというと、 UnobservedTaskExceptionEventArgs というイベントを実行しています。

ではこの UnobservedTaskException とは何かというと、 Task 内で発生し、処理がされないままの Exception (無視された Exception) をまとめて Exception として投げるためのイベント、ということのようです。

この辺りの Exception の扱いの違いが、結果の違いとなって現れているようですね。

AggregateException とは

Wait() や WaitAll() で使われていた、 AggregateException について少しだけ。

Microsoft Docsによると、アプリケーションの実行中に発生した、 1 つ以上のエラーを表す、とのこと。

なるほどわからん

この辺りの説明を読むと、 複数の Task がそれぞれ非同期で Exception を出しうる場合に、それらを一括で扱えるようにするために AggregateException が使われている、ということのようです。

今回の例では Wait() は単体の Task で使いましたが、親子関係を持たせた場合にこれが必要になるようです。

await では親子関係は持てないの?という疑問はありますが、この辺りは次回以降試してみたいと思います。

なお、 AggregateException が返る場合、 InnerExceptions で個別の Exception を取り出せたり、 AggregateException に親子関係がある場合も Flatten() を使ってまとめて取り出せたりするため便利ですね。

ということで雑に Exception でキャッチせず、ちゃんと AggregateException を指定しましょう(戒め)。

参照

AggregateException

UnobservedTaskException