Właściwy sposób radzenia sobie z wyjątkami w AsyncDispose

20

Podczas przechodzenia na nową platformę .NET Core 3 IAsynsDisposablenatknąłem się na następujący problem.

Rdzeń problemu: jeśli DisposeAsynczgłasza wyjątek, wyjątek ten ukrywa wszelkie wyjątki await usingzgłoszone w bloku.

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");
    }
}

Tym, co zostaje złapane, jest AsyncDisposewyjątek, jeśli zostanie rzucony, a wyjątek od wewnątrz await usingtylko, jeśli AsyncDisposenie zostanie rzucony .

Wolałbym jednak odwrotnie: uzyskać wyjątek od await usingbloku, jeśli to możliwe, i- DisposeAsyncwyjątek tylko wtedy, gdy await usingblok zakończy się pomyślnie.

Uzasadnienie: Wyobraź sobie, że moja klasa Dwspółpracuje z niektórymi zasobami sieciowymi i subskrybuje niektóre powiadomienia zdalnie. Kod w środku await usingmoże zrobić coś złego i zawieść kanał komunikacyjny, po czym kod w Dispose, który próbuje z wdziękiem zamknąć komunikację (np. Wypisać się z powiadomień), również się nie powiedzie. Ale pierwszy wyjątek daje mi prawdziwe informacje o problemie, a drugi to tylko problem wtórny.

W innym przypadku, gdy główna część przebiegła, a usuwanie nie powiodło się, prawdziwy problem jest w środku DisposeAsync, więc wyjątek od DisposeAsyncjest odpowiedni. Oznacza to, że zniesienie wszystkich wyjątków w środku DisposeAsyncnie powinno być dobrym pomysłem.


Wiem, że ten sam problem występuje w przypadku niesynchronicznym: wyjątek w finallyzastępuje wyjątek w try, dlatego nie zaleca się wrzucania Dispose(). Jednak w przypadku klas korzystających z dostępu do sieci tłumienie wyjątków w metodach zamykania wcale nie wygląda dobrze.


Możliwe jest obejście problemu za pomocą następującego pomocnika:

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 { }
        }
    }
}

i używaj go jak

await new D().UsingAsync(d =>
{
    throw new ArgumentException("I'm inside using");
});

co jest trochę brzydkie (i uniemożliwia takie rzeczy, jak wczesne powroty do bloku używającego).

Czy istnieje dobre kanoniczne rozwiązanie, await usingjeśli to możliwe? Moje wyszukiwanie w Internecie nie znalazło nawet dyskusji na ten temat.

Vlad
źródło
1
Ale z klasami korzystającymi z sieci eliminowanie wyjątków w metodach zamykania wcale nie wygląda dobrze ” - myślę, że większość sieciowych klas BLC ma osobną Closemetodę z tego właśnie powodu. Prawdopodobnie rozsądnie jest zrobić to samo: CloseAsyncpróbuje ładnie zamknąć rzeczy i powoduje porażkę. DisposeAsyncpo prostu daje z siebie wszystko i po cichu zawodzi.
kanton7,
@ canton7: ​​Cóż, posiadając osobne CloseAsyncśrodki, muszę podjąć dodatkowe środki ostrożności, aby uruchomić. Jeśli po prostu umieszczę go na końcu using-block, zostanie on pominięty przy wczesnych powrotach itp. (Tak chcielibyśmy, aby się wydarzyło) i wyjątkach (tak chcielibyśmy, aby tak się stało). Ale pomysł wygląda obiecująco.
Vlad
Jest powód, dla którego wiele standardów kodowania zabrania wczesnych powrotów :) Jeśli chodzi o tworzenie sieci, bycie trochę jawnym nie jest złą rzeczą IMO. Disposezawsze brzmiało: „Mogło być nie tak: po prostu staraj się poprawić sytuację, ale nie pogarszaj”, i nie rozumiem, dlaczego AsyncDisposepowinno być inaczej.
kanton7,
@ kanton7: ​​Cóż, w języku z wyjątkami każde stwierdzenie może być wczesnym powrotem: - \
Vlad
Racja, ale będą wyjątkowe . W takim przypadku robienie wszystkiego, co DisposeAsyncmożliwe, aby posprzątać, ale nie rzucać, jest właściwym rozwiązaniem. Miałeś na myśli celowe wczesne powroty, w których celowe wczesne powroty mogłyby omyłkowo ominąć wezwanie do CloseAsync: są to te, których zabrania wiele standardów kodowania.
kanton7,

