.NET: Mga tool para sa pagtatrabaho sa multithreading at asynchrony. Bahagi 1

Inilalathala ko ang orihinal na artikulo sa Habr, na ang pagsasalin ay nai-post sa korporasyon post ng blog.

Ang pangangailangan na gumawa ng isang bagay nang hindi sabaysabay, nang hindi naghihintay ng resulta dito at ngayon, o hatiin ang malaking gawain sa ilang mga yunit na gumaganap nito, ay umiral bago ang pagdating ng mga computer. Sa kanilang pagdating, ang pangangailangang ito ay naging lubhang nasasalat. Ngayon, sa 2019, tina-type ko ang artikulong ito sa isang laptop na may 8-core Intel Core processor, kung saan higit sa isang daang proseso ang tumatakbo nang magkatulad, at higit pang mga thread. Sa malapit, mayroong isang medyo sira na telepono, binili ilang taon na ang nakakaraan, mayroon itong 8-core na processor na nakasakay. Ang mga mapagkukunang pampakay ay puno ng mga artikulo at video kung saan hinahangaan ng kanilang mga may-akda ang mga flagship smartphone ngayong taon na nagtatampok ng 16-core na mga processor. Nagbibigay ang MS Azure ng virtual machine na may 20 core processor at 128 TB RAM para sa mas mababa sa $2/oras. Sa kasamaang palad, imposibleng kunin ang maximum at gamitin ang kapangyarihang ito nang hindi mapangasiwaan ang pakikipag-ugnayan ng mga thread.

terminolohiya

Proseso - OS object, nakahiwalay na address space, ay naglalaman ng mga thread.
Thread - isang OS object, ang pinakamaliit na unit ng execution, bahagi ng isang proseso, ang mga thread ay nagbabahagi ng memorya at iba pang mapagkukunan sa kanilang mga sarili sa loob ng isang proseso.
Multitasking - Pag-aari ng OS, ang kakayahang magpatakbo ng ilang mga proseso nang sabay-sabay
Multi-core - isang pag-aari ng processor, ang kakayahang gumamit ng ilang mga core para sa pagproseso ng data
Multiprocessing - isang pag-aari ng isang computer, ang kakayahang sabay-sabay na gumana sa ilang mga processor sa pisikal
Multithreading — isang pag-aari ng isang proseso, ang kakayahang ipamahagi ang pagproseso ng data sa ilang mga thread.
Paralelismo - pagsasagawa ng ilang mga aksyon na pisikal nang sabay-sabay sa bawat yunit ng oras
Asynchrony — pagpapatupad ng isang operasyon nang hindi naghihintay para sa pagkumpleto ng pagproseso na ito; ang resulta ng pagpapatupad ay maaaring iproseso sa ibang pagkakataon.

Metapora

Hindi lahat ng kahulugan ay mabuti at ang ilan ay nangangailangan ng karagdagang paliwanag, kaya magdaragdag ako ng metapora tungkol sa pagluluto ng almusal sa pormal na ipinakilalang terminolohiya. Ang pagluluto ng almusal sa metapora na ito ay isang proseso.

