Цель: понять по коду рантайма, куда девается исключение 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).
Исключение async-метода не вылетает из вызова. Оно попадает в Task, который метод вернул. Вызывающий увидит его только когда достанет: await, .Wait(), .Result или task.Exception. Это верно и для исключения, брошенного до первого await.
Строки 3–6: всё тело метода обёрнуто в try. Эта обёртка есть у любого async-метода, независимо от того, есть ли в нём свой try. В главе 1 она была в конце MoveNext.
Строки 7–10: любое исключение тела перехватывается. Состояние становится -2 («завершён»), а исключение отдаётся builder'у.
Методу не нужно ни await, ни приостановки: MoveNext вызвала заглушка, Start его выполнил, исключение уже записано в задачу. Поэтому после вызова FailAsync() задача уже в состоянии Faulted.
Строка 7: если метод ни разу не приостанавливался, бокса нет, и задача создаётся здесь же, «обещание» без кода (глава 4). Заглушка потом вернёт её через builder.Task.
Строка 8: развилка.OperationCanceledException превращает задачу в Canceled (§8.7), любое другое исключение — в Faulted.
Строки 9–11: завершить задачу дважды нельзя.
TrySetException — тот же механизм завершения, что в главе 4 (AtomicStateUpdate, затем Finish запускает продолжения). Отличие в том, что вместо результата в задачу записывается исключение:
CoreLib 10.0.12: Task.TrySetException и Task.AddException
internalsealedclassTaskExceptionHolder{privatereadonlyTaskm_task;privatevolatileList<ExceptionDispatchInfo>m_faultExceptions;privateExceptionDispatchInfom_cancellationException;privatevolatileboolm_isHandled;~TaskExceptionHolder(){if(m_faultExceptions!=null&&!m_isHandled){UnobservedTaskExceptionEventArgsueea=newUnobservedTaskExceptionEventArgs(newAggregateException(SR.TaskExceptionHolder_UnhandledException,m_faultExceptions));TaskScheduler.PublishUnobservedTaskException(m_task,ueea);}}privatevoidAddFaultException(objectexceptionObject){List<ExceptionDispatchInfo>list=m_faultExceptions??(m_faultExceptions=newList<ExceptionDispatchInfo>(1));if(exceptionObjectisExceptionsource){list.Add(ExceptionDispatchInfo.Capture(source));}...// ExceptionDispatchInfo и коллекции — для WhenAllif(list.Count>0){MarkAsUnhandled();}}internalvoidMarkAsHandled(boolcalledFromFinalizer){if(!m_isHandled){if(!calledFromFinalizer){GC.SuppressFinalize(this);}m_isHandled=true;}}internalList<ExceptionDispatchInfo>GetExceptionDispatchInfos(){List<ExceptionDispatchInfo>faultExceptions=m_faultExceptions;MarkAsHandled(calledFromFinalizer:false);returnfaultExceptions;}}
Строка 4: исключения хранятся не как Exception, а как ExceptionDispatchInfo: это исключение вместе с захваченным стеком, которое можно перебросить, не затирая его.
Строка 6: флаг m_isHandled («исключение кто-то выяснил»). Пока он false, у хранилища работает финализатор.
Строки 8–15: финализатор. Если задача с исключением собрана сборщиком мусора, а m_isHandled так и остался false, исключение публикуется событием TaskScheduler.UnobservedTaskException (§8.6).
Console.WriteLine("== 1. Где живёт исключение ==");Taskt=FailAsync();Console.WriteLine($" вызов завершён, t.Status = {t.Status}");Console.WriteLine($" {Holder.Describe(t)}");try{awaitt;}catch(Exceptione){Console.WriteLine($" поймано при await: {e.Message}");}Console.WriteLine($" после await: {Holder.Describe(t)}");
Holder.Describe читает поля Task через отражение (имена из листингов выше, детали реализации 10.0.12).
== 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).
staticasyncTaskSaveAsync(stringname){if(name==null)thrownewArgumentNullException(nameof(name));// уйдёт в Task!awaitTask.Delay(1);}vart=SaveAsync(null);// не бросило; ошибка проявится позже, и только если кто-то дождётся задачи
Вызывающий получил Faulted-задачу и мог её даже не ждать. Для публичных API проверку аргументов выносят в обычный (не async) метод, а работу — в локальную async-функцию:
await вызывает awaiter.GetResult(). Для задачи это TaskAwaiter.ValidateEnd → HandleNonSuccessAndDebuggerNotification → ThrowForNonSuccess (первые два мы видели в главе 5):
Строка 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
Console.WriteLine("== 3. await против .Result против GetAwaiter().GetResult() ==");Task<int>deep=Level1Async();try{awaitdeep;}catch(Exceptione){Show("await",e);Console.WriteLine(Frames(e));}try{_=deep.Result;}catch(Exceptione){Show(".Result",e);Console.WriteLine(Frames(e));}try{_=deep.GetAwaiter().GetResult();}catch(Exceptione){Show("GetAwaiter().GetResult()",e);}Task<int>plain=Task.Run(Thrower);try{_=plain.GetAwaiter().GetResult();}catch(Exceptione){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 с синхронным делегатом, там два маркера).
if(declaringType!=null&&IsDefinedSafe(declaringType,typeof(CompilerGeneratedAttribute),inherit:false)){flag2=declaringType.IsAssignableTo(typeof(IAsyncStateMachine));if(flag2||declaringType.IsAssignableTo(typeof(IEnumerator))){flag3=TryResolveStateMachineMethod(refmethod,outdeclaringType);}}...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.
// WhenAll(Task…) — необобщённыйpublicvoidInvoke(TaskcompletedTask){if(completedTask!=null){if(!completedTask.IsCompletedSuccessfully){objectobj=Interlocked.CompareExchange(refm_stateObject,completedTask,null);if(obj!=null){...// второй и следующие: в List<Task>, в порядке ЗАВЕРШЕНИЯ}}}if(Interlocked.Decrement(ref_remainingToComplete)!=0){return;}...if(observedExceptions!=null){TrySetException(observedExceptions);}elseif(canceledTask!=null){TrySetCanceled(canceledTask.CancellationToken,canceledTask.GetCancellationExceptionDispatchInfo());}...}// WhenAll<T>(Task<T>…) — обобщённыйfor(inti=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; отдельно их ловить не нужно.
Console.WriteLine("== 4. WhenAll ==");varslow=Task.Run(async()=>{awaitTask.Delay(200);thrownewInvalidOperationException("медленная, первая в списке");});varfast=Task.Run(async()=>{awaitTask.Delay(50);thrownewArgumentException("быстрая, вторая в списке");});Taskall=Task.WhenAll(slow,fast);try{awaitall;}catch(Exceptione){Console.WriteLine($" await дал только: {e.GetType().Name}: {e.Message}");}foreach(varinnerinall.Exception!.InnerExceptions)Console.WriteLine($" в all.Exception: {inner.GetType().Name}: {inner.Message}");varslowInt=Task.Run<int>(async()=>{awaitTask.Delay(200);thrownewInvalidOperationException("медленная, первая в списке");});varfastInt=Task.Run<int>(async()=>{awaitTask.Delay(50);thrownewArgumentException("быстрая, вторая в списке");});Task<int[]>allInt=Task.WhenAll(slowInt,fastInt);try{awaitallInt;}catch(Exceptione){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(varcts=newCancellationTokenSource()){cts.Cancel();Taskcanceled=Task.FromCanceled(cts.Token);Taskmixed=Task.WhenAll(canceled,Task.FromException(newInvalidOperationException("упала")));try{awaitmixed;}catch(Exception){}Console.WriteLine($" WhenAll(отменённая, упавшая): Status = {mixed.Status}");}
== 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.
Строка 2: контекст берётся тот, что был в начале вызоваasync void (Create запомнил его и вызвал у него OperationStarted()).
Строки 3–13 и 23–31: есть контекст — исключение публикуется в него через Post, чтобы быть брошенным на его потоке. В UI это значит, что сработает обработчик необработанных исключений приложения.
Строки 14–17 и 33–41:Post не удался или контекста нет — исключение бросается на потоке пула в рабочем элементе. Его никто не ловит, и это конец процесса.
Console.WriteLine();Console.WriteLine("== 5. async void ==");Console.WriteLine(" в потоке есть контекст, который ловит Post: исключение приходит в него");SynchronizationContext.SetSynchronizationContext(newCatchingContext());FireAndForgetWithContext();newList<int>{1}.ForEach(asyncx=>{awaitTask.Yield();thrownewInvalidOperationException("из async-лямбды");});awaitTask.Delay(300);SynchronizationContext.SetSynchronizationContext(null);Console.WriteLine(" без контекста исключение роняет процесс: см. запуск с аргументами async-void и foreach-lambda");
== 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 не дождался окончания работы.
Если у задачи есть исключение, а прочитать его никто не успел, оно остаётся в хранилище с m_isHandled = false. Когда задача собрана сборщиком мусора, срабатывает финализатор хранилища (строки 8–15 листинга хранилища) и публикует событие TaskScheduler.UnobservedTaskException.
== 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.1 мы видели развилку в AsyncTaskMethodBuilder.SetException: OperationCanceledException → Canceled, остальное → Faulted. А у задачи, которая выполняет делегат (Task.Run(Action), StartNew), правило другое:
Строка 3: делегат задачи превращает исключение в отмену, только если выполнены все три условия: это OperationCanceledException, токен задачи отменён и это тот же токен, что в исключении.
Console.WriteLine("== 7. Исключение и отмена ==");usingvarcancelled=newCancellationTokenSource();cancelled.Cancel();CancellationTokenct=cancelled.Token;awaitStatus("async-метод: throw new OperationCanceledException()",ThrowOceAsync(default));awaitStatus("async-метод: throw new OperationCanceledException(ct)",ThrowOceAsync(ct));awaitStatus("async-метод: throw new InvalidOperationException()",ThrowInvalidAsync());awaitStatus("Task.Run(Action): throw OCE() без токена",Task.Run((Action)(()=>thrownewOperationCanceledException())));awaitStatus("Task.Run(Action): throw OCE(ct), ct не передан в Run",Task.Run((Action)(()=>thrownewOperationCanceledException(ct))));usingvarrunning=newCancellationTokenSource();awaitStatus("Task.Run(Action, tok): OCE(tok), токен отменён внутри",Task.Run((Action)(()=>{running.Cancel();thrownewOperationCanceledException(running.Token);}),running.Token));awaitStatus("Task.Run(() => throw …): лямбда ушла в Func<Task>!",Task.Run(()=>thrownewOperationCanceledException()));awaitStatus("Task.Run(Action, ct): токен отменён до старта",Task.Run((Action)(()=>{}),ct));
== 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 явно.
Исключение 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.
// Глава 8, заготовка. Где живёт исключение async-метода.// PREDICT: что напечатает каждый блок?// TODO 1 (§8.2): сделайте так, чтобы SaveAsync(null) бросал сразу при вызове, а не уходил в Task.// TODO 2 (§8.4): выведите ВСЕ исключения из WhenAll, а не только первое.Console.WriteLine("== 1. Исключение до первого await ==");Taskt=FailAsync();Console.WriteLine($"вызов завершён, t.Status = {t.Status}");// PREDICT:try{awaitt;}catch(Exceptione){Console.WriteLine($"поймано при await: {e.Message}");}Console.WriteLine();Console.WriteLine("== 2. Ловушка валидации ==");try{Tasksave=SaveAsync(null!);Console.WriteLine($"SaveAsync(null) не бросил, Status = {save.Status}");// PREDICT:}catch(ArgumentNullException){Console.WriteLine("SaveAsync(null) бросил сразу");}Console.WriteLine();Console.WriteLine("== 3. WhenAll и несколько исключений ==");vart1=Task.Run(()=>thrownewInvalidOperationException("A"));vart2=Task.Run(()=>thrownewArgumentException("B"));try{awaitTask.WhenAll(t1,t2);}catch(Exceptione){Console.WriteLine($"await дал только: {e.GetType().Name}");}// PREDICT: сколько исключений в задаче WhenAll?staticasyncTaskFailAsync(){thrownewInvalidOperationException("boom");// до первого await}staticasyncTaskSaveAsync(stringname){if(name==null)thrownewArgumentNullException(nameof(name));// уйдёт в Task!awaitTask.Delay(1);}
// Глава 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();// вызывающий ничего не может пойматьelsenewList<int>{1}.ForEach(asyncx=>{awaitTask.Delay(100);thrownewInvalidOperationException("из async-лямбды");});Console.WriteLine("вызов вернулся, исключения нет");awaitTask.Delay(500);Console.WriteLine("эта строка не напечатается: процесс упадёт раньше");return;}Console.WriteLine("== 1. Где живёт исключение ==");Taskt=FailAsync();Console.WriteLine($" вызов завершён, t.Status = {t.Status}");Console.WriteLine($" {Holder.Describe(t)}");try{awaitt;}catch(Exceptione){Console.WriteLine($" поймано при await: {e.Message}");}Console.WriteLine($" после await: {Holder.Describe(t)}");Console.WriteLine();Console.WriteLine("== 2. Валидация в обычном методе, работа в локальной async-функции ==");try{Tasksave=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{awaitdeep;}catch(Exceptione){Show("await",e);Console.WriteLine(Frames(e));}try{_=deep.Result;}catch(Exceptione){Show(".Result",e);Console.WriteLine(Frames(e));}try{_=deep.GetAwaiter().GetResult();}catch(Exceptione){Show("GetAwaiter().GetResult()",e);}Task<int>plain=Task.Run(Thrower);try{_=plain.GetAwaiter().GetResult();}catch(Exceptione){Show("GetResult после обычного метода",e);Console.WriteLine(Frames(e));}Console.WriteLine();Console.WriteLine("== 4. WhenAll ==");varslow=Task.Run(async()=>{awaitTask.Delay(200);thrownewInvalidOperationException("медленная, первая в списке");});varfast=Task.Run(async()=>{awaitTask.Delay(50);thrownewArgumentException("быстрая, вторая в списке");});Taskall=Task.WhenAll(slow,fast);try{awaitall;}catch(Exceptione){Console.WriteLine($" await дал только: {e.GetType().Name}: {e.Message}");}foreach(varinnerinall.Exception!.InnerExceptions)Console.WriteLine($" в all.Exception: {inner.GetType().Name}: {inner.Message}");varslowInt=Task.Run<int>(async()=>{awaitTask.Delay(200);thrownewInvalidOperationException("медленная, первая в списке");});varfastInt=Task.Run<int>(async()=>{awaitTask.Delay(50);thrownewArgumentException("быстрая, вторая в списке");});Task<int[]>allInt=Task.WhenAll(slowInt,fastInt);try{awaitallInt;}catch(Exceptione){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(varcts=newCancellationTokenSource()){cts.Cancel();Taskcanceled=Task.FromCanceled(cts.Token);Taskmixed=Task.WhenAll(canceled,Task.FromException(newInvalidOperationException("упала")));try{awaitmixed;}catch(Exception){}Console.WriteLine($" WhenAll(отменённая, упавшая): Status = {mixed.Status}");}Console.WriteLine();Console.WriteLine("== 5. async void ==");Console.WriteLine(" в потоке есть контекст, который ловит Post: исключение приходит в него");SynchronizationContext.SetSynchronizationContext(newCatchingContext());FireAndForgetWithContext();newList<int>{1}.ForEach(asyncx=>{awaitTask.Yield();thrownewInvalidOperationException("из async-лямбды");});awaitTask.Delay(300);SynchronizationContext.SetSynchronizationContext(null);Console.WriteLine(" без контекста исключение роняет процесс: см. запуск с аргументами async-void и foreach-lambda");Console.WriteLine();Console.WriteLine("== 6. Невыясненные исключения ==");varseen=newList<string>();TaskScheduler.UnobservedTaskException+=(_,e)=>{seen.Add(e.Exception.InnerException!.Message);e.SetObserved();};DropFaultedTask("не прочитанная");ReadThenDropFaultedTask("прочитанная через .Exception");awaitTask.Delay(100);for(inti=0;i<3;i++){GC.Collect();GC.WaitForPendingFinalizers();}Console.WriteLine($" UnobservedTaskException сработал для: {(seen.Count == 0 ? "ничего" : string.Join(",", seen))}");Console.WriteLine();Console.WriteLine("== 7. Исключение и отмена ==");usingvarcancelled=newCancellationTokenSource();cancelled.Cancel();CancellationTokenct=cancelled.Token;awaitStatus("async-метод: throw new OperationCanceledException()",ThrowOceAsync(default));awaitStatus("async-метод: throw new OperationCanceledException(ct)",ThrowOceAsync(ct));awaitStatus("async-метод: throw new InvalidOperationException()",ThrowInvalidAsync());awaitStatus("Task.Run(Action): throw OCE() без токена",Task.Run((Action)(()=>thrownewOperationCanceledException())));awaitStatus("Task.Run(Action): throw OCE(ct), ct не передан в Run",Task.Run((Action)(()=>thrownewOperationCanceledException(ct))));usingvarrunning=newCancellationTokenSource();awaitStatus("Task.Run(Action, tok): OCE(tok), токен отменён внутри",Task.Run((Action)(()=>{running.Cancel();thrownewOperationCanceledException(running.Token);}),running.Token));awaitStatus("Task.Run(() => throw …): лямбда ушла в Func<Task>!",Task.Run(()=>thrownewOperationCanceledException()));awaitStatus("Task.Run(Action, ct): токен отменён до старта",Task.Run((Action)(()=>{}),ct));staticasyncTaskFailAsync(){thrownewInvalidOperationException("boom");// до первого await}staticTaskSaveAsync(stringname){ArgumentNullException.ThrowIfNull(name);// бросит сразу, синхронноreturnCore();asyncTaskCore(){awaitTask.Delay(1);}}staticasyncTask<int>Level1Async()=>awaitLevel2Async()+1;staticasyncTask<int>Level2Async(){awaitTask.Delay(10);thrownewInvalidOperationException("глубоко");}staticintThrower()=>thrownewInvalidOperationException("из обычного метода");staticvoidShow(stringhow,Exceptione){Exceptionreal=eisAggregateException?e.InnerException!:e;intmarkers=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 — стек её внутреннего исключения.staticstringFrames(Exceptione){Exceptionreal=eisAggregateException?e.InnerException!:e;returnstring.Join("\n",(real.StackTrace??"").Split('\n').Select(l=>l.Contains(" in /")?l[..l.IndexOf(" in /")]:l.TrimEnd('\r')).Select(l=>" "+l.Trim()));}staticasyncvoidFireAndCrash(){awaitTask.Delay(100);thrownewInvalidOperationException("из async void");// нет контекста — бросится на потоке пула}staticasyncvoidFireAndForgetWithContext(){awaitTask.Yield();thrownewInvalidOperationException("из async void с контекстом");}staticvoidDropFaultedTask(stringmessage)=>_=Task.Run(()=>thrownewInvalidOperationException(message));staticvoidReadThenDropFaultedTask(stringmessage){Tasktask=Task.Run(()=>thrownewInvalidOperationException(message));try{task.Wait();}catch(AggregateException){}// прочитали исключение: оно «выяснено»}staticasyncTaskThrowOceAsync(CancellationTokentoken){awaitTask.Yield();throwtoken.IsCancellationRequested?newOperationCanceledException(token):newOperationCanceledException();}staticasyncTaskThrowInvalidAsync(){awaitTask.Yield();thrownewInvalidOperationException();}staticasyncTaskStatus(stringtitle,Tasktask){try{awaittask;}catch(Exceptione){Console.WriteLine($" {title,-62}: {task.Status}, await бросил {e.GetType().Name}");}}
usingSystem.Reflection;// Заглядываем в Task через отражение: есть ли у задачи хранилище исключений и считается ли оно «обработанным».// Имена полей — детали реализации CoreLib 10.0.12 (глава 4: так же, как m_stateFlags).staticclassHolder{privateconstBindingFlagsAny=BindingFlags.Instance|BindingFlags.NonPublic;publicstaticstringDescribe(Tasktask){object?contingent=typeof(Task).GetField("m_contingentProperties",Any)!.GetValue(task);object?holder=contingent?.GetType().GetField("m_exceptionsHolder",Any)!.GetValue(contingent);if(holderisnull)return"хранилища исключений нет";boolhandled=(bool)holder.GetType().GetField("m_isHandled",Any)!.GetValue(holder)!;varlist=(System.Collections.ICollection?)holder.GetType().GetField("m_faultExceptions",Any)!.GetValue(holder);return$"хранилище есть, исключений в нём: {list?.Count ?? 0}, обработано (m_isHandled): {handled}";}}
// SynchronizationContext, который выполняет Post на месте и ловит исключения: так видно,// что async void «публикует» исключение в контекст, захваченный в начале вызова (§8.5).sealedclassCatchingContext:SynchronizationContext{publicoverridevoidPost(SendOrPostCallbackd,object?state){try{d(state);}catch(Exceptione){Console.WriteLine($" контекст получил через Post: {e.GetType().Name}: {e.Message}, поток {Environment.CurrentManagedThreadId}");}}}