Zagrożenia w wielowątkowości: Two-Step Dance

Dzisiaj o kolejnym, mało znanym, ale bardzo powszechnym zagrożeniu w środowisku współbieżnym. Zjawisko opisane w tym poście jest ściśle powiązane z lock convoy i stampede, które opisywałem w zeszłym tygodniu.

Problem jest bardzo prosty. Budzimy wątek za pomocą Pulse\Set, ale wątek i tak nic sensowego nie może zrobić, ponieważ dany zasób jest wciąż zablokowany, co skutkuje, że taki wątek znów zostanie uśpiony. Zarys:

void T1()
{
    lock (Sync)
    {
        blocker.Set();
    }
}
void T2()
{
    blocker.WaitOne();
    
    lock (Sync)
    {
    }
}

Zmienna blocker to jakakolwiek klasa implementująca WaitHandle. Skutek będzie taki, że T1 wyśle sygnał, T2 zostanie obudzony ale wtedy będzie chciał wejść do sekcji krytycznej, co poskutkuje, że znów zostanie uśpiony. Następnie T1 zwolni blokadę i T2 może zostać ponownie obudzony. Co prawda, to nie jest zagrożenie z pokroju deadlock, ponieważ aplikacja będzie działać. W świecie wielowątkowym, chcemy jednak aby wszystko działało optymalnie. Z tego względu, nie możemy sobie pozwolić na niepotrzebne zmiany kontekstu, spowodowane przez wybudzenie i uśpienie po chwili jakiegoś wątku.

Z tego co wyczytałem, na jedno rdzeniowych procesorach to jest jeszcze gorzej. W momencie gdy wysyłamy sygnał za pomocą Set, Windows zwiększy priorytet T2 tak, że T2 na pewno wywłaszczy T1. Proszę zauważyć, że nie zawsze mamy wpływ na ten problem. Domyślna implementacja condition variables w CLR, spowoduje two-step dance:

public void Thread1()
{
  Monitor.Enter(_lock);

  while (!_condition)
  {
      Monitor.Wait(_lock);
  }
  _condition = false;
  Monitor.Exit(_lock);
}

public void Thread2()
{
  Monitor.Enter(_lock);

  _condition = true;

  Monitor.Pulse(_lock);

  Monitor.Exit(_lock);
}

Niestety, aby korzystać z Pulse, musimy być w sekcji krytycznej – taka jest po prostu implementacja CLR. Nie mamy na to wpływu. Mimo wszystko, powinniśmy analizować nasz kod na bieżącą i wyłapywać niepotrzebne zmiany kontekstu, spowodowane przez two-step dance.

Szablony T4 – wprowadzenie

O T4 już kiedyś wspomniałem, przy okazji Entity Framework. W następnych kilku postach, rozwinę jednak temat. Szablony T4 przydatne są nie tylko osobom implementującym biblioteki programistyczne, ale mają miejsce zastosowania w każdym typie aplikacji.

T4 oznacza Text Template Transformation Toolkit i jest to po prostu szablon do generowania plików tekstowych. Bardzo często stosuje się T4 do wygenerowania klas – np. w Entity Framework. Dlatego też T4 jest tak często wykorzystywany w różnych frameworkach. Generowanie klas to tylko jeden z możliwych scenariuszy. W aplikacji biznesowej, T4 mógłby przedstawiać szablon e-mail’a, który wysyłamy do klientów. Każdy email wygląda tak samo, tzn. ma nagłówek, stopkę itp. a jedyną różnicą jest np. imię i nazwisko. W ten sposób możemy stworzyć szablon, posiadający wszystkie części wspólne dla emaili, a w miejsca, które różnią się, umieszczamy specjalną dyrektywę  – dzięki temu dynamicznie będziemy w stanie wstawić zawartość.

Podsumowując, T4 to szablon, posiadający tekst oraz pewne zmienne. Następnie, użytkownik przekazuje wartości tych zmiennych, a silnik T4 wygeneruje końcowy plik tekstowy, wstawiając podane wartości zmiennych do szablonu.

