Таблица решений: завершать приложение или продолжать после неожиданного исключения

· Обновлено: · · разработка под Windows, обработка исключений, проектирование, C# / .NET, надёжность

История изменений (1 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI: 10.5281/zenodo.21619710)

Статья заархивирована на Zenodo. Ниже приведены DOI, который всегда ведёт к последней версии, и DOI, закреплённый за версией, которую вы читаете.

Го Комура (2026). Таблица решений: завершать приложение или продолжать после неожиданного исключения. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21619710 https://comcomponent.com/ru/blog/2026/03/16/005-unexpected-exception-exit-or-continue-decision-table/

DOI (последняя версия)
10.5281/zenodo.21619710
DOI (эта версия)
10.5281/zenodo.21619711

Скачать Excel-чеклист с листами на японском и английском

Файл состоит из двух листов, Checklist-ja и Checklist-en, и раскладывает оси этой статьи на 27 пунктов в 4 категориях: условия продолжения / условия завершения / подход к реализации / порядок решения. Столбцы Status и Notes оставлены пустыми, поэтому таблицу можно сразу использовать как журнал разбора инцидента или чеклист на ревью проекта.

Когда речь заходит о неожиданном исключении, легко скатиться к двум вариантам: «уронить или поймать и продолжить». На практике такая постановка грубовата.

Смотреть нужно на то, можно ли удержать возможную порчу в известных границах.

  • Можно ли закончить неудачей только эту операцию?
  • Достаточно ли заново инициализировать только этот экран / соединение / worker?
  • Или уже подозрительна целостность всего процесса?

Если смотреть в таком порядке, картина обычно быстро собирается.

Порядок, в котором проверяют область порчиПосле неожиданного исключения область возможной порчи проверяют в порядке: можно ли закончить неудачей только эту операцию, достаточно ли заново инициализировать только подсистему, или подозрительна целостность всего процесса.Можно ли закончить неудачей только эту операцию?Только экран или соединение заново инициализировать?Подозрителен ли весь процесс?

Рис. 1: Возможную область порчи проверяют в порядке операция, подсистема, весь процесс.

Ниже, ориентируясь на Windows-приложения на C# / .NET, постоянно живущие приложения, службы Windows и инструменты связи с оборудованием, сводим в таблицу решений условия, при которых после неожиданного исключения можно продолжать, и условия, при которых лучше завершить работу.

1. Сначала вывод

  • Глотать всё подряд через catch (Exception) и продолжать в большинстве случаев опасно.
  • Продолжать можно, когда одновременно выполняются три условия: сбойную единицу можно отбросить, общее состояние можно вернуть, внешние побочные эффекты можно объяснить.
  • Если граница обработки чёткая — одна операция UI, одна входная запись, одно задание — продолжение иногда возможно.
  • Наоборот, если в деле общее изменяемое состояние, постоянно живущий цикл, главный поток, код запуска, native-граница или пахнет порчей памяти, склоняйтесь к завершению.
  • Исключения вроде StackOverflowException, AccessViolationException, OutOfMemoryException, которые ставят под сомнение здоровье всего процесса, безопаснее не рассматривать как повод продолжать.
  • В WPF и Windows Forms есть путь поймать необработанное исключение и внешне продолжить, но можно продолжить и безопасно продолжать — разные вещи.
  • У долго живущих служб и приложений мониторинга чаще безопаснее и проще диагностировать падение с перезапуском, чем жизнь в наполовину сломанном состоянии.

Коротко: ось решения — можно ли восстановить инварианты.

Три условия, при которых можно продолжатьПродолжать можно только когда одновременно можно отбросить сбойную единицу, вернуть общее состояние и объяснить внешние побочные эффекты; ось решения — можно ли восстановить инварианты.Сбойную единицу можно отброситьесли все три есть — можно продолжатьОбщее состояние можно вернутьВнешние побочные эффекты можно объяснитьось — можно ли восстановить инварианты

Рис. 2: Продолжать можно только когда одновременно выполняются три условия: единица сбоя, общее состояние, внешние побочные эффекты.

1.1 Слова, которыми пользуется эта статья

Перед таблицей решений заранее фиксируем пять слов, которые будут повторяться.

Термин Смысл в этой статье
Инвариант Обещание о состоянии, которое должно быть верно до и после обработки. Например: «сумма строк совпадает с итогом», «кэш и содержимое БД согласованы», «открытое соединение обязательно есть в управляемом списке». Если это уже сломано, а процесс продолжает жить, все следующие шаги становятся подозрительными
Единица сбоя Область, которую при сбое можно отбросить целиком. Одна операция, один экран, одно задание, одно соединение
Внешний побочный эффект Изменение, которое уже ушло за пределы процесса. Обновление БД, запись файла, отправка письма, команда устройству — то, что catch не откатит
Подсистема Единица, которую можно остановить и заново инициализировать вместе. Соединение, экран, worker, дочерний процесс
FailFast Имеется в виду Environment.FailFast. API, который сразу завершает процесс, не выполняя ни try / finally, ни finalizer; подробно — в 9.7

Карта знаний этой статьи

Статья разбирает, что реакция на непредвиденное исключение — не проглотить его в catch (Exception) и продолжить, а решать, продолжать или завершать, по тому, можно ли объяснить инвариант разделяемого состояния, единицу отказа и внешний побочный эффект. Исключения вроде StackOverflowException, AccessViolationException и серьёзного OutOfMemoryException, сопровождаемые признаками повреждения памяти, а также сбои на нативной границе вроде COM и P/Invoke, склоняют к завершению; средствами завершения выступают Environment.FailFast и Environment.Exit. Если родительский цикл BackgroundService упал из-за непредвиденного исключения, пока процесс работает как служба Windows, без кода выхода через Environment.Exit не сработают действия восстановления службы; обработчики необработанных исключений вроде AppDomain.UnhandledException следует использовать для записи, а не как способ восстановить состояние.

