.NET: Työkaluja monisäikeen ja asynkronian kanssa työskentelemiseen. Osa 1

Julkaisen alkuperäisen artikkelin Habrista, jonka käännös on julkaistu yhtiössä blogin viesti.

Tarve tehdä jotain asynkronisesti, odottamatta tulosta tässä ja nyt tai jakaa suuri työ useiden sitä suorittavien yksiköiden kesken, oli olemassa ennen tietokoneiden tuloa. Heidän tulonsa myötä tämä tarve tuli hyvin konkreettiseksi. Nyt, vuonna 2019, kirjoitan tätä artikkelia kannettavalla tietokoneella, jossa on 8-ytiminen Intel Core -prosessori, jossa yli sata prosessia on käynnissä rinnakkain, ja vielä enemmän säikeitä. Lähistöllä on hieman nuhjuinen, pari vuotta sitten ostettu puhelin, jossa on 8-ytiminen prosessori. Temaattiset resurssit ovat täynnä artikkeleita ja videoita, joissa niiden kirjoittajat ihailevat tämän vuoden lippulaivapuhelimia, joissa on 16-ytiminen prosessori. MS Azure tarjoaa virtuaalikoneen 20 ytimen prosessorilla ja 128 Tt RAM:lla alle 2 dollarilla/tunti. Valitettavasti on mahdotonta saada maksimi ja valjastaa tätä voimaa ilman, että pystytään hallitsemaan säikeiden vuorovaikutusta.

terminologia

Käsitellä asiaa - OS-objekti, eristetty osoiteavaruus, sisältää säikeitä.
Lanka - OS-objekti, pienin suoritusyksikkö, osa prosessia, säikeet jakavat muistin ja muut resurssit keskenään prosessin sisällä.
moniajo - Käyttöjärjestelmän ominaisuus, kyky suorittaa useita prosesseja samanaikaisesti
Moniytiminen - prosessorin ominaisuus, kyky käyttää useita ytimiä tietojenkäsittelyyn
Monikäsittely - tietokoneen ominaisuus, kyky työskennellä samanaikaisesti useiden prosessorien kanssa fyysisesti
Monisäikeinen — prosessin ominaisuus, kyky jakaa tietojenkäsittely useiden säikeiden kesken.
Rinnakkaisuus - useiden toimintojen suorittaminen fyysisesti samanaikaisesti aikayksikössä
Asynkronisuus — toiminnon suorittaminen odottamatta tämän käsittelyn valmistumista; suorituksen tulos voidaan käsitellä myöhemmin.

metafora

Kaikki määritelmät eivät ole hyviä ja jotkut tarvitsevat lisäselvitystä, joten lisään muodollisesti käyttöön otettuun terminologiaan metaforan aamiaisen valmistamisesta. Aamiaisen valmistaminen tässä metaforassa on prosessi.

Kun valmistan aamiaista aamulla, minä (prosessori) tulen keittiöön (tietokone). Minulla on 2 kättä (Cores). Keittiössä on useita laitteita (IO): uuni, vedenkeitin, leivänpaahdin, jääkaappi. Laitan kaasun päälle, laitan paistinpannun päälle ja kaadan öljyä siihen odottamatta sen lämpenemistä (asynkronisesti, Ei-Blocking-IO-Wait), otan munat jääkaapista ja rikon ne lautaseksi, sitten vatkaa ne yhdellä kädellä (Lanka nro 1), ja toinen (Lanka nro 2) pitämällä levyä (jaettu resurssi). Nyt haluaisin käynnistää vedenkeittimen, mutta minulla ei ole tarpeeksi käsiä (Nälkä) Tänä aikana paistinpannu lämpenee (Tuloksen käsittely), johon kaadan vatkatun. Kurotan vedenkeittimeen ja käynnistän sen ja katson typerästi veden kiehuvan siinä (Estäminen-IO-Odota), vaikka tänä aikana hän olisi voinut pestä lautasen, jossa hän vatkati munakkaan.

Keitin munakkaan vain kahdella kädellä, eikä minulla ole enempää, mutta samaan aikaan munakkaan vatkatessa tapahtui 2 toimintoa kerralla: munakkaan vatkailu, lautasen pitäminen, paistinpannun kuumennus. CPU on tietokoneen nopein osa, IO on se, mikä useimmiten hidastaa, joten usein tehokas ratkaisu on varata prosessori jollakin samalla kun vastaanotetaan tietoja IO:lta.

Jatkaen metaforaa:

  • Jos munakkaan valmistusprosessissa yrittäisin myös vaihtaa vaatteita, tämä olisi esimerkki monitoimista. Tärkeä vivahde: ​​tietokoneet ovat tässä paljon parempia kuin ihmiset.
  • Keittiö, jossa on useita kokkeja, esimerkiksi ravintolassa - moniytiminen tietokone.
  • Monet ravintolat kauppakeskuksen ruokakentällä - datakeskuksessa

.NET-työkalut

