Dobrze odżywieni filozofowie lub konkurencyjne programowanie .NET

Dobrze odżywieni filozofowie lub konkurencyjne programowanie .NET

Zobaczmy, jak działa programowanie współbieżne i równoległe w .Net, na przykładzie Philosophers Dining Problem. Plan jest taki, od synchronizacji wątków/procesów, po model aktora (w kolejnych częściach). Artykuł może przydać się przy pierwszej znajomości lub w celu odświeżenia wiedzy.

Po co to w ogóle robić? Tranzystory osiągają swoje minimalne rozmiary, prawo Moore'a opiera się na ograniczeniu prędkości światła, dlatego obserwuje się wzrost liczby, można wykonać więcej tranzystorów. Jednocześnie rośnie ilość danych, a użytkownicy oczekują natychmiastowej reakcji ze strony systemów. W takiej sytuacji „normalne” programowanie, gdy mamy jeden wątek wykonawczy, przestaje być skuteczne. Musisz jakoś rozwiązać problem równoczesnego lub współbieżnego wykonywania. Co więcej, problem ten istnieje na różnych poziomach: na poziomie wątków, na poziomie procesów, na poziomie maszyn w sieci (systemy rozproszone). Platforma .NET oferuje wysokiej jakości, sprawdzone technologie umożliwiające szybkie i wydajne rozwiązywanie takich problemów.

Zadanie

Edsger Dijkstra postawił ten problem swoim studentom już w 1965 roku. Ustalone sformułowanie jest następujące. Jest pewna (zwykle pięciu) liczba filozofów i taka sama liczba widelców. Siedzą przy okrągłym stole z widelcami między sobą. Filozofowie mogą jeść ze swoich talerzy z nieskończonym jedzeniem, myśleć lub czekać. Aby zjeść filozofa, musisz wziąć dwa widelce (ostatni dzieli widelec z pierwszym). Podnoszenie i odkładanie widelca to dwie odrębne czynności. Wszyscy filozofowie milczą. Zadanie polega na znalezieniu takiego algorytmu, aby wszyscy pomyśleli i byli pełni nawet po 54 latach.

Najpierw spróbujmy rozwiązać ten problem za pomocą wspólnej przestrzeni. Widelce leżą na wspólnym stole, a filozofowie po prostu je biorą i odkładają. Tutaj są problemy z synchronizacją, kiedy dokładnie przyjmować surebety? co jeśli nie ma widelca? itd. Ale najpierw zacznijmy od filozofów.

Aby rozpocząć wątki, używamy puli wątków przez Task.Run metoda:

var cancelTokenSource = new CancellationTokenSource();
Action<int> create = (i) => RunPhilosopher(i, cancelTokenSource.Token);
for (int i = 0; i < philosophersAmount; i++) 
{
    int icopy = i;
    // Поместить задачу в очередь пула потоков. Метод RunDeadlock не запускаеться 
    // сразу, а ждет своего потока. Асинхронный запуск.
    philosophers[i] = Task.Run(() => create(icopy), cancelTokenSource.Token);
}

Pula wątków została zaprojektowana w celu optymalizacji tworzenia i usuwania wątków. Ta pula ma kolejkę z zadaniami, a środowisko CLR tworzy lub usuwa wątki w zależności od liczby tych zadań. Jedna pula dla wszystkich domen aplikacji. Ta pula powinna być używana prawie zawsze, ponieważ. nie trzeba męczyć się z tworzeniem, usuwaniem wątków, ich kolejek itp. Bez puli jest to możliwe, ale wtedy trzeba bezpośrednio z niej korzystać Thread, jest to przydatne w przypadkach, gdy trzeba zmienić priorytet wątku, gdy mamy długą operację, dla wątku pierwszego planu itp.

Innymi słowy System.Threading.Tasks.Task klasa jest ta sama Thread, ale z wszelkimi udogodnieniami: możliwością uruchamiania zadania po bloku innych zadań, zwracania ich z funkcji, wygodnego przerywania ich i nie tylko. itp. Są potrzebne do obsługi konstrukcji asynchronicznych / oczekujących (wzorzec asynchroniczny oparty na zadaniach, cukier składniowy do oczekiwania na operacje IO). Porozmawiamy o tym później.

CancelationTokenSource tutaj jest to potrzebne, aby wątek mógł się zakończyć na sygnał wątku wywołującego.

Problemy z synchronizacją

