Richtige Behandlung von Ausnahmen in AsyncDispose

20

Beim Wechsel zu den neuen .NET Core 3 IAsynsDisposablebin ich auf das folgende Problem gestoßen.

Der Kern des Problems: Wenn DisposeAsynceine Ausnahme ausgelöst wird, werden durch diese Ausnahme alle innerhalb von await using-block ausgelösten Ausnahmen ausgeblendet.

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

Was gefangen wird, ist die AsyncDisposeAusnahme, wenn es geworfen wird, und die Ausnahme von innen await usingnur, wenn AsyncDisposees nicht wirft.

Ich würde es jedoch andersherum vorziehen: await usingwenn möglich die Ausnahme vom Block zu bekommen und DisposeAsync-ausnahme nur, wenn der await usingBlock erfolgreich beendet wurde.

Begründung: Stellen Sie sich vor, meine Klasse Darbeitet mit einigen Netzwerkressourcen und abonniert einige Remote-Benachrichtigungen. Der darin enthaltene Code await usingkann etwas falsch machen und den Kommunikationskanal ausfallen lassen. Danach schlägt auch der Code in Dispose fehl, der versucht, die Kommunikation ordnungsgemäß zu schließen (z. B. das Abbestellen der Benachrichtigungen). Aber die erste Ausnahme gibt mir die wirklichen Informationen über das Problem, und die zweite ist nur ein sekundäres Problem.

In dem anderen Fall, in dem der Hauptteil durchlaufen wurde und die Entsorgung fehlgeschlagen ist, liegt das eigentliche Problem im Inneren DisposeAsync, sodass die Ausnahme von DisposeAsyncder relevanten ist. Dies bedeutet, dass es DisposeAsynckeine gute Idee sein sollte, alle darin enthaltenen Ausnahmen zu unterdrücken .


Ich weiß, dass es das gleiche Problem mit nicht asynchronen Fällen gibt: Ausnahme in finallyüberschreibt die Ausnahme in try, deshalb wird nicht empfohlen, sie einzufügen Dispose(). Bei Klassen mit Netzwerkzugriff sieht das Unterdrücken von Ausnahmen beim Schließen von Methoden jedoch überhaupt nicht gut aus.


Es ist möglich, das Problem mit dem folgenden Helfer zu umgehen:

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

und benutze es gerne

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

Das ist irgendwie hässlich (und verbietet Dinge wie frühe Rückkehr innerhalb des using-Blocks).

Gibt es eine gute kanonische Lösung, await usingwenn möglich? Bei meiner Suche im Internet wurde dieses Problem nicht einmal diskutiert.

Vlad
quelle
1
" Aber wenn Klassen mit Netzwerkzugriff Ausnahmen beim Schließen von Methoden unterdrücken, sieht das überhaupt nicht gut aus " - Ich denke, die meisten netzwerkbasierten BLC-Klassen haben Closeaus diesem Grund eine separate Methode. Es ist wahrscheinlich ratsam, dasselbe zu tun: CloseAsyncVersuche, die Dinge gut zu schließen, und werfen einen Fehler auf. DisposeAsynctut einfach sein Bestes und versagt lautlos.
Kanton7
@ canton7: ​​Nun, ein separates CloseAsyncMittel bedeutet, dass ich zusätzliche Vorsichtsmaßnahmen treffen muss, um es zum Laufen zu bringen. Wenn ich es nur am Ende von using-block setze, wird es bei vorzeitiger Rückgabe usw. (das ist, was wir wollen) und Ausnahmen (das ist, was wir wollen) übersprungen. Aber die Idee sieht vielversprechend aus.
Vlad
Es gibt einen Grund, warum viele Codierungsstandards eine frühzeitige Rückgabe verbieten :) Wenn es um Netzwerke geht, ist es keine schlechte Sache, ein bisschen explizit zu sein, IMO. Disposewar schon immer "Dinge könnten schief gelaufen sein: Gib einfach dein Bestes, um die Situation zu verbessern, aber mach es nicht schlimmer", und ich verstehe nicht, warum AsyncDisposees anders sein sollte.
Kanton7
@ canton7: ​​Nun, in einer Sprache mit Ausnahmen könnte jede Aussage eine frühe Rückkehr sein: - \
Vlad
Richtig, aber die werden außergewöhnlich sein . In diesem Fall ist es richtig, das DisposeAsyncBeste zu tun, um aufzuräumen, aber nicht zu werfen . Sie sprachen von absichtlichen vorzeitigen Rückgaben, bei denen eine absichtliche vorzeitige Rückgabe fälschlicherweise einen Anruf an umgehen könnte CloseAsync: Dies sind diejenigen, die von vielen Codierungsstandards verboten sind.
canton7