.NET on hyvä työskentelemään säikeiden kanssa, kuten monien muidenkin asioiden kanssa. Jokaisen uuden version myötä se esittelee yhä enemmän uusia työkaluja niiden kanssa työskentelyyn, uusia abstraktiokerroksia käyttöjärjestelmän säikeiden yli. Abstraktioiden rakentamisen parissa kehyskehittäjät käyttävät lähestymistapaa, joka jättää korkean tason abstraktiota käytettäessä mahdollisuuden laskea yhden tai useamman tason alemmaksi. Useimmiten tämä ei ole välttämätöntä, itse asiassa se avaa oven haulikolla ampumiseen jalkaan, mutta joskus se voi harvoin olla ainoa tapa ratkaista ongelma, joka ei ratkea nykyisellä abstraktiotasolla .

Työkaluilla tarkoitan sekä viitekehyksen että kolmannen osapuolen pakettien tarjoamia sovellusohjelmointirajapintoja (API) sekä kokonaisia ​​ohjelmistoratkaisuja, jotka yksinkertaistavat monisäikeiseen koodiin liittyvien ongelmien etsimistä.

Aloitetaan ketju

Thread-luokka on .NET:n perusluokka säikeiden kanssa työskentelemiseen. Rakentaja hyväksyy yhden kahdesta edustajasta:

  • ThreadStart — Ei parametreja
  • ParametriizedThreadStart - yhdellä objektityypin parametrilla.

Delegaatti suoritetaan äskettäin luodussa säikeessä aloitusmenetelmän kutsumisen jälkeen. Jos konstruktoriin välitettiin ParametrizedThreadStart-tyyppinen delegaatti, niin objekti on välitettävä Start-metodille. Tätä mekanismia tarvitaan kaikkien paikallisten tietojen siirtämiseen streamiin. On syytä huomata, että säikeen luominen on kallis toimenpide, ja säie itsessään on raskas esine, ainakin koska se varaa pinoon 1 Mt muistia ja vaatii vuorovaikutusta käyttöjärjestelmän API:n kanssa.

new Thread(...).Start(...);

ThreadPool-luokka edustaa poolin käsitettä. .NET:ssä säiepooli on suunnittelutyötä, ja Microsoftin kehittäjät ovat tehneet paljon työtä varmistaakseen, että se toimii optimaalisesti useissa erilaisissa skenaarioissa.

Yleinen käsite:

Sovelluksen käynnistyshetkestä lähtien se luo useita säikeitä varaukseen taustalle ja tarjoaa mahdollisuuden ottaa ne käyttöön. Jos lankoja käytetään usein ja suuria määriä, joukko laajenee vastaamaan soittajan tarpeita. Kun poolissa ei ole vapaita säikeitä oikeaan aikaan, se joko odottaa jonkin säikeistä palaamista tai luo uuden. Tästä seuraa, että säiepooli sopii erinomaisesti joihinkin lyhytaikaisiin toimiin ja huonosti toimintoihin, jotka toimivat palveluina koko sovelluksen toiminnan ajan.

Säikeen käyttämiseksi poolista on olemassa QueueUserWorkItem-metodi, joka hyväksyy WaitCallback-tyypin delegaatin, jolla on sama allekirjoitus kuin ParametrizedThreadStartilla, ja sille välitetty parametri suorittaa saman toiminnon.

ThreadPool.QueueUserWorkItem(...);

Vähemmän tunnettua säievarastomenetelmää RegisterWaitForSingleObject käytetään ei-estävien IO-toimintojen järjestämiseen. Tälle menetelmälle siirretty edustaja kutsutaan, kun menetelmälle välitetty WaitHandle on "vapautettu".

ThreadPool.RegisterWaitForSingleObject(...)

.NET:ssä on säikeen ajastin ja se eroaa WinForms/WPF-ajastimista siinä, että sen käsittelijää kutsutaan poolista otetusta säikeestä.

System.Threading.Timer

On myös melko eksoottinen tapa lähettää delegaatti suoritettaviksi lankaan poolista - BeginInvoke-menetelmä.

DelegateInstance.BeginInvoke

Haluaisin lyhyesti puhua toiminnosta, johon monet yllä olevista menetelmistä voidaan kutsua - CreateThread Kernel32.dll Win32 API:sta. Ulkoisten menetelmien mekanismin ansiosta on olemassa tapa kutsua tätä funktiota. Olen nähnyt tällaisen kutsun vain kerran kauheassa esimerkissä perinnöllisestä koodista, ja juuri tämän tekijän motivaatio on edelleen minulle mysteeri.

Kernel32.dll CreateThread

Säikeiden katselu ja virheenkorjaus

Luomasi säikeet, kaikki kolmannen osapuolen komponentit ja .NET-pooli voidaan tarkastella Visual Studion säikeet-ikkunassa. Tämä ikkuna näyttää säikeen tiedot vain, kun sovellus on virheenkorjaustilassa ja katkaisutilassa. Täällä voit kätevästi tarkastella kunkin säikeen pinon nimiä ja prioriteetteja ja vaihtaa virheenkorjauksen tiettyyn säikeeseen. Thread-luokan Priority-ominaisuuden avulla voit asettaa säikeen prioriteetin, jonka OC ja CLR näkevät suosituksena jaettaessa prosessoriaikaa säikeiden kesken.

