
Pozrime sa, ako funguje konkurentné a paralelné programovanie v .Net na príklade problému hladujúcich filozofov. Plán je taký, od synchronizácie vlákien/procesov až po model hercov (v ďalších častiach). Tento článok môže byť užitočný na prvé zoznámenie alebo na osvieženie svojich poznatkov.
Prečo by sme sa to vôbec mali naučiť? Tranzistory dosahujú svoju minimálnu veľkosť, Mooreov zákon sa dostáva na sklonok rýchlosti svetla, a preto rast spočíva v množstve tranzistorov, ktoré je možné vyrobiť. Zároveň rastie množstvo dát a používatelia očakávajú okamžité reakcie systémov. V tejto situácii sa „bežné“ programovanie, kde máme jedno vykonávajúce vlákno, už neukazuje ako efektívne. Je potrebné nejakým spôsobom riešiť problém súbežného alebo konkurentného vykonávania. Tento problém existuje na rôznych úrovniach: na úrovni vlákien, na úrovni procesov, na úrovni strojov v sieti (rozptýlené systémy). V .NET existujú kvalitné, časom overené technológie pre rýchle a efektívne riešenie takýchto úloh.
Úloha
Edsger Dijkstra kládol tento problém svojim študentom už v roku 1965. Ustálená formulácia je taká. Existuje určitý (zvyčajne päť) počet filozofov a rovnaký počet vidličiek. Sedia za okrúhlym stolom, vidličky sú medzi nimi. Filozofi môžu jesť zo svojich tanierov s nekonečným jedlom, premýšľať alebo čakať. Aby filozof mohol jesť, musí vziať dve vidličky (posledný zdieľa vidličku s prvým). Vzatie a ponechanie vidličky sú dva samostatné úkony. Všetci filozofi sú tichí. Úlohou je nájsť algoritmus, aby všetci premýšľali a boli sýti aj po 54 rokoch.
Najprv sa pokúsme vyriešiť tento problém pomocou zdieľaného priestoru. Vidličky ležia na spoločnom stole a filozofi ich jednoducho berú, keď sú k dispozícii, a vracajú ich späť. Tu sa objavujú problémy s synchronizáciou, kedy presne brať vidličky? čo robiť, ak vidlička nie je? a pod. Ale najprv spustime filozofov.
Na spustenie vlákien používame pool vlákien prostredníctvom Task.Run metódy:
var cancelTokenSource = new CancellationTokenSource();
Action create = (i) => RunPhilosopher(i, cancelTokenSource.Token);
for (int i = 0; i create(icopy), cancelTokenSource.Token);
}Pool vlákien je vytvorený za účelom optimalizácie vytvárania a odstraňovania vlákien. Tento pool má frontu úloh a CLR vytvára alebo odstraňuje vlákna na základe počtu týchto úloh. Jeden pool pre všetky AppDomain-y. Tento pool sa odporúča používať takmer vždy, pretože nie je potrebné sa zaoberať vytváraním, odstraňovaním vlákien, ich frontami a pod. Môže sa to robiť aj bez poolu, ale potom bude treba priamo používať Vlákno, je to vhodné v prípadoch, keď je potrebné zmeniť prioritu vlákna, keď máme dlhú operáciu, pre vlákno Foreground a pod.
Inými slovami, System.Threading.Tasks.Task trieda — je to to isté Vlákno, ale so všelijakými vymoženosťami: možnosť spúšťať úlohu po bloku iných úloh, vracať ich z funkcií, pohodlne ich prerušiť a mnohé ďalšie. Sú potrebné na podporu async/await konštrukcií (Task-based Asynchronous Pattern, syntaktický cukor pre čakanie na IO operáciu). O tom si ešte povieme.
CancelationTokenSource je tu potrebný, aby vlákno mohlo samo skončiť na signál vyvolávajúceho vlákna.
Problémy so synchronizáciou
Zablokovaní filozofi
Dobre, vieme vytvárať vlákna, poďme sa pokúsiť najesť:
// Кто какие вилки взял. К примеру: 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();
}
}Tu sa najprv pokúšame vziať ľavé a potom pravé vidlice a ak sa to podarí, tak jeme a vraciame ich späť. Vzatie jednej vidlice je atomárne, to znamená, že dve vlákna nemôžu vziať jednu naraz (nepresne: prvé číta, že vidlica je voľná, druhé tiež, prvé berie, druhé berie). Na to je potrebné Interlocked.CompareExchange, ktorý musí byť implementovaný pomocou inštrukcie procesora (TSL, XCHG), ktorá blokuje úsek pamäte pre atomárne sekvenčné čítanie a zápis. A SpinWait je ekvivalentné konštrukcii while(true) len s malou „magickou“ — vlákno zaberá procesor (Thread.SpinWait), ale niekedy odovzdáva riadenie inému vlákno (Thread.Yeild) alebo zaspáva (Thread.Sleep).
Ale toto riešenie nefunguje, pretože sa vlákna čoskoro (u mňa v priebehu sekundy) zablokujú: všetci filozofi vezmú svoju ľavú vidlicu, ale pravú nie. Pole forks teda má hodnoty: 1 2 3 4 5.