Карта знаний: продолжать или завершать процесс после непредвиденного исключенияСхема, которая показывает, что решение продолжать или завершать после непредвиденного исключения опирается на проверку инвариантов, единицы отказа и внешнего побочного эффекта; что исключения с признаками повреждения памяти и сбои на нативной границе склоняют к завершению; что при непредвиденном исключении в BackgroundService код выхода через Environment.Exit нужен для действий восстановления службы Windows; и что обработчики необработанных исключений служат для записи, а не для восстановлениятребуеттребуеттребуетможет вызватьне рекомендуетсярекомендуется длярекомендуется длярекомендуется дляможет вызватьрекомендуется дляреализуетреализуетможет вызватьрекомендуется длятребуетне рекомендуетсяреализуетне рекомендуетсянесовместимо спредотвращаетпроверяетсянепредвиденное исключениеинвариантпродолжение обработкиединица сбоявнешний побочный эффектглотание исключений в catch (Exception)зомбирование процессазавершение процессаStackOverflowExceptionAccessViolationExceptionEnvironment.FailFastOutOfMemoryExceptionпризнаки повреждения памятиEnvironment.Exitнативная граница (COM/P/Invoke)BackgroundServiceIHostApplicationLifetime.StopApplicationслужба Windowsобработчик необработанных исключенийпопытка восстановления после исключения

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 21, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Что в этой статье называется «неожиданным исключением»

2.1 Отделяем ожидаемое от неожиданного

Сначала: редкое исключение и неожиданное исключение — не одно и то же.

Например, следующее даже при низкой частоте можно считать ожидаемым.

  • Пользователь выбрал несуществующий файл
  • Удалённая сторона временно не ответила по тайм-ауту
  • Одна строка импортируемого CSV была битой
  • При отмене операции вылетело OperationCanceledException
  • Нарушение бизнес-правила, из-за которого нужно провалить только эту операцию

Это тот тип сбоев, для которых обработку можно заранее решить на этапе проектирования.

С другой стороны, неожиданные исключения, о которых в основном эта статья, выглядят так.

  • Сломалось предположение в собственном коде, и вылетело NullReferenceException или InvalidOperationException
  • Исключение вылетело посреди обновления общего состояния, и непонятно, насколько далеко оно успело примениться
  • Упал родительский цикл мониторинга или обработки сообщений
  • Аномалия на границе COM / P/Invoke / vendor SDK
  • Как у AccessViolationException или StackOverflowException, «медосмотр» процесса уже красный

Иначе говоря, это случаи, когда «после этого исключения непонятно, можно ли ещё доверять состоянию приложения».

Где проходит граница ожидаемого и неожиданногоЕсли обработку сбоя можно заранее решить на этапе проектирования, исключение даже при низкой частоте можно считать ожидаемым; неожиданное в этой статье — то, после чего непонятно, можно ли ещё доверять состоянию приложения.можнонельзяИсключение произошлоОбработку можно заранее решить на этапе проектирования?ожидаемое (частота может быть низкой)неожиданное (неясно, можно ли доверять состоянию)

Рис. 3: Редкое исключение и неожиданное исключение — разные вещи; граница в том, можно ли заранее решить обработку на этапе проектирования.

2.2 Кажется, что выбор из двух, на деле их три

Разговор путает то, что «продолжение» рассматривают как один вариант.

На практике обычно три уровня.

Выбор Смысл
Провалить только эту операцию и продолжить Экран остаётся, но именно это сохранение или импорт считается неудавшимся
Остановить только подсистему и продолжить Заново инициализировать только соединение, экран, worker или дочерний процесс
Завершить процесс Область порчи состояния не читается, поэтому исходят из перезапуска

Фраза «приложение продолжает работу» может значить и идти дальше так, будто ничего не было, и продолжить, отрезав сломанную часть — вес разный.

Одно и то же «продолжение», разный вес«Приложение продолжает работу» может значить и идти дальше так, будто ничего не было, и продолжить, отрезав сломанную часть — это вещи разного веса.Приложение продолжает работуидти дальше так, будто ничего не былопродолжить, отрезав сломанную частьодно «продолжение», разный вес

Рис. 4: Не считать продолжение одним видом; отделять продолжение, которое отрезает сломанную часть.

3. Таблица решений, на которую смотреть сначала

3.1 Общая картина

Если начать с этой таблицы, общее направление обычно видно.

