Veilig en efficiënt multithreaden in .NET

0
148

Multithreading kan worden gebruikt om de prestaties van uw toepassing drastisch te versnellen, maar geen versnelling is gratis—het beheren van parallelle threads vereist zorgvuldige programmering, en zonder de juiste voorzorgsmaatregelen kun je race-omstandigheden, impasses en zelfs crashes tegenkomen.

Wat maakt multithreading moeilijk?

Tenzij u uw programma anders vertelt, wordt al uw code uitgevoerd op de “Hoofddraad.” Vanaf het toegangspunt van uw applicatie loopt het door en voert het al uw functies een voor een uit. Dit heeft een limiet aan de prestaties, omdat je natuurlijk alleen zoveel kunt doen als je alles één voor één moet verwerken. De meeste moderne CPU's hebben zes of meer cores met 12 of meer threads, dus er blijven prestaties over als je ze niet gebruikt.

Het is echter niet zo eenvoudig als 'multithreading inschakelen'. Alleen specifieke dingen (zoals loops) kunnen goed multithreaded worden, en er zijn veel overwegingen om rekening mee te houden wanneer je dit doet.

Het eerste en belangrijkste punt zijn race-omstandigheden< /sterk>. Deze treden vaak op tijdens schrijfbewerkingen, wanneer een thread een bron wijzigt die door meerdere threads wordt gedeeld. Dit leidt tot gedrag waarbij de uitvoer van het programma afhangt van welke thread het eerst eindigt of iets wijzigt, wat kan leiden tot willekeurig en onverwacht gedrag.

Deze kunnen heel, heel eenvoudig zijn, bijvoorbeeld, misschien moet je iets tussen de lussen bijhouden. De meest voor de hand liggende manier om dit te doen is door een variabele te maken en deze te verhogen, maar dit is niet veilig voor threads.

Advertentie

Deze raceconditie doet zich voor omdat het niet alleen “een toevoegen aan de variabele” in abstracte zin; de CPU laadt de waarde van getal in het register, voegt er een toe aan die waarde en slaat het resultaat vervolgens op als de nieuwe waarde van de variabele. Het weet niet dat in de tussentijd een andere thread ook precies hetzelfde probeerde te doen en een binnenkort onjuiste waarde van het getal laadde. De twee threads zijn conflicterend en aan het einde van de lus is het getal misschien niet gelijk aan 100.

.NET biedt een functie om dit te helpen beheren: het vergrendelingszoekwoord. Dit verhindert niet dat er rechtstreeks wijzigingen worden aangebracht, maar het helpt gelijktijdigheid te beheren door slechts één thread tegelijk toe te staan ​​om de vergrendeling te verkrijgen. Als een andere thread een lock-instructie probeert in te voeren terwijl een andere thread aan het verwerken is, zal deze tot 300 ms wachten voordat hij verder gaat.

U kunt alleen referentietypes vergrendelen, dus er ontstaat een algemeen patroon een lock-object vooraf en gebruik dat als vervanging voor het vergrendelen van het waardetype.

< /p>

Het kan je echter opvallen dat er nu een ander probleem is: deadlocks. Deze code is een worstcasevoorbeeld, maar hier is het bijna precies hetzelfde als gewoon een normale for-lus doen (eigenlijk een beetje langzamer, omdat extra threads en sloten extra overhead zijn). Elke thread probeert het slot te verkrijgen, maar slechts één tegelijk kan het slot hebben, dus slechts één thread tegelijk kan de code in het slot uitvoeren. In dit geval is dat de hele code van de lus, dus de lock-instructie verwijdert alle voordelen van threading en maakt alles alleen maar langzamer.

Over het algemeen wilt u zo nodig vergrendelen wanneer u moet schrijven. U moet echter rekening houden met gelijktijdigheid bij het kiezen van wat u wilt vergrendelen, omdat reads ook niet altijd thread-safe zijn. Als een andere thread naar het object schrijft, kan het lezen van een andere thread een onjuiste waarde geven of ervoor zorgen dat een bepaalde voorwaarde een onjuist resultaat oplevert.

Gelukkig zijn er een paar trucjes om dit correct te doen, waarbij je kunt de snelheid van multithreading balanceren terwijl je sloten gebruikt om race-omstandigheden te vermijden.

Interlocked gebruiken voor atoombewerkingen

Voor basisbewerkingen kan het gebruik van de lock-instructie overdreven zijn. Hoewel het erg handig is om te vergrendelen vóór complexe wijzigingen, is het te veel overhead voor zoiets eenvoudigs als het toevoegen of vervangen van een waarde.

Advertentie

Interlocked is een klasse die enkele geheugenbewerkingen omvat, zoals optellen, vervangen en vergelijken. De onderliggende methoden worden geïmplementeerd op CPU-niveau en zijn gegarandeerd atomair, en veel sneller dan de standaard lock-instructie. Je zult ze waar mogelijk willen gebruiken, hoewel ze de vergrendeling niet volledig zullen vervangen.

In het bovenstaande voorbeeld zal het vervangen van de vergrendeling door een aanroep van Interlocked.Add() sneller worden de operatie veel. Hoewel dit eenvoudige voorbeeld niet sneller is dan het gewoon niet gebruiken van Interlocked, is het nuttig als onderdeel van een grotere operatie en is het nog steeds een versnelling.

Er is ook een toename en afname voor de bewerkingen ++ en –, waarmee u twee solide toetsaanslagen bespaart. Ze wikkelen letterlijk Add(ref count, 1) onder de motorkap, dus er is geen specifieke versnelling om ze te gebruiken.