Na obrázku, zablokovanie vlákien (deadlock). Zelenou farbou — vykonanie, červenou — synchronizácia, šedou — vlákno spí. Rombmi je označený čas spustenia úloh.
Hlad filozofov
Aj keď na premýšľanie netreba veľa jedla, ale hlade môže kohokoľvek donútiť opustiť filozofiu. Skúsime simulovať situáciu hladovania vlákien v našej úlohe. Hladovanie je, keď vlákno pracuje, ale nevykonáva žiadnu podstatnú prácu, inými slovami, je to rovnaké ako deadlock, len teraz vlákno nespí, ale aktívne hľadá, ako by sa najesť, ale jedlo nie je. Aby sme predišli častému zablokovaniu, budeme vracať vidlicu späť, ak sa nám nepodarilo zobrať inú.
// То же что и в 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)
);
}V tomto kóde je dôležité, že dvaja z štyroch filozofov zabúdajú položiť svoju ľavú vidlicu. A tak sa stáva, že jedia viac jedla, zatiaľ čo ostatní začínajú hladovať, hoci vlákna majú rovnakú prioritu. Tu celkom nehľadujú, pretože zlí filozofi občas vracajú svoje vidlice späť. Podľa môjho názoru jedia dobrí približne päťkrát menej ako zlí. Tak malá chyba v kóde vedie k poklesu výkonu. Ešte je potrebné poznamenať, že existuje vzácna situácia, keď všetci filozofovia vezmú ľavú vidlicu, žiadna pravá nie je, položia ľavú, čakajú, znovu vezmú ľavú atď. Táto situácia je tiež hladovanie, viac podobná vzájomnému zablokovaniu. Nepodarilo sa mi ju zopakovať. Nižšie je obrázok situácie, keď dvaja zlí filozofi vzali obe vidlice, a dvaja dobrí hladujú.

