Modo corretto per gestire le eccezioni in AsyncDispose


20

Durante il passaggio ai nuovi .NET Core 3 IAsynsDisposable, mi sono imbattuto nel seguente problema.

Il nocciolo del problema: se DisposeAsyncgenera un'eccezione, questa eccezione nasconde tutte le eccezioni generate all'interno del await usingblocco.

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

Ciò che viene catturato è l' AsyncDisposeeccezione se viene generata e l'eccezione dall'interno await usingsolo se AsyncDisposenon viene generata .

Preferirei comunque il contrario: ottenere l'eccezione dal await usingblocco, se possibile, e DisposeAsync-eccezione solo se il await usingblocco è stato completato correttamente.

Motivazione: Immagina che la mia classe Dlavori con alcune risorse di rete e si iscriva per alcune notifiche remote. Il codice all'interno await usingpuò fare qualcosa di sbagliato e fallire il canale di comunicazione, dopodiché anche il codice in Dispose che tenta di chiudere con garbo la comunicazione (ad esempio, annullare l'iscrizione alle notifiche) fallirebbe. Ma la prima eccezione mi dà le informazioni reali sul problema, e la seconda è solo un problema secondario.

Nell'altro caso quando la parte principale è passata e lo smaltimento non è riuscito, il vero problema è all'interno DisposeAsync, quindi l'eccezione DisposeAsyncè quella rilevante. Ciò significa che sopprimere tutte le eccezioni al suo interno DisposeAsyncnon dovrebbe essere una buona idea.


So che c'è lo stesso problema con il caso non asincrono: l'eccezione finallysovrascrive l'eccezione try, ecco perché non è consigliabile lanciare Dispose(). Ma con le classi che accedono alla rete, sopprimere le eccezioni nei metodi di chiusura non sembra affatto buono.


È possibile aggirare il problema con il seguente aiuto:

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

e usalo come

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

che è un po 'brutto (e non consente cose come i primi ritorni all'interno del blocco using).

Esiste una buona soluzione canonica, await usingse possibile? La mia ricerca su Internet non ha trovato nemmeno discutere questo problema.


1
" Ma con le classi che accedono alla rete che sopprimono le eccezioni nei metodi di chiusura non sembra affatto buono " - Penso che la maggior parte delle classi BLC di rete abbia un Closemetodo separato proprio per questo motivo. Probabilmente è saggio fare lo stesso: CloseAsynctenta di chiudere bene le cose e genera un fallimento. DisposeAsyncfa solo del suo meglio e fallisce in silenzio.
canton7,

@ canton7: ​​Beh, con un CloseAsyncmezzo separato devo prendere delle precauzioni extra per farlo funzionare. Se lo metto solo alla fine di using-block, verrà ignorato sui rendimenti iniziali ecc. (Questo è ciò che vorremmo che accadesse) ed eccezioni (questo è ciò che vorremmo che accadesse). Ma l'idea sembra promettente.
Vlad,

C'è un motivo per cui molti standard di codifica vietano i ritorni anticipati :) Quando è coinvolta la rete, essere un po 'espliciti non è una cosa negativa dell'IMO. Disposeè sempre stato "Le cose potrebbero essere andate male: fai del tuo meglio per migliorare la situazione, ma non peggiorare", e non vedo perché AsyncDisposedovrebbe essere diverso.
canton7,

@ canton7: ​​Beh, in una lingua con eccezioni ogni affermazione potrebbe essere un ritorno anticipato: - \
Vlad

Giusto, ma quelli saranno eccezionali . In tal caso, fare la DisposeAsynccosa migliore per riordinare ma non buttare è la cosa giusta da fare. Stavi parlando di ritorni anticipati intenzionali , in cui un ritorno anticipato intenzionale potrebbe erroneamente aggirare una chiamata a CloseAsync: quelli sono quelli proibiti da molti standard di codifica.
canton7,

Risposte:


3

Esistono eccezioni che si desidera visualizzare (interrompere la richiesta corrente o interrompere il processo) e ci sono eccezioni che il progetto prevede che si verifichino a volte e che è possibile gestirle (ad esempio, riprovare e continuare).

Ma distinguere tra questi due tipi spetta al chiamante finale del codice: questo è il punto centrale delle eccezioni, lasciare la decisione al chiamante.

A volte il chiamante attribuirà maggiore priorità alla creazione dell'eccezione dal blocco di codice originale, e talvolta all'eccezione dal Dispose. Non esiste una regola generale per decidere quale dovrebbe avere la priorità. Il CLR è almeno coerente (come hai notato) tra il comportamento di sincronizzazione e non asincrono.

È forse un peccato che ora dobbiamo AggregateExceptionrappresentare più eccezioni, non è possibile installarlo per risolverlo. cioè se un'eccezione è già in volo, e un'altra viene lanciata, vengono combinate in un AggregateException. Il catchmeccanismo potrebbe essere modificato in modo tale che se si scrive catch (MyException)catturerà uno AggregateExceptionche include un'eccezione di tipo MyException. Ci sono varie altre complicazioni derivanti da questa idea, ed è probabilmente troppo rischioso modificare qualcosa di così fondamentale ora.

