
Zobaczmy, jak działa programowanie współbieżne i równoległe w .Net, na przykładzie problemu filozofów przy stole. Plan jest taki, aby przejść od synchronizacji wątków/procesów do modelu aktorów (w następnych częściach). Artykuł może być przydatny do pierwszego zapoznania się lub do odświeżenia wiedzy.
Dlaczego w ogóle warto to umieć? Tranzystory osiągają swój minimalny rozmiar, prawo Moore'a napotyka ograniczenia prędkości światła, więc wzrost obserwuje się w liczbie, a tranzystory można produkować więcej. W międzyczasie ilość danych wzrasta, a użytkownicy oczekują natychmiastowej reakcji systemów. W takiej sytuacji "zwykłe" programowanie, gdzie mamy jeden wykonujący wątek, już nie jest efektywne. Trzeba jakoś rozwiązać problem równoczesnego lub współbieżnego wykonania. Problem ten istnieje na różnych poziomach: na poziomie wątków, na poziomie procesów, na poziomie maszyn w sieci (systemy rozproszone). W .NET dostępne są wiodące, sprawdzone w czasie technologie do szybkiego i efektywnego rozwiązywania takich problemów.
Zadanie
Edsger Dijkstra stawiał ten problem swoim uczniom już w 1965 roku. Ugruntowane sformułowanie jest takie: jest pewna (zwykle pięć) liczba filozofów i tyle samo widelców. Siedzą przy okrągłym stole, widelce są między nimi. Filozofowie mogą jeść z swoich talerzy z niekończącym się jedzeniem, myśleć lub czekać. Aby zjeść, filozof musi wziąć dwa widelce (ostatni dzieli widelec z pierwszym). Wzięcie i odłożenie widelca to dwa oddzielne działania. Wszyscy filozofowie są milczący. Zadanie polega na znalezieniu takiego algorytmu, aby wszyscy mogli myśleć i być syci, nawet po 54 latach.
Na początku spróbujmy rozwiązać ten problem poprzez wykorzystanie wspólnego miejsca. Widelce leżą na wspólnym stole, a filozofowie po prostu je biorą, gdy są dostępne, i odkładają z powrotem. Pojawiają się problemy z synchronizacją, kiedy dokładnie brać widelce? co robić, gdy widelca nie ma? i inne. Ale najpierw uruchomimy filozofów.
Aby uruchomić wątki, użyjemy puli wątków przez Task.Run metodę:
var cancelTokenSource = new CancellationTokenSource();
Action create = (i) => RunPhilosopher(i, cancelTokenSource.Token);
for (int i = 0; i create(icopy), cancelTokenSource.Token);
}Pula wątków powstał w celu optymalizacji tworzenia i usuwania wątków. Ten pula ma kolejkę zadań, a CLR tworzy lub usuwa wątki w zależności od liczby tych zadań. Jeden pula dla wszystkich AppDomain'ów. Należy go używać niemal zawsze, ponieważ nie trzeba martwić się o tworzenie, usuwanie wątków, ich kolejki itd. Można też bez puli, ale wtedy trzeba korzystać bezpośrednio z Wątek, co ma sens w przypadkach, gdy trzeba zmienić priorytet wątkowi, gdy mamy długą operację, dla wątku Foreground i innych.
Innymi słowy, System.Threading.Tasks.Task klasa to to samo Wątek, ale z różnymi udogodnieniami: możliwość uruchamiania zadania po bloku innych zadań, zwracania ich z funkcji, wygodne przerywanie ich i wiele innych. Są one potrzebne do wspierania konstrukcji async/await (Task-based Asynchronous Pattern, syntaktyczny cukier do oczekiwania na operacje IO). O tym jeszcze porozmawiamy.
CancelationTokenSource jest potrzebny, aby wątek mógł zakończyć się na sygnał wątku wywołującego.
Problemy ze synchronizacją
Zablokowani filozofowie
Dobrze, potrafimy tworzyć wątki, spróbujmy zjeść obiad:
// Кто какие вилки взял. К примеру: 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 zdjąć lewy, a potem prawy widelec i jeśli się uda, to jemy i odkładamy je z powrotem. Zdjęcie jednego widelca jest atomowe, tj. dwa wątki nie mogą wziąć jednego równocześnie (błędnie: pierwszy odczytuje, że widelec jest wolny, drugi - też, pierwszy bierze, drugi bierze). W tym celu Interlocked.CompareExchange, który musi być zrealizowany za pomocą instrukcji procesora (TSL, XCHG), która blokuje obszar pamięci do atomowego sekwencyjnego odczytu i zapisu. SpinWait jest równoważne konstrukcji while(true) tylko z niewielką „magią” — wątek zajmuje procesor (Thread.SpinWait), ale czasami przekazuje kontrolę innemu wątkowi (Thread.Yeild) lub zasypia (Thread.Sleep).
Jednak to rozwiązanie nie działa, ponieważ wątki szybko (u mnie w ciągu sekundy) są blokowane: wszyscy filozofowie biorą swój lewy widelec, a prawy nie. Tablica forks wtedy ma wartości: 1 2 3 4 5.