Ситуация Первый выбор Почему
Неудачей закончились только один ввод, одна операция на экране или одно задание, и их состояние можно отбросить Склоняться к продолжению Единицу сбоя можно удержать в границах
После исключения затронутый объект или соединение можно уничтожить и собрать заново Склоняться к повторной инициализации подсистемы Область порчи можно локализовать
Общее состояние обновлено наполовину, и непонятно, насколько далеко это применилось Склоняться к завершению Возможно, инвариант уже сломан
Внешние побочные эффекты — БД / файлы / команды устройству — выполнены наполовину, и нельзя объяснить дублирование или потерю Склоняться к завершению Согласованность с внешним миром не читается
Цикл мониторинга, цикл переподключения или родительский цикл обработки сообщений упал из-за неожиданного исключения Склоняться к завершению Если часть функций тихо умрёт, легко получить зомби-процесс
Сбой в коде запуска, чтении настроек, композиции DI или инициализации обязательной зависимости Склоняться к завершению как к сбою запуска Частичный запуск опаснее
AccessViolationException, StackOverflowException, тяжёлый OutOfMemoryException, пахнет порчей на native-стороне Склоняться к немедленному завершению Под вопросом здоровье всего процесса
Опасная обработка изолирована в отдельном процессе, родительский процесс не задет Родитель продолжает, дочерний перезапускают Область отказа уже отделена
Поток решения по таблицеРешение идёт в порядке: пахнет ли порчей памяти / исчерпанием стека / критическим исчерпанием ресурсов, можно ли отбросить сбойную единицу, можно ли откатить или заново инициализировать общее состояние, можно ли объяснить внешние побочные эффекты.данетнетданетданетдаНеожиданное исключениеПахнет порчей памяти / исчерпанием стека / критическим исчерпанием ресурсов?Завершение / FailFast / перезапускСбойную единицу можно отбросить?Склоняться к завершениюОбщее состояние можно откатить / заново инициализировать?Остановить подсистему или завершитьВнешние побочные эффекты можно объяснить?Продолжить, считая неудачной только эту операцию

Рис. 5: Направление выбирают, проверяя по очереди запах порчи, единицу сбоя, общее состояние и внешние побочные эффекты.

«Запах порчи памяти» на первой развилке подробно разбирается в 3.4, FailFast справа вверху — в 9.7.

3.2 Что смотреть раньше типа исключения

Не стоит решать сразу по одному типу исключения. Сначала лучше проверить вот это.

На что смотреть Что проверять
Где произошло Событие UI, одно задание, родительский цикл, код запуска, native-граница
Насколько далеко успело пройти Не изменились ли по пути состояние в памяти, БД, файлы, состояние устройства
Возможная область порчи Только этот объект, весь экран или весь процесс
Возможен ли откат Можно ли уничтожить и собрать заново, откатить транзакцией
Внешние побочные эффекты Отправлено или нет, безопасно ли повторное выполнение, возможна ли компенсация
Наблюдение / перезапуск Есть ли автоматический перезапуск или путь восстановления после падения

3.3 Особо опасные исключения

Разбирать все типы по отдельности не нужно, но есть такие, которые не стоит рассматривать как повод продолжать.

Исключение / признак Первый выбор Почему смотреть
StackOverflowException Склоняться к немедленному завершению Стек вызовов уже сломан, обычное восстановление предполагать трудно
AccessViolationException Склоняться к немедленному завершению Недопустимый доступ к защищённой памяти; подозревают native-границу или порчу памяти
OutOfMemoryException Склоняться к завершению Код восстановления, которому самому нужны новые выделения, легко становится нестабильным
Неожиданный NullReferenceException / InvalidOperationException Зависит от контекста, но склоняться к завершению Сломалось собственное предположение; по пути могли остаться незавершённые изменения
Неожиданное исключение, вытекшее из родительского цикла Склоняться к завершению Ядро функции уже мертво, а процесс рискует остаться живым
Аномалии, которые стартуют с callback COM / P/Invoke / vendor SDK От немедленного до решительного завершения По одному managed-коду безопасность оценить трудно

3.4 Откуда берутся слова «пахнет порчей памяти» и «зомби»

Эти два выражения звучат как ощущения, но на деле смотрят на конкретные вещи.

Сначала симптомы, по которым подозревают порчу памяти.

Что видно Где смотреть
Процесс падает с кодом исключения 0xc0000005 (нарушение доступа) или 0xc0000374 (порча кучи) Просмотр событий > Журналы Windows > Приложение, «Ошибка приложения» (событие 1000)
Место падения каждый раз другое. Падает в коде, который непосредственно перед этим не трогали Логи, стек вызовов в дампе
Падает только когда трогают объект или handle, который уже должны были освободить Шаги воспроизведения, дамп
Падает в native-освобождении (free / delete / освобождение COM) Стек вызовов в дампе (падение внутри ntdll.dll и т. п.)
Значения, которые не трогали, переписываются. При том же входе результат другой Логи входа/выхода, сравнение повторных прогонов

0xc0000005 — STATUS_ACCESS_VIOLATION, 0xc0000374 — STATUS_HEAP_CORRUPTION; оба значения есть в списке NTSTATUS у Microsoft. Особенно порча кучи проявляется не в момент поломки, а у того, кто следующим тронул уже сломанную кучу, поэтому место падения не обязательно виновник. Если симптомы такого вида есть, безопаснее считать, что ловить на managed-стороне и продолжать бессмысленно.

Сдвиг места падения при порче кучиПри порче кучи падение происходит не в момент поломки, а у того, кто следующим тронул уже сломанную кучу, поэтому место падения не обязательно виновник.Где-то ломается кучав этот момент ещё не падаемпадает тот, кто тронул следующимместо падения не обязательно виновник

Рис. 6: При порче кучи падает тот, кто тронул следующим, поэтому место падения и место поломки расходятся.

Дальше симптомы, по которым подозревают зомби.

Что видно Где смотреть
Процесс жив, но время последней обработки не обновляется Лог времени последней обработки, heartbeat
Растёт только число застрявших элементов в очереди или во входной папке Длина очереди, число необработанных файлов
С какого-то момента логи внезапно обрываются Логи приложения
Экран ещё слушается, а обновление сзади уже стоит Сверка того, что на экране, с реальными данными
Число рабочих потоков меньше ожидаемого Диагностический лог, список потоков в Process Explorer

Зомби — это не «ещё не упал», а жив, но работу не делает. Если заранее завести показатели «я ещё работаю» — время последней обработки, число застрявших элементов, heartbeat — и решение продолжать или завершать, и последующий разбор становятся заметно проще.