Potresti migliorare il tuo UsingAsyncper supportare la restituzione anticipata di un valore:

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

Quindi capisco bene: la tua idea è che in alcuni casi await usingpuò essere usato solo lo standard (è qui che DisposeAsync non getta in caso non fatale) e un aiutante come UsingAsyncè più appropriato (se è probabile che DisposeAsync lanci) ? (Certo, dovrei modificarlo in UsingAsyncmodo che non catturi ciecamente tutto, ma solo non fatale (e non ossuto nell'uso di Eric Lippert ).)
Vlad

@Varia sì - l'approccio corretto dipende totalmente dal contesto. Si noti inoltre che UsingAsync non può essere scritto una volta per utilizzare una categorizzazione vera a livello globale di tipi di eccezione a seconda che debbano essere rilevati o meno. Ancora una volta questa è una decisione da prendere in modo diverso a seconda della situazione. Quando Eric Lippert parla di quelle categorie, non sono fatti intrinseci sui tipi di eccezione. La categoria per tipo di eccezione dipende dal design. A volte è prevista una IOException in base alla progettazione, a volte no.
Daniel Earwicker,

4

Forse capisci già perché questo accade, ma vale la pena spiegarlo. Questo comportamento non è specifico await using. Succederebbe anche con un semplice usingblocco. Quindi, mentre dico Dispose()qui, tutto vale DisposeAsync()anche per.

Un usingblocco è solo zucchero sintattico per un blocco try/ finally, come dice la sezione delle osservazioni della documentazione . Quello che vedi accade perché il finallyblocco viene sempre eseguito, anche dopo un'eccezione. Pertanto, se si verifica un'eccezione e non esiste alcun catchblocco, l'eccezione viene messa in attesa fino a quando il finallyblocco non viene eseguito, quindi viene generata l'eccezione. Ma se si verifica un'eccezione finally, non vedrai mai la vecchia eccezione.

Puoi vedere questo con questo esempio:

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

Non importa se Dispose()o DisposeAsync()è chiamato all'interno di finally. Il comportamento è lo stesso.

Il mio primo pensiero è: non buttare dentro Dispose(). Ma dopo aver esaminato parte del codice di Microsoft, penso che dipenda.

Dai un'occhiata alla loro implementazione FileStream, per esempio. Sia il Dispose()metodo sincrono , che DisposeAsync()può effettivamente generare eccezioni. Il sincrono Dispose()fa ignorare alcune eccezioni intenzionalmente, ma non tutti.

Ma penso che sia importante tenere conto della natura della tua classe. In a FileStream, ad esempio, Dispose()scaricherà il buffer nel file system. Questo è un compito molto importante e devi sapere se fallito . Non puoi semplicemente ignorarlo.

Tuttavia, in altri tipi di oggetti, quando chiami Dispose(), non hai più alcun uso per l'oggetto. Chiamare Dispose()significa davvero "questo oggetto è morto per me". Forse pulisce parte della memoria allocata, ma il fallimento non influisce in alcun modo sul funzionamento dell'applicazione. In tal caso, potresti decidere di ignorare l'eccezione all'interno di Dispose().

Ma in ogni caso, se vuoi distinguere tra un'eccezione all'interno usingo un'eccezione che proviene Dispose(), allora hai bisogno di un blocco try/ catchsia all'interno che all'esterno del usingblocco:

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
}

Oppure non potresti proprio usare using. Scrivi un try/ catch/ finallyblocco te stesso, dove si rileva un'eccezione 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
    }
}

3
A proposito, source.dot.net (.NET Core) / riferimentiource.microsoft.com (.NET Framework) è molto più facile da navigare rispetto a GitHub
canton7,

La ringrazio per la risposta! So qual è il vero motivo (ho menzionato try / finally e il caso sincrono nella domanda). Ora sulla tua proposta. Un catch interno al usingblocco non sarebbe di aiuto perché di solito la gestione delle eccezioni viene eseguita in qualche luogo lontano dal usingblocco stesso, quindi la sua gestione all'interno di usingsolito non è molto pratica. Informazioni sull'uso di no using: è davvero meglio della soluzione proposta?
Vlad,

2
@ canton7 Fantastico! Conoscevo riferimentiource.microsoft.com , ma non sapevo che esistesse un equivalente per .NET Core. Grazie!
Gabriel Luci,

@Vlad "Better" è qualcosa a cui solo tu puoi rispondere. So che se stessi leggendo il codice di qualcun altro, preferirei vedere try/ catch/ finallybloccare poiché sarebbe immediatamente chiaro cosa sta facendo senza dover andare a leggere cosa AsyncUsingsta facendo. Hai anche la possibilità di fare un ritorno anticipato. Ci sarà anche un costo CPU aggiuntivo per il tuo AwaitUsing. Sarebbe piccolo, ma è lì.
Gabriel Luci,

