Правильный способ обработки исключений в AsyncDispose

20

При переходе на новые .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-exception, если он брошен, и исключение изнутри, await usingтолько если AsyncDisposeне бросает.

Однако я предпочел бы это наоборот: получить исключение из await usingблока, если это возможно, и DisposeAsync-exception, только если 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если возможно? Мой поиск в интернете не нашел даже обсуждения этой проблемы.

Влад
источник
1
« Но с классами доступа к сети, подавляющими исключения в методах закрытия, это вовсе не выглядит хорошо » - я думаю, что большинство сетевых классов BLC имеют отдельный Closeметод именно по этой причине. Вероятно, разумно сделать то же самое: CloseAsyncпытаться красиво закрыть вещи и бросить на неудачу. DisposeAsyncпросто делает все возможное, и молча терпит неудачу.
canton7
@ canton7: ​​Ну, имея отдельное CloseAsyncсредство, мне нужно принять дополнительные меры предосторожности, чтобы запустить его. Если я просто поставлю его в конце using-блока, он будет пропущен при досрочном возврате и т. Д. (Это то, что мы хотели бы случиться) и исключениях (это то, что мы хотели бы случиться). Но идея выглядит многообещающей.
Влад
Есть причина, по которой многие стандарты кодирования запрещают раннее возвращение :) Если речь идет о сетевом взаимодействии, то быть немного откровенным - это не плохо, IMO. Disposeвсегда было «Все могло пойти не так: просто приложите все усилия, чтобы улучшить ситуацию, но не усугубляйте ситуацию», и я не понимаю, почему AsyncDisposeдолжно быть иначе.
canton7
@ canton7: ​​Ну, в языке с исключениями каждое утверждение может быть ранним возвращением: - \
Влад
Да, но они будут исключительными . В этом случае, делать DisposeAsyncвсе возможное, чтобы привести в порядок, а не бросить, - это правильно. Вы говорили о преднамеренном досрочном возврате, когда преднамеренное досрочное возвращение может ошибочно обойти вызов CloseAsync: это те, которые запрещены многими стандартами кодирования.
canton7

Ответы:

3

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

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

Иногда вызывающий объект будет уделять больше внимания появлению исключения из исходного блока кода, а иногда исключения из 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может использоваться только стандартный (это где DisposeAsync не будет генерировать в нефатальном случае), и вспомогательный UsingAsyncтип больше подходит (если DisposeAsync, скорее всего, выбрасывает) ? (Конечно, мне нужно изменить, UsingAsyncчтобы он не ловил все вслепую, а только не смертельно (и не без умолку в использовании Эрика Липперта ).)
Влад
@ Влад да - правильный подход полностью зависит от контекста. Также обратите внимание, что UsingAsync нельзя написать один раз, чтобы использовать некоторую глобальную истинную категоризацию типов исключений в зависимости от того, должны ли они быть перехвачены или нет. Опять же, это решение должно быть принято по-разному в зависимости от ситуации. Когда Эрик Липперт говорит об этих категориях, они не являются внутренними фактами о типах исключений. Категория для каждого типа исключения зависит от вашего дизайна. Иногда IOException ожидается по проекту, иногда нет.
Даниэль Уорвикер
4

Возможно, вы уже понимаете, почему это происходит, но это стоит объяснить. Это поведение не является специфическим для await using. Это может произойти и с простым usingблоком. Так что, хотя я и говорю Dispose()здесь, все это относится и к DisposeAsync().

