
Vaatame, kuidas on korraldatud konkurentsi- ja paralleel-programmeerimine .Net-is, kasutades näitena filosoofide lõunaaja probleemi. Plaan on selline: alates threadide/protsesside sünkroniseerimisest kuni näitlejate mudelini (järgmistest osadest). Artikkel võib olla kasulik nii esmakordseks tutvumiseks kui ka teadmiste värskendamiseks.
Miks see oskus üldse vajalik on? Transistore saavutavad oma minimaalset suurust, Moore'i seadus põrkub valguse kiirusest tingitud piirangutega, seega suureneb transistorite hulk. Sellega samal ajal kasvab ka andmete hulk ja kasutajad ootavad süsteemide kohest reaktsiooni. Sellises olukorras ei ole 'tavaline' programmeerimine, kus meil on üks täitmise niit, enam efektiivne. Probleemi tuleb kuidagi lahendada, et saavutada korraga või konkurentsiti täitmise. See probleem eksisteerib erinevatel tasanditel: threadide tasemel, protsesside tasemel, masinate tasemel võrgus (jaotatud süsteemid). .NET-is on kvaliteetsed, aastate jooksul tõestatud tehnoloogiad, mis pakuvad kiireid ja efektiivseid lahendusi sellistele ülesannetele.
Ülesanne
Edsger Dijkstra esitas selle probleemi oma õpilastele juba 1965. aastal. Määratletud sõnastus on selline: on mingisugune (tavaliselt viis) filosoofide arvu ja sama palju kahvleid. Nad istuvad ümber ümmarguse laua, kahvlid on nende vahel. Filosoofid saavad süüa oma taldrikutest, millel on lõputult toitu, mõelda või oodata. Filosoofile, et süüa, tuleb võtta kaks kahvlit (viimane jagab kahvli esimesega). Kahvli võtmine ja panemine on kaks eraldi toimingut. Kõik filosoofid on vaiksed. Ülesanne on leida selline algoritm, et nad kõik mõtleksid ja oleksid külluslikult toidetud isegi pärast 54 aastat.
Esialgu proovime seda probleemi lahendada jagatud ruumi kasutamise kaudu. Kahvlid asuvad ühisel laual ja filosoofid võtavad need lihtsalt siis, kui nad on saadaval, ja panevad tagasi. Siin tekivad sünkroniseerimise probleemid: millal täpselt kahvleid võtta? Mida teha, kui kahvlit pole? ja jne. Aga esmalt laseme filosoofidel liikuma.
Threadide käivitamiseks kasutame threadide pooti läbi Task.Run meetodi:
var cancelTokenSource = new CancellationTokenSource();
Action create = (i) => RunPhilosopher(i, cancelTokenSource.Token);
for (int i = 0; i create(icopy), cancelTokenSource.Token);
}Puhvool on loodud, et optimeerida voogude loomist ja kustutamist. Sellel puhvil on ülesannete järjekord ja CLR loob või kustutab vooge ülesannete arvu põhjal. Üks puhv on kõikide AppDomainide jaoks. Seda puhvi on peaaegu alati kasulik kasutada, kuna ei pea muretsema voogude loomise, kustutamise, nende järjekordade jms pärast. Saab ka ilma puhvita, kuid siis tuleb otse kasutada. Thread, see on mõistlik olukordades, kus tuleb vahetada voolu prioriteeti, kui meil on pikk operatsioon, Foreground voolu jaoks jne.
Teisisõnu, System.Threading.Tasks.Task klass on sama, Thread, kuid igasuguste mugavustega: võimalus käivitada ülesanne pärast teiste ülesannete plokki, tagastada need funktsioonidest, mugav neid katkestada jne. Need on vajalikud async/await konstruktsioonide toetamiseks (ülesande põhine asünkroonne musternäide, süntaktiline suhkrusool IO operatsiooni ootamiseks). Sellega räägime veel hiljem.
CancelationTokenSource on siin vajalik, et voog saaks end ise lõpetada kutsuva voolu signaali järgi.
Sünkroonimisprobleemid
Blokeeritud filosoofid
Hästi, me oskame luua vooge, proovime nüüd lõunat süüa:
// Кто какие вилки взял. К примеру: 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();
}
}Siin proovime esmalt võtta vasakut ja seejärel paremat kahvlit ning kui õnnestub, siis sööme ja paneme need tagasi. Ühe kahvli haaramine on aatomaarne, st kaks voogu ei saa mingil ajal sama kahvlit võtta (vale: esimene loeb, et kahvel on vabalt, teine samuti, esimene võtab, teine võtab). Selleks Interlocked.CompareExchange, mis peab olema rakendatud koos protsessori instruktsiooniga (TSL, XCHG), mis blokeerib mäluosa aatomaarseks järjestikuseks lugemiseks ja kirjutamiseks. Ja SpinWait on ekvivalentne konstruktsiooniga while(true) ainult väikese "maagiaga" — voog hõivab protsessori (Thread.SpinWait), kuid mõnikord annab juhtimise teisele voole (Thread.Yeild) või uinub (Thread.Sleep).
Kuid see lahendus ei toimi, kuna vood peagi (minu puhul sekundi jooksul) blokeerivad: kõik filosoofid võtavad oma vasaku kahvli, kuid paremat ei. Kahvlite massiivil on siis väärtused: 1 2 3 4 5.