Odpowiedzi:

3

Są wyjątki, które chcesz ujawnić (przerwać bieżące żądanie lub sprowadzić proces), i są wyjątki, których oczekiwanie może się zdarzyć, i możesz sobie z nimi poradzić (np. Spróbuj ponownie i kontynuuj).

Ale rozróżnienie tych dwóch typów zależy od ostatecznego wywołującego kodu - jest to cały wyjątek, aby decyzja pozostawić osobie wywołującej.

Czasami program wywołujący przywiązuje większą wagę do ujawnienia wyjątku z oryginalnego bloku kodu, a czasem wyjątku od Dispose. Nie ma ogólnej zasady decydowania o tym, które powinny mieć pierwszeństwo. CLR jest co najmniej spójny (jak zauważyłeś) między zachowaniem synchronizacji a niesynchronizacją.

Być może niefortunne jest to, że teraz musimy AggregateExceptionreprezentować wiele wyjątków, więc nie można tego doposażyć do rozwiązania tego problemu. tzn. jeśli wyjątek jest już w locie, a inny zostanie zgłoszony, są one łączone w AggregateException. catchMechanizm może zostać zmodyfikowany tak, że jeśli piszesz catch (MyException)to będzie złapać każdy AggregateException, który zawiera wyjątek typu MyException. Istnieje jednak wiele innych komplikacji wynikających z tego pomysłu i prawdopodobnie modyfikowanie czegoś tak fundamentalnego jest prawdopodobnie zbyt ryzykowne.

Możesz ulepszyć swój system, UsingAsyncaby wspierać wczesny zwrot wartości:

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;
}
Daniel Earwicker
źródło
Więc czy rozumiem poprawnie: Twoim pomysłem jest to, że w niektórych przypadkach await usingmożna użyć tylko standardu (w tym przypadku DisposeAsync nie rzuca się w przypadku niepowodującym śmierci), a pomocnik podobny UsingAsyncjest bardziej odpowiedni (jeśli DisposeAsync może rzucić) ? (Oczywiście musiałbym zmodyfikować, UsingAsyncaby nie ślepo łapał wszystko, ale tylko nie śmiertelny (i bez kości w użyciu Erica Lipperta ).)
Vlad
@Vlad tak - prawidłowe podejście jest całkowicie zależne od kontekstu. Zauważ też, że UsingAsync nie może być napisane raz, aby użyć jakiejkolwiek globalnej prawdziwej kategoryzacji typów wyjątków w zależności od tego, czy należy je złapać, czy nie. Znów jest to decyzja, którą należy podjąć inaczej w zależności od sytuacji. Kiedy Eric Lippert mówi o tych kategoriach, nie są one nieodłącznymi faktami na temat typów wyjątków. Kategoria według typu wyjątku zależy od projektu. Czasami oczekiwany jest wyjątek IOException, a czasem nie.
Daniel Earwicker,
4

Być może już rozumiesz, dlaczego tak się dzieje, ale warto to wyjaśnić. To zachowanie nie jest specyficzne dla await using. To by się stało z prostym usingblokiem. Tak więc, kiedy mówię Dispose()tutaj, wszystko to dotyczy DisposeAsync()również.

usingBlok jest po prostu cukier syntaktyczny dla try/ finallybloku, jak sekcja Uwagi dokumentacji mówi. To, co widzisz, dzieje się, ponieważ finallyblok zawsze działa, nawet po wyjątku. Jeśli więc zdarzy się wyjątek i nie będzie catchbloku, wyjątek zostanie wstrzymany do momentu uruchomienia finallybloku, a następnie wyjątek zostanie zgłoszony . Ale jeśli zdarzy się wyjątek finally, nigdy nie zobaczysz starego wyjątku.

Możesz to zobaczyć na tym przykładzie:

try {
    throw new Exception("Inside try");
} finally {
    throw new Exception("Inside finally");
}

Nie ma znaczenia, czy Dispose()czy DisposeAsync()nazywa się wewnątrz finally. Zachowanie jest takie samo.