Zablokowani filozofowie

Dobra, wiemy, jak tworzyć wątki, spróbujmy zjeść lunch:

// Кто какие вилки взял. К примеру: 1 1 3 3 - 1й и 3й взяли первые две пары.
private int[] forks = Enumerable.Repeat(0, philosophersAmount).ToArray();

// То же, что RunPhilosopher()
private void RunDeadlock(int i, CancellationToken token) 
{
    // Ждать вилку, взять её. Эквивалентно: 
    // while(true) 
    //     if forks[fork] == 0 
    //          forks[fork] = i+1
    //          break
    //     Thread.Sleep() или Yield() или SpinWait()
    void TakeFork(int fork) =>
        SpinWait.SpinUntil(() => 
            Interlocked.CompareExchange(ref forks[fork], i+1, 0) == 0);

    // Для простоты, но можно с Interlocked.Exchange:
    void PutFork(int fork) => forks[fork] = 0;

    while (true)
    {
        TakeFork(Left(i));
        TakeFork(Right(i));
        eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
        PutFork(Left(i));
        PutFork(Right(i));
        Think(i);

        // Завершить работу по-хорошему.
        token.ThrowIfCancellationRequested();
    }
}

Tutaj najpierw próbujemy wziąć lewy widelec, potem prawy widelec, a jeśli się uda, to jemy i odkładamy z powrotem. Wzięcie jednego widelca jest atomowe, tj. dwa wątki nie mogą wziąć jednego w tym samym czasie (niepoprawnie: pierwszy czyta, że ​​widelec jest wolny, drugi - też, pierwszy bierze, drugi bierze). Dla tego Interlocked.CompareExchange, który musi być zaimplementowany za pomocą instrukcji procesora (TSL, XCHG), który blokuje fragment pamięci dla sekwencyjnego odczytu i zapisu atomowego. A SpinWait jest odpowiednikiem konstrukcji while(true) tylko przy odrobinie "magii" - wątek zabiera procesor (Thread.SpinWait), ale czasami przekazuje kontrolę do innego wątku (Thread.Yeild) lub zasypia (Thread.Sleep).

Ale to rozwiązanie nie działa, ponieważ przepływy wkrótce (dla mnie w ciągu sekundy) są zablokowane: wszyscy filozofowie wybierają lewy widelec, ale nie prawy. Tablica forks ma wtedy wartości: 1 2 3 4 5.

Dobrze odżywieni filozofowie lub konkurencyjne programowanie .NET

Na rysunku blokowanie wątków (zakleszczenie). Zielony - wykonanie, czerwony - synchronizacja, szary - wątek śpi. Romby wskazują czas rozpoczęcia Zadania.

Głód filozofów

Wprawdzie nie trzeba myśleć szczególnie dużo o jedzeniu, ale głód każe każdemu porzucić filozofię. Spróbujmy zasymulować sytuację głodu wątków w naszym problemie. Głód ma miejsce, gdy nić działa, ale bez znaczącej pracy, innymi słowy, jest to ten sam impas, tylko teraz nić nie śpi, ale aktywnie szuka czegoś do jedzenia, ale nie ma jedzenia. Aby uniknąć częstego blokowania, odłożymy widelec z powrotem, gdybyśmy nie mogli wziąć innego.

// То же что и в RunDeadlock, но теперь кладем вилку назад и добавляем плохих философов.
private void RunStarvation(int i, CancellationToken token)
{
    while (true)
    {
        bool hasTwoForks = false;
        var waitTime = TimeSpan.FromMilliseconds(50);
        // Плохой философов может уже иметь вилку:
        bool hasLeft = forks[Left(i)] == i + 1;
        if (hasLeft || TakeFork(Left(i), i + 1, waitTime))
        {
            if (TakeFork(Right(i), i + 1, TimeSpan.Zero))
                hasTwoForks = true;
            else
                PutFork(Left(i)); // Иногда плохой философ отдает вилку назад.
        } 
        if (!hasTwoForks)
        {
            if (token.IsCancellationRequested) break;
            continue;
        }
        eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
        bool goodPhilosopher = i % 2 == 0;
        // А плохой философ забывает положить свою вилку обратно:
        if (goodPhilosopher)
            PutFork(Left(i));
        // А если и правую не положит, то хорошие будут вообще без еды.
        PutFork(Right(i));

        Think(i);

        if (token.IsCancellationRequested)
            break;
    }
}