.NET: Työkaluja monisäikeen ja asynkronian kanssa työskentelemiseen. Osa 1

Tehtävän rinnakkaiskirjasto

Task Parallel Library (TPL) otettiin käyttöön .NET 4.0:ssa. Nyt se on standardi ja tärkein työkalu asynkronian kanssa työskentelemiseen. Kaikki koodit, jotka käyttävät vanhempaa lähestymistapaa, katsotaan vanhaksi. TPL:n perusyksikkö on Task-luokka System.Threading.Tasks-nimiavaruudesta. Tehtävä on abstraktio langan yli. C#-kielen uudella versiolla saimme tyylikkään tavan työskennellä Tasks - async/wait -operaattoreiden kanssa. Nämä käsitteet mahdollistivat asynkronisen koodin kirjoittamisen ikään kuin se olisi yksinkertaista ja synkronista, mikä mahdollisti jopa henkilöiden, jotka eivät ymmärtäneet säikeiden sisäistä toimintaa, kirjoittaa niitä käyttäviä sovelluksia, jotka eivät jumiudu pitkiä toimintoja suoritettaessa. Async/await-toiminnon käyttö on aihe yhdelle tai jopa usealle artikkelille, mutta yritän saada sen ytimeen muutamalla lauseella:

  • async on menetelmän muokkaaja, joka palauttaa Task tai void
  • and await on ei-estämätön Task odottaa -operaattori.

Jälleen kerran: await-operaattori yleisessä tapauksessa (poikkeuksiakin on) vapauttaa nykyisen suoritussäikeen edelleen, ja kun tehtävä on suorittanut loppuun, ja säiettä (itse asiassa olisi oikeampaa sanoa konteksti , mutta siitä lisää myöhemmin) jatkaa menetelmän suorittamista edelleen. NET:n sisällä tämä mekanismi on toteutettu samalla tavalla kuin tuottotuotto, kun kirjoitettu menetelmä muuttuu kokonaiseksi luokkaksi, joka on tilakone ja voidaan suorittaa erillisinä osina näistä tiloista riippuen. Kaikki kiinnostuneet voivat kirjoittaa minkä tahansa yksinkertaisen koodin käyttämällä asynс/await-toimintoa, kääntää ja tarkastella kokoonpanoa JetBrains dotPeek -sovelluksella, jossa Compiler Generated Code on käytössä.

Katsotaanpa vaihtoehtoja Taskin käynnistämiseksi ja käyttämiseksi. Alla olevassa koodiesimerkissä luomme uuden tehtävän, joka ei tee mitään hyödyllistä (Säie.Sleep (10000)), mutta tosielämässä tämän pitäisi olla monimutkaista prosessoriintensiivistä työtä.

using TCO = System.Threading.Tasks.TaskCreationOptions;

