Pisanie solidnego kodu: Constrained Execution Regions (CERs)

Z pewnością każdy z Was odpowiedziałby, że pisze solidny kod. Oczywiście zależy to od przyjętych metryk i definicji “solidny kod”. Nie zawsze warto skupiać uwagę na drobiazgach  i pułapach, których jest na prawdę wiele. Czasami jednak jest to konieczność, głównie w aplikacjach serwerowych, które muszą działać, nawet, gdy dostarczone dane są np. nieprawidłowe. W przypadku awarii, niedopuszczalne jest wtedy zepsucie stanu aplikacji. Rozważmy, taką sytuację:

try
{
  // jakaś logika
}
catch(IOException e)
{
  // obsługa błędu
}
finally
{
  // rollback - przywrócenie stanu, wycofanie zmian
}

Jakie pułapki kryje powyższy kod ? Dobrą praktyką jest używanie finally. Klauzula finally  “gwarantuje”, że kod zawarty w nim, zostanie wykonany nawet w sytuacji w której jest wyrzucony wyjątek ThreadAbortException, powodujący usunięcie AppDomain (bardzo ważne w aplikacjach serwerowych). Dlaczego słowo gwarantuje umieściłem w cudzysłowie? Oczywiście możemy mieć po prostu pecha, i komputer zostanie wyłączony w momencie wykonywania finally (np. awaria zasilacza.). Są jednak pewne przypadki, które mogą zdarzyć się a my jesteśmy w stanie im zapobiec. Wykonanie jakiegokolwiek kodu powoduje wykonanie kilku czynności m.in.:

  1. Załadowanie wymaganych bibliotek do pamięci.
  2. Wykonanie kompilacji IL do kodu natywnego.
  3. Wywołanie statycznych konstruktorów.
  4. Alokacja pamięci.

KAŻDA z powyższych operacji, może zakończyć się niepowodzeniem. Jako programiści, chcemy zminimalizować ryzyko awarii w finally. Jest to krytyczny fragment aplikacji – jeśli kod wywoła wyjątek w finally, stan aplikacji może być nieprawidłowy. Oczywiście ciągle, mowa o aplikacjach np. serwerowych, które wymagają tak wysokiej niezawodności (reliability). Jak możemy zapobiec przedstawionym problemom? Jednym z rozwiązań jest CER, który w skrócie, wykona przedstawione operacje przed wejściem w try (wywołanie konstruktorów statycznych, JIT, alokacja pamięci itp.). Jeśli np. ma zabraknąć pamięci, stanie się to przed try a zatem aplikacja co prawda zakończy działanie ale z prawidłowym stanem, co jest niesłychanie ważne. Chcemy zatem przed wejściem w try, upewnić się, że mamy wystarczające zasoby na wykonanie ewentualnego recovery.

Działanie CER łatwo pokazać na przykładzie konstruktora statycznego. Zwykle, konstruktor statyczny jest wykonywany w momencie odwołania się do jakiś statycznych zasobów klasy:

class CerExample
{
   static CerExample()
   {
       Console.WriteLine("Type ctor");
   }      
   public static void AnyMethod(){}
}
internal class Program
{
   public static void Main()
   {
       try
       {
           Console.WriteLine("Trying...");
       }
       finally
       {
           CerExample.AnyMethod();
       }
   }
}

Typowy output dla powyższego kodu to najpierw “Trying” a potem “Type Ctor”. Najczęściej konstruktor statyczny, JIT itp. są wykonywane dopiero gdy zajdzie taka potrzeba. W przypadku CER oczywiście wszystkie operacje zostaną wykonane przed try:

class CerExample
{
   static CerExample()
   {
       Console.WriteLine("Type ctor");
   }
   [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
   public static void AnyMethod(){}
}
internal class Program
{                
   public static void Main()
   {
       RuntimeHelpers.PrepareConstrainedRegions();        
       try
       {
           Console.WriteLine("Trying...");
       }
       finally
       {
           CerExample.AnyMethod();
       }
   }
}

Przed try należy wykonać metodę PrepareConstrainedRegions, która przeszuka finally i wykona operacje takie jak JIT czy alokacja pamięci już przed try. Nie są przeszukiwane wszystkie metody, ale tylko te oznaczone atrybutem ReliabilityContract. Właściwość  Consistency  atrybutu ReliabilityContract przyjmuje następujące wartości: MayCorruptProcess, MayCorruptAppDomain, MayCorruptInstance, WillNotCorruptState. Akceptowalne wartości dla CER to WillNotCorruptState albo MayCorruptInstance. Po prostu CER nie może zagwarantować poprawności metod, które mogą popsuć stan AppDomain lub całego procesu. Właściwość Cer z kolei zawiera wartości Cer.Success, MayFail albo None. Cer.None oznacza, że mechanizm Cer nie będzie wykorzystywany (domyślna wartość). Cer.Sucess oraz Cer.MayFail dokumentują, czy funkcja może spodobać błąd czy nie.

Oczywiście PrepareConstrainedRegions ma ograniczone możliwości. Niemożliwe jest np. przeanalizowanie wirtualnych metod, reflection czy zdarzeń. RuntimeHelpers zawiera jednak kilka innych metod, które potrafią rozwiązać problem z wirtualnymi metodami – ale o tym innym razem.

public static void PrepareMethod(RuntimeMethodHandle method)
public static void PrepareMethod(RuntimeMethodHandle method,RuntimeTypeHandle[] instantiation)
public static void PrepareDelegate(Delegate d);
public static void PrepareContractedDelegate(Delegate d);

Naturalnie wspomniany atrybut nie powoduje, że kompilator będzie sprawdzał czy Consistency lub Cer mają prawidłowe wartości – to czy funkcja zmienia stan AppDomain czy nie należy do naszej odpowiedzialności. Musimy zatem sami określić prawidłową spójność danych. Inną, intersującą metodą w RuntimeHelpers jest ExecuteCodeWithGuaranteedCleanup:

public static void ExecuteCodeWithGuaranteedCleanup(
    RuntimeHelpers.TryCode code,
    RuntimeHelpers.CleanupCode backoutCode,
    Object userData
)

Przyjmuje ona po prostu kod Try oraz Finally. Jeśli nie mamy w kodzie try-catch-finally, możemy wywołać powyższą metodę, przekazując wskaźniki do naszych funkcji.

public delegate void TryCode(Object userData);
public delegate void CleanupCode(Object userData, Boolean exceptionThrown);

Leave a Reply

Your email address will not be published.