versione italiana versione italiana
english version english version

Gestione degli errori in .NET (3/3): prestazioni

Prestazioni

L’uso delle eccezioni ha un costo.

Come abbiamo visto, l’uso di On Error Goto non è mai una soluzione più efficiente: poiché si basa sulle eccezioni, e per motivi legati alle funzionalità che deve fornire, il codice generato da On Error Goto è sempre meno efficiente del codice nativo VB.NET che fa uso di eccezioni.

Attenzione, questo non significa che VB.NET sia meno efficiente di VB6 rispetto alla gestione degli errori con On Error Goto. Semplicemente, probabilmente nessuno ha mai sottolineato che anche in VB.NET On Error Goto ha un costo!

Esaminiamo il codice che segue.


Private Function BenchmarkN1(ByVal a As Int32, ByVal b As Int32) As Int32

 

On Error GoTo BenchmarkN1err

 

BenchmarkN1 = BenchmarkN2(a, b)

 

Exit Function

 

BenchmarkN1err:

 

BenchmarkN1 = 0

 

End Function

 

 

Private Function BenchmarkN2(ByVal a As Int32, ByVal b As Int32) As Int32

 

BenchmarkN2 = BenchmarkN3(a, b)

 

End Function

 

 

Private Function BenchmarkN3(ByVal a As Int32, ByVal b As Int32) As Int32

 

BenchmarkN3 = a \ b End Function

In caso di errore, i tempi di esecuzione sono notevolmente più elevati rispetto a quelli che si hanno nel caso in cui il calcolo proceda correttamente.

La tabella che segue riassume i risultati di un loop che effettua 500.000 chiamate a BenchmarkN1. La pretesa non è quella di fare un benchmark comparativo di grande precisione, ma semplicemente di trovare le differenze più macroscopiche tra un approccio e l’altro.

 

Tempi (msec)

VB6 interpretato

VB6 compilato

VB.NET compilato

BenchmarkN1( 2, 0 )

Errore su ogni chiamata

1.977

4.440

24.801

BenchmarkN1( 2, 1 )

Nessun errore

989

202

397

 

Quante sorprese!

  • La presenza di un errore rallenta sempre un programma che ha sintassi On Error Goto.
  • Con VB6 la versione interpretata è più veloce di quella compilata: probabilmente ciò è dovuto al diverso modo in cui il codice compilato deve gestire lo stack di chiamata per recuperare l’errore.
  • VB.NET è molto più lento di VB6 in caso di errore, ma non così tanto se di errori non ve ne sono.

Personalmente non sono spaventato da numeri simili, in particolare per la prima riga, relativa alla presenza di una condizione di errore: non è verosimile che la chiamata che osserviamo dia errore sul 100% delle chiamate. Vediamo il rapporto dei tempi di esecuzione tra una chiamata senza errori e una con errore.

 

Rapporto
normale:errore

VB6 interpretato

VB6 compilato

VB.NET compilato

BenchmarkN1

1:2

1:20

1:60

 

In pratica, per ciascuna chiamata con errore, si possono fare 2 chiamate senza errori in VB6 interpretato, 20 in VB6 compilato e 60 in VB.NET.

Vediamo l’incidenza complessiva della gestione degli errori con On Error Goto rispetto alla frequenza con cui si rilevano delle condizioni di errore. La tabella che segue illustra il maggior costo computazionale rispetto ai tempi di chiamata delle stesse funzioni in assenza di errore, ipotizzando diverse percentuali di errore (da una chiamata con errore ogni 20 fino a 1 chiamata ogni 10.000).

 

Penalizzazione %

VB6 interpretato

VB6 compilato

VB.NET compilato

1 errore su 20 (5%)

10,00%

100,00%

300,00%

1 errore su 100 (1%)

2,00%

20,00%

60,00%

1 errore su 1.000 (0,1%)

0,20%

2,00%

6,00%

1 errore su 10.000 (0,01%)

0,02%

0,20%

0,60%

 

Al diminuire della frequenza degli errori, i valori diventano decisamente trascurabili, tanto per ribadire che l’eccezione deve essere un’eccezione: se è veramente così, i costi non ci preoccupano.

Torniamo un attimo a VB.NET: sembrerebbe molto penalizzato rispetto a VB6, ma prima abbiamo visto come il codice IL generato per On Error Goto sia poco efficiente.

Se le prove in VB.NET le facciamo modificando il codice in modo da usare un blocco Try/Catch, otteniamo valori differenti. Questo è il codice modificato.


Private Function BenchmarkNet1(ByVal a As Int32, ByVal b As Int32) As Int32
    Try
        Return BenchmarkEx2(a, b)
    Catch e As Exception
        Return 0
    End Try
End Function
 
Private Function BenchmarkN2(ByVal a As Int32, ByVal b As Int32) As Int32
    BenchmarkN2 = BenchmarkN3(a, b)
End Function
 
Private Function BenchmarkN3(ByVal a As Int32, ByVal b As Int32) As Int32
    BenchmarkN3 = a \ b
End Function

E questi i valori ottenuti.

 

Tempi (msec)

VB.NET compilato

BenchmarkNet1( 2, 0 )

Errore su ogni chiamata

24.045