Joonisel, voogude blokeerimine (deadlock). Roheliste värvidega — täitmine, punasega — sünkroniseerimine, hall — voog magab. Rombidega on tähistatud ülesannete käivitamise aeg.
Filosoofide nälg
Kuigi palju mõelda ei pea, et toitu saada, suudab nälg sundida kedagi loobuma filosoofiast. Proovime simuleerida olukorda, kus niisked niidid meie ülesandes nälgivad. Nälgimine on siis, kui niit töötab, kuid ei tee olulist tööd; teisisõnu, see on sama, mis surnud lukustus, kuid nüüd on niit aktiivne ja otsib söömise võimalusi, kuid toitu pole. Et vältida sagedast blokeerimist, paneme kahvli tagasi, kui ei suuda teist võtta.
// То же что и в 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)
);
}Selles koodis on oluline, et kaks neljast filosoofist unustavad panna oma vasaku kahvli tagasi. Seetõttu söövad nad rohkem toitu, samas kui teised hakkavad nälgima, kuigi niitide prioriteedid on võrdsed. Siin nad ei nälgi päriselt, kuna halvad filosoofid panevad oma kahvlid mõnikord tagasi. Tundub, et head söövad umbes 5 korda vähem kui halvad. Nii et väike viga koodis viib jõudluse languseni. Siin tasub märkida, et võib esineda haruldane olukord, kus kõik filosoofid võtavad vasaku kahvli, kuid paremat pole, nad panevad vasaku tagasi, ootavad ja jälle võtavad vasaku jne. See olukord on samuti nälgimine, rohkem sarnaneb see vastastikusele blokeerimisele. Ma ei suutnud seda korduda. Allpool on joonis olukorrast, kus kaks halba filosoofi on võtnud mõlemad kahvlid ja kaks head nälgivad.