Antworten:

3

Es gibt Ausnahmen, die auftauchen sollen (die aktuelle Anforderung unterbrechen oder den Prozess beenden), und es gibt Ausnahmen, von denen Ihr Entwurf erwartet, dass sie manchmal auftreten und die Sie behandeln können (z. B. erneut versuchen und fortfahren).

Die Unterscheidung zwischen diesen beiden Typen liegt jedoch beim endgültigen Aufrufer des Codes - dies ist der springende Punkt bei Ausnahmen, um die Entscheidung dem Anrufer zu überlassen.

Manchmal legt der Anrufer größeren Wert darauf, dass die Ausnahme aus dem ursprünglichen Codeblock und manchmal die Ausnahme aus dem ursprünglichen Codeblock angezeigt wird Dispose. Es gibt keine allgemeine Regel für die Entscheidung, welche Priorität haben soll. Die CLR ist mindestens konsistent (wie Sie bemerkt haben) zwischen dem Synchronisierungs- und dem nicht asynchronen Verhalten.

Es ist vielleicht bedauerlich, dass wir jetzt AggregateExceptionmehrere Ausnahmen darstellen müssen. Es kann nicht nachgerüstet werden, um dies zu lösen. dh wenn eine Ausnahme bereits im Flug ist und eine andere ausgelöst wird, werden sie zu einer zusammengefasst AggregateException. Der catchMechanismus kann so geändert werden, dass beim Schreiben alle Mechanismen catch (MyException)abgefangen werden AggregateException, die eine Ausnahme vom Typ enthalten MyException. Es gibt jedoch verschiedene andere Komplikationen, die sich aus dieser Idee ergeben, und es ist wahrscheinlich zu riskant, etwas so Grundlegendes jetzt zu modifizieren.

Sie könnten Ihre verbessern UsingAsync, um die frühzeitige Rückgabe eines Wertes zu unterstützen:

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
quelle
Verstehe ich das auch richtig await using? Ihre Idee ist, dass in einigen Fällen nur Standard verwendet werden kann (hier wirft DisposeAsync keinen nicht tödlichen Fall), und ein Helfer wie UsingAsyncist besser geeignet (wenn DisposeAsync wahrscheinlich wirft). ? (Natürlich müsste ich modifizieren UsingAsync, damit nicht alles blind erfasst wird, sondern nur nicht tödlich (und in Eric Lipperts Gebrauch nicht ohne Kopf ).)
Vlad
@Vlad ja - der richtige Ansatz ist völlig kontextabhängig. Beachten Sie auch, dass UsingAsync nicht einmal geschrieben werden kann, um eine global wahre Kategorisierung von Ausnahmetypen zu verwenden, je nachdem, ob sie abgefangen werden sollen oder nicht. Auch dies ist eine Entscheidung, die je nach Situation unterschiedlich zu treffen ist. Wenn Eric Lippert von diesen Kategorien spricht, handelt es sich nicht um intrinsische Fakten über Ausnahmetypen. Die Kategorie pro Ausnahmetyp hängt von Ihrem Design ab. Manchmal wird eine IOException vom Design erwartet, manchmal nicht.
Daniel Earwicker
4

Vielleicht verstehen Sie bereits, warum dies passiert, aber es lohnt sich, dies zu formulieren. Dieses Verhalten ist nicht spezifisch für await using. Es würde auch mit einem einfachen usingBlock passieren . Während ich Dispose()hier sage , gilt das alles auch für DisposeAsync().

Ein usingBlock ist nur syntaktischer Zucker für einen try/ finallyBlock, wie im Abschnitt "Anmerkungen" der Dokumentation angegeben . Was Sie sehen , geschieht , weil der finallyBlock immer läuft, auch nach einer Ausnahme. Wenn also eine Ausnahme auftritt und kein catchBlock vorhanden ist , wird die Ausnahme angehalten, bis der finallyBlock ausgeführt wird, und dann wird die Ausnahme ausgelöst. Wenn jedoch eine Ausnahme auftritt finally, wird die alte Ausnahme nie angezeigt.

Sie können dies anhand dieses Beispiels sehen:

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

Es spielt keine Rolle, ob Dispose()oder DisposeAsync()innerhalb der aufgerufen wird finally. Das Verhalten ist das gleiche.

Mein erster Gedanke ist: nicht einwerfen Dispose(). Aber nachdem ich einige von Microsofts eigenen Codes überprüft habe, denke ich, dass es davon abhängt.