Moja pierwsza myśl to: nie wrzucaj Dispose(). Ale po przejrzeniu części własnego kodu Microsoftu, myślę, że to zależy.

Spójrz na FileStreamprzykład na ich wdrożenie . Zarówno Dispose()metoda synchroniczna , jak i DisposeAsync()może generować wyjątki. Synchroniczna Dispose()nie ignorować pewne wyjątki celowo, ale nie wszystkie.

Ale myślę, że ważne jest, aby wziąć pod uwagę charakter swojej klasy. Na FileStreamprzykład, Dispose()opróżni bufor do systemu plików. To bardzo ważne zadanie i musisz wiedzieć, czy to się nie powiodło . Nie możesz tego po prostu zignorować.

Jednak w innych typach obiektów, kiedy wywołujesz Dispose(), naprawdę nie ma już dla niego żadnego pożytku. Wywołanie Dispose()naprawdę oznacza po prostu „ten obiekt jest dla mnie martwy”. Może to czyści część przydzielonej pamięci, ale niepowodzenie nie wpływa w żaden sposób na działanie aplikacji. W takim przypadku możesz zignorować wyjątek w swoim pliku Dispose().

Ale w każdym razie, jeśli chcesz rozróżnić wyjątek wewnątrz usinglub wyjątek, który przyszedł Dispose(), potrzebujesz try/ catchbloku zarówno wewnątrz, jak i na zewnątrz usingbloku:

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
}

Lub po prostu nie możesz użyć using. Wypisać try/ catch/ finallyblokować siebie, gdzie można złapać żadnego wyjątku w 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
    }
}
Gabriel Luci
źródło
3
Btw, source.dot.net (.NET rdzenia) / referencesource.microsoft.com (.NET Framework) jest o wiele łatwiejsze do przeglądania niż GitHub
canton7
Dziękuję za Twoją odpowiedź! Wiem, jaki jest prawdziwy powód (w pytaniu wspomniałem o try / wreszcie i przypadku synchronicznym). Teraz o twojej propozycji. catch Wewnątrz w usingbloku nie pomoże, ponieważ zwykle obsługi wyjątków odbywa się gdzieś daleko od usingsamego bloku, więc posługiwaniu się nim w środku usingnie jest zwykle bardzo możliwe. O używaniu nie using- czy to naprawdę lepsze niż proponowane obejście?
Vlad
2
@ canton7 Awesome! Wiedziałem o referenourcesource.microsoft.com , ale nie wiedziałem, że istnieje odpowiednik .NET Core. Dzięki!
Gabriel Luci,
@Vlad „Better” to coś, na co tylko Ty możesz odpowiedzieć. Wiem, że gdybym czytał kod innej osoby, wolałbym zobaczyć try/ catch/ finallyblock, ponieważ od razu byłoby jasne, co robi, bez konieczności czytania, co AsyncUsingrobi. Możesz także zachować możliwość wcześniejszego powrotu. Będzie także dodatkowy koszt procesora AwaitUsing. Byłby mały, ale tam jest.
Gabriel Luci,
2
@PauloMorgado To tylko oznacza, że Dispose()nie powinien rzucać, ponieważ jest wywoływany więcej niż jeden raz. Własne implementacje Microsoftu mogą generować wyjątki i nie bez powodu, jak pokazałem w tej odpowiedzi. Zgadzam się jednak, że powinieneś tego unikać, jeśli to w ogóle możliwe, ponieważ normalnie nikt nie spodziewałby się, że to rzuci.
Gabriel Luci,
4

efektywne korzystanie z kodu obsługi wyjątków (cukier składniowy dla try ... wreszcie ... Dispose ()).

Jeśli kod obsługi wyjątków zgłasza wyjątki, coś po królewsku zostaje zepsute.

Cokolwiek jeszcze się zdarzyło, żeby cię tam dostać, tak naprawdę nie ma już znaczenia. Wadliwy kod obsługi wyjątków ukryje wszystkie możliwe wyjątki, w ten czy inny sposób. Kod obsługi wyjątków musi zostać naprawiony, co ma absolutny priorytet. Bez tego nigdy nie uzyskasz wystarczającej ilości danych do debugowania dla prawdziwego problemu. Widzę, że robi się to wyjątkowo źle. Łatwo się pomylić, tak samo jak posługiwanie się nagimi wskazówkami. Tak często istnieją dwa artykuły na temat I link, które mogą pomóc w wszelkich podstawowych nieporozumień projektowych:

