Si të bëni shumë fije të sigurta dhe efikase në .NET


Multithreading mund të përdoret për të përshpejtuar në mënyrë drastike performancën e aplikacionit tuaj, por asnjë përshpejtim nuk është falas—menaxhimi i temave paralele kërkon programim të kujdesshëm dhe pa masat e duhura paraprake, mund të hasni në kushte gare, ngërçe dhe madje edhe përplasje.

Çfarë e bën të vështirë multithreading?

Nëse nuk i tregoni programit tuaj ndryshe, i gjithë kodi juaj ekzekutohet në Tema kryesore. Nga pika hyrëse e aplikacionit tuaj, ai kalon dhe ekzekuton të gjitha funksionet tuaja njëra pas tjetrës. Kjo ka një kufi për performancën, pasi padyshim që mund të bëni kaq shumë vetëm nëse duhet të përpunoni gjithçka një nga një. Shumica e CPU-ve moderne kanë gjashtë ose më shumë bërthama me 12 ose më shumë fije, kështu që performanca mbetet në tryezë nëse nuk po i përdorni ato.

Sidoqoftë, nuk është aq e thjeshtë sa thjesht aktivizimi i multithreading. Vetëm gjëra specifike (të tilla si unazat) mund të ndërlidhen siç duhet, dhe ka shumë konsiderata që duhen marrë parasysh kur bëhet kjo.

Çështja e parë dhe më e rëndësishme janë kushtet e garës. Këto ndodhin shpesh gjatë operacioneve të shkrimit, kur një thread modifikon një burim që ndahet nga fije të shumta. Kjo çon në sjellje ku rezultati i programit varet nga ajo thread që përfundon ose modifikon diçka së pari, gjë që mund të çojë në sjellje të rastësishme dhe të papritura.

Këto mund të jenë shumë, shumë të thjeshta - për shembull, ndoshta ju duhet të mbani një numërim të vazhdueshëm të diçkaje midis sytheve. Mënyra më e dukshme për ta bërë këtë është krijimi i një ndryshoreje dhe rritja e saj, por kjo nuk është e sigurt për fijet.

Kjo gjendje race ndodh sepse nuk është thjesht shtimi i një ndryshores në një kuptim abstrakt; CPU-ja po ngarkon vlerën e number në regjistër, duke shtuar një në atë vlerë dhe më pas e ruan rezultatin si vlerë të re të ndryshores. Nuk e di që ndërkohë, një lidhje tjetër po përpiqej të bënte saktësisht të njëjtën gjë dhe ngarkoi një vlerë të pasaktë të numrit së shpejti. Dy fijet janë në konflikt dhe në fund të ciklit, numri mund të mos jetë i barabartë me 100.

.NET ofron një veçori për të ndihmuar në menaxhimin e kësaj: fjalën kyçe lock. Kjo nuk e pengon bërjen e ndryshimeve të drejtpërdrejta, por ndihmon në menaxhimin e konkurencës duke lejuar vetëm një fije në një kohë për të marrë bllokimin. Nëse një bashkëbisedim tjetër përpiqet të futë një deklaratë bllokimi ndërkohë që një bashkëbisedim tjetër po përpunohet, ai do të presë deri në 300 ms përpara se të vazhdojë.

Ju jeni në gjendje të kyçni vetëm llojet e referencës, kështu që një model i zakonshëm është krijimi i një objekti bllokimi paraprakisht dhe përdorimi i tij si zëvendësim për mbylljen e llojit të vlerës.

Megjithatë, mund të vëreni se tani ka një problem tjetër: blloqet. Ky kod është një shembull i rastit më të keq, por këtu, është pothuajse saktësisht i njëjtë si thjesht të bësh një cikli të rregullt for  (në fakt pak më i ngadalshëm, pasi fijet dhe kyçjet shtesë janë ekstra lart). Çdo thread përpiqet të marrë bllokimin, por vetëm një nga një mund ta ketë bllokimin, kështu që vetëm një fije në një kohë mund të ekzekutojë kodin brenda bllokimit. Në këtë rast, ky është i gjithë kodi i ciklit, kështu që deklarata e bllokimit po heq të gjitha përfitimet e filetimit dhe thjesht e bën gjithçka më të ngadaltë.

