Synchronizacja za pomocą SpinLock

W .NET istnieje wiele sposobów synchronizacji pracy wątków. O dużej części z nich pisałem już na blogu (z ciekawszych np. klasa Barrier ). Najpopularniejszym i najłatwiejszym sposobem jest użycie słowa kluczowego lock. W wielu przypadkach jest to najlepszy i najbezpieczniejszy wybór. SpinLock to zupełnie inne podejście.

W przypadku lock, wątek jest usypiany i budzony gdy przyjdzie na niego kolej. Ma to kilka poważnych wad. Wiążą się one z szeregowaniem oraz zmianą kontekstu. Zmiana kontekstu jest dość czasochłonna ponieważ należy zapisać stan CPU (rejestry itp.) Usypianie więc wątku jest dość skomplikowane ponieważ należy dokonać pewnego rodzaju serializacji.

SpinLock działa zupełnie inaczej – wątek nigdy nie jest usypiany. Po prostu wątek działa i odpytuje czy może już uzyskać dostęp. Jeśli tak to od razu wykonuje operacje bez zbędnego szeregowania czy zmiany kontekstu. SpinLock to bardzo prosty algorytm, wykonujący pętle i sprawdzający jakąś flagę. W pseudokodzie można to zapisać następująco:


while (IsLockAlreadyTaken)
{
    // do nothing        
}

 

Rozważmy więc klasyczny problem zwiększania pewnej współdzielonej zmiennej:

internal class Program
{
private static int _counter;
private static SpinLock _spinLock=new SpinLock();

private static void Main(string[] args)
{
  for (int i = 0; i < 10000; i++)
  {
      var task = new Task(IncreaseValue);
      task.Start();
  }
  Thread.Sleep(7000);
  Console.WriteLine(_counter);
}
private static void IncreaseValue()
{
  bool lockTaken = false;

  try
  {
      _spinLock.Enter(ref lockTaken);
      _counter++;
  }
  finally
  {          
      if (lockTaken)
          _spinLock.Exit();
  }
 }
}

Korzystanie z SpinLock jest łatwe, wystarczy wywołać metodę Enter a potem Exit.  Należy jednak zwrócić uwagę na kilka niesłychanie ważnych szczegółów:

  1. SpinLock to struktura a nie klasa! Z tego względu jeśli chcemy przekazać jako parametr musimy zrobić to przez referencję bo inaczej będą to całkowicie niezależne Spin’y.
  2. Przed wywołaniem metody SpinLock.Enter flaga musi być ustawiona na false.
  3. Spinlock nie jest “reentrant” czyli nie można wywołać metody Enter dwa razy. W przypadku gdy jest ustawiona właściwość  IsThreadOwnerTrackingEnabled zostanie wyrzucony wyjątek a w przeciwnym wypadku spowoduje to zakleszczenie.

Jakie są wady SpinLock? Największa to fakt, że wątek wciąż zużywa zasoby,wykonuje cykle. Dla operacji krótkotrwałych jest to lepsze niż zmiana kontekstu. W przypadku długich operacji zasoby systemowe są niepotrzebnie zużywane. Należy pamiętać, aby korzystać tylko z SpinLock dla prostych operacji, których wykonanie zajmie kilka cykli CPU – wtedy lepiej poczekać niż zmieniać kontekst. Jeszcze gorszy scenariusz, co w przypadku gdy czekamy na wątek, który został uśpiony ponieważ CPU miał coś ważniejszego do wykonania (interruption)? Im dłuższa operacja tym większe ryzyko. SpinLock jest odradzany dla procesorów jednordzeniowych gdzie trzymanie wątków nic nie robiących jest dużo bardziej kosztowne.

