Metoda MemberwiseClone

Jak wiemy, każdy obiekt, zarówno reference jak i value, pochodzi pośrednio\bezpośrednio od klasy object. Nie chce omawiać szczegółów ale jedną z protected metod dostarczonych przez object jest właśnie MemberwiseClone. Do czego ona służy?

W skrócie: do wykonania tzw. płytkiej kopii (shallow copy). MemberwiseClone kopiuje obiekt pole po polu. Niestety jest to płytka kopia więc jak jakiś obiekt zawiera referencje do innych obiektów wtedy jedynie adres zostanie sklonowany a nie całe drzewo obiektów. Z typami value nie ma takich problemów bo są one kopiowane bit po bicie (int, float, structs etc).

Jeśli płytka kopia nam wystarczy, wtedy z MemberwiseClone jest to bardzo proste. Jak już wspomniałem, jest to chroniona metoda więc aby z niej skorzystać należy ją w jakiś sposób wyeksponować. Dobrym zwyczajem jest implementacja interfejsu ICloneable:

class Person:ICloneable
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public int Age{get;set;}
    public Company Company { get; set; }
    
    public object Clone()
    {
        return MemberwiseClone();
    }
}
class Company
{
    public string Name { get; set; }
}

Następnie zróbmy mały test:

var person = new Person {FirstName = "Piotr", LastName = "Zielinski",Age = -1};
person.Company = new Company {Name = "GE"};

var newPerson = (Person)person.Clone();
newPerson.LastName=newPerson.FirstName = "new";
newPerson.Company.Name = "new";
newPerson.Age = -5;

Debug.Assert(newPerson.FirstName!=person.FirstName);
Debug.Assert(newPerson.LastName != person.LastName);
Debug.Assert(newPerson.Age != person.Age);

Debug.Assert(newPerson.Company.Name == person.Company.Name);

Zgodnie z definicją shallow copy, powinniśmy zaobserwować następujące zachowanie:

  1. Zmiana FirstName, LastName, Age powinna dotyczyć tylko nowego obiektu. Pomimo, że string to klasa a nie struktura, to zachowuje się jak zwykły value type.
  2. Company jest typem referencyjnym zatem został skopiowany wyłącznie adres. A co za tym idzie, zmiana newPerson.Company.Name spowoduje zmianę również person.Company.Name ( to ten sam obiekt).

Porównywanie string’ów część II – ustawienia regionalne, StringComparison

W poprzednim poście podałem krótki fragment kodu, który można było zoptymalizować. Wspomniałem o parametrze StringComparison, który zwykle sprawia problemy w zrozumieniu. W dzisiejszym wpisie postaram się wyjaśnić różnicę między poszczególnymi porównaniami. Dla przypomnienia istnieją następujące wartości StringComparison:

1. Ordinal  – domyślna wartość. Znaki zamieniane są na wartości numeryczne  i wtedy porównywane. Zdecydowanie najszybsza metoda jeśli chodzi o wydajność.

2. OrdinalIgnoreCase – tak jak wyżej ale ignorowana jest wielkość liter.

3. CurrentCulture – brana jest pod uwagę dana kultura. Zatem wynik może być różny w zależności od danej konfiguracji komputera (tzn. ustawień regionalnych).

4. InvariantCulture – niebrane są pod uwagę ustawienia regionalne – zawsze wynik będzie taki sam.

5. CurrentCultureIgnoreCase, InvariantCultureIgnoreCase – analogicznie jak wyżej z tym, że wielkość liter jest ignorowana.

Ordinal jest domyślną wartością dla funkcji string.Equals. Stanowi porównanie binarne a nie leksykalne. Z tego względu żadne wartości związane z danym językiem nie są brane pod uwagę. Warto zwrócić uwagę, że dla funkcji porównującej dwa napisy (Equals) jest to dokładnie co w większości sytuacjach chcemy. Porównanie binarne w końcu zagwarantuje nam, że dwa łańcuchy znaków są dokładnie takie same. Na przykład porównanie “DOM” z “DOM1” wygląda następująco:

68 79 77(DOM) !=  68 79 77 49(DOM1),

Każdy znak zamieniany jest na liczbę (unicode) i wtedy operacja porównywania dokonywana jest właśnie na tych wartościach. Niepotrzebne nam są w większości przypadków uwarunkowania językowe. Chcemy wiedzieć po prostu czy napisy są identyczne. Z tego względu Ordinal dla funkcji Equal jest dobrą wartością domyślną.

OrdinalIgnoreCase wykonuje podobną operację z tym, że ignoruje wielkość liter (“DOM” == “dom” itp.).

Wiemy, już jakie jest domyślne zachowanie metody Equal, bez podania StringCompairson. Poniższy kod również wykonuje standardowe porównanie Ordinal:

if(str1==str2)
{ 
}

Podsumowując, dla operacji ==(Equals) Ordinal lub OrdinalIgnoreCase jest wartością, którą chcemy w większości przypadków. Porównanie binarne (Ordinal) jest najszybsze dlatego nie warto go zmieniać ponieważ wydajność może dość znacząco spaść.