// Теперь можно ждать определенное время.
bool TakeFork(int fork, int philosopher, TimeSpan? waitTime = null)
{
    return SpinWait.SpinUntil(
        () => Interlocked.CompareExchange(ref forks[fork], philosopher, 0) == 0,
              waitTime ?? TimeSpan.FromMilliseconds(-1)
    );
}

Ważną rzeczą w tym kodzie jest to, że dwóch na czterech filozofów zapomina odłożyć lewy widelec. I okazuje się, że jedzą więcej jedzenia, podczas gdy inni zaczynają głodować, chociaż wątki mają ten sam priorytet. Tutaj nie są całkowicie głodni, ponieważ. źli filozofowie czasami odkładają widelce. Okazuje się, że dobrzy ludzie jedzą około 5 razy mniej niż źli. Tak więc mały błąd w kodzie prowadzi do spadku wydajności. Warto w tym miejscu również zauważyć, że możliwa jest rzadka sytuacja, gdy wszyscy filozofowie biorą lewy widelec, nie ma prawego, stawiają lewy, czekają, ponownie biorą lewy itp. Ta sytuacja jest również głodem, bardziej impasem. Nie udało mi się tego powtórzyć. Poniżej znajduje się ilustracja przedstawiająca sytuację, w której dwóch złych filozofów wzięło oba widelce, a dwóch dobrych głoduje.

Dobrze odżywieni filozofowie lub konkurencyjne programowanie .NET

Tutaj widać, że wątki czasem się budzą i próbują zdobyć zasób. Dwa z czterech rdzeni nic nie robią (zielony wykres powyżej).

Śmierć filozofa

Cóż, innym problemem, który może zakłócić wspaniały obiad filozofów, jest to, że jeden z nich nagle umrze z widelcami w dłoniach (i tak go pochowają). Wtedy sąsiedzi zostaną bez obiadu. Możesz sam wymyślić przykładowy kod dla tego przypadku, na przykład jest on wyrzucany NullReferenceException po tym, jak filozof weźmie widelce. Nawiasem mówiąc, wyjątek nie zostanie obsłużony, a wywołujący kod nie tylko go złapie (w tym celu AppDomain.CurrentDomain.UnhandledException itd.). Dlatego potrzebne są procedury obsługi błędów w samych wątkach i z płynnym zakończeniem.

Официант

Dobra, jak rozwiążemy ten impas, głód i problem śmierci? Pozwolimy tylko jednemu filozofowi dotrzeć do rozwidleń, dodajmy wzajemne wykluczenie wątków dla tego miejsca. Jak to zrobić? Załóżmy, że obok filozofów stoi kelner, który pozwala jednemu filozofowi wziąć widelce. Jak zrobimy tego kelnera i jak zadają mu filozofowie, pytania są ciekawe.

Najprościej jest wtedy, gdy filozofowie po prostu nieustannie proszą kelnera o dostęp do widelców. Te. teraz filozofowie nie będą czekać na widelec w pobliżu, ale poczekają lub poproszą kelnera. Na początku używamy do tego tylko User Space, w niej nie używamy przerwań do wywoływania jakichkolwiek procedur z jądra (o nich poniżej).

Rozwiązania w przestrzeni użytkownika

Tutaj zrobimy to samo, co kiedyś z jednym widelcem i dwoma filozofami, będziemy kręcić się w kółko i czekać. Ale teraz będą to wszyscy filozofowie i jakby tylko jeden widelec, tj. można powiedzieć, że zje tylko ten filozof, który wziął od kelnera „złoty widelec”. W tym celu używamy SpinLocka.

private static SpinLock spinLock = new SpinLock();  // Наш "официант"
private void RunSpinLock(int i, CancellationToken token)
{
    while (true)
    {
        // Взаимная блокировка через busy waiting. Вызываем до try, чтобы
        // выбрасить исключение в случае ошибки в самом SpinLock.
        bool hasLock = false;
        spinLock.Enter(ref hasLock);
        try
        {
            // Здесь может быть только один поток (mutual exclusion).
            forks[Left(i)] = i + 1;  // Берем вилку сразу, без ожидания.
            forks[Right(i)] = i + 1;
            eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
            forks[Left(i)] = 0;
            forks[Right(i)] = 0;
        }
        finally
        {
            if(hasLock) spinLock.Exit();  // Избегаем проблемы со смертью философа.
        }

        Think(i);

        if (token.IsCancellationRequested)
            break;
    }
}