10 thoughts on “Synchronizacja za pomocą SpinLock”

  1. Hej

    Przykład jest mało trafiony gdyż do implementacji inkrementacji powinno się używać Interlocked :), co więcej twój kod na niektórych architekturach może nie wykonać się poprawnie jako że zakładasz że spinLock.Enter sam założy barierę na tą sekcje w kodzie ( muszę przyznać że nie jestem pewien czy dzieje się tak zawsze i w każdych warunkach 🙁 ).

    Dużo lepsze użycie spin waita to producent konsument: mianowicie wątek czeka na operacje używając spin wait w momencie gdy wyczerpie swoją kolejkę następnie idzie spać oczekując na wybudzenie, można by wtedy pokusić się o porównanie spin waita i np thread sleep 🙂 pomyśl o tym ;).

  2. Gdzie napisalem ze inkrementacja to dobry przyklad dla SpinLock’a?
    Kod mial pokazac SpinLock.Enter a wymyslanie jakiegos realnego przykladu skomplikowal byl calosc kodu a nie o to przeciez chodzi.
    Jaka architekture masz na mysli? Kod powinien dzialac na kazdej archtiekturze. Napisz wiecej info o tym.
    Pozdrawiam

  3. Potencjalnie istnieje ryzyko że counter nigdy nie zmieni swojej wartości.

    Dzieje się tak dlatego że atomowa zmienna counter jest współdzielona przez dwa wątki z których jeden ją inkrementuje (czyta i pisze), a kolejny ustawia i wyświetla (pisze i czyta i pisze), więc z punktu widzenia architektury procesora istnieje możliwość że wątek inkrementujący doda ją do Cache więc zapis do zmiennej będzie brudny. W niektórych modelach pamięci procesor może dowolnie przenosić instrukcje jeśli tylko model pamięci jest w jakiś sposób mocniejszy niż w .NET z moich testów wynika że najbardziej podatna na takie “optymalizacje” jest architektura IA64, więc możliwym jest że zmienna counter zostanie skopiowana lokalnie, więc będzie tylko inkrementowana lokalnie.

    Najlepiej to przetestować w “Release” oraz z odłączonym debuggerem (!)

  4. Witam
    O podobnym scenariuszu juz pisalem tutaj:
    http://www.pzielinski.com/?p=1095
    Rozwiazaniem jest uzycie slowa volatile.
    Jednak co do przykladu z posta, imho nie powinien wystapic takowy problem tak samo jak nigdy on nie wystepuje z polaczeniu z lock. A to dlatego ze uzywamy SpinLock oraz wystepuje operacja zapisu ktora wymusza flush.

  5. Hej

    Tak volatile jest rozwiązaniem ale w zamian za brak optymalizacji, natomiast lepiej jest użyć Thread.MemoryBarier zamiast volatile. Z Lock nigdy nie występuje gdyż monitor wewnętrznie zakłada “pełną” dwu stronną barierę.

    Natomiast SpinLock będąc kompletnie inną strukturą synchronizacyjną która nie koniecznie natywnie ( implementuje barierę ale empirycznie można stwierdzić że będzie w złym miejscu e.g while ( lockTaken ) { //mem barier here } ) implementuje barierę nie musi zawsze działać ale tak jak powiedziałem nie jestem do końca pewien, postaram się poszukać dowodu i prześlę mailem jeśli cię to interesuje.

  6. Witam
    Prosilbym o podeslanie mailem.
    Ale czy zapis nie wymusza flush’a?
    A dwa nie jestem przekonany czy wywolanie metody Enter nie zabezpieczena tego jak lock…
    Bardzo ciekawa uwage anyway. Dzieki.

  7. W C# model pamięci wymusza Flush po każdym zapisie ale potwierdzonym też jest że na architekturze IA64 tak się nie dzieje więc pisanie nie jest volatile, co więcej instrukcja volatile nie działa w taki sam sposób jak na X86/64 więc instrukcje *mogą* być zamieniane dowolnie.

    PS. reszte wyślę mailem

  8. I wiadomo cos wiecej na temat architektury i konsekwencji? Imho zawsze to bedzie dzialac….

  9. Niestety nie miałem czasu na znalezienie odpowiednich dowodów więc sprawa nadal jest w toku [im sorry :(].

    PS. Jeśli chcesz to wyślij mi mail do siebie (na mojego maila) i wtedy jak będę mieć to napisze bezpośrednio. Thx

Leave a Reply

Your email address will not be published.