2
@PauloMorgado Significa solo che Dispose()non dovrebbe essere lanciato perché viene chiamato più di una volta. Le implementazioni di Microsoft possono generare eccezioni e, per una buona ragione, come ho mostrato in questa risposta. Tuttavia, concordo sul fatto che dovresti evitarlo, se possibile, dato che nessuno si aspetterebbe normalmente di lanciare.
Gabriel Luci,

4

using è effettivamente il codice di gestione delle eccezioni (sintassi lo zucchero per provare ... finalmente ... Dispose ()).

Se il tuo codice di gestione delle eccezioni genera Eccezioni, qualcosa viene regalmente eliminato.

Qualunque altra cosa sia successa per farti entrare, non ha più importanza. Il codice di gestione delle eccezioni difettoso nasconderà tutte le possibili eccezioni, in un modo o nell'altro. Il codice di gestione delle eccezioni deve essere fisso, con priorità assoluta. Senza questo, non avrai mai abbastanza dati di debug per il vero problema. Vedo che spesso è fatto male. È altrettanto facile sbagliarsi, come gestire i puntatori nudi. Molto spesso, ci sono due articoli sul collegamento tematico I, che potrebbero aiutarti con qualsiasi concezione errata del design sottostante:

A seconda della classificazione delle eccezioni, questo è ciò che devi fare se il tuo codice di gestione delle eccezioni / Dipose genera un'eccezione:

Per Fatal, Boneheaded e Vexing la soluzione è la stessa.

Eccezioni esogene, devono essere evitate anche a costi elevati. C'è una ragione per cui usiamo ancora i file di registro piuttosto che i database di registro per registrare le eccezioni: DB Opeartions è il modo giusto per incorrere in problemi esogeni. I file di registro sono l'unico caso, in cui non mi dispiace nemmeno se mantieni il File Handle Open l'intero runtime.

Se devi chiudere una connessione, non preoccuparti troppo dell'altra estremità. Gestiscilo come fa UDP: "Manderò le informazioni, ma non mi importa se l'altra parte le capisce". Lo smaltimento riguarda la pulizia delle risorse sul lato client / lato su cui si sta lavorando.

Posso provare a avvisarli. Ma ripulire le cose dal lato server / FS? Questo è ciò di cui sono responsabili i loro timeout e la loro gestione delle eccezioni.


Quindi la tua proposta si riduce effettivamente alla soppressione delle eccezioni alla chiusura della connessione, giusto?
Vlad

@Vlad Exogenous? Sicuro. Dipose / Finalizer sono lì per ripulire dalla loro parte. È probabile che quando si chiude il conneciton esempio a causa di un'eccezione, è farlo becaue non è più dispone di una connessione funzionante a loro comunque. E che senso avrebbe ottenere un'eccezione "Nessuna connessione" durante la gestione della precedente eccezione "Nessuna connessione"? Invii un singolo "Yo, I a chiusura di questa connessione" in cui ignori tutte le eccezioni esogene o anche se si avvicina al bersaglio. Dopo che le implementazioni predefinite di Dispose lo fanno già.
Christopher,

@Vlad: ho ricordato che ci sono un sacco di cose da cui non dovresti mai fare eccezioni (eccetto quelle fatali fatali). Gli inizializzatori di tipi sono in cima all'elenco. Dispose è anche uno di quelli: "Per aiutare a garantire che le risorse vengano sempre ripulite in modo appropriato, un metodo Dispose dovrebbe essere richiamabile più volte senza generare un'eccezione". docs.microsoft.com/en-us/dotnet/standard/garbage-collection/…
Christopher

@Vlad The Chance of Fatal Exceptions? Dobbiamo sempre rischiare quelli e non dovremmo mai gestirli al di là di "call Dispose". E non dovrebbe davvero fare nulla con quelli. In realtà vanno senza menzione in alcuna documentazione. | Eccezioni senza testa? Risolvili sempre. | Eccezionali eccezioni sono i primi candidati per deglutizione / manipolazione, come in TryParse () | Esogeno? Inoltre dovrebbe essere sempre gestito. Spesso vuoi anche parlarne all'utente e registrarlo. Ma per il resto, non vale la pena uccidere il processo.
Christopher,

@Vlad Ho cercato SqlConnection.Dispose (). Non importa nemmeno di inviare qualcosa al server a causa della connessione. Qualcosa potrebbe ancora accadere come risultato di NativeMethods.UnmapViewOfFile();e NativeMethods.CloseHandle(). Ma quelli sono importati dall'esterno. Non vi è alcun controllo di alcun valore restituito o qualsiasi altra cosa che potrebbe essere utilizzata per ottenere un'eccezione .NET adeguata in merito a ciò che questi due potrebbero incontrare. Quindi mi sto fortemente prendendo in giro, SqlConnection.Dispose (bool) semplicemente non mi interessa. | Chiudere è molto più bello, in realtà lo dice al server. Prima che chiami smaltire.
Christopher,

1

Puoi provare a utilizzare AggregateException e modificare il codice in questo modo:

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

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.