Habang naghahanda ako ng almusal sa umaga (CPU) pumunta ako sa kusina (Computer). Mayroon akong 2 kamay (Core). Mayroong ilang mga device sa kusina (IO): hurno, takure, toaster, refrigerator. Binuksan ko ang gas, nilagyan ito ng kawali at nagbuhos ng mantika dito nang hindi hinintay na uminit (asynchronously, Non-Blocking-IO-Wait), kinukuha ko ang mga itlog sa refrigerator at pinaghiwa-hiwa ang mga ito sa isang plato, pagkatapos ay talunin ang mga ito gamit ang isang kamay (Thread #1), at pangalawa (Thread #2) hawak ang plato (Shared Resource). Ngayon gusto kong buksan ang takure, ngunit wala akong sapat na mga kamay (Pagkagutom sa Thread) Sa panahong ito, umiinit ang kawali (Pinaproseso ang resulta) kung saan ibinuhos ko ang aking hinalo. Inabot ko ang takure at binuksan ito at tulala na pinapanood ang tubig na kumukulo dito (Pag-block-IO-Maghintay), bagama't sa panahong ito ay maaari niyang hugasan ang plato kung saan niya pinalo ang omelet.

Nagluto ako ng omelette gamit lamang ang 2 kamay, at wala na ako, ngunit sa parehong oras, sa sandali ng paghagupit ng omelette, 3 operasyon ang naganap nang sabay-sabay: paghagupit ng omelette, hawak ang plato, pag-init ng kawali . Ang CPU ay ang pinakamabilis na bahagi ng computer, ang IO ang kadalasang bumabagal ang lahat, kaya kadalasan ang isang epektibong solusyon ay ang sakupin ang CPU ng isang bagay habang tumatanggap ng data mula sa IO.

Pagpapatuloy ng metapora:

  • Kung sa proseso ng paghahanda ng omelet, susubukan ko ring magpalit ng damit, ito ay isang halimbawa ng multitasking. Isang mahalagang nuance: ang mga computer ay mas mahusay dito kaysa sa mga tao.
  • Isang kusina na may ilang chef, halimbawa sa isang restaurant - isang multi-core na computer.
  • Maraming mga restaurant sa isang food court sa isang shopping center - data center

.NET Tools

Ang .NET ay mahusay sa pagtatrabaho sa mga thread, tulad ng sa maraming iba pang mga bagay. Sa bawat bagong bersyon, nagpapakilala ito ng higit at higit pang mga bagong tool para sa pagtatrabaho sa kanila, mga bagong layer ng abstraction sa mga OS thread. Kapag nagtatrabaho sa pagbuo ng mga abstraction, ang mga developer ng framework ay gumagamit ng isang diskarte na nag-iiwan ng pagkakataon, kapag gumagamit ng isang mataas na antas ng abstraction, upang bumaba ng isa o higit pang mga antas sa ibaba. Kadalasan ito ay hindi kinakailangan, sa katunayan ito ay nagbubukas ng pinto sa pagbaril sa iyong sarili sa paa gamit ang isang shotgun, ngunit kung minsan, sa mga bihirang kaso, ito ay maaaring ang tanging paraan upang malutas ang isang problema na hindi nalutas sa kasalukuyang antas ng abstraction .

Sa pamamagitan ng mga tool, ang ibig kong sabihin ay parehong application programming interface (API) na ibinigay ng framework at mga third-party na pakete, pati na rin ang buong solusyon sa software na nagpapasimple sa paghahanap para sa anumang mga problema na nauugnay sa multi-threaded code.

Pagsisimula ng isang thread

Ang Thread class ay ang pinakapangunahing klase sa .NET para sa pagtatrabaho sa mga thread. Tinatanggap ng constructor ang isa sa dalawang delegado:

  • ThreadStart — Walang mga parameter
  • ParametrizedThreadStart - na may isang parameter ng uri ng object.

Ipapatupad ang delegate sa bagong likhang thread pagkatapos tawagan ang Start method. Kung ang isang delegate na may uri ng ParametrizedThreadStart ay ipinasa sa constructor, kung gayon ang isang object ay dapat na maipasa sa Start method. Ang mekanismong ito ay kinakailangan upang ilipat ang anumang lokal na impormasyon sa stream. Ito ay nagkakahalaga na tandaan na ang paglikha ng isang thread ay isang mamahaling operasyon, at ang thread mismo ay isang mabigat na bagay, hindi bababa sa dahil ito ay naglalaan ng 1MB ng memorya sa stack at nangangailangan ng pakikipag-ugnayan sa OS API.

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

Ang klase ng ThreadPool ay kumakatawan sa konsepto ng isang pool. Sa .NET, ang thread pool ay isang piraso ng engineering, at ang mga developer sa Microsoft ay naglagay ng maraming pagsisikap sa pagtiyak na ito ay mahusay na gumagana sa iba't ibang uri ng mga sitwasyon.

Pangkalahatang konsepto:

Mula sa sandaling magsimula ang application, lumilikha ito ng ilang mga thread na nakalaan sa background at nagbibigay ng kakayahang kunin ang mga ito para magamit. Kung ang mga thread ay madalas na ginagamit at sa maraming bilang, ang pool ay lumalawak upang matugunan ang mga pangangailangan ng tumatawag. Kapag walang libreng thread sa pool sa tamang oras, maghihintay ito sa isa sa mga thread na bumalik, o gagawa ng bago. Kasunod nito na ang thread pool ay mahusay para sa ilang panandaliang pagkilos at hindi angkop para sa mga operasyon na tumatakbo bilang mga serbisyo sa buong operasyon ng application.

Upang gumamit ng thread mula sa pool, mayroong QueueUserWorkItem na paraan na tumatanggap ng delegado ng uri ng WaitCallback, na may parehong lagda bilang ParametrizedThreadStart, at ang parameter na ipinasa dito ay gumaganap ng parehong function.

ThreadPool.QueueUserWorkItem(...);

Ang hindi gaanong kilalang thread pool method na RegisterWaitForSingleObject ay ginagamit upang ayusin ang mga hindi nakaharang na operasyon ng IO. Ang delegado na ipinasa sa paraang ito ay tatawagin kapag ang WaitHandle na ipinasa sa pamamaraan ay "Inilabas".

ThreadPool.RegisterWaitForSingleObject(...)

Ang .NET ay may thread timer at ito ay naiiba sa WinForms/WPF timers dahil ang handler nito ay tatawagin sa isang thread na kinuha mula sa pool.

System.Threading.Timer

Mayroon ding kakaibang paraan upang magpadala ng delegado para sa pagpapatupad sa isang thread mula sa pool - ang BeginInvoke method.

DelegateInstance.BeginInvoke

Nais kong maikling pag-isipan ang function kung saan maaaring tawagin ang marami sa mga pamamaraan sa itaas - CreateThread mula sa Kernel32.dll Win32 API. Mayroong isang paraan, salamat sa mekanismo ng mga panlabas na pamamaraan, upang tawagan ang function na ito. Isang beses lang akong nakakita ng ganoong tawag sa isang kakila-kilabot na halimbawa ng legacy code, at nananatiling misteryo sa akin ang motibasyon ng may-akda na gumawa nito nang eksakto.

Kernel32.dll CreateThread

Pagtingin at Pag-debug sa Mga Thread

Ang mga thread na ginawa mo, lahat ng third-party na bahagi, at ang .NET pool ay maaaring tingnan sa Threads window ng Visual Studio. Ang window na ito ay magpapakita lamang ng impormasyon ng thread kapag ang application ay nasa ilalim ng debug at nasa Break mode. Dito maaari mong maginhawang tingnan ang mga pangalan ng stack at priyoridad ng bawat thread, at ilipat ang pag-debug sa isang partikular na thread. Gamit ang Priority property ng Thread class, maaari mong itakda ang priyoridad ng isang thread, na iisipin ng OC at CLR bilang isang rekomendasyon kapag hinahati ang oras ng processor sa pagitan ng mga thread.

.NET: Mga tool para sa pagtatrabaho sa multithreading at asynchrony. Bahagi 1

Task Parallel Library

Ang Task Parallel Library (TPL) ay ipinakilala sa .NET 4.0. Ngayon ito ay ang pamantayan at ang pangunahing tool para sa pagtatrabaho sa asynchrony. Ang anumang code na gumagamit ng mas lumang diskarte ay itinuturing na legacy. Ang pangunahing yunit ng TPL ay ang klase ng Task mula sa System.Threading.Tasks namespace. Ang isang gawain ay isang abstraction sa isang thread. Gamit ang bagong bersyon ng wikang C#, nakakuha kami ng eleganteng paraan upang gumana sa Tasks - async/wait operators. Ang mga konseptong ito ay naging posible na magsulat ng asynchronous code na parang simple at magkasabay, ito ay naging posible kahit para sa mga taong may kaunting pag-unawa sa mga panloob na gawain ng mga thread na magsulat ng mga application na gumagamit ng mga ito, mga application na hindi nag-freeze kapag nagsasagawa ng mahabang operasyon. Ang paggamit ng async/wait ay isang paksa para sa isa o kahit ilang artikulo, ngunit susubukan kong makuha ang diwa nito sa ilang pangungusap:

  • Ang async ay isang modifier ng isang paraan na nagbabalik ng Gawain o walang bisa
  • at ang wait ay isang hindi nakaharang na operator ng paghihintay ng Task.

Muli: ang naghihintay na operator, sa pangkalahatang kaso (may mga pagbubukod), ay ilalabas pa ang kasalukuyang thread ng pagpapatupad, at kapag natapos na ng Task ang pagpapatupad nito, at ang thread (sa katunayan, mas tamang sabihin ang konteksto , ngunit higit pa sa na mamaya) ay magpapatuloy sa pagpapatupad ng pamamaraan. Sa loob ng .NET, ang mekanismong ito ay ipinatupad sa parehong paraan tulad ng yield return, kapag ang nakasulat na paraan ay naging isang buong klase, na isang state machine at maaaring isagawa sa magkahiwalay na piraso depende sa mga estadong ito. Ang sinumang interesado ay maaaring magsulat ng anumang simpleng code gamit ang asynс/wait, compile at tingnan ang assembly gamit ang JetBrains dotPeek na may naka-enable na Compiler Generated Code.

Tingnan natin ang mga opsyon para sa paglulunsad at paggamit ng Task. Sa halimbawa ng code sa ibaba, lumikha kami ng isang bagong gawain na walang kapaki-pakinabang (Thread.Sleep(10000)), ngunit sa totoong buhay ito ay dapat na isang kumplikadong gawaing masinsinang CPU.

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
}