Wyróżniamy dwa typy szablonów. Pierwszy z nich jest generowany na etapie projektowania aplikacji (design time), czyli np. przed kompilacją. Przydatny jest dla framework’ow lub innych narzędzi programistycznych, ponieważ wygenerowany tekst, może być następnie wykorzystany  w aplikacji. Jeśli dany szablon T4 generuje klasy C# (np. encję), to mogą one potem być wykorzystane w naszym projekcie.

Drugi typ szablonów to oczywiście te generowane podczas działania już aplikacji. Przydatne są w aplikacjach biznesowych, gdzie np. chcemy wysłać wspomniany e-mail. Możemy np. przekazać dane z formularza do T4 i wygenerować potem jakiś plik tekstowy.

Od Visual Studio 2010, T4 jest standardowo dodany do VS i nie musimy nic instalować. Wystarczy przejść do Add->New Item:

image

Do dyspozycji mamy dwa typy szablonów. Najpierw stwórzmy po prostu “Text Template”, który jest generowany przez Visual Studio. Nowy plik będzie zawierał kilka domyślnych dyrektyw:

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".txt" #>

Pierwsza dyrektywa importuje bibliotekę. Jeśli chcielibyśmy korzystać z jakiś innych klas, niż te w System.Core, będziemy musieli zaimportować w podobny sposób nasz dll. Następne dyrektywy są tym samym, czym jest słowo kluczowe using w C# – po prostu będziemy mogli korzystać bezpośrednio z klas znajdujących się w System.Linq czy System.Text, bez poprzedzania ich namespace.

Ostatnia dyrektywa, określa rozszerzenie wygenerowanego pliku – w tym przypadku jest to .txt. W solution explorer, zobaczymy pod szablonem, wygenerowany plik:

image

Stwórzmy pierwszy szablon, wyświetlający po prostu tekst:

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".txt" #>

Hello World

Nic ciekawego, ale na wyjściu pojawi się po prostu tekst. Oczywiście, nie ma sensu tworzyć szablonu, który nie ma żadnych elementów dynamicznych. Jeśli tworzymy szablon to prawdopodobnie chcemy przekazać jakieś  zmienne lub wygenerować treść w pętli. W każdym T4, możemy korzystać z czystego kodu c# tzn. klas, pętli itp.

Przyjrzymy się teraz Runtime Template. Domyślnie wygenerowany szablon, na początku wygląda bardzo podobnie:

<#@ template language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>

Dodajmy jakiś tekst, tak jak w poprzednim przykładzie. Wygenerowany plik, nie jest jednak plikiem tekstowym jak w poprzednim przykładzie, a zawsze klasą c#:

image

Wygenerowana klasa wygląda następująco:

public partial class RuntimeTextTemplate1 : RuntimeTextTemplate1Base
{
#line hidden
   /// <summary>
   /// Create the template output
   /// </summary>
   public virtual string TransformText()
   {
       this.Write("\r\nHello World!");
       return this.GenerationEnvironment.ToString();
   }
}

W naszym przypadku, wyświetla ona tylko tekst. Możemy ją wykorzystać potem w naszej aplikacji:

private static void Main(string[] args)
{
  var template = new RuntimeTextTemplate1();
  Console.WriteLine(template.TransformText());
}

W kolejnych postach zajmiemy się blokami, które możemy umieścić w T4. Po tym poście powinny być jednak zrozumiałe scenariusze, w których możemy zastosować T4.

Zagrożenia w programowaniu współbieżnym: lock convoy oraz stampede

O zakleszczeniu czy zagłodzeniu, każdy programista słyszał, nawet nie mając styczności z programowaniem wielowątkowym. Opisany w poprzednim poście przykład livelock jest bardziej wyrafinowanym problemem.

Dzisiaj z kolei o kilku innych błędach popełnianych w środowisku wielowątkowym.

Lock Convoy  – występuje, gdy mamy za dużo wątków próbujących założyć blokadę. Załóżmy, że napisaliśmy następującą funkcję:

private void DoAsync()
{
    lock(_sync)
    {
        // jakis kod
        Thread.Sleep(1000);
    }
    // jakis kod 
}

Wykonanie jej zajmie oczywiście więcej niż jedna sekunda. Co gorsze, mamy tam sekcję krytyczną, która zawsze będzie wykonywana sekwencyjnie. Jeśli nowe wątki będą wywoływać DoAsync z wyższą częstotliwością niż może nastąpić zwolnienie blokady, wtedy do czynienia będziemy mieli z lock convoy. Lock convoy to po prostu akumulacja wątków, które blokują zasoby, ale nie mogą wykonać kodu, ponieważ uzyskanie dostępu do sekcji krytycznej jest niemożliwe.

Lock Convoy może zrujnować całkowicie system. Jeśli występuje, po jakimś czasie po prostu zabraknie zasobów. Dlatego ważne jest monitorowanie (np. za pomocą performance counters) liczby wątków tworzonych w danym systemie.

Inną ciekawostką jest, że kiedyś systemy Windows, planowały wykonywanie wątków, w takiej samej kolejności jakie one chciały wejść w sekcję krytyczną. Jeśli zatem T1 wykonuje sekcję krytyczną, a T2 oraz T3 kolejno chciały do niej wejść, wtedy OS gwarantował, że T2 najpierw uzyska dostęp, a potem dopiero T3. Jaki to ma związek z Lock Convoy? Proszę zauważyć, że pomiędzy zwolnieniem locka a przyznaniem go uśpionemu wątkowi, wykonanych musi być wiele cyklów procesora. Z tego względu, jeśli mamy opisaną powyżej sytuację (T1 wychodzi z sekcji krytycznej a T2,T3 są uśpione), dużo lepiej nadać dostęp wątkowi, który jest już wykonywany. Załóżmy, że T4 nie wymaga zmiany kontekstu i chce akurat wejść do sekcji krytycznej trzymanej przez T1 – dużo szybciej będzie mu nadać dostęp niż zmieniać kontekst dla T2. Jaka jest wada jednak takiego rozwiązania? Oczywiście może dojść do zagłodzenia T2 i T3.

Kolejnym zagrożeniem to stampede, występującym również, gdy korzystamy np. z metody Monitor.PulseAll. Przykład:

class ConditionPatternSample
{
   private readonly Object _lock = new Object();
   private Boolean _condition = false;

   public void Thread1()
   {
       Monitor.Enter(_lock);

       while (!_condition)
       {
           Monitor.Wait(_lock);
       }
       _condition = false;
       Monitor.Exit(_lock);
   }

   public void Thread2()
   {
       Monitor.Enter(_lock);

       _condition = true;

       Monitor.PulseAll(_lock);

       Monitor.Exit(_lock);
   }
}

Powyższy kod, prezentuje sprawdzanie flagi w środowisku wielowątkowym. Używamy tutaj PulseAll, który wybudza wszystkie wątki. Jaki jest tego skutek? Jeśli mamy 100 wątków czekających w metodzie Thread1, zostaną one wszystkie wybudzone. A co potem? Jak widać, są one w sekcji krytycznej i znów 99 będzie musiało zostać uśpionych. Skutek tego jest dość katastrofalny – mnóstwo zmian kontekstu, która jest bardzo kosztowna.

Klasa Monitor ma dwie kolejki, wątków gotowych do uzyskania blokady oraz czekających na Pulse. W momencie, gdy wykonujemy metodę Monitor.Enter, przeniesiony on jest do kolejki “ready”. W przypadku wykonania Monitor.Wait jest on przeniesiony do “waiting queue”. Z kolei Pulse przenosi tylko pierwszy wątek z waiting queue  z powrotem do ready queue. PulseAll  za to przeniesie wszystkie wątki, nie tylko ten pierwszy. Różnica jest taka, że wątki mogą uzyskać dostęp do sekcji, wyłącznie gdy są w ready queue.