Siit näeme, et niidid ärkavad vahel ja proovivad ressursse saada. Kaks neljast tuumast ei tee midagi (roheline diagramm ülal).
Filosoofi surm
Ja veel üks probleem, mis võib katkestada filosoofide uhke lõuna, on see, kui üks neist äkitselt sureb kahvlitega käes (ja ta ka maetakse nii). Siis jäävad naabrid lõunast ilma. Selle juhtumi jaoks saate ise koodi mõelda, näiteks visatakse välja NullReferenceException pärast seda, kui filosoof võtab kahvlid. Muide, erand ei jää püüdmata ja kutse kood ei püüa seda lihtsalt (selle jaoks AppDomain.CurrentDomain.UnhandledException jne.). Seetõttu on vigade käsitlemine vajalik ise niidides ja korrektsel lõpetamisel.
Garson
Kuidas saame lahendada selle vastastikuste lukustuste, nälgimise ja surmade probleemi? Lubame ainult ühel filosoofil kahvlid kätte saada ja lisame sellele kohale vastastikuse välistamise. Kuidas seda teha? Oletame, et filosoofide kõrval on ettekandja, kes annab loa mõnele ühele filosoofile kahvlid võtta. Kuidas me teeme selle ettekandja ja kuidas filosoofid teda paluvad, on huvitavad küsimused.
Lihtsaim viis on see, et filosoofid paluvad lihtsalt pidevalt ettekandjale luba kahvlitega tegelemiseks. St. nüüd ei pea filosoofid ootama kahvlit kõrval, vaid nad ootavad või paluvad ettekandjat. Alguses kasutame selleks ainult kasutajaruum, kus me ei kasuta katkestusi, et kutsuda mingeid protseduure tuumas (nendest allpool).
Lahendused kasutajaruumis
Siin teeme sama, mida varem ühe kahvli ja kahe filosoofiga, keerleme tsüklis ja ootame. Kuid nüüd on need kõik filosoofid ja kui võiks öelda, et ainult üks kahvel, st. võib öelda, et söömisega tegeleb ainult see filosoof, kes on saanud selle "kuldkahvli" ettekandjalt. Selleks kasutame SpinLocki.
private static SpinLock spinLock = new SpinLock(); // Meie "ettekandja"
private void RunSpinLock(int i, CancellationToken token)
{
while (true)
{
// Vastastikune lukustus busy waiting kaudu. Kutsume enne try'd, et
// visata välja erand, kui SpinLockis on viga.
bool hasLock = false;
spinLock.Enter(ref hasLock);
try
{
// Siin võib olla ainult üks lõng (vastastikune välistamine).
forks[Left(i)] = i + 1; // Võtame kahvli kohe, ilma ootamata.
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(); // Vältimaks filosoofi surma probleemi.
}
Think(i);
if (token.IsCancellationRequested)
break;
}
}SpinLock see on lukustusmehhanism, ütleme, sama while(true) { if (!lock) break; }, aga veel suurema "maagia" kui SpinWait (mida seal kasutatakse). Nüüd oskab ta oodata, natuke magama panna ja palju muud. Ühesõnaga teeb ta kõik võimaliku optimeerimise nimel. Kuid tuleb meeles pidada, et see on ikka sama aktiivne tsükkel, mis tarbib protsessori ressursse ja hoiab voogu, mis võib viia nälgimiseni, kui üks filosoof muutub prioriteetsemaks kui teised, aga ei oma kuldset kahvlit (Priority Inversion probleem). Seega kasutame seda ainult väga väga lühiajaliste muutuste tegemiseks üldises mälus, ilma igasuguste väliste kutsungite, sisemiste lukustuste ja teiste üllatusteta.

