Перейти к содержанию

8. Исключения в async

О главе

Цель: понять по коду рантайма, куда девается исключение async-метода, как его достаёт await и чем это отличается от .Result, что делает Task.WhenAll с несколькими исключениями, почему async void роняет процесс и когда исключение превращается в отмену.

Лабораторная: start/ — три опыта с предсказанием и два TODO (валидация аргументов, все исключения WhenAll). final/ — семь опытов: хранилище исключений задачи, валидация, три способа достать исключение, WhenAll, async void, невыясненные исключения, исключение против отмены. Два сценария падения процесса запускаются отдельно. Код — в конце главы.

Статус: ✅ проверено на Ubuntu 26.04 (2 ядра), рантайм 10.0.12. Листинги CoreLib — декомпиляция System.Private.CoreLib 10.0.12 (.\tools\disasm.ps1 -Assembly corelib -Type …), листинги лабораторной — .\tools\disasm.ps1 chapters\08-exceptions\final -Mode cs. Перепроверено на Windows 11 (8 ядер): вывод совпадает, кроме номеров потоков и кода выхода при падении (§8.5).

8.1. Где «живёт» исключение

Исключение async-метода не вылетает из вызова. Оно попадает в Task, который метод вернул. Вызывающий увидит его только когда достанет: await, .Wait(), .Result или task.Exception. Это верно и для исключения, брошенного до первого await.

Что делает компилятор

Возьмём самый короткий метод (final/Program.cs, строки 116–119):

final/Program.cs
static async Task FailAsync()
{
    throw new InvalidOperationException("boom");   // до первого await
}

Вот во что он скомпилирован (C# без сахара, MoveNext машины состояний, Release):

FailAsync после компиляции: MoveNext
private void MoveNext()
{
    try
    {
        throw new InvalidOperationException("boom");
    }
    catch (Exception exception)
    {
        <>1__state = -2;
        <>t__builder.SetException(exception);
    }
}
  • Строки 3–6: всё тело метода обёрнуто в try. Эта обёртка есть у любого async-метода, независимо от того, есть ли в нём свой try. В главе 1 она была в конце MoveNext.
  • Строки 7–10: любое исключение тела перехватывается. Состояние становится -2 («завершён»), а исключение отдаётся builder'у.
  • Методу не нужно ни await, ни приостановки: MoveNext вызвала заглушка, Start его выполнил, исключение уже записано в задачу. Поэтому после вызова FailAsync() задача уже в состоянии Faulted.

Что делает builder

CoreLib 10.0.12: AsyncTaskMethodBuilder<TResult>.SetException
internal static void SetException(Exception exception, ref Task<TResult> taskField)
{
    if (exception == null)
    {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.exception);
    }
    Task<TResult> task = taskField ?? (taskField = new Task<TResult>());
    if (!((exception is OperationCanceledException ex) ? task.TrySetCanceled(ex.CancellationToken, ex) : task.TrySetException(exception)))
    {
        ThrowHelper.ThrowInvalidOperationException(ExceptionResource.TaskT_TransitionToFinal_AlreadyCompleted);
    }
}
  • Строка 7: если метод ни разу не приостанавливался, бокса нет, и задача создаётся здесь же, «обещание» без кода (глава 4). Заглушка потом вернёт её через builder.Task.
  • Строка 8: развилка. OperationCanceledException превращает задачу в Canceled (§8.7), любое другое исключение — в Faulted.
  • Строки 9–11: завершить задачу дважды нельзя.

TrySetException — тот же механизм завершения, что в главе 4 (AtomicStateUpdate, затем Finish запускает продолжения). Отличие в том, что вместо результата в задачу записывается исключение:

CoreLib 10.0.12: Task.TrySetException и Task.AddException
internal bool TrySetException(object exceptionObject)
{
    bool result = false;
    EnsureContingentPropertiesInitialized();
    if (AtomicStateUpdate(67108864, 90177536))
    {
        AddException(exceptionObject);
        Finish(userDelegateExecute: false);
        result = true;
    }
    return result;
}

internal void AddException(object exceptionObject, bool representsCancellation)
{
    ContingentProperties contingentProperties = EnsureContingentPropertiesInitialized();
    if (contingentProperties.m_exceptionsHolder == null)
    {
        TaskExceptionHolder taskExceptionHolder = new TaskExceptionHolder(this);
        if (Interlocked.CompareExchange(ref contingentProperties.m_exceptionsHolder, taskExceptionHolder, null) != null)
        {
            taskExceptionHolder.MarkAsHandled(calledFromFinalizer: false);
        }
    }
    lock (contingentProperties)
    {
        contingentProperties.m_exceptionsHolder.Add(exceptionObject, representsCancellation);
    }
}
  • Строка 4: исключения живут в «редких» данных ContingentProperties (глава 4, поле m_contingentProperties). Теперь они создаются.
  • Строка 5: право завершить задачу резервируется той же маской 0x5600000, что и в TrySetResult.
  • Строки 17–19: первое исключение создаёт хранилище TaskExceptionHolder и кладёт в m_exceptionsHolder.
  • Строки 25–27: исключение добавляется под lock: исключений может быть несколько (WhenAll, §8.4).

Хранилище исключений

CoreLib 10.0.12: TaskExceptionHolder (сокращено)
internal sealed class TaskExceptionHolder
{
    private readonly Task m_task;
    private volatile List<ExceptionDispatchInfo> m_faultExceptions;
    private ExceptionDispatchInfo m_cancellationException;
    private volatile bool m_isHandled;

    ~TaskExceptionHolder()
    {
        if (m_faultExceptions != null && !m_isHandled)
        {
            UnobservedTaskExceptionEventArgs ueea = new UnobservedTaskExceptionEventArgs(new AggregateException(SR.TaskExceptionHolder_UnhandledException, m_faultExceptions));
            TaskScheduler.PublishUnobservedTaskException(m_task, ueea);
        }
    }

    private void AddFaultException(object exceptionObject)
    {
        List<ExceptionDispatchInfo> list = m_faultExceptions ?? (m_faultExceptions = new List<ExceptionDispatchInfo>(1));
        if (exceptionObject is Exception source)
        {
            list.Add(ExceptionDispatchInfo.Capture(source));
        }
        ...                                     // ExceptionDispatchInfo и коллекции — для WhenAll
        if (list.Count > 0)
        {
            MarkAsUnhandled();
        }
    }

