.NET: Інструменты для працы з шматструменнасцю і асінхроннасцю. Частка 1

Публікую на Хабр арыгінал артыкула, пераклад якога размешчаны ў карпаратыўным блогу.

Неабходнасць рабіць нешта асінхронна, не чакаючы вынік тут і цяпер, ці падзяляць вялікую працу паміж некалькімі выконваючымі яе адзінкамі была і да з'яўлення кампутараў. З іх з'яўленнем такая неабходнасць стала вельмі адчувальнай. Цяпер, у 2019, набіраючы гэты артыкул на ноўтбуку з 8 ядзерным працэсарам Intel Core, на якім паралельна гэтаму працуе не адна сотня працэсаў, а патокаў і таго больш. Побач, ляжыць ужо крыху патрапаны, набыты пару гадоў таму тэлефон, у яго на борце 8 ядзерны працэсар. На тэматычных рэсурсах поўна артыкулаў і відэа, дзе іх аўтары захапляюцца флагманскімі смартфонамі гэтага года куды ставяць 16-ядзерныя працэсары. MS Azure дае менш чым за 20 $ / гадзіну віртуальную машыну са 128 ядзерным працэсарам і 2 TB RAM. Нажаль немагчыма атрымаць максімум і ўтаймаваць гэтую моц не ўмеючы кіраваць узаемадзеяннем струменяў.

тэрміналогія

Працэс (Process) - аб'ект АС, ізаляванае адраснае прастору, утрымоўвае струмені.
Струмень (Thread) - аб'ект АС, найменшая адзінка выканання, частка працэсу, патокі дзеляць памяць і іншыя рэсурсы паміж сабой у рамках працэсу.
шматзадачнасць - Уласцівасць АС, магчымасць выконваць некалькі працэсаў адначасова
Шмат'ядзернасць - Уласцівасць працэсара, магчымасць выкарыстоўваць некалькі ядраў для апрацоўкі дадзеных
Шматпрацэсарнасць - Уласцівасць кампутара, магчымасць адначасова працаваць з некалькімі працэсарамі фізічна
Шматструменнасць - Уласцівасць працэсу, магчымасць размяркоўваць апрацоўку дадзеных паміж некалькімі патокамі.
Паралельнасць - выкананне некалькіх дзеянняў фізічна адначасова ў адзінку часу
Асінхроннасць - Выкананне аперацыі без чакання заканчэння завяршэння гэтай апрацоўкі, вынік жа выканання можа быць апрацаваны пазней.

метафара

Не ўсе азначэнні добрыя і некаторыя маюць патрэбу ў дадатковым тлумачэнні, таму да фармальна ўведзенай тэрміналогіі дадам метафару аб падрыхтоўцы сняданку. Падрыхтоўка сняданку ў гэтай метафары - process.