Ktoś może słusznie zadać pytanie. Po co wywoływać PulseAll, skoro zarówno Pulse jak i PulseAll muszą być umieszczone pomiędzy Monitor.Enter a Monitor.Exit? W końcu zawsze tylko jeden wątek będzie mógł wykonywać daną operację. Czy nie będzie to w praktyce to samo? Pulse budzi jeden wątek i jest on wykonywany. PulseAll budzi wszystkie, ale tylko jeden naturalnie będzie wykonany. Nie lepiej nie używać po prostu PulseAll i tym sposobem unikać zawsze stapmede? Wszystko zależy od konkretnego scenariusza. Jeśli wszystkie wątki sprawdzają ten sam warunek (jak wyżej), nie ma sensu korzystać z PulseAll, ponieważ kod może zostać wykonany przez jakikolwiek wątek. Inna sprawa, gdy każdy wątek czeka na inny warunek. Wtedy jeśli będziemy budzić wątek po wątku, wykonamy masę niepotrzebnych zmian kontekstu. Wybudzimy np. T1, który sprawdzi warunek i znów uśpi się. PulseAll z kolei wybudzi wszystkie i każdy z nich sprawdzi czy spełnia dany warunek. PulseAll gwarantuje nam, że wszystkie wątki sprawdzą czy spełniają dany warunek. Gdybyśmy wywołali Pulse jeden wątek sprawdziłby warunek i w przypadku jego niespełnienia zostałby przeniesiony znów do waiting queue, co spowoduje, że wątki, które mogłyby coś wykonać, nie zostaną odpalone.

Podsumowując: PulseAll może prowadzić do stampedee. Czasami po prostu jest to wymagane, aby poprawnie zaimplementować algorytm  – przykład sprawdzania różnych warunków w środowisku równoległym. Monitor.Pulse z kolei może pogorszyć lock convoy. Wyobraźmy sobie, że budzimy wątek, on sprawdza warunek i znów musimy go uśpić – będziemy potrzebować więcej zmian kontekstu. Problem z Monitor.Pulse i  lock convoy to tam sama sytuacja jak z opisanym na początku szeregowaniem wątku – czasami lepiej dać dostęp do sekcji krytycznej wątkowi, który nie potrzebuje zmiany kontekstu i jest gotowy uzyskać dostęp od razu.

Livelock: przykład

Deadlock jest zdecydowanie najbardziej znanym problemem występującym w świecie wielowątkowym. Nieco gorzej znanym jest livelock. Pisałem już kiedyś o nim tutaj.

Nie pokazałem jednak przykładu i wielu osobom ciężko wyobrazić sobie livelock w praktyce. Występuje on jednak bardzo często i nie jest to coś, co powinno być rozważane wyłącznie teoretycznie. Bardzo często, najbardziej zoptymalizowane algorytmy cierpią właśnie na livelock.

Jak pisałem już kiedyś, livelock najłatwiej wyjaśnić na przykładzie dwóch osób na korytarzu, które próbują się minąć. Bardzo często wybierają ten sam kierunek ucieczki, co kończy się blokadą. W przeciwieństwie do deadlock, zmieniają oni jednak swój “stan”. Deadlock to coś statycznego, gdzie stan wątków pozostaje taki sam. W Livelock zmienia on się, jednak wciąż uniemożliwia wykonanie całego kodu. Inną wspólną cechą przykładu z dwoma osobami na korytarzu i livelock jest fakt, że kiedyś one się w końcu miną. Livelock bardzo często ma charakter tymczasowy – po jakimś czasie wątki tak zmienią swój stan, że możliwe będzie ich wykonanie. Oczywiście w przypadkach pesymistycznych nigdy to nie będzie miało miejsca, ale w przeciwieństwie do deadlock, jest to jak najbardziej możliwe.

Pora na przykład… Jak wspomniałem, livelock głównie ma miejsce gdy staramy się pisać optymalny kod, bez użycia czasochłonnych blokad. Wyobraźmy sobie, że chcemy zaimplementować funkcję zwiększającą liczbę o jeden:

internal class Program
{
   private static int Value = 0;
   private const int N = 1000;

