Łączenie wyjątków

Autor: Stephen Toub

Wyjątki w .NET są podstawowym mechanizmem, przez który zgłaszane są błędy i inne wyjątkowe sytuacje. Wyjątki są używane nie tylko do przechowywania informacji dotyczących takich kwestii, ale również do przekazywania tych informacji w postaci wystąpienia obiektu w obrębie stosu wywołań. W oparciu o model strukturalnej obsługi wyjątków (SEH) w Windows, tylko jeden wyjątek .NET może być „w działaniu” w danym momencie dla określonego wątku, a programiści zwykle nie myślą o tym ograniczeniu. Przecież jedna operacja zwykle zwraca tylko jeden wyjątek, a więc w kodzie sekwencyjnym, który najczęściej piszemy, musimy zajmować się tylko jednym wyjątkiem na raz. Jednakże istnieją różne scenariusze, w których wiele wyjątków mogłoby wynikać z jednej operacji. Obejmuje to scenariusze (ale nie jest do nich ograniczone) związane z przetwarzaniem równoległym.

Rozważmy wywołanie zdarzenia w .NET:

public event EventHandler MyEvent;

protected void OnMyEvent() {
    EventHandler handler = MyEvent; 
    if (handler != null) handler(this, EventArgs.Empty); 
}

Wiele delegatów może być zarejestrowanych dla MyEvent, a gdy delegat handler w powyższym fragmencie kodu zostanie wywołany, to operacja ta będzie odpowiednikiem kodu podobnego do następującego:

foreach(var d in handler.GetInvocationList()) {
    ((EventHandler)d)(this, EventArgs.Empty); 
}

Każdy delegat, który składa się na delegat handler jest wywoływany jeden po drugim. Jednakże, jeśli jakiś wyjątek zostanie wzbudzony z któregoś z wywołań, to pętla foreach zakończy przetwarzanie, co oznacza, że niektóre delegaty mogą nie zostać wykonane w przypadku wystąpienia wyjątku. Jako przykład rozważmy następujący kod:

MyEvent += (s, e) => Console.WriteLine("1");
MyEvent += (s, e) => Console.WriteLine("2");
MyEvent += (s, e) => { throw new Exception("uh oh"); };
MyEvent += (s, e) => Console.WriteLine("3");
MyEvent += (s, e) => Console.WriteLine("4");

Gdyby MyEvent został teraz wywołany, to na konsoli zostaną wypisane wartości „1” i „2”, wzbudzony zostanie wyjątek, a delegaty, które wypisałyby wartości „3” i „4” nie będą wywołane.

Aby zapewnić, że wszystkie delegaty zostaną wywołane nawet w przypadku wyjątku, możemy przepisać swoją metodę OnMyEvent następująco:

protected void OnMyEvent() {
    EventHandler handler = MyEvent; 
    if (handler != null) { 
        foreach (var d in handler.GetInvocationList()) { 
            try { 
                ((EventHandler)d)(this, EventArgs.Empty); 
            } 
            catch{}
        } 
    } 
}

Teraz przechwytujemy wszystkie wyjątki, które zostały wzbudzone w zarejestrowanej procedurze obsługi, co pozwala wywołać nawet delegaty występujące po wyjątku. Jeśli ponownie uruchomimy wcześniejszy przykład, to zobaczymy w wyniku wartości „1”, „2”, „3” i „4”, chociaż wyjątek zostanie wzbudzony w jednym z delegatów. Niestety ta nowa implementacja zjada również wyjątki, co nie jest dobrą praktyką. Wyjątki te mogą wskazywać na poważny problem z aplikacją, którego nie obsługujemy, ponieważ te wyjątki są ignorowane.

To czego naprawdę chcemy, to przechwytywanie wyjątków, które się ewentualnie pojawią, a następnie po zakończeniu wywoływania wszystkich procedur obsługi zdarzenia ponowne wzbudzenie wszystkich wyjątków, które pojawiły się w procedurach obsługi. Oczywiście, jak już wspomniano, tylko jedno wystąpienie wyjątku może być wzbudzone w danym wątku w danym czasie. Tu pojawia się klasa AggregateException.