Tu je vidno, že vlákna sa občas prebúdzajú a pokúšajú sa získať zdroj. Dve jadra zo štyroch nič nerobia (zelený graf na vrchu).
Smrť filozofa
No a ešte jedno riziko, ktoré môže prerušiť slávny obed filozofov — to je ak jeden z nich náhle umrie s vidlicami v rukách (a tak ho aj pochovajú). Potom budú susedia bez obeda. Môžete si sami vymyslieť kód pre tento prípad, napríklad vyhodením NullReferenceException po tom, ako filozof vezme vidlice. A mimochodom, výnimka nebude spracovaná a volajúci kód ju jednoducho nezachytí (na to AppDomain.CurrentDomain.UnhandledException a iné). Preto sú potrebné obsluhy chýb priamo vo vláknach a s korektným ukončením.
Čašník
Dobre, ako vyriešime tento problém s vzájomnými blokádami, hladovaním a úmrtiami? Budeme pripúšťať len jedného filozofa k vidliciam, pridáme vzájomné vylúčenie (mutual exclusion) vlákien pre túto oblasť. Ako to spraviť? Predpokladajme, že vedľa filozofov stojí čašník, ktorý povolí jednému filozofovi vziať vidlice. Ako spravíme tohto čašníka a ako filozofovia budú žiadať o jeho pomoc, to sú zaujímavé otázky.
Najjednoduchší spôsob je, keď filozofi budú jednoducho neustále žiadať čašníka o prístup k vidliciam. T.j. teraz filozofi nebudú čakať na vidlice vedľa seba, ale budú čakať alebo žiadať čašníka. Najprv použijeme na to iba User Space, v ňom nepoužívame prerušenia pre volanie akýchkoľvek procedúr z jadra (o tých neskôr).
Riešenia v užívateľskom priestore
Tu budeme robiť to isté, čo sme robili s jednou vidlicou a dvoma filozofmi, budeme sa točiť v cykle a čakať. Ale teraz to budú všetci filozofi a akoby len jedna vidlica, t.j. môžeme povedať, že jesť bude iba ten filozof, ktorý vzal túto „zlatú vidlicu“ od čašníka. Na tento účel použijeme SpinLock.
private static SpinLock spinLock = new SpinLock(); // Náš "čašník"
private void RunSpinLock(int i, CancellationToken token)
{
while (true)
{
// Vzájomná blokáda cez busy waiting. Voláme pred try, aby
// sme vyhodili výnimku v prípade chyby v samotnom SpinLock.
bool hasLock = false;
spinLock.Enter(ref hasLock);
try
{
// Tu môže byť len jedno vlákno (mutual exclusion).
forks[Left(i)] = i + 1; // Berieme vidlicu hneď, bez čakania.
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(); // Vyhneme sa problému so smrťou filozofa.
}
Think(i);
if (token.IsCancellationRequested)
break;
}
}SpinLock je to blokátor, s, povedzme, rovnakým while(true) { if (!lock) break; }, ale s ešte väčšou „magickou“, než v SpinWait (ktorý sa tam používa). Teraz dokáže počítať čakajúcich, trochu ich uspať a veľa ďalších vecí. V podstate robí všetko, čo je možné na optimalizáciu. Ale treba pamätať, že je to stále ten istý aktívny cyklus, ktorý žerie zdroje procesora a udržiava tok, ktorý môže viesť k vyhladovaniu, ak sa jeden z filozofov stane prioritnejším ako ostatní, ale nemá zlaté vidlice (problém Priority Inversion). Preto ho používame len na veľmi krátke zmeny v spoločnej pamäti, bez akýchkoľvek externých volaní, vnorených zámkov a iných prekvapení.