public static async void VoidAsyncMethod() {
    var cancellationSource = new CancellationTokenSource();

    await Task.Factory.StartNew(
        // Code of action will be executed on other context
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    //  Code after await will be executed on captured context
}

Tehtävä luodaan useilla vaihtoehdoilla:

  • LongRunning on vihje siitä, että tehtävä ei valmistu nopeasti, mikä tarkoittaa, että kannattaa harkita, että ei oteta lankaa poolista, vaan luodaan erillinen tätä tehtävää varten, jotta muita ei vahingoiteta.
  • AttachedToParent - Tehtävät voidaan järjestää hierarkiaan. Jos tätä vaihtoehtoa käytettiin, Tehtävä voi olla tilassa, jossa se on itse valmis ja odottaa lastensa suorittamista.
  • PreferFairness - tarkoittaa, että suoritettavaksi lähetetyt tehtävät olisi parempi suorittaa aikaisemmin ennen myöhemmin lähetettyjä. Mutta tämä on vain suositus, eikä tuloksia voida taata.

Toinen menetelmälle välitetty parametri on CancellationToken. Jotta toiminnon peruutus voidaan käsitellä oikein sen alkamisen jälkeen, suoritettava koodi on täytettävä CancellationToken-tilan tarkistuksilla. Jos tarkistuksia ei ole, CancellationTokenSource-objektissa kutsuttu Cancel-menetelmä voi pysäyttää tehtävän suorittamisen vain ennen sen alkamista.

Viimeinen parametri on TaskScheduler-tyyppinen ajoitusobjekti. Tämä luokka ja sen jälkeläiset on suunniteltu hallitsemaan strategioita tehtävien jakamiseksi säikeiden kesken; oletusarvoisesti Tehtävä suoritetaan satunnaisessa säikeessä poolista.

await-operaattoria käytetään luotuun tehtävään, mikä tarkoittaa, että sen jälkeen kirjoitettu koodi, jos sellainen on, suoritetaan samassa kontekstissa (usein tämä tarkoittaa samassa säikeessä) kuin await-a edeltävä koodi.

Menetelmä on merkitty async voidiksi, mikä tarkoittaa, että se voi käyttää await-operaattoria, mutta kutsuva koodi ei voi odottaa suoritusta. Jos tällainen ominaisuus on tarpeen, menetelmän on palautettava Tehtävä. Async void -merkityt menetelmät ovat melko yleisiä: ne ovat yleensä tapahtumakäsittelijöitä tai muita tulen ja unohda -periaatteella toimivia menetelmiä. Jos sinun ei tarvitse vain antaa mahdollisuus odottaa suorituksen loppuun, vaan myös palauttaa tulos, sinun on käytettävä Tehtävää.

Tehtävässä, jonka StartNew-metodi palautti, kuten myös missä tahansa muussa, voit kutsua ConfigureAwait-metodia false-parametrilla, jolloin suoritus awaitin jälkeen jatkuu ei siepatussa kontekstissa, vaan mielivaltaisessa kontekstissa. Tämä tulee tehdä aina, kun suorituskonteksti ei ole koodille tärkeä odotuksen jälkeen. Tämä on myös MS:n suositus, kun kirjoitetaan koodia, joka toimitetaan kirjastoon pakattuna.

Katsotaanpa hieman enemmän, kuinka voit odottaa tehtävän valmistumista. Alla on esimerkki koodista, jossa on kommentteja siitä, milloin odotus on tehty ehdollisesti hyvin ja milloin ehdollisesti huonosti.

public static async void AnotherMethod() {

    int result = await AsyncMethod(); // good

    result = AsyncMethod().Result; // bad

    AsyncMethod().Wait(); // bad

    IEnumerable<Task> tasks = new Task[] {
        AsyncMethod(), OtherAsyncMethod()
    };

    await Task.WhenAll(tasks); // good
    await Task.WhenAny(tasks); // good

    Task.WaitAll(tasks.ToArray()); // bad
}

Ensimmäisessä esimerkissä odotamme tehtävän valmistumista estämällä kutsuvaa säiettä, palaamme tuloksen käsittelyyn vasta, kun se on jo olemassa; siihen asti kutsuva säie jätetään omiin käsiinsä.

Toisessa vaihtoehdossa estämme kutsuvan säikeen, kunnes menetelmän tulos on laskettu. Tämä ei ole huono vain siksi, että olemme käyttäneet säiettä, niin arvokasta ohjelman resurssia, yksinkertaisella tyhjäkäynnillä, vaan myös siksi, että jos kutsumamme menetelmän koodi sisältää odottavan ja synkronointikonteksti vaatii paluuta kutsuvaan säieteen odota, niin saamme umpikujan: Kutsuva säie odottaa, kun asynkronisen menetelmän tulos lasketaan, asynkroninen menetelmä yrittää turhaan jatkaa suoritustaan ​​kutsuvassa säikeessä.

Toinen tämän lähestymistavan haittapuoli on monimutkainen virheiden käsittely. Tosiasia on, että asynkronisen koodin virheet async/awaitia käytettäessä ovat erittäin helppoja käsitellä - ne käyttäytyvät samalla tavalla kuin jos koodi olisi synkroninen. Jos taas sovelletaan synkronista odotusmanaamista tehtävään, alkuperäinen poikkeus muuttuu AggregateExceptioniksi, ts. Poikkeuksen käsittelemiseksi sinun on tutkittava InnerException-tyyppi ja kirjoitettava itse if-ketju yhden catch-lohkon sisään tai käytettävä catch-toimintoa constructissa C#-maailmassa tutumman catch-lohkoketjun sijaan.

Myös kolmas ja viimeinen esimerkki on merkitty huonoiksi samasta syystä ja sisältävät kaikki samat ongelmat.

WhenAny- ja WhenAll-menetelmät ovat erittäin käteviä tehtäväryhmän odottamiseen; ne yhdistävät joukon tehtäviä yhdeksi, joka käynnistyy joko kun ryhmän tehtävä käynnistetään ensimmäisen kerran tai kun ne kaikki ovat suorittaneet suorituksensa.

Lankojen lopettaminen

Eri syistä voi olla tarpeen pysäyttää virtaus sen alkamisen jälkeen. On olemassa useita tapoja tehdä tämä. Thread-luokassa on kaksi oikein nimettyä menetelmää: keskeyttää и keskeytys. Ensimmäistä ei suositella käytettäväksi, koska kutsuttuaan sitä millä tahansa satunnaisella hetkellä, minkä tahansa käskyn käsittelyn aikana, annetaan poikkeus ThreadAbortedException. Et odota tällaisen poikkeuksen ilmestyvän, kun lisäät mitä tahansa kokonaislukumuuttujaa, eihän? Ja tätä menetelmää käytettäessä tämä on hyvin todellinen tilanne. Jos haluat estää CLR:ää luomasta tällaista poikkeusta tietyssä koodin osassa, voit kääriä sen puheluihin Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Kaikki lopuksi lohkoon kirjoitettu koodi kääritään tällaisiin kutsuihin. Tästä syystä kehyskoodin syvyyksistä löytyy lohkoja, joissa on tyhjä yritys, mutta ei tyhjää lopulta. Microsoft vastustaa tätä menetelmää niin paljon, että he eivät sisällyttäneet sitä .net-ytimeen.

Keskeytysmenetelmä toimii ennakoitavammin. Se voi katkaista ketjun poikkeuksella ThreadInterruptedException vain niinä hetkinä, kun lanka on odotustilassa. Se siirtyy tähän tilaan roikkuessaan odottaessaan WaitHandlea, lukitusta tai kutsuttuaan Thread.Sleep-toimintoa.

Molemmat yllä kuvatut vaihtoehdot ovat huonoja arvaamattomuutensa vuoksi. Ratkaisu on käyttää rakennetta CancellationToken ja luokka CancellationTokenSource. Asia on tämä: CancellationTokenSource-luokan esiintymä luodaan ja vain sen omistava voi pysäyttää toiminnan kutsumalla menetelmää Peruuttaa. Vain CancellationToken välitetään itse toiminnolle. CancellationTokenin omistajat eivät voi itse peruuttaa toimintoa, vaan he voivat vain tarkistaa, onko toiminto peruutettu. Tätä varten on Boolen ominaisuus IsCancelationRequested ja menetelmä ThrowIfCancelRequested. Jälkimmäinen tekee poikkeuksen TaskCancelledException jos Peruuta-menetelmää kutsuttiin CancellationToken-instanssissa, jota parrotoidaan. Ja tämä on menetelmä, jota suosittelen käyttämään. Tämä on parannus aiempiin vaihtoehtoihin, sillä se saa täyden hallinnan siihen, missä vaiheessa poikkeustoiminto voidaan keskeyttää.

Brutaalin vaihtoehto säikeen pysäyttämiseen on kutsua Win32 API TerminateThread -toiminto. CLR:n käyttäytyminen tämän funktion kutsun jälkeen voi olla arvaamatonta. MSDN:ssä tästä funktiosta kirjoitetaan seuraavaa: "TerminateThread on vaarallinen toiminto, jota tulisi käyttää vain äärimmäisissä tapauksissa. "

Muunnetaan vanha sovellusliittymä tehtäväpohjaiseksi FromAsync-menetelmällä

Jos sinulla on onni työskennellä projektin parissa, joka aloitettiin Tasksin käyttöönoton jälkeen ja joka ei enää aiheuttanut hiljaista kauhua useimmille kehittäjille, sinun ei tarvitse käsitellä paljon vanhoja API-liittymiä, sekä kolmansien osapuolien että tiimisi kanssa. on kiduttanut aiemmin. Onneksi .NET Framework -tiimi piti meistä huolta, vaikka ehkä tavoitteena olikin pitää huolta itsestämme. Oli miten oli, .NET:ssä on useita työkaluja vanhoilla asynkronisilla ohjelmointimenetelmillä kirjoitetun koodin kivuttomaan muuttamiseksi uuteen. Yksi niistä on TaskFactoryn FromAsync-menetelmä. Alla olevassa koodiesimerkissä käärin WebRequest-luokan vanhat asynkronointimenetelmät tehtävään tällä menetelmällä.

object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
    wr.BeginGetResponse,
    we.EndGetResponse
);