Kolejne wartości to CurrentCulture oraz CurrentCultureIgnoreCase. Jak łatwo się domyślić biorą one pod uwagę wszelkie uwarunkowania językowe. Na przykład w języku niemieckim litera “ß” ma takie same znaczenie jak “ss”. Domyślnie poniższe porównanie zwróci false:

if (string.Equals("ß", "ss")) // zawsze false
{

}

Zapis binarny tych dwóch łańcuchów jest kompletnie różny a wiemy, że domyślna wartość to Ordinal. Jeśli jednak użyjemy CurrentCulture wtedy funkcja zwróci true:

if (string.Equals("ß", "ss",StringComparison.CurrentCulture)) // zawsze true
{

}

Czy jest zatem sens używania CurrentCulture dla Equals? Zwykle nie, bo jakby nie patrzeć są to jednak różne napisy. CultureCulture ma znaczenie jednak dla porównywania napisów w celu ich wyświetlenia użytkownikowi np. w formie posortowanej listy. Z tego względu, funkcja string.Compare przyjmuje domyślnie CurrentCulture:

Console.WriteLine(string.Compare("ß", "ss"));

Kod zwróci 0 ponieważ napisy według CurrentCulture są takie same – dla przypomnienia Compare zwraca liczbę całkowitą, ujemną, dodatnią lub zerową (0 – napisy takie same, <0 napis A przed B powinien pojawić się, >0 A po B).

Zatem podczas sortowania danych (gdzie Compare jest wykorzystywany) chcemy zachować własności leksykalne. Załóżmy, że chcemy posortować litery “z” oraz “ó”. Jeśli użyjemy porównania binarnego wtedy okaże się, że “z” powinno zostać wyświetlone przed "ó”:

// Compare zwróci wartość ujemną
Console.WriteLine(string.Compare("z", "ó",StringComparison.Ordinal)); 

Dla porównania == byłoby to w porządku bo litery są zdecydowanie różne od siebie. W celu posortowania jednak jest to niepoprawne. Jeśli użyjemy CurrentCulture wtedy dostaniemy prawidłową, dodatnią wartość:

Console.WriteLine(string.Compare("z", "ó",StringComparison.CurrentCulture));

CurrentCulture wykona porównanie takie jakby dokonał to człowiek – bazując na rzeczywistych zasadach językowych a nie binarnej, wewnętrznej reprezentacji.

Ostatnie dwa typy to InvariantCulture oraz InvariantCultureIgnoreCase. InvariantCulture to kultura niezależna od ustawień regionalnych zatem na każdej maszynie zawsze rezultat będzie taki sam. Jeśli porównujemy CurrentCulture to wynik (np. sortowania) będzie różny w zależności od ustawień regionalnych. InvariantCulture jest predefiniowaną kulturą, niezależną od ustawień dokonywanych w panelu sterowania OS.

Ktoś mógłby zapytać, jaka jest różnica między Ordinal a InvariantCulture? Obie w końcu nie nie mają wiedzy o danej kulturze  i zachowują się identycznie na wszystkich komputerach. InvariantCulture bierze pod uwagę część zasad językowych z kolei Ordinal to po prostu porównanie liczb. Jako przykład warto rozważyć następujący kod:

// zwróci wartość dodatnią
Console.WriteLine(string.Compare("ab","Az",StringComparison.Ordinal));
// zwróci wartość ujemną
Console.WriteLine(string.Compare("ab","Az", StringComparison.InvariantCulture));

Przyjrzyjmy się najpierw wartości ASCII liter ‘a’ oraz ‘A”:

1. ‘a’ – 97

2. ‘A’ – 65

Z tego względu według Ordinal ‘a’ oraz ‘A’ są kompletnie różne i porównanie tych znaków zwróciłoby 32 (odległość między znakami). Przez to porównanie ‘ab’ z ‘Az’ zwróci wartość dodatnią oznaczającą,  że wynik posortowany powinien wyglądać:

‘Az’, ‘ab’

Użytkownik jednak spodziewałby się następującej sekwencji:

‘ab’, ‘Az’

InvariantCulture zagwarantuje to ponieważ sortuje on według alfabetu zbliżonego do angielskiego czyli ‘aAbBcCdDeE…’. Z kolei Ordinal to Unicode czyli  wygląda to bardziej jak ‘ABCDE….abcde’. W praktyce  InvariantCulture jest rzadko wykorzystywany a nawet odradzany przez Microsoft.

Podsumowując warto przytoczyć kilka wskazówek sugerowanych przez MS:

  1. Używaj Ordinal\OrdinalIgnoreCase dla porównań Equal. Gwarantuje to wysoką wydajność i ma sens w zdecydowanej części przypadków.
  2. Używaj CurrentCulture\CurrentCultureIgnoreCase gdy wynik jest wyświetlany użytkownikowi.
  3. Jeśli aktualnie używasz gdzieś w kodzie InvariantCulture, zastanów się nad zastąpieniem tego Ordinal – InvariantCulture nie jest w pełni prawidłowy z punktu językowego więc lepiej użyć wydajnego Ordinal jeśli na tym nam nie zależy.
  4. Preferuj przeładowania, które akceptują parametr StringCompairson. Domyślne wywołanie jest trudne w czytaniu ponieważ domyślna wartość zależy od funkcji (Equals,Compare). Jawne dostarczenie parametru rozwiewa wszelkie wątpliwości.
  5. Unikaj InvariantCulture w większości przypadków.