   private static  void Main(string[] args)
   {
       Task t1 = Task.Factory.StartNew(()=>Increment(N));
       Task t2 = Task.Factory.StartNew(() => Increment(N));
       Task t3 = Task.Factory.StartNew(() => Increment(N));
       Task t4 = Task.Factory.StartNew(() => Increment(N));
       Task t5 = Task.Factory.StartNew(() => Increment(N));
       Task t6 = Task.Factory.StartNew(() => Increment(N));

       Task.WaitAll(t1, t2, t3, t4,t5,t6);

       Console.WriteLine(Value);
   }

   private static void Increment(int n)
   {
       for (int i = 0; i < n;i++)
       {
           Increment();
           Thread.Sleep(1);
       }
   }
   private static void Increment()
   {
       int original = Value;

       while (Interlocked.CompareExchange(ref Value, original + 1, original) != original)
       {

           original = Value;
       }
   }
}

Wiem, że można użyć Interlocked.Increment ale nie o to w przykładzie chodzi. Jakiś czas temu temu, również pokazywałem jak zaimplementować stos, bez użycia blokad:

public class LockFreeStack<T>
{
    private volatile Node m_head;

    private class Node { public Node Next; public T Value; }

    public void Push(T item)
    {
        var spin = new SpinWait();
        Node node = new Node { Value = item }, head;
        while (true)
        {
            head = m_head;
            node.Next = head;
            if (Interlocked.CompareExchange(ref m_head, node, head) == head) break;
            spin.SpinOnce();
        }
    }
    public bool TryPop(out T result)
    {
        result = default(T);
        var spin = new SpinWait();

        Node head;
        while (true)
        {
            head = m_head;
            if (head == null) return false;
            if (Interlocked.CompareExchange(ref m_head, head.Next, head) == head)
            {
                result = head.Value;
                return true;
            }
            spin.SpinOnce();
        }
    }
}

Jeśli ktoś nie rozumie konstrukcji z CompareExchange to proszę wrócić do wspomnianych wpisów i przenalizować jeszcze raz kod. W skrócie jest to synchronizacja optymistyczna – liczymy na to, że żaden inny wątek w międzyczasie nie zmodyfikował nam stanu. Jeśli to zrobił, musimy zaczynać od nowa – stąd pętla while. Przyjrzyjmy się inkrementacji:

private static void Increment()
{
  int original = Value;

  while (Interlocked.CompareExchange(ref Value, original + 1, original) != original)
  {

      original = Value;
  }
}

Może się zdarzyć, że wątek T1 nigdy nie zdoła zwiększyć liczby, ponieważ zawsze pozostałe wątki będą go ubiegały i stąd T1 będzie musiał zaczynać od nowa. Mam na myśli sytuację, w której T1 ustawia original=Value, ale potem CompareExchange wykrywa, że zmienna została zmieniona i pętla zaczyna się od nowa. W praktyce nie zachodzi to zbyt często i stąd, statycznie algorytmy bez użycia locków są dużo szybsze.

Thread.Sleep vs Task.Delay

Kiedyś tworzenie klasy Thread było najpopularniejszym sposobem na współbieżne wykonanie jakiegoś kodu. Niestety bardzo często było to wykorzystywane w złym kontekście – np. operacje niezbyt długie były wykonywane na wątku z klasy Thread, zamiast na tych z puli. Na szczęście dzisiaj klasa Task jest dużo bardziej popularniejsza i przełączanie się pomiędzy wątkami z puli a tworzeniem nowych jest dość łatwe.

Złą stroną jest fakt, że Task.Delay czasami jest mylony z funkcją Sleep. Porównajmy dwa fragmentu kodu:

private static void Test()
{
  Console.WriteLine("Before Sleep");
  Thread.Sleep(5000);
  Console.WriteLine("After Sleep");

  Console.WriteLine("Before Delay");
  Task.Delay(5000);
  Console.WriteLine("After Delay");
}