Tämä on vain esimerkki, ja sinun ei todennäköisesti tarvitse tehdä tätä sisäänrakennetuilla tyypeillä, mutta kaikki vanhat projektit ovat yksinkertaisesti täynnä BeginDoSomething-menetelmiä, jotka palauttavat sen vastaanottavat IAsyncResult- ja EndDoSomething-menetelmät.

Muunna vanha API Tehtäväpohjaiseksi TaskCompletionSource-luokan avulla

Toinen tärkeä huomioitava työkalu on luokka TaskCompletionSource. Toimintojen, tarkoituksen ja toimintaperiaatteen suhteen se saattaa muistuttaa jonkin verran ThreadPool-luokan RegisterWaitForSingleObject-metodia, josta kirjoitin edellä. Tämän luokan avulla voit helposti ja kätevästi kääriä vanhat asynkroniset API:t Tasksiin.

Sanotte, että olen jo puhunut tähän tarkoitukseen tarkoitetusta TaskFactory-luokan FromAsync-menetelmästä. Tässä meidän on muistettava koko Microsoftin viimeisten 15 vuoden aikana tarjoamien asynkronisten .net-mallien kehityshistoria: ennen Task-Based Asynchronous Pattern (TAP) -mallia oli asynkroninen ohjelmointimalli (APP), joka oli kyse menetelmistä AlkaaDoSomething palaa IAsyncResult ja menetelmät pääDoSomething, joka hyväksyy sen, ja näiden vuosien perinnölle FromAsync-menetelmä on täydellinen, mutta ajan myötä se korvattiin tapahtumapohjaisella asynkronisella mallilla (EAP), joka oletti tapahtuman nousevan, kun asynkroninen toiminto on valmis.