W następnym poście chciałbym napisać kilka słów o SortKey oraz metodach ToUpperInvariant\ToLowerInvariant…

Code review: porównywanie string’ów

Kod:

if(anyText.ToLower()=="tekst")
{
    Console.WriteLine("Zmienne takie same");   
}

Kod ma na celu sprawdzenie czy jakaś zmienna jest równa danemu strumieniowi znaków. Nie chcemy brać pod uwagę wielkości liter więc dlatego używamy funkcji ToLower. Zatem if zwróci true gdy anyText jest równy “tekst” lub “TEKST” itp.

Rozwiązanie ma jedną wadę – tworzony jest nowy, tymczasowy string po wywołaniu metody ToLower. W powyższym przykładzie będziemy mieli zatem 3 obiekty: anyText, obiekt dla “tekst” oraz obiekt dla ToLower. Wszystkie one muszą zostać potem zebrane przez GC. Jeśli mamy system czasu rzeczywistego dobrą praktyką jest optymalizacja takich rzeczy, zwłaszcza, że jest ona bardzo łatwa i wystarczy przestrzegać pewnych zasad. Lepszym rozwiązaniem jest zatem:

if(anyText.Equals("tekst",StringComparison.OrdinalIgnoreCase))
{
 Console.WriteLine("Zmienne takie same");   
}

Rozwiązanie prawie idealne – nie tworzymy już dodatkowej zmiennej pomocniczej. Mimo to, co w przypadku gdy anyText jest równy NULL? Oczywiście całość zakończy się wyjątkiem więc ktoś mógłby napisać:

if(anyText!=null&&anyText.Equals("tekst",StringComparison.OrdinalIgnoreCase))
{
    Console.WriteLine("Zmienne takie same");   
}

Wciąż brzydkie ponieważ da to się zapisać za pomocą jednego IF’a:

if(string.Equals(anyText,"tekst",StringComparison.OrdinalIgnoreCase))
{
 Console.WriteLine("Zmienne takie same");   
}

Warto zwrócić uwagę, że StringComparison przyjmuje kilka wartości:

1. Ordinal  – domyślna wartość. Znaki zamieniane są na wartości numeryczne (np. ASCII) i wtedy porównywane. Zdecydowanie najszybsza metoda jeśli chodzi o wydajność.

2. OrdinalIgnoreCase – tak jak wyżej ale ignorowana jest wielkość liter.

3. CurrentCulture – brana jest pod uwagę dana kultura. Zatem wynik może być różny w zależności od danej konfiguracji komputera (tzn. ustawień regionalnych).

4. InvariantCulture – niebrane są pod uwagę ustawienia regionalne – zawsze wynik będzie taki sam.

5. CurrentCultureIgnoreCase, InvariantCultureIgnoreCase – analogicznie jak wyżej z tym, że wielkość liter jest ignorowana.

W następnym poście opiszę dokładniej różnice między różnymi wartościami StringComparison bo jest tego trochę…

Klasa Parallel

Klasa Parallel stanowi doskonałe dopełnienie do task’ów.  Dostarcza trzy statyczne metody:

  1. Invoke – współbieżne uruchomienie kilku zadań.
  2. For – współbieżna pętla FOR.
  3. ForEach – współbieżna pętla foreach.

Klasa stanowi helper, ponieważ wszystkie powyższe operacje można wykonać za pomocą obiektów Task lub Thread. Implementacja jednak własnej pętli współbieżnej jest zawsze trudniejsza niż użycie po prostu gotowej metody.

Zacznijmy od Invoke, przykład:

static void Main(string[] args)
{
   Parallel.Invoke(Method1, Method2, Method3);        
}
static void Method1()
{
   Console.WriteLine("Method1");
}
static void Method2()
{
   Console.WriteLine("Method2");
}
static void Method3()
{
   Console.WriteLine("Method3");
}

Invoke przyjmuje jako parametry metody Action. Następnie uruchomi zadania równoległe. Należy zwrócić uwagę na następujące szczegóły implementacyjne:

  1. Invoke zakończy się dopiero gdy wszystkie zadania wykonają się. Wywołanie zatem blokuje wykonanie aż do momentu zakończenia się wszystkich metod.
  2. Nie ma gwarancji, że każda metoda zostanie wykonana równoległe. W zależności od scheduler’a może okazać się, że cześć zostanie wykonana sekwencyjnie.
  3. Invoke jest na tyle inteligentny, że nie zabija wątku po zakończeniu zadania. Jeśli przekażemy dużą tablicę metod (np. 100 zadań do wykonania), wtedy zostanie najpierw wykonanych powiedzmy 10 metod, a następnie kolejne 10, wykorzystując już poprzednie wątki. Wszystko zależy oczywiście od aktualnego obciążenia systemu i architektury. Wystarczy wiedzieć, że wątki w Invoke mogą być ponownie używane i w pewnych sytuacjach nie wszystkie metody są uruchamiane jednocześnie.
  4. Wyjątek wyrzucony w jednym zadaniu, nie wpływa na inne. Jeśli Method1 wyrzuci wyjątek wtedy i tak Method2 oraz Method3 zostaną wykonane. Na końcu jednak (po zakończeniu wszystkich task’ow), Invoke wyrzuci zbiorowy wyjątek.