SpinLock to jest bloker, z grubsza mówiąc, to samo while(true) { if (!lock) break; }, ale z jeszcze większą „magią” niż w SpinWait (który jest tam używany). Teraz wie, jak policzyć oczekujących, uśpić ich trochę i nie tylko. itp. Ogólnie rzecz biorąc, robi wszystko, co możliwe, aby zoptymalizować. Ale musimy pamiętać, że jest to wciąż ten sam aktywny cykl, który pochłania zasoby procesora i utrzymuje przepływ, co może doprowadzić do głodu, jeśli jeden z filozofów stanie się bardziej priorytetowy niż inni, ale nie ma złotego widelca (problem Inwersji Priorytetów) . Dlatego używamy go tylko do bardzo krótkich zmian w pamięci współdzielonej, bez żadnych wywołań stron trzecich, zagnieżdżonych blokad i innych niespodzianek.

Dobrze odżywieni filozofowie lub konkurencyjne programowanie .NET

Rysunek dla SpinLock. Strumienie nieustannie „walczą” o złoty widelec. Występują awarie - na rysunku wybrany obszar. Rdzenie nie są w pełni wykorzystane: tylko około 2/3 przez te cztery wątki.

Innym rozwiązaniem byłoby użycie tylko Interlocked.CompareExchange z takim samym aktywnym oczekiwaniem, jak pokazano w powyższym kodzie (u głodujących filozofów), ale to, jak już powiedziano, mogłoby teoretycznie doprowadzić do zablokowania.

Про Interlocked Należy zauważyć, że nie tylko CompareExchange, ale także inne metody atomowego odczytu ORAZ zapisu. A poprzez powtarzanie zmiany, w przypadku gdy inny wątek ma czas na dokonanie zmian (odczyt 1, odczyt 2, zapis 2, zapis 1 jest zły), można go użyć do złożonych zmian pojedynczej wartości (wzorzec Interlocked Anything) .

Rozwiązania trybu jądra

Aby uniknąć marnowania zasobów w pętli, zobaczmy, jak możemy zablokować wątek. Innymi słowy, kontynuując nasz przykład, zobaczmy, jak kelner usypia filozofa i budzi go tylko wtedy, gdy jest to konieczne. Najpierw przyjrzyjmy się, jak to zrobić w trybie jądra systemu operacyjnego. Wszystkie struktury tam są często wolniejsze niż te w przestrzeni użytkownika. Kilka razy wolniej np AutoResetEvent może 53 razy wolniej SpinLock [Richtera]. Ale z ich pomocą możesz synchronizować procesy w całym systemie, zarządzanym lub nie.

Podstawową konstrukcją jest tutaj semafor zaproponowany przez Dijkstrę ponad pół wieku temu. Semafor to, mówiąc najprościej, dodatnia liczba całkowita zarządzana przez system i dwie operacje na niej, zwiększanie i zmniejszanie. Jeśli nie zmniejszy się do zera, wówczas wątek wywołujący zostanie zablokowany. Kiedy liczba jest zwiększana przez inny aktywny wątek/proces, wątki są pomijane, a semafor jest ponownie zmniejszany o przekazaną liczbę. Można sobie wyobrazić pociągi w wąskim gardle z semaforem. .NET oferuje kilka konstrukcji o podobnej funkcjonalności: AutoResetEvent, ManualResetEvent, Mutex i ja Semaphore. Użyjemy AutoResetEvent, jest to najprostsza z tych konstrukcji: tylko dwie wartości 0 i 1 (fałsz, prawda). Jej metoda WaitOne() blokuje wątek wywołujący, jeśli wartość wynosiła 0, a jeśli 1, obniża go do 0 i pomija. Metoda Set() podnosi do 1 i przepuszcza jednego kelnera, który ponownie obniża do 0. Działa jak kołowrót w metrze.

Skomplikujmy rozwiązanie i użyjmy zamka dla każdego filozofa, a nie dla wszystkich naraz. Te. teraz może być kilku filozofów na raz, a nie jeden. Ale ponownie blokujemy dostęp do stołu, aby poprawnie, unikając wyścigów (warunków wyścigu), przyjmować surebety.

// Для блокирования отдельного философа.
// Инициализируется: new AutoResetEvent(true) для каждого.
private AutoResetEvent[] philosopherEvents;