TaskCompletionSource on täydellinen tehtävien ja vanhojen sovellusliittymien käärimiseen tapahtumamallin ympärille. Sen työn olemus on seuraava: tämän luokan objektilla on julkinen ominaisuus tyyppiä Task, jonka tilaa voidaan ohjata TaskCompletionSource-luokan SetResult-, SetException- jne. -menetelmillä. Paikoissa, joissa tähän tehtävään on käytetty await-operaattoria, se suoritetaan tai epäonnistuu, lukuun ottamatta TaskCompletionSourcessa käytettyä menetelmää. Jos se ei vieläkään ole selvä, katsotaanpa tätä koodiesimerkkiä, jossa jokin vanha EAP-sovellusliittymä on kääritty tehtävään TaskCompletionSourcea käyttäen: kun tapahtuma käynnistyy, Tehtävä siirretään Valmis-tilaan ja menetelmä, joka käytti await-operaattoria. tähän tehtävään jatkaa sen suorittamista saatuaan kohteen johtua.

public static Task<Result> DoAsync(this SomeApiInstance someApiObj) {

    var completionSource = new TaskCompletionSource<Result>();
    someApiObj.Done += 
        result => completionSource.SetResult(result);
    someApiObj.Do();

    result completionSource.Task;
}

TaskCompletionSource Vinkkejä ja temppuja

Vanhojen sovellusliittymien kääriminen ei ole kaikki, mitä voidaan tehdä TaskCompletionSourcella. Tämän luokan käyttäminen avaa mielenkiintoisen mahdollisuuden suunnitella erilaisia ​​sovellusliittymiä Tasksille, jotka eivät vie säikeitä. Ja stream, kuten muistamme, on kallis resurssi ja niiden lukumäärä on rajoitettu (pääasiassa RAM-muistin määrä). Tämä rajoitus voidaan saavuttaa helposti kehittämällä esimerkiksi ladattu web-sovellus, jossa on monimutkainen liiketoimintalogiikka. Harkitsemme mahdollisuuksia, joista puhun, kun toteutamme sellaista temppua kuin Long-Polling.

Lyhyesti sanottuna tempun ydin on tämä: sinun on saatava API:lta tietoa joistakin sen puolella tapahtuvista tapahtumista, kun taas API jostain syystä ei voi ilmoittaa tapahtumasta, vaan voi vain palauttaa tilan. Esimerkkinä näistä ovat kaikki HTTP:n päälle rakennetut API:t ennen WebSocketin aikoja tai silloin, kun tätä tekniikkaa ei jostain syystä ollut mahdollista käyttää. Asiakas voi kysyä HTTP-palvelimelta. HTTP-palvelin ei voi itse aloittaa viestintää asiakkaan kanssa. Yksinkertainen ratkaisu on pollata palvelin ajastimella, mutta tämä kuormittaa palvelinta ja lisää viivettä keskimäärin TimerInterval / 2. Tämän kiertämiseksi keksittiin Long Polling -niminen temppu, jossa vastausta viivästetään palvelimelle, kunnes aikakatkaisu umpeutuu tai tapahtuu tapahtuma. Jos tapahtuma on tapahtunut, se käsitellään, jos ei, pyyntö lähetetään uudelleen.

while(!eventOccures && !timeoutExceeded)  {

  CheckTimout();
  CheckEvent();
  Thread.Sleep(1);
}

Mutta tällainen ratkaisu osoittautuu kauheaksi heti, kun tapahtumaa odottavien asiakkaiden määrä kasvaa, koska... Jokainen tällainen asiakas vie koko ketjun odottamassa tapahtumaa. Kyllä, ja saamme ylimääräisen 1 ms viiveen, kun tapahtuma laukeaa, useimmiten tämä ei ole merkittävää, mutta miksi tehdä ohjelmistosta huonompi kuin se voi olla? Jos poistamme Thread.Sleep(1), niin turhaan lataamme yhden prosessorin ytimen 100% tyhjäkäynnillä, pyörien turhassa syklissä. Käyttämällä TaskCompletionSourcea voit helposti tehdä tämän koodin uudelleen ja ratkaista kaikki yllä mainitut ongelmat:

class LongPollingApi {

    private Dictionary<int, TaskCompletionSource<Msg>> tasks;

    public async Task<Msg> AcceptMessageAsync(int userId, int duration) {

        var cs = new TaskCompletionSource<Msg>();
        tasks[userId] = cs;
        await Task.WhenAny(Task.Delay(duration), cs.Task);
        return cs.Task.IsCompleted ? cs.Task.Result : null;
    }

    public void SendMessage(int userId, Msg m) {

        if (tasks.TryGetValue(userId, out var completionSource))
            completionSource.SetResult(m);
    }
}