Na rysunku, blokowanie wątków (deadlock). Zielonym kolorem - wykonanie, czerwonym - synchronizacja, szarym - wątek śpi. Rombikami oznaczone są czasy uruchamiania Task'ów.
Głód filozofów
Choć do myślenia nie potrzeba szczególnie dużo jedzenia, głód może sprawić, że każdy porzuci filozofię. Spróbujmy zmodelować sytuację głodzenia w naszych wątkach. Głodzenie to sytuacja, gdy wątek pracuje, ale nie wykonuje istotnej pracy, innymi słowy, to ten sam deadlock, tylko że wątek nie śpi, a aktywnie szuka, jakby coś zjeść, ale nie ma jedzenia. Aby uniknąć częstych blokad, odkładamy widelca, jeśli nie udało się wziąć drugiego.
// То же что и в 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)
);
}W tym kodzie ważne jest to, że dwóch z czterech filozofów zapomina położyć swój lewy widelec. W rezultacie jedzą więcej jedzenia, a inni zaczynają głodować, mimo że wątki mają ten sam priorytet. Tutaj nie całkiem głodują, ponieważ źli filozofowie czasami odkładają swoje widelce. Moim zdaniem, dobrzy jedzą około 5 razy mniej niż źli. Tak więc mały błąd w kodzie prowadzi do spadku wydajności. Warto zauważyć, że może wystąpić rzadka sytuacja, gdy wszyscy filozofowie biorą lewy widelec, brakuje prawego, odkładają lewy, czekają, znów biorą lewy itd. Ta sytuacja też jest głodowaniem, bardziej przypominającym wzajemną blokadę. Nie udało mi się jej powtórzyć. Poniżej znajduje się obrazek przedstawiający sytuację, gdy dwaj źli filozofowie wzięli oba widelce, a dwóch dobrych głoduje.