Joonis SpinLock. Voogud „võitlevad” pidevalt kuldse kahvli pärast. Toimuvad tõrked — joones esile tõstetud ala. Südamikke ei kasutata täielikult: neid nelja voogu kasutatakse ainult umbes 2/3 ulatuses.
Teine lahendus oleks siin kasutada ainult Interlocked.CompareExchange sama aktiivse ootamise meetodit, nagu on näidatud ülaltoodud koodis (nälgivad filosoofid), kuid nagu juba öeldud, võib see teoreetiliselt viia ummistumiseni.
Küsimus Interlocked tuleb öelda, et seal ei ole ainult CompareExchange, vaid ka teised meetodid aatomaarse lugemise ja kirjutamise jaoks. Ja primitiivse muutmise kaudu, juhul kui teine voog õnnestub teha oma muudatused (lugemine 1, lugemine 2, kirjutamine 2, kirjutamine 1 halb), saab seda kasutada ühe väärtuse keerukate muudatuste jaoks (Interlocked Anything muster).
Kernel režiimis
Kuna ressursikaotuse vältimiseks tsüklis vaatame, kuidas voogu blokeerida. Teisisõnu, jätkates meie näidet, vaatame, kuidas ettekandja filosoofi magama panna ja äratada teda ainult siis, kui on vaja. Esiteks uurime, kuidas seda teha operatsioonisüsteemi kernelirežiimi kaudu. Kõik struktuurid seal on sageli aeglasemad kui need, mis on kasutajaruumis. Aeglasem mitu korda, näiteks AutoResetEvent võib olla 53 korda aeglasem SpinLock [Richter]. Kuid nende abil saab sünkroniseerida protsesse kogu süsteemi ulatuses, olgu need siis juhitud või mitte.
Põhiline struktuur siin on semafor, mille esitas Dijkstra rohkem kui pool sajandit tagasi. Semafor on, lihtsustatult öeldes, positiivne täisarv, mida haldab süsteem, ning kaks sellel põhinevat operatsiooni: suurendada ja vähendada. Kui vähenemine ei ole võimalik, jõuab nulli, siis blokeeritakse kutsuv niit. Kui mõni teine aktiivne niit/protsess suurendab arvu, siis lastakse niidid läbi, ja semafor väheneb taas möödunud arvuga. Seda võib kujutada kui teid kitsastes kohtades semaforiga. .NET pakub mitmeid struktuure, millel on sarnased funktsioonid: AutoResetEvent, ManualResetEvent, Mutex ja ise Semaphore. Me kasutame AutoResetEvent, see on kõige lihtsam neist struktuuridest: ainult kaks väärtust 0 ja 1 (vale, tõene). Selle meetod WaitOne() blokeerib kutsuva niidi, kui väärtus oli 0, kuid kui 1, siis alandab selle 0-ni ja laseb selle läbi. Ja meetod Set() suurendab 1-ni ja laseb ühe ootava läbida, kes alandab selle jälle 0-ks. Käitub nagu metroo turnikeed.
Kompleksime lahendust ja kasutame lukustust igale filosoofile, mitte kõigile korraga. St nüüd võivad mitmed filosoofid olla korraga, mitte ainult üks. Kuid me blokeerime taas juurdepääsu lauale, et korrektselt, vältides võidujooksu (race conditions), haarata kahvlid.
// Для блокирования отдельного философа.
// Инициализируется: 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();
}Et mõista, mis siin toimub, vaadake olukorda, kus filosoof ei suutnud haarata kahvleid, siis tema tegevused on sellised. Ta ootab juurdepääsu lauale. Kui ta selle saab, proovib ta haarata kahvleid. Ei õnnestu. Ta annab juurdepääsu lauale tagasi (vastastikune erand). Ja läheb läbi oma "turnikee" (AutoResetEvent) (alguses on need avatud). Ta satub jälle tsüklisse, kuna tal pole kahvleid. Proovib neid haarata ja peatub oma "turnikee" juures. Mõni rohkem õnnelik naaber paremal või vasakul, kes on söömise lõpetanud, vabastab meie filosoofi, "avatades tema turnikee". Meie filosoof läbib selle (ja see sulgub tema järel) teist korda. Proovib kolmandat korda kahvleid haarata. Edukalt. Ja läbib oma turnikee, et lõunatada.
Kui sellises koodis tekivad juhuslikud vead (need on alati olemas), näiteks on vale naaber või on loodud sama objekt AutoResetEvent kõigile (Enumerable.Repeat), siis hakkavad filosoofid ootama arendajaid, kuna vigade leidmine sellises koodis on üsna keeruline ülesanne. Veel üks probleem selle lahendusega on see, et see ei garanteeri, et mõni filosoof ei hakkaks nälgima.
Hübriidsed lahendused
Oleme uurinud kahte lähenemist sünkroniseerimisele, kui jääme kasutaja režiimi ja pöörleme tsüklis, ja kui blokeerime voolu tuumas. Esimene meetod on hea lühikeste blokeeringute jaoks, teine pikaajaliste jaoks. Sageli on vaja kõigepealt lühikeseks ajaks oodata muutust muutuja väärtuses tsüklis ja seejärel blokeerida voog, kui ootamine kestab kaua. See lähenemine on rakendatud nn hübriidstruktuurides. Siin on samad konstruktsioonid, mis olid tuumarežiimi jaoks, kuid nüüd kasutame tsüklit kasutaja režiimis: SemaphorSlim, ManualResetEventSlim ja teised. Kõige populaarsem konstruktsioon siin on Monitor, kuna C#-is on tuntud lock süntaks. Monitor See on sama semafor maksimaalse väärtusega 1 (mütseks), kuid toetab ooteaega tsüklis, rekursiooni, Condition Variable mustrit (sellest allpool) ja teisi. Vaatame lahendust selle abil.
// Спрячем объект для Монитора от всех, чтобы без дедлоков.
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);
}
}Siin blokeerime taas täielikult laua, et anda ligipääs kahvlitele, kuid nüüd vabastame kõik korraga vood, mitte naabreid, kui keegi lõpetab söömise. See tähendab, et kõigepealt keegi sööb ja blokeerib naabreid ning kui see keegi lõpetab, kuid tahab kohe uuesti süüa, siis läheb ta blokeeringusse ja äratab oma naabreid, kuna tema ootamiseks on vähem aega.
Nii vältime deadlock'e ja mõne filosoofi nälgimist. Kasutame tsüklit lühikeseks ootamiseks ja blokeerime voolu pikaajaliseks. Kõigi samaaegne vabastamine toimib aeglasemalt kui naabri vabastamine, nagu lahenduses, AutoResetEvent, kuid erinevus ei tohiks olla suur, kuna vood peavad esmalt jääma kasutaja režiimi.
Uus lock Süntaksis on ebameeldivaid üllatusi. Soovitatakse kasutada Monitor otseselt [Richter] [Eric Lippert]. Üks neist on see, et lock väljub alati Monitor, isegi kui on olnud erand, ja siis võib teine voog muuta jagatud mälu seisundit. Sellistel juhtudel on sageli parem lülituda deadlock'i või kuidagi ohutult lõpetada programm. Teine üllatus on see, et Monitor kasutab sünkroonimisblokke (SyncBlock), mis on kõigis objektides. Seetõttu, kui valida vale objekt, võib kergesti tekkida deadlock (näiteks, kui lukustada interneeritud string). Kasutame selle jaoks alati varjatud objekti.
Condition Variable muster võimaldab lühemalt teostada ootamist keerulise tingimuse jaoks. .NET-is on see minu arvates puudulik, sest seal peaks olema mitu järjekorda mitme muutuja jaoks (nagu Posix Threads'is), mitte ainult ühes lukus. Siis oleks võimalik need luua kõikide filosoofide jaoks. Kuid isegi sellisena võimaldab see koodi lühendada.
Palju filosoofe või async / await
Hästi, nüüd oskame tõhusalt niidu blokeerida. Aga mis siis, kui meil muutub palju filosoofe? 100? 10000? Näiteks, kui saime 100000 päringut veebiserverile. Iga päringu jaoks ühe niidu loomine oleks kulukas, sest nii palju niite ei saa paralleelselt töötada. Töötab ainult nii palju, kui on loogilisi tuumasid (mulle on neid 4). Ja kõik ülejäänud võtavad lihtsalt ressursse. Üks lahendusi sellele probleemile on async / await muster. Selle idee on, et funktsioon ei hoia niitu, kui selle jätkamiseks on midagi oodata. Ja kui see midagi juhtub, jätkab ta oma täitmist (aga mitte tingimata samas niidis!). Meie puhul ootame kahvlit.
SemaphoreSlim on selle jaoks WaitAsync() meetod. Siin on rakendus, mis kasutab seda mustrit.
// Запуск такой же, как раньше. Где-нибудь в программе:
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();
}Meetodiga async / await muudetakse nutikaks lõppautomaatseks, mis tagastab kohe oma sisemise Task. Selle kaudu saab oodata meetodi lõpetamist, selle tühistada ja kõik muud asjad, mida saab teha Task'iga. Meetodi sees kontrollib lõppautomaat täitmist. Põhimõte on see, et kui ei ole viivitust, siis on täitmine sünkrone, ja kui on, siis vabaneb niit. Selle parema arusaamise jaoks on parem vaadata seda lõppautomaat. Nendest async / await meetoditest saab luua ahelaid.
Testime. 100 filosoofi töö masinal, millel on 4 loogilist tuuma, 8 sekundiga. Eelmine lahendus Monitoriga täitis ainult 4 esimest niiti, kuid ülejäänud ei töötanud üldse. Igaühel neist 4 niidist oli ligikaudu 2 ms seisak. Async / await lahendus täitis aga kõik 100 ja keskmiselt ootas igaühel 6.8 sekundit. Loomulikult pole reaalsetes süsteemides 6 sekundi seiskumine vastuvõetav ja parem on mitte töötleda nii palju päringuid. Lahendus Monitoriga osutus üldse mitte skaleeritavaks.
Kokkuvõte
Nagu nendest väikestest näidetest näha, toetab .NET paljusid sünkroniseerimise konstruktsioone. Kuid mitte alati pole selge, kuidas neid kasutada. Loodan, et see artikkel on olnud kasulik. Praeguseks jätame selle teemakäsitluse, kuid palju huvitavat on veel tulemas, näiteks kõrnuskogud, TPL Dataflow, reaktiivne programmeerimine, tarkvara tehingu mudel jne.
Allikad
- Voogude visualiseerimine:
- MSDN: , jne.
- [Richter] — CLR via C#, Jeffrey Richter
- [Erik Lippert] —
- Pilt - 'Tants mõõkade seas', G. Semiradski
Allikas: habr.com