Tämä koodi ei ole tuotantovalmis, vaan vain demo. Käyttääksesi sitä todellisissa tapauksissa, sinun on myös ainakin selvitettävä tilanne, kun viesti saapuu aikana, jolloin kukaan ei odota sitä: tässä tapauksessa AsseptMessageAsync-menetelmän pitäisi palauttaa jo suoritettu tehtävä. Jos tämä on yleisin tapaus, voit harkita ValueTaskin käyttöä.

Kun saamme viestipyynnön, luomme ja sijoitamme sanakirjaan TaskCompletionSource-kohdan ja odotamme sitten, mitä tapahtuu ensin: määritetty aikaväli umpeutuu tai viesti vastaanotetaan.

ValueTask: miksi ja miten

Async/wait-operaattorit, kuten tuottopalautusoperaattori, generoivat menetelmästä tilakoneen, joka on uuden objektin luominen, mikä ei lähes aina ole tärkeää, mutta harvoin se voi aiheuttaa ongelman. Tämä tapaus voi olla menetelmä, jota kutsutaan todella usein, puhumme kymmenistä ja sadoista tuhansista puheluista sekunnissa. Jos tällainen menetelmä on kirjoitettu siten, että useimmissa tapauksissa se palauttaa kaikki odotusmenetelmät ohittavan tuloksen, niin .NET tarjoaa työkalun tämän optimointiin - ValueTask-rakenteen. Selvyyden vuoksi katsotaanpa esimerkkiä sen käytöstä: on välimuisti, johon käymme hyvin usein. Siinä on joitain arvoja, ja sitten yksinkertaisesti palautamme ne; jos ei, niin siirrymme johonkin hitaan IO: hen hankkimaan ne. Haluan tehdä jälkimmäisen asynkronisesti, mikä tarkoittaa, että koko menetelmä osoittautuu asynkroniseksi. Näin ollen ilmeinen tapa kirjoittaa menetelmä on seuraava:

public async Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return val;
    return await RequestById(id);
}

Koska haluat hieman optimoida ja pelkäät hieman, mitä Roslyn luo tätä koodia kääntäessään, voit kirjoittaa tämän esimerkin uudelleen seuraavasti:

public Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return Task.FromResult(val);
    return RequestById(id);
}

Itse asiassa optimaalinen ratkaisu tässä tapauksessa olisi optimoida hot-path, nimittäin arvon saaminen sanakirjasta ilman tarpeettomia varauksia ja kuormitusta GC:lle, kun taas niissä harvoissa tapauksissa, joissa meidän on silti mentävä IO: lle tietojen saamiseksi. , kaikki jää plus/miinus vanhaan tapaan:

public ValueTask<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return new ValueTask<string>(val);
    return new ValueTask<string>(RequestById(id));
}

Katsotaanpa tätä koodinpätkää tarkemmin: jos välimuistissa on arvo, luomme rakenteen, muuten todellinen tehtävä kääritään merkitykselliseen. Kutsuvalla koodilla ei ole väliä millä polulla tämä koodi suoritettiin: ValueTask käyttäytyy C#-syntaksin näkökulmasta tässä tapauksessa samalla tavalla kuin tavallinen tehtävä.

TaskSchedulers: tehtävien käynnistysstrategioiden hallinta

Seuraava API, jota haluaisin harkita, on luokka Tehtävien ajoitus ja sen johdannaiset. Mainitsin jo edellä, että TPL:llä on kyky hallita strategioita tehtävien jakamiseksi säikeiden kesken. Tällaiset strategiat on määritelty TaskScheduler-luokan jälkeläisissä. Kirjastosta löytyy melkein mikä tahansa strategia, jota saatat tarvita. ParallelExtensionsExtras, Microsoftin kehittämä, mutta ei osa .NETiä, vaan toimitetaan Nuget-pakettina. Katsotaanpa lyhyesti joitain niistä:

  • CurrentThreadTaskScheduler — suorittaa tehtävät nykyisessä säikeessä
  • LimitedConcurrencyLevelTaskScheduler — rajoittaa konstruktorissa hyväksytyn parametrin N avulla samanaikaisesti suoritettavien tehtävien määrää
  • TilattuTaskScheduler — määritellään nimellä LimitedConcurrencyLevelTaskScheduler(1), joten tehtävät suoritetaan peräkkäin.
  • WorkStealingTaskScheduler - työvälineet työn varastaminen lähestymistapa tehtävien jakamiseen. Pohjimmiltaan se on erillinen ThreadPool. Ratkaisee ongelman, että .NET ThreadPool on staattinen luokka, yksi kaikille sovelluksille, mikä tarkoittaa, että sen ylikuormitus tai virheellinen käyttö yhdessä ohjelman osassa voi johtaa sivuvaikutuksiin toisessa. Lisäksi on erittäin vaikea ymmärtää tällaisten vikojen syytä. Että. Saattaa olla tarpeen käyttää erillisiä WorkStealingTaskSchedulereita ohjelman osissa, joissa ThreadPoolin käyttö voi olla aggressiivista ja arvaamatonta.
  • QueuedTaskScheduler — voit suorittaa tehtäviä prioriteettijonosääntöjen mukaisesti
  • ThreadPerTaskScheduler — luo erillisen säikeen jokaiselle siinä suoritettavalle tehtävälle. Voi olla hyödyllistä tehtävissä, joiden suorittaminen kestää arvaamattoman kauan.