Na udowodnienie ostatniego punktu napiszmy krótki program:

static void Main(string[] args)
{
   try
   {
       Parallel.Invoke(Method1, Method2, Method3);
   }
   catch(AggregateException e)
   {
       foreach (Exception innerException in e.InnerExceptions)
       {
           Console.WriteLine(innerException.Message);   
       }            
   }
}
static void Method1()
{
   throw new NotImplementedException();
}
static void Method2()
{
   Console.WriteLine("Method2");
   Thread.Sleep(5000);
}
static void Method3()
{
   Console.WriteLine("Method3");
}

Metoda Method1 wyrzuca wyjątek. Mimo to, taski Method2 oraz Method3 zostaną wykonane – są one całkowicie niezależne. Na końcu Invoke wyrzuci wszystkie wyjątki natrafione podczas wykonywania tasków. AggregateException zawiera kolekcje InnerException. Dla powyższego przykładu, ta kolekcja zawiera wyłącznie NotImplementedException.

Zadania można również anulować tzn.:

class Program
{
    static CancellationTokenSource _cancellationToken = new CancellationTokenSource();

    static void Main(string[] args)
    {        
        try
        {
            Task.Factory.StartNew(() => _cancellationToken.Cancel());
            var parallelOptions=new ParallelOptions();
            parallelOptions.CancellationToken = _cancellationToken.Token;
            Parallel.Invoke(parallelOptions, Method1, Method2, Method3);
        }
        catch(OperationCanceledException e)
        {
            Console.WriteLine(e.Message);
        }
    }
    static void Method1()
    {
        Console.WriteLine("Method1");  
    }
    static void Method2()
    {
        Console.WriteLine("Method2");        
    }
    static void Method3()
    {
        Console.WriteLine("Method3");
    }
}

W momencie anulowania tasków za pomocą metody Cancel, zostanie wyrzucony wyjątek OperationCanceledException. Wszystkie metody, które zostały już wywołane zakończą swoje działanie normalnie. Anulowanie dotyczy wyłącznie task’ow, które jeszcze nie zostały odpalone przez scheduler.

Parallel.For jest również analogiczny  w użyciu:

internal class Program
{
    private static void Main(string[] args)
    {
        Parallel.For(0, 100, Method);
    }
    private static void Method(int i)
    {
        Console.WriteLine(i);
    }
}

Powyższy kod wykona pętle od 0 do 100 wykonując za każdym razem metodę Method. Jako parametr wejściowy Method, przekazywany jest indeks aktualnej iteracji. Podobnie jak Invoke, For używa ponownie stworzone już wątki. Prawdopodobnie utworzenie stu wątków jest dość obciążające dlatego tworzone jest mniej a następnie są one ponownie używane. Innymi słowy, zbiór jest dzielony na kilka podzbiorów metod.

Istnieje kilka przeładowań For. Można m.in. przekazać wspomniany wyżej token czy state do metody. For zachowuje się analogicznie do Invoke oraz ForEach – blokuje wykonanie aż do zakończenia wszystkich iteracji. Klasyczna pętla umożliwia przerwanie “break”. W współbieżnym odpowiedniku jest również to możliwe za pomocą wspomnianego state:

private static void Main(string[] args)
{        
   Parallel.For(0, 100, Method);
}
private static void Method(int i,ParallelLoopState state)
{
   Console.WriteLine(i);
   state.Break();
}

LoopState zawiera także metodę Stop:

private static void Main(string[] args)
{        
   Parallel.For(0, 100, Method);
}
private static void Method(int i,ParallelLoopState state)
{
   Console.WriteLine(i);
   state.Stop();
}

Jaka jest różnica? Wiemy, że w pętli współbieżnej iteracja numer 5 może zostać wykonana przed iteracją  2. Break wykonany na iteracji 2 spowoduje anulowanie wszystkich metod po 2 (jeśli oczywiście nie zostały już one wykonane). Stop z kolei na iteracji 2 spowoduje anulowanie wszystkich metod nawet iteracji numer 0 i 1 jeśli jeszcze nie zostały wykonane. Break z kolei zachowuje się jak w klasycznej pętli – anuluje iteracje o wyższym indeksie.

Powyższe przykłady pokazywały bardzo proste scenariusze. Często jednak indeks jest potrzebny aby wykonać jakieś operację. Załóżmy, że chcemy zaimplementować w sposób równoległy poniższy kod:

int sum = 0;
for(int i=0;i<1000;i++)
{
  sum += i;
}
Console.WriteLine(sum);

Pierwsza próba może wyglądać następująco:

int sum = 0;
Parallel.For(0, 1000, i => sum += i);
Console.WriteLine(sum);

Niestety powyższy kod jest nie jest thread-safe i za każdym razem będzie otrzymywać różne wyniki. Naturalnie próbujemy wykorzystać blokadę:

 static void Main(string[] args)
{
    object syncLock = new object();
    int sum = 0;
    Parallel.For(0, 1000, i =>
                            {
                                lock (syncLock)
                                {
                                    sum += i;

                                }
                            }
        );
    Console.WriteLine(sum);
}

Rozwiązanie matematycznie poprawnie, jednak zdecydowanie za wolne. Zakładanie blokady w każdej iteracji spowoduje, że szybciej byśmy to wykonalni w tradycyjnej, sekwencyjnej pętli.

Za pomocą Parallel możemy jednak to zrobić dużo lepiej. Zacznijmy od razu od kodu:

internal class Program
{
   private static int _sum;
   private static object _sync=new object();

   private static void Main(string[] args)
   {                        
       Parallel.For(0, 1000, InitTask, DoWork, FinishTask);
       Console.WriteLine(_sum);
   }
   private static int InitTask()
   {
       return 0;
   }
   private static int DoWork(int i, ParallelLoopState state, int localsum)
   {
       return localsum + i;
   }
   private static void FinishTask(int i)
   {
       lock (_sync)
       {
           _sum += i;
       }
   }
}

Przekazujemy teraz trzy metody: InitTask, DoWork oraz FinishTask. InitTask będzie wywoływany dla każdego wątku na początku (w momencie inicjalizacji) – zwraca on początkowy stan wątku. Następnie DoWork będzie wykonywany sekwencyjnie dla wszystkich iteracji przydzielonych do danego wątku. Jeśli mamy 1000 iteracji a 200 wątków wtedy DoWork zostanie wykonany 5 razy w każdym wątku. FinishTask z kolei zostanie wywołany na końcu,gdy wszystkie iteracje przydzielone do wątku zostały już wykonane. Każdy wątek ma swój stan nazwany tutaj localSum. Wiemy, że stan ma każdy wątek więc operacje na nim są thread-safe. Niebezpiecznie jest  wykonanie tylko FinishTask ponieważ operujemy tam na globalnej zmiennej _sum– tutaj musimy zablokować dostęp. Jeśli zatem mamy 1000 iteracji, 200 wątków wtedy będziemy musieli zablokować dostęp 200 razy a nie 1000.

Metoda Foreach nie powinna już budzić żadnych wątpliwości:

internal class Program
{
    private static void Main(string[] args)
    {
        List<int> numbers=new List<int>();
        numbers.Add(1);
        numbers.Add(2);
        numbers.Add(3);
        numbers.Add(4);

        Parallel.ForEach(numbers,Method);
    }
    private static void Method(int i)
    {
        Console.WriteLine(i);
    }
}

Mam nadzieję, że tym wpisem zachęciłem Was do korzystania albo przemyślenia przynajmniej czy kod nad którym pracujemy nie da się w łatwy sposób zrównoleglić.

Czym jest “Delayed signing”?

W ostatnim poście napisałem kiedy warto korzystać z strong-name. Jeśli ktoś uważnie prześledził screen’y dołączone do postu, być może dostrzegł, że jest tam opcja taka jak “Delayed Signing”. Do czego to służy?

Sprawa jest bardzo prosta. Delayed signing po prostu nie używa klucza prywatnego. Taka biblioteka nie zawiera więc poprawnego podpisu cyfrowego a w miejsce jego zawiera same zera. Klucz publiczny z kolei jest wstawiany do biblioteki z tym, że nie jest liczony jej hash. Integralność zatem jest niemożliwa (brak hash’u) jak również prawidłowa autoryzacja biblioteka (brak podpisu). Po za tym, biblioteka zachowuje się jak SN więc możliwe jest umieszczenie jej w GAC’u.

Po co nam biblioteka bez podpisu cyfrowego? W dużych organizacjach dostęp do klucza prywatnego ma wyłącznie klika osób. Programiści nie mają dostępu do niego na swoich maszynach deweloperskich. Podpisanie biblioteki odbywa się na osobnych maszynach przez osoby do tego powołane. Żaden z programistów nie może tego zrobić lokalnie na swoim komputerze – klucz prywatny musi być przechowywany w bezpiecznym miejscu. Programiści jednak muszą implementować a potem testować swoje rozwiązania. Jeśli wymagane jest użycie biblioteki SN nie ma sensu za każdym razem odsyłać ją  do osoby mającej potrzebne uprawnienia. Z tego względu programiści używają Delayed Signing w czasie implementacji a dopiero na końcu np. przed wydaniem lub fazą testowania produktu dokonywane jest podpisanie. Delayed signing zatem ma znaczenie wyłącznie dla programistów – jest to taka wersja “DEBUG” ułatwiająca prace nad kodem.

