Thread.MemoryBarrier–wprowadzenie

W .NET istnieje metoda Thread.MemoryBarrier(). W użyciu jest bardzo prosta – nie wymaga przekazania żadnych parametrów.

Strona teoretyczna jest jednak bardziej skomplikowana. Przed wyjaśnieniem czym jest Memory Barrier w świecie współbieżnym musimy zrozumieć jak wykonywane są instrukcje przez procesor. Załóżmy, że mamy następujące operacje:

a = b + 1
c = a + 1
d = e + 1;

W jakiej kolejności zostaną wykonane powyższe operacje? Naturalne wydaje się, że procesor wykona je po kolei. Jednak ze względu na różne wewnętrzne optymalizacje nie zawsze jest to prawdą. Architektura CPU wprowadza pojęcie “wykonywanie poza kolejnością (out-of-order execution)”. Z tego względu najpierw może zostać wykonana operacja d = e + 1 a dopiero później a = b+1. Nie ma pewności, że w każdym przypadku a = b+1 będzie wykonane pierwsze. Oczywiście c = a + 1 musi poczekać najpierw na a = b +1. Innymi słowy możliwe są m.in. następujące przypadki:

// 2
d = e + 1;
a = b + 1
c = a + 1

// 3
a = b + 1
d = e + 1;
c = a + 1

Niemożliwe natomiast jest następująca kombinacja:

// Niemożliwe
d = e + 1;
c = a + 1
a = b + 1

Dlaczego takie dziwne rzeczy mogą się zdarzyć?  Wszystko zależy od wewnętrznej architektury ale gdy CPU uzna, że któreś jednostki wykonawcze są wolne i mogą w ten czas wykonać coś wtedy out-of-order execution może mieć miejsce.

Wiemy, co to jest wykonanie po za kolejnością. Teraz czas na dwa ważne pojęcia: acquire semantics oraz release semantics. Operacja wykonana w trybie acquire semantics  zawsze będzie widziana przed jakimikolwiek operacjami wykonywanymi po niej. Z kolei operacja wykonana w trybie release semtantics zawsze będzie wykonana po wszystkich operacjach, które znajdują się przed nią. Jeśli nie jest to jasne to zobaczmy na przykładzie inkrementacji:

 InterlockedIncrementAcquire(&a);// Acquire semtantics
 b++;
 c++;

Procesor może zmienić kolejność wykonania między b a c. Niemożliwe jednak jest wykonanie inkrementacji a po b czy c. Acquire jest jednak jednostronną barierą zatem operacje znajdujące się przed Acquire Sementaics mogą zostać wykonane po nałożeniu tej bariery:

 g++
 InterlockedIncrementAcquire(&a);// Acquire semtantics
 b++;
 c++;

Operacja g++ może zarówno zostać wykonana przed jak i po inkrementacji a++. Acquire sementaics daje jedynie pewność, że a++ zostanie wykonane przed b++ oraz c++. Analogicznie wygląda sprawa z release sementics:

 a++;
 b++;
 InterlockedIncrementRelease(&c); // Release semantics
 g++

a++ oraz b++ zawsze zostaną wykonane przed c++. Z kolei g++ może zostać wykonane w dowolnym momencie.

Znając wszystkie powyższe pojęcia definicja MemoryBarrier jest prosta. Thread.MemoryBarrier nakłada po prostu dwustronną barierę gwarantując jednocześnie zarówno Acquire Semantics jak i Release Sementaics:

a++;
b++;
Thread.MemoryBarrier();
c++;
d++;

Żadna z tych operacji nie może zostać przeniesiona powyżej\poniżej bariery. Innymi słowy mamy pewność, że a++,b++ zostaną wykonane przed c++,d++. Inny przykład to wspomniany kilka wpisów wcześniej singleton:

public sealed class Singleton
{
    private static Singleton m_Instance = null;
    private static readonly object m_Sync = new object();

    private Singleton() { }

    public static Singleton Instance
    {
        get
        {
            if(m_Instance == null )
            {
                lock(m_Sync)
                {                    
                    if(m_Instance == null)
                    {
                         var instance = new Singleton();                       
                         System.Threading.Thread.MemoryBarrier(); 
                         m_Instance = instance;
                    }
                }
            }
            return m_Instance;
        }
    }
}

Zwróćmy uwagę, że usunąłem słowo volatile. Bez powyższej bariery mogłaby zdarzyć się następująca kolejność instrukcji:

1. Stwórz instancję Singleton.

2. Przypisz instancję do m_Instance.

3. Wywołaj konstruktor Singleton, który zainicjalizuje obiekt.

Jeśli mamy pecha może okazać się, że wątek A skończył operację 2 i zaczyna wykonywać operację numer 3. Jeśli w tym samym czasie wątek B ze chce skorzystać z singleton’a pierwszy IF zostanie spełniony i wątek B będzie wywoływał operacje na singletonie, który nie jest w pełni zainicjalizowany (przypominam, że konstruktor zostanie wykonywany dopiero w instrukcji numer 3).

Na dziś wystarczy. Następnym razem, krótki post o lock oraz jak on się ma do re-ordering’u oraz cache.