Obrázok pre SpinLock. Toky neustále „bojujú“ o zlatú vidlicu. Vyskytujú sa zlyhania — na obrázku vyznačená oblasť. Jadra nie sú plne využité: len asi 2/3 týmito štyrmi tokmi.
Ďalšie riešenie by bolo použiť len Interlocked.CompareExchange s tým istým aktívnym čakaním, ako je uvedené v predchádzajúcom kóde (vo vyhladovaní filozofov), ale ako už bolo povedané, teoreticky to môže viesť k zablokovaniu.
O Interlocked je potrebné povedať, že tam nie je len CompareExchange, ale aj ďalšie metódy na atomárne čítanie a zápis. A prostredníctvom opakovania zmien v prípade, že iný tok stihne uskutočniť svoje zmeny (čítanie 1, čítanie 2, zápis 2, zápis 1 zle), môže byť použitý na zložité zmeny jednej hodnoty (Interlocked Anything vzor).
Riešenia v režime jadra
Aby sme sa vyhli strate zdrojov v cykle, pozrime sa, ako môžeme zablokovať tok. Inými slovami, pokračujúc v našom príklade, pozrime sa, ako čašník uspať filozofa a zobudí ho až vtedy, keď bude potrebné. Najprv si pozrime, ako to urobiť cez režim jadra operačného systému. Všetky štruktúry tam bývajú často pomalšie ako tie, ktoré sú v užívateľskom priestore. Pomalšie niekoľkokrát, napríklad AutoResetEvent môže byť 53-krát pomalšie SpinLock [Richter]. Ale s ich pomocou je možné synchronizovať procesy po celej sústave, riadené alebo nie.
Hlavná konštrukcia tu je semafor, ktorý navrhol Dijkstra pred viac ako polstoročím. Semafor je, zjednodušene povedané, kladné celé číslo, ktorého hodnotu riadi systém, a dve operácie na ňom, — zvýšiť a znížiť. Ak nie je možné znížiť na nulu, volajúci vláknový proces je zablokovaný. Keď sa číslo zvýši nejakým iným aktívnym vláknom/procesom, potom sú vláknové procesy prepustené a semafor sa opäť zníži o počet uvoľnených. Môžeme si to predstaviť ako vlaky na úzkej ceste so semaforom. .NET ponúka niekoľko konštrukcií s podobnými funkciami: AutoResetEvent, ManualResetEvent, Mutex a sám Semaphore. Budeme používať AutoResetEvent, to je najjednoduchšia z týchto konštrukcií: iba dve hodnoty 0 a 1 (false, true). Jej metóda WaitOne() blokuje volajúce vlákno, ak bola hodnota 0, a ak je 1, tak ju znižuje na 0 a prepúšťa ho. A metóda Set() zvyšuje na 1 a prepúšťa jedno čakajúce, ktoré opäť znižuje na 0. Funguje to ako turniket v metre.
Zložme riešenie a budeme používať zablokovanie pre každého filozofa, a nie pre všetkých naraz. T.j. teraz môže byť súčasne niekoľko filozofov, a nie jeden. Ale opäť zablokujeme prístup k stolu, aby sme správne, vyhýbajúc sa závodom (race conditions), vzali vidlice.
// Для блокирования отдельного философа.
// Инициализируется: 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 sme pochopili, čo sa tu deje, zvážme situáciu, keď filozofovi sa nepodarilo zobrať vidlice, vtedy jeho kroky budú nasledovné. Čaká na prístup k stolu. Po jeho získaní sa pokúša zobrať vidlice. Nepodarilo sa. Odovzdáva prístup k stolu (vzájomná výnimka). A prechádza svojím „turniketom“ (AutoResetEvent) (na začiatku sú otvorené). Znovu sa dostáva do cyklu, pretože nemá vidlice. Pokúša sa ich vziať a zastaví sa pri svojom „turniket“. Nejaký šťastnejší sused vpravo alebo vľavo, ktorý skončil s jedlom, odblokuje nášho filozofa, „otvárajúc jeho turniket“. Náš filozof prechádza ním (a ten sa za ním zavrie) po druhý raz. Pokúša sa po tretí raz zobrať vidlice. Úspešne. A prechádza svojím turniketom, aby sa najedol.
Keď sa v takomto kóde vyskytnú náhodné chyby (vždy sú prítomné), napríklad keď je nesprávne zadaný sused alebo je vytvorený ten istý objekt AutoResetEvent pre všetkých (Enumerable.Repeat), potom filozofi už budú čakať na vývojárov, pretože hľadanie chýb v takomto kóde je dosť zložitá úloha. Ďalším problémom s týmto riešením je, že nezaručuje, že nejaký filozof nezačne hladovať.
Hybridné riešenia
Preskúmali sme dva prístupy k synchronizácii: keď zostávame v režime používateľa a krútime v cykle, a keď blokujeme vlákno cez jadro. Prvá metóda je dobrá pre krátke zámky, druhá pre dlhé. Často je potrebné najprv krátko čakať na zmenu premennej v cykle a potom zablokovať vlákno, keď je čakanie dlhé. Tento prístup je implementovaný v tzv. hybridných konštrukciách. Tu sú rovnaké konštrukcie, aké boli pre režim jadra, ale teraz s cyklom v režime používateľa: SemaphorSlim, ManualResetEventSlim a iné. Najpopulárnejšia konštrukcia tu je Monitor, pretože v C# existuje známy lock syntax. Monitor je to ten istý semafor s maximálnou hodnotou 1 (mutex), ale s podporou čakania v cykle, rekurzie, vzoru podmienkovej premennej (o ňom nižšie) a iného. Pozrime sa na riešenie s ním.
// Спрячем объект для Монитора от всех, чтобы без дедлоков.
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);
}
}Tu opäť blokujeme celý stôl pre prístup k vidliciam, ale teraz naraz odblokujeme všetky vlákna a nie susedov, keď niekto skončí s jedením. To znamená, že najprv niekto je a blokuje susedov, a keď tento niekto skončí, ale chce hneď znova jesť, ide do zámku a zobudí svojich susedov, pretože jeho čas čakania je kratší.
Týmto sa vyhýbame mŕtvym uzlom a hladovaniu nejakého filozofa. Používame cyklus pre krátke čakanie a blokujeme vlákno pre dlhé. Odblokovanie všetkých naraz je pomalšie, než keby bolo odblokovanie iba suseda, ako v riešení s AutoResetEvent, ale rozdiel by nemal byť veľký, pretože by sa vlákna mali najprv nachádzať v režime používateľa.
U lock v syntaxi sú nepríjemné prekvapenia. Odporúča sa používať Monitor priamo [Richter] [Eric Lippert]. Jedným z nich je to, že lock vždy opúšťa Monitor, aj keď došlo k výnimke, a potom iné vlákno môže zmeniť stav zdieľanej pamäte. V takýchto prípadoch je častejšie lepšie zamknúť sa v mŕtvom uzle alebo nejako bezpečne ukončiť program. Ďalším prekvapením je to, že Monitor používa bloky synchronizácie (SyncBlock), ktoré sú vo všetkých objektoch. Preto, ak sa zvolí nevhodný objekt, môže ľahko vzniknúť mŕtvy uzol (napríklad, ak sa zamkne na internovanú reťazec). Vždy používame skrytý objekt na toto.
Pattern Condition Variable umožňuje efektívnejšie čakať na nejakú zložitú podmienku. Na .NET je podľa môjho názoru nedokonalý, pretože tam by malo byť viac radov na viacerých premenných (ako v Posix Threads), a nie na jednom zamykaní. Potom by sa pre všetkých filozofov mohli urobiť. No aj v tejto forme umožňuje skrátiť kód.
Veľa filozofov alebo async / await
Dobre, teraz vieme efektívne blokovať vlákna. Ale čo ak máme veľa filozofov? 100? 10000? Napríklad, dostali sme 100000 požiadaviek na webový server. Vytvárať vlákno pre každú požiadavku by bolo nákladné, pretože toľko vlákien sa súčasne nevykoná. Vykoná sa len toľko, koľko logických jadier (mám 4). A všetky ostatné len berú zdroje. Jedno z riešení tejto problémy je vzor async / await. Jeho myšlienka je v tom, že funkcia neudržiava vlákno, ak na jej pokračovanie potrebujeme niečo počkať. A keď sa to niečo stane, obnovení sa jej vykonanie (ale nie nutne v tom istom vlákne!). V našom prípade budeme čakať na vidlicu.
SemaphoreSlim má pre to WaitAsync() metódu. Tu je implementácia s použitím tohto vzoru.
// Запуск такой же, как раньше. Где-нибудь в программе:
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();
}Metóda sa async / await transformuje do zložitých konečných automatov, ktorý okamžite vráti svoj interný Task. Cez ňu môžeme čakať na dokončenie metódy, zrušiť ju a všetko ostatné, čo môžeme robiť s Task. Vnútri metódy, konečný automat kontroluje vykonávanie. Podstatou je, že ak nie je oneskorenie, vykonávanie je synchróne, a ak je, vlákno sa uvoľní. Pre lepšie pochopenie je lepšie sa pozrieť na tento konečný automat. Môžeme vytvárať reťazce z týchto async / await metód.
Otestujme. Práca 100 filozofov na stroji so 4 logickými jadrami, 8 sekúnd. Predchádzajúce riešenie s Monitorom vykonávalo len prvé 4 vlákna, zatiaľ čo ostatné sa vôbec nevykonávali. Každé z týchto 4 vlákien čakalo asi 2 ms. A riešenie s async / await vykonávalo všetkých 100, pričom v priemere každé čakalo 6.8 sekundy. Samozrejme, v reálnych systémoch je niekoľko sekúnd čakania neprijateľné a je lepšie takto nezpracovávať toľko požiadaviek. Riešenie s Monitorom sa ukázalo byť úplne neskalovateľné.
Záver
Ako vidno z týchto malých príkladov, .NET podporuje mnoho synchronizačných konštrukcií. Nie vždy je však jasné, ako ich používať. Dúfam, že tento článok bol užitočný. Pre zatiaľ končíme, ale zostáva ešte veľa zaujímavého, napríklad kolekcie zabezpečené proti prúdeniu, TPL Dataflow, Reaktívne programovanie, model Softvérovej transakcie a ďalšie.
Zdroje
- Vizualizácia prúdov:
- MSDN: , a mnohé ďalšie.
- [Richter] — CLR via C#, Jeffrey Richter
- [Eric Lippert] —
- Obrázok — „Tanec medzi mečmi“, H. Semiradsky
Zdroj: habr.com
