Bezpieczny dostęp do danych

Deti

1 Wstęp
2 Na czym polega bezpieczeństwo w aplikach wielowątkowych?
3 Atomiczność
4 lock()
5 Gdzie nie stosować instrukcji lock?
6 Przypisanie a Thread-Safety?
7 Instrukcja lock a wiele wątków
8 Podsumowanie

Wstęp

Artykuł opisuje zagadnienia związane z równoczesnym dostępem do wspólnych elementów przez wiele wątków oraz możliwe konflikty - w języku C#. Nie znajdziecie tu natomiast podstaw wielowątkowości w C#, zakładam iż te są już znane. Przykłady napisane jako proste aplikacje konsolowe – krótkie i zwięzłe bez nadmiaru niepotrzebnych informacji (jednak na tyle samodzielne, że można je bez problemu odpalić przez zwykłe skopiowanie).

Na czym polega bezpieczeństwo w aplikach wielowątkowych?

Język C# na platformie .NET daje możliwość działania wielu wątków jednocześnie w ramach jednego procesu. Wątki mogę wykonywać rozmaite obliczenia ale również operować na istniejących obiektach, polach, właściwościach – odczytywać je i zapisywać. Problemy zaczynają się pojawiać w momencie, gdy wiele wątków próbuje manipulować wspólnymi elementami jednocześnie. Poniżej najprostszy przykład:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;

namespace ThreadSafety {
    class Program {
        static void Main(string[] args) {
            int i=1;
            Thread Operation = new Thread(() => {
                while(true)
                    if(i!=0) {
                        //Thread.Sleep(20);
                        i=1/i;
                    }
            });

            Thread Modifier=new Thread(() => {
                    i=0;
            });

            Operation.Start();
            Modifier.Start();
            

        }
    }
}

Mamy do czynienia z dwoma wątkami – Operation i Modifier. Wątek Operation wykonuje żmudne dzielenie liczby 1 przez wartość zmiennej i (oczywiście tylko wtedy, gdy zmienna i jest różna od zera). Wątek Modifier ustawia zmienną i na wartość 0.
Program tylko z pozoru wydaje się być „bezpieczny”. Istotny jest ten fragment:

if(i!=0) {
    //Thread.Sleep(20);
    i=1/i;
 }

Linia pierwsza sprawdza czy można dzielić (czy dzielna jest niezerowa), a jeśli można – wykonuje dzielenie.

Wątki w C# działają współbieżnie, to znaczy – jednocześnie. W naszym przykładzie istnieje niebezpieczeństwo – wątek Modifier może wykonać swoje działanie po warunku if(i!=0), ale jeszcze przed dzieleniem. W „normalnych’ warunkach ciężko byłoby to zaobserwować, ale wystarczy odkomentować linijkę Thread.Sleep(20) – a szansa na wyjątek dzielenia przez zero rosną do 100% (polecam sprawdzić).

Cóż się zatem stało? Otóż wątek Modifier zmienił nam wartość zmiennej pomiędzy dwoma operacjami wątku Operation. To spowodowało wyjątek – program się potocznie nazywając - wykrzaczył.

Atomiczność

Aby zapewnić bezpieczeństwo aplikacji (jeśli chodzi o wątki) operacje, które są ze sobą bezpośrednio związane muszą być atomiczne. To znaczy – operacja ta nie może zostać w żaden sposób przerwana. W języku C# atomiczności długich operacji praktycznie nie da się osiągnąć. Można jednak zabezpieczyć pewne fragmenty kodu – a ten sposób – aby dostęp do nich w danych czasie miał tylko jeden wątek (a wszystko dzięki klasie Monitor).

Oto rozbudowana wersja aplikacji, która jest już bezpieczna:

namespace ThreadSafety {
    class Program {
        static void Main(string[] args) {
            object Latch=new object();

            int i=1;
            Thread Operation = new Thread(() => {
                while(true)
                    Monitor.Enter(Latch);
                    if(i!=0) {
                        Thread.Sleep(20);
                        i=1/i;
                    }
                    Monitor.Exit(Latch);
            });

            Thread Modifier=new Thread(() => {
                    Monitor.Enter(Latch);
                    i=0;
                    Monitor.Exit(Latch);
            });

            Operation.Start();
            Modifier.Start();
            

        }
    }
}

Widać tu dwie ważne funkcje: Enter i Exit (oba są publicznymi, statycznymi metodami klasy Monitor). Parametrem jest wcześniej utworzony obiekt. Gdy dany wątek zatrzaskuje obiekt (Monitor.Enter), blokuje dostęp dla innych wątków, które również używają tego obiektu (w tym przypadku – obiekt Latch). Pozostałe wątki czekają aż obiekt się zwolni (Monitor.Exit). Dzięki temu nie jest możliwe zakłócenie działania algorytmu w niepożądanym czasie.

lock()

Instrukcja lock to nic innego jak skrót od:

Monitor.Enter(obiekt);
try
{
//
// tutaj instrukcje...
//
}
finally
{
    Monitor.Exit(obiekt);
}

taka konstrukcja daje nam pewność że blokada zostanie zwolniona.

Ten sam program z wykorzystaniem instrukcji lock wygląda następująco:

namespace ThreadSafety {
    class Program {
        static void Main(string[] args) {
            object Latch=new object();

            int i=1;
            Thread Operation = new Thread(() => {
                while(true)
                    lock(Latch){
                        if(i!=0) {
                            Thread.Sleep(20);
                            i=1/i;
                        }
                    }
            });

            Thread Modifier=new Thread(() => {
                    lock(Latch){
                        i=0;
                    }
            });

            Operation.Start();
            Modifier.Start();
            

        }
    }
}

Użycie lock ma swoje wady i zalety. Przede wszystkim jest bezpieczne – lock jest operacją atomiczną – dwa wątki nie mogę jednocześnie zatrzasnąć jednego obiektu. Oprócz tego jest to operacja bardzo szybka – idealna wręcz dla prostej synchronizacji, gdzie kilka wątków operuje na wspólnych danych. Nie nadaje się jednak w sytuacji, gdzie wątków odczytujących jest dużo – o tym później..

Gdzie nie stosować instrukcji lock?

Wielowątkowość w C# to dość obszerny temat, a projektowanie klas które są w pełni Thread-Safe nie jest zagadnieniem trywialnym. Trudne w dwóch słowach określić, gdzie trzeba blokować instrukcją lock, a gdzie nie. Chciałbym jednak omówić pewne zagadnienie – mianowicie – używanie lock przy przypisaniu. Wielokrotnie przeglądając kody, spotkałem się z takimi zapisami:

namespace ThreadSafety {
    class Program {
        static void Main(string[] args) {
            object Latch=new object();

            int i=-1;
            Thread SetZero = new Thread(() => {
                while(true)
                    lock(Latch){
                        i=0;
                    }
            });

            Thread SetOne=new Thread(() => {
                while(true)
                    lock(Latch) {
                        i=1;
                    }
            });

            SetZero.Start();
            SetOne.Start();

        }
    }
}

Działanie programu jest bardzo proste – dwa wątki (SetZero oraz SetOne) wzajemnie przestawiają sobie wartość zmiennej i (pierwszy – na 0, drugi – na 1). Wydaje się, że wszystko jest w porządku (tak też jest – w końcu mamy instrukcję lock). Gdy tylko jeden z wątków otwiera blokadę – drugi ją przejmuje – i tak dalej.
Prawda jest jednak taka, że w programie tym instrukcja lock jest całkowicie niepotrzebna. Dlaczego ? – zobacz następny akapit.

Przypisanie a Thread-Safety?

Przypisując jakąś wartość do zmiennej – nie musimy stosować instrukcji lock (a przynajmniej w większości przypadków – w tym – w naszym programie). A kiedy jednak trzeba stosować lock? Poniżej wyjaśnienie:

#każde przypisanie klasy jest bezpieczne. Przypisanie obiektu do zmiennej jest tylko wskazaniem wskaźnika na obiekt, który jest już na stercie. Referencja w C# jest 32-bitowa, a sama operacja przypisania jest atomiczna. Wskaźnik albo wskazuje na jeden obiekt, albo na drugi – nie ma nic pomiędzy tym. Operacja przypisania wskaźnika jest atomiczna (oczywiście z punkty widzenia C# i wielowątkowości – samo działanie na poziomie elektroniki nie jest oczywiście atomiczne).
#przypisanie wartości do struktury jest bezpieczne (chyba, ze struktura ma rozmiar większy niż 32 bity na 32 bitowym procesorze!). U nas przypisywaliśmy wartość do struktury i (tak, Int jest strukturą!). Int to alias na typ System.Int32 – jest to struktura, która zajmuje 32 bity. Każde jej przypisanie do zmiennej jest atomiczne!
#przypisanie do struktury nie jest atomiczne, jeśli struktura ma rozmiar większy niż długość procesora (liczba bitów). Przykład: typ long ma 64 bity. Działanie long l=100 jest atomiczne na procesorze 64-bitowym, ale nie na procesorze 32-bitowym! W drugim przypadku muszą zajść dwie instrukcje procesora na wrzucenie struktury na stos – w tym czasie inny wątek może również chcieć zmienić wartość tej samem zmiennej. Rezultatem może być wartość, która jest kombinacją bitową dwóch operacji – zatem całkowicie niepożądany efekt.

Rozpatrując powyższe punkty warto wiedzieć zatem, że działając na nawet niewielkich strukturach – warto przypisanie ich zabezpieczyć instrukcją lock. Nie musimy się natomiast zupełnie przejmować gdy mamy do czynienia z obiektami (instancjami klas), choćby tymi – wydawało by się – „dużymi” (jak List<>, Form, czy też string).