    internal void MarkAsHandled(bool calledFromFinalizer)
    {
        if (!m_isHandled)
        {
            if (!calledFromFinalizer)
            {
                GC.SuppressFinalize(this);
            }
            m_isHandled = true;
        }
    }

    internal List<ExceptionDispatchInfo> GetExceptionDispatchInfos()
    {
        List<ExceptionDispatchInfo> faultExceptions = m_faultExceptions;
        MarkAsHandled(calledFromFinalizer: false);
        return faultExceptions;
    }
}
  • Строка 4: исключения хранятся не как Exception, а как ExceptionDispatchInfo: это исключение вместе с захваченным стеком, которое можно перебросить, не затирая его.
  • Строка 6: флаг m_isHandled («исключение кто-то выяснил»). Пока он false, у хранилища работает финализатор.
  • Строки 8–15: финализатор. Если задача с исключением собрана сборщиком мусора, а m_isHandled так и остался false, исключение публикуется событием TaskScheduler.UnobservedTaskException (§8.6).
  • Строки 31–48: любое чтение исключения (await, .Result, .Exception) ставит m_isHandled = true и отключает финализатор.

Опыт: хранилище до и после await

final/Program.cs
Console.WriteLine("== 1. Где живёт исключение ==");
Task t = FailAsync();
Console.WriteLine($"  вызов завершён, t.Status = {t.Status}");
Console.WriteLine($"  {Holder.Describe(t)}");
try { await t; }
catch (Exception e) { Console.WriteLine($"  поймано при await: {e.Message}"); }
Console.WriteLine($"  после await: {Holder.Describe(t)}");

Holder.Describe читает поля Task через отражение (имена из листингов выше, детали реализации 10.0.12).

1
2
3
4
5
== 1. Где живёт исключение ==
  вызов завершён, t.Status = Faulted
  хранилище есть, исключений в нём: 1, обработано (m_isHandled): False
  поймано при await: boom
  после await: хранилище есть, исключений в нём: 1, обработано (m_isHandled): True
  • Строка 2 (строка 29 кода): Faulted сразу после вызова, ни одного await.
  • Строка 3 (строка 30): исключение лежит в задаче, и никто о нём не знает (m_isHandled = False).
  • Строка 5 (строка 33): после await оно «обработано». Исключение не исчезло: await взял его из хранилища и перебросил (§8.3).

8.2. Ловушка валидации аргументов

Из §8.1 следует неприятное: throw в начале async-метода тоже уходит в Task.

1
2
3
4
5
6
7
static async Task SaveAsync(string name)
{
    if (name == null) throw new ArgumentNullException(nameof(name));   // уйдёт в Task!
    await Task.Delay(1);
}

var t = SaveAsync(null);       // не бросило; ошибка проявится позже, и только если кто-то дождётся задачи

Вызывающий получил Faulted-задачу и мог её даже не ждать. Для публичных API проверку аргументов выносят в обычный (не async) метод, а работу — в локальную async-функцию:

final/Program.cs
static Task SaveAsync(string name)
{
    ArgumentNullException.ThrowIfNull(name);       // бросит сразу, синхронно
    return Core();

    async Task Core() { await Task.Delay(1); }
}
  • Строка 123: обычный метод бросает синхронно, в момент вызова, как и принято для ошибок вызывающего.
  • Строки 124, 126: работа в локальной async-функции Core. Для неё ThrowIfNull уже выполнен.

Вывод опыта 2: SaveAsync(null) бросил сразу. Версия на async-методе из start/ печатает SaveAsync(null) не бросил, Status = Faulted.

Задание (TODO 1)

В start/Program.cs метод SaveAsync — async, и SaveAsync(null) не бросает. Исправьте так, чтобы опыт 2 печатал «бросил сразу».

8.3. Как await достаёт исключение

await вызывает awaiter.GetResult(). Для задачи это TaskAwaiter.ValidateEnd → HandleNonSuccessAndDebuggerNotification → ThrowForNonSuccess (первые два мы видели в главе 5):

CoreLib 10.0.12: TaskAwaiter.ThrowForNonSuccess
private static void ThrowForNonSuccess(Task task)
{
    switch (task.Status)
    {
    case TaskStatus.Canceled:
        task.GetCancellationExceptionDispatchInfo()?.Throw();
        throw new TaskCanceledException(task);
    case TaskStatus.Faulted:
    {
        List<ExceptionDispatchInfo> exceptionDispatchInfos = task.GetExceptionDispatchInfos();
        if (exceptionDispatchInfos.Count > 0)
        {
            exceptionDispatchInfos[0].Throw();
            break;
        }
        throw task.Exception;
    }
    }
}
  • Строка 10: GetExceptionDispatchInfos отдаёт список и ставит m_isHandled (строки 43–48 листинга хранилища).
  • Строка 13: перебрасывается только первый элемент списка, и без обёртки: вызывающий получает исходный тип (InvalidOperationException), а не AggregateException. ExceptionDispatchInfo.Throw() восстанавливает сохранённый стек, дописывая к нему кадры места, где сделан await.
  • Строки 5–7: отменённая задача бросает сохранённое OperationCanceledException, а если его нет, то новое TaskCanceledException (§8.7).

Путь .Result другой:

CoreLib 10.0.12: Task<TResult>.GetResultCore, Task.ThrowIfExceptional и Task.GetExceptions
internal TResult GetResultCore(bool waitCompletionNotification)
{
    if (!IsCompleted)
    {
        InternalWait(-1, default);
    }
    ...
    if (!IsCompletedSuccessfully)
    {
        ThrowIfExceptional(includeTaskCanceledExceptions: true);
    }
    return m_result;
}

internal void ThrowIfExceptional(bool includeTaskCanceledExceptions)
{
    Exception exceptions = GetExceptions(includeTaskCanceledExceptions);
    if (exceptions != null)
    {
        UpdateExceptionObservedStatus();
        throw exceptions;
    }
}