W .NET Framework 4 System.AggregateException jest nowym typem wyjątku w mscorlib. Choć jest względnie prostym typem, to umożliwia mnóstwo scenariuszy zapewniając centralną i użyteczną funkcjonalność związaną z wyjątkami.

Sama klasa AggregateException jest wyjątkiem (dziedziczy po System.Exception), który zawiera inne wyjątki. Bazowa klasa System.Exception ma już możliwość zawierania w sobie pojedynczego wystąpienia Exception, zwanego „wyjątkiem wewnętrznym”. Wyjątek wewnętrzny dostępny poprzez właściwość InnerException klasy Exception reprezentuje przyczynę wyjątku i jest często wykorzystywany przez biblioteki, które grupują funkcjonalności i wykorzystują wyjątki do przekazywania dalej zwróconych informacji. Na przykład składnik przetwarzający dane wejściowe ze strumienia może napotkać wyjątek IOException podczas odczytu ze strumienia. Może następnie utworzyć wyjątek CustomParserException obejmujący wystąpienie IOException jako InnerException zapewniając szczegóły wyższego poziomu związane z tym, co się nie powiodło podczas operacji przetwarzania udostępniając przy tym nadal wyjątek niższego poziomu IOException wraz ze związanymi z nim szczegółami.

AggregateException po prostu rozszerza te możliwości pozwalając obejmować kilka wyjątków wewnętrznych. Zapewnia konstruktory, które przyjmują tablice lub wyliczenia zawierające te wyjątki wewnętrzne (oprócz standardowego konstruktora, który przyjmuje pojedynczy wyjątek wewnętrzny) i udostępnia wyjątki wewnętrzne poprzez właściwość InnerExceptions (oprócz właściwości InnerException z klasy bazowej). Rysunek 1 przedstawia publiczne elementy klasy AggregateException.

Rysunek 1: System.AggregateException.

[Serializable]
[DebuggerDisplay("Count = {InnerExceptions.Count}")]
public class AggregateException : Exception
{
    public AggregateException();
    public AggregateException(params Exception[] innerExceptions);
    public AggregateException(IEnumerable<Exception> innerExceptions);
    public AggregateException(string message);
    public AggregateException(string message, Exception innerException);
    public AggregateException(string message, 
        params Exception[] innerExceptions);
    public AggregateException(string message, 
        IEnumerable<Exception> innerExceptions);

    public AggregateException Flatten();
    public void Handle(Func<Exception, bool> predicate);

    public ReadOnlyCollection<Exception> InnerExceptions { get; }
}

Jeśli AggregateException nie ma żadnych wyjątków wewnętrznych, to InnerException zwróci null, a InnerExceptions zwróci pustą kolekcję. Jeśli obiektowi AggregateException dostarczymy pojedynczy wyjątek, to InnerException zwróci to wystąpienie (jak można by się spodziewać), a InnerExceptions zwróci kolekcję z tylko jednym wyjątkiem. A jeśli obiektowi AggregateException dostarczymy wiele wyjątków, to InnerExceptions zwróci je wszystkie w kolekcji, a InnerException zwrócie pierwszy element z tej kolekcji.

Teraz dzięki AggregateException możemy poszerzyć nasz kod .NET wzbudzający wyjątki, jak pokazano na Rysunku 2, więc jesteśmy w stanie mieć ciastko i zjeść ciastko. Delegaty zarejestrowane w zdarzeniu będą nadal działać nawet, jeśli jeden z nich wzbudzi wyjątek, a przy tym nie stracimy żadnej informacji o wyjątkach, ponieważ zostaną one wszystkie zapakowane w wyjątek łączący i wzbudzone ponownie na końcu (oczywiście, jeśli któryś z delegatów zawiedzie).

Rysunek 2: Korzystanie z AggregateException przy obsłudze zdarzeń.

protected void OnMyEvent() { 
    EventHandler handler = MyEvent; 
    if (handler != null) { 
        List<Exception> exceptions = null;
        foreach (var d in handler.GetInvocationList()) 
        { 
            try { 
                ((EventHandler)d)(this, EventArgs.Empty); 
            } 
            catch (Exception exc) { 
                if (exceptions == null) 
                    exceptions = new List<Exception>();
                exceptions.Add(exc);
            } 
        } 
        if (exceptions != null) throw new AggregateException(exceptions); 
    } 
}