Показатели, которыми ловят зомбиЗомби — это жить, не делая работу; если заранее завести показатели вроде времени последней обработки, числа застрявших элементов и heartbeat, и решение, и последующий разбор становятся проще.Время последней обработкисначала завести показатели «я работаю»Число застрявших элементовheartbeatпроще решить, продолжать или завершатьпроще разбирать потом

Рис. 7: Зомби ловят не по тому, что процесс ещё не упал, а по показателям, что работа идёт.

4. Решение в зависимости от того, где это произошло

4.1 События UI

У событий UI — клик по кнопке, переход между экранами, поиск, выбор файла — сравнительно много пространства для продолжения. Но есть условия.

Продолжать проще, когда:

  • сбой случился до загрузки, и бизнес-состояние ещё не трогали;
  • сломано только временное состояние внутри диалога, и закрытие диалога его отбрасывает;
  • после исключения можно заново собрать ViewModel или соединение;
  • пользователю можно честно сказать: «эта операция не удалась».

Наоборот, к завершению склоняет, когда:

  • наполовину обновлены и экран, и доменное состояние;
  • тронули общее состояние, которое видят другие экраны — static / singleton / кэш;
  • после исключения остались активность кнопки или состояние выбора, и согласованность уже не читается;
  • на UI-потоке вылетело неожиданное исключение, и непонятно, насколько далеко прошли отрисовка или уведомления.
Развилка исключения в операции UIИсключение в событии UI легче продолжить, если затронуто только временное состояние, которое можно отбросить закрытием; если тронуто общее состояние или обновление наполовину, склоняются к завершению.только временное, которое можно отброситьобщее состояние или обновление наполовинуНеожиданное исключение в операции UIКакое состояние тронули?продолжить, провалив только эту операциюсклоняться к завершению

Рис. 8: У события UI пространства для продолжения много, но если тронуто общее состояние, расклад другой.

4.2 Задания / запросы, которые обрабатывают по одному

Здесь граница, где продолжение даётся легко.

  • Одно сообщение
  • Один файл
  • Один HTTP-запрос
  • Одно задание импорта
  • Один элемент пакетной обработки

Если такие единицы ясны, можно провалить только этот один элемент и идти дальше.

Но есть предпосылки.

  • Единица сбоя ясна снаружи
  • Промежуточные изменения приводятся в порядок транзакцией или компенсацией
  • Повторный прогон той же обработки не портит результат
  • Сбой можно увести в карантинную очередь или журнал ошибок

4.3 Постоянно живущие циклы / мониторинг / обработка очереди

Здесь неаккуратное продолжение опаснее всего.

Например:

  • циклы переподключения;
  • циклы мониторинга;
  • циклы потребления очереди;
  • периодический опрос;
  • мониторинг состояния устройства;
  • постоянно живущая обработка в приложении в области уведомлений (tray).

Страшный сценарий здесь — когда родительский цикл умирает от одного неожиданного исключения, а процесс остаётся жить сам по себе.

Здесь политику лучше разделить.

  • На границе обработки каждого элемента ловить ожидаемые исключения
  • Если неожиданное исключение вытекло из родительского цикла, склоняться к завершению процесса
Как разделять политику в постоянно живущем циклеНа границе обработки каждого элемента ловят ожидаемые исключения; если неожиданное вытекло из родительского цикла, склоняются к завершению процесса.Постоянно живущий циклграница обработки каждого элементародительский циклловить ожидаемые исключенияесли вытекло неожиданное — склоняться к завершениюне оставлять в живых один процесс

Рис. 9: Политику на границе элемента и в родительском цикле разделяют; неожиданное исключение из родительского цикла ведут к завершению.

4.4 Код запуска

Если к сбою при запуске относиться как к «сначала как-нибудь поднимемся, разберёмся потом», процесс продолжает жить с дырой в функциях, и потом причину отделить уже труднее.

  • не читаются обязательные настройки;
  • не удалась миграция версии;
  • нет обязательной папки или сертификата;
  • не удалась инициализация ключевой службы;
  • сломана конфигурация зависимостей.

В таких случаях понятнее завершить как сбой запуска.

4.5 Native-граница / COM / P/Invoke / unsafe

Здесь смотреть отдельно и чуть строже.

  • COM
  • P/Invoke
  • код за пределами C++/CLI
  • vendor SDK
  • native-код, который возвращается через callback
  • обработка с unsafe

Особенно к завершению склоняет, если видно следующее.

  • AccessViolationException
  • симптомы порчи кучи или double free
  • аномалии handle, запах use-after-free
  • внезапная смерть на границе callback
Как обращаться с аномалией на native-границеНа native-границе вроде COM и P/Invoke, если видны AccessViolationException, симптомы порчи кучи или внезапная смерть на границе callback, склоняются к завершению; саму границу смотрят отдельно и строже.AccessViolationExceptionсклоняться к завершениюсимптомы порчи кучи или double freeвнезапная смерть на границе callbacknative-границу смотрят отдельно и строже

Рис. 10: Если на native-границе видны такие симптомы, предпосылку «продолжим» отбрасывают и склоняются к завершению.

5. Условия, при которых можно продолжать

Условия, при которых продолжение допустимо, сводятся к следующему. Предпосылка — что они выполняются примерно все сразу.

Условие Смысл
Единица сбоя ясна Понятно, что отбросить: одну операцию, один экран, одно задание, одно соединение
Состояние можно отбросить Можно уничтожить и собрать заново или считать неприменённым
Общее состояние защищено Заражение не расползается на другие функции
Внешние побочные эффекты можно объяснить Понятно: отправили / не отправили / можно ли отправить снова
Можно быть честным с пользователем Можно показать «эта операция не удалась»
Есть наблюдаемость По логам, метрикам и дампам можно потом разобрать, что случилось