Në përgjithësi, ju dëshironi të kyçni sipas nevojës sa herë që keni nevojë të bëni shkrime. Sidoqoftë, do të dëshironi të mbani parasysh konkurencën kur zgjidhni se çfarë të kyçni, sepse leximet nuk janë gjithmonë të sigurta për temat. Nëse një thread tjetër po i shkruan objektit, leximi i tij nga një fill tjetër mund të japë një vlerë të pasaktë ose të shkaktojë që një kusht i veçantë të kthejë një rezultat të pahijshëm.

Për fat të mirë, ka disa truke për ta bërë këtë siç duhet, ku mund të balanconi shpejtësinë e multithreading ndërsa përdorni bravë për të shmangur kushtet e garës.

Përdorni Interlocked për Operacione Atomike

Për operacionet bazë, përdorimi i deklaratës lock mund të jetë i tepërt. Ndërsa është shumë i dobishëm për mbylljen përpara modifikimeve komplekse, është shumë e lartë për diçka aq të thjeshtë sa shtimi ose zëvendësimi i një vlere.

Interlocked është një klasë që mbështjell disa operacione memorie si shtimi, zëvendësimi dhe krahasimi. Metodat themelore zbatohen në nivelin e CPU-së dhe garantohen të jenë atomike dhe shumë më të shpejta se deklarata standarde lock . Ju do të dëshironi t'i përdorni ato sa herë që të jetë e mundur, megjithëse ato nuk do të zëvendësojnë plotësisht bllokimin.

Në shembullin e mësipërm, zëvendësimi i bllokimit me një thirrje te Interlocked.Add() do ta përshpejtojë shumë operacionin. Ndërsa ky shembull i thjeshtë nuk është më i shpejtë sesa thjesht mospërdorimi i Interlocked, ai është i dobishëm si pjesë e një operacioni më të madh dhe është ende një përshpejtim.

Ka gjithashtu Rritje dhe Zvogëlim për operacionet ++ dhe -- , të cilat do t'ju kursejnë dy goditje të forta tasti. Ata fjalë për fjalë e mbështjellin Shto (numrin e referencës, 1) nën kapuç, kështu që nuk ka ndonjë shpejtësi specifike për përdorimin e tyre.

Ju gjithashtu mund të përdorni Exchange, një metodë e përgjithshme që do të vendosë një variabël të barabartë me vlerën që i kalon. Megjithatë, duhet të jeni të kujdesshëm me këtë - nëse po e vendosni në një vlerë që keni llogaritur duke përdorur vlerën origjinale, kjo nuk është e sigurt, pasi vlera e vjetër mund të ishte modifikuar përpara se të ekzekutoni Interlocked.Exchange.

CompareExchange do të kontrollojë dy vlera për barazi dhe do të zëvendësojë vlerën nëse ato janë të barabarta.

Përdorni Koleksionet e Sigurta të Temave

Koleksionet e parazgjedhura në System.Collections.Generic mund të përdoren me multithreading, por ato nuk janë plotësisht të sigurta në lidhje. Microsoft ofron implementime të sigurta në fije të disa koleksioneve në System.Collections.Concurrent.

Midis tyre përfshijnë CancurrentBag, një koleksion gjenerik i parregulluar dhe ConcurrentDictionary, një fjalor i sigurt për temat. Ekzistojnë gjithashtu radhë dhe rafte të njëkohshme, dhe OrderablePartitioner, të cilat mund të ndajnë burimet e të dhënave të porositura si Listat në ndarje të veçanta për çdo thread.

Shikoni për të paralelizuar sythe

Shpesh, vendi më i lehtë për t'u bashkuar me shumë fije është në sythe të mëdha dhe të shtrenjta. Nëse mund të ekzekutoni disa opsione paralelisht, mund të merrni një shpejtësi të madhe në kohën e përgjithshme të funksionimit.