Zdarzenia stanowią porządny przykład, w którym łączenie wyjątków jest przydatne w kodzie sekwencyjnym. Jednakże AggregateException ma również kolosalne znaczenie dla nowych konstrukcji równoległych w .NET 4 (a choć w istocie klasa AggregateException jest przydatna w kodzie nierównoległym, to typ ten został utworzony i dodany do .NET Framework przez zespół platformy przetwarzania równoległego – Parallel Computing Platform w firmie Microsoft).

Rozważmy nową metodę Parallel.For w .NET 4, która jest używana do równoległego przetwarzania pętli for. W typowej pętli for tylko jedna iteracja tej pętli może być wykonywana w danym czasie, co oznacza, że tylko jeden wyjątek może wystąpić na raz. Jednakże w równoległej „pętli” wiele iteracji może być wykonywanych równolegle i wiele iteracji może wzbudzać jednocześnie wyjątki. Pojedynczy wątek wywołuje metodę Parallel.For, która logicznie może wzbudzić wiele wyjątków i dlatego potrzebny nam jest mechanizm, poprzez który te wyjątki mogą być przekazywane do pojedynczego wątku wykonywania. Parallel.For obsługuje to poprzez zbieranie wzbudzonych wyjątków i przekazywanie ich zapakowanych w wyjątek AggregateException. Pozostałe metody Parallel (Parallel.ForEach i Parallel.Invoke) obsługują sprawy podobnie, tak jak Parallel LINQ (PLINQ), również część .NET 4. W kwerendzie LINQ-to-Objects tylko jeden delegat użytkownika jest wywoływany na raz, ale przy korzystaniu z PLINQ wiele delegatów użytkownika może być wywoływanych równolegle, delegaty te mogą wzbudzać wyjątki, a PLINQ radzi sobie z tym zbierając je w wyjątku AggregateException i przekazując ten łączny wyjątek.

Jako przykład tego rodzaju wykonywania równoległego rozważmy Rysunek 3, który pokazuje metodę wykorzystującą pulę wątków (ThreadPool) do równoległego wywoływania wielu delegatów Action zapewnianych przez użytkownika (solidniejsza i lepiej skalowalna implementacja tej funkcjonalności istnieje w .NET 4 w klasie Parallel). Kod ten wykorzystuje QueueUserWorkItem do uruchomienia każdego delegata Action. Jeśli delegat Action wzbudzi wyjątek, to zamiast pozwolić temu wyjątkowi przejść dalej bez obsłużenia (co domyślnie przerywa proces), kod przechwytuje wyjątek i zapisuje go na liście wspólnej dla wszystkich działań składowych. Po zakończeniu wszystkich wywołań asynchronicznych (sukcesem lub wyjątkiem), wzbudzony zostanie wyjątek AggregateException z przechwyconymi wyjątkami, jeśli jakieś zostały przechwycone (warto zwrócić uwagę, że ten kod można by wykorzystać w OnMyEvent do równoległego wykonania wszystkich delegatów zarejestrowanych dla zdarzenia).

Rysunek 3: AggregateException w wywołaniu równoległym.

public static void ParallelInvoke(params Action[] actions)
{
    if (actions == null) throw new ArgumentNullException("actions");
    if (actions.Any(a => a == null)) throw new ArgumentException      ("actions");
    if (actions.Length == 0) return;

    using (ManualResetEvent mre = new ManualResetEvent(false)) {
        int remaining = actions.Length;
        var exceptions = new List<Exception>();
        foreach (var action in actions) {
            ThreadPool.QueueUserWorkItem(state => {
                try {
                    ((Action)state)();
                }
                catch (Exception exc) {
                    lock (exceptions) exceptions.Add(exc);
                }
                finally {
                    if (Interlocked.Decrement(ref remaining) == 0) mre.Set();
                }
            }, action);
        }
        mre.WaitOne();
        if (exceptions.Count > 0) throw new AggregateException(exceptions);
    }
}