Siellä on hyvä yksityiskohtainen artikkeli Tietoja TaskSchedulersista Microsoft-blogissa.

Visual Studiossa on Tehtävät-ikkuna, jotta kaiken tehtäviin liittyvän virheenkorjaus onnistuu kätevästi. Tässä ikkunassa näet tehtävän nykyisen tilan ja voit siirtyä parhaillaan suoritettavalle koodiriville.

.NET: Työkaluja monisäikeen ja asynkronian kanssa työskentelemiseen. Osa 1

PLinq ja rinnakkaisluokka

Tehtävien ja kaiken niistä sanotun lisäksi .NET:ssä on kaksi mielenkiintoista työkalua: PLinq (Linq2Parallel) ja Parallel-luokka. Ensimmäinen lupaa kaikkien Linq-toimintojen rinnakkaisen suorittamisen useissa säikeissä. Säikeiden lukumäärä voidaan määrittää WithDegreeOfParallelism-laajennusmenetelmällä. Valitettavasti oletustilassaan PLinqillä ei useimmiten ole tarpeeksi tietoa tietolähteesi sisäisistä ominaisuuksista merkittävän nopeuden lisäämiseksi, toisaalta kokeilun hinta on erittäin alhainen: sinun tarvitsee vain kutsua AsParallel-menetelmä ennen Linq-menetelmien ketju ja suorita suorituskykytestejä. Lisäksi PLinq:lle on mahdollista välittää lisätietoja tietolähteesi luonteesta Partitions-mekanismin avulla. Voit lukea lisää täällä и täällä.

Parallel staattinen luokka tarjoaa menetelmiä iteroidakseen Foreach-kokoelman rinnakkain, suorittaa For-silmukan ja suorittaa useita delegaatteja rinnakkain Invoke. Nykyisen säikeen suoritus pysäytetään, kunnes laskelmat on suoritettu. Säikeiden lukumäärä voidaan määrittää antamalla ParallelOptions viimeisenä argumenttina. Voit myös määrittää TaskScheduler ja CancellationToken käyttämällä vaihtoehtoja.

Tulokset

Kun aloin kirjoittaa tätä artikkelia raportin materiaalien ja sen jälkeen työssäni keräämieni tietojen pohjalta, en odottanut, että sitä olisi näin paljon. Nyt kun tekstieditori, jossa kirjoitan tätä artikkelia, sanoo minulle moittivasti, että sivu 15 on mennyt, teen yhteenvedon välituloksista. Muita temppuja, API:ita, visuaalisia työkaluja ja sudenkuoppia käsitellään seuraavassa artikkelissa.

Päätelmät:

  • Sinun on tiedettävä työkalut säikeiden, asynkronisuuden ja rinnakkaisuuden kanssa työskentelyyn, jotta voit käyttää nykyaikaisten tietokoneiden resursseja.
  • .NET:llä on monia erilaisia ​​työkaluja näihin tarkoituksiin
  • Kaikki eivät ilmestyneet kerralla, joten voit usein löytää vanhoja, mutta on olemassa tapoja muuntaa vanhat API:t ilman paljon vaivaa.
  • Säikeiden käsittelyä .NET:ssä edustavat Thread- ja ThreadPool-luokat
  • Thread.Abort-, Thread.Interrupt- ja Win32 API TerminateThread -menetelmät ovat vaarallisia, eikä niitä suositella käytettäväksi. Sen sijaan on parempi käyttää CancellationToken-mekanismia
  • Flow on arvokas resurssi ja sen tarjonta on rajallinen. Tilanteita, joissa säikeet odottavat tapahtumia, tulee välttää. Tätä varten on kätevää käyttää TaskCompletionSource-luokkaa
  • Tehokkaimmat ja edistyneimmät .NET-työkalut rinnakkaisuuden ja asynkronisuuden kanssa työskentelemiseen ovat Tasks.
  • C# async/await -operaattorit toteuttavat estottoman odotuksen käsitteen
  • Voit hallita tehtävien jakautumista säikeiden kesken TaskScheduler-pohjaisten luokkien avulla
  • ValueTask-rakenne voi olla hyödyllinen hot-polkujen ja muistiliikenteen optimoinnissa
  • Visual Studion Tasks- ja Threads-ikkunat tarjoavat paljon hyödyllistä tietoa monisäikeisen tai asynkronisen koodin virheenkorjauksessa
  • PLinq on hieno työkalu, mutta sillä ei ehkä ole tarpeeksi tietoa tietolähteestäsi, mutta tämä voidaan korjata osiointimekanismilla
  • Jatkuu ...

Lähde: will.com

Lisää kommentti