Wydajność: jak to jest z wyjątkami?

W sprawie wydajności wyjątków można znaleźć wiele opinii, często sprzecznych ze sobą. W dzisiejszym wpisie przedstawię kilka programików, mających na celu, wyjaśnienie jaki wpływ mają wyjątki oraz ich łapanie na wydajność aplikacji. Zacznijmy od przykładów a potem przejdziemy do analizy wyników. Kod z wyrzucaniem wyjątków:

internal class Program
{        
   private static void Main(string[] args)
   {
       const int n = 20000;

       Stopwatch stopwatch = Stopwatch.StartNew();

       for (int i = 0; i < n; i++)
       {
           try
           {
               GetValueWithException();
           }
           catch 
           {                    
           }
       }

       Console.WriteLine(stopwatch.ElapsedTicks);
   }
   private static object GetValueWithException()
   {
       throw new Exception();
   }
}

Wynik w trybie Debug to 900167.

Następnie analogiczna konstrukcja zwracająca po prostu NULL:

internal class Program
{        
   private static void Main(string[] args)
   {
       const int n = 20000;

       Stopwatch stopwatch = Stopwatch.StartNew();

       for (int i = 0; i < n; i++)
       {
        if (GetValueWithoutException() == null)
        {
            
        }
       }

       Console.WriteLine(stopwatch.ElapsedTicks);
   }
   private static object GetValueWithoutException()
   {
       return null;
   }
}

W trybie Debug uzyskano 760. Różnica jest więc kolosalna. Dlaczego wyrzucanie wyjątków jest tak wolne?

Najpierw musimy stworzyć nowy obiekt Exception, który jest klasą. Wiąże się  to z zadeklarowaniem pamięci na stercie. Następnie GC będzie musiał te wszystkie obiekty zebrać co jest ogromnym wyzwaniem i wpływa zdecydowanie niekorzystnie na wydajność.  W drugim rozwiązaniu zwracamy po prostu NULL czyli status operacji.

Należy zdać sobie sprawę, że wyjątki to coś więcej niż status wykonania operacji. Zaglądając do klasy Exception, znajdziemy tam wiele informacji o kontekście operacji, która nie powiodła się. Mamy do dyspozycji stacktrace, który zawiera informacje o tym, jakie metody były wykonane. Co za tym idzie, im większy stacktrace tym prawdopodobnie wyrzucenie wyjątku będzie trwało dłużej (więcej informacji do zebrania). Ponadto, wyjątek po drodze może zostać złapany i ponownie wyrzucony co jeszcze bardziej zwiększa czas wykonania. Tekst wyjątku, warto zauważyć jest zawsze w lokalnym języku – lokalizacja jest również drobnym obciążeniem. Istnieje możliwość zagnieżdżonych try-catch. Innymi słowy, wydajność wyjątków w dużej mierze zależy od głębokości stosu.

Inną ciekawą przyczyną dlaczego wyjątki są wolniejsze jest pamięć podręczna CPU. Wyrzucając wyjątek, zostanie ona zmieniona a co za tym idzie, w dalszej części algorytmu\programu będziemy mieli do czynienia z “cache misses’, co oznacza, że dane na których wcześniej operowaliśmy znów będą musiały zostać załadowane do cache.

Wyjątki jeszcze wolniejsze są w sytuacji, gdy wyrzucamy je pomiędzy AppDomain lub kodem zarządzanym\niezarządzanym – wtedy muszą być serializowane co dodatkowo obciąża CPU.

Wyjątki są traktowane w sposób specjalny przez CLR lub nawet Visual Studio. Każdy wyrzucony wyjątek jest od razu wyświetlany w oknie output. Oczywiście mowa tutaj o trybie Debug. Ponadto, mamy do dyspozycji wiele handlerów zarówno w AppDomain jak i w klasach typu Application (w zależności czy korzystamy z WPF czy WinForms).

Czy to znaczy, że mamy unikać wyjątków? NIE! Powyższy przykład jest tylko dowodem na to, że wyjątki nie służą do sterowania przepływem logiki. Oczywiście jest to zła praktyka i post udowadnia to z punktu wydajnościowego. Wyjątki powinny być używane gdy coś “wyjątkowego” zdarzyło się. Jeśli wyrzucamy kilka tysięcy wyjątków w ciągu godziny wtedy ma to niekorzystny wpływ na wydajność ale to znaczy , że architektura jest zła i wyjątki zostały użyte po prostu w niewłaściwy sposób.

Na koniec inny przykład:

internal class Program
{        
   private static void Main(string[] args)
   {
       const int n = 20000;

       Stopwatch stopwatch = Stopwatch.StartNew();

       for (int i = 0; i < n; i++)
       {
           try
           {
               GetValueWithException();
           }
           catch (Exception)
           {                    
           }
       }

       Console.WriteLine(stopwatch.ElapsedTicks);
   } 
   private static object GetValueWithException()
   {
       return null;
   }
}

Wynik to 700. Jeśli nie wyrzucamy wyjątków wtedy catch nie ma praktycznie żadnego obciążenia. “Problemy” zaczynają się wyłącznie wtedy, gdy korzystamy z throw a nie catch.

Leave a Reply

Your email address will not be published.