Jest jeszcze jeden mniej ważny powód dla którego Delayed signing może okazać się przydatne. Osoby korzystające z obfuscator’a nie mogą używać go na bibliotece już podpisanej. Jeśli odpalą obfuscator’a na takiej bibliotece wtedy oczywiście zostaną zmienione nazwy wszystkich zmiennych a to poskutkuje niepoprawną sumą kontrolną – po załadowaniu okaże się, że wyliczony hash jest inny niż ten zawarty w podpisie cyfrowym. W przypadku Delayed Signing można najpierw wygenerować bibliotekę SN, odpalić na niej obfuscator a na końcu ją podpisać.

Delayed signing można ustawić za pomocą zakładki signing znajdującej się we właściwościach projektu:

image

Niestety to nie wystarczy. Po uruchomieniu projektu lub próbie zainstalowania go w GAC dostaniemy następujący wyjątek:

“Could not load file or assembly ‘ClassLibrary1, Version=1.0.0.0, Culture=neutral, PublicKeyToken=93b3674e817090ba’ or one of its dependencies. Strong name validation failed. (Exception from HRESULT: 0x8013141A)”

Oczywiście jest ro związane z faktem, że hash jest pusty (same zera) i naturalnie nie pokrywa to się z rzeczywistą sumą kontrolną. Z tego względu musimy powiadomić .NET Framework aby nie dokonywał on wspomnianej walidacji:

sn –Vr ClassLibrary1.dll
Polecenie tak naprawdę doda klucz rejestru do: HKEY_LOCAL_MACHINE\SOFTWARE.

Załóżmy, że implementacja została zakończona i kolejnym krokiem jest już podpisanie biblioteki za pomocą klucza prywatnego. Wtedy należy użyć następującego polecenia:

sn -R ClassLibrary.dll sgKey.snk

Po tym biblioteka zostanie już w pełni SN – z sumą kontrolką i podpisem cyfrowym. Warto jednak na zakończenie usunąć bibliotekę z rejestru plików nie sprawdzanych pod kątem integralności:

SN –Vu ClassLibrary.dll

Należy uważać na sn  – Vr ponieważ wiąże się z tym ryzyko – w końcu podpis cyfrowy jest nie sprawdzany i ktoś mógłby podmienić bibliotekę na inną, zawierającą złośliwy kod.

Biblioteki Strong-Named

Nie wiem czy jest polski odpowiednik strong-named(SN) wiec będę używał nazwy angielskiej albo skrótu SN. Do zrozumienia tego wpisu niezbędna jest podstawowa wiedza o asymetrycznych algorytmach szyfrowania (RSA), podpisie cyfrowym , kluczu prywatnym, publicznym oraz zasadzie działania funkcji haszującej. Nie będę tego omawiał w poście, zachęcam więc do zapoznania się na własną rękę jeśli któreś z tych pojęć jest niezrozumiałe.

SN to przede wszystkim sposób na unikalne identyfikowanie bibliotek. Przed pojawieniem się SN trudno było odróżnić dwie różne biblioteki o tej samej nazwie. Kiedyś wszystkie biblioteki składowane były w jednym folderze co powodowało tzw. DLL HELL. W końcu może zdarzyć się, że dwie różne firmy wydają biblioteki o takiej samej nazwie. Inny scenariusz to biblioteki o tej samej nazwie ale o różnych wersjach. Na przykład aplikacja A może być kompatybilna  z wersją wyłącznie 1.00 a program B wyłącznie z wersją 2.00. Mowa oczywiście tutaj o bibliotekach wdrażanych globalnie czyli np. GAC.

Kolejna korzyść z SN to bezpieczeństwo. O tym z pewnością powstaną następne wpisy ale spróbuje to naszkicować dzisiaj. Przede wszystkim biblioteka strong-named jest podpisana i dzięki temu mamy pewność, że pochodzi ona od konkretnego producenta. Podpis gwarantuje integralność biblioteki – przed załadowaniem jej sprawdzana jest suma kontrolna. Jeśli ktoś ją po drodze zmodyfikować (np. hacker) wtedy będzie to wykryte podczas jej ładowania. Dzięki temu można również określić różne przywileje wykonania kodu w zależności kto jest właścicielem biblioteki.

Wiemy, już po co wprowadzano SN (łatwa identyfikacja oraz bezpieczeństwo). Jak wspomniałem ważną role w SN stanowią algorytmy szyfrowania asymetrycznego oraz funkcje haszujące. Cztery atrybuty identyfikują unikalnie bibliotekę:

  1. Nazwa biblioteki (bez rozszerzenia).
  2. Wersja
  3. Culture (np. en-US).
  4. Hash klucza publicznego. Sam klucz publiczny jest bardzo długi dlatego stosuje się po prostu jego hash.

Przykład:

MyLib, Version=1.0.8123.0, Culture=neutral, PublicKeyToken=d375d1c535425e6