Ang isang Gawain ay nilikha na may ilang mga opsyon:

  • Ang LongRunning ay isang pahiwatig na ang gawain ay hindi matatapos nang mabilis, na nangangahulugan na maaaring sulit na isaalang-alang ang hindi pagkuha ng isang thread mula sa pool, ngunit lumikha ng isang hiwalay na isa para sa Gawaing ito upang hindi makapinsala sa iba.
  • AttachedToParent - Maaaring isaayos ang mga gawain sa isang hierarchy. Kung ginamit ang opsyong ito, ang Gawain ay maaaring nasa isang estado kung saan ito mismo ay nakumpleto at naghihintay para sa pagpapatupad ng mga anak nito.
  • PreferFairness - nangangahulugan na mas mainam na isagawa ang mga Gawain na ipinadala para sa pagpapatupad nang mas maaga bago ang mga ipinadala sa ibang pagkakataon. Ngunit ito ay isang rekomendasyon lamang at ang mga resulta ay hindi ginagarantiyahan.

Ang pangalawang parameter na ipinasa sa pamamaraan ay CancellationToken. Upang maayos na mahawakan ang pagkansela ng isang operasyon pagkatapos na magsimula, ang code na isinasagawa ay dapat punan ng mga tseke para sa estado ng CancellationToken. Kung walang mga pagsusuri, ang paraan ng Kanselahin na tinatawag sa object ng CancellationTokenSource ay magagawang ihinto ang pagsasagawa ng Gawain bago ito magsimula.

Ang huling parameter ay isang scheduler object ng uri ng TaskScheduler. Ang klase na ito at ang mga inapo nito ay idinisenyo upang kontrolin ang mga diskarte para sa pamamahagi ng Mga Gawain sa mga thread; bilang default, ang Task ay isasagawa sa isang random na thread mula sa pool.