// Для доступа к вилкам / доступ к столу.
private AutoResetEvent tableEvent = new AutoResetEvent(true);

// Рождение философа.
public void Run(int i, CancellationToken token)
{
    while (true)
    {
        TakeForks(i); // Ждет вилки.
        // Обед. Может быть и дольше.
        eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
        PutForks(i); // Отдать вилки и разблокировать соседей.
        Think(i);
        if (token.IsCancellationRequested) break;
    }
}

// Ожидать вилки в блокировке.
void TakeForks(int i)
{
    bool hasForks = false;
    while (!hasForks) // Попробовать еще раз (блокировка не здесь).
    {
        // Исключающий доступ к столу, без гонок за вилками.
        tableEvent.WaitOne();
        if (forks[Left(i)] == 0 && forks[Right(i)] == 0)
            forks[Left(i)] = forks[Right(i)] = i + 1;
        hasForks = forks[Left(i)] == i + 1 && forks[Right(i)] == i + 1;
        if (hasForks)
            // Теперь философ поест, выйдет из цикла. Если Set 
            // вызван дважды, то значение true.
            philosopherEvents[i].Set();
        // Разблокировать одного ожидающего. После него значение tableEvent в false.
        tableEvent.Set(); 
        // Если имеет true, не блокируется, а если false, то будет ждать Set от соседа.
        philosopherEvents[i].WaitOne();
    }
}

// Отдать вилки и разблокировать соседей.
void PutForks(int i)
{
    tableEvent.WaitOne(); // Без гонок за вилками.
    forks[Left(i)] = 0;
    // Пробудить левого, а потом и правого соседа, либо AutoResetEvent в true.
    philosopherEvents[LeftPhilosopher(i)].Set();
    forks[Right(i)] = 0;
    philosopherEvents[RightPhilosopher(i)].Set();
    tableEvent.Set();
}

Aby zrozumieć, co się tutaj dzieje, rozważmy przypadek, gdy filozof nie wziął widelców, wtedy jego działania będą następujące. Czeka na dostęp do stołu. Otrzymawszy go, próbuje wziąć widelce. Nie wypracował. Daje dostęp do tabeli (wzajemne wykluczanie). I mija swój „kołowrót” (AutoResetEvent) (są początkowo otwarte). Znowu wchodzi w cykl, bo on nie ma widelców. Próbuje je zabrać i zatrzymuje się przy swoim „kołowrotku”. Jakiś bardziej szczęśliwy sąsiad z prawej lub lewej strony, skończywszy jeść, odblokowuje naszego filozofa, „otwierając kołowrót”. Nasz filozof mija ją (i zamyka się za nią) po raz drugi. Próbuje po raz trzeci wziąć widelce. Powodzenia. I mija swój kołowrót, żeby zjeść obiad.

Gdy w takim kodzie występują przypadkowe błędy (zawsze są), np. błędnie podany sąsiad lub tworzony ten sam obiekt AutoResetEvent dla wszystkich (Enumerable.Repeat), to filozofowie będą czekać na deweloperów, bo Znalezienie błędów w takim kodzie jest dość trudnym zadaniem. Innym problemem związanym z tym rozwiązaniem jest to, że nie gwarantuje ono, że jakiś filozof nie będzie głodny.

Rozwiązania hybrydowe

Przyjrzeliśmy się dwóm podejściom do taktowania, kiedy pozostajemy w trybie użytkownika i pętli oraz kiedy blokujemy wątek w jądrze. Pierwsza metoda jest dobra dla krótkich loków, druga dla długich. Często konieczne jest najpierw krótkie odczekanie na zmianę zmiennej w pętli, a następnie zablokowanie wątku, gdy oczekiwanie jest długie. Podejście to realizowane jest w tzw. struktury hybrydowe. Oto te same konstrukcje, co w trybie jądra, ale teraz z pętlą trybu użytkownika: SemaphorSlim, ManualResetEventSlim itp. Najpopularniejszym projektem jest tutaj Monitor, ponieważ w C# jest dobrze znany lock składnia. Monitor jest to ten sam semafor o maksymalnej wartości 1 (mutex), ale z obsługą czekania w pętli, rekurencji, wzorca zmiennej warunkowej (więcej o tym poniżej) itp. Przyjrzyjmy się temu rozwiązaniu.

// Спрячем объект для Монитора от всех, чтобы без дедлоков.
private readonly object _lock = new object();
// Время ожидания потока.
private DateTime?[] _waitTimes = new DateTime?[philosophersAmount];