Czy powyższe przykłady mają taki sam efekt końcowy? Zdecydowanie nie… Sleep jak dobrze wiemy, po prostu usypia aktualny wątek, co spowoduje, że 5 sekund minie pomiędzy wyświetleniem tekstu “Before Sleep” a “After Sleep”. W przypadku Delay, zajrzyjmy najpierw do dokumentacji:

public static Task Delay(
    int millisecondsDelay
)

Funkcja zwraca nowy wątek. Oznacza to, że nie blokuje ona wykonywania i z linii “Before delay”, przejdziemy natychmiast do “After delay”. Task.Delay zachowuje się jak timer. Tworzy wątek, który będzie wykonywał się przez określony czas. Jeśli chcemy, aby efekt końcowy wyglądał podobnie do Thread.Sleep, należy:

Console.WriteLine("Before Delay");
await Task.Delay(5000);
Console.WriteLine("After Delay")

Zaglądając do Reflector’a, faktycznie dowiemy się, że Delay jest oparty na timer:

public static Task Delay(int millisecondsDelay, CancellationToken cancellationToken)
{
    if (millisecondsDelay < -1)
    {
        throw new ArgumentOutOfRangeException("millisecondsDelay", Environment.GetResourceString("Task_Delay_InvalidMillisecondsDelay"));
    }
    if (cancellationToken.IsCancellationRequested)
    {
        return FromCancellation(cancellationToken);
    }
    if (millisecondsDelay == 0)
    {
        return CompletedTask;
    }
    DelayPromise state = new DelayPromise(cancellationToken);
    if (cancellationToken.CanBeCanceled)
    {
        state.Registration = cancellationToken.InternalRegisterWithoutEC(delegate (object state) {
            ((DelayPromise) state).Complete();
        }, state);
    }
    if (millisecondsDelay != -1)
    {
        state.Timer = new Timer(delegate (object state) {
            ((DelayPromise) state).Complete();
        }, state, millisecondsDelay, -1);
        state.Timer.KeepRootedWhileScheduled();
    }
    return state;
}

Jak widać, kod odpala timer, a następnie w nim ustawia rezultat dla wątku (tutaj DelayPromise). Fizycznie nie jest tworzony dodatkowy wątek (można to sprawdzić w Visual Studio za pomocą debugger’a). Korzystamy ze zwykłego timer’a (który jest funkcją systemu operacyjnego), a następnie zwracamy wynik opakowany po prostu w Task.

Task.Delay jest przydatny tak samo jak Sleep – czyli rzadko, głównie dla szybkich testów. Jeśli mamy podejrzenia, że jakiś bug przytrafia się tylko w określonych sytuacjach, często staramy się skorzystać  z Thread.Sleep, aby zasymulować różne operacje.

Podobnie jest z Task.Delay, z tym, że zwykle jest wykorzystywany w połączeniu z metodami async,await. Rozważmy jakąś metodę async:

private static async Task Test()
{
  // await - jakas dluga operacja
  Thread.Sleep(5000);
  // await - jakas inna dluga operacja
}

Thread.Sleep po prostu zablokuje wątek na ileś sekund. Jeśli chcemy mieć zachowanie asynchroniczne, powinniśmy skorzystać z Delay:

private static async Task Test()
{
  // await - jakas dluga operacja
  await Task.Delay(5000);
  // await - jakas inna dluga operacja
}

Zwykle, jeśli korzystamy z API opartego na async/await, wtedy wywołujemy Task.Delay, w przeciwnym wypadku Thread.Sleep. Inną ciekawostką o Delay jest możliwość anulowania zadania:

public static Task Delay(
    TimeSpan delay,
    CancellationToken cancellationToken
)

Kilka ciekawostek z przeładowywania metod, część II

Po krótkiej przerwie, wracam do blogowania. Ostatnio, pokazałem jak C# traktuje przeładowywanie metod. Nie zawsze jest to proste i czasami naprawdę może spowodować błędy w interpretacji. Jeszcze raz chcę powtórzyć, że celem wpisów NIE jest zachęcenie czytelników do pisania skomplikowanych przeładowań. Wręcz odwrotnie – wiedząc jak trudno odgadnąć wynik należy po prostu unikać przedstawionych konstrukcji.