Należy pamiętać, że choć przypisanie do zmiennej (jak wyżej „i=1”) jest atomiczne, to inne operacje już takie być nie muszą. Przykład: - inkrementacja, czyli i++; Taka operacja nie jest atomiczna, i powinna być zawarta w instrukcji lock (w rzeczywistości najpierw następuje odczytanie wartości zmiennej, a dopiero później przypisanie wartości zwiększonej o 1. W międzyczasie drugi wątek może wartość zmiennej nadpisać. To spowoduje nieprawidłowe działanie inkrementacji).

Instrukcja lock a wiele wątków

Jak już wspomniałem, lock nadaje się świetnie do prostych rzeczy, gdzie mamy do czynienia z niewielka liczbą wątków. Lock z pewnością nie nadaje się gdy istnieje wiele wątków, których jedynym celem jest odczyt.

Odczyt jest bezpieczny wątkowo – nie trzeba tu stosować instrukcji blokujących – w końcu nie zmienia on w żaden sposób danych. Wyobraźmy sobie sytuacje, gdzie dane są zczytywane z kilu(dziesięciu) wątków, a zmiana danych (zapis) odbywa się okazjonalnie przez tylko jeden wątek (lub więcej). Istotnie należy klasę zabezpieczyć – jednak instrukcja lock spowoduje, że każdy odczyt będzie blokował dostęp dla innych wątków – których jest dużo. Blokada powoduje to, że wątki ustawiają się w kolejce. Pierwszy, który zaczął czekać na zwolnienie obiektu, pierwszy dostanie przydział na jego lock’a itd. Po co zatem wątki, które zczytują dane mają sobie nawzajem blokować dostęp do tych danych.. tym bardziej – skoro razem mogę czytać jednocześnie bez żadnego niebezpieczeństwa.

Z pomocą przychodzi klasa ReaderWriterLockSlim. Ten dziwny twór, mimo groźnie brzmiącej nazwy, jest dość łatwy w użyciu. Jego działanie podobne jest do działania instrukcji lock – z tym, że ma dwa rodzaje blokowania – zapisu/odczytu.

Przykładowy program:

namespace ThreadSafety {
    class Program {
        static int i=-1;
        static ReaderWriterLockSlim rwl=new ReaderWriterLockSlim();

        static void Main(string[] args) {
            Thread[] readers = new Thread[6];
            for(int r=0; r<readers.Length; r++) {
                readers[r]=new Thread(() =>{
                int b;
                while(true){
                    rwl.EnterReadLock();
                    b=i; 
                    rwl.ExitReadLock();
                }
                }
            );
            }

            Thread writer=new Thread(() => {
                while(true) {
                    rwl.EnterWriteLock();
                    i++;
                    rwl.ExitWriteLock();
                    Thread.Sleep(20);
                }
            });

            for(int r=0;r<readers.Length;r++)
                readers[r].Start();
            writer.Start();

        }
    }
}

Pole rwl jest instancją klasy ReaderWriterLockSlim. Używamy czterech metod: EnterReadLock, ExitReadLock, EnterWriteLock, ExitWriteLock. Pierwsze dwie stanowią blok odczytu, drugie – blok zapisu. Gdy wątki odczytują dane – nie blokują siebie nawzajem – to znaczy – nie blokują innych wątków, które również używają metod EnterReadLock, ExitReadLock.

Sytuacja jest odwrotna jeśli chodzi o tryb zapisu. Gdy wątek próbuje wykonać EnterWriteLock, musi czekać aż wszystkie wątki zczytujące wykonają ExitReadLock – dopiero wtedy następuje wejście w sekcje. Po zablokowaniu – żaden inny wątek nie może ani czytać, ani zapisywać – do momentu wykonania ExitWriteLock.

Powyżej przedstawiłem tylko podstawy, więcej znajdziecie tu: http://msdn.microsoft.com/en-us/library/system.threading.readerwriterlockslim.aspx

Podsumowanie

Wielowątkowość ma tyle zagadnień, że nie sposób wszystkiego opisać – dlatego starałem się ograniczyć jedynie do tematów związanych z bezpieczeństwem i synchronizacją. Należy pamiętać, że klasa Monitor (lock) jest tylko prostym narzędziem blokującym – istnieją bardziej zaawansowane klasy w .NET jak Mutex, Semaphore, AutoResetEvent i inne, których działanie jeszcze bardziej wzbogaca platformę .NET.

4 komentarzy

Dopisałem "składnię" lock(). W końcu na edycję zawsze jest czas, a lepiej późno niż wcale... ;)

Nowi mogą nie wiedzieć, więc przypominam: 4programmers to Wiki i każdy może edytować i poprawiać literówki po zalogowaniu się.

czepiasz się literówki. Lepiej wypowiedz się o merytoryczności, bo temat ciekawy.

"Ten dziwny twór, mimo groźnie brzmiącej nazwy, jest dość łątwy w użyciu."

Przyznaje się, ciekawi mnie co znaczy owa "łątwość" w użyciu :)