6. Условия, при которых лучше завершить

Наоборот, если подходит что-то из следующего, склоняйтесь к завершению.

  • непонятно, что именно успели изменить по пути;
  • тронуто общее изменяемое состояние, и согласованность не читается;
  • сломано управление временем жизни блокировок, очередей, потоков или циклов мониторинга;
  • нельзя объяснить дублирование, потерю или незавершённость внешних побочных эффектов;
  • не удался запуск или инициализация ключевой инфраструктуры;
  • подозревают native-границу или порчу памяти.

На этом уровне усилия красиво продолжить уступают усилиям упасть так, чтобы потом легко подняться.

Какие усилия работают, когда склоняются к завершениюЕсли уже выполняются условия в пользу завершения, усилия красиво продолжить работают слабо; лучше вкладываться в то, чтобы упасть и легко восстановиться.Подходят условия в пользу завершенияусилия красиво продолжитьусилия упасть так, чтобы легко восстановитьсяна этом уровне работают слабоработают лучше

Рис. 11: Когда ситуация уже в пользу завершения, вкладываться стоит не в продолжение, а в лёгкость восстановления.

7. Рекомендации по типичным сценариям

Таблица в 3.1 написана в виде условий, поэтому при примерке к реальной ситуации можно застрять. Эта таблица — те же условия, приложенные к часто встречающимся сценам. Если застряли, быстрее сначала проверить условия в 3.1, затем в этой таблице искать близкую строку.

Как пользоваться таблицами, если застрялиЕсли примерка к сцене буксует, сначала проверяют условия в таблице решений 3.1, затем в таблице этой главы ищут близкую строку.Застряли на примерке к сценепроверить условия в таблице 3.1искать близкую строку в таблице этой главы

Рис. 12: От таблицы условий к таблице сцен — в таком порядке меньше путаницы.

Сценарий Рекомендация Почему
В кнопке открытия файла указан несуществующий путь Продолжить, провалив только эту операцию Порча состояния локальна
В импорте CSV бита только одна строка Продолжить, провалив одну строку или один файл Единицу сбоя легко удержать в границах
Посреди сохранения экрана вылетело неожиданное NullReferenceException От пересоздания экрана до завершения Непонятно, насколько далеко изменились ViewModel / бизнес-состояние
Одно сообщение из очереди нарушило бизнес-правило Продолжить, провалив только это сообщение Его можно увести в карантинную очередь
Родительский цикл потребления очереди упал из-за неожиданного исключения Склоняться к завершению процесса Сломано время жизни всего worker
При запуске не читаются обязательные настройки Завершить как сбой запуска Частичный запуск опаснее
AccessViolationException вокруг callback vendor SDK Склоняться к немедленному завершению Возможность порчи памяти нельзя игнорировать
Не удалась только несущественная отправка телеметрии Выключить только эту функцию и продолжить Область отказа отделяется от основной функции

8. Типичные ошибки

8.1 catch (Exception), запись в лог и продолжение

Это довольно опасно. Причина прячется, а сломанное состояние легко продолжает жить.

8.2 Попытка восстановиться в последнем обработчике необработанных исключений

AppDomain.UnhandledException, Application.ThreadException, DispatcherUnhandledException и подобные полезны как место последней записи, но не являются волшебной точкой восстановления.

8.3 Беспечный retry при внешних побочных эффектах

Если без гарантии безопасного повторного выполнения повторять команды устройству, отправку писем, списание, перемещение файлов или обновление БД, на первый план выходит уже инцидент двойного выполнения.

Беспечный retry приводит к двойному выполнениюЕсли обработку с внешним побочным эффектом повторяют без гарантии безопасного повторного выполнения, на первый план выходит инцидент двойного выполнения.Сбой обработки с внешним побочным эффектомretry без безопасного повторного выполненияна первый план выходит инцидент двойного выполнениякоманды устройству, списание, отправка и т. п.

Рис. 13: Retry без безопасного повторного выполнения приводит к инциденту двойного выполнения.

8.4 Цикл мониторинга уже мёртв, а UI ещё жив

Приложение, которое с виду живое, а работу не делает, с точки зрения пользователя выглядит нормальным, поэтому обнаружение запаздывает. Стоит сделать так, чтобы сторона наблюдения ловила симптомы зомби из 3.4.

8.5 Говорить «не хотим падать», не спроектировав падение

Если падать не хотите, сначала нужно положить вот это.

  • автоматический перезапуск
  • восстановление сеанса
  • сохранение промежуточных результатов
  • безопасное повторное выполнение
  • отделение области отказа

9. На что смотреть при реализации

9.1 Сдвигать места catch к границам

Вместо того чтобы ловить всё подряд в глубоких слоях, проще навести порядок, если принимать исключения там, где можно определить единицу сбоя:

  • граница операции UI;
  • граница одного запроса;
  • граница одного задания;
  • граница одного соединения;
  • граница процесса.

Например, в импорте по одному элементу catch ставят внутри цикла. Это и есть единица сбоя.

// C# / .NET 8. Пример цикла по одному элементу: единица сбоя замкнута внутри цикла.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;

public sealed record ImportItem(string Id, string Payload);
public sealed record ImportFailure(string Id, string ExceptionType, string Message);
public sealed record ImportSummary(int Succeeded, IReadOnlyList<ImportFailure> Failed);