Nowa przestrzeń nazw System.Threading.Tasks w .NET 4 również swobodnie korzysta z AggregateException. Task w .NET 4 jest obiektem, który reprezentuje operację asynchroniczną. W odróżnieniu od QueueUserWorkItem, które nie zapewnia żadnego mechanizmu odwoływania się z powrotem do zakolejkowanego zadania, Task zapewnia uchwyt do działania asynchronicznego, co pozwala wykonywać dużą liczbę ważnych operacji, na przykład oczekiwać na zakończenie pracy lub kontynuowanie działań w celu wykonania jakiejś operacji po zakończeniu danego działania. Metody Parallel wspomniane wcześniej, a także PLINQ opierają się na obiektach Task.

Kontynuując omawianie AggregateException, łatwą konstrukcją do rozważenia tutaj jest statyczna metoda Task.WaitAll. Do metody WaitAll przekazujemy wszystkie wystąpienia Task, na które chcemy oczekiwać, a WaitAll zastosuje „blokadę” do czasu aż te wystąpienia Task zostaną zakończone (umieściłem „blokadę” w cudzysłowie, ponieważ metoda WaitAll może w istocie uczestniczyć w wykonywaniu wystąpień Task tak, aby minimalizować zużycie zasobów i zapewniać lepszą wydajność, zamiast po prostu blokować wątek). Jeśli wszystkie wystąpienia Task zakończą się powodzeniem, to kod będzie kontynuowany dalej. Jednakże wiele wystąpień Task może wzbudzić wyjątki, a metoda WaitAll może przekazać tylko jeden wyjątek do wątku, który ją wywołał, więc opakowuje te wyjątki w pojedynczy obiekt AggregateException i wzbudza ten łączny wyjątek.

Obiekty Task wykorzystują wyjątki AggregateException również w innych miejscach. Jednym, które może nie być oczywiste, jest relacja podrzędności pomiędzy obiektami Task. Domyślnie obiekty Task tworzone podczas wykonywania jakiegoś obiektu Task są podporządkowane wobec tego obiektu Task zapewniając formę strukturalnej równoległości. Na przykład obiekt Task A tworzy Task B i Task C, a więc Task A jest obiektem nadrzędnym obiektów Task B i Task C. Te relacje mogą mieć znaczenie w odniesieniu do czasu trwania obiektów. Obiekt Task nie jest traktowany jako zakończony, dopóki wszystkie jego obiekty podrzędne nie zostaną zakończone, jeśli więc użyliśmy oczekiwania na obiekt Task A, to oczekiwanie to nie zostanie zakończone dopóki nie zostaną zakończone zarówno obiekty B, jak i C. Te relacje podrzędności nie tylko wpływają w tym względzie na wykonywanie, ale są również widoczne poprzez nowe okna narzędzia debugowania w Visual Studio 2010 znacznie upraszczając debugowanie pewnych rodzajów działań.

Rozważmy kod podobny do następującego:

var a = Task.Factory.StartNew(() => { 
    var b = Task.Factory.StartNew(() => { 
        throw new Exception("uh"); 
    }); 
    var c = Task.Factory.StartNew(() => { 
        throw new Exception("oh"); 
    }); 
});
...
a.Wait();

Tutaj obiekt Task A ma dwa obiekty podrzędne, na które nie wprost oczekuje zanim zostanie uznany za zakończony, a oba te obiekty podrzędne wzbudzają nieobsłużone wyjątki. Aby sobie z tym poradzić Task A opakowuje wyjątki ze swoich obiektów podrzędnych w AggregateException, a ten łączny wyjątek jest zwracany przez właściwość Exception obiektu A i wzbudzany w wywołaniu Wait dla A.

Jak zademonstrowałem, AggregateException może być bardzo użytecznym narzędziem. Jednak z powodów związanych z użytecznością i spójnością może to też prowadzić do projektów, które z początku mogą nie być intuicyjne. Aby wyjaśnić, o co mi chodzi, rozważmy następującą funkcję:

public void DoStuff() 
{
    var inputNum = Int32.Parse(Console.ReadLine()); 
    Parallel.For(0, 4, i=> { 
        if (i < inputNum) throw new MySpecialException(i.ToString()); 
    });
}