private AggregateException GetExceptions(bool includeTaskCanceledExceptions)
{
    ...
    if (ExceptionRecorded)
    {
        return m_contingentProperties.m_exceptionsHolder.CreateExceptionObject(calledFromFinalizer: false, ex);
    }
    ...
}
  • Строки 3–6: .Result сначала блокирует поток, если задача не готова (глава 6).
  • Строки 10, 17–21, 28–30: создаёт новый AggregateException со всеми исключениями хранилища и бросает его. CreateExceptionObject тоже ставит m_isHandled.

Сводка:

Способ Что бросает Сколько исключений видно
await task исходное исключение только первое из списка
task.GetAwaiter().GetResult() то же, что await только первое
task.Result / task.Wait() AggregateException со всеми внутри все
task.Exception ничего не бросает, возвращает AggregateException все

Опыт: три способа и стек

final/Program.cs
Console.WriteLine("== 3. await против .Result против GetAwaiter().GetResult() ==");
Task<int> deep = Level1Async();
try { await deep; } catch (Exception e) { Show("await", e); Console.WriteLine(Frames(e)); }
try { _ = deep.Result; } catch (Exception e) { Show(".Result", e); Console.WriteLine(Frames(e)); }
try { _ = deep.GetAwaiter().GetResult(); } catch (Exception e) { Show("GetAwaiter().GetResult()", e); }
Task<int> plain = Task.Run(Thrower);
try { _ = plain.GetAwaiter().GetResult(); } catch (Exception e) { Show("GetResult после обычного метода", e); Console.WriteLine(Frames(e)); }
== 3. await против .Result против GetAwaiter().GetResult() ==
  await                             : InvalidOperationException, маркеров «End of stack trace»: 0
      at Program.<<Main>$>g__Level2Async|0_13()
      at Program.<<Main>$>g__Level1Async|0_12()
      at Program.<Main>$(String[] args)
  .Result                           : AggregateException, маркеров «End of stack trace»: 0
      at Program.<<Main>$>g__Level2Async|0_13()
      at Program.<<Main>$>g__Level1Async|0_12()
      at Program.<Main>$(String[] args)
  GetAwaiter().GetResult()          : InvalidOperationException, маркеров «End of stack trace»: 0
  GetResult после обычного метода   : InvalidOperationException, маркеров «End of stack trace»: 2
      at Program.<<Main>$>g__Thrower|0_14()
      at System.Threading.Tasks.Task`1.InnerInvoke()
      at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
      --- End of stack trace from previous location ---
      at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
      at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread)
      --- End of stack trace from previous location ---
      at Program.<Main>$(String[] args)
  • Строки 2, 6 (строки 47–48 кода): тип исключения — по таблице выше. У .Result показан стек внутреннего исключения: на уровне AggregateException стек свой (там GetResultCore).
  • Строки 3–5, 7–9: стек исключения из цепочки Level1Async → Level2Async склеен в логическую цепочку: Level2Async (где бросили), Level1Async, Main. Кадров MoveNext и машины состояний нет.

Факт: маркера «End of stack trace from previous location» в обычной цепочке await нет

В первой версии курса стояло: «в стеке вы видите разорванные участки с пометкой --- End of stack trace from previous location ---». В .NET 10 для цепочки async-методов маркера нет (строка 2 вывода: 0 маркеров). Он появляется, когда «чужой» участок стека заканчивается обычным методом (строки 11–19 вывода: Task.Run с синхронным делегатом, там два маркера).

Причина — в StackTrace.ToString:

CoreLib 10.0.12: StackTrace.ToString (фрагменты)
if (declaringType != null && IsDefinedSafe(declaringType, typeof(CompilerGeneratedAttribute), inherit: false))
{
    flag2 = declaringType.IsAssignableTo(typeof(IAsyncStateMachine));
    if (flag2 || declaringType.IsAssignableTo(typeof(IEnumerator)))
    {
        flag3 = TryResolveStateMachineMethod(ref method, out declaringType);
    }
}
...
if (frame.IsLastFrameFromForeignExceptionStackTrace && !flag2)
{
    sb.AppendLine();
    sb.Append("--- End of stack trace from previous location ---");
}
  • Строки 1–7: кадры машин состояний переименовываются в методы, из которых они получены (Level2Async вместо <Level2Async>d__1.MoveNext).
  • Строка 10: && !flag2. После последнего кадра «чужого» участка маркер печатается, если этот кадр не метод машины состояний. Каждый кадр в цепочке await — как раз машина, поэтому участки склеиваются бесшовно.

Поэтому «разорванный» стек теперь означает не await, а границу через обычные методы: делегат в Task.Run, обработчик события, Thread.

8.4. Несколько исключений: WhenAll

Task.WhenAll создаёт задачу-обещание (WhenAllPromise) и подписывает её на все задачи. Когда завершилась последняя, она собирает исключения упавших:

CoreLib 10.0.12: WhenAllPromise.Invoke (сокращено): Task.WhenAll(Task[]) и Task.WhenAll<T>(Task<T>[])
// WhenAll(Task…)  — необобщённый
public void Invoke(Task completedTask)
{
    if (completedTask != null)
    {
        if (!completedTask.IsCompletedSuccessfully)
        {
            object obj = Interlocked.CompareExchange(ref m_stateObject, completedTask, null);
            if (obj != null)
            {
                ...                                   // второй и следующие: в List<Task>, в порядке ЗАВЕРШЕНИЯ
            }
        }
    }
    if (Interlocked.Decrement(ref _remainingToComplete) != 0)
    {
        return;
    }
    ...
    if (observedExceptions != null)
    {
        TrySetException(observedExceptions);
    }
    else if (canceledTask != null)
    {
        TrySetCanceled(canceledTask.CancellationToken, canceledTask.GetCancellationExceptionDispatchInfo());
    }
    ...
}

// WhenAll<T>(Task<T>…)  — обобщённый
for (int i = 0; i < m_tasks.Length; i++)
{
    Task<T> task2 = m_tasks[i];
    if (task2.IsFaulted)
    {
        list.AddRange(task2.GetExceptionDispatchInfos());     // в порядке ИНДЕКСОВ
    }
    ...
}
  • Строки 4–14: необобщённый WhenAll запоминает неуспешные задачи по мере завершения: в m_stateObject сначала лежит одна задача, потом список.
  • Строки 20–27: есть хоть одно исключение — итоговая задача Faulted, и в ней все исключения. Отменённая задача учитывается, только если упавших нет.
  • Строки 32–38: обобщённый WhenAll<T> проходит по задачам в порядке аргументов.
  • Строка 37: GetExceptionDispatchInfos ставит m_isHandled у исходных задач. Исключения теперь «принадлежат» WhenAll; отдельно их ловить не нужно.

Опыт: порядок исключений

final/Program.cs
Console.WriteLine("== 4. WhenAll ==");
var slow = Task.Run(async () => { await Task.Delay(200); throw new InvalidOperationException("медленная, первая в списке"); });
var fast = Task.Run(async () => { await Task.Delay(50); throw new ArgumentException("быстрая, вторая в списке"); });
Task all = Task.WhenAll(slow, fast);
try { await all; }
catch (Exception e) { Console.WriteLine($"  await дал только: {e.GetType().Name}: {e.Message}"); }
foreach (var inner in all.Exception!.InnerExceptions)
    Console.WriteLine($"  в all.Exception: {inner.GetType().Name}: {inner.Message}");
var slowInt = Task.Run<int>(async () => { await Task.Delay(200); throw new InvalidOperationException("медленная, первая в списке"); });
var fastInt = Task.Run<int>(async () => { await Task.Delay(50); throw new ArgumentException("быстрая, вторая в списке"); });
Task<int[]> allInt = Task.WhenAll(slowInt, fastInt);
try { await allInt; }
catch (Exception e) { Console.WriteLine($"  Task<T>: await дал только: {e.GetType().Name}"); }
Console.WriteLine($"  Task<T>: порядок в all.Exception: {string.Join(", ", allInt.Exception!.InnerExceptions.Select(x => x.GetType().Name))}");
using (var cts = new CancellationTokenSource())
{
    cts.Cancel();
    Task canceled = Task.FromCanceled(cts.Token);
    Task mixed = Task.WhenAll(canceled, Task.FromException(new InvalidOperationException("упала")));
    try { await mixed; } catch (Exception) { }
    Console.WriteLine($"  WhenAll(отменённая, упавшая): Status = {mixed.Status}");
}
1
2
3
4
5
6
7
== 4. WhenAll ==
  await дал только: ArgumentException: быстрая, вторая в списке
  в all.Exception: ArgumentException: быстрая, вторая в списке
  в all.Exception: InvalidOperationException: медленная, первая в списке
  Task<T>: await дал только: InvalidOperationException
  Task<T>: порядок в all.Exception: InvalidOperationException, ArgumentException
  WhenAll(отменённая, упавшая): Status = Faulted

Факт: какое исключение даст await Task.WhenAll(...), зависит от того, обычная это задача или Task<T>

Две задачи падают с исключениями: «медленная» (первая в списке, 200 мс) и «быстрая» (вторая, 50 мс).

  • WhenAll(Task, Task) (строки 55–59 кода): await бросил ArgumentException — исключение быстрой, то есть задачи, упавшей раньше. Первой в all.Exception тоже она.
  • WhenAll<T>(Task<T>, Task<T>) (строки 62–67): await бросил InvalidOperationException — первой в списке аргументов.

Результат одинаков во всех прогонах. Полагаться на порядок нельзя: если нужно знать все ошибки, читайте all.Exception.InnerExceptions (удержите ссылку на задачу WhenAll, не только await ею). Значение имеет и то, что await вообще достаёт только первое исключение: остальные видны только через Exception.

  • Строки 68–75 кода, строка 7 вывода: WhenAll(отменённая, упавшая) завершается как Faulted, а не Canceled: исключение важнее отмены.

Задание (TODO 2)

В start/Program.cs опыт 3 печатает только тип первого исключения. Выведите все исключения из WhenAll: для этого понадобится ссылка на задачу WhenAll, а не только await.

8.5. async void

У async void нет Task: возвращаться нечему, и исключение некому записать. Что делает его builder:

CoreLib 10.0.12: AsyncVoidMethodBuilder.SetException и Task.ThrowAsync
// AsyncVoidMethodBuilder.SetException
SynchronizationContext synchronizationContext = _synchronizationContext;
if (synchronizationContext != null)
{
    try
    {
        System.Threading.Tasks.Task.ThrowAsync(exception, synchronizationContext);
    }
    finally
    {
        NotifySynchronizationContextOfCompletion(synchronizationContext);
    }
}
else
{
    System.Threading.Tasks.Task.ThrowAsync(exception, null);
}

// Task.ThrowAsync
internal static void ThrowAsync(Exception exception, SynchronizationContext targetContext)
{
    ExceptionDispatchInfo state = ExceptionDispatchInfo.Capture(exception);
    if (targetContext != null)
    {
        try
        {
            targetContext.Post((object obj) =>
            {
                ((ExceptionDispatchInfo)obj).Throw();
            }, state);
            return;
        }
        catch (Exception ex)
        {
            state = ExceptionDispatchInfo.Capture(new AggregateException(exception, ex));
        }
    }
    ThreadPool.QueueUserWorkItem((object obj) =>
    {
        ((ExceptionDispatchInfo)obj).Throw();
    }, state);
}
  • Строка 2: контекст берётся тот, что был в начале вызова async void (Create запомнил его и вызвал у него OperationStarted()).
  • Строки 3–13 и 23–31: есть контекст — исключение публикуется в него через Post, чтобы быть брошенным на его потоке. В UI это значит, что сработает обработчик необработанных исключений приложения.
  • Строки 14–17 и 33–41: Post не удался или контекста нет — исключение бросается на потоке пула в рабочем элементе. Его никто не ловит, и это конец процесса.

Опыт: контекст ловит исключение

final/Program.cs
Console.WriteLine();
Console.WriteLine("== 5. async void ==");
Console.WriteLine("  в потоке есть контекст, который ловит Post: исключение приходит в него");
SynchronizationContext.SetSynchronizationContext(new CatchingContext());
FireAndForgetWithContext();
new List<int> { 1 }.ForEach(async x => { await Task.Yield(); throw new InvalidOperationException("из async-лямбды"); });
await Task.Delay(300);
SynchronizationContext.SetSynchronizationContext(null);
Console.WriteLine("  без контекста исключение роняет процесс: см. запуск с аргументами async-void и foreach-lambda");
1
2
3
4
5
== 5. async void ==
  в потоке есть контекст, который ловит Post: исключение приходит в него
    контекст получил через Post: InvalidOperationException: из async void с контекстом, поток 5
    контекст получил через Post: InvalidOperationException: из async-лямбды, поток 5
  без контекста исключение роняет процесс: см. запуск с аргументами async-void и foreach-lambda

CatchingContext (final/CatchingContext.cs) выполняет Post на месте и печатает исключение. Строки 3–4 вывода: оба исключения, и от именованного async void метода, и от async-лямбды в ForEach, дошли до контекста. Номер потока от прогона к прогону меняется (5 или 8).

Опыт: без контекста процесс падает

$ dotnet run -c Release -- async-void
вызов вернулся, исключения нет
UnhandledException: из async void, IsTerminating=True
Unhandled exception. System.InvalidOperationException: из async void
   at Program.<<Main>$>g__FireAndCrash|0_21() in …/final/Program.cs:line 158
   at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__124_1(Object state)
   at System.Threading.ThreadPoolWorkQueue.Dispatch()
   at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()
   at System.Threading.Thread.StartCallback()
Aborted     (код выхода 134)
  • Строка 2: async void вернул управление сразу (после await Task.Delay(100) внутри). Вызывающий не знает, что что-то произойдёт.
  • Строка 3: сработало AppDomain.UnhandledException с IsTerminating=True: поймать исключение здесь нельзя, можно только записать его в лог.
  • Строка 6: стек показывает ThrowAsync внутри рабочего элемента пула.
  • Код выхода 134 — SIGABRT на Linux (128 + 6), а строку Aborted печатает оболочка. На Windows процесс завершается с кодом 0xE0434352 (необработанное .NET-исключение), оболочка ничего не добавляет; остальной вывод (UnhandledException … IsTerminating=True, стек до ThreadPoolWorkQueue.Dispatch) такой же. Проверено на Windows 11 для async-void и foreach-lambda: код выхода в обоих случаях 0xE0434352.

Факт: List<T>.ForEach(async x => …) не «теряет» исключение, а роняет процесс

В первой версии курса было: «list.ForEach(async x => await ProcessAsync(x)): async void, не дожидается, исключения теряются». Проверено запуском dotnet run -c Release -- foreach-lambda:

вызов вернулся, исключения нет
UnhandledException: из async-лямбды, IsTerminating=True
Unhandled exception. System.InvalidOperationException: из async-лямбды
   at Program.<>c.<<<Main>$>b__0_1>d.MoveNext() in …/final/Program.cs:line 20
--- End of stack trace from previous location ---
   at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__124_1(Object state)
   …
Aborted     (код выхода 134)

Лямбда, переданная туда, где ожидается Action<T>, компилируется как async void, и дальше всё как выше. Исключение не пропало: оно упало на потоке пула. «Теряется» оно только в смысле «вызывающий не может его поймать», а ещё теряется результат: ForEach не дождался окончания работы.

Правильно:

await Task.WhenAll(list.Select(ProcessAsync));      // параллельно, ждём все
foreach (var x in list) await ProcessAsync(x);      // по очереди

async void допустим только для обработчиков событий, где сигнатура void навязана. Во всех остальных случаях — async Task.

8.6. «Невыясненные» исключения

Если у задачи есть исключение, а прочитать его никто не успел, оно остаётся в хранилище с m_isHandled = false. Когда задача собрана сборщиком мусора, срабатывает финализатор хранилища (строки 8–15 листинга хранилища) и публикует событие TaskScheduler.UnobservedTaskException.

final/Program.cs
Console.WriteLine();
Console.WriteLine("== 6. Невыясненные исключения ==");
var seen = new List<string>();
TaskScheduler.UnobservedTaskException += (_, e) => { seen.Add(e.Exception.InnerException!.Message); e.SetObserved(); };
DropFaultedTask("не прочитанная");
ReadThenDropFaultedTask("прочитанная через .Exception");
await Task.Delay(100);
for (int i = 0; i < 3; i++)
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
}
Console.WriteLine($"  UnobservedTaskException сработал для: {(seen.Count == 0 ? "ничего" : string.Join(", ", seen))}");
== 6. Невыясненные исключения ==
  UnobservedTaskException сработал для: не прочитанная
  • Строки 91–92 кода: две задачи упали. Одну мы бросили («не прочитанная»), у второй прочитали исключение через task.Wait() внутри try (ReadThenDropFaultedTask, строка 92).
  • Строки 94–98: принудительная сборка мусора. В реальном приложении она происходит когда угодно, и событие «приходит» через минуты или не приходит вовсе.
  • Строка 2 вывода: событие сработало только для первой. Прочитанное исключение выключило финализатор (MarkAsHandled вызывает GC.SuppressFinalize).
  • Строка 90: e.SetObserved() в обработчике — привычка из .NET Framework; на судьбу процесса сейчас не влияет.

Процесс при этом не падает. Проверено отдельно: тот же код без обработчика события и без SetObserved после сборки мусора печатает «процесс жив». В .NET Framework 4.0 необработанное исключение задачи роняло процесс, с 4.5 это отключено, в .NET (Core) тоже.

Значит, исключение fire-and-forget задачи пропадает молча, в лучшем случае — событие в неопределённый момент (глава 16). Если запускаете фоновую работу без ожидания, ловите исключения внутри неё.

8.7. Исключение и отмена

В §8.1 мы видели развилку в AsyncTaskMethodBuilder.SetException: OperationCanceledException → Canceled, остальное → Faulted. А у задачи, которая выполняет делегат (Task.Run(Action), StartNew), правило другое:

CoreLib 10.0.12: Task.HandleException (исключение делегата)
private void HandleException(Exception unhandledException)
{
    if (unhandledException is OperationCanceledException ex && IsCancellationRequested && m_contingentProperties.m_cancellationToken == ex.CancellationToken)
    {
        SetCancellationAcknowledged();
        AddException(ex, representsCancellation: true);
    }
    else
    {
        AddException(unhandledException);
    }
}
  • Строка 3: делегат задачи превращает исключение в отмену, только если выполнены все три условия: это OperationCanceledException, токен задачи отменён и это тот же токен, что в исключении.
  • Строки 8–11: иначе — обычное Faulted.
final/Program.cs
Console.WriteLine("== 7. Исключение и отмена ==");
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
CancellationToken ct = cancelled.Token;
await Status("async-метод: throw new OperationCanceledException()", ThrowOceAsync(default));
await Status("async-метод: throw new OperationCanceledException(ct)", ThrowOceAsync(ct));
await Status("async-метод: throw new InvalidOperationException()", ThrowInvalidAsync());
await Status("Task.Run(Action): throw OCE() без токена", Task.Run((Action)(() => throw new OperationCanceledException())));
await Status("Task.Run(Action): throw OCE(ct), ct не передан в Run", Task.Run((Action)(() => throw new OperationCanceledException(ct))));
using var running = new CancellationTokenSource();
await Status("Task.Run(Action, tok): OCE(tok), токен отменён внутри", Task.Run((Action)(() => { running.Cancel(); throw new OperationCanceledException(running.Token); }), running.Token));
await Status("Task.Run(() => throw …): лямбда ушла в Func<Task>!", Task.Run(() => throw new OperationCanceledException()));
await Status("Task.Run(Action, ct): токен отменён до старта", Task.Run((Action)(() => { }), ct));
1
2
3
4
5
6
7
8
9
== 7. Исключение и отмена ==
  async-метод: throw new OperationCanceledException()           : Canceled, await бросил OperationCanceledException
  async-метод: throw new OperationCanceledException(ct)         : Canceled, await бросил OperationCanceledException
  async-метод: throw new InvalidOperationException()            : Faulted, await бросил InvalidOperationException
  Task.Run(Action): throw OCE() без токена                      : Faulted, await бросил OperationCanceledException
  Task.Run(Action): throw OCE(ct), ct не передан в Run          : Faulted, await бросил OperationCanceledException
  Task.Run(Action, tok): OCE(tok), токен отменён внутри         : Canceled, await бросил OperationCanceledException
  Task.Run(() => throw …): лямбда ушла в Func<Task>!            : Canceled, await бросил OperationCanceledException
  Task.Run(Action, ct): токен отменён до старта                 : Canceled, await бросил TaskCanceledException

Таблица правил:

Откуда исключение Статус задачи Условие
async-метод, любой OperationCanceledException (и TaskCanceledException) Canceled токен не важен
async-метод, другое исключение Faulted
делегат Task.Run(Action) / StartNew Canceled токен задачи отменён и совпал с токеном исключения
делегат, OperationCanceledException иначе Faulted
токен отменён до старта Canceled без запуска await бросает TaskCanceledException

В обоих случаях await бросает OperationCanceledException, но не обязательно TaskCanceledException: он бросается, только если задача отменена, а сохранённого исключения нет (строки 6–7 листинга ThrowForNonSuccess). Разбор токенов и таймаутов — в главе 9.

Факт: в async-методе токен исключения не проверяется

В первой версии курса: «OperationCanceledException переводит Task в Canceled, если выброшено с токеном отмены». Для async-методов токен не нужен: достаточно самого типа исключения (строки 2–3 вывода: OperationCanceledException() без токена всё равно даёт Canceled). Проверка токена есть только у задач-делегатов (Task.Run(Action)).

Ловушка перегрузок: Task.Run(() => throw …) — это Task.Run(Func<Task>)

Лямбда () => throw new … подходит и под Action, и под Func<Task>. Компилятор выбирает Func<Task> (строка 7 вывода): исключение идёт по пути async-метода и для OperationCanceledException даёт Canceled, хотя Task.Run((Action)(…)) дал бы Faulted (строки 4–5). Если важна разница, приводите лямбду к Action явно.

8.8. Итоги

  • Исключение async-метода не вылетает из вызова: MoveNext ловит его и отдаёт builder.SetException, тот записывает его в Task (через ExceptionDispatchInfo, со стеком). Даже до первого await задача уже Faulted.
  • Валидацию аргументов публичного API делайте в обычном методе, а работу — в локальной async-функции: тогда ошибка вызывающего бросится синхронно.
  • await бросает исходное исключение и только первое; .Result / .Wait() — AggregateException со всеми. Любое чтение ставит m_isHandled и отключает уведомление о невыясненном исключении.
  • WhenAll: все исключения — в all.Exception. Какое из них даст await, зависит от перегрузки (необобщённый — по порядку завершения, Task<T> — по порядку аргументов), поэтому не полагайтесь на порядок.
  • async void (в том числе async-лямбда там, где ожидается Action) публикует исключение в захваченный SynchronizationContext, а без него бросает на потоке пула, и процесс падает.
  • Исключение задачи, которое никто не прочитал, не роняет процесс; событие UnobservedTaskException придёт при сборке мусора. fire-and-forget без обработки ошибок молча их теряет.
  • В async-методе любой OperationCanceledException даёт Canceled. В Task.Run(Action) отмена признаётся, только если токен совпал и отменён, иначе Faulted.

Код лабораторной

Запуск из папки главы: dotnet run -c Release --project start или --project final. Падения процесса — dotnet run -c Release --project final -- async-void и -- foreach-lambda.

start/Program.cs
// Глава 8, заготовка. Где живёт исключение async-метода.
// PREDICT: что напечатает каждый блок?
// TODO 1 (§8.2): сделайте так, чтобы SaveAsync(null) бросал сразу при вызове, а не уходил в Task.
// TODO 2 (§8.4): выведите ВСЕ исключения из WhenAll, а не только первое.

Console.WriteLine("== 1. Исключение до первого await ==");
Task t = FailAsync();
Console.WriteLine($"вызов завершён, t.Status = {t.Status}");        // PREDICT:
try { await t; }
catch (Exception e) { Console.WriteLine($"поймано при await: {e.Message}"); }

Console.WriteLine();
Console.WriteLine("== 2. Ловушка валидации ==");
try
{
    Task save = SaveAsync(null!);
    Console.WriteLine($"SaveAsync(null) не бросил, Status = {save.Status}");   // PREDICT:
}
catch (ArgumentNullException) { Console.WriteLine("SaveAsync(null) бросил сразу"); }

Console.WriteLine();
Console.WriteLine("== 3. WhenAll и несколько исключений ==");
var t1 = Task.Run(() => throw new InvalidOperationException("A"));
var t2 = Task.Run(() => throw new ArgumentException("B"));
try { await Task.WhenAll(t1, t2); }
catch (Exception e) { Console.WriteLine($"await дал только: {e.GetType().Name}"); }   // PREDICT: сколько исключений в задаче WhenAll?

static async Task FailAsync()
{
    throw new InvalidOperationException("boom");   // до первого await
}

static async Task SaveAsync(string name)
{
    if (name == null) throw new ArgumentNullException(nameof(name));   // уйдёт в Task!
    await Task.Delay(1);
}
final/Program.cs
// Глава 8, итог. Исключения в async-коде:
//   1) исключение попадает в Task, а не вылетает из вызова (§8.1);
//   2) валидация аргументов: обычный метод + локальная async-функция (§8.2);
//   3) await, .Result и GetAwaiter().GetResult() бросают по-разному (§8.3);
//   4) WhenAll: какие исключения и в каком порядке (§8.4);
//   5) async void: публикуется в контекст или роняет процесс (§8.5);
//   6) невыясненные исключения и UnobservedTaskException (§8.6);
//   7) исключение и отмена: Faulted или Canceled (§8.7).
// Падение процесса показывается отдельно (процесс не доживёт до конца):
//   dotnet run -c Release -- async-void       async void без контекста
//   dotnet run -c Release -- foreach-lambda   List.ForEach(async x => …)

if (args.FirstOrDefault() is "async-void" or "foreach-lambda")
{
    AppDomain.CurrentDomain.UnhandledException += (_, e) =>
        Console.WriteLine($"UnhandledException: {((Exception)e.ExceptionObject).Message}, IsTerminating={e.IsTerminating}");
    if (args[0] == "async-void")
        FireAndCrash();                   // вызывающий ничего не может поймать
    else
        new List<int> { 1 }.ForEach(async x => { await Task.Delay(100); throw new InvalidOperationException("из async-лямбды"); });
    Console.WriteLine("вызов вернулся, исключения нет");
    await Task.Delay(500);
    Console.WriteLine("эта строка не напечатается: процесс упадёт раньше");
    return;
}

Console.WriteLine("== 1. Где живёт исключение ==");
Task t = FailAsync();
Console.WriteLine($"  вызов завершён, t.Status = {t.Status}");
Console.WriteLine($"  {Holder.Describe(t)}");
try { await t; }
catch (Exception e) { Console.WriteLine($"  поймано при await: {e.Message}"); }
Console.WriteLine($"  после await: {Holder.Describe(t)}");

Console.WriteLine();
Console.WriteLine("== 2. Валидация в обычном методе, работа в локальной async-функции ==");
try
{
    Task save = SaveAsync(null!);
    Console.WriteLine($"  SaveAsync(null) не бросил, Status = {save.Status}");
}
catch (ArgumentNullException) { Console.WriteLine("  SaveAsync(null) бросил сразу"); }

Console.WriteLine();
Console.WriteLine("== 3. await против .Result против GetAwaiter().GetResult() ==");
Task<int> deep = Level1Async();
try { await deep; } catch (Exception e) { Show("await", e); Console.WriteLine(Frames(e)); }
try { _ = deep.Result; } catch (Exception e) { Show(".Result", e); Console.WriteLine(Frames(e)); }
try { _ = deep.GetAwaiter().GetResult(); } catch (Exception e) { Show("GetAwaiter().GetResult()", e); }
Task<int> plain = Task.Run(Thrower);
try { _ = plain.GetAwaiter().GetResult(); } catch (Exception e) { Show("GetResult после обычного метода", e); Console.WriteLine(Frames(e)); }

Console.WriteLine();
Console.WriteLine("== 4. WhenAll ==");
var slow = Task.Run(async () => { await Task.Delay(200); throw new InvalidOperationException("медленная, первая в списке"); });
var fast = Task.Run(async () => { await Task.Delay(50); throw new ArgumentException("быстрая, вторая в списке"); });
Task all = Task.WhenAll(slow, fast);
try { await all; }
catch (Exception e) { Console.WriteLine($"  await дал только: {e.GetType().Name}: {e.Message}"); }
foreach (var inner in all.Exception!.InnerExceptions)
    Console.WriteLine($"  в all.Exception: {inner.GetType().Name}: {inner.Message}");
var slowInt = Task.Run<int>(async () => { await Task.Delay(200); throw new InvalidOperationException("медленная, первая в списке"); });
var fastInt = Task.Run<int>(async () => { await Task.Delay(50); throw new ArgumentException("быстрая, вторая в списке"); });
Task<int[]> allInt = Task.WhenAll(slowInt, fastInt);
try { await allInt; }
catch (Exception e) { Console.WriteLine($"  Task<T>: await дал только: {e.GetType().Name}"); }
Console.WriteLine($"  Task<T>: порядок в all.Exception: {string.Join(", ", allInt.Exception!.InnerExceptions.Select(x => x.GetType().Name))}");
using (var cts = new CancellationTokenSource())
{
    cts.Cancel();
    Task canceled = Task.FromCanceled(cts.Token);
    Task mixed = Task.WhenAll(canceled, Task.FromException(new InvalidOperationException("упала")));
    try { await mixed; } catch (Exception) { }
    Console.WriteLine($"  WhenAll(отменённая, упавшая): Status = {mixed.Status}");
}

Console.WriteLine();
Console.WriteLine("== 5. async void ==");
Console.WriteLine("  в потоке есть контекст, который ловит Post: исключение приходит в него");
SynchronizationContext.SetSynchronizationContext(new CatchingContext());
FireAndForgetWithContext();
new List<int> { 1 }.ForEach(async x => { await Task.Yield(); throw new InvalidOperationException("из async-лямбды"); });
await Task.Delay(300);
SynchronizationContext.SetSynchronizationContext(null);
Console.WriteLine("  без контекста исключение роняет процесс: см. запуск с аргументами async-void и foreach-lambda");

Console.WriteLine();
Console.WriteLine("== 6. Невыясненные исключения ==");
var seen = new List<string>();
TaskScheduler.UnobservedTaskException += (_, e) => { seen.Add(e.Exception.InnerException!.Message); e.SetObserved(); };
DropFaultedTask("не прочитанная");
ReadThenDropFaultedTask("прочитанная через .Exception");
await Task.Delay(100);
for (int i = 0; i < 3; i++)
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
}
Console.WriteLine($"  UnobservedTaskException сработал для: {(seen.Count == 0 ? "ничего" : string.Join(", ", seen))}");

Console.WriteLine();
Console.WriteLine("== 7. Исключение и отмена ==");
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
CancellationToken ct = cancelled.Token;
await Status("async-метод: throw new OperationCanceledException()", ThrowOceAsync(default));
await Status("async-метод: throw new OperationCanceledException(ct)", ThrowOceAsync(ct));
await Status("async-метод: throw new InvalidOperationException()", ThrowInvalidAsync());
await Status("Task.Run(Action): throw OCE() без токена", Task.Run((Action)(() => throw new OperationCanceledException())));
await Status("Task.Run(Action): throw OCE(ct), ct не передан в Run", Task.Run((Action)(() => throw new OperationCanceledException(ct))));
using var running = new CancellationTokenSource();
await Status("Task.Run(Action, tok): OCE(tok), токен отменён внутри", Task.Run((Action)(() => { running.Cancel(); throw new OperationCanceledException(running.Token); }), running.Token));
await Status("Task.Run(() => throw …): лямбда ушла в Func<Task>!", Task.Run(() => throw new OperationCanceledException()));
await Status("Task.Run(Action, ct): токен отменён до старта", Task.Run((Action)(() => { }), ct));

static async Task FailAsync()
{
    throw new InvalidOperationException("boom");   // до первого await
}

static Task SaveAsync(string name)
{
    ArgumentNullException.ThrowIfNull(name);       // бросит сразу, синхронно
    return Core();

    async Task Core() { await Task.Delay(1); }
}

static async Task<int> Level1Async() => await Level2Async() + 1;

static async Task<int> Level2Async()
{
    await Task.Delay(10);
    throw new InvalidOperationException("глубоко");
}

static int Thrower() => throw new InvalidOperationException("из обычного метода");

static void Show(string how, Exception e)
{
    Exception real = e is AggregateException ? e.InnerException! : e;
    int markers = real.ToString().Split("End of stack trace from previous location").Length - 1;
    Console.WriteLine($"  {how,-34}: {e.GetType().Name}, маркеров «End of stack trace»: {markers}");
}

// Кадры стека без путей к файлам: у AggregateException — стек её внутреннего исключения.
static string Frames(Exception e)
{
    Exception real = e is AggregateException ? e.InnerException! : e;
    return string.Join("\n", (real.StackTrace ?? "").Split('\n')
        .Select(l => l.Contains(" in /") ? l[..l.IndexOf(" in /")] : l.TrimEnd('\r'))
        .Select(l => "      " + l.Trim()));
}

static async void FireAndCrash()
{
    await Task.Delay(100);
    throw new InvalidOperationException("из async void");   // нет контекста — бросится на потоке пула
}

static async void FireAndForgetWithContext()
{
    await Task.Yield();
    throw new InvalidOperationException("из async void с контекстом");
}

static void DropFaultedTask(string message) => _ = Task.Run(() => throw new InvalidOperationException(message));

static void ReadThenDropFaultedTask(string message)
{
    Task task = Task.Run(() => throw new InvalidOperationException(message));
    try { task.Wait(); } catch (AggregateException) { }   // прочитали исключение: оно «выяснено»
}

static async Task ThrowOceAsync(CancellationToken token)
{
    await Task.Yield();
    throw token.IsCancellationRequested ? new OperationCanceledException(token) : new OperationCanceledException();
}

static async Task ThrowInvalidAsync()
{
    await Task.Yield();
    throw new InvalidOperationException();
}

static async Task Status(string title, Task task)
{
    try { await task; }
    catch (Exception e) { Console.WriteLine($"  {title,-62}: {task.Status}, await бросил {e.GetType().Name}"); }
}
final/Holder.cs
using System.Reflection;

// Заглядываем в Task через отражение: есть ли у задачи хранилище исключений и считается ли оно «обработанным».
// Имена полей — детали реализации CoreLib 10.0.12 (глава 4: так же, как m_stateFlags).
static class Holder
{
    private const BindingFlags Any = BindingFlags.Instance | BindingFlags.NonPublic;

    public static string Describe(Task task)
    {
        object? contingent = typeof(Task).GetField("m_contingentProperties", Any)!.GetValue(task);
        object? holder = contingent?.GetType().GetField("m_exceptionsHolder", Any)!.GetValue(contingent);
        if (holder is null)
            return "хранилища исключений нет";
        bool handled = (bool)holder.GetType().GetField("m_isHandled", Any)!.GetValue(holder)!;
        var list = (System.Collections.ICollection?)holder.GetType().GetField("m_faultExceptions", Any)!.GetValue(holder);
        return $"хранилище есть, исключений в нём: {list?.Count ?? 0}, обработано (m_isHandled): {handled}";
    }
}
final/CatchingContext.cs
// SynchronizationContext, который выполняет Post на месте и ловит исключения: так видно,
// что async void «публикует» исключение в контекст, захваченный в начале вызова (§8.5).
sealed class CatchingContext : SynchronizationContext
{
    public override void Post(SendOrPostCallback d, object? state)
    {
        try
        {
            d(state);
        }
        catch (Exception e)
        {
            Console.WriteLine($"    контекст получил через Post: {e.GetType().Name}: {e.Message}, поток {Environment.CurrentManagedThreadId}");
        }
    }
}