public interface IImportStore
{
    /// <summary>
    /// Сбой одного элемента (ошибка проверки, дубликат, битый формат и т. п.) бросать как ImportItemException.
    /// Всё остальное — не «проблема этого одного элемента», поэтому выпускать наружу как есть.
    /// </summary>
    Task SaveAsync(ImportItem item, CancellationToken cancellationToken);
}

/// <summary>Сбой одного элемента, который можно отбросить и идти дальше.</summary>
public sealed class ImportItemException(string message, Exception? inner = null)
    : Exception(message, inner);

public sealed class ImportRunner(IImportStore store, ILogger<ImportRunner> logger)
{
    public async Task<ImportSummary> RunAsync(
        IReadOnlyList<ImportItem> items,
        CancellationToken cancellationToken)
    {
        int succeeded = 0;
        List<ImportFailure> failed = [];

        foreach (ImportItem item in items)
        {
            cancellationToken.ThrowIfCancellationRequested();

            try
            {
                await store.SaveAsync(item, cancellationToken);
                succeeded++;
            }
            catch (ImportItemException ex)
            {
                // Состояние одного элемента можно отбросить, поэтому записываем и идём к следующему.
                //
                // Не делать здесь catch (Exception). Если проглотить и NullReferenceException, и
                // OutOfMemoryException как «просто плохие данные», неожиданное исключение, которое
                // в 4.3 решили останавливать вместе с хостом, до родительского цикла не дойдёт.
                // После того как состоянию уже нельзя доверять, вы продолжите писать остальные элементы.
                logger.LogError(ex, "Import failed. ItemId={ItemId}", item.Id);
                failed.Add(new ImportFailure(item.Id, ex.GetType().Name, ex.Message));
            }
        }

        return new ImportSummary(succeeded, failed);
    }
}

Обратная сторона того же решения: на стороне родительского цикла, который крутит эту обработку, не глотать. Как в 4.3, хуже всего, когда родительский цикл уже мёртв, а процесс ещё жив, поэтому неожиданное исключение ведут к остановке хоста.

// Сторона постоянно живущего цикла. Запрос остановки выходит как нормальный путь;
// любое другое неожиданное исключение останавливает хост целиком.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

public interface IImportQueue
{
    Task<IReadOnlyList<ImportItem>> DequeueBatchAsync(CancellationToken cancellationToken);
}

public sealed class ImportWorker(
    IImportQueue queue,
    ImportRunner runner,
    IHostApplicationLifetime lifetime,
    ILogger<ImportWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            using PeriodicTimer timer = new(TimeSpan.FromSeconds(10));

            while (await timer.WaitForNextTickAsync(stoppingToken))
            {
                IReadOnlyList<ImportItem> batch = await queue.DequeueBatchAsync(stoppingToken);
                ImportSummary summary = await runner.RunAsync(batch, stoppingToken);

                logger.LogInformation(
                    "Batch finished. Succeeded={Succeeded} Failed={Failed}",
                    summary.Succeeded,
                    summary.Failed.Count);
            }
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
            // Запрос остановки — нормальный путь. Здесь тихо выходим.
        }
        catch (Exception ex)
        {
            // Сломался родительский цикл = сломано время жизни всего worker.
            // Не глотать и не оставлять «жив только процесс»: останавливаем и отдаём на перезапуск.
            logger.LogCritical(ex, "Worker loop failed unexpectedly. Stopping the host.");

            // StopApplication — это запрос «аккуратно сложиться». Одного этого мало:
            // сторона наблюдения, которая перезапускает только при сбое (восстановление службы,
            // Restart=on-failure у systemd, политика restart контейнера), не отличит это
            // от «работу закончили нормально», и процесс больше не встанет.
            //
            // Поставить Environment.ExitCode тоже недостаточно. Если крутимся как служба Windows,
            // при нормальной остановке хоста SCM получает SERVICE_STOPPED, ExitCode в состояние
            // завершения службы не попадает, и операции восстановления не стартуют.
            // Процесс завершают с ненулевым кодом.
            Environment.Exit(1);
        }
    }
}

Ключевое место — вызов Environment.Exit(1). IHostApplicationLifetime.StopApplication() — это запрос нормальной остановки, поэтому при работе как службы Windows SCM получает SERVICE_STOPPED. Environment.ExitCode процесса в состояние завершения службы не попадает, поэтому операции восстановления, заданные в свойствах службы («перезапустить службу»), не стартуют. В официальном руководстве по worker-службе прямо сказано: поведение по умолчанию BackgroundServiceExceptionBehavior.StopHost «аккуратно останавливает», поэтому сторона управления службами Windows не перезапускает; чтобы заработали операции восстановления, нужно вызвать Environment.Exit с ненулевым кодом (см. справочные материалы в главе 11).

Environment.Exit убивает текущий процесс, поэтому логи, которые хотите успеть отдать до падения, нужно дописать до этой строки. Если логгер буферизует, вставьте сброс. Наоборот, если напротив не служба Windows, а только systemd или контейнер, можно выставить Environment.ExitCode и сложиться через StopApplication(): сторона наблюдения всё равно увидит сбой. Выбирайте по тому, в какой среде это крутится.

Ещё одна точка: роль catch внутри цикла и снаружи разная. Внутри — запись единицы сбоя, снаружи — конец времени жизни. Если перепутать, одно сбойное задание уронит приложение, или наоборот worker уже мёртв, а процесс ещё жив.

Роли catch внутри и снаружиCatch внутри цикла записывает единицу сбоя, снаружи завершает время жизни; если перепутать, либо одно сбойное задание уронит приложение, либо умрёт worker, а процесс останется.catch внутри циклазапись единицы сбояcatch снаружи циклаконец времени жизниесли перепутать — падаем от одного сбояесли перепутать — остаётся только процесс

