Goed gevoede filosofen of competitieve .NET-programmering
Laten we eens kijken hoe gelijktijdig en parallel programmeren werkt in .Net, waarbij we het Philosophers Dining Problem als voorbeeld gebruiken. Het plan is dit, van de synchronisatie van threads/processen, tot het actormodel (in de volgende delen). Het artikel kan nuttig zijn voor de eerste kennismaking of om uw kennis op te frissen.
Edsger Dijkstra legde dit probleem al in 1965 voor aan zijn studenten. De gangbare formulering is als volgt. Er is een bepaald (meestal vijf) aantal filosofen en hetzelfde aantal vorken. Ze zitten aan een ronde tafel, vorken tussen hen in. Filosofen kunnen van hun bord met eindeloos eten eten, nadenken of wachten. Om een ββfilosoof te eten, moet je twee vorken nemen (de laatste deelt de vork met de eerste). Een vork oppakken en neerleggen zijn twee afzonderlijke handelingen. Alle filosofen zwijgen. De taak is om zo'n algoritme te vinden dat ze allemaal zouden denken en zelfs na 54 jaar vol zouden zijn.
Laten we eerst proberen dit probleem op te lossen door een gedeelde ruimte te gebruiken. De vorken liggen op de gemeenschappelijke tafel en de filosofen pakken ze gewoon als ze liggen en leggen ze weer terug. Hier zijn er problemen met synchronisatie, wanneer precies surebets nemen? wat als er geen vork is? enz. Maar laten we eerst beginnen met de filosofen.
Om threads te starten, gebruiken we een thread pool through Task.Run methode:
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);
}
Met andere woorden, System.Threading.Tasks.Task klasse is hetzelfde Thread, maar met allerlei gemakken: de mogelijkheid om een ββtaak uit te voeren na een blok met andere taken, ze terug te halen uit functies, ze gemakkelijk te onderbreken en meer. enz. Ze zijn nodig om asynchrone / wachtende constructies te ondersteunen (taakgebaseerd asynchroon patroon, syntactische suiker voor wachten op IO-bewerkingen). We zullen hier later over praten.
CancelationTokenSource hier is het nodig zodat de thread zichzelf kan beΓ«indigen op het signaal van de oproepende thread.
Maar deze oplossing werkt niet, omdat de stromen zijn al snel (voor mij binnen een seconde) geblokkeerd: alle filosofen nemen hun linkervork, maar niet de rechter. De forks-array heeft dan de waarden: 1 2 3 4 5.
In de figuur, threads blokkeren (deadlock). Groen - uitvoering, rood - synchronisatie, grijs - de draad slaapt. De ruiten geven de starttijd van Taken aan.
De honger van de filosofen
Hoewel het niet nodig is om vooral veel aan eten te denken, zorgt honger ervoor dat iedereen de filosofie opgeeft. Laten we proberen de situatie van uithongering van threads in ons probleem te simuleren. Uithongering is wanneer een draad loopt, maar zonder noemenswaardig werk, met andere woorden, dit is dezelfde impasse, alleen nu slaapt de draad niet, maar is actief op zoek naar iets om te eten, maar er is geen eten. Om frequente blokkering te voorkomen, plaatsen we de vork terug als we geen andere kunnen nemen.
Het belangrijkste aan deze code is dat twee op de vier filosofen vergeten hun linkervork neer te leggen. En het blijkt dat ze meer voedsel eten, terwijl anderen beginnen te verhongeren, hoewel de draden dezelfde prioriteit hebben. Hier verhongeren ze niet helemaal, want. slechte filosofen zetten hun vorken soms terug. Het blijkt dat goede mensen ongeveer 5 keer minder eten dan slechte. Dus een kleine fout in de code leidt tot een daling van de prestaties. Het is ook vermeldenswaard dat een zeldzame situatie mogelijk is wanneer alle filosofen de linkervork nemen, er is geen rechter, ze zetten de linker, wachten, nemen weer de linker, enz. Deze situatie is ook een uithongering, meer een impasse. Ik heb het niet herhaald. Hieronder is een foto voor een situatie waarin twee slechte filosofen beide vorken hebben genomen en twee goede verhongeren.
Hier kun je zien dat de threads soms wakker worden en proberen de bron te krijgen. Twee van de vier kernen doen niets (groene grafiek hierboven).
Dood van een filosoof
Welnu, een ander probleem dat een glorieus diner van filosofen kan verstoren, is als een van hen plotseling sterft met een vork in zijn handen (en ze zullen hem zo begraven). Dan zitten de buren zonder lunch. U kunt zelf een voorbeeldcode voor deze zaak bedenken, deze wordt bijvoorbeeld weggegooid NullReferenceException nadat de filosoof de vorken heeft genomen. En trouwens, de uitzondering wordt niet afgehandeld en de aanroepende code zal het niet zomaar opvangen (voor dit AppDomain.CurrentDomain.UnhandledException en etc.). Daarom zijn foutafhandelaars nodig in de threads zelf en met sierlijke beΓ«indiging.
De eenvoudigste manier is wanneer de filosofen de ober gewoon constant om toegang tot de vorken vragen. Die. nu wachten filosofen niet op een vork in de buurt, maar wachten of vragen de ober. In eerste instantie gebruiken we hiervoor alleen User Space, daarin gebruiken we geen interrupts om procedures uit de kernel aan te roepen (hierover hieronder).
SpinLock dit is een blocker, met grofweg hetzelfde while(true) { if (!lock) break; }, maar met nog meer "magie" dan in SpinWait (die daar wordt gebruikt). Nu weet hij de wachtenden te tellen, ze een beetje te laten inslapen en meer. etc. Doet er in het algemeen alles aan om te optimaliseren. Maar we moeten niet vergeten dat dit nog steeds dezelfde actieve cyclus is die processorbronnen opslokt en de stroom vasthoudt, wat kan leiden tot uithongering als een van de filosofen meer prioriteit krijgt dan andere, maar geen gouden vork heeft (Priority Inversion-probleem) . Daarom gebruiken we het alleen voor zeer korte wijzigingen in het gedeelde geheugen, zonder oproepen van derden, geneste vergrendelingen en andere verrassingen.
Tekenen voor SpinLock. De streams "vechten" constant om de gouden vork. Er zijn mislukkingen - in de figuur, het geselecteerde gebied. De kernen worden niet volledig benut: slechts ongeveer 2/3 door deze vier threads.
Een andere oplossing hier zou zijn om alleen te gebruiken Interlocked.CompareExchange met dezelfde actieve wacht als getoond in de bovenstaande code (in de uitgehongerde filosofen), maar dit zou, zoals reeds gezegd, theoretisch kunnen leiden tot blokkering.
ΠΡΠΎ Interlocked Opgemerkt moet worden dat er niet alleen CompareExchange, maar ook andere methoden voor atomisch lezen EN schrijven. En door wijzigingsherhaling, in het geval dat een andere thread tijd heeft om zijn wijzigingen aan te brengen (lees 1, lees 2, schrijf 2, schrijf 1 is slecht), kan deze worden gebruikt voor complexe wijzigingen in een enkele waarde (Interlocked Anything-patroon).
Oplossingen voor kernelmodus
Laten we eens kijken hoe we een thread kunnen blokkeren om te voorkomen dat we middelen in een lus verspillen. Met andere woorden, als we ons voorbeeld voortzetten, laten we eens kijken hoe de ober de filosoof in slaap brengt en hem alleen wakker maakt als dat nodig is. Laten we eerst eens kijken hoe we dit kunnen doen via de kernelmodus van het besturingssysteem. Alle structuren daar zijn vaak langzamer dan die in de gebruikersruimte. Meerdere keren langzamer bijvoorbeeld AutoResetEvent misschien 53 keer langzamer SpinLock [Richter]. Maar met hun hulp kunt u processen in het hele systeem synchroniseren, beheerd of niet.
Om te begrijpen wat hier gebeurt, overweeg het geval waarin de filosoof de vorken niet nam, dan zullen zijn acties als volgt zijn. Hij wacht op toegang tot de tafel. Nadat hij het heeft ontvangen, probeert hij de vorken te pakken. Niet gelukt. Het geeft toegang tot de tafel (wederzijdse uitsluiting). En passeert zijn "tourniquet" (AutoResetEvent) (ze zijn aanvankelijk open). Het komt weer in de cyclus, omdat hij heeft geen vorken. Hij probeert ze te pakken en stopt bij zijn "tourniquet". Een meer fortuinlijke buurman rechts of links, die klaar is met eten, ontgrendelt onze filosoof en opent zijn tourniquet. Onze filosoof passeert het (en het sluit erachter) voor de tweede keer. Hij probeert voor de derde keer de vorken te pakken. Succes. En hij passeert zijn tourniquet om te dineren.
Wanneer er willekeurige fouten in dergelijke code zitten (ze bestaan ββaltijd), bijvoorbeeld een buur is onjuist gespecificeerd of hetzelfde object is gemaakt AutoResetEvent voor iedereen (Enumerable.Repeat), dan wachten de filosofen op de ontwikkelaars, want Het vinden van fouten in dergelijke code is een vrij moeilijke taak. Een ander probleem met deze oplossing is dat het niet garandeert dat een of andere filosoof niet zal verhongeren.
Hybride oplossingen
We hebben gekeken naar twee benaderingen van timing, wanneer we in de gebruikersmodus en lus blijven, en wanneer we thread door de kernel blokkeren. De eerste methode is goed voor korte lokken, de tweede voor lange. Het is vaak nodig om eerst even te wachten tot een variabele verandert in een lus, en dan de thread te blokkeren als het wachten lang is. Deze aanpak is geΓ―mplementeerd in de zgn. hybride structuren. Hier zijn dezelfde constructies als voor de kernelmodus, maar nu met een lus in de gebruikersmodus: SemaphorSlim, ManualResetEventSlim enz. Het meest populaire ontwerp hier is Monitor, omdat in C# is er een bekende lock syntaxis. Monitor dit is dezelfde semafoor met een maximale waarde van 1 (mutex), maar met ondersteuning voor wachten in een lus, recursie, het Conditie Variabele patroon (meer hierover hieronder), enz. Laten we eens kijken naar een oplossing ermee.
Hier blokkeren we opnieuw de hele tafel voor toegang tot de vorken, maar nu deblokkeren we alle threads tegelijk, en niet de buren als iemand klaar is met eten. Die. eerst eet iemand en blokkeert de buren, en als deze iemand klaar is, maar meteen weer wil eten, gaat hij in de blokkering en maakt zijn buren wakker, omdat. de wachttijd is minder.
Zo vermijden we impasses en de uithongering van een of andere filosoof. We gebruiken een lus voor een korte wachttijd en blokkeren de draad voor een lange. Iedereen tegelijk deblokkeren gaat langzamer dan wanneer alleen de buurman werd gedeblokkeerd, zoals in de oplossing met AutoResetEvent, maar het verschil mag niet groot zijn, omdat threads moeten eerst in de gebruikersmodus blijven.
Π£ lock syntaxis heeft nare verrassingen. Aanraden om te gebruiken Monitor direct [Richter] [Eric Lippert]. Een daarvan is dat lock altijd uit Monitor, zelfs als er een uitzondering was, in welk geval een andere thread de status van het gedeelde geheugen zou kunnen wijzigen. In dergelijke gevallen is het vaak beter om in een impasse te raken of het programma op de een of andere manier veilig te beΓ«indigen. Een andere verrassing is dat Monitor synchronisatieblokken gebruikt (SyncBlock), die aanwezig zijn in alle objecten. Daarom kunt u, als een ongepast object is geselecteerd, gemakkelijk een impasse krijgen (bijvoorbeeld als u vergrendelt op een interne tekenreeks). Hiervoor gebruiken we het altijd verborgen object.
Methode met async / await wordt vertaald in een lastige toestandsmachine die onmiddellijk zijn interne teruggeeft Task. Hierdoor kunt u wachten op de voltooiing van de methode, deze annuleren en al het andere dat u met Task kunt doen. Binnen de methode bestuurt de toestandsmachine de uitvoering. Het komt erop neer dat als er geen vertraging is, de uitvoering synchroon is en als die er is, wordt de thread vrijgegeven. Voor een beter begrip hiervan kun je beter naar deze toestandsmachine kijken. Hiervan kun je ketens maken async / await methoden.
Laten we testen. Werk van 100 filosofen op een machine met 4 logische kernen, 8 seconden. De vorige oplossing met Monitor draaide alleen de eerste 4 threads en de rest helemaal niet. Elk van deze 4 threads was ongeveer 2 ms inactief. En de asynchrone / wait-oplossing draaide alle 100, met een gemiddelde wachttijd van elk 6.8 seconden. In echte systemen is 6 seconden inactiviteit natuurlijk onaanvaardbaar en het is beter om niet zoveel van dit soort verzoeken te verwerken. De oplossing met Monitor bleek helemaal niet schaalbaar.
Conclusie
Zoals je aan deze kleine voorbeelden kunt zien, ondersteunt .NET veel synchronisatieconstructies. Het is echter niet altijd duidelijk hoe ze te gebruiken. Ik hoop dat dit artikel nuttig was. Voor nu is dit het einde, maar er is nog veel interessants over, bijvoorbeeld thread-safe collecties, TPL Dataflow, Reactive programming, Software Transaction model, etc.