Tutaj widać, że wątki czasami się budzą i próbują zdobyć zasób. Dwa rdzenie z czterech nic nie robią (zielony wykres na górze).
Śmierć filozofa
I jeszcze jeden problem, który może przerwać wspaniałą ucztę filozofów — to jeśli jeden z nich nagle umrze z widelcami w rękach (i tak go pochowają). Wtedy sąsiedzi zostaną bez obiadu. Przykład kodu na ten przypadek możesz wymyślić sam, na przykład, zgłaszając NullReferenceException po tym, jak filozof weźmie widelce. I, nawiasem mówiąc, wyjątek nie będzie obsłużony i kod wywołujący go po prostu tego nie złapie (w tym celu AppDomain.CurrentDomain.UnhandledException itd.). Dlatego obsługa błędów jest niezbędna w samych wątkach i z prawidłowym zakończeniem.
Kelner
Dobrze, jak rozwiązać problem z wzajemnym blokowaniem, głodowaniem i śmiercią? Będziemy dopuszczać tylko jednego filozofa do widelców, dodamy wzajemne wykluczenie (mutual exclusion) wątków dla tego miejsca. Jak to osiągnąć? Załóżmy, że obok filozofów stoi kelner, który daje pozwolenie jednemu filozofowi na wzięcie widelców. Jak stworzymy tego kelnera i jak filozofowie będą go prosić, to ciekawe pytania.
Najprostszym sposobem jest to, że filozofowie będą ciągle prosić kelnera o dostęp do widelców. Tzn. teraz filozofowie nie będą czekać na widelec obok, tylko będą czekać lub prosić kelnera. Na początku użyjemy do tego tylko przestrzeni użytkownika, w której nie będziemy używać przerwań do wywoływania jakichkolwiek procedur z jądra (o tym wkrótce).
Rozwiązania w przestrzeni użytkownika
Tutaj zrobimy to samo, co wcześniej z jednym widelcem i dwoma filozofami, będziemy kręcić się w pętli i czekać. Ale teraz to będą wszyscy filozofowie i jakby tylko jeden widelec, tzn. można powiedzieć, że je tylko ten filozof, który wziął ten 'złoty widelec' od kelnera. W tym celu użyjemy SpinLock.
private static SpinLock spinLock = new SpinLock(); // Nasz "kelner"
private void RunSpinLock(int i, CancellationToken token)
{
while (true)
{
// Wzajemne blokowanie przez busy waiting. Wywołujemy do try, aby
// wyrzucić wyjątek w przypadku błędu w samym SpinLock.
bool hasLock = false;
spinLock.Enter(ref hasLock);
try
{
// Tutaj może być tylko jeden wątek (wzajemne wykluczenie).
forks[Left(i)] = i + 1; // Bierzemy widelec od razu, bez czekania.
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(); // Unikamy problemu ze śmiercią filozofa.
}
Think(i);
if (token.IsCancellationRequested)
break;
}
}SpinLock to blokada, mówiąc w skrócie, z tym samym while(true) { if (!lock) break; }, ale z jeszcze większą 'magiką' niż w SpinWait (który tam jest używany). Teraz potrafi liczyć oczekujących, nieco ich usypiać i wiele innych rzeczy. Generalnie, robi wszystko, co możliwe, aby zoptymalizować. Ale trzeba pamiętać, że to ten sam aktywny cykl, który zużywa zasoby procesora i utrzymuje wątek, co może prowadzić do głodu, jeśli jeden z filozofów staje się priorytetowy wobec innych, ale nie ma złotej widelca (problem zamiany priorytetów). Dlatego używamy go tylko do bardzo, bardzo krótkich zmian w pamięci ogólnodostępnej, bez jakichkolwiek wywołań zewnętrznych, zagnieżdżonych blokad czy innych niespodzianek.