BenchmarkNet1( 2, 1 )

Nessun errore

167

 

Non ci sono sostanziali differenze nel caso di errore, ma le prestazioni raddoppiano nel caso in cui non ci siano errori, migliorando in valore assoluto anche il codice compilato con VB6.

Esiste un vantaggio prestazionale a sostituire la gestione di On Error Goto con un blocco Try/Catch, in particolare quando l’errore non è la regola.

 

Quando diciamo che le eccezioni hanno un costo, sottintendiamo che hanno un costo più alto rispetto a del codice che fa uso, per trasmettere gli errori, di valori di ritorno, parametri passati per riferimento o variabili globali.

La domanda fatidica, quindi, è: quando vale la pena rinunciare alle eccezioni e a On Error Goto per non sacrificare le prestazioni?

Per rispondere a questa domanda, dobbiamo sapere due cose:

  1. Quanto costano le eccezioni (e quindi gli On Error Goto)
  2. Quale è l’alternativa

Alla prima domanda possiamo rispondere facendo un po’ di misure.

Prima abbiamo misurato le diverse prestazioni dei sistemi di gestione degli errori (On Error Goto e Try/Catch). Vediamo cosa succede usando un approccio diverso.


Private Function BenchmarkFast1(ByVal a As Long, ByVal b As Long) As Long
    Dim c As Long
    If Not BenchmarkFast2(a, b, c) Then c = 0
    BenchmarkFast1 = c
End Function
 
Private Function BenchmarkFast2(ByVal a As Long, ByVal b As Long, _
                                ByRef c As Long) As Boolean
    BenchmarkFast2 = BenchmarkFast3(a, b, c)
End Function
 
Private Function BenchmarkFast3(ByVal a As Long, ByVal b As Long, _
                                ByRef c As Long) As Boolean
    If b = 0 Then
        BenchmarkFast3 = False
    Else
        BenchmarkFast3 = True
        c = a \ b
    End If
End Function

Ed ecco i tempi misurati per 500.000 chiamate.

 

Tempi (msec)

VB6 interpretato

VB6 compilato

VB.NET compilato

BenchmarkFast1( 2, 0 )

Errore su ogni chiamata

1.006

48

56

BenchmarkFast1( 2, 1 )

Nessun errore

1.092

96

140

 

Il dato più eclatante è che il codice chiamato in condizioni di errore è più veloce: individuando preventivamente l’errore, esegue alcune operazioni in meno, quindi la cosa non è poi così strana.

VB.NET risulta leggermente più lento di VB6 compilato, ma le differenze sono minime e il senso di questo benchmark non è quello di stabilire chi sia più veloce tra VB6 e VB.NET (dovremmo misurare centinaia di altri casi diversi da questo).

Un’altra cosa interessante è che, in assenza di errori, il codice di BenchmarkFast1 è circa il doppio più veloce di BenchmarkN1. In alcuni casi non è cosa da poco, ma bisogna fare attenzione: stiamo analizzando condizioni estreme, che forse rispecchiano poco la realtà, dove le differenze effettive potrebbero essere minori.

 

Alla seconda domanda (quale è l’alternativa) possiamo rispondere solo caso per caso, senza generalizzare troppo. Se il punto in cui intercettiamo la condizione di errore è contestuale (o molto vicino) al punto in cui siamo in grado di gestire e risolvere la condizione di errore e se tale evento non è in realtà una condizione rarissima, ma qualcosa che si verifica piuttosto di frequente, magari all’interno di un loop, allora il costo implementativo per rinunciare alle eccezioni è qualcosa di accettabile (talvolta si scrive addirittura meno codice!).

Se viceversa la gestione dell’errore è molto distante dal punto in cui l’errore è rilevato, dobbiamo valutare il costo di trasferimento delle informazioni sull’errore da un livello di chiamata all’altro. Questo richiede linee di codice, disciplina da parte del programmatore e, se il numero di livelli di chiamata è alto, anche un certo overhead nel passaggio di tali valori da una funzione a un’altra (passaggio che avviene sempre, anche quando l’errore non si verifica).

Conclusioni

La gestione degli errori fatta con On Error Goto e con Try/Catch è pensata per gestire le eccezioni, e non per sostituirsi a delle If che verificano le condizioni abituali.

Non rinunciate alla chiarezza espressiva delle eccezioni e non pensiate che i blocchi Try/Catch danneggino irrimediabilmente il vostro codice: misurate le prestazioni per valutare se vale la pena riscrivere delle parti critiche. Sicuramente un Try/Catch chiamato dentro un loop non è un’idea saggia, spesso il Try/Catch al di fuori dal loop è più corretto anche dal punto di vista della logica applicativa.

Con VB.NET, usate Try/Catch al posto di On Error Goto / On Error Resume. Valutate la riscrittura delle funzioni che usano la vecchia logica se riscontrate problemi di prestazioni.

Anche se non è legato direttamente alla gestione degli errori, l’uso di Try/Finally deve essere pratica frequente nel codice che fa uso di oggetti che implementano l’interfaccia IDisposable. L’uso di Try/Finally è poco costoso e non vi sono casi reali in cui un approccio alternativo sarebbe complessivamente più efficiente in termini di prestazioni.