public void Run(int i, CancellationToken token)
{
    while (true)
    {
        TakeForks(i);
        eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
        PutForks(i);
        Think(i);
        if (token.IsCancellationRequested) break;
    }
}

// Наше сложное условие для Condition Variable паттерна.
bool CanIEat(int i)
{
    // Если есть вилки:
    if (forks[Left(i)] != 0 && forks[Right(i)] != 0)
        return false;
    var now = DateTime.Now;
    // Может, если соседи не более голодные, чем текущий.
    foreach(var p in new int[] {LeftPhilosopher(i), RightPhilosopher(i)})
        if (_waitTimes[p] != null && now - _waitTimes[p] > now - _waitTimes[i])
            return false;
    return true;
}

void TakeForks(int i)
{
    // Зайти в Монитор. То же самое: lock(_lock) {..}.
    // Вызываем вне try, чтобы возможное исключение выбрасывалось выше.
    bool lockTaken = false;
    Monitor.Enter(_lock, ref lockTaken);
    try
    {
        _waitTimes[i] = DateTime.Now;
        // Condition Variable паттерн. Освобождаем лок, если не выполненно 
        // сложное условие. И ждем пока кто-нибудь сделает Pulse / PulseAll.
        while (!CanIEat(i))
            Monitor.Wait(_lock); 
        forks[Left(i)] = i + 1;
        forks[Right(i)] = i + 1;
        _waitTimes[i] = null;
    }
    finally
    {
        if (lockTaken) Monitor.Exit(_lock);
    }
}

void PutForks(int i)
{
    // То же самое: lock (_lock) {..}.
    bool lockTaken = false;
    Monitor.Enter(_lock, ref lockTaken);
    try
    {
        forks[Left(i)] = 0;
        forks[Right(i)] = 0;
        // Освободить все потоки в очереди ПОСЛЕ вызова Monitor.Exit.
        Monitor.PulseAll(_lock); 
    }
    finally
    {
        if (lockTaken) Monitor.Exit(_lock);
    }
}

Tutaj ponownie blokujemy cały stół, aby uzyskać dostęp do widelców, ale teraz odblokowujemy wszystkie wątki naraz, a nie sąsiadów, gdy ktoś skończy jeść. Te. najpierw ktoś zjada i blokuje sąsiadów, a jak ten ktoś skończy, ale od razu chce znowu zjeść, to idzie w blokowanie i budzi sąsiadów, bo. jego czas oczekiwania jest krótszy.

W ten sposób unikamy impasu i głodu jakiegoś filozofa. Używamy pętli na krótkie oczekiwanie i blokujemy wątek na długie. Odblokowanie wszystkich naraz jest wolniejsze niż w przypadku odblokowania tylko sąsiada, jak w rozwiązaniu z AutoResetEvent, ale różnica nie powinna być duża, ponieważ wątki muszą najpierw pozostać w trybie użytkownika.

У lock składnia ma paskudne niespodzianki. Polecam do użycia Monitor bezpośrednio [Richter] [Eric Lippert]. Jednym z nich jest to lock zawsze poza Monitor, nawet jeśli wystąpił wyjątek, w którym to przypadku inny wątek mógłby zmienić stan pamięci współdzielonej. W takich przypadkach często lepiej jest przejść do impasu lub w jakiś sposób bezpiecznie zakończyć program. Kolejną niespodzianką jest to, że Monitor używa bloków synchronizacji (SyncBlock), które są obecne we wszystkich obiektach. W związku z tym, jeśli zostanie wybrany niewłaściwy obiekt, można łatwo uzyskać zakleszczenie (na przykład, jeśli zablokujesz na internowanym łańcuchu). Używamy do tego zawsze ukrytego obiektu.

Wzorzec zmiennej warunkowej umożliwia bardziej zwięzłe zaimplementowanie oczekiwanego złożonego warunku. Moim zdaniem w .NET jest niekompletny, ponieważ teoretycznie powinno być kilka kolejek na kilka zmiennych (jak w Posix Threads), a nie na jeden lok. Wtedy można by je zrobić dla wszystkich filozofów. Ale nawet w tej formie pozwala zredukować kod.

wielu filozofów lub async / await