Schauen Sie sich FileStreamzum Beispiel deren Implementierung an . Sowohl die synchrone Dispose()Methode als auch DisposeAsync()können tatsächlich Ausnahmen auslösen. Die synchrone Dispose()tut ignorieren einige Ausnahmen absichtlich, aber nicht alle.

Aber ich denke, es ist wichtig, die Natur Ihrer Klasse zu berücksichtigen. In a FileStreamwird beispielsweise Dispose()der Puffer in das Dateisystem geleert. Das ist eine sehr wichtige Aufgabe und Sie müssen wissen, ob dies fehlgeschlagen ist . Das kann man nicht einfach ignorieren.

Bei anderen Dispose()Objekttypen haben Sie beim Aufrufen jedoch wirklich keine Verwendung mehr für das Objekt. Anrufen Dispose()bedeutet wirklich nur "dieses Objekt ist für mich tot". Möglicherweise wird der zugewiesene Speicher bereinigt, aber ein Fehler hat keinerlei Auswirkungen auf den Betrieb Ihrer Anwendung. In diesem Fall können Sie die Ausnahme in Ihrem ignorieren Dispose().

Wenn Sie jedoch in jedem Fall zwischen einer Ausnahme innerhalb der usingoder einer Ausnahme, von der sie stammt Dispose(), unterscheiden möchten, benötigen Sie einen try/ catch-Block sowohl innerhalb als auch außerhalb Ihres usingBlocks:

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
}

Oder du könntest es einfach nicht benutzen using. Formulieren Sie ein try/ catch/ finallyBlock selbst, wo Sie jede Ausnahme fangen in 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
quelle
3
Übrigens ist source.dot.net (.NET Core) / referencesource.microsoft.com (.NET Framework) viel einfacher zu durchsuchen als GitHub
canton7
Vielen Dank für Ihre Antwort! Ich weiß, was der wahre Grund ist (ich habe in der Frage den Versuch / Endlich und den synchronen Fall erwähnt). Nun zu Ihrem Vorschlag. Ein catch innerhalb des usingBlocks würde nicht helfen, da die Ausnahmebehandlung normalerweise irgendwo weit vom usingBlock selbst entfernt erfolgt, so dass die Behandlung innerhalb des Blocks usingnormalerweise nicht sehr praktikabel ist. Über die Verwendung von Nein using- ist es wirklich besser als die vorgeschlagene Problemumgehung?
Vlad
2
@ canton7 Super! Mir war referenceource.microsoft.com bekannt , aber ich wusste nicht, dass es ein Äquivalent für .NET Core gibt. Vielen Dank!
Gabriel Luci
@Vlad "Besser" können nur Sie beantworten. Ich weiß, wenn ich den Code eines anderen lesen würde, würde ich es vorziehen, try/ catch/ finallyblock zu sehen, da sofort klar wäre, was es tut, ohne lesen zu müssen, was AsyncUsinges tut. Sie behalten auch die Möglichkeit einer vorzeitigen Rückgabe. Es fallen auch zusätzliche CPU-Kosten für Sie an AwaitUsing. Es wäre klein, aber es ist da.
Gabriel Luci
2
@PauloMorgado Das bedeutet nur, dass Dispose()nicht geworfen werden sollte, da es mehr als einmal aufgerufen wird. Microsofts eigene Implementierungen können aus gutem Grund Ausnahmen auslösen, wie ich in dieser Antwort gezeigt habe. Ich stimme jedoch zu, dass Sie es vermeiden sollten, wenn es überhaupt möglich ist, da normalerweise niemand erwarten würde, dass es wirft.
Gabriel Luci
4

using ist effektiv Exception Handling Code (Syntax Zucker für try ... finally ... Dispose ()).

Wenn Ihr Code für die Ausnahmebehandlung Ausnahmen auslöst, wird etwas auf königliche Weise zerstört.

Was auch immer passiert ist, um dich dorthin zu bringen, spielt keine Rolle mehr. Fehlerhafter Ausnahmebehandlungscode verbirgt alle möglichen Ausnahmen auf die eine oder andere Weise. Der Ausnahmebehandlungscode muss festgelegt sein, der absolute Priorität hat. Ohne das erhalten Sie nie genug Debugging-Daten für das eigentliche Problem. Ich sehe es extrem oft falsch gemacht. Es ist ungefähr so ​​leicht, sich zu irren, wie mit nackten Zeigern umzugehen. So oft gibt es zwei Artikel zu dem von mir verlinkten Thema, die Ihnen bei allen zugrunde liegenden Designfehlern helfen könnten:

Abhängig von der Ausnahmeklassifizierung müssen Sie Folgendes tun, wenn Ihr Ausnahmebehandlungs- / Dipose-Code eine Ausnahme auslöst:

Für Fatal, Boneheaded und Vexing ist die Lösung dieselbe.

Exogene Ausnahmen müssen auch bei schwerwiegenden Kosten vermieden werden. Es gibt einen Grund, warum wir immer noch Protokolldateien verwenden, anstatt Datenbanken zu protokollieren, um Ausnahmen zu protokollieren. DB Opeartions sind nur eine Möglichkeit, auf exogene Probleme zu stoßen. Protokolldateien sind der einzige Fall, in dem es mir nichts ausmacht, wenn Sie das Dateihandle während der gesamten Laufzeit geöffnet lassen.

Wenn Sie eine Verbindung schließen müssen, sorgen Sie sich nicht zu sehr um das andere Ende. Behandeln Sie es wie UDP: "Ich werde die Informationen senden, aber es ist mir egal, ob die andere Seite es bekommt." Bei der Entsorgung geht es darum, Ressourcen auf der Client-Seite / Seite, an der Sie arbeiten, zu bereinigen.

Ich kann versuchen, sie zu benachrichtigen. Aber Sachen auf der Server / FS-Seite aufräumen? Dafür sind ihre Timeouts und ihre Ausnahmebehandlung verantwortlich.

Christopher
quelle
Ihr Vorschlag läuft also effektiv darauf hinaus, Ausnahmen beim Schließen der Verbindung zu unterdrücken, oder?
Vlad
@Vlad Exogene? Sicher. Dipose / Finalizer sind da, um auf ihrer eigenen Seite aufzuräumen. Die Chancen stehen gut, wenn aufgrund Ausnahme der conneciton Instanz zu schließen, Sie tun so becaue Sie nicht mehr haben eine funktionierende Verbindung zu ihnen sowieso. Und welchen Sinn hätte es, eine Ausnahme "Keine Verbindung" zu erhalten, während die vorherige Ausnahme "Keine Verbindung" behandelt wird? Sie senden ein einzelnes "Yo, ich schließe diese Verbindung", in dem Sie alle exogenen Ausnahmen ignorieren oder auch wenn sie sich dem Ziel nähern. Afaik die Standardimplementierungen von Dispose machen das schon.
Christopher
@Vlad: Ich habe mich daran erinnert, dass es eine Reihe von Dingen gibt, von denen man niemals Ausnahmen werfen sollte (mit Ausnahme von Coruse Fatal). Typinitiatoren stehen ganz oben auf der Liste. Dispose ist auch eine davon: "Um sicherzustellen, dass die Ressourcen immer ordnungsgemäß bereinigt werden, sollte eine Dispose-Methode mehrmals aufgerufen werden können, ohne eine Ausnahme auszulösen." docs.microsoft.com/en-us/dotnet/standard/garbage-collection/…
Christopher
@Vlad Die Chance auf tödliche Ausnahmen? Wir müssen diese immer riskieren und sollten sie niemals über "Dispose anrufen" hinaus behandeln. Und sollte damit eigentlich nichts anfangen. Sie werden in der Tat in keiner Dokumentation erwähnt. | Ausnahmen ohne Knochen? Repariere sie immer. | Ärgerliche Ausnahmen sind Hauptkandidaten für das Schlucken / Behandeln, wie in TryParse () | Exogen? Auch sollte immer behandelt werden. Oft möchten Sie dem Benutzer auch davon erzählen und sie protokollieren. Ansonsten lohnt es sich nicht, Ihren Prozess zu beenden.
Christopher
@Vlad Ich habe SqlConnection.Dispose () nachgeschlagen. Es ist nicht einmal wichtig , irgendetwas an den Server zu senden, wenn die Verbindung unterbrochen ist. Als Ergebnis des NativeMethods.UnmapViewOfFile();und könnte noch etwas passieren NativeMethods.CloseHandle(). Aber diese werden von extern importiert. Es gibt keine Überprüfung von Rückgabewerten oder anderen Elementen, die verwendet werden könnten, um eine ordnungsgemäße .NET-Ausnahme für alle diese beiden Probleme zu erhalten. Ich gehe also stark davon aus, dass es SqlConnection.Dispose (bool) einfach egal ist. | Close ist viel schöner, wenn man es dem Server sagt. Bevor es anruft entsorgen.
Christopher
1

Sie können versuchen, AggregateException zu verwenden und Ihren Code folgendermaßen zu ändern:

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
quelle