Aby biblioteka była SN należy wykonać następujące kroki:

  1. Wygenerować dwa klucze: publiczny oraz prywatny. To one  w skali globalnej identyfikują biblioteki. Powinny być unikalne dla każdej organizacji\firmy.
  2. Klucz publiczny będzie umieszczony w bibliotece. W końcu użytkownik końcowy musi jakoś odszyfrować potem pakiet. W kryptografii klucz publiczny otrzymuje odbiorca a prywatny jest poufny i ma do niego dostęp tylko nadawca (w tym przypadku firma\organizacja). Po umieszczeniu klucza publicznego, biblioteka jest haszowana za pomocą algorytmu SHA-1. Następnie hash jest szyfrowany algorytmem asymetrycznym RSA za pomocą klucza prywatnego. Zaszyfrowany hash stanowi podpis biblioteki. Oczywiście jest to skrót myślowy ponieważ w rzeczywistości szyfrowane są specyficzne pliki ale aby zrozumieć sens SN wystarczy wiedzieć, że wygenerowany zostanie podpis cyfrowy czyli zaszyfrowanie hash’a biblioteki.
  3. Wygenerowany w kroku 2 podpis jest umieszczany również w bibliotece.

Następnie gdy użytkownik próbuje odczytać bibliotekę (wywołać jakąś metodę):

  1. Załadowanie klucza publicznego.
  2. Obliczenie hash’u biblioteki.
  3. Załadowanie podpisu. Odszyfrowanie go za pomocą klucza publicznego.
  4. Wynik z punktu 3 porównywany jest z hash’em obliczonym w punkcie 2. Jeśli są one takie same to znaczy, że biblioteka nie została podmieniona. Gwarantuje to integralność danych.

Na tym etapie, wiemy, już co to znaczy dostarczyć bibliotekę strong-named. Pytanie pozostaje, jak to zrobić?

Istnieje kilka sposobów. Jednym z nich jest użycie komendy SN w wierszu poleceń. Umożliwia to wygenerowanie pary kluczy a nawet wyodrębnienie z pary tylko klucza publicznego.

Osobiście nie lubię wiersza poleceń i dlatego w poście pokażę jak to zrobić z poziomu Visual Studio. Wystarczy przejść do właściwości projektu a następnie wybrać zakładkę Signing:

image

Następny zaznaczamy checkbox Sign the assembly, klikany new  i wpisujemy nasze hasło:

image

Po skompilowaniu biblioteka będzie zawierała Strong Name. Można się o tym przekonać dołączając ją do jakiegoś projektu i sprawdzając właściwość StrongName:

image

Na zakończenie jeszcze kilka słów o integralności. Załóżmy, że ktoś zmienił zawartość biblioteki, doczepiając jakiś złośliwy kod. Dzięki SN wykryjemy to podczas ładowania ponieważ w takim przypadku hash będzie inny. Jeśli ktoś zmieni klucz publiczny również wykryjemy to ponieważ hash został obliczony na bibliotece zawierającej w sobie już  klucz publiczny. Mechanizm gwarantuje nam, że dostaniemy identyczną zawartość, która została podpisana przez wydawcę. W następnym poście napiszę o Delay Singing a potem o sprawach bezpieczeństwa (konkretnie o nadawaniu pozwoleń).

Modyfikator protected internal

Dziś podstawy języka c#. Wszyscy znają modyfikatory public, protected, private i chętnie z nich korzystają. Modyfikator protected internal jest zdecydowanie mniej popularny a scenariusze użycia jeszcze rzadziej są prawidłowo identyfikowane.

Jak sama nazwa mówi protected internal składa się z dwóch poziomów dostępności. W obrębie tego samego assembly zachowuje się jak czysty internal i mamy dostęp do pola tak jakby było one public.

Załóżmy, że projekt składa się z dwóch bibliotek. W bibliotece numer A deklarujemy następującą klasę:

namespace ClassLibrary1
{
    public class Class1
    {
        protected internal int _Field;
    }
}

Jak wspomniałem, _Field w obrębie tego samego assembly będzie zachowywał się jak internal(public) zatem jest możliwe:

namespace ClassLibrary1
{
    class Class2
    {
        void Method()
        {
            Class1 sample=new Class1();
            sample._Field = 10; // OK
        }
    }
}

Co w przypadku gdy chcemy użyć pola poza danym assembly? Gdybyśmy oznaczyli _Field jako internal wtedy w innych bibliotekach dostęp byłby niemożliwy. Modyfikator protected internal umożliwia jednak dostęp w innych bibliotekach tak jakby było to pole protected – jest zatem możliwe to tylko z poziomu klasy dziedziczącej:

namespace ConsoleApplication4
{
    class OtherAssembly:Class1
    {
        void Method()
        {
            _Field = 5;// OK
        }
    }
}

Niemożliwe jednak jest korzystanie w osobnym assembly z pola _Field jak z pola public :

namespace ConsoleApplication4
{
    class OtherAssembly1
    {
        void Method()
        {
            Class1 class1=new Class1();
            class1._Field = 5;// ZLE
        }
    }
}

Podsumowując: protected internal w tej samej bibliotece zachowuje się jak internal(public) a w innej bibliotece jak protected.