W zależności od klasyfikacji wyjątków, musisz to zrobić, jeśli Twój kod obsługi wyjątku / upuszczenia zgłasza wyjątek:

W przypadku Fatal, Boneheaded i Vexing rozwiązanie jest takie samo.

Egzogenicznych wyjątków należy unikać nawet przy poważnych kosztach. Jest powód, dla którego nadal używamy plików dziennika , a nie baz danych do rejestrowania wyjątków - DB Opeartions to po prostu sposób na skłonność do wpadania w problemy egzogeniczne. Pliki dziennika to jedyny przypadek, w którym nawet nie mam nic przeciwko, jeśli zachowasz uchwyt pliku Otwórz cały środowisko wykonawcze.

Jeśli musisz zamknąć połączenie, nie przejmuj się zbytnio drugim końcem. Postępuj tak, jak robi to UDP: „Wyślę informacje, ale nie dbam o to, czy dostanie je druga strona”. Pozbywanie się polega na czyszczeniu zasobów po stronie klienta / stronie, nad którą pracujesz.

Mogę spróbować ich powiadomić. Ale sprzątanie rzeczy po stronie serwera / FS? To, co ich limity czasu i ich obsługa wyjątków jest odpowiedzialny.

Krzysztof
źródło
Więc twoja propozycja skutecznie sprowadza się do zniesienia wyjątków przy zamknięciu połączenia, prawda?
Vlad
@Vlad Exogenous? Pewnie. Dipose / Finalizer są po to, aby posprzątać po swojej stronie. Szanse są przy zamykaniu conneciton przykład z powodu wyjątku, to zrobić becaue już nie mieć działające połączenie do nich w każdym razie. I jaki byłby sens uzyskania wyjątku „Brak połączenia” podczas obsługi poprzedniego wyjątku „Brak połączenia”? Wysyłasz pojedyncze „Yo, ja zamykam to połączenie”, w którym ignorujesz wszystkie egzogenne wyjątki, a nawet jeśli zbliżają się do celu. Afaik domyślne implementacje Dispose już to robią.
Christopher
@Vlad: Przypomniałem sobie, że istnieje wiele rzeczy, od których nigdy nie powinieneś rzucać wyjątków (z wyjątkiem tych śmiertelnych). Typ Inicjatory są znacznie wyżej na liście. Dispose jest również jednym z następujących: „Aby zapewnić, że zasoby są zawsze odpowiednio czyszczone, metoda Dispose powinna być możliwa do wywołania wiele razy bez zgłaszania wyjątku”. docs.microsoft.com/en-us/dotnet/standard/garbage-collection/…
Christopher
@Vlad Szansa na fatalne wyjątki? Z pewnością musimy je zaryzykować i nigdy nie powinniśmy się z nimi obchodzić poza „wezwaniem Dispose”. I tak naprawdę nie powinny nic z tym zrobić. W rzeczywistości nie pojawiają się w żadnej dokumentacji. | Wyjątki z kośćmi? Zawsze je naprawiaj. | Wyjątki Vexing są głównymi kandydatami do przełykania / obsługi, jak w TryParse () | Egzogenny? Również zawsze należy handlować. Często chcesz też powiedzieć o nich użytkownikowi i zalogować się. Ale w przeciwnym razie nie są warte zabicia twojego procesu.
Christopher
@Vlad Spojrzałem na SqlConnection.Dispose (). To nawet nie obchodzi wysłać coś do serwera o połączenie bycia. Coś może nadal się zdarzyć w wyniku NativeMethods.UnmapViewOfFile();i NativeMethods.CloseHandle(). Ale te są importowane z zewnątrz. Nie ma sprawdzania żadnej zwracanej wartości ani czegokolwiek innego, co mogłoby być użyte do uzyskania właściwego wyjątku .NET wokół wszystkiego, co może spotkać tych dwóch. Tak więc zdecydowanie się narzekam, SqlConnection.Dispose (bool) po prostu nie obchodzi. | Close jest o wiele ładniejszy, w rzeczywistości mówi serwerowi. Zanim zadzwoni, pozbądź się.
Christopher
1

Możesz spróbować użyć AggregateException i zmodyfikować kod w taki sposób:

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
źródło