Inilapat ang operator ng await sa ginawang Task, na nangangahulugang ang code na nakasulat pagkatapos nito, kung mayroon man, ay isasagawa sa parehong konteksto (madalas na nangangahulugan ito sa parehong thread) bilang ang code bago maghintay.

Ang pamamaraan ay minarkahan bilang async void, na nangangahulugang magagamit nito ang operator ng naghihintay, ngunit ang code sa pagtawag ay hindi makapaghintay para sa pagpapatupad. Kung kinakailangan ang gayong tampok, dapat ibalik ng pamamaraan ang Gawain. Ang mga pamamaraan na may markang async void ay medyo karaniwan: bilang panuntunan, ito ay mga tagapangasiwa ng kaganapan o iba pang mga pamamaraan na gumagana sa apoy at nakakalimutan ang prinsipyo. Kung kailangan mong hindi lamang magbigay ng pagkakataon na maghintay hanggang sa katapusan ng pagpapatupad, ngunit ibalik din ang resulta, pagkatapos ay kailangan mong gumamit ng Task.

Sa Task na ibinalik ng StartNew method, pati na rin sa iba pa, maaari mong tawagan ang ConfigureAwait method na may false parameter, pagkatapos ay magpapatuloy ang execution pagkatapos ng paghihintay hindi sa nakunan na konteksto, ngunit sa isang arbitraryong isa. Dapat itong palaging gawin kapag ang konteksto ng pagpapatupad ay hindi mahalaga para sa code pagkatapos maghintay. Ito rin ay isang rekomendasyon mula sa MS kapag nagsusulat ng code na ihahatid na nakabalot sa isang library.

Pag-isipan pa natin nang kaunti kung paano ka makapaghintay para sa pagkumpleto ng isang Gawain. Nasa ibaba ang isang halimbawa ng code, na may mga komento sa kapag ang inaasahan ay tapos na nang maayos at kapag ito ay tapos na may kondisyon na hindi maganda.

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
}

Sa unang halimbawa, hinihintay namin na makumpleto ang Gawain nang hindi hinaharangan ang thread ng pagtawag; babalik lang kami sa pagproseso ng resulta kapag naroon na ito; hanggang doon, ang thread ng pagtawag ay naiwan sa sarili nitong mga device.

Sa pangalawang opsyon, hinarangan namin ang thread ng pagtawag hanggang sa makalkula ang resulta ng pamamaraan. Ito ay masama hindi lamang dahil sinakop namin ang isang thread, tulad ng isang mahalagang mapagkukunan ng programa, na may simpleng katamaran, ngunit din dahil kung ang code ng pamamaraan na tinatawag namin ay naglalaman ng naghihintay, at ang konteksto ng pag-synchronize ay nangangailangan ng pagbabalik sa calling thread pagkatapos maghintay, pagkatapos ay magkakaroon tayo ng deadlock : Ang calling thread ay naghihintay para sa resulta ng asynchronous na paraan upang makalkula, ang asynchronous na pamamaraan ay sumusubok na walang kabuluhan upang ipagpatuloy ang pagpapatupad nito sa calling thread.

Ang isa pang kawalan ng diskarteng ito ay kumplikadong paghawak ng error. Ang katotohanan ay ang mga error sa asynchronous code kapag gumagamit ng async/wait ay napakadaling pangasiwaan - pareho silang kumikilos na parang kasabay ang code. Habang kung ilalapat natin ang sabay-sabay na paghihintay na exorcism sa isang Gawain, ang orihinal na pagbubukod ay magiging isang AggregateException, i.e. Upang mahawakan ang exception, kakailanganin mong suriin ang uri ng InnerException at magsulat ng if chain sa loob ng isang catch block o gamitin ang catch kapag construct, sa halip na ang chain ng catch blocks na mas pamilyar sa C# world.

Ang ikatlo at huling mga halimbawa ay minarkahan din ng masama para sa parehong dahilan at naglalaman ng lahat ng parehong mga problema.

Ang WhenAny at WhenAll na mga pamamaraan ay lubos na maginhawa para sa paghihintay para sa isang pangkat ng mga Gawain; binabalot nila ang isang pangkat ng mga Gawain sa isa, na magpapagana kapag ang isang Gawain mula sa pangkat ay unang na-trigger, o kapag ang lahat ng mga ito ay nakumpleto ang kanilang pagpapatupad.

Paghinto ng mga thread

Para sa iba't ibang dahilan, maaaring kailanganin na ihinto ang daloy pagkatapos na magsimula. Mayroong ilang mga paraan upang gawin ito. Ang klase ng Thread ay may dalawang angkop na pinangalanang pamamaraan: pagkalaglag и abala. Ang una ay lubos na hindi inirerekomenda para sa paggamit, dahil pagkatapos tawagan ito sa anumang random na sandali, sa panahon ng pagproseso ng anumang pagtuturo, isang pagbubukod ang itatapon ThreadAbortedException. Hindi mo inaasahan ang gayong eksepsiyon na itatapon kapag dinadagdagan ang anumang integer variable, tama ba? At kapag ginagamit ang pamamaraang ito, ito ay isang tunay na sitwasyon. Kung kailangan mong pigilan ang CLR na makabuo ng ganoong eksepsiyon sa isang partikular na seksyon ng code, maaari mo itong i-wrap sa mga tawag. Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Ang anumang code na nakasulat sa isang block sa wakas ay nakabalot sa mga naturang tawag. Para sa kadahilanang ito, sa kalaliman ng framework code maaari kang makahanap ng mga bloke na may isang walang laman na pagsubok, ngunit hindi isang walang laman sa wakas. Masyadong hindi hinihikayat ng Microsoft ang pamamaraang ito kaya hindi nila ito isinama sa .net core.