Po co nam taki wymysł? W CPP były tylko 3 modyfikatory i programiści radzili sobie doskonale. Odpowiedz jest prosta – w zdecydowanej większości przypadków użycie protected internal jest sygnałem złej architektury.

Jednym z przykładów, które udało mi się znaleźć jest implementacja pewnych pluginów. Załóżmy, że mamy bibliotekę o nazwie Engine, która odpowiada za wykorzystanie z tychże pluginów. W innych bibliotekach użytkownicy mogą je implementować. Interfejs dla tego plugin’a mógłby wyglądać następująco:

abstract class IPlugin
{
    protected internal abstract void Init(...);
}

Co nam to daje? Init może być wywołany wyłącznie w bibliotece Engine – o to nam dokładnie chodzi. Chcemy móc załadować plugin w Engine ale uniemożliwić jego wywołanie po za nim. Dozwolona jest  przez użytkownika jedynie implementacja pluginu a  inicjalizacji musi dokonać Engine.

Blokada lock a bariera oraz caching

W ostatnim poście pisałem o barierze jako rozwiązaniu na uniknięcie problemów z związanych z optymalizacją dokonywaną przez CPU (re-ordering). Kilka postów wcześniej pisałem z kolei o buforowaniu danych i słowie kluczowym volatile. Jeśli nie wiedzie co to jest MemoryBarrier oraz Volatile zachęcam do przeczytania tych wpisów najpierw – bez nich dzisiejszy post będzie kompletnie niezrozumiały.

Wiemy, że caching oraz re-ordering może na niektórych architekturach spowodować trudne w analizie błędy. Dziś chciałbym wyjaśnić jak do tego ma się klasyczna blokada lock.

Jeśli chodzi o volatile, sprawa jest bardzo prosta. Blokada LOCK gwarantuje nam, że zawsze otrzymamy “świeżą” wartość. Wszelki kod w blokadzie jest bezpieczny i wymusza “flush”. Dla przypomnienia kod, który bez volatile może spowodować nieoczekiwane rezultaty:

class Program
{
   bool flag=true;
   static void Main(string[] args)
   {
       Program test=new Program();
       var thread = new Thread(() => { test.flag = false; });
       thread.Start();

       while (test.flag)
       {
           // blokada
       }
   }
}

Bez volatile, powyższy program może się nigdy nie zakończyć.  LOCK zawsze powoduje flush i poniższy program  zawsze zakończy się, pomimo, że nie używamy słowa volatile:

class Program
{
    bool flag = true;
    static object _sync=new object();

    static void Main(string[] args)
    {
        Program test = new Program();
        var thread = new Thread(() => { test.flag = false; });
        thread.Start();
      
            while (true)
            {
                lock (_sync)
                {
                    if(!test.flag)
                        break;
                }
            }

        Console.WriteLine("Udalo!");
    }
}

Należy wspomnieć jednak o jednej kwestii – lock powoduje flush wyłącznie na początku lock’a (na wejściu). Z tego względu poniższy kod jest niepoprawny i również może nigdy nie zakończyć się:

class Program
{
    bool flag = true;
    static object _sync=new object();

    static void Main(string[] args)
    {
        Program test = new Program();
        var thread = new Thread(() => { test.flag = false; });
        thread.Start();

        lock (_sync)
        {
            while (true)
            {
                if (!test.flag)
                    break;
            }
        }
        Console.WriteLine("Udalo!");
    }
}

Powyższy przykład wykonuje flush tylko raz – następnie program korzysta z buforowanej wartości test.flag. Oczywiście przykłady stanowią wyłącznie dowód, że lock wykonuje flush – w praktyce blokady są zbyt kosztowne aby je używać w przedstawiony sposób.

Kolejna sprawa do omówienia to MemoryBarrier oraz lock. Podobnie jak z cache, lock zakłada blokadę na wejście i na wyjście. Załóżmy, że mamy kod:

lock(Sync)
{
  field1 = Method1();
  field2 = Method2();
}

Równoważne jest to z następującymi barierami:

Thread.MemoryBarrier();
field1 = Method1();
field2 = Method2();
Thread.MemoryBarrier();

Jaki z tego wniosek? Blokada lock to dość potężne narzędzie, wystarczające w wielu przypadkach. Większość programistów doskonale zna słowo kluczowe lock a nie ma pojęcia o barierach. Bezpieczniej z tego względu jest użyć lock – dużo większe szanse, że zostanie to prawidłowo zinterpretowane przez następnych programistów. Jeśli piszemy algorytmy typowo wielowątkowe wtedy poszczególni programiści muszą znać bardziej zaawansowane mechanizmy synchronizacji i bariera z pewnością nie jest niczym nowym. W takiej sytuacji oczywiście należy stosować rozwiązania jak najbardziej optymalne – spinning itp. W przypadku biznesowych aplikacji, nieumiejętne zastosowanie  np. spinningu może spowodować drastyczny spadek wydajności i moim zdaniem lepiej po prostu ograniczyć się do klasycznego lock’a.