Rysunek dla SpinLock. Wątki nieustannie „walczą” o złotą widelca. Zdarzają się przestoje — na rysunku zaznaczony obszar. Rdzenie nie są w pełni wykorzystywane: tylko około 2/3 przez te cztery wątki.
Innym rozwiązaniem byłoby użycie tylko Interlocked.CompareExchange z tym samym aktywnym oczekiwaniem, jak pokazano w powyższym kodzie (w głodujących filozofach), ale to, jak już wspomniano, teoretycznie może prowadzić do zablokowania.
O Interlocked warto powiedzieć, że tam nie tylko CompareExchange, ale inne metody do atomowego odczytu i zapisu. A przez ponowne zmiany w przypadku, gdy inny wątek zdąży wprowadzić swoje zmiany (odczyt 1, odczyt 2, zapis 2, zapis 1 złe), może być używany do złożonych modyfikacji jednej wartości (wzorzec Interlocked Anything).
Rozwiązania w trybie jądra
Aby uniknąć utraty zasobów w pętli, zobaczmy, jak można zablokować wątek. Innymi słowy, kontynuując nasz przykład, przyjrzyjmy się, jak kelner usypia filozofa i budzi go tylko wtedy, gdy jest to potrzebne. Na początku zobaczmy, jak to zrobić przez tryb jądra systemu operacyjnego. Wszystkie struktury tam są często wolniejsza niż te w przestrzeni użytkownika. Wolniejsza kilkukrotnie, na przykład AutoResetEvent może być 53 razy wolniejsza SpinLock [Richter]. Ale za ich pomocą można synchronizować procesy w całym systemie, zarówno zarządzane, jak i nie.
Podstawową konstrukcją jest tutaj semafor, zaproponowany przez Dijkstra ponad pół wieku temu. Semafor to, mówiąc w skrócie, dodatnia liczba całkowita, zarządzana przez system, oraz dwie operacje na niej – zwiększenie i zmniejszenie. Jeśli zmniejszenie nie jest możliwe, czyli przy zerze, wątek wywołujący jest zablokowany. Gdy liczba zostaje zwiększona przez inny aktywny wątek/proces, wtedy wątki są przepuszczane, a semafor znowu się zmniejsza o liczbę, która przeszła. Można to porównać do pociągów w wąskim miejscu z semaforem. .NET oferuje kilka konstrukcji o podobnych funkcjach: AutoResetEvent, ManualResetEvent, Mutex i sam Semaphore. Będziemy używać AutoResetEvent, to najprostsza z tych konstrukcji: tylko dwa wartości 0 i 1 (false, true). Jej metoda WaitOne() blokuje wątek wywołujący, jeśli wartość wynosiła 0, a jeśli 1, to obniża ją do 0 i przepuszcza. A metoda Set() zwiększa do 1 i przepuszcza jednego oczekującego, który znowu obniża do 0. Działa jak bramka w metrze.
Utrudnijmy rozwiązanie i będziemy używać blokady dla każdego filozofa, a nie dla wszystkich na raz. Tzn. teraz może być kilku filozofów jednocześnie, a nie tylko jeden. Ale znów blokujemy dostęp do stołu, aby prawidłowo, unikając wyścigów (race conditions), wziąć widelce.
// Для блокирования отдельного философа.
// Инициализируется: 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 filozofowi nie udało się wziąć widelców, wtedy jego działania będą takie. Czeka na dostęp do stołu. Po uzyskaniu go próbuje wziąć widelce. Nie udało się. Oddaje dostęp do stołu (wzajemne wykluczenie). I przechodzi przez swój 'bramkę' (AutoResetEvent) (na początku są otwarte). Wchodzi z powrotem w cykl, ponieważ nie ma widelców. Próbuje je wziąć i zatrzymuje się przy swojej 'bramce'. Jakiś bardziej szczęśliwy sąsiad po prawej lub lewej, kończąc jedzenie, odblokowuje naszego filozofa, 'otwierając jego bramkę'. Nasz filozof przechodzi przez nią (i zamyka się za nim) po raz drugi. Próbuje po raz trzeci wziąć widelce. Udało się. I przechodzi przez swoją bramkę, aby zjeść obiad.
Gdy w takim kodzie wystąpią losowe błędy (zawsze występują), na przykład błędnie wskazany sąsiad lub stworzony ten sam obiekt AutoResetEvent dla wszystkich (Enumerable.Repeat), wtedy filozofowie będą czekać na programistów, ponieważ znajdowanie błędów w takim kodzie to dość trudne zadanie. Jeszcze jeden problem z tym rozwiązaniem to fakt, że nie gwarantuje, że jakiś filozof nie zacznie głodować.
Hybrydowe rozwiązania
Zbadaliśmy dwa podejścia do synchronizacji: jedno, kiedy pozostajemy w trybie użytkownika i działamy w pętli, oraz drugie, kiedy blokujemy wątek przez jądro. Pierwsza metoda jest dobra dla krótkich blokad, druga dla długich. Często wymaga się najpierw krótkiego oczekiwania na zmianę zmiennej w pętli, a potem zablokowania wątku, gdy oczekiwanie jest długie. To podejście jest realizowane w tzw. konstrukcjach hybrydowych. Tutaj mamy te same konstrukcje, co w trybie jądra, ale z pętlą w trybie użytkownika: SemaphoreSlim, ManualResetEventSlim i inne. Najbardziej popularną konstrukcją tutaj jest Monitor, ponieważ w C# jest znany wszystkim lock słownik. Monitor to ten sam semafor z maksymalną wartością 1 (muteks), ale z obsługą oczekiwania w pętli, rekurencji, wzorca Condition Variable (o nim poniżej) itd. Zobaczmy rozwiązanie z nim.
// Спрячем объект для Монитора от всех, чтобы без дедлоков.
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 znowu blokujemy cały stół do dostępu do widelców, ale teraz odblokowujemy wszystkie wątki jednocześnie, a nie sąsiadów, kiedy ktoś kończy jeść. Tzn. najpierw ktoś je i blokuje sąsiadów, a kiedy ten ktoś kończy, ale chce od razu zjeść ponownie, przechodzi w blokadę i budzi swoich sąsiadów, ponieważ czas oczekiwania jest krótszy.
W ten sposób unikamy zakleszczeń i głodzenia któregoś z filozofów. Używamy pętli do krótkiego oczekiwania, a blokujemy wątek na długi czas. Odblokowanie od razu wszystkich działa wolniej, niż gdyby było odblokowanie 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.
U lock słownik ma nieprzyjemne niespodzianki. Zaleca się używanie Monitor bezpośrednio [Richter] [Eric Lippert]. Jedną z nich jest to, że lock zawsze wychodzi z Monitor, nawet jeśli wystąpił wyjątek, a wtedy inny wątek może zmienić stan wspólnej pamięci. W takich przypadkach częściej lepiej jest zachować zakleszczenie lub w jakiś sposób bezpiecznie zakończyć program. Inną niespodzianką jest to, że Monitor używa bloków synchronizacji (SyncBlock), które znajdują się we wszystkich obiektach. Dlatego, jeśli wybierzesz niewłaściwy obiekt, można łatwo uzyskać zakleszczenie (na przykład, jeśli zrobisz blokadę na internacjonalizowanym ciągu). Używaj zawsze ukrytego obiektu do tego.
Wzorzec Condition Variable pozwala na bardziej zwięzłe zaimplementowanie oczekiwania na skomplikowany warunek. W .NET jest niedoskonaly, moim zdaniem, ponieważ powinno być tam kilka kolejek na różnych zmiennych (jak w Posix Threads), a nie na jednym locku. Wtedy można byłoby to zastosować dla wszystkich filozofów. Niemniej jednak, w tej formie pozwala na skrócenie kodu.
Wielu filozofów lub (196 tysięcy linii kodu, 11 autorów, 7 komiterów, 11 otwartych problemów); / await
Dobrze, teraz potrafimy efektywnie blokować wątki. Ale co jeśli mamy wielu filozofów? 100? 10000? Na przykład, otrzymaliśmy 100000 żądań do serwera www. Tworzenie wątku dla każdego żądania będzie nieefektywne, ponieważ tak wiele wątków nie będzie mogło być wykonywanych równolegle. Wykonywane będą tylko tyle, ile jest logicznych rdzeni (mam 4). A wszystkie pozostałe będą tylko marnować zasoby. Jednym z rozwiązań tego problemu jest wzorzec async / await. Jego idea polega na tym, że funkcja nie trzyma wątku, jeśli do jej kontynuacji trzeba coś poczekać. A gdy to coś się zdarza, wznawia swoje wykonywanie (ale niekoniecznie w tym samym wątku!). W naszym przypadku będziemy czekać na widelce.
SemaphoreSlim ma dla tego WaitAsync() metodę. Oto implementacja z użyciem tego wzorca.
// Запуск такой же, как раньше. Где-нибудь в программе:
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 ta (196 tysięcy linii kodu, 11 autorów, 7 komiterów, 11 otwartych problemów); / await jest transponowana do sprytnego automatu stanowego, który natychmiast zwraca swoje wewnętrzne Task. Przez niego można czekać na zakończenie metody, anulować ją i robić wszystko inne, co można zrobić z Task. Wewnątrz metody automat stanowy kontroluje wykonanie. Istota polega na tym, że jeśli nie ma opóźnienia, wykonanie jest synchroniczne, a jeśli jest, to wątek jest zwalniany. Dla lepszego zrozumienia najlepiej przyjrzeć się temu automatowi stanowemu. Można tworzyć łańcuchy z tych (196 tysięcy linii kodu, 11 autorów, 7 komiterów, 11 otwartych problemów); / await metod.
Przetestujemy. Praca 100 filozofów na maszynie z 4 logicznymi rdzeniami, 8 sekund. Poprzednie rozwiązanie z Monitor wykonywało tylko 4 pierwsze wątki, a pozostałe w ogóle nie były realizowane. Każdy z tych 4 wątków stał przez około 2 ms. A rozwiązanie z async / await realizowało wszystkie 100, przy tym średnio każdy czekał 6.8 sekundy. Oczywiście, w rzeczywistych systemach przestoje po 6 sekund są nieakceptowalne i lepiej nie obsługiwać tylu żądań w ten sposób. Rozwiązanie z Monitor okazało się całkowicie nie skalowalne.
Podsumowanie
Jak widać z tych niewielkich przykładów, .NET obsługuje wiele konstrukcji synchronizacji. Nie zawsze jednak oczywiste jest, jak ich używać. Mam nadzieję, że ten artykuł okazał się przydatny. Na tym kończymy, ale pozostaje jeszcze wiele interesujących rzeczy, takich jak kolekcje bezpieczne dla wątków, TPL Dataflow, programowanie reaktywne, model transakcji oprogramowania i inne.
Źródła
- Wizualizacja wątków:
- MSDN: , i wiele innych.
- [Richter] — CLR via C#, Jeffrey Richter
- [Eric Lippert] —
- Obraz — „Taniec wśród mieczy”, G. Semiradzki
Źródło: habr.com