Dobra, teraz możemy skutecznie blokować wątki. Ale co, jeśli mamy wielu filozofów? 100? 10000? Na przykład otrzymaliśmy 100000 4 żądań do serwera WWW. Utworzenie wątku dla każdego żądania będzie narzutem, ponieważ tak wiele wątków nie będzie działać równolegle. Będzie działać tylko tyle, ile jest rdzeni logicznych (mam XNUMX). A wszyscy inni po prostu zabiorą zasoby. Jednym z rozwiązań tego problemu jest wzorzec async/await. Jego idea polega na tym, że funkcja nie wstrzymuje wątku, jeśli musi czekać na kontynuację. A kiedy coś robi, wznawia swoje wykonanie (ale niekoniecznie w tym samym wątku!). W naszym przypadku poczekamy na rozwidlenie.

SemaphoreSlim ma za to WaitAsync() metoda. Oto implementacja wykorzystująca ten wzorzec.

// Запуск такой же, как раньше. Где-нибудь в программе:
Task.Run(() => Run(i, cancelTokenSource.Token));

// Запуск философа.
// Ключевое слово async -- компилятор транслирует этот метот в асинхронный.
public async Task Run(int i, CancellationToken token)
{
    while (true)
    {
        // await -- будем ожидать какого-то события.
        await TakeForks(i);
        // После await, продолжение возможно в другом потоке.
        eatenFood[i] = (eatenFood[i] + 1) % (int.MaxValue - 1);
        // Может быть несколько событий для ожидания.
        await PutForks(i);

        Think(i);

        if (token.IsCancellationRequested) break;
    }
}

async Task TakeForks(int i)
{
    bool hasForks = false;
    while (!hasForks)
    {
        // Взаимоисключающий доступ к столу:
        await _tableSemaphore.WaitAsync();
        if (forks[Left(i)] == 0 && forks[Right(i)] == 0)
        {
            forks[Left(i)] = i+1;
            forks[Right(i)] = i+1;
            hasForks = true;
        }
        _tableSemaphore.Release();
        // Будем ожидать, чтобы сосед положил вилки:
        if (!hasForks)
            await _philosopherSemaphores[i].WaitAsync();
    }
}

// Ждем доступа к столу и кладем вилки.
async Task PutForks(int i)
{
    await _tableSemaphore.WaitAsync();
    forks[Left(i)] = 0;
    // "Пробудить" соседей, если они "спали".
    _philosopherSemaphores[LeftPhilosopher(i)].Release();
    forks[Right(i)] = 0;
    _philosopherSemaphores[RightPhilosopher(i)].Release();
    _tableSemaphore.Release();
}

Metoda z async / await jest tłumaczony na trudną maszynę stanów, która natychmiast zwraca swoją wewnętrzną Task. Dzięki niemu możesz poczekać na zakończenie metody, anulować ją i wszystko inne, co możesz zrobić za pomocą Task. Wewnątrz metody maszyna stanów kontroluje wykonanie. Najważniejsze jest to, że jeśli nie ma opóźnienia, to wykonanie jest synchroniczne, a jeśli jest, to wątek jest zwalniany. Aby lepiej to zrozumieć, lepiej przyjrzeć się tej maszynie stanów. Możesz z nich tworzyć łańcuchy async / await metody.

przetestujmy. Praca 100 filozofów na maszynie z 4 rdzeniami logicznymi, 8 sekund. Poprzednie rozwiązanie z Monitorem uruchamiało tylko 4 pierwsze wątki, a reszta w ogóle nie działała. Każdy z tych 4 wątków był bezczynny przez około 2 ms. A rozwiązanie asynchroniczne/oczekujące uruchomiło wszystkie 100, ze średnim oczekiwaniem 6.8 sekundy na każde. Oczywiście w rzeczywistych systemach bezczynność przez 6 sekund jest niedopuszczalna i lepiej nie przetwarzać tak wielu takich żądań. Rozwiązanie z Monitorem okazało się w ogóle nie skalowalne.

wniosek

Jak widać z tych małych przykładów, .NET obsługuje wiele konstrukcji synchronizacji. Jednak nie zawsze jest oczywiste, jak z nich korzystać. Mam nadzieję, że ten artykuł był pomocny. Na razie to koniec, ale zostało jeszcze sporo ciekawych rzeczy, na przykład kolekcje bezpieczne dla wątków, TPL Dataflow, programowanie reaktywne, model Software Transaction itp.

Źródła informacji

Źródło: www.habr.com

Dodaj komentarz