Mënyra më e mirë për ta trajtuar këtë është me System.Threading.Tasks.Parallel. Kjo klasë ofron zëvendësime për sythe për dhe foreach që ekzekutojnë trupat e ciklit në fije të veçanta. Është e thjeshtë për t'u përdorur, megjithëse kërkon sintaksë paksa të ndryshme:

Natyrisht, kapja këtu është se ju duhet të siguroheni që DoSomething() është i sigurt në lidhje dhe nuk ndërhyn me asnjë variabël të përbashkët. Megjithatë, kjo nuk është gjithmonë aq e lehtë sa thjesht zëvendësimi i ciklit me një qark paralel dhe në shumë raste duhet të kyçni objektet e përbashkëta për të bërë ndryshime.

Për të zbutur disa nga problemet me bllokimet, Parallel.For dhe Parallel.For Seach sigurojnë veçori shtesë për trajtimin e gjendjes. Në thelb, jo çdo përsëritje do të ekzekutohet në një fill të veçantë - nëse keni 1000 elementë, nuk do të krijojë 1000 thread; do të krijojë aq thread sa CPU-ja juaj mund të përballojë dhe do të ekzekutojë përsëritje të shumta për thread. Kjo do të thotë që nëse jeni duke llogaritur një total, nuk keni nevojë të kyçeni për çdo përsëritje. Ju thjesht mund të kaloni rreth një ndryshoreje nëntotal, dhe në fund të mbyllni objektin dhe të bëni ndryshime një herë. Kjo redukton në mënyrë drastike shpenzimet e përgjithshme në listat shumë të mëdha.

Le të hedhim një vështrim në një shembull. Kodi i mëposhtëm merr një listë të madhe objektesh dhe duhet të serializojë secilin veçmas në JSON, duke përfunduar me një List të të gjithë objekteve. Serializimi JSON është një proces shumë i ngadaltë, kështu që ndarja e secilit element në fije të shumta është një përshpejtim i madh.

Ka një mori argumentesh dhe shumë për të shpalosur këtu:

  • Argumenti i parë merr një IEnumerable, i cili përcakton të dhënat mbi të cilat po qarkullon. Ky është një cikli ForEach, por i njëjti koncept funksionon për ciklin themelor For.
  • Veprimi i parë inicializon variablin nëntotal lokal. Kjo variabël do të ndahet në çdo përsëritje të ciklit, por vetëm brenda së njëjtës fillesë. Fijet e tjera do të kenë nëntotalet e tyre. Këtu, ne po e inicializojmë atë në një listë boshe. Nëse po llogaritni një total numerik, mund të ktheni 0 këtu.
  • Veprimi i dytë është trupi kryesor i ciklit. Argumenti i parë është elementi aktual (ose indeksi në një cikli For), i dyti është një objekt ParallelLoopState që mund ta përdorni për të thirrur .Break(), dhe i fundit është ndryshorja nëntotal.
    • Në këtë qark, mund të operoni me elementin dhe të modifikoni nëntotalin. Vlera që ktheni do të zëvendësojë nëntotalin për ciklin tjetër. Në këtë rast, ne e serializojmë elementin në një varg, më pas shtojmë vargun në nëntotalin, i cili është një Listë.

    Unity Multithreading

    Një shënim i fundit - nëse jeni duke përdorur motorin e lojës Unity, do të dëshironi të jeni të kujdesshëm me multithreading. Ju nuk mund të telefononi asnjë API të Unity, ose përndryshe loja do të rrëzohet. Është e mundur ta përdorni me masë duke kryer operacione API në fillin kryesor dhe duke kaluar mbrapa dhe mbrapa sa herë që keni nevojë të paralelizoni diçka.

    Kryesisht, kjo vlen për operacionet që ndërveprojnë me skenën ose motorin e fizikës. Matematika e Vector3 është e paprekur dhe ju jeni të lirë ta përdorni nga një temë e veçantë pa probleme. Ju jeni gjithashtu të lirë të modifikoni fushat dhe vetitë e objekteve tuaja, me kusht që ato të mos thërrasin asnjë operacion Unity nën kapak.