Рыхтуючы сняданак з раніцы я (CPU) прыходжу на кухню (Кампутар). У мяне 2 рукі (Ядра). На кухні ёсць шэраг прылад (IO): печ, чайнік, тостар, халадзільнік. Я ўключаю газ, стаўлю на яго патэльню і наліваю туды алей, не чакаючы пакуль яна разагрэецца.асінхронна, Non-Blocking-IO-Wait), я дастаю з халадзільніка яйкі і разбіваю іх у талерку, пасля чаго ўзбіваю адной рукой (Thread#1), а другі (Thread#2) прытрымліваю талерку (Shared Resource). Цяпер бы яшчэ ўключыць чайнік, але рук не хапае (Thread Starvation) За гэты час разаграваецца патэльня (Апрацоўка выніку) куды я выліваю тое што ўзбіў. Я дацягваюся да імбрычка і ўключаю яго і тупа гляджу як вада ў ім закіпае (Blocking-IO-Wait), хоць мог бы за гэты час вымыць талерку, дзе ўзбіваў амлет.

Я рыхтаваў амлет выкарыстоўваючы ўсяго 2 рукі, ды больш у мяне і няма, але пры гэтым у момант узбівання амлета адбывалася адразу 3 аперацыі: узбіванне амлета, прытрымліванне талеркі, разаграванне патэльні. CPU - з'яўляецца самай хуткай часткай кампутара, IO гэта тое, што часцей за ўсё за ўсё тармозіць, таму часта эфектыўным рашэннем з'яўляецца заняць чымсьці CPU пакуль ідзе атрыманне дадзеных ад IO.

Працягваючы метафару:

  • Калі б у працэсе гатавання амлета, я б яшчэ і спрабаваў пераапрануцца гэта быў бы прыклад шматзадачнасці. Важны нюанс: у кампутараў з гэтым куды лепш, чым у людзей.
  • Кухня з некалькімі кухарамі, напрыклад у рэстаране - шмат'ядравы кампутар.
  • Мноства рэстаранаў на фудкорце ў гандлёвым цэнтры - датацэнтр.

Інструменты .NET

У працы са струменямі, як і шмат у чым іншым,. NET добры. З кожнай новай версіяй ён уяўляе ўсё больш новых прылад для працы з імі, новыя пласты абстракцыі над струменямі АС. У працы з пабудовай абстракцый распрацоўшчыкі фрэймворка выкарыстоўваюць падыход які пакідае магчымасць пры выкарыстанні высокаўзроўневай абстракцыі, спусціцца на адзін або некалькі ўзроўняў ніжэй. Часцей за ўсё ў гэтым няма неабходнасці, больш за тое гэта адкрывае магчымасць для стрэлу сабе ў нагу з драбавіку, але часам, у рэдкіх выпадках, гэта можа апынуцца адзіным спосабам вырашыць праблему, не вырашальную на бягучым узроўні абстракцыі.

Пад прыладамі я маю на ўвазе як праграмныя інтэрфейсы (API) якія прадстаўляюцца фрэймворком і іншымі пакетамі, так і цэлы праграмныя рашэнні які спрашчае пошук якія-небудзь праблем злучаных са шматструменным кодам.

Запуск патоку

Клас Thread, самы базавы ў .NET для працы са струменямі. У канструктар прымае адзін з двух дэлегатаў:

  • ThreadStart - Без параметраў
  • ParametrizedThreadStart - з адным параметрам тыпу object.

Дэлегат будзе выкананы ў ізноў створаным струмені пасля выкліку метаду Start, калі ў канструктар быў перададзены дэлегат тыпу ParametrizedThreadStart, то ў метад Start неабходна перадаць аб'ект. Гэты механізм патрэбен для перадачы любой лакальнай інфармацыі ў паток. Варта адзначыць што стварэнне струменя гэта дарагая аперацыя, а сам струмень гэта цяжкі аб'ект, прынамсі таму, што адбываецца вылучэнне 1МБ памяці на стэк, і патрабуе ўзаемадзеянні з API АС.

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

Клас ThreadPool уяўляе канцэпцыю пула. У .NET пул патокаў з'яўляецца творам інжынернага мастацтва і распрацоўшчыкі з Microsoft уклалі мноства намаганняў, каб ён працаваў аптымальна ў самых розных сцэнарах.

Агульная канцэпцыя:

З моманту старту прыкладанне ў фоне стварае некалькі патокаў пра запас і дае магчымасць браць іх у карыстанне. Калі струмені выкарыстоўваюцца часта і ў вялікай колькасці, то пул пашыраецца, каб задаволіць запатрабаванне выклікалага кода. Калі ў пуле ў патрэбны момант часу не апыняецца вольных струменяў ён ці дачакаецца звароту аднаго з струменяў, альбо створыць новы. З гэтага вынікае, што пул патокаў выдатна падыходзіць для нейкіх кароткіх дзеянняў і дрэнна падыходзіць, для аперацый якія працуюць як службы на працягу ўсёй працы прыкладанняў.

Для выкарыстання струменя з пула, ёсць метад QueueUserWorkItem, які прымае дэлегат тыпу WaitCallback, што па сігнатуры супадае з ParametrizedThreadStart, а які перадаецца ў яго параметр выконвае тужэй функцыю.

ThreadPool.QueueUserWorkItem(...);

Менш вядомы метад пула патокаў RegisterWaitForSingleObject служыць для арганізацыі неблакіруючых IO аперацый. Дэлегат перададзены ў гэты метад будзе выкліканы тады, калі WaitHandle перададзены ў метад будзе "адпушчаны" (Released).

ThreadPool.RegisterWaitForSingleObject(...)

У .NET ёсць струменевы таймер і адрозніваецца ён ад таймераў WinForms/WPF тым, што яго апрацоўшчык будзе выкліканы ў струмені ўзятым з пула.

System.Threading.Timer

Гэтак жа ёсць даволі экзатычны спосаб адправіць дэлегат на выкананне ў струмень з пула - метад BeginInvoke.

DelegateInstance.BeginInvoke

Хачу яшчэ мімаходзь спыніцца на функцыі да выкліку якой зводзіцца многія з вышэйпаказаных метадаў – CreateThread з Kernel32.dll Win32 API. Існуе спосаб дзякуючы механізму extern метадаў выклікаць гэтую функцыю. Я бачыў такі выклік толькі аднойчы ў жудасным прыкладзе legacy кода, а матывацыя аўтара які зрабіў менавіта так усё яшчэ застаецца для мяне загадкай.

Kernel32.dll CreateThread

Прагляд і адладка патокаў

Створаныя вамі асабіста, усімі третисторонними кампанентамі і пулам .NET струмені можна прагледзець у акне Threads Visual Studio. Гэта акно адлюструе інфармацыю аб патоках толькі, калі прыкладанне будзе знаходзіцца пад адладкай і ў рэжыме супыну (Break mode). Тут можна зручна прагледзець стэк імёны і прыярытэты кожнага патоку, пераключыць адладку на канкрэтны паток. Уласцівасцю Priority класа Thread можна задаць прыярытэт струменя, які OC і CLR будуць успрымаць як рэкамендацыю пры падзеле працэсарнага часу паміж струменямі.

.NET: Інструменты для працы з шматструменнасцю і асінхроннасцю. Частка 1

Task Parallel Library

Task Parallel Library (TPL) з'явіўся ў .NET 4.0. Цяпер гэта стандарт і асноўны інструмент для працы з асінхроннасцю. Любы код які выкарыстоўвае больш старыя падыход лічыцца legacy. Асноўнай адзінкай TPL з'яўляецца клас Task з прасторы імёнаў System.Threading.Tasks. Task уяўляе сабой абстракцыю над патокам. З новай версіяй мовы C# мы атрымалі хупавы спосаб працы з Task`амі – аператары async/await. Гэтыя канцэпцыі дазволілі пісаць асінхронны код так, як калі б ён быў простым і сінхронным, гэта дало магчымасць нават людзям слаба разумеюць унутраную кухню патокаў пісаць прыкладанні іх выкарыстоўваюць, прыкладанні не якія завісаюць пры выкананні доўгіх аперацый. Выкарыстанне async/await тэма для адной ці нават некалькіх артыкулаў, але я паспрабую ў некалькіх сказах абкласці сутнасць:

  • async гэта мадыфікатар метаду які вяртае Task або void
  • а await аператар неблакіруючага чакання Task`а.

Яшчэ раз: аператар await, у агульным выпадку (ёсць выключэнні), адпусціць бягучы струмень выканання далей, а калі Task скончыць сваё выкананне, а струмень (насамрэч правільней сказаць кантэкст, але пра гэта пазней) будзе вольны працягне выкананні метаду далей. Усярэдзіне .NET гэты механізм рэалізаваны гэтак жа як і yield return, калі напісаны метад ператвараецца ў цэлы клас, які з'яўляецца машынай станаў і можа быць выкананы асобнымі кавалкамі ў залежнасці ад гэтых станаў. Каму цікава можа напісаць любы нескладаны код з выкарыстаннем asynс/await, скампіляваць і прагледзець зборку з дапамогай JetBrains dotPeek з уключаным Compiler Generated Code.

Разгледзім варыянты запуску і выкарыстанні Task'а. На прыкладзе кода ніжэй, мы ствараем новы таск, які не робіць нічога карыснага (Thread.Sleep(10000)), але ў рэальным жыцці гэта павінна быць нейкая складаная якая задзейнічае 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
}

Task ствараецца з шэрагам опцый:

  • LongRunning - падказка аб тым, што задача не будзе выканана хутка, а значыць, магчыма, варта падумаць над тым каб не браць струмень з пула, а стварыць асобны пад гэтую Task'у каб не нашкодзіць астатнім.
  • AttachedToParent - Task'і могуць выбудоўвацца ў іерархіі. Калі была выкарыстаная гэтая опцыя, то Task можа знаходзіцца ў стане, калі сам ён выканаўся і чакае выканання даччыных.
  • PreferFairness - азначае, што добра б выконваць Task'і адпраўленыя на выкананне раней перад тымі, што былі адпраўленыя пазней. Але гэта толькі рэкамендацыя і вынік не гарантаваны.

Другім параметрам у метад перададзены CancellationToken. Для карэктнай апрацоўкі адмены аперацыі пасля яе запуску выкананы код павінен быць напоўнены праверкамі стану CancellationToken. Калі праверак няма, то метад Cancel выкліканы на аб'екце CancellationTokenSource зможа спыніць выкананне Task'а толькі да яго запуску.

Апошнім параметрам перададзены аб'ект scheduler тыпу TaskScheduler. Гэты клас і яго спадчыннікі прызначаны для кіравання стратэгіямі размеркавання Task'ов па струменях, па змаўчанні Task будзе выкананы на выпадковым струмені з пула.

Да створанага Task'у ужыты аператар await, а значыць код напісаны пасля яго, калі такі ёсць будзе выкананы ў тым жа кантэксце (часта гэта азначае што на тым жа струмені), што і код да await.

Метад пазначаны як async void, гэта значыць, што ў ім дапушчальна выкарыстанне аператара await, але выклікае код не зможа дачакацца выкананні. Калі такая магчымасць неабходна, то метад павінен вяртаць Task. Метады пазначаныя async void сустракаюцца даволі часта: як правіла гэта апрацоўшчыкі падзей ці іншыя метады, якія працуюць па прынцыпе выканаць і забыцца (fire and forget). Калі неабходна не толькі даць магчымасць дачакацца канчаткі выканання, але і вярнуць вынік, тое неабходна выкарыстоўваць Task.

На Task'е што вярнуў метад StartNew, зрэшты як і на любым іншым, можна выклікаць метад ConfigureAwait з параметрам false, тады выкананне пасля await працягнецца не на захопленым кантэксце, а на адвольным. Гэта трэба рабіць заўсёды, калі для кода пасля await не прынцыповы кантэкст выканання. Таксама гэта з'яўляецца рэкамендацыяй ад MS пры напісанні кода, што будзе пастаўляцца ўпакаваным у бібліятэку выглядзе.

Давайце яшчэ крыху спынімся на тым, як можна дачакацца заканчэння выканання Task'і. Ніжэй прыклад кода, з каментарамі, калі чаканне зроблена ўмоўна добра і калі ўмоўна дрэнна.

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
}

У першым прыкладзе мы чакаем выкананні Task'і не блакуючы выклікальны струмень, да апрацоўкі выніку вернемся толькі калі ён ужо будзе, датуль выклікальны струмень прадстаўлены сабе.

У другім варыянце мы блакуем выклікае струмень датуль пакуль не будзе падлічаны вынік метаду. Гэта дрэнна не толькі таму, што мы занялі струмень, гэтак каштоўны рэсурс праграмы, простым бяздзеяннем, але яшчэ і таму, што калі ў кодзе метаду што мы выклікаем ёсць await, а кантэкст сінхранізацыі мяркуе вяртанне ў выклікае струмень пасля await, то мы атрымаем deadlock : выклікае струмень чакае пакуль будзе вылічаны вынік асінхроннага метаду, асінхронны метад дарэмна спрабуе працягнуць сваё выкананне ў выклікалым струмені.

Яшчэ адным недахопам такога падыходу з'яўляецца ўскладненая апрацоўка памылак. Справа ў тым, што памылкі ў асінхронным кодзе пры выкарыстанні async/await апрацоўваць вельмі лёгка – яны паводзяць сябе гэтак жа як калі б код быў сінхронным. У той час, як калі мы ўжываем экзарцызм сінхроннае чаканне да Task'e арыгінальнае выключэнне абарочваецца ў AggregateException, т.ч. Для апрацоўкі выключэння прыйдзецца даследаваць тып InnerException і самому пісаць ланцужок if усярэдзіне аднаго catch блока або выкарыстаць канструкцыю catch when, замест больш звыклай у C# свеце ланцужкі catch блокаў.

Трэці і апошні прыклады таксама адзначаны дрэннымі па той жа прычыне і змяшчаюць усе тыя ж праблемы.

Метады WhenAny і WhenAll вельмі зручныя ў чаканні групы Task'ов, яны абгортваюць групу Task'ов у адзін, які спрацуе альбо па першым спрацоўванні Task'а з групы, альбо калі сваё выкананне скончаць усё.

Прыпынак патокаў

Па розных прычынах можа з'явіцца неабходнасць спыніць паток пасля яго старту. Для гэтага існуе шэраг спосабаў. У класа Thread ёсць два метаду з прыдатнымі назвамі - гэта выкідак и перапыненне. Першы вельмі не рэкамендуецца да выкарыстання, т.я. пасля яго выкліку ў любы выпадковы момант, у працэсе апрацоўкі любой інструкцыі, будзе выкінута выключэнне ThreadAbortedException. Вы ж не чакаеце, што такое выключэнне вылеціць пры інкрэмэнце якой-небудзь цэлалікай зменнай, праўда? А пры выкарыстанні гэтага метаду гэта суцэль рэальная сітуацыя. У выпадку неабходнасці забараніць CLR генераваць такое выключэнне ў пэўным участку кода можна абгарнуць яго ў выклікі Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Такімі выклікамі абарочваецца любы код напісаны ў finally блоку. Па гэтай прычыне ў нетрах кода фрэймворка можна знайсці блокі з пустым try, але не пустым finally. Microsoft настолькі не рэкамендуе выкарыстоўваць гэты метад, што не ўлучылі яго ў .net core.

Метад Interrupt працуе больш прадказальна. Ён можа спыніць паток выключэннем ThreadInterruptedException толькі ў тыя моманты, калі паток знаходзіцца ў стане чакання. У такі стан ён пераходзіць падвісаючы ў чаканні WaitHandle, lock ці пасля выкліку Thread.Sleep.

Абодва апісаных вышэй варыянты, дрэнныя сваёй непрадказальнасцю. Выйсцем з'яўляецца выкарыстанне структуры CancellationToken і класа CancellationTokenSource. Іста ў наступным: ствараецца асобнік класа CancellationTokenSource і толькі той хто ім валодае, можа спыніць аперацыю выклікаўшы метад адмяніць. У саму ж аперацыю перадаецца толькі CancellationToken. Уладальнікі CancellationToken не могуць самі адмяніць аперацыю, а могуць толькі праверыць, ці не была аперацыя адменена. Для гэтага ёсць булева ўласцівасць IsCancellationRequested і метад ThrowIfCancelRequested. Апошні згенеруе выключэнне TaskCancelledException калі на які парадзіў CancellationToken асобніку CancellationTokenSource быў выкліканы метад Cancel. І менавіта гэты метад я рэкамендую выкарыстоўваць. Гэта лепш за папярэднія варыянты атрыманнем поўнага кантролю над тым у якія моманты выключэнне аперацыя можа быць перапынена.

Самым жорсткім варыянтам прыпынку патоку з'яўляецца выклік функцыі Win32 API TerminateThread. Паводзіны CLR пасля выкліку гэтай функцыі могуць быць непрадказальнымі. На MSDN жа пра гэтую функцыю напісана наступнае: “TerminateThread з'яўляецца нізкай функцыяй, якая павінна толькі быць выкарыстана ў вялікіх выпадках. “

Пераўтварэнне legacy-API у Task Based з дапамогай метаду FromAsync

Калі вам пашчасціла працаваць на праекце, што быў пачаты ўжо пасля таго як Task'і былі ўведзеныя і перасталі выклікаць ціхі жах большасці распрацоўнікаў, то вам не прыйдзецца мець справу з вялікай колькасцю старых API, як трэці, так і вымучаных вашай камандай у мінулым. На шчасце, каманда распрацоўкі. NET Framework паклапацілася пра нас, хоць магчыма мэтай быў клопат пра сябе. Як бы там ні было ў. NET ёсць шэраг інструментаў для бязбольнага пераўтварэння кода напісанага ў старых падыходах асінхроннага праграмавання ў новую. Адзін з іх гэта метад FromAsync у TaskFactory. На прыкладзе кода ніжэй, я абарочваю старыя асінхронныя метады класа WebRequest у Task з дапамогай гэтага метаду.

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

Гэта толькі прыклад і рабіць такое з убудаванымі тыпамі вам ці наўрад прыйдзецца, але любы стары праект проста кішыць метадамі BeginDoSomething якія вяртаюць IAsyncResult і метадамі EndDoSomething яго якія прымаюць.

Пераўтварэнне legacy-API у Task Based з дапамогай класа TaskCompletionSource

Яшчэ адзін важны для разгляду інструмент, гэта клас TaskCompletionSource. Па функцый, прызначэнню і прынцыпу працы ён чымсьці можа нагадаць метад RegisterWaitForSingleObject класа ThreadPool аб якім я пісаў вышэй. З дапамогай гэтага класа можна лёгка і зручна абгортваць старыя асінхронныя API у Task'і.

Вы скажаце, што я ўжо казаў аб метадзе FromAsync класа TaskFactory прызначаным для гэтых мэт. Тут давядзецца ўспомніць усю гісторыю развіцця асінхронных мадэляў у .net што прапаноўваў microsoft за апошнія 15 гадоў: да Task-Based ПачынацьDoSomething які вяртае IAsyncResult і метадах канецDoSomething яго які прымае і для legacy гэтых гадоў як раз выдатна падыходзіць метад FromAsync, але з часам, на змену яму прыйшоў Event Based Asynchronous Pattern (EAP), які меркаваў, што па завяршэнні выканання асінхроннай аперацыі будзе выклікана падзея.

TaskCompletionSource як раз выдатна падыходзіць для абгорткі ў Task'і legacy-API пабудаваных вакол падзейнай мадэлі. Сутнасць яго працы ў наступным: у аб'екта гэтага класа ёсць публічная ўласцівасць тыпу Task станам якога можна кіраваць праз метады SetResult, SetException і інш. Класа TaskCompletionSource. У месцах жа дзе быў ужыты аператар await да гэтага Task'у ён будзе выкананы ці абрушаны з выключэннем у залежнасці ад ужытага да TaskCompletionSource метаду. Калі ўсё яшчэ не зразумела, то давайце паглядзім на гэты прыклад кода, дзе нейкае старое API часоў EAP заварочваецца ў Task пры дапамозе TaskCompletionSource: пры спрацоўванні падзеі Task будзе пераведзены ў стан Completed, а метад які ўжыў да гэтага Task'у аператар await адновіць сваё выкананне атрымаўшы аб'ект вынік.

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 Tips & Tricks

Абгортка старых API гэта не ўсё што можна пракруціць з дапамогай TaskCompletionSource. Выкарыстанне гэтага класа адчыняе цікавую магчымасць праектавання розных API, на Task'ах, што не займаюць струмені. А паток, як мы памятаем рэсурс дарагі і колькасць іх абмежавана (у асноўным аб'ёмам RAM). Гэтага абмежавання лёгка дасягнуць распрацоўваючы, напрыклад, нагружанае web-дадатак са складанай бізнес логікай. Разгледзім тыя магчымасці аб якіх я кажу на рэалізацыі такога трука як Long-Polling.

Калі коратка сутнасць трука вось у чым: вам трэба атрымліваць ад API інфармацыю аб некаторых падзеях адбывалых на ім боку, пры гэтым API па нейкіх чынніках не можа паведаміць аб падзеі, а можа толькі вярнуць стан. Прыклад такіх - усе API пабудаваныя па-над HTTP да часоў WebSocket або пры немагчымасці па нейкім чынніку выкарыстоўваць гэтую тэхналогію. Кліент можа спытаць у HTTP сервера. HTTP сервер не можа сам справакаваць зносіны з кліентам. Простым рашэннем з'яўляецца апытанне сервера па таймеры, але гэта стварае дадатковую нагрузку на сервер і дадатковую затрымку ў сярэднім TimerInterval / 2. Для абыходу гэтага быў вынайдзены трук які атрымаў назву Long Polling, якія мяркуе затрымку адказу ад сервера да таго часу, пакуль не скончыцца Timeout ці не адбудзецца падзея. Калі падзея адбылася, то яна апрацоўваецца, калі не, то запыт дасылаецца нанова.

while(!eventOccures && !timeoutExceeded)  {

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

Але такое рашэнне пакажа сябе жудасна, як толькі лік кліентам якія чакаюць падзея вырасце, т.к. Кожны такі кліент у чаканні падзеі займае цэлы паток. Ды і атрымліваем дадатковую затрымку ў 1мс на спрацоўванні падзеі, часцей за ўсё гэта не істотна, але навошта рабіць ПЗ горш чым яно можа быць? Калі ж прыбраць Thread.Sleep(1), то дарма загрузім адно ядро ​​працэсара на 100% у халастую круцячыся ў бескарысным цыкле. З дапамогай TaskCompletionSource можна лёгка перарабіць гэты код і вырашыць усе пазначаныя вышэй праблемы:

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

Гэты код не з'яўляецца production-ready, а толькі дэманстрацыйным. Для выкарыстання ў рэальных выпадках трэба яшчэ, як-мінімум, апрацаваць сітуацыю, калі паведамленне прыйшло ў момант, калі яго ніхто не чакае: у такім выпадку метад AsseptMessageAsync павінен вярнуць ужо завершаны Task. Калі ж гэты выпадак і з'яўляецца найболей частым, то можна падумаць і аб выкарыстанні ValueTask.

Пры атрыманні запыту на паведамленне мы ствараем і змяшчаем у слоўнік TaskCompletionSource, а далей чакаем што адбудзецца перш: скончыцца зададзены інтэрвал часу або будзе атрымана паведамленне.

ValueTask: навошта і як

Аператары async/await як і аператар yield return генеруе з метаду машыну станаў, а гэтае стварэнне новага аб'екта, што амаль заўсёды не важна, але ў рэдкіх выпадках можа стварыць праблему. Гэтым выпадкам можа быць метад выкліканы сапраўды часта, гаворка пра дзясяткі і сотні тысяч выклікаў у секунду. Калі такі метад напісаны так, што ў большасці выпадкаў ён вяртае вынік абыходзячы ўсе await метады, то .NET падае прыладу што б гэта аптымізаваць - структура ValueTask. Каб стала зразумела разгледзім прыклад яго выкарыстання: ёсць кэш, у які мы ходзім вельмі часта. Нейкія значэння ў ім ёсць і тады мы іх проста вяртаем, калі не, то ідзем у які-небудзь павольны IO за імі. Апошняе жадаецца рабіць асінхронна, а значыць увесь метад атрымліваецца асінхронным. Такім чынам відавочны варыянт напісання метаду - наступны:

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

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

З-за жадання крыху аптымізаваць, і лёгкай боязі з нагоды таго, што згенеруе Roslyn кампілюючы гэты код, можна гэты прыклад перапісаць наступным чынам:

public Task<string> GetById(int id) {

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

Сапраўды ж аптымальным рашэннем у гэтым выпадку будзе аптымізаваць hot-path, а менавіта атрыманне значэння са слоўніка наогул без лішніх алакацый і нагрузкі на GC, у той час калі ў тых рэдкіх выпадках, калі нам усё ж такі трэба ісці ў IO за дадзенымі ўсё застанецца плюс /мінус па старым:

public ValueTask<string> GetById(int id) {

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

Давайце падрабязней разбяром гэты фрагмент кода: пры наяўнасці значэння ў кэшы мы ствараем структуру, у адваротным выпадку рэальны ж таск будзе загорнуты ў значны. Які выклікае коду ўсё роўна па якім шляху выконваўся гэты код: ValueTask з пункта гледжання сінтаксісу C# будзе паводзіць сябе гэтак жа як і звычайны Task у гэтым выпадку.

TaskScheduler'ы: кіраванне стратэгіямі запуску Task'ов

Наступнае API, што хацелася б разгледзець гэты клас TaskScheduler і яго вытворныя. Я ужо згадваў вышэй, што ў TPL ёсць магчымасць кіраваць стратэгіямі размеркавання Task'аў па плынях. Такія стратэгіі вызначаюцца ў спадчынніках класа TaskScheduler. Практычна любая стратэгія, што можа спатрэбіцца будзе знойдзена ў бібліятэцы ParallelExtensionsExtras, распрацаванай microsoft, але не якая з'яўляецца часткай .NET, а якая пастаўляецца ў выглядзе Nuget пакета. Коратка разгледзім некаторыя з іх:

  • CurrentThreadTaskScheduler - выконвае Task'і на бягучым струмені
  • LimitedConcurrencyLevelTaskScheduler - абмяжоўвае лік выкананых адначасова Task'ов параметрам N, што прымае ў канструктару
  • OrderedTaskScheduler - вызначаецца як LimitedConcurrencyLevelTaskScheduler(1), таму задачы будуць выконвацца паслядоўна.
  • WorkStealingTaskScheduler - рэалізуе work-stealing падыход да размеркавання задач. Па сутнасці з'яўляецца асобным ThreadPool. Вырашае праблему таго, што ў .NET ThreadPool гэта статычны клас, адзін на ўсе прыкладанні, а значыць яго перагрузка ці няправільнае выкарыстанне ў адной частцы праграмы можа прывесці да пабочных эфектаў у іншай. Больш за тое зразумець прычыну такіх дэфектаў вельмі складана. В.а. можа існаваць неабходнасць выкарыстоўваць асобныя WorkStealingTaskScheduler'ы ў тых частках праграмы, дзе выкарыстанне ThreadPool можа быць агрэсіўным і непрадказальным.
  • QueuedTaskScheduler - дазваляе выконваць задачы па правілах чаргі з прыярытэтамі
  • ThreadPerTaskScheduler - стварае асобны паток на кожны Task што на ім выконваецца. Можа быць карысна для задач якія выконваюцца непрадказальна доўга.

Ёсць добрая падрабязная артыкул аб TaskScheduler'ах у блогу microsoft.

Для зручнай адладкі за ўсё звязанага з Task'амі ў Visual Studio ёсць акно Tasks. У гэтым акне можна ўбачыць бягучы стан задачы і перайсці да выкананай у дадзены момант радку кода.

.NET: Інструменты для працы з шматструменнасцю і асінхроннасцю. Частка 1

PLinq і клас Parallel

Акрамя Task'ов і ўсяго з імі сказанага ў .NET ёсць яшчэ два цікавых прылады гэта PLinq(Linq2Parallel) і клас Parallel. Першы абяцае паралельнае выкананне ўсіх Linq аперацый на некалькіх плынях. Лік струменяў можна сканфігураваць метадам-пашырэннем WithDegreeOfParallelism. Нажаль, часцей за ўсё PLinq у рэжыме працы па змаўчанні не хопіць інфармацыі аб вантробах вашай крыніцы дадзеных, каб забяспечыць істотны выйгрыш па хуткасці, з іншага боку кошт спробы вельмі нізкая: трэба ўсяго толькі выклікаць метад AsParallel перад ланцужком Linq метадаў і правесці тэсты прадукцыйнасці. Больш за тое існуе магчымасць перадаць у PLinq дадатковую інфармацыю аб прыродзе вашай крыніцы дадзеных пры дапамозе механізму Partitions. Падрабязней можна пачытаць тут и тут.

Статычны клас Parallel дае метады для паралельнага перабору калекцыі Foreach, выканання цыкла For і выканання некалькіх дэлегатаў у паралель Invoke. Выкананне бягучага патоку будзе спынена да заканчэння выканання разлікаў. Колькасць плыняў можна сканфігураваць перадаўшы ParallelOptions апошнім аргументам. З дапамогай опцый таксама можна пазначыць TaskScheduler і CancellationToken.

Высновы

Калі я пачынаў пісаць гэты артыкул па матэрыялах свайго дакладу і інфармацыі, што сабраў за час працы пасля яго, я не чакаў, што яе атрымаецца так шмат. Цяпер, калі тэкставы рэдактар ​​у якім я набіраю гэты артыкул дакорліва кажа мне аб тым, што пайшла 15я старонка, я падвяду прамежкавыя вынікі. Іншыя трукі, API, візуальныя інструменты і падводныя камяні будуць разгледжаны ў наступным артыкуле.

Высновы:

  • Трэба ведаць прылады працы са струменямі, асінхроннасцю і паралелізмам, каб выкарыстаць рэсурсы сучасных ПК.
  • У .NET шмат розных інструментаў для гэтых мэт
  • Не ўсе яны з'явіліся адразу, таму часта можна сустрэць legacy, зрэшты ёсць спосабы для пераўтварэння старых API без асаблівых намаганняў.
  • Праца з струменямі ў. NET прадстаўлена класамі Thread і ThreadPool
  • Метады Thread.Abort, Thread.Interrupt, функцыя Win32 API TerminateThread небяспечныя і не рэкамендуюцца да выкарыстання. Замест іх лепш выкарыстоўваць механізм CancellationToken'ов
  • Струмень - каштоўны рэсурс, іх колькасць абмежавана. Трэба пазбягаць сітуацый, калі плыні занятыя чаканнем падзей. Для гэтага зручна выкарыстоўваць клас TaskCompletionSource
  • Найбольш магутным і прасунутым інструментаў. NET для працы з паралелізмам і асінхроннасцю з'яўляюцца Task'і.
  • Аператары c# async/await рэалізуюць канцэпцыю неблакіруючага чакання
  • Кіраваць размеркаваннем Task'аў па струменях можна з дапамогай вытворных TaskScheduler'у класаў
  • Структура ValueTask можа быць карысная ў аптымізацыі hot-paths і memory-traffic
  • Вокны Tasks і Threads Visual Studio падаюць шмат карыснай для адладкі шматструменнага ці асінхроннага кода інфармацыі
  • PLinq круты інструмент, але ў яго можа не быць дастаткова інфармацыі аб вашым крыніцы дадзеных, зрэшты гэта можна выправіць з дапамогай механізму partitioning
  • Працяг будзе…

Крыніца: habr.com

Дадаць каментар