Асинхронность в C#. Часть 8: опять исключения


(В этом сообщении я буду говорить о внутренних, неприятных, дурных и критических исключениях. Для разъяснения этих терминов, загляните сюда.)

Если в вашем процессе происходит необработанное исключение, то явно произошло что-то плохое и непредвиденное. Если это критическое исключение, то шансов спасти процесс у вас уже нет – с ним уже все кончено. Вы можете так и оставить его необработанным, или просто сделать запись в журнале его и пробросить дальше. Если бы предполагалась генерация этого исключения, то, поскольку оно является внутренним или неприятным, в нужном месте должен был бы быть его обработчик. Необработанное неприятное исключение является сбоем, однако оно не показывает наличие проблем в логике работы программы, скорее это всего лишь недосмотр.

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

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

Эта философия лежит в основе реализации необработанных исключений в CLR. В старые времена CLR v.1.0 поведение было таким, что только необработанные исключения «основного» потока приводили к принудительному завершению выполнения процесса, но необработанные исключения «рабочих» потоков просто приводили к завершению выполнения этого потока, и не приводили к завершению основного потока. (А исключения в потоке завершения игнорировались и методы завершения продолжали выполняться.) Это оказался плохим вариантом; это приводило к тому, что сервер назначал выполнение некоторой работы сбойным подсистемам с множеством рабочих потоков; эти рабочие потоки втихую завершали свое выполнение и пользователь продолжать терпеливо ждать от сервера результатов, которых никогда не будет, поскольку все рабочие потоки, получающие эти результаты, уже исчезли. Пользователю очень сложно диагностировать эту проблему; сервер, интенсивно работающий над какой-то задачей, и сервер, чьи рабочие потоки приказали долго жить, для пользователя выглядят совершенно одинаково. Поэтому эта политика была изменена в CLR 2.0 и необработанные исключения рабочих потоков по умолчанию также стали приводить к завершению процесса. Вы должны громко трубить о своих проблемах, а не скрывать их.

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

В прошлый раз я упомянул о двух интересных сценариях: (1) что если метод, возвращающий задачу, вызывает WhenAll или WhenAny для нескольких задач, часть из которых генерируют исключения? и (2) что если асинхронный метод, возвращающий void, ожидает завершение выполнения задачи, которая завершится ненормально? Что произойдет с исключением?

Давайте рассмотрим первый случай.

Метод WhenAll собирает все исключения завершившихся подзадач в агрегированное исключение. Когда все подзадачи завершатся, этот метод завершит свою задачу не нормально с этим агрегированным исключением. Однако весьма странно, что по умолчанию, метод EndAwait регенерирует только первое из этих исключений; он не регенерирует все агрегированное исключение. Наиболее часто блоки try-catch, окружающие операторы “await” будут перехватывать конкретные типы исключений; каждый раз заставлять пользователя распаковывать агрегированное исключение кажется через чур обременительным. Это может показаться странным; более подробную информацию о том, почему эта идея является разумной, читайте в недавнем сообщении Джона Скита (Jon Skeet).

С методом WhenAny ситуация аналогична. Предположим, первая подзадача завершилась – нормально или ненормально. Это приводит к завершению задачи WhenAny – нормально или ненормально. Предположим, что еще одна подзадача завершилась ненормально; что будет с этим исключением? Задача WhenAny завершена: она уже завершена и ее продолжение уже вызвано, и будет выполнено в некоторой рабочей очереди, если оно еще не выполнено.

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

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

Предположим, что мы решили, что да, должно; незамеченное исключение должно приводить к завершению работы процесса. Когда это может произойти? Т.е. когда мы точно будем знать, что исключение не будет проброшено далее? Мы будем знать об этом только тогда, когда для объекта задачи будет вызван метод завершения, без обработки результатов этой задачи. В конце концов, продолжение «живого» объекта задачи, чье выполнение завершилось не нормально, может быть выполнено когда угодно; этот объект не может знать, когда именно это произойдет. Может существовать любое количество задач в очереди этого потока, которые должны быть выполнены между временем ненормального завершения этой задачи и обработкой ее результатов. Пока объект задачи жив, ее исключение может быть обработано.

Хорошо, если у задачи метод завершения вызван, и он завершился неудачно, тогда мы… что? Сгенерируем исключение в потоке завершения? Конечно! Это приведет к завершению выполнения процесса, правильно? В CLR версии 2.0 и выше необработанные исключения в любом потоке приводят к завершению процесса. Но давайте сделаем шаг назад. Напомните-ка мне, почему мы хотим, чтобы незамеченное исключение приводило к завершению процесса? Следующая философская причина: мы не можем определить, является ли это дурным исключением, угрожающим безопасности, которое говорит о потенциально ужасной ситуации, или же это просто результат забытого обработчика неожиданного внутреннего исключения. Наиболее безопасно рассматривать это исключение, как дурное исключение, угрожающее безопасности и немедленно завершить выполнение процесса. А это в точности то, чего мы не делаем! Мы ожидаем, пока объект задания будет утилизирован сборщиком мусора и затем пытаемся завершить выполнения процесса в потоке завершения. Однако между сохранением этого исключения и получением его в потоке завершения, потенциально мы можем продолжать выполнение десятка запущенных задач, каждая из которых может использовать некорректное состояние, полученное из-за дурного исключения.

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

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

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

Я не говорил, что произойдет с методами, возвращающими void и ожидающих выполнение каких-то задач; вы можете рассматривать эти методы, как методы типа «вызвать и забыть». Предположим, что обработчик кнопки мыши, возвращающий void, ожидает асинхронного получения некоторых данных и затем обновляет пользовательский интерфейс; у обработчика нет «вызывающего кода», который ожидает завершение задачи и обработает ее результаты. Так что будет, если задача получения данных завершится неудачно?

В этом случае, когда метод, возвращающий void (который, помните, регистрирует себя в качестве продолжения) запустится снова, он проверит, завершилась ли задача неудачно. Если это так, тогда он сразу же пробросит исключение вызывающему коду, который, в данном случае будет, скорее всего, циклом обработки сообщений. Я думаю, что нужно быть последовательным с поведением, описанным ранее; в этом случае цикл обработки сообщений должен отбросить это исключение, полагая, что асинхронный метод типа «вызвать и забыть» завершился неудачно, но благоприятно.

Будучи в течение множества лет защитником философии «сбрасывания бомбы с орбиты», этот вариант не подходит ко мне эмоционально; однако я не могу найти убедительных аргументов против этой стратегии обработки исключений асинхронности на основе задач. Читатели: Что вы думаете об этом? Каково ваше мнение по поводу сценариев, при которых исключение задачи остается необработанным?

И на этой грозной ноте, я собираюсь сделать перерыв в разговорах об асинхронности на основе задач. Пожалуйста, скачайте CTP, продолжайте присылать нам свои мнения и вопросы, и начните думать о том, что будет работать хорошо, а что плохо с этой новой возможностью. В следующий раз: мы продолжим наши невероятные приключения после Дня Благодарения; я готовлю индейку вот уже в течение 19 лет, что тоже будет своего рода приключением.

Оригинал статьи

Comments (0)

Skip to main content