Tutaj w zależności od danych wprowadzonych przez użytkownika kod zawarty w pętli równoległej może wzbudzić 0, 1 lub więcej wyjątków. Teraz rozważmy kod, który musielibyśmy napisać, aby obsłużyć te wyjątki. Gdyby Parallel.For opakowało wyjątki w AggregateException tylko w przypadku, gdyby wzbudzone zostało wiele wyjątków, to jako korzystający z funkcji DoStuff musielibyśmy napisać dwie oddzielne procedury przechwytujące wyjątki: jedną dla przypadku, w którym wystąpił tylko jeden wyjątek MySpecialException i jeden dla przypadku, w którym wystąpił wyjątek AggregateException. Kod do obsługi AggregateException prawdopodobnie przeszukiwałby właściwość InnerExceptions w AggregateException w poszukiwaniu MySpecialException, a następnie uruchamiał ten sam kod obsługi dla tego wyjątku, który mielibyśmy w bloku catch dedykowanym dla wyjątku MySpecialException. Gdy zaczniemy mieć do czynienia z większą liczbą wyjątków, skala tego problemu wzrośnie. Aby poradzić sobie z tym problemem, a także aby zapewnić spójność, metody .NET 4 takie jak Parallel.For, które muszą sobie radzić z potencjalnymi wieloma wyjątkami, zawsze je opakowują nawet, jeśli występuje tylko pojedynczy wyjątek. W ten sposób trzeba pisać tylko jeden blok catch dla AggregateException. Wyjątkiem od tej reguły jest to, że wyjątki, które mogą nigdy nie wystąpić w zakresie równoległym, nie będą opakowywane. Na przykład wyjątki, które mogą pochodzić z metody Parallel.For z powodu sprawdzania poprawności jej argumentów i wykrycia, że jeden z nich ma wartość null, nie będą opakowywane. Sprawdzanie poprawności argumentów ma miejsce zanim Parallel.For rozpocznie jakiekolwiek działania asynchroniczne i dlatego nie jest możliwe, aby mogło wtedy wystąpić wiele wyjątków.

Oczywiście opakowywanie wyjątków w obiekty AggregateException może również prowadzić do pewnych trudności z powodu tego, że teraz musimy sobie radzić z dwoma modelami: wyjątkami opakowanymi i nie. Aby ułatwić przejście pomiędzy nimi, AggregateException zapewnia kilka metod pomocniczych upraszczających pracę z tymi modelami.

Pierwszą metodą pomocniczą jest Flatten. Jak wspominałem obiekt AggregateException jest sam obiektem Exception, więc może być wzbudzany jako wyjątek. To jednak oznacza, że wystąpienia AggregateException mogą opakowywać inne wystąpienia AggregateException i w istocie jest to prawdopodobne zwłaszcza w przypadku funkcji rekurencyjnych, które mogą wzbudzać łączone wyjątki. Domyślnie AggregateException zachowuje tę strukturę hierarchiczną, co może być pomocne przy debugowaniu, ponieważ hierarchiczna struktura zawieranych wyjątków będzie prawdopodobnie odpowiadać strukturze kodu, który wzbudził te wyjątki. Jednakże może to również utrudniać pracę z łączonymi wyjątkami w niektórych przypadkach. Aby temu zaradzić, metoda Flatten usuwa warstwy zawartych wyjątków łączonych tworząc nowy obiekt AggregateException, który zawiera wyjątki inne niż AggregateException ze wszystkich poziomów hierarchii. Jako przykład powiedzmy, że mieliśmy następującą strukturę wystąpień wyjątków:

  • AggregateException
  • InvalidOperationException
  • ArgumentOutOfRangeException
  • AggregateException
  • IOException
  • DivideByZeroException
  • AggregateException
  • FormatException
  • AggregateException
  • TimeZoneException

Jeśli wywołamy Flatten dla zewnętrznego wystąpienia AggregateException, to uzyskamy nowy obiekt AggregateException o następującej strukturze:

  • AggregateException
  • InvalidOperationException
  • ArgumentOutOfRangeException
  • IOException
  • DivideByZeroException
  • FormatException
  • TimeZoneException

Ułatwi nam to znacznie przechodzenie w pętli i badanie kolekcji InnerExceptions bez konieczności przejmowania się rekurencyjnym przechodzeniem przez zawarte wyjątki łączone.

Druga metoda pomocnicza Handle ułatwia takie przechodzenie przez wyjątki. Metoda Handle ma następującą sygnaturę:

public void Handle(Func predicate);

Oto jej uproszczona implementacja:

public void Handle(Func<Exception,bool> predicate) 
{ 
    if (predicate == null) throw new ArgumentNullException("predicate"); 
    List<Exception> remaining = null; 
    foreach(var exception in InnerExceptions) { 
        if (!predicate(exception)) {
            if (remaining == null) remaining = new List<Exception>();
            remaining.Add(exception); 
        }
    } 
    if (remaining != null) throw new AggregateException(remaining); 
}

Handle przechodzi przez zawartość InnerExceptions w AggregateException i dla każdego wyjątku wykonuje funkcję predykatu. Jeśli funkcja predykatu zwróci true dla danego wystąpienia wyjątku, to ten wyjątek jest traktowany jako obsłużony. Jeśli jednak predykat zwróci false, to ten wyjątek jest wzbudzany z powrotem z metody Handle, jako część nowego wyjątku AggregateException zawierającego wszystkie wyjątki, które nie odpowiadały predykatowi. To podejście można zastosować, aby szybko odfiltrować wyjątki, które nas nie interesują, na przykład:

try {
    MyOperation();
} 
catch(AggregateException ae) { 
    ae.Handle(e => e is FormatException); 
}

To wywołanie Handle odfiltrowuje wszystkie wyjątki FormatException z przechwyconego wyjątku AggregateException. Jeśli są jakieś wyjątki poza wyjątkami FormatException, to tylko one zostaną wzbudzone ponownie jako część nowego wyjątku AggregateException, a jeśli nie ma żadnych wyjątków poza wyjątkami FormatException, to Handle wróci bez wzbudzania ponownie żadnego wyjątku. W niektórych przypadkach może być też przydatne użycie najpierw metody Flatten, jak można zobaczyć tutaj:

ae.Flatten().Handle(e => e is FormatException);

Oczywiście w gruncie rzeczy AggregateException jest po prostu pojemnikiem na inne wyjątki i możemy pisać swoje własne metody pomocnicze do pracy z zawartymi w nim wyjątkami w sposób, który odpowiada potrzebom naszej aplikacji. Na przykład być może obchodzi nas bardziej po prostu wzbudzenie pojedynczego wyjątku zamiast zachowywania wszystkich wyjątków. Moglibyśmy napisać metodę rozszerzającą podobną do następującej:

public static void PropagateOne(this AggregateException aggregate)
{
    if (aggregate == null) throw new ArgumentNullException("aggregate");
    if (aggregate.InnerException != null)
        throw aggregate.InnerException; // wzbudź tylko jeden wyjątek
}

którą następnie moglibyśmy wykorzystać w następujący sposób:

catch(AggregateException ae) { ae.PropagateOne(); }

Lub być może chcemy zastosować filtr, aby pokazać tylko te wyjątki, które odpowiadają pewnym kryteriom, a następnie połączyć informacje o tych wyjątkach. Na przykład moglibyśmy mieć obiekt AggregateException zawierający całą grupę wyjątków ArgumentException i chcielibyśmy podsumować, które parametry spowodowały problemy:

AggregateException aggregate = ...;
string [] problemParameters = 
    (from exc in aggregate.InnerExceptions
     let argExc = exc as ArgumentException
     where argExc != null && argExc.ParamName != null
     select argExc.ParamName).ToArray();

Ogólnie rzecz biorąc nowa klasa System.AggregateException jest prostym, ale świetnym narzędziem zwłaszcza dla aplikacji, które nie mogą pozwolić sobie na przeoczenie żadnego wyjątku. Do celów debugowania implementacja ToString dla AggregateException wypisuje łańcuch określający wszystkie zawarte wyjątki. Jak widać wcześniej na Rysunku 1, AggregateException ma nawet atrybut DebuggerDisplayAttribute pomagający nam szybko zidentyfikować, ile wyjątków zawiera obiekt AggregateException.

Swoje pytania i uwagi można przesłać autorowi na adres netqa@microsoft.com.

Stephen Toub jest starszym menedżerem programowym w zespole platformy przetwarzania równoległego (Parallel Computing Platform) w firmie Microsoft. Jest również redaktorem współpracującym z MSDN Magazine.