Правильный способ работы с исключениями в AsyncDispose
При переходе на новые.NET Core 3 IAsynsDisposable
, Я наткнулся на следующую проблему.
Суть проблемы: если DisposeAsync
выдает исключение, это исключение скрывает любые исключения, созданные внутри await using
-блок.
class Program
{
static async Task Main()
{
try
{
await using (var d = new D())
{
throw new ArgumentException("I'm inside using");
}
}
catch (Exception e)
{
Console.WriteLine(e.Message); // prints I'm inside dispose
}
}
}
class D : IAsyncDisposable
{
public async ValueTask DisposeAsync()
{
await Task.Delay(1);
throw new Exception("I'm inside dispose");
}
}
То, что попадает в ловушку, это AsyncDispose
-исключение, если оно выброшено, и исключение изнутри await using
только если AsyncDispose
не бросает.
Однако я бы предпочел иначе: получение исключения из await using
заблокировать, если возможно, и DisposeAsync
-исключение, только если await using
блок успешно завершен.
Обоснование: представьте, что мой класс D
работает с некоторыми сетевыми ресурсами и подписывается на некоторые удаленные уведомления. Код внутриawait using
может сделать что-то не так и нарушить канал связи, после чего код в Dispose, который пытается корректно закрыть сообщение (например, отменить подписку на уведомления), тоже не сработает. Но первое исключение дает мне реальную информацию о проблеме, а второе - просто второстепенная проблема.
В другом случае, когда основная часть пробежала и утилизация не удалась, настоящая проблема внутри DisposeAsync
, поэтому исключение из DisposeAsync
соответствующий. Это означает, что просто подавление всех исключений внутриDisposeAsync
не должно быть хорошей идеей.
Я знаю, что есть та же проблема с неасинхронным случаем: исключение в finally
отменяет исключение в try
, поэтому не рекомендуется добавлять Dispose()
. Но с классами доступа к сети подавление исключений в методах закрытия выглядит не очень хорошо.
Обойти проблему можно с помощью следующего помощника:
static class AsyncTools
{
public static async Task UsingAsync<T>(this T disposable, Func<T, Task> task)
where T : IAsyncDisposable
{
bool trySucceeded = false;
try
{
await task(disposable);
trySucceeded = true;
}
finally
{
if (trySucceeded)
await disposable.DisposeAsync();
else // must suppress exceptions
try { await disposable.DisposeAsync(); } catch { }
}
}
}
и используйте это как
await new D().UsingAsync(d =>
{
throw new ArgumentException("I'm inside using");
});
что довольно уродливо (и запрещает такие вещи, как ранний возврат внутри блока using).
Есть ли хорошее каноническое решение с await using
если возможно? Мой поиск в интернете не нашел даже обсуждения этой проблемы.
4 ответа
Есть исключения, которые вы хотите выявить (прервать текущий запрос или остановить процесс), и есть исключения, которые, как ожидает ваш дизайн, будут иногда возникать, и вы можете их обработать (например, повторить попытку и продолжить).
Но различие между этими двумя типами зависит от конечной вызывающей стороны кода - в этом весь смысл исключений, чтобы оставить решение на усмотрение вызывающей стороны.
Иногда вызывающий будет уделять больше внимания обнаружению исключения из исходного блока кода, а иногда исключения из Dispose
. Не существует общего правила для определения приоритета. CLR, по крайней мере, согласована (как вы заметили) между синхронным и неасинхронным поведением.
Возможно, жаль, что теперь у нас есть AggregateException
для представления нескольких исключений его нельзя модифицировать для решения этой проблемы. то есть, если исключение уже находится в процессе, а другое выброшено, они объединяются вAggregateException
. Вcatch
механизм может быть изменен так, что если вы напишете catch (MyException)
тогда он поймает любой AggregateException
который включает исключение типа MyException
. Однако есть множество других сложностей, связанных с этой идеей, и, вероятно, сейчас слишком рискованно изменять что-то настолько фундаментальное.
Вы могли бы улучшить свой UsingAsync
для поддержки досрочного возврата значения:
public static async Task<R> UsingAsync<T, R>(this T disposable, Func<T, Task<R>> task)
where T : IAsyncDisposable
{
bool trySucceeded = false;
R result;
try
{
result = await task(disposable);
trySucceeded = true;
}
finally
{
if (trySucceeded)
await disposable.DisposeAsync();
else // must suppress exceptions
try { await disposable.DisposeAsync(); } catch { }
}
return result;
}
Возможно, вы уже понимаете, почему это происходит, но это стоит пояснить. Это поведение не характерно дляawait using
. Это случилось бы с простым using
блок тоже. Итак, пока я говорюDispose()
здесь все применимо к DisposeAsync()
тоже.
А using
блок - это просто синтаксический сахар для try
/finally
block, как сказано в разделе примечаний документации. То, что вы видите, происходит потому, чтоfinally
блок всегда запускается, даже после исключения. Итак, если произойдет исключение, и нетcatch
блокируется, исключение приостанавливается до тех пор, пока finally
блок запускается, а затем создается исключение. Но если вfinally
, вы никогда не увидите старое исключение.
Вы можете увидеть это на следующем примере:
try {
throw new Exception("Inside try");
} finally {
throw new Exception("Inside finally");
}
Неважно, Dispose()
или DisposeAsync()
называется внутри finally
. Поведение такое же.
Моя первая мысль: не бросай Dispose()
. Но после просмотра некоторого кода собственного Microsoft я думаю, что это зависит от обстоятельств.
Взгляните на их реализацию FileStream
, например. Оба синхронных Dispose()
метод и DisposeAsync()
может действительно вызывать исключения. СинхронныйDispose()
это игнорировать некоторые исключения намеренно, но не все.
Но я думаю, что важно учитывать характер вашего класса. ВFileStream
, например, Dispose()
сбросит буфер в файловую систему. Это очень важная задача, и вам нужно знать, не удалось ли она. Вы не можете просто игнорировать это.
Однако в других типах объектов при вызове Dispose()
, вы действительно больше не нуждаетесь в этом объекте. ВызовDispose()
на самом деле просто означает "этот объект мертв для меня". Возможно, он очищает часть выделенной памяти, но сбой никак не влияет на работу вашего приложения. В этом случае вы можете решить игнорировать исключение внутри вашегоDispose()
.
Но в любом случае, если вы хотите различать исключение внутри using
или исключение, возникшее из Dispose()
, тогда вам понадобится try
/catch
блокировать как внутри, так и снаружи вашего using
блок:
try {
await using (var d = new D())
{
try
{
throw new ArgumentException("I'm inside using");
}
catch (Exception e)
{
Console.WriteLine(e.Message); // prints I'm inside using
}
}
} catch (Exception e) {
Console.WriteLine(e.Message); // prints I'm inside dispose
}
Или вы могли просто не использовать using
. Напишитеtry
/catch
/finally
заблокировать себя, где вы поймаете любое исключение в finally
:
var d = new D();
try
{
throw new ArgumentException("I'm inside try");
}
catch (Exception e)
{
Console.WriteLine(e.Message); // prints I'm inside try
}
finally
{
try
{
if (D != null) await D.DisposeAsync();
}
catch (Exception e)
{
Console.WriteLine(e.Message); // prints I'm inside dispose
}
}
Using - это эффективный код обработки исключений (синтаксический сахар для try...finally...Dispose()).
Если ваш код обработки исключений выдает исключения, что-то по-королевски не работает.
Что бы еще ни случилось, чтобы вы туда попали, больше не имеет значения. Неправильный код обработки исключений скроет все возможные исключения, так или иначе. Код обработки исключений должен быть фиксированным, имеющим абсолютный приоритет. Без этого вы никогда не получите достаточно отладочных данных для реальной проблемы. Я очень часто вижу, что это делается неправильно. Ошибиться примерно так же легко, как и при работе с голыми указателями. Часто по тематической ссылке, на которую я ссылаюсь, есть две статьи, которые могут помочь вам с любыми основными ошибочными представлениями о дизайне:
- Классификация исключений, на которые следует обратить внимание
- Общие надлежащие практики, которые классификация не может охватить
В зависимости от классификации исключений это то, что вам нужно сделать, если ваш код обработки исключений /Dipose выдает исключение:
Для Fatal, Boneheaded и Vexing решение одно и то же.
Экзогенных исключений следует избегать даже за счет серьезных затрат. Есть причина, по которой мы все еще используемфайлы журналов, а небазы данных журналов для журналирования исключений - операции с базами данных - всего лишь способ столкнуться с экзогенными проблемами. Файлы журнала - это тот случай, когда я даже не возражаю, если вы сохраните дескриптор файла открытым во время выполнения.
Если вам нужно закрыть соединение, не беспокойтесь о другом конце. Обращайтесь с этим, как с UDP: "Я отправлю информацию, но меня не волнует, получит ли ее другая сторона". Утилизация - это очистка ресурсов на стороне клиента / стороне, над которой вы работаете.
Я могу попытаться их уведомить. Но что делать на стороне сервера /FS? То есть то, что их тайм - ауты и их обработка исключений несет ответственность.
Вы можете попробовать использовать AggregateException и изменить свой код примерно так:
class Program
{
static async Task Main()
{
try
{
await using (var d = new D())
{
throw new ArgumentException("I'm inside using");
}
}
catch (AggregateException ex)
{
ex.Handle(inner =>
{
if (inner is Exception)
{
Console.WriteLine(e.Message);
}
});
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
}
}
class D : IAsyncDisposable
{
public async ValueTask DisposeAsync()
{
await Task.Delay(1);
throw new Exception("I'm inside dispose");
}
}
https://docs.microsoft.com/ru-ru/dotnet/api/system.aggregateexception?view=netframework-4.8