Ang paraan ng Interrupt ay gumagana nang mas predictably. Maaari nitong matakpan ang thread na may exception ThreadInterruptedException lamang sa mga sandaling iyon kapag ang thread ay nasa kalagayang naghihintay. Papasok ito sa ganitong estado habang nakabitin habang naghihintay ng WaitHandle, i-lock, o pagkatapos tawagan ang Thread.Sleep.

Ang parehong mga opsyon na inilarawan sa itaas ay masama dahil sa kanilang hindi mahuhulaan. Ang solusyon ay ang paggamit ng isang istraktura CancellationToken at klase CancellationTokenSource. Ang punto ay ito: ang isang halimbawa ng klase ng CancellationTokenSource ay nilikha at ang isa lamang na nagmamay-ari nito ang maaaring huminto sa operasyon sa pamamagitan ng pagtawag sa pamamaraan. kanselahin. Tanging ang CancellationToken ang ipinapasa sa mismong operasyon. Hindi maaaring kanselahin ng mga may-ari ng CancellationToken ang operasyon mismo, ngunit maaari lamang suriin kung nakansela ang operasyon. Mayroong Boolean property para dito IsCancellationRequested at pamamaraan ThrowIfCancelRequested. Ang huli ay magtapon ng isang pagbubukod TaskCancelledException kung ang paraan ng Kanselahin ay tinawag sa instance ng CancellationToken na na-parrote. At ito ang paraan na inirerekomenda kong gamitin. Ito ay isang pagpapabuti sa mga nakaraang opsyon sa pamamagitan ng pagkakaroon ng ganap na kontrol sa kung anong punto ang isang exception operation ay maaaring i-abort.

Ang pinaka-brutal na opsyon para sa paghinto ng thread ay ang tawagan ang Win32 API TerminateThread function. Ang pag-uugali ng CLR pagkatapos tawagan ang function na ito ay maaaring hindi mahuhulaan. Sa MSDN ang sumusunod ay nakasulat tungkol sa function na ito: "Ang TerminateThread ay isang mapanganib na function na dapat lamang gamitin sa mga pinaka-matinding kaso. “

Pag-convert ng legacy na API sa Task Based gamit ang FromAsync method

Kung ikaw ay mapalad na gumawa sa isang proyekto na sinimulan pagkatapos na ipakilala ang Mga Gawain at tumigil na magdulot ng tahimik na kakila-kilabot para sa karamihan ng mga developer, kung gayon hindi mo na kailangang harapin ang maraming lumang API, parehong mga third-party at iyong mga koponan. ay pinahirapan sa nakaraan. Sa kabutihang-palad, inalagaan kami ng .NET Framework team, bagaman marahil ang layunin ay pangalagaan ang ating sarili. Maging na ito ay maaaring, .NET ay may isang bilang ng mga tool para sa painlessly pag-convert ng code na nakasulat sa lumang asynchronous programming approach sa bago. Ang isa sa mga ito ay ang FromAsync na paraan ng TaskFactory. Sa halimbawa ng code sa ibaba, binabalot ko ang mga lumang pamamaraan ng async ng klase ng WebRequest sa isang Gawain gamit ang paraang ito.

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

Isa lamang itong halimbawa at malamang na hindi mo ito kailangang gawin gamit ang mga built-in na uri, ngunit ang anumang lumang proyekto ay puno ng mga pamamaraan ng BeginDoSomething na nagbabalik ng mga pamamaraan ng IAsyncResult at EndDoSomething na tumatanggap nito.

I-convert ang legacy na API sa Task Based gamit ang klase ng TaskCompletionSource

Ang isa pang mahalagang tool na dapat isaalang-alang ay ang klase Pinagmulan ng Pagkumpleto ng Gawain. Sa mga tuntunin ng mga pag-andar, layunin at prinsipyo ng pagpapatakbo, maaaring medyo nakapagpapaalaala sa pamamaraan ng RegisterWaitForSingleObject ng klase ng ThreadPool, na isinulat ko tungkol sa itaas. Gamit ang klase na ito, madali at maginhawa mong ma-wrap ang mga lumang asynchronous na API sa Tasks.

Sasabihin mo na napag-usapan ko na ang tungkol sa paraan ng FromAsync ng klase ng TaskFactory na nilayon para sa mga layuning ito. Dito kailangan nating tandaan ang buong kasaysayan ng pagbuo ng mga asynchronous na modelo sa .net na inaalok ng Microsoft sa nakalipas na 15 taon: bago ang Task-Based Asynchronous Pattern (TAP), mayroong Asynchronous Programming Pattern (APP), na ay tungkol sa mga pamamaraan MagsimulaDoSomething bumabalik IAsyncResult at mga pamamaraan katapusanDoSomething na tumatanggap nito at para sa legacy ng mga taong ito ang FromAsync method ay perpekto lang, ngunit sa paglipas ng panahon, ito ay pinalitan ng Event Based Asynchronous Pattern (EAP), na ipinapalagay na ang isang kaganapan ay itataas kapag natapos ang asynchronous na operasyon.