usingБлок просто синтаксический сахар для try/ finallyблока, как раздел замечания документации говорит. То, что вы видите, происходит потому, что 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
    }
}
Габриэль Люси
источник
3
Кстати, source.dot.net (ядро .NET) / referenceource.microsoft.com (.NET Framework) намного проще для просмотра, чем GitHub
canton7
Спасибо за ваш ответ! Я знаю, какова реальная причина (я упомянул в этом вопросе попытку / окончание и синхронный случай). Теперь о вашем предложении. catch Внутри в usingблоке будет не поможет , потому что , как правило , обработка исключений делается где - то далеко от usingсамого блока, так его обработки внутри using, как правило , не очень возможно. Об использовании нет using- действительно ли это лучше, чем предлагаемый обходной путь?
Влад
2
@ canton7 Отлично! Мне было известно о referenceource.microsoft.com , но я не знал, что существует эквивалент для .NET Core. Спасибо!
Габриэль Люси
@ Влад "Лучше" - это то, на что только ты можешь ответить. Я знаю, если бы я читал чужой код, я бы предпочел видеть try/ catch/ finallyблок, так как было бы сразу понятно, что он делает, не читая, что AsyncUsingделает. Вы также сохраняете возможность сделать ранний возврат. Там будет также дополнительная стоимость процессора для вашего AwaitUsing. Это было бы мало, но это есть.
Габриэль Люси
2
@PauloMorgado Это просто означает, что Dispose()не должен бросать, потому что он вызывается более одного раза. Собственные реализации Microsoft могут генерировать исключения, и на то есть веские причины, как я показал в этом ответе. Тем не менее, я согласен с тем, что вам следует избегать этого, если это вообще возможно, поскольку никто обычно не ожидает, что он бросит.
Габриэль Люси
4

эффективно использовать код обработки исключений (синтаксический сахар для try ... finally ... Dispose ()).

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

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

В зависимости от классификации исключений, это то, что вам нужно сделать, если ваш код обработки исключений / дипозе выдает исключение:

Для Fatal, Boneheaded и Vexing решение одинаково.

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

Если вам нужно закрыть соединение, не беспокойтесь о другом конце. Обрабатывайте это так, как это делает UDP: «Я отправлю информацию, но мне все равно, получит ли это другая сторона». Утилизация - это очистка ресурсов на стороне клиента / стороне, над которой вы работаете.

Я могу попытаться уведомить их. Но убирать вещи на стороне сервера / FS? То есть то , что их тайм - ауты и их обработка исключений несет ответственность.

Кристофер
источник
Таким образом, ваше предложение фактически сводится к подавлению исключений при закрытии соединения, верно?
Влад
@ Влад Экзогенные? Конечно. Дипоза / Финализатор есть для очистки на своей стороне. Скорее всего, при закрытии экземпляра Conneciton из-за исключительной ситуации вы делаете это, потому что у вас все равно нет работающего соединения с ними. И какой смысл в получении исключения «Нет соединения» при обработке предыдущего исключения «Нет соединения»? Вы отправляете единственное «Yo, я закрываю это соединение», где вы игнорируете все экзогенные исключения или даже если оно приближается к цели. Afaik стандартные реализации Dispose уже делают это.
Кристофер
@Vlad: Я вспомнил, что есть куча вещей, из которых вы никогда не должны бросать исключения (за исключением фатальных). Инициализаторы типа находятся вверху списка. Dispose также является одним из таких способов: «Чтобы обеспечить правильную очистку ресурсов, метод Dispose должен вызываться несколько раз без исключения». docs.microsoft.com/en-us/dotnet/standard/garbage-collection/…
Кристофер
@Vlad Вероятность фатальных исключений? Мы всегда должны рисковать ими и никогда не должны обращаться с ними, кроме как «позвонить по телефону». И не должен ничего делать с ними. Они фактически не упоминаются ни в одной документации. | Искушенные исключения? Всегда исправляй их. | Исключения Vexing являются основными кандидатами для глотания / обработки, как в TryParse () | Экзогенный? Также всегда следует обращаться. Часто вы также хотите рассказать о них пользователю и войти в него. Но в противном случае, они не стоит убивать ваш процесс.
Кристофер
@Vlad Я посмотрел SqlConnection.Dispose (). Он даже не волнует , чтобы отправить что - нибудь на сервер о соединении завершения. Что-то еще может произойти в результате NativeMethods.UnmapViewOfFile();и NativeMethods.CloseHandle(). Но те импортированы из внешнего. Нет проверки какого-либо возвращаемого значения или чего-либо еще, что может быть использовано для получения правильного исключения .NET вокруг того, с чем эти два могут столкнуться. Так что я сильно сомневаюсь, SqlConnection.Dispose (bool) просто не волнует. | Закрыть гораздо приятнее, на самом деле это говорит сервер. Перед тем как звонить утилизировать.
Кристофер
1

Вы можете попробовать использовать 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

https://docs.microsoft.com/ru-ru/dotnet/standard/parallel-programming/exception-handling-task-parallel-library

GDI89
источник