Dla przypomnienia, ostatnio zatrzymaliśmy się na override:

internal class Program
{
   private static void Main(string[] args)
   {
       Child child = new Child();
       child.Display((int) 5);
   }
}

internal class Parent
{
   public virtual void Display(int arg)
   {
       Console.WriteLine("int");
   }
}

internal class Child : Parent
{
   public override void Display(int arg)
   {
       Console.WriteLine("override int");
   }

   public void Display(double arg)
   {
       Console.WriteLine("double");
   }
}

Jako zostało to już wyjaśnione, C# w pierwszej kolejności ignoruje metody override i przejdzie do Display(double args) – co może wydawać się dziwne.

Zmodyfikujmy nieco przykład:

internal class Parent
{
   public void Display(int arg)
   {
       Console.WriteLine("int");
   }
}

internal class Child : Parent
{
   public new void Display(int arg)
   {
       Console.WriteLine("method hidding int");
   }

   public void Display(double arg)
   {
       Console.WriteLine("double");
   }
}

Używamy teraz method-hiding a nie polimorfizmu. Jaki to ma pływ na przeładowywanie metod? Taki, że zostanie wywołana metoda Display(int) – ponieważ jest ona najbardziej podobna do wywołania oraz nie zawiera override w sygnaturze. Przykład pokazuje, jaka jest różnica w interpretacji przeładowań dla wirtualnych i przykrytych metod.

Override nie jest całkowicie ignorowany. Jeśli nie ma nigdzie nadającej się do użycia sygnatury, kompilator oczywiście wróci i wywoła metodę override. Podobnie w przypadku, gdy metody bez override, nie mają wystarczająco dobrej (tzn. dopasowanej) sygnatury:

internal class Program
{
   private static void Main(string[] args)
   {
       Child child = new Child();
       child.Display((int) 5);
   }
}

internal class Parent
{
   public virtual void Display(int arg)
   {
       Console.WriteLine("int");
   }

   public void Display(long arg)
   {
       Console.WriteLine("arg");
   }
}

internal class Child : Parent
{
   public override void Display(int arg)
   {
       Console.WriteLine("override int");
   }
}

Zostanie tutaj wywołana metoda Display(int arg) – ta przeciążona. Jeśli nie istnieje lepsza, nieprzeciążona sygnatura to ta z override zostanie użyta.

Kolejny przykład:

internal class Program
{
   private static void Main(string[] args)
   {
       Child child = new Child();
       child.Display((Int16) 5);
   }
}

internal class Parent
{
   public virtual void Display(long arg)
   {
       Console.WriteLine("virtual int");
   }

   public void Display(int arg)
   {
       Console.WriteLine("int");
   }
}

internal class Child : Parent
{
   public override void Display(long arg)
   {
       Console.WriteLine("override int");
   }
}

Tutaj zostanie wywołana Display(int arg), ponieważ jest to najlepsza sygnatura i nie ma potrzeby “cofania się” do override.

Mieszanie przeładowania metod z dziedziczeniem, jak widać, nie jest najlepszym pomysłem.

Na zakończenie pewna uwaga o domyślnych wartościach:

private static void Main(string[] args)
{
  Display(1);
}

private static void Display(int a, string b=null)
{
  
}
private static void Display(int a)
{

}

Zostanie wywołana metoda Display(int a), ponieważ nie ma ona domyślnych wartości dla parametrów i spełnia wymagania. Lepsze dopasowanie jest takie, które nie potrzebuje korzystać z dobrodziejstw domyślnych wartości. Inna sytuacja to:

internal class Program
{
   private static void Main(string[] args)
   {
       Display(1);
   }

   private static void Display(int a, string b=null)
   {
       
   }
   private static void Display(int a,int b=5)
   {

   }
}

Kod nie skompiluje się, ponieważ nie wiadomo, która metoda powinna zostać wywołana. Powyższe dwa przykłady wydają się sensowne i logiczne, ale mimo wszystko mogą spowodować poważne problemy w wersjonowaniu kodu.