U kunt ook Exchange gebruiken, een generieke methode die een variabele instelt die gelijk is aan de waarde die eraan wordt doorgegeven. Je moet hier echter voorzichtig mee zijn – als je het instelt op een waarde die je hebt berekend met behulp van de oorspronkelijke waarde, is dit niet veilig voor threads, omdat de oude waarde had kunnen worden gewijzigd voordat Interlocked werd uitgevoerd .Exchange.

CompareExchange controleert twee waarden op gelijkheid, en vervang de waarde als ze gelijk zijn.

Gebruik threadveilige verzamelingen

De standaardcollecties in System.Collections.Generic kunnen worden gebruikt met multithreading, maar ze zijn niet helemaal threadveilig. Microsoft biedt thread-safe implementaties van sommige collecties in System.Collections.Concurrent.

Advertentie

Hieronder vallen de ConcurrentBag, een ongeordende generieke verzameling, en ConcurrentDictionary, een thread-safe Dictionary. Er zijn ook gelijktijdige wachtrijen en stapels, en OrderablePartitioner, waarmee bestelbare gegevensbronnen zoals lijsten kunnen worden opgesplitst in afzonderlijke partities voor elke thread.

Kijk om lussen te parallelliseren< /h2>

Vaak is de gemakkelijkste plaats om te multithreaden in grote, dure lussen. Als je meerdere opties parallel kunt uitvoeren, kun je een enorme snelheidswinst behalen in de totale looptijd.

De beste manier om dit aan te pakken is met System.Threading.Tasks.Parallel. Deze klasse biedt vervangingen voor for- en foreach-lussen die de lus-body's op afzonderlijke threads uitvoeren. Het is eenvoudig te gebruiken, maar vereist een iets andere syntaxis:

Vanzelfsprekend is de valkuil hier dat je ervoor moet zorgen dat DoSomething() draadveilig is en geen gedeelde variabelen verstoort. Dat is echter niet altijd zo eenvoudig als het vervangen van de lus door een parallelle lus, en in veel gevallen moet u gedeelde objecten vergrendelen om wijzigingen aan te brengen.

Om een ​​aantal problemen met impasses te verhelpen, bieden Parallel.For en Parallel.ForEach extra functies voor het omgaan met status. In principe zal niet elke iteratie op een aparte thread worden uitgevoerd. Als je 1000 elementen hebt, worden er geen 1000 threads gemaakt; het gaat zoveel threads maken als je CPU aankan, en meerdere iteraties per thread uitvoeren. Dit betekent dat als u een totaal berekent, u niet voor elke iteratie hoeft te vergrendelen. U kunt eenvoudig een subtotaalvariabele doorgeven en helemaal aan het einde het object vergrendelen en één keer wijzigingen aanbrengen. Dit vermindert de overhead op zeer grote lijsten drastisch.

Advertentie

Laten we eens naar een voorbeeld kijken. De volgende code heeft een grote lijst met objecten nodig en moet elk afzonderlijk naar JSON serialiseren, wat resulteert in een List<string> van alle objecten. JSON-serialisatie is een erg langzaam proces, dus het splitsen van elk element over meerdere threads is een grote versnelling.

Er zijn een heleboel argumenten, en veel om hier uit te pakken:

  • Het eerste argument neemt een IEnumerable, die de gegevens definieert waarover het wordt herhaald. Dit is een ForEach-lus, maar hetzelfde concept werkt voor standaard For-lussen.
  • De eerste actie initialiseert de lokale subtotaalvariabele. Deze variabele wordt gedeeld over elke iteratie van de lus, maar alleen binnen dezelfde thread. Andere threads hebben hun eigen subtotalen. Hier initialiseren we het naar een lege lijst. Als u een numeriek totaal aan het berekenen was, zou u hier 0 kunnen retourneren.
  • De tweede actie is de hoofdlus. Het eerste argument is het huidige element (of de index in een For-lus), het tweede is een ParallelLoopState-object dat u kunt gebruiken om .Break() aan te roepen, en het laatste is de subtotaalvariabele.
    • In deze lus kunt u het element bewerken en het subtotaal wijzigen. De waarde die u retourneert, vervangt het subtotaal voor de volgende lus. In dit geval rangschikken we het element naar een string en voegen we de string toe aan het subtotaal, wat een Lijst is.
  • Ten slotte krijgt de laatste actie het subtotaal ‘resultaat’ nadat alle uitvoeringen zijn voltooid, kunt u een resource vergrendelen en wijzigen op basis van het uiteindelijke totaal. Deze actie wordt één keer uitgevoerd, helemaal aan het einde, maar het draait nog steeds op een aparte thread, dus je moet vergrendelen of Interlocked-methoden gebruiken om bronnen te wijzigen. Hier noemen we AddRange() om de subtotaallijst toe te voegen aan de definitieve lijst.

Unity Multithreading

One laatste opmerking: als je de Unity-game-engine gebruikt, moet je voorzichtig zijn met multithreading. Je kunt geen Unity-API's aanroepen, anders crasht het spel. Het is mogelijk om het spaarzaam te gebruiken door API-bewerkingen op de hoofdthread uit te voeren en heen en weer te schakelen wanneer u iets moet parallelliseren.

Dit is meestal van toepassing op bewerkingen die interactie hebben met de scène of de fysica motor. Vector3-wiskunde wordt niet beïnvloed en je bent vrij om het zonder problemen vanuit een aparte thread te gebruiken. Je bent ook vrij om velden en eigenschappen van je eigen objecten te wijzigen, op voorwaarde dat ze geen Unity-bewerkingen onder de motorkap aanroepen.