Ang TaskCompletionSource ay perpekto para sa pagbabalot ng Mga Gawain at legacy na API na binuo sa modelo ng kaganapan. Ang kakanyahan ng gawain nito ay ang mga sumusunod: ang isang bagay ng klase na ito ay may pampublikong pag-aari ng uri ng Task, ang estado kung saan maaaring kontrolin sa pamamagitan ng mga pamamaraan ng SetResult, SetException, atbp. ng klase ng TaskCompletionSource. Sa mga lugar kung saan inilapat ang naghihintay na operator sa Gawaing ito, ito ay isasagawa o mabibigo nang may pagbubukod depende sa pamamaraang inilapat sa TaskCompletionSource. Kung hindi pa rin malinaw, tingnan natin ang halimbawa ng code na ito, kung saan ang ilang lumang EAP API ay nakabalot sa isang Gawain gamit ang isang TaskCompletionSource: kapag naganap ang kaganapan, ililipat ang Gawain sa Katayuang Nakumpleto, at ang paraan na naglapat sa operator ng naghihintay. sa Gawain na ito ay magpapatuloy sa pagpapatupad nito pagkatanggap ng bagay resulta.

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

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

    result completionSource.Task;
}

Mga Tip at Trick ng TaskCompletionSource

Ang pag-wrap ng mga lumang API ay hindi lahat ng maaaring gawin gamit ang TaskCompletionSource. Ang paggamit sa klase na ito ay nagbubukas ng isang kawili-wiling posibilidad ng pagdidisenyo ng iba't ibang mga API sa Mga Gawain na hindi sumasakop sa mga thread. At ang stream, tulad ng naaalala natin, ay isang mamahaling mapagkukunan at ang kanilang bilang ay limitado (pangunahin sa dami ng RAM). Ang limitasyong ito ay madaling makamit sa pamamagitan ng pagbuo, halimbawa, ng isang load na web application na may kumplikadong lohika ng negosyo. Isaalang-alang natin ang mga posibilidad na pinag-uusapan ko kapag nagpapatupad ng ganitong trick gaya ng Long-Polling.

Sa madaling sabi, ang esensya ng trick ay ito: kailangan mong makatanggap ng impormasyon mula sa API tungkol sa ilang mga kaganapang nagaganap sa gilid nito, habang ang API, sa ilang kadahilanan, ay hindi maaaring mag-ulat ng kaganapan, ngunit maaari lamang ibalik ang estado. Ang isang halimbawa ng mga ito ay ang lahat ng mga API na binuo sa tuktok ng HTTP bago ang mga oras ng WebSocket o kapag imposible sa ilang kadahilanan na gamitin ang teknolohiyang ito. Maaaring magtanong ang kliyente sa HTTP server. Ang HTTP server ay hindi maaaring magsimula ng komunikasyon sa kliyente. Ang isang simpleng solusyon ay ang pag-poll sa server gamit ang isang timer, ngunit ito ay lumilikha ng karagdagang pag-load sa server at isang karagdagang pagkaantala sa average na TimerInterval / 2. Upang makayanan ito, isang trick na tinatawag na Long Polling ay naimbento, na kinabibilangan ng pagkaantala sa tugon mula sa ang server hanggang sa mag-expire ang Timeout o isang kaganapan ang magaganap. Kung naganap ang kaganapan, pagkatapos ito ay naproseso, kung hindi, pagkatapos ay ipapadala muli ang kahilingan.

while(!eventOccures && !timeoutExceeded)  {

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

Ngunit ang gayong solusyon ay magiging kakila-kilabot sa sandaling tumaas ang bilang ng mga kliyenteng naghihintay para sa kaganapan, dahil... Ang bawat ganoong kliyente ay sumasakop sa isang buong thread na naghihintay para sa isang kaganapan. Oo, at nakakakuha kami ng karagdagang 1ms na pagkaantala kapag na-trigger ang kaganapan, kadalasan ay hindi ito makabuluhan, ngunit bakit mas malala ang software kaysa sa maaari? Kung aalisin namin ang Thread.Sleep(1), walang kabuluhan na maglo-load kami ng isang core ng processor na 100% idle, umiikot sa isang walang kwentang cycle. Gamit ang TaskCompletionSource madali mong gawing muli ang code na ito at malutas ang lahat ng problemang natukoy sa itaas:

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);
    }
}

Ang code na ito ay hindi handa sa produksyon, ngunit isang demo lamang. Upang magamit ito sa mga totoong kaso, kailangan mo rin, sa pinakamababa, upang pangasiwaan ang sitwasyon kapag dumating ang isang mensahe sa oras na walang umaasa nito: sa kasong ito, ang paraan ng AsseptMessageAsync ay dapat magbalik ng nakumpletong Gawain. Kung ito ang pinakakaraniwang kaso, maaari mong isipin ang tungkol sa paggamit ng ValueTask.

Kapag nakatanggap kami ng kahilingan para sa isang mensahe, gumagawa kami at naglalagay ng TaskCompletionSource sa diksyunaryo, at pagkatapos ay hihintayin kung ano ang unang mangyayari: ang tinukoy na agwat ng oras ay mag-e-expire o ang isang mensahe ay natanggap.

ValueTask: bakit at paano

Ang mga operator ng async/wait, tulad ng yield return operator, ay bumubuo ng state machine mula sa pamamaraan, at ito ay ang paglikha ng isang bagong bagay, na halos palaging hindi mahalaga, ngunit sa mga bihirang kaso maaari itong lumikha ng isang problema. Ang kasong ito ay maaaring isang paraan na talagang madalas na tinatawag, pinag-uusapan natin ang tungkol sa sampu at daan-daang libong mga tawag sa bawat segundo. Kung ang ganitong paraan ay isinulat sa paraang sa karamihan ng mga kaso ay nagbabalik ito ng resulta na lumalampas sa lahat ng mga pamamaraan ng paghihintay, kung gayon ang .NET ay nagbibigay ng tool upang ma-optimize ito - ang istraktura ng ValueTask. Upang gawing malinaw, tingnan natin ang isang halimbawa ng paggamit nito: mayroong isang cache na madalas nating pinupuntahan. Mayroong ilang mga halaga sa loob nito at pagkatapos ay ibabalik lamang namin ang mga ito; kung hindi, pagkatapos ay pupunta kami sa ilang mabagal na IO upang makuha ang mga ito. Gusto kong gawin ang huli nang asynchronous, na nangangahulugang ang buong pamamaraan ay lumalabas na asynchronous. Kaya, ang malinaw na paraan upang isulat ang pamamaraan ay ang mga sumusunod:

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

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

Dahil sa pagnanais na mag-optimize ng kaunti, at isang bahagyang takot sa kung ano ang bubuo ni Roslyn kapag kino-compile ang code na ito, maaari mong muling isulat ang halimbawang ito tulad ng sumusunod:

public Task<string> GetById(int id) {

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

Sa katunayan, ang pinakamainam na solusyon sa kasong ito ay ang pag-optimize ng hot-path, ibig sabihin, pagkuha ng isang halaga mula sa diksyunaryo nang walang anumang hindi kinakailangang paglalaan at pag-load sa GC, habang sa mga bihirang kaso na kailangan pa nating pumunta sa IO para sa data , lahat ay mananatiling plus /minus sa dating paraan:

public ValueTask<string> GetById(int id) {

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

Tingnan natin ang piraso ng code na ito: kung mayroong isang halaga sa cache, lumikha kami ng isang istraktura, kung hindi, ang tunay na gawain ay balot sa isang makabuluhang isa. Ang calling code ay walang pakialam kung saang path na-execute ang code na ito: ValueTask, mula sa isang C# syntax point of view, ay magiging katulad ng isang regular na Gawain sa kasong ito.

Mga TaskScheduler: pamamahala ng mga diskarte sa paglulunsad ng gawain

Ang susunod na API na gusto kong isaalang-alang ay ang klase gawain Scheduler at mga derivatives nito. Nabanggit ko na sa itaas na may kakayahan ang TPL na pamahalaan ang mga diskarte para sa pamamahagi ng Mga Gawain sa mga thread. Ang ganitong mga diskarte ay tinukoy sa mga inapo ng klase ng TaskScheduler. Halos anumang diskarte na maaaring kailangan mo ay matatagpuan sa library. ParallelExtensionsExtras, na binuo ng Microsoft, ngunit hindi bahagi ng .NET, ngunit ibinigay bilang isang Nuget package. Tingnan natin sandali ang ilan sa mga ito:

  • CurrentThreadTaskScheduler — nagsasagawa ng Mga Gawain sa kasalukuyang thread
  • LimitedConcurrencyLevelTaskScheduler — nililimitahan ang bilang ng mga Gawain na isinagawa nang sabay-sabay ng parameter N, na tinatanggap sa constructor
  • OrderedTaskScheduler — ay tinukoy bilang LimitedConcurrencyLevelTaskScheduler(1), kaya ang mga gawain ay isasagawa nang sunud-sunod.
  • WorkStealingTaskScheduler - nagpapatupad pagnanakaw ng trabaho diskarte sa pamamahagi ng gawain. Sa pangkalahatan, ito ay isang hiwalay na ThreadPool. Nilulutas ang problema na sa .NET ThreadPool ay isang static na klase, isa para sa lahat ng mga application, na nangangahulugan na ang sobrang karga o maling paggamit nito sa isang bahagi ng program ay maaaring humantong sa mga side effect sa isa pa. Bukod dito, napakahirap na maunawaan ang sanhi ng naturang mga depekto. yun. Maaaring may pangangailangang gumamit ng hiwalay na WorkStealingTaskSchedulers sa mga bahagi ng programa kung saan ang paggamit ng ThreadPool ay maaaring agresibo at hindi mahuhulaan.
  • QueueTaskScheduler — nagbibigay-daan sa iyo na magsagawa ng mga gawain ayon sa mga tuntunin ng priority queue
  • ThreadPerTaskScheduler — lumilikha ng isang hiwalay na thread para sa bawat Gawain na isinasagawa dito. Maaaring maging kapaki-pakinabang para sa mga gawaing nagtatagal ng hindi inaasahang mahabang panahon upang makumpleto.

May magandang detalyado artikulo tungkol sa TaskSchedulers sa microsoft blog.

Para sa maginhawang pag-debug ng lahat ng nauugnay sa Mga Gawain, ang Visual Studio ay may window ng Mga Gawain. Sa window na ito makikita mo ang kasalukuyang estado ng gawain at tumalon sa kasalukuyang gumaganang linya ng code.

.NET: Mga tool para sa pagtatrabaho sa multithreading at asynchrony. Bahagi 1

PLinq at ang Parallel class

Bilang karagdagan sa Mga Gawain at lahat ng sinabi tungkol sa kanila, may dalawa pang kawili-wiling tool sa .NET: PLinq (Linq2Parallel) at ang Parallel na klase. Ang una ay nangangako ng parallel na pagpapatupad ng lahat ng mga operasyon ng Linq sa maraming mga thread. Maaaring i-configure ang bilang ng mga thread gamit ang WithDegreeOfParallelism na paraan ng extension. Sa kasamaang palad, kadalasan ang PLinq sa default na mode nito ay walang sapat na impormasyon tungkol sa mga panloob ng iyong data source para makapagbigay ng makabuluhang pagtaas ng bilis, sa kabilang banda, ang gastos sa pagsubok ay napakababa: kailangan mo lang tawagan ang AsParallel method bago ang kadena ng mga pamamaraan ng Linq at magpatakbo ng mga pagsubok sa pagganap. Bukod dito, posibleng magpasa ng karagdagang impormasyon sa PLinq tungkol sa likas na katangian ng iyong data source gamit ang mekanismo ng Partitions. Maaari mong basahin ang higit pa dito и dito.

Ang Parallel static na klase ay nagbibigay ng mga pamamaraan para sa pag-ulit sa pamamagitan ng isang Foreach na koleksyon nang magkatulad, pag-execute ng For loop, at pag-execute ng maramihang mga delegado sa parallel na Invoke. Ihihinto ang pagpapatupad ng kasalukuyang thread hanggang sa makumpleto ang mga kalkulasyon. Maaaring i-configure ang bilang ng mga thread sa pamamagitan ng pagpasa sa ParallelOptions bilang huling argumento. Maaari mo ring tukuyin ang TaskScheduler at CancellationToken gamit ang mga opsyon.

Natuklasan

Noong sinimulan kong isulat ang artikulong ito batay sa mga materyales ng aking ulat at ang impormasyong nakolekta ko sa panahon ng aking trabaho pagkatapos nito, hindi ko inaasahan na magkakaroon ng ganito karami. Ngayon, kapag ang text editor kung saan ako nagta-type ng artikulong ito ay may kapintasang sinabi sa akin na ang pahina 15 ay nawala, ibubuod ko ang mga pansamantalang resulta. Iba pang mga trick, API, visual na tool at pitfalls ay tatalakayin sa susunod na artikulo.

Konklusyon:

  • Kailangan mong malaman ang mga tool para sa pagtatrabaho sa mga thread, asynchrony at parallelism upang magamit ang mga mapagkukunan ng mga modernong PC.
  • Ang .NET ay may maraming iba't ibang tool para sa mga layuning ito
  • Hindi lahat ng mga ito ay lumitaw nang sabay-sabay, kaya madalas kang makakahanap ng mga legacy, gayunpaman, may mga paraan upang i-convert ang mga lumang API nang walang labis na pagsisikap.
  • Ang pagtatrabaho sa mga thread sa .NET ay kinakatawan ng mga klase ng Thread at ThreadPool
  • Ang mga pamamaraan ng Thread.Abort, Thread.Interrupt, at Win32 API TerminateThread ay mapanganib at hindi inirerekomenda para sa paggamit. Sa halip, mas mabuting gamitin ang mekanismo ng CancellationToken
  • Ang daloy ay isang mahalagang mapagkukunan at limitado ang supply nito. Dapat iwasan ang mga sitwasyon kung saan abala ang mga thread sa paghihintay ng mga kaganapan. Para dito, maginhawang gamitin ang klase ng TaskCompletionSource
  • Ang pinakamakapangyarihan at advanced na .NET na mga tool para sa pagtatrabaho sa parallelism at asynchrony ay ang Mga Gawain.
  • Ang mga operator ng c# async/wait ay nagpapatupad ng konsepto ng hindi nakaharang na paghihintay
  • Makokontrol mo ang pamamahagi ng Mga Gawain sa mga thread gamit ang mga klase na nagmula sa TaskScheduler
  • Maaaring maging kapaki-pakinabang ang istraktura ng ValueTask sa pag-optimize ng mga hot-path at memory-traffic
  • Ang Visual Studio's Tasks and Threads windows ay nagbibigay ng maraming impormasyon na kapaki-pakinabang para sa pag-debug ng multi-threaded o asynchronous na code
  • Ang PLinq ay isang cool na tool, ngunit maaaring wala itong sapat na impormasyon tungkol sa iyong data source, ngunit maaari itong ayusin gamit ang mekanismo ng partitioning
  • Upang patuloy ...

Pinagmulan: www.habr.com

Magdagdag ng komento