Рис. 14: У catch внутри и снаружи цикла разные роли; путаница даёт аварию в обе стороны.

9.2 Отделять ожидаемые исключения от неожиданных

  • Ожидаемые: validation, not found, timeout, cancel, нарушение бизнес-правила
  • Неожиданные: сломанное предположение, утечка из родительского цикла, аномалия на native-границе, запах порчи памяти

9.3 Уменьшать общее состояние

Чем больше общее изменяемое состояние, тем труднее решение о продолжении. Наоборот, чем сильнее удаётся замкнуть его внутри одного экрана, одной сессии, одного worker, тем легче удержать и сбой.

9.4 Опасную обработку уводить в отдельный процесс

Для всего, для чего не хотите расползания ущерба при падении — COM / ActiveX / vendor SDK / unsafe / тяжёлая обработка изображений / управление внешним оборудованием — вынос в отдельный процесс заметно помогает.

9.5 Обработчики необработанных исключений — для «записи», не для «восстановления»

  • сведения об исключении
  • контекст операции
  • важные логи непосредственно перед падением
  • настройки / версия / адреса подключения
  • путь снятия дампа

Если это собрать заранее и сделать приоритетом возможность разобраться уже после падения, система в итоге стабильнее.

Для WPF обработчика «только запись» хватает примерно такого вида.

// App.xaml.cs для WPF (.NET 8). Обработчики — для «записи», не для «восстановления».
using System;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using System.Windows;

namespace SampleApp;

public partial class App : Application
{
    private static readonly string CrashLogPath = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
        "SampleApp",
        "crash.log");

    protected override void OnStartup(StartupEventArgs e)
    {
        // Регистрацию обработчиков ставить до base.OnStartup(e).
        // base.OnStartup поднимает событие Startup; если исключение вылетит
        // у подписчика, обработчики, написанные ниже, ещё не зарегистрированы,
        // и получается самый неприятный вид: «упали на запуске, в логе ни строки».
        // Именно сбой самого запуска и хочется записать, поэтому вешаем заранее.

        // Исключение, которое на UI-потоке дошло необработанным
        DispatcherUnhandledException += (_, args) =>
        {
            Record("DispatcherUnhandledException", args.Exception);

            // args.Handled = true позволит продолжить, но можно ли продолжать —
            // смотреть по условиям главы 5. Если уверенности нет, записать
            // и оставить поведение по умолчанию (завершение).
            args.Handled = false;
        };

        // Последнее уведомление, в том числе не с UI-потока. Здесь уже не остановить, только записать.
        AppDomain.CurrentDomain.UnhandledException += (_, args) =>
            Record("AppDomain.UnhandledException", args.ExceptionObject as Exception);

        // Исключение Task, которое так и не await-нули и потом собрали
        TaskScheduler.UnobservedTaskException += (_, args) =>
        {
            Record("UnobservedTaskException", args.Exception);
            args.SetObserved();
        };

        // Когда всё повешено, идём в запуск по умолчанию (подъём события Startup)
        base.OnStartup(e);
    }

    private static void Record(string source, Exception? exception)
    {
        try
        {
            Directory.CreateDirectory(Path.GetDirectoryName(CrashLogPath)!);

            string version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
            string text = string.Join(
                Environment.NewLine,
                $"[{DateTimeOffset.Now:O}] {source}",
                $"version={version} os={Environment.OSVersion} user={Environment.UserName}",
                exception?.ToString() ?? "(no exception object)",
                string.Empty);

            File.AppendAllText(CrashLogPath, text);
        }
        catch
        {
            // Сбой записи не должен останавливать завершение.
        }
    }
}

Главное — внутри обработчика не чинить состояние. Здесь делают только одно: оставить материал, по которому потом можно разобрать тот же симптом.

Поток обработчика только для записиВ WPF обработчики регистрируют до base.OnStartup, затем идут в запуск; внутри обработчика состояние не чинят, оставляют только материал, по которому потом можно разобрать тот же симптом.Сначала зарегистрировать обработчикиидти в base.OnStartupзаписать необработанное исключениесостояние не чинить

Рис. 15: Регистрацию заканчивают до запуска; в обработчике только записывают.

9.6 Не переоценивать события необработанных исключений в WPF / WinForms

В WPF, если в DispatcherUnhandledException поставить Handled = true, после необработанного исключения действительно можно продолжить. В Windows Forms на главном UI-потоке способ остановки тоже можно выбрать через Application.ThreadException и SetUnhandledExceptionMode.

Но то, можно ли продолжить, и то, выполнены ли условия восстановления, — разные вопросы.

9.7 Environment.FailFast — только когда «убирать опаснее»

FailFast на блок-схеме в 3.1 — это Environment.FailFast. Поведение, которое описано в официальной документации, такое.

  • Процесс завершается, не выполняя ни текущие try / finally, ни finalizer
  • В Windows переданное сообщение пишется в журнал событий приложений Windows, затем снимается дамп приложения, и уже потом процесс завершается
  • Сообщение и сведения об исключении входят и в отчёт об ошибке в Microsoft через Windows Error Reporting
  • Если вызвать под отладчиком Visual Studio, получается ExecutionEngineException и срабатывает managed debugging assistant fatalExecutionEngineError

То, что finally не выполняется, — не недостаток, а цель этого API. Если при уже сломанном состоянии гонять код уборки, сломанное содержимое иногда записывают в файл или БД. В документации тоже сказано: если состояние приложения сломано неисправимо, и выполнение try / finally или finalizer само испортит ресурсы, использовать не Environment.Exit, а FailFast.

Как выбирать:

Ситуация Что брать
Инвариант сломан, гонять уборку опаснее Environment.FailFast
Состояние здорово, хотим сначала убраться и выйти Нормальное завершение (IHostApplicationLifetime.StopApplication и т. п.)
Просто вернуть код завершения и выйти Environment.Exit или return из Main

В коде это место выглядит так.

// C# / .NET 8. Место, где обнаружили, что инвариант общего состояния сломан.
// Дальше ни одной уборке доверять нельзя.
if (cache.Count != store.Count)
{
    Environment.FailFast(
        $"Invariant broken: cache={cache.Count} store={store.Count}",
        new InvalidOperationException("Cache and store are out of sync."));
}

Дамп снимается сам, поэтому в сообщение FailFast стоит класть какой инвариант какими значениями сломался — последующий разбор становится заметно проще. Наоборот, вызывать FailFast на ожидаемый сбой вроде опечатки во вводе или ошибки связи — избыточно. Это возвращает к разговору об единице сбоя в 9.1.

Почему FailFast не гоняет уборкуЕсли при неисправимо сломанном состоянии гонять код уборки, можно записать сломанное содержимое, поэтому Environment.FailFast завершает процесс, не выполняя try, finally и finalizer, а в Windows оставляет журнал событий и дамп.Инвариант сломансразу завершить через FailFastне выполнять ни finally, ни finalizerоставить дамп и журнал событий (Windows)не дать записать сломанное содержимое

Рис. 16: FailFast не гоняет уборку и тем самым не даёт записать сломанное состояние.

10. Итог

Когда случилось неожиданное исключение, смотреть нужно не на «можно ли поймать это исключение», а на то, можно ли после этого ещё доверять состоянию приложения.

Порядка решения обычно хватает такого.

  1. Можно ли отбросить сбойную единицу?
  2. Можно ли вернуть или заново собрать общее состояние?
  3. Можно ли объяснить внешние побочные эффекты?
  4. Можно ли доверять здоровью памяти / потоков / native-границы?

Если во всех четырёх уверены — можно продолжать. Если уверенности нет — склоняйтесь к завершению.

Порядок решения в итогеПо очереди проверяют, можно ли отбросить единицу сбоя, вернуть общее состояние, объяснить внешние побочные эффекты и доверять здоровью вплоть до native-границы; продолжают только если во всех четырёх уверены, иначе склоняются к завершению.во всех четырёх увереныуверенности нетМожно ли отбросить сбойную единицу?Можно ли вернуть общее состояние?Можно ли объяснить внешние побочные эффекты?Можно ли доверять здоровью?можно продолжатьсклоняться к завершению

Рис. 17: На четыре вопроса отвечают по порядку и выбирают продолжение только если во всех уверены.

Особенно у долго живущих приложений, приложений мониторинга, служб и связи с оборудованием нередко жить в сломанном состоянии опаснее, чем честно упасть.

Обработка исключений — это не «искусство никогда не падать». Это проектирование, при котором масштаб порчи минимален, при поломке приложение честно останавливается, а восстановиться легко.

11. Справочные материалы

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Технические консультации и ревью дизайна

Речь о том, как выстроить политику обработки исключений, границы отказа, стратегию перезапуска и критерии «продолжать или нет», поэтому тема хорошо стыкуется с технической консультацией и ревью архитектуры.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Нельзя ли ловить неожиданное исключение через catch (Exception), проглотить его и продолжить?
Продолжать, только записав в лог, в большинстве случаев опасно: это прячет причину и легко продлевает жизнь уже сломанному состоянию. Продолжать можно, когда одновременно выполняются три условия: сбойную единицу можно отбросить, общее состояние можно вернуть, внешние побочные эффекты можно объяснить. Ось решения — не «можно ли поймать это исключение», а «можно ли после этого ещё доверять состоянию приложения».
После каких исключений нужно сразу завершать процесс?
Исключения, которые ставят под сомнение здоровье всего процесса — StackOverflowException, AccessViolationException, тяжёлый OutOfMemoryException, — безопаснее не рассматривать как повод продолжать. При StackOverflowException стек вызовов уже сломан, AccessViolationException — это недопустимый доступ к защищённой памяти, и подозревают порчу памяти. Аномалии, которые стартуют с callback COM, P/Invoke или vendor SDK, тоже сильнее склоняют к завершению: по одному managed-коду безопасность оценить трудно.
При каких условиях после исключения можно продолжать работу приложения?
Предпосылка — что примерно одновременно выполняются такие условия: единица сбоя ясна (понятно, что отбросить: одну операцию, один экран, одно задание, одно соединение); состояние можно выбросить и собрать заново; заражение не расползается по общему состоянию; внешние побочные эффекты можно объяснить; пользователю можно честно сказать, что «эта операция не удалась»; по логам и метрикам можно потом разобрать, что случилось. Если граница обработки чёткая, как у одной операции UI или одного задания импорта, продолжение иногда возможно. Наоборот, аномалии посреди обновления общего состояния, в родительском цикле, при запуске или на native-границе склоняют к завершению.
В WPF можно продолжить, если в DispatcherUnhandledException поставить Handled = true?
Продолжить после необработанного исключения технически можно, но «можно продолжить» и «безопасно продолжать» — разные вещи. Обработчики вроде AppDomain.UnhandledException и DispatcherUnhandledException полезны как место последней записи, но не как волшебная точка восстановления. В итоге стабильнее собрать сведения об исключении, контекст операции и путь снятия дампа, чтобы разбирать уже после падения. Особенно у долго живущих служб и приложений мониторинга чаще безопаснее и проще диагностировать падение с перезапуском, чем жизнь в наполовину сломанном состоянии.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог